Test #470

Merged
klaus merged 7 commits from test into main 2026-11-11 07:29:03 +00:00
2 changed files with 324 additions and 0 deletions
Showing only changes of commit cc85322eb5 - Show all commits
@@ -0,0 +1,184 @@
# 자막 Phase 1 Spring 연동 구현 계획
**Goal:** 기존 업로드와 권한을 보존하면서 자막 작업 등록, 네 BOT 내부 API, 언어별 안전한 자막 조회를 제공한다.
**Architecture:** 기존 Kotlin/Spring/JPA와 member JWT를 재사용한다. 신규 자막 기능은 `v2.subtitle` 하위에 두고 기존 콘텐츠 서비스의 업로드 및 상세에만 연결한다. 원본 S3 쓰기 이전 commit과 callback receipt/status/metadata의 원자성을 보장한다.
**Tech Stack:** Kotlin 1.6.21, Java 17, Spring Boot 2.7.14, JPA, MySQL 운영 DDL, H2 테스트.
**Spec:** [prd.md](prd.md). 사용자 승인 범위이며 재생성 기능은 제외한다.
## 현재 상태
- Phase 1 완료 기록 유지. Phase 2 generate-url 구현·새 HTTP3개·독립 리뷰·임시 자원 정리 완료. 활성 로컬 Goal 없음. 운영 배포/외부 게이트는 여전히 BLOCKED.
- 실행: subagent-driven-development, 공유 Kotlin 구현은 단일 담당자가 순차 처리한다. 엔티티 계약이 확보된 additive DDL·운영 문서는 별도 담당자가 병렬 준비하고 마지막에 대조한다.
- 새 worktree는 만들지 않는다. 기존 `test` 작업 트리는 조사 시 clean이며 main/master가 아니다.
- commit/push/배포/DB 적용은 이 계획의 실행 항목이 아니다.
## 전역 제약
- PRD의 네 endpoint/payload/상태/경로와 BOT 인증을 그대로 구현한다. 공개 재생성 API와 직접 START 호출은 금지한다.
- 기존 upload/MediaConvert/preview/알림/JWT/텍스트 번역 worker를 보존한다. 기존 테스트를 삭제하거나 완화하지 않는다.
- additive DDL만 작성한다. 적용 migration 편집/삭제와 데이터 DROP 금지.
- 이번 작업에서 새로 작성한 테스트만 실행하며 `bootJar`로 빌드를 별도 확인한다. 기존 클래스에 추가한 새 테스트는 메서드 selector로 지정한다. 기존 focused 회귀도 실행하지 않는다. 전체 테스트 명령은 사용자용 `./gradlew test`다.
- AWS/live/mixed/2h/운영 DB/consumer 미검증을 성공으로 보고하지 않는다.
## Review Focus
1. 외부 호출자가 트랜잭션을 가지고 있어도 S3 이벤트 시점에는 job/source가 commit되어 있어야 한다.
2. UUID 파일명 변경 후 기존 MediaConvert 완료 콜백을 수락하되 다른 콘텐츠/경로를 허용하지 않아야 한다.
3. callback 중복/경합 때 receipt와 상태 및 자막이 원자 반영되고 older 결과가 최신을 덮지 않아야 한다.
4. JSON 숫자 coercion/누락 nullable 필드/추가 필드 및 raw-body 재직렬화로 계약이 약화되지 않아야 한다.
5. preview-only 사용자에게 전체 자막이 노출되거나 BOT 변경으로 기존 역할 권한이 넓어지지 않아야 한다.
## Phase 1: 업로드부터 자막 조회까지
### Task 1.1: 자막 backend 연결
**Goal `P1-T1`:** 기존 콘텐츠 흐름에 durable source/job 등록과 네 내부 API, 안전한 자막 조회가 연결된다.
- 시작 조건: 재생성 제외 승인, PRD 작성 완료, 업로드 transaction 경계 검토 완료.
- 완료 증거: 아래 RED/GREEN 기록과 5개 기능 그룹을 검증하는 새 테스트 통과. 기존 콘텐츠/로그인 테스트는 코드 대조만 하고 미실행으로 보고.
- 범위 밖: Lambda 코드, 앱 코드, 인프라, 재생성, 자동 복구.
- 신규 구현 파일은 `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/` 아래 `application`, `domain`, `adapter`, `dto` 책임별로 배치한다. 과도한 port/wrapper는 만들지 않는다.
**Files:**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/domain/SubtitleJobStatus.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJob.kt`, `SubtitleJobRepository.kt`, `SubtitleCallbackReceipt.kt`, `SubtitleCallbackReceiptRepository.kt` 및 필요한 최소 subtitle metadata 저장 타입.
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/Subtitle.kt`, `SubtitleRepository.kt`. 결과 행은 auto-increment Long PK와 `jobId`(36), `language`(35), `format`(16), `s3Bucket`(255), `s3Key`(255)를 저장하며 `(jobId, language)` unique와 job FK를 둔다. `findAllByJobId(jobId)` 및 `findByJobIdAndLanguage(jobId, language)`로 callback/조회에서 재사용한다. 언어별 컬럼을 제거하되 아직 미배포인 이번 draft만 교체하고 기존 적용 schema/data는 건드리지 않는다.
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/application/SubtitleJobService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleJobController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/dto/SubtitleJobDtos.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`, `AudioContent.kt`, `GetAudioContentDetailResponse.kt` 및 필요한 기존 호출자.
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`와 필요한 자막 전용 설정.
- Create: `docs/20261110_자막Phase1연동/subtitle-schema.sql`.
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/`의 기능 통합 테스트 및 기존 `content/AudioContentServiceTest.kt`, `member/MemberLoginResponseContractTest.kt`, `jwt/TokenProviderTest.kt` 관련 회귀.
**Interfaces:**
- Consumes: 기존 `AudioContent`, `S3Uploader`, `AudioContentCloudFront`, `LangContext`, member JWT/role 및 PRD의 exact Lambda HTTP 계약.
- Produces: PRD의 네 내부 HTTP endpoint; 기존 콘텐츠 상세의 nullable 자막 정보. source lookup은 최초 업로드 binding에 고정한다.
- 내부 함수/저장 형태는 기존 패턴과 transaction 검토에 맞추며 실제 파일/이름은 구현 기록에 갱신한다. 공개 계약은 변경하지 않는다.
- [x] **RED:** 기존 관련 테스트의 의도를 먼저 읽고 새 실제 HTTP 테스트를 작성했다. 지시 이후 기존 baseline은 실행하지 않았다.
- [x] **RED 확인:** 실제 로그인 후 403 기대/404 실제, source 확장자 보존 실패 및 기존 eventId/unknown job의 422 기대/404 실제를 확인했다. compile/setup 실패는 RED로 인정하지 않았다.
- [x] **GREEN:** 최소 persistence/업로드 commit 경계/API/상세 연결 및 additive DDL을 구현했다. 언어별 결과는 `Subtitle` 행이다.
- [x] **GREEN 확인:** 부모 직접 실행에서 신규 `SubtitleHttpIntegrationTest` 13개 통과, 실패·오류·skip 0.
- [x] **REFACTOR:** Kotlin/JPA·직접 영향 test compile, 변경 파일 ktlint 및 bootJar 성공을 확인했다. 기존 회귀 case는 실행하지 않았다.
검증 상세: [implementation-report.md](implementation-report.md). 부모 재실행 `parent-verification.log`는
`BUILD SUCCESSFUL in 2m 21s`, XML timestamp `2026-11-10T14:33:59`, 13/0/0/0이다.
### Task 1.2: 실제 HTTP와 독립 리뷰
**Goal `P1-T2`:** 구현 결과를 실제 HTTP 표면과 독립 검토로 확인하고 외부 미검증을 구분한다.
- 시작 조건: `P1-T1` 구현 및 focused test 완료.
- 완료 증거: 실제 HTTP status/body assertion, `bootJar`, 변경 범위 lint/compile, 독립 리뷰의 blocker 해소.
- 범위 밖: 외부 AWS 환경에 대한 실제 운영 검증 및 전체 테스트.
- TDD 예외 사유: 검증/보고 task다. 대체 검증은 HTTP 실행과 코드/DDL/테스트 대조다.
- [x] 실제 localhost HTTP로 EMAIL BOT 로그인, 네 endpoint, 중복/경합/오류 및 기존 콘텐츠 상세를 실행했다. H2와 외부 S3/서명 경계 대체를 사용했다.
- [x] `bootJar`, 변경 파일 `ktlintCheck`, 신규 HTTP 테스트를 실행했다. LSP 미제공이며 Kotlin compile/lint를 대체 증거로 기록했다.
- [x] 독립 리뷰에서 HTTP 오류 경계 1건을 발견·수정하고, 제한 delta 재검토에서 ADDRESSED 및 로컬 backend APPROVE를 받았다.
- [x] `final-report.md`에 실제 수정·삭제·보존 경로, 명령/결과, DB 적용 및 consumer 상태, fixture 출처, live/mixed/2h BLOCKED를 기록했다.
### Task 1.R1: 리뷰 회귀 수정
**Goal `P1-R1`:** 자막 API의 빈 body/저장 실패가 HTTP 200으로 숨겨지지 않는다.
- 시작 조건: 독립 리뷰 P1 finding 및 `SodaExceptionHandler.kt`의 기존 HTTP200 변환 확인.
- 완료 증거: 새 실제 HTTP 테스트에서 빈 callback/started 422 및 DB 실패 5xx, receipt/status/results rollback 확인.
- 범위 밖: 기존 공통 exception handler나 다른 API 응답 변경.
- Files: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleExceptionHandler.kt`,
`src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/SubtitleHttpIntegrationTest.kt`.
- [x] **RED:** 빈 body와 receipt 저장 실패의 실제 HTTP 테스트 2개를 추가했다.
- [x] **RED 확인:** `[422,422]` 기대에 `[200,200]`, `500` 기대에 `200` 실패를 확인했다.
- [x] **GREEN:** 자막 전용 advice에서 body 읽기 오류는 422, 예상치 못한 서버 오류는 일반화된 500으로 반환한다.
- [x] **GREEN 확인:** 신규 자막 15개 통과, 부모의 추가 두 HTTP 사례 재실행도 통과했다. 기존 서비스 직접 호출 rollback 테스트를 유지했다.
- [x] **REFACTOR:** 변경 lint/build 및 부모 HTTP 재검증 후 제한 delta 리뷰가 승인했다. 원 reviewer session 유실로 대체 reviewer가 기존 판정과 수정분만 재검토했다.
### Phase 1 Gate
**Goal `P1-GATE`:** 로컬 backend 완료와 운영 배포 준비 상태가 증거로 구분된다.
- [x] `P1-T1`, `P1-T2`, `P1-R1` 완료 증거 확인.
- [x] 임시 QA 프로세스/listener 부재와 작업 소유 build/cache/log root 제거를 확인했다. 공유 프로세스·캐시는 변경하지 않았다.
- [x] 재생성 URI/worker/HMAC/파괴적 SQL/외부 프로젝트 변경이 없음을 diff 확인했다.
- [x] 운영 반영은 consumer inventory, DB additive DDL 검토, 조정 배포, controlled live ko/ja/mixed와 2h 측정이 필요하다고 보고했다.
## Phase 2: generate-url 자막 응답
### Task 2.1: 기존 URL 권한을 유지한 자막 제공
**Goal `P2-T1`:** 구매자의 generate-url 응답에 상세와 동일한 요청 언어 자막이 포함되고 기존 contentUrl 계약이 보존된다.
- 시작 조건: PRD §7 사용자 요청 확정, 기존 generateUrl/공유 subtitle 조회 및 새 HTTP fixture 확인.
- 범위 밖: 기존 구매 판정 확대, URI/DB/Lambda 변경, 재생성, 기존 테스트/전체 테스트 실행.
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/GenerateUrlResponse.kt`.
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`의 `generateUrl`만.
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/SubtitleHttpIntegrationTest.kt`에 이번 후속용 HTTP 사례 추가.
- Consumes: `SubtitleJobService.subtitle(content, lang, expirationTime)`, 기존 `LangContext`, 구매 검사와 `AudioContentCloudFront`.
- Produces: `data.contentUrl` 유지 + nullable `data.subtitle`의 language/format/url.
- 완료 증거: 아래 세 시나리오의 새 HTTP 사례 RED/GREEN, 컴파일/변경 lint, 범위 한정 독립 리뷰와 임시 자원 정리.
시나리오:
1. 구매·공개 완료 콘텐츠: `Accept-Language: ko/ja` 각각에 해당 SRT 정보와 기존 음원 URL, 기존 envelope가 반환된다.
2. 미완료 job/해당 언어 없음/미지원 언어/자막 미등록: 음원 URL은 유지하고 subtitle은 명시적 null이다.
3. 미구매자(소유자·무료 포함)는 기존 빈 contentUrl과 null 자막을 받는다. 예약 공개 구매자의 subtitle은 공개 전 null이며 공개 후 제공된다. 기존 URL 반환은 그대로다.
- [x] **RED:** 기존 HTTP fixture를 재사용한 새 메서드 3개만 추가했다.
- [x] **RED 확인:** 실제 응답의 `has("subtitle")`에서 3개 모두 assertion 실패함을 확인했다.
- [x] **GREEN:** DTO field와 generateUrl의 공유 자막 조회 연결만 구현했다.
- [x] **GREEN 확인:** 이번 새 HTTP 메서드 3개 통과, 부모 직접 재실행도 3/0/0/0. 이전 15개와 클래스 전체는 실행하지 않았다.
- [x] **REFACTOR:** 컴파일/bootJar/테스트 lint 및 제한 독립 리뷰 APPROVE를 확인했다. production lint의 기존 EOF 빈 줄1건을 별도 기록하고 dirty 변경을 보존했다. 자원 정리는 Phase2 Gate에서 확인한다.
### Phase 2 Gate
**Goal `P2-GATE`:** 추가 응답의 HTTP·권한·호환성 증거가 확보된다.
- [x] 새 HTTP3개 통과와 독립 리뷰 APPROVE 확인.
- [x] 기존 코드 변경은 DTO와 generateUrl 본문뿐임을 실제 전후 diff로 대조했다.
- [x] 결과 보고를 보존하고 소유 테스트 PID/listener 및 임시 build/cache/root 부재를 확인했다.
## Phase 1 실행 검증 이력
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subtitle.*'
./gradlew bootJar
```
기존 클래스에 추가한 새 테스트는 실제 메서드명을 `--tests`에 지정한다. 기존 테스트 클래스 전체는 실행하지 않는다. 수동 전체 검증은 사용자 실행 `./gradlew test`다. 실제 명령/추가 영향 테스트/결과는 아래에 누적한다.
## Progress
- 2026-11-11 `P2-GATE` 종료: 부모 직접 HTTP3개 재실행 성공과 독립 승인 후 소유 root `generate-url-subtitle.fSBkJM`의 디렉터리/symlink 여부를 확인해 제거했다. `test ! -e` exit0이며 공유 캐시/다른 dirty 파일은 건드리지 않았다. 원래 EOF lint1건은 별도 미해결 관찰로 남긴다. 검증 로그 경로는 과거 위치이며 결과/명령/실패 이력은 보고서에 보존했다.
- 2026-11-11 `P2-T1` 독립 리뷰 APPROVE: 기존 contentUrl/envelope/활성 구매 정책과 서명 만료 인자 유지, 자막만 공개 시점·언어·최신 결과 조건 적용, 새 HTTP3개 증거를 확인했다. 이번 delta blocker/새 회귀 없음. production EOF 공백은 작업 시작 전 dirty 상태의 잔여사항이며 전체 lint clean을 주장하지 않는다.
- 2026-11-11 `P2-T1` 구현: DTO nullable field 및 generateUrl 본문만 수정. 새 HTTP3개 RED/GREEN 후 부모가 정확히 새3method selector로 직접 실행해 3 tests/0 failures/errors/skipped, `BUILD SUCCESSFUL in 37s`, XML UTC `2026-11-10T22:12:51` 확인. 실제 Tomcat PID17759/port50914. Kotlin compile/bootJar 및 테스트 lint 성공. 두 production 파일 lint에서는 작업 전부터 있던 서비스 EOF 빈 줄1건만 실패하므로 다른 dirty 변경으로 보존했다. 상세는 `generate-url-report.md`. 범위 한정 독립 리뷰 진행 중.
- 2026-11-11 후속 요청: generate-url에도 상세와 같은 자막 응답 추가. `GenerateUrlResponse`는 현재 contentUrl만 있고 기존 URL 권한은 구매 검사다. `P2-T1`에서 additive field/공유 조회만 연결한다. 기존 Phase1 완료·검증 기록은 유지한다. 시작 시 추가 dirty 파일(기존 관리자 controller/query 테스트 등)을 발견했으며 다른 작업으로 보고 변경하지 않는다.
- 2026-11-11 종료 정리: 소유 root `subtitle-phase1.wiRcAJ`가 실제 디렉터리이며 symlink가 아님을 확인한 후 제거했고 `test ! -e` exit0으로 부재 확인했다. 작업 소유 테스트 PID/HTTP port의 종료는 먼저 확인했다. 결과·명령·실패 이력·리뷰 판정은 저장소 문서에 보존했으며 임시 log/init/JAR 경로는 과거 검증 위치다. 모든 로컬 Goal 완료, 외부 배포 게이트는 별도 BLOCKED로 유지한다.
- 2026-11-11 최종 제한 리뷰: `REV-P1-001 ADDRESSED`, 로컬 backend gate `APPROVE`. 신규 blocker 없음. 원 reviewer session 유실 때문에 기존 전체 리뷰 판정과 오류 advice/두 테스트 delta만 대체 reviewer가 확인했으며 전체 리뷰를 반복하지 않았다. 재생성·AWS·운영 DB·live·2h는 구현/승인 범위에서 분리했다.
- 2026-11-11 `P1-R1` 검증: 2개 HTTP RED 후 수정, 신규 자막 15개 전체와 bootJar/변경 lint 통과 (`p1-green-15-build-lint.log`, 7m44s). 부모는 수정한 두 새 HTTP 사례만 재실행해 2/0/0/0 및 `BUILD SUCCESSFUL in 4m 55s` 확인. 실제 Tomcat port 59977/PID44707, XML timestamp `2026-11-10T15:34:43`. 기존 reviewer session continuation이 유실돼 이전 판정과 수정 delta만 전달한 단일 재리뷰를 진행 중이다. 같은 기능을 재설계하거나 전체 리뷰를 반복하지 않는다.
- 2026-11-10 독립 gate 리뷰: REQUEST_CHANGES 1건. 자막 advice가 ResponseStatusException만 처리하므로 빈 body/저장 실패가 기존 공통 advice에서 HTTP200으로 변환될 수 있다. 부모가 실제 공통 handler를 확인했고 전용 advice+새 HTTP 사례의 `P1-R1`로 수정한다. 나머지 저장/권한/언어별 구조/업로드/DDL 범위는 reviewer가 부합 판정했다.
- 2026-11-10 부모 검증: 격리 build/cache에서 신규 HTTP 13개를 up-to-date 무시하고 직접 재실행, 13 passed/0 failed/0 errors/0 skipped, `BUILD SUCCESSFUL in 2m 21s`. 같은 코드의 bootJar와 ktlint는 up-to-date 확인이며 구현 담당의 실제 실행 로그 `build-lint.log`는 `BUILD SUCCESSFUL in 1m 28s`다. 로컬 mapping의 `sourceUploadErrorAt` TIMESTAMP 차이는 해소했다. 기존 독립 reviewer 결과 회수 실패 및 session 유실로 승인을 주장하지 않고 대체 단일 reviewer를 실행 중이다.
- 2026-11-10 실제 ZIP 계약 대조: `unzip -p .../start-subtitle-transcription.zip common/transcribe.py`로 확장자 없는 원본 key가 `UNSUPPORTED_MEDIA`임을 확인했다. `common/runtime.py::source_content`는 UUID 뒤 확장자를 허용한다. 원본의 안전한 확장자를 보존하도록 등록 seam/새 테스트를 수정한다. 이는 기존 artifact의 read-only 계약 확인이며 Lambda 변경·실행·AWS 호출이 아니다. 확장자는 형식 보장이 아니며 기존 업로드/MediaConvert 동작을 미지원 자막 형식 때문에 막지 않는다.
- 2026-11-10 후속 설계 조정: 사용자에게 향후 언어 추가 가능성이 확인되어 고정 `koKey`/`jaKey` 대신 `Subtitle` 결과 행을 둔다. 현재 API 허용 언어/상태 판정은 ko/ja 고정 그대로다. 구현 담당과 DDL 담당을 잠시 중단하고 같은 session으로 갱신 계약을 전달한다. callback 원자성/terminal replay/조회 권한 새 테스트를 같은 구조로 갱신한다. 아래 Oracle의 고정 컬럼 권고는 당시 두 언어 고정 가정의 기록이며 이 결정으로 대체한다.
- 2026-11-10 후속 지시: 테스트는 이번에 작성한 것만 실행. 지시 반영을 위해 실행 중 implementer를 일시 중단하고 같은 session으로 재개한다. Kotlin 공유 파일은 기존 담당자가 소유하고 DDL/운영 문서만 병렬 분리한다. 이전 baseline 실행 이력은 아래에 보존하되 재실행하지 않는다.
- 2026-11-10: 현재 상태 조사 완료. Spring 자막/recovery 구현 없음, 기존 BOT 인증/업로드 보존. Phase 1 Lambda ZIP hash 대조 완료, AWS 상태는 미확인. 사용자 결정으로 재생성 전체 제외.
- 2026-11-10: PRD/계획 준비. 아직 production 코드 수정 및 테스트 실행 전이며 성공 결과를 주장하지 않는다.
- 2026-11-10: Oracle 경계 검토 완료. create orchestration은 `NOT_SUPPORTED`, 실제 등록은 필수 주입 `TransactionTemplate(REQUIRES_NEW)`로 분리한다. AI 관리자 create 바깥 transaction도 조정한다. content/source/job/최신 선택값을 한 번 저장한 뒤 S3 원본 업로드하고 후속 input 경로 재대입은 제거한다. 업로드 예외는 객체 생성 여부가 불명확하므로 처리 상태를 terminal로 덮지 않고 별도 업로드 오류만 기록해 수동 확인 대상으로 남긴다.
- 2026-11-10: 고정 두 언어 metadata는 job의 nullable 컬럼으로 최소 저장하고 별도 번역/checkpoint 구조는 만들지 않는다. receipt eventId는 case-sensitive unique, callback/started는 동일 job lock을 사용한다. 다른 job 사이 eventId 경합은 rollback 후 receipt 재조회로 판정한다.
- 2026-11-10: UUID 파일명 변경은 외부 MediaConvert의 ID 추출/output 명명 규칙 확인 전 **배포 BLOCKED**다. 현재 저장소에 외부 consumer의 운영 계약 증거가 없으며, 임의 prefix 완화로 숨기지 않는다. 자막 S3 bucket의 private CloudFront origin/서명 접근도 운영 확인 게이트다.
- 2026-11-10: baseline 명령 `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' --tests 'kr.co.vividnext.sodalive.member.MemberLoginResponseContractTest' --tests 'kr.co.vividnext.sodalive.jwt.TokenProviderTest'`는 `kaptGenerateStubsKotlin` 단계에서 200초 도구 제한으로 종료. 테스트 결과 없음. 기존 다른 Gradle 작업이 관찰되어 타 작업을 종료하지 않고 이 작업 전용 build/cache 경로로 검증을 격리한다.
+140
View File
@@ -0,0 +1,140 @@
# 자막 Phase 1 Spring 연동 PRD
## 문서 정보
- 작성일: 2026-11-10
- 상태: 재생성 제외 로컬 backend 구현·검증 완료. 운영 배포/외부 게이트는 별도 차단 상태.
- 구현 계획: [plan-task.md](plan-task.md)
- 기준: 사용자 제공 Phase 1 계약 및 후속 결정 "이번 스코프에 재생성 기능은 추가하지 않고 나머지만 진행"
## 1. 목표와 범위
기존 콘텐츠 업로드에 자막 원본 binding과 PENDING 작업을 연결하고, 기존 BOT JWT로 보호한 네 내부 API를 통해 Lambda 결과를 저장한다. 기존 콘텐츠 상세에서 요청 언어와 원본 열람 권한에 맞는 자막 정보만 반환한다.
Spring은 업로드/작업 등록/결과 저장/콘텐츠 접근 제어를 맡는다. Lambda의 S3 이벤트, Transcribe, 번역, SRT 생성은 이미 별도 구현되어 있으므로 다시 구현하지 않는다. 앱 플레이어 구현은 이 Spring 저장소의 변경 대상이 아니며 실제 앱 표시 검증은 외부 게이트다.
### 제외
- 사용자 결정에 따라 자막 재생성 전체: 새 공개 API, 새 job 생성 business action, START 직접 비동기 호출 모두 제외.
- Scheduler discovery, recovery/lease/fence/checkpoint/outbox/자동 복구, HMAC 인증, 신규 로그인/refresh 계약.
- 외부 프로젝트 수정, AWS 설정/배포/실데이터 변경, DB DROP, 적용 migration 편집/삭제.
- MediaConvert/AAC/preview/기존 알림의 기능 변경과 2GB 다운로드/probe pipeline.
## 2. 실제 현재 상태
| 영역 | 확인 결과 | 처리 |
|---|---|---|
| Spring 작업 트리 | `test`, 조사 기준 `fe620aa9`, 변경 없음 | 현 작업 트리에서 최소 변경 |
| 로그인/JWT/BOT | `MemberService.login`, `MemberRole.BOT`, `JwtFilter`, `TokenProvider` 존재 | 재사용, 공유 인증 약화 금지 |
| 콘텐츠 | `AudioContent`의 numeric PK, 기존 multipart 업로드와 상세/서명 URL 존재 | 보존 및 필요한 연결만 추가 |
| 업로드 | `AudioContentService.createAudioContent` 트랜잭션 안에서 S3 업로드 | S3 이벤트 전에 committed PENDING/source가 보이도록 조정 |
| 업로드 완료 | `uploadComplete`가 파일명의 contentId prefix 검사 후 경로/duration/알림 처리 | 기존 파일은 유지, 새 UUID 원본과의 정확한 연결 검증 필요 |
| 자막/recovery | 자막 API/job/worker/HMAC 및 자막 migration 검색 결과 없음 | 실제 삭제 대상 없음, 공유 텍스트 TranslationJob/JWT/Agora 보존 |
| DB 관리 | Flyway/Liquibase 의존성 없음, 기존 문서 SQL 관례 | additive MySQL DDL 문서만 작성, 운영 적용 여부 미확인 |
| 재생성 | 기존 action 없음. `generate-url`은 재생 URL 발급 | 이번 범위 제외 |
### Lambda handoff 확인
참조는 `/Users/klaus/Develop/sodalive/Lambda/generate-subtitle/aws-subtitle-service/docs/FINAL_REPORT.md`다. 실제 로컬 ZIP hash가 보고서 및 manifest와 일치함을 조사했다.
- `start-subtitle-transcription.zip`: `a0d0b19b2ecb24c0f89caf528bf09fd039558563a99063f082ac64418704abfd`
- `generate-subtitle.zip`: `bfa8742ca436bace853ec2540fc5f70daa24cbab49e75315565d980189fbf209`
이는 AWS 배포 상태의 증거가 아니다. 현재 운영 consumer/in-flight/Scheduler/DB 상태는 미확인이다. 이 저장소에 삭제할 legacy 자막 consumer는 발견되지 않았으며 외부 consumer 삭제/중지/교체는 하지 않는다.
## 3. 고정 계약
### 식별자와 저장 경로
- `contentId`: 기존 Long PK, JSON number. `jobId`: Spring 생성 UUID string.
- 원본: `input/{contentId}/{UUID filename}`, 같은 key 덮어쓰기 금지. VersionId 생성/사용 없음.
- 원본 파일의 안전한 확장자를 UUID 뒤에 보존한다(예: `{UUID}.mp3`). 실제 Phase 1 START ZIP의 `common/transcribe.py::validate_binding`은 `amr/flac/m4a/mp3/mp4/ogg/webm/wav` 확장자를 요구하며, 확장자 없는 key는 `UNSUPPORTED_MEDIA`다. 확장자는 형식 힌트일 뿐 실제 미디어 검증을 대신하지 않는다. 기존 업로드 허용 동작을 임의로 축소하지 않고 미지원 형식은 자막 실패와 기존 업로드를 구분한다.
- transcript: `transcripts/{contentId}/{jobId}/transcript.json`.
- 자막: `subtitles/{contentId}/{jobId}/ko.srt`, `subtitles/{contentId}/{jobId}/ja.srt`.
- 상태: `PENDING`, `PROCESSING`, `COMPLETED`, `PARTIAL_COMPLETED`, `FAILED`.
- 등록과 원본 binding은 S3 원본 업로드 전에 commit한다. 업로드 실패를 성공으로 숨기지 않으며 이미 commit된 job의 실패를 기록한다. 자동 복구는 추가하지 않는다.
### 내부 API: 정확히 네 개
모든 요청은 기존 `/member/login`에서 발급된 JWT의 BOT 권한을 검증한다. 일반 USER/CREATOR/ADMIN만으로 허용하지 않는다. unknown job/source는 404이며 조회 중 job을 만들지 않는다.
| Method | Path | 계약 |
|---|---|---|
| GET | `/internal/api/subtitle-jobs/resolve?bucket=...&key=...` | 업로드의 정확한 source binding 조회 |
| GET | `/internal/api/subtitle-jobs/{jobId}` | UUID job 조회 |
| POST | `/internal/api/subtitle-jobs/{jobId}/started` | 정확히 `transcribeJobName`, `status` |
| POST | `/internal/api/subtitle-jobs/callback` | 정확히 아래 8개 필드 |
조회 응답은 envelope 없이 `jobId`, numeric `contentId`, `status`, `source:{bucket,key,versionId:null}`다. recovery/provider/lease 정보는 노출하지 않는다.
started의 이름은 `subtitle-{jobId}`, status는 `PROCESSING`만 허용한다. PENDING만 PROCESSING으로 전이하고 같은 PROCESSING 및 terminal은 no-op이다. 성공은 200 `{}` 또는 204 empty다.
callback은 `eventId`, `jobId`, numeric `contentId`, `transcribeJobName`, `status`, `subtitles`, `errorCode`, `errorMessage`만 받는다. `eventId`는 비어 있지 않은 문자열로 UUID 제한을 하지 않는다. subtitle 항목은 `language`, `format`, `s3Bucket`, `s3Key`만 받는다. 언어는 `ko-KR`/`ja-JP`, format은 `SRT`, 언어 중복은 금지한다. 이름/job/content/configured bucket allowlist/정확한 자막 key를 검증한다.
- 2개 자막이면 COMPLETED, 1개면 PARTIAL_COMPLETED, 0개면 FAILED만 허용한다.
- COMPLETED의 errorCode/errorMessage는 명시적 null, partial/failed는 구체적 비어 있지 않은 오류를 요구한다. nullable 필드도 요청에서 생략하지 않는다.
- started 전 callback도 허용한다. callback 검증은 SRT 내용 검사로 위장하지 않는다. 실제 빈 파일/무음 방지는 기존 Lambda 및 live 검증 책임이다.
- receipt의 eventId와 **원래 HTTP body bytes의 SHA-256**을 상태/자막 metadata와 같은 DB 트랜잭션으로 저장한다. hash는 인증이 아니다.
- 같은 eventId/hash는 200, 같은 eventId/다른 hash는 422, terminal job의 다른 결과는 409다. 회귀 및 부분 반영 금지.
- 오래된 job의 결과는 자체 저장하되 콘텐츠 최신 선택 결과를 대체하지 않는다. 재생성 API가 제외되어도 이 저장 불변식은 유지한다.
### 로그인/권한/개인정보
기존 login 요청의 `isAdmin:false`, `isCreator:false`와 기존 `data.token` 응답을 바꾸지 않는다. 활성 EMAIL BOT의 DB role과 발급 JWT를 실제 인증 경로 테스트로 확인한다. Lambda의 success envelope/401 1회 갱신/403 갱신 금지 구현은 재작성하지 않는다.
credentials/JWT/body를 코드/로그/보고서에 출력하지 않는다. BOT 운영 계정 준비는 운영자 책임이다. 기존 JWT 등록/회원 권한/다른 role 경로는 그대로 보존한다.
### 언어별 저장 구조
추후 언어 추가 가능성을 반영해 `SubtitleJob`에 `koKey`/`jaKey` 같은 언어별 컬럼을 두지 않는다. `Subtitle` 결과 행에 `jobId`, `language`, `format`, `s3Bucket`, `s3Key`를 저장하고 `(jobId, language)` unique로 중복을 방지한다. 작업 상태/원본/오류는 `SubtitleJob`, callback 중복 방지는 receipt가 담당한다. callback transaction에서 결과 행도 함께 저장한다.
이번 Phase 1의 외부 계약은 그대로 유지한다. callback은 `ko-KR`/`ja-JP`만 허용하며 2개/1개/0개 결과의 상태 판정도 변경하지 않는다. 다른 언어 활성화, 언어 관리 API, 동적 provider 설정은 구현하지 않는다. 새 언어는 향후 계약·Lambda·조회 정책 변경이 필요하지만 언어별 DB 컬럼 추가는 필요 없도록 한다.
### 콘텐츠 상세 응답
기존 상세 API 응답에 nullable 자막 정보를 additive하게 제공한다. 기존 `LangContext`의 한국어/일본어에 맞는 완료 자막만 선택한다. 미지원 언어/미완료/없는 언어는 null이며 원문 복사 fallback을 만들지 않는다.
원본 재생이 가능한 구매/소유자/무료 조건과 공개 시점 등 기존 접근 제한을 통과한 경우에만 서명된 SRT URL을 제공한다. 유료 preview만 볼 수 있는 사용자는 전체 자막을 받지 않는다. 새로운 공개 download URI를 만들지 않는다.
## 4. 검증 시나리오
| 그룹 | 통과 조건 | 증거 |
|---|---|---|
| 1. 등록/조회 | S3 upload 경계에서 별도 transaction으로 PENDING/source 조회 가능, UUID/숫자 JSON 및 404 정확 | Spring DB/HTTP 테스트, upload 경계 assertion |
| 2. started | 단조 전이, duplicate/terminal no-op, 잘못된 이름/status 거부 | DB/HTTP 테스트 |
| 3. callback | complete/partial/fail, exact body hash replay, 422/409, callback-before-started, atomic rollback, older/latest guard | DB/HTTP 및 동시성 테스트 |
| 4. 로그인/보안 | 실제 active EMAIL BOT 로그인/JWT로 네 API 허용, 다른 role/익명 거부, 기존 envelope 보존 | 기존 로그인/JWT 회귀 및 통합 테스트 |
| 5. 콘텐츠 | ko/ja 선택 및 구매/소유자/무료/preview 권한 유지, 기존 업로드 완료/알림 회귀 없음 | 기존 콘텐츠 테스트와 추가 회귀 |
실제 서버 HTTP 요청으로 핵심 endpoint를 검증하고, 이번 작업에서 새로 작성한 테스트와 `bootJar`를 분리 실행한다. 기존 테스트는 읽어 계약을 보존하되 실행하지 않는다. 기존 클래스에 새 테스트를 추가한 경우 해당 메서드만 지정한다. 전체 테스트는 사용자 실행 `./gradlew test`로 남긴다. LSP 도구가 제공되지 않으면 Kotlin compile/lint 대체 결과와 미실행 사실을 명시한다.
## 5. 외부 게이트와 미검증
- AWS 접근/배포는 승인 없이 하지 않는다. 기존 consumer 운영 여부가 미확인이므로 삭제/전환/재개는 BLOCKED다.
- live ko/ja/mixed S3 -> callback -> 콘텐츠/앱 검증은 별도 승인 및 환경 필요. 실제 word/item 언어 evidence 없는 mixed 결과는 통과 불가. synthetic fixture는 대체 증거가 아니다.
- 2시간은 입력 목표이며 SLA/지원 보장이 아니다. 기존 신뢰 가능한 duration의 가용성과 검증 경계를 확인한다. 구형 또는 검증 불가능한 원본은 수동 게이트로 남기고 임의 client 값을 신뢰하지 않는다.
- 대표 2시간 처리 예산은 미측정이며 지원 검증 BLOCKED. 분할/상한 하향/Translate 50-call 제한을 추가하지 않는다.
- 운영 DB 적용 상태 미확인. DDL 적용/기존 데이터/미확인 환경에 대한 삭제는 하지 않는다.
## 6. Decision Log
| 날짜 | 결정 | 근거 |
|---|---|---|
| 2026-11-10 | 자막 재생성 기능 전체 제외, 나머지 진행 | 사용자 명시 결정 |
| 2026-11-10 | 현재 Spring 중심, 외부 참조는 명시된 Lambda handoff에 한정 | 사용자 범위 확인 |
| 2026-11-10 | 이미 있는 로그인/JWT/Lambda 재구현 금지, 자막 recovery 삭제 없음 | 실제 코드/ZIP 조사 |
| 2026-11-10 | 실행 테스트는 이번 작업에서 새로 작성한 사례만. 기존 focused 회귀 및 전체 테스트 실행 금지 | 사용자 후속 지시 |
| 2026-11-10 | 향후 언어 추가를 고려해 언어별 결과 행으로 저장, 이번 API는 ko/ja만 유지 | 사용자 확장 가능성 제시 및 중단 없이 진행 지시에 따른 최소 저장 구조 조정 |
## 7. 후속 범위: generate-url 자막 응답
2026-11-11 사용자 요청에 따라 기존 `GET /audio-content/{id}/generate-url` 응답에도 콘텐츠 상세와 동일한
nullable `subtitle`을 추가한다. 기존 `ApiResponse` envelope와 `data.contentUrl` 이름·타입·URL 생성 동작은 유지한다.
- `GenerateUrlResponse`에 `subtitle: ContentSubtitleResponse? = null`을 추가한다. 필드는 `language`, `format`, `url`이다.
- 기존 `orderRepository.isExistOrdered` 검사를 유지한다. 구매가 없으면 기존처럼 `contentUrl`은 빈 문자열이며 `subtitle`은 null이다.
- 상세와 달리 현재 generate-url은 소유자/무료 콘텐츠만으로 구매 검사를 우회하지 않는다. 이 요청으로 기존 URL 권한을 확대하지 않는다.
- 구매가 확인된 사용자의 자막은 상세와 같은 공개 시점 조건(미지정/도래 또는 소유자)을 적용한다. 기존 `contentUrl` 동작은 바꾸지 않는다.
- `SubtitleJobService.subtitle`을 재사용해 요청 `LangContext`의 ko/ja, 최신 job, 완료/부분 완료 및 해당 언어 존재 여부를 처리한다.
- 자막 미완료·누락·미지원 언어면 null이며 원문 fallback이나 새 자막 생성은 하지 않는다. 음원과 자막 서명 URL에는 같은 만료 기간을 전달한다.
- 컨트롤러 URI/인증·DB 스키마·Lambda·재생성은 변경하지 않는다. 검증은 이번 후속 작업에서 새로 작성한 HTTP 테스트만 실행한다.