diff --git a/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 b/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 index 36cb3170..d035189b 100644 --- a/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 +++ b/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 @@ -141,46 +141,46 @@ facility → user 리포지토리: Method calls method in (CareFacilityService.java:355) facility → user 리포지토리: Method calls method in (CareFacilityService.java:390) facility → user 리포지토리: Method calls method in (CareFacilityService.java:380) -health → facility 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.health.mapper.HealthRecordMapper)> has parameter of type in (HealthService.java:0) +health → facility 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.service.HealthActorResolver, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository)> has parameter of type in (HealthInsightService.java:0) health → facility 리포지토리: Constructor (com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.facility.repository.FacilityWaitlistRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (SiblingOverviewService.java:0) -health → facility 리포지토리: Field has type in (HealthService.java:0) +health → facility 리포지토리: Field has type in (HealthInsightService.java:0) health → facility 리포지토리: Field has type in (SiblingOverviewService.java:0) -health → facility 리포지토리: Method calls method in (HealthService.java:446) +health → facility 리포지토리: Method calls method in (HealthInsightService.java:113) health → facility 리포지토리: Method calls method in (SiblingOverviewService.java:91) -health → policy 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.health.mapper.HealthRecordMapper)> has parameter of type in (HealthService.java:0) +health → policy 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.service.HealthActorResolver, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository)> has parameter of type in (HealthInsightService.java:0) health → policy 리포지토리: Constructor (com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.facility.repository.FacilityWaitlistRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (SiblingOverviewService.java:0) health → policy 리포지토리: Constructor (com.carecode.domain.health.service.ChildService, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.policy.repository.PolicyRepository)> has parameter of type in (ChildTimelineService.java:0) -health → policy 리포지토리: Field has type in (HealthService.java:0) +health → policy 리포지토리: Field has type in (HealthInsightService.java:0) health → policy 리포지토리: Field has type in (SiblingOverviewService.java:0) health → policy 리포지토리: Field has type in (ChildTimelineService.java:0) -health → policy 리포지토리: Method calls method in (HealthService.java:442) +health → policy 리포지토리: Method calls method in (HealthInsightService.java:109) health → policy 리포지토리: Method calls method in (SiblingOverviewService.java:101) health → policy 리포지토리: Method calls method in (ChildTimelineService.java:180) -health → user 리포지토리: Constructor (com.carecode.domain.health.service.HealthService, com.carecode.domain.health.repository.HospitalRepository, com.carecode.core.ops.sync.SyncFreshnessService, com.carecode.domain.health.repository.HospitalLikeRepository, com.carecode.domain.health.repository.HospitalReviewRepository, com.carecode.domain.health.mapper.HospitalMapper, com.carecode.domain.health.mapper.HospitalReviewMapper, com.carecode.domain.user.repository.UserRepository)> has parameter of type in (HealthFacade.java:0) +health → user 리포지토리: Constructor (com.carecode.domain.health.service.HealthService, com.carecode.domain.health.service.HealthInsightService, com.carecode.domain.health.repository.HospitalRepository, com.carecode.core.ops.sync.SyncFreshnessService, com.carecode.domain.health.repository.HospitalLikeRepository, com.carecode.domain.health.repository.HospitalReviewRepository, com.carecode.domain.health.mapper.HospitalMapper, com.carecode.domain.health.mapper.HospitalReviewMapper, com.carecode.domain.user.repository.UserRepository)> has parameter of type in (HealthFacade.java:0) health → user 리포지토리: Constructor (com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.health.mapper.ChildMapper, com.carecode.core.security.CurrentUserFacade, com.carecode.domain.health.service.VaccinationScheduleService, com.carecode.core.analytics.EventLogger)> has parameter of type in (ChildService.java:0) health → user 리포지토리: Constructor (com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (GrowthChartService.java:0) -health → user 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.health.mapper.HealthRecordMapper)> has parameter of type in (HealthService.java:0) -health → user 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.health.mapper.HealthRecordMapper)> has parameter of type in (HealthService.java:0) +health → user 리포지토리: Constructor (com.carecode.domain.user.repository.UserRepository)> has parameter of type in (HealthActorResolver.java:0) +health → user 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.health.service.HealthActorResolver, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.health.mapper.HealthRecordMapper)> has parameter of type in (HealthService.java:0) health → user 리포지토리: Constructor (com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.facility.repository.FacilityWaitlistRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (SiblingOverviewService.java:0) health → user 리포지토리: Constructor (com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.repository.ChildRepository)> has parameter of type in (VaccinationScheduleService.java:0) health → user 리포지토리: Field has type in (HealthFacade.java:0) health → user 리포지토리: Field has type in (ChildService.java:0) health → user 리포지토리: Field has type in (GrowthChartService.java:0) +health → user 리포지토리: Field has type in (HealthActorResolver.java:0) health → user 리포지토리: Field has type in (HealthService.java:0) -health → user 리포지토리: Field has type in (HealthService.java:0) health → user 리포지토리: Field has type in (SiblingOverviewService.java:0) health → user 리포지토리: Field has type in (VaccinationScheduleService.java:0) -health → user 리포지토리: Method calls method in (HealthFacade.java:247) -health → user 리포지토리: Method calls method in (HealthFacade.java:144) +health → user 리포지토리: Method calls method in (HealthFacade.java:239) +health → user 리포지토리: Method calls method in (HealthFacade.java:136) health → user 리포지토리: Method calls method in (ChildService.java:50) health → user 리포지토리: Method calls method in (ChildService.java:86) health → user 리포지토리: Method calls method in (ChildService.java:62) health → user 리포지토리: Method calls method in (ChildService.java:106) health → user 리포지토리: Method calls method in (ChildService.java:81) health → user 리포지토리: Method calls method in (GrowthChartService.java:85) -health → user 리포지토리: Method calls method in (HealthService.java:97) -health → user 리포지토리: Method calls method in (HealthService.java:605) -health → user 리포지토리: Method calls method in (HealthService.java:597) +health → user 리포지토리: Method calls method in (HealthActorResolver.java:70) +health → user 리포지토리: Method calls method in (HealthActorResolver.java:64) +health → user 리포지토리: Method calls method in (HealthService.java:72) health → user 리포지토리: Method calls method in (SiblingOverviewService.java:44) health → user 리포지토리: Method calls method in (VaccinationScheduleService.java:32) notification → user 리포지토리: Constructor (com.carecode.domain.notification.service.NotificationService, com.carecode.domain.notification.service.NotificationPreferenceService, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.notification.sender.NotificationDispatcher, com.carecode.domain.notification.repository.NotificationPreferenceRepository)> has parameter of type in (NotificationFacade.java:0) diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 95952f21..c3967116 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -1288,33 +1288,6 @@ }, "type" : "object" }, - "CheckupScheduleResponse" : { - "properties" : { - "checkupName" : { - "type" : "string" - }, - "completedDate" : { - "type" : "string" - }, - "description" : { - "type" : "string" - }, - "notes" : { - "type" : "string" - }, - "recommendedAge" : { - "format" : "int32", - "type" : "integer" - }, - "scheduledDate" : { - "type" : "string" - }, - "status" : { - "type" : "string" - } - }, - "type" : "object" - }, "ChildCreateRequest" : { "properties" : { "birthDate" : { @@ -4829,33 +4802,6 @@ }, "type" : "object" }, - "VaccineScheduleResponse" : { - "properties" : { - "completedDate" : { - "type" : "string" - }, - "description" : { - "type" : "string" - }, - "notes" : { - "type" : "string" - }, - "recommendedAge" : { - "format" : "int32", - "type" : "integer" - }, - "scheduledDate" : { - "type" : "string" - }, - "status" : { - "type" : "string" - }, - "vaccineName" : { - "type" : "string" - } - }, - "type" : "object" - }, "WaitlistEntryResponse" : { "properties" : { "appliedAt" : { @@ -11887,46 +11833,6 @@ "tags" : [ "건강 관리" ] } }, - "/health/checkups/schedule" : { - "get" : { - "operationId" : "getCheckupSchedule", - "parameters" : [ { - "description" : "아동 ID", - "in" : "query", - "name" : "childId", - "required" : true, - "schema" : { - "type" : "string" - } - }, { - "description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400", - "in" : "header", - "name" : "X-API-Version", - "required" : false, - "schema" : { - "default" : "1", - "type" : "string" - } - } ], - "responses" : { - "200" : { - "content" : { - "application/json" : { - "schema" : { - "items" : { - "$ref" : "#/components/schemas/CheckupScheduleResponse" - }, - "type" : "array" - } - } - }, - "description" : "OK" - } - }, - "summary" : "건강 검진 스케줄 조회", - "tags" : [ "건강 관리" ] - } - }, "/health/hospitals" : { "get" : { "description" : "등록된 모든 병원 정보 조회", @@ -13193,46 +13099,6 @@ "tags" : [ "건강 관리" ] } }, - "/health/vaccines/schedule" : { - "get" : { - "operationId" : "getVaccineSchedule", - "parameters" : [ { - "description" : "아동 ID", - "in" : "query", - "name" : "childId", - "required" : true, - "schema" : { - "type" : "string" - } - }, { - "description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400", - "in" : "header", - "name" : "X-API-Version", - "required" : false, - "schema" : { - "default" : "1", - "type" : "string" - } - } ], - "responses" : { - "200" : { - "content" : { - "application/json" : { - "schema" : { - "items" : { - "$ref" : "#/components/schemas/VaccineScheduleResponse" - }, - "type" : "array" - } - } - }, - "description" : "OK" - } - }, - "summary" : "예방접종 스케줄 조회", - "tags" : [ "건강 관리" ] - } - }, "/legal/privacy-policy" : { "get" : { "description" : "동의 화면에 표시할 원문", diff --git a/docs/quality/architecture-rules.md b/docs/quality/architecture-rules.md index 0a461376..b31635d4 100644 --- a/docs/quality/architecture-rules.md +++ b/docs/quality/architecture-rules.md @@ -328,6 +328,64 @@ expected: 36 but was: 3 - ArchUnit baseline 362 → **357** (도메인 경계 247 → 242) - 전체 테스트 **639개, 실패 0** +## HealthService 를 셋으로 나눴다 (5c 단계) + +688줄을 읽으며 책임을 세어 보니 네 가지였습니다 — 기록 CRUD·첨부, 통계·알림·추천, +"일정" 조회, 그리고 그 셋이 공유하는 신원 확인. 그중 **"일정" 은 일정이 아니었습니다.** + +### `/health/*/schedule` 두 개는 일정이 아니었다 + +```java +.recommendedAge(null) // 나이 정보는 별도 계산 필요 +.status(isCompleted ? "COMPLETED" : "UPCOMING") // OVERDUE 는 나올 수 없다 +.notes(record.getDescription()) // description 과 같은 값 +``` + +`HealthRecord` 중 타입이 VACCINATION/CHECKUP 인 것을 읽어 필드 이름만 바꿔 담았습니다. +권장 시기는 항상 `null`, 상태는 "기한 경과" 가 나올 수 없고, 메모는 설명의 복사본입니다. +**같은 데이터를 주는 `GET /health/records/type` 이 이미 있고 프런트는 그것을 씁니다.** + +진짜 일정은 다른 곳에 있습니다 — `VaccinationScheduleService`(생년월일 + 표준 접종 +일정에서 예정일을 계산, `/children/{id}/vaccinations`)와 `ChildTimelineService` +(`CheckupStandard` 의 권장 시기 구간과 기록을 맞춰 봄, `/children/{id}/timeline`). +이름이 "schedule" 이라서 그쪽이 이미 있는지 확인하지 않고 쓰게 되는 종류의 중복입니다. + +함께 지운 것: `ConditionalResponse`(ETag/304 유틸). 유일한 사용처가 이 엔드포인트였고 +테스트도 문서도 없었습니다. 조건부 응답이 필요해지면 **측정한 자리에** 다시 넣는 것이 맞습니다. + +### 나눈 모양 + +| 클래스 | 줄 | 하는 일 | +|--------|-----|---------| +| `HealthService` | 367 | 기록 생성·조회·수정·삭제, 첨부 | +| `HealthInsightService` | 168 | 통계·알림·연계 추천 (**아무것도 쓰지 않는다** — 클래스 전체 `readOnly`) | +| `HealthActorResolver` | 76 | 받은 `userId` 가 요청자 본인인지 확인하고 사용자를 꺼낸다 | + +기록을 **쓰는** 일과 기록을 읽어 **해석하는** 일은 바뀌는 이유가 다릅니다. 앞쪽은 입력 검증과 +트랜잭션이, 뒤쪽은 "무엇을 보여줄지" 가 바뀝니다. 한 클래스에 있으면 어느 쪽을 고쳐도 +다른 쪽 테스트를 함께 봐야 합니다. + +`HealthActorResolver` 를 따로 둔 이유: 신원 확인이 검증·조회·본인 확인 세 조각으로 흩어져 +있었고, 둘로 나누면 양쪽이 그 세 조각을 각자 들고 갑니다. 소유권 검증을 복제하면 한쪽이 +조용히 뒤처진다는 것은 자녀 쪽에서 이미 겪었습니다(4a). 합치면서 **쿼리도 하나 줄었습니다** — +전에는 본인 확인에서 한 번, 사용자 조회에서 또 한 번 읽었습니다. + +### 위반은 줄지 않았다 (357 → 357) + +1a 와 같습니다. `HealthService` 가 들고 있던 policy·facility 리포지토리 참조가 +`HealthInsightService` 로 **옮겨 간 것**이고, 사라진 것은 아닙니다. 숫자가 줄려면 그 참조 +자체를 포트로 바꿔야 합니다(정책·시설 추천을 각 도메인에 묻는 식). + +이 단계의 값은 숫자가 아니라 **거짓 API 두 개가 사라진 것**과, 1000줄짜리 파일이 셋으로 +나뉘어 각자 한 가지만 하게 된 것입니다. + +### 숫자 + +- `HealthService` 688 → **367줄** (+ 168 + 76 로 분리) +- OpenAPI: 228 → **226** 경로, 160 → **158** 스키마 +- ArchUnit baseline **357 → 357** (위반이 새 클래스로 이동) +- 전체 테스트 **639개, 실패 0** + ## 다음 단계 | 단계 | 내용 | 효과 | @@ -341,4 +399,5 @@ expected: 36 but was: 3 | 4b. Child 를 제 자리로 | `ChildService` 가 `health` 에 있다. health 는 아직 엔티티가 필요해 `ChildRepository` 를 쓴다 | 결합 추가 해소 | | ~~5a. 도달 불가 코드 제거~~ | ~~HealthService 1011줄, CareFacilityService 580줄~~ | **완료** — 715줄·411줄 | | ~~5b. 아이 조회를 health 에서 뺀다~~ | ~~`/health/children/*` 4개가 `/children` 과 중복~~ | **완료** — 362 → 357, 단위 버그 발견 | -| 5c. 남은 책임 분리 | HealthService 688줄은 아직 기록·일정·알림·통계를 함께 들고 있다 | 읽기 쉬움 | +| ~~5c. 남은 책임 분리~~ | ~~HealthService 688줄~~ | **완료** — 367줄 + 168줄 + 76줄 | +| 5d. 식별 로직을 user 로 | `HealthActorResolver` 가 health 의 마지막 `UserRepository` 사용처다 | 결합 추가 해소 | diff --git a/src/main/java/com/carecode/core/web/ConditionalResponse.java b/src/main/java/com/carecode/core/web/ConditionalResponse.java deleted file mode 100644 index dba700ec..00000000 --- a/src/main/java/com/carecode/core/web/ConditionalResponse.java +++ /dev/null @@ -1,52 +0,0 @@ -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/health/app/HealthFacade.java b/src/main/java/com/carecode/domain/health/app/HealthFacade.java index 6439c1d8..d0cbe9d0 100644 --- a/src/main/java/com/carecode/domain/health/app/HealthFacade.java +++ b/src/main/java/com/carecode/domain/health/app/HealthFacade.java @@ -6,8 +6,6 @@ import com.carecode.domain.health.dto.response.HealthRecommendationResponse; import com.carecode.domain.health.dto.response.HealthRecordResponse; import com.carecode.domain.health.dto.response.HealthRecordAttachmentResponse; -import com.carecode.domain.health.dto.response.VaccineScheduleResponse; -import com.carecode.domain.health.dto.response.CheckupScheduleResponse; import com.carecode.domain.health.dto.response.HealthStatsResponse; import com.carecode.domain.health.dto.response.HealthAlertResponse; import com.carecode.domain.health.dto.response.HospitalInfoResponse; @@ -15,6 +13,7 @@ import com.carecode.core.exception.HospitalNotFoundException; import com.carecode.core.exception.HospitalReviewNotFoundException; import com.carecode.core.exception.HospitalReviewAccessDeniedException; +import com.carecode.domain.health.service.HealthInsightService; import com.carecode.domain.health.service.HealthService; import com.carecode.domain.health.entity.Hospital; import com.carecode.domain.health.entity.HospitalLike; @@ -40,6 +39,7 @@ public class HealthFacade { private final HealthService healthService; + private final HealthInsightService healthInsightService; private final HospitalRepository hospitalRepository; private final com.carecode.core.ops.sync.SyncFreshnessService syncFreshnessService; private final HospitalLikeRepository hospitalLikeRepository; @@ -71,19 +71,11 @@ public void deleteHealthRecord(Long recordId, Long actorUserId) { } public HealthStatsResponse getHealthStatistics(String userId, Long actorUserId) { - return healthService.getHealthStatistics(userId, actorUserId); - } - - public List getVaccineSchedule(String childId, Long actorUserId) { - return healthService.getVaccineSchedule(childId, actorUserId); - } - - public List getCheckupSchedule(String childId, Long actorUserId) { - return healthService.getCheckupSchedule(childId, actorUserId); + return healthInsightService.getHealthStatistics(userId, actorUserId); } public List getHealthAlerts(String userId, Long actorUserId) { - return healthService.getHealthAlerts(userId, actorUserId); + return healthInsightService.getHealthAlerts(userId, actorUserId); } public List getHealthRecordsByDateRangeAsc(Long childId, LocalDate startDate, LocalDate endDate, Long actorUserId) { @@ -107,7 +99,7 @@ public void deleteAttachment(Long attachmentId, Long actorUserId) { } public HealthRecommendationResponse getIntegratedRecommendations(String userId, Long actorUserId) { - return healthService.getIntegratedRecommendations(userId, actorUserId); + return healthInsightService.getIntegratedRecommendations(userId, actorUserId); } // ==================== 병원 관리 ==================== @@ -286,8 +278,4 @@ public void deleteHospitalReview(Long reviewId, Long userId) { // 매핑은 HospitalMapper/HospitalReviewMapper에 위임 - /** 조건부 응답용 지문. 일정이 수정되면 값이 바뀌어 캐시가 무효화된다. */ - public String getVaccineScheduleVersion(String childId) { - return healthService.getVaccineScheduleVersion(childId); - } } 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 ed217743..d7c80b26 100644 --- a/src/main/java/com/carecode/domain/health/controller/HealthController.java +++ b/src/main/java/com/carecode/domain/health/controller/HealthController.java @@ -2,7 +2,6 @@ import com.carecode.core.annotation.LogExecutionTime; import com.carecode.core.web.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; @@ -25,7 +24,6 @@ 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; @@ -138,36 +136,6 @@ public ResponseEntity getHealthStatistics(@Parameter(descri return ResponseEntity.ok(statistics); } - // 예방접종 스케줄 조회 - @GetMapping("/vaccines/schedule") - @LogExecutionTime - @Operation(summary = "예방접종 스케줄 조회") - public ResponseEntity> getVaccineSchedule( - @Parameter(description = "아동 ID", required = true) @RequestParam String childId, - WebRequest webRequest) { - - List schedule = healthFacade.getVaccineSchedule(childId, getAuthenticatedUserPk()); - - // 접종 기록은 병원에서 확인하는 경우가 많다. 바뀌지 않았으면 304 로 본문을 아낀다. - return ConditionalResponse.of(webRequest, schedule, fingerprint(childId, schedule.size())); - } - - /** 내용이 바뀌면 함께 바뀌는 값. 건수만으로는 수정 감지가 안 되므로 갱신 시각을 섞는다. */ - private String fingerprint(String key, int size) { - return key + ":" + size + ":" + healthFacade.getVaccineScheduleVersion(key); - } - - // 건강 검진 스케줄 조회 - @GetMapping("/checkups/schedule") - @LogExecutionTime - @Operation(summary = "건강 검진 스케줄 조회") - public ResponseEntity> getCheckupSchedule(@Parameter(description = "아동 ID", required = true) @RequestParam String childId) { - - List schedule = healthFacade.getCheckupSchedule(childId, getAuthenticatedUserPk()); - - return ResponseEntity.ok(schedule); - } - // 건강 알림 조회 @GetMapping("/alerts") @LogExecutionTime 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 deleted file mode 100644 index 8be08ef9..00000000 --- a/src/main/java/com/carecode/domain/health/dto/response/CheckupScheduleResponse.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.carecode.domain.health.dto.response; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -/** 건강 검진 스케줄 응답 */ -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class CheckupScheduleResponse { - private String checkupName; - private String description; - private Integer recommendedAge; - private String status; // COMPLETED, UPCOMING, OVERDUE - private String scheduledDate; - private String completedDate; - private String notes; -} - 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 deleted file mode 100644 index 55e4c757..00000000 --- a/src/main/java/com/carecode/domain/health/dto/response/VaccineScheduleResponse.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.carecode.domain.health.dto.response; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -/** 예방접종 스케줄 응답 */ -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class VaccineScheduleResponse { - private String vaccineName; - private String description; - private Integer recommendedAge; - private String status; // COMPLETED, UPCOMING, OVERDUE - private String scheduledDate; - private String completedDate; - private String notes; -} - diff --git a/src/main/java/com/carecode/domain/health/service/HealthActorResolver.java b/src/main/java/com/carecode/domain/health/service/HealthActorResolver.java new file mode 100644 index 00000000..22f0e72c --- /dev/null +++ b/src/main/java/com/carecode/domain/health/service/HealthActorResolver.java @@ -0,0 +1,76 @@ +package com.carecode.domain.health.service; + +import com.carecode.core.exception.BusinessException; +import com.carecode.core.exception.ErrorCode; +import com.carecode.core.exception.UserNotFoundException; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.Optional; + +/** + * health API 가 받은 {@code userId} 를 요청자 본인으로 확인하고 사용자를 꺼낸다. + * + *

health 쪽 조회 API 는 {@code userId} 를 문자열로 받는다. 그 값이 요청자 본인인지 확인하는 + * 코드가 {@code HealthService} 안에 세 조각(검증·조회·본인 확인)으로 흩어져 있었고, 통계·알림· + * 추천을 따로 떼면 양쪽이 그 세 조각을 각자 들고 가게 된다. 소유권 검증을 복제하면 한쪽이 + * 조용히 뒤처진다는 것은 자녀 쪽에서 이미 겪었다({@code ChildDirectory}). + * + *

합치면서 쿼리도 하나 줄었다. 전에는 본인 확인에서 한 번, 사용자 조회에서 또 한 번 + * 읽었다. + * + *

다음 자리: 이것은 사실 {@code user} 도메인의 일이다. health 가 아직 + * {@code UserRepository} 를 직접 쓰는 이유가 여기뿐이므로, {@code ChildDirectory} 처럼 + * 사용자 쪽 입구로 옮기면 그 의존이 사라진다. + */ +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class HealthActorResolver { + + private final UserRepository userRepository; + + /** + * {@code userId} 가 요청자 본인인지 확인하고 사용자를 돌려준다. + * + * @throws BusinessException 요청자를 알 수 없거나({@code 401}) 다른 사용자일 때({@code 403}) + */ + public User requireSelf(String userId, Long actorUserId) { + if (actorUserId == null) { + throw new BusinessException(ErrorCode.UNAUTHORIZED, "인증이 필요합니다."); + } + User resolved = resolve(userId); + if (!resolved.getId().equals(actorUserId)) { + throw new BusinessException(ErrorCode.FORBIDDEN, "해당 사용자 정보에 접근할 권한이 없습니다."); + } + return resolved; + } + + /** + * {@code userId} 로 사용자를 찾는다. + * + *

이 값은 가입 시 발급한 문자열 {@code userId} 일 수도, PK 를 문자열로 적은 것일 수도 있다. + * 두 가지가 섞여 들어오는 것이 애초에 혼란이지만, 기존 호출자가 양쪽을 보내고 있어 둘 다 받는다. + */ + public User resolve(String userId) { + if (!StringUtils.hasText(userId)) { + throw new BusinessException(ErrorCode.INVALID_INPUT, "사용자 ID가 필요합니다."); + } + + Optional byUserId = userRepository.findByUserId(userId); + if (byUserId.isPresent()) { + return byUserId.get(); + } + + try { + return userRepository.findById(Long.parseLong(userId)) + .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다: " + userId)); + } catch (NumberFormatException e) { + throw new UserNotFoundException("사용자를 찾을 수 없습니다: " + userId); + } + } +} diff --git a/src/main/java/com/carecode/domain/health/service/HealthInsightService.java b/src/main/java/com/carecode/domain/health/service/HealthInsightService.java new file mode 100644 index 00000000..567b7502 --- /dev/null +++ b/src/main/java/com/carecode/domain/health/service/HealthInsightService.java @@ -0,0 +1,168 @@ +package com.carecode.domain.health.service; + +import com.carecode.core.annotation.LogExecutionTime; +import com.carecode.domain.facility.entity.CareFacility; +import com.carecode.domain.facility.repository.CareFacilityRepository; +import com.carecode.domain.health.dto.response.HealthAlertResponse; +import com.carecode.domain.health.dto.response.HealthRecommendationResponse; +import com.carecode.domain.health.dto.response.HealthStatsResponse; +import com.carecode.domain.health.entity.HealthRecord; +import com.carecode.domain.health.repository.HealthRecordRepository; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.repository.PolicyRepository; +import com.carecode.domain.user.app.ChildDirectory; +import com.carecode.domain.user.entity.User; +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.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * 건강 기록을 읽어 요약·알림·추천을 만든다. + * + *

{@link HealthService} 에서 떼어낸 조각이다. 기록을 쓰는 일(생성·수정·삭제·첨부)과 기록을 + * 읽어 해석하는 일은 바뀌는 이유가 다르다 — 앞쪽은 입력 검증과 트랜잭션이, 뒤쪽은 "무엇을 + * 보여줄지" 가 바뀐다. 한 클래스에 있으면 어느 쪽을 고쳐도 다른 쪽 테스트를 함께 본다. + * + *

여기서는 아무것도 쓰지 않는다. 클래스 전체가 {@code readOnly} 다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class HealthInsightService { + + /** 다가오는 일정을 몇 개까지 보여줄지. 목록이 길면 요약이 아니게 된다. */ + private static final int MAX_UPCOMING_EVENTS = 5; + + /** + * 알림 우선순위. 지금은 기록만으로 급한지를 판단할 근거가 없어 전부 같은 값이다. + * 근거(기한 임박, 미접종 차수 등)가 생기면 여기서 나뉜다. + */ + private static final String DEFAULT_ALERT_PRIORITY = "MEDIUM"; + + private final HealthRecordRepository healthRecordRepository; + private final HealthActorResolver actorResolver; + private final ChildDirectory childDirectory; + private final PolicyRepository policyRepository; + private final CareFacilityRepository careFacilityRepository; + + @LogExecutionTime + public HealthStatsResponse getHealthStatistics(String userId, Long actorUserId) { + List records = recordsOf(userId, actorUserId); + + return HealthStatsResponse.builder() + .totalRecords(records.size()) + .completedVaccines(count(records, HealthRecord.RecordType.VACCINATION, true)) + .pendingVaccines(count(records, HealthRecord.RecordType.VACCINATION, false)) + .completedCheckups(count(records, HealthRecord.RecordType.CHECKUP, true)) + .pendingCheckups(count(records, HealthRecord.RecordType.CHECKUP, false)) + .recordTypeDistribution(recordTypeDistribution(records)) + .upcomingEvents(upcomingEvents(records)) + .build(); + } + + /** 다음 방문일이 적혀 있고 아직 오지 않은 기록만 알림으로 본다. */ + @LogExecutionTime + public List getHealthAlerts(String userId, Long actorUserId) { + LocalDate today = LocalDate.now(); + + return recordsOf(userId, actorUserId).stream() + .filter(record -> record.getNextDate() != null && record.getNextDate().isAfter(today)) + .map(this::toAlert) + .collect(Collectors.toList()); + } + + /** + * 아이 연령 기준 연계 추천. + * + *

연령 단위는 개월이다. 정책의 {@code targetAgeMin/Max} 가 개월이기 때문이다 + * (시드 데이터: "부모급여(0세)" 0~11, "아동수당" 0~95). 한때 여기서만 + * {@code Period.between(...).getYears()} 로 연 나이를 계산해 그 쿼리에 넘겼고, + * 세 살 아이가 "3" 으로 들어가 0~11개월 대상 정책이 추천됐다. + * + *

월령을 모르면({@code null}) 연령 기반 조회를 하지 않는다. 전에는 {@code .orElse(0)} 이 + * 0개월로 바꿔, 자녀를 등록하지 않은 사용자에게 신생아 정책을 추천했다. + */ + @LogExecutionTime + public HealthRecommendationResponse getIntegratedRecommendations(String userId, Long actorUserId) { + User user = actorResolver.requireSelf(userId, actorUserId); + + Integer childAgeMonths = childDirectory.childrenOf(user.getId()).stream() + .map(child -> child.ageMonths(LocalDate.now())) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + + if (childAgeMonths == null) { + return new HealthRecommendationResponse( + user.getUserId(), null, List.of(), List.of(), + "아이를 등록하면 연령에 맞는 정책과 시설을 알려드립니다."); + } + + List recommendedPolicies = policyRepository.findByChildAge(childAgeMonths).stream() + .limit(3) + .map(Policy::getTitle) + .collect(Collectors.toList()); + List recommendedFacilities = careFacilityRepository.findByChildAge(childAgeMonths).stream() + .limit(3) + .map(CareFacility::getName) + .collect(Collectors.toList()); + + return new HealthRecommendationResponse( + user.getUserId(), + childAgeMonths, + recommendedPolicies, + recommendedFacilities, + "아이 연령에 맞는 정책/시설을 확인해보세요."); + } + + /** 본인 확인과 조회를 한 번에. JOIN FETCH 로 Child·User 를 함께 읽어 N+1 을 피한다. */ + private List recordsOf(String userId, Long actorUserId) { + User user = actorResolver.requireSelf(userId, actorUserId); + return healthRecordRepository.findByUserIdWithChildAndUser(user.getId()); + } + + private int count(List records, HealthRecord.RecordType type, boolean completed) { + return (int) records.stream() + .filter(record -> record.getRecordType() == type) + .filter(record -> Boolean.TRUE.equals(record.getIsCompleted()) == completed) + .count(); + } + + private Map recordTypeDistribution(List records) { + return records.stream() + .filter(record -> record.getRecordType() != null) + .collect(Collectors.groupingBy( + record -> record.getRecordType().name(), + Collectors.collectingAndThen(Collectors.counting(), Long::intValue))); + } + + private List upcomingEvents(List records) { + LocalDate today = LocalDate.now(); + + return records.stream() + .filter(record -> record.getNextDate() != null && record.getNextDate().isAfter(today)) + .map(record -> String.format("%s: %s", record.getTitle(), record.getNextDate())) + .limit(MAX_UPCOMING_EVENTS) + .collect(Collectors.toList()); + } + + private HealthAlertResponse toAlert(HealthRecord record) { + return HealthAlertResponse.builder() + .alertId(record.getId().toString()) + .alertType(record.getRecordType() != null ? record.getRecordType().name() : null) + .title(record.getTitle()) + .message(record.getDescription()) + .priority(DEFAULT_ALERT_PRIORITY) + .dueDate(record.getNextDate() != null ? record.getNextDate().toString() : null) + .isRead(false) + .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 bd8621db..4850583e 100644 --- a/src/main/java/com/carecode/domain/health/service/HealthService.java +++ b/src/main/java/com/carecode/domain/health/service/HealthService.java @@ -6,7 +6,6 @@ import com.carecode.core.exception.CareCodeException; import com.carecode.core.exception.CareServiceException; import com.carecode.core.exception.ResourceNotFoundException; -import com.carecode.core.exception.UserNotFoundException; import com.carecode.core.exception.HealthRecordNotFoundException; import com.carecode.core.exception.ChildNotFoundException; import com.carecode.core.exception.BusinessException; @@ -14,44 +13,25 @@ import com.carecode.domain.health.dto.request.HealthCreateHealthRecordRequest; import com.carecode.domain.health.dto.request.HealthRecordAttachmentRequest; import com.carecode.domain.health.dto.request.HealthUpdateHealthRecordRequest; -import com.carecode.domain.health.dto.response.HealthRecommendationResponse; import com.carecode.domain.health.dto.response.HealthRecordAttachmentResponse; import com.carecode.domain.health.dto.response.HealthRecordResponse; -import com.carecode.domain.health.dto.response.VaccineScheduleResponse; -import com.carecode.domain.health.dto.response.CheckupScheduleResponse; -import com.carecode.domain.health.dto.response.HealthStatsResponse; -import com.carecode.domain.health.dto.response.HealthAlertResponse; import com.carecode.domain.health.entity.HealthRecord; import com.carecode.domain.health.entity.HealthRecordAttachment; -import com.carecode.domain.policy.entity.Policy; -import com.carecode.domain.facility.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.facility.repository.CareFacilityRepository; import com.carecode.domain.user.app.ChildDirectory; 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 com.carecode.domain.health.mapper.HealthRecordMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import java.util.Optional; import java.util.stream.Collectors; /** 통합 건강 관리 서비스 */ @@ -62,22 +42,17 @@ public class HealthService { // 상수 정의 - private static final String DEFAULT_ALERT_PRIORITY = "MEDIUM"; - private static final int MAX_UPCOMING_EVENTS = 5; private final HealthRecordRepository healthRecordRepository; - private final VaccinationScheduleRepository vaccinationScheduleRepository; private final ConsentGuard consentGuard; private final HealthRecordAttachmentRepository healthRecordAttachmentRepository; /** * 건강 기록을 자녀에 붙이려면 엔티티가 필요해 남겨 둔다. 조회·소유권 판단은 * {@link ChildDirectory} 가 하고, 여기서는 통과한 뒤 연관을 걸 때만 쓴다. */ + private final HealthActorResolver actorResolver; private final ChildRepository childRepository; private final ChildDirectory childDirectory; - private final UserRepository userRepository; - private final PolicyRepository policyRepository; - private final CareFacilityRepository careFacilityRepository; private final HealthRecordMapper healthRecordMapper; // ===== 건강 기록 관리 ===== @@ -140,35 +115,14 @@ public HealthRecordResponse getHealthRecordById(Long recordId, Long actorUserId) } } - // 사용자별 건강 기록 조회 (DTO 반환) + /** 사용자별 건강 기록. JOIN FETCH 로 Child·User 를 함께 읽어 N+1 을 피한다. */ @LogExecutionTime public List getHealthRecordsByUserId(String userId, Long actorUserId) { - log.info("사용자별 건강 기록 조회: 사용자ID={}", userId); - try { - assertUserIdBelongsToActor(userId, actorUserId); - List records = getHealthRecordsByUserIdAsEntity(userId); - return records.stream() - .map(healthRecordMapper::toResponse) - .collect(Collectors.toList()); - } catch (CareCodeException e) { - // 우리가 던진 예외는 자기 상태 코드를 들고 있다. 아래 catch 에 걸리면 404·403 이 - // 전부 500 으로 덮이고, 남의 자녀 접근 거부가 서버 오류로 보고된다. - throw e; - } catch (Exception e) { - log.error("사용자별 건강 기록 조회 실패: {}", e.getMessage()); - throw new CareServiceException("사용자별 건강 기록 조회 중 오류가 발생했습니다.", e); - } - } - - // HealthRecord -> DTO 변환은 healthRecordMapper 사용 + User user = actorResolver.requireSelf(userId, actorUserId); - // 사용자별 건강 기록 조회 (Entity 반환) JOIN FETCH를 사용하여 N+1 쿼리 문제 해결 - @LogExecutionTime - public List getHealthRecordsByUserIdAsEntity(String userId) { - validateUserId(userId); - User user = findUserByIdOrUserId(userId); - // JOIN FETCH를 사용하여 Child와 User를 한 번에 조회 - return healthRecordRepository.findByUserIdWithChildAndUser(user.getId()); + return healthRecordRepository.findByUserIdWithChildAndUser(user.getId()).stream() + .map(healthRecordMapper::toResponse) + .collect(Collectors.toList()); } // 건강 기록 수정 @@ -221,84 +175,8 @@ public void deleteHealthRecord(Long recordId, Long actorUserId) { // ===== 아동 정보 관리 ===== - // 건강 통계 조회 - @LogExecutionTime - public HealthStatsResponse getHealthStatistics(String userId, Long actorUserId) { - log.info("건강 통계 조회: 사용자ID={}", userId); - - try { - assertUserIdBelongsToActor(userId, actorUserId); - List records = getHealthRecordsByUserIdAsEntity(userId); - - return HealthStatsResponse.builder() - .totalRecords(records.size()) - .completedVaccines(countCompletedVaccines(records)) - .pendingVaccines(countPendingVaccines(records)) - .completedCheckups(countCompletedCheckups(records)) - .pendingCheckups(countPendingCheckups(records)) - .recordTypeDistribution(calculateRecordTypeDistribution(records)) - .upcomingEvents(generateUpcomingEvents(records)) - .build(); - } catch (CareCodeException e) { - // 우리가 던진 예외는 자기 상태 코드를 들고 있다. 아래 catch 에 걸리면 404·403 이 - // 전부 500 으로 덮이고, 남의 자녀 접근 거부가 서버 오류로 보고된다. - throw e; - } catch (Exception e) { - log.error("건강 통계 조회 실패: {}", e.getMessage()); - throw new CareServiceException("건강 통계 조회 중 오류가 발생했습니다.", e); - } - } - // ===== 스케줄 및 알림 관리 ===== - // 예방접종 스케줄 조회 - @LogExecutionTime - public List getVaccineSchedule(String childId, Long actorUserId) { - log.info("예방접종 스케줄 조회: 아동ID={}", childId); - - try { - Long cid = Long.valueOf(childId); - assertChildOwnedByUserId(cid, actorUserId); - List vaccineRecords = healthRecordRepository.findByChildIdAndRecordType( - cid, HealthRecord.RecordType.VACCINATION); - - return vaccineRecords.stream() - .map(this::convertToVaccineScheduleResponse) - .collect(Collectors.toList()); - } catch (CareCodeException e) { - // 우리가 던진 예외는 자기 상태 코드를 들고 있다. 아래 catch 에 걸리면 404·403 이 - // 전부 500 으로 덮이고, 남의 자녀 접근 거부가 서버 오류로 보고된다. - throw e; - } catch (Exception e) { - log.error("예방접종 스케줄 조회 실패: {}", e.getMessage()); - throw new CareServiceException("예방접종 스케줄 조회 중 오류가 발생했습니다.", e); - } - } - - // 건강 검진 스케줄 조회 - @LogExecutionTime - public List getCheckupSchedule(String childId, Long actorUserId) { - log.info("건강 검진 스케줄 조회: 아동ID={}", childId); - - try { - Long cid = Long.valueOf(childId); - assertChildOwnedByUserId(cid, actorUserId); - List checkupRecords = healthRecordRepository.findByChildIdAndRecordType( - cid, HealthRecord.RecordType.CHECKUP); - - return checkupRecords.stream() - .map(this::convertToCheckupScheduleResponse) - .collect(Collectors.toList()); - } catch (CareCodeException e) { - // 우리가 던진 예외는 자기 상태 코드를 들고 있다. 아래 catch 에 걸리면 404·403 이 - // 전부 500 으로 덮이고, 남의 자녀 접근 거부가 서버 오류로 보고된다. - throw e; - } catch (Exception e) { - log.error("건강 검진 스케줄 조회 실패: {}", e.getMessage()); - throw new CareServiceException("건강 검진 스케줄 조회 중 오류가 발생했습니다.", e); - } - } - // 기간별 건강 기록 조회 (오래된순) JOIN FETCH를 사용하여 N+1 쿼리 문제 해결 @LogExecutionTime public List getHealthRecordsByDateRangeAsc(Long childId, LocalDate startDate, LocalDate endDate, Long actorUserId) { @@ -388,74 +266,6 @@ public void deleteAttachment(Long attachmentId, Long actorUserId) { healthRecordAttachmentRepository.save(attachment); } - // 건강 알림 조회 - @LogExecutionTime - public List getHealthAlerts(String userId, Long actorUserId) { - log.info("건강 알림 조회: 사용자ID={}", userId); - - try { - assertUserIdBelongsToActor(userId, actorUserId); - List records = getHealthRecordsByUserIdAsEntity(userId); - - return records.stream() - .filter(r -> r.getNextDate() != null && r.getNextDate().isAfter(java.time.LocalDate.now())) - .map(this::convertToHealthAlertResponse) - .collect(Collectors.toList()); - } catch (CareCodeException e) { - // 우리가 던진 예외는 자기 상태 코드를 들고 있다. 아래 catch 에 걸리면 404·403 이 - // 전부 500 으로 덮이고, 남의 자녀 접근 거부가 서버 오류로 보고된다. - throw e; - } catch (Exception e) { - log.error("건강 알림 조회 실패: {}", e.getMessage()); - throw new CareServiceException("건강 알림 조회 중 오류가 발생했습니다.", e); - } - } - - /** - * 아이 연령 기준 연계 추천. - * - *

연령 단위는 개월이다. 정책의 {@code targetAgeMin/Max} 가 개월이기 때문이다 - * (시드 데이터: "부모급여(0세)" 0~11, "아동수당" 0~95). 전에는 여기서만 - * {@code Period.between(...).getYears()} 로 연 나이를 계산해 그 쿼리에 넘겼다. - * 세 살 아이가 "3" 으로 들어가 0~11개월 대상 정책이 추천됐다. - * - *

월령을 모르면({@code null}) 연령 기반 조회를 하지 않는다. 전에는 {@code .orElse(0)} 이 - * 0개월로 바꿔, 자녀를 등록하지 않은 사용자에게 신생아 정책을 추천했다. - */ - @LogExecutionTime - public HealthRecommendationResponse getIntegratedRecommendations(String userId, Long actorUserId) { - assertUserIdBelongsToActor(userId, actorUserId); - User user = findUserByIdOrUserId(userId); - - Integer childAgeMonths = childDirectory.childrenOf(user.getId()).stream() - .map(child -> child.ageMonths(LocalDate.now())) - .filter(java.util.Objects::nonNull) - .findFirst() - .orElse(null); - - if (childAgeMonths == null) { - return new HealthRecommendationResponse( - user.getUserId(), null, List.of(), List.of(), - "아이를 등록하면 연령에 맞는 정책과 시설을 알려드립니다."); - } - - List recommendedPolicies = policyRepository.findByChildAge(childAgeMonths).stream() - .limit(3) - .map(Policy::getTitle) - .collect(Collectors.toList()); - List recommendedFacilities = careFacilityRepository.findByChildAge(childAgeMonths).stream() - .limit(3) - .map(CareFacility::getName) - .collect(Collectors.toList()); - - return new HealthRecommendationResponse( - user.getUserId(), - childAgeMonths, - recommendedPolicies, - recommendedFacilities, - "아이 연령에 맞는 정책/시설을 확인해보세요."); - } - // ===== 차트 및 시각화 ===== // ===== 시스템 관리 ===== @@ -489,126 +299,17 @@ private void assertChildOwnedByUserId(Long childId, Long actorUserId) { childDirectory.requireOwnedChild(childId, actorUserId); } - private void assertUserIdBelongsToActor(String userId, Long actorUserId) { - if (actorUserId == null) { - throw new BusinessException(ErrorCode.UNAUTHORIZED, "인증이 필요합니다."); - } - User resolved = findUserByIdOrUserId(userId); - if (!resolved.getId().equals(actorUserId)) { - throw new BusinessException(ErrorCode.FORBIDDEN, "해당 사용자 정보에 접근할 권한이 없습니다."); - } - } - // ===== Helper Methods ===== // Child Entity를 DTO로 변환 - // 예방접종 스케줄 응답 DTO 변환 - private VaccineScheduleResponse convertToVaccineScheduleResponse(HealthRecord record) { - return VaccineScheduleResponse.builder() - .vaccineName(record.getTitle()) - .description(record.getDescription()) - .recommendedAge(null) // 나이 정보는 별도 계산 필요 - .status(Boolean.TRUE.equals(record.getIsCompleted()) ? "COMPLETED" : "UPCOMING") - .scheduledDate(record.getRecordDate() != null ? record.getRecordDate().toString() : null) - .completedDate(Boolean.TRUE.equals(record.getIsCompleted()) ? record.getUpdatedAt().toString() : null) - .notes(record.getDescription()) - .build(); - } - - // 건강 검진 스케줄 응답 DTO 변환 - private CheckupScheduleResponse convertToCheckupScheduleResponse(HealthRecord record) { - return CheckupScheduleResponse.builder() - .checkupName(record.getTitle()) - .description(record.getDescription()) - .recommendedAge(null) // 나이 정보는 별도 계산 필요 - .status(Boolean.TRUE.equals(record.getIsCompleted()) ? "COMPLETED" : "UPCOMING") - .scheduledDate(record.getRecordDate() != null ? record.getRecordDate().toString() : null) - .completedDate(Boolean.TRUE.equals(record.getIsCompleted()) ? record.getUpdatedAt().toString() : null) - .notes(record.getDescription()) - .build(); - } - - // 건강 알림 응답 DTO 변환 - private HealthAlertResponse convertToHealthAlertResponse(HealthRecord record) { - return HealthAlertResponse.builder() - .alertId(record.getId().toString()) - .alertType(record.getRecordType() != null ? record.getRecordType().name() : null) - .title(record.getTitle()) - .message(record.getDescription()) - .priority(DEFAULT_ALERT_PRIORITY) - .dueDate(record.getNextDate() != null ? record.getNextDate().toString() : null) - .isRead(false) - .build(); - } - - private int countCompletedVaccines(List records) { - return (int) records.stream() - .filter(r -> r.getRecordType() == HealthRecord.RecordType.VACCINATION && Boolean.TRUE.equals(r.getIsCompleted())) - .count(); - } - - private int countPendingVaccines(List records) { - return (int) records.stream() - .filter(r -> r.getRecordType() == HealthRecord.RecordType.VACCINATION && !Boolean.TRUE.equals(r.getIsCompleted())) - .count(); - } - - private int countCompletedCheckups(List records) { - return (int) records.stream() - .filter(r -> r.getRecordType() == HealthRecord.RecordType.CHECKUP && Boolean.TRUE.equals(r.getIsCompleted())) - .count(); - } - - private int countPendingCheckups(List records) { - return (int) records.stream() - .filter(r -> r.getRecordType() == HealthRecord.RecordType.CHECKUP && !Boolean.TRUE.equals(r.getIsCompleted())) - .count(); - } - - private Map calculateRecordTypeDistribution(List records) { - return records.stream() - .collect(Collectors.groupingBy( - r -> r.getRecordType().name(), - Collectors.collectingAndThen(Collectors.counting(), Long::intValue) - )); - } - - private List generateUpcomingEvents(List records) { - return records.stream() - .filter(r -> r.getNextDate() != null && r.getNextDate().isAfter(java.time.LocalDate.now())) - .map(r -> String.format("%s: %s", r.getTitle(), r.getNextDate())) - .limit(MAX_UPCOMING_EVENTS) - .collect(Collectors.toList()); - } - // HealthRecord 엔티티를 HealthRecordResponse DTO로 변환 // HealthRecord 매핑은 HealthRecordMapper 사용 // ===== Validation Helper Methods ===== - // User ID 또는 Long ID로 사용자 조회 (중복 로직 제거) - private User findUserByIdOrUserId(String userId) { - validateUserId(userId); - - // userId(문자열)로 먼저 조회 - Optional userOpt = userRepository.findByUserId(userId); - if (userOpt.isPresent()) { - return userOpt.get(); - } - - // userId가 숫자라면 PK(id)로도 조회 시도 - try { - Long id = Long.parseLong(userId); - return userRepository.findById(id) - .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다: " + userId)); - } catch (NumberFormatException e) { - throw new UserNotFoundException("사용자를 찾을 수 없습니다: " + userId); - } - } - // 요청 객체 검증 private void validateRequest(HealthCreateHealthRecordRequest request) { if (request == null) { @@ -642,13 +343,6 @@ private void validateChildId(Long childId) { } } - // 사용자 ID 검증 - private void validateUserId(String userId) { - if (!StringUtils.hasText(userId)) { - throw new BusinessException(ErrorCode.INVALID_INPUT, "사용자 ID가 필요합니다."); - } - } - // 날짜 범위 검증 private void validateDateRange(LocalDate startDate, LocalDate endDate) { if (startDate != null && endDate != null && startDate.isAfter(endDate)) { @@ -670,19 +364,4 @@ private HealthRecordAttachmentResponse toAttachmentResponse(HealthRecordAttachme .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/test/java/com/carecode/integration/AccessControlContractTest.java b/src/test/java/com/carecode/integration/AccessControlContractTest.java index 1950e312..02bc4290 100644 --- a/src/test/java/com/carecode/integration/AccessControlContractTest.java +++ b/src/test/java/com/carecode/integration/AccessControlContractTest.java @@ -251,7 +251,12 @@ void publicDataReadStaysOpen(String path) throws Exception { "/health/children/age-range?userId=1&minAge=0&maxAge=5", "/health/children/gender?userId=1&gender=FEMALE", "/health/children/special-needs?userId=1", - "/health/children/search?userId=1&name=아" + "/health/children/search?userId=1&name=아", + // → GET /health/records/type (같은 데이터), GET /children/{id}/vaccinations 와 + // GET /children/{id}/timeline (진짜 일정). "스케줄" 이라는 이름과 달리 기록 + // 목록을 돌려주고 있었다. + "/health/vaccines/schedule?childId=1", + "/health/checkups/schedule?childId=1" }) @WithMockUser(username = "admin@example.com", roles = "ADMIN") void duplicateMappingsRemoved(String path) throws Exception {