diff --git a/src/main/java/com/pinHouse/server/core/response/response/pageable/SliceRequest.java b/src/main/java/com/pinHouse/server/core/response/response/pageable/SliceRequest.java index 716ef8f..ad70d27 100644 --- a/src/main/java/com/pinHouse/server/core/response/response/pageable/SliceRequest.java +++ b/src/main/java/com/pinHouse/server/core/response/response/pageable/SliceRequest.java @@ -1,14 +1,13 @@ package com.pinHouse.server.core.response.response.pageable; import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; -@Tag(name = "무한스크롤 요청 DTO") +@Schema(name = "[요청][페이징] 무한 스크롤 페이징", description = "무한 스크롤 방식의 페이징 처리를 위한 요청 DTO") public record SliceRequest( - @Schema(description = "페이지 시작", example = "1") + @Schema(description = "페이지 번호 (0부터 시작)", example = "0", defaultValue = "1") int page, - @Schema(description = "조회할 개수", example = "10") + @Schema(description = "한 페이지에 조회할 데이터 개수", example = "10", defaultValue = "10") int offSet) { } diff --git a/src/main/java/com/pinHouse/server/core/util/DistanceCalculator.java b/src/main/java/com/pinHouse/server/core/util/DistanceCalculator.java new file mode 100644 index 0000000..fa680c9 --- /dev/null +++ b/src/main/java/com/pinHouse/server/core/util/DistanceCalculator.java @@ -0,0 +1,57 @@ +package com.pinHouse.server.core.util; + +import com.pinHouse.server.platform.Location; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * 거리 계산 유틸리티 클래스 + * Haversine 공식을 사용하여 두 지점 간의 거리를 계산합니다. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class DistanceCalculator { + + /** 지구 반지름 (km) */ + private static final double EARTH_RADIUS_KM = 6371.0; + + /** + * 두 지점 간 거리(km) 계산 (Haversine 공식) + * + * @param location1 첫 번째 위치 (위도, 경도) + * @param location2 두 번째 위치 (위도, 경도) + * @return 두 지점 간의 거리 (km) + */ + public static double calculateDistanceKm(Location location1, Location location2) { + if (location1 == null || location2 == null) { + throw new IllegalArgumentException("Location cannot be null"); + } + + double lat1 = location1.getLatitude(); + double lon1 = location1.getLongitude(); + double lat2 = location2.getLatitude(); + double lon2 = location2.getLongitude(); + + return calculateDistanceKm(lat1, lon1, lat2, lon2); + } + + /** + * 두 지점 간 거리(km) 계산 (Haversine 공식) + * + * @param lat1 첫 번째 지점의 위도 + * @param lon1 첫 번째 지점의 경도 + * @param lat2 두 번째 지점의 위도 + * @param lon2 두 번째 지점의 경도 + * @return 두 지점 간의 거리 (km) + */ + public static double calculateDistanceKm(double lat1, double lon1, double lat2, double lon2) { + double dLat = Math.toRadians(lat2 - lat1); + double dLon = Math.toRadians(lon2 - lon1); + double radLat1 = Math.toRadians(lat1); + double radLat2 = Math.toRadians(lat2); + + double h = Math.pow(Math.sin(dLat / 2), 2) + + Math.pow(Math.sin(dLon / 2), 2) * Math.cos(radLat1) * Math.cos(radLat2); + + return 2 * EARTH_RADIUS_KM * Math.asin(Math.sqrt(h)); + } +} diff --git a/src/main/java/com/pinHouse/server/core/util/TimeFormatter.java b/src/main/java/com/pinHouse/server/core/util/TimeFormatter.java new file mode 100644 index 0000000..1406eed --- /dev/null +++ b/src/main/java/com/pinHouse/server/core/util/TimeFormatter.java @@ -0,0 +1,56 @@ +package com.pinHouse.server.core.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * 시간 포맷팅 유틸리티 클래스 + * 분(minutes) 단위 시간을 "X시간 Y분" 형식의 문자열로 변환합니다. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class TimeFormatter { + + /** + * 시간을 "X시간 Y분" 형식으로 포맷팅 + * + * @param totalMinutes 총 시간(분) + * @return 포맷팅된 시간 문자열 (예: "1시간 30분", "45분") + * - 0 이하면 "0분" 반환 + * - 60분 미만이면 "X분" 형식 + * - 60분 이상이면 "X시간" 또는 "X시간 Y분" 형식 + */ + public static String formatTime(int totalMinutes) { + if (totalMinutes <= 0) { + return "0분"; + } + + if (totalMinutes < 60) { + return totalMinutes + "분"; + } + + int hours = totalMinutes / 60; + int minutes = totalMinutes % 60; + + if (minutes == 0) { + return hours + "시간"; + } + + return hours + "시간 " + minutes + "분"; + } + + /** + * 시간을 "X시간 Y분" 형식으로 포맷팅 (null 반환 버전) + * + * @param totalMinutes 총 시간(분) + * @return 포맷팅된 시간 문자열 (예: "1시간 30분", "45분") + * - 0 이하면 null 반환 + * - 나머지는 formatTime()과 동일 + */ + public static String formatTimeOrNull(int totalMinutes) { + if (totalMinutes <= 0) { + return null; + } + + return formatTime(totalMinutes); + } +} diff --git a/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/dto/DiagnosisDetailResponse.java b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/dto/DiagnosisDetailResponse.java new file mode 100644 index 0000000..ac3a97d --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/dto/DiagnosisDetailResponse.java @@ -0,0 +1,190 @@ +package com.pinHouse.server.platform.diagnostic.diagnosis.application.dto; + +import com.pinHouse.server.platform.diagnostic.diagnosis.domain.entity.Diagnosis; +import com.pinHouse.server.platform.diagnostic.rule.domain.entity.EvaluationContext; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 진단 결과 상세 조회 응답 DTO + * - 진단에 입력된 정보 + * - 최종 진단 결과 + */ +@Schema(name = "[응답][진단] 청약 진단 상세 결과", description = "청약 진단 입력 정보와 최종 결과를 함께 응답하는 DTO") +@Builder +public record DiagnosisDetailResponse( + + // === 진단 메타 정보 === + @Schema(description = "진단 ID", example = "101") + Long diagnosisId, + + @Schema(description = "진단 일시", example = "2025-12-28T10:30:00") + LocalDateTime diagnosedAt, + + // === 1. 성별/나이 === + @Schema(description = "성별", example = "여자") + String gender, + + @Schema(description = "나이 (만 나이)", example = "28") + int age, + + // === 2. 소득 === + @Schema(description = "가구 소득 수준", example = "소득 1분위") + String incomeLevel, + + @Schema(description = "월 소득 (원)", example = "2000000") + int monthPay, + + // === 3. 대학생 여부 === + @Schema(description = "대학생 여부", example = "false") + boolean isCollegeStudent, + + // === 4. 청약 저축 === + @Schema(description = "청약통장 보유 여부", example = "true") + boolean hasSubscription, + + @Schema(description = "청약통장 가입 기간", example = "2년 이상") + String subscriptionYears, + + @Schema(description = "청약통장 납입 횟수", example = "24회 이상") + String subscriptionCount, + + @Schema(description = "청약통장 예치금", example = "600만원 이하") + String subscriptionAmount, + + // === 5. 결혼 여부 === + @Schema(description = "결혼 여부", example = "true") + boolean isMarried, + + @Schema(description = "결혼 기간 (년)", example = "5") + Integer marriedYears, + + // === 6. 자녀 여부 === + @Schema(description = "태아 수", example = "0") + int unbornChildren, + + @Schema(description = "6세 이하 자녀 수", example = "1") + int under6Children, + + @Schema(description = "7세 이상 미성년 자녀 수", example = "1") + int over7MinorChildren, + + @Schema(description = "총 자녀 수", example = "2") + int totalChildren, + + // === 7. 세대 정보 === + @Schema(description = "세대주 여부", example = "true") + boolean isHouseholdHead, + + @Schema(description = "1인 가구 여부", example = "false") + boolean isSingleHousehold, + + @Schema(description = "전체 세대원 수", example = "4") + int householdSize, + + // === 8. 주택 소유 여부 === + @Schema(description = "주택 소유 상태", example = "우리집 가구원 모두 주택을 소유하고 있지 않아요") + String housingStatus, + + @Schema(description = "무주택 기간 (년)", example = "5") + int housingYears, + + // === 9. 자동차 소유 여부 === + @Schema(description = "자동차 보유 여부", example = "false") + boolean ownsCar, + + @Schema(description = "자동차 가격 (원)", example = "0") + long carValue, + + // === 10. 총 자산 === + @Schema(description = "부동산/토지 자산 (원)", example = "0") + long propertyAsset, + + @Schema(description = "자동차 자산 (원)", example = "0") + long carAsset, + + @Schema(description = "금융 자산 (원)", example = "50000000") + long financialAsset, + + @Schema(description = "총 자산 (원)", example = "50000000") + long totalAssets, + + // === 최종 진단 결과 === + @Schema(description = "최종 자격 여부", example = "true") + boolean eligible, + + @Schema(description = "진단 결과 메시지", example = "추천 임대주택이 있습니다") + String diagnosisResult, + + @Schema(description = "추천 임대주택 후보 리스트", example = "[\"공공임대 : 장기\", \"민간임대 : 단기\"]") + List recommended +) { + + /// 정적 팩토리 메서드 + public static DiagnosisDetailResponse from(EvaluationContext context) { + + Diagnosis diagnosis = context.getDiagnosis(); + + // 추천 후보 계산 + List recommended = context.getCurrentCandidates().isEmpty() ? + List.of("해당 없음") : + context.getCurrentCandidates().stream() + .map((c -> c.noticeType().getValue() + " : " + c.supplyType().getValue())) + .toList(); + + // 총 자녀 수 계산 + int totalChildren = diagnosis.getUnbornChildrenCount() + + diagnosis.getUnder6ChildrenCount() + + diagnosis.getOver7MinorChildrenCount(); + + return DiagnosisDetailResponse.builder() + // 메타 정보 + .diagnosisId(diagnosis.getId()) + .diagnosedAt(diagnosis.getCreatedAt()) + // 1. 성별/나이 + .gender(diagnosis.getGender() != null ? diagnosis.getGender().getValue() : "미입력") + .age(diagnosis.getAge()) + // 2. 소득 + .incomeLevel(diagnosis.getIncomeLevel() != null ? diagnosis.getIncomeLevel().getValue() : "미입력") + .monthPay(diagnosis.getMonthPay()) + // 3. 대학생 여부 + .isCollegeStudent(diagnosis.getEducationStatus() != null) + // 4. 청약 저축 + .hasSubscription(diagnosis.isHasAccount()) + .subscriptionYears(diagnosis.getAccountYears() != null ? diagnosis.getAccountYears().getDescription() : "미입력") + .subscriptionCount(diagnosis.getAccountDeposit() != null ? diagnosis.getAccountDeposit().getDescription() : "미입력") + .subscriptionAmount(diagnosis.getAccount() != null ? diagnosis.getAccount().getValue() : "미입력") + // 5. 결혼 여부 + .isMarried(diagnosis.isMaritalStatus()) + .marriedYears(diagnosis.getMarriageYears()) + // 6. 자녀 여부 + .unbornChildren(diagnosis.getUnbornChildrenCount()) + .under6Children(diagnosis.getUnder6ChildrenCount()) + .over7MinorChildren(diagnosis.getOver7MinorChildrenCount()) + .totalChildren(totalChildren) + // 7. 세대 정보 + .isHouseholdHead(diagnosis.isHouseholdHead()) + .isSingleHousehold(diagnosis.isSingle()) + .householdSize(diagnosis.getFamilyCount()) + // 8. 주택 소유 여부 + .housingStatus(diagnosis.getHousingStatus() != null ? diagnosis.getHousingStatus().getDescription() : "미입력") + .housingYears(diagnosis.getHousingYears()) + // 9. 자동차 소유 여부 + .ownsCar(diagnosis.isHasCar()) + .carValue(diagnosis.getCarValue()) + // 10. 총 자산 + .propertyAsset(diagnosis.getPropertyAsset()) + .carAsset(diagnosis.getCarAsset()) + .financialAsset(diagnosis.getFinancialAsset()) + .totalAssets(diagnosis.getTotalAsset()) + // 최종 진단 결과 + .eligible(!recommended.contains("해당 없음")) + .diagnosisResult(!recommended.contains("해당 없음") ? + "추천 임대주택이 있습니다" : "모든 조건 미충족") + .recommended(recommended) + .build(); + } +} diff --git a/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/dto/DiagnosisResponse.java b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/dto/DiagnosisResponse.java index 7a4a470..c99faee 100644 --- a/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/dto/DiagnosisResponse.java +++ b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/dto/DiagnosisResponse.java @@ -1,130 +1,35 @@ package com.pinHouse.server.platform.diagnostic.diagnosis.application.dto; -import com.pinHouse.server.platform.diagnostic.diagnosis.domain.entity.Diagnosis; -import com.pinHouse.server.platform.diagnostic.rule.application.dto.RuleResult; import com.pinHouse.server.platform.diagnostic.rule.domain.entity.EvaluationContext; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; -import java.time.LocalDateTime; import java.util.List; -import java.util.UUID; /** * 최종 진단 응답 DTO - * - 어떤 Rule들을 통과/실패했는지 * - 최종 적합 여부 * - 요약 메시지 - * - 진단에 입력된 상세 정보 + * - 추천 후보 리스트 */ -@Schema(name = "[응답][진단] 청약 진단 응답", description = "진단 최종 결과를 응답하는 DTO입니다.") +@Schema(name = "[응답][진단] 청약 진단 결과", description = "청약 진단 최종 결과만 응답하는 DTO") @Builder public record DiagnosisResponse( - @Schema(description = "진단 기록 ID", example = "101") - Long id, - - @Schema(description = "유저 ID", example = "1") - UUID userId, - - @Schema(description = "진단 일시", example = "2025-12-01T10:30:00") - LocalDateTime createdAt, - - @Schema(description = "최종 자격 여부", example = "true") + @Schema(description = "최종 자격 여부 (추천 임대주택이 있는지 여부)", example = "true") boolean eligible, - @Schema(description = "최종 요약 메시지 (예: \"국민임대주택 가능\", \"부적합\" 등)", example = "추천 임대주택이 있습니다") + @Schema(description = "최종 요약 메시지", example = "추천 임대주택이 있습니다") String decisionMessage, - @Schema(description = "추천 후보 리스트", example = "[\"공공임대 : 장기\", \"민간임대 : 단기\"]") - List recommended, - - // === 진단 입력 정보 === - @Schema(description = "나이", example = "38") - int age, - - @Schema(description = "성별", example = "남성") - String gender, - - @Schema(description = "월 소득", example = "0") - int monthPay, - - @Schema(description = "청약통장 보유 여부", example = "true") - boolean hasAccount, - - @Schema(description = "청약통장 가입 기간", example = "2년 이상") - String accountYears, - - @Schema(description = "결혼 여부", example = "true") - boolean maritalStatus, - - @Schema(description = "결혼 기간(년)", example = "10") - Integer marriageYears, - - @Schema(description = "태아 수", example = "1") - int unbornChildrenCount, - - @Schema(description = "6세 이하 자녀 수", example = "1") - int under6ChildrenCount, - - @Schema(description = "7세 이상 미성년 자녀 수", example = "1") - int over7MinorChildrenCount, - - @Schema(description = "자동차 보유 여부", example = "false") - boolean hasCar, - - @Schema(description = "자동차 가격", example = "0") - long carValue, - - @Schema(description = "세대주 여부", example = "true") - boolean isHouseholdHead, - - @Schema(description = "1인 가구 여부", example = "false") - boolean isSingle, - - @Schema(description = "성인 가구 수", example = "2") - int adultCount, - - @Schema(description = "미성년자 가구 수", example = "2") - int minorCount, - - @Schema(description = "태아 가구 수", example = "1") - int fetusCount, - - @Schema(description = "가구 소득 수준", example = "4구간") - String incomeLevel, - - @Schema(description = "주택 소유 상태", example = "우리집 가구원 모두 주택을 소유하고 있지 않아요") - String housingStatus, - - @Schema(description = "무주택 기간(년)", example = "0") - int housingYears, - - @Schema(description = "부동산/토지 자산", example = "0") - long propertyAsset, - - @Schema(description = "자동차 자산", example = "0") - long carAsset, - - @Schema(description = "금융 자산", example = "0") - long financialAsset, - - @Schema(description = "총 자산", example = "0") - long totalAsset + @Schema(description = "추천 임대주택 후보 리스트", example = "[\"공공임대 : 장기\", \"민간임대 : 단기\"]") + List recommended ) { /// 정적 팩토리 메서드 public static DiagnosisResponse from(EvaluationContext context) { - Diagnosis diagnosis = context.getDiagnosis(); - - // 실패 이유만 추출 - List failureReasons = context.getRuleResults().stream() - .filter(r -> !r.pass()) - .map(RuleResult::message) - .toList(); - // 추천 후보 (후보군이 남아있으면 추천, 없으면 "해당 없음") List recommended = context.getCurrentCandidates().isEmpty() ? List.of("해당 없음") : @@ -133,38 +38,10 @@ public static DiagnosisResponse from(EvaluationContext context) { .toList(); return DiagnosisResponse.builder() - .id(diagnosis.getId()) - .userId(diagnosis.getUser().getId()) - .createdAt(diagnosis.getCreatedAt()) .eligible(!recommended.contains("해당 없음")) .decisionMessage(!recommended.contains("해당 없음") ? "추천 임대주택이 있습니다" : "모든 조건 미충족") .recommended(recommended) - // 진단 입력 정보 - .age(diagnosis.getAge()) - .gender(diagnosis.getGender() != null ? diagnosis.getGender().getValue() : "미입력") - .monthPay(diagnosis.getMonthPay()) - .hasAccount(diagnosis.isHasAccount()) - .accountYears(diagnosis.getAccountYears() != null ? diagnosis.getAccountYears().getDescription() : "미입력") - .maritalStatus(diagnosis.isMaritalStatus()) - .marriageYears(diagnosis.getMarriageYears()) - .unbornChildrenCount(diagnosis.getUnbornChildrenCount()) - .under6ChildrenCount(diagnosis.getUnder6ChildrenCount()) - .over7MinorChildrenCount(diagnosis.getOver7MinorChildrenCount()) - .hasCar(diagnosis.isHasCar()) - .carValue(diagnosis.getCarValue()) - .isHouseholdHead(diagnosis.isHouseholdHead()) - .isSingle(diagnosis.isSingle()) - .adultCount(diagnosis.getAdultCount()) - .minorCount(diagnosis.getMinorCount()) - .fetusCount(diagnosis.getFetusCount()) - .incomeLevel(diagnosis.getIncomeLevel() != null ? diagnosis.getIncomeLevel().getValue() : "미입력") - .housingStatus(diagnosis.getHousingStatus() != null ? diagnosis.getHousingStatus().getDescription() : "미입력") - .housingYears(diagnosis.getHousingYears()) - .propertyAsset(diagnosis.getPropertyAsset()) - .carAsset(diagnosis.getCarAsset()) - .financialAsset(diagnosis.getFinancialAsset()) - .totalAsset(diagnosis.getTotalAsset()) .build(); } } diff --git a/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/service/DiagnosisService.java b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/service/DiagnosisService.java index 8faa2a9..ac1b782 100644 --- a/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/service/DiagnosisService.java +++ b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/service/DiagnosisService.java @@ -1,6 +1,6 @@ package com.pinHouse.server.platform.diagnostic.diagnosis.application.service; -import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisHistoryResponse; +import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisDetailResponse; import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisRequest; import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisResponse; import com.pinHouse.server.platform.diagnostic.diagnosis.application.usecase.DiagnosisUseCase; @@ -14,7 +14,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; import java.util.UUID; /** @@ -60,13 +59,13 @@ public DiagnosisResponse diagnose(UUID userId, DiagnosisRequest request) { } /** - * 나의 최근 청약진단 1개 가져오기 + * 나의 최근 청약진단 상세 조회 (입력 정보 + 결과) * @param userId 유저ID - * @return 청약진단 DTO + * @return 청약진단 상세 DTO */ @Override @Transactional(readOnly = true) - public DiagnosisResponse getDiagnose(UUID userId) { + public DiagnosisDetailResponse getDiagnoseDetail(UUID userId) { /// 유저 예외 처리 User user = userService.loadUser(userId); @@ -84,26 +83,7 @@ public DiagnosisResponse getDiagnose(UUID userId) { EvaluationContext context = ruleChain.evaluateAll(diagnosis); /// DTO 생성 - return DiagnosisResponse.from(context); - } - - /** - * 나의 진단 히스토리 목록 조회하기 - * @param userId 유저ID - * @return 진단 히스토리 목록 - */ - @Override - @Transactional(readOnly = true) - public List getDiagnosisHistory(UUID userId) { - - /// 유저 예외 처리 - User user = userService.loadUser(userId); - - /// DB에서 모든 진단 히스토리 조회 (최신순) - List diagnoses = repository.findAllByUserOrderByCreatedAtDesc(user); - - /// DTO 변환 - return DiagnosisHistoryResponse.fromList(diagnoses); + return DiagnosisDetailResponse.from(context); } } diff --git a/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/usecase/DiagnosisUseCase.java b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/usecase/DiagnosisUseCase.java index 41694bb..8dd998c 100644 --- a/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/usecase/DiagnosisUseCase.java +++ b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/application/usecase/DiagnosisUseCase.java @@ -1,21 +1,17 @@ package com.pinHouse.server.platform.diagnostic.diagnosis.application.usecase; -import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisHistoryResponse; +import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisDetailResponse; import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisRequest; import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisResponse; -import java.util.List; import java.util.UUID; public interface DiagnosisUseCase { - /// 청약 진단하기 + /// 청약 진단하기 (결과만 반환) DiagnosisResponse diagnose(UUID userId, DiagnosisRequest request); - /// 나의 진단 목록 조회하기 - DiagnosisResponse getDiagnose(UUID userId); - - /// 나의 진단 히스토리 목록 조회하기 - List getDiagnosisHistory(UUID userId); + /// 최근 진단 상세 조회하기 (입력 정보 + 결과) + DiagnosisDetailResponse getDiagnoseDetail(UUID userId); } diff --git a/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/presentation/DiagnosisApi.java b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/presentation/DiagnosisApi.java index 087b256..7b589ff 100644 --- a/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/presentation/DiagnosisApi.java +++ b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/presentation/DiagnosisApi.java @@ -2,7 +2,7 @@ import com.pinHouse.server.core.aop.CheckLogin; import com.pinHouse.server.core.response.response.ApiResponse; -import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisHistoryResponse; +import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisDetailResponse; import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisResponse; import com.pinHouse.server.platform.diagnostic.diagnosis.application.usecase.DiagnosisUseCase; import com.pinHouse.server.platform.diagnostic.diagnosis.presentation.swagger.DiagnosisApiSpec; @@ -12,8 +12,6 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.List; - @RestController @RequestMapping("/v1/diagnosis") @RequiredArgsConstructor @@ -39,34 +37,17 @@ public ApiResponse diagnosis(@AuthenticationPrincipal Princip } /** - * 최근 진단 결과 1개 조회 + * 최근 진단 결과 상세 조회 (입력 정보 + 결과) * * @param principalDetails 로그인한 유저 - * @return 최근 진단 결과 + * @return 최근 진단 상세 결과 */ @GetMapping("/latest") @CheckLogin - public ApiResponse getLatestDiagnosis(@AuthenticationPrincipal PrincipalDetails principalDetails) { - - /// 서비스 - DiagnosisResponse response = service.getDiagnose(principalDetails.getId()); - - /// 리턴 - return ApiResponse.ok(response); - } - - /** - * 진단 히스토리 목록 조회 - * - * @param principalDetails 로그인한 유저 - * @return 진단 히스토리 목록 - */ - @GetMapping("/history") - @CheckLogin - public ApiResponse> getDiagnosisHistory(@AuthenticationPrincipal PrincipalDetails principalDetails) { + public ApiResponse getLatestDiagnosis(@AuthenticationPrincipal PrincipalDetails principalDetails) { /// 서비스 - List response = service.getDiagnosisHistory(principalDetails.getId()); + DiagnosisDetailResponse response = service.getDiagnoseDetail(principalDetails.getId()); /// 리턴 return ApiResponse.ok(response); diff --git a/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/presentation/swagger/DiagnosisApiSpec.java b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/presentation/swagger/DiagnosisApiSpec.java index 760b776..09f0653 100644 --- a/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/presentation/swagger/DiagnosisApiSpec.java +++ b/src/main/java/com/pinHouse/server/platform/diagnostic/diagnosis/presentation/swagger/DiagnosisApiSpec.java @@ -1,7 +1,7 @@ package com.pinHouse.server.platform.diagnostic.diagnosis.presentation.swagger; import com.pinHouse.server.core.response.response.ApiResponse; -import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisHistoryResponse; +import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisDetailResponse; import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisRequest; import com.pinHouse.server.platform.diagnostic.diagnosis.application.dto.DiagnosisResponse; import com.pinHouse.server.security.oauth2.domain.PrincipalDetails; @@ -10,28 +10,20 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.RequestBody; -import java.util.List; - @Tag(name = "진단 API", description = "청약 진단 기능 API 입니다") public interface DiagnosisApiSpec { @Operation( summary = "청약 진단 API", - description = "청약 진단 API 입니다." + description = "청약 진단을 수행하고 결과만 반환합니다." ) ApiResponse diagnosis(@AuthenticationPrincipal PrincipalDetails principalDetails, @RequestBody DiagnosisRequest requestDTO); @Operation( - summary = "최근 진단 결과 조회 API", - description = "사용자의 최근 진단 결과 1개를 상세하게 조회합니다." - ) - ApiResponse getLatestDiagnosis(@AuthenticationPrincipal PrincipalDetails principalDetails); - - @Operation( - summary = "진단 히스토리 목록 조회 API", - description = "사용자의 모든 진단 히스토리를 최신순으로 조회합니다." + summary = "최근 진단 결과 상세 조회 API", + description = "사용자의 최근 진단 결과를 입력 정보와 함께 상세하게 조회합니다." ) - ApiResponse> getDiagnosisHistory(@AuthenticationPrincipal PrincipalDetails principalDetails); + ApiResponse getLatestDiagnosis(@AuthenticationPrincipal PrincipalDetails principalDetails); } diff --git a/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/ComplexDetailResponse.java b/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/ComplexDetailResponse.java index a186a4c..9484cb8 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/ComplexDetailResponse.java +++ b/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/ComplexDetailResponse.java @@ -1,6 +1,8 @@ package com.pinHouse.server.platform.housing.complex.application.dto.response; import com.fasterxml.jackson.annotation.JsonInclude; +import com.pinHouse.server.core.util.TimeFormatter; +import com.pinHouse.server.platform.housing.complex.application.dto.response.TransitRoutesResponse.SegmentResponse; import com.pinHouse.server.platform.housing.complex.domain.entity.ComplexDocument; import com.pinHouse.server.platform.housing.complex.domain.entity.UnitType; import com.pinHouse.server.platform.housing.facility.application.dto.NoticeFacilityListResponse; @@ -45,11 +47,11 @@ public record ComplexDetailResponse( String totalTime, @Schema(description = "전체 대중교통 정보 (임대주택 상세조회용)") - DistanceResponse distance + List distance ) { - /// 정적 팩토리 메서드 - 임대주택 상세조회용 (DistanceResponse 전체 포함) - public static ComplexDetailResponse from(ComplexDocument document, NoticeFacilityListResponse facilities, DistanceResponse distance) { + /// 정적 팩토리 메서드 - 임대주택 상세조회용 (SegmentResponse 리스트 포함) + public static ComplexDetailResponse from(ComplexDocument document, NoticeFacilityListResponse facilities, List distance) { return ComplexDetailResponse.builder() .id(document.getId()) @@ -122,7 +124,7 @@ public static List from( .infra(facilities.infra()) .unitCount(document.getUnitTypes().size()) .unitTypes(null) - .totalTime(formatTime(totalTimeMinutes)) + .totalTime(TimeFormatter.formatTime(totalTimeMinutes)) .distance(null) .build(); }) @@ -149,28 +151,4 @@ private static String extractRegion(String fullAddress) { return parts[0] + " " + parts[1]; } - /** - * 시간을 "0시간 0분" 형식으로 포맷팅 - * @param totalMinutes 총 시간(분) - * @return 포맷팅된 시간 문자열 (예: "1시간 30분", "45분"), 0 이하면 "0분" - */ - private static String formatTime(int totalMinutes) { - if (totalMinutes <= 0) { - return "0분"; - } - - if (totalMinutes < 60) { - return totalMinutes + "분"; - } - - int hours = totalMinutes / 60; - int minutes = totalMinutes % 60; - - if (minutes == 0) { - return hours + "시간"; - } - - return hours + "시간 " + minutes + "분"; - } - } diff --git a/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/DepositResponse.java b/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/DepositResponse.java index fb1769d..cc8e3d0 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/DepositResponse.java +++ b/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/DepositResponse.java @@ -8,8 +8,13 @@ @Builder public record DepositResponse( + @Schema(description = "최소 보증금 및 월세 정보") DepositMinMaxResponse min, + + @Schema(description = "보통 보증금 및 월세 정보") DepositMinMaxResponse normal, + + @Schema(description = "최대 보증금 및 월세 정보") DepositMinMaxResponse max ) { diff --git a/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/DistanceResponse.java b/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/DistanceResponse.java index 8acc5fa..10fc6a5 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/DistanceResponse.java +++ b/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/DistanceResponse.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; +import com.pinHouse.server.core.util.TimeFormatter; import com.pinHouse.server.platform.housing.complex.application.dto.result.RootResult; import com.pinHouse.server.platform.housing.complex.application.dto.result.SubwayLineType; import com.pinHouse.server.platform.housing.complex.application.dto.result.BusRouteType; @@ -26,60 +27,20 @@ public record DistanceResponse( double totalDistance, @Schema(description = "교통 구간 정보 목록") - List routes, - - @Schema(description = "환승 지점 정보 목록") - List stops + List routes ) { /// 정적 팩토리 메서드 public static DistanceResponse from(RootResult rootResult, List routes) { int minutes = rootResult.totalTime(); return DistanceResponse.builder() - .totalTime(formatTime(minutes)) + .totalTime(TimeFormatter.formatTimeOrNull(minutes)) .totalTimeMinutes(minutes) .totalDistance(Math.round(rootResult.totalDistance() / 100.0) / 10.0) .routes(routes) - .stops(null) .build(); } - /// 정적 팩토리 메서드 - public static DistanceResponse from(RootResult rootResult, List routes, List stops) { - int minutes = rootResult.totalTime(); - return DistanceResponse.builder() - .totalTime(formatTime(minutes)) - .totalTimeMinutes(minutes) - .totalDistance(Math.round(rootResult.totalDistance() / 100.0) / 10.0) - .routes(routes) - .stops(stops) - .build(); - } - - /** - * 시간을 "##시 ##분" 형식으로 포맷팅 - * @param totalMinutes 총 시간(분) - * @return 포맷팅된 시간 문자열 (예: "1시간 30분", "45분"), 0 이하면 null - */ - private static String formatTime(int totalMinutes) { - if (totalMinutes <= 0) { - return null; - } - - if (totalMinutes < 60) { - return totalMinutes + "분"; - } - - int hours = totalMinutes / 60; - int minutes = totalMinutes % 60; - - if (minutes == 0) { - return hours + "시간"; - } - - return hours + "시간 " + minutes + "분"; - } - @@ -91,8 +52,8 @@ public record TransitResponse( @Schema(description = "교통 타입 (WALK, BUS, SUBWAY, TRAIN, AIR)", example = "BUS") ChipType type, - @Schema(description = "구간 소요 시간 텍스트", example = "12분") - String minutesText, + @Schema(description = "막대 위 표시 텍스트 (호선명, 버스번호, 또는 소요시간), WALK인 경우 null", example = "수도권 7호선") + String labelText, @Schema(description = "노선 정보(버스번호/지하철 호선 등), 없는 경우 null", example = "9401, G8110") String lineText, @@ -122,47 +83,4 @@ public record TransitResponse( String bgColorHex) { } - @Builder - public record TransferPointResponse( - @Schema(description = "환승 역할 (START, TRANSFER, ARRIVAL)") - TransferRole role, - - @Schema(description = "교통 타입 (WALK, BUS, SUBWAY, TRAIN, AIR)") - ChipType type, - - @Schema(description = "정류장/역 이름") - String stopName, - - @Schema(description = "노선 정보(버스번호/지하철 호선 등)") - String lineText, - - @Schema(description = "통합 노선 정보 (코드, 이름, 색상)") - LineInfo line, - - @Schema(hidden = true) - @com.fasterxml.jackson.annotation.JsonIgnore - SubwayLineType subwayLine, - - @Schema(hidden = true) - @com.fasterxml.jackson.annotation.JsonIgnore - BusRouteType busRouteType, - - @Schema(hidden = true) - @com.fasterxml.jackson.annotation.JsonIgnore - TrainType trainType, - - @Schema(hidden = true) - @com.fasterxml.jackson.annotation.JsonIgnore - ExpressBusType expressBusType, - - @Schema(description = "배경 컬러(Hex 코드)") - @JsonIgnore - String bgColorHex - ) { - public enum TransferRole { - START, // 승차 지점 - TRANSFER, // 환승 지점 - ARRIVAL // 도착 지점 - } - } } diff --git a/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/LocationResponse.java b/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/LocationResponse.java index f6ab3e0..1745fbf 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/LocationResponse.java +++ b/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/LocationResponse.java @@ -1,11 +1,16 @@ package com.pinHouse.server.platform.housing.complex.application.dto.response; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; @Builder +@Schema(name = "[응답][위치] 위치 좌표 정보", description = "위도와 경도 좌표를 응답하는 DTO") public record LocationResponse( - double longitude, // 위도 - double latitude // 경도 + @Schema(description = "경도 (Longitude)", example = "127.0276") + double longitude, + + @Schema(description = "위도 (Latitude)", example = "37.4979") + double latitude ) { /// 정적 팩토리 메서드 diff --git a/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/TransitRoutesResponse.java b/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/TransitRoutesResponse.java index 1511bfb..a531f19 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/TransitRoutesResponse.java +++ b/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/TransitRoutesResponse.java @@ -81,8 +81,8 @@ public record SegmentResponse( @Schema(description = "소요 시간(분)", example = "65") int minutes, - @Schema(description = "막대 위 표시 텍스트", example = "65분") - String minutesText, + @Schema(description = "막대 위 표시 텍스트 (호선명, 버스번호, 또는 소요시간), WALK인 경우 null", example = "수도권 7호선") + String labelText, @Schema(description = "구간 색상(Hex)", example = "#3356B4") String colorHex, @@ -113,8 +113,7 @@ public record StepResponse( @Schema(description = "주 텍스트 (UI에 굵게 표시)", example = "시청역 승차") String primaryText, - @Schema(description = "부 텍스트 (노선명, 방면 등)", example = "수도권 1호선") - @JsonIgnore + @Schema(description = "부 텍스트 (노선명, 버스번호 등)", example = "수도권 1호선") String secondaryText, @Schema(description = "해당 구간 소요 시간(분), 없으면 null", example = "65") diff --git a/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/UnitTypeResponse.java b/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/UnitTypeResponse.java index 5d8d2f8..1e73e9a 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/UnitTypeResponse.java +++ b/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/UnitTypeResponse.java @@ -2,17 +2,37 @@ import com.pinHouse.server.platform.housing.complex.domain.entity.Quota; import com.pinHouse.server.platform.housing.complex.domain.entity.UnitType; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; +import java.util.List; + @Builder +@Schema(name = "[응답][유닛타입] 유닛타입 정보", description = "유닛타입(주택형)의 상세 정보를 응답할 때 사용되는 DTO") public record UnitTypeResponse( + @Schema(description = "유닛타입 ID (고유 식별자)", example = "a1b2c3d4e5f6g7h8i9j0k1l2") String typeId, - String typeCode, // 공급유형 (예: 26A) + + @Schema(description = "타입 코드 (주택형)", example = "59A") + String typeCode, + + @Schema(description = "썸네일 이미지 URL", example = "https://example.com/thumbnail.jpg", nullable = true) String thumbnail, - Integer quota, // 모집호수 정보 - Double exclusiveAreaM2, // 전용면적 - DepositResponse deposit, /// 최대/보통/최소 - boolean liked /// 좋아요 + + @Schema(description = "모집 호수 (총 공급 세대수)", example = "50") + Integer quota, + + @Schema(description = "전용 면적 (제곱미터)", example = "59.42") + Double exclusiveAreaM2, + + @Schema(description = "보증금 정보 (최소/보통/최대)") + DepositResponse deposit, + + @Schema(description = "좋아요 여부", example = "true") + boolean liked, + + @Schema(description = "공급 그룹 정보 (대상 계층)", example = "[\"신혼부부\", \"청년\"]") + List group ) { /// 정적 팩토리 메서드 @@ -28,6 +48,7 @@ public static UnitTypeResponse from(UnitType unitType, DepositResponse deposit, .deposit(deposit) .quota(typeQuota.getTotal()) .liked(liked) + .group(unitType.getGroup()) .build(); } 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 db0bd1c..0cf48ae 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 @@ -3,6 +3,7 @@ import com.pinHouse.server.core.exception.code.CommonErrorCode; import com.pinHouse.server.core.exception.code.ComplexErrorCode; import com.pinHouse.server.core.response.response.CustomException; +import com.pinHouse.server.core.util.DistanceCalculator; import com.pinHouse.server.platform.Location; import com.pinHouse.server.platform.housing.complex.application.dto.response.*; import com.pinHouse.server.platform.housing.complex.application.dto.result.PathResult; @@ -68,11 +69,11 @@ public ComplexDetailResponse getComplex(String id, String pinPointId) throws Uns /// 주변 인프라 조회 NoticeFacilityListResponse nearFacilities = facilityService.getNearFacilities(complex.getId()); - /// 거리 계산 - DistanceResponse distance = getEasyDistance(id, pinPointId); + /// 거리 계산 - Segment 리스트로 변환 + List segments = getSegments(id, pinPointId); /// 리턴 - return ComplexDetailResponse.from(complex, nearFacilities, distance); + return ComplexDetailResponse.from(complex, nearFacilities, segments); } @@ -108,62 +109,11 @@ public List getComplexUnitTypes(String id, UUID userId) { - /// 대중교통 시뮬레이터 (기존 스키마) - @Override - @Transactional - public List getDistance(String id, String pinPointId) throws UnsupportedEncodingException { - - /// 임대주택 예외처리 - ComplexDocument complex = loadComplex(id); - Location location = complex.getLocation(); - - /// 핀포인트 조회 - PinPoint pinPoint = pinPointService.loadPinPoint(pinPointId); - Location pointLocation = pinPoint.getLocation(); - - /// 대중교통 목록 가져오기 - PathResult rootResult = distanceUtil.findPathResult(pointLocation.getLatitude(), pointLocation.getLongitude(), location.getLatitude(), location.getLongitude()); - - /// 빠른 순서 3개 가져오기 - List rootResults = mapper.selectTop3(rootResult); - - /// 간편조건 탐색 DTO - return rootResults.stream() - .map(route -> { - // 각 경로의 세부 구간을 TransitResponse 리스트로 매핑 - List distance = mapper.from(route); - List stops = mapper.extractStops(route); - - // DistanceResponse 하나 생성 - return DistanceResponse.from(route, distance, stops); - }) - .toList(); - - } - /// 대중교통 시뮬레이터 (새 스키마 - 3개 경로 한 번에) @Override @Transactional public TransitRoutesResponse getDistanceV2(String id, String pinPointId) throws UnsupportedEncodingException { - - /// 임대주택 예외처리 - ComplexDocument complex = loadComplex(id); - Location location = complex.getLocation(); - - /// 핀포인트 조회 - PinPoint pinPoint = pinPointService.loadPinPoint(pinPointId); - Location pointLocation = pinPoint.getLocation(); - - /// 대중교통 목록 가져오기 - PathResult pathResult = distanceUtil.findPathResult( - pointLocation.getLatitude(), - pointLocation.getLongitude(), - location.getLatitude(), - location.getLongitude() - ); - - /// 새 스키마로 변환 (3개 경로 한 번에) - return mapper.toTransitRoutesResponse(pathResult); + return calculateTransitRoute(id, pinPointId, mapper::toTransitRoutesResponse); } /// 좋아요 누른 방 목록 조회 @@ -251,7 +201,7 @@ public List filterDistanceOnly(List co return complexDocuments.stream() .filter(c -> nearbyDocs.stream().anyMatch(n -> n.getId().equals(c.getId()))) .map(c -> { - double km = calcDistanceKm(pointLocation, c.getLocation()); + double km = DistanceCalculator.calculateDistanceKm(pointLocation, c.getLocation()); int minutes = (int) Math.round((km / avgSpeedKmh) * 60.0); // 평균속도 기반 시간 예측 return new ComplexDistanceResponse(c, km, minutes); }) @@ -259,19 +209,6 @@ public List filterDistanceOnly(List co .toList(); } - /** 두 지점 간 거리(km) 계산 (Haversine) */ - private double calcDistanceKm(Location a, Location b) { - final double R = 6371.0; - double dLat = Math.toRadians(b.getLatitude() - a.getLatitude()); - double dLon = Math.toRadians(b.getLongitude() - a.getLongitude()); - double lat1 = Math.toRadians(a.getLatitude()); - double lat2 = Math.toRadians(b.getLatitude()); - - double h = Math.pow(Math.sin(dLat / 2), 2) - + Math.pow(Math.sin(dLon / 2), 2) * Math.cos(lat1) * Math.cos(lat2); - return 2 * R * Math.asin(Math.sqrt(h)); - } - /// 필터링 @Override @@ -282,13 +219,18 @@ public List filterUnitTypesOnly(List rentalTypeValues = req.getRentalTypes() != null + ? req.getRentalTypes().stream() + .map(com.pinHouse.server.platform.search.domain.entity.RentalType::getValue) + .toList() + : List.of(); return complexes.stream() .filter(cd -> cd != null && cd.complex() != null && cd.complex().getUnitTypes() != null && !cd.complex().getUnitTypes().isEmpty()) .flatMap(cd -> cd.complex().getUnitTypes().stream() - .filter(u -> matchesUnitType(u, minM2, maxM2, maxDeposit, maxMonthlyPay)) + .filter(u -> matchesUnitType(u, minM2, maxM2, maxDeposit, maxMonthlyPay, rentalTypeValues)) .map(u -> { ComplexDocument oneUnitDoc = new ComplexDocument(cd.complex(), List.of(u)); return new ComplexDistanceResponse(oneUnitDoc, cd.distanceKm(), cd.estimatedMinutes()); @@ -310,12 +252,13 @@ private double toM2(double pyeong) { return pyeong * PYEONG_TO_M2; } - /** 전용면적(m²)/보증금/월임대료 필터 함수 */ + /** 전용면적(m²)/보증금/월임대료/모집대상 필터 함수 */ private boolean matchesUnitType(UnitType u, double minM2, double maxM2, long maxDeposit, - long maxMonthlyPay) { + long maxMonthlyPay, + List rentalTypeValues) { if (u == null) return false; // 전용면적(m²) 체크 @@ -335,6 +278,24 @@ private boolean matchesUnitType(UnitType u, if (monthlyRent <= 0) return false; if (monthlyRent > maxMonthlyPay) return false; + // 모집대상(group) 체크 + List group = u.getGroup(); + if (group != null && !group.isEmpty() && rentalTypeValues != null && !rentalTypeValues.isEmpty()) { + // "기본" 또는 "일반"이 포함되어 있으면 무조건 포함 + boolean hasDefaultGroup = group.stream() + .anyMatch(g -> "기본".equals(g) || "일반".equals(g)); + + if (!hasDefaultGroup) { + // "기본"/"일반"이 없으면, rentalTypes 중 하나라도 group에 포함되어야 함 + boolean hasMatchingRentalType = rentalTypeValues.stream() + .anyMatch(group::contains); + + if (!hasMatchingRentalType) { + return false; + } + } + } + return true; } @@ -463,6 +424,57 @@ public DepositResponse getLeaseMinMax(String complexId, String type) { ); } + // ================= + // 대중교통 경로 계산 (공통 로직) + // ================= + + /** + * 대중교통 경로 계산 템플릿 메서드 + * 공통 로직(complex/pinpoint 조회, pathResult 계산)을 처리하고, + * 결과 변환은 mapper 함수에 위임합니다. + * + * @param complexId 임대주택 ID + * @param pinPointId 핀포인트 ID + * @param pathMapper PathResult를 원하는 타입으로 변환하는 함수 + * @param 반환 타입 + * @return 변환된 결과 + * @throws UnsupportedEncodingException 인코딩 예외 + */ + private T calculateTransitRoute( + String complexId, + String pinPointId, + java.util.function.Function pathMapper + ) throws UnsupportedEncodingException { + + /// 임대주택 조회 + ComplexDocument complex = loadComplex(complexId); + Location complexLocation = complex.getLocation(); + + /// 핀포인트 조회 + PinPoint pinPoint = pinPointService.loadPinPoint(pinPointId); + Location pinPointLocation = pinPoint.getLocation(); + + /// 대중교통 경로 계산 + PathResult pathResult = distanceUtil.findPathResult( + pinPointLocation.getLatitude(), + pinPointLocation.getLongitude(), + complexLocation.getLatitude(), + complexLocation.getLongitude() + ); + + /// 결과 매핑 + return pathMapper.apply(pathResult); + } + + /// Segment 리스트 조회 (임대주택 상세조회용) + @Transactional(readOnly = true) + public List getSegments(String id, String pinPointId) throws UnsupportedEncodingException { + return calculateTransitRoute(id, pinPointId, pathResult -> { + RootResult rootResult = mapper.selectBest(pathResult); + return mapper.toSegmentResponses(rootResult); + }); + } + /// 간편 대중교통 시뮬레이터 @Override @Transactional(readOnly = true) @@ -475,31 +487,17 @@ public DistanceResponse getEasyDistance(String id, String pinPointId) throws Uns return cached; } - /// 임대주택 예외처리 - ComplexDocument complex = loadComplex(id); - Location location = complex.getLocation(); - - /// 핀포인트 조회 - PinPoint pinPoint = pinPointService.loadPinPoint(pinPointId); - - /// 대중교통 목록 비교하기 - Location pointLocation = pinPoint.getLocation(); - PathResult pathResult = distanceUtil.findPathResult(pointLocation.getLatitude(), pointLocation.getLongitude(), location.getLatitude(), location.getLongitude()); - - /// 조건 바탕으로 가져오기 - RootResult rootResult = mapper.selectBest(pathResult); - - /// 간편 조건 탐색 DTO - List routes = mapper.from(rootResult); - - /// DistanceResponse 생성 - DistanceResponse distance = DistanceResponse.from(rootResult, routes); + /// 템플릿 메서드를 사용하여 경로 계산 + DistanceResponse distance = calculateTransitRoute(id, pinPointId, pathResult -> { + RootResult rootResult = mapper.selectBest(pathResult); + List routes = mapper.from(rootResult); + return DistanceResponse.from(rootResult, routes); + }); /// Redis에 캐싱 distanceCacheService.cacheDistance(id, pinPointId, distance); /// 리턴 return distance; - } } 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 40c7856..fcd94b9 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 @@ -26,9 +26,6 @@ public interface ComplexUseCase { /// 상세 조회 List getComplexUnitTypes(String id, UUID userId); - /// 거리 시뮬레이터 전부 조회 (기존 스키마) - List getDistance(String id, String pinPointId) throws UnsupportedEncodingException; - /// 거리 시뮬레이터 전부 조회 (새 스키마 - 3개 경로 한 번에) TransitRoutesResponse getDistanceV2(String id, String pinPointId) throws UnsupportedEncodingException; diff --git a/src/main/java/com/pinHouse/server/platform/housing/complex/application/util/TransitResponseMapper.java b/src/main/java/com/pinHouse/server/platform/housing/complex/application/util/TransitResponseMapper.java index 0ee6c7c..218c333 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/complex/application/util/TransitResponseMapper.java +++ b/src/main/java/com/pinHouse/server/platform/housing/complex/application/util/TransitResponseMapper.java @@ -1,5 +1,6 @@ package com.pinHouse.server.platform.housing.complex.application.util; +import com.pinHouse.server.core.util.TimeFormatter; import com.pinHouse.server.platform.housing.complex.application.dto.response.ChipType; import com.pinHouse.server.platform.housing.complex.application.dto.response.DistanceResponse; import com.pinHouse.server.platform.housing.complex.application.dto.response.TransitRoutesResponse; @@ -13,6 +14,26 @@ import java.util.Comparator; import java.util.List; +/** + * 대중교통 경로 응답 매퍼 + * + *

ODsay API의 {@link PathResult}와 {@link RootResult}를 + * 애플리케이션의 응답 DTO로 변환하는 역할을 담당합니다.

+ * + *

지원하는 스키마

+ *
    + *
  • 신규 스키마: {@link TransitRoutesResponse} - 3개 경로 + 상세 단계 정보
  • + *
  • 구 스키마 (Deprecated): {@link DistanceResponse} - 단일 경로 정보
  • + *
+ * + *

주요 기능

+ *
    + *
  • 경로 선택 (최적 1개, 상위 3개)
  • + *
  • 교통수단 타입 매핑 및 색상 추출
  • + *
  • 노선 정보 정규화
  • + *
  • 승차/하차/도보 단계별 상세 정보 생성
  • + *
+ */ @Component @NoArgsConstructor(access = AccessLevel.PROTECTED) public class TransitResponseMapper { @@ -52,77 +73,6 @@ public List selectTop3(PathResult pathResult) { .toList(); } - public List extractStops(RootResult route) { - - List result = new ArrayList<>(); - - if (route == null || route.steps() == null || route.steps().isEmpty()) { - return result; - } - - // WALK 빼고 “실제 탑승하는 구간”만 추출 - List moveSteps = route.steps().stream() - .filter(s -> s.type() != RootResult.TransportType.WALK) - .toList(); - - if (moveSteps.isEmpty()) { - return result; - } - - // 1) 첫 승차 지점 - RootResult.DistanceStep first = moveSteps.get(0); - ChipType firstType = mapType(first.type()); - result.add(DistanceResponse.TransferPointResponse.builder() - .role(DistanceResponse.TransferPointResponse.TransferRole.START) - .type(firstType) - .stopName(first.startName()) - .lineText(first.lineInfo()) - .line(first.line()) - .subwayLine(first.subwayLine()) - .busRouteType(first.busRouteType()) - .trainType(first.trainType()) - .expressBusType(first.expressBusType()) - .bgColorHex(extractBgColorHex(first, firstType)) - .build()); - - // 2) 중간 환승 지점들 (두 번째 탑승부터는 전부 환승으로 간주) - for (int i = 1; i < moveSteps.size(); i++) { - RootResult.DistanceStep step = moveSteps.get(i); - ChipType stepType = mapType(step.type()); - - result.add(DistanceResponse.TransferPointResponse.builder() - .role(DistanceResponse.TransferPointResponse.TransferRole.TRANSFER) - .type(stepType) - .stopName(step.startName()) - .lineText(step.lineInfo()) - .line(step.line()) - .subwayLine(step.subwayLine()) - .busRouteType(step.busRouteType()) - .trainType(step.trainType()) - .expressBusType(step.expressBusType()) - .bgColorHex(extractBgColorHex(step, stepType)) - .build()); - } - - // 3) 마지막 도착 지점 - RootResult.DistanceStep last = moveSteps.get(moveSteps.size() - 1); - ChipType lastType = mapType(last.type()); - result.add(DistanceResponse.TransferPointResponse.builder() - .role(DistanceResponse.TransferPointResponse.TransferRole.ARRIVAL) - .type(lastType) - .stopName(last.endName()) - .lineText(null) - .line(null) - .subwayLine(null) - .busRouteType(null) - .trainType(null) - .expressBusType(null) - .bgColorHex(null) - .build()); - - return result; - } - /// 출력을 위한 매퍼 @@ -142,18 +92,18 @@ public List from(RootResult route) { /// enum ChipType type = mapType(step.type()); - /// 분 - String minutes = formatMinutes(step.time()); + /// 라벨 텍스트 (WALK인 경우 null, 그 외에는 호선/버스 번호) + String labelText = (type == ChipType.WALK) ? null : normalizeLine(step, type); /// 라인 String line = normalizeLine(step, type); /// bgColorHex를 enum으로부터 추출 - String bgColorHex = extractBgColorHex(step, type); + String bgColorHex = TransportColorResolver.extractBgColorHex(step, type); chips.add(DistanceResponse.TransitResponse.builder() .type(type) - .minutesText(minutes) + .labelText(labelText) .lineText(line) .line(step.line()) .subwayLine(step.subwayLine()) @@ -192,18 +142,6 @@ private static ChipType mapType(RootResult.TransportType t) { }; } - /// 분 표기 - private static String formatMinutes(int min) { - - /// 방어 - if (min <= 0){ - return "0분"; - } - - /// 리턴 - return min + "분"; - } - /** 버스/지하철 표기 보정 */ private static String normalizeLine(RootResult.DistanceStep step, ChipType type) { @@ -227,47 +165,6 @@ private static String normalizeLine(RootResult.DistanceStep step, ChipType type) return (line == null || line.isBlank()) ? null : line; } - /// enum으로부터 bgColorHex 추출 - private static String extractBgColorHex(RootResult.DistanceStep step, ChipType type) { - // WALK와 AIR는 ChipType의 defaultBg 사용 - if (type == ChipType.WALK || type == ChipType.AIR) { - return type.defaultBg; - } - - // SUBWAY: subwayLine enum의 색상 사용 - if (type == ChipType.SUBWAY) { - if (step.subwayLine() != null) { - return step.subwayLine().getColorHex(); - } - // enum이 없으면 기본 회색 - return "#BBBBBB"; - } - - // BUS: busRouteType 또는 expressBusType enum의 색상 사용 - if (type == ChipType.BUS) { - if (step.busRouteType() != null) { - return step.busRouteType().getColorHex(); - } - if (step.expressBusType() != null) { - return step.expressBusType().getColorHex(); - } - // enum이 없으면 기본 회색 - return "#BBBBBB"; - } - - // TRAIN: trainType enum의 색상 사용 - if (type == ChipType.TRAIN) { - if (step.trainType() != null) { - return step.trainType().getColorHex(); - } - // enum이 없으면 기본 회색 - return "#BBBBBB"; - } - - // 그 외의 경우 기본 회색 반환 - return "#BBBBBB"; - } - // ================= // 새 스키마 변환 로직 // ================= @@ -321,7 +218,7 @@ private TransitRoutesResponse.SummaryResponse toSummaryResponse(RootResult route .totalDistanceKm(Math.round(route.totalDistance() / 100.0) / 10.0) .totalFareWon(route.totalPayment() > 0 ? route.totalPayment() : null) .transferCount(transferCount) - .displayText(formatTime(totalMinutes)) + .displayText(TimeFormatter.formatTime(totalMinutes)) .build(); } @@ -343,7 +240,7 @@ private int countTransfers(RootResult route) { /** * Segments 생성 (색 막대용) */ - private List toSegmentResponses(RootResult route) { + public List toSegmentResponses(RootResult route) { if (route == null || route.steps() == null) { return List.of(); } @@ -352,12 +249,28 @@ private List toSegmentResponses(RootResul .filter(step -> step.time() > 0) // 0분인 구간은 제외 .map(step -> { ChipType type = mapType(step.type()); - String bgColorHex = extractBgColorHex(step, type); + String bgColorHex = TransportColorResolver.extractBgColorHex(step, type); + + // 막대 위 표시 텍스트 (WALK는 null) + String labelText; + if (type == ChipType.WALK) { + // WALK는 null + labelText = null; + } else if (step.type() == RootResult.TransportType.BUS && step.lineInfo() != null && !step.lineInfo().isBlank()) { + // 버스 노선 정보 포함 + labelText = step.lineInfo(); + } else if (step.type() == RootResult.TransportType.SUBWAY && step.lineInfo() != null && !step.lineInfo().isBlank()) { + // 지하철 노선 정보 포함 + labelText = normalizeLine(step, type); + } else { + // 기타 교통수단은 시간 표시 + labelText = TimeFormatter.formatTime(step.time()); + } return TransitRoutesResponse.SegmentResponse.builder() .type(step.type().name()) .minutes(step.time()) - .minutesText(formatMinutes(step.time())) + .labelText(labelText) .colorHex(bgColorHex) .line(step.line()) .build(); @@ -404,12 +317,12 @@ private List toStepResponses(RootResult rout } else { // 교통수단: BOARD + ALIGHT 추가 (색상 포함) ChipType chipType = mapType(step.type()); - steps.add(createBoardStep(step, true, chipType)); + steps.add(createBoardStep(step, chipType)); // 다음이 WALK거나 마지막이면 하차 boolean isLast = (transportIndex == transportSteps.size() - 1); if (isLast || (i + 1 < distanceSteps.size() && distanceSteps.get(i + 1).type() == RootResult.TransportType.WALK)) { - steps.add(createAlightStep(step, true, chipType)); + steps.add(createAlightStep(step, chipType)); } transportIndex++; @@ -464,7 +377,7 @@ private TransitRoutesResponse.StepResponse createWalkStep(RootResult.DistanceSte .type("WALK") .stopName(null) .primaryText("도보 이동") - .secondaryText("약 " + formatMinutes(step.time())) + .secondaryText(null) .minutes(step.time()) .colorHex(colorHex) .line(null) @@ -474,9 +387,8 @@ private TransitRoutesResponse.StepResponse createWalkStep(RootResult.DistanceSte /** * BOARD step 생성 (승차) */ - private TransitRoutesResponse.StepResponse createBoardStep(RootResult.DistanceStep step, boolean isInferred, ChipType chipType) { + private TransitRoutesResponse.StepResponse createBoardStep(RootResult.DistanceStep step, ChipType chipType) { String stopType = getStopTypeSuffix(step.type()); - String shortLabel = shortenLineLabel(step.lineInfo()); String secondaryText = step.lineInfo(); // 버스 노선 축약 @@ -485,7 +397,7 @@ private TransitRoutesResponse.StepResponse createBoardStep(RootResult.DistanceSt } // 색상 추출 - String colorHex = extractBgColorHex(step, chipType); + String colorHex = TransportColorResolver.extractBgColorHex(step, chipType); return TransitRoutesResponse.StepResponse.builder() .stepIndex(0) @@ -503,12 +415,17 @@ private TransitRoutesResponse.StepResponse createBoardStep(RootResult.DistanceSt /** * ALIGHT step 생성 (하차) */ - private TransitRoutesResponse.StepResponse createAlightStep(RootResult.DistanceStep step, boolean isInferred, ChipType chipType) { + private TransitRoutesResponse.StepResponse createAlightStep(RootResult.DistanceStep step, ChipType chipType) { String stopType = getStopTypeSuffix(step.type()); - String shortLabel = shortenLineLabel(step.lineInfo()); + String secondaryText = step.lineInfo(); + + // 버스 노선 축약 (승차와 동일하게 처리) + if (step.type() == RootResult.TransportType.BUS && step.lineInfo() != null) { + secondaryText = abbreviateBusNumbers(step.lineInfo()); + } // 색상 추출 (ALIGHT는 해당 교통수단의 색상 유지) - String colorHex = extractBgColorHex(step, chipType); + String colorHex = TransportColorResolver.extractBgColorHex(step, chipType); return TransitRoutesResponse.StepResponse.builder() .stepIndex(0) @@ -516,7 +433,7 @@ private TransitRoutesResponse.StepResponse createAlightStep(RootResult.DistanceS .type(step.type().name()) .stopName(step.endName()) .primaryText(step.endName() + stopType + " 하차") - .secondaryText(step.lineInfo()) + .secondaryText(secondaryText) .minutes(null) .colorHex(colorHex) .line(step.line()) @@ -574,14 +491,6 @@ private String getStopTypeSuffix(RootResult.TransportType type) { return ""; } - /** - * 노선명 축약 - */ - private String shortenLineLabel(String label) { - if (label == null) return null; - return label.replace("수도권 ", "").replace("호선", "호선"); - } - /** * 버스 번호 축약 */ @@ -595,26 +504,4 @@ private String abbreviateBusNumbers(String busNumbers) { int remaining = numbers.length - 3; return first3 + "번 외 " + remaining + "개"; } - - /** - * 시간 포맷팅 - */ - private String formatTime(int totalMinutes) { - if (totalMinutes <= 0) { - return "0분"; - } - - if (totalMinutes < 60) { - return totalMinutes + "분"; - } - - int hours = totalMinutes / 60; - int minutes = totalMinutes % 60; - - if (minutes == 0) { - return hours + "시간"; - } - - return hours + "시간 " + minutes + "분"; - } } diff --git a/src/main/java/com/pinHouse/server/platform/housing/complex/application/util/TransportColorResolver.java b/src/main/java/com/pinHouse/server/platform/housing/complex/application/util/TransportColorResolver.java new file mode 100644 index 0000000..1028d35 --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/housing/complex/application/util/TransportColorResolver.java @@ -0,0 +1,65 @@ +package com.pinHouse.server.platform.housing.complex.application.util; + +import com.pinHouse.server.platform.housing.complex.application.dto.response.ChipType; +import com.pinHouse.server.platform.housing.complex.application.dto.result.RootResult; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * 교통수단 색상 추출 유틸리티 클래스 + * 교통수단 타입과 노선 정보를 바탕으로 배경 색상(Hex)을 추출합니다. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class TransportColorResolver { + + /** 기본 색상 (회색) */ + private static final String DEFAULT_COLOR = "#BBBBBB"; + + /** + * enum으로부터 배경 색상(Hex) 추출 + * + * @param step 교통 단계 정보 + * @param type 교통수단 타입 + * @return 배경 색상(Hex 코드) + */ + public static String extractBgColorHex(RootResult.DistanceStep step, ChipType type) { + if (step == null || type == null) { + return DEFAULT_COLOR; + } + + // WALK와 AIR는 ChipType의 defaultBg 사용 + if (type == ChipType.WALK || type == ChipType.AIR) { + return type.defaultBg; + } + + // SUBWAY: subwayLine enum의 색상 사용 + if (type == ChipType.SUBWAY) { + if (step.subwayLine() != null) { + return step.subwayLine().getColorHex(); + } + return DEFAULT_COLOR; + } + + // BUS: busRouteType 또는 expressBusType enum의 색상 사용 + if (type == ChipType.BUS) { + if (step.busRouteType() != null) { + return step.busRouteType().getColorHex(); + } + if (step.expressBusType() != null) { + return step.expressBusType().getColorHex(); + } + return DEFAULT_COLOR; + } + + // TRAIN: trainType enum의 색상 사용 + if (type == ChipType.TRAIN) { + if (step.trainType() != null) { + return step.trainType().getColorHex(); + } + return DEFAULT_COLOR; + } + + // 그 외의 경우 기본 회색 반환 + return DEFAULT_COLOR; + } +} diff --git a/src/main/java/com/pinHouse/server/platform/housing/complex/domain/entity/UnitType.java b/src/main/java/com/pinHouse/server/platform/housing/complex/domain/entity/UnitType.java index 39247e6..ed5b852 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/complex/domain/entity/UnitType.java +++ b/src/main/java/com/pinHouse/server/platform/housing/complex/domain/entity/UnitType.java @@ -6,6 +6,8 @@ import lombok.NoArgsConstructor; import org.springframework.data.mongodb.core.mapping.Field; +import java.util.List; + @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class UnitType { @@ -31,9 +33,12 @@ public class UnitType { @Field("quota") private Quota quota; + @Field("group") + private List group; + /// 빌더 생성자 @Builder - public UnitType(String typeId, String typeCode, String complexId, double exclusiveAreaM2, int monthlyRent, Deposit deposit, Quota quota) { + public UnitType(String typeId, String typeCode, String complexId, double exclusiveAreaM2, int monthlyRent, Deposit deposit, Quota quota, List group) { this.typeId = typeId; this.typeCode = typeCode; this.complexId = complexId; @@ -41,5 +46,6 @@ public UnitType(String typeId, String typeCode, String complexId, double exclusi this.monthlyRent = monthlyRent; this.deposit = deposit; this.quota = quota; + this.group = group; } } diff --git a/src/main/java/com/pinHouse/server/platform/housing/complex/external/OdsayUtil.java b/src/main/java/com/pinHouse/server/platform/housing/complex/external/OdsayUtil.java index 90bb189..034552c 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/complex/external/OdsayUtil.java +++ b/src/main/java/com/pinHouse/server/platform/housing/complex/external/OdsayUtil.java @@ -59,7 +59,6 @@ public PathResult findPathResult(double startY, double startX, double endY, doub /// 자동 판별 JsonNode root = OM.readTree(response); - log.info(root.toString()); int searchType = detectSearchType(root); diff --git a/src/main/java/com/pinHouse/server/platform/housing/complex/presentation/ComplexApi.java b/src/main/java/com/pinHouse/server/platform/housing/complex/presentation/ComplexApi.java index ea0860e..30338b6 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/complex/presentation/ComplexApi.java +++ b/src/main/java/com/pinHouse/server/platform/housing/complex/presentation/ComplexApi.java @@ -2,7 +2,6 @@ import com.pinHouse.server.core.aop.CheckLogin; import com.pinHouse.server.core.response.response.ApiResponse; -import com.pinHouse.server.platform.housing.complex.application.dto.response.DistanceResponse; import com.pinHouse.server.platform.housing.complex.application.dto.response.TransitRoutesResponse; import com.pinHouse.server.platform.housing.complex.application.dto.response.UnitTypeResponse; import com.pinHouse.server.platform.housing.complex.application.usecase.ComplexUseCase; diff --git a/src/main/java/com/pinHouse/server/platform/housing/facility/application/dto/NoticeFacilityListResponse.java b/src/main/java/com/pinHouse/server/platform/housing/facility/application/dto/NoticeFacilityListResponse.java index 13bc7f4..d318d56 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/facility/application/dto/NoticeFacilityListResponse.java +++ b/src/main/java/com/pinHouse/server/platform/housing/facility/application/dto/NoticeFacilityListResponse.java @@ -1,19 +1,18 @@ package com.pinHouse.server.platform.housing.facility.application.dto; import com.pinHouse.server.platform.housing.facility.domain.entity.FacilityType; -import com.pinHouse.server.platform.housing.facility.domain.entity.infra.*; -import io.micrometer.common.lang.Nullable; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; /** * 인프라 관련 응답 DTO 클래스입니다. */ @Builder +@Schema(name = "[응답][시설] 주변 인프라 시설 목록", description = "단지 주변 1KM 이내의 주요 생활편의 시설 정보") public record NoticeFacilityListResponse( + @Schema(description = "주변 인프라 시설 타입 목록 (3개 이상 있는 시설만 포함)", example = "[\"PARK\", \"LIBRARY\", \"HOSPITAL\"]") List infra ) { /// 정적 팩토리 메서드 diff --git a/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/NoticeDetailFilterRequest.java b/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/NoticeDetailFilterRequest.java index 3adc72d..37c554d 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/NoticeDetailFilterRequest.java +++ b/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/NoticeDetailFilterRequest.java @@ -8,30 +8,32 @@ import lombok.RequiredArgsConstructor; import java.util.List; +@Schema(name = "[요청][공고] 공고 상세 필터링 Request", description = "공고 상세 조회 시 필터링 조건을 지정하는 DTO") public record NoticeDetailFilterRequest( + @Schema(description = "정렬 유형 (거리순/생활태그 매칭순)", example = "거리 순") DetailSortType sortType, @NotBlank(message = "pinPointId는 필수 입력값입니다") - @Schema(example = "fec9aba3-0fd9-4b75-bebf-9cb7641fd251", required = true) + @Schema(description = "핀포인트 ID (기준 위치)", example = "fec9aba3-0fd9-4b75-bebf-9cb7641fd251", required = true) String pinPointId, - @Schema(example = "100") + @Schema(description = "대중교통 최대 소요 시간 (분)", example = "100") int transitTime, - @Schema(example = "[\"양주시\"]") + @Schema(description = "지역 필터 (시/군/구)", example = "[\"양주시\"]") List region, - @Schema(description = "대상 유형 목록", example = "[\"청년\", \"신혼부부\"]") + @Schema(description = "대상 유형 목록 (청년, 신혼부부 등)", example = "[\"청년\", \"신혼부부\"]") List targetType, - @Schema(description = "보증금 최대값", example = "50000000") + @Schema(description = "보증금 최대값 (원)", example = "50000000") int maxDeposit, - @Schema(description = "월 임대료 최대값", example = "300000") + @Schema(description = "월 임대료 최대값 (원)", example = "300000") int maxMonthPay, - @Schema(example = "[\"26A\"]") + @Schema(description = "주택형 코드 필터", example = "[\"26A\"]") List typeCode, @Schema(description = "원하는 인프라, 최대 3개까지 가능", example = "[\"공원\"]") 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 ffac06c..bce4876 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 @@ -56,7 +56,10 @@ public record UnitTypeComparisonItem( String distanceFromPinPoint, @Schema(description = "좋아요 여부", example = "true") - boolean isLiked + boolean isLiked, + + @Schema(description = "공급 그룹 정보", example = "[\"신혼부부\", \"청년\"]") + List group ) { /** * 정적 팩토리 메서드 @@ -77,6 +80,7 @@ public static UnitTypeComparisonItem from( .nearbyFacilities(facilities != null ? facilities : List.of()) .distanceFromPinPoint(distance) .isLiked(isLiked) + .group(unitType.getGroup() != null ? unitType.getGroup() : List.of()) .build(); } } diff --git a/src/main/java/com/pinHouse/server/platform/like/application/dto/LikeRequest.java b/src/main/java/com/pinHouse/server/platform/like/application/dto/LikeRequest.java index 0bb43dc..387a68d 100644 --- a/src/main/java/com/pinHouse/server/platform/like/application/dto/LikeRequest.java +++ b/src/main/java/com/pinHouse/server/platform/like/application/dto/LikeRequest.java @@ -1,10 +1,14 @@ package com.pinHouse.server.platform.like.application.dto; import com.pinHouse.server.platform.like.domain.LikeType; +import io.swagger.v3.oas.annotations.media.Schema; -/// 좋아요 요청 DTO +@Schema(name = "[요청][좋아요] 좋아요 등록/취소", description = "공고 또는 유닛타입에 대한 좋아요를 등록하거나 취소하는 요청 DTO") public record LikeRequest( + @Schema(description = "좋아요 대상 ID (공고 ID 또는 유닛타입 ID)", example = "19417 또는 type456", required = true) String targetId, + + @Schema(description = "좋아요 타입 (NOTICE: 공고, ROOM: 유닛타입)", example = "NOTICE", required = true, allowableValues = {"NOTICE", "ROOM"}) LikeType type ) { } diff --git a/src/main/java/com/pinHouse/server/platform/like/presentation/swagger/LikeApiSpec.java b/src/main/java/com/pinHouse/server/platform/like/presentation/swagger/LikeApiSpec.java index 009a9bf..0b6f210 100644 --- a/src/main/java/com/pinHouse/server/platform/like/presentation/swagger/LikeApiSpec.java +++ b/src/main/java/com/pinHouse/server/platform/like/presentation/swagger/LikeApiSpec.java @@ -56,7 +56,7 @@ ApiResponse disLike( /// 공고 좋아요 예시 String NOTICE_EXAMPLE = """ { - "targetId": "18442", + "targetId": "19417", "type": "NOTICE" } @@ -65,7 +65,7 @@ ApiResponse disLike( /// 방 좋아요 예시 String COMPLEX_EXAMPLE = """ { - "targetId": "68f65b276cd8c3eaf57c4658", + "targetId": "4b30ca7d718d4ea9a9f6966f", "type": "ROOM" } """; diff --git a/src/main/java/com/pinHouse/server/platform/pinPoint/application/dto/UpdatePinPointRequest.java b/src/main/java/com/pinHouse/server/platform/pinPoint/application/dto/UpdatePinPointRequest.java index 4a882b7..559fb26 100644 --- a/src/main/java/com/pinHouse/server/platform/pinPoint/application/dto/UpdatePinPointRequest.java +++ b/src/main/java/com/pinHouse/server/platform/pinPoint/application/dto/UpdatePinPointRequest.java @@ -2,9 +2,12 @@ import io.swagger.v3.oas.annotations.media.Schema; -@Schema(name = "UpdatePinPointRequest", description = "핀 포인트 이름 업데이트 요청 DTO입니다.") +@Schema(name = "[요청][핀포인트] 핀포인트 수정", description = "사용자가 등록한 핀포인트의 이름과 우선순위를 수정하는 요청 DTO") public record UpdatePinPointRequest( - @Schema(description = "핀 포인트 이름", example = "서울역") - String name + @Schema(description = "변경할 핀포인트 이름 (최대 20자)", example = "서울역 근처 집") + String name, + + @Schema(description = "우선순위 설정 여부, true인 경우 최우선", example = "true") + Boolean isFirst ) { } 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 312f1c2..64d1b03 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 @@ -43,6 +43,15 @@ public void savePinPoint(UUID userId, PinPointRequest request) { /// 유저 검증 User user = userService.loadUser(userId); + /// 새로운 핀포인트가 first=true인 경우, 기존 first=true인 핀포인트를 false로 변경 + if (request.first()) { + Optional existingFirstPinPoint = repository.findByUserIdAndIsFirst(user.getId().toString(), true); + existingFirstPinPoint.ifPresent(pinPoint -> { + pinPoint.setFirst(false); + repository.save(pinPoint); + }); + } + /// 주소를 좌표로 변환 Location location = locationTool.getLocation(request.address()); @@ -61,8 +70,8 @@ public List loadPinPoints(UUID userId) { /// 유저 검증 User user = userService.loadUser(userId); - /// 유저가 존재하는 핀포인트 목록 조회 - List pinPoints = repository.findByUserId(user.getId().toString()); + /// 유저가 존재하는 핀포인트 목록을 first 기준으로 정렬하여 조회 + List pinPoints = repository.findByUserIdOrderByIsFirstDesc(user.getId().toString()); /// Stream 돌면서 DTO 변경 return PinPointResponse.from(pinPoints); @@ -72,12 +81,26 @@ public List loadPinPoints(UUID userId) { @Transactional public void update(String id, UUID userId, UpdatePinPointRequest request) { - /// 영속성 컨테이너 조횐 + /// 영속성 컨테이너 조회 PinPoint pinPoint = loadPinPoint(id); + /// isFirst가 true로 변경되는 경우, 기존 first=true인 핀포인트를 false로 변경 + if (request.isFirst() != null && request.isFirst() && !pinPoint.isFirst()) { + Optional existingFirstPinPoint = repository.findByUserIdAndIsFirst(userId.toString(), true); + existingFirstPinPoint.ifPresent(existingPinPoint -> { + existingPinPoint.setFirst(false); + repository.save(existingPinPoint); + }); + } + /// 수정 (더티체킹) pinPoint.updateName(request.name()); + /// isFirst 값이 null이 아닌 경우에만 업데이트 + if (request.isFirst() != null) { + pinPoint.setFirst(request.isFirst()); + } + repository.save(pinPoint); } diff --git a/src/main/java/com/pinHouse/server/platform/pinPoint/domain/entity/PinPoint.java b/src/main/java/com/pinHouse/server/platform/pinPoint/domain/entity/PinPoint.java index a5f63a4..67c50ff 100644 --- a/src/main/java/com/pinHouse/server/platform/pinPoint/domain/entity/PinPoint.java +++ b/src/main/java/com/pinHouse/server/platform/pinPoint/domain/entity/PinPoint.java @@ -66,4 +66,9 @@ public void updateName(String newName) { } + + /// first 값 변경 + public void setFirst(boolean isFirst) { + this.isFirst = isFirst; + } } diff --git a/src/main/java/com/pinHouse/server/platform/pinPoint/domain/repository/PinPointMongoRepository.java b/src/main/java/com/pinHouse/server/platform/pinPoint/domain/repository/PinPointMongoRepository.java index 159ab5d..d364b90 100644 --- a/src/main/java/com/pinHouse/server/platform/pinPoint/domain/repository/PinPointMongoRepository.java +++ b/src/main/java/com/pinHouse/server/platform/pinPoint/domain/repository/PinPointMongoRepository.java @@ -17,5 +17,11 @@ public interface PinPointMongoRepository extends MongoRepository findByUserIdAndIsFirst(String userId, boolean isFirst); + + /// 유저ID에 따른 핀포인트 목록을 first 기준으로 정렬하여 조회 + List findByUserIdOrderByIsFirstDesc(String userId); + void deleteByUserId(String userId); } diff --git a/src/main/java/com/pinHouse/server/platform/search/application/dto/FastUnitTypeResponse.java b/src/main/java/com/pinHouse/server/platform/search/application/dto/FastUnitTypeResponse.java index 0ff5556..34417b7 100644 --- a/src/main/java/com/pinHouse/server/platform/search/application/dto/FastUnitTypeResponse.java +++ b/src/main/java/com/pinHouse/server/platform/search/application/dto/FastUnitTypeResponse.java @@ -30,7 +30,10 @@ public record FastUnitTypeResponse( List infra, // 인프라 종류 @Schema(description = "좋아요 여부", example = "true") - boolean liked // 좋아요 여부 + boolean liked, // 좋아요 여부 + + @Schema(description = "모집 대상 그룹", example = "[\"신혼부부\", \"청년\"]") + List group // 모집 대상 그룹 ) { @@ -58,6 +61,7 @@ public static FastUnitTypeResponse from(ComplexDistanceResponse complexDistanceR .map(FacilityType::getValue) .toList()) .liked(liked) + .group(unitType.getGroup()) .build(); }