diff --git a/.gitignore b/.gitignore index 01fcd9d9..bbc2d2f7 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,7 @@ src/main/resources/application-prod.yml src/main/resources/firebase/ ### Claude Code ### +CLAUDE.md ### macOS ### .DS_Store @@ -59,3 +60,6 @@ src/main/resources/firebase/ ### Node ### node_modules/ + + +/loadtest \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeHomeCurationController.java b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeHomeCurationController.java new file mode 100644 index 00000000..2ef7775f --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeHomeCurationController.java @@ -0,0 +1,92 @@ +package com.assu.server.domain.backoffice.controller; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.assu.server.domain.backoffice.annotation.BackofficeAudited; +import com.assu.server.domain.backoffice.dto.BackofficeCurationTitleUpdateRequestDTO; +import com.assu.server.domain.backoffice.dto.BackofficeHomeAutoRecommendResponseDTO; +import com.assu.server.domain.backoffice.dto.BackofficeHomeCurationResponseDTO; +import com.assu.server.domain.backoffice.dto.BackofficeHomeCurationUpdateRequestDTO; +import com.assu.server.domain.backoffice.service.BackofficeHomeCurationService; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Backoffice Home Curation", description = "백오피스 학생 홈 큐레이션 관리 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/backoffice/home/curation") +@PreAuthorize("hasRole('BACKOFFICE')") +public class BackofficeHomeCurationController { + + private final BackofficeHomeCurationService backofficeHomeCurationService; + + @BackofficeAudited(action = "HOME_CURATION_READ") + @Operation( + summary = "홈 화면 큐레이션 현재 설정 조회 API (백오피스용)", + description = "현재 백오피스에 등록되어 학생 홈에 노출 중인 큐레이션 설정(제목, 상단 추천 업체, 2개 그룹 x 각 2개 업체)을 가져옵니다.\n\n" + + "**Response:**\n" + + " - 성공 시 200(OK)과 `BackofficeHomeCurationResponseDTO` 반환" + ) + @GetMapping + public BaseResponse getHomeCuration() { + return BaseResponse.onSuccess(SuccessStatus._OK, backofficeHomeCurationService.getHomeCuration()); + } + + @BackofficeAudited(action = "HOME_CURATION_UPDATE") + @Operation( + summary = "전체 큐레이션 및 추천 업체 직접 저장 API (백오피스용)", + description = "상단 추천 업체 및 2개 그룹(각 그룹당 2개 매장)을 직접 지정하여 저장합니다.\n\n" + + "**Request Body:**\n" + + " - `title`: 큐레이션 섹션 제목\n" + + " - `featuredStoreId`: 상단 추천 업체 ID\n" + + " - `featuredDiscountContent`: 상단 추천 할인 문구 (선택)\n" + + " - `curationLists`: 2개 그룹(각 그룹당 2개 상점) 정보\n\n" + + "**Response:**\n" + + " - 성공 시 200(OK)과 수정된 `BackofficeHomeCurationResponseDTO` 반환" + ) + @PutMapping + public BaseResponse updateHomeCuration( + @RequestBody @Valid BackofficeHomeCurationUpdateRequestDTO request + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, backofficeHomeCurationService.updateHomeCuration(request)); + } + + @BackofficeAudited(action = "HOME_CURATION_TITLE_UPDATE") + @Operation( + summary = "큐레이션 섹션 제목 단독 수정 API (백오피스용)", + description = "추천 업체 목록은 그대로 두고 제목 텍스트만 변경할 때 사용합니다.\n\n" + + "**Request Body:**\n" + + " - `title`: 수정할 큐레이션 섹션 제목 (예: {name}님을 위한 특별 혜택)\n\n" + + "**Response:**\n" + + " - 성공 시 200(OK)과 수정된 `BackofficeHomeCurationResponseDTO` 반환" + ) + @PatchMapping("/title") + public BaseResponse updateCurationTitle( + @RequestBody @Valid BackofficeCurationTitleUpdateRequestDTO request + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, backofficeHomeCurationService.updateCurationTitle(request)); + } + + @BackofficeAudited(action = "HOME_CURATION_AUTO_RECOMMEND_READ") + @Operation( + summary = "추천 업체 자동 추출 API (백오피스 서브 기능 버튼용)", + description = "평점 및 활성 매장 기반으로 상단 추천 1곳 + 2개 그룹(각 2곳)을 자동 선별합니다. 백오피스 UI의 [자동 추천 불러오기] 버튼 클릭 시 호출하여 폼에 자동 입력해 주는 용도입니다.\n\n" + + "**Response:**\n" + + " - 성공 시 200(OK)과 `BackofficeHomeAutoRecommendResponseDTO` 반환" + ) + @GetMapping("/auto-recommend") + public BaseResponse getAutoRecommendedCuration() { + return BaseResponse.onSuccess(SuccessStatus._OK, backofficeHomeCurationService.getAutoRecommendedCuration()); + } +} diff --git a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeMemberController.java b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeMemberController.java index 46269b0b..7aac4dac 100644 --- a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeMemberController.java +++ b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeMemberController.java @@ -13,6 +13,7 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; @@ -53,7 +54,7 @@ public BaseResponse> listMembers( @RequestParam(required = false) UserRole role, @RequestParam(required = false) ActivationStatus status, @RequestParam(required = false) Boolean deleted, - @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + @ParameterObject @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable ) { return BaseResponse.onSuccess( SuccessStatus._OK, @@ -73,7 +74,7 @@ public BaseResponse> listMembers( ) @GetMapping("/deleted") public BaseResponse> listDeletedMembers( - @PageableDefault(size = 20, sort = "deletedAt", direction = Sort.Direction.DESC) Pageable pageable + @ParameterObject @PageableDefault(size = 20, sort = "deletedAt", direction = Sort.Direction.DESC) Pageable pageable ) { return BaseResponse.onSuccess( SuccessStatus._OK, diff --git a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficePaperController.java b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficePaperController.java index 91a74222..1777caa0 100644 --- a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficePaperController.java +++ b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficePaperController.java @@ -1,5 +1,6 @@ package com.assu.server.domain.backoffice.controller; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -66,7 +67,7 @@ public class BackofficePaperController { ) @GetMapping public BaseResponse> getPapers( - @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + @ParameterObject @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable ) { return BaseResponse.onSuccess(SuccessStatus._OK, backofficePaperService.getPapers(pageable)); } diff --git a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficePartnershipController.java b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficePartnershipController.java index 248fbbe8..215b8609 100644 --- a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficePartnershipController.java +++ b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficePartnershipController.java @@ -1,5 +1,6 @@ package com.assu.server.domain.backoffice.controller; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -60,7 +61,7 @@ public class BackofficePartnershipController { ) @GetMapping public BaseResponse> getPartnerships( - @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + @ParameterObject @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable ) { return BaseResponse.onSuccess(SuccessStatus._OK, backofficePartnershipService.getPartnerships(pageable)); } @@ -98,7 +99,7 @@ public BaseResponse> getPartnerships( @GetMapping("/admin/{adminId}") public BaseResponse> getPartnershipsByAdmin( @PathVariable @Parameter(description = "학생회 ID", required = true) Long adminId, - @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + @ParameterObject @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable ) { return BaseResponse.onSuccess(SuccessStatus._OK, backofficePartnershipService.getPartnershipsByAdmin(adminId, pageable)); } @@ -136,7 +137,7 @@ public BaseResponse> getPartnershipsByAdmin( @GetMapping("/store/{storeId}") public BaseResponse> getPartnershipsByStore( @PathVariable @Parameter(description = "가게 ID", required = true) Long storeId, - @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + @ParameterObject @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable ) { return BaseResponse.onSuccess(SuccessStatus._OK, backofficePartnershipService.getPartnershipsByStore(storeId, pageable)); } diff --git a/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeCurationTitleUpdateRequestDTO.java b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeCurationTitleUpdateRequestDTO.java new file mode 100644 index 00000000..b688c227 --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeCurationTitleUpdateRequestDTO.java @@ -0,0 +1,10 @@ +package com.assu.server.domain.backoffice.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record BackofficeCurationTitleUpdateRequestDTO( + @NotBlank + @Schema(description = "수정할 큐레이션 섹션 제목 ({name} 플레이스홀더 사용 가능)", example = "{name}님을 위한 제휴") + String title +) {} diff --git a/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeHomeAutoRecommendResponseDTO.java b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeHomeAutoRecommendResponseDTO.java new file mode 100644 index 00000000..ceca3304 --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeHomeAutoRecommendResponseDTO.java @@ -0,0 +1,103 @@ +package com.assu.server.domain.backoffice.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +public record BackofficeHomeAutoRecommendResponseDTO( + @Schema(description = "추천 기본 섹션 제목", example = "{name}님을 위한 제휴") + String suggestedTitle, + + @Schema( + description = "자동 추출된 상단 추천 업체", + example = "{\"storeId\": 1, \"storeName\": \"더진국 숭실대점\", \"discountContent\": \"전 메뉴 1,000원 할인 또는 음료수 증정\", \"storeCategory\": \"RESTAURANT\"}" + ) + AutoFeaturedDTO featuredRecommendation, + + @Schema( + description = "자동 추출된 추천 큐레이션 2개 그룹 (각 2개 업체)", + example = "[{\"groupIndex\": 1, \"groupTitle\": \"추천 제휴 1\", \"stores\": [{\"storeId\": 10, \"storeName\": \"가게 1\", \"discountContent\": \"10% 할인\", \"storeCategory\": \"RESTAURANT\", \"sortOrder\": 1}, {\"storeId\": 11, \"storeName\": \"가게 2\", \"discountContent\": \"음료 증정\", \"storeCategory\": \"CAFE\", \"sortOrder\": 2}]}, {\"groupIndex\": 2, \"groupTitle\": \"추천 제휴 2\", \"stores\": [{\"storeId\": 20, \"storeName\": \"가게 3\", \"discountContent\": \"500원 할인\", \"storeCategory\": \"PUB\", \"sortOrder\": 1}, {\"storeId\": 21, \"storeName\": \"가게 4\", \"discountContent\": \"사이드 무료\", \"storeCategory\": \"RESTAURANT\", \"sortOrder\": 2}]}]" + ) + List curationLists +) { + + public record AutoFeaturedDTO( + @Schema(description = "추천 업체 ID", example = "1") + Long storeId, + + @Schema(description = "추천 업체 이름", example = "더진국 숭실대점") + String storeName, + + @Schema(description = "추천 할인 내용", example = "전 메뉴 1,000원 할인 또는 음료수 증정") + String discountContent, + + @Schema(description = "업체 카테고리", example = "RESTAURANT") + String storeCategory + ) { + public static AutoFeaturedDTO of( + Long storeId, + String storeName, + String discountContent, + String storeCategory + ) { + return new AutoFeaturedDTO(storeId, storeName, discountContent, storeCategory); + } + } + + public record AutoGroupDTO( + @Schema(description = "그룹 인덱스 (1 또는 2)", example = "1") + Integer groupIndex, + + @Schema(description = "그룹 소제목", example = "추천 제휴 1") + String groupTitle, + + @Schema( + description = "추천 업체 2개 목록", + example = "[{\"storeId\": 10, \"storeName\": \"가게 1\", \"discountContent\": \"10% 할인\", \"storeCategory\": \"RESTAURANT\", \"sortOrder\": 1}, {\"storeId\": 11, \"storeName\": \"가게 2\", \"discountContent\": \"음료 증정\", \"storeCategory\": \"CAFE\", \"sortOrder\": 2}]" + ) + List stores + ) { + public static AutoGroupDTO of( + Integer groupIndex, + String groupTitle, + List stores + ) { + return new AutoGroupDTO(groupIndex, groupTitle, stores); + } + } + + public record AutoStoreDTO( + @Schema(description = "가게 ID", example = "2") + Long storeId, + + @Schema(description = "가게 이름", example = "스타벅스 숭실대점") + String storeName, + + @Schema(description = "자동 추출된 할인 내용", example = "아메리카노 사이즈업") + String discountContent, + + @Schema(description = "가게 카테고리", example = "CAFE") + String storeCategory, + + @Schema(description = "노출 순서", example = "1") + Integer sortOrder + ) { + public static AutoStoreDTO of( + Long storeId, + String storeName, + String discountContent, + String storeCategory, + Integer sortOrder + ) { + return new AutoStoreDTO(storeId, storeName, discountContent, storeCategory, sortOrder); + } + } + + public static BackofficeHomeAutoRecommendResponseDTO of( + String suggestedTitle, + AutoFeaturedDTO featuredRecommendation, + List curationLists + ) { + return new BackofficeHomeAutoRecommendResponseDTO(suggestedTitle, featuredRecommendation, curationLists); + } +} diff --git a/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeHomeCurationResponseDTO.java b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeHomeCurationResponseDTO.java new file mode 100644 index 00000000..51519221 --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeHomeCurationResponseDTO.java @@ -0,0 +1,107 @@ +package com.assu.server.domain.backoffice.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +public record BackofficeHomeCurationResponseDTO( + @Schema(description = "큐레이션 설정 고유 ID", example = "1") + Long curationId, + + @Schema(description = "설정된 큐레이션 제목 템플릿", example = "{name}님을 위한 제휴") + String title, + + @Schema( + description = "상단 추천 업체 및 할인 정보", + example = "{\"storeId\": 1, \"storeName\": \"더진국 숭실대점\", \"discountContent\": \"전 메뉴 1,000원 할인 또는 음료수 증정\", \"storeCategory\": \"RESTAURANT\"}" + ) + FeaturedDTO featuredRecommendation, + + @Schema( + description = "추천 큐레이션 그룹 목록 (2개 그룹 x 2개 업체)", + example = "[{\"groupIndex\": 1, \"groupTitle\": \"추천 제휴 1\", \"stores\": [{\"storeId\": 10, \"storeName\": \"가게 A\", \"discountContent\": \"10% 할인\", \"storeCategory\": \"RESTAURANT\", \"sortOrder\": 1}, {\"storeId\": 11, \"storeName\": \"가게 B\", \"discountContent\": \"음료 무료\", \"storeCategory\": \"RESTAURANT\", \"sortOrder\": 2}]}, {\"groupIndex\": 2, \"groupTitle\": \"추천 제휴 2\", \"stores\": [{\"storeId\": 20, \"storeName\": \"가게 C\", \"discountContent\": \"사이즈업\", \"storeCategory\": \"CAFE\", \"sortOrder\": 1}, {\"storeId\": 21, \"storeName\": \"가게 D\", \"discountContent\": \"500원 할인\", \"storeCategory\": \"CAFE\", \"sortOrder\": 2}]}]" + ) + List curationLists +) { + + public record FeaturedDTO( + @Schema(description = "추천 업체 ID", example = "1") + Long storeId, + + @Schema(description = "추천 업체 이름", example = "더진국 숭실대점") + String storeName, + + @Schema(description = "추천 할인 내용", example = "전 메뉴 1,000원 할인 또는 음료수 증정") + String discountContent, + + @Schema(description = "업체 카테고리", example = "RESTAURANT") + String storeCategory + ) { + public static FeaturedDTO of( + Long storeId, + String storeName, + String discountContent, + String storeCategory + ) { + return new FeaturedDTO(storeId, storeName, discountContent, storeCategory); + } + } + + public record GroupDTO( + @Schema(description = "그룹 인덱스 (1 또는 2)", example = "1") + Integer groupIndex, + + @Schema(description = "그룹 소제목", example = "인기 식사 혜택") + String groupTitle, + + @Schema( + description = "그룹 내 추천 업체 목록 (2개)", + example = "[{\"storeId\": 10, \"storeName\": \"가게 A\", \"discountContent\": \"10% 할인\", \"storeCategory\": \"RESTAURANT\", \"sortOrder\": 1}, {\"storeId\": 11, \"storeName\": \"가게 B\", \"discountContent\": \"음료 무료\", \"storeCategory\": \"RESTAURANT\", \"sortOrder\": 2}]" + ) + List stores + ) { + public static GroupDTO of( + Integer groupIndex, + String groupTitle, + List stores + ) { + return new GroupDTO(groupIndex, groupTitle, stores); + } + } + + public record StoreItemDTO( + @Schema(description = "가게 ID", example = "2") + Long storeId, + + @Schema(description = "가게 이름", example = "스타벅스 숭실대점") + String storeName, + + @Schema(description = "할인 및 혜택 내용", example = "아메리카노 사이즈업") + String discountContent, + + @Schema(description = "가게 카테고리", example = "CAFE") + String storeCategory, + + @Schema(description = "노출 순서", example = "1") + Integer sortOrder + ) { + public static StoreItemDTO of( + Long storeId, + String storeName, + String discountContent, + String storeCategory, + Integer sortOrder + ) { + return new StoreItemDTO(storeId, storeName, discountContent, storeCategory, sortOrder); + } + } + + public static BackofficeHomeCurationResponseDTO of( + Long curationId, + String title, + FeaturedDTO featuredRecommendation, + List curationLists + ) { + return new BackofficeHomeCurationResponseDTO(curationId, title, featuredRecommendation, curationLists); + } +} diff --git a/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeHomeCurationUpdateRequestDTO.java b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeHomeCurationUpdateRequestDTO.java new file mode 100644 index 00000000..158b576c --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeHomeCurationUpdateRequestDTO.java @@ -0,0 +1,66 @@ +package com.assu.server.domain.backoffice.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public record BackofficeHomeCurationUpdateRequestDTO( + @NotBlank + @Schema(description = "큐레이션 섹션 제목 ({name} 플레이스홀더 사용 가능)", example = "{name}님을 위한 제휴") + String title, + + @NotNull + @Schema(description = "상단 추천 업체 ID", example = "1") + Long featuredStoreId, + + @Size(max = 255) + @Schema(description = "상단 추천 할인 내용 문구 (미입력 시 제휴 기본 내용 적용)", example = "전 메뉴 1,000원 할인 또는 음료수 증정") + String featuredDiscountContent, + + @NotEmpty + @Size(min = 2, max = 2) + @Valid + @Schema( + description = "추천 업체 그룹 목록 (반드시 2개 그룹)", + example = "[{\"groupIndex\": 1, \"groupTitle\": \"인기 식사 혜택\", \"stores\": [{\"storeId\": 10, \"customDiscountContent\": \"10% 할인\", \"sortOrder\": 1}, {\"storeId\": 11, \"customDiscountContent\": \"음료 무료\", \"sortOrder\": 2}]}, {\"groupIndex\": 2, \"groupTitle\": \"카페/디저트\", \"stores\": [{\"storeId\": 20, \"customDiscountContent\": \"사이즈업\", \"sortOrder\": 1}, {\"storeId\": 21, \"customDiscountContent\": \"500원 할인\", \"sortOrder\": 2}]}]" + ) + List curationLists +) { + + public record GroupUpdateRequestDTO( + @NotNull + @Schema(description = "그룹 인덱스 (1 또는 2)", example = "1") + Integer groupIndex, + + @Schema(description = "그룹 소제목", example = "인기 식사 혜택") + String groupTitle, + + @NotEmpty + @Size(min = 2, max = 2) + @Valid + @Schema( + description = "그룹 내 추천 업체 목록 (반드시 2개)", + example = "[{\"storeId\": 10, \"customDiscountContent\": \"10% 할인\", \"sortOrder\": 1}, {\"storeId\": 11, \"customDiscountContent\": \"음료 무료\", \"sortOrder\": 2}]" + ) + List stores + ) {} + + public record StoreItemRequestDTO( + @NotNull + @Schema(description = "추천할 가게 ID", example = "2") + Long storeId, + + @Size(max = 255) + @Schema(description = "할인 및 혜택 문구 직접 지정 (선택)", example = "아메리카노 사이즈업") + String customDiscountContent, + + @NotNull + @Schema(description = "노출 순서 (1 또는 2)", example = "1") + Integer sortOrder + ) {} +} diff --git a/src/main/java/com/assu/server/domain/backoffice/service/BackofficeHomeCurationService.java b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeHomeCurationService.java new file mode 100644 index 00000000..1ac4ea22 --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeHomeCurationService.java @@ -0,0 +1,13 @@ +package com.assu.server.domain.backoffice.service; + +import com.assu.server.domain.backoffice.dto.BackofficeCurationTitleUpdateRequestDTO; +import com.assu.server.domain.backoffice.dto.BackofficeHomeAutoRecommendResponseDTO; +import com.assu.server.domain.backoffice.dto.BackofficeHomeCurationResponseDTO; +import com.assu.server.domain.backoffice.dto.BackofficeHomeCurationUpdateRequestDTO; + +public interface BackofficeHomeCurationService { + BackofficeHomeCurationResponseDTO getHomeCuration(); + BackofficeHomeCurationResponseDTO updateCurationTitle(BackofficeCurationTitleUpdateRequestDTO request); + BackofficeHomeCurationResponseDTO updateHomeCuration(BackofficeHomeCurationUpdateRequestDTO request); + BackofficeHomeAutoRecommendResponseDTO getAutoRecommendedCuration(); +} diff --git a/src/main/java/com/assu/server/domain/backoffice/service/BackofficeHomeCurationServiceImpl.java b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeHomeCurationServiceImpl.java new file mode 100644 index 00000000..1a128a61 --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeHomeCurationServiceImpl.java @@ -0,0 +1,271 @@ +package com.assu.server.domain.backoffice.service; + +import com.assu.server.domain.backoffice.dto.BackofficeCurationTitleUpdateRequestDTO; +import com.assu.server.domain.backoffice.dto.BackofficeHomeAutoRecommendResponseDTO; +import com.assu.server.domain.backoffice.dto.BackofficeHomeCurationResponseDTO; +import com.assu.server.domain.backoffice.dto.BackofficeHomeCurationUpdateRequestDTO; +import com.assu.server.domain.partnership.dto.PaperContentResponseDTO; +import com.assu.server.domain.partnership.entity.PaperContent; +import com.assu.server.domain.partnership.repository.PaperContentRepository; +import com.assu.server.domain.store.entity.Store; +import com.assu.server.domain.store.repository.StoreRepository; +import com.assu.server.domain.student.entity.HomeCuration; +import com.assu.server.domain.student.entity.HomeCurationItem; +import com.assu.server.domain.student.repository.HomeCurationItemRepository; +import com.assu.server.domain.student.repository.HomeCurationRepository; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional +public class BackofficeHomeCurationServiceImpl implements BackofficeHomeCurationService { + + private final HomeCurationRepository homeCurationRepository; + private final HomeCurationItemRepository homeCurationItemRepository; + private final StoreRepository storeRepository; + private final PaperContentRepository paperContentRepository; + + private static final String DEFAULT_TITLE = "{name}님을 위한 제휴"; + + @Override + @Transactional(readOnly = true) + public BackofficeHomeCurationResponseDTO getHomeCuration() { + HomeCuration curation = homeCurationRepository.findLatest() + .orElse(null); + + if (curation == null) { + return BackofficeHomeCurationResponseDTO.of( + null, + DEFAULT_TITLE, + null, + List.of( + BackofficeHomeCurationResponseDTO.GroupDTO.of(1, "추천 제휴 1", List.of()), + BackofficeHomeCurationResponseDTO.GroupDTO.of(2, "추천 제휴 2", List.of()) + ) + ); + } + + List items = homeCurationItemRepository.findByHomeCurationIdWithStoreAndPartner(curation.getId()); + return mapToBackofficeResponseDTO(curation, items); + } + + @Override + public BackofficeHomeCurationResponseDTO updateCurationTitle(BackofficeCurationTitleUpdateRequestDTO request) { + HomeCuration curation = homeCurationRepository.findTopByOrderByIdDesc() + .orElseGet(() -> homeCurationRepository.save( + HomeCuration.builder() + .title(request.title()) + .build() + )); + + curation.updateTitle(request.title()); + homeCurationRepository.save(curation); + + List items = homeCurationItemRepository.findByHomeCurationIdWithStoreAndPartner(curation.getId()); + return mapToBackofficeResponseDTO(curation, items); + } + + @Override + public BackofficeHomeCurationResponseDTO updateHomeCuration(BackofficeHomeCurationUpdateRequestDTO request) { + validateCurationGroups(request.curationLists()); + + Store featuredStore = storeRepository.findById(request.featuredStoreId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.NO_SUCH_STORE)); + + HomeCuration curation = homeCurationRepository.findTopByOrderByIdDesc() + .orElseGet(() -> HomeCuration.builder() + .title(request.title()) + .build() + ); + + curation.updateTitle(request.title()); + curation.updateFeatured(featuredStore, request.featuredDiscountContent()); + + curation.getItems().clear(); + + for (BackofficeHomeCurationUpdateRequestDTO.GroupUpdateRequestDTO groupReq : request.curationLists()) { + for (BackofficeHomeCurationUpdateRequestDTO.StoreItemRequestDTO storeReq : groupReq.stores()) { + Store store = storeRepository.findById(storeReq.storeId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.NO_SUCH_STORE)); + + HomeCurationItem item = HomeCurationItem.builder() + .homeCuration(curation) + .groupIndex(groupReq.groupIndex()) + .groupTitle(groupReq.groupTitle() != null ? groupReq.groupTitle() : "추천 제휴 " + groupReq.groupIndex()) + .store(store) + .customDiscountContent(storeReq.customDiscountContent()) + .sortOrder(storeReq.sortOrder()) + .build(); + + curation.getItems().add(item); + } + } + + HomeCuration savedCuration = homeCurationRepository.save(curation); + List items = homeCurationItemRepository.findByHomeCurationIdWithStoreAndPartner(savedCuration.getId()); + return mapToBackofficeResponseDTO(savedCuration, items); + } + + private void validateCurationGroups(List curationLists) { + if (curationLists == null || curationLists.size() != 2) { + throw new GeneralException(ErrorStatus._BAD_REQUEST); + } + Set groupIndices = curationLists.stream() + .map(BackofficeHomeCurationUpdateRequestDTO.GroupUpdateRequestDTO::groupIndex) + .collect(Collectors.toSet()); + if (!Set.of(1, 2).equals(groupIndices)) { + throw new GeneralException(ErrorStatus._BAD_REQUEST); + } + + for (BackofficeHomeCurationUpdateRequestDTO.GroupUpdateRequestDTO groupReq : curationLists) { + if (groupReq.stores() == null || groupReq.stores().size() != 2) { + throw new GeneralException(ErrorStatus._BAD_REQUEST); + } + Set sortOrders = groupReq.stores().stream() + .map(BackofficeHomeCurationUpdateRequestDTO.StoreItemRequestDTO::sortOrder) + .collect(Collectors.toSet()); + if (!Set.of(1, 2).equals(sortOrders)) { + throw new GeneralException(ErrorStatus._BAD_REQUEST); + } + } + } + + @Override + @Transactional(readOnly = true) + public BackofficeHomeAutoRecommendResponseDTO getAutoRecommendedCuration() { + List candidateStores = storeRepository.findAll().stream() + .sorted(Comparator.comparing(Store::getRate, Comparator.nullsLast(Comparator.reverseOrder()))) + .limit(5) + .toList(); + + if (candidateStores.isEmpty()) { + return BackofficeHomeAutoRecommendResponseDTO.of( + DEFAULT_TITLE, + null, + List.of( + BackofficeHomeAutoRecommendResponseDTO.AutoGroupDTO.of(1, "추천 제휴 1", List.of()), + BackofficeHomeAutoRecommendResponseDTO.AutoGroupDTO.of(2, "추천 제휴 2", List.of()) + ) + ); + } + + Store featuredStore = candidateStores.get(0); + String featuredDiscount = resolveStoreDiscountDescription(featuredStore.getId()); + BackofficeHomeAutoRecommendResponseDTO.AutoFeaturedDTO featuredDTO = BackofficeHomeAutoRecommendResponseDTO.AutoFeaturedDTO.of( + featuredStore.getId(), + featuredStore.getName(), + featuredDiscount, + featuredStore.getStoreCategory() != null ? featuredStore.getStoreCategory().name() : null + ); + + List remainingStores = candidateStores.subList(1, candidateStores.size()); + List group1Stores = new ArrayList<>(); + List group2Stores = new ArrayList<>(); + + for (int i = 0; i < remainingStores.size(); i++) { + Store store = remainingStores.get(i); + String discount = resolveStoreDiscountDescription(store.getId()); + String category = store.getStoreCategory() != null ? store.getStoreCategory().name() : null; + + if (i < 2) { + group1Stores.add(BackofficeHomeAutoRecommendResponseDTO.AutoStoreDTO.of( + store.getId(), + store.getName(), + discount, + category, + i + 1 + )); + } else if (i < 4) { + group2Stores.add(BackofficeHomeAutoRecommendResponseDTO.AutoStoreDTO.of( + store.getId(), + store.getName(), + discount, + category, + i - 1 + )); + } + } + + List groups = List.of( + BackofficeHomeAutoRecommendResponseDTO.AutoGroupDTO.of(1, "추천 제휴 1", group1Stores), + BackofficeHomeAutoRecommendResponseDTO.AutoGroupDTO.of(2, "추천 제휴 2", group2Stores) + ); + + return BackofficeHomeAutoRecommendResponseDTO.of(DEFAULT_TITLE, featuredDTO, groups); + } + + private BackofficeHomeCurationResponseDTO mapToBackofficeResponseDTO(HomeCuration curation, List items) { + BackofficeHomeCurationResponseDTO.FeaturedDTO featuredDTO = null; + if (curation.getFeaturedStore() != null) { + Store fs = curation.getFeaturedStore(); + String discount = curation.getFeaturedDiscountContent(); + if (discount == null || discount.isBlank()) { + discount = resolveStoreDiscountDescription(fs.getId()); + } + featuredDTO = BackofficeHomeCurationResponseDTO.FeaturedDTO.of( + fs.getId(), + fs.getName(), + discount, + fs.getStoreCategory() != null ? fs.getStoreCategory().name() : null + ); + } + + Map> itemsByGroup = items.stream() + .collect(Collectors.groupingBy(HomeCurationItem::getGroupIndex)); + + List groupDTOList = new ArrayList<>(); + for (int groupIdx = 1; groupIdx <= 2; groupIdx++) { + List groupItems = itemsByGroup.getOrDefault(groupIdx, List.of()); + String groupTitle = groupItems.isEmpty() ? "추천 제휴 " + groupIdx : groupItems.get(0).getGroupTitle(); + + List storeList = groupItems.stream() + .map(item -> { + Store s = item.getStore(); + String discount = item.getCustomDiscountContent(); + if (discount == null || discount.isBlank()) { + discount = resolveStoreDiscountDescription(s.getId()); + } + return BackofficeHomeCurationResponseDTO.StoreItemDTO.of( + s.getId(), + s.getName(), + discount, + s.getStoreCategory() != null ? s.getStoreCategory().name() : null, + item.getSortOrder() + ); + }) + .toList(); + + groupDTOList.add(BackofficeHomeCurationResponseDTO.GroupDTO.of(groupIdx, groupTitle, storeList)); + } + + return BackofficeHomeCurationResponseDTO.of( + curation.getId(), + curation.getTitle(), + featuredDTO, + groupDTOList + ); + } + + private String resolveStoreDiscountDescription(Long storeId) { + if (storeId == null) { + return ""; + } + List contents = paperContentRepository.findTopByStoreIdIn(List.of(storeId)); + if (!contents.isEmpty()) { + PaperContent pc = contents.get(0); + return PaperContentResponseDTO.toContentResponse(pc).paperContent(); + } + return "제휴 혜택 제공"; + } +} diff --git a/src/main/java/com/assu/server/domain/map/controller/MapController.java b/src/main/java/com/assu/server/domain/map/controller/MapController.java index 289502b7..c0fae377 100644 --- a/src/main/java/com/assu/server/domain/map/controller/MapController.java +++ b/src/main/java/com/assu/server/domain/map/controller/MapController.java @@ -4,6 +4,7 @@ import com.assu.server.domain.map.dto.*; import com.assu.server.domain.map.service.MapService; import com.assu.server.domain.map.service.PlaceSearchService; +import com.assu.server.domain.store.entity.enums.StoreCategory; import com.assu.server.global.apiPayload.BaseResponse; import com.assu.server.global.apiPayload.code.status.ErrorStatus; import com.assu.server.global.apiPayload.code.status.SuccessStatus; @@ -29,10 +30,13 @@ public class MapController { @Operation( summary = "현재 위치 기반 주변 장소 조회 API", - description = "# [v1.3 (2025-01-04)](https://clumsy-seeder-416.notion.site/2441197c19ed80bcb55fcad675dd9837?source=copy_link)\n" + + description = "# [v1.4 (2025-01-04)](https://clumsy-seeder-416.notion.site/2441197c19ed80bcb55fcad675dd9837?source=copy_link)\n" + "- 로그인한 유저의 역할에 따라 Map 객체를 반환합니다.\n" + "- 경도, 위도 순서로 입력한 Viewport 객체 입력.\n" + "- 성공 시 200(OK)과 Map 객체 반환.\n"+ + "\n**Request Params (STUDENT 전용):**\n" + + " - `storeCategory` (StoreCategory, optional): 카테고리 필터\n" + + " - `adminId` (Long, optional): 특정 학생회 필터\n" + "\n**Request Body:**\n" + " - `viewport` 객체 (JSON, required): 공간인덱싱을 위한 경도, 위도 객체\n" + " - `lng1` (double): 좌 상단 경도\n" + @@ -86,13 +90,15 @@ public class MapController { @GetMapping("/nearby") public BaseResponse getLocations( @ModelAttribute MapRequestDTO viewport, + @RequestParam(required = false) StoreCategory storeCategory, + @RequestParam(required = false) Long adminId, @AuthenticationPrincipal PrincipalDetails pd ) { Long memberId = pd.getMember().getId(); UserRole role = pd.getMember().getRole(); return switch (role) { - case STUDENT -> BaseResponse.onSuccess(SuccessStatus._OK, mapService.getStores(viewport, memberId)); + case STUDENT -> BaseResponse.onSuccess(SuccessStatus._OK, mapService.getStores(viewport, memberId, storeCategory, adminId)); case ADMIN -> BaseResponse.onSuccess(SuccessStatus._OK, mapService.getPartners(viewport, memberId)); case PARTNER -> BaseResponse.onSuccess(SuccessStatus._OK, mapService.getAdmins(viewport, memberId)); default -> BaseResponse.onFailure(ErrorStatus._BAD_REQUEST, null); diff --git a/src/main/java/com/assu/server/domain/map/dto/StoreMapResponseDTO.java b/src/main/java/com/assu/server/domain/map/dto/StoreMapResponseDTO.java index a12e4110..b2eae823 100644 --- a/src/main/java/com/assu/server/domain/map/dto/StoreMapResponseDTO.java +++ b/src/main/java/com/assu/server/domain/map/dto/StoreMapResponseDTO.java @@ -1,6 +1,7 @@ package com.assu.server.domain.map.dto; import com.assu.server.domain.store.entity.Store; +import com.assu.server.domain.store.entity.enums.StoreCategory; import com.assu.server.infra.s3.AmazonS3Manager; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; @@ -35,7 +36,10 @@ public record StoreMapResponseDTO( String phoneNumber, @Schema(description = "모든 제휴 혜택 목록") - List partnerships + List partnerships, + + @Schema(description = "가게 카테고리", example = "CAFE") + StoreCategory storeCategory ) { public record PartnershipInfo( Long adminId, @@ -43,44 +47,6 @@ public record PartnershipInfo( List benefits ) {} - public static StoreMapResponseDTO of( - Store store, - Long adminId1, Long adminId2, - String adminName1, String adminName2, - List benefits1, List benefits2, - AmazonS3Manager s3Manager - ) { - final boolean hasPartner = store.getPartner() != null; - final String key = (store.getPartner() != null && store.getPartner().getMember() != null) - ? store.getPartner().getMember().getProfileUrl() : null; - final String profileUrl = (key != null && !key.isBlank()) - ? s3Manager.generatePresignedUrl(key) : null; - final String phoneNumber = (store.getPartner() != null - && store.getPartner().getPhoneNum() != null) - ? store.getPartner().getPhoneNum() : ""; - - List partnerships = new java.util.ArrayList<>(); - if (adminId1 != null) { - partnerships.add(new PartnershipInfo(adminId1, adminName1, benefits1)); - } - if (adminId2 != null) { - partnerships.add(new PartnershipInfo(adminId2, adminName2, benefits2)); - } - - return new StoreMapResponseDTO( - store.getId(), - store.getName(), - store.getAddress() != null ? store.getAddress() : store.getDetailAddress(), - store.getRate(), - hasPartner, - store.getLatitude(), - store.getLongitude(), - profileUrl, - phoneNumber, - partnerships - ); - } - public static StoreMapResponseDTO ofWithPartnerships( Store store, List partnerships, @@ -105,7 +71,8 @@ public static StoreMapResponseDTO ofWithPartnerships( store.getLongitude(), profileUrl, phoneNumber, - partnerships + partnerships, + store.getStoreCategory() ); } } diff --git a/src/main/java/com/assu/server/domain/map/service/MapService.java b/src/main/java/com/assu/server/domain/map/service/MapService.java index d48a90e2..17120017 100644 --- a/src/main/java/com/assu/server/domain/map/service/MapService.java +++ b/src/main/java/com/assu/server/domain/map/service/MapService.java @@ -4,13 +4,14 @@ import com.assu.server.domain.map.dto.MapRequestDTO; import com.assu.server.domain.map.dto.PartnerMapResponseDTO; import com.assu.server.domain.map.dto.StoreMapResponseDTO; +import com.assu.server.domain.store.entity.enums.StoreCategory; import java.util.List; public interface MapService { List getAdmins(MapRequestDTO viewport, Long memberId); List getPartners(MapRequestDTO viewport, Long memberId); - List getStores(MapRequestDTO viewport, Long memberId); + List getStores(MapRequestDTO viewport, Long memberId, StoreCategory storeCategory, Long adminId); List searchStores(String keyword, Long memberId); List searchPartner(String keyword, Long memberId); diff --git a/src/main/java/com/assu/server/domain/map/service/MapServiceImpl.java b/src/main/java/com/assu/server/domain/map/service/MapServiceImpl.java index d7d74c8c..4904ff3d 100644 --- a/src/main/java/com/assu/server/domain/map/service/MapServiceImpl.java +++ b/src/main/java/com/assu/server/domain/map/service/MapServiceImpl.java @@ -16,6 +16,7 @@ import com.assu.server.domain.partnership.repository.PaperContentRepository; import com.assu.server.domain.partnership.repository.PaperRepository; import com.assu.server.domain.store.entity.Store; +import com.assu.server.domain.store.entity.enums.StoreCategory; import com.assu.server.domain.store.repository.StoreRepository; import com.assu.server.domain.student.entity.UserPaper; import com.assu.server.domain.student.repository.UserPaperRepository; @@ -90,7 +91,7 @@ public List getAdmins(MapRequestDTO viewport, Long memberId * papercontent의 note가 있으면 benefit 대신 note를 사용. */ @Override - public List getStores(MapRequestDTO viewport, Long memberId) { + public List getStores(MapRequestDTO viewport, Long memberId, StoreCategory storeCategory, Long adminId) { final String wkt = toWKT(viewport); // 1) 뷰포트 내 매장 조회 (Partner, Member fetch join) @@ -100,7 +101,7 @@ public List getStores(MapRequestDTO viewport, Long memberId } // 2) 해당 학생의 활성 UserPaper 조회 (paper, store, admin fetch join 포함) - final List userPapers = userPaperRepository.findActivePartnershipsByStudentId(memberId); + final List userPapers = userPaperRepository.findActivePartnershipsByStudentId(memberId, storeCategory, adminId); if (userPapers.isEmpty()) { return List.of(); // active 제휴가 없으면 빈 리스트 반환 } @@ -151,9 +152,17 @@ public List getStores(MapRequestDTO viewport, Long memberId // 7) 매장별 DTO 생성 return storesWithActivePaper.stream().map(s -> { final List sPapers = papersByStore.get(s.getId()); + final Map adminInfoMap = new HashMap<>(); + + for (Paper sp : sPapers) { + if (sp.getAdmin() != null) { + adminInfoMap.putIfAbsent(sp.getAdmin().getId(), sp.getAdmin().getName()); + } + } // admin별로 그룹화해서 benefits 리스트 생성 Map> benefitsByAdmin = sPapers.stream() + .filter(p -> p.getAdmin() != null) .collect(Collectors.groupingBy( paper -> paper.getAdmin().getId(), Collectors.flatMapping( @@ -170,14 +179,10 @@ public List getStores(MapRequestDTO viewport, Long memberId List partnerships = benefitsByAdmin.entrySet().stream() .map(entry -> { - Long adminId = entry.getKey(); + Long paperAdminId = entry.getKey(); List benefits = entry.getValue(); - String adminName = sPapers.stream() - .filter(p -> p.getAdmin().getId().equals(adminId)) - .findFirst() - .map(p -> p.getAdmin().getName()) - .orElse(null); - return new StoreMapResponseDTO.PartnershipInfo(adminId, adminName, benefits); + String adminName = adminInfoMap.get(paperAdminId); + return new StoreMapResponseDTO.PartnershipInfo(paperAdminId, adminName, benefits); }) .filter(p -> p.adminId() != null && !p.benefits().isEmpty()) .toList(); @@ -231,7 +236,7 @@ private String generateBenefitText(PaperContent content) { public List searchStores(String keyword, Long memberId) { String normalizedKeyword = (keyword == null) ? "" : keyword.replace(" ", "").toLowerCase(); - List userPapers = userPaperRepository.findActivePartnershipsByStudentId(memberId); + List userPapers = userPaperRepository.findActivePartnershipsByStudentId(memberId, null, null); if (userPapers.isEmpty()) { return List.of(); diff --git a/src/main/java/com/assu/server/domain/partnership/controller/PartnershipController.java b/src/main/java/com/assu/server/domain/partnership/controller/PartnershipController.java index 5be33532..7db7c61a 100644 --- a/src/main/java/com/assu/server/domain/partnership/controller/PartnershipController.java +++ b/src/main/java/com/assu/server/domain/partnership/controller/PartnershipController.java @@ -11,6 +11,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -348,7 +349,7 @@ public BaseResponse deleteSuspendedPaper( @GetMapping("/admin") @PreAuthorize("hasRole('ADMIN')") public BaseResponse> listForAdmin( - @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable, + @ParameterObject @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable, @AuthenticationPrincipal PrincipalDetails pd ) { return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.listPartnershipsForAdmin(pageable, pd.getId())); @@ -387,7 +388,7 @@ public BaseResponse> listForAdmin( @GetMapping("/partner") @PreAuthorize("hasRole('PARTNER')") public BaseResponse> listForPartner( - @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable, + @ParameterObject @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable, @AuthenticationPrincipal PrincipalDetails pd ) { return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.listPartnershipsForPartner(pageable, pd.getId())); diff --git a/src/main/java/com/assu/server/domain/qr/controller/RedirectController.java b/src/main/java/com/assu/server/domain/qr/controller/RedirectController.java index 15c4052a..f6e0710b 100644 --- a/src/main/java/com/assu/server/domain/qr/controller/RedirectController.java +++ b/src/main/java/com/assu/server/domain/qr/controller/RedirectController.java @@ -1,13 +1,11 @@ package com.assu.server.domain.qr.controller; -import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; @Controller -@PreAuthorize("hasRole('STUDENT')") public class RedirectController { @GetMapping("/verify") public String handleQrRedirect( diff --git a/src/main/java/com/assu/server/domain/review/controller/ReviewController.java b/src/main/java/com/assu/server/domain/review/controller/ReviewController.java index 944e235b..f21a9c71 100644 --- a/src/main/java/com/assu/server/domain/review/controller/ReviewController.java +++ b/src/main/java/com/assu/server/domain/review/controller/ReviewController.java @@ -10,6 +10,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; @@ -53,7 +54,7 @@ public BaseResponse writeReview( @GetMapping("/student") @PreAuthorize("hasRole('STUDENT')") public BaseResponse> checkStudent( - @AuthenticationPrincipal PrincipalDetails pd, Pageable pageable + @AuthenticationPrincipal PrincipalDetails pd, @ParameterObject Pageable pageable ) { return BaseResponse.onSuccess(SuccessStatus._OK, reviewService.checkStudentReview(pd.getId(), pageable)); } @@ -66,7 +67,7 @@ public BaseResponse> checkStudent @GetMapping("/partner") @PreAuthorize("hasRole('PARTNER')") public BaseResponse> checkPartnerReview( - @AuthenticationPrincipal PrincipalDetails pd, Pageable pageable + @AuthenticationPrincipal PrincipalDetails pd, @ParameterObject Pageable pageable ){ return BaseResponse.onSuccess(SuccessStatus._OK, reviewService.checkPartnerReview(pd.getId(), pageable)); } @@ -78,7 +79,7 @@ public BaseResponse> checkPartner ) @GetMapping("/store/{storeId}") public BaseResponse> checkStoreReview( - Pageable pageable, @PathVariable Long storeId + @ParameterObject Pageable pageable, @PathVariable Long storeId ){ return BaseResponse.onSuccess(SuccessStatus._OK, reviewService.checkStoreReview(storeId, pageable)); } diff --git a/src/main/java/com/assu/server/domain/store/controller/StoreController.java b/src/main/java/com/assu/server/domain/store/controller/StoreController.java index f6ed6bef..27056838 100644 --- a/src/main/java/com/assu/server/domain/store/controller/StoreController.java +++ b/src/main/java/com/assu/server/domain/store/controller/StoreController.java @@ -1,7 +1,7 @@ package com.assu.server.domain.store.controller; -import java.awt.*; import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -34,6 +34,38 @@ public class StoreController { private final StoreService storeService; private final PaperQueryService paperQueryService; + @GetMapping("/{storeId}") + @Operation( + summary = "가게 상세 조회 API", + description = "# [v1.0 (2026-08-31)](https://clumsy-seeder-416.notion.site/3cd1197c19ed806eb1abce6ab4c5f4fb?source=copy_link)\n" + + "- 가게 ID로 가게 상세 정보를 조회합니다.\n\n" + + "**Path Variable:**\n" + + " - `storeId` (Long, required): 조회할 가게 ID\n\n" + + "**Response (GetStoreDetailsDTO):**\n" + + " - `storeId` (Long): 가게 고유 ID\n" + + " - `storeName` (String): 가게 이름\n" + + " - `address` (String): 가게 주소\n" + + " - `detailAddress` (String): 가게 상세 주소\n" + + " - `latitude` (Double): 위도\n" + + " - `longitude` (Double): 경도\n" + + " - `storeCategory` (String): 가게 카테고리\n" + + " - `rate` (Integer): 가게 평점\n" + + " - `phoneNumber` (String): 업체 전화번호\n" + + " - `hasPartner` (Boolean): 제휴업체 여부\n" + + " - `profileUrl` (String): 업체 프로필 이미지 presigned URL\n\n" + + "**Error Cases:**\n" + + " - 성공: 200 OK\n" + + " - 404 NOT_FOUND: 해당 가게를 찾을 수 없는 경우" + ) + @Parameters({ + @Parameter(name = "storeId", description = "조회할 가게 ID를 입력해주세요") + }) + public ResponseEntity> getStoreDetails( + @PathVariable Long storeId + ) { + return ResponseEntity.ok(BaseResponse.onSuccess(SuccessStatus._OK, storeService.getStoreDetails(storeId))); + } + @GetMapping("/best") @Operation( summary = "오늘 인기 매장 조회 API", diff --git a/src/main/java/com/assu/server/domain/store/dto/StoreResponseDTO.java b/src/main/java/com/assu/server/domain/store/dto/StoreResponseDTO.java index 42373eb2..eaa4caa8 100644 --- a/src/main/java/com/assu/server/domain/store/dto/StoreResponseDTO.java +++ b/src/main/java/com/assu/server/domain/store/dto/StoreResponseDTO.java @@ -25,4 +25,18 @@ public record ListWeeklyRankResponseDTO( List items // 과거→현재 (6개) ) {} + public record GetStoreDetailsDTO( + Long storeId, + String storeName, + String address, + String detailAddress, + Double latitude, + Double longitude, + String storeCategory, + Integer rate, + String phoneNumber, + boolean hasPartner, + String profileUrl + ) {} + } diff --git a/src/main/java/com/assu/server/domain/store/entity/Store.java b/src/main/java/com/assu/server/domain/store/entity/Store.java index f348d99f..9adec009 100644 --- a/src/main/java/com/assu/server/domain/store/entity/Store.java +++ b/src/main/java/com/assu/server/domain/store/entity/Store.java @@ -2,6 +2,7 @@ import com.assu.server.domain.common.entity.BaseEntity; import com.assu.server.domain.common.enums.ActivationStatus; import com.assu.server.domain.partner.entity.Partner; +import com.assu.server.domain.store.entity.enums.StoreCategory; import jakarta.persistence.*; import lombok.*; @@ -44,6 +45,9 @@ public class Store extends BaseEntity { private double latitude; private double longitude; + @Enumerated(EnumType.STRING) + private StoreCategory storeCategory; + public void linkPartner(Partner partner) { this.partner = partner; } diff --git a/src/main/java/com/assu/server/domain/store/entity/enums/StoreCategory.java b/src/main/java/com/assu/server/domain/store/entity/enums/StoreCategory.java new file mode 100644 index 00000000..c19cbc10 --- /dev/null +++ b/src/main/java/com/assu/server/domain/store/entity/enums/StoreCategory.java @@ -0,0 +1,5 @@ +package com.assu.server.domain.store.entity.enums; + +public enum StoreCategory { + RESTAURANT, CAFE, BAR, BEAUTY, ENTERTAINMENT, SPORTS, LIVING, HOSPITAL, EDUCATION, OTHERS +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/store/repository/StoreRepository.java b/src/main/java/com/assu/server/domain/store/repository/StoreRepository.java index 28ea7caa..5c8c2b9f 100644 --- a/src/main/java/com/assu/server/domain/store/repository/StoreRepository.java +++ b/src/main/java/com/assu/server/domain/store/repository/StoreRepository.java @@ -1,5 +1,7 @@ package com.assu.server.domain.store.repository; import java.util.Optional; + +import com.assu.server.domain.store.entity.enums.StoreCategory; import org.springframework.data.jpa.repository.JpaRepository; import com.assu.server.domain.store.entity.Store; import com.assu.server.domain.partner.entity.Partner; @@ -113,7 +115,9 @@ Optional findBySameAddress( WHERE s.point IS NOT NULL AND function('ST_Contains', function('ST_GeomFromText', :wkt, 4326), s.point) = true """) - List findAllWithinViewportWithPartner(@Param("wkt") String wkt); + List findAllWithinViewportWithPartner( + @Param("wkt") String wkt + ); @Query(""" SELECT DISTINCT s diff --git a/src/main/java/com/assu/server/domain/store/service/StoreService.java b/src/main/java/com/assu/server/domain/store/service/StoreService.java index 9061a947..ae1f1a50 100644 --- a/src/main/java/com/assu/server/domain/store/service/StoreService.java +++ b/src/main/java/com/assu/server/domain/store/service/StoreService.java @@ -3,6 +3,7 @@ import com.assu.server.domain.store.dto.TodayBestResponseDTO; public interface StoreService { + StoreResponseDTO.GetStoreDetailsDTO getStoreDetails(Long storeId); TodayBestResponseDTO getTodayBestStore(); StoreResponseDTO.WeeklyRankResponseDTO getWeeklyRank(Long memberId); StoreResponseDTO.ListWeeklyRankResponseDTO getListWeeklyRank(Long memberId); diff --git a/src/main/java/com/assu/server/domain/store/service/StoreServiceImpl.java b/src/main/java/com/assu/server/domain/store/service/StoreServiceImpl.java index 8bea2162..0b990d86 100644 --- a/src/main/java/com/assu/server/domain/store/service/StoreServiceImpl.java +++ b/src/main/java/com/assu/server/domain/store/service/StoreServiceImpl.java @@ -5,6 +5,7 @@ import java.util.stream.Collectors; import com.assu.server.domain.store.exception.CustomStoreException; +import com.assu.server.infra.s3.AmazonS3Manager; import org.springframework.stereotype.Service; import com.assu.server.domain.store.dto.StoreResponseDTO; import com.assu.server.domain.store.dto.TodayBestResponseDTO; @@ -27,8 +28,39 @@ public class StoreServiceImpl implements StoreService { private final PartnerRepository partnerRepository; private final PartnershipUsageRepository partnershipUsageRepository; private final QRCertificationRepository qrCertificationRepository; + private final AmazonS3Manager amazonS3Manager; - @Override + @Override + @Transactional + public StoreResponseDTO.GetStoreDetailsDTO getStoreDetails(Long storeId) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new CustomStoreException(ErrorStatus.NO_SUCH_STORE)); + + Partner partner = store.getPartner(); + String profileUrl = null; + if (partner != null && partner.getMember() != null) { + String key = partner.getMember().getProfileUrl(); + if (key != null && !key.isBlank()) { + profileUrl = amazonS3Manager.generatePresignedUrl(key); + } + } + + return new StoreResponseDTO.GetStoreDetailsDTO( + store.getId(), + store.getName(), + store.getAddress(), + store.getDetailAddress(), + store.getLatitude(), + store.getLongitude(), + store.getStoreCategory() != null ? store.getStoreCategory().name() : null, + store.getRate(), + partner != null ? partner.getPhoneNum() : null, + partner != null, + profileUrl + ); + } + + @Override @Transactional public TodayBestResponseDTO getTodayBestStore() { List bestStores = storeRepository.findTodayBestStoreNames(); diff --git a/src/main/java/com/assu/server/domain/student/controller/StudentController.java b/src/main/java/com/assu/server/domain/student/controller/StudentController.java index 59fe399f..d0255d1d 100644 --- a/src/main/java/com/assu/server/domain/student/controller/StudentController.java +++ b/src/main/java/com/assu/server/domain/student/controller/StudentController.java @@ -2,6 +2,8 @@ import java.util.List; +import org.springdoc.core.annotations.ParameterObject; +import com.assu.server.domain.store.entity.enums.StoreCategory; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; @@ -14,6 +16,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.assu.server.domain.student.dto.StudentHomeResponseDTO; import com.assu.server.domain.student.dto.StudentProfileResponseDTO; import com.assu.server.domain.student.dto.StudentResponseDTO; import com.assu.server.domain.student.service.StudentService; @@ -71,7 +74,7 @@ public ResponseEntity> getMyPartn ) public ResponseEntity>> getUnreviewedUsage( @AuthenticationPrincipal PrincipalDetails pd, - Pageable pageable + @ParameterObject Pageable pageable ){ return ResponseEntity.ok(BaseResponse .onSuccess(SuccessStatus.UNREVIEWED_HISTORY_SUCCESS, @@ -108,13 +111,14 @@ public BaseResponse earnStamp( @Operation( summary = "사용자의 이용 가능한 제휴 조회 API", - description = "# [v1.0 (2025-10-30)](https://clumsy-seeder-416.notion.site/API-29c1197c19ed8030b1f5e2a744416651?source=copy_link)\n" + + description = "# [v1.1 (2026-08-31)](https://clumsy-seeder-416.notion.site/API-29c1197c19ed8030b1f5e2a744416651?source=copy_link)\n" + "- 현재 로그인한 사용자가 이용 가능한 제휴 목록을 조회합니다.\n" + "- 활성 상태인 제휴만 반환합니다.\n\n" + "**Request Parameters:**\n" + "- `all` (Boolean, optional): 전체 조회 여부 - 기본값: false\n" + " - true: 모든 이용 가능한 제휴 조회\n" + - " - false: 최대 2개만 조회\n\n" + + " - false: 최대 2개만 조회\n" + + "- `storeCategory` (StoreCategory, optional): 카테고리 필터 - 미입력 시 전체 조회\n\n" + "**Response:**\n" + "- 성공 시 200(OK)와 이용 가능한 제휴 목록 반환\n" + "- 401(UNAUTHORIZED): 인증되지 않은 사용자\n" + @@ -123,9 +127,28 @@ public BaseResponse earnStamp( @GetMapping("/usable") public BaseResponse> getUsablePartnership( @AuthenticationPrincipal PrincipalDetails pd, - @RequestParam(name = "all", defaultValue = "false") boolean all + @RequestParam(name = "all", defaultValue = "false") boolean all, + @RequestParam(required = false) StoreCategory storeCategory, + @RequestParam(required = false) Long adminId + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, studentService.getUsablePartnership(pd.getId(), all, storeCategory, adminId)); + } + + @Operation( + summary = "사용자의 추천 제휴 조회 API", + description = "# [v1.0 (2026-08-31)](https://clumsy-seeder-416.notion.site/3cd1197c19ed800b86f5ed255f91caf1?source=copy_link)\n" + + "- 현재 로그인한 사용자가 이용 가능한 제휴 중 최대 14개를 랜덤으로 반환합니다.\n" + + "- 활성 상태인 제휴만 포함됩니다.\n\n" + + "**Response:**\n" + + "- 성공 시 200(OK)와 추천 제휴 목록(최대 14개) 반환\n" + + "- 401(UNAUTHORIZED): 인증되지 않은 사용자\n" + + "- 404(NOT_FOUND): 사용자 정보를 찾을 수 없음" + ) + @GetMapping("/recommend/carousel") + public BaseResponse> getRecommendCarouselPartnership( + @AuthenticationPrincipal PrincipalDetails pd ) { - return BaseResponse.onSuccess(SuccessStatus._OK, studentService.getUsablePartnership(pd.getId(), all)); + return BaseResponse.onSuccess(SuccessStatus._OK, studentService.getRecommendCarouselPartnership(pd.getId())); } @Operation( @@ -143,4 +166,20 @@ public BaseResponse getStudentProfile( return BaseResponse.onSuccess(SuccessStatus._OK, studentService.getStudentProfile(pd.getId())); } + @Operation( + summary = "학생 맞춤 큐레이션 추천 제휴 조회 API", + description = "- 학생 홈 화면에 노출될 상단 추천 제휴(추천 업체/할인 내용) 및 맞춤형 큐레이션 리스트(2개 그룹 x 2개 업체)를 조회합니다.\n" + + "- 큐레이션 섹션 제목의 {name}은 로그인한 학생의 이름으로 자동 치환되어 반환됩니다.\n\n" + + "**Response:**\n" + + "- 성공 시 200(OK)와 큐레이션 추천 정보(`StudentHomeResponseDTO`) 반환\n" + + "- 401(UNAUTHORIZED): 인증되지 않은 사용자\n" + + "- 404(NOT_FOUND): 학생 정보를 찾을 수 없음" + ) + @GetMapping("/recommend/curation") + public BaseResponse getRecommendCuration( + @AuthenticationPrincipal PrincipalDetails pd + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, studentService.getStudentHome(pd.getId())); + } + } diff --git a/src/main/java/com/assu/server/domain/student/dto/StudentHomeResponseDTO.java b/src/main/java/com/assu/server/domain/student/dto/StudentHomeResponseDTO.java new file mode 100644 index 00000000..bf0b32c7 --- /dev/null +++ b/src/main/java/com/assu/server/domain/student/dto/StudentHomeResponseDTO.java @@ -0,0 +1,107 @@ +package com.assu.server.domain.student.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +public record StudentHomeResponseDTO( + @Schema( + description = "상단 추천 업체 및 할인 정보", + example = "{\"storeId\": 1, \"storeName\": \"더진국 숭실대점\", \"discountContent\": \"전 메뉴 1,000원 할인 또는 음료수 증정\", \"storeCategory\": \"RESTAURANT\", \"profileImageUrl\": \"https://assu-s3.s3.ap-northeast-2.amazonaws.com/profile.png\"}" + ) + FeaturedRecommendationDTO featuredRecommendation, + + @Schema(description = "큐레이션 섹션 제목 (학생 이름 적용 완료)", example = "홍길동님을 위한 제휴") + String curationTitle, + + @Schema( + description = "추천 업체 그룹 목록 (총 2개 리스트, 각 리스트당 2개 업체)", + example = "[{\"groupIndex\": 1, \"groupTitle\": \"인기 식사 혜택\", \"stores\": [{\"storeId\": 10, \"storeName\": \"가게 A\", \"discountContent\": \"10% 할인\", \"storeCategory\": \"RESTAURANT\", \"profileImageUrl\": \"https://assu-s3.s3.ap-northeast-2.amazonaws.com/store1.png\"}, {\"storeId\": 11, \"storeName\": \"가게 B\", \"discountContent\": \"음료 무료\", \"storeCategory\": \"RESTAURANT\", \"profileImageUrl\": \"https://assu-s3.s3.ap-northeast-2.amazonaws.com/store2.png\"}]}, {\"groupIndex\": 2, \"groupTitle\": \"카페/디저트 추천\", \"stores\": [{\"storeId\": 20, \"storeName\": \"가게 C\", \"discountContent\": \"사이즈업\", \"storeCategory\": \"CAFE\", \"profileImageUrl\": \"https://assu-s3.s3.ap-northeast-2.amazonaws.com/store3.png\"}, {\"storeId\": 21, \"storeName\": \"가게 D\", \"discountContent\": \"500원 할인\", \"storeCategory\": \"CAFE\", \"profileImageUrl\": \"https://assu-s3.s3.ap-northeast-2.amazonaws.com/store4.png\"}]}]" + ) + List curationLists +) { + + public record FeaturedRecommendationDTO( + @Schema(description = "추천 업체 ID", example = "1") + Long storeId, + + @Schema(description = "추천 업체 이름", example = "더진국 숭실대점") + String storeName, + + @Schema(description = "추천 할인 내용", example = "전 메뉴 1,000원 할인 또는 음료수 증정") + String discountContent, + + @Schema(description = "업체 카테고리", example = "RESTAURANT") + String storeCategory, + + @Schema(description = "업체 프로필 이미지 URL", example = "https://assu-s3.s3.ap-northeast-2.amazonaws.com/profile.png") + String profileImageUrl + ) { + public static FeaturedRecommendationDTO of( + Long storeId, + String storeName, + String discountContent, + String storeCategory, + String profileImageUrl + ) { + return new FeaturedRecommendationDTO(storeId, storeName, discountContent, storeCategory, profileImageUrl); + } + } + + public record CurationGroupDTO( + @Schema(description = "그룹 인덱스 (1 또는 2)", example = "1") + Integer groupIndex, + + @Schema(description = "그룹 소제목", example = "인기 식사 혜택") + String groupTitle, + + @Schema( + description = "그룹 내 추천 업체 목록 (2개)", + example = "[{\"storeId\": 10, \"storeName\": \"가게 A\", \"discountContent\": \"10% 할인\", \"storeCategory\": \"RESTAURANT\", \"profileImageUrl\": \"https://assu-s3.s3.ap-northeast-2.amazonaws.com/store1.png\"}, {\"storeId\": 11, \"storeName\": \"가게 B\", \"discountContent\": \"음료 무료\", \"storeCategory\": \"RESTAURANT\", \"profileImageUrl\": \"https://assu-s3.s3.ap-northeast-2.amazonaws.com/store2.png\"}]" + ) + List stores + ) { + public static CurationGroupDTO of( + Integer groupIndex, + String groupTitle, + List stores + ) { + return new CurationGroupDTO(groupIndex, groupTitle, stores); + } + } + + public record CurationStoreDTO( + @Schema(description = "가게 ID", example = "2") + Long storeId, + + @Schema(description = "가게 이름", example = "스타벅스 숭실대점") + String storeName, + + @Schema(description = "할인 및 혜택 내용", example = "아메리카노 사이즈업") + String discountContent, + + @Schema(description = "가게 카테고리", example = "CAFE") + String storeCategory, + + @Schema(description = "가게 프로필 이미지 URL", example = "https://assu-s3.s3.ap-northeast-2.amazonaws.com/store.png") + String profileImageUrl + ) { + public static CurationStoreDTO of( + Long storeId, + String storeName, + String discountContent, + String storeCategory, + String profileImageUrl + ) { + return new CurationStoreDTO(storeId, storeName, discountContent, storeCategory, profileImageUrl); + } + } + + public static StudentHomeResponseDTO of( + FeaturedRecommendationDTO featuredRecommendation, + String curationTitle, + List curationLists + ) { + return new StudentHomeResponseDTO(featuredRecommendation, curationTitle, curationLists); + } +} diff --git a/src/main/java/com/assu/server/domain/student/dto/StudentResponseDTO.java b/src/main/java/com/assu/server/domain/student/dto/StudentResponseDTO.java index a42e42ef..c0685b45 100644 --- a/src/main/java/com/assu/server/domain/student/dto/StudentResponseDTO.java +++ b/src/main/java/com/assu/server/domain/student/dto/StudentResponseDTO.java @@ -39,6 +39,14 @@ public static class CheckStampResponseDTO { private String message; } + public record RecommendCarouselDTO( + String category, + String belonging, + String partnerProfileUrl, + String partnerName, + Long storeId + ) {} + @Getter @NoArgsConstructor @AllArgsConstructor @@ -55,6 +63,9 @@ public static class UsablePartnershipDTO { private Long paperId; private String category; private Long discountRate; + private Long storeId; + private Integer extraCount; + private String partnerProfileUrl; } } diff --git a/src/main/java/com/assu/server/domain/student/entity/HomeCuration.java b/src/main/java/com/assu/server/domain/student/entity/HomeCuration.java new file mode 100644 index 00000000..513194f9 --- /dev/null +++ b/src/main/java/com/assu/server/domain/student/entity/HomeCuration.java @@ -0,0 +1,60 @@ +package com.assu.server.domain.student.entity; + +import com.assu.server.domain.common.entity.BaseEntity; +import com.assu.server.domain.store.entity.Store; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.BatchSize; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class HomeCuration extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "featured_store_id") + private Store featuredStore; + + @Column(length = 255) + private String featuredDiscountContent; + + @BatchSize(size = 20) + @OneToMany(mappedBy = "homeCuration", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List items = new ArrayList<>(); + + public void updateTitle(String title) { + this.title = title; + } + + public void updateFeatured(Store featuredStore, String featuredDiscountContent) { + this.featuredStore = featuredStore; + this.featuredDiscountContent = featuredDiscountContent; + } +} diff --git a/src/main/java/com/assu/server/domain/student/entity/HomeCurationItem.java b/src/main/java/com/assu/server/domain/student/entity/HomeCurationItem.java new file mode 100644 index 00000000..b131dc92 --- /dev/null +++ b/src/main/java/com/assu/server/domain/student/entity/HomeCurationItem.java @@ -0,0 +1,49 @@ +package com.assu.server.domain.student.entity; + +import com.assu.server.domain.common.entity.BaseEntity; +import com.assu.server.domain.store.entity.Store; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class HomeCurationItem extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "home_curation_id", nullable = false) + private HomeCuration homeCuration; + + @Column(nullable = false) + private Integer groupIndex; + + private String groupTitle; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "store_id", nullable = false) + private Store store; + + @Column(length = 255) + private String customDiscountContent; + + @Column(nullable = false) + private Integer sortOrder; +} diff --git a/src/main/java/com/assu/server/domain/student/repository/HomeCurationItemRepository.java b/src/main/java/com/assu/server/domain/student/repository/HomeCurationItemRepository.java new file mode 100644 index 00000000..0b59a53b --- /dev/null +++ b/src/main/java/com/assu/server/domain/student/repository/HomeCurationItemRepository.java @@ -0,0 +1,16 @@ +package com.assu.server.domain.student.repository; + +import com.assu.server.domain.student.entity.HomeCurationItem; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface HomeCurationItemRepository extends JpaRepository { + + @Query("SELECT i FROM HomeCurationItem i JOIN FETCH i.store s LEFT JOIN FETCH s.partner p LEFT JOIN FETCH p.member WHERE i.homeCuration.id = :curationId ORDER BY i.groupIndex ASC, i.sortOrder ASC") + List findByHomeCurationIdWithStoreAndPartner(@Param("curationId") Long curationId); + + List findByHomeCurationIdOrderByGroupIndexAscSortOrderAsc(Long homeCurationId); +} diff --git a/src/main/java/com/assu/server/domain/student/repository/HomeCurationRepository.java b/src/main/java/com/assu/server/domain/student/repository/HomeCurationRepository.java new file mode 100644 index 00000000..0be80faf --- /dev/null +++ b/src/main/java/com/assu/server/domain/student/repository/HomeCurationRepository.java @@ -0,0 +1,15 @@ +package com.assu.server.domain.student.repository; + +import com.assu.server.domain.student.entity.HomeCuration; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.Optional; + +public interface HomeCurationRepository extends JpaRepository { + + @Query("SELECT hc FROM HomeCuration hc LEFT JOIN FETCH hc.featuredStore ORDER BY hc.id DESC LIMIT 1") + Optional findLatest(); + + Optional findTopByOrderByIdDesc(); +} diff --git a/src/main/java/com/assu/server/domain/student/repository/UserPaperRepository.java b/src/main/java/com/assu/server/domain/student/repository/UserPaperRepository.java index ef94173f..d872dc73 100644 --- a/src/main/java/com/assu/server/domain/student/repository/UserPaperRepository.java +++ b/src/main/java/com/assu/server/domain/student/repository/UserPaperRepository.java @@ -1,11 +1,10 @@ package com.assu.server.domain.student.repository; +import com.assu.server.domain.store.entity.enums.StoreCategory; import com.assu.server.domain.student.entity.UserPaper; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; - -import java.time.LocalDate; import java.util.List; public interface UserPaperRepository extends JpaRepository { @@ -15,15 +14,19 @@ public interface UserPaperRepository extends JpaRepository { JOIN FETCH up.paper p LEFT JOIN FETCH p.store s LEFT JOIN FETCH p.admin a + LEFT JOIN FETCH p.partner pt + LEFT JOIN FETCH pt.member pm WHERE up.student.id = :studentId AND p.isActivated = com.assu.server.domain.common.enums.ActivationStatus.ACTIVE + AND (:storeCategory IS NULL OR s.storeCategory = :storeCategory) + AND (:adminId IS NULL OR a.id = :adminId) ORDER BY p.id DESC """) - List findActivePartnershipsByStudentId(@Param("studentId") Long studentId); - - boolean existsByStudentIdAndPaperId(Long studentId, Long paperId); - - boolean existsByStudentIdAndPaperIdAndPaperContentId(Long studentId, Long paperId, Long paperContentId); + List findActivePartnershipsByStudentId( + @Param("studentId") Long studentId, + @Param("storeCategory") StoreCategory storeCategory, + @Param("adminId") Long adminId + ); @Query(""" SELECT up FROM UserPaper up diff --git a/src/main/java/com/assu/server/domain/student/service/StudentService.java b/src/main/java/com/assu/server/domain/student/service/StudentService.java index 4cc96d7c..099731e8 100644 --- a/src/main/java/com/assu/server/domain/student/service/StudentService.java +++ b/src/main/java/com/assu/server/domain/student/service/StudentService.java @@ -2,9 +2,11 @@ import java.util.List; +import com.assu.server.domain.store.entity.enums.StoreCategory; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import com.assu.server.domain.student.dto.StudentHomeResponseDTO; import com.assu.server.domain.student.dto.StudentProfileResponseDTO; import com.assu.server.domain.student.dto.StudentResponseDTO; @@ -12,9 +14,11 @@ public interface StudentService { StudentResponseDTO.MyPartnership getMyPartnership(Long studentId, int year, int month); StudentResponseDTO.CheckStampResponseDTO getStamp(Long memberId);//조회 Page getUnreviewedUsage(Long memberId, Pageable pageable); - List getUsablePartnership(Long memberId, Boolean all); + List getUsablePartnership(Long memberId, Boolean all, StoreCategory storeCategory, Long adminId); + List getRecommendCarouselPartnership(Long memberId); void syncUserPapersForAllStudents(); void syncUserPapersForStudent(Long studentId); StudentResponseDTO.CheckStampResponseDTO addStamp(Long id); StudentProfileResponseDTO getStudentProfile(Long memberId); + StudentHomeResponseDTO getStudentHome(Long memberId); } diff --git a/src/main/java/com/assu/server/domain/student/service/StudentServiceImpl.java b/src/main/java/com/assu/server/domain/student/service/StudentServiceImpl.java index 4ac77821..d7082999 100644 --- a/src/main/java/com/assu/server/domain/student/service/StudentServiceImpl.java +++ b/src/main/java/com/assu/server/domain/student/service/StudentServiceImpl.java @@ -3,20 +3,22 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; -import java.util.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; -import com.assu.server.domain.notification.service.NotificationCommandService; -import com.assu.server.domain.student.entity.StampEventApplicant; -import com.assu.server.domain.student.repository.StampEventApplicantRepository; -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.assu.server.domain.admin.entity.Admin; import com.assu.server.domain.admin.repository.AdminRepository; import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.domain.notification.service.NotificationCommandService; +import com.assu.server.domain.partnership.dto.PaperContentResponseDTO; import com.assu.server.domain.partnership.entity.Goods; import com.assu.server.domain.partnership.entity.Paper; import com.assu.server.domain.partnership.entity.PaperContent; @@ -25,22 +27,33 @@ import com.assu.server.domain.partnership.repository.PaperContentRepository; import com.assu.server.domain.partnership.repository.PaperRepository; import com.assu.server.domain.store.entity.Store; +import com.assu.server.domain.store.entity.enums.StoreCategory; +import com.assu.server.domain.store.repository.StoreRepository; import com.assu.server.domain.student.converter.StudentConverter; -import com.assu.server.domain.student.dto.StudentResponseDTO; -import com.assu.server.domain.member.entity.Member; -import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.domain.student.dto.StudentHomeResponseDTO; import com.assu.server.domain.student.dto.StudentProfileResponseDTO; +import com.assu.server.domain.student.dto.StudentResponseDTO; +import com.assu.server.domain.student.entity.HomeCuration; +import com.assu.server.domain.student.entity.HomeCurationItem; import com.assu.server.domain.student.entity.PartnershipUsage; +import com.assu.server.domain.student.entity.StampEventApplicant; import com.assu.server.domain.student.entity.Student; import com.assu.server.domain.student.entity.UserPaper; +import com.assu.server.domain.student.repository.HomeCurationItemRepository; +import com.assu.server.domain.student.repository.HomeCurationRepository; import com.assu.server.domain.student.repository.PartnershipUsageRepository; +import com.assu.server.domain.student.repository.StampEventApplicantRepository; import com.assu.server.domain.student.repository.StudentRepository; import com.assu.server.domain.student.repository.UserPaperRepository; import com.assu.server.global.apiPayload.code.status.ErrorStatus; import com.assu.server.global.exception.DatabaseException; - -import org.springframework.transaction.annotation.Transactional; +import com.assu.server.infra.s3.AmazonS3Manager; 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 @@ -55,6 +68,10 @@ public class StudentServiceImpl implements StudentService { private final PaperRepository paperRepository; private final NotificationCommandService notificationCommandService; private final MemberRepository memberRepository; + private final HomeCurationRepository homeCurationRepository; + private final HomeCurationItemRepository homeCurationItemRepository; + private final StoreRepository storeRepository; + private final AmazonS3Manager amazonS3Manager; @Override @Transactional public StudentResponseDTO.CheckStampResponseDTO getStamp(Long memberId) { @@ -141,17 +158,28 @@ public Page getUnreviewedUsage(Long memberId, Pa } @Override - public List getUsablePartnership(Long memberId, Boolean all) { - List userPapers = userPaperRepository.findActivePartnershipsByStudentId(memberId); - - // Goods 일괄 조회 (N+1 방지) - List contentIds = userPapers.stream() + public List getUsablePartnership(Long memberId, Boolean all, StoreCategory storeCategory, Long adminId) { + List userPapers = userPaperRepository.findActivePartnershipsByStudentId(memberId, storeCategory, adminId); + + Map countByPaperId = userPapers.stream() + .collect(Collectors.groupingBy(up -> up.getPaper().getId(), Collectors.counting())); + + List representatives = userPapers.stream() + .collect(Collectors.toMap( + up -> up.getPaper().getId(), + up -> up, + (a, b) -> a, + LinkedHashMap::new + )) + .values().stream().toList(); + + List contentIds = representatives.stream() .map(up -> up.getPaperContent().getId()) .toList(); Map> goodsMap = goodsRepository.findByContentIdIn(contentIds).stream() .collect(Collectors.groupingBy(g -> g.getContent().getId())); - List result = userPapers.stream().map(up -> { + List result = representatives.stream().map(up -> { Paper paper = up.getPaper(); PaperContent content = up.getPaperContent(); Store store = paper.getStore(); @@ -164,6 +192,7 @@ public List getUsablePartnership(Long m } } + int extraCount = countByPaperId.get(paper.getId()).intValue() - 1; return StudentResponseDTO.UsablePartnershipDTO.builder() .partnershipId(paper.getId()) .adminName(paper.getAdmin() != null ? paper.getAdmin().getName() : null) @@ -176,12 +205,46 @@ public List getUsablePartnership(Long m .cost(content.getCost()) .category(finalCategory) .discountRate(content.getDiscount()) + .storeId(store != null ? store.getId() : null) + .extraCount(extraCount) + .partnerProfileUrl(getStoreProfileImageUrl(store)) .build(); }).toList(); return Boolean.FALSE.equals(all) ? result.stream().limit(2).toList() : result; } + @Override + @Transactional(readOnly = true) + public List getRecommendCarouselPartnership(Long memberId) { + List userPapers = userPaperRepository.findActivePartnershipsByStudentId(memberId, null, null); + List shuffled = new ArrayList<>(userPapers); + Collections.shuffle(shuffled); + List randomUserPapers = shuffled.subList(0, Math.min(10, shuffled.size())); + + List contentIds = randomUserPapers.stream() + .map(up -> up.getPaperContent().getId()) + .toList(); + Map> goodsMap = goodsRepository.findByContentIdIn(contentIds).stream() + .collect(Collectors.groupingBy(g -> g.getContent().getId())); + + return randomUserPapers.stream().map(up -> { + PaperContent content = up.getPaperContent(); + Store store = up.getPaper().getStore(); + + List goods = goodsMap.get(content.getId()); + String firstBelonging = (goods != null && !goods.isEmpty()) ? goods.get(0).getBelonging() : null; + + return new StudentResponseDTO.RecommendCarouselDTO( + content.getCategory(), + firstBelonging, + getStoreProfileImageUrl(store), + store != null ? store.getName() : null, + store != null ? store.getId() : null + ); + }).toList(); + } + @Transactional @Override public void syncUserPapersForStudent(Long studentId) { @@ -276,6 +339,146 @@ public StudentProfileResponseDTO getStudentProfile(Long memberId) { .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_STUDENT)); return StudentProfileResponseDTO.from(member); } + + @Override + @Transactional(readOnly = true) + public StudentHomeResponseDTO getStudentHome(Long memberId) { + Student student = studentRepository.findById(memberId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_STUDENT)); + + HomeCuration curation = homeCurationRepository.findLatest() + .orElse(null); + + String studentName = (student.getName() != null && !student.getName().isBlank()) ? student.getName() : "학생"; + + if (curation == null) { + List activeStores = storeRepository.findAll().stream() + .sorted(Comparator.comparing(Store::getRate, Comparator.nullsLast(Comparator.reverseOrder()))) + .limit(5) + .toList(); + + StudentHomeResponseDTO.FeaturedRecommendationDTO featuredDTO = null; + if (!activeStores.isEmpty()) { + Store fs = activeStores.get(0); + String fsImg = getStoreProfileImageUrl(fs); + String fsDiscount = resolveStoreDiscountDescription(fs.getId()); + featuredDTO = StudentHomeResponseDTO.FeaturedRecommendationDTO.of( + fs.getId(), + fs.getName(), + fsDiscount, + fs.getStoreCategory() != null ? fs.getStoreCategory().name() : null, + fsImg + ); + } + + List g1Stores = new ArrayList<>(); + List g2Stores = new ArrayList<>(); + if (activeStores.size() > 1) { + List rem = activeStores.subList(1, activeStores.size()); + for (int i = 0; i < rem.size(); i++) { + Store s = rem.get(i); + String sImg = getStoreProfileImageUrl(s); + String discount = resolveStoreDiscountDescription(s.getId()); + var storeDTO = StudentHomeResponseDTO.CurationStoreDTO.of( + s.getId(), + s.getName(), + discount, + s.getStoreCategory() != null ? s.getStoreCategory().name() : null, + sImg + ); + if (i < 2) { + g1Stores.add(storeDTO); + } else if (i < 4) { + g2Stores.add(storeDTO); + } + } + } + + return StudentHomeResponseDTO.of( + featuredDTO, + studentName + "님을 위한 제휴", + List.of( + StudentHomeResponseDTO.CurationGroupDTO.of(1, "추천 제휴 1", g1Stores), + StudentHomeResponseDTO.CurationGroupDTO.of(2, "추천 제휴 2", g2Stores) + ) + ); + } + + String rawTitle = curation.getTitle(); + String formattedTitle = rawTitle != null ? rawTitle.replace("{name}", studentName) : studentName + "님을 위한 제휴"; + + StudentHomeResponseDTO.FeaturedRecommendationDTO featuredDTO = null; + if (curation.getFeaturedStore() != null) { + Store fs = curation.getFeaturedStore(); + String fsImg = getStoreProfileImageUrl(fs); + String discount = curation.getFeaturedDiscountContent(); + if (discount == null || discount.isBlank()) { + discount = resolveStoreDiscountDescription(fs.getId()); + } + featuredDTO = StudentHomeResponseDTO.FeaturedRecommendationDTO.of( + fs.getId(), + fs.getName(), + discount, + fs.getStoreCategory() != null ? fs.getStoreCategory().name() : null, + fsImg + ); + } + + List items = homeCurationItemRepository.findByHomeCurationIdWithStoreAndPartner(curation.getId()); + Map> itemsByGroup = items.stream() + .collect(Collectors.groupingBy(HomeCurationItem::getGroupIndex)); + + List curationLists = new ArrayList<>(); + for (int groupIdx = 1; groupIdx <= 2; groupIdx++) { + List groupItems = itemsByGroup.getOrDefault(groupIdx, List.of()); + String groupTitle = groupItems.isEmpty() ? "추천 제휴 " + groupIdx : groupItems.get(0).getGroupTitle(); + + List storeList = groupItems.stream() + .map(item -> { + Store s = item.getStore(); + String sImg = getStoreProfileImageUrl(s); + String discount = item.getCustomDiscountContent(); + if (discount == null || discount.isBlank()) { + discount = resolveStoreDiscountDescription(s.getId()); + } + return StudentHomeResponseDTO.CurationStoreDTO.of( + s.getId(), + s.getName(), + discount, + s.getStoreCategory() != null ? s.getStoreCategory().name() : null, + sImg + ); + }) + .toList(); + + curationLists.add(StudentHomeResponseDTO.CurationGroupDTO.of(groupIdx, groupTitle, storeList)); + } + + return StudentHomeResponseDTO.of(featuredDTO, formattedTitle, curationLists); + } + + private String getStoreProfileImageUrl(Store store) { + if (store == null || store.getPartner() == null || store.getPartner().getMember() == null) { + return null; + } + String key = store.getPartner().getMember().getProfileUrl(); + if (key != null && !key.isBlank()) { + return amazonS3Manager.generatePresignedUrl(key); + } + return null; + } + + private String resolveStoreDiscountDescription(Long storeId) { + if (storeId == null) { + return ""; + } + List contents = paperContentRepository.findTopByStoreIdIn(List.of(storeId)); + if (!contents.isEmpty()) { + PaperContent pc = contents.get(0); + return PaperContentResponseDTO.toContentResponse(pc).paperContent(); + } + return "제휴 혜택 제공"; + } } diff --git a/src/test/java/com/assu/server/domain/map/service/MapServiceImplTest.java b/src/test/java/com/assu/server/domain/map/service/MapServiceImplTest.java index 2df13db1..3c2a7949 100644 --- a/src/test/java/com/assu/server/domain/map/service/MapServiceImplTest.java +++ b/src/test/java/com/assu/server/domain/map/service/MapServiceImplTest.java @@ -28,6 +28,7 @@ import com.assu.server.domain.partnership.repository.PaperContentRepository; import com.assu.server.domain.partnership.repository.PaperRepository; import com.assu.server.domain.store.entity.Store; +import com.assu.server.domain.store.entity.enums.StoreCategory; import com.assu.server.domain.store.repository.StoreRepository; import com.assu.server.domain.student.entity.UserPaper; import com.assu.server.domain.student.repository.UserPaperRepository; @@ -79,6 +80,144 @@ private UserPaper userPaper(Paper paper) { return UserPaper.builder().paper(paper).build(); } + // ===== getStores ===== + + @Test + @DisplayName("뷰포트 내 가게가 없으면 빈 리스트를 반환하고 UserPaper 조회를 하지 않는다") + void getStores_EmptyViewport_ReturnsEmptyList() { + // 1. Given + when(storeRepository.findAllWithinViewportWithPartner(anyString())).thenReturn(List.of()); + + // 2. When + List result = mapService.getStores(viewport(), STUDENT_ID, null, null); + + // 3. Then + assertTrue(result.isEmpty()); + verify(userPaperRepository, never()).findActivePartnershipsByStudentId(any(), any(), any()); + } + + @Test + @DisplayName("활성 제휴가 없는 학생이 주변 가게를 조회하면 빈 리스트를 반환한다") + void getStores_NoActivePartnership_ReturnsEmptyList() { + // 1. Given + when(storeRepository.findAllWithinViewportWithPartner(anyString())).thenReturn(List.of(store(500L, "숭실카페"))); + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, null)).thenReturn(List.of()); + + // 2. When + List result = mapService.getStores(viewport(), STUDENT_ID, null, null); + + // 3. Then + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("adminId 필터링 시 해당 학생회 제휴가 있는 가게만 반환한다") + void getStores_WithAdminIdFilter_ReturnsOnlyMatchingAdminStores() { + // 1. Given - A학생회(가게1), B학생회(가게2) 각각 제휴 + Store store1 = store(1L, "가게1"); + Store store2 = store(2L, "가게2"); + Admin adminA = admin(10L, "A학생회"); + Admin adminB = admin(20L, "B학생회"); + Paper paperA = Paper.builder().id(100L).store(store1).admin(adminA).build(); + Paper paperB = Paper.builder().id(200L).store(store2).admin(adminB).build(); + + when(storeRepository.findAllWithinViewportWithPartner(anyString())).thenReturn(List.of(store1, store2)); + // DB가 adminId=10으로 필터링해서 paperA만 반환 + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, 10L)) + .thenReturn(List.of(userPaper(paperA))); + + PaperContent contentA = PaperContent.builder().id(1000L).paper(paperA).note("A학생회 혜택").build(); + when(paperContentRepository.findByPaperIdIn(anyList())).thenReturn(List.of(contentA)); + + // 2. When - adminId=10(A학생회)으로 필터링 + List result = mapService.getStores(viewport(), STUDENT_ID, null, 10L); + + // 3. Then - 가게1만 반환 + assertEquals(1, result.size()); + assertEquals(1L, result.get(0).storeId()); + assertEquals("A학생회", result.get(0).partnerships().get(0).adminName()); + verify(userPaperRepository).findActivePartnershipsByStudentId(STUDENT_ID, null, 10L); + } + + @Test + @DisplayName("여러 학생회가 같은 가게와 제휴하면 학생회별로 혜택이 그룹화된다") + void getStores_MultipleAdmins_GroupsBenefitsByAdmin() { + // 1. Given - A학생회, B학생회 모두 가게1과 제휴 + Store store1 = store(1L, "가게1"); + Admin adminA = admin(10L, "A학생회"); + Admin adminB = admin(20L, "B학생회"); + Paper paperA = Paper.builder().id(100L).store(store1).admin(adminA).build(); + Paper paperB = Paper.builder().id(200L).store(store1).admin(adminB).build(); + + when(storeRepository.findAllWithinViewportWithPartner(anyString())).thenReturn(List.of(store1)); + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, null)) + .thenReturn(List.of(userPaper(paperA), userPaper(paperB))); + + PaperContent contentA = PaperContent.builder().id(1000L).paper(paperA).note("A혜택").build(); + PaperContent contentB = PaperContent.builder().id(2000L).paper(paperB).note("B혜택").build(); + when(paperContentRepository.findByPaperIdIn(anyList())).thenReturn(List.of(contentA, contentB)); + + // 2. When + List result = mapService.getStores(viewport(), STUDENT_ID, null, null); + + // 3. Then - 가게1에 2개의 학생회 혜택 + assertEquals(1, result.size()); + assertEquals(2, result.get(0).partnerships().size()); + } + + @Test + @DisplayName("storeCategory 필터링 시 해당 카테고리 가게만 반환하며 카테고리가 DTO에 포함된다") + void getStores_WithStoreCategoryFilter_ReturnsOnlyMatchingCategoryStores() { + // 1. Given - CAFE 가게1, RESTAURANT 가게2 둘 다 뷰포트 내 + Store cafeStore = Store.builder().id(1L).name("숭실카페").address("서울특별시 동작구") + .rate(4).latitude(37.50).longitude(126.96).storeCategory(StoreCategory.CAFE).build(); + Store restaurantStore = Store.builder().id(2L).name("숭실분식").address("서울특별시 동작구") + .rate(3).latitude(37.50).longitude(126.96).storeCategory(StoreCategory.RESTAURANT).build(); + Admin adminA = admin(10L, "A학생회"); + Paper cafeP = Paper.builder().id(100L).store(cafeStore).admin(adminA).build(); + + when(storeRepository.findAllWithinViewportWithPartner(anyString())) + .thenReturn(List.of(cafeStore, restaurantStore)); + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, StoreCategory.CAFE, null)) + .thenReturn(List.of(userPaper(cafeP))); + PaperContent content = PaperContent.builder().id(1000L).paper(cafeP).note("아메리카노 할인").build(); + when(paperContentRepository.findByPaperIdIn(anyList())).thenReturn(List.of(content)); + + // 2. When + List result = mapService.getStores(viewport(), STUDENT_ID, StoreCategory.CAFE, null); + + // 3. Then - CAFE 가게만 반환, storeCategory 포함 확인 + assertEquals(1, result.size()); + assertEquals(1L, result.get(0).storeId()); + assertEquals(StoreCategory.CAFE, result.get(0).storeCategory()); + verify(userPaperRepository).findActivePartnershipsByStudentId(STUDENT_ID, StoreCategory.CAFE, null); + result.forEach(r -> System.out.println("[storeCategory 필터] " + r)); + } + + @Test + @DisplayName("필터 없이 조회해도 응답 DTO에 가게 카테고리가 포함된다") + void getStores_NoFilter_StoreCategoryIncludedInResponse() { + // 1. Given + Store store = Store.builder().id(1L).name("숭실카페").address("서울특별시 동작구") + .rate(4).latitude(37.50).longitude(126.96).storeCategory(StoreCategory.CAFE).build(); + Paper paper = Paper.builder().id(100L).store(store).admin(admin(10L, "총학생회")).build(); + + when(storeRepository.findAllWithinViewportWithPartner(anyString())).thenReturn(List.of(store)); + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, null)) + .thenReturn(List.of(userPaper(paper))); + when(paperContentRepository.findByPaperIdIn(anyList())) + .thenReturn(List.of(PaperContent.builder().id(1000L).paper(paper).note("혜택").build())); + + // 2. When + List result = mapService.getStores(viewport(), STUDENT_ID, null, null); + + // 3. Then + assertEquals(StoreCategory.CAFE, result.get(0).storeCategory()); + result.forEach(r -> System.out.println("[필터 없음] " + r)); + } + + // ===== getPartners ===== + @Test @DisplayName("뷰포트 내 제휴업체가 없으면 빈 리스트를 반환한다") void getPartners_EmptyViewport_ReturnsEmptyList() { @@ -97,7 +236,7 @@ void getPartners_EmptyViewport_ReturnsEmptyList() { @DisplayName("활성 제휴가 없는 학생이 매장을 검색하면 빈 리스트를 반환한다") void searchStores_NoActivePartnership_ReturnsEmptyList() { // 1. Given - when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID)).thenReturn(List.of()); + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, null)).thenReturn(List.of()); // 2. When List result = mapService.searchStores("맥주", STUDENT_ID); @@ -112,7 +251,7 @@ void searchStores_NoMatchingKeyword_ReturnsEmptyList() { // 1. Given Store store = store(500L, "숭실마트"); Paper paper = Paper.builder().id(100L).store(store).admin(admin(10L, "총학생회")).build(); - when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID)) + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, null)) .thenReturn(List.of(userPaper(paper))); // 2. When @@ -128,7 +267,7 @@ void searchStores_MatchingKeyword_UsesNoteAsBenefit() { // 1. Given ("역전 할머니 맥주" 매장을 "할머니맥주"로 검색) Store store = store(500L, "역전 할머니 맥주"); Paper paper = Paper.builder().id(100L).store(store).admin(admin(10L, "총학생회")).build(); - when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID)) + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, null)) .thenReturn(List.of(userPaper(paper))); PaperContent content = PaperContent.builder() @@ -155,7 +294,7 @@ void searchStores_NoNote_GeneratesDiscountBenefitText() { // 1. Given Store store = store(500L, "숭실분식"); Paper paper = Paper.builder().id(100L).store(store).admin(admin(10L, "총학생회")).build(); - when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID)) + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, null)) .thenReturn(List.of(userPaper(paper))); PaperContent content = PaperContent.builder() @@ -180,7 +319,7 @@ void searchStores_NoNote_GeneratesServiceBenefitText() { // 1. Given Store store = store(500L, "숭실분식"); Paper paper = Paper.builder().id(100L).store(store).admin(admin(10L, "총학생회")).build(); - when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID)) + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, null)) .thenReturn(List.of(userPaper(paper))); PaperContent content = PaperContent.builder() @@ -205,7 +344,7 @@ void searchStores_NoPaperContent_ExcludesStore() { // 1. Given Store store = store(500L, "숭실분식"); Paper paper = Paper.builder().id(100L).store(store).admin(admin(10L, "총학생회")).build(); - when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID)) + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, null)) .thenReturn(List.of(userPaper(paper))); when(paperContentRepository.findByPaperIdIn(List.of(100L))).thenReturn(List.of()); diff --git a/src/test/java/com/assu/server/domain/store/service/StoreServiceImplTest.java b/src/test/java/com/assu/server/domain/store/service/StoreServiceImplTest.java index b801c1b4..3044ef04 100644 --- a/src/test/java/com/assu/server/domain/store/service/StoreServiceImplTest.java +++ b/src/test/java/com/assu/server/domain/store/service/StoreServiceImplTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; import java.util.List; @@ -15,15 +16,18 @@ import org.mockito.junit.jupiter.MockitoExtension; import com.assu.server.domain.certification.repository.QRCertificationRepository; +import com.assu.server.domain.member.entity.Member; import com.assu.server.domain.partner.entity.Partner; import com.assu.server.domain.partner.repository.PartnerRepository; import com.assu.server.domain.store.dto.StoreResponseDTO; import com.assu.server.domain.store.dto.TodayBestResponseDTO; import com.assu.server.domain.store.entity.Store; +import com.assu.server.domain.store.entity.enums.StoreCategory; import com.assu.server.domain.store.exception.CustomStoreException; import com.assu.server.domain.store.repository.StoreRepository; import com.assu.server.domain.student.repository.PartnershipUsageRepository; import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.infra.s3.AmazonS3Manager; @ExtendWith(MockitoExtension.class) class StoreServiceImplTest { @@ -43,7 +47,104 @@ class StoreServiceImplTest { @Mock private QRCertificationRepository qrCertificationRepository; + @Mock + private AmazonS3Manager amazonS3Manager; + private static final Long PARTNER_ID = 20L; + private static final Long STORE_ID = 500L; + + // ===== getStoreDetails ===== + + @Test + @DisplayName("존재하지 않는 storeId로 조회하면 NO_SUCH_STORE 예외가 발생한다") + void getStoreDetails_StoreNotFound_ThrowsException() { + // given + when(storeRepository.findById(STORE_ID)).thenReturn(Optional.empty()); + + // when + CustomStoreException exception = assertThrows(CustomStoreException.class, + () -> storeService.getStoreDetails(STORE_ID)); + + // then + assertEquals(ErrorStatus.NO_SUCH_STORE, exception.getCode()); + } + + @Test + @DisplayName("파트너와 프로필 이미지가 있으면 presigned URL을 반환한다") + void getStoreDetails_WithPartnerAndProfileUrl_ReturnsPresignedUrl() { + // given + Member member = mock(Member.class); + when(member.getProfileUrl()).thenReturn("partners/42/profile.jpg"); + + Partner partner = mock(Partner.class); + when(partner.getMember()).thenReturn(member); + when(partner.getPhoneNum()).thenReturn("02-820-0114"); + + Store store = Store.builder() + .id(STORE_ID).name("역할맥").address("서울시 동작구 상도로 123").detailAddress("1층") + .latitude(37.50).longitude(126.96).storeCategory(StoreCategory.BAR).rate(4) + .partner(partner).build(); + + when(storeRepository.findById(STORE_ID)).thenReturn(Optional.of(store)); + when(amazonS3Manager.generatePresignedUrl("partners/42/profile.jpg")).thenReturn("https://presigned.url"); + + // when + StoreResponseDTO.GetStoreDetailsDTO result = storeService.getStoreDetails(STORE_ID); + + // then + assertEquals(STORE_ID, result.storeId()); + assertEquals("역할맥", result.storeName()); + assertEquals("서울시 동작구 상도로 123", result.address()); + assertEquals("1층", result.detailAddress()); + assertEquals(37.50, result.latitude()); + assertEquals(126.96, result.longitude()); + assertEquals("BAR", result.storeCategory()); + assertEquals(4, result.rate()); + assertEquals("02-820-0114", result.phoneNumber()); + assertTrue(result.hasPartner()); + assertEquals("https://presigned.url", result.profileUrl()); + } + + @Test + @DisplayName("파트너가 있지만 프로필 이미지가 없으면 profileUrl은 null이다") + void getStoreDetails_WithPartnerButNoProfileUrl_ReturnsNullProfileUrl() { + // given + Member member = mock(Member.class); + when(member.getProfileUrl()).thenReturn(null); + + Partner partner = mock(Partner.class); + when(partner.getMember()).thenReturn(member); + when(partner.getPhoneNum()).thenReturn("02-820-0114"); + + Store store = Store.builder().id(STORE_ID).name("역할맥").partner(partner).build(); + when(storeRepository.findById(STORE_ID)).thenReturn(Optional.of(store)); + + // when + StoreResponseDTO.GetStoreDetailsDTO result = storeService.getStoreDetails(STORE_ID); + + // then + assertTrue(result.hasPartner()); + assertNull(result.profileUrl()); + verify(amazonS3Manager, never()).generatePresignedUrl(anyString()); + } + + @Test + @DisplayName("파트너가 없으면 hasPartner false, phoneNumber·profileUrl은 null이다") + void getStoreDetails_WithoutPartner_ReturnsHasPartnerFalse() { + // given + Store store = Store.builder().id(STORE_ID).name("미제휴매장").partner(null).build(); + when(storeRepository.findById(STORE_ID)).thenReturn(Optional.of(store)); + + // when + StoreResponseDTO.GetStoreDetailsDTO result = storeService.getStoreDetails(STORE_ID); + + // then + assertFalse(result.hasPartner()); + assertNull(result.phoneNumber()); + assertNull(result.profileUrl()); + } + + // ===== getTodayBestStore ===== @Test @DisplayName("오늘의 베스트 매장 이름 목록을 그대로 반환한다") diff --git a/src/test/java/com/assu/server/domain/student/service/StudentServiceImplTest.java b/src/test/java/com/assu/server/domain/student/service/StudentServiceImplTest.java index 9f657450..79c30826 100644 --- a/src/test/java/com/assu/server/domain/student/service/StudentServiceImplTest.java +++ b/src/test/java/com/assu/server/domain/student/service/StudentServiceImplTest.java @@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.*; +import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -18,6 +19,8 @@ import com.assu.server.domain.admin.entity.Admin; import com.assu.server.domain.admin.repository.AdminRepository; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.partner.entity.Partner; import com.assu.server.domain.common.entity.enums.University; import com.assu.server.domain.notification.service.NotificationCommandService; import com.assu.server.domain.partnership.entity.Goods; @@ -28,6 +31,7 @@ import com.assu.server.domain.partnership.repository.PaperContentRepository; import com.assu.server.domain.partnership.repository.PaperRepository; import com.assu.server.domain.store.entity.Store; +import com.assu.server.domain.store.entity.enums.StoreCategory; import com.assu.server.domain.student.dto.StudentResponseDTO; import com.assu.server.domain.student.entity.Student; import com.assu.server.domain.student.entity.UserPaper; @@ -37,6 +41,7 @@ import com.assu.server.domain.student.repository.UserPaperRepository; import com.assu.server.global.apiPayload.code.status.ErrorStatus; import com.assu.server.global.exception.DatabaseException; +import com.assu.server.infra.s3.AmazonS3Manager; @ExtendWith(MockitoExtension.class) class StudentServiceImplTest { @@ -71,6 +76,9 @@ class StudentServiceImplTest { @Mock private NotificationCommandService notificationCommandService; + @Mock + private AmazonS3Manager amazonS3Manager; + private static final Long STUDENT_ID = 1L; // ===== getStamp / addStamp ===== @@ -124,6 +132,19 @@ void addStamp_TenthStamp_AppliesEventAndResets() { // ===== getUsablePartnership ===== + private UserPaper usablePartnershipWithPartner(Long paperId, Long contentId, String profileKey) { + Admin admin = Admin.builder().id(10L).name("총학생회").isPhoneVerified(false).build(); + Member member = mock(Member.class); + when(member.getProfileUrl()).thenReturn(profileKey); + Partner partner = mock(Partner.class); + when(partner.getMember()).thenReturn(member); + Store store = Store.builder().id(500L).name("역전할머니 맥주").partner(partner).build(); + Paper paper = Paper.builder().id(paperId).admin(admin).store(store).build(); + PaperContent content = PaperContent.builder() + .id(contentId).paper(paper).category("안주").optionType(OptionType.SERVICE).build(); + return UserPaper.builder().paper(paper).paperContent(content).build(); + } + private UserPaper usablePartnership(Long paperId, Long contentId, String category, OptionType optionType) { Admin admin = Admin.builder().id(10L).name("총학생회").isPhoneVerified(false).build(); Store store = Store.builder().id(500L).name("역전할머니 맥주").build(); @@ -141,11 +162,11 @@ void getUsablePartnership_NotAll_LimitsToTwo() { usablePartnership(100L, 1000L, "안주", OptionType.SERVICE), usablePartnership(101L, 1001L, "음료", OptionType.SERVICE), usablePartnership(102L, 1002L, "주류", OptionType.SERVICE)); - when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID)).thenReturn(userPapers); + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, null)).thenReturn(userPapers); // 2. When List result = - studentService.getUsablePartnership(STUDENT_ID, false); + studentService.getUsablePartnership(STUDENT_ID, false, null, null); // 3. Then assertEquals(2, result.size()); @@ -159,11 +180,11 @@ void getUsablePartnership_All_ReturnsEverything() { usablePartnership(100L, 1000L, "안주", OptionType.SERVICE), usablePartnership(101L, 1001L, "음료", OptionType.SERVICE), usablePartnership(102L, 1002L, "주류", OptionType.SERVICE)); - when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID)).thenReturn(userPapers); + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, null)).thenReturn(userPapers); // 2. When List result = - studentService.getUsablePartnership(STUDENT_ID, true); + studentService.getUsablePartnership(STUDENT_ID, true, null, null); // 3. Then assertEquals(3, result.size()); @@ -176,7 +197,7 @@ void getUsablePartnership_All_ReturnsEverything() { void getUsablePartnership_NoCategory_FallsBackToGoodsBelonging() { // 1. Given (category가 null인 SERVICE 제휴) UserPaper userPaper = usablePartnership(100L, 1000L, null, OptionType.SERVICE); - when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID)).thenReturn(List.of(userPaper)); + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, null)).thenReturn(List.of(userPaper)); Goods goods = Goods.builder() .content(userPaper.getPaperContent()).belonging("생맥주 500cc").build(); @@ -184,12 +205,207 @@ void getUsablePartnership_NoCategory_FallsBackToGoodsBelonging() { // 2. When List result = - studentService.getUsablePartnership(STUDENT_ID, true); + studentService.getUsablePartnership(STUDENT_ID, true, null, null); // 3. Then assertEquals("생맥주 500cc", result.get(0).getCategory()); } + @Test + @DisplayName("storeCategory=BAR이면 레포지토리에 BAR가 전달되고 반환된 BAR 결과만 노출된다") + void getUsablePartnership_WithStoreCategory_PassesCategoryToRepository() { + // 1. Given - DB가 BAR만 필터링해서 2건 반환한다고 가정 + List barUserPapers = List.of( + usablePartnership(100L, 1000L, "안주", OptionType.SERVICE), + usablePartnership(101L, 1001L, "주류", OptionType.SERVICE)); + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, StoreCategory.BAR, null)) + .thenReturn(barUserPapers); + + // null이면 전체(BAR 2개 + RESTAURANT 1개) 반환 + List allUserPapers = List.of( + usablePartnership(100L, 1000L, "안주", OptionType.SERVICE), + usablePartnership(101L, 1001L, "주류", OptionType.SERVICE), + usablePartnership(102L, 1002L, "파스타", OptionType.SERVICE)); + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, null)) + .thenReturn(allUserPapers); + + // 2. When + List barResult = + studentService.getUsablePartnership(STUDENT_ID, true, StoreCategory.BAR, null); + List allResult = + studentService.getUsablePartnership(STUDENT_ID, true, null, null); + + // 3. Then + assertEquals(2, barResult.size()); + assertEquals(3, allResult.size()); + verify(userPaperRepository).findActivePartnershipsByStudentId(STUDENT_ID, StoreCategory.BAR, null); + verify(userPaperRepository).findActivePartnershipsByStudentId(STUDENT_ID, null, null); + } + + @Test + @DisplayName("adminId를 지정하면 레포지토리에 adminId가 전달되고 반환된 결과만 노출된다") + void getUsablePartnership_WithAdminId_PassesAdminIdToRepository() { + // 1. Given - DB가 adminId=10으로 필터링해서 1건 반환한다고 가정 + List filtered = List.of(usablePartnership(100L, 1000L, "안주", OptionType.SERVICE)); + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, 10L)).thenReturn(filtered); + + List all = List.of( + usablePartnership(100L, 1000L, "안주", OptionType.SERVICE), + usablePartnership(101L, 1001L, "음료", OptionType.SERVICE)); + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, null)).thenReturn(all); + + // 2. When + List filteredResult = + studentService.getUsablePartnership(STUDENT_ID, true, null, 10L); + List allResult = + studentService.getUsablePartnership(STUDENT_ID, true, null, null); + + // 3. Then + assertEquals(1, filteredResult.size()); + assertEquals(2, allResult.size()); + verify(userPaperRepository).findActivePartnershipsByStudentId(STUDENT_ID, null, 10L); + verify(userPaperRepository).findActivePartnershipsByStudentId(STUDENT_ID, null, null); + } + + @Test + @DisplayName("파트너 프로필 이미지가 있으면 getUsablePartnership은 presigned URL을 반환한다") + void getUsablePartnership_WithPartnerProfileUrl_ReturnsPresignedUrl() { + // given + UserPaper up = usablePartnershipWithPartner(100L, 1000L, "partners/42/profile.jpg"); + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, null)).thenReturn(List.of(up)); + when(goodsRepository.findByContentIdIn(anyList())).thenReturn(List.of()); + when(amazonS3Manager.generatePresignedUrl("partners/42/profile.jpg")).thenReturn("https://presigned.url"); + + // when + List result = + studentService.getUsablePartnership(STUDENT_ID, true, null, null); + + // then + assertEquals("https://presigned.url", result.get(0).getPartnerProfileUrl()); + } + + @Test + @DisplayName("파트너가 없으면 getUsablePartnership의 partnerProfileUrl은 null이다") + void getUsablePartnership_NoPartner_ReturnsNullProfileUrl() { + // given (기존 helper는 partner 없는 store) + UserPaper up = usablePartnership(100L, 1000L, "안주", OptionType.SERVICE); + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, null)).thenReturn(List.of(up)); + when(goodsRepository.findByContentIdIn(anyList())).thenReturn(List.of()); + + // when + List result = + studentService.getUsablePartnership(STUDENT_ID, true, null, null); + + // then + assertNull(result.get(0).getPartnerProfileUrl()); + verify(amazonS3Manager, never()).generatePresignedUrl(anyString()); + } + + // ===== getRecommendPartnership ===== + + @Test + @DisplayName("이용 가능한 제휴가 10개 이하면 전체를 반환한다") + void getRecommendPartnership_LessThan10_ReturnsAll() { + // given + List userPapers = List.of( + usablePartnership(100L, 1000L, "안주", OptionType.SERVICE), + usablePartnership(101L, 1001L, "음료", OptionType.SERVICE), + usablePartnership(102L, 1002L, "주류", OptionType.SERVICE)); + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, null)).thenReturn(userPapers); + when(goodsRepository.findByContentIdIn(anyList())).thenReturn(List.of()); + + // when + List result = studentService.getRecommendCarouselPartnership(STUDENT_ID); + + // then + assertEquals(3, result.size()); + } + + @Test + @DisplayName("이용 가능한 제휴가 10개를 초과하면 10개만 반환한다") + void getRecommendPartnership_MoreThan10_Returns10() { + // given (20개 생성) + List userPapers = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + userPapers.add(usablePartnership((long) (100 + i), (long) (1000 + i), "안주", OptionType.SERVICE)); + } + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, null)).thenReturn(userPapers); + when(goodsRepository.findByContentIdIn(anyList())).thenReturn(List.of()); + + // when + List result = studentService.getRecommendCarouselPartnership(STUDENT_ID); + + // then + assertEquals(10, result.size()); + } + + @Test + @DisplayName("이용 가능한 제휴가 없으면 빈 리스트를 반환한다") + void getRecommendPartnership_Empty_ReturnsEmptyList() { + // given + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, null)).thenReturn(List.of()); + when(goodsRepository.findByContentIdIn(anyList())).thenReturn(List.of()); + + // when + List result = studentService.getRecommendCarouselPartnership(STUDENT_ID); + + // then + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("10개 초과 시 goodsRepository는 선택된 10개의 contentId로만 조회한다") + void getRecommendPartnership_MoreThan10_QueriesOnlySelectedContentIds() { + // given (20개 생성) + List userPapers = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + userPapers.add(usablePartnership((long) (100 + i), (long) (1000 + i), "안주", OptionType.SERVICE)); + } + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, null)).thenReturn(userPapers); + when(goodsRepository.findByContentIdIn(anyList())).thenReturn(List.of()); + + // when + studentService.getRecommendCarouselPartnership(STUDENT_ID); + + // then + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(goodsRepository).findByContentIdIn(captor.capture()); + assertEquals(10, captor.getValue().size()); + } + + @Test + @DisplayName("파트너 프로필 이미지가 있으면 getRecommendCarouselPartnership은 presigned URL을 반환한다") + void getRecommendCarouselPartnership_WithPartnerProfileUrl_ReturnsPresignedUrl() { + // given + UserPaper up = usablePartnershipWithPartner(100L, 1000L, "partners/42/profile.jpg"); + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, null)).thenReturn(List.of(up)); + when(goodsRepository.findByContentIdIn(anyList())).thenReturn(List.of()); + when(amazonS3Manager.generatePresignedUrl("partners/42/profile.jpg")).thenReturn("https://presigned.url"); + + // when + List result = studentService.getRecommendCarouselPartnership(STUDENT_ID); + + // then + assertEquals("https://presigned.url", result.get(0).partnerProfileUrl()); + } + + @Test + @DisplayName("파트너가 없으면 getRecommendCarouselPartnership의 partnerProfileUrl은 null이다") + void getRecommendCarouselPartnership_NoPartner_ReturnsNullProfileUrl() { + // given + UserPaper up = usablePartnership(100L, 1000L, "안주", OptionType.SERVICE); + when(userPaperRepository.findActivePartnershipsByStudentId(STUDENT_ID, null, null)).thenReturn(List.of(up)); + when(goodsRepository.findByContentIdIn(anyList())).thenReturn(List.of()); + + // when + List result = studentService.getRecommendCarouselPartnership(STUDENT_ID); + + // then + assertNull(result.get(0).partnerProfileUrl()); + verify(amazonS3Manager, never()).generatePresignedUrl(anyString()); + } + // ===== syncUserPapersForStudent ===== @Test