From b2f9e504893cb0b2f0bd795977d2deabb25bc745 Mon Sep 17 00:00:00 2001 From: eedo_y Date: Tue, 28 Apr 2026 15:27:08 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=EC=8B=9C=EC=99=B8=20=EB=8C=80?= =?UTF-8?q?=EC=A4=91=EA=B5=90=ED=86=B5=20=EA=B8=B0=EB=B0=98=EC=97=90=20API?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=ED=98=B8=EC=B6=9C=20(#136)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/ComplexService.java | 31 ++++- .../util/InterCityResultParser.java | 4 + .../util/IntraCityResultParser.java | 4 + .../util/TransitResponseMapper.java | 46 +++++--- .../complex/domain/transit/RootResult.java | 19 ++- .../housing/complex/external/OdsayUtil.java | 110 ++++++++++++++++-- 6 files changed, 186 insertions(+), 28 deletions(-) diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/service/ComplexService.java b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/service/ComplexService.java index 991f6d52..bd6da386 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/service/ComplexService.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/service/ComplexService.java @@ -6,6 +6,7 @@ import java.util.Comparator; import java.util.List; import java.util.UUID; +import java.util.function.BiFunction; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -119,7 +120,8 @@ public List getComplexUnitTypes(String id, UUID userId) { @Override @Transactional public TransitRoutesResponse getDistanceV2(String id, String pinPointId) throws UnsupportedEncodingException { - return calculateTransitRoute(id, pinPointId, mapper::toTransitRoutesResponse); + return calculateTransitRoute(id, pinPointId, + (pathResult, pinPoint) -> mapper.toTransitRoutesResponse(pathResult, resolveDepartureLabel(pinPoint))); } // ================= @@ -462,7 +464,7 @@ public DepositResponse getLeaseMinMax(String complexId, String type) { * * @param complexId 임대주택 ID * @param pinPointId 핀포인트 ID - * @param pathMapper PathResult를 원하는 타입으로 변환하는 함수 + * @param pathMapper PathResult와 PinPoint를 원하는 타입으로 변환하는 함수 * @param 반환 타입 * @return 변환된 결과 * @throws UnsupportedEncodingException 인코딩 예외 @@ -470,7 +472,7 @@ public DepositResponse getLeaseMinMax(String complexId, String type) { private T calculateTransitRoute( String complexId, String pinPointId, - java.util.function.Function pathMapper + BiFunction pathMapper ) throws UnsupportedEncodingException { /// 임대주택 조회 @@ -491,7 +493,24 @@ private T calculateTransitRoute( validateTransitRoute(pathResult, complexId, pinPointId); /// 결과 매핑 - return pathMapper.apply(pathResult); + return pathMapper.apply(pathResult, pinPoint); + } + + private String resolveDepartureLabel(PinPoint pinPoint) { + if (pinPoint == null) { + return "출발지"; + } + if (hasText(pinPoint.getName())) { + return pinPoint.getName(); + } + if (hasText(pinPoint.getAddress())) { + return pinPoint.getAddress(); + } + return "출발지"; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); } /// 계산 결과에 실제 경로 후보가 있는지 검증 @@ -517,7 +536,7 @@ public TransitInfoResponse getTransitInfo(String id, String pinPointId) throws U } /// 상세조회 캐시가 없으면 경로를 다시 계산해 색상 포함 응답을 생성한다. - return calculateTransitRoute(id, pinPointId, pathResult -> { + return calculateTransitRoute(id, pinPointId, (pathResult, pinPoint) -> { RootResult rootResult = mapper.selectBest(pathResult); TransitInfoResponse transitInfo = mapper.toTransitInfoResponse(rootResult); @@ -546,7 +565,7 @@ public DistanceResponse getEasyDistance(String id, String pinPointId) throws Uns } /// 캐시가 없으면 템플릿 메서드를 사용하여 경로 계산 - DistanceResponse distance = calculateTransitRoute(id, pinPointId, pathResult -> { + DistanceResponse distance = calculateTransitRoute(id, pinPointId, (pathResult, pinPoint) -> { RootResult rootResult = mapper.selectBest(pathResult); TransitInfoResponse transitInfo = mapper.toTransitInfoResponse(rootResult); diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/InterCityResultParser.java b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/InterCityResultParser.java index d74333d0..0b22f159 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/InterCityResultParser.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/InterCityResultParser.java @@ -128,6 +128,10 @@ public static InterCityResult parse(JsonNode root) { .distance(sub.path("distance").asInt(0)) .startName(sub.path("startName").asText(null)) .endName(sub.path("endName").asText(null)) + .startX(sub.path("startX").asDouble(0)) + .startY(sub.path("startY").asDouble(0)) + .endX(sub.path("endX").asDouble(0)) + .endY(sub.path("endY").asDouble(0)) .lineInfo(lineInfo) .line(line) .subwayLine(subwayLine) diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/IntraCityResultParser.java b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/IntraCityResultParser.java index 707f0f64..184f7b99 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/IntraCityResultParser.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/IntraCityResultParser.java @@ -104,6 +104,10 @@ public static IntraCityResult parse(JsonNode root) { .time(sub.path("sectionTime").asInt(0)) .startName(safeText(sub, "startName")) .endName(safeText(sub, "endName")) + .startX(sub.path("startX").asDouble(0)) + .startY(sub.path("startY").asDouble(0)) + .endX(sub.path("endX").asDouble(0)) + .endY(sub.path("endY").asDouble(0)) .lineInfo(lineInfo) .line(line) .subwayLine(subwayLine) diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/TransitResponseMapper.java b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/TransitResponseMapper.java index 3f06b6b0..ebb9d590 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/TransitResponseMapper.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/TransitResponseMapper.java @@ -174,6 +174,13 @@ public List from(RootResult route) { * 새 스키마: 3개 경로를 한 번에 변환 */ public TransitRoutesResponse toTransitRoutesResponse(PathResult pathResult) { + return toTransitRoutesResponse(pathResult, null); + } + + /** + * 새 스키마: 3개 경로를 한 번에 변환 + */ + public TransitRoutesResponse toTransitRoutesResponse(PathResult pathResult, String departureLabel) { if (pathResult == null || pathResult.routes() == null) { return TransitRoutesResponse.builder() .totalCount(0) @@ -186,7 +193,7 @@ public TransitRoutesResponse toTransitRoutesResponse(PathResult pathResult) { for (int i = 0; i < top3.size(); i++) { RootResult route = top3.get(i); - routeResponses.add(toRouteResponse(route, i)); + routeResponses.add(toRouteResponse(route, i, departureLabel)); } return TransitRoutesResponse.builder() @@ -198,12 +205,12 @@ public TransitRoutesResponse toTransitRoutesResponse(PathResult pathResult) { /** * 개별 경로 변환 */ - private TransitRoutesResponse.RouteResponse toRouteResponse(RootResult route, int index) { + private TransitRoutesResponse.RouteResponse toRouteResponse(RootResult route, int index, String departureLabel) { return TransitRoutesResponse.RouteResponse.builder() .routeIndex(index) .summary(toSummaryResponse(route)) .distance(toSegmentResponses(route)) - .steps(toStepResponses(route)) + .steps(toStepResponses(route, departureLabel)) .build(); } @@ -310,7 +317,7 @@ public List toSegmentResponses(RootResult /** * Steps 생성 (색깔 + 승차/하차 통합) */ - private List toStepResponses(RootResult route) { + private List toStepResponses(RootResult route, String departureLabel) { if (route == null || route.steps() == null || route.steps().isEmpty()) { return List.of(); } @@ -325,15 +332,17 @@ private List toStepResponses(RootResult rout if (transportSteps.isEmpty()) { // WALK만 있는 경로 (드문 케이스) + boolean isFirstWalk = true; for (RootResult.DistanceStep step : distanceSteps) { - steps.add(createWalkStep(step, null, null, false)); + steps.add(createWalkStep(step, null, departureLabel, departureLabel, isFirstWalk)); + isFirstWalk = false; } return assignStepIndexes(steps); } - // 출발지 정보 (첫 번째 WALK에 사용) + // primaryText용 표시 이름: 명시적 라벨 > 첫 번째 교통수단의 출발역명 RootResult.DistanceStep firstTransport = transportSteps.get(0); - String departureLocation = firstTransport.startName(); + String departureDisplay = hasText(departureLabel) ? departureLabel : firstTransport.startName(); // 전체 구간 순회하며 steps 생성 int transportIndex = 0; @@ -343,8 +352,8 @@ private List toStepResponses(RootResult rout RootResult.DistanceStep step = distanceSteps.get(i); if (step.type() == RootResult.TransportType.WALK) { - // 첫 번째 WALK는 출발지 정보 포함 - steps.add(createWalkStep(step, ChipType.WALK, departureLocation, isFirstWalk)); + // stopName에는 명시적 라벨 사용 (없으면 step.startName()으로 폴백은 createWalkStep 내부 처리) + steps.add(createWalkStep(step, ChipType.WALK, departureDisplay, departureLabel, isFirstWalk)); isFirstWalk = false; } else { // 교통수단: BOARD + ALIGHT 추가 (색상 포함) @@ -385,28 +394,35 @@ private List toStepResponses(RootResult rout * WALK step 생성 * UI에서는 중간 POI를 숨기고 행동 중심 정보만 표시 * 첫 번째 WALK인 경우 출발지 정보 포함 + * + * @param primaryLabel primaryText에 사용할 이름 (ex: 교통수단 출발역명 또는 명시적 라벨) + * @param stopNameLabel stopName에 사용할 명시적 라벨 (null이면 step.startName() 사용) */ private TransitRoutesResponse.StepResponse createWalkStep( RootResult.DistanceStep step, ChipType chipType, - String departureLocation, + String primaryLabel, + String stopNameLabel, boolean isFirstWalk) { String colorHex = (chipType != null) ? chipType.defaultBg : ChipType.WALK.defaultBg; // 첫 번째 WALK는 출발지 포함, 나머지는 "도보 약 n분"만 String primaryText; - if (isFirstWalk && departureLocation != null) { - primaryText = departureLocation + "에서 도보 약 " + step.time() + "분"; + if (isFirstWalk && hasText(primaryLabel)) { + primaryText = primaryLabel + "에서 도보 약 " + step.time() + "분"; } else { primaryText = "도보 약 " + step.time() + "분"; } + // stopName: 명시적 라벨 우선, 없으면 step 자체의 출발 지점명 + String stopName = (isFirstWalk && hasText(stopNameLabel)) ? stopNameLabel : step.startName(); + return TransitRoutesResponse.StepResponse.builder() .stepIndex(0) .action(TransitRoutesResponse.StepAction.WALK) .type("WALK") - .stopName(step.startName()) // 내부 로직용 유지 + .stopName(stopName) .primaryText(primaryText) .secondaryText(null) .minutes(step.time()) @@ -537,4 +553,8 @@ private String abbreviateBusNumbers(String busNumbers) { int remaining = numbers.length - 3; return first3 + "번 외 " + remaining + "개"; } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/domain/transit/RootResult.java b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/domain/transit/RootResult.java index 7829120f..6cfb31e7 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/domain/transit/RootResult.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/domain/transit/RootResult.java @@ -83,7 +83,24 @@ public record DistanceStep( @Schema(hidden = true) @com.fasterxml.jackson.annotation.JsonIgnore - ExpressBusType expressBusType + ExpressBusType expressBusType, + + // 3-leg 조합을 위한 좌표 (출발/도착 시내 leg 호출 시 사용, 직렬화 제외) + @Schema(hidden = true) + @com.fasterxml.jackson.annotation.JsonIgnore + double startX, + + @Schema(hidden = true) + @com.fasterxml.jackson.annotation.JsonIgnore + double startY, + + @Schema(hidden = true) + @com.fasterxml.jackson.annotation.JsonIgnore + double endX, + + @Schema(hidden = true) + @com.fasterxml.jackson.annotation.JsonIgnore + double endY ) { } diff --git a/module-infrastructure/src/main/java/co/kr/pinhouse/infrastructure/housing/complex/external/OdsayUtil.java b/module-infrastructure/src/main/java/co/kr/pinhouse/infrastructure/housing/complex/external/OdsayUtil.java index b0c79170..db9695af 100644 --- a/module-infrastructure/src/main/java/co/kr/pinhouse/infrastructure/housing/complex/external/OdsayUtil.java +++ b/module-infrastructure/src/main/java/co/kr/pinhouse/infrastructure/housing/complex/external/OdsayUtil.java @@ -5,6 +5,9 @@ import java.net.URI; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; import java.util.Locale; import org.springframework.beans.factory.annotation.Value; @@ -20,7 +23,9 @@ import co.kr.pinhouse.domain.housing.complex.application.util.DistanceUtil; import co.kr.pinhouse.domain.housing.complex.application.util.InterCityResultParser; import co.kr.pinhouse.domain.housing.complex.application.util.IntraCityResultParser; +import co.kr.pinhouse.domain.housing.complex.domain.transit.InterCityResult; import co.kr.pinhouse.domain.housing.complex.domain.transit.PathResult; +import co.kr.pinhouse.domain.housing.complex.domain.transit.RootResult; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -42,44 +47,134 @@ public class OdsayUtil implements DistanceUtil { @Override public PathResult findPathResult(double startY, double startX, double endY, double endX) { + PathResult single = callSingleLeg(startY, startX, endY, endX); + if (!(single instanceof InterCityResult interCity)) { + return single; + } + + // 시외 경로 → 앞뒤 시내 leg 조합 + RootResult bestInterCity = selectBest(interCity.routes()); + if (bestInterCity == null || bestInterCity.steps().isEmpty()) { + return interCity; + } + + RootResult.DistanceStep firstStep = bestInterCity.steps().get(0); + RootResult.DistanceStep lastStep = bestInterCity.steps().get(bestInterCity.steps().size() - 1); + + PathResult departureLeg = null; + PathResult arrivalLeg = null; + + if (hasCoords(firstStep.startX(), firstStep.startY())) { + departureLeg = callSingleLegSilently(startY, startX, firstStep.startY(), firstStep.startX()); + } + if (hasCoords(lastStep.endX(), lastStep.endY())) { + arrivalLeg = callSingleLegSilently(lastStep.endY(), lastStep.endX(), endY, endX); + } + + return composeRoutes(departureLeg, interCity, arrivalLeg); + } + + // ================= + // 3-leg 조합 로직 + // ================= + + private PathResult composeRoutes(PathResult departureLeg, InterCityResult interCity, PathResult arrivalLeg) { + RootResult bestDep = (departureLeg != null) ? selectBest(departureLeg.routes()) : null; + RootResult bestArr = (arrivalLeg != null) ? selectBest(arrivalLeg.routes()) : null; + + List composed = new ArrayList<>(); + List top3 = interCity.routes().stream() + .sorted(Comparator.comparingInt(RootResult::totalTime).thenComparingInt(RootResult::totalPayment)) + .limit(3) + .toList(); + + for (RootResult inter : top3) { + List steps = new ArrayList<>(); + int totalTime = inter.totalTime(); + int totalPayment = inter.totalPayment(); + double totalDistance = inter.totalDistance(); + + if (bestDep != null) { + steps.addAll(bestDep.steps()); + totalTime += bestDep.totalTime(); + totalPayment += bestDep.totalPayment(); + totalDistance += bestDep.totalDistance(); + } + steps.addAll(inter.steps()); + if (bestArr != null) { + steps.addAll(bestArr.steps()); + totalTime += bestArr.totalTime(); + totalPayment += bestArr.totalPayment(); + totalDistance += bestArr.totalDistance(); + } + + composed.add(RootResult.builder() + .totalTime(totalTime) + .totalPayment(totalPayment) + .totalDistance(totalDistance) + .steps(List.copyOf(steps)) + .build()); + } + + return () -> List.copyOf(composed); + } + + private RootResult selectBest(List routes) { + if (routes == null || routes.isEmpty()) { + return null; + } + return routes.stream() + .min(Comparator.comparingInt(RootResult::totalTime).thenComparingInt(RootResult::totalPayment)) + .orElse(null); + } + + private boolean hasCoords(double cx, double cy) { + return cx != 0 || cy != 0; + } + + private PathResult callSingleLegSilently(double startY, double startX, double endY, double endX) { + try { + return callSingleLeg(startY, startX, endY, endX); + } catch (Exception e) { + log.warn("시내 leg 경로 조회 실패 (startY={}, startX={}, endY={}, endX={}) - 해당 leg 생략", + startY, startX, endY, endX); + return null; + } + } + + private PathResult callSingleLeg(double startY, double startX, double endY, double endX) { String normalizedApiKey = normalizeApiKey(apiKey); if (normalizedApiKey == null || normalizedApiKey.isBlank()) { log.error("ODsay API Key가 비어 있습니다"); throw new CustomException(ComplexErrorCode.ODSAY_INVALID_API_KEY); } - /// ODsay 안내에 따라 API Key는 1회 인코딩 후 직접 URI에 반영 String encodedApiKey = encodeApiKey(normalizedApiKey); URI requestUri = URI.create(buildRequestUri(startY, startX, endY, endX, encodedApiKey)); - /// 값 호출 try { String response = webClient.get() .uri(requestUri) .retrieve() .bodyToMono(String.class) .onErrorMap(e -> new CustomException(ComplexErrorCode.ODSAY_SERVER_ERROR)) - .block(); // 동기 + .block(); if (response == null || response.isBlank()) { log.error("ODsay 응답 본문이 비어 있습니다"); throw new CustomException(ComplexErrorCode.ODSAY_PARSING_ERROR); } - /// 자동 판별 JsonNode root = OM.readTree(response); handleOdsayError(root); ensurePathExists(root); int searchType = detectSearchType(root); - /// 분기 처리 if (searchType == 0) { - /// 도시내 return IntraCityResultParser.parse(root); } else { - /// 도시간(직통/환승 등 포함) return InterCityResultParser.parse(root); } @@ -89,7 +184,6 @@ public PathResult findPathResult(double startY, double startX, double endY, doub log.error("ODsay 응답 파싱에 실패했습니다", e); throw new CustomException(ComplexErrorCode.ODSAY_PARSING_ERROR); } - } // ================= From 3eddfdf1438ce659e223ba84b923a9c5373184f0 Mon Sep 17 00:00:00 2001 From: eedo_y Date: Tue, 28 Apr 2026 15:50:49 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20=EC=B5=9C=EC=B4=88=20=EB=8F=84?= =?UTF-8?q?=EB=B3=B4=EC=97=90=20=EB=AF=B8=ED=84=B0=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=20(#136)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/dto/response/TransitRoutesResponse.java | 3 +++ .../complex/application/util/IntraCityResultParser.java | 1 + .../complex/application/util/TransitResponseMapper.java | 5 +++++ 3 files changed, 9 insertions(+) diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/dto/response/TransitRoutesResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/dto/response/TransitRoutesResponse.java index 3fd784f3..e7c851ea 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/dto/response/TransitRoutesResponse.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/dto/response/TransitRoutesResponse.java @@ -131,6 +131,9 @@ public record StepResponse( @Schema(description = "해당 구간 소요 시간(분), 없으면 null", example = "65") Integer minutes, + @Schema(description = "핀포인트에서 첫 번째 대중교통 정류장/역까지의 도보 거리(m), 해당 구간이 없으면 null", example = "520") + Integer distanceMeters, + @Schema(description = "색 막대용 색상(Hex), 출발/도착은 null", example = "#3356B4") String colorHex, diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/IntraCityResultParser.java b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/IntraCityResultParser.java index 184f7b99..0d37fa1e 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/IntraCityResultParser.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/IntraCityResultParser.java @@ -102,6 +102,7 @@ public static IntraCityResult parse(JsonNode root) { steps.add(RootResult.DistanceStep.builder() .type(type) .time(sub.path("sectionTime").asInt(0)) + .distance(sub.path("distance").asInt(0)) .startName(safeText(sub, "startName")) .endName(safeText(sub, "endName")) .startX(sub.path("startX").asDouble(0)) diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/TransitResponseMapper.java b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/TransitResponseMapper.java index ebb9d590..4811a464 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/TransitResponseMapper.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/TransitResponseMapper.java @@ -418,6 +418,9 @@ private TransitRoutesResponse.StepResponse createWalkStep( // stopName: 명시적 라벨 우선, 없으면 step 자체의 출발 지점명 String stopName = (isFirstWalk && hasText(stopNameLabel)) ? stopNameLabel : step.startName(); + // 첫 번째 WALK에만 거리(m) 노출, 0이면 null 처리 + Integer distanceMeters = (isFirstWalk && step.distance() > 0) ? step.distance() : null; + return TransitRoutesResponse.StepResponse.builder() .stepIndex(0) .action(TransitRoutesResponse.StepAction.WALK) @@ -426,6 +429,7 @@ private TransitRoutesResponse.StepResponse createWalkStep( .primaryText(primaryText) .secondaryText(null) .minutes(step.time()) + .distanceMeters(distanceMeters) .colorHex(colorHex) .line(null) .build(); @@ -519,6 +523,7 @@ private List assignStepIndexes(List Date: Tue, 28 Apr 2026 16:37:23 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=EC=B2=AD=EC=95=BD=EC=A7=84=EB=8B=A8?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EC=88=98=EC=A0=95=20(#136)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../diagnosis/domain/entity/IncomeLevel.java | 6 ++--- .../application/service/PolicyProvider.java | 24 +++++++++++-------- .../rule/domain/rule/ElderCandidateRule.java | 6 ++--- .../rule/domain/rule/GeneralSupplyRule.java | 7 +++--- .../rule/domain/rule/MinorRule.java | 7 +++--- .../rule/YouthSpecialCandidateRule.java | 7 +++--- 6 files changed, 29 insertions(+), 28 deletions(-) diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/domain/entity/IncomeLevel.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/domain/entity/IncomeLevel.java index b1502910..478a4ade 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/domain/entity/IncomeLevel.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/domain/entity/IncomeLevel.java @@ -10,9 +10,9 @@ public enum IncomeLevel { PERCENT_50("2구간", 50), PERCENT_70("3구간", 70), PERCENT_100("4구간", 100), - PERCENT_110("5구간", 110), - PERCENT_120("5구간", 120), - PERCENT_130("5구간", 130), + PERCENT_110("5구간(110%)", 110), + PERCENT_120("5구간(120%)", 120), + PERCENT_130("5구간(130%)", 130), PERCENT_150("6구간", 150), PERCENT_160("기타", 160), PERCENT_170("기타", 170), diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/application/service/PolicyProvider.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/application/service/PolicyProvider.java index 281380f2..d2bdac19 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/application/service/PolicyProvider.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/application/service/PolicyProvider.java @@ -47,22 +47,26 @@ public double maxIncomeRatio(SupplyType supply, NoticeType rental, int familyCou DISABLED, NON_HOUSING_RESIDENT -> incomeByFamily(familyCount, Map.of(1, 170.0, 2, 160.0, 3, 150.0), 150.0); - /// 일반공급 - 기준중위소득 150% - case GENERAL -> 150.0; + /// 일반공급 - 가구원 수별 차등 (1인 170%, 2인 160%, 3인 이상 150%) + case GENERAL -> incomeByFamily(familyCount, + Map.of(1, 170.0, 2, 160.0, 3, 150.0), 150.0); default -> 150.0; }; - /// 영구임대 (전년도 도시근로자 월평균 소득 50~100%) + /// 영구임대 (전년도 도시근로자 월평균 소득 기준, 가구원수별 차등) case PERMANENT_RENTAL -> switch (supply) { - /// 일반 - 50% 이하 - case GENERAL -> 50.0; + /// 일반 - 1인 70%, 2인 60%, 3인 이상 50% + case GENERAL -> incomeByFamily(familyCount, + Map.of(1, 70.0, 2, 60.0, 3, 50.0), 50.0); - /// 국가유공자, 북한이탈주민, 아동복지시설퇴소자, 장애인(1순위) - 70% 이하 - case NATIONAL_MERIT, NORTH_DEFECTOR, DISABLED -> 70.0; + /// 국가유공자, 북한이탈주민, 아동복지시설퇴소자, 장애인(1순위) - 1인 90%, 2인 80%, 3인 이상 70% + case NATIONAL_MERIT, NORTH_DEFECTOR, DISABLED -> incomeByFamily(familyCount, + Map.of(1, 90.0, 2, 80.0, 3, 70.0), 70.0); - /// 장애인(2순위) - 100% 이하 - default -> 100.0; + /// 장애인(2순위) - 1인 120%, 2인 110%, 3인 이상 100% + default -> incomeByFamily(familyCount, + Map.of(1, 120.0, 2, 110.0, 3, 100.0), 100.0); }; /// 국민임대 (60㎡ 이하: 70%, 60㎡ 초과: 100%) @@ -214,7 +218,7 @@ public int newlyMarriedMaxYears() { @Override public int marriedYouthAgeMin() { - return 20; + return 19; // 민법상 미성년자: 만 19세 미만(18세 이하) } @Override diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/ElderCandidateRule.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/ElderCandidateRule.java index d3202ed5..c86dbff6 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/ElderCandidateRule.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/ElderCandidateRule.java @@ -33,10 +33,8 @@ public RuleResult evaluate(EvaluationContext ctx) { /// 나이가 고령자 제한이 안된다면 삭제 (만 65세 이상) if (diagnosis.getAge() < policyUseCase.elderAge()) { - /// 고령자 관련 특별공급 모두 제거 - candidates.removeIf(c -> - c.supplyType() == SupplyType.ELDER_SPECIAL - || c.supplyType() == SupplyType.ELDER_SUPPORT_SPECIAL); + /// 고령자 본인 특별공급만 제거 (노부모 부양은 신청자 나이와 무관하므로 ElderSupportCandidateRule에서 처리) + candidates.removeIf(c -> c.supplyType() == SupplyType.ELDER_SPECIAL); /// 결과 저장하기 ctx.setCurrentCandidates(candidates); diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/GeneralSupplyRule.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/GeneralSupplyRule.java index 473d4884..65d1e65f 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/GeneralSupplyRule.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/GeneralSupplyRule.java @@ -21,11 +21,10 @@ * 임대주택 일반공급 후보 탐색 규칙 * * <청약통장 필요 여부> - * - 공공임대(PUBLIC_RENTAL), 통합공공임대(PUBLIC_INTEGRATED): 청약통장 6개월 이상 필요 - * (분양전환형 임대주택으로 추후 소유권 전환 가능) + * - 공공임대(PUBLIC_RENTAL): 청약통장 6개월 이상 필요 (분양전환형 임대주택) * - * - 국민임대, 영구임대, 장기전세, 행복주택: 청약통장 불필요 - * (순수 임대주택으로 소유권 전환 불가) + * - 통합공공임대, 국민임대, 영구임대, 장기전세, 행복주택: 청약통장 불필요 + * (순수 임대주택 또는 통합공공임대) * * <공통 요건> * - 무주택 세대구성원이어야 함 diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/MinorRule.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/MinorRule.java index f03d8cec..d4a5f206 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/MinorRule.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/MinorRule.java @@ -30,10 +30,11 @@ public RuleResult evaluate(EvaluationContext ctx) { /// 현재 후보 리스트 복사 var candidates = new ArrayList<>(ctx.getCurrentCandidates()); - /// 결혼 여부 & 신생아 여부 + /// 신생아 여부: unbornChildrenCount는 "태아 또는 출산(입양) 후 2년 이내 자녀" 수를 의미 + /// (실제 신생아 특별공급 기준: 임신 중이거나 출산/입양 후 2년 이내) boolean minorOk = diagnosis.getUnbornChildrenCount() >= 1; - /// 신생아가 1명이상 없다면, 제거한다. + /// 신생아(태아 포함)가 1명 이상 없다면 제거 if (!minorOk) { /// 삭제 @@ -47,7 +48,7 @@ public RuleResult evaluate(EvaluationContext ctx) { "신생아 특별공급 해당 없음", Map.of( "candidate", candidates, - "failReason", "신생아의 자녀가 없음" + "failReason", "태아 또는 출산(입양) 2년 이내 자녀 없음" )); } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/YouthSpecialCandidateRule.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/YouthSpecialCandidateRule.java index 9ddaed6e..24d0673b 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/YouthSpecialCandidateRule.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/YouthSpecialCandidateRule.java @@ -33,9 +33,8 @@ public RuleResult evaluate(EvaluationContext ctx) { /// 가능한 리스트 추출하기 var candidates = new ArrayList<>(ctx.getCurrentCandidates()); - /// 미성년자인지 체크 - int ageMin = policyUseCase.marriedYouthAgeMin(); - boolean ageOk = diagnosis.getAge() < ageMin; + /// 미성년자인지 체크 (민법상 미성년자: 만 18세 이하, 즉 19세 미만) + boolean ageOk = diagnosis.getAge() < policyUseCase.marriedYouthAgeMin(); /// 결혼했는지 여부 확인 boolean isMarried = diagnosis.isMaritalStatus(); @@ -76,6 +75,6 @@ public RuleResult evaluate(EvaluationContext ctx) { @Override public String code() { - return "CANDIDATE_YOUTH_SPECIAL"; + return "CANDIDATE_MINOR_MARRIED_SPECIAL"; } } From 589e2318e1da6f3cca418f0c9cdf73da22f4151f Mon Sep 17 00:00:00 2001 From: eedo_y Date: Tue, 28 Apr 2026 17:36:14 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8B=A4=EC=A0=9C=EA=B3=B5=EA=B3=A0=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EC=A0=84=EB=8B=AC=20=EA=B5=AC=ED=98=84=20(#136)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdvertisementEventRepository.java | 4 + .../repository/AdvertisementRepository.java | 9 + .../dto/response/AdminDashboardResponse.java | 76 +++ .../service/AdminDashboardService.java | 431 ++++++++++++++++++ .../usecase/AdminDashboardUseCase.java | 10 + .../dto/response/AdminNoticeResponse.java | 24 + .../response/AdminNoticeSummaryResponse.java | 24 + .../repository/CsInquiryRepository.java | 15 + .../repository/DiagnosisJpaRepository.java | 28 ++ .../NoticeDocumentRepositoryCustom.java | 12 + .../NoticeDocumentRepositoryImpl.java | 22 + .../domain/repository/UserJpaRepository.java | 3 + .../admin/dashboard/AdminDashboardApi.java | 30 ++ 13 files changed, 688 insertions(+) create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/dashboard/application/dto/response/AdminDashboardResponse.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/dashboard/application/service/AdminDashboardService.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/dashboard/application/usecase/AdminDashboardUseCase.java create mode 100644 module-presentation/src/main/java/co/kr/pinhouse/domain/admin/dashboard/AdminDashboardApi.java diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/repository/AdvertisementEventRepository.java b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/repository/AdvertisementEventRepository.java index ce0949f8..85bff747 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/repository/AdvertisementEventRepository.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/repository/AdvertisementEventRepository.java @@ -1,5 +1,7 @@ package co.kr.pinhouse.domain.ad.domain.repository; +import java.time.LocalDateTime; + import org.springframework.data.jpa.repository.JpaRepository; import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementEvent; @@ -8,4 +10,6 @@ public interface AdvertisementEventRepository extends JpaRepository { long countByAdvertisement_IdAndEventType(Long advertisementId, AdvertisementEventType eventType); + + long countByEventTypeAndOccurredAtBetween(AdvertisementEventType eventType, LocalDateTime from, LocalDateTime to); } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/repository/AdvertisementRepository.java b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/repository/AdvertisementRepository.java index 6ee11b93..8513334c 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/repository/AdvertisementRepository.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/repository/AdvertisementRepository.java @@ -1,5 +1,6 @@ package co.kr.pinhouse.domain.ad.domain.repository; +import java.time.LocalDateTime; import java.util.List; import org.springframework.data.domain.Page; @@ -18,4 +19,12 @@ List findByPlacementAndStatusOrderByPriorityDescIdDesc( AdvertisementPlacement placement, AdvertisementStatus status ); + + List findByStatus(AdvertisementStatus status); + + long countByStatus(AdvertisementStatus status); + + long countByStatusAndCreatedAtBetween(AdvertisementStatus status, LocalDateTime from, LocalDateTime to); + + List findByStatusAndEndAtBetween(AdvertisementStatus status, LocalDateTime from, LocalDateTime to); } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/dashboard/application/dto/response/AdminDashboardResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/dashboard/application/dto/response/AdminDashboardResponse.java new file mode 100644 index 00000000..b1eddda3 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/dashboard/application/dto/response/AdminDashboardResponse.java @@ -0,0 +1,76 @@ +package co.kr.pinhouse.domain.admin.dashboard.application.dto.response; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.List; + +import lombok.Builder; + +@Builder +public record AdminDashboardResponse( + OffsetDateTime asOf, + String timezone, + StatCards statCards, + TodayBrief todayBrief, + List recentCs, + List weeklyTrend, + Checkpoints checkpoints +) { + + public record StatCards( + DashboardMetric totalUsers, + DashboardMetric csInquiries, + DashboardMetric activeAdvertisements, + DashboardMetric monthlyDiagnoses + ) { + } + + public record DashboardMetric( + long value, + Long deltaValue, + BigDecimal deltaPercent, + String comparisonLabel, + BigDecimal subValue, + String subLabel + ) { + } + + public record TodayBrief( + long urgentCsCount, + int urgentCsThresholdMinutes, + long newUsersToday, + long newUsersDeltaFromYesterday, + long diagnosesCompletedToday, + BigDecimal diagnosisCompletionRateToday + ) { + } + + public record RecentCsItem( + Long inquiryId, + String title, + String requesterMaskedName, + String status, + OffsetDateTime createdAt, + OffsetDateTime lastMessageAt, + String assignedAdminName + ) { + } + + public record WeeklyTrendPoint( + LocalDate date, + long newUsers, + long csInquiries, + long diagnosesCompleted, + long adClicks + ) { + } + + public record Checkpoints( + long delayedCsCount, + long adsEndingSoonCount, + long newVisibleNoticesToday, + long noticesMissingActualLinkCount + ) { + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/dashboard/application/service/AdminDashboardService.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/dashboard/application/service/AdminDashboardService.java new file mode 100644 index 00000000..169e72a8 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/dashboard/application/service/AdminDashboardService.java @@ -0,0 +1,431 @@ +package co.kr.pinhouse.domain.admin.dashboard.application.service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.DayOfWeek; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.TemporalAdjusters; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import co.kr.pinhouse.domain.ad.domain.entity.Advertisement; +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementEventType; +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementStatus; +import co.kr.pinhouse.domain.ad.domain.repository.AdvertisementEventRepository; +import co.kr.pinhouse.domain.ad.domain.repository.AdvertisementRepository; +import co.kr.pinhouse.domain.admin.application.usecase.AdminSessionUseCase; +import co.kr.pinhouse.domain.admin.dashboard.application.dto.response.AdminDashboardResponse; +import co.kr.pinhouse.domain.admin.dashboard.application.usecase.AdminDashboardUseCase; +import co.kr.pinhouse.domain.admin.notice.domain.repository.NoticeAdminOverrideRepository; +import co.kr.pinhouse.domain.cs.domain.entity.CsInquiry; +import co.kr.pinhouse.domain.cs.domain.entity.CsInquiryStatus; +import co.kr.pinhouse.domain.cs.domain.repository.CsInquiryRepository; +import co.kr.pinhouse.domain.diagnostic.diagnosis.domain.repository.DiagnosisJpaRepository; +import co.kr.pinhouse.domain.housing.notice.domain.entity.NoticeDocument; +import co.kr.pinhouse.domain.housing.notice.domain.entity.Urls; +import co.kr.pinhouse.domain.housing.notice.domain.repository.NoticeDocumentRepository; +import co.kr.pinhouse.domain.user.domain.entity.User; +import co.kr.pinhouse.domain.user.domain.repository.UserJpaRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminDashboardService implements AdminDashboardUseCase { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final String TIMEZONE = "Asia/Seoul"; + private static final int URGENT_CS_THRESHOLD_MINUTES = 60; + private static final int DELAYED_CS_THRESHOLD_HOURS = 24; + private static final int ADS_ENDING_SOON_DAYS = 7; + + private static final List RESOLVED_CS_STATUSES = List.of( + CsInquiryStatus.RESOLVED, + CsInquiryStatus.CLOSED + ); + private static final List URGENT_CS_STATUSES = List.of( + CsInquiryStatus.RECEIVED, + CsInquiryStatus.IN_PROGRESS + ); + + private final AdminSessionUseCase adminSessionService; + private final UserJpaRepository userRepository; + private final CsInquiryRepository inquiryRepository; + private final DiagnosisJpaRepository diagnosisRepository; + private final AdvertisementRepository advertisementRepository; + private final AdvertisementEventRepository advertisementEventRepository; + private final NoticeDocumentRepository noticeRepository; + private final NoticeAdminOverrideRepository overrideRepository; + + @Override + public AdminDashboardResponse getDashboard(UUID adminId) { + adminSessionService.loadAdmin(adminId); + + ZonedDateTime now = ZonedDateTime.now(KST); + LocalDate today = now.toLocalDate(); + LocalDateTime nowLocal = now.toLocalDateTime(); + + DateTimeRange todayRange = DateTimeRange.of(today.atStartOfDay(), nowLocal); + DateTimeRange yesterdayComparableRange = todayRange.shiftDays(-1); + DateTimeRange currentWeekRange = DateTimeRange.of( + today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay(), + nowLocal + ); + DateTimeRange previousWeekComparableRange = currentWeekRange.shiftWeeks(-1); + DateTimeRange recentSevenDaysRange = DateTimeRange.of(today.minusDays(6).atStartOfDay(), nowLocal); + DateTimeRange previousSevenDaysRange = recentSevenDaysRange.shiftDays(-7); + DateTimeRange currentMonthRange = DateTimeRange.of(today.withDayOfMonth(1).atStartOfDay(), nowLocal); + DateTimeRange previousMonthComparableRange = currentMonthRange.shiftMonths(-1); + + return AdminDashboardResponse.builder() + .asOf(now.toOffsetDateTime()) + .timezone(TIMEZONE) + .statCards(buildStatCards( + nowLocal, + currentWeekRange, + previousWeekComparableRange, + recentSevenDaysRange, + previousSevenDaysRange, + currentMonthRange, + previousMonthComparableRange + )) + .todayBrief(buildTodayBrief(nowLocal, todayRange, yesterdayComparableRange)) + .recentCs(buildRecentCs()) + .weeklyTrend(buildWeeklyTrend(today, todayRange)) + .checkpoints(buildCheckpoints(today, nowLocal)) + .build(); + } + + private AdminDashboardResponse.StatCards buildStatCards( + LocalDateTime now, + DateTimeRange currentWeekRange, + DateTimeRange previousWeekRange, + DateTimeRange recentSevenDaysRange, + DateTimeRange previousSevenDaysRange, + DateTimeRange currentMonthRange, + DateTimeRange previousMonthRange + ) { + return new AdminDashboardResponse.StatCards( + buildTotalUsersCard(currentWeekRange, previousWeekRange), + buildCsInquiriesCard(recentSevenDaysRange, previousSevenDaysRange), + buildActiveAdvertisementsCard(now, recentSevenDaysRange), + buildMonthlyDiagnosesCard(currentMonthRange, previousMonthRange) + ); + } + + private AdminDashboardResponse.DashboardMetric buildTotalUsersCard( + DateTimeRange currentWeekRange, + DateTimeRange previousWeekRange + ) { + long totalUsers = userRepository.count(); + long currentWeekNewUsers = countUsers(currentWeekRange); + long previousWeekNewUsers = countUsers(previousWeekRange); + + return new AdminDashboardResponse.DashboardMetric( + totalUsers, + currentWeekNewUsers - previousWeekNewUsers, + calculateChangePercent(currentWeekNewUsers, previousWeekNewUsers), + "주간 신규 vs 지난주", + BigDecimal.valueOf(currentWeekNewUsers), + "이번 주 신규" + ); + } + + private AdminDashboardResponse.DashboardMetric buildCsInquiriesCard( + DateTimeRange recentSevenDaysRange, + DateTimeRange previousSevenDaysRange + ) { + long recentSevenDaysCs = countCsInquiries(recentSevenDaysRange); + long previousSevenDaysCs = countCsInquiries(previousSevenDaysRange); + long unresolvedCs = inquiryRepository.countByStatusNotIn(RESOLVED_CS_STATUSES); + + return new AdminDashboardResponse.DashboardMetric( + recentSevenDaysCs, + recentSevenDaysCs - previousSevenDaysCs, + calculateChangePercent(recentSevenDaysCs, previousSevenDaysCs), + "최근 7일 vs 이전 7일", + BigDecimal.valueOf(unresolvedCs), + "미처리" + ); + } + + private AdminDashboardResponse.DashboardMetric buildActiveAdvertisementsCard( + LocalDateTime now, + DateTimeRange recentSevenDaysRange + ) { + List activeStatusAdvertisements = advertisementRepository.findByStatus(AdvertisementStatus.ACTIVE); + long activeNow = activeStatusAdvertisements.stream() + .filter(advertisement -> advertisement.isExposedAt(now)) + .count(); + long activeWeekAgo = activeStatusAdvertisements.stream() + .filter(advertisement -> advertisement.isExposedAt(now.minusWeeks(1))) + .count(); + + long weeklyClicks = countAdvertisementEvents(AdvertisementEventType.CLICK, recentSevenDaysRange); + long weeklyImpressions = countAdvertisementEvents(AdvertisementEventType.IMPRESSION, recentSevenDaysRange); + + return new AdminDashboardResponse.DashboardMetric( + activeNow, + activeNow - activeWeekAgo, + calculateChangePercent(activeNow, activeWeekAgo), + "활성 수 vs 지난주", + calculateRate(weeklyClicks, weeklyImpressions), + "최근 7일 CTR" + ); + } + + private AdminDashboardResponse.DashboardMetric buildMonthlyDiagnosesCard( + DateTimeRange currentMonthRange, + DateTimeRange previousMonthRange + ) { + long currentMonthDiagnoses = countDiagnoses(currentMonthRange); + long previousMonthDiagnoses = countDiagnoses(previousMonthRange); + long currentMonthNewUsers = countUsers(currentMonthRange); + long currentMonthDiagnosedNewUsers = diagnosisRepository.countDistinctUsersByCreatedAtBetweenAndUserCreatedAtBetween( + currentMonthRange.start(), + currentMonthRange.end(), + currentMonthRange.start(), + currentMonthRange.end() + ); + + return new AdminDashboardResponse.DashboardMetric( + currentMonthDiagnoses, + currentMonthDiagnoses - previousMonthDiagnoses, + calculateChangePercent(currentMonthDiagnoses, previousMonthDiagnoses), + "전월 동기간 대비", + calculateRate(currentMonthDiagnosedNewUsers, currentMonthNewUsers), + "신규 가입 진단 완료율" + ); + } + + private AdminDashboardResponse.TodayBrief buildTodayBrief( + LocalDateTime now, + DateTimeRange todayRange, + DateTimeRange yesterdayComparableRange + ) { + long newUsersToday = countUsers(todayRange); + long diagnosesToday = countDiagnoses(todayRange); + long diagnosedNewUsersToday = diagnosisRepository.countDistinctUsersByCreatedAtBetweenAndUserCreatedAtBetween( + todayRange.start(), + todayRange.end(), + todayRange.start(), + todayRange.end() + ); + + return new AdminDashboardResponse.TodayBrief( + inquiryRepository.countByStatusInAndLastMessageAtBefore( + URGENT_CS_STATUSES, + now.minusMinutes(URGENT_CS_THRESHOLD_MINUTES) + ), + URGENT_CS_THRESHOLD_MINUTES, + newUsersToday, + newUsersToday - countUsers(yesterdayComparableRange), + diagnosesToday, + calculateRate(diagnosedNewUsersToday, newUsersToday) + ); + } + + private List buildRecentCs() { + List recentInquiries = inquiryRepository.findTop5ByOrderByLastMessageAtDescIdDesc(); + Map adminNames = loadAdminNames(recentInquiries); + + return recentInquiries.stream() + .map(inquiry -> new AdminDashboardResponse.RecentCsItem( + inquiry.getId(), + inquiry.getTitle(), + maskName(inquiry.getUser().getName()), + inquiry.getStatus().name(), + toOffsetDateTime(inquiry.getCreatedAt()), + toOffsetDateTime(inquiry.getLastMessageAt()), + adminNames.get(inquiry.getAssignedAdminId()) + )) + .toList(); + } + + private List buildWeeklyTrend( + LocalDate today, + DateTimeRange todayRange + ) { + return java.util.stream.IntStream.rangeClosed(0, 6) + .mapToObj(offset -> today.minusDays(6L - offset)) + .map(date -> { + DateTimeRange range = date.equals(today) ? todayRange : DateTimeRange.fullDay(date); + return new AdminDashboardResponse.WeeklyTrendPoint( + date, + countUsers(range), + countCsInquiries(range), + countDiagnoses(range), + countAdvertisementEvents(AdvertisementEventType.CLICK, range) + ); + }) + .toList(); + } + + private AdminDashboardResponse.Checkpoints buildCheckpoints(LocalDate today, LocalDateTime now) { + long delayedCsCount = inquiryRepository.countByStatusInAndLastMessageAtBefore( + URGENT_CS_STATUSES, + now.minusHours(DELAYED_CS_THRESHOLD_HOURS) + ); + long adsEndingSoonCount = advertisementRepository.findByStatusAndEndAtBetween( + AdvertisementStatus.ACTIVE, + now, + now.plusDays(ADS_ENDING_SOON_DAYS) + ).stream() + .filter(advertisement -> advertisement.isExposedAt(now)) + .count(); + + List todayNoticeIds = noticeRepository.findNoticeIdsByAnnounceDate(today); + Set hiddenTodayNoticeIds = loadHiddenNoticeIds(todayNoticeIds); + + List noticeLinkCandidates = noticeRepository.findNoticeLinkCandidatesByAnnounceDateLessThanEqual(today); + Set hiddenCandidateIds = loadHiddenNoticeIds( + noticeLinkCandidates.stream() + .map(NoticeDocument::getId) + .toList() + ); + + long noticesMissingActualLinkCount = noticeLinkCandidates.stream() + .filter(notice -> !hiddenCandidateIds.contains(notice.getId())) + .filter(this::isMissingActualLink) + .count(); + + return new AdminDashboardResponse.Checkpoints( + delayedCsCount, + adsEndingSoonCount, + todayNoticeIds.size() - hiddenTodayNoticeIds.size(), + noticesMissingActualLinkCount + ); + } + + private Map loadAdminNames(List inquiries) { + Set adminIds = inquiries.stream() + .map(CsInquiry::getAssignedAdminId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + if (adminIds.isEmpty()) { + return Map.of(); + } + + return userRepository.findAllById(adminIds).stream() + .collect(Collectors.toMap(User::getId, User::getName)); + } + + private Set loadHiddenNoticeIds(Collection noticeIds) { + if (noticeIds == null || noticeIds.isEmpty()) { + return Set.of(); + } + + return overrideRepository.findByNoticeIdIn(noticeIds).stream() + .filter(override -> override.isHidden()) + .map(override -> override.getNoticeId()) + .collect(Collectors.toSet()); + } + + private long countUsers(DateTimeRange range) { + return userRepository.countByCreatedAtBetween(range.start(), range.end()); + } + + private long countCsInquiries(DateTimeRange range) { + return inquiryRepository.countByCreatedAtBetween(range.start(), range.end()); + } + + private long countDiagnoses(DateTimeRange range) { + return diagnosisRepository.countByCreatedAtBetween(range.start(), range.end()); + } + + private long countAdvertisementEvents(AdvertisementEventType eventType, DateTimeRange range) { + return advertisementEventRepository.countByEventTypeAndOccurredAtBetween(eventType, range.start(), range.end()); + } + + private BigDecimal calculateRate(long numerator, long denominator) { + if (denominator <= 0) { + return BigDecimal.ZERO.setScale(1, RoundingMode.HALF_UP); + } + + return BigDecimal.valueOf(numerator) + .multiply(BigDecimal.valueOf(100)) + .divide(BigDecimal.valueOf(denominator), 1, RoundingMode.HALF_UP); + } + + private BigDecimal calculateChangePercent(long current, long previous) { + if (previous <= 0) { + return current > 0 + ? BigDecimal.valueOf(100).setScale(1, RoundingMode.HALF_UP) + : BigDecimal.ZERO.setScale(1, RoundingMode.HALF_UP); + } + + return BigDecimal.valueOf(current - previous) + .multiply(BigDecimal.valueOf(100)) + .divide(BigDecimal.valueOf(previous), 1, RoundingMode.HALF_UP); + } + + private OffsetDateTime toOffsetDateTime(LocalDateTime value) { + return value != null ? value.atZone(KST).toOffsetDateTime() : null; + } + + private boolean isMissingActualLink(NoticeDocument notice) { + Urls urls = notice.getUrls(); + if (urls == null) { + return true; + } + + return !hasText(urls.getApply()) && !hasText(urls.getMyhomePc()) && !hasText(urls.getMyhomeMo()); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private String maskName(String name) { + if (name == null || name.isBlank()) { + return null; + } + if (name.length() == 1) { + return "*"; + } + if (name.length() == 2) { + return name.charAt(0) + "*"; + } + return name.charAt(0) + "*" + name.charAt(name.length() - 1); + } + + private record DateTimeRange(LocalDateTime start, LocalDateTime end) { + + private static DateTimeRange of(LocalDateTime start, LocalDateTime end) { + return new DateTimeRange(start, end); + } + + private static DateTimeRange fullDay(LocalDate date) { + LocalDateTime start = date.atStartOfDay(); + return new DateTimeRange(start, date.plusDays(1).atStartOfDay().minusNanos(1)); + } + + private DateTimeRange shiftDays(long days) { + return new DateTimeRange(start.plusDays(days), end.plusDays(days)); + } + + private DateTimeRange shiftWeeks(long weeks) { + return new DateTimeRange(start.plusWeeks(weeks), end.plusWeeks(weeks)); + } + + private DateTimeRange shiftMonths(long months) { + Duration duration = Duration.between(start, end); + LocalDateTime shiftedStart = start.plusMonths(months); + return new DateTimeRange(shiftedStart, shiftedStart.plus(duration)); + } + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/dashboard/application/usecase/AdminDashboardUseCase.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/dashboard/application/usecase/AdminDashboardUseCase.java new file mode 100644 index 00000000..3ed2aa15 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/dashboard/application/usecase/AdminDashboardUseCase.java @@ -0,0 +1,10 @@ +package co.kr.pinhouse.domain.admin.dashboard.application.usecase; + +import java.util.UUID; + +import co.kr.pinhouse.domain.admin.dashboard.application.dto.response.AdminDashboardResponse; + +public interface AdminDashboardUseCase { + + AdminDashboardResponse getDashboard(UUID adminId); +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/dto/response/AdminNoticeResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/dto/response/AdminNoticeResponse.java index 449d9246..5b8c2f5c 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/dto/response/AdminNoticeResponse.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/dto/response/AdminNoticeResponse.java @@ -5,6 +5,7 @@ import co.kr.pinhouse.domain.admin.notice.domain.entity.NoticeAdminOverride; import co.kr.pinhouse.domain.housing.notice.domain.entity.NoticeDocument; +import co.kr.pinhouse.domain.housing.notice.domain.entity.Urls; import lombok.Builder; @Builder @@ -24,6 +25,7 @@ public record AdminNoticeResponse( LocalDate announceDate, LocalDate applyStart, LocalDate applyEnd, + String actualLink, boolean hidden, String adminMemo, boolean hasOverride, @@ -47,6 +49,7 @@ public static AdminNoticeResponse from(NoticeDocument notice, NoticeAdminOverrid .announceDate(notice.getAnnounceDate()) .applyStart(notice.getApplyStart()) .applyEnd(notice.getApplyEnd()) + .actualLink(resolveActualLink(notice)) .hidden(override != null && override.isHidden()) .adminMemo(override != null ? override.getAdminMemo() : null) .hasOverride(override != null) @@ -57,4 +60,25 @@ public static AdminNoticeResponse from(NoticeDocument notice, NoticeAdminOverrid private static String resolve(String overrideValue, String originalValue) { return overrideValue != null ? overrideValue : originalValue; } + + private static String resolveActualLink(NoticeDocument notice) { + Urls urls = notice.getUrls(); + if (urls == null) { + return null; + } + + if (hasText(urls.getApply())) { + return urls.getApply(); + } + + if (hasText(urls.getMyhomePc())) { + return urls.getMyhomePc(); + } + + return hasText(urls.getMyhomeMo()) ? urls.getMyhomeMo() : null; + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/dto/response/AdminNoticeSummaryResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/dto/response/AdminNoticeSummaryResponse.java index 1402ea5b..0e9d754a 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/dto/response/AdminNoticeSummaryResponse.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/dto/response/AdminNoticeSummaryResponse.java @@ -4,6 +4,7 @@ import co.kr.pinhouse.domain.admin.notice.domain.entity.NoticeAdminOverride; import co.kr.pinhouse.domain.housing.notice.domain.entity.NoticeDocument; +import co.kr.pinhouse.domain.housing.notice.domain.entity.Urls; import lombok.Builder; @Builder @@ -15,6 +16,7 @@ public record AdminNoticeSummaryResponse( LocalDate announceDate, LocalDate applyStart, LocalDate applyEnd, + String actualLink, boolean hidden, boolean hasOverride ) { @@ -28,6 +30,7 @@ public static AdminNoticeSummaryResponse from(NoticeDocument notice, NoticeAdmin .announceDate(notice.getAnnounceDate()) .applyStart(notice.getApplyStart()) .applyEnd(notice.getApplyEnd()) + .actualLink(resolveActualLink(notice)) .hidden(override != null && override.isHidden()) .hasOverride(override != null) .build(); @@ -36,4 +39,25 @@ public static AdminNoticeSummaryResponse from(NoticeDocument notice, NoticeAdmin private static String resolve(String overrideValue, String originalValue) { return overrideValue != null ? overrideValue : originalValue; } + + private static String resolveActualLink(NoticeDocument notice) { + Urls urls = notice.getUrls(); + if (urls == null) { + return null; + } + + if (hasText(urls.getApply())) { + return urls.getApply(); + } + + if (hasText(urls.getMyhomePc())) { + return urls.getMyhomePc(); + } + + return hasText(urls.getMyhomeMo()) ? urls.getMyhomeMo() : null; + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/repository/CsInquiryRepository.java b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/repository/CsInquiryRepository.java index 81f40285..2ffe270c 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/repository/CsInquiryRepository.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/repository/CsInquiryRepository.java @@ -1,17 +1,32 @@ package co.kr.pinhouse.domain.cs.domain.repository; +import java.time.LocalDateTime; +import java.util.List; import java.util.UUID; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import co.kr.pinhouse.domain.cs.domain.entity.CsInquiry; +import co.kr.pinhouse.domain.cs.domain.entity.CsInquiryStatus; public interface CsInquiryRepository extends JpaRepository, JpaSpecificationExecutor { Page findByUser_IdOrderByCreatedAtDesc(UUID userId, Pageable pageable); long countByUser_Id(UUID userId); + + long countByCreatedAtBetween(LocalDateTime from, LocalDateTime to); + + long countByStatusNotIn(List statuses); + + long countByStatusInAndLastMessageAtBefore(List statuses, LocalDateTime threshold); + + List findTop5ByOrderByCreatedAtDesc(); + + @EntityGraph(attributePaths = "user") + List findTop5ByOrderByLastMessageAtDescIdDesc(); } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/domain/repository/DiagnosisJpaRepository.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/domain/repository/DiagnosisJpaRepository.java index ecd40cf6..584d49c8 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/domain/repository/DiagnosisJpaRepository.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/domain/repository/DiagnosisJpaRepository.java @@ -1,10 +1,13 @@ package co.kr.pinhouse.domain.diagnostic.diagnosis.domain.repository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import co.kr.pinhouse.domain.diagnostic.diagnosis.domain.entity.Diagnosis; import co.kr.pinhouse.domain.user.domain.entity.User; @@ -39,4 +42,29 @@ public interface DiagnosisJpaRepository extends JpaRepository { */ void deleteByUser_Id(UUID userId); + long countByCreatedAtBetween(LocalDateTime from, LocalDateTime to); + + @Query(""" + select count(distinct d.user.id) + from Diagnosis d + where d.createdAt between :from and :to + """) + long countDistinctUsersByCreatedAtBetween( + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to + ); + + @Query(""" + select count(distinct d.user.id) + from Diagnosis d + where d.createdAt between :diagnosisFrom and :diagnosisTo + and d.user.createdAt between :userFrom and :userTo + """) + long countDistinctUsersByCreatedAtBetweenAndUserCreatedAtBetween( + @Param("diagnosisFrom") LocalDateTime diagnosisFrom, + @Param("diagnosisTo") LocalDateTime diagnosisTo, + @Param("userFrom") LocalDateTime userFrom, + @Param("userTo") LocalDateTime userTo + ); + } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/notice/domain/repository/NoticeDocumentRepositoryCustom.java b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/notice/domain/repository/NoticeDocumentRepositoryCustom.java index 44c5b0d1..a228b12b 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/notice/domain/repository/NoticeDocumentRepositoryCustom.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/notice/domain/repository/NoticeDocumentRepositoryCustom.java @@ -1,6 +1,7 @@ package co.kr.pinhouse.domain.housing.notice.domain.repository; import java.time.Instant; +import java.time.LocalDate; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -108,4 +109,15 @@ Page findRecommendedNoticesByDiagnosis( */ org.springframework.data.domain.Slice searchNoticesByHouseType(String keyword, Pageable pageable); + /** + * 특정 공고일의 공고 ID 목록 조회 + */ + java.util.List findNoticeIdsByAnnounceDate(LocalDate announceDate); + + /** + * 실제 링크 점검 대상 공고 조회 + * noticeId, urls 필드만 projection 한다. + */ + java.util.List findNoticeLinkCandidatesByAnnounceDateLessThanEqual(LocalDate announceDate); + } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/notice/domain/repository/NoticeDocumentRepositoryImpl.java b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/notice/domain/repository/NoticeDocumentRepositoryImpl.java index e563aeef..cf58c8aa 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/notice/domain/repository/NoticeDocumentRepositoryImpl.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/notice/domain/repository/NoticeDocumentRepositoryImpl.java @@ -1,6 +1,7 @@ package co.kr.pinhouse.domain.housing.notice.domain.repository; import java.time.Instant; +import java.time.LocalDate; import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.List; @@ -322,6 +323,27 @@ public org.springframework.data.domain.Slice searchNoticesByHous return findNoticeSlice(criteria, pageable); } + @Override + public List findNoticeIdsByAnnounceDate(LocalDate announceDate) { + Query query = new Query(Criteria.where("announceDate").is(announceDate)); + query.fields().include("noticeId"); + + return mongoTemplate.find(query, NoticeDocument.class) + .stream() + .map(NoticeDocument::getId) + .toList(); + } + + @Override + public List findNoticeLinkCandidatesByAnnounceDateLessThanEqual(LocalDate announceDate) { + Query query = new Query(Criteria.where("announceDate").lte(announceDate)); + query.fields() + .include("noticeId") + .include("urls"); + + return mongoTemplate.find(query, NoticeDocument.class); + } + private org.springframework.data.domain.Slice findNoticeSlice(Criteria criteria, Pageable pageable) { Query query = new Query(criteria).with(pageable); diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/user/domain/repository/UserJpaRepository.java b/module-domain/src/main/java/co/kr/pinhouse/domain/user/domain/repository/UserJpaRepository.java index 5b9ea69e..74816d06 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/user/domain/repository/UserJpaRepository.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/user/domain/repository/UserJpaRepository.java @@ -1,5 +1,6 @@ package co.kr.pinhouse.domain.user.domain.repository; +import java.time.LocalDateTime; import java.util.Optional; import java.util.UUID; @@ -28,4 +29,6 @@ Page findByNameContainingIgnoreCaseOrNicknameContainingIgnoreCaseOrEmailCo String emailKeyword, Pageable pageable ); + + long countByCreatedAtBetween(LocalDateTime from, LocalDateTime to); } diff --git a/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/dashboard/AdminDashboardApi.java b/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/dashboard/AdminDashboardApi.java new file mode 100644 index 00000000..1eb4ea37 --- /dev/null +++ b/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/dashboard/AdminDashboardApi.java @@ -0,0 +1,30 @@ +package co.kr.pinhouse.domain.admin.dashboard; + +import java.util.UUID; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import co.kr.pinhouse.common.auth.CurrentUserId; +import co.kr.pinhouse.common.response.ApiResponse; +import co.kr.pinhouse.domain.admin.dashboard.application.dto.response.AdminDashboardResponse; +import co.kr.pinhouse.domain.admin.dashboard.application.usecase.AdminDashboardUseCase; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/v1/admin/dashboard") +@RequiredArgsConstructor +@Tag(name = "관리자 대시보드 API", description = "관리자 홈 대시보드 집계 API") +public class AdminDashboardApi { + + private final AdminDashboardUseCase adminDashboardService; + + @GetMapping + @Operation(summary = "관리자 대시보드 조회", description = "대시보드 초기 렌더에 필요한 집계 데이터를 조회합니다.") + public ApiResponse getDashboard(@CurrentUserId(required = true) UUID adminId) { + return ApiResponse.ok(adminDashboardService.getDashboard(adminId)); + } +} From 667b992513c140fb171b8189dfb1bde34162c702 Mon Sep 17 00:00:00 2001 From: eedo_y Date: Tue, 28 Apr 2026 17:36:56 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=EB=8C=80=EC=A4=91=EA=B5=90?= =?UTF-8?q?=ED=86=B5=20=EA=B4=80=EB=A0=A8=20=EB=9D=BC=EB=B2=A8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#136)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/ComplexService.java | 32 +++++++++++--- .../util/TransitResponseMapper.java | 44 ++++++++++++++++--- 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/service/ComplexService.java b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/service/ComplexService.java index bd6da386..fd78b245 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/service/ComplexService.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/service/ComplexService.java @@ -6,7 +6,6 @@ import java.util.Comparator; import java.util.List; import java.util.UUID; -import java.util.function.BiFunction; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -121,7 +120,11 @@ public List getComplexUnitTypes(String id, UUID userId) { @Transactional public TransitRoutesResponse getDistanceV2(String id, String pinPointId) throws UnsupportedEncodingException { return calculateTransitRoute(id, pinPointId, - (pathResult, pinPoint) -> mapper.toTransitRoutesResponse(pathResult, resolveDepartureLabel(pinPoint))); + (pathResult, pinPoint, complex) -> mapper.toTransitRoutesResponse( + pathResult, + resolveDepartureLabel(pinPoint), + resolveArrivalLabel(complex) + )); } // ================= @@ -464,7 +467,7 @@ public DepositResponse getLeaseMinMax(String complexId, String type) { * * @param complexId 임대주택 ID * @param pinPointId 핀포인트 ID - * @param pathMapper PathResult와 PinPoint를 원하는 타입으로 변환하는 함수 + * @param pathMapper PathResult, PinPoint, ComplexDocument를 원하는 타입으로 변환하는 함수 * @param 반환 타입 * @return 변환된 결과 * @throws UnsupportedEncodingException 인코딩 예외 @@ -472,7 +475,7 @@ public DepositResponse getLeaseMinMax(String complexId, String type) { private T calculateTransitRoute( String complexId, String pinPointId, - BiFunction pathMapper + TransitRouteMapper pathMapper ) throws UnsupportedEncodingException { /// 임대주택 조회 @@ -493,7 +496,7 @@ private T calculateTransitRoute( validateTransitRoute(pathResult, complexId, pinPointId); /// 결과 매핑 - return pathMapper.apply(pathResult, pinPoint); + return pathMapper.apply(pathResult, pinPoint, complex); } private String resolveDepartureLabel(PinPoint pinPoint) { @@ -509,6 +512,16 @@ private String resolveDepartureLabel(PinPoint pinPoint) { return "출발지"; } + private String resolveArrivalLabel(ComplexDocument complex) { + if (complex == null) { + return null; + } + if (hasText(complex.getName())) { + return complex.getName(); + } + return null; + } + private boolean hasText(String value) { return value != null && !value.isBlank(); } @@ -536,7 +549,7 @@ public TransitInfoResponse getTransitInfo(String id, String pinPointId) throws U } /// 상세조회 캐시가 없으면 경로를 다시 계산해 색상 포함 응답을 생성한다. - return calculateTransitRoute(id, pinPointId, (pathResult, pinPoint) -> { + return calculateTransitRoute(id, pinPointId, (pathResult, pinPoint, complex) -> { RootResult rootResult = mapper.selectBest(pathResult); TransitInfoResponse transitInfo = mapper.toTransitInfoResponse(rootResult); @@ -565,7 +578,7 @@ public DistanceResponse getEasyDistance(String id, String pinPointId) throws Uns } /// 캐시가 없으면 템플릿 메서드를 사용하여 경로 계산 - DistanceResponse distance = calculateTransitRoute(id, pinPointId, (pathResult, pinPoint) -> { + DistanceResponse distance = calculateTransitRoute(id, pinPointId, (pathResult, pinPoint, complex) -> { RootResult rootResult = mapper.selectBest(pathResult); TransitInfoResponse transitInfo = mapper.toTransitInfoResponse(rootResult); @@ -580,4 +593,9 @@ public DistanceResponse getEasyDistance(String id, String pinPointId) throws Uns /// 리턴 return distance; } + + @FunctionalInterface + private interface TransitRouteMapper { + T apply(PathResult pathResult, PinPoint pinPoint, ComplexDocument complex); + } } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/TransitResponseMapper.java b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/TransitResponseMapper.java index 4811a464..91d8e2a3 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/TransitResponseMapper.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/util/TransitResponseMapper.java @@ -174,13 +174,20 @@ public List from(RootResult route) { * 새 스키마: 3개 경로를 한 번에 변환 */ public TransitRoutesResponse toTransitRoutesResponse(PathResult pathResult) { - return toTransitRoutesResponse(pathResult, null); + return toTransitRoutesResponse(pathResult, null, null); } /** * 새 스키마: 3개 경로를 한 번에 변환 */ public TransitRoutesResponse toTransitRoutesResponse(PathResult pathResult, String departureLabel) { + return toTransitRoutesResponse(pathResult, departureLabel, null); + } + + /** + * 새 스키마: 3개 경로를 한 번에 변환 + */ + public TransitRoutesResponse toTransitRoutesResponse(PathResult pathResult, String departureLabel, String arrivalLabel) { if (pathResult == null || pathResult.routes() == null) { return TransitRoutesResponse.builder() .totalCount(0) @@ -193,7 +200,7 @@ public TransitRoutesResponse toTransitRoutesResponse(PathResult pathResult, Stri for (int i = 0; i < top3.size(); i++) { RootResult route = top3.get(i); - routeResponses.add(toRouteResponse(route, i, departureLabel)); + routeResponses.add(toRouteResponse(route, i, departureLabel, arrivalLabel)); } return TransitRoutesResponse.builder() @@ -205,12 +212,17 @@ public TransitRoutesResponse toTransitRoutesResponse(PathResult pathResult, Stri /** * 개별 경로 변환 */ - private TransitRoutesResponse.RouteResponse toRouteResponse(RootResult route, int index, String departureLabel) { + private TransitRoutesResponse.RouteResponse toRouteResponse( + RootResult route, + int index, + String departureLabel, + String arrivalLabel + ) { return TransitRoutesResponse.RouteResponse.builder() .routeIndex(index) .summary(toSummaryResponse(route)) .distance(toSegmentResponses(route)) - .steps(toStepResponses(route, departureLabel)) + .steps(toStepResponses(route, departureLabel, arrivalLabel)) .build(); } @@ -317,7 +329,11 @@ public List toSegmentResponses(RootResult /** * Steps 생성 (색깔 + 승차/하차 통합) */ - private List toStepResponses(RootResult route, String departureLabel) { + private List toStepResponses( + RootResult route, + String departureLabel, + String arrivalLabel + ) { if (route == null || route.steps() == null || route.steps().isEmpty()) { return List.of(); } @@ -372,8 +388,7 @@ private List toStepResponses(RootResult rout } // 도착지 추가 - RootResult.DistanceStep lastTransport = transportSteps.get(transportSteps.size() - 1); - steps.add(createArriveStep(lastTransport.endName())); + steps.add(createArriveStep(resolveArrivalLabel(distanceSteps, arrivalLabel))); // minutes가 0인 step 필터링 (ARRIVE/ALIGHT는 null이므로 유지) List filteredSteps = steps.stream() @@ -508,6 +523,21 @@ private TransitRoutesResponse.StepResponse createArriveStep(String stopName) { .build(); } + private String resolveArrivalLabel(List distanceSteps, String arrivalLabel) { + if (hasText(arrivalLabel)) { + return arrivalLabel; + } + + for (int i = distanceSteps.size() - 1; i >= 0; i--) { + String endName = distanceSteps.get(i).endName(); + if (hasText(endName)) { + return endName; + } + } + + return null; + } + /** * Step 인덱스 부여 */ From 883dde17b6e1d9c4691000a4a5f4c4c3b171fa82 Mon Sep 17 00:00:00 2001 From: eedo_y Date: Tue, 28 Apr 2026 17:54:24 +0900 Subject: [PATCH 6/6] =?UTF-8?q?style:=20lint=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#136)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../diagnosis/domain/repository/DiagnosisJpaRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/domain/repository/DiagnosisJpaRepository.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/domain/repository/DiagnosisJpaRepository.java index 584d49c8..c889e142 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/domain/repository/DiagnosisJpaRepository.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/domain/repository/DiagnosisJpaRepository.java @@ -57,8 +57,8 @@ long countDistinctUsersByCreatedAtBetween( @Query(""" select count(distinct d.user.id) from Diagnosis d - where d.createdAt between :diagnosisFrom and :diagnosisTo - and d.user.createdAt between :userFrom and :userTo + where d.createdAt between :diagnosisFrom and :diagnosisTo + and d.user.createdAt between :userFrom and :userTo """) long countDistinctUsersByCreatedAtBetweenAndUserCreatedAtBetween( @Param("diagnosisFrom") LocalDateTime diagnosisFrom,