Test #463

Merged
klaus merged 4 commits from test into main 2026-10-30 14:29:24 +00:00
2 changed files with 263 additions and 0 deletions
Showing only changes of commit e6afae2763 - Show all commits
@@ -0,0 +1,100 @@
# 관리자 공지 예약 공개 API 연동 작업
아래 요구사항에 따라 기존 ADMIN 공지사항 생성·수정 화면의 API 연동을 변경하세요. 이 작업은 기존 화면에 예약 공개 입력과 처리만 추가하며 화면을 새로 설계하지 않습니다. **아래 API 계약을 기준으로 작업하며 실제 연결은 대상 API 사용 가능 여부를 확인한다.**
## 작업 범위
- 기존 공지 생성과 수정 기능에 선택적 예약 공개 시각 `publishAt` 입력과 전송을 추가합니다.
- API 요청·응답 및 화면 상태 연결만 다룹니다. 서버 구현이나 서버 설정을 변경하지 않습니다.
- 기존 화면 구조와 사용자 동작을 유지하고, 필요한 API 연동만 수정합니다.
## 공통 계약
- 기존 ADMIN Bearer 인증을 재사용합니다. 새 인증 흐름이나 권한 체계를 만들지 않습니다.
- 성공은 `ApiResponse`의 `success`, `message`, `data`, `errorProperty` envelope로 판별합니다. HTTP 200이어도 `success: false`면 실패입니다.
- 오류 응답의 기존 필드를 보존하고, 사용자에게는 응답 `message`를 표시합니다. 인증 오류는 기존 로그인 흐름을 따릅니다.
- 시각 문자열은 모두 Asia/Seoul 기준 `yyyy-MM-dd HH:mm:ss`입니다. `T`, `Z`, offset, 밀리초를 포함하지 않습니다.
- 브라우저 시간대를 거쳐 변환하거나 KST 시각을 다시 변환하지 않습니다. 표시·입력한 시각을 그대로 계약 형식으로 보내 이중 시간대 이동을 막습니다.
## 생성
`POST /notice`
요청 필드: `title`, `content`, 선택 필드 `publishAt` (`string | null`).
```json
{
"title": "서비스 점검 안내",
"content": "점검 시간과 안내 내용",
"publishAt": "2026-11-15 09:00:00"
}
```
- `publishAt` 생략 또는 `null`은 즉시 공개입니다.
- 미래의 유효한 시각 문자열은 예약 공개입니다. 예시 시각은 실제 요청 시점보다 미래여야 합니다.
- 현재 또는 과거 시각은 허용하지 않습니다. 오류를 정상 성공처럼 처리하지 않습니다.
- 요청에 `isScheduled`, `createdAt`, `isActive`를 추가하지 않습니다. 생성 성공 응답의 `data`는 숫자 공지 ID입니다.
## 수정
`PUT /notice`
요청 필드: 필수 `id`, 선택 `title`, `content`, `publishAt` (`string | null`).
```json
{
"id": 42,
"publishAt": "2026-11-20 10:30:00"
}
```
- `publishAt` 생략은 기존 예약 상태를 보존합니다. 제목만 또는 본문만 수정할 때도 이 규칙을 따릅니다.
- 명시적 `null`은 아직 공개되지 않은 예약 공지를 지금 공개하겠다는 의도일 때만 보냅니다.
- 미래 시각 문자열은 아직 공개되지 않은 예약 공지의 예약 시각을 변경합니다. 시각만 변경하는 요청도 유효합니다.
- 이미 공개된 공지는 제목·본문 수정은 허용되지만 공개 시각 변경은 허용되지 않습니다. 그런 요청의 API 실패를 존중하고 우회하거나 재시도하지 않습니다.
- 제목만 수정할 때 폼에 조회한 `publishAt` 값이 있어도 요청에서 반드시 생략합니다. 기본값 `null`을 무심코 직렬화하지 말고, 사용자가 즉시 공개 전환을 명시한 경우에만 `publishAt: null`을 보냅니다.
- 수정 성공 시 `data`의 구체적인 값에 의존하지 않습니다. `success`를 확인한 뒤 목록을 다시 조회합니다.
## 관리자 목록
`GET /api/v2/admin/notices?page=0&size=20`
기존 ADMIN 인증을 사용합니다. 공개된 항목만 반환하는 공개 공지 API를 대신 사용하지 않습니다. ADMIN 토큰으로 공개 API를 호출해도 예약 공개 전 공지는 반환되지 않습니다.
응답 `data` 형식:
```json
{
"totalCount": 1,
"noticeList": [
{
"id": 42,
"title": "서비스 점검 안내",
"content": "점검 시간과 안내 내용",
"isActive": true,
"isScheduled": true,
"publishAt": "2026-11-15 09:00:00",
"createdAt": "2026-10-30 14:20:00"
}
]
}
```
각 항목은 `id`, `title`, `content`, `isActive`, `isScheduled`, `publishAt`, `createdAt`을 가집니다. `publishAt`은 공개 시각 문자열 또는 `null`이며, 예약을 즉시 공개로 전환한 항목에는 전환 시각이 들어 있습니다. `null`은 시각을 지정하지 않고 즉시 등록한 공지입니다. 목록에는 활성 공개 공지와 아직 공개되지 않은 활성 예약 공지가 모두 포함됩니다. 페이지 인덱스는 0부터 시작하고 `totalCount`는 이 관리자 목록의 전체 대상 건수입니다.
별도의 공지 상세 API는 없습니다. 편집 시 목록에서 받은 전체 항목을 사용하고, 존재하지 않는 상세 경로를 만들지 않습니다.
## 날짜 표시와 목록 갱신
- 예약 공개 공지의 날짜 표시는 `publishAt`의 KST 날짜를 사용합니다.
- 즉시 생성된 공지와 예약에서 즉시 공개로 전환한 공지의 날짜 표시는 원래 등록 시각인 `createdAt`의 KST 날짜를 사용합니다.
- `isScheduled`는 응답 전용 표시 상태이며 요청에 보내지 않습니다. 예약이 자연 공개된 뒤에도 `true`일 수 있으므로 이를 미공개 여부로 해석하지 않습니다. `isScheduled`와 미래 `publishAt`을 함께 화면 동작 판단에 참고할 수 있지만, 공개 경계를 최종 판단하는 주체는 API입니다. 클라이언트 시계나 이 불리언만으로 공개 상태를 확정하거나 API 거부를 무시하지 않습니다.
- 생성·수정 성공 후 목록을 다시 조회합니다. 응답은 공개 시각 내림차순, 같은 시각이면 ID 내림차순입니다. 이 순서를 유지하고 표시 날짜를 기준으로 클라이언트에서 재정렬하지 않습니다. 특히 예약을 즉시 공개로 전환한 경우 표시 날짜는 원래 등록일이지만 목록 순서는 공개 시각 변경에 따라 달라질 수 있습니다.
- 목록을 불러오거나 저장할 수 없을 때 임의의 공개 API로 자동 대체하지 않습니다. API 실패를 보존해 기존 오류 흐름으로 처리합니다.
## 완료 확인
- 생성에서 `publishAt` 생략/null은 즉시 공개, 미래 시각은 예약 요청으로 전달됩니다.
- 수정에서 `publishAt` 생략은 보존, 명시적 null은 의도된 즉시 전환, 미래 시각은 재예약으로 전달됩니다. 제목만 수정할 때 `publishAt`은 생략됩니다.
- 관리자 목록은 위 경로와 페이지 파라미터를 사용하며 응답의 `success`를 확인하고 목록 순서를 유지합니다.
- 모든 시각 요청·표시는 KST `yyyy-MM-dd HH:mm:ss`이며 브라우저 시간대 변환을 추가하지 않습니다.
@@ -0,0 +1,163 @@
# 공지사항 예약 공개 PRD
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 기능 구현·관련 테스트 122개·패키징·리뷰 완료, 전체 회귀 timeout으로 최종 Gate 보류, 미배포 |
| 작성일 | 2026-10-30 |
| 최종 수정일 | 2026-10-30 |
| 대상 제품 | 관리자 공지사항 예약 공개와 공개 공지 조회 |
| 작성자·결정권자 | 제품 승인 요구사항, 기술 제안은 본 문서에 구분 표기 |
| 관련 API Contract | 본 문서 §7 |
| 관련 구현 계획 | [plan-task.md](plan-task.md) |
| 관련 review | 없음 |
## 1. 목표와 문제
관리자는 공지를 미리 등록하고 지정한 시각에 공개할 수 있어야 한다. 예약 전 공지는 관리자 목록에서 확인하고 관리할 수 있지만, 관리자 권한이 있더라도 일반 공개 조회에서는 예약 시각 전 공지를 볼 수 없어야 한다. 구현 범위는 서버 API, DB, 테스트이며 관리자·클라이언트 UI는 제외한다.
## 2. 결정 상태와 범위
| 구분 | 내용 |
|---|---|
| 사용자 확정 정책 | §3~§8의 권한, 상태 전이, 날짜·정렬·공개 경계, 기존 API 호환 정책 |
| 구현 설계 결정 | nullable `publishAt`와 `isScheduled`, API 입력 의미, `yyyy-MM-dd HH:mm:ss` KST 형식, 관리자 목록 경로와 페이지 계약 |
| 사용자 추가 확인 | 서버 UTC, DB UTC. 신규 `publish_at`은 초 단위 MySQL `TIMESTAMP NULL DEFAULT NULL`로 확정한다. 기존 `created_at`의 정밀도를 복제하거나 변경하지 않는다. 기존 운영 DDL은 미조회이며 배포 전 신규 컬럼 적용·연결 시간대 확인은 유지한다. |
## 3. 목표와 제외
### 목표
- 관리자는 예약 공개 공지를 포함한 활성 공지 목록과 예약 시각을 확인하고 기존 생성·수정·삭제 권한으로 관리한다.
- 공개 목록과 최근 공지는 예약 시각이 현재 시각 이하인 활성 공지만 반환한다.
- 기존 공개 응답 형식, 권한, 삭제 방식은 보존하되 공개 순서와 `date` 값은 승인된 정책으로 변경한다.
- DB 시각은 UTC로 저장하고 관리자 입력·표시와 클라이언트 날짜 표시는 KST를 적용한다.
### 제외
- 관리자 페이지와 클라이언트 UI, 예약 공개 scheduler·catch-up 작업, 캐시, 역할 신설, 복원, 일괄 예약, 신규 의존성.
- 공지 상세 API, 검색·상태 필터, 글로벌 시간대/BaseEntity 변경, 과거 레코드 backfill.
- 운영 DB에 대한 자동 DDL 적용 또는 실제 운영 DB 접속.
## 4. 사용자와 권한
| 작업 | 익명 | 일반 인증 사용자 | ADMIN |
|---|---:|---:|---:|
| 기존 공개 목록·v1/v2 최근 공지 조회 | 허용된 기존 경로만 | 기존 정책 유지 | 공개 규칙만 적용, 예약 미공개 우회 금지 |
| 활성 예약 포함 관리자 목록 조회 | 거부 | 거부 | 허용 |
| 공지 생성·수정·삭제 | 거부 | 거부 | 기존 ADMIN 전용 정책 유지 |
예약 공개 관리자 API는 별도 관리자 조회 경로다. 공개 API는 인증 주체의 역할에 따라 예약 공지를 노출하지 않는다. 현재 공통 `SodaExceptionHandler`의 business/AccessDenied 오류는 `ApiResponse.error` 응답을 사용하며 HTTP 상태를 임의로 새로 정하지 않는다. 익명 인증 실패는 현재 SecurityConfig 및 entry point 계약을 따른다. 실제 통합 검증에서 status와 envelope를 확인한다.
## 5. 상태·데이터 정책
### 제품 정책
1. 기존 활성 상태(`isActive`)와 등록·수정·삭제 권한은 유지한다. 기존 공지는 계속 공개된 공지로 간주하되 `isActive=false`면 공개 조회에서 제외한다. 예약 시각 변경으로 이미 공개된 공지를 숨기거나 재예약할 수 없다는 뜻이며, 기존 ADMIN soft delete는 계속 허용한다.
2. 생성 시 공개 시각 생략 또는 JSON `null`은 즉시 공개다. 미래 시각은 예약이며 과거 시각은 거부한다.
3. 예약 공개 시각에 도달하면 `publishAt <= now`인 공개 조회에서 자동으로 공개된다. 공개 여부는 query 시각으로 계산하며 scheduler가 필요 없다. 서비스 중단 중 catch-up 작업 없이 재기동 후 다음 조회가 경계를 판정한다.
4. 아직 공개되지 않은 예약 공지만 공개 시각을 변경하거나 즉시 공개로 전환할 수 있다. 공개 시각 변경으로 이미 공개된 공지를 숨기거나 재예약할 수 없다. 기존 ADMIN soft delete 권한은 유지한다. 제목·본문 수정 규칙은 유지하고 예약 시각만 수정하는 요청도 허용한다.
5. 공개 결과는 유효 공개 시각 내림차순, 동률이면 기존 등록 순서인 `id` 내림차순이다. 즉시 공개 전환 시 정렬용 유효 공개 시각은 전환 시각이다.
6. 자동 예약 공개 날짜는 예약 공개 시각의 KST 날짜다. 즉시 생성 공지 날짜는 원래 등록 시각의 KST 날짜다. 예약을 즉시 공개로 전환해도 표시 날짜는 원래 등록 시각을 유지한다. 이 날짜 표시 규칙은 정렬에 쓰이는 유효 공개 시각과 별개다.
7. 관리자 입력·응답 시각은 `Asia/Seoul`, DB 시각은 UTC다.
### 구현 설계 결정
- 저장 필드는 nullable UTC `publishAt`와 `isScheduled`(기본 `false`)로 제안한다. 기존·즉시 공지는 `publishAt=NULL`, 예약 공지는 미래 시각과 `isScheduled=true`를 저장한다. 예약에서 즉시 공개로 바꿀 때 서버 시각을 `publishAt`에 저장하고 `isScheduled=false`로 한다. `publishAt`을 NULL로 초기화하면 전환 시각 정렬이 깨지므로 금지한다.
- 공개 여부는 `isActive AND (publishAt IS NULL OR publishAt <= now)`다. 표시 날짜의 원본은 `isScheduled=true`일 때 `publishAt`, 그 외에는 `createdAt`이다. 공개 정렬 키는 `COALESCE(publishAt, createdAt) DESC, id DESC`다.
- 공개 필터는 count 및 목록에서 pagination/limit 전에 적용한다. 목록, v1 latest, v2 latest 모두 같은 가시성 및 정렬 규칙을 사용한다. 관리자 목록은 별도 ADMIN 전용 조회로 활성 전체를 반환하고 공개 여부로 숨기지 않는다.
- 별도 상태 엔진, 공개 모드 이력, backfill은 두지 않는다. `isScheduled`만으로 예약값과 즉시값을 구분한다.
- 사용자 후속 승인에 따라 신규 `publish_at`은 초 단위 MySQL `TIMESTAMP NULL DEFAULT NULL`와 COMMENT로 추가한다. 기존 컬럼 정밀도 복제는 하지 않으며 `created_at`, `BaseEntity`, 전역 timezone 설정은 변경하지 않는다. 서버·DB UTC는 사용자 확인을 근거로 하고 운영 DB 직접 조회를 수행했다는 뜻은 아니다.
## 6. 흐름 및 시간·동시성 규칙
### 쓰기
- 기술 계약 선택: 생성 요청은 기존 `title`, `content`와 선택 입력 `publishAt`을 받는다. 필드 생략/JSON null은 즉시 생성, 유효한 미래 KST 시각은 UTC 예약으로 저장한다. 제목·본문 기존 blank 검증은 유지한다.
- 수정은 기존 `id`, 선택적 `title`, `content`와 선택 입력 `publishAt`을 받는다. 공개 시각 필드 생략은 보존, 명시적 null은 예약 미공개일 때 즉시 전환, 유효한 미래 시각은 예약 미공개일 때 재예약이다. 예약 시각만 보내는 수정도 유효하다. 제목·본문만 수정하는 기존 사용법은 유지한다.
- 현재 DTO의 nullable 필드만으로 JSON 생략과 null을 구분할 수 없으므로 입력 경계에서 field presence를 구분한다. 배너 선례처럼 JSON tree의 `has("publishAt")` 검사가 설계 참고다. null 제목/본문을 예약 전환 의미로 오해하지 않는다.
- 기술 계약 선택: wire format은 KST `yyyy-MM-dd HH:mm:ss`이며 파서는 `uuuu-MM-dd HH:mm:ss` + `ResolverStyle.STRICT`를 사용한다. UTC 변환 후 미래여야 하며 현재 시각도 거부한다. 포맷 오류, 과거/현재 시각, 잘못된 전환은 `SodaException(messageKey="common.error.invalid_request")`를 사용한다. HTTP 400을 별도 강제하지 않는다. 숫자 범위는 P1-T1에서 확인된 실제 timestamp 타입 기준으로만 검사하며 임의 범위를 만들지 않는다.
- 수정 boundary는 기존 `ServiceNoticeController.updateNotice`가 JSON object `JsonNode`를 받도록 하고, `publishAt`은 JSON string 또는 JSON null만 허용한다. object가 아니거나 필드가 다른 타입이면 거부한다. 컨트롤러는 `ObjectMapper`로 기존 `UpdateNoticeRequest`를 변환하고 `service.update(request, publishAtPresent = fields.has("publishAt"))`를 호출한다. `title/content/id`의 기존 nullable/blank 동작을 보존한다. omitted `publishAt`은 explicit null과 다르다.
- 기술 결정: 기존 JPA repository에 `findByIdForUpdate(id: Long): ServiceNotice?`를 `PESSIMISTIC_WRITE`로 구현한다. 수정 transaction은 이 잠금을 먼저 획득한 뒤 fresh UTC now와 현재 상태를 읽고 eligibility를 판정한다. title/content-only 수정도 같은 잠금을 거쳐 stale JPA flush가 새 schedule 값을 덮지 않게 한다. 잠금 뒤 공개 시각이 이미 경과했으면 시간 변경을 거부한다. 변경의 선형화 지점은 잠금 후 eligibility 검사 시점이며, wall-clock 시간이 이후에도 경계를 넘을 수 있음은 정상적인 query-time 공개 정책이다. 공개 query는 row lock을 얻지 않는다.
### 읽기와 정밀도
- 공개 서비스 조회는 요청 단위로 UTC now를 한 번 캡처하고 count와 목록이 같은 값을 사용한다. 기존 `nowProvider` 테스트 seam 패턴을 재사용하며 전역 Clock 추상화는 도입하지 않는다. 기존 `ServiceNoticeService.getNoticeList(pageable, timezone)`와 `getLatestNotice()` 진입 시그니처를 유지하고 내부에서 시각을 한 번 캡처한다. v2 `LatestNoticeService`는 같은 now를 받는 repository query를 사용한다.
- 새 `publishAt`의 업무·API 계약은 초 단위다. 예약 입력은 초 단위로 파싱하고, 즉시 전환은 한 번 캡처한 UTC now를 저장 경계에서 초 절삭한 단일 값으로 저장·반환한다. 확인된 물리 컬럼이 `(3)/(6)`이어도 이 새 필드의 값은 나노초 0을 유지하며, 기존 `createdAt`의 정밀도나 저장값에는 적용하지 않는다. 미래 여부와 기존 공개 경계 판정은 잠금 후 캡처한 원본 now를 기준으로 한다. 즉시 생성 공지는 `publishAt=NULL`이라 `BaseEntity.PrePersist`의 등록 시각과 별도 시계값을 중복 저장하지 않는다.
- 저장 정밀도 왕복 테스트는 즉시 전환의 now에 nanos `0`, `123456789`, `999999999`를 고정 입력으로 포함해 DB `flush/clear/reload`를 수행한다. 새 `publishAt`의 예상 나노초는 모두 0이며 다음 초·날짜로 반올림되지 않아야 한다. 관리자 응답의 `publishAt`을 KST에서 UTC로 복원한 값과 DB 값도 정확히 같아야 한다. `createdAt`의 초 단위 응답은 표시 형식일 뿐 DB 원본과 소수초까지 같다는 주장이 아니다. 테스트 DB의 컬럼 정밀도는 확인된 운영 계약에 맞추며 H2가 운영 정밀도와 같다고 가정하지 않는다.
## 7. API 계약
공통 성공 envelope는 기존 `ApiResponse` (`success`, `message`, `data`, `errorProperty`)를 유지한다. POST 생성 응답의 숫자 ID와 PUT/DELETE 성공 응답의 `data: {}` 및 message를 유지한다. PUT/DELETE 값은 실제 HTTP 검증으로 확인했으며 기존 문서의 null 표기를 정정한다. 아래 `publishAt`은 JSON request body 필드다. Route, 필드 presence semantics, wire format, 오류 키는 사용자 정책을 구현하기 위한 기술 계약 선택이며 사용자가 직접 지정한 세부 스키마로 간주하지 않는다.
### 7.1 기존 공개 조회 계약 보정
| Method / path | 인증 | 응답 |
|---|---|---|
| `GET /notice` | 기존 인증 정책 | `ApiResponse<{totalCount: Int, noticeList: NoticeItem[]}>` |
| `GET /notice/latest` | 기존 공개 정책 | `ApiResponse<NoticeItem \| null>` |
| `GET /api/v2/notices/latest` | 기존 공개 정책 | `ApiResponse<NoticeItem[]>`, 최대 3개 |
`NoticeItem = {id: Long, title: String, content: String, date: String}`를 유지한다. 페이지 응답의 모양과 파라미터 관례는 기존 계약대로 둔다. 세 응답은 모두 활성 공개 공지만 반환하며 동일한 유효 공개 시각 DESC, `id` DESC를 적용한다. `date`는 §5의 표시 규칙에 따른 KST `yyyy-MM-dd`다. 예약 공지는 경계 시각 이전에는 ADMIN 토큰을 포함한 공개 요청에도 제외한다.
### 7.2 관리자 목록
| Method / path | 인증·인가 | 요청 | 성공 응답 |
|---|---|---|---|
| `GET /api/v2/admin/notices` | ADMIN만 | `page: Int` (0부터), `size: Int` (Spring Pageable 관례) | `ApiResponse<{totalCount: Int, noticeList: AdminNoticeItem[]}>` |
`AdminNoticeItem = {id: Long, title: String, content: String, isActive: Boolean, isScheduled: Boolean, publishAt: String \| null, createdAt: String}`. `publishAt`과 `createdAt`은 KST `yyyy-MM-dd HH:mm:ss`; `publishAt=null`은 공개 시각이 지정되지 않은 즉시 공지다. `createdAt` 응답 표시를 초 단위 문자열로 포맷하는 것은 원본 DB 정밀도나 저장값을 절삭하는 것을 뜻하지 않는다. 목록은 활성 공지 전체를 공개 여부와 무관하게 페이지네이션하며 정렬은 공개 조회와 동일한 유효 공개 시각 DESC, id DESC다. 검색·추가 필터는 제공하지 않는다.
### 7.3 생성 및 수정 요청
| Method / path | 요청 계약 | 결과 |
|---|---|---|
| `POST /notice` | `{title: String, content: String, publishAt?: String \| null}` | 기존 성공 `data` 공지 ID 유지 |
| `PUT /notice` | `{id: Long, title?: String \| null, content?: String \| null, publishAt?: String \| null}` | 기존 성공 응답 유지 |
| 기존 DELETE 경로 | 변경 없음 | 기존 soft delete 및 응답 유지 |
요청 예: `{"title":"점검 안내","content":"본문","publishAt":"2026-11-01 09:00:00"}`는 KST 09:00 예약이다. 생성 요청에서 필드를 빼거나 null이면 즉시 공개다. 수정 요청에서 필드를 빼면 저장값 보존, null이면 예약 미공개 공지를 지금 공개로 전환, 미래 시각 문자열이면 예약 미공개 공지를 재예약한다. 수정 시 공개된 공지는 `publishAt` 필드를 바꾸는 모든 요청을 거부한다. title/content 수정은 기존 동작과 검증을 유지하고 시각-only 요청을 허용한다.
오류는 기존 `ApiResponse.error`와 `common.error.invalid_request` 정책을 사용한다. 오류 envelope/status를 전역 변경하지 않는다. 상세한 unauthorized/forbidden HTTP status를 신규 계약으로 약속하지 않고 현재 SecurityConfig, entry point, exception advice의 실제 응답을 통합 테스트한다.
## 8. 수용 기준과 요구사항 추적
| ID | 상태 | 요구사항 및 검증 가능한 수용 기준 | 계약 / 계획 Goal |
|---|---|---|---|
| NS-001 | 확정, 사용자 | 기존 쓰기 API ADMIN-only 권한과 생성/수정/삭제 응답을 보존한다. | §4, §7.3 / P1-T2, P1-T3, P3-GATE |
| NS-002 | 확정, 사용자 | 공개 조회는 활성 및 공개 경계 조건을 pagination 전 적용한다. ADMIN도 공개 API로 미공개 예약 공지를 받지 않는다. | §4, §5, §7.1 / P2-T1, P3-GATE |
| NS-003 | 확정, 사용자 | 미지정/null 생성은 즉시, 미래는 예약, 과거는 common invalid request로 거부한다. | §5, §7.3 / P1-T2 |
| NS-004 | 확정, 사용자 | 미공개 예약만 시각 변경/즉시 전환 허용. 기존 title/content 수정 유지, 시각-only 수정 허용. 이미 공개된 공지의 시각 변경은 거부. | §5, §6, §7.3 / P1-T3, P3-GATE |
| NS-005 | 확정, 사용자 | 공개는 `publishAt <= captured now`, scheduler 불필요, 중단 후 다음 조회에서 공개된다. | §5~6 / P2-T1 |
| NS-006 | 확정, 사용자 | 정렬은 유효 공개 시각 DESC, 동률 id DESC. 즉시 전환은 현재 시각 정렬. | §5, §7.1 / P2-T1, P3-GATE |
| NS-007 | 확정, 사용자 | 예약 공개 날짜는 예약 시각 KST 날짜, 즉시 생성/전환 날짜는 원래 등록 시각 KST 날짜. 관리자 시각 KST, DB UTC. | §5~7 / P1-T1, P2-T1 |
| NS-008 | 확정, 사용자 | ADMIN 목록은 활성 예약 미공개 공지도 포함하고 ADMIN 전용이다. | §4, §7.2 / P2-T1, P2-GATE |
| NS-009 | 확정, 사용자 후속 승인 | 서버·DB UTC, 새 publish_at은 초 단위 TIMESTAMP. 기존 created_at은 미변경. 신규 컬럼의 DB 왕복 및 UTC/KST 변환을 검증한다. 운영 적용과 연결 설정 실확인은 배포 전 수행한다. | §2, §6 / P1-T1, P1-T3, P2-GATE |
## 9. 구현 위치 근거 및 의존성
- 기존 쓰기와 public query: `src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNotice.kt`, `ServiceNoticeService.kt`, `ServiceServiceNoticeRepository.kt`, `CreateNoticeRequest.kt`, `UpdateNoticeRequest.kt`, `GetNoticeResponse.kt`.
- v2 최신 공지 service와 실제 HTTP regression: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt`.
- KST 엄격 파싱 및 JSON field presence 선례: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/application/SubBannerService.kt`.
- UTC now 주입 seam 선례: `SubBannerService.kt`와 `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt`.
- 운영 공지 DDL은 저장소 검색으로 발견되지 않았다. 실제 DB/DDL/JDBC 설정은 확인되지 않았고 별도 선행 확인이 필요하다.
## 10. 완료 기준
- [x] NS-001~NS-008의 각 계약·권한·상태 전이가 계획의 자동 또는 수동 검증으로 추적된다.
- [x] 공개 query 경계, role에 따른 가시성 누출 방지, 동률 정렬, 날짜 표시와 전환 정렬 구분을 실제 HTTP로 검증했다.
- [x] 사용자 확정 UTC·초 단위 신규 컬럼 계약과 모델·정규화 전략이 일치하고 로컬 DB 왕복 테스트를 통과했다. 운영 적용·연결 시간대 실확인은 배포 전 조건으로 별도 기록한다.
- [x] 저장 왕복 검증과 기존 full-field HTTP assertion을 유지했다. 기존 id-only fixture 기대는 승인된 공개 정렬에 맞게 변경했고 다른 응답·보안 assertion을 유지했다.
전체 회귀 `./gradlew --offline --no-daemon test ktlintCheck bootJar --continue`는 test 단계에서 20분 timeout으로 종료됐다. 전체 suite 성공은 미확인이다. 별도의 최종 focused 실행은 실제 HTTP·DB 122개, `ktlintCheck`, `bootJar`가 모두 exit 0이었고 독립 최종 리뷰는 승인됐다. Kotlin LSP는 미설치/기존 설치 거부로 compiler·lint를 사용했다. 운영 DB 적용과 배포는 수행하지 않았다.
## 11. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 |
|---|---|---|---|---|
| 2026-10-30 | NS-D1 | 사용자 확정 | 공개 정책, 권한, 정렬, KST 날짜 표시, 경계 공개, UI 제외는 승인 요구사항을 그대로 따른다. | 이번 작업의 승인 정책 |
| 2026-10-30 | NS-D2 | 기술 결정 | 예약 상태는 nullable `publishAt` + `isScheduled`로 표현하고 공개 전환 시 `publishAt`에 서버 now를 보존한다. | 전환 정렬과 표시 날짜 정책을 분리하며 backfill을 피함 |
| 2026-10-30 | NS-D3 | 기술 결정 | 관리자 시각 문자열은 엄격한 KST `yyyy-MM-dd HH:mm:ss`; 오류 키는 기존 `common.error.invalid_request`다. | 기존 KST 입력/presence 구현 선례, 공통 예외 정책 |
| 2026-10-30 | NS-D4 | 미확인 차단 | 기존 및 신규 timestamp 실제 정밀도와 DB/JDBC timezone은 운영 DDL 확인 전 확정하지 않는다. | 공지 DDL이 저장소에 없고 시간 정밀도 가이드가 사전 확인을 요구함 |
| 2026-10-30 | NS-D5 | 사용자 승인, NS-D4 정정 | 서버 UTC·DB UTC 확인. 새 publish_at은 초 단위 TIMESTAMP로 독립 정의하고 기존 created_at은 변경하지 않는다. 기존 DDL 미확인은 구현 차단에서 배포 전 확인으로 이동한다. | 사용자: "서버 UTC, DB UTC로 되어 있다. publish_at은 네가 말한대로 처리" |