diff --git a/src/main/java/com/pinHouse/server/platform/home/application/dto/HomeNoticeListResponse.java b/src/main/java/com/pinHouse/server/platform/home/application/dto/HomeNoticeListResponse.java new file mode 100644 index 00000000..66d53f90 --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/home/application/dto/HomeNoticeListResponse.java @@ -0,0 +1,25 @@ +package com.pinHouse.server.platform.home.application.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Schema(name = "[응답][홈] 홈 화면 공고 목록 조회 응답", description = "홈 화면에서 마감임박 공고 목록을 조회하기 위한 DTO입니다. SliceResponse와 유사한 구조에 region 필드를 추가했습니다.") +@Builder +public record HomeNoticeListResponse( + + @Schema(description = "공통 지역", example = "성남시") + String region, + + @Schema(description = "공고 목록") + List content, + + @Schema(description = "다음 페이지 존재 여부", example = "true") + boolean hasNext, + + @Schema(description = "전체 공고 개수", example = "100") + long totalElements + +) { +} diff --git a/src/main/java/com/pinHouse/server/platform/home/application/dto/HomeNoticeResponse.java b/src/main/java/com/pinHouse/server/platform/home/application/dto/HomeNoticeResponse.java new file mode 100644 index 00000000..8cc6d684 --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/home/application/dto/HomeNoticeResponse.java @@ -0,0 +1,63 @@ +package com.pinHouse.server.platform.home.application.dto; + +import com.pinHouse.server.core.util.DateUtil; +import com.pinHouse.server.platform.housing.notice.domain.entity.NoticeDocument; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Schema(name = "[응답][홈] 개별 공고 정보", description = "홈 화면의 개별 공고 정보입니다.") +@Builder +public record HomeNoticeResponse( + + @Schema(description = "공고ID", example = "101") + String id, + + @Schema(description = "공고 썸네일 URL", example = "https://example.jpg") + String thumbnailUrl, + + @Schema(description = "공고명", example = "2025 청년 행복주택 공고") + String name, + + @Schema(description = "공급주체", example = "LH") + String supplier, + + @Schema(description = "공급 주택 개수", example = "3") + Integer complexes, + + @Schema(description = "공급유형", example = "영구임대") + String type, + + @Schema(description = "주택유형", example = "아파트") + String housingType, + + @Schema(description = "모집 공고일", example = "2025년 10월 5일") + String announcePeriod, + + @Schema(description = "모집 일정", example = "2025년 10월 3일 ~ 11월 3일") + String applyPeriod, + + @Schema(description = "좋아요 여부", example = "false") + boolean liked + +) { + + /** + * NoticeDocument와 좋아요 여부로부터 HomeNoticeResponse를 생성 + */ + public static HomeNoticeResponse from(NoticeDocument notice, boolean liked) { + String period = DateUtil.formatDate(notice.getApplyStart(), notice.getApplyEnd()); + + return HomeNoticeResponse.builder() + .id(notice.getId()) + .thumbnailUrl(notice.getThumbnail()) + .name(notice.getTitle()) + .supplier(notice.getAgency()) + .complexes(notice.getMeta().getTotalComplexCount()) + .announcePeriod(notice.getAnnounceDate().toString()) + .applyPeriod(period) + .type(notice.getSupplyType()) + .housingType(notice.getHouseType()) + .liked(liked) + .build(); + } +} diff --git a/src/main/java/com/pinHouse/server/platform/home/application/service/HomeService.java b/src/main/java/com/pinHouse/server/platform/home/application/service/HomeService.java index ab8e2e8c..24fc7c16 100644 --- a/src/main/java/com/pinHouse/server/platform/home/application/service/HomeService.java +++ b/src/main/java/com/pinHouse/server/platform/home/application/service/HomeService.java @@ -4,9 +4,10 @@ import com.pinHouse.server.core.response.response.CustomException; import com.pinHouse.server.core.response.response.pageable.SliceRequest; import com.pinHouse.server.core.response.response.pageable.SliceResponse; +import com.pinHouse.server.platform.home.application.dto.HomeNoticeListResponse; +import com.pinHouse.server.platform.home.application.dto.HomeNoticeResponse; import com.pinHouse.server.platform.home.application.usecase.HomeUseCase; import com.pinHouse.server.platform.housing.notice.application.dto.NoticeListRequest; -import com.pinHouse.server.platform.housing.notice.application.dto.NoticeListResponse; import com.pinHouse.server.platform.housing.notice.domain.entity.NoticeDocument; import com.pinHouse.server.platform.housing.notice.domain.repository.NoticeDocumentRepository; import com.pinHouse.server.platform.like.application.usecase.LikeQueryUseCase; @@ -51,7 +52,7 @@ public class HomeService implements HomeUseCase { * - PinPoint의 address에서 광역 단위와 시/군/구를 추출하여 해당 지역의 마감임박 공고를 조회 */ @Override - public SliceResponse getDeadlineApproachingNotices( + public HomeNoticeListResponse getDeadlineApproachingNotices( String pinpointId, SliceRequest sliceRequest, UUID userId @@ -88,15 +89,21 @@ public SliceResponse getDeadlineApproachingNotices( // 좋아요 상태 조회 List likedNoticeIds = likeService.getLikeNoticeIds(userId); - // DTO 변환 - List content = page.getContent().stream() + // DTO 변환 (개별 공고 정보) + List content = page.getContent().stream() .map(notice -> { boolean isLiked = likedNoticeIds.contains(notice.getId()); - return NoticeListResponse.from(notice, isLiked); + return HomeNoticeResponse.from(notice, isLiked); }) .toList(); - return SliceResponse.from(new SliceImpl<>(content, pageable, page.hasNext()), page.getTotalElements()); + // 최종 응답 생성 (region + content + 페이징 정보) + return HomeNoticeListResponse.builder() + .region(county) + .content(content) + .hasNext(page.hasNext()) + .totalElements(page.getTotalElements()) + .build(); } /** diff --git a/src/main/java/com/pinHouse/server/platform/home/application/usecase/HomeUseCase.java b/src/main/java/com/pinHouse/server/platform/home/application/usecase/HomeUseCase.java index 7a721d71..f0fb1fb6 100644 --- a/src/main/java/com/pinHouse/server/platform/home/application/usecase/HomeUseCase.java +++ b/src/main/java/com/pinHouse/server/platform/home/application/usecase/HomeUseCase.java @@ -2,8 +2,8 @@ import com.pinHouse.server.core.response.response.pageable.SliceRequest; import com.pinHouse.server.core.response.response.pageable.SliceResponse; +import com.pinHouse.server.platform.home.application.dto.HomeNoticeListResponse; import com.pinHouse.server.platform.housing.notice.application.dto.NoticeListRequest; -import com.pinHouse.server.platform.housing.notice.application.dto.NoticeListResponse; import com.pinHouse.server.platform.search.application.dto.NoticeSearchFilterType; import com.pinHouse.server.platform.search.application.dto.NoticeSearchResultResponse; import com.pinHouse.server.platform.search.application.dto.NoticeSearchSortType; @@ -20,9 +20,9 @@ public interface HomeUseCase { * @param pinpointId PinPoint ID (해당 지역의 공고를 조회) * @param sliceRequest 페이징 정보 * @param userId 사용자 ID (좋아요 정보 조회용, null 가능) - * @return 해당 지역의 마감임박순으로 정렬된 공고 목록 + * @return 해당 지역의 마감임박순으로 정렬된 공고 목록 (region + notices 배열) */ - SliceResponse getDeadlineApproachingNotices( + HomeNoticeListResponse getDeadlineApproachingNotices( String pinpointId, SliceRequest sliceRequest, UUID userId diff --git a/src/main/java/com/pinHouse/server/platform/home/presentation/HomeApi.java b/src/main/java/com/pinHouse/server/platform/home/presentation/HomeApi.java index 3ea87b40..a28a782e 100644 --- a/src/main/java/com/pinHouse/server/platform/home/presentation/HomeApi.java +++ b/src/main/java/com/pinHouse/server/platform/home/presentation/HomeApi.java @@ -4,9 +4,9 @@ import com.pinHouse.server.core.response.response.ApiResponse; import com.pinHouse.server.core.response.response.pageable.SliceRequest; import com.pinHouse.server.core.response.response.pageable.SliceResponse; +import com.pinHouse.server.platform.home.application.dto.HomeNoticeListResponse; import com.pinHouse.server.platform.home.application.usecase.HomeUseCase; import com.pinHouse.server.platform.home.presentation.swagger.HomeApiSpec; -import com.pinHouse.server.platform.housing.notice.application.dto.NoticeListResponse; import com.pinHouse.server.platform.search.application.dto.NoticeSearchFilterType; import com.pinHouse.server.platform.search.application.dto.NoticeSearchResultResponse; import com.pinHouse.server.platform.search.application.dto.NoticeSearchSortType; @@ -37,7 +37,7 @@ public class HomeApi implements HomeApiSpec { @Override @CheckLogin @GetMapping("/notice") - public ApiResponse> getDeadlineApproachingNotices( + public ApiResponse getDeadlineApproachingNotices( @RequestParam String pinpointId, SliceRequest sliceRequest, @AuthenticationPrincipal PrincipalDetails principalDetails @@ -46,7 +46,7 @@ public ApiResponse> getDeadlineApproachingNoti UUID userId = principalDetails.getId(); // 서비스 호출 - SliceResponse response = homeService.getDeadlineApproachingNotices( + HomeNoticeListResponse response = homeService.getDeadlineApproachingNotices( pinpointId, sliceRequest, userId diff --git a/src/main/java/com/pinHouse/server/platform/home/presentation/swagger/HomeApiSpec.java b/src/main/java/com/pinHouse/server/platform/home/presentation/swagger/HomeApiSpec.java index 9898efd0..78e348ba 100644 --- a/src/main/java/com/pinHouse/server/platform/home/presentation/swagger/HomeApiSpec.java +++ b/src/main/java/com/pinHouse/server/platform/home/presentation/swagger/HomeApiSpec.java @@ -3,7 +3,7 @@ import com.pinHouse.server.core.response.response.ApiResponse; import com.pinHouse.server.core.response.response.pageable.SliceRequest; import com.pinHouse.server.core.response.response.pageable.SliceResponse; -import com.pinHouse.server.platform.housing.notice.application.dto.NoticeListResponse; +import com.pinHouse.server.platform.home.application.dto.HomeNoticeListResponse; import com.pinHouse.server.platform.search.application.dto.NoticeSearchFilterType; import com.pinHouse.server.platform.search.application.dto.NoticeSearchResultResponse; import com.pinHouse.server.platform.search.application.dto.NoticeSearchSortType; @@ -22,9 +22,10 @@ public interface HomeApiSpec { description = "PinPoint의 지역을 기반으로 마감임박순으로 정렬된 공고 목록을 조회하는 API 입니다. " + "PinPoint의 주소에서 광역 단위(시/도)를 추출하여 해당 지역의 모집중인 공고만 조회합니다. " + "본인의 PinPoint만 사용 가능하며, 다른 사용자의 PinPoint ID를 사용하면 400 에러가 발생합니다. " + - "좋아요 정보가 포함됩니다." + "응답 구조: 공통 지역(region) + 공고 목록 배열(content) + 페이징 정보(hasNext, totalElements). " + + "SliceResponse와 유사한 구조이지만 region 필드가 추가되었습니다." ) - ApiResponse> getDeadlineApproachingNotices( + ApiResponse getDeadlineApproachingNotices( @Parameter(description = "PinPoint ID", example = "83ec36ce-8fc1-4f62-8983-397c2729fc22") @RequestParam String pinpointId, diff --git a/src/main/java/com/pinHouse/server/platform/housing/complex/application/service/ComplexService.java b/src/main/java/com/pinHouse/server/platform/housing/complex/application/service/ComplexService.java index 182194de..feacc835 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/complex/application/service/ComplexService.java +++ b/src/main/java/com/pinHouse/server/platform/housing/complex/application/service/ComplexService.java @@ -166,6 +166,17 @@ public List loadComplexes(String noticeId) { return repository.findByNoticeId(noticeId); } + /// 공고 기반 목록 조회 (정렬된 유닛타입 포함) + @Override + @Transactional(readOnly = true) + public List loadSortedComplexes( + String noticeId, + com.pinHouse.server.platform.housing.notice.application.dto.UnitTypeSortType sortType + ) { + log.debug("정렬된 단지 목록 조회 - noticeId: {}, sortType: {}", noticeId, sortType); + return repository.findSortedComplexesWithUnitTypes(noticeId, sortType); + } + /// 유닛타입 ID 목록으로 단지 목록 조회 @Override @Transactional(readOnly = true) diff --git a/src/main/java/com/pinHouse/server/platform/housing/complex/application/usecase/ComplexUseCase.java b/src/main/java/com/pinHouse/server/platform/housing/complex/application/usecase/ComplexUseCase.java index fcd94b9b..fb01618b 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/complex/application/usecase/ComplexUseCase.java +++ b/src/main/java/com/pinHouse/server/platform/housing/complex/application/usecase/ComplexUseCase.java @@ -47,6 +47,9 @@ public interface ComplexUseCase { /// 공고 내부 목록 조회 List loadComplexes(String noticeId); + /// 공고 내부 목록 조회 (정렬된 유닛타입 포함) + List loadSortedComplexes(String noticeId, com.pinHouse.server.platform.housing.notice.application.dto.UnitTypeSortType sortType); + /// 유닛타입 ID 목록으로 단지 목록 조회 List findComplexesByUnitTypeIds(List typeIds); diff --git a/src/main/java/com/pinHouse/server/platform/housing/complex/domain/repository/ComplexDocumentRepository.java b/src/main/java/com/pinHouse/server/platform/housing/complex/domain/repository/ComplexDocumentRepository.java index 655714d5..f9f7bf5e 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/complex/domain/repository/ComplexDocumentRepository.java +++ b/src/main/java/com/pinHouse/server/platform/housing/complex/domain/repository/ComplexDocumentRepository.java @@ -7,7 +7,7 @@ import java.util.List; -public interface ComplexDocumentRepository extends MongoRepository { +public interface ComplexDocumentRepository extends MongoRepository, CustomComplexDocumentRepository { List findByNoticeId(String noticeId); diff --git a/src/main/java/com/pinHouse/server/platform/housing/complex/domain/repository/CustomComplexDocumentRepository.java b/src/main/java/com/pinHouse/server/platform/housing/complex/domain/repository/CustomComplexDocumentRepository.java new file mode 100644 index 00000000..b4503463 --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/housing/complex/domain/repository/CustomComplexDocumentRepository.java @@ -0,0 +1,22 @@ +package com.pinHouse.server.platform.housing.complex.domain.repository; + +import com.pinHouse.server.platform.housing.complex.domain.entity.ComplexDocument; +import com.pinHouse.server.platform.housing.notice.application.dto.UnitTypeSortType; + +import java.util.List; + +/** + * ComplexDocument 커스텀 Repository + * MongoDB Aggregation을 사용한 복잡한 쿼리 처리 + */ +public interface CustomComplexDocumentRepository { + + /** + * 공고에 속한 모든 단지와 유닛타입을 정렬하여 조회 + * + * @param noticeId 공고 ID + * @param sortType 정렬 기준 + * @return 정렬된 단지 목록 (각 단지의 unitTypes도 정렬됨) + */ + List findSortedComplexesWithUnitTypes(String noticeId, UnitTypeSortType sortType); +} diff --git a/src/main/java/com/pinHouse/server/platform/housing/complex/domain/repository/CustomComplexDocumentRepositoryImpl.java b/src/main/java/com/pinHouse/server/platform/housing/complex/domain/repository/CustomComplexDocumentRepositoryImpl.java new file mode 100644 index 00000000..54d39f1a --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/housing/complex/domain/repository/CustomComplexDocumentRepositoryImpl.java @@ -0,0 +1,147 @@ +package com.pinHouse.server.platform.housing.complex.domain.repository; + +import com.pinHouse.server.platform.housing.complex.domain.entity.ComplexDocument; +import com.pinHouse.server.platform.housing.notice.application.dto.UnitTypeSortType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static org.springframework.data.mongodb.core.aggregation.Aggregation.*; + +/** + * ComplexDocument 커스텀 Repository 구현체 + * MongoDB Aggregation Pipeline을 사용한 DB 레벨 정렬 처리 + */ +@Slf4j +@Repository +@RequiredArgsConstructor +public class CustomComplexDocumentRepositoryImpl implements CustomComplexDocumentRepository { + + private final MongoTemplate mongoTemplate; + + /** + * 공고에 속한 모든 단지와 유닛타입을 정렬하여 조회 + * + * MongoDB Aggregation Pipeline을 사용하여: + * 1. noticeId로 필터링 + * 2. unitTypes 배열을 개별 문서로 펼침 ($unwind) + * 3. 정렬 기준에 따라 정렬 ($sort with tie-break rules) + * 4. 단지별로 다시 그룹화 ($group) + * 5. ComplexDocument 형태로 재구성 ($project) + * + * Tie-break 규칙: + * - 1차: 보증금 or 면적 (정렬 타입에 따라) + * - 2차: 지역 (address.county) + * - 3차: 단지명 (name) + * - 4차: 방 이름 (unitTypes.typeCode) + * + * @param noticeId 공고 ID + * @param sortType 정렬 기준 + * @return 정렬된 단지 목록 + */ + @Override + public List findSortedComplexesWithUnitTypes(String noticeId, UnitTypeSortType sortType) { + + log.debug("MongoDB Aggregation 시작 - noticeId: {}, sortType: {}", noticeId, sortType); + + // 정렬 기준 설정 (tie-break 규칙 포함) + Sort sort = buildSortCriteria(sortType); + + // Aggregation Pipeline 구성 + Aggregation aggregation = newAggregation( + // 1. noticeId로 필터링 + match(Criteria.where("noticeId").is(noticeId)), + + // 2. unitTypes 배열을 개별 문서로 펼침 + unwind("unitTypes"), + + // 3. 정렬 적용 (DB 레벨 정렬) + sort(sort), + + // 4. 단지별로 다시 그룹화 + // group("$_id")로 원본 _id로 그룹화하면, 그룹화 키가 결과의 _id가 됨 + // ComplexDocument의 id 필드는 @Field("complexId")로 매핑되므로 complexId도 포함 필요 + group("$_id") + .first("$complexId").as("complexId") + .first("$noticeId").as("noticeId") + .first("$houseSn").as("houseSn") + .first("$name").as("name") + .first("$address").as("address") + .first("$pnu").as("pnu") + .first("$city").as("city") + .first("$county").as("county") + .first("$heating").as("heating") + .first("$totalHouseholds").as("totalHouseholds") + .first("$totalSupplyInNotice").as("totalSupplyInNotice") + .first("$applyStart").as("applyStart") + .first("$applyEnd").as("applyEnd") + .first("$location").as("location") + .push("$unitTypes").as("unitTypes"), + + // 5. ComplexDocument 형태로 매핑을 위한 projection + // ComplexDocument의 id 필드는 @Field("complexId")로 매핑되므로 + // complexId를 그대로 사용 (Spring Data가 자동으로 매핑) + project() + .andInclude( + "complexId", "noticeId", "houseSn", "name", "address", + "pnu", "city", "county", "heating", + "totalHouseholds", "totalSupplyInNotice", + "applyStart", "applyEnd", "location", "unitTypes" + ) + ); + + // 실행 + AggregationResults results = mongoTemplate.aggregate( + aggregation, + "complexes", // collection name + ComplexDocument.class + ); + + List complexes = results.getMappedResults(); + log.debug("MongoDB Aggregation 완료 - 조회된 단지 수: {}", complexes.size()); + + return complexes; + } + + /** + * 정렬 기준 생성 (Tie-break 규칙 포함) + * + * 보증금 낮은 순: + * 1. deposit.total ASC + * 2. address.county ASC (지역) + * 3. name ASC (단지명) + * 4. unitTypes.typeCode ASC (방 이름) + * + * 평수 넓은 순: + * 1. exclusiveAreaM2 DESC + * 2. address.county ASC (지역) + * 3. name ASC (단지명) + * 4. unitTypes.typeCode ASC (방 이름) + */ + private Sort buildSortCriteria(UnitTypeSortType sortType) { + if (sortType == UnitTypeSortType.AREA_DESC) { + // 평수 넓은 순 + return Sort.by( + Sort.Order.desc("unitTypes.exclusiveAreaM2"), // 1차: 면적 큰 순 + Sort.Order.asc("address.county"), // 2차: 지역 + Sort.Order.asc("name"), // 3차: 단지명 + Sort.Order.asc("unitTypes.typeCode") // 4차: 방 이름 + ); + } else { + // 보증금 낮은 순 (기본값) + return Sort.by( + Sort.Order.asc("unitTypes.deposit.total"), // 1차: 보증금 낮은 순 + Sort.Order.asc("address.county"), // 2차: 지역 + Sort.Order.asc("name"), // 3차: 단지명 + Sort.Order.asc("unitTypes.typeCode") // 4차: 방 이름 + ); + } + } +} diff --git a/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/UnitTypeCompareResponse.java b/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/UnitTypeCompareResponse.java index 0b96aca1..a3275824 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/UnitTypeCompareResponse.java +++ b/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/UnitTypeCompareResponse.java @@ -49,7 +49,7 @@ public record UnitTypeComparisonItem( @Schema(description = "비용 정보") CostInfo cost, - @Schema(description = "주변 인프라 태그", example = "[\"공원\", \"도서관\", \"병원\"]") + @Schema(description = "단지 기반 주변 인프라 태그 (Complex에 속한 시설 정보)", example = "[\"공원\", \"도서관\", \"병원\"]") List nearbyFacilities, @Schema(description = "핀포인트 기준 거리", example = "3.5km") diff --git a/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/UnitTypeSortType.java b/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/UnitTypeSortType.java index 85da2ddd..e395fb13 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/UnitTypeSortType.java +++ b/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/UnitTypeSortType.java @@ -9,7 +9,9 @@ @RequiredArgsConstructor public enum UnitTypeSortType { DEPOSIT_ASC("보증금 낮은 순"), - AREA_DESC("평수 넓은 순"); + AREA_DESC("면적 넓은 순"), + FACILITY_MATCH("주변환경 매칭 순"), + DISTANCE_ASC("핀포인트 거리 순"); private final String label; diff --git a/src/main/java/com/pinHouse/server/platform/housing/notice/application/service/NoticeService.java b/src/main/java/com/pinHouse/server/platform/housing/notice/application/service/NoticeService.java index ab48a6bc..b1b2485d 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/notice/application/service/NoticeService.java +++ b/src/main/java/com/pinHouse/server/platform/housing/notice/application/service/NoticeService.java @@ -266,13 +266,36 @@ public int countFilteredComplexes(String noticeId, NoticeDetailFilterRequest req /// 유닛타입(방) 비교 @Override @Transactional(readOnly = true) - public UnitTypeCompareResponse compareUnitTypes(String noticeId, String pinPointId, UnitTypeSortType sortType, UUID userId) { + public UnitTypeCompareResponse compareUnitTypes( + String noticeId, + String pinPointId, + UnitTypeSortType sortType, + List nearbyFacilities, + UUID userId + ) { /// 공고 존재 확인 loadNotice(noticeId); - /// 공고에 속한 모든 단지 조회 - List complexes = complexService.loadComplexes(noticeId); + /// 정렬 기준 설정 (null이면 기본값) + UnitTypeSortType finalSortType = sortType != null ? sortType : UnitTypeSortType.DEPOSIT_ASC; + + /// DISTANCE_ASC 정렬은 pinPointId 필수 + if (finalSortType == UnitTypeSortType.DISTANCE_ASC && (pinPointId == null || pinPointId.isBlank())) { + log.warn("거리순 정렬 요청이지만 pinPointId가 없어 기본 정렬(DEPOSIT_ASC)로 변경"); + finalSortType = UnitTypeSortType.DEPOSIT_ASC; + } + + /// ⭐️ DB 레벨에서 정렬된 단지 및 유닛타입 조회 + /// FACILITY_MATCH, DISTANCE_ASC의 경우 DB 정렬 없이 전체 조회 (애플리케이션 레벨 정렬 예정) + List complexes; + if (finalSortType == UnitTypeSortType.FACILITY_MATCH || finalSortType == UnitTypeSortType.DISTANCE_ASC) { + complexes = complexService.loadComplexes(noticeId); + log.debug("{} 정렬 - 정렬 없이 {} 개 단지 조회", finalSortType, complexes.size()); + } else { + complexes = complexService.loadSortedComplexes(noticeId, finalSortType); + log.debug("DB 정렬 완료 - 총 {} 개 단지 조회", complexes.size()); + } /// PinPoint 위치 조회 (optional) Location userLocation = null; @@ -331,39 +354,138 @@ public UnitTypeCompareResponse compareUnitTypes(String noticeId, String pinPoint }) .collect(Collectors.toList()); - /// 정렬 기준 적용 (null이면 기본값) - UnitTypeSortType finalSortType = sortType != null ? sortType : UnitTypeSortType.DEPOSIT_ASC; - sortUnitTypes(comparisonItems, finalSortType); + /// ⭐️ 애플리케이션 레벨 정렬 수행 + if (finalSortType == UnitTypeSortType.FACILITY_MATCH && nearbyFacilities != null && !nearbyFacilities.isEmpty()) { + // 시설 매칭 기반 정렬 + sortByFacilityMatch(comparisonItems, nearbyFacilities); + log.debug("시설 매칭 정렬 완료 - 매칭 대상 시설: {}", nearbyFacilities); + } else if (finalSortType == UnitTypeSortType.DISTANCE_ASC) { + // 거리 기반 정렬 + sortByDistance(comparisonItems); + log.debug("거리순 정렬 완료"); + } + /// 그 외에는 DB에서 이미 정렬되어 왔으므로 순서 유지 /// DTO 정적 팩토리 메서드로 응답 생성 return UnitTypeCompareResponse.from(comparisonItems); } /** - * 유닛타입 정렬 + * 시설 매칭 기반 정렬 + * + * 정렬 우선순위: + * 1. 시설 매칭 개수 (많은 순) + * 2. 보증금 (낮은 순) + * 3. 지역 (오름차순) + * 4. 단지명 (오름차순) + * 5. 방 이름 (오름차순) */ - private void sortUnitTypes( + private void sortByFacilityMatch( List items, - UnitTypeSortType sortType + List targetFacilities ) { - switch (sortType) { - case DEPOSIT_ASC: - // 보증금 낮은 순 (null은 최대값으로 처리) - items.sort(Comparator.comparing( - item -> item.cost() != null ? item.cost().totalDeposit() : Long.MAX_VALUE - )); - break; - - case AREA_DESC: - // 평수 넓은 순 (null은 최소값으로 처리) - items.sort(Comparator.comparing( - (UnitTypeCompareResponse.UnitTypeComparisonItem item) -> - item.area() != null ? item.area().exclusiveAreaM2() : 0.0 - ).reversed()); - break; + items.sort(Comparator + // 1차: 시설 매칭 개수 (많은 순 = 내림차순) + .comparing((UnitTypeCompareResponse.UnitTypeComparisonItem item) -> { + List itemFacilities = item.nearbyFacilities(); + if (itemFacilities == null || itemFacilities.isEmpty()) { + return 0; + } + // targetFacilities와 itemFacilities의 교집합 개수 계산 + return (int) targetFacilities.stream() + .filter(itemFacilities::contains) + .count(); + }).reversed() + // 2차: 보증금 (낮은 순 = 오름차순) + .thenComparing(item -> + item.cost() != null ? item.cost().totalDeposit() : Long.MAX_VALUE + ) + // 3차: 지역 (오름차순) + .thenComparing(item -> + item.complex() != null && item.complex().address() != null + ? item.complex().address() + : "" + ) + // 4차: 단지명 (오름차순) + .thenComparing(item -> + item.complex() != null && item.complex().name() != null + ? item.complex().name() + : "" + ) + // 5차: 방 이름 (오름차순) + .thenComparing(item -> + item.typeCode() != null ? item.typeCode() : "" + ) + ); + } + + /** + * 거리 기반 정렬 + * + * 정렬 우선순위: + * 1. 핀포인트로부터의 거리 (가까운 순) + * 2. 보증금 (낮은 순) + * 3. 지역 (오름차순) + * 4. 단지명 (오름차순) + * 5. 방 이름 (오름차순) + */ + private void sortByDistance(List items) { + items.sort(Comparator + // 1차: 거리 (가까운 순 = 오름차순) + .comparing((UnitTypeCompareResponse.UnitTypeComparisonItem item) -> { + String distanceStr = item.distanceFromPinPoint(); + if (distanceStr == null || distanceStr.isBlank()) { + return Double.MAX_VALUE; + } + // "3.5km" 또는 "500m" 형식을 km 단위 double로 변환 + return parseDistanceToKm(distanceStr); + }) + // 2차: 보증금 (낮은 순) + .thenComparing(item -> + item.cost() != null ? item.cost().totalDeposit() : Long.MAX_VALUE + ) + // 3차: 지역 (오름차순) + .thenComparing(item -> + item.complex() != null && item.complex().address() != null + ? item.complex().address() + : "" + ) + // 4차: 단지명 (오름차순) + .thenComparing(item -> + item.complex() != null && item.complex().name() != null + ? item.complex().name() + : "" + ) + // 5차: 방 이름 (오름차순) + .thenComparing(item -> + item.typeCode() != null ? item.typeCode() : "" + ) + ); + } + + /** + * 거리 문자열을 km 단위 double로 변환 + * @param distanceStr "3.5km" 또는 "500m" 형식 + * @return km 단위 거리 + */ + private double parseDistanceToKm(String distanceStr) { + try { + if (distanceStr.endsWith("km")) { + // "3.5km" → 3.5 + return Double.parseDouble(distanceStr.replace("km", "")); + } else if (distanceStr.endsWith("m")) { + // "500m" → 0.5 + double meters = Double.parseDouble(distanceStr.replace("m", "")); + return meters / 1000.0; + } + return Double.MAX_VALUE; + } catch (NumberFormatException e) { + log.warn("거리 문자열 파싱 실패: {}", distanceStr); + return Double.MAX_VALUE; } } + /** * 두 지점 간 거리 계산 (Haversine formula) * @return 거리 (km) diff --git a/src/main/java/com/pinHouse/server/platform/housing/notice/application/usecase/NoticeUseCase.java b/src/main/java/com/pinHouse/server/platform/housing/notice/application/usecase/NoticeUseCase.java index 65e5238f..62b00bda 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/notice/application/usecase/NoticeUseCase.java +++ b/src/main/java/com/pinHouse/server/platform/housing/notice/application/usecase/NoticeUseCase.java @@ -46,7 +46,13 @@ public interface NoticeUseCase { int countFilteredComplexes(String noticeId, NoticeDetailFilterRequest request); /// 유닛타입(방) 비교 - UnitTypeCompareResponse compareUnitTypes(String noticeId, String pinPointId, UnitTypeSortType sortType, UUID userId); + UnitTypeCompareResponse compareUnitTypes( + String noticeId, + String pinPointId, + UnitTypeSortType sortType, + java.util.List nearbyFacilities, + UUID userId + ); /// 나의 좋아요 공고 목록 조회 List getNoticesLike(UUID userId); diff --git a/src/main/java/com/pinHouse/server/platform/housing/notice/presentation/NoticeApi.java b/src/main/java/com/pinHouse/server/platform/housing/notice/presentation/NoticeApi.java index a00c634a..12bbd73d 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/notice/presentation/NoticeApi.java +++ b/src/main/java/com/pinHouse/server/platform/housing/notice/presentation/NoticeApi.java @@ -142,13 +142,14 @@ public ApiResponse compareUnitTypes( @PathVariable String noticeId, @RequestParam(required = false) String pinPointId, @RequestParam(required = false, defaultValue = "DEPOSIT_ASC") UnitTypeSortType sortType, + @RequestParam(required = false) List nearbyFacilities, @AuthenticationPrincipal PrincipalDetails principalDetails) { /// 로그인하지 않은 경우 userId는 null var userId = (principalDetails != null) ? principalDetails.getId() : null; /// 서비스 계층 - var response = service.compareUnitTypes(noticeId, pinPointId, sortType, userId); + var response = service.compareUnitTypes(noticeId, pinPointId, sortType, nearbyFacilities, userId); /// 리턴 return ApiResponse.ok(response); diff --git a/src/main/java/com/pinHouse/server/platform/housing/notice/presentation/swagger/NoticeApiSpec.java b/src/main/java/com/pinHouse/server/platform/housing/notice/presentation/swagger/NoticeApiSpec.java index a8872262..2c619dfb 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/notice/presentation/swagger/NoticeApiSpec.java +++ b/src/main/java/com/pinHouse/server/platform/housing/notice/presentation/swagger/NoticeApiSpec.java @@ -112,7 +112,7 @@ ApiResponse countFilteredComplexes( @Operation( summary = "공고의 모든 유닛타입(방) 비교 API", description = "공고에 포함된 모든 유닛타입의 상세 특징을 조회하고 정렬합니다. " + - "보증금 낮은 순(DEPOSIT_ASC) 또는 평수 넓은 순(AREA_DESC)으로 정렬 가능합니다. " + + "보증금 낮은 순(DEPOSIT_ASC), 평수 넓은 순(AREA_DESC), 인프라 매칭 순(FACILITY_MATCH), 거리 가까운 순(DISTANCE_ASC)으로 정렬 가능합니다. " + "면적, 비용, 공급호수, 단지 정보, 주변 인프라, 핀포인트 기준 거리, 좋아요 여부 등 모든 특징을 반환합니다. " + "로그인한 사용자의 경우 각 유닛타입의 좋아요 여부가 포함됩니다." ) @@ -120,13 +120,17 @@ ApiResponse compareUnitTypes( @Parameter(description = "공고 ID", example = "18214") @PathVariable String noticeId, - @Parameter(description = "핀포인트 ID", example = "fec9aba3-0fd9-4b75-bebf-9cb7641fd251") + @Parameter(description = "핀포인트 ID (DISTANCE_ASC 정렬 시 필수)", example = "fec9aba3-0fd9-4b75-bebf-9cb7641fd251") @RequestParam(required = false) String pinPointId, - @Parameter(description = "정렬 기준 (DEPOSIT_ASC: 보증금 낮은 순, AREA_DESC: 평수 넓은 순)", + @Parameter(description = "정렬 기준 (DEPOSIT_ASC: 보증금 낮은 순, AREA_DESC: 평수 넓은 순, FACILITY_MATCH: 인프라 매칭 순, DISTANCE_ASC: 거리 가까운 순)", example = "DEPOSIT_ASC") @RequestParam(required = false, defaultValue = "보증금 낮은 순") UnitTypeSortType sortType, + @Parameter(description = "필터링할 인프라 시설 목록 (FACILITY_MATCH 정렬 시 사용)", + example = "PARK,LIBRARY,HOSPITAL") + @RequestParam(required = false) List nearbyFacilities, + @AuthenticationPrincipal PrincipalDetails principalDetails ); diff --git a/src/main/java/com/pinHouse/server/platform/pinPoint/application/dto/PinPointListResponse.java b/src/main/java/com/pinHouse/server/platform/pinPoint/application/dto/PinPointListResponse.java new file mode 100644 index 00000000..d1853beb --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/pinPoint/application/dto/PinPointListResponse.java @@ -0,0 +1,29 @@ +package com.pinHouse.server.platform.pinPoint.application.dto; + +import com.pinHouse.server.platform.pinPoint.domain.entity.PinPoint; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import java.util.List; + +@Schema(name = "[응답][핀포인트] 핀포인트 목록 응답", description = "핀포인트 목록 조회를 위한 DTO입니다.") +@Builder +public record PinPointListResponse( + + @Schema(description = "유저 이름", example = "홍길동") + String userName, + + @Schema(description = "핀포인트 목록") + List pinPoints + +) { + + /// 정적 팩토리 메서드 + public static PinPointListResponse of(String userName, List pinPoints) { + + return PinPointListResponse.builder() + .userName(userName) + .pinPoints(PinPointResponse.from(pinPoints)) + .build(); + } + +} diff --git a/src/main/java/com/pinHouse/server/platform/pinPoint/application/service/PinPointService.java b/src/main/java/com/pinHouse/server/platform/pinPoint/application/service/PinPointService.java index 15f4fe85..2a502ddd 100644 --- a/src/main/java/com/pinHouse/server/platform/pinPoint/application/service/PinPointService.java +++ b/src/main/java/com/pinHouse/server/platform/pinPoint/application/service/PinPointService.java @@ -3,8 +3,8 @@ import com.pinHouse.server.core.exception.code.PinPointErrorCode; import com.pinHouse.server.core.response.response.CustomException; import com.pinHouse.server.platform.Location; +import com.pinHouse.server.platform.pinPoint.application.dto.PinPointListResponse; import com.pinHouse.server.platform.pinPoint.application.dto.PinPointRequest; -import com.pinHouse.server.platform.pinPoint.application.dto.PinPointResponse; import com.pinHouse.server.platform.pinPoint.application.dto.UpdatePinPointRequest; import com.pinHouse.server.platform.pinPoint.util.LocationUtil; import com.pinHouse.server.platform.pinPoint.application.usecase.PinPointUseCase; @@ -65,7 +65,7 @@ public void savePinPoint(UUID userId, PinPointRequest request) { /// 목록 조회 @Override @Transactional(readOnly = true) - public List loadPinPoints(UUID userId) { + public PinPointListResponse loadPinPoints(UUID userId) { /// 유저 검증 User user = userService.loadUser(userId); @@ -73,8 +73,8 @@ public List loadPinPoints(UUID userId) { /// 유저가 존재하는 핀포인트 목록을 first 기준으로 정렬하여 조회 List pinPoints = repository.findByUserIdOrderByIsFirstDesc(user.getId().toString()); - /// Stream 돌면서 DTO 변경 - return PinPointResponse.from(pinPoints); + /// 유저 이름과 핀포인트 목록을 포함한 응답 반환 + return PinPointListResponse.of(user.getName(), pinPoints); } @Override diff --git a/src/main/java/com/pinHouse/server/platform/pinPoint/application/usecase/PinPointUseCase.java b/src/main/java/com/pinHouse/server/platform/pinPoint/application/usecase/PinPointUseCase.java index ca77ffa7..cd256791 100644 --- a/src/main/java/com/pinHouse/server/platform/pinPoint/application/usecase/PinPointUseCase.java +++ b/src/main/java/com/pinHouse/server/platform/pinPoint/application/usecase/PinPointUseCase.java @@ -1,11 +1,10 @@ package com.pinHouse.server.platform.pinPoint.application.usecase; +import com.pinHouse.server.platform.pinPoint.application.dto.PinPointListResponse; import com.pinHouse.server.platform.pinPoint.application.dto.PinPointRequest; -import com.pinHouse.server.platform.pinPoint.application.dto.PinPointResponse; import com.pinHouse.server.platform.pinPoint.application.dto.UpdatePinPointRequest; import com.pinHouse.server.platform.pinPoint.domain.entity.PinPoint; -import java.util.List; import java.util.UUID; public interface PinPointUseCase { @@ -18,7 +17,7 @@ public interface PinPointUseCase { void savePinPoint(UUID userId, PinPointRequest request); /// 목록 조회 - List loadPinPoints(UUID userId); + PinPointListResponse loadPinPoints(UUID userId); /// 수정 void update(String id, UUID userId, UpdatePinPointRequest request); diff --git a/src/main/java/com/pinHouse/server/platform/pinPoint/presentation/PinPointApi.java b/src/main/java/com/pinHouse/server/platform/pinPoint/presentation/PinPointApi.java index 8313b2b2..4673f135 100644 --- a/src/main/java/com/pinHouse/server/platform/pinPoint/presentation/PinPointApi.java +++ b/src/main/java/com/pinHouse/server/platform/pinPoint/presentation/PinPointApi.java @@ -2,8 +2,8 @@ import com.pinHouse.server.core.aop.CheckLogin; import com.pinHouse.server.core.response.response.ApiResponse; +import com.pinHouse.server.platform.pinPoint.application.dto.PinPointListResponse; import com.pinHouse.server.platform.pinPoint.application.dto.PinPointRequest; -import com.pinHouse.server.platform.pinPoint.application.dto.PinPointResponse; import com.pinHouse.server.platform.pinPoint.application.dto.UpdatePinPointRequest; import com.pinHouse.server.platform.pinPoint.application.usecase.PinPointUseCase; import com.pinHouse.server.platform.pinPoint.presentation.swagger.PinPointApiSpec; @@ -12,7 +12,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.List; @RestController @RequestMapping("/v1/pinpoints") @@ -54,15 +53,15 @@ public ApiResponse updatePinPoint( /// 나의 핀포인트 목록 조회하기 @CheckLogin @GetMapping() - public ApiResponse> getPinPoints( + public ApiResponse getPinPoints( @AuthenticationPrincipal PrincipalDetails principalDetails ) { /// 서비스 - var responses = service.loadPinPoints(principalDetails.getId()); + var response = service.loadPinPoints(principalDetails.getId()); /// 리턴 - return ApiResponse.ok(responses); + return ApiResponse.ok(response); } /// 핀포인트 제거하기 diff --git a/src/main/java/com/pinHouse/server/platform/pinPoint/presentation/swagger/PinPointApiSpec.java b/src/main/java/com/pinHouse/server/platform/pinPoint/presentation/swagger/PinPointApiSpec.java index 34d55ff5..d0cac034 100644 --- a/src/main/java/com/pinHouse/server/platform/pinPoint/presentation/swagger/PinPointApiSpec.java +++ b/src/main/java/com/pinHouse/server/platform/pinPoint/presentation/swagger/PinPointApiSpec.java @@ -1,8 +1,8 @@ package com.pinHouse.server.platform.pinPoint.presentation.swagger; import com.pinHouse.server.core.response.response.ApiResponse; +import com.pinHouse.server.platform.pinPoint.application.dto.PinPointListResponse; import com.pinHouse.server.platform.pinPoint.application.dto.PinPointRequest; -import com.pinHouse.server.platform.pinPoint.application.dto.PinPointResponse; import com.pinHouse.server.platform.pinPoint.application.dto.UpdatePinPointRequest; import com.pinHouse.server.security.oauth2.domain.PrincipalDetails; import io.swagger.v3.oas.annotations.Operation; @@ -16,8 +16,6 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; -import java.util.List; - @Tag(name = "핀포인트 API", description = "핀포인트 생성/조회/삭제 API 입니다.") public interface PinPointApiSpec { @@ -43,7 +41,7 @@ ApiResponse addPinPoint( summary = "핀포인트 목록조회 API", description = "나의 핀포인트 목록 들을 조회하는 API 입니다." ) - ApiResponse> getPinPoints( + ApiResponse getPinPoints( @AuthenticationPrincipal PrincipalDetails principalDetails );