diff --git a/src/main/java/com/ryu/studyhelper/infrastructure/solvedac/SolvedAcClient.java b/src/main/java/com/ryu/studyhelper/infrastructure/solvedac/SolvedAcClient.java new file mode 100644 index 0000000..86fa845 --- /dev/null +++ b/src/main/java/com/ryu/studyhelper/infrastructure/solvedac/SolvedAcClient.java @@ -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 recommendUnsolvedProblems(List handles, int count, + Integer minLevel, Integer maxLevel, + List 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 handles, Integer minLevel, Integer maxLevel, List tagKeys) { + List 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 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); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/ryu/studyhelper/infrastructure/solvedac/client/SolvedAcRestClient.java b/src/main/java/com/ryu/studyhelper/infrastructure/solvedac/client/SolvedAcRestClient.java new file mode 100644 index 0000000..3053111 --- /dev/null +++ b/src/main/java/com/ryu/studyhelper/infrastructure/solvedac/client/SolvedAcRestClient.java @@ -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 get(String path, Map params, Class 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); + } + +} \ No newline at end of file diff --git a/src/main/java/com/ryu/studyhelper/solvedac/dto/ProblemInfo.java b/src/main/java/com/ryu/studyhelper/infrastructure/solvedac/dto/ProblemInfo.java similarity index 93% rename from src/main/java/com/ryu/studyhelper/solvedac/dto/ProblemInfo.java rename to src/main/java/com/ryu/studyhelper/infrastructure/solvedac/dto/ProblemInfo.java index a149915..a769696 100644 --- a/src/main/java/com/ryu/studyhelper/solvedac/dto/ProblemInfo.java +++ b/src/main/java/com/ryu/studyhelper/infrastructure/solvedac/dto/ProblemInfo.java @@ -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; diff --git a/src/main/java/com/ryu/studyhelper/solvedac/dto/ProblemSearchResponse.java b/src/main/java/com/ryu/studyhelper/infrastructure/solvedac/dto/ProblemSearchResponse.java similarity index 75% rename from src/main/java/com/ryu/studyhelper/solvedac/dto/ProblemSearchResponse.java rename to src/main/java/com/ryu/studyhelper/infrastructure/solvedac/dto/ProblemSearchResponse.java index 3f044d8..bcd30f7 100644 --- a/src/main/java/com/ryu/studyhelper/solvedac/dto/ProblemSearchResponse.java +++ b/src/main/java/com/ryu/studyhelper/infrastructure/solvedac/dto/ProblemSearchResponse.java @@ -1,4 +1,4 @@ -package com.ryu.studyhelper.solvedac.dto; +package com.ryu.studyhelper.infrastructure.solvedac.dto; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/ryu/studyhelper/solvedac/dto/SolvedAcTagInfo.java b/src/main/java/com/ryu/studyhelper/infrastructure/solvedac/dto/SolvedAcTagInfo.java similarity index 96% rename from src/main/java/com/ryu/studyhelper/solvedac/dto/SolvedAcTagInfo.java rename to src/main/java/com/ryu/studyhelper/infrastructure/solvedac/dto/SolvedAcTagInfo.java index 63f51ee..4da163a 100644 --- a/src/main/java/com/ryu/studyhelper/solvedac/dto/SolvedAcTagInfo.java +++ b/src/main/java/com/ryu/studyhelper/infrastructure/solvedac/dto/SolvedAcTagInfo.java @@ -1,4 +1,4 @@ -package com.ryu.studyhelper.solvedac.dto; +package com.ryu.studyhelper.infrastructure.solvedac.dto; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/ryu/studyhelper/infrastructure/solvedac/dto/SolvedAcUserBioResponse.java b/src/main/java/com/ryu/studyhelper/infrastructure/solvedac/dto/SolvedAcUserBioResponse.java new file mode 100644 index 0000000..28199ad --- /dev/null +++ b/src/main/java/com/ryu/studyhelper/infrastructure/solvedac/dto/SolvedAcUserBioResponse.java @@ -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 +) {} \ No newline at end of file diff --git a/src/main/java/com/ryu/studyhelper/solvedac/dto/SolvedAcUserResponse.java b/src/main/java/com/ryu/studyhelper/infrastructure/solvedac/dto/SolvedAcUserResponse.java similarity index 85% rename from src/main/java/com/ryu/studyhelper/solvedac/dto/SolvedAcUserResponse.java rename to src/main/java/com/ryu/studyhelper/infrastructure/solvedac/dto/SolvedAcUserResponse.java index 9424a52..a7c4059 100644 --- a/src/main/java/com/ryu/studyhelper/solvedac/dto/SolvedAcUserResponse.java +++ b/src/main/java/com/ryu/studyhelper/infrastructure/solvedac/dto/SolvedAcUserResponse.java @@ -1,4 +1,4 @@ -package com.ryu.studyhelper.solvedac.dto; +package com.ryu.studyhelper.infrastructure.solvedac.dto; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/ryu/studyhelper/solvedac/dto/SolvedAcVerificationResponse.java b/src/main/java/com/ryu/studyhelper/infrastructure/solvedac/dto/SolvedAcVerificationResponse.java similarity index 73% rename from src/main/java/com/ryu/studyhelper/solvedac/dto/SolvedAcVerificationResponse.java rename to src/main/java/com/ryu/studyhelper/infrastructure/solvedac/dto/SolvedAcVerificationResponse.java index 8f9915d..27c63f1 100644 --- a/src/main/java/com/ryu/studyhelper/solvedac/dto/SolvedAcVerificationResponse.java +++ b/src/main/java/com/ryu/studyhelper/infrastructure/solvedac/dto/SolvedAcVerificationResponse.java @@ -1,4 +1,4 @@ -package com.ryu.studyhelper.solvedac.dto; +package com.ryu.studyhelper.infrastructure.solvedac.dto; public record SolvedAcVerificationResponse( String handle, diff --git a/src/main/java/com/ryu/studyhelper/member/MemberService.java b/src/main/java/com/ryu/studyhelper/member/MemberService.java index 03a04a4..66da4d1 100644 --- a/src/main/java/com/ryu/studyhelper/member/MemberService.java +++ b/src/main/java/com/ryu/studyhelper/member/MemberService.java @@ -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; @@ -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; @@ -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; @@ -84,7 +84,7 @@ public List getVerifiedHandles() { */ public Member verifySolvedAcHandle(Long memberId, String handle) { // 1. solved.ac에 존재하는지 확인 (예외 발생 시 검증 실패) - solvedacService.getUserInfo(handle); + solvedAcClient.getUserInfo(handle); // 2. 회원 엔티티에 핸들 저장 (중복 허용, isVerified는 false 유지) Member member = getById(memberId); @@ -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); diff --git a/src/main/java/com/ryu/studyhelper/member/verification/BojVerificationFacade.java b/src/main/java/com/ryu/studyhelper/member/verification/BojVerificationFacade.java index 0a6ba33..c3876a7 100644 --- a/src/main/java/com/ryu/studyhelper/member/verification/BojVerificationFacade.java +++ b/src/main/java/com/ryu/studyhelper/member/verification/BojVerificationFacade.java @@ -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; @@ -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) { diff --git a/src/main/java/com/ryu/studyhelper/problem/ProblemController.java b/src/main/java/com/ryu/studyhelper/problem/ProblemController.java index 3a0a90f..0b541c0 100644 --- a/src/main/java/com/ryu/studyhelper/problem/ProblemController.java +++ b/src/main/java/com/ryu/studyhelper/problem/ProblemController.java @@ -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; diff --git a/src/main/java/com/ryu/studyhelper/problem/dto/TeamProblemRecommendResponse.java b/src/main/java/com/ryu/studyhelper/problem/dto/TeamProblemRecommendResponse.java index f8f1664..94bc359 100644 --- a/src/main/java/com/ryu/studyhelper/problem/dto/TeamProblemRecommendResponse.java +++ b/src/main/java/com/ryu/studyhelper/problem/dto/TeamProblemRecommendResponse.java @@ -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; diff --git a/src/main/java/com/ryu/studyhelper/problem/service/ProblemService.java b/src/main/java/com/ryu/studyhelper/problem/service/ProblemService.java index afac6a3..ae41d7d 100644 --- a/src/main/java/com/ryu/studyhelper/problem/service/ProblemService.java +++ b/src/main/java/com/ryu/studyhelper/problem/service/ProblemService.java @@ -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; @@ -13,35 +14,25 @@ @Service @Transactional public class ProblemService { - private final SolvedAcService solvedAcService; - /** - * 핸들 목록을 기반으로 문제 추천 - */ - public List recommend(List handles, int count) { - return solvedAcService.recommendUnsolvedProblems(handles, count); - } + private final SolvedAcClient solvedAcClient; /** - * 핸들 목록과 난이도 범위를 기반으로 문제 추천 - */ - public List recommend(List handles, int count, Integer minLevel, Integer maxLevel) { - return solvedAcService.recommendUnsolvedProblems(handles, count, minLevel, maxLevel); - } - - /** - * 핸들 목록, 난이도 범위, 태그 필터를 기반으로 문제 추천 + * 문제 추천 */ public List recommend(List handles, int count, Integer minLevel, Integer maxLevel, List tagKeys) { - return solvedAcService.recommendUnsolvedProblems(handles, count, minLevel, maxLevel, tagKeys); + return solvedAcClient.recommendUnsolvedProblems(handles, count, minLevel, maxLevel, tagKeys); } - - public List recommend(ProblemRecommendRequest request, int count) { - return recommend(request.handles(), count); + return recommend(request.handles(), count, null, null, null); } - public List recommend(String handle , int count) { - return recommend(List.of(handle), count); + + public List recommend(String handle, int count) { + return recommend(List.of(handle), count, null, null, null); + } + + public List recommend(List handles, int count) { + return recommend(handles, count, null, null, null); } } \ No newline at end of file diff --git a/src/main/java/com/ryu/studyhelper/problem/service/ProblemSyncService.java b/src/main/java/com/ryu/studyhelper/problem/service/ProblemSyncService.java index 8c8ac14..026f1d5 100644 --- a/src/main/java/com/ryu/studyhelper/problem/service/ProblemSyncService.java +++ b/src/main/java/com/ryu/studyhelper/problem/service/ProblemSyncService.java @@ -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; diff --git a/src/main/java/com/ryu/studyhelper/recommendation/RecommendationService.java b/src/main/java/com/ryu/studyhelper/recommendation/RecommendationService.java index ad44a5c..3241eeb 100644 --- a/src/main/java/com/ryu/studyhelper/recommendation/RecommendationService.java +++ b/src/main/java/com/ryu/studyhelper/recommendation/RecommendationService.java @@ -25,7 +25,7 @@ import com.ryu.studyhelper.recommendation.repository.MemberRecommendationRepository; import com.ryu.studyhelper.recommendation.repository.RecommendationProblemRepository; import com.ryu.studyhelper.recommendation.repository.RecommendationRepository; -import com.ryu.studyhelper.solvedac.dto.ProblemInfo; +import com.ryu.studyhelper.infrastructure.solvedac.dto.ProblemInfo; import com.ryu.studyhelper.team.repository.TeamMemberRepository; import com.ryu.studyhelper.team.repository.TeamRepository; import com.ryu.studyhelper.team.domain.Team; diff --git a/src/main/java/com/ryu/studyhelper/solvedac/SolvedAcService.java b/src/main/java/com/ryu/studyhelper/solvedac/SolvedAcService.java deleted file mode 100644 index afc933b..0000000 --- a/src/main/java/com/ryu/studyhelper/solvedac/SolvedAcService.java +++ /dev/null @@ -1,174 +0,0 @@ -package com.ryu.studyhelper.solvedac; - -import com.ryu.studyhelper.common.enums.CustomResponseStatus; -import com.ryu.studyhelper.common.exception.CustomException; -import com.ryu.studyhelper.solvedac.api.SolvedAcClient; -import com.ryu.studyhelper.solvedac.dto.ProblemInfo; -import com.ryu.studyhelper.solvedac.dto.ProblemSearchResponse; -import com.ryu.studyhelper.solvedac.dto.SolvedAcUserResponse; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.web.client.HttpClientErrorException; - -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -@Service -@Slf4j -public class SolvedAcService { - private final SolvedAcClient solvedAcClient; - - public SolvedAcService(SolvedAcClient solvedAcClient) { - this.solvedAcClient = solvedAcClient; - } - - public SolvedAcUserResponse getUserInfo(String handle) { - try { - return solvedAcClient.getUserInfo(handle); - } catch (HttpClientErrorException.NotFound e) { - log.warn("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); - } - } - - /** - * 주어진 사용자 핸들에 대해, 1000명이상이 푼 골드문제중 풀지 않은 문제를 추천합니다. - * @param handles 추천할 사용자 핸들 목록 - * @param count 추천할 문제 개수 - * @return 추천된 문제 목록 - */ - public List recommendUnsolvedProblems(List handles, int count) { - return recommendUnsolvedProblems(handles, count, null, null); - } - - /** - * 주어진 사용자 핸들과 난이도 범위에 대해 풀지 않은 문제를 추천합니다. - * @param handles 추천할 사용자 핸들 목록 - * @param count 추천할 문제 개수 - * @param minLevel 최소 난이도 (1~30, null이면 기본값 적용) - * @param maxLevel 최대 난이도 (1~30, null이면 기본값 적용) - * @return 추천된 문제 목록 - */ - public List recommendUnsolvedProblems(List handles, int count, Integer minLevel, Integer maxLevel) { - return recommendUnsolvedProblems(handles, count, minLevel, maxLevel, null); - } - - /** - * 주어진 사용자 핸들, 난이도 범위, 태그 필터에 대해 풀지 않은 문제를 추천합니다. - * @param handles 추천할 사용자 핸들 목록 - * @param count 추천할 문제 개수 - * @param minLevel 최소 난이도 (1~30, null이면 기본값 적용) - * @param maxLevel 최대 난이도 (1~30, null이면 기본값 적용) - * @param tagKeys 포함할 태그 키 목록 (null 또는 빈 목록이면 필터 없음) - * @return 추천된 문제 목록 - */ - public List recommendUnsolvedProblems(List handles, int count, Integer minLevel, Integer maxLevel, List tagKeys) { - // 난이도 범위 설정 (기본값: 골드 5 ~ 골드 1) - String levelRange = buildLevelRange(minLevel, maxLevel); - - // 태그 필터 생성 (예: "(tag:dp|tag:greedy)") - String tagFilter = buildTagFilter(tagKeys); - - // 쿼리 조합 - Stream baseQuery = Stream.of(levelRange, "s#1000..", "lang:ko"); - Stream userExclusions = handles.stream().map(h -> "!s@" + h); - - String query; - if (tagFilter != null) { - query = Stream.concat( - Stream.concat(baseQuery, Stream.of(tagFilter)), - userExclusions - ).collect(Collectors.joining("+")); - } else { - query = Stream.concat(baseQuery, userExclusions) - .collect(Collectors.joining("+")); - } - - log.debug("solved.ac 추천 쿼리: {}", query); - ProblemSearchResponse response = solvedAcClient.searchProblems(query, count); - return response.items().stream() - .map(ProblemInfo::withUrl) - .toList(); - } - - /** - * 난이도 범위 쿼리 문자열 생성 - */ - private String buildLevelRange(Integer minLevel, Integer maxLevel) { - // 기본값: 골드 5(11) ~ 골드 1(15) - int min = (minLevel != null && minLevel >= 1 && minLevel <= 30) ? minLevel : 11; - int max = (maxLevel != null && maxLevel >= 1 && maxLevel <= 30) ? maxLevel : 15; - - // 범위 검증 - if (min > max) { - min = 11; - max = 15; - } - - return String.format("*%d..%d", min, max); - } - - /** - * 태그 필터 쿼리 문자열 생성 - * 예: ["dp", "greedy"] → "(tag:dp|tag:greedy)" - * @param tagKeys 태그 키 목록 - * @return 태그 필터 문자열 (빈 목록이면 null) - */ - private String buildTagFilter(List tagKeys) { - if (tagKeys == null || tagKeys.isEmpty()) { - return null; - } - - String tagConditions = tagKeys.stream() - .map(key -> "tag:" + key) - .collect(Collectors.joining("|")); - - return "(" + tagConditions + ")"; - } - - public List fetchSolvedProblems(String handle) { - ProblemSearchResponse response = solvedAcClient.getSolvedProblemsRaw(handle); - return response.items().stream() - .map(ProblemInfo::withUrl) - .toList(); - } - - /** - * 특정 사용자가 특정 문제를 풀었는지 확인 - * @param handle 사용자 핸들 - * @param problemId 문제 번호 - * @return 해결 여부 - */ - public boolean hasUserSolvedProblem(String handle, Long problemId) { - try { - return solvedAcClient.hasUserSolvedProblem(handle, problemId); - } catch (Exception e) { - log.error("Failed to check if user {} solved problem {}", handle, problemId, e); - throw new CustomException(CustomResponseStatus.SOLVED_AC_API_ERROR); - } - } - - - - - - - - /** - * solved ac api 응답 시간 측정용 - * */ - public List recommendTest(List handles, int totalCount) { - List results = new ArrayList<>(); - - int retry = 0; - for(int i=0;i<10;i++){ - results.addAll(recommendUnsolvedProblems(handles, totalCount)); - } - return results; - } -} \ No newline at end of file diff --git a/src/main/java/com/ryu/studyhelper/solvedac/TestController.java b/src/main/java/com/ryu/studyhelper/solvedac/TestController.java deleted file mode 100644 index 5939beb..0000000 --- a/src/main/java/com/ryu/studyhelper/solvedac/TestController.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.ryu.studyhelper.solvedac; - -import com.ryu.studyhelper.solvedac.dto.ProblemInfo; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -@RequiredArgsConstructor -@RestController -public class TestController { - private final SolvedAcService solvedAcService; - - @GetMapping("/test/{handle}") - public void test(@PathVariable String handle) { - solvedAcService.getUserInfo(handle); - } - - @PostMapping("/recommend") - public List recommend(@RequestBody List handles) { - return solvedAcService.recommendUnsolvedProblems(handles, 3); // 기본 추천 개수 3 - } -} diff --git a/src/main/java/com/ryu/studyhelper/solvedac/api/SolvedAcClient.java b/src/main/java/com/ryu/studyhelper/solvedac/api/SolvedAcClient.java deleted file mode 100644 index 25e72cd..0000000 --- a/src/main/java/com/ryu/studyhelper/solvedac/api/SolvedAcClient.java +++ /dev/null @@ -1,111 +0,0 @@ -package com.ryu.studyhelper.solvedac.api; - -import com.ryu.studyhelper.solvedac.dto.BojVerificationDto; -import com.ryu.studyhelper.solvedac.dto.ProblemInfo; -import com.ryu.studyhelper.solvedac.dto.ProblemSearchResponse; -import com.ryu.studyhelper.solvedac.dto.SolvedAcUserResponse; -import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClient; -import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; -import org.apache.hc.client5.http.impl.classic.HttpClients; -import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; -import java.time.Duration; -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; -import java.util.Map; - -@Component -public class SolvedAcClient { - private final RestClient rest; - - public SolvedAcClient() { -// PoolingHttpClientConnectionManager pool = PoolingHttpClientConnectionManagerBuilder.create() -// .setMaxConnTotal(200) -// .setMaxConnPerRoute(50) -// .build(); -// CloseableHttpClient http = HttpClients.custom() -// .setConnectionManager(pool) -// .evictExpiredConnections() -// .build(); -// HttpComponentsClientHttpRequestFactory rf = new HttpComponentsClientHttpRequestFactory(http); -// rf.setConnectTimeout(Duration.ofSeconds(5)); -// rf.setReadTimeout(Duration.ofSeconds(5)); - - this.rest = RestClient.builder() -// .requestFactory(rf) - .baseUrl("https://solved.ac/api/v3") - .defaultHeader("User-Agent", "studyhelper/1.0") - .build(); - } - - - - private T get(String path, Map params, Class 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); - } - - - public ProblemSearchResponse searchProblems(String query, int count) { - ProblemSearchResponse resp = get("/search/problem", Map.of( - "query", query, - "sort", "random", - "direction", "asc" - ), ProblemSearchResponse.class); - - //count개 만 리턴 - var limited = resp.items().stream() - .map(ProblemInfo::withUrl) - .limit(count) - .toList(); - - return new ProblemSearchResponse(limited); - } - - - - public ProblemSearchResponse getSolvedProblemsRaw(String handle) { - return get("/search/problem", Map.of( - "query", "s@" + handle, - "sort", "id", - "direction", "asc" - ), ProblemSearchResponse.class); - } - - /** - * 특정 사용자가 특정 문제를 풀었는지 확인 - * @param handle 사용자 핸들 - * @param problemId 문제 번호 - * @return 해결 여부 - */ - public boolean hasUserSolvedProblem(String handle, Long problemId) { - // solved.ac 쿼리에서 조건은 공백으로 구분 (URL에서 +는 공백으로 해석됨) - ProblemSearchResponse resp = get("/search/problem", Map.of( - "query", "id:" + problemId + " s@" + handle - ), ProblemSearchResponse.class); - return resp.items() != null && !resp.items().isEmpty(); - } - - /** - * 백준 핸들 인증용 사용자 정보 조회 (bio 포함) - * @param handle 백준 핸들 - * @return 핸들과 bio 정보 - */ - public BojVerificationDto getUserBio(String handle) { - return get("/user/show", Map.of("handle", handle), BojVerificationDto.class); - } - -} \ No newline at end of file diff --git a/src/main/java/com/ryu/studyhelper/solvedac/dto/BojVerificationDto.java b/src/main/java/com/ryu/studyhelper/solvedac/dto/BojVerificationDto.java deleted file mode 100644 index 656dbd0..0000000 --- a/src/main/java/com/ryu/studyhelper/solvedac/dto/BojVerificationDto.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.ryu.studyhelper.solvedac.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; - -/** - * 백준 핸들 인증용 DTO - * solved.ac API에서 사용자의 bio(상태 메시지)만 가져옴 - */ -public record BojVerificationDto( - @JsonProperty("handle") String handle, - @JsonProperty("bio") String bio -) {} \ No newline at end of file diff --git a/src/test/java/com/ryu/studyhelper/member/MemberServiceTest.java b/src/test/java/com/ryu/studyhelper/member/MemberServiceTest.java index 9ce939a..cc72831 100644 --- a/src/test/java/com/ryu/studyhelper/member/MemberServiceTest.java +++ b/src/test/java/com/ryu/studyhelper/member/MemberServiceTest.java @@ -8,7 +8,7 @@ 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.infrastructure.solvedac.SolvedAcClient; import com.ryu.studyhelper.team.repository.TeamMemberRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -54,7 +54,7 @@ class MemberServiceTest { private TeamMemberRepository teamMemberRepository; @Mock - private SolvedAcService solvedAcService; + private SolvedAcClient solvedAcClient; private Member member; private Problem problem; @@ -89,7 +89,7 @@ void success() { given(memberRepository.findById(1L)).willReturn(Optional.of(member)); given(problemRepository.findById(1000L)).willReturn(Optional.of(problem)); given(memberSolvedProblemRepository.existsByMemberIdAndProblemId(1L, 1000L)).willReturn(false); - given(solvedAcService.hasUserSolvedProblem("testuser", 1000L)).willReturn(true); + given(solvedAcClient.hasUserSolvedProblem("testuser", 1000L)).willReturn(true); given(memberSolvedProblemRepository.save(any(MemberSolvedProblem.class))) .willAnswer(invocation -> invocation.getArgument(0)); @@ -166,7 +166,7 @@ void fail_notSolvedYet() { given(memberRepository.findById(1L)).willReturn(Optional.of(member)); given(problemRepository.findById(1000L)).willReturn(Optional.of(problem)); given(memberSolvedProblemRepository.existsByMemberIdAndProblemId(1L, 1000L)).willReturn(false); - given(solvedAcService.hasUserSolvedProblem("testuser", 1000L)).willReturn(false); + given(solvedAcClient.hasUserSolvedProblem("testuser", 1000L)).willReturn(false); // when & then assertThatThrownBy(() -> memberService.verifyProblemSolved(1L, 1000L)) @@ -269,7 +269,7 @@ void setUp() { problemRepository, memberSolvedProblemRepository, teamMemberRepository, - solvedAcService, + solvedAcClient, null, // jwtUtil null, // mailSender null, // emailChangeMailBuilder @@ -375,7 +375,7 @@ class GetDailySolvedRangeValidationTest { void setUp() { service = new MemberService( memberRepository, problemRepository, memberSolvedProblemRepository, - teamMemberRepository, solvedAcService, null, null, null, Clock.systemDefaultZone() + teamMemberRepository, solvedAcClient, null, null, null, Clock.systemDefaultZone() ); }