diff --git a/src/main/java/com/example/umc9th/application/mission/dto/MissionResDTO.java b/src/main/java/com/example/umc9th/application/mission/dto/MissionResDTO.java new file mode 100644 index 0000000..157b1f0 --- /dev/null +++ b/src/main/java/com/example/umc9th/application/mission/dto/MissionResDTO.java @@ -0,0 +1,15 @@ +package com.example.umc9th.application.mission.dto; + +import lombok.Builder; + +public class MissionResDTO { + + @Builder + public record StoreMission( + Long missionId, + Integer rewardPoint, + String title, + String description + ) {} +} + diff --git a/src/main/java/com/example/umc9th/application/mission/dto/UserMissionResDTO.java b/src/main/java/com/example/umc9th/application/mission/dto/UserMissionResDTO.java index 8bdb7a1..51b6990 100644 --- a/src/main/java/com/example/umc9th/application/mission/dto/UserMissionResDTO.java +++ b/src/main/java/com/example/umc9th/application/mission/dto/UserMissionResDTO.java @@ -15,4 +15,4 @@ public record Challenge( UserMissionStatus status, LocalDateTime assignedAt ) {} -} +} \ No newline at end of file diff --git a/src/main/java/com/example/umc9th/application/mission/service/MissionQueryService.java b/src/main/java/com/example/umc9th/application/mission/service/MissionQueryService.java new file mode 100644 index 0000000..249d5a4 --- /dev/null +++ b/src/main/java/com/example/umc9th/application/mission/service/MissionQueryService.java @@ -0,0 +1,34 @@ +package com.example.umc9th.application.mission.service; + +import com.example.umc9th.application.mission.dto.MissionResDTO; +import com.example.umc9th.domain.mission.Mission; +import com.example.umc9th.domain.mission.converter.MissionConverter; +import com.example.umc9th.global.paging.PageResponse; +import com.example.umc9th.infrastructure.mission.MissionJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MissionQueryService { + + private final MissionJpaRepository missionRepository; + + public PageResponse getStoreMissions(Long storeId, int page) { + + Pageable pageable = PageRequest.of(page, 10); + + Page missions = missionRepository.findByStoreId(storeId, pageable); + + Page dtoPage = + missions.map(MissionConverter::toStoreMission); + + return PageResponse.from(dtoPage); + } +} + diff --git a/src/main/java/com/example/umc9th/application/mission/service/UserMissionQueryService.java b/src/main/java/com/example/umc9th/application/mission/service/UserMissionQueryService.java new file mode 100644 index 0000000..d8918b2 --- /dev/null +++ b/src/main/java/com/example/umc9th/application/mission/service/UserMissionQueryService.java @@ -0,0 +1,35 @@ +package com.example.umc9th.application.mission.service; + +import com.example.umc9th.application.mission.dto.UserMissionResDTO; +import com.example.umc9th.domain.mission.UserMission; +import com.example.umc9th.global.paging.PageResponse; +import com.example.umc9th.infrastructure.mission.UserMissionJpaRepository; +import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import com.example.umc9th.domain.mission.converter.UserMissionConverter; +import com.example.umc9th.domain.enums.UserMissionStatus; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserMissionQueryService { + + private final UserMissionJpaRepository userMissionRepository; + + public PageResponse getMyProgressMissions(Long userId, int page) { + Pageable pageable = PageRequest.of(page, 10); + + Page pageEntity = + userMissionRepository.findByUserIdAndStatus(userId, UserMissionStatus.IN_PROGRESS, pageable); + + Page dtoPage = + pageEntity.map(UserMissionConverter::toChallengeDTO); + + return PageResponse.from(dtoPage); + } +} + 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 new file mode 100644 index 0000000..dad0b06 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/mission/converter/MissionConverter.java @@ -0,0 +1,18 @@ +package com.example.umc9th.domain.mission.converter; + +import com.example.umc9th.application.mission.dto.MissionResDTO; +import com.example.umc9th.domain.mission.Mission; + +public class MissionConverter { + + public static MissionResDTO.StoreMission toStoreMission(Mission mission) { + return MissionResDTO.StoreMission.builder() + .missionId(mission.getId()) + // Mission 엔티티에 아직 rewardPoint가 없으므로 임시로 null + // TODO: rewardPoint 필드 생기면 mission.getRewardPoint()로 변경 + .rewardPoint(null) + .title(mission.getTitle()) + .description(mission.getDescription()) + .build(); + } +} diff --git a/src/main/java/com/example/umc9th/domain/mission/converter/UserMissionConverter.java b/src/main/java/com/example/umc9th/domain/mission/converter/UserMissionConverter.java index 36967d9..6f7b5cf 100644 --- a/src/main/java/com/example/umc9th/domain/mission/converter/UserMissionConverter.java +++ b/src/main/java/com/example/umc9th/domain/mission/converter/UserMissionConverter.java @@ -4,6 +4,7 @@ import com.example.umc9th.domain.enums.UserMissionStatus; import com.example.umc9th.domain.mission.Mission; import com.example.umc9th.domain.mission.UserMission; +import com.example.umc9th.domain.store.Store; import com.example.umc9th.domain.user.User; import java.time.LocalDateTime; diff --git a/src/main/java/com/example/umc9th/domain/review/converter/ReviewConverter.java b/src/main/java/com/example/umc9th/domain/review/converter/ReviewConverter.java index 5c05859..ea6b05a 100644 --- a/src/main/java/com/example/umc9th/domain/review/converter/ReviewConverter.java +++ b/src/main/java/com/example/umc9th/domain/review/converter/ReviewConverter.java @@ -25,13 +25,12 @@ public static Review toStoreReview( .build(); if (dto.photoUrls() != null) { - for (String url : dto.photoUrls()) { - ReviewPhoto photo = ReviewPhoto.builder() - .review(review) // 연관관계 주인 설정 - .url(url) - .build(); - review.getPhotos().add(photo); // 양방향 컬렉션 쪽에도 추가 - } + dto.photoUrls().stream() + .map(url -> ReviewPhoto.builder() + .review(review) // 연관관계 주인 설정 + .url(url) + .build()) + .forEach(photo -> review.getPhotos().add(photo)); // 양방향 컬렉션에도 추가 } return review; diff --git a/src/main/java/com/example/umc9th/global/apiPayload/code/GeneralErrorCode.java b/src/main/java/com/example/umc9th/global/apiPayload/code/GeneralErrorCode.java index d41e910..870d519 100644 --- a/src/main/java/com/example/umc9th/global/apiPayload/code/GeneralErrorCode.java +++ b/src/main/java/com/example/umc9th/global/apiPayload/code/GeneralErrorCode.java @@ -3,6 +3,7 @@ import lombok.AllArgsConstructor; import lombok.Getter; import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatus; @Getter @AllArgsConstructor @@ -23,6 +24,7 @@ public enum GeneralErrorCode implements BaseErrorCode{ INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500_1", "예기치 않은 서버 에러가 발생했습니다."), + INVALID_PAGE(HttpStatus.BAD_REQUEST, "PAGE_001", "page는 1 이상의 정수여야 합니다."), ; private final HttpStatus status; diff --git a/src/main/java/com/example/umc9th/global/config/WebConfig.java b/src/main/java/com/example/umc9th/global/config/WebConfig.java new file mode 100644 index 0000000..f24cfbb --- /dev/null +++ b/src/main/java/com/example/umc9th/global/config/WebConfig.java @@ -0,0 +1,22 @@ +package com.example.umc9th.global.config; + +import com.example.umc9th.global.resolver.PositivePageArgumentResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final PositivePageArgumentResolver positivePageArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(positivePageArgumentResolver); + } +} + diff --git a/src/main/java/com/example/umc9th/global/paging/PageResponse.java b/src/main/java/com/example/umc9th/global/paging/PageResponse.java new file mode 100644 index 0000000..37acabf --- /dev/null +++ b/src/main/java/com/example/umc9th/global/paging/PageResponse.java @@ -0,0 +1,32 @@ +package com.example.umc9th.global.paging; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.domain.Page; + +import java.util.List; + +// global/paging/PageResponse.java +@Getter +@Builder +public class PageResponse { + + private List content; + private int page; // 프론트 기준 1-based + private int size; + private long totalElements; + private int totalPages; + private boolean last; + + public static PageResponse from(Page page) { + return PageResponse.builder() + .content(page.getContent()) + .page(page.getNumber() + 1) // 0-based → 1-based + .size(page.getSize()) + .totalElements(page.getTotalElements()) + .totalPages(page.getTotalPages()) + .last(page.isLast()) + .build(); + } +} + diff --git a/src/main/java/com/example/umc9th/global/resolver/PositivePage.java b/src/main/java/com/example/umc9th/global/resolver/PositivePage.java new file mode 100644 index 0000000..476eaf2 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/resolver/PositivePage.java @@ -0,0 +1,13 @@ +package com.example.umc9th.global.resolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +// global/resolver/PositivePage.java +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface PositivePage { +} + diff --git a/src/main/java/com/example/umc9th/global/resolver/PositivePageArgumentResolver.java b/src/main/java/com/example/umc9th/global/resolver/PositivePageArgumentResolver.java new file mode 100644 index 0000000..fc1d367 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/resolver/PositivePageArgumentResolver.java @@ -0,0 +1,54 @@ +package com.example.umc9th.global.resolver; + +import com.example.umc9th.global.apiPayload.code.GeneralErrorCode; +import com.example.umc9th.global.apiPayload.exception.GeneralException; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public class PositivePageArgumentResolver implements HandlerMethodArgumentResolver { + + private static final String PARAM_NAME = "page"; + private static final int DEFAULT_PAGE = 1; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(PositivePage.class) + && (parameter.getParameterType().equals(Integer.class) + || parameter.getParameterType().equals(int.class)); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + + String raw = webRequest.getParameter(PARAM_NAME); + + int page; + if (raw == null || raw.isBlank()) { + page = DEFAULT_PAGE; + } else { + try { + page = Integer.parseInt(raw); + } catch (NumberFormatException e) { + throw new GeneralException(GeneralErrorCode.INVALID_PAGE); + } + } + + if (page <= 0) { + throw new GeneralException(GeneralErrorCode.INVALID_PAGE); + } + + // 프론트는 1부터, 서버는 0부터 사용 + return page - 1; + } +} + diff --git a/src/main/java/com/example/umc9th/infrastructure/mission/MissionJpaRepository.java b/src/main/java/com/example/umc9th/infrastructure/mission/MissionJpaRepository.java index 4284447..17abbf3 100644 --- a/src/main/java/com/example/umc9th/infrastructure/mission/MissionJpaRepository.java +++ b/src/main/java/com/example/umc9th/infrastructure/mission/MissionJpaRepository.java @@ -1,7 +1,10 @@ package com.example.umc9th.infrastructure.mission; import com.example.umc9th.domain.mission.Mission; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface MissionJpaRepository extends JpaRepository { + Page findByStoreId(Long storeId, Pageable pageable); } diff --git a/src/main/java/com/example/umc9th/infrastructure/mission/UserMissionJpaRepository.java b/src/main/java/com/example/umc9th/infrastructure/mission/UserMissionJpaRepository.java index ba27d18..4eca791 100644 --- a/src/main/java/com/example/umc9th/infrastructure/mission/UserMissionJpaRepository.java +++ b/src/main/java/com/example/umc9th/infrastructure/mission/UserMissionJpaRepository.java @@ -3,6 +3,7 @@ import com.example.umc9th.application.mission.dto.MissionCardDto; import com.example.umc9th.application.mission.dto.MyMissionRowDto; import com.example.umc9th.application.mission.dto.ReviewTargetDto; +import com.example.umc9th.domain.enums.UserMissionStatus; import com.example.umc9th.domain.mission.Mission; import com.example.umc9th.domain.mission.UserMission; import com.example.umc9th.domain.user.User; @@ -13,8 +14,13 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.Optional; + public interface UserMissionJpaRepository extends JpaRepository { + Page findByUserIdAndStatus(Long userId, UserMissionStatus status, Pageable pageable); + + Optional findByIdAndUserId(Long id, Long userId); // 이미 해당 미션 도전 중인지 체크 boolean existsByUserAndMission(User user, Mission mission); diff --git a/src/main/java/com/example/umc9th/presentation/mission/MissionController.java b/src/main/java/com/example/umc9th/presentation/mission/MissionController.java new file mode 100644 index 0000000..0d09b2c --- /dev/null +++ b/src/main/java/com/example/umc9th/presentation/mission/MissionController.java @@ -0,0 +1,33 @@ +package com.example.umc9th.presentation.mission; + +import com.example.umc9th.application.mission.dto.MissionResDTO; +import com.example.umc9th.application.mission.service.MissionQueryService; +import com.example.umc9th.global.apiPayload.ApiResponse; +import com.example.umc9th.global.apiPayload.code.BaseSuccessCode; +import com.example.umc9th.global.paging.PageResponse; +import com.example.umc9th.global.resolver.PositivePage; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/stores") +@Tag(name = "Mission", description = "미션 관련 API") +public class MissionController { + + private final MissionQueryService missionQueryService; + + @GetMapping("/{storeId}/missions") + @Operation(summary = "특정 가게의 미션 목록 조회") + public ApiResponse getStoreMissions( + @PathVariable Long storeId, + @PositivePage @RequestParam(name = "page", required = false) Integer page + ) { + BaseSuccessCode response = + (BaseSuccessCode) missionQueryService.getStoreMissions(storeId, page); + return ApiResponse.onSuccess(response); + } +} + diff --git a/src/main/java/com/example/umc9th/presentation/mission/UserMissionCommandController.java b/src/main/java/com/example/umc9th/presentation/mission/UserMissionCommandController.java index e502b9c..c1cc06e 100644 --- a/src/main/java/com/example/umc9th/presentation/mission/UserMissionCommandController.java +++ b/src/main/java/com/example/umc9th/presentation/mission/UserMissionCommandController.java @@ -25,3 +25,4 @@ public ApiResponse challengeMission( ); } } + diff --git a/src/main/java/com/example/umc9th/presentation/mission/UserMissionController.java b/src/main/java/com/example/umc9th/presentation/mission/UserMissionController.java new file mode 100644 index 0000000..e876cea --- /dev/null +++ b/src/main/java/com/example/umc9th/presentation/mission/UserMissionController.java @@ -0,0 +1,38 @@ +package com.example.umc9th.presentation.mission; + +import com.example.umc9th.application.mission.dto.UserMissionResDTO; +import com.example.umc9th.application.mission.service.UserMissionQueryService; +import com.example.umc9th.global.apiPayload.ApiResponse; +import com.example.umc9th.global.apiPayload.code.BaseSuccessCode; +import com.example.umc9th.global.paging.PageResponse; +import com.example.umc9th.global.resolver.PositivePage; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +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; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/missions/me") +@Tag(name = "UserMission", description = "사용자 미션 API") +public class UserMissionController { + + private final UserMissionQueryService userMissionQueryService; + + @GetMapping("/progress") + @Operation(summary = "내가 진행중인 미션 목록 조회") + public ApiResponse getMyProgressMissions( + @PositivePage @RequestParam(name = "page", required = false) Integer page + ) { + // TODO: 나중에 Spring Security 붙이면 로그인 사용자 ID로 변경 + Long userId = 1L; + + BaseSuccessCode result = + (BaseSuccessCode) userMissionQueryService.getMyProgressMissions(userId, page); + + return ApiResponse.onSuccess(result); + } +} diff --git a/src/main/java/com/example/umc9th/presentation/review/ReviewSearchController.java b/src/main/java/com/example/umc9th/presentation/review/ReviewSearchController.java index 2c6bdc4..40139ce 100644 --- a/src/main/java/com/example/umc9th/presentation/review/ReviewSearchController.java +++ b/src/main/java/com/example/umc9th/presentation/review/ReviewSearchController.java @@ -2,6 +2,7 @@ import com.example.umc9th.application.review.query.ReviewQueryService; import com.example.umc9th.domain.review.Review; +import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -28,6 +29,7 @@ public List searchReview( } // 페이징 버전 + @Operation(summary = "내가 작성한 리뷰 목록 조회") @GetMapping("/reviews/search/page") public Page searchReviewPage( @RequestHeader("X-USER-ID") Long userId,