diff --git a/src/main/java/com/ryu/studyhelper/config/security/SecurityConfig.java b/src/main/java/com/ryu/studyhelper/config/security/SecurityConfig.java index 992b23f..ea32e3a 100644 --- a/src/main/java/com/ryu/studyhelper/config/security/SecurityConfig.java +++ b/src/main/java/com/ryu/studyhelper/config/security/SecurityConfig.java @@ -53,6 +53,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/api/auth/refresh").permitAll() .requestMatchers("/api/member/verify-email").permitAll() + // 테스트 API (local 환경 전용) +// .requestMatchers("/api/test/**").permitAll() + // 헬스체크 (인증 불필요) .requestMatchers("/actuator/health", "/actuator/health/**").permitAll() @@ -62,7 +65,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/api/teams/{teamId:\\d+}/activity").permitAll() // 랭킹 API (비로그인 허용) - .requestMatchers("/api/ranking/**").permitAll() + .requestMatchers("/api/solve/ranking/**").permitAll() + .requestMatchers("/api/ranking/**").permitAll() // TODO: 프론트엔드 마이그레이션 후 제거 // Swagger 테스트용 - 모든 API 개방 // .requestMatchers("/api/**").permitAll() diff --git a/src/main/java/com/ryu/studyhelper/member/MemberController.java b/src/main/java/com/ryu/studyhelper/member/MemberController.java index 5e7d763..66b8db7 100644 --- a/src/main/java/com/ryu/studyhelper/member/MemberController.java +++ b/src/main/java/com/ryu/studyhelper/member/MemberController.java @@ -8,12 +8,13 @@ import com.ryu.studyhelper.member.domain.Member; import com.ryu.studyhelper.member.dto.request.*; import com.ryu.studyhelper.member.dto.response.CheckEmailResponse; -import com.ryu.studyhelper.member.dto.response.DailySolvedResponse; import com.ryu.studyhelper.member.dto.response.EmailChangeResponse; import com.ryu.studyhelper.member.dto.response.HandleVerificationResponse; import com.ryu.studyhelper.member.dto.response.MemberPublicResponse; import com.ryu.studyhelper.member.dto.response.MemberSearchResponse; import com.ryu.studyhelper.member.dto.response.MyProfileResponse; +import com.ryu.studyhelper.solve.dto.response.DailySolvedResponse; +import com.ryu.studyhelper.solve.service.SolveService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -32,6 +33,7 @@ public class MemberController { private final MemberService memberService; + private final SolveService solveService; // TODO: 프론트엔드 마이그레이션 후 제거 @Operation( summary = "내 프로필 조회", @@ -133,21 +135,6 @@ public ResponseEntity> verifyEmail( return ResponseEntity.ok(ApiResponse.createSuccess(new EmailChangeResponse(newEmail), CustomResponseStatus.SUCCESS)); } - @Operation( - summary = "문제 해결 인증", - description = "BOJ 문제 해결을 solved.ac API로 검증하고 인증합니다. 성공 시 MemberSolvedProblem 레코드가 생성됩니다." - ) - @RateLimit(type = RateLimitType.SOLVED_AC) - @PostMapping("/me/problems/{problemId}/verify-solved") - public ResponseEntity> verifyProblemSolved( - @Parameter(description = "BOJ 문제 번호", example = "1000") - @PathVariable Long problemId, - @AuthenticationPrincipal PrincipalDetails principalDetails - ) { - memberService.verifyProblemSolved(principalDetails.getMemberId(), problemId); - return ResponseEntity.ok(ApiResponse.createSuccess(null, CustomResponseStatus.SUCCESS)); - } - @Operation( summary = "회원 탈퇴", description = "회원 탈퇴를 진행합니다. 모든 팀에서 탈퇴한 상태여야 합니다. 민감정보(이메일, 핸들, providerId)는 마스킹 처리됩니다." @@ -160,17 +147,28 @@ public ResponseEntity> withdraw( return ResponseEntity.ok(ApiResponse.createSuccess(null, CustomResponseStatus.SUCCESS)); } - @Operation( - summary = "일별 문제 풀이 현황 조회", - description = "최근 N일간 일별 문제 풀이 현황을 조회합니다. 날짜 기준은 오전 6시입니다." - ) + // ========== 하위 호환용 (프론트엔드 마이그레이션 후 제거) ========== + + @Deprecated + @Operation(summary = "[Deprecated] 문제 해결 인증", description = "POST /api/solve/problems/{problemId}/verify 로 이전됨") + @RateLimit(type = RateLimitType.SOLVED_AC) + @PostMapping("/me/problems/{problemId}/verify-solved") + public ResponseEntity> verifyProblemSolved( + @PathVariable Long problemId, + @AuthenticationPrincipal PrincipalDetails principalDetails + ) { + solveService.verifyProblemSolved(principalDetails.getMemberId(), problemId); + return ResponseEntity.ok(ApiResponse.createSuccess(null, CustomResponseStatus.SUCCESS)); + } + + @Deprecated + @Operation(summary = "[Deprecated] 일별 문제 풀이 현황 조회", description = "GET /api/solve/daily 로 이전됨") @GetMapping("/me/daily-solved") public ResponseEntity> getDailySolved( - @Parameter(description = "조회할 일수 (기본 7일)", example = "7") @RequestParam(defaultValue = "7") int days, @AuthenticationPrincipal PrincipalDetails principalDetails ) { - DailySolvedResponse response = memberService.getDailySolved(principalDetails.getMemberId(), days); + DailySolvedResponse response = solveService.getDailySolved(principalDetails.getMemberId(), days); return ResponseEntity.ok(ApiResponse.createSuccess(response, CustomResponseStatus.SUCCESS)); } } diff --git a/src/main/java/com/ryu/studyhelper/member/MemberService.java b/src/main/java/com/ryu/studyhelper/member/MemberService.java index 66da4d1..24627a8 100644 --- a/src/main/java/com/ryu/studyhelper/member/MemberService.java +++ b/src/main/java/com/ryu/studyhelper/member/MemberService.java @@ -7,14 +7,10 @@ import com.ryu.studyhelper.infrastructure.mail.sender.MailSender; import com.ryu.studyhelper.member.mail.EmailChangeMailBuilder; import com.ryu.studyhelper.member.domain.Member; -import com.ryu.studyhelper.member.domain.MemberSolvedProblem; -import com.ryu.studyhelper.member.dto.response.DailySolvedResponse; import com.ryu.studyhelper.member.dto.response.MemberSearchResponse; import com.ryu.studyhelper.member.dto.response.MyProfileResponse; import com.ryu.studyhelper.member.repository.MemberRepository; -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.solve.service.SolveService; import com.ryu.studyhelper.team.repository.TeamMemberRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -23,13 +19,8 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Clock; -import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.LocalTime; -import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; @Service @RequiredArgsConstructor @@ -38,10 +29,9 @@ public class MemberService { private final MemberRepository memberRepository; - private final ProblemRepository problemRepository; - private final MemberSolvedProblemRepository memberSolvedProblemRepository; private final TeamMemberRepository teamMemberRepository; private final SolvedAcClient solvedAcClient; + private final SolveService solveService; private final JwtUtil jwtUtil; private final MailSender mailSender; private final EmailChangeMailBuilder emailChangeMailBuilder; @@ -59,7 +49,7 @@ public Member getById(Long id) { @Transactional(readOnly = true) public MyProfileResponse getMyProfile(Long memberId) { Member member = getById(memberId); - long solvedCount = memberSolvedProblemRepository.countByMemberId(memberId); + long solvedCount = solveService.countByMemberId(memberId); return MyProfileResponse.from(member, solvedCount); } @@ -163,41 +153,6 @@ public String verifyAndChangeEmail(String token) { return newEmail; } - /** - * 문제 해결 인증 - * @param memberId 회원 ID - * @param problemId BOJ 문제 번호 - */ - public void verifyProblemSolved(Long memberId, Long problemId) { - // 1. Member 조회 - Member member = getById(memberId); - - // 2. 핸들 존재 여부 확인 (조기 검증) - if (member.getHandle() == null || member.getHandle().isEmpty()) { - throw new CustomException(CustomResponseStatus.SOLVED_AC_USER_NOT_FOUND); - } - - // 3. Problem 조회 (DB에 존재하는지 확인) - Problem problem = problemRepository.findById(problemId) - .orElseThrow(() -> new CustomException(CustomResponseStatus.PROBLEM_NOT_FOUND)); - - // 4. 이미 인증된 문제인지 확인 - if (memberSolvedProblemRepository.existsByMemberIdAndProblemId(memberId, problemId)) { - throw new CustomException(CustomResponseStatus.ALREADY_SOLVED); - } - - // 5. solved.ac API로 실제 해결 여부 검증 - boolean isSolved = solvedAcClient.hasUserSolvedProblem(member.getHandle(), problemId); - - if (!isSolved) { - throw new CustomException(CustomResponseStatus.PROBLEM_NOT_SOLVED_YET); - } - - // 6. MemberSolvedProblem 레코드 생성 - MemberSolvedProblem memberSolvedProblem = MemberSolvedProblem.create(member, problem); - memberSolvedProblemRepository.save(memberSolvedProblem); - } - /** * 마지막 접속 시간 업데이트 * @param memberId 회원 ID @@ -231,81 +186,4 @@ public void withdraw(Long memberId) { memberRepository.save(member); } - private static final int MAX_DAILY_SOLVED_DAYS = 730; - - /** - * 최근 N일간 일별 문제 풀이 현황 조회 - * - 날짜 기준: 오전 6시 (06:00 ~ 다음날 05:59를 하루로 계산) - * @param memberId 회원 ID - * @param days 조회할 일수 (1~730일) - * @return 일별 풀이 현황 - */ - @Transactional(readOnly = true) - public DailySolvedResponse getDailySolved(Long memberId, int days) { - if (days < 1 || days > MAX_DAILY_SOLVED_DAYS) { - throw new CustomException(CustomResponseStatus.INVALID_DAYS_RANGE); - } - - LocalDateTime now = LocalDateTime.now(clock); - LocalDate today = getAdjustedDate(now); - - // 조회 범위: (days-1)일 전 06:00 <= solvedAt < 내일 06:00 - LocalDateTime startDateTime = today.minusDays(days - 1).atTime(LocalTime.of(6, 0)); - LocalDateTime endDateTime = today.plusDays(1).atTime(LocalTime.of(6, 0)); - - List solvedProblems = memberSolvedProblemRepository - .findByMemberIdAndSolvedAtGreaterThanEqualAndSolvedAtLessThanOrderBySolvedAtAsc(memberId, startDateTime, endDateTime); - - // 날짜별로 그룹핑 (오전 6시 기준) - Map> groupedByDate = new LinkedHashMap<>(); - - // 먼저 모든 날짜를 빈 리스트로 초기화 (과거 → 현재 순서) - for (int i = days - 1; i >= 0; i--) { - groupedByDate.put(today.minusDays(i), new ArrayList<>()); - } - - // 풀이 데이터를 날짜별로 분류 - for (MemberSolvedProblem solved : solvedProblems) { - LocalDate adjustedDate = getAdjustedDate(solved.getSolvedAt()); - Problem problem = solved.getProblem(); - - DailySolvedResponse.SolvedProblem solvedProblem = new DailySolvedResponse.SolvedProblem( - problem.getId(), - problem.getTitleKo() != null ? problem.getTitleKo() : problem.getTitle(), - problem.getLevel() - ); - - if (groupedByDate.containsKey(adjustedDate)) { - groupedByDate.get(adjustedDate).add(solvedProblem); - } - } - - // 응답 생성 - List dailySolvedList = groupedByDate.entrySet().stream() - .map(entry -> new DailySolvedResponse.DailySolved( - entry.getKey().toString(), - entry.getValue().size(), - entry.getValue() - )) - .toList(); - - int totalCount = dailySolvedList.stream() - .mapToInt(DailySolvedResponse.DailySolved::count) - .sum(); - - return new DailySolvedResponse(dailySolvedList, totalCount); - } - - /** - * 오전 6시 기준으로 날짜 계산 - * - 06:00 이전이면 전날로 처리 - */ - private LocalDate getAdjustedDate(LocalDateTime dateTime) { - if (dateTime.getHour() < 6) { - return dateTime.toLocalDate().minusDays(1); - } - return dateTime.toLocalDate(); - } - } - diff --git a/src/main/java/com/ryu/studyhelper/member/RankingController.java b/src/main/java/com/ryu/studyhelper/solve/controller/RankingController.java similarity index 62% rename from src/main/java/com/ryu/studyhelper/member/RankingController.java rename to src/main/java/com/ryu/studyhelper/solve/controller/RankingController.java index 2fdc47b..9fc47ab 100644 --- a/src/main/java/com/ryu/studyhelper/member/RankingController.java +++ b/src/main/java/com/ryu/studyhelper/solve/controller/RankingController.java @@ -1,8 +1,9 @@ -package com.ryu.studyhelper.member; +package com.ryu.studyhelper.solve.controller; import com.ryu.studyhelper.common.dto.ApiResponse; import com.ryu.studyhelper.common.enums.CustomResponseStatus; -import com.ryu.studyhelper.member.dto.response.GlobalRankingResponse; +import com.ryu.studyhelper.solve.dto.response.GlobalRankingResponse; +import com.ryu.studyhelper.solve.service.RankingService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -11,18 +12,18 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +// TODO: 프론트엔드 마이그레이션 후 제거 → SolveController(/api/solve/ranking/global)로 통합 완료 +@Deprecated @RestController @RequiredArgsConstructor @RequestMapping("/api/ranking") -@Tag(name = "Ranking", description = "랭킹 API") +@Tag(name = "Ranking (Deprecated)", description = "랭킹 API — /api/solve/ranking/global 로 이전됨") public class RankingController { private final RankingService rankingService; - @Operation( - summary = "전체 랭킹 조회", - description = "전체 회원 중 문제를 가장 많이 푼 상위 10명의 랭킹을 조회합니다." - ) + @Deprecated + @Operation(summary = "[Deprecated] 전체 랭킹 조회", description = "GET /api/solve/ranking/global 로 이전됨") @GetMapping("/global") public ResponseEntity> getGlobalRanking() { GlobalRankingResponse response = rankingService.getGlobalRanking(); diff --git a/src/main/java/com/ryu/studyhelper/solve/controller/SolveController.java b/src/main/java/com/ryu/studyhelper/solve/controller/SolveController.java new file mode 100644 index 0000000..408669e --- /dev/null +++ b/src/main/java/com/ryu/studyhelper/solve/controller/SolveController.java @@ -0,0 +1,67 @@ +package com.ryu.studyhelper.solve.controller; + +import com.ryu.studyhelper.common.dto.ApiResponse; +import com.ryu.studyhelper.common.enums.CustomResponseStatus; +import com.ryu.studyhelper.config.security.PrincipalDetails; +import com.ryu.studyhelper.infrastructure.ratelimit.RateLimit; +import com.ryu.studyhelper.infrastructure.ratelimit.RateLimitType; +import com.ryu.studyhelper.solve.dto.response.DailySolvedResponse; +import com.ryu.studyhelper.solve.dto.response.GlobalRankingResponse; +import com.ryu.studyhelper.solve.service.RankingService; +import com.ryu.studyhelper.solve.service.SolveService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/solve") +@Tag(name = "Solve", description = "풀이 인증/랭킹 API") +public class SolveController { + + private final SolveService solveService; + private final RankingService rankingService; + + @Operation( + summary = "문제 해결 인증", + description = "BOJ 문제 해결을 solved.ac API로 검증하고 인증합니다. 성공 시 MemberSolvedProblem 레코드가 생성됩니다." + ) + @RateLimit(type = RateLimitType.SOLVED_AC) + @PostMapping("/problems/{problemId}/verify") + public ResponseEntity> verifyProblemSolved( + @Parameter(description = "BOJ 문제 번호", example = "1000") + @PathVariable Long problemId, + @AuthenticationPrincipal PrincipalDetails principalDetails + ) { + solveService.verifyProblemSolved(principalDetails.getMemberId(), problemId); + return ResponseEntity.ok(ApiResponse.createSuccess(null, CustomResponseStatus.SUCCESS)); + } + + @Operation( + summary = "일별 문제 풀이 현황 조회", + description = "최근 N일간 일별 문제 풀이 현황을 조회합니다. 날짜 기준은 오전 6시입니다." + ) + @GetMapping("/daily") + public ResponseEntity> getDailySolved( + @Parameter(description = "조회할 일수 (기본 7일)", example = "7") + @RequestParam(defaultValue = "7") int days, + @AuthenticationPrincipal PrincipalDetails principalDetails + ) { + DailySolvedResponse response = solveService.getDailySolved(principalDetails.getMemberId(), days); + return ResponseEntity.ok(ApiResponse.createSuccess(response, CustomResponseStatus.SUCCESS)); + } + + @Operation( + summary = "전체 랭킹 조회", + description = "전체 회원 중 문제를 가장 많이 푼 상위 10명의 랭킹을 조회합니다." + ) + @GetMapping("/ranking/global") + public ResponseEntity> getGlobalRanking() { + GlobalRankingResponse response = rankingService.getGlobalRanking(); + return ResponseEntity.ok(ApiResponse.createSuccess(response, CustomResponseStatus.SUCCESS)); + } +} \ No newline at end of file diff --git a/src/main/java/com/ryu/studyhelper/member/domain/MemberSolvedProblem.java b/src/main/java/com/ryu/studyhelper/solve/domain/MemberSolvedProblem.java similarity index 92% rename from src/main/java/com/ryu/studyhelper/member/domain/MemberSolvedProblem.java rename to src/main/java/com/ryu/studyhelper/solve/domain/MemberSolvedProblem.java index a52afdd..19618af 100644 --- a/src/main/java/com/ryu/studyhelper/member/domain/MemberSolvedProblem.java +++ b/src/main/java/com/ryu/studyhelper/solve/domain/MemberSolvedProblem.java @@ -1,6 +1,7 @@ -package com.ryu.studyhelper.member.domain; +package com.ryu.studyhelper.solve.domain; import com.ryu.studyhelper.common.entity.BaseEntity; +import com.ryu.studyhelper.member.domain.Member; import com.ryu.studyhelper.problem.domain.Problem; import jakarta.persistence.*; import lombok.*; @@ -40,4 +41,4 @@ public static MemberSolvedProblem create(Member member, Problem problem) { .solvedAt(LocalDateTime.now()) .build(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/ryu/studyhelper/solve/dto/projection/GlobalRankingProjection.java b/src/main/java/com/ryu/studyhelper/solve/dto/projection/GlobalRankingProjection.java new file mode 100644 index 0000000..b515e1d --- /dev/null +++ b/src/main/java/com/ryu/studyhelper/solve/dto/projection/GlobalRankingProjection.java @@ -0,0 +1,6 @@ +package com.ryu.studyhelper.solve.dto.projection; + +public interface GlobalRankingProjection { + String getHandle(); + Long getTotalSolved(); +} diff --git a/src/main/java/com/ryu/studyhelper/team/dto/projection/MemberSolvedSummaryProjection.java b/src/main/java/com/ryu/studyhelper/solve/dto/projection/MemberSolvedSummaryProjection.java similarity index 81% rename from src/main/java/com/ryu/studyhelper/team/dto/projection/MemberSolvedSummaryProjection.java rename to src/main/java/com/ryu/studyhelper/solve/dto/projection/MemberSolvedSummaryProjection.java index ab88e25..3718836 100644 --- a/src/main/java/com/ryu/studyhelper/team/dto/projection/MemberSolvedSummaryProjection.java +++ b/src/main/java/com/ryu/studyhelper/solve/dto/projection/MemberSolvedSummaryProjection.java @@ -1,4 +1,4 @@ -package com.ryu.studyhelper.team.dto.projection; +package com.ryu.studyhelper.solve.dto.projection; /** * 멤버별 기간 내 풀이 수 집계 Projection @@ -8,4 +8,4 @@ public interface MemberSolvedSummaryProjection { Long getMemberId(); String getHandle(); Long getTotalSolved(); -} \ No newline at end of file +} diff --git a/src/main/java/com/ryu/studyhelper/member/dto/response/DailySolvedResponse.java b/src/main/java/com/ryu/studyhelper/solve/dto/response/DailySolvedResponse.java similarity index 95% rename from src/main/java/com/ryu/studyhelper/member/dto/response/DailySolvedResponse.java rename to src/main/java/com/ryu/studyhelper/solve/dto/response/DailySolvedResponse.java index 4cdd5bd..dd1f506 100644 --- a/src/main/java/com/ryu/studyhelper/member/dto/response/DailySolvedResponse.java +++ b/src/main/java/com/ryu/studyhelper/solve/dto/response/DailySolvedResponse.java @@ -1,4 +1,4 @@ -package com.ryu.studyhelper.member.dto.response; +package com.ryu.studyhelper.solve.dto.response; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/com/ryu/studyhelper/member/dto/response/GlobalRankingResponse.java b/src/main/java/com/ryu/studyhelper/solve/dto/response/GlobalRankingResponse.java similarity index 93% rename from src/main/java/com/ryu/studyhelper/member/dto/response/GlobalRankingResponse.java rename to src/main/java/com/ryu/studyhelper/solve/dto/response/GlobalRankingResponse.java index 2d906e1..f1dd74f 100644 --- a/src/main/java/com/ryu/studyhelper/member/dto/response/GlobalRankingResponse.java +++ b/src/main/java/com/ryu/studyhelper/solve/dto/response/GlobalRankingResponse.java @@ -1,4 +1,4 @@ -package com.ryu.studyhelper.member.dto.response; +package com.ryu.studyhelper.solve.dto.response; import io.swagger.v3.oas.annotations.media.Schema; @@ -24,4 +24,4 @@ public record RankEntry( public static GlobalRankingResponse from(List rankings) { return new GlobalRankingResponse(rankings); } -} \ No newline at end of file +} diff --git a/src/main/java/com/ryu/studyhelper/member/repository/MemberSolvedProblemRepository.java b/src/main/java/com/ryu/studyhelper/solve/repository/MemberSolvedProblemRepository.java similarity index 90% rename from src/main/java/com/ryu/studyhelper/member/repository/MemberSolvedProblemRepository.java rename to src/main/java/com/ryu/studyhelper/solve/repository/MemberSolvedProblemRepository.java index 563bddf..88c0558 100644 --- a/src/main/java/com/ryu/studyhelper/member/repository/MemberSolvedProblemRepository.java +++ b/src/main/java/com/ryu/studyhelper/solve/repository/MemberSolvedProblemRepository.java @@ -1,7 +1,8 @@ -package com.ryu.studyhelper.member.repository; +package com.ryu.studyhelper.solve.repository; -import com.ryu.studyhelper.member.domain.MemberSolvedProblem; -import com.ryu.studyhelper.team.dto.projection.MemberSolvedSummaryProjection; +import com.ryu.studyhelper.solve.domain.MemberSolvedProblem; +import com.ryu.studyhelper.solve.dto.projection.GlobalRankingProjection; +import com.ryu.studyhelper.solve.dto.projection.MemberSolvedSummaryProjection; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -72,9 +73,4 @@ ORDER BY COUNT(msp.id) DESC, m.handle ASC LIMIT :limit """) List findGlobalRanking(@Param("limit") int limit); - - interface GlobalRankingProjection { - String getHandle(); - Long getTotalSolved(); - } -} \ No newline at end of file +} diff --git a/src/main/java/com/ryu/studyhelper/member/RankingService.java b/src/main/java/com/ryu/studyhelper/solve/service/RankingService.java similarity index 86% rename from src/main/java/com/ryu/studyhelper/member/RankingService.java rename to src/main/java/com/ryu/studyhelper/solve/service/RankingService.java index 5288aa4..7a87eee 100644 --- a/src/main/java/com/ryu/studyhelper/member/RankingService.java +++ b/src/main/java/com/ryu/studyhelper/solve/service/RankingService.java @@ -1,8 +1,8 @@ -package com.ryu.studyhelper.member; +package com.ryu.studyhelper.solve.service; -import com.ryu.studyhelper.member.dto.response.GlobalRankingResponse; -import com.ryu.studyhelper.member.repository.MemberSolvedProblemRepository; -import com.ryu.studyhelper.member.repository.MemberSolvedProblemRepository.GlobalRankingProjection; +import com.ryu.studyhelper.solve.dto.projection.GlobalRankingProjection; +import com.ryu.studyhelper.solve.dto.response.GlobalRankingResponse; +import com.ryu.studyhelper.solve.repository.MemberSolvedProblemRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -62,4 +62,4 @@ private List assignRanks(List new CustomException(CustomResponseStatus.PROBLEM_NOT_FOUND)); + + // 4. 이미 인증된 문제인지 확인 + if (memberSolvedProblemRepository.existsByMemberIdAndProblemId(memberId, problemId)) { + throw new CustomException(CustomResponseStatus.ALREADY_SOLVED); + } + + // 5. solved.ac API로 실제 해결 여부 검증 + boolean isSolved = solvedAcClient.hasUserSolvedProblem(member.getHandle(), problemId); + + if (!isSolved) { + throw new CustomException(CustomResponseStatus.PROBLEM_NOT_SOLVED_YET); + } + + // 6. MemberSolvedProblem 레코드 생성 + MemberSolvedProblem memberSolvedProblem = MemberSolvedProblem.create(member, problem); + try { + memberSolvedProblemRepository.save(memberSolvedProblem); + } catch (DataIntegrityViolationException e) { + throw new CustomException(CustomResponseStatus.ALREADY_SOLVED); + } + } + + /** + * 최근 N일간 일별 문제 풀이 현황 조회 + * - 날짜 기준: 오전 6시 (06:00 ~ 다음날 05:59를 하루로 계산) + * @param memberId 회원 ID + * @param days 조회할 일수 (1~730일) + * @return 일별 풀이 현황 + */ + @Transactional(readOnly = true) + public DailySolvedResponse getDailySolved(Long memberId, int days) { + if (days < 1 || days > MAX_DAILY_SOLVED_DAYS) { + throw new CustomException(CustomResponseStatus.INVALID_DAYS_RANGE); + } + + LocalDateTime now = LocalDateTime.now(clock); + LocalDate today = getAdjustedDate(now); + + // 조회 범위: (days-1)일 전 06:00 <= solvedAt < 내일 06:00 + LocalDateTime startDateTime = today.minusDays(days - 1).atTime(LocalTime.of(6, 0)); + LocalDateTime endDateTime = today.plusDays(1).atTime(LocalTime.of(6, 0)); + + List solvedProblems = memberSolvedProblemRepository + .findByMemberIdAndSolvedAtGreaterThanEqualAndSolvedAtLessThanOrderBySolvedAtAsc(memberId, startDateTime, endDateTime); + + // 날짜별로 그룹핑 (오전 6시 기준) + Map> groupedByDate = new LinkedHashMap<>(); + + // 먼저 모든 날짜를 빈 리스트로 초기화 (과거 → 현재 순서) + for (int i = days - 1; i >= 0; i--) { + groupedByDate.put(today.minusDays(i), new ArrayList<>()); + } + + // 풀이 데이터를 날짜별로 분류 + for (MemberSolvedProblem solved : solvedProblems) { + LocalDate adjustedDate = getAdjustedDate(solved.getSolvedAt()); + Problem problem = solved.getProblem(); + + DailySolvedResponse.SolvedProblem solvedProblem = new DailySolvedResponse.SolvedProblem( + problem.getId(), + problem.getTitleKo() != null ? problem.getTitleKo() : problem.getTitle(), + problem.getLevel() + ); + + if (groupedByDate.containsKey(adjustedDate)) { + groupedByDate.get(adjustedDate).add(solvedProblem); + } + } + + // 응답 생성 + List dailySolvedList = groupedByDate.entrySet().stream() + .map(entry -> new DailySolvedResponse.DailySolved( + entry.getKey().toString(), + entry.getValue().size(), + entry.getValue() + )) + .toList(); + + int totalCount = dailySolvedList.stream() + .mapToInt(DailySolvedResponse.DailySolved::count) + .sum(); + + return new DailySolvedResponse(dailySolvedList, totalCount); + } + + /** + * 회원의 총 풀이 수 조회 + * @param memberId 회원 ID + * @return 풀이 수 + */ + @Transactional(readOnly = true) + public long countByMemberId(Long memberId) { + return memberSolvedProblemRepository.countByMemberId(memberId); + } + + /** + * 오전 6시 기준으로 날짜 계산 + * - 06:00 이전이면 전날로 처리 + */ + private LocalDate getAdjustedDate(LocalDateTime dateTime) { + if (dateTime.getHour() < 6) { + return dateTime.toLocalDate().minusDays(1); + } + return dateTime.toLocalDate(); + } + + private Member findMemberById(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new CustomException(CustomResponseStatus.MEMBER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/ryu/studyhelper/team/service/TeamActivityService.java b/src/main/java/com/ryu/studyhelper/team/service/TeamActivityService.java index f1f060a..042d7bb 100644 --- a/src/main/java/com/ryu/studyhelper/team/service/TeamActivityService.java +++ b/src/main/java/com/ryu/studyhelper/team/service/TeamActivityService.java @@ -3,8 +3,8 @@ import com.ryu.studyhelper.common.enums.CustomResponseStatus; import com.ryu.studyhelper.common.exception.CustomException; import com.ryu.studyhelper.member.domain.Member; -import com.ryu.studyhelper.member.domain.MemberSolvedProblem; -import com.ryu.studyhelper.member.repository.MemberSolvedProblemRepository; +import com.ryu.studyhelper.solve.domain.MemberSolvedProblem; +import com.ryu.studyhelper.solve.repository.MemberSolvedProblemRepository; import com.ryu.studyhelper.recommendation.domain.Recommendation; import com.ryu.studyhelper.recommendation.domain.RecommendationProblem; import com.ryu.studyhelper.recommendation.repository.RecommendationRepository; @@ -13,7 +13,7 @@ import com.ryu.studyhelper.team.domain.Team; import com.ryu.studyhelper.team.dto.internal.MemberSolvedStatus; import com.ryu.studyhelper.team.dto.internal.QueryPeriod; -import com.ryu.studyhelper.team.dto.projection.MemberSolvedSummaryProjection; +import com.ryu.studyhelper.solve.dto.projection.MemberSolvedSummaryProjection; import com.ryu.studyhelper.team.dto.response.TeamActivityResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/test/java/com/ryu/studyhelper/member/MemberServiceTest.java b/src/test/java/com/ryu/studyhelper/member/MemberServiceTest.java index cc72831..c127eb4 100644 --- a/src/test/java/com/ryu/studyhelper/member/MemberServiceTest.java +++ b/src/test/java/com/ryu/studyhelper/member/MemberServiceTest.java @@ -3,12 +3,8 @@ import com.ryu.studyhelper.common.enums.CustomResponseStatus; import com.ryu.studyhelper.common.exception.CustomException; import com.ryu.studyhelper.member.domain.Member; -import com.ryu.studyhelper.member.domain.MemberSolvedProblem; import com.ryu.studyhelper.member.repository.MemberRepository; -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.infrastructure.solvedac.SolvedAcClient; +import com.ryu.studyhelper.solve.service.SolveService; import com.ryu.studyhelper.team.repository.TeamMemberRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -19,15 +15,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.time.Clock; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.util.List; import java.util.Optional; -import com.ryu.studyhelper.member.dto.response.DailySolvedResponse; - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -44,20 +33,13 @@ class MemberServiceTest { @Mock private MemberRepository memberRepository; - @Mock - private ProblemRepository problemRepository; - - @Mock - private MemberSolvedProblemRepository memberSolvedProblemRepository; - @Mock private TeamMemberRepository teamMemberRepository; @Mock - private SolvedAcClient solvedAcClient; + private SolveService solveService; private Member member; - private Problem problem; @BeforeEach void setUp() { @@ -69,130 +51,6 @@ void setUp() { .handle("testuser") .isVerified(true) .build(); - - problem = Problem.builder() - .id(1000L) - .title("A+B") - .titleKo("A+B") - .level(1) - .build(); - } - - @Nested - @DisplayName("verifyProblemSolved 메서드") - class VerifyProblemSolvedTest { - - @Test - @DisplayName("성공 - 문제 해결 인증") - void success() { - // given - given(memberRepository.findById(1L)).willReturn(Optional.of(member)); - given(problemRepository.findById(1000L)).willReturn(Optional.of(problem)); - given(memberSolvedProblemRepository.existsByMemberIdAndProblemId(1L, 1000L)).willReturn(false); - given(solvedAcClient.hasUserSolvedProblem("testuser", 1000L)).willReturn(true); - given(memberSolvedProblemRepository.save(any(MemberSolvedProblem.class))) - .willAnswer(invocation -> invocation.getArgument(0)); - - // when - memberService.verifyProblemSolved(1L, 1000L); - - // then - verify(memberSolvedProblemRepository).save(any(MemberSolvedProblem.class)); - } - - @Test - @DisplayName("실패 - 회원을 찾을 수 없음") - void fail_memberNotFound() { - // given - given(memberRepository.findById(1L)).willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> memberService.verifyProblemSolved(1L, 1000L)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("status", CustomResponseStatus.MEMBER_NOT_FOUND); - } - - @Test - @DisplayName("실패 - 문제를 찾을 수 없음") - void fail_problemNotFound() { - // given - given(memberRepository.findById(1L)).willReturn(Optional.of(member)); - given(problemRepository.findById(1000L)).willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> memberService.verifyProblemSolved(1L, 1000L)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("status", CustomResponseStatus.PROBLEM_NOT_FOUND); - } - - @Test - @DisplayName("실패 - 이미 인증된 문제") - void fail_alreadySolved() { - // given - given(memberRepository.findById(1L)).willReturn(Optional.of(member)); - given(problemRepository.findById(1000L)).willReturn(Optional.of(problem)); - given(memberSolvedProblemRepository.existsByMemberIdAndProblemId(1L, 1000L)).willReturn(true); - - // when & then - assertThatThrownBy(() -> memberService.verifyProblemSolved(1L, 1000L)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("status", CustomResponseStatus.ALREADY_SOLVED); - } - - @Test - @DisplayName("실패 - 핸들이 등록되지 않음") - void fail_handleNotRegistered() { - // given - Member memberWithoutHandle = Member.builder() - .id(1L) - .provider("google") - .providerId("google_123") - .email("test@example.com") - .handle(null) - .build(); - - given(memberRepository.findById(1L)).willReturn(Optional.of(memberWithoutHandle)); - - // when & then - assertThatThrownBy(() -> memberService.verifyProblemSolved(1L, 1000L)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("status", CustomResponseStatus.SOLVED_AC_USER_NOT_FOUND); - } - - @Test - @DisplayName("실패 - solved.ac에서 해결 확인 안됨") - void fail_notSolvedYet() { - // given - given(memberRepository.findById(1L)).willReturn(Optional.of(member)); - given(problemRepository.findById(1000L)).willReturn(Optional.of(problem)); - given(memberSolvedProblemRepository.existsByMemberIdAndProblemId(1L, 1000L)).willReturn(false); - given(solvedAcClient.hasUserSolvedProblem("testuser", 1000L)).willReturn(false); - - // when & then - assertThatThrownBy(() -> memberService.verifyProblemSolved(1L, 1000L)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("status", CustomResponseStatus.PROBLEM_NOT_SOLVED_YET); - } - - @Test - @DisplayName("실패 - 빈 핸들") - void fail_emptyHandle() { - // given - Member memberWithEmptyHandle = Member.builder() - .id(1L) - .provider("google") - .providerId("google_123") - .email("test@example.com") - .handle("") - .build(); - - given(memberRepository.findById(1L)).willReturn(Optional.of(memberWithEmptyHandle)); - - // when & then - assertThatThrownBy(() -> memberService.verifyProblemSolved(1L, 1000L)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("status", CustomResponseStatus.SOLVED_AC_USER_NOT_FOUND); - } } @Nested @@ -247,180 +105,4 @@ void fail_memberHasTeam() { verify(memberRepository, never()).save(any()); } } - - @Nested - @DisplayName("getDailySolved 메서드") - class GetDailySolvedTest { - - @Mock - private Clock clock; - - private MemberService memberServiceWithClock; - - @BeforeEach - void setUp() { - // 2024-11-28 14:00 (오후 2시)으로 고정 - ZonedDateTime fixedTime = ZonedDateTime.of(2024, 11, 28, 14, 0, 0, 0, ZoneId.systemDefault()); - given(clock.instant()).willReturn(fixedTime.toInstant()); - given(clock.getZone()).willReturn(ZoneId.systemDefault()); - - memberServiceWithClock = new MemberService( - memberRepository, - problemRepository, - memberSolvedProblemRepository, - teamMemberRepository, - solvedAcClient, - null, // jwtUtil - null, // mailSender - null, // emailChangeMailBuilder - clock - ); - } - - @Test - @DisplayName("성공 - 7일간 일별 풀이 현황 조회") - void success_getDailySolved() { - // given - Problem problem1 = Problem.builder().id(1000L).title("A+B").titleKo("A+B").level(1).build(); - Problem problem2 = Problem.builder().id(7576L).title("토마토").titleKo("토마토").level(11).build(); - - MemberSolvedProblem solved1 = mock(MemberSolvedProblem.class); - given(solved1.getSolvedAt()).willReturn(LocalDateTime.of(2024, 11, 28, 10, 0)); // 11/28 10:00 - given(solved1.getProblem()).willReturn(problem1); - - MemberSolvedProblem solved2 = mock(MemberSolvedProblem.class); - given(solved2.getSolvedAt()).willReturn(LocalDateTime.of(2024, 11, 28, 15, 0)); // 11/28 15:00 - given(solved2.getProblem()).willReturn(problem2); - - given(memberSolvedProblemRepository.findByMemberIdAndSolvedAtGreaterThanEqualAndSolvedAtLessThanOrderBySolvedAtAsc(any(), any(), any())) - .willReturn(List.of(solved1, solved2)); - - // when - DailySolvedResponse response = memberServiceWithClock.getDailySolved(1L, 7); - - // then - assertThat(response.totalCount()).isEqualTo(2); - assertThat(response.dailySolved()).hasSize(7); - - // 11/28에 2문제 - DailySolvedResponse.DailySolved nov28 = response.dailySolved().stream() - .filter(d -> d.date().equals("2024-11-28")) - .findFirst() - .orElseThrow(); - assertThat(nov28.count()).isEqualTo(2); - assertThat(nov28.problems()).hasSize(2); - } - - @Test - @DisplayName("성공 - 오전 6시 이전은 전날로 계산") - void success_before6am_countAsPreviousDay() { - // given - Problem problem1 = Problem.builder().id(1000L).title("A+B").titleKo("A+B").level(1).build(); - - MemberSolvedProblem solved = mock(MemberSolvedProblem.class); - // 11/28 05:30 → 11/27로 계산되어야 함 - given(solved.getSolvedAt()).willReturn(LocalDateTime.of(2024, 11, 28, 5, 30)); - given(solved.getProblem()).willReturn(problem1); - - given(memberSolvedProblemRepository.findByMemberIdAndSolvedAtGreaterThanEqualAndSolvedAtLessThanOrderBySolvedAtAsc(any(), any(), any())) - .willReturn(List.of(solved)); - - // when - DailySolvedResponse response = memberServiceWithClock.getDailySolved(1L, 7); - - // then - // 11/27에 1문제 - DailySolvedResponse.DailySolved nov27 = response.dailySolved().stream() - .filter(d -> d.date().equals("2024-11-27")) - .findFirst() - .orElseThrow(); - assertThat(nov27.count()).isEqualTo(1); - - // 11/28에 0문제 - DailySolvedResponse.DailySolved nov28 = response.dailySolved().stream() - .filter(d -> d.date().equals("2024-11-28")) - .findFirst() - .orElseThrow(); - assertThat(nov28.count()).isEqualTo(0); - } - - @Test - @DisplayName("성공 - 풀이가 없는 경우 빈 리스트") - void success_noSolved() { - // given - given(memberSolvedProblemRepository.findByMemberIdAndSolvedAtGreaterThanEqualAndSolvedAtLessThanOrderBySolvedAtAsc(any(), any(), any())) - .willReturn(List.of()); - - // when - DailySolvedResponse response = memberServiceWithClock.getDailySolved(1L, 7); - - // then - assertThat(response.totalCount()).isEqualTo(0); - assertThat(response.dailySolved()).hasSize(7); - response.dailySolved().forEach(daily -> { - assertThat(daily.count()).isEqualTo(0); - assertThat(daily.problems()).isEmpty(); - }); - } - - } - - @Nested - @DisplayName("getDailySolved 메서드 - 범위 검증") - class GetDailySolvedRangeValidationTest { - - private MemberService service; - - @BeforeEach - void setUp() { - service = new MemberService( - memberRepository, problemRepository, memberSolvedProblemRepository, - teamMemberRepository, solvedAcClient, null, null, null, Clock.systemDefaultZone() - ); - } - - @Test - @DisplayName("실패 - days가 1 미만일 때 예외 발생") - void fail_daysLessThanOne() { - assertThatThrownBy(() -> service.getDailySolved(1L, 0)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("status", CustomResponseStatus.INVALID_DAYS_RANGE); - } - - @Test - @DisplayName("실패 - days가 730 초과일 때 예외 발생") - void fail_daysGreaterThan730() { - assertThatThrownBy(() -> service.getDailySolved(1L, 731)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("status", CustomResponseStatus.INVALID_DAYS_RANGE); - } - - @Test - @DisplayName("성공 - 최소 경계값 days=1") - void success_minBoundary() { - // given - given(memberSolvedProblemRepository.findByMemberIdAndSolvedAtGreaterThanEqualAndSolvedAtLessThanOrderBySolvedAtAsc(any(), any(), any())) - .willReturn(List.of()); - - // when - DailySolvedResponse response = service.getDailySolved(1L, 1); - - // then - assertThat(response.dailySolved()).hasSize(1); - } - - @Test - @DisplayName("성공 - 최대 경계값 days=730") - void success_maxBoundary() { - // given - given(memberSolvedProblemRepository.findByMemberIdAndSolvedAtGreaterThanEqualAndSolvedAtLessThanOrderBySolvedAtAsc(any(), any(), any())) - .willReturn(List.of()); - - // when - DailySolvedResponse response = service.getDailySolved(1L, 730); - - // then - assertThat(response.dailySolved()).hasSize(730); - } - } -} \ No newline at end of file +} diff --git a/src/test/java/com/ryu/studyhelper/member/RankingServiceTest.java b/src/test/java/com/ryu/studyhelper/solve/service/RankingServiceTest.java similarity index 95% rename from src/test/java/com/ryu/studyhelper/member/RankingServiceTest.java rename to src/test/java/com/ryu/studyhelper/solve/service/RankingServiceTest.java index b73545a..c68d9fa 100644 --- a/src/test/java/com/ryu/studyhelper/member/RankingServiceTest.java +++ b/src/test/java/com/ryu/studyhelper/solve/service/RankingServiceTest.java @@ -1,8 +1,8 @@ -package com.ryu.studyhelper.member; +package com.ryu.studyhelper.solve.service; -import com.ryu.studyhelper.member.dto.response.GlobalRankingResponse; -import com.ryu.studyhelper.member.repository.MemberSolvedProblemRepository; -import com.ryu.studyhelper.member.repository.MemberSolvedProblemRepository.GlobalRankingProjection; +import com.ryu.studyhelper.solve.dto.projection.GlobalRankingProjection; +import com.ryu.studyhelper.solve.dto.response.GlobalRankingResponse; +import com.ryu.studyhelper.solve.repository.MemberSolvedProblemRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -146,4 +146,4 @@ public Long getTotalSolved() { }; } } -} \ No newline at end of file +} diff --git a/src/test/java/com/ryu/studyhelper/solve/service/SolveServiceTest.java b/src/test/java/com/ryu/studyhelper/solve/service/SolveServiceTest.java new file mode 100644 index 0000000..e1fa4e9 --- /dev/null +++ b/src/test/java/com/ryu/studyhelper/solve/service/SolveServiceTest.java @@ -0,0 +1,384 @@ +package com.ryu.studyhelper.solve.service; + +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.domain.Member; +import com.ryu.studyhelper.member.repository.MemberRepository; +import com.ryu.studyhelper.problem.domain.Problem; +import com.ryu.studyhelper.problem.repository.ProblemRepository; +import com.ryu.studyhelper.solve.domain.MemberSolvedProblem; +import com.ryu.studyhelper.solve.dto.response.DailySolvedResponse; +import com.ryu.studyhelper.solve.repository.MemberSolvedProblemRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("SolveService 단위 테스트") +class SolveServiceTest { + + @InjectMocks + private SolveService solveService; + + @Mock + private MemberRepository memberRepository; + + @Mock + private ProblemRepository problemRepository; + + @Mock + private MemberSolvedProblemRepository memberSolvedProblemRepository; + + @Mock + private SolvedAcClient solvedAcClient; + + @Mock + private Clock clock; + + private Member member; + private Problem problem; + + @BeforeEach + void setUp() { + member = Member.builder() + .id(1L) + .provider("google") + .providerId("google_123") + .email("test@example.com") + .handle("testuser") + .isVerified(true) + .build(); + + problem = Problem.builder() + .id(1000L) + .title("A+B") + .titleKo("A+B") + .level(1) + .build(); + } + + @Nested + @DisplayName("verifyProblemSolved 메서드") + class VerifyProblemSolvedTest { + + @Test + @DisplayName("성공 - 문제 해결 인증") + void success() { + // given + given(memberRepository.findById(1L)).willReturn(Optional.of(member)); + given(problemRepository.findById(1000L)).willReturn(Optional.of(problem)); + given(memberSolvedProblemRepository.existsByMemberIdAndProblemId(1L, 1000L)).willReturn(false); + given(solvedAcClient.hasUserSolvedProblem("testuser", 1000L)).willReturn(true); + given(memberSolvedProblemRepository.save(any(MemberSolvedProblem.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + + // when + solveService.verifyProblemSolved(1L, 1000L); + + // then + verify(memberSolvedProblemRepository).save(any(MemberSolvedProblem.class)); + } + + @Test + @DisplayName("실패 - 회원을 찾을 수 없음") + void fail_memberNotFound() { + // given + given(memberRepository.findById(1L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> solveService.verifyProblemSolved(1L, 1000L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("status", CustomResponseStatus.MEMBER_NOT_FOUND); + } + + @Test + @DisplayName("실패 - 문제를 찾을 수 없음") + void fail_problemNotFound() { + // given + given(memberRepository.findById(1L)).willReturn(Optional.of(member)); + given(problemRepository.findById(1000L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> solveService.verifyProblemSolved(1L, 1000L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("status", CustomResponseStatus.PROBLEM_NOT_FOUND); + } + + @Test + @DisplayName("실패 - 이미 인증된 문제") + void fail_alreadySolved() { + // given + given(memberRepository.findById(1L)).willReturn(Optional.of(member)); + given(problemRepository.findById(1000L)).willReturn(Optional.of(problem)); + given(memberSolvedProblemRepository.existsByMemberIdAndProblemId(1L, 1000L)).willReturn(true); + + // when & then + assertThatThrownBy(() -> solveService.verifyProblemSolved(1L, 1000L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("status", CustomResponseStatus.ALREADY_SOLVED); + } + + @Test + @DisplayName("실패 - 핸들이 등록되지 않음") + void fail_handleNotRegistered() { + // given + Member memberWithoutHandle = Member.builder() + .id(1L) + .provider("google") + .providerId("google_123") + .email("test@example.com") + .handle(null) + .build(); + + given(memberRepository.findById(1L)).willReturn(Optional.of(memberWithoutHandle)); + + // when & then + assertThatThrownBy(() -> solveService.verifyProblemSolved(1L, 1000L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("status", CustomResponseStatus.SOLVED_AC_USER_NOT_FOUND); + } + + @Test + @DisplayName("실패 - solved.ac에서 해결 확인 안됨") + void fail_notSolvedYet() { + // given + given(memberRepository.findById(1L)).willReturn(Optional.of(member)); + given(problemRepository.findById(1000L)).willReturn(Optional.of(problem)); + given(memberSolvedProblemRepository.existsByMemberIdAndProblemId(1L, 1000L)).willReturn(false); + given(solvedAcClient.hasUserSolvedProblem("testuser", 1000L)).willReturn(false); + + // when & then + assertThatThrownBy(() -> solveService.verifyProblemSolved(1L, 1000L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("status", CustomResponseStatus.PROBLEM_NOT_SOLVED_YET); + } + + @Test + @DisplayName("실패 - 빈 핸들") + void fail_emptyHandle() { + // given + Member memberWithEmptyHandle = Member.builder() + .id(1L) + .provider("google") + .providerId("google_123") + .email("test@example.com") + .handle("") + .build(); + + given(memberRepository.findById(1L)).willReturn(Optional.of(memberWithEmptyHandle)); + + // when & then + assertThatThrownBy(() -> solveService.verifyProblemSolved(1L, 1000L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("status", CustomResponseStatus.SOLVED_AC_USER_NOT_FOUND); + } + + @Test + @DisplayName("실패 - 동시 요청으로 인한 중복 저장 (TOCTOU)") + void fail_concurrentDuplicate() { + // given + given(memberRepository.findById(1L)).willReturn(Optional.of(member)); + given(problemRepository.findById(1000L)).willReturn(Optional.of(problem)); + given(memberSolvedProblemRepository.existsByMemberIdAndProblemId(1L, 1000L)).willReturn(false); + given(solvedAcClient.hasUserSolvedProblem("testuser", 1000L)).willReturn(true); + given(memberSolvedProblemRepository.save(any(MemberSolvedProblem.class))) + .willThrow(new org.springframework.dao.DataIntegrityViolationException("duplicate")); + + // when & then + assertThatThrownBy(() -> solveService.verifyProblemSolved(1L, 1000L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("status", CustomResponseStatus.ALREADY_SOLVED); + } + } + + @Nested + @DisplayName("getDailySolved 메서드") + class GetDailySolvedTest { + + @Mock + private Clock clock; + + private SolveService solveServiceWithClock; + + @BeforeEach + void setUp() { + // 2024-11-28 14:00 (오후 2시)으로 고정 + ZonedDateTime fixedTime = ZonedDateTime.of(2024, 11, 28, 14, 0, 0, 0, ZoneId.of("Asia/Seoul")); + given(clock.instant()).willReturn(fixedTime.toInstant()); + given(clock.getZone()).willReturn(ZoneId.of("Asia/Seoul")); + + solveServiceWithClock = new SolveService( + memberRepository, + problemRepository, + memberSolvedProblemRepository, + solvedAcClient, + clock + ); + } + + @Test + @DisplayName("성공 - 7일간 일별 풀이 현황 조회") + void success_getDailySolved() { + // given + Problem problem1 = Problem.builder().id(1000L).title("A+B").titleKo("A+B").level(1).build(); + Problem problem2 = Problem.builder().id(7576L).title("토마토").titleKo("토마토").level(11).build(); + + MemberSolvedProblem solved1 = mock(MemberSolvedProblem.class); + given(solved1.getSolvedAt()).willReturn(LocalDateTime.of(2024, 11, 28, 10, 0)); // 11/28 10:00 + given(solved1.getProblem()).willReturn(problem1); + + MemberSolvedProblem solved2 = mock(MemberSolvedProblem.class); + given(solved2.getSolvedAt()).willReturn(LocalDateTime.of(2024, 11, 28, 15, 0)); // 11/28 15:00 + given(solved2.getProblem()).willReturn(problem2); + + given(memberSolvedProblemRepository.findByMemberIdAndSolvedAtGreaterThanEqualAndSolvedAtLessThanOrderBySolvedAtAsc(any(), any(), any())) + .willReturn(List.of(solved1, solved2)); + + // when + DailySolvedResponse response = solveServiceWithClock.getDailySolved(1L, 7); + + // then + assertThat(response.totalCount()).isEqualTo(2); + assertThat(response.dailySolved()).hasSize(7); + + // 11/28에 2문제 + DailySolvedResponse.DailySolved nov28 = response.dailySolved().stream() + .filter(d -> d.date().equals("2024-11-28")) + .findFirst() + .orElseThrow(); + assertThat(nov28.count()).isEqualTo(2); + assertThat(nov28.problems()).hasSize(2); + } + + @Test + @DisplayName("성공 - 오전 6시 이전은 전날로 계산") + void success_before6am_countAsPreviousDay() { + // given + Problem problem1 = Problem.builder().id(1000L).title("A+B").titleKo("A+B").level(1).build(); + + MemberSolvedProblem solved = mock(MemberSolvedProblem.class); + // 11/28 05:30 → 11/27로 계산되어야 함 + given(solved.getSolvedAt()).willReturn(LocalDateTime.of(2024, 11, 28, 5, 30)); + given(solved.getProblem()).willReturn(problem1); + + given(memberSolvedProblemRepository.findByMemberIdAndSolvedAtGreaterThanEqualAndSolvedAtLessThanOrderBySolvedAtAsc(any(), any(), any())) + .willReturn(List.of(solved)); + + // when + DailySolvedResponse response = solveServiceWithClock.getDailySolved(1L, 7); + + // then + // 11/27에 1문제 + DailySolvedResponse.DailySolved nov27 = response.dailySolved().stream() + .filter(d -> d.date().equals("2024-11-27")) + .findFirst() + .orElseThrow(); + assertThat(nov27.count()).isEqualTo(1); + + // 11/28에 0문제 + DailySolvedResponse.DailySolved nov28 = response.dailySolved().stream() + .filter(d -> d.date().equals("2024-11-28")) + .findFirst() + .orElseThrow(); + assertThat(nov28.count()).isEqualTo(0); + } + + @Test + @DisplayName("성공 - 풀이가 없는 경우 빈 리스트") + void success_noSolved() { + // given + given(memberSolvedProblemRepository.findByMemberIdAndSolvedAtGreaterThanEqualAndSolvedAtLessThanOrderBySolvedAtAsc(any(), any(), any())) + .willReturn(List.of()); + + // when + DailySolvedResponse response = solveServiceWithClock.getDailySolved(1L, 7); + + // then + assertThat(response.totalCount()).isEqualTo(0); + assertThat(response.dailySolved()).hasSize(7); + response.dailySolved().forEach(daily -> { + assertThat(daily.count()).isEqualTo(0); + assertThat(daily.problems()).isEmpty(); + }); + } + + } + + @Nested + @DisplayName("getDailySolved 메서드 - 범위 검증") + class GetDailySolvedRangeValidationTest { + + private SolveService service; + + @BeforeEach + void setUp() { + service = new SolveService( + memberRepository, problemRepository, memberSolvedProblemRepository, + solvedAcClient, Clock.system(ZoneId.of("Asia/Seoul")) + ); + } + + @Test + @DisplayName("실패 - days가 1 미만일 때 예외 발생") + void fail_daysLessThanOne() { + assertThatThrownBy(() -> service.getDailySolved(1L, 0)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("status", CustomResponseStatus.INVALID_DAYS_RANGE); + } + + @Test + @DisplayName("실패 - days가 730 초과일 때 예외 발생") + void fail_daysGreaterThan730() { + assertThatThrownBy(() -> service.getDailySolved(1L, 731)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("status", CustomResponseStatus.INVALID_DAYS_RANGE); + } + + @Test + @DisplayName("성공 - 최소 경계값 days=1") + void success_minBoundary() { + // given + given(memberSolvedProblemRepository.findByMemberIdAndSolvedAtGreaterThanEqualAndSolvedAtLessThanOrderBySolvedAtAsc(any(), any(), any())) + .willReturn(List.of()); + + // when + DailySolvedResponse response = service.getDailySolved(1L, 1); + + // then + assertThat(response.dailySolved()).hasSize(1); + } + + @Test + @DisplayName("성공 - 최대 경계값 days=730") + void success_maxBoundary() { + // given + given(memberSolvedProblemRepository.findByMemberIdAndSolvedAtGreaterThanEqualAndSolvedAtLessThanOrderBySolvedAtAsc(any(), any(), any())) + .willReturn(List.of()); + + // when + DailySolvedResponse response = service.getDailySolved(1L, 730); + + // then + assertThat(response.dailySolved()).hasSize(730); + } + } +} diff --git a/src/test/java/com/ryu/studyhelper/team/service/TeamActivityServiceTest.java b/src/test/java/com/ryu/studyhelper/team/service/TeamActivityServiceTest.java index dbec188..f7ca380 100644 --- a/src/test/java/com/ryu/studyhelper/team/service/TeamActivityServiceTest.java +++ b/src/test/java/com/ryu/studyhelper/team/service/TeamActivityServiceTest.java @@ -3,9 +3,9 @@ import com.ryu.studyhelper.common.enums.CustomResponseStatus; import com.ryu.studyhelper.common.exception.CustomException; import com.ryu.studyhelper.member.domain.Member; -import com.ryu.studyhelper.member.domain.MemberSolvedProblem; import com.ryu.studyhelper.member.domain.Role; -import com.ryu.studyhelper.member.repository.MemberSolvedProblemRepository; +import com.ryu.studyhelper.solve.domain.MemberSolvedProblem; +import com.ryu.studyhelper.solve.repository.MemberSolvedProblemRepository; import com.ryu.studyhelper.problem.domain.Problem; import com.ryu.studyhelper.recommendation.domain.Recommendation; import com.ryu.studyhelper.recommendation.domain.RecommendationProblem; @@ -13,7 +13,7 @@ import com.ryu.studyhelper.team.repository.TeamMemberRepository; import com.ryu.studyhelper.team.repository.TeamRepository; import com.ryu.studyhelper.team.domain.Team; -import com.ryu.studyhelper.team.dto.projection.MemberSolvedSummaryProjection; +import com.ryu.studyhelper.solve.dto.projection.MemberSolvedSummaryProjection; import com.ryu.studyhelper.team.dto.response.TeamActivityResponse; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName;