diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/home/application/dto/response/HomeNoticeEmptyReason.java b/module-domain/src/main/java/co/kr/pinhouse/domain/home/application/dto/response/HomeNoticeEmptyReason.java new file mode 100644 index 00000000..7677264c --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/home/application/dto/response/HomeNoticeEmptyReason.java @@ -0,0 +1,19 @@ +package co.kr.pinhouse.domain.home.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "홈 공고 목록이 비어 있는 이유") +public enum HomeNoticeEmptyReason { + + @Schema(description = "진단 기록이 없어 추천 공고를 생성할 수 없음") + NO_DIAGNOSIS, + + @Schema(description = "진단 결과상 추천 가능한 임대주택 유형이 없음") + NO_ELIGIBLE_RENTAL_TYPES, + + @Schema(description = "진단 결과를 공고 필터 조건으로 매핑할 수 없음") + NO_MAPPED_SUPPLY_TYPES, + + @Schema(description = "진단 결과에 맞는 공고가 현재 없음") + NO_MATCHING_NOTICES +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/home/application/dto/response/HomeNoticeListResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/home/application/dto/response/HomeNoticeListResponse.java index f5dc50b8..f7b97a7b 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/home/application/dto/response/HomeNoticeListResponse.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/home/application/dto/response/HomeNoticeListResponse.java @@ -2,6 +2,8 @@ import java.util.List; +import com.fasterxml.jackson.annotation.JsonInclude; + import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; @@ -9,6 +11,7 @@ @Builder public record HomeNoticeListResponse( + @JsonInclude(JsonInclude.Include.NON_NULL) @Schema(description = "공통 지역", example = "성남시") String region, @@ -22,7 +25,19 @@ public record HomeNoticeListResponse( boolean hasNext, @Schema(description = "전체 공고 개수", example = "100") - long totalElements + long totalElements, + + @JsonInclude(JsonInclude.Include.NON_NULL) + @Schema(description = "진단 기반 추천 응답에서 진단 기록 존재 여부를 제공합니다.", example = "true", nullable = true) + Boolean hasDiagnosis, + + @JsonInclude(JsonInclude.Include.NON_NULL) + @Schema(description = "목록이 비어 있는 이유. 진단 기반 추천 응답에서만 사용됩니다.", example = "NO_DIAGNOSIS", nullable = true) + HomeNoticeEmptyReason emptyReason, + + @JsonInclude(JsonInclude.Include.NON_NULL) + @Schema(description = "목록이 비어 있는 사유를 설명하는 메시지. 진단 기반 추천 응답에서만 사용됩니다.", example = "진단 기록이 없어 추천 공고를 제공할 수 없습니다.", nullable = true) + String emptyMessage ) { } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/home/application/service/HomeService.java b/module-domain/src/main/java/co/kr/pinhouse/domain/home/application/service/HomeService.java index c7108dd3..3c4695de 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/home/application/service/HomeService.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/home/application/service/HomeService.java @@ -15,7 +15,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import co.kr.pinhouse.common.exception.code.DiagnosisErrorCode; import co.kr.pinhouse.common.exception.code.PinPointErrorCode; import co.kr.pinhouse.common.response.CustomException; import co.kr.pinhouse.common.response.pageable.SliceRequest; @@ -24,6 +23,7 @@ import co.kr.pinhouse.domain.diagnostic.diagnosis.application.dto.response.DiagnosisDetailResponse; import co.kr.pinhouse.domain.diagnostic.diagnosis.application.usecase.DiagnosisUseCase; import co.kr.pinhouse.domain.home.application.dto.HomeSearchCategoryType; +import co.kr.pinhouse.domain.home.application.dto.response.HomeNoticeEmptyReason; import co.kr.pinhouse.domain.home.application.dto.response.HomeNoticeListResponse; import co.kr.pinhouse.domain.home.application.dto.response.HomeNoticeResponse; import co.kr.pinhouse.domain.home.application.dto.response.HomeSearchCategoryPageResponse; @@ -63,6 +63,7 @@ public class HomeService implements HomeUseCase { */ private static final int HOME_SEARCH_PREVIEW_LIMIT = 5; private static final int HOME_SEARCH_CATEGORY_PAGE_SIZE = 5; + private static final String RECOMMENDED_NOTICES_TITLE = "진단 기반 추천"; private final NoticeDocumentRepository noticeRepository; private final ComplexDocumentRepository complexRepository; private final LikeQueryUseCase likeService; @@ -403,8 +404,12 @@ public HomeNoticeListResponse getRecommendedNoticesByDiagnosis( // 2. 진단 기록 없음 처리 if (diagnosis == null) { - log.warn("진단 기록이 없습니다 - userId={}", sanitize(userId)); - throw new CustomException(DiagnosisErrorCode.NOT_FOUND_DIAGNOSIS); + log.info("진단 기록이 없어 추천 공고를 비워서 반환합니다 - userId={}", sanitize(userId)); + return emptyRecommendedNoticeResponse( + false, + HomeNoticeEmptyReason.NO_DIAGNOSIS, + "진단 기록이 없어 추천 공고를 제공할 수 없습니다." + ); } // 3. 추천 임대주택 유형 추출 @@ -415,13 +420,11 @@ public HomeNoticeListResponse getRecommendedNoticesByDiagnosis( || availableRentalTypes.isEmpty() || availableRentalTypes.contains("해당 없음")) { log.info("추천 가능한 임대주택이 없습니다 - userId={}", sanitize(userId)); - return HomeNoticeListResponse.builder() - .region(null) - .title("진단 기반 추천") - .content(List.of()) - .hasNext(false) - .totalElements(0L) - .build(); + return emptyRecommendedNoticeResponse( + true, + HomeNoticeEmptyReason.NO_ELIGIBLE_RENTAL_TYPES, + "진단 결과상 추천 가능한 임대주택 유형이 없습니다." + ); } // 5. 진단 결과 → 공고 supplyType 매핑 @@ -433,13 +436,11 @@ public HomeNoticeListResponse getRecommendedNoticesByDiagnosis( // 진단 결과에 매핑될 공고 유형이 없는 경우 빈 응답 반환 if (targetSupplyTypes.isEmpty()) { log.info("진단 결과에 매핑 가능한 주택 유형이 없습니다 - userId={}", sanitize(userId)); - return HomeNoticeListResponse.builder() - .region(null) - .title("진단 기반 추천") - .content(List.of()) - .hasNext(false) - .totalElements(0L) - .build(); + return emptyRecommendedNoticeResponse( + true, + HomeNoticeEmptyReason.NO_MAPPED_SUPPLY_TYPES, + "진단 결과를 공고 필터 조건으로 변환할 수 없습니다." + ); } log.debug("진단 기반 필터링 - rentalTypes={}, supplyTypes={}", @@ -455,6 +456,16 @@ public HomeNoticeListResponse getRecommendedNoticesByDiagnosis( pageable ); + if (page.isEmpty()) { + log.info("진단 결과에 맞는 공고가 없습니다 - userId={}, supplyTypes={}", + sanitize(userId), sanitize(targetSupplyTypes)); + return emptyRecommendedNoticeResponse( + true, + HomeNoticeEmptyReason.NO_MATCHING_NOTICES, + "진단 결과에 맞는 공고가 없습니다." + ); + } + // 8. 좋아요 상태 조회 List likedNoticeIds = likeService.getLikeNoticeIds(userId); @@ -469,10 +480,28 @@ public HomeNoticeListResponse getRecommendedNoticesByDiagnosis( // 10. 최종 응답 return HomeNoticeListResponse.builder() .region(null) - .title("진단 기반 추천") + .title(RECOMMENDED_NOTICES_TITLE) .content(content) .hasNext(page.hasNext()) .totalElements(page.getTotalElements()) + .hasDiagnosis(true) + .build(); + } + + private HomeNoticeListResponse emptyRecommendedNoticeResponse( + boolean hasDiagnosis, + HomeNoticeEmptyReason emptyReason, + String emptyMessage + ) { + return HomeNoticeListResponse.builder() + .region(null) + .title(RECOMMENDED_NOTICES_TITLE) + .content(List.of()) + .hasNext(false) + .totalElements(0L) + .hasDiagnosis(hasDiagnosis) + .emptyReason(emptyReason) + .emptyMessage(emptyMessage) .build(); } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/pinpoint/application/service/PinPointService.java b/module-domain/src/main/java/co/kr/pinhouse/domain/pinpoint/application/service/PinPointService.java index afda1767..7a545df8 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/pinpoint/application/service/PinPointService.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/pinpoint/application/service/PinPointService.java @@ -43,9 +43,11 @@ public void savePinPoint(UUID userId, PinPointRequest request) { /// 유저 검증 User user = userService.loadUser(userId); + boolean isFirstPinPoint = repository.countByUserId(user.getId().toString()) == 0; + boolean shouldSetFirst = isFirstPinPoint || request.first(); - /// 새로운 핀포인트가 first=true인 경우, 기존 first=true인 핀포인트를 false로 변경 - if (request.first()) { + /// 최초 저장이거나 새로운 핀포인트가 first=true인 경우, 새 핀포인트를 대표로 설정 + if (shouldSetFirst && !isFirstPinPoint) { Optional existingFirstPinPoint = repository.findByUserIdAndIsFirst(user.getId().toString(), true); existingFirstPinPoint.ifPresent(pinPoint -> { pinPoint.setFirst(false); @@ -58,7 +60,7 @@ public void savePinPoint(UUID userId, PinPointRequest request) { /// 도메인 생성 var entity = PinPoint.of(user.getId().toString(), request.address(), request.name(), location.getLatitude(), - location.getLongitude(), request.first()); + location.getLongitude(), shouldSetFirst); /// 저장하기 repository.save(entity); diff --git a/module-presentation/src/main/java/co/kr/pinhouse/domain/home/HomeApiSpec.java b/module-presentation/src/main/java/co/kr/pinhouse/domain/home/HomeApiSpec.java index 65d7b18a..0725f86d 100644 --- a/module-presentation/src/main/java/co/kr/pinhouse/domain/home/HomeApiSpec.java +++ b/module-presentation/src/main/java/co/kr/pinhouse/domain/home/HomeApiSpec.java @@ -98,20 +98,16 @@ ApiResponse getNoticeCountWithinTravelTime( description = "사용자의 최근 청약 진단 결과를 기반으로 추천 공고를 조회하는 API입니다. " + "진단 결과의 신청가능한 임대주택 유형(availableRentalTypes)을 기준으로 공고를 필터링하며, " + "마감임박순으로 정렬됩니다. 모든 공고 상태(모집중 + 마감)를 포함합니다. " - + "진단 기록이 없는 경우 404 에러가 발생합니다. " - + "진단 결과 자격이 없는 경우(해당 없음) 200 OK와 함께 빈 리스트를 반환합니다." + + "진단 기록이 없는 경우에도 200 OK와 함께 빈 리스트를 반환하며, " + + "`hasDiagnosis=false`, `emptyReason=NO_DIAGNOSIS` 로 상태를 구분합니다. " + + "진단 결과상 추천 가능한 유형이 없거나, 추천 조건에 맞는 공고가 없는 경우에도 200 OK와 함께 빈 리스트를 반환합니다." ) @ApiResponses(value = { @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", - description = "조회 성공 (진단 결과에 맞는 공고가 없어도 200 반환)", + description = "조회 성공 (진단 기록이 없거나, 추천 결과가 비어 있어도 200 반환)", content = @Content(schema = @Schema(implementation = HomeNoticeListResponse.class)) ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "404", - description = "진단 기록을 찾을 수 없음", - content = @Content(schema = @Schema(implementation = ApiResponse.class)) - ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "401", description = "인증 실패 (로그인 필요)",