Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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()
Expand Down
42 changes: 20 additions & 22 deletions src/main/java/com/ryu/studyhelper/member/MemberController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,6 +33,7 @@
public class MemberController {

private final MemberService memberService;
private final SolveService solveService; // TODO: 프론트엔드 마이그레이션 후 제거

@Operation(
summary = "내 프로필 조회",
Expand Down Expand Up @@ -133,21 +135,6 @@ public ResponseEntity<ApiResponse<EmailChangeResponse>> 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<ApiResponse<Void>> 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)는 마스킹 처리됩니다."
Expand All @@ -160,17 +147,28 @@ public ResponseEntity<ApiResponse<Void>> 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<ApiResponse<Void>> 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<ApiResponse<DailySolvedResponse>> 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));
}
}
128 changes: 3 additions & 125 deletions src/main/java/com/ryu/studyhelper/member/MemberService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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);
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<MemberSolvedProblem> solvedProblems = memberSolvedProblemRepository
.findByMemberIdAndSolvedAtGreaterThanEqualAndSolvedAtLessThanOrderBySolvedAtAsc(memberId, startDateTime, endDateTime);

// 날짜별로 그룹핑 (오전 6시 기준)
Map<LocalDate, List<DailySolvedResponse.SolvedProblem>> 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<DailySolvedResponse.DailySolved> 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();
}

}

Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<ApiResponse<GlobalRankingResponse>> getGlobalRanking() {
GlobalRankingResponse response = rankingService.getGlobalRanking();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<Void>> 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<ApiResponse<DailySolvedResponse>> 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<ApiResponse<GlobalRankingResponse>> getGlobalRanking() {
GlobalRankingResponse response = rankingService.getGlobalRanking();
return ResponseEntity.ok(ApiResponse.createSuccess(response, CustomResponseStatus.SUCCESS));
}
}
Original file line number Diff line number Diff line change
@@ -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.*;
Expand Down Expand Up @@ -40,4 +41,4 @@ public static MemberSolvedProblem create(Member member, Problem problem) {
.solvedAt(LocalDateTime.now())
.build();
}
}
}
Loading