Files
sodalive-backend-spring-boot/docs/20261110_자막Phase1연동/prd.md
T

151 lines
14 KiB
Markdown

# 자막 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 테스트만 실행한다.
## 8. Schema-validation 회귀 수정
2026-11-11 실제 기동에서 `Schema-validation: missing column [s3bucket] in table [subtitle]`가 보고됐다.
공개된 DDL의 `s3_bucket`/`s3_key`를 기준으로 엔티티의 물리 컬럼 이름을 명시한다.
Kotlin 속성 및 JSON 이름 `s3Bucket`/`s3Key`는 바꾸지 않고 DB 컬럼 추가/rename이나 전역 naming strategy 변경도 하지 않는다.
기존 테스트의 `ddl-auto:create-drop`은 잘못 추론된 이름으로 테이블을 함께 생성해 이 불일치를 가렸다.
신규 회귀 검증은 엔티티와 독립적으로 기존 DDL 컬럼 이름의 H2 테이블을 먼저 생성하고 실제 Hibernate
`validate` 및 저장/재조회로 두 필드를 확인한다. 실제 운영 MySQL 재기동을 검증했다고 주장하지 않는다.