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 new file mode 100644 index 0000000..ab8e2e8 --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/home/application/service/HomeService.java @@ -0,0 +1,185 @@ +package com.pinHouse.server.platform.home.application.service; + +import com.pinHouse.server.core.exception.code.PinPointErrorCode; +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.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; +import com.pinHouse.server.platform.pinPoint.application.usecase.PinPointUseCase; +import com.pinHouse.server.platform.pinPoint.domain.entity.PinPoint; +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; +import com.pinHouse.server.platform.search.application.usecase.NoticeSearchUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.UUID; + +/** + * 홈 화면 서비스 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class HomeService implements HomeUseCase { + + private final NoticeDocumentRepository noticeRepository; + private final LikeQueryUseCase likeService; + private final NoticeSearchUseCase noticeSearchService; + private final PinPointUseCase pinPointService; + + /** + * 마감임박공고 조회 (PinPoint 지역 기반) + * - PinPoint의 address에서 광역 단위와 시/군/구를 추출하여 해당 지역의 마감임박 공고를 조회 + */ + @Override + public SliceResponse getDeadlineApproachingNotices( + String pinpointId, + SliceRequest sliceRequest, + UUID userId + ) { + // PinPoint 소유자 검증 + boolean isOwner = pinPointService.checkPinPoint(pinpointId, userId); + if (!isOwner) { + log.warn("PinPoint 소유자 불일치 - pinpointId={}, requestUserId={}", pinpointId, userId); + throw new CustomException(PinPointErrorCode.BAD_REQUEST_PINPOINT); + } + + // PinPoint 조회 + PinPoint pinPoint = pinPointService.loadPinPoint(pinpointId); + + // PinPoint의 address에서 광역 단위(Region)와 시/군/구 추출 + NoticeListRequest.Region region = extractRegionFromAddress(pinPoint.getAddress()); + String county = extractCountyFromAddress(pinPoint.getAddress()); + + // 현재 시각 (한국 시간 기준) + Instant now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toInstant(); + + // 페이징 설정 (마감임박순 정렬) + Sort sort = Sort.by(Sort.Order.asc("applyEnd"), Sort.Order.asc("noticeId")); + Pageable pageable = PageRequest.of(sliceRequest.page() - 1, sliceRequest.offSet(), sort); + + // Repository에서 직접 조회 + Page page = noticeRepository.findDeadlineApproachingNoticesByRegionAndCounty( + region.getFullName(), + county, + pageable, + now + ); + + // 좋아요 상태 조회 + List likedNoticeIds = likeService.getLikeNoticeIds(userId); + + // DTO 변환 + List content = page.getContent().stream() + .map(notice -> { + boolean isLiked = likedNoticeIds.contains(notice.getId()); + return NoticeListResponse.from(notice, isLiked); + }) + .toList(); + + return SliceResponse.from(new SliceImpl<>(content, pageable, page.hasNext()), page.getTotalElements()); + } + + /** + * 주소에서 시/군/구를 추출 + * @param address 전체 주소 (예: "경기도 성남시 분당구 정자동" 또는 "서울특별시 강남구 역삼동") + * @return 시/군/구 이름 (예: "성남시", "강남구") + */ + private String extractCountyFromAddress(String address) { + if (address == null || address.isBlank()) { + return null; + } + + // 공백으로 주소를 분리 + String[] parts = address.trim().split("\\s+"); + + // 광역 단위를 제외한 나머지 부분에서 시/군/구를 찾기 + for (int i = 1; i < parts.length; i++) { + String part = parts[i]; + // "시", "군", "구"로 끝나는 첫 번째 토큰을 반환 + if (part.endsWith("시") || part.endsWith("군") || part.endsWith("구")) { + return part; + } + } + + // 시/군/구를 찾지 못한 경우 null 반환 (광역시 등의 경우) + log.debug("주소에서 시/군/구를 추출할 수 없습니다. address={}", address); + return null; + } + + /** + * 주소에서 광역 단위(시/도)를 추출 + * @param address 전체 주소 (예: "서울특별시 강남구 역삼동" 또는 "서울 강남구 역삼동") + * @return Region enum + */ + private NoticeListRequest.Region extractRegionFromAddress(String address) { + if (address == null || address.isBlank()) { + throw new CustomException(PinPointErrorCode.BAD_REQUEST_PINPOINT); + } + + // 1차: 정확한 fullName으로 매칭 시도 + for (NoticeListRequest.Region region : NoticeListRequest.Region.values()) { + if (address.startsWith(region.getFullName())) { + return region; + } + } + + // 2차: 약칭으로 매칭 시도 (예: "서울" -> "서울특별시") + if (address.startsWith("서울")) return NoticeListRequest.Region.SEOUL; + if (address.startsWith("부산")) return NoticeListRequest.Region.BUSAN; + if (address.startsWith("대구")) return NoticeListRequest.Region.DAEGU; + if (address.startsWith("인천")) return NoticeListRequest.Region.INCHEON; + if (address.startsWith("광주")) return NoticeListRequest.Region.GWANGJU; + if (address.startsWith("대전")) return NoticeListRequest.Region.DAEJEON; + if (address.startsWith("울산")) return NoticeListRequest.Region.ULSAN; + if (address.startsWith("세종")) return NoticeListRequest.Region.SEJONG; + if (address.startsWith("경기")) return NoticeListRequest.Region.GYEONGGI; + if (address.startsWith("강원")) return NoticeListRequest.Region.GANGWON; + if (address.startsWith("충북") || address.startsWith("충청북")) return NoticeListRequest.Region.CHUNGBUK; + if (address.startsWith("충남") || address.startsWith("충청남")) return NoticeListRequest.Region.CHUNGNAM; + if (address.startsWith("전북") || address.startsWith("전라북")) return NoticeListRequest.Region.JEONBUK; + if (address.startsWith("전남") || address.startsWith("전라남")) return NoticeListRequest.Region.JEONNAM; + if (address.startsWith("경북") || address.startsWith("경상북")) return NoticeListRequest.Region.GYEONGBUK; + if (address.startsWith("경남") || address.startsWith("경상남")) return NoticeListRequest.Region.GYEONGNAM; + if (address.startsWith("제주")) return NoticeListRequest.Region.JEJU; + + // 매칭되는 Region을 찾지 못한 경우 + log.warn("주소에서 광역 단위를 추출할 수 없습니다. address={}", address); + throw new CustomException(PinPointErrorCode.BAD_REQUEST_PINPOINT); + } + + /** + * 통합 검색 (공고 제목 및 타겟 그룹 기반) + * - NoticeSearchUseCase를 그대로 활용 + */ + @Override + public SliceResponse searchNoticesIntegrated( + String keyword, + int page, + int size, + NoticeSearchSortType sortType, + NoticeSearchFilterType status, + UUID userId + ) { + return noticeSearchService.searchNotices(keyword, page, size, sortType, status, userId); + } +} 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 new file mode 100644 index 0000000..7a721d7 --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/home/application/usecase/HomeUseCase.java @@ -0,0 +1,49 @@ +package com.pinHouse.server.platform.home.application.usecase; + +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.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; + +import java.util.UUID; + +/** + * 홈 화면 Use Case + */ +public interface HomeUseCase { + + /** + * 마감임박공고 조회 (PinPoint 지역 기반) + * @param pinpointId PinPoint ID (해당 지역의 공고를 조회) + * @param sliceRequest 페이징 정보 + * @param userId 사용자 ID (좋아요 정보 조회용, null 가능) + * @return 해당 지역의 마감임박순으로 정렬된 공고 목록 + */ + SliceResponse getDeadlineApproachingNotices( + String pinpointId, + SliceRequest sliceRequest, + UUID userId + ); + + /** + * 통합 검색 (공고 제목 및 타겟 그룹 기반) + * @param keyword 검색 키워드 + * @param page 페이지 번호 (1부터 시작) + * @param size 페이지 크기 + * @param sortType 정렬 방식 (LATEST: 최신공고순, END: 마감임박순) + * @param status 공고 상태 (ALL: 전체, RECRUITING: 모집중) + * @param userId 사용자 ID (좋아요 정보 조회용, null 가능) + * @return 검색 결과 (무한 스크롤 응답) + */ + SliceResponse searchNoticesIntegrated( + String keyword, + int page, + int size, + NoticeSearchSortType sortType, + NoticeSearchFilterType status, + 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 new file mode 100644 index 0000000..3ea87b4 --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/home/presentation/HomeApi.java @@ -0,0 +1,86 @@ +package com.pinHouse.server.platform.home.presentation; + +import com.pinHouse.server.core.aop.CheckLogin; +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.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; +import com.pinHouse.server.security.oauth2.domain.PrincipalDetails; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +/** + * 홈 화면 API + */ +@Slf4j +@RestController +@RequestMapping("/v1/home") +@RequiredArgsConstructor +public class HomeApi implements HomeApiSpec { + + private final HomeUseCase homeService; + + /** + * 마감임박공고 조회 (PinPoint 지역 기반) + * GET /v1/home/deadline-approaching?pinpointId=xxx&page=1&offSet=20 + * 로그인 필수 + */ + @Override + @CheckLogin + @GetMapping("/notice") + public ApiResponse> getDeadlineApproachingNotices( + @RequestParam String pinpointId, + SliceRequest sliceRequest, + @AuthenticationPrincipal PrincipalDetails principalDetails + ) { + // @CheckLogin에 의해 principalDetails는 항상 non-null + UUID userId = principalDetails.getId(); + + // 서비스 호출 + SliceResponse response = homeService.getDeadlineApproachingNotices( + pinpointId, + sliceRequest, + userId + ); + + return ApiResponse.ok(response); + } + + /** + * 통합 검색 (공고 제목 및 타겟 그룹 기반) + * GET /v1/home/search?q=키워드&page=1&offSet=20&sortType=LATEST&status=ALL + */ + @Override + @GetMapping("/search") + public ApiResponse> searchNoticesIntegrated( + @RequestParam String q, + SliceRequest sliceRequest, + @RequestParam(required = false, defaultValue = "LATEST") NoticeSearchSortType sortType, + @RequestParam(required = false, defaultValue = "ALL") NoticeSearchFilterType status, + @AuthenticationPrincipal PrincipalDetails principalDetails + ) { + // 로그인하지 않은 경우 userId는 null + UUID userId = (principalDetails != null) ? principalDetails.getId() : null; + + // 서비스 호출 + SliceResponse response = homeService.searchNoticesIntegrated( + q, + sliceRequest.page(), + sliceRequest.offSet(), + sortType, + status, + userId + ); + + return ApiResponse.ok(response); + } +} 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 new file mode 100644 index 0000000..9898efd --- /dev/null +++ b/src/main/java/com/pinHouse/server/platform/home/presentation/swagger/HomeApiSpec.java @@ -0,0 +1,56 @@ +package com.pinHouse.server.platform.home.presentation.swagger; + +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.search.application.dto.NoticeSearchFilterType; +import com.pinHouse.server.platform.search.application.dto.NoticeSearchResultResponse; +import com.pinHouse.server.platform.search.application.dto.NoticeSearchSortType; +import com.pinHouse.server.security.oauth2.domain.PrincipalDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "홈 API", description = "홈 화면 관련 API 입니다") +public interface HomeApiSpec { + + @Operation( + summary = "마감임박공고 조회 API (PinPoint 지역 기반) - 로그인 필수", + description = "PinPoint의 지역을 기반으로 마감임박순으로 정렬된 공고 목록을 조회하는 API 입니다. " + + "PinPoint의 주소에서 광역 단위(시/도)를 추출하여 해당 지역의 모집중인 공고만 조회합니다. " + + "본인의 PinPoint만 사용 가능하며, 다른 사용자의 PinPoint ID를 사용하면 400 에러가 발생합니다. " + + "좋아요 정보가 포함됩니다." + ) + ApiResponse> getDeadlineApproachingNotices( + @Parameter(description = "PinPoint ID", example = "83ec36ce-8fc1-4f62-8983-397c2729fc22") + @RequestParam String pinpointId, + + SliceRequest sliceRequest, + + @AuthenticationPrincipal PrincipalDetails principalDetails + ); + + @Operation( + summary = "통합 검색 API", + description = "공고 제목 및 타겟 그룹을 기반으로 검색하는 통합 검색 API 입니다. " + + "키워드를 입력하면 공고 제목과 모집 대상에서 검색하여 결과를 반환합니다. " + + "정렬 방식과 공고 상태 필터를 적용할 수 있으며, 로그인한 사용자의 경우 좋아요 정보가 포함됩니다." + ) + ApiResponse> searchNoticesIntegrated( + @Parameter(description = "검색 키워드", example = "청년") + @RequestParam String q, + + SliceRequest sliceRequest, + + @Parameter(description = "정렬 방식 (LATEST: 최신공고순, END: 마감임박순)", example = "LATEST") + @RequestParam(required = false, defaultValue = "LATEST") NoticeSearchSortType sortType, + + @Parameter(description = "공고 상태 (ALL: 전체, RECRUITING: 모집중)", example = "ALL") + @RequestParam(required = false, defaultValue = "ALL") NoticeSearchFilterType status, + + @AuthenticationPrincipal PrincipalDetails principalDetails + ); +} diff --git a/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/DepositMinMaxResponse.java b/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/DepositMinMaxResponse.java index 812f11e..cf0a9b3 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/DepositMinMaxResponse.java +++ b/src/main/java/com/pinHouse/server/platform/housing/complex/application/dto/response/DepositMinMaxResponse.java @@ -3,16 +3,27 @@ import io.swagger.v3.oas.annotations.media.Schema; public record DepositMinMaxResponse( - @Schema(description = "보증금 합계", example = "50000000") + @Schema(description = "보증금 합계 (만원 단위)", example = "5000") long total, - @Schema(description = "계약금", example = "50000000") + @Schema(description = "계약금 (만원 단위)", example = "5000") long contract, - @Schema(description = "잔금", example = "50000000") + @Schema(description = "잔금 (만원 단위)", example = "5000") long balance, - @Schema(description = "월 임대료", example = "500000") + @Schema(description = "월 임대료 (원)", example = "500000") long monthPay ) { + /** + * 원본 값(원 단위)을 받아서 보증금은 만원 단위로, 월세는 원 단위로 변환 + */ + public static DepositMinMaxResponse fromWon(long totalWon, long contractWon, long balanceWon, long monthPayWon) { + return new DepositMinMaxResponse( + totalWon / 10000, + contractWon / 10000, + balanceWon / 10000, + monthPayWon // 월세는 원 단위 그대로 + ); + } } 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 b0a7449..182194d 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 @@ -343,7 +343,7 @@ public DepositResponse getLeaseMinMax(String complexId, String type) { // 1) 기본 (NORMAL OPTION) // =================================== - DepositMinMaxResponse normalOption = new DepositMinMaxResponse( + DepositMinMaxResponse normalOption = DepositMinMaxResponse.fromWon( totalDepositBase, contractBase, balanceBase, @@ -378,7 +378,7 @@ public DepositResponse getLeaseMinMax(String complexId, String type) { long maxMonthRent = monthRentBase + actualRentIncrease; long minDepositContract = Math.max(0, minDepositTotal - balanceBase); - DepositMinMaxResponse minOption = new DepositMinMaxResponse( + DepositMinMaxResponse minOption = DepositMinMaxResponse.fromWon( minDepositTotal, minDepositContract, balanceBase, @@ -409,7 +409,7 @@ public DepositResponse getLeaseMinMax(String complexId, String type) { long minMonthRent = monthRentBase - actualRentReduce; long maxDepositContract = Math.max(0, maxDepositTotal - balanceBase); - DepositMinMaxResponse maxOption = new DepositMinMaxResponse( + DepositMinMaxResponse maxOption = DepositMinMaxResponse.fromWon( maxDepositTotal, maxDepositContract, balanceBase, 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 445db64..f4e3749 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 @@ -322,23 +322,26 @@ private List toStepResponses(RootResult rout if (transportSteps.isEmpty()) { // WALK만 있는 경로 (드문 케이스) for (RootResult.DistanceStep step : distanceSteps) { - steps.add(createWalkStep(step, null)); + steps.add(createWalkStep(step, null, null, false)); } return assignStepIndexes(steps); } - // 1. DEPART (출발) 추가 + // 출발지 정보 (첫 번째 WALK에 사용) RootResult.DistanceStep firstTransport = transportSteps.get(0); - steps.add(createDepartStep(firstTransport.startName())); + String departureLocation = firstTransport.startName(); - // 2. 전체 구간 순회하며 steps 생성 + // 전체 구간 순회하며 steps 생성 int transportIndex = 0; + boolean isFirstWalk = true; + for (int i = 0; i < distanceSteps.size(); i++) { RootResult.DistanceStep step = distanceSteps.get(i); if (step.type() == RootResult.TransportType.WALK) { - // WALK step 추가 (색상 포함) - steps.add(createWalkStep(step, ChipType.WALK)); + // 첫 번째 WALK는 출발지 정보 포함 + steps.add(createWalkStep(step, ChipType.WALK, departureLocation, isFirstWalk)); + isFirstWalk = false; } else { // 교통수단: BOARD + ALIGHT 추가 (색상 포함) ChipType chipType = mapType(step.type()); @@ -354,14 +357,14 @@ private List toStepResponses(RootResult rout } } - // 3. ARRIVE (도착) 추가 + // 도착지 추가 RootResult.DistanceStep lastTransport = transportSteps.get(transportSteps.size() - 1); steps.add(createArriveStep(lastTransport.endName())); - // 4. minutes가 0인 step 필터링 (DEPART/ARRIVE/ALIGHT는 null이므로 유지) + // minutes가 0인 step 필터링 (ARRIVE/ALIGHT는 null이므로 유지) List filteredSteps = steps.stream() .filter(step -> { - // minutes가 null이면 유지 (DEPART, ARRIVE, ALIGHT) + // minutes가 null이면 유지 (ARRIVE, ALIGHT) if (step.minutes() == null) { return true; } @@ -373,35 +376,33 @@ private List toStepResponses(RootResult rout return assignStepIndexes(filteredSteps); } - /** - * DEPART step 생성 - */ - private TransitRoutesResponse.StepResponse createDepartStep(String stopName) { - return TransitRoutesResponse.StepResponse.builder() - .stepIndex(0) - .action(TransitRoutesResponse.StepAction.DEPART) - .type(null) - .stopName(stopName) - .primaryText(stopName) - .secondaryText("출발") - .minutes(null) - .colorHex(null) - .line(null) - .build(); - } - /** * WALK step 생성 + * UI에서는 중간 POI를 숨기고 행동 중심 정보만 표시 + * 첫 번째 WALK인 경우 출발지 정보 포함 */ - private TransitRoutesResponse.StepResponse createWalkStep(RootResult.DistanceStep step, ChipType chipType) { + private TransitRoutesResponse.StepResponse createWalkStep( + RootResult.DistanceStep step, + ChipType chipType, + String departureLocation, + boolean isFirstWalk) { + String colorHex = (chipType != null) ? chipType.defaultBg : ChipType.WALK.defaultBg; + // 첫 번째 WALK는 출발지 포함, 나머지는 "도보 약 n분"만 + String primaryText; + if (isFirstWalk && departureLocation != null) { + primaryText = departureLocation + "에서 도보 약 " + step.time() + "분"; + } else { + primaryText = "도보 약 " + step.time() + "분"; + } + return TransitRoutesResponse.StepResponse.builder() .stepIndex(0) .action(TransitRoutesResponse.StepAction.WALK) .type("WALK") - .stopName(null) - .primaryText("도보 이동") + .stopName(step.startName()) // 내부 로직용 유지 + .primaryText(primaryText) .secondaryText(null) .minutes(step.time()) .colorHex(colorHex) diff --git a/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/ComplexFilterResponse.java b/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/ComplexFilterResponse.java index 0bb2e62..e9c30ca 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/ComplexFilterResponse.java +++ b/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/ComplexFilterResponse.java @@ -51,13 +51,13 @@ public record District( @Builder @Schema(name = "가격 필터", description = "단지의 가격 범위 및 분포 정보") public record CostFilter( - @Schema(description = "최소 가격 (보증금)", example = "5000000") + @Schema(description = "최소 가격 (보증금, 만원 단위)", example = "500") long minPrice, - @Schema(description = "최대 가격 (보증금)", example = "150000000") + @Schema(description = "최대 가격 (보증금, 만원 단위)", example = "15000") long maxPrice, - @Schema(description = "평균 가격 (보증금)", example = "45000000") + @Schema(description = "평균 가격 (보증금, 만원 단위)", example = "4500") long avgPrice, @Schema(description = "가격 분포 (최대 20개 구간)") @@ -70,10 +70,10 @@ public record CostFilter( @Builder @Schema(name = "가격 분포 구간", description = "특정 가격 범위에 속하는 유닛 개수") public record PriceDistribution( - @Schema(description = "구간 시작 가격", example = "10000000") + @Schema(description = "구간 시작 가격 (만원 단위)", example = "1000") long rangeStart, - @Schema(description = "구간 종료 가격", example = "20000000") + @Schema(description = "구간 종료 가격 (만원 단위)", example = "2000") long rangeEnd, @Schema(description = "해당 구간에 속하는 유닛 개수", example = "45") 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 37c554d..b023398 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 @@ -52,7 +52,7 @@ public record NoticeDetailFilterRequest( public enum DetailSortType { KM("거리 순"), - INFRA("생활태그 매칭순"); + INFRA("주변환경 매칭순"); private final String label; diff --git a/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/NoticeDetailFilteredResponse.java b/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/NoticeDetailFilteredResponse.java index d446a2d..e28313a 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/NoticeDetailFilteredResponse.java +++ b/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/NoticeDetailFilteredResponse.java @@ -97,7 +97,10 @@ public record NoticeBasicInfo( String name, @Schema(description = "모집일정", example = "2025년 10월 ~ 11월") - String period + String period, + + @Schema(description = "대상 계층", example = "[\"청년\", \"신혼부부\"]") + List targetGroups ) { public static NoticeBasicInfo from(NoticeDocument notice) { String period = DateUtil.formatDate(notice.getApplyStart(), notice.getApplyEnd()); @@ -108,6 +111,7 @@ public static NoticeBasicInfo from(NoticeDocument notice) { .period(period) .type(notice.getSupplyType()) .housingType(notice.getHouseType()) + .targetGroups(notice.getTargetGroups()) .build(); } } diff --git a/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/NoticeDetailResponse.java b/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/NoticeDetailResponse.java index 7fc9d34..b88fc8a 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/NoticeDetailResponse.java +++ b/src/main/java/com/pinHouse/server/platform/housing/notice/application/dto/NoticeDetailResponse.java @@ -31,6 +31,9 @@ public record NoticeDetailResponse( @Schema(description = "모집일정", example = "2025년 10월 ~ 11월") String period, + @Schema(description = "대상 계층", example = "[\"청년\", \"신혼부부\"]") + List targetGroups, + @Schema(description = "전체 임대주택 개수", example = "6") long totalCount, @@ -51,6 +54,7 @@ public static NoticeDetailResponse from(NoticeDocument notice, List distribution = calculatePriceDistribution(allPrices, minPrice, maxPrice); + // 만 단위로 변환하여 반환 return ComplexFilterResponse.CostFilter.builder() - .minPrice(minPrice) - .maxPrice(maxPrice) - .avgPrice(avgPrice) + .minPrice(minPrice / 10000) + .maxPrice(maxPrice / 10000) + .avgPrice(avgPrice / 10000) .priceDistribution(distribution) .build(); } @@ -424,8 +425,8 @@ private List calculatePriceDistribution ) { if (prices.isEmpty() || minPrice == maxPrice) { return List.of(ComplexFilterResponse.PriceDistribution.builder() - .rangeStart(minPrice) - .rangeEnd(maxPrice) + .rangeStart(minPrice / 10000) + .rangeEnd(maxPrice / 10000) .count(prices.size()) .build()); } @@ -446,7 +447,7 @@ private List calculatePriceDistribution bucketCounts.merge(bucketIndex, 1L, Long::sum); } - // PriceDistribution 리스트 생성 + // PriceDistribution 리스트 생성 (만 단위로 변환) List distributions = new ArrayList<>(); for (int i = 0; i < bucketCount; i++) { long rangeStart = minPrice + (i * bucketSize); @@ -454,8 +455,8 @@ private List calculatePriceDistribution long count = bucketCounts.getOrDefault(i, 0L); distributions.add(ComplexFilterResponse.PriceDistribution.builder() - .rangeStart(rangeStart) - .rangeEnd(rangeEnd) + .rangeStart(rangeStart / 10000) + .rangeEnd(rangeEnd / 10000) .count(count) .build()); } diff --git a/src/main/java/com/pinHouse/server/platform/housing/notice/domain/repository/NoticeDocumentRepositoryCustom.java b/src/main/java/com/pinHouse/server/platform/housing/notice/domain/repository/NoticeDocumentRepositoryCustom.java index 6dbb951..3a25f82 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/notice/domain/repository/NoticeDocumentRepositoryCustom.java +++ b/src/main/java/com/pinHouse/server/platform/housing/notice/domain/repository/NoticeDocumentRepositoryCustom.java @@ -47,4 +47,21 @@ public interface NoticeDocumentRepositoryCustom { */ long countByTitle(String keyword, boolean filterOpen, Instant now); + /** + * 지역(Region)과 시/군/구(County) 기반 마감임박 공고 조회 + * HomeService의 마감임박 공고 조회 전용 메서드 + * + * @param region 광역 단위 (예: "경기도") + * @param county 시/군/구 (예: "성남시", null 가능) + * @param pageable 페이징 정보 + * @param now 현재 시각 + * @return 마감임박순으로 정렬된 모집중인 공고 목록 + */ + Page findDeadlineApproachingNoticesByRegionAndCounty( + String region, + String county, + Pageable pageable, + Instant now + ); + } diff --git a/src/main/java/com/pinHouse/server/platform/housing/notice/domain/repository/NoticeDocumentRepositoryImpl.java b/src/main/java/com/pinHouse/server/platform/housing/notice/domain/repository/NoticeDocumentRepositoryImpl.java index 94101e6..52b140f 100644 --- a/src/main/java/com/pinHouse/server/platform/housing/notice/domain/repository/NoticeDocumentRepositoryImpl.java +++ b/src/main/java/com/pinHouse/server/platform/housing/notice/domain/repository/NoticeDocumentRepositoryImpl.java @@ -168,4 +168,41 @@ public long countByTitle(String keyword, boolean filterOpen, Instant now) { return mongoTemplate.count(query, NoticeDocument.class); } + @Override + public Page findDeadlineApproachingNoticesByRegionAndCounty( + String region, + String county, + Pageable pageable, + Instant now + ) { + Criteria criteria = new Criteria(); + + /// 오늘의 시작 시각 계산 (오늘까지 포함되도록) + Instant todayStart = ZonedDateTime.ofInstant(now, ZoneId.of("Asia/Seoul")) + .toLocalDate() + .atStartOfDay(ZoneId.of("Asia/Seoul")) + .toInstant(); + + /// 모집중인 공고만 조회 (마감일이 오늘 이후) + criteria.and("applyEnd").gte(todayStart); + + /// 광역 단위 필터링 + if (region != null && !region.isBlank()) { + criteria.and("city").is(region); + } + + /// 시/군/구 필터링 (부분 일치: "성남시"로 "성남시 수정구", "성남시 분당구" 등 매칭) + if (county != null && !county.isBlank()) { + criteria.and("county").regex("^" + county); + } + + Query query = new Query(criteria).with(pageable); + + /// 실행 및 Page 응답 구성 + List notices = mongoTemplate.find(query, NoticeDocument.class); + long count = mongoTemplate.count(Query.of(query).limit(-1).skip(-1), NoticeDocument.class); + + return new PageImpl<>(notices, pageable, count); + } + } diff --git a/src/main/java/com/pinHouse/server/platform/like/application/dto/UnityTypeLikeResponse.java b/src/main/java/com/pinHouse/server/platform/like/application/dto/UnityTypeLikeResponse.java index fda2d88..6aaff78 100644 --- a/src/main/java/com/pinHouse/server/platform/like/application/dto/UnityTypeLikeResponse.java +++ b/src/main/java/com/pinHouse/server/platform/like/application/dto/UnityTypeLikeResponse.java @@ -13,8 +13,8 @@ public record UnityTypeLikeResponse( String typeCode, // 공급유형 (예: 26A) Integer quota, // 모집호수 정보 Double exclusiveAreaM2, // 전용면적 - Long deposit, // 임대보증금 - Integer monthlyRent, // 월임대료(원) + Long deposit, // 임대보증금 (만원 단위) + Integer monthlyRent, // 월임대료 (원) boolean liked ) { @@ -30,7 +30,7 @@ public static UnityTypeLikeResponse from(ComplexDocument document) { .typeCode(unitType.getTypeCode()) .quota(unitType.getQuota().getTotal()) .exclusiveAreaM2(unitType.getExclusiveAreaM2()) - .deposit(unitType.getDeposit().getTotal()) + .deposit(unitType.getDeposit().getTotal() / 10000) .monthlyRent(unitType.getMonthlyRent()) .liked(true) // 여기는 나의 좋아요만 보는 곳이기 때문 .build(); 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 34417b7..83c5684 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 @@ -16,8 +16,13 @@ public record FastUnitTypeResponse( String typeCode, // 방 유형 String typeId, // 방 ID String heating, // 난방방식 - long deposit, // 보증금 - long monthPayment, // 월임대료 + + @Schema(description = "보증금 (만원 단위)", example = "5000") + long deposit, + + @Schema(description = "월 임대료 (원)", example = "300000") + long monthPayment, + double size, // 면적 Integer totalSupplyInNotice, // 공급호수합계 @@ -50,7 +55,7 @@ public static FastUnitTypeResponse from(ComplexDistanceResponse complexDistanceR .complexName(complexDocument.getName()) .typeCode(unitType.getTypeCode()) .heating(complexDocument.getHeating()) - .deposit(unitType.getDeposit().getTotal()) + .deposit(unitType.getDeposit().getTotal() / 10000) .monthPayment(unitType.getMonthlyRent()) .size(unitType.getExclusiveAreaM2()) .totalSupplyInNotice(unitType.getQuota().getTotal()) diff --git a/src/main/java/com/pinHouse/server/platform/search/application/dto/NoticeSearchResultResponse.java b/src/main/java/com/pinHouse/server/platform/search/application/dto/NoticeSearchResultResponse.java index 5564c88..75de2dd 100644 --- a/src/main/java/com/pinHouse/server/platform/search/application/dto/NoticeSearchResultResponse.java +++ b/src/main/java/com/pinHouse/server/platform/search/application/dto/NoticeSearchResultResponse.java @@ -5,6 +5,7 @@ import lombok.Builder; import java.time.LocalDate; +import java.util.List; /** * 공고 검색 결과 단일 항목 응답 DTO @@ -36,6 +37,9 @@ public record NoticeSearchResultResponse( @Schema(description = "신청 종료일", example = "2024-01-24") LocalDate applyEnd, + @Schema(description = "모집 대상 그룹", example = "[\"청년\", \"신혼부부\"]") + List targetGroups, + @Schema(description = "좋아요 여부", example = "true") boolean liked ) { @@ -53,6 +57,7 @@ public static NoticeSearchResultResponse from(NoticeDocument notice, boolean lik .announceDate(notice.getAnnounceDate()) .applyStart(notice.getApplyStart()) .applyEnd(notice.getApplyEnd()) + .targetGroups(notice.getTargetGroups()) .liked(liked) .build(); }