diff --git a/docs/20261030_공지사항예약공개/alter-service-notice-publication.sql b/docs/20261030_공지사항예약공개/alter-service-notice-publication.sql new file mode 100644 index 00000000..aa357855 --- /dev/null +++ b/docs/20261030_공지사항예약공개/alter-service-notice-publication.sql @@ -0,0 +1,9 @@ +-- 수동 배포용 additive schema이며 운영 DB에서 자동 실행하지 않는다. +-- 서버/JDBC/DB UTC 확인 후 schema를 먼저 적용하고, 공개 필터가 포함된 앱으로 모든 reader를 교체한다. +-- 이전 reader가 하나라도 남아 있으면 예약 공지를 노출하므로 전체 교체 전에는 예약 쓰기/admin 사용을 금지한다. +-- 기존 row는 NULL/FALSE를 유지하며 created_at 변경이나 backfill을 수행하지 않는다. +ALTER TABLE service_notice + ADD COLUMN publish_at TIMESTAMP NULL DEFAULT NULL COMMENT '예약 공개 시각(UTC)'; + +ALTER TABLE service_notice + ADD COLUMN is_scheduled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '예약 공개 여부'; diff --git a/docs/20261030_공지사항예약공개/client-prompt.md b/docs/20261030_공지사항예약공개/client-prompt.md new file mode 100644 index 00000000..e3c819a3 --- /dev/null +++ b/docs/20261030_공지사항예약공개/client-prompt.md @@ -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`이며 브라우저 시간대 변환을 추가하지 않습니다. diff --git a/docs/20261030_공지사항예약공개/plan-task.md b/docs/20261030_공지사항예약공개/plan-task.md new file mode 100644 index 00000000..e0247d8e --- /dev/null +++ b/docs/20261030_공지사항예약공개/plan-task.md @@ -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}` 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`, `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`다. `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`, `getLatestNotice(now: LocalDateTime): NoticeItem?`; admin methods `getAdminNoticeTotalCount(): Int`, `getAdminNoticeList(pageable: Pageable): List`. Each public service captures now once; list and count receive same now. `AdminNoticeController.getNotices(pageable: Pageable): ApiResponse` delegates to `AdminNoticeService.getNoticeList(pageable): AdminNoticeResponse`, whose exact output is `{totalCount: Int, noticeList: List}`. +- **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 적용·배포는 수행하지 않았다. diff --git a/docs/20261030_공지사항예약공개/prd.md b/docs/20261030_공지사항예약공개/prd.md new file mode 100644 index 00000000..94ec4ac1 --- /dev/null +++ b/docs/20261030_공지사항예약공개/prd.md @@ -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` | +| `GET /api/v2/notices/latest` | 기존 공개 정책 | `ApiResponse`, 최대 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은 네가 말한대로 처리" | diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/notice/CreateNoticeRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/notice/CreateNoticeRequest.kt index 5c48a407..8c465e4e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/notice/CreateNoticeRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/notice/CreateNoticeRequest.kt @@ -1,10 +1,13 @@ package kr.co.vividnext.sodalive.notice +import java.time.LocalDateTime + data class CreateNoticeRequest( val title: String, - val content: String + val content: String, + val publishAt: String? = null ) { - fun toEntity(): ServiceNotice { - return ServiceNotice(title, content) + fun toEntity(publishAt: LocalDateTime? = null): ServiceNotice { + return ServiceNotice(title, content, publishAt = publishAt, isScheduled = publishAt != null) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNotice.kt b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNotice.kt index 6800fff1..0855b1ee 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNotice.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNotice.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.notice import kr.co.vividnext.sodalive.common.BaseEntity +import java.time.LocalDateTime import javax.persistence.Column import javax.persistence.Entity @@ -11,5 +12,9 @@ data class ServiceNotice( @Column(columnDefinition = "TEXT", nullable = false) var content: String, @Column(nullable = false) - var isActive: Boolean = true + var isActive: Boolean = true, + @Column(columnDefinition = "TIMESTAMP", nullable = true) + var publishAt: LocalDateTime? = null, + @Column(nullable = false) + var isScheduled: Boolean = false ) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeController.kt index 299dd20e..839bb96e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeController.kt @@ -1,6 +1,9 @@ package kr.co.vividnext.sodalive.notice +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException import org.springframework.data.domain.Pageable import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.DeleteMapping @@ -14,20 +17,31 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/notice") -class ServiceNoticeController(private val service: ServiceNoticeService) { +class ServiceNoticeController( + private val service: ServiceNoticeService, + private val objectMapper: ObjectMapper +) { @PostMapping @PreAuthorize("hasRole('ADMIN')") - fun createNotice(@RequestBody request: CreateNoticeRequest) = ApiResponse.ok( - service.save(request), - "등록되었습니다." - ) + fun createNotice(@RequestBody fields: JsonNode): ApiResponse { + val publishAt = fields["publishAt"] + if (!fields.isObject || (publishAt != null && !publishAt.isTextual && !publishAt.isNull)) { + throw SodaException(messageKey = "common.error.invalid_request") + } + val request = objectMapper.treeToValue(fields, CreateNoticeRequest::class.java) + return ApiResponse.ok(service.save(request), "등록되었습니다.") + } @PutMapping @PreAuthorize("hasRole('ADMIN')") - fun updateNotice(@RequestBody request: UpdateNoticeRequest) = ApiResponse.ok( - service.update(request), - "수정되었습니다." - ) + fun updateNotice(@RequestBody fields: JsonNode): ApiResponse { + val publishAt = fields["publishAt"] + if (!fields.isObject || (publishAt != null && !publishAt.isTextual && !publishAt.isNull)) { + throw SodaException(messageKey = "common.error.invalid_request") + } + val request = objectMapper.treeToValue(fields, UpdateNoticeRequest::class.java) + return ApiResponse.ok(service.update(request, publishAtPresent = fields.has("publishAt")), "수정되었습니다.") + } @DeleteMapping("/{id}") @PreAuthorize("hasRole('ADMIN')") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeService.kt index a5d873b7..31d4a4aa 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeService.kt @@ -2,32 +2,52 @@ package kr.co.vividnext.sodalive.notice import kr.co.vividnext.sodalive.common.SodaException import org.springframework.data.domain.Pageable -import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException +import java.time.format.ResolverStyle +import java.time.temporal.ChronoUnit @Service @Transactional(readOnly = true) -class ServiceNoticeService(private val repository: ServiceServiceNoticeRepository) { +class ServiceNoticeService( + private val repository: ServiceServiceNoticeRepository, + private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) } +) { @Transactional fun save(request: CreateNoticeRequest): Long { if (request.title.isBlank()) throw SodaException(messageKey = "notice.error.title_required") if (request.content.isBlank()) throw SodaException(messageKey = "notice.error.content_required") - val notice = request.toEntity() + val publishAt = request.publishAt?.let { parsePublishAt(it, nowProvider()) } + val notice = request.toEntity(publishAt) return repository.save(notice).id!! } @Transactional - fun update(request: UpdateNoticeRequest) { + fun update(request: UpdateNoticeRequest, publishAtPresent: Boolean = false) { if (request.id <= 0) throw SodaException(messageKey = "common.error.invalid_request") - if (request.title.isNullOrBlank() && request.content.isNullOrBlank()) { + if (request.title.isNullOrBlank() && request.content.isNullOrBlank() && !publishAtPresent) { throw SodaException(messageKey = "notice.error.update_required") } - val notice = repository.findByIdOrNull(request.id) + val notice = repository.findByIdForUpdate(request.id) ?: throw SodaException(messageKey = "common.error.invalid_request") + if (publishAtPresent) { + val now = nowProvider() + val currentPublishAt = notice.publishAt + if (!notice.isActive || !notice.isScheduled || currentPublishAt == null || currentPublishAt <= now) { + throw SodaException(messageKey = "common.error.invalid_request") + } + notice.publishAt = request.publishAt?.let { parsePublishAt(it, now) } ?: now.truncatedTo(ChronoUnit.SECONDS) + notice.isScheduled = request.publishAt != null + } + if (!request.title.isNullOrBlank()) notice.title = request.title if (!request.content.isNullOrBlank()) notice.content = request.content } @@ -35,20 +55,45 @@ class ServiceNoticeService(private val repository: ServiceServiceNoticeRepositor @Transactional fun delete(id: Long) { if (id <= 0) throw SodaException(messageKey = "common.error.invalid_request") - val notice = repository.findByIdOrNull(id) + val notice = repository.findByIdForUpdate(id) ?: throw SodaException(messageKey = "common.error.invalid_request") notice.isActive = false } fun getNoticeList(pageable: Pageable, timezone: String): GetNoticeResponse { - val totalCount = repository.getNoticeTotalCount() - val noticeList = repository.getNoticeList(pageable) + val now = nowProvider() + val totalCount = repository.getNoticeTotalCount(now) + val noticeList = repository.getNoticeList(pageable, now) return GetNoticeResponse(totalCount, noticeList) } fun getLatestNotice(): NoticeItem? { - return repository.getLatestNotice() + val now = nowProvider() + return repository.getLatestNotice(now) + } + + private fun parsePublishAt(value: String, now: LocalDateTime): LocalDateTime { + if (value.length != 19) throw SodaException(messageKey = "common.error.invalid_request") + val utc = try { + LocalDateTime.parse(value, PUBLISH_AT_FORMAT) + .atZone(ZoneId.of("Asia/Seoul")) + .withZoneSameInstant(ZoneOffset.UTC) + .toLocalDateTime() + } catch (_: DateTimeParseException) { + throw SodaException(messageKey = "common.error.invalid_request") + } + if (utc < MIN_TIMESTAMP || utc > MAX_TIMESTAMP || utc <= now) { + throw SodaException(messageKey = "common.error.invalid_request") + } + return utc + } + + companion object { + private val PUBLISH_AT_FORMAT = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss") + .withResolverStyle(ResolverStyle.STRICT) + private val MIN_TIMESTAMP = LocalDateTime.of(1970, 1, 1, 0, 0, 1) + private val MAX_TIMESTAMP = LocalDateTime.of(2038, 1, 19, 3, 14, 7) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceServiceNoticeRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceServiceNoticeRepository.kt index f1261e43..9cab98d9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceServiceNoticeRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceServiceNoticeRepository.kt @@ -1,80 +1,121 @@ package kr.co.vividnext.sodalive.notice -import com.querydsl.core.types.dsl.DateTimePath +import com.querydsl.core.types.Expression +import com.querydsl.core.types.dsl.BooleanExpression +import com.querydsl.core.types.dsl.CaseBuilder import com.querydsl.core.types.dsl.Expressions import com.querydsl.core.types.dsl.StringTemplate import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.notice.QServiceNotice.serviceNotice +import kr.co.vividnext.sodalive.v2.api.admin.notice.dto.AdminNoticeItem +import kr.co.vividnext.sodalive.v2.api.admin.notice.dto.QAdminNoticeItem +import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository import java.time.LocalDateTime +import javax.persistence.LockModeType @Repository -interface ServiceServiceNoticeRepository : JpaRepository, ServiceNoticeQueryRepository +interface ServiceServiceNoticeRepository : JpaRepository, ServiceNoticeQueryRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select notice from ServiceNotice notice where notice.id = :id") + fun findByIdForUpdate(@Param("id") id: Long): ServiceNotice? +} interface ServiceNoticeQueryRepository { - fun getNoticeTotalCount(): Int - fun getNoticeList(pageable: Pageable): List - fun getLatestNotice(): NoticeItem? + fun getNoticeTotalCount(now: LocalDateTime): Int + fun getNoticeList(pageable: Pageable, now: LocalDateTime): List + fun getLatestNotice(now: LocalDateTime): NoticeItem? + fun getAdminNoticeTotalCount(): Int + fun getAdminNoticeList(pageable: Pageable): List } @Repository class ServiceNoticeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : ServiceNoticeQueryRepository { - override fun getNoticeTotalCount(): Int { - return queryFactory - .select(serviceNotice.id) - .from(serviceNotice) - .where(serviceNotice.isActive.isTrue) - .fetch() - .size + override fun getNoticeTotalCount(now: LocalDateTime): Int { + return requireNotNull( + queryFactory.select(serviceNotice.id.count()) + .from(serviceNotice) + .where(isPublic(now)) + .fetchOne() + ).toInt() } - override fun getNoticeList(pageable: Pageable): List { + override fun getNoticeList(pageable: Pageable, now: LocalDateTime): List { return queryFactory .select( QNoticeItem( serviceNotice.id, serviceNotice.title, serviceNotice.content, - getFormattedDate(serviceNotice.createdAt) + getFormattedDate( + CaseBuilder().`when`(serviceNotice.isScheduled.isTrue) + .then(serviceNotice.publishAt).otherwise(serviceNotice.createdAt), + "%Y-%m-%d" + ) + ) + ) + .from(serviceNotice) + .where(isPublic(now)) + .offset(pageable.offset) + .limit(pageable.pageSize.toLong()) + .orderBy(serviceNotice.publishAt.coalesce(serviceNotice.createdAt).desc(), serviceNotice.id.desc()) + .fetch() + } + + override fun getLatestNotice(now: LocalDateTime): NoticeItem? { + return getNoticeList(PageRequest.of(0, 1), now).firstOrNull() + } + + override fun getAdminNoticeTotalCount(): Int { + return requireNotNull( + queryFactory.select(serviceNotice.id.count()) + .from(serviceNotice) + .where(serviceNotice.isActive.isTrue) + .fetchOne() + ).toInt() + } + + override fun getAdminNoticeList(pageable: Pageable): List { + return queryFactory + .select( + QAdminNoticeItem( + serviceNotice.id, + serviceNotice.title, + serviceNotice.content, + serviceNotice.isActive, + serviceNotice.isScheduled, + getFormattedDate(serviceNotice.publishAt, "%Y-%m-%d %H:%i:%s"), + getFormattedDate(serviceNotice.createdAt, "%Y-%m-%d %H:%i:%s") ) ) .from(serviceNotice) .where(serviceNotice.isActive.isTrue) .offset(pageable.offset) .limit(pageable.pageSize.toLong()) - .orderBy(serviceNotice.id.desc()) + .orderBy(serviceNotice.publishAt.coalesce(serviceNotice.createdAt).desc(), serviceNotice.id.desc()) .fetch() } - override fun getLatestNotice(): NoticeItem? { - return queryFactory - .select( - QNoticeItem( - serviceNotice.id, - serviceNotice.title, - serviceNotice.content, - getFormattedDate(serviceNotice.createdAt) - ) - ) - .from(serviceNotice) - .where(serviceNotice.isActive.isTrue) - .orderBy(serviceNotice.id.desc()) - .fetchFirst() + private fun isPublic(now: LocalDateTime): BooleanExpression { + return serviceNotice.isActive.isTrue.and(serviceNotice.publishAt.isNull.or(serviceNotice.publishAt.loe(now))) } - private fun getFormattedDate(dateTimePath: DateTimePath): StringTemplate { + private fun getFormattedDate(dateTime: Expression, format: String): StringTemplate { return Expressions.stringTemplate( "DATE_FORMAT({0}, {1})", Expressions.dateTimeTemplate( LocalDateTime::class.java, "CONVERT_TZ({0},{1},{2})", - dateTimePath, + dateTime, "UTC", "Asia/Seoul" ), - "%Y-%m-%d" + format ) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/notice/UpdateNoticeRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/notice/UpdateNoticeRequest.kt index c3da07da..f089d090 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/notice/UpdateNoticeRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/notice/UpdateNoticeRequest.kt @@ -3,5 +3,6 @@ package kr.co.vividnext.sodalive.notice data class UpdateNoticeRequest( val id: Long, val title: String? = null, - val content: String? = null + val content: String? = null, + val publishAt: String? = null ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/adapter/in/web/AdminNoticeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/adapter/in/web/AdminNoticeController.kt new file mode 100644 index 00000000..b6699425 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/adapter/in/web/AdminNoticeController.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.v2.api.admin.notice.adapter.`in`.web + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.v2.api.admin.notice.application.AdminNoticeService +import kr.co.vividnext.sodalive.v2.api.admin.notice.dto.AdminNoticeResponse +import org.springframework.data.domain.Pageable +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v2/admin/notices") +@PreAuthorize("hasRole('ADMIN')") +class AdminNoticeController(private val service: AdminNoticeService) { + @GetMapping + fun getNotices(pageable: Pageable): ApiResponse = ApiResponse.ok(service.getNoticeList(pageable)) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/application/AdminNoticeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/application/AdminNoticeService.kt new file mode 100644 index 00000000..3a591e05 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/application/AdminNoticeService.kt @@ -0,0 +1,17 @@ +package kr.co.vividnext.sodalive.v2.api.admin.notice.application + +import kr.co.vividnext.sodalive.notice.ServiceServiceNoticeRepository +import kr.co.vividnext.sodalive.v2.api.admin.notice.dto.AdminNoticeResponse +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class AdminNoticeService(private val repository: ServiceServiceNoticeRepository) { + fun getNoticeList(pageable: Pageable): AdminNoticeResponse { + val totalCount = repository.getAdminNoticeTotalCount() + val noticeList = repository.getAdminNoticeList(pageable) + return AdminNoticeResponse(totalCount, noticeList) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeItem.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeItem.kt new file mode 100644 index 00000000..02ad5914 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeItem.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.v2.api.admin.notice.dto + +import com.querydsl.core.annotations.QueryProjection + +data class AdminNoticeItem @QueryProjection constructor( + val id: Long, + val title: String, + val content: String, + val isActive: Boolean, + val isScheduled: Boolean, + val publishAt: String?, + val createdAt: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeResponse.kt new file mode 100644 index 00000000..01ae91c1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/dto/AdminNoticeResponse.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.v2.api.admin.notice.dto + +data class AdminNoticeResponse( + val totalCount: Int, + val noticeList: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt index cda88bd2..35dc8aa8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt @@ -5,9 +5,17 @@ import kr.co.vividnext.sodalive.notice.ServiceServiceNoticeRepository import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.ZoneOffset @Service @Transactional(readOnly = true) -class LatestNoticeService(private val repository: ServiceServiceNoticeRepository) { - fun getLatestNotices(): List = repository.getNoticeList(PageRequest.of(0, 3)) +class LatestNoticeService( + private val repository: ServiceServiceNoticeRepository, + private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) } +) { + fun getLatestNotices(): List { + val now = nowProvider() + return repository.getNoticeList(PageRequest.of(0, 3), now) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeServiceIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeServiceIntegrationTest.kt new file mode 100644 index 00000000..14a8b3da --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeServiceIntegrationTest.kt @@ -0,0 +1,104 @@ +package kr.co.vividnext.sodalive.content.main.tab.home + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.notice.NoticeItem +import kr.co.vividnext.sodalive.notice.ServiceNotice +import kr.co.vividnext.sodalive.notice.ServiceServiceNoticeRepository +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.http.HttpStatus +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.context.request.ServletRequestAttributes +import java.sql.Timestamp +import java.time.LocalDateTime + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "spring.datasource.url=jdbc:h2:mem:notice-home-integration;" + + "MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@Import(AudioContentMainTabHomeServiceIntegrationTest.TimeConfiguration::class) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class AudioContentMainTabHomeServiceIntegrationTest @Autowired constructor( + private val service: AudioContentMainTabHomeService, + private val repository: ServiceServiceNoticeRepository, + private val jdbcTemplate: JdbcTemplate, + private val restTemplate: TestRestTemplate, + private val objectMapper: ObjectMapper +) { + @BeforeEach + fun setUp() { + repository.deleteAllInBatch() + jdbcTemplate.execute("ALTER TABLE service_notice ALTER COLUMN publish_at TIMESTAMP(0)") + jdbcTemplate.execute( + "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" + ) + jdbcTemplate.execute( + "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" + ) + } + + @AfterEach + fun cleanUp() { + repository.deleteAllInBatch() + } + + @Test + @DisplayName("실제 홈 조립과 HTTP latestNotice는 미공개 예약을 제외한 최신 공개 공지다") + fun shouldDelegateHomeLatestNoticeToRealFilteredLookup() { + val id = requireNotNull(repository.saveAndFlush(ServiceNotice("공개 공지", "홈 전체 본문")).id) + jdbcTemplate.update( + "UPDATE service_notice SET created_at = ? WHERE id = ?", + Timestamp.valueOf("2026-10-29 15:00:00"), + id + ) + repository.saveAndFlush( + ServiceNotice("미공개", "노출 금지", publishAt = LocalDateTime.of(2026, 11, 1, 0, 0, 0), isScheduled = true) + ) + repository.saveAndFlush(ServiceNotice("비활성", "노출 금지", isActive = false)) + + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(MockHttpServletRequest())) + try { + val result = service.fetchData(false, ContentType.ALL, null) + assertEquals(NoticeItem(id, "공개 공지", "홈 전체 본문", "2026-10-30"), result.latestNotice) + } finally { + RequestContextHolder.resetRequestAttributes() + } + + val response = restTemplate.getForEntity("/v2/audio-content/main/home", String::class.java) + println("HTTP GET /v2/audio-content/main/home -> ${response.statusCodeValue} ${response.body}") + assertEquals(HttpStatus.OK, response.statusCode) + val body = objectMapper.readTree(response.body) + assertEquals(true, body["success"].asBoolean()) + assertEquals( + objectMapper.readTree( + """{"id":$id,"title":"공개 공지","content":"홈 전체 본문","date":"2026-10-30"}""" + ), + body["data"]["latestNotice"] + ) + } + + @TestConfiguration + class TimeConfiguration { + @Bean + fun noticeNowProvider(): () -> LocalDateTime = { LocalDateTime.of(2026, 10, 30, 0, 0, 0) } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeConcurrencyIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeConcurrencyIntegrationTest.kt new file mode 100644 index 00000000..f130d2e2 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeConcurrencyIntegrationTest.kt @@ -0,0 +1,282 @@ +package kr.co.vividnext.sodalive.notice + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate +import java.sql.Timestamp +import java.time.LocalDateTime +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import javax.persistence.EntityManager +import javax.persistence.LockModeType + +@SpringBootTest( + properties = [ + "spring.datasource.url=jdbc:h2:mem:notice-concurrency;" + + "MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;" + + "DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;LOCK_TIMEOUT=15000" + ] +) +@Import(ServiceNoticeConcurrencyIntegrationTest.TimeConfiguration::class) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class ServiceNoticeConcurrencyIntegrationTest @Autowired constructor( + private val service: ServiceNoticeService, + private val repository: ServiceServiceNoticeRepository, + private val entityManager: EntityManager, + private val transactionManager: PlatformTransactionManager, + private val jdbcTemplate: JdbcTemplate, + private val noticeTime: AtomicReference +) { + @BeforeEach + fun setUp() { + noticeTime.set(LocalDateTime.of(2026, 10, 30, 0, 0, 0, 123456789)) + repository.deleteAllInBatch() + jdbcTemplate.execute("ALTER TABLE service_notice ALTER COLUMN publish_at TIMESTAMP(0)") + } + + @AfterEach + fun cleanUp() { + repository.deleteAllInBatch() + } + + @Test + @DisplayName("row lock 대기 중 공개 경계를 넘으면 잠금 뒤 fresh now로 재검사하여 시각·제목 변경을 거부한다") + fun shouldRejectUpdateWhenLockWaitCrossesPublishAt() { + val before = scheduledNotice() + val id = requireNotNull(before.id) + + val failure = runWithBlockedWrite( + id, + first = {}, + second = { service.update(UpdateNoticeRequest(id, title = "반영 금지", publishAt = NEXT_SCHEDULE), true) }, + whileBlocked = { noticeTime.set(LocalDateTime.of(2026, 11, 1, 0, 0, 0)) } + ) + + assertInvalidRequest(failure) + val stored = reload(id) + assertEquals(before, stored) + assertEquals(before.createdAt, stored.createdAt) + } + + @Test + @DisplayName("즉시 전환이 먼저 잠금을 얻으면 뒤의 재예약은 최신 즉시 상태를 읽고 거부한다") + fun shouldSerializeImmediateConversionAndReschedule() { + val before = scheduledNotice() + val id = requireNotNull(before.id) + + val failure = runWithBlockedWrite( + id, + first = { service.update(UpdateNoticeRequest(id), true) }, + second = { service.update(UpdateNoticeRequest(id, publishAt = NEXT_SCHEDULE), true) } + ) + + assertInvalidRequest(failure) + val stored = reload(id) + assertEquals(LocalDateTime.of(2026, 10, 30, 0, 0, 0), stored.publishAt) + assertEquals(false, stored.isScheduled) + assertEquals(true, stored.isActive) + assertEquals(before.createdAt, stored.createdAt) + } + + @ParameterizedTest + @ValueSource(booleans = [true, false]) + @DisplayName("제목과 재예약 동시 수정은 어느 순서에서도 최신 제목과 예약 시각을 함께 보존한다") + fun shouldPreserveLatestScheduleWhenTitleUpdateRaces(scheduleFirst: Boolean) { + val before = scheduledNotice() + val id = requireNotNull(before.id) + val reschedule = { service.update(UpdateNoticeRequest(id, publishAt = NEXT_SCHEDULE), true) } + val titleUpdate = { service.update(UpdateNoticeRequest(id, title = "최신 제목")) } + + assertNull( + runWithBlockedWrite( + id, + first = if (scheduleFirst) reschedule else titleUpdate, + second = if (scheduleFirst) titleUpdate else reschedule + ) + ) + + val stored = reload(id) + assertEquals("최신 제목", stored.title) + assertEquals(before.content, stored.content) + assertEquals(LocalDateTime.of(2026, 11, 2, 0, 0, 0), stored.publishAt) + assertEquals(true, stored.isScheduled) + assertEquals(true, stored.isActive) + assertEquals(before.createdAt, stored.createdAt) + } + + @ParameterizedTest + @CsvSource("true,true", "true,false", "false,true", "false,false") + @DisplayName("삭제와 즉시 전환·재예약은 순서대로 처리하여 삭제를 되돌리거나 최신 예약을 덮지 않는다") + fun shouldSerializeDeleteAndScheduleChanges(deleteFirst: Boolean, immediate: Boolean) { + val before = scheduledNotice() + val id = requireNotNull(before.id) + val scheduleUpdate = { + service.update(UpdateNoticeRequest(id, publishAt = if (immediate) null else NEXT_SCHEDULE), true) + } + val delete = { service.delete(id) } + + val failure = runWithBlockedWrite( + id, + first = if (deleteFirst) delete else scheduleUpdate, + second = if (deleteFirst) scheduleUpdate else delete + ) + + if (deleteFirst) assertInvalidRequest(failure) else assertNull(failure) + val stored = reload(id) + assertEquals(false, stored.isActive) + assertEquals( + when { + deleteFirst -> LocalDateTime.of(2026, 11, 1, 0, 0, 0) + immediate -> LocalDateTime.of(2026, 10, 30, 0, 0, 0) + else -> LocalDateTime.of(2026, 11, 2, 0, 0, 0) + }, + stored.publishAt + ) + assertEquals(deleteFirst || !immediate, stored.isScheduled) + assertEquals(before.createdAt, stored.createdAt) + } + + @Test + @DisplayName("삭제 후 대기하던 기존 제목 수정은 허용하되 stale row로 삭제 상태를 되돌리지 않는다") + fun shouldPreserveDeletionWhenTitleUpdateWaits() { + val before = scheduledNotice() + val id = requireNotNull(before.id) + + assertNull( + runWithBlockedWrite( + id, + first = { service.delete(id) }, + second = { service.update(UpdateNoticeRequest(id, title = "삭제 후 제목")) } + ) + ) + + val stored = reload(id) + assertEquals(false, stored.isActive) + assertEquals("삭제 후 제목", stored.title) + assertEquals(before.publishAt, stored.publishAt) + assertEquals(before.isScheduled, stored.isScheduled) + } + + private fun runWithBlockedWrite( + id: Long, + first: () -> Unit, + second: () -> Unit, + whileBlocked: () -> Unit = {} + ): Throwable? { + val locked = CountDownLatch(1) + val release = CountDownLatch(1) + val executor = Executors.newFixedThreadPool(2) + val futures = mutableListOf>() + try { + val holder = executor.submit { + TransactionTemplate(transactionManager).executeWithoutResult { + entityManager.find(ServiceNotice::class.java, id, LockModeType.PESSIMISTIC_WRITE) + first() + entityManager.flush() + locked.countDown() + check(release.await(10, TimeUnit.SECONDS)) { "lock holder was not released" } + } + } + futures.add(holder) + assertTrue(locked.await(5, TimeUnit.SECONDS), "first transaction did not acquire the row lock") + val waiter = executor.submit { runCatching { second() }.exceptionOrNull() } + futures.add(waiter) + assertTrue(awaitBlockedSelect(waiter), "second write did not wait on SELECT FOR UPDATE") + whileBlocked() + release.countDown() + holder.get(5, TimeUnit.SECONDS) + return waiter.get(5, TimeUnit.SECONDS) + } finally { + release.countDown() + futures.forEach { it.cancel(true) } + executor.shutdownNow() + assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS), "concurrency executor did not terminate") + } + } + + private fun awaitBlockedSelect(waiter: Future<*>): Boolean { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (System.nanoTime() < deadline && !waiter.isDone) { + val blocked = jdbcTemplate.queryForList( + "SELECT EXECUTING_STATEMENT FROM INFORMATION_SCHEMA.SESSIONS " + + "WHERE BLOCKER_ID IS NOT NULL AND LOWER(EXECUTING_STATEMENT) LIKE '%service_notice%for update%'", + String::class.java + ) + if (blocked.isNotEmpty()) { + println("DB blocked SELECT FOR UPDATE: ${blocked.single()}") + return true + } + Thread.yield() + } + return false + } + + private fun scheduledNotice(): ServiceNotice { + val notice = repository.saveAndFlush( + ServiceNotice("예약 제목", "예약 본문", publishAt = LocalDateTime.of(2026, 11, 1, 0, 0, 0), isScheduled = true) + ) + val id = requireNotNull(notice.id) + jdbcTemplate.update( + "UPDATE service_notice SET created_at = ? WHERE id = ?", + Timestamp.valueOf("2026-10-29 14:59:59.123456"), + id + ) + return reload(id) + } + + private fun reload(id: Long): ServiceNotice { + return requireNotNull( + TransactionTemplate(transactionManager).execute { + entityManager.flush() + entityManager.clear() + repository.findById(id).orElseThrow().also { + println( + "DB concurrency reload id=$id title=${it.title} active=${it.isActive} " + + "scheduled=${it.isScheduled} publishAt=${it.publishAt} createdAt=${it.createdAt}" + ) + } + } + ) + } + + private fun assertInvalidRequest(failure: Throwable?) { + assertTrue(failure is SodaException, "expected SodaException, got $failure") + assertEquals("common.error.invalid_request", (failure as SodaException).messageKey) + } + + @TestConfiguration + class TimeConfiguration { + @Bean + fun noticeTime(): AtomicReference = AtomicReference(LocalDateTime.of(2026, 10, 30, 0, 0, 0)) + + @Bean + fun noticeNowProvider(noticeTime: AtomicReference): () -> LocalDateTime = { noticeTime.get() } + } + + companion object { + private const val NEXT_SCHEDULE = "2026-11-02 09:00:00" + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeQueryRepositoryTest.kt new file mode 100644 index 00000000..eadabf72 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeQueryRepositoryTest.kt @@ -0,0 +1,199 @@ +package kr.co.vividnext.sodalive.notice + +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.data.domain.PageRequest +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.annotation.Transactional +import java.nio.file.Files +import java.nio.file.Path +import java.sql.DriverManager +import java.sql.SQLException +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest( + properties = [ + "spring.datasource.url=jdbc:h2:mem:notice-query-repository;" + + "MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@Import(ServiceNoticeQueryRepositoryTest.TimeConfiguration::class) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@Transactional +class ServiceNoticeQueryRepositoryTest @Autowired constructor( + private val service: ServiceNoticeService, + private val repository: ServiceServiceNoticeRepository, + private val entityManager: EntityManager, + private val jdbcTemplate: JdbcTemplate +) { + @BeforeEach + fun setUp() { + jdbcTemplate.execute("ALTER TABLE service_notice ALTER COLUMN publish_at TIMESTAMP(0)") + jdbcTemplate.execute( + "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" + ) + jdbcTemplate.execute( + "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" + ) + } + + @Test + @DisplayName("필터는 페이지와 latest limit 전에 적용되며 count는 공개 활성 건수다") + fun shouldApplyPublicFilterBeforeLatestLimit() { + val eligible = (1..4).map { day -> saveNotice("2026-10-${20 + day}T00:00:00") } + repeat(5) { saveNotice("2026-10-29T00:00:00", "2026-11-01T00:00:00") } + saveNotice("2026-10-29T00:00:00", isActive = false) + saveNotice("2026-10-29T00:00:00", "2026-10-29T00:00:00", isActive = false) + + val first = service.getNoticeList(PageRequest.of(0, 3), "UTC") + val second = service.getNoticeList(PageRequest.of(1, 3), "UTC") + assertEquals(4, first.totalCount) + assertEquals(eligible.reversed().take(3), first.noticeList.map { it.id }) + assertEquals(4, second.totalCount) + assertEquals(listOf(eligible.first()), second.noticeList.map { it.id }) + assertEquals(eligible.last(), service.getLatestNotice()?.id) + } + + @Test + @DisplayName("동일 공개 시각은 등록 시각이 아니라 id 역순으로 정렬한다") + fun shouldOrderEqualPublishTimesByIdDescending() { + val first = saveNotice("2026-10-29T00:00:00", "2026-10-29T12:00:00") + val second = saveNotice("2026-10-28T00:00:00", "2026-10-29T12:00:00") + val immediate = saveNotice("2026-10-29T12:00:00") + + assertEquals( + listOf(immediate, second, first), + service.getNoticeList(PageRequest.of(0, 20), "Asia/Seoul").noticeList.map { it.id } + ) + } + + @Test + @DisplayName("count와 list는 서비스가 한 번 캡처한 동일 now로 경계와 총 건수를 판정한다") + fun shouldCaptureNowOnceForCountAndList() { + val boundary = LocalDateTime.of(2026, 10, 30, 0, 0, 0) + val visible = saveNotice("2026-10-29T00:00:00", boundary.toString()) + saveNotice("2026-10-29T00:00:00", boundary.plusSeconds(1).toString()) + var calls = 0 + val advancingService = ServiceNoticeService(repository) { boundary.plusSeconds(calls++.toLong()) } + + val result = advancingService.getNoticeList(PageRequest.of(0, 20), "UTC") + + assertEquals(1, calls) + assertEquals(1, result.totalCount) + assertEquals(listOf(visible), result.noticeList.map { it.id }) + } + + private fun saveNotice(createdAt: String, publishAt: String? = null, isActive: Boolean = true): Long { + val notice = repository.saveAndFlush( + ServiceNotice( + "공지", + "본문", + isActive, + publishAt = publishAt?.let(LocalDateTime::parse), + isScheduled = publishAt != null + ) + ) + entityManager.createNativeQuery("UPDATE service_notice SET created_at = :createdAt WHERE id = :id") + .setParameter("createdAt", LocalDateTime.parse(createdAt)) + .setParameter("id", notice.id) + .executeUpdate() + entityManager.clear() + return requireNotNull(notice.id) + } + + @Test + @DisplayName("KST 예약 생성은 TIMESTAMP(0) flush·clear·재조회 후 UTC 값을 정확히 보존한다") + fun shouldPersistAndReloadScheduledPublishAt() { + val id = service.save(CreateNoticeRequest("예약 공지", "본문", "2026-11-01 09:00:00")) + entityManager.flush() + entityManager.clear() + + val stored = repository.findById(id).orElseThrow() + println("DB reload id=$id publishAt=${stored.publishAt} isScheduled=${stored.isScheduled}") + assertEquals(LocalDateTime.of(2026, 11, 1, 0, 0, 0), stored.publishAt) + assertEquals(0, requireNotNull(stored.publishAt).nano) + assertEquals(true, stored.isScheduled) + assertNotNull(stored.createdAt) + assertEquals( + 0, + jdbcTemplate.queryForObject( + "SELECT DATETIME_PRECISION FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'service_notice' AND COLUMN_NAME = 'publish_at'", + Int::class.java + ) + ) + } + + @Test + @DisplayName("기존 생성은 DB 왕복 후에도 예약 시각 null과 예약 상태 false를 보존한다") + fun shouldPersistAndReloadImmediateNotice() { + val id = service.save(CreateNoticeRequest("즉시 공지", "본문")) + entityManager.flush() + entityManager.clear() + + val stored = repository.findById(id).orElseThrow() + assertNull(stored.publishAt) + assertEquals(false, stored.isScheduled) + assertNotNull(stored.createdAt) + } + + @Test + @DisplayName("작성한 additive DDL은 기존 row와 created_at을 바꾸지 않고 nullable 시각·false 기본값을 추가한다") + fun shouldApplyAdditiveDdlWithoutChangingExistingRows() { + DriverManager.getConnection( + "jdbc:h2:mem:notice-publication-ddl;MODE=MySQL;DATABASE_TO_UPPER=false" + ).use { connection -> + connection.createStatement().use { statement -> + statement.execute("CREATE TABLE service_notice (id BIGINT PRIMARY KEY, created_at TIMESTAMP(6))") + statement.execute("INSERT INTO service_notice VALUES (1, TIMESTAMP '2026-10-29 23:59:59.123456')") + statement.execute( + Files.readString(Path.of("docs/20261030_공지사항예약공개/alter-service-notice-publication.sql")) + ) + statement.executeQuery("SELECT publish_at, is_scheduled, created_at FROM service_notice").use { rows -> + assertTrue(rows.next()) + assertNull(rows.getTimestamp("publish_at")) + assertEquals(false, rows.getBoolean("is_scheduled")) + assertEquals( + LocalDateTime.of(2026, 10, 29, 23, 59, 59, 123456000), + rows.getTimestamp("created_at").toLocalDateTime() + ) + } + connection.metaData.getColumns(null, null, "service_notice", "publish_at").use { columns -> + assertTrue(columns.next()) + assertEquals("YES", columns.getString("IS_NULLABLE")) + assertEquals("예약 공개 시각(UTC)", columns.getString("REMARKS")) + } + connection.metaData.getColumns(null, null, "service_notice", "is_scheduled").use { columns -> + assertTrue(columns.next()) + assertEquals("NO", columns.getString("IS_NULLABLE")) + assertEquals("예약 공개 여부", columns.getString("REMARKS")) + } + assertThrows(SQLException::class.java) { + statement.executeUpdate("UPDATE service_notice SET is_scheduled = NULL") + } + } + } + } + + @TestConfiguration + class TimeConfiguration { + @Bean + fun noticeNowProvider(): () -> LocalDateTime = { LocalDateTime.of(2026, 10, 30, 0, 0, 0) } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeTest.kt new file mode 100644 index 00000000..a721e037 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeTest.kt @@ -0,0 +1,19 @@ +package kr.co.vividnext.sodalive.notice + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class ServiceNoticeTest { + @Test + @DisplayName("기존 엔티티 생성은 활성·즉시 공개 기본값과 미지정 등록 시각을 유지한다") + fun shouldDefaultToImmediateActiveNotice() { + val notice = ServiceNotice("공지", "본문") + + assertEquals(true, notice.isActive) + assertEquals(false, notice.isScheduled) + assertNull(notice.publishAt) + assertNull(notice.createdAt) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt b/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt index d30246b8..2535ac39 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt @@ -33,6 +33,7 @@ class H2MysqlDateFunctions { .replace("%W", "EEEE") .replace("%h", "hh") .replace("%i", "mm") + .replace("%s", "ss") .replace("%p", "a") return value.toLocalDateTime().format(DateTimeFormatter.ofPattern(javaPattern)) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/AdminNoticeControllerIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/AdminNoticeControllerIntegrationTest.kt new file mode 100644 index 00000000..3a4ff2f2 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/notice/AdminNoticeControllerIntegrationTest.kt @@ -0,0 +1,695 @@ +package kr.co.vividnext.sodalive.v2.api.admin.notice + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.jwt.TokenProvider +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.token.MemberTokenRepository +import kr.co.vividnext.sodalive.notice.ServiceNotice +import kr.co.vividnext.sodalive.notice.ServiceServiceNoticeRepository +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate +import java.sql.Timestamp +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.UUID +import java.util.concurrent.atomic.AtomicReference +import javax.persistence.EntityManager + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "jwt.secret=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYwMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2RlZg==", + "spring.datasource.url=jdbc:h2:mem:admin-notice-http;" + + "MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@Import(AdminNoticeControllerIntegrationTest.TimeConfiguration::class) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class AdminNoticeControllerIntegrationTest @Autowired constructor( + private val restTemplate: TestRestTemplate, + private val objectMapper: ObjectMapper, + private val repository: ServiceServiceNoticeRepository, + private val memberRepository: MemberRepository, + private val tokenProvider: TokenProvider, + private val tokenRepository: MemberTokenRepository, + private val jdbcTemplate: JdbcTemplate, + private val entityManager: EntityManager, + private val transactionManager: PlatformTransactionManager, + private val noticeTime: AtomicReference +) { + private lateinit var adminToken: String + private val memberIds = mutableListOf() + + @BeforeEach + fun setUp() { + noticeTime.set(LocalDateTime.of(2026, 10, 30, 0, 0, 0)) + repository.deleteAllInBatch() + jdbcTemplate.execute("ALTER TABLE service_notice ALTER COLUMN publish_at TIMESTAMP(0)") + jdbcTemplate.execute( + "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" + ) + jdbcTemplate.execute( + "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" + ) + adminToken = tokenFor(MemberRole.ADMIN) + } + + @ParameterizedTest + @ValueSource(strings = ["", ",\"publishAt\":null"]) + @DisplayName("생략과 null 생성은 기존 ID 응답과 원본 등록 시각을 유지하는 즉시 공지다") + fun shouldCreateImmediateNoticeWhenPublishAtIsOmittedOrNull(publishAtField: String) { + val body = postNotice("""{"title":"공지","content":"본문"$publishAtField}""") + val notice = repository.findAll().single() + + assertCreated(body, requireNotNull(notice.id)) + assertEquals("공지", notice.title) + assertEquals("본문", notice.content) + assertTrue(notice.isActive) + assertEquals(false, notice.isScheduled) + assertNull(notice.publishAt) + assertNotNull(notice.createdAt) + } + + @ParameterizedTest + @CsvSource( + "2026-11-01 09:00:00, 2026-11-01T00:00:00", + "2026-10-30 09:00:01, 2026-10-30T00:00:01", + "2028-02-29 00:00:00, 2028-02-28T15:00:00", + "2038-01-19 12:14:07, 2038-01-19T03:14:07" + ) + @DisplayName("미래 KST 생성은 UTC 초 단위 예약과 기존 생성 ID 응답을 저장한다") + fun shouldCreateScheduledNoticeWithUtcPublishAt(publishAt: String, expectedUtc: String) { + val body = postNotice("""{"title":"예약 공지","content":"예약 본문","publishAt":"$publishAt"}""") + val notice = repository.findAll().single() + + assertCreated(body, requireNotNull(notice.id)) + assertEquals("예약 공지", notice.title) + assertEquals("예약 본문", notice.content) + assertTrue(notice.isActive) + assertEquals(true, notice.isScheduled) + assertEquals(LocalDateTime.parse(expectedUtc), notice.publishAt) + } + + @AfterEach + fun cleanUp() { + repository.deleteAllInBatch() + memberIds.forEach { tokenRepository.deleteById(it) } + memberRepository.deleteAllById(memberIds) + memberIds.clear() + } + + @ParameterizedTest + @ValueSource( + strings = [ + "42", "true", "{}", "[]", "[\"2026-11-01 09:00:00\"]", + "\"\"", "\"2026-02-30 09:00:00\"", "\"2026-11-31 09:00:00\"", + "\"2026-11-01T09:00:00\"", "\"2026-11-01 09:00\"", "\"2026-11-01 09:00:00.0\"", + "\"2026-11-01 09:00:00Z\"", "\" 2026-11-01 09:00:00\"", "\"2026-11-01 09:00:00 \"", + "\"2026-1-01 09:00:00\"", "\"2026-11-01 24:00:00\"", "\"2026-11-01 09:00:60\"", + "\"2026-10-30 09:00:00\"", "\"2026-10-30 08:59:59\"", + "\"1970-01-01 09:00:00\"", "\"2038-01-19 12:14:08\"", "\"9999-12-31 23:59:59\"" + ] + ) + @DisplayName("생성은 문자열 외 타입과 엄격한 형식·달력·미래·TIMESTAMP 범위 위반을 저장하지 않는다") + fun shouldRejectInvalidPublishAtWithoutPersistence(publishAtJson: String) { + val body = postNotice("""{"title":"공지","content":"본문","publishAt":$publishAtJson}""") + + assertEquals(error("Invalid request."), body) + assertEquals(0L, repository.count()) + } + + @ParameterizedTest + @ValueSource(strings = ["[]", "true", "42", "\"notice\"", "null"]) + @DisplayName("생성 요청은 JSON object만 허용한다") + fun shouldRejectNonObjectCreateRequest(json: String) { + assertEquals(error("Invalid request."), postNotice(json)) + assertEquals(0L, repository.count()) + } + + @Test + @DisplayName("생성은 기존 제목과 본문 공백 오류 envelope를 유지한다") + fun shouldPreserveBlankTitleAndContentErrors() { + assertEquals(error("Please enter a title."), postNotice("""{"title":" ","content":"본문"}""")) + assertEquals(error("Please enter content."), postNotice("""{"title":"공지","content":" "}""")) + assertEquals(0L, repository.count()) + } + + @Test + @DisplayName("실제 보안 체인은 익명 생성을 401로 거부하고 일반 회원은 기존 권한 오류를 반환한다") + fun shouldPreserveAdminOnlyCreation() { + val json = """{"title":"공지","content":"본문","publishAt":"2026-11-01 09:00:00"}""" + val anonymous = restTemplate.postForEntity("/notice", request(json, null), String::class.java) + assertEquals(HttpStatus.UNAUTHORIZED, anonymous.statusCode) + assertEquals(error("You do not have permission."), postNotice(json, tokenFor(MemberRole.USER))) + assertEquals(0L, repository.count()) + } + + @Test + @DisplayName("publishAt 생략 수정은 제목·본문만 변경하고 예약 필드와 등록 시각을 보존한다") + fun shouldPreserveScheduleWhenPublishAtIsOmitted() { + val before = scheduledNotice() + val id = requireNotNull(before.id) + + assertUpdated(putNotice("""{"id":$id,"title":"변경 제목","content":null}""")) + assertUpdated(putNotice("""{"id":$id,"title":" ","content":"변경 본문"}""")) + + val stored = reload(id) + assertEquals("변경 제목", stored.title) + assertEquals("변경 본문", stored.content) + assertEquals(before.publishAt, stored.publishAt) + assertEquals(true, stored.isScheduled) + assertEquals(before.createdAt, stored.createdAt) + } + + @Test + @DisplayName("미공개 예약은 제목·본문과 미래 시각을 한 번에 변경한다") + fun shouldRescheduleUnpublishedNotice() { + val before = scheduledNotice() + val id = requireNotNull(before.id) + + assertUpdated( + putNotice("""{"id":$id,"title":"변경 제목","content":"변경 본문","publishAt":"2026-11-02 10:00:00"}""") + ) + + val stored = reload(id) + assertEquals("변경 제목", stored.title) + assertEquals("변경 본문", stored.content) + assertEquals(LocalDateTime.of(2026, 11, 2, 1, 0, 0), stored.publishAt) + assertEquals(true, stored.isScheduled) + assertEquals(before.createdAt, stored.createdAt) + } + + @Test + @DisplayName("제목·본문이 없는 예약 시각만의 수정도 유효하다") + fun shouldAllowScheduleOnlyUpdate() { + val before = scheduledNotice() + val id = requireNotNull(before.id) + + assertUpdated(putNotice("""{"id":$id,"publishAt":"2026-11-02 09:00:00"}""")) + + val stored = reload(id) + assertEquals(before.title, stored.title) + assertEquals(before.content, stored.content) + assertEquals(LocalDateTime.of(2026, 11, 2, 0, 0, 0), stored.publishAt) + assertEquals(true, stored.isScheduled) + assertEquals(before.createdAt, stored.createdAt) + } + + @ParameterizedTest + @CsvSource( + "2026-10-30T00:00:00, 2026-10-30T00:00:00", + "2026-10-30T00:00:00.123456789, 2026-10-30T00:00:00", + "2026-10-30T00:00:00.999999999, 2026-10-30T00:00:00", + "2026-10-30T23:59:59, 2026-10-30T23:59:59", + "2026-10-30T23:59:59.123456789, 2026-10-30T23:59:59", + "2026-10-30T23:59:59.999999999, 2026-10-30T23:59:59" + ) + @DisplayName("명시 null 즉시 전환은 원본 등록 시각을 보존하고 DB 왕복 시 초·날짜를 반올림하지 않는다") + fun shouldConvertScheduledNoticeToImmediateWhenPublishAtIsNull(now: String, expectedUtc: String) { + val before = scheduledNotice() + val id = requireNotNull(before.id) + noticeTime.set(LocalDateTime.parse(now)) + + assertUpdated(putNotice("""{"id":$id,"publishAt":null}""")) + + val stored = reload(id) + assertEquals(LocalDateTime.parse(expectedUtc), stored.publishAt) + assertEquals(0, requireNotNull(stored.publishAt).nano) + assertEquals(false, stored.isScheduled) + assertEquals(before.title, stored.title) + assertEquals(before.content, stored.content) + assertEquals(before.createdAt, stored.createdAt) + assertEquals( + 0, + jdbcTemplate.queryForObject( + "SELECT DATETIME_PRECISION FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'service_notice' AND COLUMN_NAME = 'publish_at'", + Int::class.java + ) + ) + val body = getNotice("/api/v2/admin/notices?page=0&size=20") + val expectedKst = if (expectedUtc.endsWith("23:59:59")) "2026-10-31 08:59:59" else "2026-10-30 09:00:00" + assertEquals( + envelope( + """{"totalCount":1,"noticeList":[ + {"id":$id,"title":"예약 제목","content":"예약 본문","isActive":true,"isScheduled":false, + "publishAt":"$expectedKst","createdAt":"2026-10-29 23:59:59"}]}""" + ), + body + ) + val responseUtc = LocalDateTime.parse(body["data"]["noticeList"][0]["publishAt"].asText(), ADMIN_TIME_FORMAT) + .atZone(ZoneId.of("Asia/Seoul")).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime() + assertEquals(stored.publishAt, responseUtc) + println("Precision input=$now DB=${stored.publishAt} adminKst=$expectedKst parsedUTC=$responseUtc") + } + + @ParameterizedTest + @ValueSource(strings = ["2026-11-01T00:00:00", "2026-11-01T00:00:00.000000001", "2026-11-02T00:00:00"]) + @DisplayName("공개 경계 정각 이후에는 같은 시각 재지정과 null을 포함한 모든 시각 수정을 거부한다") + fun shouldRejectRescheduleAfterPublishBoundary(now: String) { + val before = scheduledNotice() + val id = requireNotNull(before.id) + noticeTime.set(LocalDateTime.parse(now)) + + listOf("null", "\"2026-11-01 09:00:00\"", "\"2026-11-03 09:00:00\"").forEach { publishAt -> + assertEquals( + error("Invalid request."), + putNotice("""{"id":$id,"title":"반영 금지","publishAt":$publishAt}""") + ) + assertEquals(before, reload(id)) + } + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + @DisplayName("즉시 생성 또는 즉시 전환한 공지는 null과 미래 시각 모두 재예약할 수 없다") + fun shouldRejectScheduleChangesForImmediateNotice(converted: Boolean) { + val before = repository.saveAndFlush( + ServiceNotice("즉시 공지", "본문", publishAt = if (converted) noticeTime.get() else null) + ) + val id = requireNotNull(before.id) + + listOf("null", "\"2026-11-03 09:00:00\"").forEach { publishAt -> + assertEquals( + error("Invalid request."), + putNotice("""{"id":$id,"title":"반영 금지","publishAt":$publishAt}""") + ) + } + val stored = reload(id) + assertEquals(before.title, stored.title) + assertEquals(before.publishAt, stored.publishAt) + assertEquals(false, stored.isScheduled) + } + + @Test + @DisplayName("비활성 예약의 시각 변경은 거부하지만 기존 제목·본문 수정은 유지한다") + fun shouldRejectScheduleChangesForInactiveNoticeButKeepLegacyEdits() { + val before = scheduledNotice() + val id = requireNotNull(before.id) + jdbcTemplate.update("UPDATE service_notice SET is_active = false WHERE id = ?", id) + + assertEquals(error("Invalid request."), putNotice("""{"id":$id,"title":"반영 금지","publishAt":null}""")) + assertEquals( + error("Invalid request."), + putNotice("""{"id":$id,"publishAt":"2026-11-03 09:00:00"}""") + ) + assertUpdated(putNotice("""{"id":$id,"title":"비활성 제목"}""")) + val stored = reload(id) + assertEquals("비활성 제목", stored.title) + assertEquals(false, stored.isActive) + assertEquals(before.publishAt, stored.publishAt) + assertEquals(true, stored.isScheduled) + } + + @Test + @DisplayName("예약이 공개된 이후에도 제목·본문만 수정하는 기존 요청은 허용한다") + fun shouldAllowTitleAndContentEditsAfterPublication() { + val before = scheduledNotice() + val id = requireNotNull(before.id) + noticeTime.set(LocalDateTime.of(2026, 11, 1, 0, 0, 0)) + + assertUpdated(putNotice("""{"id":$id,"title":"공개 후 제목","content":"공개 후 본문"}""")) + val stored = reload(id) + assertEquals("공개 후 제목", stored.title) + assertEquals("공개 후 본문", stored.content) + assertEquals(before.publishAt, stored.publishAt) + assertEquals(before.isScheduled, stored.isScheduled) + assertEquals(before.createdAt, stored.createdAt) + } + + @ParameterizedTest + @ValueSource( + strings = [ + "42", "true", "{}", "[]", "[\"2026-11-01 09:00:00\"]", + "\"\"", "\"2026-02-30 09:00:00\"", "\"2026-11-31 09:00:00\"", + "\"2026-11-01T09:00:00\"", "\"2026-11-01 09:00\"", "\"2026-11-01 09:00:00.0\"", + "\"2026-11-01 09:00:00Z\"", "\" 2026-11-01 09:00:00\"", "\"2026-11-01 09:00:00 \"", + "\"2026-1-01 09:00:00\"", "\"2026-11-01 24:00:00\"", "\"2026-11-01 09:00:60\"", + "\"2026-10-30 09:00:00\"", "\"2026-10-30 08:59:59\"", + "\"1970-01-01 09:00:00\"", "\"2038-01-19 12:14:08\"", "\"9999-12-31 23:59:59\"" + ] + ) + @DisplayName("잘못된 시각 수정은 타입·형식·달력·미래·범위를 거부하며 제목·본문도 부분 반영하지 않는다") + fun shouldRejectInvalidUpdatePublishAtWithoutPartialChanges(publishAtJson: String) { + val before = scheduledNotice() + val id = requireNotNull(before.id) + + assertEquals( + error("Invalid request."), + putNotice("""{"id":$id,"title":"반영 금지","content":"반영 금지","publishAt":$publishAtJson}""") + ) + assertEquals(before, reload(id)) + } + + @ParameterizedTest + @ValueSource(strings = ["[]", "true", "42", "\"notice\"", "null"]) + @DisplayName("수정 요청은 JSON object만 허용한다") + fun shouldRejectNonObjectUpdateRequest(json: String) { + assertEquals(error("Invalid request."), putNotice(json)) + assertEquals(0L, repository.count()) + } + + @Test + @DisplayName("잘못된 ID와 수정 필드가 없는 요청의 기존 business 오류를 보존한다") + fun shouldPreserveInvalidIdAndEmptyUpdateErrors() { + val id = requireNotNull(scheduledNotice().id) + listOf(0L, -1L, Long.MAX_VALUE).forEach { invalidId -> + assertEquals(error("Invalid request."), putNotice("""{"id":$invalidId,"title":"변경"}""")) + } + listOf("", ",\"title\":null,\"content\":null", ",\"title\":\" \",\"content\":\"\"").forEach { fields -> + assertEquals(error("Please enter content to update."), putNotice("""{"id":$id$fields}""")) + } + assertEquals(error("Invalid request."), putNotice("""{"id":0,"publishAt":null}""")) + } + + @Test + @DisplayName("실제 JWT 보안 체인은 PUT·DELETE도 ADMIN 전용이며 기존 soft delete 응답을 유지한다") + fun shouldPreserveAdminOnlyUpdateAndDelete() { + val before = scheduledNotice() + val id = requireNotNull(before.id) + val json = """{"id":$id,"title":"반영 금지"}""" + val userToken = tokenFor(MemberRole.USER) + listOf(HttpMethod.PUT, HttpMethod.DELETE).forEach { method -> + val path = if (method == HttpMethod.PUT) "/notice" else "/notice/$id" + val anonymous = restTemplate.exchange(path, method, request(json, null), String::class.java) + assertEquals(HttpStatus.UNAUTHORIZED, anonymous.statusCode) + val forbidden = restTemplate.exchange(path, method, request(json, userToken), String::class.java) + assertEquals(HttpStatus.OK, forbidden.statusCode) + assertEquals(error("You do not have permission."), objectMapper.readTree(forbidden.body)) + } + assertEquals(before, reload(id)) + val response = restTemplate.exchange("/notice/$id", HttpMethod.DELETE, request("", adminToken), String::class.java) + println("HTTP DELETE /notice/$id -> ${response.statusCodeValue} ${response.body}") + assertEquals(HttpStatus.OK, response.statusCode) + assertEquals( + objectMapper.readTree("""{"success":true,"message":"삭제되었습니다.","data":{},"errorProperty":null}"""), + objectMapper.readTree(response.body) + ) + val deleted = reload(id) + assertEquals(false, deleted.isActive) + assertEquals(before.publishAt, deleted.publishAt) + assertEquals(before.createdAt, deleted.createdAt) + } + + @ParameterizedTest + @ValueSource(strings = ["anonymous", "USER", "ADMIN"]) + @DisplayName("관리자를 포함한 모든 공개 경로는 경계 전 예약을 제외한다") + fun shouldExcludeScheduledNoticeBeforeBoundaryForAdminOnPublicRoutes(role: String) { + val id = saveNotice("공개", "2026-10-29T15:00:00") + repeat(5) { saveNotice("미공개", "2026-10-29T16:00:00", "2026-11-01T00:00:00") } + saveNotice("비활성", "2026-10-29T17:00:00", isActive = false) + val token = if (role == "anonymous") null else tokenFor(MemberRole.valueOf(role)) + val item = """{"id":$id,"title":"공개","content":"본문","date":"2026-10-30"}""" + + assertEquals(envelope(item), getNotice("/notice/latest", token)) + assertEquals(envelope("[$item]"), getNotice("/api/v2/notices/latest", token)) + if (token != null) { + assertEquals( + envelope("""{"totalCount":1,"noticeList":[$item]}"""), + getNotice("/notice?page=0&size=2&timezone=UTC", token) + ) + } + } + + @ParameterizedTest + @CsvSource( + "2026-10-31T23:59:59.999999999, false", + "2026-11-01T00:00:00, true", + "2026-11-01T00:00:00.000000001, true", + "2026-11-01T00:00:00.999999999, true", + "2026-11-02T00:00:00, true" + ) + @DisplayName("원본 나노초 now로 공개 전·정각·후 경계를 판정하고 예약 공개 날짜를 쓴다") + fun shouldIncludeNoticeAtExactPublishBoundary(now: String, visible: Boolean) { + val id = saveNotice("예약", "2026-10-29T14:59:59", "2026-11-01T00:00:00") + saveNotice("비활성 예약", "2026-10-29T14:59:59", "2026-11-01T00:00:00", false) + noticeTime.set(LocalDateTime.parse(now)) + val item = """{"id":$id,"title":"예약","content":"본문","date":"2026-11-01"}""" + val items = if (visible) "[$item]" else "[]" + + assertEquals(envelope(if (visible) item else "null"), getNotice("/notice/latest", null)) + assertEquals(envelope(items), getNotice("/api/v2/notices/latest", null)) + assertEquals( + envelope("""{"totalCount":${if (visible) 1 else 0},"noticeList":$items}"""), + getNotice("/notice?page=0&size=20&timezone=UTC") + ) + assertEquals(true, reload(id).isScheduled) + } + + @Test + @DisplayName("공개 목록과 v2 latest는 미공개 5건을 먼저 제거하고 정렬·총 건수·페이지 크기를 유지한다") + fun shouldFilterAndOrderBeforePublicPaginationAndLatestLimit() { + val newest = saveNotice("최신", "2026-10-29T14:00:00") + val oldest = saveNotice("이전", "2026-10-26T00:00:00") + val middle = saveNotice("중간", "2026-10-28T00:00:00") + val second = saveNotice("예약 공개", "2026-10-25T00:00:00", "2026-10-29T00:00:00") + repeat(5) { saveNotice("미공개", "2026-10-29T15:00:00", "2026-11-01T00:00:00") } + val newestItem = """{"id":$newest,"title":"최신","content":"본문","date":"2026-10-29"}""" + val secondItem = """{"id":$second,"title":"예약 공개","content":"본문","date":"2026-10-29"}""" + val middleItem = """{"id":$middle,"title":"중간","content":"본문","date":"2026-10-28"}""" + val oldestItem = """{"id":$oldest,"title":"이전","content":"본문","date":"2026-10-26"}""" + val user = tokenFor(MemberRole.USER) + + assertEquals(envelope("[$newestItem,$secondItem,$middleItem]"), getNotice("/api/v2/notices/latest", null)) + assertEquals(envelope(newestItem), getNotice("/notice/latest")) + assertEquals( + envelope("""{"totalCount":4,"noticeList":[$newestItem,$secondItem,$middleItem]}"""), + getNotice("/notice?page=0&size=3&timezone=Asia/Seoul", user) + ) + assertEquals( + envelope("""{"totalCount":4,"noticeList":[$oldestItem]}"""), + getNotice("/notice?page=1&size=3&timezone=Asia/Seoul", user) + ) + assertEquals( + envelope("""{"totalCount":4,"noticeList":[]}"""), + getNotice("/notice?page=2&size=3&timezone=Asia/Seoul", user) + ) + } + + @Test + @DisplayName("KST 자정을 넘긴 즉시 전환은 전환 시각 순서와 원래 등록 날짜를 분리한다") + fun shouldUseRegistrationDateButConversionTimeForImmediateScheduleConversion() { + val converted = saveNotice("전환", "2026-10-31T14:50:00", "2026-11-02T00:00:00") + val competitor = saveNotice("경쟁", "2026-10-31T15:05:00") + noticeTime.set(LocalDateTime.of(2026, 10, 31, 15, 10, 0, 999999999)) + assertUpdated(putNotice("""{"id":$converted,"publishAt":null}""")) + val convertedItem = """{"id":$converted,"title":"전환","content":"본문","date":"2026-10-31"}""" + val competitorItem = """{"id":$competitor,"title":"경쟁","content":"본문","date":"2026-11-01"}""" + + assertEquals(envelope(convertedItem), getNotice("/notice/latest")) + assertEquals(envelope("[$convertedItem,$competitorItem]"), getNotice("/api/v2/notices/latest", null)) + assertEquals( + envelope("""{"totalCount":2,"noticeList":[$convertedItem,$competitorItem]}"""), + getNotice("/notice?page=0&size=20&timezone=UTC") + ) + assertEquals(LocalDateTime.of(2026, 10, 31, 15, 10, 0), reload(converted).publishAt) + } + + @Test + @DisplayName("관리자 목록은 미래 활성 예약도 포함하고 공개 시각·id 역순의 0-based 페이지와 전체 DTO를 반환한다") + fun shouldIncludeActiveFutureNoticesInAdminPages() { + val immediate = saveNotice("즉시", "2026-10-29T14:59:58.123456") + val first = saveNotice("예약1", "2026-10-28T14:59:57.654321", "2026-11-01T00:00:13") + val second = saveNotice("예약2", "2026-10-27T14:59:56", "2026-11-01T00:00:13") + saveNotice("비활성", "2026-10-29T00:00:00", "2026-11-02T00:00:00", false) + val firstItem = """{"id":$first,"title":"예약1","content":"본문","isActive":true,"isScheduled":true, + "publishAt":"2026-11-01 09:00:13","createdAt":"2026-10-28 23:59:57"}""" + val secondItem = """{"id":$second,"title":"예약2","content":"본문","isActive":true,"isScheduled":true, + "publishAt":"2026-11-01 09:00:13","createdAt":"2026-10-27 23:59:56"}""" + val immediateItem = """{"id":$immediate,"title":"즉시","content":"본문","isActive":true,"isScheduled":false, + "publishAt":null,"createdAt":"2026-10-29 23:59:58"}""" + + assertEquals( + envelope("""{"totalCount":3,"noticeList":[$secondItem,$firstItem]}"""), + getNotice("/api/v2/admin/notices?page=0&size=2") + ) + assertEquals( + envelope("""{"totalCount":3,"noticeList":[$immediateItem]}"""), + getNotice("/api/v2/admin/notices?page=1&size=2") + ) + assertEquals( + envelope("""{"totalCount":3,"noticeList":[]}"""), + getNotice("/api/v2/admin/notices?page=2&size=2") + ) + assertEquals(LocalDateTime.parse("2026-10-29T14:59:58.123456"), reload(immediate).createdAt) + noticeTime.set(LocalDateTime.of(2026, 11, 2, 0, 0, 0)) + assertEquals(true, getNotice("/api/v2/admin/notices")["data"]["noticeList"][0]["isScheduled"].asBoolean()) + } + + @Test + @DisplayName("관리자 목록은 빈 목록을 유지하며 익명 401·비관리자 기존 권한 오류·ADMIN 200을 보장한다") + fun shouldRestrictAdminListWithoutExpandingAnonymousRoutes() { + val path = "/api/v2/admin/notices?page=0&size=20" + listOf(path, "/notice?page=0&size=20&timezone=UTC").forEach { protectedPath -> + val anonymous = restTemplate.exchange(protectedPath, HttpMethod.GET, request("", null), String::class.java) + println("HTTP anonymous GET $protectedPath -> ${anonymous.statusCodeValue} ${anonymous.body}") + assertEquals(HttpStatus.UNAUTHORIZED, anonymous.statusCode) + } + assertEquals(error("You do not have permission."), getNotice(path, tokenFor(MemberRole.USER))) + assertEquals(envelope("""{"totalCount":0,"noticeList":[]}"""), getNotice(path)) + } + + private fun saveNotice( + title: String, + createdAt: String, + publishAt: String? = null, + isActive: Boolean = true + ): Long { + val id = requireNotNull( + repository.saveAndFlush( + ServiceNotice(title, "본문", isActive, publishAt?.let(LocalDateTime::parse), publishAt != null) + ).id + ) + assertEquals( + 1, + jdbcTemplate.update( + "UPDATE service_notice SET created_at = ? WHERE id = ?", + Timestamp.valueOf(LocalDateTime.parse(createdAt)), + id + ) + ) + return id + } + + private fun getNotice(path: String, token: String? = adminToken): JsonNode { + val response = restTemplate.exchange(path, HttpMethod.GET, request("", token), String::class.java) + println( + "HTTP ${if (token == null) "anonymous" else "authenticated"} GET $path -> " + + "${response.statusCodeValue} ${response.body}" + ) + assertEquals(HttpStatus.OK, response.statusCode) + return objectMapper.readTree(response.body) + } + + private fun envelope(data: String): JsonNode = objectMapper.readTree( + """{"success":true,"message":null,"data":$data,"errorProperty":null}""" + ) + + private fun scheduledNotice(): ServiceNotice { + val notice = repository.saveAndFlush( + ServiceNotice("예약 제목", "예약 본문", publishAt = LocalDateTime.of(2026, 11, 1, 0, 0, 0), isScheduled = true) + ) + val id = requireNotNull(notice.id) + jdbcTemplate.update( + "UPDATE service_notice SET created_at = ? WHERE id = ?", + Timestamp.valueOf("2026-10-29 14:59:59.123456"), + id + ) + return reload(id) + } + + private fun reload(id: Long): ServiceNotice { + return requireNotNull( + TransactionTemplate(transactionManager).execute { + entityManager.flush() + entityManager.clear() + repository.findById(id).orElseThrow().also { + println( + "DB reload id=$id title=${it.title} active=${it.isActive} scheduled=${it.isScheduled} " + + "publishAt=${it.publishAt} createdAt=${it.createdAt}" + ) + } + } + ) + } + + private fun putNotice(json: String, token: String = adminToken): JsonNode { + val response = restTemplate.exchange("/notice", HttpMethod.PUT, request(json, token), String::class.java) + println("HTTP PUT /notice input=$json -> ${response.statusCodeValue} ${response.body}") + assertEquals(HttpStatus.OK, response.statusCode) + return objectMapper.readTree(response.body) + } + + private fun assertUpdated(body: JsonNode) { + assertEquals( + objectMapper.readTree("""{"success":true,"message":"수정되었습니다.","data":{},"errorProperty":null}"""), + body + ) + } + + private fun postNotice(json: String, token: String = adminToken): JsonNode { + val response = restTemplate.postForEntity("/notice", request(json, token), String::class.java) + println("HTTP POST /notice input=$json -> ${response.statusCodeValue} ${response.body}") + assertEquals(HttpStatus.OK, response.statusCode) + return objectMapper.readTree(response.body) + } + + private fun request(json: String, token: String?): HttpEntity = HttpEntity( + json, + HttpHeaders().apply { + contentType = MediaType.APPLICATION_JSON + set(HttpHeaders.ACCEPT_LANGUAGE, "en") + if (token != null) setBearerAuth(token) + } + ) + + private fun error(message: String): JsonNode = objectMapper.readTree( + """{"success":false,"message":"$message","data":null,"errorProperty":null}""" + ) + + private fun assertCreated(body: JsonNode, id: Long) { + assertEquals( + objectMapper.readTree("""{"success":true,"message":"등록되었습니다.","data":$id,"errorProperty":null}"""), + body + ) + } + + private fun tokenFor(role: MemberRole): String { + val member = memberRepository.saveAndFlush( + Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "test", role = role) + ) + val id = requireNotNull(member.id) + memberIds.add(id) + return tokenProvider.createToken( + UsernamePasswordAuthenticationToken(member.email, "", listOf(SimpleGrantedAuthority("ROLE_${role.name}"))), + id + ) + } + + @TestConfiguration + class TimeConfiguration { + @Bean + fun noticeTime(): AtomicReference = AtomicReference(LocalDateTime.of(2026, 10, 30, 0, 0, 0)) + + @Bean + fun noticeNowProvider(noticeTime: AtomicReference): () -> LocalDateTime = { noticeTime.get() } + } + + companion object { + private val ADMIN_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt index 3c0260a3..5ffad992 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt @@ -13,7 +13,10 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import import org.springframework.http.HttpEntity import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus @@ -22,6 +25,7 @@ import org.springframework.test.annotation.DirtiesContext import org.springframework.test.context.ContextConfiguration import java.sql.Timestamp import java.time.LocalDateTime +import java.util.concurrent.atomic.AtomicInteger @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, @@ -31,12 +35,14 @@ import java.time.LocalDateTime ] ) @ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@Import(LatestNoticeControllerIntegrationTest.TimeConfiguration::class) @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) class LatestNoticeControllerIntegrationTest @Autowired constructor( private val restTemplate: TestRestTemplate, private val repository: ServiceServiceNoticeRepository, private val jdbcTemplate: JdbcTemplate, - private val objectMapper: ObjectMapper + private val objectMapper: ObjectMapper, + private val nowCalls: AtomicInteger ) { @BeforeEach fun setUp() { @@ -47,6 +53,7 @@ class LatestNoticeControllerIntegrationTest @Autowired constructor( "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" ) repository.deleteAllInBatch() + nowCalls.set(0) } @AfterEach @@ -55,15 +62,15 @@ class LatestNoticeControllerIntegrationTest @Autowired constructor( } @Test - @DisplayName("익명 요청은 작성 시각과 무관하게 활성 공지 id 역순 최대 3개와 전체 필드를 반환한다") - fun shouldReturnLatestThreeActiveNoticesByIdWithUnchangedFields() { - saveNotice("제외될 활성 공지", "오래된 id", "2026-10-30T12:00:00") + @DisplayName("익명 요청은 유효 공개 시각 역순 최대 3개와 전체 필드를 반환한다") + fun shouldReturnLatestThreeActiveNoticesByPublicationWithUnchangedFields() { + saveNotice("제외될 활성 공지", "오래된 공개 시각", "2026-10-26T12:00:00") val second = saveNotice("두 번째 공지", "본문 2", "2026-10-29T12:00:00") val third = saveNotice("세 번째 공지", "본문 3", "2026-10-28T12:00:00") val fourth = saveNotice( "최근 서비스 공지", "서비스 공지\n두 번째 줄: 전체 본문 & \"인용\"", - "2026-10-27T15:00:00" + "2026-10-29T15:00:00" ) saveNotice("더 큰 비활성 id", "공개하면 안 되는 본문", "2026-10-31T12:00:00", false) @@ -72,9 +79,9 @@ class LatestNoticeControllerIntegrationTest @Autowired constructor( """ {"success":true,"message":null,"data":[ {"id":$fourth,"title":"최근 서비스 공지", - "content":"서비스 공지\n두 번째 줄: 전체 본문 & \"인용\"","date":"2026-10-28"}, - {"id":$third,"title":"세 번째 공지","content":"본문 3","date":"2026-10-28"}, - {"id":$second,"title":"두 번째 공지","content":"본문 2","date":"2026-10-29"} + "content":"서비스 공지\n두 번째 줄: 전체 본문 & \"인용\"","date":"2026-10-30"}, + {"id":$second,"title":"두 번째 공지","content":"본문 2","date":"2026-10-29"}, + {"id":$third,"title":"세 번째 공지","content":"본문 3","date":"2026-10-28"} ],"errorProperty":null} """.trimIndent() ) @@ -121,7 +128,7 @@ class LatestNoticeControllerIntegrationTest @Autowired constructor( } @Test - @DisplayName("활성 공지 2개는 중복 없이 id 역순으로 반환한다") + @DisplayName("활성 공지 2개는 중복 없이 공개 시각 역순으로 반환한다") fun shouldReturnTwoNoticesWithoutPadding() { val first = saveNotice("첫 공지", "첫 본문", "2026-10-28T12:00:00") val second = saveNotice("다음 공지", "다음 본문", "2026-10-27T12:00:00") @@ -130,8 +137,8 @@ class LatestNoticeControllerIntegrationTest @Autowired constructor( "/api/v2/notices/latest", """ {"success":true,"message":null,"data":[ - {"id":$second,"title":"다음 공지","content":"다음 본문","date":"2026-10-27"}, - {"id":$first,"title":"첫 공지","content":"첫 본문","date":"2026-10-28"} + {"id":$first,"title":"첫 공지","content":"첫 본문","date":"2026-10-28"}, + {"id":$second,"title":"다음 공지","content":"다음 본문","date":"2026-10-27"} ],"errorProperty":null} """.trimIndent() ) @@ -152,7 +159,7 @@ class LatestNoticeControllerIntegrationTest @Autowired constructor( @Test @DisplayName("기존 v1 최근 공지는 배열이 아닌 최신 활성 공지 단일 객체를 유지한다") fun shouldKeepLegacyLatestNoticeAsAnObject() { - saveNotice("이전 공지", "이전 본문", "2026-10-29T12:00:00") + saveNotice("이전 공지", "이전 본문", "2026-10-26T12:00:00") val latest = saveNotice("기존 최근 공지", "기존 전체 본문", "2026-10-27T15:00:00") saveNotice("비활성", "미공개 본문", "2026-10-30T12:00:00", false) @@ -172,6 +179,15 @@ class LatestNoticeControllerIntegrationTest @Autowired constructor( assertGet("/notice/latest", """{"success":true,"message":null,"data":null,"errorProperty":null}""") } + @ParameterizedTest + @CsvSource("/notice/latest", "/api/v2/notices/latest") + @DisplayName("각 공개 최신 조회는 서비스에서 UTC now를 정확히 한 번 캡처한다") + fun shouldCaptureNowOnceForEachLatestService(path: String) { + val response = restTemplate.getForEntity(path, String::class.java) + assertEquals(HttpStatus.OK, response.statusCode) + assertEquals(1, nowCalls.get()) + } + @ParameterizedTest @CsvSource("POST, /api/v2/notices/latest", "GET, /api/v2/notices/private") @DisplayName("익명 POST 및 인접 보호 경로는 공개하지 않는다") @@ -202,4 +218,16 @@ class LatestNoticeControllerIntegrationTest @Autowired constructor( ) return noticeId } + + @TestConfiguration + class TimeConfiguration { + @Bean + fun nowCalls(): AtomicInteger = AtomicInteger() + + @Bean + fun noticeNowProvider(nowCalls: AtomicInteger): () -> LocalDateTime = { + nowCalls.incrementAndGet() + LocalDateTime.of(2026, 10, 30, 0, 0, 0) + } + } }