From d10b543939fc97c8a8ac06eb7d51ee7fb3771d3c Mon Sep 17 00:00:00 2001 From: SJ Hwang Date: Fri, 6 Feb 2026 17:50:34 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[Refactor/#233]=20-=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/controller/AdminController.java | 18 +- .../domain/admin/dto/AdminRequestDTO.java | 4 - .../admin/repository/AdminRepository.java | 19 + .../domain/map/controller/MapController.java | 134 ++++++- .../domain/map/converter/MapConverter.java | 3 - .../server/domain/map/dto/MapRequestDTO.java | 9 - .../server/domain/map/dto/MapResponseDTO.java | 26 +- .../domain/map/dto/SelectedPlacePayload.java | 1 - .../domain/map/service/MapServiceImpl.java | 147 +++++--- .../partner/controller/PartnerController.java | 16 +- .../partner/repository/PartnerRepository.java | 25 +- .../partner/service/PartnerService.java | 1 - .../controller/PartnershipController.java | 354 ++++++++++++++---- .../dto/PartnershipRequestDTO.java | 13 +- .../dto/PartnershipResponseDTO.java | 11 +- .../repository/PaperRepository.java | 31 ++ .../service/PartnershipServiceImpl.java | 7 +- .../store/repository/StoreRepository.java | 20 + .../controller/SuggestionController.java | 53 ++- .../suggestion/dto/SuggestionRequestDTO.java | 6 +- .../suggestion/dto/SuggestionResponseDTO.java | 10 +- 21 files changed, 708 insertions(+), 200 deletions(-) delete mode 100644 src/main/java/com/assu/server/domain/admin/dto/AdminRequestDTO.java diff --git a/src/main/java/com/assu/server/domain/admin/controller/AdminController.java b/src/main/java/com/assu/server/domain/admin/controller/AdminController.java index 0ea470cd..0f89e649 100644 --- a/src/main/java/com/assu/server/domain/admin/controller/AdminController.java +++ b/src/main/java/com/assu/server/domain/admin/controller/AdminController.java @@ -6,23 +6,33 @@ import com.assu.server.global.apiPayload.code.status.SuccessStatus; import com.assu.server.global.util.PrincipalDetails; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "Admin", description = "관리자 API") @RestController -@RequestMapping("/admin") @RequiredArgsConstructor +@RequestMapping("/admin") public class AdminController { private final AdminService adminService; @Operation( - summary = "파트너 추천 API", - description = "제휴하지 않은 파트너 중 한 곳을 랜덤으로 조회합니다." - ) + summary = "제휴업체 추천 API", + description = "# [v1.3 (2026-01-04)](https://clumsy-seeder-416.notion.site/2591197c19ed80f5b05cffcfecef9c24?source=copy_link)\n" + + "- 현재 로그인 한 관리자와 제휴하지 않은 제휴업체 중 한 곳을 랜덤으로 조회합니다.\n" + + "\n**Response:**\n" + + " - 성공 시 200(OK)과 `RandomPartnerResponse` 객체 반환\n" + + " - `partnerId` (Long): 제휴업체 ID\n" + + " - `partnerAddress` (String): 제휴업체 주소\n" + + " - `partnerDetailAddress` (String): 제휴업체 상세주소\n" + + " - `partnerName` (String): 제휴업체 상호명\n" + + " - `partnerUrl` (String): 제휴업체 카카오맵 URL\n" + + " - `partnerPhone` (String): 제휴업체 전화번호\n") @GetMapping("/partner-recommend") public BaseResponse randomPartnerRecommend( @AuthenticationPrincipal PrincipalDetails pd diff --git a/src/main/java/com/assu/server/domain/admin/dto/AdminRequestDTO.java b/src/main/java/com/assu/server/domain/admin/dto/AdminRequestDTO.java deleted file mode 100644 index bf5c379a..00000000 --- a/src/main/java/com/assu/server/domain/admin/dto/AdminRequestDTO.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.assu.server.domain.admin.dto; - -public class AdminRequestDTO { -} diff --git a/src/main/java/com/assu/server/domain/admin/repository/AdminRepository.java b/src/main/java/com/assu/server/domain/admin/repository/AdminRepository.java index 8a1eaeeb..9fe072b6 100644 --- a/src/main/java/com/assu/server/domain/admin/repository/AdminRepository.java +++ b/src/main/java/com/assu/server/domain/admin/repository/AdminRepository.java @@ -63,6 +63,15 @@ AND ST_Contains(ST_GeomFromText(:wkt, 4326), a.point) """, nativeQuery = true) List findAllWithinViewport(@Param("wkt") String wkt); + @Query(""" + SELECT DISTINCT a + FROM Admin a + LEFT JOIN FETCH a.member + WHERE a.point IS NOT NULL + AND function('ST_Contains', function('ST_GeomFromText', :wkt, 4326), a.point) = true + """) + List findAllWithinViewportWithMember(@Param("wkt") String wkt); + @Query(""" select distinct a from Admin a @@ -72,6 +81,16 @@ List searchAdminByKeyword( @Param("keyword") String keyword ); + @Query(""" + SELECT DISTINCT a + FROM Admin a + LEFT JOIN FETCH a.member + WHERE LOWER(a.name) LIKE LOWER(CONCAT('%', :keyword, '%')) + """) + List searchAdminByKeywordWithMember( + @Param("keyword") String keyword + ); + Long member(Member member); Optional findById(Long id); 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 e22be087..46abea5e 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 @@ -10,6 +10,7 @@ import com.assu.server.global.apiPayload.code.status.SuccessStatus; import com.assu.server.global.util.PrincipalDetails; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -17,6 +18,7 @@ import java.util.List; +@Tag(name = "Map", description = "지도 API") @RestController @RequiredArgsConstructor @RequestMapping("/map") @@ -27,8 +29,63 @@ public class MapController { @Operation( summary = "주변 장소 조회 API", - description = "공간 인덱싱에 들어갈 좌표 4개를 경도, 위도 순서로 입력해주세요 (user -> store 조회 / admin -> partner 조회 / partner -> admin 조회)" - ) + description = "# [v1.3 (2025-01-04)](https://clumsy-seeder-416.notion.site/2441197c19ed80bcb55fcad675dd9837?source=copy_link)\n" + + "- 로그인한 유저의 역할에 따라 Map 객체를 반환합니다.\n" + + "- 경도, 위도 순서로 입력한 Viewport 객체 입력.\n" + + "- 성공 시 200(OK)과 Map 객체 반환.\n"+ + "\n**Request Body:**\n" + + " - `lng1` (double): 좌 상단 경도\n" + + " - `lat1` (double): 좌 상단 위도\n" + + " - `lng2` (double): 우 상단 경도\n" + + " - `lat2` (double): 우 상단 위도\n" + + " - `lng3` (double): 우 하단 경도\n" + + " - `lat3` (double): 우 하단 위도\n" + + " - `lng4` (double): 좌 하단 경도\n" + + " - `lat4` (double): 좌 하단 위도\n" + + "\n**Response:**\n" + + " - `User`: 가게 조회\n" + + " - `storeId` (Long): 가게 ID\n" + + " - `adminId` (Long): 관리자 ID\n" + + " - `adminName` (String): 관리자 이름\n" + + " - `name` (String): 가게 이름\n" + + " - `address` (String): 가게 주소\n" + + " - `rate` (Integer): 가게 별점\n" + + " - `criterionType` (CriterionType): 서비스 제공 기준(PRICE/HEADCOUNT)\n" + + " - `optionType` (OptionType): 제공 서비스 종류(SERVICE/DISCOUNT)\n" + + " - `people` (Integer): 인원 수(HEADCOUNT)\n" + + " - `cost` (Long): 가격(PRICE)\n" + + " - `category` (String): 카테고리(SERVICE)\n" + + " - `note` (String): 제휴 설명 (형식에 맞지 않는 제휴일때)\n" + + " - `discountRate` (Long): 할인률(DISCOUNT)\n" + + " - `hasPartner` (boolean): 제휴업체인지 여부\n" + + " - `latitude` (Double): 가게 위치 위도\n" + + " - `longitude` (Double): 가게 위치 경도\n" + + " - `profileUrl` (String): 가게 카카오맵 URL\n" + + " - `phoneNumber` (String): 가게 전화번호\n" + + " - `Admin`: 제휴업체 조회\n" + + " - `partnerId` (Long): 제휴업체 ID\n" + + " - `name` (String): 제휴업체 이름\n" + + " - `address` (String): 제휴업체 주소\n" + + " - `isPartnered` (boolean): 관리자와 제휴 여부\n" + + " - `partnershipId` (Long): 제휴 ID\n" + + " - `partnershipStartDate` (LocalDate): 제휴 시작일\n" + + " - `partnershipEndDate` (LocalDate): 제휴 마감일\n" + + " - `latitude` (Double): 제휴업체 위도\n" + + " - `longitude` (Double): 제휴업체 경도\n" + + " - `profileUrl` (String): 제휴업체 카카오맵 Url\n" + + " - `phoneNumber` (String): 제휴업체 전화번호\n" + + " - `Partner`: 관리자 조회\n" + + " - `adminId` (Long): 관리자 ID\n" + + " - `name` (String): 관리자 이름\n" + + " - `address` (String): 관리자 주소\n" + + " - `isPartnered` (boolean): 제휴업체와 제휴 여부\n" + + " - `partnershipId` (Long): 제휴 ID\n" + + " - `partnershipStartDate` (LocalDate): 제휴 시작일\n" + + " - `partnershipEndDate` (LocalDate): 제휴 마감일\n" + + " - `latitude` (Double): 관리자 위도\n" + + " - `longitude` (Double): 관리자 경도\n" + + " - `profileUrl` (String): 관리자 카카오맵 Url\n" + + " - `phoneNumber` (String): 관리자 전화번호\n") @GetMapping("/nearby") public BaseResponse getLocations( @ModelAttribute MapRequestDTO.ViewOnMapDTO viewport, @@ -47,8 +104,56 @@ public BaseResponse getLocations( @Operation( summary = "검색어 기반 장소 조회 API", - description = "검색어를 입력해주세요. (user → store 전체조회 / admin → partner 전체조회 / partner → admin 전체조회)" - ) + description = "# [v1.3 (2025-01-04)](https://clumsy-seeder-416.notion.site/2591197c19ed8017adf1f3d711bab3d4)\n" + + "- 로그인한 유저의 역할과 검색어에 따라 Map 객체를 반환합니다.\n" + + "- 검색어(searchKeyword) 입력.\n" + + "- 성공 시 200(OK)과 Map 객체 반환.\n"+ + "\n**Request Parts:**\n" + + " - `searchKeyword` (String): 검색어\n" + + "\n**Response:**\n" + + " - `User`: 가게 조회\n" + + " - `storeId` (Long): 가게 ID\n" + + " - `adminId` (Long): 관리자 ID\n" + + " - `adminName` (String): 관리자 이름\n" + + " - `name` (String): 가게 이름\n" + + " - `address` (String): 가게 주소\n" + + " - `rate` (Integer): 가게 별점\n" + + " - `criterionType` (CriterionType): 서비스 제공 기준(PRICE/HEADCOUNT)\n" + + " - `optionType` (OptionType): 제공 서비스 종류(SERVICE/DISCOUNT)\n" + + " - `people` (Integer): 인원 수(HEADCOUNT)\n" + + " - `cost` (Long): 가격(PRICE)\n" + + " - `category` (String): 카테고리(SERVICE)\n" + + " - `note` (String): 제휴 설명 (형식에 맞지 않는 제휴일때)\n" + + " - `discountRate` (Long): 할인률(DISCOUNT)\n" + + " - `hasPartner` (boolean): 제휴업체인지 여부\n" + + " - `latitude` (Double): 가게 위치 위도\n" + + " - `longitude` (Double): 가게 위치 경도\n" + + " - `profileUrl` (String): 가게 카카오맵 URL\n" + + " - `phoneNumber` (String): 가게 전화번호\n" + + " - `Admin`: 제휴업체 조회\n" + + " - `partnerId` (Long): 제휴업체 ID\n" + + " - `name` (String): 제휴업체 이름\n" + + " - `address` (String): 제휴업체 주소\n" + + " - `isPartnered` (boolean): 관리자와 제휴 여부\n" + + " - `partnershipId` (Long): 제휴 ID\n" + + " - `partnershipStartDate` (LocalDate): 제휴 시작일\n" + + " - `partnershipEndDate` (LocalDate): 제휴 마감일\n" + + " - `latitude` (Double): 제휴업체 위도\n" + + " - `longitude` (Double): 제휴업체 경도\n" + + " - `profileUrl` (String): 제휴업체 카카오맵 Url\n" + + " - `phoneNumber` (String): 제휴업체 전화번호\n" + + " - `Partner`: 관리자 조회\n" + + " - `adminId` (Long): 관리자 ID\n" + + " - `name` (String): 관리자 이름\n" + + " - `address` (String): 관리자 주소\n" + + " - `isPartnered` (boolean): 제휴업체와 제휴 여부\n" + + " - `partnershipId` (Long): 제휴 ID\n" + + " - `partnershipStartDate` (LocalDate): 제휴 시작일\n" + + " - `partnershipEndDate` (LocalDate): 제휴 마감일\n" + + " - `latitude` (Double): 관리자 위도\n" + + " - `longitude` (Double): 관리자 경도\n" + + " - `profileUrl` (String): 관리자 카카오맵 Url\n" + + " - `phoneNumber` (String): 관리자 전화번호\n") @GetMapping("/search") public BaseResponse getLocationsByKeyword( @RequestParam("searchKeyword") @NotNull String keyword, @@ -76,8 +181,25 @@ public BaseResponse getLocationsByKeyword( @Operation( summary = "주소 입력 시 장소 검색용 API", - description = "검색어를 기반으로 장소를 검색하여 리스트로 반환합니다. limit로 개수를 제한할 수 있습니다." - ) + description = "# [v1.3 (2025-01-04)](https://clumsy-seeder-416.notion.site/25d1197c19ed807eb7a7c5ede2e75040)\n" + + "- 검색어에 따라 Map 객체를 반환합니다.\n" + + "- 검색어(searchKeyword) 입력.\n" + + "- limit을 통해 Map 객체 수 제한.\n" + + "- 성공 시 200(OK)과 Map 객체 반환.\n"+ + "\n**Request Parts:**\n" + + " - `searchKeyword` (String): 검색어\n" + + " - `limit` (Integer): 결과 객체 수\n" + + "\n**Response:**\n" + + " - `placeId` (String): kakao place ID\n" + + " - `name` (String): kakao place 이름\n" + + " - `category` (String): kakao 카테고리 또는 그룹 이름\n" + + " - `address` (String): 지번 주소\n" + + " - `roadAddress` (String): 도로명 주소\n" + + " - `phone` (String): 장소 전화번호\n" + + " - `placeUrl` (String): kakao place 상세 URL\n" + + " - `latitude` (Double): 장소 위도\n" + + " - `longitude` (Double): 장소 경도\n" + + " - `distance` (Integer): 장소의 m 좌표 (좌표바이어스/카테고리 검색 시 제공)\n") @GetMapping("/place") public BaseResponse> search( @RequestParam("searchKeyword") String query, diff --git a/src/main/java/com/assu/server/domain/map/converter/MapConverter.java b/src/main/java/com/assu/server/domain/map/converter/MapConverter.java index 1d1a9af6..f43b4ae9 100644 --- a/src/main/java/com/assu/server/domain/map/converter/MapConverter.java +++ b/src/main/java/com/assu/server/domain/map/converter/MapConverter.java @@ -9,9 +9,6 @@ public class MapConverter { - - - private static List extractGoods(PaperContent content) { if (content.getOptionType() == OptionType.SERVICE ) { return content.getGoods().stream() diff --git a/src/main/java/com/assu/server/domain/map/dto/MapRequestDTO.java b/src/main/java/com/assu/server/domain/map/dto/MapRequestDTO.java index 6b97d05c..a707fce4 100644 --- a/src/main/java/com/assu/server/domain/map/dto/MapRequestDTO.java +++ b/src/main/java/com/assu/server/domain/map/dto/MapRequestDTO.java @@ -18,13 +18,4 @@ public static class ViewOnMapDTO { private double lat4; } - @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder - public static class ConfirmRequest { - private String placeId; - private String name; - private String address; // 지번 - private String roadAddress; // 도로명 - private Double longitude; // x - private Double latitude; // y - } } diff --git a/src/main/java/com/assu/server/domain/map/dto/MapResponseDTO.java b/src/main/java/com/assu/server/domain/map/dto/MapResponseDTO.java index 23d63f46..852b70e6 100644 --- a/src/main/java/com/assu/server/domain/map/dto/MapResponseDTO.java +++ b/src/main/java/com/assu/server/domain/map/dto/MapResponseDTO.java @@ -5,7 +5,6 @@ import lombok.*; import java.time.LocalDate; -import java.util.List; public class MapResponseDTO { @@ -70,17 +69,20 @@ public static class StoreMapResponseDTO { private String phoneNumber; } - @Getter @NoArgsConstructor @AllArgsConstructor @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder public static class PlaceSuggestionDTO { - private String placeId; // kakao place id - private String name; // place_name - private String category; // category_name or category_group_name - private String address; // 지번 주소 - private String roadAddress; // 도로명 주소 - private String phone; // 전화 - private String placeUrl; // 카카오 상세 URL - private Double latitude; // y - private Double longitude; // x - private Integer distance; // m (좌표바이어스/카테고리 검색 시 제공) + private String placeId; + private String name; + private String category; + private String address; + private String roadAddress; + private String phone; + private String placeUrl; + private Double latitude; + private Double longitude; + private Integer distance; } } diff --git a/src/main/java/com/assu/server/domain/map/dto/SelectedPlacePayload.java b/src/main/java/com/assu/server/domain/map/dto/SelectedPlacePayload.java index fc2119ca..9837f544 100644 --- a/src/main/java/com/assu/server/domain/map/dto/SelectedPlacePayload.java +++ b/src/main/java/com/assu/server/domain/map/dto/SelectedPlacePayload.java @@ -8,7 +8,6 @@ @AllArgsConstructor @Builder public class SelectedPlacePayload { - private String placeId; private String name; private String address; 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 2e936056..f7636770 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 @@ -34,6 +34,7 @@ import software.amazon.awssdk.services.s3.auth.scheme.internal.S3EndpointResolverAware; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; @Service @@ -53,11 +54,19 @@ public class MapServiceImpl implements MapService { public List getPartners(MapRequestDTO.ViewOnMapDTO viewport, Long memberId) { String wkt = toWKT(viewport); - List partners = partnerRepository.findAllWithinViewport(wkt); + List partners = partnerRepository.findAllWithinViewportWithMember(wkt); + + if (partners.isEmpty()) { + return List.of(); + } + + List partnerIds = partners.stream().map(Partner::getId).toList(); + List papers = paperRepository.findByAdminIdAndPartnerIdInAndIsActivated(memberId, partnerIds, ActivationStatus.ACTIVE); + Map partnerIdToPaper = papers.stream() + .collect(Collectors.toMap(p -> p.getPartner().getId(), p -> p, (p1, p2) -> p1.getId() > p2.getId() ? p1 : p2)); return partners.stream().map(p -> { - Paper active = paperRepository.findTopByAdmin_IdAndPartner_IdAndIsActivatedOrderByIdDesc(memberId, p.getId(), ActivationStatus.ACTIVE) - .orElse(null); + Paper active = partnerIdToPaper.get(p.getId()); String key = (p.getMember() != null) ? p.getMember().getProfileUrl() : null; String url = amazonS3Manager.generatePresignedUrl(key); @@ -81,11 +90,19 @@ public List getPartners(MapRequestDTO.View @Override public List getAdmins(MapRequestDTO.ViewOnMapDTO viewport, Long memberId) { String wkt = toWKT(viewport); - List admins = adminRepository.findAllWithinViewport(wkt); + List admins = adminRepository.findAllWithinViewportWithMember(wkt); + + if (admins.isEmpty()) { + return List.of(); + } + + List adminIds = admins.stream().map(Admin::getId).toList(); + List papers = paperRepository.findByAdminIdInAndPartnerIdAndIsActivated(adminIds, memberId, ActivationStatus.ACTIVE); + Map adminIdToPaper = papers.stream() + .collect(Collectors.toMap(p -> p.getAdmin().getId(), p -> p, (p1, p2) -> p1.getId() > p2.getId() ? p1 : p2)); return admins.stream().map(a -> { - Paper active = paperRepository.findTopByAdmin_IdAndPartner_IdAndIsActivatedOrderByIdDesc(a.getId(), memberId, ActivationStatus.ACTIVE) - .orElse(null); + Paper active = adminIdToPaper.get(a.getId()); String key = (a.getMember() != null) ? a.getMember().getProfileUrl() : null; String url = amazonS3Manager.generatePresignedUrl(key); @@ -110,14 +127,33 @@ public List getAdmins(MapRequestDTO.ViewOnMa public List getStores(MapRequestDTO.ViewOnMapDTO viewport, Long memberId) { final String wkt = toWKT(viewport); - // 1) 뷰포트 내 매장 조회 - final List stores = storeRepository.findAllWithinViewport(wkt); - - // 2) 매장별 content는 "있으면 사용, 없으면 null" 전략 + // 1) 뷰포트 내 매장 조회 (Partner, Member fetch join) + final List stores = storeRepository.findAllWithinViewportWithPartner(wkt); + + if (stores.isEmpty()) { + return List.of(); + } + + // 2) Paper 및 Admin 정보 batch 조회 + List storeIds = stores.stream().map(Store::getId).toList(); + List papers = paperRepository.findByStoreIdIn(storeIds); + Map storeIdToPaper = papers.stream() + .collect(Collectors.toMap(p -> p.getStore().getId(), p -> p, (p1, p2) -> p1.getId() > p2.getId() ? p1 : p2)); + + List adminIds = papers.stream() + .map(p -> p.getAdmin() != null ? p.getAdmin().getId() : null) + .filter(id -> id != null) + .distinct() + .toList(); + List admins = adminIds.isEmpty() ? List.of() : adminRepository.findAllById(adminIds); + Map adminIdToAdmin = admins.stream() + .collect(Collectors.toMap(Admin::getId, a -> a)); + + // 3) 매장별 DTO 생성 return stores.stream().map(s -> { final boolean hasPartner = (s.getPartner() != null); - // 2-1) 유효한 paper_content만 조회 (없으면 null 허용) + // 3-1) 유효한 paper_content만 조회 (없으면 null 허용) final PaperContent content = paperContentRepository.findLatestValidByStoreIdNative( s.getId(), ActivationStatus.ACTIVE.name(), @@ -127,18 +163,13 @@ public List getStores(MapRequestDTO.ViewOnMa CriterionType.HEADCOUNT.name() ).orElse(null); - // 2-2) admin 정보 (null-safe) - final Long adminId = paperRepository.findTopPaperByStoreId(s.getId()) - .map(p -> p.getAdmin() != null ? p.getAdmin().getId() : null) - .orElse(null); - - String adminName = null; - if (adminId != null) { - final Admin admin = adminRepository.findById(adminId).orElse(null); - adminName = (admin != null ? admin.getName() : null); - } + // 3-2) admin 정보 (null-safe) + final Paper paper = storeIdToPaper.get(s.getId()); + final Long adminId = paper != null && paper.getAdmin() != null ? paper.getAdmin().getId() : null; + final String adminName = adminId != null ? adminIdToAdmin.getOrDefault(adminId, null) != null + ? adminIdToAdmin.get(adminId).getName() : null : null; - // 2-3) S3 presigned URL (키가 없으면 null) + // 3-3) S3 presigned URL (키가 없으면 null) final String key = (s.getPartner() != null && s.getPartner().getMember() != null) ? s.getPartner().getMember().getProfileUrl() : null; @@ -151,7 +182,7 @@ public List getStores(MapRequestDTO.ViewOnMa ? s.getPartner().getMember().getPhoneNum() : ""; - // 2-4) DTO 빌드 (content null 허용) + // 3-4) DTO 빌드 (content null 허용) return MapResponseDTO.StoreMapResponseDTO.builder() .storeId(s.getId()) .adminId(adminId) @@ -176,42 +207,54 @@ public List getStores(MapRequestDTO.ViewOnMa @Override public List searchStores(String keyword) { - List stores = storeRepository.findByNameContainingIgnoreCaseOrderByIdDesc(keyword); + List stores = storeRepository.findByNameContainingIgnoreCaseOrderByIdDescWithPartner(keyword); + + if (stores.isEmpty()) { + return List.of(); + } + + List storeIds = stores.stream().map(Store::getId).toList(); + List papers = paperRepository.findByStoreIdIn(storeIds); + Map storeIdToPaper = papers.stream() + .collect(Collectors.toMap(p -> p.getStore().getId(), p -> p, (p1, p2) -> p1.getId() > p2.getId() ? p1 : p2)); + + List adminIds = papers.stream() + .map(p -> p.getAdmin() != null ? p.getAdmin().getId() : null) + .filter(id -> id != null) + .distinct() + .toList(); + List admins = adminIds.isEmpty() ? List.of() : adminRepository.findAllById(adminIds); + Map adminIdToAdmin = admins.stream() + .collect(Collectors.toMap(Admin::getId, a -> a)); return stores.stream().map(s -> { boolean hasPartner = s.getPartner() != null; PaperContent content = paperContentRepository.findTopByPaperStoreIdOrderByIdDesc(s.getId()) .orElse(null); - String key = (s.getPartner() != null) ? s.getPartner().getMember().getProfileUrl() : null; + String key = (s.getPartner() != null && s.getPartner().getMember() != null) + ? s.getPartner().getMember().getProfileUrl() : null; String url = amazonS3Manager.generatePresignedUrl(key); - Long adminId = paperRepository.findTopPaperByStoreId(s.getId()) - .map(p -> p.getAdmin() != null ? p.getAdmin().getId() : null) - .orElse(null); - - Admin admin = adminRepository.findById(adminId).orElse(null); + Paper paper = storeIdToPaper.get(s.getId()); + Long adminId = paper != null && paper.getAdmin() != null ? paper.getAdmin().getId() : null; + Admin admin = adminId != null ? adminIdToAdmin.get(adminId) : null; String finalCategory = null; if (content != null) { - // 2. content에 카테고리가 이미 존재하면 그 값을 사용합니다. if (content.getCategory() != null) { finalCategory = content.getCategory(); } - // 3. 카테고리가 없고, 옵션 타입이 SERVICE인 경우 Goods를 조회합니다. else if (content.getOptionType() == OptionType.SERVICE) { List goods = goodsRepository.findByContentId(content.getId()); - // 4. (가장 중요) goods 리스트가 비어있지 않은지 반드시 확인합니다. if (!goods.isEmpty()) { finalCategory = goods.get(0).getBelonging(); } - // goods가 비어있으면 finalCategory는 그대로 null로 유지됩니다. } } - // phoneNumber null-safe 처리 (빈 문자열로 변환) String phoneNumber = (s.getPartner() != null && s.getPartner().getMember() != null && s.getPartner().getMember().getPhoneNum() != null) @@ -223,7 +266,7 @@ else if (content.getOptionType() == OptionType.SERVICE) { .adminName(admin != null ? admin.getName() : null) .adminId(adminId) .name(s.getName()) - .note(content.getNote()) + .note(content != null ? content.getNote() : null) .address(s.getAddress() != null ? s.getAddress() : s.getDetailAddress()) .rate(s.getRate()) .criterionType(content != null ? content.getCriterionType() : null) @@ -243,17 +286,24 @@ else if (content.getOptionType() == OptionType.SERVICE) { @Override public List searchPartner(String keyword, Long memberId) { - List partners = partnerRepository.searchPartnerByKeyword(keyword); + List partners = partnerRepository.searchPartnerByKeywordWithMember(keyword); + + if (partners.isEmpty()) { + return List.of(); + } + + List partnerIds = partners.stream().map(Partner::getId).toList(); + List papers = paperRepository.findByAdminIdAndPartnerIdInAndIsActivated(memberId, partnerIds, ActivationStatus.ACTIVE); + Map partnerIdToPaper = papers.stream() + .collect(Collectors.toMap(p -> p.getPartner().getId(), p -> p, (p1, p2) -> p1.getId() > p2.getId() ? p1 : p2)); return partners.stream().map(p -> { - Paper active = paperRepository - .findTopByAdmin_IdAndPartner_IdAndIsActivatedOrderByIdDesc(memberId, p.getId(), ActivationStatus.ACTIVE) - .orElse(null); + Paper active = partnerIdToPaper.get(p.getId()); String key = (p.getMember() != null) ? p.getMember().getProfileUrl() : null; String url = amazonS3Manager.generatePresignedUrl(key); - return MapResponseDTO.PartnerMapResponseDTO.builder() + return MapResponseDTO.PartnerMapResponseDTO.builder() .partnerId(p.getId()) .name(p.getName()) .address(p.getAddress() != null ? p.getAddress() : p.getDetailAddress()) @@ -271,12 +321,19 @@ public List searchPartner(String keyword, @Override public List searchAdmin(String keyword, Long memberId) { - List admins = adminRepository.searchAdminByKeyword(keyword); + List admins = adminRepository.searchAdminByKeywordWithMember(keyword); + + if (admins.isEmpty()) { + return List.of(); + } + + List adminIds = admins.stream().map(Admin::getId).toList(); + List papers = paperRepository.findByAdminIdInAndPartnerIdAndIsActivated(adminIds, memberId, ActivationStatus.ACTIVE); + Map adminIdToPaper = papers.stream() + .collect(Collectors.toMap(p -> p.getAdmin().getId(), p -> p, (p1, p2) -> p1.getId() > p2.getId() ? p1 : p2)); return admins.stream().map(a -> { - Paper active = paperRepository - .findTopByAdmin_IdAndPartner_IdAndIsActivatedOrderByIdDesc(a.getId(), memberId, ActivationStatus.ACTIVE) - .orElse(null); + Paper active = adminIdToPaper.get(a.getId()); String key = (a.getMember() != null) ? a.getMember().getProfileUrl() : null; String url = amazonS3Manager.generatePresignedUrl(key); diff --git a/src/main/java/com/assu/server/domain/partner/controller/PartnerController.java b/src/main/java/com/assu/server/domain/partner/controller/PartnerController.java index cfd7e55f..60affa3f 100644 --- a/src/main/java/com/assu/server/domain/partner/controller/PartnerController.java +++ b/src/main/java/com/assu/server/domain/partner/controller/PartnerController.java @@ -6,10 +6,12 @@ import com.assu.server.global.apiPayload.code.status.SuccessStatus; import com.assu.server.global.util.PrincipalDetails; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +@Tag(name = "Partner", description = "제휴업체 API") @RestController @RequestMapping("/partner") @RequiredArgsConstructor @@ -18,9 +20,17 @@ public class PartnerController { private final PartnerService partnerService; @Operation( - summary = "어드민 추천 API", - description = "제휴하지 않은 어드민 중 두 곳을 랜덤으로 조회합니다." - ) + summary = "관리자 추천 API", + description = "# [v1.3 (2026-01-04)](https://clumsy-seeder-416.notion.site/2591197c19ed80368a9edf1f6e92ea38)\n" + + "- 현재 로그인 한 제휴업체와 제휴하지 않은 관리자 중 최대 두 곳을 랜덤으로 조회합니다.\n" + + "\n**Response:**\n" + + " - 성공 시 200(OK)과 `RandomAdminResponse` 객체(최대 두 개) 반환\n" + + " - `adminId` (Long): 관리자 ID\n" + + " - `adminAddress` (String): 관리자 주소\n" + + " - `adminDetailAddress` (String): 관리자 상세주소\n" + + " - `adminName` (String): 관리자 상호명\n" + + " - `adminUrl` (String): 관리자 카카오맵 URL\n" + + " - `adminPhone` (String): 관리자 전화번호\n") @GetMapping("/admin-recommend") public BaseResponse randomAdminRecommend( @AuthenticationPrincipal PrincipalDetails pd diff --git a/src/main/java/com/assu/server/domain/partner/repository/PartnerRepository.java b/src/main/java/com/assu/server/domain/partner/repository/PartnerRepository.java index df9aa420..adcd6562 100644 --- a/src/main/java/com/assu/server/domain/partner/repository/PartnerRepository.java +++ b/src/main/java/com/assu/server/domain/partner/repository/PartnerRepository.java @@ -1,12 +1,9 @@ package com.assu.server.domain.partner.repository; -import com.assu.server.domain.common.enums.ActivationStatus; import com.assu.server.domain.partner.entity.Partner; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import java.util.List; @@ -38,20 +35,22 @@ SELECT COUNT(*) Partner findUnpartneredActiveByAdminWithOffset(@Param("adminId") Long adminId, @Param("offset") int offset); - @Query(value = """ - SELECT p.* - FROM partner p + @Query(""" + SELECT DISTINCT p + FROM Partner p + LEFT JOIN FETCH p.member WHERE p.point IS NOT NULL - AND ST_Contains(ST_GeomFromText(:wkt, 4326), p.point) - """, nativeQuery = true) - List findAllWithinViewport(@Param("wkt") String wkt); + AND function('ST_Contains', function('ST_GeomFromText', :wkt, 4326), p.point) = true + """) + List findAllWithinViewportWithMember(@Param("wkt") String wkt); @Query(""" - select distinct p - from Partner p - where lower(p.name) like lower(concat('%', :keyword, '%')) + SELECT DISTINCT p + FROM Partner p + LEFT JOIN FETCH p.member + WHERE LOWER(p.name) LIKE LOWER(CONCAT('%', :keyword, '%')) """) - List searchPartnerByKeyword( + List searchPartnerByKeywordWithMember( @Param("keyword") String keyword ); diff --git a/src/main/java/com/assu/server/domain/partner/service/PartnerService.java b/src/main/java/com/assu/server/domain/partner/service/PartnerService.java index 7edd9165..5aa93b14 100644 --- a/src/main/java/com/assu/server/domain/partner/service/PartnerService.java +++ b/src/main/java/com/assu/server/domain/partner/service/PartnerService.java @@ -5,5 +5,4 @@ public interface PartnerService { PartnerResponseDTO.RandomAdminResponseDTO getRandomAdmin(Long partnerId); - } 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 c8dbd95b..915d74a0 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 @@ -26,15 +26,14 @@ import org.springframework.web.multipart.MultipartFile; import java.util.List; +@Tag(name = "Partnership", description = "제휴 제안 API") @RestController -@Tag(name = "제휴 요청 api", description = "최종적으로 @@ 제휴를 요청할때 사용하는 api ") @RequiredArgsConstructor @RequestMapping("/partnership") public class PartnershipController { private final PartnershipService partnershipService; private final NotificationCommandService notificationCommandService; - private final StoreRepository storeRepository; @PostMapping("/usage") @@ -48,22 +47,90 @@ public ResponseEntity> finalPartnershipRequest( return ResponseEntity.ok(BaseResponse.onSuccess(SuccessStatus.USER_PAPER_REQUEST_SUCCESS, null)); } - @PatchMapping("/proposal") @Operation( - summary = "제휴 제안서 내용 수정 API", - description = "제공 서비스 종류(SERVICE, DISCOUNT), 서비스 제공 기준(PRICE, HEADCOUNT), 서비스 제공 항목, 카테고리, 할인율을 상황에 맞게 작성해주세요." - ) - public BaseResponse updatePartnership( - @RequestBody PartnershipRequestDTO.WritePartnershipRequestDTO request, + summary = "제휴 제안서 초안 생성 API", + description = "# [v1.3 (2026-01-04)](https://clumsy-seeder-416.notion.site/2fe1197c19ed8043a511cc8ea005d5b4)\n" + + "- 관리자로 로그인한 상태에서 제안서 초안을 생성합니다.\n" + + "- 제안서를 작성할 제휴업체 ID 입력.\n" + + "- 성공 시 200(OK)과 `CreateDraftResponse` 객체 반환.\n" + + "\n**Request Body:**\n" + + " - `CreateDraftRequest` 객체 (JSON)\n" + + " - `partnerId` (Long): 제휴 제안서를 작성할 제휴업체 ID\n" + + "\n**Response:**\n" + + " - `paperId` (Long): 생성된 제안서 ID\n") + @PostMapping("/proposal/draft") + public BaseResponse createDraftPartnership( + @RequestBody PartnershipRequestDTO.CreateDraftRequestDTO request, @AuthenticationPrincipal PrincipalDetails pd - ){ - return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.updatePartnership(request, pd.getId())); + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.createDraftPartnership(request, pd.getId())); } @Operation( summary = "제휴 제안서 수동 등록 API", - description = "제공 서비스 종류(SERVICE, DISCOUNT), 서비스 제공 기준(PRICE, HEADCOUNT), 서비스 제공 항목, 카테고리, 할인율을 상황에 맞게 작성하고, 계약서 이미지를 업로드하세요." - ) + description = "# [v1.3 (2026-01-04)](https://clumsy-seeder-416.notion.site/2591197c19ed804785d9f58f95223048)\n" + + "- 관리자로 로그인한 상황에서 내용이 있는 제휴 제안서를 생성합니다.\n" + + "- 계약서 이미지 MultipartFile을 입력.\n" + + "- 주소 입력 시 장소 검색용 API에서 반환된 Map 객체의 내용을 selectedPlace에 입력.\n" + + "- options의 optionType을 SERVICE/DISCOUNT 중 하나로 설정.\n" + + "- options의 criterionType을 PRICE/HEADCOUNT 중 하나로 설정.\n" + + "- 이외의 제휴 유형일 경우 anotherType을 true로 설정.\n" + + "- 성공 시 200(OK)과 `ManualPartnershipResponse` 객체 반환.\n" + + "- DB에 해당하는 store가 없다면 생성.\n" + + "- 해당하는 store가 INACTIVE 상태였다면 ACTIVE 상태로 변환.\n" + + "\n**Request Body:**\n" + + " - `ManualPartnershipRequest` 객체 (JSON): 제안서 내용\n" + + " - `storeName` (String): 가게 이름\n" + + " - `selectedPlace` (JSON): 선택된 장소\n" + + " - `placeId` (String): kakao place ID\n" + + " - `name` (String): kakao place 이름\n" + + " - `address` (String): 지번 주소\n" + + " - `roadAddress` (String): 도로명 주소\n" + + " - `latitude` (Double): 장소 위도\n" + + " - `longitude` (Double): 장소 경도\n" + + " - `storeDetailAddress` (String): 가게 상세주소\n" + + " - `partnershipPeriodStart` (LocalDate): 제휴 시작일\n" + + " - `partnershipPeriodEnd` (LocalDate): 제휴 마감일\n" + + " - `options` (JSON): 제휴 옵션\n" + + " - `optionType` (OptionType): 제공 서비스 종류 (서비스 제공, 할인)\n" + + " - `criterionType` (CriterionType): 서비스 제공 기준 (금액, 인원)\n" + + " - `anotherType` (Boolean): 기타 제공 서비스\n" + + " - `people` (Integer): 서비스 제공 기준 인원 수\n" + + " - `cost` (Integer): 서비스 제공 기준 금액\n" + + " - `category` (String): 서비스 카테고리, 서비스 제공 항목이 여러 개 일 때 작성\n" + + " - `discountRate` (Long): 서비스 제공 인원 수\n" + + " - `note` (String): 기타 유형 제휴 옵션 문구\n" + + " - `goods` (JSON): 서비스 제공 항목\n" + + " - `goodsName` (String): 서비스 제공 항목명\n" + + " - `contractImage` (MultipartFile, required): 계약서 이미지 파일\n" + + "\n**Response:**\n" + + " - `storeId` (Long): 가게 ID\n" + + " - `storeCreated` (boolean): 가게가 DB에 생성되었는지 여부\n" + + " - `storeActivated` (boolean): 가게가 재활성화되었는지 여부\n" + + " - `status` (String): 제휴 제안서의 상태\n" + + " - `contractImageUrl` (String): 계약서 파일 URL\n" + + " - `partnership` (JSON): 제휴 제안서\n" + + " - `partnershipId` (Long): 제안서 ID\n" + + " - `partnershipPeriodStart` (LocalDate): 제휴 시작일\n" + + " - `partnershipPeriodEnd` (LocalDate): 제휴 마감일\n" + + " - `adminId` (Long): 관리자 ID\n" + + " - `partnerId` (Long): 제휴업체 ID\n" + + " - `storeId` (Long): 가게 ID\n" + + " - `storeName` (String): 가게 이름\n" + + " - `adminName` (String): 관리자 이름\n" + + " - `isActivated` (ActivationStatus): 제안서 활성화 여부\n" + + " - `options` (JSON): 제휴 옵션\n" + + " - `optionType` (OptionType): 제공 서비스 종류 (서비스 제공, 할인)\n" + + " - `criterionType` (CriterionType): 서비스 제공 기준 (금액, 인원)\n" + + " - `anotherType` (Boolean): 기타 제공 서비스\n" + + " - `people` (Integer): 서비스 제공 기준 인원 수\n" + + " - `cost` (Integer): 서비스 제공 기준 금액\n" + + " - `note` (String): 기타 유형 제휴 옵션 문구\n" + + " - `category` (String): 서비스 카테고리, 서비스 제공 항목이 여러 개 일 때 작성\n" + + " - `discountRate` (Long): 서비스 제공 인원 수\n" + + " - `goods` (JSON): 서비스 제공 항목\n" + + " - `goodsId` (Long): 서비스 제공 항목 ID\n" + + " - `goodsName` (String): 서비스 제공 항목명\n") @PostMapping(value = "/passivity", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public BaseResponse createManualPartnership( @RequestPart("request") @Parameter PartnershipRequestDTO.ManualPartnershipRequestDTO request, @@ -80,33 +147,111 @@ public BaseResponse createM } @Operation( - summary = "제휴 중인 가게 조회 API", - description = "전체를 조회하려면 all을 true로, 가장 최근 두 건을 조회하려면 all을 false로 설정해주세요." - ) - @GetMapping("/admin") - public BaseResponse> listForAdmin( - @RequestParam(name = "all", defaultValue = "false") boolean all, + summary = "제휴 제안서 내용 수정 API", + description = "# [v1.3 (2026-01-04)](https://clumsy-seeder-416.notion.site/2371197c19ed80aa8468d2377ef8eac2)\n" + + "- 제안서 초안 또는 이미 작성된 제안서의 내용을 수정합니다.\n" + + "- options의 optionType을 SERVICE/DISCOUNT 중 하나로 설정\n" + + "- options의 criterionType을 PRICE/HEADCOUNT 중 하나로 설정\n" + + "- 이외의 제휴 유형일 경우 anotherType을 true로 설정\n" + + "- 성공 시 200(OK)과 `WritePartnershipResponse` 객체 반환\n" + + "\n**Request Body:**\n" + + " - `WritePartnershipRequest` 객체 (JSON): 수정 내용\n" + + " - `paperId` (String): 이메일 주소\n" + + " - `partnershipPeriodStart` (LocalDate): 제휴 시작일\n" + + " - `partnershipPeriodEnd` (LocalDate): 제휴 마감일\n" + + " - `options` (JSON): 제휴 옵션\n" + + " - `optionType` (OptionType): 제공 서비스 종류 (서비스 제공, 할인)\n" + + " - `criterionType` (CriterionType): 서비스 제공 기준 (금액, 인원)\n" + + " - `anotherType` (Boolean): 기타 제공 서비스\n" + + " - `people` (Integer): 서비스 제공 기준 인원 수\n" + + " - `cost` (Integer): 서비스 제공 기준 금액\n" + + " - `category` (String): 서비스 카테고리, 서비스 제공 항목이 여러 개 일 때 작성\n" + + " - `discountRate` (Long): 서비스 제공 인원 수\n" + + " - `note` (String): 기타 유형 제휴 옵션 문구\n" + + " - `goods` (JSON): 서비스 제공 항목\n" + + " - `goodsName` (String): 서비스 제공 항목명\n" + + "\n**Response:**\n" + + " - `partnershipId` (Long): 제안서 ID\n" + + " - `partnershipPeriodStart` (LocalDate): 제휴 시작일\n" + + " - `partnershipPeriodEnd` (LocalDate): 제휴 마감일\n" + + " - `adminId` (Long): 관리자 ID\n" + + " - `partnerId` (Long): 제휴업체 ID\n" + + " - `storeId` (Long): 가게 ID\n" + + " - `storeName` (String): 가게 이름\n" + + " - `adminName` (String): 관리자 이름\n" + + " - `isActivated` (ActivationStatus): 제안서 활성화 여부\n" + + " - `options` (JSON): 제휴 옵션\n" + + " - `optionType` (OptionType): 제공 서비스 종류 (서비스 제공, 할인)\n" + + " - `criterionType` (CriterionType): 서비스 제공 기준 (금액, 인원)\n" + + " - `anotherType` (Boolean): 기타 제공 서비스\n" + + " - `people` (Integer): 서비스 제공 기준 인원 수\n" + + " - `cost` (Integer): 서비스 제공 기준 금액\n" + + " - `note` (String): 기타 유형 제휴 옵션 문구\n" + + " - `category` (String): 서비스 카테고리, 서비스 제공 항목이 여러 개 일 때 작성\n" + + " - `discountRate` (Long): 서비스 제공 인원 수\n" + + " - `goods` (JSON): 서비스 제공 항목\n" + + " - `goodsId` (Long): 서비스 제공 항목 ID\n" + + " - `goodsName` (String): 서비스 제공 항목명\n") + @PatchMapping("/proposal") + public BaseResponse updatePartnership( + @RequestBody PartnershipRequestDTO.WritePartnershipRequestDTO request, @AuthenticationPrincipal PrincipalDetails pd - ) { - return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.listPartnershipsForAdmin(all, pd.getId())); + ){ + return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.updatePartnership(request, pd.getId())); } @Operation( - summary = "제휴 중인 관리자 조회 API", - description = "전체를 조회하려면 all을 true로, 가장 최근 두 건을 조회하려면 all을 false로 설정해주세요." - ) - @GetMapping("/partner") - public BaseResponse> listForPartner( - @RequestParam(name = "all", defaultValue = "false") boolean all, - @AuthenticationPrincipal PrincipalDetails pd + summary = "제휴 상태 업데이트 API", + description = "# [v1.3 (2026-01-04)](https://clumsy-seeder-416.notion.site/SUSPEND-ACTIVE-INACTIVE-2371197c19ed805ab509f552817e823a)\n" + + "- 제휴 상태를 변경합니다.\n" + + "- 적용할 상태 입력 (ACTIVE/SUSPEND/INACTIVE).\n" + + "- 성공 시 200(OK)과 `UpdateResponse` 객체 반환.\n" + + "\n**Parameters:**\n" + + " - `partnershipId` (Long): 상태를 적용할 제안서 ID\n" + + "\n**Request Body:**\n" + + " - `UpdateRequest` 객체 (JSON)\n" + + " - `status` (String): 제안서에 적용할 상태\n" + + "\n**Response:**\n" + + " - `partnershipId` (Long): 생성된 제안서 ID\n"+ + " - `prevStatus` (String): 제안서의 이전 상태\n"+ + " - `newStatus` (String): 제안서의 이전 상태\n"+ + " - `changedAt` (LocalDateTime): 상태 변경 시간\n") + @PatchMapping("/{partnershipId}/status") + public BaseResponse updatePartnershipStatus( + @PathVariable("partnershipId") Long partnershipId, + @RequestBody PartnershipRequestDTO.UpdateRequestDTO request ) { - return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.listPartnershipsForPartner(all, pd.getId())); + return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.updatePartnershipStatus(partnershipId, request)); } @Operation( summary = "제휴 상세조회 API", - description = "제휴 아이디를 입력하세요." - ) + description = "# [v1.3 (2026-01-04)](https://clumsy-seeder-416.notion.site/2371197c19ed80cdac8beb2ffddb2f61)\n" + + "- 제휴 제안서의 내용을 조회합니다.\n" + + "- 적용할 상태 입력 (ACTIVE/SUSPEND/INACTIVE).\n" + + "- 성공 시 200(OK)과 `GetPartnershipDetailResponse` 객체 반환.\n" + + "\n**Parameters:**\n" + + " - `partnershipId` (Long): 내용을 조회할 제안서 ID\n" + + "\n**Response:**\n" + + " - `partnershipId` (Long): 제안서 ID\n"+ + " - `updatedAt` (LocalDateTime): 업데이트된 시간\n"+ + " - `partnershipPeriodStart` (LocalDate): 제휴 시작일\n"+ + " - `partnershipPeriodEnd` (LocalDate): 제휴 마감일\n"+ + " - `adminId` (Long): 관리자 ID\n" + + " - `partnerId` (Long): 제휴업체 ID\n" + + " - `storeId` (Long): 가게 ID\n" + + " - `options` (JSON): 제휴 옵션\n" + + " - `optionType` (OptionType): 제공 서비스 종류 (서비스 제공, 할인)\n" + + " - `criterionType` (CriterionType): 서비스 제공 기준 (금액, 인원)\n" + + " - `anotherType` (Boolean): 기타 제공 서비스\n" + + " - `people` (Integer): 서비스 제공 기준 인원 수\n" + + " - `cost` (Integer): 서비스 제공 기준 금액\n" + + " - `note` (String): 기타 유형 제휴 옵션 문구\n" + + " - `category` (String): 서비스 카테고리, 서비스 제공 항목이 여러 개 일 때 작성\n" + + " - `discountRate` (Long): 서비스 제공 인원 수\n" + + " - `goods` (JSON): 서비스 제공 항목\n" + + " - `goodsId` (Long): 서비스 제공 항목 ID\n" + + " - `goodsName` (String): 서비스 제공 항목명\n") @GetMapping("/{partnershipId}") public BaseResponse getPartnership( @PathVariable Long partnershipId @@ -115,57 +260,128 @@ public BaseResponse getP } @Operation( - summary = "제휴 상태 업데이트 API", - description = "제휴 ID와 바꾸고 싶은 상태를 입력하세요(SUSPEND/ACTIVE/INACTIVE/BLANK)" - ) - @PatchMapping("/{partnershipId}/status") - public BaseResponse updatePartnershipStatus( - @PathVariable("partnershipId") Long partnershipId, - @RequestBody PartnershipRequestDTO.UpdateRequestDTO request + summary = "제휴 제안서 삭제 API", + description = "# [v1.3 (2026-01-04)](https://clumsy-seeder-416.notion.site/2fe1197c19ed80e58d30c469e4ba3146)\n" + + "- paperId와 관련된 모든 내용을 삭제합니다.\n" + + "- 성공 시 200(OK) 반환.\n" + + "\n**Parameters:**\n" + + " - `paperId` (Long, required): 삭제할 제안서 ID\n") + @DeleteMapping("/proposal/delete/{paperId}") + public BaseResponse deletePartnership( + @PathVariable Long paperId ) { - return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.updatePartnershipStatus(partnershipId, request)); + partnershipService.deletePartnership(paperId); + return BaseResponse.onSuccess(SuccessStatus._OK, null); } - @PostMapping("/proposal/draft") @Operation( - summary = "제휴 제안서 초안 생성 API", - description = "현재 로그인한 관리자(Admin)가 내용이 비어있는 제휴 제안서를 초안 상태로 생성합니다." - ) - public BaseResponse createDraftPartnership( - @RequestBody PartnershipRequestDTO.CreateDraftRequestDTO request, + summary = "제휴 중인 가게 조회 API", + description = "# [v1.3 (2026-01-04)](https://clumsy-seeder-416.notion.site/_-2241197c19ed81b1b9adf724adc4600c)\n" + + "- 현재 로그인한 관리자와 제휴 중인 가게를 조회합니다.\n" + + "- 전체를 조회하려면 all을 true로, 가장 최근 두 건을 조회하려면 all을 false로 설정.\n" + + "- 성공 시 200(OK)과 `WritePartnershipResponse` 객체 반환.\n" + + "\n**Parameters:**\n" + + " - `all` (boolean, required): 조회 옵션\n" + + "\n**Response:**\n" + + " - `partnershipId` (Long): 제안서 ID\n"+ + " - `partnershipPeriodStart` (LocalDate): 제휴 시작일\n"+ + " - `partnershipPeriodEnd` (LocalDate): 제휴 마감일\n"+ + " - `adminId` (Long): 관리자 ID\n" + + " - `partnerId` (Long): 제휴업체 ID\n" + + " - `storeId` (Long): 가게 ID\n" + + " - `storeName` (String): 가게 이름\n" + + " - `adminName` (String): 관리자 이름\n" + + " - `isActivated` (ActivationStatus): 제안서 활성화 여부\n" + + " - `options` (JSON): 제휴 옵션\n" + + " - `optionType` (OptionType): 제공 서비스 종류 (서비스 제공, 할인)\n" + + " - `criterionType` (CriterionType): 서비스 제공 기준 (금액, 인원)\n" + + " - `anotherType` (Boolean): 기타 제공 서비스\n" + + " - `people` (Integer): 서비스 제공 기준 인원 수\n" + + " - `cost` (Integer): 서비스 제공 기준 금액\n" + + " - `note` (String): 기타 유형 제휴 옵션 문구\n" + + " - `category` (String): 서비스 카테고리, 서비스 제공 항목이 여러 개 일 때 작성\n" + + " - `discountRate` (Long): 서비스 제공 인원 수\n" + + " - `goods` (JSON): 서비스 제공 항목\n" + + " - `goodsId` (Long): 서비스 제공 항목 ID\n" + + " - `goodsName` (String): 서비스 제공 항목명\n") + @GetMapping("/admin") + public BaseResponse> listForAdmin( + @RequestParam(name = "all", defaultValue = "false") boolean all, @AuthenticationPrincipal PrincipalDetails pd ) { - return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.createDraftPartnership(request, pd.getId())); + return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.listPartnershipsForAdmin(all, pd.getId())); } - @DeleteMapping("/proposal/delete/{paperId}") @Operation( - summary = "제휴 제안서 삭제 API", - description = "특정 제휴 제안서(paperId)와 관련된 모든 데이터를 삭제합니다." - ) - public BaseResponse deletePartnership( - @PathVariable Long paperId + summary = "제휴 중인 관리자 조회 API", + description = "# [v1.3 (2026-01-04)](https://clumsy-seeder-416.notion.site/_-24f1197c19ed802784fddadbbd3ea2c6)\n" + + "- 현재 로그인한 제휴업체와 제휴 중인 관리자를 조회합니다.\n" + + "- 전체를 조회하려면 all을 true로, 가장 최근 두 건을 조회하려면 all을 false로 설정.\n" + + "- 성공 시 200(OK)과 `WritePartnershipResponse` 객체 반환.\n" + + "\n**Parameters:**\n" + + " - `all` (boolean, required): 조회 옵션\n" + + "\n**Response:**\n" + + " - `partnershipId` (Long): 제안서 ID\n"+ + " - `partnershipPeriodStart` (LocalDate): 제휴 시작일\n"+ + " - `partnershipPeriodEnd` (LocalDate): 제휴 마감일\n"+ + " - `adminId` (Long): 관리자 ID\n" + + " - `partnerId` (Long): 제휴업체 ID\n" + + " - `storeId` (Long): 가게 ID\n" + + " - `storeName` (String): 가게 이름\n" + + " - `adminName` (String): 관리자 이름\n" + + " - `isActivated` (ActivationStatus): 제안서 활성화 여부\n" + + " - `options` (JSON): 제휴 옵션\n" + + " - `optionType` (OptionType): 제공 서비스 종류 (서비스 제공, 할인)\n" + + " - `criterionType` (CriterionType): 서비스 제공 기준 (금액, 인원)\n" + + " - `anotherType` (Boolean): 기타 제공 서비스\n" + + " - `people` (Integer): 서비스 제공 기준 인원 수\n" + + " - `cost` (Integer): 서비스 제공 기준 금액\n" + + " - `note` (String): 기타 유형 제휴 옵션 문구\n" + + " - `category` (String): 서비스 카테고리, 서비스 제공 항목이 여러 개 일 때 작성\n" + + " - `discountRate` (Long): 서비스 제공 인원 수\n" + + " - `goods` (JSON): 서비스 제공 항목\n" + + " - `goodsId` (Long): 서비스 제공 항목 ID\n" + + " - `goodsName` (String): 서비스 제공 항목명\n") + @GetMapping("/partner") + public BaseResponse> listForPartner( + @RequestParam(name = "all", defaultValue = "false") boolean all, + @AuthenticationPrincipal PrincipalDetails pd ) { - partnershipService.deletePartnership(paperId); - return BaseResponse.onSuccess(SuccessStatus._OK, null); + return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.listPartnershipsForPartner(all, pd.getId())); } - @GetMapping("/suspended") @Operation( summary = "대기 중인 제휴 계약서 조회 API", - description = "현재 로그인한 관리자(Admin)가 대기 중인 제휴 계약서를 모두 조회하여 리스트로 반환합니다." - ) + description = "# [v1.3 (2026-01-04)](https://clumsy-seeder-416.notion.site/_-24f1197c19ed802784fddadbbd3ea2c6)\n" + + "- 현재 로그인한 관리자와 제휴 중인 제안서 중 SUSPEND 상태인 제안서를 모두 조회합니다.\n" + + "- 성공 시 200(OK)과 `SuspendedPaper` 객체 반환.\n" + + "\n**Response:**\n" + + " - `paperId` (Long): 제안서 ID\n"+ + " - `partnerName` (String): 제휴업체 이름\n"+ + " - `createdAt` (LocalDateTime): 제휴 생성 일자\n") + @GetMapping("/suspended") public BaseResponse> suspendPartnership( @AuthenticationPrincipal PrincipalDetails pd ) { return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.getSuspendedPapers(pd.getId())); } - @GetMapping("/check/admin") @Operation( - summary = "관리자 채팅방 내 제휴 확인 API", - description = "현재 로그인한 관리자(Admin)가 파라미터로 받은 partnerId를 가진 상대 제휴업체(Partner)와 맺고 있는 제휴를 조회합니다. 비활성화되지 않은 가장 최근 제휴 1건을 조회합니다." - ) + summary = "채팅방 내 제휴 확인 API(관리자용)", + description = "# [v1.3 (2026-01-04)](https://clumsy-seeder-416.notion.site/2fe1197c19ed8078af77d65bfcc09087)\n" + + "- 현재 로그인한 관리자와 파라미터로 받은 partnerId를 가진 제휴업체 간에 제휴를 조회합니다.\n" + + "- 비활성화 되지 않은 가장 최근 제휴 1건 조회.\n" + + "- 성공 시 200(OK)과 `AdminPartnershipWithPartnerResponse` 객체 반환.\n" + + "\n**Parameters:**\n" + + " - `partnerId` (Long, required): 제휴업체 ID\n" + + "\n**Response:**\n" + + " - `paperId` (Long): 제안서 ID\n"+ + " - `isPartnered` (boolean): 제휴 여부\n"+ + " - `status` (String): 제휴 상태\n"+ + " - `partnerId` (Long): 제휴업체 ID\n"+ + " - `partnerName` (String): 제휴업체 이름\n"+ + " - `partnerAddress` (String): 제휴업체 주소\n") + @GetMapping("/check/admin") public BaseResponse checkAdminPartnership( @RequestParam("partnerId") Long partnerId, @AuthenticationPrincipal PrincipalDetails pd @@ -173,11 +389,23 @@ public BaseResponse checkPartnerPartnership( @RequestParam("adminId") Long adminId, @AuthenticationPrincipal PrincipalDetails pd diff --git a/src/main/java/com/assu/server/domain/partnership/dto/PartnershipRequestDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipRequestDTO.java index 65dea839..6bce0e21 100644 --- a/src/main/java/com/assu/server/domain/partnership/dto/PartnershipRequestDTO.java +++ b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipRequestDTO.java @@ -26,22 +26,23 @@ public static class finalRequest{ @Getter public static class WritePartnershipRequestDTO { - private Long paperId; // 제휴 제안서 아이디 + private Long paperId; private LocalDate partnershipPeriodStart; private LocalDate partnershipPeriodEnd; - private List options; // 동적으로 받는 제안 항목 + private List options; } @Getter public static class PartnershipOptionRequestDTO { - private OptionType optionType; // 제공 서비스 종류 (서비스 제공, 할인) - private CriterionType criterionType; // 서비스 제공 기준 (금액, 인원) + private OptionType optionType; + private CriterionType criterionType; + private Boolean anotherType; private Integer people; private Long cost; private String category; private Long discountRate; private String note; - private List goods; // 서비스 제공 항목 + private List goods; } @@ -71,6 +72,6 @@ public static class ManualPartnershipRequestDTO { @Getter public static class CreateDraftRequestDTO { - private Long partnerId; // 제안서를 보낼 제휴업체 ID + private Long partnerId; } } diff --git a/src/main/java/com/assu/server/domain/partnership/dto/PartnershipResponseDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipResponseDTO.java index 712273cf..4da34bea 100644 --- a/src/main/java/com/assu/server/domain/partnership/dto/PartnershipResponseDTO.java +++ b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipResponseDTO.java @@ -38,6 +38,7 @@ public static class WritePartnershipResponseDTO { public static class PartnershipOptionResponseDTO { private OptionType optionType; private CriterionType criterionType; + private Boolean anotherType; private Integer people; private Long cost; private String note; @@ -88,7 +89,7 @@ public static class ManualPartnershipResponseDTO { @NoArgsConstructor @AllArgsConstructor public static class CreateDraftResponseDTO { - private Long paperId; // 생성된 빈 제안서의 ID + private Long paperId; } @Getter @@ -108,8 +109,8 @@ public static class SuspendedPaperDTO { @Builder public static class AdminPartnershipWithPartnerResponseDTO { private Long paperId; - private boolean isPartnered; // 제휴 여부 - private String status; // 제휴 상태 + private boolean isPartnered; + private String status; private Long partnerId; private String partnerName; private String partnerAddress; @@ -122,8 +123,8 @@ public static class AdminPartnershipWithPartnerResponseDTO { @Builder public static class PartnerPartnershipWithAdminResponseDTO { private Long paperId; - private boolean isPartnered; // 제휴 여부 - private String status; // 제휴 상태 + private boolean isPartnered; + private String status; private Long adminId; private String adminName; private String adminAddress; diff --git a/src/main/java/com/assu/server/domain/partnership/repository/PaperRepository.java b/src/main/java/com/assu/server/domain/partnership/repository/PaperRepository.java index 8c67b899..aa1a5f61 100644 --- a/src/main/java/com/assu/server/domain/partnership/repository/PaperRepository.java +++ b/src/main/java/com/assu/server/domain/partnership/repository/PaperRepository.java @@ -62,4 +62,35 @@ List findActivePapersByAdminIds(@Param("adminIds") List adminIds, @Param("status") ActivationStatus status); List findByStoreIdAndAdminIdAndIsActivated(Long storeId, Long adminId, ActivationStatus isActivated); + + @Query(""" + SELECT p FROM Paper p + WHERE p.admin.id = :adminId + AND p.partner.id IN :partnerIds + AND p.isActivated = :status + """) + List findByAdminIdAndPartnerIdInAndIsActivated( + @Param("adminId") Long adminId, + @Param("partnerIds") List partnerIds, + @Param("status") ActivationStatus status + ); + + @Query(""" + SELECT p FROM Paper p + WHERE p.admin.id IN :adminIds + AND p.partner.id = :partnerId + AND p.isActivated = :status + """) + List findByAdminIdInAndPartnerIdAndIsActivated( + @Param("adminIds") List adminIds, + @Param("partnerId") Long partnerId, + @Param("status") ActivationStatus status + ); + + @Query(""" + SELECT p FROM Paper p + WHERE p.store.id IN :storeIds + ORDER BY p.id DESC + """) + List findByStoreIdIn(@Param("storeIds") List storeIds); } diff --git a/src/main/java/com/assu/server/domain/partnership/service/PartnershipServiceImpl.java b/src/main/java/com/assu/server/domain/partnership/service/PartnershipServiceImpl.java index 76679a2b..e58d99ba 100644 --- a/src/main/java/com/assu/server/domain/partnership/service/PartnershipServiceImpl.java +++ b/src/main/java/com/assu/server/domain/partnership/service/PartnershipServiceImpl.java @@ -46,8 +46,6 @@ import java.util.*; import java.util.stream.Collectors; - - @Service @Transactional @RequiredArgsConstructor @@ -102,9 +100,6 @@ public void recordPartnershipUsage(PartnershipRequestDTO.finalRequest dto, Membe // @Transactional 환경에서는 studentsToUpdate의 변경 사항(스탬프)이 자동으로 DB에 반영됩니다. } - - - private final PaperRepository paperRepository; private final PaperContentRepository paperContentRepository; private final GoodsRepository goodsRepository; @@ -176,6 +171,7 @@ public PartnershipResponseDTO.WritePartnershipResponseDTO updatePartnership( } @Override + @Transactional public List listPartnershipsForAdmin(boolean all, Long adminId) { Sort sort = Sort.by(Sort.Direction.DESC, "createdAt"); List papers = all @@ -190,6 +186,7 @@ public List listPartnerships } @Override + @Transactional public List listPartnershipsForPartner(boolean all, Long partnerId) { Sort sort = Sort.by(Sort.Direction.DESC, "createdAt"); List papers = all 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 7114810e..aabeb715 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 @@ -115,7 +115,27 @@ AND ST_Contains(ST_GeomFromText(:wkt, 4326), s.point) """, nativeQuery = true) List findAllWithinViewport(@Param("wkt") String wkt); + @Query(""" + SELECT DISTINCT s + FROM Store s + LEFT JOIN FETCH s.partner p + LEFT JOIN FETCH p.member + 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 findByNameContainingIgnoreCaseOrderByIdDesc(String name); + + @Query(""" + SELECT DISTINCT s + FROM Store s + LEFT JOIN FETCH s.partner p + LEFT JOIN FETCH p.member + WHERE LOWER(s.name) LIKE LOWER(CONCAT('%', :name, '%')) + ORDER BY s.id DESC + """) + List findByNameContainingIgnoreCaseOrderByIdDescWithPartner(@Param("name") String name); Optional findByName(String name); Optional findById(Long id); Optional findByPartnerId(Long partnerId); diff --git a/src/main/java/com/assu/server/domain/suggestion/controller/SuggestionController.java b/src/main/java/com/assu/server/domain/suggestion/controller/SuggestionController.java index 1a973246..a1614c24 100644 --- a/src/main/java/com/assu/server/domain/suggestion/controller/SuggestionController.java +++ b/src/main/java/com/assu/server/domain/suggestion/controller/SuggestionController.java @@ -16,18 +16,29 @@ @Tag(name = "Suggestion", description = "제휴 건의 API") @RestController -@RequiredArgsConstructor // 파라미터가 있어야만 하는 생성자 -@RequestMapping("/suggestion") // suggestion 아래에서 시작 +@RequiredArgsConstructor +@RequestMapping("/suggestion") public class SuggestionController { private final SuggestionService suggestionService; - - @PostMapping @Operation( summary = "제휴 건의 API", - description = "[v1.0 (2025-09-03)](https://www.notion.so/_-2241197c19ed81e68840d565af59b534) 현재 로그인한 학생(User)이 관리자에게 제휴를 건의합니다.\n" - ) + description = "# [v1.3 (2026-01-04)](https://clumsy-seeder-416.notion.site/_-2241197c19ed81e68840d565af59b534)\n" + + "- 현재 로그인한 학생이 관리자에게 제휴를 건의합니다.\n" + + "- 성공 시 200(OK)과 Suggestion 객체 반환.\n" + + "\n**Request Body:**\n" + + " - `suggestionRequest` 객체 (JSON, required)\n" + + " - `adminId` (Long): 건의 대상 관리자 ID\n" + + " - `storeName` (String): 희망 가게 이름\n" + + " - `benefit` (String): 희망 혜택\n" + + "\n**Response:**\n" + + " - `suggestionId` (Long): 건의 ID\n" + + " - `userId` (Long): 제안인 ID\n" + + " - `adminId` (Long): 건의 대상 관리자 ID\n" + + " - `storeName` (String): 희망 가게 이름\n" + + " - `suggestionBenefit` (String): 희망 혜택\n") + @PostMapping public BaseResponse writeSuggestion( @RequestBody SuggestionRequestDTO.WriteSuggestionRequestDTO suggestionRequestDTO, @AuthenticationPrincipal PrincipalDetails pd @@ -35,22 +46,40 @@ public BaseResponse writeSugge return BaseResponse.onSuccess(SuccessStatus._OK, suggestionService.writeSuggestion(suggestionRequestDTO, pd.getId())); } - @GetMapping("/admin") @Operation( summary = "제휴 건의대상 조회 API", - description = "[v1.0 (2025-09-03)](https://www.notion.so/_-2241197c19ed81e68840d565af59b534) 현재 로그인한 학생(User)이 제휴를 건의할 수 있는 학생회(Admin)를 조회합니다.\n" - ) + description = "# [v1.3 (2026-01-04)](https://clumsy-seeder-416.notion.site/_-2621197c19ed808c9627fcb7f58f4538)\n" + + "- 현재 로그인한 학생이 건의할 수 있는 관리자를 조회합니다.\n" + + "- 성공 시 200(OK)과 SuggestionAdmins 객체 반환.\n" + + "\n**Response:**\n" + + " - `adminId` (Long): 총학생회 ID\n" + + " - `adminName` (String): 총학생회 이름\n" + + " - `departId` (Long): 단과대학 학생회 ID\n" + + " - `departName` (String): 단과대학 학생회 이름\n" + + " - `majorId` (Long): 학부/학과 학생회 ID\n" + + " - `majorName` (String): 학부/학과 학생회 이름\n") + @GetMapping("/admin") public BaseResponse getSuggestionAdmins( @AuthenticationPrincipal PrincipalDetails pd ) { return BaseResponse.onSuccess(SuccessStatus._OK, suggestionService.getSuggestionAdmins(pd.getId())); } - @GetMapping("/list") @Operation( summary = "제휴 건의 조회 API", - description = "[v1.0 (2025-09-03)](https://www.notion.so/_-24c1197c19ed8083bf8be4b6a6a43f18) 현재 로그인한 학생회(Admin)가 받은 모든 제휴 건의를 조회합니다." - ) + description = "# [v1.3 (2026-01-04)](https://clumsy-seeder-416.notion.site/_-24c1197c19ed8083bf8be4b6a6a43f18)\n" + + "- 현재 로그인한 관리자가 받은 모든 제휴 건의를 조회합니다.\n" + + "- 제휴 건의는 작성일 기준 최신순으로 조회.\n" + + "- enrollmentStatus로 재학 상태 표시(ENROLLED, LEAVE, GRADUATED)\n" + + "- 성공 시 200(OK)과 Suggestion 객체 반환.\n" + + "\n**Response:**\n" + + " - `suggestionId` (Long): 건의 ID\n" + + " - `createdAt` (LocalDateTime): 건의 작성일\n" + + " - `storeName` (String): 희망 가게 이름\n" + + " - `content` (String): 건의 내용\n" + + " - `studentMajor` (Long): 건의자의 학부/학과\n" + + " - `enrollmentStatus` (EnrollmentStatus): 재학 상태\n") + @GetMapping("/list") public BaseResponse> getSuggestions( @AuthenticationPrincipal PrincipalDetails pd ) { diff --git a/src/main/java/com/assu/server/domain/suggestion/dto/SuggestionRequestDTO.java b/src/main/java/com/assu/server/domain/suggestion/dto/SuggestionRequestDTO.java index 410462dc..f3890f8e 100644 --- a/src/main/java/com/assu/server/domain/suggestion/dto/SuggestionRequestDTO.java +++ b/src/main/java/com/assu/server/domain/suggestion/dto/SuggestionRequestDTO.java @@ -6,8 +6,8 @@ public class SuggestionRequestDTO { @Getter public static class WriteSuggestionRequestDTO{ - private Long adminId; // 건의 대상 - private String storeName; // 희망 가게 - private String benefit; // 희망 혜택 + private Long adminId; + private String storeName; + private String benefit; } } diff --git a/src/main/java/com/assu/server/domain/suggestion/dto/SuggestionResponseDTO.java b/src/main/java/com/assu/server/domain/suggestion/dto/SuggestionResponseDTO.java index b628bd17..299cc9d8 100644 --- a/src/main/java/com/assu/server/domain/suggestion/dto/SuggestionResponseDTO.java +++ b/src/main/java/com/assu/server/domain/suggestion/dto/SuggestionResponseDTO.java @@ -16,11 +16,11 @@ public class SuggestionResponseDTO { @AllArgsConstructor @Builder public static class WriteSuggestionResponseDTO { - private Long suggestionId; // 제안 번호 - private Long userId; // 제안인 아이디 - private Long adminId; // 건의 대상 아이디 - private String storeName; // 희망 가게 이름 - private String suggestionBenefit; // 희망 혜택 + private Long suggestionId; + private Long userId; + private Long adminId; + private String storeName; + private String suggestionBenefit; } @Getter From cdccff2cf86d1fa91940df1e1a8063df50b11214 Mon Sep 17 00:00:00 2001 From: SJ Hwang Date: Sun, 22 Feb 2026 00:52:46 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[Refactor/#233]=20-=20=EB=AA=A8=EB=93=A0=20?= =?UTF-8?q?dto=EB=A5=BC=20record=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/map/dto/AdminMapResponseDTO.java | 41 +++++++++ .../domain/map/dto/PartnerMapResponseDTO.java | 41 +++++++++ .../domain/map/dto/PlaceSuggestionDTO.java | 14 +++ .../domain/map/dto/StoreMapResponseDTO.java | 87 +++++++++++++++++++ .../domain/map/dto/StoreMapResponseV2DTO.java | 19 ++++ .../dto/AdminPartnershipCheckResponseDTO.java | 28 ++++++ .../dto/ManualPartnershipRequestDTO.java | 42 +++++++++ .../dto/ManualPartnershipResponseDTO.java | 29 +++++++ .../PartnerPartnershipCheckResponseDTO.java | 28 ++++++ .../dto/PartnershipDetailResponseDTO.java | 72 +++++++++++++++ .../dto/PartnershipDraftRequestDTO.java | 23 +++++ .../dto/PartnershipDraftResponseDTO.java | 11 +++ .../dto/PartnershipGoodsRequestDTO.java | 6 ++ .../dto/PartnershipGoodsResponseDTO.java | 19 ++++ .../dto/PartnershipOptionRequestDTO.java | 48 ++++++++++ .../dto/PartnershipOptionResponseDTO.java | 35 ++++++++ .../PartnershipStatusUpdateRequestDTO.java | 6 ++ .../PartnershipStatusUpdateResponseDTO.java | 26 ++++++ .../dto/SuspendedPaperResponseDTO.java | 21 +++++ .../dto/WritePartnershipRequestDTO.java | 46 ++++++++++ .../dto/WritePartnershipResponseDTO.java | 51 +++++++++++ 21 files changed, 693 insertions(+) create mode 100644 src/main/java/com/assu/server/domain/map/dto/AdminMapResponseDTO.java create mode 100644 src/main/java/com/assu/server/domain/map/dto/PartnerMapResponseDTO.java create mode 100644 src/main/java/com/assu/server/domain/map/dto/PlaceSuggestionDTO.java create mode 100644 src/main/java/com/assu/server/domain/map/dto/StoreMapResponseDTO.java create mode 100644 src/main/java/com/assu/server/domain/map/dto/StoreMapResponseV2DTO.java create mode 100644 src/main/java/com/assu/server/domain/partnership/dto/AdminPartnershipCheckResponseDTO.java create mode 100644 src/main/java/com/assu/server/domain/partnership/dto/ManualPartnershipRequestDTO.java create mode 100644 src/main/java/com/assu/server/domain/partnership/dto/ManualPartnershipResponseDTO.java create mode 100644 src/main/java/com/assu/server/domain/partnership/dto/PartnerPartnershipCheckResponseDTO.java create mode 100644 src/main/java/com/assu/server/domain/partnership/dto/PartnershipDetailResponseDTO.java create mode 100644 src/main/java/com/assu/server/domain/partnership/dto/PartnershipDraftRequestDTO.java create mode 100644 src/main/java/com/assu/server/domain/partnership/dto/PartnershipDraftResponseDTO.java create mode 100644 src/main/java/com/assu/server/domain/partnership/dto/PartnershipGoodsRequestDTO.java create mode 100644 src/main/java/com/assu/server/domain/partnership/dto/PartnershipGoodsResponseDTO.java create mode 100644 src/main/java/com/assu/server/domain/partnership/dto/PartnershipOptionRequestDTO.java create mode 100644 src/main/java/com/assu/server/domain/partnership/dto/PartnershipOptionResponseDTO.java create mode 100644 src/main/java/com/assu/server/domain/partnership/dto/PartnershipStatusUpdateRequestDTO.java create mode 100644 src/main/java/com/assu/server/domain/partnership/dto/PartnershipStatusUpdateResponseDTO.java create mode 100644 src/main/java/com/assu/server/domain/partnership/dto/SuspendedPaperResponseDTO.java create mode 100644 src/main/java/com/assu/server/domain/partnership/dto/WritePartnershipRequestDTO.java create mode 100644 src/main/java/com/assu/server/domain/partnership/dto/WritePartnershipResponseDTO.java diff --git a/src/main/java/com/assu/server/domain/map/dto/AdminMapResponseDTO.java b/src/main/java/com/assu/server/domain/map/dto/AdminMapResponseDTO.java new file mode 100644 index 00000000..30ba6b8d --- /dev/null +++ b/src/main/java/com/assu/server/domain/map/dto/AdminMapResponseDTO.java @@ -0,0 +1,41 @@ +package com.assu.server.domain.map.dto; + +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.partnership.entity.Paper; +import com.assu.server.infra.s3.AmazonS3Manager; + +import java.time.LocalDate; + +public record AdminMapResponseDTO( + Long adminId, + String name, + String address, + boolean isPartnered, + Long partnershipId, + LocalDate partnershipStartDate, + LocalDate partnershipEndDate, + Double latitude, + Double longitude, + String profileUrl, + String phoneNumber +) { + public static AdminMapResponseDTO of(Admin admin, Paper activePaper, AmazonS3Manager s3Manager) { + final String key = admin.getMember() != null ? admin.getMember().getProfileUrl() : null; + final String profileUrl = (key != null && !key.isBlank()) ? s3Manager.generatePresignedUrl(key) : null; + + assert admin.getMember() != null; + return new AdminMapResponseDTO( + admin.getId(), + admin.getName(), + admin.getOfficeAddress() != null ? admin.getOfficeAddress() : admin.getDetailAddress(), + activePaper != null, + activePaper != null ? activePaper.getId() : null, + activePaper != null ? activePaper.getPartnershipPeriodStart() : null, + activePaper != null ? activePaper.getPartnershipPeriodEnd() : null, + admin.getLatitude(), + admin.getLongitude(), + profileUrl, + admin.getMember().getPhoneNum() + ); + } +} diff --git a/src/main/java/com/assu/server/domain/map/dto/PartnerMapResponseDTO.java b/src/main/java/com/assu/server/domain/map/dto/PartnerMapResponseDTO.java new file mode 100644 index 00000000..f53315b6 --- /dev/null +++ b/src/main/java/com/assu/server/domain/map/dto/PartnerMapResponseDTO.java @@ -0,0 +1,41 @@ +package com.assu.server.domain.map.dto; + +import com.assu.server.domain.partner.entity.Partner; +import com.assu.server.domain.partnership.entity.Paper; +import com.assu.server.infra.s3.AmazonS3Manager; + +import java.time.LocalDate; + +public record PartnerMapResponseDTO( + Long partnerId, + String name, + String address, + boolean isPartnered, + Long partnershipId, + LocalDate partnershipStartDate, + LocalDate partnershipEndDate, + Double latitude, + Double longitude, + String profileUrl, + String phoneNumber +) { + public static PartnerMapResponseDTO of(Partner partner, Paper activePaper, AmazonS3Manager s3Manager) { + final String key = partner.getMember() != null ? partner.getMember().getProfileUrl() : null; + final String profileUrl = (key != null && !key.isBlank()) ? s3Manager.generatePresignedUrl(key) : null; + + assert partner.getMember() != null; + return new PartnerMapResponseDTO( + partner.getId(), + partner.getName(), + partner.getAddress() != null ? partner.getAddress() : partner.getDetailAddress(), + activePaper != null, + activePaper != null ? activePaper.getId() : null, + activePaper != null ? activePaper.getPartnershipPeriodStart() : null, + activePaper != null ? activePaper.getPartnershipPeriodEnd() : null, + partner.getLatitude(), + partner.getLongitude(), + profileUrl, + partner.getMember().getPhoneNum() + ); + } +} diff --git a/src/main/java/com/assu/server/domain/map/dto/PlaceSuggestionDTO.java b/src/main/java/com/assu/server/domain/map/dto/PlaceSuggestionDTO.java new file mode 100644 index 00000000..5708d907 --- /dev/null +++ b/src/main/java/com/assu/server/domain/map/dto/PlaceSuggestionDTO.java @@ -0,0 +1,14 @@ +package com.assu.server.domain.map.dto; + +public record PlaceSuggestionDTO( + String placeId, + String name, + String category, + String address, + String roadAddress, + String phone, + String placeUrl, + Double latitude, + Double longitude, + Integer distance +) {} 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 new file mode 100644 index 00000000..72d751fd --- /dev/null +++ b/src/main/java/com/assu/server/domain/map/dto/StoreMapResponseDTO.java @@ -0,0 +1,87 @@ +package com.assu.server.domain.map.dto; + +import com.assu.server.domain.partnership.entity.PaperContent; +import com.assu.server.domain.partnership.entity.enums.CriterionType; +import com.assu.server.domain.partnership.entity.enums.OptionType; +import com.assu.server.domain.store.entity.Store; +import com.assu.server.infra.s3.AmazonS3Manager; + +public record StoreMapResponseDTO( + Long storeId, + Long adminId, + String adminName, + String name, + String address, + Integer rate, + CriterionType criterionType, + OptionType optionType, + Integer people, + Long cost, + String category, + String note, + Long discountRate, + boolean hasPartner, + Double latitude, + Double longitude, + String profileUrl, + String phoneNumber +) { + public static StoreMapResponseDTO of( + Store store, PaperContent content, Long adminId, String adminName, 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 ? s3Manager.generatePresignedUrl(key) : null; + final String phoneNumber = (store.getPartner() != null + && store.getPartner().getMember() != null + && store.getPartner().getMember().getPhoneNum() != null) + ? store.getPartner().getMember().getPhoneNum() : ""; + + return new StoreMapResponseDTO( + store.getId(), adminId, adminName, store.getName(), + store.getAddress() != null ? store.getAddress() : store.getDetailAddress(), + store.getRate(), + content != null ? content.getCriterionType() : null, + content != null ? content.getOptionType() : null, + content != null ? content.getPeople() : null, + content != null ? content.getCost() : null, + content != null ? content.getCategory() : null, + null, + content != null ? content.getDiscount() : null, + hasPartner, + store.getLatitude(), store.getLongitude(), + profileUrl, phoneNumber + ); + } + + public static StoreMapResponseDTO ofSearch( + Store store, PaperContent content, String finalCategory, + Long adminId, String adminName, 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().getMember() != null + && store.getPartner().getMember().getPhoneNum() != null) + ? store.getPartner().getMember().getPhoneNum() : ""; + + return new StoreMapResponseDTO( + store.getId(), adminId, adminName, store.getName(), + store.getAddress() != null ? store.getAddress() : store.getDetailAddress(), + store.getRate(), + content != null ? content.getCriterionType() : null, + content != null ? content.getOptionType() : null, + content != null ? content.getPeople() : null, + content != null ? content.getCost() : null, + finalCategory, + content != null ? content.getNote() : null, + content != null ? content.getDiscount() : null, + hasPartner, + store.getLatitude(), store.getLongitude(), + profileUrl, phoneNumber + ); + } +} diff --git a/src/main/java/com/assu/server/domain/map/dto/StoreMapResponseV2DTO.java b/src/main/java/com/assu/server/domain/map/dto/StoreMapResponseV2DTO.java new file mode 100644 index 00000000..fff78ec5 --- /dev/null +++ b/src/main/java/com/assu/server/domain/map/dto/StoreMapResponseV2DTO.java @@ -0,0 +1,19 @@ +package com.assu.server.domain.map.dto; + +public record StoreMapResponseV2DTO( + Long storeId, + Long adminId, + String adminName, + String name, + String address, + Integer rate, + boolean hasPartner, + Double latitude, + Double longitude, + String profileUrl, + String phoneNumber, + String partner1, + String partner2, + String benefit1, + String benefit2 +) {} diff --git a/src/main/java/com/assu/server/domain/partnership/dto/AdminPartnershipCheckResponseDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/AdminPartnershipCheckResponseDTO.java new file mode 100644 index 00000000..7be633a1 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/dto/AdminPartnershipCheckResponseDTO.java @@ -0,0 +1,28 @@ +package com.assu.server.domain.partnership.dto; + +import com.assu.server.domain.partner.entity.Partner; + +public record AdminPartnershipCheckResponseDTO( + Long paperId, + boolean isPartnered, + String status, + Long partnerId, + String partnerName, + String partnerAddress +) { + public static AdminPartnershipCheckResponseDTO of( + Partner partner, + Long paperId, + boolean isPartnered, + String status + ) { + return new AdminPartnershipCheckResponseDTO( + paperId, + isPartnered, + status, + partner.getId(), + partner.getName(), + partner.getAddress() + ); + } +} diff --git a/src/main/java/com/assu/server/domain/partnership/dto/ManualPartnershipRequestDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/ManualPartnershipRequestDTO.java new file mode 100644 index 00000000..b5194cb1 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/dto/ManualPartnershipRequestDTO.java @@ -0,0 +1,42 @@ +package com.assu.server.domain.partnership.dto; + +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.map.dto.SelectedPlacePayload; +import com.assu.server.domain.partnership.entity.Paper; +import com.assu.server.domain.partnership.entity.PaperContent; +import com.assu.server.domain.store.entity.Store; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +public record ManualPartnershipRequestDTO( + String storeName, + @NotNull SelectedPlacePayload selectedPlace, + String storeDetailAddress, + LocalDate partnershipPeriodStart, + LocalDate partnershipPeriodEnd, + List options +) { + public Paper toPaper(Admin admin, Store store, ActivationStatus status) { + return Paper.builder() + .admin(admin) + .store(store) + .partner(null) + .isActivated(status) + .partnershipPeriodStart(partnershipPeriodStart()) + .partnershipPeriodEnd(partnershipPeriodEnd()) + .build(); + } + + public List toPaperContents(Paper paper) { + if (options() == null || options().isEmpty()) return List.of(); + List list = new ArrayList<>(options().size()); + for (var o : options()) { + list.add(o.toPaperContent(paper)); + } + return list; + } +} diff --git a/src/main/java/com/assu/server/domain/partnership/dto/ManualPartnershipResponseDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/ManualPartnershipResponseDTO.java new file mode 100644 index 00000000..f864a592 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/dto/ManualPartnershipResponseDTO.java @@ -0,0 +1,29 @@ +package com.assu.server.domain.partnership.dto; + +import com.assu.server.domain.store.entity.Store; + +public record ManualPartnershipResponseDTO( + Long storeId, + boolean storeCreated, + boolean storeActivated, + String status, + String contractImageUrl, + WritePartnershipResponseDTO partnership +) { + public static ManualPartnershipResponseDTO of( + Store store, + boolean storeCreated, + boolean storeActivated, + String contractImageUrl, + WritePartnershipResponseDTO partnership + ) { + return new ManualPartnershipResponseDTO( + store.getId(), + storeCreated, + storeActivated, + store.getIsActivate() == null ? null : store.getIsActivate().name(), + contractImageUrl, + partnership + ); + } +} diff --git a/src/main/java/com/assu/server/domain/partnership/dto/PartnerPartnershipCheckResponseDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/PartnerPartnershipCheckResponseDTO.java new file mode 100644 index 00000000..5b38ce7f --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/dto/PartnerPartnershipCheckResponseDTO.java @@ -0,0 +1,28 @@ +package com.assu.server.domain.partnership.dto; + +import com.assu.server.domain.admin.entity.Admin; + +public record PartnerPartnershipCheckResponseDTO( + Long paperId, + boolean isPartnered, + String status, + Long adminId, + String adminName, + String adminAddress +) { + public static PartnerPartnershipCheckResponseDTO of( + Admin admin, + Long paperId, + boolean isPartnered, + String status + ) { + return new PartnerPartnershipCheckResponseDTO( + paperId, + isPartnered, + status, + admin.getId(), + admin.getName(), + admin.getOfficeAddress() + ); + } +} diff --git a/src/main/java/com/assu/server/domain/partnership/dto/PartnershipDetailResponseDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipDetailResponseDTO.java new file mode 100644 index 00000000..ed04f31a --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipDetailResponseDTO.java @@ -0,0 +1,72 @@ +package com.assu.server.domain.partnership.dto; + +import com.assu.server.domain.common.entity.BaseEntity; +import com.assu.server.domain.partnership.entity.Goods; +import com.assu.server.domain.partnership.entity.Paper; +import com.assu.server.domain.partnership.entity.PaperContent; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +public record PartnershipDetailResponseDTO( + Long partnershipId, + LocalDateTime updatedAt, + LocalDate partnershipPeriodStart, + LocalDate partnershipPeriodEnd, + Long adminId, + Long partnerId, + Long storeId, + List options +) { + public static PartnershipDetailResponseDTO of( + Paper paper, + List contents, + List> goodsBatches + ) { + List allTimestamps = new ArrayList<>(); + + if (paper.getUpdatedAt() != null) allTimestamps.add(paper.getUpdatedAt()); + if (contents != null) { + contents.stream() + .map(BaseEntity::getUpdatedAt) + .filter(Objects::nonNull) + .forEach(allTimestamps::add); + } + if (goodsBatches != null) { + goodsBatches.stream() + .flatMap(List::stream) + .map(BaseEntity::getUpdatedAt) + .filter(Objects::nonNull) + .forEach(allTimestamps::add); + } + + LocalDateTime mostRecentUpdatedAt = allTimestamps.stream() + .max(Comparator.naturalOrder()) + .orElse(paper.getUpdatedAt()); + + List optionDTOs = new ArrayList<>(); + if (contents != null) { + for (int i = 0; i < contents.size(); i++) { + PaperContent pc = contents.get(i); + List goods = (goodsBatches != null && goodsBatches.size() > i) + ? goodsBatches.get(i) : List.of(); + optionDTOs.add(PartnershipOptionResponseDTO.of(pc, goods)); + } + } + + return new PartnershipDetailResponseDTO( + paper.getId(), + mostRecentUpdatedAt, + paper.getPartnershipPeriodStart(), + paper.getPartnershipPeriodEnd(), + paper.getAdmin() != null ? paper.getAdmin().getId() : null, + paper.getPartner() != null ? paper.getPartner().getId() : null, + paper.getStore() != null ? paper.getStore().getId() : null, + optionDTOs + ); + } +} diff --git a/src/main/java/com/assu/server/domain/partnership/dto/PartnershipDraftRequestDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipDraftRequestDTO.java new file mode 100644 index 00000000..44b0a188 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipDraftRequestDTO.java @@ -0,0 +1,23 @@ +package com.assu.server.domain.partnership.dto; + +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.partner.entity.Partner; +import com.assu.server.domain.partnership.entity.Paper; +import com.assu.server.domain.store.entity.Store; + +public record PartnershipDraftRequestDTO( + Long partnerId +) { + public Paper toDraftPaper(Admin admin, Partner partner, Store store) { + return Paper.builder() + .admin(admin) + .partner(partner) + .store(store) + .partnershipPeriodStart(null) + .partnershipPeriodEnd(null) + .isActivated(ActivationStatus.BLANK) + .contractImageKey(null) + .build(); + } +} diff --git a/src/main/java/com/assu/server/domain/partnership/dto/PartnershipDraftResponseDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipDraftResponseDTO.java new file mode 100644 index 00000000..197c3118 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipDraftResponseDTO.java @@ -0,0 +1,11 @@ +package com.assu.server.domain.partnership.dto; + +import com.assu.server.domain.partnership.entity.Paper; + +public record PartnershipDraftResponseDTO( + Long paperId +) { + public static PartnershipDraftResponseDTO of(Paper paper) { + return new PartnershipDraftResponseDTO(paper.getId()); + } +} diff --git a/src/main/java/com/assu/server/domain/partnership/dto/PartnershipGoodsRequestDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipGoodsRequestDTO.java new file mode 100644 index 00000000..faaca6f8 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipGoodsRequestDTO.java @@ -0,0 +1,6 @@ +package com.assu.server.domain.partnership.dto; + +public record PartnershipGoodsRequestDTO( + String goodsName +) { +} diff --git a/src/main/java/com/assu/server/domain/partnership/dto/PartnershipGoodsResponseDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipGoodsResponseDTO.java new file mode 100644 index 00000000..37b864c7 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipGoodsResponseDTO.java @@ -0,0 +1,19 @@ +package com.assu.server.domain.partnership.dto; + +import com.assu.server.domain.partnership.entity.Goods; + +import java.util.List; + +public record PartnershipGoodsResponseDTO( + Long goodsId, + String goodsName +) { + public static PartnershipGoodsResponseDTO of(Goods goods) { + return new PartnershipGoodsResponseDTO(goods.getId(), goods.getBelonging()); + } + + public static List ofList(List goods) { + if (goods == null || goods.isEmpty()) return List.of(); + return goods.stream().map(PartnershipGoodsResponseDTO::of).toList(); + } +} diff --git a/src/main/java/com/assu/server/domain/partnership/dto/PartnershipOptionRequestDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipOptionRequestDTO.java new file mode 100644 index 00000000..940aa8fa --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipOptionRequestDTO.java @@ -0,0 +1,48 @@ +package com.assu.server.domain.partnership.dto; + +import com.assu.server.domain.partnership.entity.Goods; +import com.assu.server.domain.partnership.entity.Paper; +import com.assu.server.domain.partnership.entity.PaperContent; +import com.assu.server.domain.partnership.entity.enums.CriterionType; +import com.assu.server.domain.partnership.entity.enums.OptionType; + +import java.util.ArrayList; +import java.util.List; + +public record PartnershipOptionRequestDTO( + OptionType optionType, + CriterionType criterionType, + Boolean anotherType, + Integer people, + Long cost, + String category, + Long discountRate, + String note, + List goods +) { + public PaperContent toPaperContent(Paper paper) { + return PaperContent.builder() + .note(note()) + .paper(paper) + .optionType(optionType()) + .criterionType(criterionType()) + .anotherType(anotherType()) + .people(people()) + .cost(cost()) + .category(category()) + .discount(discountRate()) + .build(); + } + + public List toGoods(PaperContent content) { + if (goods() == null || goods().isEmpty()) return List.of(); + List batch = new ArrayList<>(goods().size()); + for (var g : goods()) { + batch.add(Goods.builder() + .content(content) + .belonging(g.goodsName()) + .build()); + } + return batch; + } +} diff --git a/src/main/java/com/assu/server/domain/partnership/dto/PartnershipOptionResponseDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipOptionResponseDTO.java new file mode 100644 index 00000000..4bb036ee --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipOptionResponseDTO.java @@ -0,0 +1,35 @@ +package com.assu.server.domain.partnership.dto; + +import com.assu.server.domain.partnership.entity.Goods; +import com.assu.server.domain.partnership.entity.PaperContent; +import com.assu.server.domain.partnership.entity.enums.CriterionType; +import com.assu.server.domain.partnership.entity.enums.OptionType; + +import java.util.List; + +public record PartnershipOptionResponseDTO( + OptionType optionType, + CriterionType criterionType, + Boolean anotherType, + Integer people, + Long cost, + String note, + String category, + Long discountRate, + List goods +) { + public static PartnershipOptionResponseDTO of(PaperContent pc, List goods) { + String note = pc.getNote() != null ? pc.getNote() : null; + return new PartnershipOptionResponseDTO( + pc.getOptionType(), + pc.getCriterionType(), + pc.getAnotherType(), + pc.getPeople(), + pc.getCost(), + note, + pc.getCategory(), + pc.getDiscount(), + PartnershipGoodsResponseDTO.ofList(goods) + ); + } +} diff --git a/src/main/java/com/assu/server/domain/partnership/dto/PartnershipStatusUpdateRequestDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipStatusUpdateRequestDTO.java new file mode 100644 index 00000000..aa95ef9b --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipStatusUpdateRequestDTO.java @@ -0,0 +1,6 @@ +package com.assu.server.domain.partnership.dto; + +public record PartnershipStatusUpdateRequestDTO( + String status +) { +} diff --git a/src/main/java/com/assu/server/domain/partnership/dto/PartnershipStatusUpdateResponseDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipStatusUpdateResponseDTO.java new file mode 100644 index 00000000..38835858 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/dto/PartnershipStatusUpdateResponseDTO.java @@ -0,0 +1,26 @@ +package com.assu.server.domain.partnership.dto; + +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.partnership.entity.Paper; + +import java.time.LocalDateTime; + +public record PartnershipStatusUpdateResponseDTO( + Long partnershipId, + String prevStatus, + String newStatus, + LocalDateTime changedAt +) { + public static PartnershipStatusUpdateResponseDTO of( + Paper paper, + ActivationStatus prevPaperStatus, + ActivationStatus nextPaperStatus + ) { + return new PartnershipStatusUpdateResponseDTO( + paper.getId(), + prevPaperStatus == null ? null : prevPaperStatus.name(), + nextPaperStatus.name(), + LocalDateTime.now() + ); + } +} diff --git a/src/main/java/com/assu/server/domain/partnership/dto/SuspendedPaperResponseDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/SuspendedPaperResponseDTO.java new file mode 100644 index 00000000..50d892ce --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/dto/SuspendedPaperResponseDTO.java @@ -0,0 +1,21 @@ +package com.assu.server.domain.partnership.dto; + +import com.assu.server.domain.partnership.entity.Paper; + +import java.time.LocalDateTime; + +public record SuspendedPaperResponseDTO( + Long paperId, + String partnerName, + LocalDateTime createdAt +) { + public static SuspendedPaperResponseDTO of(Paper paper) { + return new SuspendedPaperResponseDTO( + paper.getId(), + paper.getPartner() != null + ? paper.getPartner().getName() + : (paper.getStore() != null ? paper.getStore().getName() : "미등록"), + paper.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/assu/server/domain/partnership/dto/WritePartnershipRequestDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/WritePartnershipRequestDTO.java new file mode 100644 index 00000000..6331de28 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/dto/WritePartnershipRequestDTO.java @@ -0,0 +1,46 @@ +package com.assu.server.domain.partnership.dto; + +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.partnership.entity.Goods; +import com.assu.server.domain.partnership.entity.Paper; +import com.assu.server.domain.partnership.entity.PaperContent; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; + +public record WritePartnershipRequestDTO( + Long paperId, + LocalDate partnershipPeriodStart, + LocalDate partnershipPeriodEnd, + List options +) { + public void updatePaper(Paper paper) { + paper.setPartnershipPeriodStart(partnershipPeriodStart()); + paper.setPartnershipPeriodEnd(partnershipPeriodEnd()); + paper.setIsActivated(ActivationStatus.SUSPEND); + } + + public List toPaperContents(Paper paper) { + if (options() == null || options().isEmpty()) return Collections.emptyList(); + return options().stream() + .map(opt -> opt.toPaperContent(paper)) + .toList(); + } + + public List> toGoodsBatches() { + if (options() == null || options().isEmpty()) return Collections.emptyList(); + return options().stream() + .map(optionDto -> { + if (optionDto.goods() == null || optionDto.goods().isEmpty()) { + return Collections.emptyList(); + } + return optionDto.goods().stream() + .map(goodsDto -> Goods.builder() + .belonging(goodsDto.goodsName()) + .build()) + .toList(); + }) + .toList(); + } +} diff --git a/src/main/java/com/assu/server/domain/partnership/dto/WritePartnershipResponseDTO.java b/src/main/java/com/assu/server/domain/partnership/dto/WritePartnershipResponseDTO.java new file mode 100644 index 00000000..82609319 --- /dev/null +++ b/src/main/java/com/assu/server/domain/partnership/dto/WritePartnershipResponseDTO.java @@ -0,0 +1,51 @@ +package com.assu.server.domain.partnership.dto; + +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.partnership.entity.Goods; +import com.assu.server.domain.partnership.entity.Paper; +import com.assu.server.domain.partnership.entity.PaperContent; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +public record WritePartnershipResponseDTO( + Long partnershipId, + LocalDate partnershipPeriodStart, + LocalDate partnershipPeriodEnd, + Long adminId, + Long partnerId, + Long storeId, + String storeName, + String adminName, + ActivationStatus isActivated, + List options +) { + public static WritePartnershipResponseDTO of( + Paper paper, + List contents, + List> goodsBatches + ) { + List optionDTOs = new ArrayList<>(); + if (contents != null) { + for (int i = 0; i < contents.size(); i++) { + PaperContent pc = contents.get(i); + List goods = (goodsBatches != null && goodsBatches.size() > i) + ? goodsBatches.get(i) : List.of(); + optionDTOs.add(PartnershipOptionResponseDTO.of(pc, goods)); + } + } + return new WritePartnershipResponseDTO( + paper.getId(), + paper.getPartnershipPeriodStart(), + paper.getPartnershipPeriodEnd(), + paper.getAdmin() != null ? paper.getAdmin().getId() : null, + paper.getPartner() != null ? paper.getPartner().getId() : null, + paper.getStore() != null ? paper.getStore().getId() : null, + paper.getStore().getName(), + paper.getAdmin().getName(), + paper.getIsActivated(), + optionDTOs + ); + } +} From caea6350283da799f0d2a24da3afcba118057f0b Mon Sep 17 00:00:00 2001 From: SJ Hwang Date: Sun, 22 Feb 2026 01:00:23 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[Refactor/#233]=20-=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/assu/server/domain/store/service/StoreService.java | 1 - 1 file changed, 1 deletion(-) 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 e18b4ecf..9d235c34 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 @@ -1,5 +1,4 @@ package com.assu.server.domain.store.service; -import com.assu.server.domain.partnership.dto.PartnershipResponseDTO; import com.assu.server.domain.store.dto.StoreResponseDTO; import com.assu.server.domain.store.dto.TodayBestResponseDTO; import com.assu.server.domain.user.dto.StudentResponseDTO; From 5a19cb4c06771542adaa55a087559a2f3016388d Mon Sep 17 00:00:00 2001 From: SJ Hwang Date: Sun, 22 Feb 2026 16:32:26 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[Refactor/#233]=20-=20storemapresponsedto?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20-=20=EC=A7=80=EB=8F=84=20=EB=82=B4=20=EB=A7=A4=EC=9E=A5?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/map/controller/MapController.java | 6 +- .../domain/map/dto/StoreMapResponseDTO.java | 78 ++---- .../domain/map/dto/StoreMapResponseV2DTO.java | 19 -- .../server/domain/map/service/MapService.java | 14 +- .../domain/map/service/MapServiceImpl.java | 262 +++++++----------- .../domain/partnership/entity/Paper.java | 3 +- .../repository/PaperContentRepository.java | 52 ++-- 7 files changed, 142 insertions(+), 292 deletions(-) delete mode 100644 src/main/java/com/assu/server/domain/map/dto/StoreMapResponseV2DTO.java 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 c26c5a6e..f090ee90 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 @@ -107,8 +107,8 @@ public BaseResponse getLocations( } @Operation( - summary = "주변 장소 조회 API", - description = "공간 인덱싱에 들어갈 좌표 4개를 경도, 위도 순서로 입력해주세요 (user -> store 조회 / admin -> partner 조회 / partner -> admin 조회)" + summary = "주변 장소 조회 API (학생 혜택 기반)", + description = "공간 인덱싱에 들어갈 좌표 4개를 경도, 위도 순서로 입력해주세요 (user -> store 조회)" ) @GetMapping("/nearby/v2") public BaseResponse getLocationsV2( @@ -116,7 +116,7 @@ public BaseResponse getLocationsV2( @AuthenticationPrincipal PrincipalDetails pd ) { Long memberId = pd.getMember().getId(); - return BaseResponse.onSuccess(SuccessStatus._OK, mapService.getStoresV2(viewport, memberId)); + return BaseResponse.onSuccess(SuccessStatus._OK, mapService.getStores(viewport, memberId)); } @Operation( 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 72d751fd..2a4dafad 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,87 +1,55 @@ package com.assu.server.domain.map.dto; -import com.assu.server.domain.partnership.entity.PaperContent; -import com.assu.server.domain.partnership.entity.enums.CriterionType; -import com.assu.server.domain.partnership.entity.enums.OptionType; import com.assu.server.domain.store.entity.Store; import com.assu.server.infra.s3.AmazonS3Manager; public record StoreMapResponseDTO( Long storeId, - Long adminId, - String adminName, String name, String address, Integer rate, - CriterionType criterionType, - OptionType optionType, - Integer people, - Long cost, - String category, - String note, - Long discountRate, boolean hasPartner, Double latitude, Double longitude, String profileUrl, - String phoneNumber + String phoneNumber, + Long adminId1, + Long adminId2, + String adminName1, + String adminName2, + String benefit1, + String benefit2 ) { public static StoreMapResponseDTO of( - Store store, PaperContent content, Long adminId, String adminName, AmazonS3Manager s3Manager + Store store, + Long adminId1, Long adminId2, + String adminName1, String adminName2, + String benefit1, String benefit2, + 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 ? s3Manager.generatePresignedUrl(key) : null; + final String profileUrl = (key != null && !key.isBlank()) + ? s3Manager.generatePresignedUrl(key) : null; final String phoneNumber = (store.getPartner() != null && store.getPartner().getMember() != null && store.getPartner().getMember().getPhoneNum() != null) ? store.getPartner().getMember().getPhoneNum() : ""; return new StoreMapResponseDTO( - store.getId(), adminId, adminName, store.getName(), + store.getId(), + store.getName(), store.getAddress() != null ? store.getAddress() : store.getDetailAddress(), store.getRate(), - content != null ? content.getCriterionType() : null, - content != null ? content.getOptionType() : null, - content != null ? content.getPeople() : null, - content != null ? content.getCost() : null, - content != null ? content.getCategory() : null, - null, - content != null ? content.getDiscount() : null, hasPartner, - store.getLatitude(), store.getLongitude(), - profileUrl, phoneNumber - ); - } - - public static StoreMapResponseDTO ofSearch( - Store store, PaperContent content, String finalCategory, - Long adminId, String adminName, 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().getMember() != null - && store.getPartner().getMember().getPhoneNum() != null) - ? store.getPartner().getMember().getPhoneNum() : ""; - - return new StoreMapResponseDTO( - store.getId(), adminId, adminName, store.getName(), - store.getAddress() != null ? store.getAddress() : store.getDetailAddress(), - store.getRate(), - content != null ? content.getCriterionType() : null, - content != null ? content.getOptionType() : null, - content != null ? content.getPeople() : null, - content != null ? content.getCost() : null, - finalCategory, - content != null ? content.getNote() : null, - content != null ? content.getDiscount() : null, - hasPartner, - store.getLatitude(), store.getLongitude(), - profileUrl, phoneNumber + store.getLatitude(), + store.getLongitude(), + profileUrl, + phoneNumber, + adminId1, adminId2, + adminName1, adminName2, + benefit1, benefit2 ); } } diff --git a/src/main/java/com/assu/server/domain/map/dto/StoreMapResponseV2DTO.java b/src/main/java/com/assu/server/domain/map/dto/StoreMapResponseV2DTO.java deleted file mode 100644 index fff78ec5..00000000 --- a/src/main/java/com/assu/server/domain/map/dto/StoreMapResponseV2DTO.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.assu.server.domain.map.dto; - -public record StoreMapResponseV2DTO( - Long storeId, - Long adminId, - String adminName, - String name, - String address, - Integer rate, - boolean hasPartner, - Double latitude, - Double longitude, - String profileUrl, - String phoneNumber, - String partner1, - String partner2, - String benefit1, - String benefit2 -) {} 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 4553289b..82047677 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,17 +4,15 @@ 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.map.dto.StoreMapResponseV2DTO; 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 getStoresV2(MapRequestDTO viewport, Long memberId); + List getAdmins(MapRequestDTO viewport, Long memberId); + List getPartners(MapRequestDTO viewport, Long memberId); + List getStores(MapRequestDTO viewport, Long memberId); - List searchStores(String keyword); - List searchPartner(String keyword, Long memberId); - List searchAdmin(String keyword, Long memberId); + List searchStores(String keyword); + List searchPartner(String keyword, Long memberId); + List searchAdmin(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 1ce430b7..c4f75d72 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 @@ -7,15 +7,12 @@ 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.map.dto.StoreMapResponseV2DTO; import com.assu.server.domain.partner.entity.Partner; import com.assu.server.domain.partner.repository.PartnerRepository; -import com.assu.server.domain.partnership.entity.Goods; import com.assu.server.domain.partnership.entity.Paper; import com.assu.server.domain.partnership.entity.PaperContent; import com.assu.server.domain.partnership.entity.enums.CriterionType; import com.assu.server.domain.partnership.entity.enums.OptionType; -import com.assu.server.domain.partnership.repository.GoodsRepository; import com.assu.server.domain.partnership.repository.PaperContentRepository; import com.assu.server.domain.partnership.repository.PaperRepository; import com.assu.server.domain.store.entity.Store; @@ -27,8 +24,14 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; @Service @@ -41,13 +44,11 @@ public class MapServiceImpl implements MapService { private final StoreRepository storeRepository; private final PaperContentRepository paperContentRepository; private final PaperRepository paperRepository; - private final GoodsRepository goodsRepository; private final AmazonS3Manager amazonS3Manager; private final UserPaperRepository userPaperRepository; @Override public List getPartners(MapRequestDTO viewport, Long memberId) { - String wkt = toWKT(viewport); List partners = partnerRepository.findAllWithinViewportWithMember(wkt); @@ -84,172 +85,112 @@ public List getAdmins(MapRequestDTO viewport, Long memberId .toList(); } + /** + * 뷰포트 내 매장 조회 (학생 혜택 기반). + * + * 조회 기준: + * 1. memberId(student_id)와 매치되는 모든 user_paper 조회 + * 2. 각 user_paper의 paper_id를 가진 paper 중 is_activated = ACTIVE인 것 + * 3. 그 중 뷰포트 내 store를 포함한 paper + * + * 위 기준을 만족하는 paper 중, 매장당 중복 없이 가장 최근 2건(paper+papercontent 쌍)을 가져와 + * adminId1/2, adminName1/2, benefit1/2를 채운 StoreMapResponseDTO 반환. + * papercontent의 note가 있으면 benefit 대신 note를 사용. + */ @Override public List getStores(MapRequestDTO viewport, Long memberId) { final String wkt = toWKT(viewport); // 1) 뷰포트 내 매장 조회 (Partner, Member fetch join) final List stores = storeRepository.findAllWithinViewportWithPartner(wkt); - if (stores.isEmpty()) { return List.of(); } - // 2) Paper 및 Admin 정보 batch 조회 - List storeIds = stores.stream().map(Store::getId).toList(); - List papers = paperRepository.findByStoreIdIn(storeIds); - Map storeIdToPaper = papers.stream() - .collect(Collectors.toMap(p -> p.getStore().getId(), p -> p, (p1, p2) -> p1.getId() > p2.getId() ? p1 : p2)); - - List adminIds = papers.stream() - .map(p -> p.getAdmin() != null ? p.getAdmin().getId() : null) - .filter(id -> id != null) - .distinct() - .toList(); - List admins = adminIds.isEmpty() ? List.of() : adminRepository.findAllById(adminIds); - Map adminIdToAdmin = admins.stream() - .collect(Collectors.toMap(Admin::getId, a -> a)); - - // 3) PaperContent batch 조회 (N+1 방지) - List contents = paperContentRepository.findLatestValidByStoreIdInNative( - storeIds, - ActivationStatus.ACTIVE.name(), - OptionType.SERVICE.name(), - OptionType.DISCOUNT.name(), - CriterionType.PRICE.name(), - CriterionType.HEADCOUNT.name() - ); - Map storeIdToContent = contents.stream() - .collect(Collectors.toMap( - pc -> pc.getPaper().getStore().getId(), - pc -> pc, - (pc1, pc2) -> pc1.getId() > pc2.getId() ? pc1 : pc2 - )); - - // 4) 매장별 DTO 생성 - return stores.stream().map(s -> { - final PaperContent content = storeIdToContent.get(s.getId()); - final Paper paper = storeIdToPaper.get(s.getId()); - final Long adminId = paper != null && paper.getAdmin() != null ? paper.getAdmin().getId() : null; - final String adminName = adminId != null ? adminIdToAdmin.getOrDefault(adminId, null) != null - ? adminIdToAdmin.get(adminId).getName() : null : null; + // 2) 해당 학생의 활성 UserPaper 조회 (paper, store, admin fetch join 포함) + final List userPapers = userPaperRepository.findActivePartnershipsByStudentId(memberId, LocalDate.now()); + if (userPapers.isEmpty()) { + return stores.stream() + .map(s -> StoreMapResponseDTO.of(s, null, null, null, null, null, null, amazonS3Manager)) + .toList(); + } - return StoreMapResponseDTO.of(s, content, adminId, adminName, amazonS3Manager); - }).toList(); - } + // 3) 뷰포트 매장 ID Set (O(1) 조회용) + final Set storeIdSet = new HashSet<>(); + for (Store s : stores) storeIdSet.add(s.getId()); - @Override - public List getStoresV2(MapRequestDTO viewport, Long memberId) { - final String wkt = toWKT(viewport); - final List stores = storeRepository.findAllWithinViewport(wkt); - final List userPapers = userPaperRepository.findActivePartnershipsByStudentId(memberId, java.time.LocalDate.now()); + // 4) UserPaper를 매장별로 그룹화: 뷰포트 내 매장 필터 + paper 중복 제거 + 매장당 최대 2건 + // findActivePartnershipsByStudentId 결과는 이미 paper.id DESC 정렬 상태 + final Map> papersByStore = new LinkedHashMap<>(); + final Set seenPaperIds = new HashSet<>(); + for (UserPaper up : userPapers) { + final Long storeId = up.getPaper().getStore().getId(); + if (!storeIdSet.contains(storeId)) continue; - // 모든 매장 ID 수집 - List storeIds = stores.stream().map(Store::getId).toList(); + final Long paperId = up.getPaper().getId(); + if (!seenPaperIds.add(paperId)) continue; // 이미 처리한 paper면 스킵 - // 빈 화면(매장 없음) 방어 코드 - if (storeIds.isEmpty()) { - return java.util.Collections.emptyList(); + final List list = papersByStore.computeIfAbsent(storeId, k -> new ArrayList<>(2)); + if (list.size() < 2) { + list.add(up.getPaper()); + } } - // 사용자가 가진 Paper ID 수집 (쿼리에 파라미터로 넘기기 위해 위치를 위로 올림!) - List userPaperIds = userPapers.stream() - .map(up -> up.getPaper().getId()) + // 5) 선택된 paper ID 목록으로 각 paper의 최신 PaperContent 1건씩 일괄 조회 + final List selectedPaperIds = papersByStore.values().stream() + .flatMap(List::stream) + .map(Paper::getId) .toList(); - // 일괄 조회 1: 모든 매장의 PaperContent (내 혜택 기준 매장당 최대 2개) - List allContents; - if (userPaperIds.isEmpty()) { - // 방어 코드: 사용자가 가진 혜택이 없다면 IN () 에러 방지를 위해 쿼리 실행 없이 빈 리스트 할당 - allContents = java.util.Collections.emptyList(); + final Map contentByPaperId; + if (selectedPaperIds.isEmpty()) { + contentByPaperId = Collections.emptyMap(); } else { - allContents = paperContentRepository.findLatestValidByStoreIdInNativeMax2( - storeIds, - ActivationStatus.ACTIVE.name(), - OptionType.SERVICE.name(), - OptionType.DISCOUNT.name(), - CriterionType.PRICE.name(), - CriterionType.HEADCOUNT.name(), - userPaperIds // ⭐️ 새로 추가된 파라미터 전달! - ); + contentByPaperId = paperContentRepository.findLatestByPaperIds(selectedPaperIds).stream() + .collect(Collectors.toMap( + pc -> pc.getPaper().getId(), + pc -> pc, + (a, b) -> a + )); } - // 일괄 조회 2: 모든 매장의 Paper와 Admin 정보 (Fetch Join) - List allPapers = paperRepository.findLatestPapersByStoreIds(storeIds); - - // 매장별 Paper 매핑 (매장당 최신 1개) - java.util.Map paperByStore = allPapers.stream() - .collect(java.util.stream.Collectors.toMap( - p -> p.getStore().getId(), - p -> p, - (existing, replacement) -> existing // 이미 키가 있으면 기존 값(최신) 유지 - )); - - // 매장별로 PaperContent 그룹화 - java.util.Map> contentsByStore = allContents.stream() - .collect(java.util.stream.Collectors.groupingBy( - pc -> pc.getPaper().getStore().getId() - )); - + // 6) 매장별 DTO 생성 return stores.stream().map(s -> { - final boolean hasPartner = (s.getPartner() != null); - - // ⭐️ DB에서 이미 '내 혜택 중 상위 2개'만 가져왔으므로 filter와 limit이 필요 없음! 꺼내기만 하면 끝. - List benefits = contentsByStore.getOrDefault(s.getId(), java.util.Collections.emptyList()); - - // 혜택 텍스트 생성 - String partner1 = null; - String benefit1 = null; - String partner2 = null; - String benefit2 = null; - - if (!benefits.isEmpty()) { - PaperContent content1 = benefits.get(0); - partner1 = content1.getPaper() != null && content1.getPaper().getPartner() != null - ? content1.getPaper().getPartner().getName() : null; - benefit1 = generateBenefitText(content1); - } + final List sPapers = papersByStore.getOrDefault(s.getId(), Collections.emptyList()); - if (benefits.size() > 1) { - PaperContent content2 = benefits.get(1); - partner2 = content2.getPaper() != null && content2.getPaper().getPartner() != null - ? content2.getPaper().getPartner().getName() : null; - benefit2 = generateBenefitText(content2); - } + Long adminId1 = null; String adminName1 = null; String benefit1 = null; + Long adminId2 = null; String adminName2 = null; String benefit2 = null; - Paper paper = paperByStore.get(s.getId()); - Long adminId = null; - String adminName = null; - if (paper != null && paper.getAdmin() != null) { - adminId = paper.getAdmin().getId(); - String name = paper.getAdmin().getName(); - adminName = (name != null) ? name : ""; // Null이면 빈 문자열로 덮어쓰기! + if (!sPapers.isEmpty()) { + final Paper p1 = sPapers.get(0); + if (p1.getAdmin() != null) { + adminId1 = p1.getAdmin().getId(); + adminName1 = p1.getAdmin().getName(); + } + benefit1 = resolveBenefit(contentByPaperId.get(p1.getId())); + } + if (sPapers.size() > 1) { + final Paper p2 = sPapers.get(1); + if (p2.getAdmin() != null) { + adminId2 = p2.getAdmin().getId(); + adminName2 = p2.getAdmin().getName(); + } + benefit2 = resolveBenefit(contentByPaperId.get(p2.getId())); } - // S3 presigned URL - final String key = (s.getPartner() != null && s.getPartner().getMember() != null) - ? s.getPartner().getMember().getProfileUrl() : null; - final String profileUrl = (key != null && !key.isBlank()) - ? amazonS3Manager.generatePresignedUrl(key) : null; - - // phoneNumber - final String phoneNumber = (s.getPartner() != null - && s.getPartner().getMember() != null - && s.getPartner().getMember().getPhoneNum() != null) - ? s.getPartner().getMember().getPhoneNum() - : ""; - - return new StoreMapResponseV2DTO( - s.getId(), adminId, adminName, s.getName(), - s.getAddress() != null ? s.getAddress() : s.getDetailAddress(), - s.getRate(), hasPartner, - s.getLatitude(), s.getLongitude(), - profileUrl, phoneNumber, - partner1, partner2, benefit1, benefit2 - ); + return StoreMapResponseDTO.of(s, adminId1, adminId2, adminName1, adminName2, benefit1, benefit2, amazonS3Manager); }).toList(); } + /** note가 있으면 note를, 없으면 generateBenefitText 결과를 반환 */ + private String resolveBenefit(PaperContent content) { + if (content == null) return null; + if (content.getNote() != null && !content.getNote().isBlank()) { + return content.getNote(); + } + return generateBenefitText(content); + } + private String generateBenefitText(PaperContent content) { if (content == null) return null; @@ -291,20 +232,22 @@ public List searchStores(String keyword) { } List storeIds = stores.stream().map(Store::getId).toList(); + + // 매장당 최신 Paper 1건 (admin 정보용) List papers = paperRepository.findByStoreIdIn(storeIds); Map storeIdToPaper = papers.stream() .collect(Collectors.toMap(p -> p.getStore().getId(), p -> p, (p1, p2) -> p1.getId() > p2.getId() ? p1 : p2)); List adminIds = papers.stream() - .map(p -> p.getAdmin() != null ? p.getAdmin().getId() : null) - .filter(id -> id != null) + .filter(p -> p.getAdmin() != null) + .map(p -> p.getAdmin().getId()) .distinct() .toList(); List admins = adminIds.isEmpty() ? List.of() : adminRepository.findAllById(adminIds); Map adminIdToAdmin = admins.stream() .collect(Collectors.toMap(Admin::getId, a -> a)); - // PaperContent batch 조회 (N+1 방지) + // 매장당 최신 PaperContent 1건 (benefit 생성용) List contents = paperContentRepository.findTopByStoreIdIn(storeIds); Map storeIdToContent = contents.stream() .collect(Collectors.toMap( @@ -313,36 +256,17 @@ public List searchStores(String keyword) { (pc1, pc2) -> pc1.getId() > pc2.getId() ? pc1 : pc2 )); - // Goods batch 조회 (N+1 방지) - List contentIds = contents.stream().map(PaperContent::getId).toList(); - List allGoods = contentIds.isEmpty() ? List.of() : goodsRepository.findByContentIdIn(contentIds); - Map> contentIdToGoods = allGoods.stream() - .collect(Collectors.groupingBy(g -> g.getContent().getId())); - return stores.stream().map(s -> { PaperContent content = storeIdToContent.get(s.getId()); Paper paper = storeIdToPaper.get(s.getId()); Long adminId = paper != null && paper.getAdmin() != null ? paper.getAdmin().getId() : null; Admin admin = adminId != null ? adminIdToAdmin.get(adminId) : null; - String finalCategory = null; - - if (content != null) { - if (content.getCategory() != null) { - finalCategory = content.getCategory(); - } - else if (content.getOptionType() == OptionType.SERVICE) { - List goods = contentIdToGoods.getOrDefault(content.getId(), List.of()); - - if (!goods.isEmpty()) { - finalCategory = goods.get(0).getBelonging(); - } - } - } - - return StoreMapResponseDTO.ofSearch( - s, content, finalCategory, adminId, - admin != null ? admin.getName() : null, + return StoreMapResponseDTO.of( + s, + adminId, null, + admin != null ? admin.getName() : null, null, + generateBenefitText(content), null, amazonS3Manager ); }).toList(); diff --git a/src/main/java/com/assu/server/domain/partnership/entity/Paper.java b/src/main/java/com/assu/server/domain/partnership/entity/Paper.java index 4bfae082..771baaf5 100644 --- a/src/main/java/com/assu/server/domain/partnership/entity/Paper.java +++ b/src/main/java/com/assu/server/domain/partnership/entity/Paper.java @@ -9,7 +9,6 @@ import jakarta.persistence.*; import lombok.*; -import java.time.LocalDate; @Entity @Getter @@ -22,7 +21,7 @@ public class Paper extends BaseEntity { private Long id; @Setter - private LocalDate partnershipPeriodStart; // LocalDate vs String + private LocalDate partnershipPeriodStart; @Setter private LocalDate partnershipPeriodEnd; diff --git a/src/main/java/com/assu/server/domain/partnership/repository/PaperContentRepository.java b/src/main/java/com/assu/server/domain/partnership/repository/PaperContentRepository.java index 5d55c91c..25a3b287 100644 --- a/src/main/java/com/assu/server/domain/partnership/repository/PaperContentRepository.java +++ b/src/main/java/com/assu/server/domain/partnership/repository/PaperContentRepository.java @@ -11,8 +11,6 @@ public interface PaperContentRepository extends JpaRepository { - Optional findTopByPaperStoreIdOrderByIdDesc(Long storeId); - List findByPaperId(Long paperId); @Query(""" @@ -33,39 +31,6 @@ public interface PaperContentRepository extends JpaRepository findById(Long id); - @Query(value = """ - SELECT pc.* - FROM paper_content pc - JOIN paper p ON p.id = pc.paper_id - WHERE p.store_id = :storeId - AND p.is_activated = :active - AND CURRENT_DATE BETWEEN p.partnership_period_start AND p.partnership_period_end - AND ( - (pc.option_type = :service AND - ((pc.criterion_type = :price AND pc.cost IS NOT NULL) - OR (pc.criterion_type = :headcount AND pc.cost IS NOT NULL AND pc.people IS NOT NULL))) - OR (pc.option_type = :discount AND pc.discount IS NOT NULL) - ) - ORDER BY - CASE pc.option_type - WHEN :service THEN 0 ELSE 1 END, -- SERVICE 우선 - CASE pc.criterion_type - WHEN :price THEN 0 - WHEN :headcount THEN 1 - ELSE 2 END, -- PRICE > HEADCOUNT > 기타 - pc.updated_at DESC, - pc.id DESC - LIMIT 1 - """, nativeQuery = true) - Optional findLatestValidByStoreIdNative( - @Param("storeId") Long storeId, - @Param("active") String active, - @Param("service") String service, - @Param("discount") String discount, - @Param("price") String price, - @Param("headcount") String headcount - ); - @Query(value = """ WITH ranked_content AS ( SELECT pc.*, @@ -84,7 +49,7 @@ WITH ranked_content AS ( FROM paper_content pc JOIN paper p ON p.id = pc.paper_id WHERE p.store_id IN :storeIds - AND p.id IN :userPaperIds -- 대장님이 추가하신 핵심 조건! + AND p.id IN :userPaperIds AND p.is_activated = :active AND CURRENT_DATE BETWEEN p.partnership_period_start AND p.partnership_period_end AND ( @@ -154,4 +119,19 @@ List findLatestValidByStoreIdInNative( """) List findTopByStoreIdIn(@Param("storeIds") List storeIds); + /** + * 주어진 paper_id 목록에서 각 paper의 가장 최신 PaperContent를 1건씩 반환. + * paper_id 컬럼 인덱스를 활용한 ROW_NUMBER() 윈도우 함수 사용. + */ + @Query(value = """ + WITH ranked AS ( + SELECT pc.*, + ROW_NUMBER() OVER (PARTITION BY pc.paper_id ORDER BY pc.id DESC) AS rn + FROM paper_content pc + WHERE pc.paper_id IN :paperIds + ) + SELECT * FROM ranked WHERE rn = 1 + """, nativeQuery = true) + List findLatestByPaperIds(@Param("paperIds") List paperIds); + }