diff --git a/sun/src/main/java/com/example/umc/domain/mission/controller/MissionController.java b/sun/src/main/java/com/example/umc/domain/mission/controller/MissionController.java index 9ef550a..b180812 100644 --- a/sun/src/main/java/com/example/umc/domain/mission/controller/MissionController.java +++ b/sun/src/main/java/com/example/umc/domain/mission/controller/MissionController.java @@ -13,8 +13,11 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -67,13 +70,144 @@ public class MissionController { ) }) @PostMapping - public ResponseEntity> challengeMission( + public ApiResponse challengeMission( @Parameter(hidden = true) @LoginMemberId Long memberId, @Valid @RequestBody MissionRequestDto.ChallengeMissionDto request) { MissionResponseDto.ChallengeMissionDto response = missionService.challengeMission(memberId, request); - return ResponseEntity.ok(ApiResponse.onSuccess(GeneralSuccessCode.CREATED, response)); + return ApiResponse.onSuccess(GeneralSuccessCode.CREATED, response); + } + + /** + * 내가 진행 중인 미션 목록 조회 API + * GET /api/v1/members/me/missions/in-progress + * + * @param memberId 현재 로그인한 회원 ID (헤더에서 자동 추출) + * @param page 페이지 번호 (0부터 시작, 기본값: 0) + * @param size 페이지 크기 (1~100, 기본값: 10) + * @return 진행 중인 미션 목록 (페이징 정보 포함) + */ + @Operation( + summary = "내가 진행 중인 미션 목록 조회", + description = "현재 로그인한 회원이 진행 중인 미션 목록을 조회합니다. 페이징을 지원하며, 최근 도전한 순서로 정렬됩니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "미션 목록 조회 성공", + content = @Content(schema = @Schema(implementation = MissionResponseDto.MissionListDto.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "잘못된 페이징 파라미터 (페이지 번호 음수, 크기 범위 초과 등)" + ) + }) + @GetMapping("/in-progress") + public ApiResponse> getInProgressMissions( + @Parameter(hidden = true) @LoginMemberId Long memberId, + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") + @Min(value = 0, message = "페이지 번호는 0 이상이어야 합니다.") + @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기 (1~100)", example = "10") + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.") + @Max(value = 100, message = "페이지 크기는 100 이하여야 합니다.") + @RequestParam(defaultValue = "10") int size) { + + // Pageable 생성 + Pageable pageable = PageRequest.of(page, size); + + // 서비스 호출 + org.springframework.data.domain.Page response = + missionService.getInProgressMissions(memberId, pageable); + + return ApiResponse.onSuccess(GeneralSuccessCode.OK, response); + } + + /** + * 진행 중인 미션을 완료로 변경 API + * PATCH /api/v1/members/me/missions/{missionId}/complete + * + * @param memberId 현재 로그인한 회원 ID (헤더에서 자동 추출) + * @param missionId 완료할 미션 ID (Path Variable) + * @return 완료된 미션 정보 + */ + @Operation( + summary = "진행 중인 미션 완료하기", + description = "현재 로그인한 회원이 진행 중인 미션을 완료 상태로 변경합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "미션 완료 성공", + content = @Content(schema = @Schema(implementation = MissionResponseDto.CompleteMissionDto.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "회원의 미션을 찾을 수 없음" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "409", + description = "이미 완료된 미션" + ) + }) + @PatchMapping("/{missionId}/complete") + public ApiResponse completeMission( + @Parameter(hidden = true) @LoginMemberId Long memberId, + @Parameter(description = "완료할 미션 ID", example = "1") + @PathVariable Long missionId) { + + // 서비스 호출 + MissionResponseDto.CompleteMissionDto response = + missionService.completeMission(memberId, missionId); + + return ApiResponse.onSuccess(GeneralSuccessCode.OK, response); + } + + /** + * 특정 가게의 미션 목록 조회 API + * GET /api/v1/restaurants/{restaurantId}/missions + * + * @param restaurantId 가게 ID (Path Variable) + * @param page 페이지 번호 (0부터 시작, 기본값: 0) + * @param size 페이지 크기 (1~100, 기본값: 10) + * @return 가게의 미션 목록 (페이징 정보 포함) + */ + @Operation( + summary = "특정 가게의 미션 목록 조회", + description = "특정 가게에 등록된 미션 목록을 조회합니다. 페이징을 지원하며, 마감일 임박순으로 정렬됩니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "미션 목록 조회 성공", + content = @Content(schema = @Schema(implementation = MissionResponseDto.RestaurantMissionListDto.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "잘못된 페이징 파라미터 (페이지 번호 음수, 크기 범위 초과 등)" + ) + }) + @GetMapping("/restaurants/{restaurantId}/missions") + public ApiResponse getMissionsByRestaurant( + @Parameter(description = "가게 ID", example = "1") + @PathVariable Long restaurantId, + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") + @Min(value = 0, message = "페이지 번호는 0 이상이어야 합니다.") + @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기 (1~100)", example = "10") + @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.") + @Max(value = 100, message = "페이지 크기는 100 이하여야 합니다.") + @RequestParam(defaultValue = "10") int size) { + + // Pageable 생성 + Pageable pageable = PageRequest.of(page, size); + + // 서비스 호출 + MissionResponseDto.RestaurantMissionListDto response = + missionService.getMissionsByRestaurant(restaurantId, pageable); + + return ApiResponse.onSuccess(GeneralSuccessCode.OK, response); } } diff --git a/sun/src/main/java/com/example/umc/domain/mission/dto/response/MissionResponseDto.java b/sun/src/main/java/com/example/umc/domain/mission/dto/response/MissionResponseDto.java index 82614ac..87d53e1 100644 --- a/sun/src/main/java/com/example/umc/domain/mission/dto/response/MissionResponseDto.java +++ b/sun/src/main/java/com/example/umc/domain/mission/dto/response/MissionResponseDto.java @@ -7,6 +7,8 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.stream.Collectors; public class MissionResponseDto { @@ -131,4 +133,91 @@ public static AvailableMissionDto from(Mission mission) { .build(); } } + + /** + * 미션 완료 응답 DTO + */ + @Getter + @Builder + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + public static class CompleteMissionDto { + private Long memberMissionId; + private Long missionId; + private String status; // "완료" + private String completedAt; // "2025.11.30" 형식 + + /** + * MemberMission Entity -> CompleteMissionDto 변환 + */ + public static CompleteMissionDto from(MemberMission memberMission) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd"); + return CompleteMissionDto.builder() + .memberMissionId(memberMission.getId()) + .missionId(memberMission.getMission().getId()) + .status("완료") + .completedAt(LocalDateTime.now().format(formatter)) + .build(); + } + } + + /** + * 특정 가게의 미션 DTO + */ + @Getter + @Builder + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + public static class RestaurantMissionDto { + private Long missionId; + private String restaurantName; + private String missionCondition; + private LocalDateTime deadline; + private Integer missionPoint; + + /** + * Mission Entity -> RestaurantMissionDto 변환 + */ + public static RestaurantMissionDto from(Mission mission) { + return RestaurantMissionDto.builder() + .missionId(mission.getId()) + .restaurantName(mission.getRestaurant().getRestaurantName()) + .missionCondition(mission.getMissionCondition()) + .deadline(mission.getDeadline()) + .missionPoint(mission.getMissionPoint()) + .build(); + } + } + + /** + * 특정 가게의 미션 목록 응답 DTO (페이징 포함) + */ + @Getter + @Builder + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + public static class RestaurantMissionListDto { + private List missions; + private int currentPage; + private int totalPages; + private long totalElements; + private boolean hasNext; + + /** + * Page -> RestaurantMissionListDto 변환 + */ + public static RestaurantMissionListDto from(Page missionPage) { + List missions = missionPage.getContent().stream() + .map(RestaurantMissionDto::from) + .collect(Collectors.toList()); + + return RestaurantMissionListDto.builder() + .missions(missions) + .currentPage(missionPage.getNumber()) + .totalPages(missionPage.getTotalPages()) + .totalElements(missionPage.getTotalElements()) + .hasNext(missionPage.hasNext()) + .build(); + } + } } diff --git a/sun/src/main/java/com/example/umc/domain/mission/entity/MemberMission.java b/sun/src/main/java/com/example/umc/domain/mission/entity/MemberMission.java index dd4291e..7c60992 100644 --- a/sun/src/main/java/com/example/umc/domain/mission/entity/MemberMission.java +++ b/sun/src/main/java/com/example/umc/domain/mission/entity/MemberMission.java @@ -34,4 +34,11 @@ public class MemberMission { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "mission_id", nullable = false) private Mission mission; + + /** + * 미션을 완료 상태로 변경 + */ + public void complete() { + this.isComplete = true; + } } diff --git a/sun/src/main/java/com/example/umc/domain/mission/repository/MemberMissionRepository.java b/sun/src/main/java/com/example/umc/domain/mission/repository/MemberMissionRepository.java index dade406..3c9c66a 100644 --- a/sun/src/main/java/com/example/umc/domain/mission/repository/MemberMissionRepository.java +++ b/sun/src/main/java/com/example/umc/domain/mission/repository/MemberMissionRepository.java @@ -49,4 +49,19 @@ Page findMissionsByMemberAndStatus( * @return 도전 중이면 true, 아니면 false */ boolean existsByMemberIdAndMissionId(Long memberId, Long missionId); + + /** + * 회원 ID와 미션 ID로 MemberMission 조회 + * @param memberId 회원 ID + * @param missionId 미션 ID + * @return MemberMission + */ + @Query("SELECT mm FROM MemberMission mm " + + "JOIN FETCH mm.mission m " + + "WHERE mm.member.id = :memberId " + + "AND mm.mission.id = :missionId") + java.util.Optional findByMemberIdAndMissionId( + @Param("memberId") Long memberId, + @Param("missionId") Long missionId + ); } diff --git a/sun/src/main/java/com/example/umc/domain/mission/repository/MissionRepository.java b/sun/src/main/java/com/example/umc/domain/mission/repository/MissionRepository.java index 36190e3..6fbdc03 100644 --- a/sun/src/main/java/com/example/umc/domain/mission/repository/MissionRepository.java +++ b/sun/src/main/java/com/example/umc/domain/mission/repository/MissionRepository.java @@ -44,4 +44,24 @@ Page findAvailableMissionsByRegion( @Param("baseAddressId") Long baseAddressId, Pageable pageable ); + + /** + * 특정 가게의 미션 목록 조회 + * - JOIN FETCH로 N+1 문제 해결 + * - deadline 오름차순 정렬 (마감일 임박순) + * + * @param restaurantId 가게 ID + * @param pageable 페이징 정보 + * @return 가게의 미션 목록 (페이징) + */ + @Query(value = "SELECT m FROM Mission m " + + "JOIN FETCH m.restaurant r " + + "WHERE r.id = :restaurantId " + + "ORDER BY m.deadline ASC", + countQuery = "SELECT COUNT(m) FROM Mission m " + + "WHERE m.restaurant.id = :restaurantId") + Page findByRestaurantId( + @Param("restaurantId") Long restaurantId, + Pageable pageable + ); } diff --git a/sun/src/main/java/com/example/umc/domain/mission/service/MissionService.java b/sun/src/main/java/com/example/umc/domain/mission/service/MissionService.java index 307d41e..5876115 100644 --- a/sun/src/main/java/com/example/umc/domain/mission/service/MissionService.java +++ b/sun/src/main/java/com/example/umc/domain/mission/service/MissionService.java @@ -145,4 +145,42 @@ public HomeDto getHomeScreen(Long memberId, Pageable pageable) { .availableMissions(availableMissions) .build(); } + + /** + * 특정 가게의 미션 목록 조회 (페이징) + * @param restaurantId 가게 ID + * @param pageable 페이징 정보 (페이지 번호, 크기, 정렬) + * @return 가게의 미션 목록 (페이징 정보 포함) + */ + public MissionResponseDto.RestaurantMissionListDto getMissionsByRestaurant(Long restaurantId, Pageable pageable) { + // 1. 가게의 미션 목록 조회 (페이징) + Page missionPage = missionRepository.findByRestaurantId(restaurantId, pageable); + + // 2. Entity -> DTO 변환 + return MissionResponseDto.RestaurantMissionListDto.from(missionPage); + } + + /** + * 진행 중인 미션을 완료로 변경 + * @param memberId JWT 토큰에서 추출한 회원 ID + * @param missionId 완료할 미션 ID + * @return 완료된 미션 정보 + */ + @Transactional + public MissionResponseDto.CompleteMissionDto completeMission(Long memberId, Long missionId) { + // 1. MemberMission 조회 + MemberMission memberMission = memberMissionRepository.findByMemberIdAndMissionId(memberId, missionId) + .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_MISSION_NOT_FOUND)); + + // 2. 이미 완료된 미션인지 확인 + if (memberMission.getIsComplete()) { + throw new CustomException(ErrorCode.MISSION_ALREADY_COMPLETED); + } + + // 3. 미션 완료 처리 (dirty checking으로 자동 업데이트) + memberMission.complete(); + + // 4. Entity -> DTO 변환하여 반환 + return MissionResponseDto.CompleteMissionDto.from(memberMission); + } } diff --git a/sun/src/main/java/com/example/umc/domain/review/controller/ReviewController.java b/sun/src/main/java/com/example/umc/domain/review/controller/ReviewController.java index 0ac8ed9..616d042 100644 --- a/sun/src/main/java/com/example/umc/domain/review/controller/ReviewController.java +++ b/sun/src/main/java/com/example/umc/domain/review/controller/ReviewController.java @@ -19,7 +19,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -66,10 +65,10 @@ public class ReviewController { ) }) @PostMapping - public ResponseEntity> createReview( + public ApiResponse createReview( @Valid @RequestBody ReviewRequestDto.CreateReviewDto request) { ReviewResponseDto.ReviewCreateDto response = reviewService.createReview(request); - return ResponseEntity.ok(ApiResponse.onSuccess(GeneralSuccessCode.CREATED, response)); + return ApiResponse.onSuccess(GeneralSuccessCode.CREATED, response); } /** @@ -98,7 +97,7 @@ public ResponseEntity> createRevi ) }) @GetMapping("/my") - public ResponseEntity> getMyReviews( + public ApiResponse getMyReviews( @Parameter(hidden = true) @LoginMemberId Long memberId, @Parameter(description = "가게 이름 (선택)", example = "맛있는 식당") @RequestParam(required = false) String restaurantName, @@ -130,6 +129,6 @@ public ResponseEntity> getMyRevie // 서비스 호출 ReviewResponseDto.MyReviewListDto response = reviewService.getMyReviews(memberId, filter, pageable); - return ResponseEntity.ok(ApiResponse.onSuccess(GeneralSuccessCode.OK, response)); + return ApiResponse.onSuccess(GeneralSuccessCode.OK, response); } } diff --git a/sun/src/main/java/com/example/umc/global/exception/ErrorCode.java b/sun/src/main/java/com/example/umc/global/exception/ErrorCode.java index 41b5c82..a1084f4 100644 --- a/sun/src/main/java/com/example/umc/global/exception/ErrorCode.java +++ b/sun/src/main/java/com/example/umc/global/exception/ErrorCode.java @@ -20,6 +20,8 @@ public enum ErrorCode implements BaseErrorCode { // Mission 관련 에러 MISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "MISSION-001", "미션을 찾을 수 없습니다."), ALREADY_CHALLENGING_MISSION(HttpStatus.CONFLICT, "MISSION-002", "이미 도전 중인 미션입니다."), + MEMBER_MISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "MISSION-003", "회원의 미션을 찾을 수 없습니다."), + MISSION_ALREADY_COMPLETED(HttpStatus.CONFLICT, "MISSION-004", "이미 완료된 미션입니다."), // 입력값 검증 에러 INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "COMMON-001", "잘못된 입력값입니다."),