Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package com.ryu.studyhelper.infrastructure.solvedac;

import com.ryu.studyhelper.common.enums.CustomResponseStatus;
import com.ryu.studyhelper.common.exception.CustomException;
import com.ryu.studyhelper.infrastructure.solvedac.client.SolvedAcRestClient;
import com.ryu.studyhelper.infrastructure.solvedac.dto.SolvedAcUserBioResponse;
import com.ryu.studyhelper.infrastructure.solvedac.dto.ProblemInfo;
import com.ryu.studyhelper.infrastructure.solvedac.dto.ProblemSearchResponse;
import com.ryu.studyhelper.infrastructure.solvedac.dto.SolvedAcUserResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpClientErrorException;

import java.util.ArrayList;
import java.util.List;
import java.util.StringJoiner;

@Component
@Slf4j
@RequiredArgsConstructor
public class SolvedAcClient {
private static final int MIN_LEVEL = 1;
private static final int MAX_LEVEL = 30;
private static final int MIN_SOLVED_COUNT = 1000;

private final SolvedAcRestClient solvedAcRestClient;

public SolvedAcUserResponse getUserInfo(String handle) {
try {
return solvedAcRestClient.getUserInfo(handle);
} catch (HttpClientErrorException.NotFound e) {
log.info("solved.ac user not found: {}", handle);
throw new CustomException(CustomResponseStatus.SOLVED_AC_USER_NOT_FOUND);
} catch (Exception e) {
log.error("Failed to fetch user info from solved.ac: {}", handle, e);
throw new CustomException(CustomResponseStatus.SOLVED_AC_API_ERROR);
}
}

/**
* 풀지 않은 문제를 추천합니다.
* @param handles 추천할 사용자 핸들 목록
* @param count 추천할 문제 개수
* @param minLevel 최소 난이도 (1~30, null이면 1)
* @param maxLevel 최대 난이도 (1~30, null이면 30)
* @param tagKeys 포함할 태그 키 목록 (null 또는 빈 목록이면 필터 없음)
*/
public List<ProblemInfo> recommendUnsolvedProblems(List<String> handles, int count,
Integer minLevel, Integer maxLevel,
List<String> tagKeys) {
try {
String query = buildRecommendQuery(handles, minLevel, maxLevel, tagKeys);
log.debug("solved.ac 추천 쿼리: {}", query);

ProblemSearchResponse response = solvedAcRestClient.searchProblems(query, "random", "asc");
if (response.items() == null) {
return List.of();
}

return response.items().stream()
.map(ProblemInfo::withUrl)
.limit(count)
.toList();
} catch (Exception e) {
log.error("Failed to recommend problems for handles: {}", handles, e);
throw new CustomException(CustomResponseStatus.SOLVED_AC_API_ERROR);
}
}

private String buildRecommendQuery(List<String> handles, Integer minLevel, Integer maxLevel, List<String> tagKeys) {
List<String> conditions = new ArrayList<>();

// 난이도 범위
int min = (minLevel != null && minLevel >= MIN_LEVEL && minLevel <= MAX_LEVEL) ? minLevel : MIN_LEVEL;
int max = (maxLevel != null && maxLevel >= MIN_LEVEL && maxLevel <= MAX_LEVEL) ? maxLevel : MAX_LEVEL;
conditions.add(String.format("*%d..%d", Math.min(min, max), Math.max(min, max)));

// 기본 조건: 1000명 이상 풀이, 한국어
conditions.add("s#" + MIN_SOLVED_COUNT + "..");
conditions.add("lang:ko");

// 태그 필터 (선택)
if (tagKeys != null && !tagKeys.isEmpty()) {
conditions.add(buildTagFilter(tagKeys));
}

// 사용자 제외 조건
handles.forEach(h -> conditions.add("!s@" + h));

return String.join("+", conditions);
}

private String buildTagFilter(List<String> tagKeys) {
StringJoiner joiner = new StringJoiner("|", "(", ")");
tagKeys.forEach(key -> joiner.add("tag:" + key));
return joiner.toString();
}

/**
* 백준 핸들 인증용 사용자 bio 조회
*/
public SolvedAcUserBioResponse getUserBio(String handle) {
try {
return solvedAcRestClient.getUserBio(handle);
} catch (HttpClientErrorException.NotFound e) {
log.info("solved.ac user not found: {}", handle);
throw new CustomException(CustomResponseStatus.SOLVED_AC_USER_NOT_FOUND);
} catch (Exception e) {
log.error("Failed to fetch user bio from solved.ac: {}", handle, e);
throw new CustomException(CustomResponseStatus.SOLVED_AC_API_ERROR);
}
}

/**
* 특정 사용자가 특정 문제를 풀었는지 확인
*/
public boolean hasUserSolvedProblem(String handle, Long problemId) {
try {
String query = "id:" + problemId + "+s@" + handle;
ProblemSearchResponse response = solvedAcRestClient.searchProblems(query, "id", "asc");
return response.items() != null && !response.items().isEmpty();
} catch (Exception e) {
log.error("Failed to check if user {} solved problem {}", handle, problemId, e);
throw new CustomException(CustomResponseStatus.SOLVED_AC_API_ERROR);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.ryu.studyhelper.infrastructure.solvedac.client;

import com.ryu.studyhelper.infrastructure.solvedac.dto.SolvedAcUserBioResponse;
import com.ryu.studyhelper.infrastructure.solvedac.dto.ProblemSearchResponse;
import com.ryu.studyhelper.infrastructure.solvedac.dto.SolvedAcUserResponse;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

import java.util.Map;

@Component
public class SolvedAcRestClient {
private final RestClient rest;

public SolvedAcRestClient() {
this.rest = RestClient.builder()
.baseUrl("https://solved.ac/api/v3")
.defaultHeader("User-Agent", "studyhelper/1.0")
.build();
}



private <T> T get(String path, Map<String, String> params, Class<T> responseType) {
return rest.get()
.uri(b -> {
var ub = b.path(path);
if (params != null) params.forEach(ub::queryParam);
return ub.build(); // 인코딩/결합은 RestClient에 맡김
})
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.body(responseType);
}


public SolvedAcUserResponse getUserInfo(String handle) {
return get("/user/show", Map.of("handle", handle), SolvedAcUserResponse.class);
}


/**
* 문제 검색 API 호출 (순수 HTTP 호출만 담당)
* @param query 검색 쿼리
* @param sort 정렬 기준 (id, level, title, solved, random 등)
* @param direction 정렬 방향 (asc, desc)
* @return API 응답 원본
*/
public ProblemSearchResponse searchProblems(String query, String sort, String direction) {
return get("/search/problem", Map.of(
"query", query,
"sort", sort,
"direction", direction
), ProblemSearchResponse.class);
}

/**
* 백준 핸들 인증용 사용자 정보 조회 (bio 포함)
* @param handle 백준 핸들
* @return 핸들과 bio 정보
*/
public SolvedAcUserBioResponse getUserBio(String handle) {
return get("/user/show", Map.of("handle", handle), SolvedAcUserBioResponse.class);
}

}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.ryu.studyhelper.solvedac.dto;
package com.ryu.studyhelper.infrastructure.solvedac.dto;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.ryu.studyhelper.common.util.ProblemUrlUtils;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.ryu.studyhelper.solvedac.dto;
package com.ryu.studyhelper.infrastructure.solvedac.dto;

import com.fasterxml.jackson.annotation.JsonProperty;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.ryu.studyhelper.solvedac.dto;
package com.ryu.studyhelper.infrastructure.solvedac.dto;

import com.fasterxml.jackson.annotation.JsonProperty;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.ryu.studyhelper.infrastructure.solvedac.dto;

import com.fasterxml.jackson.annotation.JsonProperty;

/**
* solved.ac 사용자 bio 응답 DTO
*/
public record SolvedAcUserBioResponse(
@JsonProperty("handle") String handle,
@JsonProperty("bio") String bio
) {}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.ryu.studyhelper.solvedac.dto;
package com.ryu.studyhelper.infrastructure.solvedac.dto;

import com.fasterxml.jackson.annotation.JsonProperty;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.ryu.studyhelper.solvedac.dto;
package com.ryu.studyhelper.infrastructure.solvedac.dto;

public record SolvedAcVerificationResponse(
String handle,
Expand Down
8 changes: 4 additions & 4 deletions src/main/java/com/ryu/studyhelper/member/MemberService.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.ryu.studyhelper.common.enums.CustomResponseStatus;
import com.ryu.studyhelper.common.exception.CustomException;
import com.ryu.studyhelper.config.security.jwt.JwtUtil;
import com.ryu.studyhelper.infrastructure.solvedac.SolvedAcClient;
import com.ryu.studyhelper.infrastructure.mail.sender.MailSender;
import com.ryu.studyhelper.member.mail.EmailChangeMailBuilder;
import com.ryu.studyhelper.member.domain.Member;
Expand All @@ -14,7 +15,6 @@
import com.ryu.studyhelper.member.repository.MemberSolvedProblemRepository;
import com.ryu.studyhelper.problem.repository.ProblemRepository;
import com.ryu.studyhelper.problem.domain.Problem;
import com.ryu.studyhelper.solvedac.SolvedAcService;
import com.ryu.studyhelper.team.repository.TeamMemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -41,7 +41,7 @@ public class MemberService {
private final ProblemRepository problemRepository;
private final MemberSolvedProblemRepository memberSolvedProblemRepository;
private final TeamMemberRepository teamMemberRepository;
private final SolvedAcService solvedacService;
private final SolvedAcClient solvedAcClient;
private final JwtUtil jwtUtil;
private final MailSender mailSender;
private final EmailChangeMailBuilder emailChangeMailBuilder;
Expand Down Expand Up @@ -84,7 +84,7 @@ public List<String> getVerifiedHandles() {
*/
public Member verifySolvedAcHandle(Long memberId, String handle) {
// 1. solved.ac에 존재하는지 확인 (예외 발생 시 검증 실패)
solvedacService.getUserInfo(handle);
solvedAcClient.getUserInfo(handle);

// 2. 회원 엔티티에 핸들 저장 (중복 허용, isVerified는 false 유지)
Member member = getById(memberId);
Expand Down Expand Up @@ -187,7 +187,7 @@ public void verifyProblemSolved(Long memberId, Long problemId) {
}

// 5. solved.ac API로 실제 해결 여부 검증
boolean isSolved = solvedacService.hasUserSolvedProblem(member.getHandle(), problemId);
boolean isSolved = solvedAcClient.hasUserSolvedProblem(member.getHandle(), problemId);

if (!isSolved) {
throw new CustomException(CustomResponseStatus.PROBLEM_NOT_SOLVED_YET);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

import com.ryu.studyhelper.common.enums.CustomResponseStatus;
import com.ryu.studyhelper.common.exception.CustomException;
import com.ryu.studyhelper.infrastructure.solvedac.SolvedAcClient;
import com.ryu.studyhelper.member.repository.MemberRepository;
import com.ryu.studyhelper.member.verification.dto.GenerateHashResponse;
import com.ryu.studyhelper.member.verification.dto.VerifyBojResponse;
import com.ryu.studyhelper.member.domain.Member;
import com.ryu.studyhelper.solvedac.api.SolvedAcClient;
import com.ryu.studyhelper.solvedac.dto.BojVerificationDto;
import com.ryu.studyhelper.infrastructure.solvedac.dto.SolvedAcUserBioResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
Expand Down Expand Up @@ -70,7 +70,7 @@ public VerifyBojResponse verifyBojHandle(Long memberId, String handle) {
.orElseThrow(() -> new CustomException(CustomResponseStatus.VERIFICATION_HASH_NOT_FOUND));

// 4. solved.ac API에서 사용자 bio 조회
BojVerificationDto userInfo;
SolvedAcUserBioResponse userInfo;
try {
userInfo = solvedAcClient.getUserBio(handle);
} catch (Exception e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import com.ryu.studyhelper.member.domain.Member;
import com.ryu.studyhelper.problem.dto.ProblemRecommendRequest;
import com.ryu.studyhelper.problem.service.ProblemService;
import com.ryu.studyhelper.solvedac.dto.ProblemInfo;
import com.ryu.studyhelper.infrastructure.solvedac.dto.ProblemInfo;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import jakarta.validation.Valid;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.ryu.studyhelper.problem.dto;

import com.ryu.studyhelper.solvedac.dto.ProblemInfo;
import com.ryu.studyhelper.infrastructure.solvedac.dto.ProblemInfo;

import java.util.List;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package com.ryu.studyhelper.problem.service;


import com.ryu.studyhelper.infrastructure.solvedac.SolvedAcClient;
import com.ryu.studyhelper.infrastructure.solvedac.dto.ProblemInfo;
import com.ryu.studyhelper.problem.dto.ProblemRecommendRequest;
import com.ryu.studyhelper.solvedac.SolvedAcService;
import com.ryu.studyhelper.solvedac.dto.ProblemInfo;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -13,35 +14,25 @@
@Service
@Transactional
public class ProblemService {
private final SolvedAcService solvedAcService;

/**
* 핸들 목록을 기반으로 문제 추천
*/
public List<ProblemInfo> recommend(List<String> handles, int count) {
return solvedAcService.recommendUnsolvedProblems(handles, count);
}
private final SolvedAcClient solvedAcClient;

/**
* 핸들 목록과 난이도 범위를 기반으로 문제 추천
*/
public List<ProblemInfo> recommend(List<String> handles, int count, Integer minLevel, Integer maxLevel) {
return solvedAcService.recommendUnsolvedProblems(handles, count, minLevel, maxLevel);
}

/**
* 핸들 목록, 난이도 범위, 태그 필터를 기반으로 문제 추천
* 문제 추천
*/
public List<ProblemInfo> recommend(List<String> handles, int count, Integer minLevel, Integer maxLevel, List<String> tagKeys) {
return solvedAcService.recommendUnsolvedProblems(handles, count, minLevel, maxLevel, tagKeys);
return solvedAcClient.recommendUnsolvedProblems(handles, count, minLevel, maxLevel, tagKeys);
}



public List<ProblemInfo> recommend(ProblemRecommendRequest request, int count) {
return recommend(request.handles(), count);
return recommend(request.handles(), count, null, null, null);
}
public List<ProblemInfo> recommend(String handle , int count) {
return recommend(List.of(handle), count);

public List<ProblemInfo> recommend(String handle, int count) {
return recommend(List.of(handle), count, null, null, null);
}

public List<ProblemInfo> recommend(List<String> handles, int count) {
return recommend(handles, count, null, null, null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
import com.ryu.studyhelper.problem.domain.Tag;
import com.ryu.studyhelper.problem.repository.ProblemTagRepository;
import com.ryu.studyhelper.problem.repository.TagRepository;
import com.ryu.studyhelper.solvedac.dto.ProblemInfo;
import com.ryu.studyhelper.solvedac.dto.SolvedAcTagInfo;
import com.ryu.studyhelper.infrastructure.solvedac.dto.ProblemInfo;
import com.ryu.studyhelper.infrastructure.solvedac.dto.SolvedAcTagInfo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
Expand Down
Loading