Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions docs/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1269,6 +1269,44 @@
},
"type" : "object"
},
"ChildTimelineResponse" : {
"properties" : {
"birthDate" : {
"format" : "date",
"type" : "string"
},
"childId" : {
"format" : "int64",
"type" : "integer"
},
"childName" : {
"type" : "string"
},
"from" : {
"format" : "date",
"type" : "string"
},
"items" : {
"items" : {
"$ref" : "#/components/schemas/TimelineItem"
},
"type" : "array"
},
"overdueCount" : {
"format" : "int32",
"type" : "integer"
},
"to" : {
"format" : "date",
"type" : "string"
},
"upcomingCount" : {
"format" : "int32",
"type" : "integer"
}
},
"type" : "object"
},
"Cohort" : {
"properties" : {
"day1" : {
Expand Down Expand Up @@ -3846,6 +3884,34 @@
},
"type" : "object"
},
"TimelineItem" : {
"properties" : {
"ageMonths" : {
"format" : "int32",
"type" : "integer"
},
"date" : {
"format" : "date",
"type" : "string"
},
"description" : {
"type" : "string"
},
"referenceId" : {
"type" : "string"
},
"status" : {
"type" : "string"
},
"title" : {
"type" : "string"
},
"type" : {
"type" : "string"
}
},
"type" : "object"
},
"TokenDto" : {
"properties" : {
"accessToken" : {
Expand Down Expand Up @@ -8484,6 +8550,53 @@
"tags" : [ "아이 관리" ]
}
},
"/children/{childId}/timeline" : {
"get" : {
"description" : "접종·검진 권장 시기·지원금 신청 마감·신학기를 한 축에 모아 날짜순으로 준다. 놓친 항목(OVERDUE)은 구간 앞이라도 포함한다. 기본 12개월, 최대 36개월.",
"operationId" : "getTimeline",
"parameters" : [ {
"in" : "path",
"name" : "childId",
"required" : true,
"schema" : {
"format" : "int64",
"type" : "integer"
}
}, {
"description" : "조회 기간(개월). 기본 12, 최대 36",
"in" : "query",
"name" : "months",
"required" : false,
"schema" : {
"format" : "int32",
"type" : "integer"
}
}, {
"description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400",
"in" : "header",
"name" : "X-API-Version",
"required" : false,
"schema" : {
"default" : "1",
"type" : "string"
}
} ],
"responses" : {
"200" : {
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ChildTimelineResponse"
}
}
},
"description" : "OK"
}
},
"summary" : "아이 할 일 타임라인",
"tags" : [ "아이 관리" ]
}
},
"/children/{childId}/vaccinations" : {
"get" : {
"description" : "표준 일정에 따른 접종 예정일과 완료 여부 반환",
Expand Down
81 changes: 81 additions & 0 deletions docs/features/child-timeline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# 아이 할 일 타임라인

> 관련 이슈: #128

## 문제

부모가 "다음에 뭘 해야 하나" 를 알려면 화면 세 곳을 돌아야 했습니다.

| 할 일 | 있던 곳 |
|-------|---------|
| 예방접종 | 아이 상세 > 접종 일정 |
| 건강검진 | 건강 기록 (그나마 **받은 것만** 보였습니다) |
| 지원금 신청 마감 | 정책 화면 |
| 시설 입소 시기 | 어디에도 없음 |

특히 검진이 문제였습니다. `GET /health/checkups/schedule` 은 이름과 달리 **이미 기록된 검진을 나열**할
뿐이어서, 아직 받지 않은 검진은 화면에 나타나지 않았습니다. 놓쳐도 아무도 알려주지 않습니다.

## 해결 — 이미 있는 데이터를 한 축에 놓는다

`GET /children/{childId}/timeline?months=12`

새로 수집하는 데이터는 없습니다. 흩어져 있던 것을 날짜 하나로 정렬해 돌려줍니다.

```mermaid
flowchart LR
V["접종 일정<br/>(자동 생성된 표준 일정)"] --> T
C["검진 권장 시기<br/>(표준 8차) + 검진 기록"] --> T
P["지원금 마감<br/>(아이 나이에 맞는 정책)"] --> T
N["3월 신학기"] --> T
T["날짜순 타임라인<br/>OVERDUE / UPCOMING / DONE / INFO"]

style T fill:#d4edda,stroke:#28a745
```

| 항목 | 출처 | 상태 판단 |
|------|------|-----------|
| `VACCINATION` | `TBL_VACCINATION_SCHEDULE` | 완료·건너뜀은 제외. 기한이 지났으면 `OVERDUE` |
| `CHECKUP` | 표준 시기(`CheckupStandard`) + `TBL_HEALTH_RECORD` 의 검진 기록 | 그 시기에 기록이 있으면 `DONE`, 없이 지났으면 `OVERDUE` |
| `POLICY_DEADLINE` | `TBL_POLICIES` 의 신청 마감 | 마감일에 `UPCOMING` |
| `NEW_TERM` | 달력(3월) | 참고(`INFO`) |

## 판단 세 가지

### 놓친 항목을 감추지 않는다

지난 일이라고 목록에서 빼면 사용자는 놓친 사실 자체를 모릅니다. 조회 구간이 오늘부터여도
**기한이 지난 접종·검진은 담아** `OVERDUE` 로 표시하고, 개수를 `overdueCount` 로 함께 줍니다.

### 없는 날짜를 만들지 않는다

3월 신학기는 시설 입소가 몰리는 시점이지만 **신청 일정은 시설마다 다릅니다.** 그럴듯한 날짜를 적으면
그걸 믿고 놓치는 사람이 생기므로, 참고 항목으로만 두고 "시설에 직접 확인하세요" 라고 밝힙니다.

검증되지 않은 지원금 금액에도 같은 원칙을 적용합니다 — `verifiedAt` 이 없으면 설명에 추정치임을 붙입니다.

### 검진 회차는 날짜로 맞춘다

검진 기록에 회차 정보가 없어서, 권장 시기 구간 안에 기록이 있으면 받은 것으로 봅니다.
지금 데이터로 할 수 있는 최선이고, 회차를 받게 되면 `ChildTimelineService` 한 곳만 고치면 됩니다.

## 표준 검진 시기

국민건강보험 영유아 건강검진(생후 14일~71개월, 8차)을 `CheckupStandard` 에 담았습니다.
예방접종 일정(`VaccineType`)과 같은 방식입니다. 구강검진은 별도 회차라 넣지 않았습니다.
제도가 바뀔 수 있으므로 화면에는 "권장 시기" 로 표시합니다.

## 접근제어

아이 개인정보이므로 `/children/**` 인증 규칙을 따르고, 서비스에서 **보호자 본인 것만** 반환합니다.
남의 아이면 403 이 아니라 **404** 입니다 — 403 은 "그 아이가 존재한다" 는 사실을 알려 줍니다.

소유권 검증은 `ChildService.requireOwned` 하나만 씁니다. 검증을 복사하면 한쪽만 고쳐져 구멍이 남습니다.

## 한계

| 항목 | 내용 |
|------|------|
| 조회 기간 | 기본 12개월, 최대 36개월. 더 길게 보면 정책 마감이 의미 없어집니다 |
| 지원금 대상 판정 | 나이만 봅니다. 소득·다자녀 조건은 [지원금 지능화](benefit-intelligence.md)의 추천 API 가 봅니다 |
| 시설 신청 일정 | 공공데이터에 없습니다. 시설별 신청 일정을 받으면 `NEW_TERM` 을 실제 날짜로 바꿀 수 있습니다 |
2 changes: 1 addition & 1 deletion docs/reference/access-control-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ flowchart TD
| `/auth/user/**`, `/auth/logout` | — |
| `/users/**` | **본인 계정 전용.** 경로 변수가 있는 구 경로는 서비스 진입 전에 본인인지 확인한다 |
| `/users/privacy/**` | 열람·동의·탈퇴 |
| `/children/**` | 자녀 정보 |
| `/children/**` | 자녀 정보. 서비스에서 보호자 본인 것만 반환하고, 남의 아이는 404 (존재 여부를 숨긴다) |
| `/notifications/**` | — |
| `POST /facilities/{id}/bookings`, `/facilities/bookings/user`, `/facilities/bookings/{bookingId}` | 본인 예약. 남의 예약은 403 |
| `/facilities/{id}/reviews` (POST), `/facilities/reviews/{reviewId}` | 리뷰 작성·수정·삭제 (본인 것만) |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public class ChildController {
private final SiblingOverviewService siblingOverviewService;
private final VaccinationScheduleService vaccinationScheduleService;
private final GrowthChartService growthChartService;
private final com.carecode.domain.health.timeline.ChildTimelineService timelineService;

@PostMapping
@LogExecutionTime
Expand All @@ -52,6 +53,17 @@ public ResponseEntity<List<ChildInfoResponse>> getMyChildren() {
return ResponseEntity.ok(childService.getMyChildren());
}

@GetMapping("/{childId}/timeline")
@LogExecutionTime
@Operation(summary = "아이 할 일 타임라인",
description = "접종·검진 권장 시기·지원금 신청 마감·신학기를 한 축에 모아 날짜순으로 준다. "
+ "놓친 항목(OVERDUE)은 구간 앞이라도 포함한다. 기본 12개월, 최대 36개월.")
public ResponseEntity<com.carecode.domain.health.dto.response.ChildTimelineResponse> getTimeline(
@PathVariable Long childId,
@Parameter(description = "조회 기간(개월). 기본 12, 최대 36") @RequestParam(required = false) Integer months) {
return ResponseEntity.ok(timelineService.timeline(childId, months));
}

@GetMapping("/{childId}")
@LogExecutionTime
@Operation(summary = "아이 상세 조회")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.carecode.domain.health.dto.response;

import lombok.Builder;
import lombok.Getter;

import java.time.LocalDate;
import java.util.List;

/**
* 아이 한 명의 할 일을 시간 축 하나에 모은 것.
*
* <p>접종은 접종 화면, 검진은 기록 화면, 지원금 마감은 정책 화면에 흩어져 있었다. 부모가 "다음에 뭘
* 해야 하나" 를 알려면 화면 세 곳을 돌아야 했고, 그래서 놓쳤다. 데이터는 이미 다 있으므로 합쳐서 준다.
*/
@Getter
@Builder
public class ChildTimelineResponse {

private final Long childId;
private final String childName;
private final LocalDate birthDate;

/** 조회 구간. 기준일(오늘)부터 몇 개월까지 본 결과인지. */
private final LocalDate from;
private final LocalDate to;

/** 지난 항목 중 아직 하지 않은 것. 구간 앞이라도 놓친 건 보여 줘야 한다. */
private final int overdueCount;
private final int upcomingCount;

private final List<TimelineItem> items;

@Getter
@Builder
public static class TimelineItem {

/** 기준 날짜. 구간이 있는 항목(검진)은 시작일을 쓴다. */
private final LocalDate date;

/** VACCINATION, CHECKUP, POLICY_DEADLINE, NEW_TERM */
private final String type;

/** OVERDUE(지났는데 안 함), UPCOMING(앞으로), DONE(완료), INFO(참고) */
private final String status;

private final String title;
private final String description;

/** 해당 도메인 상세로 이어 주기 위한 식별자. 없으면 null. */
private final String referenceId;

/** 그 날짜의 아이 월령. 화면에서 "12개월 무렵" 처럼 쓴다. */
private final Integer ageMonths;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ public void deleteChild(Long childId) {
childRepository.delete(requireOwnedChild(childId));
}

/**
* 소유권을 확인한 아이 엔티티. 다른 서비스(타임라인 등)가 같은 검증을 다시 구현하지 않도록 공개한다.
* 검증을 복사하면 한쪽만 고쳐져 남의 아이가 열리는 일이 생긴다.
*/
public Child requireOwned(Long childId) {
return requireOwnedChild(childId);
}

/** 아이 조회 + 소유권 검증. 남의 아이 정보에 접근하지 못하도록 보호자 본인 것만 반환한다. */
private Child requireOwnedChild(Long childId) {
User parent = currentUserFacade.requireCurrentUser();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.carecode.domain.health.timeline;

import lombok.Getter;

import java.time.LocalDate;
import java.util.Arrays;
import java.util.List;

/**
* 국가 영유아 건강검진 시기(월령 구간).
*
* <p>이 앱에는 표준 검진 시기가 없었다. {@code getCheckupSchedule} 은 이름과 달리 이미 기록된 검진을
* 나열할 뿐이어서, 아직 받지 않은 검진은 화면에 나타나지 않았다 — 놓쳐도 아무도 알려주지 않는다.
* 예방접종 일정({@code VaccineType})과 같은 방식으로 시기를 코드에 담는다.
*
* <p>출처: 국민건강보험 영유아 건강검진(생후 14일~71개월, 8차). 구강검진은 별도 회차라 포함하지 않는다.
* 실제 대상 기간은 제도 개편으로 바뀔 수 있으므로 화면에는 "권장 시기" 로 표시한다.
*/
@Getter
public enum CheckupStandard {

ROUND_1(1, 14, 35, "1차 건강검진", "생후 14~35일"),
ROUND_2(2, 4 * 30, 6 * 30 + 30, "2차 건강검진", "4~6개월"),
ROUND_3(3, 9 * 30, 12 * 30 + 30, "3차 건강검진", "9~12개월"),
ROUND_4(4, 18 * 30, 24 * 30 + 30, "4차 건강검진", "18~24개월"),
ROUND_5(5, 30 * 30, 36 * 30 + 30, "5차 건강검진", "30~36개월"),
ROUND_6(6, 42 * 30, 48 * 30 + 30, "6차 건강검진", "42~48개월"),
ROUND_7(7, 54 * 30, 60 * 30 + 30, "7차 건강검진", "54~60개월"),
ROUND_8(8, 66 * 30, 71 * 30 + 30, "8차 건강검진", "66~71개월");

private final int round;

/** 생후 일수 기준 시작·종료. 월령 구간을 일수로 환산해 둔다(월 길이 차이는 안내 문구로 흡수). */
private final int startDays;
private final int endDays;

private final String title;
private final String periodLabel;

CheckupStandard(int round, int startDays, int endDays, String title, String periodLabel) {
this.round = round;
this.startDays = startDays;
this.endDays = endDays;
this.title = title;
this.periodLabel = periodLabel;
}

public LocalDate windowStart(LocalDate birthDate) {
return birthDate.plusDays(startDays);
}

public LocalDate windowEnd(LocalDate birthDate) {
return birthDate.plusDays(endDays);
}

public static List<CheckupStandard> all() {
return Arrays.asList(values());
}
}
Loading
Loading