Test #463
@@ -0,0 +1,357 @@
|
|||||||
|
# 공지사항 예약 공개 구현 계획
|
||||||
|
|
||||||
|
> 실행자: 아래 Goal을 순서대로 하나씩 실행하고 각 체크박스와 실제 검증 결과를 누적한다. 구현 Task는 RED → RED 확인 → GREEN → GREEN 확인 → REFACTOR를 따른다. 사용자 후속 요청으로 SQL/source/test 구현이 승인됐다.
|
||||||
|
|
||||||
|
**Goal:** ADMIN이 예약 공지를 안전하게 관리하고 모든 공개 consumer가 공개 시점·정렬·날짜 계약을 지킨다.
|
||||||
|
|
||||||
|
**Architecture:** 현재 notice domain/repository와 ServiceNoticeController를 확장하고 v2 ADMIN 목록 조립 endpoint를 추가한다. 공개 query는 역할과 무관한 단일 visibility predicate를 사용하며 reservation write는 기존 notice row의 pessimistic lock에서 검증한다.
|
||||||
|
|
||||||
|
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Spring Data JPA, QueryDSL, MySQL, 기존 H2/JUnit 5 테스트 구성. 신규 dependency 없음.
|
||||||
|
|
||||||
|
**Spec:** [prd.md](prd.md)
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- PRD §3~§7의 사용자 확정 권한·공개 정책·응답 호환성을 변경하지 않는다.
|
||||||
|
- P1-T1은 사용자 확인(서버 UTC·DB UTC·신규 publish_at 초 단위 TIMESTAMP)으로 완료한다. 기존 created_at 정밀도 복제·변경은 하지 않는다.
|
||||||
|
- 운영 rollout은 additive schema → 전체 read-aware app 교체 → 예약 쓰기/admin 사용 순서이며 feature flag는 추가하지 않는다.
|
||||||
|
- DB/source/test 변경은 승인된 구현 Task에서 수행한다. 운영 DB 적용·접속 및 배포는 제외한다.
|
||||||
|
|
||||||
|
| 문서 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 상태 | 기능 구현·관련 검증·리뷰 완료, 전체 회귀 timeout으로 최종 Gate 보류 |
|
||||||
|
| 작성일 | 2026-10-30 |
|
||||||
|
| 요구사항 기준 | [prd.md](prd.md) |
|
||||||
|
| API 기준 | `prd.md` §7 |
|
||||||
|
| 현재 Phase | Phase 3, 전체 회귀 완료 증거 대기 |
|
||||||
|
| 현재 활성 Goal | 없음, `P3-GATE` blocked |
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
관리자는 활성 공지 예약을 안전하게 관리하고, 공개 클라이언트는 공개 경계에 도달한 공지만 승인된 순서와 날짜로 조회한다.
|
||||||
|
|
||||||
|
## 현재 상태 및 실행 규칙
|
||||||
|
|
||||||
|
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 조건 |
|
||||||
|
|---:|---|---:|---|---|
|
||||||
|
| 1. 저장 계약과 쓰기 | 완료 | 3/3 | 없음 | 사용자 승인 계약·104개 회귀·단계 리뷰 통과, 운영 적용은 별도 |
|
||||||
|
| 2. 공개 조회와 관리자 목록 | 완료 | 1/1 | 없음 | 관련 HTTP·DB 122개 및 최종 리뷰 승인 |
|
||||||
|
| 3. 통합 회귀 (Gate only) | blocked | 해당 없음 | `P3-GATE` | 전체 test 20분 timeout, 전체 회귀 성공 미확인 |
|
||||||
|
|
||||||
|
- 동시에 하나의 미완료 Goal만 실행한다. 아래 순서대로 앞 Goal의 완료 증거를 남긴 뒤 다음으로 진행한다.
|
||||||
|
- 신규 컬럼 초 정밀도·서버/DB UTC는 사용자 승인값이다. 기존 컬럼의 물리 정밀도는 추정하지 않으며 운영 DB에 접속하지 않는다.
|
||||||
|
- 완료 증거가 기록된 항목만 체크한다. 미완료 테스트 명령과 수동 HTTP 확인은 계획값이다.
|
||||||
|
|
||||||
|
## 범위
|
||||||
|
|
||||||
|
### 포함
|
||||||
|
|
||||||
|
- §7 계약을 기준으로 기존 notice entity, 입력·service, query, 새 ADMIN 목록 API와 security를 변경한다.
|
||||||
|
- 예약 쓰기, 공개 필터·정렬·표시 날짜, 시각 정밀도와 동시 변경 보호를 테스트한다.
|
||||||
|
- 기존 공개 v1/v2 응답·권한·오류 assertion을 보존하며 실제 HTTP에서 검증한다.
|
||||||
|
|
||||||
|
### 제외
|
||||||
|
|
||||||
|
- 관리자/클라이언트 UI, scheduler, catch-up 작업, cache, 신규 role/dependency, 검색/추가 필터, 복원, bulk schedule.
|
||||||
|
- 운영 DDL 자동 적용/실제 DB 접근, BaseEntity/global timezone 리팩터링, 과거 레코드 backfill.
|
||||||
|
|
||||||
|
## 기술 제약과 파일 책임 지도
|
||||||
|
|
||||||
|
| 책임 | 현재 파일 또는 계획 파일 |
|
||||||
|
|---|---|
|
||||||
|
| 상태/기존 쓰기와 입력 | `src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNotice.kt`, `ServiceNoticeService.kt`, `CreateNoticeRequest.kt`, `UpdateNoticeRequest.kt` |
|
||||||
|
| 기존 쓰기 controller boundary | `src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeController.kt` |
|
||||||
|
| 공개 query 및 ADMIN query | `src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceServiceNoticeRepository.kt` |
|
||||||
|
| admin query projection | 신규 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeItem.kt` (`@QueryProjection`, createdAt/publishAt KST formatted `AdminNoticeItem` 최소 projection). 기존 query 구현 위치는 유지하고 신규 API DTO는 v2에 둔다. |
|
||||||
|
| 기존 public latest 조립 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt` |
|
||||||
|
| ADMIN 목록 endpoint/application/DTO | 새 파일 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/adapter/in/web/AdminNoticeController.kt`, `application/AdminNoticeService.kt`, `dto/AdminNoticeResponse.kt` (추가 DTO는 필요 최소로 구현) |
|
||||||
|
| 보안 | `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt` |
|
||||||
|
| admin response DTO | 신규 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeResponse.kt`; `{totalCount: Int, noticeList: List<AdminNoticeItem>}` wrapper와 item 필드만 정의 |
|
||||||
|
| 통합 HTTP 회귀 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt` |
|
||||||
|
| 쓰기/admin HTTP 통합 | 신규 `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/AdminNoticeControllerIntegrationTest.kt` |
|
||||||
|
| query/DB 회귀 | 신규 `src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeQueryRepositoryTest.kt` |
|
||||||
|
| entity 회귀 | 신규 `src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeTest.kt` |
|
||||||
|
| 홈 latest 연결 회귀 | 신규 `src/test/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeServiceIntegrationTest.kt`; production linkage는 `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeService.kt`의 `noticeService.getLatestNotice()`다. 기존 `api/home/HomeServiceTest.kt`는 다른 서비스를 검사하므로 수정하지 않는다. |
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
- 경계와 role 격리: 예약 Nov 1, fixed now Oct 30; 익명·일반·ADMIN의 public endpoints 모두 미공개 제외, boundary exact instant 포함 (`LatestNoticeControllerIntegrationTest`).
|
||||||
|
- 제한 적용 순서와 동률: eligible 4건 뒤 더 최신 id의 미공개 예약들을 추가해도 top 3가 유지되고, 같은 publishAt이면 id DESC (`ServiceNoticeQueryRepositoryTest`).
|
||||||
|
- 시간 캡처와 날짜 분리: count/list가 nowProvider를 한 번 호출; scheduled→immediate가 KST 자정을 넘어도 date는 original createdAt, 정렬은 conversion instant (`ServiceNoticeQueryRepositoryTest`, `AdminNoticeControllerIntegrationTest`).
|
||||||
|
- 기존 소비자 연결: v1 object/null과 홈 `latestNotice`가 같은 filtered `getLatestNotice()`를 사용하고 기존 full-field assertion 유지 (`LatestNoticeControllerIntegrationTest`, `AudioContentMainTabHomeServiceIntegrationTest`).
|
||||||
|
- 경계 입력/DB 정밀도/동시 수정: non-string/non-null publishAt 거부, exact DB round-trip nanos 0/123456789/999999999, lock 대기 중 publishAt 경과 시 update 거부, schedule→immediate와 competing re-reserve 결과가 순차화됨 (`AdminNoticeControllerIntegrationTest`).
|
||||||
|
|
||||||
|
실제 기존 스키마는 미조회다. `docs/20261030_공지사항예약공개/alter-service-notice-publication.sql`에 사용자 승인된 초 단위 신규 컬럼만 추가한다. 기존 created_at은 변경하지 않는다. 배포 순서는 additive schema 및 연결 UTC 확인 → 공개 query filter가 포함된 새 앱 버전으로 전체 instance 교체 → 운영 조정으로 예약 쓰기/admin 사용 활성화다. 이전 앱 instance가 남아 있는 동안 예약을 쓰면 기존 공개 reader가 이를 노출할 수 있으므로 rollout 순서와 관리자 사용 통제로 쓰기를 막는다.
|
||||||
|
|
||||||
|
## 전역 API·데이터 인터페이스
|
||||||
|
|
||||||
|
구현 기준은 `prd.md` §7이며 이 계획은 별도 API contract 파일을 만들지 않는다.
|
||||||
|
|
||||||
|
- 공개 응답 `NoticeItem(id: Long, title: String, content: String, date: String)` 및 v1 object-or-null, v2 최대 3개 array, 목록의 `totalCount/noticeList` envelope를 보존한다.
|
||||||
|
- ADMIN `GET /api/v2/admin/notices?page={0-based}&size={pageSize}`는 ADMIN 전용이며 `ApiResponse<{totalCount: Int, noticeList: AdminNoticeItem[]}>`를 반환한다. item 필드: `id`, `title`, `content`, `isActive`, `isScheduled`, `publishAt`, `createdAt`; 시각은 KST `yyyy-MM-dd HH:mm:ss`, `publishAt` nullable.
|
||||||
|
- `POST /notice`: 기존 `{title,content}` + optional nullable `publishAt`; 생략/null 즉시, 미래 문자열 예약. 기존 생성 ID 응답 보존.
|
||||||
|
- `PUT /notice`: 기존 `id,title?,content?` + optional nullable `publishAt`; 필드 부재 보존, explicit null 즉시 전환, 미래 시각 재예약. 공개된 공지의 publishAt 변경 거부. 기존 수정 응답 보존.
|
||||||
|
- 오류는 기존 `SodaException(common.error.invalid_request)`와 `ApiResponse` 처리. 보안 HTTP status는 현재 SecurityConfig/handler를 따르며 통합 테스트로 확인한다.
|
||||||
|
- 저장 제안: `publishAt: LocalDateTime?` UTC와 `isScheduled: Boolean=false`. 실제 타입·물리 정밀도는 DDL 확인 후 확정한다. 새 `publishAt`의 업무/API 계약은 초 단위이며 즉시 전환 now를 저장 경계에서 한 번만 초 절삭해 엔티티/응답에 같은 값을 사용한다. 물리 컬럼이 `(3)/(6)`이어도 이 새 필드의 나노초는 0을 유지한다. 기존 `createdAt`은 절삭하지 않는다. 예약의 미래 여부와 공개 경계 판정에는 캡처한 원본 UTC now를 사용한다.
|
||||||
|
- 공개 repository 계약은 `getNoticeTotalCount(now: LocalDateTime): Int`, `getNoticeList(pageable: Pageable, now: LocalDateTime): List<NoticeItem>`, `getLatestNotice(now: LocalDateTime): NoticeItem?`다. `ServiceNoticeService.getNoticeList(pageable, timezone)` 및 `getLatestNotice()` 외부 진입 시그니처는 유지하고 각 메서드에서 now를 한 번 캡처한다. count/list는 같은 캡처값을 전달한다. v2 `LatestNoticeService.getLatestNotices()`도 내부 nowProvider로 한 번 캡처해 repo에 전달한다.
|
||||||
|
- 공개 조건은 `isActive AND (publishAt IS NULL OR publishAt <= now)`, 순서는 `COALESCE(publishAt, createdAt) DESC, id DESC`. 표시 날짜 source는 scheduled일 때 `publishAt`, 그 외 `createdAt`다.
|
||||||
|
- `ServiceServiceNoticeRepository`에 `@Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select notice from ServiceNotice notice where notice.id = :id") fun findByIdForUpdate(@Param("id") id: Long): ServiceNotice?`를 추가한다. `ServiceNoticeService.update(request: UpdateNoticeRequest, publishAtPresent: Boolean = false)`는 transaction에서 이 lock으로 row를 읽고 난 뒤 now를 캡처해 검사한다. 공개된 공지에 명시된 publishAt 변경은 거부한다. 동시 요청의 선형화 지점은 잠금 후 eligibility 검사이며 시간 경계가 이후 통과하는 것은 query-time 공개 정책에 맡긴다.
|
||||||
|
- `AdminNoticeItem` projection은 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeItem.kt` 한 곳에 두고, repository admin methods는 `getAdminNoticeTotalCount(): Int`, `getAdminNoticeList(pageable: Pageable): List<AdminNoticeItem>`다. `AdminNoticeService.getNoticeList(pageable: Pageable): AdminNoticeResponse`와 ADMIN controller output은 이 응답 타입을 사용한다. 별도 중복 projection은 만들지 않는다.
|
||||||
|
|
||||||
|
## Phase 1: schema 확인 및 예약 쓰기
|
||||||
|
|
||||||
|
**Phase 결과:** 안전한 저장 정밀도를 확인하고 생성·예약 변경·즉시 전환이 잘못된 공개 상태를 만들지 않는다.
|
||||||
|
|
||||||
|
**선행조건:** 없음. P1-T1 차단 해제 전 구현 Task를 시작하지 않는다.
|
||||||
|
|
||||||
|
**Phase 완료 조건:** `P1-T1`~`P1-T3`와 `P1-GATE` 완료.
|
||||||
|
|
||||||
|
- [x] **Task 1.1: 사용자 UTC·신규 컬럼 계약 확인 (`P1-T1`)**
|
||||||
|
- [x] **Task 1.2: 모델·생성·시각 검증 (`P1-T2`)**
|
||||||
|
- [x] **Task 1.3: 수정과 경합 방지 (`P1-T3`)**
|
||||||
|
- [x] **Phase 1 Gate (`P1-GATE`)**
|
||||||
|
|
||||||
|
### Task 1.1 운영 schema와 시간 계약 확인
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T1`:** 사용자 확인을 근거로 서버·DB UTC와 신규 컬럼 초 정밀도를 확정한다. 기존의 운영 DDL 선행 조건은 NS-D5로 정정됐다.
|
||||||
|
|
||||||
|
- **시작 조건:** 사용자 후속 승인 및 PRD NS-D5.
|
||||||
|
- **완료 증거:** 사용자 서버 UTC·DB UTC 확인, 새 publish_at 초 단위 TIMESTAMP 승인과 기존 created_at 미변경 결정 기록.
|
||||||
|
- **범위 밖:** 운영 DB 접속/변경, 기존 물리 컬럼 정의를 확인한 것으로 주장하기.
|
||||||
|
- **Files:** 확인 `src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNotice.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/common/BaseEntity.kt`; 기록은 이 문서와 PRD NS-D5.
|
||||||
|
- **Interfaces:** 신규 MySQL `publish_at TIMESTAMP NULL DEFAULT NULL`, H2 검증 `TIMESTAMP(0)`; 기존 created_at 미변경. `is_scheduled BOOLEAN NOT NULL DEFAULT FALSE`, 두 컬럼 COMMENT 및 기존 NULL/FALSE 유지.
|
||||||
|
- **TDD 예외 사유:** 사용자 확인과 계약 결정 기록이며 동작 코드 변경이 아니다.
|
||||||
|
- **대체 검증 방법:** 엔티티·상속 시각 필드 재확인 및 사용자 승인 내용 대조.
|
||||||
|
- [x] 서버·DB UTC 사용자 확인을 기록했다.
|
||||||
|
- [x] 신규 publish_at은 초 정밀도, 기존 created_at은 변경하지 않기로 확정했다.
|
||||||
|
- [x] 운영 연결 설정 실확인과 DDL 적용은 배포 전 확인으로 분리했다.
|
||||||
|
|
||||||
|
### Task 1.2 모델·생성·시각 검증
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T2`:** schema gate 결과에 맞춘 SQL/entity 상태 모델로 즉시 생성과 미래 예약을 정확히 저장한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T1` 완료 및 정밀도/timezone 계약 기록.
|
||||||
|
- **완료 증거:** 생성 API에서 생략/null 즉시, 미래 KST 예약 UTC 저장, 과거/현재·strict format 오류, entity 상태, actual DB flush/clear/reload 정밀도 검증 통과.
|
||||||
|
- **범위 밖:** 수정·관리자 목록·공개 필터.
|
||||||
|
- **Files:** Create `docs/20261030_공지사항예약공개/alter-service-notice-publication.sql`; Modify `src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNotice.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeController.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/notice/CreateNoticeRequest.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeService.kt`; Create/Test `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/AdminNoticeControllerIntegrationTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeQueryRepositoryTest.kt`. Entity 기본값은 unit test, 실제 DB 왕복은 repository test가 담당한다.
|
||||||
|
- **Interfaces:** Consumes: `P1-T1` 정밀도 계약. Create controller signature `createNotice(@RequestBody fields: JsonNode)` accepts object with optional `publishAt` string/null only, maps to existing `CreateNoticeRequest`, then calls existing `service.save(request)`. Produces: `publishAt` nullable UTC, `isScheduled` default false; creation is scheduled=false & publishAt null or scheduled=true & validated future UTC.
|
||||||
|
- **TDD:**
|
||||||
|
- [x] **RED:** entity test에서 기본값 `isScheduled=false,publishAt=null` assertion 및 HTTP create assertion 작성. publishAt 미입력/null은 즉시; 미래 KST `2026-11-01 09:00:00`은 UTC `2026-11-01T00:00:00`; 과거/현재·strict invalid calendar·non-string/non-null JSON은 `common.error.invalid_request` assertion.
|
||||||
|
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.AdminNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeQueryRepositoryTest'`에서 미구현 assertion 실패 확인.
|
||||||
|
- [x] **GREEN:** SQL에 초 단위 `publish_at TIMESTAMP NULL DEFAULT NULL COMMENT '예약 공개 시각(UTC)'` 및 `is_scheduled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '예약 공개 여부'`를 추가한다. 기존 row는 `publish_at=NULL,is_scheduled=FALSE`로 유지하고 backfill 금지. Create JSON object boundary도 `ServiceNoticeController`에서 `publishAt` string/null만 허용하고 `ObjectMapper`로 `CreateNoticeRequest` 변환 후 service 호출하도록 한다. 이어서 최소 entity·validation 구현. Parser pattern은 `uuuu-MM-dd HH:mm:ss` + `ResolverStyle.STRICT`, wire 표기는 `yyyy-MM-dd HH:mm:ss`; `nowProvider`는 SubBannerService 패턴으로 필요한 service에만 주입.
|
||||||
|
- [x] **GREEN 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.AdminNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeQueryRepositoryTest'` 통과. `ServiceNoticeQueryRepositoryTest.shouldPersistAndReloadScheduledPublishAt`는 KST `2026-11-01 09:00:00` 예약을 실제 DB flush/clear/reload한 값이 UTC `2026-11-01T00:00:00`과 정확히 같음을 검증한다. 나노초 now 입력의 즉시 전환 검증은 해당 기능을 만드는 P1-T3가 소유한다.
|
||||||
|
- [x] **REFACTOR:** 이번 Task 중복만 정리하고 같은 focused test, 직접 영향 notice regression, ktlint 결과 기록.
|
||||||
|
- [x] **SQL metadata check:** SQL은 `ALTER TABLE service_notice`에 nullable `publish_at`과 `is_scheduled NOT NULL DEFAULT FALSE`를 추가하고 두 컬럼에 COMMENT를 포함해야 한다. 기존 rows는 NULL/FALSE 유지, backfill 및 live 실행은 없어야 한다. SQL 정밀도는 P1-T1 evidence와 일치해야 한다.
|
||||||
|
|
||||||
|
### Task 1.3 수정과 경합 방지
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T3`:** 예약 미공개 공지의 시각 변경과 즉시 전환을 지원하며 공개된 공지를 다시 감추지 못하게 한다.
|
||||||
|
|
||||||
|
- **실행 보완:** 기존 delete도 같은 `findByIdForUpdate`로 읽는다. 삭제/예약 변경이 같은 엔티티를 오래된 값으로 flush하여 다른 필드를 덮거나 삭제를 되돌리는 경합을 막기 위한 최소 변경이며, 기존 삭제 API·응답·soft delete 정책은 유지한다. 삭제와 즉시 공개/재예약 경합의 최종 `isActive=false` 및 최신 예약 필드 보존을 테스트한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T2` 완료 및 생성 contract 확정.
|
||||||
|
- **완료 증거:** update 생략/명시 null/future 및 title/content 기존 규칙, schedule-only update, 공개 경계 거부, 동시 공개 race 보호 테스트 통과.
|
||||||
|
- **범위 밖:** 제목/본문 정책 변경, 복원, 사용자 제공 시각을 기준으로 권한 판정.
|
||||||
|
- **Files:** Modify `src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeController.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/notice/UpdateNoticeRequest.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeService.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceServiceNoticeRepository.kt`; Test `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/AdminNoticeControllerIntegrationTest.kt`, 신규 `src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeConcurrencyIntegrationTest.kt`. HTTP와 DB 정밀도는 전자, 독립 transaction의 결정적 row-lock 경합은 후자가 담당한다.
|
||||||
|
- **Interfaces:** Consumes: P1-T2 create model. Controller signature `updateNotice(@RequestBody fields: JsonNode)` checks `fields.isObject` and if present accepts `publishAt` only when `isTextual || isNull`; maps `objectMapper.treeToValue(fields, UpdateNoticeRequest::class.java)` then calls `service.update(request, publishAtPresent = fields.has("publishAt"))`. Service signature `update(request: UpdateNoticeRequest, publishAtPresent: Boolean = false)` retains existing callers. Produces: repository `findByIdForUpdate(id: Long): ServiceNotice?` with `@Lock(PESSIMISTIC_WRITE)`; locked/rechecked update, reservation-only time edits, immediate conversion `publishAt=serverNow,isScheduled=false`.
|
||||||
|
- **TDD:**
|
||||||
|
- [x] **RED:** update HTTP tests pin `shouldPreserveScheduleWhenPublishAtIsOmitted`, `shouldConvertScheduledNoticeToImmediateWhenPublishAtIsNull`, `shouldRescheduleUnpublishedNotice`, `shouldAllowScheduleOnlyUpdate`, `shouldRejectRescheduleAfterPublishBoundary`, `shouldRejectInvalidUpdatePublishAtWithoutPartialChanges`, and title/content-only behavior. Existing blank/null validation expectations stay unchanged. `shouldRejectUpdateWhenLockWaitCrossesPublishAt` and competing `shouldSerializeImmediateConversionAndReschedule` exercise lock scheduling. 즉시 전환 now에 nanos 0/123456789/999999999와 UTC 23:59:59 경계를 주입하고 DB flush/clear/reload한 publishAt의 나노초가 0이며 다음 초·날짜로 반올림되지 않는지 검증한다. 저장값을 assertion에서 다시 절삭해 비교하지 않는다.
|
||||||
|
- [x] **RED 확인:** `./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.AdminNoticeControllerIntegrationTest'` exit 1, 80개 중 41개 assertion 실패. 상태 전이 최소 구현 뒤 이 class와 `--tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeConcurrencyIntegrationTest'` 실행에서 HTTP 80개 통과, 잠금 부재로 동시성 9개 실패를 확인했다. compiler 오류를 RED로 계산하지 않았다.
|
||||||
|
- [x] **GREEN:** transaction 안에서 `findByIdForUpdate(id)` row lock을 수정 필드 변경 전에 얻고 fresh now를 잠금 뒤 한 번 캡처해 active·미공개 예약 eligibility를 재검사한다. 원본 now로 미래 여부를 비교하고 즉시 전환 저장값만 `now.truncatedTo(SECONDS)`로 만든다. title/content-only update와 delete도 같은 lock을 쓴다.
|
||||||
|
- [x] **GREEN 확인:** `./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeConcurrencyIntegrationTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.AdminNoticeControllerIntegrationTest'` exit 0, 89개 통과. H2 `INFORMATION_SCHEMA.SESSIONS.BLOCKER_ID`와 실행 중 SELECT FOR UPDATE를 확인한 뒤 old `publishAt` 경계로 now를 옮겨 거부됨을 검증했다. 즉시 전환→재예약, 제목↔재예약, 삭제↔즉시 전환/재예약 경합의 최종 DB를 재조회했다. 선형화는 lock 이후 eligibility check이며 wall-clock boundary 통과를 차단한다고 주장하지 않는다.
|
||||||
|
- [x] **REFACTOR:** 추가한 테스트의 출력 포맷만 정리한 뒤 아래 Phase 1 명령으로 직접 영향 notice 5개 class 104개 및 ktlintCheck 통과. 기존 테스트 삭제·완화·skip은 없다.
|
||||||
|
|
||||||
|
#### Phase 1 Gate
|
||||||
|
|
||||||
|
**Goal 실행 `P1-GATE`:** 시간 저장 계약, 생성·수정 상태 전이를 판정한다.
|
||||||
|
|
||||||
|
- **시작 조건:** 모든 Phase 1 task 완료, schema gate 근거 포함.
|
||||||
|
- **완료 증거:** P1 focused tests와 정밀도 대조표 통과, Progress에 실행 결과 및 차단 여부 기록.
|
||||||
|
- **범위 밖:** schema 확인 없이 구현 진행 또는 실패 test 완화.
|
||||||
|
- **TDD 예외 사유:** 통합 검증 gate이며 자체 production 기능을 추가하지 않는다.
|
||||||
|
- **대체 검증 방법:** 해당 focused test 명령과 저장소 문서/DDL 대조표 확인.
|
||||||
|
- [x] `./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeConcurrencyIntegrationTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.AdminNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeTest' --tests 'kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest' ktlintCheck` exit 0, 104개 통과.
|
||||||
|
- [x] 사용자 승인 신규 MySQL TIMESTAMP 초 단위 계약과 H2 fixture TIMESTAMP(0)이 일치하고 exact DB 왕복 입력 nanos 0/123456789/999999999 및 UTC 23:59:59 결과를 Progress와 stage report에 기록했다. 기존 created_at의 물리 정밀도를 추정·변경하지 않았다.
|
||||||
|
- [x] 실제 JWT/HTTP에서 익명 write 401, 일반 회원의 기존 HTTP 200 권한 오류, ADMIN 생성 ID 및 PUT/DELETE `data={}` 응답과 soft delete를 보존했다.
|
||||||
|
- **후속 검증 대기:** 관리자 GET 응답의 publishAt을 KST→UTC 복원하여 DB와 비교하는 검증은 P2 endpoint 구현 후 수행한다. 이 단계의 DB 왕복 통과를 응답 시각 비교 통과로 대체하지 않는다. 상단 전체 Phase 상태와 Gate 완료 판정은 본 단계에서 변경하지 않는다.
|
||||||
|
|
||||||
|
## Phase 2: 공개 조회와 관리자 목록
|
||||||
|
|
||||||
|
**Phase 결과:** 공개 query는 예약 미공개를 역할 관계없이 차단하고 ADMIN의 별도 목록은 활성 예약을 모두 보여 준다.
|
||||||
|
|
||||||
|
**선행조건:** `P1-GATE` 완료, 외부 schema gate 충족.
|
||||||
|
|
||||||
|
- [x] **Task 2.1: 공유 query, 관리자 endpoint, 공개 HTTP 회귀 (`P2-T1`)**
|
||||||
|
- [x] **Phase 2 Gate (`P2-GATE`)**
|
||||||
|
|
||||||
|
### Task 2.1 공유 query, 관리자 endpoint, 공개 HTTP 회귀
|
||||||
|
|
||||||
|
**Goal 실행 `P2-T1`:** 모든 notice 조회가 승인된 가시성·정렬·날짜 정책을 따르고 ADMIN 목록은 예약을 포함한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-GATE` 완료, PRD §7 계약 확인.
|
||||||
|
- **완료 증거:** 공개 count/list/v1 latest/v2 latest와 관리자 페이지의 query/HTTP assertion 통과, auth matrix 확인.
|
||||||
|
- **범위 밖:** UI, 공개 역할 우회, 추가 검색/filter, 스케줄러.
|
||||||
|
- **Files:** Modify `src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceServiceNoticeRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeService.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/AdminNoticeControllerIntegrationTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeQueryRepositoryTest.kt`; Create `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeItem.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/adapter/in/web/AdminNoticeController.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/application/AdminNoticeService.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeResponse.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeServiceIntegrationTest.kt`.
|
||||||
|
- **Interfaces:** Consumes: P1 model/write contract. Preserve `ServiceNoticeService.getNoticeList(pageable: Pageable, timezone: String)` and `getLatestNotice()` public signatures. Repository methods: `getNoticeTotalCount(now: LocalDateTime): Int`, `getNoticeList(pageable: Pageable, now: LocalDateTime): List<NoticeItem>`, `getLatestNotice(now: LocalDateTime): NoticeItem?`; admin methods `getAdminNoticeTotalCount(): Int`, `getAdminNoticeList(pageable: Pageable): List<AdminNoticeItem>`. Each public service captures now once; list and count receive same now. `AdminNoticeController.getNotices(pageable: Pageable): ApiResponse<AdminNoticeResponse>` delegates to `AdminNoticeService.getNoticeList(pageable): AdminNoticeResponse`, whose exact output is `{totalCount: Int, noticeList: List<AdminNoticeItem>}`.
|
||||||
|
- **TDD:**
|
||||||
|
- [x] **RED:** repository/HTTP tests pin named cases: `shouldExcludeScheduledNoticeBeforeBoundaryForAdminOnPublicRoutes` uses Nov 1 scheduled + fixed Oct 30 now for anonymous, member, ADMIN; `shouldIncludeNoticeAtExactPublishBoundary`; `shouldApplyPublicFilterBeforeLatestLimit` adds newer unpublished ids beyond 3 eligible items and still expects eligible top 3; `shouldOrderEqualPublishTimesByIdDescending` uses two distinct rows with equal `publishAt`; `shouldCaptureNowOnceForCountAndList` expects provider call count 1 and identical now arguments; `shouldUseRegistrationDateButConversionTimeForImmediateScheduleConversion` has registration Oct 31 23:50 KST, conversion Nov 1 00:10 KST, competitor effective time Nov 1 00:05 KST, and expects conversion notice first in order but its displayed date `2026-10-31`. Assert v1 latest object/null and preserve every full-field assertion. Admin HTTP verifies active schedule inclusion/inactive exclusion. Rewrite existing id-only order fixtures while retaining all full-field/auth/format assertions.
|
||||||
|
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.AdminNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.content.main.tab.home.AudioContentMainTabHomeServiceIntegrationTest'`에서 요구 동작 부재로 실패 확인.
|
||||||
|
- [x] **GREEN:** count/list/latest에 동일 가시성 predicate, effective time/id ordering, 표시 날짜 source 적용. `ServiceNoticeService` public methods preserve signatures and capture now once; v2 latest captures once and passes to repository. Admin projection uses the exact `AdminNoticeItem.kt` path above. Add separate ADMIN-only route authorization without role-dependent public visibility.
|
||||||
|
- [x] **GREEN 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.AdminNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.content.main.tab.home.AudioContentMainTabHomeServiceIntegrationTest'` 통과. 익명/비관리자/ADMIN으로 API를 호출해 익명 정상 공개 조회, 모든 역할에서 예약 비공개, ADMIN 목록만 예약 포함을 확인. P1-T3의 세 나노초 입력으로 생성한 즉시 전환 DB publishAt과 관리자 GET의 publishAt을 KST에서 UTC로 복원한 값을 정확히 비교한다.
|
||||||
|
- [x] **REFACTOR:** 새 코드에서 생긴 중복만 정리하고 위 네 focused test, ktlintCheck 및 영향 범위 notice regression 결과 기록.
|
||||||
|
- [x] **Compatibility check:** `AudioContentMainTabHomeService.fetchData` at `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeService.kt` already calls `ServiceNoticeService.getLatestNotice()`. Keep that entrypoint filtered; verify `AudioContentMainTabHomeServiceIntegrationTest` asserts `latestNotice` uses expected visible item and excludes an unpublished scheduled notice. Preserve `/notice/latest` object/null and current complete response fields.
|
||||||
|
- [x] **Admin timestamp display check:** `createdAt` is formatted in KST `yyyy-MM-dd HH:mm:ss` for output only. Preserve the source DB value/precision; do not change existing `created_at` DDL or truncate stored values to seconds.
|
||||||
|
- **실행 증거:** 아래 Progress의 P2 실행 기록 및 소유 임시 루트 `stage-report.md`에 실제 명령·exit·HTTP·DB 결과를 기록했다. SecurityConfig 수정 없이 기존 authenticated 정책과 새 controller의 ADMIN method guard를 사용했다.
|
||||||
|
|
||||||
|
#### Phase 2 Gate
|
||||||
|
|
||||||
|
**Goal 실행 `P2-GATE`:** 공개 query와 ADMIN 조회가 서버 API 표면에서 계약대로 동작하는지 판정한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P2-T1` 완료.
|
||||||
|
- **완료 증거:** 최신 full-field HTTP, auth, 페이지네이션 전 filter, 정렬/표시 날짜 확인.
|
||||||
|
- **범위 밖:** frontend 및 운영배포/실 DB 수행.
|
||||||
|
- **TDD 예외 사유:** 구현 Task 결과 종합 확인.
|
||||||
|
- **대체 검증 방법:** 아래 HTTP 시나리오의 실제 응답 상태/envelope와 focused integration 결과를 기록한다.
|
||||||
|
- [x] `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.AdminNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.content.main.tab.home.AudioContentMainTabHomeServiceIntegrationTest'` 성공.
|
||||||
|
- [x] 실행 서버에서 anonymous `GET /api/v2/notices/latest`, non-admin/anonymous `GET /api/v2/admin/notices?page=0&size=20`, ADMIN 같은 관리자 GET의 실제 status와 ApiResponse/data fields 기록.
|
||||||
|
- [x] ADMIN 토큰을 붙인 `GET /notice`, `/notice/latest`, `/api/v2/notices/latest` 모두 boundary 이전 예약 공지를 제외하고 관리자 GET만 이를 반환함을 확인.
|
||||||
|
|
||||||
|
## Phase 3: 회귀와 최종 증거
|
||||||
|
|
||||||
|
**Phase 결과:** 예약 공개 변경이 기존 notice contract를 유지하고 완료 여부가 검증 기록과 함께 판정된다. 이 phase는 `P3-GATE` 하나뿐이며 별도 Task goal은 없다.
|
||||||
|
|
||||||
|
**선행조건:** `P2-GATE` 완료.
|
||||||
|
|
||||||
|
- [ ] **Phase 3 Gate (`P3-GATE`)**
|
||||||
|
|
||||||
|
**Goal 실행 `P3-GATE`:** 기존 public/admin notice 동작의 영향 범위를 검증하고 남은 schema/배포 의존성을 분명히 한다.
|
||||||
|
|
||||||
|
- **시작 조건:** Phase 1~2 완료 및 focused HTTP 검증 통과.
|
||||||
|
- **완료 증거:** 선택된 package regression, `ktlintCheck`, conditional full regression 결과와 미실행 근거를 기록. DB 운영 확인 및 수동 migration/rollout은 별도 승인·절차가 완료되기 전 미완료로 남긴다.
|
||||||
|
- **범위 밖:** unrelated test 수정, 운영 migration 실행, 배포 선언.
|
||||||
|
- **Files:** Verify only `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/AdminNoticeControllerIntegrationTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeQueryRepositoryTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeServiceIntegrationTest.kt`; 변경 필요한 실패가 생기면 이 gate에서 범위를 넓히지 않고 새 regression task를 추가.
|
||||||
|
- **Interfaces:** Consumes: P1/P2 API와 data contract. Produces: 검증 결과/미확인 목록이며 API 계약 변경 없음.
|
||||||
|
- **TDD 예외 사유:** release-level 검증 gate이며 별도 기능 구현 없음.
|
||||||
|
- **대체 검증 방법:** focused commands, 직접 영향 notice package 회귀, lint, 실제 HTTP role matrix. 전체 test는 아래 조건에서만 실행한다.
|
||||||
|
- [x] 실제 실행: `./gradlew --offline --no-daemon test --tests 'kr.co.vividnext.sodalive.notice.*' --tests 'kr.co.vividnext.sodalive.v2.api.notice.*' --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.*' --tests 'kr.co.vividnext.sodalive.content.main.tab.home.AudioContentMainTabHomeServiceIntegrationTest' ktlintCheck bootJar --continue` exit 0, 4분 9초, 122개 통과.
|
||||||
|
- [x] 직접 영향 notice/home regression, `ktlintCheck`, `bootJar` 성공. `build/libs/sodalive-0.0.1-SNAPSHOT.jar` 생성 확인.
|
||||||
|
- [ ] 사용자 ULW 전체 suite 검증 요청에 따라 `./gradlew --offline --no-daemon test ktlintCheck bootJar --continue`를 실행했으나 test 단계 20분 timeout으로 미완료다. 전체 test 성공으로 표시하지 않는다. 장시간 정체 원인은 미확인이고 관련 코드 수정이나 test skip 없이 후속 확인 대상으로 남긴다.
|
||||||
|
- [x] 기존 v1 object/null, v2 최대 3개 array, 목록 응답 shape, create ID 및 update/delete 기존 success response assertion 유지 확인.
|
||||||
|
- [x] 독립 최종 리뷰 APPROVED. 요구사항을 위반하는 구체적 결함이 없다고 판정했으며 전체 suite 미완료와 운영 DB 미검증은 별도로 남겼다.
|
||||||
|
|
||||||
|
## 실행 순서
|
||||||
|
|
||||||
|
| 순서 | Goal | 선행조건 | 상태/차단 시 행동 |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| 1 | `P1-T1` | 없음 | schema 근거 없음이면 외부 조건 대기, 코드 구현 금지 |
|
||||||
|
| 2 | `P1-T2` | `P1-T1` | 저장 precision과 시간 contract 고정 |
|
||||||
|
| 3 | `P1-T3` | `P1-T2` | 상태 전이 및 race guard |
|
||||||
|
| 4 | `P1-GATE` | Phase 1 tasks | 실패 task로 회귀 추가 |
|
||||||
|
| 5 | `P2-T1` | `P1-GATE` | 공개/Admin query와 API 구현 |
|
||||||
|
| 6 | `P2-GATE` | `P2-T1` | 실제 HTTP auth matrix 수행 |
|
||||||
|
| 7 | `P3-GATE` | `P2-GATE` | 관련 test/lint 및 조건부 full regression 기록 |
|
||||||
|
|
||||||
|
## 변경 금지 및 중단 규칙
|
||||||
|
|
||||||
|
- 승인되지 않은 역할·UI·scheduler·복원·bulk 기능을 추가하지 않는다. 공개 API는 role-aware visibility bypass를 두지 않는다.
|
||||||
|
- 기존 ID-only test 기대는 승인된 새 공개 순서에 맞춰 fixture를 수정하되 response, 날짜 format, 권한 assertion은 지우거나 완화하지 않는다.
|
||||||
|
- 신규 TIMESTAMP 초 정밀도와 서버·DB UTC는 NS-D5 승인값을 사용한다. 기존 created_at 및 BaseEntity는 변경하지 않는다. 운영 직접 확인은 수행했다고 주장하지 않는다.
|
||||||
|
- 공통 security/error handling을 변경할 필요가 생기면 먼저 계획을 갱신하고 실제 기존 동작과 요구를 재검토한다.
|
||||||
|
- 검증 실패는 원인 test를 삭제/skip/허용오차로 감추지 않는다. scope 밖 issue는 기록만 한다.
|
||||||
|
- 커밋은 필수 단계가 아니며, 본 요청은 커밋을 승인하지 않는다.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
현재 문서 작성 시점에는 향후 구현을 실행하지 않았다. 각 Goal 완료 후 실제 실행 명령, 핵심 결과, 다음 행동을 아래에 누적한다. 미실행 검증을 성공으로 표시하지 않는다.
|
||||||
|
|
||||||
|
### P1-T1 1차 실행: 2026-10-30
|
||||||
|
|
||||||
|
- 상태: **blocked**, 운영 스키마·배포 시간대 자료 대기. 체크박스는 완료 처리하지 않는다.
|
||||||
|
- 무엇을: 구현 승인 후 저장소 SQL/DDL·관련 문서·시각 처리 코드와 설정의 비민감 항목을 재확인했다. 실제 `service_notice` 정의는 발견하지 못했다.
|
||||||
|
- 근거: `src/main/resources/application.yml:126`은 `ddl-auto: validate`이며, `src/main/kotlin/kr/co/vividnext/sodalive/common/BaseEntity.kt:31`은 `LocalDateTime.now()`로 등록 시각을 설정한다. 이 정보는 운영 `created_at` 타입·소수초 정밀도 또는 운영 JVM/JDBC/DB 시간대를 증명하지 않는다.
|
||||||
|
- 확인 방법: 저장소 SQL/DDL 후보와 `service_notice` 정의 검색, `serverTimezone`, `connectionTimeZone`, `hibernate.jdbc.time_zone`, `user.timezone`, `TimeZone.setDefault` 설정 검색. 운영 DB·네트워크에는 접속하지 않았다. 비밀값은 기록하지 않는다.
|
||||||
|
- 재개 조건·담당: 운영 DB/배포 담당자가 `SHOW CREATE TABLE service_notice` 결과, 같은 환경의 DB 세션/전역 시간대 및 운영 JVM/JDBC 시간대 설정을 제공하면 확인 결과를 기록하고 P1-T1을 재판정한다. JDBC URL 전체나 자격증명 대신 시간대 옵션만 받는다.
|
||||||
|
- 미실행: P1-T2 이후 소스·SQL·테스트 구현과 Gradle·HTTP 검증. 선행 근거 없이 스키마를 추정하지 않는 승인 계획을 따른다.
|
||||||
|
|
||||||
|
### P1-T2 실행: 2026-10-30
|
||||||
|
|
||||||
|
- 상태: **P1-T2 완료**. 생성·모델·additive SQL만 구현했다. P1-T3, 모든 Gate, 관리자 목록과 공개 query는 변경하거나 완료 처리하지 않았다.
|
||||||
|
- 증거 루트: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/notice-p1t2.lPSBOW`. 상세 명령·파일 목록은 `stage-report.md`, 실행 로그와 JUnit XML은 같은 디렉터리에 보관한다.
|
||||||
|
- 기준 검증: 운영 코드 변경 전 `./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest'` exit 0, 11개 통과 (`baseline.log`, `baseline-latest.xml`). 기존 테스트 assertion은 수정하지 않았다.
|
||||||
|
- RED: 신규 실제 JWT/HTTP 테스트 단독 실행 exit 1, 29개 중 27개 assertion 실패. `publishAt=42`가 HTTP 200 success=true와 생성 ID로 수락됐다 (`red-http.log`, `red-http.xml`). 컴파일 가능한 모델/DTO 필드와 typed assertion 추가 뒤 본 Task의 세 focused class 실행 exit 1, 38개 중 32개 assertion 실패. 예약 DB flush/clear/reload가 기대 UTC 대신 null, HTTP 예약 상태가 true 대신 false였다 (`red-focused.log`, `red-focused-db.xml`, `red-focused-http.xml`). 컴파일 오류를 RED로 계산하지 않았다. entity 기본값은 이 구조 단계에서 통과했다.
|
||||||
|
- GREEN: `./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.AdminNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeQueryRepositoryTest'` exit 0, 39개 통과 (`green-focused-retry.log`, `green-focused-*.xml`). 첫 GREEN 시도는 도구 120초 제한으로 중단됐으며 성공으로 계산하지 않았다. 해당 프로세스 종료를 확인한 뒤 PID·log·exit 파일을 남기는 소유 runner로 재실행했다.
|
||||||
|
- 실제 경계: 생략/null 즉시 생성과 생성 ID/envelope, 엄격한 KST 형식·달력·현재/과거·JSON 타입 거부, MySQL 상한 KST `2038-01-19 12:14:07` 수락 및 다음 초 거부, 익명 HTTP 401와 비ADMIN의 기존 HTTP 200 권한 오류, 제목/본문 공백 오류를 확인했다. `TIMESTAMP(0)`을 명시한 H2에서 KST `2026-11-01 09:00:00` 생성값을 flush/clear/reload하여 UTC `2026-11-01T00:00:00`, nano=0과 정확히 비교했다.
|
||||||
|
- SQL: 작성한 MySQL additive SQL을 로컬 H2 MySQL mode에 실제 실행하여 기존 row의 NULL/FALSE, 두 컬럼 COMMENT·nullability, is_scheduled NOT NULL 및 기존 created_at 소수초 보존을 확인했다. H2 기본 TIMESTAMP 정밀도를 MySQL과 같다고 주장하지 않으며 생성 저장 fixture는 별도로 TIMESTAMP(0)에 고정했다. 운영 DB 접속·SQL 실행·backfill은 하지 않았다.
|
||||||
|
- 최종 회귀: `./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.notice.*' --tests 'kr.co.vividnext.sodalive.v2.api.notice.*' --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.AdminNoticeControllerIntegrationTest' ktlintCheck` exit 0, 총 50개 통과(HTTP 35, DB 3, entity 1, 기존 latest HTTP 11), failure/error/skip 모두 0 (`final-regression.log`, `final-{http,db,entity,latest}.xml`). Kotlin 컴파일 및 ktlintCheck 통과, `git diff --check` 공백 오류 없음.
|
||||||
|
- 미실행: LSP 도구가 없어 compiler/lint로 대체했다. 단일 notice 생성 단계이며 공통 보안·오류·설정 변경이 없어 전체 test/build와 release 감사는 실행하지 않았다. 시간대는 사용자 UTC 확인을 전제로 했으며 운영 연결 실확인·배포는 제외했다. 공개 필터 전 단계이므로 현재 산출물만으로 운영 예약 쓰기를 활성화하면 안 된다.
|
||||||
|
|
||||||
|
### P1-T3 실행 및 Phase 1 검증 증거: 2026-10-30
|
||||||
|
|
||||||
|
- 상태: **P1-T3 완료**, Phase 1 검증 항목 충족. 공개 query·관리자 GET·client-prompt.md는 변경하지 않았고 전체 Phase 상태/다음 Goal 전환은 별도 판정으로 남겼다.
|
||||||
|
- 기준선: 부모 실행에서 기존 notice 4개 class 50개 통과 및 생성 단계 리뷰 승인을 확인한 채 재개했다. P1-T2 RED는 반복하지 않았다.
|
||||||
|
- 증거 루트: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/notice-p1t3.FfcAgS`. `stage-report.md`에 변경 파일·명령·실제 HTTP 발췌·DB 정밀도 대조표·경합 결과를 기록했다. 각 phase의 `.command.json`, `.runner.pid`, `.gradle.pid`, `.log`, `.exit.json` 및 notice JUnit XML을 보존했다. TMPDIR와 Node/Bun cache 경로를 이 루트로 격리한 단일 detached Gradle을 순차 실행했다.
|
||||||
|
- HTTP RED: `red-http` exit 1, 80개 중 41개 assertion 실패. `{"id":22,"title":"반영 금지","content":"반영 금지","publishAt":42}`가 HTTP 200 success=true, `data={}`로 수락됐고 schedule-only/null은 update-required로 거부됐다. 컴파일 오류·timeout을 RED 또는 성공으로 계산하지 않았다.
|
||||||
|
- 잠금 RED→GREEN: Task 1.3의 두 class 명령으로 `red-lock` exit 1(HTTP 80 통과, 동시성 9 실패) → `green-focused` exit 0(89개 통과). 첫 실패는 SELECT FOR UPDATE 대기 부재 assertion이다. CountDownLatch/TransactionTemplate과 H2 실제 BLOCKER_ID를 사용해 sleep·repository mock 없이 순서를 고정했다. 모든 대기/future를 제한하고 finally에서 barrier/future/executor를 정리했다.
|
||||||
|
- 실제 HTTP/DB: 미래 KST `2026-11-02 10:00:00` PUT은 HTTP 200 기존 성공 envelope와 UTC `2026-11-02T01:00:00`, isScheduled=true를 저장했다. 명시 null PUT은 HTTP 200 `{"success":true,"message":"수정되었습니다.","data":{},"errorProperty":null}`이고, 잘못된 시각·이미 공개/즉시/비활성 공지의 시각 변경은 기존 HTTP 200 invalid_request envelope다. 잘못된 예약에 동반한 제목·본문은 부분 반영되지 않았다. omitted 시각과 공개 후 title/content-only 수정 및 기존 ID/수정 필드 누락 오류도 보존했다.
|
||||||
|
- 정밀도 대조: UTC `2026-10-30T00:00:00`과 `2026-10-30T23:59:59` 각각에 nanos `0/123456789/999999999`를 입력했다. TIMESTAMP(0) metadata=0인 DB에서 flush/clear/reload한 결과는 각각 정확히 `2026-10-30T00:00:00`과 `2026-10-30T23:59:59`, nano=0이다. 다음 초·다음 날짜로 반올림하지 않고 original createdAt=`2026-10-29T14:59:59.123456`을 보존했다. P2 관리자 응답 시각과 DB 비교는 **미검증/pending**이다.
|
||||||
|
- 경합 DB: 대기 중 old publishAt 정각 도달 시 fresh now 검사로 거부, 즉시 전환 선행 후 재예약 거부, 제목/재예약 어느 순서에서도 최신 값 보존, 삭제와 전환/재예약 어느 순서에서도 isActive=false와 마지막 유효 예약 필드를 보존했다. 삭제 후 제목-only 수정도 삭제를 되돌리지 않았다.
|
||||||
|
- 최종 회귀: Phase 1에 기재한 5개 class + ktlintCheck 명령 `final-regression` exit 0, 111.0초. HTTP 80, 동시성 9, DB 3, entity 1, 기존 latest HTTP 11 = **104개**, failure/error/skip 0. Kotlin compiler와 main/test/script ktlint 통과. LSP 도구는 없어 compiler/lint로 대체했다.
|
||||||
|
- 미실행/제한: 전체 suite/build와 독립 최종 리뷰는 이 단계 범위 밖으로 남겼다. 운영 DB·외부 서비스 접속, migration, commit/worktree, 전역 인증·오류·timezone/BaseEntity 변경 및 신규 dependency는 없다. 공개 필터가 아직 없으므로 이 산출물만으로 운영 예약 쓰기를 활성화하면 안 된다.
|
||||||
|
|
||||||
|
### P2-T1 및 P2-GATE 실행: 2026-10-30
|
||||||
|
|
||||||
|
- 상태: **P2-T1/P2-GATE 검증 충족**. 중단된 작업의 기존 테스트·P1 구현을 유지하고 공개 query와 ADMIN GET만 마무리했다. 상단 전체 Phase/활성 Goal 및 P3 판정은 부모 작업이 갱신한다.
|
||||||
|
- 증거 루트: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/p2-notice.TWFcpR`. `stage-report.md`, `red.log`/`red.exit`, `red-summary.json`, `red-TEST-*.xml`, `green-final.log`/`green-final.exit`, `green-final-summary.json`, `green-final-TEST-*.xml`을 보존했다. 모든 명령에서 TMPDIR 및 Node/Bun cache 경로를 이 소유 루트로 지정했고 Gradle은 순차·offline 실행했다.
|
||||||
|
- RED: 위 Task 2.1의 4개 class에 `--offline --no-daemon`을 붙여 실행, exit 1. 112개 중 25개 실패이며 공개 count 4→9, now 호출 1→0, 예약 노출·날짜·정렬 및 ADMIN GET 404 등 **24개 assertion 실패**를 확인했다. 나머지 홈 1개는 직접 service 호출의 request-scoped LangContext fixture 오류로 RED 증거에서 제외하고 테스트에 request scope를 설정·finally 정리했다. compiler 오류를 RED로 계산하지 않았다.
|
||||||
|
- 구현: 공개 predicate를 pagination/limit 전에 적용하고 유효 시각/id 역순과 scheduled 여부별 KST 날짜를 사용한다. public 서비스는 now를 한 번 얻으며 count/list가 공유한다. ADMIN은 별도 active-only projection·read-only service·ADMIN controller로 미래 예약도 조회한다. H2 테스트 formatter에 `%s`→`ss`만 추가했다. 전역 auth/timezone/formatter/기존 created_at은 변경하지 않았다.
|
||||||
|
- 중간 검증: `green.log` exit 1은 기존 P2 초안의 인자 줄바꿈 ktlint 위반으로 test task 전에 중단됐다. 이를 고친 뒤 재실행했으며 이 중간 실행은 테스트 통과 증거가 아니다.
|
||||||
|
- GREEN: `./gradlew --offline --no-daemon --continue test --tests 'kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.AdminNoticeControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.content.main.tab.home.AudioContentMainTabHomeServiceIntegrationTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeConcurrencyIntegrationTest' --tests 'kr.co.vividnext.sodalive.notice.ServiceNoticeTest' ktlintCheck` exit 0, 3분 37초. **122개**(ADMIN/write HTTP 92, latest HTTP 13, query/DB 6, 실제 홈 1, 동시성 9, entity 1), failure/error/skip 0. Kotlin compiler와 main/test/script ktlint 통과. P1 기존 104개를 포함하며 새 full-field assertions를 완화하지 않았다.
|
||||||
|
- 실제 로컬 HTTP: RANDOM_PORT Tomcat + 실제 JWT·H2·embedded Redis로 검증했다. 익명 latest HTTP 200의 array/object/null, 모든 역할에서 future 제외, ADMIN 목록의 active future 포함·0-based page·빈 페이지·전체 DTO를 확인했다. 관리자 GET은 익명 401, USER HTTP 200 `success=false,message="You do not have permission."`, ADMIN HTTP 200 성공 envelope다. 실제 `/v2/audio-content/main/home`도 HTTP 200에서 공개 공지 `latestNotice={id:1,title:"공개 공지",content:"홈 전체 본문",date:"2026-10-30"}`를 반환했다. notice와 홈 의존성 mock은 없다.
|
||||||
|
- 정밀도: PUT-null의 UTC `2026-10-30T00:00:00` 및 `23:59:59` 각각 nanos 0/123456789/999999999를 flush/clear/reload했다. DB의 정확한 초 단위 값과 관리자 GET KST `2026-10-30 09:00:00`/`2026-10-31 08:59:59`를 UTC로 복원한 값이 6건 모두 일치했다. 기존 createdAt=`2026-10-29T14:59:58.123456` 보존과 예약 자연 공개 뒤 isScheduled=true도 검증했다.
|
||||||
|
- 제한: Kotlin LSP 미설치·기존 설치 거부 상태여서 compiler/lint로 대체했다. 전체 suite/build·독립 최종 리뷰·P3는 부모 작업 담당이다. 운영 DB/migration/배포·의존성 추가·commit/stage/push/worktree·client-prompt.md 변경은 없다. JVM 목록과 각 환경의 TMPDIR를 대조해 이 소유 루트의 실행 중 Gradle/worker/Kotlin daemon이 없음을 확인했다.
|
||||||
|
|
||||||
|
## 추적성
|
||||||
|
|
||||||
|
### P1-T1 2차 실행: 사용자 확인으로 완료
|
||||||
|
|
||||||
|
- 사용자 확인: "서버 UTC, DB UTC로 되어 있다. publish_at은 네가 말한대로 처리".
|
||||||
|
- 결정: 새 publish_at은 초 단위 TIMESTAMP로 독립 정의하며 기존 created_at 정밀도 복제 조건을 제거했다. 기존 DDL 미조회 사실과 이전 차단 기록은 유지한다.
|
||||||
|
- 상태: P1-T1 완료, P1-T2 구현 시작 가능. 운영 DB 적용과 연결 UTC 실검증은 배포 전 별도 확인한다.
|
||||||
|
|
||||||
|
|
||||||
|
| 요구사항 | 구현 Goal | 자동 검증 | 수동/외부 검증 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| NS-001, NS-003 | `P1-T2`, `P1-T3`, `P3-GATE` | admin integration, 기존 write response | anonymous/non-admin/admin mutation auth |
|
||||||
|
| NS-004 | `P1-T3`, `P3-GATE` | omitted/null/future/schedule-only 및 공개 경계 concurrency integration | 별도 UI 없음 |
|
||||||
|
| NS-005, NS-006, NS-007 | `P2-T1`, `P2-GATE` | 경계 전/정각/후, sort tie, 표시 날짜와 full fields | ADMIN token public query와 경계 이후 재조회 |
|
||||||
|
| NS-008 | `P2-T1`, `P2-GATE` | admin page와 visibility integration | 익명/비ADMIN 거부, ADMIN active scheduled 포함 |
|
||||||
|
| NS-009 | `P1-T1`, `P1-T2`, `P1-T3`, `P1-GATE`, `P2-GATE` | 운영 정밀도에 맞춘 DB round-trip nanos 0/123456789/999999999 및 관리자 응답 완전 일치 | 실제 DDL/JDBC/DB timezone 권위 자료 확인 |
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
|
||||||
|
### 2026-10-30 최종 구현 검증
|
||||||
|
|
||||||
|
- 기능 상태: P1-T2/P1-T3/P2-T1 및 Phase 1~2 완료. 관리자 등록·수정·별도 조회, 공개 목록·최신·홈 필터, 정렬·날짜, UTC/KST·초 정밀도, 동시 수정/삭제를 구현했다. 전체 회귀 성공 증거가 없으므로 P3-GATE는 blocked다.
|
||||||
|
- 전체 suite: `./gradlew --offline --no-daemon test ktlintCheck bootJar --continue`는 1,200,000ms 제한 초과로 종료됐다. `bootJar` 실행은 완료됐으나 test 종료 결과가 없어 전체 성공을 주장하지 않는다. timeout 후 잔여 Gradle 29742/test worker 29822의 종료를 확인했고 반복 전체 실행은 하지 않았다. 원인 stack 수집 시에는 이미 프로세스가 종료되어 원인을 특정하지 못했다.
|
||||||
|
- 최종 focused: P3-GATE에 적힌 실행 명령이 `BUILD SUCCESSFUL in 4m 9s`, exit 0. 최신 JUnit XML 기준 ADMIN/write HTTP 92, latest HTTP 13, query/DB 6, 홈 1, 동시성 9, entity 1 = 122개, failure/error/skip 모두 0. 실제 Tomcat·JWT·H2·embedded Redis로 전체 envelope와 DB 재조회 상태를 검증했다. 이는 MockMvc만의 결과가 아니라 실제 localhost HTTP 요청 결과다.
|
||||||
|
- 코드 품질: Kotlin 컴파일, `ktlintCheck`, 패키징 성공. `lsp_diagnostics` 확인 결과 kotlin-ls 미설치 및 기존 설치 거부로 LSP 진단은 불가했다. 공통 보안 설정·BaseEntity·기존 created_at·dependency는 변경하지 않았다. 테스트용 MySQL 날짜 함수의 `%s`만 보강했다.
|
||||||
|
- 최종 리뷰: Oracle 결과 회수 도구 오류로 사용 가능한 보고서가 없어 단일 대체 reviewer가 전체 diff·신규 파일·계약·테스트 근거를 검토했다. 결과 APPROVED, 차단 코드 결함 없음. 첫 결과를 승인으로 간주하거나 중복 리뷰 성공으로 계산하지 않았다.
|
||||||
|
- 관리자 프롬프트: `client-prompt.md`에 API 요청/응답·생략/null·권한·날짜 형식만 제공했다. 서버 내부 구현·DB 설정은 포함하지 않았다.
|
||||||
|
- 배포 주의: 신규 컬럼 수동 적용 및 연결 UTC 확인 → 모든 앱 reader 교체 → 관리자 예약 사용 순서를 지킨다. 운영 DB 접속/SQL 실행/배포/커밋·push는 하지 않았다.
|
||||||
|
- 증거 보존: 단계별 임시 루트의 실행 요약은 이 문서에 누적돼 있다. 임시 로그·runner·cache는 정리 대상으로 전환했으며 원래 기록은 삭제하지 않는다. 최신 실제 HTTP·DB 증거는 `build/test-results/test/TEST-*.xml`, HTML 결과는 `build/reports/tests/test/index.html`에 있다. 임시 경로는 영구 산출물 링크가 아니다.
|
||||||
|
- 정리 완료: 실행 프로세스 종료와 각 루트의 소유 보고서/명령 파일을 확인한 뒤 `notice-impl.6a7hk3`, `notice-p1t2.lPSBOW`, `notice-p1t3.FfcAgS`, `notice-p2.8nhNQr`, `p2-notice.TWFcpR` 다섯 임시 루트만 제거하고 각 경로의 부재를 확인했다. 기존 IDE/다른 프로젝트 JVM·공유 cache·다른 임시 디렉터리는 변경하지 않았다.
|
||||||
|
|
||||||
|
### 2026-10-30 문서 작성·검토
|
||||||
|
|
||||||
|
- 무엇을: PRD와 구현 계획 두 파일만 작성했다. 실제 구현·SQL·테스트 파일은 생성하지 않았고, 향후 구현 체크박스는 모두 미완료다.
|
||||||
|
- 왜: 인터뷰로 확정한 공개 정책을 API·DB·구현 Goal·수용 테스트로 연결하기 위해 문서 전용 검증을 수행했다.
|
||||||
|
- 어떻게: 두 문서 전체와 관련 소스를 대조했다. 독립 builder check 결과 중요한 미결 사항이 없었고, 작성자 검토에서 홈 테스트 대상을 실제 공지 조립 서비스로 수정하고 저장·응답의 초 단위 계약과 검증 소유 Task를 구체화했다.
|
||||||
|
- 공백 검사: `GIT_MASTER=1 git diff --no-index --check /dev/null docs/20261030_공지사항예약공개/prd.md` 및 같은 명령의 `plan-task.md` 검사에서 공백 오류가 없었다. `rg`의 완료 체크박스·trailing whitespace·미해결 placeholder 검사도 출력이 없었다.
|
||||||
|
- 변경 범위: `GIT_MASTER=1 git -c core.quotePath=false status --short --untracked-files=all`에서 이 디렉터리의 두 Markdown 파일만 신규 파일로 확인했다. Gradle, 테스트, 빌드, live HTTP, 운영 DB 검증은 문서 전용 요청이므로 실행하지 않았다. 운영 DDL·정밀도·시간대는 `P1-T1`의 외부 확인 차단 조건이다.
|
||||||
|
|
||||||
|
### 2026-10-30 커밋 전 재검증
|
||||||
|
|
||||||
|
- 사용자 `/commit-with-validation` 요청으로 기존 변경의 커밋을 승인받았다. 전역 공용 절차와 저장소 커밋 규칙을 확인하고 변경 파일 전체를 점검했다.
|
||||||
|
- 실행: `./gradlew --offline --no-daemon test --tests 'kr.co.vividnext.sodalive.notice.*' --tests 'kr.co.vividnext.sodalive.v2.api.notice.*' --tests 'kr.co.vividnext.sodalive.v2.api.admin.notice.*' --tests 'kr.co.vividnext.sodalive.content.main.tab.home.AudioContentMainTabHomeServiceIntegrationTest' ktlintCheck bootJar --continue`.
|
||||||
|
- 결과: exit 0, `BUILD SUCCESSFUL in 1m 31s`. test task는 실제 재실행됐으며 최신 JUnit XML의 6개 class 합계 122개, failure/error/skip 모두 0이다. 컴파일·lint·bootJar는 변경 없음으로 UP-TO-DATE였다. `git diff --check`도 통과했다.
|
||||||
|
- 전체 suite는 이번 커밋 검증에서 재실행하지 않았다. 구현 변경 없이 기존 산출물을 커밋하는 범위이므로 직접 영향 notice/home 회귀를 검증했다. 이전 전체 suite timeout과 P3-GATE blocked 상태는 유지한다. 운영 DB 검증·DDL 적용·배포는 수행하지 않았다.
|
||||||
Reference in New Issue
Block a user