From 5832a990008eed625ee88953ea9b04182021647e Mon Sep 17 00:00:00 2001 From: RosieOh Date: Tue, 4 Aug 2026 14:53:43 +0900 Subject: [PATCH 01/68] =?UTF-8?q?FEAT=20:=20=EC=8B=9C=EC=84=A4=20=EC=A0=95?= =?UTF-8?q?=EC=9B=90=C2=B7=ED=98=84=EC=9B=90=20=EA=B4=80=EC=B8=A1=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=20=EC=A0=81=EC=9E=AC=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/sync/CapacitySnapshotRecorder.java | 47 +++++++++++++++ .../sync/CareFacilityUpsertService.java | 2 + .../sync/KindergartenUpsertService.java | 2 + .../entity/FacilityCapacitySnapshot.java | 60 +++++++++++++++++++ .../FacilityCapacitySnapshotRepository.java | 30 ++++++++++ .../V5__facility_capacity_snapshot.sql | 24 ++++++++ .../sync/KindergartenUpsertServiceTest.java | 2 +- 7 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/carecode/core/client/sync/CapacitySnapshotRecorder.java create mode 100644 src/main/java/com/carecode/domain/careFacility/entity/FacilityCapacitySnapshot.java create mode 100644 src/main/java/com/carecode/domain/careFacility/repository/FacilityCapacitySnapshotRepository.java create mode 100644 src/main/resources/db/migration/V5__facility_capacity_snapshot.sql diff --git a/src/main/java/com/carecode/core/client/sync/CapacitySnapshotRecorder.java b/src/main/java/com/carecode/core/client/sync/CapacitySnapshotRecorder.java new file mode 100644 index 00000000..2a6ba135 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/CapacitySnapshotRecorder.java @@ -0,0 +1,47 @@ +package com.carecode.core.client.sync; + +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.FacilityCapacitySnapshot; +import com.carecode.domain.careFacility.repository.FacilityCapacitySnapshotRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +/** 동기화 시점의 정원·현원을 이력으로 남긴다. 시설 행은 최신값만 갖고, 추이는 여기에 쌓인다. */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CapacitySnapshotRecorder { + + private final FacilityCapacitySnapshotRepository snapshotRepository; + + /** 호출부의 트랜잭션에 참여한다. 스냅샷 실패가 시설 저장을 되돌리지 않도록 예외는 삼킨다. */ + public void record(CareFacility facility) { + if (facility.getId() == null) { + return; + } + // 정원·현원이 둘 다 없으면 추이 분석에 쓸 수 없으므로 남기지 않는다. + if (facility.getCapacity() == null && facility.getCurrentEnrollment() == null) { + return; + } + + LocalDate today = LocalDate.now(); + try { + snapshotRepository.findByFacilityIdAndObservedDate(facility.getId(), today) + .ifPresentOrElse( + existing -> existing.refresh(facility.getCapacity(), + facility.getCurrentEnrollment(), facility.getAvailableSpots()), + () -> snapshotRepository.save(FacilityCapacitySnapshot.builder() + .facilityId(facility.getId()) + .observedDate(today) + .capacity(facility.getCapacity()) + .currentEnrollment(facility.getCurrentEnrollment()) + .availableSpots(facility.getAvailableSpots()) + .build())); + } catch (Exception e) { + log.warn("정원 스냅샷 기록 실패 - facilityId={}, 사유={}", facility.getId(), e.getMessage()); + } + } +} diff --git a/src/main/java/com/carecode/core/client/sync/CareFacilityUpsertService.java b/src/main/java/com/carecode/core/client/sync/CareFacilityUpsertService.java index bccd1766..358bb60d 100644 --- a/src/main/java/com/carecode/core/client/sync/CareFacilityUpsertService.java +++ b/src/main/java/com/carecode/core/client/sync/CareFacilityUpsertService.java @@ -19,6 +19,7 @@ public class CareFacilityUpsertService { private final CareFacilityRepository careFacilityRepository; + private final CapacitySnapshotRecorder snapshotRecorder; /** 시설 코드 기준 upsert. */ @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -72,6 +73,7 @@ public boolean upsert(JsonNode row) { facility.setUpdatedAt(LocalDateTime.now()); careFacilityRepository.save(facility); + snapshotRecorder.record(facility); return isNew; } diff --git a/src/main/java/com/carecode/core/client/sync/KindergartenUpsertService.java b/src/main/java/com/carecode/core/client/sync/KindergartenUpsertService.java index 3c966788..739660df 100644 --- a/src/main/java/com/carecode/core/client/sync/KindergartenUpsertService.java +++ b/src/main/java/com/carecode/core/client/sync/KindergartenUpsertService.java @@ -25,6 +25,7 @@ public class KindergartenUpsertService { public static final String CODE_PREFIX = "KG-"; private final CareFacilityRepository careFacilityRepository; + private final CapacitySnapshotRecorder snapshotRecorder; /** 시설 코드 기준 upsert. 표준데이터에 고유 코드가 없으면 이름+주소로 만들어 쓴다. */ @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -83,6 +84,7 @@ public boolean upsert(JsonNode row) { facility.setUpdatedAt(LocalDateTime.now()); careFacilityRepository.save(facility); + snapshotRecorder.record(facility); return isNew; } diff --git a/src/main/java/com/carecode/domain/careFacility/entity/FacilityCapacitySnapshot.java b/src/main/java/com/carecode/domain/careFacility/entity/FacilityCapacitySnapshot.java new file mode 100644 index 00000000..7afe6d94 --- /dev/null +++ b/src/main/java/com/carecode/domain/careFacility/entity/FacilityCapacitySnapshot.java @@ -0,0 +1,60 @@ +package com.carecode.domain.careFacility.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** 특정 시점의 시설 정원·현원 관측값. 한 번 쓰면 고치지 않는 append-only 기록이다. */ +@Entity +@Table(name = "TBL_FACILITY_CAPACITY_SNAPSHOT", + uniqueConstraints = @UniqueConstraint(name = "UK_FACILITY_SNAPSHOT_DATE", + columnNames = {"FACILITY_ID", "OBSERVED_DATE"})) +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class FacilityCapacitySnapshot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ID") + private Long id; + + @Column(name = "FACILITY_ID", nullable = false) + private Long facilityId; + + @Column(name = "OBSERVED_DATE", nullable = false) + private LocalDate observedDate; + + @Column(name = "CAPACITY") + private Integer capacity; + + @Column(name = "CURRENT_ENROLLMENT") + private Integer currentEnrollment; + + @Column(name = "AVAILABLE_SPOTS") + private Integer availableSpots; + + @Column(name = "CREATED_AT", nullable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + } + + /** 같은 날 재동기화 시 최신 관측값으로 맞춘다. 날짜별로 한 행만 유지한다. */ + public void refresh(Integer capacity, Integer currentEnrollment, Integer availableSpots) { + this.capacity = capacity; + this.currentEnrollment = currentEnrollment; + this.availableSpots = availableSpots; + } +} diff --git a/src/main/java/com/carecode/domain/careFacility/repository/FacilityCapacitySnapshotRepository.java b/src/main/java/com/carecode/domain/careFacility/repository/FacilityCapacitySnapshotRepository.java new file mode 100644 index 00000000..cfd990c1 --- /dev/null +++ b/src/main/java/com/carecode/domain/careFacility/repository/FacilityCapacitySnapshotRepository.java @@ -0,0 +1,30 @@ +package com.carecode.domain.careFacility.repository; + +import com.carecode.domain.careFacility.entity.FacilityCapacitySnapshot; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Repository +public interface FacilityCapacitySnapshotRepository extends JpaRepository { + + Optional findByFacilityIdAndObservedDate(Long facilityId, LocalDate observedDate); + + /** 예측 입력. 오래된 것부터 줘야 증감을 순서대로 훑을 수 있다. */ + @Query("SELECT s FROM FacilityCapacitySnapshot s " + + "WHERE s.facilityId = :facilityId AND s.observedDate >= :from " + + "ORDER BY s.observedDate ASC") + List findHistory(@Param("facilityId") Long facilityId, + @Param("from") LocalDate from); + + /** 관측 기간이 얼마나 쌓였는지. 예측 가능 여부 판단에 쓴다. */ + @Query("SELECT MIN(s.observedDate) FROM FacilityCapacitySnapshot s WHERE s.facilityId = :facilityId") + Optional findEarliestObservedDate(@Param("facilityId") Long facilityId); + + long countByFacilityId(Long facilityId); +} diff --git a/src/main/resources/db/migration/V5__facility_capacity_snapshot.sql b/src/main/resources/db/migration/V5__facility_capacity_snapshot.sql new file mode 100644 index 00000000..0c71645b --- /dev/null +++ b/src/main/resources/db/migration/V5__facility_capacity_snapshot.sql @@ -0,0 +1,24 @@ +-- 시설 정원·현원 시계열. +-- 동기화 때마다 현원을 덮어쓰면 관측 이력이 사라져 입소 가능 시점을 예측할 수 없다. +-- 이 테이블이 쌓이는 기간 자체가 경쟁 장벽이므로 가능한 이른 시점부터 적재한다. +CREATE TABLE TBL_FACILITY_CAPACITY_SNAPSHOT ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + FACILITY_ID BIGINT NOT NULL COMMENT '시설 ID', + OBSERVED_DATE DATE NOT NULL COMMENT '관측일 (동기화 실행일)', + CAPACITY INT COMMENT '정원', + CURRENT_ENROLLMENT INT COMMENT '현원', + AVAILABLE_SPOTS INT COMMENT '잔여석 (정원 - 현원)', + CREATED_AT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- 같은 날 재동기화해도 행이 늘지 않도록 (시설, 관측일) 을 고유키로 둔다. + CONSTRAINT UK_FACILITY_SNAPSHOT_DATE UNIQUE (FACILITY_ID, OBSERVED_DATE), + CONSTRAINT FK_SNAPSHOT_FACILITY FOREIGN KEY (FACILITY_ID) + REFERENCES TBL_CARE_FACILITIES (ID) ON DELETE CASCADE +) COMMENT '시설 정원 변동 관측 이력'; + +-- 예측은 항상 "특정 시설의 기간별 추이" 로 조회하므로 이 순서가 맞다. +CREATE INDEX IDX_SNAPSHOT_FACILITY_DATE + ON TBL_FACILITY_CAPACITY_SNAPSHOT (FACILITY_ID, OBSERVED_DATE DESC); + +-- 전국 단위 집계(지역별 여석률 등) 용 +CREATE INDEX IDX_SNAPSHOT_DATE + ON TBL_FACILITY_CAPACITY_SNAPSHOT (OBSERVED_DATE); diff --git a/src/test/java/com/carecode/core/client/sync/KindergartenUpsertServiceTest.java b/src/test/java/com/carecode/core/client/sync/KindergartenUpsertServiceTest.java index 7e3affd3..1145004e 100644 --- a/src/test/java/com/carecode/core/client/sync/KindergartenUpsertServiceTest.java +++ b/src/test/java/com/carecode/core/client/sync/KindergartenUpsertServiceTest.java @@ -30,7 +30,7 @@ class KindergartenUpsertServiceTest { void setUp() { repository = mock(CareFacilityRepository.class); when(repository.findByFacilityCode(anyString())).thenReturn(Optional.empty()); - service = new KindergartenUpsertService(repository); + service = new KindergartenUpsertService(repository, mock(CapacitySnapshotRecorder.class)); } @Test From 9921c89b1d68d2107e2a84bc807cdef8ba4ec784 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Tue, 4 Aug 2026 14:53:44 +0900 Subject: [PATCH 02/68] =?UTF-8?q?FEAT=20:=20=EA=B4=80=EC=B8=A1=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=20=EA=B8=B0=EB=B0=98=20=EC=9E=85=EC=86=8C=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=20=EC=8B=9C=EC=A0=90=20=EC=98=88=EC=B8=A1=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../careFacility/app/CareFacilityFacade.java | 10 +- .../controller/CareFacilityController.java | 14 +- .../response/AdmissionForecastResponse.java | 41 ++++ .../service/AdmissionForecastService.java | 197 ++++++++++++++++++ .../service/AdmissionForecastServiceTest.java | 177 ++++++++++++++++ 5 files changed, 436 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/carecode/domain/careFacility/dto/response/AdmissionForecastResponse.java create mode 100644 src/main/java/com/carecode/domain/careFacility/service/AdmissionForecastService.java create mode 100644 src/test/java/com/carecode/domain/careFacility/service/AdmissionForecastServiceTest.java diff --git a/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java b/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java index e478f7b1..face7459 100644 --- a/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java +++ b/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java @@ -1,5 +1,6 @@ package com.carecode.domain.careFacility.app; +import com.carecode.domain.careFacility.dto.response.AdmissionForecastResponse; import com.carecode.domain.careFacility.dto.response.BookingResponse; import com.carecode.domain.careFacility.dto.request.ReviewRequest; import com.carecode.domain.careFacility.dto.request.CreateBookingRequest; @@ -10,6 +11,7 @@ import com.carecode.domain.careFacility.dto.response.CareFacilityStatsResponse; import com.carecode.domain.careFacility.dto.response.ReviewResponse; import com.carecode.domain.careFacility.entity.FacilityType; +import com.carecode.domain.careFacility.service.AdmissionForecastService; import com.carecode.domain.careFacility.service.CareFacilityBookingService; import com.carecode.domain.careFacility.service.CareFacilityService; import lombok.RequiredArgsConstructor; @@ -25,6 +27,7 @@ public class CareFacilityFacade { private final CareFacilityService careFacilityService; private final CareFacilityBookingService bookingService; + private final AdmissionForecastService admissionForecastService; @Transactional(readOnly = true) public List getAllCareFacilities(int page, int size) { @@ -202,6 +205,9 @@ public ReviewResponse updateReview(Long reviewId, String userEmail, ReviewReques public void deleteReview(Long reviewId, String userEmail) { careFacilityService.deleteReview(reviewId, userEmail); } -} - + @Transactional(readOnly = true) + public AdmissionForecastResponse forecastAdmission(Long facilityId, Integer childAgeMonths, Integer horizonMonths) { + return admissionForecastService.forecast(facilityId, childAgeMonths, horizonMonths); + } +} diff --git a/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java b/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java index 6b01f548..d5c51644 100644 --- a/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java +++ b/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java @@ -11,6 +11,7 @@ import com.carecode.domain.careFacility.dto.response.CareFacilityInfo; import com.carecode.domain.careFacility.dto.response.CareFacilityListResponse; import com.carecode.domain.careFacility.dto.response.CareFacilityStatsResponse; +import com.carecode.domain.careFacility.dto.response.AdmissionForecastResponse; import com.carecode.domain.careFacility.dto.response.BookingResponse; import com.carecode.domain.careFacility.dto.response.ReviewResponse; import com.carecode.domain.careFacility.dto.request.CreateBookingRequest; @@ -432,4 +433,15 @@ public ResponseEntity> getTodayBookingsByFacility(@Paramet return ResponseEntity.ok(bookings); } -} \ No newline at end of file + + // 입소 가능 시점 예측 + @GetMapping("/{facilityId}/admission-forecast") + @LogExecutionTime + @Operation(summary = "입소 가능 시점 예측", description = "관측된 정원 변동으로 자리가 날 확률을 추정합니다.") + public ResponseEntity forecastAdmission( + @Parameter(description = "시설 ID", required = true) @PathVariable Long facilityId, + @Parameter(description = "아이 월령", example = "18") @RequestParam(required = false) Integer childAgeMonths, + @Parameter(description = "예측 기간(개월)", example = "6") @RequestParam(required = false) Integer horizonMonths) { + return ResponseEntity.ok(careFacilityFacade.forecastAdmission(facilityId, childAgeMonths, horizonMonths)); + } +} diff --git a/src/main/java/com/carecode/domain/careFacility/dto/response/AdmissionForecastResponse.java b/src/main/java/com/carecode/domain/careFacility/dto/response/AdmissionForecastResponse.java new file mode 100644 index 00000000..330fbb98 --- /dev/null +++ b/src/main/java/com/carecode/domain/careFacility/dto/response/AdmissionForecastResponse.java @@ -0,0 +1,41 @@ +package com.carecode.domain.careFacility.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.util.List; + +/** 입소 가능 시점 예측. 근거 없이 숫자만 주지 않는다. */ +@Getter +@Builder +public class AdmissionForecastResponse { + + private Long facilityId; + private String facilityName; + + /** 예측 산출 가능 여부. false 면 probability 는 null 이다. */ + private boolean available; + + /** 예측을 낼 수 없는 이유. available=true 면 null. */ + private String unavailableReason; + + /** 관측 기간(일). 짧을수록 신뢰도가 낮다. */ + private long observationDays; + private long observationCount; + + /** 아이 월령 기준 배정 반. */ + private String targetClass; + + /** 목표 시점까지 자리가 날 확률(0~100). */ + private Integer probability; + + /** LOW / MEDIUM / HIGH — 관측량과 변동성으로 정한다. */ + private String confidence; + + /** 예측 기준 시점. */ + private LocalDate targetDate; + + /** 사용자에게 보여줄 근거 문장. */ + private List reasons; +} diff --git a/src/main/java/com/carecode/domain/careFacility/service/AdmissionForecastService.java b/src/main/java/com/carecode/domain/careFacility/service/AdmissionForecastService.java new file mode 100644 index 00000000..3e9cfee3 --- /dev/null +++ b/src/main/java/com/carecode/domain/careFacility/service/AdmissionForecastService.java @@ -0,0 +1,197 @@ +package com.carecode.domain.careFacility.service; + +import com.carecode.core.exception.CareServiceException; +import com.carecode.domain.careFacility.dto.response.AdmissionForecastResponse; +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.FacilityCapacitySnapshot; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.carecode.domain.careFacility.repository.FacilityCapacitySnapshotRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.Month; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; + +/** + * 관측된 정원 변동으로 입소 가능 시점을 추정한다. + * 통계적 근거가 부족하면 숫자를 만들어내지 않고 부족하다고 답한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdmissionForecastService { + + /** 이보다 관측이 적으면 추세라고 부를 수 없다. */ + private static final int MIN_OBSERVATIONS = 4; + private static final int MIN_OBSERVATION_DAYS = 60; + + /** 신학기. 승급·졸업으로 자리가 가장 많이 열리는 시점이다. */ + private static final Month NEW_TERM_MONTH = Month.MARCH; + + private static final int LOOKBACK_MONTHS = 18; + private static final int DEFAULT_HORIZON_MONTHS = 6; + + private final CareFacilityRepository careFacilityRepository; + private final FacilityCapacitySnapshotRepository snapshotRepository; + + /** 아이 월령 기준으로 목표 시점까지 자리가 날 확률을 추정한다. */ + public AdmissionForecastResponse forecast(Long facilityId, Integer childAgeMonths, Integer horizonMonths) { + CareFacility facility = careFacilityRepository.findById(facilityId) + .orElseThrow(() -> new CareServiceException("시설을 찾을 수 없습니다: " + facilityId)); + + LocalDate today = LocalDate.now(); + int horizon = horizonMonths != null && horizonMonths > 0 ? horizonMonths : DEFAULT_HORIZON_MONTHS; + LocalDate targetDate = today.plusMonths(horizon); + + List history = + snapshotRepository.findHistory(facilityId, today.minusMonths(LOOKBACK_MONTHS)); + + long observationDays = history.isEmpty() ? 0 + : ChronoUnit.DAYS.between(history.get(0).getObservedDate(), today); + + AdmissionForecastResponse.AdmissionForecastResponseBuilder base = AdmissionForecastResponse.builder() + .facilityId(facilityId) + .facilityName(facility.getName()) + .observationCount(history.size()) + .observationDays(observationDays) + .targetClass(resolveClassName(childAgeMonths)) + .targetDate(targetDate); + + String shortage = checkDataSufficiency(history.size(), observationDays); + if (shortage != null) { + return base.available(false).unavailableReason(shortage).build(); + } + + return buildForecast(base, history, targetDate, today); + } + + /** 관측이 부족한 이유를 사용자가 이해할 수 있게 돌려준다. */ + private String checkDataSufficiency(int count, long days) { + if (count == 0) { + return "이 시설의 정원 관측 이력이 아직 없습니다."; + } + if (count < MIN_OBSERVATIONS) { + return String.format("관측 %d회로는 추세를 판단할 수 없습니다. (최소 %d회 필요)", count, MIN_OBSERVATIONS); + } + if (days < MIN_OBSERVATION_DAYS) { + return String.format("관측 기간이 %d일로 짧습니다. (최소 %d일 필요)", days, MIN_OBSERVATION_DAYS); + } + return null; + } + + private AdmissionForecastResponse buildForecast( + AdmissionForecastResponse.AdmissionForecastResponseBuilder base, + List history, LocalDate targetDate, LocalDate today) { + + List reasons = new ArrayList<>(); + + // 1. 관측 구간 중 자리가 있었던 비율 — 예측의 기준선 + long observedWithSeat = history.stream().filter(this::hasSeat).count(); + double baseRate = (double) observedWithSeat / history.size(); + reasons.add(String.format("최근 관측 %d회 중 %d회에 잔여석이 있었습니다.", history.size(), observedWithSeat)); + + // 2. 자리가 실제로 열린 횟수. 잔여석이 늘어난 전환만 센다. + int openings = countSeatOpenings(history); + double openingsPerMonth = monthsCovered(history) > 0 ? openings / monthsCovered(history) : 0; + if (openings > 0) { + reasons.add(String.format("관측 기간에 자리가 %d회 열렸습니다 (월 평균 %.1f회).", openings, openingsPerMonth)); + } else { + reasons.add("관측 기간에 자리가 열린 적이 없습니다."); + } + + // 3. 목표 시점까지 신학기가 끼면 승급·졸업으로 자리가 크게 열린다. + boolean spansNewTerm = spansNewTerm(today, targetDate); + if (spansNewTerm) { + reasons.add("목표 시점까지 3월 신학기가 포함되어 승급·졸업으로 자리가 열릴 가능성이 높습니다."); + } + + int probability = estimateProbability(baseRate, openingsPerMonth, + ChronoUnit.MONTHS.between(today, targetDate), spansNewTerm); + + return base.available(true) + .probability(probability) + .confidence(resolveConfidence(history.size(), openings)) + .reasons(reasons) + .build(); + } + + /** + * 기준선(관측 중 자리 있던 비율)에 자리 발생률을 포아송으로 얹는다. + * 정교한 모델이 아니라 관측을 그대로 반영하는 추정치이며, 근거를 함께 노출해 과신을 막는다. + */ + private int estimateProbability(double baseRate, double openingsPerMonth, long months, boolean spansNewTerm) { + // 기간 내 자리가 최소 1회 열릴 확률 = 1 - e^(-λt) + double openingProbability = 1 - Math.exp(-openingsPerMonth * Math.max(months, 1)); + double combined = 1 - (1 - baseRate) * (1 - openingProbability); + + if (spansNewTerm) { + // 신학기는 관측만으로 잡히지 않는 구조적 요인이라 하한을 둔다. + combined = Math.max(combined, 0.5); + } + return (int) Math.round(Math.min(combined, 0.95) * 100); + } + + /** 잔여석이 0 이하에서 1 이상으로 바뀐 전환 횟수. */ + private int countSeatOpenings(List history) { + int openings = 0; + boolean previousHadSeat = hasSeat(history.get(0)); + for (int i = 1; i < history.size(); i++) { + boolean current = hasSeat(history.get(i)); + if (current && !previousHadSeat) { + openings++; + } + previousHadSeat = current; + } + return openings; + } + + private boolean hasSeat(FacilityCapacitySnapshot snapshot) { + Integer spots = snapshot.getAvailableSpots(); + if (spots != null) { + return spots > 0; + } + Integer capacity = snapshot.getCapacity(); + Integer enrolled = snapshot.getCurrentEnrollment(); + return capacity != null && enrolled != null && capacity > enrolled; + } + + private double monthsCovered(List history) { + long days = ChronoUnit.DAYS.between( + history.get(0).getObservedDate(), history.get(history.size() - 1).getObservedDate()); + return days / 30.0; + } + + private boolean spansNewTerm(LocalDate from, LocalDate to) { + LocalDate term = LocalDate.of(from.getYear(), NEW_TERM_MONTH, 1); + if (term.isBefore(from)) { + term = term.plusYears(1); + } + return !term.isAfter(to); + } + + /** 관측이 많고 자리 열림이 실제로 관측됐을수록 신뢰도가 높다. */ + private String resolveConfidence(int observations, int openings) { + if (observations >= 24 && openings >= 3) { + return "HIGH"; + } + if (observations >= 12 && openings >= 1) { + return "MEDIUM"; + } + return "LOW"; + } + + /** 어린이집 반 편성은 만 나이 기준이다. */ + private String resolveClassName(Integer ageMonths) { + if (ageMonths == null) { + return null; + } + int years = ageMonths / 12; + return years >= 5 ? "5세반 이상" : years + "세반"; + } +} diff --git a/src/test/java/com/carecode/domain/careFacility/service/AdmissionForecastServiceTest.java b/src/test/java/com/carecode/domain/careFacility/service/AdmissionForecastServiceTest.java new file mode 100644 index 00000000..4e650181 --- /dev/null +++ b/src/test/java/com/carecode/domain/careFacility/service/AdmissionForecastServiceTest.java @@ -0,0 +1,177 @@ +package com.carecode.domain.careFacility.service; + +import com.carecode.domain.careFacility.dto.response.AdmissionForecastResponse; +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.FacilityCapacitySnapshot; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.carecode.domain.careFacility.repository.FacilityCapacitySnapshotRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("입소 가능 시점 예측") +class AdmissionForecastServiceTest { + + private CareFacilityRepository facilityRepository; + private FacilityCapacitySnapshotRepository snapshotRepository; + private AdmissionForecastService service; + + @BeforeEach + void setUp() { + facilityRepository = mock(CareFacilityRepository.class); + snapshotRepository = mock(FacilityCapacitySnapshotRepository.class); + when(facilityRepository.findById(anyLong())) + .thenReturn(Optional.of(CareFacility.builder().name("행복어린이집").build())); + service = new AdmissionForecastService(facilityRepository, snapshotRepository); + } + + @Test + @DisplayName("관측이 없으면 확률을 만들어내지 않는다") + void refusesWithoutObservations() { + givenSnapshots(List.of()); + + AdmissionForecastResponse result = service.forecast(1L, 12, 6); + + assertThat(result.isAvailable()).isFalse(); + assertThat(result.getProbability()).isNull(); + assertThat(result.getUnavailableReason()).contains("관측 이력이 아직 없습니다"); + } + + @Test + @DisplayName("관측 횟수가 모자라면 추세로 보지 않는다") + void refusesWithTooFewObservations() { + givenSnapshots(weekly(3, 100, 100)); + + AdmissionForecastResponse result = service.forecast(1L, 12, 6); + + assertThat(result.isAvailable()).isFalse(); + assertThat(result.getUnavailableReason()).contains("최소 4회 필요"); + } + + @Test + @DisplayName("관측 기간이 짧으면 판단을 보류한다") + void refusesWithShortObservationWindow() { + // 6회지만 하루 간격이라 기간이 짧다 + List daily = new ArrayList<>(); + for (int i = 0; i < 6; i++) { + daily.add(snapshot(LocalDate.now().minusDays(6 - i), 100, 100)); + } + givenSnapshots(daily); + + AdmissionForecastResponse result = service.forecast(1L, 12, 6); + + assertThat(result.isAvailable()).isFalse(); + assertThat(result.getUnavailableReason()).contains("관측 기간이"); + } + + @Test + @DisplayName("계속 만원이었으면 확률이 낮게 나온다") + void lowProbabilityWhenAlwaysFull() { + givenSnapshots(weekly(20, 100, 100)); + + // 신학기가 끼지 않도록 짧은 기간으로 본다 + AdmissionForecastResponse result = service.forecast(1L, 12, 1); + + assertThat(result.isAvailable()).isTrue(); + assertThat(result.getProbability()).isLessThan(30); + assertThat(result.getReasons()).anyMatch(r -> r.contains("자리가 열린 적이 없습니다")); + } + + @Test + @DisplayName("자리가 자주 열렸으면 확률이 높게 나온다") + void highProbabilityWhenSeatsOpenOften() { + List history = new ArrayList<>(); + LocalDate start = LocalDate.now().minusWeeks(20); + for (int i = 0; i < 20; i++) { + // 만원과 여석을 반복 — 자리가 계속 열리는 시설 + int enrolled = i % 2 == 0 ? 100 : 95; + history.add(snapshot(start.plusWeeks(i), 100, enrolled)); + } + givenSnapshots(history); + + AdmissionForecastResponse result = service.forecast(1L, 12, 1); + + assertThat(result.isAvailable()).isTrue(); + assertThat(result.getProbability()).isGreaterThan(60); + } + + @Test + @DisplayName("확률이 100%가 되지는 않는다") + void neverReturnsCertainty() { + List history = new ArrayList<>(); + LocalDate start = LocalDate.now().minusWeeks(30); + for (int i = 0; i < 30; i++) { + history.add(snapshot(start.plusWeeks(i), 100, i % 2 == 0 ? 100 : 50)); + } + givenSnapshots(history); + + AdmissionForecastResponse result = service.forecast(1L, 12, 12); + + assertThat(result.getProbability()).isLessThanOrEqualTo(95); + } + + @Test + @DisplayName("예측 기간에 3월이 포함되면 근거에 신학기를 남긴다") + void mentionsNewTermWhenSpanned() { + givenSnapshots(weekly(20, 100, 100)); + + // 12개월을 보면 반드시 3월이 포함된다 + AdmissionForecastResponse result = service.forecast(1L, 12, 12); + + assertThat(result.getReasons()).anyMatch(r -> r.contains("신학기")); + assertThat(result.getProbability()).isGreaterThanOrEqualTo(50); + } + + @Test + @DisplayName("아이 월령으로 배정 반을 계산한다") + void resolvesTargetClass() { + givenSnapshots(weekly(20, 100, 100)); + + assertThat(service.forecast(1L, 6, 6).getTargetClass()).isEqualTo("0세반"); + assertThat(service.forecast(1L, 18, 6).getTargetClass()).isEqualTo("1세반"); + assertThat(service.forecast(1L, 70, 6).getTargetClass()).isEqualTo("5세반 이상"); + } + + @Test + @DisplayName("관측이 적으면 신뢰도를 낮게 표기한다") + void reportsLowConfidenceOnThinData() { + givenSnapshots(weekly(10, 100, 100)); + + assertThat(service.forecast(1L, 12, 1).getConfidence()).isEqualTo("LOW"); + } + + private void givenSnapshots(List snapshots) { + when(snapshotRepository.findHistory(anyLong(), any())).thenReturn(snapshots); + } + + /** 주 1회 관측을 count 회 만든다. */ + private List weekly(int count, int capacity, int enrolled) { + List list = new ArrayList<>(); + LocalDate start = LocalDate.now().minusWeeks(count); + for (int i = 0; i < count; i++) { + list.add(snapshot(start.plusWeeks(i), capacity, enrolled)); + } + return list; + } + + private FacilityCapacitySnapshot snapshot(LocalDate date, int capacity, int enrolled) { + return FacilityCapacitySnapshot.builder() + .facilityId(1L) + .observedDate(date) + .capacity(capacity) + .currentEnrollment(enrolled) + .availableSpots(Math.max(0, capacity - enrolled)) + .build(); + } +} From 6e62fde7e25aba2a74dfd29004aa8aaba3ee92b4 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Tue, 4 Aug 2026 14:53:44 +0900 Subject: [PATCH 03/68] =?UTF-8?q?FEAT=20:=20=EB=86=93=EC=B9=9C=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=EA=B8=88=20=EC=86=8C=EA=B8=89=20=ED=8C=90=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EC=86=8C=EB=93=9D=C2=B7=EC=9E=90=EB=85=80=EC=88=98?= =?UTF-8?q?=20=EC=9A=94=EA=B1=B4=20=EB=8F=84=EC=9E=85=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + .../core/security/SecurityConfig.java | 1 + .../domain/policy/app/PolicyFacade.java | 10 +- .../policy/controller/PolicyController.java | 9 + .../dto/response/MissedBenefitResponse.java | 34 +++ .../MissedBenefitSummaryResponse.java | 27 +++ .../carecode/domain/policy/entity/Policy.java | 12 + .../policy/service/MissedBenefitService.java | 189 ++++++++++++++++ .../service/PolicyRecommendationService.java | 35 ++- .../dto/request/UserUpdateRequestDto.java | 11 + .../com/carecode/domain/user/entity/User.java | 7 + .../db/migration/V6__benefit_eligibility.sql | 25 +++ .../service/MissedBenefitServiceTest.java | 207 ++++++++++++++++++ 13 files changed, 567 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/carecode/domain/policy/dto/response/MissedBenefitResponse.java create mode 100644 src/main/java/com/carecode/domain/policy/dto/response/MissedBenefitSummaryResponse.java create mode 100644 src/main/java/com/carecode/domain/policy/service/MissedBenefitService.java create mode 100644 src/main/resources/db/migration/V6__benefit_eligibility.sql create mode 100644 src/test/java/com/carecode/domain/policy/service/MissedBenefitServiceTest.java diff --git a/.gitignore b/.gitignore index 61e02318..419f2ef5 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,6 @@ logs/ ### Temporary files ### *.tmp *.temp + +# 별도 저장소로 관리되는 프론트엔드 +CareCode_FE/ diff --git a/src/main/java/com/carecode/core/security/SecurityConfig.java b/src/main/java/com/carecode/core/security/SecurityConfig.java index d474bcf5..c09cd710 100644 --- a/src/main/java/com/carecode/core/security/SecurityConfig.java +++ b/src/main/java/com/carecode/core/security/SecurityConfig.java @@ -150,6 +150,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 정책 API: 개인화·북마크는 인증 필요, 나머지 조회는 공개 // 아래 /policies/* 와일드카드보다 먼저 선언해야 적용된다. .requestMatchers("/policies/recommendations").authenticated() + .requestMatchers("/policies/missed-benefits").authenticated() .requestMatchers("/policies/bookmarks").authenticated() .requestMatchers("/policies/*/bookmarks").authenticated() .requestMatchers("/policies").permitAll() diff --git a/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java b/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java index 7803f8e3..42685c59 100644 --- a/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java +++ b/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java @@ -1,11 +1,13 @@ package com.carecode.domain.policy.app; import com.carecode.domain.policy.dto.request.PolicySearchRequest; +import com.carecode.domain.policy.dto.response.MissedBenefitSummaryResponse; import com.carecode.domain.policy.dto.response.PersonalizedPolicyResponse; import com.carecode.domain.policy.dto.response.PolicyBookmarkResponse; import com.carecode.domain.policy.dto.response.PolicyDto; import com.carecode.domain.policy.dto.response.PolicyListResponse; import com.carecode.domain.policy.dto.response.PolicyStatsSimpleResponse; +import com.carecode.domain.policy.service.MissedBenefitService; import com.carecode.domain.policy.service.PolicyRecommendationService; import com.carecode.domain.policy.service.PolicyService; import lombok.RequiredArgsConstructor; @@ -20,6 +22,7 @@ public class PolicyFacade { private final PolicyService policyService; private final PolicyRecommendationService policyRecommendationService; + private final MissedBenefitService missedBenefitService; @Transactional(readOnly = true) public List getAllPolicies(int page, int size) { return policyService.getAllPolicies(page, size); } @@ -79,6 +82,9 @@ public void removeBookmark(String userIdOrEmail, Long policyId) { public List recommendPolicies(int limit) { return policyRecommendationService.recommendForCurrentUser(limit); } -} - + @Transactional(readOnly = true) + public MissedBenefitSummaryResponse findMissedBenefits() { + return missedBenefitService.findMissedBenefits(); + } +} diff --git a/src/main/java/com/carecode/domain/policy/controller/PolicyController.java b/src/main/java/com/carecode/domain/policy/controller/PolicyController.java index c29ec226..28d6713a 100644 --- a/src/main/java/com/carecode/domain/policy/controller/PolicyController.java +++ b/src/main/java/com/carecode/domain/policy/controller/PolicyController.java @@ -7,6 +7,7 @@ import com.carecode.core.security.CurrentUserFacade; import com.carecode.core.exception.CareServiceException; import com.carecode.core.exception.PolicyNotFoundException; +import com.carecode.domain.policy.dto.response.MissedBenefitSummaryResponse; import com.carecode.domain.policy.dto.response.PersonalizedPolicyResponse; import com.carecode.domain.policy.dto.response.PolicyDto; import com.carecode.domain.policy.dto.request.PolicySearchRequest; @@ -273,6 +274,14 @@ public ResponseEntity> getRecommendations( return ResponseEntity.ok(policyFacade.recommendPolicies(PageRequestUtil.normalizeSize(limit))); } + // 놓친 지원금 발굴 + @GetMapping("/missed-benefits") + @LogExecutionTime + @Operation(summary = "놓친 지원금 조회", description = "자녀가 대상이었으나 지나간 지원금과 소급 가능 여부를 조회합니다.") + public ResponseEntity getMissedBenefits() { + return ResponseEntity.ok(policyFacade.findMissedBenefits()); + } + private String getAuthenticatedUserCode() { return currentUserFacade.requireCurrentUserId(); } diff --git a/src/main/java/com/carecode/domain/policy/dto/response/MissedBenefitResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/MissedBenefitResponse.java new file mode 100644 index 00000000..1255beda --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/dto/response/MissedBenefitResponse.java @@ -0,0 +1,34 @@ +package com.carecode.domain.policy.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +/** 놓쳤을 가능성이 있는 지원금 한 건. */ +@Getter +@Builder +public class MissedBenefitResponse { + + private Long policyId; + private String title; + private String childName; + + /** 해당 아이가 이 정책 대상이었던 구간(월령). */ + private Integer eligibleFromMonth; + private Integer eligibleToMonth; + + /** 지금도 소급 신청이 가능한지. */ + private boolean claimable; + + /** 소급 신청 마감까지 남은 개월. claimable=false 면 null. */ + private Integer remainingMonths; + + /** 지원 금액(원). 확인되지 않으면 null. */ + private Integer benefitAmount; + + private String applicationUrl; + + /** 왜 이 정책이 목록에 올라왔는지. */ + private List reasons; +} diff --git a/src/main/java/com/carecode/domain/policy/dto/response/MissedBenefitSummaryResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/MissedBenefitSummaryResponse.java new file mode 100644 index 00000000..dde342f4 --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/dto/response/MissedBenefitSummaryResponse.java @@ -0,0 +1,27 @@ +package com.carecode.domain.policy.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +/** 놓친 지원금 요약. 첫 화면에 "놓친 금액" 을 띄우기 위한 응답이다. */ +@Getter +@Builder +public class MissedBenefitSummaryResponse { + + /** 아직 소급 신청이 가능한 건수. */ + private int claimableCount; + + /** 소급 가능한 건의 지원금 합계(원). 금액 미상 정책은 제외된다. */ + private long claimableAmount; + + /** 기간이 지나 신청할 수 없게 된 건수. 같은 실수를 반복하지 않도록 함께 보여준다. */ + private int expiredCount; + + /** 소득 정보가 없어 판정을 보류한 건수. */ + private int unknownEligibilityCount; + + private List claimable; + private List expired; +} diff --git a/src/main/java/com/carecode/domain/policy/entity/Policy.java b/src/main/java/com/carecode/domain/policy/entity/Policy.java index 751d4ceb..1408230c 100644 --- a/src/main/java/com/carecode/domain/policy/entity/Policy.java +++ b/src/main/java/com/carecode/domain/policy/entity/Policy.java @@ -51,6 +51,18 @@ public class Policy { @Column(name = "target_region") private String targetRegion; + + /** 기준중위소득 대비 상한(%). null 이면 소득 무관 정책이다. */ + @Column(name = "income_threshold_percent") + private Integer incomeThresholdPercent; + + /** 최소 자녀 수 요건. null 이면 무관, 2 이상이면 다자녀 정책이다. */ + @Column(name = "min_children") + private Integer minChildren; + + /** 대상 연령이 지난 뒤에도 신청 가능한 개월 수. null 이면 소급 불가. */ + @Column(name = "retroactive_months") + private Integer retroactiveMonths; @Column(name = "benefit_amount") private Integer benefitAmount; diff --git a/src/main/java/com/carecode/domain/policy/service/MissedBenefitService.java b/src/main/java/com/carecode/domain/policy/service/MissedBenefitService.java new file mode 100644 index 00000000..e75084ff --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/service/MissedBenefitService.java @@ -0,0 +1,189 @@ +package com.carecode.domain.policy.service; + +import com.carecode.core.security.CurrentUserFacade; +import com.carecode.domain.policy.dto.response.MissedBenefitResponse; +import com.carecode.domain.policy.dto.response.MissedBenefitSummaryResponse; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.repository.PolicyRepository; +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.ChildRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * 아이가 이미 지나온 월령 구간을 훑어 받을 수 있었던 지원금을 찾는다. + * 수급 여부를 알 수 없으므로 "받았다/못 받았다" 가 아니라 "대상이었다" 로만 판정한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MissedBenefitService { + + private static final int CANDIDATE_SIZE = 300; + + private final PolicyRepository policyRepository; + private final ChildRepository childRepository; + private final CurrentUserFacade currentUserFacade; + + public MissedBenefitSummaryResponse findMissedBenefits() { + User user = currentUserFacade.requireCurrentUser(); + List children = childRepository.findByUserIdOrderByCreatedAtDesc(user.getId()); + LocalDate today = LocalDate.now(); + + List claimable = new ArrayList<>(); + List expired = new ArrayList<>(); + int unknownEligibility = 0; + + if (children.isEmpty()) { + return summarize(claimable, expired, 0); + } + + List candidates = policyRepository + .findByIsActiveTrueOrderByPriorityDescViewCountDesc(PageRequest.of(0, CANDIDATE_SIZE)) + .getContent(); + + for (Child child : children) { + if (child.getBirthDate() == null) { + continue; + } + int currentMonths = (int) ChronoUnit.MONTHS.between(child.getBirthDate(), today); + + for (Policy policy : candidates) { + if (!hasPassedAgeWindow(policy, currentMonths)) { + continue; + } + Eligibility eligibility = checkEligibility(policy, user, children.size()); + if (eligibility == Eligibility.NOT_ELIGIBLE) { + continue; + } + if (eligibility == Eligibility.UNKNOWN) { + unknownEligibility++; + } + + MissedBenefitResponse item = toResponse(policy, child, currentMonths, eligibility, today); + if (item.isClaimable()) { + claimable.add(item); + } else { + expired.add(item); + } + } + } + + claimable.sort(Comparator.comparingInt( + (MissedBenefitResponse m) -> m.getBenefitAmount() == null ? 0 : m.getBenefitAmount()).reversed()); + return summarize(claimable, expired, unknownEligibility); + } + + /** 아이가 대상 연령 구간을 이미 지났는지. 아직 대상이면 "놓친" 것이 아니다. */ + private boolean hasPassedAgeWindow(Policy policy, int currentMonths) { + Integer max = policy.getTargetAgeMax(); + if (max == null) { + return false; // 상한이 없으면 지금도 대상이다 + } + Integer min = policy.getTargetAgeMin(); + if (min != null && currentMonths < min) { + return false; // 아직 대상 연령에 도달하지 않았다 + } + return currentMonths > max; + } + + private enum Eligibility { + ELIGIBLE, NOT_ELIGIBLE, UNKNOWN + } + + /** 소득·다자녀 조건을 본다. 사용자가 소득을 입력하지 않았으면 배제하지 않고 보류한다. */ + private Eligibility checkEligibility(Policy policy, User user, int childCount) { + Integer minChildren = policy.getMinChildren(); + if (minChildren != null && childCount < minChildren) { + return Eligibility.NOT_ELIGIBLE; + } + + Integer threshold = policy.getIncomeThresholdPercent(); + if (threshold == null) { + return Eligibility.ELIGIBLE; + } + Integer income = user.getIncomePercent(); + if (income == null) { + // 소득 미입력을 탈락으로 처리하면 받을 수 있었던 지원금이 통째로 사라진다. + return Eligibility.UNKNOWN; + } + return income <= threshold ? Eligibility.ELIGIBLE : Eligibility.NOT_ELIGIBLE; + } + + private MissedBenefitResponse toResponse(Policy policy, Child child, int currentMonths, + Eligibility eligibility, LocalDate today) { + List reasons = new ArrayList<>(); + reasons.add(String.format("%s 님이 %d~%d개월이던 시기에 대상이었습니다.", + child.getName(), + policy.getTargetAgeMin() != null ? policy.getTargetAgeMin() : 0, + policy.getTargetAgeMax())); + + // 소급 기간은 대상 연령 상한을 지난 시점부터 센다. + Integer retroactive = policy.getRetroactiveMonths(); + int monthsSinceIneligible = currentMonths - policy.getTargetAgeMax(); + boolean withinRetroactive = retroactive != null && monthsSinceIneligible <= retroactive; + + // 정책 자체의 신청 마감도 지나지 않아야 한다. + boolean applicationOpen = policy.getApplicationEndDate() == null + || !policy.getApplicationEndDate().isBefore(today); + + boolean claimable = withinRetroactive && applicationOpen; + Integer remaining = claimable ? retroactive - monthsSinceIneligible : null; + + if (claimable) { + reasons.add(String.format("소급 신청이 %d개월 남았습니다.", remaining)); + } else if (retroactive == null) { + reasons.add("이 정책은 소급 신청을 받지 않습니다."); + } else if (!applicationOpen) { + reasons.add("정책 신청 기간이 종료되었습니다."); + } else { + reasons.add(String.format("소급 가능 기간(%d개월)이 지났습니다.", retroactive)); + } + + if (eligibility == Eligibility.UNKNOWN) { + reasons.add("소득 정보를 입력하면 대상 여부를 정확히 판정할 수 있습니다."); + } + + return MissedBenefitResponse.builder() + .policyId(policy.getId()) + .title(policy.getTitle()) + .childName(child.getName()) + .eligibleFromMonth(policy.getTargetAgeMin()) + .eligibleToMonth(policy.getTargetAgeMax()) + .claimable(claimable) + .remainingMonths(remaining) + .benefitAmount(policy.getBenefitAmount()) + .applicationUrl(policy.getApplicationUrl()) + .reasons(reasons) + .build(); + } + + private MissedBenefitSummaryResponse summarize(List claimable, + List expired, + int unknownEligibility) { + long total = claimable.stream() + .filter(m -> m.getBenefitAmount() != null) + .mapToLong(MissedBenefitResponse::getBenefitAmount) + .sum(); + + return MissedBenefitSummaryResponse.builder() + .claimableCount(claimable.size()) + .claimableAmount(total) + .expiredCount(expired.size()) + .unknownEligibilityCount(unknownEligibility) + .claimable(claimable) + .expired(expired) + .build(); + } +} diff --git a/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java b/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java index e0b0e2af..6247a0b2 100644 --- a/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java +++ b/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java @@ -69,8 +69,12 @@ public List recommendForCurrentUser(int limit) { return scored.size() > limit ? scored.subList(0, limit) : scored; } - /** 연령 조건이 있는데 맞는 아이가 없으면 0점으로 제외한다. */ + /** 연령·소득·자녀수 조건에 맞지 않으면 0점으로 제외한다. */ private int score(Policy policy, User user, List children, LocalDate today, List reasons) { + if (!meetsHouseholdConditions(policy, user, children.size(), reasons)) { + return 0; + } + int score = 1; // 조건 없는 범용 정책도 노출되도록 하는 기본 점수 boolean hasAgeCondition = policy.getTargetAgeMin() != null || policy.getTargetAgeMax() != null; @@ -99,6 +103,35 @@ private int score(Policy policy, User user, List children, LocalDate toda return score; } + /** + * 소득·자녀수 요건을 확인한다. + * 소득 미입력 사용자를 탈락시키면 받을 수 있는 정책이 통째로 사라지므로, 안내만 붙이고 통과시킨다. + */ + private boolean meetsHouseholdConditions(Policy policy, User user, int childCount, List reasons) { + Integer minChildren = policy.getMinChildren(); + if (minChildren != null) { + if (childCount < minChildren) { + return false; + } + reasons.add("자녀 " + minChildren + "명 이상 대상 정책입니다."); + } + + Integer threshold = policy.getIncomeThresholdPercent(); + if (threshold == null) { + return true; + } + Integer income = user.getIncomePercent(); + if (income == null) { + reasons.add("소득 조건이 있는 정책입니다. 소득 정보를 입력하면 정확히 판정됩니다."); + return true; + } + if (income > threshold) { + return false; + } + reasons.add("기준중위소득 " + threshold + "% 이하 대상에 해당합니다."); + return true; + } + /** 정책 대상 월령과 아이의 월령을 비교한다. 시드 데이터 기준 targetAge 단위는 개월이다. */ private boolean matchesAge(Policy policy, Child child, LocalDate today) { if (child.getBirthDate() == null) { diff --git a/src/main/java/com/carecode/domain/user/dto/request/UserUpdateRequestDto.java b/src/main/java/com/carecode/domain/user/dto/request/UserUpdateRequestDto.java index baecdab9..3dedc048 100644 --- a/src/main/java/com/carecode/domain/user/dto/request/UserUpdateRequestDto.java +++ b/src/main/java/com/carecode/domain/user/dto/request/UserUpdateRequestDto.java @@ -1,6 +1,8 @@ package com.carecode.domain.user.dto.request; import com.carecode.domain.user.entity.Gender; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; @@ -36,6 +38,15 @@ public class UserUpdateRequestDto { private Double latitude; private Double longitude; + + /** 가구 소득 / 기준중위소득 (%). 소득 조건이 붙은 지원금 판정에만 쓰며 실제 금액은 받지 않는다. */ + @Min(value = 0, message = "소득 비율은 0 이상이어야 합니다") + @Max(value = 1000, message = "소득 비율은 1000% 이하여야 합니다") + private Integer incomePercent; + + @Min(value = 1, message = "가구원 수는 1명 이상이어야 합니다") + @Max(value = 20, message = "가구원 수는 20명 이하여야 합니다") + private Integer householdSize; } diff --git a/src/main/java/com/carecode/domain/user/entity/User.java b/src/main/java/com/carecode/domain/user/entity/User.java index ce2b6702..f0200058 100644 --- a/src/main/java/com/carecode/domain/user/entity/User.java +++ b/src/main/java/com/carecode/domain/user/entity/User.java @@ -63,6 +63,13 @@ public class User { @Column(name = "LONGITUDE") private Double longitude; + + /** 가구 소득 / 기준중위소득 (%). 실제 금액은 받지 않는다. null 이면 미입력. */ + @Column(name = "INCOME_PERCENT") + private Integer incomePercent; + + @Column(name = "HOUSEHOLD_SIZE") + private Integer householdSize; @Column(name = "PROFILE_IMAGE_URL") private String profileImageUrl; diff --git a/src/main/resources/db/migration/V6__benefit_eligibility.sql b/src/main/resources/db/migration/V6__benefit_eligibility.sql new file mode 100644 index 00000000..e525d1e9 --- /dev/null +++ b/src/main/resources/db/migration/V6__benefit_eligibility.sql @@ -0,0 +1,25 @@ +-- 놓친 지원금 발굴에 필요한 자격 조건. +-- 연령·지역만으로는 실제 수급 가능 여부를 가릴 수 없어 소득·다자녀·소급 조건을 추가한다. + +-- 기준중위소득 대비 % 이하가 대상. NULL 이면 소득 무관 정책이다. +ALTER TABLE TBL_POLICIES + ADD COLUMN INCOME_THRESHOLD_PERCENT INT NULL COMMENT '기준중위소득 대비 상한(%) - NULL 이면 소득 무관'; + +-- 자녀 수 요건. NULL 이면 무관, 2 면 다자녀 정책. +ALTER TABLE TBL_POLICIES + ADD COLUMN MIN_CHILDREN INT NULL COMMENT '최소 자녀 수 요건 - NULL 이면 무관'; + +-- 대상 연령이 지난 뒤에도 신청 가능한 기간(개월). NULL 이면 소급 불가. +ALTER TABLE TBL_POLICIES + ADD COLUMN RETROACTIVE_MONTHS INT NULL COMMENT '소급 신청 가능 개월 - NULL 이면 소급 불가'; + +-- 사용자 가구 소득. 소득 조건이 붙은 정책을 거르는 데 쓴다. +-- 실제 금액이 아니라 기준중위소득 대비 비율만 저장한다 (민감정보 최소 수집). +ALTER TABLE TBL_USER + ADD COLUMN INCOME_PERCENT INT NULL COMMENT '가구 소득 / 기준중위소득 (%) - NULL 이면 미입력'; + +ALTER TABLE TBL_USER + ADD COLUMN HOUSEHOLD_SIZE INT NULL COMMENT '가구원 수'; + +-- 소급 판정은 "소급 가능한 정책" 만 훑으므로 부분 인덱스 대신 조건 컬럼에 인덱스를 둔다. +CREATE INDEX IDX_POLICIES_RETROACTIVE ON TBL_POLICIES (RETROACTIVE_MONTHS, IS_ACTIVE); diff --git a/src/test/java/com/carecode/domain/policy/service/MissedBenefitServiceTest.java b/src/test/java/com/carecode/domain/policy/service/MissedBenefitServiceTest.java new file mode 100644 index 00000000..85f3502a --- /dev/null +++ b/src/test/java/com/carecode/domain/policy/service/MissedBenefitServiceTest.java @@ -0,0 +1,207 @@ +package com.carecode.domain.policy.service; + +import com.carecode.core.security.CurrentUserFacade; +import com.carecode.domain.policy.dto.response.MissedBenefitSummaryResponse; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.repository.PolicyRepository; +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.ChildRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("놓친 지원금 발굴") +class MissedBenefitServiceTest { + + private PolicyRepository policyRepository; + private ChildRepository childRepository; + private CurrentUserFacade currentUserFacade; + private MissedBenefitService service; + + private User user; + + @BeforeEach + void setUp() { + policyRepository = mock(PolicyRepository.class); + childRepository = mock(ChildRepository.class); + currentUserFacade = mock(CurrentUserFacade.class); + + user = User.builder().id(1L).name("부모").build(); + when(currentUserFacade.requireCurrentUser()).thenReturn(user); + + service = new MissedBenefitService(policyRepository, childRepository, currentUserFacade); + } + + @Test + @DisplayName("아직 대상 연령이면 놓친 것이 아니다") + void ignoresPolicyStillEligible() { + givenChildAgedMonths(12); + givenPolicies(policy("부모급여", 0, 23, 350000, 12)); + + MissedBenefitSummaryResponse result = service.findMissedBenefits(); + + assertThat(result.getClaimableCount()).isZero(); + assertThat(result.getExpiredCount()).isZero(); + } + + @Test + @DisplayName("연령이 지났고 소급 기간이 남았으면 신청 가능으로 분류한다") + void detectsClaimableBenefit() { + givenChildAgedMonths(30); // 23개월 상한을 7개월 지남 + givenPolicies(policy("부모급여", 0, 23, 350000, 12)); + + MissedBenefitSummaryResponse result = service.findMissedBenefits(); + + assertThat(result.getClaimableCount()).isEqualTo(1); + assertThat(result.getClaimableAmount()).isEqualTo(350000); + assertThat(result.getClaimable().get(0).getRemainingMonths()).isEqualTo(5); + } + + @Test + @DisplayName("소급 기간이 지났으면 만료로 분류한다") + void detectsExpiredBenefit() { + givenChildAgedMonths(40); // 상한을 17개월 지남, 소급은 12개월까지 + givenPolicies(policy("부모급여", 0, 23, 350000, 12)); + + MissedBenefitSummaryResponse result = service.findMissedBenefits(); + + assertThat(result.getClaimableCount()).isZero(); + assertThat(result.getExpiredCount()).isEqualTo(1); + assertThat(result.getExpired().get(0).getReasons()) + .anyMatch(r -> r.contains("소급 가능 기간")); + } + + @Test + @DisplayName("소급을 받지 않는 정책은 만료로 둔다") + void treatsNonRetroactiveAsExpired() { + givenChildAgedMonths(30); + givenPolicies(policy("일회성지원", 0, 23, 100000, null)); + + MissedBenefitSummaryResponse result = service.findMissedBenefits(); + + assertThat(result.getExpiredCount()).isEqualTo(1); + assertThat(result.getExpired().get(0).getReasons()) + .anyMatch(r -> r.contains("소급 신청을 받지 않습니다")); + } + + @Test + @DisplayName("소득 조건 초과자는 목록에서 제외한다") + void excludesWhenIncomeExceedsThreshold() { + user.setIncomePercent(200); + givenChildAgedMonths(30); + Policy p = policy("저소득지원", 0, 23, 500000, 12); + p.setIncomeThresholdPercent(150); + givenPolicies(p); + + MissedBenefitSummaryResponse result = service.findMissedBenefits(); + + assertThat(result.getClaimableCount()).isZero(); + assertThat(result.getExpiredCount()).isZero(); + } + + @Test + @DisplayName("소득 미입력은 제외하지 않고 보류로 표시한다") + void keepsUnknownIncomeAsPending() { + givenChildAgedMonths(30); // incomePercent 미입력 + Policy p = policy("저소득지원", 0, 23, 500000, 12); + p.setIncomeThresholdPercent(150); + givenPolicies(p); + + MissedBenefitSummaryResponse result = service.findMissedBenefits(); + + assertThat(result.getClaimableCount()).isEqualTo(1); + assertThat(result.getUnknownEligibilityCount()).isEqualTo(1); + assertThat(result.getClaimable().get(0).getReasons()) + .anyMatch(r -> r.contains("소득 정보를 입력하면")); + } + + @Test + @DisplayName("자녀 수 요건을 못 채우면 제외한다") + void excludesWhenNotEnoughChildren() { + givenChildAgedMonths(30); // 자녀 1명 + Policy p = policy("다자녀지원", 0, 23, 300000, 12); + p.setMinChildren(2); + givenPolicies(p); + + MissedBenefitSummaryResponse result = service.findMissedBenefits(); + + assertThat(result.getClaimableCount()).isZero(); + } + + @Test + @DisplayName("정책 신청 기간이 끝났으면 소급 기간이 남아도 만료다") + void expiredWhenApplicationWindowClosed() { + givenChildAgedMonths(30); + Policy p = policy("종료된지원", 0, 23, 200000, 12); + p.setApplicationEndDate(LocalDate.now().minusDays(1)); + givenPolicies(p); + + MissedBenefitSummaryResponse result = service.findMissedBenefits(); + + assertThat(result.getExpiredCount()).isEqualTo(1); + assertThat(result.getExpired().get(0).getReasons()) + .anyMatch(r -> r.contains("신청 기간이 종료")); + } + + @Test + @DisplayName("금액이 큰 순으로 정렬한다") + void sortsByAmountDescending() { + givenChildAgedMonths(30); + givenPolicies( + policy("소액", 0, 23, 100000, 12), + policy("고액", 0, 23, 900000, 12)); + + MissedBenefitSummaryResponse result = service.findMissedBenefits(); + + assertThat(result.getClaimable().get(0).getTitle()).isEqualTo("고액"); + assertThat(result.getClaimableAmount()).isEqualTo(1_000_000); + } + + @Test + @DisplayName("아이가 없으면 조회하지 않는다") + void returnsEmptyWithoutChildren() { + when(childRepository.findByUserIdOrderByCreatedAtDesc(anyLong())).thenReturn(List.of()); + + MissedBenefitSummaryResponse result = service.findMissedBenefits(); + + assertThat(result.getClaimableCount()).isZero(); + assertThat(result.getClaimable()).isEmpty(); + } + + private void givenChildAgedMonths(int months) { + Child child = Child.builder() + .name("아이") + .birthDate(LocalDate.now().minusMonths(months)) + .build(); + when(childRepository.findByUserIdOrderByCreatedAtDesc(anyLong())).thenReturn(List.of(child)); + } + + private void givenPolicies(Policy... policies) { + Page page = new PageImpl<>(List.of(policies)); + when(policyRepository.findByIsActiveTrueOrderByPriorityDescViewCountDesc(any())).thenReturn(page); + } + + private Policy policy(String title, Integer ageMin, Integer ageMax, Integer amount, Integer retroactive) { + Policy p = new Policy(); + p.setId(1L); + p.setTitle(title); + p.setTargetAgeMin(ageMin); + p.setTargetAgeMax(ageMax); + p.setBenefitAmount(amount); + p.setRetroactiveMonths(retroactive); + p.setIsActive(true); + return p; + } +} From 4fea5deb3c1434d8adcaeaba7af552b78762ebac Mon Sep 17 00:00:00 2001 From: RosieOh Date: Tue, 4 Aug 2026 17:34:10 +0900 Subject: [PATCH 04/68] =?UTF-8?q?FEAT=20:=20=EC=A7=80=EC=9B=90=EA=B8=88=20?= =?UTF-8?q?=EC=A7=80=EA=B8=89=20=EB=B0=A9=EC=8B=9D=20=ED=8C=90=EB=B3=84=20?= =?UTF-8?q?=EB=B0=8F=20=EB=88=84=EC=A0=81=20=EC=88=98=EB=A0=B9=EC=95=A1=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EA=B8=B0=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/benefit/BenefitPaymentType.java | 52 ++++++ .../benefit/BenefitProjectionCalculator.java | 72 ++++++++ .../BenefitProjectionCalculatorTest.java | 163 ++++++++++++++++++ 3 files changed, 287 insertions(+) create mode 100644 src/main/java/com/carecode/core/benefit/BenefitPaymentType.java create mode 100644 src/main/java/com/carecode/core/benefit/BenefitProjectionCalculator.java create mode 100644 src/test/java/com/carecode/core/benefit/BenefitProjectionCalculatorTest.java diff --git a/src/main/java/com/carecode/core/benefit/BenefitPaymentType.java b/src/main/java/com/carecode/core/benefit/BenefitPaymentType.java new file mode 100644 index 00000000..cc0e2b33 --- /dev/null +++ b/src/main/java/com/carecode/core/benefit/BenefitPaymentType.java @@ -0,0 +1,52 @@ +package com.carecode.core.benefit; + +import java.util.List; + +/** 지원금 지급 방식. 누적 수령액을 계산하려면 월 지급인지 일시금인지부터 갈라야 한다. */ +public enum BenefitPaymentType { + + /** 대상 기간 동안 매월 지급. */ + MONTHLY, + + /** 조건 충족 시 1회 지급. */ + ONE_TIME, + + /** 현금이 아니라 서비스·할인·공급. 금액 합산에서 제외한다. */ + NON_CASH, + + /** 표기가 없거나 판별 불가. */ + UNKNOWN; + + private static final List MONTHLY_MARKERS = List.of("월지급", "월지원", "월급여", "매월", "월 지급"); + private static final List ONE_TIME_MARKERS = List.of("일시", "일회", "1회", "출산지원금", "축하금"); + private static final List NON_CASH_MARKERS = List.of( + "서비스", "무료", "할인", "공급", "감면", "면제", "이용권", "제공"); + + /** 지급 방식 문자열에서 판별한다. 공공데이터 표기가 제각각이라 부분 일치로 본다. */ + public static BenefitPaymentType resolve(String benefitType) { + if (benefitType == null || benefitType.isBlank()) { + return UNKNOWN; + } + String normalized = benefitType.replaceAll("\\s+", ""); + + if (NON_CASH_MARKERS.stream().anyMatch(normalized::contains)) { + return NON_CASH; + } + if (MONTHLY_MARKERS.stream().anyMatch(m -> normalized.contains(m.replaceAll("\\s+", "")))) { + return MONTHLY; + } + if (ONE_TIME_MARKERS.stream().anyMatch(normalized::contains)) { + return ONE_TIME; + } + // "월" 단독 표기도 월 지급으로 본다. + return normalized.startsWith("월") ? MONTHLY : UNKNOWN; + } + + /** + * 금액 합산에 포함할지. + * UNKNOWN 은 포함하되 호출부에서 1회 지급으로 취급한다 — 월 지급으로 잘못 보면 60배까지 부풀려진다. + */ + public boolean countsTowardCash() { + return this != NON_CASH; + } +} diff --git a/src/main/java/com/carecode/core/benefit/BenefitProjectionCalculator.java b/src/main/java/com/carecode/core/benefit/BenefitProjectionCalculator.java new file mode 100644 index 00000000..9d62206b --- /dev/null +++ b/src/main/java/com/carecode/core/benefit/BenefitProjectionCalculator.java @@ -0,0 +1,72 @@ +package com.carecode.core.benefit; + +import com.carecode.domain.policy.entity.Policy; +import org.springframework.stereotype.Component; + +/** + * 아이의 현재 월령과 전망 기간으로 정책 하나의 예상 수령액을 계산한다. + * 부풀리면 제품 신뢰가 끝나므로, 판별이 애매하면 항상 적게 잡는다. + */ +@Component +public class BenefitProjectionCalculator { + + /** 정책 한 건의 전망 결과. */ + public record Projection(long amount, int eligibleMonths, BenefitPaymentType paymentType) { + + public boolean isCash() { + return amount > 0; + } + + static Projection none(BenefitPaymentType type) { + return new Projection(0, 0, type); + } + } + + /** + * @param currentAgeMonths 아이의 현재 월령 + * @param horizonMonths 앞으로 몇 개월을 볼 것인지 + */ + public Projection project(Policy policy, int currentAgeMonths, int horizonMonths) { + BenefitPaymentType type = BenefitPaymentType.resolve(policy.getBenefitType()); + + int eligibleMonths = countEligibleMonths(policy, currentAgeMonths, horizonMonths); + if (eligibleMonths == 0) { + return Projection.none(type); + } + if (type == BenefitPaymentType.NON_CASH) { + // 혜택은 받지만 금액으로 환산하지 않는다. 건수는 별도로 보여준다. + return new Projection(0, eligibleMonths, type); + } + + Integer amount = policy.getBenefitAmount(); + if (amount == null || amount <= 0) { + return new Projection(0, eligibleMonths, type); + } + + // UNKNOWN 을 월 지급으로 가정하면 5년 기준 최대 60배 과대 계상된다. 1회로 본다. + long total = type == BenefitPaymentType.MONTHLY + ? (long) amount * eligibleMonths + : amount; + + return new Projection(total, eligibleMonths, type); + } + + /** + * 전망 구간 [현재월령, 현재월령+기간) 과 정책 대상 구간 [min, max] 의 겹치는 개월 수. + * 연령 조건이 없는 정책은 기간 내내 대상으로 본다. + */ + private int countEligibleMonths(Policy policy, int currentAgeMonths, int horizonMonths) { + if (horizonMonths <= 0) { + return 0; + } + int windowStart = currentAgeMonths; + int windowEnd = currentAgeMonths + horizonMonths - 1; + + int policyStart = policy.getTargetAgeMin() != null ? policy.getTargetAgeMin() : 0; + int policyEnd = policy.getTargetAgeMax() != null ? policy.getTargetAgeMax() : Integer.MAX_VALUE - 1; + + int overlapStart = Math.max(windowStart, policyStart); + int overlapEnd = Math.min(windowEnd, policyEnd); + return Math.max(0, overlapEnd - overlapStart + 1); + } +} diff --git a/src/test/java/com/carecode/core/benefit/BenefitProjectionCalculatorTest.java b/src/test/java/com/carecode/core/benefit/BenefitProjectionCalculatorTest.java new file mode 100644 index 00000000..8ee4ff5d --- /dev/null +++ b/src/test/java/com/carecode/core/benefit/BenefitProjectionCalculatorTest.java @@ -0,0 +1,163 @@ +package com.carecode.core.benefit; + +import com.carecode.domain.policy.entity.Policy; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("지원금 전망 계산") +class BenefitProjectionCalculatorTest { + + private final BenefitProjectionCalculator calculator = new BenefitProjectionCalculator(); + + @Nested + @DisplayName("지급 방식 판별") + class PaymentTypeResolution { + + @Test + @DisplayName("월 지급 표기가 제각각이어도 인식한다") + void recognizesMonthlyVariants() { + assertThat(BenefitPaymentType.resolve("월지급")).isEqualTo(BenefitPaymentType.MONTHLY); + assertThat(BenefitPaymentType.resolve("월지원")).isEqualTo(BenefitPaymentType.MONTHLY); + assertThat(BenefitPaymentType.resolve("월급여")).isEqualTo(BenefitPaymentType.MONTHLY); + assertThat(BenefitPaymentType.resolve("매월 지급")).isEqualTo(BenefitPaymentType.MONTHLY); + } + + @Test + @DisplayName("일시 지급을 구분한다") + void recognizesOneTime() { + assertThat(BenefitPaymentType.resolve("일시지급")).isEqualTo(BenefitPaymentType.ONE_TIME); + } + + @Test + @DisplayName("현금이 아닌 혜택을 걸러낸다") + void recognizesNonCash() { + assertThat(BenefitPaymentType.resolve("서비스제공")).isEqualTo(BenefitPaymentType.NON_CASH); + assertThat(BenefitPaymentType.resolve("무료검진")).isEqualTo(BenefitPaymentType.NON_CASH); + assertThat(BenefitPaymentType.resolve("이용료할인")).isEqualTo(BenefitPaymentType.NON_CASH); + assertThat(BenefitPaymentType.resolve("특별공급")).isEqualTo(BenefitPaymentType.NON_CASH); + } + + @Test + @DisplayName("표기가 없으면 판별하지 않는다") + void unknownWhenBlank() { + assertThat(BenefitPaymentType.resolve(null)).isEqualTo(BenefitPaymentType.UNKNOWN); + assertThat(BenefitPaymentType.resolve("")).isEqualTo(BenefitPaymentType.UNKNOWN); + } + } + + @Test + @DisplayName("월 지급은 대상 개월 수만큼 곱한다") + void multipliesMonthlyBenefit() { + // 12개월 아이, 대상 0~23개월 → 남은 12개월 수령 + Policy policy = policy(0, 23, 350000, "월지급"); + + BenefitProjectionCalculator.Projection result = calculator.project(policy, 12, 60); + + assertThat(result.eligibleMonths()).isEqualTo(12); + assertThat(result.amount()).isEqualTo(12L * 350000); + } + + @Test + @DisplayName("전망 기간을 넘는 부분은 세지 않는다") + void clipsAtHorizon() { + Policy policy = policy(0, 71, 100000, "월지급"); + + BenefitProjectionCalculator.Projection result = calculator.project(policy, 0, 12); + + assertThat(result.eligibleMonths()).isEqualTo(12); + assertThat(result.amount()).isEqualTo(1_200_000); + } + + @Test + @DisplayName("일시금은 대상 기간이 길어도 1회만 계산한다") + void countsOneTimeOnce() { + Policy policy = policy(0, 23, 2000000, "일시지급"); + + BenefitProjectionCalculator.Projection result = calculator.project(policy, 0, 24); + + assertThat(result.amount()).isEqualTo(2_000_000); + } + + @Test + @DisplayName("지급 방식이 불명확하면 1회로 본다 — 과대 계상을 피한다") + void treatsUnknownAsOneTime() { + Policy policy = policy(0, 59, 500000, null); + + BenefitProjectionCalculator.Projection result = calculator.project(policy, 0, 60); + + // 월 지급으로 오인하면 3천만원이 된다 + assertThat(result.amount()).isEqualTo(500_000); + } + + @Test + @DisplayName("현금이 아닌 혜택은 금액에 넣지 않는다") + void excludesNonCashFromAmount() { + Policy policy = policy(0, 59, 300000, "무료검진"); + + BenefitProjectionCalculator.Projection result = calculator.project(policy, 0, 60); + + assertThat(result.amount()).isZero(); + assertThat(result.eligibleMonths()).isPositive(); + assertThat(result.paymentType()).isEqualTo(BenefitPaymentType.NON_CASH); + } + + @Test + @DisplayName("대상 연령이 이미 지났으면 0원이다") + void zeroWhenAgeWindowPassed() { + Policy policy = policy(0, 23, 350000, "월지급"); + + BenefitProjectionCalculator.Projection result = calculator.project(policy, 30, 60); + + assertThat(result.eligibleMonths()).isZero(); + assertThat(result.amount()).isZero(); + } + + @Test + @DisplayName("아직 대상 연령 전이면 도달 이후분만 센다") + void countsOnlyFutureEligibleMonths() { + // 현재 0개월, 대상 36~71개월, 전망 48개월 → 36~47개월 구간 12개월만 해당 + Policy policy = policy(36, 71, 280000, "월지원"); + + BenefitProjectionCalculator.Projection result = calculator.project(policy, 0, 48); + + assertThat(result.eligibleMonths()).isEqualTo(12); + } + + @Test + @DisplayName("연령 조건이 없으면 기간 내내 대상으로 본다") + void treatsNoAgeConditionAsAlwaysEligible() { + Policy policy = policy(null, null, 50000, "월지급"); + + BenefitProjectionCalculator.Projection result = calculator.project(policy, 10, 24); + + assertThat(result.eligibleMonths()).isEqualTo(24); + } + + @Test + @DisplayName("금액이 없으면 합산하지 않는다") + void zeroWhenAmountMissing() { + Policy policy = policy(0, 59, null, "월지급"); + + assertThat(calculator.project(policy, 0, 12).amount()).isZero(); + } + + @Test + @DisplayName("전망 기간이 0이면 계산하지 않는다") + void zeroWhenHorizonEmpty() { + Policy policy = policy(0, 59, 100000, "월지급"); + + assertThat(calculator.project(policy, 0, 0).eligibleMonths()).isZero(); + } + + private Policy policy(Integer ageMin, Integer ageMax, Integer amount, String benefitType) { + Policy p = new Policy(); + p.setTargetAgeMin(ageMin); + p.setTargetAgeMax(ageMax); + p.setBenefitAmount(amount); + p.setBenefitType(benefitType); + return p; + } +} From 5667b71bb2a2fb4affb7e9a2c5987617b165d82b Mon Sep 17 00:00:00 2001 From: RosieOh Date: Tue, 4 Aug 2026 17:34:10 +0900 Subject: [PATCH 05/68] =?UTF-8?q?FEAT=20:=20=EA=B1=B0=EC=A3=BC=EC=A7=80?= =?UTF-8?q?=EB=B3=84=20=EC=A7=80=EC=9B=90=EA=B8=88=20=EB=B9=84=EA=B5=90=20?= =?UTF-8?q?=EB=B0=8F=20=EC=B0=A8=EC=95=A1=20=EC=82=B0=EC=B6=9C=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/security/SecurityConfig.java | 1 + .../domain/policy/app/PolicyFacade.java | 8 + .../policy/controller/PolicyController.java | 12 + .../RegionalBenefitComparisonResponse.java | 33 +++ .../dto/response/RegionalBenefitResponse.java | 37 +++ .../policy/repository/PolicyRepository.java | 6 + .../RegionalBenefitComparisonService.java | 203 ++++++++++++++++ .../RegionalBenefitComparisonServiceTest.java | 224 ++++++++++++++++++ 8 files changed, 524 insertions(+) create mode 100644 src/main/java/com/carecode/domain/policy/dto/response/RegionalBenefitComparisonResponse.java create mode 100644 src/main/java/com/carecode/domain/policy/dto/response/RegionalBenefitResponse.java create mode 100644 src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java create mode 100644 src/test/java/com/carecode/domain/policy/service/RegionalBenefitComparisonServiceTest.java diff --git a/src/main/java/com/carecode/core/security/SecurityConfig.java b/src/main/java/com/carecode/core/security/SecurityConfig.java index c09cd710..14f2134d 100644 --- a/src/main/java/com/carecode/core/security/SecurityConfig.java +++ b/src/main/java/com/carecode/core/security/SecurityConfig.java @@ -151,6 +151,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 아래 /policies/* 와일드카드보다 먼저 선언해야 적용된다. .requestMatchers("/policies/recommendations").authenticated() .requestMatchers("/policies/missed-benefits").authenticated() + .requestMatchers("/policies/regional-comparison").authenticated() .requestMatchers("/policies/bookmarks").authenticated() .requestMatchers("/policies/*/bookmarks").authenticated() .requestMatchers("/policies").permitAll() diff --git a/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java b/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java index 42685c59..d70dfc82 100644 --- a/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java +++ b/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java @@ -3,12 +3,14 @@ import com.carecode.domain.policy.dto.request.PolicySearchRequest; import com.carecode.domain.policy.dto.response.MissedBenefitSummaryResponse; import com.carecode.domain.policy.dto.response.PersonalizedPolicyResponse; +import com.carecode.domain.policy.dto.response.RegionalBenefitComparisonResponse; import com.carecode.domain.policy.dto.response.PolicyBookmarkResponse; import com.carecode.domain.policy.dto.response.PolicyDto; import com.carecode.domain.policy.dto.response.PolicyListResponse; import com.carecode.domain.policy.dto.response.PolicyStatsSimpleResponse; import com.carecode.domain.policy.service.MissedBenefitService; import com.carecode.domain.policy.service.PolicyRecommendationService; +import com.carecode.domain.policy.service.RegionalBenefitComparisonService; import com.carecode.domain.policy.service.PolicyService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -23,6 +25,7 @@ public class PolicyFacade { private final PolicyService policyService; private final PolicyRecommendationService policyRecommendationService; private final MissedBenefitService missedBenefitService; + private final RegionalBenefitComparisonService regionalBenefitComparisonService; @Transactional(readOnly = true) public List getAllPolicies(int page, int size) { return policyService.getAllPolicies(page, size); } @@ -87,4 +90,9 @@ public List recommendPolicies(int limit) { public MissedBenefitSummaryResponse findMissedBenefits() { return missedBenefitService.findMissedBenefits(); } + + @Transactional(readOnly = true) + public RegionalBenefitComparisonResponse compareRegionalBenefits(Long childId, Integer years, Integer limit) { + return regionalBenefitComparisonService.compare(childId, years, limit); + } } diff --git a/src/main/java/com/carecode/domain/policy/controller/PolicyController.java b/src/main/java/com/carecode/domain/policy/controller/PolicyController.java index 28d6713a..25ef9d2a 100644 --- a/src/main/java/com/carecode/domain/policy/controller/PolicyController.java +++ b/src/main/java/com/carecode/domain/policy/controller/PolicyController.java @@ -9,6 +9,7 @@ import com.carecode.core.exception.PolicyNotFoundException; import com.carecode.domain.policy.dto.response.MissedBenefitSummaryResponse; import com.carecode.domain.policy.dto.response.PersonalizedPolicyResponse; +import com.carecode.domain.policy.dto.response.RegionalBenefitComparisonResponse; import com.carecode.domain.policy.dto.response.PolicyDto; import com.carecode.domain.policy.dto.request.PolicySearchRequest; import com.carecode.domain.policy.dto.response.PolicyListResponse; @@ -282,6 +283,17 @@ public ResponseEntity getMissedBenefits() { return ResponseEntity.ok(policyFacade.findMissedBenefits()); } + // 거주지별 지원금 비교 + @GetMapping("/regional-comparison") + @LogExecutionTime + @Operation(summary = "거주지별 지원금 비교", description = "지역별 예상 수령액을 계산해 현재 거주지와 비교합니다.") + public ResponseEntity compareRegionalBenefits( + @Parameter(description = "자녀 ID (미지정 시 최근 등록 자녀)") @RequestParam(required = false) Long childId, + @Parameter(description = "전망 기간(년)", example = "5") @RequestParam(required = false) Integer years, + @Parameter(description = "상위 노출 지역 수", example = "10") @RequestParam(required = false) Integer limit) { + return ResponseEntity.ok(policyFacade.compareRegionalBenefits(childId, years, limit)); + } + private String getAuthenticatedUserCode() { return currentUserFacade.requireCurrentUserId(); } diff --git a/src/main/java/com/carecode/domain/policy/dto/response/RegionalBenefitComparisonResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/RegionalBenefitComparisonResponse.java new file mode 100644 index 00000000..23f1a9a4 --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/dto/response/RegionalBenefitComparisonResponse.java @@ -0,0 +1,33 @@ +package com.carecode.domain.policy.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +/** 거주지별 지원금 비교 결과. */ +@Getter +@Builder +public class RegionalBenefitComparisonResponse { + + private String childName; + private Integer childAgeMonths; + + /** 전망 기간(개월). */ + private int horizonMonths; + + /** 기준이 된 현재 거주 지역. 주소 미입력이면 null. */ + private String baseRegion; + private Long baseAmount; + + /** 총액 내림차순. */ + private List rankings; + + /** + * 데이터 신뢰 수준. 지자체 정책 수집이 불완전하면 실제와 차이가 날 수 있어 함께 노출한다. + * VERIFIED / ESTIMATED + */ + private String dataQuality; + + private List disclaimers; +} diff --git a/src/main/java/com/carecode/domain/policy/dto/response/RegionalBenefitResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/RegionalBenefitResponse.java new file mode 100644 index 00000000..e94f602e --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/dto/response/RegionalBenefitResponse.java @@ -0,0 +1,37 @@ +package com.carecode.domain.policy.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +/** 지역 한 곳의 예상 수령액. */ +@Getter +@Builder +public class RegionalBenefitResponse { + + private String region; + + /** 전국 정책 + 해당 지역 정책의 기간 내 예상 총액(원). */ + private long totalAmount; + + /** 현재 거주지 대비 차액(원). 음수면 지금이 더 유리하다. */ + private long differenceFromBase; + + /** 금액으로 환산된 정책 수. */ + private int cashPolicyCount; + + /** 금액이 아닌 혜택(무료검진·서비스 등) 수. 합산에는 빠져 있다. */ + private int nonCashPolicyCount; + + /** 금액 상위 기여 정책. 왜 이 지역이 높은지 설명한다. */ + private List topContributors; + + @Getter + @Builder + public static class Contribution { + private String title; + private long amount; + private String paymentType; + } +} diff --git a/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java b/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java index 326bae72..e88c37da 100644 --- a/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java +++ b/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java @@ -26,6 +26,12 @@ public interface PolicyRepository extends JpaRepository { // 활성화된 정책 목록 조회 List findByIsActiveTrue(); + /** 지역 비교 대상. 전국 정책은 모든 지역에 공통 적용되므로 후보 목록에서 뺀다. */ + @Query("SELECT DISTINCT p.targetRegion FROM Policy p " + + "WHERE p.isActive = true AND p.targetRegion IS NOT NULL " + + "AND p.targetRegion <> '' AND p.targetRegion NOT LIKE '%전국%'") + List findDistinctTargetRegions(); + /** 추천 후보 조회. 우선순위 높은 정책부터 가져와 상위 N건만 채점한다. */ Page findByIsActiveTrueOrderByPriorityDescViewCountDesc(Pageable pageable); diff --git a/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java b/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java new file mode 100644 index 00000000..ad8a745c --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java @@ -0,0 +1,203 @@ +package com.carecode.domain.policy.service; + +import com.carecode.core.benefit.BenefitPaymentType; +import com.carecode.core.benefit.BenefitProjectionCalculator; +import com.carecode.core.exception.CareServiceException; +import com.carecode.core.security.CurrentUserFacade; +import com.carecode.domain.policy.dto.response.RegionalBenefitComparisonResponse; +import com.carecode.domain.policy.dto.response.RegionalBenefitResponse; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.repository.PolicyRepository; +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.ChildRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 같은 아이라도 사는 지역에 따라 받는 지원금 총액이 크게 다르다. + * 지역별 예상 수령액을 계산해 현재 거주지와 비교한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RegionalBenefitComparisonService { + + private static final int DEFAULT_HORIZON_MONTHS = 60; + private static final int MAX_HORIZON_MONTHS = 240; + private static final int DEFAULT_LIMIT = 10; + private static final int TOP_CONTRIBUTORS = 3; + + private final PolicyRepository policyRepository; + private final ChildRepository childRepository; + private final CurrentUserFacade currentUserFacade; + private final BenefitProjectionCalculator calculator; + + public RegionalBenefitComparisonResponse compare(Long childId, Integer years, Integer limit) { + User user = currentUserFacade.requireCurrentUser(); + Child child = resolveChild(user, childId); + + int horizon = resolveHorizon(years); + int currentAgeMonths = (int) ChronoUnit.MONTHS.between(child.getBirthDate(), LocalDate.now()); + + List activePolicies = policyRepository.findByIsActiveTrue(); + List nationwide = activePolicies.stream().filter(this::isNationwide).toList(); + List regions = policyRepository.findDistinctTargetRegions(); + + // 전국 정책은 어디에 살든 받으므로 모든 지역에 공통으로 얹는다. + RegionSummary nationwideBase = summarize(nationwide, currentAgeMonths, horizon); + + Map summaries = new LinkedHashMap<>(); + for (String region : regions) { + List regional = activePolicies.stream() + .filter(p -> region.equals(p.getTargetRegion())) + .toList(); + summaries.put(region, summarize(regional, currentAgeMonths, horizon).merge(nationwideBase)); + } + + // 차액을 내려면 기준액이 먼저 정해져야 하므로 응답은 그 뒤에 만든다. + String baseRegion = findBaseRegion(user, regions); + RegionSummary baseSummary = baseRegion == null ? null : summaries.get(baseRegion); + // 기준 지역이 없으면 전국 공통 정책만 받는 것으로 보고 비교한다. + long base = baseSummary != null ? baseSummary.amount() : nationwideBase.amount(); + + List rankings = summaries.entrySet().stream() + .map(e -> e.getValue().toResponse(e.getKey(), base)) + .sorted(Comparator.comparingLong(RegionalBenefitResponse::getTotalAmount).reversed()) + .collect(Collectors.toCollection(ArrayList::new)); + + int size = limit != null && limit > 0 ? limit : DEFAULT_LIMIT; + + return RegionalBenefitComparisonResponse.builder() + .childName(child.getName()) + .childAgeMonths(currentAgeMonths) + .horizonMonths(horizon) + .baseRegion(baseRegion) + .baseAmount(base) + .rankings(rankings.size() > size ? rankings.subList(0, size) : rankings) + .dataQuality("ESTIMATED") + .disclaimers(buildDisclaimers(baseRegion)) + .build(); + } + + /** 지역 한 곳의 집계 중간 결과. */ + private record RegionSummary(long amount, int cashCount, int nonCashCount, + List contributions) { + + RegionSummary merge(RegionSummary other) { + List merged = new ArrayList<>(contributions); + merged.addAll(other.contributions); + return new RegionSummary(amount + other.amount, cashCount + other.cashCount, + nonCashCount + other.nonCashCount, merged); + } + + RegionalBenefitResponse toResponse(String region, long baseAmount) { + List top = contributions.stream() + .sorted(Comparator.comparingLong(RegionalBenefitResponse.Contribution::getAmount).reversed()) + .limit(TOP_CONTRIBUTORS) + .toList(); + + return RegionalBenefitResponse.builder() + .region(region) + .totalAmount(amount) + .differenceFromBase(amount - baseAmount) + .cashPolicyCount(cashCount) + .nonCashPolicyCount(nonCashCount) + .topContributors(top) + .build(); + } + } + + private RegionSummary summarize(List policies, int ageMonths, int horizon) { + long total = 0; + int cash = 0; + int nonCash = 0; + List contributions = new ArrayList<>(); + + for (Policy policy : policies) { + BenefitProjectionCalculator.Projection projection = calculator.project(policy, ageMonths, horizon); + if (projection.eligibleMonths() == 0) { + continue; + } + if (projection.paymentType() == BenefitPaymentType.NON_CASH) { + nonCash++; + continue; + } + if (!projection.isCash()) { + continue; // 금액이 확인되지 않은 정책은 합산하지 않는다 + } + total += projection.amount(); + cash++; + contributions.add(RegionalBenefitResponse.Contribution.builder() + .title(policy.getTitle()) + .amount(projection.amount()) + .paymentType(projection.paymentType().name()) + .build()); + } + return new RegionSummary(total, cash, nonCash, contributions); + } + + private Child resolveChild(User user, Long childId) { + List children = childRepository.findByUserIdOrderByCreatedAtDesc(user.getId()); + if (children.isEmpty()) { + throw new CareServiceException("등록된 자녀가 없습니다. 자녀를 먼저 등록해 주세요."); + } + Child child = childId == null ? children.get(0) + : children.stream().filter(c -> c.getId().equals(childId)).findFirst() + .orElseThrow(() -> new CareServiceException("자녀를 찾을 수 없습니다: " + childId)); + + if (child.getBirthDate() == null) { + throw new CareServiceException("자녀의 생년월일이 없어 지원금을 계산할 수 없습니다."); + } + return child; + } + + private int resolveHorizon(Integer years) { + if (years == null || years <= 0) { + return DEFAULT_HORIZON_MONTHS; + } + return Math.min(years * 12, MAX_HORIZON_MONTHS); + } + + private boolean isNationwide(Policy policy) { + String region = policy.getTargetRegion(); + return region == null || region.isBlank() || region.contains("전국"); + } + + /** 사용자 주소에서 정책 지역명을 찾는다. 표기 단위가 달라 양방향으로 확인한다. */ + private String findBaseRegion(User user, List regions) { + String address = user.getAddress(); + if (address == null || address.isBlank()) { + return null; + } + return regions.stream() + .filter(r -> address.contains(r) || r.contains(address)) + // 가장 구체적인 지역명을 고른다 ("경기도" 보다 "성남시") + .max(Comparator.comparingInt(String::length)) + .orElse(null); + } + + private List buildDisclaimers(String baseRegion) { + List notes = new ArrayList<>(); + notes.add("수집된 정책 기준 추정치이며 실제 수령액과 다를 수 있습니다."); + notes.add("무료검진·서비스 등 금액으로 환산할 수 없는 혜택은 합산에서 제외했습니다."); + notes.add("지급 방식이 명시되지 않은 정책은 과대 계상을 피하기 위해 1회 지급으로 계산했습니다."); + if (baseRegion == null) { + notes.add("주소가 등록되지 않아 전국 공통 정책만을 기준으로 비교했습니다."); + } + notes.add("전입 지원금은 거주 요건·기간 조건이 붙는 경우가 많으므로 신청 전 해당 지자체에 확인하세요."); + return notes; + } +} diff --git a/src/test/java/com/carecode/domain/policy/service/RegionalBenefitComparisonServiceTest.java b/src/test/java/com/carecode/domain/policy/service/RegionalBenefitComparisonServiceTest.java new file mode 100644 index 00000000..93e508c3 --- /dev/null +++ b/src/test/java/com/carecode/domain/policy/service/RegionalBenefitComparisonServiceTest.java @@ -0,0 +1,224 @@ +package com.carecode.domain.policy.service; + +import com.carecode.core.benefit.BenefitProjectionCalculator; +import com.carecode.core.exception.CareServiceException; +import com.carecode.core.security.CurrentUserFacade; +import com.carecode.domain.policy.dto.response.RegionalBenefitComparisonResponse; +import com.carecode.domain.policy.dto.response.RegionalBenefitResponse; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.repository.PolicyRepository; +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.ChildRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("거주지별 지원금 비교") +class RegionalBenefitComparisonServiceTest { + + private PolicyRepository policyRepository; + private ChildRepository childRepository; + private RegionalBenefitComparisonService service; + private User user; + + @BeforeEach + void setUp() { + policyRepository = mock(PolicyRepository.class); + childRepository = mock(ChildRepository.class); + CurrentUserFacade currentUserFacade = mock(CurrentUserFacade.class); + + user = User.builder().id(1L).name("부모").address("경기도 성남시 분당구").build(); + when(currentUserFacade.requireCurrentUser()).thenReturn(user); + + service = new RegionalBenefitComparisonService( + policyRepository, childRepository, currentUserFacade, new BenefitProjectionCalculator()); + } + + @Test + @DisplayName("전국 정책은 모든 지역에 공통으로 더한다") + void addsNationwidePolicyToEveryRegion() { + givenChildAgedMonths(0); + givenPolicies( + policy("부모급여", "전국", 0, 11, 1000000, "월지급"), + policy("성남시 출산장려금", "성남시", 0, 11, 500000, "일시지급"), + policy("OO군 출산장려금", "OO군", 0, 11, 3000000, "일시지급")); + givenRegions("성남시", "OO군"); + + RegionalBenefitComparisonResponse result = service.compare(null, 1, 10); + + // 전국 1,000,000 × 12개월 = 12,000,000 이 두 지역 모두에 포함된다 + assertThat(byRegion(result, "성남시").getTotalAmount()).isEqualTo(12_000_000 + 500_000); + assertThat(byRegion(result, "OO군").getTotalAmount()).isEqualTo(12_000_000 + 3_000_000); + } + + @Test + @DisplayName("현재 거주지 대비 차액을 계산한다") + void calculatesDifferenceFromCurrentRegion() { + givenChildAgedMonths(0); + givenPolicies( + policy("성남시 지원", "성남시", 0, 11, 500000, "일시지급"), + policy("OO군 지원", "OO군", 0, 11, 3000000, "일시지급")); + givenRegions("성남시", "OO군"); + + RegionalBenefitComparisonResponse result = service.compare(null, 1, 10); + + assertThat(result.getBaseRegion()).isEqualTo("성남시"); + assertThat(result.getBaseAmount()).isEqualTo(500_000); + assertThat(byRegion(result, "OO군").getDifferenceFromBase()).isEqualTo(2_500_000); + assertThat(byRegion(result, "성남시").getDifferenceFromBase()).isZero(); + } + + @Test + @DisplayName("총액 내림차순으로 정렬한다") + void sortsByTotalDescending() { + givenChildAgedMonths(0); + givenPolicies( + policy("소액", "A시", 0, 11, 100000, "일시지급"), + policy("고액", "B군", 0, 11, 5000000, "일시지급")); + givenRegions("A시", "B군"); + + RegionalBenefitComparisonResponse result = service.compare(null, 1, 10); + + assertThat(result.getRankings().get(0).getRegion()).isEqualTo("B군"); + } + + @Test + @DisplayName("주소가 없으면 전국 정책만을 기준으로 삼는다") + void fallsBackToNationwideWhenAddressMissing() { + user.setAddress(null); + givenChildAgedMonths(0); + givenPolicies( + policy("전국지원", "전국", 0, 11, 100000, "월지급"), + policy("OO군 지원", "OO군", 0, 11, 3000000, "일시지급")); + givenRegions("OO군"); + + RegionalBenefitComparisonResponse result = service.compare(null, 1, 10); + + assertThat(result.getBaseRegion()).isNull(); + assertThat(result.getBaseAmount()).isEqualTo(1_200_000); // 전국 정책만 + assertThat(result.getDisclaimers()).anyMatch(d -> d.contains("주소가 등록되지 않아")); + } + + @Test + @DisplayName("현금이 아닌 혜택은 금액이 아니라 건수로 센다") + void countsNonCashSeparately() { + givenChildAgedMonths(0); + givenPolicies( + policy("무료검진", "A시", 0, 11, 300000, "무료검진"), + policy("현금지원", "A시", 0, 11, 200000, "일시지급")); + givenRegions("A시"); + + RegionalBenefitResponse a = byRegion(service.compare(null, 1, 10), "A시"); + + assertThat(a.getTotalAmount()).isEqualTo(200_000); + assertThat(a.getCashPolicyCount()).isEqualTo(1); + assertThat(a.getNonCashPolicyCount()).isEqualTo(1); + } + + @Test + @DisplayName("금액 기여가 큰 정책을 근거로 노출한다") + void exposesTopContributors() { + givenChildAgedMonths(0); + givenPolicies( + policy("소액", "A시", 0, 11, 100000, "일시지급"), + policy("고액", "A시", 0, 11, 900000, "일시지급")); + givenRegions("A시"); + + RegionalBenefitResponse a = byRegion(service.compare(null, 1, 10), "A시"); + + assertThat(a.getTopContributors().get(0).getTitle()).isEqualTo("고액"); + } + + @Test + @DisplayName("상위 노출 개수를 제한한다") + void limitsRankingSize() { + givenChildAgedMonths(0); + givenPolicies( + policy("a", "A시", 0, 11, 100000, "일시지급"), + policy("b", "B시", 0, 11, 200000, "일시지급"), + policy("c", "C시", 0, 11, 300000, "일시지급")); + givenRegions("A시", "B시", "C시"); + + assertThat(service.compare(null, 1, 2).getRankings()).hasSize(2); + } + + @Test + @DisplayName("추정치임을 항상 알린다") + void alwaysMarksAsEstimate() { + givenChildAgedMonths(0); + givenPolicies(policy("지원", "A시", 0, 11, 100000, "일시지급")); + givenRegions("A시"); + + RegionalBenefitComparisonResponse result = service.compare(null, 1, 10); + + assertThat(result.getDataQuality()).isEqualTo("ESTIMATED"); + assertThat(result.getDisclaimers()).anyMatch(d -> d.contains("추정치")); + } + + @Test + @DisplayName("자녀가 없으면 계산할 수 없다고 알린다") + void failsWithoutChild() { + when(childRepository.findByUserIdOrderByCreatedAtDesc(anyLong())).thenReturn(List.of()); + + assertThatThrownBy(() -> service.compare(null, 5, 10)) + .isInstanceOf(CareServiceException.class) + .hasMessageContaining("등록된 자녀가 없습니다"); + } + + @Test + @DisplayName("생년월일이 없으면 계산할 수 없다고 알린다") + void failsWithoutBirthDate() { + Child child = Child.builder().name("아이").birthDate(null).build(); + when(childRepository.findByUserIdOrderByCreatedAtDesc(anyLong())).thenReturn(List.of(child)); + + assertThatThrownBy(() -> service.compare(null, 5, 10)) + .isInstanceOf(CareServiceException.class) + .hasMessageContaining("생년월일"); + } + + private void givenChildAgedMonths(int months) { + Child child = Child.builder() + .id(1L).name("아이") + .birthDate(LocalDate.now().minusMonths(months)) + .build(); + when(childRepository.findByUserIdOrderByCreatedAtDesc(anyLong())).thenReturn(List.of(child)); + } + + private void givenPolicies(Policy... policies) { + when(policyRepository.findByIsActiveTrue()).thenReturn(List.of(policies)); + } + + private void givenRegions(String... regions) { + when(policyRepository.findDistinctTargetRegions()).thenReturn(List.of(regions)); + } + + private RegionalBenefitResponse byRegion(RegionalBenefitComparisonResponse response, String region) { + return response.getRankings().stream() + .filter(r -> r.getRegion().equals(region)) + .findFirst() + .orElseThrow(() -> new AssertionError(region + " 결과가 없습니다")); + } + + private Policy policy(String title, String region, Integer ageMin, Integer ageMax, + Integer amount, String benefitType) { + Policy p = new Policy(); + p.setTitle(title); + p.setTargetRegion(region); + p.setTargetAgeMin(ageMin); + p.setTargetAgeMax(ageMax); + p.setBenefitAmount(amount); + p.setBenefitType(benefitType); + p.setIsActive(true); + return p; + } +} From 5810ebc7d09a0d128a025e224a26ee15f9d2da68 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Tue, 4 Aug 2026 17:34:11 +0900 Subject: [PATCH 06/68] =?UTF-8?q?FEAT=20:=20=EC=B6=A9=EC=9B=90=EC=9C=A8=20?= =?UTF-8?q?=EC=B6=94=EC=9D=B4=20=EA=B8=B0=EB=B0=98=20=EC=8B=9C=EC=84=A4=20?= =?UTF-8?q?=EC=9D=B8=EA=B8=B0=EB=8F=84=20=EB=B6=84=EC=84=9D=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../careFacility/app/CareFacilityFacade.java | 8 + .../controller/CareFacilityController.java | 10 + .../response/FacilityPopularityResponse.java | 41 ++++ .../service/FacilityPopularityService.java | 161 ++++++++++++++++ .../FacilityPopularityServiceTest.java | 182 ++++++++++++++++++ 5 files changed, 402 insertions(+) create mode 100644 src/main/java/com/carecode/domain/careFacility/dto/response/FacilityPopularityResponse.java create mode 100644 src/main/java/com/carecode/domain/careFacility/service/FacilityPopularityService.java create mode 100644 src/test/java/com/carecode/domain/careFacility/service/FacilityPopularityServiceTest.java diff --git a/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java b/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java index face7459..94f9e75d 100644 --- a/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java +++ b/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java @@ -2,6 +2,7 @@ import com.carecode.domain.careFacility.dto.response.AdmissionForecastResponse; import com.carecode.domain.careFacility.dto.response.BookingResponse; +import com.carecode.domain.careFacility.dto.response.FacilityPopularityResponse; import com.carecode.domain.careFacility.dto.request.ReviewRequest; import com.carecode.domain.careFacility.dto.request.CreateBookingRequest; import com.carecode.domain.careFacility.dto.request.UpdateBookingRequest; @@ -13,6 +14,7 @@ import com.carecode.domain.careFacility.entity.FacilityType; import com.carecode.domain.careFacility.service.AdmissionForecastService; import com.carecode.domain.careFacility.service.CareFacilityBookingService; +import com.carecode.domain.careFacility.service.FacilityPopularityService; import com.carecode.domain.careFacility.service.CareFacilityService; import lombok.RequiredArgsConstructor; import org.springframework.security.core.userdetails.UserDetails; @@ -28,6 +30,7 @@ public class CareFacilityFacade { private final CareFacilityService careFacilityService; private final CareFacilityBookingService bookingService; private final AdmissionForecastService admissionForecastService; + private final FacilityPopularityService facilityPopularityService; @Transactional(readOnly = true) public List getAllCareFacilities(int page, int size) { @@ -210,4 +213,9 @@ public void deleteReview(Long reviewId, String userEmail) { public AdmissionForecastResponse forecastAdmission(Long facilityId, Integer childAgeMonths, Integer horizonMonths) { return admissionForecastService.forecast(facilityId, childAgeMonths, horizonMonths); } + + @Transactional(readOnly = true) + public FacilityPopularityResponse analyzePopularity(Long facilityId) { + return facilityPopularityService.analyze(facilityId); + } } diff --git a/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java b/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java index d5c51644..e31ce69e 100644 --- a/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java +++ b/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java @@ -13,6 +13,7 @@ import com.carecode.domain.careFacility.dto.response.CareFacilityStatsResponse; import com.carecode.domain.careFacility.dto.response.AdmissionForecastResponse; import com.carecode.domain.careFacility.dto.response.BookingResponse; +import com.carecode.domain.careFacility.dto.response.FacilityPopularityResponse; import com.carecode.domain.careFacility.dto.response.ReviewResponse; import com.carecode.domain.careFacility.dto.request.CreateBookingRequest; import com.carecode.domain.careFacility.dto.request.UpdateBookingRequest; @@ -444,4 +445,13 @@ public ResponseEntity forecastAdmission( @Parameter(description = "예측 기간(개월)", example = "6") @RequestParam(required = false) Integer horizonMonths) { return ResponseEntity.ok(careFacilityFacade.forecastAdmission(facilityId, childAgeMonths, horizonMonths)); } + + // 충원율 기반 인기도 + @GetMapping("/{facilityId}/popularity") + @LogExecutionTime + @Operation(summary = "시설 인기도 조회", description = "충원율 추이로 수요 수준과 변동을 분석합니다.") + public ResponseEntity getPopularity( + @Parameter(description = "시설 ID", required = true) @PathVariable Long facilityId) { + return ResponseEntity.ok(careFacilityFacade.analyzePopularity(facilityId)); + } } diff --git a/src/main/java/com/carecode/domain/careFacility/dto/response/FacilityPopularityResponse.java b/src/main/java/com/carecode/domain/careFacility/dto/response/FacilityPopularityResponse.java new file mode 100644 index 00000000..b4e839a9 --- /dev/null +++ b/src/main/java/com/carecode/domain/careFacility/dto/response/FacilityPopularityResponse.java @@ -0,0 +1,41 @@ +package com.carecode.domain.careFacility.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.util.List; + +/** 충원율 추이로 본 시설 인기도. 리뷰와 달리 시설이 개입할 수 없는 지표다. */ +@Getter +@Builder +public class FacilityPopularityResponse { + + private Long facilityId; + private String facilityName; + + private boolean available; + private String unavailableReason; + + private long observationCount; + + /** 평균 충원율(%). 현원/정원. */ + private Integer averageFillRate; + + /** 최근 관측 충원율(%). */ + private Integer latestFillRate; + + /** 관측 중 정원이 꽉 찬 비율(%). 높을수록 대기가 밀린다. */ + private Integer fullRatio; + + /** 충원율 추세. RISING / STABLE / FALLING */ + private String trend; + + /** IN_DEMAND / STEADY / UNDERSUBSCRIBED */ + private String demandLevel; + + /** 충원율이 급락한 시점. 운영 변화 신호일 수 있어 별도로 알린다. */ + private List sharpDropDates; + + private List reasons; +} diff --git a/src/main/java/com/carecode/domain/careFacility/service/FacilityPopularityService.java b/src/main/java/com/carecode/domain/careFacility/service/FacilityPopularityService.java new file mode 100644 index 00000000..b56083bf --- /dev/null +++ b/src/main/java/com/carecode/domain/careFacility/service/FacilityPopularityService.java @@ -0,0 +1,161 @@ +package com.carecode.domain.careFacility.service; + +import com.carecode.core.exception.CareServiceException; +import com.carecode.domain.careFacility.dto.response.FacilityPopularityResponse; +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.FacilityCapacitySnapshot; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.carecode.domain.careFacility.repository.FacilityCapacitySnapshotRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +/** + * 충원율 추이로 시설 인기도를 추정한다. + * 평가인증은 대부분 최고등급이라 변별력이 없고 리뷰는 조작될 수 있지만, + * 충원율은 공공데이터가 원천이라 시설이 개입할 수 없다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FacilityPopularityService { + + private static final int MIN_OBSERVATIONS = 4; + private static final int LOOKBACK_MONTHS = 24; + + /** 이 이상이면 사실상 만원으로 본다. 정원 관리 여유를 감안한 값이다. */ + private static final int FULL_THRESHOLD = 98; + + private static final int IN_DEMAND_THRESHOLD = 90; + private static final int UNDERSUBSCRIBED_THRESHOLD = 70; + + /** 충원율이 이만큼 떨어지면 운영 변화 신호로 본다. */ + private static final int SHARP_DROP_POINTS = 15; + + /** 추세 판정 기준. 전·후반 평균 차이. */ + private static final int TREND_POINTS = 5; + + private final CareFacilityRepository careFacilityRepository; + private final FacilityCapacitySnapshotRepository snapshotRepository; + + public FacilityPopularityResponse analyze(Long facilityId) { + CareFacility facility = careFacilityRepository.findById(facilityId) + .orElseThrow(() -> new CareServiceException("시설을 찾을 수 없습니다: " + facilityId)); + + List history = + snapshotRepository.findHistory(facilityId, LocalDate.now().minusMonths(LOOKBACK_MONTHS)); + + List rates = toFillRates(history); + + FacilityPopularityResponse.FacilityPopularityResponseBuilder base = FacilityPopularityResponse.builder() + .facilityId(facilityId) + .facilityName(facility.getName()) + .observationCount(rates.size()); + + if (rates.size() < MIN_OBSERVATIONS) { + return base.available(false) + .unavailableReason(String.format("정원 관측이 %d회로 부족합니다. (최소 %d회 필요)", + rates.size(), MIN_OBSERVATIONS)) + .build(); + } + + int average = (int) Math.round(rates.stream().mapToInt(Rate::fillRate).average().orElse(0)); + int latest = rates.get(rates.size() - 1).fillRate(); + int fullRatio = (int) Math.round( + 100.0 * rates.stream().filter(r -> r.fillRate() >= FULL_THRESHOLD).count() / rates.size()); + + String trend = resolveTrend(rates); + String demandLevel = resolveDemandLevel(average, fullRatio); + List drops = findSharpDrops(rates); + + return base.available(true) + .averageFillRate(average) + .latestFillRate(latest) + .fullRatio(fullRatio) + .trend(trend) + .demandLevel(demandLevel) + .sharpDropDates(drops) + .reasons(buildReasons(average, fullRatio, trend, demandLevel, drops)) + .build(); + } + + private record Rate(LocalDate date, int fillRate) { + } + + /** 정원이 0 이거나 없는 관측은 비율을 낼 수 없어 버린다. */ + private List toFillRates(List history) { + List rates = new ArrayList<>(); + for (FacilityCapacitySnapshot s : history) { + Integer capacity = s.getCapacity(); + Integer enrolled = s.getCurrentEnrollment(); + if (capacity == null || capacity <= 0 || enrolled == null) { + continue; + } + rates.add(new Rate(s.getObservedDate(), (int) Math.round(100.0 * enrolled / capacity))); + } + return rates; + } + + /** 전반부와 후반부 평균을 비교한다. 관측 간격이 불규칙해 회귀보다 이쪽이 안정적이다. */ + private String resolveTrend(List rates) { + int half = rates.size() / 2; + double earlier = rates.subList(0, half).stream().mapToInt(Rate::fillRate).average().orElse(0); + double later = rates.subList(half, rates.size()).stream().mapToInt(Rate::fillRate).average().orElse(0); + + double delta = later - earlier; + if (delta >= TREND_POINTS) { + return "RISING"; + } + return delta <= -TREND_POINTS ? "FALLING" : "STABLE"; + } + + private String resolveDemandLevel(int average, int fullRatio) { + if (average >= IN_DEMAND_THRESHOLD || fullRatio >= 50) { + return "IN_DEMAND"; + } + return average < UNDERSUBSCRIBED_THRESHOLD ? "UNDERSUBSCRIBED" : "STEADY"; + } + + /** 직전 관측 대비 급락 지점. 3월 신학기 전환은 정상 변동이라 제외한다. */ + private List findSharpDrops(List rates) { + List drops = new ArrayList<>(); + for (int i = 1; i < rates.size(); i++) { + Rate current = rates.get(i); + int delta = current.fillRate() - rates.get(i - 1).fillRate(); + if (delta <= -SHARP_DROP_POINTS && current.date().getMonthValue() != 3) { + drops.add(current.date()); + } + } + return drops; + } + + private List buildReasons(int average, int fullRatio, String trend, + String demandLevel, List drops) { + List reasons = new ArrayList<>(); + reasons.add(String.format("평균 충원율 %d%%, 관측 중 %d%% 기간이 정원에 도달했습니다.", average, fullRatio)); + + switch (demandLevel) { + case "IN_DEMAND" -> reasons.add("정원이 자주 차는 시설로, 대기가 길 수 있습니다."); + case "UNDERSUBSCRIBED" -> reasons.add("정원에 여유가 지속되고 있어 입소가 비교적 쉽습니다."); + default -> reasons.add("정원과 현원이 안정적으로 유지되고 있습니다."); + } + + switch (trend) { + case "RISING" -> reasons.add("충원율이 상승 추세입니다."); + case "FALLING" -> reasons.add("충원율이 하락 추세입니다."); + default -> { } + } + + if (!drops.isEmpty()) { + reasons.add(String.format("충원율이 크게 떨어진 시점이 %d회 있었습니다. 신학기 전환이 아닌 변동입니다.", + drops.size())); + } + return reasons; + } +} diff --git a/src/test/java/com/carecode/domain/careFacility/service/FacilityPopularityServiceTest.java b/src/test/java/com/carecode/domain/careFacility/service/FacilityPopularityServiceTest.java new file mode 100644 index 00000000..3aa3951e --- /dev/null +++ b/src/test/java/com/carecode/domain/careFacility/service/FacilityPopularityServiceTest.java @@ -0,0 +1,182 @@ +package com.carecode.domain.careFacility.service; + +import com.carecode.domain.careFacility.dto.response.FacilityPopularityResponse; +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.FacilityCapacitySnapshot; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.carecode.domain.careFacility.repository.FacilityCapacitySnapshotRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("충원율 기반 시설 인기도") +class FacilityPopularityServiceTest { + + private FacilityCapacitySnapshotRepository snapshotRepository; + private FacilityPopularityService service; + + @BeforeEach + void setUp() { + CareFacilityRepository facilityRepository = mock(CareFacilityRepository.class); + snapshotRepository = mock(FacilityCapacitySnapshotRepository.class); + when(facilityRepository.findById(anyLong())) + .thenReturn(Optional.of(CareFacility.builder().name("행복어린이집").build())); + service = new FacilityPopularityService(facilityRepository, snapshotRepository); + } + + @Test + @DisplayName("관측이 부족하면 판단하지 않는다") + void refusesWithoutEnoughObservations() { + given(rates(95, 96)); + + FacilityPopularityResponse result = service.analyze(1L); + + assertThat(result.isAvailable()).isFalse(); + assertThat(result.getUnavailableReason()).contains("최소 4회"); + } + + @Test + @DisplayName("정원이 없는 관측은 비율을 낼 수 없어 제외한다") + void skipsSnapshotsWithoutCapacity() { + List history = new ArrayList<>(); + LocalDate start = LocalDate.now().minusWeeks(6); + for (int i = 0; i < 6; i++) { + history.add(FacilityCapacitySnapshot.builder() + .facilityId(1L).observedDate(start.plusWeeks(i)) + .capacity(null).currentEnrollment(50).build()); + } + when(snapshotRepository.findHistory(anyLong(), any())).thenReturn(history); + + FacilityPopularityResponse result = service.analyze(1L); + + assertThat(result.isAvailable()).isFalse(); + assertThat(result.getObservationCount()).isZero(); + } + + @Test + @DisplayName("정원이 계속 차 있으면 인기 시설로 본다") + void marksInDemandWhenConsistentlyFull() { + given(rates(100, 100, 99, 100, 100, 100)); + + FacilityPopularityResponse result = service.analyze(1L); + + assertThat(result.isAvailable()).isTrue(); + assertThat(result.getDemandLevel()).isEqualTo("IN_DEMAND"); + assertThat(result.getFullRatio()).isEqualTo(100); + assertThat(result.getReasons()).anyMatch(r -> r.contains("대기가 길 수 있습니다")); + } + + @Test + @DisplayName("정원 미달이 지속되면 기피 신호로 본다") + void marksUndersubscribed() { + given(rates(55, 52, 50, 48, 51, 49)); + + FacilityPopularityResponse result = service.analyze(1L); + + assertThat(result.getDemandLevel()).isEqualTo("UNDERSUBSCRIBED"); + assertThat(result.getReasons()).anyMatch(r -> r.contains("입소가 비교적 쉽습니다")); + } + + @Test + @DisplayName("충원율 상승 추세를 잡아낸다") + void detectsRisingTrend() { + given(rates(60, 62, 65, 80, 85, 90)); + + assertThat(service.analyze(1L).getTrend()).isEqualTo("RISING"); + } + + @Test + @DisplayName("충원율 하락 추세를 잡아낸다") + void detectsFallingTrend() { + given(rates(95, 93, 90, 70, 65, 60)); + + assertThat(service.analyze(1L).getTrend()).isEqualTo("FALLING"); + } + + @Test + @DisplayName("변동이 작으면 안정으로 본다") + void detectsStableTrend() { + given(rates(80, 82, 79, 81, 80, 83)); + + assertThat(service.analyze(1L).getTrend()).isEqualTo("STABLE"); + } + + @Test + @DisplayName("급락 시점을 기록한다") + void recordsSharpDrop() { + // 4월에 30포인트 급락 (3월 신학기가 아님) + List history = new ArrayList<>(); + int[] values = {95, 94, 60, 62, 63, 61}; + LocalDate start = LocalDate.of(LocalDate.now().getYear() - 1, 4, 1); + for (int i = 0; i < values.length; i++) { + history.add(snapshot(start.plusMonths(i), 100, values[i])); + } + when(snapshotRepository.findHistory(anyLong(), any())).thenReturn(history); + + FacilityPopularityResponse result = service.analyze(1L); + + assertThat(result.getSharpDropDates()).hasSize(1); + assertThat(result.getReasons()).anyMatch(r -> r.contains("크게 떨어진 시점")); + } + + @Test + @DisplayName("3월 신학기 전환은 급락으로 보지 않는다") + void ignoresMarchTransition() { + List history = new ArrayList<>(); + int[] values = {98, 97, 60, 75, 85, 92}; + // 세 번째 관측이 3월이 되도록 1월부터 시작 + LocalDate start = LocalDate.of(LocalDate.now().getYear() - 1, 1, 1); + for (int i = 0; i < values.length; i++) { + history.add(snapshot(start.plusMonths(i), 100, values[i])); + } + when(snapshotRepository.findHistory(anyLong(), any())).thenReturn(history); + + assertThat(service.analyze(1L).getSharpDropDates()).isEmpty(); + } + + @Test + @DisplayName("평균과 최근 충원율을 함께 보여준다") + void reportsAverageAndLatest() { + given(rates(80, 80, 80, 90)); + + FacilityPopularityResponse result = service.analyze(1L); + + assertThat(result.getAverageFillRate()).isEqualTo(83); + assertThat(result.getLatestFillRate()).isEqualTo(90); + } + + private void given(List history) { + when(snapshotRepository.findHistory(anyLong(), any())).thenReturn(history); + } + + /** 충원율(%)을 주 단위 관측으로 만든다. 정원 100 기준. */ + private List rates(int... fillRates) { + List list = new ArrayList<>(); + LocalDate start = LocalDate.now().minusWeeks(fillRates.length); + for (int i = 0; i < fillRates.length; i++) { + list.add(snapshot(start.plusWeeks(i), 100, fillRates[i])); + } + return list; + } + + private FacilityCapacitySnapshot snapshot(LocalDate date, int capacity, int enrolled) { + return FacilityCapacitySnapshot.builder() + .facilityId(1L) + .observedDate(date) + .capacity(capacity) + .currentEnrollment(enrolled) + .availableSpots(Math.max(0, capacity - enrolled)) + .build(); + } +} From 20ea4bea4d26d4a8df2cc5d0e98b109e7b5819df Mon Sep 17 00:00:00 2001 From: RosieOh Date: Tue, 4 Aug 2026 18:44:56 +0900 Subject: [PATCH 07/68] =?UTF-8?q?FIX=20:=20=EC=A7=80=EC=97=AD=20=EB=B9=84?= =?UTF-8?q?=EA=B5=90=EC=97=90=EC=84=9C=20=EC=9E=90=EB=85=80=EC=88=98=C2=B7?= =?UTF-8?q?=EC=86=8C=EB=93=9D=20=EC=9A=94=EA=B1=B4=20=EB=AF=B8=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=88=98=EC=A0=95=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RegionalBenefitComparisonService.java | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java b/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java index ad8a745c..51288040 100644 --- a/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java +++ b/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java @@ -47,12 +47,17 @@ public class RegionalBenefitComparisonService { public RegionalBenefitComparisonResponse compare(Long childId, Integer years, Integer limit) { User user = currentUserFacade.requireCurrentUser(); - Child child = resolveChild(user, childId); + List children = childRepository.findByUserIdOrderByCreatedAtDesc(user.getId()); + Child child = resolveChild(children, childId); int horizon = resolveHorizon(years); int currentAgeMonths = (int) ChronoUnit.MONTHS.between(child.getBirthDate(), LocalDate.now()); - List activePolicies = policyRepository.findByIsActiveTrue(); + // 자격이 안 되는 정책을 총액에 넣으면 "이사하면 얼마 더" 가 통째로 틀어진다. + List activePolicies = policyRepository.findByIsActiveTrue().stream() + .filter(p -> meetsHouseholdConditions(p, user, children.size())) + .toList(); + long conditional = activePolicies.stream().filter(p -> isIncomeConditional(p, user)).count(); List nationwide = activePolicies.stream().filter(this::isNationwide).toList(); List regions = policyRepository.findDistinctTargetRegions(); @@ -88,10 +93,26 @@ public RegionalBenefitComparisonResponse compare(Long childId, Integer years, In .baseAmount(base) .rankings(rankings.size() > size ? rankings.subList(0, size) : rankings) .dataQuality("ESTIMATED") - .disclaimers(buildDisclaimers(baseRegion)) + .disclaimers(buildDisclaimers(baseRegion, conditional)) .build(); } + /** 자녀 수는 확정값이라 못 맞추면 제외한다. 소득은 미입력을 탈락으로 보지 않는다. */ + private boolean meetsHouseholdConditions(Policy policy, User user, int childCount) { + Integer minChildren = policy.getMinChildren(); + if (minChildren != null && childCount < minChildren) { + return false; + } + Integer threshold = policy.getIncomeThresholdPercent(); + Integer income = user.getIncomePercent(); + return threshold == null || income == null || income <= threshold; + } + + /** 소득 조건이 있는데 사용자가 소득을 입력하지 않아 판정을 보류한 정책. */ + private boolean isIncomeConditional(Policy policy, User user) { + return policy.getIncomeThresholdPercent() != null && user.getIncomePercent() == null; + } + /** 지역 한 곳의 집계 중간 결과. */ private record RegionSummary(long amount, int cashCount, int nonCashCount, List contributions) { @@ -149,8 +170,7 @@ private RegionSummary summarize(List policies, int ageMonths, int horizo return new RegionSummary(total, cash, nonCash, contributions); } - private Child resolveChild(User user, Long childId) { - List children = childRepository.findByUserIdOrderByCreatedAtDesc(user.getId()); + private Child resolveChild(List children, Long childId) { if (children.isEmpty()) { throw new CareServiceException("등록된 자녀가 없습니다. 자녀를 먼저 등록해 주세요."); } @@ -189,9 +209,16 @@ private String findBaseRegion(User user, List regions) { .orElse(null); } - private List buildDisclaimers(String baseRegion) { + private List buildDisclaimers(String baseRegion, long conditionalCount) { List notes = new ArrayList<>(); notes.add("수집된 정책 기준 추정치이며 실제 수령액과 다를 수 있습니다."); + // 중복 수급이 불가능한 정책들이 함께 더해질 수 있어, 총액보다 지역 간 차액이 신뢰도가 높다. + notes.add("총액은 모든 정책을 단순 합산한 값입니다. 상호 배타적인 정책이 포함될 수 있으므로 " + + "지역 간 '차액' 을 기준으로 보세요."); + if (conditionalCount > 0) { + notes.add(String.format("소득 조건이 걸린 정책 %d건은 소득 미입력 상태로 포함했습니다. " + + "소득을 입력하면 정확해집니다.", conditionalCount)); + } notes.add("무료검진·서비스 등 금액으로 환산할 수 없는 혜택은 합산에서 제외했습니다."); notes.add("지급 방식이 명시되지 않은 정책은 과대 계상을 피하기 위해 1회 지급으로 계산했습니다."); if (baseRegion == null) { From 753dc17c1820288f8f29794f91890f44f895e016 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Tue, 4 Aug 2026 18:44:56 +0900 Subject: [PATCH 08/68] =?UTF-8?q?FIX=20:=20=EB=8C=80=EC=83=81=20=EC=97=B0?= =?UTF-8?q?=EB=A0=B9=EA=B3=BC=20=EC=A7=80=EA=B8=89=20=EA=B8=B0=EA=B0=84=20?= =?UTF-8?q?=ED=98=BC=EB=8F=99=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20?= =?UTF-8?q?=EC=88=98=EB=A0=B9=EC=95=A1=20=EA=B3=BC=EB=8C=80=20=EA=B3=84?= =?UTF-8?q?=EC=83=81=20=EC=88=98=EC=A0=95=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../benefit/BenefitProjectionCalculator.java | 21 +++++++++--- .../carecode/domain/policy/entity/Policy.java | 7 ++++ .../service/PolicyInitializationService.java | 2 ++ .../V7__benefit_payment_duration.sql | 7 ++++ .../BenefitProjectionCalculatorTest.java | 32 +++++++++++++++++++ 5 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 src/main/resources/db/migration/V7__benefit_payment_duration.sql diff --git a/src/main/java/com/carecode/core/benefit/BenefitProjectionCalculator.java b/src/main/java/com/carecode/core/benefit/BenefitProjectionCalculator.java index 9d62206b..51c7d433 100644 --- a/src/main/java/com/carecode/core/benefit/BenefitProjectionCalculator.java +++ b/src/main/java/com/carecode/core/benefit/BenefitProjectionCalculator.java @@ -43,12 +43,23 @@ public Projection project(Policy policy, int currentAgeMonths, int horizonMonths return new Projection(0, eligibleMonths, type); } - // UNKNOWN 을 월 지급으로 가정하면 5년 기준 최대 60배 과대 계상된다. 1회로 본다. - long total = type == BenefitPaymentType.MONTHLY - ? (long) amount * eligibleMonths - : amount; + if (type != BenefitPaymentType.MONTHLY) { + // UNKNOWN 을 월 지급으로 가정하면 5년 기준 최대 60배 과대 계상된다. 1회로 본다. + return new Projection(amount, eligibleMonths, type); + } + + // 대상 연령 구간과 지급 기간은 다르다. 상한이 있으면 그만큼만 받는다. + int paidMonths = capByPaymentDuration(policy, eligibleMonths); + return new Projection((long) amount * paidMonths, paidMonths, type); + } - return new Projection(total, eligibleMonths, type); + /** 지급 개월 상한. 미지정이면 대상 구간 전체를 받는 것으로 본다. */ + private int capByPaymentDuration(Policy policy, int eligibleMonths) { + Integer max = policy.getMaxPaymentMonths(); + if (max == null || max <= 0) { + return eligibleMonths; + } + return Math.min(eligibleMonths, max); } /** diff --git a/src/main/java/com/carecode/domain/policy/entity/Policy.java b/src/main/java/com/carecode/domain/policy/entity/Policy.java index 1408230c..ec33b895 100644 --- a/src/main/java/com/carecode/domain/policy/entity/Policy.java +++ b/src/main/java/com/carecode/domain/policy/entity/Policy.java @@ -67,6 +67,13 @@ public class Policy { @Column(name = "benefit_amount") private Integer benefitAmount; + /** + * 월 지급 정책의 최대 지급 개월. null 이면 대상 연령 구간 내내 지급한다. + * 대상 연령과 지급 기간은 다르다 — 육아휴직급여는 아이가 0~96개월이어도 최대 12개월만 받는다. + */ + @Column(name = "max_payment_months") + private Integer maxPaymentMonths; + @Column(name = "benefit_type") private String benefitType; diff --git a/src/main/java/com/carecode/domain/policy/service/PolicyInitializationService.java b/src/main/java/com/carecode/domain/policy/service/PolicyInitializationService.java index 6e37945a..da6166a0 100644 --- a/src/main/java/com/carecode/domain/policy/service/PolicyInitializationService.java +++ b/src/main/java/com/carecode/domain/policy/service/PolicyInitializationService.java @@ -137,6 +137,7 @@ private void createMaternityAndChildcareLeave(PolicyCategory category) { .targetAgeMax(96) .targetRegion("전국") .benefitAmount(1500000) + .maxPaymentMonths(12) .benefitType("월급여") .applicationUrl("https://www.ei.go.kr") .contactInfo("고용보험 고객상담센터 1350") @@ -155,6 +156,7 @@ private void createMaternityAndChildcareLeave(PolicyCategory category) { .targetAgeMax(96) .targetRegion("전국") .benefitAmount(2500000) + .maxPaymentMonths(3) .benefitType("월급여") .applicationUrl("https://www.ei.go.kr") .contactInfo("고용보험 고객상담센터 1350") diff --git a/src/main/resources/db/migration/V7__benefit_payment_duration.sql b/src/main/resources/db/migration/V7__benefit_payment_duration.sql new file mode 100644 index 00000000..956a0a32 --- /dev/null +++ b/src/main/resources/db/migration/V7__benefit_payment_duration.sql @@ -0,0 +1,7 @@ +-- 지급 기간 상한. +-- +-- targetAgeMin/Max 는 "어떤 아이가 대상인가" 이지 "몇 개월 받는가" 가 아니다. +-- 이 둘을 같은 것으로 보면 육아휴직급여(월 150만원, 대상 0~96개월)가 60개월 전망에서 +-- 9,000만원으로 계산된다. 실제로는 최대 12개월 지급이다. +ALTER TABLE TBL_POLICIES + ADD COLUMN MAX_PAYMENT_MONTHS INT NULL COMMENT '월 지급 최대 개월 - NULL 이면 대상 연령 내내 지급'; diff --git a/src/test/java/com/carecode/core/benefit/BenefitProjectionCalculatorTest.java b/src/test/java/com/carecode/core/benefit/BenefitProjectionCalculatorTest.java index 8ee4ff5d..4626fb60 100644 --- a/src/test/java/com/carecode/core/benefit/BenefitProjectionCalculatorTest.java +++ b/src/test/java/com/carecode/core/benefit/BenefitProjectionCalculatorTest.java @@ -152,6 +152,38 @@ void zeroWhenHorizonEmpty() { assertThat(calculator.project(policy, 0, 0).eligibleMonths()).isZero(); } + @Test + @DisplayName("지급 개월 상한이 있으면 대상 기간이 길어도 그만큼만 준다") + void capsByPaymentDuration() { + // 육아휴직급여: 대상 0~96개월이지만 실제 지급은 최대 12개월 + Policy policy = policy(0, 96, 1_500_000, "월급여"); + policy.setMaxPaymentMonths(12); + + BenefitProjectionCalculator.Projection result = calculator.project(policy, 0, 60); + + // 상한이 없으면 9,000만원이 된다 + assertThat(result.eligibleMonths()).isEqualTo(12); + assertThat(result.amount()).isEqualTo(18_000_000); + } + + @Test + @DisplayName("대상 기간이 상한보다 짧으면 짧은 쪽을 따른다") + void usesShorterOfWindowAndCap() { + Policy policy = policy(0, 5, 1_000_000, "월지급"); + policy.setMaxPaymentMonths(12); + + assertThat(calculator.project(policy, 0, 60).eligibleMonths()).isEqualTo(6); + } + + @Test + @DisplayName("일시금에는 지급 개월 상한이 영향을 주지 않는다") + void capDoesNotAffectOneTime() { + Policy policy = policy(0, 96, 2_000_000, "일시지급"); + policy.setMaxPaymentMonths(3); + + assertThat(calculator.project(policy, 0, 60).amount()).isEqualTo(2_000_000); + } + private Policy policy(Integer ageMin, Integer ageMax, Integer amount, String benefitType) { Policy p = new Policy(); p.setTargetAgeMin(ageMin); From ebda4e0cdf08c06a4af4f4bc356b441ec4ea5cd4 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Tue, 4 Aug 2026 18:45:57 +0900 Subject: [PATCH 09/68] =?UTF-8?q?FEAT=20:=20=EA=B3=B5=EA=B3=B5=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=97=B0=EB=8F=99=20=EC=A0=84=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=EC=9A=A9=20=EA=B0=9C=EB=B0=9C=20=EC=83=98=ED=94=8C=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/devtools/SampleDataCleaner.java | 59 +++++ .../core/devtools/SampleDataProperties.java | 14 ++ .../core/devtools/SampleDataRunner.java | 43 ++++ .../core/devtools/SampleFacilitySeeder.java | 119 ++++++++++ .../core/devtools/SamplePolicySeeder.java | 103 +++++++++ .../controller/AdminSampleDataController.java | 45 ++++ src/main/resources/application-dev.yml | 5 + .../integration/SampleDataScenarioTest.java | 212 ++++++++++++++++++ 8 files changed, 600 insertions(+) create mode 100644 src/main/java/com/carecode/core/devtools/SampleDataCleaner.java create mode 100644 src/main/java/com/carecode/core/devtools/SampleDataProperties.java create mode 100644 src/main/java/com/carecode/core/devtools/SampleDataRunner.java create mode 100644 src/main/java/com/carecode/core/devtools/SampleFacilitySeeder.java create mode 100644 src/main/java/com/carecode/core/devtools/SamplePolicySeeder.java create mode 100644 src/main/java/com/carecode/domain/admin/controller/AdminSampleDataController.java create mode 100644 src/test/java/com/carecode/integration/SampleDataScenarioTest.java diff --git a/src/main/java/com/carecode/core/devtools/SampleDataCleaner.java b/src/main/java/com/carecode/core/devtools/SampleDataCleaner.java new file mode 100644 index 00000000..d10a0cc6 --- /dev/null +++ b/src/main/java/com/carecode/core/devtools/SampleDataCleaner.java @@ -0,0 +1,59 @@ +package com.carecode.core.devtools; + +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.FacilityCapacitySnapshot; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.carecode.domain.careFacility.repository.FacilityCapacitySnapshotRepository; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.repository.PolicyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** 샘플 데이터 제거. 접두어로 식별하므로 실데이터는 건드리지 않는다. */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SampleDataCleaner { + + private final PolicyRepository policyRepository; + private final CareFacilityRepository facilityRepository; + private final FacilityCapacitySnapshotRepository snapshotRepository; + + @Transactional + public Map clean() { + List policies = policyRepository.findAll().stream() + .filter(p -> p.getPolicyCode() != null + && p.getPolicyCode().startsWith(SampleDataProperties.POLICY_PREFIX)) + .toList(); + policyRepository.deleteAll(policies); + + List facilities = facilityRepository.findAll().stream() + .filter(f -> f.getFacilityCode() != null + && f.getFacilityCode().startsWith(SampleDataProperties.FACILITY_PREFIX)) + .toList(); + + // 스냅샷은 FK ON DELETE CASCADE 로 지워지지만, JPA 로 지울 때는 직접 정리해야 한다. + int snapshots = 0; + for (CareFacility facility : facilities) { + List owned = + snapshotRepository.findHistory(facility.getId(), LocalDate.EPOCH); + snapshots += owned.size(); + snapshotRepository.deleteAll(owned); + } + facilityRepository.deleteAll(facilities); + + Map removed = new LinkedHashMap<>(); + removed.put("policies", policies.size()); + removed.put("facilities", facilities.size()); + removed.put("snapshots", snapshots); + log.warn("샘플 데이터를 제거했습니다 - {}", removed); + return removed; + } +} diff --git a/src/main/java/com/carecode/core/devtools/SampleDataProperties.java b/src/main/java/com/carecode/core/devtools/SampleDataProperties.java new file mode 100644 index 00000000..e34e1700 --- /dev/null +++ b/src/main/java/com/carecode/core/devtools/SampleDataProperties.java @@ -0,0 +1,14 @@ +package com.carecode.core.devtools; + +/** 샘플 데이터 식별자. 실데이터와 섞이지 않도록 접두어로 구분하고, 이 접두어로 일괄 삭제한다. */ +public final class SampleDataProperties { + + /** 샘플 정책 코드 접두어. */ + public static final String POLICY_PREFIX = "SAMPLE-POLICY-"; + + /** 샘플 시설 코드 접두어. */ + public static final String FACILITY_PREFIX = "SAMPLE-FACILITY-"; + + private SampleDataProperties() { + } +} diff --git a/src/main/java/com/carecode/core/devtools/SampleDataRunner.java b/src/main/java/com/carecode/core/devtools/SampleDataRunner.java new file mode 100644 index 00000000..e01f3422 --- /dev/null +++ b/src/main/java/com/carecode/core/devtools/SampleDataRunner.java @@ -0,0 +1,43 @@ +package com.carecode.core.devtools; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +/** + * 기동 시 샘플 데이터를 적재한다. + * 공공데이터 연동 전에 거주지 비교·입소 예측·인기도 분석을 확인하기 위한 개발 편의 기능이다. + */ +@Slf4j +@Component +@Profile("!prod") +@ConditionalOnProperty(name = "app.dev.seed-sample-data", havingValue = "true") +@RequiredArgsConstructor +public class SampleDataRunner implements ApplicationRunner { + + private final SamplePolicySeeder policySeeder; + private final SampleFacilitySeeder facilitySeeder; + + @Override + public void run(ApplicationArguments args) { + try { + int policies = policySeeder.seed(); + int facilities = facilitySeeder.seed(); + + if (policies == 0 && facilities == 0) { + log.info("샘플 데이터가 이미 적재되어 있습니다."); + return; + } + log.warn("샘플 데이터를 적재했습니다 - 정책 {}건, 시설 {}건. " + + "실제 지원 금액이 아니므로 운영 데이터와 혼동하지 마세요. " + + "제거하려면 DELETE /api/admin/dev/sample-data 를 호출하세요.", policies, facilities); + } catch (Exception e) { + // 샘플 적재 실패가 애플리케이션 기동을 막지 않도록 한다. + log.error("샘플 데이터 적재 실패", e); + } + } +} diff --git a/src/main/java/com/carecode/core/devtools/SampleFacilitySeeder.java b/src/main/java/com/carecode/core/devtools/SampleFacilitySeeder.java new file mode 100644 index 00000000..6961c7df --- /dev/null +++ b/src/main/java/com/carecode/core/devtools/SampleFacilitySeeder.java @@ -0,0 +1,119 @@ +package com.carecode.core.devtools; + +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.FacilityCapacitySnapshot; +import com.carecode.domain.careFacility.entity.FacilityType; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.carecode.domain.careFacility.repository.FacilityCapacitySnapshotRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +/** + * 입소 예측·인기도 분석을 확인하기 위한 샘플 시설과 정원 관측 이력. + * 각 시설이 서로 다른 충원 패턴을 갖도록 해서 분석 결과가 갈리는지 볼 수 있게 한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SampleFacilitySeeder { + + /** 월 1회 관측을 이만큼 만든다. 신학기 사이클이 한 번 이상 들어가야 의미가 있다. */ + private static final int MONTHS_OF_HISTORY = 18; + private static final int CAPACITY = 100; + + private final CareFacilityRepository facilityRepository; + private final FacilityCapacitySnapshotRepository snapshotRepository; + + @Transactional + public int seed() { + int created = 0; + for (Pattern pattern : Pattern.values()) { + String code = SampleDataProperties.FACILITY_PREFIX + pattern.name(); + if (facilityRepository.findByFacilityCode(code).isPresent()) { + continue; + } + CareFacility facility = facilityRepository.save(buildFacility(code, pattern)); + seedSnapshots(facility, pattern); + created++; + } + return created; + } + + /** 분석 결과가 서로 다르게 나와야 기능을 확인할 수 있다. */ + private enum Pattern { + /** 정원이 늘 차 있음 → IN_DEMAND, 입소 확률 낮음 */ + ALWAYS_FULL("늘찬어린이집", "고흥군"), + /** 정원 여유 지속 → UNDERSUBSCRIBED, 입소 확률 높음 */ + UNDERSUBSCRIBED("여유어린이집", "고흥군"), + /** 충원율 하락 추세 → FALLING */ + DECLINING("내리막어린이집", "성남시"), + /** 3월이 아닌 시점에 급락 → 운영 변화 신호 */ + SHARP_DROP("변동어린이집", "성남시"); + + final String name; + final String region; + + Pattern(String name, String region) { + this.name = name; + this.region = region; + } + } + + private CareFacility buildFacility(String code, Pattern pattern) { + return CareFacility.builder() + .facilityCode(code) + .name("[샘플] " + pattern.name) + .facilityType(FacilityType.DAYCARE) + .address(pattern.region + " 샘플로 1") + .city(pattern.region) + .latitude(37.5 + Math.random() * 0.01) + .longitude(127.0 + Math.random() * 0.01) + .capacity(CAPACITY) + .isActive(true) + .isPublic(true) + .viewCount(0) + .build(); + } + + /** 과거 MONTHS_OF_HISTORY 개월간 월 1회 관측을 만든다. */ + private void seedSnapshots(CareFacility facility, Pattern pattern) { + LocalDate start = LocalDate.now().minusMonths(MONTHS_OF_HISTORY); + + for (int i = 0; i < MONTHS_OF_HISTORY; i++) { + LocalDate observedDate = start.plusMonths(i); + int enrolled = enrollmentFor(pattern, i, observedDate); + + snapshotRepository.save(FacilityCapacitySnapshot.builder() + .facilityId(facility.getId()) + .observedDate(observedDate) + .capacity(CAPACITY) + .currentEnrollment(enrolled) + .availableSpots(Math.max(0, CAPACITY - enrolled)) + .build()); + } + + // 시설 행에는 최신 관측값을 반영해 둔다. + int latest = enrollmentFor(pattern, MONTHS_OF_HISTORY - 1, start.plusMonths(MONTHS_OF_HISTORY - 1L)); + facility.setCurrentEnrollment(latest); + facility.setAvailableSpots(Math.max(0, CAPACITY - latest)); + facilityRepository.save(facility); + } + + private int enrollmentFor(Pattern pattern, int monthIndex, LocalDate date) { + // 3월 신학기에는 졸업·승급으로 어느 시설이든 일시적으로 자리가 난다. + boolean newTerm = date.getMonthValue() == 3; + + return switch (pattern) { + case ALWAYS_FULL -> newTerm ? 92 : 100; + case UNDERSUBSCRIBED -> newTerm ? 45 : 55 + (monthIndex % 3); + // 95 에서 시작해 서서히 내려간다 + case DECLINING -> Math.max(50, 95 - monthIndex * 3); + // 중간 지점에서 한 번 크게 떨어진 뒤 회복하지 않는다 + case SHARP_DROP -> monthIndex < MONTHS_OF_HISTORY / 2 ? 95 : 62; + }; + } +} diff --git a/src/main/java/com/carecode/core/devtools/SamplePolicySeeder.java b/src/main/java/com/carecode/core/devtools/SamplePolicySeeder.java new file mode 100644 index 00000000..acab1aa3 --- /dev/null +++ b/src/main/java/com/carecode/core/devtools/SamplePolicySeeder.java @@ -0,0 +1,103 @@ +package com.carecode.core.devtools; + +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.repository.PolicyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 거주지별 지원금 비교를 확인하기 위한 샘플 정책. + * 실제 지자체 금액이 아니라 기능 확인용 임의값이다 — 공공데이터 연동 전까지만 쓴다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SamplePolicySeeder { + + private final PolicyRepository policyRepository; + + @Transactional + public int seed() { + List policies = buildPolicies(); + int created = 0; + + for (Policy policy : policies) { + if (policyRepository.findByPolicyCode(policy.getPolicyCode()).isPresent()) { + continue; // 이미 넣었으면 건너뛴다 (재기동 시 중복 방지) + } + policyRepository.save(policy); + created++; + } + return created; + } + + private List buildPolicies() { + return List.of( + // ── 전국 공통: 어디 살든 받는다. 지역 비교의 공통 기준선이 된다. + policy("NATION-01", "부모급여(0세)", "전국", 0, 11, 1_000_000, "월지급", 12), + policy("NATION-02", "부모급여(1세)", "전국", 12, 23, 500_000, "월지급", 12), + policy("NATION-03", "아동수당", "전국", 0, 95, 100_000, "월지급", 6), + policy("NATION-04", "첫만남이용권", "전국", 0, 11, 2_000_000, "일시지급", 12), + policy("NATION-05", "영유아 건강검진", "전국", 0, 71, 0, "무료검진", null), + + // ── 수도권: 전국 정책 외 추가 지원이 적다. + policy("REGION-01", "출산축하금", "성남시", 0, 11, 300_000, "일시지급", 6), + policy("REGION-02", "산후조리비 지원", "서울특별시", 0, 5, 1_000_000, "일시지급", 6), + + // ── 인구감소지역: 전입·출산 지원이 크다. 차액이 드러나는 지점. + policy("REGION-03", "출산장려금(1인당)", "고흥군", 0, 23, 7_200_000, "일시지급", 12), + policy("REGION-04", "양육비 추가지원", "고흥군", 0, 59, 200_000, "월지급", 6), + policy("REGION-05", "전입가구 정착지원금", "고흥군", 0, 95, 3_000_000, "일시지급", null), + policy("REGION-06", "출산장려금", "의성군", 0, 23, 5_000_000, "일시지급", 12), + policy("REGION-07", "육아용품 구입비", "의성군", 0, 35, 150_000, "월지원", 6), + policy("REGION-08", "다자녀 양육지원금", "해남군", 0, 71, 300_000, "월지급", 12), + + // ── 다자녀 요건: 자녀 수 조건 동작 확인용 + multiChildPolicy("REGION-09", "셋째아 이상 지원금", "고흥군", 0, 59, 500_000, 3), + + // ── 소득 요건: 소득구간 판정 동작 확인용 + incomeCappedPolicy("REGION-10", "저소득 양육지원", "성남시", 0, 59, 400_000, 150) + ); + } + + private Policy policy(String code, String title, String region, int ageMin, int ageMax, + int amount, String benefitType, Integer retroactiveMonths) { + return base(code, title, region, ageMin, ageMax, amount, benefitType, retroactiveMonths).build(); + } + + private Policy multiChildPolicy(String code, String title, String region, int ageMin, int ageMax, + int amount, int minChildren) { + return base(code, title, region, ageMin, ageMax, amount, "월지급", 12) + .minChildren(minChildren) + .build(); + } + + private Policy incomeCappedPolicy(String code, String title, String region, int ageMin, int ageMax, + int amount, int incomeThreshold) { + return base(code, title, region, ageMin, ageMax, amount, "월지급", 12) + .incomeThresholdPercent(incomeThreshold) + .build(); + } + + private Policy.PolicyBuilder base(String code, String title, String region, int ageMin, int ageMax, + int amount, String benefitType, Integer retroactiveMonths) { + return Policy.builder() + .policyCode(SampleDataProperties.POLICY_PREFIX + code) + .title(title) + .description("[샘플] 기능 확인용 데이터입니다. 실제 지원 금액이 아닙니다.") + .policyType("현금지원") + .targetRegion(region) + .targetAgeMin(ageMin) + .targetAgeMax(ageMax) + .benefitAmount(amount) + .benefitType(benefitType) + .retroactiveMonths(retroactiveMonths) + .isActive(true) + .priority(1) + .viewCount(0); + } +} diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminSampleDataController.java b/src/main/java/com/carecode/domain/admin/controller/AdminSampleDataController.java new file mode 100644 index 00000000..95500489 --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/controller/AdminSampleDataController.java @@ -0,0 +1,45 @@ +package com.carecode.domain.admin.controller; + +import com.carecode.core.devtools.SampleDataCleaner; +import com.carecode.core.devtools.SampleFacilitySeeder; +import com.carecode.core.devtools.SamplePolicySeeder; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** 샘플 데이터 관리. prod 프로파일에서는 빈 자체가 등록되지 않는다. */ +@RestController +@RequestMapping("/api/admin/dev/sample-data") +@Profile("!prod") +@RequiredArgsConstructor +@Tag(name = "어드민 - 개발용 샘플 데이터", description = "공공데이터 연동 전 기능 확인용") +public class AdminSampleDataController { + + private final SamplePolicySeeder policySeeder; + private final SampleFacilitySeeder facilitySeeder; + private final SampleDataCleaner cleaner; + + @PostMapping + @Operation(summary = "샘플 데이터 적재", description = "지역별 정책과 정원 관측 이력을 넣습니다") + public ResponseEntity> seed() { + Map result = new LinkedHashMap<>(); + result.put("policies", policySeeder.seed()); + result.put("facilities", facilitySeeder.seed()); + return ResponseEntity.ok(result); + } + + @DeleteMapping + @Operation(summary = "샘플 데이터 제거", description = "접두어로 식별해 실데이터는 남깁니다") + public ResponseEntity> clean() { + return ResponseEntity.ok(cleaner.clean()); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 8cdb176d..3f28a9db 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -12,6 +12,11 @@ spring: generate_statistics: true app: + # 공공데이터 연동 전 거주지 비교·입소 예측·인기도를 확인하기 위한 샘플 데이터. + # 실제 지원 금액이 아니므로 검증이 끝나면 DELETE /api/admin/dev/sample-data 로 지운다. + dev: + seed-sample-data: ${SEED_SAMPLE_DATA:false} + monitoring: query-count: enabled: true diff --git a/src/test/java/com/carecode/integration/SampleDataScenarioTest.java b/src/test/java/com/carecode/integration/SampleDataScenarioTest.java new file mode 100644 index 00000000..84d94adf --- /dev/null +++ b/src/test/java/com/carecode/integration/SampleDataScenarioTest.java @@ -0,0 +1,212 @@ +package com.carecode.integration; + +import com.carecode.CareCodeApplication; +import com.carecode.core.devtools.SampleFacilitySeeder; +import com.carecode.core.devtools.SamplePolicySeeder; +import com.carecode.core.security.CurrentUserFacade; +import com.carecode.domain.careFacility.dto.response.AdmissionForecastResponse; +import com.carecode.domain.careFacility.dto.response.FacilityPopularityResponse; +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.carecode.domain.careFacility.service.AdmissionForecastService; +import com.carecode.domain.careFacility.service.FacilityPopularityService; +import com.carecode.domain.policy.dto.response.MissedBenefitSummaryResponse; +import com.carecode.domain.policy.dto.response.RegionalBenefitComparisonResponse; +import com.carecode.domain.policy.service.MissedBenefitService; +import com.carecode.domain.policy.service.RegionalBenefitComparisonService; +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.entity.UserRole; +import com.carecode.domain.user.repository.ChildRepository; +import com.carecode.domain.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.mail.javamail.JavaMailSender; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +/** + * 샘플 데이터를 넣고 신규 기능 3종이 실제로 값을 내는지 확인한다. + * 공공데이터 연동 전에도 기능 전체가 살아 있는지 검증하기 위한 것이다. + */ +@SpringBootTest( + classes = CareCodeApplication.class, + properties = { + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," + + "org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration," + + "org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration", + "spring.cache.type=none", + "spring.batch.job.enabled=false", + "spring.datasource.url=jdbc:h2:mem:sample;MODE=MySQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.flyway.enabled=false", + "app.search.fulltext-enabled=false", + "jwt.secret=testJwtSecretKeyForSampleDataScenarioMustBe256BitsLong0123456789", + "springdoc.api-docs.enabled=false", + "springdoc.swagger-ui.enabled=false", + "public.data.api.key=dummy", + "KAKAO_CLIENT_ID=dummy-kakao-client", + "KAKAO_CLIENT_SECRET=dummy-kakao-secret", + "MAIL_USERNAME=dummy", + "MAIL_PASSWORD=dummy" + } +) +@DisplayName("샘플 데이터 기반 신규 기능 시나리오") +class SampleDataScenarioTest { + + @MockBean + private RedisConnectionFactory redisConnectionFactory; + @MockBean + private StringRedisTemplate stringRedisTemplate; + @MockBean + private JavaMailSender javaMailSender; + + /** 인증 컨텍스트 없이 서비스 계층만 검증한다. */ + @MockBean + private CurrentUserFacade currentUserFacade; + + @Autowired + private SamplePolicySeeder policySeeder; + @Autowired + private SampleFacilitySeeder facilitySeeder; + @Autowired + private RegionalBenefitComparisonService regionalBenefitComparisonService; + @Autowired + private MissedBenefitService missedBenefitService; + @Autowired + private AdmissionForecastService admissionForecastService; + @Autowired + private FacilityPopularityService facilityPopularityService; + @Autowired + private UserRepository userRepository; + @Autowired + private ChildRepository childRepository; + @Autowired + private CareFacilityRepository facilityRepository; + + @BeforeEach + void setUp() { + policySeeder.seed(); + facilitySeeder.seed(); + + User user = userRepository.findByEmail("sample@carecode.test").orElseGet(() -> + userRepository.save(User.builder() + .userId("sample-user") + .email("sample@carecode.test") + .name("샘플부모") + .address("경기도 성남시 분당구") + .role(UserRole.PARENT) + .isActive(true) + .emailVerified(true) + .registrationCompleted(true) + .build())); + + if (childRepository.findByUserIdOrderByCreatedAtDesc(user.getId()).isEmpty()) { + childRepository.save(Child.builder() + .user(user) + .name("샘플아이") + .birthDate(LocalDate.now().minusMonths(30)) + .build()); + } + when(currentUserFacade.requireCurrentUser()).thenReturn(user); + } + + @Test + @DisplayName("거주지별 지원금 비교가 지역 간 차액을 계산한다") + void comparesRegionalBenefits() { + RegionalBenefitComparisonResponse result = + regionalBenefitComparisonService.compare(null, 5, 10); + + assertThat(result.getRankings()).isNotEmpty(); + assertThat(result.getBaseRegion()).isEqualTo("성남시"); + assertThat(result.getDataQuality()).isEqualTo("ESTIMATED"); + + // 인구감소지역 지원금이 수도권보다 커야 비교가 의미를 갖는다 + var top = result.getRankings().get(0); + assertThat(top.getDifferenceFromBase()).isPositive(); + assertThat(top.getTopContributors()).isNotEmpty(); + } + + @Test + @DisplayName("놓친 지원금이 소급 가능/만료로 분류된다") + void findsMissedBenefits() { + // 30개월 아이 → 0~11개월, 0~23개월 대상 정책 구간을 이미 지났다 + MissedBenefitSummaryResponse result = missedBenefitService.findMissedBenefits(); + + assertThat(result.getClaimableCount() + result.getExpiredCount()).isPositive(); + } + + @Test + @DisplayName("늘 만원인 시설은 입소 확률이 낮고 인기 시설로 분류된다") + void alwaysFullFacilityIsInDemand() { + CareFacility facility = findSample("ALWAYS_FULL"); + + FacilityPopularityResponse popularity = facilityPopularityService.analyze(facility.getId()); + assertThat(popularity.isAvailable()).isTrue(); + assertThat(popularity.getDemandLevel()).isEqualTo("IN_DEMAND"); + + AdmissionForecastResponse forecast = + admissionForecastService.forecast(facility.getId(), 30, 1); + assertThat(forecast.isAvailable()).isTrue(); + assertThat(forecast.getProbability()).isNotNull(); + } + + @Test + @DisplayName("정원 미달 시설은 여유 시설로 분류되고 입소 확률이 높다") + void undersubscribedFacilityIsEasyToEnter() { + CareFacility facility = findSample("UNDERSUBSCRIBED"); + + assertThat(facilityPopularityService.analyze(facility.getId()).getDemandLevel()) + .isEqualTo("UNDERSUBSCRIBED"); + assertThat(admissionForecastService.forecast(facility.getId(), 30, 1).getProbability()) + .isGreaterThan(50); + } + + @Test + @DisplayName("충원율이 내려가는 시설은 하락 추세로 잡힌다") + void decliningFacilityShowsFallingTrend() { + assertThat(facilityPopularityService.analyze(findSample("DECLINING").getId()).getTrend()) + .isEqualTo("FALLING"); + } + + @Test + @DisplayName("급락 시설은 변동 시점을 기록한다") + void sharpDropIsRecorded() { + assertThat(facilityPopularityService.analyze(findSample("SHARP_DROP").getId()) + .getSharpDropDates()).isNotEmpty(); + } + + @Test + @DisplayName("샘플 데이터를 두 번 넣어도 중복되지 않는다") + void seedingIsIdempotent() { + long before = facilityRepository.count(); + + policySeeder.seed(); + facilitySeeder.seed(); + + assertThat(facilityRepository.count()).isEqualTo(before); + } + + private CareFacility findSample(String pattern) { + List all = facilityRepository.findAll(); + return all.stream() + .filter(f -> f.getFacilityCode() != null && f.getFacilityCode().endsWith(pattern)) + .findFirst() + .orElseThrow(() -> new AssertionError("샘플 시설을 찾을 수 없습니다: " + pattern)); + } +} From 7ebeba2d926722b0beff2daa8f16921c26c0a902 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Tue, 4 Aug 2026 18:45:57 +0900 Subject: [PATCH 10/68] =?UTF-8?q?FEAT=20:=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=8B=9C=20=ED=86=A0=ED=81=B0=20HttpOnly=20=EC=BF=A0=ED=82=A4?= =?UTF-8?q?=20=EB=B0=9C=EA=B8=89=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/RefreshTokenCookieFactory.java | 75 +++++++++++++++++++ .../user/controller/AuthController.java | 73 +++++++++++++----- .../user/controller/KakaoAuthController.java | 14 +++- src/main/resources/application.yml | 8 ++ 4 files changed, 149 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/carecode/core/security/RefreshTokenCookieFactory.java diff --git a/src/main/java/com/carecode/core/security/RefreshTokenCookieFactory.java b/src/main/java/com/carecode/core/security/RefreshTokenCookieFactory.java new file mode 100644 index 00000000..87bebe41 --- /dev/null +++ b/src/main/java/com/carecode/core/security/RefreshTokenCookieFactory.java @@ -0,0 +1,75 @@ +package com.carecode.core.security; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Arrays; +import java.util.Optional; + +/** + * 리프레시 토큰을 HttpOnly 쿠키로 주고받기 위한 헬퍼. + * + *

리프레시 토큰을 응답 본문으로만 내리면 클라이언트가 JS 로 접근 가능한 저장소 + * (localStorage 등)에 둘 수밖에 없어 XSS 한 번에 세션 전체가 탈취된다. + * HttpOnly 쿠키로 내려 스크립트가 읽지 못하게 하고, 경로를 {@code /auth} 로 좁혀 + * 일반 API 요청에는 실려 나가지 않도록 한다. + * + *

본문 응답도 당분간 유지한다. 쿠키를 쓸 수 없는 클라이언트(모바일 네이티브 등)와 + * 기존 웹 클라이언트가 함께 동작해야 하기 때문이다. + */ +@Component +public class RefreshTokenCookieFactory { + + public static final String COOKIE_NAME = "refreshToken"; + + /** 쿠키가 실려 나갈 경로. 갱신·로그아웃 외의 요청에는 붙지 않는다. */ + private static final String COOKIE_PATH = "/auth"; + + private final boolean secure; + private final String sameSite; + private final Duration maxAge; + + public RefreshTokenCookieFactory( + @Value("${app.auth.refresh-cookie.secure:true}") boolean secure, + @Value("${app.auth.refresh-cookie.same-site:None}") String sameSite, + @Value("${app.auth.refresh-cookie.max-age-days:14}") long maxAgeDays) { + this.secure = secure; + this.sameSite = sameSite; + this.maxAge = Duration.ofDays(maxAgeDays); + } + + /** 로그인·갱신 성공 시 내려보낼 쿠키. */ + public ResponseCookie create(String refreshToken) { + return baseBuilder(refreshToken).maxAge(maxAge).build(); + } + + /** 로그아웃 시 즉시 만료시킬 쿠키. */ + public ResponseCookie expire() { + return baseBuilder("").maxAge(0).build(); + } + + private ResponseCookie.ResponseCookieBuilder baseBuilder(String value) { + return ResponseCookie.from(COOKIE_NAME, value) + .httpOnly(true) + .secure(secure) + .sameSite(sameSite) + .path(COOKIE_PATH); + } + + /** 요청에 실려 온 리프레시 토큰을 꺼낸다. */ + public Optional read(HttpServletRequest request) { + if (request == null || request.getCookies() == null) { + return Optional.empty(); + } + + return Arrays.stream(request.getCookies()) + .filter(cookie -> COOKIE_NAME.equals(cookie.getName())) + .map(Cookie::getValue) + .filter(value -> value != null && !value.isBlank()) + .findFirst(); + } +} diff --git a/src/main/java/com/carecode/domain/user/controller/AuthController.java b/src/main/java/com/carecode/domain/user/controller/AuthController.java index 756879f8..7c6d746d 100644 --- a/src/main/java/com/carecode/domain/user/controller/AuthController.java +++ b/src/main/java/com/carecode/domain/user/controller/AuthController.java @@ -4,6 +4,7 @@ import com.carecode.core.annotation.RateLimit; import com.carecode.core.controller.BaseController; import com.carecode.core.security.CurrentUserFacade; +import com.carecode.core.security.RefreshTokenCookieFactory; import com.carecode.core.exception.UserNotFoundException; import com.carecode.domain.user.dto.request.LoginRequestDto; import com.carecode.domain.user.dto.request.RefreshTokenRequest; @@ -18,9 +19,11 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; @@ -46,6 +49,7 @@ public class AuthController extends BaseController { private final PasswordEncoder passwordEncoder; private final RefreshTokenStore refreshTokenStore; private final CurrentUserFacade currentUserFacade; + private final RefreshTokenCookieFactory refreshTokenCookieFactory; // ==================== 일반 로그인 ==================== @@ -77,7 +81,7 @@ public ResponseEntity login(@Parameter(description = "로그인 정보 userEntity.setUpdatedAt(LocalDateTime.now()); userService.saveUser(userEntity); - return ResponseEntity.ok(authService.issueTokenForUser(userEntity, "로그인 성공!")); + return withRefreshCookie(authService.issueTokenForUser(userEntity, "로그인 성공!")); } // 회원가입 @@ -89,7 +93,21 @@ public ResponseEntity register(@Parameter(description = "회원가입 User user = userService.getUserEntityByEmail(createdUser.getEmail()); TokenDto tokenDto = authService.issueTokenForUser(user, "회원가입 성공!"); tokenDto.setUser(createdUser); - return ResponseEntity.ok(tokenDto); + return withRefreshCookie(tokenDto); + } + + /** + * 발급된 리프레시 토큰을 HttpOnly 쿠키로도 내려보낸다. + * 본문에도 남겨 두어 쿠키를 쓰지 않는 클라이언트가 계속 동작하게 한다. + */ + private ResponseEntity withRefreshCookie(TokenDto tokenDto) { + if (tokenDto.getRefreshToken() == null) { + return ResponseEntity.ok(tokenDto); + } + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, refreshTokenCookieFactory.create(tokenDto.getRefreshToken()).toString()) + .body(tokenDto); } // ==================== 토큰 관리 ==================== @@ -98,25 +116,28 @@ public ResponseEntity register(@Parameter(description = "회원가입 @PostMapping("/refresh") @LogExecutionTime - @Operation(summary = "토큰 갱신", description = "Refresh Token을 사용하여 새로운 Access Token을 발급합니다.") - public ResponseEntity refreshToken(@Parameter(description = "토큰 갱신 정보", required = true) @Valid @RequestBody RefreshTokenRequest request) { - - if (!jwtService.validateToken(request.getRefreshToken())) { - return ResponseEntity.status(401).body(TokenDto.builder() - .success(false) - .message("유효하지 않은 Refresh Token입니다.") - .build()); + @Operation(summary = "토큰 갱신", + description = "Refresh Token 으로 새로운 Access Token 을 발급합니다. " + + "토큰은 HttpOnly 쿠키에서 우선 읽고, 없으면 요청 본문에서 읽습니다.") + public ResponseEntity refreshToken( + @Parameter(description = "토큰 갱신 정보 (쿠키를 쓰는 경우 생략 가능)") + @RequestBody(required = false) RefreshTokenRequest request, + HttpServletRequest httpRequest) { + + // 쿠키를 우선한다. 본문은 쿠키를 쓸 수 없는 클라이언트를 위한 대체 경로다. + String refreshToken = refreshTokenCookieFactory.read(httpRequest) + .orElseGet(() -> request != null ? request.getRefreshToken() : null); + + if (refreshToken == null || refreshToken.isBlank() || !jwtService.validateToken(refreshToken)) { + return unauthorizedRefresh("유효하지 않은 Refresh Token입니다."); } // Refresh Token에서 사용자 정보 추출 - String userId = jwtService.getUserIdFromToken(request.getRefreshToken()); - String email = jwtService.getEmailFromToken(request.getRefreshToken()); + String userId = jwtService.getUserIdFromToken(refreshToken); + String email = jwtService.getEmailFromToken(refreshToken); - if (!refreshTokenStore.isRegistered(request.getRefreshToken(), userId)) { - return ResponseEntity.status(401).body(TokenDto.builder() - .success(false) - .message("세션이 만료되었거나 로그아웃된 Refresh Token입니다.") - .build()); + if (!refreshTokenStore.isRegistered(refreshToken, userId)) { + return unauthorizedRefresh("세션이 만료되었거나 로그아웃된 Refresh Token입니다."); } // 사용자 존재 확인 @@ -128,7 +149,7 @@ public ResponseEntity refreshToken(@Parameter(description = "토큰 // 새로운 Refresh Token 생성 (토큰 로테이션) String newRefreshToken = jwtService.generateRefreshToken(userId, email); - refreshTokenStore.remove(request.getRefreshToken(), userId); + refreshTokenStore.remove(refreshToken, userId); refreshTokenStore.register(userId, newRefreshToken); TokenDto tokenDto = TokenDto.builder() @@ -142,7 +163,17 @@ public ResponseEntity refreshToken(@Parameter(description = "토큰 .user(user) .build(); - return ResponseEntity.ok(tokenDto); + return withRefreshCookie(tokenDto); + } + + /** 갱신 실패 시에는 남아 있는 쿠키도 함께 지워 재시도 루프를 끊는다. */ + private ResponseEntity unauthorizedRefresh(String message) { + return ResponseEntity.status(401) + .header(HttpHeaders.SET_COOKIE, refreshTokenCookieFactory.expire().toString()) + .body(TokenDto.builder() + .success(false) + .message(message) + .build()); } @PostMapping("/logout") @@ -150,7 +181,9 @@ public ResponseEntity refreshToken(@Parameter(description = "토큰 @Operation(summary = "로그아웃", description = "서버에 등록된 리프레시 토큰 세션을 모두 폐기합니다. (액세스 토큰은 만료 시까지 유효할 수 있음)") public ResponseEntity logout() { refreshTokenStore.removeAllForUser(currentUserFacade.requireCurrentUserId()); - return ResponseEntity.ok(ApiSuccess.of("로그아웃되었습니다.")); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, refreshTokenCookieFactory.expire().toString()) + .body(ApiSuccess.of("로그아웃되었습니다.")); } // ==================== 이메일 인증 ==================== diff --git a/src/main/java/com/carecode/domain/user/controller/KakaoAuthController.java b/src/main/java/com/carecode/domain/user/controller/KakaoAuthController.java index 3e667422..f64f5cc9 100644 --- a/src/main/java/com/carecode/domain/user/controller/KakaoAuthController.java +++ b/src/main/java/com/carecode/domain/user/controller/KakaoAuthController.java @@ -4,6 +4,7 @@ import com.carecode.core.controller.BaseController; import com.carecode.core.exception.UserNotFoundException; import com.carecode.core.security.CurrentUserFacade; +import com.carecode.core.security.RefreshTokenCookieFactory; import com.carecode.core.util.KakaoUtil; import com.carecode.domain.user.dto.request.KakaoRegistrationRequest; import com.carecode.domain.user.dto.response.TokenDto; @@ -16,6 +17,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -36,6 +38,7 @@ public class KakaoAuthController extends BaseController { private final UserService userService; private final KakaoUtil kakaoUtil; private final CurrentUserFacade currentUserFacade; + private final RefreshTokenCookieFactory refreshTokenCookieFactory; @PostMapping("/login") @LogExecutionTime @@ -44,7 +47,16 @@ public ResponseEntity kakaoLogin( @Parameter(description = "카카오 인증 코드", required = true) @RequestParam String code) { log.info("카카오 OAuth 로그인 요청 수신 (authorization code는 로그에 기록하지 않음)"); TokenDto body = authService.oAuthLoginOrRegister(code); - return ResponseEntity.ok(body); + + if (body.getRefreshToken() == null) { + return ResponseEntity.ok(body); + } + + // 리프레시 토큰은 HttpOnly 쿠키로도 내려 JS 저장소에 남기지 않게 한다. + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, + refreshTokenCookieFactory.create(body.getRefreshToken()).toString()) + .body(body); } @PostMapping("/complete-registration") diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f548d6ef..a1c74dee 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -107,6 +107,14 @@ app: security: cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://127.0.0.1:3000} + auth: + refresh-cookie: + # 리프레시 토큰을 HttpOnly 쿠키로 내려 XSS 로 세션이 통째로 털리는 것을 막는다. + # 프런트가 다른 오리진에 있으므로 기본값은 SameSite=None + Secure 다. + # (브라우저는 localhost 를 보안 컨텍스트로 취급하므로 로컬 개발에서도 그대로 동작한다) + secure: ${REFRESH_COOKIE_SECURE:true} + same-site: ${REFRESH_COOKIE_SAME_SITE:None} + max-age-days: ${REFRESH_COOKIE_MAX_AGE_DAYS:14} rate-limit: # 신뢰할 수 있는 프록시 뒤에 있을 때만 X-Forwarded-For 를 사용한다. # 프록시가 없는데 true 로 두면 헤더 위조로 rate limit 을 우회할 수 있다. From b7807f84b5769e5049a552f4acde68862d7e82b9 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Tue, 4 Aug 2026 18:45:57 +0900 Subject: [PATCH 11/68] =?UTF-8?q?FEAT=20:=20=EA=B1=B4=EA=B0=95=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=B8=A1=EC=A0=95=EA=B0=92=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=EB=B3=B4=EC=99=84=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HealthCreateHealthRecordRequest.java | 38 ++++++++++++++++--- .../HealthUpdateHealthRecordRequest.java | 33 ++++++++++++++-- .../health/mapper/HealthRecordMapper.java | 8 ++++ .../domain/health/service/HealthService.java | 9 ++++- 4 files changed, 79 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/carecode/domain/health/dto/request/HealthCreateHealthRecordRequest.java b/src/main/java/com/carecode/domain/health/dto/request/HealthCreateHealthRecordRequest.java index f5b7eb22..734be141 100644 --- a/src/main/java/com/carecode/domain/health/dto/request/HealthCreateHealthRecordRequest.java +++ b/src/main/java/com/carecode/domain/health/dto/request/HealthCreateHealthRecordRequest.java @@ -1,5 +1,9 @@ package com.carecode.domain.health.dto.request; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; @@ -21,20 +25,44 @@ public class HealthCreateHealthRecordRequest { @NotBlank(message = "아동 ID는 필수입니다") private String childId; - + @NotBlank(message = "기록 타입은 필수입니다") private String recordType; - + @NotBlank(message = "제목은 필수입니다") private String title; - + private String description; - + @NotNull(message = "기록 날짜는 필수입니다") private LocalDateTime recordDate; - + private LocalDateTime nextDate; private String location; private String doctorName; + private String hospitalName; + + // ==================== 측정값 ==================== + // 성장 곡선(GrowthChartService)이 이 값을 읽으므로 생성 시점에 받을 수 있어야 한다. + + @DecimalMin(value = "0.0", inclusive = false, message = "키는 0보다 커야 합니다") + @DecimalMax(value = "250.0", message = "키는 250cm를 넘을 수 없습니다") + private Double height; // cm + + @DecimalMin(value = "0.0", inclusive = false, message = "몸무게는 0보다 커야 합니다") + @DecimalMax(value = "200.0", message = "몸무게는 200kg를 넘을 수 없습니다") + private Double weight; // kg + + @DecimalMin(value = "30.0", message = "체온 값을 확인해주세요") + @DecimalMax(value = "45.0", message = "체온 값을 확인해주세요") + private Double temperature; // °C + + private String bloodPressure; // 예: 120/80 + + @Min(value = 0, message = "맥박은 0 이상이어야 합니다") + @Max(value = 300, message = "맥박 값을 확인해주세요") + private Integer pulseRate; // 회/분 + + private String vaccineName; } diff --git a/src/main/java/com/carecode/domain/health/dto/request/HealthUpdateHealthRecordRequest.java b/src/main/java/com/carecode/domain/health/dto/request/HealthUpdateHealthRecordRequest.java index b1e8dcbe..dadc6551 100644 --- a/src/main/java/com/carecode/domain/health/dto/request/HealthUpdateHealthRecordRequest.java +++ b/src/main/java/com/carecode/domain/health/dto/request/HealthUpdateHealthRecordRequest.java @@ -1,5 +1,9 @@ package com.carecode.domain.health.dto.request; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; @@ -21,15 +25,38 @@ public class HealthUpdateHealthRecordRequest { @NotBlank(message = "제목은 필수입니다") private String title; - + private String description; - + @NotNull(message = "기록 날짜는 필수입니다") private LocalDateTime recordDate; - + private LocalDateTime nextDate; private String location; private String doctorName; + private String hospitalName; private Boolean isCompleted; + + // ==================== 측정값 ==================== + + @DecimalMin(value = "0.0", inclusive = false, message = "키는 0보다 커야 합니다") + @DecimalMax(value = "250.0", message = "키는 250cm를 넘을 수 없습니다") + private Double height; // cm + + @DecimalMin(value = "0.0", inclusive = false, message = "몸무게는 0보다 커야 합니다") + @DecimalMax(value = "200.0", message = "몸무게는 200kg를 넘을 수 없습니다") + private Double weight; // kg + + @DecimalMin(value = "30.0", message = "체온 값을 확인해주세요") + @DecimalMax(value = "45.0", message = "체온 값을 확인해주세요") + private Double temperature; // °C + + private String bloodPressure; + + @Min(value = 0, message = "맥박은 0 이상이어야 합니다") + @Max(value = 300, message = "맥박 값을 확인해주세요") + private Integer pulseRate; + + private String vaccineName; } diff --git a/src/main/java/com/carecode/domain/health/mapper/HealthRecordMapper.java b/src/main/java/com/carecode/domain/health/mapper/HealthRecordMapper.java index 12512ed6..8dda2237 100644 --- a/src/main/java/com/carecode/domain/health/mapper/HealthRecordMapper.java +++ b/src/main/java/com/carecode/domain/health/mapper/HealthRecordMapper.java @@ -23,6 +23,14 @@ public HealthRecord toEntity(HealthCreateHealthRecordRequest request) { .nextDate(request.getNextDate() != null ? request.getNextDate().toLocalDate() : null) .location(request.getLocation()) .doctorName(request.getDoctorName()) + .hospitalName(request.getHospitalName()) + // 측정값은 성장 곡선의 입력이므로 생성 시점에 그대로 반영한다. + .height(request.getHeight()) + .weight(request.getWeight()) + .temperature(request.getTemperature()) + .bloodPressure(request.getBloodPressure()) + .pulseRate(request.getPulseRate()) + .vaccineName(request.getVaccineName()) .isCompleted(false); return builder.build(); } diff --git a/src/main/java/com/carecode/domain/health/service/HealthService.java b/src/main/java/com/carecode/domain/health/service/HealthService.java index 5b85d198..44fb2b01 100644 --- a/src/main/java/com/carecode/domain/health/service/HealthService.java +++ b/src/main/java/com/carecode/domain/health/service/HealthService.java @@ -191,8 +191,15 @@ public HealthRecordResponse updateHealthRecord(Long recordId, HealthUpdateHealth record.setNextDate(request.getNextDate() != null ? request.getNextDate().toLocalDate() : null); record.setLocation(request.getLocation()); record.setDoctorName(request.getDoctorName()); + record.setHospitalName(request.getHospitalName()); + record.setHeight(request.getHeight()); + record.setWeight(request.getWeight()); + record.setTemperature(request.getTemperature()); + record.setBloodPressure(request.getBloodPressure()); + record.setPulseRate(request.getPulseRate()); + record.setVaccineName(request.getVaccineName()); record.setIsCompleted(request.getIsCompleted()); - + HealthRecord updatedRecord = healthRecordRepository.save(record); log.info("건강 기록 수정 완료: 기록ID={}", recordId); return healthRecordMapper.toResponse(updatedRecord); From 66bb783c614a0070f4eeef95d423c98b26bdca9b Mon Sep 17 00:00:00 2001 From: RosieOh Date: Wed, 5 Aug 2026 11:08:41 +0900 Subject: [PATCH 12/68] =?UTF-8?q?STYLE=20:=20=EC=A3=BC=EC=84=9D=EA=B3=BC?= =?UTF-8?q?=20Swagger=20description=20=ED=95=9C=20=EC=A4=84=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../carecode/core/RateLimitInterceptor.java | 24 +--- .../carecode/core/annotation/ApiVersion.java | 6 +- .../core/annotation/LogExecutionTime.java | 13 +- .../carecode/core/annotation/RateLimit.java | 12 +- .../core/annotation/RequireAdminRole.java | 5 +- .../annotation/RequireAuthentication.java | 5 +- .../core/annotation/ValidateChildAge.java | 5 +- .../core/annotation/ValidateLocation.java | 5 +- .../core/aspect/AuthenticationAspect.java | 7 +- .../carecode/core/aspect/LoggingAspect.java | 7 +- .../core/aspect/RateLimitingAspect.java | 9 +- .../core/benefit/BenefitPaymentType.java | 5 +- .../benefit/BenefitProjectionCalculator.java | 14 +- .../core/client/CareFacilityApiService.java | 34 +---- .../core/client/PublicDataApiClient.java | 41 +----- .../core/client/PublicDataApiService.java | 58 ++------ .../controller/PublicDataController.java | 29 +--- .../core/client/dto/PublicDataResponse.java | 25 +--- .../exception/PublicDataApiException.java | 5 +- .../client/provider/DataGoKrProvider.java | 3 +- .../sync/KindergartenUpsertService.java | 5 +- .../com/carecode/core/config/AsyncConfig.java | 6 +- .../com/carecode/core/config/CacheConfig.java | 14 +- .../carecode/core/config/FirebaseConfig.java | 8 +- .../com/carecode/core/config/RedisConfig.java | 9 +- .../core/config/RestTemplateConfig.java | 5 +- .../carecode/core/config/SwaggerConfig.java | 6 +- .../carecode/core/config/WebMvcConfig.java | 12 +- .../core/constants/CustomHttpStatus.java | 5 +- .../core/controller/BaseController.java | 5 +- .../controller/CareFacilityApiController.java | 36 +---- .../core/devtools/SampleDataRunner.java | 5 +- .../core/devtools/SampleFacilitySeeder.java | 5 +- .../core/devtools/SamplePolicySeeder.java | 5 +- .../core/exception/BusinessException.java | 5 +- .../core/exception/CareCodeException.java | 5 +- .../CareFacilityNotFoundException.java | 5 +- .../core/exception/CareServiceException.java | 5 +- .../exception/ChildNotFoundException.java | 4 +- .../CommentAccessDeniedException.java | 4 +- .../carecode/core/exception/ErrorCode.java | 5 +- .../HealthRecordNotFoundException.java | 4 +- .../exception/HospitalNotFoundException.java | 4 +- .../HospitalReviewAccessDeniedException.java | 4 +- .../HospitalReviewNotFoundException.java | 4 +- .../exception/PolicyNotFoundException.java | 5 +- .../exception/PostAccessDeniedException.java | 4 +- .../exception/RateLimitExceededException.java | 4 +- .../exception/ResourceNotFoundException.java | 5 +- .../core/exception/UserNotFoundException.java | 5 +- .../carecode/core/handler/ApiResponse.java | 13 +- .../com/carecode/core/handler/ApiSuccess.java | 7 +- ...tomizedResponseEntityExceptionHandler.java | 25 +--- .../carecode/core/handler/ErrorResponse.java | 4 +- .../scheduler/BookingReminderScheduler.java | 4 +- .../core/scheduler/DataCleanupScheduler.java | 9 +- .../VaccinationReminderScheduler.java | 7 +- .../core/security/CurrentUserFacade.java | 5 +- .../security/CustomUserDetailsService.java | 8 +- .../security/JwtAuthenticationFilter.java | 11 +- .../security/RefreshTokenCookieFactory.java | 12 +- .../core/security/SecurityConfig.java | 22 +-- .../core/storage/FileStorageService.java | 18 +-- .../core/storage/LocalFileStorageService.java | 7 +- .../com/carecode/core/storage/StoredFile.java | 4 +- .../com/carecode/core/util/ChildAgeUtil.java | 6 +- .../carecode/core/util/ClientIpResolver.java | 11 +- .../com/carecode/core/util/CommonUtil.java | 14 +- .../core/util/KakaoUserInfoExtractor.java | 5 +- .../com/carecode/core/util/KakaoUtil.java | 15 +- .../com/carecode/core/util/LocationUtil.java | 13 +- .../com/carecode/core/util/LoggingUtil.java | 21 +-- .../carecode/core/util/PageRequestUtil.java | 7 +- .../com/carecode/core/util/PolicyUtil.java | 16 +-- .../com/carecode/core/util/RequestMapper.java | 5 +- .../carecode/core/util/ResponseMapper.java | 5 +- .../java/com/carecode/core/util/SortUtil.java | 41 +----- .../carecode/core/util/ValidationUtil.java | 40 +----- .../docs/ApiDocumentationGenerator.java | 40 +----- .../AdminCareFacilityBookingController.java | 8 +- .../controller/AdminCommunityController.java | 8 +- .../controller/AdminDashboardController.java | 6 +- .../controller/AdminHealthController.java | 6 +- .../controller/AdminHospitalController.java | 4 +- .../AdminNotificationController.java | 6 +- .../controller/AdminPolicyController.java | 8 +- .../controller/AdminReportController.java | 6 +- .../admin/controller/AdminUserController.java | 10 +- .../admin/dto/AdminBookingDetailResponse.java | 4 +- .../admin/dto/AdminBookingListResponse.java | 4 +- .../admin/dto/AdminBookingSearchRequest.java | 4 +- .../admin/dto/AdminBookingSearchResponse.java | 4 +- .../admin/dto/AdminBookingStatsResponse.java | 4 +- .../dto/AdminNotificationCreateRequest.java | 5 +- .../domain/admin/dto/AdminPolicyRequest.java | 7 +- .../admin/dto/AdminStatusUpdateRequest.java | 4 +- .../domain/admin/dto/AdminUserResponse.java | 5 +- .../admin/dto/AdminUserUpdateRequest.java | 7 +- .../CareFacilityBookingAdminService.java | 4 +- .../admin/service/PolicyAdminService.java | 9 +- .../careFacility/app/CareFacilityFacade.java | 4 +- .../controller/CareFacilityController.java | 92 +++++------- ...CareFacilityAdminBookingSearchRequest.java | 4 +- .../CareFacilityAdminStatusUpdateRequest.java | 4 +- .../CareFacilityAdvancedSearchRequest.java | 5 +- .../CareFacilityListBookingsRequest.java | 4 +- .../request/CareFacilitySearchRequest.java | 4 +- .../dto/request/CreateBookingRequest.java | 4 +- .../dto/request/UpdateBookingRequest.java | 4 +- .../dto/response/BookingListResponse.java | 4 +- .../dto/response/BookingResponse.java | 4 +- .../dto/response/BookingStats.java | 4 +- .../response/CareFacilityBookingResponse.java | 4 +- .../dto/response/CareFacilityInfo.java | 4 +- .../response/CareFacilityListResponse.java | 4 +- .../response/CareFacilityStatsResponse.java | 4 +- .../dto/response/DailyBookingCount.java | 4 +- .../dto/response/FacilityDistribution.java | 4 +- .../dto/response/StatusDistribution.java | 4 +- .../dto/response/TypeDistribution.java | 4 +- .../careFacility/dto/response/TypeStats.java | 4 +- .../careFacility/entity/CareFacility.java | 5 +- .../entity/CareFacilityBooking.java | 14 +- .../careFacility/entity/FacilityType.java | 6 +- .../domain/careFacility/entity/Review.java | 95 ++---------- .../careFacility/mapper/BookingMapper.java | 8 +- .../mapper/CareFacilityMapper.java | 1 - .../CareFacilityBookingRepository.java | 23 +-- .../repository/CareFacilityRepository.java | 15 +- .../service/AdmissionForecastService.java | 10 +- .../service/CareFacilityBookingService.java | 32 +---- .../CareFacilityDataMigrationService.java | 13 +- .../service/CareFacilityService.java | 63 +------- .../service/FacilityPopularityService.java | 6 +- .../domain/chatbot/app/ChatbotFacade.java | 1 - .../chatbot/controller/ChatbotController.java | 52 ++----- .../request/ChatbotAskQuestionRequest.java | 4 +- .../ChatbotContextualQuestionRequest.java | 4 +- .../dto/request/ChatbotFeedbackRequest.java | 4 +- .../dto/request/ChatbotMessageRequest.java | 4 +- .../request/ChatbotStartSessionRequest.java | 4 +- .../chatbot/dto/response/ChatMessageDto.java | 4 +- .../ChatbotChatHistoryDtoResponse.java | 4 +- .../response/ChatbotChatMessageResponse.java | 4 +- .../response/ChatbotFeedbackDtoResponse.java | 4 +- .../dto/response/ChatbotFeedbackResponse.java | 4 +- .../dto/response/ChatbotInfoResponse.java | 4 +- .../ChatbotKnowledgeBaseResponse.java | 4 +- .../dto/response/ChatbotMessageResponse.java | 4 +- .../response/ChatbotRelatedInfoResponse.java | 4 +- .../response/ChatbotSessionDtoResponse.java | 4 +- .../dto/response/ChatbotSessionResponse.java | 4 +- .../dto/response/ChatbotStatsResponse.java | 4 +- .../domain/chatbot/entity/ChatMessage.java | 5 +- .../domain/chatbot/entity/ChatSession.java | 7 +- .../chatbot/llm/ChatCompletionClient.java | 13 +- .../llm/ClaudeChatCompletionClient.java | 12 +- .../chatbot/rag/CareKnowledgeRetriever.java | 5 +- .../domain/chatbot/rag/RetrievedContext.java | 4 +- .../repository/ChatMessageRepository.java | 4 +- .../repository/ChatSessionRepository.java | 4 +- .../chatbot/service/ChatbotService.java | 76 +--------- .../domain/community/app/CommunityFacade.java | 1 - .../controller/CommunityController.java | 84 +++-------- .../controller/ModerationController.java | 8 +- .../CommunityCreateCommentRequest.java | 4 +- .../request/CommunityCreatePostRequest.java | 4 +- .../request/CommunityListPostsRequest.java | 4 +- .../request/CommunitySearchPostsRequest.java | 4 +- .../CommunityUpdateCommentRequest.java | 4 +- .../request/CommunityUpdatePostRequest.java | 4 +- .../dto/request/ReportCreateRequest.java | 4 +- .../CommunityCommentListResponse.java | 4 +- .../response/CommunityCommentResponse.java | 4 +- .../dto/response/CommunityPageResponse.java | 4 +- .../response/CommunityPostDetailResponse.java | 4 +- .../response/CommunityPostListResponse.java | 4 +- .../dto/response/CommunityPostResponse.java | 4 +- .../response/CommunityPostSearchResponse.java | 4 +- .../CommunityRelatedPostResponse.java | 4 +- .../dto/response/CommunityStatsResponse.java | 4 +- .../response/CommunityTagListResponse.java | 4 +- .../dto/response/CommunityTagResponse.java | 4 +- .../dto/response/ReportResponse.java | 6 +- .../domain/community/entity/Bookmark.java | 4 +- .../domain/community/entity/Comment.java | 23 +-- .../domain/community/entity/Post.java | 22 +-- .../domain/community/entity/PostCategory.java | 4 +- .../domain/community/entity/PostLike.java | 4 +- .../domain/community/entity/PostStatus.java | 4 +- .../domain/community/entity/Report.java | 6 +- .../domain/community/entity/UserBlock.java | 6 +- .../community/mapper/CommunityMapper.java | 18 +-- .../repository/BookmarkRepository.java | 18 +-- .../repository/CommentRepository.java | 8 +- .../repository/PostLikeRepository.java | 18 +-- .../community/repository/PostRepository.java | 17 +-- .../CommunityInitializationService.java | 13 +- .../community/service/CommunityService.java | 61 +------- .../community/service/ModerationService.java | 16 +-- .../health/controller/ChildController.java | 18 ++- .../HealthRecordAttachmentController.java | 11 +- .../dto/request/ChildCreateRequest.java | 4 +- .../dto/request/HealthAlertsRequest.java | 4 +- .../request/HealthCheckupScheduleRequest.java | 4 +- .../dto/request/HealthCreateChildRequest.java | 4 +- .../HealthCreateHealthRecordRequest.java | 7 +- .../HealthCreateHospitalReviewRequest.java | 4 +- .../request/HealthLikeHospitalRequest.java | 4 +- .../dto/request/HealthStatsRequest.java | 4 +- .../HealthUpdateHealthRecordRequest.java | 8 +- .../HealthUpdateHospitalReviewRequest.java | 4 +- .../request/HealthVaccineScheduleRequest.java | 4 +- .../dto/response/AttachmentResponse.java | 4 +- .../dto/response/CheckupScheduleResponse.java | 4 +- .../dto/response/ChildInfoResponse.java | 4 +- .../dto/response/GrowthDataResponse.java | 4 +- .../dto/response/GrowthPointResponse.java | 6 +- .../dto/response/GrowthTrendResponse.java | 4 +- .../dto/response/HealthAlertResponse.java | 4 +- .../dto/response/HealthRecordResponse.java | 4 +- .../dto/response/HealthStatsResponse.java | 4 +- .../dto/response/HospitalDetailResponse.java | 4 +- .../dto/response/HospitalInfoResponse.java | 4 +- .../dto/response/HospitalListResponse.java | 4 +- .../dto/response/HospitalNearbyResponse.java | 4 +- .../dto/response/HospitalReviewResponse.java | 4 +- .../dto/response/HospitalSearchResponse.java | 4 +- .../response/VaccinationScheduleResponse.java | 4 +- .../dto/response/VaccineScheduleResponse.java | 4 +- .../domain/health/entity/HealthRecord.java | 14 +- .../health/entity/HealthRecordAttachment.java | 86 ++--------- .../health/entity/HealthRecordType.java | 34 +---- .../health/entity/VaccinationSchedule.java | 7 +- .../domain/health/entity/VaccineType.java | 14 +- .../domain/health/growth/GrowthMetric.java | 4 +- .../growth/GrowthPercentileCalculator.java | 15 +- .../health/growth/GrowthPercentileResult.java | 16 +-- .../domain/health/growth/GrowthStandard.java | 12 +- .../health/growth/GrowthStandardTable.java | 16 +-- .../carecode/domain/health/growth/Sex.java | 6 +- .../domain/health/mapper/ChildMapper.java | 1 - .../health/mapper/HealthRecordMapper.java | 5 +- .../domain/health/mapper/HospitalMapper.java | 1 - .../health/mapper/HospitalReviewMapper.java | 1 - .../repository/HealthRecordRepository.java | 4 +- .../VaccinationScheduleRepository.java | 6 +- .../domain/health/service/ChildService.java | 11 +- .../health/service/GrowthChartService.java | 7 +- .../HealthRecordAttachmentService.java | 11 +- .../domain/health/service/HealthService.java | 95 +----------- .../service/VaccinationScheduleService.java | 11 +- .../notification/app/NotificationFacade.java | 11 -- .../controller/NotificationController.java | 124 +++++----------- .../request/NotificationCreateRequest.java | 4 +- .../NotificationMarkAsReadRequest.java | 4 +- .../NotificationRegisterPushTokenRequest.java | 4 +- .../request/NotificationSendTestRequest.java | 4 +- .../NotificationUpdateSettingsRequest.java | 4 +- .../NotificationDeliveryStatusResponse.java | 4 +- .../response/NotificationDetailResponse.java | 4 +- .../NotificationExtendedStatsResponse.java | 4 +- .../NotificationExtendedTemplateResponse.java | 4 +- .../response/NotificationInfoResponse.java | 4 +- .../response/NotificationListResponse.java | 4 +- .../response/NotificationSearchResponse.java | 4 +- .../NotificationSettingsResponse.java | 4 +- .../response/NotificationStatsResponse.java | 4 +- .../response/NotificationSummaryResponse.java | 4 +- .../NotificationTemplateResponse.java | 4 +- .../NotificationUnreadCountResponse.java | 4 +- .../notification/entity/Notification.java | 11 +- .../entity/NotificationChannel.java | 48 +------ .../entity/NotificationPreference.java | 9 +- .../entity/NotificationTemplate.java | 136 +++--------------- .../factory/NotificationStrategyFactory.java | 11 +- .../NotificationPreferenceRepository.java | 22 +-- .../repository/NotificationRepository.java | 26 +--- .../sender/EmailNotificationSender.java | 4 +- .../sender/NotificationChannelType.java | 4 +- .../sender/NotificationDispatcher.java | 20 +-- .../sender/NotificationPayload.java | 4 +- .../sender/NotificationSender.java | 19 +-- .../sender/PushNotificationSender.java | 6 +- .../sender/SmsNotificationSender.java | 8 +- .../service/NotificationCreationService.java | 13 +- .../NotificationInitializationService.java | 13 +- .../NotificationPreferenceService.java | 33 +---- .../service/NotificationService.java | 51 +------ .../service/NotificationTemplateService.java | 29 +--- .../CommunityNotificationStrategy.java | 8 +- .../strategy/NotificationStrategy.java | 15 +- .../strategy/PolicyNotificationStrategy.java | 8 +- .../strategy/SystemNotificationStrategy.java | 8 +- .../policy/controller/PolicyController.java | 43 +++--- .../dto/request/PolicyBookmarkRequest.java | 4 +- .../dto/request/PolicyCategoryRequest.java | 4 +- .../dto/request/PolicyCreateRequest.java | 4 +- .../dto/request/PolicyDeleteRequest.java | 4 +- .../dto/request/PolicyDetailRequest.java | 4 +- .../policy/dto/request/PolicyListRequest.java | 4 +- .../dto/request/PolicySearchRequest.java | 4 +- .../dto/request/PolicyUpdateRequest.java | 4 +- .../response/PolicyBookmarkInfoResponse.java | 4 +- .../response/PolicyBookmarkListResponse.java | 4 +- .../dto/response/PolicyBookmarkResponse.java | 4 +- .../response/PolicyCategoryInfoResponse.java | 4 +- .../response/PolicyCategoryListResponse.java | 4 +- .../dto/response/PolicyCategoryResponse.java | 4 +- .../response/PolicyCategoryStatsResponse.java | 4 +- .../dto/response/PolicyDetailResponse.java | 4 +- .../domain/policy/dto/response/PolicyDto.java | 1 - .../dto/response/PolicyInfoResponse.java | 4 +- .../dto/response/PolicyListResponse.java | 4 +- .../PolicyRecommendationResponse.java | 4 +- .../dto/response/PolicySearchResponse.java | 4 +- .../dto/response/PolicyStatsResponse.java | 4 +- .../response/PolicyStatsSimpleResponse.java | 4 +- .../RegionalBenefitComparisonResponse.java | 5 +- .../carecode/domain/policy/entity/Policy.java | 10 +- .../domain/policy/entity/PolicyCategory.java | 9 +- .../domain/policy/mapper/PolicyMapper.java | 1 - .../policy/repository/PolicyRepository.java | 26 +--- .../policy/service/MissedBenefitService.java | 5 +- .../service/PolicyInitializationService.java | 23 +-- .../service/PolicyRecommendationService.java | 5 +- .../domain/policy/service/PolicyService.java | 20 +-- .../RegionalBenefitComparisonService.java | 5 +- .../carecode/domain/user/app/UserFacade.java | 1 - .../user/controller/AuthController.java | 39 +++-- .../user/controller/KakaoAuthController.java | 11 +- .../user/controller/PrivacyController.java | 12 +- .../user/controller/UserController.java | 97 ++++--------- .../dto/request/ConsentUpdateRequest.java | 4 +- .../dto/request/KakaoRegistrationRequest.java | 4 +- .../user/dto/request/LoginRequestDto.java | 4 +- .../dto/request/PasswordChangeRequestDto.java | 4 +- .../user/dto/request/RefreshTokenRequest.java | 4 +- .../dto/request/TokenValidationRequest.java | 4 +- .../request/UserChangePasswordRequest.java | 4 +- .../request/UserKakaoRegistrationRequest.java | 4 +- .../user/dto/request/UserLoginRequest.java | 4 +- .../dto/request/UserRefreshTokenRequest.java | 4 +- .../user/dto/request/UserRegisterRequest.java | 4 +- .../request/UserUpdateNicknameRequest.java | 4 +- .../dto/request/UserUpdateProfileRequest.java | 4 +- .../dto/request/UserUpdateRequestDto.java | 1 - .../dto/request/UserValidateTokenRequest.java | 4 +- .../dto/response/ConsentStatusResponse.java | 4 +- .../user/dto/response/KakaoAccount.java | 4 +- .../user/dto/response/KakaoOAuthToken.java | 4 +- .../user/dto/response/KakaoProfile.java | 4 +- .../user/dto/response/KakaoProfileInfo.java | 4 +- .../user/dto/response/KakaoProperties.java | 4 +- .../domain/user/dto/response/TokenDto.java | 4 +- .../dto/response/TokenValidationResponse.java | 4 +- .../dto/response/UserActivityResponse.java | 4 +- .../domain/user/dto/response/UserDto.java | 9 +- .../user/dto/response/UserInfoResponse.java | 4 +- .../user/dto/response/UserListResponse.java | 4 +- .../user/dto/response/UserLoginResponse.java | 4 +- .../dto/response/UserNearbyUsersResponse.java | 4 +- .../UserProfileCompletionResponse.java | 4 +- .../response/UserProfileMissingFields.java | 4 +- .../dto/response/UserProfileUpdateDto.java | 4 +- .../user/dto/response/UserSearchResponse.java | 4 +- .../user/dto/response/UserStatsDto.java | 4 +- .../user/dto/response/UserStatsResponse.java | 4 +- .../user/dto/response/UserTokenResponse.java | 4 +- .../response/UserTokenValidationResponse.java | 4 +- .../carecode/domain/user/entity/Child.java | 5 +- .../domain/user/entity/ConsentType.java | 7 +- .../carecode/domain/user/entity/Gender.java | 4 +- .../user/entity/NotificationSettings.java | 5 +- .../com/carecode/domain/user/entity/User.java | 4 +- .../domain/user/entity/UserConsent.java | 7 +- .../carecode/domain/user/entity/UserRole.java | 4 +- .../domain/user/mapper/UserMapper.java | 1 - .../user/repository/ChildRepository.java | 9 +- .../EmailVerificationTokenRepository.java | 5 +- .../NotificationSettingsRepository.java | 4 +- .../repository/UserConsentRepository.java | 5 +- .../user/repository/UserRepository.java | 4 +- .../domain/user/service/JwtService.java | 45 +----- .../domain/user/service/PrivacyService.java | 35 ++--- .../domain/user/service/UserService.java | 64 +-------- .../refreshtoken/RedisRefreshTokenStore.java | 4 +- .../refreshtoken/RefreshTokenStore.java | 9 +- .../resources/db/migration/V1__baseline.sql | 43 ++---- .../db/migration/V2__feature_tables.sql | 26 +--- .../migration/V3__hospital_external_code.sql | 8 +- .../db/migration/V4__search_indexes.sql | 6 +- .../V5__facility_capacity_snapshot.sql | 4 +- .../db/migration/V6__benefit_eligibility.sql | 6 +- .../V7__benefit_payment_duration.sql | 6 +- .../core/util/ClientIpResolverTest.java | 7 +- .../core/util/PageRequestUtilTest.java | 4 +- .../CareFacilityBookingServiceTest.java | 7 +- .../CommunityServiceOwnershipTest.java | 7 +- .../domain/health/entity/VaccineTypeTest.java | 4 +- .../GrowthPercentileCalculatorTest.java | 6 +- .../health/service/HealthServiceTest.java | 4 +- .../domain/user/service/JwtServiceTest.java | 7 +- .../ApplicationContextLoadTest.java | 7 +- .../integration/SampleDataScenarioTest.java | 5 +- 405 files changed, 755 insertions(+), 3566 deletions(-) diff --git a/src/main/java/com/carecode/core/RateLimitInterceptor.java b/src/main/java/com/carecode/core/RateLimitInterceptor.java index 308b0097..d0c3d643 100644 --- a/src/main/java/com/carecode/core/RateLimitInterceptor.java +++ b/src/main/java/com/carecode/core/RateLimitInterceptor.java @@ -15,16 +15,7 @@ import java.time.Duration; -/** - * Rate Limiting 인터셉터 - * - * - 인증된 사용자: userId 기반 분당 300회 (NAT/공유 IP 환경 대응) - * - 미인증 요청: IP 기반 분당 120회 - * - 민감 공개 API(/auth/signup 등): IP 기반 분당 30회 - * - * 학교 환경처럼 다수 사용자가 동일 공인 IP를 쓰는 경우 - * IP 기반 단일 제한은 오탐이 많아 인증 여부로 키를 분리합니다. - */ +/** Rate Limiting 인터셉터 - 인증된 사용자: userId 기반 분당 300회 (NAT/공유 IP 환경 대응) - 미인증 요청: IP 기반 분당 120회 - 민감 */ @Component @Slf4j @RequiredArgsConstructor @@ -88,9 +79,7 @@ private boolean checkLimit(String keyBody, int limit, HttpServletResponse respon return true; } - /** - * SecurityContext에서 인증된 사용자 ID 추출. 미인증이면 null. - */ + /** SecurityContext에서 인증된 사용자 ID 추출. 미인증이면 null. */ private String resolveUserId() { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getPrincipal())) { @@ -99,17 +88,12 @@ private String resolveUserId() { return auth.getName(); } - /** - * 클라이언트 IP 추출. - * 프록시 헤더 신뢰 여부는 {@link ClientIpResolver} 가 설정에 따라 판단한다. - */ + /** 클라이언트 IP 추출. 프록시 헤더 신뢰 여부는 ClientIpResolver 가 설정에 따라 판단한다. */ private String getClientIp(HttpServletRequest request) { return clientIpResolver.resolve(request); } - /** - * 공개 API 중 민감한 엔드포인트 (낮은 rate limit 적용) - */ + /** 공개 API 중 민감한 엔드포인트 (낮은 rate limit 적용) */ private boolean isPublicSensitiveEndpoint(String path) { return path.startsWith("/api/v1/contact") || path.startsWith("/api/v1/auth/signup"); diff --git a/src/main/java/com/carecode/core/annotation/ApiVersion.java b/src/main/java/com/carecode/core/annotation/ApiVersion.java index c5ad9de7..a24b9ee9 100644 --- a/src/main/java/com/carecode/core/annotation/ApiVersion.java +++ b/src/main/java/com/carecode/core/annotation/ApiVersion.java @@ -5,16 +5,12 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/** - * API 버전 지정 어노테이션 - * 컨트롤러나 메서드에 API 버전을 지정할 수 있습니다. - */ +/** API 버전 지정 어노테이션 */ @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface ApiVersion { // API 버전 (예: "v1", "v2") - String value(); } diff --git a/src/main/java/com/carecode/core/annotation/LogExecutionTime.java b/src/main/java/com/carecode/core/annotation/LogExecutionTime.java index 0e681198..d80ad745 100644 --- a/src/main/java/com/carecode/core/annotation/LogExecutionTime.java +++ b/src/main/java/com/carecode/core/annotation/LogExecutionTime.java @@ -5,26 +5,17 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/** - * 메서드 실행 시간을 측정하는 어노테이션 - * 육아 관련 API의 성능 모니터링에 활용 - */ +/** 메서드 실행 시간을 측정하는 어노테이션 육아 관련 API의 성능 모니터링에 활용 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface LogExecutionTime { // 로그 메시지 (선택사항) - String value() default ""; - // 메서드 인자 로깅 여부 - boolean logArgs() default false; - - - // 경고 임계값 (밀리초). 이 값을 초과하면 WARN 레벨로 로깅 - // 0이면 경고 없음 + // 경고 임계값 (밀리초). 이 값을 초과하면 WARN 레벨로 로깅 0이면 경고 없음 long warnThreshold() default 0; } \ No newline at end of file diff --git a/src/main/java/com/carecode/core/annotation/RateLimit.java b/src/main/java/com/carecode/core/annotation/RateLimit.java index 066b938d..5d0c167b 100644 --- a/src/main/java/com/carecode/core/annotation/RateLimit.java +++ b/src/main/java/com/carecode/core/annotation/RateLimit.java @@ -5,31 +5,21 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/** - * API Rate Limiting을 위한 어노테이션 - * 지정된 시간 내에 허용되는 최대 요청 수를 제한합니다. - */ +/** API Rate Limiting을 위한 어노테이션 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RateLimit { // 시간당 허용되는 최대 요청 수 - int requests() default 100; - // 시간 윈도우 (초 단위) - int windowSeconds() default 60; - // Rate limit 초과 시 메시지 - String message() default "요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요."; - // 사용자별로 제한할지 여부 (true면 IP 기반, false면 전역) - boolean perUser() default true; } diff --git a/src/main/java/com/carecode/core/annotation/RequireAdminRole.java b/src/main/java/com/carecode/core/annotation/RequireAdminRole.java index bf40b2ea..759fb714 100644 --- a/src/main/java/com/carecode/core/annotation/RequireAdminRole.java +++ b/src/main/java/com/carecode/core/annotation/RequireAdminRole.java @@ -5,10 +5,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/** - * 관리자 권한 확인 어노테이션 - * 해당 어노테이션이 붙은 메서드는 관리자 권한이 필요합니다. - */ +/** 관리자 권한 확인 어노테이션 */ @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface RequireAdminRole { diff --git a/src/main/java/com/carecode/core/annotation/RequireAuthentication.java b/src/main/java/com/carecode/core/annotation/RequireAuthentication.java index 48ff0594..2284527c 100644 --- a/src/main/java/com/carecode/core/annotation/RequireAuthentication.java +++ b/src/main/java/com/carecode/core/annotation/RequireAuthentication.java @@ -5,10 +5,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/** - * 인증이 필요한 API를 위한 어노테이션 - * 육아 커뮤니티, 개인정보 등 민감한 기능에 활용 - */ +/** 인증이 필요한 API를 위한 어노테이션 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequireAuthentication { diff --git a/src/main/java/com/carecode/core/annotation/ValidateChildAge.java b/src/main/java/com/carecode/core/annotation/ValidateChildAge.java index adfc1e6e..a23c41c4 100644 --- a/src/main/java/com/carecode/core/annotation/ValidateChildAge.java +++ b/src/main/java/com/carecode/core/annotation/ValidateChildAge.java @@ -5,10 +5,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/** - * 자녀 연령 검증을 위한 어노테이션 - * 육아 정책 및 서비스에서 연령별 맞춤 정보 제공 시 활용 - */ +/** 자녀 연령 검증을 위한 어노테이션 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface ValidateChildAge { diff --git a/src/main/java/com/carecode/core/annotation/ValidateLocation.java b/src/main/java/com/carecode/core/annotation/ValidateLocation.java index cc504361..51ed131e 100644 --- a/src/main/java/com/carecode/core/annotation/ValidateLocation.java +++ b/src/main/java/com/carecode/core/annotation/ValidateLocation.java @@ -5,10 +5,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/** - * 위치 기반 검증을 위한 어노테이션 - * 육아 서비스에서 지역별 정책 및 시설 검색 시 활용 - */ +/** 위치 기반 검증을 위한 어노테이션 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface ValidateLocation { diff --git a/src/main/java/com/carecode/core/aspect/AuthenticationAspect.java b/src/main/java/com/carecode/core/aspect/AuthenticationAspect.java index 6a2c48ce..c0b5b8f0 100644 --- a/src/main/java/com/carecode/core/aspect/AuthenticationAspect.java +++ b/src/main/java/com/carecode/core/aspect/AuthenticationAspect.java @@ -10,18 +10,13 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; -/** - * RequireAuthentication 어노테이션을 처리하는 Aspect - * 인증이 필요한 API에 대한 인증 상태를 확인합니다. - */ +/** RequireAuthentication 어노테이션을 처리하는 Aspect 인증이 필요한 API에 대한 인증 상태를 확인합니다. */ @Slf4j @Aspect @Component public class AuthenticationAspect { - // RequireAuthentication 어노테이션이 붙은 메서드 실행 전에 인증 상태를 확인 - @Before("@annotation(requireAuthentication)") public void checkAuthentication(JoinPoint joinPoint, RequireAuthentication requireAuthentication) { log.debug("인증 상태 확인: {}", joinPoint.getSignature().getName()); diff --git a/src/main/java/com/carecode/core/aspect/LoggingAspect.java b/src/main/java/com/carecode/core/aspect/LoggingAspect.java index c1be0bf1..9595db60 100644 --- a/src/main/java/com/carecode/core/aspect/LoggingAspect.java +++ b/src/main/java/com/carecode/core/aspect/LoggingAspect.java @@ -10,10 +10,7 @@ import org.springframework.stereotype.Component; import org.springframework.util.StopWatch; -/** - * 메서드 실행 시간 로깅을 위한 Aspect - * @LogExecutionTime 어노테이션이 붙은 메서드의 실행 시간을 측정하고 로깅합니다. - */ +/** 메서드 실행 시간 로깅을 위한 Aspect @LogExecutionTime 어노테이션이 붙은 메서드의 실행 시간을 측정하고 로깅합니다. */ @Aspect @Component @Slf4j @@ -73,10 +70,8 @@ public Object logExecutionTime(ProceedingJoinPoint joinPoint, LogExecutionTime l MDC.remove("error"); } } - // 메서드 인자를 문자열로 포맷팅 - private String formatArgs(Object[] args) { if (args == null || args.length == 0) { return "[]"; diff --git a/src/main/java/com/carecode/core/aspect/RateLimitingAspect.java b/src/main/java/com/carecode/core/aspect/RateLimitingAspect.java index d0ba6685..6a4090de 100644 --- a/src/main/java/com/carecode/core/aspect/RateLimitingAspect.java +++ b/src/main/java/com/carecode/core/aspect/RateLimitingAspect.java @@ -17,14 +17,7 @@ import java.time.Duration; -/** - * {@link RateLimit} 이 붙은 메서드에 대한 요청 수 제한. - * - *

이전 구현은 인스턴스 로컬 {@code ConcurrentHashMap} 을 썼기 때문에 - * (1) 다중 인스턴스에서 무의미했고, (2) IP 별 키가 무한히 쌓여 메모리를 잠식했으며, - * (3) 윈도우 리셋이 원자적이지 않아 경계에서 한도를 초과할 수 있었다. - * Redis 의 INCR + EXPIRE 로 교체해 세 문제를 모두 해소한다. - */ +/** RateLimit 이 붙은 메서드에 대한 요청 수 제한. 이전 구현은 인스턴스 로컬 ConcurrentHashMap 을 썼기 때문에 (1) 다중 인스턴스에서 무의미했고 */ @Aspect @Component @Slf4j diff --git a/src/main/java/com/carecode/core/benefit/BenefitPaymentType.java b/src/main/java/com/carecode/core/benefit/BenefitPaymentType.java index cc0e2b33..8fe62156 100644 --- a/src/main/java/com/carecode/core/benefit/BenefitPaymentType.java +++ b/src/main/java/com/carecode/core/benefit/BenefitPaymentType.java @@ -42,10 +42,7 @@ public static BenefitPaymentType resolve(String benefitType) { return normalized.startsWith("월") ? MONTHLY : UNKNOWN; } - /** - * 금액 합산에 포함할지. - * UNKNOWN 은 포함하되 호출부에서 1회 지급으로 취급한다 — 월 지급으로 잘못 보면 60배까지 부풀려진다. - */ + /** 금액 합산에 포함할지. UNKNOWN 은 포함하되 호출부에서 1회 지급으로 취급한다 — 월 지급으로 잘못 보면 60배까지 부풀려진다. */ public boolean countsTowardCash() { return this != NON_CASH; } diff --git a/src/main/java/com/carecode/core/benefit/BenefitProjectionCalculator.java b/src/main/java/com/carecode/core/benefit/BenefitProjectionCalculator.java index 51c7d433..e7baec79 100644 --- a/src/main/java/com/carecode/core/benefit/BenefitProjectionCalculator.java +++ b/src/main/java/com/carecode/core/benefit/BenefitProjectionCalculator.java @@ -3,10 +3,7 @@ import com.carecode.domain.policy.entity.Policy; import org.springframework.stereotype.Component; -/** - * 아이의 현재 월령과 전망 기간으로 정책 하나의 예상 수령액을 계산한다. - * 부풀리면 제품 신뢰가 끝나므로, 판별이 애매하면 항상 적게 잡는다. - */ +/** 아이의 현재 월령과 전망 기간으로 정책 하나의 예상 수령액을 계산한다. */ @Component public class BenefitProjectionCalculator { @@ -22,10 +19,6 @@ static Projection none(BenefitPaymentType type) { } } - /** - * @param currentAgeMonths 아이의 현재 월령 - * @param horizonMonths 앞으로 몇 개월을 볼 것인지 - */ public Projection project(Policy policy, int currentAgeMonths, int horizonMonths) { BenefitPaymentType type = BenefitPaymentType.resolve(policy.getBenefitType()); @@ -62,10 +55,7 @@ private int capByPaymentDuration(Policy policy, int eligibleMonths) { return Math.min(eligibleMonths, max); } - /** - * 전망 구간 [현재월령, 현재월령+기간) 과 정책 대상 구간 [min, max] 의 겹치는 개월 수. - * 연령 조건이 없는 정책은 기간 내내 대상으로 본다. - */ + /** 전망 구간 [현재월령, 현재월령+기간) 과 정책 대상 구간 [min, max] 의 겹치는 개월 수. 연령 조건이 없는 정책은 기간 내내 대상으로 본다. */ private int countEligibleMonths(Policy policy, int currentAgeMonths, int horizonMonths) { if (horizonMonths <= 0) { return 0; diff --git a/src/main/java/com/carecode/core/client/CareFacilityApiService.java b/src/main/java/com/carecode/core/client/CareFacilityApiService.java index 5ea6edd0..de808548 100644 --- a/src/main/java/com/carecode/core/client/CareFacilityApiService.java +++ b/src/main/java/com/carecode/core/client/CareFacilityApiService.java @@ -12,10 +12,7 @@ import java.util.List; import java.util.Map; -/** - * 돌봄시설 공공데이터 API 서비스 - * 보육시설, 어린이집 등의 정보를 공공데이터 포털에서 가져옴 - */ +/** 돌봄시설 공공데이터 API 서비스 */ @Slf4j @Service @RequiredArgsConstructor @@ -27,23 +24,14 @@ public class CareFacilityApiService { @Value("${public.data.api.key}") private String apiKey; - - // 보육시설 정보 조회 (서울시 공공데이터 API) - // 서울시 API는 지역별 필터링을 지원하지 않으므로 전체 서울시 데이터를 가져옵니다. - // @param sido 시도명 (예: 서울특별시, 경기도) - 현재는 무시됨 - // @param sigungu 시군구명 (예: 강남구, 수원시) - 현재는 무시됨 - // @param pageNo 페이지 번호 - // @param numOfRows 한 페이지 결과 수 (최대 1000건) - // @return 정제된 보육시설 정보 - + // 보육시설 정보 조회 (서울시 공공데이터 API) 서울시 API는 지역별 필터링을 지원하지 않으므로 전체 서울시 데이터를 가져옵니다 public Map getChildcareFacilities(String sido, String sigungu, int pageNo, int numOfRows) { // 서울시 API는 한 번에 최대 1000건까지만 요청 가능 if (numOfRows > 1000) { numOfRows = 1000; } - // 서울시 API는 파라미터가 URL 경로에 포함됨 - // URL 구조: /서비스명/START_INDEX/END_INDEX/ + // 서울시 API는 파라미터가 URL 경로에 포함됨 URL 구조: /서비스명/START_INDEX/END_INDEX/ String startIndex = String.valueOf((pageNo - 1) * numOfRows + 1); String endIndex = String.valueOf(pageNo * numOfRows); @@ -59,9 +47,7 @@ public Map getChildcareFacilities(String sido, String sigungu, i return result; } - // API 응답을 파싱하고 정제하여 사용하기 쉬운 형태로 변환 - private Map parseAndRefineResponse(String rawResponse, String serviceName) { try { log.debug("원본 응답: {}", rawResponse); @@ -160,10 +146,8 @@ private Map parseAndRefineResponse(String rawResponse, String se return createErrorResponse("PARSE_ERROR", "응답 파싱 실패: " + e.getMessage(), serviceName); } } - // 빈 응답 생성 - private Map createEmptyResponse(String serviceName) { Map result = new HashMap<>(); result.put("success", true); @@ -174,10 +158,8 @@ private Map createEmptyResponse(String serviceName) { result.put("serviceName", serviceName); return result; } - // 에러 응답 생성 - private Map createErrorResponse(String code, String message, String serviceName) { Map result = new HashMap<>(); result.put("success", false); @@ -188,18 +170,14 @@ private Map createErrorResponse(String code, String message, Str result.put("serviceName", serviceName); return result; } - // JsonNode에서 텍스트 값을 안전하게 추출 - private String getNodeText(JsonNode node, String fieldName) { JsonNode fieldNode = node.get(fieldName); return fieldNode != null && !fieldNode.isNull() ? fieldNode.asText() : ""; } - // 시간 형식을 HH:MM으로 변환 - private String formatTime(String timeStr) { if (timeStr == null || timeStr.trim().isEmpty()) { return ""; @@ -216,9 +194,7 @@ private String formatTime(String timeStr) { } } - // 유치원 정보 조회 - public Map getKindergartens(String sido, String sigungu, int pageNo, int numOfRows) { String startIndex = String.valueOf((pageNo - 1) * numOfRows + 1); String endIndex = String.valueOf(pageNo * numOfRows); @@ -228,9 +204,7 @@ public Map getKindergartens(String sido, String sigungu, int pag return parseAndRefineResponse(response, "kindergarten"); } - // 돌봄시설 통계 정보 조회 - public Map getCareFacilityStatistics(String sido) { String endpoint = "/statistics/" + sido + "/"; String response = publicDataApiClient.get(endpoint, null, String.class); @@ -238,9 +212,7 @@ public Map getCareFacilityStatistics(String sido) { return parseAndRefineResponse(response, "statistics"); } - // 돌봄시설 키워드 검색 - public Map searchCareFacilities(String keyword, int pageNo, int numOfRows) { String startIndex = String.valueOf((pageNo - 1) * numOfRows + 1); String endIndex = String.valueOf(pageNo * numOfRows); diff --git a/src/main/java/com/carecode/core/client/PublicDataApiClient.java b/src/main/java/com/carecode/core/client/PublicDataApiClient.java index ff72706f..3033532b 100644 --- a/src/main/java/com/carecode/core/client/PublicDataApiClient.java +++ b/src/main/java/com/carecode/core/client/PublicDataApiClient.java @@ -10,10 +10,7 @@ import java.util.Map; -/** - * 공공데이터 API 호출을 위한 공통 클라이언트 - * 다양한 공공데이터 포털 API 호출에 사용 - */ +/** 공공데이터 API 호출을 위한 공통 클라이언트 다양한 공공데이터 포털 API 호출에 사용 */ @Slf4j @Component @RequiredArgsConstructor @@ -27,16 +24,9 @@ public class PublicDataApiClient { @Value("${public.data.api.base-url:}") private String baseUrl; - - // GET 요청으로 공공데이터 API 호출 - // @param endpoint API 엔드포인트 - // @param params 쿼리 파라미터 - // @param responseType 응답 타입 - // @return API 응답 - + // GET 요청으로 공공데이터 API 호출 @param endpoint API 엔드포인트 @param params 쿼리 파라미터 @param responseType 응답 public T get(String endpoint, Map params, Class responseType) { // 서울시 API URL 구조: http://openapi.seoul.go.kr:8088/KEY/TYPE/SERVICE/START_INDEX/END_INDEX/ - // endpoint는 SERVICE/START_INDEX/END_INDEX/ 형태로 전달됨 String url = baseUrl + "/" + apiKey + "/json/" + endpoint; log.info("공공데이터 API 호출: {}", url); @@ -61,13 +51,7 @@ public T get(String endpoint, Map params, Class responseT } } - - // POST 요청으로 공공데이터 API 호출 - // @param endpoint API 엔드포인트 - // @param requestBody 요청 본문 - // @param responseType 응답 타입 - // @return API 응답 - + // POST 요청으로 공공데이터 API 호출 @param endpoint API 엔드포인트 @param requestBody 요청 본문 @param responseType public T post(String endpoint, Object requestBody, Class responseType) { String url = baseUrl + endpoint; HttpHeaders headers = new HttpHeaders(); @@ -85,14 +69,7 @@ public T post(String endpoint, Object requestBody, Class responseType) { } } - - // 헤더를 포함한 GET 요청 - // @param endpoint API 엔드포인트 - // @param params 쿼리 파라미터 - // @param headers 추가 헤더 - // @param responseType 응답 타입 - // @return API 응답 - + // 헤더를 포함한 GET 요청 @param endpoint API 엔드포인트 @param params 쿼리 파라미터 @param headers 추가 헤더 @param public T getWithHeaders(String endpoint, Map params, Map headers, Class responseType) { UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(baseUrl + endpoint).queryParam("serviceKey", apiKey); @@ -116,18 +93,12 @@ public T getWithHeaders(String endpoint, Map params, Map getChildcareFacilities(String region, int pageNo, int numOfRows) { HashMap params = new HashMap<>(); @@ -40,13 +31,7 @@ public PublicDataResponse getChildcareFacilities(String region, int page return apiClient.get("/getChildcareFacilities", params, PublicDataResponse.class); } - - // 보육 정책 정보 조회 - // @param policyType 정책 유형 - // @param pageNo 페이지 번호 - // @param numOfRows 페이지당 행 수 - // @return 보육 정책 정보 - + // 보육 정책 정보 조회 @param policyType 정책 유형 @param pageNo 페이지 번호 @param numOfRows 페이지당 행 수 @return 보육 public PublicDataResponse getChildcarePolicies(String policyType, int pageNo, int numOfRows) { Map params = new HashMap<>(); params.put("pageNo", String.valueOf(pageNo)); @@ -60,13 +45,7 @@ public PublicDataResponse getChildcarePolicies(String policyType, int pa return apiClient.get("/getChildcarePolicies", params, PublicDataResponse.class); } - - // 소아과 병원 정보 조회 - // @param region 지역명 - // @param pageNo 페이지 번호 - // @param numOfRows 페이지당 행 수 - // @return 소아과 병원 정보 - + // 소아과 병원 정보 조회 @param region 지역명 @param pageNo 페이지 번호 @param numOfRows 페이지당 행 수 @return 소아과 병원 정보 public PublicDataResponse getPediatricHospitals(String region, int pageNo, int numOfRows) { Map params = new HashMap<>(); params.put("pageNo", String.valueOf(pageNo)); @@ -80,13 +59,7 @@ public PublicDataResponse getPediatricHospitals(String region, int pageN return apiClient.get("/getPediatricHospitals", params, PublicDataResponse.class); } - - // 육아 지원금 정보 조회 - // @param region 지역명 - // @param pageNo 페이지 번호 - // @param numOfRows 페이지당 행 수 - // @return 육아 지원금 정보 - + // 육아 지원금 정보 조회 @param region 지역명 @param pageNo 페이지 번호 @param numOfRows 페이지당 행 수 @return 육아 지원금 정보 public PublicDataResponse getChildcareSubsidies(String region, int pageNo, int numOfRows) { Map params = new HashMap<>(); params.put("pageNo", String.valueOf(pageNo)); @@ -100,13 +73,7 @@ public PublicDataResponse getChildcareSubsidies(String region, int pageN return apiClient.get("/getChildcareSubsidies", params, PublicDataResponse.class); } - - // 육아 관련 교육 정보 조회 - // @param educationType 교육 유형 - // @param pageNo 페이지 번호 - // @param numOfRows 페이지당 행 수 - // @return 육아 교육 정보 - + // 육아 관련 교육 정보 조회 @param educationType 교육 유형 @param pageNo 페이지 번호 @param numOfRows 페이지당 행 수 public PublicDataResponse getChildcareEducation(String educationType, int pageNo, int numOfRows) { Map params = new HashMap<>(); params.put("pageNo", String.valueOf(pageNo)); @@ -120,12 +87,7 @@ public PublicDataResponse getChildcareEducation(String educationType, in return apiClient.get("/getChildcareEducation", params, PublicDataResponse.class); } - - // 커스텀 API 호출 - // @param endpoint API 엔드포인트 - // @param params 파라미터 - // @return API 응답 - + // 커스텀 API 호출 @param endpoint API 엔드포인트 @param params 파라미터 @return API 응답 public PublicDataResponse callCustomApi(String endpoint, Map params) { if (params == null) { params = new HashMap<>(); @@ -135,11 +97,7 @@ public PublicDataResponse callCustomApi(String endpoint, Map response) { if (response == null) { log.error("API 응답이 null입니다."); diff --git a/src/main/java/com/carecode/core/client/controller/PublicDataController.java b/src/main/java/com/carecode/core/client/controller/PublicDataController.java index f146948b..83576eae 100644 --- a/src/main/java/com/carecode/core/client/controller/PublicDataController.java +++ b/src/main/java/com/carecode/core/client/controller/PublicDataController.java @@ -12,10 +12,7 @@ import java.util.Map; -/** - * 공공데이터 API 컨트롤러 - * 외부에서 공공데이터 API를 호출할 수 있는 REST API 엔드포인트 제공 - */ +/** 공공데이터 API 컨트롤러 */ @Slf4j @RestController @RequestMapping("/api/public-data") @@ -25,11 +22,9 @@ public class PublicDataController { private final PublicDataApiService publicDataApiService; - // 육아 시설 정보 조회 - @GetMapping("/childcare-facilities") - @Operation(summary = "육아 시설 정보 조회", description = "지역별 육아 관련 시설 정보를 조회합니다.") + @Operation(summary = "육아 시설 정보 조회", description = "지역별 육아 관련 시설 정보 조회") public ResponseEntity> getChildcareFacilities(@Parameter(description = "지역명") @RequestParam(required = false) String region, @Parameter(description = "페이지 번호") @RequestParam(defaultValue = "1") int pageNo, @Parameter(description = "페이지당 행 수") @RequestParam(defaultValue = "10") int numOfRows) { @@ -43,11 +38,9 @@ public ResponseEntity> getChildcareFacilities(@Parame } } - // 보육 정책 정보 조회 - @GetMapping("/childcare-policies") - @Operation(summary = "보육 정책 정보 조회", description = "보육 관련 정책 정보를 조회합니다.") + @Operation(summary = "보육 정책 정보 조회") public ResponseEntity> getChildcarePolicies(@Parameter(description = "정책 유형") @RequestParam(required = false) String policyType, @Parameter(description = "페이지 번호") @RequestParam(defaultValue = "1") int pageNo, @Parameter(description = "페이지당 행 수") @RequestParam(defaultValue = "10") int numOfRows) { @@ -61,11 +54,9 @@ public ResponseEntity> getChildcarePolicies(@Paramete } } - // 소아과 병원 정보 조회 - @GetMapping("/pediatric-hospitals") - @Operation(summary = "소아과 병원 정보 조회", description = "지역별 소아과 병원 정보를 조회합니다.") + @Operation(summary = "소아과 병원 정보 조회") public ResponseEntity> getPediatricHospitals(@Parameter(description = "지역명") @RequestParam(required = false) String region, @Parameter(description = "페이지 번호") @RequestParam(defaultValue = "1") int pageNo, @Parameter(description = "페이지당 행 수") @RequestParam(defaultValue = "10") int numOfRows) { @@ -79,11 +70,9 @@ public ResponseEntity> getPediatricHospitals(@Paramet } } - // 육아 지원금 정보 조회 - @GetMapping("/childcare-subsidies") - @Operation(summary = "육아 지원금 정보 조회", description = "지역별 육아 지원금 정보를 조회합니다.") + @Operation(summary = "육아 지원금 정보 조회") public ResponseEntity> getChildcareSubsidies(@Parameter(description = "지역명") @RequestParam(required = false) String region, @Parameter(description = "페이지 번호") @RequestParam(defaultValue = "1") int pageNo, @Parameter(description = "페이지당 행 수") @RequestParam(defaultValue = "10") int numOfRows) { @@ -97,11 +86,9 @@ public ResponseEntity> getChildcareSubsidies(@Paramet } } - // 육아 교육 정보 조회 - @GetMapping("/childcare-education") - @Operation(summary = "육아 교육 정보 조회", description = "육아 관련 교육 정보를 조회합니다.") + @Operation(summary = "육아 교육 정보 조회") public ResponseEntity> getChildcareEducation(@Parameter(description = "교육 유형") @RequestParam(required = false) String educationType, @Parameter(description = "페이지 번호") @RequestParam(defaultValue = "1") int pageNo, @Parameter(description = "페이지당 행 수") @RequestParam(defaultValue = "10") int numOfRows) { @@ -115,11 +102,9 @@ public ResponseEntity> getChildcareEducation(@Paramet } } - // 커스텀 API 호출 - @GetMapping("/custom/{endpoint}") - @Operation(summary = "커스텀 API 호출", description = "사용자 정의 엔드포인트로 공공데이터 API를 호출합니다.") + @Operation(summary = "커스텀 API 호출", description = "사용자 정의 엔드포인트로 공공데이터 API를 호출") public ResponseEntity> callCustomApi(@Parameter(description = "API 엔드포인트") @PathVariable String endpoint, @Parameter(description = "쿼리 파라미터") @RequestParam Map params) { diff --git a/src/main/java/com/carecode/core/client/dto/PublicDataResponse.java b/src/main/java/com/carecode/core/client/dto/PublicDataResponse.java index 518f1847..5daf3b78 100644 --- a/src/main/java/com/carecode/core/client/dto/PublicDataResponse.java +++ b/src/main/java/com/carecode/core/client/dto/PublicDataResponse.java @@ -6,10 +6,7 @@ import java.util.List; -/** - * 공공데이터 API 공통 응답 DTO - * 대부분의 공공데이터 API가 공통적으로 사용하는 응답 구조 - */ +/** 공공데이터 API 공통 응답 */ @Data @NoArgsConstructor public class PublicDataResponse { @@ -60,20 +57,14 @@ public static class Items { private List item; } - - // 응답이 성공인지 확인 - // @return 성공 여부 - + // 응답이 성공인지 확인 @return 성공 여부 public boolean isSuccess() { return response != null && response.getHeader() != null && "00".equals(response.getHeader().getResultCode()); } - - // 에러 메시지 반환 - // @return 에러 메시지 - + // 에러 메시지 반환 @return 에러 메시지 public String getErrorMessage() { if (response != null && response.getHeader() != null) { return response.getHeader().getResultMsg(); @@ -81,10 +72,7 @@ public String getErrorMessage() { return "Unknown error"; } - - // 데이터 목록 반환 - // @return 데이터 목록 - + // 데이터 목록 반환 @return 데이터 목록 public List getData() { if (response != null && response.getBody() != null && @@ -94,10 +82,7 @@ public List getData() { return null; } - - // 총 개수 반환 - // @return 총 개수 - + // 총 개수 반환 @return 총 개수 public int getTotalCount() { if (response != null && response.getBody() != null) { return response.getBody().getTotalCount(); diff --git a/src/main/java/com/carecode/core/client/exception/PublicDataApiException.java b/src/main/java/com/carecode/core/client/exception/PublicDataApiException.java index f221c9e4..6a719446 100644 --- a/src/main/java/com/carecode/core/client/exception/PublicDataApiException.java +++ b/src/main/java/com/carecode/core/client/exception/PublicDataApiException.java @@ -1,9 +1,6 @@ package com.carecode.core.client.exception; -/** - * 공공데이터 API 관련 예외 - * API 호출 실패 시 발생하는 커스텀 예외 - */ +/** 공공데이터 API 관련 예외 API 호출 실패 시 발생하는 커스텀 예외 */ public class PublicDataApiException extends RuntimeException { public PublicDataApiException(String message) { diff --git a/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java b/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java index 39ff9203..d4fe507e 100644 --- a/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java +++ b/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java @@ -51,8 +51,7 @@ public String fetch(String resource, int pageNo, int numOfRows, Map알림 발송(SMTP/FCM 왕복)이 요청 스레드를 붙잡지 않도록 별도 풀에서 처리한다. - */ +/** 비동기 실행 설정. 알림 발송(SMTP/FCM 왕복)이 요청 스레드를 붙잡지 않도록 별도 풀에서 처리한다. */ @Slf4j @Configuration @EnableAsync diff --git a/src/main/java/com/carecode/core/config/CacheConfig.java b/src/main/java/com/carecode/core/config/CacheConfig.java index 190d4f06..44ac92bc 100644 --- a/src/main/java/com/carecode/core/config/CacheConfig.java +++ b/src/main/java/com/carecode/core/config/CacheConfig.java @@ -20,17 +20,12 @@ import java.util.HashMap; import java.util.Map; -/** - * Redis 캐시 설정 - * Spring Cache Abstraction을 사용한 캐싱 전략 - */ +/** Redis 캐시 설정 */ @Configuration @EnableCaching public class CacheConfig { - // 기본 캐시 설정 - private RedisCacheConfiguration defaultCacheConfig() { return RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) // 기본 TTL: 10분 @@ -41,10 +36,7 @@ private RedisCacheConfiguration defaultCacheConfig() { .disableCachingNullValues(); // null 값은 캐싱하지 않음 } - /** - * 캐시 값 직렬화용 ObjectMapper. - * JavaTimeModule 이 없으면 LocalDate/LocalDateTime 필드를 가진 DTO 캐싱이 실패한다. - */ + /** 캐시 값 직렬화용 ObjectMapper. JavaTimeModule 이 없으면 LocalDate/LocalDateTime 필드를 가진 DTO 캐싱이 실패한다. */ private ObjectMapper cacheObjectMapper() { ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); @@ -61,9 +53,7 @@ private ObjectMapper cacheObjectMapper() { return mapper; } - // 캐시별 TTL 설정 - @Bean public CacheManager cacheManager(RedisConnectionFactory factory) { Map cacheConfigurations = new HashMap<>(); diff --git a/src/main/java/com/carecode/core/config/FirebaseConfig.java b/src/main/java/com/carecode/core/config/FirebaseConfig.java index b3e0ddd4..246b9fbb 100644 --- a/src/main/java/com/carecode/core/config/FirebaseConfig.java +++ b/src/main/java/com/carecode/core/config/FirebaseConfig.java @@ -13,13 +13,7 @@ import java.io.InputStream; -/** - * FCM 초기화. - * - *

서비스 계정 자격증명이 설정되지 않은 환경(로컬/CI)에서도 애플리케이션이 뜨도록 - * 자격증명이 없으면 {@link FirebaseMessaging} 빈을 만들지 않는다. - * 푸시 발송기는 이 빈이 없으면 자동으로 비활성 상태가 된다. - */ +/** FCM 초기화. 서비스 계정 자격증명이 설정되지 않은 환경(로컬/CI)에서도 애플리케이션이 뜨도록 자격증명이 없으면 FirebaseMessaging 빈을 만들지 않는다 */ @Slf4j @Configuration public class FirebaseConfig { diff --git a/src/main/java/com/carecode/core/config/RedisConfig.java b/src/main/java/com/carecode/core/config/RedisConfig.java index ada3aa72..9ede49b2 100644 --- a/src/main/java/com/carecode/core/config/RedisConfig.java +++ b/src/main/java/com/carecode/core/config/RedisConfig.java @@ -7,16 +7,11 @@ import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; -/** - * Redis 설정 클래스 - * 캐싱과 Rate Limiting에 사용됩니다. - */ +/** Redis 설정 클래스 캐싱과 Rate Limiting에 사용됩니다. */ @Configuration public class RedisConfig { - // 문자열 기반 RedisTemplate (Rate Limiting용) - @Bean public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate template = new RedisTemplate<>(); @@ -28,10 +23,8 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec template.afterPropertiesSet(); return template; } - // 객체 기반 RedisTemplate (캐싱용) - @Bean public RedisTemplate redisObjectTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate template = new RedisTemplate<>(); diff --git a/src/main/java/com/carecode/core/config/RestTemplateConfig.java b/src/main/java/com/carecode/core/config/RestTemplateConfig.java index ccf5eaad..769e5056 100644 --- a/src/main/java/com/carecode/core/config/RestTemplateConfig.java +++ b/src/main/java/com/carecode/core/config/RestTemplateConfig.java @@ -5,10 +5,7 @@ import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; -/** - * RestTemplate 설정 - * 공공데이터 API 클라이언트에서 사용할 RestTemplate을 설정 - */ +/** RestTemplate 설정 */ @Configuration public class RestTemplateConfig { diff --git a/src/main/java/com/carecode/core/config/SwaggerConfig.java b/src/main/java/com/carecode/core/config/SwaggerConfig.java index ad44c0b2..4452d6c5 100644 --- a/src/main/java/com/carecode/core/config/SwaggerConfig.java +++ b/src/main/java/com/carecode/core/config/SwaggerConfig.java @@ -14,9 +14,7 @@ import java.util.ArrayList; import java.util.List; -/** - * Swagger/OpenAPI 3 설정 - */ +/** Swagger/OpenAPI 3 설정 */ @Configuration public class SwaggerConfig { @@ -60,10 +58,8 @@ public OpenAPI customOpenAPI() { .scheme("basic") .description("기본 인증 정보를 입력하세요"))); } - // 환경별 서버 목록 생성 - private List createServerList() { List servers = new ArrayList<>(); diff --git a/src/main/java/com/carecode/core/config/WebMvcConfig.java b/src/main/java/com/carecode/core/config/WebMvcConfig.java index 342896ac..a6a49a2e 100644 --- a/src/main/java/com/carecode/core/config/WebMvcConfig.java +++ b/src/main/java/com/carecode/core/config/WebMvcConfig.java @@ -10,12 +10,7 @@ import java.nio.file.Paths; -/** - * 웹 계층 공통 설정. - * - *

{@link RateLimitInterceptor} 는 {@code @Component} 로 빈 등록만 되어 있고 - * 인터셉터 체인에는 연결되어 있지 않아 동작하지 않는 상태였다. 여기서 등록한다. - */ +/** 웹 계층 공통 설정. RateLimitInterceptor 는 @Component 로 빈 등록만 되어 있고 인터셉터 체인에는 연결되어 있지 않아 동작하지 않는 상태였다 */ @Configuration @RequiredArgsConstructor public class WebMvcConfig implements WebMvcConfigurer { @@ -28,10 +23,7 @@ public class WebMvcConfig implements WebMvcConfigurer { @Value("${app.storage.public-base-url:/files}") private String publicBaseUrl; - /** - * 업로드된 파일을 정적 리소스로 서빙한다. - * (S3 로 전환하면 이 매핑 대신 버킷 URL 을 그대로 쓰면 된다.) - */ + /** 업로드된 파일을 정적 리소스로 서빙한다. (S3 로 전환하면 이 매핑 대신 버킷 URL 을 그대로 쓰면 된다.) */ @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String location = Paths.get(storageRoot).toAbsolutePath().normalize().toUri().toString(); diff --git a/src/main/java/com/carecode/core/constants/CustomHttpStatus.java b/src/main/java/com/carecode/core/constants/CustomHttpStatus.java index 3df29faf..dab17002 100644 --- a/src/main/java/com/carecode/core/constants/CustomHttpStatus.java +++ b/src/main/java/com/carecode/core/constants/CustomHttpStatus.java @@ -1,9 +1,6 @@ package com.carecode.core.constants; -/** - * 맘편한 서비스 전용 커스텀 HTTP 상태코드 Enum - * (Spring HttpStatus와 별도 관리) - */ +/** 맘편한 서비스 */ public enum CustomHttpStatus { CARE_POLICY_NOT_FOUND(460, "Care Policy Not Found"), CARE_FACILITY_NOT_FOUND(461, "Care Facility Not Found"), diff --git a/src/main/java/com/carecode/core/controller/BaseController.java b/src/main/java/com/carecode/core/controller/BaseController.java index 4956f6d5..7e783bdb 100644 --- a/src/main/java/com/carecode/core/controller/BaseController.java +++ b/src/main/java/com/carecode/core/controller/BaseController.java @@ -2,10 +2,7 @@ import org.springframework.web.bind.annotation.RequestMapping; -/** - * 모든 컨트롤러의 기본 클래스 - * 공통 URL 경로와 기본 설정을 제공 - */ +/** 모든 컨트롤러의 기본 클래스 공통 URL 경로와 기본 설정을 제공 */ @RequestMapping("/api/v1") public abstract class BaseController { diff --git a/src/main/java/com/carecode/core/controller/CareFacilityApiController.java b/src/main/java/com/carecode/core/controller/CareFacilityApiController.java index 039e1dda..625b767f 100644 --- a/src/main/java/com/carecode/core/controller/CareFacilityApiController.java +++ b/src/main/java/com/carecode/core/controller/CareFacilityApiController.java @@ -20,10 +20,7 @@ import java.util.List; import java.util.Optional; -/** - * 돌봄시설 공공데이터 API 컨트롤러 - * 보육시설 정보를 공공데이터 포털에서 가져와서 DB에 저장 - */ +/** 돌봄시설 공공데이터 API 컨트롤러 */ @Slf4j @RestController @RequestMapping("/api/public/care-facilities") @@ -36,11 +33,9 @@ public class CareFacilityApiController { private final CareFacilityRepository careFacilityRepository; private FacilityType facilityType; - // 전체 보육시설 정보 동기화 (모든 데이터) - @PostMapping("/sync-all") - @Operation(summary = "전체 보육시설 정보 동기화", description = "공공데이터에서 모든 보육시설 정보를 조회하고 DB에 저장합니다.") + @Operation(summary = "전체 보육시설 정보 동기화", description = "공공데이터에서 모든 보육시설 정보를 조회하고 DB에 저장") public ResponseEntity> syncAllChildcareFacilities( @Parameter(description = "시도명", example = "서울특별시") @RequestParam(defaultValue = "서울특별시") String sido, @Parameter(description = "시군구명", example = "강남구") @RequestParam(defaultValue = "강남구") String sigungu, @@ -147,9 +142,7 @@ public ResponseEntity> syncAllChildcareFacilities( } } - // Swagger UI용 간단한 보육시설 동기화 (GET 방식) - @GetMapping("/swagger/sync") @Operation(summary = "Swagger용 보육시설 동기화", description = "Swagger UI에서 쉽게 테스트할 수 있는 GET 방식 동기화") public ResponseEntity> swaggerSync() { @@ -168,11 +161,9 @@ public ResponseEntity> swaggerSync() { } } - // DB 저장된 보육시설 목록 조회 - @GetMapping("/swagger/db-facilities") - @Operation(summary = "DB 저장된 보육시설 목록", description = "DB에 저장된 보육시설 목록을 조회합니다") + @Operation(summary = "DB 저장된 보육시설 목록") public ResponseEntity> swaggerGetDbFacilities( @Parameter(description = "페이지 번호", example = "0") @RequestParam(defaultValue = "0") int page, @Parameter(description = "한 페이지 결과 수", example = "10") @RequestParam(defaultValue = "10") int size) { @@ -180,8 +171,7 @@ public ResponseEntity> swaggerGetDbFacilities( try { log.info("DB 저장된 보육시설 목록 조회 요청: page={}, size={}", page, size); - // 전체를 읽어 subList 로 자르면 테이블이 커질수록 그대로 부하가 되고, - // start 가 목록 크기를 넘으면 IndexOutOfBoundsException 이 난다. DB 페이징으로 처리한다. + // 전체를 읽어 subList 로 자르면 테이블이 커질수록 그대로 부하가 되고, start 가 목록 크기를 넘으면 IndexOutOfBoundsException 이 난다 int safePage = PageRequestUtil.normalizePage(page); int safeSize = PageRequestUtil.normalizeSize(size); List pagedFacilities = careFacilityService.getAllCareFacilities(safePage, safeSize); @@ -204,11 +194,9 @@ public ResponseEntity> swaggerGetDbFacilities( } } - // 보육시설 통계 - @GetMapping("/swagger/stats") - @Operation(summary = "보육시설 통계", description = "DB에 저장된 보육시설 통계 정보를 조회합니다") + @Operation(summary = "보육시설 통계", description = "DB에 저장된 보육시설 통계 정보 조회") public ResponseEntity> swaggerGetStats() { try { log.info("보육시설 통계 조회 요청"); @@ -231,9 +219,7 @@ public ResponseEntity> swaggerGetStats() { } } - // 공공데이터로부터 새로운 CareFacility 엔티티 생성 - private CareFacility createCareFacilityFromPublicData(Map facilityData) { try { return CareFacility.builder() @@ -261,9 +247,7 @@ private CareFacility createCareFacilityFromPublicData(Map facili } } - // 기존 CareFacility 엔티티를 공공데이터로 업데이트 - private void updateCareFacilityFromPublicData(CareFacility existingFacility, Map facilityData) { try { existingFacility.setName((String) facilityData.get("facilityName")); @@ -287,9 +271,7 @@ private void updateCareFacilityFromPublicData(CareFacility existingFacility, Map } } - // 서비스 타입을 FacilityType으로 매핑 - private FacilityType mapServiceTypeToFacilityType(String serviceType) { if (serviceType == null) { return FacilityType.OTHER; @@ -309,9 +291,7 @@ private FacilityType mapServiceTypeToFacilityType(String serviceType) { } } - // 운영시간 정보 포맷팅 - private String formatOperatingHours(Map facilityData) { StringBuilder hours = new StringBuilder(); @@ -337,9 +317,7 @@ private String formatOperatingHours(Map facilityData) { return hours.toString(); } - // 시설 설명 생성 - private String generateDescription(Map facilityData) { StringBuilder description = new StringBuilder(); @@ -367,9 +345,7 @@ private String generateDescription(Map facilityData) { return description.toString(); } - // Double 파싱 헬퍼 메서드 - private Double parseDouble(Object value) { if (value == null || value.toString().trim().isEmpty()) { return null; @@ -382,9 +358,7 @@ private Double parseDouble(Object value) { } } - // Integer 파싱 헬퍼 메서드 - private Integer parseInteger(Object value) { if (value == null || value.toString().trim().isEmpty()) { return null; diff --git a/src/main/java/com/carecode/core/devtools/SampleDataRunner.java b/src/main/java/com/carecode/core/devtools/SampleDataRunner.java index e01f3422..f9121c96 100644 --- a/src/main/java/com/carecode/core/devtools/SampleDataRunner.java +++ b/src/main/java/com/carecode/core/devtools/SampleDataRunner.java @@ -8,10 +8,7 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; -/** - * 기동 시 샘플 데이터를 적재한다. - * 공공데이터 연동 전에 거주지 비교·입소 예측·인기도 분석을 확인하기 위한 개발 편의 기능이다. - */ +/** 기동 시 샘플 데이터를 적재한다. */ @Slf4j @Component @Profile("!prod") diff --git a/src/main/java/com/carecode/core/devtools/SampleFacilitySeeder.java b/src/main/java/com/carecode/core/devtools/SampleFacilitySeeder.java index 6961c7df..dba772ff 100644 --- a/src/main/java/com/carecode/core/devtools/SampleFacilitySeeder.java +++ b/src/main/java/com/carecode/core/devtools/SampleFacilitySeeder.java @@ -12,10 +12,7 @@ import java.time.LocalDate; -/** - * 입소 예측·인기도 분석을 확인하기 위한 샘플 시설과 정원 관측 이력. - * 각 시설이 서로 다른 충원 패턴을 갖도록 해서 분석 결과가 갈리는지 볼 수 있게 한다. - */ +/** 입소 예측·인기도 분석을 확인하기 위한 샘플 시설과 정원 관측 이력. 각 시설이 서로 다른 충원 패턴을 갖도록 해서 분석 결과가 갈리는지 볼 수 있게 한다. */ @Slf4j @Component @RequiredArgsConstructor diff --git a/src/main/java/com/carecode/core/devtools/SamplePolicySeeder.java b/src/main/java/com/carecode/core/devtools/SamplePolicySeeder.java index acab1aa3..0c030fa5 100644 --- a/src/main/java/com/carecode/core/devtools/SamplePolicySeeder.java +++ b/src/main/java/com/carecode/core/devtools/SamplePolicySeeder.java @@ -9,10 +9,7 @@ import java.util.List; -/** - * 거주지별 지원금 비교를 확인하기 위한 샘플 정책. - * 실제 지자체 금액이 아니라 기능 확인용 임의값이다 — 공공데이터 연동 전까지만 쓴다. - */ +/** 거주지별 지원금 비교를 확인하기 위한 샘플 정책. 실제 지자체 금액이 아니라 기능 확인용 임의값이다 — 공공데이터 연동 전까지만 쓴다. */ @Slf4j @Component @RequiredArgsConstructor diff --git a/src/main/java/com/carecode/core/exception/BusinessException.java b/src/main/java/com/carecode/core/exception/BusinessException.java index 9d6ce0b2..b23b4f4e 100644 --- a/src/main/java/com/carecode/core/exception/BusinessException.java +++ b/src/main/java/com/carecode/core/exception/BusinessException.java @@ -1,9 +1,6 @@ package com.carecode.core.exception; -/** - * 비즈니스 로직 위반 시 발생하는 예외 - * 하위 호환성을 위해 유지 - */ +/** 비즈니스 로직 위반 시 발생하는 예외 하위 호환성을 위해 유지 */ public class BusinessException extends CareCodeException { public BusinessException(String message) { diff --git a/src/main/java/com/carecode/core/exception/CareCodeException.java b/src/main/java/com/carecode/core/exception/CareCodeException.java index 99a0a721..70ab1a1f 100644 --- a/src/main/java/com/carecode/core/exception/CareCodeException.java +++ b/src/main/java/com/carecode/core/exception/CareCodeException.java @@ -3,10 +3,7 @@ import lombok.Getter; import org.springframework.http.HttpStatus; -/** - * CareCode 애플리케이션의 기본 예외 클래스 - * 모든 커스텀 예외는 이 클래스를 상속받아야 함 - */ +/** CareCode 애플리케이션의 기본 예외 */ @Getter public abstract class CareCodeException extends RuntimeException { diff --git a/src/main/java/com/carecode/core/exception/CareFacilityNotFoundException.java b/src/main/java/com/carecode/core/exception/CareFacilityNotFoundException.java index 79fcf8ec..ef2871db 100644 --- a/src/main/java/com/carecode/core/exception/CareFacilityNotFoundException.java +++ b/src/main/java/com/carecode/core/exception/CareFacilityNotFoundException.java @@ -1,9 +1,6 @@ package com.carecode.core.exception; -/** - * 돌봄 시설을 찾을 수 없을 때 발생하는 예외 - * 하위 호환성을 위해 유지 - */ +/** 돌봄 시설을 찾을 수 없을 때 발생하는 예외 하위 호환성을 위해 유지 */ public class CareFacilityNotFoundException extends CareCodeException { public CareFacilityNotFoundException(Long facilityId) { diff --git a/src/main/java/com/carecode/core/exception/CareServiceException.java b/src/main/java/com/carecode/core/exception/CareServiceException.java index e9cc30ed..c1b04e75 100644 --- a/src/main/java/com/carecode/core/exception/CareServiceException.java +++ b/src/main/java/com/carecode/core/exception/CareServiceException.java @@ -1,9 +1,6 @@ package com.carecode.core.exception; -/** - * 육아 서비스 전용 예외 클래스 - * 육아 관련 비즈니스 로직에서 발생하는 예외를 처리 - */ +/** 육아 서비스 전용 예외 클래스 육아 관련 비즈니스 로직에서 발생하는 예외를 처리 */ public class CareServiceException extends RuntimeException { private final String errorCode; diff --git a/src/main/java/com/carecode/core/exception/ChildNotFoundException.java b/src/main/java/com/carecode/core/exception/ChildNotFoundException.java index ffb15d06..7daa6296 100644 --- a/src/main/java/com/carecode/core/exception/ChildNotFoundException.java +++ b/src/main/java/com/carecode/core/exception/ChildNotFoundException.java @@ -1,8 +1,6 @@ package com.carecode.core.exception; -/** - * 아동을 찾을 수 없을 때 발생하는 예외 - */ +/** 아동을 찾을 수 없을 때 발생하는 예외 */ public class ChildNotFoundException extends CareCodeException { public ChildNotFoundException(Long childId) { diff --git a/src/main/java/com/carecode/core/exception/CommentAccessDeniedException.java b/src/main/java/com/carecode/core/exception/CommentAccessDeniedException.java index 2c3a2121..915ff21b 100644 --- a/src/main/java/com/carecode/core/exception/CommentAccessDeniedException.java +++ b/src/main/java/com/carecode/core/exception/CommentAccessDeniedException.java @@ -1,8 +1,6 @@ package com.carecode.core.exception; -/** - * 댓글에 대한 접근(수정/삭제) 권한이 없을 때 발생하는 예외 - */ +/** 댓글에 대한 접근(수정/삭제) 권한이 없을 때 발생하는 예외 */ public class CommentAccessDeniedException extends CareCodeException { public CommentAccessDeniedException() { diff --git a/src/main/java/com/carecode/core/exception/ErrorCode.java b/src/main/java/com/carecode/core/exception/ErrorCode.java index 10f9c2aa..e4abfc36 100644 --- a/src/main/java/com/carecode/core/exception/ErrorCode.java +++ b/src/main/java/com/carecode/core/exception/ErrorCode.java @@ -3,10 +3,7 @@ import lombok.Getter; import org.springframework.http.HttpStatus; -/** - * 에러 코드 정의 - * 도메인별로 그룹화하여 관리 - */ +/** 에러 코드 정의 도메인별로 그룹화하여 관리 */ @Getter public enum ErrorCode { diff --git a/src/main/java/com/carecode/core/exception/HealthRecordNotFoundException.java b/src/main/java/com/carecode/core/exception/HealthRecordNotFoundException.java index 81a266ad..7fec0f80 100644 --- a/src/main/java/com/carecode/core/exception/HealthRecordNotFoundException.java +++ b/src/main/java/com/carecode/core/exception/HealthRecordNotFoundException.java @@ -1,8 +1,6 @@ package com.carecode.core.exception; -/** - * 건강 기록을 찾을 수 없을 때 발생하는 예외 - */ +/** 건강 기록을 찾을 수 없을 때 발생하는 예외 */ public class HealthRecordNotFoundException extends CareCodeException { public HealthRecordNotFoundException(Long recordId) { diff --git a/src/main/java/com/carecode/core/exception/HospitalNotFoundException.java b/src/main/java/com/carecode/core/exception/HospitalNotFoundException.java index 78a4bb10..b7f43d97 100644 --- a/src/main/java/com/carecode/core/exception/HospitalNotFoundException.java +++ b/src/main/java/com/carecode/core/exception/HospitalNotFoundException.java @@ -1,8 +1,6 @@ package com.carecode.core.exception; -/** - * 병원을 찾을 수 없을 때 발생하는 예외 - */ +/** 병원을 찾을 수 없을 때 발생하는 예외 */ public class HospitalNotFoundException extends CareCodeException { public HospitalNotFoundException(Long hospitalId) { diff --git a/src/main/java/com/carecode/core/exception/HospitalReviewAccessDeniedException.java b/src/main/java/com/carecode/core/exception/HospitalReviewAccessDeniedException.java index 13e16b9a..8848c8e1 100644 --- a/src/main/java/com/carecode/core/exception/HospitalReviewAccessDeniedException.java +++ b/src/main/java/com/carecode/core/exception/HospitalReviewAccessDeniedException.java @@ -1,8 +1,6 @@ package com.carecode.core.exception; -/** - * 병원 리뷰에 대한 접근 권한이 없을 때 발생하는 예외 - */ +/** 병원 리뷰에 대한 접근 권한이 없을 때 발생하는 예외 */ public class HospitalReviewAccessDeniedException extends CareCodeException { public HospitalReviewAccessDeniedException() { diff --git a/src/main/java/com/carecode/core/exception/HospitalReviewNotFoundException.java b/src/main/java/com/carecode/core/exception/HospitalReviewNotFoundException.java index 665aa136..7363b877 100644 --- a/src/main/java/com/carecode/core/exception/HospitalReviewNotFoundException.java +++ b/src/main/java/com/carecode/core/exception/HospitalReviewNotFoundException.java @@ -1,8 +1,6 @@ package com.carecode.core.exception; -/** - * 병원 리뷰를 찾을 수 없을 때 발생하는 예외 - */ +/** 병원 리뷰를 찾을 수 없을 때 발생하는 예외 */ public class HospitalReviewNotFoundException extends CareCodeException { public HospitalReviewNotFoundException(Long reviewId) { diff --git a/src/main/java/com/carecode/core/exception/PolicyNotFoundException.java b/src/main/java/com/carecode/core/exception/PolicyNotFoundException.java index 38b3e997..3d03277c 100644 --- a/src/main/java/com/carecode/core/exception/PolicyNotFoundException.java +++ b/src/main/java/com/carecode/core/exception/PolicyNotFoundException.java @@ -1,9 +1,6 @@ package com.carecode.core.exception; -/** - * 육아 정책을 찾을 수 없을 때 발생하는 예외 - * 하위 호환성을 위해 유지 - */ +/** 육아 정책을 찾을 수 없을 때 발생하는 예외 하위 호환성을 위해 유지 */ public class PolicyNotFoundException extends CareCodeException { public PolicyNotFoundException(Long policyId) { diff --git a/src/main/java/com/carecode/core/exception/PostAccessDeniedException.java b/src/main/java/com/carecode/core/exception/PostAccessDeniedException.java index 52eb9ee8..3b72495f 100644 --- a/src/main/java/com/carecode/core/exception/PostAccessDeniedException.java +++ b/src/main/java/com/carecode/core/exception/PostAccessDeniedException.java @@ -1,8 +1,6 @@ package com.carecode.core.exception; -/** - * 게시글에 대한 접근(수정/삭제) 권한이 없을 때 발생하는 예외 - */ +/** 게시글에 대한 접근(수정/삭제) 권한이 없을 때 발생하는 예외 */ public class PostAccessDeniedException extends CareCodeException { public PostAccessDeniedException() { diff --git a/src/main/java/com/carecode/core/exception/RateLimitExceededException.java b/src/main/java/com/carecode/core/exception/RateLimitExceededException.java index fc11e0a9..e8a4c44b 100644 --- a/src/main/java/com/carecode/core/exception/RateLimitExceededException.java +++ b/src/main/java/com/carecode/core/exception/RateLimitExceededException.java @@ -1,8 +1,6 @@ package com.carecode.core.exception; -/** - * Rate limit 초과 시 발생하는 예외 (HTTP 429). - */ +/** Rate limit 초과 시 발생하는 예외 (HTTP 429). */ public class RateLimitExceededException extends CareCodeException { public RateLimitExceededException() { diff --git a/src/main/java/com/carecode/core/exception/ResourceNotFoundException.java b/src/main/java/com/carecode/core/exception/ResourceNotFoundException.java index 7ab24c3f..e176d284 100644 --- a/src/main/java/com/carecode/core/exception/ResourceNotFoundException.java +++ b/src/main/java/com/carecode/core/exception/ResourceNotFoundException.java @@ -1,9 +1,6 @@ package com.carecode.core.exception; -/** - * 리소스를 찾을 수 없을 때 발생하는 예외 - * 하위 호환성을 위해 유지 - */ +/** 리소스를 찾을 수 없을 때 발생하는 예외 하위 호환성을 위해 유지 */ public class ResourceNotFoundException extends CareCodeException { public ResourceNotFoundException(String resourceName) { diff --git a/src/main/java/com/carecode/core/exception/UserNotFoundException.java b/src/main/java/com/carecode/core/exception/UserNotFoundException.java index 2170e7de..06cf1f9c 100644 --- a/src/main/java/com/carecode/core/exception/UserNotFoundException.java +++ b/src/main/java/com/carecode/core/exception/UserNotFoundException.java @@ -1,9 +1,6 @@ package com.carecode.core.exception; -/** - * 사용자를 찾을 수 없을 때 발생하는 예외 - * 하위 호환성을 위해 유지 - */ +/** 사용자를 찾을 수 없을 때 발생하는 예외 하위 호환성을 위해 유지 */ public class UserNotFoundException extends CareCodeException { public UserNotFoundException(String userId) { diff --git a/src/main/java/com/carecode/core/handler/ApiResponse.java b/src/main/java/com/carecode/core/handler/ApiResponse.java index e6d516b8..c8572959 100644 --- a/src/main/java/com/carecode/core/handler/ApiResponse.java +++ b/src/main/java/com/carecode/core/handler/ApiResponse.java @@ -8,10 +8,7 @@ import java.time.LocalDateTime; -/** - * 표준화된 API 응답 래퍼 - * 모든 API 응답을 일관된 형식으로 제공 - */ +/** 표준화된 API 응답 래퍼 모든 API 응답을 일관된 형식으로 제공 */ @Getter @Builder @NoArgsConstructor @@ -24,9 +21,7 @@ public class ApiResponse { private T data; private LocalDateTime timestamp; - // 성공 응답 생성 - public static ApiResponse success(T data) { return ApiResponse.builder() .code("SUCCESS") @@ -36,9 +31,7 @@ public static ApiResponse success(T data) { .build(); } - // 성공 응답 생성 (커스텀 메시지) - public static ApiResponse success(T data, String message) { return ApiResponse.builder() .code("SUCCESS") @@ -48,9 +41,7 @@ public static ApiResponse success(T data, String message) { .build(); } - // 성공 응답 생성 (데이터 없음) - public static ApiResponse success() { return ApiResponse.builder() .code("SUCCESS") @@ -59,9 +50,7 @@ public static ApiResponse success() { .build(); } - // 실패 응답 생성 - public static ApiResponse error(String code, String message) { return ApiResponse.builder() .code(code) diff --git a/src/main/java/com/carecode/core/handler/ApiSuccess.java b/src/main/java/com/carecode/core/handler/ApiSuccess.java index 13613dff..dda0ad36 100644 --- a/src/main/java/com/carecode/core/handler/ApiSuccess.java +++ b/src/main/java/com/carecode/core/handler/ApiSuccess.java @@ -14,12 +14,8 @@ public class ApiSuccess { private Date timestamp; private String message; - - - // 간편한 ApiSuccess 객체 생성을 위한 정적 팩토리 메서드 - // @param message 성공 메시지 - // @return ApiSuccess 객체 + // 간편한 ApiSuccess 객체 생성을 위한 정적 팩토리 메서드 @param message 성공 메시지 @return ApiSuccess 객체 public static ApiSuccess of(String message) { return ApiSuccess.builder() .timestamp(new Date()) @@ -28,4 +24,3 @@ public static ApiSuccess of(String message) { } } - diff --git a/src/main/java/com/carecode/core/handler/CustomizedResponseEntityExceptionHandler.java b/src/main/java/com/carecode/core/handler/CustomizedResponseEntityExceptionHandler.java index 61b1e55e..12b2459d 100644 --- a/src/main/java/com/carecode/core/handler/CustomizedResponseEntityExceptionHandler.java +++ b/src/main/java/com/carecode/core/handler/CustomizedResponseEntityExceptionHandler.java @@ -13,17 +13,12 @@ import java.util.Map; import java.util.stream.Collectors; -/** - * 전역 예외 핸들러 - * 모든 예외를 일관된 형식으로 처리 - */ +/** 전역 예외 핸들러 모든 예외를 일관된 형식으로 처리 */ @Slf4j @RestControllerAdvice public class CustomizedResponseEntityExceptionHandler { - // CareCodeException 계층의 예외 처리 - @ExceptionHandler(CareCodeException.class) public ResponseEntity handleCareCodeException(CareCodeException ex, WebRequest request) { log.warn("CareCodeException 발생: {} - {}", ex.getErrorCode().getCode(), ex.getMessage()); @@ -39,9 +34,7 @@ public ResponseEntity handleCareCodeException(CareCodeException e .body(errorResponse); } - // UserNotFoundException 처리 (하위 호환성 유지) - @ExceptionHandler(UserNotFoundException.class) public ResponseEntity handleUserNotFoundException(UserNotFoundException ex, WebRequest request) { log.warn("UserNotFoundException 발생: {}", ex.getMessage()); @@ -57,9 +50,7 @@ public ResponseEntity handleUserNotFoundException(UserNotFoundExc .body(errorResponse); } - // ResourceNotFoundException 처리 (하위 호환성 유지) - @ExceptionHandler(ResourceNotFoundException.class) public ResponseEntity handleResourceNotFoundException(ResourceNotFoundException ex, WebRequest request) { log.warn("ResourceNotFoundException 발생: {}", ex.getMessage()); @@ -75,9 +66,7 @@ public ResponseEntity handleResourceNotFoundException(ResourceNot .body(errorResponse); } - // BusinessException 처리 (하위 호환성 유지) - @ExceptionHandler(BusinessException.class) public ResponseEntity handleBusinessException(BusinessException ex, WebRequest request) { log.warn("BusinessException 발생: {}", ex.getMessage()); @@ -93,9 +82,7 @@ public ResponseEntity handleBusinessException(BusinessException e .body(errorResponse); } - // CareServiceException 처리 (하위 호환성 유지) - @ExceptionHandler(CareServiceException.class) public ResponseEntity handleCareServiceException(CareServiceException ex, WebRequest request) { log.error("CareServiceException 발생: {} - {}", ex.getErrorCode(), ex.getMessage(), ex); @@ -129,9 +116,7 @@ private ErrorCode resolveCareServiceErrorCode(String careServiceErrorCode) { return ErrorCode.INTERNAL_SERVER_ERROR; } - // PolicyNotFoundException 처리 (하위 호환성 유지) - @ExceptionHandler(PolicyNotFoundException.class) public ResponseEntity handlePolicyNotFoundException(PolicyNotFoundException ex, WebRequest request) { log.warn("PolicyNotFoundException 발생: {}", ex.getMessage()); @@ -147,9 +132,7 @@ public ResponseEntity handlePolicyNotFoundException(PolicyNotFoun .body(errorResponse); } - // CareFacilityNotFoundException 처리 (하위 호환성 유지) - @ExceptionHandler(CareFacilityNotFoundException.class) public ResponseEntity handleCareFacilityNotFoundException(CareFacilityNotFoundException ex, WebRequest request) { log.warn("CareFacilityNotFoundException 발생: {}", ex.getMessage()); @@ -165,9 +148,7 @@ public ResponseEntity handleCareFacilityNotFoundException(CareFac .body(errorResponse); } - // Validation 예외 처리 (@Valid 실패) - @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleValidationException(MethodArgumentNotValidException ex, WebRequest request) { log.warn("Validation 실패: {}", ex.getMessage()); @@ -196,9 +177,7 @@ public ResponseEntity handleValidationException(MethodArgumentNot .body(errorResponse); } - // IllegalArgumentException 처리 - @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity handleIllegalArgumentException(IllegalArgumentException ex, WebRequest request) { log.warn("IllegalArgumentException 발생: {}", ex.getMessage()); @@ -214,9 +193,7 @@ public ResponseEntity handleIllegalArgumentException(IllegalArgum .body(errorResponse); } - // 모든 예외 처리 (최후의 수단) - @ExceptionHandler(Exception.class) public ResponseEntity handleAllExceptions(Exception ex, WebRequest request) { log.error("예상치 못한 예외 발생", ex); diff --git a/src/main/java/com/carecode/core/handler/ErrorResponse.java b/src/main/java/com/carecode/core/handler/ErrorResponse.java index 32116fd4..7cc1b6fe 100644 --- a/src/main/java/com/carecode/core/handler/ErrorResponse.java +++ b/src/main/java/com/carecode/core/handler/ErrorResponse.java @@ -8,9 +8,7 @@ import java.time.LocalDateTime; -/** - * 표준화된 에러 응답 DTO - */ +/** 표준화된 에러 응답 DTO */ @Getter @Builder @NoArgsConstructor diff --git a/src/main/java/com/carecode/core/scheduler/BookingReminderScheduler.java b/src/main/java/com/carecode/core/scheduler/BookingReminderScheduler.java index 3f584045..34e26674 100644 --- a/src/main/java/com/carecode/core/scheduler/BookingReminderScheduler.java +++ b/src/main/java/com/carecode/core/scheduler/BookingReminderScheduler.java @@ -16,9 +16,7 @@ import java.time.format.DateTimeFormatter; import java.util.List; -/** - * 시설 예약 전날 리마인더. - */ +/** 시설 예약 전날 리마인더. */ @Slf4j @Component @RequiredArgsConstructor diff --git a/src/main/java/com/carecode/core/scheduler/DataCleanupScheduler.java b/src/main/java/com/carecode/core/scheduler/DataCleanupScheduler.java index bfc5e949..945af1cd 100644 --- a/src/main/java/com/carecode/core/scheduler/DataCleanupScheduler.java +++ b/src/main/java/com/carecode/core/scheduler/DataCleanupScheduler.java @@ -9,9 +9,7 @@ import java.time.LocalDateTime; -/** - * 주기적인 데이터 정리. - */ +/** 주기적인 데이터 정리. */ @Slf4j @Component @RequiredArgsConstructor @@ -19,10 +17,7 @@ public class DataCleanupScheduler { private final EmailVerificationTokenRepository emailVerificationTokenRepository; - /** - * 만료·사용 완료된 이메일 인증 토큰 정리. 매일 새벽 4시. - * 정리하지 않으면 가입 시도마다 행이 쌓여 테이블이 무한히 커진다. - */ + /** 만료·사용 완료된 이메일 인증 토큰 정리. 매일 새벽 4시. 정리하지 않으면 가입 시도마다 행이 쌓여 테이블이 무한히 커진다. */ @Scheduled(cron = "${app.scheduler.cleanup.cron:0 0 4 * * *}", zone = "Asia/Seoul") @Transactional public void cleanupExpiredVerificationTokens() { diff --git a/src/main/java/com/carecode/core/scheduler/VaccinationReminderScheduler.java b/src/main/java/com/carecode/core/scheduler/VaccinationReminderScheduler.java index 03b27ffc..6b7cb72f 100644 --- a/src/main/java/com/carecode/core/scheduler/VaccinationReminderScheduler.java +++ b/src/main/java/com/carecode/core/scheduler/VaccinationReminderScheduler.java @@ -16,12 +16,7 @@ import java.time.LocalDate; import java.util.List; -/** - * 예방접종 사전 알림. - * - *

접종 예정일 D-{@code reminderDaysBefore} 구간에 들어온 일정을 찾아 보호자에게 알린다. - * 이미 알림을 보낸 일정은 {@code reminderSentAt} 으로 걸러 중복 발송하지 않는다. - */ +/** 예방접종 사전 알림. 접종 예정일 D-reminderDaysBefore 구간에 들어온 일정을 찾아 보호자에게 알린다 */ @Slf4j @Component @RequiredArgsConstructor diff --git a/src/main/java/com/carecode/core/security/CurrentUserFacade.java b/src/main/java/com/carecode/core/security/CurrentUserFacade.java index 6e68f3ed..b048dafe 100644 --- a/src/main/java/com/carecode/core/security/CurrentUserFacade.java +++ b/src/main/java/com/carecode/core/security/CurrentUserFacade.java @@ -10,10 +10,7 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; -/** - * Resolves the authenticated user from {@link SecurityContextHolder} and the persistence layer. - * Keeps controllers free of repeated SecurityContext + repository lookups. - */ +/** Resolves the authenticated user from SecurityContextHolder and the persistence layer */ @Component @RequiredArgsConstructor public class CurrentUserFacade { diff --git a/src/main/java/com/carecode/core/security/CustomUserDetailsService.java b/src/main/java/com/carecode/core/security/CustomUserDetailsService.java index b81f7b70..7e35d185 100644 --- a/src/main/java/com/carecode/core/security/CustomUserDetailsService.java +++ b/src/main/java/com/carecode/core/security/CustomUserDetailsService.java @@ -8,13 +8,7 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; -/** - * {@link SecurityConfig}에서 등록하는 {@link UserDetailsService}. - * Stateless JWT API가 기본이며, 이 빈은 필터 체인의 {@code userDetailsService()} 연동 및 - * 향후 폼/세션 인증 확장 시를 위한 이메일·비밀번호 조회용이다. - * 소셜 전용 계정({@code password == null})은 로드 시 비밀번호가 없을 수 있으므로 - * 폼 로그인 경로에서는 별도 검증이 필요하다. - */ +/** SecurityConfig에서 등록하는 UserDetailsService. Stateless JWT API가 기본이며 */ @Service @RequiredArgsConstructor public class CustomUserDetailsService implements UserDetailsService { diff --git a/src/main/java/com/carecode/core/security/JwtAuthenticationFilter.java b/src/main/java/com/carecode/core/security/JwtAuthenticationFilter.java index 17b3cc9c..7b7db105 100644 --- a/src/main/java/com/carecode/core/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/carecode/core/security/JwtAuthenticationFilter.java @@ -17,10 +17,7 @@ import java.io.IOException; import java.util.Collections; -/** - * JWT 인증 필터 - * 요청에서 JWT 토큰을 추출하고 검증하여 인증 정보를 설정 - */ +/** JWT 인증 필터 요청에서 JWT 토큰을 추출하고 검증하여 인증 정보를 설정 */ @Slf4j @Component @RequiredArgsConstructor @@ -85,9 +82,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterChain.doFilter(request, response); } - // 요청에서 JWT 토큰 추출 - private String extractTokenFromRequest(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); @@ -103,9 +98,7 @@ private String extractTokenFromRequest(HttpServletRequest request) { protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { String path = request.getRequestURI(); - // 다음 경로들은 JWT 인증을 건너뜀. - // 주의: /admin 은 여기서 제외하면 안 된다. 세션 기반 어드민 체인(SecurityConfig 참고)이 - // 별도로 처리하며, 과거처럼 스킵하면 /admin/** 이 인증 주체 없이 항상 403 이 된다. + // 다음 경로들은 JWT 인증을 건너뜀. 주의: /admin 은 여기서 제외하면 안 된다. 세션 기반 어드민 체인(SecurityConfig 참고)이 별도로 처리하며 boolean shouldNotFilter = path.startsWith("/swagger-ui") || path.startsWith("/api-docs") || path.startsWith("/v3/api-docs") || diff --git a/src/main/java/com/carecode/core/security/RefreshTokenCookieFactory.java b/src/main/java/com/carecode/core/security/RefreshTokenCookieFactory.java index 87bebe41..76fb05b4 100644 --- a/src/main/java/com/carecode/core/security/RefreshTokenCookieFactory.java +++ b/src/main/java/com/carecode/core/security/RefreshTokenCookieFactory.java @@ -10,17 +10,7 @@ import java.util.Arrays; import java.util.Optional; -/** - * 리프레시 토큰을 HttpOnly 쿠키로 주고받기 위한 헬퍼. - * - *

리프레시 토큰을 응답 본문으로만 내리면 클라이언트가 JS 로 접근 가능한 저장소 - * (localStorage 등)에 둘 수밖에 없어 XSS 한 번에 세션 전체가 탈취된다. - * HttpOnly 쿠키로 내려 스크립트가 읽지 못하게 하고, 경로를 {@code /auth} 로 좁혀 - * 일반 API 요청에는 실려 나가지 않도록 한다. - * - *

본문 응답도 당분간 유지한다. 쿠키를 쓸 수 없는 클라이언트(모바일 네이티브 등)와 - * 기존 웹 클라이언트가 함께 동작해야 하기 때문이다. - */ +/** 리프레시 토큰을 HttpOnly 쿠키로 주고받기 위한 헬퍼. 리프레시 토큰을 응답 본문으로만 내리면 클라이언트가 JS 로 접근 가능한 저장소 (localStorage */ @Component public class RefreshTokenCookieFactory { diff --git a/src/main/java/com/carecode/core/security/SecurityConfig.java b/src/main/java/com/carecode/core/security/SecurityConfig.java index 14f2134d..a39472b4 100644 --- a/src/main/java/com/carecode/core/security/SecurityConfig.java +++ b/src/main/java/com/carecode/core/security/SecurityConfig.java @@ -22,9 +22,7 @@ import java.util.Arrays; import java.util.List; -/** - * Spring Security 설정 - */ +/** Spring Security 설정 */ @Slf4j @Configuration @EnableWebSecurity @@ -49,12 +47,7 @@ public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, .toList(); } - /** - * 전체 API 체인. - *

어드민도 동일한 JWT 인증을 사용하고 {@code /api/admin/**} 에서 ADMIN 역할로 구분한다. - * (과거에는 세션 기반 어드민 화면이 별도로 있었으나, 템플릿 엔진이 없어 렌더링 자체가 불가능했고 - * 컨트롤러가 STATELESS 정책 아래에서 SecurityContextHolder 를 직접 조작해 항상 403 이었다.) - */ + /** 전체 API 체인. 어드민도 동일한 JWT 인증을 사용하고 /api/admin/** 에서 ADMIN 역할로 구분한다 */ @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http @@ -110,7 +103,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/auth/kakao/login-url").permitAll() // 카카오 로그인 URL 생성 .requestMatchers("/auth/kakao/complete-registration").permitAll() // 카카오 가입 완료 - // OAuth2 authorize/token (Spring Client beans). Kakao REST login uses KakaoUtil + /auth/kakao/login only; oauth2Login() is not configured. + // OAuth2 authorize/token (Spring Client beans). .requestMatchers("/oauth2/**").permitAll() .requestMatchers("/kakao-callback.html").permitAll() @@ -147,8 +140,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.POST, "/hospitals/*/like").authenticated() .requestMatchers(HttpMethod.DELETE, "/hospitals/*/like").authenticated() - // 정책 API: 개인화·북마크는 인증 필요, 나머지 조회는 공개 - // 아래 /policies/* 와일드카드보다 먼저 선언해야 적용된다. + // 정책 API: 개인화·북마크는 인증 필요, 나머지 조회는 공개 아래 /policies/* 와일드카드보다 먼저 선언해야 적용된다. .requestMatchers("/policies/recommendations").authenticated() .requestMatchers("/policies/missed-benefits").authenticated() .requestMatchers("/policies/regional-comparison").authenticated() @@ -198,11 +190,7 @@ public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } - /** - * {@code @Component} 로 선언된 필터는 Boot 가 서블릿 컨테이너에 자동 등록한다. - * 그대로 두면 어드민 체인에서도 JWT 필터가 돌면서 세션으로 복원된 인증을 지운다. - * 필터 체인 등록은 {@link #filterChain(HttpSecurity)} 에서만 이뤄지도록 자동 등록을 끈다. - */ + /** @Component 로 선언된 필터는 Boot 가 서블릿 컨테이너에 자동 등록한다. */ @Bean public FilterRegistrationBean disableJwtFilterAutoRegistration( JwtAuthenticationFilter filter) { diff --git a/src/main/java/com/carecode/core/storage/FileStorageService.java b/src/main/java/com/carecode/core/storage/FileStorageService.java index 479799d1..2ffbf40e 100644 --- a/src/main/java/com/carecode/core/storage/FileStorageService.java +++ b/src/main/java/com/carecode/core/storage/FileStorageService.java @@ -2,24 +2,12 @@ import org.springframework.web.multipart.MultipartFile; -/** - * 파일 저장소 추상화. - * - *

구현체를 바꾸면 로컬 디스크 ↔ S3 전환이 가능하도록 도메인 코드는 이 인터페이스에만 의존한다. - */ +/** 파일 저장소 추상화. 구현체를 바꾸면 로컬 디스크 ↔ S3 전환이 가능하도록 도메인 코드는 이 인터페이스에만 의존한다. */ public interface FileStorageService { - /** - * 파일을 저장한다. - * - * @param file 업로드된 파일 - * @param directory 저장소 내 논리 디렉터리 (예: {@code health-records}, {@code profiles}) - * @return 저장 결과 메타데이터 - */ + /** 파일을 저장한다. */ StoredFile store(MultipartFile file, String directory); - /** - * 저장된 파일을 삭제한다. 없는 키를 지워도 예외를 던지지 않는다. - */ + /** 저장된 파일을 삭제한다. 없는 키를 지워도 예외를 던지지 않는다. */ void delete(String key); } diff --git a/src/main/java/com/carecode/core/storage/LocalFileStorageService.java b/src/main/java/com/carecode/core/storage/LocalFileStorageService.java index af8f62bf..d7f9353e 100644 --- a/src/main/java/com/carecode/core/storage/LocalFileStorageService.java +++ b/src/main/java/com/carecode/core/storage/LocalFileStorageService.java @@ -17,12 +17,7 @@ import java.util.Set; import java.util.UUID; -/** - * 로컬 디스크 기반 파일 저장소. - * - *

단일 인스턴스 배포를 전제로 한다. 다중 인스턴스로 확장할 때는 - * 같은 인터페이스로 S3 구현체를 만들어 교체한다. - */ +/** 로컬 디스크 기반 파일 저장소. 단일 인스턴스 배포를 전제로 한다. */ @Slf4j @Service public class LocalFileStorageService implements FileStorageService { diff --git a/src/main/java/com/carecode/core/storage/StoredFile.java b/src/main/java/com/carecode/core/storage/StoredFile.java index f3cc81e0..e31c71b2 100644 --- a/src/main/java/com/carecode/core/storage/StoredFile.java +++ b/src/main/java/com/carecode/core/storage/StoredFile.java @@ -3,9 +3,7 @@ import lombok.Builder; import lombok.Getter; -/** - * 저장된 파일의 메타데이터. - */ +/** 저장된 파일의 메타데이터. */ @Getter @Builder public class StoredFile { diff --git a/src/main/java/com/carecode/core/util/ChildAgeUtil.java b/src/main/java/com/carecode/core/util/ChildAgeUtil.java index d72050af..b192d8ad 100644 --- a/src/main/java/com/carecode/core/util/ChildAgeUtil.java +++ b/src/main/java/com/carecode/core/util/ChildAgeUtil.java @@ -3,10 +3,7 @@ import java.time.LocalDate; import java.time.Period; -/** - * 자녀 연령 관련 유틸리티 클래스 - * 육아 서비스에서 연령별 맞춤 정보 제공에 활용 - */ +/** 자녀 연령 관련 유틸리티 클래스 육아 서비스에서 연령별 맞춤 정보 제공에 활용 */ public class ChildAgeUtil { // 생년월일로부터 만 나이 계산 @@ -66,7 +63,6 @@ public static boolean isDaycareEligible(int age) { return age >= 0 && age <= 5; } - // 초등학교 대상 연령인지 확인 public static boolean isElementaryEligible(int age) { return age >= 6 && age <= 12; diff --git a/src/main/java/com/carecode/core/util/ClientIpResolver.java b/src/main/java/com/carecode/core/util/ClientIpResolver.java index 3ec4d5c4..a1ecfc8e 100644 --- a/src/main/java/com/carecode/core/util/ClientIpResolver.java +++ b/src/main/java/com/carecode/core/util/ClientIpResolver.java @@ -5,16 +5,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -/** - * 클라이언트 IP 해석기. - * - *

{@code X-Forwarded-For} 는 클라이언트가 임의로 붙일 수 있는 헤더다. - * 신뢰할 수 있는 리버스 프록시 뒤에 있지 않은데 이 헤더를 그대로 쓰면 - * 헤더 값만 바꿔가며 IP 기반 rate limit 을 무한히 우회할 수 있다. - * - *

그래서 {@code app.rate-limit.trust-forwarded-headers=true} 인 경우에만 - * 프록시 헤더를 사용하고, 기본값은 TCP 연결의 실제 원격 주소를 쓴다. - */ +/** 클라이언트 IP 해석기. X-Forwarded-For 는 클라이언트가 임의로 붙일 수 있는 헤더다 */ @Slf4j @Component public class ClientIpResolver { diff --git a/src/main/java/com/carecode/core/util/CommonUtil.java b/src/main/java/com/carecode/core/util/CommonUtil.java index 8994d1e7..c9420ae3 100644 --- a/src/main/java/com/carecode/core/util/CommonUtil.java +++ b/src/main/java/com/carecode/core/util/CommonUtil.java @@ -2,12 +2,7 @@ public class CommonUtil { - /** - * Object를 Double로 안전하게 파싱 - * - * @param value 파싱할 값 - * @return 파싱된 Double 값, 실패 시 null - */ + /** Object를 Double로 안전하게 파싱 */ public static Double parseDouble(Object value) { if (value == null || value.toString().trim().isEmpty()) { return null; @@ -20,12 +15,7 @@ public static Double parseDouble(Object value) { } } - /** - * Object를 Integer로 안전하게 파싱 - * - * @param value 파싱할 값 - * @return 파싱된 Integer 값, 실패 시 null - */ + /** Object를 Integer로 안전하게 파싱 */ public static Integer parseInteger(Object value) { if (value == null || value.toString().trim().isEmpty()) { return null; diff --git a/src/main/java/com/carecode/core/util/KakaoUserInfoExtractor.java b/src/main/java/com/carecode/core/util/KakaoUserInfoExtractor.java index 2de0bea6..6cc8ff2c 100644 --- a/src/main/java/com/carecode/core/util/KakaoUserInfoExtractor.java +++ b/src/main/java/com/carecode/core/util/KakaoUserInfoExtractor.java @@ -5,9 +5,7 @@ import java.util.Map; -/** - * 카카오 사용자 정보 추출 유틸리티 - */ +/** 카카오 사용자 정보 추출 유틸리티 */ @Slf4j @Component public class KakaoUserInfoExtractor { @@ -38,7 +36,6 @@ public String extractProfileImageUrl(Map attributes) { return null; } - // 카카오 사용자 정보에서 ID 추출 public String extractKakaoId(Map attributes) { try { diff --git a/src/main/java/com/carecode/core/util/KakaoUtil.java b/src/main/java/com/carecode/core/util/KakaoUtil.java index 1d194063..4ec50358 100644 --- a/src/main/java/com/carecode/core/util/KakaoUtil.java +++ b/src/main/java/com/carecode/core/util/KakaoUtil.java @@ -20,13 +20,7 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -/** - * 카카오 OAuth2 authorization_code 토큰 교환 및 프로필 조회. - *

- * 클라이언트 자격 증명은 {@code spring.security.oauth2.client.registration.kakao}에서 읽으며, - * Spring Security의 OAuth2 Login 필터 체인은 사용하지 않습니다({@code SecurityConfig}에 {@code oauth2Login()} 없음). - * 프론트엔드가 받은 {@code code}를 {@code /auth/kakao/login}으로 전달하는 REST 플로우만 활성화되어 있습니다. - */ +/** 카카오 OAuth2 authorization_code 토큰 교환 및 프로필 조회 */ @Component @Slf4j public class KakaoUtil { @@ -49,9 +43,7 @@ public class KakaoUtil { private final ObjectMapper objectMapper = new ObjectMapper() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - /** - * 카카오 로그인 동의 화면 URL (프론트 리다이렉트용). {@link #redirectUri}는 카카오 개발자 콘솔에 등록된 값과 동일해야 합니다. - */ + /** 카카오 로그인 동의 화면 URL (프론트 리다이렉트용). #redirectUri는 카카오 개발자 콘솔에 등록된 값과 동일해야 합니다. */ public String buildAuthorizationUrl() { try { return String.format( @@ -65,7 +57,6 @@ public String buildAuthorizationUrl() { } // 카카오 액세스 토큰 요청 - public KakaoOAuthToken requestToken(String accessCode) { if (accessCode == null || accessCode.trim().isEmpty()) { throw new IllegalArgumentException("인증 코드가 비어있습니다."); @@ -124,9 +115,7 @@ public KakaoOAuthToken requestToken(String accessCode) { } } - // 카카오 사용자 프로필 요청 - public KakaoProfile requestProfile(KakaoOAuthToken oAuthToken) { if (oAuthToken == null || oAuthToken.getAccess_token() == null) { throw new IllegalArgumentException("유효하지 않은 OAuth 토큰입니다."); diff --git a/src/main/java/com/carecode/core/util/LocationUtil.java b/src/main/java/com/carecode/core/util/LocationUtil.java index a28d5395..2140f7e1 100644 --- a/src/main/java/com/carecode/core/util/LocationUtil.java +++ b/src/main/java/com/carecode/core/util/LocationUtil.java @@ -2,18 +2,13 @@ import lombok.extern.slf4j.Slf4j; -/** - * 위치 관련 유틸리티 클래스 - * 육아 서비스에서 위치 기반 검색 및 거리 계산에 활용 - */ +/** 위치 관련 유틸리티 클래스 육아 서비스에서 위치 기반 검색 및 거리 계산에 활용 */ @Slf4j public class LocationUtil { private static final double EARTH_RADIUS = 6371; // 지구 반지름 (km) - // 두 지점 간의 거리를 계산 (Haversine 공식) - public static double calculateDistance(double lat1, double lon1, double lat2, double lon2) { double dLat = Math.toRadians(lat2 - lat1); double dLon = Math.toRadians(lon2 - lon1); @@ -27,26 +22,20 @@ public static double calculateDistance(double lat1, double lon1, double lat2, do return EARTH_RADIUS * c; } - // 주어진 반경 내에 있는지 확인 - public static boolean isWithinRadius(double centerLat, double centerLon, double targetLat, double targetLon, double radiusKm) { double distance = calculateDistance(centerLat, centerLon, targetLat, targetLon); return distance <= radiusKm; } - // 위도/경도 유효성 검사 - public static boolean isValidCoordinates(double latitude, double longitude) { return latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180; } - // 지역 코드를 시/도로 변환 - public static String getRegionFromCode(String regionCode) { if (regionCode == null || regionCode.length() < 2) { return "알 수 없음"; diff --git a/src/main/java/com/carecode/core/util/LoggingUtil.java b/src/main/java/com/carecode/core/util/LoggingUtil.java index 44a5b2d9..e6a4ef55 100644 --- a/src/main/java/com/carecode/core/util/LoggingUtil.java +++ b/src/main/java/com/carecode/core/util/LoggingUtil.java @@ -5,20 +5,15 @@ import java.util.UUID; -/** - * 로깅 유틸리티 - * MDC를 활용한 구조화된 로깅 지원 - */ +/** 로깅 유틸리티 MDC를 활용한 구조화된 로깅 지원 */ public class LoggingUtil { private static final String TRACE_ID_KEY = "traceId"; private static final String USER_ID_KEY = "userId"; private static final String CHILD_ID_KEY = "childId"; private static final String REQUEST_ID_KEY = "requestId"; - // 트레이스 ID 설정 (요청 추적용) - public static void setTraceId(String traceId) { if (StringUtils.hasText(traceId)) { MDC.put(TRACE_ID_KEY, traceId); @@ -26,58 +21,44 @@ public static void setTraceId(String traceId) { MDC.put(TRACE_ID_KEY, generateTraceId()); } } - // 새로운 트레이스 ID 생성 - public static String generateTraceId() { return UUID.randomUUID().toString().substring(0, 8); } - // 사용자 ID 설정 - public static void setUserId(String userId) { if (StringUtils.hasText(userId)) { MDC.put(USER_ID_KEY, userId); } } - // 아동 ID 설정 - public static void setChildId(String childId) { if (StringUtils.hasText(childId)) { MDC.put(CHILD_ID_KEY, childId); } } - // 요청 ID 설정 - public static void setRequestId(String requestId) { if (StringUtils.hasText(requestId)) { MDC.put(REQUEST_ID_KEY, requestId); } } - // 모든 MDC 컨텍스트 초기화 - public static void clear() { MDC.clear(); } - // 현재 트레이스 ID 조회 - public static String getTraceId() { return MDC.get(TRACE_ID_KEY); } - // 현재 사용자 ID 조회 - public static String getUserId() { return MDC.get(USER_ID_KEY); } diff --git a/src/main/java/com/carecode/core/util/PageRequestUtil.java b/src/main/java/com/carecode/core/util/PageRequestUtil.java index 5fa5239b..f4808b3f 100644 --- a/src/main/java/com/carecode/core/util/PageRequestUtil.java +++ b/src/main/java/com/carecode/core/util/PageRequestUtil.java @@ -1,11 +1,6 @@ package com.carecode.core.util; -/** - * 목록 API 의 페이지 파라미터를 안전한 범위로 보정한다. - * - *

클라이언트가 {@code size=1000000} 같은 값을 보내면 페이징이 무의미해지므로 - * 상한을 강제한다. - */ +/** 목록 API 의 페이지 파라미터를 안전한 범위로 보정한다. */ public final class PageRequestUtil { /** 페이지 파라미터가 없을 때 사용할 기본 크기. */ diff --git a/src/main/java/com/carecode/core/util/PolicyUtil.java b/src/main/java/com/carecode/core/util/PolicyUtil.java index 991894b5..73c7cc4d 100644 --- a/src/main/java/com/carecode/core/util/PolicyUtil.java +++ b/src/main/java/com/carecode/core/util/PolicyUtil.java @@ -3,16 +3,12 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; -/** - * 육아 정책 관련 유틸리티 클래스 - */ +/** 육아 정책 관련 유틸리티 클래스 */ public class PolicyUtil { private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); - // 정책 신청 기간이 유효한지 확인 - public static boolean isApplicationPeriodValid(LocalDate startDate, LocalDate endDate) { if (startDate == null || endDate == null) { return false; @@ -22,9 +18,7 @@ public static boolean isApplicationPeriodValid(LocalDate startDate, LocalDate en return !today.isBefore(startDate) && !today.isAfter(endDate); } - // 정책 신청 기간이 남았는지 확인 - public static boolean isApplicationPeriodRemaining(LocalDate endDate) { if (endDate == null) { return false; @@ -33,9 +27,7 @@ public static boolean isApplicationPeriodRemaining(LocalDate endDate) { return LocalDate.now().isBefore(endDate); } - // 정책 신청 기간까지 남은 일수 계산 - public static long getRemainingDays(LocalDate endDate) { if (endDate == null) { return 0; @@ -49,9 +41,7 @@ public static long getRemainingDays(LocalDate endDate) { return java.time.temporal.ChronoUnit.DAYS.between(today, endDate); } - // 정책 유형 분류 - public static String getPolicyType(String policyCode) { if (policyCode == null) { return "기타"; @@ -72,9 +62,7 @@ public static String getPolicyType(String policyCode) { } } - // 정책 우선순위 계산 - public static int calculatePriority(String policyType, int childAge, String region) { int priority = 0; @@ -106,9 +94,7 @@ public static int calculatePriority(String policyType, int childAge, String regi return priority; } - // 정책 상태 확인 - public static String getPolicyStatus(LocalDate startDate, LocalDate endDate) { LocalDate today = LocalDate.now(); diff --git a/src/main/java/com/carecode/core/util/RequestMapper.java b/src/main/java/com/carecode/core/util/RequestMapper.java index 7ad5cbe3..39e5bf05 100644 --- a/src/main/java/com/carecode/core/util/RequestMapper.java +++ b/src/main/java/com/carecode/core/util/RequestMapper.java @@ -1,10 +1,7 @@ package com.carecode.core.util; -/** - * 공통 요청 매퍼 인터페이스 - */ +/** 공통 요청 매퍼 인터페이스 */ public interface RequestMapper { E toEntity(RQ request); } - diff --git a/src/main/java/com/carecode/core/util/ResponseMapper.java b/src/main/java/com/carecode/core/util/ResponseMapper.java index 4dd65cab..d2723b72 100644 --- a/src/main/java/com/carecode/core/util/ResponseMapper.java +++ b/src/main/java/com/carecode/core/util/ResponseMapper.java @@ -1,10 +1,7 @@ package com.carecode.core.util; -/** - * 공통 응답 매퍼 인터페이스 - */ +/** 공통 응답 매퍼 인터페이스 */ public interface ResponseMapper { R toResponse(E entity); } - diff --git a/src/main/java/com/carecode/core/util/SortUtil.java b/src/main/java/com/carecode/core/util/SortUtil.java index 7d3c2adb..adc09a89 100644 --- a/src/main/java/com/carecode/core/util/SortUtil.java +++ b/src/main/java/com/carecode/core/util/SortUtil.java @@ -2,21 +2,10 @@ import org.springframework.data.domain.Sort; -/** - * 정렬 유틸리티 클래스 - * 동적 정렬 옵션을 생성하는 헬퍼 메서드 제공 - */ +/** 정렬 유틸리티 클래스 동적 정렬 옵션을 생성하는 헬퍼 메서드 제공 */ public class SortUtil { - /** - * 정렬 필드와 방향으로 Sort 객체 생성 - * - * @param sortBy 정렬 필드 (null이면 기본값 사용) - * @param sortDirection 정렬 방향 (ASC, DESC, null이면 기본값 사용) - * @param defaultField 기본 정렬 필드 - * @param defaultDirection 기본 정렬 방향 - * @return Sort 객체 - */ + /** 정렬 필드와 방향으로 Sort 객체 생성 */ public static Sort createSort(String sortBy, String sortDirection, String defaultField, Sort.Direction defaultDirection) { String field = (sortBy != null && !sortBy.trim().isEmpty()) ? sortBy : defaultField; Sort.Direction direction = parseDirection(sortDirection, defaultDirection); @@ -24,15 +13,7 @@ public static Sort createSort(String sortBy, String sortDirection, String defaul return Sort.by(direction, field); } - /** - * 여러 필드로 정렬하는 Sort 객체 생성 - * - * @param sortBy 정렬 필드 (쉼표로 구분된 여러 필드 가능) - * @param sortDirection 정렬 방향 - * @param defaultFields 기본 정렬 필드 배열 - * @param defaultDirection 기본 정렬 방향 - * @return Sort 객체 - */ + /** 여러 필드로 정렬하는 Sort 객체 생성 */ public static Sort createMultiSort(String sortBy, String sortDirection, String[] defaultFields, Sort.Direction defaultDirection) { Sort.Direction direction = parseDirection(sortDirection, defaultDirection); @@ -52,13 +33,7 @@ public static Sort createMultiSort(String sortBy, String sortDirection, String[] } } - /** - * 정렬 방향 문자열을 Sort.Direction으로 변환 - * - * @param sortDirection 정렬 방향 문자열 (ASC, DESC, asc, desc) - * @param defaultDirection 기본 정렬 방향 - * @return Sort.Direction - */ + /** 정렬 방향 문자열을 Sort.Direction으로 변환 */ public static Sort.Direction parseDirection(String sortDirection, Sort.Direction defaultDirection) { if (sortDirection == null || sortDirection.trim().isEmpty()) { return defaultDirection; @@ -74,13 +49,7 @@ public static Sort.Direction parseDirection(String sortDirection, Sort.Direction } } - /** - * 허용된 정렬 필드인지 검증 - * - * @param sortBy 정렬 필드 - * @param allowedFields 허용된 필드 목록 - * @return 검증 통과 여부 - */ + /** 허용된 정렬 필드인지 검증 */ public static boolean isValidSortField(String sortBy, String... allowedFields) { if (sortBy == null || sortBy.trim().isEmpty()) { return true; // null은 기본값 사용을 의미하므로 허용 diff --git a/src/main/java/com/carecode/core/util/ValidationUtil.java b/src/main/java/com/carecode/core/util/ValidationUtil.java index 51f45c9d..61469545 100644 --- a/src/main/java/com/carecode/core/util/ValidationUtil.java +++ b/src/main/java/com/carecode/core/util/ValidationUtil.java @@ -9,23 +9,14 @@ import java.util.Set; import java.util.stream.Collectors; -/** - * 공통 검증 유틸리티 클래스 - * DTO 검증을 일관되게 처리합니다. - */ +/** 공통 검증 유틸리티 클래스 DTO 검증을 일관되게 처리합니다. */ @Component @RequiredArgsConstructor public class ValidationUtil { private final Validator validator; - - // 객체를 검증하고 위반 사항이 있으면 예외를 발생시킵니다. - // - // @param object 검증할 객체 - // @param 객체 타입 - // @throws BusinessException 검증 실패 시 - + // 객체를 검증하고 위반 사항이 있으면 예외를 발생시킵니다. @param object 검증할 객체 @param 객체 타입 @throws public void validate(T object) { Set> violations = validator.validate(object); @@ -37,15 +28,7 @@ public void validate(T object) { } } - - // 객체를 검증하고 위반 사항이 있으면 예외를 발생시킵니다. - // 커스텀 메시지를 포함합니다. - // - // @param object 검증할 객체 - // @param message 커스텀 에러 메시지 - // @param 객체 타입 - // @throws BusinessException 검증 실패 시 - + // 객체를 검증하고 위반 사항이 있으면 예외를 발생시킵니다. 커스텀 메시지를 포함합니다. public void validate(T object, String message) { Set> violations = validator.validate(object); @@ -57,14 +40,7 @@ public void validate(T object, String message) { } } - - // 특정 그룹으로 객체를 검증합니다. - // - // @param object 검증할 객체 - // @param groups 검증 그룹 - // @param 객체 타입 - // @throws BusinessException 검증 실패 시 - + // 특정 그룹으로 객체를 검증합니다. @param object 검증할 객체 @param groups 검증 그룹 @param 객체 타입 @throws public void validate(T object, Class... groups) { Set> violations = validator.validate(object, groups); @@ -76,13 +52,7 @@ public void validate(T object, Class... groups) { } } - - // 검증 결과를 반환합니다 (예외 발생 없이). - // - // @param object 검증할 객체 - // @param 객체 타입 - // @return 검증 실패 메시지 목록 (비어있으면 검증 통과) - + // 검증 결과를 반환합니다 (예외 발생 없이). @param object 검증할 객체 @param 객체 타입 @return 검증 실패 메시지 목록 (비어있으면 검증 public Set validateAndGetErrors(T object) { Set> violations = validator.validate(object); return violations.stream() diff --git a/src/main/java/com/carecode/docs/ApiDocumentationGenerator.java b/src/main/java/com/carecode/docs/ApiDocumentationGenerator.java index 325caddc..ce436832 100644 --- a/src/main/java/com/carecode/docs/ApiDocumentationGenerator.java +++ b/src/main/java/com/carecode/docs/ApiDocumentationGenerator.java @@ -10,20 +10,12 @@ import java.nio.file.Paths; import java.util.*; -/** - * API 문서 생성기 - * - * 기능: - * 1. Swagger JSON에서 상세한 API 문서 생성 - * 2. 파라미터, 요청/응답 예제, 데이터 모델 정보 포함 - * 3. 태그별 그룹화 및 구조화된 문서 생성 - */ +/** API 문서 생성기 기능: 1. Swagger JSON에서 상세한 API 문서 생성 2. 파라미터, 요청/응답 예제, 데이터 모델 정보 포함 3 */ @Component public class ApiDocumentationGenerator { private static final ObjectMapper objectMapper = new ObjectMapper(); // 메인 메서드 - 독립 실행용 - public static void main(String[] args) { if (args.length != 2) { System.err.println("사용법: java ApiDocumentationGenerator "); @@ -48,9 +40,7 @@ public static void main(String[] args) { } } - // 상세한 API 문서 생성 - public void generateDetailedDocumentation(String swaggerUrl, String outputPath) throws IOException { // Swagger JSON 다운로드 JsonNode swaggerJson = objectMapper.readTree(new URL(swaggerUrl)); @@ -62,9 +52,7 @@ public void generateDetailedDocumentation(String swaggerUrl, String outputPath) Files.write(Paths.get(outputPath), asciiDoc.getBytes()); } - // Swagger JSON을 파싱하여 상세한 AsciiDoc 생성 - private String generateDetailedAsciiDoc(JsonNode swaggerJson) { StringBuilder asciiDoc = new StringBuilder(); @@ -99,9 +87,7 @@ private String generateDetailedAsciiDoc(JsonNode swaggerJson) { return asciiDoc.toString(); } - // API 정보 생성 - private void generateApiInfo(StringBuilder asciiDoc, JsonNode swaggerJson) { asciiDoc.append("=== API 정보\n\n"); @@ -121,9 +107,7 @@ private void generateApiInfo(StringBuilder asciiDoc, JsonNode swaggerJson) { asciiDoc.append("* **인증**: JWT Bearer Token\n\n"); } - // 인증 정보 생성 - private void generateAuthenticationInfo(StringBuilder asciiDoc) { asciiDoc.append("== 인증\n\n"); asciiDoc.append("=== JWT 토큰\n\n"); @@ -134,9 +118,7 @@ private void generateAuthenticationInfo(StringBuilder asciiDoc) { asciiDoc.append("----\n\n"); } - // API 엔드포인트 생성 - private void generateApiEndpoints(StringBuilder asciiDoc, JsonNode swaggerJson) { asciiDoc.append("== API 엔드포인트\n\n"); @@ -168,9 +150,7 @@ private void generateApiEndpoints(StringBuilder asciiDoc, JsonNode swaggerJson) } } - // 데이터 모델 생성 - private void generateDataModels(StringBuilder asciiDoc, JsonNode swaggerJson) { asciiDoc.append("== 데이터 모델\n\n"); @@ -189,9 +169,7 @@ private void generateDataModels(StringBuilder asciiDoc, JsonNode swaggerJson) { } } - // 응답 코드 생성 - private void generateResponseCodes(StringBuilder asciiDoc) { asciiDoc.append("== 응답 코드\n\n"); asciiDoc.append("|코드|설명|\n"); @@ -205,9 +183,7 @@ private void generateResponseCodes(StringBuilder asciiDoc) { asciiDoc.append("|500|서버 오류|\n\n"); } - // 엔드포인트를 태그별로 그룹화 - private Map>> groupEndpointsByTag(JsonNode paths) { Map>> grouped = new LinkedHashMap<>(); @@ -236,9 +212,7 @@ private Map>> groupEndpointsByTag(JsonN return grouped; } - // 상세한 엔드포인트 문서 생성 - private void generateDetailedEndpointDocumentation(StringBuilder asciiDoc, String path, String method, JsonNode methodNode) { // 요약 if (methodNode.has("summary")) { @@ -260,9 +234,7 @@ private void generateDetailedEndpointDocumentation(StringBuilder asciiDoc, Strin generateResponseInfo(asciiDoc, methodNode); } - // HTTP 요청 예제 생성 - private void generateHttpRequestExample(StringBuilder asciiDoc, String path, String method, JsonNode methodNode) { asciiDoc.append("[source,http]\n"); asciiDoc.append("----\n"); @@ -282,9 +254,7 @@ private void generateHttpRequestExample(StringBuilder asciiDoc, String path, Str asciiDoc.append("----\n\n"); } - // 파라미터 정보 생성 - private void generateParameterInfo(StringBuilder asciiDoc, JsonNode methodNode) { if (methodNode.has("parameters") && methodNode.get("parameters").isArray()) { asciiDoc.append("**파라미터:**\n\n"); @@ -319,9 +289,7 @@ private void generateParameterInfo(StringBuilder asciiDoc, JsonNode methodNode) } } - // 응답 정보 생성 - private void generateResponseInfo(StringBuilder asciiDoc, JsonNode methodNode) { if (methodNode.has("responses")) { asciiDoc.append("**응답:**\n\n"); @@ -356,9 +324,7 @@ private void generateResponseInfo(StringBuilder asciiDoc, JsonNode methodNode) { } } - // 요청 본문 예제 생성 - private void generateRequestBodyExample(StringBuilder asciiDoc, JsonNode requestBody) { if (requestBody.has("content") && requestBody.get("content").has("application/json")) { JsonNode schema = requestBody.get("content").get("application/json").get("schema"); @@ -415,9 +381,7 @@ private void generateRequestBodyExample(StringBuilder asciiDoc, JsonNode request } } - // 상세한 스키마 문서 생성 - private void generateDetailedSchemaDocumentation(StringBuilder asciiDoc, String schemaName, JsonNode schemaNode) { asciiDoc.append("=== ").append(schemaName).append("\n\n"); @@ -476,9 +440,7 @@ private void generateDetailedSchemaDocumentation(StringBuilder asciiDoc, String } } - // 스키마 JSON 예제 생성 - private void generateSchemaJsonExample(StringBuilder asciiDoc, JsonNode properties) { asciiDoc.append("[source,json]\n"); asciiDoc.append("----\n"); diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminCareFacilityBookingController.java b/src/main/java/com/carecode/domain/admin/controller/AdminCareFacilityBookingController.java index 85e601cf..ee5f4dff 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminCareFacilityBookingController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminCareFacilityBookingController.java @@ -19,11 +19,7 @@ import java.time.LocalDate; import java.util.Map; -/** - * 관리자용 육아 시설 예약 관리 API. - * - *

접근 제어는 SecurityConfig 의 {@code /api/admin/**} → hasRole("ADMIN") 규칙이 담당한다. - */ +/** 관리자용 육아 시설 예약 관리 API. 접근 제어는 SecurityConfig 의 /api/admin/** → hasRole("ADMIN") 규칙이 담당한다. */ @Slf4j @RestController @RequestMapping("/api/admin/facilities/bookings") @@ -132,7 +128,7 @@ public ResponseEntity statusBookings( @GetMapping("/dashboard") @LogExecutionTime - @Operation(summary = "예약 대시보드 요약", description = "통계, 최근 예약, 오늘의 예약을 함께 반환합니다.") + @Operation(summary = "예약 대시보드 요약", description = "통계, 최근 예약, 오늘의 예약을 함께 반환") public ResponseEntity> bookingDashboard() { AdminBookingSearchRequest recentRequest = AdminBookingSearchRequest.builder() .page(0) diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminCommunityController.java b/src/main/java/com/carecode/domain/admin/controller/AdminCommunityController.java index 8d43dd46..4a975c39 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminCommunityController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminCommunityController.java @@ -16,11 +16,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; -/** - * 어드민 커뮤니티 관리 API. - * - *

관리자는 게시글을 대신 작성하지 않는다. 모더레이션(조회/삭제)만 제공한다. - */ +/** 어드민 커뮤니티 관리 API. 관리자는 게시글을 대신 작성하지 않는다. 모더레이션(조회/삭제)만 제공한다. */ @RestController @RequestMapping("/api/admin/community/posts") @RequiredArgsConstructor @@ -44,7 +40,7 @@ public ResponseEntity detail(@PathVariable Long id) { } @DeleteMapping("/{id}") - @Operation(summary = "게시글 삭제", description = "부적절한 게시글을 관리자 권한으로 삭제합니다.") + @Operation(summary = "게시글 삭제", description = "부적절한 게시글을 관리자 권한으로 삭제") @Transactional public ResponseEntity delete(@PathVariable Long id) { postRepository.delete(findPost(id)); diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminDashboardController.java b/src/main/java/com/carecode/domain/admin/controller/AdminDashboardController.java index 1950388c..75180473 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminDashboardController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminDashboardController.java @@ -20,9 +20,7 @@ import java.util.*; import java.util.stream.Collectors; -/** - * 어드민 대시보드 API. - */ +/** 어드민 대시보드 API. */ @RestController @RequestMapping("/api/admin") @RequiredArgsConstructor @@ -37,7 +35,7 @@ public class AdminDashboardController { private final PolicyRepository policyRepository; @GetMapping("/dashboard") - @Operation(summary = "대시보드 요약 조회", description = "전체 건수, 최근 활동, 가입자 추이를 반환합니다.") + @Operation(summary = "대시보드 요약 조회", description = "전체 건수, 최근 활동, 가입자 추이 반환") public ResponseEntity> dashboard() { Map dashboard = new LinkedHashMap<>(); diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminHealthController.java b/src/main/java/com/carecode/domain/admin/controller/AdminHealthController.java index 7218dfa3..d8f92e57 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminHealthController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminHealthController.java @@ -16,11 +16,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; -/** - * 어드민 건강기록 관리 API. - * - *

건강기록은 민감정보다. 관리자가 대신 생성·수정하지 않고 조회와 삭제만 제공한다. - */ +/** 어드민 건강기록 관리 API. 건강기록은 민감정보다. 관리자가 대신 생성·수정하지 않고 조회와 삭제만 제공한다. */ @RestController @RequestMapping("/api/admin/health/records") @RequiredArgsConstructor diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminHospitalController.java b/src/main/java/com/carecode/domain/admin/controller/AdminHospitalController.java index 0213ee58..f5cc0d9e 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminHospitalController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminHospitalController.java @@ -15,9 +15,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; -/** - * 어드민 병원 관리 API. - */ +/** 어드민 병원 관리 API. */ @RestController @RequestMapping("/api/admin/hospitals") @RequiredArgsConstructor diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminNotificationController.java b/src/main/java/com/carecode/domain/admin/controller/AdminNotificationController.java index 90694c34..1d6eef13 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminNotificationController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminNotificationController.java @@ -21,9 +21,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; -/** - * 어드민 알림 관리 API. - */ +/** 어드민 알림 관리 API. */ @RestController @RequestMapping("/api/admin/notifications") @RequiredArgsConstructor @@ -47,7 +45,7 @@ public ResponseEntity detail(@PathVariable Long id) { } @PostMapping - @Operation(summary = "알림 발송", description = "특정 사용자에게 알림을 생성합니다.") + @Operation(summary = "알림 발송", description = "특정 사용자에게 알림 생성") @Transactional public ResponseEntity create( @Valid @RequestBody AdminNotificationCreateRequest request) { diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminPolicyController.java b/src/main/java/com/carecode/domain/admin/controller/AdminPolicyController.java index 0d34260a..1304c795 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminPolicyController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminPolicyController.java @@ -18,9 +18,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -/** - * 어드민 정책 관리 API. - */ +/** 어드민 정책 관리 API. */ @RestController @RequestMapping("/api/admin/policies") @RequiredArgsConstructor @@ -45,13 +43,13 @@ public ResponseEntity detail(@PathVariable Long id) { } @PostMapping - @Operation(summary = "정책 등록", description = "재배포 없이 새 정책을 추가합니다.") + @Operation(summary = "정책 등록", description = "재배포 없이 새 정책 추가") public ResponseEntity create(@Valid @RequestBody AdminPolicyRequest request) { return ResponseEntity.status(HttpStatus.CREATED).body(policyAdminService.create(request)); } @PutMapping("/{id}") - @Operation(summary = "정책 수정", description = "수정 시 해당 정책의 캐시가 무효화됩니다.") + @Operation(summary = "정책 수정", description = "수정 시 해당 정책의 캐시가 무효화") public ResponseEntity update(@PathVariable Long id, @Valid @RequestBody AdminPolicyRequest request) { return ResponseEntity.ok(policyAdminService.update(id, request)); diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminReportController.java b/src/main/java/com/carecode/domain/admin/controller/AdminReportController.java index 1ce79551..4b68783b 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminReportController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminReportController.java @@ -13,9 +13,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -/** - * 어드민 신고 처리 API. - */ +/** 어드민 신고 처리 API. */ @RestController @RequestMapping("/api/admin/reports") @RequiredArgsConstructor @@ -33,7 +31,7 @@ public ResponseEntity> pendingReports( @PatchMapping("/{reportId}") @Operation(summary = "신고 처리", - description = "ACCEPTED 로 처리하면 대상 게시글·댓글이 숨김 처리됩니다.") + description = "ACCEPTED 로 처리하면 대상 게시글·댓글이 숨김 처리") public ResponseEntity resolve( @PathVariable Long reportId, @Parameter(description = "처리 결과 (ACCEPTED 또는 REJECTED)", required = true) diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminUserController.java b/src/main/java/com/carecode/domain/admin/controller/AdminUserController.java index 8da7a1d8..4b1b7890 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminUserController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminUserController.java @@ -18,11 +18,7 @@ import java.time.LocalDateTime; -/** - * 어드민 사용자 관리 API. - * - *

접근 제어는 SecurityConfig 의 {@code /api/admin/**} → hasRole("ADMIN") 규칙이 담당한다. - */ +/** 어드민 사용자 관리 API. 접근 제어는 SecurityConfig 의 /api/admin/** → hasRole("ADMIN") 규칙이 담당한다. */ @RestController @RequestMapping("/api/admin/users") @RequiredArgsConstructor @@ -45,7 +41,7 @@ public ResponseEntity detail(@PathVariable Long id) { } @PatchMapping("/{id}") - @Operation(summary = "사용자 정보 수정", description = "이름·연락처·역할·활성 상태만 변경할 수 있습니다.") + @Operation(summary = "사용자 정보 수정", description = "이름·연락처·역할·활성 상태만 변경할 수 있습니다") @Transactional public ResponseEntity update(@PathVariable Long id, @Valid @RequestBody AdminUserUpdateRequest request) { @@ -69,7 +65,7 @@ public ResponseEntity update(@PathVariable Long id, } @DeleteMapping("/{id}") - @Operation(summary = "사용자 탈퇴 처리", description = "물리 삭제 대신 soft delete 로 비활성화합니다.") + @Operation(summary = "사용자 탈퇴 처리", description = "물리 삭제 대신 soft delete 로 비활성화") @Transactional public ResponseEntity delete(@PathVariable Long id) { User user = findUser(id); diff --git a/src/main/java/com/carecode/domain/admin/dto/AdminBookingDetailResponse.java b/src/main/java/com/carecode/domain/admin/dto/AdminBookingDetailResponse.java index aab2c27a..f2b9a520 100644 --- a/src/main/java/com/carecode/domain/admin/dto/AdminBookingDetailResponse.java +++ b/src/main/java/com/carecode/domain/admin/dto/AdminBookingDetailResponse.java @@ -7,9 +7,7 @@ import java.time.LocalDateTime; -/** - * 관리자용 예약 상세 DTO - */ +/** 관리자용 예약 상세 DTO */ @Data @Builder @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/admin/dto/AdminBookingListResponse.java b/src/main/java/com/carecode/domain/admin/dto/AdminBookingListResponse.java index 49d37239..4fe4f0ee 100644 --- a/src/main/java/com/carecode/domain/admin/dto/AdminBookingListResponse.java +++ b/src/main/java/com/carecode/domain/admin/dto/AdminBookingListResponse.java @@ -7,9 +7,7 @@ import java.time.LocalDateTime; -/** - * 관리자용 예약 목록 DTO - */ +/** 관리자용 예약 목록 DTO */ @Data @Builder @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/admin/dto/AdminBookingSearchRequest.java b/src/main/java/com/carecode/domain/admin/dto/AdminBookingSearchRequest.java index f8fdf53b..59982837 100644 --- a/src/main/java/com/carecode/domain/admin/dto/AdminBookingSearchRequest.java +++ b/src/main/java/com/carecode/domain/admin/dto/AdminBookingSearchRequest.java @@ -7,9 +7,7 @@ import java.time.LocalDateTime; -/** - * 관리자용 예약 검색 요청 DTO - */ +/** 관리자용 예약 검색 요청 DTO */ @Data @Builder @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/admin/dto/AdminBookingSearchResponse.java b/src/main/java/com/carecode/domain/admin/dto/AdminBookingSearchResponse.java index 8dec0b88..2ac61559 100644 --- a/src/main/java/com/carecode/domain/admin/dto/AdminBookingSearchResponse.java +++ b/src/main/java/com/carecode/domain/admin/dto/AdminBookingSearchResponse.java @@ -7,9 +7,7 @@ import java.util.List; -/** - * 관리자용 예약 검색 응답 DTO - */ +/** 관리자용 예약 검색 응답 DTO */ @Data @Builder @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/admin/dto/AdminBookingStatsResponse.java b/src/main/java/com/carecode/domain/admin/dto/AdminBookingStatsResponse.java index e89ff37c..aa47494d 100644 --- a/src/main/java/com/carecode/domain/admin/dto/AdminBookingStatsResponse.java +++ b/src/main/java/com/carecode/domain/admin/dto/AdminBookingStatsResponse.java @@ -11,9 +11,7 @@ import java.util.List; -/** - * 관리자용 예약 통계 DTO - */ +/** 관리자용 예약 통계 DTO */ @Data @Builder @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/admin/dto/AdminNotificationCreateRequest.java b/src/main/java/com/carecode/domain/admin/dto/AdminNotificationCreateRequest.java index 24666555..9173d32c 100644 --- a/src/main/java/com/carecode/domain/admin/dto/AdminNotificationCreateRequest.java +++ b/src/main/java/com/carecode/domain/admin/dto/AdminNotificationCreateRequest.java @@ -7,10 +7,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 어드민 알림 발송 요청. - *

엔티티를 직접 바인딩하지 않고 허용 필드만 받는다. - */ +/** 어드민 알림 발송 요청. 엔티티를 직접 바인딩하지 않고 허용 필드만 받는다. */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/admin/dto/AdminPolicyRequest.java b/src/main/java/com/carecode/domain/admin/dto/AdminPolicyRequest.java index d172b48e..413d0c4d 100644 --- a/src/main/java/com/carecode/domain/admin/dto/AdminPolicyRequest.java +++ b/src/main/java/com/carecode/domain/admin/dto/AdminPolicyRequest.java @@ -8,12 +8,7 @@ import java.time.LocalDate; -/** - * 관리자 정책 생성·수정 요청. - * - *

정책은 매년 바뀌는데 코드에 하드코딩돼 있어 재배포 없이는 수정할 수 없었다. - * 이 API 로 운영 중 관리할 수 있게 한다. - */ +/** 관리자 정책 생성·수정 요청. 정책은 매년 바뀌는데 코드에 하드코딩돼 있어 재배포 없이는 수정할 수 없었다. */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/admin/dto/AdminStatusUpdateRequest.java b/src/main/java/com/carecode/domain/admin/dto/AdminStatusUpdateRequest.java index 0f410749..fb9557ef 100644 --- a/src/main/java/com/carecode/domain/admin/dto/AdminStatusUpdateRequest.java +++ b/src/main/java/com/carecode/domain/admin/dto/AdminStatusUpdateRequest.java @@ -5,9 +5,7 @@ import lombok.Data; import lombok.NoArgsConstructor; -/** - * 관리자용 예약 상태 변경 요청 DTO - */ +/** 관리자용 예약 상태 변경 요청 DTO */ @Data @Builder @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/admin/dto/AdminUserResponse.java b/src/main/java/com/carecode/domain/admin/dto/AdminUserResponse.java index ad936c41..c0bfe6e7 100644 --- a/src/main/java/com/carecode/domain/admin/dto/AdminUserResponse.java +++ b/src/main/java/com/carecode/domain/admin/dto/AdminUserResponse.java @@ -6,10 +6,7 @@ import java.time.LocalDateTime; -/** - * 어드민 사용자 목록/상세 응답. - *

비밀번호 해시, OAuth provider ID 등 밖으로 나가면 안 되는 값은 담지 않는다. - */ +/** 어드민 사용자 목록/상세 응답. 비밀번호 해시, OAuth provider ID 등 밖으로 나가면 안 되는 값은 담지 않는다. */ @Getter @Builder public class AdminUserResponse { diff --git a/src/main/java/com/carecode/domain/admin/dto/AdminUserUpdateRequest.java b/src/main/java/com/carecode/domain/admin/dto/AdminUserUpdateRequest.java index f09ff0bb..d46b3799 100644 --- a/src/main/java/com/carecode/domain/admin/dto/AdminUserUpdateRequest.java +++ b/src/main/java/com/carecode/domain/admin/dto/AdminUserUpdateRequest.java @@ -5,12 +5,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 어드민이 변경할 수 있는 사용자 속성만 명시한 요청 객체. - * - *

엔티티를 그대로 바인딩하면(과거 {@code @ModelAttribute User}) 비밀번호 해시나 - * 임의 필드까지 덮어쓸 수 있는 mass assignment 가 된다. 허용 필드를 화이트리스트로 고정한다. - */ +/** 어드민이 변경할 수 있는 사용자 속성만 명시한 요청 객체. 엔티티를 그대로 바인딩하면(과거 @ModelAttribute User) 비밀번호 해시나 임의 필드까지 덮어쓸 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/admin/service/CareFacilityBookingAdminService.java b/src/main/java/com/carecode/domain/admin/service/CareFacilityBookingAdminService.java index ec84c194..4d19cbd6 100644 --- a/src/main/java/com/carecode/domain/admin/service/CareFacilityBookingAdminService.java +++ b/src/main/java/com/carecode/domain/admin/service/CareFacilityBookingAdminService.java @@ -30,9 +30,7 @@ import java.util.List; import java.util.stream.Collectors; -/** - * 관리자용 육아 시설 예약 서비스 클래스 - */ +/** 관리자용 육아 시설 예약 서비스 클래스 */ @Slf4j @Service @RequiredArgsConstructor diff --git a/src/main/java/com/carecode/domain/admin/service/PolicyAdminService.java b/src/main/java/com/carecode/domain/admin/service/PolicyAdminService.java index b8034b22..bf0c85ef 100644 --- a/src/main/java/com/carecode/domain/admin/service/PolicyAdminService.java +++ b/src/main/java/com/carecode/domain/admin/service/PolicyAdminService.java @@ -16,9 +16,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -/** - * 관리자 정책 관리. - */ +/** 관리자 정책 관리. */ @Slf4j @Service @RequiredArgsConstructor @@ -45,10 +43,7 @@ public PolicyDto create(AdminPolicyRequest request) { return policyMapper.toResponse(saved); } - /** - * 정책 수정. - * 캐시된 상세 응답이 낡지 않도록 해당 항목을 무효화한다. - */ + /** 정책 수정. 캐시된 상세 응답이 낡지 않도록 해당 항목을 무효화한다. */ @Transactional @CacheEvict(cacheNames = "policy", key = "#policyId") public PolicyDto update(Long policyId, AdminPolicyRequest request) { diff --git a/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java b/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java index 94f9e75d..3fd748bd 100644 --- a/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java +++ b/src/main/java/com/carecode/domain/careFacility/app/CareFacilityFacade.java @@ -142,8 +142,8 @@ public List getTodayBookingsByFacility(Long facilityId) { return bookingService.getTodayBookingsByFacility(facilityId); } - // ==================== 고급 검색 기능 ==================== - + // ==================== + // 고급 검색 기능 ==================== @Transactional(readOnly = true) public List recommendFacilitiesByChildAge(Integer childAge) { return careFacilityService.recommendFacilitiesByChildAge(childAge); diff --git a/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java b/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java index e31ce69e..8692d5f5 100644 --- a/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java +++ b/src/main/java/com/carecode/domain/careFacility/controller/CareFacilityController.java @@ -33,10 +33,7 @@ import com.carecode.core.handler.ApiSuccess; import java.util.Date; -/** - * 육아 시설 컨트롤러 - * 육아 시설 관련 REST API 엔드포인트 제공 - */ +/** 육아 시설 컨트롤러 육아 시설 관련 REST API 엔드포인트 제공 */ @Slf4j @RestController @RequestMapping("/facilities") @@ -49,7 +46,7 @@ public class CareFacilityController extends BaseController { // 전체 시설 목록 조회 @GetMapping @LogExecutionTime - @Operation(summary = "전체 시설 목록 조회", description = "등록된 모든 육아 시설 목록을 조회합니다.") + @Operation(summary = "전체 시설 목록 조회", description = "등록된 모든 육아 시설 목록 조회") public ResponseEntity> getAllFacilities( @Parameter(description = "페이지 번호 (0부터)") @RequestParam(required = false) Integer page, @Parameter(description = "페이지 크기 (최대 200)") @RequestParam(required = false) Integer size) { @@ -63,7 +60,7 @@ public ResponseEntity> getAllFacilities( // 시설 ID로 시설 조회 @GetMapping("/{id}") @LogExecutionTime - @Operation(summary = "시설 상세 조회", description = "시설 ID로 특정 육아 시설의 상세 정보를 조회합니다.") + @Operation(summary = "시설 상세 조회", description = "시설 ID로 특정 육아 시설의 상세 정보 조회") public ResponseEntity getFacilityById(@Parameter(description = "시설 ID", required = true) @PathVariable Long id) { CareFacilityInfo facility = careFacilityFacade.getCareFacilityById(id); @@ -74,7 +71,7 @@ public ResponseEntity getFacilityById(@Parameter(description = // 시설 유형별 조회 @GetMapping("/type/{facilityType}") @LogExecutionTime - @Operation(summary = "시설 유형별 조회", description = "특정 유형의 육아 시설 목록을 조회합니다.") + @Operation(summary = "시설 유형별 조회", description = "특정 유형의 육아 시설 목록 조회") public ResponseEntity> getFacilitiesByType(@Parameter(description = "시설 유형 (KINDERGARTEN: 유치원, DAYCARE: 어린이집, PLAYGROUP: 놀이방, NURSERY: 보육원, OTHER: 기타)", required = true) @PathVariable FacilityType facilityType) { List facilities = careFacilityFacade.getCareFacilitiesByType(facilityType); @@ -86,7 +83,7 @@ public ResponseEntity> getFacilitiesByType(@Parameter(des @GetMapping("/location/{location}") @LogExecutionTime @ValidateLocation - @Operation(summary = "지역별 시설 조회", description = "특정 지역의 육아 시설 목록을 조회합니다.") + @Operation(summary = "지역별 시설 조회", description = "특정 지역의 육아 시설 목록 조회") public ResponseEntity> getFacilitiesByLocation(@Parameter(description = "지역명", required = true) @PathVariable String location) { List facilities = careFacilityFacade.getCareFacilitiesByLocation(location); @@ -98,7 +95,7 @@ public ResponseEntity> getFacilitiesByLocation(@Parameter @GetMapping("/age") @LogExecutionTime @ValidateChildAge - @Operation(summary = "연령대별 시설 조회", description = "특정 연령대에 적합한 육아 시설 목록을 조회합니다.") + @Operation(summary = "연령대별 시설 조회", description = "특정 연령대에 적합한 육아 시설 목록 조회") public ResponseEntity> getFacilitiesByAgeRange(@Parameter(description = "최소 연령", required = true) @RequestParam Integer minAge, @Parameter(description = "최대 연령", required = true) @RequestParam Integer maxAge) { @@ -110,7 +107,7 @@ public ResponseEntity> getFacilitiesByAgeRange(@Parameter // 운영 시간별 시설 조회 @GetMapping("/operating-hours") @LogExecutionTime - @Operation(summary = "운영 시간별 시설 조회", description = "특정 운영 시간을 가진 육아 시설 목록을 조회합니다.") + @Operation(summary = "운영 시간별 시설 조회", description = "특정 운영 시간을 가진 육아 시설 목록 조회") public ResponseEntity> getFacilitiesByOperatingHours(@Parameter(description = "운영 시간", required = true) @RequestParam String operatingHours) { List facilities = careFacilityFacade.getCareFacilitiesByOperatingHours(operatingHours); @@ -121,7 +118,7 @@ public ResponseEntity> getFacilitiesByOperatingHours(@Par // 인기 시설 조회 (평점 기준) @GetMapping("/popular") @LogExecutionTime - @Operation(summary = "인기 시설 조회", description = "평점 기준으로 인기 있는 육아 시설 목록을 조회합니다.") + @Operation(summary = "인기 시설 조회", description = "평점 기준으로 인기 있는 육아 시설 목록 조회") public ResponseEntity> getPopularFacilities(@Parameter(description = "조회할 시설 수", example = "10") @RequestParam(defaultValue = "10") Integer limit) { List facilities = careFacilityFacade.getPopularCareFacilities(limit); @@ -132,7 +129,7 @@ public ResponseEntity> getPopularFacilities(@Parameter(de // 신규 시설 조회 @GetMapping("/new") @LogExecutionTime - @Operation(summary = "신규 시설 조회", description = "최근 등록된 육아 시설 목록을 조회합니다.") + @Operation(summary = "신규 시설 조회", description = "최근 등록된 육아 시설 목록 조회") public ResponseEntity> getNewFacilities(@Parameter(description = "조회할 시설 수", example = "10") @RequestParam(defaultValue = "10") Integer limit) { List facilities = careFacilityFacade.getNewCareFacilities(limit); @@ -144,7 +141,7 @@ public ResponseEntity> getNewFacilities(@Parameter(descri @GetMapping("/radius") @LogExecutionTime @ValidateLocation - @Operation(summary = "반경 내 시설 검색", description = "특정 위치 기준 반경 내의 육아 시설을 검색합니다.") + @Operation(summary = "반경 내 시설 검색", description = "특정 위치 기준 반경 내의 육아 시설 검색") public ResponseEntity> getFacilitiesWithinRadius(@Parameter(description = "위도", required = true) @RequestParam Double latitude, @Parameter(description = "경도", required = true) @RequestParam Double longitude, @Parameter(description = "반경 (km)", required = true) @RequestParam Double radius) { @@ -157,7 +154,7 @@ public ResponseEntity> getFacilitiesWithinRadius(@Paramet // 복합 조건으로 시설 검색 (페이징) @PostMapping("/search") @LogExecutionTime - @Operation(summary = "복합 조건 시설 검색", description = "다양한 조건으로 육아 시설을 검색합니다.") + @Operation(summary = "복합 조건 시설 검색", description = "다양한 조건으로 육아 시설 검색") public ResponseEntity searchFacilities(@Parameter(description = "검색 조건", required = true) @RequestBody CareFacilitySearchRequest requestDto) { CareFacilityListResponse response = careFacilityFacade.searchCareFacilities(requestDto); @@ -168,7 +165,7 @@ public ResponseEntity searchFacilities(@Parameter(desc // 시설 조회수 증가 @PostMapping("/{id}/view") @LogExecutionTime - @Operation(summary = "시설 조회수 증가", description = "특정 시설의 조회수를 증가시킵니다.") + @Operation(summary = "시설 조회수 증가", description = "특정 시설의 조회수를 증가시킵니다") public ResponseEntity incrementViewCount(@Parameter(description = "시설 ID", required = true) @PathVariable Long id) { careFacilityFacade.incrementViewCount(id); @@ -179,7 +176,7 @@ public ResponseEntity incrementViewCount(@Parameter(description = " // 시설 평점 업데이트 @PostMapping("/{id}/rating") @LogExecutionTime - @Operation(summary = "시설 평점 업데이트", description = "특정 시설의 평점을 업데이트합니다.") + @Operation(summary = "시설 평점 업데이트", description = "특정 시설의 평점을 업데이트") public ResponseEntity updateRating(@Parameter(description = "시설 ID", required = true) @PathVariable Long id, @Parameter(description = "평점 (0.0 ~ 5.0)", required = true) @RequestParam Double rating) { @@ -191,7 +188,7 @@ public ResponseEntity updateRating(@Parameter(description = "시설 // 시설 통계 조회 @GetMapping("/statistics") @LogExecutionTime - @Operation(summary = "시설 통계 조회", description = "육아 시설 관련 통계 정보를 조회합니다.") + @Operation(summary = "시설 통계 조회", description = "육아 시설 관련 통계 정보 조회") public ResponseEntity getFacilityStatistics() { CareFacilityStatsResponse stats = careFacilityFacade.getFacilityStats(); @@ -199,75 +196,64 @@ public ResponseEntity getFacilityStatistics() { return ResponseEntity.ok(stats); } - // ==================== 고급 검색 기능 ==================== - + // ==================== + // 고급 검색 기능 ==================== // 아이 연령별 시설 추천 - @GetMapping("/recommend/age") @LogExecutionTime @ValidateChildAge - @Operation(summary = "아이 연령별 시설 추천", description = "아이의 연령에 맞는 육아 시설을 추천합니다.") + @Operation(summary = "아이 연령별 시설 추천", description = "아이의 연령에 맞는 육아 시설을 추천") public ResponseEntity> recommendFacilitiesByAge( @Parameter(description = "아이 연령", required = true) @RequestParam Integer childAge) { List facilities = careFacilityFacade.recommendFacilitiesByChildAge(childAge); return ResponseEntity.ok(facilities); } - // 최소 평점 이상의 시설 조회 - @GetMapping("/rating") @LogExecutionTime - @Operation(summary = "평점별 시설 조회", description = "최소 평점 이상의 육아 시설을 조회합니다.") + @Operation(summary = "평점별 시설 조회", description = "최소 평점 이상의 육아 시설 조회") public ResponseEntity> getFacilitiesByRating( @Parameter(description = "최소 평점 (0.0 ~ 5.0)", required = true) @RequestParam Double minRating) { List facilities = careFacilityFacade.getFacilitiesByMinRating(minRating); return ResponseEntity.ok(facilities); } - // 빈 자리가 있는 시설 조회 - @GetMapping("/available-spots") @LogExecutionTime - @Operation(summary = "빈 자리 있는 시설 조회", description = "최소 자리 수 이상의 빈 자리가 있는 시설을 조회합니다.") + @Operation(summary = "빈 자리 있는 시설 조회", description = "최소 자리 수 이상의 빈 자리가 있는 시설 조회") public ResponseEntity> getFacilitiesWithSpots( @Parameter(description = "최소 자리 수", example = "1") @RequestParam(defaultValue = "1") Integer minSpots) { List facilities = careFacilityFacade.getFacilitiesWithAvailableSpots(minSpots); return ResponseEntity.ok(facilities); } - // 등록금 범위로 시설 조회 - @GetMapping("/tuition-fee") @LogExecutionTime - @Operation(summary = "등록금 범위별 시설 조회", description = "최대 등록금 이하의 시설을 조회합니다.") + @Operation(summary = "등록금 범위별 시설 조회", description = "최대 등록금 이하의 시설 조회") public ResponseEntity> getFacilitiesByTuitionFee( @Parameter(description = "최대 등록금 (원)", required = true) @RequestParam Integer maxFee) { List facilities = careFacilityFacade.getFacilitiesByMaxTuitionFee(maxFee); return ResponseEntity.ok(facilities); } - // 키워드로 시설 검색 - @GetMapping("/keyword") @LogExecutionTime - @Operation(summary = "키워드 검색", description = "키워드로 시설명 또는 주소를 검색합니다.") + @Operation(summary = "키워드 검색", description = "키워드로 시설명 또는 주소 검색") public ResponseEntity> searchByKeyword( @Parameter(description = "검색 키워드", required = true) @RequestParam String keyword) { List facilities = careFacilityFacade.searchFacilitiesByKeyword(keyword); return ResponseEntity.ok(facilities); } - // 고급 검색 (복합 조건) - @PostMapping("/advanced-search") @LogExecutionTime - @Operation(summary = "고급 검색", description = "다양한 조건을 조합하여 시설을 검색합니다.") + @Operation(summary = "고급 검색", description = "다양한 조건을 조합하여 시설 검색") public ResponseEntity> advancedSearch( @Parameter(description = "검색 조건", required = true) @RequestBody CareFacilityAdvancedSearchRequest request) { List facilities = careFacilityFacade.searchFacilitiesAdvanced( @@ -282,12 +268,10 @@ public ResponseEntity> advancedSearch( return ResponseEntity.ok(facilities); } - // 리뷰와 함께 시설 상세 조회 - @GetMapping("/{id}/with-reviews") @LogExecutionTime - @Operation(summary = "시설 상세 조회 (리뷰 포함)", description = "리뷰 정보를 포함한 시설 상세 정보를 조회합니다.") + @Operation(summary = "시설 상세 조회 (리뷰 포함)", description = "리뷰 정보를 포함한 시설 상세 정보 조회") public ResponseEntity getFacilityWithReviews( @Parameter(description = "시설 ID", required = true) @PathVariable Long id) { CareFacilityInfo facility = careFacilityFacade.getFacilityByIdWithReviews(id); @@ -296,14 +280,14 @@ public ResponseEntity getFacilityWithReviews( @GetMapping("/{id}/reviews") @LogExecutionTime - @Operation(summary = "시설 리뷰 목록 조회", description = "시설의 리뷰 목록을 조회합니다.") + @Operation(summary = "시설 리뷰 목록 조회") public ResponseEntity> getFacilityReviews(@PathVariable Long id) { return ResponseEntity.ok(careFacilityFacade.getFacilityReviews(id)); } @PostMapping("/{id}/reviews") @LogExecutionTime - @Operation(summary = "시설 리뷰 작성", description = "시설 리뷰를 작성합니다.") + @Operation(summary = "시설 리뷰 작성") public ResponseEntity createReview(@PathVariable Long id, @RequestBody ReviewRequest request, @AuthenticationPrincipal UserDetails userDetails) { @@ -312,7 +296,7 @@ public ResponseEntity createReview(@PathVariable Long id, @PutMapping("/reviews/{reviewId}") @LogExecutionTime - @Operation(summary = "시설 리뷰 수정", description = "작성한 시설 리뷰를 수정합니다.") + @Operation(summary = "시설 리뷰 수정") public ResponseEntity updateReview(@PathVariable Long reviewId, @RequestBody ReviewRequest request, @AuthenticationPrincipal UserDetails userDetails) { @@ -321,7 +305,7 @@ public ResponseEntity updateReview(@PathVariable Long reviewId, @DeleteMapping("/reviews/{reviewId}") @LogExecutionTime - @Operation(summary = "시설 리뷰 삭제", description = "작성한 시설 리뷰를 삭제합니다.") + @Operation(summary = "시설 리뷰 삭제") public ResponseEntity deleteReview(@PathVariable Long reviewId, @AuthenticationPrincipal UserDetails userDetails) { careFacilityFacade.deleteReview(reviewId, userDetails.getUsername()); @@ -331,7 +315,7 @@ public ResponseEntity deleteReview(@PathVariable Long reviewId, // 예약 생성 @PostMapping("/{facilityId}/bookings") @LogExecutionTime - @Operation(summary = "시설 예약 생성", description = "특정 육아 시설에 예약을 생성합니다.") + @Operation(summary = "시설 예약 생성", description = "특정 육아 시설에 예약 생성") public ResponseEntity createBooking(@Parameter(description = "시설 ID", required = true) @PathVariable Long facilityId, @Parameter(description = "예약 정보", required = true) @RequestBody CreateBookingRequest request, @AuthenticationPrincipal UserDetails userDetails) { @@ -344,7 +328,7 @@ public ResponseEntity createBooking(@Parameter(description = " // 예약 상세 조회 @GetMapping("/bookings/{bookingId}") @LogExecutionTime - @Operation(summary = "예약 상세 조회", description = "특정 예약의 상세 정보를 조회합니다.") + @Operation(summary = "예약 상세 조회", description = "특정 예약의 상세 정보 조회") public ResponseEntity getBookingById(@Parameter(description = "예약 ID", required = true) @PathVariable Long bookingId, @AuthenticationPrincipal UserDetails userDetails) { @@ -356,7 +340,7 @@ public ResponseEntity getBookingById(@Parameter(description = " // 사용자별 예약 목록 조회 @GetMapping("/bookings/user") @LogExecutionTime - @Operation(summary = "사용자별 예약 목록 조회", description = "현재 로그인한 사용자의 예약 목록을 조회합니다.") + @Operation(summary = "사용자별 예약 목록 조회", description = "현재 로그인한 사용자의 예약 목록 조회") public ResponseEntity> getUserBookings(@AuthenticationPrincipal UserDetails userDetails) { List bookings = careFacilityFacade.getUserBookings(userDetails); @@ -367,7 +351,7 @@ public ResponseEntity> getUserBookings(@AuthenticationPrin // 시설별 예약 목록 조회 @GetMapping("/{facilityId}/bookings") @LogExecutionTime - @Operation(summary = "시설별 예약 목록 조회", description = "특정 시설의 예약 목록을 조회합니다.") + @Operation(summary = "시설별 예약 목록 조회") public ResponseEntity> getFacilityBookings(@Parameter(description = "시설 ID", required = true) @PathVariable Long facilityId) { List bookings = careFacilityFacade.getFacilityBookings(facilityId); @@ -378,7 +362,7 @@ public ResponseEntity> getFacilityBookings(@Parameter(desc // 예약 상태 업데이트 @PutMapping("/bookings/{bookingId}/status") @LogExecutionTime - @Operation(summary = "예약 상태 업데이트", description = "예약의 상태를 업데이트합니다.") + @Operation(summary = "예약 상태 업데이트") public ResponseEntity updateBookingStatus(@Parameter(description = "예약 ID", required = true) @PathVariable Long bookingId, @Parameter(description = "새로운 상태", required = true) @RequestParam String status, @AuthenticationPrincipal UserDetails userDetails) { @@ -391,7 +375,7 @@ public ResponseEntity updateBookingStatus(@Parameter(descriptio // 예약 취소 @DeleteMapping("/bookings/{bookingId}") @LogExecutionTime - @Operation(summary = "예약 취소", description = "예약을 취소합니다.") + @Operation(summary = "예약 취소", description = "예약을 취소") public ResponseEntity cancelBooking(@Parameter(description = "예약 ID", required = true) @PathVariable Long bookingId, @AuthenticationPrincipal UserDetails userDetails) { @@ -403,7 +387,7 @@ public ResponseEntity cancelBooking(@Parameter(description = "예약 // 예약 수정 @PutMapping("/bookings/{bookingId}") @LogExecutionTime - @Operation(summary = "예약 수정", description = "기존 예약 정보를 수정합니다.") + @Operation(summary = "예약 수정", description = "기존 예약 정보 수정") public ResponseEntity updateBooking(@Parameter(description = "예약 ID", required = true) @PathVariable Long bookingId, @Parameter(description = "수정할 예약 정보", required = true) @RequestBody UpdateBookingRequest request, @AuthenticationPrincipal UserDetails userDetails) { @@ -416,7 +400,7 @@ public ResponseEntity updateBooking(@Parameter(description = " // 오늘의 예약 조회 @GetMapping("/bookings/today") @LogExecutionTime - @Operation(summary = "오늘의 예약 조회", description = "오늘 날짜의 예약 목록을 조회합니다.") + @Operation(summary = "오늘의 예약 조회", description = "오늘 날짜의 예약 목록 조회") public ResponseEntity> getTodayBookings() { List bookings = careFacilityFacade.getTodayBookings(); @@ -427,7 +411,7 @@ public ResponseEntity> getTodayBookings() { // 시설별 오늘의 예약 조회 @GetMapping("/{facilityId}/bookings/today") @LogExecutionTime - @Operation(summary = "시설별 오늘의 예약 조회", description = "특정 시설의 오늘 예약 목록을 조회합니다.") + @Operation(summary = "시설별 오늘의 예약 조회", description = "특정 시설의 오늘 예약 목록 조회") public ResponseEntity> getTodayBookingsByFacility(@Parameter(description = "시설 ID", required = true) @PathVariable Long facilityId) { List bookings = careFacilityFacade.getTodayBookingsByFacility(facilityId); @@ -438,7 +422,7 @@ public ResponseEntity> getTodayBookingsByFacility(@Paramet // 입소 가능 시점 예측 @GetMapping("/{facilityId}/admission-forecast") @LogExecutionTime - @Operation(summary = "입소 가능 시점 예측", description = "관측된 정원 변동으로 자리가 날 확률을 추정합니다.") + @Operation(summary = "입소 가능 시점 예측", description = "관측된 정원 변동으로 자리가 날 확률을 추정") public ResponseEntity forecastAdmission( @Parameter(description = "시설 ID", required = true) @PathVariable Long facilityId, @Parameter(description = "아이 월령", example = "18") @RequestParam(required = false) Integer childAgeMonths, @@ -449,7 +433,7 @@ public ResponseEntity forecastAdmission( // 충원율 기반 인기도 @GetMapping("/{facilityId}/popularity") @LogExecutionTime - @Operation(summary = "시설 인기도 조회", description = "충원율 추이로 수요 수준과 변동을 분석합니다.") + @Operation(summary = "시설 인기도 조회", description = "충원율 추이로 수요 수준과 변동 분석") public ResponseEntity getPopularity( @Parameter(description = "시설 ID", required = true) @PathVariable Long facilityId) { return ResponseEntity.ok(careFacilityFacade.analyzePopularity(facilityId)); diff --git a/src/main/java/com/carecode/domain/careFacility/dto/request/CareFacilityAdminBookingSearchRequest.java b/src/main/java/com/carecode/domain/careFacility/dto/request/CareFacilityAdminBookingSearchRequest.java index 5593892b..1daead21 100644 --- a/src/main/java/com/carecode/domain/careFacility/dto/request/CareFacilityAdminBookingSearchRequest.java +++ b/src/main/java/com/carecode/domain/careFacility/dto/request/CareFacilityAdminBookingSearchRequest.java @@ -8,9 +8,7 @@ import java.time.LocalDateTime; -/** - * 관리자 예약 검색 요청 - */ +/** 관리자 예약 검색 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/careFacility/dto/request/CareFacilityAdminStatusUpdateRequest.java b/src/main/java/com/carecode/domain/careFacility/dto/request/CareFacilityAdminStatusUpdateRequest.java index 5e5119ec..78929122 100644 --- a/src/main/java/com/carecode/domain/careFacility/dto/request/CareFacilityAdminStatusUpdateRequest.java +++ b/src/main/java/com/carecode/domain/careFacility/dto/request/CareFacilityAdminStatusUpdateRequest.java @@ -8,9 +8,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 관리자 상태 업데이트 요청 - */ +/** 관리자 상태 업데이트 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/careFacility/dto/request/CareFacilityAdvancedSearchRequest.java b/src/main/java/com/carecode/domain/careFacility/dto/request/CareFacilityAdvancedSearchRequest.java index 03cd69e3..27bfa1d8 100644 --- a/src/main/java/com/carecode/domain/careFacility/dto/request/CareFacilityAdvancedSearchRequest.java +++ b/src/main/java/com/carecode/domain/careFacility/dto/request/CareFacilityAdvancedSearchRequest.java @@ -7,10 +7,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 고급 시설 검색 요청 DTO - * 복합 조건으로 시설을 검색합니다. - */ +/** 고급 시설 검색 요청 DTO 복합 조건으로 시설을 검색합니다. */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/careFacility/dto/request/CareFacilityListBookingsRequest.java b/src/main/java/com/carecode/domain/careFacility/dto/request/CareFacilityListBookingsRequest.java index cffe71d9..03bb6ae1 100644 --- a/src/main/java/com/carecode/domain/careFacility/dto/request/CareFacilityListBookingsRequest.java +++ b/src/main/java/com/carecode/domain/careFacility/dto/request/CareFacilityListBookingsRequest.java @@ -9,9 +9,7 @@ import java.time.LocalDateTime; -/** - * 예약 목록 조회 요청 - */ +/** 예약 목록 조회 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/careFacility/dto/request/CareFacilitySearchRequest.java b/src/main/java/com/carecode/domain/careFacility/dto/request/CareFacilitySearchRequest.java index 24d6aacb..eaa38a6a 100644 --- a/src/main/java/com/carecode/domain/careFacility/dto/request/CareFacilitySearchRequest.java +++ b/src/main/java/com/carecode/domain/careFacility/dto/request/CareFacilitySearchRequest.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 보육시설 검색 요청 - */ +/** 보육시설 검색 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/careFacility/dto/request/CreateBookingRequest.java b/src/main/java/com/carecode/domain/careFacility/dto/request/CreateBookingRequest.java index 667222f5..b4106168 100644 --- a/src/main/java/com/carecode/domain/careFacility/dto/request/CreateBookingRequest.java +++ b/src/main/java/com/carecode/domain/careFacility/dto/request/CreateBookingRequest.java @@ -7,9 +7,7 @@ import java.time.LocalDateTime; -/** - * 예약 생성 요청 DTO - */ +/** 예약 생성 요청 DTO */ @Data @Builder @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/careFacility/dto/request/UpdateBookingRequest.java b/src/main/java/com/carecode/domain/careFacility/dto/request/UpdateBookingRequest.java index c9a9b06f..a7e1c24a 100644 --- a/src/main/java/com/carecode/domain/careFacility/dto/request/UpdateBookingRequest.java +++ b/src/main/java/com/carecode/domain/careFacility/dto/request/UpdateBookingRequest.java @@ -7,9 +7,7 @@ import java.time.LocalDateTime; -/** - * 예약 수정 요청 DTO - */ +/** 예약 수정 요청 DTO */ @Data @Builder @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/careFacility/dto/response/BookingListResponse.java b/src/main/java/com/carecode/domain/careFacility/dto/response/BookingListResponse.java index 8d898871..a7f1fa43 100644 --- a/src/main/java/com/carecode/domain/careFacility/dto/response/BookingListResponse.java +++ b/src/main/java/com/carecode/domain/careFacility/dto/response/BookingListResponse.java @@ -7,9 +7,7 @@ import java.time.LocalDateTime; -/** - * 예약 목록 응답 DTO - */ +/** 예약 목록 응답 DTO */ @Data @Builder @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/careFacility/dto/response/BookingResponse.java b/src/main/java/com/carecode/domain/careFacility/dto/response/BookingResponse.java index 0377d797..866cd764 100644 --- a/src/main/java/com/carecode/domain/careFacility/dto/response/BookingResponse.java +++ b/src/main/java/com/carecode/domain/careFacility/dto/response/BookingResponse.java @@ -7,9 +7,7 @@ import java.time.LocalDateTime; -/** - * 예약 응답 DTO - */ +/** 예약 응답 DTO */ @Data @Builder @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/careFacility/dto/response/BookingStats.java b/src/main/java/com/carecode/domain/careFacility/dto/response/BookingStats.java index 4130082b..0e4684f3 100644 --- a/src/main/java/com/carecode/domain/careFacility/dto/response/BookingStats.java +++ b/src/main/java/com/carecode/domain/careFacility/dto/response/BookingStats.java @@ -5,9 +5,7 @@ import lombok.Data; import lombok.NoArgsConstructor; -/** - * 예약 통계 DTO - */ +/** 예약 통계 DTO */ @Data @Builder @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/careFacility/dto/response/CareFacilityBookingResponse.java b/src/main/java/com/carecode/domain/careFacility/dto/response/CareFacilityBookingResponse.java index 95c162a0..71d98977 100644 --- a/src/main/java/com/carecode/domain/careFacility/dto/response/CareFacilityBookingResponse.java +++ b/src/main/java/com/carecode/domain/careFacility/dto/response/CareFacilityBookingResponse.java @@ -8,9 +8,7 @@ import java.time.LocalDateTime; -/** - * 예약 응답 - */ +/** 예약 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/careFacility/dto/response/CareFacilityInfo.java b/src/main/java/com/carecode/domain/careFacility/dto/response/CareFacilityInfo.java index 75be757f..eea9ba69 100644 --- a/src/main/java/com/carecode/domain/careFacility/dto/response/CareFacilityInfo.java +++ b/src/main/java/com/carecode/domain/careFacility/dto/response/CareFacilityInfo.java @@ -10,9 +10,7 @@ import java.util.List; import java.util.Map; -/** - * 보육시설 정보 응답 - */ +/** 보육시설 정보 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/careFacility/dto/response/CareFacilityListResponse.java b/src/main/java/com/carecode/domain/careFacility/dto/response/CareFacilityListResponse.java index 925401e6..b2836d10 100644 --- a/src/main/java/com/carecode/domain/careFacility/dto/response/CareFacilityListResponse.java +++ b/src/main/java/com/carecode/domain/careFacility/dto/response/CareFacilityListResponse.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 보육시설 목록 응답 - */ +/** 보육시설 목록 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/careFacility/dto/response/CareFacilityStatsResponse.java b/src/main/java/com/carecode/domain/careFacility/dto/response/CareFacilityStatsResponse.java index c4ade2ab..4575579c 100644 --- a/src/main/java/com/carecode/domain/careFacility/dto/response/CareFacilityStatsResponse.java +++ b/src/main/java/com/carecode/domain/careFacility/dto/response/CareFacilityStatsResponse.java @@ -9,9 +9,7 @@ import java.util.List; import java.util.Map; -/** - * 보육시설 통계 응답 - */ +/** 보육시설 통계 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/careFacility/dto/response/DailyBookingCount.java b/src/main/java/com/carecode/domain/careFacility/dto/response/DailyBookingCount.java index beea28be..4e00d8dd 100644 --- a/src/main/java/com/carecode/domain/careFacility/dto/response/DailyBookingCount.java +++ b/src/main/java/com/carecode/domain/careFacility/dto/response/DailyBookingCount.java @@ -5,9 +5,7 @@ import lombok.Data; import lombok.NoArgsConstructor; -/** - * 일별 예약 수 DTO - */ +/** 일별 예약 수 DTO */ @Data @Builder @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/careFacility/dto/response/FacilityDistribution.java b/src/main/java/com/carecode/domain/careFacility/dto/response/FacilityDistribution.java index 5fa678f6..9d44ecd0 100644 --- a/src/main/java/com/carecode/domain/careFacility/dto/response/FacilityDistribution.java +++ b/src/main/java/com/carecode/domain/careFacility/dto/response/FacilityDistribution.java @@ -5,9 +5,7 @@ import lombok.Data; import lombok.NoArgsConstructor; -/** - * 시설별 예약 분포 DTO - */ +/** 시설별 예약 분포 DTO */ @Data @Builder @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/careFacility/dto/response/StatusDistribution.java b/src/main/java/com/carecode/domain/careFacility/dto/response/StatusDistribution.java index 0118144f..bbf4bff7 100644 --- a/src/main/java/com/carecode/domain/careFacility/dto/response/StatusDistribution.java +++ b/src/main/java/com/carecode/domain/careFacility/dto/response/StatusDistribution.java @@ -5,9 +5,7 @@ import lombok.Data; import lombok.NoArgsConstructor; -/** - * 상태별 예약 분포 DTO - */ +/** 상태별 예약 분포 DTO */ @Data @Builder @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/careFacility/dto/response/TypeDistribution.java b/src/main/java/com/carecode/domain/careFacility/dto/response/TypeDistribution.java index 67551c82..e5258a00 100644 --- a/src/main/java/com/carecode/domain/careFacility/dto/response/TypeDistribution.java +++ b/src/main/java/com/carecode/domain/careFacility/dto/response/TypeDistribution.java @@ -5,9 +5,7 @@ import lombok.Data; import lombok.NoArgsConstructor; -/** - * 예약 유형별 분포 DTO - */ +/** 예약 유형별 분포 DTO */ @Data @Builder @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/careFacility/dto/response/TypeStats.java b/src/main/java/com/carecode/domain/careFacility/dto/response/TypeStats.java index d8535d39..c3ba8cf6 100644 --- a/src/main/java/com/carecode/domain/careFacility/dto/response/TypeStats.java +++ b/src/main/java/com/carecode/domain/careFacility/dto/response/TypeStats.java @@ -6,9 +6,7 @@ import lombok.Builder; import com.carecode.domain.careFacility.entity.FacilityType; -/** - * 시설 유형별 통계 DTO - */ +/** 시설 유형별 통계 DTO */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/careFacility/entity/CareFacility.java b/src/main/java/com/carecode/domain/careFacility/entity/CareFacility.java index 498c318c..536c6eda 100644 --- a/src/main/java/com/carecode/domain/careFacility/entity/CareFacility.java +++ b/src/main/java/com/carecode/domain/careFacility/entity/CareFacility.java @@ -11,10 +11,7 @@ import java.util.ArrayList; import java.util.List; -/** - * 육아 시설 엔티티 - * 어린이집, 유치원 등 육아 관련 시설 정보를 관리 - */ +/** 육아 시설 엔티티 어린이집, 유치원 등 육아 관련 시설 정보를 관리 */ @Entity @Table(name = "TBL_CARE_FACILITIES") @Getter diff --git a/src/main/java/com/carecode/domain/careFacility/entity/CareFacilityBooking.java b/src/main/java/com/carecode/domain/careFacility/entity/CareFacilityBooking.java index 737478b8..e9dee9fd 100644 --- a/src/main/java/com/carecode/domain/careFacility/entity/CareFacilityBooking.java +++ b/src/main/java/com/carecode/domain/careFacility/entity/CareFacilityBooking.java @@ -7,9 +7,7 @@ import java.time.LocalDateTime; -/** - * 육아 시설 예약 엔티티 - */ +/** 육아 시설 예약 엔티티 */ @Entity @Table(name = "care_facility_bookings") @EntityListeners(AuditingEntityListener.class) @@ -150,34 +148,26 @@ public CareFacilityBooking(Long id, CareFacility facility, String userId, String public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } - // 예약 취소 - public void cancel(String reason) { this.status = BookingStatus.CANCELLED; this.cancellationReason = reason; this.cancelledAt = LocalDateTime.now(); } - // 예약 확정 - public void confirm() { this.status = BookingStatus.CONFIRMED; } - // 예약 완료 - public void complete() { this.status = BookingStatus.COMPLETED; this.actualStartTime = this.startTime; this.actualEndTime = this.endTime; } - // 예약 유형 열거형 - public enum BookingType { VISIT("방문"), REGULAR("정기"), @@ -194,9 +184,7 @@ public String getDisplayName() { } } - // 예약 상태 열거형 - public enum BookingStatus { PENDING("대기중"), CONFIRMED("확정"), diff --git a/src/main/java/com/carecode/domain/careFacility/entity/FacilityType.java b/src/main/java/com/carecode/domain/careFacility/entity/FacilityType.java index a0ea0fa6..71d8554b 100644 --- a/src/main/java/com/carecode/domain/careFacility/entity/FacilityType.java +++ b/src/main/java/com/carecode/domain/careFacility/entity/FacilityType.java @@ -1,8 +1,6 @@ package com.carecode.domain.careFacility.entity; -/** - * 육아 시설 유형 열거형 - */ +/** 육아 시설 유형 열거형 */ public enum FacilityType { KINDERGARTEN("유치원"), DAYCARE("어린이집"), @@ -20,9 +18,7 @@ public String getDisplayName() { return displayName; } - // 문자열을 FacilityType으로 안전하게 변환 - public static FacilityType fromString(String type) { try { return FacilityType.valueOf(type.toUpperCase()); diff --git a/src/main/java/com/carecode/domain/careFacility/entity/Review.java b/src/main/java/com/carecode/domain/careFacility/entity/Review.java index 4c2b9168..c9558658 100644 --- a/src/main/java/com/carecode/domain/careFacility/entity/Review.java +++ b/src/main/java/com/carecode/domain/careFacility/entity/Review.java @@ -12,15 +12,7 @@ import java.time.LocalDateTime; -/** - * 시설 리뷰 엔티티 - * - * 돌봄 시설에 대한 사용자 리뷰를 관리. - * @author CareCode Team - * @since 1.0.0 - * @see CareFacility - * @see User - */ +/** 시설 리뷰 엔티티 돌봄 시설에 대한 사용자 리뷰를 관리. */ @Entity @Table(name = "TBL_REVIEWS") @Getter @@ -28,104 +20,48 @@ @EntityListeners(AuditingEntityListener.class) public class Review { - // 리뷰 고유 식별자 - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - // 리뷰가 작성된 돌봄 시설 - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "FACILITY_ID", nullable = false) private CareFacility careFacility; - - // 리뷰를 작성한 사용자 - // - //

리뷰와 사용자 간의 다대일 관계를 나타냅니다. - // LAZY 로딩을 사용하여 성능을 최적화합니다.

- // - //

사용자가 삭제되면 관련된 모든 리뷰도 함께 삭제됩니다.

- + // 리뷰를 작성한 사용자 리뷰와 사용자 간의 다대일 관계를 나타냅니다. LAZY 로딩을 사용하여 성능을 최적화합니다. 사용자가 삭제되면 관련된 모든 리뷰도 함께 삭제됩니다. @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "USER_ID", nullable = false) private User user; - - // 리뷰 평점 - // - //

사용자가 시설에 대해 매긴 평점으로, 1점부터 5점까지 가능합니다. - // 1점: 매우 불만족, 5점: 매우 만족

- // - //

시설의 전체 평점 계산에 사용되며, 리뷰 검색 및 정렬에도 활용됩니다.

- + // 리뷰 평점 사용자가 시설에 대해 매긴 평점으로, 1점부터 5점까지 가능합니다. 1점: 매우 불만족, 5점: 매우 만족 시설의 전체 평점 계산에 사용되며 @Column(name = "RATING", nullable = false) private Integer rating; - - // 리뷰 내용 - // - //

사용자가 작성한 리뷰의 상세 내용입니다. - // TEXT 타입으로 설정하여 긴 리뷰도 저장할 수 있습니다.

- // - //

null 값이 허용되며, 평점만 있는 리뷰도 가능합니다.

- + // 리뷰 내용 사용자가 작성한 리뷰의 상세 내용입니다. TEXT 타입으로 설정하여 긴 리뷰도 저장할 수 있습니다. null 값이 허용되며, 평점만 있는 리뷰도 가능합니다. @Column(name = "CONTENT", columnDefinition = "TEXT") private String content; - - // 리뷰 검증 여부 - // - //

리뷰가 관리자에 의해 검증되었는지 여부를 나타냅니다. - // 검증된 리뷰만 UI에 표시되며, 신뢰도가 높습니다.

- // - //

기본값은 false이며, 관리자가 수동으로 검증합니다.

- + // 리뷰 검증 여부 리뷰가 관리자에 의해 검증되었는지 여부를 나타냅니다. 검증된 리뷰만 UI에 표시되며, 신뢰도가 높습니다. @Column(name = "IS_VERIFIED", nullable = false) private Boolean isVerified = false; - - // 리뷰 활성 상태 여부 - // - //

리뷰의 활성/비활성 상태를 나타냅니다. - // false인 경우 UI에서 숨겨지고 검색에서 제외됩니다.

- // - //

부적절한 리뷰나 신고된 리뷰를 비활성화할 때 사용됩니다.

- + // 리뷰 활성 상태 여부 리뷰의 활성/비활성 상태를 나타냅니다. false인 경우 UI에서 숨겨지고 검색에서 제외됩니다. @Column(name = "IS_ACTIVE", nullable = false) private Boolean isActive = true; - - // 리뷰 작성 일시 - // - //

리뷰가 데이터베이스에 처음 저장된 시간입니다. - // JPA Auditing에 의해 자동으로 설정되며, 수정할 수 없습니다.

- + // 리뷰 작성 일시 리뷰가 데이터베이스에 처음 저장된 시간입니다. JPA Auditing에 의해 자동으로 설정되며, 수정할 수 없습니다. @CreatedDate @Column(name = "CREATED_AT", nullable = false, updatable = false) private LocalDateTime createdAt; - // 리뷰 수정 일시 - @LastModifiedDate @Column(name = "UPDATED_AT") private LocalDateTime updatedAt; - - // 리뷰 생성자 - // - // @param careFacility 리뷰가 작성된 돌봄 시설 (필수) - // @param user 리뷰를 작성한 사용자 (필수) - // @param rating 리뷰 평점 (1-5점, 필수) - // @param content 리뷰 내용 (선택사항) - // - // @throws IllegalArgumentException rating이 1-5 범위를 벗어나는 경우 - // @throws IllegalArgumentException careFacility 또는 user가 null인 경우 - + // 리뷰 생성자 @param careFacility 리뷰가 작성된 돌봄 시설 (필수) @param user 리뷰를 작성한 사용자 (필수) @param rating 리뷰 평점 @Builder public Review(CareFacility careFacility, User user, Integer rating, String content) { this.careFacility = careFacility; @@ -134,36 +70,23 @@ public Review(CareFacility careFacility, User user, Integer rating, String conte this.content = content; } - - // 리뷰 정보 업데이트 - // - // @param rating 새로운 평점 (1-5점, null이면 기존 값 유지) - // @param content 새로운 리뷰 내용 (null이면 기존 값 유지) - // - // @throws IllegalArgumentException rating이 1-5 범위를 벗어나는 경우 - + // 리뷰 정보 업데이트 @param rating 새로운 평점 (1-5점, null이면 기존 값 유지) @param content 새로운 리뷰 내용 (null이면 기존 값 public void updateReview(Integer rating, String content) { this.rating = rating; this.content = content; } - // 리뷰 검증 - public void verify() { this.isVerified = true; } - // 리뷰 비활성화 - public void deactivate() { this.isActive = false; } - // 리뷰 활성화 - public void activate() { this.isActive = true; } diff --git a/src/main/java/com/carecode/domain/careFacility/mapper/BookingMapper.java b/src/main/java/com/carecode/domain/careFacility/mapper/BookingMapper.java index 1791e70b..90b4316c 100644 --- a/src/main/java/com/carecode/domain/careFacility/mapper/BookingMapper.java +++ b/src/main/java/com/carecode/domain/careFacility/mapper/BookingMapper.java @@ -4,14 +4,10 @@ import com.carecode.domain.careFacility.dto.response.BookingResponse; import com.carecode.domain.careFacility.entity.CareFacilityBooking; -/** - * 예약 Entity와 DTO 간 변환을 담당하는 Mapper 클래스 - */ +/** 예약 Entity와 DTO 간 변환을 담당하는 Mapper 클래스 */ public class BookingMapper { - // Entity를 DTO로 변환 - public static BookingResponse fromEntity(CareFacilityBooking booking) { return BookingResponse.builder() .id(booking.getId()) @@ -33,9 +29,7 @@ public static BookingResponse fromEntity(CareFacilityBooking booking) { .build(); } - // Entity를 목록 DTO로 변환 - public static BookingListResponse toListResponse(CareFacilityBooking booking) { return BookingListResponse.builder() .id(booking.getId()) diff --git a/src/main/java/com/carecode/domain/careFacility/mapper/CareFacilityMapper.java b/src/main/java/com/carecode/domain/careFacility/mapper/CareFacilityMapper.java index a106c5f6..318e66ce 100644 --- a/src/main/java/com/carecode/domain/careFacility/mapper/CareFacilityMapper.java +++ b/src/main/java/com/carecode/domain/careFacility/mapper/CareFacilityMapper.java @@ -34,4 +34,3 @@ public CareFacilityInfo toResponse(CareFacility facility) { } } - diff --git a/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityBookingRepository.java b/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityBookingRepository.java index a991814b..919517e2 100644 --- a/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityBookingRepository.java +++ b/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityBookingRepository.java @@ -14,9 +14,7 @@ import java.time.LocalDateTime; import java.util.List; -/** - * 육아 시설 예약 리포지토리 - */ +/** 육아 시설 예약 리포지토리 */ @Repository public interface CareFacilityBookingRepository extends JpaRepository { @@ -32,15 +30,7 @@ List findByFacilityIdAndStartTimeBetween(@Param("facilityId @Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate); - /** - * 주어진 구간과 실제로 겹치는 유효 예약 수. - * - *

겹침 판정은 {@code 기존.start < 신규.end AND 기존.end > 신규.start} 이다. - * 시작 시각만 비교하면 09:00~18:00 종일 예약이 있어도 11:00 예약이 통과된다. - * 취소/거절된 예약은 자리를 차지하지 않으므로 제외한다. - * - * @param excludeBookingId 예약 수정 시 자기 자신을 충돌로 세지 않기 위한 제외 ID (신규 생성 시 null) - */ + /** 주어진 구간과 실제로 겹치는 유효 예약 수. 겹침 판정은 기존.start < 신규.end AND 기존.end > 신규.start 이다 */ // PESSIMISTIC_WRITE: 검사와 저장 사이에 다른 트랜잭션이 같은 구간을 예약하는 경쟁 조건을 막는다. @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT COUNT(cb) FROM CareFacilityBooking cb " + @@ -59,10 +49,7 @@ long countOverlappingBookings(@Param("facilityId") Long facilityId, // 예약 타입별 예약 수 조회 long countByBookingType(CareFacilityBooking.BookingType bookingType); - // 오늘 예약 목록 조회 - // 주의: HQL 에 DATE(...) 함수는 없다. Hibernate 6 에서는 파싱 단계에서 실패해 - // 리포지토리 빈 생성이 깨지고 애플리케이션이 기동되지 않는다. - // 범위 비교로 바꾸면 startTime 인덱스도 그대로 탈 수 있다. + // 오늘 예약 목록 조회 주의: HQL 에 DATE(...) 함수는 없다. Hibernate 6 에서는 파싱 단계에서 실패해 리포지토리 빈 생성이 깨지고 애플리케이션이 @Query("SELECT cb FROM CareFacilityBooking cb " + "WHERE cb.startTime >= :dayStart AND cb.startTime < :dayEnd ORDER BY cb.startTime ASC") List findBookingsBetween(@Param("dayStart") LocalDateTime dayStart, @@ -123,13 +110,11 @@ Page findBySearchCriteria(@Param("facilityId") Long facilit @Param("keyword") String keyword, Pageable pageable); - // 시설별 예약 통계 @Query("SELECT cb.facility.id, cb.facility.name, COUNT(cb) FROM CareFacilityBooking cb GROUP BY cb.facility.id, cb.facility.name ORDER BY COUNT(cb) DESC") List getFacilityBookingStats(); - // 일별 예약 수 통계 - // DATE(...) 대신 HQL 표준인 cast(... as date) 를 쓴다. 파라미터 타입도 비교 대상과 맞춘다. + // 일별 예약 수 통계 DATE(...) 대신 HQL 표준인 cast(... as date) 를 쓴다. 파라미터 타입도 비교 대상과 맞춘다. @Query("SELECT cast(cb.startTime as date), COUNT(cb) FROM CareFacilityBooking cb " + "WHERE cb.startTime >= :startDate " + "GROUP BY cast(cb.startTime as date) ORDER BY cast(cb.startTime as date) DESC") diff --git a/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java b/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java index 4965e157..77d4d720 100644 --- a/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java +++ b/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java @@ -14,9 +14,7 @@ import java.util.List; import java.util.Optional; -/** - * 육아 시설 리포지토리 인터페이스 - */ +/** 육아 시설 리포지토리 인터페이스 */ @Repository public interface CareFacilityRepository extends JpaRepository { @@ -34,7 +32,6 @@ public interface CareFacilityRepository extends JpaRepository= :childAge))") List findByChildAge(@Param("childAge") Integer childAge); - // 최소 평점 이상의 시설 조회 @Query("SELECT cf FROM CareFacility cf WHERE cf.isActive = true AND cf.rating >= :minRating") @@ -67,7 +64,6 @@ public interface CareFacilityRepository extends JpaRepository findByLocationAndRadius(@Param("latitude") Double latitude, @Param("longitude") Double longitude, @Param("radiusKm") Double radiusKm); - // 복합 조건으로 시설 검색 @Query("SELECT cf FROM CareFacility cf WHERE cf.isActive = true " + @@ -88,7 +84,6 @@ List searchFacilities(@Param("facilityType") FacilityType facility @Param("minAvailableSpots") Integer minAvailableSpots, @Param("maxTuitionFee") Integer maxTuitionFee, @Param("childAge") Integer childAge); - // 전체 조회수 합계 조회 @Query("SELECT COALESCE(SUM(cf.viewCount), 0) FROM CareFacility cf WHERE cf.isActive = true") @@ -105,7 +100,6 @@ List searchFacilities(@Param("facilityType") FacilityType facility @Query("SELECT cf FROM CareFacility cf WHERE cf.isActive = true AND " + "cf.ageRangeMin <= :maxAge AND cf.ageRangeMax >= :minAge") List findByAgeRange(@Param("minAge") int minAge, @Param("maxAge") int maxAge); - // 운영 시간별 시설 조회 @Query("SELECT cf FROM CareFacility cf WHERE cf.isActive = true AND " + @@ -116,13 +110,11 @@ List searchFacilities(@Param("facilityType") FacilityType facility @Query("SELECT cf FROM CareFacility cf WHERE cf.isActive = true " + "ORDER BY cf.rating DESC, cf.reviewCount DESC") List findPopularFacilities(org.springframework.data.domain.Pageable pageable); - // 신규 시설 조회 @Query("SELECT cf FROM CareFacility cf WHERE cf.isActive = true " + "ORDER BY cf.createdAt DESC") List findNewFacilities(org.springframework.data.domain.Pageable pageable); - /** 반경 내 시설 조회. 바운딩 박스로 후보를 좁힌 뒤 정확한 거리를 계산한다. */ @Query(value = "SELECT cf.* FROM (" @@ -163,14 +155,11 @@ Page findBySearchCriteria(@Param("keyword") String keyword, @Param("facilityType") FacilityType facilityType, @Param("address") String address, Pageable pageable); - // ID로 시설 조회 (Reviews와 함께) @Query("SELECT cf FROM CareFacility cf LEFT JOIN FETCH cf.reviews WHERE cf.id = :id") Optional findByIdWithReviews(@Param("id") Long id); - /** - * 조회수를 DB 에서 원자적으로 증가시킨다 (lost update 방지). - */ + /** 조회수를 DB 에서 원자적으로 증가시킨다 (lost update 방지). */ @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("UPDATE CareFacility cf SET cf.viewCount = COALESCE(cf.viewCount, 0) + 1 WHERE cf.id = :facilityId") int incrementViewCount(@Param("facilityId") Long facilityId); diff --git a/src/main/java/com/carecode/domain/careFacility/service/AdmissionForecastService.java b/src/main/java/com/carecode/domain/careFacility/service/AdmissionForecastService.java index 3e9cfee3..5c29b365 100644 --- a/src/main/java/com/carecode/domain/careFacility/service/AdmissionForecastService.java +++ b/src/main/java/com/carecode/domain/careFacility/service/AdmissionForecastService.java @@ -17,10 +17,7 @@ import java.util.ArrayList; import java.util.List; -/** - * 관측된 정원 변동으로 입소 가능 시점을 추정한다. - * 통계적 근거가 부족하면 숫자를 만들어내지 않고 부족하다고 답한다. - */ +/** 관측된 정원 변동으로 입소 가능 시점을 추정한다. 통계적 근거가 부족하면 숫자를 만들어내지 않고 부족하다고 답한다. */ @Slf4j @Service @RequiredArgsConstructor @@ -121,10 +118,7 @@ private AdmissionForecastResponse buildForecast( .build(); } - /** - * 기준선(관측 중 자리 있던 비율)에 자리 발생률을 포아송으로 얹는다. - * 정교한 모델이 아니라 관측을 그대로 반영하는 추정치이며, 근거를 함께 노출해 과신을 막는다. - */ + /** 기준선(관측 중 자리 있던 비율)에 자리 발생률을 포아송으로 얹는다. */ private int estimateProbability(double baseRate, double openingsPerMonth, long months, boolean spansNewTerm) { // 기간 내 자리가 최소 1회 열릴 확률 = 1 - e^(-λt) double openingProbability = 1 - Math.exp(-openingsPerMonth * Math.max(months, 1)); diff --git a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityBookingService.java b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityBookingService.java index f4817d4d..d2c54262 100644 --- a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityBookingService.java +++ b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityBookingService.java @@ -24,10 +24,7 @@ import java.util.List; import java.util.stream.Collectors; -/** - * 육아 시설 예약 서비스 클래스 - * 시설 방문 및 상담 예약 기능을 제공 - */ +/** 육아 시설 예약 서비스 클래스 시설 방문 및 상담 예약 기능을 제공 */ @Slf4j @Service @RequiredArgsConstructor @@ -41,9 +38,7 @@ public class CareFacilityBookingService { private final CareFacilityRepository careFacilityRepository; private final UserRepository userRepository; - // 예약 생성 - @LogExecutionTime @Transactional public BookingResponse createBooking(Long facilityId, CreateBookingRequest request, UserDetails userDetails) { @@ -85,9 +80,7 @@ public BookingResponse createBooking(Long facilityId, CreateBookingRequest reque return convertToDto(savedBooking); } - // 예약 조회 - @LogExecutionTime public BookingResponse getBookingById(Long bookingId, UserDetails userDetails) { CareFacilityBooking booking = bookingRepository.findById(bookingId) @@ -101,9 +94,7 @@ public BookingResponse getBookingById(Long bookingId, UserDetails userDetails) { return convertToDto(booking); } - // 사용자별 예약 목록 조회 - @LogExecutionTime public List getUserBookings(UserDetails userDetails) { User user = userRepository.findByUserId(userDetails.getUsername()) @@ -116,9 +107,7 @@ public List getUserBookings(UserDetails userDetails) { .collect(Collectors.toList()); } - // 시설별 예약 목록 조회 - @LogExecutionTime public List getFacilityBookings(Long facilityId) { List bookings = bookingRepository.findByFacilityIdOrderByStartTimeAsc(facilityId); @@ -128,9 +117,7 @@ public List getFacilityBookings(Long facilityId) { .collect(Collectors.toList()); } - // 예약 상태 업데이트 - @LogExecutionTime @Transactional public BookingResponse updateBookingStatus(Long bookingId, String status, UserDetails userDetails) { @@ -155,9 +142,7 @@ public BookingResponse updateBookingStatus(Long bookingId, String status, UserDe return convertToDto(savedBooking); } - // 예약 취소 - @LogExecutionTime @Transactional public void cancelBooking(Long bookingId, UserDetails userDetails) { @@ -181,9 +166,7 @@ public void cancelBooking(Long bookingId, UserDetails userDetails) { bookingRepository.save(booking); } - // 예약 수정 - @LogExecutionTime @Transactional public BookingResponse updateBooking(Long bookingId, UpdateBookingRequest request, UserDetails userDetails) { @@ -225,9 +208,7 @@ public BookingResponse updateBooking(Long bookingId, UpdateBookingRequest reques return convertToDto(savedBooking); } - // 오늘 예약 목록 조회 - @LogExecutionTime public List getTodayBookings() { List bookings = bookingRepository.findTodayBookings(); @@ -237,9 +218,7 @@ public List getTodayBookings() { .collect(Collectors.toList()); } - // 시설별 오늘 예약 목록 조회 - @LogExecutionTime public List getTodayBookingsByFacility(Long facilityId) { List bookings = bookingRepository.findTodayBookingsByFacility(facilityId); @@ -249,16 +228,9 @@ public List getTodayBookingsByFacility(Long facilityId) { .collect(Collectors.toList()); } - // 예약 시간 중복 확인 - /** - * 예약 가능 여부 검증. - * - *

이전 구현은 시작 시각 ±1시간만 비교해서 (1) 기존 예약의 종료 시각을 무시했고, - * (2) 취소된 예약도 충돌로 셌으며, (3) 시설 정원과 무관하게 1건만 있어도 막았다. - * 여기서는 실제 구간 겹침을 보고, 겹치는 유효 예약 수가 정원 미만일 때만 허용한다. - */ + /** 예약 가능 여부 검증. 이전 구현은 시작 시각 ±1시간만 비교해서 (1) 기존 예약의 종료 시각을 무시했고, (2) 취소된 예약도 충돌로 셌으며 */ private void validateBookingTime(CareFacility facility, LocalDateTime startTime, LocalDateTime endTime, diff --git a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityDataMigrationService.java b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityDataMigrationService.java index 3e872a20..bfb04c7f 100644 --- a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityDataMigrationService.java +++ b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityDataMigrationService.java @@ -14,10 +14,7 @@ import java.util.List; import java.util.Map; -/** - * CareFacility 데이터 마이그레이션 서비스 - * Hospital 데이터를 CareFacility로 변환 - */ +/** CareFacility 데이터 마이그레이션 서비스 */ @Slf4j @Service @RequiredArgsConstructor @@ -32,9 +29,7 @@ public void run(String... args) throws Exception { migrateHospitalDataToCareFacility(); } - // Hospital 데이터를 CareFacility로 마이그레이션 - private void migrateHospitalDataToCareFacility() { // 이미 CareFacility 데이터가 있으면 마이그레이션 건너뛰기 if (careFacilityRepository.count() > 0) { @@ -56,9 +51,7 @@ private void migrateHospitalDataToCareFacility() { } } - // Hospital 데이터로부터 CareFacility 생성 - private CareFacility createCareFacilityFromHospital(Map hospital) { String name = (String) hospital.get("name"); String address = (String) hospital.get("address"); @@ -100,9 +93,7 @@ private CareFacility createCareFacilityFromHospital(Map hospital .build(); } - // 주소에서 시 추출 - private String extractCity(String address) { if (address == null) return "서울시"; @@ -118,9 +109,7 @@ private String extractCity(String address) { return "서울시"; // 기본값 } - // 주소에서 구 추출 - private String extractDistrict(String address) { if (address == null) return "강남구"; diff --git a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java index 714c2415..88287961 100644 --- a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java +++ b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java @@ -37,10 +37,7 @@ import java.util.Optional; import java.util.stream.Collectors; -/** - * 돌봄 시설 서비스 클래스 - * 육아 지원 시설 관련 비즈니스 로직 처리 - */ +/** 돌봄 시설 서비스 클래스 육아 지원 시설 관련 비즈니스 로직 처리 */ @Service @RequiredArgsConstructor @Slf4j @@ -53,7 +50,6 @@ public class CareFacilityService { private final CareFacilityMapper careFacilityMapper; private final FullTextSearchSupport fullTextSearchSupport; - // 공공데이터 API에서 받아온 보육시설 데이터를 DB에 저장 @Transactional public void saveCareFacilitiesFromPublicData(Map publicData) { @@ -100,7 +96,6 @@ public void saveCareFacilitiesFromPublicData(Map publicData) { } - // 공공데이터로부터 새로운 CareFacility 엔티티 생성 private CareFacility createCareFacilityFromPublicData(Map facilityData) { return CareFacility.builder() @@ -126,9 +121,7 @@ private CareFacility createCareFacilityFromPublicData(Map facili } - // 기존 CareFacility 엔티티를 공공데이터로 업데이트 - private void updateCareFacilityFromPublicData(CareFacility existingFacility, Map facilityData) { existingFacility.setName((String) facilityData.get("facilityName")); existingFacility.setFacilityType(mapServiceTypeToFacilityType((String) facilityData.get("serviceType"))); @@ -147,9 +140,7 @@ private void updateCareFacilityFromPublicData(CareFacility existingFacility, Map careFacilityRepository.save(existingFacility); } - // 서비스 타입을 FacilityType으로 매핑 - private FacilityType mapServiceTypeToFacilityType(String serviceType) { if (serviceType == null) { return FacilityType.OTHER; @@ -169,9 +160,7 @@ private FacilityType mapServiceTypeToFacilityType(String serviceType) { } } - // 운영시간 정보 포맷팅 - private String formatOperatingHours(Map facilityData) { StringBuilder hours = new StringBuilder(); @@ -197,9 +186,7 @@ private String formatOperatingHours(Map facilityData) { return hours.toString(); } - // 시설 설명 생성 - private String generateDescription(Map facilityData) { StringBuilder description = new StringBuilder(); @@ -227,13 +214,9 @@ private String generateDescription(Map facilityData) { return description.toString(); } - // 돌봄 시설 목록 조회 - /** - * 시설 목록 조회. - *

테이블 전체를 메모리로 올리지 않도록 항상 페이지 단위로 읽는다. - */ + /** 시설 목록 조회. 테이블 전체를 메모리로 올리지 않도록 항상 페이지 단위로 읽는다. */ @LogExecutionTime public List getAllCareFacilities(int page, int size) { @@ -243,16 +226,12 @@ public List getAllCareFacilities(int page, int size) { .collect(Collectors.toList()); } - // 전체 시설 수 - public long countCareFacilities() { return careFacilityRepository.count(); } - // 돌봄 시설 상세 조회 - @LogExecutionTime @Cacheable(cacheNames = "careFacility", key = "#facilityId") public CareFacilityInfo getCareFacilityById(Long facilityId) { @@ -262,9 +241,7 @@ public CareFacilityInfo getCareFacilityById(Long facilityId) { return careFacilityMapper.toResponse(facility); } - // 돌봄 시설 검색 - @LogExecutionTime @ValidateLocation public CareFacilityListResponse searchCareFacilities(CareFacilitySearchRequest request) { @@ -288,7 +265,6 @@ public CareFacilityListResponse searchCareFacilities(CareFacilitySearchRequest r request.getKeyword(), null, request.getCity(), pageable); } - List facilities = facilityPage.getContent().stream() .map(careFacilityMapper::toResponse) .collect(Collectors.toList()); @@ -303,9 +279,7 @@ public CareFacilityListResponse searchCareFacilities(CareFacilitySearchRequest r .build(); } - // 시설 유형별 조회 - @LogExecutionTime public List getCareFacilitiesByType(FacilityType facilityType) { List facilities = careFacilityRepository.findByFacilityType(facilityType); @@ -314,9 +288,7 @@ public List getCareFacilitiesByType(FacilityType facilityType) .collect(Collectors.toList()); } - // 지역별 돌봄 시설 조회 - @LogExecutionTime @ValidateLocation public List getCareFacilitiesByLocation(String location) { @@ -326,9 +298,7 @@ public List getCareFacilitiesByLocation(String location) { .collect(Collectors.toList()); } - // 반경 내 돌봄 시설 조회 - @LogExecutionTime @ValidateLocation public List getCareFacilitiesWithinRadius(Double latitude, Double longitude, Double radius) { @@ -341,9 +311,7 @@ public List getCareFacilitiesWithinRadius(Double latitude, Dou .collect(Collectors.toList()); } - // 연령대별 돌봄 시설 조회 - @LogExecutionTime public List getCareFacilitiesByAgeRange(int minAge, int maxAge) { List facilities = careFacilityRepository.findByAgeRange(minAge, maxAge); @@ -352,9 +320,7 @@ public List getCareFacilitiesByAgeRange(int minAge, int maxAge .collect(Collectors.toList()); } - // 운영 시간별 돌봄 시설 조회 - @LogExecutionTime public List getCareFacilitiesByOperatingHours(String operatingHours) { List facilities = careFacilityRepository.findByOperatingHours(operatingHours); @@ -363,9 +329,7 @@ public List getCareFacilitiesByOperatingHours(String operating .collect(Collectors.toList()); } - // 인기 돌봄 시설 조회 (평점 기준) - @LogExecutionTime public List getPopularCareFacilities(int limit) { Pageable pageable = PageRequest.of(0, limit); @@ -375,9 +339,7 @@ public List getPopularCareFacilities(int limit) { .collect(Collectors.toList()); } - // 신규 돌봄 시설 조회 - @LogExecutionTime public List getNewCareFacilities(int limit) { Pageable pageable = PageRequest.of(0, limit); @@ -387,9 +349,7 @@ public List getNewCareFacilities(int limit) { .collect(Collectors.toList()); } - // 돌봄 시설 조회수 증가 - @Transactional public void incrementViewCount(Long facilityId) { // DB 에서 원자적으로 증가시킨다. 갱신된 행이 없으면 존재하지 않는 시설이다. @@ -399,9 +359,7 @@ public void incrementViewCount(Long facilityId) { } } - // 돌봄 시설 평점 업데이트 - @Transactional public void updateRating(Long facilityId, Double rating) { CareFacility facility = careFacilityRepository.findById(facilityId) @@ -418,9 +376,7 @@ public void updateRating(Long facilityId, Double rating) { careFacilityRepository.save(facility); } - // 돌봄 시설 통계 조회 - @LogExecutionTime public CareFacilityStatsResponse getFacilityStats() { long totalFacilities = careFacilityRepository.count(); @@ -439,9 +395,7 @@ public CareFacilityStatsResponse getFacilityStats() { .build(); } - // 아이 연령별 시설 추천 - @LogExecutionTime public List recommendFacilitiesByChildAge(Integer childAge) { List facilities = careFacilityRepository.findByChildAge(childAge); @@ -450,9 +404,7 @@ public List recommendFacilitiesByChildAge(Integer childAge) { .collect(Collectors.toList()); } - // 최소 평점 이상의 시설 조회 - @LogExecutionTime public List getFacilitiesByMinRating(Double minRating) { List facilities = careFacilityRepository.findByMinRating(minRating); @@ -461,9 +413,7 @@ public List getFacilitiesByMinRating(Double minRating) { .collect(Collectors.toList()); } - // 빈 자리가 있는 시설 조회 - @LogExecutionTime public List getFacilitiesWithAvailableSpots(Integer minSpots) { List facilities = careFacilityRepository.findByAvailableSpots(minSpots); @@ -472,9 +422,7 @@ public List getFacilitiesWithAvailableSpots(Integer minSpots) .collect(Collectors.toList()); } - // 등록금 범위로 시설 조회 - @LogExecutionTime public List getFacilitiesByMaxTuitionFee(Integer maxFee) { List facilities = careFacilityRepository.findByMaxTuitionFee(maxFee); @@ -483,9 +431,7 @@ public List getFacilitiesByMaxTuitionFee(Integer maxFee) { .collect(Collectors.toList()); } - // 키워드로 시설 검색 - @LogExecutionTime public List searchFacilitiesByKeyword(String keyword) { List facilities = careFacilityRepository.searchByKeyword(keyword); @@ -494,9 +440,7 @@ public List searchFacilitiesByKeyword(String keyword) { .collect(Collectors.toList()); } - // 복합 조건으로 시설 검색 (고급 검색) - @LogExecutionTime public List searchFacilitiesAdvanced( FacilityType facilityType, @@ -515,9 +459,7 @@ public List searchFacilitiesAdvanced( .collect(Collectors.toList()); } - // 리뷰와 함께 시설 상세 조회 - @LogExecutionTime public CareFacilityInfo getFacilityByIdWithReviews(Long facilityId) { CareFacility facility = careFacilityRepository.findByIdWithReviews(facilityId) @@ -592,7 +534,6 @@ private ReviewResponse toReviewResponse(Review review) { .build(); } - // Entity를 DTO로 변환 // 매핑은 CareFacilityMapper 사용 diff --git a/src/main/java/com/carecode/domain/careFacility/service/FacilityPopularityService.java b/src/main/java/com/carecode/domain/careFacility/service/FacilityPopularityService.java index b56083bf..97371cc1 100644 --- a/src/main/java/com/carecode/domain/careFacility/service/FacilityPopularityService.java +++ b/src/main/java/com/carecode/domain/careFacility/service/FacilityPopularityService.java @@ -15,11 +15,7 @@ import java.util.ArrayList; import java.util.List; -/** - * 충원율 추이로 시설 인기도를 추정한다. - * 평가인증은 대부분 최고등급이라 변별력이 없고 리뷰는 조작될 수 있지만, - * 충원율은 공공데이터가 원천이라 시설이 개입할 수 없다. - */ +/** 충원율 추이로 시설 인기도를 추정한다. */ @Slf4j @Service @RequiredArgsConstructor diff --git a/src/main/java/com/carecode/domain/chatbot/app/ChatbotFacade.java b/src/main/java/com/carecode/domain/chatbot/app/ChatbotFacade.java index 6fe1007d..afdd67c4 100644 --- a/src/main/java/com/carecode/domain/chatbot/app/ChatbotFacade.java +++ b/src/main/java/com/carecode/domain/chatbot/app/ChatbotFacade.java @@ -76,4 +76,3 @@ public long getSessionCountByUser(String userId) { } } - diff --git a/src/main/java/com/carecode/domain/chatbot/controller/ChatbotController.java b/src/main/java/com/carecode/domain/chatbot/controller/ChatbotController.java index 875d663c..804e5b7a 100644 --- a/src/main/java/com/carecode/domain/chatbot/controller/ChatbotController.java +++ b/src/main/java/com/carecode/domain/chatbot/controller/ChatbotController.java @@ -22,10 +22,7 @@ import java.util.List; import java.util.Map; -/** - * 챗봇 API 컨트롤러 - * 육아 관련 챗봇 서비스 - */ +/** 챗봇 API 컨트롤러 육아 관련 챗봇 서비스 */ @RestController @RequestMapping("/chatbot") @RequiredArgsConstructor @@ -37,12 +34,10 @@ public class ChatbotController extends BaseController { private final ChatbotFacade chatbotFacade; private final CurrentUserFacade currentUserFacade; - // 챗봇 메시지 전송 - @PostMapping("/chat") @LogExecutionTime - @Operation(summary = "챗봇 메시지 전송", description = "챗봇과 대화를 시작합니다.") + @Operation(summary = "챗봇 메시지 전송", description = "챗봇과 대화를 시작") public ResponseEntity sendMessage( @Parameter(description = "챗봇 요청 정보", required = true) @RequestBody ChatbotMessageRequest request) { String userId = currentUserFacade.requireCurrentUserId(); @@ -61,12 +56,10 @@ public ResponseEntity sendMessage( } } - // 대화 기록 조회 - @GetMapping("/history") @LogExecutionTime - @Operation(summary = "대화 기록 조회", description = "사용자의 챗봇 대화 기록을 조회합니다.") + @Operation(summary = "대화 기록 조회", description = "사용자의 챗봇 대화 기록 조회") public ResponseEntity> getChatHistory( @Parameter(description = "세션 ID") @RequestParam(required = false) String sessionId, @Parameter(description = "페이지 번호") @RequestParam(defaultValue = "0") int page, @@ -83,12 +76,10 @@ public ResponseEntity> getChatHistory( } } - // 세션 목록 조회 - @GetMapping("/sessions") @LogExecutionTime - @Operation(summary = "세션 목록 조회", description = "사용자의 챗봇 세션 목록을 조회합니다.") + @Operation(summary = "세션 목록 조회", description = "사용자의 챗봇 세션 목록 조회") public ResponseEntity> getSessions( @Parameter(description = "페이지 번호") @RequestParam(defaultValue = "0") int page, @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "10") int size) { @@ -104,12 +95,10 @@ public ResponseEntity> getSessions( } } - // 메시지 피드백 처리 - @PostMapping("/feedback") @LogExecutionTime - @Operation(summary = "메시지 피드백 처리", description = "챗봇 메시지에 대한 피드백을 처리합니다.") + @Operation(summary = "메시지 피드백 처리", description = "챗봇 메시지에 대한 피드백 처리") public ResponseEntity processFeedback( @Parameter(description = "메시지 ID", required = true) @RequestParam Long messageId, @Parameter(description = "도움됨 여부", required = true) @RequestParam boolean isHelpful) { @@ -130,14 +119,13 @@ public ResponseEntity processFeedback( } } - // ==================== 챗봇 필터링 기능 ==================== - + // ==================== + // 챗봇 필터링 기능 ==================== // 의도 타입별 메시지 조회 - @GetMapping("/messages/intent") @LogExecutionTime - @Operation(summary = "의도 타입별 메시지 조회", description = "특정 의도 타입의 메시지를 조회합니다.") + @Operation(summary = "의도 타입별 메시지 조회") public ResponseEntity> getMessagesByIntentType( @Parameter(description = "사용자 ID", required = true) @RequestParam String userId, @Parameter(description = "의도 타입 (GREETING, QUESTION, COMPLAINT, THANKS, GOODBYE, HEALTH_INFO, UNKNOWN)", required = true) @RequestParam String intentType) { @@ -146,12 +134,10 @@ public ResponseEntity> getMessagesByIntentTy return ResponseEntity.ok(messages); } - // 기간별 메시지 조회 - @GetMapping("/messages/date-range") @LogExecutionTime - @Operation(summary = "기간별 메시지 조회", description = "특정 기간의 메시지를 조회합니다.") + @Operation(summary = "기간별 메시지 조회") public ResponseEntity> getMessagesByDateRange( @Parameter(description = "사용자 ID", required = true) @RequestParam String userId, @Parameter(description = "시작일시 (yyyy-MM-ddTHH:mm:ss)", required = true) @RequestParam String startDate, @@ -161,12 +147,10 @@ public ResponseEntity> getMessagesByDateRang return ResponseEntity.ok(messages); } - // 도움됨 여부별 메시지 조회 - @GetMapping("/messages/helpful") @LogExecutionTime - @Operation(summary = "도움됨 여부별 메시지 조회", description = "도움됨/도움 안됨 여부별 메시지를 조회합니다.") + @Operation(summary = "도움됨 여부별 메시지 조회") public ResponseEntity> getMessagesByHelpfulStatus( @Parameter(description = "사용자 ID", required = true) @RequestParam String userId, @Parameter(description = "도움됨 여부", required = true) @RequestParam Boolean isHelpful) { @@ -174,12 +158,10 @@ public ResponseEntity> getMessagesByHelpfulS return ResponseEntity.ok(messages); } - // 키워드로 메시지 검색 - @GetMapping("/messages/search") @LogExecutionTime - @Operation(summary = "키워드로 메시지 검색", description = "키워드로 메시지를 검색합니다.") + @Operation(summary = "키워드로 메시지 검색") public ResponseEntity> searchMessagesByKeyword( @Parameter(description = "사용자 ID", required = true) @RequestParam String userId, @Parameter(description = "검색 키워드", required = true) @RequestParam String keyword) { @@ -187,12 +169,10 @@ public ResponseEntity> searchMessagesByKeywo return ResponseEntity.ok(messages); } - // 상태별 세션 조회 - @GetMapping("/sessions/status") @LogExecutionTime - @Operation(summary = "상태별 세션 조회", description = "특정 상태의 세션을 조회합니다.") + @Operation(summary = "상태별 세션 조회", description = "특정 상태의 세션 조회") public ResponseEntity> getSessionsByStatus( @Parameter(description = "사용자 ID", required = true) @RequestParam String userId, @Parameter(description = "세션 상태 (ACTIVE, INACTIVE, CLOSED)", required = true) @RequestParam String status) { @@ -201,12 +181,10 @@ public ResponseEntity> getSessionsByStatus( return ResponseEntity.ok(sessions); } - // 기간별 세션 조회 - @GetMapping("/sessions/date-range") @LogExecutionTime - @Operation(summary = "기간별 세션 조회", description = "특정 기간의 세션을 조회합니다.") + @Operation(summary = "기간별 세션 조회", description = "특정 기간의 세션 조회") public ResponseEntity> getSessionsByDateRange( @Parameter(description = "사용자 ID", required = true) @RequestParam String userId, @Parameter(description = "시작일시 (yyyy-MM-ddTHH:mm:ss)", required = true) @RequestParam String startDate, @@ -216,12 +194,10 @@ public ResponseEntity> getSessionsByDateRange( return ResponseEntity.ok(sessions); } - // 사용자별 세션 수 조회 - @GetMapping("/sessions/count") @LogExecutionTime - @Operation(summary = "사용자별 세션 수 조회", description = "사용자의 전체 세션 수를 조회합니다.") + @Operation(summary = "사용자별 세션 수 조회") public ResponseEntity> getSessionCountByUser( @Parameter(description = "사용자 ID", required = true) @RequestParam String userId) { long count = chatbotFacade.getSessionCountByUser(userId); diff --git a/src/main/java/com/carecode/domain/chatbot/dto/request/ChatbotAskQuestionRequest.java b/src/main/java/com/carecode/domain/chatbot/dto/request/ChatbotAskQuestionRequest.java index 964cce2e..1761ff66 100644 --- a/src/main/java/com/carecode/domain/chatbot/dto/request/ChatbotAskQuestionRequest.java +++ b/src/main/java/com/carecode/domain/chatbot/dto/request/ChatbotAskQuestionRequest.java @@ -9,9 +9,7 @@ import java.util.Map; -/** - * 챗봇 질문 요청 - */ +/** 챗봇 질문 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/chatbot/dto/request/ChatbotContextualQuestionRequest.java b/src/main/java/com/carecode/domain/chatbot/dto/request/ChatbotContextualQuestionRequest.java index 4c507814..543731aa 100644 --- a/src/main/java/com/carecode/domain/chatbot/dto/request/ChatbotContextualQuestionRequest.java +++ b/src/main/java/com/carecode/domain/chatbot/dto/request/ChatbotContextualQuestionRequest.java @@ -9,9 +9,7 @@ import java.util.Map; -/** - * 컨텍스트 질문 요청 - */ +/** 컨텍스트 질문 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/chatbot/dto/request/ChatbotFeedbackRequest.java b/src/main/java/com/carecode/domain/chatbot/dto/request/ChatbotFeedbackRequest.java index 457c51a7..70e221da 100644 --- a/src/main/java/com/carecode/domain/chatbot/dto/request/ChatbotFeedbackRequest.java +++ b/src/main/java/com/carecode/domain/chatbot/dto/request/ChatbotFeedbackRequest.java @@ -7,9 +7,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 피드백 요청 - */ +/** 피드백 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/chatbot/dto/request/ChatbotMessageRequest.java b/src/main/java/com/carecode/domain/chatbot/dto/request/ChatbotMessageRequest.java index 86869d16..f214a434 100644 --- a/src/main/java/com/carecode/domain/chatbot/dto/request/ChatbotMessageRequest.java +++ b/src/main/java/com/carecode/domain/chatbot/dto/request/ChatbotMessageRequest.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 기본 챗봇 요청 DTO - */ +/** 기본 챗봇 요청 DTO */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/chatbot/dto/request/ChatbotStartSessionRequest.java b/src/main/java/com/carecode/domain/chatbot/dto/request/ChatbotStartSessionRequest.java index 4cb9985f..bd54ad99 100644 --- a/src/main/java/com/carecode/domain/chatbot/dto/request/ChatbotStartSessionRequest.java +++ b/src/main/java/com/carecode/domain/chatbot/dto/request/ChatbotStartSessionRequest.java @@ -9,9 +9,7 @@ import java.util.Map; -/** - * 세션 시작 요청 - */ +/** 세션 시작 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatMessageDto.java b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatMessageDto.java index 8092d89e..df078de7 100644 --- a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatMessageDto.java +++ b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatMessageDto.java @@ -8,9 +8,7 @@ import java.time.LocalDateTime; -/** - * 채팅 메시지 DTO - */ +/** 채팅 메시지 DTO */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotChatHistoryDtoResponse.java b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotChatHistoryDtoResponse.java index 1a17bdc0..18d35a24 100644 --- a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotChatHistoryDtoResponse.java +++ b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotChatHistoryDtoResponse.java @@ -8,9 +8,7 @@ import java.time.LocalDateTime; -/** - * 대화 기록 응답 DTO - */ +/** 대화 기록 응답 DTO */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotChatMessageResponse.java b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotChatMessageResponse.java index 77b45a0b..1a87ed89 100644 --- a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotChatMessageResponse.java +++ b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotChatMessageResponse.java @@ -9,9 +9,7 @@ import java.time.LocalDateTime; import java.util.Map; -/** - * 채팅 메시지 응답 - */ +/** 채팅 메시지 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotFeedbackDtoResponse.java b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotFeedbackDtoResponse.java index 047de5d9..9f6f0153 100644 --- a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotFeedbackDtoResponse.java +++ b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotFeedbackDtoResponse.java @@ -8,9 +8,7 @@ import java.time.LocalDateTime; -/** - * 피드백 응답 DTO - */ +/** 피드백 응답 DTO */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotFeedbackResponse.java b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotFeedbackResponse.java index 833e4e29..55703d9e 100644 --- a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotFeedbackResponse.java +++ b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotFeedbackResponse.java @@ -8,9 +8,7 @@ import java.time.LocalDateTime; -/** - * 피드백 응답 - */ +/** 피드백 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotInfoResponse.java b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotInfoResponse.java index b8e6e53c..6df8ef61 100644 --- a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotInfoResponse.java +++ b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotInfoResponse.java @@ -10,9 +10,7 @@ import java.util.List; import java.util.Map; -/** - * 챗봇 응답 - */ +/** 챗봇 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotKnowledgeBaseResponse.java b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotKnowledgeBaseResponse.java index 86c2fdd9..8663d834 100644 --- a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotKnowledgeBaseResponse.java +++ b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotKnowledgeBaseResponse.java @@ -9,9 +9,7 @@ import java.time.LocalDateTime; import java.util.List; -/** - * 지식베이스 응답 - */ +/** 지식베이스 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotMessageResponse.java b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotMessageResponse.java index 228e1238..3cbadcb1 100644 --- a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotMessageResponse.java +++ b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotMessageResponse.java @@ -8,9 +8,7 @@ import java.time.LocalDateTime; -/** - * 챗봇 메시지 응답 DTO - */ +/** 챗봇 메시지 응답 DTO */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotRelatedInfoResponse.java b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotRelatedInfoResponse.java index 2db93d32..15a0a36b 100644 --- a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotRelatedInfoResponse.java +++ b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotRelatedInfoResponse.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 관련 정보 응답 - */ +/** 관련 정보 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotSessionDtoResponse.java b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotSessionDtoResponse.java index 6b3890d8..6557c2b7 100644 --- a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotSessionDtoResponse.java +++ b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotSessionDtoResponse.java @@ -8,9 +8,7 @@ import java.time.LocalDateTime; -/** - * 세션 응답 DTO - */ +/** 세션 응답 DTO */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotSessionResponse.java b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotSessionResponse.java index 5d9550e8..9544eb06 100644 --- a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotSessionResponse.java +++ b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotSessionResponse.java @@ -9,9 +9,7 @@ import java.time.LocalDateTime; import java.util.Map; -/** - * 세션 응답 - */ +/** 세션 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotStatsResponse.java b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotStatsResponse.java index c4c210bb..54af6c4c 100644 --- a/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotStatsResponse.java +++ b/src/main/java/com/carecode/domain/chatbot/dto/response/ChatbotStatsResponse.java @@ -9,9 +9,7 @@ import java.util.List; import java.util.Map; -/** - * 챗봇 통계 응답 - */ +/** 챗봇 통계 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/chatbot/entity/ChatMessage.java b/src/main/java/com/carecode/domain/chatbot/entity/ChatMessage.java index 6f6c7533..04eada66 100644 --- a/src/main/java/com/carecode/domain/chatbot/entity/ChatMessage.java +++ b/src/main/java/com/carecode/domain/chatbot/entity/ChatMessage.java @@ -8,10 +8,7 @@ import java.time.LocalDateTime; -/** - * 챗봇 대화 메시지 엔티티 - * 사용자와 챗봇 간의 대화 기록을 저장 - */ +/** 챗봇 대화 메시지 엔티티 사용자와 챗봇 간의 대화 기록을 저장 */ @Entity @Table(name = "TBL_CHAT_MESSAGES") @Getter diff --git a/src/main/java/com/carecode/domain/chatbot/entity/ChatSession.java b/src/main/java/com/carecode/domain/chatbot/entity/ChatSession.java index 8e6bac87..51c98e92 100644 --- a/src/main/java/com/carecode/domain/chatbot/entity/ChatSession.java +++ b/src/main/java/com/carecode/domain/chatbot/entity/ChatSession.java @@ -8,10 +8,7 @@ import java.time.LocalDateTime; -/** - * 챗봇 대화 세션 엔티티 - * 사용자별 대화 세션 정보를 관리 - */ +/** 챗봇 대화 세션 엔티티 사용자별 대화 세션 정보를 관리 */ @Entity @Table(name = "TBL_CHAT_SESSIONS") @Getter @@ -59,9 +56,7 @@ public class ChatSession { @Column private LocalDateTime endedAt; - // 세션 상태 - public enum SessionStatus { ACTIVE, // 활성 PAUSED, // 일시정지 diff --git a/src/main/java/com/carecode/domain/chatbot/llm/ChatCompletionClient.java b/src/main/java/com/carecode/domain/chatbot/llm/ChatCompletionClient.java index aa074a5a..366128b4 100644 --- a/src/main/java/com/carecode/domain/chatbot/llm/ChatCompletionClient.java +++ b/src/main/java/com/carecode/domain/chatbot/llm/ChatCompletionClient.java @@ -4,20 +4,11 @@ import java.util.Optional; -/** - * 챗봇 응답 생성기. - * - *

구현체를 바꾸면 LLM 공급자를 교체할 수 있다. - * 사용 불가 상태({@link #isAvailable()} == false)면 호출부가 룰 기반 응답으로 폴백한다. - */ +/** 챗봇 응답 생성기. 구현체를 바꾸면 LLM 공급자를 교체할 수 있다. */ public interface ChatCompletionClient { boolean isAvailable(); - /** - * 검색된 근거를 바탕으로 답변을 생성한다. - * - * @return 생성 실패 시 {@link Optional#empty()} — 예외를 던지지 않는다 - */ + /** 검색된 근거를 바탕으로 답변을 생성한다. */ Optional generateReply(String userMessage, RetrievedContext context); } diff --git a/src/main/java/com/carecode/domain/chatbot/llm/ClaudeChatCompletionClient.java b/src/main/java/com/carecode/domain/chatbot/llm/ClaudeChatCompletionClient.java index 6c791f8e..1d687b9f 100644 --- a/src/main/java/com/carecode/domain/chatbot/llm/ClaudeChatCompletionClient.java +++ b/src/main/java/com/carecode/domain/chatbot/llm/ClaudeChatCompletionClient.java @@ -13,12 +13,7 @@ import java.util.Optional; import java.util.stream.Collectors; -/** - * Claude API 기반 챗봇 응답 생성기. - * - *

API 키가 없으면 비활성 상태로 동작하고, 호출부가 기존 룰 기반 응답으로 폴백한다. - * 로컬/CI 환경에서 키 없이도 애플리케이션이 뜨도록 하기 위함이다. - */ +/** Claude API 기반 챗봇 응답 생성기. API 키가 없으면 비활성 상태로 동작하고, 호출부가 기존 룰 기반 응답으로 폴백한다 */ @Slf4j @Component public class ClaudeChatCompletionClient implements ChatCompletionClient { @@ -91,10 +86,7 @@ public Optional generateReply(String userMessage, RetrievedContext conte } } - /** - * 검색된 근거를 프롬프트에 넣는다. - * 근거가 없으면 그 사실을 명시해 모델이 없는 정보를 지어내지 않게 한다. - */ + /** 검색된 근거를 프롬프트에 넣는다. 근거가 없으면 그 사실을 명시해 모델이 없는 정보를 지어내지 않게 한다. */ private String buildUserPrompt(String userMessage, RetrievedContext context) { StringBuilder sb = new StringBuilder(); sb.append("<참고자료>\n"); diff --git a/src/main/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetriever.java b/src/main/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetriever.java index 07d2ac7f..5d5327a0 100644 --- a/src/main/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetriever.java +++ b/src/main/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetriever.java @@ -125,10 +125,7 @@ List extractKeywords(String question) { return keywords.size() > MAX_KEYWORDS ? keywords.subList(0, MAX_KEYWORDS) : keywords; } - /** - * 흔한 조사를 떼어낸다. 형태소 분석기 없이 "어린이집은" → "어린이집" 정도만 맞춘다. - * 조사를 떼면 2자 미만이 되는 짧은 단어는 원형을 유지한다. - */ + /** 흔한 조사를 떼어낸다. */ private String stripJosa(String token) { for (String josa : JOSA) { if (token.length() > josa.length() + 1 && token.endsWith(josa)) { diff --git a/src/main/java/com/carecode/domain/chatbot/rag/RetrievedContext.java b/src/main/java/com/carecode/domain/chatbot/rag/RetrievedContext.java index 95f49458..2b397b12 100644 --- a/src/main/java/com/carecode/domain/chatbot/rag/RetrievedContext.java +++ b/src/main/java/com/carecode/domain/chatbot/rag/RetrievedContext.java @@ -5,9 +5,7 @@ import java.util.List; -/** - * 챗봇 답변의 근거로 사용할 검색 결과. - */ +/** 챗봇 답변의 근거로 사용할 검색 결과. */ @Getter @Builder public class RetrievedContext { diff --git a/src/main/java/com/carecode/domain/chatbot/repository/ChatMessageRepository.java b/src/main/java/com/carecode/domain/chatbot/repository/ChatMessageRepository.java index ba8d3b10..e32c4f17 100644 --- a/src/main/java/com/carecode/domain/chatbot/repository/ChatMessageRepository.java +++ b/src/main/java/com/carecode/domain/chatbot/repository/ChatMessageRepository.java @@ -12,9 +12,7 @@ import java.time.LocalDateTime; import java.util.List; -/** - * 챗봇 메시지 리포지토리 - */ +/** 챗봇 메시지 리포지토리 */ @Repository public interface ChatMessageRepository extends JpaRepository { diff --git a/src/main/java/com/carecode/domain/chatbot/repository/ChatSessionRepository.java b/src/main/java/com/carecode/domain/chatbot/repository/ChatSessionRepository.java index db77fc94..5746159b 100644 --- a/src/main/java/com/carecode/domain/chatbot/repository/ChatSessionRepository.java +++ b/src/main/java/com/carecode/domain/chatbot/repository/ChatSessionRepository.java @@ -13,9 +13,7 @@ import java.util.List; import java.util.Optional; -/** - * 챗봇 세션 리포지토리 - */ +/** 챗봇 세션 리포지토리 */ @Repository public interface ChatSessionRepository extends JpaRepository { diff --git a/src/main/java/com/carecode/domain/chatbot/service/ChatbotService.java b/src/main/java/com/carecode/domain/chatbot/service/ChatbotService.java index 3073a2a8..de366515 100644 --- a/src/main/java/com/carecode/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/carecode/domain/chatbot/service/ChatbotService.java @@ -30,10 +30,7 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; -/** - * 챗봇 서비스 클래스 - * 육아 관련 챗봇 기능을 제공 - */ +/** 챗봇 서비스 클래스 육아 관련 챗봇 기능을 제공 */ @Slf4j @Service @RequiredArgsConstructor @@ -107,9 +104,7 @@ public class ChatbotService { )); } - // 챗봇 메시지 처리 - @LogExecutionTime @Transactional public ChatbotMessageResponse processMessage(ChatbotMessageRequest request) { @@ -154,9 +149,7 @@ public ChatbotMessageResponse processMessage(ChatbotMessageRequest request) { } } - // 대화 기록 조회 - @LogExecutionTime public List getChatHistory(String userId, String sessionId, int page, int size) { log.info("대화 기록 조회: 사용자ID={}, 세션ID={}", userId, sessionId); @@ -183,9 +176,7 @@ public List getChatHistory(String userId, String } } - // 세션 목록 조회 - @LogExecutionTime public List getSessions(String userId, int page, int size) { log.info("세션 목록 조회: 사용자ID={}", userId); @@ -206,9 +197,7 @@ public List getSessions(String userId, int page, int } } - // 메시지 피드백 처리 - @LogExecutionTime @Transactional public void processFeedback(Long messageId, boolean isHelpful) { @@ -227,9 +216,7 @@ public void processFeedback(Long messageId, boolean isHelpful) { } } - // 의도 타입별 메시지 조회 - @LogExecutionTime public List getMessagesByIntentType(String userId, ChatMessage.IntentType intentType) { log.info("의도 타입별 메시지 조회: 사용자ID={}, 의도타입={}", userId, intentType); @@ -248,9 +235,7 @@ public List getMessagesByIntentType(String userId } } - // 기간별 메시지 조회 - @LogExecutionTime public List getMessagesByDateRange(String userId, LocalDateTime startDate, LocalDateTime endDate) { log.info("기간별 메시지 조회: 사용자ID={}, 시작일={}, 종료일={}", userId, startDate, endDate); @@ -269,9 +254,7 @@ public List getMessagesByDateRange(String userId, } } - // 도움됨 여부별 메시지 조회 - @LogExecutionTime public List getMessagesByHelpfulStatus(String userId, Boolean isHelpful) { log.info("도움됨 여부별 메시지 조회: 사용자ID={}, 도움됨={}", userId, isHelpful); @@ -290,9 +273,7 @@ public List getMessagesByHelpfulStatus(String use } } - // 키워드로 메시지 검색 - @LogExecutionTime public List searchMessagesByKeyword(String userId, String keyword) { log.info("키워드로 메시지 검색: 사용자ID={}, 키워드={}", userId, keyword); @@ -311,9 +292,7 @@ public List searchMessagesByKeyword(String userId } } - // 상태별 세션 조회 - @LogExecutionTime public List getSessionsByStatus(String userId, ChatSession.SessionStatus status) { log.info("상태별 세션 조회: 사용자ID={}, 상태={}", userId, status); @@ -332,9 +311,7 @@ public List getSessionsByStatus(String userId, ChatSe } } - // 기간별 세션 조회 - @LogExecutionTime public List getSessionsByDateRange(String userId, LocalDateTime startDate, LocalDateTime endDate) { log.info("기간별 세션 조회: 사용자ID={}, 시작일={}, 종료일={}", userId, startDate, endDate); @@ -353,9 +330,7 @@ public List getSessionsByDateRange(String userId, Loc } } - // 사용자별 세션 수 조회 - @LogExecutionTime public long getSessionCountByUser(String userId) { log.info("사용자별 세션 수 조회: 사용자ID={}", userId); @@ -370,9 +345,7 @@ public long getSessionCountByUser(String userId) { } } - // 세션 생성 또는 조회 - private ChatSession getOrCreateSession(User user, String sessionId) { if (sessionId != null && !sessionId.isEmpty()) { return chatSessionRepository.findBySessionId(sessionId) @@ -382,9 +355,7 @@ private ChatSession getOrCreateSession(User user, String sessionId) { } } - // 새 세션 생성 - private ChatSession createNewSession(User user, String sessionId) { ChatSession session = ChatSession.builder() .sessionId(sessionId) @@ -400,9 +371,7 @@ private ChatSession createNewSession(User user, String sessionId) { return chatSessionRepository.save(session); } - // 세션 ID 생성 - private String generateSessionId() { return "session_" + System.currentTimeMillis() + "_" + UUID.randomUUID().toString().substring(0, 8); } @@ -413,9 +382,7 @@ private User resolveUser(String userIdOrEmail) { .orElseThrow(() -> new CareServiceException("사용자를 찾을 수 없습니다: " + userIdOrEmail)); } - // 의도 분석 - private ChatMessage.IntentType analyzeIntent(String message) { String lowerMessage = message.toLowerCase(); @@ -430,9 +397,7 @@ private ChatMessage.IntentType analyzeIntent(String message) { return ChatMessage.IntentType.UNKNOWN; } - // 신뢰도 계산 - private double calculateConfidence(String message, ChatMessage.IntentType intentType) { if (intentType == ChatMessage.IntentType.UNKNOWN) { return 0.1; @@ -451,14 +416,7 @@ private double calculateConfidence(String message, ChatMessage.IntentType intent return Math.min(0.9, 0.3 + (matchCount * 0.2)); } - - /** - * 응답 생성. - * - *

DB 에서 관련 정책·시설을 검색해 근거로 넘기고 LLM 이 답하게 한다(RAG). - * API 키가 없거나 호출이 실패하면 아래 규칙 기반 응답으로 폴백하므로, - * LLM 을 붙이지 않은 환경에서도 챗봇은 그대로 동작한다. - */ + /** 응답 생성. DB 에서 관련 정책·시설을 검색해 근거로 넘기고 LLM 이 답하게 한다(RAG) */ private String generateReply(String message, ChatMessage.IntentType intentType, User user) { // 인사·감사·작별처럼 검색이 필요 없는 의도는 정형 응답이 더 빠르고 안정적이다. if (intentType == ChatMessage.IntentType.GREETING @@ -479,9 +437,7 @@ private String generateReply(String message, ChatMessage.IntentType intentType, return generateResponse(message, intentType, user); } - // 응답 생성 (규칙 기반 폴백) - private String generateResponse(String message, ChatMessage.IntentType intentType, User user) { switch (intentType) { case GREETING: @@ -507,45 +463,33 @@ private String generateResponse(String message, ChatMessage.IntentType intentTyp } } - // 인사 응답 생성 - private String generateGreetingResponse(User user) { String userName = user.getName() != null ? user.getName() : "게스트"; return String.format("안녕하세요, %s님! 육아에 관한 궁금한 점이 있으시면 언제든 물어보세요. 건강, 정책, 시설, 교육 등 다양한 정보를 제공해드릴 수 있습니다.", userName); } - // 질문 응답 생성 - private String generateQuestionResponse(String message) { return "좋은 질문이네요! 구체적으로 어떤 부분에 대해 알고 싶으신지 말씀해 주시면 더 자세히 답변해드릴 수 있습니다."; } - // 불만/문의 응답 생성 - private String generateComplaintResponse() { return "불편하신 점이 있으시군요. 구체적인 상황을 말씀해 주시면 해결 방법을 찾아보겠습니다. 필요하시면 고객센터로 연결해드릴 수도 있습니다."; } - // 감사 응답 생성 - private String generateThanksResponse() { return "도움이 되었다니 기쁩니다! 앞으로도 육아에 관한 궁금한 점이 있으시면 언제든 찾아주세요."; } - // 작별인사 응답 생성 - private String generateGoodbyeResponse() { return "안녕히 가세요! 언제든 다시 찾아주세요. 육아에 관한 궁금한 점이 생기시면 언제든 도움을 드릴 준비가 되어 있습니다."; } - // 건강 정보 응답 생성 - private String generateHealthInfoResponse(String message) { if (message.contains("예방접종") || message.contains("백신")) { return "예방접종은 아이의 건강을 지키는 중요한 방법입니다. 연령별 예방접종 일정과 주의사항을 확인해보세요. 구체적인 질문이 있으시면 더 자세히 답변해드릴 수 있습니다."; @@ -556,9 +500,7 @@ private String generateHealthInfoResponse(String message) { } } - // 정책 정보 응답 생성 - private String generatePolicyInfoResponse(String message) { String topPolicies = policyRepository.findPopularPolicies(PageRequest.of(0, 3)).stream() .map(p -> "- " + p.getTitle()) @@ -573,9 +515,7 @@ private String generatePolicyInfoResponse(String message) { } } - // 시설 정보 응답 생성 - private String generateFacilityInfoResponse(String message) { String topFacilities = careFacilityRepository.findPopularFacilities(PageRequest.of(0, 3)).stream() .map(f -> "- " + f.getName()) @@ -590,9 +530,7 @@ private String generateFacilityInfoResponse(String message) { } } - // 교육 정보 응답 생성 - private String generateEducationInfoResponse(String message) { if (message.contains("육아") || message.contains("양육")) { return "육아와 양육에 관한 다양한 교육 프로그램과 정보를 제공해드릴 수 있습니다. 부모 교육, 양육 스킬, 발달 단계별 놀이 등 어떤 부분에 관심이 있으신가요?"; @@ -603,16 +541,12 @@ private String generateEducationInfoResponse(String message) { } } - // 기본 응답 생성 - private String generateDefaultResponse() { return "죄송합니다. 질문을 정확히 이해하지 못했습니다. 육아에 관한 건강, 정책, 시설, 교육 등 어떤 부분에 대해 궁금하신지 다시 말씀해 주세요."; } - // 메시지 저장 - private ChatMessage saveChatMessage(User user, ChatSession session, String message, String response, ChatMessage.IntentType intentType, double confidence) { ChatMessage chatMessage = ChatMessage.builder() @@ -630,9 +564,7 @@ private ChatMessage saveChatMessage(User user, ChatSession session, String messa return chatMessageRepository.save(chatMessage); } - // 세션 업데이트 - private void updateSession(ChatSession session, String message) { session.setMessageCount(session.getMessageCount() + 1); session.setLastActivityAt(LocalDateTime.now()); @@ -646,9 +578,7 @@ private void updateSession(ChatSession session, String message) { chatSessionRepository.save(session); } - // 대화 기록 응답 변환 - private ChatbotChatHistoryDtoResponse convertToHistoryResponse(ChatMessage message) { return ChatbotChatHistoryDtoResponse.builder() .messageId(message.getId()) @@ -663,9 +593,7 @@ private ChatbotChatHistoryDtoResponse convertToHistoryResponse(ChatMessage messa .build(); } - // 세션 응답 변환 - private ChatbotSessionDtoResponse convertToSessionResponse(ChatSession session) { return ChatbotSessionDtoResponse.builder() .sessionId(session.getSessionId()) diff --git a/src/main/java/com/carecode/domain/community/app/CommunityFacade.java b/src/main/java/com/carecode/domain/community/app/CommunityFacade.java index f97d9322..481c33d2 100644 --- a/src/main/java/com/carecode/domain/community/app/CommunityFacade.java +++ b/src/main/java/com/carecode/domain/community/app/CommunityFacade.java @@ -128,4 +128,3 @@ public Long getCurrentAuthenticatedUserId() { } } - diff --git a/src/main/java/com/carecode/domain/community/controller/CommunityController.java b/src/main/java/com/carecode/domain/community/controller/CommunityController.java index 3b8ce118..296e2925 100644 --- a/src/main/java/com/carecode/domain/community/controller/CommunityController.java +++ b/src/main/java/com/carecode/domain/community/controller/CommunityController.java @@ -26,10 +26,7 @@ import com.carecode.core.handler.ApiSuccess; import java.util.Date; -/** - * 커뮤니티 API 컨트롤러 - * 육아 커뮤니티 게시글 및 댓글 관리 서비스 - */ +/** 커뮤니티 API 컨트롤러 육아 커뮤니티 게시글 및 댓글 관리 서비스 */ @RestController @RequestMapping("/community") @RequiredArgsConstructor @@ -39,12 +36,10 @@ public class CommunityController extends BaseController { private final CommunityFacade communityFacade; - // 게시글 목록 조회 (페이징) - @GetMapping("/posts") @LogExecutionTime - @Operation(summary = "게시글 목록 조회", description = "커뮤니티 게시글 목록을 페이징으로 조회합니다.") + @Operation(summary = "게시글 목록 조회", description = "커뮤니티 게시글 목록을 페이징으로 조회") public ResponseEntity> getAllPosts( @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(defaultValue = "0") int page, @Parameter(description = "페이지당 항목 수", example = "10") @RequestParam(defaultValue = "10") int size, @@ -54,36 +49,30 @@ public ResponseEntity> getAllPosts( return ResponseEntity.ok(posts); } - // 게시글 상세 조회 - @GetMapping("/posts/{postId}") @LogExecutionTime - @Operation(summary = "게시글 상세 조회", description = "특정 게시글의 상세 정보를 조회합니다.") + @Operation(summary = "게시글 상세 조회", description = "특정 게시글의 상세 정보 조회") public ResponseEntity getPost( @Parameter(description = "게시글 ID", required = true) @PathVariable Long postId) { CommunityPostDetailResponse post = communityFacade.getPostDetailById(postId); return ResponseEntity.ok(post); } - // 게시글 작성 - @PostMapping("/posts") @LogExecutionTime - @Operation(summary = "게시글 작성", description = "새로운 게시글을 작성합니다.") + @Operation(summary = "게시글 작성", description = "새로운 게시글을 작성") public ResponseEntity createPost( @Parameter(description = "게시글 정보", required = true) @RequestBody CommunityCreatePostRequest request) { CommunityPostResponse post = communityFacade.createPost(request); return ResponseEntity.ok(post); } - // 게시글 수정 - @PutMapping("/posts/{postId}") @LogExecutionTime - @Operation(summary = "게시글 수정", description = "기존 게시글을 수정합니다.") + @Operation(summary = "게시글 수정") public ResponseEntity updatePost( @Parameter(description = "게시글 ID", required = true) @PathVariable Long postId, @Parameter(description = "수정할 게시글 정보", required = true) @RequestBody CommunityUpdatePostRequest request) { @@ -91,36 +80,30 @@ public ResponseEntity updatePost( return ResponseEntity.ok(post); } - // 게시글 삭제 - @DeleteMapping("/posts/{postId}") @LogExecutionTime - @Operation(summary = "게시글 삭제", description = "게시글을 삭제합니다.") + @Operation(summary = "게시글 삭제") public ResponseEntity deletePost( @Parameter(description = "게시글 ID", required = true) @PathVariable Long postId) { communityFacade.deletePost(postId); return ResponseEntity.ok(ApiSuccess.builder().timestamp(new Date()).message("게시글이 삭제되었습니다.").build()); } - // 댓글 목록 조회 - @GetMapping("/posts/{postId}/comments") @LogExecutionTime - @Operation(summary = "댓글 목록 조회", description = "특정 게시글의 댓글 목록을 조회합니다.") + @Operation(summary = "댓글 목록 조회", description = "특정 게시글의 댓글 목록 조회") public ResponseEntity> getComments( @Parameter(description = "게시글 ID", required = true) @PathVariable Long postId) { List comments = communityFacade.getCommentsByPostId(postId); return ResponseEntity.ok(comments); } - // 댓글 작성 - @PostMapping("/posts/{postId}/comments") @LogExecutionTime - @Operation(summary = "댓글 작성", description = "게시글에 댓글을 작성합니다.") + @Operation(summary = "댓글 작성", description = "게시글에 댓글을 작성") public ResponseEntity createComment( @Parameter(description = "게시글 ID", required = true) @PathVariable Long postId, @Parameter(description = "댓글 정보", required = true) @RequestBody CommunityCreateCommentRequest request) { @@ -128,12 +111,10 @@ public ResponseEntity createComment( return ResponseEntity.ok(comment); } - // 댓글 수정 - @PutMapping("/comments/{commentId}") @LogExecutionTime - @Operation(summary = "댓글 수정", description = "기존 댓글을 수정합니다.") + @Operation(summary = "댓글 수정") public ResponseEntity updateComment( @Parameter(description = "댓글 ID", required = true) @PathVariable Long commentId, @Parameter(description = "수정할 댓글 정보", required = true) @RequestBody CommunityUpdateCommentRequest request) { @@ -141,24 +122,20 @@ public ResponseEntity updateComment( return ResponseEntity.ok(comment); } - // 댓글 삭제 - @DeleteMapping("/comments/{commentId}") @LogExecutionTime - @Operation(summary = "댓글 삭제", description = "댓글을 삭제합니다.") + @Operation(summary = "댓글 삭제") public ResponseEntity deleteComment( @Parameter(description = "댓글 ID", required = true) @PathVariable Long commentId) { communityFacade.deleteComment(commentId); return ResponseEntity.ok(ApiSuccess.builder().timestamp(new Date()).message("댓글이 삭제되었습니다.").build()); } - // 게시글 검색 (페이징) - @GetMapping("/search") @LogExecutionTime - @Operation(summary = "게시글 검색", description = "키워드로 게시글을 페이징 검색합니다.") + @Operation(summary = "게시글 검색", description = "키워드로 게시글을 페이징 검색") public ResponseEntity> searchPosts( @Parameter(description = "검색 키워드", required = true) @RequestParam String keyword, @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(defaultValue = "0") int page, @@ -167,12 +144,10 @@ public ResponseEntity> searchPosts( return ResponseEntity.ok(posts); } - // 인기 게시글 조회 (페이징) - @GetMapping("/popular") @LogExecutionTime - @Operation(summary = "인기 게시글 조회", description = "인기 있는 게시글 목록을 페이징으로 조회합니다.") + @Operation(summary = "인기 게시글 조회", description = "인기 있는 게시글 목록을 페이징으로 조회") public ResponseEntity> getPopularPosts( @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(defaultValue = "0") int page, @Parameter(description = "페이지당 항목 수", example = "10") @RequestParam(defaultValue = "10") int size) { @@ -180,12 +155,10 @@ public ResponseEntity> getPopularPo return ResponseEntity.ok(posts); } - // 최신 게시글 조회 (페이징) - @GetMapping("/latest") @LogExecutionTime - @Operation(summary = "최신 게시글 조회", description = "최근 작성된 게시글 목록을 페이징으로 조회합니다.") + @Operation(summary = "최신 게시글 조회", description = "최근 작성된 게시글 목록을 페이징으로 조회") public ResponseEntity> getLatestPosts( @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(defaultValue = "0") int page, @Parameter(description = "페이지당 항목 수", example = "10") @RequestParam(defaultValue = "10") int size) { @@ -193,25 +166,22 @@ public ResponseEntity> getLatestPos return ResponseEntity.ok(posts); } - // 태그 목록 조회 - @GetMapping("/tags") @LogExecutionTime - @Operation(summary = "태그 목록 조회", description = "커뮤니티 태그 목록을 조회합니다.") + @Operation(summary = "태그 목록 조회") public ResponseEntity> getAllTags() { List tags = communityFacade.getAllTags(); return ResponseEntity.ok(tags); } - // ==================== 좋아요 및 북마크 기능 ==================== - + // ==================== + // 좋아요 및 북마크 기능 ==================== // 게시글 좋아요 토글 - @PostMapping("/posts/{postId}/like") @LogExecutionTime - @Operation(summary = "게시글 좋아요", description = "게시글에 좋아요를 추가하거나 제거합니다.") + @Operation(summary = "게시글 좋아요", description = "게시글에 좋아요를 추가하거나 제거") public ResponseEntity> toggleLike( @Parameter(description = "게시글 ID", required = true) @PathVariable Long postId) { Long userId = communityFacade.getCurrentAuthenticatedUserId(); @@ -224,12 +194,10 @@ public ResponseEntity> toggleLike( return ResponseEntity.ok(response); } - // 게시글 북마크 토글 - @PostMapping("/posts/{postId}/bookmark") @LogExecutionTime - @Operation(summary = "게시글 북마크", description = "게시글을 북마크에 추가하거나 제거합니다.") + @Operation(summary = "게시글 북마크", description = "게시글을 북마크에 추가하거나 제거") public ResponseEntity> toggleBookmark( @Parameter(description = "게시글 ID", required = true) @PathVariable Long postId) { Long userId = communityFacade.getCurrentAuthenticatedUserId(); @@ -242,12 +210,10 @@ public ResponseEntity> toggleBookmark( return ResponseEntity.ok(response); } - // 사용자가 좋아요한 게시글 목록 조회 - @GetMapping("/posts/liked") @LogExecutionTime - @Operation(summary = "좋아요한 게시글 목록", description = "현재 사용자가 좋아요한 게시글 목록을 조회합니다.") + @Operation(summary = "좋아요한 게시글 목록", description = "현재 사용자가 좋아요한 게시글 목록 조회") public ResponseEntity> getLikedPosts( ) { Long userId = communityFacade.getCurrentAuthenticatedUserId(); @@ -255,12 +221,10 @@ public ResponseEntity> getLikedPosts( return ResponseEntity.ok(posts); } - // 사용자가 북마크한 게시글 목록 조회 - @GetMapping("/posts/bookmarked") @LogExecutionTime - @Operation(summary = "북마크한 게시글 목록", description = "현재 사용자가 북마크한 게시글 목록을 조회합니다.") + @Operation(summary = "북마크한 게시글 목록", description = "현재 사용자가 북마크한 게시글 목록 조회") public ResponseEntity> getBookmarkedPosts( ) { Long userId = communityFacade.getCurrentAuthenticatedUserId(); @@ -268,12 +232,10 @@ public ResponseEntity> getBookmarkedPosts( return ResponseEntity.ok(posts); } - // 게시글 좋아요 수 조회 - @GetMapping("/posts/{postId}/like-count") @LogExecutionTime - @Operation(summary = "게시글 좋아요 수", description = "특정 게시글의 좋아요 수를 조회합니다.") + @Operation(summary = "게시글 좋아요 수", description = "특정 게시글의 좋아요 수 조회") public ResponseEntity> getLikeCount( @Parameter(description = "게시글 ID", required = true) @PathVariable Long postId) { long count = communityFacade.getLikeCount(postId); @@ -282,12 +244,10 @@ public ResponseEntity> getLikeCount( return ResponseEntity.ok(response); } - // 게시글 북마크 수 조회 - @GetMapping("/posts/{postId}/bookmark-count") @LogExecutionTime - @Operation(summary = "게시글 북마크 수", description = "특정 게시글의 북마크 수를 조회합니다.") + @Operation(summary = "게시글 북마크 수", description = "특정 게시글의 북마크 수 조회") public ResponseEntity> getBookmarkCount( @Parameter(description = "게시글 ID", required = true) @PathVariable Long postId) { long count = communityFacade.getBookmarkCount(postId); diff --git a/src/main/java/com/carecode/domain/community/controller/ModerationController.java b/src/main/java/com/carecode/domain/community/controller/ModerationController.java index cf679de3..c3d9b8d3 100644 --- a/src/main/java/com/carecode/domain/community/controller/ModerationController.java +++ b/src/main/java/com/carecode/domain/community/controller/ModerationController.java @@ -14,9 +14,7 @@ import java.util.List; -/** - * 커뮤니티 신고·차단 API (사용자용). - */ +/** 커뮤니티 신고·차단 API (사용자용). */ @RestController @RequestMapping("/community") @RequiredArgsConstructor @@ -28,14 +26,14 @@ public class ModerationController { @PostMapping("/reports") @LogExecutionTime @Operation(summary = "게시글·댓글 신고", - description = "신고가 누적되면 관리자 확인 전까지 자동으로 숨김 처리됩니다.") + description = "신고가 누적되면 관리자 확인 전까지 자동으로 숨김 처리") public ResponseEntity report(@Valid @RequestBody ReportCreateRequest request) { return ResponseEntity.status(HttpStatus.CREATED).body(moderationService.report(request)); } @PostMapping("/blocks/{userId}") @LogExecutionTime - @Operation(summary = "사용자 차단", description = "차단한 사용자의 글과 댓글이 목록에서 보이지 않습니다.") + @Operation(summary = "사용자 차단", description = "차단한 사용자의 글과 댓글이 목록에서 보이지 않습니다") public ResponseEntity blockUser(@PathVariable Long userId) { moderationService.blockUser(userId); return ResponseEntity.noContent().build(); diff --git a/src/main/java/com/carecode/domain/community/dto/request/CommunityCreateCommentRequest.java b/src/main/java/com/carecode/domain/community/dto/request/CommunityCreateCommentRequest.java index a877bb99..b1378f60 100644 --- a/src/main/java/com/carecode/domain/community/dto/request/CommunityCreateCommentRequest.java +++ b/src/main/java/com/carecode/domain/community/dto/request/CommunityCreateCommentRequest.java @@ -8,9 +8,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 댓글 작성 요청 - */ +/** 댓글 작성 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/community/dto/request/CommunityCreatePostRequest.java b/src/main/java/com/carecode/domain/community/dto/request/CommunityCreatePostRequest.java index a1dd3fba..d8dd50e0 100644 --- a/src/main/java/com/carecode/domain/community/dto/request/CommunityCreatePostRequest.java +++ b/src/main/java/com/carecode/domain/community/dto/request/CommunityCreatePostRequest.java @@ -10,9 +10,7 @@ import java.util.List; -/** - * 게시글 작성 요청 - */ +/** 게시글 작성 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/community/dto/request/CommunityListPostsRequest.java b/src/main/java/com/carecode/domain/community/dto/request/CommunityListPostsRequest.java index 8a917f50..6d9d836a 100644 --- a/src/main/java/com/carecode/domain/community/dto/request/CommunityListPostsRequest.java +++ b/src/main/java/com/carecode/domain/community/dto/request/CommunityListPostsRequest.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 게시글 목록 조회 요청 - */ +/** 게시글 목록 조회 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/community/dto/request/CommunitySearchPostsRequest.java b/src/main/java/com/carecode/domain/community/dto/request/CommunitySearchPostsRequest.java index 0af7f9c6..a85ba964 100644 --- a/src/main/java/com/carecode/domain/community/dto/request/CommunitySearchPostsRequest.java +++ b/src/main/java/com/carecode/domain/community/dto/request/CommunitySearchPostsRequest.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 게시글 검색 요청 - */ +/** 게시글 검색 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/community/dto/request/CommunityUpdateCommentRequest.java b/src/main/java/com/carecode/domain/community/dto/request/CommunityUpdateCommentRequest.java index 9e7dd6bd..8362bc56 100644 --- a/src/main/java/com/carecode/domain/community/dto/request/CommunityUpdateCommentRequest.java +++ b/src/main/java/com/carecode/domain/community/dto/request/CommunityUpdateCommentRequest.java @@ -8,9 +8,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 댓글 수정 요청 - */ +/** 댓글 수정 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/community/dto/request/CommunityUpdatePostRequest.java b/src/main/java/com/carecode/domain/community/dto/request/CommunityUpdatePostRequest.java index c2753711..5136d794 100644 --- a/src/main/java/com/carecode/domain/community/dto/request/CommunityUpdatePostRequest.java +++ b/src/main/java/com/carecode/domain/community/dto/request/CommunityUpdatePostRequest.java @@ -10,9 +10,7 @@ import java.util.List; -/** - * 게시글 수정 요청 - */ +/** 게시글 수정 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/community/dto/request/ReportCreateRequest.java b/src/main/java/com/carecode/domain/community/dto/request/ReportCreateRequest.java index 610ec8bc..956c4d01 100644 --- a/src/main/java/com/carecode/domain/community/dto/request/ReportCreateRequest.java +++ b/src/main/java/com/carecode/domain/community/dto/request/ReportCreateRequest.java @@ -7,9 +7,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 게시글·댓글 신고 요청. - */ +/** 게시글·댓글 신고 요청. */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/community/dto/response/CommunityCommentListResponse.java b/src/main/java/com/carecode/domain/community/dto/response/CommunityCommentListResponse.java index da7e6fa3..77119914 100644 --- a/src/main/java/com/carecode/domain/community/dto/response/CommunityCommentListResponse.java +++ b/src/main/java/com/carecode/domain/community/dto/response/CommunityCommentListResponse.java @@ -9,9 +9,7 @@ import java.util.List; -/** - * 댓글 목록 응답 - */ +/** 댓글 목록 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/community/dto/response/CommunityCommentResponse.java b/src/main/java/com/carecode/domain/community/dto/response/CommunityCommentResponse.java index dd8ccf8b..b501d69e 100644 --- a/src/main/java/com/carecode/domain/community/dto/response/CommunityCommentResponse.java +++ b/src/main/java/com/carecode/domain/community/dto/response/CommunityCommentResponse.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 댓글 응답 - */ +/** 댓글 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/community/dto/response/CommunityPageResponse.java b/src/main/java/com/carecode/domain/community/dto/response/CommunityPageResponse.java index 2e77c3a8..c189acfd 100644 --- a/src/main/java/com/carecode/domain/community/dto/response/CommunityPageResponse.java +++ b/src/main/java/com/carecode/domain/community/dto/response/CommunityPageResponse.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 페이지 응답 - */ +/** 페이지 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/community/dto/response/CommunityPostDetailResponse.java b/src/main/java/com/carecode/domain/community/dto/response/CommunityPostDetailResponse.java index 251da367..0759b5fb 100644 --- a/src/main/java/com/carecode/domain/community/dto/response/CommunityPostDetailResponse.java +++ b/src/main/java/com/carecode/domain/community/dto/response/CommunityPostDetailResponse.java @@ -7,9 +7,7 @@ import java.util.List; -/** - * 게시글 상세 응답 - */ +/** 게시글 상세 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/community/dto/response/CommunityPostListResponse.java b/src/main/java/com/carecode/domain/community/dto/response/CommunityPostListResponse.java index 993f5e16..64c83ba5 100644 --- a/src/main/java/com/carecode/domain/community/dto/response/CommunityPostListResponse.java +++ b/src/main/java/com/carecode/domain/community/dto/response/CommunityPostListResponse.java @@ -9,9 +9,7 @@ import java.util.List; -/** - * 게시글 목록 응답 - */ +/** 게시글 목록 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/community/dto/response/CommunityPostResponse.java b/src/main/java/com/carecode/domain/community/dto/response/CommunityPostResponse.java index 0e539794..5d8ea5a6 100644 --- a/src/main/java/com/carecode/domain/community/dto/response/CommunityPostResponse.java +++ b/src/main/java/com/carecode/domain/community/dto/response/CommunityPostResponse.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 게시글 응답 - */ +/** 게시글 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/community/dto/response/CommunityPostSearchResponse.java b/src/main/java/com/carecode/domain/community/dto/response/CommunityPostSearchResponse.java index e9e2d869..d7b43441 100644 --- a/src/main/java/com/carecode/domain/community/dto/response/CommunityPostSearchResponse.java +++ b/src/main/java/com/carecode/domain/community/dto/response/CommunityPostSearchResponse.java @@ -9,9 +9,7 @@ import java.util.List; -/** - * 게시글 검색 응답 - */ +/** 게시글 검색 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/community/dto/response/CommunityRelatedPostResponse.java b/src/main/java/com/carecode/domain/community/dto/response/CommunityRelatedPostResponse.java index 0cf15761..1788a2ad 100644 --- a/src/main/java/com/carecode/domain/community/dto/response/CommunityRelatedPostResponse.java +++ b/src/main/java/com/carecode/domain/community/dto/response/CommunityRelatedPostResponse.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 관련 게시글 응답 - */ +/** 관련 게시글 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/community/dto/response/CommunityStatsResponse.java b/src/main/java/com/carecode/domain/community/dto/response/CommunityStatsResponse.java index 5332023d..dffa9401 100644 --- a/src/main/java/com/carecode/domain/community/dto/response/CommunityStatsResponse.java +++ b/src/main/java/com/carecode/domain/community/dto/response/CommunityStatsResponse.java @@ -8,9 +8,7 @@ import java.util.Map; -/** - * 커뮤니티 통계 응답 - */ +/** 커뮤니티 통계 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/community/dto/response/CommunityTagListResponse.java b/src/main/java/com/carecode/domain/community/dto/response/CommunityTagListResponse.java index 4f132c0b..75574f34 100644 --- a/src/main/java/com/carecode/domain/community/dto/response/CommunityTagListResponse.java +++ b/src/main/java/com/carecode/domain/community/dto/response/CommunityTagListResponse.java @@ -9,9 +9,7 @@ import java.util.List; -/** - * 태그 목록 응답 - */ +/** 태그 목록 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/community/dto/response/CommunityTagResponse.java b/src/main/java/com/carecode/domain/community/dto/response/CommunityTagResponse.java index 577f6039..7c48ecd8 100644 --- a/src/main/java/com/carecode/domain/community/dto/response/CommunityTagResponse.java +++ b/src/main/java/com/carecode/domain/community/dto/response/CommunityTagResponse.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 태그 응답 - */ +/** 태그 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/community/dto/response/ReportResponse.java b/src/main/java/com/carecode/domain/community/dto/response/ReportResponse.java index 88cfda3c..adc17381 100644 --- a/src/main/java/com/carecode/domain/community/dto/response/ReportResponse.java +++ b/src/main/java/com/carecode/domain/community/dto/response/ReportResponse.java @@ -6,11 +6,7 @@ import java.time.LocalDateTime; -/** - * 신고 응답. - * - *

신고자 신원은 관리자에게도 최소한만 노출한다. - */ +/** 신고 응답. 신고자 신원은 관리자에게도 최소한만 노출한다. */ @Getter @Builder public class ReportResponse { diff --git a/src/main/java/com/carecode/domain/community/entity/Bookmark.java b/src/main/java/com/carecode/domain/community/entity/Bookmark.java index 617c4ddc..693f233a 100644 --- a/src/main/java/com/carecode/domain/community/entity/Bookmark.java +++ b/src/main/java/com/carecode/domain/community/entity/Bookmark.java @@ -6,9 +6,7 @@ import java.time.LocalDateTime; -/** - * 북마크 Entity - */ +/** 북마크 Entity */ @Entity @Table(name = "TBL_BOOKMARK", uniqueConstraints = @UniqueConstraint(columnNames = {"post_id", "user_id"}) diff --git a/src/main/java/com/carecode/domain/community/entity/Comment.java b/src/main/java/com/carecode/domain/community/entity/Comment.java index c4e2c1f6..9b83f00b 100644 --- a/src/main/java/com/carecode/domain/community/entity/Comment.java +++ b/src/main/java/com/carecode/domain/community/entity/Comment.java @@ -12,10 +12,7 @@ import java.util.ArrayList; import java.util.List; -/** - * 커뮤니티 댓글 엔티티 - * 계층형 댓글 구조를 지원 (답글/대댓글 기능) - */ +/** 커뮤니티 댓글 엔티티 계층형 댓글 구조를 지원 (답글/대댓글 기능) */ @Entity @Table(name = "TBL_COMMENT") @Getter @@ -81,73 +78,55 @@ protected void onCreate() { protected void onUpdate() { updatedAt = LocalDateTime.now(); } - // 댓글에 답글 추가 - public void addReply(Comment reply) { replies.add(reply); reply.setParentComment(this); } - // 댓글에서 답글 제거 - public void removeReply(Comment reply) { replies.remove(reply); reply.setParentComment(null); } - // 좋아요 수 증가 - public void incrementLikeCount() { this.likeCount++; } - // 좋아요 수 감소 - public void decrementLikeCount() { if (this.likeCount > 0) { this.likeCount--; } } - // 댓글 상태 변경 - public void updateStatus(CommentStatus status) { this.status = status; if (status == CommentStatus.DELETED) { this.isActive = false; } } - // 댓글 내용 업데이트 - public void updateContent(String content) { this.content = content; } - // 댓글인지 확인 (답글이 아닌 최상위 댓글) - public boolean isTopLevelComment() { return parentComment == null; } - // 답글인지 확인 - public boolean isReply() { return parentComment != null; } - // 댓글 상태 Enum - public enum CommentStatus { PUBLISHED("발행"), HIDDEN("숨김"), diff --git a/src/main/java/com/carecode/domain/community/entity/Post.java b/src/main/java/com/carecode/domain/community/entity/Post.java index 04dcc8f7..de3bfd07 100644 --- a/src/main/java/com/carecode/domain/community/entity/Post.java +++ b/src/main/java/com/carecode/domain/community/entity/Post.java @@ -12,9 +12,7 @@ import java.util.ArrayList; import java.util.List; -/** - * 커뮤니티 게시글 엔티티 - */ +/** 커뮤니티 게시글 엔티티 */ @Entity @Table(name = "TBL_POST") @Getter @@ -99,77 +97,59 @@ protected void onCreate() { protected void onUpdate() { updatedAt = LocalDateTime.now(); } - // 게시글에 댓글 추가 - public void addComment(Comment comment) { comments.add(comment); comment.setPost(this); this.commentCount = comments.size(); } - // 게시글에서 댓글 제거 - public void removeComment(Comment comment) { comments.remove(comment); comment.setPost(null); this.commentCount = comments.size(); } - // 조회수 증가 - public void incrementViewCount() { this.viewCount++; } - // 좋아요 수 증가 - public void incrementLikeCount() { this.likeCount++; } - // 좋아요 수 감소 - public void decrementLikeCount() { if (this.likeCount > 0) { this.likeCount--; } } - // 게시글 상태 변경 - public void updateStatus(PostStatus status) { this.status = status; if (status == PostStatus.DELETED) { this.isActive = false; } } - // 태그 추가 - public void addTag(Tag tag) { if (!tags.contains(tag)) { tags.add(tag); } } - // 태그 제거 - public void removeTag(Tag tag) { tags.remove(tag); } - // 모든 태그 제거 - public void clearTags() { tags.clear(); } diff --git a/src/main/java/com/carecode/domain/community/entity/PostCategory.java b/src/main/java/com/carecode/domain/community/entity/PostCategory.java index 4f6717b8..857244e0 100644 --- a/src/main/java/com/carecode/domain/community/entity/PostCategory.java +++ b/src/main/java/com/carecode/domain/community/entity/PostCategory.java @@ -1,8 +1,6 @@ package com.carecode.domain.community.entity; -/** - * 게시글 카테고리 Enum - */ +/** 게시글 카테고리 Enum */ public enum PostCategory { GENERAL("일반"), QUESTION("질문"), diff --git a/src/main/java/com/carecode/domain/community/entity/PostLike.java b/src/main/java/com/carecode/domain/community/entity/PostLike.java index c1c1ef4b..31d62a97 100644 --- a/src/main/java/com/carecode/domain/community/entity/PostLike.java +++ b/src/main/java/com/carecode/domain/community/entity/PostLike.java @@ -6,9 +6,7 @@ import java.time.LocalDateTime; -/** - * 게시글 좋아요 Entity - */ +/** 게시글 좋아요 Entity */ @Entity @Table(name = "TBL_POST_LIKE", uniqueConstraints = @UniqueConstraint(columnNames = {"post_id", "user_id"}) diff --git a/src/main/java/com/carecode/domain/community/entity/PostStatus.java b/src/main/java/com/carecode/domain/community/entity/PostStatus.java index 663ed5cb..836034b3 100644 --- a/src/main/java/com/carecode/domain/community/entity/PostStatus.java +++ b/src/main/java/com/carecode/domain/community/entity/PostStatus.java @@ -1,8 +1,6 @@ package com.carecode.domain.community.entity; -/** - * 게시글 상태 Enum - */ +/** 게시글 상태 Enum */ public enum PostStatus { DRAFT("임시저장"), PUBLISHED("발행"), diff --git a/src/main/java/com/carecode/domain/community/entity/Report.java b/src/main/java/com/carecode/domain/community/entity/Report.java index 224bc410..9c07e202 100644 --- a/src/main/java/com/carecode/domain/community/entity/Report.java +++ b/src/main/java/com/carecode/domain/community/entity/Report.java @@ -10,11 +10,7 @@ import java.time.LocalDateTime; -/** - * 게시글·댓글 신고. - * - *

같은 사용자가 같은 대상을 중복 신고하지 못하도록 유니크 제약을 둔다. - */ +/** 게시글·댓글 신고. 같은 사용자가 같은 대상을 중복 신고하지 못하도록 유니크 제약을 둔다. */ @Entity @Table( name = "TBL_REPORT", diff --git a/src/main/java/com/carecode/domain/community/entity/UserBlock.java b/src/main/java/com/carecode/domain/community/entity/UserBlock.java index afd2eb19..e6a7e422 100644 --- a/src/main/java/com/carecode/domain/community/entity/UserBlock.java +++ b/src/main/java/com/carecode/domain/community/entity/UserBlock.java @@ -9,11 +9,7 @@ import java.time.LocalDateTime; -/** - * 사용자 차단. - * - *

차단하면 상대의 게시글·댓글이 목록에서 보이지 않는다. - */ +/** 사용자 차단. 차단하면 상대의 게시글·댓글이 목록에서 보이지 않는다. */ @Entity @Table( name = "TBL_USER_BLOCK", diff --git a/src/main/java/com/carecode/domain/community/mapper/CommunityMapper.java b/src/main/java/com/carecode/domain/community/mapper/CommunityMapper.java index b3047685..5108fe79 100644 --- a/src/main/java/com/carecode/domain/community/mapper/CommunityMapper.java +++ b/src/main/java/com/carecode/domain/community/mapper/CommunityMapper.java @@ -16,9 +16,7 @@ import java.util.List; import java.util.stream.Collectors; -/** - * 커뮤니티 DTO 변환 매퍼 클래스 - */ +/** 커뮤니티 DTO 변환 매퍼 클래스 */ @Slf4j @Component @RequiredArgsConstructor @@ -27,10 +25,8 @@ public class CommunityMapper { private final CommentRepository commentRepository; private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - // Post 엔티티를 PostResponse DTO로 변환 - public CommunityPostResponse toPostResponse(Post post) { List tagNames = post.getTags() != null ? post.getTags().stream().map(Tag::getName).toList() : List.of(); return CommunityPostResponse.builder() @@ -50,10 +46,8 @@ public CommunityPostResponse toPostResponse(Post post) { .isBookmarked(false) .build(); } - // Post 엔티티를 PostDetailResponse DTO로 변환 - public CommunityPostDetailResponse toPostDetailResponse(Post post) { List comments = commentRepository.findByPostIdAndParentCommentIsNull(post.getId()); List commentResponses = comments.stream() @@ -81,10 +75,8 @@ public CommunityPostDetailResponse toPostDetailResponse(Post post) { return response; } - // Comment 엔티티를 CommentResponse DTO로 변환 - public CommunityCommentResponse toCommentResponse(Comment comment) { List replies = comment.getReplies().stream() .map(this::toCommentResponse) @@ -102,10 +94,8 @@ public CommunityCommentResponse toCommentResponse(Comment comment) { .replies(replies) .build(); } - // Tag 엔티티를 TagResponse DTO로 변환 - public CommunityTagResponse toTagResponse(Tag tag) { return CommunityTagResponse.builder() .id(tag.getId()) @@ -114,28 +104,22 @@ public CommunityTagResponse toTagResponse(Tag tag) { .createdAt(tag.getCreatedAt() != null ? tag.getCreatedAt().format(DATE_FORMATTER) : null) .build(); } - // Post 엔티티 리스트를 PostResponse DTO 리스트로 변환 - public List toPostResponseList(List posts) { return posts.stream() .map(this::toPostResponse) .collect(Collectors.toList()); } - // Comment 엔티티 리스트를 CommentResponse DTO 리스트로 변환 - public List toCommentResponseList(List comments) { return comments.stream() .map(this::toCommentResponse) .collect(Collectors.toList()); } - // Tag 엔티티 리스트를 TagResponse DTO 리스트로 변환 - public List toTagResponseList(List tags) { return tags.stream() .map(this::toTagResponse) diff --git a/src/main/java/com/carecode/domain/community/repository/BookmarkRepository.java b/src/main/java/com/carecode/domain/community/repository/BookmarkRepository.java index 1dcbe0bf..1b517566 100644 --- a/src/main/java/com/carecode/domain/community/repository/BookmarkRepository.java +++ b/src/main/java/com/carecode/domain/community/repository/BookmarkRepository.java @@ -11,45 +11,29 @@ import java.util.List; import java.util.Optional; -/** - * 북마크 Repository - */ +/** 북마크 Repository */ @Repository public interface BookmarkRepository extends JpaRepository { - // 특정 사용자가 특정 게시글을 북마크했는지 확인 - boolean existsByPostAndUser(Post post, User user); - // 특정 사용자가 특정 게시글에 한 북마크 조회 - Optional findByPostAndUser(Post post, User user); - // 특정 게시글의 북마크 개수 - long countByPost(Post post); - // 특정 사용자가 북마크한 게시글 목록 - List findByUser(User user); - // 특정 게시글의 모든 북마크 삭제 - void deleteByPost(Post post); - // 특정 사용자와 게시글의 북마크 삭제 - void deleteByPostAndUser(Post post, User user); - // 특정 게시글 목록에 대해 사용자가 북마크했는지 확인 - @Query("SELECT b.post.id FROM Bookmark b WHERE b.user = :user AND b.post.id IN :postIds") List findBookmarkedPostIdsByUserAndPostIds(@Param("user") User user, @Param("postIds") List postIds); } diff --git a/src/main/java/com/carecode/domain/community/repository/CommentRepository.java b/src/main/java/com/carecode/domain/community/repository/CommentRepository.java index a9d46cc0..a7721ef0 100644 --- a/src/main/java/com/carecode/domain/community/repository/CommentRepository.java +++ b/src/main/java/com/carecode/domain/community/repository/CommentRepository.java @@ -8,21 +8,15 @@ import java.util.List; -/** - * 댓글 레포지토리 - */ +/** 댓글 레포지토리 */ @Repository public interface CommentRepository extends JpaRepository { - // 게시글 ID로 댓글 목록 조회 (부모 댓글만) - @Query("SELECT c FROM Comment c WHERE c.post.id = :postId AND c.parentComment IS NULL AND c.isActive = true ORDER BY c.createdAt ASC") List findByPostIdAndParentCommentIsNull(@Param("postId") Long postId); - // 게시글의 댓글 수 조회 - @Query("SELECT COUNT(c) FROM Comment c WHERE c.post.id = :postId AND c.isActive = true") long countByPostId(@Param("postId") Long postId); } \ No newline at end of file diff --git a/src/main/java/com/carecode/domain/community/repository/PostLikeRepository.java b/src/main/java/com/carecode/domain/community/repository/PostLikeRepository.java index 303632be..f2384c6b 100644 --- a/src/main/java/com/carecode/domain/community/repository/PostLikeRepository.java +++ b/src/main/java/com/carecode/domain/community/repository/PostLikeRepository.java @@ -11,45 +11,29 @@ import java.util.List; import java.util.Optional; -/** - * 게시글 좋아요 Repository - */ +/** 게시글 좋아요 Repository */ @Repository public interface PostLikeRepository extends JpaRepository { - // 특정 사용자가 특정 게시글에 좋아요를 눌렀는지 확인 - boolean existsByPostAndUser(Post post, User user); - // 특정 사용자가 특정 게시글에 누른 좋아요 조회 - Optional findByPostAndUser(Post post, User user); - // 특정 게시글의 좋아요 개수 - long countByPost(Post post); - // 특정 사용자가 좋아요한 게시글 목록 - List findByUser(User user); - // 특정 게시글의 모든 좋아요 삭제 - void deleteByPost(Post post); - // 특정 사용자와 게시글의 좋아요 삭제 - void deleteByPostAndUser(Post post, User user); - // 특정 게시글 목록에 대해 사용자가 좋아요를 눌렀는지 확인 - @Query("SELECT pl.post.id FROM PostLike pl WHERE pl.user = :user AND pl.post.id IN :postIds") List findLikedPostIdsByUserAndPostIds(@Param("user") User user, @Param("postIds") List postIds); } diff --git a/src/main/java/com/carecode/domain/community/repository/PostRepository.java b/src/main/java/com/carecode/domain/community/repository/PostRepository.java index fa1f1ac6..a1f6487b 100644 --- a/src/main/java/com/carecode/domain/community/repository/PostRepository.java +++ b/src/main/java/com/carecode/domain/community/repository/PostRepository.java @@ -12,42 +12,29 @@ import java.util.List; -/** - * 커뮤니티 게시글 리포지토리 인터페이스 - */ +/** 커뮤니티 게시글 리포지토리 인터페이스 */ @Repository public interface PostRepository extends JpaRepository { - // 제목 또는 내용으로 검색 - @Query("SELECT p FROM Post p WHERE p.isActive = true AND (p.title LIKE %:keyword% OR p.content LIKE %:keyword%)") Page findByKeyword(@Param("keyword") String keyword, Pageable pageable); - // 인기 게시글 조회 (좋아요 순) - 페이징 - @Query("SELECT p FROM Post p WHERE p.isActive = true ORDER BY p.likeCount DESC, p.createdAt DESC") Page findPopularPosts(Pageable pageable); - // 최신 게시글 조회 - 페이징 - @Query("SELECT p FROM Post p WHERE p.isActive = true ORDER BY p.createdAt DESC") Page findLatestPosts(Pageable pageable); - - // 태그별 게시글 목록 조회 - @Query("SELECT p FROM Post p JOIN p.tags t WHERE t = :tag AND p.isActive = true") List findByTagsContaining(@Param("tag") Tag tag); long countByAuthorId(Long authorId); - /** - * 조회수를 DB 에서 원자적으로 증가시킨다 (lost update 방지). - */ + /** 조회수를 DB 에서 원자적으로 증가시킨다 (lost update 방지). */ @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("UPDATE Post p SET p.viewCount = COALESCE(p.viewCount, 0) + 1 WHERE p.id = :postId") int incrementViewCount(@Param("postId") Long postId); diff --git a/src/main/java/com/carecode/domain/community/service/CommunityInitializationService.java b/src/main/java/com/carecode/domain/community/service/CommunityInitializationService.java index f356f46a..c5e8cd46 100644 --- a/src/main/java/com/carecode/domain/community/service/CommunityInitializationService.java +++ b/src/main/java/com/carecode/domain/community/service/CommunityInitializationService.java @@ -21,10 +21,7 @@ import java.util.List; import java.util.Optional; -/** - * 커뮤니티 더미 데이터 초기화 서비스 - * 서버 시작 시 자동으로 실행되어 더미 데이터를 생성합니다. - */ +/** 커뮤니티 더미 데이터 초기화 서비스 */ @Slf4j @Service @Profile("dev") @@ -53,9 +50,7 @@ public void run(String... args) throws Exception { log.info("커뮤니티 더미 데이터 초기화 완료"); } - // 테스트 사용자 생성 - private User createTestUser() { Optional existingUser = userRepository.findByEmail("testuser@example.com"); @@ -80,9 +75,7 @@ private User createTestUser() { return savedUser; } - // 태그 생성 - private List createTags() { List tagNames = Arrays.asList("육아팁", "질문", "정보공유", "일상", "고민상담"); List tags = tagNames.stream() @@ -103,9 +96,7 @@ private List createTags() { return tags; } - // 게시글 생성 - private void createPosts(User testUser, List tags) { // 이미 게시글이 있다면 생성하지 않음 if (postRepository.count() > 0) { @@ -245,9 +236,7 @@ private void createPosts(User testUser, List tags) { log.info("{}개의 게시글 생성 완료", posts.size()); } - // 게시글 생성 헬퍼 메서드 - private Post createPost(String title, String content, User author, PostCategory category, int viewCount, int likeCount, int daysAgo) { return Post.builder() diff --git a/src/main/java/com/carecode/domain/community/service/CommunityService.java b/src/main/java/com/carecode/domain/community/service/CommunityService.java index 0e5c703d..d98bf1fe 100644 --- a/src/main/java/com/carecode/domain/community/service/CommunityService.java +++ b/src/main/java/com/carecode/domain/community/service/CommunityService.java @@ -42,9 +42,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; -/** - * 커뮤니티 서비스 클래스 - */ +/** 커뮤니티 서비스 클래스 */ @Slf4j @Service @RequiredArgsConstructor @@ -58,10 +56,8 @@ public class CommunityService { private final PostLikeRepository postLikeRepository; private final BookmarkRepository bookmarkRepository; private final CommunityMapper communityMapper; - // 게시글 목록 조회 (페이징) - public CommunityPageResponse getAllPosts(int page, int size, String sortBy, String sortDirection) { log.info("게시글 목록 조회 - 페이지: {}, 크기: {}, 정렬: {}, 방향: {}", page, size, sortBy, sortDirection); try { @@ -91,10 +87,8 @@ public CommunityPageResponse getAllPosts(int page, int si } // 레거시 전체 조회 메서드 제거 (페이징 API로 일원화) - // 게시글 상세 조회 - public CommunityPostDetailResponse getPostById(Long postId) { log.info("게시글 상세 조회 - 게시글 ID: {}", postId); @@ -109,10 +103,8 @@ public CommunityPostDetailResponse getPostById(Long postId) { return communityMapper.toPostDetailResponse(post); } - // 게시글 작성 - public CommunityPostResponse createPost(CommunityCreatePostRequest request) { // 현재 인증된 사용자 가져오기 User author = getCurrentUser(); @@ -138,10 +130,8 @@ public CommunityPostResponse createPost(CommunityCreatePostRequest request) { return communityMapper.toPostResponse(savedPost); } - // 게시글 수정 - public CommunityPostResponse updatePost(Long postId, CommunityUpdatePostRequest request) { log.info("게시글 수정 - 게시글 ID: {}", postId); Post post = postRepository.findById(postId) @@ -157,9 +147,7 @@ public CommunityPostResponse updatePost(Long postId, CommunityUpdatePostRequest return communityMapper.toPostResponse(updatedPost); } - // 게시글 삭제 - public void deletePost(Long postId) { log.info("게시글 삭제 - 게시글 ID: {}", postId); Post post = postRepository.findById(postId) @@ -169,14 +157,12 @@ public void deletePost(Long postId) { postRepository.delete(post); } - - // ========== 댓글 관련 메서드 ========== - + // ========== + // 댓글 관련 메서드 ========== // 게시글의 댓글 목록 조회 - public List getCommentsByPostId(Long postId) { log.info("댓글 목록 조회 - 게시글 ID: {}", postId); try { @@ -194,10 +180,8 @@ public List getCommentsByPostId(Long postId) { throw new CareServiceException("댓글 목록을 조회하는 중 오류가 발생했습니다."); } } - // 댓글 작성 - public CommunityCommentResponse createComment(Long postId, CommunityCreateCommentRequest request) { log.info("댓글 작성 - 게시글 ID: {}, 부모 댓글 ID: {}", postId, request.getParentCommentId()); try { @@ -235,10 +219,8 @@ public CommunityCommentResponse createComment(Long postId, CommunityCreateCommen throw new CareServiceException("댓글을 작성하는 중 오류가 발생했습니다."); } } - // 댓글 수정 - public CommunityCommentResponse updateComment(Long commentId, CommunityUpdateCommentRequest request) { Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new ResourceNotFoundException("댓글을 찾을 수 없습니다. ID: " + commentId)); @@ -251,9 +233,7 @@ public CommunityCommentResponse updateComment(Long commentId, CommunityUpdateCom return communityMapper.toCommentResponse(updatedComment); } - // 댓글 삭제 - public void deleteComment(Long commentId) { Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new ResourceNotFoundException("댓글을 찾을 수 없습니다. ID: " + commentId)); @@ -271,19 +251,16 @@ public void deleteComment(Long commentId) { } } - // ========== 태그 관련 메서드 ========== - + // ========== + // 태그 관련 메서드 ========== // 태그 목록 조회 - public List getAllTags() { List tags = tagRepository.findByIsActiveTrue(); return communityMapper.toTagResponseList(tags); } - // 게시글에 태그 추가 - private void addTagsToPost(Post post, List tagNames) { for (String tagName : tagNames) { Tag tag = tagRepository.findByName(tagName) @@ -292,18 +269,14 @@ private void addTagsToPost(Post post, List tagNames) { } postRepository.save(post); } - // 태그가 없으면 생성 - private Tag createTagIfNotExists(String tagName) { Tag tag = new Tag(tagName, "자동 생성된 태그"); return tagRepository.save(tag); } - // 현재 인증된 사용자 가져오기 - private User getCurrentUser() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); @@ -329,9 +302,7 @@ private User getCurrentUser() { }); } - // 게시글 소유권 검증 (작성자 본인 또는 관리자만 허용) - private void requirePostOwnership(Post post) { User currentUser = getCurrentUser(); if (isOwner(post.getAuthor(), currentUser) || isAdmin(currentUser)) { @@ -341,9 +312,7 @@ private void requirePostOwnership(Post post) { throw new PostAccessDeniedException("본인이 작성한 게시글만 수정/삭제할 수 있습니다."); } - // 댓글 소유권 검증 (작성자 본인 또는 관리자만 허용) - private void requireCommentOwnership(Comment comment) { User currentUser = getCurrentUser(); if (isOwner(comment.getAuthor(), currentUser) || isAdmin(currentUser)) { @@ -363,9 +332,7 @@ private boolean isAdmin(User user) { return UserRole.ADMIN == user.getRole(); } - // 카테고리 매핑 메서드 - private PostCategory mapCategory(String category) { if (category == null) { return PostCategory.GENERAL; @@ -399,9 +366,7 @@ private PostCategory mapCategory(String category) { } } - // 게시글 검색 (페이징) - public CommunityPageResponse searchPosts(String keyword, int page, int size) { Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); Page postPage = postRepository.findByKeyword(keyword, pageable); @@ -423,9 +388,7 @@ public CommunityPageResponse searchPosts(String keyword, // 레거시 전체 검색 메서드 제거 (페이징 API로 일원화) - // 인기 게시글 조회 (페이징) - public CommunityPageResponse getPopularPosts(int page, int size) { Pageable pageable = PageRequest.of(page, size); Page postPage = postRepository.findPopularPosts(pageable); @@ -447,9 +410,7 @@ public CommunityPageResponse getPopularPosts(int page, in // 레거시 인기 게시글 리스트 메서드 제거 (페이징 API로 일원화) - // 최신 게시글 조회 (페이징) - public CommunityPageResponse getLatestPosts(int page, int size) { Pageable pageable = PageRequest.of(page, size); Page postPage = postRepository.findLatestPosts(pageable); @@ -469,9 +430,7 @@ public CommunityPageResponse getLatestPosts(int page, int .build(); } - // 좋아요 토글 - public boolean toggleLike(Long postId, Long userId) { log.info("좋아요 토글 - 게시글 ID: {}, 사용자 ID: {}", postId, userId); @@ -499,9 +458,7 @@ public boolean toggleLike(Long postId, Long userId) { } } - // 북마크 토글 - public boolean toggleBookmark(Long postId, Long userId) { log.info("북마크 토글 - 게시글 ID: {}, 사용자 ID: {}", postId, userId); @@ -529,9 +486,7 @@ public boolean toggleBookmark(Long postId, Long userId) { } } - // 특정 게시글의 좋아요 개수 조회 - @Transactional(readOnly = true) public long getLikeCount(Long postId) { Post post = postRepository.findById(postId).orElse(null); @@ -541,9 +496,7 @@ public long getLikeCount(Long postId) { return postLikeRepository.countByPost(post); } - // 특정 게시글의 북마크 개수 조회 - @Transactional(readOnly = true) public long getBookmarkCount(Long postId) { Post post = postRepository.findById(postId).orElse(null); @@ -553,9 +506,7 @@ public long getBookmarkCount(Long postId) { return bookmarkRepository.countByPost(post); } - // 사용자가 좋아요한 게시글 목록 조회 - @Transactional(readOnly = true) public List getLikedPosts(Long userId) { User user = userRepository.findById(userId) @@ -569,9 +520,7 @@ public List getLikedPosts(Long userId) { return communityMapper.toPostResponseList(posts); } - // 사용자가 북마크한 게시글 목록 조회 - @Transactional(readOnly = true) public List getBookmarkedPosts(Long userId) { User user = userRepository.findById(userId) diff --git a/src/main/java/com/carecode/domain/community/service/ModerationService.java b/src/main/java/com/carecode/domain/community/service/ModerationService.java index 4a294c63..e47bcc11 100644 --- a/src/main/java/com/carecode/domain/community/service/ModerationService.java +++ b/src/main/java/com/carecode/domain/community/service/ModerationService.java @@ -27,9 +27,7 @@ import java.util.List; -/** - * 커뮤니티 모더레이션: 신고 접수·처리, 사용자 차단. - */ +/** 커뮤니티 모더레이션: 신고 접수·처리, 사용자 차단. */ @Slf4j @Service @RequiredArgsConstructor @@ -47,8 +45,8 @@ public class ModerationService { @Value("${app.community.auto-hide-report-threshold:5}") private long autoHideThreshold; - // ==================== 신고 ==================== - + // ==================== + // 신고 ==================== @Transactional public ReportResponse report(ReportCreateRequest request) { User reporter = currentUserFacade.requireCurrentUser(); @@ -102,8 +100,8 @@ public ReportResponse resolve(Long reportId, Report.ReportStatus status, String return ReportResponse.from(reportRepository.save(report)); } - // ==================== 차단 ==================== - + // ==================== + // 차단 ==================== @Transactional public void blockUser(Long targetUserId) { User blocker = currentUserFacade.requireCurrentUser(); @@ -133,8 +131,8 @@ public List getBlockedUserIds() { return userBlockRepository.findBlockedUserIds(blocker.getId()); } - // ==================== 내부 ==================== - + // ==================== + // 내부 ==================== private void applyAutoHideIfNeeded(Report.TargetType targetType, Long targetId) { long reportCount = reportRepository.countActiveReports(targetType, targetId); if (reportCount >= autoHideThreshold) { diff --git a/src/main/java/com/carecode/domain/health/controller/ChildController.java b/src/main/java/com/carecode/domain/health/controller/ChildController.java index ba4bdc19..53c70317 100644 --- a/src/main/java/com/carecode/domain/health/controller/ChildController.java +++ b/src/main/java/com/carecode/domain/health/controller/ChildController.java @@ -22,9 +22,7 @@ import java.time.LocalDate; import java.util.List; -/** - * 아이 정보 및 예방접종 일정 API. - */ +/** 아이 정보 및 예방접종 일정 API. */ @Slf4j @RestController @RequestMapping("/children") @@ -39,7 +37,7 @@ public class ChildController { @PostMapping @LogExecutionTime @Operation(summary = "아이 등록", - description = "아이를 등록하고 생년월일 기준 표준 예방접종 일정을 자동 생성합니다.") + description = "아이를 등록하고 생년월일 기준 표준 예방접종 일정을 자동 생성") public ResponseEntity createChild(@Valid @RequestBody ChildCreateRequest request) { return ResponseEntity.status(HttpStatus.CREATED).body(childService.createChild(request)); } @@ -74,11 +72,11 @@ public ResponseEntity deleteChild(@PathVariable Long childId) { return ResponseEntity.noContent().build(); } - // ==================== 예방접종 일정 ==================== - + // ==================== + // 예방접종 일정 ==================== @GetMapping("/{childId}/vaccinations") @LogExecutionTime - @Operation(summary = "예방접종 일정 조회", description = "표준 일정에 따른 접종 예정일과 완료 여부를 반환합니다.") + @Operation(summary = "예방접종 일정 조회", description = "표준 일정에 따른 접종 예정일과 완료 여부 반환") public ResponseEntity> getVaccinationSchedule(@PathVariable Long childId) { // 소유권 검증을 위해 아이 조회를 먼저 태운다. childService.getChild(childId); @@ -105,12 +103,12 @@ public ResponseEntity completeVaccination( return ResponseEntity.ok(vaccinationScheduleService.markCompleted(scheduleId, completedDate)); } - // ==================== 성장 곡선 ==================== - + // ==================== + // 성장 곡선 ==================== @GetMapping("/{childId}/growth") @LogExecutionTime @Operation(summary = "성장 곡선 조회", - description = "기록된 키/몸무게를 WHO 성장 표준과 비교한 백분위와 함께 반환합니다. " + description = "기록된 키/몸무게를 WHO 성장 표준과 비교한 백분위와 함께 반환" + "백분위는 참고 지표이며 진단은 의료진 판단을 따릅니다.") public ResponseEntity> getGrowthChart( @PathVariable Long childId, diff --git a/src/main/java/com/carecode/domain/health/controller/HealthRecordAttachmentController.java b/src/main/java/com/carecode/domain/health/controller/HealthRecordAttachmentController.java index 9beca249..38fc35a7 100644 --- a/src/main/java/com/carecode/domain/health/controller/HealthRecordAttachmentController.java +++ b/src/main/java/com/carecode/domain/health/controller/HealthRecordAttachmentController.java @@ -13,14 +13,7 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -/** - * 건강기록 첨부파일 업로드 API. - * - *

기존 {@code POST /health/records/{id}/attachments} 는 이미 업로드된 파일의 - * URL·메타데이터를 JSON 으로 받는 등록 API 다. 실제 바이너리를 올릴 경로가 없어 - * 첨부 기능을 쓸 수 없었으므로, 여기서 multipart 업로드만 추가한다. - * 목록 조회·삭제는 기존 엔드포인트를 그대로 사용한다. - */ +/** 건강기록 첨부파일 업로드 API. 기존 POST /health/records/{id/attachments} 는 이미 업로드된 파일의 URL·메타데이터를 JSON 으로 */ @RestController @RequestMapping("/health/records/{recordId}/attachments") @RequiredArgsConstructor @@ -32,7 +25,7 @@ public class HealthRecordAttachmentController { @PostMapping(path = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @LogExecutionTime @Operation(summary = "첨부파일 업로드", - description = "이미지 또는 PDF 를 업로드하고 건강기록에 연결합니다. (최대 10MB)") + description = "이미지 또는 PDF 를 업로드하고 건강기록에 연결합니다") public ResponseEntity upload( @PathVariable Long recordId, @Parameter(description = "업로드할 파일", required = true) @RequestPart("file") MultipartFile file, diff --git a/src/main/java/com/carecode/domain/health/dto/request/ChildCreateRequest.java b/src/main/java/com/carecode/domain/health/dto/request/ChildCreateRequest.java index bfa37945..c8930d06 100644 --- a/src/main/java/com/carecode/domain/health/dto/request/ChildCreateRequest.java +++ b/src/main/java/com/carecode/domain/health/dto/request/ChildCreateRequest.java @@ -10,9 +10,7 @@ import java.time.LocalDate; -/** - * 아이 등록/수정 요청. - */ +/** 아이 등록/수정 요청. */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/dto/request/HealthAlertsRequest.java b/src/main/java/com/carecode/domain/health/dto/request/HealthAlertsRequest.java index 20c29b6f..40322c69 100644 --- a/src/main/java/com/carecode/domain/health/dto/request/HealthAlertsRequest.java +++ b/src/main/java/com/carecode/domain/health/dto/request/HealthAlertsRequest.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 건강 알림 조회 요청 - */ +/** 건강 알림 조회 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/dto/request/HealthCheckupScheduleRequest.java b/src/main/java/com/carecode/domain/health/dto/request/HealthCheckupScheduleRequest.java index bfb7f564..a82d5c46 100644 --- a/src/main/java/com/carecode/domain/health/dto/request/HealthCheckupScheduleRequest.java +++ b/src/main/java/com/carecode/domain/health/dto/request/HealthCheckupScheduleRequest.java @@ -10,9 +10,7 @@ import java.time.LocalDateTime; -/** - * 건강 검진 스케줄 조회 요청 - */ +/** 건강 검진 스케줄 조회 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/dto/request/HealthCreateChildRequest.java b/src/main/java/com/carecode/domain/health/dto/request/HealthCreateChildRequest.java index 04a93a10..cc083b94 100644 --- a/src/main/java/com/carecode/domain/health/dto/request/HealthCreateChildRequest.java +++ b/src/main/java/com/carecode/domain/health/dto/request/HealthCreateChildRequest.java @@ -10,9 +10,7 @@ import java.time.LocalDate; -/** - * 아동 정보 생성 요청 - */ +/** 아동 정보 생성 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/dto/request/HealthCreateHealthRecordRequest.java b/src/main/java/com/carecode/domain/health/dto/request/HealthCreateHealthRecordRequest.java index 734be141..4c19498b 100644 --- a/src/main/java/com/carecode/domain/health/dto/request/HealthCreateHealthRecordRequest.java +++ b/src/main/java/com/carecode/domain/health/dto/request/HealthCreateHealthRecordRequest.java @@ -14,9 +14,7 @@ import java.time.LocalDateTime; -/** - * 건강 기록 생성 요청 - */ +/** 건강 기록 생성 요청 */ @Getter @Setter @NoArgsConstructor @@ -43,8 +41,7 @@ public class HealthCreateHealthRecordRequest { private String hospitalName; // ==================== 측정값 ==================== - // 성장 곡선(GrowthChartService)이 이 값을 읽으므로 생성 시점에 받을 수 있어야 한다. - + // 성장 곡선(GrowthChartService)이 이 값을 읽으므로 생성 시점에 받을 수 @DecimalMin(value = "0.0", inclusive = false, message = "키는 0보다 커야 합니다") @DecimalMax(value = "250.0", message = "키는 250cm를 넘을 수 없습니다") private Double height; // cm diff --git a/src/main/java/com/carecode/domain/health/dto/request/HealthCreateHospitalReviewRequest.java b/src/main/java/com/carecode/domain/health/dto/request/HealthCreateHospitalReviewRequest.java index 52f2daa9..0d71f630 100644 --- a/src/main/java/com/carecode/domain/health/dto/request/HealthCreateHospitalReviewRequest.java +++ b/src/main/java/com/carecode/domain/health/dto/request/HealthCreateHospitalReviewRequest.java @@ -8,9 +8,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 병원 리뷰 작성 요청 - */ +/** 병원 리뷰 작성 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/dto/request/HealthLikeHospitalRequest.java b/src/main/java/com/carecode/domain/health/dto/request/HealthLikeHospitalRequest.java index fd5f1621..33dffa82 100644 --- a/src/main/java/com/carecode/domain/health/dto/request/HealthLikeHospitalRequest.java +++ b/src/main/java/com/carecode/domain/health/dto/request/HealthLikeHospitalRequest.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 병원 좋아요 요청 - */ +/** 병원 좋아요 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/dto/request/HealthStatsRequest.java b/src/main/java/com/carecode/domain/health/dto/request/HealthStatsRequest.java index 4de29918..5183d9a5 100644 --- a/src/main/java/com/carecode/domain/health/dto/request/HealthStatsRequest.java +++ b/src/main/java/com/carecode/domain/health/dto/request/HealthStatsRequest.java @@ -8,9 +8,7 @@ import java.time.LocalDateTime; -/** - * 건강 통계 조회 요청 - */ +/** 건강 통계 조회 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/dto/request/HealthUpdateHealthRecordRequest.java b/src/main/java/com/carecode/domain/health/dto/request/HealthUpdateHealthRecordRequest.java index dadc6551..cc7e8872 100644 --- a/src/main/java/com/carecode/domain/health/dto/request/HealthUpdateHealthRecordRequest.java +++ b/src/main/java/com/carecode/domain/health/dto/request/HealthUpdateHealthRecordRequest.java @@ -14,9 +14,7 @@ import java.time.LocalDateTime; -/** - * 건강 기록 수정 요청 - */ +/** 건강 기록 수정 요청 */ @Getter @Setter @NoArgsConstructor @@ -37,8 +35,8 @@ public class HealthUpdateHealthRecordRequest { private String hospitalName; private Boolean isCompleted; - // ==================== 측정값 ==================== - + // ==================== + // 측정값 ==================== @DecimalMin(value = "0.0", inclusive = false, message = "키는 0보다 커야 합니다") @DecimalMax(value = "250.0", message = "키는 250cm를 넘을 수 없습니다") private Double height; // cm diff --git a/src/main/java/com/carecode/domain/health/dto/request/HealthUpdateHospitalReviewRequest.java b/src/main/java/com/carecode/domain/health/dto/request/HealthUpdateHospitalReviewRequest.java index 854323a5..0857e97f 100644 --- a/src/main/java/com/carecode/domain/health/dto/request/HealthUpdateHospitalReviewRequest.java +++ b/src/main/java/com/carecode/domain/health/dto/request/HealthUpdateHospitalReviewRequest.java @@ -8,9 +8,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 병원 리뷰 수정 요청 - */ +/** 병원 리뷰 수정 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/dto/request/HealthVaccineScheduleRequest.java b/src/main/java/com/carecode/domain/health/dto/request/HealthVaccineScheduleRequest.java index d79727ba..c32cd8f2 100644 --- a/src/main/java/com/carecode/domain/health/dto/request/HealthVaccineScheduleRequest.java +++ b/src/main/java/com/carecode/domain/health/dto/request/HealthVaccineScheduleRequest.java @@ -10,9 +10,7 @@ import java.time.LocalDateTime; -/** - * 예방접종 스케줄 조회 요청 - */ +/** 예방접종 스케줄 조회 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/dto/response/AttachmentResponse.java b/src/main/java/com/carecode/domain/health/dto/response/AttachmentResponse.java index 8c881d6b..f5eaacd2 100644 --- a/src/main/java/com/carecode/domain/health/dto/response/AttachmentResponse.java +++ b/src/main/java/com/carecode/domain/health/dto/response/AttachmentResponse.java @@ -6,9 +6,7 @@ import java.time.LocalDateTime; -/** - * 건강기록 첨부파일 응답. - */ +/** 건강기록 첨부파일 응답. */ @Getter @Builder public class AttachmentResponse { diff --git a/src/main/java/com/carecode/domain/health/dto/response/CheckupScheduleResponse.java b/src/main/java/com/carecode/domain/health/dto/response/CheckupScheduleResponse.java index 3b806869..8be08ef9 100644 --- a/src/main/java/com/carecode/domain/health/dto/response/CheckupScheduleResponse.java +++ b/src/main/java/com/carecode/domain/health/dto/response/CheckupScheduleResponse.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 건강 검진 스케줄 응답 - */ +/** 건강 검진 스케줄 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/dto/response/ChildInfoResponse.java b/src/main/java/com/carecode/domain/health/dto/response/ChildInfoResponse.java index 2adfa3d0..e9793f45 100644 --- a/src/main/java/com/carecode/domain/health/dto/response/ChildInfoResponse.java +++ b/src/main/java/com/carecode/domain/health/dto/response/ChildInfoResponse.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 아동 정보 응답 - */ +/** 아동 정보 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/dto/response/GrowthDataResponse.java b/src/main/java/com/carecode/domain/health/dto/response/GrowthDataResponse.java index f6c4bbc9..f110c8b0 100644 --- a/src/main/java/com/carecode/domain/health/dto/response/GrowthDataResponse.java +++ b/src/main/java/com/carecode/domain/health/dto/response/GrowthDataResponse.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 성장 데이터 - */ +/** 성장 데이터 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/dto/response/GrowthPointResponse.java b/src/main/java/com/carecode/domain/health/dto/response/GrowthPointResponse.java index 5c3d3028..5502141d 100644 --- a/src/main/java/com/carecode/domain/health/dto/response/GrowthPointResponse.java +++ b/src/main/java/com/carecode/domain/health/dto/response/GrowthPointResponse.java @@ -7,11 +7,7 @@ import java.time.LocalDate; -/** - * 성장 곡선의 한 지점. - * - *

백분위는 성별/생년월일이 없거나 WHO 표준 적용 범위(0~60개월)를 벗어나면 null 이다. - */ +/** 성장 곡선의 한 지점. 백분위는 성별/생년월일이 없거나 WHO 표준 적용 범위(0~60개월)를 벗어나면 null 이다. */ @Getter @Builder public class GrowthPointResponse { diff --git a/src/main/java/com/carecode/domain/health/dto/response/GrowthTrendResponse.java b/src/main/java/com/carecode/domain/health/dto/response/GrowthTrendResponse.java index 241c5511..dbef6006 100644 --- a/src/main/java/com/carecode/domain/health/dto/response/GrowthTrendResponse.java +++ b/src/main/java/com/carecode/domain/health/dto/response/GrowthTrendResponse.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 성장 추이 응답 - */ +/** 성장 추이 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/dto/response/HealthAlertResponse.java b/src/main/java/com/carecode/domain/health/dto/response/HealthAlertResponse.java index 2618b362..cca28226 100644 --- a/src/main/java/com/carecode/domain/health/dto/response/HealthAlertResponse.java +++ b/src/main/java/com/carecode/domain/health/dto/response/HealthAlertResponse.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 건강 알림 응답 - */ +/** 건강 알림 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/dto/response/HealthRecordResponse.java b/src/main/java/com/carecode/domain/health/dto/response/HealthRecordResponse.java index 8c3d2da7..e33f6b44 100644 --- a/src/main/java/com/carecode/domain/health/dto/response/HealthRecordResponse.java +++ b/src/main/java/com/carecode/domain/health/dto/response/HealthRecordResponse.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 건강 기록 응답 - */ +/** 건강 기록 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/dto/response/HealthStatsResponse.java b/src/main/java/com/carecode/domain/health/dto/response/HealthStatsResponse.java index bfeb7b0d..0b6783d9 100644 --- a/src/main/java/com/carecode/domain/health/dto/response/HealthStatsResponse.java +++ b/src/main/java/com/carecode/domain/health/dto/response/HealthStatsResponse.java @@ -9,9 +9,7 @@ import java.util.List; import java.util.Map; -/** - * 건강 통계 응답 - */ +/** 건강 통계 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/dto/response/HospitalDetailResponse.java b/src/main/java/com/carecode/domain/health/dto/response/HospitalDetailResponse.java index f3514e4d..71acc126 100644 --- a/src/main/java/com/carecode/domain/health/dto/response/HospitalDetailResponse.java +++ b/src/main/java/com/carecode/domain/health/dto/response/HospitalDetailResponse.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 병원 상세 정보 응답 - */ +/** 병원 상세 정보 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/dto/response/HospitalInfoResponse.java b/src/main/java/com/carecode/domain/health/dto/response/HospitalInfoResponse.java index 71a4bce2..7f4019fa 100644 --- a/src/main/java/com/carecode/domain/health/dto/response/HospitalInfoResponse.java +++ b/src/main/java/com/carecode/domain/health/dto/response/HospitalInfoResponse.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 병원 정보 응답 - */ +/** 병원 정보 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/dto/response/HospitalListResponse.java b/src/main/java/com/carecode/domain/health/dto/response/HospitalListResponse.java index 6c333221..859dd01e 100644 --- a/src/main/java/com/carecode/domain/health/dto/response/HospitalListResponse.java +++ b/src/main/java/com/carecode/domain/health/dto/response/HospitalListResponse.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 병원 목록 응답 - */ +/** 병원 목록 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/dto/response/HospitalNearbyResponse.java b/src/main/java/com/carecode/domain/health/dto/response/HospitalNearbyResponse.java index 9d6f49e3..fe927e53 100644 --- a/src/main/java/com/carecode/domain/health/dto/response/HospitalNearbyResponse.java +++ b/src/main/java/com/carecode/domain/health/dto/response/HospitalNearbyResponse.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 근처 병원 응답 - */ +/** 근처 병원 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/dto/response/HospitalReviewResponse.java b/src/main/java/com/carecode/domain/health/dto/response/HospitalReviewResponse.java index eb28e9e8..d6454fec 100644 --- a/src/main/java/com/carecode/domain/health/dto/response/HospitalReviewResponse.java +++ b/src/main/java/com/carecode/domain/health/dto/response/HospitalReviewResponse.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 병원 리뷰 응답 - */ +/** 병원 리뷰 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/dto/response/HospitalSearchResponse.java b/src/main/java/com/carecode/domain/health/dto/response/HospitalSearchResponse.java index 2752d9bb..6294ed79 100644 --- a/src/main/java/com/carecode/domain/health/dto/response/HospitalSearchResponse.java +++ b/src/main/java/com/carecode/domain/health/dto/response/HospitalSearchResponse.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 병원 검색 응답 - */ +/** 병원 검색 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/dto/response/VaccinationScheduleResponse.java b/src/main/java/com/carecode/domain/health/dto/response/VaccinationScheduleResponse.java index bc19edd7..07532e30 100644 --- a/src/main/java/com/carecode/domain/health/dto/response/VaccinationScheduleResponse.java +++ b/src/main/java/com/carecode/domain/health/dto/response/VaccinationScheduleResponse.java @@ -6,9 +6,7 @@ import java.time.LocalDate; -/** - * 예방접종 일정 응답. - */ +/** 예방접종 일정 응답. */ @Getter @Builder public class VaccinationScheduleResponse { diff --git a/src/main/java/com/carecode/domain/health/dto/response/VaccineScheduleResponse.java b/src/main/java/com/carecode/domain/health/dto/response/VaccineScheduleResponse.java index 9ba14ce3..55e4c757 100644 --- a/src/main/java/com/carecode/domain/health/dto/response/VaccineScheduleResponse.java +++ b/src/main/java/com/carecode/domain/health/dto/response/VaccineScheduleResponse.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 예방접종 스케줄 응답 - */ +/** 예방접종 스케줄 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/entity/HealthRecord.java b/src/main/java/com/carecode/domain/health/entity/HealthRecord.java index e746bad4..185f96f5 100644 --- a/src/main/java/com/carecode/domain/health/entity/HealthRecord.java +++ b/src/main/java/com/carecode/domain/health/entity/HealthRecord.java @@ -14,9 +14,7 @@ import java.util.ArrayList; import java.util.List; -/** - * 건강 기록 엔티티 - */ +/** 건강 기록 엔티티 */ @Entity @Table(name = "TBL_HEALTH_RECORD") @Getter @@ -135,28 +133,22 @@ protected void onCreate() { protected void onUpdate() { updatedAt = LocalDateTime.now(); } - // 기록 완료 처리 - public void markAsCompleted() { this.isCompleted = true; this.status = RecordStatus.COMPLETED; } - // 기록 상태 업데이트 - public void updateStatus(RecordStatus status) { this.status = status; if (status == RecordStatus.COMPLETED) { this.isCompleted = true; } } - // 기록 내용 업데이트 - public void updateRecord(LocalDate recordDate, Double height, Double weight, Double temperature, String bloodPressure, Integer pulseRate, String notes) { @@ -173,10 +165,8 @@ public void updateRecord(LocalDate recordDate, this.pulseRate = pulseRate; this.notes = notes; } - // 기록 타입 Enum - public enum RecordType { VACCINATION("예방접종"), CHECKUP("건강검진"), @@ -197,10 +187,8 @@ public String getDisplayName() { return displayName; } } - // 기록 상태 Enum - public enum RecordStatus { SCHEDULED("예정"), IN_PROGRESS("진행중"), diff --git a/src/main/java/com/carecode/domain/health/entity/HealthRecordAttachment.java b/src/main/java/com/carecode/domain/health/entity/HealthRecordAttachment.java index e05338af..6b477dc8 100644 --- a/src/main/java/com/carecode/domain/health/entity/HealthRecordAttachment.java +++ b/src/main/java/com/carecode/domain/health/entity/HealthRecordAttachment.java @@ -11,10 +11,6 @@ import java.time.LocalDateTime; -/* - * @author CareCode Team - * @since 1.0.0 - */ @Entity @Table(name = "TBL_HEALTH_RECORD_ATTACHMENTS") @Getter @@ -22,97 +18,56 @@ @EntityListeners(AuditingEntityListener.class) public class HealthRecordAttachment { - // 첨부파일 고유 식별자 - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "ID") private Long id; - // 이 첨부파일이 속한 건강 기록 - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "HEALTH_RECORD_ID", nullable = false) private HealthRecord healthRecord; - - // 파일 URL - // 파일이 저장된 위치의 URL (최대 500자) - + // 파일 URL 파일이 저장된 위치의 URL (최대 500자) @Column(name = "FILE_URL", nullable = false, length = 500) private String fileUrl; - - // 파일명 - // 원본 파일명 (최대 200자) - + // 파일명 원본 파일명 (최대 200자) @Column(name = "FILE_NAME", nullable = false, length = 200) private String fileName; - - // 파일 타입 - // MIME 타입 (예: image/jpeg, application/pdf) (최대 50자) - + // 파일 타입 MIME 타입 (예: image/jpeg, application/pdf) (최대 50자) @Column(name = "FILE_TYPE", length = 50) private String fileType; - - // 파일 크기 (바이트) - // 파일의 크기를 바이트 단위로 저장 - + // 파일 크기 (바이트) 파일의 크기를 바이트 단위로 저장 @Column(name = "FILE_SIZE") private Long fileSize; - - // 첨부파일 설명 - // 첨부파일에 대한 설명 (최대 500자) - + // 첨부파일 설명 첨부파일에 대한 설명 (최대 500자) @Column(name = "DESCRIPTION", length = 500) private String description; - - // 표시 순서 - // UI에서 첨부파일이 표시되는 순서 (기본값: 0) - + // 표시 순서 UI에서 첨부파일이 표시되는 순서 (기본값: 0) @Column(name = "DISPLAY_ORDER", nullable = false) private Integer displayOrder = 0; - - // 활성 상태 여부 - // 첨부파일의 활성/비활성 상태 (기본값: true) - + // 활성 상태 여부 첨부파일의 활성/비활성 상태 (기본값: true) @Column(name = "IS_ACTIVE", nullable = false) private Boolean isActive = true; - - // 생성 일시 - // 첨부파일이 데이터베이스에 처음 저장된 시간 (JPA Auditing) - + // 생성 일시 첨부파일이 데이터베이스에 처음 저장된 시간 (JPA Auditing) @CreatedDate @Column(name = "CREATED_AT", nullable = false, updatable = false) private LocalDateTime createdAt; - - // 수정 일시 - // 첨부파일 정보가 마지막으로 수정된 시간 (JPA Auditing) - + // 수정 일시 첨부파일 정보가 마지막으로 수정된 시간 (JPA Auditing) @LastModifiedDate @Column(name = "UPDATED_AT") private LocalDateTime updatedAt; - - // 첨부파일 생성자 - // - // @param healthRecord 이 첨부파일이 속한 건강 기록 - // @param fileUrl 파일 URL - // @param fileName 파일명 - // @param fileType 파일 타입 - // @param fileSize 파일 크기 - // @param description 첨부파일 설명 - // @param displayOrder 표시 순서 - + // 첨부파일 생성자 @param healthRecord 이 첨부파일이 속한 건강 기록 @param fileUrl 파일 URL @param fileName 파일명 @param @Builder public HealthRecordAttachment(HealthRecord healthRecord, String fileUrl, String fileName, String fileType, Long fileSize, String description, Integer displayOrder) { @@ -125,16 +80,7 @@ public HealthRecordAttachment(HealthRecord healthRecord, String fileUrl, String this.displayOrder = displayOrder; } - - // 첨부파일 정보 업데이트 - // - // @param fileUrl 새로운 파일 URL - // @param fileName 새로운 파일명 - // @param fileType 새로운 파일 타입 - // @param fileSize 새로운 파일 크기 - // @param description 새로운 첨부파일 설명 - // @param displayOrder 새로운 표시 순서 - + // 첨부파일 정보 업데이트 @param fileUrl 새로운 파일 URL @param fileName 새로운 파일명 @param fileType 새로운 파일 타입 public void updateAttachment(String fileUrl, String fileName, String fileType, Long fileSize, String description, Integer displayOrder) { this.fileUrl = fileUrl; @@ -145,18 +91,12 @@ public void updateAttachment(String fileUrl, String fileName, String fileType, this.displayOrder = displayOrder; } - - // 첨부파일 비활성화 - // 첨부파일을 비활성 상태로 변경 - + // 첨부파일 비활성화 첨부파일을 비활성 상태로 변경 public void deactivate() { this.isActive = false; } - - // 첨부파일 활성화 - // 비활성화된 첨부파일을 다시 활성 상태로 변경 - + // 첨부파일 활성화 비활성화된 첨부파일을 다시 활성 상태로 변경 public void activate() { this.isActive = true; } diff --git a/src/main/java/com/carecode/domain/health/entity/HealthRecordType.java b/src/main/java/com/carecode/domain/health/entity/HealthRecordType.java index 0c346b19..c04be86f 100644 --- a/src/main/java/com/carecode/domain/health/entity/HealthRecordType.java +++ b/src/main/java/com/carecode/domain/health/entity/HealthRecordType.java @@ -13,13 +13,7 @@ import java.util.ArrayList; import java.util.List; -/** - * 건강 기록 유형 엔티티 - * 건강 기록(예: 예방접종, 진료, 성장 등)의 유형을 정의하고 관리합니다. - * @author CareCode Team - * @since 1.0.0 - * @see HealthRecord - */ +/** 건강 기록 유형 엔티티 */ @Entity @Table(name = "TBL_HEALTH_RECORD_TYPES") @Getter @@ -27,66 +21,46 @@ @EntityListeners(AuditingEntityListener.class) public class HealthRecordType { - // 건강 기록 유형 고유 식별자 - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - // 건강 기록 유형명 - @Column(name = "name", nullable = false, unique = true, length = 100) private String name; - // 카테고리 - @Column(name = "category", length = 50) private String category; - // 유형 설명 - @Column(name = "description", length = 500) private String description; - // 표시 순서 - @Column(name = "display_order", nullable = false) private Integer displayOrder = 0; - // 활성 상태 여부 - @Column(name = "is_active", nullable = false) private Boolean isActive = true; - // 생성 일시 - @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; - // 수정 일시 - @LastModifiedDate @Column(name = "updated_at") private LocalDateTime updatedAt; - // 이 유형에 속한 건강 기록 목록 - @OneToMany(mappedBy = "healthRecordType", cascade = CascadeType.ALL, orphanRemoval = true) private List healthRecords = new ArrayList<>(); - // 건강 기록 유형 생성자 - @Builder public HealthRecordType(String name, String category, String description, Integer displayOrder) { this.name = name; @@ -95,9 +69,7 @@ public HealthRecordType(String name, String category, String description, Intege this.displayOrder = displayOrder; } - // 건강 기록 유형 정보 업데이트 - public void updateRecordType(String name, String category, String description, Integer displayOrder) { this.name = name; this.category = category; @@ -105,16 +77,12 @@ public void updateRecordType(String name, String category, String description, I this.displayOrder = displayOrder; } - // 유형 비활성화 - public void deactivate() { this.isActive = false; } - // 유형 활성화 - public void activate() { this.isActive = true; } diff --git a/src/main/java/com/carecode/domain/health/entity/VaccinationSchedule.java b/src/main/java/com/carecode/domain/health/entity/VaccinationSchedule.java index 61a6aa69..0270a844 100644 --- a/src/main/java/com/carecode/domain/health/entity/VaccinationSchedule.java +++ b/src/main/java/com/carecode/domain/health/entity/VaccinationSchedule.java @@ -11,12 +11,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; -/** - * 아이별 예방접종 일정. - * - *

아이를 등록하면 {@link VaccineType} 표준 일정에 따라 접종 예정일이 자동으로 생성된다. - * 스케줄러가 예정일이 임박한 항목을 찾아 보호자에게 알림을 보낸다. - */ +/** 아이별 예방접종 일정. 아이를 등록하면 VaccineType 표준 일정에 따라 접종 예정일이 자동으로 생성된다. */ @Entity @Table( name = "TBL_VACCINATION_SCHEDULE", diff --git a/src/main/java/com/carecode/domain/health/entity/VaccineType.java b/src/main/java/com/carecode/domain/health/entity/VaccineType.java index b0ef960d..5143c42f 100644 --- a/src/main/java/com/carecode/domain/health/entity/VaccineType.java +++ b/src/main/java/com/carecode/domain/health/entity/VaccineType.java @@ -3,15 +3,7 @@ import java.util.Arrays; import java.util.List; -/** - * 국가예방접종(NIP) 표준 일정. - * - *

각 백신의 회차별 접종 시기를 생후 개월 수로 정의한다. - * 아이 등록 시 생년월일에 이 개월 수를 더해 접종 예정일을 계산한다. - * - *

주의: 접종 시기는 질병관리청 지침에 따라 바뀔 수 있다. - * 여기 값은 표준 일정이며, 실제 접종은 의료진 판단을 따른다. - */ +/** 국가예방접종(NIP) 표준 일정. 각 백신의 회차별 접종 시기를 생후 개월 수로 정의한다. */ public enum VaccineType { BCG("BCG(결핵)", List.of(0)), @@ -49,10 +41,6 @@ public int getTotalDoses() { return doseMonths.size(); } - /** - * @param doseNumber 1부터 시작하는 회차 - * @return 해당 회차의 접종 시기(생후 개월 수) - */ public int getMonthsForDose(int doseNumber) { if (doseNumber < 1 || doseNumber > doseMonths.size()) { throw new IllegalArgumentException( diff --git a/src/main/java/com/carecode/domain/health/growth/GrowthMetric.java b/src/main/java/com/carecode/domain/health/growth/GrowthMetric.java index 1f349be3..f210c1b8 100644 --- a/src/main/java/com/carecode/domain/health/growth/GrowthMetric.java +++ b/src/main/java/com/carecode/domain/health/growth/GrowthMetric.java @@ -1,8 +1,6 @@ package com.carecode.domain.health.growth; -/** - * 성장 지표 종류. - */ +/** 성장 지표 종류. */ public enum GrowthMetric { WEIGHT("체중", "kg"), HEIGHT("신장", "cm"); diff --git a/src/main/java/com/carecode/domain/health/growth/GrowthPercentileCalculator.java b/src/main/java/com/carecode/domain/health/growth/GrowthPercentileCalculator.java index 430b1a1a..5a57fb27 100644 --- a/src/main/java/com/carecode/domain/health/growth/GrowthPercentileCalculator.java +++ b/src/main/java/com/carecode/domain/health/growth/GrowthPercentileCalculator.java @@ -2,20 +2,12 @@ import java.util.Optional; -/** - * WHO LMS 방식 백분위 계산기. - * - *

Z-score = ((측정값 / M)^L - 1) / (L × S), L=0 이면 ln(측정값/M) / S. - * 계산한 Z-score 를 표준정규 누적분포에 통과시켜 백분위를 얻는다. - */ +/** WHO LMS 방식 백분위 계산기. Z-score = ((측정값 / M)^L - 1) / (L × S), L=0 이면 ln(측정값/M) / S */ public final class GrowthPercentileCalculator { private GrowthPercentileCalculator() { } - /** - * @return 계산 결과. 표준표 범위를 벗어나거나 입력이 유효하지 않으면 empty - */ public static Optional calculate(GrowthMetric metric, Sex sex, int ageMonths, @@ -48,10 +40,7 @@ private static double toZScore(double value, GrowthStandard standard) { return (Math.pow(value / m, l) - 1) / (l * s); } - /** - * 표준정규 누적분포함수. - * Abramowitz & Stegun 7.1.26 근사식을 사용한다 (오차 < 1.5e-7). - */ + /** 표준정규 누적분포함수. Abramowitz & Stegun 7.1.26 근사식을 사용한다 (오차 < 1.5e-7). */ static double normalCdf(double z) { return 0.5 * (1.0 + erf(z / Math.sqrt(2.0))); } diff --git a/src/main/java/com/carecode/domain/health/growth/GrowthPercentileResult.java b/src/main/java/com/carecode/domain/health/growth/GrowthPercentileResult.java index dc93b0e5..4b79b6c4 100644 --- a/src/main/java/com/carecode/domain/health/growth/GrowthPercentileResult.java +++ b/src/main/java/com/carecode/domain/health/growth/GrowthPercentileResult.java @@ -1,15 +1,6 @@ package com.carecode.domain.health.growth; -/** - * 성장 백분위 계산 결과. - * - * @param metric 지표 종류 - * @param ageMonths 측정 시점의 생후 개월 수 - * @param measuredValue 측정값 - * @param medianValue 같은 연령·성별의 표준 중앙값(50 백분위) - * @param zScore 표준화 점수 - * @param percentile 백분위 (0~100) - */ +/** 성장 백분위 계산 결과. */ public record GrowthPercentileResult( GrowthMetric metric, int ageMonths, @@ -18,10 +9,7 @@ public record GrowthPercentileResult( double zScore, double percentile) { - /** - * 임상적 주의가 필요한 범위인지. - * WHO 는 |Z| > 2 를 주의 구간으로 본다. - */ + /** 임상적 주의가 필요한 범위인지. WHO 는 |Z| > 2 를 주의 구간으로 본다. */ public boolean needsAttention() { return Math.abs(zScore) > 2.0; } diff --git a/src/main/java/com/carecode/domain/health/growth/GrowthStandard.java b/src/main/java/com/carecode/domain/health/growth/GrowthStandard.java index ffc7ce0f..1f3f36a5 100644 --- a/src/main/java/com/carecode/domain/health/growth/GrowthStandard.java +++ b/src/main/java/com/carecode/domain/health/growth/GrowthStandard.java @@ -1,15 +1,5 @@ package com.carecode.domain.health.growth; -/** - * WHO 아동 성장 표준의 LMS 파라미터. - * - *

WHO 는 연령·성별별로 L(왜도), M(중앙값), S(변동계수) 세 값을 제공하며, - * 이 값으로 개별 측정치의 Z-score 와 백분위를 계산한다. - * - * @param ageMonths 생후 개월 수 - * @param l Box-Cox 변환 지수 - * @param m 중앙값 - * @param s 변동계수 - */ +/** WHO 아동 성장 표준의 LMS 파라미터. WHO 는 연령·성별별로 L(왜도), M(중앙값), S(변동계수) 세 값을 제공하며 */ public record GrowthStandard(int ageMonths, double l, double m, double s) { } diff --git a/src/main/java/com/carecode/domain/health/growth/GrowthStandardTable.java b/src/main/java/com/carecode/domain/health/growth/GrowthStandardTable.java index 5e8083e6..c06c3f0b 100644 --- a/src/main/java/com/carecode/domain/health/growth/GrowthStandardTable.java +++ b/src/main/java/com/carecode/domain/health/growth/GrowthStandardTable.java @@ -4,15 +4,7 @@ import java.util.Map; import java.util.Optional; -/** - * WHO 아동 성장 표준(0~60개월) LMS 표. - * - *

출처: WHO Child Growth Standards (weight-for-age, length/height-for-age). - * 표는 6개월 간격 발췌본이며, 사이 값은 선형 보간한다. - * 정밀한 임상 판단이 필요하면 WHO 전체 표를 적재해 대체할 수 있다. - * - *

주의: 백분위는 참고 지표다. 진단은 의료진의 판단을 따른다. - */ +/** WHO 아동 성장 표준(0~60개월) LMS 표. 출처: WHO Child Growth Standards (weight-for-age. */ public final class GrowthStandardTable { /** 남아 체중(kg) for age. */ @@ -80,11 +72,7 @@ public final class GrowthStandardTable { private GrowthStandardTable() { } - /** - * 해당 연령의 LMS 값을 구한다. 표에 없는 개월 수는 인접 구간을 선형 보간한다. - * - * @return 표 범위를 벗어나면 {@link Optional#empty()} - */ + /** 해당 연령의 LMS 값을 구한다. 표에 없는 개월 수는 인접 구간을 선형 보간한다. */ public static Optional lookup(GrowthMetric metric, Sex sex, int ageMonths) { List table = TABLES.get(new Key(metric, sex)); if (table == null || ageMonths < 0) { diff --git a/src/main/java/com/carecode/domain/health/growth/Sex.java b/src/main/java/com/carecode/domain/health/growth/Sex.java index 6eb72c55..95b14eb8 100644 --- a/src/main/java/com/carecode/domain/health/growth/Sex.java +++ b/src/main/java/com/carecode/domain/health/growth/Sex.java @@ -3,11 +3,7 @@ import java.util.Locale; import java.util.Optional; -/** - * 성장 표준 조회를 위한 성별. - * - *

아이 엔티티의 gender 는 자유 문자열이라 다양한 표기가 들어올 수 있어 관대하게 파싱한다. - */ +/** 성장 표준 조회를 위한 성별. 아이 엔티티의 gender 는 자유 문자열이라 다양한 표기가 들어올 수 있어 관대하게 파싱한다. */ public enum Sex { MALE, FEMALE; diff --git a/src/main/java/com/carecode/domain/health/mapper/ChildMapper.java b/src/main/java/com/carecode/domain/health/mapper/ChildMapper.java index 059c7836..ef9ffc13 100644 --- a/src/main/java/com/carecode/domain/health/mapper/ChildMapper.java +++ b/src/main/java/com/carecode/domain/health/mapper/ChildMapper.java @@ -21,4 +21,3 @@ public ChildInfoResponse toResponse(Child child) { } } - diff --git a/src/main/java/com/carecode/domain/health/mapper/HealthRecordMapper.java b/src/main/java/com/carecode/domain/health/mapper/HealthRecordMapper.java index 8dda2237..29d774f2 100644 --- a/src/main/java/com/carecode/domain/health/mapper/HealthRecordMapper.java +++ b/src/main/java/com/carecode/domain/health/mapper/HealthRecordMapper.java @@ -7,9 +7,7 @@ import com.carecode.domain.health.entity.HealthRecord; import org.springframework.stereotype.Component; -/** - * HealthRecord 변환용 공통 매퍼 - */ +/** HealthRecord 변환용 공통 매퍼 */ @Component public class HealthRecordMapper implements RequestMapper, ResponseMapper { @@ -63,4 +61,3 @@ public HealthRecordResponse toResponse(HealthRecord record) { } } - diff --git a/src/main/java/com/carecode/domain/health/mapper/HospitalMapper.java b/src/main/java/com/carecode/domain/health/mapper/HospitalMapper.java index 2723a099..1c3979fa 100644 --- a/src/main/java/com/carecode/domain/health/mapper/HospitalMapper.java +++ b/src/main/java/com/carecode/domain/health/mapper/HospitalMapper.java @@ -23,4 +23,3 @@ public HospitalInfoResponse toResponse(Hospital hospital) { } } - diff --git a/src/main/java/com/carecode/domain/health/mapper/HospitalReviewMapper.java b/src/main/java/com/carecode/domain/health/mapper/HospitalReviewMapper.java index fa7a0ccd..ad71adc7 100644 --- a/src/main/java/com/carecode/domain/health/mapper/HospitalReviewMapper.java +++ b/src/main/java/com/carecode/domain/health/mapper/HospitalReviewMapper.java @@ -23,4 +23,3 @@ public HospitalReviewResponse toResponse(HospitalReview review) { } } - diff --git a/src/main/java/com/carecode/domain/health/repository/HealthRecordRepository.java b/src/main/java/com/carecode/domain/health/repository/HealthRecordRepository.java index d96079bb..92cb698e 100644 --- a/src/main/java/com/carecode/domain/health/repository/HealthRecordRepository.java +++ b/src/main/java/com/carecode/domain/health/repository/HealthRecordRepository.java @@ -13,9 +13,7 @@ import java.time.LocalDate; import java.util.List; -/** - * 건강 기록 리포지토리 인터페이스 - */ +/** 건강 기록 리포지토리 인터페이스 */ @Repository public interface HealthRecordRepository extends JpaRepository { diff --git a/src/main/java/com/carecode/domain/health/repository/VaccinationScheduleRepository.java b/src/main/java/com/carecode/domain/health/repository/VaccinationScheduleRepository.java index e2c5d4b2..54bcfc60 100644 --- a/src/main/java/com/carecode/domain/health/repository/VaccinationScheduleRepository.java +++ b/src/main/java/com/carecode/domain/health/repository/VaccinationScheduleRepository.java @@ -16,11 +16,7 @@ public interface VaccinationScheduleRepository extends JpaRepositorychild, user 를 함께 로딩해 알림 발송 시 N+1 을 피한다. - */ + /** 알림 대상 조회: 예정일이 구간 안에 있고 아직 알림을 보내지 않은 미완료 일정. child, user 를 함께 로딩해 알림 발송 시 N+1 을 피한다. */ @Query("SELECT vs FROM VaccinationSchedule vs " + "JOIN FETCH vs.child c JOIN FETCH c.user " + "WHERE vs.status = com.carecode.domain.health.entity.VaccinationSchedule.VaccinationStatus.SCHEDULED " + diff --git a/src/main/java/com/carecode/domain/health/service/ChildService.java b/src/main/java/com/carecode/domain/health/service/ChildService.java index f591e577..347d8adf 100644 --- a/src/main/java/com/carecode/domain/health/service/ChildService.java +++ b/src/main/java/com/carecode/domain/health/service/ChildService.java @@ -17,11 +17,7 @@ import java.time.Period; import java.util.List; -/** - * 아이 정보 관리. - * - *

등록 시 표준 예방접종 일정을 함께 생성한다. - */ +/** 아이 정보 관리. 등록 시 표준 예방접종 일정을 함께 생성한다. */ @Slf4j @Service @RequiredArgsConstructor @@ -84,10 +80,7 @@ public void deleteChild(Long childId) { childRepository.delete(requireOwnedChild(childId)); } - /** - * 아이 조회 + 소유권 검증. - * 남의 아이 정보에 접근하지 못하도록 보호자 본인 것만 반환한다. - */ + /** 아이 조회 + 소유권 검증. 남의 아이 정보에 접근하지 못하도록 보호자 본인 것만 반환한다. */ private Child requireOwnedChild(Long childId) { User parent = currentUserFacade.requireCurrentUser(); Child child = childRepository.findById(childId) diff --git a/src/main/java/com/carecode/domain/health/service/GrowthChartService.java b/src/main/java/com/carecode/domain/health/service/GrowthChartService.java index a06a5483..0b8ed14b 100644 --- a/src/main/java/com/carecode/domain/health/service/GrowthChartService.java +++ b/src/main/java/com/carecode/domain/health/service/GrowthChartService.java @@ -25,12 +25,7 @@ import java.util.Optional; import java.util.function.Function; -/** - * 아이 성장 곡선. - * - *

기록된 키/몸무게를 WHO 성장 표준과 비교해 백분위를 함께 제공한다. - * 기존 차트 API 는 측정값만 나열해서 "또래와 비교해 어떤지" 를 알 수 없었다. - */ +/** 아이 성장 곡선. 기록된 키/몸무게를 WHO 성장 표준과 비교해 백분위를 함께 제공한다. */ @Slf4j @Service @RequiredArgsConstructor diff --git a/src/main/java/com/carecode/domain/health/service/HealthRecordAttachmentService.java b/src/main/java/com/carecode/domain/health/service/HealthRecordAttachmentService.java index 8a228b6b..387da343 100644 --- a/src/main/java/com/carecode/domain/health/service/HealthRecordAttachmentService.java +++ b/src/main/java/com/carecode/domain/health/service/HealthRecordAttachmentService.java @@ -18,11 +18,7 @@ import java.util.List; -/** - * 건강기록 첨부파일 관리. - * - *

첨부 엔티티와 테이블은 있었지만 업로드 경로가 없어 사용할 수 없던 기능을 연결한다. - */ +/** 건강기록 첨부파일 관리. 첨부 엔티티와 테이블은 있었지만 업로드 경로가 없어 사용할 수 없던 기능을 연결한다. */ @Slf4j @Service @RequiredArgsConstructor @@ -77,10 +73,7 @@ public void delete(Long recordId, Long attachmentId) { attachmentRepository.delete(attachment); } - /** - * 건강기록 조회 + 소유권 검증. - * 건강기록은 민감정보이므로 본인 기록만 접근할 수 있어야 한다. - */ + /** 건강기록 조회 + 소유권 검증. 건강기록은 민감정보이므로 본인 기록만 접근할 수 있어야 한다. */ private HealthRecord requireOwnedRecord(Long recordId) { User currentUser = currentUserFacade.requireCurrentUser(); HealthRecord record = healthRecordRepository.findById(recordId) diff --git a/src/main/java/com/carecode/domain/health/service/HealthService.java b/src/main/java/com/carecode/domain/health/service/HealthService.java index 44fb2b01..9a999cd6 100644 --- a/src/main/java/com/carecode/domain/health/service/HealthService.java +++ b/src/main/java/com/carecode/domain/health/service/HealthService.java @@ -50,10 +50,7 @@ import java.util.Optional; import java.util.stream.Collectors; -/** - * 통합 건강 관리 서비스 클래스 - * 건강 기록, 아동 정보, 건강 분석 등 모든 건강 관련 비즈니스 로직을 처리 - */ +/** 통합 건강 관리 서비스 */ @Slf4j @Service @RequiredArgsConstructor @@ -78,10 +75,8 @@ public class HealthService { private final ChildMapper childMapper; // ===== 건강 기록 관리 ===== - // 건강 기록 생성 - @LogExecutionTime @Transactional public HealthRecordResponse createHealthRecord(HealthCreateHealthRecordRequest request, Long actorUserId) { @@ -115,9 +110,7 @@ public HealthRecordResponse createHealthRecord(HealthCreateHealthRecordRequest r } } - // 건강 기록 조회 - @LogExecutionTime public HealthRecordResponse getHealthRecordById(Long recordId, Long actorUserId) { validateRecordId(recordId); @@ -139,9 +132,7 @@ public HealthRecordResponse getHealthRecordById(Long recordId, Long actorUserId) } } - // 사용자별 건강 기록 조회 (DTO 반환) - @LogExecutionTime public List getHealthRecordsByUserId(String userId, Long actorUserId) { log.info("사용자별 건강 기록 조회: 사용자ID={}", userId); @@ -159,10 +150,7 @@ public List getHealthRecordsByUserId(String userId, Long a // HealthRecord -> DTO 변환은 healthRecordMapper 사용 - - // 사용자별 건강 기록 조회 (Entity 반환) - // JOIN FETCH를 사용하여 N+1 쿼리 문제 해결 - + // 사용자별 건강 기록 조회 (Entity 반환) JOIN FETCH를 사용하여 N+1 쿼리 문제 해결 @LogExecutionTime public List getHealthRecordsByUserIdAsEntity(String userId) { validateUserId(userId); @@ -171,9 +159,7 @@ public List getHealthRecordsByUserIdAsEntity(String userId) { return healthRecordRepository.findByUserIdWithChildAndUser(user.getId()); } - // 건강 기록 수정 - @LogExecutionTime @Transactional public HealthRecordResponse updateHealthRecord(Long recordId, HealthUpdateHealthRecordRequest request, Long actorUserId) { @@ -205,9 +191,7 @@ public HealthRecordResponse updateHealthRecord(Long recordId, HealthUpdateHealth return healthRecordMapper.toResponse(updatedRecord); } - // 건강 기록 삭제 - @LogExecutionTime @Transactional public void deleteHealthRecord(Long recordId, Long actorUserId) { @@ -221,9 +205,7 @@ public void deleteHealthRecord(Long recordId, Long actorUserId) { log.info("건강 기록이 삭제되었습니다: 기록ID={}", recordId); } - // 건강 기록 목록 조회 (페이징) - @LogExecutionTime public List getHealthRecords(Long childId, int page, int size, Long actorUserId) { validateChildId(childId); @@ -240,10 +222,7 @@ public List getHealthRecords(Long childId, int page, int s .collect(Collectors.toList()); } - - // 기간별 건강 기록 조회 - // JOIN FETCH를 사용하여 N+1 쿼리 문제 해결 - + // 기간별 건강 기록 조회 JOIN FETCH를 사용하여 N+1 쿼리 문제 해결 @LogExecutionTime public List getHealthRecordsByDateRange(Long childId, LocalDate startDate, LocalDate endDate, Long actorUserId) { validateChildId(childId); @@ -263,9 +242,7 @@ public List getHealthRecordsByDateRange(Long childId, Loca // ===== 아동 정보 관리 ===== - // 연령 범위별 자녀 조회 - @LogExecutionTime public List getChildrenByAgeRange(Long userId, Integer minAge, Integer maxAge) { log.info("연령 범위별 자녀 조회 - 사용자 ID: {}, 최소 연령: {}, 최대 연령: {}", userId, minAge, maxAge); @@ -276,9 +253,7 @@ public List getChildrenByAgeRange(Long userId, Integer minAge .collect(Collectors.toList()); } - // 성별 자녀 조회 - @LogExecutionTime public List getChildrenByGender(Long userId, String gender) { log.info("성별 자녀 조회 - 사용자 ID: {}, 성별: {}", userId, gender); @@ -289,9 +264,7 @@ public List getChildrenByGender(Long userId, String gender) { .collect(Collectors.toList()); } - // 특별한 요구사항이 있는 자녀 조회 - @LogExecutionTime public List getChildrenWithSpecialNeeds(Long userId) { log.info("특별한 요구사항이 있는 자녀 조회 - 사용자 ID: {}", userId); @@ -302,9 +275,7 @@ public List getChildrenWithSpecialNeeds(Long userId) { .collect(Collectors.toList()); } - // 이름으로 자녀 검색 - @LogExecutionTime public List searchChildrenByName(Long userId, String name) { log.info("이름으로 자녀 검색 - 사용자 ID: {}, 이름: {}", userId, name); @@ -315,9 +286,7 @@ public List searchChildrenByName(Long userId, String name) { .collect(Collectors.toList()); } - // 건강 상태 분석 - @LogExecutionTime public Map analyzeHealthStatus(HealthCreateHealthRecordRequest request, Long actorUserId) { validateRequest(request); @@ -340,9 +309,7 @@ public Map analyzeHealthStatus(HealthCreateHealthRecordRequest r return analysis; } - // 건강 리포트 생성 - @LogExecutionTime public Map generateHealthReport(HealthCreateHealthRecordRequest request, Long actorUserId) { validateRequest(request); @@ -365,9 +332,7 @@ public Map generateHealthReport(HealthCreateHealthRecordRequest return report; } - // 건강 통계 조회 - @LogExecutionTime public HealthStatsResponse getHealthStatistics(String userId, Long actorUserId) { log.info("건강 통계 조회: 사용자ID={}", userId); @@ -393,9 +358,7 @@ public HealthStatsResponse getHealthStatistics(String userId, Long actorUserId) // ===== 스케줄 및 알림 관리 ===== - // 예방접종 스케줄 조회 - @LogExecutionTime public List getVaccineSchedule(String childId, Long actorUserId) { log.info("예방접종 스케줄 조회: 아동ID={}", childId); @@ -415,9 +378,7 @@ public List getVaccineSchedule(String childId, Long act } } - // 건강 검진 스케줄 조회 - @LogExecutionTime public List getCheckupSchedule(String childId, Long actorUserId) { log.info("건강 검진 스케줄 조회: 아동ID={}", childId); @@ -437,10 +398,7 @@ public List getCheckupSchedule(String childId, Long act } } - - // 기간별 건강 기록 조회 (오래된순) - // JOIN FETCH를 사용하여 N+1 쿼리 문제 해결 - + // 기간별 건강 기록 조회 (오래된순) JOIN FETCH를 사용하여 N+1 쿼리 문제 해결 @LogExecutionTime public List getHealthRecordsByDateRangeAsc(Long childId, LocalDate startDate, LocalDate endDate, Long actorUserId) { validateChildId(childId); @@ -463,10 +421,7 @@ public List getHealthRecordsByDateRangeAsc(Long childId, L } } - - // 특정 타입의 건강 기록 조회 - // JOIN FETCH를 사용하여 N+1 쿼리 문제 해결 - + // 특정 타입의 건강 기록 조회 JOIN FETCH를 사용하여 N+1 쿼리 문제 해결 @LogExecutionTime public List getHealthRecordsByType(Long childId, HealthRecord.RecordType recordType, Long actorUserId) { validateChildId(childId); @@ -524,9 +479,7 @@ public void deleteAttachment(Long attachmentId, Long actorUserId) { healthRecordAttachmentRepository.save(attachment); } - // 건강 알림 조회 - @LogExecutionTime public List getHealthAlerts(String userId, Long actorUserId) { log.info("건강 알림 조회: 사용자ID={}", userId); @@ -574,9 +527,7 @@ public Map getIntegratedRecommendations(String userId, Long acto return recommendations; } - // 건강 목표 조회 - @LogExecutionTime public Map getHealthGoals(String userId, Long actorUserId) { validateUserId(userId); @@ -597,9 +548,7 @@ public Map getHealthGoals(String userId, Long actorUserId) { // ===== 차트 및 시각화 ===== - // 건강 차트 데이터 조회 - @LogExecutionTime public List> getHealthChart(String userId, String type, LocalDate from, LocalDate to, Long actorUserId) { validateUserId(userId); @@ -628,9 +577,7 @@ public List> getHealthChart(String userId, String type, Loca // ===== 시스템 관리 ===== - // 시스템 상태 확인 - public Map checkSystemHealth() { log.info("시스템 상태 확인"); @@ -680,16 +627,11 @@ private void assertUserIdBelongsToActor(String userId, Long actorUserId) { // ===== Helper Methods ===== - - - // Child Entity를 DTO로 변환 // Child 매핑은 ChildMapper 사용 - // 예방접종 스케줄 응답 DTO 변환 - private VaccineScheduleResponse convertToVaccineScheduleResponse(HealthRecord record) { return VaccineScheduleResponse.builder() .vaccineName(record.getTitle()) @@ -702,9 +644,7 @@ private VaccineScheduleResponse convertToVaccineScheduleResponse(HealthRecord re .build(); } - // 건강 검진 스케줄 응답 DTO 변환 - private CheckupScheduleResponse convertToCheckupScheduleResponse(HealthRecord record) { return CheckupScheduleResponse.builder() .checkupName(record.getTitle()) @@ -717,9 +657,7 @@ private CheckupScheduleResponse convertToCheckupScheduleResponse(HealthRecord re .build(); } - // 건강 알림 응답 DTO 변환 - private HealthAlertResponse convertToHealthAlertResponse(HealthRecord record) { return HealthAlertResponse.builder() .alertId(record.getId().toString()) @@ -733,7 +671,6 @@ private HealthAlertResponse convertToHealthAlertResponse(HealthRecord record) { } // ===== 계산 및 분석 Helper Methods ===== - private int calculateHealthScore(List records) { if (records.isEmpty()) return 0; @@ -872,16 +809,13 @@ private Map calculateProgress(List records) { return progress; } - // HealthRecord 엔티티를 HealthRecordResponse DTO로 변환 // HealthRecord 매핑은 HealthRecordMapper 사용 // ===== Validation Helper Methods ===== - // User ID 또는 Long ID로 사용자 조회 (중복 로직 제거) - private User findUserByIdOrUserId(String userId) { validateUserId(userId); @@ -900,10 +834,8 @@ private User findUserByIdOrUserId(String userId) { throw new UserNotFoundException("사용자를 찾을 수 없습니다: " + userId); } } - // 요청 객체 검증 - private void validateRequest(HealthCreateHealthRecordRequest request) { if (request == null) { throw new BusinessException(ErrorCode.INVALID_INPUT, "요청 정보가 없습니다."); @@ -912,48 +844,38 @@ private void validateRequest(HealthCreateHealthRecordRequest request) { throw new BusinessException(ErrorCode.INVALID_INPUT, "아동 ID가 필요합니다."); } } - // 업데이트 요청 객체 검증 - private void validateUpdateRequest(HealthUpdateHealthRecordRequest request) { if (request == null) { throw new BusinessException(ErrorCode.INVALID_INPUT, "요청 정보가 없습니다."); } } - // 기록 ID 검증 - private void validateRecordId(Long recordId) { if (recordId == null || recordId <= 0) { throw new BusinessException(ErrorCode.INVALID_RECORD_ID, ErrorCode.INVALID_RECORD_ID.getMessage() + ": " + recordId); } } - // 아동 ID 검증 - private void validateChildId(Long childId) { if (childId == null || childId <= 0) { throw new BusinessException(ErrorCode.INVALID_CHILD_ID, ErrorCode.INVALID_CHILD_ID.getMessage() + ": " + childId); } } - // 사용자 ID 검증 - private void validateUserId(String userId) { if (!StringUtils.hasText(userId)) { throw new BusinessException(ErrorCode.INVALID_INPUT, "사용자 ID가 필요합니다."); } } - // 페이징 파라미터 검증 - private void validatePaginationParams(int page, int size) { if (page < 0) { throw new BusinessException(ErrorCode.INVALID_INPUT, @@ -964,7 +886,6 @@ private void validatePaginationParams(int page, int size) { "페이지 크기는 1 이상 100 이하여야 합니다: " + size); } } - // 날짜 범위 검증 private void validateDateRange(LocalDate startDate, LocalDate endDate) { @@ -972,20 +893,16 @@ private void validateDateRange(LocalDate startDate, LocalDate endDate) { throw new BusinessException(ErrorCode.INVALID_DATE_RANGE.getMessage()); } } - // 개월 수 검증 - private void validateMonths(int months) { if (months <= 0 || months > 120) { throw new BusinessException(ErrorCode.INVALID_MONTHS, ErrorCode.INVALID_MONTHS.getMessage() + ": " + months); } } - // 차트 타입 검증 - private void validateChartType(String type) { if (!StringUtils.hasText(type)) { throw new BusinessException(ErrorCode.INVALID_INPUT, "차트 타입이 필요합니다."); @@ -997,10 +914,8 @@ private void validateChartType(String type) { ErrorCode.INVALID_CHART_TYPE.getMessage() + ": " + type); } } - // 차트 값 추출 - private Object extractChartValue(HealthRecord record, String type) { if (record == null || type == null) { return null; diff --git a/src/main/java/com/carecode/domain/health/service/VaccinationScheduleService.java b/src/main/java/com/carecode/domain/health/service/VaccinationScheduleService.java index 806f44df..da8f2d91 100644 --- a/src/main/java/com/carecode/domain/health/service/VaccinationScheduleService.java +++ b/src/main/java/com/carecode/domain/health/service/VaccinationScheduleService.java @@ -16,9 +16,7 @@ import java.util.ArrayList; import java.util.List; -/** - * 아이별 예방접종 일정 생성·조회. - */ +/** 아이별 예방접종 일정 생성·조회. */ @Slf4j @Service @RequiredArgsConstructor @@ -28,12 +26,7 @@ public class VaccinationScheduleService { private final VaccinationScheduleRepository scheduleRepository; private final ChildRepository childRepository; - /** - * 아이의 생년월일을 기준으로 표준 접종 일정을 생성한다. - * 이미 일정이 있으면 중복 생성하지 않는다. - * - * @return 생성된 일정 수 - */ + /** 아이의 생년월일을 기준으로 표준 접종 일정을 생성한다. 이미 일정이 있으면 중복 생성하지 않는다. */ @Transactional public int generateScheduleForChild(Long childId) { Child child = childRepository.findById(childId) diff --git a/src/main/java/com/carecode/domain/notification/app/NotificationFacade.java b/src/main/java/com/carecode/domain/notification/app/NotificationFacade.java index 468e7754..ef080fad 100644 --- a/src/main/java/com/carecode/domain/notification/app/NotificationFacade.java +++ b/src/main/java/com/carecode/domain/notification/app/NotificationFacade.java @@ -108,9 +108,7 @@ public void registerPushToken(String userId, NotificationRegisterPushTokenReques preferenceService.registerPushToken(userId, request); } - // 알림 설정 수정 - @Transactional public void updateSettings(String userId, NotificationUpdateSettingsRequest request) { preferenceService.updateSettings(userId, request); @@ -144,37 +142,28 @@ public long getNotificationCountByReadStatus(String userId, boolean isRead) { return notificationService.getNotificationCountByReadStatus(user.getId(), isRead); } - // 테스트 알림 발송 - @Transactional public void sendTestNotification(String userId, NotificationSendTestRequest request) { notificationService.sendTestNotification(userId, request); } - // 알림 통계 조회 - @Transactional(readOnly = true) public NotificationStatsResponse getNotificationStats(String userId) { return notificationService.getNotificationStats(userId); } - // 알림 템플릿 조회 - @Transactional(readOnly = true) public List getNotificationTemplates(String type) { return notificationService.getNotificationTemplates(type); } - // 알림 전송 상태 조회 - @Transactional(readOnly = true) public NotificationDeliveryStatusResponse getDeliveryStatus(Long notificationId, String actorUserId) { return notificationService.getDeliveryStatus(notificationId, actorUserId); } } - diff --git a/src/main/java/com/carecode/domain/notification/controller/NotificationController.java b/src/main/java/com/carecode/domain/notification/controller/NotificationController.java index 75fd1db3..d9abdb82 100644 --- a/src/main/java/com/carecode/domain/notification/controller/NotificationController.java +++ b/src/main/java/com/carecode/domain/notification/controller/NotificationController.java @@ -31,10 +31,7 @@ import java.util.Date; import java.util.Map; -/** - * 알림 API 컨트롤러 - * 육아 관련 알림 관리 서비스 - */ +/** 알림 API 컨트롤러 육아 관련 알림 관리 서비스 */ @RestController @RequestMapping("/notifications") @RequiredArgsConstructor @@ -46,24 +43,20 @@ public class NotificationController extends BaseController { private final NotificationFacade notificationFacade; private final CurrentUserFacade currentUserFacade; - // 알림 목록 조회 - @GetMapping @LogExecutionTime - @Operation(summary = "알림 목록 조회", description = "사용자의 알림 목록을 조회합니다.") + @Operation(summary = "알림 목록 조회") public ResponseEntity> getAllNotifications() { String userId = getAuthenticatedUserCode(); List notifications = notificationFacade.getNotificationsByUserId(userId); return ResponseEntity.ok(notifications); } - // 알림 상세 조회 - @GetMapping("/{notificationId}") @LogExecutionTime - @Operation(summary = "알림 상세 조회", description = "특정 알림의 상세 정보를 조회합니다.") + @Operation(summary = "알림 상세 조회", description = "특정 알림의 상세 정보 조회") public ResponseEntity getNotification(@Parameter(description = "알림 ID", required = true) @PathVariable Long notificationId) { NotificationInfoResponse notification = notificationFacade.getNotificationById(notificationId, getAuthenticatedUserCode()); @@ -71,12 +64,10 @@ public ResponseEntity getNotification(@Parameter(descr return ResponseEntity.ok(notification); } - // 알림 생성 - @PostMapping @LogExecutionTime - @Operation(summary = "알림 생성", description = "새로운 알림을 생성합니다.") + @Operation(summary = "알림 생성", description = "새로운 알림 생성") public ResponseEntity createNotification(@Parameter(description = "알림 정보", required = true) @RequestBody NotificationCreateRequest request) { NotificationInfoResponse notification = notificationFacade.createNotification(request, getAuthenticatedUserCode()); @@ -84,12 +75,10 @@ public ResponseEntity createNotification(@Parameter(de return ResponseEntity.ok(notification); } - // 알림 수정 - @PutMapping("/{notificationId}") @LogExecutionTime - @Operation(summary = "알림 수정", description = "기존 알림을 수정합니다.") + @Operation(summary = "알림 수정") public ResponseEntity updateNotification(@Parameter(description = "알림 ID", required = true) @PathVariable Long notificationId, @Parameter(description = "수정할 알림 정보", required = true) @RequestBody NotificationCreateRequest request) { @@ -98,12 +87,10 @@ public ResponseEntity updateNotification(@Parameter(de return ResponseEntity.ok(notification); } - // 알림 삭제 - @DeleteMapping("/{notificationId}") @LogExecutionTime - @Operation(summary = "알림 삭제", description = "알림을 삭제합니다.") + @Operation(summary = "알림 삭제") public ResponseEntity deleteNotification(@Parameter(description = "알림 ID", required = true) @PathVariable Long notificationId) { notificationFacade.deleteNotification(notificationId, getAuthenticatedUserCode()); @@ -111,12 +98,10 @@ public ResponseEntity deleteNotification(@Parameter(description = " return ResponseEntity.ok(ApiSuccess.builder().timestamp(new Date()).message("알림이 삭제되었습니다.").build()); } - // 알림 읽음 처리 - @PutMapping("/{notificationId}/read") @LogExecutionTime - @Operation(summary = "알림 읽음 처리", description = "알림을 읽음 상태로 변경합니다.") + @Operation(summary = "알림 읽음 처리", description = "알림을 읽음 상태로 변경") public ResponseEntity markAsRead(@Parameter(description = "알림 ID", required = true) @PathVariable Long notificationId) { notificationFacade.markAsRead(notificationId, getAuthenticatedUserCode()); @@ -124,12 +109,10 @@ public ResponseEntity markAsRead(@Parameter(description = "알림 ID return ResponseEntity.ok(ApiSuccess.builder().timestamp(new Date()).message("알림이 읽음 처리되었습니다.").build()); } - // 모든 알림 읽음 처리 - @PutMapping("/read-all") @LogExecutionTime - @Operation(summary = "모든 알림 읽음 처리", description = "사용자의 모든 알림을 읽음 상태로 변경합니다.") + @Operation(summary = "모든 알림 읽음 처리", description = "사용자의 모든 알림을 읽음 상태로 변경") public ResponseEntity markAllAsRead() { String userId = getAuthenticatedUserCode(); @@ -138,12 +121,10 @@ public ResponseEntity markAllAsRead() { return ResponseEntity.ok(ApiSuccess.builder().timestamp(new Date()).message("모든 알림이 읽음 처리되었습니다.").build()); } - // 읽지 않은 알림 조회 - @GetMapping("/unread") @LogExecutionTime - @Operation(summary = "읽지 않은 알림 조회", description = "사용자의 읽지 않은 알림 목록을 조회합니다.") + @Operation(summary = "읽지 않은 알림 조회", description = "사용자의 읽지 않은 알림 목록 조회") public ResponseEntity> getUnreadNotifications() { String userId = getAuthenticatedUserCode(); @@ -152,12 +133,10 @@ public ResponseEntity> getUnreadNotifications() { return ResponseEntity.ok(notifications); } - // 알림 설정 조회 - @GetMapping("/settings/{userId}") @LogExecutionTime - @Operation(summary = "알림 설정 조회", description = "사용자의 알림 설정을 조회합니다.") + @Operation(summary = "알림 설정 조회") public ResponseEntity> getNotificationSettings(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId) { requireNotificationUserIdMatchesCurrent(userId); Map settings = notificationFacade.getNotificationSettings(getAuthenticatedUserCode()); @@ -165,12 +144,10 @@ public ResponseEntity> getNotificationSettings(@Parameter(de return ResponseEntity.ok(settings); } - // 알림 설정 업데이트 - @PutMapping("/settings/{userId}") @LogExecutionTime - @Operation(summary = "알림 설정 업데이트", description = "사용자의 알림 설정을 업데이트합니다.") + @Operation(summary = "알림 설정 업데이트", description = "사용자의 알림 설정을 업데이트") public ResponseEntity> updateNotificationSettings(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId, @Parameter(description = "알림 설정", required = true) @RequestBody Map settings) { requireNotificationUserIdMatchesCurrent(userId); @@ -179,12 +156,10 @@ public ResponseEntity> updateNotificationSettings(@Parameter return ResponseEntity.ok(updatedSettings); } - // 알림 통계 조회 - @GetMapping("/statistics/{userId}") @LogExecutionTime - @Operation(summary = "알림 통계 조회", description = "사용자의 알림 관련 통계를 조회합니다.") + @Operation(summary = "알림 통계 조회", description = "사용자의 알림 관련 통계 조회") public ResponseEntity> getNotificationStatistics(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId) { requireNotificationUserIdMatchesCurrent(userId); Map statistics = notificationFacade.getNotificationStatistics(getAuthenticatedUserCode()); @@ -192,12 +167,10 @@ public ResponseEntity> getNotificationStatistics(@Parameter( return ResponseEntity.ok(statistics); } - // 알림 설정 목록 조회 - @GetMapping("/preferences") @LogExecutionTime - @Operation(summary = "알림 설정 목록 조회", description = "사용자의 알림 설정 목록을 조회합니다.") + @Operation(summary = "알림 설정 목록 조회") public ResponseEntity> getNotificationPreferences() { String userId = getAuthenticatedUserCode(); @@ -206,12 +179,10 @@ public ResponseEntity> getNotificationPrefere return ResponseEntity.ok(preferences); } - // 특정 알림 타입 설정 조회 - @GetMapping("/preferences/{notificationType}") @LogExecutionTime - @Operation(summary = "특정 알림 타입 설정 조회", description = "사용자의 특정 알림 타입 설정을 조회합니다.") + @Operation(summary = "특정 알림 타입 설정 조회") public ResponseEntity getNotificationPreferenceByType(@Parameter(description = "사용자 ID", required = true) @RequestParam String userId, @Parameter(description = "알림 타입", required = true) @PathVariable String notificationType) { requireNotificationUserIdMatchesCurrent(userId); @@ -220,12 +191,10 @@ public ResponseEntity getNotificationPreferenceByT return ResponseEntity.ok(preference); } - // 전체 알림 설정 업데이트 - @PutMapping("/preferences") @LogExecutionTime - @Operation(summary = "전체 알림 설정 업데이트", description = "사용자의 모든 알림 설정을 한 번에 업데이트합니다.") + @Operation(summary = "전체 알림 설정 업데이트", description = "사용자의 모든 알림 설정을 한 번에 업데이트") public ResponseEntity updateNotificationPreferences(@Parameter(description = "사용자 ID", required = true) @RequestParam String userId, @Parameter(description = "알림 설정", required = true) @RequestBody NotificationSettingsResponse preferenceDto) { requireNotificationUserIdMatchesCurrent(userId); @@ -234,12 +203,10 @@ public ResponseEntity updateNotificationPreference return ResponseEntity.ok(updatedPreference); } - // 알림 설정 저장 - @PostMapping("/preferences") @LogExecutionTime - @Operation(summary = "알림 설정 저장", description = "사용자의 알림 설정을 저장합니다.") + @Operation(summary = "알림 설정 저장", description = "사용자의 알림 설정을 저장") public ResponseEntity saveNotificationPreference(@Parameter(description = "사용자 ID", required = true) @RequestParam String userId, @Parameter(description = "알림 설정", required = true) @RequestBody NotificationSettingsResponse preferenceDto) { requireNotificationUserIdMatchesCurrent(userId); @@ -248,12 +215,10 @@ public ResponseEntity saveNotificationPreference(@ return ResponseEntity.ok(savedPreference); } - // 채널별 설정 업데이트 - @PutMapping("/preferences/{notificationType}/channels/{channel}") @LogExecutionTime - @Operation(summary = "채널별 설정 업데이트", description = "사용자의 특정 알림 타입의 채널별 설정을 업데이트합니다.") + @Operation(summary = "채널별 설정 업데이트", description = "사용자의 특정 알림 타입의 채널별 설정을 업데이트") public ResponseEntity updateChannelPreference(@Parameter(description = "사용자 ID", required = true) @RequestParam String userId, @Parameter(description = "알림 타입", required = true) @PathVariable String notificationType, @Parameter(description = "채널", required = true) @PathVariable String channel, @@ -264,12 +229,10 @@ public ResponseEntity updateChannelPreference(@Par return ResponseEntity.ok(updatedPreference); } - // 모든 알림 설정 비활성화 - @PutMapping("/preferences/disable-all") @LogExecutionTime - @Operation(summary = "모든 알림 설정 비활성화", description = "사용자의 모든 알림 설정을 비활성화합니다.") + @Operation(summary = "모든 알림 설정 비활성화") public ResponseEntity disableAllNotifications(@Parameter(description = "사용자 ID", required = true) @RequestParam String userId) { requireNotificationUserIdMatchesCurrent(userId); notificationFacade.disableAllNotifications(getAuthenticatedUserCode()); @@ -277,12 +240,10 @@ public ResponseEntity disableAllNotifications(@Parameter(description return ResponseEntity.ok(ApiSuccess.builder().timestamp(new Date()).message("모든 알림 설정이 비활성화되었습니다.").build()); } - // 알림 설정 기본값으로 초기화 - @PutMapping("/preferences/reset") @LogExecutionTime - @Operation(summary = "알림 설정 초기화", description = "사용자의 알림 설정을 기본값으로 초기화합니다.") + @Operation(summary = "알림 설정 초기화", description = "사용자의 알림 설정을 기본값으로 초기화") public ResponseEntity resetNotificationPreferences(@Parameter(description = "사용자 ID", required = true) @RequestParam String userId) { requireNotificationUserIdMatchesCurrent(userId); notificationFacade.resetToDefault(getAuthenticatedUserCode()); @@ -290,12 +251,10 @@ public ResponseEntity resetNotificationPreferences(@Parameter(descri return ResponseEntity.ok(ApiSuccess.builder().timestamp(new Date()).message("알림 설정이 기본값으로 초기화되었습니다.").build()); } - // 알림 읽음 처리 - @PutMapping("/mark-read") @LogExecutionTime - @Operation(summary = "알림 읽음 처리", description = "알림을 읽음으로 표시합니다.") + @Operation(summary = "알림 읽음 처리", description = "알림을 읽음으로 표시") public ResponseEntity markAsRead(@Parameter(description = "읽음 처리 요청", required = true) @RequestBody NotificationMarkAsReadRequest request) { notificationFacade.markAsRead(request, getAuthenticatedUserCode()); @@ -303,12 +262,10 @@ public ResponseEntity markAsRead(@Parameter(description = "읽음 return ResponseEntity.ok(ApiSuccess.builder().timestamp(new Date()).message("알림이 읽음으로 처리되었습니다.").build()); } - // 푸시 알림 토큰 등록 - @PostMapping("/push-token") @LogExecutionTime - @Operation(summary = "푸시 알림 토큰 등록", description = "사용자의 푸시 알림 토큰을 등록합니다.") + @Operation(summary = "푸시 알림 토큰 등록") public ResponseEntity registerPushToken(@Parameter(description = "사용자 ID", required = true) @RequestParam String userId, @Parameter(description = "푸시 토큰 등록 요청", required = true) @RequestBody NotificationRegisterPushTokenRequest request) { requireNotificationUserIdMatchesCurrent(userId); @@ -317,12 +274,10 @@ public ResponseEntity registerPushToken(@Parameter(description = " return ResponseEntity.ok(ApiSuccess.builder().timestamp(new Date()).message("푸시 알림 토큰이 등록되었습니다.").build()); } - // 알림 설정 수정 - @PutMapping("/settings") @LogExecutionTime - @Operation(summary = "알림 설정 수정", description = "사용자의 알림 설정을 수정합니다.") + @Operation(summary = "알림 설정 수정") public ResponseEntity updateSettings(@Parameter(description = "사용자 ID", required = true) @RequestParam String userId, @Parameter(description = "설정 수정 요청", required = true) @RequestBody NotificationUpdateSettingsRequest request) { requireNotificationUserIdMatchesCurrent(userId); @@ -331,12 +286,10 @@ public ResponseEntity updateSettings(@Parameter(description = "사 return ResponseEntity.ok(ApiSuccess.builder().timestamp(new Date()).message("알림 설정이 수정되었습니다.").build()); } - // 테스트 알림 발송 - @PostMapping("/test") @LogExecutionTime - @Operation(summary = "테스트 알림 발송", description = "테스트 알림을 발송합니다.") + @Operation(summary = "테스트 알림 발송") public ResponseEntity sendTestNotification(@Parameter(description = "사용자 ID", required = true) @RequestParam String userId, @Parameter(description = "테스트 알림 요청", required = true) @RequestBody NotificationSendTestRequest request) { requireNotificationUserIdMatchesCurrent(userId); @@ -345,12 +298,10 @@ public ResponseEntity sendTestNotification(@Parameter(description = return ResponseEntity.ok(ApiSuccess.builder().timestamp(new Date()).message("테스트 알림이 발송되었습니다.").build()); } - // 알림 통계 조회 - @GetMapping("/stats") @LogExecutionTime - @Operation(summary = "알림 통계 조회", description = "사용자의 알림 통계를 조회합니다.") + @Operation(summary = "알림 통계 조회") public ResponseEntity getNotificationStats(@Parameter(description = "사용자 ID", required = true) @RequestParam String userId) { requireNotificationUserIdMatchesCurrent(userId); NotificationStatsResponse stats = notificationFacade.getNotificationStats(getAuthenticatedUserCode()); @@ -358,12 +309,10 @@ public ResponseEntity getNotificationStats(@Parameter return ResponseEntity.ok(stats); } - // 알림 템플릿 조회 - @GetMapping("/templates") @LogExecutionTime - @Operation(summary = "알림 템플릿 조회", description = "알림 템플릿 목록을 조회합니다.") + @Operation(summary = "알림 템플릿 조회") public ResponseEntity> getNotificationTemplates(@Parameter(description = "알림 타입", required = false) @RequestParam(required = false) String type) { List templates = notificationFacade.getNotificationTemplates(type); @@ -371,12 +320,10 @@ public ResponseEntity> getNotificationTemplat return ResponseEntity.ok(templates); } - // 알림 전송 상태 조회 - @GetMapping("/delivery-status/{notificationId}") @LogExecutionTime - @Operation(summary = "알림 전송 상태 조회", description = "특정 알림의 전송 상태를 조회합니다.") + @Operation(summary = "알림 전송 상태 조회") public ResponseEntity getDeliveryStatus(@Parameter(description = "알림 ID", required = true) @PathVariable Long notificationId) { NotificationDeliveryStatusResponse status = notificationFacade.getDeliveryStatus(notificationId, getAuthenticatedUserCode()); @@ -384,14 +331,13 @@ public ResponseEntity getDeliveryStatus(@Par return ResponseEntity.ok(status); } - // ==================== 알림 필터링 기능 ==================== - + // ==================== + // 알림 필터링 기능 ==================== // 알림 타입별 조회 - @GetMapping("/type") @LogExecutionTime - @Operation(summary = "알림 타입별 조회", description = "특정 타입의 알림을 조회합니다.") + @Operation(summary = "알림 타입별 조회", description = "특정 타입의 알림 조회") public ResponseEntity> getNotificationsByType( @Parameter(description = "사용자 ID", required = true) @RequestParam String userId, @Parameter(description = "알림 타입 (SYSTEM, POLICY, FACILITY, COMMUNITY, CHATBOT, HEALTH)", required = true) @RequestParam String notificationType) { @@ -401,12 +347,10 @@ public ResponseEntity> getNotificationsByType( return ResponseEntity.ok(notifications); } - // 기간별 알림 조회 - @GetMapping("/date-range") @LogExecutionTime - @Operation(summary = "기간별 알림 조회", description = "특정 기간의 알림을 조회합니다.") + @Operation(summary = "기간별 알림 조회", description = "특정 기간의 알림 조회") public ResponseEntity> getNotificationsByDateRange( @Parameter(description = "사용자 ID", required = true) @RequestParam String userId, @Parameter(description = "시작일시 (yyyy-MM-ddTHH:mm:ss)", required = true) @RequestParam String startDate, @@ -417,12 +361,10 @@ public ResponseEntity> getNotificationsByDateRang return ResponseEntity.ok(notifications); } - // 사용자별 전체 알림 개수 조회 - @GetMapping("/count") @LogExecutionTime - @Operation(summary = "전체 알림 개수 조회", description = "사용자의 전체 알림 개수를 조회합니다.") + @Operation(summary = "전체 알림 개수 조회") public ResponseEntity> getTotalNotificationCount( @Parameter(description = "사용자 ID", required = true) @RequestParam String userId) { requireNotificationUserIdMatchesCurrent(userId); @@ -432,12 +374,10 @@ public ResponseEntity> getTotalNotificationCount( return ResponseEntity.ok(response); } - // 읽음 상태별 알림 개수 조회 - @GetMapping("/count-by-status") @LogExecutionTime - @Operation(summary = "읽음 상태별 알림 개수 조회", description = "읽음/읽지 않음 상태별 알림 개수를 조회합니다.") + @Operation(summary = "읽음 상태별 알림 개수 조회") public ResponseEntity> getNotificationCountByReadStatus( @Parameter(description = "사용자 ID", required = true) @RequestParam String userId, @Parameter(description = "읽음 여부 (true: 읽음, false: 읽지 않음)", required = true) @RequestParam boolean isRead) { diff --git a/src/main/java/com/carecode/domain/notification/dto/request/NotificationCreateRequest.java b/src/main/java/com/carecode/domain/notification/dto/request/NotificationCreateRequest.java index 3de4ce56..8155a531 100644 --- a/src/main/java/com/carecode/domain/notification/dto/request/NotificationCreateRequest.java +++ b/src/main/java/com/carecode/domain/notification/dto/request/NotificationCreateRequest.java @@ -9,9 +9,7 @@ import java.time.LocalDateTime; -/** - * 알림 생성 요청 - */ +/** 알림 생성 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/notification/dto/request/NotificationMarkAsReadRequest.java b/src/main/java/com/carecode/domain/notification/dto/request/NotificationMarkAsReadRequest.java index 9e543c3e..e4d38f67 100644 --- a/src/main/java/com/carecode/domain/notification/dto/request/NotificationMarkAsReadRequest.java +++ b/src/main/java/com/carecode/domain/notification/dto/request/NotificationMarkAsReadRequest.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 알림 읽음 처리 요청 - */ +/** 알림 읽음 처리 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/notification/dto/request/NotificationRegisterPushTokenRequest.java b/src/main/java/com/carecode/domain/notification/dto/request/NotificationRegisterPushTokenRequest.java index 43773238..a0e55613 100644 --- a/src/main/java/com/carecode/domain/notification/dto/request/NotificationRegisterPushTokenRequest.java +++ b/src/main/java/com/carecode/domain/notification/dto/request/NotificationRegisterPushTokenRequest.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 푸시 토큰 등록 요청 - */ +/** 푸시 토큰 등록 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/notification/dto/request/NotificationSendTestRequest.java b/src/main/java/com/carecode/domain/notification/dto/request/NotificationSendTestRequest.java index 947c66bc..68e427cf 100644 --- a/src/main/java/com/carecode/domain/notification/dto/request/NotificationSendTestRequest.java +++ b/src/main/java/com/carecode/domain/notification/dto/request/NotificationSendTestRequest.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 테스트 알림 발송 요청 - */ +/** 테스트 알림 발송 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/notification/dto/request/NotificationUpdateSettingsRequest.java b/src/main/java/com/carecode/domain/notification/dto/request/NotificationUpdateSettingsRequest.java index 0fd55485..fef0ed0f 100644 --- a/src/main/java/com/carecode/domain/notification/dto/request/NotificationUpdateSettingsRequest.java +++ b/src/main/java/com/carecode/domain/notification/dto/request/NotificationUpdateSettingsRequest.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 알림 설정 수정 요청 - */ +/** 알림 설정 수정 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/notification/dto/response/NotificationDeliveryStatusResponse.java b/src/main/java/com/carecode/domain/notification/dto/response/NotificationDeliveryStatusResponse.java index 71be42ca..5d57aa1b 100644 --- a/src/main/java/com/carecode/domain/notification/dto/response/NotificationDeliveryStatusResponse.java +++ b/src/main/java/com/carecode/domain/notification/dto/response/NotificationDeliveryStatusResponse.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 알림 전송 상태 응답 - */ +/** 알림 전송 상태 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/notification/dto/response/NotificationDetailResponse.java b/src/main/java/com/carecode/domain/notification/dto/response/NotificationDetailResponse.java index 6271b59c..a45996d4 100644 --- a/src/main/java/com/carecode/domain/notification/dto/response/NotificationDetailResponse.java +++ b/src/main/java/com/carecode/domain/notification/dto/response/NotificationDetailResponse.java @@ -7,9 +7,7 @@ import java.time.LocalDateTime; -/** - * 알림 상세 응답 - */ +/** 알림 상세 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/notification/dto/response/NotificationExtendedStatsResponse.java b/src/main/java/com/carecode/domain/notification/dto/response/NotificationExtendedStatsResponse.java index 4a0d3139..88c0b025 100644 --- a/src/main/java/com/carecode/domain/notification/dto/response/NotificationExtendedStatsResponse.java +++ b/src/main/java/com/carecode/domain/notification/dto/response/NotificationExtendedStatsResponse.java @@ -9,9 +9,7 @@ import java.util.List; import java.util.Map; -/** - * 확장된 알림 통계 응답 - */ +/** 확장된 알림 통계 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/notification/dto/response/NotificationExtendedTemplateResponse.java b/src/main/java/com/carecode/domain/notification/dto/response/NotificationExtendedTemplateResponse.java index d41f0a64..8566246a 100644 --- a/src/main/java/com/carecode/domain/notification/dto/response/NotificationExtendedTemplateResponse.java +++ b/src/main/java/com/carecode/domain/notification/dto/response/NotificationExtendedTemplateResponse.java @@ -10,9 +10,7 @@ import java.util.List; import java.util.Map; -/** - * 확장된 알림 템플릿 응답 - */ +/** 확장된 알림 템플릿 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/notification/dto/response/NotificationInfoResponse.java b/src/main/java/com/carecode/domain/notification/dto/response/NotificationInfoResponse.java index 8247741b..f051b062 100644 --- a/src/main/java/com/carecode/domain/notification/dto/response/NotificationInfoResponse.java +++ b/src/main/java/com/carecode/domain/notification/dto/response/NotificationInfoResponse.java @@ -8,9 +8,7 @@ import java.time.LocalDateTime; -/** - * 알림 정보 응답 - */ +/** 알림 정보 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/notification/dto/response/NotificationListResponse.java b/src/main/java/com/carecode/domain/notification/dto/response/NotificationListResponse.java index 751c8655..4a9f080b 100644 --- a/src/main/java/com/carecode/domain/notification/dto/response/NotificationListResponse.java +++ b/src/main/java/com/carecode/domain/notification/dto/response/NotificationListResponse.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 알림 목록 응답 - */ +/** 알림 목록 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/notification/dto/response/NotificationSearchResponse.java b/src/main/java/com/carecode/domain/notification/dto/response/NotificationSearchResponse.java index cf4ada35..fb990e9e 100644 --- a/src/main/java/com/carecode/domain/notification/dto/response/NotificationSearchResponse.java +++ b/src/main/java/com/carecode/domain/notification/dto/response/NotificationSearchResponse.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 알림 검색 응답 - */ +/** 알림 검색 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/notification/dto/response/NotificationSettingsResponse.java b/src/main/java/com/carecode/domain/notification/dto/response/NotificationSettingsResponse.java index 17c10fa2..e61426a9 100644 --- a/src/main/java/com/carecode/domain/notification/dto/response/NotificationSettingsResponse.java +++ b/src/main/java/com/carecode/domain/notification/dto/response/NotificationSettingsResponse.java @@ -8,9 +8,7 @@ import java.time.LocalDateTime; -/** - * 알림 설정 응답 - */ +/** 알림 설정 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/notification/dto/response/NotificationStatsResponse.java b/src/main/java/com/carecode/domain/notification/dto/response/NotificationStatsResponse.java index 2fffc3df..a5d5151a 100644 --- a/src/main/java/com/carecode/domain/notification/dto/response/NotificationStatsResponse.java +++ b/src/main/java/com/carecode/domain/notification/dto/response/NotificationStatsResponse.java @@ -8,9 +8,7 @@ import java.util.Map; -/** - * 알림 통계 응답 - */ +/** 알림 통계 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/notification/dto/response/NotificationSummaryResponse.java b/src/main/java/com/carecode/domain/notification/dto/response/NotificationSummaryResponse.java index d574e246..e1459b63 100644 --- a/src/main/java/com/carecode/domain/notification/dto/response/NotificationSummaryResponse.java +++ b/src/main/java/com/carecode/domain/notification/dto/response/NotificationSummaryResponse.java @@ -9,9 +9,7 @@ import java.util.List; import java.util.Map; -/** - * 알림 요약 응답 - */ +/** 알림 요약 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/notification/dto/response/NotificationTemplateResponse.java b/src/main/java/com/carecode/domain/notification/dto/response/NotificationTemplateResponse.java index 62396bf6..f52efbf8 100644 --- a/src/main/java/com/carecode/domain/notification/dto/response/NotificationTemplateResponse.java +++ b/src/main/java/com/carecode/domain/notification/dto/response/NotificationTemplateResponse.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 알림 템플릿 응답 - */ +/** 알림 템플릿 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/notification/dto/response/NotificationUnreadCountResponse.java b/src/main/java/com/carecode/domain/notification/dto/response/NotificationUnreadCountResponse.java index a0db76db..7e4a2060 100644 --- a/src/main/java/com/carecode/domain/notification/dto/response/NotificationUnreadCountResponse.java +++ b/src/main/java/com/carecode/domain/notification/dto/response/NotificationUnreadCountResponse.java @@ -9,9 +9,7 @@ import java.time.LocalDateTime; import java.util.Map; -/** - * 읽지 않은 알림 수 응답 - */ +/** 읽지 않은 알림 수 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/notification/entity/Notification.java b/src/main/java/com/carecode/domain/notification/entity/Notification.java index 7033b822..887f4185 100644 --- a/src/main/java/com/carecode/domain/notification/entity/Notification.java +++ b/src/main/java/com/carecode/domain/notification/entity/Notification.java @@ -10,12 +10,7 @@ import java.time.LocalDateTime; -/** - * 알림 엔티티 - * - *

사용자에게 전송되는 알림을 관리합니다. - * 단순한 구조로 필수 기능만 포함합니다.

- */ +/** 알림 엔티티 사용자에게 전송되는 알림을 관리합니다. 단순한 구조로 필수 기능만 포함합니다. */ @Entity @Table(name = "TBL_NOTIFICATION") @Getter @@ -55,17 +50,13 @@ public class Notification { protected void onCreate() { createdAt = LocalDateTime.now(); } - // 알림 읽음 처리 - public void markAsRead() { this.isRead = true; } - // 알림 타입 Enum - public enum NotificationType { POLICY("정책"), HEALTH("건강"), diff --git a/src/main/java/com/carecode/domain/notification/entity/NotificationChannel.java b/src/main/java/com/carecode/domain/notification/entity/NotificationChannel.java index 5793fbf6..f21fe96a 100644 --- a/src/main/java/com/carecode/domain/notification/entity/NotificationChannel.java +++ b/src/main/java/com/carecode/domain/notification/entity/NotificationChannel.java @@ -9,18 +9,7 @@ import java.time.LocalDateTime; -/** - * 알림 채널 엔티티 - * - * 알림을 전송할 수 있는 다양한 채널 정보를 관리. - * - * 주요 기능: - * - 알림 채널 정보 관리 (이름, 설명) - * - 채널별 알림 전송 통계 및 모니터링 - * - * @author CareCode Team - * @since 1.0.0 - */ +/** 알림 채널 엔티티 */ @Entity @Table(name = "TBL_NOTIFICATION_CHANNEL") @Getter @@ -33,68 +22,41 @@ public class NotificationChannel { @Column(name = "ID") private Long id; - // 알림 채널 이름 - @Column(name = "NAME", nullable = false) private String name; - // 알림 채널에 대한 설명 - @Column(name = "DESCRIPTION") private String description; - // 알림 채널 생성 시간 - @Column(name = "CREATED_AT", nullable = false) private LocalDateTime createdAt; - // 알림 채널 정보 수정 시간 - @Column(name = "UPDATED_AT") private LocalDateTime updatedAt; - - // 알림 채널 생성자 - // - // @param name 채널 이름 (예: "EMAIL", "PUSH", "SMS") - // @param description 채널 설명 (예: "이메일을 통한 알림 전송") - + // 알림 채널 생성자 @param name 채널 이름 (예: "EMAIL", "PUSH". @Builder public NotificationChannel(String name, String description) { this.name = name; this.description = description; } - - // 알림 채널 정보 업데이트 - // - // @param name 새로운 채널 이름 - // @param description 새로운 채널 설명 - + // 알림 채널 정보 업데이트 @param name 새로운 채널 이름 @param description 새로운 채널 설명 public void updateChannel(String name, String description) { this.name = name; this.description = description; } - - // 채널이 활성 상태인지 확인 - // (현재는 항상 true, 향후 isActive 필드 추가 시 활용) - // - // @return 활성 상태 여부 - + // 채널이 활성 상태인지 확인 (현재는 항상 true, 향후 isActive 필드 추가 시 활용) @return 활성 상태 여부 public boolean isActive() { return true; // 향후 isActive 필드 추가 시 수정 } - - // 채널 타입별 표시명 반환 - // - // @return 채널 타입별 한글 표시명 - + // 채널 타입별 표시명 반환 @return 채널 타입별 한글 표시명 public String getDisplayName() { switch (name.toUpperCase()) { case "EMAIL": diff --git a/src/main/java/com/carecode/domain/notification/entity/NotificationPreference.java b/src/main/java/com/carecode/domain/notification/entity/NotificationPreference.java index 67ae0a5b..7ac43940 100644 --- a/src/main/java/com/carecode/domain/notification/entity/NotificationPreference.java +++ b/src/main/java/com/carecode/domain/notification/entity/NotificationPreference.java @@ -9,10 +9,7 @@ import java.time.LocalDateTime; -/** - * 알림 설정 엔티티 - * 사용자별 알림 수신 설정을 관리 - */ +/** 알림 설정 엔티티 사용자별 알림 수신 설정을 관리 */ @Entity @Table(name = "notification_preferences") @Getter @@ -68,16 +65,12 @@ public class NotificationPreference { @Column(nullable = false) private LocalDateTime updatedAt; - // 모든 채널이 비활성화되어 있는지 확인 - public boolean isAllChannelsDisabled() { return !emailEnabled && !pushEnabled && !smsEnabled && !inAppEnabled; } - // 특정 채널이 활성화되어 있는지 확인 - public boolean isChannelEnabled(String channel) { return switch (channel.toLowerCase()) { case "email" -> emailEnabled; diff --git a/src/main/java/com/carecode/domain/notification/entity/NotificationTemplate.java b/src/main/java/com/carecode/domain/notification/entity/NotificationTemplate.java index fa9585fa..f8a1b2c3 100644 --- a/src/main/java/com/carecode/domain/notification/entity/NotificationTemplate.java +++ b/src/main/java/com/carecode/domain/notification/entity/NotificationTemplate.java @@ -11,20 +11,7 @@ import java.time.LocalDateTime; -/** - * 알림 템플릿 엔티티 - * - * 알림 발송 시 사용할 수 있는 미리 정의된 템플릿을 관리. - * - * 주요 기능: - * - 알림 템플릿 정보 관리 (제목, 내용, 타입, 설명) - * - 템플릿 활성화/비활성화 상태 관리 - * - 템플릿별 알림 발송 통계 및 모니터링 - * - * @author CareCode Team - * @since 1.0.0 - * @version 1.0.0 - */ +/** 알림 템플릿 엔티티 */ @Entity @Table(name = "TBL_NOTIFICATION_TEMPLATES") @Getter @@ -32,98 +19,47 @@ @EntityListeners(AuditingEntityListener.class) public class NotificationTemplate { - - // 알림 템플릿 고유 식별자 (Primary Key) - // @since 1.0.0 - + // 알림 템플릿 고유 식별자 (Primary Key) @since 1.0.0 @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "ID") private Long id; - - // 템플릿 코드 - // @since 1.0.0 - + // 템플릿 코드 @since 1.0.0 @Column(name = "TEMPLATE_CODE", nullable = false, unique = true, length = 50) private String templateCode; - - // 알림 제목 템플릿 - // @since 1.0.0 - + // 알림 제목 템플릿 @since 1.0.0 @Column(name = "TITLE", nullable = false, length = 200) private String title; - - // 알림 내용 템플릿 - // @since 1.0.0 - + // 알림 내용 템플릿 @since 1.0.0 @Column(name = "CONTENT", columnDefinition = "TEXT", nullable = false) private String content; - - // 템플릿 타입 - // @since 1.0.0 - + // 템플릿 타입 @since 1.0.0 @Column(name = "TEMPLATE_TYPE", length = 50) private String templateType; - - // 템플릿에 대한 설명 - // - // 예시: "신규 사용자 가입 시 발송되는 환영 이메일" - // - // @since 1.0.0 - + // 템플릿에 대한 설명 예시: "신규 사용자 가입 시 발송되는 환영 이메일" @since 1.0.0 @Column(name = "DESCRIPTION", length = 500) private String description; - - // 템플릿 활성화 상태 - // - // true: 활성화 (사용 가능) - // false: 비활성화 (사용 불가) - // 기본값: true - // - // @since 1.0.0 - + // 템플릿 활성화 상태 true: 활성화 (사용 가능) false: 비활성화 (사용 불가) 기본값: true @since 1.0.0 @Column(name = "IS_ACTIVE", nullable = false) private Boolean isActive = true; - - // 템플릿 생성 시간 - // - // JPA Auditing을 통해 자동 설정됨 - // - // @since 1.0.0 - + // 템플릿 생성 시간 JPA Auditing을 통해 자동 설정됨 @since 1.0.0 @CreatedDate @Column(name = "CREATED_AT", nullable = false, updatable = false) private LocalDateTime createdAt; - - // 템플릿 정보 수정 시간 - // - // JPA Auditing을 통해 자동 업데이트됨 - // - // @since 1.0.0 - + // 템플릿 정보 수정 시간 JPA Auditing을 통해 자동 업데이트됨 @since 1.0.0 @LastModifiedDate @Column(name = "UPDATED_AT") private LocalDateTime updatedAt; - - // 알림 템플릿 생성자 - // - // @param templateCode 템플릿 코드 (예: "WELCOME_EMAIL") - // @param title 알림 제목 (예: "환영합니다!") - // @param content 알림 내용 (예: "안녕하세요! CareCode에 오신 것을 환영합니다.") - // @param templateType 템플릿 타입 (예: "EMAIL") - // @param description 템플릿 설명 (예: "신규 사용자 가입 시 발송되는 환영 이메일") - // - // @since 1.0.0 - + // 알림 템플릿 생성자 @param templateCode 템플릿 코드 (예: "WELCOME_EMAIL") @param title 알림 제목 (예: "환영합니다!") @Builder public NotificationTemplate(String templateCode, String title, String content, String templateType, String description) { @@ -134,16 +70,7 @@ public NotificationTemplate(String templateCode, String title, String content, this.description = description; } - - // 템플릿 정보 업데이트 - // - // @param title 새로운 알림 제목 - // @param content 새로운 알림 내용 - // @param templateType 새로운 템플릿 타입 - // @param description 새로운 템플릿 설명 - // - // @since 1.0.0 - + // 템플릿 정보 업데이트 @param title 새로운 알림 제목 @param content 새로운 알림 내용 @param templateType 새로운 템플릿 타입 public void updateTemplate(String title, String content, String templateType, String description) { this.title = title; this.content = content; @@ -151,44 +78,22 @@ public void updateTemplate(String title, String content, String templateType, St this.description = description; } - - // 템플릿 비활성화 - // - // 비활성화된 템플릿은 새로운 알림 발송에 사용할 수 없음 - // - // @since 1.0.0 - + // 템플릿 비활성화 비활성화된 템플릿은 새로운 알림 발송에 사용할 수 없음 @since 1.0.0 public void deactivate() { this.isActive = false; } - - // 템플릿 활성화 - // - // 활성화된 템플릿은 새로운 알림 발송에 사용 가능 - // - // @since 1.0.0 - + // 템플릿 활성화 활성화된 템플릿은 새로운 알림 발송에 사용 가능 @since 1.0.0 public void activate() { this.isActive = true; } - - // 템플릿이 활성 상태인지 확인 - // - // @return 활성 상태 여부 - // @since 1.0.0 - + // 템플릿이 활성 상태인지 확인 @return 활성 상태 여부 @since 1.0.0 public boolean isTemplateActive() { return isActive; } - - // 템플릿 타입별 표시명 반환 - // - // @return 템플릿 타입별 한글 표시명 - // @since 1.0.0 - + // 템플릿 타입별 표시명 반환 @return 템플릿 타입별 한글 표시명 @since 1.0.0 public String getTemplateTypeDisplayName() { switch (templateType != null ? templateType.toUpperCase() : "") { case "EMAIL": @@ -204,14 +109,7 @@ public String getTemplateTypeDisplayName() { } } - - // 템플릿 사용 가능 여부 확인 - // - // 활성화된 템플릿만 사용 가능 - // - // @return 사용 가능 여부 - // @since 1.0.0 - + // 템플릿 사용 가능 여부 확인 활성화된 템플릿만 사용 가능 @return 사용 가능 여부 @since 1.0.0 public boolean isUsable() { return isActive; } diff --git a/src/main/java/com/carecode/domain/notification/factory/NotificationStrategyFactory.java b/src/main/java/com/carecode/domain/notification/factory/NotificationStrategyFactory.java index 3c8578ab..aa54528d 100644 --- a/src/main/java/com/carecode/domain/notification/factory/NotificationStrategyFactory.java +++ b/src/main/java/com/carecode/domain/notification/factory/NotificationStrategyFactory.java @@ -9,10 +9,7 @@ import java.util.function.Function; import java.util.stream.Collectors; -/** - * 알림 전략 팩토리 - * 알림 타입에 따라 적절한 전략을 반환 - */ +/** 알림 전략 팩토리 알림 타입에 따라 적절한 전략을 반환 */ @Slf4j @Component public class NotificationStrategyFactory { @@ -28,10 +25,8 @@ public NotificationStrategyFactory(List strategies) { Function.identity() )); } - // 알림 타입에 따른 전략 반환 - public NotificationStrategy getStrategy(String notificationType) { NotificationStrategy strategy = strategyMap.get(notificationType.toUpperCase()); @@ -43,19 +38,15 @@ public NotificationStrategy getStrategy(String notificationType) { return strategy; } - // 지원하는 모든 알림 타입 반환 - public List getSupportedNotificationTypes() { return strategies.stream() .map(NotificationStrategy::getNotificationType) .collect(Collectors.toList()); } - // 전략 존재 여부 확인 - public boolean supportsNotificationType(String notificationType) { return strategyMap.containsKey(notificationType.toUpperCase()); } diff --git a/src/main/java/com/carecode/domain/notification/repository/NotificationPreferenceRepository.java b/src/main/java/com/carecode/domain/notification/repository/NotificationPreferenceRepository.java index 81b5f551..8d3625ed 100644 --- a/src/main/java/com/carecode/domain/notification/repository/NotificationPreferenceRepository.java +++ b/src/main/java/com/carecode/domain/notification/repository/NotificationPreferenceRepository.java @@ -11,59 +11,39 @@ import java.util.List; import java.util.Optional; -/** - * 알림 설정 리포지토리 - */ +/** 알림 설정 리포지토리 */ @Repository public interface NotificationPreferenceRepository extends JpaRepository { - // 사용자별 알림 설정 목록 조회 - List findByUserOrderByNotificationType(User user); - // 사용자와 알림 타입으로 설정 조회 - Optional findByUserAndNotificationType(User user, Notification.NotificationType notificationType); - // 사용자별 활성화된 이메일 알림 설정 조회 - @Query("SELECT np FROM NotificationPreference np WHERE np.user = :user AND np.emailEnabled = true") List findEmailEnabledByUser(@Param("user") User user); - // 사용자별 활성화된 푸시 알림 설정 조회 - @Query("SELECT np FROM NotificationPreference np WHERE np.user = :user AND np.pushEnabled = true") List findPushEnabledByUser(@Param("user") User user); - // 사용자별 활성화된 SMS 알림 설정 조회 - @Query("SELECT np FROM NotificationPreference np WHERE np.user = :user AND np.smsEnabled = true") List findSmsEnabledByUser(@Param("user") User user); - // 사용자별 활성화된 인앱 알림 설정 조회 - @Query("SELECT np FROM NotificationPreference np WHERE np.user = :user AND np.inAppEnabled = true") List findInAppEnabledByUser(@Param("user") User user); - // 특정 알림 타입의 활성화된 설정 조회 - @Query("SELECT np FROM NotificationPreference np WHERE np.notificationType = :notificationType AND (np.emailEnabled = true OR np.pushEnabled = true OR np.smsEnabled = true OR np.inAppEnabled = true)") List findEnabledByNotificationType(@Param("notificationType") Notification.NotificationType notificationType); - // 사용자별 설정 수 조회 - long countByUser(User user); - // 알림 타입별 설정 수 조회 - long countByNotificationType(Notification.NotificationType notificationType); } \ No newline at end of file diff --git a/src/main/java/com/carecode/domain/notification/repository/NotificationRepository.java b/src/main/java/com/carecode/domain/notification/repository/NotificationRepository.java index 60419bb8..1bd60203 100644 --- a/src/main/java/com/carecode/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/com/carecode/domain/notification/repository/NotificationRepository.java @@ -12,69 +12,45 @@ import java.time.LocalDateTime; import java.util.List; -/** - * 알림 리포지토리 인터페이스 - */ +/** 알림 리포지토리 인터페이스 */ @Repository public interface NotificationRepository extends JpaRepository { - // 사용자별 알림 목록 조회 - Page findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); - // 사용자별 알림 목록 조회 (전체) - List findByUserIdOrderByCreatedAtDesc(Long userId); - // 읽지 않은 알림 조회 - List findByUserIdAndIsReadFalse(Long userId); - // 알림 타입별 조회 - List findByUserIdAndNotificationTypeOrderByCreatedAtDesc(Long userId, Notification.NotificationType notificationType); - // 기간별 알림 조회 - @Query("SELECT n FROM Notification n WHERE n.user.id = :userId AND n.createdAt BETWEEN :startDate AND :endDate") List findByDateRange(@Param("userId") Long userId, @Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate); - // 사용자별 알림 개수 조회 - long countByUserId(Long userId); - // 읽지 않은 알림 개수 조회 - long countByUserIdAndIsReadFalse(Long userId); - // 알림 타입별 개수 조회 - long countByUserIdAndNotificationType(Long userId, Notification.NotificationType notificationType); - // 읽음/읽지 않음별 알림 개수 조회 - long countByUserIdAndIsRead(Long userId, boolean isRead); - // 모든 알림을 읽음으로 처리 - @Query("UPDATE Notification n SET n.isRead = true WHERE n.isRead = false") void markAllAsRead(); - // 특정 알림들을 읽음으로 처리 - @Query("UPDATE Notification n SET n.isRead = true WHERE n.id IN :notificationIds") void markAsReadByIds(@Param("notificationIds") List notificationIds); diff --git a/src/main/java/com/carecode/domain/notification/sender/EmailNotificationSender.java b/src/main/java/com/carecode/domain/notification/sender/EmailNotificationSender.java index 75b93881..73534fba 100644 --- a/src/main/java/com/carecode/domain/notification/sender/EmailNotificationSender.java +++ b/src/main/java/com/carecode/domain/notification/sender/EmailNotificationSender.java @@ -6,9 +6,7 @@ import org.springframework.mail.javamail.JavaMailSender; import org.springframework.stereotype.Component; -/** - * 이메일 채널 발송기. - */ +/** 이메일 채널 발송기. */ @Slf4j @Component public class EmailNotificationSender implements NotificationSender { diff --git a/src/main/java/com/carecode/domain/notification/sender/NotificationChannelType.java b/src/main/java/com/carecode/domain/notification/sender/NotificationChannelType.java index 621626f7..a592c958 100644 --- a/src/main/java/com/carecode/domain/notification/sender/NotificationChannelType.java +++ b/src/main/java/com/carecode/domain/notification/sender/NotificationChannelType.java @@ -1,8 +1,6 @@ package com.carecode.domain.notification.sender; -/** - * 알림 전달 채널. - */ +/** 알림 전달 채널. */ public enum NotificationChannelType { IN_APP("인앱"), EMAIL("이메일"), diff --git a/src/main/java/com/carecode/domain/notification/sender/NotificationDispatcher.java b/src/main/java/com/carecode/domain/notification/sender/NotificationDispatcher.java index 51d685b3..3603d9df 100644 --- a/src/main/java/com/carecode/domain/notification/sender/NotificationDispatcher.java +++ b/src/main/java/com/carecode/domain/notification/sender/NotificationDispatcher.java @@ -13,12 +13,7 @@ import java.util.Map; import java.util.Optional; -/** - * 알림을 사용자 설정에 맞는 채널로 실제 발송한다. - * - *

이전에는 알림을 DB 에 저장만 하고 {@code deliveryStatus="SENT"} 를 하드코딩해서, - * 채널 설정·디바이스 토큰을 모아두고도 실제로는 아무데도 보내지 않았다. - */ +/** 알림을 사용자 설정에 맞는 채널로 실제 발송한다. */ @Slf4j @Component public class NotificationDispatcher { @@ -36,14 +31,7 @@ public NotificationDispatcher(List senderBeans, log.info("알림 발송 채널 등록: {}", senders.keySet()); } - /** - * 저장된 알림을 사용자 설정 채널로 발송한다. - * - *

요청 스레드를 막지 않도록 비동기로 실행한다. 발송 실패는 로그로 남기고 - * 다른 채널 발송을 계속한다. - * - * @return 하나 이상의 채널로 발송에 성공했는지 여부 - */ + /** 저장된 알림을 사용자 설정 채널로 발송한다. */ @Async("notificationExecutor") public void dispatchAsync(Notification notification) { dispatch(notification); @@ -94,9 +82,7 @@ public boolean dispatch(Notification notification) { return anySent; } - /** - * 채널 사용 여부. 사용자 설정이 없으면 인앱과 푸시를 기본으로 켠다. - */ + /** 채널 사용 여부. 사용자 설정이 없으면 인앱과 푸시를 기본으로 켠다. */ private boolean isChannelEnabled(NotificationPreference preference, NotificationChannelType channel) { if (preference == null) { return channel == NotificationChannelType.IN_APP || channel == NotificationChannelType.PUSH; diff --git a/src/main/java/com/carecode/domain/notification/sender/NotificationPayload.java b/src/main/java/com/carecode/domain/notification/sender/NotificationPayload.java index 01b29ef4..86c531c6 100644 --- a/src/main/java/com/carecode/domain/notification/sender/NotificationPayload.java +++ b/src/main/java/com/carecode/domain/notification/sender/NotificationPayload.java @@ -4,9 +4,7 @@ import lombok.Builder; import lombok.Getter; -/** - * 채널 구현체에 전달되는 발송 요청. - */ +/** 채널 구현체에 전달되는 발송 요청. */ @Getter @Builder public class NotificationPayload { diff --git a/src/main/java/com/carecode/domain/notification/sender/NotificationSender.java b/src/main/java/com/carecode/domain/notification/sender/NotificationSender.java index 99b3a095..f5832dba 100644 --- a/src/main/java/com/carecode/domain/notification/sender/NotificationSender.java +++ b/src/main/java/com/carecode/domain/notification/sender/NotificationSender.java @@ -1,27 +1,14 @@ package com.carecode.domain.notification.sender; -/** - * 채널별 알림 발송 구현체. - * - *

구현체를 빈으로 등록하면 {@code NotificationDispatcher} 가 자동으로 수집한다. - * 새 채널(카카오 알림톡 등)을 붙일 때는 이 인터페이스만 구현하면 된다. - */ +/** 채널별 알림 발송 구현체. 구현체를 빈으로 등록하면 NotificationDispatcher 가 자동으로 수집한다 */ public interface NotificationSender { NotificationChannelType channel(); - /** - * 발송을 시도한다. - * - * @return 발송 성공 여부. 실패 시 예외를 던지지 말고 false 를 반환한다 - * (한 채널 실패가 다른 채널 발송을 막지 않도록). - */ + /** 발송을 시도한다. (한 채널 실패가 다른 채널 발송을 막지 않도록). */ boolean send(NotificationPayload payload); - /** - * 현재 설정으로 이 채널을 실제로 사용할 수 있는지. - * 자격증명이 없으면 false 를 반환해 조용히 건너뛴다. - */ + /** 현재 설정으로 이 채널을 실제로 사용할 수 있는지. 자격증명이 없으면 false 를 반환해 조용히 건너뛴다. */ default boolean isAvailable() { return true; } diff --git a/src/main/java/com/carecode/domain/notification/sender/PushNotificationSender.java b/src/main/java/com/carecode/domain/notification/sender/PushNotificationSender.java index 0820b9af..8f111347 100644 --- a/src/main/java/com/carecode/domain/notification/sender/PushNotificationSender.java +++ b/src/main/java/com/carecode/domain/notification/sender/PushNotificationSender.java @@ -7,11 +7,7 @@ import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; -/** - * FCM 푸시 채널 발송기. - * - *

{@link FirebaseMessaging} 빈이 없으면(자격증명 미설정) 비활성 상태로 동작한다. - */ +/** FCM 푸시 채널 발송기. FirebaseMessaging 빈이 없으면(자격증명 미설정) 비활성 상태로 동작한다. */ @Slf4j @Component public class PushNotificationSender implements NotificationSender { diff --git a/src/main/java/com/carecode/domain/notification/sender/SmsNotificationSender.java b/src/main/java/com/carecode/domain/notification/sender/SmsNotificationSender.java index aa74eb3d..52bc0cd7 100644 --- a/src/main/java/com/carecode/domain/notification/sender/SmsNotificationSender.java +++ b/src/main/java/com/carecode/domain/notification/sender/SmsNotificationSender.java @@ -4,13 +4,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -/** - * SMS 채널 발송기. - * - *

아직 계약된 SMS 사업자가 없어 실제 전송은 하지 않는다. - * 채널 배선은 완성해 두고, 사업자가 정해지면 {@link #send} 구현만 채우면 된다. - * 설정이 없는 동안에는 {@link #isAvailable()} 이 false 라 디스패처가 건너뛴다. - */ +/** SMS 채널 발송기. 아직 계약된 SMS 사업자가 없어 실제 전송은 하지 않는다. */ @Slf4j @Component public class SmsNotificationSender implements NotificationSender { diff --git a/src/main/java/com/carecode/domain/notification/service/NotificationCreationService.java b/src/main/java/com/carecode/domain/notification/service/NotificationCreationService.java index 1b444f8a..34d98610 100644 --- a/src/main/java/com/carecode/domain/notification/service/NotificationCreationService.java +++ b/src/main/java/com/carecode/domain/notification/service/NotificationCreationService.java @@ -9,12 +9,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -/** - * 시스템이 발생시키는 알림 생성 경로. - * - *

{@link NotificationService#createNotification} 은 "본인이 본인에게" 만드는 사용자 API 라 - * 배치·스케줄러가 쓸 수 없다. 여기서는 액터 검증 없이 대상 사용자에게 직접 알림을 만든다. - */ +/** 시스템이 발생시키는 알림 생성 경로. NotificationService#createNotification 은 "본인이 본인에게" 만드는 사용자 API 라 */ @Slf4j @Service @RequiredArgsConstructor @@ -23,11 +18,7 @@ public class NotificationCreationService { private final NotificationRepository notificationRepository; private final NotificationDispatcher notificationDispatcher; - /** - * 알림을 저장하고 사용자 설정 채널로 발송한다. - * - * @return 저장된 알림 - */ + /** 알림을 저장하고 사용자 설정 채널로 발송한다. */ @Transactional public Notification createAndSend(User recipient, Notification.NotificationType type, diff --git a/src/main/java/com/carecode/domain/notification/service/NotificationInitializationService.java b/src/main/java/com/carecode/domain/notification/service/NotificationInitializationService.java index f7b158bc..7bfc7f3b 100644 --- a/src/main/java/com/carecode/domain/notification/service/NotificationInitializationService.java +++ b/src/main/java/com/carecode/domain/notification/service/NotificationInitializationService.java @@ -13,10 +13,7 @@ import java.util.Optional; -/** - * 알림 초기화 서비스 - * 애플리케이션 시작 시 테스트용 알림을 생성 - */ +/** 알림 초기화 서비스 애플리케이션 시작 시 테스트용 알림을 생성 */ @Slf4j @Service @Profile("dev") @@ -31,9 +28,7 @@ public void run(String... args) throws Exception { createTestNotifications(); } - // 테스트용 알림 생성 - @Transactional public void createTestNotifications() { try { @@ -64,9 +59,7 @@ public void createTestNotifications() { } } - // 시스템 알림 생성 - private void createSystemNotification(User user) { Notification systemNotification = Notification.builder() .user(user) @@ -80,9 +73,7 @@ private void createSystemNotification(User user) { log.info("시스템 알림 생성 완료: {}", systemNotification.getTitle()); } - // 정책 알림 생성 - private void createPolicyNotification(User user) { Notification policyNotification = Notification.builder() .user(user) @@ -96,9 +87,7 @@ private void createPolicyNotification(User user) { log.info("정책 알림 생성 완료: {}", policyNotification.getTitle()); } - // 커뮤니티 알림 생성 - private void createCommunityNotification(User user) { Notification communityNotification = Notification.builder() .user(user) diff --git a/src/main/java/com/carecode/domain/notification/service/NotificationPreferenceService.java b/src/main/java/com/carecode/domain/notification/service/NotificationPreferenceService.java index 3b0de2a0..751e210d 100644 --- a/src/main/java/com/carecode/domain/notification/service/NotificationPreferenceService.java +++ b/src/main/java/com/carecode/domain/notification/service/NotificationPreferenceService.java @@ -20,10 +20,7 @@ import java.util.Optional; import java.util.stream.Collectors; -/** - * 알림 설정 서비스 클래스 - * 사용자별 알림 설정을 관리 - */ +/** 알림 설정 서비스 클래스 사용자별 알림 설정을 관리 */ @Slf4j @Service @RequiredArgsConstructor @@ -33,9 +30,7 @@ public class NotificationPreferenceService { private final NotificationPreferenceRepository preferenceRepository; private final UserRepository userRepository; - // 사용자별 알림 설정 목록 조회 - @LogExecutionTime public List getUserPreferences(String userId) { log.info("사용자별 알림 설정 조회: 사용자ID={}", userId); @@ -55,9 +50,7 @@ public List getUserPreferences(String userId) { } } - // 특정 알림 타입 설정 조회 - @LogExecutionTime public NotificationSettingsResponse getPreferenceByType(String userId, Notification.NotificationType notificationType) { log.info("알림 타입별 설정 조회: 사용자ID={}, 타입={}", userId, notificationType); @@ -76,9 +69,7 @@ public NotificationSettingsResponse getPreferenceByType(String userId, Notificat } } - // 알림 설정 생성 또는 업데이트 - @LogExecutionTime @Transactional public NotificationSettingsResponse savePreference(String userId, NotificationSettingsResponse preferenceDto) { @@ -103,9 +94,7 @@ public NotificationSettingsResponse savePreference(String userId, NotificationSe } } - // 채널별 설정 업데이트 - @LogExecutionTime @Transactional public NotificationSettingsResponse updateChannelPreference(String userId, String notificationType, String channel, boolean enabled) { @@ -130,9 +119,7 @@ public NotificationSettingsResponse updateChannelPreference(String userId, Strin } } - // 모든 알림 설정 비활성화 - @LogExecutionTime @Transactional public void disableAllNotifications(String userId) { @@ -157,9 +144,7 @@ public void disableAllNotifications(String userId) { } } - // 기본 설정으로 초기화 - @LogExecutionTime @Transactional public void resetToDefault(String userId) { @@ -183,9 +168,7 @@ public void resetToDefault(String userId) { } } - // 특정 알림 타입의 활성화된 설정 조회 - @LogExecutionTime public List getEnabledPreferencesByType(Notification.NotificationType notificationType) { log.info("알림 타입별 활성화된 설정 조회: 타입={}", notificationType); @@ -202,9 +185,7 @@ public List getEnabledPreferencesByType(Notificati } } - // 기본 설정 생성 - private NotificationPreference createDefaultPreference(User user, Notification.NotificationType notificationType) { NotificationPreference preference = NotificationPreference.builder() .user(user) @@ -220,9 +201,7 @@ private NotificationPreference createDefaultPreference(User user, Notification.N return preferenceRepository.save(preference); } - // 새 설정 생성 - private NotificationPreference createNewPreference(User user, NotificationSettingsResponse preferenceDto) { return NotificationPreference.builder() .user(user) @@ -237,9 +216,7 @@ private NotificationPreference createNewPreference(User user, NotificationSettin .build(); } - // 설정 업데이트 - private void updatePreference(NotificationPreference preference, NotificationSettingsResponse preferenceDto) { preference.setEmailEnabled(preferenceDto.getEmailEnabled()); preference.setPushEnabled(preferenceDto.getPushEnabled()); @@ -250,9 +227,7 @@ private void updatePreference(NotificationPreference preference, NotificationSet preference.setDeviceToken(preferenceDto.getDeviceToken()); } - // 채널별 설정 업데이트 - private void updateChannelSetting(NotificationPreference preference, String channel, boolean enabled) { switch (channel.toLowerCase()) { case "email" -> preference.setEmailEnabled(enabled); @@ -263,9 +238,7 @@ private void updateChannelSetting(NotificationPreference preference, String chan } } - // DTO 변환 - private NotificationSettingsResponse convertToDto(NotificationPreference preference) { return NotificationSettingsResponse.builder() .id(preference.getId()) @@ -283,9 +256,7 @@ private NotificationSettingsResponse convertToDto(NotificationPreference prefere .build(); } - // 푸시 알림 토큰 등록 - @Transactional public void registerPushToken(String userId, NotificationRegisterPushTokenRequest request) { User user = userRepository.findByUserId(userId) @@ -317,9 +288,7 @@ public void registerPushToken(String userId, NotificationRegisterPushTokenReques } } - // 알림 설정 수정 - @Transactional public void updateSettings(String userId, NotificationUpdateSettingsRequest request) { User user = userRepository.findByUserId(userId) diff --git a/src/main/java/com/carecode/domain/notification/service/NotificationService.java b/src/main/java/com/carecode/domain/notification/service/NotificationService.java index e0ce6c9c..607939a8 100644 --- a/src/main/java/com/carecode/domain/notification/service/NotificationService.java +++ b/src/main/java/com/carecode/domain/notification/service/NotificationService.java @@ -35,10 +35,7 @@ import java.util.Objects; import java.util.stream.Collectors; -/** - * 알림 서비스 클래스 - * 전략 패턴을 사용하여 알림 타입별로 다른 처리 로직 적용 - */ +/** 알림 서비스 클래스 전략 패턴을 사용하여 알림 타입별로 다른 처리 로직 적용 */ @Slf4j @Service @RequiredArgsConstructor @@ -50,10 +47,7 @@ public class NotificationService { private final NotificationStrategyFactory strategyFactory; private final NotificationDispatcher notificationDispatcher; - - // 사용자별 알림 목록 조회 - @LogExecutionTime public List getNotificationsByUserId(String userId) { log.info("사용자별 알림 목록 조회 - 사용자 ID: {}", userId); @@ -73,9 +67,7 @@ public List getNotificationsByUserId(String userId) { } } - // 알림 상세 조회 - @LogExecutionTime public NotificationInfoResponse getNotificationById(Long notificationId, String actorUserId) { log.info("알림 상세 조회 - 알림 ID: {}", notificationId); @@ -92,9 +84,7 @@ public NotificationInfoResponse getNotificationById(Long notificationId, String } } - // 알림 생성 (전략 패턴 사용) - @Transactional public NotificationInfoResponse createNotification(NotificationCreateRequest request, String actorUserId) { log.info("알림 생성 - 사용자 ID: {}, 타입: {}, 제목: {}", @@ -132,9 +122,7 @@ public NotificationInfoResponse createNotification(NotificationCreateRequest req } } - // 알림 수정 - @Transactional public NotificationInfoResponse updateNotification(Long notificationId, NotificationCreateRequest request, String actorUserId) { Notification notification = notificationRepository.findById(notificationId) @@ -157,9 +145,7 @@ public NotificationInfoResponse updateNotification(Long notificationId, Notifica return convertToResponseDto(updatedNotification); } - // 알림 삭제 - @Transactional public void deleteNotification(Long notificationId, String actorUserId) { Notification notification = notificationRepository.findById(notificationId) @@ -169,9 +155,7 @@ public void deleteNotification(Long notificationId, String actorUserId) { notificationRepository.delete(notification); } - // 알림 읽음 처리 - @Transactional public void markAsRead(Long notificationId, String actorUserId) { Notification notification = notificationRepository.findById(notificationId) @@ -182,9 +166,7 @@ public void markAsRead(Long notificationId, String actorUserId) { notificationRepository.save(notification); } - // 모든 알림 읽음 처리 - @Transactional public void markAllAsRead(String userId) { User user = userRepository.findByUserId(userId) @@ -195,9 +177,7 @@ public void markAllAsRead(String userId) { notificationRepository.saveAll(unreadNotifications); } - // 읽지 않은 알림 조회 - @LogExecutionTime public List getUnreadNotifications(String userId) { User user = userRepository.findByUserId(userId) @@ -210,9 +190,7 @@ public List getUnreadNotifications(String userId) { .collect(Collectors.toList()); } - // 알림 설정 조회 - @LogExecutionTime public Map getNotificationSettings(String userId) { User user = userRepository.findByUserId(userId) @@ -230,9 +208,7 @@ public Map getNotificationSettings(String userId) { return settings; } - // 알림 설정 업데이트 - @Transactional public Map updateNotificationSettings(String userId, Map settings) { log.info("알림 설정 업데이트 - 사용자 ID: {}", userId); @@ -253,9 +229,7 @@ public Map updateNotificationSettings(String userId, Map getNotificationStatistics(String userId) { log.info("알림 통계 조회 - 사용자 ID: {}", userId); @@ -285,7 +259,6 @@ public Map getNotificationStatistics(String userId) { } // 기존 메서드들 (호환성을 위해 유지) - // Helper methods private NotificationInfoResponse convertToResponseDto(Notification notification) { @@ -309,9 +282,7 @@ private NotificationInfoResponse convertToResponseDto(Notification notification) .build(); } - // DTO 변환 메서드 - private NotificationInfoResponse convertToDto(Notification notification) { return NotificationInfoResponse.builder() .id(notification.getId()) @@ -324,9 +295,7 @@ private NotificationInfoResponse convertToDto(Notification notification) { .build(); } - // 알림 타입별 분포 계산 - private Map calculateTypeDistribution(Long userId) { Map distribution = new HashMap<>(); @@ -338,9 +307,7 @@ private Map calculateTypeDistribution(Long userId) { return distribution; } - // 알림 읽음 처리 - @Transactional(readOnly = false) public void markAsRead(NotificationMarkAsReadRequest request, String actorUserId) { User user = userRepository.findByUserId(actorUserId) @@ -352,9 +319,7 @@ public void markAsRead(NotificationMarkAsReadRequest request, String actorUserId } } - // 테스트 알림 발송 - @Transactional public void sendTestNotification(String userId, NotificationSendTestRequest request) { User user = userRepository.findByUserId(userId) @@ -371,9 +336,7 @@ public void sendTestNotification(String userId, NotificationSendTestRequest requ notificationRepository.save(notification); } - // 알림 타입별 조회 - @LogExecutionTime public List getNotificationsByType(Long userId, Notification.NotificationType notificationType) { log.info("알림 타입별 조회 - 사용자 ID: {}, 타입: {}", userId, notificationType); @@ -385,9 +348,7 @@ public List getNotificationsByType(Long userId, Notifi .collect(Collectors.toList()); } - // 기간별 알림 조회 - @LogExecutionTime public List getNotificationsByDateRange(Long userId, LocalDateTime startDate, LocalDateTime endDate) { log.info("기간별 알림 조회 - 사용자 ID: {}, 시작일: {}, 종료일: {}", userId, startDate, endDate); @@ -399,9 +360,7 @@ public List getNotificationsByDateRange(Long userId, L .collect(Collectors.toList()); } - // 사용자별 전체 알림 개수 조회 - @LogExecutionTime public long getTotalNotificationCount(Long userId) { log.info("사용자별 전체 알림 개수 조회 - 사용자 ID: {}", userId); @@ -409,9 +368,7 @@ public long getTotalNotificationCount(Long userId) { return notificationRepository.countByUserId(userId); } - // 읽음/읽지 않음별 알림 개수 조회 - @LogExecutionTime public long getNotificationCountByReadStatus(Long userId, boolean isRead) { log.info("읽음 상태별 알림 개수 조회 - 사용자 ID: {}, 읽음: {}", userId, isRead); @@ -419,9 +376,7 @@ public long getNotificationCountByReadStatus(Long userId, boolean isRead) { return notificationRepository.countByUserIdAndIsRead(userId, isRead); } - // 알림 통계 조회 - @Transactional(readOnly = true) public NotificationStatsResponse getNotificationStats(String userId) { User user = userRepository.findByUserId(userId) @@ -445,9 +400,7 @@ public NotificationStatsResponse getNotificationStats(String userId) { .build(); } - // 알림 템플릿 조회 - @Transactional(readOnly = true) public List getNotificationTemplates(String type) { List templates = new ArrayList<>(); @@ -482,9 +435,7 @@ public List getNotificationTemplates(String type) return templates; } - // 알림 전송 상태 조회 - @Transactional(readOnly = true) public NotificationDeliveryStatusResponse getDeliveryStatus(Long notificationId, String actorUserId) { Notification notification = notificationRepository.findById(notificationId) diff --git a/src/main/java/com/carecode/domain/notification/service/NotificationTemplateService.java b/src/main/java/com/carecode/domain/notification/service/NotificationTemplateService.java index bcb97582..7a150276 100644 --- a/src/main/java/com/carecode/domain/notification/service/NotificationTemplateService.java +++ b/src/main/java/com/carecode/domain/notification/service/NotificationTemplateService.java @@ -11,20 +11,15 @@ import java.util.HashMap; import java.util.Map; -/** - * 알림 템플릿 서비스 - * 공통 알림 템플릿을 관리하고 제공 - */ +/** 알림 템플릿 서비스 공통 알림 템플릿을 관리하고 제공 */ @Slf4j @Service @RequiredArgsConstructor public class NotificationTemplateService { private final NotificationStrategyFactory strategyFactory; - // 시스템 업데이트 알림 템플릿 - public NotificationCreateRequest createSystemUpdateTemplate(User user, String version, String features) { return NotificationCreateRequest.builder() .userId(user.getUserId()) @@ -34,10 +29,8 @@ public NotificationCreateRequest createSystemUpdateTemplate(User user, String ve .priority("NORMAL") .build(); } - // 정책 변경 알림 템플릿 - public NotificationCreateRequest createPolicyChangeTemplate(User user, String policyName, String changeDetails) { return NotificationCreateRequest.builder() .userId(user.getUserId()) @@ -47,10 +40,8 @@ public NotificationCreateRequest createPolicyChangeTemplate(User user, String po .priority("HIGH") .build(); } - // 커뮤니티 활동 알림 템플릿 - public NotificationCreateRequest createCommunityActivityTemplate(User user, String activityType, String content) { return NotificationCreateRequest.builder() .userId(user.getUserId()) @@ -60,10 +51,8 @@ public NotificationCreateRequest createCommunityActivityTemplate(User user, Stri .priority("LOW") .build(); } - // 건강 기록 알림 템플릿 - public NotificationCreateRequest createHealthRecordTemplate(User user, String childName, String recordType) { return NotificationCreateRequest.builder() .userId(user.getUserId()) @@ -73,10 +62,8 @@ public NotificationCreateRequest createHealthRecordTemplate(User user, String ch .priority("NORMAL") .build(); } - // 예방접종 알림 템플릿 - public NotificationCreateRequest createVaccinationReminderTemplate(User user, String childName, String vaccineName, String dueDate) { return NotificationCreateRequest.builder() .userId(user.getUserId()) @@ -86,10 +73,8 @@ public NotificationCreateRequest createVaccinationReminderTemplate(User user, St .priority("HIGH") .build(); } - // 시설 추천 알림 템플릿 - public NotificationCreateRequest createFacilityRecommendationTemplate(User user, String facilityName, String reason) { return NotificationCreateRequest.builder() .userId(user.getUserId()) @@ -99,10 +84,8 @@ public NotificationCreateRequest createFacilityRecommendationTemplate(User user, .priority("NORMAL") .build(); } - // 챗봇 응답 알림 템플릿 - public NotificationCreateRequest createChatbotResponseTemplate(User user, String question, String answer) { return NotificationCreateRequest.builder() .userId(user.getUserId()) @@ -112,10 +95,8 @@ public NotificationCreateRequest createChatbotResponseTemplate(User user, String .priority("LOW") .build(); } - // 긴급 알림 템플릿 - public NotificationCreateRequest createEmergencyTemplate(User user, String emergencyType, String details) { return NotificationCreateRequest.builder() .userId(user.getUserId()) @@ -125,10 +106,8 @@ public NotificationCreateRequest createEmergencyTemplate(User user, String emerg .priority("HIGH") .build(); } - // 사용자 정의 알림 템플릿 생성 - public NotificationCreateRequest createCustomTemplate(User user, String type, String title, String message, String priority) { return NotificationCreateRequest.builder() .userId(user.getUserId()) @@ -138,10 +117,8 @@ public NotificationCreateRequest createCustomTemplate(User user, String type, St .priority(priority) .build(); } - // 템플릿 유효성 검사 - public boolean validateTemplate(NotificationCreateRequest template) { if (template == null) return false; @@ -169,10 +146,8 @@ public boolean validateTemplate(NotificationCreateRequest template) { return true; } - // 템플릿 변수 치환 - public String replaceTemplateVariables(String template, Map variables) { String result = template; @@ -182,10 +157,8 @@ public String replaceTemplateVariables(String template, Map vari return result; } - // 기본 변수 맵 생성 - public Map createDefaultVariables(User user) { Map variables = new HashMap<>(); variables.put("userName", user.getName()); diff --git a/src/main/java/com/carecode/domain/notification/strategy/CommunityNotificationStrategy.java b/src/main/java/com/carecode/domain/notification/strategy/CommunityNotificationStrategy.java index 3f2165e8..ab2f1efb 100644 --- a/src/main/java/com/carecode/domain/notification/strategy/CommunityNotificationStrategy.java +++ b/src/main/java/com/carecode/domain/notification/strategy/CommunityNotificationStrategy.java @@ -5,10 +5,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -/** - * 커뮤니티 알림 전략 - * 커뮤니티 활동 관련 알림을 처리하는 전략 - */ +/** 커뮤니티 알림 전략 커뮤니티 활동 관련 알림을 처리하는 전략 */ @Slf4j @Component public class CommunityNotificationStrategy implements NotificationStrategy { @@ -35,8 +32,7 @@ public Notification createNotification(User user, String title, String message) public void processNotification(Notification notification) { log.info("커뮤니티 알림 처리: 알림ID={}, 제목={}", notification.getId(), notification.getTitle()); - // 커뮤니티 알림은 사용자 설정에 따라 전송 - // 실제로는 사용자의 커뮤니티 알림 설정을 확인하여 전송 + // 커뮤니티 알림은 사용자 설정에 따라 전송 실제로는 사용자의 커뮤니티 알림 설정을 확인하여 전송 log.info("커뮤니티 알림 전송 완료: {}", notification.getTitle()); } diff --git a/src/main/java/com/carecode/domain/notification/strategy/NotificationStrategy.java b/src/main/java/com/carecode/domain/notification/strategy/NotificationStrategy.java index b23a201c..8c92f00e 100644 --- a/src/main/java/com/carecode/domain/notification/strategy/NotificationStrategy.java +++ b/src/main/java/com/carecode/domain/notification/strategy/NotificationStrategy.java @@ -3,34 +3,21 @@ import com.carecode.domain.notification.entity.Notification; import com.carecode.domain.user.entity.User; -/** - * 알림 전략 인터페이스 - * 각 알림 타입별로 다른 처리 로직을 구현할 수 있도록 함 - */ +/** 알림 전략 인터페이스 각 알림 타입별로 다른 처리 로직을 구현할 수 있도록 함 */ public interface NotificationStrategy { - // 알림 타입 반환 - String getNotificationType(); - // 알림 생성 - Notification createNotification(User user, String title, String message); - // 알림 처리 (전송, 저장 등) - void processNotification(Notification notification); - // 알림 유효성 검사 - boolean validateNotification(Notification notification); - // 알림 우선순위 결정 - String determinePriority(Notification notification); } \ No newline at end of file diff --git a/src/main/java/com/carecode/domain/notification/strategy/PolicyNotificationStrategy.java b/src/main/java/com/carecode/domain/notification/strategy/PolicyNotificationStrategy.java index 2ec64770..b9b96241 100644 --- a/src/main/java/com/carecode/domain/notification/strategy/PolicyNotificationStrategy.java +++ b/src/main/java/com/carecode/domain/notification/strategy/PolicyNotificationStrategy.java @@ -5,10 +5,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -/** - * 정책 알림 전략 - * 육아 정책 관련 알림을 처리하는 전략 - */ +/** 정책 알림 전략 육아 정책 관련 알림을 처리하는 전략 */ @Slf4j @Component public class PolicyNotificationStrategy implements NotificationStrategy { @@ -35,8 +32,7 @@ public Notification createNotification(User user, String title, String message) public void processNotification(Notification notification) { log.info("정책 알림 처리: 알림ID={}, 제목={}", notification.getId(), notification.getTitle()); - // 정책 알림은 사용자 설정에 따라 전송 - // 실제로는 사용자의 정책 알림 설정을 확인하여 전송 + // 정책 알림은 사용자 설정에 따라 전송 실제로는 사용자의 정책 알림 설정을 확인하여 전송 log.info("정책 알림 전송 완료: {}", notification.getTitle()); } diff --git a/src/main/java/com/carecode/domain/notification/strategy/SystemNotificationStrategy.java b/src/main/java/com/carecode/domain/notification/strategy/SystemNotificationStrategy.java index 1a921a3c..0b8b3843 100644 --- a/src/main/java/com/carecode/domain/notification/strategy/SystemNotificationStrategy.java +++ b/src/main/java/com/carecode/domain/notification/strategy/SystemNotificationStrategy.java @@ -5,10 +5,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -/** - * 시스템 알림 전략 - * 시스템 관련 알림을 처리하는 전략 - */ +/** 시스템 알림 전략 시스템 관련 알림을 처리하는 전략 */ @Slf4j @Component public class SystemNotificationStrategy implements NotificationStrategy { @@ -35,8 +32,7 @@ public Notification createNotification(User user, String title, String message) public void processNotification(Notification notification) { log.info("시스템 알림 처리: 알림ID={}, 제목={}", notification.getId(), notification.getTitle()); - // 시스템 알림은 즉시 전송 - // 실제로는 이메일, 푸시 알림 등을 발송 + // 시스템 알림은 즉시 전송 실제로는 이메일, 푸시 알림 등을 발송 log.info("시스템 알림 전송 완료: {}", notification.getTitle()); } diff --git a/src/main/java/com/carecode/domain/policy/controller/PolicyController.java b/src/main/java/com/carecode/domain/policy/controller/PolicyController.java index 25ef9d2a..dd17f4f0 100644 --- a/src/main/java/com/carecode/domain/policy/controller/PolicyController.java +++ b/src/main/java/com/carecode/domain/policy/controller/PolicyController.java @@ -32,10 +32,7 @@ import com.carecode.core.handler.ApiSuccess; import java.util.Date; -/** - * 육아 정책 API 컨트롤러 - * 정부 육아 정책 정보 제공 및 검색 서비스 - */ +/** 육아 정책 API 컨트롤러 정부 육아 정책 정보 제공 및 검색 서비스 */ @RestController @RequestMapping("/policies") @RequiredArgsConstructor @@ -49,7 +46,7 @@ public class PolicyController extends BaseController { // 전체 정책 목록 조회 @GetMapping @LogExecutionTime - @Operation(summary = "전체 정책 목록 조회", description = "등록된 모든 육아 정책 목록을 조회합니다.") + @Operation(summary = "전체 정책 목록 조회", description = "등록된 모든 육아 정책 목록 조회") public ResponseEntity> getAllPolicies( @Parameter(description = "페이지 번호 (0부터)") @RequestParam(required = false) Integer page, @Parameter(description = "페이지 크기 (최대 200)") @RequestParam(required = false) Integer size) { @@ -62,7 +59,7 @@ public ResponseEntity> getAllPolicies( // 정책 상세 정보 조회 @GetMapping("/{policyId}") @LogExecutionTime - @Operation(summary = "정책 상세 조회", description = "정책 ID로 특정 육아 정책의 상세 정보를 조회합니다.") + @Operation(summary = "정책 상세 조회", description = "정책 ID로 특정 육아 정책의 상세 정보 조회") public ResponseEntity getPolicy( @Parameter(description = "정책 ID", required = true) @PathVariable Long policyId) { log.info("정책 상세 조회: 정책ID={}", policyId); @@ -79,7 +76,7 @@ public ResponseEntity getPolicy( // 정책 검색 (페이징) @PostMapping("/search") @LogExecutionTime - @Operation(summary = "정책 검색", description = "다양한 조건으로 육아 정책을 검색합니다.") + @Operation(summary = "정책 검색", description = "다양한 조건으로 육아 정책 검색") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "검색 성공", content = @Content(schema = @Schema(implementation = PolicyListResponse.class))), @@ -103,7 +100,7 @@ public ResponseEntity searchPolicies( // 카테고리별 정책 조회 @GetMapping("/category/{category}") @LogExecutionTime - @Operation(summary = "카테고리별 정책 조회", description = "특정 카테고리의 육아 정책 목록을 조회합니다.") + @Operation(summary = "카테고리별 정책 조회", description = "특정 카테고리의 육아 정책 목록 조회") public ResponseEntity> getPoliciesByCategory(@Parameter(description = "정책 카테고리", required = true) @PathVariable String category) { log.info("카테고리별 정책 조회: 카테고리={}", category); @@ -120,7 +117,7 @@ public ResponseEntity> getPoliciesByCategory(@Parameter(descript @GetMapping("/location/{location}") @LogExecutionTime @ValidateLocation - @Operation(summary = "지역별 정책 조회", description = "특정 지역의 육아 정책 목록을 조회합니다.") + @Operation(summary = "지역별 정책 조회", description = "특정 지역의 육아 정책 목록 조회") public ResponseEntity> getPoliciesByLocation( @Parameter(description = "지역명", required = true) @PathVariable String location) { log.info("지역별 정책 조회: 지역={}", location); @@ -137,7 +134,7 @@ public ResponseEntity> getPoliciesByLocation( // 연령대별 정책 조회 @GetMapping("/age") @LogExecutionTime - @Operation(summary = "연령대별 정책 조회", description = "월령 범위에 해당하는 정책을 조회합니다.") + @Operation(summary = "연령대별 정책 조회", description = "월령 범위에 해당하는 정책 조회") public ResponseEntity> getPoliciesByAgeRange( @Parameter(description = "최소 월령", example = "0", required = true) @RequestParam Integer minAge, @Parameter(description = "최대 월령", example = "71", required = true) @RequestParam Integer maxAge) { @@ -155,7 +152,7 @@ public ResponseEntity> getPoliciesByAgeRange( // 인기 정책 조회 @GetMapping("/popular") @LogExecutionTime - @Operation(summary = "인기 정책 조회", description = "인기 있는 육아 정책 목록을 조회합니다.") + @Operation(summary = "인기 정책 조회", description = "인기 있는 육아 정책 목록 조회") public ResponseEntity> getPopularPolicies(@Parameter(description = "조회할 정책 수", example = "10") @RequestParam(defaultValue = "10") Integer limit) { log.info("인기 정책 조회: 제한={}", limit); @@ -171,7 +168,7 @@ public ResponseEntity> getPopularPolicies(@Parameter(description // 최신 정책 조회 @GetMapping("/latest") @LogExecutionTime - @Operation(summary = "최신 정책 조회", description = "최근 등록된 육아 정책 목록을 조회합니다.") + @Operation(summary = "최신 정책 조회", description = "최근 등록된 육아 정책 목록 조회") public ResponseEntity> getLatestPolicies(@Parameter(description = "조회할 정책 수", example = "10") @RequestParam(defaultValue = "10") Integer limit) { log.info("최신 정책 조회: 제한={}", limit); @@ -187,7 +184,7 @@ public ResponseEntity> getLatestPolicies(@Parameter(description // 정책 조회수 증가 @PostMapping("/{policyId}/view") @LogExecutionTime - @Operation(summary = "정책 조회수 증가", description = "특정 정책의 조회수를 증가시킵니다.") + @Operation(summary = "정책 조회수 증가", description = "특정 정책의 조회수를 증가시킵니다") public ResponseEntity incrementViewCount(@Parameter(description = "정책 ID", required = true) @PathVariable Long policyId) { log.info("정책 조회수 증가: 정책ID={}", policyId); @@ -203,7 +200,7 @@ public ResponseEntity incrementViewCount(@Parameter(description = " // 정책 카테고리 목록 조회 @GetMapping("/categories") @LogExecutionTime - @Operation(summary = "정책 카테고리 목록 조회", description = "사용 가능한 정책 카테고리 목록을 조회합니다.") + @Operation(summary = "정책 카테고리 목록 조회") public ResponseEntity> getPolicyCategories() { List categories = policyFacade.getPolicyCategories(); @@ -213,7 +210,7 @@ public ResponseEntity> getPolicyCategories() { // 정책 통계 조회 @GetMapping("/statistics") @LogExecutionTime - @Operation(summary = "정책 통계 조회", description = "육아 정책 관련 통계 정보를 조회합니다.") + @Operation(summary = "정책 통계 조회", description = "육아 정책 관련 통계 정보 조회") public ResponseEntity getPolicyStatistics() { PolicyStatsSimpleResponse stats = policyFacade.getPolicyStats(); @@ -223,7 +220,7 @@ public ResponseEntity getPolicyStatistics() { // 아이 연령별 정책 조회 @GetMapping("/child-age") @LogExecutionTime - @Operation(summary = "아이 연령별 정책 조회", description = "해당 월령의 아이가 받을 수 있는 정책을 조회합니다.") + @Operation(summary = "아이 연령별 정책 조회", description = "해당 월령의 아이가 받을 수 있는 정책 조회") public ResponseEntity> getPoliciesByChildAge( @Parameter(description = "아이 월령", example = "24", required = true) @RequestParam Integer childAge) { List policies = policyFacade.getPoliciesByChildAge(childAge); @@ -234,7 +231,7 @@ public ResponseEntity> getPoliciesByChildAge( // 신청 기간이 유효한 정책 조회 @GetMapping("/active") @LogExecutionTime - @Operation(summary = "신청 기간이 유효한 정책 조회", description = "현재 신청 기간 내에 있는 정책을 조회합니다.") + @Operation(summary = "신청 기간이 유효한 정책 조회", description = "현재 신청 기간 내에 있는 정책 조회") public ResponseEntity> getActivePoliciesByDate() { List policies = policyFacade.getActivePoliciesByDate(); @@ -243,7 +240,7 @@ public ResponseEntity> getActivePoliciesByDate() { @PostMapping("/{policyId}/bookmarks") @LogExecutionTime - @Operation(summary = "정책 북마크 추가", description = "현재 로그인한 사용자의 정책 북마크를 추가합니다.") + @Operation(summary = "정책 북마크 추가", description = "현재 로그인한 사용자의 정책 북마크 추가") public ResponseEntity addBookmark( @Parameter(description = "정책 ID", required = true) @PathVariable Long policyId) { PolicyBookmarkResponse response = policyFacade.addBookmark(getAuthenticatedUserCode(), policyId); @@ -252,14 +249,14 @@ public ResponseEntity addBookmark( @GetMapping("/bookmarks") @LogExecutionTime - @Operation(summary = "정책 북마크 목록", description = "현재 로그인한 사용자의 정책 북마크 목록을 조회합니다.") + @Operation(summary = "정책 북마크 목록", description = "현재 로그인한 사용자의 정책 북마크 목록 조회") public ResponseEntity> getBookmarks() { return ResponseEntity.ok(policyFacade.getBookmarks(getAuthenticatedUserCode())); } @DeleteMapping("/{policyId}/bookmarks") @LogExecutionTime - @Operation(summary = "정책 북마크 삭제", description = "현재 로그인한 사용자의 정책 북마크를 삭제합니다.") + @Operation(summary = "정책 북마크 삭제", description = "현재 로그인한 사용자의 정책 북마크 삭제") public ResponseEntity removeBookmark( @Parameter(description = "정책 ID", required = true) @PathVariable Long policyId) { policyFacade.removeBookmark(getAuthenticatedUserCode(), policyId); @@ -269,7 +266,7 @@ public ResponseEntity removeBookmark( // 개인화 정책 추천 @GetMapping("/recommendations") @LogExecutionTime - @Operation(summary = "맞춤 정책 추천", description = "자녀 월령과 거주지에 맞는 정책을 추천합니다.") + @Operation(summary = "맞춤 정책 추천", description = "자녀 월령과 거주지에 맞는 정책을 추천") public ResponseEntity> getRecommendations( @Parameter(description = "추천 개수", example = "10") @RequestParam(defaultValue = "10") Integer limit) { return ResponseEntity.ok(policyFacade.recommendPolicies(PageRequestUtil.normalizeSize(limit))); @@ -278,7 +275,7 @@ public ResponseEntity> getRecommendations( // 놓친 지원금 발굴 @GetMapping("/missed-benefits") @LogExecutionTime - @Operation(summary = "놓친 지원금 조회", description = "자녀가 대상이었으나 지나간 지원금과 소급 가능 여부를 조회합니다.") + @Operation(summary = "놓친 지원금 조회", description = "자녀가 대상이었으나 지나간 지원금과 소급 가능 여부 조회") public ResponseEntity getMissedBenefits() { return ResponseEntity.ok(policyFacade.findMissedBenefits()); } @@ -286,7 +283,7 @@ public ResponseEntity getMissedBenefits() { // 거주지별 지원금 비교 @GetMapping("/regional-comparison") @LogExecutionTime - @Operation(summary = "거주지별 지원금 비교", description = "지역별 예상 수령액을 계산해 현재 거주지와 비교합니다.") + @Operation(summary = "거주지별 지원금 비교", description = "지역별 예상 수령액을 계산해 현재 거주지와 비교") public ResponseEntity compareRegionalBenefits( @Parameter(description = "자녀 ID (미지정 시 최근 등록 자녀)") @RequestParam(required = false) Long childId, @Parameter(description = "전망 기간(년)", example = "5") @RequestParam(required = false) Integer years, diff --git a/src/main/java/com/carecode/domain/policy/dto/request/PolicyBookmarkRequest.java b/src/main/java/com/carecode/domain/policy/dto/request/PolicyBookmarkRequest.java index 54a42009..75ca70a0 100644 --- a/src/main/java/com/carecode/domain/policy/dto/request/PolicyBookmarkRequest.java +++ b/src/main/java/com/carecode/domain/policy/dto/request/PolicyBookmarkRequest.java @@ -8,9 +8,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 정책 북마크 요청 - */ +/** 정책 북마크 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/dto/request/PolicyCategoryRequest.java b/src/main/java/com/carecode/domain/policy/dto/request/PolicyCategoryRequest.java index 770bb9ec..610d0d99 100644 --- a/src/main/java/com/carecode/domain/policy/dto/request/PolicyCategoryRequest.java +++ b/src/main/java/com/carecode/domain/policy/dto/request/PolicyCategoryRequest.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 정책 카테고리 조회 요청 - */ +/** 정책 카테고리 조회 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/dto/request/PolicyCreateRequest.java b/src/main/java/com/carecode/domain/policy/dto/request/PolicyCreateRequest.java index a78ae2b4..8954cc9e 100644 --- a/src/main/java/com/carecode/domain/policy/dto/request/PolicyCreateRequest.java +++ b/src/main/java/com/carecode/domain/policy/dto/request/PolicyCreateRequest.java @@ -7,9 +7,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 정책 생성 요청 - */ +/** 정책 생성 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/dto/request/PolicyDeleteRequest.java b/src/main/java/com/carecode/domain/policy/dto/request/PolicyDeleteRequest.java index a627886e..e5ac683b 100644 --- a/src/main/java/com/carecode/domain/policy/dto/request/PolicyDeleteRequest.java +++ b/src/main/java/com/carecode/domain/policy/dto/request/PolicyDeleteRequest.java @@ -7,9 +7,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 정책 삭제 요청 - */ +/** 정책 삭제 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/dto/request/PolicyDetailRequest.java b/src/main/java/com/carecode/domain/policy/dto/request/PolicyDetailRequest.java index 1c98fe7a..3fa413f5 100644 --- a/src/main/java/com/carecode/domain/policy/dto/request/PolicyDetailRequest.java +++ b/src/main/java/com/carecode/domain/policy/dto/request/PolicyDetailRequest.java @@ -7,9 +7,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 정책 상세 조회 요청 - */ +/** 정책 상세 조회 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/dto/request/PolicyListRequest.java b/src/main/java/com/carecode/domain/policy/dto/request/PolicyListRequest.java index 43860b64..0f72e01a 100644 --- a/src/main/java/com/carecode/domain/policy/dto/request/PolicyListRequest.java +++ b/src/main/java/com/carecode/domain/policy/dto/request/PolicyListRequest.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 정책 목록 조회 요청 - */ +/** 정책 목록 조회 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/dto/request/PolicySearchRequest.java b/src/main/java/com/carecode/domain/policy/dto/request/PolicySearchRequest.java index e328cebd..9531af27 100644 --- a/src/main/java/com/carecode/domain/policy/dto/request/PolicySearchRequest.java +++ b/src/main/java/com/carecode/domain/policy/dto/request/PolicySearchRequest.java @@ -8,9 +8,7 @@ import java.time.LocalDate; -/** - * 정책 검색 요청 - */ +/** 정책 검색 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/dto/request/PolicyUpdateRequest.java b/src/main/java/com/carecode/domain/policy/dto/request/PolicyUpdateRequest.java index 3de35d1c..73d4e5bf 100644 --- a/src/main/java/com/carecode/domain/policy/dto/request/PolicyUpdateRequest.java +++ b/src/main/java/com/carecode/domain/policy/dto/request/PolicyUpdateRequest.java @@ -7,9 +7,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 정책 수정 요청 - */ +/** 정책 수정 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/dto/response/PolicyBookmarkInfoResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/PolicyBookmarkInfoResponse.java index 93f6f42a..8992da72 100644 --- a/src/main/java/com/carecode/domain/policy/dto/response/PolicyBookmarkInfoResponse.java +++ b/src/main/java/com/carecode/domain/policy/dto/response/PolicyBookmarkInfoResponse.java @@ -8,9 +8,7 @@ import java.time.LocalDateTime; -/** - * 정책 북마크 정보 응답 - */ +/** 정책 북마크 정보 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/dto/response/PolicyBookmarkListResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/PolicyBookmarkListResponse.java index f80d51f0..b895dcb1 100644 --- a/src/main/java/com/carecode/domain/policy/dto/response/PolicyBookmarkListResponse.java +++ b/src/main/java/com/carecode/domain/policy/dto/response/PolicyBookmarkListResponse.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 정책 북마크 목록 응답 - */ +/** 정책 북마크 목록 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/dto/response/PolicyBookmarkResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/PolicyBookmarkResponse.java index e7b73b75..bfbfa145 100644 --- a/src/main/java/com/carecode/domain/policy/dto/response/PolicyBookmarkResponse.java +++ b/src/main/java/com/carecode/domain/policy/dto/response/PolicyBookmarkResponse.java @@ -8,9 +8,7 @@ import java.time.LocalDateTime; -/** - * 정책 북마크 응답 - */ +/** 정책 북마크 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/dto/response/PolicyCategoryInfoResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/PolicyCategoryInfoResponse.java index 038b3db6..b68160e8 100644 --- a/src/main/java/com/carecode/domain/policy/dto/response/PolicyCategoryInfoResponse.java +++ b/src/main/java/com/carecode/domain/policy/dto/response/PolicyCategoryInfoResponse.java @@ -8,9 +8,7 @@ import java.time.LocalDateTime; -/** - * 정책 카테고리 정보 응답 - */ +/** 정책 카테고리 정보 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/dto/response/PolicyCategoryListResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/PolicyCategoryListResponse.java index 37cf84d9..88910648 100644 --- a/src/main/java/com/carecode/domain/policy/dto/response/PolicyCategoryListResponse.java +++ b/src/main/java/com/carecode/domain/policy/dto/response/PolicyCategoryListResponse.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 정책 카테고리 목록 응답 - */ +/** 정책 카테고리 목록 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/dto/response/PolicyCategoryResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/PolicyCategoryResponse.java index 21ce1ac3..6f76daac 100644 --- a/src/main/java/com/carecode/domain/policy/dto/response/PolicyCategoryResponse.java +++ b/src/main/java/com/carecode/domain/policy/dto/response/PolicyCategoryResponse.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 정책 카테고리 응답 - */ +/** 정책 카테고리 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/dto/response/PolicyCategoryStatsResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/PolicyCategoryStatsResponse.java index e8d40119..c76564a3 100644 --- a/src/main/java/com/carecode/domain/policy/dto/response/PolicyCategoryStatsResponse.java +++ b/src/main/java/com/carecode/domain/policy/dto/response/PolicyCategoryStatsResponse.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 카테고리별 통계 응답 - */ +/** 카테고리별 통계 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/dto/response/PolicyDetailResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/PolicyDetailResponse.java index 47a7110d..09dbefc9 100644 --- a/src/main/java/com/carecode/domain/policy/dto/response/PolicyDetailResponse.java +++ b/src/main/java/com/carecode/domain/policy/dto/response/PolicyDetailResponse.java @@ -9,9 +9,7 @@ import java.util.List; import java.util.Map; -/** - * 정책 상세 정보 응답 - */ +/** 정책 상세 정보 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/dto/response/PolicyDto.java b/src/main/java/com/carecode/domain/policy/dto/response/PolicyDto.java index 407a3cb7..361e902e 100644 --- a/src/main/java/com/carecode/domain/policy/dto/response/PolicyDto.java +++ b/src/main/java/com/carecode/domain/policy/dto/response/PolicyDto.java @@ -36,4 +36,3 @@ public class PolicyDto { private LocalDateTime updatedAt; } - diff --git a/src/main/java/com/carecode/domain/policy/dto/response/PolicyInfoResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/PolicyInfoResponse.java index 1499a9d6..0a3f6ce4 100644 --- a/src/main/java/com/carecode/domain/policy/dto/response/PolicyInfoResponse.java +++ b/src/main/java/com/carecode/domain/policy/dto/response/PolicyInfoResponse.java @@ -9,9 +9,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; -/** - * 정책 정보 응답 - */ +/** 정책 정보 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/dto/response/PolicyListResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/PolicyListResponse.java index e515246c..b64603ec 100644 --- a/src/main/java/com/carecode/domain/policy/dto/response/PolicyListResponse.java +++ b/src/main/java/com/carecode/domain/policy/dto/response/PolicyListResponse.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 정책 목록 응답 - */ +/** 정책 목록 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/dto/response/PolicyRecommendationResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/PolicyRecommendationResponse.java index 0f2e2b4e..2c5c50f9 100644 --- a/src/main/java/com/carecode/domain/policy/dto/response/PolicyRecommendationResponse.java +++ b/src/main/java/com/carecode/domain/policy/dto/response/PolicyRecommendationResponse.java @@ -9,9 +9,7 @@ import java.util.List; import java.util.Map; -/** - * 정책 추천 응답 - */ +/** 정책 추천 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/dto/response/PolicySearchResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/PolicySearchResponse.java index 626a45df..b1227824 100644 --- a/src/main/java/com/carecode/domain/policy/dto/response/PolicySearchResponse.java +++ b/src/main/java/com/carecode/domain/policy/dto/response/PolicySearchResponse.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 정책 검색 응답 - */ +/** 정책 검색 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/dto/response/PolicyStatsResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/PolicyStatsResponse.java index b56ea70f..87bf226d 100644 --- a/src/main/java/com/carecode/domain/policy/dto/response/PolicyStatsResponse.java +++ b/src/main/java/com/carecode/domain/policy/dto/response/PolicyStatsResponse.java @@ -9,9 +9,7 @@ import java.util.List; import java.util.Map; -/** - * 정책 통계 응답 - */ +/** 정책 통계 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/dto/response/PolicyStatsSimpleResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/PolicyStatsSimpleResponse.java index f27d2515..af026522 100644 --- a/src/main/java/com/carecode/domain/policy/dto/response/PolicyStatsSimpleResponse.java +++ b/src/main/java/com/carecode/domain/policy/dto/response/PolicyStatsSimpleResponse.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 정책 통계 응답 (간단 버전) - */ +/** 정책 통계 응답 (간단 버전) */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/dto/response/RegionalBenefitComparisonResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/RegionalBenefitComparisonResponse.java index 23f1a9a4..f4b2b65b 100644 --- a/src/main/java/com/carecode/domain/policy/dto/response/RegionalBenefitComparisonResponse.java +++ b/src/main/java/com/carecode/domain/policy/dto/response/RegionalBenefitComparisonResponse.java @@ -23,10 +23,7 @@ public class RegionalBenefitComparisonResponse { /** 총액 내림차순. */ private List rankings; - /** - * 데이터 신뢰 수준. 지자체 정책 수집이 불완전하면 실제와 차이가 날 수 있어 함께 노출한다. - * VERIFIED / ESTIMATED - */ + /** 데이터 신뢰 수준. 지자체 정책 수집이 불완전하면 실제와 차이가 날 수 있어 함께 노출한다. */ private String dataQuality; private List disclaimers; diff --git a/src/main/java/com/carecode/domain/policy/entity/Policy.java b/src/main/java/com/carecode/domain/policy/entity/Policy.java index ec33b895..200d68f2 100644 --- a/src/main/java/com/carecode/domain/policy/entity/Policy.java +++ b/src/main/java/com/carecode/domain/policy/entity/Policy.java @@ -12,10 +12,7 @@ import java.util.ArrayList; import java.util.List; -/** - * 육아 정책 엔티티 - * 정부에서 제공하는 육아 관련 정책 정보를 관리 - */ +/** 육아 정책 엔티티 정부에서 제공하는 육아 관련 정책 정보를 관리 */ @Entity @Table(name = "TBL_POLICIES") @Getter @@ -67,10 +64,7 @@ public class Policy { @Column(name = "benefit_amount") private Integer benefitAmount; - /** - * 월 지급 정책의 최대 지급 개월. null 이면 대상 연령 구간 내내 지급한다. - * 대상 연령과 지급 기간은 다르다 — 육아휴직급여는 아이가 0~96개월이어도 최대 12개월만 받는다. - */ + /** 월 지급 정책의 최대 지급 개월. null 이면 대상 연령 구간 내내 지급한다. */ @Column(name = "max_payment_months") private Integer maxPaymentMonths; diff --git a/src/main/java/com/carecode/domain/policy/entity/PolicyCategory.java b/src/main/java/com/carecode/domain/policy/entity/PolicyCategory.java index bb03f27b..0caf4762 100644 --- a/src/main/java/com/carecode/domain/policy/entity/PolicyCategory.java +++ b/src/main/java/com/carecode/domain/policy/entity/PolicyCategory.java @@ -13,13 +13,7 @@ import java.util.ArrayList; import java.util.List; -/** - * 정책 카테고리 엔티티 - * - * @author CareCode Team - * @since 1.0.0 - * @see Policy - */ +/** 정책 카테고리 엔티티 */ @Entity @Table(name = "TBL_POLICY_CATEGORIES") @Getter @@ -27,7 +21,6 @@ @EntityListeners(AuditingEntityListener.class) public class PolicyCategory { - // 카테고리 고유 식별자 @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/carecode/domain/policy/mapper/PolicyMapper.java b/src/main/java/com/carecode/domain/policy/mapper/PolicyMapper.java index 7b2db725..b5f1b4ba 100644 --- a/src/main/java/com/carecode/domain/policy/mapper/PolicyMapper.java +++ b/src/main/java/com/carecode/domain/policy/mapper/PolicyMapper.java @@ -45,4 +45,3 @@ private String formatApplicationPeriod(LocalDate startDate, LocalDate endDate) { private String formatWebsiteUrl(String applicationUrl) { return applicationUrl; } } - diff --git a/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java b/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java index e88c37da..1161850c 100644 --- a/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java +++ b/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java @@ -14,9 +14,7 @@ import java.util.List; import java.util.Optional; -/** - * 정책 리포지토리 인터페이스 - */ +/** 정책 리포지토리 인터페이스 */ @Repository public interface PolicyRepository extends JpaRepository { @@ -72,31 +70,23 @@ List searchPolicies(@Param("policyType") String policyType, @Param("targetRegion") String targetRegion, @Param("benefitType") String benefitType, @Param("childAge") Integer childAge); - // 연령대별 정책 조회 - @Query("SELECT p FROM Policy p WHERE p.isActive = true AND " + "p.targetAgeMin <= :maxAge AND p.targetAgeMax >= :minAge") List findByAgeRange(@Param("minAge") int minAge, @Param("maxAge") int maxAge); - // 인기 정책 조회 (우선순위 기준) - @Query("SELECT p FROM Policy p WHERE p.isActive = true " + "ORDER BY p.priority DESC, p.createdAt DESC") List findPopularPolicies(Pageable pageable); - // 최신 정책 조회 - @Query("SELECT p FROM Policy p WHERE p.isActive = true " + "ORDER BY p.createdAt DESC") List findLatestPolicies(Pageable pageable); - // 검색 조건으로 정책 검색 (페이징) - @Query("SELECT p FROM Policy p WHERE p.isActive = true " + "AND (:keyword IS NULL OR p.title LIKE %:keyword% OR p.description LIKE %:keyword%) " + "AND (:category IS NULL OR p.policyType = :category) " + @@ -106,33 +96,25 @@ List searchPolicies(@Param("policyType") String policyType, Page findBySearchCriteria(@Param("keyword") String keyword, @Param("category") String category, @Param("location") String location, @Param("minAge") Integer minAge, @Param("maxAge") Integer maxAge, Pageable pageable); - @Query("SELECT COALESCE(SUM(p.viewCount), 0) FROM Policy p WHERE p.isActive = true") long getTotalViewCount(); - // 카테고리별 통계 조회 - // HQL 의 생성자 표현식은 완전한 패키지 경로를 요구한다. - // 실제 클래스는 dto.response 패키지에 있으므로 경로가 틀리면 기동 시점에 실패한다. + // HQL 의 생성자 표현식은 완전한 패키지 경로를 요구한다. 실제 클래스는 dto.response 패키지에 있으므로 경로가 틀리면 기동 시점에 실패한다. @Query("SELECT new com.carecode.domain.policy.dto.response.PolicyCategoryStatsResponse(" + "p.policyType, COUNT(p), 0.0, 0) " + "FROM Policy p WHERE p.isActive = true " + "GROUP BY p.policyType") List getCategoryStats(); - /** - * 조회수를 DB 에서 원자적으로 증가시킨다. - * 엔티티를 읽어 +1 후 save 하면 동시 요청 시 증가분이 유실된다(lost update). - */ + /** 조회수를 DB 에서 원자적으로 증가시킨다. */ @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("UPDATE Policy p SET p.viewCount = COALESCE(p.viewCount, 0) + 1 WHERE p.id = :policyId") int incrementViewCount(@Param("policyId") Long policyId); - /** - * 중복 없는 정책 유형 목록. 전체 행을 메모리로 올려 distinct 하지 않는다. - */ + /** 중복 없는 정책 유형 목록. 전체 행을 메모리로 올려 distinct 하지 않는다. */ @Query("SELECT DISTINCT p.policyType FROM Policy p " + "WHERE p.policyType IS NOT NULL AND p.policyType <> '' " + "ORDER BY p.policyType") diff --git a/src/main/java/com/carecode/domain/policy/service/MissedBenefitService.java b/src/main/java/com/carecode/domain/policy/service/MissedBenefitService.java index e75084ff..c8ae14d6 100644 --- a/src/main/java/com/carecode/domain/policy/service/MissedBenefitService.java +++ b/src/main/java/com/carecode/domain/policy/service/MissedBenefitService.java @@ -20,10 +20,7 @@ import java.util.Comparator; import java.util.List; -/** - * 아이가 이미 지나온 월령 구간을 훑어 받을 수 있었던 지원금을 찾는다. - * 수급 여부를 알 수 없으므로 "받았다/못 받았다" 가 아니라 "대상이었다" 로만 판정한다. - */ +/** 아이가 이미 지나온 월령 구간을 훑어 받을 수 있었던 지원금을 찾는다. */ @Slf4j @Service @RequiredArgsConstructor diff --git a/src/main/java/com/carecode/domain/policy/service/PolicyInitializationService.java b/src/main/java/com/carecode/domain/policy/service/PolicyInitializationService.java index da6166a0..c7c74e1e 100644 --- a/src/main/java/com/carecode/domain/policy/service/PolicyInitializationService.java +++ b/src/main/java/com/carecode/domain/policy/service/PolicyInitializationService.java @@ -15,10 +15,7 @@ import java.util.Arrays; import java.util.List; -/** - * 정책 관련 초기 데이터를 생성하는 서비스 - * 서버 시작 시 실제 대한민국 육아 정책 데이터를 자동으로 생성합니다. - */ +/** 정책 관련 초기 데이터를 생성하는 서비스 */ @Slf4j @Service @Profile("dev") @@ -50,9 +47,7 @@ public void run(String... args) throws Exception { } } - // 정책 카테고리 생성 - private List createPolicyCategories() { if (policyCategoryRepository.count() > 0) { log.info("정책 카테고리가 이미 존재하므로 생성을 건너뜁니다."); @@ -85,9 +80,7 @@ private List createPolicyCategories() { return savedCategories; } - // 실제 대한민국 육아 정책 생성 - private void createPolicies(List categories) { if (policyRepository.count() > 0) { log.info("정책이 이미 존재하므로 생성을 건너뜁니다."); @@ -106,9 +99,7 @@ private void createPolicies(List categories) { log.info("정책 데이터 생성 완료"); } - // 출산・육아휴직 정책 - private void createMaternityAndChildcareLeave(PolicyCategory category) { List policies = Arrays.asList( Policy.builder() @@ -171,9 +162,7 @@ private void createMaternityAndChildcareLeave(PolicyCategory category) { log.info("출산・육아휴직 정책 {}개 생성 완료", policies.size()); } - // 양육수당・보육료 정책 - private void createChildcareAllowances(PolicyCategory category) { List policies = Arrays.asList( Policy.builder() @@ -271,9 +260,7 @@ private void createChildcareAllowances(PolicyCategory category) { log.info("양육수당・보육료 정책 {}개 생성 완료", policies.size()); } - // 돌봄서비스 정책 - private void createCareServices(PolicyCategory category) { List policies = Arrays.asList( Policy.builder() @@ -332,9 +319,7 @@ private void createCareServices(PolicyCategory category) { log.info("돌봄서비스 정책 {}개 생성 완료", policies.size()); } - // 의료・건강 정책 - private void createHealthcareSupport(PolicyCategory category) { List policies = Arrays.asList( Policy.builder() @@ -394,9 +379,7 @@ private void createHealthcareSupport(PolicyCategory category) { log.info("의료・건강 정책 {}개 생성 완료", policies.size()); } - // 교육지원 정책 - private void createEducationSupport(PolicyCategory category) { List policies = Arrays.asList( Policy.builder() @@ -439,9 +422,7 @@ private void createEducationSupport(PolicyCategory category) { log.info("교육지원 정책 {}개 생성 완료", policies.size()); } - // 주거지원 정책 - private void createHousingSupport(PolicyCategory category) { List policies = Arrays.asList( Policy.builder() @@ -483,9 +464,7 @@ private void createHousingSupport(PolicyCategory category) { log.info("주거지원 정책 {}개 생성 완료", policies.size()); } - // 다자녀혜택 정책 - private void createMultiChildBenefits(PolicyCategory category) { List policies = Arrays.asList( Policy.builder() diff --git a/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java b/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java index 6247a0b2..9aecead1 100644 --- a/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java +++ b/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java @@ -103,10 +103,7 @@ private int score(Policy policy, User user, List children, LocalDate toda return score; } - /** - * 소득·자녀수 요건을 확인한다. - * 소득 미입력 사용자를 탈락시키면 받을 수 있는 정책이 통째로 사라지므로, 안내만 붙이고 통과시킨다. - */ + /** 소득·자녀수 요건을 확인한다. */ private boolean meetsHouseholdConditions(Policy policy, User user, int childCount, List reasons) { Integer minChildren = policy.getMinChildren(); if (minChildren != null) { diff --git a/src/main/java/com/carecode/domain/policy/service/PolicyService.java b/src/main/java/com/carecode/domain/policy/service/PolicyService.java index 0511d467..924c72b8 100644 --- a/src/main/java/com/carecode/domain/policy/service/PolicyService.java +++ b/src/main/java/com/carecode/domain/policy/service/PolicyService.java @@ -30,10 +30,7 @@ import java.util.List; import java.util.stream.Collectors; -/** - * 정책 서비스 클래스 - * 육아 지원 정책 관련 비즈니스 로직 처리 - */ +/** 정책 서비스 클래스 육아 지원 정책 관련 비즈니스 로직 처리 */ @Service @RequiredArgsConstructor @Slf4j @@ -45,13 +42,9 @@ public class PolicyService { private final UserRepository userRepository; private final PolicyMapper policyMapper; - // 정책 목록 조회 - /** - * 정책 목록 조회. - *

테이블 전체를 메모리로 올리지 않도록 항상 페이지 단위로 읽는다. - */ + /** 정책 목록 조회. 테이블 전체를 메모리로 올리지 않도록 항상 페이지 단위로 읽는다. */ @LogExecutionTime public List getAllPolicies(int page, int size) { log.info("전체 정책 목록 조회 - page={}, size={}", page, size); @@ -62,9 +55,7 @@ public List getAllPolicies(int page, int size) { .collect(Collectors.toList()); } - // 정책 상세 조회 - @LogExecutionTime @Cacheable(cacheNames = "policy", key = "#policyId") public PolicyDto getPolicyById(Long policyId) { @@ -197,7 +188,6 @@ public List getLatestPolicies(int limit) { .collect(Collectors.toList()); } - @Transactional public void incrementViewCount(Long policyId) { log.info("정책 조회수 증가: 정책ID={}", policyId); @@ -208,9 +198,7 @@ public void incrementViewCount(Long policyId) { } } - // 정책 카테고리 목록 조회 - @LogExecutionTime public List getPolicyCategories() { log.info("정책 카테고리 목록 조회"); @@ -218,9 +206,7 @@ public List getPolicyCategories() { return policyRepository.findDistinctPolicyTypes(); } - // 정책 통계 조회 - @LogExecutionTime public PolicyStatsSimpleResponse getPolicyStats() { long totalPolicies = policyRepository.count(); @@ -245,7 +231,6 @@ public List getPoliciesByChildAge(Integer childAge) { .collect(Collectors.toList()); } - // 신청 기간이 유효한 정책 조회 @LogExecutionTime public List getActivePoliciesByDate() { @@ -303,5 +288,4 @@ private PolicyBookmarkResponse toBookmarkResponse(PolicyBookmark bookmark) { .build(); } - } \ No newline at end of file diff --git a/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java b/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java index 51288040..6d50fb49 100644 --- a/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java +++ b/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java @@ -25,10 +25,7 @@ import java.util.Map; import java.util.stream.Collectors; -/** - * 같은 아이라도 사는 지역에 따라 받는 지원금 총액이 크게 다르다. - * 지역별 예상 수령액을 계산해 현재 거주지와 비교한다. - */ +/** 같은 아이라도 사는 지역에 따라 받는 지원금 총액이 크게 다르다. 지역별 예상 수령액을 계산해 현재 거주지와 비교한다. */ @Slf4j @Service @RequiredArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/app/UserFacade.java b/src/main/java/com/carecode/domain/user/app/UserFacade.java index 17446c2d..f29e2d1b 100644 --- a/src/main/java/com/carecode/domain/user/app/UserFacade.java +++ b/src/main/java/com/carecode/domain/user/app/UserFacade.java @@ -54,4 +54,3 @@ public User getUserEntityByEmail(String email) { } } - diff --git a/src/main/java/com/carecode/domain/user/controller/AuthController.java b/src/main/java/com/carecode/domain/user/controller/AuthController.java index 7c6d746d..b3cb9912 100644 --- a/src/main/java/com/carecode/domain/user/controller/AuthController.java +++ b/src/main/java/com/carecode/domain/user/controller/AuthController.java @@ -31,10 +31,7 @@ import java.time.LocalDateTime; import com.carecode.core.handler.ApiSuccess; -/** - * 통합 인증 컨트롤러 - * 일반 로그인, 카카오 로그인, 토큰 갱신, 회원가입 등 모든 인증 관련 API - */ +/** 통합 인증 컨트롤러 */ @RestController @RequestMapping("/auth") @RequiredArgsConstructor @@ -51,18 +48,16 @@ public class AuthController extends BaseController { private final CurrentUserFacade currentUserFacade; private final RefreshTokenCookieFactory refreshTokenCookieFactory; - // ==================== 일반 로그인 ==================== - + // ==================== + // 일반 로그인 ==================== // 일반 로그인 - @PostMapping("/login") @LogExecutionTime(warnThreshold = 1000) @RateLimit(requests = 5, windowSeconds = 60, message = "로그인 시도 횟수를 초과했습니다. 1분 후 다시 시도해주세요.") - @Operation(summary = "일반 로그인", description = "이메일과 비밀번호로 로그인합니다.") + @Operation(summary = "일반 로그인", description = "이메일과 비밀번호로 로그인") public ResponseEntity login(@Parameter(description = "로그인 정보", required = true) @Valid @RequestBody LoginRequestDto request) { - // 가입 여부가 응답으로 새어나가지 않도록, 실패 사유와 무관하게 동일한 401 을 반환한다. - // (미가입 404 / 비밀번호 불일치 401 로 나뉘면 이메일 열거가 가능하다.) + // 가입 여부가 응답으로 새어나가지 않도록, 실패 사유와 무관하게 동일한 401 을 반환한다. (미가입 404 / 비밀번호 불일치 401 로 나뉘면 이메일 열거가 가능하다.) User userEntity = userService.findActiveUserEntityByEmail(request.getEmail()).orElse(null); if (userEntity == null @@ -87,7 +82,7 @@ public ResponseEntity login(@Parameter(description = "로그인 정보 // 회원가입 @PostMapping("/register") @LogExecutionTime - @Operation(summary = "회원가입", description = "새로운 사용자를 등록합니다.") + @Operation(summary = "회원가입", description = "새로운 사용자 등록") public ResponseEntity register(@Parameter(description = "회원가입 정보", required = true) @Valid @RequestBody UserDto request) { UserDto createdUser = userService.createUser(request); User user = userService.getUserEntityByEmail(createdUser.getEmail()); @@ -96,10 +91,7 @@ public ResponseEntity register(@Parameter(description = "회원가입 return withRefreshCookie(tokenDto); } - /** - * 발급된 리프레시 토큰을 HttpOnly 쿠키로도 내려보낸다. - * 본문에도 남겨 두어 쿠키를 쓰지 않는 클라이언트가 계속 동작하게 한다. - */ + /** 발급된 리프레시 토큰을 HttpOnly 쿠키로도 내려보낸다. */ private ResponseEntity withRefreshCookie(TokenDto tokenDto) { if (tokenDto.getRefreshToken() == null) { return ResponseEntity.ok(tokenDto); @@ -110,14 +102,14 @@ private ResponseEntity withRefreshCookie(TokenDto tokenDto) { .body(tokenDto); } - // ==================== 토큰 관리 ==================== + // ==================== + // 토큰 관리 ==================== // 토큰 갱신 - @PostMapping("/refresh") @LogExecutionTime @Operation(summary = "토큰 갱신", - description = "Refresh Token 으로 새로운 Access Token 을 발급합니다. " + description = "Refresh Token 으로 새로운 Access Token 을 발급" + "토큰은 HttpOnly 쿠키에서 우선 읽고, 없으면 요청 본문에서 읽습니다.") public ResponseEntity refreshToken( @Parameter(description = "토큰 갱신 정보 (쿠키를 쓰는 경우 생략 가능)") @@ -178,7 +170,7 @@ private ResponseEntity unauthorizedRefresh(String message) { @PostMapping("/logout") @LogExecutionTime - @Operation(summary = "로그아웃", description = "서버에 등록된 리프레시 토큰 세션을 모두 폐기합니다. (액세스 토큰은 만료 시까지 유효할 수 있음)") + @Operation(summary = "로그아웃", description = "서버에 등록된 리프레시 토큰 세션을 모두 폐기합니다") public ResponseEntity logout() { refreshTokenStore.removeAllForUser(currentUserFacade.requireCurrentUserId()); return ResponseEntity.ok() @@ -186,12 +178,13 @@ public ResponseEntity logout() { .body(ApiSuccess.of("로그아웃되었습니다.")); } - // ==================== 이메일 인증 ==================== + // ==================== + // 이메일 인증 ==================== // 이메일 인증 토큰 검증 @GetMapping("/verify") @LogExecutionTime - @Operation(summary = "이메일 인증", description = "이메일 인증 토큰을 검증합니다.") + @Operation(summary = "이메일 인증", description = "이메일 인증 토큰을 검증") public ResponseEntity verifyEmail(@RequestParam String token) { boolean ok = emailVerificationService.verifyEmail(token); String message = ok ? "이메일 인증이 완료되었습니다." : "유효하지 않거나 만료된 토큰입니다."; @@ -201,7 +194,7 @@ public ResponseEntity verifyEmail(@RequestParam String token) { // 인증 코드 발송 @PostMapping("/send-code") @LogExecutionTime - @Operation(summary = "이메일 인증 코드 발송", description = "해당 이메일로 인증 코드를 발송합니다.") + @Operation(summary = "이메일 인증 코드 발송") public ResponseEntity sendVerificationCode(@RequestParam String email) { emailVerificationService.sendVerificationCode(email); return ResponseEntity.ok(ApiSuccess.of("인증 코드가 발송되었습니다.")); @@ -210,7 +203,7 @@ public ResponseEntity sendVerificationCode(@RequestParam String emai // 인증 코드 검증 @PostMapping("/verify-code") @LogExecutionTime - @Operation(summary = "이메일 인증 코드 검증", description = "발송된 인증 코드를 검증합니다.") + @Operation(summary = "이메일 인증 코드 검증", description = "발송된 인증 코드를 검증") public ResponseEntity verifyCode(@RequestParam String email, @RequestParam String code) { boolean ok = emailVerificationService.verifyCode(email, code); String message = ok ? "인증 코드가 확인되었습니다." : "유효하지 않거나 만료된 코드입니다."; diff --git a/src/main/java/com/carecode/domain/user/controller/KakaoAuthController.java b/src/main/java/com/carecode/domain/user/controller/KakaoAuthController.java index f64f5cc9..c655f47e 100644 --- a/src/main/java/com/carecode/domain/user/controller/KakaoAuthController.java +++ b/src/main/java/com/carecode/domain/user/controller/KakaoAuthController.java @@ -23,10 +23,7 @@ import java.util.Map; -/** - * 카카오 로그인 관련 통합 컨트롤러 - * 카카오 OAuth 로그인, 회원가입 완료, 토큰 갱신 등을 처리 - */ +/** 카카오 로그인 관련 통합 컨트롤러 */ @RestController @RequestMapping("/auth/kakao") @RequiredArgsConstructor @@ -42,7 +39,7 @@ public class KakaoAuthController extends BaseController { @PostMapping("/login") @LogExecutionTime - @Operation(summary = "카카오 OAuth 로그인/회원가입", description = "카카오 인증 코드를 받아 로그인하거나 신규 사용자를 생성합니다.") + @Operation(summary = "카카오 OAuth 로그인/회원가입", description = "카카오 인증 코드를 받아 로그인하거나 신규 사용자 생성") public ResponseEntity kakaoLogin( @Parameter(description = "카카오 인증 코드", required = true) @RequestParam String code) { log.info("카카오 OAuth 로그인 요청 수신 (authorization code는 로그에 기록하지 않음)"); @@ -61,7 +58,7 @@ public ResponseEntity kakaoLogin( @PostMapping("/complete-registration") @LogExecutionTime - @Operation(summary = "카카오 신규 사용자 가입 완료", description = "카카오 로그인 후 신규 사용자의 이름과 역할을 설정하여 가입을 완료합니다.") + @Operation(summary = "카카오 신규 사용자 가입 완료", description = "카카오 로그인 후 신규 사용자의 이름과 역할을 설정하여 가입을 완료") public ResponseEntity completeRegistration( @Parameter(description = "가입 완료 정보", required = true) @Valid @RequestBody KakaoRegistrationRequest request) { String email = currentUserFacade.requireCurrentUserEmail(); @@ -72,7 +69,7 @@ public ResponseEntity completeRegistration( @GetMapping("/login-url") @LogExecutionTime - @Operation(summary = "카카오 로그인 URL 생성", description = "카카오 OAuth 로그인을 위한 URL을 생성합니다.") + @Operation(summary = "카카오 로그인 URL 생성", description = "카카오 OAuth 로그인을 위한 URL 생성") public ResponseEntity> getKakaoLoginUrl() { log.debug("카카오 로그인 URL 생성 요청"); String kakaoLoginUrl = kakaoUtil.buildAuthorizationUrl(); diff --git a/src/main/java/com/carecode/domain/user/controller/PrivacyController.java b/src/main/java/com/carecode/domain/user/controller/PrivacyController.java index f2fb51f9..e9369cb8 100644 --- a/src/main/java/com/carecode/domain/user/controller/PrivacyController.java +++ b/src/main/java/com/carecode/domain/user/controller/PrivacyController.java @@ -16,9 +16,7 @@ import java.util.List; import java.util.Map; -/** - * 개인정보 관련 API: 동의 관리, 내 데이터 열람, 회원 탈퇴. - */ +/** 개인정보 관련 API: 동의 관리, 내 데이터 열람, 회원 탈퇴. */ @RestController @RequestMapping("/users/privacy") @RequiredArgsConstructor @@ -30,7 +28,7 @@ public class PrivacyController { @GetMapping("/consents") @LogExecutionTime - @Operation(summary = "동의 상태 조회", description = "항목별 현재 동의 여부와 동의한 약관 버전을 반환합니다.") + @Operation(summary = "동의 상태 조회", description = "항목별 현재 동의 여부와 동의한 약관 버전 반환") public ResponseEntity getConsents() { return ResponseEntity.ok(privacyService.getConsentStatus()); } @@ -38,7 +36,7 @@ public ResponseEntity getConsents() { @PostMapping("/consents") @LogExecutionTime @Operation(summary = "동의/철회 기록", - description = "동의 이력은 덮어쓰지 않고 새 기록으로 남습니다.") + description = "동의 이력은 덮어쓰지 않고 새 기록으로 남습니다") public ResponseEntity updateConsent( @Valid @RequestBody ConsentUpdateRequest request, HttpServletRequest httpRequest) { @@ -56,7 +54,7 @@ public ResponseEntity> getConsent @GetMapping("/export") @LogExecutionTime @Operation(summary = "내 데이터 내려받기", - description = "개인정보 열람권 행사를 위한 데이터 export. 인증 정보는 포함하지 않습니다.") + description = "개인정보 열람권 행사를 위한 데이터 export") public ResponseEntity> exportMyData() { return ResponseEntity.ok(privacyService.exportMyData()); } @@ -64,7 +62,7 @@ public ResponseEntity> exportMyData() { @DeleteMapping("/account") @LogExecutionTime @Operation(summary = "회원 탈퇴", - description = "개인 식별 정보를 익명화하고 계정을 비활성화합니다.") + description = "개인 식별 정보를 익명화하고 계정을 비활성화") public ResponseEntity deleteAccount() { privacyService.deleteMyAccount(); return ResponseEntity.noContent().build(); diff --git a/src/main/java/com/carecode/domain/user/controller/UserController.java b/src/main/java/com/carecode/domain/user/controller/UserController.java index 1bd5a145..88b89639 100644 --- a/src/main/java/com/carecode/domain/user/controller/UserController.java +++ b/src/main/java/com/carecode/domain/user/controller/UserController.java @@ -32,10 +32,7 @@ import com.carecode.domain.user.dto.response.UserListResponse; import com.carecode.domain.user.dto.response.UserInfoResponse; -/** - * 통합 사용자 관리 컨트롤러 - * 사용자 프로필, 통계, 위치 관리 등 모든 사용자 관련 API - */ +/** 통합 사용자 관리 컨트롤러 */ @RestController @RequestMapping("/users") @RequiredArgsConstructor @@ -49,14 +46,13 @@ public class UserController extends BaseController { private final UserMapper userMapper; private final CurrentUserFacade currentUserFacade; - // ==================== 사용자 프로필 ==================== - + // ==================== + // 사용자 프로필 ==================== // 사용자 프로필 조회 - @GetMapping("/{userId}") @LogExecutionTime - @Operation(summary = "사용자 프로필 조회", description = "특정 사용자의 프로필 정보를 조회합니다.") + @Operation(summary = "사용자 프로필 조회", description = "특정 사용자의 프로필 정보 조회") @SecurityRequirement(name = "Bearer Authentication") public ResponseEntity getUserProfile(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId) { @@ -64,12 +60,10 @@ public ResponseEntity getUserProfile(@Parameter(description = "사용 return ResponseEntity.ok(user); } - // 현재 사용자 프로필 조회 - @GetMapping("/profile") @LogExecutionTime - @Operation(summary = "현재 사용자 프로필 조회", description = "현재 로그인한 사용자의 프로필 정보를 조회합니다.") + @Operation(summary = "현재 사용자 프로필 조회", description = "현재 로그인한 사용자의 프로필 정보 조회") @SecurityRequirement(name = "Bearer Authentication") public ResponseEntity getCurrentUserProfile() { String currentUserEmail = getCurrentUserEmail(); @@ -81,7 +75,7 @@ public ResponseEntity getCurrentUserProfile() { // 사용자 프로필 업데이트 @PutMapping("/{userId}") @LogExecutionTime - @Operation(summary = "사용자 프로필 업데이트", description = "사용자의 프로필 정보를 업데이트합니다.") + @Operation(summary = "사용자 프로필 업데이트", description = "사용자의 프로필 정보를 업데이트") @SecurityRequirement(name = "Bearer Authentication") public ResponseEntity updateUserProfile(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId, @Parameter(description = "업데이트할 사용자 정보", required = true) @@ -95,7 +89,7 @@ public ResponseEntity updateUserProfile(@Parameter(description = "사 // 프로필 완성도 체크 @GetMapping("/profile/completion") @LogExecutionTime - @Operation(summary = "프로필 완성도 체크", description = "사용자 프로필의 완성도를 확인합니다.") + @Operation(summary = "프로필 완성도 체크", description = "사용자 프로필의 완성도를 확인") @SecurityRequirement(name = "Bearer Authentication") public ResponseEntity checkProfileCompletion() { String currentUserEmail = getCurrentUserEmail(); @@ -107,7 +101,7 @@ public ResponseEntity checkProfileCompletion() { // 프로필 이미지 업데이트 @PutMapping("/{userId}/profile-image") @LogExecutionTime - @Operation(summary = "프로필 이미지 업데이트", description = "사용자의 프로필 이미지를 업데이트합니다.") + @Operation(summary = "프로필 이미지 업데이트") @SecurityRequirement(name = "Bearer Authentication") public ResponseEntity updateProfileImage(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId, @Parameter(description = "프로필 이미지 URL", required = true) @RequestParam String profileImageUrl) { @@ -115,14 +109,13 @@ public ResponseEntity updateProfileImage(@Parameter(description = "사용 return ResponseEntity.ok().build(); } - // ==================== 사용자 위치 관리 ==================== - + // ==================== + // 사용자 위치 관리 ==================== // 사용자 위치 업데이트 - @PutMapping("/{userId}/location") @LogExecutionTime - @Operation(summary = "사용자 위치 업데이트", description = "사용자의 현재 위치를 업데이트합니다.") + @Operation(summary = "사용자 위치 업데이트", description = "사용자의 현재 위치를 업데이트") @SecurityRequirement(name = "Bearer Authentication") public ResponseEntity updateUserLocation(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId, @Parameter(description = "위도", required = true) @RequestParam Double latitude, @@ -135,7 +128,7 @@ public ResponseEntity updateUserLocation(@Parameter(description = "사 // 회원 탈퇴 (계정 비활성화) @PutMapping("/{userId}/deactivate") @LogExecutionTime - @Operation(summary = "회원 탈퇴 (계정 비활성화)", description = "사용자 계정을 비활성화합니다. 데이터는 보존되며 필요시 복구 가능합니다.") + @Operation(summary = "회원 탈퇴 (계정 비활성화)", description = "사용자 계정을 비활성화합니다. 데이터는 보존되며 필요시 복구 가능") @SecurityRequirement(name = "Bearer Authentication") public ResponseEntity deactivateUser(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId) { userFacade.deactivateUser(userId); @@ -145,7 +138,7 @@ public ResponseEntity deactivateUser(@Parameter(description = "사 // 회원 탈퇴 (소프트 삭제) @DeleteMapping("/{userId}") @LogExecutionTime - @Operation(summary = "회원 탈퇴 (소프트 삭제)", description = "사용자 계정을 소프트 삭제합니다. 데이터는 보존되며 필요시 복구 가능합니다.") + @Operation(summary = "회원 탈퇴 (소프트 삭제)", description = "사용자 계정을 소프트 삭제합니다. 데이터는 보존되며 필요시 복구 가능") @SecurityRequirement(name = "Bearer Authentication") public ResponseEntity deleteUser(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId) { userFacade.deleteUser(userId); @@ -156,7 +149,7 @@ public ResponseEntity deleteUser(@Parameter(description = "사용자 // 계정 복구 (비활성화된 계정 재활성화) @PutMapping("/{userId}/reactivate") @LogExecutionTime - @Operation(summary = "계정 복구", description = "비활성화된 사용자 계정을 재활성화합니다.") + @Operation(summary = "계정 복구", description = "비활성화된 사용자 계정을 재활성화") @SecurityRequirement(name = "Bearer Authentication") public ResponseEntity reactivateUser(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId) { userFacade.reactivateUser(userId); @@ -164,13 +157,13 @@ public ResponseEntity reactivateUser(@Parameter(description = "사 return ResponseEntity.ok(ApiSuccess.of("계정이 성공적으로 복구되었습니다.")); } - // ==================== 프로필 관리 ==================== + // ==================== + // 프로필 관리 ==================== // 프로필 업데이트 (추가 정보 입력) - @PutMapping("/profile") @LogExecutionTime - @Operation(summary = "프로필 업데이트", description = "사용자의 추가 정보를 입력/업데이트합니다.") + @Operation(summary = "프로필 업데이트", description = "사용자의 추가 정보를 입력/업데이트") @SecurityRequirement(name = "Bearer Authentication") public ResponseEntity updateProfile(@Parameter(description = "업데이트할 프로필 정보", required = true) @Valid @RequestBody UserUpdateRequestDto updateDto) { @@ -192,7 +185,7 @@ public ResponseEntity updateProfile(@Parameter(description = "업데이 // 닉네임 업데이트 (카카오 닉네임과 별도) @PatchMapping("/profile/nickname") @LogExecutionTime - @Operation(summary = "닉네임 업데이트", description = "사용자의 표시 닉네임을 업데이트합니다.") + @Operation(summary = "닉네임 업데이트", description = "사용자의 표시 닉네임을 업데이트") @SecurityRequirement(name = "Bearer Authentication") public ResponseEntity updateNickname(@Parameter(description = "새로운 닉네임", required = true) @RequestBody Map request) { @@ -215,26 +208,23 @@ public ResponseEntity updateNickname(@Parameter(description = "새 return ResponseEntity.ok(ApiSuccess.of("닉네임이 업데이트되었습니다")); } - // ==================== 사용자 관리 (관리자용) ==================== - + // ==================== + // 사용자 관리 (관리자용) ==================== // 사용자 통계 조회 - @GetMapping("/statistics") @LogExecutionTime - @Operation(summary = "사용자 통계 조회", description = "전체 사용자 통계를 조회합니다.") + @Operation(summary = "사용자 통계 조회") @SecurityRequirement(name = "Bearer Authentication") public ResponseEntity getUserStatistics() { UserStatsResponse stats = userService.getUserStatistics(); return ResponseEntity.ok(stats); } - // 사용자 검색 - @GetMapping("/search") @LogExecutionTime - @Operation(summary = "사용자 검색", description = "키워드로 사용자를 검색합니다.") + @Operation(summary = "사용자 검색", description = "키워드로 사용자 검색") @SecurityRequirement(name = "Bearer Authentication") public ResponseEntity searchUsers(@Parameter(description = "검색 키워드", required = true) @RequestParam String keyword, @Parameter(description = "검색 타입", required = false) @RequestParam(required = false) String type) { @@ -256,12 +246,10 @@ public ResponseEntity searchUsers(@Parameter(description = " return ResponseEntity.ok(searchResult); } - // 활성 사용자 목록 조회 - @GetMapping("/active") @LogExecutionTime - @Operation(summary = "활성 사용자 목록", description = "활성화된 사용자 목록을 조회합니다.") + @Operation(summary = "활성 사용자 목록", description = "활성화된 사용자 목록 조회") @SecurityRequirement(name = "Bearer Authentication") public ResponseEntity getActiveUsers() { List users = userService.getActiveUsers(); @@ -276,12 +264,10 @@ public ResponseEntity getActiveUsers() { return ResponseEntity.ok(userList); } - // 사용자 유형별 조회 - @GetMapping("/by-type/{userType}") @LogExecutionTime - @Operation(summary = "사용자 유형별 조회", description = "특정 유형의 사용자들을 조회합니다.") + @Operation(summary = "사용자 유형별 조회", description = "특정 유형의 사용자들 조회") @SecurityRequirement(name = "Bearer Authentication") public ResponseEntity getUsersByType(@Parameter(description = "사용자 유형", required = true) @PathVariable String userType) { List users = userService.getUsersByType(userType); @@ -296,12 +282,10 @@ public ResponseEntity getUsersByType(@Parameter(description = return ResponseEntity.ok(userList); } - // 지역별 사용자 조회 - @GetMapping("/by-region/{region}") @LogExecutionTime - @Operation(summary = "지역별 사용자 조회", description = "특정 지역의 사용자들을 조회합니다.") + @Operation(summary = "지역별 사용자 조회", description = "특정 지역의 사용자들 조회") @SecurityRequirement(name = "Bearer Authentication") public ResponseEntity getUsersByRegion(@Parameter(description = "지역", required = true) @PathVariable String region) { List users = userService.getUsersByRegion(region); @@ -316,12 +300,10 @@ public ResponseEntity getUsersByRegion(@Parameter(description return ResponseEntity.ok(userList); } - // 인증된 사용자 목록 조회 - @GetMapping("/verified") @LogExecutionTime - @Operation(summary = "인증된 사용자 목록", description = "이메일 인증이 완료된 사용자 목록을 조회합니다.") + @Operation(summary = "인증된 사용자 목록", description = "이메일 인증이 완료된 사용자 목록 조회") @SecurityRequirement(name = "Bearer Authentication") public ResponseEntity getVerifiedUsers() { List users = userService.getVerifiedUsers(); @@ -336,12 +318,10 @@ public ResponseEntity getVerifiedUsers() { return ResponseEntity.ok(userList); } - // 최근 활동 사용자 목록 조회 - @GetMapping("/recently-active") @LogExecutionTime - @Operation(summary = "최근 활동 사용자 목록", description = "최근에 활동한 사용자 목록을 조회합니다.") + @Operation(summary = "최근 활동 사용자 목록", description = "최근에 활동한 사용자 목록 조회") @SecurityRequirement(name = "Bearer Authentication") public ResponseEntity getRecentlyActiveUsers() { List users = userService.getRecentlyActiveUsers(); @@ -356,12 +336,10 @@ public ResponseEntity getRecentlyActiveUsers() { return ResponseEntity.ok(userList); } - // 사용자 역할 변경 - @PutMapping("/{userId}/role") @LogExecutionTime - @Operation(summary = "사용자 역할 변경", description = "사용자의 역할을 변경합니다.") + @Operation(summary = "사용자 역할 변경", description = "사용자의 역할을 변경") @SecurityRequirement(name = "Bearer Authentication") public ResponseEntity updateUserRole(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId, @Parameter(description = "새로운 역할", required = true) @RequestBody Map request) { @@ -380,30 +358,25 @@ public ResponseEntity updateUserRole(@Parameter(description = "사 return ResponseEntity.ok(ApiSuccess.of("사용자 역할이 변경되었습니다")); } - // 사용자 활성화 - @PutMapping("/{userId}/activate") @LogExecutionTime - @Operation(summary = "사용자 활성화", description = "비활성화된 사용자를 활성화합니다.") + @Operation(summary = "사용자 활성화", description = "비활성화된 사용자를 활성화") @SecurityRequirement(name = "Bearer Authentication") public ResponseEntity activateUser(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId) { userService.activateUser(userId); return ResponseEntity.ok(ApiSuccess.of("사용자가 활성화되었습니다")); } - // ==================== 유틸리티 메서드 ==================== - + // ==================== + // 유틸리티 메서드 ==================== // 현재 로그인한 사용자의 이메일을 가져오기 - private String getCurrentUserEmail() { return currentUserFacade.requireCurrentUserEmail(); } - // 사용자 프로필 업데이트 - private void updateUserProfile(User user, UserUpdateRequestDto updateDto) { if (!isBlank(updateDto.getName())) { user.setName(updateDto.getName().trim()); @@ -428,9 +401,7 @@ private void updateUserProfile(User user, UserUpdateRequestDto updateDto) { } } - // User Entity를 UserDto로 변환 - private UserDto convertToDto(User user) { return UserDto.builder() .id(user.getId()) @@ -455,9 +426,7 @@ private UserDto convertToDto(User user) { .build(); } - // 프로필 완성도 계산 - private UserProfileCompletionResponse calculateProfileCompletion(User user) { UserProfileMissingFields missingFields = UserProfileMissingFields.builder() @@ -495,16 +464,12 @@ private UserProfileCompletionResponse calculateProfileCompletion(User user) { .build(); } - // 문자열이 비어있는지 확인 - private boolean isBlank(String str) { return str == null || str.trim().isEmpty(); } - // UserDto를 UserInfoResponse로 변환 - private UserInfoResponse convertToUserInfo(UserDto userDto) { return UserInfoResponse.builder() .id(userDto.getId()) diff --git a/src/main/java/com/carecode/domain/user/dto/request/ConsentUpdateRequest.java b/src/main/java/com/carecode/domain/user/dto/request/ConsentUpdateRequest.java index 01a9d7a6..2dfbec44 100644 --- a/src/main/java/com/carecode/domain/user/dto/request/ConsentUpdateRequest.java +++ b/src/main/java/com/carecode/domain/user/dto/request/ConsentUpdateRequest.java @@ -7,9 +7,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 동의/철회 요청. - */ +/** 동의/철회 요청. */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/request/KakaoRegistrationRequest.java b/src/main/java/com/carecode/domain/user/dto/request/KakaoRegistrationRequest.java index 990c63a0..88b47054 100644 --- a/src/main/java/com/carecode/domain/user/dto/request/KakaoRegistrationRequest.java +++ b/src/main/java/com/carecode/domain/user/dto/request/KakaoRegistrationRequest.java @@ -9,9 +9,7 @@ import jakarta.validation.constraints.Pattern; import io.swagger.v3.oas.annotations.media.Schema; -/** - * 카카오 신규 사용자 역할 설정 요청 DTO - */ +/** 카카오 신규 사용자 역할 설정 요청 DTO */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/request/LoginRequestDto.java b/src/main/java/com/carecode/domain/user/dto/request/LoginRequestDto.java index 16060edd..f46715c8 100644 --- a/src/main/java/com/carecode/domain/user/dto/request/LoginRequestDto.java +++ b/src/main/java/com/carecode/domain/user/dto/request/LoginRequestDto.java @@ -5,9 +5,7 @@ import lombok.NoArgsConstructor; import lombok.AllArgsConstructor; -/** - * 로그인 요청 DTO - */ +/** 로그인 요청 DTO */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/request/PasswordChangeRequestDto.java b/src/main/java/com/carecode/domain/user/dto/request/PasswordChangeRequestDto.java index 563d4304..578c4889 100644 --- a/src/main/java/com/carecode/domain/user/dto/request/PasswordChangeRequestDto.java +++ b/src/main/java/com/carecode/domain/user/dto/request/PasswordChangeRequestDto.java @@ -5,9 +5,7 @@ import lombok.NoArgsConstructor; import lombok.AllArgsConstructor; -/** - * 비밀번호 변경 요청 DTO - */ +/** 비밀번호 변경 요청 DTO */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/request/RefreshTokenRequest.java b/src/main/java/com/carecode/domain/user/dto/request/RefreshTokenRequest.java index 8fdf264d..3e377f4f 100644 --- a/src/main/java/com/carecode/domain/user/dto/request/RefreshTokenRequest.java +++ b/src/main/java/com/carecode/domain/user/dto/request/RefreshTokenRequest.java @@ -6,9 +6,7 @@ import lombok.AllArgsConstructor; import lombok.Builder; -/** - * 토큰 갱신 요청 DTO - */ +/** 토큰 갱신 요청 DTO */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/request/TokenValidationRequest.java b/src/main/java/com/carecode/domain/user/dto/request/TokenValidationRequest.java index 05a38b29..af758fd9 100644 --- a/src/main/java/com/carecode/domain/user/dto/request/TokenValidationRequest.java +++ b/src/main/java/com/carecode/domain/user/dto/request/TokenValidationRequest.java @@ -5,9 +5,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 토큰 검증 요청 DTO - */ +/** 토큰 검증 요청 DTO */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/request/UserChangePasswordRequest.java b/src/main/java/com/carecode/domain/user/dto/request/UserChangePasswordRequest.java index 9701760e..80ad435d 100644 --- a/src/main/java/com/carecode/domain/user/dto/request/UserChangePasswordRequest.java +++ b/src/main/java/com/carecode/domain/user/dto/request/UserChangePasswordRequest.java @@ -7,9 +7,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 비밀번호 변경 요청 - */ +/** 비밀번호 변경 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/request/UserKakaoRegistrationRequest.java b/src/main/java/com/carecode/domain/user/dto/request/UserKakaoRegistrationRequest.java index 0e442f31..e471f4b6 100644 --- a/src/main/java/com/carecode/domain/user/dto/request/UserKakaoRegistrationRequest.java +++ b/src/main/java/com/carecode/domain/user/dto/request/UserKakaoRegistrationRequest.java @@ -8,9 +8,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 카카오 회원가입 완료 요청 - */ +/** 카카오 회원가입 완료 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/request/UserLoginRequest.java b/src/main/java/com/carecode/domain/user/dto/request/UserLoginRequest.java index 56835208..5aefadbe 100644 --- a/src/main/java/com/carecode/domain/user/dto/request/UserLoginRequest.java +++ b/src/main/java/com/carecode/domain/user/dto/request/UserLoginRequest.java @@ -7,9 +7,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 로그인 요청 - */ +/** 로그인 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/request/UserRefreshTokenRequest.java b/src/main/java/com/carecode/domain/user/dto/request/UserRefreshTokenRequest.java index 25eb53b7..8a0ffb81 100644 --- a/src/main/java/com/carecode/domain/user/dto/request/UserRefreshTokenRequest.java +++ b/src/main/java/com/carecode/domain/user/dto/request/UserRefreshTokenRequest.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 토큰 갱신 요청 - */ +/** 토큰 갱신 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/request/UserRegisterRequest.java b/src/main/java/com/carecode/domain/user/dto/request/UserRegisterRequest.java index 8cba2226..7bb46959 100644 --- a/src/main/java/com/carecode/domain/user/dto/request/UserRegisterRequest.java +++ b/src/main/java/com/carecode/domain/user/dto/request/UserRegisterRequest.java @@ -13,9 +13,7 @@ import java.time.LocalDate; -/** - * 회원가입 요청 - */ +/** 회원가입 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/request/UserUpdateNicknameRequest.java b/src/main/java/com/carecode/domain/user/dto/request/UserUpdateNicknameRequest.java index 4335b630..4eff95b3 100644 --- a/src/main/java/com/carecode/domain/user/dto/request/UserUpdateNicknameRequest.java +++ b/src/main/java/com/carecode/domain/user/dto/request/UserUpdateNicknameRequest.java @@ -7,9 +7,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 닉네임 변경 요청 - */ +/** 닉네임 변경 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/request/UserUpdateProfileRequest.java b/src/main/java/com/carecode/domain/user/dto/request/UserUpdateProfileRequest.java index b33d68ef..4acb6ed6 100644 --- a/src/main/java/com/carecode/domain/user/dto/request/UserUpdateProfileRequest.java +++ b/src/main/java/com/carecode/domain/user/dto/request/UserUpdateProfileRequest.java @@ -12,9 +12,7 @@ import java.time.LocalDate; -/** - * 프로필 업데이트 요청 - */ +/** 프로필 업데이트 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/request/UserUpdateRequestDto.java b/src/main/java/com/carecode/domain/user/dto/request/UserUpdateRequestDto.java index 3dedc048..786e0a27 100644 --- a/src/main/java/com/carecode/domain/user/dto/request/UserUpdateRequestDto.java +++ b/src/main/java/com/carecode/domain/user/dto/request/UserUpdateRequestDto.java @@ -49,4 +49,3 @@ public class UserUpdateRequestDto { private Integer householdSize; } - diff --git a/src/main/java/com/carecode/domain/user/dto/request/UserValidateTokenRequest.java b/src/main/java/com/carecode/domain/user/dto/request/UserValidateTokenRequest.java index 1e131361..1485720d 100644 --- a/src/main/java/com/carecode/domain/user/dto/request/UserValidateTokenRequest.java +++ b/src/main/java/com/carecode/domain/user/dto/request/UserValidateTokenRequest.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 토큰 검증 요청 - */ +/** 토큰 검증 요청 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/response/ConsentStatusResponse.java b/src/main/java/com/carecode/domain/user/dto/response/ConsentStatusResponse.java index a1e8d45b..6a265180 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/ConsentStatusResponse.java +++ b/src/main/java/com/carecode/domain/user/dto/response/ConsentStatusResponse.java @@ -6,9 +6,7 @@ import java.time.LocalDateTime; import java.util.List; -/** - * 항목별 현재 동의 상태. - */ +/** 항목별 현재 동의 상태. */ @Getter @Builder public class ConsentStatusResponse { diff --git a/src/main/java/com/carecode/domain/user/dto/response/KakaoAccount.java b/src/main/java/com/carecode/domain/user/dto/response/KakaoAccount.java index a50c197f..a388dc0f 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/KakaoAccount.java +++ b/src/main/java/com/carecode/domain/user/dto/response/KakaoAccount.java @@ -3,9 +3,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import lombok.Data; -/** - * 카카오 계정 정보 - */ +/** 카카오 계정 정보 */ @Data @JsonIgnoreProperties(ignoreUnknown = true) public class KakaoAccount { diff --git a/src/main/java/com/carecode/domain/user/dto/response/KakaoOAuthToken.java b/src/main/java/com/carecode/domain/user/dto/response/KakaoOAuthToken.java index 2989384d..fd4ddcae 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/KakaoOAuthToken.java +++ b/src/main/java/com/carecode/domain/user/dto/response/KakaoOAuthToken.java @@ -3,9 +3,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import lombok.Data; -/** - * 카카오 OAuth 토큰 - */ +/** 카카오 OAuth 토큰 */ @Data @JsonIgnoreProperties(ignoreUnknown = true) public class KakaoOAuthToken { diff --git a/src/main/java/com/carecode/domain/user/dto/response/KakaoProfile.java b/src/main/java/com/carecode/domain/user/dto/response/KakaoProfile.java index 53e2b323..b45191f2 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/KakaoProfile.java +++ b/src/main/java/com/carecode/domain/user/dto/response/KakaoProfile.java @@ -3,9 +3,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import lombok.Data; -/** - * 카카오 프로필 - */ +/** 카카오 프로필 */ @Data @JsonIgnoreProperties(ignoreUnknown = true) public class KakaoProfile { diff --git a/src/main/java/com/carecode/domain/user/dto/response/KakaoProfileInfo.java b/src/main/java/com/carecode/domain/user/dto/response/KakaoProfileInfo.java index 5d3506a3..b6f4d778 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/KakaoProfileInfo.java +++ b/src/main/java/com/carecode/domain/user/dto/response/KakaoProfileInfo.java @@ -3,9 +3,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import lombok.Data; -/** - * 카카오 프로필 정보 - */ +/** 카카오 프로필 정보 */ @Data @JsonIgnoreProperties(ignoreUnknown = true) public class KakaoProfileInfo { diff --git a/src/main/java/com/carecode/domain/user/dto/response/KakaoProperties.java b/src/main/java/com/carecode/domain/user/dto/response/KakaoProperties.java index 47c815b6..88cafacc 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/KakaoProperties.java +++ b/src/main/java/com/carecode/domain/user/dto/response/KakaoProperties.java @@ -3,9 +3,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import lombok.Data; -/** - * 카카오 프로퍼티 - */ +/** 카카오 프로퍼티 */ @Data @JsonIgnoreProperties(ignoreUnknown = true) public class KakaoProperties { diff --git a/src/main/java/com/carecode/domain/user/dto/response/TokenDto.java b/src/main/java/com/carecode/domain/user/dto/response/TokenDto.java index 80b3c32a..5aed64d1 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/TokenDto.java +++ b/src/main/java/com/carecode/domain/user/dto/response/TokenDto.java @@ -6,9 +6,7 @@ import lombok.AllArgsConstructor; import lombok.Builder; -/** - * JWT 토큰 DTO - */ +/** JWT 토큰 DTO */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/response/TokenValidationResponse.java b/src/main/java/com/carecode/domain/user/dto/response/TokenValidationResponse.java index 8436fd0d..a2866dd6 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/TokenValidationResponse.java +++ b/src/main/java/com/carecode/domain/user/dto/response/TokenValidationResponse.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 토큰 검증 응답 DTO - */ +/** 토큰 검증 응답 DTO */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/response/UserActivityResponse.java b/src/main/java/com/carecode/domain/user/dto/response/UserActivityResponse.java index 49b85880..f4374386 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/UserActivityResponse.java +++ b/src/main/java/com/carecode/domain/user/dto/response/UserActivityResponse.java @@ -8,9 +8,7 @@ import java.time.LocalDateTime; -/** - * 사용자 활동 통계 응답 - */ +/** 사용자 활동 통계 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/response/UserDto.java b/src/main/java/com/carecode/domain/user/dto/response/UserDto.java index ac3cabcc..e7e5923b 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/UserDto.java +++ b/src/main/java/com/carecode/domain/user/dto/response/UserDto.java @@ -10,9 +10,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; -/** - * 사용자 정보 전송 객체 - */ +/** 사용자 정보 전송 객체 */ @Getter @Setter @NoArgsConstructor @@ -24,10 +22,7 @@ public class UserDto { private String userId; private String email; - /** - * 회원가입/수정 요청에서만 사용한다. - * WRITE_ONLY 로 두지 않으면 이 DTO 를 반환하는 모든 응답에 비밀번호 해시가 실려 나간다. - */ + /** 회원가입/수정 요청에서만 사용한다. */ @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) private String password; private String name; diff --git a/src/main/java/com/carecode/domain/user/dto/response/UserInfoResponse.java b/src/main/java/com/carecode/domain/user/dto/response/UserInfoResponse.java index a1a8c100..f44157b6 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/UserInfoResponse.java +++ b/src/main/java/com/carecode/domain/user/dto/response/UserInfoResponse.java @@ -9,9 +9,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; -/** - * 사용자 정보 응답 - */ +/** 사용자 정보 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/response/UserListResponse.java b/src/main/java/com/carecode/domain/user/dto/response/UserListResponse.java index 1d3d76c5..cf635d6f 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/UserListResponse.java +++ b/src/main/java/com/carecode/domain/user/dto/response/UserListResponse.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 사용자 목록 응답 - */ +/** 사용자 목록 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/response/UserLoginResponse.java b/src/main/java/com/carecode/domain/user/dto/response/UserLoginResponse.java index 498feebd..2a5500c1 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/UserLoginResponse.java +++ b/src/main/java/com/carecode/domain/user/dto/response/UserLoginResponse.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 로그인 응답 - */ +/** 로그인 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/response/UserNearbyUsersResponse.java b/src/main/java/com/carecode/domain/user/dto/response/UserNearbyUsersResponse.java index 59570c89..11d1d32f 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/UserNearbyUsersResponse.java +++ b/src/main/java/com/carecode/domain/user/dto/response/UserNearbyUsersResponse.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 위치 기반 사용자 응답 - */ +/** 위치 기반 사용자 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/response/UserProfileCompletionResponse.java b/src/main/java/com/carecode/domain/user/dto/response/UserProfileCompletionResponse.java index fe444a09..34d3bc1a 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/UserProfileCompletionResponse.java +++ b/src/main/java/com/carecode/domain/user/dto/response/UserProfileCompletionResponse.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 프로필 완성도 응답 DTO - */ +/** 프로필 완성도 응답 DTO */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/response/UserProfileMissingFields.java b/src/main/java/com/carecode/domain/user/dto/response/UserProfileMissingFields.java index 590b1a46..a8acd509 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/UserProfileMissingFields.java +++ b/src/main/java/com/carecode/domain/user/dto/response/UserProfileMissingFields.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 프로필 누락 필드 정보 - */ +/** 프로필 누락 필드 정보 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/response/UserProfileUpdateDto.java b/src/main/java/com/carecode/domain/user/dto/response/UserProfileUpdateDto.java index e0e34a49..50df3b6a 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/UserProfileUpdateDto.java +++ b/src/main/java/com/carecode/domain/user/dto/response/UserProfileUpdateDto.java @@ -12,9 +12,7 @@ import java.time.LocalDate; -/** - * 사용자 추가 정보 입력/업데이트 DTO - */ +/** 사용자 추가 정보 입력/업데이트 DTO */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/response/UserSearchResponse.java b/src/main/java/com/carecode/domain/user/dto/response/UserSearchResponse.java index cc7e4636..1cd50846 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/UserSearchResponse.java +++ b/src/main/java/com/carecode/domain/user/dto/response/UserSearchResponse.java @@ -8,9 +8,7 @@ import java.util.List; -/** - * 사용자 검색 응답 - */ +/** 사용자 검색 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/response/UserStatsDto.java b/src/main/java/com/carecode/domain/user/dto/response/UserStatsDto.java index 68dccf4d..f6380fdd 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/UserStatsDto.java +++ b/src/main/java/com/carecode/domain/user/dto/response/UserStatsDto.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 사용자 통계 정보 - */ +/** 사용자 통계 정보 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/response/UserStatsResponse.java b/src/main/java/com/carecode/domain/user/dto/response/UserStatsResponse.java index d3b97731..569bc890 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/UserStatsResponse.java +++ b/src/main/java/com/carecode/domain/user/dto/response/UserStatsResponse.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 사용자 통계 응답 - */ +/** 사용자 통계 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/response/UserTokenResponse.java b/src/main/java/com/carecode/domain/user/dto/response/UserTokenResponse.java index dab3ec83..5d6cec30 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/UserTokenResponse.java +++ b/src/main/java/com/carecode/domain/user/dto/response/UserTokenResponse.java @@ -6,9 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -/** - * 토큰 응답 - */ +/** 토큰 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/dto/response/UserTokenValidationResponse.java b/src/main/java/com/carecode/domain/user/dto/response/UserTokenValidationResponse.java index dd3a75d9..4f14e3c4 100644 --- a/src/main/java/com/carecode/domain/user/dto/response/UserTokenValidationResponse.java +++ b/src/main/java/com/carecode/domain/user/dto/response/UserTokenValidationResponse.java @@ -8,9 +8,7 @@ import java.time.LocalDateTime; -/** - * 토큰 검증 응답 - */ +/** 토큰 검증 응답 */ @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/carecode/domain/user/entity/Child.java b/src/main/java/com/carecode/domain/user/entity/Child.java index d1c31b30..3ed813cf 100644 --- a/src/main/java/com/carecode/domain/user/entity/Child.java +++ b/src/main/java/com/carecode/domain/user/entity/Child.java @@ -10,10 +10,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; -/** - * 자녀 엔티티 - * 사용자의 자녀 정보를 관리 - */ +/** 자녀 엔티티 사용자의 자녀 정보를 관리 */ @Entity @Table(name = "TBL_CHILD") @Getter diff --git a/src/main/java/com/carecode/domain/user/entity/ConsentType.java b/src/main/java/com/carecode/domain/user/entity/ConsentType.java index e259065d..e3d6e577 100644 --- a/src/main/java/com/carecode/domain/user/entity/ConsentType.java +++ b/src/main/java/com/carecode/domain/user/entity/ConsentType.java @@ -1,11 +1,6 @@ package com.carecode.domain.user.entity; -/** - * 동의 항목. - * - *

필수 항목은 미동의 시 서비스 이용이 불가하고, 선택 항목은 언제든 철회할 수 있다. - * 아동 정보 수집은 보호자 동의가 별도로 필요하다. - */ +/** 동의 항목. 필수 항목은 미동의 시 서비스 이용이 불가하고, 선택 항목은 언제든 철회할 수 있다. */ public enum ConsentType { TERMS_OF_SERVICE("서비스 이용약관", true), diff --git a/src/main/java/com/carecode/domain/user/entity/Gender.java b/src/main/java/com/carecode/domain/user/entity/Gender.java index adbbc689..7b0a74cf 100644 --- a/src/main/java/com/carecode/domain/user/entity/Gender.java +++ b/src/main/java/com/carecode/domain/user/entity/Gender.java @@ -1,8 +1,6 @@ package com.carecode.domain.user.entity; -/** - * 성별 Enum - */ +/** 성별 Enum */ public enum Gender { MALE("남성"), FEMALE("여성"), diff --git a/src/main/java/com/carecode/domain/user/entity/NotificationSettings.java b/src/main/java/com/carecode/domain/user/entity/NotificationSettings.java index 3bc7500a..309acc4b 100644 --- a/src/main/java/com/carecode/domain/user/entity/NotificationSettings.java +++ b/src/main/java/com/carecode/domain/user/entity/NotificationSettings.java @@ -10,10 +10,7 @@ import java.time.LocalTime; import java.time.LocalDateTime; -/** - * 알림 설정 엔티티 - * 사용자의 알림 설정을 관리 - */ +/** 알림 설정 엔티티 사용자의 알림 설정을 관리 */ @Entity @Table(name = "TBL_NOTIFICATION_SETTINGS") @Getter diff --git a/src/main/java/com/carecode/domain/user/entity/User.java b/src/main/java/com/carecode/domain/user/entity/User.java index f0200058..b8f44808 100644 --- a/src/main/java/com/carecode/domain/user/entity/User.java +++ b/src/main/java/com/carecode/domain/user/entity/User.java @@ -16,9 +16,7 @@ import java.util.ArrayList; import java.util.List; -/** - * 사용자 엔티티 - */ +/** 사용자 엔티티 */ @Entity @Table(name = "TBL_USER") @Getter diff --git a/src/main/java/com/carecode/domain/user/entity/UserConsent.java b/src/main/java/com/carecode/domain/user/entity/UserConsent.java index 7b7c7095..89882f58 100644 --- a/src/main/java/com/carecode/domain/user/entity/UserConsent.java +++ b/src/main/java/com/carecode/domain/user/entity/UserConsent.java @@ -8,12 +8,7 @@ import java.time.LocalDateTime; -/** - * 동의 이력. - * - *

개인정보보호법상 "언제, 어떤 버전의 약관에, 무엇을 동의했는지" 를 입증할 수 있어야 한다. - * 따라서 현재 상태를 덮어쓰지 않고 동의·철회를 각각 새 행으로 남긴다(append-only). - */ +/** 동의 이력. 개인정보보호법상 "언제, 어떤 버전의 약관에, 무엇을 동의했는지" 를 입증할 수 있어야 한다 */ @Entity @Table( name = "TBL_USER_CONSENT", diff --git a/src/main/java/com/carecode/domain/user/entity/UserRole.java b/src/main/java/com/carecode/domain/user/entity/UserRole.java index 110d4375..ce1c4135 100644 --- a/src/main/java/com/carecode/domain/user/entity/UserRole.java +++ b/src/main/java/com/carecode/domain/user/entity/UserRole.java @@ -1,8 +1,6 @@ package com.carecode.domain.user.entity; -/** - * 사용자 역할 Enum - */ +/** 사용자 역할 Enum */ public enum UserRole { PARENT("부모"), CAREGIVER("보육사"), diff --git a/src/main/java/com/carecode/domain/user/mapper/UserMapper.java b/src/main/java/com/carecode/domain/user/mapper/UserMapper.java index 5e58d60b..ad3d162b 100644 --- a/src/main/java/com/carecode/domain/user/mapper/UserMapper.java +++ b/src/main/java/com/carecode/domain/user/mapper/UserMapper.java @@ -39,4 +39,3 @@ public interface UserMapper { void updateUserFromRequest(UserUpdateRequestDto request, @MappingTarget User user); } - diff --git a/src/main/java/com/carecode/domain/user/repository/ChildRepository.java b/src/main/java/com/carecode/domain/user/repository/ChildRepository.java index 7f86f6ea..e71cf7f7 100644 --- a/src/main/java/com/carecode/domain/user/repository/ChildRepository.java +++ b/src/main/java/com/carecode/domain/user/repository/ChildRepository.java @@ -8,25 +8,18 @@ import java.util.List; -/** - * 자녀 리포지토리 인터페이스 - */ +/** 자녀 리포지토리 인터페이스 */ @Repository public interface ChildRepository extends JpaRepository { - // 사용자별 자녀 목록 조회 - List findByUserIdOrderByCreatedAtDesc(Long userId); - // 연령 범위별 자녀 조회 - @Query("SELECT c FROM Child c WHERE c.user.id = :userId AND c.age >= :minAge AND c.age <= :maxAge") List findByUserIdAndAgeRange(@Param("userId") Long userId, @Param("minAge") Integer minAge, @Param("maxAge") Integer maxAge); - // 성별 자녀 조회 List findByUserIdAndGender(Long userId, String gender); diff --git a/src/main/java/com/carecode/domain/user/repository/EmailVerificationTokenRepository.java b/src/main/java/com/carecode/domain/user/repository/EmailVerificationTokenRepository.java index 9dc800c8..d609ece8 100644 --- a/src/main/java/com/carecode/domain/user/repository/EmailVerificationTokenRepository.java +++ b/src/main/java/com/carecode/domain/user/repository/EmailVerificationTokenRepository.java @@ -12,10 +12,7 @@ public interface EmailVerificationTokenRepository extends JpaRepository { Optional findByToken(String token); - /** - * 만료되었거나 이미 사용된 토큰을 정리한다. - * 정리하지 않으면 테이블이 무한히 커진다. - */ + /** 만료되었거나 이미 사용된 토큰을 정리한다. 정리하지 않으면 테이블이 무한히 커진다. */ @Modifying @Query("DELETE FROM EmailVerificationToken t WHERE t.expiryDate < :threshold OR t.used = true") int deleteExpiredOrUsed(@Param("threshold") LocalDateTime threshold); diff --git a/src/main/java/com/carecode/domain/user/repository/NotificationSettingsRepository.java b/src/main/java/com/carecode/domain/user/repository/NotificationSettingsRepository.java index f898e971..d98a3a8b 100644 --- a/src/main/java/com/carecode/domain/user/repository/NotificationSettingsRepository.java +++ b/src/main/java/com/carecode/domain/user/repository/NotificationSettingsRepository.java @@ -7,9 +7,7 @@ import java.util.List; import java.util.Optional; -/** - * 알림 설정 리포지토리 인터페이스 - */ +/** 알림 설정 리포지토리 인터페이스 */ @Repository public interface NotificationSettingsRepository extends JpaRepository { diff --git a/src/main/java/com/carecode/domain/user/repository/UserConsentRepository.java b/src/main/java/com/carecode/domain/user/repository/UserConsentRepository.java index b0c68360..e037cda4 100644 --- a/src/main/java/com/carecode/domain/user/repository/UserConsentRepository.java +++ b/src/main/java/com/carecode/domain/user/repository/UserConsentRepository.java @@ -13,10 +13,7 @@ public interface UserConsentRepository extends JpaRepository List findByUserIdOrderByCreatedAtDesc(Long userId); - /** - * 항목별 가장 최근 동의 이력. - * 이력은 append-only 라서 현재 동의 상태는 최신 행으로 판단한다. - */ + /** 항목별 가장 최근 동의 이력. 이력은 append-only 라서 현재 동의 상태는 최신 행으로 판단한다. */ @Query("SELECT uc FROM UserConsent uc " + "WHERE uc.user.id = :userId AND uc.consentType = :consentType " + "ORDER BY uc.createdAt DESC LIMIT 1") diff --git a/src/main/java/com/carecode/domain/user/repository/UserRepository.java b/src/main/java/com/carecode/domain/user/repository/UserRepository.java index 262ee55f..80260d97 100644 --- a/src/main/java/com/carecode/domain/user/repository/UserRepository.java +++ b/src/main/java/com/carecode/domain/user/repository/UserRepository.java @@ -10,9 +10,7 @@ import java.util.List; import java.util.Optional; -/** - * 사용자 리포지토리 인터페이스 - */ +/** 사용자 리포지토리 인터페이스 */ @Repository public interface UserRepository extends JpaRepository { diff --git a/src/main/java/com/carecode/domain/user/service/JwtService.java b/src/main/java/com/carecode/domain/user/service/JwtService.java index ee7e3eed..172aaf42 100644 --- a/src/main/java/com/carecode/domain/user/service/JwtService.java +++ b/src/main/java/com/carecode/domain/user/service/JwtService.java @@ -18,10 +18,7 @@ import java.util.Map; import java.util.UUID; -/** - * JWT 토큰 서비스 - * Access Token과 Refresh Token 생성, 검증, 갱신을 담당 - */ +/** JWT 토큰 서비스 */ @Slf4j @Service public class JwtService { @@ -66,30 +63,22 @@ private SecretKey getSigningKey() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } - // Access Token 생성 - public String generateAccessToken(String userId, String email, String role) { return generateToken(TOKEN_TYPE_ACCESS, userId, email, role, null, accessTokenExpiration); } - // Access Token 생성 (name 포함) - public String generateAccessToken(String userId, String email, String role, String name) { return generateToken(TOKEN_TYPE_ACCESS, userId, email, role, name, accessTokenExpiration); } - // Refresh Token 생성 - public String generateRefreshToken(String userId, String email) { return generateToken(TOKEN_TYPE_REFRESH, userId, email, null, null, refreshTokenExpiration); } - // 토큰 생성 - private String generateToken(String tokenType, String userId, String email, String role, String name, long expiration) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + expiration); @@ -115,66 +104,48 @@ private String generateToken(String tokenType, String userId, String email, Stri .compact(); } - // 토큰에서 종류(access/refresh) 추출 - public String getTokenType(String token) { return getClaimFromToken(token, CLAIM_TOKEN_TYPE, String.class); } - // 토큰에서 사용자 ID 추출 - public String getUserIdFromToken(String token) { return getClaimFromToken(token, "userId", String.class); } - // 토큰에서 이메일 추출 - public String getEmailFromToken(String token) { return getClaimFromToken(token, "email", String.class); } - // 토큰에서 이메일 추출 (별칭 메서드) - public String extractEmailFromToken(String token) { return getEmailFromToken(token); } - // 토큰에서 역할 추출 - public String getRoleFromToken(String token) { return getClaimFromToken(token, "role", String.class); } - // 토큰에서 이름 추출 - public String getNameFromToken(String token) { return getClaimFromToken(token, "name", String.class); } - // 토큰에서 만료 시간 추출 - public Date getExpirationDateFromToken(String token) { return getClaimFromToken(token, Claims.EXPIRATION, Date.class); } - // 토큰에서 특정 클레임 추출 - public T getClaimFromToken(String token, String claimName, Class requiredType) { final Claims claims = getAllClaimsFromToken(token); return claims.get(claimName, requiredType); } - // 토큰에서 모든 클레임 추출 - private Claims getAllClaimsFromToken(String token) { return Jwts.parserBuilder() .setSigningKey(getSigningKey()) @@ -183,9 +154,7 @@ private Claims getAllClaimsFromToken(String token) { .getBody(); } - // 토큰 만료 여부 확인 - public Boolean isTokenExpired(String token) { try { final Date expiration = getExpirationDateFromToken(token); @@ -195,9 +164,7 @@ public Boolean isTokenExpired(String token) { } } - // 토큰 유효성 검증 - public boolean validateToken(String token) { try { Jwts.parserBuilder() @@ -212,16 +179,12 @@ public boolean validateToken(String token) { } } - // Access Token 전용 검증 - typ=access 인 토큰만 통과시킵니다. - public boolean validateAccessToken(String token) { return validateTokenOfType(token, TOKEN_TYPE_ACCESS); } - // Refresh Token 전용 검증 - typ=refresh 인 토큰만 통과시킵니다. - public boolean validateRefreshToken(String token) { return validateTokenOfType(token, TOKEN_TYPE_REFRESH); } @@ -243,9 +206,7 @@ private boolean validateTokenOfType(String token, String expectedType) { } } - // 토큰 검증 및 정보 추출 - public TokenValidationResponse validateTokenAndExtractInfo(String token) { try { if (!validateToken(token)) { @@ -275,9 +236,7 @@ public TokenValidationResponse validateTokenAndExtractInfo(String token) { } } - // 토큰 갱신 - public TokenDto refreshTokens(String refreshToken) { // Access Token 을 Refresh 엔드포인트로 재사용하는 것을 차단합니다. if (!validateRefreshToken(refreshToken)) { @@ -305,9 +264,7 @@ public TokenDto refreshTokens(String refreshToken) { .build(); } - // 토큰에서 Authorization 헤더 추출 - public String extractTokenFromAuthorizationHeader(String authorizationHeader) { if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { return authorizationHeader.substring(7); diff --git a/src/main/java/com/carecode/domain/user/service/PrivacyService.java b/src/main/java/com/carecode/domain/user/service/PrivacyService.java index f4d95dae..1d9a6e97 100644 --- a/src/main/java/com/carecode/domain/user/service/PrivacyService.java +++ b/src/main/java/com/carecode/domain/user/service/PrivacyService.java @@ -22,11 +22,7 @@ import java.util.List; import java.util.Map; -/** - * 개인정보 관련 기능: 동의 이력 관리, 내 데이터 열람, 파기. - * - *

개인정보보호법상 정보주체는 자신의 정보를 열람하고 처리 정지·삭제를 요구할 수 있다. - */ +/** 개인정보 관련 기능: 동의 이력 관리, 내 데이터 열람, 파기. 개인정보보호법상 정보주체는 자신의 정보를 열람하고 처리 정지·삭제를 요구할 수 있다. */ @Slf4j @Service @RequiredArgsConstructor @@ -40,13 +36,10 @@ public class PrivacyService { private final PostRepository postRepository; private final CurrentUserFacade currentUserFacade; - // ==================== 동의 관리 ==================== + // ==================== + // 동의 관리 ==================== - /** - * 동의 상태를 기록한다. - * - *

기존 행을 수정하지 않고 새 이력을 남긴다. 언제 무엇에 동의/철회했는지 추적해야 하기 때문이다. - */ + /** 동의 상태를 기록한다. 기존 행을 수정하지 않고 새 이력을 남긴다. 언제 무엇에 동의/철회했는지 추적해야 하기 때문이다. */ @Transactional public ConsentStatusResponse recordConsent(ConsentUpdateRequest request, String ipAddress) { User user = currentUserFacade.requireCurrentUser(); @@ -100,13 +93,10 @@ public List getConsentHistory() { .toList(); } - // ==================== 데이터 열람 ==================== + // ==================== + // 데이터 열람 ==================== - /** - * 내 데이터 전체 내려받기. - * - *

정보주체의 열람권 행사에 대응한다. 비밀번호 등 인증 정보는 포함하지 않는다. - */ + /** 내 데이터 전체 내려받기. 정보주체의 열람권 행사에 대응한다. 비밀번호 등 인증 정보는 포함하지 않는다. */ public Map exportMyData() { User user = currentUserFacade.requireCurrentUser(); @@ -137,15 +127,10 @@ public Map exportMyData() { return export; } - // ==================== 파기 ==================== + // ==================== + // 파기 ==================== - /** - * 회원 탈퇴(파기 요청). - * - *

즉시 물리 삭제하지 않는 이유: 게시글·댓글 등 참조 데이터가 함께 사라지면 - * 다른 이용자의 대화 맥락이 깨지고, 법령상 일정 기간 보존이 필요한 기록도 있다. - * 개인 식별 정보를 익명화하고 soft delete 로 표시한다. - */ + /** 회원 탈퇴(파기 요청). 즉시 물리 삭제하지 않는 이유: 게시글·댓글 등 참조 데이터가 함께 사라지면 다른 이용자의 대화 맥락이 깨지고 */ @Transactional public void deleteMyAccount() { User user = currentUserFacade.requireCurrentUser(); diff --git a/src/main/java/com/carecode/domain/user/service/UserService.java b/src/main/java/com/carecode/domain/user/service/UserService.java index b11f967c..41011d50 100644 --- a/src/main/java/com/carecode/domain/user/service/UserService.java +++ b/src/main/java/com/carecode/domain/user/service/UserService.java @@ -30,10 +30,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.client.RestTemplate; -/** - * 사용자 서비스 클래스 - * 사용자 관련 비즈니스 로직 처리 - */ +/** 사용자 서비스 클래스 사용자 관련 비즈니스 로직 처리 */ @Service @RequiredArgsConstructor @Slf4j @@ -44,10 +41,7 @@ public class UserService { private final PasswordEncoder passwordEncoder; private final RestTemplate restTemplate; - - // 사용자 상세 조회 (String ID) - 삭제되지 않은 사용자만 - @LogExecutionTime public UserDto getUserById(String userId) { // 먼저 userId로 조회 시도 (삭제되지 않은 사용자만) @@ -69,9 +63,7 @@ public UserDto getUserById(String userId) { return convertToDto(user); } - // 이메일로 사용자 조회 (삭제되지 않은 사용자만) - @LogExecutionTime public UserDto getUserByEmail(String email) { User user = userRepository.findByEmailAndDeletedAtIsNull(email) @@ -80,45 +72,31 @@ public UserDto getUserByEmail(String email) { return convertToDto(user); } - // 이메일로 사용자 Optional 조회 (삭제되지 않은 사용자만) - @LogExecutionTime public Optional getUserByEmailOptional(String email) { return userRepository.findByEmailAndDeletedAtIsNull(email); } - - // 이메일로 User 엔티티 조회 (비밀번호 포함) - 삭제되지 않은 사용자만 - @LogExecutionTime public User getUserEntityByEmail(String email) { return userRepository.findByEmailAndDeletedAtIsNull(email) .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다: " + email)); } - - /** - * 예외를 던지지 않는 조회. - * 로그인처럼 "존재하지 않음"과 "비밀번호 불일치"를 구분해서 응답하면 안 되는 경로에서 사용한다. - */ + /** 예외를 던지지 않는 조회. 로그인처럼 "존재하지 않음"과 "비밀번호 불일치"를 구분해서 응답하면 안 되는 경로에서 사용한다. */ public Optional findActiveUserEntityByEmail(String email) { return userRepository.findByEmailAndDeletedAtIsNull(email); } - // User 엔티티 저장 - @Transactional public User saveUser(User user) { return userRepository.save(user); } - - // 카카오 API를 통해 사용자 정보 조회 - public Map getKakaoUserInfo(String accessToken) { log.info("카카오 사용자 정보 조회 시작: accessToken={}", accessToken != null ? accessToken.substring(0, Math.min(20, accessToken.length())) + "..." : "null"); @@ -127,8 +105,7 @@ public Map getKakaoUserInfo(String accessToken) { headers.set("Authorization", "Bearer " + accessToken); headers.set("Content-Type", "application/x-www-form-urlencoded;charset=utf-8"); HttpEntity entity = new HttpEntity<>(headers); - - + ResponseEntity> response = restTemplate.exchange( "https://kapi.kakao.com/v2/user/me", HttpMethod.GET, @@ -175,9 +152,7 @@ public Map getKakaoUserInfo(String accessToken) { } } - // 사용자 통계 조회 - @LogExecutionTime public UserStatsResponse getUserStatistics() { long totalUsers = userRepository.count(); @@ -203,9 +178,7 @@ public UserStatsResponse getUserStatistics() { .build(); } - // 카카오 신규 사용자 가입 완료 (이름 및 역할 설정) - @Transactional public UserDto completeKakaoRegistration(String email, String name, String role) { if (name == null || name.trim().isEmpty()) { @@ -247,9 +220,7 @@ public UserDto completeKakaoRegistration(String email, String name, String role) return convertToDto(updatedUser); } - // 사용자 생성 - @Transactional public UserDto createUser(UserDto userDto) { log.info("사용자 생성: 이메일={}, provider={}", userDto.getEmail(), userDto.getProvider()); @@ -296,13 +267,7 @@ public UserDto createUser(UserDto userDto) { return convertToDto(savedUser); } - - - - - // 비밀번호 변경 (PasswordChangeRequestDto) - @Transactional @RequireAuthentication public void changePassword(String userId, PasswordChangeRequestDto request) { @@ -321,10 +286,7 @@ public void changePassword(String userId, PasswordChangeRequestDto request) { userRepository.save(user); } - - // 사용자 비활성화 (String ID) - @Transactional @RequireAuthentication public void deactivateUser(String userId) { @@ -342,10 +304,7 @@ public void deactivateUser(String userId) { } } - - // 사용자 활성화 (String ID) - @Transactional @RequireAuthentication public void activateUser(String userId) { @@ -396,10 +355,7 @@ public void updateProfileImage(String userId, String imageUrl) { } } - - // 사용자 검색 (삭제되지 않은 사용자만) - @LogExecutionTime @RequireAuthentication public List searchUsers(String keyword) { @@ -409,9 +365,7 @@ public List searchUsers(String keyword) { .collect(Collectors.toList()); } - // 사용자 검색 (타입별, 삭제되지 않은 사용자만) - @LogExecutionTime @RequireAuthentication public List searchUsers(String keyword, String type) { @@ -621,15 +575,5 @@ public List getEmailVerifiedUsers() { .collect(Collectors.toList()); } - // 최근 업데이트된 사용자 조회 -> 나중에 사용 - // @LogExecutionTime - // public List getRecentlyUpdatedUsers(int days) { - // log.info("최근 업데이트된 사용자 조회: {}일 이내", days); - // - // LocalDateTime dateTime = LocalDateTime.now().minusDays(days); - // List users = userRepository.findByUpdatedAtAfterAndDeletedAtIsNull(dateTime); - // return users.stream() - // .map(this::convertToDto) - // .collect(Collectors.toList()); - // } + // 최근 업데이트된 사용자 조회 -> 나중에 사용 @LogExecutionTime public List getRecentlyUpdatedUsers(int } \ No newline at end of file diff --git a/src/main/java/com/carecode/domain/user/service/refreshtoken/RedisRefreshTokenStore.java b/src/main/java/com/carecode/domain/user/service/refreshtoken/RedisRefreshTokenStore.java index 0b350a0e..cb20ed04 100644 --- a/src/main/java/com/carecode/domain/user/service/refreshtoken/RedisRefreshTokenStore.java +++ b/src/main/java/com/carecode/domain/user/service/refreshtoken/RedisRefreshTokenStore.java @@ -7,9 +7,7 @@ import java.util.Objects; import java.util.Set; -/** - * 리프레시 JWT를 서명만으로 두지 않고, 서버(Redis)에 활성 세션으로 등록해 재사용·로그아웃 시 제어합니다. - */ +/** 리프레시 JWT를 서명만으로 두지 않고, 서버(Redis)에 활성 세션으로 등록해 재사용·로그아웃 시 제어합니다. */ @RequiredArgsConstructor public class RedisRefreshTokenStore implements RefreshTokenStore { diff --git a/src/main/java/com/carecode/domain/user/service/refreshtoken/RefreshTokenStore.java b/src/main/java/com/carecode/domain/user/service/refreshtoken/RefreshTokenStore.java index 8eae5f23..960816a7 100644 --- a/src/main/java/com/carecode/domain/user/service/refreshtoken/RefreshTokenStore.java +++ b/src/main/java/com/carecode/domain/user/service/refreshtoken/RefreshTokenStore.java @@ -1,16 +1,11 @@ package com.carecode.domain.user.service.refreshtoken; -/** - * 서버 측 리프레시 토큰 세션(회전·로그아웃 시 폐기). - * {@code jwt.refresh-token.store=none} 이면 모두 무작동으로 JWT만 검증하는 기존 동작과 동일합니다. - */ +/** 서버 측 리프레시 토큰 세션(회전·로그아웃 시 폐기). jwt.refresh-token.store=none 이면 모두 무작동으로 JWT만 검증하는 기존 동작과 동일합니다. */ public interface RefreshTokenStore { void register(String userId, String refreshTokenJwt); - /** - * Redis 등에 등록된 활성 리프레시 토큰인지 확인합니다. - */ + /** Redis 등에 등록된 활성 리프레시 토큰인지 확인합니다. */ boolean isRegistered(String refreshTokenJwt, String userId); void remove(String refreshTokenJwt, String userId); diff --git a/src/main/resources/db/migration/V1__baseline.sql b/src/main/resources/db/migration/V1__baseline.sql index a34fdb59..843a7079 100644 --- a/src/main/resources/db/migration/V1__baseline.sql +++ b/src/main/resources/db/migration/V1__baseline.sql @@ -1,15 +1,6 @@ --- ================================================================================ --- CareCode baseline schema (V1) --- MariaDB/MySQL DDL --- --- 주의: 이 파일은 Flyway 가 신규 환경에 적용하는 최초 스키마다. --- 기존 운영 DB 는 flyway.baseline-on-migrate=true + baseline-version=0 으로 건너뛴다. --- 이후 스키마 변경은 이 파일을 수정하지 말고 V2__*.sql 을 새로 추가한다. --- ================================================================================ - --- ================================================================================ --- 1. User Domain Tables --- ================================================================================ +-- ================================================================================ CareCode baseline s + +-- ================================================================================ 1 -- User Table CREATE TABLE TBL_USER ( @@ -91,9 +82,7 @@ CREATE TABLE TBL_EMAIL_VERIFICATION_TOKEN ( INDEX idx_user_id (USER_ID) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='이메일 인증 토큰'; --- ================================================================================ --- 2. Care Facility Domain Tables --- ================================================================================ +-- ================================================================================ 2 -- Care Facility Table CREATE TABLE TBL_CARE_FACILITIES ( @@ -190,9 +179,7 @@ CREATE TABLE TBL_REVIEWS ( INDEX idx_is_active (IS_ACTIVE) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='돌봄시설 리뷰'; --- ================================================================================ --- 3. Community Domain Tables --- ================================================================================ +-- ================================================================================ 3 -- Post Table CREATE TABLE TBL_POST ( @@ -293,9 +280,7 @@ CREATE TABLE TBL_BOOKMARK ( INDEX idx_user_id (USER_ID) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='북마크'; --- ================================================================================ --- 4. Health Domain Tables --- ================================================================================ +-- ================================================================================ 4 -- Hospital Table CREATE TABLE TBL_HOSPITAL ( @@ -401,9 +386,7 @@ CREATE TABLE TBL_HEALTH_RECORD_ATTACHMENTS ( INDEX idx_is_active (IS_ACTIVE) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='건강기록 첨부파일'; --- ================================================================================ --- 5. Policy Domain Tables --- ================================================================================ +-- ================================================================================ 5 -- Policy Category Table CREATE TABLE TBL_POLICY_CATEGORIES ( @@ -471,9 +454,7 @@ CREATE TABLE TBL_POLICY_DOCUMENTS ( INDEX idx_is_active (IS_ACTIVE) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='정책 문서'; --- ================================================================================ --- 6. Notification Domain Tables --- ================================================================================ +-- ================================================================================ 6 -- Notification Table CREATE TABLE TBL_NOTIFICATION ( @@ -529,9 +510,7 @@ CREATE TABLE TBL_NOTIFICATION_TEMPLATES ( INDEX idx_is_active (IS_ACTIVE) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='알림 템플릿'; --- ================================================================================ --- 7. Chatbot Domain Tables --- ================================================================================ +-- ================================================================================ 7 -- Chat Session Table CREATE TABLE TBL_CHAT_SESSIONS ( @@ -573,6 +552,4 @@ CREATE TABLE TBL_CHAT_MESSAGES ( INDEX idx_created_at (CREATED_AT) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='채팅 메시지'; --- ================================================================================ --- End of CareCode Database Schema --- ================================================================================ +-- ================================================================================ End of CareCode Dat diff --git a/src/main/resources/db/migration/V2__feature_tables.sql b/src/main/resources/db/migration/V2__feature_tables.sql index de7bcdb2..0a342765 100644 --- a/src/main/resources/db/migration/V2__feature_tables.sql +++ b/src/main/resources/db/migration/V2__feature_tables.sql @@ -1,13 +1,6 @@ --- ================================================================================ --- V2: 예방접종 일정, 커뮤니티 모더레이션, 개인정보 동의 이력 --- --- prod 는 ddl-auto=validate 이므로 엔티티를 추가할 때마다 이 파일 같은 --- 포워드 마이그레이션을 함께 넣어야 기동된다. --- ================================================================================ +-- ================================================================================ V2: 예방접종 일정 --- -------------------------------------------------------------------------------- --- 1. 예방접종 일정 (아이 등록 시 표준 일정이 자동 생성됨) --- -------------------------------------------------------------------------------- +-- -------------------------------------------------------------------------------- 1 CREATE TABLE TBL_VACCINATION_SCHEDULE ( id BIGINT AUTO_INCREMENT PRIMARY KEY, child_id BIGINT NOT NULL, @@ -27,9 +20,7 @@ CREATE TABLE TBL_VACCINATION_SCHEDULE ( INDEX idx_vaccination_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='아이별 예방접종 일정'; --- -------------------------------------------------------------------------------- --- 2. 게시글·댓글 신고 --- -------------------------------------------------------------------------------- +-- -------------------------------------------------------------------------------- 2 CREATE TABLE TBL_REPORT ( id BIGINT AUTO_INCREMENT PRIMARY KEY, reporter_id BIGINT NOT NULL, @@ -48,9 +39,7 @@ CREATE TABLE TBL_REPORT ( INDEX idx_report_target (target_type, target_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='커뮤니티 신고'; --- -------------------------------------------------------------------------------- --- 3. 사용자 차단 --- -------------------------------------------------------------------------------- +-- -------------------------------------------------------------------------------- 3 CREATE TABLE TBL_USER_BLOCK ( id BIGINT AUTO_INCREMENT PRIMARY KEY, blocker_id BIGINT NOT NULL COMMENT '차단한 사용자', @@ -63,12 +52,7 @@ CREATE TABLE TBL_USER_BLOCK ( INDEX idx_user_block_blocker (blocker_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='사용자 차단'; --- -------------------------------------------------------------------------------- --- 4. 개인정보 동의 이력 (append-only) --- --- 현재 상태를 덮어쓰지 않고 동의·철회를 각각 새 행으로 남긴다. --- "언제, 어떤 버전 약관에, 무엇을 동의했는지" 를 입증할 수 있어야 하기 때문이다. --- -------------------------------------------------------------------------------- +-- -------------------------------------------------------------------------------- 4 CREATE TABLE TBL_USER_CONSENT ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, diff --git a/src/main/resources/db/migration/V3__hospital_external_code.sql b/src/main/resources/db/migration/V3__hospital_external_code.sql index 9f5afdee..57cf01ef 100644 --- a/src/main/resources/db/migration/V3__hospital_external_code.sql +++ b/src/main/resources/db/migration/V3__hospital_external_code.sql @@ -1,10 +1,4 @@ --- ================================================================================ --- V3: 병원 외부 식별자(심평원 암호화 요양기호) 추가 --- --- 공공데이터 동기화 시 같은 병원을 중복 적재하지 않기 위한 키다. --- 심평원은 ykiho 를 암호화해 제공하며 복호화 수단이 없으므로 값 자체를 식별자로 쓴다. --- 기존 수기 등록 병원은 NULL 로 남고, UNIQUE 제약은 NULL 을 중복으로 보지 않는다. --- ================================================================================ +-- ================================================================================ V3: 병원 외부 식별자(심평원 암 ALTER TABLE TBL_HOSPITAL ADD COLUMN external_code VARCHAR(100) NULL COMMENT '심평원 암호화 요양기호(ykiho)'; diff --git a/src/main/resources/db/migration/V4__search_indexes.sql b/src/main/resources/db/migration/V4__search_indexes.sql index 48068efb..07de11eb 100644 --- a/src/main/resources/db/migration/V4__search_indexes.sql +++ b/src/main/resources/db/migration/V4__search_indexes.sql @@ -1,8 +1,4 @@ --- V4: 위치 검색 및 전문 검색 인덱스 --- --- 반경 검색은 그동안 모든 행에 삼각함수를 계산한 뒤 HAVING 으로 걸러 풀 스캔이었다. --- 바운딩 박스로 후보를 좁히도록 바꾸면서 BETWEEN 조건이 인덱스를 타도록 추가한다. --- 검색은 LIKE '%키워드%' 라 인덱스를 쓸 수 없어 FULLTEXT 를 추가한다(ngram: 한글 대응). +-- V4: 위치 검색 및 전문 검색 인덱스 반경 검색은 그동안 모든 행에 삼각함수를 계산한 뒤 HAVING 으로 걸러 풀 스캔이었다 -- 위치 검색: 위도로 범위를 좁히고 경도로 다시 좁힌다. CREATE INDEX idx_facility_location ON TBL_CARE_FACILITIES (LATITUDE, LONGITUDE); diff --git a/src/main/resources/db/migration/V5__facility_capacity_snapshot.sql b/src/main/resources/db/migration/V5__facility_capacity_snapshot.sql index 0c71645b..d96399b5 100644 --- a/src/main/resources/db/migration/V5__facility_capacity_snapshot.sql +++ b/src/main/resources/db/migration/V5__facility_capacity_snapshot.sql @@ -1,6 +1,4 @@ --- 시설 정원·현원 시계열. --- 동기화 때마다 현원을 덮어쓰면 관측 이력이 사라져 입소 가능 시점을 예측할 수 없다. --- 이 테이블이 쌓이는 기간 자체가 경쟁 장벽이므로 가능한 이른 시점부터 적재한다. +-- 시설 정원·현원 시계열. 동기화 때마다 현원을 덮어쓰면 관측 이력이 사라져 입소 가능 시점을 예측할 수 없다 CREATE TABLE TBL_FACILITY_CAPACITY_SNAPSHOT ( ID BIGINT AUTO_INCREMENT PRIMARY KEY, FACILITY_ID BIGINT NOT NULL COMMENT '시설 ID', diff --git a/src/main/resources/db/migration/V6__benefit_eligibility.sql b/src/main/resources/db/migration/V6__benefit_eligibility.sql index e525d1e9..9b75fc84 100644 --- a/src/main/resources/db/migration/V6__benefit_eligibility.sql +++ b/src/main/resources/db/migration/V6__benefit_eligibility.sql @@ -1,5 +1,4 @@ --- 놓친 지원금 발굴에 필요한 자격 조건. --- 연령·지역만으로는 실제 수급 가능 여부를 가릴 수 없어 소득·다자녀·소급 조건을 추가한다. +-- 놓친 지원금 발굴에 필요한 자격 조건. 연령·지역만으로는 실제 수급 가능 여부를 가릴 수 없어 소득·다자녀·소급 조건을 추가한다. -- 기준중위소득 대비 % 이하가 대상. NULL 이면 소득 무관 정책이다. ALTER TABLE TBL_POLICIES @@ -13,8 +12,7 @@ ALTER TABLE TBL_POLICIES ALTER TABLE TBL_POLICIES ADD COLUMN RETROACTIVE_MONTHS INT NULL COMMENT '소급 신청 가능 개월 - NULL 이면 소급 불가'; --- 사용자 가구 소득. 소득 조건이 붙은 정책을 거르는 데 쓴다. --- 실제 금액이 아니라 기준중위소득 대비 비율만 저장한다 (민감정보 최소 수집). +-- 사용자 가구 소득. 소득 조건이 붙은 정책을 거르는 데 쓴다. 실제 금액이 아니라 기준중위소득 대비 비율만 저장한다 (민감정보 최소 수집). ALTER TABLE TBL_USER ADD COLUMN INCOME_PERCENT INT NULL COMMENT '가구 소득 / 기준중위소득 (%) - NULL 이면 미입력'; diff --git a/src/main/resources/db/migration/V7__benefit_payment_duration.sql b/src/main/resources/db/migration/V7__benefit_payment_duration.sql index 956a0a32..17c77723 100644 --- a/src/main/resources/db/migration/V7__benefit_payment_duration.sql +++ b/src/main/resources/db/migration/V7__benefit_payment_duration.sql @@ -1,7 +1,3 @@ --- 지급 기간 상한. --- --- targetAgeMin/Max 는 "어떤 아이가 대상인가" 이지 "몇 개월 받는가" 가 아니다. --- 이 둘을 같은 것으로 보면 육아휴직급여(월 150만원, 대상 0~96개월)가 60개월 전망에서 --- 9,000만원으로 계산된다. 실제로는 최대 12개월 지급이다. +-- 지급 기간 상한. targetAgeMin/Max 는 "어떤 아이가 대상인가" 이지 "몇 개월 받는가" 가 아니다 ALTER TABLE TBL_POLICIES ADD COLUMN MAX_PAYMENT_MONTHS INT NULL COMMENT '월 지급 최대 개월 - NULL 이면 대상 연령 내내 지급'; diff --git a/src/test/java/com/carecode/core/util/ClientIpResolverTest.java b/src/test/java/com/carecode/core/util/ClientIpResolverTest.java index 78bd8dc4..aa4be7f2 100644 --- a/src/test/java/com/carecode/core/util/ClientIpResolverTest.java +++ b/src/test/java/com/carecode/core/util/ClientIpResolverTest.java @@ -6,12 +6,7 @@ import static org.assertj.core.api.Assertions.assertThat; -/** - * X-Forwarded-For 신뢰 정책에 대한 회귀 테스트. - * - *

과거에는 이 헤더를 무조건 신뢰해서, 헤더 값만 바꾸면 - * IP 기반 rate limit 을 무한히 우회할 수 있었다. - */ +/** X-Forwarded-For 신뢰 정책에 대한 회귀 테스트. 과거에는 이 헤더를 무조건 신뢰해서. */ @DisplayName("ClientIpResolver") class ClientIpResolverTest { diff --git a/src/test/java/com/carecode/core/util/PageRequestUtilTest.java b/src/test/java/com/carecode/core/util/PageRequestUtilTest.java index d3e9b61c..1bc60642 100644 --- a/src/test/java/com/carecode/core/util/PageRequestUtilTest.java +++ b/src/test/java/com/carecode/core/util/PageRequestUtilTest.java @@ -5,9 +5,7 @@ import static org.assertj.core.api.Assertions.assertThat; -/** - * 목록 API 가 무제한 조회로 되돌아가지 않도록 보장하는 테스트. - */ +/** 목록 API 가 무제한 조회로 되돌아가지 않도록 보장하는 테스트. */ @DisplayName("PageRequestUtil") class PageRequestUtilTest { diff --git a/src/test/java/com/carecode/domain/careFacility/service/CareFacilityBookingServiceTest.java b/src/test/java/com/carecode/domain/careFacility/service/CareFacilityBookingServiceTest.java index 974133eb..1aff7936 100644 --- a/src/test/java/com/carecode/domain/careFacility/service/CareFacilityBookingServiceTest.java +++ b/src/test/java/com/carecode/domain/careFacility/service/CareFacilityBookingServiceTest.java @@ -33,12 +33,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -/** - * 예약 겹침 검증 회귀 테스트. - * - *

이전 구현은 시작 시각 ±1시간만 비교해서 (1) 기존 예약의 종료 시각을 무시했고, - * (2) 취소된 예약도 충돌로 셌으며, (3) 시설 정원과 무관하게 1건만 있어도 막았다. - */ +/** 예약 겹침 검증 회귀 테스트. 이전 구현은 시작 시각 ±1시간만 비교해서 (1) 기존 예약의 종료 시각을 무시했고, (2) 취소된 예약도 충돌로 셌으며 */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @DisplayName("시설 예약 - 겹침 검증") diff --git a/src/test/java/com/carecode/domain/community/service/CommunityServiceOwnershipTest.java b/src/test/java/com/carecode/domain/community/service/CommunityServiceOwnershipTest.java index e851f586..a93ed6ad 100644 --- a/src/test/java/com/carecode/domain/community/service/CommunityServiceOwnershipTest.java +++ b/src/test/java/com/carecode/domain/community/service/CommunityServiceOwnershipTest.java @@ -39,12 +39,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -/** - * 커뮤니티 게시글/댓글 소유권 검증에 대한 회귀 테스트. - * - *

과거에는 수정·삭제 시 작성자 확인이 전혀 없어서 - * 로그인한 사용자라면 누구나 남의 글과 댓글을 지울 수 있었다. - */ +/** 커뮤니티 게시글/댓글 소유권 검증에 대한 회귀 테스트. 과거에는 수정·삭제 시 작성자 확인이 전혀 없어서 로그인한 사용자라면 누구나 남의 글과 댓글을 지울 수 있었다. */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @DisplayName("CommunityService - 소유권 검증") diff --git a/src/test/java/com/carecode/domain/health/entity/VaccineTypeTest.java b/src/test/java/com/carecode/domain/health/entity/VaccineTypeTest.java index 98b67a9c..c4b1d095 100644 --- a/src/test/java/com/carecode/domain/health/entity/VaccineTypeTest.java +++ b/src/test/java/com/carecode/domain/health/entity/VaccineTypeTest.java @@ -8,9 +8,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -/** - * 표준 예방접종 일정 정의 검증. - */ +/** 표준 예방접종 일정 정의 검증. */ @DisplayName("예방접종 표준 일정") class VaccineTypeTest { diff --git a/src/test/java/com/carecode/domain/health/growth/GrowthPercentileCalculatorTest.java b/src/test/java/com/carecode/domain/health/growth/GrowthPercentileCalculatorTest.java index 3ab02f01..201392c5 100644 --- a/src/test/java/com/carecode/domain/health/growth/GrowthPercentileCalculatorTest.java +++ b/src/test/java/com/carecode/domain/health/growth/GrowthPercentileCalculatorTest.java @@ -8,11 +8,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.within; -/** - * WHO LMS 백분위 계산 검증. - * - *

기존 차트 API 는 상수(DEFAULT_NUTRITION_PROGRESS)를 반환해 또래 비교가 불가능했다. - */ +/** WHO LMS 백분위 계산 검증. 기존 차트 API 는 상수(DEFAULT_NUTRITION_PROGRESS)를 반환해 또래 비교가 불가능했다. */ @DisplayName("성장 백분위 계산") class GrowthPercentileCalculatorTest { diff --git a/src/test/java/com/carecode/domain/health/service/HealthServiceTest.java b/src/test/java/com/carecode/domain/health/service/HealthServiceTest.java index c8e6dada..e36a4501 100644 --- a/src/test/java/com/carecode/domain/health/service/HealthServiceTest.java +++ b/src/test/java/com/carecode/domain/health/service/HealthServiceTest.java @@ -27,9 +27,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -/** - * HealthService 단위 테스트 - */ +/** HealthService 단위 테스트 */ @ExtendWith(MockitoExtension.class) @DisplayName("HealthService 테스트") class HealthServiceTest { diff --git a/src/test/java/com/carecode/domain/user/service/JwtServiceTest.java b/src/test/java/com/carecode/domain/user/service/JwtServiceTest.java index e37a63cb..bc4907c1 100644 --- a/src/test/java/com/carecode/domain/user/service/JwtServiceTest.java +++ b/src/test/java/com/carecode/domain/user/service/JwtServiceTest.java @@ -11,12 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -/** - * JWT 토큰 종류 분리에 대한 회귀 테스트. - * - *

과거에는 Access/Refresh 를 구분하는 클레임이 없어서 - * 30일짜리 Refresh Token 으로 보호된 API 에 접근할 수 있었다. - */ +/** JWT 토큰 종류 분리에 대한 회귀 테스트. 과거에는 Access/Refresh 를 구분하는 클레임이 없어서 30일짜리 Refresh Token 으로 보호된 API 에 */ @DisplayName("JwtService - 토큰 종류 검증") class JwtServiceTest { diff --git a/src/test/java/com/carecode/integration/ApplicationContextLoadTest.java b/src/test/java/com/carecode/integration/ApplicationContextLoadTest.java index 9e3c26f0..225badfa 100644 --- a/src/test/java/com/carecode/integration/ApplicationContextLoadTest.java +++ b/src/test/java/com/carecode/integration/ApplicationContextLoadTest.java @@ -14,12 +14,7 @@ import static org.assertj.core.api.Assertions.assertThat; -/** - * Docker 없이도 도는 컨텍스트 로딩 테스트. - * - *

보안 필터 체인, 인터셉터, 캐시 설정 등 빈 구성이 깨지면 여기서 바로 잡힌다. - * MariaDB Testcontainers 통합 테스트는 Docker 가 없으면 스킵되므로 그 공백을 메운다. - */ +/** Docker 없이도 도는 컨텍스트 로딩 테스트. 보안 필터 체인, 인터셉터, 캐시 설정 등 빈 구성이 깨지면 여기서 바로 잡힌다 */ @SpringBootTest( classes = CareCodeApplication.class, properties = { diff --git a/src/test/java/com/carecode/integration/SampleDataScenarioTest.java b/src/test/java/com/carecode/integration/SampleDataScenarioTest.java index 84d94adf..79c10d49 100644 --- a/src/test/java/com/carecode/integration/SampleDataScenarioTest.java +++ b/src/test/java/com/carecode/integration/SampleDataScenarioTest.java @@ -35,10 +35,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; -/** - * 샘플 데이터를 넣고 신규 기능 3종이 실제로 값을 내는지 확인한다. - * 공공데이터 연동 전에도 기능 전체가 살아 있는지 검증하기 위한 것이다. - */ +/** 샘플 데이터를 넣고 신규 기능 3종이 실제로 값을 내는지 확인한다. */ @SpringBootTest( classes = CareCodeApplication.class, properties = { From fedfefa0a5cbe84c1ce5f686eb5b5d1cb4dfc357 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Wed, 5 Aug 2026 11:08:41 +0900 Subject: [PATCH 13/68] =?UTF-8?q?FEAT=20:=20=EB=B3=91=EC=9B=90=20=EC=B0=9C?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/health/app/HealthFacade.java | 26 ++-- .../health/controller/HealthController.java | 138 ++++++++---------- .../response/HospitalLikeStatusResponse.java | 17 +++ 3 files changed, 93 insertions(+), 88 deletions(-) create mode 100644 src/main/java/com/carecode/domain/health/dto/response/HospitalLikeStatusResponse.java diff --git a/src/main/java/com/carecode/domain/health/app/HealthFacade.java b/src/main/java/com/carecode/domain/health/app/HealthFacade.java index f7316d3d..330bf8db 100644 --- a/src/main/java/com/carecode/domain/health/app/HealthFacade.java +++ b/src/main/java/com/carecode/domain/health/app/HealthFacade.java @@ -45,7 +45,6 @@ public class HealthFacade { // ==================== 건강 기록 관리 ==================== // 트랜잭션은 Service 계층에서 관리하므로 Facade에서는 제거 - public HealthRecordResponse createHealthRecord(HealthCreateHealthRecordRequest request, Long actorUserId) { return healthService.createHealthRecord(request, actorUserId); } @@ -118,8 +117,8 @@ public List searchChi return healthService.searchChildrenByName(userId, name); } - // ==================== 건강 분석 및 리포트 ==================== - + // ==================== + // 건강 분석 및 리포트 ==================== public Map analyzeHealthStatus(HealthCreateHealthRecordRequest request, Long actorUserId) { return healthService.analyzeHealthStatus(request, actorUserId); } @@ -147,9 +146,7 @@ public Map checkSystemHealth() { } // ==================== 병원 관리 ==================== - // 병원 관련 작업은 Facade에서 직접 처리하므로 트랜잭션 필요 - // 하지만 Service 계층으로 이동하는 것이 더 나음 (향후 개선) - + // 병원 관련 작업은 Facade에서 직접 처리하므로 트랜잭션 필요 하지만 public List getAllHospitals(int page, int size) { // 테이블 전체를 메모리로 올리지 않도록 항상 페이지 단위로 읽는다. return hospitalRepository.findAll(PageRequest.of(page, size, Sort.by("name"))) @@ -196,10 +193,17 @@ public boolean unlikeHospital(Long id, Long userId) { public long getLikeCount(Long id) { hospitalRepository.findById(id).orElseThrow(() -> new HospitalNotFoundException(id)); - + return hospitalLikeRepository.countByHospitalId(id); } + /** 현재 사용자가 이 병원을 찜했는지 여부. 이 값이 없으면 클라이언트가 찜 상태를 화면에 유지할 수 없어 새로고침마다 초기화된다. */ + public boolean isLikedByUser(Long id, Long userId) { + hospitalRepository.findById(id).orElseThrow(() -> new HospitalNotFoundException(id)); + + return hospitalLikeRepository.existsByHospitalIdAndUserId(id, userId); + } + public List getNearbyHospitals(double lat, double lng, double radius) { // 반경을 미터 단위로 변환 (km -> m) double radiusInMeters = radius * 1000; @@ -223,8 +227,8 @@ public List getPopularHospitals(int limit) { .toList(); } - // ==================== 병원 리뷰 관리 ==================== - + // ==================== + // 병원 리뷰 관리 ==================== public List getHospitalReviews(Long hospitalId) { return hospitalReviewRepository.findByHospitalId(hospitalId).stream() .map(hospitalReviewMapper::toResponse) @@ -272,9 +276,9 @@ public void deleteHospitalReview(Long reviewId, Long userId) { hospitalReviewRepository.delete(review); } - // ==================== Helper Methods ==================== + // ==================== + // Helper Methods ==================== // 매핑은 HospitalMapper/HospitalReviewMapper에 위임 } - diff --git a/src/main/java/com/carecode/domain/health/controller/HealthController.java b/src/main/java/com/carecode/domain/health/controller/HealthController.java index 81d7f787..c7e7f7cb 100644 --- a/src/main/java/com/carecode/domain/health/controller/HealthController.java +++ b/src/main/java/com/carecode/domain/health/controller/HealthController.java @@ -28,10 +28,7 @@ import java.util.List; import java.util.Date; -/** - * 통합 건강 관리 컨트롤러 - * 건강 정보, 병원 정보, 병원 리뷰 등 모든 건강 관련 API - */ +/** 통합 건강 관리 컨트롤러 */ @RestController @RequestMapping("/health") @RequiredArgsConstructor @@ -43,14 +40,13 @@ public class HealthController extends BaseController { private final HealthFacade healthFacade; private final CurrentUserFacade currentUserFacade; - // ==================== 건강 정보 관리 ==================== - + // ==================== + // 건강 정보 관리 ==================== // 건강 정보 등록 - @PostMapping("/records") @LogExecutionTime - @Operation(summary = "건강 정보 등록", description = "새로운 건강 정보를 등록합니다.") + @Operation(summary = "건강 정보 등록") public ResponseEntity createHealthRecord(@Parameter(description = "건강 정보", required = true) @RequestBody HealthCreateHealthRecordRequest request) { @@ -59,12 +55,10 @@ public ResponseEntity createHealthRecord(@Parameter(descri return ResponseEntity.ok(record); } - // 건강 정보 조회 - @GetMapping("/records/{recordId}") @LogExecutionTime - @Operation(summary = "건강 정보 조회", description = "특정 건강 정보를 조회합니다.") + @Operation(summary = "건강 정보 조회") public ResponseEntity getHealthRecord(@Parameter(description = "건강 정보 ID", required = true) @PathVariable Long recordId) { HealthRecordResponse record = healthFacade.getHealthRecordById(recordId, getAuthenticatedUserPk()); @@ -72,24 +66,20 @@ public ResponseEntity getHealthRecord(@Parameter(descripti return ResponseEntity.ok(record); } - // 사용자별 건강 정보 조회 - @GetMapping("/records/user/{userId}") @LogExecutionTime - @Operation(summary = "사용자별 건강 정보 조회", description = "특정 사용자의 모든 건강 정보를 조회합니다.") + @Operation(summary = "사용자별 건강 정보 조회", description = "특정 사용자의 모든 건강 정보 조회") public ResponseEntity> getUserHealthRecords(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId) { List records = healthFacade.getHealthRecordsByUserId(getAuthenticatedUserCode(), getAuthenticatedUserPk()); return ResponseEntity.ok(records); } - // 건강 정보 수정 - @PutMapping("/records/{recordId}") @LogExecutionTime - @Operation(summary = "건강 정보 수정", description = "기존 건강 정보를 수정합니다.") + @Operation(summary = "건강 정보 수정") public ResponseEntity updateHealthRecord(@Parameter(description = "건강 정보 ID", required = true) @PathVariable Long recordId, @Parameter(description = "수정할 건강 정보", required = true) @RequestBody HealthUpdateHealthRecordRequest request) { @@ -98,12 +88,10 @@ public ResponseEntity updateHealthRecord(@Parameter(descri return ResponseEntity.ok(record); } - // 건강 정보 삭제 - @DeleteMapping("/records/{recordId}") @LogExecutionTime - @Operation(summary = "건강 정보 삭제", description = "건강 정보를 삭제합니다.") + @Operation(summary = "건강 정보 삭제") public ResponseEntity deleteHealthRecord(@Parameter(description = "건강 정보 ID", required = true) @PathVariable Long recordId) { healthFacade.deleteHealthRecord(recordId, getAuthenticatedUserPk()); @@ -113,7 +101,7 @@ public ResponseEntity deleteHealthRecord(@Parameter(description = "건강 @PostMapping("/records/{recordId}/attachments") @LogExecutionTime - @Operation(summary = "건강 기록 첨부 추가", description = "건강 기록에 첨부파일 메타 정보를 추가합니다.") + @Operation(summary = "건강 기록 첨부 추가", description = "건강 기록에 첨부파일 메타 정보 추가") public ResponseEntity addAttachment(@PathVariable Long recordId, @RequestBody HealthRecordAttachmentRequest request) { return ResponseEntity.ok(healthFacade.addAttachment(recordId, request, getAuthenticatedUserPk())); @@ -121,39 +109,36 @@ public ResponseEntity addAttachment(@PathVariabl @GetMapping("/records/{recordId}/attachments") @LogExecutionTime - @Operation(summary = "건강 기록 첨부 조회", description = "건강 기록의 첨부파일 목록을 조회합니다.") + @Operation(summary = "건강 기록 첨부 조회", description = "건강 기록의 첨부파일 목록 조회") public ResponseEntity> getAttachments(@PathVariable Long recordId) { return ResponseEntity.ok(healthFacade.getAttachments(recordId, getAuthenticatedUserPk())); } @DeleteMapping("/records/attachments/{attachmentId}") @LogExecutionTime - @Operation(summary = "건강 기록 첨부 삭제", description = "건강 기록 첨부파일을 비활성화합니다.") + @Operation(summary = "건강 기록 첨부 삭제", description = "건강 기록 첨부파일을 비활성화") public ResponseEntity deleteAttachment(@PathVariable Long attachmentId) { healthFacade.deleteAttachment(attachmentId, getAuthenticatedUserPk()); return ResponseEntity.ok(ApiSuccess.builder().timestamp(new Date()).message("첨부파일이 삭제되었습니다.").build()); } - // ==================== 건강 통계 ==================== - + // ==================== + // 건강 통계 ==================== // 건강 통계 조회 - @GetMapping("/statistics") @LogExecutionTime - @Operation(summary = "건강 통계 조회", description = "사용자의 건강 관련 통계를 조회합니다.") + @Operation(summary = "건강 통계 조회", description = "사용자의 건강 관련 통계 조회") public ResponseEntity getHealthStatistics(@Parameter(description = "사용자 ID", required = true) @RequestParam String userId) { HealthStatsResponse statistics = healthFacade.getHealthStatistics(getAuthenticatedUserCode(), getAuthenticatedUserPk()); return ResponseEntity.ok(statistics); } - // 예방접종 스케줄 조회 - @GetMapping("/vaccines/schedule") @LogExecutionTime - @Operation(summary = "예방접종 스케줄 조회", description = "아동의 예방접종 스케줄을 조회합니다.") + @Operation(summary = "예방접종 스케줄 조회") public ResponseEntity> getVaccineSchedule(@Parameter(description = "아동 ID", required = true) @RequestParam String childId) { List schedule = healthFacade.getVaccineSchedule(childId, getAuthenticatedUserPk()); @@ -161,12 +146,10 @@ public ResponseEntity> getVaccineSchedule(@Paramet return ResponseEntity.ok(schedule); } - // 건강 검진 스케줄 조회 - @GetMapping("/checkups/schedule") @LogExecutionTime - @Operation(summary = "건강 검진 스케줄 조회", description = "아동의 건강 검진 스케줄을 조회합니다.") + @Operation(summary = "건강 검진 스케줄 조회") public ResponseEntity> getCheckupSchedule(@Parameter(description = "아동 ID", required = true) @RequestParam String childId) { List schedule = healthFacade.getCheckupSchedule(childId, getAuthenticatedUserPk()); @@ -174,12 +157,10 @@ public ResponseEntity> getCheckupSchedule(@Paramet return ResponseEntity.ok(schedule); } - // 건강 알림 조회 - @GetMapping("/alerts") @LogExecutionTime - @Operation(summary = "건강 알림 조회", description = "사용자의 건강 관련 알림을 조회합니다.") + @Operation(summary = "건강 알림 조회", description = "사용자의 건강 관련 알림 조회") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공"), @ApiResponse(responseCode = "401", description = "인증 필요"), @@ -193,17 +174,18 @@ public ResponseEntity> getHealthAlerts( @GetMapping("/recommendations") @LogExecutionTime - @Operation(summary = "연계 추천 조회", description = "아동 연령 기반 정책/시설 연계 추천을 제공합니다.") + @Operation(summary = "연계 추천 조회", description = "아동 연령 기반 정책/시설 연계 추천 제공") public ResponseEntity> getIntegratedRecommendations() { return ResponseEntity.ok(healthFacade.getIntegratedRecommendations(getAuthenticatedUserCode(), getAuthenticatedUserPk())); } - // ==================== 병원 관리 ==================== + // ==================== + // 병원 관리 ==================== // 모든 병원 조회 @GetMapping("/hospitals") @LogExecutionTime - @Operation(summary = "모든 병원 조회", description = "등록된 모든 병원 정보를 조회합니다.") + @Operation(summary = "모든 병원 조회", description = "등록된 모든 병원 정보 조회") public ResponseEntity> getAllHospitals( @Parameter(description = "페이지 번호 (0부터)") @RequestParam(required = false) Integer page, @Parameter(description = "페이지 크기 (최대 200)") @RequestParam(required = false) Integer size) { @@ -212,11 +194,10 @@ public ResponseEntity> getAllHospitals( return ResponseEntity.ok(hospitals); } - // 병원 상세 조회 @GetMapping("/hospitals/{id}") @LogExecutionTime - @Operation(summary = "병원 상세 조회", description = "특정 병원의 상세 정보를 조회합니다.") + @Operation(summary = "병원 상세 조회", description = "특정 병원의 상세 정보 조회") public ResponseEntity getHospitalById(@Parameter(description = "병원 ID", required = true) @PathVariable Long id) { HospitalInfoResponse hospital = healthFacade.getHospitalById(id); @@ -224,11 +205,10 @@ public ResponseEntity getHospitalById(@Parameter(descripti return ResponseEntity.ok(hospital); } - // 근처 병원 조회 @GetMapping("/hospitals/nearby") @LogExecutionTime - @Operation(summary = "근처 병원 조회", description = "위치 기반으로 근처 병원을 조회합니다.") + @Operation(summary = "근처 병원 조회", description = "위치 기반으로 근처 병원 조회") public ResponseEntity> getNearbyHospitals(@Parameter(description = "위도", required = true) @RequestParam double lat, @Parameter(description = "경도", required = true) @RequestParam double lng, @Parameter(description = "반경(km)", required = true) @RequestParam double radius) { @@ -238,11 +218,10 @@ public ResponseEntity> getNearbyHospitals(@Parameter( return ResponseEntity.ok(hospitals); } - // 병원 타입별 조회 @GetMapping("/hospitals/type/{type}") @LogExecutionTime - @Operation(summary = "병원 타입별 조회", description = "특정 타입의 병원들을 조회합니다.") + @Operation(summary = "병원 타입별 조회", description = "특정 타입의 병원들 조회") public ResponseEntity> getHospitalsByType(@Parameter(description = "병원 타입", required = true) @PathVariable String type) { List hospitals = healthFacade.getHospitalsByType(type); @@ -250,14 +229,12 @@ public ResponseEntity> getHospitalsByType(@Parameter( return ResponseEntity.ok(hospitals); } - // 병원 좋아요 - @PostMapping("/hospitals/{id}/like") @LogExecutionTime - @Operation(summary = "병원 좋아요", description = "병원에 좋아요를 추가합니다.") + @Operation(summary = "병원 좋아요", description = "병원에 좋아요 추가") public ResponseEntity likeHospital(@Parameter(description = "병원 ID", required = true) @PathVariable Long id, - @Parameter(description = "사용자 ID", required = true) @RequestParam Long userId) { + @Parameter(description = "(사용하지 않음) 대상은 인증 주체로 결정됩니다") @RequestParam(required = false) Long userId) { boolean success = healthFacade.likeHospital(id, getAuthenticatedUserPk()); if (!success) { @@ -267,13 +244,12 @@ public ResponseEntity likeHospital(@Parameter(description = "병원 ID", requ return ResponseEntity.ok().build(); } - // 병원 좋아요 취소 @DeleteMapping("/hospitals/{id}/like") @LogExecutionTime - @Operation(summary = "병원 좋아요 취소", description = "병원의 좋아요를 취소합니다.") + @Operation(summary = "병원 좋아요 취소", description = "병원의 좋아요를 취소") public ResponseEntity unlikeHospital(@Parameter(description = "병원 ID", required = true) @PathVariable Long id, - @Parameter(description = "사용자 ID", required = true) @RequestParam Long userId) { + @Parameter(description = "(사용하지 않음) 대상은 인증 주체로 결정됩니다") @RequestParam(required = false) Long userId) { boolean success = healthFacade.unlikeHospital(id, getAuthenticatedUserPk()); if (!success) { @@ -283,11 +259,10 @@ public ResponseEntity unlikeHospital(@Parameter(description = "병원 ID", re return ResponseEntity.ok().build(); } - // 병원 좋아요 수 조회 @GetMapping("/hospitals/{id}/likes") @LogExecutionTime - @Operation(summary = "병원 좋아요 수 조회", description = "병원의 좋아요 수를 조회합니다.") + @Operation(summary = "병원 좋아요 수 조회") public ResponseEntity getHospitalLikeCount(@Parameter(description = "병원 ID", required = true) @PathVariable Long id) { long likeCount = healthFacade.getLikeCount(id); @@ -295,11 +270,23 @@ public ResponseEntity getHospitalLikeCount(@Parameter(description = "병 return ResponseEntity.ok(likeCount); } + // 내 좋아요 여부 + 총 개수 + @GetMapping("/hospitals/{id}/like-status") + @LogExecutionTime + @Operation(summary = "병원 좋아요 상태 조회", description = "찜 여부와 총 개수를 함께 반환") + public ResponseEntity getHospitalLikeStatus( + @Parameter(description = "병원 ID", required = true) @PathVariable Long id) { + return ResponseEntity.ok(HospitalLikeStatusResponse.builder() + .hospitalId(id) + .liked(healthFacade.isLikedByUser(id, getAuthenticatedUserPk())) + .likeCount(healthFacade.getLikeCount(id)) + .build()); + } // 인기 병원 조회 @GetMapping("/hospitals/popular") @LogExecutionTime - @Operation(summary = "인기 병원 조회", description = "좋아요가 많은 인기 병원들을 조회합니다.") + @Operation(summary = "인기 병원 조회", description = "좋아요가 많은 인기 병원들 조회") public ResponseEntity> getPopularHospitals(@Parameter(description = "조회할 개수", required = false) @RequestParam(defaultValue = "10") int limit) { List hospitals = healthFacade.getPopularHospitals(limit); @@ -307,26 +294,25 @@ public ResponseEntity> getPopularHospitals(@Parameter return ResponseEntity.ok(hospitals); } - // ==================== 병원 리뷰 관리 ==================== + // ==================== + // 병원 리뷰 관리 ==================== // 병원 리뷰 작성 @PostMapping("/hospitals/{id}/reviews") @LogExecutionTime - @Operation(summary = "병원 리뷰 작성", description = "병원에 리뷰를 작성합니다.") + @Operation(summary = "병원 리뷰 작성", description = "병원에 리뷰를 작성") public ResponseEntity createHospitalReview(@Parameter(description = "병원 ID", required = true) @PathVariable Long id, @Parameter(description = "리뷰 정보", required = true) @RequestBody HealthCreateHospitalReviewRequest request, - @Parameter(description = "사용자 ID", required = true) @RequestParam Long userId) { + @Parameter(description = "(사용하지 않음) 대상은 인증 주체로 결정됩니다") @RequestParam(required = false) Long userId) { HospitalReviewResponse review = healthFacade.createHospitalReview(id, getAuthenticatedUserPk(), request.getRating(), request.getContent()); return ResponseEntity.ok(review); } - // 병원 리뷰 조회 - @GetMapping("/hospitals/{id}/reviews") @LogExecutionTime - @Operation(summary = "병원 리뷰 조회", description = "특정 병원의 모든 리뷰를 조회합니다.") + @Operation(summary = "병원 리뷰 조회", description = "특정 병원의 모든 리뷰 조회") public ResponseEntity> getHospitalReviews(@Parameter(description = "병원 ID", required = true) @PathVariable Long id) { List reviews = healthFacade.getHospitalReviews(id); @@ -334,39 +320,36 @@ public ResponseEntity> getHospitalReviews(@Paramete return ResponseEntity.ok(reviews); } - // 병원 리뷰 수정 - @PutMapping("/hospitals/reviews/{reviewId}") @LogExecutionTime - @Operation(summary = "병원 리뷰 수정", description = "기존 병원 리뷰를 수정합니다.") + @Operation(summary = "병원 리뷰 수정") public ResponseEntity updateHospitalReview(@Parameter(description = "리뷰 ID", required = true) @PathVariable Long reviewId, @Parameter(description = "수정할 리뷰 정보", required = true) @RequestBody HealthUpdateHospitalReviewRequest request, - @Parameter(description = "사용자 ID", required = true) @RequestParam Long userId) { + @Parameter(description = "(사용하지 않음) 대상은 인증 주체로 결정됩니다") @RequestParam(required = false) Long userId) { HospitalReviewResponse review = healthFacade.updateHospitalReview(reviewId, getAuthenticatedUserPk(), request.getRating(), request.getContent()); return ResponseEntity.ok(review); } - // 병원 리뷰 삭제 - @DeleteMapping("/hospitals/reviews/{reviewId}") @LogExecutionTime - @Operation(summary = "병원 리뷰 삭제", description = "병원 리뷰를 삭제합니다.") + @Operation(summary = "병원 리뷰 삭제") public ResponseEntity deleteHospitalReview(@Parameter(description = "리뷰 ID", required = true) @PathVariable Long reviewId, - @Parameter(description = "사용자 ID", required = true) @RequestParam Long userId) { + @Parameter(description = "(사용하지 않음) 대상은 인증 주체로 결정됩니다") @RequestParam(required = false) Long userId) { healthFacade.deleteHospitalReview(reviewId, getAuthenticatedUserPk()); return ResponseEntity.ok().build(); } - // ==================== 건강 기록 필터링 기능 ==================== + // ==================== + // 건강 기록 필터링 기능 ==================== // 기간별 건강 기록 조회 (오래된순) @GetMapping("/records/date-range-asc") @LogExecutionTime - @Operation(summary = "기간별 건강 기록 조회 (오래된순)", description = "특정 기간의 건강 기록을 오래된순으로 조회합니다.") + @Operation(summary = "기간별 건강 기록 조회 (오래된순)", description = "특정 기간의 건강 기록을 오래된순으로 조회") public ResponseEntity> getHealthRecordsByDateRangeAsc(@Parameter(description = "아동 ID", required = true) @RequestParam Long childId, @Parameter(description = "시작일 (yyyy-MM-dd)", required = true) @RequestParam String startDate, @Parameter(description = "종료일 (yyyy-MM-dd)", required = true) @RequestParam String endDate) { @@ -377,7 +360,7 @@ public ResponseEntity> getHealthRecordsByDateRangeAsc // 타입별 건강 기록 조회 @GetMapping("/records/type") @LogExecutionTime - @Operation(summary = "타입별 건강 기록 조회", description = "특정 타입의 건강 기록을 조회합니다.") + @Operation(summary = "타입별 건강 기록 조회") public ResponseEntity> getHealthRecordsByType(@Parameter(description = "아동 ID", required = true) @RequestParam Long childId, @Parameter(description = "기록 타입 (VACCINATION, CHECKUP, MEDICATION, SYMPTOM, OTHER)", required = true) @RequestParam String recordType) { List records = healthFacade.getHealthRecordsByType(childId, HealthRecord.RecordType.valueOf(recordType), getAuthenticatedUserPk()); @@ -385,12 +368,13 @@ public ResponseEntity> getHealthRecordsByType(@Parame return ResponseEntity.ok(records); } - // ==================== 자녀 관리 기능 ==================== + // ==================== + // 자녀 관리 기능 ==================== // 연령 범위별 자녀 조회 @GetMapping("/children/age-range") @LogExecutionTime - @Operation(summary = "연령 범위별 자녀 조회", description = "특정 연령 범위에 해당하는 자녀를 조회합니다.") + @Operation(summary = "연령 범위별 자녀 조회", description = "특정 연령 범위에 해당하는 자녀 조회") public ResponseEntity> getChildrenByAgeRange(@Parameter(description = "사용자 ID", required = true) @RequestParam Long userId, @Parameter(description = "최소 연령", required = true) @RequestParam Integer minAge, @Parameter(description = "최대 연령", required = true) @RequestParam Integer maxAge) { List children = healthFacade.getChildrenByAgeRange(getAuthenticatedUserPk(), minAge, maxAge); @@ -400,7 +384,7 @@ public ResponseEntity> getChildrenByGender(@Parameter(description = "사용자 ID", required = true) @RequestParam Long userId, @Parameter(description = "성별 (MALE, FEMALE)", required = true) @RequestParam String gender) { List children = healthFacade.getChildrenByGender(getAuthenticatedUserPk(), gender); @@ -410,7 +394,7 @@ public ResponseEntity> getChildrenWithSpecialNeeds(@Parameter(description = "사용자 ID", required = true) @RequestParam Long userId) { List children = healthFacade.getChildrenWithSpecialNeeds(getAuthenticatedUserPk()); @@ -420,7 +404,7 @@ public ResponseEntity> searchChildrenByName(@Parameter(description = "사용자 ID", required = true) @RequestParam Long userId, @Parameter(description = "검색할 이름", required = true) @RequestParam String name) { List children = healthFacade.searchChildrenByName(getAuthenticatedUserPk(), name); diff --git a/src/main/java/com/carecode/domain/health/dto/response/HospitalLikeStatusResponse.java b/src/main/java/com/carecode/domain/health/dto/response/HospitalLikeStatusResponse.java new file mode 100644 index 00000000..a03d8e5d --- /dev/null +++ b/src/main/java/com/carecode/domain/health/dto/response/HospitalLikeStatusResponse.java @@ -0,0 +1,17 @@ +package com.carecode.domain.health.dto.response; + +import lombok.Builder; +import lombok.Getter; + +/** 병원 좋아요(찜) 상태 응답. 여부와 총 개수를 한 번에 내려 요청을 두 번 하지 않게 한다. */ +@Getter +@Builder +public class HospitalLikeStatusResponse { + + private final Long hospitalId; + + /** 현재 로그인한 사용자가 이 병원을 찜했는지 여부 */ + private final boolean liked; + + private final long likeCount; +} From cb276b21701eabd7411c24d4ebf88a011841c7ca Mon Sep 17 00:00:00 2001 From: RosieOh Date: Wed, 5 Aug 2026 14:51:15 +0900 Subject: [PATCH 14/68] =?UTF-8?q?FEAT=20:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=ED=96=89=EB=8F=99=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A7=91=20=EB=B0=8F=20=ED=8D=BC=EB=84=90=C2=B7=EB=A6=AC?= =?UTF-8?q?=ED=85=90=EC=85=98=20=EC=A7=91=EA=B3=84=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/analytics/AnalyticsService.java | 102 ++++++++++++++++ .../carecode/core/analytics/EventLogger.java | 53 +++++++++ .../carecode/core/analytics/EventType.java | 27 +++++ .../carecode/core/analytics/UserEvent.java | 55 +++++++++ .../core/analytics/UserEventRepository.java | 46 ++++++++ .../core/analytics/dto/FunnelResponse.java | 27 +++++ .../core/analytics/dto/RetentionResponse.java | 26 ++++ .../com/carecode/core/config/AsyncConfig.java | 16 +++ .../controller/AdminAnalyticsController.java | 62 ++++++++++ .../controller/BenefitLinkController.java | 68 +++++++++++ .../db/migration/V8__user_events.sql | 15 +++ .../core/analytics/AnalyticsServiceTest.java | 111 ++++++++++++++++++ 12 files changed, 608 insertions(+) create mode 100644 src/main/java/com/carecode/core/analytics/AnalyticsService.java create mode 100644 src/main/java/com/carecode/core/analytics/EventLogger.java create mode 100644 src/main/java/com/carecode/core/analytics/EventType.java create mode 100644 src/main/java/com/carecode/core/analytics/UserEvent.java create mode 100644 src/main/java/com/carecode/core/analytics/UserEventRepository.java create mode 100644 src/main/java/com/carecode/core/analytics/dto/FunnelResponse.java create mode 100644 src/main/java/com/carecode/core/analytics/dto/RetentionResponse.java create mode 100644 src/main/java/com/carecode/domain/admin/controller/AdminAnalyticsController.java create mode 100644 src/main/java/com/carecode/domain/policy/controller/BenefitLinkController.java create mode 100644 src/main/resources/db/migration/V8__user_events.sql create mode 100644 src/test/java/com/carecode/core/analytics/AnalyticsServiceTest.java diff --git a/src/main/java/com/carecode/core/analytics/AnalyticsService.java b/src/main/java/com/carecode/core/analytics/AnalyticsService.java new file mode 100644 index 00000000..de7eacbc --- /dev/null +++ b/src/main/java/com/carecode/core/analytics/AnalyticsService.java @@ -0,0 +1,102 @@ +package com.carecode.core.analytics; + +import com.carecode.core.analytics.dto.FunnelResponse; +import com.carecode.core.analytics.dto.RetentionResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** 수집한 이벤트로 퍼널과 리텐션을 계산한다. */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AnalyticsService { + + /** 온보딩부터 핵심 가치까지의 경로. 순서가 곧 퍼널이다. */ + private static final List FUNNEL = List.of( + new StepDef(EventType.SIGNED_UP, "가입"), + new StepDef(EventType.CHILD_REGISTERED, "자녀 등록"), + new StepDef(EventType.MISSED_BENEFIT_VIEWED, "놓친 지원금 확인"), + new StepDef(EventType.BENEFIT_LINK_CLICKED, "신청 링크 클릭")); + + private static final int MAX_COHORT_DAYS = 60; + + private final UserEventRepository eventRepository; + + private record StepDef(EventType type, String label) { + } + + public FunnelResponse funnel(LocalDate from, LocalDate to) { + List steps = new ArrayList<>(); + long previous = 0; + + for (int i = 0; i < FUNNEL.size(); i++) { + StepDef def = FUNNEL.get(i); + // 두 번째 단계부터는 앞 단계를 거친 사용자만 센다. 그래야 전환율이 의미를 갖는다. + long users = i == 0 + ? eventRepository.countDistinctUsers(def.type(), from, to) + : eventRepository.countConverted(FUNNEL.get(i - 1).type(), def.type(), from, to); + + steps.add(FunnelResponse.Step.builder() + .event(def.type().name()) + .label(def.label()) + .users(users) + .conversionRate(i == 0 ? null : percentage(users, previous)) + .build()); + previous = users; + } + + return FunnelResponse.builder().from(from).to(to).steps(steps).build(); + } + + public RetentionResponse retention(LocalDate from, LocalDate to) { + LocalDate start = from.isBefore(to.minusDays(MAX_COHORT_DAYS)) ? to.minusDays(MAX_COHORT_DAYS) : from; + LocalDate today = LocalDate.now(); + List cohorts = new ArrayList<>(); + + for (LocalDate date = start; !date.isAfter(to); date = date.plusDays(1)) { + List signedUp = eventRepository.findUserIdsSignedUpOn(date); + if (signedUp.isEmpty()) { + continue; + } + cohorts.add(RetentionResponse.Cohort.builder() + .signUpDate(date) + .signedUp(signedUp.size()) + .day1(retentionAt(signedUp, date, 1, today)) + .day7(retentionAt(signedUp, date, 7, today)) + .day30(retentionAt(signedUp, date, 30, today)) + .build()); + } + return RetentionResponse.builder().cohorts(cohorts).build(); + } + + /** 아직 그날이 오지 않은 코호트는 0% 가 아니라 미집계다. 구분하지 않으면 지표가 왜곡된다. */ + private Integer retentionAt(List userIds, LocalDate signUpDate, int offset, LocalDate today) { + LocalDate target = signUpDate.plusDays(offset); + if (target.isAfter(today)) { + return null; + } + return percentage(eventRepository.countActiveOn(userIds, target), userIds.size()); + } + + /** 이벤트 종류별 발생 건수. 대시보드 개요용. */ + public Map eventCounts(LocalDate from, LocalDate to) { + Map counts = new LinkedHashMap<>(); + for (Object[] row : eventRepository.countByType(from, to)) { + counts.put(((EventType) row[0]).name(), (Long) row[1]); + } + return counts; + } + + private Integer percentage(long part, long whole) { + return whole == 0 ? 0 : (int) Math.round(100.0 * part / whole); + } +} diff --git a/src/main/java/com/carecode/core/analytics/EventLogger.java b/src/main/java/com/carecode/core/analytics/EventLogger.java new file mode 100644 index 00000000..763589e2 --- /dev/null +++ b/src/main/java/com/carecode/core/analytics/EventLogger.java @@ -0,0 +1,53 @@ +package com.carecode.core.analytics; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** 행동 이벤트 기록. 지표 수집 실패가 기능을 막지 않도록 비동기로 처리하고 예외를 삼킨다. */ +@Slf4j +@Component +@RequiredArgsConstructor +public class EventLogger { + + private static final int MAX_METADATA = 500; + + private final UserEventRepository eventRepository; + + public void log(EventType type, Long userId) { + log(type, userId, null, null); + } + + public void log(EventType type, Long userId, String targetId) { + log(type, userId, targetId, null); + } + + /** 호출부의 트랜잭션과 분리한다. 이벤트 저장 실패가 본 작업을 롤백시키면 안 된다. */ + @Async("analyticsExecutor") + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void log(EventType type, Long userId, String targetId, String metadata) { + try { + eventRepository.save(UserEvent.builder() + .userId(userId) + .eventType(type) + .targetId(truncate(targetId, 100)) + .metadata(truncate(metadata, MAX_METADATA)) + .occurredAt(LocalDateTime.now()) + .build()); + } catch (Exception e) { + log.warn("이벤트 기록 실패 - type={}, 사유={}", type, e.getMessage()); + } + } + + private String truncate(String value, int limit) { + if (value == null) { + return null; + } + return value.length() <= limit ? value : value.substring(0, limit); + } +} diff --git a/src/main/java/com/carecode/core/analytics/EventType.java b/src/main/java/com/carecode/core/analytics/EventType.java new file mode 100644 index 00000000..1613d85f --- /dev/null +++ b/src/main/java/com/carecode/core/analytics/EventType.java @@ -0,0 +1,27 @@ +package com.carecode.core.analytics; + +/** 추적 대상 이벤트. 제품 판단에 쓰이는 것만 남긴다 — 다 찍으면 아무것도 안 보인다. */ +public enum EventType { + + // 온보딩 퍼널 + SIGNED_UP, + CHILD_REGISTERED, + ADDRESS_REGISTERED, + INCOME_REGISTERED, + + // 핵심 가치 — 이 전환율이 서비스의 존재 이유를 증명한다 + MISSED_BENEFIT_VIEWED, + BENEFIT_LINK_CLICKED, + + // 탐색 + RECOMMENDATION_VIEWED, + REGIONAL_COMPARISON_VIEWED, + FACILITY_VIEWED, + ADMISSION_FORECAST_VIEWED, + FACILITY_POPULARITY_VIEWED, + + // 유지 + APP_OPENED, + BOOKING_CREATED, + CHATBOT_ASKED +} diff --git a/src/main/java/com/carecode/core/analytics/UserEvent.java b/src/main/java/com/carecode/core/analytics/UserEvent.java new file mode 100644 index 00000000..11998321 --- /dev/null +++ b/src/main/java/com/carecode/core/analytics/UserEvent.java @@ -0,0 +1,55 @@ +package com.carecode.core.analytics; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** 한 번 쓰면 고치지 않는 append-only 기록. 지표를 나중에 다시 계산할 수 있어야 한다. */ +@Entity +@Table(name = "TBL_USER_EVENT") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class UserEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ID") + private Long id; + + /** 비로그인 이벤트는 null. */ + @Column(name = "USER_ID") + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "EVENT_TYPE", nullable = false, length = 60) + private EventType eventType; + + @Column(name = "TARGET_ID", length = 100) + private String targetId; + + @Column(name = "METADATA", length = 500) + private String metadata; + + @Column(name = "OCCURRED_AT", nullable = false) + private LocalDateTime occurredAt; + + /** 집계 쿼리가 인덱스를 타도록 날짜를 따로 저장한다. */ + @Column(name = "OCCURRED_DATE", nullable = false) + private LocalDate occurredDate; + + @PrePersist + protected void onCreate() { + if (occurredAt == null) { + occurredAt = LocalDateTime.now(); + } + occurredDate = occurredAt.toLocalDate(); + } +} diff --git a/src/main/java/com/carecode/core/analytics/UserEventRepository.java b/src/main/java/com/carecode/core/analytics/UserEventRepository.java new file mode 100644 index 00000000..af9c74a3 --- /dev/null +++ b/src/main/java/com/carecode/core/analytics/UserEventRepository.java @@ -0,0 +1,46 @@ +package com.carecode.core.analytics; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@Repository +public interface UserEventRepository extends JpaRepository { + + /** 퍼널 단계별 고유 사용자 수. 이벤트 발생 횟수가 아니라 사람 수를 센다. */ + @Query("SELECT COUNT(DISTINCT e.userId) FROM UserEvent e " + + "WHERE e.eventType = :type AND e.occurredDate BETWEEN :from AND :to") + long countDistinctUsers(@Param("type") EventType type, + @Param("from") LocalDate from, + @Param("to") LocalDate to); + + /** 앞 단계를 거친 사용자 중 뒤 단계까지 간 사람 수. */ + @Query("SELECT COUNT(DISTINCT e2.userId) FROM UserEvent e2 " + + "WHERE e2.eventType = :next AND e2.occurredDate BETWEEN :from AND :to " + + "AND e2.userId IN (SELECT e1.userId FROM UserEvent e1 " + + "WHERE e1.eventType = :previous AND e1.occurredDate BETWEEN :from AND :to)") + long countConverted(@Param("previous") EventType previous, + @Param("next") EventType next, + @Param("from") LocalDate from, + @Param("to") LocalDate to); + + /** 가입일이 기준일인 사용자들. 리텐션의 분모가 된다. */ + @Query("SELECT DISTINCT e.userId FROM UserEvent e " + + "WHERE e.eventType = com.carecode.core.analytics.EventType.SIGNED_UP " + + "AND e.occurredDate = :date AND e.userId IS NOT NULL") + List findUserIdsSignedUpOn(@Param("date") LocalDate date); + + /** 주어진 사용자들 중 특정 날짜에 활동한 사람 수. */ + @Query("SELECT COUNT(DISTINCT e.userId) FROM UserEvent e " + + "WHERE e.userId IN :userIds AND e.occurredDate = :date") + long countActiveOn(@Param("userIds") List userIds, @Param("date") LocalDate date); + + /** 이벤트 종류별 발생 건수. */ + @Query("SELECT e.eventType, COUNT(e) FROM UserEvent e " + + "WHERE e.occurredDate BETWEEN :from AND :to GROUP BY e.eventType") + List countByType(@Param("from") LocalDate from, @Param("to") LocalDate to); +} diff --git a/src/main/java/com/carecode/core/analytics/dto/FunnelResponse.java b/src/main/java/com/carecode/core/analytics/dto/FunnelResponse.java new file mode 100644 index 00000000..4f92665d --- /dev/null +++ b/src/main/java/com/carecode/core/analytics/dto/FunnelResponse.java @@ -0,0 +1,27 @@ +package com.carecode.core.analytics.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.util.List; + +/** 온보딩·핵심가치 퍼널. 각 단계에 도달한 고유 사용자 수를 센다. */ +@Getter +@Builder +public class FunnelResponse { + + private LocalDate from; + private LocalDate to; + private List steps; + + @Getter + @Builder + public static class Step { + private String event; + private String label; + private long users; + /** 직전 단계 대비 전환율(%). 첫 단계는 null. */ + private Integer conversionRate; + } +} diff --git a/src/main/java/com/carecode/core/analytics/dto/RetentionResponse.java b/src/main/java/com/carecode/core/analytics/dto/RetentionResponse.java new file mode 100644 index 00000000..2518dc32 --- /dev/null +++ b/src/main/java/com/carecode/core/analytics/dto/RetentionResponse.java @@ -0,0 +1,26 @@ +package com.carecode.core.analytics.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.util.List; + +/** 가입일 기준 코호트 리텐션. */ +@Getter +@Builder +public class RetentionResponse { + + private List cohorts; + + @Getter + @Builder + public static class Cohort { + private LocalDate signUpDate; + private long signedUp; + /** D1/D7/D30 잔존율(%). 아직 그날이 오지 않았으면 null. */ + private Integer day1; + private Integer day7; + private Integer day30; + } +} diff --git a/src/main/java/com/carecode/core/config/AsyncConfig.java b/src/main/java/com/carecode/core/config/AsyncConfig.java index 6f706dca..a2f5d790 100644 --- a/src/main/java/com/carecode/core/config/AsyncConfig.java +++ b/src/main/java/com/carecode/core/config/AsyncConfig.java @@ -31,4 +31,20 @@ public Executor notificationExecutor() { executor.initialize(); return executor; } + + /** 행동 로그 전용 풀. 지표 수집이 사용자 응답을 늦추면 안 된다. */ + @Bean(name = "analyticsExecutor") + public Executor analyticsExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(1); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(2000); + executor.setThreadNamePrefix("analytics-"); + // 알림과 달리 이벤트는 버려도 서비스에 지장이 없다. 폭주 시 요청 스레드를 붙잡지 않고 버린다. + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(10); + executor.initialize(); + return executor; + } } diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminAnalyticsController.java b/src/main/java/com/carecode/domain/admin/controller/AdminAnalyticsController.java new file mode 100644 index 00000000..1c4984ff --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/controller/AdminAnalyticsController.java @@ -0,0 +1,62 @@ +package com.carecode.domain.admin.controller; + +import com.carecode.core.analytics.AnalyticsService; +import com.carecode.core.analytics.dto.FunnelResponse; +import com.carecode.core.analytics.dto.RetentionResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.util.Map; + +/** 지표 조회 API. */ +@RestController +@RequestMapping("/api/admin/analytics") +@RequiredArgsConstructor +@Tag(name = "어드민 - 지표", description = "퍼널·리텐션 지표") +public class AdminAnalyticsController { + + private static final int DEFAULT_RANGE_DAYS = 30; + + private final AnalyticsService analyticsService; + + @GetMapping("/funnel") + @Operation(summary = "온보딩 퍼널 조회", description = "단계별 도달 사용자 수와 전환율") + public ResponseEntity funnel( + @Parameter(description = "시작일 (기본: 30일 전)") + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @Parameter(description = "종료일 (기본: 오늘)") + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { + LocalDate end = to != null ? to : LocalDate.now(); + LocalDate start = from != null ? from : end.minusDays(DEFAULT_RANGE_DAYS); + return ResponseEntity.ok(analyticsService.funnel(start, end)); + } + + @GetMapping("/retention") + @Operation(summary = "코호트 리텐션 조회", description = "가입일 기준 D1/D7/D30 잔존율") + public ResponseEntity retention( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { + LocalDate end = to != null ? to : LocalDate.now(); + LocalDate start = from != null ? from : end.minusDays(DEFAULT_RANGE_DAYS); + return ResponseEntity.ok(analyticsService.retention(start, end)); + } + + @GetMapping("/events") + @Operation(summary = "이벤트 발생 건수", description = "종류별 집계") + public ResponseEntity> events( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { + LocalDate end = to != null ? to : LocalDate.now(); + LocalDate start = from != null ? from : end.minusDays(DEFAULT_RANGE_DAYS); + return ResponseEntity.ok(analyticsService.eventCounts(start, end)); + } +} diff --git a/src/main/java/com/carecode/domain/policy/controller/BenefitLinkController.java b/src/main/java/com/carecode/domain/policy/controller/BenefitLinkController.java new file mode 100644 index 00000000..400251a3 --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/controller/BenefitLinkController.java @@ -0,0 +1,68 @@ +package com.carecode.domain.policy.controller; + +import com.carecode.core.analytics.EventLogger; +import com.carecode.core.analytics.EventType; +import com.carecode.core.exception.CareServiceException; +import com.carecode.core.security.CurrentUserFacade; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.repository.PolicyRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URI; + +/** + * 지원금 신청 링크를 거쳐 가게 해서 클릭을 집계한다. + * 이 전환율이 서비스가 실제로 돈을 찾아줬는지 증명하는 유일한 지표다. + */ +@Slf4j +@RestController +@RequestMapping("/policies") +@RequiredArgsConstructor +@Tag(name = "육아 정책", description = "육아 정책 정보 및 검색 API") +public class BenefitLinkController { + + private final PolicyRepository policyRepository; + private final EventLogger eventLogger; + private final CurrentUserFacade currentUserFacade; + + @GetMapping("/{policyId}/apply") + @Operation(summary = "지원금 신청 링크 이동", description = "클릭을 집계한 뒤 신청 페이지로 리다이렉트") + public ResponseEntity apply( + @Parameter(description = "정책 ID", required = true) @PathVariable Long policyId) { + + Policy policy = policyRepository.findById(policyId) + .orElseThrow(() -> new CareServiceException("정책을 찾을 수 없습니다: " + policyId)); + + String url = policy.getApplicationUrl(); + if (url == null || url.isBlank()) { + throw new CareServiceException("이 정책은 온라인 신청 경로가 없습니다."); + } + // 외부 URL 로 리다이렉트하므로 스킴을 확인한다. javascript: 같은 값이 들어오면 XSS 가 된다. + if (!url.startsWith("http://") && !url.startsWith("https://")) { + throw new CareServiceException("허용되지 않은 신청 링크입니다."); + } + + eventLogger.log(EventType.BENEFIT_LINK_CLICKED, currentUserIdOrNull(), String.valueOf(policyId)); + + return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(url)).build(); + } + + /** 비로그인 클릭도 집계 대상이다. 인증 실패로 리다이렉트를 막지 않는다. */ + private Long currentUserIdOrNull() { + try { + return currentUserFacade.requireCurrentUserDbId(); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/resources/db/migration/V8__user_events.sql b/src/main/resources/db/migration/V8__user_events.sql new file mode 100644 index 00000000..76f17e64 --- /dev/null +++ b/src/main/resources/db/migration/V8__user_events.sql @@ -0,0 +1,15 @@ +-- 사용자 행동 이벤트. 전환율·리텐션을 사후에 계산할 수 있게 원본을 남긴다 +CREATE TABLE TBL_USER_EVENT ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + USER_ID BIGINT NULL COMMENT '비로그인 이벤트는 NULL', + EVENT_TYPE VARCHAR(60) NOT NULL COMMENT '이벤트 종류', + TARGET_ID VARCHAR(100) NULL COMMENT '대상 식별자 (정책 ID, 시설 ID 등)', + METADATA VARCHAR(500) NULL COMMENT '부가 정보 (JSON)', + OCCURRED_AT DATETIME NOT NULL, + OCCURRED_DATE DATE NOT NULL COMMENT '일 단위 집계용 — 리텐션 쿼리가 매번 DATE() 를 쓰면 인덱스를 못 탄다' +) COMMENT '사용자 행동 이벤트 로그'; + +-- 퍼널: 특정 이벤트의 기간별 사용자 수 +CREATE INDEX IDX_EVENT_TYPE_DATE ON TBL_USER_EVENT (EVENT_TYPE, OCCURRED_DATE); +-- 리텐션: 사용자별 활동일 +CREATE INDEX IDX_EVENT_USER_DATE ON TBL_USER_EVENT (USER_ID, OCCURRED_DATE); diff --git a/src/test/java/com/carecode/core/analytics/AnalyticsServiceTest.java b/src/test/java/com/carecode/core/analytics/AnalyticsServiceTest.java new file mode 100644 index 00000000..d45d9bba --- /dev/null +++ b/src/test/java/com/carecode/core/analytics/AnalyticsServiceTest.java @@ -0,0 +1,111 @@ +package com.carecode.core.analytics; + +import com.carecode.core.analytics.dto.FunnelResponse; +import com.carecode.core.analytics.dto.RetentionResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("퍼널·리텐션 집계") +class AnalyticsServiceTest { + + private UserEventRepository repository; + private AnalyticsService service; + private final LocalDate from = LocalDate.now().minusDays(30); + private final LocalDate to = LocalDate.now(); + + @BeforeEach + void setUp() { + repository = mock(UserEventRepository.class); + service = new AnalyticsService(repository); + } + + @Test + @DisplayName("퍼널은 앞 단계를 거친 사용자만 세어 전환율을 낸다") + void countsOnlyConvertedUsers() { + when(repository.countDistinctUsers(eq(EventType.SIGNED_UP), any(), any())).thenReturn(100L); + when(repository.countConverted(eq(EventType.SIGNED_UP), eq(EventType.CHILD_REGISTERED), any(), any())) + .thenReturn(60L); + when(repository.countConverted(eq(EventType.CHILD_REGISTERED), eq(EventType.MISSED_BENEFIT_VIEWED), any(), any())) + .thenReturn(30L); + when(repository.countConverted(eq(EventType.MISSED_BENEFIT_VIEWED), eq(EventType.BENEFIT_LINK_CLICKED), any(), any())) + .thenReturn(9L); + + FunnelResponse result = service.funnel(from, to); + + assertThat(result.getSteps()).hasSize(4); + assertThat(result.getSteps().get(0).getUsers()).isEqualTo(100); + assertThat(result.getSteps().get(0).getConversionRate()).isNull(); + assertThat(result.getSteps().get(1).getConversionRate()).isEqualTo(60); + assertThat(result.getSteps().get(2).getConversionRate()).isEqualTo(50); + // 신청 링크 클릭 = 서비스가 실제로 돈을 찾아줬는지 증명하는 지표 + assertThat(result.getSteps().get(3).getConversionRate()).isEqualTo(30); + } + + @Test + @DisplayName("앞 단계가 0명이면 전환율을 0으로 둔다") + void handlesEmptyFunnel() { + when(repository.countDistinctUsers(any(), any(), any())).thenReturn(0L); + when(repository.countConverted(any(), any(), any(), any())).thenReturn(0L); + + FunnelResponse result = service.funnel(from, to); + + assertThat(result.getSteps().get(1).getConversionRate()).isZero(); + } + + @Test + @DisplayName("리텐션은 가입일 코호트별로 잔존율을 계산한다") + void calculatesCohortRetention() { + LocalDate signUp = LocalDate.now().minusDays(40); + when(repository.findUserIdsSignedUpOn(any())).thenReturn(List.of()); + when(repository.findUserIdsSignedUpOn(signUp)).thenReturn(List.of(1L, 2L, 3L, 4L)); + when(repository.countActiveOn(anyList(), eq(signUp.plusDays(1)))).thenReturn(2L); + when(repository.countActiveOn(anyList(), eq(signUp.plusDays(7)))).thenReturn(1L); + when(repository.countActiveOn(anyList(), eq(signUp.plusDays(30)))).thenReturn(1L); + + RetentionResponse result = service.retention(LocalDate.now().minusDays(45), LocalDate.now()); + + RetentionResponse.Cohort cohort = result.getCohorts().stream() + .filter(c -> c.getSignUpDate().equals(signUp)).findFirst().orElseThrow(); + assertThat(cohort.getSignedUp()).isEqualTo(4); + assertThat(cohort.getDay1()).isEqualTo(50); + assertThat(cohort.getDay7()).isEqualTo(25); + assertThat(cohort.getDay30()).isEqualTo(25); + } + + @Test + @DisplayName("아직 오지 않은 날짜는 0%가 아니라 미집계로 둔다") + void marksFutureCheckpointsAsUnknown() { + LocalDate signUp = LocalDate.now().minusDays(2); + when(repository.findUserIdsSignedUpOn(any())).thenReturn(List.of()); + when(repository.findUserIdsSignedUpOn(signUp)).thenReturn(List.of(1L, 2L)); + when(repository.countActiveOn(anyList(), any())).thenReturn(1L); + + RetentionResponse result = service.retention(LocalDate.now().minusDays(5), LocalDate.now()); + + RetentionResponse.Cohort cohort = result.getCohorts().stream() + .filter(c -> c.getSignUpDate().equals(signUp)).findFirst().orElseThrow(); + assertThat(cohort.getDay1()).isEqualTo(50); + // D7·D30 은 아직 도래하지 않았다 — 0% 로 보이면 리텐션이 폭락한 것처럼 왜곡된다 + assertThat(cohort.getDay7()).isNull(); + assertThat(cohort.getDay30()).isNull(); + } + + @Test + @DisplayName("가입자가 없는 날짜는 코호트에서 제외한다") + void skipsEmptyCohorts() { + when(repository.findUserIdsSignedUpOn(any())).thenReturn(List.of()); + + assertThat(service.retention(from, to).getCohorts()).isEmpty(); + } +} From 35fefb1308fda43695a5e256ab1955a9737bc090 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Wed, 5 Aug 2026 14:51:15 +0900 Subject: [PATCH 15/68] =?UTF-8?q?FEAT=20:=20=EA=B1=B4=EA=B0=95=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EB=AF=BC=EA=B0=90=EC=A0=95=EB=B3=B4=20=EB=8F=99?= =?UTF-8?q?=EC=9D=98=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EC=A0=91=EA=B7=BC?= =?UTF-8?q?=20=EC=B0=A8=EB=8B=A8=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...tomizedResponseEntityExceptionHandler.java | 28 ++++++ .../domain/health/service/HealthService.java | 5 ++ .../domain/user/entity/ConsentType.java | 26 ++++-- .../domain/user/service/ConsentGuard.java | 48 ++++++++++ .../health/service/HealthServiceTest.java | 5 ++ .../domain/user/service/ConsentGuardTest.java | 89 +++++++++++++++++++ 6 files changed, 195 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/carecode/domain/user/service/ConsentGuard.java create mode 100644 src/test/java/com/carecode/domain/user/service/ConsentGuardTest.java diff --git a/src/main/java/com/carecode/core/handler/CustomizedResponseEntityExceptionHandler.java b/src/main/java/com/carecode/core/handler/CustomizedResponseEntityExceptionHandler.java index 12b2459d..86d98ff0 100644 --- a/src/main/java/com/carecode/core/handler/CustomizedResponseEntityExceptionHandler.java +++ b/src/main/java/com/carecode/core/handler/CustomizedResponseEntityExceptionHandler.java @@ -1,6 +1,9 @@ package com.carecode.core.handler; import com.carecode.core.exception.*; +import com.carecode.core.ops.OperationalAlerter; +import com.carecode.domain.user.service.ConsentGuard; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -10,14 +13,18 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.WebRequest; +import java.util.LinkedHashMap; import java.util.Map; import java.util.stream.Collectors; /** 전역 예외 핸들러 모든 예외를 일관된 형식으로 처리 */ @Slf4j @RestControllerAdvice +@RequiredArgsConstructor public class CustomizedResponseEntityExceptionHandler { + private final OperationalAlerter alerter; + // CareCodeException 계층의 예외 처리 @ExceptionHandler(CareCodeException.class) public ResponseEntity handleCareCodeException(CareCodeException ex, WebRequest request) { @@ -82,6 +89,23 @@ public ResponseEntity handleBusinessException(BusinessException e .body(errorResponse); } + /** 동의가 없어 막힌 경우. 클라이언트가 어떤 동의를 받아야 하는지 알아야 화면을 띄울 수 있다. */ + @ExceptionHandler(ConsentGuard.ConsentRequiredException.class) + public ResponseEntity> handleConsentRequired( + ConsentGuard.ConsentRequiredException ex, WebRequest request) { + log.info("동의 미완료로 접근 차단: {}", ex.getConsentType()); + + Map body = new LinkedHashMap<>(); + body.put("error", "CONSENT_REQUIRED"); + body.put("consentType", ex.getConsentType().name()); + body.put("displayName", ex.getConsentType().getDisplayName()); + body.put("sensitive", ex.getConsentType().isSensitive()); + body.put("message", ex.getMessage()); + body.put("path", request.getDescription(false)); + + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body); + } + // CareServiceException 처리 (하위 호환성 유지) @ExceptionHandler(CareServiceException.class) public ResponseEntity handleCareServiceException(CareServiceException ex, WebRequest request) { @@ -197,6 +221,10 @@ public ResponseEntity handleIllegalArgumentException(IllegalArgum @ExceptionHandler(Exception.class) public ResponseEntity handleAllExceptions(Exception ex, WebRequest request) { log.error("예상치 못한 예외 발생", ex); + // 5xx 는 사용자가 이미 실패를 겪은 뒤다. 로그만 남기면 아무도 모른 채 지나간다. + alerter.alert("unhandled-" + ex.getClass().getSimpleName(), + "처리되지 않은 예외: " + ex.getClass().getSimpleName(), + ex.getMessage() + System.lineSeparator() + request.getDescription(false)); ErrorResponse errorResponse = ErrorResponse.of( ErrorCode.INTERNAL_SERVER_ERROR, diff --git a/src/main/java/com/carecode/domain/health/service/HealthService.java b/src/main/java/com/carecode/domain/health/service/HealthService.java index 9a999cd6..16fec086 100644 --- a/src/main/java/com/carecode/domain/health/service/HealthService.java +++ b/src/main/java/com/carecode/domain/health/service/HealthService.java @@ -1,5 +1,7 @@ package com.carecode.domain.health.service; +import com.carecode.domain.user.entity.ConsentType; +import com.carecode.domain.user.service.ConsentGuard; import com.carecode.core.annotation.LogExecutionTime; import com.carecode.core.exception.CareCodeException; import com.carecode.core.exception.CareServiceException; @@ -66,6 +68,7 @@ public class HealthService { private static final int HEALTH_SCORE_MEDIUM_THRESHOLD = 60; private final HealthRecordRepository healthRecordRepository; + private final ConsentGuard consentGuard; private final HealthRecordAttachmentRepository healthRecordAttachmentRepository; private final ChildRepository childRepository; private final UserRepository userRepository; @@ -80,6 +83,8 @@ public class HealthService { @LogExecutionTime @Transactional public HealthRecordResponse createHealthRecord(HealthCreateHealthRecordRequest request, Long actorUserId) { + // 건강정보는 민감정보다. 별도 동의 없이는 수집하지 않는다. + consentGuard.require(actorUserId, ConsentType.HEALTH_DATA); validateRequest(request); log.info("건강 기록 생성: 아이ID={}, 제목={}", request.getChildId(), request.getTitle()); diff --git a/src/main/java/com/carecode/domain/user/entity/ConsentType.java b/src/main/java/com/carecode/domain/user/entity/ConsentType.java index e3d6e577..d19f1d63 100644 --- a/src/main/java/com/carecode/domain/user/entity/ConsentType.java +++ b/src/main/java/com/carecode/domain/user/entity/ConsentType.java @@ -3,18 +3,32 @@ /** 동의 항목. 필수 항목은 미동의 시 서비스 이용이 불가하고, 선택 항목은 언제든 철회할 수 있다. */ public enum ConsentType { - TERMS_OF_SERVICE("서비스 이용약관", true), - PRIVACY_POLICY("개인정보 수집·이용", true), - CHILD_DATA("자녀 정보 수집에 대한 보호자 동의", true), - MARKETING("마케팅 정보 수신", false), - THIRD_PARTY_SHARING("제3자 정보 제공", false); + TERMS_OF_SERVICE("서비스 이용약관", true, false), + PRIVACY_POLICY("개인정보 수집·이용", true, false), + CHILD_DATA("자녀 정보 수집에 대한 보호자 동의", true, false), + + /** + * 건강·의료 정보는 개인정보보호법상 민감정보라 일반 개인정보 동의로 갈음할 수 없다. + * 키·몸무게·접종이력·진료기록을 다루므로 반드시 별도로 받는다. + */ + HEALTH_DATA("건강정보 수집·이용 (민감정보)", false, true), + + MARKETING("마케팅 정보 수신", false, false), + THIRD_PARTY_SHARING("제3자 정보 제공", false, false); private final String displayName; private final boolean required; + private final boolean sensitive; - ConsentType(String displayName, boolean required) { + ConsentType(String displayName, boolean required, boolean sensitive) { this.displayName = displayName; this.required = required; + this.sensitive = sensitive; + } + + /** 민감정보 여부. 별도 동의가 필요하고 철회 시 해당 기능을 즉시 막아야 한다. */ + public boolean isSensitive() { + return sensitive; } public String getDisplayName() { diff --git a/src/main/java/com/carecode/domain/user/service/ConsentGuard.java b/src/main/java/com/carecode/domain/user/service/ConsentGuard.java new file mode 100644 index 00000000..1055f6cf --- /dev/null +++ b/src/main/java/com/carecode/domain/user/service/ConsentGuard.java @@ -0,0 +1,48 @@ +package com.carecode.domain.user.service; + +import com.carecode.core.exception.CareServiceException; +import com.carecode.domain.user.entity.ConsentType; +import com.carecode.domain.user.entity.UserConsent; +import com.carecode.domain.user.repository.UserConsentRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** 민감정보 동의 확인. 동의를 받아두기만 하고 강제하지 않으면 받지 않은 것과 같다. */ +@Slf4j +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ConsentGuard { + + private final UserConsentRepository consentRepository; + + /** 동의가 없거나 철회됐으면 접근을 막는다. */ + public void require(Long userId, ConsentType type) { + if (!hasConsent(userId, type)) { + throw new ConsentRequiredException(type); + } + } + + public boolean hasConsent(Long userId, ConsentType type) { + return consentRepository.findLatest(userId, type) + .map(UserConsent::isGranted) + .orElse(false); + } + + /** 동의가 필요해서 막힌 것인지 클라이언트가 구분할 수 있게 별도 예외로 던진다. */ + public static class ConsentRequiredException extends CareServiceException { + + private final transient ConsentType consentType; + + public ConsentRequiredException(ConsentType consentType) { + super(consentType.getDisplayName() + " 동의가 필요합니다."); + this.consentType = consentType; + } + + public ConsentType getConsentType() { + return consentType; + } + } +} diff --git a/src/test/java/com/carecode/domain/health/service/HealthServiceTest.java b/src/test/java/com/carecode/domain/health/service/HealthServiceTest.java index e36a4501..30b36908 100644 --- a/src/test/java/com/carecode/domain/health/service/HealthServiceTest.java +++ b/src/test/java/com/carecode/domain/health/service/HealthServiceTest.java @@ -15,6 +15,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import com.carecode.domain.user.service.ConsentGuard; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -44,6 +45,10 @@ class HealthServiceTest { @Mock private HealthRecordMapper healthRecordMapper; + /** 동의 확인은 ConsentGuardTest 에서 검증한다. 여기서는 통과시킨 뒤 기록 로직만 본다. */ + @Mock + private ConsentGuard consentGuard; + @InjectMocks private HealthService healthService; diff --git a/src/test/java/com/carecode/domain/user/service/ConsentGuardTest.java b/src/test/java/com/carecode/domain/user/service/ConsentGuardTest.java new file mode 100644 index 00000000..0841b224 --- /dev/null +++ b/src/test/java/com/carecode/domain/user/service/ConsentGuardTest.java @@ -0,0 +1,89 @@ +package com.carecode.domain.user.service; + +import com.carecode.domain.user.entity.ConsentType; +import com.carecode.domain.user.entity.UserConsent; +import com.carecode.domain.user.repository.UserConsentRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("민감정보 동의 확인") +class ConsentGuardTest { + + private UserConsentRepository repository; + private ConsentGuard guard; + + @BeforeEach + void setUp() { + repository = mock(UserConsentRepository.class); + guard = new ConsentGuard(repository); + } + + @Test + @DisplayName("동의 이력이 없으면 막는다") + void blocksWithoutConsent() { + when(repository.findLatest(anyLong(), eq(ConsentType.HEALTH_DATA))).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> guard.require(1L, ConsentType.HEALTH_DATA)) + .isInstanceOf(ConsentGuard.ConsentRequiredException.class) + .hasMessageContaining("건강정보"); + } + + @Test + @DisplayName("철회된 동의는 없는 것으로 본다") + void blocksWhenRevoked() { + // when() 안에서 mock 을 만들면 스터빙이 중첩돼 Mockito 가 거부한다 + UserConsent revoked = consent(false); + when(repository.findLatest(anyLong(), eq(ConsentType.HEALTH_DATA))) + .thenReturn(Optional.of(revoked)); + + assertThat(guard.hasConsent(1L, ConsentType.HEALTH_DATA)).isFalse(); + assertThatThrownBy(() -> guard.require(1L, ConsentType.HEALTH_DATA)) + .isInstanceOf(ConsentGuard.ConsentRequiredException.class); + } + + @Test + @DisplayName("동의했으면 통과시킨다") + void allowsWhenGranted() { + UserConsent granted = consent(true); + when(repository.findLatest(anyLong(), eq(ConsentType.HEALTH_DATA))) + .thenReturn(Optional.of(granted)); + + assertThat(guard.hasConsent(1L, ConsentType.HEALTH_DATA)).isTrue(); + guard.require(1L, ConsentType.HEALTH_DATA); // 예외 없음 + } + + @Test + @DisplayName("어떤 동의가 필요한지 예외에 담아 알린다") + void exposesRequiredConsentType() { + when(repository.findLatest(anyLong(), eq(ConsentType.HEALTH_DATA))).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> guard.require(1L, ConsentType.HEALTH_DATA)) + .isInstanceOfSatisfying(ConsentGuard.ConsentRequiredException.class, + e -> assertThat(e.getConsentType()).isEqualTo(ConsentType.HEALTH_DATA)); + } + + @Test + @DisplayName("건강정보는 민감정보로 분류된다") + void healthDataIsSensitive() { + assertThat(ConsentType.HEALTH_DATA.isSensitive()).isTrue(); + assertThat(ConsentType.PRIVACY_POLICY.isSensitive()).isFalse(); + // 일반 개인정보 동의로 갈음할 수 없어야 한다 + assertThat(ConsentType.HEALTH_DATA).isNotEqualTo(ConsentType.PRIVACY_POLICY); + } + + private UserConsent consent(boolean granted) { + UserConsent c = mock(UserConsent.class); + when(c.isGranted()).thenReturn(granted); + return c; + } +} From dbbe5711882d3474e7812d9d32ae050b4effe6fe Mon Sep 17 00:00:00 2001 From: RosieOh Date: Wed, 5 Aug 2026 14:51:15 +0900 Subject: [PATCH 16/68] =?UTF-8?q?FEAT=20:=20=EC=A7=80=EC=9B=90=EA=B8=88=20?= =?UTF-8?q?=EA=B8=88=EC=95=A1=20=EC=88=98=EA=B8=B0=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A7=80=EC=97=AD=EB=B3=84=20=EC=8B=A0=EB=A2=B0?= =?UTF-8?q?=EB=8F=84=20=ED=91=9C=EA=B8=B0=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminPolicyVerificationController.java | 91 +++++++++++++++++++ .../dto/response/RegionalBenefitResponse.java | 6 ++ .../carecode/domain/policy/entity/Policy.java | 11 +++ .../db/migration/V9__policy_verification.sql | 12 +++ 4 files changed, 120 insertions(+) create mode 100644 src/main/java/com/carecode/domain/admin/controller/AdminPolicyVerificationController.java create mode 100644 src/main/resources/db/migration/V9__policy_verification.sql diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminPolicyVerificationController.java b/src/main/java/com/carecode/domain/admin/controller/AdminPolicyVerificationController.java new file mode 100644 index 00000000..73d774e4 --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/controller/AdminPolicyVerificationController.java @@ -0,0 +1,91 @@ +package com.carecode.domain.admin.controller; + +import com.carecode.core.exception.CareServiceException; +import com.carecode.core.security.CurrentUserFacade; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.repository.PolicyRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** 정책 금액 검증. 자동 수집된 금액을 사람이 확인한 뒤 확정으로 표시한다. */ +@Slf4j +@RestController +@RequestMapping("/api/admin/policies") +@RequiredArgsConstructor +@Tag(name = "어드민 - 정책 검증", description = "지원금 금액 수기 검증") +public class AdminPolicyVerificationController { + + private final PolicyRepository policyRepository; + private final CurrentUserFacade currentUserFacade; + + @PostMapping("/{policyId}/verify") + @Transactional + @Operation(summary = "정책 금액 검증 표시", description = "확인한 금액을 확정으로 전환") + public ResponseEntity> verify( + @Parameter(description = "정책 ID", required = true) @PathVariable Long policyId, + @Parameter(description = "금액 근거 출처 URL") @RequestParam(required = false) String sourceUrl) { + + Policy policy = policyRepository.findById(policyId) + .orElseThrow(() -> new CareServiceException("정책을 찾을 수 없습니다: " + policyId)); + + policy.setVerifiedAt(LocalDateTime.now()); + policy.setVerifiedBy(currentUserFacade.requireCurrentUserEmail()); + policy.setSourceUrl(sourceUrl); + policyRepository.save(policy); + + Map body = new LinkedHashMap<>(); + body.put("policyId", policyId); + body.put("title", policy.getTitle()); + body.put("verifiedAt", policy.getVerifiedAt()); + body.put("verifiedBy", policy.getVerifiedBy()); + return ResponseEntity.ok(body); + } + + @DeleteMapping("/{policyId}/verify") + @Transactional + @Operation(summary = "검증 표시 해제", description = "추정치로 되돌림") + public ResponseEntity unverify(@PathVariable Long policyId) { + Policy policy = policyRepository.findById(policyId) + .orElseThrow(() -> new CareServiceException("정책을 찾을 수 없습니다: " + policyId)); + policy.setVerifiedAt(null); + policy.setVerifiedBy(null); + policyRepository.save(policy); + return ResponseEntity.noContent().build(); + } + + /** 검증 우선순위 판단용. 미검증 정책이 많은 지역부터 손봐야 한다. */ + @GetMapping("/verification-status") + @Operation(summary = "지역별 검증 현황", description = "미검증 정책이 많은 지역 순") + public ResponseEntity>> status() { + Map> byRegion = policyRepository.findByIsActiveTrue().stream() + .filter(p -> p.getTargetRegion() != null && !p.getTargetRegion().isBlank()) + .collect(Collectors.groupingBy(Policy::getTargetRegion)); + + List> rows = byRegion.entrySet().stream().map(e -> { + long verified = e.getValue().stream().filter(p -> p.getVerifiedAt() != null).count(); + Map row = new LinkedHashMap<>(); + row.put("region", e.getKey()); + row.put("total", e.getValue().size()); + row.put("verified", verified); + row.put("unverified", e.getValue().size() - verified); + row.put("verifiedRate", e.getValue().isEmpty() ? 0 + : (int) Math.round(100.0 * verified / e.getValue().size())); + return row; + }).sorted(Comparator.comparingInt(r -> (Integer) r.get("verifiedRate"))).toList(); + + return ResponseEntity.ok(rows); + } +} diff --git a/src/main/java/com/carecode/domain/policy/dto/response/RegionalBenefitResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/RegionalBenefitResponse.java index e94f602e..2905ae7a 100644 --- a/src/main/java/com/carecode/domain/policy/dto/response/RegionalBenefitResponse.java +++ b/src/main/java/com/carecode/domain/policy/dto/response/RegionalBenefitResponse.java @@ -24,6 +24,12 @@ public class RegionalBenefitResponse { /** 금액이 아닌 혜택(무료검진·서비스 등) 수. 합산에는 빠져 있다. */ private int nonCashPolicyCount; + /** 금액이 수기 검증된 정책 수. */ + private int verifiedPolicyCount; + + /** VERIFIED(전부 검증) / PARTIAL(일부) / ESTIMATED(미검증). */ + private String dataQuality; + /** 금액 상위 기여 정책. 왜 이 지역이 높은지 설명한다. */ private List topContributors; diff --git a/src/main/java/com/carecode/domain/policy/entity/Policy.java b/src/main/java/com/carecode/domain/policy/entity/Policy.java index 200d68f2..200e2ddb 100644 --- a/src/main/java/com/carecode/domain/policy/entity/Policy.java +++ b/src/main/java/com/carecode/domain/policy/entity/Policy.java @@ -68,6 +68,17 @@ public class Policy { @Column(name = "max_payment_months") private Integer maxPaymentMonths; + /** 수기 검증 시각. null 이면 자동 수집된 추정치이며 확정 금액으로 노출하면 안 된다. */ + @Column(name = "verified_at") + private LocalDateTime verifiedAt; + + @Column(name = "verified_by", length = 100) + private String verifiedBy; + + /** 금액 근거 출처. 분쟁 시 확인 경로가 된다. */ + @Column(name = "source_url", length = 500) + private String sourceUrl; + @Column(name = "benefit_type") private String benefitType; diff --git a/src/main/resources/db/migration/V9__policy_verification.sql b/src/main/resources/db/migration/V9__policy_verification.sql new file mode 100644 index 00000000..912a5777 --- /dev/null +++ b/src/main/resources/db/migration/V9__policy_verification.sql @@ -0,0 +1,12 @@ +-- 정책 금액 검증 이력. 틀린 금액을 확정치처럼 보여주면 신뢰 문제를 넘어 분쟁이 된다 +ALTER TABLE TBL_POLICIES + ADD COLUMN VERIFIED_AT DATETIME NULL COMMENT '수기 검증 시각 - NULL 이면 미검증(추정치)'; + +ALTER TABLE TBL_POLICIES + ADD COLUMN VERIFIED_BY VARCHAR(100) NULL COMMENT '검증자'; + +ALTER TABLE TBL_POLICIES + ADD COLUMN SOURCE_URL VARCHAR(500) NULL COMMENT '금액 근거 출처'; + +-- 지역별 검증률 집계용 +CREATE INDEX IDX_POLICIES_REGION_VERIFIED ON TBL_POLICIES (TARGET_REGION, VERIFIED_AT); From af6786d199ebd8f40a1879a5f03f2eb3ef786cb2 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Wed, 5 Aug 2026 14:51:15 +0900 Subject: [PATCH 17/68] =?UTF-8?q?FEAT=20:=20=EB=8F=99=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=C2=B7=EB=AF=B8=EC=B2=98=EB=A6=AC=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=9A=B4=EC=98=81=20=EC=95=8C=EB=A6=BC=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../carecode/core/ops/OperationalAlerter.java | 88 +++++++++++++++++++ .../scheduler/PublicDataSyncScheduler.java | 7 ++ .../service/AdmissionForecastService.java | 5 ++ .../service/FacilityPopularityService.java | 5 ++ .../domain/health/service/ChildService.java | 4 + .../policy/service/MissedBenefitService.java | 6 ++ .../service/PolicyRecommendationService.java | 5 ++ .../RegionalBenefitComparisonService.java | 28 +++++- .../domain/user/service/UserService.java | 4 + src/main/resources/application.yml | 4 + .../core/ops/OperationalAlerterTest.java | 83 +++++++++++++++++ .../service/AdmissionForecastServiceTest.java | 4 +- .../FacilityPopularityServiceTest.java | 4 +- .../service/MissedBenefitServiceTest.java | 4 +- .../RegionalBenefitComparisonServiceTest.java | 4 +- 15 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/carecode/core/ops/OperationalAlerter.java create mode 100644 src/test/java/com/carecode/core/ops/OperationalAlerterTest.java diff --git a/src/main/java/com/carecode/core/ops/OperationalAlerter.java b/src/main/java/com/carecode/core/ops/OperationalAlerter.java new file mode 100644 index 00000000..b44ba55c --- /dev/null +++ b/src/main/java/com/carecode/core/ops/OperationalAlerter.java @@ -0,0 +1,88 @@ +package com.carecode.core.ops; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** 운영 이상을 Slack 으로 알린다. 웹훅이 없으면 로그만 남기고 조용히 비활성 상태로 동작한다. */ +@Slf4j +@Component +public class OperationalAlerter { + + /** 같은 알림이 쏟아지면 아무도 안 보게 된다. 키별로 이 간격 안에는 한 번만 보낸다. */ + private static final Duration COOLDOWN = Duration.ofMinutes(30); + + private final RestTemplate restTemplate; + private final String webhookUrl; + private final String environment; + private final Map lastSentAt = new ConcurrentHashMap<>(); + + public OperationalAlerter(RestTemplate restTemplate, + @Value("${app.ops.slack-webhook-url:}") String webhookUrl, + @Value("${spring.profiles.active:local}") String environment) { + this.restTemplate = restTemplate; + this.webhookUrl = webhookUrl; + this.environment = environment; + if (webhookUrl == null || webhookUrl.isBlank()) { + log.info("운영 알림 웹훅이 설정되지 않아 로그로만 남깁니다."); + } + } + + public boolean isEnabled() { + return webhookUrl != null && !webhookUrl.isBlank(); + } + + /** + * @param key 중복 억제 기준. 같은 원인은 같은 키를 쓴다. + * @param title 한 줄 요약 + */ + @Async("analyticsExecutor") + public void alert(String key, String title, String detail) { + if (isSuppressed(key)) { + log.debug("알림 억제 (쿨다운) - key={}", key); + return; + } + log.error("[운영알림] {} - {}", title, detail); + + if (!isEnabled()) { + return; + } + try { + String text = String.format("*[%s] %s*\n```%s```", environment, title, truncate(detail)); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + restTemplate.postForEntity(webhookUrl, + new HttpEntity<>(Map.of("text", text), headers), String.class); + } catch (Exception e) { + // 알림 실패가 서비스에 영향을 주면 안 된다. + log.warn("운영 알림 전송 실패: {}", e.getMessage()); + } + } + + private boolean isSuppressed(String key) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime previous = lastSentAt.get(key); + if (previous != null && previous.plus(COOLDOWN).isAfter(now)) { + return true; + } + lastSentAt.put(key, now); + return false; + } + + private String truncate(String detail) { + if (detail == null) { + return ""; + } + return detail.length() <= 1500 ? detail : detail.substring(0, 1500) + "..."; + } +} diff --git a/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java index 3c9be021..b645f486 100644 --- a/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java +++ b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java @@ -5,6 +5,7 @@ import com.carecode.core.client.sync.NationwideChildcareFacilitySyncService; import com.carecode.core.client.sync.PediatricHospitalSyncService; import com.carecode.core.client.sync.SyncResult; +import com.carecode.core.ops.OperationalAlerter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -20,6 +21,7 @@ public class PublicDataSyncScheduler { private final KindergartenSyncService kindergartenSyncService; private final GovernmentBenefitSyncService benefitSyncService; private final PediatricHospitalSyncService hospitalSyncService; + private final OperationalAlerter alerter; /** 전국 어린이집 동기화. */ @Scheduled(cron = "${app.scheduler.public-data.facility-cron:0 0 3 * * MON}", zone = "Asia/Seoul") @@ -52,8 +54,13 @@ public void syncPediatricHospitals() { private void logResult(String label, SyncResult result) { if (!result.isCompleted()) { log.warn("{} 동기화 미완료 - {}", label, result); + alerter.alert("sync-" + label, label + " 동기화 미완료", result.toString()); return; } + if (result.getFailed() > 0) { + alerter.alert("sync-failed-" + label, + label + " 동기화 중 " + result.getFailed() + "건 실패", result.toString()); + } if (result.getTotalProcessed() == 0 && result.getFailed() == 0) { log.debug("{} 동기화: 변경 없음", label); return; diff --git a/src/main/java/com/carecode/domain/careFacility/service/AdmissionForecastService.java b/src/main/java/com/carecode/domain/careFacility/service/AdmissionForecastService.java index 5c29b365..fe39e8db 100644 --- a/src/main/java/com/carecode/domain/careFacility/service/AdmissionForecastService.java +++ b/src/main/java/com/carecode/domain/careFacility/service/AdmissionForecastService.java @@ -1,5 +1,7 @@ package com.carecode.domain.careFacility.service; +import com.carecode.core.analytics.EventLogger; +import com.carecode.core.analytics.EventType; import com.carecode.core.exception.CareServiceException; import com.carecode.domain.careFacility.dto.response.AdmissionForecastResponse; import com.carecode.domain.careFacility.entity.CareFacility; @@ -36,6 +38,7 @@ public class AdmissionForecastService { private final CareFacilityRepository careFacilityRepository; private final FacilityCapacitySnapshotRepository snapshotRepository; + private final EventLogger eventLogger; /** 아이 월령 기준으로 목표 시점까지 자리가 날 확률을 추정한다. */ public AdmissionForecastResponse forecast(Long facilityId, Integer childAgeMonths, Integer horizonMonths) { @@ -46,6 +49,8 @@ public AdmissionForecastResponse forecast(Long facilityId, Integer childAgeMonth int horizon = horizonMonths != null && horizonMonths > 0 ? horizonMonths : DEFAULT_HORIZON_MONTHS; LocalDate targetDate = today.plusMonths(horizon); + eventLogger.log(EventType.ADMISSION_FORECAST_VIEWED, null, String.valueOf(facilityId)); + List history = snapshotRepository.findHistory(facilityId, today.minusMonths(LOOKBACK_MONTHS)); diff --git a/src/main/java/com/carecode/domain/careFacility/service/FacilityPopularityService.java b/src/main/java/com/carecode/domain/careFacility/service/FacilityPopularityService.java index 97371cc1..ea1d3556 100644 --- a/src/main/java/com/carecode/domain/careFacility/service/FacilityPopularityService.java +++ b/src/main/java/com/carecode/domain/careFacility/service/FacilityPopularityService.java @@ -1,5 +1,7 @@ package com.carecode.domain.careFacility.service; +import com.carecode.core.analytics.EventLogger; +import com.carecode.core.analytics.EventType; import com.carecode.core.exception.CareServiceException; import com.carecode.domain.careFacility.dto.response.FacilityPopularityResponse; import com.carecode.domain.careFacility.entity.CareFacility; @@ -39,11 +41,14 @@ public class FacilityPopularityService { private final CareFacilityRepository careFacilityRepository; private final FacilityCapacitySnapshotRepository snapshotRepository; + private final EventLogger eventLogger; public FacilityPopularityResponse analyze(Long facilityId) { CareFacility facility = careFacilityRepository.findById(facilityId) .orElseThrow(() -> new CareServiceException("시설을 찾을 수 없습니다: " + facilityId)); + eventLogger.log(EventType.FACILITY_POPULARITY_VIEWED, null, String.valueOf(facilityId)); + List history = snapshotRepository.findHistory(facilityId, LocalDate.now().minusMonths(LOOKBACK_MONTHS)); diff --git a/src/main/java/com/carecode/domain/health/service/ChildService.java b/src/main/java/com/carecode/domain/health/service/ChildService.java index 347d8adf..8563a677 100644 --- a/src/main/java/com/carecode/domain/health/service/ChildService.java +++ b/src/main/java/com/carecode/domain/health/service/ChildService.java @@ -1,5 +1,7 @@ package com.carecode.domain.health.service; +import com.carecode.core.analytics.EventLogger; +import com.carecode.core.analytics.EventType; import com.carecode.core.exception.ChildNotFoundException; import com.carecode.core.security.CurrentUserFacade; import com.carecode.domain.health.dto.request.ChildCreateRequest; @@ -28,6 +30,7 @@ public class ChildService { private final ChildMapper childMapper; private final CurrentUserFacade currentUserFacade; private final VaccinationScheduleService vaccinationScheduleService; + private final EventLogger eventLogger; @Transactional public ChildInfoResponse createChild(ChildCreateRequest request) { @@ -43,6 +46,7 @@ public ChildInfoResponse createChild(ChildCreateRequest request) { .build(); Child saved = childRepository.save(child); + eventLogger.log(EventType.CHILD_REGISTERED, parent.getId(), String.valueOf(saved.getId())); log.info("아이 등록 - childId={}, userId={}", saved.getId(), parent.getId()); // 생년월일 기준 표준 접종 일정 자동 생성 diff --git a/src/main/java/com/carecode/domain/policy/service/MissedBenefitService.java b/src/main/java/com/carecode/domain/policy/service/MissedBenefitService.java index c8ae14d6..534ae6a6 100644 --- a/src/main/java/com/carecode/domain/policy/service/MissedBenefitService.java +++ b/src/main/java/com/carecode/domain/policy/service/MissedBenefitService.java @@ -1,5 +1,7 @@ package com.carecode.domain.policy.service; +import com.carecode.core.analytics.EventLogger; +import com.carecode.core.analytics.EventType; import com.carecode.core.security.CurrentUserFacade; import com.carecode.domain.policy.dto.response.MissedBenefitResponse; import com.carecode.domain.policy.dto.response.MissedBenefitSummaryResponse; @@ -32,6 +34,7 @@ public class MissedBenefitService { private final PolicyRepository policyRepository; private final ChildRepository childRepository; private final CurrentUserFacade currentUserFacade; + private final EventLogger eventLogger; public MissedBenefitSummaryResponse findMissedBenefits() { User user = currentUserFacade.requireCurrentUser(); @@ -77,6 +80,9 @@ public MissedBenefitSummaryResponse findMissedBenefits() { } } + eventLogger.log(EventType.MISSED_BENEFIT_VIEWED, user.getId(), + null, "claimable=" + claimable.size()); + claimable.sort(Comparator.comparingInt( (MissedBenefitResponse m) -> m.getBenefitAmount() == null ? 0 : m.getBenefitAmount()).reversed()); return summarize(claimable, expired, unknownEligibility); diff --git a/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java b/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java index 9aecead1..8fbda79f 100644 --- a/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java +++ b/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java @@ -1,5 +1,7 @@ package com.carecode.domain.policy.service; +import com.carecode.core.analytics.EventLogger; +import com.carecode.core.analytics.EventType; import com.carecode.core.security.CurrentUserFacade; import com.carecode.domain.policy.dto.response.PersonalizedPolicyResponse; import com.carecode.domain.policy.entity.Policy; @@ -37,6 +39,7 @@ public class PolicyRecommendationService { private final ChildRepository childRepository; private final PolicyMapper policyMapper; private final CurrentUserFacade currentUserFacade; + private final EventLogger eventLogger; /** 로그인 사용자에게 맞는 정책을 점수 순으로 반환한다. */ public List recommendForCurrentUser(int limit) { @@ -65,6 +68,8 @@ public List recommendForCurrentUser(int limit) { .build()); } + eventLogger.log(EventType.RECOMMENDATION_VIEWED, user.getId()); + scored.sort(Comparator.comparingInt(PersonalizedPolicyResponse::getScore).reversed()); return scored.size() > limit ? scored.subList(0, limit) : scored; } diff --git a/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java b/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java index 6d50fb49..57c18b8b 100644 --- a/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java +++ b/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java @@ -3,6 +3,8 @@ import com.carecode.core.benefit.BenefitPaymentType; import com.carecode.core.benefit.BenefitProjectionCalculator; import com.carecode.core.exception.CareServiceException; +import com.carecode.core.analytics.EventLogger; +import com.carecode.core.analytics.EventType; import com.carecode.core.security.CurrentUserFacade; import com.carecode.domain.policy.dto.response.RegionalBenefitComparisonResponse; import com.carecode.domain.policy.dto.response.RegionalBenefitResponse; @@ -40,6 +42,7 @@ public class RegionalBenefitComparisonService { private final PolicyRepository policyRepository; private final ChildRepository childRepository; private final CurrentUserFacade currentUserFacade; + private final EventLogger eventLogger; private final BenefitProjectionCalculator calculator; public RegionalBenefitComparisonResponse compare(Long childId, Integer years, Integer limit) { @@ -82,6 +85,8 @@ public RegionalBenefitComparisonResponse compare(Long childId, Integer years, In int size = limit != null && limit > 0 ? limit : DEFAULT_LIMIT; + eventLogger.log(EventType.REGIONAL_COMPARISON_VIEWED, user.getId(), baseRegion); + return RegionalBenefitComparisonResponse.builder() .childName(child.getName()) .childAgeMonths(currentAgeMonths) @@ -111,14 +116,25 @@ private boolean isIncomeConditional(Policy policy, User user) { } /** 지역 한 곳의 집계 중간 결과. */ - private record RegionSummary(long amount, int cashCount, int nonCashCount, + private record RegionSummary(long amount, int cashCount, int nonCashCount, int verifiedCount, List contributions) { RegionSummary merge(RegionSummary other) { List merged = new ArrayList<>(contributions); merged.addAll(other.contributions); return new RegionSummary(amount + other.amount, cashCount + other.cashCount, - nonCashCount + other.nonCashCount, merged); + nonCashCount + other.nonCashCount, verifiedCount + other.verifiedCount, merged); + } + + /** 금액에 들어간 정책이 전부 검증됐을 때만 확정으로 표기한다. */ + String quality() { + if (cashCount == 0) { + return "ESTIMATED"; + } + if (verifiedCount == cashCount) { + return "VERIFIED"; + } + return verifiedCount > 0 ? "PARTIAL" : "ESTIMATED"; } RegionalBenefitResponse toResponse(String region, long baseAmount) { @@ -133,6 +149,8 @@ RegionalBenefitResponse toResponse(String region, long baseAmount) { .differenceFromBase(amount - baseAmount) .cashPolicyCount(cashCount) .nonCashPolicyCount(nonCashCount) + .verifiedPolicyCount(verifiedCount) + .dataQuality(quality()) .topContributors(top) .build(); } @@ -142,6 +160,7 @@ private RegionSummary summarize(List policies, int ageMonths, int horizo long total = 0; int cash = 0; int nonCash = 0; + int verified = 0; List contributions = new ArrayList<>(); for (Policy policy : policies) { @@ -158,13 +177,16 @@ private RegionSummary summarize(List policies, int ageMonths, int horizo } total += projection.amount(); cash++; + if (policy.getVerifiedAt() != null) { + verified++; + } contributions.add(RegionalBenefitResponse.Contribution.builder() .title(policy.getTitle()) .amount(projection.amount()) .paymentType(projection.paymentType().name()) .build()); } - return new RegionSummary(total, cash, nonCash, contributions); + return new RegionSummary(total, cash, nonCash, verified, contributions); } private Child resolveChild(List children, Long childId) { diff --git a/src/main/java/com/carecode/domain/user/service/UserService.java b/src/main/java/com/carecode/domain/user/service/UserService.java index 41011d50..70b143af 100644 --- a/src/main/java/com/carecode/domain/user/service/UserService.java +++ b/src/main/java/com/carecode/domain/user/service/UserService.java @@ -1,5 +1,7 @@ package com.carecode.domain.user.service; +import com.carecode.core.analytics.EventLogger; +import com.carecode.core.analytics.EventType; import com.carecode.core.annotation.LogExecutionTime; import com.carecode.core.annotation.RequireAuthentication; import com.carecode.core.exception.UserNotFoundException; @@ -40,6 +42,7 @@ public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final RestTemplate restTemplate; + private final EventLogger eventLogger; // 사용자 상세 조회 (String ID) - 삭제되지 않은 사용자만 @LogExecutionTime @@ -264,6 +267,7 @@ public UserDto createUser(UserDto userDto) { .build(); User savedUser = userRepository.save(user); + eventLogger.log(EventType.SIGNED_UP, savedUser.getId(), userDto.getProvider()); return convertToDto(savedUser); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a1c74dee..ac3d24e5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -115,6 +115,10 @@ app: secure: ${REFRESH_COOKIE_SECURE:true} same-site: ${REFRESH_COOKIE_SAME_SITE:None} max-age-days: ${REFRESH_COOKIE_MAX_AGE_DAYS:14} + ops: + # 동기화 실패·처리되지 않은 예외를 알린다. 비워두면 로그만 남는다 + slack-webhook-url: ${OPS_SLACK_WEBHOOK_URL:} + rate-limit: # 신뢰할 수 있는 프록시 뒤에 있을 때만 X-Forwarded-For 를 사용한다. # 프록시가 없는데 true 로 두면 헤더 위조로 rate limit 을 우회할 수 있다. diff --git a/src/test/java/com/carecode/core/ops/OperationalAlerterTest.java b/src/test/java/com/carecode/core/ops/OperationalAlerterTest.java new file mode 100644 index 00000000..47d871f0 --- /dev/null +++ b/src/test/java/com/carecode/core/ops/OperationalAlerterTest.java @@ -0,0 +1,83 @@ +package com.carecode.core.ops; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.ExpectedCount.never; +import static org.springframework.test.web.client.ExpectedCount.once; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +@DisplayName("운영 알림") +class OperationalAlerterTest { + + private static final String WEBHOOK = "https://hooks.slack.test/abc"; + + @Test + @DisplayName("웹훅이 없으면 비활성 상태로 동작한다") + void disabledWithoutWebhook() { + RestTemplate rest = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(rest).build(); + OperationalAlerter alerter = new OperationalAlerter(rest, "", "test"); + + server.expect(never(), requestTo(WEBHOOK)); + alerter.alert("key", "제목", "내용"); + + assertThat(alerter.isEnabled()).isFalse(); + server.verify(); + } + + @Test + @DisplayName("웹훅이 있으면 전송한다") + void sendsWhenConfigured() { + RestTemplate rest = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(rest).build(); + OperationalAlerter alerter = new OperationalAlerter(rest, WEBHOOK, "prod"); + + server.expect(once(), requestTo(WEBHOOK)) + .andExpect(method(HttpMethod.POST)) + .andRespond(withSuccess("ok", MediaType.TEXT_PLAIN)); + + alerter.alert("sync-fail", "동기화 실패", "상세 내용"); + + server.verify(); + } + + @Test + @DisplayName("같은 키는 쿨다운 동안 한 번만 보낸다") + void suppressesDuplicateAlerts() { + RestTemplate rest = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(rest).build(); + OperationalAlerter alerter = new OperationalAlerter(rest, WEBHOOK, "prod"); + + // 같은 장애로 알림이 쏟아지면 아무도 보지 않게 된다 + server.expect(once(), requestTo(WEBHOOK)).andRespond(withSuccess("ok", MediaType.TEXT_PLAIN)); + + alerter.alert("same-key", "제목", "1회차"); + alerter.alert("same-key", "제목", "2회차"); + alerter.alert("same-key", "제목", "3회차"); + + server.verify(); + } + + @Test + @DisplayName("전송 실패가 호출부로 전파되지 않는다") + void swallowsSendFailure() { + RestTemplate rest = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(rest).build(); + OperationalAlerter alerter = new OperationalAlerter(rest, WEBHOOK, "prod"); + + server.expect(once(), requestTo(WEBHOOK)) + .andRespond(request -> { + throw new java.io.IOException("연결 실패"); + }); + + alerter.alert("key", "제목", "내용"); // 예외가 밖으로 나오면 안 된다 + } +} diff --git a/src/test/java/com/carecode/domain/careFacility/service/AdmissionForecastServiceTest.java b/src/test/java/com/carecode/domain/careFacility/service/AdmissionForecastServiceTest.java index 4e650181..9d2a1e68 100644 --- a/src/test/java/com/carecode/domain/careFacility/service/AdmissionForecastServiceTest.java +++ b/src/test/java/com/carecode/domain/careFacility/service/AdmissionForecastServiceTest.java @@ -5,6 +5,7 @@ import com.carecode.domain.careFacility.entity.FacilityCapacitySnapshot; import com.carecode.domain.careFacility.repository.CareFacilityRepository; import com.carecode.domain.careFacility.repository.FacilityCapacitySnapshotRepository; +import com.carecode.core.analytics.EventLogger; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -33,7 +34,8 @@ void setUp() { snapshotRepository = mock(FacilityCapacitySnapshotRepository.class); when(facilityRepository.findById(anyLong())) .thenReturn(Optional.of(CareFacility.builder().name("행복어린이집").build())); - service = new AdmissionForecastService(facilityRepository, snapshotRepository); + service = new AdmissionForecastService(facilityRepository, snapshotRepository, + mock(EventLogger.class)); } @Test diff --git a/src/test/java/com/carecode/domain/careFacility/service/FacilityPopularityServiceTest.java b/src/test/java/com/carecode/domain/careFacility/service/FacilityPopularityServiceTest.java index 3aa3951e..cb71fc54 100644 --- a/src/test/java/com/carecode/domain/careFacility/service/FacilityPopularityServiceTest.java +++ b/src/test/java/com/carecode/domain/careFacility/service/FacilityPopularityServiceTest.java @@ -5,6 +5,7 @@ import com.carecode.domain.careFacility.entity.FacilityCapacitySnapshot; import com.carecode.domain.careFacility.repository.CareFacilityRepository; import com.carecode.domain.careFacility.repository.FacilityCapacitySnapshotRepository; +import com.carecode.core.analytics.EventLogger; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -32,7 +33,8 @@ void setUp() { snapshotRepository = mock(FacilityCapacitySnapshotRepository.class); when(facilityRepository.findById(anyLong())) .thenReturn(Optional.of(CareFacility.builder().name("행복어린이집").build())); - service = new FacilityPopularityService(facilityRepository, snapshotRepository); + service = new FacilityPopularityService(facilityRepository, snapshotRepository, + mock(EventLogger.class)); } @Test diff --git a/src/test/java/com/carecode/domain/policy/service/MissedBenefitServiceTest.java b/src/test/java/com/carecode/domain/policy/service/MissedBenefitServiceTest.java index 85f3502a..d8aa7729 100644 --- a/src/test/java/com/carecode/domain/policy/service/MissedBenefitServiceTest.java +++ b/src/test/java/com/carecode/domain/policy/service/MissedBenefitServiceTest.java @@ -7,6 +7,7 @@ import com.carecode.domain.user.entity.Child; import com.carecode.domain.user.entity.User; import com.carecode.domain.user.repository.ChildRepository; +import com.carecode.core.analytics.EventLogger; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -41,7 +42,8 @@ void setUp() { user = User.builder().id(1L).name("부모").build(); when(currentUserFacade.requireCurrentUser()).thenReturn(user); - service = new MissedBenefitService(policyRepository, childRepository, currentUserFacade); + service = new MissedBenefitService(policyRepository, childRepository, currentUserFacade, + mock(EventLogger.class)); } @Test diff --git a/src/test/java/com/carecode/domain/policy/service/RegionalBenefitComparisonServiceTest.java b/src/test/java/com/carecode/domain/policy/service/RegionalBenefitComparisonServiceTest.java index 93e508c3..7a774e53 100644 --- a/src/test/java/com/carecode/domain/policy/service/RegionalBenefitComparisonServiceTest.java +++ b/src/test/java/com/carecode/domain/policy/service/RegionalBenefitComparisonServiceTest.java @@ -10,6 +10,7 @@ import com.carecode.domain.user.entity.Child; import com.carecode.domain.user.entity.User; import com.carecode.domain.user.repository.ChildRepository; +import com.carecode.core.analytics.EventLogger; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -41,7 +42,8 @@ void setUp() { when(currentUserFacade.requireCurrentUser()).thenReturn(user); service = new RegionalBenefitComparisonService( - policyRepository, childRepository, currentUserFacade, new BenefitProjectionCalculator()); + policyRepository, childRepository, currentUserFacade, mock(EventLogger.class), + new BenefitProjectionCalculator()); } @Test From 3e151bb34110f20b6ec50519859fc5b673dea935 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Wed, 5 Aug 2026 18:40:55 +0900 Subject: [PATCH 18/68] =?UTF-8?q?FEAT=20:=20=EB=B3=B4=EC=9C=A1=ED=86=B5?= =?UTF-8?q?=ED=95=A9=EC=A0=95=EB=B3=B4=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EA=B3=B5=EA=B8=89=EC=9E=90=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=96=B4=EB=A6=B0=EC=9D=B4=EC=A7=91=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EC=A0=84=ED=99=98=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../provider/ChildcarePortalProvider.java | 102 ++++++++++++++ ...ationwideChildcareFacilitySyncService.java | 6 +- src/main/resources/application.yml | 11 +- .../provider/ChildcarePortalProviderTest.java | 125 ++++++++++++++++++ 4 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/carecode/core/client/provider/ChildcarePortalProvider.java create mode 100644 src/test/java/com/carecode/core/client/provider/ChildcarePortalProviderTest.java diff --git a/src/main/java/com/carecode/core/client/provider/ChildcarePortalProvider.java b/src/main/java/com/carecode/core/client/provider/ChildcarePortalProvider.java new file mode 100644 index 00000000..660ee8cf --- /dev/null +++ b/src/main/java/com/carecode/core/client/provider/ChildcarePortalProvider.java @@ -0,0 +1,102 @@ +package com.carecode.core.client.provider; + +import com.carecode.core.client.exception.PublicDataApiException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** + * 보육통합정보시스템(api.childcare.go.kr) 공급자. + * data.go.kr 과 호스트·인증 파라미터·응답 포맷이 모두 달라 별도 구현이 필요하다. + */ +@Slf4j +@Component +public class ChildcarePortalProvider implements PublicDataProvider { + + public static final String PROVIDER_NAME = "CHILDCARE_PORTAL"; + + private final RestTemplate restTemplate; + private final String serviceKey; + private final String baseUrl; + private final String keyParam; + private final String pageParam; + private final String sizeParam; + + public ChildcarePortalProvider( + RestTemplate restTemplate, + @Value("${public.data.childcare-portal.service-key:}") String serviceKey, + @Value("${public.data.childcare-portal.base-url:http://api.childcare.go.kr}") String baseUrl, + // 명세서마다 파라미터명이 달라 설정으로 뺀다. 문서와 다르면 여기만 고치면 된다. + @Value("${public.data.childcare-portal.key-param:key}") String keyParam, + @Value("${public.data.childcare-portal.page-param:}") String pageParam, + @Value("${public.data.childcare-portal.size-param:}") String sizeParam) { + this.restTemplate = restTemplate; + this.serviceKey = serviceKey; + this.baseUrl = stripTrailingSlash(baseUrl); + this.keyParam = keyParam; + this.pageParam = pageParam; + this.sizeParam = sizeParam; + } + + @Override + public String getProviderName() { + return PROVIDER_NAME; + } + + @Override + public boolean isAvailable() { + return serviceKey != null && !serviceKey.isBlank(); + } + + @Override + public String fetch(String resource, int pageNo, int numOfRows, Map params) { + if (!isAvailable()) { + throw new PublicDataApiException("보육통합정보 서비스 키가 설정되지 않았습니다."); + } + + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(toAbsoluteUrl(resource)) + .queryParam(keyParam, serviceKey); + + // 이 API 는 페이징 파라미터가 명세서마다 다르고 없는 경우도 있다. 설정된 경우에만 붙인다. + if (!pageParam.isBlank()) { + builder.queryParam(pageParam, pageNo); + } + if (!sizeParam.isBlank()) { + builder.queryParam(sizeParam, numOfRows); + } + if (params != null) { + params.forEach((k, v) -> { + if (v != null && !v.isBlank()) { + builder.queryParam(k, v); + } + }); + } + + String url = builder.encode(StandardCharsets.UTF_8).toUriString(); + log.debug("보육통합정보 호출: resource={}, page={}", resource, pageNo); + + try { + return restTemplate.getForObject(URI.create(url), String.class); + } catch (Exception e) { + throw new PublicDataApiException( + "보육통합정보 호출 실패: resource=" + resource + ", 사유=" + e.getMessage(), e); + } + } + + private String toAbsoluteUrl(String resource) { + if (resource != null && (resource.startsWith("http://") || resource.startsWith("https://"))) { + return resource; + } + return baseUrl + "/" + (resource != null && resource.startsWith("/") ? resource.substring(1) : resource); + } + + private String stripTrailingSlash(String url) { + return url != null && url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } +} diff --git a/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java b/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java index cf49d9d8..4aa1ab2a 100644 --- a/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java +++ b/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java @@ -1,6 +1,6 @@ package com.carecode.core.client.sync; -import com.carecode.core.client.provider.DataGoKrProvider; +import com.carecode.core.client.provider.ChildcarePortalProvider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -14,12 +14,12 @@ public class NationwideChildcareFacilitySyncService { private static final int ROWS_PER_PAGE = 500; - private final DataGoKrProvider provider; + private final ChildcarePortalProvider provider; private final CareFacilityUpsertService upsertService; private final PagedSyncTemplate syncTemplate; /** 데이터셋 경로는 개편될 수 있어 재배포 없이 바꿀 수 있게 프로퍼티로 둔다. */ - @Value("${public.data.resource.childcare:B551014/CCEF/childcare}") + @Value("${public.data.resource.childcare:http://api.childcare.go.kr/mediate/rest/cpmsapi021/cpmsapi021/request}") private String resource; public SyncResult sync() { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ac3d24e5..83738205 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -226,6 +226,15 @@ public: service-key: ${DATA_GO_KR_SERVICE_KEY:} base-url: ${DATA_GO_KR_BASE_URL:https://apis.data.go.kr} + # 보육통합정보시스템 — data.go.kr 이 아닌 별도 시스템. 인증 파라미터명이 다르다 + childcare-portal: + service-key: ${CHILDCARE_PORTAL_KEY:} + base-url: ${CHILDCARE_PORTAL_BASE_URL:http://api.childcare.go.kr} + # 명세서와 다르면 코드가 아니라 여기를 고친다 + key-param: ${CHILDCARE_PORTAL_KEY_PARAM:key} + page-param: ${CHILDCARE_PORTAL_PAGE_PARAM:} + size-param: ${CHILDCARE_PORTAL_SIZE_PARAM:} + # 심평원 병원정보서비스 hospital: # 진료과목 코드. 심평원 코드표 기준 소아청소년과는 "10". @@ -236,7 +245,7 @@ public: # 데이터셋 경로. 공공데이터 오퍼레이션은 개편되므로 재배포 없이 바꿀 수 있게 뺀다. # 절대 URL 을 넣으면 base-url 대신 그대로 호출한다(표준데이터는 호스트가 다르다). resource: - childcare: ${PUBLIC_DATA_RESOURCE_CHILDCARE:B551014/CCEF/childcare} + childcare: ${PUBLIC_DATA_RESOURCE_CHILDCARE:http://api.childcare.go.kr/mediate/rest/cpmsapi021/cpmsapi021/request} kindergarten: ${PUBLIC_DATA_RESOURCE_KINDERGARTEN:http://api.data.go.kr/openapi/tn_pubr_public_kindergarten_api} benefit: ${PUBLIC_DATA_RESOURCE_BENEFIT:1741000/publicServiceInformations/publicServiceInformation} hospital: ${PUBLIC_DATA_RESOURCE_HOSPITAL:B551182/hospInfoServicev2/getHospBasisList} diff --git a/src/test/java/com/carecode/core/client/provider/ChildcarePortalProviderTest.java b/src/test/java/com/carecode/core/client/provider/ChildcarePortalProviderTest.java new file mode 100644 index 00000000..c1f1b471 --- /dev/null +++ b/src/test/java/com/carecode/core/client/provider/ChildcarePortalProviderTest.java @@ -0,0 +1,125 @@ +package com.carecode.core.client.provider; + +import com.carecode.core.client.exception.PublicDataApiException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +@DisplayName("보육통합정보 공급자") +class ChildcarePortalProviderTest { + + private static final String KEY = "testkey123"; + private static final String URL = + "http://api.childcare.go.kr/mediate/rest/cpmsapi021/cpmsapi021/request"; + + @Test + @DisplayName("서비스 키가 없으면 비활성 상태다") + void inactiveWithoutKey() { + ChildcarePortalProvider provider = provider("", "key", "", ""); + + assertThat(provider.isAvailable()).isFalse(); + assertThatThrownBy(() -> provider.fetch(URL, 1, 10, Map.of())) + .isInstanceOf(PublicDataApiException.class) + .hasMessageContaining("서비스 키"); + } + + @Test + @DisplayName("data.go.kr 과 달리 key 파라미터로 인증한다") + void usesKeyParameter() { + RestTemplate rest = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(rest).build(); + ChildcarePortalProvider provider = provider(rest, KEY, "key", "", ""); + + server.expect(request -> { + String query = request.getURI().getRawQuery(); + assertThat(query).contains("key=" + KEY); + assertThat(query).doesNotContain("serviceKey="); + }).andRespond(withSuccess("", MediaType.APPLICATION_XML)); + + provider.fetch(URL, 1, 100, Map.of()); + server.verify(); + } + + @Test + @DisplayName("인증 파라미터명을 설정으로 바꿀 수 있다") + void keyParameterIsConfigurable() { + RestTemplate rest = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(rest).build(); + // 명세서가 apiKey 를 쓰면 코드가 아니라 설정만 고친다 + ChildcarePortalProvider provider = provider(rest, KEY, "apiKey", "", ""); + + server.expect(request -> + assertThat(request.getURI().getRawQuery()).contains("apiKey=" + KEY)) + .andRespond(withSuccess("", MediaType.APPLICATION_XML)); + + provider.fetch(URL, 1, 100, Map.of()); + server.verify(); + } + + @Test + @DisplayName("페이징 파라미터는 설정된 경우에만 붙인다") + void omitsPagingWhenNotConfigured() { + RestTemplate rest = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(rest).build(); + ChildcarePortalProvider provider = provider(rest, KEY, "key", "", ""); + + server.expect(request -> { + String query = request.getURI().getRawQuery(); + assertThat(query).doesNotContain("pageNo").doesNotContain("numOfRows"); + }).andRespond(withSuccess("", MediaType.APPLICATION_XML)); + + provider.fetch(URL, 3, 500, Map.of()); + server.verify(); + } + + @Test + @DisplayName("페이징 파라미터를 지정하면 전달한다") + void sendsPagingWhenConfigured() { + RestTemplate rest = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(rest).build(); + ChildcarePortalProvider provider = provider(rest, KEY, "key", "pageIndex", "pageUnit"); + + server.expect(request -> { + String query = request.getURI().getRawQuery(); + assertThat(query).contains("pageIndex=3").contains("pageUnit=500"); + }).andRespond(withSuccess("", MediaType.APPLICATION_XML)); + + provider.fetch(URL, 3, 500, Map.of()); + server.verify(); + } + + @Test + @DisplayName("추가 파라미터를 함께 보낸다") + void sendsExtraParams() { + RestTemplate rest = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(rest).build(); + ChildcarePortalProvider provider = provider(rest, KEY, "key", "", ""); + + server.expect(request -> { + URI uri = request.getURI(); + assertThat(uri.getHost()).isEqualTo("api.childcare.go.kr"); + assertThat(uri.getRawQuery()).contains("arcode=11"); + }).andRespond(withSuccess("", MediaType.APPLICATION_XML)); + + provider.fetch(URL, 1, 100, Map.of("arcode", "11")); + server.verify(); + } + + private ChildcarePortalProvider provider(String key, String keyParam, String page, String size) { + return provider(new RestTemplate(), key, keyParam, page, size); + } + + private ChildcarePortalProvider provider(RestTemplate rest, String key, String keyParam, + String page, String size) { + return new ChildcarePortalProvider(rest, key, "http://api.childcare.go.kr", keyParam, page, size); + } +} From 43a793d0ee372d7bda958bce215ed8860dcc953b Mon Sep 17 00:00:00 2001 From: RosieOh Date: Wed, 5 Aug 2026 18:59:44 +0900 Subject: [PATCH 19/68] =?UTF-8?q?FEAT=20:=20=EC=9C=A0=EC=B9=98=EC=9B=90?= =?UTF-8?q?=EC=95=8C=EB=A6=AC=EB=AF=B8=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20?= =?UTF-8?q?=EC=8B=9C=EA=B5=B0=EA=B5=AC=20=EC=88=9C=ED=9A=8C=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../provider/KindergartenInfoProvider.java | 81 ++++++ .../client/sync/KindergartenSyncService.java | 98 +++++-- .../sync/KindergartenUpsertService.java | 110 ++++---- .../core/client/sync/RegionCodeCatalog.java | 57 ++++ src/main/resources/application.yml | 7 +- .../public-data/kindergarten-regions.txt | 246 ++++++++++++++++++ .../sync/KindergartenUpsertServiceTest.java | 123 +++++---- 7 files changed, 611 insertions(+), 111 deletions(-) create mode 100644 src/main/java/com/carecode/core/client/provider/KindergartenInfoProvider.java create mode 100644 src/main/java/com/carecode/core/client/sync/RegionCodeCatalog.java create mode 100644 src/main/resources/public-data/kindergarten-regions.txt diff --git a/src/main/java/com/carecode/core/client/provider/KindergartenInfoProvider.java b/src/main/java/com/carecode/core/client/provider/KindergartenInfoProvider.java new file mode 100644 index 00000000..28f970af --- /dev/null +++ b/src/main/java/com/carecode/core/client/provider/KindergartenInfoProvider.java @@ -0,0 +1,81 @@ +package com.carecode.core.client.provider; + +import com.carecode.core.client.exception.PublicDataApiException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** + * 유치원알리미(e-childschoolinfo.moe.go.kr) 공급자. + * 페이지가 아니라 시군구 단위로 조회하므로 pageNo·numOfRows 를 보내지 않는다. + */ +@Slf4j +@Component +public class KindergartenInfoProvider implements PublicDataProvider { + + public static final String PROVIDER_NAME = "KINDERGARTEN_INFO"; + + private final RestTemplate restTemplate; + private final String serviceKey; + private final String baseUrl; + + public KindergartenInfoProvider( + RestTemplate restTemplate, + @Value("${public.data.kindergarten-info.service-key:}") String serviceKey, + @Value("${public.data.kindergarten-info.base-url:https://e-childschoolinfo.moe.go.kr}") String baseUrl) { + this.restTemplate = restTemplate; + this.serviceKey = serviceKey; + this.baseUrl = baseUrl != null && baseUrl.endsWith("/") + ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + } + + @Override + public String getProviderName() { + return PROVIDER_NAME; + } + + @Override + public boolean isAvailable() { + return serviceKey != null && !serviceKey.isBlank(); + } + + @Override + public String fetch(String resource, int pageNo, int numOfRows, Map params) { + if (!isAvailable()) { + throw new PublicDataApiException("유치원알리미 서비스 키가 설정되지 않았습니다."); + } + + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(toAbsoluteUrl(resource)) + .queryParam("key", serviceKey); + + if (params != null) { + params.forEach((k, v) -> { + if (v != null && !v.isBlank()) { + builder.queryParam(k, v); + } + }); + } + + String url = builder.encode(StandardCharsets.UTF_8).toUriString(); + log.debug("유치원알리미 호출: {}", params); + + try { + return restTemplate.getForObject(URI.create(url), String.class); + } catch (Exception e) { + throw new PublicDataApiException("유치원알리미 호출 실패: 사유=" + e.getMessage(), e); + } + } + + private String toAbsoluteUrl(String resource) { + if (resource != null && (resource.startsWith("http://") || resource.startsWith("https://"))) { + return resource; + } + return baseUrl + "/" + (resource != null && resource.startsWith("/") ? resource.substring(1) : resource); + } +} diff --git a/src/main/java/com/carecode/core/client/sync/KindergartenSyncService.java b/src/main/java/com/carecode/core/client/sync/KindergartenSyncService.java index 502b8a92..0fd29833 100644 --- a/src/main/java/com/carecode/core/client/sync/KindergartenSyncService.java +++ b/src/main/java/com/carecode/core/client/sync/KindergartenSyncService.java @@ -1,35 +1,103 @@ package com.carecode.core.client.sync; -import com.carecode.core.client.provider.DataGoKrProvider; +import com.carecode.core.client.provider.KindergartenInfoProvider; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -/** 전국 유치원 표준데이터 동기화. 어린이집만 있고 유치원이 비어 있던 공백을 메운다. */ +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 전국 유치원 동기화. + * 이 API 는 sggCode 가 필수라 페이지가 아니라 시군구를 순회한다 — PagedSyncTemplate 을 쓸 수 없다. + */ @Slf4j @Service @RequiredArgsConstructor public class KindergartenSyncService { - private static final int ROWS_PER_PAGE = 500; - - private final DataGoKrProvider provider; + private final KindergartenInfoProvider provider; private final KindergartenUpsertService upsertService; - private final PagedSyncTemplate syncTemplate; + private final RegionCodeCatalog regionCatalog; + private final ObjectMapper objectMapper; - /** 표준데이터는 apis.data.go.kr 이 아닌 별도 호스트라 절대 URL 로 지정한다. */ + /** basicInfo2 는 basicInfo 와 달리 정원·위경도를 준다. 예측 기능이 이 값에 의존한다. */ @Value("${public.data.resource.kindergarten:" - + "http://api.data.go.kr/openapi/tn_pubr_public_kindergarten_api}") + + "https://e-childschoolinfo.moe.go.kr/api/notice/basicInfo2.do}") private String resource; public SyncResult sync() { - return syncTemplate.run(SyncSpec.builder() - .provider(provider) - .resource(resource) - .label("전국유치원") - .rowsPerPage(ROWS_PER_PAGE) - .upsert(upsertService::upsert) - .build()); + SyncResult result = new SyncResult(provider.getProviderName(), "전국유치원"); + + if (!provider.isAvailable()) { + result.stop("유치원알리미 서비스 키 미설정"); + log.info("유치원 동기화 건너뜀 - 서비스 키가 없습니다."); + return result; + } + + List regions = regionCatalog.kindergartenRegions(); + if (regions.isEmpty()) { + result.stop("시군구 코드 목록이 비어 있음"); + return result; + } + + int emptyRegions = 0; + for (RegionCodeCatalog.RegionCode region : regions) { + JsonNode rows; + try { + rows = extractRows(provider.fetch(resource, 1, 0, buildParams(region))); + } catch (Exception e) { + // 한 지역 실패로 전국 수집을 중단하지 않는다. + log.warn("유치원 조회 실패 - {}/{}: {}", region.sidoCode(), region.sggCode(), e.getMessage()); + result.countFailed(); + continue; + } + + if (rows == null || rows.isEmpty()) { + emptyRegions++; + continue; + } + for (JsonNode row : rows) { + try { + if (upsertService.upsert(row)) { + result.countCreated(); + } else { + result.countUpdated(); + } + } catch (Exception e) { + result.countFailed(); + log.warn("유치원 저장 실패: {}", e.getMessage()); + } + } + result.countPage(); + } + + // 전 지역이 비었다면 키가 막혔거나 응답 스펙이 바뀐 것이다. + if (emptyRegions == regions.size()) { + result.stop("전 지역 응답 없음 - 서비스 키 또는 응답 형식 확인 필요"); + log.error("유치원 동기화: {}개 지역 전부 빈 응답", regions.size()); + } + return result; + } + + private Map buildParams(RegionCodeCatalog.RegionCode region) { + Map params = new LinkedHashMap<>(); + params.put("sidoCode", region.sidoCode()); + params.put("sggCode", region.sggCode()); + return params; + } + + private JsonNode extractRows(String body) throws Exception { + if (body == null || body.isBlank()) { + return null; + } + JsonNode root = objectMapper.readTree(body); + JsonNode rows = root.path("kinderInfo"); + return rows.isArray() ? rows : null; } } diff --git a/src/main/java/com/carecode/core/client/sync/KindergartenUpsertService.java b/src/main/java/com/carecode/core/client/sync/KindergartenUpsertService.java index 192b4876..635de8b5 100644 --- a/src/main/java/com/carecode/core/client/sync/KindergartenUpsertService.java +++ b/src/main/java/com/carecode/core/client/sync/KindergartenUpsertService.java @@ -10,12 +10,10 @@ import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; -import java.util.HexFormat; import java.util.function.Consumer; -/** 유치원 한 건을 저장하는 트랜잭션 경계. */ +/** 유치원 한 건을 저장하는 트랜잭션 경계. 필드명은 유치원알리미 basicInfo2 응답 기준이다. */ @Slf4j @Service @RequiredArgsConstructor @@ -27,16 +25,15 @@ public class KindergartenUpsertService { private final CareFacilityRepository careFacilityRepository; private final CapacitySnapshotRecorder snapshotRecorder; - /** 시설 코드 기준 upsert. 표준데이터에 고유 코드가 없으면 이름+주소로 만들어 쓴다. */ @Transactional(propagation = Propagation.REQUIRES_NEW) public boolean upsert(JsonNode row) { - String name = text(row, "유치원명", "kindrgrtnNm", "KINDER_NM"); - String address = text(row, "소재지도로명주소", "rdnmadr", "소재지지번주소", "lnmadr", "ADDR"); - if (name == null) { - throw new IllegalArgumentException("유치원명이 없는 응답입니다."); + String externalCode = text(row, "kindercode"); + String name = text(row, "kindername"); + if (externalCode == null || name == null) { + throw new IllegalArgumentException("유치원 코드 또는 이름이 없는 응답입니다."); } - String facilityCode = resolveCode(row, name, address); + String facilityCode = CODE_PREFIX + externalCode; CareFacility facility = careFacilityRepository.findByFacilityCode(facilityCode).orElse(null); boolean isNew = facility == null; if (isNew) { @@ -49,34 +46,23 @@ public boolean upsert(JsonNode row) { } facility.setName(name); + String address = text(row, "addr"); applyIfPresent(address, facility::setAddress); - applyIfPresent(text(row, "전화번호", "telno", "TEL"), facility::setPhone); - applyIfPresent(text(row, "홈페이지주소", "homepageAddr", "HOMEPAGE"), facility::setWebsite); - applyIfPresent(text(row, "운영시간", "operPdCn"), facility::setOperatingHours); - applyIfPresent(text(row, "시도명", "ctprvnNm"), facility::setCity); - applyIfPresent(text(row, "시군구명", "signguNm"), facility::setDistrict); - - // 국공립 여부는 설립유형에서 판단한다. 사립은 비용 부담이 달라 사용자에게 중요한 구분이다. - String establishment = text(row, "설립유형", "estblshSe", "설립구분"); - if (establishment != null) { - facility.setIsPublic(establishment.contains("공립") || establishment.contains("국립")); + applyIfPresent(text(row, "telno"), facility::setPhone); + applyIfPresent(text(row, "hpaddr"), facility::setWebsite); + applyIfPresent(text(row, "opertime"), facility::setOperatingHours); + applyRegion(facility, address); + + // "공립(병설)" / "사립(사인)" 형태로 온다. 비용 부담이 달라 사용자에게 중요한 구분이다. + String establish = text(row, "establish"); + if (establish != null) { + facility.setIsPublic(establish.contains("공립") || establish.contains("국립")); } - Integer capacity = integer(row, "정원", "fixnum", "TOTAL_CAPACITY"); - if (capacity != null) { - facility.setCapacity(capacity); - } - Integer enrollment = integer(row, "현원", "nowNmpr", "CURRENT_CNT"); - if (enrollment != null) { - facility.setCurrentEnrollment(enrollment); - Integer effectiveCapacity = capacity != null ? capacity : facility.getCapacity(); - if (effectiveCapacity != null) { - facility.setAvailableSpots(Math.max(0, effectiveCapacity - enrollment)); - } - } + applyCapacity(facility, row); - Double lat = decimal(row, "위도", "latitude", "LAT"); - Double lng = decimal(row, "경도", "longitude", "LNG"); + Double lat = decimal(row, "lttdcdnt"); + Double lng = decimal(row, "lngtcdnt"); if (lat != null && lng != null) { facility.setLatitude(lat); facility.setLongitude(lng); @@ -88,23 +74,38 @@ public boolean upsert(JsonNode row) { return isNew; } - /** 표준데이터는 고유 식별자를 주지 않는 경우가 많다. */ - private String resolveCode(JsonNode row, String name, String address) { - String external = text(row, "유치원코드", "kindrgrtnCode", "KINDER_CD"); - if (external != null) { - return CODE_PREFIX + external; + /** + * 정원은 연령별 편성정원의 합을 쓴다. + * prmstfcnt(인가정원)는 상한이라 늘 여유 있어 보여 충원율 분모로는 실제와 어긋난다. + */ + private void applyCapacity(CareFacility facility, JsonNode row) { + Integer classCapacity = sum(row, "ag3fpcnt", "ag4fpcnt", "ag5fpcnt", "mixfpcnt", "spcnfpcnt"); + Integer capacity = classCapacity != null && classCapacity > 0 + ? classCapacity + : integer(row, "prmstfcnt"); + if (capacity != null) { + facility.setCapacity(capacity); + } + + Integer enrolled = sum(row, "ppcnt3", "ppcnt4", "ppcnt5", "mixppcnt", "shppcnt"); + if (enrolled != null) { + facility.setCurrentEnrollment(enrolled); + Integer effective = capacity != null ? capacity : facility.getCapacity(); + if (effective != null) { + facility.setAvailableSpots(Math.max(0, effective - enrolled)); + } } - String naturalKey = name + "|" + (address != null ? address : ""); - return CODE_PREFIX + hash(naturalKey); } - private String hash(String value) { - try { - byte[] digest = java.security.MessageDigest.getInstance("SHA-256") - .digest(value.getBytes(StandardCharsets.UTF_8)); - return HexFormat.of().formatHex(digest, 0, 12); - } catch (java.security.NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-256 을 사용할 수 없습니다.", e); + /** 주소 앞부분이 시도·시군구다. 지역 필터에 쓰이므로 분리해 둔다. */ + private void applyRegion(CareFacility facility, String address) { + if (address == null || address.isBlank()) { + return; + } + String[] parts = address.trim().split("\\s+"); + facility.setCity(parts[0]); + if (parts.length >= 2) { + facility.setDistrict(parts[1]); } } @@ -114,7 +115,20 @@ private void applyIfPresent(String value, Consumer setter) { } } - /** 표준데이터는 한글 필드명, 오픈API 는 영문 필드명을 쓰므로 후보를 순서대로 본다. */ + /** 하나라도 값이 있으면 합계를 낸다. 전부 없으면 null 이라 기존 값을 덮어쓰지 않는다. */ + private Integer sum(JsonNode row, String... keys) { + int total = 0; + boolean any = false; + for (String key : keys) { + Integer value = integer(row, key); + if (value != null) { + total += value; + any = true; + } + } + return any ? total : null; + } + private String text(JsonNode row, String... keys) { for (String key : keys) { JsonNode node = row.get(key); diff --git a/src/main/java/com/carecode/core/client/sync/RegionCodeCatalog.java b/src/main/java/com/carecode/core/client/sync/RegionCodeCatalog.java new file mode 100644 index 00000000..4e8d0a3c --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/RegionCodeCatalog.java @@ -0,0 +1,57 @@ +package com.carecode.core.client.sync; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** 시군구 코드 목록. 지역 단위로만 조회되는 API 를 전국 수집할 때 순회 대상이 된다. */ +@Slf4j +@Component +public class RegionCodeCatalog { + + private static final String KINDERGARTEN_REGIONS = "public-data/kindergarten-regions.txt"; + + private final List kindergartenRegions; + + public RegionCodeCatalog() { + this.kindergartenRegions = load(KINDERGARTEN_REGIONS); + log.info("유치원 조회 대상 시군구 {}개를 읽었습니다.", kindergartenRegions.size()); + } + + public record RegionCode(String sidoCode, String sggCode) { + } + + public List kindergartenRegions() { + return kindergartenRegions; + } + + /** 목록이 없으면 동기화가 조용히 0건으로 끝나므로 실패를 로그로 드러낸다. */ + private List load(String path) { + List codes = new ArrayList<>(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(new ClassPathResource(path).getInputStream(), StandardCharsets.UTF_8))) { + + String line; + while ((line = reader.readLine()) != null) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + String[] parts = trimmed.split(","); + if (parts.length == 2) { + codes.add(new RegionCode(parts[0].trim(), parts[1].trim())); + } + } + } catch (Exception e) { + log.error("시군구 코드 목록을 읽지 못했습니다: {}", path, e); + } + return Collections.unmodifiableList(codes); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 83738205..f0be8264 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -235,6 +235,11 @@ public: page-param: ${CHILDCARE_PORTAL_PAGE_PARAM:} size-param: ${CHILDCARE_PORTAL_SIZE_PARAM:} + # 유치원알리미 — 시군구 단위 조회, JSON. 정원·위경도는 basicInfo2 에만 있다 + kindergarten-info: + service-key: ${KINDERGARTEN_INFO_KEY:} + base-url: ${KINDERGARTEN_INFO_BASE_URL:https://e-childschoolinfo.moe.go.kr} + # 심평원 병원정보서비스 hospital: # 진료과목 코드. 심평원 코드표 기준 소아청소년과는 "10". @@ -246,7 +251,7 @@ public: # 절대 URL 을 넣으면 base-url 대신 그대로 호출한다(표준데이터는 호스트가 다르다). resource: childcare: ${PUBLIC_DATA_RESOURCE_CHILDCARE:http://api.childcare.go.kr/mediate/rest/cpmsapi021/cpmsapi021/request} - kindergarten: ${PUBLIC_DATA_RESOURCE_KINDERGARTEN:http://api.data.go.kr/openapi/tn_pubr_public_kindergarten_api} + kindergarten: ${PUBLIC_DATA_RESOURCE_KINDERGARTEN:https://e-childschoolinfo.moe.go.kr/api/notice/basicInfo2.do} benefit: ${PUBLIC_DATA_RESOURCE_BENEFIT:1741000/publicServiceInformations/publicServiceInformation} hospital: ${PUBLIC_DATA_RESOURCE_HOSPITAL:B551182/hospInfoServicev2/getHospBasisList} diff --git a/src/main/resources/public-data/kindergarten-regions.txt b/src/main/resources/public-data/kindergarten-regions.txt new file mode 100644 index 00000000..945e9b02 --- /dev/null +++ b/src/main/resources/public-data/kindergarten-regions.txt @@ -0,0 +1,246 @@ +# 유치원알리미 조회 대상 시군구 코드 (sidoCode,sggCode) +# 이 API 는 sggCode 가 필수라 페이지가 아니라 지역 단위로 순회해야 한다. +# 2026-08-05 전 범위 탐색으로 확인한 목록. 행정구역 개편 시 갱신한다. +# 광주(29)·전남(46)은 응답이 SUCCESS 이나 공시 데이터가 비어 있어 목록에 없다. + +# 서울 +11,11110 +11,11140 +11,11170 +11,11200 +11,11215 +11,11230 +11,11260 +11,11290 +11,11305 +11,11320 +11,11350 +11,11380 +11,11410 +11,11440 +11,11470 +11,11500 +11,11530 +11,11545 +11,11560 +11,11590 +11,11620 +11,11650 +11,11680 +11,11710 +11,11740 + +# 부산 +26,26110 +26,26140 +26,26170 +26,26200 +26,26230 +26,26260 +26,26290 +26,26320 +26,26350 +26,26380 +26,26410 +26,26440 +26,26470 +26,26500 +26,26530 +26,26710 + +# 대구 +27,27110 +27,27140 +27,27170 +27,27200 +27,27230 +27,27260 +27,27290 +27,27710 +27,27720 + +# 인천 +28,28125 +28,28155 +28,28177 +28,28185 +28,28200 +28,28237 +28,28245 +28,28275 +28,28290 +28,28710 +28,28720 + +# 대전 +30,30110 +30,30140 +30,30170 +30,30200 +30,30230 + +# 울산 +31,31110 +31,31140 +31,31170 +31,31200 +31,31710 + +# 세종 +36,36110 + +# 경기 +41,41110 +41,41111 +41,41113 +41,41115 +41,41117 +41,41130 +41,41131 +41,41133 +41,41135 +41,41150 +41,41170 +41,41171 +41,41173 +41,41192 +41,41194 +41,41196 +41,41210 +41,41220 +41,41250 +41,41271 +41,41273 +41,41281 +41,41285 +41,41287 +41,41290 +41,41310 +41,41360 +41,41370 +41,41390 +41,41410 +41,41430 +41,41450 +41,41461 +41,41463 +41,41465 +41,41480 +41,41500 +41,41550 +41,41570 +41,41591 +41,41593 +41,41595 +41,41597 +41,41610 +41,41630 +41,41650 +41,41670 +41,41800 + +# 충북 +43,43111 +43,43112 +43,43113 +43,43114 +43,43130 +43,43150 +43,43720 +43,43730 +43,43740 +43,43745 +43,43750 +43,43760 +43,43770 +43,43800 + +# 충남 +44,44131 +44,44133 +44,44150 +44,44180 +44,44200 +44,44210 +44,44230 +44,44250 +44,44270 +44,44710 +44,44760 +44,44770 +44,44790 +44,44800 + +# 경북 +47,47110 +47,47111 +47,47113 +47,47130 +47,47150 +47,47170 +47,47190 +47,47210 +47,47230 +47,47250 +47,47280 +47,47290 +47,47730 +47,47750 +47,47760 +47,47770 + +# 경남 +48,48120 +48,48121 +48,48123 +48,48125 +48,48127 +48,48129 +48,48170 +48,48220 +48,48240 +48,48250 +48,48270 +48,48310 +48,48330 +48,48720 +48,48730 +48,48740 + +# 제주 +50,50110 +50,50130 + +# 강원 +51,51110 +51,51130 +51,51150 +51,51170 +51,51190 +51,51210 +51,51230 +51,51720 +51,51730 +51,51750 +51,51760 +51,51770 +51,51780 +51,51790 +51,51800 + +# 전북 +52,52111 +52,52113 +52,52130 +52,52140 +52,52180 +52,52190 +52,52210 +52,52710 +52,52720 +52,52730 +52,52740 +52,52750 +52,52770 +52,52790 +52,52800 diff --git a/src/test/java/com/carecode/core/client/sync/KindergartenUpsertServiceTest.java b/src/test/java/com/carecode/core/client/sync/KindergartenUpsertServiceTest.java index 1145004e..e5d3aeac 100644 --- a/src/test/java/com/carecode/core/client/sync/KindergartenUpsertServiceTest.java +++ b/src/test/java/com/carecode/core/client/sync/KindergartenUpsertServiceTest.java @@ -19,9 +19,23 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +/** 실제 유치원알리미 basicInfo2 응답을 기준으로 검증한다. */ @DisplayName("유치원 적재") class KindergartenUpsertServiceTest { + /** 2026-08-05 실호출로 받은 응답 (옥인유치원). */ + private static final String REAL_ROW = """ + {"key":"1","kindercode":"1ecec08c-f026-b044-e053-0a32095ab044", + "officeedu":"서울특별시교육청","subofficeedu":"중부교육지원청", + "kindername":"옥인유치원","establish":"사립(사인)", + "addr":"서울특별시 종로구 자하문로 69","telno":"02-735-3984", + "hpaddr":"http://okin.kidis.co.kr","opertime":"08시00분~20시00분", + "clcnt3":"1","clcnt4":"1","clcnt5":"1","mixclcnt":"0","shclcnt":"0", + "ppcnt3":"7","ppcnt4":"17","ppcnt5":"17","mixppcnt":"0","shppcnt":"0", + "prmstfcnt":"90","ag3fpcnt":"15","ag4fpcnt":"20","ag5fpcnt":"22", + "mixfpcnt":"0","spcnfpcnt":"0","lttdcdnt":"37.5806","lngtcdnt":"126.9662"} + """; + private final ObjectMapper objectMapper = new ObjectMapper(); private CareFacilityRepository repository; private KindergartenUpsertService service; @@ -34,79 +48,94 @@ void setUp() { } @Test - @DisplayName("표준데이터 한글 필드명을 읽는다") - void mapsStandardDataFields() { - boolean isNew = service.upsert(row(""" - {"유치원명":"행복유치원","소재지도로명주소":"서울특별시 강남구 테헤란로 1", - "전화번호":"02-123-4567","설립유형":"공립","정원":"100","현원":"80", - "위도":"37.5","경도":"127.0","시도명":"서울특별시","시군구명":"강남구"} - """)); + @DisplayName("실제 응답의 기본 정보를 읽는다") + void mapsRealResponse() { + boolean isNew = service.upsert(row(REAL_ROW)); assertThat(isNew).isTrue(); CareFacility saved = captureSaved(); - assertThat(saved.getName()).isEqualTo("행복유치원"); + assertThat(saved.getName()).isEqualTo("옥인유치원"); assertThat(saved.getFacilityType()).isEqualTo(FacilityType.KINDERGARTEN); - assertThat(saved.getIsPublic()).isTrue(); - assertThat(saved.getAvailableSpots()).isEqualTo(20); - assertThat(saved.getLatitude()).isEqualTo(37.5); - assertThat(saved.getCity()).isEqualTo("서울특별시"); + assertThat(saved.getPhone()).isEqualTo("02-735-3984"); + assertThat(saved.getOperatingHours()).isEqualTo("08시00분~20시00분"); + assertThat(saved.getLatitude()).isEqualTo(37.5806); } @Test - @DisplayName("영문 필드명으로 와도 동일하게 읽는다") - void mapsEnglishFieldNames() { - service.upsert(row(""" - {"kindrgrtnNm":"한빛유치원","rdnmadr":"부산광역시 해운대구 1","telno":"051-1234-5678"} - """)); + @DisplayName("kindercode 를 시설 코드로 쓴다") + void usesKinderCodeAsFacilityCode() { + service.upsert(row(REAL_ROW)); - CareFacility saved = captureSaved(); - assertThat(saved.getName()).isEqualTo("한빛유치원"); - assertThat(saved.getPhone()).isEqualTo("051-1234-5678"); + assertThat(captureSaved().getFacilityCode()) + .isEqualTo(KindergartenUpsertService.CODE_PREFIX + "1ecec08c-f026-b044-e053-0a32095ab044"); } @Test - @DisplayName("고유 코드가 없어도 같은 유치원은 같은 코드가 나온다") - void generatesStableCodeFromNaturalKey() { - String json = """ - {"유치원명":"행복유치원","소재지도로명주소":"서울특별시 강남구 테헤란로 1"} - """; + @DisplayName("정원은 인가정원이 아니라 연령별 편성정원의 합을 쓴다") + void usesClassCapacityNotLicensed() { + service.upsert(row(REAL_ROW)); - service.upsert(row(json)); - String first = captureSaved().getFacilityCode(); + CareFacility saved = captureSaved(); + // 인가정원 90 을 쓰면 충원율이 46% 로 실제(72%)보다 낮게 나온다 + assertThat(saved.getCapacity()).isEqualTo(57); + assertThat(saved.getCurrentEnrollment()).isEqualTo(41); + assertThat(saved.getAvailableSpots()).isEqualTo(16); + } - service.upsert(row(json)); - String second = captureSaved().getFacilityCode(); + @Test + @DisplayName("편성정원이 없으면 인가정원으로 대체한다") + void fallsBackToLicensedCapacity() { + service.upsert(row(""" + {"kindercode":"c1","kindername":"테스트유치원","prmstfcnt":"80", + "ppcnt3":"10","ppcnt4":"10"} + """)); - assertThat(first).isEqualTo(second).startsWith(KindergartenUpsertService.CODE_PREFIX); + CareFacility saved = captureSaved(); + assertThat(saved.getCapacity()).isEqualTo(80); + assertThat(saved.getCurrentEnrollment()).isEqualTo(20); } @Test - @DisplayName("이름이 같아도 주소가 다르면 다른 시설로 본다") - void distinguishesSameNameDifferentAddress() { - service.upsert(row("{\"유치원명\":\"행복유치원\",\"소재지도로명주소\":\"서울특별시 강남구 1\"}")); - String seoul = captureSaved().getFacilityCode(); + @DisplayName("설립유형으로 국공립을 판별한다") + void resolvesPublicFromEstablishType() { + service.upsert(row("{\"kindercode\":\"c1\",\"kindername\":\"가\",\"establish\":\"공립(병설)\"}")); + assertThat(captureSaved().getIsPublic()).isTrue(); + + service.upsert(row("{\"kindercode\":\"c2\",\"kindername\":\"나\",\"establish\":\"사립(사인)\"}")); + assertThat(captureSaved().getIsPublic()).isFalse(); + } - service.upsert(row("{\"유치원명\":\"행복유치원\",\"소재지도로명주소\":\"부산광역시 해운대구 1\"}")); - String busan = captureSaved().getFacilityCode(); + @Test + @DisplayName("주소에서 시도·시군구를 분리한다") + void splitsRegionFromAddress() { + service.upsert(row(REAL_ROW)); - assertThat(seoul).isNotEqualTo(busan); + CareFacility saved = captureSaved(); + assertThat(saved.getCity()).isEqualTo("서울특별시"); + assertThat(saved.getDistrict()).isEqualTo("종로구"); } @Test - @DisplayName("고유 코드가 있으면 그것을 쓴다") - void prefersExternalCode() { - service.upsert(row("{\"유치원명\":\"행복유치원\",\"유치원코드\":\"K12345\"}")); + @DisplayName("null 로 오는 필드가 있어도 합계를 낸다") + void sumsWithNullFields() { + // 실제 응답에서 shclcnt·shppcnt 가 null 로 오는 경우가 있다 + service.upsert(row(""" + {"kindercode":"c1","kindername":"가","ag3fpcnt":"15","ag4fpcnt":null, + "ppcnt3":"7","ppcnt4":null,"shppcnt":null} + """)); - assertThat(captureSaved().getFacilityCode()) - .isEqualTo(KindergartenUpsertService.CODE_PREFIX + "K12345"); + CareFacility saved = captureSaved(); + assertThat(saved.getCapacity()).isEqualTo(15); + assertThat(saved.getCurrentEnrollment()).isEqualTo(7); } @Test - @DisplayName("유치원명이 없으면 저장하지 않는다") - void rejectsRowWithoutName() { - assertThatThrownBy(() -> service.upsert(row("{\"소재지도로명주소\":\"서울특별시\"}"))) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("유치원명"); + @DisplayName("코드나 이름이 없으면 저장하지 않는다") + void rejectsRowWithoutIdentity() { + assertThatThrownBy(() -> service.upsert(row("{\"kindername\":\"이름만\"}"))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> service.upsert(row("{\"kindercode\":\"코드만\"}"))) + .isInstanceOf(IllegalArgumentException.class); } private CareFacility captureSaved() { From 7ddcd15066b5f0e6725c4a9520ddc9d2e4f00aaa Mon Sep 17 00:00:00 2001 From: RosieOh Date: Wed, 5 Aug 2026 19:16:43 +0900 Subject: [PATCH 20/68] =?UTF-8?q?FEAT=20:=20=EB=B3=B4=EC=A1=B0=EA=B8=8824?= =?UTF-8?q?=20=EC=A0=95=EC=B1=85=20API=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A7=80=EC=9E=90=EC=B2=B4=20=EC=A7=80=EC=97=AD=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20=EC=88=98=EC=A0=95=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/client/provider/OdcloudProvider.java | 83 +++++++++++++++++++ .../sync/GovernmentBenefitSyncService.java | 6 +- .../core/client/sync/PolicyUpsertService.java | 16 +++- src/main/resources/application.yml | 2 +- 4 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/carecode/core/client/provider/OdcloudProvider.java diff --git a/src/main/java/com/carecode/core/client/provider/OdcloudProvider.java b/src/main/java/com/carecode/core/client/provider/OdcloudProvider.java new file mode 100644 index 00000000..1caf8243 --- /dev/null +++ b/src/main/java/com/carecode/core/client/provider/OdcloudProvider.java @@ -0,0 +1,83 @@ +package com.carecode.core.client.provider; + +import com.carecode.core.client.exception.PublicDataApiException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** + * 공공데이터포털 오픈API(api.odcloud.kr) 공급자. + * apis.data.go.kr 과 같은 인증키를 쓰지만 페이징 규약이 page/perPage 로 다르다. + */ +@Slf4j +@Component +public class OdcloudProvider implements PublicDataProvider { + + public static final String PROVIDER_NAME = "ODCLOUD"; + + private final RestTemplate restTemplate; + private final String serviceKey; + private final String baseUrl; + + public OdcloudProvider(RestTemplate restTemplate, + @Value("${public.data.datagokr.service-key:}") String serviceKey, + @Value("${public.data.odcloud.base-url:https://api.odcloud.kr}") String baseUrl) { + this.restTemplate = restTemplate; + this.serviceKey = serviceKey; + this.baseUrl = baseUrl != null && baseUrl.endsWith("/") + ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + } + + @Override + public String getProviderName() { + return PROVIDER_NAME; + } + + @Override + public boolean isAvailable() { + return serviceKey != null && !serviceKey.isBlank(); + } + + @Override + public String fetch(String resource, int pageNo, int numOfRows, Map params) { + if (!isAvailable()) { + throw new PublicDataApiException("공공데이터포털 서비스 키가 설정되지 않았습니다."); + } + + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(toAbsoluteUrl(resource)) + .queryParam("page", pageNo) + .queryParam("perPage", numOfRows); + + if (params != null) { + params.forEach((k, v) -> { + if (v != null && !v.isBlank()) { + builder.queryParam(k, v); + } + }); + } + + // serviceKey 는 이미 인코딩된 값이라 빌더를 거치면 이중 인코딩된다. + String url = builder.encode(StandardCharsets.UTF_8).toUriString() + "&serviceKey=" + serviceKey; + log.debug("odcloud 호출: resource={}, page={}, perPage={}", resource, pageNo, numOfRows); + + try { + return restTemplate.getForObject(URI.create(url), String.class); + } catch (Exception e) { + throw new PublicDataApiException( + "odcloud 호출 실패: resource=" + resource + ", 사유=" + e.getMessage(), e); + } + } + + private String toAbsoluteUrl(String resource) { + if (resource != null && (resource.startsWith("http://") || resource.startsWith("https://"))) { + return resource; + } + return baseUrl + "/" + (resource != null && resource.startsWith("/") ? resource.substring(1) : resource); + } +} diff --git a/src/main/java/com/carecode/core/client/sync/GovernmentBenefitSyncService.java b/src/main/java/com/carecode/core/client/sync/GovernmentBenefitSyncService.java index f68e6ab6..9029e88a 100644 --- a/src/main/java/com/carecode/core/client/sync/GovernmentBenefitSyncService.java +++ b/src/main/java/com/carecode/core/client/sync/GovernmentBenefitSyncService.java @@ -1,6 +1,6 @@ package com.carecode.core.client.sync; -import com.carecode.core.client.provider.DataGoKrProvider; +import com.carecode.core.client.provider.OdcloudProvider; import com.fasterxml.jackson.databind.JsonNode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,11 +22,11 @@ public class GovernmentBenefitSyncService { "육아", "출산", "임신", "보육", "양육", "아동", "어린이", "영유아", "유아", "산모", "신생아", "돌봄", "child", "어린이집", "유치원"); - private final DataGoKrProvider provider; + private final OdcloudProvider provider; private final PolicyUpsertService upsertService; private final PagedSyncTemplate syncTemplate; - @Value("${public.data.resource.benefit:1741000/publicServiceInformations/publicServiceInformation}") + @Value("${public.data.resource.benefit:https://api.odcloud.kr/api/gov24/v3/serviceList}") private String resource; public SyncResult sync() { diff --git a/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java b/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java index 2c139869..517786e7 100644 --- a/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java +++ b/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java @@ -46,7 +46,7 @@ public boolean upsert(JsonNode row) { policy.setTitle(text(row, "서비스명", "servNm", "SVC_NM")); policy.setDescription(text(row, "서비스목적요약", "servDgst", "SVC_DGST")); policy.setPolicyType(text(row, "서비스분야", "srvPvsnNm", "INTRS_THEMA_NM")); - policy.setTargetRegion(text(row, "소관기관명", "jurMnofNm", "JURISDICTION")); + policy.setTargetRegion(resolveRegion(row)); policy.setApplicationUrl(text(row, "상세조회URL", "servDtlLink", "DETAIL_URL")); policy.setContactInfo(text(row, "전화문의", "rprsCtadr", "CONTACT")); policy.setRequiredDocuments(text(row, "구비서류", "docCn")); @@ -57,6 +57,20 @@ public boolean upsert(JsonNode row) { return isNew; } + /** + * 중앙행정기관 정책은 전국 공통이고, 지자체 정책은 소관기관명이 곧 지역이다. + * 이 구분이 없으면 "교육부" 가 지역명으로 들어가 거주지 비교가 무의미해진다. + */ + private String resolveRegion(JsonNode row) { + String orgType = text(row, "소관기관유형", "orgTypeNm"); + String orgName = text(row, "소관기관명", "jurMnofNm", "JURISDICTION"); + + if (orgType != null && orgType.contains("중앙")) { + return "전국"; + } + return orgName != null ? orgName : "전국"; + } + /** 자유 텍스트에서 연령 조건을 개월로 환산해 채운다. 못 찾으면 기존 값을 건드리지 않는다. */ private void applyAgeRange(Policy policy, JsonNode row) { String source = String.join(" ", diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f0be8264..9519f074 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -252,7 +252,7 @@ public: resource: childcare: ${PUBLIC_DATA_RESOURCE_CHILDCARE:http://api.childcare.go.kr/mediate/rest/cpmsapi021/cpmsapi021/request} kindergarten: ${PUBLIC_DATA_RESOURCE_KINDERGARTEN:https://e-childschoolinfo.moe.go.kr/api/notice/basicInfo2.do} - benefit: ${PUBLIC_DATA_RESOURCE_BENEFIT:1741000/publicServiceInformations/publicServiceInformation} + benefit: ${PUBLIC_DATA_RESOURCE_BENEFIT:https://api.odcloud.kr/api/gov24/v3/serviceList} hospital: ${PUBLIC_DATA_RESOURCE_HOSPITAL:B551182/hospInfoServicev2/getHospBasisList} sync: From eccb4eb76d928e07a4aaeae655d18487338cb9fd Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 10:32:16 +0900 Subject: [PATCH 21/68] =?UTF-8?q?FIX=20:=20=EC=BB=B4=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=BD=94=EB=94=A9=20=EB=AF=B8=EC=A7=80=EC=A0=95?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=95=9C=EA=B8=80=20=EB=A6=AC=ED=84=B0?= =?UTF-8?q?=EB=9F=B4=20=EA=B9=A8=EC=A7=90=20=EA=B5=90=EC=A0=95=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index d734674e..45772f2a 100644 --- a/build.gradle +++ b/build.gradle @@ -83,11 +83,40 @@ dependencies { implementation 'org.asciidoctor:asciidoctorj-pdf:2.3.4' } +// 소스에 한글 문자열 리터럴이 있다. 인코딩을 지정하지 않으면 Windows(CP949)에서 깨져 컴파일된다. +// 공공데이터 응답의 한글 필드명("서비스ID" 등) 조회가 전부 실패하는 원인이었다. +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} + +tasks.withType(Test).configureEach { + systemProperty 'file.encoding', 'UTF-8' + jvmArgs '-Dfile.encoding=UTF-8' +} + tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform { + // 외부 API 를 호출하는 테스트는 기본 빌드에서 제외한다. 네트워크 상태로 빌드가 깨지면 안 된다. + // 실행: ./gradlew liveSyncCheck + excludeTags 'live' + } finalizedBy(tasks.named('jacocoTestReport')) } +/** 공공데이터 실연동 점검. 키를 환경변수로 넘겨 수동 실행한다. */ +tasks.register('liveSyncCheck', Test) { + group = 'verification' + description = '실제 공공데이터 API 를 호출해 적재까지 확인한다' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform { + includeTags 'live' + } + testLogging { + showStandardStreams = true + } +} + jacoco { toolVersion = "0.8.12" } From 1204b29f1cc77f7c98ef45dd3e077444faa5811b Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 10:32:16 +0900 Subject: [PATCH 22/68] =?UTF-8?q?FIX=20:=20=EC=BA=90=EC=8B=9C=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EC=9D=B4=20none=20=EC=9D=B4=EC=96=B4=EB=8F=84=20Redis?= =?UTF-8?q?=20=EC=BA=90=EC=8B=9C=EA=B0=80=20=EC=83=9D=EC=84=B1=EB=90=98?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/carecode/core/config/CacheConfig.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/carecode/core/config/CacheConfig.java b/src/main/java/com/carecode/core/config/CacheConfig.java index 44ac92bc..0c73ad12 100644 --- a/src/main/java/com/carecode/core/config/CacheConfig.java +++ b/src/main/java/com/carecode/core/config/CacheConfig.java @@ -8,6 +8,7 @@ import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.cache.RedisCacheConfiguration; import org.springframework.data.redis.cache.RedisCacheManager; @@ -20,9 +21,13 @@ import java.util.HashMap; import java.util.Map; -/** Redis 캐시 설정 */ +/** + * Redis 캐시 설정. + * spring.cache.type=none 이면 이 설정을 만들지 않는다 — 그렇지 않으면 Redis 없이 로컬·테스트 구동이 불가능하다. + */ @Configuration @EnableCaching +@ConditionalOnProperty(name = "spring.cache.type", havingValue = "redis", matchIfMissing = true) public class CacheConfig { // 기본 캐시 설정 From 439bfe33b9c213444c4db8eb78abfc218535b777 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 10:32:16 +0900 Subject: [PATCH 23/68] =?UTF-8?q?FEAT=20:=20=EB=B3=91=EC=9B=90=20=EC=A7=84?= =?UTF-8?q?=EB=A3=8C=EA=B3=BC=EB=AA=A9=EA=B3=BC=20=EC=9A=94=EC=96=91?= =?UTF-8?q?=EA=B8=B0=EA=B4=80=20=EC=A2=85=EB=B3=84=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../carecode/core/client/sync/HospitalUpsertService.java | 5 +++-- .../java/com/carecode/domain/health/entity/Hospital.java | 6 +++++- src/main/resources/db/migration/V10__hospital_grade.sql | 6 ++++++ 3 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 src/main/resources/db/migration/V10__hospital_grade.sql diff --git a/src/main/java/com/carecode/core/client/sync/HospitalUpsertService.java b/src/main/java/com/carecode/core/client/sync/HospitalUpsertService.java index cf4cf97f..64d349d3 100644 --- a/src/main/java/com/carecode/core/client/sync/HospitalUpsertService.java +++ b/src/main/java/com/carecode/core/client/sync/HospitalUpsertService.java @@ -42,8 +42,9 @@ public boolean upsert(JsonNode row, String defaultType) { applyIfPresent(text(row, "addr", "ADDR"), hospital::setAddress); applyIfPresent(text(row, "telno", "TELNO"), hospital::setPhone); - String clCdNm = text(row, "clCdNm", "CLCDNM"); - hospital.setType(clCdNm != null ? clCdNm : defaultType); + // clCdNm 은 "상급종합"·"의원" 같은 종별이다. 이걸 type 에 넣으면 소아과 검색이 안 된다. + hospital.setType(defaultType); + applyIfPresent(text(row, "clCdNm", "CLCDNM"), hospital::setGrade); // 심평원 좌표는 XPos=경도, YPos=위도 순서다. 뒤집으면 지도에서 엉뚱한 위치가 나온다. Double lng = decimal(row, "XPos", "XPOS"); diff --git a/src/main/java/com/carecode/domain/health/entity/Hospital.java b/src/main/java/com/carecode/domain/health/entity/Hospital.java index c6fdb7a4..c121ab4a 100644 --- a/src/main/java/com/carecode/domain/health/entity/Hospital.java +++ b/src/main/java/com/carecode/domain/health/entity/Hospital.java @@ -28,7 +28,11 @@ public class Hospital { private String name; @Column - private String type; // 소아과, 산부인과 등 + private String type; // 진료과목 (소아청소년과 등) + + /** 요양기관 종별. 동네 의원과 대학병원은 부모의 선택 기준이 다르다. */ + @Column(name = "GRADE") + private String grade; @Column private String address; diff --git a/src/main/resources/db/migration/V10__hospital_grade.sql b/src/main/resources/db/migration/V10__hospital_grade.sql new file mode 100644 index 00000000..a3dafbe8 --- /dev/null +++ b/src/main/resources/db/migration/V10__hospital_grade.sql @@ -0,0 +1,6 @@ +-- 요양기관 종별. type 에는 진료과목(소아청소년과)이 들어가야 검색이 되므로 종별은 따로 둔다 +-- "의원"(동네 소아과)과 "상급종합"(대학병원)은 부모의 선택 기준이 완전히 다르다 +ALTER TABLE TBL_HOSPITAL + ADD COLUMN GRADE VARCHAR(50) NULL COMMENT '요양기관 종별 (의원/병원/종합병원/상급종합)'; + +CREATE INDEX IDX_HOSPITAL_TYPE_GRADE ON TBL_HOSPITAL (TYPE, GRADE); From 4034e5d7dfe9ca45269e89654cbb646d0ade2e65 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 10:32:16 +0900 Subject: [PATCH 24/68] =?UTF-8?q?TEST=20:=20=EA=B3=B5=EA=B3=B5=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=8B=A4=EC=97=B0=EB=8F=99=20=EC=A0=90?= =?UTF-8?q?=EA=B2=80=20=ED=83=9C=EC=8A=A4=ED=81=AC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration/LivePublicDataSyncTest.java | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 src/test/java/com/carecode/integration/LivePublicDataSyncTest.java diff --git a/src/test/java/com/carecode/integration/LivePublicDataSyncTest.java b/src/test/java/com/carecode/integration/LivePublicDataSyncTest.java new file mode 100644 index 00000000..7814b218 --- /dev/null +++ b/src/test/java/com/carecode/integration/LivePublicDataSyncTest.java @@ -0,0 +1,139 @@ +package com.carecode.integration; + +import com.carecode.CareCodeApplication; +import com.carecode.core.client.sync.GovernmentBenefitSyncService; +import com.carecode.core.client.sync.KindergartenSyncService; +import com.carecode.core.client.sync.PediatricHospitalSyncService; +import com.carecode.core.client.sync.SyncResult; +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.carecode.domain.careFacility.repository.FacilityCapacitySnapshotRepository; +import com.carecode.domain.health.repository.HospitalRepository; +import com.carecode.domain.policy.repository.PolicyRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.mail.javamail.JavaMailSender; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 실제 공공데이터 API 를 호출해 적재까지 확인한다. + * 외부 의존이 있어 일반 빌드에서는 제외되고, 연동 점검이 필요할 때만 수동으로 돌린다. + * + * 실행: ./gradlew test --tests '*LivePublicDataSyncTest' -DincludeTags=live \ + * -DKINDERGARTEN_INFO_KEY=... -DDATA_GO_KR_SERVICE_KEY=... + */ +@Tag("live") +@SpringBootTest( + classes = CareCodeApplication.class, + properties = { + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," + + "org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration," + + "org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration", + "spring.cache.type=none", + "spring.batch.job.enabled=false", + "spring.datasource.url=jdbc:h2:mem:live;MODE=MySQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.flyway.enabled=false", + "app.search.fulltext-enabled=false", + // 전국을 다 돌면 오래 걸린다. 연동 확인에는 몇 페이지면 충분하다. + "public.data.sync.max-pages=2", + "jwt.secret=liveSyncTestSecretKeyMustBeAtLeast256BitsLong0123456789abc", + "springdoc.api-docs.enabled=false", + "springdoc.swagger-ui.enabled=false", + "public.data.api.key=dummy", + "KAKAO_CLIENT_ID=d", "KAKAO_CLIENT_SECRET=d", + "MAIL_USERNAME=d", "MAIL_PASSWORD=d" + } +) +@DisplayName("공공데이터 실연동 점검") +class LivePublicDataSyncTest { + + @MockBean private RedisConnectionFactory redisConnectionFactory; + @MockBean private StringRedisTemplate stringRedisTemplate; + @MockBean private JavaMailSender javaMailSender; + + @Autowired private KindergartenSyncService kindergartenSync; + @Autowired private GovernmentBenefitSyncService benefitSync; + @Autowired private PediatricHospitalSyncService hospitalSync; + @Autowired private CareFacilityRepository facilityRepository; + @Autowired private FacilityCapacitySnapshotRepository snapshotRepository; + @Autowired private PolicyRepository policyRepository; + @Autowired private HospitalRepository hospitalRepository; + + @Test + @DisplayName("유치원: 시설과 정원 스냅샷이 함께 적재된다") + void syncsKindergartens() { + SyncResult result = kindergartenSync.sync(); + System.out.println("@@ 유치원 " + result); + + assertThat(result.getCreated()).isPositive(); + List saved = facilityRepository.findAll(); + assertThat(saved).isNotEmpty(); + + CareFacility sample = saved.get(0); + assertThat(sample.getName()).isNotBlank(); + assertThat(sample.getCapacity()).isNotNull(); + assertThat(sample.getCurrentEnrollment()).isNotNull(); + // 위경도가 없으면 반경 검색이 무의미해진다 + assertThat(sample.getLatitude()).isNotNull(); + // 스냅샷이 쌓여야 입소 예측이 가능해진다 + assertThat(snapshotRepository.count()).isPositive(); + + System.out.println("@@ 예시: " + sample.getName() + " / 정원 " + sample.getCapacity() + + " 현원 " + sample.getCurrentEnrollment() + " / " + sample.getAddress()); + } + + @Test + @DisplayName("정책: 지자체 정책의 지역명이 기관명이 아니라 지역으로 들어간다") + void syncsBenefits() { + SyncResult result = benefitSync.sync(); + System.out.println("@@ 정책 " + result); + + assertThat(result.getTotalProcessed()).isPositive(); + var policies = policyRepository.findByIsActiveTrue(); + assertThat(policies).isNotEmpty(); + + policies.stream().limit(5).forEach(p -> + System.out.println("@@ 정책: [" + p.getTargetRegion() + "] " + p.getTitle())); + // "교육부" 같은 기관명이 지역으로 들어가면 거주지 비교가 깨진다 + assertThat(policies).allSatisfy(p -> assertThat(p.getTargetRegion()).isNotBlank()); + } + + @Test + @DisplayName("병원: 진료과목과 종별이 분리 저장된다") + void syncsHospitals() { + SyncResult result = hospitalSync.sync(); + System.out.println("@@ 병원 " + result); + + assertThat(result.getCreated()).isPositive(); + var hospitals = hospitalRepository.findAll(); + assertThat(hospitals).isNotEmpty(); + + hospitals.stream().limit(5).forEach(h -> System.out.println( + "@@ 병원: " + h.getName() + " / " + h.getType() + " / " + h.getGrade() + + " / " + h.getLatitude() + "," + h.getLongitude())); + + assertThat(hospitals).allSatisfy(h -> { + assertThat(h.getType()).isEqualTo("소아청소년과"); + // 위도는 33~39, 경도는 124~132 범위다. 뒤집히면 여기서 잡힌다. + if (h.getLatitude() != null) { + assertThat(h.getLatitude()).isBetween(33.0, 39.0); + assertThat(h.getLongitude()).isBetween(124.0, 132.0); + } + }); + } +} From 3bdfa144c05905937fa4407706b4bcc641146113 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 10:51:19 +0900 Subject: [PATCH 25/68] =?UTF-8?q?FEAT=20:=20=EC=96=B4=EB=A6=B0=EC=9D=B4?= =?UTF-8?q?=EC=A7=91=20=EC=8B=A4=EC=97=B0=EB=8F=99=20-=20HTTPS=20=EC=A0=84?= =?UTF-8?q?=ED=99=98,=20=EC=8B=9C=EA=B5=B0=EA=B5=AC=20=EC=88=9C=ED=9A=8C,?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=EB=A7=A4=ED=95=91=20=EA=B5=90=EC=A0=95?= =?UTF-8?q?=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sync/CareFacilityUpsertService.java | 22 +- ...ationwideChildcareFacilitySyncService.java | 96 ++++++- .../core/client/sync/RegionCodeCatalog.java | 41 ++- src/main/resources/application.yml | 4 +- .../public-data/childcare-regions.txt | 234 ++++++++++++++++++ .../integration/LivePublicDataSyncTest.java | 17 ++ 6 files changed, 381 insertions(+), 33 deletions(-) create mode 100644 src/main/resources/public-data/childcare-regions.txt diff --git a/src/main/java/com/carecode/core/client/sync/CareFacilityUpsertService.java b/src/main/java/com/carecode/core/client/sync/CareFacilityUpsertService.java index 358bb60d..43bdaaa1 100644 --- a/src/main/java/com/carecode/core/client/sync/CareFacilityUpsertService.java +++ b/src/main/java/com/carecode/core/client/sync/CareFacilityUpsertService.java @@ -24,7 +24,7 @@ public class CareFacilityUpsertService { /** 시설 코드 기준 upsert. */ @Transactional(propagation = Propagation.REQUIRES_NEW) public boolean upsert(JsonNode row) { - String facilityCode = text(row, "STCODE", "crcodeCd", "crcode"); + String facilityCode = text(row, "stcode", "STCODE", "crcode"); if (facilityCode == null) { throw new IllegalArgumentException("시설 코드가 없는 응답입니다."); } @@ -39,23 +39,25 @@ public boolean upsert(JsonNode row) { .build(); } - applyIfPresent(text(row, "CRNAME", "crname"), facility::setName); - applyIfPresent(text(row, "CRADDR", "craddr"), facility::setAddress); - applyIfPresent(text(row, "CRTELNO", "crtelno"), facility::setPhone); - applyIfPresent(text(row, "CRHOME", "crhome"), facility::setWebsite); + applyIfPresent(text(row, "crname", "CRNAME"), facility::setName); + applyIfPresent(text(row, "craddr", "CRADDR"), facility::setAddress); + // 실제 응답은 crtel 이다 (crtelno 아님) + applyIfPresent(text(row, "crtel", "CRTELNO", "crtelno"), facility::setPhone); + applyIfPresent(text(row, "crhome", "CRHOME"), facility::setWebsite); - String typeName = text(row, "CRTYPENAME", "crtypeName"); + String typeName = text(row, "crtypename", "CRTYPENAME", "crtypeName"); if (typeName != null) { facility.setFacilityType(resolveType(typeName)); } else if (isNew) { facility.setFacilityType(FacilityType.DAYCARE); } - Integer capacity = integer(row, "CRCAPAT", "crcapat"); + Integer capacity = integer(row, "crcapat", "CRCAPAT"); if (capacity != null) { facility.setCapacity(capacity); } - Integer enrollment = integer(row, "CRCHCNT", "crchcnt"); + // cpmsapi021 은 현원을 주지 않는다. 상세 오퍼레이션이 열리면 채워진다. + Integer enrollment = integer(row, "crchcnt", "CRCHCNT"); if (enrollment != null) { facility.setCurrentEnrollment(enrollment); Integer effectiveCapacity = capacity != null ? capacity : facility.getCapacity(); @@ -64,8 +66,8 @@ public boolean upsert(JsonNode row) { } } - Double lat = decimal(row, "LA", "la", "LAT"); - Double lng = decimal(row, "LO", "lo", "LNG"); + Double lat = decimal(row, "la", "LA", "LAT"); + Double lng = decimal(row, "lo", "LO", "LNG"); if (lat != null && lng != null) { facility.setLatitude(lat); facility.setLongitude(lng); diff --git a/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java b/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java index 4aa1ab2a..ed9ff493 100644 --- a/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java +++ b/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java @@ -1,34 +1,104 @@ package com.carecode.core.client.sync; +import com.carecode.core.client.XmlResponseParser; import com.carecode.core.client.provider.ChildcarePortalProvider; +import com.fasterxml.jackson.databind.JsonNode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -/** 전국 어린이집 정보 동기화. */ +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 전국 어린이집 동기화. + * arcode(시군구) 가 필수이고 페이징이 없어 지역을 순회한다 — PagedSyncTemplate 을 쓸 수 없다. + */ @Slf4j @Service @RequiredArgsConstructor public class NationwideChildcareFacilitySyncService { - private static final int ROWS_PER_PAGE = 500; - private final ChildcarePortalProvider provider; private final CareFacilityUpsertService upsertService; - private final PagedSyncTemplate syncTemplate; + private final RegionCodeCatalog regionCatalog; + private final XmlResponseParser xmlResponseParser; - /** 데이터셋 경로는 개편될 수 있어 재배포 없이 바꿀 수 있게 프로퍼티로 둔다. */ - @Value("${public.data.resource.childcare:http://api.childcare.go.kr/mediate/rest/cpmsapi021/cpmsapi021/request}") + @Value("${public.data.resource.childcare:" + + "https://api.childcare.go.kr/mediate/rest/cpmsapi021/cpmsapi021/request}") private String resource; public SyncResult sync() { - return syncTemplate.run(SyncSpec.builder() - .provider(provider) - .resource(resource) - .label("전국어린이집") - .rowsPerPage(ROWS_PER_PAGE) - .upsert(upsertService::upsert) - .build()); + SyncResult result = new SyncResult(provider.getProviderName(), "전국어린이집"); + + if (!provider.isAvailable()) { + result.stop("보육통합정보 서비스 키 미설정"); + log.info("어린이집 동기화 건너뜀 - 서비스 키가 없습니다."); + return result; + } + + List regions = regionCatalog.childcareRegions(); + if (regions.isEmpty()) { + result.stop("시군구 코드 목록이 비어 있음"); + return result; + } + + int emptyRegions = 0; + for (String arcode : regions) { + JsonNode rows; + try { + rows = extractRows(provider.fetch(resource, 1, 0, buildParams(arcode))); + } catch (Exception e) { + // 한 지역 실패로 전국 수집을 중단하지 않는다. + log.warn("어린이집 조회 실패 - arcode={}: {}", arcode, e.getMessage()); + result.countFailed(); + continue; + } + + if (rows == null || rows.isEmpty()) { + emptyRegions++; + continue; + } + for (JsonNode row : rows) { + try { + if (upsertService.upsert(row)) { + result.countCreated(); + } else { + result.countUpdated(); + } + } catch (Exception e) { + result.countFailed(); + log.warn("시설 저장 실패: {}", e.getMessage()); + } + } + result.countPage(); + } + + if (emptyRegions == regions.size()) { + result.stop("전 지역 응답 없음 - 서비스 키 또는 응답 형식 확인 필요"); + log.error("어린이집 동기화: {}개 지역 전부 빈 응답", regions.size()); + } + return result; + } + + private Map buildParams(String arcode) { + Map params = new LinkedHashMap<>(); + params.put("arcode", arcode); + return params; + } + + /** 응답은 XML 이고 항목이 response/item 으로 온다. */ + private JsonNode extractRows(String body) { + if (body == null || body.isBlank()) { + return null; + } + JsonNode root = xmlResponseParser.parse(body); + if (root == null) { + return null; + } + JsonNode items = root.path("item"); + return items.isArray() ? items : null; } } diff --git a/src/main/java/com/carecode/core/client/sync/RegionCodeCatalog.java b/src/main/java/com/carecode/core/client/sync/RegionCodeCatalog.java index 4e8d0a3c..05627100 100644 --- a/src/main/java/com/carecode/core/client/sync/RegionCodeCatalog.java +++ b/src/main/java/com/carecode/core/client/sync/RegionCodeCatalog.java @@ -17,12 +17,21 @@ public class RegionCodeCatalog { private static final String KINDERGARTEN_REGIONS = "public-data/kindergarten-regions.txt"; + private static final String CHILDCARE_REGIONS = "public-data/childcare-regions.txt"; private final List kindergartenRegions; + private final List childcareRegions; public RegionCodeCatalog() { this.kindergartenRegions = load(KINDERGARTEN_REGIONS); - log.info("유치원 조회 대상 시군구 {}개를 읽었습니다.", kindergartenRegions.size()); + this.childcareRegions = loadCodes(CHILDCARE_REGIONS); + log.info("조회 대상 시군구 - 유치원 {}개, 어린이집 {}개", + kindergartenRegions.size(), childcareRegions.size()); + } + + /** 어린이집 API 는 시군구 코드 하나만 받는다. */ + public List childcareRegions() { + return childcareRegions; } public record RegionCode(String sidoCode, String sggCode) { @@ -32,26 +41,42 @@ public List kindergartenRegions() { return kindergartenRegions; } + private List loadCodes(String path) { + List codes = new ArrayList<>(); + for (String line : readLines(path)) { + codes.add(line); + } + return Collections.unmodifiableList(codes); + } + /** 목록이 없으면 동기화가 조용히 0건으로 끝나므로 실패를 로그로 드러낸다. */ private List load(String path) { List codes = new ArrayList<>(); + for (String line : readLines(path)) { + String[] parts = line.split(","); + if (parts.length == 2) { + codes.add(new RegionCode(parts[0].trim(), parts[1].trim())); + } + } + return Collections.unmodifiableList(codes); + } + + /** 주석과 빈 줄을 걸러 내용만 돌려준다. */ + private List readLines(String path) { + List lines = new ArrayList<>(); try (BufferedReader reader = new BufferedReader( new InputStreamReader(new ClassPathResource(path).getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { String trimmed = line.trim(); - if (trimmed.isEmpty() || trimmed.startsWith("#")) { - continue; - } - String[] parts = trimmed.split(","); - if (parts.length == 2) { - codes.add(new RegionCode(parts[0].trim(), parts[1].trim())); + if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { + lines.add(trimmed); } } } catch (Exception e) { log.error("시군구 코드 목록을 읽지 못했습니다: {}", path, e); } - return Collections.unmodifiableList(codes); + return lines; } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9519f074..8e0799d8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -229,7 +229,7 @@ public: # 보육통합정보시스템 — data.go.kr 이 아닌 별도 시스템. 인증 파라미터명이 다르다 childcare-portal: service-key: ${CHILDCARE_PORTAL_KEY:} - base-url: ${CHILDCARE_PORTAL_BASE_URL:http://api.childcare.go.kr} + base-url: ${CHILDCARE_PORTAL_BASE_URL:https://api.childcare.go.kr} # 명세서와 다르면 코드가 아니라 여기를 고친다 key-param: ${CHILDCARE_PORTAL_KEY_PARAM:key} page-param: ${CHILDCARE_PORTAL_PAGE_PARAM:} @@ -250,7 +250,7 @@ public: # 데이터셋 경로. 공공데이터 오퍼레이션은 개편되므로 재배포 없이 바꿀 수 있게 뺀다. # 절대 URL 을 넣으면 base-url 대신 그대로 호출한다(표준데이터는 호스트가 다르다). resource: - childcare: ${PUBLIC_DATA_RESOURCE_CHILDCARE:http://api.childcare.go.kr/mediate/rest/cpmsapi021/cpmsapi021/request} + childcare: ${PUBLIC_DATA_RESOURCE_CHILDCARE:https://api.childcare.go.kr/mediate/rest/cpmsapi021/cpmsapi021/request} kindergarten: ${PUBLIC_DATA_RESOURCE_KINDERGARTEN:https://e-childschoolinfo.moe.go.kr/api/notice/basicInfo2.do} benefit: ${PUBLIC_DATA_RESOURCE_BENEFIT:https://api.odcloud.kr/api/gov24/v3/serviceList} hospital: ${PUBLIC_DATA_RESOURCE_HOSPITAL:B551182/hospInfoServicev2/getHospBasisList} diff --git a/src/main/resources/public-data/childcare-regions.txt b/src/main/resources/public-data/childcare-regions.txt new file mode 100644 index 00000000..cdb83185 --- /dev/null +++ b/src/main/resources/public-data/childcare-regions.txt @@ -0,0 +1,234 @@ +# 어린이집(보육통합정보) 조회 대상 시군구 코드 (arcode) +# 이 API 는 arcode 가 필수이고 페이징이 없어 지역 단위로 순회한다. +# 2026-08-06 실호출 탐색으로 확인. 행정구역 개편 시 갱신한다. +# 광주(29)·전남(46)은 어린이집·유치원 API 양쪽 모두 데이터가 비어 있어 목록에 없다. + +# 서울 +11110 +11140 +11170 +11200 +11215 +11230 +11260 +11290 +11305 +11320 +11350 +11380 +11410 +11440 +11470 +11500 +11530 +11545 +11560 +11590 +11620 +11650 +11680 +11710 +11740 + +# 부산 +26110 +26140 +26170 +26200 +26230 +26260 +26290 +26320 +26350 +26380 +26410 +26440 +26470 +26500 +26530 +26710 + +# 대구 +27110 +27140 +27170 +27200 +27230 +27260 +27290 +27710 +27720 + +# 인천 +28125 +28155 +28177 +28185 +28200 +28237 +28245 +28275 +28290 +28710 +28720 + +# 대전 +30110 +30140 +30170 +30200 +30230 + +# 울산 +31110 +31140 +31170 +31200 +31710 + +# 세종 +36110 + +# 경기 +41110 +41111 +41113 +41115 +41117 +41130 +41131 +41133 +41135 +41150 +41170 +41171 +41173 +41192 +41194 +41196 +41210 +41220 +41250 +41271 +41273 +41281 +41285 +41287 +41290 +41310 +41360 +41370 +41390 +41410 +41430 +41450 +41461 +41463 +41480 +41500 +41550 +41570 +41591 +41593 +41595 +41597 +41610 +41630 +41650 +41800 + +# 충북 +43111 +43112 +43113 +43114 +43130 +43150 +43720 +43730 +43740 +43750 +43760 +43770 +43800 + +# 충남 +44131 +44150 +44180 +44200 +44210 +44230 +44710 +44760 +44770 +44790 +44800 + +# 경북 +47110 +47130 +47150 +47170 +47190 +47210 +47230 +47250 +47280 +47290 +47730 +47750 +47760 +47770 + +# 경남 +48120 +48121 +48123 +48125 +48127 +48129 +48170 +48220 +48240 +48250 +48270 +48310 +48330 +48720 +48730 +48740 + +# 강원 +51110 +51130 +51150 +51170 +51190 +51210 +51230 +51720 +51730 +51750 +51760 +51770 +51780 +51790 +51800 + +# 전북 +52111 +52113 +52130 +52140 +52180 +52190 +52210 +52710 +52720 +52730 +52740 +52750 +52770 +52790 +52800 diff --git a/src/test/java/com/carecode/integration/LivePublicDataSyncTest.java b/src/test/java/com/carecode/integration/LivePublicDataSyncTest.java index 7814b218..e8ca5ba9 100644 --- a/src/test/java/com/carecode/integration/LivePublicDataSyncTest.java +++ b/src/test/java/com/carecode/integration/LivePublicDataSyncTest.java @@ -2,6 +2,7 @@ import com.carecode.CareCodeApplication; import com.carecode.core.client.sync.GovernmentBenefitSyncService; +import com.carecode.core.client.sync.NationwideChildcareFacilitySyncService; import com.carecode.core.client.sync.KindergartenSyncService; import com.carecode.core.client.sync.PediatricHospitalSyncService; import com.carecode.core.client.sync.SyncResult; @@ -67,6 +68,7 @@ class LivePublicDataSyncTest { @MockBean private JavaMailSender javaMailSender; @Autowired private KindergartenSyncService kindergartenSync; + @Autowired private NationwideChildcareFacilitySyncService childcareSync; @Autowired private GovernmentBenefitSyncService benefitSync; @Autowired private PediatricHospitalSyncService hospitalSync; @Autowired private CareFacilityRepository facilityRepository; @@ -136,4 +138,19 @@ void syncsHospitals() { } }); } + + @Test + @DisplayName("어린이집: 시설코드·정원이 적재된다") + void syncsChildcareFacilities() { + SyncResult result = childcareSync.sync(); + System.out.println("@@ 어린이집 " + result); + + assertThat(result.getCreated()).isPositive(); + CareFacility sample = facilityRepository.findAll().get(0); + System.out.println("@@ 예시: " + sample.getName() + " / 정원 " + sample.getCapacity() + + " / " + sample.getPhone() + " / " + sample.getAddress()); + + assertThat(sample.getName()).isNotBlank(); + assertThat(sample.getCapacity()).isNotNull(); + } } From 470db2a7ee101ece24a85d97e87bcd742e9c89f2 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 11:21:55 +0900 Subject: [PATCH 26/68] =?UTF-8?q?FEAT=20:=20=EB=B3=B4=EC=9C=A1=ED=86=B5?= =?UTF-8?q?=ED=95=A9=EC=A0=95=EB=B3=B4=20=EC=9D=91=EB=8B=B5=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B2=98=EB=A6=AC=EB=A1=9C=20=ED=95=9C=EB=8F=84=20?= =?UTF-8?q?=EC=B4=88=EA=B3=BC=C2=B7=ED=82=A4=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=EA=B0=90=EC=A7=80=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/client/sync/ChildcareApiStatus.java | 62 ++++++++++++++ ...ationwideChildcareFacilitySyncService.java | 28 +++++-- .../client/sync/ChildcareApiStatusTest.java | 81 +++++++++++++++++++ 3 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/carecode/core/client/sync/ChildcareApiStatus.java create mode 100644 src/test/java/com/carecode/core/client/sync/ChildcareApiStatusTest.java diff --git a/src/main/java/com/carecode/core/client/sync/ChildcareApiStatus.java b/src/main/java/com/carecode/core/client/sync/ChildcareApiStatus.java new file mode 100644 index 00000000..6b041268 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/ChildcareApiStatus.java @@ -0,0 +1,62 @@ +package com.carecode.core.client.sync; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * 보육통합정보 API 응답 코드 (명세서 v1.0 기준). + * 한도 초과·키 만료를 "검색결과 없음" 과 구분하지 않으면 동기화가 조용히 0건으로 끝난다. + */ +public enum ChildcareApiStatus { + + OK(null, false), + MISSING_PARAM("ERROR-100", false), + SERVER_ERROR("ERROR-200", false), + + /** 아래 셋은 지역을 더 돌아도 소용없으므로 즉시 중단해야 한다. */ + INVALID_KEY("INFO-100", true), + QUOTA_EXCEEDED("INFO-300", true), + EXPIRED_KEY("INFO-400", true), + + /** 그 지역에 데이터가 없는 정상 상태. */ + NO_RESULT("INFO-200", false); + + private final String code; + private final boolean fatal; + + ChildcareApiStatus(String code, boolean fatal) { + this.code = code; + this.fatal = fatal; + } + + /** 계속 호출해도 의미가 없는 상태인지. */ + public boolean isFatal() { + return fatal; + } + + public String getCode() { + return code; + } + + public static ChildcareApiStatus of(JsonNode root) { + if (root == null) { + return OK; + } + JsonNode errcode = root.path("errcode"); + if (errcode.isMissingNode() || errcode.isNull()) { + return OK; + } + String value = errcode.asText().trim(); + for (ChildcareApiStatus status : values()) { + if (status.code != null && status.code.equals(value)) { + return status; + } + } + return SERVER_ERROR; + } + + /** 사용자에게 보여줄 중단 사유. */ + public String describe(JsonNode root) { + String message = root != null ? root.path("errmsg").asText("") : ""; + return message.isBlank() ? name() : code + " " + message; + } +} diff --git a/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java b/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java index ed9ff493..ae74724b 100644 --- a/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java +++ b/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java @@ -47,9 +47,9 @@ public SyncResult sync() { int emptyRegions = 0; for (String arcode : regions) { - JsonNode rows; + JsonNode root; try { - rows = extractRows(provider.fetch(resource, 1, 0, buildParams(arcode))); + root = parse(provider.fetch(resource, 1, 0, buildParams(arcode))); } catch (Exception e) { // 한 지역 실패로 전국 수집을 중단하지 않는다. log.warn("어린이집 조회 실패 - arcode={}: {}", arcode, e.getMessage()); @@ -57,6 +57,20 @@ public SyncResult sync() { continue; } + // 한도 초과·키 만료를 "검색결과 없음" 으로 넘기면 조용히 0건으로 끝난다. + ChildcareApiStatus status = ChildcareApiStatus.of(root); + if (status.isFatal()) { + result.stop("API 응답: " + status.describe(root)); + log.error("어린이집 동기화 중단 - {}", status.describe(root)); + return result; + } + if (status == ChildcareApiStatus.MISSING_PARAM || status == ChildcareApiStatus.SERVER_ERROR) { + log.warn("어린이집 조회 오류 - arcode={}, {}", arcode, status.describe(root)); + result.countFailed(); + continue; + } + + JsonNode rows = extractRows(root); if (rows == null || rows.isEmpty()) { emptyRegions++; continue; @@ -89,12 +103,12 @@ private Map buildParams(String arcode) { return params; } + private JsonNode parse(String body) { + return body == null || body.isBlank() ? null : xmlResponseParser.parse(body); + } + /** 응답은 XML 이고 항목이 response/item 으로 온다. */ - private JsonNode extractRows(String body) { - if (body == null || body.isBlank()) { - return null; - } - JsonNode root = xmlResponseParser.parse(body); + private JsonNode extractRows(JsonNode root) { if (root == null) { return null; } diff --git a/src/test/java/com/carecode/core/client/sync/ChildcareApiStatusTest.java b/src/test/java/com/carecode/core/client/sync/ChildcareApiStatusTest.java new file mode 100644 index 00000000..daee3918 --- /dev/null +++ b/src/test/java/com/carecode/core/client/sync/ChildcareApiStatusTest.java @@ -0,0 +1,81 @@ +package com.carecode.core.client.sync; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** 명세서 v1.0 의 응답 코드 기준. */ +@DisplayName("보육통합정보 응답 코드") +class ChildcareApiStatusTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + @DisplayName("정상 응답은 코드가 없다") + void okWhenNoErrorCode() { + assertThat(ChildcareApiStatus.of(node("{\"item\":[]}"))).isEqualTo(ChildcareApiStatus.OK); + assertThat(ChildcareApiStatus.of(null)).isEqualTo(ChildcareApiStatus.OK); + } + + @Test + @DisplayName("검색결과 없음은 정상이라 계속 진행한다") + void noResultIsNotFatal() { + ChildcareApiStatus status = ChildcareApiStatus.of( + node("{\"errcode\":\"INFO-200\",\"errmsg\":\"검색결과가 없습니다.\"}")); + + assertThat(status).isEqualTo(ChildcareApiStatus.NO_RESULT); + assertThat(status.isFatal()).isFalse(); + } + + @Test + @DisplayName("일 요청 한도 초과는 즉시 중단해야 한다") + void quotaExceededIsFatal() { + // 이걸 빈 응답으로 넘기면 남은 지역을 헛돌며 0건으로 끝난다 + ChildcareApiStatus status = ChildcareApiStatus.of( + node("{\"errcode\":\"INFO-300\",\"errmsg\":\"일 요청 건수를 초과하였습니다.\"}")); + + assertThat(status).isEqualTo(ChildcareApiStatus.QUOTA_EXCEEDED); + assertThat(status.isFatal()).isTrue(); + } + + @Test + @DisplayName("인증키 무효·만료는 즉시 중단해야 한다") + void keyProblemsAreFatal() { + assertThat(ChildcareApiStatus.of(node("{\"errcode\":\"INFO-100\"}")).isFatal()).isTrue(); + assertThat(ChildcareApiStatus.of(node("{\"errcode\":\"INFO-400\"}")).isFatal()).isTrue(); + } + + @Test + @DisplayName("파라미터 누락·서버 오류는 해당 지역만 건너뛴다") + void requestErrorsAreNotFatal() { + assertThat(ChildcareApiStatus.of(node("{\"errcode\":\"ERROR-100\"}")).isFatal()).isFalse(); + assertThat(ChildcareApiStatus.of(node("{\"errcode\":\"ERROR-200\"}")).isFatal()).isFalse(); + } + + @Test + @DisplayName("모르는 코드는 서버 오류로 본다") + void unknownCodeTreatedAsServerError() { + assertThat(ChildcareApiStatus.of(node("{\"errcode\":\"XXX-999\"}"))) + .isEqualTo(ChildcareApiStatus.SERVER_ERROR); + } + + @Test + @DisplayName("중단 사유에 API 메시지를 담는다") + void describesWithApiMessage() { + JsonNode root = node("{\"errcode\":\"INFO-300\",\"errmsg\":\"일 요청 건수를 초과하였습니다.\"}"); + + assertThat(ChildcareApiStatus.of(root).describe(root)) + .contains("INFO-300").contains("일 요청 건수"); + } + + private JsonNode node(String json) { + try { + return objectMapper.readTree(json); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +} From 00cb0bae14ca6dd5427a14c3290e65131c5ed310 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 11:30:14 +0900 Subject: [PATCH 27/68] =?UTF-8?q?FEAT=20:=20=EC=A3=BC=EC=86=8C=20=EC=A7=80?= =?UTF-8?q?=EC=98=A4=EC=BD=94=EB=94=A9=EC=9C=BC=EB=A1=9C=20=EC=96=B4?= =?UTF-8?q?=EB=A6=B0=EC=9D=B4=EC=A7=91=20=EC=A2=8C=ED=91=9C=20=EB=B3=B4?= =?UTF-8?q?=EC=A0=95=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../geocoding/FacilityGeocodingService.java | 96 +++++++++++++++ .../com/carecode/core/geocoding/Geocoder.java | 25 ++++ .../core/geocoding/KakaoGeocoder.java | 94 +++++++++++++++ .../scheduler/PublicDataSyncScheduler.java | 9 ++ .../controller/AdminPublicDataController.java | 14 +++ .../repository/CareFacilityRepository.java | 11 ++ src/main/resources/application.yml | 11 ++ .../FacilityGeocodingServiceTest.java | 111 +++++++++++++++++ .../core/geocoding/KakaoGeocoderTest.java | 113 ++++++++++++++++++ 9 files changed, 484 insertions(+) create mode 100644 src/main/java/com/carecode/core/geocoding/FacilityGeocodingService.java create mode 100644 src/main/java/com/carecode/core/geocoding/Geocoder.java create mode 100644 src/main/java/com/carecode/core/geocoding/KakaoGeocoder.java create mode 100644 src/test/java/com/carecode/core/geocoding/FacilityGeocodingServiceTest.java create mode 100644 src/test/java/com/carecode/core/geocoding/KakaoGeocoderTest.java diff --git a/src/main/java/com/carecode/core/geocoding/FacilityGeocodingService.java b/src/main/java/com/carecode/core/geocoding/FacilityGeocodingService.java new file mode 100644 index 00000000..284cbeb2 --- /dev/null +++ b/src/main/java/com/carecode/core/geocoding/FacilityGeocodingService.java @@ -0,0 +1,96 @@ +package com.carecode.core.geocoding; + +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 좌표가 없는 시설의 주소를 좌표로 채운다. + * 동기화 중에 인라인으로 돌리면 수집이 느려지고 외부 API 한도에 걸리므로 배치로 분리한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class FacilityGeocodingService { + + /** 한 번 실행에서 처리할 최대 건수. 외부 API 일일 한도를 넘지 않도록 나눠 돌린다. */ + @Value("${app.geocoding.batch-size:500}") + private int batchSize; + + /** 호출 간 간격(ms). 초당 요청 제한에 걸리지 않게 한다. */ + @Value("${app.geocoding.delay-ms:100}") + private long delayMs; + + private final CareFacilityRepository facilityRepository; + private final Geocoder geocoder; + + @Getter + public static class GeocodingResult { + private int resolved; + private int failed; + private long remaining; + private String skippedReason; + + @Override + public String toString() { + return skippedReason != null + ? "건너뜀 - " + skippedReason + : String.format("보정=%d, 실패=%d, 남은 대상=%d", resolved, failed, remaining); + } + } + + @Transactional + public GeocodingResult fillMissingCoordinates() { + GeocodingResult result = new GeocodingResult(); + + if (!geocoder.isAvailable()) { + result.skippedReason = "지오코딩 키 미설정"; + log.info("좌표 보정 건너뜀 - 키가 없습니다."); + return result; + } + + List targets = + facilityRepository.findMissingCoordinates(PageRequest.of(0, batchSize)); + if (targets.isEmpty()) { + log.debug("좌표 보정 대상이 없습니다."); + return result; + } + + for (CareFacility facility : targets) { + geocoder.geocode(facility.getAddress()).ifPresentOrElse( + coordinates -> { + facility.setLatitude(coordinates.latitude()); + facility.setLongitude(coordinates.longitude()); + facilityRepository.save(facility); + result.resolved++; + }, + () -> result.failed++); + + pause(); + } + + result.remaining = facilityRepository.countMissingCoordinates(); + log.info("좌표 보정 완료 - {}", result); + return result; + } + + /** 외부 API 는 초당 요청 제한이 있다. 한 건씩 간격을 둔다. */ + private void pause() { + if (delayMs <= 0) { + return; + } + try { + Thread.sleep(delayMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/main/java/com/carecode/core/geocoding/Geocoder.java b/src/main/java/com/carecode/core/geocoding/Geocoder.java new file mode 100644 index 00000000..269385cf --- /dev/null +++ b/src/main/java/com/carecode/core/geocoding/Geocoder.java @@ -0,0 +1,25 @@ +package com.carecode.core.geocoding; + +import java.util.Optional; + +/** 주소 → 좌표 변환. 공급자를 바꿔도 호출부가 흔들리지 않도록 분리한다. */ +public interface Geocoder { + + /** 좌표 한 쌍. */ + record Coordinates(double latitude, double longitude) { + + /** 한반도 범위를 벗어나면 잘못 변환된 값이다. */ + public boolean isWithinKorea() { + return latitude >= 33.0 && latitude <= 39.5 + && longitude >= 124.0 && longitude <= 132.0; + } + } + + String getProviderName(); + + /** 키가 없으면 비활성 상태로 두고 기능만 건너뛴다. */ + boolean isAvailable(); + + /** 변환에 실패하면 비어 있는 값을 돌려준다. 예외로 배치를 중단시키지 않는다. */ + Optional geocode(String address); +} diff --git a/src/main/java/com/carecode/core/geocoding/KakaoGeocoder.java b/src/main/java/com/carecode/core/geocoding/KakaoGeocoder.java new file mode 100644 index 00000000..acbd4160 --- /dev/null +++ b/src/main/java/com/carecode/core/geocoding/KakaoGeocoder.java @@ -0,0 +1,94 @@ +package com.carecode.core.geocoding; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +/** 카카오 로컬 API 주소 검색. */ +@Slf4j +@Component +public class KakaoGeocoder implements Geocoder { + + private static final String SEARCH_URL = "https://dapi.kakao.com/v2/local/search/address.json"; + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final String restApiKey; + + public KakaoGeocoder(RestTemplate restTemplate, + ObjectMapper objectMapper, + @Value("${app.geocoding.kakao.rest-api-key:}") String restApiKey) { + this.restTemplate = restTemplate; + this.objectMapper = objectMapper; + this.restApiKey = restApiKey; + if (restApiKey == null || restApiKey.isBlank()) { + log.info("카카오 지오코딩 키가 없어 좌표 보정을 건너뜁니다."); + } + } + + @Override + public String getProviderName() { + return "KAKAO"; + } + + @Override + public boolean isAvailable() { + return restApiKey != null && !restApiKey.isBlank(); + } + + @Override + public Optional geocode(String address) { + if (!isAvailable() || address == null || address.isBlank()) { + return Optional.empty(); + } + + try { + String url = UriComponentsBuilder.fromHttpUrl(SEARCH_URL) + .queryParam("query", address.trim()) + .queryParam("size", 1) + .encode(StandardCharsets.UTF_8) + .toUriString(); + + HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.AUTHORIZATION, "KakaoAK " + restApiKey); + + ResponseEntity response = restTemplate.exchange( + URI.create(url), org.springframework.http.HttpMethod.GET, + new HttpEntity<>(headers), String.class); + + return parse(response.getBody()); + } catch (Exception e) { + // 한 건 실패가 배치를 멈추면 안 된다. + log.debug("지오코딩 실패 - address={}, 사유={}", address, e.getMessage()); + return Optional.empty(); + } + } + + /** 응답의 x 가 경도, y 가 위도다. 뒤집으면 지도에서 엉뚱한 곳이 나온다. */ + private Optional parse(String body) throws Exception { + if (body == null || body.isBlank()) { + return Optional.empty(); + } + JsonNode documents = objectMapper.readTree(body).path("documents"); + if (!documents.isArray() || documents.isEmpty()) { + return Optional.empty(); + } + JsonNode first = documents.get(0); + double lng = first.path("x").asDouble(0); + double lat = first.path("y").asDouble(0); + + Coordinates coordinates = new Coordinates(lat, lng); + return coordinates.isWithinKorea() ? Optional.of(coordinates) : Optional.empty(); + } +} diff --git a/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java index b645f486..29518d1a 100644 --- a/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java +++ b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java @@ -5,6 +5,7 @@ import com.carecode.core.client.sync.NationwideChildcareFacilitySyncService; import com.carecode.core.client.sync.PediatricHospitalSyncService; import com.carecode.core.client.sync.SyncResult; +import com.carecode.core.geocoding.FacilityGeocodingService; import com.carecode.core.ops.OperationalAlerter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,6 +22,7 @@ public class PublicDataSyncScheduler { private final KindergartenSyncService kindergartenSyncService; private final GovernmentBenefitSyncService benefitSyncService; private final PediatricHospitalSyncService hospitalSyncService; + private final FacilityGeocodingService geocodingService; private final OperationalAlerter alerter; /** 전국 어린이집 동기화. */ @@ -51,6 +53,13 @@ public void syncPediatricHospitals() { logResult("소아청소년과 병원", result); } + /** 좌표 보정. 동기화가 끝난 뒤 돌아야 새로 들어온 시설이 대상에 포함된다. */ + @Scheduled(cron = "${app.scheduler.public-data.geocoding-cron:0 0 5 * * *}", zone = "Asia/Seoul") + public void fillMissingCoordinates() { + var result = geocodingService.fillMissingCoordinates(); + log.info("시설 좌표 보정 - {}", result); + } + private void logResult(String label, SyncResult result) { if (!result.isCompleted()) { log.warn("{} 동기화 미완료 - {}", label, result); diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java b/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java index f2ed8dd0..20797d3e 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java @@ -5,6 +5,7 @@ import com.carecode.core.client.sync.NationwideChildcareFacilitySyncService; import com.carecode.core.client.sync.PediatricHospitalSyncService; import com.carecode.core.client.sync.SyncResult; +import com.carecode.core.geocoding.FacilityGeocodingService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -27,6 +28,7 @@ public class AdminPublicDataController { private final KindergartenSyncService kindergartenSyncService; private final GovernmentBenefitSyncService benefitSyncService; private final PediatricHospitalSyncService hospitalSyncService; + private final FacilityGeocodingService geocodingService; @PostMapping("/facilities/sync") @Operation(summary = "전국 어린이집 동기화", description = "시설 코드 기준으로 갱신") @@ -52,6 +54,18 @@ public ResponseEntity> syncHospitals() { return ResponseEntity.ok(toResponse(hospitalSyncService.sync())); } + @PostMapping("/facilities/geocode") + @Operation(summary = "시설 좌표 보정", description = "좌표 없는 시설의 주소를 좌표로 변환") + public ResponseEntity> geocode() { + var result = geocodingService.fillMissingCoordinates(); + Map body = new LinkedHashMap<>(); + body.put("resolved", result.getResolved()); + body.put("failed", result.getFailed()); + body.put("remaining", result.getRemaining()); + body.put("skippedReason", result.getSkippedReason()); + return ResponseEntity.ok(body); + } + private Map toResponse(SyncResult result) { Map body = new LinkedHashMap<>(); body.put("provider", result.getProvider()); diff --git a/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java b/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java index 77d4d720..69cecbf7 100644 --- a/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java +++ b/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java @@ -136,6 +136,17 @@ List findWithinBoundingBox(@Param("latitude") double latitude, @Param("minLng") double minLng, @Param("maxLng") double maxLng); + /** 좌표가 없어 반경 검색에 잡히지 않는 시설. 지오코딩 대상이다. */ + @Query("SELECT cf FROM CareFacility cf WHERE cf.isActive = true " + + "AND (cf.latitude IS NULL OR cf.longitude IS NULL) " + + "AND cf.address IS NOT NULL AND cf.address <> ''") + List findMissingCoordinates(Pageable pageable); + + @Query("SELECT COUNT(cf) FROM CareFacility cf WHERE cf.isActive = true " + + "AND (cf.latitude IS NULL OR cf.longitude IS NULL) " + + "AND cf.address IS NOT NULL AND cf.address <> ''") + long countMissingCoordinates(); + /** 전문 검색. LIKE '%키워드%' 와 달리 인덱스를 타고 관련도 순으로 정렬된다. */ @Query(value = "SELECT * FROM TBL_CARE_FACILITIES " + "WHERE IS_ACTIVE = true " diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8e0799d8..f18f0568 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -115,6 +115,15 @@ app: secure: ${REFRESH_COOKIE_SECURE:true} same-site: ${REFRESH_COOKIE_SAME_SITE:None} max-age-days: ${REFRESH_COOKIE_MAX_AGE_DAYS:14} + geocoding: + # 어린이집 API 는 좌표를 주지 않아 주소로 보정한다. 키가 없으면 보정을 건너뛴다 + kakao: + # 카카오는 OAuth client_id 가 곧 REST API 키다. 별도 발급 없이 재사용한다. + # 단, 개발자센터에서 해당 앱의 "카카오맵" 사용 설정이 켜져 있어야 한다. + rest-api-key: ${KAKAO_REST_API_KEY:${KAKAO_CLIENT_ID:}} + batch-size: ${GEOCODING_BATCH_SIZE:500} + delay-ms: ${GEOCODING_DELAY_MS:100} + ops: # 동기화 실패·처리되지 않은 예외를 알린다. 비워두면 로그만 남는다 slack-webhook-url: ${OPS_SLACK_WEBHOOK_URL:} @@ -159,6 +168,8 @@ app: kindergarten-cron: ${PUBLIC_DATA_KINDERGARTEN_CRON:0 0 4 * * MON} benefit-cron: ${PUBLIC_DATA_BENEFIT_CRON:0 30 3 * * *} hospital-cron: ${PUBLIC_DATA_HOSPITAL_CRON:0 0 3 * * TUE} + # 동기화가 끝난 뒤 돌아야 새로 들어온 시설이 대상에 포함된다 + geocoding-cron: ${PUBLIC_DATA_GEOCODING_CRON:0 0 5 * * *} jwt: secret: ${JWT_SECRET} diff --git a/src/test/java/com/carecode/core/geocoding/FacilityGeocodingServiceTest.java b/src/test/java/com/carecode/core/geocoding/FacilityGeocodingServiceTest.java new file mode 100644 index 00000000..d32a4c9e --- /dev/null +++ b/src/test/java/com/carecode/core/geocoding/FacilityGeocodingServiceTest.java @@ -0,0 +1,111 @@ +package com.carecode.core.geocoding; + +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("시설 좌표 보정") +class FacilityGeocodingServiceTest { + + private CareFacilityRepository repository; + private Geocoder geocoder; + private FacilityGeocodingService service; + + @BeforeEach + void setUp() { + repository = mock(CareFacilityRepository.class); + geocoder = mock(Geocoder.class); + when(geocoder.isAvailable()).thenReturn(true); + + service = new FacilityGeocodingService(repository, geocoder); + ReflectionTestUtils.setField(service, "batchSize", 100); + // 테스트에서 대기하지 않도록 간격을 0 으로 둔다 + ReflectionTestUtils.setField(service, "delayMs", 0L); + } + + @Test + @DisplayName("키가 없으면 조회조차 하지 않는다") + void skipsWhenGeocoderUnavailable() { + when(geocoder.isAvailable()).thenReturn(false); + + var result = service.fillMissingCoordinates(); + + assertThat(result.getSkippedReason()).contains("키 미설정"); + verify(repository, never()).findMissingCoordinates(any(Pageable.class)); + } + + @Test + @DisplayName("변환된 좌표를 저장한다") + void savesResolvedCoordinates() { + CareFacility facility = facility("서울특별시 종로구 자하문로 69"); + when(repository.findMissingCoordinates(any(Pageable.class))).thenReturn(List.of(facility)); + when(geocoder.geocode(anyString())) + .thenReturn(Optional.of(new Geocoder.Coordinates(37.5806, 126.9662))); + when(repository.countMissingCoordinates()).thenReturn(0L); + + var result = service.fillMissingCoordinates(); + + assertThat(result.getResolved()).isEqualTo(1); + assertThat(facility.getLatitude()).isEqualTo(37.5806); + assertThat(facility.getLongitude()).isEqualTo(126.9662); + verify(repository).save(facility); + } + + @Test + @DisplayName("변환 실패는 세되 다음 건을 계속 처리한다") + void continuesAfterFailure() { + List targets = List.of(facility("주소1"), facility("주소2"), facility("주소3")); + when(repository.findMissingCoordinates(any(Pageable.class))).thenReturn(targets); + when(geocoder.geocode("주소1")).thenReturn(Optional.empty()); + when(geocoder.geocode("주소2")) + .thenReturn(Optional.of(new Geocoder.Coordinates(37.5, 127.0))); + when(geocoder.geocode("주소3")).thenReturn(Optional.empty()); + when(repository.countMissingCoordinates()).thenReturn(2L); + + var result = service.fillMissingCoordinates(); + + assertThat(result.getResolved()).isEqualTo(1); + assertThat(result.getFailed()).isEqualTo(2); + assertThat(result.getRemaining()).isEqualTo(2); + } + + @Test + @DisplayName("대상이 없으면 조용히 끝낸다") + void doesNothingWhenNoTargets() { + when(repository.findMissingCoordinates(any(Pageable.class))).thenReturn(List.of()); + + var result = service.fillMissingCoordinates(); + + assertThat(result.getResolved()).isZero(); + verify(repository, never()).save(any()); + } + + @Test + @DisplayName("한반도 범위 판정이 동작한다") + void validatesKoreanBounds() { + assertThat(new Geocoder.Coordinates(37.5, 127.0).isWithinKorea()).isTrue(); + assertThat(new Geocoder.Coordinates(33.2, 126.5).isWithinKorea()).isTrue(); // 제주 + assertThat(new Geocoder.Coordinates(40.7, -74.0).isWithinKorea()).isFalse(); // 뉴욕 + // 위경도가 뒤집힌 경우 + assertThat(new Geocoder.Coordinates(127.0, 37.5).isWithinKorea()).isFalse(); + } + + private CareFacility facility(String address) { + return CareFacility.builder().name("테스트시설").address(address).isActive(true).build(); + } +} diff --git a/src/test/java/com/carecode/core/geocoding/KakaoGeocoderTest.java b/src/test/java/com/carecode/core/geocoding/KakaoGeocoderTest.java new file mode 100644 index 00000000..12480319 --- /dev/null +++ b/src/test/java/com/carecode/core/geocoding/KakaoGeocoderTest.java @@ -0,0 +1,113 @@ +package com.carecode.core.geocoding; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.ExpectedCount.never; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +@DisplayName("카카오 지오코딩") +class KakaoGeocoderTest { + + private static final String KEY = "testRestApiKey"; + + /** 카카오 응답은 x 가 경도, y 가 위도다. */ + private static final String RESPONSE = """ + {"documents":[{"address_name":"서울 종로구 자하문로 69","x":"126.9662","y":"37.5806"}]} + """; + + @Test + @DisplayName("키가 없으면 비활성 상태로 아무것도 호출하지 않는다") + void inactiveWithoutKey() { + RestTemplate rest = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(rest).build(); + KakaoGeocoder geocoder = new KakaoGeocoder(rest, new ObjectMapper(), ""); + + server.expect(never(), request -> { }); + + assertThat(geocoder.isAvailable()).isFalse(); + assertThat(geocoder.geocode("서울시청")).isEmpty(); + server.verify(); + } + + @Test + @DisplayName("x 를 경도, y 를 위도로 읽는다") + void mapsXToLongitudeAndYToLatitude() { + RestTemplate rest = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(rest).build(); + KakaoGeocoder geocoder = new KakaoGeocoder(rest, new ObjectMapper(), KEY); + + server.expect(header("Authorization", "KakaoAK " + KEY)) + .andRespond(withSuccess(RESPONSE, MediaType.APPLICATION_JSON)); + + Optional result = geocoder.geocode("서울특별시 종로구 자하문로 69"); + + assertThat(result).isPresent(); + // 뒤집히면 위도 126 이 되어 지도에서 엉뚱한 곳이 나온다 + assertThat(result.get().latitude()).isEqualTo(37.5806); + assertThat(result.get().longitude()).isEqualTo(126.9662); + server.verify(); + } + + @Test + @DisplayName("검색 결과가 없으면 비어 있는 값을 준다") + void emptyWhenNoDocuments() { + RestTemplate rest = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(rest).build(); + KakaoGeocoder geocoder = new KakaoGeocoder(rest, new ObjectMapper(), KEY); + + server.expect(request -> { }) + .andRespond(withSuccess("{\"documents\":[]}", MediaType.APPLICATION_JSON)); + + assertThat(geocoder.geocode("존재하지 않는 주소")).isEmpty(); + } + + @Test + @DisplayName("호출이 실패해도 예외를 던지지 않는다") + void swallowsFailure() { + RestTemplate rest = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(rest).build(); + KakaoGeocoder geocoder = new KakaoGeocoder(rest, new ObjectMapper(), KEY); + + server.expect(request -> { }).andRespond(withServerError()); + + // 배치가 한 건 때문에 멈추면 안 된다 + assertThat(geocoder.geocode("서울시청")).isEmpty(); + } + + @Test + @DisplayName("한반도 밖 좌표는 잘못된 변환으로 보고 버린다") + void rejectsCoordinatesOutsideKorea() { + RestTemplate rest = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(rest).build(); + KakaoGeocoder geocoder = new KakaoGeocoder(rest, new ObjectMapper(), KEY); + + server.expect(request -> { }).andRespond(withSuccess( + "{\"documents\":[{\"x\":\"-74.0060\",\"y\":\"40.7128\"}]}", MediaType.APPLICATION_JSON)); + + assertThat(geocoder.geocode("New York")).isEmpty(); + } + + @Test + @DisplayName("빈 주소는 호출하지 않는다") + void skipsBlankAddress() { + RestTemplate rest = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(rest).build(); + KakaoGeocoder geocoder = new KakaoGeocoder(rest, new ObjectMapper(), KEY); + + server.expect(never(), request -> { }); + + assertThat(geocoder.geocode(null)).isEmpty(); + assertThat(geocoder.geocode(" ")).isEmpty(); + server.verify(); + } +} From b62fddc09f0a28cf3ebd960eca1d69ca574e3b08 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 11:52:30 +0900 Subject: [PATCH 28/68] =?UTF-8?q?FIX=20:=20=EB=8F=99=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=EC=9D=98=20=EC=A7=80=EA=B8=89=EC=9C=A0?= =?UTF-8?q?=ED=98=95=20=EB=88=84=EB=9D=BD=20=EB=B0=8F=20=EA=B8=88=EC=95=A1?= =?UTF-8?q?=20=EB=AF=B8=EC=83=81=20=EC=A0=95=EC=B1=85=20=EC=86=8C=EC=8B=A4?= =?UTF-8?q?=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/benefit/BenefitPaymentType.java | 4 ++- .../core/client/sync/PolicyUpsertService.java | 3 ++ .../dto/response/HospitalInfoResponse.java | 9 ++++++ .../domain/health/mapper/HospitalMapper.java | 1 + .../dto/response/RegionalBenefitResponse.java | 3 ++ .../RegionalBenefitComparisonService.java | 16 +++++++--- .../RegionalBenefitComparisonServiceTest.java | 29 +++++++++++++++++++ 7 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/carecode/core/benefit/BenefitPaymentType.java b/src/main/java/com/carecode/core/benefit/BenefitPaymentType.java index 8fe62156..11b157d0 100644 --- a/src/main/java/com/carecode/core/benefit/BenefitPaymentType.java +++ b/src/main/java/com/carecode/core/benefit/BenefitPaymentType.java @@ -19,8 +19,10 @@ public enum BenefitPaymentType { private static final List MONTHLY_MARKERS = List.of("월지급", "월지원", "월급여", "매월", "월 지급"); private static final List ONE_TIME_MARKERS = List.of("일시", "일회", "1회", "출산지원금", "축하금"); + // 융자·대출은 갚아야 하는 돈이라 수령액에 넣으면 안 된다. 감면액은 개인별로 달라 산정 불가. private static final List NON_CASH_MARKERS = List.of( - "서비스", "무료", "할인", "공급", "감면", "면제", "이용권", "제공"); + "서비스", "무료", "할인", "공급", "감면", "면제", "이용권", "제공", + "융자", "대출", "보증", "상담", "교육", "돌봄"); /** 지급 방식 문자열에서 판별한다. 공공데이터 표기가 제각각이라 부분 일치로 본다. */ public static BenefitPaymentType resolve(String benefitType) { diff --git a/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java b/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java index 517786e7..44df13f1 100644 --- a/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java +++ b/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java @@ -50,6 +50,9 @@ public boolean upsert(JsonNode row) { policy.setApplicationUrl(text(row, "상세조회URL", "servDtlLink", "DETAIL_URL")); policy.setContactInfo(text(row, "전화문의", "rprsCtadr", "CONTACT")); policy.setRequiredDocuments(text(row, "구비서류", "docCn")); + // 지원유형("현금"/"이용권"/"서비스(일자리)")은 명확하지만 금액은 자유 텍스트라 자동 추출하지 않는다. + // "국공립 100,000원, 사립 280,000원" 처럼 조건별로 갈리는 표기가 많아 틀린 금액이 확정치로 들어간다. + policy.setBenefitType(text(row, "지원유형", "benefitType")); applyAgeRange(policy, row); policy.setUpdatedAt(LocalDateTime.now()); diff --git a/src/main/java/com/carecode/domain/health/dto/response/HospitalInfoResponse.java b/src/main/java/com/carecode/domain/health/dto/response/HospitalInfoResponse.java index 7f4019fa..5b0f958e 100644 --- a/src/main/java/com/carecode/domain/health/dto/response/HospitalInfoResponse.java +++ b/src/main/java/com/carecode/domain/health/dto/response/HospitalInfoResponse.java @@ -15,7 +15,16 @@ public class HospitalInfoResponse { private Long id; private String name; + + /** 진료과목 (소아청소년과 등) */ private String type; + + /** + * 요양기관 종별 (의원/병원/종합병원/상급종합). + * 동네 소아과와 대학병원은 부모의 선택 기준이 달라 진료과목과 분리해 노출한다. + */ + private String grade; + private String address; private String phoneNumber; private Double latitude; diff --git a/src/main/java/com/carecode/domain/health/mapper/HospitalMapper.java b/src/main/java/com/carecode/domain/health/mapper/HospitalMapper.java index 1c3979fa..b009580c 100644 --- a/src/main/java/com/carecode/domain/health/mapper/HospitalMapper.java +++ b/src/main/java/com/carecode/domain/health/mapper/HospitalMapper.java @@ -13,6 +13,7 @@ public HospitalInfoResponse toResponse(Hospital hospital) { .id(hospital.getId()) .name(hospital.getName()) .type(hospital.getType()) + .grade(hospital.getGrade()) .address(hospital.getAddress()) .phoneNumber(hospital.getPhone()) .latitude(hospital.getLatitude()) diff --git a/src/main/java/com/carecode/domain/policy/dto/response/RegionalBenefitResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/RegionalBenefitResponse.java index 2905ae7a..919f4795 100644 --- a/src/main/java/com/carecode/domain/policy/dto/response/RegionalBenefitResponse.java +++ b/src/main/java/com/carecode/domain/policy/dto/response/RegionalBenefitResponse.java @@ -27,6 +27,9 @@ public class RegionalBenefitResponse { /** 금액이 수기 검증된 정책 수. */ private int verifiedPolicyCount; + /** 대상이지만 금액이 확인되지 않아 합계에서 빠진 정책 수. 0 이 아니면 총액은 과소 집계다. */ + private int unknownAmountCount; + /** VERIFIED(전부 검증) / PARTIAL(일부) / ESTIMATED(미검증). */ private String dataQuality; diff --git a/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java b/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java index 57c18b8b..9191ab8d 100644 --- a/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java +++ b/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java @@ -116,14 +116,16 @@ private boolean isIncomeConditional(Policy policy, User user) { } /** 지역 한 곳의 집계 중간 결과. */ - private record RegionSummary(long amount, int cashCount, int nonCashCount, int verifiedCount, + private record RegionSummary(long amount, int cashCount, int nonCashCount, + int verifiedCount, int unknownAmountCount, List contributions) { RegionSummary merge(RegionSummary other) { List merged = new ArrayList<>(contributions); merged.addAll(other.contributions); return new RegionSummary(amount + other.amount, cashCount + other.cashCount, - nonCashCount + other.nonCashCount, verifiedCount + other.verifiedCount, merged); + nonCashCount + other.nonCashCount, verifiedCount + other.verifiedCount, + unknownAmountCount + other.unknownAmountCount, merged); } /** 금액에 들어간 정책이 전부 검증됐을 때만 확정으로 표기한다. */ @@ -150,6 +152,7 @@ RegionalBenefitResponse toResponse(String region, long baseAmount) { .cashPolicyCount(cashCount) .nonCashPolicyCount(nonCashCount) .verifiedPolicyCount(verifiedCount) + .unknownAmountCount(unknownAmountCount) .dataQuality(quality()) .topContributors(top) .build(); @@ -161,6 +164,7 @@ private RegionSummary summarize(List policies, int ageMonths, int horizo int cash = 0; int nonCash = 0; int verified = 0; + int unknownAmount = 0; List contributions = new ArrayList<>(); for (Policy policy : policies) { @@ -173,7 +177,9 @@ private RegionSummary summarize(List policies, int ageMonths, int horizo continue; } if (!projection.isCash()) { - continue; // 금액이 확인되지 않은 정책은 합산하지 않는다 + // 합산은 못 하지만 "이 지역에 이런 혜택이 있다" 는 사실은 사라지면 안 된다. + unknownAmount++; + continue; } total += projection.amount(); cash++; @@ -186,7 +192,7 @@ private RegionSummary summarize(List policies, int ageMonths, int horizo .paymentType(projection.paymentType().name()) .build()); } - return new RegionSummary(total, cash, nonCash, verified, contributions); + return new RegionSummary(total, cash, nonCash, verified, unknownAmount, contributions); } private Child resolveChild(List children, Long childId) { @@ -239,6 +245,8 @@ private List buildDisclaimers(String baseRegion, long conditionalCount) + "소득을 입력하면 정확해집니다.", conditionalCount)); } notes.add("무료검진·서비스 등 금액으로 환산할 수 없는 혜택은 합산에서 제외했습니다."); + notes.add("공공데이터는 지원금액을 숫자로 제공하지 않아, 금액이 확인된 정책만 합산됩니다. " + + "unknownAmountCount 가 크면 실제 수령액은 표시된 금액보다 많습니다."); notes.add("지급 방식이 명시되지 않은 정책은 과대 계상을 피하기 위해 1회 지급으로 계산했습니다."); if (baseRegion == null) { notes.add("주소가 등록되지 않아 전국 공통 정책만을 기준으로 비교했습니다."); diff --git a/src/test/java/com/carecode/domain/policy/service/RegionalBenefitComparisonServiceTest.java b/src/test/java/com/carecode/domain/policy/service/RegionalBenefitComparisonServiceTest.java index 7a774e53..f4688c61 100644 --- a/src/test/java/com/carecode/domain/policy/service/RegionalBenefitComparisonServiceTest.java +++ b/src/test/java/com/carecode/domain/policy/service/RegionalBenefitComparisonServiceTest.java @@ -127,6 +127,35 @@ void countsNonCashSeparately() { assertThat(a.getNonCashPolicyCount()).isEqualTo(1); } + @Test + @DisplayName("금액이 없는 정책도 사라지지 않고 건수로 남는다") + void countsPoliciesWithUnknownAmount() { + givenChildAgedMonths(0); + givenPolicies( + policy("금액확인됨", "A시", 0, 11, 200000, "일시지급"), + // 공공데이터는 금액을 숫자로 주지 않아 대부분 여기 해당한다 + policy("금액미상", "A시", 0, 11, null, "현금")); + givenRegions("A시"); + + RegionalBenefitResponse a = byRegion(service.compare(null, 1, 10), "A시"); + + assertThat(a.getTotalAmount()).isEqualTo(200_000); + assertThat(a.getCashPolicyCount()).isEqualTo(1); + // 이게 없으면 "이 지역에 혜택이 하나뿐" 으로 오해하게 된다 + assertThat(a.getUnknownAmountCount()).isEqualTo(1); + } + + @Test + @DisplayName("금액 미상이 있으면 과소 집계임을 알린다") + void warnsWhenAmountsAreIncomplete() { + givenChildAgedMonths(0); + givenPolicies(policy("금액미상", "A시", 0, 11, null, "현금")); + givenRegions("A시"); + + assertThat(service.compare(null, 1, 10).getDisclaimers()) + .anyMatch(d -> d.contains("금액이 확인된 정책만 합산")); + } + @Test @DisplayName("금액 기여가 큰 정책을 근거로 노출한다") void exposesTopContributors() { From 983365b85556c8e749f4233c04710c74c9c29c19 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 12:20:26 +0900 Subject: [PATCH 29/68] =?UTF-8?q?FEAT=20:=20=EC=A0=95=EC=B1=85=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EA=B0=90=EC=A7=80=20=EB=B0=8F=20=EC=A7=80=EC=97=AD?= =?UTF-8?q?=EB=B3=84=20=EC=95=8C=EB=A6=BC=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/client/sync/PolicyUpsertService.java | 14 +- .../domain/policy/entity/PolicyChange.java | 81 ++++++++++ .../repository/PolicyChangeRepository.java | 23 +++ .../policy/service/PolicyChangeDetector.java | 96 ++++++++++++ .../policy/service/PolicyChangeNotifier.java | 142 ++++++++++++++++++ .../db/migration/V11__policy_change_log.sql | 18 +++ .../service/PolicyChangeDetectorTest.java | 132 ++++++++++++++++ 7 files changed, 505 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/carecode/domain/policy/entity/PolicyChange.java create mode 100644 src/main/java/com/carecode/domain/policy/repository/PolicyChangeRepository.java create mode 100644 src/main/java/com/carecode/domain/policy/service/PolicyChangeDetector.java create mode 100644 src/main/java/com/carecode/domain/policy/service/PolicyChangeNotifier.java create mode 100644 src/main/resources/db/migration/V11__policy_change_log.sql create mode 100644 src/test/java/com/carecode/domain/policy/service/PolicyChangeDetectorTest.java diff --git a/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java b/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java index 44df13f1..f0ee945e 100644 --- a/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java +++ b/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java @@ -3,6 +3,7 @@ import com.carecode.core.util.AgeRangeParser; import com.carecode.domain.policy.entity.Policy; import com.carecode.domain.policy.repository.PolicyRepository; +import com.carecode.domain.policy.service.PolicyChangeDetector; import com.fasterxml.jackson.databind.JsonNode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -23,6 +24,7 @@ public class PolicyUpsertService { public static final String EXTERNAL_CODE_PREFIX = "GOV-"; private final PolicyRepository policyRepository; + private final PolicyChangeDetector changeDetector; /** 서비스 ID 기준 upsert. */ @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -36,6 +38,8 @@ public boolean upsert(JsonNode row) { String policyCode = EXTERNAL_CODE_PREFIX + serviceId; Policy policy = policyRepository.findByPolicyCode(policyCode).orElse(null); boolean isNew = policy == null; + // 필드를 덮어쓰기 전에 값을 떠 둔다. + PolicyChangeDetector.Before snapshot = isNew ? null : PolicyChangeDetector.Before.of(policy); if (isNew) { policy = new Policy(); policy.setPolicyCode(policyCode); @@ -56,7 +60,15 @@ public boolean upsert(JsonNode row) { applyAgeRange(policy, row); policy.setUpdatedAt(LocalDateTime.now()); - policyRepository.save(policy); + // 저장하면 이전 값이 사라진다. 비교는 그 전에 끝내야 한다. + PolicyChangeDetector.Before before = snapshot; + Policy saved = policyRepository.save(policy); + + if (isNew) { + changeDetector.recordCreated(saved); + } else { + changeDetector.recordUpdates(saved, before); + } return isNew; } diff --git a/src/main/java/com/carecode/domain/policy/entity/PolicyChange.java b/src/main/java/com/carecode/domain/policy/entity/PolicyChange.java new file mode 100644 index 00000000..6d988448 --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/entity/PolicyChange.java @@ -0,0 +1,81 @@ +package com.carecode.domain.policy.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** 정책이 어떻게 바뀌었는지. 값이 덮어써지기 전에 남겨야 알림을 만들 수 있다. */ +@Entity +@Table(name = "TBL_POLICY_CHANGE") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class PolicyChange { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ID") + private Long id; + + @Column(name = "POLICY_ID", nullable = false) + private Long policyId; + + @Enumerated(EnumType.STRING) + @Column(name = "CHANGE_TYPE", nullable = false, length = 30) + private ChangeType changeType; + + @Column(name = "FIELD_NAME", length = 50) + private String fieldName; + + @Column(name = "OLD_VALUE", length = 500) + private String oldValue; + + @Column(name = "NEW_VALUE", length = 500) + private String newValue; + + /** 알림 대상을 고를 때 정책을 다시 조회하지 않도록 중복 저장한다. */ + @Column(name = "TARGET_REGION", length = 200) + private String targetRegion; + + @Column(name = "DETECTED_AT", nullable = false) + private LocalDateTime detectedAt; + + @Column(name = "NOTIFIED", nullable = false) + @Builder.Default + private Boolean notified = false; + + /** 사용자에게 알릴 가치가 있는 변경만 정의한다. 오탈자 수정까지 알리면 알림이 소음이 된다. */ + public enum ChangeType { + CREATED("신규 지원금"), + AMOUNT_CHANGED("지원금액 변경"), + DEADLINE_CHANGED("신청기한 변경"), + AGE_RANGE_CHANGED("대상 연령 변경"); + + private final String displayName; + + ChangeType(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } + } + + @PrePersist + protected void onCreate() { + if (detectedAt == null) { + detectedAt = LocalDateTime.now(); + } + } + + public void markNotified() { + this.notified = true; + } +} diff --git a/src/main/java/com/carecode/domain/policy/repository/PolicyChangeRepository.java b/src/main/java/com/carecode/domain/policy/repository/PolicyChangeRepository.java new file mode 100644 index 00000000..d1e60eed --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/repository/PolicyChangeRepository.java @@ -0,0 +1,23 @@ +package com.carecode.domain.policy.repository; + +import com.carecode.domain.policy.entity.PolicyChange; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface PolicyChangeRepository extends JpaRepository { + + /** 아직 알리지 않은 변경. 오래된 것부터 처리한다. */ + @Query("SELECT c FROM PolicyChange c WHERE c.notified = false ORDER BY c.detectedAt ASC") + List findUnnotified(Pageable pageable); + + /** 최근 변경 내역. 사용자에게 "이번 달 달라진 지원금" 으로 보여준다. */ + @Query("SELECT c FROM PolicyChange c WHERE c.detectedAt >= :since ORDER BY c.detectedAt DESC") + List findRecent(@Param("since") LocalDateTime since, Pageable pageable); +} diff --git a/src/main/java/com/carecode/domain/policy/service/PolicyChangeDetector.java b/src/main/java/com/carecode/domain/policy/service/PolicyChangeDetector.java new file mode 100644 index 00000000..4b955a8c --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/service/PolicyChangeDetector.java @@ -0,0 +1,96 @@ +package com.carecode.domain.policy.service; + +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.entity.PolicyChange; +import com.carecode.domain.policy.repository.PolicyChangeRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * 동기화 때 덮어쓰기 전후를 비교해 변경을 기록한다. + * 알릴 가치가 있는 필드만 본다 — 설명 오탈자까지 알리면 알림이 소음이 된다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PolicyChangeDetector { + + private final PolicyChangeRepository changeRepository; + + /** 변경 전 상태 스냅샷. 엔티티는 곧 덮어써지므로 값만 복사해 둔다. */ + public record Before(Integer benefitAmount, LocalDate applicationEndDate, + Integer targetAgeMin, Integer targetAgeMax) { + + public static Before of(Policy policy) { + return new Before(policy.getBenefitAmount(), policy.getApplicationEndDate(), + policy.getTargetAgeMin(), policy.getTargetAgeMax()); + } + } + + /** 신규 정책. 해당 지역 사용자에게 알릴 가치가 가장 크다. */ + public void recordCreated(Policy policy) { + save(PolicyChange.builder() + .policyId(policy.getId()) + .changeType(PolicyChange.ChangeType.CREATED) + .newValue(policy.getTitle()) + .targetRegion(policy.getTargetRegion()) + .build()); + } + + public void recordUpdates(Policy policy, Before before) { + List changes = new ArrayList<>(); + + if (!Objects.equals(before.benefitAmount(), policy.getBenefitAmount()) + && policy.getBenefitAmount() != null) { + changes.add(build(policy, PolicyChange.ChangeType.AMOUNT_CHANGED, "benefitAmount", + text(before.benefitAmount()), text(policy.getBenefitAmount()))); + } + if (!Objects.equals(before.applicationEndDate(), policy.getApplicationEndDate())) { + changes.add(build(policy, PolicyChange.ChangeType.DEADLINE_CHANGED, "applicationEndDate", + text(before.applicationEndDate()), text(policy.getApplicationEndDate()))); + } + if (!Objects.equals(before.targetAgeMin(), policy.getTargetAgeMin()) + || !Objects.equals(before.targetAgeMax(), policy.getTargetAgeMax())) { + changes.add(build(policy, PolicyChange.ChangeType.AGE_RANGE_CHANGED, "targetAge", + range(before.targetAgeMin(), before.targetAgeMax()), + range(policy.getTargetAgeMin(), policy.getTargetAgeMax()))); + } + + changes.forEach(this::save); + } + + private PolicyChange build(Policy policy, PolicyChange.ChangeType type, + String field, String oldValue, String newValue) { + return PolicyChange.builder() + .policyId(policy.getId()) + .changeType(type) + .fieldName(field) + .oldValue(oldValue) + .newValue(newValue) + .targetRegion(policy.getTargetRegion()) + .build(); + } + + /** 변경 기록 실패가 동기화를 멈추면 안 된다. */ + private void save(PolicyChange change) { + try { + changeRepository.save(change); + } catch (Exception e) { + log.warn("정책 변경 기록 실패 - policyId={}, 사유={}", change.getPolicyId(), e.getMessage()); + } + } + + private String text(Object value) { + return value == null ? null : String.valueOf(value); + } + + private String range(Integer min, Integer max) { + return (min == null ? "-" : min) + "~" + (max == null ? "-" : max) + "개월"; + } +} diff --git a/src/main/java/com/carecode/domain/policy/service/PolicyChangeNotifier.java b/src/main/java/com/carecode/domain/policy/service/PolicyChangeNotifier.java new file mode 100644 index 00000000..9b06b9a3 --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/service/PolicyChangeNotifier.java @@ -0,0 +1,142 @@ +package com.carecode.domain.policy.service; + +import com.carecode.domain.notification.entity.Notification; +import com.carecode.domain.notification.repository.NotificationRepository; +import com.carecode.domain.notification.sender.NotificationDispatcher; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.entity.PolicyChange; +import com.carecode.domain.policy.repository.PolicyChangeRepository; +import com.carecode.domain.policy.repository.PolicyRepository; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.UserRepository; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 기록된 정책 변경을 해당 지역 사용자에게 알린다. + * 이 앱은 "한 번 보고 끝" 이 되기 쉬운데, 다시 열 이유를 만드는 유일한 경로다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PolicyChangeNotifier { + + /** 한 번에 처리할 변경 수. 동기화 직후 수천 건이 쌓여도 알림이 폭주하지 않게 한다. */ + @Value("${app.policy-change.batch-size:200}") + private int batchSize; + + /** 한 사용자에게 한 번에 보낼 최대 알림 수. 넘치면 묶어서 한 건으로 보낸다. */ + @Value("${app.policy-change.max-per-user:3}") + private int maxPerUser; + + private final PolicyChangeRepository changeRepository; + private final PolicyRepository policyRepository; + private final UserRepository userRepository; + private final NotificationRepository notificationRepository; + private final NotificationDispatcher dispatcher; + + @Getter + public static class NotifyResult { + private int changesProcessed; + private int notificationsSent; + + @Override + public String toString() { + return String.format("변경 %d건 처리, 알림 %d건 발송", changesProcessed, notificationsSent); + } + } + + @Transactional + public NotifyResult notifyPendingChanges() { + NotifyResult result = new NotifyResult(); + + List pending = changeRepository.findUnnotified(PageRequest.of(0, batchSize)); + if (pending.isEmpty()) { + return result; + } + + for (PolicyChange change : pending) { + try { + result.notificationsSent += notify(change); + } catch (Exception e) { + log.warn("정책 변경 알림 실패 - changeId={}, 사유={}", change.getId(), e.getMessage()); + } finally { + // 실패해도 표시해 둔다. 재시도로 같은 알림이 반복되는 편이 더 나쁘다. + change.markNotified(); + result.changesProcessed++; + } + } + + log.info("정책 변경 알림 - {}", result); + return result; + } + + private int notify(PolicyChange change) { + Policy policy = policyRepository.findById(change.getPolicyId()).orElse(null); + if (policy == null || !Boolean.TRUE.equals(policy.getIsActive())) { + return 0; + } + + List targets = findTargets(change); + String title = buildTitle(change, policy); + String message = buildMessage(change, policy); + + int sent = 0; + for (User user : targets) { + Notification notification = notificationRepository.save(Notification.builder() + .user(user) + .notificationType(Notification.NotificationType.POLICY) + .title(title) + .message(message) + .createdAt(LocalDateTime.now()) + .build()); + + dispatcher.dispatchAsync(notification); + sent++; + } + return sent; + } + + /** 전국 정책은 모두에게, 지역 정책은 그 지역 주민에게만 알린다. */ + private List findTargets(PolicyChange change) { + String region = change.getTargetRegion(); + List active = userRepository.findByIsActiveTrue(); + + if (region == null || region.isBlank() || region.contains("전국")) { + return active; + } + return active.stream() + .filter(u -> u.getAddress() != null && !u.getAddress().isBlank()) + // 주소는 "충청북도 청주시 ...", 정책 지역은 "충청북도 청주시" 처럼 표기가 달라 양방향으로 본다. + .filter(u -> u.getAddress().contains(region) || region.contains(u.getAddress())) + .toList(); + } + + private String buildTitle(PolicyChange change, Policy policy) { + return switch (change.getChangeType()) { + case CREATED -> "새로운 지원금: " + policy.getTitle(); + case AMOUNT_CHANGED -> "지원금액 변경: " + policy.getTitle(); + case DEADLINE_CHANGED -> "신청기한 변경: " + policy.getTitle(); + case AGE_RANGE_CHANGED -> "대상 연령 변경: " + policy.getTitle(); + }; + } + + private String buildMessage(PolicyChange change, Policy policy) { + if (change.getChangeType() == PolicyChange.ChangeType.CREATED) { + String region = policy.getTargetRegion() == null ? "" : policy.getTargetRegion() + " "; + return region + "지역에 새로운 지원금이 등록되었습니다. 대상 여부를 확인해 보세요."; + } + String from = change.getOldValue() == null ? "미상" : change.getOldValue(); + String to = change.getNewValue() == null ? "미상" : change.getNewValue(); + return String.format("%s이(가) %s → %s 로 변경되었습니다.", + change.getChangeType().getDisplayName(), from, to); + } +} diff --git a/src/main/resources/db/migration/V11__policy_change_log.sql b/src/main/resources/db/migration/V11__policy_change_log.sql new file mode 100644 index 00000000..f2c2179c --- /dev/null +++ b/src/main/resources/db/migration/V11__policy_change_log.sql @@ -0,0 +1,18 @@ +-- 정책 변경 이력. 동기화 때 값이 덮어써지면 "무엇이 바뀌었는지" 가 사라져 알림을 만들 수 없다 +CREATE TABLE TBL_POLICY_CHANGE ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + POLICY_ID BIGINT NOT NULL, + CHANGE_TYPE VARCHAR(30) NOT NULL COMMENT 'CREATED / AMOUNT_CHANGED / DEADLINE_CHANGED / TITLE_CHANGED', + FIELD_NAME VARCHAR(50) COMMENT '바뀐 필드', + OLD_VALUE VARCHAR(500), + NEW_VALUE VARCHAR(500), + TARGET_REGION VARCHAR(200) COMMENT '알림 대상을 고르기 위해 중복 저장한다', + DETECTED_AT DATETIME NOT NULL, + NOTIFIED BOOLEAN NOT NULL DEFAULT FALSE COMMENT '알림 발송 여부', + CONSTRAINT FK_POLICY_CHANGE_POLICY FOREIGN KEY (POLICY_ID) + REFERENCES TBL_POLICIES (ID) ON DELETE CASCADE +) COMMENT '정책 변경 이력'; + +-- 미발송 변경만 골라 알림을 보낸다 +CREATE INDEX IDX_POLICY_CHANGE_NOTIFIED ON TBL_POLICY_CHANGE (NOTIFIED, DETECTED_AT); +CREATE INDEX IDX_POLICY_CHANGE_POLICY ON TBL_POLICY_CHANGE (POLICY_ID, DETECTED_AT DESC); diff --git a/src/test/java/com/carecode/domain/policy/service/PolicyChangeDetectorTest.java b/src/test/java/com/carecode/domain/policy/service/PolicyChangeDetectorTest.java new file mode 100644 index 00000000..450fbcf8 --- /dev/null +++ b/src/test/java/com/carecode/domain/policy/service/PolicyChangeDetectorTest.java @@ -0,0 +1,132 @@ +package com.carecode.domain.policy.service; + +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.entity.PolicyChange; +import com.carecode.domain.policy.repository.PolicyChangeRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@DisplayName("정책 변경 감지") +class PolicyChangeDetectorTest { + + private PolicyChangeRepository repository; + private PolicyChangeDetector detector; + + @BeforeEach + void setUp() { + repository = mock(PolicyChangeRepository.class); + detector = new PolicyChangeDetector(repository); + } + + @Test + @DisplayName("신규 정책을 기록한다") + void recordsCreation() { + Policy policy = policy(100000, LocalDate.of(2026, 12, 31), 0, 11); + + detector.recordCreated(policy); + + PolicyChange saved = capture(); + assertThat(saved.getChangeType()).isEqualTo(PolicyChange.ChangeType.CREATED); + assertThat(saved.getTargetRegion()).isEqualTo("성남시"); + } + + @Test + @DisplayName("금액 변경을 기록한다") + void recordsAmountChange() { + Policy policy = policy(300000, LocalDate.of(2026, 12, 31), 0, 11); + var before = new PolicyChangeDetector.Before(100000, LocalDate.of(2026, 12, 31), 0, 11); + + detector.recordUpdates(policy, before); + + PolicyChange saved = capture(); + assertThat(saved.getChangeType()).isEqualTo(PolicyChange.ChangeType.AMOUNT_CHANGED); + assertThat(saved.getOldValue()).isEqualTo("100000"); + assertThat(saved.getNewValue()).isEqualTo("300000"); + } + + @Test + @DisplayName("신청기한 변경을 기록한다") + void recordsDeadlineChange() { + Policy policy = policy(100000, LocalDate.of(2027, 6, 30), 0, 11); + var before = new PolicyChangeDetector.Before(100000, LocalDate.of(2026, 12, 31), 0, 11); + + detector.recordUpdates(policy, before); + + assertThat(capture().getChangeType()).isEqualTo(PolicyChange.ChangeType.DEADLINE_CHANGED); + } + + @Test + @DisplayName("대상 연령 변경을 기록한다") + void recordsAgeRangeChange() { + Policy policy = policy(100000, LocalDate.of(2026, 12, 31), 0, 23); + var before = new PolicyChangeDetector.Before(100000, LocalDate.of(2026, 12, 31), 0, 11); + + detector.recordUpdates(policy, before); + + PolicyChange saved = capture(); + assertThat(saved.getChangeType()).isEqualTo(PolicyChange.ChangeType.AGE_RANGE_CHANGED); + assertThat(saved.getNewValue()).isEqualTo("0~23개월"); + } + + @Test + @DisplayName("바뀐 게 없으면 기록하지 않는다") + void recordsNothingWhenUnchanged() { + Policy policy = policy(100000, LocalDate.of(2026, 12, 31), 0, 11); + var before = new PolicyChangeDetector.Before(100000, LocalDate.of(2026, 12, 31), 0, 11); + + detector.recordUpdates(policy, before); + + verify(repository, never()).save(org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("금액이 null 로 바뀌는 것은 알리지 않는다") + void ignoresAmountBecomingNull() { + // 동기화가 금액을 못 읽은 경우까지 "금액 변경" 으로 알리면 소음이 된다 + Policy policy = policy(null, LocalDate.of(2026, 12, 31), 0, 11); + var before = new PolicyChangeDetector.Before(100000, LocalDate.of(2026, 12, 31), 0, 11); + + detector.recordUpdates(policy, before); + + verify(repository, never()).save(org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("기록 실패가 동기화를 멈추지 않는다") + void swallowsSaveFailure() { + org.mockito.Mockito.when(repository.save(org.mockito.ArgumentMatchers.any())) + .thenThrow(new RuntimeException("DB 오류")); + + // 예외가 밖으로 나오면 동기화 전체가 실패한다 + detector.recordCreated(policy(100000, null, 0, 11)); + } + + private PolicyChange capture() { + ArgumentCaptor captor = ArgumentCaptor.forClass(PolicyChange.class); + verify(repository, org.mockito.Mockito.atLeastOnce()).save(captor.capture()); + List all = captor.getAllValues(); + return all.get(all.size() - 1); + } + + private Policy policy(Integer amount, LocalDate endDate, Integer ageMin, Integer ageMax) { + Policy p = new Policy(); + p.setId(1L); + p.setTitle("테스트 지원금"); + p.setTargetRegion("성남시"); + p.setBenefitAmount(amount); + p.setApplicationEndDate(endDate); + p.setTargetAgeMin(ageMin); + p.setTargetAgeMax(ageMax); + return p; + } +} From 2a0b159cd193232a977669c9606a84ea0b9cef5f Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 12:20:26 +0900 Subject: [PATCH 30/68] =?UTF-8?q?FEAT=20:=20=EC=A7=80=EC=9B=90=EA=B8=88=20?= =?UTF-8?q?=EC=8B=A4=EC=88=98=EB=A0=B9=EC=95=A1=20=EC=A0=9C=EB=B3=B4=20?= =?UTF-8?q?=EB=B0=8F=20=ED=95=A9=EC=9D=98=20=EA=B8=B0=EB=B0=98=20=EA=B8=88?= =?UTF-8?q?=EC=95=A1=20=ED=99=95=EC=A0=95=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../request/BenefitAmountReportRequest.java | 34 +++++ .../BenefitAmountConsensusResponse.java | 36 +++++ .../policy/entity/BenefitAmountReport.java | 71 +++++++++ .../BenefitAmountReportRepository.java | 24 +++ .../service/BenefitAmountReportService.java | 117 +++++++++++++++ .../migration/V12__benefit_amount_report.sql | 20 +++ .../BenefitAmountReportServiceTest.java | 140 ++++++++++++++++++ 7 files changed, 442 insertions(+) create mode 100644 src/main/java/com/carecode/domain/policy/dto/request/BenefitAmountReportRequest.java create mode 100644 src/main/java/com/carecode/domain/policy/dto/response/BenefitAmountConsensusResponse.java create mode 100644 src/main/java/com/carecode/domain/policy/entity/BenefitAmountReport.java create mode 100644 src/main/java/com/carecode/domain/policy/repository/BenefitAmountReportRepository.java create mode 100644 src/main/java/com/carecode/domain/policy/service/BenefitAmountReportService.java create mode 100644 src/main/resources/db/migration/V12__benefit_amount_report.sql create mode 100644 src/test/java/com/carecode/domain/policy/service/BenefitAmountReportServiceTest.java diff --git a/src/main/java/com/carecode/domain/policy/dto/request/BenefitAmountReportRequest.java b/src/main/java/com/carecode/domain/policy/dto/request/BenefitAmountReportRequest.java new file mode 100644 index 00000000..a25f75e1 --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/dto/request/BenefitAmountReportRequest.java @@ -0,0 +1,34 @@ +package com.carecode.domain.policy.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; + +/** 실제로 받은 금액 제보. */ +@Getter +@Setter +@NoArgsConstructor +public class BenefitAmountReportRequest { + + @NotNull(message = "수령액은 필수입니다") + @Min(value = 0, message = "수령액은 0 이상이어야 합니다") + // 육아 지원금에 1억을 넘는 항목은 없다. 자릿수 오입력을 여기서 막는다. + @Max(value = 100_000_000, message = "수령액이 너무 큽니다. 자릿수를 확인해 주세요") + private Integer amount; + + @NotNull(message = "지급 방식은 필수입니다") + @Pattern(regexp = "MONTHLY|ONE_TIME", message = "지급 방식은 MONTHLY 또는 ONE_TIME 이어야 합니다") + private String paymentType; + + private LocalDate receivedAt; + + @Size(max = 300, message = "메모는 300자 이하여야 합니다") + private String note; +} diff --git a/src/main/java/com/carecode/domain/policy/dto/response/BenefitAmountConsensusResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/BenefitAmountConsensusResponse.java new file mode 100644 index 00000000..04376370 --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/dto/response/BenefitAmountConsensusResponse.java @@ -0,0 +1,36 @@ +package com.carecode.domain.policy.dto.response; + +import lombok.Builder; +import lombok.Getter; + +/** 제보 합의 현황. 몇 명이 더 필요한지 보여줘야 참여가 이어진다. */ +@Getter +@Builder +public class BenefitAmountConsensusResponse { + + private Long policyId; + private String title; + + /** 이 정책에 들어온 제보 수. */ + private long totalReports; + + /** 확정에 필요한 동일 응답 수. */ + private int consensusThreshold; + + /** 가장 많이 나온 값에 동의한 사람 수. */ + private long agreedCount; + + private Integer consensusAmount; + private String consensusPaymentType; + + /** 확정 여부. true 면 정책 금액이 채워졌다. */ + private boolean confirmed; + + /** 현재 정책에 저장된 금액. 확정 전이면 null 일 수 있다. */ + private Integer currentAmount; + + /** 확정까지 남은 제보 수. */ + public long getRemainingForConsensus() { + return Math.max(0, consensusThreshold - agreedCount); + } +} diff --git a/src/main/java/com/carecode/domain/policy/entity/BenefitAmountReport.java b/src/main/java/com/carecode/domain/policy/entity/BenefitAmountReport.java new file mode 100644 index 00000000..a7f213f5 --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/entity/BenefitAmountReport.java @@ -0,0 +1,71 @@ +package com.carecode.domain.policy.entity; + +import com.carecode.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** 실제로 받아본 사람이 알려주는 금액. 공공데이터가 채우지 못하는 공백을 메운다. */ +@Entity +@Table(name = "TBL_BENEFIT_AMOUNT_REPORT", + uniqueConstraints = @UniqueConstraint(name = "UK_BENEFIT_REPORT_USER", + columnNames = {"POLICY_ID", "USER_ID"})) +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class BenefitAmountReport { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ID") + private Long id; + + @Column(name = "POLICY_ID", nullable = false) + private Long policyId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "USER_ID", nullable = false) + private User user; + + @Column(name = "REPORTED_AMOUNT", nullable = false) + private Integer reportedAmount; + + @Enumerated(EnumType.STRING) + @Column(name = "PAYMENT_TYPE", nullable = false, length = 20) + private PaymentType paymentType; + + @Column(name = "RECEIVED_AT") + private LocalDate receivedAt; + + @Column(name = "NOTE", length = 300) + private String note; + + @Column(name = "CREATED_AT", nullable = false) + private LocalDateTime createdAt; + + /** 월 지급인지 1회인지에 따라 같은 금액이라도 연간 총액이 12배 차이 난다. */ + public enum PaymentType { + MONTHLY, ONE_TIME + } + + @PrePersist + protected void onCreate() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + } + + public void update(Integer amount, PaymentType type, LocalDate receivedAt, String note) { + this.reportedAmount = amount; + this.paymentType = type; + this.receivedAt = receivedAt; + this.note = note; + } +} diff --git a/src/main/java/com/carecode/domain/policy/repository/BenefitAmountReportRepository.java b/src/main/java/com/carecode/domain/policy/repository/BenefitAmountReportRepository.java new file mode 100644 index 00000000..135b7b5c --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/repository/BenefitAmountReportRepository.java @@ -0,0 +1,24 @@ +package com.carecode.domain.policy.repository; + +import com.carecode.domain.policy.entity.BenefitAmountReport; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface BenefitAmountReportRepository extends JpaRepository { + + Optional findByPolicyIdAndUserId(Long policyId, Long userId); + + List findByPolicyId(Long policyId); + + /** 같은 금액·지급방식을 몇 명이 제보했는지. 합의가 이뤄진 값만 채택한다. */ + @Query("SELECT r.reportedAmount, r.paymentType, COUNT(r) FROM BenefitAmountReport r " + + "WHERE r.policyId = :policyId " + + "GROUP BY r.reportedAmount, r.paymentType ORDER BY COUNT(r) DESC") + List countByAmountAndType(@Param("policyId") Long policyId); +} diff --git a/src/main/java/com/carecode/domain/policy/service/BenefitAmountReportService.java b/src/main/java/com/carecode/domain/policy/service/BenefitAmountReportService.java new file mode 100644 index 00000000..bdbb7706 --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/service/BenefitAmountReportService.java @@ -0,0 +1,117 @@ +package com.carecode.domain.policy.service; + +import com.carecode.core.exception.CareServiceException; +import com.carecode.core.security.CurrentUserFacade; +import com.carecode.domain.policy.dto.request.BenefitAmountReportRequest; +import com.carecode.domain.policy.dto.response.BenefitAmountConsensusResponse; +import com.carecode.domain.policy.entity.BenefitAmountReport; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.repository.BenefitAmountReportRepository; +import com.carecode.domain.policy.repository.PolicyRepository; +import com.carecode.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 실수령액 제보를 모아 금액을 확정한다. + * 공공데이터는 지원금액을 숫자로 주지 않아 수기 검증이 유일한 대안인데, 10,964건을 사람이 다 볼 수 없다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class BenefitAmountReportService { + + /** 이 수 이상이 같은 값을 내면 채택한다. 낮추면 오류가, 높이면 영영 안 채워진다. */ + @Value("${app.benefit-report.consensus-threshold:3}") + private int consensusThreshold; + + private final BenefitAmountReportRepository reportRepository; + private final PolicyRepository policyRepository; + private final CurrentUserFacade currentUserFacade; + + @Transactional + public BenefitAmountConsensusResponse report(Long policyId, BenefitAmountReportRequest request) { + Policy policy = policyRepository.findById(policyId) + .orElseThrow(() -> new CareServiceException("정책을 찾을 수 없습니다: " + policyId)); + User user = currentUserFacade.requireCurrentUser(); + + BenefitAmountReport.PaymentType type = + BenefitAmountReport.PaymentType.valueOf(request.getPaymentType()); + + // 같은 사람이 여러 번 내면 표본이 왜곡되므로 갱신으로 처리한다. + reportRepository.findByPolicyIdAndUserId(policyId, user.getId()) + .ifPresentOrElse( + existing -> existing.update(request.getAmount(), type, + request.getReceivedAt(), request.getNote()), + () -> reportRepository.save(BenefitAmountReport.builder() + .policyId(policyId) + .user(user) + .reportedAmount(request.getAmount()) + .paymentType(type) + .receivedAt(request.getReceivedAt()) + .note(request.getNote()) + .createdAt(LocalDateTime.now()) + .build())); + + return evaluateConsensus(policy); + } + + @Transactional(readOnly = true) + public BenefitAmountConsensusResponse getConsensus(Long policyId) { + Policy policy = policyRepository.findById(policyId) + .orElseThrow(() -> new CareServiceException("정책을 찾을 수 없습니다: " + policyId)); + return buildResponse(policy, findTopReport(policyId)); + } + + /** 합의가 이뤄지면 정책 금액을 채우고 검증 표시를 남긴다. */ + private BenefitAmountConsensusResponse evaluateConsensus(Policy policy) { + TopReport top = findTopReport(policy.getId()); + + if (top != null && top.count() >= consensusThreshold && policy.getVerifiedAt() == null) { + policy.setBenefitAmount(top.amount()); + policy.setBenefitType(top.paymentType() == BenefitAmountReport.PaymentType.MONTHLY + ? "월지급" : "일시지급"); + policy.setVerifiedAt(LocalDateTime.now()); + policy.setVerifiedBy("제보 합의 " + top.count() + "명"); + policyRepository.save(policy); + + log.info("제보 합의로 금액 확정 - policyId={}, amount={}, 제보 {}명", + policy.getId(), top.amount(), top.count()); + } + return buildResponse(policy, top); + } + + private record TopReport(int amount, BenefitAmountReport.PaymentType paymentType, long count) { + } + + private TopReport findTopReport(Long policyId) { + List rows = reportRepository.countByAmountAndType(policyId); + if (rows.isEmpty()) { + return null; + } + Object[] top = rows.get(0); + return new TopReport((Integer) top[0], (BenefitAmountReport.PaymentType) top[1], (Long) top[2]); + } + + private BenefitAmountConsensusResponse buildResponse(Policy policy, TopReport top) { + long totalReports = reportRepository.findByPolicyId(policy.getId()).size(); + + return BenefitAmountConsensusResponse.builder() + .policyId(policy.getId()) + .title(policy.getTitle()) + .totalReports(totalReports) + .consensusThreshold(consensusThreshold) + .agreedCount(top == null ? 0 : top.count()) + .consensusAmount(top == null ? null : top.amount()) + .consensusPaymentType(top == null ? null : top.paymentType().name()) + .confirmed(policy.getVerifiedAt() != null) + .currentAmount(policy.getBenefitAmount()) + .build(); + } +} diff --git a/src/main/resources/db/migration/V12__benefit_amount_report.sql b/src/main/resources/db/migration/V12__benefit_amount_report.sql new file mode 100644 index 00000000..c957a794 --- /dev/null +++ b/src/main/resources/db/migration/V12__benefit_amount_report.sql @@ -0,0 +1,20 @@ +-- 실수령액 제보. 공공데이터는 지원금액을 숫자로 주지 않아 10,964건이 금액 미상이다 +-- 받아본 사람만 정확한 금액을 안다. 여러 명이 같은 값을 내면 수기 검증에 준하는 신뢰도가 된다 +CREATE TABLE TBL_BENEFIT_AMOUNT_REPORT ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + POLICY_ID BIGINT NOT NULL, + USER_ID BIGINT NOT NULL, + REPORTED_AMOUNT INT NOT NULL COMMENT '실제 수령액(원)', + PAYMENT_TYPE VARCHAR(20) NOT NULL COMMENT 'MONTHLY / ONE_TIME', + RECEIVED_AT DATE COMMENT '수령 시점', + NOTE VARCHAR(300), + CREATED_AT DATETIME NOT NULL, + -- 한 사람이 같은 정책을 여러 번 제보하면 표본이 왜곡된다 + CONSTRAINT UK_BENEFIT_REPORT_USER UNIQUE (POLICY_ID, USER_ID), + CONSTRAINT FK_BENEFIT_REPORT_POLICY FOREIGN KEY (POLICY_ID) + REFERENCES TBL_POLICIES (ID) ON DELETE CASCADE, + CONSTRAINT FK_BENEFIT_REPORT_USER FOREIGN KEY (USER_ID) + REFERENCES TBL_USER (ID) ON DELETE CASCADE +) COMMENT '지원금 실수령액 제보'; + +CREATE INDEX IDX_BENEFIT_REPORT_POLICY ON TBL_BENEFIT_AMOUNT_REPORT (POLICY_ID, REPORTED_AMOUNT); diff --git a/src/test/java/com/carecode/domain/policy/service/BenefitAmountReportServiceTest.java b/src/test/java/com/carecode/domain/policy/service/BenefitAmountReportServiceTest.java new file mode 100644 index 00000000..8667adea --- /dev/null +++ b/src/test/java/com/carecode/domain/policy/service/BenefitAmountReportServiceTest.java @@ -0,0 +1,140 @@ +package com.carecode.domain.policy.service; + +import com.carecode.core.security.CurrentUserFacade; +import com.carecode.domain.policy.dto.request.BenefitAmountReportRequest; +import com.carecode.domain.policy.dto.response.BenefitAmountConsensusResponse; +import com.carecode.domain.policy.entity.BenefitAmountReport; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.repository.BenefitAmountReportRepository; +import com.carecode.domain.policy.repository.PolicyRepository; +import com.carecode.domain.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("지원금 실수령액 제보") +class BenefitAmountReportServiceTest { + + private BenefitAmountReportRepository reportRepository; + private PolicyRepository policyRepository; + private BenefitAmountReportService service; + private Policy policy; + + @BeforeEach + void setUp() { + reportRepository = mock(BenefitAmountReportRepository.class); + policyRepository = mock(PolicyRepository.class); + CurrentUserFacade facade = mock(CurrentUserFacade.class); + when(facade.requireCurrentUser()).thenReturn(User.builder().id(1L).name("부모").build()); + + policy = new Policy(); + policy.setId(10L); + policy.setTitle("출산장려금"); + when(policyRepository.findById(10L)).thenReturn(Optional.of(policy)); + when(reportRepository.findByPolicyIdAndUserId(anyLong(), anyLong())).thenReturn(Optional.empty()); + when(reportRepository.findByPolicyId(anyLong())).thenReturn(List.of()); + + service = new BenefitAmountReportService(reportRepository, policyRepository, facade); + ReflectionTestUtils.setField(service, "consensusThreshold", 3); + } + + @Test + @DisplayName("제보 수가 모자라면 확정하지 않는다") + void doesNotConfirmBelowThreshold() { + givenConsensus(500000, BenefitAmountReport.PaymentType.ONE_TIME, 2); + + BenefitAmountConsensusResponse result = service.report(10L, request(500000, "ONE_TIME")); + + assertThat(result.isConfirmed()).isFalse(); + assertThat(result.getRemainingForConsensus()).isEqualTo(1); + assertThat(policy.getBenefitAmount()).isNull(); + } + + @Test + @DisplayName("3명이 같은 값을 내면 금액을 확정한다") + void confirmsAtThreshold() { + givenConsensus(500000, BenefitAmountReport.PaymentType.ONE_TIME, 3); + + BenefitAmountConsensusResponse result = service.report(10L, request(500000, "ONE_TIME")); + + assertThat(result.isConfirmed()).isTrue(); + assertThat(result.getRemainingForConsensus()).isZero(); + assertThat(policy.getBenefitAmount()).isEqualTo(500000); + assertThat(policy.getBenefitType()).isEqualTo("일시지급"); + assertThat(policy.getVerifiedBy()).contains("제보 합의"); + } + + @Test + @DisplayName("월 지급이면 지급방식을 월지급으로 확정한다") + void confirmsMonthlyType() { + givenConsensus(300000, BenefitAmountReport.PaymentType.MONTHLY, 5); + + service.report(10L, request(300000, "MONTHLY")); + + assertThat(policy.getBenefitType()).isEqualTo("월지급"); + } + + @Test + @DisplayName("수기 검증된 정책은 제보로 덮어쓰지 않는다") + void doesNotOverrideManualVerification() { + policy.setVerifiedAt(java.time.LocalDateTime.now()); + policy.setBenefitAmount(700000); + givenConsensus(500000, BenefitAmountReport.PaymentType.ONE_TIME, 10); + + service.report(10L, request(500000, "ONE_TIME")); + + // 사람이 공고를 보고 확인한 값이 제보보다 우선한다 + assertThat(policy.getBenefitAmount()).isEqualTo(700000); + } + + @Test + @DisplayName("같은 사람이 다시 내면 새로 만들지 않고 갱신한다") + void updatesInsteadOfDuplicating() { + BenefitAmountReport existing = mock(BenefitAmountReport.class); + when(reportRepository.findByPolicyIdAndUserId(anyLong(), anyLong())) + .thenReturn(Optional.of(existing)); + givenConsensus(500000, BenefitAmountReport.PaymentType.ONE_TIME, 1); + + service.report(10L, request(600000, "ONE_TIME")); + + verify(existing).update(any(), any(), any(), any()); + verify(reportRepository, never()).save(any()); + } + + @Test + @DisplayName("제보가 없으면 합의 값이 비어 있다") + void emptyWhenNoReports() { + when(reportRepository.countByAmountAndType(anyLong())).thenReturn(List.of()); + + BenefitAmountConsensusResponse result = service.getConsensus(10L); + + assertThat(result.getConsensusAmount()).isNull(); + assertThat(result.getAgreedCount()).isZero(); + assertThat(result.getRemainingForConsensus()).isEqualTo(3); + } + + private void givenConsensus(int amount, BenefitAmountReport.PaymentType type, long count) { + List rows = new java.util.ArrayList<>(); + rows.add(new Object[]{amount, type, count}); + when(reportRepository.countByAmountAndType(anyLong())).thenReturn(rows); + } + + private BenefitAmountReportRequest request(int amount, String type) { + BenefitAmountReportRequest r = new BenefitAmountReportRequest(); + r.setAmount(amount); + r.setPaymentType(type); + return r; + } +} From 0133858f3dce35195830cca0362328469f03e422 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 12:20:26 +0900 Subject: [PATCH 31/68] =?UTF-8?q?FEAT=20:=20=EC=96=B4=EB=A6=B0=EC=9D=B4?= =?UTF-8?q?=EC=A7=91=20=EB=8C=80=EA=B8=B0=20=EA=B8=B0=EB=A1=9D=20=EB=B0=8F?= =?UTF-8?q?=20=EC=8B=A4=EC=A0=9C=20=EB=8C=80=EA=B8=B0=EA=B8=B0=EA=B0=84=20?= =?UTF-8?q?=ED=86=B5=EA=B3=84=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FacilityWaitlistController.java | 85 ++++++++++ .../dto/request/WaitlistRequest.java | 30 ++++ .../dto/response/WaitlistStatsResponse.java | 32 ++++ .../careFacility/entity/FacilityWaitlist.java | 108 +++++++++++++ .../FacilityWaitlistRepository.java | 29 ++++ .../service/FacilityWaitlistService.java | 153 ++++++++++++++++++ .../db/migration/V13__facility_waitlist.sql | 28 ++++ 7 files changed, 465 insertions(+) create mode 100644 src/main/java/com/carecode/domain/careFacility/controller/FacilityWaitlistController.java create mode 100644 src/main/java/com/carecode/domain/careFacility/dto/request/WaitlistRequest.java create mode 100644 src/main/java/com/carecode/domain/careFacility/dto/response/WaitlistStatsResponse.java create mode 100644 src/main/java/com/carecode/domain/careFacility/entity/FacilityWaitlist.java create mode 100644 src/main/java/com/carecode/domain/careFacility/repository/FacilityWaitlistRepository.java create mode 100644 src/main/java/com/carecode/domain/careFacility/service/FacilityWaitlistService.java create mode 100644 src/main/resources/db/migration/V13__facility_waitlist.sql diff --git a/src/main/java/com/carecode/domain/careFacility/controller/FacilityWaitlistController.java b/src/main/java/com/carecode/domain/careFacility/controller/FacilityWaitlistController.java new file mode 100644 index 00000000..a826e025 --- /dev/null +++ b/src/main/java/com/carecode/domain/careFacility/controller/FacilityWaitlistController.java @@ -0,0 +1,85 @@ +package com.carecode.domain.careFacility.controller; + +import com.carecode.core.annotation.LogExecutionTime; +import com.carecode.domain.careFacility.dto.request.WaitlistRequest; +import com.carecode.domain.careFacility.dto.response.WaitlistStatsResponse; +import com.carecode.domain.careFacility.entity.FacilityWaitlist; +import com.carecode.domain.careFacility.service.FacilityWaitlistService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** 어린이집 대기 기록. 실제 대기 기간은 공공데이터에 없어 사용자에게서만 얻는다. */ +@RestController +@RequestMapping("/facilities") +@RequiredArgsConstructor +@Tag(name = "육아 시설", description = "육아 시설 정보 및 검색 API") +public class FacilityWaitlistController { + + private final FacilityWaitlistService waitlistService; + + @PostMapping("/{facilityId}/waitlist") + @LogExecutionTime + @Operation(summary = "대기 신청 기록", description = "대기 순번과 신청일을 남깁니다") + public ResponseEntity> register( + @Parameter(description = "시설 ID", required = true) @PathVariable Long facilityId, + @Valid @RequestBody WaitlistRequest request) { + + Long id = waitlistService.register(facilityId, request); + Map body = new LinkedHashMap<>(); + body.put("waitlistId", id); + return ResponseEntity.ok(body); + } + + @PatchMapping("/waitlist/{waitlistId}") + @LogExecutionTime + @Operation(summary = "대기 결과 기록", description = "입소 또는 포기를 남겨 대기 기간을 확정합니다") + public ResponseEntity resolve( + @PathVariable Long waitlistId, + @Parameter(description = "ADMITTED 또는 GAVE_UP", required = true) @RequestParam String status, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate resolvedAt, + @RequestParam(required = false) String note) { + + waitlistService.resolve(waitlistId, status, resolvedAt, note); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/waitlist/me") + @LogExecutionTime + @Operation(summary = "내 대기 목록", description = "등록한 대기 기록 조회") + public ResponseEntity>> myWaitlists() { + List> rows = waitlistService.getMyWaitlists().stream() + .map(this::toSummary) + .toList(); + return ResponseEntity.ok(rows); + } + + @GetMapping("/{facilityId}/waitlist/stats") + @LogExecutionTime + @Operation(summary = "실제 대기 기간 통계", description = "입소한 사람들의 기록 기반") + public ResponseEntity stats(@PathVariable Long facilityId) { + return ResponseEntity.ok(waitlistService.getStats(facilityId)); + } + + private Map toSummary(FacilityWaitlist entry) { + Map row = new LinkedHashMap<>(); + row.put("waitlistId", entry.getId()); + row.put("facilityId", entry.getFacilityId()); + row.put("waitNumber", entry.getWaitNumber()); + row.put("appliedAt", entry.getAppliedAt()); + row.put("status", entry.getStatus().name()); + row.put("statusName", entry.getStatus().getDisplayName()); + row.put("waitedDays", entry.waitedDays()); + return row; + } +} diff --git a/src/main/java/com/carecode/domain/careFacility/dto/request/WaitlistRequest.java b/src/main/java/com/carecode/domain/careFacility/dto/request/WaitlistRequest.java new file mode 100644 index 00000000..a76de8e2 --- /dev/null +++ b/src/main/java/com/carecode/domain/careFacility/dto/request/WaitlistRequest.java @@ -0,0 +1,30 @@ +package com.carecode.domain.careFacility.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; + +/** 대기 신청 기록. */ +@Getter +@Setter +@NoArgsConstructor +public class WaitlistRequest { + + /** 미지정 시 최근 등록 자녀. */ + private Long childId; + + @Min(value = 1, message = "대기 순번은 1 이상이어야 합니다") + @Max(value = 9999, message = "대기 순번이 너무 큽니다") + private Integer waitNumber; + + /** 미지정 시 오늘. */ + private LocalDate appliedAt; + + @Size(max = 300, message = "메모는 300자 이하여야 합니다") + private String note; +} diff --git a/src/main/java/com/carecode/domain/careFacility/dto/response/WaitlistStatsResponse.java b/src/main/java/com/carecode/domain/careFacility/dto/response/WaitlistStatsResponse.java new file mode 100644 index 00000000..5417cf0c --- /dev/null +++ b/src/main/java/com/carecode/domain/careFacility/dto/response/WaitlistStatsResponse.java @@ -0,0 +1,32 @@ +package com.carecode.domain.careFacility.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +/** 실제 대기 기간 통계. 정원 관측 기반 예측을 실측으로 보정한다. */ +@Getter +@Builder +public class WaitlistStatsResponse { + + private Long facilityId; + private String facilityName; + + /** 통계를 낼 만큼 표본이 모였는지. false 면 아래 값은 null 이다. */ + private boolean available; + private String unavailableReason; + + /** 입소까지 간 기록 수. 이게 표본 크기다. */ + private int admittedSamples; + + /** 현재 대기 중으로 등록된 사람 수. */ + private long currentlyWaiting; + + /** 입소까지 걸린 기간(일). 평균은 이상치에 흔들려 중앙값을 함께 준다. */ + private Integer averageWaitDays; + private Integer medianWaitDays; + private Integer maxWaitDays; + + private List reasons; +} diff --git a/src/main/java/com/carecode/domain/careFacility/entity/FacilityWaitlist.java b/src/main/java/com/carecode/domain/careFacility/entity/FacilityWaitlist.java new file mode 100644 index 00000000..8058e90c --- /dev/null +++ b/src/main/java/com/carecode/domain/careFacility/entity/FacilityWaitlist.java @@ -0,0 +1,108 @@ +package com.carecode.domain.careFacility.entity; + +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; + +/** 대기 신청부터 입소까지의 기록. 공공데이터에 없는 "실제 대기 기간" 의 유일한 출처다. */ +@Entity +@Table(name = "TBL_FACILITY_WAITLIST", + uniqueConstraints = @UniqueConstraint(name = "UK_WAITLIST_CHILD_FACILITY", + columnNames = {"FACILITY_ID", "CHILD_ID"})) +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class FacilityWaitlist { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ID") + private Long id; + + @Column(name = "FACILITY_ID", nullable = false) + private Long facilityId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "USER_ID", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "CHILD_ID", nullable = false) + private Child child; + + @Column(name = "WAIT_NUMBER") + private Integer waitNumber; + + @Column(name = "APPLIED_AT", nullable = false) + private LocalDate appliedAt; + + @Enumerated(EnumType.STRING) + @Column(name = "STATUS", nullable = false, length = 20) + private WaitStatus status; + + @Column(name = "RESOLVED_AT") + private LocalDate resolvedAt; + + /** 신청 당시 월령. 0세반과 3세반은 대기 양상이 완전히 다르다. */ + @Column(name = "CLASS_AGE") + private Integer classAge; + + @Column(name = "NOTE", length = 300) + private String note; + + @Column(name = "CREATED_AT", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "UPDATED_AT") + private LocalDateTime updatedAt; + + public enum WaitStatus { + WAITING("대기 중"), + ADMITTED("입소"), + GAVE_UP("포기"); + + private final String displayName; + + WaitStatus(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } + } + + @PrePersist + protected void onCreate() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + if (status == null) { + status = WaitStatus.WAITING; + } + } + + /** 입소·포기 처리. 이 시점이 찍혀야 대기 기간이 계산된다. */ + public void resolve(WaitStatus status, LocalDate resolvedAt, String note) { + this.status = status; + this.resolvedAt = resolvedAt != null ? resolvedAt : LocalDate.now(); + this.note = note; + this.updatedAt = LocalDateTime.now(); + } + + /** 대기 일수. 아직 대기 중이면 오늘까지로 센다. */ + public long waitedDays() { + LocalDate end = resolvedAt != null ? resolvedAt : LocalDate.now(); + return ChronoUnit.DAYS.between(appliedAt, end); + } +} diff --git a/src/main/java/com/carecode/domain/careFacility/repository/FacilityWaitlistRepository.java b/src/main/java/com/carecode/domain/careFacility/repository/FacilityWaitlistRepository.java new file mode 100644 index 00000000..c9f701d0 --- /dev/null +++ b/src/main/java/com/carecode/domain/careFacility/repository/FacilityWaitlistRepository.java @@ -0,0 +1,29 @@ +package com.carecode.domain.careFacility.repository; + +import com.carecode.domain.careFacility.entity.FacilityWaitlist; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface FacilityWaitlistRepository extends JpaRepository { + + Optional findByFacilityIdAndChildId(Long facilityId, Long childId); + + List findByUserIdOrderByAppliedAtDesc(Long userId); + + /** 입소까지 간 기록만. 대기 기간 통계의 표본이 된다. */ + @Query("SELECT w FROM FacilityWaitlist w " + + "WHERE w.facilityId = :facilityId " + + "AND w.status = com.carecode.domain.careFacility.entity.FacilityWaitlist.WaitStatus.ADMITTED " + + "AND w.resolvedAt IS NOT NULL") + List findAdmitted(@Param("facilityId") Long facilityId); + + @Query("SELECT COUNT(w) FROM FacilityWaitlist w WHERE w.facilityId = :facilityId " + + "AND w.status = com.carecode.domain.careFacility.entity.FacilityWaitlist.WaitStatus.WAITING") + long countWaiting(@Param("facilityId") Long facilityId); +} diff --git a/src/main/java/com/carecode/domain/careFacility/service/FacilityWaitlistService.java b/src/main/java/com/carecode/domain/careFacility/service/FacilityWaitlistService.java new file mode 100644 index 00000000..b25140a0 --- /dev/null +++ b/src/main/java/com/carecode/domain/careFacility/service/FacilityWaitlistService.java @@ -0,0 +1,153 @@ +package com.carecode.domain.careFacility.service; + +import com.carecode.core.exception.CareServiceException; +import com.carecode.core.security.CurrentUserFacade; +import com.carecode.domain.careFacility.dto.request.WaitlistRequest; +import com.carecode.domain.careFacility.dto.response.WaitlistStatsResponse; +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.FacilityWaitlist; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.carecode.domain.careFacility.repository.FacilityWaitlistRepository; +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.ChildRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; + +/** + * 대기 신청과 결과를 기록한다. + * 정원 관측은 "자리가 났는가" 만 알려줄 뿐, 대기 순번이 언제 도는지는 겪은 사람만 안다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class FacilityWaitlistService { + + /** 이보다 표본이 적으면 평균이 우연에 좌우된다. */ + private static final int MIN_SAMPLES = 3; + + private final FacilityWaitlistRepository waitlistRepository; + private final CareFacilityRepository facilityRepository; + private final ChildRepository childRepository; + private final CurrentUserFacade currentUserFacade; + + @Transactional + public Long register(Long facilityId, WaitlistRequest request) { + User user = currentUserFacade.requireCurrentUser(); + CareFacility facility = facilityRepository.findById(facilityId) + .orElseThrow(() -> new CareServiceException("시설을 찾을 수 없습니다: " + facilityId)); + Child child = resolveOwnChild(user, request.getChildId()); + + // 같은 아이·같은 시설의 중복 등록은 통계를 왜곡하므로 기존 기록을 그대로 돌려준다. + var existing = waitlistRepository.findByFacilityIdAndChildId(facilityId, child.getId()); + if (existing.isPresent()) { + return existing.get().getId(); + } + + FacilityWaitlist saved = waitlistRepository.save(FacilityWaitlist.builder() + .facilityId(facility.getId()) + .user(user) + .child(child) + .waitNumber(request.getWaitNumber()) + .appliedAt(request.getAppliedAt() != null ? request.getAppliedAt() : LocalDate.now()) + .status(FacilityWaitlist.WaitStatus.WAITING) + .classAge(monthsOld(child)) + .note(request.getNote()) + .build()); + return saved.getId(); + } + + /** 입소·포기 처리. 이 시점이 찍혀야 대기 기간 데이터가 완성된다. */ + @Transactional + public void resolve(Long waitlistId, String status, LocalDate resolvedAt, String note) { + User user = currentUserFacade.requireCurrentUser(); + FacilityWaitlist entry = waitlistRepository.findById(waitlistId) + .orElseThrow(() -> new CareServiceException("대기 기록을 찾을 수 없습니다: " + waitlistId)); + + if (!entry.getUser().getId().equals(user.getId())) { + throw new CareServiceException("본인의 대기 기록만 수정할 수 있습니다."); + } + entry.resolve(FacilityWaitlist.WaitStatus.valueOf(status), resolvedAt, note); + } + + @Transactional(readOnly = true) + public List getMyWaitlists() { + return waitlistRepository.findByUserIdOrderByAppliedAtDesc( + currentUserFacade.requireCurrentUser().getId()); + } + + @Transactional(readOnly = true) + public WaitlistStatsResponse getStats(Long facilityId) { + CareFacility facility = facilityRepository.findById(facilityId) + .orElseThrow(() -> new CareServiceException("시설을 찾을 수 없습니다: " + facilityId)); + + List admitted = waitlistRepository.findAdmitted(facilityId); + long waiting = waitlistRepository.countWaiting(facilityId); + + WaitlistStatsResponse.WaitlistStatsResponseBuilder base = WaitlistStatsResponse.builder() + .facilityId(facilityId) + .facilityName(facility.getName()) + .admittedSamples(admitted.size()) + .currentlyWaiting(waiting); + + if (admitted.size() < MIN_SAMPLES) { + return base.available(false) + .unavailableReason(String.format("입소 기록이 %d건으로 부족합니다. (최소 %d건 필요)", + admitted.size(), MIN_SAMPLES)) + .build(); + } + + List days = admitted.stream() + .map(w -> ChronoUnit.DAYS.between(w.getAppliedAt(), w.getResolvedAt())) + .sorted() + .toList(); + + int average = (int) Math.round(days.stream().mapToLong(Long::longValue).average().orElse(0)); + int median = (int) (long) days.get(days.size() / 2); + int max = (int) (long) days.get(days.size() - 1); + + return base.available(true) + .averageWaitDays(average) + .medianWaitDays(median) + .maxWaitDays(max) + .reasons(buildReasons(days.size(), median, waiting)) + .build(); + } + + private List buildReasons(int samples, int median, long waiting) { + List reasons = new ArrayList<>(); + reasons.add(String.format("입소한 %d명의 실제 기록 기준입니다.", samples)); + reasons.add(String.format("절반이 %d개월 안에 입소했습니다.", Math.max(1, median / 30))); + if (waiting > 0) { + reasons.add(String.format("현재 %d명이 대기 중으로 등록해 두었습니다.", waiting)); + } + return reasons; + } + + /** 남의 아이로 등록하지 못하게 소유권을 확인한다. */ + private Child resolveOwnChild(User user, Long childId) { + List children = childRepository.findByUserIdOrderByCreatedAtDesc(user.getId()); + if (children.isEmpty()) { + throw new CareServiceException("등록된 자녀가 없습니다."); + } + if (childId == null) { + return children.get(0); + } + return children.stream() + .filter(c -> c.getId().equals(childId)) + .findFirst() + .orElseThrow(() -> new CareServiceException("본인의 자녀만 등록할 수 있습니다.")); + } + + private Integer monthsOld(Child child) { + return child.getBirthDate() == null ? null + : (int) ChronoUnit.MONTHS.between(child.getBirthDate(), LocalDate.now()); + } +} diff --git a/src/main/resources/db/migration/V13__facility_waitlist.sql b/src/main/resources/db/migration/V13__facility_waitlist.sql new file mode 100644 index 00000000..593837b3 --- /dev/null +++ b/src/main/resources/db/migration/V13__facility_waitlist.sql @@ -0,0 +1,28 @@ +-- 대기 기록. 정원 관측만으로는 "실제로 얼마나 기다렸는지" 를 알 수 없다 +-- 이 데이터는 공공데이터에 존재하지 않아 사용자에게서만 얻을 수 있다 +CREATE TABLE TBL_FACILITY_WAITLIST ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + FACILITY_ID BIGINT NOT NULL, + USER_ID BIGINT NOT NULL, + CHILD_ID BIGINT NOT NULL, + WAIT_NUMBER INT COMMENT '대기 순번', + APPLIED_AT DATE NOT NULL COMMENT '대기 신청일', + STATUS VARCHAR(20) NOT NULL COMMENT 'WAITING / ADMITTED / GAVE_UP', + RESOLVED_AT DATE COMMENT '입소일 또는 포기일', + CLASS_AGE INT COMMENT '신청 당시 아이 월령', + NOTE VARCHAR(300), + CREATED_AT DATETIME NOT NULL, + UPDATED_AT DATETIME, + -- 같은 아이가 같은 시설에 중복 등록되면 통계가 왜곡된다 + CONSTRAINT UK_WAITLIST_CHILD_FACILITY UNIQUE (FACILITY_ID, CHILD_ID), + CONSTRAINT FK_WAITLIST_FACILITY FOREIGN KEY (FACILITY_ID) + REFERENCES TBL_CARE_FACILITIES (ID) ON DELETE CASCADE, + CONSTRAINT FK_WAITLIST_USER FOREIGN KEY (USER_ID) + REFERENCES TBL_USER (ID) ON DELETE CASCADE, + CONSTRAINT FK_WAITLIST_CHILD FOREIGN KEY (CHILD_ID) + REFERENCES TBL_CHILD (ID) ON DELETE CASCADE +) COMMENT '어린이집 대기 기록'; + +-- 시설별 실제 대기 기간 통계 +CREATE INDEX IDX_WAITLIST_FACILITY_STATUS ON TBL_FACILITY_WAITLIST (FACILITY_ID, STATUS); +CREATE INDEX IDX_WAITLIST_USER ON TBL_FACILITY_WAITLIST (USER_ID, STATUS); From 95f74212df059b7c42c986f4554c24963033b895 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 12:20:26 +0900 Subject: [PATCH 32/68] =?UTF-8?q?FEAT=20:=20=EC=9E=90=EB=85=80=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=98=84=ED=99=A9=20=EB=B0=8F=20=EB=8B=A4=EC=9E=90?= =?UTF-8?q?=EB=85=80=20=ED=98=9C=ED=83=9D=20=EC=95=88=EB=82=B4=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/health/app/HealthFacade.java | 6 +- .../health/controller/ChildController.java | 10 ++ .../health/controller/HealthController.java | 14 +- .../dto/response/SiblingOverviewResponse.java | 44 ++++++ .../domain/health/service/HealthService.java | 18 +++ .../service/SiblingOverviewService.java | 142 ++++++++++++++++++ 6 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/carecode/domain/health/dto/response/SiblingOverviewResponse.java create mode 100644 src/main/java/com/carecode/domain/health/service/SiblingOverviewService.java diff --git a/src/main/java/com/carecode/domain/health/app/HealthFacade.java b/src/main/java/com/carecode/domain/health/app/HealthFacade.java index 330bf8db..ba2727f5 100644 --- a/src/main/java/com/carecode/domain/health/app/HealthFacade.java +++ b/src/main/java/com/carecode/domain/health/app/HealthFacade.java @@ -280,5 +280,9 @@ public void deleteHospitalReview(Long reviewId, Long userId) { // Helper Methods ==================== // 매핑은 HospitalMapper/HospitalReviewMapper에 위임 -} + /** 조건부 응답용 지문. 일정이 수정되면 값이 바뀌어 캐시가 무효화된다. */ + public String getVaccineScheduleVersion(String childId) { + return healthService.getVaccineScheduleVersion(childId); + } +} diff --git a/src/main/java/com/carecode/domain/health/controller/ChildController.java b/src/main/java/com/carecode/domain/health/controller/ChildController.java index 53c70317..7446f3c6 100644 --- a/src/main/java/com/carecode/domain/health/controller/ChildController.java +++ b/src/main/java/com/carecode/domain/health/controller/ChildController.java @@ -3,6 +3,8 @@ import com.carecode.core.annotation.LogExecutionTime; import com.carecode.domain.health.dto.request.ChildCreateRequest; import com.carecode.domain.health.dto.response.ChildInfoResponse; +import com.carecode.domain.health.dto.response.SiblingOverviewResponse; +import com.carecode.domain.health.service.SiblingOverviewService; import com.carecode.domain.health.dto.response.GrowthPointResponse; import com.carecode.domain.health.dto.response.VaccinationScheduleResponse; import com.carecode.domain.health.growth.GrowthMetric; @@ -31,6 +33,7 @@ public class ChildController { private final ChildService childService; + private final SiblingOverviewService siblingOverviewService; private final VaccinationScheduleService vaccinationScheduleService; private final GrowthChartService growthChartService; @@ -127,4 +130,11 @@ public ResponseEntity getLatestGrowth( .map(ResponseEntity::ok) .orElseGet(() -> ResponseEntity.noContent().build()); } + + // 형제자매 통합 조회 + @GetMapping("/overview") + @Operation(summary = "자녀 통합 현황", description = "모든 자녀의 접종·대기·다자녀 혜택을 한 번에 조회") + public ResponseEntity overview() { + return ResponseEntity.ok(siblingOverviewService.getOverview()); + } } diff --git a/src/main/java/com/carecode/domain/health/controller/HealthController.java b/src/main/java/com/carecode/domain/health/controller/HealthController.java index c7e7f7cb..b8ba8117 100644 --- a/src/main/java/com/carecode/domain/health/controller/HealthController.java +++ b/src/main/java/com/carecode/domain/health/controller/HealthController.java @@ -2,6 +2,7 @@ import com.carecode.core.annotation.LogExecutionTime; import com.carecode.core.controller.BaseController; +import com.carecode.core.web.ConditionalResponse; import com.carecode.core.util.PageRequestUtil; import com.carecode.core.security.CurrentUserFacade; import com.carecode.domain.health.dto.request.HealthCreateHealthRecordRequest; @@ -23,6 +24,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; +import org.springframework.web.context.request.WebRequest; import java.time.LocalDate; import java.util.List; @@ -139,11 +141,19 @@ public ResponseEntity getHealthStatistics(@Parameter(descri @GetMapping("/vaccines/schedule") @LogExecutionTime @Operation(summary = "예방접종 스케줄 조회") - public ResponseEntity> getVaccineSchedule(@Parameter(description = "아동 ID", required = true) @RequestParam String childId) { + public ResponseEntity> getVaccineSchedule( + @Parameter(description = "아동 ID", required = true) @RequestParam String childId, + WebRequest webRequest) { List schedule = healthFacade.getVaccineSchedule(childId, getAuthenticatedUserPk()); - return ResponseEntity.ok(schedule); + // 접종 기록은 병원에서 확인하는 경우가 많다. 바뀌지 않았으면 304 로 본문을 아낀다. + return ConditionalResponse.of(webRequest, schedule, fingerprint(childId, schedule.size())); + } + + /** 내용이 바뀌면 함께 바뀌는 값. 건수만으로는 수정 감지가 안 되므로 갱신 시각을 섞는다. */ + private String fingerprint(String key, int size) { + return key + ":" + size + ":" + healthFacade.getVaccineScheduleVersion(key); } // 건강 검진 스케줄 조회 diff --git a/src/main/java/com/carecode/domain/health/dto/response/SiblingOverviewResponse.java b/src/main/java/com/carecode/domain/health/dto/response/SiblingOverviewResponse.java new file mode 100644 index 00000000..caab215c --- /dev/null +++ b/src/main/java/com/carecode/domain/health/dto/response/SiblingOverviewResponse.java @@ -0,0 +1,44 @@ +package com.carecode.domain.health.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.util.List; + +/** 자녀 전체를 한 화면에서 본다. 다자녀 가구는 아이별로 앱을 다시 여는 게 가장 큰 불편이다. */ +@Getter +@Builder +public class SiblingOverviewResponse { + + private int childCount; + + /** 다자녀 기준 충족 여부. 어린이집 입소 가점과 다자녀 정책의 조건이다. */ + private boolean multiChildHousehold; + + private List children; + + /** 자녀 수 덕분에 받을 수 있게 된 정책. */ + private List multiChildBenefits; + + private List notes; + + @Getter + @Builder + public static class ChildSummary { + private Long childId; + private String name; + private LocalDate birthDate; + private Integer ageMonths; + + /** 어린이집·유치원 반 편성 기준. */ + private String classLabel; + + /** 다가오는 접종. 아이별로 흩어져 있으면 놓치기 쉽다. */ + private String nextVaccination; + private LocalDate nextVaccinationDate; + + /** 대기 등록해 둔 시설 수. */ + private long waitlistCount; + } +} diff --git a/src/main/java/com/carecode/domain/health/service/HealthService.java b/src/main/java/com/carecode/domain/health/service/HealthService.java index 16fec086..bc22c55f 100644 --- a/src/main/java/com/carecode/domain/health/service/HealthService.java +++ b/src/main/java/com/carecode/domain/health/service/HealthService.java @@ -26,6 +26,7 @@ import com.carecode.domain.careFacility.entity.CareFacility; import com.carecode.domain.health.repository.HealthRecordAttachmentRepository; import com.carecode.domain.health.repository.HealthRecordRepository; +import com.carecode.domain.health.repository.VaccinationScheduleRepository; import com.carecode.domain.policy.repository.PolicyRepository; import com.carecode.domain.careFacility.repository.CareFacilityRepository; import com.carecode.domain.user.entity.Child; @@ -68,6 +69,7 @@ public class HealthService { private static final int HEALTH_SCORE_MEDIUM_THRESHOLD = 60; private final HealthRecordRepository healthRecordRepository; + private final VaccinationScheduleRepository vaccinationScheduleRepository; private final ConsentGuard consentGuard; private final HealthRecordAttachmentRepository healthRecordAttachmentRepository; private final ChildRepository childRepository; @@ -949,4 +951,20 @@ private HealthRecordAttachmentResponse toAttachmentResponse(HealthRecordAttachme .createdAt(attachment.getCreatedAt()) .build(); } + + /** 아이의 접종 일정 중 가장 최근 갱신 시각. 없으면 "0" 을 돌려준다. */ + @Transactional(readOnly = true) + public String getVaccineScheduleVersion(String childId) { + try { + return vaccinationScheduleRepository + .findByChildIdOrderByDueDateAsc(Long.valueOf(childId)).stream() + .map(v -> v.getUpdatedAt() != null ? v.getUpdatedAt() : v.getCreatedAt()) + .filter(java.util.Objects::nonNull) + .max(java.time.LocalDateTime::compareTo) + .map(String::valueOf) + .orElse("0"); + } catch (Exception e) { + return "0"; + } + } } diff --git a/src/main/java/com/carecode/domain/health/service/SiblingOverviewService.java b/src/main/java/com/carecode/domain/health/service/SiblingOverviewService.java new file mode 100644 index 00000000..ae564d6f --- /dev/null +++ b/src/main/java/com/carecode/domain/health/service/SiblingOverviewService.java @@ -0,0 +1,142 @@ +package com.carecode.domain.health.service; + +import com.carecode.core.security.CurrentUserFacade; +import com.carecode.domain.careFacility.repository.FacilityWaitlistRepository; +import com.carecode.domain.health.dto.response.SiblingOverviewResponse; +import com.carecode.domain.health.entity.VaccinationSchedule; +import com.carecode.domain.health.repository.VaccinationScheduleRepository; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.repository.PolicyRepository; +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.ChildRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; + +/** + * 자녀 전체를 한 번에 본다. + * 다자녀 가구는 이 앱이 가장 필요한 집단인데, 화면이 아이 한 명 기준이라 매번 전환해야 했다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SiblingOverviewService { + + /** 다자녀 기준. 대부분의 지자체가 2명부터 다자녀로 본다. */ + private static final int MULTI_CHILD_THRESHOLD = 2; + + private final ChildRepository childRepository; + private final VaccinationScheduleRepository vaccinationRepository; + private final FacilityWaitlistRepository waitlistRepository; + private final PolicyRepository policyRepository; + private final CurrentUserFacade currentUserFacade; + + public SiblingOverviewResponse getOverview() { + User user = currentUserFacade.requireCurrentUser(); + List children = childRepository.findByUserIdOrderByCreatedAtDesc(user.getId()); + + List summaries = children.stream() + .map(this::toSummary) + .toList(); + + boolean multiChild = children.size() >= MULTI_CHILD_THRESHOLD; + + return SiblingOverviewResponse.builder() + .childCount(children.size()) + .multiChildHousehold(multiChild) + .children(summaries) + .multiChildBenefits(multiChild ? findMultiChildPolicies(children.size(), user) : List.of()) + .notes(buildNotes(children.size(), multiChild)) + .build(); + } + + private SiblingOverviewResponse.ChildSummary toSummary(Child child) { + Integer months = child.getBirthDate() == null ? null + : (int) ChronoUnit.MONTHS.between(child.getBirthDate(), LocalDate.now()); + + VaccinationSchedule next = findNextVaccination(child); + + return SiblingOverviewResponse.ChildSummary.builder() + .childId(child.getId()) + .name(child.getName()) + .birthDate(child.getBirthDate()) + .ageMonths(months) + .classLabel(classLabel(months)) + .nextVaccination(next == null ? null : next.getVaccineType().name()) + .nextVaccinationDate(next == null ? null : next.getDueDate()) + .waitlistCount(countWaitlists(child)) + .build(); + } + + /** 아직 맞지 않은 것 중 가장 이른 일정. 아이별로 흩어져 있으면 놓치기 쉽다. */ + private VaccinationSchedule findNextVaccination(Child child) { + LocalDate today = LocalDate.now(); + return vaccinationRepository.findByChildIdOrderByDueDateAsc(child.getId()).stream() + .filter(v -> v.getCompletedDate() == null) + .filter(v -> v.getDueDate() != null && !v.getDueDate().isBefore(today)) + .findFirst() + .orElse(null); + } + + private long countWaitlists(Child child) { + try { + return waitlistRepository.findByUserIdOrderByAppliedAtDesc(child.getUser().getId()).stream() + .filter(w -> w.getChild() != null && w.getChild().getId().equals(child.getId())) + .count(); + } catch (Exception e) { + return 0; + } + } + + /** 자녀 수 조건이 붙은 정책 중 지금 충족하는 것. */ + private List findMultiChildPolicies(int childCount, User user) { + return policyRepository.findByIsActiveTrue().stream() + .filter(p -> p.getMinChildren() != null && p.getMinChildren() <= childCount) + .filter(p -> matchesRegion(p, user)) + .map(Policy::getTitle) + .distinct() + .limit(10) + .toList(); + } + + private boolean matchesRegion(Policy policy, User user) { + String region = policy.getTargetRegion(); + if (region == null || region.isBlank() || region.contains("전국")) { + return true; + } + String address = user.getAddress(); + return address != null && (address.contains(region) || region.contains(address)); + } + + /** 어린이집·유치원 반 편성은 만 나이 기준이다. */ + private String classLabel(Integer months) { + if (months == null) { + return null; + } + int years = months / 12; + return years >= 5 ? "5세반 이상" : years + "세반"; + } + + private List buildNotes(int childCount, boolean multiChild) { + List notes = new ArrayList<>(); + if (childCount == 0) { + notes.add("자녀를 등록하면 맞춤 지원금과 접종 일정을 함께 볼 수 있습니다."); + return notes; + } + if (multiChild) { + notes.add("다자녀 가구는 어린이집 입소 시 우선순위 가점을 받습니다. 신청 시 확인해 보세요."); + notes.add("첫째가 다니는 시설에 둘째를 넣으면 형제자매 가점이 추가로 붙는 경우가 많습니다."); + } else { + notes.add("자녀가 2명 이상이면 다자녀 지원금과 입소 가점 대상이 됩니다."); + } + return notes; + } +} From b9eab5be333a3afab25d149a4dac3f721d0f6eab Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 12:20:26 +0900 Subject: [PATCH 33/68] =?UTF-8?q?FEAT=20:=20ETag=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=EB=B6=80=20=EC=9D=91=EB=8B=B5=EC=9C=BC=EB=A1=9C=20=EC=9E=AC?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EC=A0=88=EA=B0=90=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scheduler/PublicDataSyncScheduler.java | 9 ++++ .../core/security/SecurityConfig.java | 4 ++ .../core/web/ConditionalResponse.java | 52 +++++++++++++++++++ .../domain/policy/app/PolicyFacade.java | 15 ++++++ .../policy/controller/PolicyController.java | 20 +++++++ src/main/resources/application.yml | 6 +++ 6 files changed, 106 insertions(+) create mode 100644 src/main/java/com/carecode/core/web/ConditionalResponse.java diff --git a/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java index 29518d1a..81210c02 100644 --- a/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java +++ b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java @@ -6,6 +6,7 @@ import com.carecode.core.client.sync.PediatricHospitalSyncService; import com.carecode.core.client.sync.SyncResult; import com.carecode.core.geocoding.FacilityGeocodingService; +import com.carecode.domain.policy.service.PolicyChangeNotifier; import com.carecode.core.ops.OperationalAlerter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -23,6 +24,7 @@ public class PublicDataSyncScheduler { private final GovernmentBenefitSyncService benefitSyncService; private final PediatricHospitalSyncService hospitalSyncService; private final FacilityGeocodingService geocodingService; + private final PolicyChangeNotifier policyChangeNotifier; private final OperationalAlerter alerter; /** 전국 어린이집 동기화. */ @@ -53,6 +55,13 @@ public void syncPediatricHospitals() { logResult("소아청소년과 병원", result); } + /** 정책 변경 알림. 동기화가 끝난 뒤 돌아야 그날 바뀐 내용이 잡힌다. */ + @Scheduled(cron = "${app.scheduler.public-data.policy-change-cron:0 0 9 * * *}", zone = "Asia/Seoul") + public void notifyPolicyChanges() { + var result = policyChangeNotifier.notifyPendingChanges(); + log.info("정책 변경 알림 - {}", result); + } + /** 좌표 보정. 동기화가 끝난 뒤 돌아야 새로 들어온 시설이 대상에 포함된다. */ @Scheduled(cron = "${app.scheduler.public-data.geocoding-cron:0 0 5 * * *}", zone = "Asia/Seoul") public void fillMissingCoordinates() { diff --git a/src/main/java/com/carecode/core/security/SecurityConfig.java b/src/main/java/com/carecode/core/security/SecurityConfig.java index a39472b4..29fb2a7e 100644 --- a/src/main/java/com/carecode/core/security/SecurityConfig.java +++ b/src/main/java/com/carecode/core/security/SecurityConfig.java @@ -114,6 +114,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/api/admin/**").hasRole("ADMIN") // 공개 API 엔드포인트 + // 대기 기록은 본인 것만 다루므로 인증이 필요하다. 와일드카드보다 먼저 선언한다. + .requestMatchers("/facilities/waitlist/**").authenticated() + .requestMatchers(HttpMethod.POST, "/facilities/*/waitlist").authenticated() .requestMatchers("/facilities").permitAll() .requestMatchers("/facilities/type/**").permitAll() .requestMatchers("/facilities/location/**").permitAll() @@ -144,6 +147,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/policies/recommendations").authenticated() .requestMatchers("/policies/missed-benefits").authenticated() .requestMatchers("/policies/regional-comparison").authenticated() + .requestMatchers(HttpMethod.POST, "/policies/*/amount-reports").authenticated() .requestMatchers("/policies/bookmarks").authenticated() .requestMatchers("/policies/*/bookmarks").authenticated() .requestMatchers("/policies").permitAll() diff --git a/src/main/java/com/carecode/core/web/ConditionalResponse.java b/src/main/java/com/carecode/core/web/ConditionalResponse.java new file mode 100644 index 00000000..dba700ec --- /dev/null +++ b/src/main/java/com/carecode/core/web/ConditionalResponse.java @@ -0,0 +1,52 @@ +package com.carecode.core.web; + +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; +import org.springframework.web.context.request.WebRequest; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.zip.CRC32; + +/** + * 변경되지 않았으면 304 로 응답한다. + * 소아과 대기실처럼 신호가 약한 곳에서 접종 기록을 확인하는 경우가 많아, 본문 재전송을 줄이면 체감이 크게 다르다. + */ +public final class ConditionalResponse { + + /** 목록은 자주 바뀌지 않지만 오래 캐싱하면 갱신이 늦는다. */ + private static final Duration DEFAULT_MAX_AGE = Duration.ofMinutes(5); + + private ConditionalResponse() { + } + + /** + * 내용이 그대로면 304, 바뀌었으면 200 + ETag 를 준다. + * + * @param fingerprint 내용이 바뀌면 함께 바뀌는 값 (갱신 시각, 건수 등) + */ + public static ResponseEntity of(WebRequest request, T body, String fingerprint) { + String etag = toEtag(fingerprint); + + // checkNotModified 는 일치하면 응답에 304 를 세팅하고 true 를 돌려준다. + if (request.checkNotModified(etag)) { + return ResponseEntity.status(304) + .eTag(etag) + .cacheControl(CacheControl.maxAge(DEFAULT_MAX_AGE).cachePrivate()) + .build(); + } + + return ResponseEntity.ok() + .eTag(etag) + // 개인 데이터라 중간 캐시에 저장되면 안 된다. + .cacheControl(CacheControl.maxAge(DEFAULT_MAX_AGE).cachePrivate()) + .body(body); + } + + /** 지문을 짧은 해시로 줄인다. 값이 그대로면 같은 태그가 나온다. */ + private static String toEtag(String fingerprint) { + CRC32 crc = new CRC32(); + crc.update(fingerprint == null ? new byte[0] : fingerprint.getBytes(StandardCharsets.UTF_8)); + return "\"" + Long.toHexString(crc.getValue()) + "\""; + } +} diff --git a/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java b/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java index d70dfc82..f8bce5d5 100644 --- a/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java +++ b/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java @@ -1,6 +1,8 @@ package com.carecode.domain.policy.app; import com.carecode.domain.policy.dto.request.PolicySearchRequest; +import com.carecode.domain.policy.dto.request.BenefitAmountReportRequest; +import com.carecode.domain.policy.dto.response.BenefitAmountConsensusResponse; import com.carecode.domain.policy.dto.response.MissedBenefitSummaryResponse; import com.carecode.domain.policy.dto.response.PersonalizedPolicyResponse; import com.carecode.domain.policy.dto.response.RegionalBenefitComparisonResponse; @@ -8,6 +10,7 @@ import com.carecode.domain.policy.dto.response.PolicyDto; import com.carecode.domain.policy.dto.response.PolicyListResponse; import com.carecode.domain.policy.dto.response.PolicyStatsSimpleResponse; +import com.carecode.domain.policy.service.BenefitAmountReportService; import com.carecode.domain.policy.service.MissedBenefitService; import com.carecode.domain.policy.service.PolicyRecommendationService; import com.carecode.domain.policy.service.RegionalBenefitComparisonService; @@ -25,6 +28,7 @@ public class PolicyFacade { private final PolicyService policyService; private final PolicyRecommendationService policyRecommendationService; private final MissedBenefitService missedBenefitService; + private final BenefitAmountReportService benefitAmountReportService; private final RegionalBenefitComparisonService regionalBenefitComparisonService; @Transactional(readOnly = true) @@ -95,4 +99,15 @@ public MissedBenefitSummaryResponse findMissedBenefits() { public RegionalBenefitComparisonResponse compareRegionalBenefits(Long childId, Integer years, Integer limit) { return regionalBenefitComparisonService.compare(childId, years, limit); } + + @Transactional + public BenefitAmountConsensusResponse reportBenefitAmount(Long policyId, + BenefitAmountReportRequest request) { + return benefitAmountReportService.report(policyId, request); + } + + @Transactional(readOnly = true) + public BenefitAmountConsensusResponse getBenefitAmountConsensus(Long policyId) { + return benefitAmountReportService.getConsensus(policyId); + } } diff --git a/src/main/java/com/carecode/domain/policy/controller/PolicyController.java b/src/main/java/com/carecode/domain/policy/controller/PolicyController.java index dd17f4f0..4d7f198c 100644 --- a/src/main/java/com/carecode/domain/policy/controller/PolicyController.java +++ b/src/main/java/com/carecode/domain/policy/controller/PolicyController.java @@ -7,6 +7,8 @@ import com.carecode.core.security.CurrentUserFacade; import com.carecode.core.exception.CareServiceException; import com.carecode.core.exception.PolicyNotFoundException; +import com.carecode.domain.policy.dto.request.BenefitAmountReportRequest; +import com.carecode.domain.policy.dto.response.BenefitAmountConsensusResponse; import com.carecode.domain.policy.dto.response.MissedBenefitSummaryResponse; import com.carecode.domain.policy.dto.response.PersonalizedPolicyResponse; import com.carecode.domain.policy.dto.response.RegionalBenefitComparisonResponse; @@ -291,6 +293,24 @@ public ResponseEntity compareRegionalBenefits return ResponseEntity.ok(policyFacade.compareRegionalBenefits(childId, years, limit)); } + // 실수령액 제보 + @PostMapping("/{policyId}/amount-reports") + @LogExecutionTime + @Operation(summary = "지원금 실수령액 제보", description = "받은 금액을 알려 정보를 함께 채웁니다") + public ResponseEntity reportAmount( + @Parameter(description = "정책 ID", required = true) @PathVariable Long policyId, + @jakarta.validation.Valid @RequestBody BenefitAmountReportRequest request) { + return ResponseEntity.ok(policyFacade.reportBenefitAmount(policyId, request)); + } + + @GetMapping("/{policyId}/amount-reports") + @LogExecutionTime + @Operation(summary = "제보 합의 현황", description = "확정까지 몇 명이 더 필요한지 조회") + public ResponseEntity getAmountConsensus( + @Parameter(description = "정책 ID", required = true) @PathVariable Long policyId) { + return ResponseEntity.ok(policyFacade.getBenefitAmountConsensus(policyId)); + } + private String getAuthenticatedUserCode() { return currentUserFacade.requireCurrentUserId(); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f18f0568..8dd3c572 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -115,6 +115,10 @@ app: secure: ${REFRESH_COOKIE_SECURE:true} same-site: ${REFRESH_COOKIE_SAME_SITE:None} max-age-days: ${REFRESH_COOKIE_MAX_AGE_DAYS:14} + policy-change: + batch-size: ${POLICY_CHANGE_BATCH_SIZE:200} + max-per-user: ${POLICY_CHANGE_MAX_PER_USER:3} + geocoding: # 어린이집 API 는 좌표를 주지 않아 주소로 보정한다. 키가 없으면 보정을 건너뛴다 kakao: @@ -170,6 +174,8 @@ app: hospital-cron: ${PUBLIC_DATA_HOSPITAL_CRON:0 0 3 * * TUE} # 동기화가 끝난 뒤 돌아야 새로 들어온 시설이 대상에 포함된다 geocoding-cron: ${PUBLIC_DATA_GEOCODING_CRON:0 0 5 * * *} + # 알림은 새벽이 아니라 사람이 볼 시간에 보낸다 + policy-change-cron: ${PUBLIC_DATA_POLICY_CHANGE_CRON:0 0 9 * * *} jwt: secret: ${JWT_SECRET} From 8418fc7ccfcf511d4da90ab191382deeecb96518 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 12:20:38 +0900 Subject: [PATCH 34/68] =?UTF-8?q?DOCS=20:=20=EB=B3=B4=EC=9C=A1=ED=86=B5?= =?UTF-8?q?=ED=95=A9=EC=A0=95=EB=B3=B4=20API=20=EB=AA=85=EC=84=B8=EC=84=9C?= =?UTF-8?q?=20=EB=B3=B4=EA=B4=80=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...05\354\204\270\354\204\234_021_v1.0 (1).doc" | Bin 0 -> 97792 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 "docs/OpenAPI\354\204\234\353\271\204\354\212\244\353\252\205\354\204\270\354\204\234_021_v1.0 (1).doc" diff --git "a/docs/OpenAPI\354\204\234\353\271\204\354\212\244\353\252\205\354\204\270\354\204\234_021_v1.0 (1).doc" "b/docs/OpenAPI\354\204\234\353\271\204\354\212\244\353\252\205\354\204\270\354\204\234_021_v1.0 (1).doc" new file mode 100644 index 0000000000000000000000000000000000000000..6a4dc2f675d65e54b42b45f5b6d78618e7d8bf92 GIT binary patch literal 97792 zcmeF42|!NS`^V3_^tLEcNeHi0LfR$SvLy-0zC|P<6|!$324k!-7>s=^X$;x2ZzXHC zY%v)7zHfv6-{;=<_TKwe#F!cL`@8jd?>*<9bM86kdCq#5lYXr5|bL~R+R%7Q)5470App~f9XINu;piO3wJk% zpd{h?^M^Cm8Ri4<-w}J=O-Of3Traonr9WdfI8hAoqP^fw*i*RTO~_YaFQij&L+Nc; zkTE;t59LS8HvE|!d&I{A+M^o|neor%p9A)kKjKXYM{Jj$Vx9}YkTt|v1J;y9vu-Ss@iuB^y}RCyrF+zDY_ivG>)l%aA3HMET+2G4$N@ErNwwQ- z!s2i>3fm4x433areYSSjyd%}Oszo*w&gcK-m0Cklj*=Tg{L$VRab20L zW)6qRWDZjc?`WiBVGaz`#)h%$u0U)_)kDG<_Zz=c$w`NDtRzJ=IO2=1&1{`lB` zPF!5G<3bWrv-XgE?=@togFIpdGd8zx-J(iFHR^<-~fy_7_CBK@yO|ahfHO9;&M@cnd!%`hKkKg8%A>^-EgS4fC zYy~}$+6vmH&uL=bI+we4io2&>H3@&_JvkQLf0M8a=J093xu=!TuJDPQVH4h9 z`4gWfi8M=nzV6G!n@Wo|9zsi;TTEP`S(FCB%|7(qBG$xh=iO4B*cYh{;mWht71dO9 z4Q*byqJQGTg%or8HBupjM=tqLs2+$wSWz%5546bO%SFymC^phCs5dFN=fes^j0S1K zDt~h6o=*wO7rmr5jQ7EZ76N&NA}#vt?SVF6y-q8{wcy5Jgpp?g(x$!qFqkxSAgz6>~{ z4V7w@L5lSO6V&m1%izGpwjmeR*HqKj(v-X!oK|(g9Fr?#P?dS2_veBfa7R8=hS>wX z;SjEO>;*R=IOh(H4TCf(zg=-`58J-kWuXS_T;awGE-9bHc|&IE!ROWow$W(?Nw(b) zGW8R_WLxNpnB{B>d0S~)sLi1&NUb~4?2hd)YrobAM0u_KG@B-f8^^^NIP93{{@cxb0dtX0!{bbwbCu>(OdZPWCKXdYg znUm>n?34NYA7d)Z@H^fOaS_Y#1*IKXl!MH;2xp_QFC;eMEZirQa$pShD+*R)yovX< zyH{(Cna*lWysxdwm&orT;U&r*5nREv)&HbmLJY_R*EphU;K)YNlco|Z!%tRv!&(~1 zdiybDB*3YDeul~zKTNkEl)?rRM4^ydIDkEcqKPj|R~;NRCJ%;ul>;SE0RtfD1L*?E z-52-)e-HqgfIuMVLn|D&0qsBs5DYqlE+7Km=c8&}Pp#;TefLmK(UetL_fSqzl~uBjm~d2_K`~( zIWtRJS;H_HuBEcq9y83j`i8ZFwP7u-tswt8g4E$kV`nbZsY=LB1q^@@@DiuO`7;ch z7X*|YPt6rmaEBu_O2M5dDx^U^t1HvXu%g>-VpK2_1{vd-YJOI_JY7H9#HaL98$TlYLE*!$a{I-D6oPJfd3?;K?p!D~$TQns&xf_V}*^m3cw#f}C5xj^rE$1er$QSdi-w z9IpVYKmtevH^41$2iyZsz%%dyRDcdR0w>@C+<*rl{qO?bpf8941Hd2<3tm5ace}OyMT9dWTm~ZE^b6bU89Po~m<6OgX7bGVqq_bUEYM%Uf&tC? zUU_KF5LCU@=$Dc}_JK>_GPnvXQCC=j;=me|0?+ULap}~h z)KiyECI6QE$C^LpCzE+9na7iP5NGZMGcVz-h$D0&TYe;AUWPUKGr7p4ATL8fRW!P? z8(N%uNK{|uu6KeqJKzD3F;U&4I=7WpYS7=zf!&d^w&s{u zfoYlfalwSycCCrmdUAy$xU1G@G~CfI&B|*{yt`AK3X?lX&AHJt-BOiZO8JteDW$x$ zHb7f-b>pMb%7<)tP4(hK&qY%O{mLnOT^-I_-L=mJ7PI2Z;-fLJgZOb78`DOdqkfdr5UlEF5x3!DO(;2O9A zZh>du1yJD)0|Q_NEPy3&02P26@Bno{JrDqzfCP{T)`2}>A2tTE})Z}NLbZd#64aJzjn-0QNvTnHdOw1n z-jB{e$3JIu1v{Gm=afe&F27WEsmy*Y@-L0J{6+aM%tijO-{h~AzqpR3`kCtEuSNdU z7JQTc-&_8L_LtgTq22vjO3;>8wWXnb=UZj;%g0 zT0@u<3l!0et@yDPjk+}BzE2y8!L@ee$A_gg(=(}>v7az9g?q{96Y&;}O|!?5!U&O1 zpk_2 zX%L@arrhjQvABDzgnj3-wsPmV$&7z`k)NT`&Q-;Vz~Iq=bPCl}geYLCl`6J{|7}R9y5)f%*-+z5I~OEyxi|jz3<_(B1>-F?h>n#C&7BEtZNHinXguav zza9nRCw@g*^IuX0UcM0Ghs&*s`qe(54rl>L?qMJt3Y$>;QYfK9B+q zfV1E{xCEYnH{dOJ4;cD!X21g209)V&JV13&6Zir@;1Ak?2oMQgKf8DRV#d*o6x_qf z_$^`cvW$xvxLY%R*{H!d;{TT6Rxx*9SKGJcd->Go<&T;xed!22XURPf^p%%|&JlX; zFclB95t#zoE`y1FwfAnCS@ag&LnhM)dh7^MI--z{HaAT&yxKmwRzlxj74Q zd}5U|a&b2-hn>*qqp%A1?LOEfa@WxG1HMr8o_(JXVQVH^SXK$`?L)D(b?(+BbGOd3 z)u#_G=GmDMCsA-m_kXzdUnbPuRBwyx>U@@e$vt2X?oxUDKvbHs9(Qjy+Vb;sio3T< zI%Q_RFyH#*M>kX@+uwI@H%HG;ax?cIy0^(SoT6r;|8VmwMz<7{%{VzFFL!8MUs{eE9$QII(E_f04(?@)NmyYmV zhA-3)_$Q^!NkQmrXq7Dch?llh23-5EN8>9&?&CDFpN@SzSPGVd6<{4m0vo|*a1a~@ z=Rqd81MY!m;01UO80K!}pdc^>R=^sR0&c(q_yKDc}&Vx*F30wxZz&-Hh>4Tg2yL$8d&69`sZQr2zJNY2-!SY1>B>sGU z_Ji5y@%QtL2Qw0vC(ht3ag5QEdpl3fC{HwE%f8P-b~NVVpZn9?tHD`*tS6k+9j|H4 z#^{%2zYVIj6Th>~J-mbw*ZQDT zVf=;>n|7?1+nv1U0TU(n`J`h%?P!n2iCPmM5>)S&meLoZFjQU|K<2D!ME~{d=eUNZvQup|MF`8N!CKy9f4yiyHswe z%;v2Fk~8@a?Bh5jWq(kpa)qC?Jl*s)ZR*DUrQ`JZ?VBuY{>iggYypQ?bz-*N>(3&7(jlHRkJkg&LKSDjaJeWAa%MjJr*V&L|#8 z)kQ!4Af_RTGg*9AAG=u9MLQvHgm=FUGvrK8GYOYvW*PX-49hxq?i_afZKW)YvwZC> zUMMcwai5JXj(5=!QVD)%&F@O`yPU(r&ir-BheCKCToe=jZqP)emC1!ZHT9*(!bA2Z zQHE^W|CxhaYNf%X5&J$zBmUIO)(Do1M)1n;HOL?0Lnl)#e_;$2ficr8HB!D=q)rAdV~lVR*_x+y%{7`JaiY>i?DUZ{ms7 zAVRQK2CabMhOd5lVEqeM*j44$IiVFIXmycLoFSL?SmB2M>y7nAYQP7rxksy>ROVLu zvcL*ei)Iu%~ zY2^@MWuBVIXOa-DFC+X_!BKUr0U~v$&h{@}kEbS9>e;?U&9-k_yGvMahx|(2Nd5Aw z@YKXr+#pvs{L!i^v__!|_HNv@$-m%E>YHD8N9wyOeA7BC?(i>MU2rG$Ev&Os6RVAF zPQfaVyU(qUSfgeJiC!7!)}!m0phg(9suG{3di#w}Za%@Er!-n0z&yp8ge{YHyRphg zg==X;m0nF)k*6k0*&mQ9xYpWAU2E6dsfn~y$xeybUh6}=ri@sMbQS42t)DRDDlgSY zl)Bz1e=*PtacQIV<|u^RU1~#e#;P+~z4}%HKU8Olb1JtI=z&_0{)F;JbssPN{FT6Z z`?qabnm#`5)5~9F)UTDeHHdndUfFv0vGS+&P371fe#O2&R{pjp?B2Cv?Y{MD z7M)Pza5Tyw?Hi}6*`(cvw;)dv)zD0BZSe6jMq1wkTHOo3q0n<$2at{ia;*iekwoib zeM%{$HpnB}M}EqoR+fLQ9J**U;9EKTa(#!7mqY5UR27v&)bX73^H&bnro27CumO6nYutM_ghkXN9sSw%7WQwpt#@16~?`tt8Rf%@o3?l_c_!v#-go zYnQ(N-X=9Gw2fa?Sgo_lBduB}UgeR@!di~B!g3H+XAaUxnO1|OL zo2-(O_N&?W)D3(1)g;y2c{MXjs0SA6ZDcPY&ugtG$sE_qY)tZ$8V-l?P&=m}hyiLeE#LHS(KZzmitr{_vI5 zxK?f}rVHz1=YG9r>FV0Qk5AXoz0x4i0?-^lB#6Zvz-SN$ z#sM1dKLYaMm<|IfU;qY;#IwU-Vk~2mz+^BLWMFK47Mur9z$@?uyaxtju)Y*12+V*5 zumldE0tf|RARG(?tpvXEqD(s;t&?F0uJEiz3ceX`Mq@J#KFA> zw0VMB*knd$sd=&hES>Q5w3!EU|E+7Hy0;B`^ygB&))Y<(Uy7uKP zq;p>Mw1?D&n*}Q>l+#SeipnUJO)8U;xnRg!yFp>JTVL%Vz6&Kbd_~8(%=K#DfKm!~ zpfIt8`CoO}&zKQhV3<84D4zcnvfwM8|J4)fU#fel-t8ttn)aRYG>A|9i^nQXSo}MV zb4h$jI^xT|nUmM|g6X+q7bcI-da0skjt?qyQxmJ)k>lOyzwhnQ98ZxNAU;_{34G2( zb7-wSEU7)rc^imZ=r3CPE-{xjE-;1g{;xu4YpI>3HWv4-v|FV15IqC2;XiRK){gH< zJBHdy3-?F0gPu72WbOE(PZ4Je{R_yS6B;Lf;T{lE2>mMRQ&E5Fe_H;wcHiW$BY&X} zP5o!;JO5A1|HwD_3lAG=-v3nyC)hhtN#bse=Gkw z<)6mMBzs2?07wS{K^G7XdV*nK1c(KrK^zzlCW6UeDu@TOz+5m7EC36^Qm`DPgA?Es z$N=X-Cb$GHgKOXhxCN?X@KF;4ffk?@c=_33!z{&CQ=8Hl@agG#UD?=lqkZT zmAdKYXGUHz+O87$J4WynDl4RO4UEyr9%FQTp_Ow1_sM0q3+~JF?u&3MeDN;oRFI1z z7ai^%mc?)#_+P4y7F~f%aES2ucYA5(fZpNNos%FleWU73RVQ4PGhv=VYeL+z=O{FO zS$VO%Nyd`;UsB{HhOe;9i=&iB`7d4WTlv>1|Kf5z4)UjRJqb{`o(`y7&jwVkg)%MC zktlJ#_x^_r;dx$%Db4UiDHS{>gCix4?&)QBuKqvpP5wIVzcJO(;(GWh%tBpE_3kN< z0nUQ+AQM~y@5B=LQ*3xBHrx;!B+ev;D`IC7!&&hei9r&8#BfsV=YQikTaQt*DQx9_ zwk*U}uF-R4HR!l~?F>w=o3ny(;j)gPduVJh-hnP)j@j^6S;gXHnlCSR5AEw|#+MD5 zIb$Xy<2GO|SO?O<36PiEUp{?#C-d~vBTv)z{BPv!SC9&6%bwl-7YQe!@_hxYUJ6%Q z^C$Lkgg%LGuZ0!xXGSiv!Y(rFKpC^gBEPOv!)0!Pa^@X&Z0J&lzeih2}!es|2^+I8C5aS$XYMn)9@`c!3`*sb=*;fsssxf~jPcGxV5D;jX}5CRZ>$?Bxoi0(*s0 zkzN>krJf>`&g-cZ-4xvHsga^9zMTJ;Vm~;hOW7n=RNgC;bU~cAlv8-GfYKv|;!hZ4 zjAyF(S?Th0{b&=P(o1a&?PFE$NRM=jRpFf-oh??iFc-UY`jU%ei&cf@5?;3)c3+J= zxmO=Jp%%}uLQ*t+SLyzrT0{MdCdfJ`Aual1!o-~@RVvSZ7 zki#F{2*Ub2xF_3!dxXdx51!J4cOH0LkB(e$?*He-={?kcE#?2}#ohkRl>e(2cmFq2 z{;yu#7rWlHE*okXt2{U$|zUV@uW;btndN5XpRR=Lf#DfzbZ zik#Zct_7&=bTOvcHX~!91vUKF*D~W;S6X_E+8d>&Ssg%oG^?rn^qSmR1+zjaw^l{x ze}OgrNt8{w)l&J$8UA6rFSAJ3C#I2)c%|G#s>h2o6qBYh`3(aSX*(5(RB1>Ps2K)= zV6lu2-!A(zK0zN@ppKdVLEHENFZZ1ivK2l<@}a%O3puw;iL}e4r;+M5O=l@i z(^;xYlcbR93^FVApGP!PN9@ z{?oPM=G>}#8Q+n=Xs2!K4&r%-FZ+c*YQJQB=NsSq%bw)nJ`pJSlwY*?dxCsNguddp zFrP91Q;(XZ%QDR^GR+7f|6d|RONtwm`H3Olh0?|_zj-?o{fMtCZ>Q%oZxyxT5#% z=by|0xvCA80F$wPvO)~ci_;4e+%G+Dk{>wdANd)C=T`)xahw~vLAc=MZQ3Xf!ZBy@ zH~-M}K4tUL#4|sBgmeh`njb&;aU+DAAGcqV8;bW@ZUz&pir|1;SOZW*`_) z_#ysQc)3=34L7;WM}}6$O>Tnl$MtHBFyArj11pp9U0>$NcBPfy=C!*MXG7))&Nc$y zq8R`p7`alja2(UO4SV2AB;7ez!g=yj8*i@0;0r`OSQmUGwi9cFt3@L`{;Q5}5pFkx-w$7J3+JvAhBJ=11|MS~EQ)DG;J{=l zN&Cy=s0G5HZ@u-yaTDwZVjqM@IC~@fNQB_PI-F*Swk?iqSjzT;-Y}{0NT(WC z>kT)n5389nP1jbL2HS*{31siY{Rw-@k4XG=V}szkAqSsJf-RXE;;XGj zn%y~9{I7?^KbachQB*z@Y1s7bYW|A!#H0n2F_o>XfUk_1FMY%;mJVV*_RNS?HjveY z0(CHuS8U%C`@6`UnPGz zK=I)7fIs5>(l3S0$5c3?G|+MWLKafY$Cr<(5CVlu8bf>felNv=o-Z^<-2>e)ve&2w zsfLUd;B~{2h4@=CW-C`fJKV50F)hTxUDDz2zD7GJB(ZkzIYPfg3zWJpkSx^?A5Q~G zn|z7uBHqmZsy`!4$_18Cp3B8giw#MKeA52Yd{S)5C+$DYCrOBW(*DzYQawOEY5!?H zJvBaseHBQz1UeC%R>-N+FiF8oBs9RBt2pGSPlEj!RKtQk?7;eg7~m~e!c86QO-xPM zlYTV5d8^S9N{?7e$V^0Ne%0JQTw@YycXGjhF_cy|=m!(IAc@kRc zlq2Hvf4hmyTN@ z4Sb2^Ll&;)o=eJwb9}CldwOX>dVRj0;p3}K9odR=LDv_Z70y#ih2yH4nA6vp#n-_X zHJFmuAqDA>kG+BwMN=~>H`T#ch@oL1#WNB|T9iwZnA^Yx+jUn(L5M#nnTiizrHQ9MM&dFon#702x5q+Qq$A?6$n3?1 zWx$7}&xfV22`ds`qQp8&=$}x3laE&sO}t2Ygt#pyGif*V1*tEE)j<&g7fS;Q*(@|% z#?U#HdD+UU){{fB26#c<0DV-xPHnCpaog=Fr48&wI@d_g|>{4p_w0c(@4z+t?v$O@2p3!wlt)ke)r^SML(Atz!iIUoUT4-)L zLX+BjT4>UAQQV|9pB9>jC~l-%x??JFTUMOY6`8C!bVaFY%bsg=MI4S)SEOg8CTi)# zIZfj%sSVZ0QWx5GP;4)5dBpa7jjn4W&3|$u%|mf$QpuBBX^hCGvLudT-#M>DtwhfF z{)HpEh+8`4;aYiaSB4~2eDR@#SZ>w7Y)wMMuM#P1d6m7K7JB~ly5kFjZNtVW+G z6o0wf^6hg@*L=Sur#;_u$Z5~_KyupiEpASGYDLAdl$wM%3;l+rpw-28ZQep+BWq`i_O-X*0K9r(EhaqS`w!IAA--{F)4RtccqM>wbpNzmK| z$s`c|yW<#bGAATj*U@HrO7%Uh~gHZpaDdz2H?yebdVlAm3BSTye%C_M@Ay@TSo6|9=!%ptr z5`jUmF{@cYhVRNnhtrd*o~)JnN!D*NPf=V9*|Ysg`>E%Xi$CM&@K_d$)j`ZT9jM9l z&_AQTUbI|^9*hHKp=kET1Z@?kH;>pnJEXoKjm<@0)bnE&oPWPu{L_Qu#aKz+zgj{5 zTh%*&%qFJNIA0VHw6O>mQoUS4qPH+DSt*2sp87mH1T$4eK?KLC71B^#;ErVI!bzNJ zFl~Bh#-Si2(yCq?d2k%@rxLwB)V6u~px%5z=vJ$GY6V>@IBqHKF}Oe%^729b_JXVo zeB6R-qOk$_D3+%W>WLSG=C!J~Rv8>eyugQ_To{%e%p)Dt-!90?!p9)J;5g!EbVgk; zZqCa`RnCVkeE6t>;|M?WSADQX2F5*dlw4>(U)$?3udUX*)mT9;`T&g(>{#OzBlyM( z0I}FX>zew@p{07zQaNPyzA4J2oN5qWOR1(=x@T89tstVE7p2+GBxn>%NJ@!bPo>G8 zD^2{kD4NY>>sdc}G#83p=po!G(nBAdViC7pjnY?zdABO}IjgF&!mJ$1on%5AF|Ehu zAmyVe-bnda@A4>JM(l@E>fLJ8ue(yxw_weY<4gB!K0!5r16M}PBq8fm(m zR`J7ZL&0(Q71u~K|IF7&1yNhKs^<@VB?`-_oU1VMApeWybwvIPh-x$klt7wI6T+x~ zFowZDF-{W(ABS~$#-X#VI@^Cm_&*H4#1%;#_T(7{7p%cY{T(3=jtHX(N)GXxLY$5h z9}I)~H$oVd5XK7lCu%FjVO;%S7}S>%!f-+uLr}ViX?lDZ-kdf*k$ZFbNp+@F9;8u8 zYgeX_tuv$1BZiKQbkGuGP|{?%A^?6}*-K{!_*Fp%o!KksF5SN%t##qFR*gDvRGkVk z@nizk!rIxWHheEl(FPjrq6y7Z5}LMU)E+UUj}KXQbU-a7e+6FNHj0{DS?Ozi99NZd zaisXUaq%n05V@vkrUU);(Ohc^85cp^_jzAvn%qr+`#`!A7QVzsTQpu7AX4()vk zcjzO@fhA7mMz`(=rzvzcV%nfA-*!LSb~(Ws_m6lzD25S%?u-Cr8XSk)lE&HXDz(*`-h;x8 zYug(%I)gCvHNBKh%=qN6)UlAT9POw+Kl_Z zueh$iwM0HbB#<-yoRnHX*7aCmnX$+(Ig8zHjlNDnjHLJ;I;SP*HwbkxM&06>V?%VE zkp|p)Ff^uH54h7yQmn0Pf_T=|gsRVlDwd)Up7>6>W>k-x=%O$&9-{D?DKQ>`M)I*# zB9&3-8-(I|3W^!-U)M!ix+q)Wsts7Xw!?8opVK%syOwls|D$~_7{!@k#Wd1AZI8LR zf-*u}-++bP3ui4pGI8>*~-7Ys%3|%&_8KF&{${ zO-TG*Zf#8IzAMSVPt+PvNo>KzW||Tb!#O?-vk!!kznF!i_l%yGG8~~YE>s#_QA@Ap z#y$*flRvb|N``L@(wM`_)<`^h)ASvJ3qb8o6E}y(ys;)fFfX0(_JFp> zS_W+=A9vk2W7y9tH*_qo7>mdDo?Px9S7KC&F!|iaXDLKi6X&-97bcZ@duET(=oO`X zcK%Y$!~63(Od|^g^Q%DjTwIWvC(0nM$Hs@RLf?XBFVvW|r;)!ER{Y~-pr_);&cSh3 zdY$81zo(Hi<(f3d!_ab~C#Q!R&7hek&Pt8OkdBqcd9%>Vs4u8o=;jczC!cwP8V%*7 zupZTDftu?WW?v`we5t_7Az#YqRgmOMi{uq}I)={|%0n|~jv0DtrH~FcJ?g2o$d)wk zRv4RDV5LK|&__sB_QXPnE#fa|B0rm;)kdvs4N((wwH6ou=Gn3phc8Zp5_ee*p>;;G z!ssPI`-(C4NAU+3U8@kgrl^asswLM;F3(yrJ$l{)l~1Szve|s;WtqiNCdq0adqYGN za|@LX&{-`+6*k40FE!Ta>w0#YhHz{8(1;dg-=a11YT6m;lxwbHj`~kwD-%6YLamw& z)r@uhF%gCVYIdcqmdsaa>Nh>A-&$!x#rmAw6H3Cp2)gT}2}#=(ry;LG9BN=Ah0+2o zsR80uhTXnYXeYfUZ!M>}GsfE2jf#xv78%p2#o*{}{W>}g>K&0V?NF1wE~fQg`n?@< zy^{aDbv_1mw@0T=PF*}~*MT{1s<>74kY$BtKTF=R&7sP7u?;884O%wzMs)Xp zbu}hW?OS^4(i3mIxZCrtDDBdzY4>xzwQOSY-;;QH&?wpXVn zCD-z7lHgz0V9fem(@))7@!B`dvX0ZiE#;m&Ev-0V(UG=$TD&f{_EvDW%Ev3OxTJnk zV3OJ1wZ;=V9qXZ>n0EL3_jdsca}xx=e2H`pFBLW{Yd0U zRu-NgljPU3j9+f$^JTuUtT+vqaWtkC##O}7Ol)(JhHW_rTE z{soUNwj+O;)~C~%(z6#7yX+Zo^0{TunJS$Vr>scJTEExj@PekhNB-(~y}mNCN_;?% zYh4ztaQ>tD_NgnntvXdIeZ%J08SjvkoC)nUcowaC$tD785EI_5cQ9d6|bGT@Wjs-&RqXvOqHX*Tb%x> zkx5&}dHN<3yB%>Fzvh{b2rk!vp=A4KI6o>ew|K zE~+y&R6Jegq2tBiEw(*teBYq;t&=ayU#)+)qP>yaEBDA>sP_A~1E`Xx7pEDxTq5sE z(Z1KVh;^XQp zS{68WR!xueP1WN!j#9pv{(f%n=I4{1IY0C7I>oMi!W_Q}uAN<^UB%jgWUgu)}%|2-c&1PL*UM)4V#>fX32OeEq zvhWqFYMp<6RPt0z*)q$k?YAji$Ng&evlitZMI1h1Ww^Ot_@mBc0$*NZyqf1WQ9IM}W)6(~uSGTOMpS0d(ahLDcgq>+VvcT4{yKgOA{3f8> zpM!r{aC2zwiZ6d&JFg47~CCgbM1?S}!y%9dWtWvR0|9iw)A7 z=+LmBXS!Xj72^&azcJNy$<|hb_P!jl;ozRvMtbYZ{M>Qf_pXihZQNqHq`l*PnUB1@ znQfna!zQMN8=QQ#f61||VXqcFO`Q?dsrsuE{g*q0SkG-VGw9bAW3!&zTv@S5;iNIs z!YU1xneRAm``T;R?CC4RlOK+0-FnR1clZ9>`E!jvlONWovE${*$M613eL3Q=b+J}k z9p>C>W#9btWA6obUv(P!>e`P#?_67LoZUvF-A{ilmG$D;;LIJ z%%{uY`G;N|*pkx4YW7&eOHDf+>tb4Far>m{h7Am-Z*TKmaAla2)SvT!=MBgv&uSlYfzgS}7oIb-Ub^t|Na zeFvfjdg}GI^ZYR+&SY-u^8U)Xmz4z?)aEpJxfwQRYsmGiLu@Nb}Bmcw&C)_wb>pd7 zmw*NX&epbCG^pp^7Y)sBpMCjC7SQZkhi-dRzjwW1H0|Y7{rHH+FHJ`O5VNy`N#cwN zwR=>qeQieDo@v+h4o`Z0;pdaCd;EL)yfUdVrD0#UJ}2p zlY{&8>(zT_&^YABP6L+BFzmE>L3HtGu0OqL-^sSyQhm=Y+R$N^{;7cKUS_Lb zO#gY)-cfZ!Z!O&DJ^uQGA{QojyEq&AT{u%_QU9>%E86*;n)QR5-lrw?`Pe zS9X?r=b5+1T}$pisBg@<7DLAdl(0>@9X361TC>WN8idCcUEh4;4x=B=9O>V6X3&@g z#$)dkTAne{cg_1K$0l=ot!;lsHTRZ(ZS$KeZmU|BUVhlL@lOw|3T8zYYZ~$(dduVb zG4fy2$JrdQeA_U-+uXj#Yga!gPpnt2`Iy-T^StBtHEvz7R#Qde_;C)A*NqJJth77B z+E(p-O^xe69yIB@I!(7`*p!`rarB<iB30)b!a%aNrQ{m zUDF>}b{!YnvrJoyJ?|zst{oEiL(jRbf0(p4W7VI1%NDEJ*rRpjlYvFrR=!^$?AcmJ zMX%qgx1DyTO{KcVbCm_BIn!9oM>uD6_bSGRourBb7p)!d;PmIYUw$mLZR+Bu`}M6(?|59;K6ppJ zYVM(ZrmdXfSigRU;c?|#N0bgxJt_PBT~pKf)oL$0v%>Ry?M2OcKQ{{grK7XQo`oH6 zDMQwryc;)S`|+)Jy}vWMw_(+yqA_>JF0>w}=j_n%&2!&HH(FMx^lQedf!i}K?)Oyr zE=WtYu`~_de>bxhTDF`Gx5@FTKX1gD5@^=T>9l=(Z-r~bMa{RS_tMyCkBddz%h3Z3 zqM}YFn@+R8I6i&NDMj41$|jz*Y@6M?Z<6R};PhR{jyXNH{y0C)ysO(K*YH)v4ZLs9 z>@#-QLA?=xJ7LR^Z&M_3yljPw4Jk+Op&OX0`jB z8>{EAIOXu=vOlc~P#$cboV@S%?4--&m2jgYukI|IiMCuiw}FkM0qFMcw7y-CpQ7HL zu1DSelGbA}bR zJ`|0C^;k!w{)iEos@Fo-Nj;!j-+n!!B5T;VI=R@WyG3@53hfcuy@pN87QU6dY}EZ? zLLx&$BBCO@)vy`dt)ES;nkGioL%h32^lcmxtwsQm{k%hJ*o4Q#M0-0s_v?zzWk^4# zsOWByI2RVxw|7VkjD5R1hxQE_h=6-XIJ>#HcslnE=@Dt8?$X`0hD}6_tBpEl5RO87 z;iyY@H?nghI~;}f!cj=qt{BR|h2}u8!Fk>$+l{+m<1V=I5NtdIo63SsWx>W%u<^7} zhez~??1dE3&PE*;72(HQ2uC)~H7WOofQPv8hV0aAS=bab7{f-#x<|a3{Lx$)zX`7R zu+YO@NcMtnJaB^o!2@Y?qjs7xA_{46!k(bJ<#dOchJ)U4919tjfZ%|p1PTLJ&=~Xpy+8zr1oNS+<@Awm z;0bntJ>VsDWfXK`4A=_2tPh220I2+!o5W|Cw*huBSu=OdJp8H0Gbfq1vB)Zt%P%QS zIY3hI0On=lvu4KPfRhL1H|2FV&>v8lG&dp_{4Z4UQ8?^S`W~tAt3(ZaVzIHY>?Iysz)R8c zNAHnYYG&{V*Wv=E>ZA};Jh}mUJW~G10roOx_=p0~-Q>)O^GAhK4=2feBK$2j=Iyzv z37Ov<5v7F30vRW2Qa98fN z$8RlMN~SZlvT=;p^!IQ{>O-Y7m)1IdT5Zi#ROap{mUd0Ha#K-BjeJ&EcMff8_I+r? z;DGR`DKj>fs5h<6dH1ZH<+f&8r_>IxI_tgaS_S8&J|~X0xl`fwj^Dc#3W`>`xp?jI zFE#D@@*UgDzicqYF=+1Q>BA?zZ&p5|#^haFp8EATk?c|@AT_;wp*ITe7IznKZhgL4 zy@_q-RSWItd3MiD)BP`|CN{N~>Cd`)JIQN(1;a~)TGb6$-qYsknCbHk2m3o!%WQva zV8gnrIvGB_=6!kI(1uOT6KX#;DpPasY>#-ugy{Rle|fsIT3FgN_cI$?b-kp#_VXs_f3{g-Ayx5;~tKo znW#Q<_gXD!;=Cli*X>ff&Q9!FyuGj6xi>G|$Lw|+a-rGI^p_Jxj(ipHeYx z4hfuH^7ZU7qh20*d|NNR#ZTKiIxHDg!pJ%$^}Bb)inhNn?eK5od&WDeZnQhJI^$|s zKr8PGvoD0%G}*Xu+8C#dj=g#31z-!|E- zY~vHMI<)O7|J(Kb-^hYpzHhm9<^7I+tKSbz4U1|RylP{0^#%8u?%i(;ioDc#_;)W_ zRInd3`CY|AWe-+t=Dy>|<1?uz8oC6{eKa>_^hxVl&Bp#->GvI5ihVapwYW&f#&&Yk z#C3fR^jZCU>XyV+?arI`w^JS5eb(Tdxk>vi{d+a^^?6vW%A;*(OO8#@FJEDm#XQUP zg`F#nIGOH0GO~F6%6Ix2Z|U*pk1uw*jvP3+?z%O$bq}qoTl|{yx?6^Sm_AdsG;N&T zWL@jP8mpHEEGD*L=>YtTAh9-rwoB?~Zk+ zuKOAmsx)Nj4BvrW>Wr|M^JdbRgS+mBum&|o$=*b4S#>42#qSnZyPP)Lv20V&#NU=J z9rt!vzmwZHd;j5}SXZpp#NzU4^DQmIdxl0$)W7Z%TX6Ug@8>DD&39kvZ?-g8x!fqC znf1OMy%#iE_s9L(72YkFSZYrF$#%~!EuQgi`w9CJ(@S;?aB=d_=)W9co z<5s7Hty_El?VKUEz1x;sWUhLA-f;Wk=*UAYZ|z&QbNs}+T{cvB+sQt$bXcX;y$&{> zy(H=Fqhsv`n!a|NIAe4Ctv0Lx*Rgy;!uANq> z&O;}^`EE@to3t4>ZHCjfeUINhbNXSsi^=y-sxBLva(id(j#>-=hW0sp4a6U28%E9V3)*v@4v7hei#I?AvVs>3G1DBUBm2J@nf)W`kd5yPuNk z^`6^!tWSs9@ugQ*sdCP~@MG_Z7wxyzI5eTuzyTgl8}FFk;g_QxaT)G=E@sBsI1NAD z_lHN1-&b!x%IJ9CZq<&(J*q3)xc_;}$n^)Oy!E}=F=1w0^Z~~@=3Rg8QS#upc_#gS zH$U|7mVWmo^Xjfyy?e6R1Mm4(GkSJ!KWkjdi)Uv8UvIBgHSXAtf4uM2BlvE)fxk}~ z;WqbTyRw%q1r#a~IBof>sByK1)&8!|?1Jz2wG1q|$ENb=Qia1NH1wI!b-q)*s_Lby z?QYqe`O$o0C#00m3+c#Aw|Rgw(UwbNp*tqhtuVE|osq#G7~1JpHCm5OsDPvukM2cv zWvKOA7!?=!6-iVWaM7z*PvNp8)PgqCM@s$xzlFJDL>QZi#-7s;`J*(1i1&uFy8#C9 zMcshzu+Mil0Dj<{bMK6UIx@Z+Kw;rQS~dgS06!e=k`?_viye~9Lx)r*pOKcu^pF?m z^~lMlmK@(mV+!i{pnE`lV!nG2fTy16oo~8t?#130pdT`Hl}>DkX^1_AB6R5MlGeQ=@L#RtKYdR;Ai zBkOUA6;-zOuyD71BHOUdsbYE7JG#`+O22Pvx_`b!yotBFN?GZpPv4Urf=^u-t~9&n zd!p}ws+TI(9}t@sSjez0sRfDL;VL_Fb{CPacx`h9LQXU?!{8~>cbNW}4L)V7wKK^n~ z(;3?ok9!7<@HFr0@MHKnw)@7^Hm%>!Pg>ZdMO@-j%Y&YUoi_|=8hz8h!>c{Yrz4-X z-?)AEF#p!y{oIo}-8rj`S$ghms7wDN&gSSC)aa#fH6Hh}PS%*?m!8_yEpN9YVr)X0 zv56@e!>6tfwe8pV{qTTA%UZANA0Pazy7T$_&OJN}Ol%TR(6ey?Rr%<7;n9yq3>i8^ zwq$?vXYS_Sn-}ldF?873l6_t*D|OqeTjiN`?sRsv+u3e)$i8bEu1sn%V}af4pa#Vv zUR=4SpLAj4@QqJ?l3kqGqu}vT)>kbL-=4erb)jmFYq#0r-1u_nmWRJYPw8296x z<6}DZ;pnW8Id!6r_!e07Lt@aO(&`ybv9X>Gdg$fHZ!9vv_an`> zbSiiM{f&o<-Xvx|9N~L2aafIt&tJLhsN|z8zW3s=t?y6$HsXcxneJDWefwvtn6x(5 zEtN>SXv;@!tgEG3->$gi2Wn$o32QwHwXv?eb?anZfoVfnq^ZyIsFPpAWjt4@-vhW<1}Hbu3GL(S+(si5ItErpEgI8p6h7$zF%QpKyn%+5|k zEzBMvU>}2X{1gNYP-%o_g==`i)p9#bbHD`+O-WZV)WS4H-Yc@{<6Hxb)au|BoUW{w z#4WvQ?aBSQaAUZ<1~}05}8AgA3pyxB{rkcm`gASKtl!6HtU@fSSkxzyuTog@GBc0L4K`U<2?b zIEw=PzdCJV@*RQkXG6a>XVXVO8IAqWJaARI)20bnAS3}%6O;8(C7 zYz8~P34rg{vdiEmcm>`9JgdZ%Kpz+aBVYmw0#h&({0L@)pTJQR`cvR4xCI`7XW%(_ z30?u3F`^kCyz_-l8iS^wC1?-2fN&56`h%fh4443>fH`0(NCeH%iE0bFgWjMY7z~Dh z(I5^?1XICGuoSEWzk(#N7aRab!6|SFTn0D6eee{#1~Sa~82}St0g3}PZ~#ug1Jnc! zL30oZqCr2<9}ED4!4NPE3f(c+Im;-Ksdw@PUP!2c%A5a_A1@(Y0XaE`l ze-Ho~gCNig^acF@P1Uakx50ft^)nt%`^L9C_?LA+iR%;k=qE@K>}apg^&t4Z1I?1t zkhKh;J_z+CsBa+j34X#c^$C6h)CWid)b>-GPi?)>#tUt`(3Y1$+xwxm`~=QZ8-5W` z+kFdAUq@(zg|@dQ?5M461*nasww2maq3xW8V`?+!0Nu8d+C-rZypLmQ`EkM%i4M=LMK?2wewt^IJ5ZIyRryd4P!cgzR4^Yn{ z1W=FSJ3u{&O@Mk3#{l&lsQXLOvj!wJy17O-(!NJ)ISEV#ogq1D1(LuHa15LUFF+C0 z+|&}dg8WD|5blK5pf`@c4k_o9o}gzWNd?9YtGh0&xHw~&KRv3gU`ptr3TF)H-ZakA z#{Y^B6sQ^s#jTVT#$)`!*wf?uQ4Z9P$30rL;Ejy5K^ge1B(iK6ZB3e0wggyAvhsWdU0E`We3W~g#z_PTqsa)J}&en03j~fuCot#Z_)79 z58k5Ttsi+);>NQAw>)`|^tt8SulJ4;0gvw}5#&*htm8cu6}*V{1#kjMypwV5z2KemdNxn57M#~V;k9HI zUc-mil38^s;Nmi*L`E0p-m$`M(M{aUEa{jyJz_KI*~iTEKnHCUcU+p23e5|UnOZ2? z)08OfX|jvT*DEDETDn#pGRdHDkFf8+CdkMY*(p$N z$&QF=e1Z3bW6VtBsy;;`HW_-O zUKPhIs^$d~+Yv3e6p7Q`*Di=|h4LGhhSw26G$kprxzTp5X>#b6A>u2vlocWIiCjJO z=uZkJvMJwlfR0V`B8zow3Jr216%zUSxn-=5&4j8Gb!=Q~8(ZX-pC!6CD6*{FB~FV3 zsmHeNPYN!&LI*bmsXC`qa+!+Jht?qTev>U{m%T7poJvpGO3MvZj2e@jIM=2(y2gdT z12T=vU|A_2l#fl*rf4=B0FOHWfWuEVr~iGdxo)(`{2E z#SJ654&L-g1;26EKWu(T$7a6g%i_u5Ju8S)c%#WC>)b+bZj)*)1jlQ8+nP2s9m#Lp z6~?r$R4TXf)OJL2DIwR;8-czS3q8SIRh);J7Ivj`^KI|5jGlH9U1ytiq0Wsou zI}|EkR&d0}uc!MH9h<7d3)o8C92s21Hn-Fk^RH`L*0Kd&u-4WVgL)3u@%=-yKkcO9 zDf>;bCv75s;zgT3#9=$tSlOfpUg; zVW{qPx9c5u6mtIm+Pe~{s;c$>V|D^jaYB&mfCw^NCXq=%W(7nMM?{$inL$Mnr_9na zhb%KSH8soBv@8=f)11mFGxKF-=F9h9rfFtn4&DFnJNI6_m+(EUx7L5Xwb*z4?mqiF z-yY6)_PqD`wqbg%jJ)J-9MA0m|MF-^`ZKJ*^^($Mn96l_xxU7}Q+>YlmAcN5>%!m! ze|`O{FMHx{s2*$WTI+u%^t}K>|1VDcL^l0sjgr4sMmYA?6SVJG-rYX8BZI^rXJex0 z*SjxaHYAsWK{kDiLs~q-TGo5CMu|=8`-bv{)SAL3ao(>jCU<(38 z#_>zbp|3>@b4+c^>~Lew@_=>qDDAs{RgYrjuL%z@JEqi0`aB@5a6{$)O{b?L8_I#N zcy5j~j(I}NSCI`p`P@#!qZ$fX6*VhLO1j~buL(UAWgM!6DDA8^FE#C-9IY>3`jjv1 z{9JTHrO8tr5@Q_JRhNvI{iP?SX{q0|v5uAZY8byyv#?lWKNE%xu1ATVxUC-Lh{`|FB;yoZsrjxRWw1}c zKw}=MsT;~USs69;INPLL94IxNVOq?huh-*RogR{G9D|gbhDybll7)keIi)sgsD_(d zzJKsHC2>;?V{TFMe2TH}@PPOs4b@lM&B})8>p6XH+)=xFh?E4w5L5fFA7UKhQ@yth zkzS=C*R+JTsm5F$q0aRvEzzeQ*MV*jYDx5(Jxb9 z&*wm7ZT{9MbTVx5r7U4gIb4dT*TcKMZM;dVExD>~p4RM2RtFzi&-F zij+KkKI2w(wkP`9#jx%;*Ktm%zU**?Zt_p1#^ElqMwv0?LXbl}%DE9K#yF(c zqug(g^*jqWewP6|r#5ks5R*-u;(X^2_O|NAan-Jk3EbGa=5e&~aikeClgzNn-yFYj z`MTu}4s zzShX#($|S{Ys5^lMxBifes-g(JhcsG#oHjS&<0JbY;cpuK8R|g=PI}-q?IjPf^E?% z#uo3D+TznOtx(asHHycy#?k!Nuo$uw#!BUwh2Rv&W@Edo-KW3A-&EaJ-cR4!3u}nWm1&Q#s;t zPe;@hQ906t&Txr#LXTP}Ocw7RPXZ?Oz^BeGaE*4sp+PPAin2Ucd^pV&cg)?8>F0)KW883HtQ+4`o_9PERA zYF{k%^F?6~KkT&e$2=#hU-t0F2VVZD>+KKUk-e!tqYr%3`e0jaAAIH&h$j*P(Ysj? zl4FA4kRF6rZGv$^9gL9ZV5|uW!Hm=p*dTv-ezs$=0&5sNKU1#-kb z4ohR>@MTRL{LJEUDJUKmtrHa0W#3dLAiyyZNBSqi(me?$dMDv>U=ltGOG3z0V$?wH zW=KZi#AFV(X+-tm-~gQRuvvhbC?G7!KEh;W#mEI12lXKw$C+3>!OwJ7h*;Lg$fqmRRUC z5*EWoB06g%b~=s1tS+Mv)qfObu%C&cY3MmF4OdFi@J(47taC>ry?Qi`IH$wHD;Kx2%or~sO@=zX^hrR>z@IB#`lZWBM^06SY09&F8p^h!Y?M~xxYuGpp zvMa{YtYYL%F2?8XB}f}y0;}LsJegREU&?8>EyG$>8R9yX6az19O7T~EoqQ^qGWiCYf zCX4WRt3~*5_#$i%Sd5jb=aAj!Ik*KpkFouq$4QGN%wtQCF<=QkZv6uMnk_{@;ZjVk zdJ#>cmLaxynWA(!&t*BL4p@%YQkKIlcR6NRt-vPr3d9Aiz|Uzb(6@92hP7FV?XD}) z%x5J!+pL0X`6}#ez8ZI0tVXflYAm0!8V9UiR#Z5e=D&>KsV`$!$2BKrl+m4$R z+xfx%yQpjbE`DmY17COEf$pw5@V6m5U|+NYaYRn_4lH)6!!v<(7(Aj5XGhiHZbluh zTkk}k=T2NAZrSZZUANunp0XQumhWNbl=rY9at}Vp-@_eT?;|_&eYi*Oh3KBXF@7&L zTkOMzf%_B%&2zi&Xa3j^7t;f{+T;L!Xn6puIv&8h9tU9VbpR*E9bh>+fPgLs@gY%O zaS#!c4q`*QLkKE5gdt51Pn<5`EuRaRn|A@Z(SOI8Uf;s5`XBHN{3rSj z`zJcJ`4?OqzQZo{cd!fp4qM$XAvf(3T&gc&hX41tGVFWoPX8Vw%fCl)uggdqdKvph zUO{Hw6-9+sQ`aAGGVKSX`CdhB*;V{tdksUJuVZ}hbvzq(9k=7I)^j)pU1D*X?7Ei zHa9V*%T3%3xrxezn@~CYh6@S5VKbGqilWvRJKn-6k6XCueG5Ucx9~Z!r1lmLr{2cm z+S>^8yMxXlckpZ29aK-ggV+vt@ut&VoC&`R=jyxs8u)ia#A|RWRRi@@jdUaLt$WM0 z^i(y<9$j&Hn+iu3d$wYHbS^W+<*Rc^uavvBbS_!T<)^wj7dxjky>%`t#TB4)Nl%s^ zm+D+i6_>ZUHa^Z)awi&1&MJ*2XRHZrQ^JfeCoBj{!is1@*lM^rj$d{X_myGlmB*WK zc3a#3)?dlWoR%j4+AH&WS5$$gqshvh_O2Y;qrH+tcf!r4Yqu{;uwuk@|C)<{nqMtV zhimU?MJfUmL5X-oC=`Y4cHW}x(2K1+FKzBgQ0TTgTUm1=-A?CLMo`+7Ln|tAE8#BY z67Fc8$bX?~r7FDl*B9j@T2tQD((Ye3Cn2`;0;b&5)Y8nt+``n%T;qxJqpFq7h{CSy z+7|oYADYNF&Tvd&VPRrwVr6M+p?%fbWnA$tX1e^PU=j}u%4CX*=<@YcVsCC~ZYt)1 zu}ARWgaYo_R{Zo`U@`-y%`MC*RIbN+aVz5ODQ8^ui*tGzoZJz9NQLto+rG`cyG-2- zGgC8)-bBnBrr1J11!_qW%}Slz>~@|0m^)h;Qxze{T5=30@tK)6|4)98P$jv1h+ay+ zjcL6pri)CJOe7Dedp^H2 zA$2DYtlf9{6lG$gyfNjj!QXu^x;HkAk{3zn5BikJg9ye);hS49MoTlkH%pw?bf@6t zjnJ&rm4--4QQo%p?Wy<~zDPEo94ZzE?ETXdPTXGah9s6r7V`^~hbD_7mSz?u6y+_^aG#54h_zNj6G2d&IjBS`Xy`tWu2D6?L{1u~Q z3MGK1zK`ivb+)b9&U#92$On`flNnHB_lFO1MKM z?eva)M^CG$#6m^IAX?kOr{}@!13eFlQsgi9t=*S)QtQk3-;Z+Ee(S+KLOr!{N`aKD zEizP{zVKr~(^IYHU4!jNm!)ry-L>|ecq_WEYTd#sB}Mz(N>f5tqxm0_!0#-?mZtX$ zj6Uh|O3y#O^7?NB^KD;W*aW>?-uhfk+LOG|iniQeK#0#52+1-V3Ca8K5|X3$6Vft& zOh`xJTS7Wsa=wOSY|(-u{g6JiCF{#-iDaT=+EN3g(YB#2)pbkSWwa&BRnu-mdpd22 zUy7^jw-CRF&hK6O{@MMj>yB$_`m!kZxX-rx4@+q<^z-Q6GTt~>C>y>NWVE8%w_5(}~_N-Ilqt5gGXa|^O^ zRKrRuvPlmo^d?*`o}5-=fBU$Ja*ev3bJ~5by{^7J`)OW_Jf&!fEzOeH(lm+PXc{HZ zD1k-^G)kaR0*w-Alt7~d8YR#ufkp{5N}y2!k1Bx&jsIm_ZF>6H>E)iCY@c1i@xS|T zo1;jxs7LeT*z_9dGKS`*GTEw()rFUsLdZCF79sNnPZRQS(qcl!{L2X$)2}6DOuvzk z??tu|G6%4Wkg!h?hGTtmy2Z$NSjOFA>&MG7eW)ELw^ZWbtq0nj8M&le(qb@oAO|x^ zbTX`$$JqPIyFV&n3h&d5NIwo&(Jiz-gm?%i#4Xp7?lH;nQDfo~;*$oXC^VU?h#)<=&i}KB!*kZ)i zmqC3Vl)6jiZy%(f%1`G{AD*4gP3upEC-bn94t;nM_g_h$EbkbGCv(_;B|PtX;Yl0u zSHe@%f1_!XK%)d2CD15=MhP@Zpiu&kDgo)M>H8rvrj|aOjH6}UUiv$-UN39(GCr2^ ztc=N}UnOIF8Mn)rUe@wu>@NMcwuFq|WsEQ5@{WY`6FU(Ogd-t?YUvxQ2%%2GdRb1Mxbs zk=R7Y{WoZDA+{245^oXPh_?y3e^-Ycbl2%CIA`!4g=cl)Q)WZOmiC+S7z2od_N({#^1WN+F~fM(J0uEeb zwm$wQf4Hk9=;PJfhHUYC;vA(_F{D2HwZ8l{rI$y^e`j@3Bvaa@Y{_zHOKy|d(x{J&)$_4y6s z6){Nqr8<`OQR)$S7Rk$!pX9v?y}8adjH~hbe_sM2nL#O~<14aqDpe^tMLfqNCp)ET zQV|apO^Zm5@Eo3!&{d@nk}`@(B(1CNfYHo{IbgI@*#ygD!^Ri$tWfPKqnQPnMFpjK z6&YpulXQXoSAyyr;&ty~cpxe>>_eDGK5w<8RD0ZNc4bg@MpZ^74?f9>ugWP_kx)Q8 zJ}R_pjZaKqKtQB-fJeWWXg?3XK0c8ief)hQJbZlpBKyPy^y77OtzMAXm4Ei}v&J*} z;^D Date: Thu, 6 Aug 2026 12:29:45 +0900 Subject: [PATCH 35/68] =?UTF-8?q?FEAT=20:=20=EC=A4=91=EB=B3=B5=20=EC=88=98?= =?UTF-8?q?=EA=B8=89=20=EB=B0=B0=ED=83=80=20=EA=B7=B8=EB=A3=B9=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=B4=9D=EC=95=A1=20=EA=B3=BC=EB=8C=80=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84=20=ED=95=B4=EC=86=8C=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../carecode/domain/policy/entity/Policy.java | 7 ++++ .../service/PolicyInitializationService.java | 4 ++ .../RegionalBenefitComparisonService.java | 40 +++++++++++++++---- .../migration/V14__policy_exclusion_group.sql | 7 ++++ .../RegionalBenefitComparisonServiceTest.java | 35 ++++++++++++++++ 5 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 src/main/resources/db/migration/V14__policy_exclusion_group.sql diff --git a/src/main/java/com/carecode/domain/policy/entity/Policy.java b/src/main/java/com/carecode/domain/policy/entity/Policy.java index 200e2ddb..082ec694 100644 --- a/src/main/java/com/carecode/domain/policy/entity/Policy.java +++ b/src/main/java/com/carecode/domain/policy/entity/Policy.java @@ -68,6 +68,13 @@ public class Policy { @Column(name = "max_payment_months") private Integer maxPaymentMonths; + /** + * 중복 수급 불가 그룹. 같은 그룹의 정책은 동시에 받을 수 없다. + * null 이면 다른 정책과 함께 받을 수 있다 — 대부분은 여기 해당한다. + */ + @Column(name = "exclusion_group", length = 60) + private String exclusionGroup; + /** 수기 검증 시각. null 이면 자동 수집된 추정치이며 확정 금액으로 노출하면 안 된다. */ @Column(name = "verified_at") private LocalDateTime verifiedAt; diff --git a/src/main/java/com/carecode/domain/policy/service/PolicyInitializationService.java b/src/main/java/com/carecode/domain/policy/service/PolicyInitializationService.java index c7c74e1e..4d6c4007 100644 --- a/src/main/java/com/carecode/domain/policy/service/PolicyInitializationService.java +++ b/src/main/java/com/carecode/domain/policy/service/PolicyInitializationService.java @@ -190,6 +190,7 @@ private void createChildcareAllowances(PolicyCategory category) { .policyType("현금지원") .targetAgeMin(0) .targetAgeMax(11) + .exclusionGroup("INFANT_CARE_0") .targetRegion("전국") .benefitAmount(700000) .benefitType("월지급") @@ -208,6 +209,7 @@ private void createChildcareAllowances(PolicyCategory category) { .policyType("현금지원") .targetAgeMin(12) .targetAgeMax(23) + .exclusionGroup("INFANT_CARE_1") .targetRegion("전국") .benefitAmount(350000) .benefitType("월지급") @@ -226,6 +228,7 @@ private void createChildcareAllowances(PolicyCategory category) { .policyType("이용료지원") .targetAgeMin(0) .targetAgeMax(71) + .exclusionGroup("INFANT_CARE_0") .targetRegion("전국") .benefitAmount(514000) .benefitType("월지원") @@ -389,6 +392,7 @@ private void createEducationSupport(PolicyCategory category) { .policyType("교육지원") .targetAgeMin(36) .targetAgeMax(71) + .exclusionGroup("PRESCHOOL_EDU") .targetRegion("전국") .benefitAmount(280000) .benefitType("월지원") diff --git a/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java b/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java index 9191ab8d..f35f3e1e 100644 --- a/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java +++ b/src/main/java/com/carecode/domain/policy/service/RegionalBenefitComparisonService.java @@ -167,6 +167,11 @@ private RegionSummary summarize(List policies, int ageMonths, int horizo int unknownAmount = 0; List contributions = new ArrayList<>(); + // 부모급여와 양육수당처럼 동시 수급이 불가한 정책은 가장 큰 것 하나만 남긴다. + // 이 처리가 없으면 총액이 실제 수령액보다 크게 부풀려진다. + Map bestByGroup = new LinkedHashMap<>(); + Map bestContribution = new LinkedHashMap<>(); + for (Policy policy : policies) { BenefitProjectionCalculator.Projection projection = calculator.project(policy, ageMonths, horizon); if (projection.eligibleMonths() == 0) { @@ -181,17 +186,39 @@ private RegionSummary summarize(List policies, int ageMonths, int horizo unknownAmount++; continue; } + RegionalBenefitResponse.Contribution contribution = + RegionalBenefitResponse.Contribution.builder() + .title(policy.getTitle()) + .amount(projection.amount()) + .paymentType(projection.paymentType().name()) + .build(); + + String group = policy.getExclusionGroup(); + if (group != null && !group.isBlank()) { + // 같은 그룹에서 더 큰 금액이 나오면 교체한다. 합산하지 않는다. + Long current = bestByGroup.get(group); + if (current == null || projection.amount() > current) { + bestByGroup.put(group, projection.amount()); + bestContribution.put(group, contribution); + } + continue; + } + total += projection.amount(); cash++; if (policy.getVerifiedAt() != null) { verified++; } - contributions.add(RegionalBenefitResponse.Contribution.builder() - .title(policy.getTitle()) - .amount(projection.amount()) - .paymentType(projection.paymentType().name()) - .build()); + contributions.add(contribution); } + + // 그룹별 대표 정책만 합계에 넣는다. + for (Long amount : bestByGroup.values()) { + total += amount; + cash++; + } + contributions.addAll(bestContribution.values()); + return new RegionSummary(total, cash, nonCash, verified, unknownAmount, contributions); } @@ -238,8 +265,7 @@ private List buildDisclaimers(String baseRegion, long conditionalCount) List notes = new ArrayList<>(); notes.add("수집된 정책 기준 추정치이며 실제 수령액과 다를 수 있습니다."); // 중복 수급이 불가능한 정책들이 함께 더해질 수 있어, 총액보다 지역 간 차액이 신뢰도가 높다. - notes.add("총액은 모든 정책을 단순 합산한 값입니다. 상호 배타적인 정책이 포함될 수 있으므로 " - + "지역 간 '차액' 을 기준으로 보세요."); + notes.add("중복 수급이 불가한 정책은 같은 그룹에서 가장 큰 금액 하나만 합산했습니다."); if (conditionalCount > 0) { notes.add(String.format("소득 조건이 걸린 정책 %d건은 소득 미입력 상태로 포함했습니다. " + "소득을 입력하면 정확해집니다.", conditionalCount)); diff --git a/src/main/resources/db/migration/V14__policy_exclusion_group.sql b/src/main/resources/db/migration/V14__policy_exclusion_group.sql new file mode 100644 index 00000000..6a0adc2c --- /dev/null +++ b/src/main/resources/db/migration/V14__policy_exclusion_group.sql @@ -0,0 +1,7 @@ +-- 중복 수급 배타 그룹. +-- 부모급여와 양육수당은 동시에 받을 수 없는데 지금은 단순 합산돼 총액이 부풀려진다. +-- 같은 그룹에서는 금액이 가장 큰 것 하나만 계산해야 실제 수령액에 가까워진다. +ALTER TABLE TBL_POLICIES + ADD COLUMN EXCLUSION_GROUP VARCHAR(60) NULL COMMENT '중복 수급 불가 그룹 - NULL 이면 다른 정책과 함께 받을 수 있음'; + +CREATE INDEX IDX_POLICIES_EXCLUSION ON TBL_POLICIES (EXCLUSION_GROUP, IS_ACTIVE); diff --git a/src/test/java/com/carecode/domain/policy/service/RegionalBenefitComparisonServiceTest.java b/src/test/java/com/carecode/domain/policy/service/RegionalBenefitComparisonServiceTest.java index f4688c61..11f81cf4 100644 --- a/src/test/java/com/carecode/domain/policy/service/RegionalBenefitComparisonServiceTest.java +++ b/src/test/java/com/carecode/domain/policy/service/RegionalBenefitComparisonServiceTest.java @@ -156,6 +156,41 @@ void warnsWhenAmountsAreIncomplete() { .anyMatch(d -> d.contains("금액이 확인된 정책만 합산")); } + @Test + @DisplayName("중복 수급 불가 정책은 가장 큰 것 하나만 합산한다") + void keepsOnlyBestInExclusionGroup() { + givenChildAgedMonths(0); + Policy parentAllowance = policy("부모급여", "A시", 0, 11, 1000000, "월지급"); + parentAllowance.setExclusionGroup("INFANT_CARE_0"); + Policy childcareFee = policy("보육료지원", "A시", 0, 11, 500000, "월지급"); + childcareFee.setExclusionGroup("INFANT_CARE_0"); + givenPolicies(parentAllowance, childcareFee); + givenRegions("A시"); + + RegionalBenefitResponse a = byRegion(service.compare(null, 1, 10), "A시"); + + // 둘 다 더하면 1,800만원이 되지만 실제로는 하나만 받는다 + assertThat(a.getTotalAmount()).isEqualTo(12_000_000); + assertThat(a.getCashPolicyCount()).isEqualTo(1); + } + + @Test + @DisplayName("배타 그룹이 다르면 각각 합산한다") + void addsAcrossDifferentGroups() { + givenChildAgedMonths(0); + Policy a1 = policy("부모급여", "A시", 0, 11, 1000000, "월지급"); + a1.setExclusionGroup("INFANT_CARE_0"); + Policy a2 = policy("누리과정", "A시", 0, 11, 300000, "월지급"); + a2.setExclusionGroup("PRESCHOOL_EDU"); + givenPolicies(a1, a2); + givenRegions("A시"); + + RegionalBenefitResponse a = byRegion(service.compare(null, 1, 10), "A시"); + + assertThat(a.getTotalAmount()).isEqualTo(12_000_000 + 3_600_000); + assertThat(a.getCashPolicyCount()).isEqualTo(2); + } + @Test @DisplayName("금액 기여가 큰 정책을 근거로 노출한다") void exposesTopContributors() { From 8eb8a95e903fc9be1edd947b3573a8f5ec8c65bb Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 12:29:45 +0900 Subject: [PATCH 36/68] =?UTF-8?q?FEAT=20:=20=EC=95=8C=EB=A6=BC=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EC=A7=91=EA=B3=84=20=EB=B0=8F=20=EC=9E=AC=EB=B0=A9?= =?UTF-8?q?=EB=AC=B8=20=EC=A0=84=ED=99=98=20=ED=8D=BC=EB=84=90=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/analytics/AnalyticsService.java | 21 +++- .../carecode/core/analytics/EventType.java | 6 ++ .../controller/AdminAnalyticsController.java | 10 ++ .../NotificationLinkController.java | 99 +++++++++++++++++++ 4 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/carecode/domain/notification/controller/NotificationLinkController.java diff --git a/src/main/java/com/carecode/core/analytics/AnalyticsService.java b/src/main/java/com/carecode/core/analytics/AnalyticsService.java index de7eacbc..6f8efeb4 100644 --- a/src/main/java/com/carecode/core/analytics/AnalyticsService.java +++ b/src/main/java/com/carecode/core/analytics/AnalyticsService.java @@ -27,6 +27,12 @@ public class AnalyticsService { new StepDef(EventType.MISSED_BENEFIT_VIEWED, "놓친 지원금 확인"), new StepDef(EventType.BENEFIT_LINK_CLICKED, "신청 링크 클릭")); + /** 알림이 실제로 사람을 돌아오게 하는지. 리텐션의 핵심 지표다. */ + private static final List NOTIFICATION_FUNNEL = List.of( + new StepDef(EventType.NOTIFICATION_SENT, "알림 발송"), + new StepDef(EventType.NOTIFICATION_CLICKED, "알림 클릭"), + new StepDef(EventType.BENEFIT_LINK_CLICKED, "신청 링크 클릭")); + private static final int MAX_COHORT_DAYS = 60; private final UserEventRepository eventRepository; @@ -35,15 +41,24 @@ private record StepDef(EventType type, String label) { } public FunnelResponse funnel(LocalDate from, LocalDate to) { + return buildFunnel(FUNNEL, from, to); + } + + /** 알림 → 재방문 전환. 이 값이 낮으면 알림 내용이나 시점을 바꿔야 한다. */ + public FunnelResponse notificationFunnel(LocalDate from, LocalDate to) { + return buildFunnel(NOTIFICATION_FUNNEL, from, to); + } + + private FunnelResponse buildFunnel(List definition, LocalDate from, LocalDate to) { List steps = new ArrayList<>(); long previous = 0; - for (int i = 0; i < FUNNEL.size(); i++) { - StepDef def = FUNNEL.get(i); + for (int i = 0; i < definition.size(); i++) { + StepDef def = definition.get(i); // 두 번째 단계부터는 앞 단계를 거친 사용자만 센다. 그래야 전환율이 의미를 갖는다. long users = i == 0 ? eventRepository.countDistinctUsers(def.type(), from, to) - : eventRepository.countConverted(FUNNEL.get(i - 1).type(), def.type(), from, to); + : eventRepository.countConverted(definition.get(i - 1).type(), def.type(), from, to); steps.add(FunnelResponse.Step.builder() .event(def.type().name()) diff --git a/src/main/java/com/carecode/core/analytics/EventType.java b/src/main/java/com/carecode/core/analytics/EventType.java index 1613d85f..f6975b3d 100644 --- a/src/main/java/com/carecode/core/analytics/EventType.java +++ b/src/main/java/com/carecode/core/analytics/EventType.java @@ -20,6 +20,12 @@ public enum EventType { ADMISSION_FORECAST_VIEWED, FACILITY_POPULARITY_VIEWED, + // 알림 효과 — 이 앱이 "한 번 보고 끝" 을 벗어났는지 판단하는 지표 + NOTIFICATION_SENT, + NOTIFICATION_CLICKED, + BENEFIT_AMOUNT_REPORTED, + WAITLIST_REGISTERED, + // 유지 APP_OPENED, BOOKING_CREATED, diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminAnalyticsController.java b/src/main/java/com/carecode/domain/admin/controller/AdminAnalyticsController.java index 1c4984ff..f59117ce 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminAnalyticsController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminAnalyticsController.java @@ -40,6 +40,16 @@ public ResponseEntity funnel( return ResponseEntity.ok(analyticsService.funnel(start, end)); } + @GetMapping("/notification-funnel") + @Operation(summary = "알림 효과 퍼널", description = "발송 → 클릭 → 신청 전환율") + public ResponseEntity notificationFunnel( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { + LocalDate end = to != null ? to : LocalDate.now(); + LocalDate start = from != null ? from : end.minusDays(DEFAULT_RANGE_DAYS); + return ResponseEntity.ok(analyticsService.notificationFunnel(start, end)); + } + @GetMapping("/retention") @Operation(summary = "코호트 리텐션 조회", description = "가입일 기준 D1/D7/D30 잔존율") public ResponseEntity retention( diff --git a/src/main/java/com/carecode/domain/notification/controller/NotificationLinkController.java b/src/main/java/com/carecode/domain/notification/controller/NotificationLinkController.java new file mode 100644 index 00000000..38685229 --- /dev/null +++ b/src/main/java/com/carecode/domain/notification/controller/NotificationLinkController.java @@ -0,0 +1,99 @@ +package com.carecode.domain.notification.controller; + +import com.carecode.core.analytics.EventLogger; +import com.carecode.core.analytics.EventType; +import com.carecode.core.exception.CareServiceException; +import com.carecode.core.security.CurrentUserFacade; +import com.carecode.domain.notification.entity.Notification; +import com.carecode.domain.notification.repository.NotificationRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Set; + +/** + * 알림을 거쳐 들어온 재방문을 집계한다. + * 알림이 실제로 사람을 돌아오게 하는지는 이 전환율로만 알 수 있다. + */ +@Slf4j +@RestController +@RequestMapping("/notifications") +@RequiredArgsConstructor +@Tag(name = "알림", description = "알림 API") +public class NotificationLinkController { + + /** 열어줄 수 있는 화면. 임의 경로를 허용하면 오픈 리다이렉트가 된다. */ + private static final Set ALLOWED_TARGETS = Set.of( + "policies", "policy", "facilities", "facility", "children", "health", "notifications"); + + private final NotificationRepository notificationRepository; + private final EventLogger eventLogger; + private final CurrentUserFacade currentUserFacade; + + @Value("${app.notification.deep-link-base:carecode://}") + private String deepLinkBase; + + @GetMapping("/{notificationId}/open") + @Operation(summary = "알림 열기", description = "클릭을 집계한 뒤 해당 화면으로 이동") + public ResponseEntity open( + @Parameter(description = "알림 ID", required = true) @PathVariable Long notificationId, + @Parameter(description = "이동할 화면 (policies, facilities 등)") @RequestParam(required = false) String target, + @Parameter(description = "대상 식별자") @RequestParam(required = false) String targetId) { + + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> new CareServiceException("알림을 찾을 수 없습니다: " + notificationId)); + + Long userId = currentUserIdOrNull(); + // 본인 알림만 열람 표시를 남긴다. 남의 알림 ID 로 읽음 처리되면 안 된다. + if (userId != null && notification.getUser() != null + && userId.equals(notification.getUser().getId())) { + notification.setIsRead(true); + notificationRepository.save(notification); + } + + eventLogger.log(EventType.NOTIFICATION_CLICKED, userId, + String.valueOf(notificationId), notification.getNotificationType().name()); + + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(buildDeepLink(target, targetId))) + .build(); + } + + /** 허용 목록에 없는 화면은 홈으로 보낸다. */ + private String buildDeepLink(String target, String targetId) { + if (target == null || !ALLOWED_TARGETS.contains(target)) { + return deepLinkBase; + } + StringBuilder link = new StringBuilder(deepLinkBase).append(target); + if (targetId != null && !targetId.isBlank()) { + link.append('/').append(URI.create("").resolve(encode(targetId))); + } + return link.toString(); + } + + private String encode(String value) { + return java.net.URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + /** 알림 클릭은 비로그인 상태에서도 발생할 수 있어 인증 실패로 막지 않는다. */ + private Long currentUserIdOrNull() { + try { + return currentUserFacade.requireCurrentUserDbId(); + } catch (Exception e) { + return null; + } + } +} From e7236aa8371b0a59cff5ee70b4a17668ef86ec23 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 12:29:45 +0900 Subject: [PATCH 37/68] =?UTF-8?q?FEAT=20:=20=EC=8B=A4=EC=88=98=EB=A0=B9?= =?UTF-8?q?=EC=95=A1=20=EC=A0=9C=EB=B3=B4=20=EC=9A=94=EC=B2=AD=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scheduler/PublicDataSyncScheduler.java | 9 + .../service/FacilityWaitlistService.java | 4 + .../service/BenefitAmountReportService.java | 4 + .../service/BenefitReportSolicitor.java | 159 ++++++++++++++++++ .../policy/service/PolicyChangeNotifier.java | 6 + src/main/resources/application.yml | 11 ++ .../BenefitAmountReportServiceTest.java | 3 +- 7 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/carecode/domain/policy/service/BenefitReportSolicitor.java diff --git a/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java index 81210c02..a34e3403 100644 --- a/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java +++ b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java @@ -6,6 +6,7 @@ import com.carecode.core.client.sync.PediatricHospitalSyncService; import com.carecode.core.client.sync.SyncResult; import com.carecode.core.geocoding.FacilityGeocodingService; +import com.carecode.domain.policy.service.BenefitReportSolicitor; import com.carecode.domain.policy.service.PolicyChangeNotifier; import com.carecode.core.ops.OperationalAlerter; import lombok.RequiredArgsConstructor; @@ -25,6 +26,7 @@ public class PublicDataSyncScheduler { private final PediatricHospitalSyncService hospitalSyncService; private final FacilityGeocodingService geocodingService; private final PolicyChangeNotifier policyChangeNotifier; + private final BenefitReportSolicitor reportSolicitor; private final OperationalAlerter alerter; /** 전국 어린이집 동기화. */ @@ -62,6 +64,13 @@ public void notifyPolicyChanges() { log.info("정책 변경 알림 - {}", result); } + /** 실수령액 제보 요청. 매일 보내면 소음이라 주 1회만 묻는다. */ + @Scheduled(cron = "${app.scheduler.public-data.report-ask-cron:0 0 10 * * WED}", zone = "Asia/Seoul") + public void solicitBenefitReports() { + var result = reportSolicitor.solicitReports(); + log.info("실수령액 제보 요청 - {}", result); + } + /** 좌표 보정. 동기화가 끝난 뒤 돌아야 새로 들어온 시설이 대상에 포함된다. */ @Scheduled(cron = "${app.scheduler.public-data.geocoding-cron:0 0 5 * * *}", zone = "Asia/Seoul") public void fillMissingCoordinates() { diff --git a/src/main/java/com/carecode/domain/careFacility/service/FacilityWaitlistService.java b/src/main/java/com/carecode/domain/careFacility/service/FacilityWaitlistService.java index b25140a0..d8e51ab1 100644 --- a/src/main/java/com/carecode/domain/careFacility/service/FacilityWaitlistService.java +++ b/src/main/java/com/carecode/domain/careFacility/service/FacilityWaitlistService.java @@ -1,5 +1,7 @@ package com.carecode.domain.careFacility.service; +import com.carecode.core.analytics.EventLogger; +import com.carecode.core.analytics.EventType; import com.carecode.core.exception.CareServiceException; import com.carecode.core.security.CurrentUserFacade; import com.carecode.domain.careFacility.dto.request.WaitlistRequest; @@ -37,6 +39,7 @@ public class FacilityWaitlistService { private final CareFacilityRepository facilityRepository; private final ChildRepository childRepository; private final CurrentUserFacade currentUserFacade; + private final EventLogger eventLogger; @Transactional public Long register(Long facilityId, WaitlistRequest request) { @@ -61,6 +64,7 @@ public Long register(Long facilityId, WaitlistRequest request) { .classAge(monthsOld(child)) .note(request.getNote()) .build()); + eventLogger.log(EventType.WAITLIST_REGISTERED, user.getId(), String.valueOf(facilityId)); return saved.getId(); } diff --git a/src/main/java/com/carecode/domain/policy/service/BenefitAmountReportService.java b/src/main/java/com/carecode/domain/policy/service/BenefitAmountReportService.java index bdbb7706..e11fc693 100644 --- a/src/main/java/com/carecode/domain/policy/service/BenefitAmountReportService.java +++ b/src/main/java/com/carecode/domain/policy/service/BenefitAmountReportService.java @@ -1,5 +1,7 @@ package com.carecode.domain.policy.service; +import com.carecode.core.analytics.EventLogger; +import com.carecode.core.analytics.EventType; import com.carecode.core.exception.CareServiceException; import com.carecode.core.security.CurrentUserFacade; import com.carecode.domain.policy.dto.request.BenefitAmountReportRequest; @@ -34,6 +36,7 @@ public class BenefitAmountReportService { private final BenefitAmountReportRepository reportRepository; private final PolicyRepository policyRepository; private final CurrentUserFacade currentUserFacade; + private final EventLogger eventLogger; @Transactional public BenefitAmountConsensusResponse report(Long policyId, BenefitAmountReportRequest request) { @@ -59,6 +62,7 @@ public BenefitAmountConsensusResponse report(Long policyId, BenefitAmountReportR .createdAt(LocalDateTime.now()) .build())); + eventLogger.log(EventType.BENEFIT_AMOUNT_REPORTED, user.getId(), String.valueOf(policyId)); return evaluateConsensus(policy); } diff --git a/src/main/java/com/carecode/domain/policy/service/BenefitReportSolicitor.java b/src/main/java/com/carecode/domain/policy/service/BenefitReportSolicitor.java new file mode 100644 index 00000000..7412eec6 --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/service/BenefitReportSolicitor.java @@ -0,0 +1,159 @@ +package com.carecode.domain.policy.service; + +import com.carecode.core.analytics.EventLogger; +import com.carecode.core.analytics.EventType; +import com.carecode.domain.notification.entity.Notification; +import com.carecode.domain.notification.repository.NotificationRepository; +import com.carecode.domain.notification.sender.NotificationDispatcher; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.repository.BenefitAmountReportRepository; +import com.carecode.domain.policy.repository.PolicyRepository; +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.ChildRepository; +import com.carecode.domain.user.repository.UserRepository; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; + +/** + * 받았을 법한 사람에게 실수령액을 묻는다. + * 아무에게나 물으면 소음이지만, 대상 연령을 막 지난 사람은 방금 받아봤을 가능성이 높다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class BenefitReportSolicitor { + + /** 대상 연령이 지난 뒤 이 기간 안에 있는 사람에게만 묻는다. 너무 오래되면 기억이 흐려진다. */ + @Value("${app.benefit-report.ask-within-months:6}") + private int askWithinMonths; + + /** 한 번에 한 사람에게 보낼 최대 질문 수. 여러 건을 한꺼번에 물으면 아무것도 답하지 않는다. */ + @Value("${app.benefit-report.max-asks-per-user:1}") + private int maxAsksPerUser; + + private final PolicyRepository policyRepository; + private final BenefitAmountReportRepository reportRepository; + private final UserRepository userRepository; + private final ChildRepository childRepository; + private final NotificationRepository notificationRepository; + private final NotificationDispatcher dispatcher; + private final EventLogger eventLogger; + + @Getter + public static class SolicitResult { + private int usersAsked; + private int questionsSent; + + @Override + public String toString() { + return String.format("%d명에게 %d건 질문", usersAsked, questionsSent); + } + } + + @Transactional + public SolicitResult solicitReports() { + SolicitResult result = new SolicitResult(); + + // 금액이 이미 확인된 정책은 물을 이유가 없다. + List unknownAmount = policyRepository.findByIsActiveTrue().stream() + .filter(p -> p.getBenefitAmount() == null || p.getBenefitAmount() <= 0) + .filter(p -> p.getVerifiedAt() == null) + .toList(); + + if (unknownAmount.isEmpty()) { + return result; + } + + for (User user : userRepository.findByIsActiveTrue()) { + List candidates = findRecentlyPassed(user, unknownAmount); + if (candidates.isEmpty()) { + continue; + } + + int asked = 0; + for (Policy policy : candidates) { + if (asked >= maxAsksPerUser) { + break; + } + // 이미 답한 사람에게 다시 묻지 않는다. + if (reportRepository.findByPolicyIdAndUserId(policy.getId(), user.getId()).isPresent()) { + continue; + } + ask(user, policy); + asked++; + result.questionsSent++; + } + if (asked > 0) { + result.usersAsked++; + } + } + + log.info("실수령액 제보 요청 - {}", result); + return result; + } + + /** 아이가 대상 연령을 최근에 지난 정책. 방금 받아봤을 가능성이 높은 구간이다. */ + private List findRecentlyPassed(User user, List policies) { + List children = childRepository.findByUserIdOrderByCreatedAtDesc(user.getId()); + if (children.isEmpty()) { + return List.of(); + } + + LocalDate today = LocalDate.now(); + List matched = new ArrayList<>(); + + for (Policy policy : policies) { + if (policy.getTargetAgeMax() == null || !matchesRegion(policy, user)) { + continue; + } + boolean recentlyPassed = children.stream().anyMatch(child -> { + if (child.getBirthDate() == null) { + return false; + } + long months = ChronoUnit.MONTHS.between(child.getBirthDate(), today); + long sincePassed = months - policy.getTargetAgeMax(); + return sincePassed > 0 && sincePassed <= askWithinMonths; + }); + if (recentlyPassed) { + matched.add(policy); + } + } + return matched; + } + + private boolean matchesRegion(Policy policy, User user) { + String region = policy.getTargetRegion(); + if (region == null || region.isBlank() || region.contains("전국")) { + return true; + } + String address = user.getAddress(); + return address != null && (address.contains(region) || region.contains(address)); + } + + private void ask(User user, Policy policy) { + Notification notification = notificationRepository.save(Notification.builder() + .user(user) + .notificationType(Notification.NotificationType.POLICY) + .title("혹시 이 지원금 받으셨나요?") + .message(String.format( + "'%s' 의 실제 수령액을 알려주시면 같은 지역 부모들에게 정확한 정보가 전달됩니다. " + + "30초면 됩니다.", policy.getTitle())) + .createdAt(LocalDateTime.now()) + .build()); + + dispatcher.dispatchAsync(notification); + eventLogger.log(EventType.NOTIFICATION_SENT, user.getId(), + String.valueOf(notification.getId()), "BENEFIT_REPORT_ASK"); + } +} diff --git a/src/main/java/com/carecode/domain/policy/service/PolicyChangeNotifier.java b/src/main/java/com/carecode/domain/policy/service/PolicyChangeNotifier.java index 9b06b9a3..8444fbcc 100644 --- a/src/main/java/com/carecode/domain/policy/service/PolicyChangeNotifier.java +++ b/src/main/java/com/carecode/domain/policy/service/PolicyChangeNotifier.java @@ -1,5 +1,7 @@ package com.carecode.domain.policy.service; +import com.carecode.core.analytics.EventLogger; +import com.carecode.core.analytics.EventType; import com.carecode.domain.notification.entity.Notification; import com.carecode.domain.notification.repository.NotificationRepository; import com.carecode.domain.notification.sender.NotificationDispatcher; @@ -42,6 +44,7 @@ public class PolicyChangeNotifier { private final UserRepository userRepository; private final NotificationRepository notificationRepository; private final NotificationDispatcher dispatcher; + private final EventLogger eventLogger; @Getter public static class NotifyResult { @@ -100,6 +103,9 @@ private int notify(PolicyChange change) { .build()); dispatcher.dispatchAsync(notification); + // 발송 대비 클릭률이 알림의 효과를 판단하는 유일한 지표다. + eventLogger.log(EventType.NOTIFICATION_SENT, user.getId(), + String.valueOf(notification.getId()), change.getChangeType().name()); sent++; } return sent; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8dd3c572..0a378ec2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -115,6 +115,13 @@ app: secure: ${REFRESH_COOKIE_SECURE:true} same-site: ${REFRESH_COOKIE_SAME_SITE:None} max-age-days: ${REFRESH_COOKIE_MAX_AGE_DAYS:14} + benefit-report: + # 같은 값을 이만큼 받으면 금액을 확정한다 + consensus-threshold: ${BENEFIT_CONSENSUS_THRESHOLD:3} + # 대상 연령이 지난 뒤 이 기간 안에 있는 사람에게만 묻는다 + ask-within-months: ${BENEFIT_ASK_WITHIN_MONTHS:6} + max-asks-per-user: ${BENEFIT_MAX_ASKS_PER_USER:1} + policy-change: batch-size: ${POLICY_CHANGE_BATCH_SIZE:200} max-per-user: ${POLICY_CHANGE_MAX_PER_USER:3} @@ -143,6 +150,8 @@ app: public-base-url: ${STORAGE_PUBLIC_BASE_URL:/files} max-file-size-bytes: ${STORAGE_MAX_FILE_SIZE:10485760} notification: + # 알림에서 앱 화면으로 이동하는 스킴. 클릭 집계 후 이 주소로 리다이렉트한다 + deep-link-base: ${NOTIFICATION_DEEP_LINK_BASE:carecode://} fcm: # 서비스 계정 JSON 경로 (예: file:/opt/carecode/fcm-service-account.json). # 비워두면 푸시 발송이 비활성화된다. @@ -176,6 +185,8 @@ app: geocoding-cron: ${PUBLIC_DATA_GEOCODING_CRON:0 0 5 * * *} # 알림은 새벽이 아니라 사람이 볼 시간에 보낸다 policy-change-cron: ${PUBLIC_DATA_POLICY_CHANGE_CRON:0 0 9 * * *} + # 제보 요청은 매일 보내면 소음이 된다 + report-ask-cron: ${BENEFIT_REPORT_ASK_CRON:0 0 10 * * WED} jwt: secret: ${JWT_SECRET} diff --git a/src/test/java/com/carecode/domain/policy/service/BenefitAmountReportServiceTest.java b/src/test/java/com/carecode/domain/policy/service/BenefitAmountReportServiceTest.java index 8667adea..9e4c8b3e 100644 --- a/src/test/java/com/carecode/domain/policy/service/BenefitAmountReportServiceTest.java +++ b/src/test/java/com/carecode/domain/policy/service/BenefitAmountReportServiceTest.java @@ -46,7 +46,8 @@ void setUp() { when(reportRepository.findByPolicyIdAndUserId(anyLong(), anyLong())).thenReturn(Optional.empty()); when(reportRepository.findByPolicyId(anyLong())).thenReturn(List.of()); - service = new BenefitAmountReportService(reportRepository, policyRepository, facade); + service = new BenefitAmountReportService(reportRepository, policyRepository, facade, + mock(com.carecode.core.analytics.EventLogger.class)); ReflectionTestUtils.setField(service, "consensusThreshold", 3); } From c0764ebc6f4698a2068bfc9cc949ca8b5b864d7b Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 16:24:19 +0900 Subject: [PATCH 38/68] =?UTF-8?q?FIX=20:=20Logback=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EA=B0=92=20=EB=AC=B8=EB=B2=95=20=EC=98=A4=EB=A5=98=EB=A1=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=8F=99=20=EC=8B=A4=ED=8C=A8=ED=95=98=EB=8D=98=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EA=B2=BD=EB=A1=9C=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logback 은 ${VAR:-기본값} 문법이라 Spring 문법을 쓰면 경로가 _IS_UNDEFINED 로 잡혀 기동 자체가 실패했다. --- src/main/resources/application-prod.yml | 3 +++ src/main/resources/logback-spring.xml | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index a913a6cb..40925346 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -22,6 +22,9 @@ springdoc: enabled: false logging: + file: + # 이 값이 있어야 Logback 의 LOG_FILE 변수가 정의된다. 없으면 기본 경로로 떨어진다. + name: ${LOG_FILE_PATH:/var/log/carecode/application.log} level: root: WARN com.carecode: INFO diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 4d15a057..50fb5ebe 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -49,8 +49,10 @@ + - ${LOG_FILE:/var/log/carecode/application.log} + ${LOG_FILE:-/var/log/carecode/application.log} @@ -78,7 +80,7 @@ - ${LOG_FILE:/var/log/carecode/application.log}.%d{yyyy-MM-dd}.%i.gz + ${LOG_FILE:-/var/log/carecode/application.log}.%d{yyyy-MM-dd}.%i.gz 100MB From 8b4313980a0285e72673c8fda987f12e3ebc793c Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 16:24:20 +0900 Subject: [PATCH 39/68] =?UTF-8?q?FIX=20:=20MariaDB=20=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20ngram=20=ED=8C=8C=EC=84=9C=20=EC=A0=9C=EA=B1=B0=20(?= =?UTF-8?q?#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ngram 은 MySQL 전용이라 마이그레이션이 실패했다. 짧은 키워드는 FullTextSearchSupport 의 LIKE 폴백으로 계속 동작한다. --- .../resources/db/migration/V4__search_indexes.sql | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/resources/db/migration/V4__search_indexes.sql b/src/main/resources/db/migration/V4__search_indexes.sql index 07de11eb..53372f76 100644 --- a/src/main/resources/db/migration/V4__search_indexes.sql +++ b/src/main/resources/db/migration/V4__search_indexes.sql @@ -1,13 +1,16 @@ --- V4: 위치 검색 및 전문 검색 인덱스 반경 검색은 그동안 모든 행에 삼각함수를 계산한 뒤 HAVING 으로 걸러 풀 스캔이었다 +-- V4: 위치 검색 및 전문 검색 인덱스. 반경 검색은 그동안 모든 행에 삼각함수를 계산해 풀 스캔이었다 -- 위치 검색: 위도로 범위를 좁히고 경도로 다시 좁힌다. CREATE INDEX idx_facility_location ON TBL_CARE_FACILITIES (LATITUDE, LONGITUDE); CREATE INDEX idx_hospital_location ON TBL_HOSPITAL (LATITUDE, LONGITUDE); --- 전문 검색 (ngram 파서는 MySQL 5.7+ / MariaDB 10.0+ 에서 한글 토큰화에 필요) -CREATE FULLTEXT INDEX ft_facility_search ON TBL_CARE_FACILITIES (NAME, ADDRESS) WITH PARSER ngram; -CREATE FULLTEXT INDEX ft_policy_search ON TBL_POLICIES (TITLE, DESCRIPTION) WITH PARSER ngram; -CREATE FULLTEXT INDEX ft_post_search ON TBL_POST (TITLE, CONTENT) WITH PARSER ngram; +-- 전문 검색. +-- ngram 파서는 MySQL 전용이라 MariaDB 에서는 "Function 'ngram' is not defined" 로 기동이 실패한다. +-- MariaDB 내장 토크나이저는 공백 단위라 "행복어린이집" 같은 붙은 말은 부분 일치가 안 되는데, +-- FullTextSearchSupport 가 짧은 키워드·미지원 상황을 LIKE 로 폴백하므로 검색 자체는 동작한다. +CREATE FULLTEXT INDEX ft_facility_search ON TBL_CARE_FACILITIES (NAME, ADDRESS); +CREATE FULLTEXT INDEX ft_policy_search ON TBL_POLICIES (TITLE, DESCRIPTION); +CREATE FULLTEXT INDEX ft_post_search ON TBL_POST (TITLE, CONTENT); -- 목록 정렬에 쓰이는 컬럼 CREATE INDEX idx_facility_active_rating ON TBL_CARE_FACILITIES (IS_ACTIVE, RATING); From d1d0d90308a686a4dd2f0be4f59a8a8d347225d6 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 16:24:20 +0900 Subject: [PATCH 40/68] =?UTF-8?q?FIX=20:=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=EB=AA=85=20=EB=8C=80=EC=86=8C=EB=AC=B8=EC=9E=90=20=EB=B6=88?= =?UTF-8?q?=EC=9D=BC=EC=B9=98=EB=A1=9C=20=EC=A0=84=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EA=B2=80=EC=A6=9D=20=EC=8B=A4=ED=8C=A8=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boot 기본 전략이 테이블명을 소문자로 바꾸는데 마이그레이션은 대문자라 lower_case_table_names=0 환경에서 전부 어긋났다. 메일 헬스체크가 서비스 전체를 DOWN 시키던 것도 함께 분리했다. --- .../core/config/CareCodeNamingStrategy.java | 26 +++++++++++++++++++ src/main/resources/application.yml | 11 ++++++++ 2 files changed, 37 insertions(+) create mode 100644 src/main/java/com/carecode/core/config/CareCodeNamingStrategy.java diff --git a/src/main/java/com/carecode/core/config/CareCodeNamingStrategy.java b/src/main/java/com/carecode/core/config/CareCodeNamingStrategy.java new file mode 100644 index 00000000..8b80ba94 --- /dev/null +++ b/src/main/java/com/carecode/core/config/CareCodeNamingStrategy.java @@ -0,0 +1,26 @@ +package com.carecode.core.config; + +import org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy; +import org.hibernate.boot.model.naming.Identifier; +import org.hibernate.engine.jdbc.env.spi.JdbcEnvironment; + +/** + * 테이블 이름은 선언한 그대로, 컬럼 이름은 기존처럼 snake_case 로 변환한다. + * + *

Boot 기본 전략은 테이블 이름까지 소문자로 바꾼다. 그런데 마이그레이션은 대문자로 만들고 + * Linux MariaDB 는 lower_case_table_names=0 이라 대소문자를 구분해 전 테이블이 검증 실패한다. + * 반대로 이름을 전부 그대로 쓰면 @Column 없이 선언된 필드가 camelCase 로 남아 또 어긋난다. + */ +public class CareCodeNamingStrategy extends CamelCaseToUnderscoresNamingStrategy { + + @Override + public Identifier toPhysicalTableName(Identifier name, JdbcEnvironment context) { + // @Table 로 선언한 이름을 손대지 않는다. + return name; + } + + @Override + public Identifier toPhysicalSequenceName(Identifier name, JdbcEnvironment context) { + return name; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0a378ec2..c3a5924f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -38,6 +38,13 @@ spring: max-lifetime: 1800000 jpa: + hibernate: + naming: + # 엔티티가 @Table 로 대문자 이름을 명시하는데, Boot 기본 전략은 이를 소문자로 바꾼다. + # Linux MariaDB 는 lower_case_table_names=0 이라 대소문자를 구분해 전 테이블이 검증 실패한다. + # 테이블만 선언한 그대로 쓰고 컬럼은 기존처럼 snake_case 로 변환한다. + physical-strategy: com.carecode.core.config.CareCodeNamingStrategy + database-platform: org.hibernate.dialect.MariaDBDialect open-in-view: false properties: @@ -232,6 +239,10 @@ management: enabled: true readinessState: enabled: true + # 메일은 부가 기능이라 SMTP 가 막히면 헬스체크가 통째로 DOWN 이 되고 + # 로드밸런서가 멀쩡한 인스턴스를 내려버린다. 발송 실패는 알림 쪽에서 따로 잡는다. + mail: + enabled: false kakao: redirect-uri: ${KAKAO_REDIRECT_URI:http://localhost:3000/auth/kakao/callback} From 77b769b004d42c6f5c51b3240926a44faf07e7b4 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 16:24:33 +0900 Subject: [PATCH 41/68] =?UTF-8?q?FIX=20:=20DDL=20=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=EB=90=9C=20=ED=85=8C=EC=9D=B4=EB=B8=94=C2=B7?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EB=B3=B4=EC=B6=A9=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정책 북마크는 API 와 리포지토리까지 있으면서 테이블이 없어 한 번도 동작한 적이 없고, 조회수는 컬럼이 없어 저장된 적이 없다. --- .../migration/V15__missing_entity_tables.sql | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/main/resources/db/migration/V15__missing_entity_tables.sql diff --git a/src/main/resources/db/migration/V15__missing_entity_tables.sql b/src/main/resources/db/migration/V15__missing_entity_tables.sql new file mode 100644 index 00000000..6b97bda8 --- /dev/null +++ b/src/main/resources/db/migration/V15__missing_entity_tables.sql @@ -0,0 +1,29 @@ +-- 엔티티는 있는데 DDL 에 빠져 있던 테이블. +-- prod 는 ddl-auto=validate 라 이 둘 때문에 기동 자체가 실패하고 있었다. +-- 특히 정책 북마크는 API 와 리포지토리까지 있으면서 테이블이 없어 한 번도 동작한 적이 없다. + +CREATE TABLE TBL_POLICY_BOOKMARKS ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + policy_id BIGINT NOT NULL, + created_at DATETIME NOT NULL, + -- 같은 정책을 두 번 북마크하면 목록에 중복으로 뜬다 + CONSTRAINT UK_POLICY_BOOKMARK UNIQUE (user_id, policy_id), + CONSTRAINT FK_POLICY_BOOKMARK_USER FOREIGN KEY (user_id) + REFERENCES TBL_USER (ID) ON DELETE CASCADE, + CONSTRAINT FK_POLICY_BOOKMARK_POLICY FOREIGN KEY (policy_id) + REFERENCES TBL_POLICIES (ID) ON DELETE CASCADE +) COMMENT '정책 북마크'; + +CREATE INDEX IDX_POLICY_BOOKMARK_USER ON TBL_POLICY_BOOKMARKS (user_id, created_at DESC); + +-- 조회수. 엔티티와 서비스 코드는 쓰고 있는데 컬럼이 없어 저장된 적이 없다. +ALTER TABLE TBL_POLICIES ADD COLUMN VIEW_COUNT INT NOT NULL DEFAULT 0 COMMENT '조회수'; + +CREATE TABLE TBL_NOTIFICATION_CHANNEL ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + NAME VARCHAR(100) NOT NULL, + DESCRIPTION VARCHAR(500), + CREATED_AT DATETIME NOT NULL, + UPDATED_AT DATETIME +) COMMENT '알림 채널'; From cabf747727a258846d09f63dc671a8e9c987eab9 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 16:24:33 +0900 Subject: [PATCH 42/68] =?UTF-8?q?REFACTOR=20:=20=EC=B0=B8=EC=A1=B0=200?= =?UTF-8?q?=EA=B1=B4=EC=9D=B8=20HealthRecordType=20=EB=A7=A4=ED=95=91=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 테이블이 존재한 적 없고 쓰는 곳도 없는데 매핑만 남아 스키마 검증을 막고 있었다. --- .../domain/health/entity/HealthRecord.java | 4 - .../health/entity/HealthRecordType.java | 89 ------------------- 2 files changed, 93 deletions(-) delete mode 100644 src/main/java/com/carecode/domain/health/entity/HealthRecordType.java diff --git a/src/main/java/com/carecode/domain/health/entity/HealthRecord.java b/src/main/java/com/carecode/domain/health/entity/HealthRecord.java index 185f96f5..a3eea728 100644 --- a/src/main/java/com/carecode/domain/health/entity/HealthRecord.java +++ b/src/main/java/com/carecode/domain/health/entity/HealthRecord.java @@ -110,10 +110,6 @@ public class HealthRecord { @Column(name = "updated_at") private LocalDateTime updatedAt; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "record_type_id", insertable = false, updatable = false) - private HealthRecordType healthRecordType; - @OneToMany(mappedBy = "healthRecord", cascade = CascadeType.ALL, orphanRemoval = true) @lombok.Builder.Default private List healthRecordAttachments = new ArrayList<>(); diff --git a/src/main/java/com/carecode/domain/health/entity/HealthRecordType.java b/src/main/java/com/carecode/domain/health/entity/HealthRecordType.java deleted file mode 100644 index c04be86f..00000000 --- a/src/main/java/com/carecode/domain/health/entity/HealthRecordType.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.carecode.domain.health.entity; - -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.annotation.LastModifiedDate; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; - -/** 건강 기록 유형 엔티티 */ -@Entity -@Table(name = "TBL_HEALTH_RECORD_TYPES") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@EntityListeners(AuditingEntityListener.class) -public class HealthRecordType { - - // 건강 기록 유형 고유 식별자 - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - // 건강 기록 유형명 - @Column(name = "name", nullable = false, unique = true, length = 100) - private String name; - - // 카테고리 - @Column(name = "category", length = 50) - private String category; - - // 유형 설명 - @Column(name = "description", length = 500) - private String description; - - // 표시 순서 - @Column(name = "display_order", nullable = false) - private Integer displayOrder = 0; - - // 활성 상태 여부 - @Column(name = "is_active", nullable = false) - private Boolean isActive = true; - - // 생성 일시 - @CreatedDate - @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; - - // 수정 일시 - @LastModifiedDate - @Column(name = "updated_at") - private LocalDateTime updatedAt; - - // 이 유형에 속한 건강 기록 목록 - @OneToMany(mappedBy = "healthRecordType", cascade = CascadeType.ALL, orphanRemoval = true) - private List healthRecords = new ArrayList<>(); - - // 건강 기록 유형 생성자 - @Builder - public HealthRecordType(String name, String category, String description, Integer displayOrder) { - this.name = name; - this.category = category; - this.description = description; - this.displayOrder = displayOrder; - } - - // 건강 기록 유형 정보 업데이트 - public void updateRecordType(String name, String category, String description, Integer displayOrder) { - this.name = name; - this.category = category; - this.description = description; - this.displayOrder = displayOrder; - } - - // 유형 비활성화 - public void deactivate() { - this.isActive = false; - } - - // 유형 활성화 - public void activate() { - this.isActive = true; - } -} \ No newline at end of file From b2c6517f1667b90195eb7afa476381c3050b004d Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 16:24:34 +0900 Subject: [PATCH 43/68] =?UTF-8?q?FIX=20:=20=ED=97=88=EA=B5=AC=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=EB=A5=BC=20=EB=A7=8C=EB=93=A4=EB=8D=98=20?= =?UTF-8?q?=EA=B8=B0=EB=8F=99=20=EB=9F=AC=EB=84=88=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 매 기동마다 병원을 정원 50·평점 4.5 같은 지어낸 값으로 시설 테이블에 복사했다. 지금은 전국 어린이집·유치원 동기화가 실데이터를 채우므로 오염만 남긴다. 소문자 네이티브 SQL 때문에 기동도 실패시켰다. --- .../CareFacilityDataMigrationService.java | 138 ------------------ 1 file changed, 138 deletions(-) delete mode 100644 src/main/java/com/carecode/domain/careFacility/service/CareFacilityDataMigrationService.java diff --git a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityDataMigrationService.java b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityDataMigrationService.java deleted file mode 100644 index bfb04c7f..00000000 --- a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityDataMigrationService.java +++ /dev/null @@ -1,138 +0,0 @@ -package com.carecode.domain.careFacility.service; - -import com.carecode.domain.careFacility.entity.CareFacility; -import com.carecode.domain.careFacility.entity.FacilityType; -import com.carecode.domain.careFacility.repository.CareFacilityRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.CommandLineRunner; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; - -/** CareFacility 데이터 마이그레이션 서비스 */ -@Slf4j -@Service -@RequiredArgsConstructor -public class CareFacilityDataMigrationService implements CommandLineRunner { - - private final CareFacilityRepository careFacilityRepository; - private final JdbcTemplate jdbcTemplate; - - @Override - @Transactional - public void run(String... args) throws Exception { - migrateHospitalDataToCareFacility(); - } - - // Hospital 데이터를 CareFacility로 마이그레이션 - private void migrateHospitalDataToCareFacility() { - // 이미 CareFacility 데이터가 있으면 마이그레이션 건너뛰기 - if (careFacilityRepository.count() > 0) { - log.info("CareFacility 데이터가 이미 존재합니다. 마이그레이션을 건너뜁니다."); - return; - } - - // Hospital 데이터 조회 - String sql = "SELECT * FROM tbl_hospital"; - List> hospitals = jdbcTemplate.queryForList(sql); - - if (hospitals.isEmpty()) { - return; - } - - for (Map hospital : hospitals) { - CareFacility careFacility = createCareFacilityFromHospital(hospital); - careFacilityRepository.save(careFacility); - } - } - - // Hospital 데이터로부터 CareFacility 생성 - private CareFacility createCareFacilityFromHospital(Map hospital) { - String name = (String) hospital.get("name"); - String address = (String) hospital.get("address"); - String phone = (String) hospital.get("phone"); - Double latitude = (Double) hospital.get("latitude"); - Double longitude = (Double) hospital.get("longitude"); - - // 주소에서 시/구 추출 - String city = extractCity(address); - String district = extractDistrict(address); - - return CareFacility.builder() - .facilityCode("CF" + System.currentTimeMillis() + (int)(Math.random() * 1000)) - .name(name) - .facilityType(FacilityType.OTHER) // 병원/의원은 기타로 분류 - .city(city) - .district(district) - .address(address) - .latitude(latitude) - .longitude(longitude) - .phone(phone) - .capacity(50) // 기본값 - .currentEnrollment(0) - .availableSpots(50) - .ageRangeMin(0) - .ageRangeMax(18) - .operatingHours("09:00-18:00") - .tuitionFee(0) // 병원은 수업료 없음 - .rating(4.5) // 기본 평점 - .reviewCount(0) - .viewCount(0) - .description(name + "에서 아이들의 건강을 관리합니다.") - .ageRange("0-18세") - .isActive(true) - .isPublic(false) - .subsidyAvailable(false) - .createdAt(LocalDateTime.now()) - .updatedAt(LocalDateTime.now()) - .build(); - } - - // 주소에서 시 추출 - private String extractCity(String address) { - if (address == null) return "서울시"; - - if (address.contains("서울시")) return "서울시"; - if (address.contains("경북")) return "경북"; - if (address.contains("부산시")) return "부산시"; - if (address.contains("대구시")) return "대구시"; - if (address.contains("인천시")) return "인천시"; - if (address.contains("광주시")) return "광주시"; - if (address.contains("대전시")) return "대전시"; - if (address.contains("울산시")) return "울산시"; - - return "서울시"; // 기본값 - } - - // 주소에서 구 추출 - private String extractDistrict(String address) { - if (address == null) return "강남구"; - - if (address.contains("강남구")) return "강남구"; - if (address.contains("서초구")) return "서초구"; - if (address.contains("송파구")) return "송파구"; - if (address.contains("마포구")) return "마포구"; - if (address.contains("북구")) return "북구"; - if (address.contains("중구")) return "중구"; - if (address.contains("영등포구")) return "영등포구"; - if (address.contains("강서구")) return "강서구"; - if (address.contains("강동구")) return "강동구"; - if (address.contains("성동구")) return "성동구"; - if (address.contains("광진구")) return "광진구"; - if (address.contains("용산구")) return "용산구"; - if (address.contains("서대문구")) return "서대문구"; - if (address.contains("은평구")) return "은평구"; - if (address.contains("노원구")) return "노원구"; - if (address.contains("도봉구")) return "도봉구"; - if (address.contains("강북구")) return "강북구"; - if (address.contains("종로구")) return "종로구"; - if (address.contains("중랑구")) return "중랑구"; - - return "강남구"; // 기본값 - } -} \ No newline at end of file From b90f042659b0736f85a7784417fe95062207d11f Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 16:24:44 +0900 Subject: [PATCH 44/68] =?UTF-8?q?FIX=20:=20=EB=B3=91=EC=9B=90=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=9C=20=EC=A1=B0=ED=9A=8C=EA=B0=80=20=EC=A0=84=EB=B6=80=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=95=84=EC=88=98=EC=98=80?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실제 경로는 /health/hospitals/** 인데 공개 규칙은 존재하지 않는 /hospitals/** 에 걸려 있었고, 앞선 /health/** authenticated() 가 전부 잡았다. 클래스 레벨 @PreAuthorize 도 함께 덮어야 실제로 열린다. 좋아요 여부는 '내' 상태라 인증을 유지한다. --- .../core/security/SecurityConfig.java | 21 ++++++++++++------- .../health/controller/HealthController.java | 14 +++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/carecode/core/security/SecurityConfig.java b/src/main/java/com/carecode/core/security/SecurityConfig.java index 29fb2a7e..89b16e95 100644 --- a/src/main/java/com/carecode/core/security/SecurityConfig.java +++ b/src/main/java/com/carecode/core/security/SecurityConfig.java @@ -91,6 +91,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 공개 엔드포인트 .requestMatchers("/actuator/health", "/actuator/info", "/actuator/prometheus").permitAll() .requestMatchers("/", "/error", "/favicon.ico").permitAll() + // 약관·방침은 동의하기 전에 읽어야 하므로 비로그인도 볼 수 있어야 한다. + .requestMatchers("/legal/**").permitAll() // 정적 리소스 (공개 접근) .requestMatchers("/css/**", "/js/**", "/images/**").permitAll() @@ -133,15 +135,20 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 돌봄시설 공공데이터 API (공개 접근) .requestMatchers("/api/public/care-facilities/**").permitAll() + // 병원 조회는 로그인 전에도 보여야 한다. 실제 경로가 /health/hospitals/** 라 + // 아래 /health/** 규칙보다 먼저 선언해야 한다. + .requestMatchers(HttpMethod.GET, "/health/hospitals").permitAll() + .requestMatchers(HttpMethod.GET, "/health/hospitals/nearby").permitAll() + .requestMatchers(HttpMethod.GET, "/health/hospitals/popular").permitAll() + .requestMatchers(HttpMethod.GET, "/health/hospitals/type/*").permitAll() + .requestMatchers(HttpMethod.GET, "/health/hospitals/*").permitAll() + .requestMatchers(HttpMethod.GET, "/health/hospitals/*/reviews").permitAll() + .requestMatchers(HttpMethod.GET, "/health/hospitals/*/likes").permitAll() + // 좋아요 여부는 "내" 상태라 로그인이 필요하다. + .requestMatchers("/health/hospitals/*/like-status").authenticated() + // 건강 API: 인증된 사용자만 (소유권은 서비스 계층에서 검증) .requestMatchers("/health/**").authenticated() - .requestMatchers("/hospitals").permitAll() - .requestMatchers("/hospitals/search").permitAll() - .requestMatchers(HttpMethod.GET, "/hospitals/*/reviews").permitAll() - .requestMatchers(HttpMethod.GET, "/hospitals/*/rating").permitAll() - .requestMatchers(HttpMethod.GET, "/hospitals/*/likes").permitAll() - .requestMatchers(HttpMethod.POST, "/hospitals/*/like").authenticated() - .requestMatchers(HttpMethod.DELETE, "/hospitals/*/like").authenticated() // 정책 API: 개인화·북마크는 인증 필요, 나머지 조회는 공개 아래 /policies/* 와일드카드보다 먼저 선언해야 적용된다. .requestMatchers("/policies/recommendations").authenticated() diff --git a/src/main/java/com/carecode/domain/health/controller/HealthController.java b/src/main/java/com/carecode/domain/health/controller/HealthController.java index b8ba8117..0d9edc00 100644 --- a/src/main/java/com/carecode/domain/health/controller/HealthController.java +++ b/src/main/java/com/carecode/domain/health/controller/HealthController.java @@ -193,6 +193,8 @@ public ResponseEntity> getIntegratedRecommendation // 병원 관리 ==================== // 모든 병원 조회 + // 로그인 전에도 병원을 둘러볼 수 있어야 한다. 클래스 레벨 isAuthenticated() 를 덮는다. + @PreAuthorize("permitAll()") @GetMapping("/hospitals") @LogExecutionTime @Operation(summary = "모든 병원 조회", description = "등록된 모든 병원 정보 조회") @@ -205,6 +207,8 @@ public ResponseEntity> getAllHospitals( } // 병원 상세 조회 + // 로그인 전에도 병원을 둘러볼 수 있어야 한다. 클래스 레벨 isAuthenticated() 를 덮는다. + @PreAuthorize("permitAll()") @GetMapping("/hospitals/{id}") @LogExecutionTime @Operation(summary = "병원 상세 조회", description = "특정 병원의 상세 정보 조회") @@ -216,6 +220,8 @@ public ResponseEntity getHospitalById(@Parameter(descripti } // 근처 병원 조회 + // 로그인 전에도 병원을 둘러볼 수 있어야 한다. 클래스 레벨 isAuthenticated() 를 덮는다. + @PreAuthorize("permitAll()") @GetMapping("/hospitals/nearby") @LogExecutionTime @Operation(summary = "근처 병원 조회", description = "위치 기반으로 근처 병원 조회") @@ -229,6 +235,8 @@ public ResponseEntity> getNearbyHospitals(@Parameter( } // 병원 타입별 조회 + // 로그인 전에도 병원을 둘러볼 수 있어야 한다. 클래스 레벨 isAuthenticated() 를 덮는다. + @PreAuthorize("permitAll()") @GetMapping("/hospitals/type/{type}") @LogExecutionTime @Operation(summary = "병원 타입별 조회", description = "특정 타입의 병원들 조회") @@ -270,6 +278,8 @@ public ResponseEntity unlikeHospital(@Parameter(description = "병원 ID", re } // 병원 좋아요 수 조회 + // 로그인 전에도 병원을 둘러볼 수 있어야 한다. 클래스 레벨 isAuthenticated() 를 덮는다. + @PreAuthorize("permitAll()") @GetMapping("/hospitals/{id}/likes") @LogExecutionTime @Operation(summary = "병원 좋아요 수 조회") @@ -294,6 +304,8 @@ public ResponseEntity getHospitalLikeStatus( } // 인기 병원 조회 + // 로그인 전에도 병원을 둘러볼 수 있어야 한다. 클래스 레벨 isAuthenticated() 를 덮는다. + @PreAuthorize("permitAll()") @GetMapping("/hospitals/popular") @LogExecutionTime @Operation(summary = "인기 병원 조회", description = "좋아요가 많은 인기 병원들 조회") @@ -320,6 +332,8 @@ public ResponseEntity createHospitalReview(@Parameter(de } // 병원 리뷰 조회 + // 로그인 전에도 병원을 둘러볼 수 있어야 한다. 클래스 레벨 isAuthenticated() 를 덮는다. + @PreAuthorize("permitAll()") @GetMapping("/hospitals/{id}/reviews") @LogExecutionTime @Operation(summary = "병원 리뷰 조회", description = "특정 병원의 모든 리뷰 조회") From 6adc4ff93d9bf41565174314b8883d9ba755b554 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 16:24:45 +0900 Subject: [PATCH 45/68] =?UTF-8?q?FIX=20:=20404=C2=B7403=20=EC=9D=B4=20500?= =?UTF-8?q?=20=EC=9C=BC=EB=A1=9C=20=EC=83=88=EB=A9=B0=20=EC=9A=B4=EC=98=81?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=EC=9D=84=20=EC=9A=B8=EB=A6=AC=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 없는 URL 과 권한 거부는 장애가 아닌데 최후 핸들러가 잡아 5xx 로 응답하고 알림까지 보냈다. 진짜 장애가 묻힌다. --- ...tomizedResponseEntityExceptionHandler.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/main/java/com/carecode/core/handler/CustomizedResponseEntityExceptionHandler.java b/src/main/java/com/carecode/core/handler/CustomizedResponseEntityExceptionHandler.java index 86d98ff0..9cc96229 100644 --- a/src/main/java/com/carecode/core/handler/CustomizedResponseEntityExceptionHandler.java +++ b/src/main/java/com/carecode/core/handler/CustomizedResponseEntityExceptionHandler.java @@ -12,6 +12,8 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.WebRequest; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.web.servlet.resource.NoResourceFoundException; import java.util.LinkedHashMap; import java.util.Map; @@ -217,6 +219,40 @@ public ResponseEntity handleIllegalArgumentException(IllegalArgum .body(errorResponse); } + // @PreAuthorize 거부는 권한 문제지 장애가 아니다. + // 그냥 두면 최후 핸들러가 500 을 주고 운영 알림이 울린다. + @ExceptionHandler(AuthorizationDeniedException.class) + public ResponseEntity handleAuthorizationDenied(AuthorizationDeniedException ex, WebRequest request) { + log.warn("권한 없는 접근: {}", request.getDescription(false)); + + ErrorResponse errorResponse = ErrorResponse.of( + ErrorCode.FORBIDDEN, + "접근 권한이 없습니다", + request.getDescription(false) + ); + + return ResponseEntity + .status(HttpStatus.FORBIDDEN) + .body(errorResponse); + } + + // 없는 URL 은 잘못된 요청이지 장애가 아니다. + // 그냥 두면 아래 최후 핸들러가 500 으로 응답하고 운영 알림까지 울린다. + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResourceFound(NoResourceFoundException ex, WebRequest request) { + log.warn("존재하지 않는 경로 요청: {}", ex.getResourcePath()); + + ErrorResponse errorResponse = ErrorResponse.of( + ErrorCode.RESOURCE_NOT_FOUND, + "요청하신 경로를 찾을 수 없습니다", + request.getDescription(false) + ); + + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(errorResponse); + } + // 모든 예외 처리 (최후의 수단) @ExceptionHandler(Exception.class) public ResponseEntity handleAllExceptions(Exception ex, WebRequest request) { From 14199c10874d40ea2c334568243e7c4b23d1aa77 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 16:24:53 +0900 Subject: [PATCH 46/68] =?UTF-8?q?FEAT=20:=20=EA=B0=9C=EC=9D=B8=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=B2=98=EB=A6=AC=EB=B0=A9=EC=B9=A8=C2=B7=EC=9D=B4?= =?UTF-8?q?=EC=9A=A9=EC=95=BD=EA=B4=80=20v1.0=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B3=B5=EA=B0=9C=20=EC=A1=B0=ED=9A=8C=20API=20(#7?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 동의 기능은 있는데 동의 대상 문서가 없었다. 엔티티에 실재하는 수집 항목을 근거로 작성했고, 지원금·입소예측·성장정보가 보장이 아니라는 고지를 약관에 넣었다. 동의 전에 읽어야 하므로 비로그인도 볼 수 있다. [확인 필요] 표시 항목은 법률 검토 전까지 미확정이다. --- .../controller/LegalDocumentController.java | 50 +++++ .../user/service/LegalDocumentService.java | 61 ++++++ .../resources/legal/privacy-policy-v1.0.md | 207 ++++++++++++++++++ .../resources/legal/terms-of-service-v1.0.md | 114 ++++++++++ 4 files changed, 432 insertions(+) create mode 100644 src/main/java/com/carecode/domain/user/controller/LegalDocumentController.java create mode 100644 src/main/java/com/carecode/domain/user/service/LegalDocumentService.java create mode 100644 src/main/resources/legal/privacy-policy-v1.0.md create mode 100644 src/main/resources/legal/terms-of-service-v1.0.md diff --git a/src/main/java/com/carecode/domain/user/controller/LegalDocumentController.java b/src/main/java/com/carecode/domain/user/controller/LegalDocumentController.java new file mode 100644 index 00000000..c1cd1e0f --- /dev/null +++ b/src/main/java/com/carecode/domain/user/controller/LegalDocumentController.java @@ -0,0 +1,50 @@ +package com.carecode.domain.user.controller; + +import com.carecode.domain.user.service.LegalDocumentService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** 약관·개인정보 처리방침 조회. 동의 전에 읽어야 하므로 비로그인도 접근할 수 있다. */ +@RestController +@RequestMapping("/legal") +@RequiredArgsConstructor +@Tag(name = "약관·방침", description = "이용약관 및 개인정보 처리방침") +public class LegalDocumentController { + + private final LegalDocumentService legalDocumentService; + + @GetMapping(value = "/privacy-policy", produces = MediaType.TEXT_MARKDOWN_VALUE + ";charset=UTF-8") + @Operation(summary = "개인정보 처리방침", description = "동의 화면에 표시할 원문") + public ResponseEntity privacyPolicy( + @Parameter(description = "버전 (미지정 시 현재 시행본)") @RequestParam(required = false) String version) { + return ResponseEntity.ok(legalDocumentService.getContent( + LegalDocumentService.DocumentType.PRIVACY_POLICY, version)); + } + + @GetMapping(value = "/terms", produces = MediaType.TEXT_MARKDOWN_VALUE + ";charset=UTF-8") + @Operation(summary = "서비스 이용약관", description = "동의 화면에 표시할 원문") + public ResponseEntity terms( + @RequestParam(required = false) String version) { + return ResponseEntity.ok(legalDocumentService.getContent( + LegalDocumentService.DocumentType.TERMS_OF_SERVICE, version)); + } + + @GetMapping("/version") + @Operation(summary = "현재 시행 버전", description = "동의 이력에 기록할 버전") + public ResponseEntity> currentVersion() { + Map body = new LinkedHashMap<>(); + body.put("version", LegalDocumentService.CURRENT_VERSION); + return ResponseEntity.ok(body); + } +} diff --git a/src/main/java/com/carecode/domain/user/service/LegalDocumentService.java b/src/main/java/com/carecode/domain/user/service/LegalDocumentService.java new file mode 100644 index 00000000..c58fa6cf --- /dev/null +++ b/src/main/java/com/carecode/domain/user/service/LegalDocumentService.java @@ -0,0 +1,61 @@ +package com.carecode.domain.user.service; + +import com.carecode.core.exception.CareServiceException; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 약관·개인정보 처리방침 원문 제공. + * 동의 이력에 버전을 남기고 있는데 정작 그 버전의 원문을 볼 수 없으면 증빙이 되지 않는다. + */ +@Slf4j +@Service +public class LegalDocumentService { + + /** 현재 시행 중인 버전. 문서를 고치면 반드시 함께 올린다. */ + public static final String CURRENT_VERSION = "v1.0"; + + private static final Map PATHS = Map.of( + DocumentType.PRIVACY_POLICY, "legal/privacy-policy-%s.md", + DocumentType.TERMS_OF_SERVICE, "legal/terms-of-service-%s.md"); + + /** 문서를 매 요청마다 읽지 않도록 캐시한다. 파일은 배포 단위로 고정이다. */ + private final Map cache = new LinkedHashMap<>(); + + @Getter + public enum DocumentType { + PRIVACY_POLICY("개인정보 처리방침"), + TERMS_OF_SERVICE("서비스 이용약관"); + + private final String displayName; + + DocumentType(String displayName) { + this.displayName = displayName; + } + } + + public String getContent(DocumentType type, String version) { + String resolved = version == null || version.isBlank() ? CURRENT_VERSION : version; + String key = type.name() + ":" + resolved; + + return cache.computeIfAbsent(key, k -> load(type, resolved)); + } + + private String load(DocumentType type, String version) { + String path = String.format(PATHS.get(type), version); + try (InputStream in = new ClassPathResource(path).getInputStream()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (Exception e) { + log.error("법적 고지 문서를 읽지 못했습니다: {}", path, e); + throw new CareServiceException( + type.getDisplayName() + " " + version + " 문서를 찾을 수 없습니다."); + } + } +} diff --git a/src/main/resources/legal/privacy-policy-v1.0.md b/src/main/resources/legal/privacy-policy-v1.0.md new file mode 100644 index 00000000..3d47c7e5 --- /dev/null +++ b/src/main/resources/legal/privacy-policy-v1.0.md @@ -0,0 +1,207 @@ +# 개인정보 처리방침 + +**시행일: 2026-08-06 · 버전: v1.0** + +맘편한(이하 "서비스")은 「개인정보 보호법」에 따라 이용자의 개인정보를 보호하고 +이와 관련한 고충을 신속하게 처리하기 위하여 다음과 같이 개인정보 처리방침을 수립·공개합니다. + +> **⚠️ 운영 전 확인 필요** +> 이 문서는 코드에 구현된 실제 수집 항목을 근거로 작성한 초안입니다. +> 아래 `[확인 필요]` 표시 항목을 채우고 **법률 검토를 거친 뒤** 공개하십시오. +> 특히 아동 개인정보와 건강정보(민감정보)는 위반 시 제재 수위가 높습니다. + +--- + +## 1. 수집하는 개인정보 항목 + +### 1.1 필수 수집 (회원가입 시) + +| 항목 | 수집 방법 | +|---|---| +| 이메일, 비밀번호 | 직접 입력 | +| 이름 | 직접 입력 | + +카카오 계정으로 가입하는 경우 카카오로부터 **이메일, 프로필 닉네임, 프로필 이미지, +카카오 회원번호**를 제공받습니다. 이 경우 비밀번호는 수집하지 않습니다. + +### 1.2 선택 수집 (서비스 이용 중) + +| 항목 | 이용 목적 | +|---|---| +| 휴대전화번호 | 알림 발송 | +| 생년월일, 성별 | 연령별 정책 안내 | +| 주소, 위도·경도 | 지역별 지원금·시설 추천, 반경 검색 | +| **가구 소득 비율, 가구원 수** | 소득 조건이 있는 지원금 대상 여부 판정 | +| 프로필 이미지 | 프로필 표시 | + +**소득은 실제 금액이 아니라 기준중위소득 대비 비율(%)만 저장합니다.** + +### 1.3 자녀 정보 (보호자 동의 필요) + +| 항목 | +|---| +| 이름, 생년월일, 성별, 특이사항(알레르기·발달 관련 등) | + +### 1.4 민감정보 — 건강정보 (별도 동의 필요) + +「개인정보 보호법」 제23조의 민감정보에 해당하며, **별도 동의 없이는 수집하지 않습니다.** + +| 항목 | +|---| +| 신장, 체중, 체온, 혈압, 맥박 | +| 예방접종 이력(백신 종류, 차수, 접종일, 로트번호) | +| 진료 기록(증상, 진단, 치료, 투약, 병원명, 담당의) | +| 건강검진 일정 및 결과 | +| 첨부 파일(진료 관련 이미지·문서) | + +동의를 철회하면 건강정보 신규 등록이 즉시 차단됩니다. + +### 1.5 자동 수집 + +| 항목 | 목적 | +|---|---| +| 접속 IP | 요청 제한, 동의 이력 증빙 | +| 서비스 이용 기록(조회·클릭 이벤트) | 서비스 개선, 이용 통계 | +| 기기 토큰 | 푸시 알림 발송 | +| 최근 로그인 일시 | 휴면 계정 관리 | + +--- + +## 2. 개인정보의 처리 목적 + +1. **회원 관리** — 가입 의사 확인, 본인 식별, 부정 이용 방지 +2. **맞춤 지원금 안내** — 자녀 연령·거주지·소득 조건에 맞는 정책 추천 및 미신청 지원금 안내 +3. **시설·병원 정보 제공** — 반경 검색, 입소 가능성 안내, 대기 기록 관리 +4. **자녀 건강 관리** — 예방접종 일정 관리, 성장 발달 기록, 접종 시기 알림 +5. **알림 발송** — 정책 변경, 접종 예정, 예약 안내 +6. **서비스 개선** — 이용 통계 분석 (개인을 식별할 수 없는 형태로 처리) + +--- + +## 3. 개인정보의 보유 및 이용 기간 + +| 구분 | 보유 기간 | +|---|---| +| 회원 정보 | 회원 탈퇴 시까지 | +| 자녀 정보·건강정보 | 회원 탈퇴 시까지 | +| 동의 이력 | **탈퇴 후 5년** (증빙 목적, 「개인정보 보호법」 제15조) | +| 서비스 이용 기록 | 3개월 (「통신비밀보호법」) | +| 표시·광고에 관한 기록 | 6개월 (「전자상거래법」) | +| 계약·결제 및 재화 공급 기록 | 5년 (「전자상거래법」) | +| 소비자 불만·분쟁 처리 기록 | 3년 (「전자상거래법」) | + +--- + +## 4. 개인정보의 파기 절차 및 방법 + +보유 기간이 지나거나 처리 목적이 달성된 개인정보는 지체 없이 파기합니다. + +**현재 탈퇴 처리 방식** — 회원 탈퇴 시 다음 항목을 즉시 삭제하거나 복원 불가능하게 대체합니다. + +- 삭제: 비밀번호, 휴대전화번호, 주소, 위도·경도, 프로필 이미지, 소셜 계정 식별자 +- 대체: 이름 → "탈퇴한 사용자", 이메일 → 식별 불가능한 임의 값 + +> `[확인 필요]` **자녀 건강정보(민감정보)의 파기 방식** +> 현재 구현은 회원 식별정보를 익명화하는 방식입니다. +> 민감정보를 익명화로 갈음할 수 있는지, 아니면 완전 삭제가 필요한지 법률 검토가 필요합니다. +> 완전 삭제가 필요하다고 판단되면 별도의 삭제 경로를 구현해야 합니다. + +전자적 파일은 복구할 수 없는 방법으로 삭제하고, 출력물은 분쇄하거나 소각합니다. + +--- + +## 5. 개인정보의 제3자 제공 + +**서비스는 이용자의 개인정보를 제3자에게 제공하지 않습니다.** + +다만 다음의 경우는 예외로 합니다. +- 이용자가 사전에 동의한 경우 +- 법령에 근거하여 수사기관이 적법한 절차에 따라 요구하는 경우 + +--- + +## 6. 개인정보 처리의 위탁 + +서비스 제공을 위해 다음 업무를 위탁하고 있습니다. + +| 수탁자 | 위탁 업무 | 개인정보 국외 이전 | +|---|---|---| +| `[확인 필요]` 클라우드 사업자 | 서버 운영 및 데이터 보관 | `[확인 필요]` 리전 명시 | +| 카카오 | 소셜 로그인 인증 | 국내 | +| 카카오 | 주소 → 좌표 변환 (시설 주소만 전송, 개인정보 미포함) | 국내 | +| Google (Firebase) | 푸시 알림 발송 (기기 토큰) | **국외(미국)** | +| `[확인 필요]` 메일 발송 사업자 | 이메일 인증·알림 발송 | `[확인 필요]` | +| Anthropic | AI 상담 응답 생성 | **국외(미국)** | + +> `[확인 필요]` **AI 상담(챗봇) 관련** +> 이용자가 입력한 질문 내용이 Anthropic API로 전송됩니다. +> 챗봇 이용 전 **국외 이전에 대한 별도 고지 및 동의**가 필요하며, +> 질문에 자녀 건강정보가 포함될 수 있음을 안내해야 합니다. +> 챗봇 기능을 제공하지 않는다면 이 항목을 삭제하십시오. + +--- + +## 7. 정보주체의 권리와 행사 방법 + +이용자는 언제든지 다음 권리를 행사할 수 있습니다. + +| 권리 | 행사 방법 | +|---|---| +| 개인정보 열람 | 앱 내 "내 데이터 내려받기" | +| 정정·삭제 | 앱 내 프로필 수정 | +| 처리 정지 | 동의 철회 (선택 항목) | +| 회원 탈퇴 | 앱 내 "회원 탈퇴" | +| 동의 이력 확인 | 앱 내 "동의 내역" | + +만 14세 미만 아동의 개인정보는 **법정대리인의 동의**를 받아 처리하며, +법정대리인은 아동의 개인정보에 대한 열람·정정·삭제·처리정지를 요구할 수 있습니다. + +--- + +## 8. 개인정보의 안전성 확보 조치 + +| 구분 | 조치 내용 | +|---|---| +| 비밀번호 | BCrypt 단방향 암호화 저장 (복호화 불가) | +| 전송 구간 | HTTPS 암호화 | +| 인증 | JWT 기반, 액세스·리프레시 토큰 분리 | +| 리프레시 토큰 | HttpOnly 쿠키로 발급하여 스크립트 접근 차단 | +| 접근 통제 | 본인 데이터만 접근 가능하도록 소유권 검증 | +| 관리자 권한 | 별도 역할로 분리 | +| 요청 제한 | 무차별 대입 방지를 위한 요청 빈도 제한 | +| 파일 업로드 | 확장자·형식·용량 검증, 경로 조작 차단 | +| 접속 기록 | 보관 및 위·변조 방지 | + +--- + +## 9. 개인정보 보호책임자 + +| 구분 | 내용 | +|---|---| +| 개인정보 보호책임자 | `[확인 필요]` 성명 / 직책 | +| 연락처 | `[확인 필요]` 이메일 / 전화 | +| 개인정보 보호 담당부서 | `[확인 필요]` | + +개인정보 침해에 대한 신고나 상담이 필요한 경우 아래 기관에 문의할 수 있습니다. + +- 개인정보침해신고센터 (privacy.kisa.or.kr / 국번없이 118) +- 개인정보분쟁조정위원회 (kopico.go.kr / 1833-6972) +- 대검찰청 사이버수사과 (spo.go.kr / 1301) +- 경찰청 사이버수사국 (ecrm.police.go.kr / 182) + +--- + +## 10. 개인정보 처리방침의 변경 + +이 방침은 시행일부터 적용됩니다. 내용을 추가·삭제·수정하는 경우 +변경 사항의 시행 **7일 전부터** 앱 내 공지를 통해 알립니다. +다만 이용자 권리에 중대한 영향을 미치는 변경은 **30일 전**에 알리고, +필요한 경우 동의를 다시 받습니다. + +--- + +### 변경 이력 + +| 버전 | 시행일 | 변경 내용 | +|---|---|---| +| v1.0 | 2026-08-06 | 최초 작성 | diff --git a/src/main/resources/legal/terms-of-service-v1.0.md b/src/main/resources/legal/terms-of-service-v1.0.md new file mode 100644 index 00000000..a74e5130 --- /dev/null +++ b/src/main/resources/legal/terms-of-service-v1.0.md @@ -0,0 +1,114 @@ +# 서비스 이용약관 + +**시행일: 2026-08-06 · 버전: v1.0** + +> **⚠️ 운영 전 확인 필요** +> 이 문서는 초안입니다. `[확인 필요]` 항목을 채우고 법률 검토를 거친 뒤 공개하십시오. + +--- + +## 제1조 (목적) + +이 약관은 `[확인 필요: 운영 주체명]`(이하 "회사")이 제공하는 육아 지원 서비스 +"맘편한"(이하 "서비스")의 이용조건 및 절차, 회사와 이용자의 권리·의무를 규정함을 목적으로 합니다. + +## 제2조 (정의) + +1. **서비스** — 육아 지원금 안내, 보육시설·병원 정보 제공, 자녀 건강 기록 관리 등 회사가 제공하는 일체의 서비스 +2. **이용자** — 이 약관에 따라 서비스를 이용하는 회원 +3. **회원** — 회사에 개인정보를 제공하여 회원등록을 한 자 +4. **콘텐츠** — 이용자가 서비스에 게시하거나 등록한 글, 사진, 제보 등 + +## 제3조 (약관의 효력 및 변경) + +1. 이 약관은 서비스 화면에 게시하여 효력이 발생합니다. +2. 회사는 필요한 경우 관련 법령을 위배하지 않는 범위에서 약관을 변경할 수 있습니다. +3. 약관을 변경할 경우 시행일 **7일 전**(이용자에게 불리한 변경은 **30일 전**)에 공지합니다. +4. 이용자가 변경된 약관에 동의하지 않으면 이용계약을 해지할 수 있습니다. + +## 제4조 (회원가입) + +1. 이용자는 회사가 정한 절차에 따라 회원가입을 신청합니다. +2. 회사는 다음의 경우 가입을 거부하거나 사후에 이용계약을 해지할 수 있습니다. + - 타인의 명의를 도용한 경우 + - 허위 정보를 기재한 경우 + - 이전에 이 약관 위반으로 자격을 상실한 경우 + +## 제5조 (서비스의 제공) + +회사는 다음 서비스를 제공합니다. + +1. 육아 지원금·정책 정보 검색 및 맞춤 안내 +2. 어린이집·유치원 등 보육시설 정보 제공 및 검색 +3. 소아청소년과 등 의료기관 정보 제공 +4. 자녀 예방접종 일정 및 성장 기록 관리 +5. 대기 신청 기록 관리 +6. 커뮤니티 +7. 그 밖에 회사가 정하는 서비스 + +## 제6조 (정보의 정확성에 관한 고지) + +**이 조항은 서비스 이용 시 반드시 확인해야 합니다.** + +1. 서비스가 제공하는 **지원금 정보는 공공데이터를 기반으로 한 참고 자료**이며, + 실제 지급 여부·금액·조건은 관할 기관의 결정에 따릅니다. +2. **금액이 확인되지 않은 정책은 "추정치"로 표시**되며, 실제와 다를 수 있습니다. + 신청 전 반드시 관할 기관에 확인하십시오. +3. 시설의 **정원·현원·입소 가능성 예측은 통계적 추정**이며 입소를 보장하지 않습니다. +4. 이용자 제보로 수집된 정보는 회사가 그 정확성을 보증하지 않습니다. +5. **성장 발달 정보는 참고 지표이며 의학적 진단이 아닙니다.** + 건강에 관한 판단은 반드시 의료 전문가와 상담하십시오. +6. 회사는 위 정보의 오류로 인해 발생한 손해에 대해 고의 또는 중과실이 없는 한 책임지지 않습니다. + +## 제7조 (이용자의 의무) + +이용자는 다음 행위를 해서는 안 됩니다. + +1. 타인의 정보를 도용하는 행위 +2. 허위 사실을 제보하거나 게시하는 행위 +3. 서비스의 정보를 회사의 사전 승낙 없이 영리 목적으로 이용하는 행위 +4. 자동화된 수단으로 서비스에 과도한 부하를 발생시키는 행위 +5. 다른 이용자를 비방하거나 명예를 훼손하는 행위 +6. 아동에게 유해한 내용을 게시하는 행위 + +## 제8조 (콘텐츠의 관리) + +1. 이용자가 게시한 콘텐츠의 저작권은 이용자에게 있습니다. +2. 회사는 서비스 운영·개선을 위해 콘텐츠를 사용할 수 있습니다. +3. 회사는 다음 콘텐츠를 사전 통지 없이 삭제하거나 숨길 수 있습니다. + - 다른 이용자의 신고가 누적된 경우 + - 법령 또는 이 약관에 위반되는 경우 + - 아동의 안전을 위협하는 경우 + +## 제9조 (서비스의 중단) + +1. 회사는 시스템 점검·교체, 통신 두절 등의 사유로 서비스 제공을 일시 중단할 수 있습니다. +2. 이 경우 사전에 공지하되, 불가피한 경우 사후에 공지할 수 있습니다. + +## 제10조 (계약 해지) + +1. 이용자는 언제든지 서비스 내 기능을 통해 이용계약을 해지할 수 있습니다. +2. 해지 시 개인정보는 개인정보 처리방침에 따라 처리됩니다. + +## 제11조 (책임의 제한) + +1. 회사는 천재지변, 이용자의 귀책사유로 인한 장애에 대해 책임지지 않습니다. +2. 회사는 이용자가 서비스의 정보를 신뢰하여 취한 조치의 결과에 대해 + 고의 또는 중과실이 없는 한 책임지지 않습니다. +3. **제6조에 따른 정보의 부정확성으로 인한 손해에 대해 회사의 책임은 제한됩니다.** + +## 제12조 (분쟁 해결) + +1. 회사와 이용자 간 분쟁은 상호 협의하여 해결하는 것을 원칙으로 합니다. +2. 협의가 이루어지지 않을 경우 「민사소송법」상의 관할 법원에 소를 제기할 수 있습니다. + +--- + +**부칙** — 이 약관은 2026년 8월 6일부터 시행합니다. + +| 문의 | `[확인 필요]` | +|---|---| +| 운영 주체 | `[확인 필요]` | +| 사업자등록번호 | `[확인 필요]` | +| 대표자 | `[확인 필요]` | +| 주소 | `[확인 필요]` | From 06db25d46cd75980c0a3362d7229d921255df80d Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 17:36:21 +0900 Subject: [PATCH 47/68] =?UTF-8?q?TEST=20:=20Flyway=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=EC=99=80=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=A0=95?= =?UTF-8?q?=ED=95=A9=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 통합 테스트가 전부 create-drop 이라 Hibernate 가 엔티티로 스키마를 만들어 냈다. 그래서 마이그레이션이 어긋나도 100% 통과했고, 테이블 없는 정책 북마크가 그대로 초록불이었다. 운영과 같은 방식으로 띄워 검증한다. Docker 가 없으면 조용히 skip 되므로 CI 에서 실행 여부까지 확인한다. --- .github/workflows/ci-cd.yml | 16 +++ build.gradle | 4 +- .../FlywaySchemaValidationTest.java | 128 ++++++++++++++++++ 3 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/carecode/integration/FlywaySchemaValidationTest.java diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 8f3e859a..9ee29ec9 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -68,6 +68,22 @@ jobs: - name: Run tests run: ./gradlew clean test jacocoTestReport + # Testcontainers 테스트는 Docker 가 없으면 조용히 skip 되고 빌드는 초록불이 된다. + # 스키마 정합성 검증이 그렇게 빠지면 마이그레이션 누락을 아무도 못 잡는다. + - name: Assert schema validation actually ran + run: | + report=build/test-results/test/TEST-com.carecode.integration.FlywaySchemaValidationTest.xml + if [ ! -f "$report" ]; then + echo "::error::스키마 정합성 테스트 리포트가 없습니다." + exit 1 + fi + if grep -q 'skipped="0"' "$report"; then + echo "스키마 정합성 테스트 실행 확인" + else + echo "::error::스키마 정합성 테스트가 skip 되었습니다. Docker 환경을 확인하세요." + exit 1 + fi + - name: Publish test report uses: mikepenz/action-junit-report@v5 if: always() diff --git a/build.gradle b/build.gradle index 45772f2a..6a6b297e 100644 --- a/build.gradle +++ b/build.gradle @@ -58,8 +58,8 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testImplementation 'org.springframework.batch:spring-batch-test' - testImplementation 'org.testcontainers:junit-jupiter:1.20.4' - testImplementation 'org.testcontainers:mariadb:1.20.4' + testImplementation 'org.testcontainers:junit-jupiter:1.21.3' + testImplementation 'org.testcontainers:mariadb:1.21.3' runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' // 소셜 로그인(OAuth2) diff --git a/src/test/java/com/carecode/integration/FlywaySchemaValidationTest.java b/src/test/java/com/carecode/integration/FlywaySchemaValidationTest.java new file mode 100644 index 00000000..66692015 --- /dev/null +++ b/src/test/java/com/carecode/integration/FlywaySchemaValidationTest.java @@ -0,0 +1,128 @@ +package com.carecode.integration; + +import com.carecode.CareCodeApplication; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MariaDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 마이그레이션만으로 만든 스키마가 엔티티와 일치하는지 검증한다. + * + *

다른 통합 테스트는 전부 {@code ddl-auto=create-drop} 이라 Hibernate 가 엔티티에서 + * 스키마를 만들어 낸다. 그래서 Flyway 가 아무리 어긋나도 통과한다. 실제로 정책 북마크는 + * 테이블 없이 API 와 리포지토리까지 있었고, 조회수는 컬럼이 없어 저장된 적이 없었는데 + * 기존 테스트는 전부 초록불이었다. + * + *

여기서는 운영과 같은 방식(Flyway 전체 적용 + {@code validate})으로 띄운다. + * 엔티티에 필드를 추가하고 마이그레이션을 안 쓰면 이 테스트가 기동 단계에서 깨진다. + */ +@SpringBootTest( + classes = CareCodeApplication.class, + properties = { + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," + + "org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration," + + "org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration", + "spring.cache.type=none", + "spring.batch.job.enabled=false", + "jwt.secret=testJwtSecretKeyForIntegrationTestsMustBe256BitsLong012345678901234567890", + // 운영과 동일하게: 스키마는 Flyway 가 만들고 Hibernate 는 검증만 한다. + "spring.flyway.enabled=true", + "spring.jpa.hibernate.ddl-auto=validate", + "springdoc.api-docs.enabled=false", + "springdoc.swagger-ui.enabled=false", + "public.data.api.key=dummy", + "GOOGLE_CLIENT_ID=dummy-google-client", + "GOOGLE_CLIENT_SECRET=dummy-google-secret", + "KAKAO_CLIENT_ID=dummy-kakao-client", + "KAKAO_CLIENT_SECRET=dummy-kakao-secret", + "MAIL_USERNAME=dummy", + "MAIL_PASSWORD=dummy" + } +) +@Testcontainers(disabledWithoutDocker = true) +@DisplayName("Flyway 스키마와 엔티티 정합성") +class FlywaySchemaValidationTest { + + /** + * Linux MariaDB 는 lower_case_table_names=0 이라 테이블 이름의 대소문자를 구분한다. + * 운영과 같은 조건이어야 대문자 마이그레이션 / 소문자 매핑 불일치가 여기서 잡힌다. + */ + @Container + static final MariaDBContainer MARIA_DB = new MariaDBContainer<>("mariadb:10.11") + .withDatabaseName("carecode_schema") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureDataSource(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MARIA_DB::getJdbcUrl); + registry.add("spring.datasource.username", MARIA_DB::getUsername); + registry.add("spring.datasource.password", MARIA_DB::getPassword); + } + + @MockBean + RedisConnectionFactory redisConnectionFactory; + + @MockBean + StringRedisTemplate stringRedisTemplate; + + @MockBean + JavaMailSender javaMailSender; + + @Autowired + JdbcTemplate jdbcTemplate; + + @Test + @DisplayName("마이그레이션만으로 만든 스키마로 컨텍스트가 뜬다") + void contextLoadsOnMigratedSchema() { + // ddl-auto=validate 라 엔티티와 어긋나면 여기 오기 전에 기동이 실패한다. + Integer applied = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM flyway_schema_history WHERE success = 1", Integer.class); + + assertThat(applied) + .as("적용된 마이그레이션") + .isNotNull() + .isGreaterThanOrEqualTo(15); + } + + @Test + @DisplayName("실패한 마이그레이션이 남아 있지 않다") + void noFailedMigrations() { + Integer failed = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM flyway_schema_history WHERE success = 0", Integer.class); + + assertThat(failed).as("실패한 마이그레이션").isZero(); + } + + @Test + @DisplayName("한 번도 동작한 적 없던 테이블들이 실제로 만들어진다") + void previouslyMissingTablesExist() { + // V15 로 뒤늦게 채운 것들. 회귀하면 즉시 알아야 한다. + List mustExist = List.of("TBL_POLICY_BOOKMARKS", "TBL_NOTIFICATION_CHANNEL"); + + for (String table : mustExist) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM information_schema.tables " + + "WHERE table_schema = DATABASE() AND table_name = ?", + Integer.class, table); + + assertThat(count).as("%s 테이블", table).isEqualTo(1); + } + } +} From 6ca92d9216d59c11e3201dab3a1b61220b83ce37 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 17:36:21 +0900 Subject: [PATCH 48/68] =?UTF-8?q?TEST=20:=20=EA=B3=B5=EA=B0=9C=C2=B7?= =?UTF-8?q?=EB=B3=B4=ED=98=B8=20=EA=B2=BD=EB=A1=9C=20=EC=A0=91=EA=B7=BC?= =?UTF-8?q?=EC=A0=9C=EC=96=B4=20=EA=B3=84=EC=95=BD=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SecurityConfig 는 앞선 규칙이 뒤를 덮는데 규칙만 보면 멀쩡해 보인다. 규칙을 읽는 대신 실제 응답 코드를 확인한다. 병원 조회가 통째로 로그인 필수였던 문제를 이 테스트가 잡는다. H2 라 Docker 없이도 돈다. --- .../AccessControlContractTest.java | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 src/test/java/com/carecode/integration/AccessControlContractTest.java diff --git a/src/test/java/com/carecode/integration/AccessControlContractTest.java b/src/test/java/com/carecode/integration/AccessControlContractTest.java new file mode 100644 index 00000000..a76c3e14 --- /dev/null +++ b/src/test/java/com/carecode/integration/AccessControlContractTest.java @@ -0,0 +1,126 @@ +package com.carecode.integration; + +import com.carecode.CareCodeApplication; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +/** + * 어떤 경로가 로그인 없이 열려 있고 어떤 경로가 막혀 있어야 하는지를 코드로 고정한다. + * + *

SecurityConfig 는 선언 순서에 따라 앞선 규칙이 뒤를 덮는다. 실제로 병원 공개 규칙이 + * 존재하지 않는 {@code /hospitals/**} 에 걸려 있고 앞선 {@code /health/**} 가 전부 잡아 + * 병원 조회가 통째로 로그인 필수였는데, 규칙 자체는 멀쩡해 보여서 아무도 눈치채지 못했다. + * 클래스 레벨 {@code @PreAuthorize} 가 URL 규칙을 덮는 경우도 마찬가지다. + * + *

그래서 규칙을 읽는 대신 실제 응답 코드를 확인한다. + */ +@SpringBootTest( + classes = CareCodeApplication.class, + properties = { + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," + + "org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration," + + "org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration", + "spring.cache.type=none", + "spring.batch.job.enabled=false", + "spring.datasource.url=jdbc:h2:mem:carecode_acl;MODE=MySQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.flyway.enabled=false", + "jwt.secret=testJwtSecretKeyForAccessControlTestMustBe256BitsLong0123456789", + "springdoc.api-docs.enabled=false", + "springdoc.swagger-ui.enabled=false", + "public.data.api.key=dummy", + "KAKAO_CLIENT_ID=dummy-kakao-client", + "KAKAO_CLIENT_SECRET=dummy-kakao-secret", + "MAIL_USERNAME=dummy", + "MAIL_PASSWORD=dummy" + } +) +@AutoConfigureMockMvc +@DisplayName("접근제어 계약") +class AccessControlContractTest { + + @MockBean + RedisConnectionFactory redisConnectionFactory; + + @MockBean + StringRedisTemplate stringRedisTemplate; + + @MockBean + JavaMailSender javaMailSender; + + @Autowired + MockMvc mockMvc; + + /** + * 로그인 전에도 보여야 하는 경로. + * + *

여기서 확인하는 건 인가지 응답 내용이 아니다. 데이터가 없어 404 가 나올 수는 있어도 + * 인증을 요구해서는 안 된다. + */ + @ParameterizedTest(name = "{0} 은 로그인 없이 열려 있다") + @ValueSource(strings = { + "/actuator/health", + // 동의하기 전에 읽어야 하는 문서 + "/legal/privacy-policy", + "/legal/terms", + "/legal/version", + // 둘러보기 단계에서 보여줘야 가입 전환이 생긴다 + "/policies", + "/policies/categories", + "/policies/statistics", + "/facilities", + "/facilities/popular", + "/facilities/statistics", + "/health/hospitals", + "/health/hospitals/popular", + "/community/posts", + "/community/tags" + }) + void publicPathsDoNotRequireLogin(String path) throws Exception { + MvcResult result = mockMvc.perform(get(path)).andReturn(); + + assertThat(result.getResponse().getStatus()) + .as("%s 는 비로그인 접근이 가능해야 한다", path) + .isNotIn(401, 403); + } + + /** 남의 개인정보가 걸린 경로. 뚫리면 그대로 사고다. */ + @ParameterizedTest(name = "{0} 은 로그인이 필요하다") + @ValueSource(strings = { + "/policies/recommendations", + "/policies/missed-benefits", + "/policies/regional-comparison", + "/policies/bookmarks", + "/health/records/user/1", + "/health/statistics", + // 좋아요 "여부" 는 내 상태라 공개 조회와 구분해야 한다 + "/health/hospitals/1/like-status", + "/notifications", + "/auth/user/profile" + }) + void protectedPathsRequireLogin(String path) throws Exception { + MvcResult result = mockMvc.perform(get(path)).andReturn(); + + assertThat(result.getResponse().getStatus()) + .as("%s 는 인증을 요구해야 한다", path) + .isEqualTo(401); + } +} From 9385e04e7df9a3d9452d64c66c05092ad6bfe29e Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 17:36:37 +0900 Subject: [PATCH 49/68] =?UTF-8?q?FEAT=20:=20=EB=8C=80=EA=B8=B0=20=EA=B1=B8?= =?UTF-8?q?=EC=96=B4=EB=91=94=20=EC=8B=9C=EC=84=A4=EC=97=90=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=EA=B0=80=20=EB=82=98=EB=A9=B4=20=EC=95=8C=EB=A6=B0?= =?UTF-8?q?=EB=8B=A4=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정원 스냅샷은 자리가 난 걸 알고 있었고 대기 명단도 있었는데 둘이 이어져 있지 않아 사용자가 직접 들어와야만 알 수 있었다. 계속 자리가 있는 곳은 이미 알 테니 새로 늘어난 자리만 알린다. 공공데이터는 시설 전체 정원만 주므로 어느 반인지는 알 수 없어 그 한계를 문구에 밝힌다. --- .../careFacility/entity/FacilityWaitlist.java | 26 +++ .../FacilityWaitlistRepository.java | 17 ++ .../service/FacilityVacancyNotifier.java | 187 ++++++++++++++++++ .../notification/entity/Notification.java | 1 + .../V16__waitlist_vacancy_notice.sql | 6 + .../service/FacilityVacancyNotifierTest.java | 174 ++++++++++++++++ 6 files changed, 411 insertions(+) create mode 100644 src/main/java/com/carecode/domain/careFacility/service/FacilityVacancyNotifier.java create mode 100644 src/main/resources/db/migration/V16__waitlist_vacancy_notice.sql create mode 100644 src/test/java/com/carecode/domain/careFacility/service/FacilityVacancyNotifierTest.java diff --git a/src/main/java/com/carecode/domain/careFacility/entity/FacilityWaitlist.java b/src/main/java/com/carecode/domain/careFacility/entity/FacilityWaitlist.java index 8058e90c..302b5cfd 100644 --- a/src/main/java/com/carecode/domain/careFacility/entity/FacilityWaitlist.java +++ b/src/main/java/com/carecode/domain/careFacility/entity/FacilityWaitlist.java @@ -60,6 +60,10 @@ public class FacilityWaitlist { @Column(name = "NOTE", length = 300) private String note; + /** 마지막으로 빈자리를 알린 관측일. 같은 자리로 반복 발송하지 않기 위한 기준이다. */ + @Column(name = "VACANCY_NOTIFIED_AT") + private LocalDate vacancyNotifiedAt; + @Column(name = "CREATED_AT", nullable = false) private LocalDateTime createdAt; @@ -100,6 +104,28 @@ public void resolve(WaitStatus status, LocalDate resolvedAt, String note) { this.updatedAt = LocalDateTime.now(); } + /** 빈자리를 알렸음을 기록한다. */ + public void markVacancyNotified(LocalDate observedDate) { + this.vacancyNotifiedAt = observedDate; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 이 관측일의 빈자리를 알려도 되는지. + * + *

같은 자리를 두 번 알리면 신뢰를 잃고, 정원이 오르내릴 때마다 알리면 스팸이 된다. + * 마지막 발송 이후 최소 간격이 지나야 다시 보낸다. + */ + public boolean canNotifyVacancy(LocalDate observedDate, int minIntervalDays) { + if (status != WaitStatus.WAITING) { + return false; + } + if (vacancyNotifiedAt == null) { + return true; + } + return ChronoUnit.DAYS.between(vacancyNotifiedAt, observedDate) >= minIntervalDays; + } + /** 대기 일수. 아직 대기 중이면 오늘까지로 센다. */ public long waitedDays() { LocalDate end = resolvedAt != null ? resolvedAt : LocalDate.now(); diff --git a/src/main/java/com/carecode/domain/careFacility/repository/FacilityWaitlistRepository.java b/src/main/java/com/carecode/domain/careFacility/repository/FacilityWaitlistRepository.java index c9f701d0..33164e8c 100644 --- a/src/main/java/com/carecode/domain/careFacility/repository/FacilityWaitlistRepository.java +++ b/src/main/java/com/carecode/domain/careFacility/repository/FacilityWaitlistRepository.java @@ -26,4 +26,21 @@ public interface FacilityWaitlistRepository extends JpaRepository빈자리를 확인할 대상을 여기서 좁힌다. 전국 시설을 다 뒤지면 대부분이 아무도 + * 기다리지 않는 곳이라 헛일이다. + */ + @Query("SELECT DISTINCT w.facilityId FROM FacilityWaitlist w " + + "WHERE w.status = com.carecode.domain.careFacility.entity.FacilityWaitlist.WaitStatus.WAITING") + List findFacilityIdsWithWaiting(); + + /** 해당 시설에서 아직 기다리는 사람들. 알림 대상이다. */ + @Query("SELECT w FROM FacilityWaitlist w " + + "WHERE w.facilityId = :facilityId " + + "AND w.status = com.carecode.domain.careFacility.entity.FacilityWaitlist.WaitStatus.WAITING " + + "ORDER BY w.appliedAt ASC") + List findWaiting(@Param("facilityId") Long facilityId); } diff --git a/src/main/java/com/carecode/domain/careFacility/service/FacilityVacancyNotifier.java b/src/main/java/com/carecode/domain/careFacility/service/FacilityVacancyNotifier.java new file mode 100644 index 00000000..1ec26d7a --- /dev/null +++ b/src/main/java/com/carecode/domain/careFacility/service/FacilityVacancyNotifier.java @@ -0,0 +1,187 @@ +package com.carecode.domain.careFacility.service; + +import com.carecode.core.analytics.EventLogger; +import com.carecode.core.analytics.EventType; +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.FacilityCapacitySnapshot; +import com.carecode.domain.careFacility.entity.FacilityWaitlist; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.carecode.domain.careFacility.repository.FacilityCapacitySnapshotRepository; +import com.carecode.domain.careFacility.repository.FacilityWaitlistRepository; +import com.carecode.domain.notification.entity.Notification; +import com.carecode.domain.notification.repository.NotificationRepository; +import com.carecode.domain.notification.sender.NotificationDispatcher; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 대기 걸어둔 시설에 자리가 나면 알린다. + * + *

정원 스냅샷은 자리가 났다는 사실을 알고 있었고 대기 명단도 있었는데 둘이 이어져 있지 + * 않아서, 지금까지는 사용자가 직접 들어와 확인해야만 알 수 있었다. 학부모가 이 앱을 다시 열 + * 가장 강한 이유가 바로 이 알림이다. + * + *

판단 기준은 "빈자리가 늘었는가" 다. 빈자리가 계속 있는 시설은 이미 알고 있을 테니 + * 새로 생긴 자리만 알린다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class FacilityVacancyNotifier { + + /** 직전 관측을 찾기 위해 거슬러 올라갈 기간. 동기화는 주 단위라 넉넉히 잡는다. */ + private static final int LOOKBACK_DAYS = 30; + + /** 같은 사람에게 다시 빈자리를 알리기까지의 최소 간격. */ + @Value("${app.facility-vacancy.min-interval-days:14}") + private int minIntervalDays; + + /** 이 수 이상 늘어야 알린다. 1자리 오르내림까지 알리면 스팸이 된다. */ + @Value("${app.facility-vacancy.min-increase:1}") + private int minIncrease; + + private final FacilityWaitlistRepository waitlistRepository; + private final FacilityCapacitySnapshotRepository snapshotRepository; + private final CareFacilityRepository facilityRepository; + private final NotificationRepository notificationRepository; + private final NotificationDispatcher dispatcher; + private final EventLogger eventLogger; + + @Getter + public static class VacancyNotifyResult { + private int facilitiesChecked; + private int facilitiesWithVacancy; + private int notificationsSent; + + @Override + public String toString() { + return String.format("시설 %d곳 확인, 자리 발생 %d곳, 알림 %d건", + facilitiesChecked, facilitiesWithVacancy, notificationsSent); + } + } + + @Transactional + public VacancyNotifyResult notifyNewVacancies() { + VacancyNotifyResult result = new VacancyNotifyResult(); + + List facilityIds = waitlistRepository.findFacilityIdsWithWaiting(); + if (facilityIds.isEmpty()) { + return result; + } + + for (Long facilityId : facilityIds) { + result.facilitiesChecked++; + try { + int sent = notifyIfVacancyAppeared(facilityId); + if (sent > 0) { + result.facilitiesWithVacancy++; + result.notificationsSent += sent; + } + } catch (Exception e) { + // 한 시설의 실패가 나머지 대기자들의 알림을 막아서는 안 된다. + log.warn("빈자리 알림 실패 - facilityId={}, 사유={}", facilityId, e.getMessage()); + } + } + + log.info("빈자리 알림 - {}", result); + return result; + } + + private int notifyIfVacancyAppeared(Long facilityId) { + List history = snapshotRepository.findHistory( + facilityId, LocalDate.now().minusDays(LOOKBACK_DAYS)); + + // 관측이 한 번뿐이면 늘었는지 줄었는지 알 수 없다. + if (history.size() < 2) { + return 0; + } + + FacilityCapacitySnapshot latest = history.get(history.size() - 1); + FacilityCapacitySnapshot previous = history.get(history.size() - 2); + + int increase = vacancyIncrease(previous, latest); + if (increase < minIncrease) { + return 0; + } + + CareFacility facility = facilityRepository.findById(facilityId).orElse(null); + if (facility == null || !Boolean.TRUE.equals(facility.getIsActive())) { + return 0; + } + + LocalDate observedDate = latest.getObservedDate(); + String title = String.format("%s에 자리가 났습니다", facility.getName()); + String message = buildMessage(facility, latest, increase); + + int sent = 0; + for (FacilityWaitlist entry : waitlistRepository.findWaiting(facilityId)) { + if (!entry.canNotifyVacancy(observedDate, minIntervalDays)) { + continue; + } + + Notification notification = notificationRepository.save(Notification.builder() + .user(entry.getUser()) + .notificationType(Notification.NotificationType.FACILITY) + .title(title) + .message(message) + .createdAt(LocalDateTime.now()) + .build()); + + dispatcher.dispatchAsync(notification); + eventLogger.log(EventType.NOTIFICATION_SENT, entry.getUser().getId(), + String.valueOf(notification.getId()), "FACILITY_VACANCY"); + + entry.markVacancyNotified(observedDate); + sent++; + } + return sent; + } + + /** + * 빈자리가 얼마나 늘었는지. + * + *

공공데이터는 빈자리를 직접 주기도 하고 정원·현원만 주기도 한다. 둘 다 없으면 + * 판단할 근거가 없으므로 0으로 본다. + */ + private int vacancyIncrease(FacilityCapacitySnapshot before, FacilityCapacitySnapshot after) { + Integer beforeSpots = availableSpots(before); + Integer afterSpots = availableSpots(after); + + if (beforeSpots == null || afterSpots == null) { + return 0; + } + return afterSpots - beforeSpots; + } + + private Integer availableSpots(FacilityCapacitySnapshot snapshot) { + if (snapshot.getAvailableSpots() != null) { + return snapshot.getAvailableSpots(); + } + if (snapshot.getCapacity() != null && snapshot.getCurrentEnrollment() != null) { + return snapshot.getCapacity() - snapshot.getCurrentEnrollment(); + } + return null; + } + + /** + * 관측 사실만 쓰고 넘겨짚지 않는다. + * + *

공공데이터는 시설 전체 정원만 주므로 어느 반에 자리가 났는지는 알 수 없다. + * 반이 다르면 헛걸음이라 그 한계를 문구에 그대로 밝힌다. + */ + private String buildMessage(CareFacility facility, FacilityCapacitySnapshot latest, int increase) { + Integer spots = availableSpots(latest); + return String.format( + "대기 등록해 두신 %s의 빈자리가 %d자리 늘어 현재 %d자리입니다. (%s 관측 기준) " + + "시설 전체 기준이라 해당 반에 자리가 있는지는 시설에 확인해 보세요.", + facility.getName(), increase, spots == null ? increase : spots, latest.getObservedDate()); + } +} diff --git a/src/main/java/com/carecode/domain/notification/entity/Notification.java b/src/main/java/com/carecode/domain/notification/entity/Notification.java index 887f4185..2abf1d80 100644 --- a/src/main/java/com/carecode/domain/notification/entity/Notification.java +++ b/src/main/java/com/carecode/domain/notification/entity/Notification.java @@ -61,6 +61,7 @@ public enum NotificationType { POLICY("정책"), HEALTH("건강"), COMMUNITY("커뮤니티"), + FACILITY("시설"), SYSTEM("시스템"); private final String displayName; diff --git a/src/main/resources/db/migration/V16__waitlist_vacancy_notice.sql b/src/main/resources/db/migration/V16__waitlist_vacancy_notice.sql new file mode 100644 index 00000000..d24ff21c --- /dev/null +++ b/src/main/resources/db/migration/V16__waitlist_vacancy_notice.sql @@ -0,0 +1,6 @@ +-- 빈자리 알림 발송 이력. +-- 정원 스냅샷은 자리가 났다는 사실을 알고 있었고 대기 명단도 있었는데 둘이 이어져 있지 않아 +-- 지금까지는 사용자가 직접 들어와야만 알 수 있었다. 이 컬럼이 중복 발송을 막는 기준이 된다. + +ALTER TABLE TBL_FACILITY_WAITLIST + ADD COLUMN VACANCY_NOTIFIED_AT DATE NULL COMMENT '마지막으로 빈자리를 알린 관측일'; diff --git a/src/test/java/com/carecode/domain/careFacility/service/FacilityVacancyNotifierTest.java b/src/test/java/com/carecode/domain/careFacility/service/FacilityVacancyNotifierTest.java new file mode 100644 index 00000000..2863df2d --- /dev/null +++ b/src/test/java/com/carecode/domain/careFacility/service/FacilityVacancyNotifierTest.java @@ -0,0 +1,174 @@ +package com.carecode.domain.careFacility.service; + +import com.carecode.core.analytics.EventLogger; +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.FacilityCapacitySnapshot; +import com.carecode.domain.careFacility.entity.FacilityWaitlist; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.carecode.domain.careFacility.repository.FacilityCapacitySnapshotRepository; +import com.carecode.domain.careFacility.repository.FacilityWaitlistRepository; +import com.carecode.domain.notification.entity.Notification; +import com.carecode.domain.notification.repository.NotificationRepository; +import com.carecode.domain.notification.sender.NotificationDispatcher; +import com.carecode.domain.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("빈자리 알림") +class FacilityVacancyNotifierTest { + + private FacilityWaitlistRepository waitlistRepository; + private FacilityCapacitySnapshotRepository snapshotRepository; + private CareFacilityRepository facilityRepository; + private NotificationRepository notificationRepository; + private FacilityVacancyNotifier notifier; + + @BeforeEach + void setUp() { + waitlistRepository = mock(FacilityWaitlistRepository.class); + snapshotRepository = mock(FacilityCapacitySnapshotRepository.class); + facilityRepository = mock(CareFacilityRepository.class); + notificationRepository = mock(NotificationRepository.class); + + when(facilityRepository.findById(anyLong())).thenReturn(Optional.of( + CareFacility.builder().name("행복어린이집").isActive(true).build())); + when(notificationRepository.save(any(Notification.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + notifier = new FacilityVacancyNotifier(waitlistRepository, snapshotRepository, + facilityRepository, notificationRepository, + mock(NotificationDispatcher.class), mock(EventLogger.class)); + ReflectionTestUtils.setField(notifier, "minIntervalDays", 14); + ReflectionTestUtils.setField(notifier, "minIncrease", 1); + } + + @Test + @DisplayName("빈자리가 늘면 대기자에게 알린다") + void notifiesWhenVacancyIncreases() { + givenWaitingFacility(1L, waiting()); + givenSnapshots(spots(0), spots(2)); + + var result = notifier.notifyNewVacancies(); + + assertThat(result.getNotificationsSent()).isEqualTo(1); + assertThat(result.getFacilitiesWithVacancy()).isEqualTo(1); + } + + @Test + @DisplayName("빈자리가 그대로면 알리지 않는다") + void silentWhenVacancyUnchanged() { + givenWaitingFacility(1L, waiting()); + // 계속 자리가 있는 곳은 이미 알고 있다. 새로 난 자리만 알린다. + givenSnapshots(spots(3), spots(3)); + + assertThat(notifier.notifyNewVacancies().getNotificationsSent()).isZero(); + } + + @Test + @DisplayName("빈자리가 줄면 알리지 않는다") + void silentWhenVacancyDecreases() { + givenWaitingFacility(1L, waiting()); + givenSnapshots(spots(4), spots(1)); + + assertThat(notifier.notifyNewVacancies().getNotificationsSent()).isZero(); + } + + @Test + @DisplayName("관측이 한 번뿐이면 늘었는지 알 수 없으므로 알리지 않는다") + void silentWithSingleObservation() { + givenWaitingFacility(1L, waiting()); + givenSnapshots(spots(5)); + + assertThat(notifier.notifyNewVacancies().getNotificationsSent()).isZero(); + } + + @Test + @DisplayName("최근에 이미 알린 사람에게는 다시 보내지 않는다") + void doesNotRepeatWithinInterval() { + FacilityWaitlist entry = waiting(); + entry.markVacancyNotified(LocalDate.now().minusDays(3)); + givenWaitingFacility(1L, entry); + givenSnapshots(spots(0), spots(2)); + + assertThat(notifier.notifyNewVacancies().getNotificationsSent()).isZero(); + } + + @Test + @DisplayName("간격이 지났으면 다시 알린다") + void notifiesAgainAfterInterval() { + FacilityWaitlist entry = waiting(); + entry.markVacancyNotified(LocalDate.now().minusDays(30)); + givenWaitingFacility(1L, entry); + givenSnapshots(spots(0), spots(2)); + + assertThat(notifier.notifyNewVacancies().getNotificationsSent()).isEqualTo(1); + } + + @Test + @DisplayName("정원·현원만 있어도 빈자리를 계산한다") + void derivesVacancyFromCapacity() { + givenWaitingFacility(1L, waiting()); + givenSnapshots(capacityOnly(50, 50), capacityOnly(50, 47)); + + assertThat(notifier.notifyNewVacancies().getNotificationsSent()).isEqualTo(1); + } + + @Test + @DisplayName("대기자가 없으면 아무 시설도 확인하지 않는다") + void skipsWhenNobodyIsWaiting() { + when(waitlistRepository.findFacilityIdsWithWaiting()).thenReturn(List.of()); + + var result = notifier.notifyNewVacancies(); + + assertThat(result.getFacilitiesChecked()).isZero(); + assertThat(result.getNotificationsSent()).isZero(); + } + + private void givenWaitingFacility(Long facilityId, FacilityWaitlist... entries) { + when(waitlistRepository.findFacilityIdsWithWaiting()).thenReturn(List.of(facilityId)); + when(waitlistRepository.findWaiting(facilityId)).thenReturn(List.of(entries)); + } + + private void givenSnapshots(FacilityCapacitySnapshot... snapshots) { + when(snapshotRepository.findHistory(anyLong(), any(LocalDate.class))) + .thenReturn(List.of(snapshots)); + } + + private FacilityWaitlist waiting() { + return FacilityWaitlist.builder() + .facilityId(1L) + .user(User.builder().id(1L).name("보호자").build()) + .appliedAt(LocalDate.now().minusMonths(2)) + .status(FacilityWaitlist.WaitStatus.WAITING) + .build(); + } + + private FacilityCapacitySnapshot spots(int availableSpots) { + return FacilityCapacitySnapshot.builder() + .facilityId(1L) + .observedDate(LocalDate.now()) + .availableSpots(availableSpots) + .build(); + } + + private FacilityCapacitySnapshot capacityOnly(int capacity, int enrollment) { + return FacilityCapacitySnapshot.builder() + .facilityId(1L) + .observedDate(LocalDate.now()) + .capacity(capacity) + .currentEnrollment(enrollment) + .build(); + } +} From 4ee05ca40fa7bc9779a4761d2e82b2c8169d253c Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 17:36:37 +0900 Subject: [PATCH 50/68] =?UTF-8?q?FEAT=20:=20=EC=8B=A0=EC=B2=AD=20=EB=A7=88?= =?UTF-8?q?=EA=B0=90=20=EC=9E=84=EB=B0=95=20=EC=A7=80=EC=9B=90=EA=B8=88?= =?UTF-8?q?=EC=9D=84=20=EB=AF=B8=EB=A6=AC=20=EC=95=8C=EB=A6=B0=EB=8B=A4=20?= =?UTF-8?q?(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지금은 이미 놓친 것을 사후에 알려준다. 놓치기 전에 막는 편이 낫다. 놓친 사람의 손해가 잘못 받은 알림의 성가심보다 크므로 소득 미입력은 배제하지 않는다. 남은 일수만으로는 인스턴스가 둘일 때 중복을 못 막아 발송 이력에 유니크 제약을 건다. --- .../policy/entity/PolicyDeadlineNotice.java | 55 ++++ .../PolicyDeadlineNoticeRepository.java | 24 ++ .../service/PolicyDeadlineNotifier.java | 243 ++++++++++++++++++ .../migration/V17__policy_deadline_notice.sql | 18 ++ .../service/PolicyDeadlineNotifierTest.java | 218 ++++++++++++++++ 5 files changed, 558 insertions(+) create mode 100644 src/main/java/com/carecode/domain/policy/entity/PolicyDeadlineNotice.java create mode 100644 src/main/java/com/carecode/domain/policy/repository/PolicyDeadlineNoticeRepository.java create mode 100644 src/main/java/com/carecode/domain/policy/service/PolicyDeadlineNotifier.java create mode 100644 src/main/resources/db/migration/V17__policy_deadline_notice.sql create mode 100644 src/test/java/com/carecode/domain/policy/service/PolicyDeadlineNotifierTest.java diff --git a/src/main/java/com/carecode/domain/policy/entity/PolicyDeadlineNotice.java b/src/main/java/com/carecode/domain/policy/entity/PolicyDeadlineNotice.java new file mode 100644 index 00000000..6e11fb66 --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/entity/PolicyDeadlineNotice.java @@ -0,0 +1,55 @@ +package com.carecode.domain.policy.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 마감 임박 알림을 누구에게 언제 보냈는지. + * + *

보낸 사실을 남겨두지 않으면 스케줄러가 하루에 두 번 돌거나 인스턴스가 두 대일 때 + * 같은 알림이 반복해서 나간다. 지원금 알림은 한 번 더 오는 순간 신뢰를 잃는다. + */ +@Entity +@Table(name = "TBL_POLICY_DEADLINE_NOTICE", + uniqueConstraints = @UniqueConstraint(name = "UK_POLICY_DEADLINE_NOTICE", + columnNames = {"POLICY_ID", "USER_ID", "NOTIFIED_ON"})) +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PolicyDeadlineNotice { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ID") + private Long id; + + @Column(name = "POLICY_ID", nullable = false) + private Long policyId; + + @Column(name = "USER_ID", nullable = false) + private Long userId; + + @Column(name = "NOTIFIED_ON", nullable = false) + private LocalDate notifiedOn; + + /** 발송 시점의 잔여 일수. D-7 과 D-1 중 어느 쪽이 실제로 열렸는지 보려면 필요하다. */ + @Column(name = "DAYS_LEFT", nullable = false) + private Integer daysLeft; + + @Column(name = "CREATED_AT", nullable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + } +} diff --git a/src/main/java/com/carecode/domain/policy/repository/PolicyDeadlineNoticeRepository.java b/src/main/java/com/carecode/domain/policy/repository/PolicyDeadlineNoticeRepository.java new file mode 100644 index 00000000..c67d09f5 --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/repository/PolicyDeadlineNoticeRepository.java @@ -0,0 +1,24 @@ +package com.carecode.domain.policy.repository; + +import com.carecode.domain.policy.entity.PolicyDeadlineNotice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@Repository +public interface PolicyDeadlineNoticeRepository extends JpaRepository { + + /** + * 오늘 이 정책으로 이미 알림을 받은 사용자들. + * + *

사용자마다 한 번씩 물으면 대상자 수만큼 질의가 나가므로 한 번에 가져온다. + */ + @Query("SELECT n.userId FROM PolicyDeadlineNotice n " + + "WHERE n.policyId = :policyId AND n.notifiedOn = :notifiedOn") + List findNotifiedUserIds(@Param("policyId") Long policyId, + @Param("notifiedOn") LocalDate notifiedOn); +} diff --git a/src/main/java/com/carecode/domain/policy/service/PolicyDeadlineNotifier.java b/src/main/java/com/carecode/domain/policy/service/PolicyDeadlineNotifier.java new file mode 100644 index 00000000..2a54fefb --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/service/PolicyDeadlineNotifier.java @@ -0,0 +1,243 @@ +package com.carecode.domain.policy.service; + +import com.carecode.core.analytics.EventLogger; +import com.carecode.core.analytics.EventType; +import com.carecode.domain.notification.entity.Notification; +import com.carecode.domain.notification.repository.NotificationRepository; +import com.carecode.domain.notification.sender.NotificationDispatcher; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.entity.PolicyDeadlineNotice; +import com.carecode.domain.policy.repository.PolicyDeadlineNoticeRepository; +import com.carecode.domain.policy.repository.PolicyRepository; +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.ChildRepository; +import com.carecode.domain.user.repository.UserRepository; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 신청 마감이 다가온 지원금을 대상자에게 미리 알린다. + * + *

{@link MissedBenefitService} 는 이미 놓친 것을 사후에 알려준다. 놓치기 전에 막는 쪽이 + * 훨씬 낫고, 사용자가 실제로 돈을 받게 되는 순간이 이 서비스의 유일한 증명이다. + * + *

중복 발송은 두 겹으로 막는다. 마감일까지 남은 일수가 지정한 값과 정확히 같은 날에만 + * 보내고, 그날 이미 보낸 사람은 발송 이력으로 걸러낸다. 남은 일수만으로는 스케줄러가 하루에 + * 두 번 돌거나 배포 중 인스턴스가 두 대일 때를 막지 못한다. 지원금 알림은 한 번 더 오는 + * 순간 신뢰를 잃는다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PolicyDeadlineNotifier { + + private static final int CANDIDATE_SIZE = 500; + + /** + * 마감 며칠 전에 알릴지. 기본은 D-7 과 D-1 이다. + * + *

D-7 은 서류를 준비할 시간을 주고, D-1 은 그날 스케줄러가 실패했거나 알림을 놓친 + * 사람에게 마지막 기회가 된다. + */ + @Value("${app.policy-deadline.lead-days:7,1}") + private String leadDaysRaw; + + private final PolicyRepository policyRepository; + private final PolicyDeadlineNoticeRepository noticeRepository; + private final UserRepository userRepository; + private final ChildRepository childRepository; + private final NotificationRepository notificationRepository; + private final NotificationDispatcher dispatcher; + private final EventLogger eventLogger; + + @Getter + public static class DeadlineNotifyResult { + private int policiesDueSoon; + private int notificationsSent; + + @Override + public String toString() { + return String.format("마감 임박 정책 %d건, 알림 %d건 발송", policiesDueSoon, notificationsSent); + } + } + + @Transactional + public DeadlineNotifyResult notifyUpcomingDeadlines() { + DeadlineNotifyResult result = new DeadlineNotifyResult(); + Set leadDays = parseLeadDays(); + if (leadDays.isEmpty()) { + return result; + } + + LocalDate today = LocalDate.now(); + List candidates = policyRepository + .findByIsActiveTrueOrderByPriorityDescViewCountDesc(PageRequest.of(0, CANDIDATE_SIZE)) + .getContent(); + + List dueSoon = candidates.stream() + .filter(p -> p.getApplicationEndDate() != null) + .filter(p -> leadDays.contains((int) ChronoUnit.DAYS.between(today, p.getApplicationEndDate()))) + .toList(); + + if (dueSoon.isEmpty()) { + return result; + } + result.policiesDueSoon = dueSoon.size(); + + List activeUsers = userRepository.findByIsActiveTrue(); + for (Policy policy : dueSoon) { + try { + result.notificationsSent += notify(policy, activeUsers, today); + } catch (Exception e) { + // 한 정책의 실패가 나머지 마감 알림을 막아서는 안 된다. + log.warn("마감 임박 알림 실패 - policyId={}, 사유={}", policy.getId(), e.getMessage()); + } + } + + log.info("마감 임박 알림 - {}", result); + return result; + } + + private int notify(Policy policy, List activeUsers, LocalDate today) { + int daysLeft = (int) ChronoUnit.DAYS.between(today, policy.getApplicationEndDate()); + String title = String.format("신청 마감 %s: %s", daysLeft <= 1 ? "내일" : "D-" + daysLeft, policy.getTitle()); + String message = buildMessage(policy, daysLeft); + + Set alreadyNotified = Set.copyOf( + noticeRepository.findNotifiedUserIds(policy.getId(), today)); + + int sent = 0; + for (User user : activeUsers) { + if (alreadyNotified.contains(user.getId())) { + continue; + } + if (!isTarget(policy, user, today)) { + continue; + } + + // 보낸 사실을 먼저 남긴다. 유니크 제약이 인스턴스가 둘일 때도 한 번만 남게 만든다. + noticeRepository.save(PolicyDeadlineNotice.builder() + .policyId(policy.getId()) + .userId(user.getId()) + .notifiedOn(today) + .daysLeft(daysLeft) + .build()); + + Notification notification = notificationRepository.save(Notification.builder() + .user(user) + .notificationType(Notification.NotificationType.POLICY) + .title(title) + .message(message) + .createdAt(LocalDateTime.now()) + .build()); + + dispatcher.dispatchAsync(notification); + eventLogger.log(EventType.NOTIFICATION_SENT, user.getId(), + String.valueOf(notification.getId()), "POLICY_DEADLINE"); + sent++; + } + return sent; + } + + /** + * 대상자인지 판단한다. + * + *

마감 알림은 성격상 조금 넓게 보내는 편이 낫다. 놓친 사람의 손해가 잘못 받은 알림의 + * 성가심보다 훨씬 크기 때문에, 소득 미입력처럼 판단할 수 없는 경우는 배제하지 않는다. + * 다만 자녀가 없거나 지역·연령이 명확히 어긋나면 보내지 않는다. + */ + private boolean isTarget(Policy policy, User user, LocalDate today) { + List children = childRepository.findByUserIdOrderByCreatedAtDesc(user.getId()); + if (children.isEmpty()) { + return false; + } + if (!matchesRegion(policy, user)) { + return false; + } + + Integer minChildren = policy.getMinChildren(); + if (minChildren != null && children.size() < minChildren) { + return false; + } + + Integer threshold = policy.getIncomeThresholdPercent(); + Integer income = user.getIncomePercent(); + // 소득 미입력을 탈락으로 처리하면 받을 수 있었던 지원금이 통째로 사라진다. + if (threshold != null && income != null && income > threshold) { + return false; + } + + return children.stream().anyMatch(child -> matchesAge(policy, child, today)); + } + + /** 전국 정책은 모두에게, 지역 정책은 그 지역 주민에게만. */ + private boolean matchesRegion(Policy policy, User user) { + String region = policy.getTargetRegion(); + if (region == null || region.isBlank() || region.contains("전국")) { + return true; + } + String address = user.getAddress(); + if (address == null || address.isBlank()) { + return false; + } + // 주소는 "충청북도 청주시 ...", 정책 지역은 "충청북도 청주시" 처럼 표기가 달라 양방향으로 본다. + return address.contains(region) || region.contains(address); + } + + private boolean matchesAge(Policy policy, Child child, LocalDate today) { + if (child.getBirthDate() == null) { + // 생일을 모르면 연령으로 배제하지 않는다. + return true; + } + int months = (int) ChronoUnit.MONTHS.between(child.getBirthDate(), today); + + Integer min = policy.getTargetAgeMin(); + if (min != null && months < min) { + return false; + } + Integer max = policy.getTargetAgeMax(); + return max == null || months <= max; + } + + private String buildMessage(Policy policy, int daysLeft) { + String when = daysLeft <= 1 + ? "내일(" + policy.getApplicationEndDate() + ") 마감됩니다." + : policy.getApplicationEndDate() + "에 마감됩니다. (" + daysLeft + "일 남음)"; + + String amount = policy.getBenefitAmount() != null && policy.getBenefitAmount() > 0 + ? String.format(" 지원금액은 %,d원으로 등록되어 있습니다.", policy.getBenefitAmount()) + : ""; + + // 신청 자체는 정부 사이트에서 해야 하므로 기대를 정확히 맞춰 준다. + return String.format("%s 신청이 %s%s 대상 여부와 서류를 지금 확인해 보세요.", + policy.getTitle(), when, amount); + } + + private Set parseLeadDays() { + try { + return Arrays.stream(leadDaysRaw.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(Integer::parseInt) + .filter(d -> d >= 0) + .collect(Collectors.toSet()); + } catch (NumberFormatException e) { + log.warn("app.policy-deadline.lead-days 설정이 잘못되어 마감 알림을 건너뜁니다: {}", leadDaysRaw); + return Set.of(); + } + } +} diff --git a/src/main/resources/db/migration/V17__policy_deadline_notice.sql b/src/main/resources/db/migration/V17__policy_deadline_notice.sql new file mode 100644 index 00000000..8eba65fc --- /dev/null +++ b/src/main/resources/db/migration/V17__policy_deadline_notice.sql @@ -0,0 +1,18 @@ +-- 마감 임박 알림 발송 이력. +-- "남은 일수가 D-7 인 날에만 보낸다" 는 규칙만으로는 하루에 여러 번 실행될 때를 막지 못한다. +-- 배포가 Blue/Green 이라 인스턴스가 잠깐 2대가 되면 모든 알림이 두 번씩 나간다. +-- 유니크 제약이 그 상황에서도 한 번만 남게 만든다. + +CREATE TABLE TBL_POLICY_DEADLINE_NOTICE ( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + POLICY_ID BIGINT NOT NULL, + USER_ID BIGINT NOT NULL, + NOTIFIED_ON DATE NOT NULL COMMENT '발송한 날짜', + DAYS_LEFT INT NOT NULL COMMENT '발송 시점의 잔여 일수', + CREATED_AT DATETIME NOT NULL, + CONSTRAINT UK_POLICY_DEADLINE_NOTICE UNIQUE (POLICY_ID, USER_ID, NOTIFIED_ON), + CONSTRAINT FK_POLICY_DEADLINE_NOTICE_USER FOREIGN KEY (USER_ID) + REFERENCES TBL_USER (ID) ON DELETE CASCADE, + CONSTRAINT FK_POLICY_DEADLINE_NOTICE_POLICY FOREIGN KEY (POLICY_ID) + REFERENCES TBL_POLICIES (ID) ON DELETE CASCADE +) COMMENT '마감 임박 알림 발송 이력'; diff --git a/src/test/java/com/carecode/domain/policy/service/PolicyDeadlineNotifierTest.java b/src/test/java/com/carecode/domain/policy/service/PolicyDeadlineNotifierTest.java new file mode 100644 index 00000000..617eac90 --- /dev/null +++ b/src/test/java/com/carecode/domain/policy/service/PolicyDeadlineNotifierTest.java @@ -0,0 +1,218 @@ +package com.carecode.domain.policy.service; + +import com.carecode.core.analytics.EventLogger; +import com.carecode.domain.notification.entity.Notification; +import com.carecode.domain.notification.repository.NotificationRepository; +import com.carecode.domain.notification.sender.NotificationDispatcher; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.entity.PolicyDeadlineNotice; +import com.carecode.domain.policy.repository.PolicyDeadlineNoticeRepository; +import com.carecode.domain.policy.repository.PolicyRepository; +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.ChildRepository; +import com.carecode.domain.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("신청 마감 임박 알림") +class PolicyDeadlineNotifierTest { + + private PolicyRepository policyRepository; + private UserRepository userRepository; + private ChildRepository childRepository; + private PolicyDeadlineNoticeRepository noticeRepository; + private PolicyDeadlineNotifier notifier; + + @BeforeEach + void setUp() { + policyRepository = mock(PolicyRepository.class); + userRepository = mock(UserRepository.class); + childRepository = mock(ChildRepository.class); + noticeRepository = mock(PolicyDeadlineNoticeRepository.class); + when(noticeRepository.findNotifiedUserIds(anyLong(), any(LocalDate.class))) + .thenReturn(List.of()); + + NotificationRepository notificationRepository = mock(NotificationRepository.class); + when(notificationRepository.save(any(Notification.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + notifier = new PolicyDeadlineNotifier(policyRepository, noticeRepository, userRepository, childRepository, + notificationRepository, mock(NotificationDispatcher.class), mock(EventLogger.class)); + ReflectionTestUtils.setField(notifier, "leadDaysRaw", "7,1"); + + givenUser(User.builder().id(1L).name("보호자").address("충청북도 청주시 흥덕구").build()); + givenChildren(child(24)); + } + + @Test + @DisplayName("마감 7일 전이면 알린다") + void notifiesSevenDaysBefore() { + givenPolicies(policyDueIn(7)); + + var result = notifier.notifyUpcomingDeadlines(); + + assertThat(result.getPoliciesDueSoon()).isEqualTo(1); + assertThat(result.getNotificationsSent()).isEqualTo(1); + } + + @Test + @DisplayName("마감 1일 전이면 마지막으로 한 번 더 알린다") + void notifiesOneDayBefore() { + givenPolicies(policyDueIn(1)); + + assertThat(notifier.notifyUpcomingDeadlines().getNotificationsSent()).isEqualTo(1); + } + + @Test + @DisplayName("지정하지 않은 날에는 보내지 않아 중복 발송이 없다") + void silentOnOtherDays() { + givenPolicies(policyDueIn(5)); + + var result = notifier.notifyUpcomingDeadlines(); + + assertThat(result.getPoliciesDueSoon()).isZero(); + assertThat(result.getNotificationsSent()).isZero(); + } + + @Test + @DisplayName("이미 마감된 정책은 알리지 않는다") + void silentAfterDeadline() { + givenPolicies(policyDueIn(-3)); + + assertThat(notifier.notifyUpcomingDeadlines().getNotificationsSent()).isZero(); + } + + @Test + @DisplayName("다른 지역 정책은 보내지 않는다") + void skipsOtherRegions() { + Policy policy = policyDueIn(7); + policy.setTargetRegion("제주특별자치도"); + givenPolicies(policy); + + assertThat(notifier.notifyUpcomingDeadlines().getNotificationsSent()).isZero(); + } + + @Test + @DisplayName("대상 연령을 벗어난 아이만 있으면 보내지 않는다") + void skipsWhenNoChildInAgeRange() { + Policy policy = policyDueIn(7); + policy.setTargetAgeMin(0); + policy.setTargetAgeMax(12); + givenPolicies(policy); + givenChildren(child(36)); + + assertThat(notifier.notifyUpcomingDeadlines().getNotificationsSent()).isZero(); + } + + @Test + @DisplayName("소득을 입력하지 않았으면 배제하지 않는다") + void includesUsersWithUnknownIncome() { + Policy policy = policyDueIn(7); + policy.setIncomeThresholdPercent(150); + givenPolicies(policy); + // 소득 미입력을 탈락으로 처리하면 받을 수 있었던 지원금이 통째로 사라진다. + givenUser(User.builder().id(1L).name("보호자").address("충청북도 청주시").build()); + + assertThat(notifier.notifyUpcomingDeadlines().getNotificationsSent()).isEqualTo(1); + } + + @Test + @DisplayName("소득이 기준을 넘으면 보내지 않는다") + void skipsUsersAboveIncomeThreshold() { + Policy policy = policyDueIn(7); + policy.setIncomeThresholdPercent(150); + givenPolicies(policy); + givenUser(User.builder().id(1L).name("보호자").address("충청북도 청주시").incomePercent(200).build()); + + assertThat(notifier.notifyUpcomingDeadlines().getNotificationsSent()).isZero(); + } + + @Test + @DisplayName("자녀가 없으면 보내지 않는다") + void skipsUsersWithoutChildren() { + givenPolicies(policyDueIn(7)); + givenChildren(); + + assertThat(notifier.notifyUpcomingDeadlines().getNotificationsSent()).isZero(); + } + + @Test + @DisplayName("마감일이 없는 정책은 대상이 아니다") + void skipsPoliciesWithoutDeadline() { + Policy policy = policyDueIn(7); + policy.setApplicationEndDate(null); + givenPolicies(policy); + + assertThat(notifier.notifyUpcomingDeadlines().getPoliciesDueSoon()).isZero(); + } + + @Test + @DisplayName("오늘 이미 받은 사람에게는 다시 보내지 않는다") + void doesNotResendOnSameDay() { + givenPolicies(policyDueIn(7)); + // 스케줄러가 하루에 두 번 돌거나 배포 중 인스턴스가 두 대여도 한 번만 나가야 한다. + when(noticeRepository.findNotifiedUserIds(anyLong(), any(LocalDate.class))) + .thenReturn(List.of(1L)); + + assertThat(notifier.notifyUpcomingDeadlines().getNotificationsSent()).isZero(); + } + + @Test + @DisplayName("발송하면 이력을 남긴다") + void recordsNoticeWhenSent() { + givenPolicies(policyDueIn(7)); + + notifier.notifyUpcomingDeadlines(); + + verify(noticeRepository).save(any(PolicyDeadlineNotice.class)); + } + + private void givenPolicies(Policy... policies) { + Page page = new PageImpl<>(List.of(policies)); + when(policyRepository.findByIsActiveTrueOrderByPriorityDescViewCountDesc(any(Pageable.class))) + .thenReturn(page); + } + + private void givenUser(User user) { + when(userRepository.findByIsActiveTrue()).thenReturn(List.of(user)); + } + + private void givenChildren(Child... children) { + when(childRepository.findByUserIdOrderByCreatedAtDesc(anyLong())).thenReturn(List.of(children)); + } + + private Policy policyDueIn(int days) { + Policy policy = new Policy(); + policy.setId(1L); + policy.setTitle("청주시 출산장려금"); + policy.setTargetRegion("충청북도 청주시"); + policy.setBenefitAmount(1_000_000); + policy.setApplicationEndDate(LocalDate.now().plusDays(days)); + policy.setIsActive(true); + return policy; + } + + private Child child(int months) { + return Child.builder() + .id(1L) + .name("아이") + .birthDate(LocalDate.now().minusMonths(months)) + .build(); + } +} From a888b09e975bd822262744233f38b044b53dd1a5 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 17:36:37 +0900 Subject: [PATCH 51/68] =?UTF-8?q?FEAT=20:=20=EB=91=90=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=EC=9D=84=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=C2=B7=EC=88=98?= =?UTF-8?q?=EB=8F=99=20=EC=8B=A4=ED=96=89=EC=97=90=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?(#73,=20#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 스케줄러는 하루 한 번만 돌아서 발송이 안 나갔을 때 원인을 확인하려면 다음 날까지 기다려야 한다. 확인한 시설 수까지 돌려주므로 대기자가 없어서인지 자리가 안 나서인지 구분된다. --- .../scheduler/PublicDataSyncScheduler.java | 31 ++++++++++++++---- .../controller/AdminPublicDataController.java | 32 +++++++++++++++++++ src/main/resources/application.yml | 11 +++++++ 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java index a34e3403..19e3093e 100644 --- a/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java +++ b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java @@ -6,8 +6,10 @@ import com.carecode.core.client.sync.PediatricHospitalSyncService; import com.carecode.core.client.sync.SyncResult; import com.carecode.core.geocoding.FacilityGeocodingService; +import com.carecode.domain.careFacility.service.FacilityVacancyNotifier; import com.carecode.domain.policy.service.BenefitReportSolicitor; import com.carecode.domain.policy.service.PolicyChangeNotifier; +import com.carecode.domain.policy.service.PolicyDeadlineNotifier; import com.carecode.core.ops.OperationalAlerter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -26,6 +28,8 @@ public class PublicDataSyncScheduler { private final PediatricHospitalSyncService hospitalSyncService; private final FacilityGeocodingService geocodingService; private final PolicyChangeNotifier policyChangeNotifier; + private final PolicyDeadlineNotifier policyDeadlineNotifier; + private final FacilityVacancyNotifier vacancyNotifier; private final BenefitReportSolicitor reportSolicitor; private final OperationalAlerter alerter; @@ -60,22 +64,37 @@ public void syncPediatricHospitals() { /** 정책 변경 알림. 동기화가 끝난 뒤 돌아야 그날 바뀐 내용이 잡힌다. */ @Scheduled(cron = "${app.scheduler.public-data.policy-change-cron:0 0 9 * * *}", zone = "Asia/Seoul") public void notifyPolicyChanges() { - var result = policyChangeNotifier.notifyPendingChanges(); - log.info("정책 변경 알림 - {}", result); + policyChangeNotifier.notifyPendingChanges(); + } + + /** + * 빈자리 알림. 시설 동기화로 새 정원이 들어온 뒤에 돌아야 그날 난 자리가 잡힌다. + * 대기 걸어둔 사람이 이 앱을 다시 열 가장 강한 이유다. + */ + @Scheduled(cron = "${app.scheduler.public-data.vacancy-cron:0 30 9 * * *}", zone = "Asia/Seoul") + public void notifyFacilityVacancies() { + vacancyNotifier.notifyNewVacancies(); + } + + /** + * 신청 마감 임박 알림. 놓친 뒤에 알려주는 것보다 놓치기 전에 막는 편이 낫다. + * 마감일까지 남은 일수로 판단하므로 매일 돌아야 D-7·D-1 을 놓치지 않는다. + */ + @Scheduled(cron = "${app.scheduler.public-data.policy-deadline-cron:0 0 10 * * *}", zone = "Asia/Seoul") + public void notifyPolicyDeadlines() { + policyDeadlineNotifier.notifyUpcomingDeadlines(); } /** 실수령액 제보 요청. 매일 보내면 소음이라 주 1회만 묻는다. */ @Scheduled(cron = "${app.scheduler.public-data.report-ask-cron:0 0 10 * * WED}", zone = "Asia/Seoul") public void solicitBenefitReports() { - var result = reportSolicitor.solicitReports(); - log.info("실수령액 제보 요청 - {}", result); + reportSolicitor.solicitReports(); } /** 좌표 보정. 동기화가 끝난 뒤 돌아야 새로 들어온 시설이 대상에 포함된다. */ @Scheduled(cron = "${app.scheduler.public-data.geocoding-cron:0 0 5 * * *}", zone = "Asia/Seoul") public void fillMissingCoordinates() { - var result = geocodingService.fillMissingCoordinates(); - log.info("시설 좌표 보정 - {}", result); + geocodingService.fillMissingCoordinates(); } private void logResult(String label, SyncResult result) { diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java b/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java index 20797d3e..e13cdb91 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java @@ -6,6 +6,8 @@ import com.carecode.core.client.sync.PediatricHospitalSyncService; import com.carecode.core.client.sync.SyncResult; import com.carecode.core.geocoding.FacilityGeocodingService; +import com.carecode.domain.careFacility.service.FacilityVacancyNotifier; +import com.carecode.domain.policy.service.PolicyDeadlineNotifier; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -29,6 +31,8 @@ public class AdminPublicDataController { private final GovernmentBenefitSyncService benefitSyncService; private final PediatricHospitalSyncService hospitalSyncService; private final FacilityGeocodingService geocodingService; + private final FacilityVacancyNotifier vacancyNotifier; + private final PolicyDeadlineNotifier policyDeadlineNotifier; @PostMapping("/facilities/sync") @Operation(summary = "전국 어린이집 동기화", description = "시설 코드 기준으로 갱신") @@ -66,6 +70,34 @@ public ResponseEntity> geocode() { return ResponseEntity.ok(body); } + /** + * 빈자리 알림 수동 실행. + * + *

스케줄러는 하루 한 번만 돌아서, 발송이 안 나갔을 때 원인을 확인하려면 + * 다음 날까지 기다려야 한다. 확인한 시설 수까지 돌려주므로 대기자가 없어서인지 + * 자리가 안 나서인지 구분할 수 있다. + */ + @PostMapping("/facilities/notify-vacancy") + @Operation(summary = "빈자리 알림 실행", description = "대기자가 있는 시설에 새로 난 자리를 알린다") + public ResponseEntity> notifyVacancies() { + var result = vacancyNotifier.notifyNewVacancies(); + Map body = new LinkedHashMap<>(); + body.put("facilitiesChecked", result.getFacilitiesChecked()); + body.put("facilitiesWithVacancy", result.getFacilitiesWithVacancy()); + body.put("notificationsSent", result.getNotificationsSent()); + return ResponseEntity.ok(body); + } + + @PostMapping("/policies/notify-deadline") + @Operation(summary = "마감 임박 알림 실행", description = "신청 마감이 임박한 지원금을 대상자에게 알린다") + public ResponseEntity> notifyDeadlines() { + var result = policyDeadlineNotifier.notifyUpcomingDeadlines(); + Map body = new LinkedHashMap<>(); + body.put("policiesDueSoon", result.getPoliciesDueSoon()); + body.put("notificationsSent", result.getNotificationsSent()); + return ResponseEntity.ok(body); + } + private Map toResponse(SyncResult result) { Map body = new LinkedHashMap<>(); body.put("provider", result.getProvider()); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c3a5924f..3dcf49ca 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -133,6 +133,17 @@ app: batch-size: ${POLICY_CHANGE_BATCH_SIZE:200} max-per-user: ${POLICY_CHANGE_MAX_PER_USER:3} + policy-deadline: + # 마감 며칠 전에 알릴지. 남은 일수가 이 값과 정확히 같은 날에만 보내므로 + # 매일 돌아도 정책 하나당 여기 적은 횟수만큼만 나간다. + lead-days: ${POLICY_DEADLINE_LEAD_DAYS:7,1} + + facility-vacancy: + # 같은 사람에게 다시 빈자리를 알리기까지의 최소 간격 + min-interval-days: ${FACILITY_VACANCY_MIN_INTERVAL_DAYS:14} + # 이만큼 늘어야 알린다. 1자리 오르내림까지 알리면 스팸이 된다 + min-increase: ${FACILITY_VACANCY_MIN_INCREASE:1} + geocoding: # 어린이집 API 는 좌표를 주지 않아 주소로 보정한다. 키가 없으면 보정을 건너뛴다 kakao: From 41f176e54bf7778ce140fca5c527a094d2eda107 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 23:56:20 +0900 Subject: [PATCH 52/68] =?UTF-8?q?DOCS=20:=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=20=EB=B0=8F=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=ED=9D=90=EB=A6=84=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계층 구조와 배치 순서를 Mermaid 로 남기고 편집 가능한 draw.io 원본을 함께 둔다. 배치 순서에는 이유가 있다. 수집이 먼저고 발송이 나중이어야 그날 들어온 데이터로 알림이 나간다. --- docs/README.md | 80 +++++ .../architecture/carecode-architecture.drawio | 278 ++++++++++++++++++ docs/architecture/data-flow.md | 210 +++++++++++++ docs/architecture/system-overview.md | 169 +++++++++++ 4 files changed, 737 insertions(+) create mode 100644 docs/README.md create mode 100644 docs/architecture/carecode-architecture.drawio create mode 100644 docs/architecture/data-flow.md create mode 100644 docs/architecture/system-overview.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..34672466 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,80 @@ +# CareCode(맘편한) 문서 + +육아 지원금·보육시설·건강관리를 한 곳에서 다루는 백엔드 서비스입니다. +이 디렉터리는 **무엇을 만들었는지**가 아니라 **왜 그렇게 만들었는지**를 남기는 것을 목표로 합니다. + +## 이 문서들의 전제 + +이 서비스의 핵심 데이터는 전부 **정부 공공데이터**에서 옵니다. 그래서 대부분의 설계 판단은 +"우리가 무엇을 하고 싶은가" 보다 **"공공데이터가 무엇을 주지 않는가"** 에서 출발합니다. + +예를 들어 어린이집 정원 데이터는 시설 전체 수치만 주고 반별로는 주지 않습니다. +그래서 빈자리 알림은 "어느 반에 자리가 났는지는 알 수 없다" 는 한계를 문구에 그대로 밝힙니다. +정확한 척하는 것이 틀린 정보보다 위험하기 때문입니다. + +이런 판단의 근거를 각 문서에 함께 적었습니다. + +## 문서 지도 + +### 아키텍처 + +| 문서 | 내용 | +|------|------| +| [시스템 개요](architecture/system-overview.md) | 전체 구성, 계층 구조, 요청·배치 흐름 (Mermaid) | +| [데이터 흐름](architecture/data-flow.md) | 공공데이터 수집 → 정제 → 알림까지의 파이프라인 (Mermaid) | +| [carecode-architecture.drawio](architecture/carecode-architecture.drawio) | draw.io 편집용 아키텍처 원본 | + +### 기능 + +| 문서 | 다루는 범위 | 관련 이슈 | +|------|-------------|-----------| +| [공공데이터 연동](features/public-data-integration.md) | 4개 정부 API 연동, 공급자 추상화, 전국 순회 동기화 | #61 #68 | +| [지원금 지능화](features/benefit-intelligence.md) | 추천·지역 비교·놓친 지원금·실수령액 제보·중복 수급 배타 | #65 #67 #69 | +| [시설 지능화](features/facility-intelligence.md) | 정원 시계열·입소 예측·인기도·대기 기록·빈자리 알림 | #65 #67 #69 #73 | +| [알림과 리텐션](features/notification-and-retention.md) | 정책 변경·빈자리·마감 임박 알림, 딥링크, 클릭 전환 | #69 #73 #74 | +| [지표 수집](features/analytics.md) | 행동 이벤트, 퍼널, 코호트 리텐션 | #68 | +| [개인정보와 법적 문서](features/privacy-and-legal.md) | 동의 분리, 민감정보 차단, 처리방침·약관 | #68 #71 | +| [운영](features/operations.md) | 운영 알림, 헬스체크, 스케줄러, 수동 실행 | #68 #70 | + +### 품질 + +| 문서 | 내용 | 관련 이슈 | +|------|------|-----------| +| [기동 안정화](quality/runtime-hardening.md) | 실기동에서 드러난 차단 8건과 접근제어 결함 | #70 | +| [회귀 방지](quality/regression-safety.md) | 왜 CI 가 못 잡았는지, 어떻게 막았는지 | #72 | + +### 레퍼런스 + +| 문서 | 내용 | +|------|------| +| [데이터베이스 마이그레이션](reference/database-migrations.md) | V1~V17 각각이 왜 필요했는지 | +| [접근제어 매트릭스](reference/access-control-matrix.md) | 공개·인증·관리자 경로 전수 | + +### 기존 문서 + +| 문서 | 내용 | +|------|------| +| [ERD.md](ERD.md) | 엔티티 관계도 | +| [ISSUE_MANAGEMENT.md](ISSUE_MANAGEMENT.md) | 이슈·커밋 연결 규칙 | +| [system-architecture.md](system-architecture.md) | 초기 아키텍처 문서 | +| [ARCHITECTURE_IMPROVEMENTS.md](ARCHITECTURE_IMPROVEMENTS.md) | 초기 개선 기록 | + +## 기술 스택 + +| 구분 | 사용 기술 | +|------|-----------| +| 런타임 | Java 17, Spring Boot 3.3.3 | +| 데이터 | MariaDB 10.11, Redis 7, Flyway | +| 빌드 | Gradle 8.14, JaCoCo | +| 테스트 | JUnit 5, Mockito, AssertJ, Testcontainers, H2 | +| 문서 | springdoc-openapi (운영에서는 비공개) | +| 배포 | Docker, GitHub Actions, Blue/Green | + +## 개발 규칙 + +- **커밋**: `TYPE : 한글 설명 (#이슈번호)` — 관심사별로 잘게 나눕니다. +- **스키마**: 운영은 `ddl-auto=validate` 입니다. 엔티티를 바꾸면 마이그레이션도 반드시 씁니다. + 안 쓰면 [스키마 정합성 테스트](quality/regression-safety.md)가 기동 단계에서 깨뜨립니다. +- **접근제어**: 경로를 추가하면 [접근제어 계약 테스트](reference/access-control-matrix.md)에도 넣습니다. + SecurityConfig 는 앞선 규칙이 뒤를 덮어서, 규칙만 보고는 실제로 열렸는지 알 수 없습니다. +- **비밀값**: API 키·자격증명은 저장소에 넣지 않습니다. 환경변수로만 주입합니다. diff --git a/docs/architecture/carecode-architecture.drawio b/docs/architecture/carecode-architecture.drawio new file mode 100644 index 00000000..97881b7e --- /dev/null +++ b/docs/architecture/carecode-architecture.drawio @@ -0,0 +1,278 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/architecture/data-flow.md b/docs/architecture/data-flow.md new file mode 100644 index 00000000..097c50dd --- /dev/null +++ b/docs/architecture/data-flow.md @@ -0,0 +1,210 @@ +# 데이터 흐름 + +이 서비스의 가치는 **정부가 흩어놓은 데이터를 한 사람 기준으로 다시 조립하는 것**에서 나옵니다. +그 조립 과정을 단계별로 정리합니다. + +## 전체 파이프라인 + +```mermaid +flowchart LR + subgraph collect["1. 수집"] + direction TB + P1["보육통합정보
XML · HTTPS"] + P2["유치원알리미
JSON"] + P3["보조금24
JSON"] + P4["심평원
XML"] + end + + subgraph normalize["2. 정제"] + direction TB + N1["시군구 순회
RegionCodeCatalog"] + N2["필드 매핑
Upsert 서비스"] + N3["좌표 보정
주소 → 위경도"] + N4["지급유형 판별
월정액·일시금·융자"] + end + + subgraph enrich["3. 축적"] + direction TB + E1["정원 스냅샷
일자별 관측"] + E2["정책 변경 이력
금액·기한·연령"] + E3["실수령액 제보
사용자 입력"] + E4["대기 기록
사용자 입력"] + end + + subgraph derive["4. 판단"] + direction TB + D1["입소 예측"] + D2["시설 인기도"] + D3["놓친 지원금"] + D4["지역별 비교"] + D5["빈자리 감지"] + D6["마감 임박 감지"] + end + + subgraph act["5. 전달"] + direction TB + A1["알림 발송"] + A2["조회 API"] + A3["행동 이벤트"] + end + + P1 & P2 --> N1 --> N2 --> N3 + P3 --> N4 + P4 --> N2 + + N2 --> E1 + N4 --> E2 + N2 --> E1 + + E1 --> D1 & D2 & D5 + E2 --> D6 + E3 --> D3 & D4 + E4 --> D5 + + D1 & D2 & D3 & D4 --> A2 + D5 & D6 --> A1 + A1 & A2 --> A3 +``` + +**3단계(축적)가 이 구조의 핵심**입니다. 정부 API 는 "지금 이 순간" 만 알려주고 과거를 주지 않습니다. +매일 관측해서 쌓아야만 "자리가 늘었다", "금액이 바뀌었다" 를 말할 수 있습니다. +그래서 스냅샷과 변경 이력은 기능이 아니라 **다른 모든 판단의 재료**입니다. + +## 공공데이터 수집 상세 + +```mermaid +sequenceDiagram + participant S as Scheduler + participant Sync as SyncService + participant T as PagedSyncTemplate + participant Cat as RegionCodeCatalog + participant Prov as Provider + participant Gov as 정부 API + participant Up as UpsertService + participant Snap as CapacitySnapshotRecorder + participant DB as MariaDB + participant Ops as OperationalAlerter + + S->>Sync: sync() + Sync->>Cat: 시군구 코드 목록 (어린이집 202 / 유치원 212) + loop 시군구마다 + Sync->>T: 페이지 순회 + T->>Prov: 요청 + Prov->>Gov: HTTP + alt 정상 + Gov-->>Prov: 목록 + Prov-->>T: 파싱 결과 + T->>Up: upsert (코드 기준) + Up->>DB: 저장 + Up->>Snap: 정원·현원 관측 기록 + Snap->>DB: 스냅샷 (일자별 1행) + else 응답 코드가 한도 초과·키 만료 + Gov-->>Prov: 오류 코드 + Prov-->>Sync: ChildcareApiStatus + Sync->>Ops: 운영 알림 + else 통신 실패 + Sync->>Sync: 해당 시군구만 실패 기록 + end + end + Sync-->>S: SyncResult (생성·갱신·실패) + alt 실패 있음 + S->>Ops: 운영 알림 + end +``` + +한 시군구가 실패해도 **나머지는 계속 돕니다.** 전국 데이터는 일부가 비어도 쓸 수 있지만, +하나 때문에 전체가 멈추면 아무것도 못 씁니다. + +## 빈자리 알림 판단 + +```mermaid +flowchart TD + START([스케줄러 09:30]) --> W{대기자가 있는
시설이 있는가} + W -->|없음| END1([종료]) + W -->|있음| H[최근 30일 스냅샷 조회] + H --> C{관측이 2회 이상인가} + C -->|아니오| SKIP1[증감을 알 수 없음
건너뜀] + C -->|예| CALC[직전 대비 빈자리 증감 계산] + CALC --> INC{증가분 ≥ 기준?} + INC -->|아니오| SKIP2[이미 있던 자리거나
오르내림 · 건너뜀] + INC -->|예| LOOP[대기자 순회] + LOOP --> DUP{최근에
알린 적 있는가} + DUP -->|예| SKIP3[간격 미달 · 건너뜀] + DUP -->|아니오| SEND[알림 저장 · 발송] + SEND --> MARK[대기 기록에 관측일 기록] + MARK --> LOOP + + style SEND fill:#d4edda,stroke:#28a745 + style SKIP1 fill:#f8f9fa,stroke:#adb5bd + style SKIP2 fill:#f8f9fa,stroke:#adb5bd + style SKIP3 fill:#f8f9fa,stroke:#adb5bd +``` + +**"빈자리가 있다" 가 아니라 "빈자리가 늘었다" 로 판단합니다.** +계속 자리가 있는 시설은 사용자도 이미 알고 있어서, 매일 알리면 그냥 스팸이 됩니다. + +## 마감 임박 알림 판단 + +```mermaid +flowchart TD + START([스케줄러 10:00]) --> LOAD[활성 정책 조회] + LOAD --> DL{마감일이 있는가} + DL -->|없음| SKIP0[대상 아님] + DL -->|있음| LEAD{남은 일수가
D-7 또는 D-1 인가} + LEAD -->|아니오| SKIP1[해당 없음] + LEAD -->|예| HIST[오늘 이미 받은 사용자 조회] + HIST --> USER[활성 사용자 순회] + USER --> SENT{오늘 이미
받았는가} + SENT -->|예| SKIP2[중복 방지] + SENT -->|아니오| T1{자녀가 있는가} + T1 -->|없음| SKIP3[대상 아님] + T1 -->|있음| T2{지역이 맞는가} + T2 -->|아니오| SKIP4[다른 지역] + T2 -->|예| T3{자녀수 요건
충족하는가} + T3 -->|아니오| SKIP5[대상 아님] + T3 -->|예| T4{소득이 기준을
넘는가} + T4 -->|넘음| SKIP6[대상 아님] + T4 -->|미입력·이하| T5{연령이 맞는
아이가 있는가} + T5 -->|없음| SKIP7[대상 아님] + T5 -->|있음| REC[발송 이력 저장
유니크 제약] + REC --> SEND[알림 저장 · 발송] + SEND --> USER + + style SEND fill:#d4edda,stroke:#28a745 + style REC fill:#fff3cd,stroke:#ffc107 +``` + +**소득 미입력은 탈락시키지 않습니다.** 놓친 사람의 손해가 잘못 받은 알림의 성가심보다 훨씬 크기 +때문입니다. 반대로 소득이 기준을 명확히 넘으면 보내지 않습니다. + +발송 이력을 **먼저** 저장하는 것도 의도적입니다. 이 서비스는 Blue/Green 배포라 인스턴스가 잠깐 +2대가 될 수 있고, 그러면 유니크 제약만이 중복을 막습니다. + +## 지원금 총액 계산 + +```mermaid +flowchart LR + P[정책 목록] --> ELIG{자격 판정} + ELIG -->|미충족| DROP[제외] + ELIG -->|충족| TYPE{지급 유형} + TYPE -->|월정액| M["금액 × min(지급개월, 남은개월)"] + TYPE -->|일시금| L[금액 그대로] + TYPE -->|융자·현물| X[총액에서 제외
별도 안내] + TYPE -->|금액 미상| U[unknownAmountCount 로 노출] + M & L --> EX{배타 그룹} + EX -->|같은 그룹| MAX[최댓값 하나만] + EX -->|무관| ADD[합산] + MAX & ADD --> TOTAL[예상 총액] + + style X fill:#f8d7da,stroke:#dc3545 + style U fill:#fff3cd,stroke:#ffc107 +``` + +이 계산은 처음에 **2억 9,506만 원** 이라는 값을 냈습니다. 원인이 두 가지였습니다. + +1. 자격 요건(자녀수·소득)을 보지 않고 전부 더했습니다. +2. **대상 연령 상한을 지급 기간으로 착각**했습니다. 아빠육아휴직보너스 250만 원을 60개월 곱하면 + 1억 5천만 원이 됩니다. + +지급 기간 컬럼을 분리하고 자격 판정을 넣어 **8,056만 원**이 되었습니다. +자세한 내용은 [지원금 지능화 문서](../features/benefit-intelligence.md)에 있습니다. diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md new file mode 100644 index 00000000..4d48ce87 --- /dev/null +++ b/docs/architecture/system-overview.md @@ -0,0 +1,169 @@ +# 시스템 개요 + +## 한 장으로 보는 구성 + +```mermaid +graph TB + subgraph clients["클라이언트"] + APP["모바일 · 웹"] + end + + subgraph edge["진입 계층"] + SEC["SecurityConfig
JWT · OAuth2(카카오)"] + RATE["RateLimitInterceptor
Redis 카운터"] + EXH["전역 예외 핸들러
404·403 을 5xx 로 새지 않게"] + end + + subgraph app["애플리케이션"] + CTRL["Controller 20종"] + FACADE["Facade · Service"] + GUARD["ConsentGuard
민감정보 접근 차단"] + end + + subgraph domains["도메인"] + POLICY["policy
지원금"] + FACILITY["careFacility
어린이집·유치원"] + HEALTHD["health
건강기록·병원"] + USERD["user
계정·자녀·동의"] + NOTI["notification"] + COMM["community"] + BOT["chatbot"] + end + + subgraph batch["배치 · 수집"] + SCHED["PublicDataSyncScheduler"] + SYNC["Sync 서비스
PagedSyncTemplate"] + PROVIDER["PublicDataProvider
공급자 추상화"] + GEO["KakaoGeocoder
좌표 보정"] + end + + subgraph store["저장소"] + DB[("MariaDB
Flyway V1~V17")] + REDIS[("Redis
캐시·레이트리밋·토큰")] + FILES["파일 저장소"] + end + + subgraph external["외부"] + GOV1["보육통합정보시스템
어린이집"] + GOV2["유치원알리미
유치원"] + GOV3["보조금24
정부지원 서비스"] + GOV4["심평원
소아청소년과"] + KAKAO["카카오 로컬 API"] + FCM["FCM · SMTP · SMS"] + SLACK["Slack Webhook"] + end + + APP --> SEC --> RATE --> CTRL + CTRL --> EXH + CTRL --> FACADE --> GUARD + FACADE --> POLICY & FACILITY & HEALTHD & USERD & NOTI & COMM & BOT + + SCHED --> SYNC --> PROVIDER + PROVIDER --> GOV1 & GOV2 & GOV3 & GOV4 + SCHED --> GEO --> KAKAO + + POLICY & FACILITY & HEALTHD & USERD & NOTI & COMM --> DB + SYNC --> DB + FACADE --> REDIS + NOTI --> FCM + SCHED --> SLACK + HEALTHD --> FILES + + classDef ext fill:#fff4e6,stroke:#d9822b + classDef st fill:#e8f4fd,stroke:#2b6cb0 + class GOV1,GOV2,GOV3,GOV4,KAKAO,FCM,SLACK ext + class DB,REDIS,FILES st +``` + +## 계층 구조 + +| 계층 | 패키지 | 책임 | +|------|--------|------| +| 진입 | `core.security`, `core.RateLimitInterceptor`, `core.handler` | 인증·인가, 호출 제한, 예외의 상태코드 변환 | +| 표현 | `domain.*.controller` | HTTP 계약. 비즈니스 판단은 하지 않음 | +| 조합 | `domain.*.facade` | 여러 서비스를 묶어 화면 단위 응답을 만듦 | +| 도메인 | `domain.*.service` | 판단과 계산. 대부분의 설계 결정이 여기 있음 | +| 수집 | `core.client` | 공공데이터 공급자 추상화와 동기화 | +| 지표 | `core.analytics` | 행동 이벤트 적재와 퍼널·리텐션 집계 | +| 운영 | `core.ops`, `core.scheduler` | 알림, 주기 실행 | + +## 요청 처리 흐름 + +```mermaid +sequenceDiagram + participant C as 클라이언트 + participant S as SecurityFilterChain + participant R as RateLimitInterceptor + participant Ctrl as Controller + participant G as ConsentGuard + participant Svc as Service + participant DB as MariaDB + participant E as EventLogger + + C->>S: 요청 (+ JWT) + alt 공개 경로 + S->>R: 통과 + else 보호 경로 + S->>S: 토큰 검증 + S--xC: 401 (실패 시) + end + R->>R: Redis 카운터 확인 + R--xC: 429 (초과 시) + R->>Ctrl: 진입 + Ctrl->>G: 민감정보 접근이면 동의 확인 + G--xC: 403 CONSENT_REQUIRED (미동의 시) + Ctrl->>Svc: 위임 + Svc->>DB: 조회·저장 + Svc-)E: 행동 이벤트 (비동기, 실패해도 응답에 영향 없음) + Svc-->>Ctrl: 결과 + Ctrl-->>C: 200 (+ ETag) +``` + +`EventLogger` 는 **비동기이고 큐가 차면 버립니다**. 지표 수집이 사용자 응답을 느리게 하거나 +실패시키면 본말이 전도되기 때문입니다. 지표는 유실을 감수하고, 응답은 감수하지 않습니다. + +## 배치 실행 시각 + +모두 `Asia/Seoul` 기준이며 프로퍼티로 덮어쓸 수 있습니다. + +```mermaid +gantt + title 일일 배치 순서 + dateFormat HH:mm + axisFormat %H:%M + + section 수집 + 어린이집 동기화 (월) :03:00, 30m + 정부지원 서비스 동기화 :03:30, 30m + 유치원 동기화 (월) :04:00, 30m + 병원 동기화 (화) :03:00, 30m + + section 정제 + 좌표 보정 :05:00, 30m + + section 발송 + 정책 변경 알림 :09:00, 30m + 빈자리 알림 :09:30, 30m + 마감 임박 알림 :10:00, 30m + 실수령액 제보 요청 (수) :10:00, 30m +``` + +순서에는 이유가 있습니다. + +- **수집이 먼저, 발송이 나중**입니다. 알림은 그날 들어온 데이터를 근거로 나가야 합니다. +- **좌표 보정은 수집 뒤**입니다. 새로 들어온 시설이 보정 대상에 포함되어야 합니다. +- **빈자리 알림은 시설 동기화 뒤**입니다. 새 정원이 반영되어야 그날 난 자리가 잡힙니다. +- **어린이집과 유치원은 1시간 벌립니다.** 둘 다 전국 200여 개 시군구를 순회해서 오래 걸립니다. + +자세한 내용은 [운영 문서](../features/operations.md)를 보세요. + +## 저장소 사용 구분 + +| 저장소 | 용도 | 없으면 | +|--------|------|--------| +| MariaDB | 모든 영속 데이터 | 기동 불가 | +| Redis | 캐시, 레이트리밋, 리프레시 토큰 | **기동 불가** — `RateLimitingAspect` 가 `StringRedisTemplate` 을 요구 | +| 파일 저장소 | 건강기록 첨부 | 첨부 기능만 실패 | + +Redis 가 필수라는 점은 로컬 개발에서 자주 걸립니다. 캐시는 `spring.cache.type=none` 으로 끌 수 있지만 +레이트리밋은 끌 수 없습니다. 이건 [기동 안정화 문서](../quality/runtime-hardening.md)에 기록해 두었습니다. From 12a875783cd9370f96ec2c3ac715ac12b8980dce Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 23:56:20 +0900 Subject: [PATCH 53/68] =?UTF-8?q?DOCS=20:=20=EA=B8=B0=EB=8A=A5=EB=B3=84=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C=207=EC=A2=85=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 무엇을 만들었는지가 아니라 왜 그렇게 만들었는지를 남긴다. 대부분의 판단은 '공공데이터가 무엇을 주지 않는가' 에서 출발했다. 반별 정원을 주지 않아 빈자리 알림에 한계를 명시하고, 금액을 숫자로 주지 않아 제보와 검증을 만들었다. --- docs/features/analytics.md | 116 +++++++++++ docs/features/benefit-intelligence.md | 219 ++++++++++++++++++++ docs/features/facility-intelligence.md | 193 +++++++++++++++++ docs/features/notification-and-retention.md | 201 ++++++++++++++++++ docs/features/operations.md | 179 ++++++++++++++++ docs/features/privacy-and-legal.md | 133 ++++++++++++ docs/features/public-data-integration.md | 180 ++++++++++++++++ 7 files changed, 1221 insertions(+) create mode 100644 docs/features/analytics.md create mode 100644 docs/features/benefit-intelligence.md create mode 100644 docs/features/facility-intelligence.md create mode 100644 docs/features/notification-and-retention.md create mode 100644 docs/features/operations.md create mode 100644 docs/features/privacy-and-legal.md create mode 100644 docs/features/public-data-integration.md diff --git a/docs/features/analytics.md b/docs/features/analytics.md new file mode 100644 index 00000000..232a2093 --- /dev/null +++ b/docs/features/analytics.md @@ -0,0 +1,116 @@ +# 지표 수집 + +> 관련 이슈: #68 · 관련 마이그레이션: V8 + +## 문제 + +"사용자가 늘고 있다" 는 말은 아무것도 설명하지 못합니다. + +- 가입한 사람 중 몇 명이 **아이를 등록**했는가 +- 아이를 등록한 사람 중 몇 명이 **주소를 넣었는가** +- 주소를 넣은 사람 중 몇 명이 **추천을 봤는가** +- 추천을 본 사람 중 몇 명이 **신청 링크를 눌렀는가** + +이걸 모르면 어디를 고쳐야 할지 알 수 없습니다. +개선은 짐작이 아니라 **어느 단계에서 사람이 빠져나가는지** 를 보고 결정해야 합니다. + +## 이벤트 수집 + +```mermaid +flowchart LR + SVC["서비스 코드"] -->|"log()"| EL["EventLogger
@Async"] + EL --> Q["analyticsExecutor
큐"] + Q --> DB[("TBL_USER_EVENT")] + Q -.->|"큐가 차면"| DROP["버림
DiscardPolicy"] + + style DROP fill:#f8d7da,stroke:#dc3545 +``` + +**비동기이고, 큐가 차면 버립니다.** + +지표 수집이 사용자 응답을 느리게 하거나 실패시키면 본말이 전도됩니다. +지표는 유실을 감수하고, 응답은 감수하지 않습니다. +트래픽이 몰릴 때 지표 몇 건을 잃는 것은 서비스가 느려지는 것보다 훨씬 낫습니다. + +## 수집하는 이벤트 + +| 분류 | 이벤트 | +|------|--------| +| 온보딩 | `SIGNED_UP`, `CHILD_REGISTERED`, `ADDRESS_REGISTERED`, `INCOME_REGISTERED` | +| 지원금 | `MISSED_BENEFIT_VIEWED`, `BENEFIT_LINK_CLICKED`, `RECOMMENDATION_VIEWED`, `REGIONAL_COMPARISON_VIEWED` | +| 시설 | `FACILITY_VIEWED`, `ADMISSION_FORECAST_VIEWED`, `FACILITY_POPULARITY_VIEWED`, `WAITLIST_REGISTERED` | +| 알림 | `NOTIFICATION_SENT`, `NOTIFICATION_CLICKED` | +| 참여 | `BENEFIT_AMOUNT_REPORTED`, `APP_OPENED`, `BOOKING_CREATED`, `CHATBOT_ASKED` | + +## 온보딩 퍼널 + +```mermaid +flowchart TD + A["SIGNED_UP
가입"] --> B["CHILD_REGISTERED
아이 등록"] + B --> C["ADDRESS_REGISTERED
주소 입력"] + C --> D["RECOMMENDATION_VIEWED
추천 조회"] + D --> E["BENEFIT_LINK_CLICKED
신청 링크 클릭"] + + A -.->|이탈| X1[" "] + B -.->|이탈| X2[" "] + C -.->|이탈| X3[" "] + D -.->|이탈| X4[" "] + + style E fill:#d4edda,stroke:#28a745 +``` + +### 전환율 계산 방식 + +각 단계의 전환율은 **직전 단계를 통과한 사용자만** 분모로 씁니다. + +전체 가입자를 분모로 쓰면 "주소 입력률 30%" 같은 숫자가 나오는데, +이건 아이 등록에서 빠진 사람까지 포함한 값이라 **주소 입력 화면의 문제인지 아이 등록의 문제인지 +구분할 수 없습니다.** + +## 알림 전환 퍼널 + +`NOTIFICATION_SENT` → `NOTIFICATION_CLICKED` 를 알림 종류별로 봅니다. + +이게 [알림 기능](notification-and-retention.md)의 효과를 판단하는 유일한 방법입니다. +발송 수만 세면 "많이 보냈다" 는 것 외에 아무것도 알 수 없습니다. + +어떤 알림의 클릭률이 낮다면 그 알림은 **가치가 없거나 문구가 잘못된 것**이고, +그건 발송을 줄여야 한다는 신호입니다. + +## 코호트 리텐션 + +가입 주차별로 묶어 이후 재방문을 봅니다. + +응답 형태 (수치는 **설명용 예시**이며 실측값이 아닙니다): + +| 가입 코호트 | D1 | D7 | D30 | +|-------------|----|----|-----| +| 5주 전 가입 | 값 | 값 | 값 | +| 2주 전 가입 | 값 | 값 | **null** | +| 이번 주 가입 | 값 | **null** | **null** | + +**아직 오지 않은 시점은 0이 아니라 `null`** 입니다. + +가입 1주일 된 코호트의 D30 리텐션을 0%로 표시하면 "리텐션이 무너지고 있다" 는 착시가 생깁니다. +측정할 수 없는 것과 0인 것은 다릅니다. + +## 관련 API + +| 메서드 | 경로 | 인증 | +|--------|------|------| +| GET | `/api/admin/analytics/funnel` | 관리자 | +| GET | `/api/admin/analytics/events` | 관리자 | +| GET | `/api/admin/analytics/notification-funnel` | 관리자 | +| GET | `/api/admin/analytics/retention` | 관리자 | + +## 개인정보 관점 + +이벤트에는 사용자 ID 와 대상 식별자만 남기고 **개인 식별 정보는 담지 않습니다.** +회원 탈퇴 시 익명화 대상에 포함됩니다. 자세한 내용은 [개인정보 문서](privacy-and-legal.md)를 보세요. + +## 미해결 + +| 항목 | 내용 | +|------|------| +| 검색어 로그 | 사용자가 무엇을 찾는지 = 다음에 무엇을 만들지의 근거인데, 아직 수집하지 않습니다 | +| 이벤트 보존 기간 | 무한 적재 중입니다. 파티셔닝이나 아카이빙 정책이 필요합니다 | diff --git a/docs/features/benefit-intelligence.md b/docs/features/benefit-intelligence.md new file mode 100644 index 00000000..d7bf2c88 --- /dev/null +++ b/docs/features/benefit-intelligence.md @@ -0,0 +1,219 @@ +# 지원금 지능화 + +> 관련 이슈: #65 #67 #69 · 관련 마이그레이션: V6, V7, V9, V11, V12, V14 + +## 문제 + +육아 지원금은 **중앙정부와 지자체가 따로 운영**합니다. +받을 수 있는데 몰라서 못 받는 경우가 흔하고, 조건이 복잡해 본인이 대상인지 판단하기 어렵습니다. + +"목록을 보여준다" 는 검색만으로는 이 문제를 풀지 못합니다. +**이 사람이 얼마를 받을 수 있는지**를 계산해야 합니다. + +## 기능 구성 + +```mermaid +mindmap + root((지원금 지능화)) + 맞춤 추천 + 자녀 월령 + 거주지 + 소득분위 + 자녀수 + 놓친 지원금 + 지나온 월령 구간 역추적 + 소급 신청 가능 여부 + 마감까지 남은 개월 + 지역 비교 + 현재 거주지 총액 + 다른 지역 총액 + 차액과 근거 + 금액 신뢰도 + 수기 검증 + 실수령액 제보 + 3인 합의 + 변경 감지 + 금액·기한·연령 + 지역별 알림 +``` + +## 자격 판정 + +세 가지를 봅니다. 판정 결과는 **세 갈래**입니다. + +| 결과 | 조건 | 처리 | +|------|------|------| +| `ELIGIBLE` | 요건을 모두 충족 | 총액에 포함 | +| `NOT_ELIGIBLE` | 자녀수 미달 또는 소득 초과 | 제외 | +| `UNKNOWN` | 소득을 입력하지 않음 | **제외하지 않고 보류로 표기** | + +소득 미입력을 탈락으로 처리하면 **받을 수 있었던 지원금이 통째로 사라집니다.** +사용자는 자기가 왜 목록에서 그걸 못 봤는지도 모릅니다. + +## 수령액 계산 — 두 번의 큰 오류 + +### 처음 결과: 2억 9,506만 원 + +지역 비교 기능을 붙이고 실행했더니 한 지역의 예상 총액이 **2억 9,506만 원**으로 나왔습니다. +명백히 틀린 값입니다. + +### 원인 1 — 자격 요건을 보지 않았다 + +`minChildren`(최소 자녀수)과 `incomeThresholdPercent`(소득 기준)를 검사하지 않고 +지역에 있는 정책을 전부 더하고 있었습니다. 다자녀 전용 지원금이 외동 가정에도 합산됐습니다. + +### 원인 2 — 대상 연령을 지급 기간으로 착각했다 + +이쪽이 더 컸습니다. `targetAgeMax`(대상 연령 상한, 개월)를 **지급 개월 수**로 쓰고 있었습니다. + +> 아빠육아휴직보너스: 월 250만 원 × `targetAgeMax` 60 = **1억 5천만 원** + +실제로는 최대 3개월만 지급됩니다. `max_payment_months` 컬럼을 분리(V7)하고 +지급 유형을 판별하도록 고쳤습니다. + +### 수정 후: 8,056만 원 + +```mermaid +flowchart TD + A["2억 9,506만 원
(초기값)"] --> B[자격 요건 검사 추가] + B --> C[지급 기간 컬럼 분리] + C --> D["8,056만 원
(수정 후)"] + + style A fill:#f8d7da,stroke:#dc3545 + style D fill:#d4edda,stroke:#28a745 +``` + +## 지급 유형 판별 + +`BenefitPaymentType` 이 정책 설명에서 지급 방식을 구분합니다. + +| 유형 | 계산 | 이유 | +|------|------|------| +| 월정액 | 금액 × min(지급개월, 남은 대상 개월) | 실제 받을 기간만큼만 | +| 일시금 | 금액 그대로 | 한 번 받고 끝 | +| 융자 | **총액에서 제외** | 갚아야 하는 돈이라 "받는 돈" 이 아님 | +| 현물·바우처 | 총액에서 제외 | 현금 총액과 섞으면 오해를 부름 | +| 미상 | `unknownAmountCount` 로 노출 | 버리면 존재 자체를 모름 | + +**융자를 지원금 총액에 넣으면 안 됩니다.** 연 1.5% 대출 3천만 원을 "받을 수 있는 돈" 이라고 +표시하면 그건 거짓말입니다. + +## 중복 수급 배타 그룹 (V14) + +같은 목적의 지원금은 **동시에 받을 수 없습니다.** 예를 들어 부모급여와 양육수당은 택일입니다. +전부 더하면 실제로 받을 수 없는 금액이 나옵니다. + +`exclusion_group` 컬럼으로 묶고, 같은 그룹 안에서는 **가장 큰 금액 하나만** 총액에 넣습니다. + +```mermaid +flowchart LR + subgraph g1["exclusion_group = 'INFANT_CASH'"] + A[부모급여 100만] + B[양육수당 10만] + end + subgraph g2["그룹 없음"] + C[첫만남이용권 200만] + end + A & B --> MAX["최댓값 100만"] + MAX --> SUM["총액 300만"] + C --> SUM + + style MAX fill:#fff3cd,stroke:#ffc107 +``` + +## 금액 신뢰도 — 두 가지 경로 + +정책 금액은 자유 텍스트에서 추출하므로 틀릴 수 있습니다. +**자동 파싱을 신뢰하지 않기로** 하고, 대신 두 가지를 만들었습니다. + +### 1. 수기 검증 (V9) + +관리자가 확인한 정책에 `verified_at`, `verified_by` 를 남깁니다. +응답에 **지역별 검증 비율**을 함께 노출해서 사용자가 얼마나 믿을지 판단할 수 있게 합니다. + +### 2. 실수령액 제보와 합의 (V12) + +실제로 받은 사람에게 금액을 묻습니다. + +```mermaid +flowchart TD + ASK[주 1회 제보 요청 알림] --> REP[사용자가 실수령액 입력] + REP --> N{같은 금액
제보가 3건 이상?} + N -->|아니오| PEND[참고값으로만 표기] + N -->|예| CONS[합의값으로 확정] + CONS --> SHOW[지원금 상세에 표기] + + style CONS fill:#d4edda,stroke:#28a745 + style PEND fill:#fff3cd,stroke:#ffc107 +``` + +3인 합의를 기준으로 삼는 이유는, 한 사람의 오타나 착각이 전체 금액을 흔들면 안 되기 때문입니다. + +제보 요청은 **주 1회(수요일)** 만 보냅니다. 매일 물으면 소음이 됩니다. + +## 놓친 지원금 + +아이가 **이미 지나온 월령 구간**을 훑어 대상이었던 지원금을 찾습니다. + +```mermaid +flowchart LR + A[아이 생년월일] --> B[현재 월령 계산] + B --> C{targetAgeMax 를
지났는가} + C -->|아니오| SKIP[지금도 대상] + C -->|예| D[소급 가능 기간 확인] + D --> E{retroactive_months
안에 있는가} + E -->|예| CLAIM["claimable
아직 신청 가능"] + E -->|아니오| EXP["expired
기회를 놓침"] + + style CLAIM fill:#d4edda,stroke:#28a745 + style EXP fill:#f8d7da,stroke:#dc3545 +``` + +놓친 것을 `claimable`(아직 받을 수 있음)과 `expired`(놓침)로 나눠서 보여줍니다. +`expired` 를 굳이 보여주는 이유는, 둘째를 준비하는 부모에게는 그게 중요한 정보이기 때문입니다. + +> 이 기능은 사후 대응입니다. 놓치기 **전에** 막는 쪽이 낫다는 판단으로 +> [마감 임박 알림](notification-and-retention.md#신청-마감-임박-알림)을 추가했습니다. + +## 지역별 비교 + +같은 조건에서 **다른 지역에 살면 얼마를 더 받는지** 계산합니다. +이사를 고민하는 가정에게는 실질적인 판단 재료이고, 지자체 간 격차를 드러내는 데이터이기도 합니다. + +응답에는 총액만이 아니라 **차액의 근거가 되는 정책 목록**을 함께 담습니다. +숫자만 주면 믿을 이유가 없습니다. + +## 정책 변경 감지 (V11) + +동기화할 때마다 이전 값과 비교해 변경을 기록합니다. + +| 변경 유형 | 감지 대상 | +|-----------|-----------| +| `CREATED` | 새 정책 등록 | +| `AMOUNT_CHANGED` | 지원 금액 변경 | +| `DEADLINE_CHANGED` | 신청 기한 변경 | +| `AGE_RANGE_CHANGED` | 대상 연령 변경 | + +기록된 변경은 [해당 지역 사용자에게 알림](notification-and-retention.md)으로 나갑니다. + +## 관련 API + +| 메서드 | 경로 | 인증 | +|--------|------|------| +| GET | `/policies` | 공개 | +| GET | `/policies/search` | 공개 | +| GET | `/policies/categories` | 공개 | +| GET | `/policies/statistics` | 공개 | +| GET | `/policies/{id}` | 공개 | +| GET | `/policies/recommendations` | 인증 | +| GET | `/policies/missed-benefits` | 인증 | +| GET | `/policies/regional-comparison` | 인증 | +| POST | `/policies/{id}/amount-reports` | 인증 | +| GET/POST/DELETE | `/policies/bookmarks` | 인증 | + +## 미해결 + +| 항목 | 내용 | +|------|------| +| 상위 30개 지자체 금액 수기 검증 | 자동 파싱 값은 참고용입니다. 사람이 확인해야 신뢰할 수 있습니다 | +| 배타 그룹 데이터 입력 | `exclusion_group` 은 구조만 있고 실제 그룹 지정은 수기 작업이 필요합니다 | diff --git a/docs/features/facility-intelligence.md b/docs/features/facility-intelligence.md new file mode 100644 index 00000000..cc55ef34 --- /dev/null +++ b/docs/features/facility-intelligence.md @@ -0,0 +1,193 @@ +# 시설 지능화 + +> 관련 이슈: #65 #67 #69 #73 · 관련 마이그레이션: V5, V13, V16 + +## 문제 + +어린이집 대기는 부모가 겪는 가장 답답한 일 중 하나입니다. +**언제 자리가 나는지 아무도 알려주지 않습니다.** 시설에 전화해도 "기다려 보세요" 가 전부입니다. + +정부 API 는 지금 이 순간의 정원과 현원을 줍니다. 그런데 그것만으로는 +"이 시설은 자리가 잘 나는 곳인가" 를 알 수 없습니다. **과거를 주지 않기 때문입니다.** + +## 해결의 출발점 — 관측을 쌓는다 + +```mermaid +flowchart LR + SYNC["동기화
(주 1회)"] --> SNAP["정원 스냅샷
일자별 1행"] + SNAP --> F1[입소 예측] + SNAP --> F2[시설 인기도] + SNAP --> F3[빈자리 감지] + + style SNAP fill:#fff3cd,stroke:#ffc107 +``` + +`FacilityCapacitySnapshot`(V5)은 기능이 아니라 **다른 세 기능의 재료**입니다. +시설 행은 최신값만 갖고, 추이는 여기에 쌓입니다. + +하루에 여러 번 동기화해도 같은 날짜면 갱신만 하므로 일자별로 한 행만 남습니다. + +## 입소 가능 시점 예측 + +관측 이력에서 자리가 났던 횟수를 세어 확률을 추정합니다. + +```mermaid +flowchart TD + A[목표 시점 입력] --> B{관측이 있는가} + B -->|없음| N1["available=false
'관측 이력이 아직 없습니다'"] + B -->|있음| C{관측 기간이
충분한가} + C -->|아니오| N2["available=false
기간 부족"] + C -->|예| D[자리 발생 빈도 계산] + D --> E[목표 시점까지 확률 추정] + E --> F["probability + 근거 문구"] + + style N1 fill:#f8f9fa,stroke:#adb5bd + style N2 fill:#f8f9fa,stroke:#adb5bd + style F fill:#d4edda,stroke:#28a745 +``` + +**근거가 부족하면 확률을 만들어내지 않습니다.** `available=false` 와 이유를 돌려줍니다. + +이건 테스트로 고정해 두었습니다 — "관측이 없으면 확률을 만들어내지 않는다". +그럴듯한 숫자를 보여주는 쪽이 사용자 경험은 좋아 보이지만, 그 숫자를 믿고 다른 시설을 포기한 +부모에게는 피해입니다. + +## 시설 인기도 + +충원율 **추이**로 판단합니다. 현재 충원율만 보면 정원이 작은 시설이 항상 높게 나옵니다. + +- 충원율이 계속 높게 유지 → 인기 있음 +- 자리가 나도 금방 채워짐 → 인기 있음 +- 정원 대비 대기 등록이 많음 → 인기 있음 + +## 대기 기록 (V13) + +정원 관측은 "자리가 났는가" 만 알려줍니다. **대기 순번이 언제 도는지는 겪은 사람만 압니다.** +그래서 사용자가 직접 기록하게 합니다. + +| 상태 | 의미 | +|------|------| +| `WAITING` | 대기 중 | +| `ADMITTED` | 입소 | +| `GAVE_UP` | 포기 | + +입소·포기 시점이 찍혀야 **대기 기간 데이터가 완성**됩니다. + +### 통계는 표본이 모여야 낸다 + +```mermaid +flowchart LR + A[입소 기록 수집] --> B{3건 이상인가} + B -->|아니오| C["available=false
'입소 기록이 N건으로 부족합니다'"] + B -->|예| D[평균 · 중앙값 · 최대 대기일] + D --> E["근거 문구와 함께 응답"] + + style C fill:#f8f9fa,stroke:#adb5bd + style E fill:#d4edda,stroke:#28a745 +``` + +표본이 3건 미만이면 평균이 우연에 좌우됩니다. 그럴 때는 숫자 대신 이유를 돌려줍니다. + +응답에는 "입소한 N명의 실제 기록 기준입니다", "절반이 N개월 안에 입소했습니다" 처럼 +**근거를 문장으로** 함께 담습니다. 숫자만으로는 얼마나 믿을지 판단할 수 없습니다. + +## 빈자리 알림 (V16) — 끊겨 있던 루프 + +여기가 이 도메인에서 가장 큰 구멍이었습니다. + +**정원 스냅샷은 자리가 났다는 사실을 알고 있었고 대기 명단도 있었는데, 둘을 잇는 코드가 없었습니다.** +입소 예측까지 만들어 놓고 정작 그 예측이 맞았을 때 알려주지 않았습니다. +사용자는 직접 들어와 확인해야만 알 수 있었습니다. + +### 판단 기준: "있다" 가 아니라 "늘었다" + +```mermaid +flowchart TD + A[대기자가 있는 시설만 조회] --> B[최근 30일 스냅샷] + B --> C{관측 2회 이상} + C -->|아니오| S1[증감을 알 수 없음] + C -->|예| D[직전 대비 증감] + D --> E{증가분 ≥ 기준} + E -->|아니오| S2[이미 있던 자리] + E -->|예| F{시설이 활성인가} + F -->|아니오| S3[건너뜀] + F -->|예| G[대기자 순회] + G --> H{최근에 알렸는가} + H -->|예| S4[간격 미달] + H -->|아니오| I[알림 발송] + I --> J[관측일 기록] + + style I fill:#d4edda,stroke:#28a745 +``` + +빈자리가 **계속 있는** 시설은 사용자도 이미 압니다. 매일 알리면 그냥 스팸입니다. +그래서 새로 늘어난 자리만 알립니다. + +### 빈자리 계산 — 두 가지 경로 + +공공데이터는 빈자리를 직접 주기도 하고 정원·현원만 주기도 합니다. + +1. `availableSpots` 가 있으면 그대로 사용 +2. 없으면 `capacity - currentEnrollment` +3. 둘 다 없으면 **판단하지 않음** (0으로 간주하지 않음) + +없는 값을 0으로 채우면 "자리가 났다" 는 잘못된 알림이 나갑니다. + +### 알리지 않는 것도 설계다 + +| 상황 | 처리 | 이유 | +|------|------|------| +| 관측 1회 | 발송 안 함 | 늘었는지 줄었는지 알 수 없음 | +| 빈자리 동일 | 발송 안 함 | 이미 알고 있음 | +| 빈자리 감소 | 발송 안 함 | 알릴 내용이 아님 | +| 최근 14일 내 발송 | 발송 안 함 | 같은 자리 반복 알림은 신뢰를 잃음 | +| 대기자 없음 | 시설 조회조차 안 함 | 전국 시설을 다 뒤지면 대부분이 헛일 | + +### 알림 문구에 한계를 밝힌다 + +공공데이터는 **시설 전체 정원만** 줍니다. 어느 반에 자리가 났는지는 알 수 없습니다. +0세반이 찼는데 5세반에 자리가 난 것일 수도 있습니다. + +> "대기 등록해 두신 행복어린이집의 빈자리가 2자리 늘어 현재 3자리입니다. (2026-08-06 관측 기준) +> **시설 전체 기준이라 해당 반에 자리가 있는지는 시설에 확인해 보세요.**" + +이 한 문장이 없으면 부모가 헛걸음합니다. 정확한 척하지 않는 것이 더 나은 제품입니다. + +### 실기동 검증 + +| 시나리오 | 결과 | +|----------|------| +| 빈자리 0 → 3 변화, 대기자 1명 | **알림 1건 발송** | +| 같은 조건 재실행 | **0건** (중복 방지 동작) | +| 대기 기록 `VACANCY_NOTIFIED_AT` | 관측일 기록 확인 | + +## 관련 API + +| 메서드 | 경로 | 인증 | +|--------|------|------| +| GET | `/facilities` | 공개 | +| GET | `/facilities/radius` | 공개 | +| GET | `/facilities/popular` | 공개 | +| GET | `/facilities/statistics` | 공개 | +| GET | `/facilities/{facilityId}/admission-forecast` | 공개 | +| GET | `/facilities/search` | 인증 | +| POST | `/facilities/{facilityId}/waitlist` | 인증 | +| GET | `/facilities/waitlist/me` | 인증 | +| PATCH | `/facilities/waitlist/{waitlistId}` | 인증 | +| GET | `/facilities/{facilityId}/waitlist/stats` | 공개 | +| POST | `/api/admin/public-data/facilities/notify-vacancy` | 관리자 | + +## 설정 + +| 프로퍼티 | 기본값 | 설명 | +|----------|--------|------| +| `app.facility-vacancy.min-interval-days` | 14 | 같은 사람에게 다시 알리기까지 최소 간격 | +| `app.facility-vacancy.min-increase` | 1 | 이만큼 늘어야 알림 | +| `app.scheduler.public-data.vacancy-cron` | `0 30 9 * * *` | 실행 시각 (시설 동기화 이후) | + +## 미해결 + +| 항목 | 내용 | +|------|------| +| 반별 정원 | 공공데이터가 주지 않습니다. 시설 직접 입력이나 크라우드 제보가 필요합니다 | +| 대기 순번 검증 | 사용자가 입력한 순번을 검증할 방법이 없습니다 | diff --git a/docs/features/notification-and-retention.md b/docs/features/notification-and-retention.md new file mode 100644 index 00000000..ebff27b2 --- /dev/null +++ b/docs/features/notification-and-retention.md @@ -0,0 +1,201 @@ +# 알림과 리텐션 + +> 관련 이슈: #69 #73 #74 · 관련 마이그레이션: V11, V16, V17 + +## 문제 + +이 앱은 **"한 번 보고 끝"** 이 되기 쉽습니다. +지원금을 한 번 조회하고 나면 다시 열 이유가 없습니다. + +알림은 다시 열 이유를 만드는 **유일한 경로**입니다. 그런데 알림이 성가시면 앱을 지웁니다. +그래서 이 도메인의 설계는 대부분 **"언제 보내지 않을 것인가"** 에 대한 것입니다. + +## 알림 3종 + +```mermaid +flowchart LR + subgraph src["재료"] + S1[정책 변경 이력] + S2[정원 스냅샷] + S3[신청 마감일] + end + + subgraph det["감지"] + D1["PolicyChangeNotifier
금액·기한·연령이 바뀜"] + D2["FacilityVacancyNotifier
빈자리가 늘었음"] + D3["PolicyDeadlineNotifier
D-7 · D-1"] + end + + subgraph guard["중복 방지"] + G1["markNotified()"] + G2["vacancyNotifiedAt
+ 최소 간격"] + G3["발송 이력 테이블
+ 유니크 제약"] + end + + S1 --> D1 --> G1 + S2 --> D2 --> G2 + S3 --> D3 --> G3 + G1 & G2 & G3 --> SEND["NotificationDispatcher"] + SEND --> CH["EMAIL · PUSH · SMS"] +``` + +| 알림 | 계기 | 대상 | 실행 | +|------|------|------|------| +| 정책 변경 | 동기화에서 금액·기한·연령 변경 감지 | 해당 지역 사용자 | 매일 09:00 | +| 빈자리 | 대기 시설의 빈자리 **증가** | 그 시설 대기자 | 매일 09:30 | +| 마감 임박 | 신청 마감 D-7, D-1 | 조건이 맞는 사용자 | 매일 10:00 | +| 제보 요청 | 지원금 수령 여부 확인 | 대상자 | 주 1회 (수 10:00) | + +## 신청 마감 임박 알림 + +### 왜 필요했나 + +`MissedBenefitService` 는 **이미 놓친 것을 사후에** 알려줍니다. +놓치기 전에 막는 쪽이 훨씬 낫고, 사용자가 실제로 돈을 받게 되는 순간이 이 서비스의 유일한 증명입니다. + +`applicationEndDate` 컬럼도 있고 `DEADLINE_CHANGED` 변경 감지도 있었는데, +정작 "내 조건에 맞는 지원금이 D-7" 을 알려주는 기능은 없었습니다. + +### 언제 보내는가 + +남은 일수가 **정확히** D-7 또는 D-1 인 날에만 보냅니다. (`app.policy-deadline.lead-days`) + +- **D-7** — 서류를 준비할 시간을 줍니다. +- **D-1** — 그날 스케줄러가 실패했거나 알림을 놓친 사람에게 마지막 기회입니다. + +### 대상 판단 — 넓게, 그러나 명확히 아닌 건 제외 + +```mermaid +flowchart TD + U[활성 사용자] --> C1{자녀가 있는가} + C1 -->|없음| X1[제외] + C1 -->|있음| C2{지역이 맞는가} + C2 -->|아니오| X2[제외] + C2 -->|예| C3{자녀수 요건} + C3 -->|미달| X3[제외] + C3 -->|충족| C4{소득} + C4 -->|기준 초과| X4[제외] + C4 -->|미입력| OK1["포함 (판단 보류)"] + C4 -->|기준 이하| C5{연령이 맞는 아이} + OK1 --> C5 + C5 -->|없음| X5[제외] + C5 -->|있음| SEND[발송] + + style OK1 fill:#fff3cd,stroke:#ffc107 + style SEND fill:#d4edda,stroke:#28a745 +``` + +마감 알림은 성격상 **조금 넓게 보내는 편이 낫습니다.** +놓친 사람의 손해가 잘못 받은 알림의 성가심보다 훨씬 크기 때문입니다. +그래서 소득 미입력은 배제하지 않습니다. 반대로 소득이 기준을 명확히 넘으면 보내지 않습니다. + +### 중복 방지 — Blue/Green 에서 드러난 결함 + +처음 설계는 **"남은 일수가 D-7 인 날에만 보내니 하루 한 번"** 이었습니다. +별도 이력 테이블 없이 중복을 막는 깔끔한 방법이라고 생각했습니다. + +그런데 이 서비스는 **Blue/Green 배포**입니다. 배포 중에는 인스턴스가 잠깐 2대가 되고, +각 인스턴스의 스케줄러가 모두 돌면 **모든 알림이 두 번씩** 나갑니다. + +지원금 알림은 한 번 더 오는 순간 신뢰를 잃습니다. 그래서 발송 이력 테이블(V17)을 추가했습니다. + +```sql +CONSTRAINT UK_POLICY_DEADLINE_NOTICE UNIQUE (POLICY_ID, USER_ID, NOTIFIED_ON) +``` + +발송 이력을 **먼저** 저장하고 알림을 보냅니다. +존재 확인은 반복 실행을 막고, 유니크 제약은 동시 실행을 막습니다. + +### 실기동 검증 + +| 시나리오 | 결과 | +|----------|------| +| D-7 정책 2건 (청주·제주), 사용자는 청주 거주 | 지역이 맞는 **1건만 발송** | +| D-5 정책 | 대상에서 제외 | +| 같은 조건 **15회 재실행** | 모두 **0건** | +| 발송 이력 테이블 | 1행만 존재 | + +## 딥링크와 클릭 측정 + +알림을 보내는 것과 **알림이 효과가 있는 것**은 다릅니다. +발송 수만 세면 아무것도 알 수 없습니다. + +```mermaid +sequenceDiagram + participant N as Notifier + participant E as EventLogger + participant U as 사용자 + participant L as NotificationLinkController + + N->>E: NOTIFICATION_SENT (알림ID, 종류) + N->>U: 알림 발송 + U->>L: 딥링크 클릭 + L->>L: 리다이렉트 대상 검증 + L->>E: NOTIFICATION_CLICKED + L-->>U: 목적지로 이동 +``` + +`NOTIFICATION_SENT` → `NOTIFICATION_CLICKED` 전환율이 **알림의 효과를 판단하는 유일한 지표**입니다. +알림 종류별로 나눠 보면 어떤 알림이 실제로 가치 있는지 알 수 있습니다. + +### 오픈 리다이렉트 차단 + +딥링크와 지원금 신청 링크는 외부 URL 로 이동합니다. +검증 없이 리다이렉트하면 **우리 도메인을 경유한 피싱 통로**가 됩니다. +허용 대상을 확인한 뒤에만 이동합니다. + +## 알림 폭주 방지 + +정책 동기화 직후에는 수천 건의 변경이 한꺼번에 쌓일 수 있습니다. + +| 설정 | 기본값 | 목적 | +|------|--------|------| +| `app.policy-change.batch-size` | 200 | 한 번에 처리할 변경 수 | +| `app.policy-change.max-per-user` | 3 | 한 사람에게 보낼 최대 알림 수 (넘으면 묶어서 한 건) | + +10건이 바뀌었다고 10개를 보내면 그날로 알림을 끕니다. + +## 실패해도 표시한다 + +```java +} finally { + // 실패해도 표시해 둔다. 재시도로 같은 알림이 반복되는 편이 더 나쁘다. + change.markNotified(); +} +``` + +발송이 실패했을 때 재시도하지 않는 것은 의도적입니다. +누락 한 건보다 **같은 알림이 계속 오는 쪽**이 사용자에게 더 나쁩니다. + +## 발송 채널 + +`NotificationDispatcher` 가 EMAIL·PUSH·SMS 를 등록합니다. +FCM 자격증명이 없으면 **푸시만 비활성화**되고 나머지는 그대로 동작합니다. +기동을 막지 않습니다. + +## 관련 API + +| 메서드 | 경로 | 인증 | +|--------|------|------| +| GET | `/notifications` | 인증 | +| PATCH | `/notifications/{id}/read` | 인증 | +| GET | `/notifications/link/{id}` | 인증 (딥링크) | +| POST | `/api/admin/public-data/facilities/notify-vacancy` | 관리자 | +| POST | `/api/admin/public-data/policies/notify-deadline` | 관리자 | + +관리자 수동 실행이 있는 이유는, 스케줄러가 하루 한 번만 돌아서 +발송이 안 나갔을 때 원인을 확인하려면 다음 날까지 기다려야 하기 때문입니다. +확인한 시설 수까지 돌려주므로 **대기자가 없어서인지 자리가 안 나서인지** 구분됩니다. + +## 로그를 두 번 찍던 문제 + +스케줄러와 서비스가 **같은 결과를 각각 로그**하고 있었습니다. + +``` +17:30:24 PolicyDeadlineNotifier | 마감 임박 알림 - 정책 2건, 알림 1건 발송 +17:30:24 PublicDataSyncScheduler | 마감 임박 알림 - 정책 2건, 알림 1건 발송 +``` + +검증 중에 이걸 보고 **"두 번 실행되어 중복 발송됐다"** 고 잘못 판단했습니다. +운영 중에 같은 오해를 하면 없는 장애를 쫓게 됩니다. +서비스가 이미 남기므로 스케줄러 쪽 로그를 걷어냈습니다. (기존 4개 작업 모두 같은 문제였습니다) diff --git a/docs/features/operations.md b/docs/features/operations.md new file mode 100644 index 00000000..98710bb9 --- /dev/null +++ b/docs/features/operations.md @@ -0,0 +1,179 @@ +# 운영 + +> 관련 이슈: #68 #70 + +## 원칙 + +**사용자가 이미 실패를 겪은 뒤라면, 로그만 남겨서는 아무도 모릅니다.** + +이 도메인의 기능들은 전부 "문제가 생겼을 때 사람이 알게 하는 것" 에 대한 것입니다. +동시에, **문제가 아닌 것으로 사람을 깨우지 않는 것** 도 똑같이 중요합니다. + +## 운영 알림 + +`OperationalAlerter` 가 Slack 웹훅으로 보냅니다. + +```mermaid +flowchart TD + E[알릴 사건 발생] --> W{웹훅이
설정됐는가} + W -->|없음| LOG[로그로만 남김] + W -->|있음| C{같은 키로
최근 30분 내
보낸 적 있는가} + C -->|예| SKIP[건너뜀] + C -->|아니오| SEND[Slack 발송] + + style SKIP fill:#f8f9fa,stroke:#adb5bd + style SEND fill:#d4edda,stroke:#28a745 +``` + +**키별 30분 쿨다운**이 있습니다. 같은 장애가 초당 수십 번 발생할 때 +알림이 폭주하면 사람이 채널을 음소거하고, 그러면 알림 자체가 무의미해집니다. + +웹훅이 없으면 기동을 막지 않고 로그로만 남깁니다. 로컬 개발에서 Slack 을 요구하면 안 됩니다. + +### 무엇을 알리는가 + +| 사건 | 이유 | +|------|------| +| 동기화 미완료 | 데이터가 며칠씩 낡은 채로 서비스될 수 있음 | +| 동기화 부분 실패 | 특정 지역 데이터가 비어 있을 수 있음 | +| 공공데이터 한도 초과·키 만료 | 조용히 0건을 받으면 며칠 모르고 지나감 | +| 처리되지 않은 예외 | 5xx 는 사용자가 이미 실패를 겪은 뒤 | + +### 무엇을 알리지 않는가 + +이게 더 중요합니다. 초기에는 **없는 URL 요청과 권한 거부까지 운영 알림**으로 올라갔습니다. + +``` +[운영알림] 처리되지 않은 예외: NoResourceFoundException - No static resource hospitals. +``` + +없는 URL 은 잘못된 요청이지 장애가 아닙니다. 봇이 `/wp-admin` 을 긁고 가면 알림이 울립니다. +그러면 **진짜 장애가 그 소음에 묻힙니다.** + +전역 예외 핸들러에 다음을 추가해 걸러냅니다. + +| 예외 | 응답 | 알림 | +|------|------|------| +| `NoResourceFoundException` | 404 | 안 보냄 | +| `AuthorizationDeniedException` | 403 | 안 보냄 | +| 그 외 미처리 예외 | 500 | 보냄 | + +## 헬스체크 + +`/actuator/health` 는 로드밸런서와 컨테이너 오케스트레이터가 봅니다. +여기가 DOWN 이면 **멀쩡한 인스턴스가 내려갑니다.** + +### 메일 헬스체크를 뺀 이유 + +기본 설정에서는 SMTP 에 연결하지 못하면 헬스체크 전체가 DOWN 이 됩니다. + +```json +{"status":"DOWN","components":{"mail":{"error":"AuthenticationFailedException ..."}}} +``` + +메일은 부가 기능입니다. **메일 서버 장애 하나로 조회·검색·알림이 전부 멈추면** 안 됩니다. + +```yaml +management: + health: + mail: + enabled: false +``` + +메일 발송 실패는 알림 도메인에서 따로 잡습니다. + +### 노출 범위 + +| 프로파일 | 노출 | Swagger | +|----------|------|---------| +| dev / docker | health, info, prometheus | 공개 | +| prod | health, info, prometheus | **비공개** | + +운영에서 API 문서를 열어두면 공격 표면을 그대로 알려주는 셈입니다. + +## 스케줄러 + +전부 `Asia/Seoul` 기준이며 프로퍼티로 덮어쓸 수 있습니다. + +| 작업 | 기본 cron | 프로퍼티 | +|------|-----------|----------| +| 어린이집 동기화 | `0 0 3 * * MON` | `app.scheduler.public-data.facility-cron` | +| 정부지원 서비스 동기화 | `0 30 3 * * *` | `...benefit-cron` | +| 병원 동기화 | `0 0 3 * * TUE` | `...hospital-cron` | +| 유치원 동기화 | `0 0 4 * * MON` | `...kindergarten-cron` | +| 좌표 보정 | `0 0 5 * * *` | `...geocoding-cron` | +| 정책 변경 알림 | `0 0 9 * * *` | `...policy-change-cron` | +| 빈자리 알림 | `0 30 9 * * *` | `...vacancy-cron` | +| 마감 임박 알림 | `0 0 10 * * *` | `...policy-deadline-cron` | +| 제보 요청 | `0 0 10 * * WED` | `...report-ask-cron` | + +순서의 근거는 [시스템 개요](../architecture/system-overview.md#배치-실행-시각)에 있습니다. + +### 로그는 서비스에서만 남긴다 + +스케줄러와 서비스가 **같은 결과를 각각 로그**하던 시절이 있었습니다. + +``` +17:30:24 PolicyDeadlineNotifier | 마감 임박 알림 - 정책 2건, 알림 1건 발송 +17:30:24 PublicDataSyncScheduler | 마감 임박 알림 - 정책 2건, 알림 1건 발송 +``` + +검증 중에 이걸 **"두 번 실행되어 중복 발송됐다"** 고 잘못 읽었습니다. +운영 중에 같은 오해를 하면 없는 장애를 쫓게 됩니다. 스케줄러 쪽 로그를 걷어냈습니다. + +## 수동 실행 + +스케줄러는 하루 한 번만 돕니다. 발송이 안 나갔을 때 원인을 확인하려면 +**다음 날까지 기다려야 합니다.** 그래서 관리자 수동 실행을 열어 두었습니다. + +| 경로 | 반환 | +|------|------| +| `POST /api/admin/public-data/facilities/sync` | 생성·갱신·실패 수 | +| `POST /api/admin/public-data/kindergartens/sync` | 동일 | +| `POST /api/admin/public-data/benefits/sync` | 동일 | +| `POST /api/admin/public-data/hospitals/sync` | 동일 | +| `POST /api/admin/public-data/facilities/geocode` | 보정·실패·남은 수 | +| `POST /api/admin/public-data/facilities/notify-vacancy` | **확인한 시설 수**, 자리 발생 시설 수, 발송 수 | +| `POST /api/admin/public-data/policies/notify-deadline` | 마감 임박 정책 수, 발송 수 | + +빈자리 알림이 **확인한 시설 수**까지 돌려주는 이유는, +0건이 나왔을 때 **대기자가 없어서인지 자리가 안 나서인지** 구분하기 위해서입니다. + +## 로깅 + +`logback-spring.xml` 에서 JSON 으로 남깁니다. + +> Logback 의 기본값 문법은 `${VAR:-기본값}` 입니다. +> Spring 문법인 `${VAR:기본값}` 을 쓰면 변수가 없을 때 `..._IS_UNDEFINED` 경로가 되어 +> **기동 자체가 실패합니다.** 자세한 내용은 [기동 안정화](../quality/runtime-hardening.md)에 있습니다. + +## 필수 의존성 + +| 의존성 | 없으면 | +|--------|--------| +| MariaDB | 기동 불가 | +| Redis | **기동 불가** — `RateLimitingAspect` 가 `StringRedisTemplate` 을 요구 | +| SMTP | 메일만 실패 (헬스체크에는 영향 없음) | +| FCM | 푸시만 비활성화 | +| 카카오 지오코딩 키 | 좌표 보정만 건너뜀 | +| Slack 웹훅 | 운영 알림이 로그로만 남음 | + +Redis 가 필수라는 점은 로컬 개발에서 자주 걸립니다. +캐시는 `spring.cache.type=none` 으로 끌 수 있지만 레이트리밋은 끌 수 없습니다. + +## 배포 + +GitHub Actions → Docker 이미지 → **Blue/Green**. + +Blue/Green 이라는 사실이 알림 설계에 직접 영향을 줍니다. +배포 중에는 인스턴스가 잠깐 2대가 되고, 각 인스턴스의 스케줄러가 모두 돌면 +**중복 발송**이 생깁니다. 이 때문에 [마감 임박 알림](notification-and-retention.md#중복-방지--bluegreen-에서-드러난-결함)에 +유니크 제약 기반 발송 이력을 넣었습니다. + +## 미해결 + +| 항목 | 내용 | 이슈 | +|------|------|------| +| traceId 전파 | 요청 추적이 안 됩니다. 500 원인 찾을 때 로그를 손으로 파싱해야 합니다 | #50 | +| 배포 후 스모크 테스트 | 배포가 성공해도 실제로 도는지 확인하지 않습니다 | #51 | +| 스케줄러 단일 실행 보장 | 인스턴스별 중복 실행을 알림 쪽에서만 막고 있습니다. 분산 락이 근본 해결입니다 | — | diff --git a/docs/features/privacy-and-legal.md b/docs/features/privacy-and-legal.md new file mode 100644 index 00000000..15265ca7 --- /dev/null +++ b/docs/features/privacy-and-legal.md @@ -0,0 +1,133 @@ +# 개인정보와 법적 문서 + +> 관련 이슈: #68 #71 · 관련 마이그레이션: V2, V15 + +## 문제 + +이 서비스는 **아이의 건강 정보**를 다룹니다. 진단명, 처방, 증상까지 저장합니다. +이건 개인정보보호법상 **민감정보**이고, 일반 개인정보와 같은 동의로 처리할 수 없습니다. + +그런데 동의 기능(`UserConsent`, `ConsentGuard`)은 있는데 +**정작 동의 대상 문서가 없었습니다.** 무엇에 동의하는지 모른 채 동의를 받고 있었습니다. + +## 실제로 수집하는 항목 + +문서는 추상적인 양식이 아니라 **엔티티에 실재하는 필드**를 근거로 작성했습니다. + +| 엔티티 | 항목 | 구분 | +|--------|------|------| +| `User` | 이메일, 이름, 전화번호, 주소, 위경도, 소득분위, 가구원수 | 일반 | +| `Child` | 이름, 생년월일, 성별, 특수보육 필요 여부 | 일반 (아동) | +| `HealthRecord` | 키, 몸무게, 체온, 혈압, 맥박, 접종명, **증상, 진단, 처방** | **민감정보** | + +소득분위와 주소는 지원금 자격 판정에, 위경도는 반경 검색에 필요합니다. +필요 없는 항목은 받지 않는다는 원칙을 문서에 명시했습니다. + +## 동의 분리와 접근 차단 + +```mermaid +flowchart TD + REQ[건강 정보 접근 요청] --> G[ConsentGuard] + G --> C{민감정보 동의가
있는가} + C -->|없음| E["403 CONSENT_REQUIRED
+ 어떤 동의가 필요한지"] + C -->|있음| OK[접근 허용] + + style E fill:#f8d7da,stroke:#dc3545 + style OK fill:#d4edda,stroke:#28a745 +``` + +거부할 때 **어떤 동의가 필요한지**를 응답에 담습니다. + +```json +{ + "error": "CONSENT_REQUIRED", + "consentType": "SENSITIVE_HEALTH", + "displayName": "건강정보 수집·이용", + "sensitive": true, + "message": "...", + "path": "..." +} +``` + +그냥 403만 주면 클라이언트가 동의 화면을 띄울 수 없습니다. +사용자는 왜 막혔는지 모른 채 화면만 보게 됩니다. + +## 법적 문서 + +| 문서 | 경로 | 버전 | +|------|------|------| +| 개인정보 처리방침 | `src/main/resources/legal/privacy-policy-v1.0.md` | v1.0 | +| 서비스 이용약관 | `src/main/resources/legal/terms-of-service-v1.0.md` | v1.0 | + +### 비로그인도 읽을 수 있어야 한다 + +``` +.requestMatchers("/legal/**").permitAll() +``` + +**동의하기 전에 읽어야 하는 문서**입니다. 로그인해야 볼 수 있으면 순서가 뒤집힙니다. + +### 버전을 파일명에 담는다 + +`LegalDocumentService` 가 `legal/privacy-policy-{version}.md` 를 읽습니다. +동의 이력에 버전을 남기므로, 나중에 "이 사용자가 어떤 내용에 동의했는지" 를 되짚을 수 있습니다. + +> 처음에는 파일명을 `-v1.md`, 버전 상수를 `v1.0` 으로 두어 문서를 찾지 못했습니다. +> 파일명과 버전 문자열이 **정확히 일치**해야 합니다. + +## 이용약관에 넣은 정확성 고지 + +이 서비스는 **추정치와 예측을 제공합니다.** 잘못된 기대는 그대로 분쟁이 됩니다. +그래서 약관 제6조에 명시했습니다. + +| 기능 | 고지 내용 | +|------|-----------| +| 지원금 금액 | 참고자료이며 추정치입니다. 실제 수령액은 다를 수 있습니다 | +| 입소 예측 | 관측 기반 추정이며 입소를 보장하지 않습니다 | +| 성장 정보 | 의학적 진단이 아닙니다 | +| 빈자리 알림 | 시설 전체 기준이며 해당 반의 자리를 보장하지 않습니다 | + +## 정보주체 권리 + +| 기능 | 경로 | 처리 | +|------|------|------| +| 내 데이터 열람 | `GET /users/privacy/export` | 저장된 개인정보 전체 반환 | +| 동의 관리 | `GET/POST /users/privacy/consents`
`GET .../consents/history` | 동의 항목별 조회·변경 | +| 회원 탈퇴 | `DELETE /users/privacy/account` | 식별 정보 익명화 + 계정 비활성화 | + +### 탈퇴는 물리 삭제가 아니다 + +식별 정보를 `deleted_{id}` 로 익명화하고 `deletedAt` 을 남깁니다. +`CustomUserDetailsService` 가 `deletedAt` 조건을 포함해 조회하므로 **탈퇴 계정은 로그인되지 않습니다.** + +물리 삭제하지 않는 이유는, 커뮤니티 게시글이나 통계 표본처럼 다른 사용자의 데이터와 +얽힌 부분이 함께 사라지면 서비스가 깨지기 때문입니다. + +## 보관 기간 + +| 항목 | 기간 | 근거 | +|------|------|------| +| 회원 정보 | 탈퇴 시까지 | — | +| 동의 이력 | 5년 | 분쟁 대비 | +| 건강 정보 | 탈퇴 시 | (파기 방식 확정 필요) | + +## 안전 조치 + +- 비밀번호는 단방향 해시로 저장합니다. +- 리프레시 토큰은 해시해서 저장하고 **HttpOnly 쿠키**로 발급합니다. +- 건강 정보는 소유권을 서비스 계층에서 검증합니다. (IDOR 방지) +- 개인정보는 로그·예외 메시지에 남기지 않습니다. + +## 미해결 — **법률 검토 전까지 시행할 수 없습니다** + +문서 안에 `[확인 필요]` 로 표시해 두었습니다. 제가 정할 수 없는 사실관계입니다. + +| 항목 | 왜 필요한가 | +|------|-------------| +| 자녀 건강정보 파기 방식 | 익명화로 충분한지 완전삭제가 필요한지 | +| 클라우드 사업자 명시 | 처리 위탁 고지 의무 | +| 메일 발송 사업자 명시 | 처리 위탁 고지 의무 | +| 챗봇 Claude API 국외이전 | 국외 이전 동의를 별도로 받아야 하는지 | +| 개인정보 보호책임자 연락처 | 법정 필수 기재 사항 | + +이 항목들이 채워지고 법률 검토를 받기 전에는 **v1.0 을 시행일 문서로 게시하면 안 됩니다.** diff --git a/docs/features/public-data-integration.md b/docs/features/public-data-integration.md new file mode 100644 index 00000000..0470d552 --- /dev/null +++ b/docs/features/public-data-integration.md @@ -0,0 +1,180 @@ +# 공공데이터 연동 + +> 관련 이슈: #61 #68 · 관련 마이그레이션: V3, V10 + +## 문제 + +이 서비스가 다루는 어린이집·유치원·병원·지원금은 전부 정부가 공개합니다. +그런데 **네 곳이 전부 다른 방식**입니다. 응답 형식도, 페이징 규칙도, 지역 코드 체계도 다릅니다. + +각각에 맞춰 코드를 쓰면 새 데이터를 붙일 때마다 처음부터 다시 만들어야 하고, +한 곳이 장애를 내면 그게 어디서 온 문제인지 알기 어렵습니다. + +## 연동한 4개 소스 + +| 소스 | 대상 | 형식 | 페이징 | 특이사항 | +|------|------|------|--------|----------| +| 보육통합정보시스템 | 어린이집 | XML | 없음 | **HTTPS 전용**, 시군구(`arcode`) 5자리, 지역당 50건 상한 | +| 유치원알리미 | 유치원 | JSON | 없음 | 시군구(`sggCode`) 순회 | +| 보조금24 (odcloud) | 정부지원 서비스 | JSON | `page`/`perPage` | 전국 단위 | +| 심평원 | 소아청소년과 | XML | `pageNo`/`numOfRows` | 요양기호가 자연키 | + +### 실연동으로 확인한 수치 + +`./gradlew liveSyncCheck` 로 실제 키를 넣고 확인한 결과입니다. + +| 소스 | 수집량 | 실패 | +|------|--------|------| +| 유치원 | 7,052곳 (212개 시군구) | 0건 | +| 어린이집 | 8,331곳 (202개 중 200개 시군구) | 2건 | +| 병원 | 200곳 수집 (전체 4,292곳) | 2건 | +| 정부지원 서비스 | 60건 | 0건 | + +## 공급자 추상화 + +```mermaid +classDiagram + class PublicDataProvider { + <> + +fetch(SyncSpec) PublicDataResponse + } + class ChildcarePortalProvider { + XML · HTTPS 전용 + } + class KindergartenInfoProvider { + JSON · 시군구 순회 + } + class OdcloudProvider { + JSON · page/perPage + } + class DataGoKrProvider { + XML · 절대 URL 지원 + } + + PublicDataProvider <|.. ChildcarePortalProvider + PublicDataProvider <|.. KindergartenInfoProvider + PublicDataProvider <|.. OdcloudProvider + PublicDataProvider <|.. DataGoKrProvider + + class PagedSyncTemplate { + 페이지 순회 · 실패 격리 + } + class RegionCodeCatalog { + 시군구 코드 목록 + } + + PagedSyncTemplate --> PublicDataProvider + PagedSyncTemplate --> RegionCodeCatalog +``` + +`PagedSyncTemplate` 이 순회와 실패 격리를 맡고, 공급자는 **"한 번 요청해서 목록을 준다"** 만 책임집니다. +새 데이터 소스를 붙일 때 작성할 코드가 공급자 하나로 줄어듭니다. + +## 각 소스에서 실제로 겪은 문제 + +### 어린이집 — "연결 실패" 의 진짜 원인 + +처음에는 문서에 적힌 `http://api.childcare.go.kr` 로 붙였는데 계속 연결이 되지 않았습니다. +포트 80이 막혀 있었고 **HTTPS 로는 정상**이었습니다. + +또 `arcode` 에 시도 코드(2자리)를 넣으면 빈 결과가 옵니다. **5자리 시군구 코드**여야 합니다. + +### 어린이집 — 지역당 50건 상한 + +실연동 결과를 검증하다 지역마다 정확히 50건에서 끊기는 것을 발견했습니다. +페이징 파라미터가 명세에 없어서 더 가져올 방법이 없습니다. +**개발키의 제한으로 보이며, 운영키 전환이 필요합니다.** (미해결 — 아래 참조) + +### 광주·전남이 두 API 모두에서 빈 결과 + +시도 코드 29(광주)와 46(전남)은 어린이집·유치원 **양쪽 모두** 데이터를 주지 않습니다. +우리 코드 문제가 아니라 정부 API 쪽 상태입니다. +이 사실을 `src/main/resources/public-data/*.txt` 주석에 남겨 두었습니다. +누군가 나중에 "왜 광주가 비었지" 를 다시 조사하지 않도록. + +### 병원 — 진료과목과 종별을 섞어 담고 있었다 + +`clCdNm` 은 "상급종합", "종합병원" 같은 **요양기관 종별**인데 이걸 `type` 에 넣고 있었습니다. +그래서 "소아과" 로 검색하면 아무것도 나오지 않았습니다. + +`type` 은 진료과목(소아청소년과), `grade` 는 종별로 분리했습니다. (V10) + +### 정책 — 지자체 지역 매핑 + +보조금24 응답의 소관기관명은 "청주시청" 처럼 오지만 사용자 주소는 "충청북도 청주시 흥덕구" 입니다. +그대로 비교하면 매칭되지 않아서, 기관명에서 지자체명을 추출해 정규화합니다. + +### 정책 — 금액 미상이 소실되던 문제 + +지원금 설명은 자유 텍스트라 `"국공립 100,000원, 사립 280,000원"`, `"융자(연 1.5%)"` 처럼 옵니다. +파싱에 실패한 정책을 그냥 버리면 **목록에서 사라져 사용자는 존재조차 모릅니다.** + +버리지 않고 금액을 `null` 로 두되, 응답에 `unknownAmountCount` 로 몇 건이 미상인지 노출합니다. +정확한 척하는 것보다 모른다고 말하는 편이 낫습니다. + +## 안전 장치 + +### XXE 차단 + +외부 XML 을 파싱하므로 `XmlResponseParser` 에서 외부 엔티티 확장을 끕니다. +정부 API 라고 신뢰할 이유가 없고, 중간자 공격이면 더욱 그렇습니다. + +### 응답 코드 해석 + +보육통합정보시스템은 HTTP 200 으로 오면서 본문에 오류 코드를 담습니다. +`ChildcareApiStatus` 로 한도 초과·키 만료를 구분해 **운영 알림**으로 올립니다. +조용히 0건을 받아 "오늘은 데이터가 없네" 로 넘어가면 며칠씩 모르고 지나갑니다. + +### 비밀값 + +API 키는 저장소에 넣지 않습니다. 환경변수로만 주입하고, 예외 메시지나 로그에도 남기지 않습니다. + +## 좌표 보정 + +어린이집 API 는 **좌표를 주지 않습니다.** 반경 검색을 하려면 위경도가 필요합니다. + +```mermaid +flowchart LR + A[좌표 없는 시설 조회] --> B{카카오 키가
설정됐는가} + B -->|없음| SKIP[건너뜀 · 로그만] + B -->|있음| C[주소 → 카카오 로컬 API] + C --> D{한반도 범위
안인가} + D -->|아니오| DROP[버림] + D -->|예| SAVE[좌표 저장] +``` + +카카오 응답은 `x` 가 경도, `y` 가 위도입니다. 바꿔 넣으면 전국 시설이 동해 한가운데로 갑니다. +받은 좌표가 한반도 범위 안인지 검사하는 이유입니다. + +키가 없으면 **기동을 막지 않고 건너뜁니다.** 좌표 보정은 부가 기능이라 +로컬 개발자가 카카오 키 없이도 앱을 띄울 수 있어야 합니다. + +## 관련 API + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| POST | `/api/admin/public-data/facilities/sync` | 전국 어린이집 동기화 | +| POST | `/api/admin/public-data/kindergartens/sync` | 전국 유치원 동기화 | +| POST | `/api/admin/public-data/benefits/sync` | 정부지원 서비스 동기화 | +| POST | `/api/admin/public-data/hospitals/sync` | 소아청소년과 동기화 | +| POST | `/api/admin/public-data/facilities/geocode` | 좌표 보정 | + +전부 `ROLE_ADMIN` 이 필요합니다. + +## 실연동 점검 방법 + +```bash +./gradlew liveSyncCheck \ + -Dchildcare.key=... -Dkindergarten.key=... -Dodcloud.key=... -Dhpsvc.key=... +``` + +`@Tag("live")` 가 붙어 있어 일반 빌드에서는 제외됩니다. +실제 정부 API 를 때리므로 CI 에서 매번 돌리면 한도를 소진합니다. + +## 미해결 + +| 항목 | 내용 | 담당 | +|------|------|------| +| 어린이집 운영키 | 지역당 50건 상한 때문에 실제 수집량이 실제보다 적습니다 | 사용자 (키 신청) | +| 병원 전량 수집 | 현재 2페이지에서 끊습니다. 전체 4,292곳을 받으려면 상한 해제 필요 | 설정 변경 | +| 광주·전남 | 정부 API 가 비어 있습니다. 대체 소스 검토 필요 | 조사 필요 | From 665b0da44107cf980bf40e7202dc58a4c70d3196 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 23:56:20 +0900 Subject: [PATCH 54/68] =?UTF-8?q?DOCS=20:=20=EA=B8=B0=EB=8F=99=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=ED=99=94=EC=99=80=20=ED=9A=8C=EA=B7=80=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=20=EA=B8=B0=EB=A1=9D=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 테스트가 전부 통과하는데도 애플리케이션이 한 번도 기동한 적 없던 이유와, 통합 테스트가 create-drop 이라 스키마 어긋남을 구조적으로 못 잡던 문제를 남긴다. --- docs/quality/regression-safety.md | 187 ++++++++++++++++++++++++++++ docs/quality/runtime-hardening.md | 194 ++++++++++++++++++++++++++++++ 2 files changed, 381 insertions(+) create mode 100644 docs/quality/regression-safety.md create mode 100644 docs/quality/runtime-hardening.md diff --git a/docs/quality/regression-safety.md b/docs/quality/regression-safety.md new file mode 100644 index 00000000..78debe2f --- /dev/null +++ b/docs/quality/regression-safety.md @@ -0,0 +1,187 @@ +# 회귀 방지 + +> 관련 이슈: #72 + +## 문제 — 왜 CI 가 못 잡았나 + +[기동 안정화](runtime-hardening.md)에서 찾은 문제들은 전부 **CI 가 잡았어야 하는** 것들입니다. +테이블이 없고, 접근제어가 뒤집혀 있었는데 빌드는 계속 초록불이었습니다. + +원인을 찾아보니 구조적이었습니다. + +### 원인 1 — 모든 통합 테스트가 create-drop + +```java +"spring.flyway.enabled=false", +"spring.jpa.hibernate.ddl-auto=create-drop", +``` + +통합 테스트 4개가 **전부** 이 설정이었습니다. +Hibernate 가 엔티티로부터 스키마를 만들어 내니, **Flyway 마이그레이션이 아무리 어긋나도 통과합니다.** + +정책 북마크는 테이블 없이 API 와 리포지토리까지 있었고, +조회수는 컬럼이 없어 저장된 적이 없었는데, 그 상태로 모든 테스트가 초록불이었습니다. + +**테스트가 검증한 것은 "엔티티끼리 앞뒤가 맞는가" 였지 "실제 스키마와 맞는가" 가 아니었습니다.** + +### 원인 2 — 접근제어를 실제로 호출해 본 적이 없다 + +SecurityConfig 는 선언 순서에 따라 앞선 규칙이 뒤를 덮습니다. +게다가 클래스 레벨 `@PreAuthorize` 가 URL 규칙을 다시 덮습니다. + +규칙 목록만 읽으면 "병원은 공개" 로 보이는데 실제로는 401 이었습니다. +**읽어서는 알 수 없고, 호출해 봐야 압니다.** + +## 대응 1 — 스키마 정합성 테스트 + +`FlywaySchemaValidationTest` 는 **운영과 같은 방식**으로 띄웁니다. + +```java +"spring.flyway.enabled=true", +"spring.jpa.hibernate.ddl-auto=validate", +``` + +```mermaid +flowchart LR + A["Testcontainers
MariaDB 10.11"] --> B["Flyway 전체 적용
V1 ~ V17"] + B --> C["Hibernate validate"] + C -->|불일치| F["기동 실패
= 테스트 실패"] + C -->|일치| D["단언 검증"] + + style F fill:#f8d7da,stroke:#dc3545 +``` + +엔티티에 필드를 추가하고 마이그레이션을 안 쓰면 **기동 단계에서 깨집니다.** + +### MariaDB 컨테이너를 쓰는 이유 + +H2 로는 이 검증이 성립하지 않습니다. + +- 마이그레이션에 MariaDB 전용 문법(FULLTEXT, COMMENT)이 있습니다. +- Linux MariaDB 는 `lower_case_table_names=0` 이라 **테이블명 대소문자를 구분**합니다. + 이 조건이어야 대문자 마이그레이션 / 소문자 매핑 불일치가 여기서 잡힙니다. + +### 단언 항목 + +| 검증 | 목적 | +|------|------| +| 마이그레이션 성공 15건 이상 | 전부 적용됐는가 | +| 실패 0건 | 중간에 깨진 게 없는가 | +| `TBL_POLICY_BOOKMARKS`, `TBL_NOTIFICATION_CHANNEL` 존재 | 뒤늦게 채운 것들의 회귀 감시 | + +가장 중요한 검증은 단언이 아니라 **컨텍스트가 뜬다는 사실 자체**입니다. +`validate` 가 실패하면 단언에 도달하기 전에 죽습니다. + +## 대응 2 — 접근제어 계약 테스트 + +`AccessControlContractTest` 는 규칙을 읽는 대신 **실제 응답 코드**를 확인합니다. + +```java +@ParameterizedTest +@ValueSource(strings = { "/actuator/health", "/legal/privacy-policy", "/policies", + "/facilities", "/health/hospitals", "/community/posts", ... }) +void publicPathsDoNotRequireLogin(String path) { + assertThat(status).isNotIn(401, 403); +} +``` + +| 구분 | 단언 | 이유 | +|------|------|------| +| 공개 경로 | 401·403 이 **아님** | 데이터가 없어 404 일 수는 있어도 인증을 요구하면 안 됨 | +| 보호 경로 | 정확히 **401** | 남의 개인정보가 걸린 경로는 뚫리면 그대로 사고 | + +응답 **내용**이 아니라 **인가**만 봅니다. 그래야 테스트가 기능 변경에 흔들리지 않습니다. + +### H2 를 쓰는 이유 + +접근제어는 DB 방언과 무관합니다. H2 in-memory 로 돌면 **Docker 없이도** 실행되므로 +개발자가 로컬에서 항상 돌릴 수 있습니다. + +현재 **23개 케이스, skip 0, 전부 통과**합니다. + +## 대응 3 — 조용한 skip 방지 + +Testcontainers 테스트는 `disabledWithoutDocker = true` 라 +**Docker 가 없으면 조용히 skip 되고 빌드는 초록불**이 됩니다. + +스키마 검증이 그렇게 빠지면 이 테스트를 만든 의미가 없습니다. +CI 에 실행 여부 검사를 넣었습니다. + +```yaml +- name: Assert schema validation actually ran + run: | + report=build/test-results/test/TEST-com.carecode.integration.FlywaySchemaValidationTest.xml + if [ ! -f "$report" ]; then + echo "::error::스키마 정합성 테스트 리포트가 없습니다." + exit 1 + fi + if grep -q 'skipped="0"' "$report"; then + echo "스키마 정합성 테스트 실행 확인" + else + echo "::error::스키마 정합성 테스트가 skip 되었습니다. Docker 환경을 확인하세요." + exit 1 + fi +``` + +CI 는 `ubuntu-latest` 라 Docker 가 있으므로 정상 실행됩니다. +이 검사는 **환경이 바뀌어 조용히 빠지는 상황**을 막습니다. + +## 테스트 지형 + +```mermaid +flowchart TD + subgraph unit["단위 — Mockito"] + U1[알림 판단 로직] + U2[지원금 계산] + U3[예측·통계] + U4[소유권 검증] + end + subgraph slice["통합 — H2"] + S1[컨텍스트 로딩] + S2["접근제어 계약
23 케이스"] + S3[샘플 데이터 시나리오] + end + subgraph container["통합 — Testcontainers"] + C1["스키마 정합성
Flyway + validate"] + C2[MariaDB 통합] + end + subgraph live["실연동 — @Tag(live)"] + L1["공공데이터 실호출
일반 빌드 제외"] + end + + unit --> slice --> container --> live + + style C1 fill:#d4edda,stroke:#28a745 + style S2 fill:#d4edda,stroke:#28a745 +``` + +| 계층 | 도구 | Docker | CI | +|------|------|--------|-----| +| 단위 | JUnit 5 + Mockito | 불필요 | 항상 | +| 통합(경량) | H2 in-memory | 불필요 | 항상 | +| 통합(스키마) | Testcontainers MariaDB | **필요** | 항상 (실행 여부 검사) | +| 실연동 | 실제 정부 API | 불필요 | **제외** (한도 소진) | + +실연동 테스트를 CI 에서 빼는 이유는, 매 커밋마다 정부 API 를 때리면 +**하루 호출 한도를 개발이 다 써버리기** 때문입니다. 필요할 때 수동으로 돕니다. + +```bash +./gradlew liveSyncCheck -Dchildcare.key=... -Dkindergarten.key=... +``` + +## 새 기능을 추가할 때 + +| 바꾼 것 | 해야 할 일 | +|---------|-----------| +| 엔티티에 필드·테이블 추가 | 마이그레이션도 작성 (안 하면 스키마 테스트가 깨뜨림) | +| 컨트롤러에 경로 추가 | 접근제어 계약 테스트에 공개/보호 중 하나로 등록 | +| 클래스 레벨 `@PreAuthorize` 가 있는 컨트롤러에 공개 API 추가 | 메서드에도 `@PreAuthorize("permitAll()")` 필요 | +| 알림 발송 추가 | 중복 방지 수단 확보 (Blue/Green 에서 인스턴스가 2대가 됨) | + +## 알려진 한계 + +| 항목 | 내용 | +|------|------| +| 로컬 Docker Desktop | 일부 환경에서 docker-java 가 Docker Desktop 29.x 에 붙지 못해 Testcontainers 테스트가 skip 됩니다. CI(ubuntu)에서는 정상입니다 | +| 접근제어 테스트 범위 | 전 경로가 아니라 대표 경로만 담았습니다. 새 경로는 수동 등록이 필요합니다 | +| 스케줄러 | 단위 테스트만 있고, 실제 cron 발화는 검증하지 않습니다 | diff --git a/docs/quality/runtime-hardening.md b/docs/quality/runtime-hardening.md new file mode 100644 index 00000000..427ae41c --- /dev/null +++ b/docs/quality/runtime-hardening.md @@ -0,0 +1,194 @@ +# 기동 안정화 + +> 관련 이슈: #70 · 관련 마이그레이션: V15 + +## 발단 + +기능을 여러 차례 추가하고 테스트도 전부 통과하던 시점에, +**"실제로 실행은 되는지"** 를 확인해 보기로 했습니다. + +Docker 로 MariaDB 와 Redis 를 띄우고 `--spring.profiles.active=prod` 로 기동했습니다. + +결과: **이 애플리케이션은 한 번도 정상 기동한 적이 없었습니다.** + +여덟 개의 차단 요인이 **연쇄적으로** 나왔습니다. 하나를 고치면 다음 것이 나오는 식이었습니다. +그리고 기동에 성공한 뒤에도 접근제어 결함 두 건이 더 나왔습니다. + +## 기동 차단 8건 + +```mermaid +flowchart TD + S([기동 시도]) --> B1["1. 로그 경로
_IS_UNDEFINED"] + B1 --> B2["2. ngram 파서
MariaDB 미지원"] + B2 --> B3["3. 테이블명 대소문자
전 엔티티 검증 실패"] + B3 --> B4["4. 누락 테이블 2개"] + B4 --> B5["5. 누락 컬럼
view_count"] + B5 --> B6["6. 죽은 매핑
HealthRecordType"] + B6 --> B7["7. 기동 러너
소문자 네이티브 SQL"] + B7 --> B8["8. 헬스체크 503
메일 실패"] + B8 --> OK([기동 성공]) + + style S fill:#f8d7da,stroke:#dc3545 + style OK fill:#d4edda,stroke:#28a745 +``` + +### 1. Logback 기본값 문법 + +```xml + +${LOG_FILE:/var/log/carecode/application.log} + + +${LOG_FILE:-/var/log/carecode/application.log} +``` + +Logback 은 `${VAR:-기본값}` 입니다. Spring 의 `${VAR:기본값}` 을 쓰면 +변수가 없을 때 경로가 `..._IS_UNDEFINED` 가 되어 **기동 자체가 실패**합니다. + +두 문법이 비슷해서 눈으로는 잘 안 보입니다. + +### 2. ngram 파서는 MySQL 전용 + +```sql +-- 실패: Function 'ngram' is not defined +CREATE FULLTEXT INDEX ft_facility_search ON TBL_CARE_FACILITIES (NAME, ADDRESS) WITH PARSER ngram; +``` + +MariaDB 에는 `ngram` 파서가 없습니다. 마이그레이션 V4 가 실패하고 기동이 멈춥니다. + +파서를 제거했습니다. MariaDB 내장 토크나이저는 공백 단위라 "행복어린이집" 같은 붙은 말은 +부분 일치가 안 되지만, `FullTextSearchSupport` 가 짧은 키워드를 **LIKE 로 폴백**하므로 +검색 자체는 동작합니다. + +### 3. 테이블명 대소문자 불일치 + +Spring Boot 기본 네이밍 전략(`CamelCaseToUnderscoresNamingStrategy`)은 테이블 이름을 **소문자로** 바꿉니다. +그런데 마이그레이션은 `TBL_USER` 처럼 대문자로 만듭니다. + +Linux MariaDB 는 `lower_case_table_names=0` 이라 대소문자를 구분하므로 **전 엔티티가 검증 실패**합니다. +(개발자 Windows 환경에서는 대소문자를 구분하지 않아 드러나지 않았습니다.) + +반대로 이름을 전부 그대로 쓰면 `@Column` 없이 선언된 필드가 camelCase 로 남아 또 어긋납니다. + +```java +public class CareCodeNamingStrategy extends CamelCaseToUnderscoresNamingStrategy { + @Override + public Identifier toPhysicalTableName(Identifier name, JdbcEnvironment context) { + return name; // @Table 로 선언한 이름은 손대지 않는다 + } + // 컬럼은 기존처럼 snake_case 로 변환 +} +``` + +### 4~5. DDL 에 없던 테이블과 컬럼 + +| 대상 | 상태 | +|------|------| +| `TBL_POLICY_BOOKMARKS` | **API 와 리포지토리까지 있는데 테이블이 없었습니다.** 한 번도 동작한 적이 없습니다 | +| `TBL_NOTIFICATION_CHANNEL` | 엔티티만 있고 테이블 없음 | +| `TBL_POLICIES.VIEW_COUNT` | 엔티티와 서비스는 쓰는데 컬럼이 없어 **조회수가 저장된 적이 없습니다** | + +정책 북마크는 특히 나쁩니다. 컨트롤러·서비스·리포지토리가 다 있으니 **코드만 보면 완성된 기능**입니다. +호출하면 그때 터집니다. + +이게 [회귀 방지 문서](regression-safety.md)를 쓰게 된 직접적인 계기입니다. + +### 6. 참조 0건인 죽은 매핑 + +`HealthRecord` 가 `HealthRecordType` 을 `@ManyToOne` 으로 물고 있었는데, +**테이블이 존재한 적도 없고 코드에서 쓰는 곳도 없었습니다.** 매핑만 남아 검증을 막고 있었습니다. + +엔티티와 매핑을 함께 제거했습니다. + +### 7. 허구 데이터를 만들던 기동 러너 + +`CareFacilityDataMigrationService` 가 `CommandLineRunner` 로 **매 기동마다** 실행되면서 +병원 데이터를 어린이집 테이블로 복사하고 있었습니다. + +```java +.capacity(50) // 기본값 +.rating(4.5) // 기본 평점 +.facilityCode("CF" + System.currentTimeMillis() + (int)(Math.random() * 1000)) +``` + +**정원 50, 평점 4.5는 지어낸 값**입니다. 초기 스캐폴딩 시절의 흔적인데, +지금은 전국 어린이집·유치원 동기화가 실데이터를 채우므로 **데이터를 오염시키기만** 합니다. + +게다가 소문자 네이티브 SQL(`SELECT * FROM tbl_hospital`)을 써서 기동도 실패시켰습니다. + +삭제했습니다. 다른 초기화 러너들은 전부 `@Profile("dev")` 로 막혀 있어 prod 에서 도는 건 이것뿐이었습니다. + +### 8. 메일 헬스체크가 서비스 전체를 내린다 + +SMTP 인증에 실패하면 `/actuator/health` 가 503 을 반환합니다. +로드밸런서와 k8s 가 이걸 보고 **멀쩡한 인스턴스를 내려버립니다.** + +메일은 부가 기능이라 헬스체크에서 분리했습니다. 자세한 내용은 [운영 문서](../features/operations.md#헬스체크)에. + +## 기동 이후 발견한 접근제어 결함 + +### 병원 조회가 전부 로그인 필수였다 + +병원 API 의 실제 경로는 `/health/hospitals/**` 인데, +SecurityConfig 의 공개 규칙은 **존재하지 않는 경로**에 걸려 있었습니다. + +```java +.requestMatchers("/health/**").authenticated() // ← 이게 먼저 잡는다 +.requestMatchers("/hospitals").permitAll() // ← 이 경로는 없다 +.requestMatchers("/hospitals/search").permitAll() // ← 죽은 규칙 +``` + +SecurityConfig 는 **앞선 규칙이 뒤를 덮습니다.** `/health/**` 가 전부 잡아버려서 +병원 목록·검색·리뷰 조회가 통째로 로그인 필수였습니다. + +규칙만 읽으면 "병원은 공개" 로 보입니다. **실제로 호출해 보기 전에는 알 수 없습니다.** + +### 클래스 레벨 @PreAuthorize 가 URL 규칙을 덮는다 + +URL 규칙을 고쳤는데도 500 이 났습니다. + +```java +@RestController +@RequestMapping("/health") +@PreAuthorize("isAuthenticated()") // ← 클래스 전체에 적용 +public class HealthController { +``` + +URL 규칙을 통과해도 메서드 진입 시 다시 막힙니다. +공개해야 할 GET 7개에 `@PreAuthorize("permitAll()")` 를 붙여야 실제로 열립니다. + +**두 겹의 접근제어가 서로 다른 말을 하고 있었습니다.** + +### 404·403 이 500 으로 새며 운영 알림을 울렸다 + +없는 URL 요청이 정적 리소스 핸들러까지 흘러가 `NoResourceFoundException` 이 되고, +최후 예외 핸들러가 이걸 잡아 **500 + 운영 알림**으로 처리했습니다. + +`@PreAuthorize` 거부(`AuthorizationDeniedException`)도 마찬가지였습니다. + +둘 다 장애가 아닙니다. 이걸 알리면 진짜 장애가 소음에 묻힙니다. +전용 핸들러를 추가해 각각 404·403 으로 응답하고 알림을 보내지 않게 했습니다. + +## 검증 결과 + +prod 프로파일 + 실제 MariaDB·Redis 컨테이너. + +| 항목 | 결과 | +|------|------| +| 기동 시간 | 약 16~22초 | +| 마이그레이션 | 17건 적용, 0건 실패 | +| 공개 경로 | `/actuator/health`, `/legal/*`, `/policies*`, `/facilities*`, `/health/hospitals*`, `/community/*` → **200** | +| 보호 경로 | `/policies/recommendations`, `/health/records/*`, `/notifications`, 관리자 경로 → **401** | +| `./gradlew clean build` | 통과 | + +## 교훈 + +이 작업에서 얻은 것은 개별 버그 수정이 아니라 **테스트가 무엇을 증명하지 못하는지에 대한 이해**입니다. + +- 모든 테스트가 초록불이어도 **애플리케이션은 기동조차 못할 수 있습니다.** +- 컨트롤러·서비스·리포지토리가 다 있어도 **테이블이 없으면 기능은 존재하지 않습니다.** +- 접근제어는 **규칙을 읽어서가 아니라 호출해 봐야** 알 수 있습니다. +- 개발자 환경(Windows, 대소문자 무시)과 운영 환경(Linux, 대소문자 구분)의 차이가 + **전 엔티티 검증 실패** 같은 큰 문제로 나타날 수 있습니다. + +이 이해를 코드로 옮긴 것이 [회귀 방지](regression-safety.md)입니다. From 141146a84cfe0dc80db8bcd59901ccd82f93397d Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 23:56:21 +0900 Subject: [PATCH 55/68] =?UTF-8?q?DOCS=20:=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=9D=B4=EB=A0=A5=EA=B3=BC=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=EC=A0=9C=EC=96=B4=20=EB=A7=A4=ED=8A=B8?= =?UTF-8?q?=EB=A6=AD=EC=8A=A4=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SecurityConfig 는 앞선 규칙이 뒤를 덮고 클래스 레벨 @PreAuthorize 가 다시 덮는다. 규칙만 읽어서는 실제로 열렸는지 알 수 없어 의도를 문서로, 사실을 테스트로 나눠 남긴다. --- docs/reference/access-control-matrix.md | 197 ++++++++++++++++++++++++ docs/reference/database-migrations.md | 122 +++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 docs/reference/access-control-matrix.md create mode 100644 docs/reference/database-migrations.md diff --git a/docs/reference/access-control-matrix.md b/docs/reference/access-control-matrix.md new file mode 100644 index 00000000..93044c5a --- /dev/null +++ b/docs/reference/access-control-matrix.md @@ -0,0 +1,197 @@ +# 접근제어 매트릭스 + +## 왜 이 문서가 필요한가 + +SecurityConfig 는 **앞선 규칙이 뒤를 덮습니다.** 게다가 클래스 레벨 `@PreAuthorize` 가 +URL 규칙을 다시 덮습니다. 두 겹이 서로 다른 말을 할 수 있습니다. + +실제로 병원 조회는 공개 규칙이 선언돼 있는데도 **전부 로그인 필수**였습니다. +규칙 목록만 읽어서는 알 수 없었고, 호출해 보고서야 드러났습니다. + +그래서 이 문서와 [접근제어 계약 테스트](../quality/regression-safety.md#대응-2--접근제어-계약-테스트)를 +함께 둡니다. 문서는 의도를, 테스트는 사실을 기록합니다. + +## 두 겹의 접근제어 + +```mermaid +flowchart TD + REQ[요청] --> URL{SecurityConfig
URL 규칙} + URL -->|거부| E401[401] + URL -->|통과| PRE{메서드
@PreAuthorize} + PRE -->|거부| E403[403] + PRE -->|통과| CTRL[컨트롤러 진입] + CTRL --> CONSENT{ConsentGuard
민감정보} + CONSENT -->|미동의| E403C["403
CONSENT_REQUIRED"] + CONSENT -->|동의| OK[처리] + + style E401 fill:#f8d7da,stroke:#dc3545 + style E403 fill:#f8d7da,stroke:#dc3545 + style E403C fill:#fff3cd,stroke:#ffc107 + style OK fill:#d4edda,stroke:#28a745 +``` + +**URL 규칙만 열어서는 부족합니다.** 클래스에 `@PreAuthorize("isAuthenticated()")` 가 붙어 있으면 +메서드에도 `@PreAuthorize("permitAll()")` 를 붙여야 실제로 열립니다. + +## 공개 경로 (로그인 불필요) + +### 시스템·문서 + +| 경로 | 비고 | +|------|------| +| `/actuator/health`, `/actuator/info`, `/actuator/prometheus` | 로드밸런서·모니터링 | +| `/legal/privacy-policy`, `/legal/terms`, `/legal/version` | **동의하기 전에 읽어야 하므로 공개** | +| `/`, `/error`, `/favicon.ico` | — | +| `/css/**`, `/js/**`, `/images/**`, `/static/**` | 정적 리소스 | + +### 인증 흐름 + +| 경로 | 비고 | +|------|------| +| `/auth/login`, `/auth/register` | — | +| `/auth/refresh` | 토큰 갱신 | +| `/auth/kakao/login`, `/auth/kakao/login-url`, `/auth/kakao/complete-registration` | 카카오 | +| `/oauth2/**` | — | +| `/users/send-code`, `/users/verify-code`, `/users/verify` | 이메일 인증 | + +### 지원금 + +| 경로 | 비고 | +|------|------| +| `/policies` | 목록 | +| `/policies/search` | 검색 | +| `/policies/categories` | 분류 | +| `/policies/statistics` | 통계 | +| `/policies/{id}` | 상세 | + +### 시설 + +| 경로 | 비고 | +|------|------| +| `/facilities` | 목록 | +| `/facilities/type/**`, `/facilities/location/**`, `/facilities/age` | 조건별 조회 | +| `/facilities/operating-hours`, `/facilities/radius` | — | +| `/facilities/popular`, `/facilities/new` | — | +| `/facilities/statistics` | — | +| `/facilities/{id}/view` | 조회수 증가 | +| `/facilities/{id}/rating` (GET) | 평점 조회 | +| `/api/public/care-facilities/**` | 공공데이터 조회 | + +### 병원 + +**실제 경로는 `/health/hospitals/**` 입니다.** `/hospitals/**` 가 아닙니다. +`/health/**` → `authenticated()` 보다 **먼저** 선언해야 합니다. + +| 경로 | 메서드 | 비고 | +|------|--------|------| +| `/health/hospitals` | GET | 목록 | +| `/health/hospitals/{id}` | GET | 상세 | +| `/health/hospitals/nearby` | GET | 반경 검색 | +| `/health/hospitals/popular` | GET | 인기 | +| `/health/hospitals/type/{type}` | GET | 진료과목별 | +| `/health/hospitals/{id}/reviews` | GET | 리뷰 조회 | +| `/health/hospitals/{id}/likes` | GET | 좋아요 수 | + +> 위 7개는 URL 규칙과 **메서드 `@PreAuthorize("permitAll()")` 둘 다** 필요합니다. +> `HealthController` 에 클래스 레벨 `@PreAuthorize("isAuthenticated()")` 가 있기 때문입니다. + +### 커뮤니티 (GET 만) + +| 경로 | +|------| +| `/community/posts`, `/community/posts/{id}`, `/community/posts/{id}/comments` | +| `/community/search`, `/community/search/all` | +| `/community/popular`, `/community/popular/limit` | +| `/community/latest`, `/community/latest/limit` | +| `/community/tags`, `/community/tags/**` | + +## 인증 필요 + +### 개인화 — 남의 정보가 걸린 경로 + +| 경로 | 이유 | +|------|------| +| `/policies/recommendations` | 자녀·주소·소득 기반 | +| `/policies/missed-benefits` | 동일 | +| `/policies/regional-comparison` | 동일 | +| `/policies/bookmarks`, `/policies/{id}/bookmarks` | 내 북마크 | +| `POST /policies/{id}/amount-reports` | 제보자 식별 | + +### 건강 — 민감정보 + +| 경로 | 이유 | +|------|------| +| `/health/**` (병원 공개 조회 제외) | 건강기록은 민감정보 | +| `/health/records/**` | 소유권을 서비스 계층에서 다시 검증 | +| `/health/hospitals/{id}/like-status` | **"내" 좋아요 여부라 공개 조회와 구분** | + +### 그 외 + +| 경로 | 비고 | +|------|------| +| `/auth/user/**`, `/auth/logout` | — | +| `/users/privacy/**` | 열람·동의·탈퇴 | +| `/children/**` | 자녀 정보 | +| `/notifications/**` | — | +| `/facilities/search` | 개인화 검색 | +| `/facilities/{id}/bookings/**` | 예약 | +| `/facilities/waitlist/**`, `POST /facilities/{facilityId}/waitlist` | 대기 등록 | +| `/community/comments/**` | 댓글 작성·수정 | +| `POST /facilities/{id}/rating` | 평점 등록 | +| `POST/DELETE /health/hospitals/{id}/like` | 좋아요 등록·해제 | +| `/api/**` | **기본 정책** — 명시하지 않은 `/api` 경로는 인증 필요 | + +### 최종 규칙 + +```java +.anyRequest().authenticated() +``` + +명시하지 않은 모든 경로는 인증이 필요합니다. +새 컨트롤러를 만들고 규칙을 빠뜨리면 **닫힌 채로 시작**합니다. 안전한 기본값입니다. + +## 관리자 전용 + +`ROLE_ADMIN` 이 필요합니다. + +| 경로 | 용도 | +|------|------| +| `/api/admin/**` | 전체 | +| `/api/admin/public-data/*/sync` | 수동 동기화 | +| `/api/admin/public-data/facilities/geocode` | 좌표 보정 | +| `/api/admin/public-data/facilities/notify-vacancy` | 빈자리 알림 실행 | +| `/api/admin/public-data/policies/notify-deadline` | 마감 알림 실행 | +| `/api/admin/analytics/**` | 퍼널·리텐션 | +| `/api/admin/policy-verification/**` | 금액 수기 검증 | +| `/api/admin/reports/**` | 신고 처리 | + +## 프로파일별 차이 + +| 경로 | dev / docker | prod | +|------|--------------|------| +| `/swagger-ui/**`, `/v3/api-docs/**` | 공개 | **차단(401)** | +| `/*.html` | 공개 | 차단 | +| `/kakao-test.html`, `/kakao-debug.html` | 공개 | 차단 | + +운영에서 API 문서를 열어두면 공격 표면을 그대로 알려주는 셈입니다. + +## 오류 응답 + +| 상황 | 상태 | 본문 | +|------|------|------| +| 미인증 | 401 | `{"error":"Unauthorized","message":"Authentication required"}` | +| 권한 없음 | 403 | `{"code":"C003","message":"접근 권한이 없습니다"}` | +| 동의 필요 | 403 | `{"error":"CONSENT_REQUIRED","consentType":"...","displayName":"..."}` | +| 없는 경로 | 404 | `{"code":"C004","message":"요청하신 경로를 찾을 수 없습니다"}` | +| 서버 오류 | 500 | `{"code":"C000","message":"서버 내부 오류가 발생했습니다"}` + 운영 알림 | + +**404·403 은 운영 알림을 보내지 않습니다.** 장애가 아니기 때문입니다. +초기에는 봇이 없는 URL 을 긁을 때마다 알림이 울렸고, 그러면 진짜 장애가 소음에 묻힙니다. + +## 경로를 추가할 때 + +1. SecurityConfig 에 규칙을 넣습니다. **와일드카드보다 구체적인 경로를 먼저** 선언합니다. +2. 클래스 레벨 `@PreAuthorize` 가 있는 컨트롤러라면 메서드에도 붙입니다. +3. `AccessControlContractTest` 에 공개/보호 중 하나로 등록합니다. + +3번을 빠뜨리면 다음에 누가 규칙 순서를 바꿨을 때 아무도 모릅니다. diff --git a/docs/reference/database-migrations.md b/docs/reference/database-migrations.md new file mode 100644 index 00000000..2b166707 --- /dev/null +++ b/docs/reference/database-migrations.md @@ -0,0 +1,122 @@ +# 데이터베이스 마이그레이션 + +운영은 `ddl-auto=validate` 입니다. **스키마는 Flyway 만 바꿉니다.** +엔티티를 고치고 마이그레이션을 안 쓰면 [스키마 정합성 테스트](../quality/regression-safety.md)가 +기동 단계에서 깨뜨립니다. + +## 전체 목록 + +| 버전 | 파일 | 무엇을 | 왜 | +|------|------|--------|-----| +| V1 | `baseline` | 기본 스키마 전체 | 초기 구축 | +| V2 | `feature_tables` | 예방접종 일정, 동의, 신고, 차단 | 아이 건강·커뮤니티 모더레이션 | +| V3 | `hospital_external_code` | 병원 외부 식별자(요양기호) | 심평원 데이터와 매칭할 자연키 | +| V4 | `search_indexes` | 위치·전문 검색 인덱스 | 반경 검색이 전 행에 삼각함수를 돌리는 풀스캔이었음 | +| V5 | `facility_capacity_snapshot` | 정원·현원 시계열 | 덮어쓰면 관측 이력이 사라져 예측 불가 | +| V6 | `benefit_eligibility` | 소득·다자녀·소급 조건 | 연령·지역만으로는 수급 가능 여부를 못 가림 | +| V7 | `benefit_payment_duration` | 지급 기간 상한 | **대상 연령을 지급 기간으로 착각해 총액이 폭증** | +| V8 | `user_events` | 행동 이벤트 원본 | 전환율·리텐션을 사후 계산하려면 원본이 필요 | +| V9 | `policy_verification` | 금액 검증 이력 | 틀린 금액을 확정치처럼 보이면 분쟁이 됨 | +| V10 | `hospital_grade` | 요양기관 종별 분리 | `type` 에 종별이 들어가 "소아과" 검색이 안 됐음 | +| V11 | `policy_change_log` | 정책 변경 이력 | 덮어쓰면 "무엇이 바뀌었는지" 가 사라져 알림 불가 | +| V12 | `benefit_amount_report` | 실수령액 제보 | 공공데이터가 금액을 숫자로 주지 않음 | +| V13 | `facility_waitlist` | 대기 기록 | **공공데이터에 존재하지 않는 데이터** — 사용자에게서만 얻음 | +| V14 | `policy_exclusion_group` | 중복 수급 배타 그룹 | 부모급여와 양육수당을 합산해 총액이 부풀려짐 | +| V15 | `missing_entity_tables` | 누락 테이블·컬럼 보충 | **엔티티는 있는데 DDL 에 없어 기동 실패** | +| V16 | `waitlist_vacancy_notice` | 빈자리 알림 발송 이력 | 같은 자리를 반복 알리면 신뢰를 잃음 | +| V17 | `policy_deadline_notice` | 마감 알림 발송 이력 | **Blue/Green 에서 인스턴스가 2대가 되면 중복 발송** | + +## 특히 기억할 것들 + +### V7 — 총액이 3.6배 부풀려졌던 원인 + +`targetAgeMin`/`targetAgeMax` 는 **"어떤 아이가 대상인가"** 이지 **"몇 개월 받는가"** 가 아닙니다. + +이걸 지급 기간으로 쓰는 바람에 아빠육아휴직보너스 250만 원 × 60개월 = **1억 5천만 원**이 +한 사람의 예상 수령액에 들어갔습니다. + +`max_payment_months` 를 분리해 2억 9,506만 원 → 8,056만 원이 되었습니다. +자세한 내용은 [지원금 지능화](../features/benefit-intelligence.md#수령액-계산--두-번의-큰-오류)에. + +### V13 — 공공데이터에 없는 데이터 + +대기 기록은 정부가 공개하지 않습니다. **사용자에게서만 얻을 수 있습니다.** + +정원 관측은 "자리가 났는가" 만 알려주고, "실제로 얼마나 기다렸는지" 는 겪은 사람만 압니다. +이런 데이터가 쌓일수록 공공데이터만으로는 만들 수 없는 것을 할 수 있게 됩니다. + +### V15 — 있는 줄 알았던 테이블 + +`TBL_POLICY_BOOKMARKS` 는 **컨트롤러·서비스·리포지토리가 다 있는데 테이블이 없었습니다.** +코드만 보면 완성된 기능이라 아무도 의심하지 않았고, 모든 테스트가 통과했습니다. + +`TBL_POLICIES.VIEW_COUNT` 도 마찬가지로 코드는 쓰는데 컬럼이 없어 조회수가 저장된 적이 없습니다. + +이 마이그레이션이 [회귀 방지](../quality/regression-safety.md)를 만들게 된 직접적인 계기입니다. + +### V17 — 유니크 제약이 하는 일 + +```sql +CONSTRAINT UK_POLICY_DEADLINE_NOTICE UNIQUE (POLICY_ID, USER_ID, NOTIFIED_ON) +``` + +"남은 일수가 D-7 인 날에만 보낸다" 는 규칙은 **하루에 한 번 실행될 때만** 성립합니다. +Blue/Green 배포로 인스턴스가 잠깐 2대가 되면 모든 알림이 두 번 나갑니다. + +존재 확인은 반복 실행을, 유니크 제약은 동시 실행을 막습니다. + +## 작성 규칙 + +### MariaDB 문법만 사용 + +MySQL 전용 문법은 실패합니다. 실제로 V4 의 `WITH PARSER ngram` 이 기동을 막았습니다. + +```sql +-- 실패: Function 'ngram' is not defined +CREATE FULLTEXT INDEX ... WITH PARSER ngram; +``` + +### 테이블명은 대문자 + +Linux MariaDB 는 `lower_case_table_names=0` 이라 대소문자를 구분합니다. +`CareCodeNamingStrategy` 가 `@Table` 이름을 그대로 쓰도록 하므로 **엔티티와 정확히 일치**해야 합니다. + +Windows 개발 환경에서는 대소문자를 구분하지 않아 이 문제가 드러나지 않습니다. +스키마 정합성 테스트가 Linux 컨테이너를 쓰는 이유입니다. + +### 주석에 "왜" 를 남긴다 + +```sql +-- 지급 기간 상한. targetAgeMin/Max 는 "어떤 아이가 대상인가" 이지 "몇 개월 받는가" 가 아니다 +``` + +무엇을 추가하는지는 SQL 이 말해 줍니다. 주석은 **왜 필요했는지**를 남깁니다. +6개월 뒤에 이 컬럼을 지워도 되는지 판단할 사람에게 필요한 건 그 정보입니다. + +### 적용된 마이그레이션은 수정하지 않는다 + +Flyway 체크섬이 어긋나 기동이 실패합니다. 새 버전을 추가하세요. + +## 검증 + +```bash +# 스키마 정합성 (Testcontainers MariaDB 필요) +./gradlew test --tests "*FlywaySchemaValidationTest*" +``` + +로컬에서 직접 확인하려면: + +```bash +docker run -d --name cc-db -e MARIADB_ROOT_PASSWORD=pw -e MARIADB_DATABASE=carecode \ + -p 13306:3306 mariadb:10.11 +docker run -d --name cc-redis -p 16379:6379 redis:7-alpine + +java -jar build/libs/carecode-app.jar --spring.profiles.active=prod \ + --spring.datasource.url='jdbc:mariadb://localhost:13306/carecode' \ + --spring.datasource.username=root --spring.datasource.password=pw \ + --spring.data.redis.host=localhost --spring.data.redis.port=16379 \ + --spring.flyway.enabled=true +``` + +`Started CareCodeApplication` 이 나오면 마이그레이션과 엔티티가 일치하는 것입니다. +`validate` 는 불일치가 있으면 그 전에 죽습니다. From 48d01d9f4d285298af8ab1c98b39710ca6267bca Mon Sep 17 00:00:00 2001 From: RosieOh Date: Thu, 6 Aug 2026 23:56:21 +0900 Subject: [PATCH 56/68] =?UTF-8?q?DOCS=20:=20=EB=A3=A8=ED=8A=B8=20README=20?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 3cd7551f..08e8b4ec 100644 --- a/README.md +++ b/README.md @@ -473,9 +473,26 @@ docker-compose up carecode-mariadb carecode-redis -d ## API 문서 +### 설계 문서 + +기능별 상세 문서와 아키텍처는 [`docs/`](docs/README.md) 에 있습니다. +무엇을 만들었는지보다 **왜 그렇게 만들었는지**를 남기는 것을 목표로 합니다. + +| 문서 | 내용 | +|------|------| +| [시스템 개요](docs/architecture/system-overview.md) | 계층 구조, 요청·배치 흐름 | +| [데이터 흐름](docs/architecture/data-flow.md) | 공공데이터 수집 → 알림까지의 파이프라인 | +| [공공데이터 연동](docs/features/public-data-integration.md) | 4개 정부 API, 공급자 추상화 | +| [지원금 지능화](docs/features/benefit-intelligence.md) | 추천·비교·놓친 지원금·금액 신뢰도 | +| [시설 지능화](docs/features/facility-intelligence.md) | 정원 시계열·입소 예측·빈자리 알림 | +| [알림과 리텐션](docs/features/notification-and-retention.md) | 알림 3종과 중복 방지 | +| [기동 안정화](docs/quality/runtime-hardening.md) | 실기동에서 드러난 차단 8건 | +| [회귀 방지](docs/quality/regression-safety.md) | 왜 CI 가 못 잡았는지 | + ### Swagger UI 프로젝트는 **SpringDoc OpenAPI 3**를 사용하여 자동으로 API 문서를 생성합니다. +**운영(prod) 프로파일에서는 차단됩니다.** **접속 URL**: http://localhost/swagger-ui.html From aa1f73da0e1548abba33d682b58d0561905f6ecf Mon Sep 17 00:00:00 2001 From: RosieOh Date: Sun, 9 Aug 2026 23:05:20 +0900 Subject: [PATCH 57/68] =?UTF-8?q?FIX=20:=20=EC=9A=94=EC=B2=AD=ED=95=9C=20?= =?UTF-8?q?=EC=A0=81=20=EC=97=86=EB=8A=94=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=EC=9D=B4=20=EC=BC=9C=EC=A7=80=EB=8D=98=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=EA=B0=92=20(#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설정 행이 없을 때 실제로 발송되는 채널에는 이메일이 없는데 기본 행만 이메일을 켜고 있었다. 사용자가 다른 채널 하나를 끄는 순간 기본 행이 만들어지면서 요청한 적 없는 이메일 알림이 켜졌다. 모두 끄기도 저장된 행만 꺼서, 설정을 건드린 적 없는 사용자는 눌러도 알림이 계속 왔다. --- .../entity/NotificationPreference.java | 4 +- .../NotificationPreferenceService.java | 30 +++- .../NotificationPreferenceDefaultsTest.java | 138 ++++++++++++++++++ 3 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 src/test/java/com/carecode/domain/notification/service/NotificationPreferenceDefaultsTest.java diff --git a/src/main/java/com/carecode/domain/notification/entity/NotificationPreference.java b/src/main/java/com/carecode/domain/notification/entity/NotificationPreference.java index 7ac43940..1da83901 100644 --- a/src/main/java/com/carecode/domain/notification/entity/NotificationPreference.java +++ b/src/main/java/com/carecode/domain/notification/entity/NotificationPreference.java @@ -32,9 +32,11 @@ public class NotificationPreference { @Column(nullable = false) private Notification.NotificationType notificationType; + // 사용자가 직접 켜야 하는 채널이다. 기본값을 true 로 두면 값을 지정하지 않은 생성 경로에서 + // 요청한 적 없는 이메일 알림이 켜진다. (설정 행이 없을 때 실제로 발송되는 채널과 맞춘다) @Column(nullable = false) @Builder.Default - private Boolean emailEnabled = true; + private Boolean emailEnabled = false; @Column(nullable = false) @Builder.Default diff --git a/src/main/java/com/carecode/domain/notification/service/NotificationPreferenceService.java b/src/main/java/com/carecode/domain/notification/service/NotificationPreferenceService.java index 751e210d..27524cbd 100644 --- a/src/main/java/com/carecode/domain/notification/service/NotificationPreferenceService.java +++ b/src/main/java/com/carecode/domain/notification/service/NotificationPreferenceService.java @@ -17,6 +17,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; @@ -119,7 +120,12 @@ public NotificationSettingsResponse updateChannelPreference(String userId, Strin } } - // 모든 알림 설정 비활성화 + /** + * 모든 알림 설정 비활성화. + * + * 설정 행이 없는 유형도 함께 끈다. 저장된 행만 끄면, 설정을 한 번도 건드린 적 없는 사용자는 + * "모두 끄기" 를 눌러도 행이 없어 아무것도 바뀌지 않고 인앱·푸시 기본값으로 계속 알림을 받는다. + */ @LogExecutionTime @Transactional public void disableAllNotifications(String userId) { @@ -129,9 +135,15 @@ public void disableAllNotifications(String userId) { User user = userRepository.findByUserId(userId) .orElseThrow(() -> new CareServiceException("사용자를 찾을 수 없습니다: " + userId)); - List preferences = preferenceRepository.findByUserOrderByNotificationType(user); - - for (NotificationPreference preference : preferences) { + Map stored = + preferenceRepository.findByUserOrderByNotificationType(user).stream() + .collect(Collectors.toMap(NotificationPreference::getNotificationType, preference -> preference, (a, b) -> a)); + + for (Notification.NotificationType type : Notification.NotificationType.values()) { + NotificationPreference preference = stored.containsKey(type) + ? stored.get(type) + : createDefaultPreference(user, type); + preference.setEmailEnabled(false); preference.setPushEnabled(false); preference.setSmsEnabled(false); @@ -185,12 +197,18 @@ public List getEnabledPreferencesByType(Notificati } } - // 기본 설정 생성 + /** + * 기본 설정 생성. + * + * 기본값은 설정 행이 없을 때 {@code NotificationDispatcher} 가 실제로 발송하는 채널과 같아야 한다. + * 예전에는 여기서만 이메일을 켜 두어, 사용자가 설정 화면에서 다른 채널 하나를 끄는 순간 + * (그 시점에 이 기본 행이 만들어지면서) 요청한 적 없는 이메일 알림이 켜졌다. + */ private NotificationPreference createDefaultPreference(User user, Notification.NotificationType notificationType) { NotificationPreference preference = NotificationPreference.builder() .user(user) .notificationType(notificationType) - .emailEnabled(true) + .emailEnabled(false) .pushEnabled(true) .smsEnabled(false) .inAppEnabled(true) diff --git a/src/test/java/com/carecode/domain/notification/service/NotificationPreferenceDefaultsTest.java b/src/test/java/com/carecode/domain/notification/service/NotificationPreferenceDefaultsTest.java new file mode 100644 index 00000000..62650461 --- /dev/null +++ b/src/test/java/com/carecode/domain/notification/service/NotificationPreferenceDefaultsTest.java @@ -0,0 +1,138 @@ +package com.carecode.domain.notification.service; + +import com.carecode.domain.notification.dto.response.NotificationSettingsResponse; +import com.carecode.domain.notification.entity.Notification; +import com.carecode.domain.notification.entity.NotificationPreference; +import com.carecode.domain.notification.repository.NotificationPreferenceRepository; +import com.carecode.domain.notification.sender.NotificationChannelType; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 알림 설정 기본값 검증. + * + *

설정 행이 없는 사용자에게 {@code NotificationDispatcher} 는 인앱과 푸시만 발송한다. + * 설정 화면에서 토글 하나를 건드리면 그 시점에 기본 행이 만들어지므로, 기본 행의 채널 구성이 + * 위 규칙과 어긋나면 사용자가 요청한 적 없는 채널이 켜진다. 두 값이 함께 움직이도록 고정한다. + */ +@ExtendWith(MockitoExtension.class) +class NotificationPreferenceDefaultsTest { + + private static final String USER_ID = "u-1"; + + @Mock + private NotificationPreferenceRepository preferenceRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private NotificationPreferenceService preferenceService; + + private User user; + + @BeforeEach + void setUp() { + user = User.builder() + .id(1L).userId(USER_ID).email("parent@example.com").name("보호자") + .build(); + } + + @Test + @DisplayName("설정이 없으면 기본값은 인앱·푸시만 켠다 - 이메일과 SMS 는 사용자가 직접 켜야 한다") + void 설정이_없으면_인앱과_푸시만_켠다() { + when(userRepository.findByUserId(USER_ID)).thenReturn(Optional.of(user)); + when(preferenceRepository.findByUserAndNotificationType(user, Notification.NotificationType.POLICY)) + .thenReturn(Optional.empty()); + when(preferenceRepository.save(any(NotificationPreference.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + NotificationSettingsResponse settings = + preferenceService.getPreferenceByType(USER_ID, Notification.NotificationType.POLICY); + + assertThat(settings.getInAppEnabled()).isTrue(); + assertThat(settings.getPushEnabled()).isTrue(); + assertThat(settings.getEmailEnabled()).isFalse(); + assertThat(settings.getSmsEnabled()).isFalse(); + } + + @Test + @DisplayName("모두 끄기는 설정 행이 없는 유형까지 끈다 - 행이 없으면 기본값으로 계속 발송되기 때문") + void 모두_끄기가_저장되지_않은_유형까지_끈다() { + when(userRepository.findByUserId(USER_ID)).thenReturn(Optional.of(user)); + when(preferenceRepository.findByUserOrderByNotificationType(user)).thenReturn(List.of()); + when(preferenceRepository.save(any(NotificationPreference.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + preferenceService.disableAllNotifications(USER_ID); + + ArgumentCaptor captor = ArgumentCaptor.forClass(NotificationPreference.class); + verify(preferenceRepository, atLeastOnce()).save(captor.capture()); + + assertThat(captor.getAllValues()) + .extracting(NotificationPreference::getNotificationType) + .containsAll(Arrays.asList(Notification.NotificationType.values())); + assertThat(captor.getAllValues()).allSatisfy(preference -> { + assertThat(preference.getInAppEnabled()).isFalse(); + assertThat(preference.getPushEnabled()).isFalse(); + assertThat(preference.getEmailEnabled()).isFalse(); + assertThat(preference.getSmsEnabled()).isFalse(); + }); + } + + @Test + @DisplayName("채널 상태 조회가 알려주는 채널 키는 모두 설정 변경이 받아들인다") + void 모든_채널_키를_설정_변경이_받아들인다() { + // 조회는 `inapp` 을 알려주는데 변경은 `inApp` 만 받는 식으로 어긋나면 + // 화면에 보이는 토글이 저장되지 않는다. + when(userRepository.findByUserId(USER_ID)).thenReturn(Optional.of(user)); + when(preferenceRepository.findByUserAndNotificationType(any(User.class), any())) + .thenReturn(Optional.empty()); + when(preferenceRepository.save(any(NotificationPreference.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + for (NotificationChannelType channel : NotificationChannelType.values()) { + assertThatCode(() -> preferenceService.updateChannelPreference( + USER_ID, "SYSTEM", channel.getKey(), false)) + .as("채널 키 %s", channel.getKey()) + .doesNotThrowAnyException(); + } + } + + @Test + @DisplayName("채널 하나만 바꿔도 나머지 채널의 기본값은 그대로 유지된다") + void 채널_변경이_다른_채널을_켜지_않는다() { + when(userRepository.findByUserId(USER_ID)).thenReturn(Optional.of(user)); + when(preferenceRepository.findByUserAndNotificationType(user, Notification.NotificationType.HEALTH)) + .thenReturn(Optional.empty()); + when(preferenceRepository.save(any(NotificationPreference.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + NotificationSettingsResponse settings = + preferenceService.updateChannelPreference(USER_ID, "HEALTH", "push", false); + + assertThat(settings.getPushEnabled()).isFalse(); + assertThat(settings.getEmailEnabled()).isFalse(); + assertThat(settings.getSmsEnabled()).isFalse(); + assertThat(settings.getInAppEnabled()).isTrue(); + } +} From e2b3be1b97fc9ac22b5ab64189c43967e9840454 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Sun, 9 Aug 2026 23:05:20 +0900 Subject: [PATCH 58/68] =?UTF-8?q?FEAT=20:=20=EC=B1=84=EB=84=90=EB=B3=84=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EA=B0=80=EB=8A=A5=20=EC=97=AC=EB=B6=80?= =?UTF-8?q?=EC=99=80=20=EC=82=AC=EC=9C=A0=20=EB=85=B8=EC=B6=9C=20(#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FCM 자격증명이 없으면 푸시가 비활성화되는데 설정 화면에서는 이유를 알 수 없어 사용자가 자기 문제인 줄 안다. 왜 못 쓰는지는 채널마다 달라 발송기 자신만 안다. 채널 이름은 설정 변경 API 가 받는 값과 같아야 해서 enum 이름 대신 별도 키를 둔다. --- .../notification/app/NotificationFacade.java | 58 +++++++ .../controller/NotificationController.java | 10 ++ .../NotificationChannelStatusResponse.java | 31 ++++ .../sender/EmailNotificationSender.java | 5 + .../sender/NotificationChannelType.java | 22 ++- .../sender/NotificationDispatcher.java | 47 ++++- .../sender/NotificationSender.java | 10 ++ .../sender/PushNotificationSender.java | 5 + .../sender/SmsNotificationSender.java | 5 + .../NotificationChannelAvailabilityTest.java | 164 ++++++++++++++++++ 10 files changed, 351 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/carecode/domain/notification/dto/response/NotificationChannelStatusResponse.java create mode 100644 src/test/java/com/carecode/domain/notification/sender/NotificationChannelAvailabilityTest.java diff --git a/src/main/java/com/carecode/domain/notification/app/NotificationFacade.java b/src/main/java/com/carecode/domain/notification/app/NotificationFacade.java index ef080fad..02c65b94 100644 --- a/src/main/java/com/carecode/domain/notification/app/NotificationFacade.java +++ b/src/main/java/com/carecode/domain/notification/app/NotificationFacade.java @@ -5,12 +5,17 @@ import com.carecode.domain.notification.dto.request.NotificationRegisterPushTokenRequest; import com.carecode.domain.notification.dto.request.NotificationUpdateSettingsRequest; import com.carecode.domain.notification.dto.request.NotificationSendTestRequest; +import com.carecode.domain.notification.dto.response.NotificationChannelStatusResponse; import com.carecode.domain.notification.dto.response.NotificationInfoResponse; import com.carecode.domain.notification.dto.response.NotificationSettingsResponse; import com.carecode.domain.notification.dto.response.NotificationStatsResponse; import com.carecode.domain.notification.dto.response.NotificationTemplateResponse; import com.carecode.domain.notification.dto.response.NotificationDeliveryStatusResponse; +import com.carecode.core.exception.CareServiceException; import com.carecode.domain.notification.entity.Notification; +import com.carecode.domain.notification.repository.NotificationPreferenceRepository; +import com.carecode.domain.notification.sender.NotificationChannelType; +import com.carecode.domain.notification.sender.NotificationDispatcher; import com.carecode.domain.notification.service.NotificationPreferenceService; import com.carecode.domain.notification.service.NotificationService; import lombok.RequiredArgsConstructor; @@ -18,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.Arrays; import java.util.List; import java.util.Map; import com.carecode.domain.user.repository.UserRepository; @@ -30,6 +36,58 @@ public class NotificationFacade { private final NotificationService notificationService; private final NotificationPreferenceService preferenceService; private final UserRepository userRepository; + private final NotificationDispatcher notificationDispatcher; + private final NotificationPreferenceRepository preferenceRepository; + + /** + * 채널별 실제 사용 가능 여부. + * + * 서버 설정(자격증명·사업자 연동)뿐 아니라 **이 사용자에게 보낼 수단이 있는지**까지 본다. + * 발송기가 살아 있어도 받을 주소나 기기가 없으면 알림은 오지 않는다. 설정 화면이 + * 그 차이를 모르면 사용자는 켜 두고 오지 않는 알림을 기다리게 된다. + */ + @Transactional(readOnly = true) + public List getChannelStatuses(String userId) { + User user = userRepository.findByUserId(userId) + .orElseThrow(() -> new CareServiceException("사용자를 찾을 수 없습니다: " + userId)); + + return Arrays.stream(NotificationChannelType.values()) + .map(channel -> toChannelStatus(channel, user)) + .toList(); + } + + private NotificationChannelStatusResponse toChannelStatus(NotificationChannelType channel, User user) { + String reason = notificationDispatcher.unavailableReason(channel) + .orElseGet(() -> missingDestinationReason(channel, user)); + + return NotificationChannelStatusResponse.builder() + .channel(channel.getKey()) + .displayName(channel.getDisplayName()) + .available(reason == null) + .unavailableReason(reason) + .build(); + } + + /** 발송기는 살아 있는데 이 사용자에게 보낼 수단이 없는 경우. 보낼 수 있으면 null. */ + private String missingDestinationReason(NotificationChannelType channel, User user) { + return switch (channel) { + // 인앱은 알림 레코드 자체가 전달 수단이라 따로 수신처가 필요 없다. + case IN_APP -> null; + case EMAIL -> hasText(user.getEmail()) + ? null + : "받을 이메일 주소가 없어요. 프로필에서 이메일을 등록해주세요."; + case SMS -> hasText(user.getPhoneNumber()) + ? null + : "받을 전화번호가 없어요. 프로필에서 연락처를 등록해주세요."; + case PUSH -> preferenceRepository.findDeviceTokensByUser(user).isEmpty() + ? "이 기기에서 알림을 허용하면 받을 수 있어요." + : null; + }; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } @Transactional(readOnly = true) public List getNotificationsByUserId(String userId) { diff --git a/src/main/java/com/carecode/domain/notification/controller/NotificationController.java b/src/main/java/com/carecode/domain/notification/controller/NotificationController.java index d9abdb82..d8b8686c 100644 --- a/src/main/java/com/carecode/domain/notification/controller/NotificationController.java +++ b/src/main/java/com/carecode/domain/notification/controller/NotificationController.java @@ -9,6 +9,7 @@ import com.carecode.domain.notification.dto.request.NotificationRegisterPushTokenRequest; import com.carecode.domain.notification.dto.request.NotificationUpdateSettingsRequest; import com.carecode.domain.notification.dto.request.NotificationSendTestRequest; +import com.carecode.domain.notification.dto.response.NotificationChannelStatusResponse; import com.carecode.domain.notification.dto.response.NotificationInfoResponse; import com.carecode.domain.notification.dto.response.NotificationSettingsResponse; import com.carecode.domain.notification.dto.response.NotificationStatsResponse; @@ -203,6 +204,15 @@ public ResponseEntity updateNotificationPreference return ResponseEntity.ok(updatedPreference); } + // 채널별 사용 가능 여부 + @GetMapping("/channels") + @LogExecutionTime + @Operation(summary = "알림 채널 상태 조회", + description = "각 채널을 이 사용자에게 지금 실제로 발송할 수 있는지와, 불가능하면 그 이유") + public ResponseEntity> getNotificationChannels() { + return ResponseEntity.ok(notificationFacade.getChannelStatuses(getAuthenticatedUserCode())); + } + // 알림 설정 저장 @PostMapping("/preferences") @LogExecutionTime diff --git a/src/main/java/com/carecode/domain/notification/dto/response/NotificationChannelStatusResponse.java b/src/main/java/com/carecode/domain/notification/dto/response/NotificationChannelStatusResponse.java new file mode 100644 index 00000000..87533c7c --- /dev/null +++ b/src/main/java/com/carecode/domain/notification/dto/response/NotificationChannelStatusResponse.java @@ -0,0 +1,31 @@ +package com.carecode.domain.notification.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * 채널을 지금 실제로 쓸 수 있는지. + * + * 자격증명 미설정이나 사업자 미연동은 서버만 아는 사정이다. 클라이언트가 알 방법이 없어 + * 설정 화면이 "켤 수 있다" 고 안내하면 사용자는 켜 두고 오지 않는 알림을 기다리게 된다. + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class NotificationChannelStatusResponse { + + /** 채널별 설정 변경 API 가 받는 값과 같다. (inapp, email, push, sms) */ + private String channel; + + private String displayName; + + private boolean available; + + /** 쓸 수 없을 때만 채워진다. 화면에 그대로 보여줄 수 있는 문구. */ + private String unavailableReason; +} diff --git a/src/main/java/com/carecode/domain/notification/sender/EmailNotificationSender.java b/src/main/java/com/carecode/domain/notification/sender/EmailNotificationSender.java index 73534fba..8a28ffc0 100644 --- a/src/main/java/com/carecode/domain/notification/sender/EmailNotificationSender.java +++ b/src/main/java/com/carecode/domain/notification/sender/EmailNotificationSender.java @@ -30,6 +30,11 @@ public boolean isAvailable() { return fromAddress != null && !fromAddress.isBlank(); } + @Override + public String getUnavailableReason() { + return isAvailable() ? null : "이메일 발송이 아직 설정되지 않았어요."; + } + @Override public boolean send(NotificationPayload payload) { String to = payload.resolveEmailAddress(); diff --git a/src/main/java/com/carecode/domain/notification/sender/NotificationChannelType.java b/src/main/java/com/carecode/domain/notification/sender/NotificationChannelType.java index a592c958..7185a437 100644 --- a/src/main/java/com/carecode/domain/notification/sender/NotificationChannelType.java +++ b/src/main/java/com/carecode/domain/notification/sender/NotificationChannelType.java @@ -2,18 +2,30 @@ /** 알림 전달 채널. */ public enum NotificationChannelType { - IN_APP("인앱"), - EMAIL("이메일"), - PUSH("푸시"), - SMS("SMS"); + IN_APP("인앱", "inapp"), + EMAIL("이메일", "email"), + PUSH("푸시", "push"), + SMS("SMS", "sms"); private final String displayName; + private final String key; - NotificationChannelType(String displayName) { + NotificationChannelType(String displayName, String key) { this.displayName = displayName; + this.key = key; } public String getDisplayName() { return displayName; } + + /** + * 외부에 노출하는 채널 이름. + * + * 채널별 설정 변경 API 가 받는 값(`.../channels/{channel}`)과 같아야 한다. + * 클라이언트가 이 값을 그대로 되돌려 보내기 때문에 enum 이름(IN_APP)을 쓰지 않는다. + */ + public String getKey() { + return key; + } } diff --git a/src/main/java/com/carecode/domain/notification/sender/NotificationDispatcher.java b/src/main/java/com/carecode/domain/notification/sender/NotificationDispatcher.java index 3603d9df..114f51a3 100644 --- a/src/main/java/com/carecode/domain/notification/sender/NotificationDispatcher.java +++ b/src/main/java/com/carecode/domain/notification/sender/NotificationDispatcher.java @@ -53,7 +53,7 @@ public boolean dispatch(Notification notification) { .title(notification.getTitle()) .message(notification.getMessage()) .emailAddress(preference != null ? preference.getEmailAddress() : null) - .deviceToken(preference != null ? preference.getDeviceToken() : null) + .deviceToken(resolveDeviceToken(recipient, preference)) .phoneNumber(preference != null ? preference.getPhoneNumber() : null) .build(); @@ -82,6 +82,24 @@ public boolean dispatch(Notification notification) { return anySent; } + /** + * 푸시 대상 디바이스 토큰. + * + * 토큰 등록은 SYSTEM 설정 행에만 쓰는데 발송은 알림 유형별 행을 읽는다. 그래서 해당 유형의 + * 행만 보면 SYSTEM 알림 외에는 토큰이 없어 푸시가 조용히 실패한다. 토큰은 기기의 성질이므로 + * 유형과 무관하게 찾는다. + */ + private String resolveDeviceToken(User recipient, NotificationPreference preference) { + if (preference != null && preference.getDeviceToken() != null + && !preference.getDeviceToken().isBlank()) { + return preference.getDeviceToken(); + } + + return preferenceRepository.findDeviceTokensByUser(recipient).stream() + .findFirst() + .orElse(null); + } + /** 채널 사용 여부. 사용자 설정이 없으면 인앱과 푸시를 기본으로 켠다. */ private boolean isChannelEnabled(NotificationPreference preference, NotificationChannelType channel) { if (preference == null) { @@ -95,6 +113,33 @@ private boolean isChannelEnabled(NotificationPreference preference, Notification }; } + /** + * 지금 설정으로 이 채널을 실제 발송할 수 있는지. + * + * 인앱은 알림 레코드 자체가 전달 수단이라 발송기가 없고 항상 가능하다. + * {@link #dispatch} 가 채널을 건너뛸 때 보는 조건과 같아야 한다 — 다르면 설정 화면에서 + * 켤 수 있다고 안내한 채널이 실제로는 아무것도 보내지 않는다. + */ + public boolean isChannelAvailable(NotificationChannelType channel) { + if (channel == NotificationChannelType.IN_APP) { + return true; + } + + return senderFor(channel).filter(NotificationSender::isAvailable).isPresent(); + } + + /** 채널을 쓸 수 없는 이유. 쓸 수 있으면 빈 값. */ + public Optional unavailableReason(NotificationChannelType channel) { + if (isChannelAvailable(channel)) { + return Optional.empty(); + } + + return senderFor(channel) + .map(NotificationSender::getUnavailableReason) + .filter(reason -> reason != null && !reason.isBlank()) + .or(() -> Optional.of("지금은 이 방법으로 보낼 수 없어요.")); + } + Optional senderFor(NotificationChannelType channel) { return Optional.ofNullable(senders.get(channel)); } diff --git a/src/main/java/com/carecode/domain/notification/sender/NotificationSender.java b/src/main/java/com/carecode/domain/notification/sender/NotificationSender.java index f5832dba..c5e331af 100644 --- a/src/main/java/com/carecode/domain/notification/sender/NotificationSender.java +++ b/src/main/java/com/carecode/domain/notification/sender/NotificationSender.java @@ -12,4 +12,14 @@ public interface NotificationSender { default boolean isAvailable() { return true; } + + /** + * 사용할 수 없을 때 그 이유. 설정 화면에 그대로 보여줄 수 있는 문구다. + * + * 왜 못 쓰는지는 채널마다 다르고(자격증명 미설정, 사업자 미연동 등) 발송기 자신만 안다. + * 사용 가능할 때는 {@code null}. + */ + default String getUnavailableReason() { + return null; + } } diff --git a/src/main/java/com/carecode/domain/notification/sender/PushNotificationSender.java b/src/main/java/com/carecode/domain/notification/sender/PushNotificationSender.java index 8f111347..eabc851f 100644 --- a/src/main/java/com/carecode/domain/notification/sender/PushNotificationSender.java +++ b/src/main/java/com/carecode/domain/notification/sender/PushNotificationSender.java @@ -28,6 +28,11 @@ public boolean isAvailable() { return firebaseMessaging != null; } + @Override + public String getUnavailableReason() { + return isAvailable() ? null : "푸시 발송이 아직 설정되지 않았어요."; + } + @Override public boolean send(NotificationPayload payload) { if (!isAvailable()) { diff --git a/src/main/java/com/carecode/domain/notification/sender/SmsNotificationSender.java b/src/main/java/com/carecode/domain/notification/sender/SmsNotificationSender.java index 52bc0cd7..325f4be8 100644 --- a/src/main/java/com/carecode/domain/notification/sender/SmsNotificationSender.java +++ b/src/main/java/com/carecode/domain/notification/sender/SmsNotificationSender.java @@ -25,6 +25,11 @@ public boolean isAvailable() { return enabled; } + @Override + public String getUnavailableReason() { + return isAvailable() ? null : "문자 발송은 아직 준비 중이에요."; + } + @Override public boolean send(NotificationPayload payload) { String to = payload.resolvePhoneNumber(); diff --git a/src/test/java/com/carecode/domain/notification/sender/NotificationChannelAvailabilityTest.java b/src/test/java/com/carecode/domain/notification/sender/NotificationChannelAvailabilityTest.java new file mode 100644 index 00000000..4c2a5923 --- /dev/null +++ b/src/test/java/com/carecode/domain/notification/sender/NotificationChannelAvailabilityTest.java @@ -0,0 +1,164 @@ +package com.carecode.domain.notification.sender; + +import com.carecode.domain.notification.entity.Notification; +import com.carecode.domain.notification.entity.NotificationPreference; +import com.carecode.domain.notification.repository.NotificationPreferenceRepository; +import com.carecode.domain.user.entity.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +/** + * 채널 가용 여부 검증. + * + *

설정 화면은 이 값을 보고 "켜도 오지 않는" 채널을 잠근다. {@code dispatch} 가 채널을 건너뛰는 + * 조건과 어긋나면, 사용자는 켜 둔 채로 오지 않는 알림을 기다리게 된다. + */ +@ExtendWith(MockitoExtension.class) +class NotificationChannelAvailabilityTest { + + @Mock + private NotificationPreferenceRepository preferenceRepository; + + /** 테스트용 발송기. 실제 발송은 하지 않는다. */ + private record StubSender(NotificationChannelType channel, boolean available, String reason) + implements NotificationSender { + + @Override + public NotificationChannelType channel() { + return channel; + } + + @Override + public boolean isAvailable() { + return available; + } + + @Override + public String getUnavailableReason() { + return reason; + } + + @Override + public boolean send(NotificationPayload payload) { + return false; + } + } + + private NotificationDispatcher dispatcherWith(NotificationSender... senders) { + return new NotificationDispatcher(List.of(senders), preferenceRepository); + } + + @Test + @DisplayName("인앱은 발송기가 없어도 항상 사용할 수 있다 - 알림 레코드 자체가 전달 수단이다") + void 인앱은_항상_사용할_수_있다() { + NotificationDispatcher dispatcher = dispatcherWith(); + + assertThat(dispatcher.isChannelAvailable(NotificationChannelType.IN_APP)).isTrue(); + assertThat(dispatcher.unavailableReason(NotificationChannelType.IN_APP)).isEmpty(); + } + + @Test + @DisplayName("발송기가 등록되지 않은 채널은 쓸 수 없고 이유가 비어 있지 않다") + void 발송기가_없으면_사용할_수_없다() { + NotificationDispatcher dispatcher = dispatcherWith(); + + assertThat(dispatcher.isChannelAvailable(NotificationChannelType.EMAIL)).isFalse(); + assertThat(dispatcher.unavailableReason(NotificationChannelType.EMAIL)) + .isPresent() + .get() + .asString() + .isNotBlank(); + } + + @Test + @DisplayName("발송기가 비활성이면 발송기가 밝힌 이유를 그대로 전한다") + void 비활성_발송기의_이유를_그대로_전한다() { + NotificationDispatcher dispatcher = dispatcherWith( + new StubSender(NotificationChannelType.SMS, false, "문자 발송은 아직 준비 중이에요.")); + + assertThat(dispatcher.isChannelAvailable(NotificationChannelType.SMS)).isFalse(); + assertThat(dispatcher.unavailableReason(NotificationChannelType.SMS)) + .contains("문자 발송은 아직 준비 중이에요."); + } + + @Test + @DisplayName("사용 가능한 채널은 이유를 남기지 않는다") + void 사용_가능하면_이유가_없다() { + NotificationDispatcher dispatcher = dispatcherWith( + new StubSender(NotificationChannelType.PUSH, true, null)); + + assertThat(dispatcher.isChannelAvailable(NotificationChannelType.PUSH)).isTrue(); + assertThat(dispatcher.unavailableReason(NotificationChannelType.PUSH)).isEmpty(); + } + + @Test + @DisplayName("푸시 토큰은 알림 유형과 무관하게 찾는다 - 등록은 SYSTEM 행에만 쓰기 때문") + void 다른_유형에_등록된_토큰으로도_푸시를_보낸다() { + User recipient = User.builder().id(1L).userId("u-1").name("보호자").build(); + // POLICY 설정 행에는 토큰이 없다. 등록 시 SYSTEM 행에만 저장하기 때문이다. + NotificationPreference policyPreference = NotificationPreference.builder() + .user(recipient) + .notificationType(Notification.NotificationType.POLICY) + .pushEnabled(true) + .build(); + + when(preferenceRepository.findByUserAndNotificationType(recipient, Notification.NotificationType.POLICY)) + .thenReturn(Optional.of(policyPreference)); + when(preferenceRepository.findDeviceTokensByUser(recipient)).thenReturn(List.of("token-1")); + + RecordingSender push = new RecordingSender(NotificationChannelType.PUSH); + NotificationDispatcher dispatcher = dispatcherWith(push); + + Notification notification = Notification.builder() + .user(recipient) + .notificationType(Notification.NotificationType.POLICY) + .title("아동수당 신청 마감") + .message("3일 남았어요") + .build(); + + dispatcher.dispatch(notification); + + assertThat(push.lastPayload).isNotNull(); + assertThat(push.lastPayload.getDeviceToken()).isEqualTo("token-1"); + } + + /** 전달받은 발송 요청을 기록만 하는 발송기. */ + private static final class RecordingSender implements NotificationSender { + private final NotificationChannelType channel; + private NotificationPayload lastPayload; + + private RecordingSender(NotificationChannelType channel) { + this.channel = channel; + } + + @Override + public NotificationChannelType channel() { + return channel; + } + + @Override + public boolean send(NotificationPayload payload) { + this.lastPayload = payload; + return true; + } + } + + @Test + @DisplayName("채널 키는 설정 변경 API 가 받는 값과 같다") + void 채널_키가_설정_API_와_일치한다() { + // 클라이언트가 이 값을 그대로 `.../channels/{channel}` 로 되돌려 보낸다. + assertThat(NotificationChannelType.IN_APP.getKey()).isEqualTo("inapp"); + assertThat(NotificationChannelType.EMAIL.getKey()).isEqualTo("email"); + assertThat(NotificationChannelType.PUSH.getKey()).isEqualTo("push"); + assertThat(NotificationChannelType.SMS.getKey()).isEqualTo("sms"); + } +} From f8518d623bdda7ed1915b667e0e7ce96bc606f6c Mon Sep 17 00:00:00 2001 From: RosieOh Date: Sun, 9 Aug 2026 23:05:33 +0900 Subject: [PATCH 59/68] =?UTF-8?q?FEAT=20:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EB=B6=80=EB=B6=84=20=EC=88=98=EC=A0=95(PA?= =?UTF-8?q?TCH)=20=EC=A7=80=EC=9B=90=20(#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전체 교체만 있어서 금액 하나를 고치려 해도 모든 값을 담아야 하고, 빠뜨리면 데이터가 지워졌다. 값의 null 여부만 보면 비우기와 건드리지 않기를 구분할 수 없어 요청 JSON 에 키가 왔는지를 본다. --- .../controller/AdminPolicyController.java | 33 ++- .../admin/dto/AdminPolicyDetailResponse.java | 93 ++++++++ .../admin/dto/AdminPolicyPatchRequest.java | 47 ++++ .../admin/service/PolicyAdminService.java | 137 +++++++++-- .../service/PolicyAdminServicePatchTest.java | 219 ++++++++++++++++++ 5 files changed, 502 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/carecode/domain/admin/dto/AdminPolicyDetailResponse.java create mode 100644 src/main/java/com/carecode/domain/admin/dto/AdminPolicyPatchRequest.java create mode 100644 src/test/java/com/carecode/domain/admin/service/PolicyAdminServicePatchTest.java diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminPolicyController.java b/src/main/java/com/carecode/domain/admin/controller/AdminPolicyController.java index 1304c795..bdc1ee30 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminPolicyController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminPolicyController.java @@ -1,12 +1,12 @@ package com.carecode.domain.admin.controller; import com.carecode.core.exception.PolicyNotFoundException; +import com.carecode.domain.admin.dto.AdminPolicyDetailResponse; import com.carecode.domain.admin.dto.AdminPolicyRequest; import com.carecode.domain.admin.service.PolicyAdminService; -import com.carecode.domain.policy.dto.response.PolicyDto; import com.carecode.domain.policy.entity.Policy; -import com.carecode.domain.policy.mapper.PolicyMapper; import com.carecode.domain.policy.repository.PolicyRepository; +import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -26,35 +26,46 @@ public class AdminPolicyController { private final PolicyRepository policyRepository; - private final PolicyMapper policyMapper; private final PolicyAdminService policyAdminService; @GetMapping - @Operation(summary = "정책 목록 조회") - public ResponseEntity> list( + @Operation(summary = "정책 목록 조회", + description = "수정 요청과 1:1 로 대응하는 원본 값을 반환합니다 (사용자용 PolicyDto 는 가공된 값이라 수정에 쓸 수 없음)") + public ResponseEntity> list( @PageableDefault(size = 50, sort = "createdAt") Pageable pageable) { - return ResponseEntity.ok(policyRepository.findAll(pageable).map(policyMapper::toResponse)); + return ResponseEntity.ok(policyRepository.findAll(pageable).map(AdminPolicyDetailResponse::from)); } @GetMapping("/{id}") @Operation(summary = "정책 상세 조회") - public ResponseEntity detail(@PathVariable Long id) { - return ResponseEntity.ok(policyMapper.toResponse(findPolicy(id))); + public ResponseEntity detail(@PathVariable Long id) { + return ResponseEntity.ok(AdminPolicyDetailResponse.from(findPolicy(id))); } @PostMapping @Operation(summary = "정책 등록", description = "재배포 없이 새 정책 추가") - public ResponseEntity create(@Valid @RequestBody AdminPolicyRequest request) { + public ResponseEntity create(@Valid @RequestBody AdminPolicyRequest request) { return ResponseEntity.status(HttpStatus.CREATED).body(policyAdminService.create(request)); } @PutMapping("/{id}") - @Operation(summary = "정책 수정", description = "수정 시 해당 정책의 캐시가 무효화") - public ResponseEntity update(@PathVariable Long id, + @Operation(summary = "정책 전체 교체", + description = "요청에 담긴 값으로 전체를 덮어씁니다. 보내지 않은 항목은 null 이 되므로, " + + "일부만 고칠 때는 PATCH 를 쓰세요") + public ResponseEntity update(@PathVariable Long id, @Valid @RequestBody AdminPolicyRequest request) { return ResponseEntity.ok(policyAdminService.update(id, request)); } + @PatchMapping("/{id}") + @Operation(summary = "정책 부분 수정", + description = "요청 JSON 에 담긴 키만 반영합니다. 키가 없으면 기존 값을 유지하고, " + + "키가 있는데 값이 null 이면 해당 항목을 비웁니다") + public ResponseEntity patch(@PathVariable Long id, + @RequestBody JsonNode body) { + return ResponseEntity.ok(policyAdminService.patch(id, body)); + } + @DeleteMapping("/{id}") @Operation(summary = "정책 삭제") public ResponseEntity delete(@PathVariable Long id) { diff --git a/src/main/java/com/carecode/domain/admin/dto/AdminPolicyDetailResponse.java b/src/main/java/com/carecode/domain/admin/dto/AdminPolicyDetailResponse.java new file mode 100644 index 00000000..9c4a2dca --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/dto/AdminPolicyDetailResponse.java @@ -0,0 +1,93 @@ +package com.carecode.domain.admin.dto; + +import com.carecode.domain.policy.entity.Policy; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 어드민 정책 목록·상세 응답. + * + *

사용자용 {@code PolicyDto} 는 화면 표시에 맞춰 값을 가공한다 + * (신청 기간을 "2026.01.01 ~ 2026.03.31" 문자열로 합치고, policyCode 는 아예 내려주지 않는다). + * 그 값으로는 수정 화면을 채울 수 없다. {@code PolicyAdminService#apply} 는 요청에 담긴 값으로 + * 전체를 덮어쓰므로, 되돌려 보내지 못하는 필드는 수정할 때마다 null 이 된다. + * + *

그래서 어드민에는 {@link com.carecode.domain.admin.dto.AdminPolicyRequest} 와 1:1 로 대응하는 + * 원본 값을 내려준다. 사용자용 DTO 는 그대로 두어 화면 계약을 건드리지 않는다. + */ +@Getter +@Builder +public class AdminPolicyDetailResponse { + + private final Long id; + + // ==================== 수정 요청과 1:1 대응 ==================== + + private final String policyCode; + private final String title; + private final String description; + private final String policyType; + private final Integer targetAgeMin; + private final Integer targetAgeMax; + private final String targetRegion; + private final Integer benefitAmount; + private final String benefitType; + private final LocalDate applicationStartDate; + private final LocalDate applicationEndDate; + private final LocalDate policyStartDate; + private final LocalDate policyEndDate; + private final String applicationUrl; + private final String contactInfo; + private final String requiredDocuments; + private final Boolean isActive; + private final Integer priority; + private final Long policyCategoryId; + + // ==================== 참고용 (수정 대상 아님) ==================== + + private final String policyCategoryName; + + /** 금액이 수기 검증된 시각. null 이면 자동 수집된 추정치다. */ + private final LocalDateTime verifiedAt; + private final String verifiedBy; + private final String sourceUrl; + + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + + public static AdminPolicyDetailResponse from(Policy policy) { + return AdminPolicyDetailResponse.builder() + .id(policy.getId()) + .policyCode(policy.getPolicyCode()) + .title(policy.getTitle()) + .description(policy.getDescription()) + .policyType(policy.getPolicyType()) + .targetAgeMin(policy.getTargetAgeMin()) + .targetAgeMax(policy.getTargetAgeMax()) + .targetRegion(policy.getTargetRegion()) + .benefitAmount(policy.getBenefitAmount()) + .benefitType(policy.getBenefitType()) + .applicationStartDate(policy.getApplicationStartDate()) + .applicationEndDate(policy.getApplicationEndDate()) + .policyStartDate(policy.getPolicyStartDate()) + .policyEndDate(policy.getPolicyEndDate()) + .applicationUrl(policy.getApplicationUrl()) + .contactInfo(policy.getContactInfo()) + .requiredDocuments(policy.getRequiredDocuments()) + .isActive(policy.getIsActive()) + .priority(policy.getPriority()) + .policyCategoryId(policy.getPolicyCategory() != null + ? policy.getPolicyCategory().getId() : null) + .policyCategoryName(policy.getPolicyCategory() != null + ? policy.getPolicyCategory().getName() : null) + .verifiedAt(policy.getVerifiedAt()) + .verifiedBy(policy.getVerifiedBy()) + .sourceUrl(policy.getSourceUrl()) + .createdAt(policy.getCreatedAt()) + .updatedAt(policy.getUpdatedAt()) + .build(); + } +} diff --git a/src/main/java/com/carecode/domain/admin/dto/AdminPolicyPatchRequest.java b/src/main/java/com/carecode/domain/admin/dto/AdminPolicyPatchRequest.java new file mode 100644 index 00000000..9f05e04f --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/dto/AdminPolicyPatchRequest.java @@ -0,0 +1,47 @@ +package com.carecode.domain.admin.dto; + +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; + +/** + * 정책 부분 수정 요청. + * + *

{@link AdminPolicyRequest} 와 달리 필수 항목이 없다. 어느 필드를 실제로 바꿀지는 + * 이 객체가 아니라 요청 JSON 에 그 키가 있었는지로 판단한다 + * ({@code PolicyAdminService#patch}). + * + *

그래서 세 가지가 구분된다. + *

    + *
  • 키 없음 → 기존 값 유지
  • + *
  • 키가 있고 값이 있음 → 그 값으로 변경
  • + *
  • 키가 있고 값이 null → 값을 비움
  • + *
+ * + *

null 만으로 판단하면 "비우기" 와 "건드리지 않기" 를 구분할 수 없어 둘 중 하나는 못 하게 된다. + */ +@Getter +@Setter +public class AdminPolicyPatchRequest { + + private String policyCode; + private String title; + private String description; + private String policyType; + private Integer targetAgeMin; + private Integer targetAgeMax; + private String targetRegion; + private Integer benefitAmount; + private String benefitType; + private LocalDate applicationStartDate; + private LocalDate applicationEndDate; + private LocalDate policyStartDate; + private LocalDate policyEndDate; + private String applicationUrl; + private String contactInfo; + private String requiredDocuments; + private Boolean isActive; + private Integer priority; + private Long policyCategoryId; +} diff --git a/src/main/java/com/carecode/domain/admin/service/PolicyAdminService.java b/src/main/java/com/carecode/domain/admin/service/PolicyAdminService.java index bf0c85ef..aa0c7816 100644 --- a/src/main/java/com/carecode/domain/admin/service/PolicyAdminService.java +++ b/src/main/java/com/carecode/domain/admin/service/PolicyAdminService.java @@ -3,19 +3,25 @@ import com.carecode.core.exception.BusinessException; import com.carecode.core.exception.ErrorCode; import com.carecode.core.exception.PolicyNotFoundException; +import com.carecode.domain.admin.dto.AdminPolicyDetailResponse; +import com.carecode.domain.admin.dto.AdminPolicyPatchRequest; import com.carecode.domain.admin.dto.AdminPolicyRequest; -import com.carecode.domain.policy.dto.response.PolicyDto; import com.carecode.domain.policy.entity.Policy; import com.carecode.domain.policy.entity.PolicyCategory; -import com.carecode.domain.policy.mapper.PolicyMapper; import com.carecode.domain.policy.repository.PolicyCategoryRepository; import com.carecode.domain.policy.repository.PolicyRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.cache.annotation.CacheEvict; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; +import java.util.List; + /** 관리자 정책 관리. */ @Slf4j @Service @@ -25,14 +31,11 @@ public class PolicyAdminService { private final PolicyRepository policyRepository; private final PolicyCategoryRepository policyCategoryRepository; - private final PolicyMapper policyMapper; + private final ObjectMapper objectMapper; @Transactional - public PolicyDto create(AdminPolicyRequest request) { - policyRepository.findByPolicyCode(request.getPolicyCode()).ifPresent(existing -> { - throw new BusinessException(ErrorCode.INVALID_INPUT, - "이미 존재하는 정책 코드입니다: " + request.getPolicyCode()); - }); + public AdminPolicyDetailResponse create(AdminPolicyRequest request) { + assertPolicyCodeAvailable(request.getPolicyCode(), null); Policy policy = new Policy(); apply(policy, request); @@ -40,18 +43,123 @@ public PolicyDto create(AdminPolicyRequest request) { Policy saved = policyRepository.save(policy); log.info("정책 생성 - policyId={}, code={}", saved.getId(), saved.getPolicyCode()); - return policyMapper.toResponse(saved); + return AdminPolicyDetailResponse.from(saved); } - /** 정책 수정. 캐시된 상세 응답이 낡지 않도록 해당 항목을 무효화한다. */ + /** 정책 전체 교체. 요청에 없는 필드는 null 이 되므로 모든 값을 담아 보내야 한다. */ @Transactional @CacheEvict(cacheNames = "policy", key = "#policyId") - public PolicyDto update(Long policyId, AdminPolicyRequest request) { + public AdminPolicyDetailResponse update(Long policyId, AdminPolicyRequest request) { Policy policy = policyRepository.findById(policyId) .orElseThrow(() -> new PolicyNotFoundException("정책을 찾을 수 없습니다: " + policyId)); apply(policy, request); - return policyMapper.toResponse(policyRepository.save(policy)); + return AdminPolicyDetailResponse.from(policyRepository.save(policy)); + } + + /** + * 정책 부분 수정. + * + *

요청 JSON 에 담긴 키만 반영한다. 키가 없으면 기존 값을 그대로 두고, 키가 있는데 값이 + * null 이면 비운다. 값의 null 여부만 보면 "비우기" 와 "건드리지 않기" 를 구분할 수 없다. + * + * @param body 원본 요청 JSON. 어떤 키가 왔는지 확인하기 위해 트리 형태로 받는다. + */ + @Transactional + @CacheEvict(cacheNames = "policy", key = "#policyId") + public AdminPolicyDetailResponse patch(Long policyId, JsonNode body) { + Policy policy = policyRepository.findById(policyId) + .orElseThrow(() -> new PolicyNotFoundException("정책을 찾을 수 없습니다: " + policyId)); + + AdminPolicyPatchRequest request = toPatchRequest(body); + + applyIfPresent(body, "policyCode", () -> { + requireText(request.getPolicyCode(), "정책 코드"); + assertPolicyCodeAvailable(request.getPolicyCode(), policyId); + policy.setPolicyCode(request.getPolicyCode()); + }); + applyIfPresent(body, "title", () -> { + requireText(request.getTitle(), "정책명"); + policy.setTitle(request.getTitle()); + }); + applyIfPresent(body, "description", () -> policy.setDescription(request.getDescription())); + applyIfPresent(body, "policyType", () -> policy.setPolicyType(request.getPolicyType())); + applyIfPresent(body, "targetAgeMin", () -> policy.setTargetAgeMin(request.getTargetAgeMin())); + applyIfPresent(body, "targetAgeMax", () -> policy.setTargetAgeMax(request.getTargetAgeMax())); + applyIfPresent(body, "targetRegion", () -> policy.setTargetRegion(request.getTargetRegion())); + applyIfPresent(body, "benefitAmount", () -> policy.setBenefitAmount(request.getBenefitAmount())); + applyIfPresent(body, "benefitType", () -> policy.setBenefitType(request.getBenefitType())); + applyIfPresent(body, "applicationStartDate", + () -> policy.setApplicationStartDate(request.getApplicationStartDate())); + applyIfPresent(body, "applicationEndDate", + () -> policy.setApplicationEndDate(request.getApplicationEndDate())); + applyIfPresent(body, "policyStartDate", + () -> policy.setPolicyStartDate(request.getPolicyStartDate())); + applyIfPresent(body, "policyEndDate", () -> policy.setPolicyEndDate(request.getPolicyEndDate())); + applyIfPresent(body, "applicationUrl", + () -> policy.setApplicationUrl(request.getApplicationUrl())); + applyIfPresent(body, "contactInfo", () -> policy.setContactInfo(request.getContactInfo())); + applyIfPresent(body, "requiredDocuments", + () -> policy.setRequiredDocuments(request.getRequiredDocuments())); + applyIfPresent(body, "priority", () -> policy.setPriority(request.getPriority())); + + // 노출 여부는 비울 수 없다. null 로 두면 목록 조회 조건에서 빠져 사라진 것처럼 보인다. + applyIfPresent(body, "isActive", () -> policy.setIsActive( + request.getIsActive() != null ? request.getIsActive() : Boolean.TRUE)); + + applyIfPresent(body, "policyCategoryId", () -> policy.setPolicyCategory( + request.getPolicyCategoryId() != null + ? findCategory(request.getPolicyCategoryId()) + : null)); + + log.info("정책 부분 수정 - policyId={}, 변경 필드={}", policyId, fieldNames(body)); + return AdminPolicyDetailResponse.from(policyRepository.save(policy)); + } + + private AdminPolicyPatchRequest toPatchRequest(JsonNode body) { + if (body == null || !body.isObject()) { + throw new BusinessException(ErrorCode.INVALID_INPUT, "요청 본문이 올바르지 않습니다."); + } + try { + return objectMapper.treeToValue(body, AdminPolicyPatchRequest.class); + } catch (JsonProcessingException e) { + throw new BusinessException(ErrorCode.INVALID_INPUT, "요청 값을 해석할 수 없습니다: " + e.getOriginalMessage()); + } + } + + private void applyIfPresent(JsonNode body, String field, Runnable apply) { + if (body.has(field)) { + apply.run(); + } + } + + /** 부분 수정이라도 필수 항목을 빈 값으로 만들 수는 없다. */ + private void requireText(String value, String label) { + if (value == null || value.isBlank()) { + throw new BusinessException(ErrorCode.INVALID_INPUT, label + "은(는) 비울 수 없습니다."); + } + } + + /** @param policyId 수정 중인 정책. 신규 등록이면 null (자기 자신과의 충돌 검사가 없다) */ + private void assertPolicyCodeAvailable(String policyCode, Long policyId) { + policyRepository.findByPolicyCode(policyCode) + .filter(existing -> !existing.getId().equals(policyId)) + .ifPresent(existing -> { + throw new BusinessException(ErrorCode.INVALID_INPUT, + "이미 존재하는 정책 코드입니다: " + policyCode); + }); + } + + private PolicyCategory findCategory(Long categoryId) { + return policyCategoryRepository.findById(categoryId) + .orElseThrow(() -> new BusinessException(ErrorCode.POLICY_CATEGORY_NOT_FOUND, + "정책 카테고리를 찾을 수 없습니다: " + categoryId)); + } + + private String fieldNames(JsonNode body) { + List names = new ArrayList<>(); + body.fieldNames().forEachRemaining(names::add); + return String.join(", ", names); } @Transactional @@ -83,10 +191,7 @@ private void apply(Policy policy, AdminPolicyRequest request) { policy.setPriority(request.getPriority()); if (request.getPolicyCategoryId() != null) { - PolicyCategory category = policyCategoryRepository.findById(request.getPolicyCategoryId()) - .orElseThrow(() -> new BusinessException(ErrorCode.POLICY_CATEGORY_NOT_FOUND, - "정책 카테고리를 찾을 수 없습니다: " + request.getPolicyCategoryId())); - policy.setPolicyCategory(category); + policy.setPolicyCategory(findCategory(request.getPolicyCategoryId())); } } } diff --git a/src/test/java/com/carecode/domain/admin/service/PolicyAdminServicePatchTest.java b/src/test/java/com/carecode/domain/admin/service/PolicyAdminServicePatchTest.java new file mode 100644 index 00000000..77cacb1d --- /dev/null +++ b/src/test/java/com/carecode/domain/admin/service/PolicyAdminServicePatchTest.java @@ -0,0 +1,219 @@ +package com.carecode.domain.admin.service; + +import com.carecode.core.exception.BusinessException; +import com.carecode.domain.admin.dto.AdminPolicyDetailResponse; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.entity.PolicyCategory; +import com.carecode.domain.policy.repository.PolicyCategoryRepository; +import com.carecode.domain.policy.repository.PolicyRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 정책 부분 수정. + * + *

핵심은 "값이 null" 과 "키가 아예 없음" 을 구분하는 것이다. 이걸 구분하지 못하면 + * 필드를 비우는 것과 건드리지 않는 것 중 하나는 할 수 없게 된다. + */ +@DisplayName("정책 부분 수정 (PATCH)") +class PolicyAdminServicePatchTest { + + private PolicyRepository policyRepository; + private PolicyCategoryRepository policyCategoryRepository; + private ObjectMapper objectMapper; + private PolicyAdminService service; + + @BeforeEach + void setUp() { + policyRepository = mock(PolicyRepository.class); + policyCategoryRepository = mock(PolicyCategoryRepository.class); + objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + service = new PolicyAdminService(policyRepository, policyCategoryRepository, objectMapper); + + when(policyRepository.save(any(Policy.class))).thenAnswer(inv -> inv.getArgument(0)); + } + + @Test + @DisplayName("보내지 않은 필드는 그대로 둔다") + void keepsAbsentFields() { + Policy policy = existingPolicy(); + givenPolicy(policy); + + service.patch(1L, json("{\"title\":\"첫만남이용권(개정)\"}")); + + assertThat(policy.getTitle()).isEqualTo("첫만남이용권(개정)"); + // 전체 교체(PUT)였다면 아래가 전부 null 이 됐을 것이다. + assertThat(policy.getPolicyCode()).isEqualTo("FIRST_MEETING"); + assertThat(policy.getBenefitAmount()).isEqualTo(2_000_000); + assertThat(policy.getApplicationEndDate()).isEqualTo(LocalDate.of(2026, 12, 31)); + assertThat(policy.getPolicyType()).isEqualTo("VOUCHER"); + assertThat(policy.getPriority()).isEqualTo(5); + } + + @Test + @DisplayName("키가 있고 값이 null 이면 해당 항목을 비운다") + void clearsExplicitNull() { + Policy policy = existingPolicy(); + givenPolicy(policy); + + service.patch(1L, json("{\"applicationEndDate\":null,\"benefitAmount\":null}")); + + assertThat(policy.getApplicationEndDate()).isNull(); + assertThat(policy.getBenefitAmount()).isNull(); + // 함께 보내지 않은 값은 유지된다. + assertThat(policy.getApplicationStartDate()).isEqualTo(LocalDate.of(2026, 1, 1)); + } + + @Test + @DisplayName("날짜와 숫자를 형식에 맞게 변환한다") + void convertsTypes() { + Policy policy = existingPolicy(); + givenPolicy(policy); + + service.patch(1L, json("{\"applicationEndDate\":\"2027-03-31\",\"benefitAmount\":3000000}")); + + assertThat(policy.getApplicationEndDate()).isEqualTo(LocalDate.of(2027, 3, 31)); + assertThat(policy.getBenefitAmount()).isEqualTo(3_000_000); + } + + @Test + @DisplayName("필수 항목은 비울 수 없다") + void rejectsBlankingRequiredFields() { + givenPolicy(existingPolicy()); + + assertThatThrownBy(() -> service.patch(1L, json("{\"title\":null}"))) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("정책명"); + + assertThatThrownBy(() -> service.patch(1L, json("{\"policyCode\":\" \"}"))) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("정책 코드"); + } + + @Test + @DisplayName("다른 정책이 쓰는 코드로는 바꿀 수 없다") + void rejectsDuplicatePolicyCode() { + givenPolicy(existingPolicy()); + + Policy other = new Policy(); + other.setId(2L); + other.setPolicyCode("TAKEN"); + when(policyRepository.findByPolicyCode("TAKEN")).thenReturn(Optional.of(other)); + + assertThatThrownBy(() -> service.patch(1L, json("{\"policyCode\":\"TAKEN\"}"))) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("이미 존재하는"); + } + + @Test + @DisplayName("자기 코드를 그대로 다시 보내는 것은 충돌이 아니다") + void allowsSamePolicyCode() { + Policy policy = existingPolicy(); + givenPolicy(policy); + when(policyRepository.findByPolicyCode("FIRST_MEETING")).thenReturn(Optional.of(policy)); + + service.patch(1L, json("{\"policyCode\":\"FIRST_MEETING\"}")); + + assertThat(policy.getPolicyCode()).isEqualTo("FIRST_MEETING"); + } + + @Test + @DisplayName("노출 여부는 비우면 노출로 되돌린다") + void neverLeavesIsActiveNull() { + Policy policy = existingPolicy(); + givenPolicy(policy); + + service.patch(1L, json("{\"isActive\":null}")); + + // null 로 두면 목록 조회 조건에서 빠져 사라진 것처럼 보인다. + assertThat(policy.getIsActive()).isTrue(); + } + + @Test + @DisplayName("카테고리를 비우거나 바꿀 수 있다") + void updatesCategory() { + Policy policy = existingPolicy(); + givenPolicy(policy); + + // PolicyCategory 는 기본 생성자가 protected 라 테스트에서 직접 만들 수 없다. + PolicyCategory category = mock(PolicyCategory.class); + when(policyCategoryRepository.findById(7L)).thenReturn(Optional.of(category)); + + service.patch(1L, json("{\"policyCategoryId\":7}")); + assertThat(policy.getPolicyCategory()).isEqualTo(category); + + service.patch(1L, json("{\"policyCategoryId\":null}")); + assertThat(policy.getPolicyCategory()).isNull(); + } + + @Test + @DisplayName("없는 카테고리를 지정하면 거부한다") + void rejectsUnknownCategory() { + givenPolicy(existingPolicy()); + when(policyCategoryRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.patch(1L, json("{\"policyCategoryId\":99}"))) + .isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("빈 본문은 아무것도 바꾸지 않는다") + void emptyBodyChangesNothing() { + Policy policy = existingPolicy(); + givenPolicy(policy); + + AdminPolicyDetailResponse response = service.patch(1L, json("{}")); + + assertThat(response.getTitle()).isEqualTo("첫만남이용권"); + assertThat(policy.getBenefitAmount()).isEqualTo(2_000_000); + } + + @Test + @DisplayName("객체가 아닌 본문은 거부한다") + void rejectsNonObjectBody() { + givenPolicy(existingPolicy()); + + assertThatThrownBy(() -> service.patch(1L, json("[]"))) + .isInstanceOf(BusinessException.class); + } + + private void givenPolicy(Policy policy) { + when(policyRepository.findById(1L)).thenReturn(Optional.of(policy)); + } + + private Policy existingPolicy() { + Policy policy = new Policy(); + policy.setId(1L); + policy.setPolicyCode("FIRST_MEETING"); + policy.setTitle("첫만남이용권"); + policy.setPolicyType("VOUCHER"); + policy.setBenefitAmount(2_000_000); + policy.setBenefitType("LUMP_SUM"); + policy.setApplicationStartDate(LocalDate.of(2026, 1, 1)); + policy.setApplicationEndDate(LocalDate.of(2026, 12, 31)); + policy.setPriority(5); + policy.setIsActive(true); + return policy; + } + + private JsonNode json(String raw) { + try { + return objectMapper.readTree(raw); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +} From fc6f27e8efa6fa9d79613a4220155edb69ea833f Mon Sep 17 00:00:00 2001 From: RosieOh Date: Sun, 9 Aug 2026 23:05:33 +0900 Subject: [PATCH 60/68] =?UTF-8?q?DOCS=20:=20=EC=95=8C=EB=A6=BC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EA=B8=B0=EB=B3=B8=EA=B0=92=EA=B3=BC=20=EA=B4=80?= =?UTF-8?q?=EA=B3=84=20=ED=91=9C=EA=B8=B0=20=EC=A0=95=EC=A0=95=20(#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1:1 로 적혀 있었으나 실제로는 알림 유형별 한 행이라 1:N 이다. 누락됐던 인앱 활성화 컬럼도 채운다. --- docs/ERD.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/ERD.md b/docs/ERD.md index 26b168c1..37266b15 100644 --- a/docs/ERD.md +++ b/docs/ERD.md @@ -887,9 +887,10 @@ erDiagram | ID | BIGINT | PK, AUTO_INCREMENT | 고유 식별자 | | USER_ID | BIGINT | FK, NOT NULL | 사용자 ID | | NOTIFICATION_TYPE | ENUM | NOT NULL | 알림 유형 | -| EMAIL_ENABLED | BOOLEAN | DEFAULT TRUE | 이메일 알림 활성화 | +| EMAIL_ENABLED | BOOLEAN | DEFAULT FALSE | 이메일 알림 활성화 | | PUSH_ENABLED | BOOLEAN | DEFAULT TRUE | 푸시 알림 활성화 | | SMS_ENABLED | BOOLEAN | DEFAULT FALSE | SMS 알림 활성화 | +| IN_APP_ENABLED | BOOLEAN | DEFAULT TRUE | 인앱 알림 활성화 | | CREATED_AT | DATETIME | NOT NULL | 생성 시간 | | UPDATED_AT | DATETIME | NULL | 수정 시간 | @@ -1023,7 +1024,7 @@ TBL_USER (1) ----< (N) TBL_CHAT_SESSION | Hospital - HospitalReview | 1:N | 한 병원은 여러 리뷰 받을 수 있음 | | HealthRecord - Attachment | 1:N | 한 건강 기록은 여러 첨부파일 가능 | | Policy - PolicyDocument | 1:N | 한 정책은 여러 문서를 가질 수 있음 | -| User - NotificationSettings | 1:1 | 한 사용자는 하나의 알림 설정을 가짐 | +| User - NotificationPreference | 1:N | 알림 유형별로 한 행씩 가짐 (UNIQUE: USER_ID + NOTIFICATION_TYPE) | | ChatSession - ChatMessage | 1:N | 한 세션은 여러 메시지를 포함 | --- From 9f053077da8b43b1f62aba48df67cd58c4693cd9 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Sun, 9 Aug 2026 23:08:46 +0900 Subject: [PATCH 61/68] =?UTF-8?q?FIX=20:=20SYSTEM=20=EC=9D=B4=20=EC=95=84?= =?UTF-8?q?=EB=8B=8C=20=EC=95=8C=EB=A6=BC=EC=97=90=20=ED=91=B8=EC=8B=9C?= =?UTF-8?q?=EA=B0=80=20=EB=82=98=EA=B0=80=EC=A7=80=20=EC=95=8A=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20(#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 디바이스 토큰은 기기의 성질이지 알림 유형의 성질이 아닌데 설정 행마다 들고 있다. 등록은 SYSTEM 행에만 쓰므로 유형별 행에서 찾으면 정책·시설 알림은 토큰을 못 찾아 푸시가 누락된다. 유형과 무관하게 찾는다. --- .../NotificationPreferenceRepository.java | 11 ++ .../app/NotificationChannelStatusTest.java | 148 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 src/test/java/com/carecode/domain/notification/app/NotificationChannelStatusTest.java diff --git a/src/main/java/com/carecode/domain/notification/repository/NotificationPreferenceRepository.java b/src/main/java/com/carecode/domain/notification/repository/NotificationPreferenceRepository.java index 8d3625ed..6a7cd1fd 100644 --- a/src/main/java/com/carecode/domain/notification/repository/NotificationPreferenceRepository.java +++ b/src/main/java/com/carecode/domain/notification/repository/NotificationPreferenceRepository.java @@ -21,6 +21,17 @@ public interface NotificationPreferenceRepository extends JpaRepository findByUserAndNotificationType(User user, Notification.NotificationType notificationType); + /** + * 사용자에게 등록된 디바이스 토큰. + * + * 토큰은 기기의 성질이지 알림 유형의 성질이 아닌데 설정 행마다 들고 있다. 등록은 SYSTEM 행에만 + * 쓰므로 유형별 행에서만 찾으면 SYSTEM 이 아닌 알림은 푸시가 나가지 않는다. 유형과 무관하게 찾는다. + */ + @Query("SELECT np.deviceToken FROM NotificationPreference np " + + "WHERE np.user = :user AND np.deviceToken IS NOT NULL AND np.deviceToken <> '' " + + "ORDER BY np.updatedAt DESC") + List findDeviceTokensByUser(@Param("user") User user); + // 사용자별 활성화된 이메일 알림 설정 조회 @Query("SELECT np FROM NotificationPreference np WHERE np.user = :user AND np.emailEnabled = true") List findEmailEnabledByUser(@Param("user") User user); diff --git a/src/test/java/com/carecode/domain/notification/app/NotificationChannelStatusTest.java b/src/test/java/com/carecode/domain/notification/app/NotificationChannelStatusTest.java new file mode 100644 index 00000000..a32b4feb --- /dev/null +++ b/src/test/java/com/carecode/domain/notification/app/NotificationChannelStatusTest.java @@ -0,0 +1,148 @@ +package com.carecode.domain.notification.app; + +import com.carecode.domain.notification.dto.response.NotificationChannelStatusResponse; +import com.carecode.domain.notification.repository.NotificationPreferenceRepository; +import com.carecode.domain.notification.sender.NotificationChannelType; +import com.carecode.domain.notification.sender.NotificationDispatcher; +import com.carecode.domain.notification.service.NotificationPreferenceService; +import com.carecode.domain.notification.service.NotificationService; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * 채널 상태 조회 검증. + * + *

발송기가 살아 있어도 이 사용자에게 보낼 주소나 기기가 없으면 알림은 오지 않는다. + * 설정 화면은 이 응답만 보고 토글을 잠그므로, 두 사정을 모두 반영해야 한다. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class NotificationChannelStatusTest { + + private static final String USER_ID = "u-1"; + + @Mock + private NotificationService notificationService; + + @Mock + private NotificationPreferenceService preferenceService; + + @Mock + private UserRepository userRepository; + + @Mock + private NotificationDispatcher notificationDispatcher; + + @Mock + private NotificationPreferenceRepository preferenceRepository; + + @InjectMocks + private NotificationFacade notificationFacade; + + @BeforeEach + void setUp() { + // 서버 설정은 모두 정상인 상태에서 시작한다. 수신처 유무만 검증하기 위해서다. + when(notificationDispatcher.unavailableReason(any())).thenReturn(Optional.empty()); + when(preferenceRepository.findDeviceTokensByUser(any(User.class))).thenReturn(List.of()); + } + + private void givenUser(String email, String phoneNumber) { + User user = User.builder() + .id(1L).userId(USER_ID).name("보호자") + .email(email).phoneNumber(phoneNumber) + .build(); + when(userRepository.findByUserId(USER_ID)).thenReturn(Optional.of(user)); + } + + private Map statuses() { + return notificationFacade.getChannelStatuses(USER_ID).stream() + .collect(Collectors.toMap(NotificationChannelStatusResponse::getChannel, Function.identity())); + } + + @Test + @DisplayName("인앱은 수신처가 필요 없어 항상 사용할 수 있다") + void 인앱은_항상_사용할_수_있다() { + givenUser(null, null); + + assertThat(statuses().get("inapp").isAvailable()).isTrue(); + } + + @Test + @DisplayName("이메일 주소가 없으면 이메일 채널을 쓸 수 없다") + void 이메일_주소가_없으면_쓸_수_없다() { + givenUser(null, "010-0000-0000"); + + NotificationChannelStatusResponse email = statuses().get("email"); + + assertThat(email.isAvailable()).isFalse(); + assertThat(email.getUnavailableReason()).contains("이메일"); + } + + @Test + @DisplayName("전화번호가 없으면 문자 채널을 쓸 수 없다") + void 전화번호가_없으면_문자를_쓸_수_없다() { + givenUser("parent@example.com", " "); + + NotificationChannelStatusResponse sms = statuses().get("sms"); + + assertThat(sms.isAvailable()).isFalse(); + assertThat(sms.getUnavailableReason()).contains("전화번호"); + } + + @Test + @DisplayName("등록된 기기가 없으면 푸시를 쓸 수 없다") + void 등록된_기기가_없으면_푸시를_쓸_수_없다() { + givenUser("parent@example.com", "010-0000-0000"); + + NotificationChannelStatusResponse push = statuses().get("push"); + + assertThat(push.isAvailable()).isFalse(); + assertThat(push.getUnavailableReason()).isNotBlank(); + } + + @Test + @DisplayName("수신처가 모두 있으면 전 채널을 쓸 수 있다") + void 수신처가_있으면_쓸_수_있다() { + givenUser("parent@example.com", "010-0000-0000"); + when(preferenceRepository.findDeviceTokensByUser(any(User.class))).thenReturn(List.of("token-1")); + + assertThat(statuses().values()) + .allSatisfy(status -> { + assertThat(status.isAvailable()).isTrue(); + assertThat(status.getUnavailableReason()).isNull(); + }); + } + + @Test + @DisplayName("서버 설정 문제가 수신처 문제보다 먼저 안내된다") + void 서버_설정_이유가_우선한다() { + // 둘 다 문제인 상황에서 "번호를 등록하세요" 라고 안내하면, 등록해도 여전히 안 온다. + givenUser("parent@example.com", null); + when(notificationDispatcher.unavailableReason(NotificationChannelType.SMS)) + .thenReturn(Optional.of("문자 발송은 아직 준비 중이에요.")); + + NotificationChannelStatusResponse sms = statuses().get("sms"); + + assertThat(sms.isAvailable()).isFalse(); + assertThat(sms.getUnavailableReason()).isEqualTo("문자 발송은 아직 준비 중이에요."); + } +} From 60e8ef518f34e8a2507afe3c092ef67132f2fabd Mon Sep 17 00:00:00 2001 From: RosieOh Date: Mon, 10 Aug 2026 10:09:51 +0900 Subject: [PATCH 62/68] =?UTF-8?q?FIX=20:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20DDL=20=EA=B8=B0=EB=B3=B8=EA=B0=92=EC=9D=84?= =?UTF-8?q?=20=EC=97=94=ED=8B=B0=ED=8B=B0=EC=99=80=20=EC=9D=BC=EC=B9=98?= =?UTF-8?q?=EC=8B=9C=ED=82=B4=20(#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 엔티티는 false 로 고쳤는데 DDL 은 TRUE 로 남아 있었다. JPA 는 값을 항상 명시해 쓰지만 시드 스크립트나 수기 SQL 은 기본값을 타므로 그 경로로 같은 버그가 재현된다. --- .../db/migration/V18__notification_email_default.sql | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/main/resources/db/migration/V18__notification_email_default.sql diff --git a/src/main/resources/db/migration/V18__notification_email_default.sql b/src/main/resources/db/migration/V18__notification_email_default.sql new file mode 100644 index 00000000..3b5b6c95 --- /dev/null +++ b/src/main/resources/db/migration/V18__notification_email_default.sql @@ -0,0 +1,7 @@ +-- 이메일 알림 기본값을 끔으로 바꾼다. +-- 설정 행이 없을 때 실제로 발송되는 채널에는 이메일이 없는데 DDL 기본값만 TRUE 라, +-- 시드 스크립트나 수기 SQL 처럼 JPA 를 거치지 않는 삽입에서는 요청한 적 없는 이메일 알림이 켜진다. +-- 엔티티 기본값(false)과 맞춘다. + +ALTER TABLE notification_preferences + ALTER COLUMN EMAIL_ENABLED SET DEFAULT FALSE; From 9c108c6eb426e3246d00c849a9ec339feb2709ef Mon Sep 17 00:00:00 2001 From: RosieOh Date: Mon, 10 Aug 2026 10:09:51 +0900 Subject: [PATCH 63/68] =?UTF-8?q?FEAT=20:=20=EC=B1=84=EB=84=90=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EB=B6=88=EA=B0=80=20=EC=82=AC=EC=9C=A0=EC=97=90=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문구는 바뀔 수 있어 클라이언트가 문자열로 판단하면 안 된다. 서버 설정 문제와 보낼 곳 없음은 사용자가 할 수 있는 일이 다르다. 둘 다 문제일 때 번호를 등록하라고 안내하면 등록하고도 알림을 못 받는다. --- .../notification/app/NotificationFacade.java | 15 +++++++++++++-- .../NotificationChannelStatusResponse.java | 13 +++++++++++++ .../app/NotificationChannelStatusTest.java | 6 ++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/carecode/domain/notification/app/NotificationFacade.java b/src/main/java/com/carecode/domain/notification/app/NotificationFacade.java index 02c65b94..9fce3f9d 100644 --- a/src/main/java/com/carecode/domain/notification/app/NotificationFacade.java +++ b/src/main/java/com/carecode/domain/notification/app/NotificationFacade.java @@ -57,14 +57,25 @@ public List getChannelStatuses(String userId) } private NotificationChannelStatusResponse toChannelStatus(NotificationChannelType channel, User user) { - String reason = notificationDispatcher.unavailableReason(channel) - .orElseGet(() -> missingDestinationReason(channel, user)); + // 서버 설정 문제가 먼저다. 둘 다 문제인데 "번호를 등록하세요" 라고 안내하면 + // 사용자가 등록하고도 알림을 받지 못한다. + String serverReason = notificationDispatcher.unavailableReason(channel).orElse(null); + String destinationReason = serverReason == null ? missingDestinationReason(channel, user) : null; + String reason = serverReason != null ? serverReason : destinationReason; + + String reasonCode = null; + if (serverReason != null) { + reasonCode = NotificationChannelStatusResponse.REASON_SERVER_NOT_CONFIGURED; + } else if (destinationReason != null) { + reasonCode = NotificationChannelStatusResponse.REASON_NO_DESTINATION; + } return NotificationChannelStatusResponse.builder() .channel(channel.getKey()) .displayName(channel.getDisplayName()) .available(reason == null) .unavailableReason(reason) + .reasonCode(reasonCode) .build(); } diff --git a/src/main/java/com/carecode/domain/notification/dto/response/NotificationChannelStatusResponse.java b/src/main/java/com/carecode/domain/notification/dto/response/NotificationChannelStatusResponse.java index 87533c7c..aad03d0d 100644 --- a/src/main/java/com/carecode/domain/notification/dto/response/NotificationChannelStatusResponse.java +++ b/src/main/java/com/carecode/domain/notification/dto/response/NotificationChannelStatusResponse.java @@ -28,4 +28,17 @@ public class NotificationChannelStatusResponse { /** 쓸 수 없을 때만 채워진다. 화면에 그대로 보여줄 수 있는 문구. */ private String unavailableReason; + + /** + * 쓸 수 없는 이유의 종류. 문구는 바뀔 수 있어 클라이언트가 문자열로 판단하면 안 된다. + * + *

    + *
  • {@code SERVER_NOT_CONFIGURED} - 서버 설정 문제. 사용자가 할 수 있는 일이 없다.
  • + *
  • {@code NO_DESTINATION} - 보낼 곳이 없다. 사용자가 등록하면 해결된다.
  • + *
+ */ + private String reasonCode; + + public static final String REASON_SERVER_NOT_CONFIGURED = "SERVER_NOT_CONFIGURED"; + public static final String REASON_NO_DESTINATION = "NO_DESTINATION"; } diff --git a/src/test/java/com/carecode/domain/notification/app/NotificationChannelStatusTest.java b/src/test/java/com/carecode/domain/notification/app/NotificationChannelStatusTest.java index a32b4feb..1ee29a69 100644 --- a/src/test/java/com/carecode/domain/notification/app/NotificationChannelStatusTest.java +++ b/src/test/java/com/carecode/domain/notification/app/NotificationChannelStatusTest.java @@ -117,6 +117,9 @@ private Map statuses() { assertThat(push.isAvailable()).isFalse(); assertThat(push.getUnavailableReason()).isNotBlank(); + // 사용자가 기기를 등록하면 해결되는 문제다. 화면은 이 코드를 보고 등록 버튼을 띄운다. + assertThat(push.getReasonCode()) + .isEqualTo(NotificationChannelStatusResponse.REASON_NO_DESTINATION); } @Test @@ -144,5 +147,8 @@ private Map statuses() { assertThat(sms.isAvailable()).isFalse(); assertThat(sms.getUnavailableReason()).isEqualTo("문자 발송은 아직 준비 중이에요."); + // 사용자가 번호를 등록해도 해결되지 않는다. 등록을 권해서는 안 된다. + assertThat(sms.getReasonCode()) + .isEqualTo(NotificationChannelStatusResponse.REASON_SERVER_NOT_CONFIGURED); } } From 726eac9ee1926aed9a808bc710f6944cae1483ad Mon Sep 17 00:00:00 2001 From: RosieOh Date: Mon, 10 Aug 2026 10:11:14 +0900 Subject: [PATCH 64/68] =?UTF-8?q?DOCS=20:=20V18=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B2=84=EC=A0=84=20=EA=B3=A0=EC=A0=95=20=ED=91=9C?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=9C=EA=B1=B0=20(#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 마이그레이션 번호를 문서 네 곳에 박아 두어 추가할 때마다 전부 손봐야 했다. 엔티티 기본값만 고치면 절반만 고친 것이라는 점도 함께 남긴다. --- docs/README.md | 2 +- docs/architecture/carecode-architecture.drawio | 2 +- docs/architecture/system-overview.md | 2 +- docs/quality/regression-safety.md | 2 +- docs/reference/database-migrations.md | 13 +++++++++++++ 5 files changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/README.md b/docs/README.md index 34672466..1642be4b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,7 +47,7 @@ | 문서 | 내용 | |------|------| -| [데이터베이스 마이그레이션](reference/database-migrations.md) | V1~V17 각각이 왜 필요했는지 | +| [데이터베이스 마이그레이션](reference/database-migrations.md) | 각 마이그레이션이 왜 필요했는지 | | [접근제어 매트릭스](reference/access-control-matrix.md) | 공개·인증·관리자 경로 전수 | ### 기존 문서 diff --git a/docs/architecture/carecode-architecture.drawio b/docs/architecture/carecode-architecture.drawio index 97881b7e..76436c8f 100644 --- a/docs/architecture/carecode-architecture.drawio +++ b/docs/architecture/carecode-architecture.drawio @@ -83,7 +83,7 @@ - + diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md index 4d48ce87..3496dcbd 100644 --- a/docs/architecture/system-overview.md +++ b/docs/architecture/system-overview.md @@ -38,7 +38,7 @@ graph TB end subgraph store["저장소"] - DB[("MariaDB
Flyway V1~V17")] + DB[("MariaDB
Flyway 마이그레이션")] REDIS[("Redis
캐시·레이트리밋·토큰")] FILES["파일 저장소"] end diff --git a/docs/quality/regression-safety.md b/docs/quality/regression-safety.md index 78debe2f..f5ecbf79 100644 --- a/docs/quality/regression-safety.md +++ b/docs/quality/regression-safety.md @@ -43,7 +43,7 @@ SecurityConfig 는 선언 순서에 따라 앞선 규칙이 뒤를 덮습니다. ```mermaid flowchart LR - A["Testcontainers
MariaDB 10.11"] --> B["Flyway 전체 적용
V1 ~ V17"] + A["Testcontainers
MariaDB 10.11"] --> B["Flyway 전체 적용"] B --> C["Hibernate validate"] C -->|불일치| F["기동 실패
= 테스트 실패"] C -->|일치| D["단언 검증"] diff --git a/docs/reference/database-migrations.md b/docs/reference/database-migrations.md index 2b166707..482d0439 100644 --- a/docs/reference/database-migrations.md +++ b/docs/reference/database-migrations.md @@ -25,6 +25,7 @@ | V15 | `missing_entity_tables` | 누락 테이블·컬럼 보충 | **엔티티는 있는데 DDL 에 없어 기동 실패** | | V16 | `waitlist_vacancy_notice` | 빈자리 알림 발송 이력 | 같은 자리를 반복 알리면 신뢰를 잃음 | | V17 | `policy_deadline_notice` | 마감 알림 발송 이력 | **Blue/Green 에서 인스턴스가 2대가 되면 중복 발송** | +| V18 | `notification_email_default` | 이메일 알림 DDL 기본값 | 엔티티는 `false` 인데 DDL 이 `TRUE` 라 JPA 를 안 거치면 켜짐 | ## 특히 기억할 것들 @@ -65,6 +66,18 @@ Blue/Green 배포로 인스턴스가 잠깐 2대가 되면 모든 알림이 두 존재 확인은 반복 실행을, 유니크 제약은 동시 실행을 막습니다. +### V18 — 엔티티만 고치면 절반만 고친 것 + +이메일 알림 기본값 버그를 엔티티에서 `false` 로 고쳤는데 DDL 은 `DEFAULT TRUE` 로 남아 있었습니다. + +JPA 는 값을 항상 명시해서 쓰기 때문에 **앱을 거치는 생성은 정상**입니다. +그래서 테스트도 통과하고 실사용에서도 드러나지 않습니다. + +문제는 **시드 스크립트나 수기 SQL** 처럼 JPA 를 거치지 않는 삽입입니다. +그 경로로는 여전히 요청한 적 없는 이메일 알림이 켜진 채 행이 만들어집니다. + +기본값을 바꾸는 엔티티 변경은 **DDL 기본값도 함께 봐야 합니다.** + ## 작성 규칙 ### MariaDB 문법만 사용 From e9efcacf2f8b3c379ee7066a891c3ad052a091b7 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Mon, 10 Aug 2026 10:26:49 +0900 Subject: [PATCH 65/68] =?UTF-8?q?FIX=20:=20=EA=B7=BC=EA=B1=B0=20=EC=97=86?= =?UTF-8?q?=EC=9D=B4=2085%=EB=A1=9C=20=EA=B3=A0=EC=A0=95=EB=8F=BC=20?= =?UTF-8?q?=EC=9E=88=EB=8D=98=20=EC=98=81=EC=96=91=20=EC=A7=84=ED=96=89?= =?UTF-8?q?=EB=A5=A0=20=EC=A0=9C=EA=B1=B0=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 섭취를 기록하는 수단이 없는데 상수 85 를 돌려주어 모든 사용자가 자기 아이의 영양 상태를 85% 로 봤다. 근거 없는 숫자는 없는 것보다 나쁘다. 목표 문구는 남기고 달성률만 뺀다. --- .../carecode/domain/health/service/HealthService.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/carecode/domain/health/service/HealthService.java b/src/main/java/com/carecode/domain/health/service/HealthService.java index bc22c55f..92a5f068 100644 --- a/src/main/java/com/carecode/domain/health/service/HealthService.java +++ b/src/main/java/com/carecode/domain/health/service/HealthService.java @@ -62,7 +62,6 @@ public class HealthService { // 상수 정의 private static final String DEFAULT_ALERT_PRIORITY = "MEDIUM"; - private static final int DEFAULT_NUTRITION_PROGRESS = 85; private static final int DEFAULT_MONTHS_FOR_NEXT_CHECKUP = 3; private static final int MAX_UPCOMING_EVENTS = 5; private static final int HEALTH_SCORE_HIGH_THRESHOLD = 80; @@ -547,6 +546,7 @@ public Map getHealthGoals(String userId, Long actorUserId) { goals.put("userId", userId); goals.put("vaccineGoal", "모든 예방접종 완료"); goals.put("checkupGoal", "정기 검진 100% 완료"); + // 영양은 목표만 제시하고 달성률은 내지 않는다. 섭취를 기록하는 수단이 없다. goals.put("nutritionGoal", "균형 잡힌 영양 섭취"); goals.put("progress", calculateProgress(records)); @@ -796,6 +796,12 @@ private String calculateCheckupStatus(List records) { "검진 기록 없음"; } + /** + * 목표별 달성률. 계산할 근거가 없는 항목은 넣지 않는다. + * + *

영양은 목표 문구만 있고 섭취를 기록하는 수단이 없다. 예전에는 여기서 85 를 돌려주어 + * 모든 사용자가 자기 아이의 영양 상태를 85% 로 봤다. 근거 없는 숫자는 없는 것보다 나쁘다. + */ private Map calculateProgress(List records) { Map progress = new HashMap<>(); @@ -811,7 +817,6 @@ private Map calculateProgress(List records) { progress.put("vaccine", totalVaccines > 0 ? (completedVaccines * 100) / totalVaccines : 0); progress.put("checkup", totalCheckups > 0 ? (completedCheckups * 100) / totalCheckups : 0); - progress.put("nutrition", DEFAULT_NUTRITION_PROGRESS); // TODO: 실제 영양 진행률 계산 로직 필요 return progress; } From 691cc1138316cd037d0e539f5e268462ec7a016d Mon Sep 17 00:00:00 2001 From: RosieOh Date: Mon, 10 Aug 2026 10:26:50 +0900 Subject: [PATCH 66/68] =?UTF-8?q?CHORE=20:=20=EC=82=AC=EC=9A=A9=EC=B2=98?= =?UTF-8?q?=20=EC=97=86=EB=8A=94=20asciidoctor=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=A0=9C=EA=B1=B0=20(#29,=20#33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API 문서는 springdoc 이 런타임에 생성한다. .adoc 도 사용처도 없이 실행 jar 에 들어가 있었고 JRuby 까지 끌고 와서 191MB 를 156MB 로 줄였다. 취약점 스캔 표면도 함께 줄어든다. --- build.gradle | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 6a6b297e..4c84bd7b 100644 --- a/build.gradle +++ b/build.gradle @@ -78,9 +78,9 @@ dependencies { // Logging - JSON 형식 로깅 지원 implementation 'net.logstash.logback:logstash-logback-encoder:7.4' - // AsciiDoctor for documentation generation - implementation 'org.asciidoctor:asciidoctorj:2.5.7' - implementation 'org.asciidoctor:asciidoctorj-pdf:2.3.4' + // API 문서는 springdoc-openapi 가 런타임에 생성한다(운영 프로파일에서는 비공개). + // asciidoctor 로 정적 산출물을 만들던 시절의 의존성이 남아 있었는데, .adoc 도 사용처도 없이 + // 실행 jar 에 7.5MB 를 차지하고 취약점 스캔 표면만 늘리고 있었다. } // 소스에 한글 문자열 리터럴이 있다. 인코딩을 지정하지 않으면 Windows(CP949)에서 깨져 컴파일된다. From cc43048d7a4490d1015f99d5a08fd1328b839fb4 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Mon, 10 Aug 2026 10:26:50 +0900 Subject: [PATCH 67/68] =?UTF-8?q?FEAT=20:=20=EC=9A=94=EC=B2=AD=EB=B3=84=20?= =?UTF-8?q?=EC=B6=94=EC=A0=81=20ID=20=EC=A0=84=ED=8C=8C=20(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit traceId 를 @LogExecutionTime 안에서만 넣어서 인증 실패나 없는 경로처럼 컨트롤러 전에 끝나는 요청은 추적할 수 없었다. 보안 필터보다 먼저 돌려 401 에도 남기고, 응답 헤더와 오류 본문 양쪽에 담는다. 사용자는 화면을 캡처해 보내는데 헤더는 캡처에 안 나온다. --- .../carecode/core/handler/ErrorResponse.java | 12 ++ .../core/monitoring/TraceIdFilter.java | 66 +++++++++++ .../core/monitoring/TraceIdFilterTest.java | 112 ++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 src/main/java/com/carecode/core/monitoring/TraceIdFilter.java create mode 100644 src/test/java/com/carecode/core/monitoring/TraceIdFilterTest.java diff --git a/src/main/java/com/carecode/core/handler/ErrorResponse.java b/src/main/java/com/carecode/core/handler/ErrorResponse.java index 7cc1b6fe..cc927c70 100644 --- a/src/main/java/com/carecode/core/handler/ErrorResponse.java +++ b/src/main/java/com/carecode/core/handler/ErrorResponse.java @@ -1,6 +1,7 @@ package com.carecode.core.handler; import com.carecode.core.exception.ErrorCode; +import com.carecode.core.util.LoggingUtil; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -19,6 +20,14 @@ public class ErrorResponse { private String message; private String details; private LocalDateTime timestamp; + + /** + * 이 요청의 추적 ID. 응답 헤더 {@code X-Request-Id} 와 같은 값이다. + * + *

본문에도 담는 이유는 사용자가 오류 화면을 캡처해 보내는 경우가 대부분이기 때문이다. + * 헤더는 캡처에 안 나온다. + */ + private String traceId; public static ErrorResponse of(ErrorCode errorCode, String details) { return ErrorResponse.builder() @@ -26,6 +35,7 @@ public static ErrorResponse of(ErrorCode errorCode, String details) { .message(errorCode.getMessage()) .details(details) .timestamp(LocalDateTime.now()) + .traceId(LoggingUtil.getTraceId()) .build(); } @@ -35,6 +45,7 @@ public static ErrorResponse of(ErrorCode errorCode, String customMessage, String .message(customMessage) .details(details) .timestamp(LocalDateTime.now()) + .traceId(LoggingUtil.getTraceId()) .build(); } @@ -43,6 +54,7 @@ public static ErrorResponse of(ErrorCode errorCode) { .code(errorCode.getCode()) .message(errorCode.getMessage()) .timestamp(LocalDateTime.now()) + .traceId(LoggingUtil.getTraceId()) .build(); } } diff --git a/src/main/java/com/carecode/core/monitoring/TraceIdFilter.java b/src/main/java/com/carecode/core/monitoring/TraceIdFilter.java new file mode 100644 index 00000000..bf94576a --- /dev/null +++ b/src/main/java/com/carecode/core/monitoring/TraceIdFilter.java @@ -0,0 +1,66 @@ +package com.carecode.core.monitoring; + +import com.carecode.core.util.LoggingUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * 요청 하나에 추적 ID 하나를 붙인다. + * + *

MDC 는 예전부터 로그로 나가고 있었지만 traceId 를 넣는 곳이 {@code @LogExecutionTime} 안뿐이라, + * 인증 실패나 없는 경로처럼 컨트롤러에 닿기 전에 끝나는 요청에는 아무 값도 없었다. 실제로 500 원인을 + * 찾을 때 타임스탬프로 로그를 뒤져야 했다. + * + *

보안 필터보다 먼저 돌려 401 로그에도 ID 가 남게 하고, 응답 헤더로 돌려줘서 사용자가 화면에 + * 뜬 값을 그대로 알려주면 바로 찾을 수 있게 한다. + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class TraceIdFilter extends OncePerRequestFilter { + + public static final String TRACE_ID_HEADER = "X-Request-Id"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + // 로드밸런서나 게이트웨이가 이미 붙였다면 그대로 이어받아야 같은 요청으로 묶인다. + String inbound = request.getHeader(TRACE_ID_HEADER); + String traceId = StringUtils.hasText(inbound) + ? sanitize(inbound) + : LoggingUtil.generateTraceId(); + + LoggingUtil.setTraceId(traceId); + // 오류 화면에 띄운 값을 사용자가 그대로 불러 주면 로그에서 바로 찾을 수 있다. + response.setHeader(TRACE_ID_HEADER, traceId); + + try { + filterChain.doFilter(request, response); + } finally { + // 톰캣은 스레드를 재사용한다. 비우지 않으면 다음 요청 로그에 남의 추적 ID 가 붙는다. + LoggingUtil.clear(); + } + } + + /** + * 외부에서 온 값은 그대로 믿지 않는다. + * + *

로그에 그대로 들어가므로 개행이 섞이면 한 줄을 위조해 다른 요청인 것처럼 꾸밀 수 있다. + * 길이도 제한해 로그가 헤더로 부풀지 않게 한다. + */ + private String sanitize(String value) { + String cleaned = value.replaceAll("[^A-Za-z0-9._-]", ""); + if (cleaned.isEmpty()) { + return LoggingUtil.generateTraceId(); + } + return cleaned.length() > 64 ? cleaned.substring(0, 64) : cleaned; + } +} diff --git a/src/test/java/com/carecode/core/monitoring/TraceIdFilterTest.java b/src/test/java/com/carecode/core/monitoring/TraceIdFilterTest.java new file mode 100644 index 00000000..8a630660 --- /dev/null +++ b/src/test/java/com/carecode/core/monitoring/TraceIdFilterTest.java @@ -0,0 +1,112 @@ +package com.carecode.core.monitoring; + +import com.carecode.core.util.LoggingUtil; +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +@DisplayName("요청 추적 ID") +class TraceIdFilterTest { + + private final TraceIdFilter filter = new TraceIdFilter(); + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("헤더가 없으면 새로 만들어 응답으로 돌려준다") + void generatesWhenAbsent() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(new MockHttpServletRequest(), response, mock(FilterChain.class)); + + assertThat(response.getHeader(TraceIdFilter.TRACE_ID_HEADER)).isNotBlank(); + } + + @Test + @DisplayName("들어온 추적 ID 를 이어받는다") + void reusesInboundId() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(TraceIdFilter.TRACE_ID_HEADER, "gateway-abc123"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, mock(FilterChain.class)); + + // 게이트웨이가 붙인 값을 그대로 써야 같은 요청으로 묶인다. + assertThat(response.getHeader(TraceIdFilter.TRACE_ID_HEADER)).isEqualTo("gateway-abc123"); + } + + @Test + @DisplayName("체인이 도는 동안 MDC 에서 추적 ID 를 읽을 수 있다") + void exposesIdDuringChain() throws Exception { + AtomicReference seen = new AtomicReference<>(); + FilterChain chain = (req, res) -> seen.set(LoggingUtil.getTraceId()); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(new MockHttpServletRequest(), response, chain); + + assertThat(seen.get()).isEqualTo(response.getHeader(TraceIdFilter.TRACE_ID_HEADER)); + } + + @Test + @DisplayName("요청이 끝나면 MDC 를 비운다") + void clearsAfterRequest() throws Exception { + filter.doFilter(new MockHttpServletRequest(), new MockHttpServletResponse(), mock(FilterChain.class)); + + // 톰캣은 스레드를 재사용한다. 남겨두면 다음 요청 로그에 남의 추적 ID 가 붙는다. + assertThat(LoggingUtil.getTraceId()).isNull(); + } + + @Test + @DisplayName("체인에서 예외가 나도 MDC 를 비운다") + void clearsOnException() { + FilterChain exploding = (req, res) -> { + throw new IllegalStateException("boom"); + }; + + try { + filter.doFilter(new MockHttpServletRequest(), new MockHttpServletResponse(), exploding); + } catch (Exception ignored) { + // 예외 자체는 이 테스트의 관심사가 아니다. + } + + assertThat(LoggingUtil.getTraceId()).isNull(); + } + + @Test + @DisplayName("로그를 위조할 수 있는 문자는 걸러낸다") + void sanitizesInboundId() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + // 개행이 그대로 들어가면 로그 한 줄을 통째로 지어낼 수 있다. + request.addHeader(TraceIdFilter.TRACE_ID_HEADER, "abc\n{\"level\":\"ERROR\"}"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, mock(FilterChain.class)); + + String traceId = response.getHeader(TraceIdFilter.TRACE_ID_HEADER); + assertThat(traceId).doesNotContain("\n").doesNotContain("{").doesNotContain("\""); + } + + @Test + @DisplayName("지나치게 긴 값은 잘라낸다") + void truncatesLongId() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(TraceIdFilter.TRACE_ID_HEADER, "x".repeat(500)); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, mock(FilterChain.class)); + + assertThat(response.getHeader(TraceIdFilter.TRACE_ID_HEADER)).hasSize(64); + } +} From 3c03e6e8ad7d8fd1577643c0b6fdfd99321d80b7 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Mon, 10 Aug 2026 10:26:50 +0900 Subject: [PATCH 68/68] =?UTF-8?q?DOCS=20:=20=EC=9A=94=EC=B2=AD=20=EC=B6=94?= =?UTF-8?q?=EC=A0=81=20=EB=B0=A9=EC=8B=9D=20=EA=B8=B0=EB=A1=9D=20(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/features/operations.md | 35 ++++++++++++++++++++++++- docs/reference/access-control-matrix.md | 3 +++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/docs/features/operations.md b/docs/features/operations.md index 98710bb9..fa92d37d 100644 --- a/docs/features/operations.md +++ b/docs/features/operations.md @@ -143,6 +143,40 @@ management: `logback-spring.xml` 에서 JSON 으로 남깁니다. +### 요청 추적 + +`TraceIdFilter` 가 모든 요청에 ID 하나를 붙입니다. + +```mermaid +flowchart LR + REQ[요청] --> F{X-Request-Id
헤더가 있는가} + F -->|있음| S[정제 후 이어받기] + F -->|없음| G[새로 생성] + S & G --> M[MDC 에 저장] + M --> H[응답 헤더에 반환] + H --> B[오류 응답 본문에도 포함] + B --> C[요청 종료 시 MDC 비움] + + style C fill:#fff3cd,stroke:#ffc107 +``` + +| 판단 | 이유 | +|------|------| +| 보안 필터보다 **먼저** 실행 | 401·404 처럼 컨트롤러에 닿기 전에 끝나는 요청도 추적해야 함 | +| 들어온 헤더를 **이어받음** | 로드밸런서·게이트웨이가 붙인 ID 와 같은 요청으로 묶임 | +| 응답 **헤더 + 오류 본문** 양쪽 | 사용자는 오류 화면을 캡처해 보내는데 헤더는 캡처에 안 나옴 | +| 외부 값 **정제** | 개행이 섞이면 로그 한 줄을 위조해 다른 요청인 것처럼 꾸밀 수 있음 | +| 종료 시 **MDC 비움** | 톰캣은 스레드를 재사용해서, 안 비우면 다음 요청 로그에 남의 ID 가 붙음 | + +장애 조사는 사용자가 알려준 ID 하나로 시작합니다. + +```bash +grep '"traceId":"notfound-77"' application.log +``` + +> 이전에는 `@LogExecutionTime` 안에서만 traceId 를 넣어서, 컨트롤러에 닿기 전에 끝난 요청은 +> 아무 값도 없었습니다. 실제로 500 원인을 찾을 때 타임스탬프로 로그를 뒤져야 했습니다. + > Logback 의 기본값 문법은 `${VAR:-기본값}` 입니다. > Spring 문법인 `${VAR:기본값}` 을 쓰면 변수가 없을 때 `..._IS_UNDEFINED` 경로가 되어 > **기동 자체가 실패합니다.** 자세한 내용은 [기동 안정화](../quality/runtime-hardening.md)에 있습니다. @@ -174,6 +208,5 @@ Blue/Green 이라는 사실이 알림 설계에 직접 영향을 줍니다. | 항목 | 내용 | 이슈 | |------|------|------| -| traceId 전파 | 요청 추적이 안 됩니다. 500 원인 찾을 때 로그를 손으로 파싱해야 합니다 | #50 | | 배포 후 스모크 테스트 | 배포가 성공해도 실제로 도는지 확인하지 않습니다 | #51 | | 스케줄러 단일 실행 보장 | 인스턴스별 중복 실행을 알림 쪽에서만 막고 있습니다. 분산 락이 근본 해결입니다 | — | diff --git a/docs/reference/access-control-matrix.md b/docs/reference/access-control-matrix.md index 93044c5a..74816ffe 100644 --- a/docs/reference/access-control-matrix.md +++ b/docs/reference/access-control-matrix.md @@ -185,6 +185,9 @@ flowchart TD | 없는 경로 | 404 | `{"code":"C004","message":"요청하신 경로를 찾을 수 없습니다"}` | | 서버 오류 | 500 | `{"code":"C000","message":"서버 내부 오류가 발생했습니다"}` + 운영 알림 | +모든 오류 응답에는 `traceId` 가 함께 담기고, 응답 헤더 `X-Request-Id` 로도 나갑니다. +사용자가 알려준 ID 하나로 로그를 바로 찾을 수 있습니다. + **404·403 은 운영 알림을 보내지 않습니다.** 장애가 아니기 때문입니다. 초기에는 봇이 없는 URL 을 긁을 때마다 알림이 울렸고, 그러면 진짜 장애가 소음에 묻힙니다.