diff --git a/src/main/java/com/example/umc9th/domain/mission/controller/MissionController.java b/src/main/java/com/example/umc9th/domain/mission/controller/MissionController.java index e88fe35..46806f5 100644 --- a/src/main/java/com/example/umc9th/domain/mission/controller/MissionController.java +++ b/src/main/java/com/example/umc9th/domain/mission/controller/MissionController.java @@ -5,14 +5,12 @@ import com.example.umc9th.domain.mission.service.MissionService; import com.example.umc9th.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import java.time.LocalDate; -import java.time.LocalDateTime; @RestController @RequestMapping("/api/missions") @@ -23,14 +21,25 @@ public class MissionController { @GetMapping("/my") @Operation(summary = "나의 미션 목록 조회 API", description = "진행중 또는 완료된 미션 목록을 커서 기반으로 조회합니다.") - public ApiResponse getMyMissions( + public ApiResponse getMyMissions( @RequestParam MissionStatus status, @RequestParam(required = false) LocalDate cursorDeadline, @RequestParam(required = false) Long cursorId, @RequestParam(defaultValue = "10") int size ) { - MissionResponse.MyMissionListDTO response = missionService.getMyMissions(1L, status, cursorDeadline, cursorId, size); + MissionResponse.MissionListDTO response = missionService.getMyMissions(1L, status, cursorDeadline, cursorId, size); return ApiResponse.onSuccess(response); } + @PatchMapping("/{mbmId}/complete") + @Operation(summary = "미션 진행 완료 상태 변경 API", description = "진행 중인 미션의 상태를 완료(COMPLETE)로 변경하고 변경 결과를 반환합니다.") + @Parameters({ + @Parameter(name = "mbmId", description = "변경할 MissionByMember의 ID") + }) + public ApiResponse completeMission( + @PathVariable(name = "mbmId") Long mbmId + ) { + MissionResponse.MissionDTO response = missionService.completeMission(mbmId); + return ApiResponse.onSuccess(response); + } } diff --git a/src/main/java/com/example/umc9th/domain/mission/converter/MissionConverter.java b/src/main/java/com/example/umc9th/domain/mission/converter/MissionConverter.java index 047954d..a8e57df 100644 --- a/src/main/java/com/example/umc9th/domain/mission/converter/MissionConverter.java +++ b/src/main/java/com/example/umc9th/domain/mission/converter/MissionConverter.java @@ -8,27 +8,27 @@ import org.springframework.data.domain.Slice; import java.time.LocalDate; -import java.time.LocalDateTime; import java.util.List; import java.util.stream.Collectors; public class MissionConverter { - public static MissionResponse.MyMissionDTO toMyMissionDTO(MissionByMember missionByMember) { + public static MissionResponse.MissionDTO toMyMissionDTO(MissionByMember missionByMember) { Mission mission = missionByMember.getMission(); - return MissionResponse.MyMissionDTO.builder() + return MissionResponse.MissionDTO.builder() .storeName(mission.getStore().getName()) .missionContent(mission.getContent()) .targetAmount(mission.getTargetAmount()) .rewardPoint(mission.getRewardPoint()) .deadline(mission.getDeadline()) + .missionStatus(missionByMember.getStatus()) .build(); } - public static MissionResponse.MyMissionListDTO toMyMissionListDTO(Slice missionByMemberSlice) { + public static MissionResponse.MissionListDTO toMyMissionListDTO(Slice missionByMemberSlice) { // 각 MissionByMember 엔터티 -> MyMissionDTO로 변환 -> list로 - List missionDTOList = missionByMemberSlice.getContent().stream() + List missionDTOList = missionByMemberSlice.getContent().stream() .map(MissionConverter::toMyMissionDTO) .collect(Collectors.toList()); @@ -46,7 +46,7 @@ public static MissionResponse.MyMissionListDTO toMyMissionListDTO(Slice MissionDTO 변환 + public static MissionResponse.MissionDTO toMissionDTO(Mission mission) { + return MissionResponse.MissionDTO.builder() + .storeName(mission.getStore().getName()) + .missionContent(mission.getContent()) + .targetAmount(mission.getTargetAmount()) + .rewardPoint(mission.getRewardPoint()) + .deadline(mission.getDeadline()) + .build(); + } } diff --git a/src/main/java/com/example/umc9th/domain/mission/dto/MissionResponse.java b/src/main/java/com/example/umc9th/domain/mission/dto/MissionResponse.java index 8c62b5a..bc83019 100644 --- a/src/main/java/com/example/umc9th/domain/mission/dto/MissionResponse.java +++ b/src/main/java/com/example/umc9th/domain/mission/dto/MissionResponse.java @@ -1,13 +1,12 @@ package com.example.umc9th.domain.mission.dto; +import com.example.umc9th.domain.mission.entity.enums.MissionStatus; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; -import lombok.NoArgsConstructor; import java.time.LocalDate; -import java.time.LocalDateTime; import java.util.List; public class MissionResponse { @@ -15,23 +14,24 @@ public class MissionResponse { @Getter @Builder @AllArgsConstructor - @Schema(description = "나의 미션 목록의 개별 미션 응답") - public static class MyMissionDTO { + @Schema(description = "미션 목록의 개별 미션 응답") + public static class MissionDTO { private String storeName; private String missionContent; private Integer rewardPoint; private Integer targetAmount; private LocalDate deadline; + private MissionStatus missionStatus; } @Getter @Builder @AllArgsConstructor - @Schema(description = "나의 미션 목록 조회 응답") - public static class MyMissionListDTO { + @Schema(description = "미션 목록 조회 응답") + public static class MissionListDTO { - private List missionList; + private List missionList; private Boolean hasNext; private LocalDate nextCursorDeadline; private Long nextCursorId; diff --git a/src/main/java/com/example/umc9th/domain/mission/entity/MissionByMember.java b/src/main/java/com/example/umc9th/domain/mission/entity/MissionByMember.java index 758df05..6fed697 100644 --- a/src/main/java/com/example/umc9th/domain/mission/entity/MissionByMember.java +++ b/src/main/java/com/example/umc9th/domain/mission/entity/MissionByMember.java @@ -34,4 +34,9 @@ public class MissionByMember extends BaseEntity { @ColumnDefault("false") private Boolean isReviewed = false; + + // 미션 상태 변경 메소드 + public void updateStatus(MissionStatus status) { + this.status = status; + } } diff --git a/src/main/java/com/example/umc9th/domain/mission/repository/MissionRepository.java b/src/main/java/com/example/umc9th/domain/mission/repository/MissionRepository.java index de450fd..5ad529e 100644 --- a/src/main/java/com/example/umc9th/domain/mission/repository/MissionRepository.java +++ b/src/main/java/com/example/umc9th/domain/mission/repository/MissionRepository.java @@ -28,4 +28,21 @@ Slice findAvailableMissionsByRegion( @Param("memberId") Long memberId, @Param("cursorId") Long cursorId, Pageable pageable); + + /** + * 특정 가게의 미션 목록을 커서 기반으로 조회 (미션 ID 기준 내림차순) + * @param storeId 조회할 가게의 ID + * @param cursorId 마지막으로 조회된 미션의 ID (첫 페이지는 null) + * @param pageable LIMIT 절과 페이징 처리를 위한 정보 + * @return Slice + */ + @Query("SELECT m FROM Mission m " + + "WHERE m.store.id = :storeId " + + " AND (:cursorId IS NULL OR m.id < :cursorId) " + // 커서 조건 + "ORDER BY m.id DESC") + Slice findMissionsByStore( + @Param("storeId") Long storeId, + @Param("cursorId") Long cursorId, + Pageable pageable); + } diff --git a/src/main/java/com/example/umc9th/domain/mission/service/MissionService.java b/src/main/java/com/example/umc9th/domain/mission/service/MissionService.java index 45d4509..4b731c2 100644 --- a/src/main/java/com/example/umc9th/domain/mission/service/MissionService.java +++ b/src/main/java/com/example/umc9th/domain/mission/service/MissionService.java @@ -1,14 +1,19 @@ package com.example.umc9th.domain.mission.service; import com.example.umc9th.domain.mission.dto.MissionResponse; +import com.example.umc9th.domain.mission.entity.Mission; import com.example.umc9th.domain.mission.entity.enums.MissionStatus; import java.time.LocalDate; -import java.time.LocalDateTime; public interface MissionService { // 나의 미션 목록 조회 - MissionResponse.MyMissionListDTO getMyMissions(Long memberId, MissionStatus status, LocalDate cursorDeadline, Long cursorId, int size); + MissionResponse.MissionListDTO getMyMissions(Long memberId, MissionStatus status, LocalDate cursorDeadline, Long cursorId, int size); + // 가게 미션 목록 조회 + MissionResponse.MissionListDTO getMissionListByStore(Long storeId, Long cursorId); + + // 미션 완료 처리 + MissionResponse.MissionDTO completeMission(Long mbmId); } diff --git a/src/main/java/com/example/umc9th/domain/mission/service/MissionServiceImpl.java b/src/main/java/com/example/umc9th/domain/mission/service/MissionServiceImpl.java index fe6e610..7a63495 100644 --- a/src/main/java/com/example/umc9th/domain/mission/service/MissionServiceImpl.java +++ b/src/main/java/com/example/umc9th/domain/mission/service/MissionServiceImpl.java @@ -2,17 +2,23 @@ import com.example.umc9th.domain.mission.converter.MissionConverter; import com.example.umc9th.domain.mission.dto.MissionResponse; +import com.example.umc9th.domain.mission.entity.Mission; import com.example.umc9th.domain.mission.entity.MissionByMember; import com.example.umc9th.domain.mission.entity.enums.MissionStatus; import com.example.umc9th.domain.mission.repository.MissionByMemberRepository; +import com.example.umc9th.domain.mission.repository.MissionRepository; +import com.example.umc9th.global.apiPayload.code.status.ErrorStatus; +import com.example.umc9th.global.apiPayload.exception.handler.ErrorHandler; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; -import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; @Service @Transactional(readOnly = true) @@ -20,9 +26,11 @@ public class MissionServiceImpl implements MissionService { private final MissionByMemberRepository missionByMemberRepository; + private static final int PAGE_SIZE = 10; + private final MissionRepository missionRepository; @Override - public MissionResponse.MyMissionListDTO getMyMissions(Long memberId, MissionStatus status, LocalDate cursorDeadline, Long cursorId, int size) { + public MissionResponse.MissionListDTO getMyMissions(Long memberId, MissionStatus status, LocalDate cursorDeadline, Long cursorId, int size) { // 1. Pageable 객체 생성 (커서 기반이므로 페이지 번호는 항상 0) PageRequest pageRequest = PageRequest.of(0, size); @@ -36,4 +44,58 @@ public MissionResponse.MyMissionListDTO getMyMissions(Long memberId, MissionStat return MissionConverter.toMyMissionListDTO(missionSlice); } + @Override + public MissionResponse.MissionListDTO getMissionListByStore(Long storeId, Long cursorId) { + + // 1. Pageable 객체 생성 + Pageable pageable = PageRequest.of(0, PAGE_SIZE); + + // 2. Repository 호출 + Slice missionSlice = missionRepository.findMissionsByStore(storeId, cursorId, pageable); + + // 3. DTO 변환 및 커서 계산 + List missionDTOList = missionSlice.getContent().stream() + .map(MissionConverter::toMissionDTO) + .collect(Collectors.toList()); + + Long nextCursorId = null; + LocalDate nextCursorDeadline = null; + + if (missionSlice.hasNext() && !missionDTOList.isEmpty()) { + // 다음 요청에 사용할 커서 + Mission lastMission = missionSlice.getContent().get(missionDTOList.size() - 1); + nextCursorId = lastMission.getId(); + nextCursorDeadline = lastMission.getDeadline(); + } + + // 4. 응답 DTO 반환 + return MissionResponse.MissionListDTO.builder() + .missionList(missionDTOList) + .hasNext(missionSlice.hasNext()) + .nextCursorDeadline(nextCursorDeadline) + .nextCursorId(nextCursorId) + .build(); + } + + // 미션 진행 완료 처리 + @Transactional + @Override + public MissionResponse.MissionDTO completeMission(Long mbmId) { + + // 1. MissionByMember 엔티티 조회 + MissionByMember mbm = missionByMemberRepository.findById(mbmId) + .orElseThrow(() -> new ErrorHandler(ErrorStatus.MISSION_BY_MEMBER_NOT_FOUND)); // 미션-멤버 관계 없음 + + // 2. 현재 상태 확인 (IN_PROGRESS인지 확인) + if (mbm.getStatus() != MissionStatus.IN_PROGRESS) { + throw new ErrorHandler(ErrorStatus.MISSION_STATUS_NOT_IN_PROGRESS); // 진행 중인 미션이 아님 + } + + // 3. 상태 변경 (COMPLETE) + // MissionByMember 엔티티에 updateStatus(MissionStatus status) 메서드가 존재한다고 가정 + mbm.updateStatus(MissionStatus.SUCCESS); + + // 4. 응답 DTO 변환 및 반환 + return MissionConverter.toMyMissionDTO(mbm); + } } diff --git a/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepository.java b/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepository.java index cde7845..d793dd5 100644 --- a/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepository.java +++ b/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepository.java @@ -2,6 +2,8 @@ import com.example.umc9th.domain.member.entity.Member; import com.example.umc9th.domain.review.entity.Review; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -28,4 +30,21 @@ void createReview(@Param("memberId") Long memberId, @Param("storeId") Long storeId, @Param("content") String content, @Param("rate") Float rate); + + + /** + * 가게별 리뷰 목록 조회 (커서 기반 페이징) + * - storeId: 해당 가게의 리뷰만 조회 + * - cursorId: 마지막으로 조회한 리뷰 ID (첫 페이지는 NULL) + * - Pageable: 페이징 사이즈(LIMIT) 처리 + */ + @Query("SELECT r FROM Review r " + + "WHERE r.store.id = :storeId " + + " AND (:cursorId IS NULL OR r.id < :cursorId) " + + "ORDER BY r.id DESC") + Slice findReviewsByStore( + @Param("storeId") Long storeId, + @Param("cursorId") Long cursorId, + Pageable pageable + ); } diff --git a/src/main/java/com/example/umc9th/domain/review/service/ReviewService.java b/src/main/java/com/example/umc9th/domain/review/service/ReviewService.java index 915e1b8..fe92c5c 100644 --- a/src/main/java/com/example/umc9th/domain/review/service/ReviewService.java +++ b/src/main/java/com/example/umc9th/domain/review/service/ReviewService.java @@ -15,4 +15,6 @@ ReviewResponse.ReviewListDTO getMyReviews( Integer rate, Long cursorId ); + + ReviewResponse.ReviewListDTO getReviewList(Long storeId, Long cursorId); } diff --git a/src/main/java/com/example/umc9th/domain/review/service/ReviewServiceImpl.java b/src/main/java/com/example/umc9th/domain/review/service/ReviewServiceImpl.java index b666676..8a571be 100644 --- a/src/main/java/com/example/umc9th/domain/review/service/ReviewServiceImpl.java +++ b/src/main/java/com/example/umc9th/domain/review/service/ReviewServiceImpl.java @@ -14,6 +14,8 @@ import com.example.umc9th.global.apiPayload.exception.handler.ErrorHandler; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -32,6 +34,8 @@ public class ReviewServiceImpl implements ReviewService { private final ReviewRepository reviewRepository; private final ReviewRepositoryCustom reviewRepositoryCustom; + private static final int PAGE_SIZE = 10; + // 리뷰 작성 @Transactional @Override @@ -89,5 +93,35 @@ public ReviewResponse.ReviewListDTO getMyReviews( .build(); } + // 가게별 리뷰 조회 + public ReviewResponse.ReviewListDTO getReviewList(Long storeId, Long cursorId) { + + Pageable pageable = PageRequest.of(0, PAGE_SIZE); + + Slice reviewSlice = reviewRepository.findReviewsByStore(storeId, cursorId, pageable); + + // 리뷰 ID 리스트 추출 + List reviewIds = reviewSlice.getContent().stream() + .map(Review::getId) + .toList(); + + // 리뷰별 이미지 Map 조회 (N+1 문제 방지를 위해 별도의 Batch 조회) + Map> reviewImageMap = reviewRepositoryCustom.findReviewImages(reviewIds); + + List dtoList = + ReviewConverter.toReviewResultDTOList(reviewSlice.getContent(), reviewImageMap); + + Long nextCursorId = null; + if (reviewSlice.hasNext() && !dtoList.isEmpty()) { + // 다음 페이지가 존재하고 DTO 리스트가 비어있지 않은 경우 + nextCursorId = dtoList.get(dtoList.size() - 1).getReviewId(); + } + + return ReviewResponse.ReviewListDTO.builder() + .reviewList(dtoList) + .hasNext(reviewSlice.hasNext()) + .nextCursorId(nextCursorId) + .build(); + } } diff --git a/src/main/java/com/example/umc9th/domain/store/controller/StoreController.java b/src/main/java/com/example/umc9th/domain/store/controller/StoreController.java index 8a5c985..42a195a 100644 --- a/src/main/java/com/example/umc9th/domain/store/controller/StoreController.java +++ b/src/main/java/com/example/umc9th/domain/store/controller/StoreController.java @@ -4,11 +4,17 @@ import com.example.umc9th.domain.mission.dto.MissionRequest; import com.example.umc9th.domain.mission.dto.MissionResponse; import com.example.umc9th.domain.mission.entity.Mission; +import com.example.umc9th.domain.mission.service.MissionService; +import com.example.umc9th.domain.review.dto.ReviewResponse; +import com.example.umc9th.domain.review.entity.Review; +import com.example.umc9th.domain.review.service.ReviewService; import com.example.umc9th.domain.store.dto.StoreResponse; import com.example.umc9th.domain.store.entity.enums.StoreSortType; import com.example.umc9th.domain.store.service.StoreService; import com.example.umc9th.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -19,6 +25,8 @@ public class StoreController { private final StoreService storeService; + private final ReviewService reviewService; + private final MissionService missionService; @Operation( summary = "커서 기반 가게 검색", @@ -55,4 +63,32 @@ public ApiResponse addMission( return ApiResponse.onSuccess(response); } + + @GetMapping("/{storeId}/reviews") + @Operation(summary = "가게 리뷰 목록 조회 API", description = "특정 가게의 리뷰 목록을 커서 기반으로 조회합니다.") + @Parameters({ + @Parameter(name = "storeId", description = "가게 ID"), + @Parameter(name = "cursorId", description = "마지막 리뷰 ID (첫 요청 시 비워두세요)") + }) + public ApiResponse getReviewList( + @PathVariable(name = "storeId") Long storeId, + @RequestParam(name = "cursorId", required = false) Long cursorId + ) { + ReviewResponse.ReviewListDTO response = reviewService.getReviewList(storeId, cursorId); + return ApiResponse.onSuccess(response); + } + + @GetMapping("/{storeId}/missions") + @Operation(summary = "가게 미션 목록 조회 API", description = "특정 가게의 미션 목록을 커서 기반으로 조회합니다.") + @Parameters({ + @Parameter(name = "storeId", description = "가게 ID"), + @Parameter(name = "cursorId", description = "마지막 미션 ID (첫 요청 시 비워두세요)", required = false) + }) + public ApiResponse getMissionListByStore( + @PathVariable(name = "storeId") Long storeId, + @RequestParam(name = "cursorId", required = false) Long cursorId + ) { + MissionResponse.MissionListDTO response = missionService.getMissionListByStore(storeId, cursorId); + return ApiResponse.onSuccess(response); + } } diff --git a/src/main/java/com/example/umc9th/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/example/umc9th/global/apiPayload/code/status/ErrorStatus.java index c55e12e..8828af8 100644 --- a/src/main/java/com/example/umc9th/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/example/umc9th/global/apiPayload/code/status/ErrorStatus.java @@ -20,8 +20,11 @@ public enum ErrorStatus implements BaseErrorCode { MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER4041", "존재하지 않는 회원입니다."), // 가게 관련 에러 - STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE4041", "존재하지 않는 가게 입니다."); + STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE4041", "존재하지 않는 가게 입니다."), + // 미션 관련 에러 + MISSION_BY_MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND,"MISSION4041", "존재하지 않는 유저 미션 입니다."), + MISSION_STATUS_NOT_IN_PROGRESS(HttpStatus.CONFLICT, "MISSION4091","해당 미션은 현재 진행 중(IN_PROGRESS) 상태가 아니므로 완료할 수 없습니다."); private final HttpStatus httpStatus; private final String code; private final String message;