From 4c1d876d5e06cc06b6f97c526eab5376df4f1998 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 11 Nov 2026 08:35:29 +0900 Subject: [PATCH] =?UTF-8?q?docs(subtitle):=20=EC=9E=90=EB=A7=89=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EA=B2=80=EC=A6=9D=EA=B3=BC=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=EC=A1=B0=EA=B1=B4=EC=9D=84=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../deployment-gates.md | 141 +++++++++++++ docs/20261110_자막Phase1연동/final-report.md | 124 +++++++++++ .../generate-url-report.md | 101 +++++++++ .../implementation-report.md | 192 ++++++++++++++++++ docs/20261110_자막Phase1연동/plan-task.md | 2 + .../reviews/backend-gate-review.md | 47 +++++ .../reviews/generate-url-review.md | 16 ++ 7 files changed, 623 insertions(+) create mode 100644 docs/20261110_자막Phase1연동/deployment-gates.md create mode 100644 docs/20261110_자막Phase1연동/final-report.md create mode 100644 docs/20261110_자막Phase1연동/generate-url-report.md create mode 100644 docs/20261110_자막Phase1연동/implementation-report.md create mode 100644 docs/20261110_자막Phase1연동/reviews/backend-gate-review.md create mode 100644 docs/20261110_자막Phase1연동/reviews/generate-url-review.md diff --git a/docs/20261110_자막Phase1연동/deployment-gates.md b/docs/20261110_자막Phase1연동/deployment-gates.md new file mode 100644 index 00000000..de4eddb5 --- /dev/null +++ b/docs/20261110_자막Phase1연동/deployment-gates.md @@ -0,0 +1,141 @@ +# 자막 Phase 1 배포 게이트 + +**현재 판정: 배포 BLOCKED.** 이 문서는 로컬 저장 계약과 배포 전 확인 항목이며 DB 적용·AWS 배포 증거가 아니다. +기준일은 2026-11-10이며 [PRD](prd.md), [구현 계획](plan-task.md), [DDL](subtitle-schema.sql)을 함께 본다. + +## 1. 검토 기준과 파일 경계 + +- persistence 기준 경로: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/`. +- 실제 읽은 파일: `SubtitleJob.kt`, `Subtitle.kt`, `SubtitleCallbackReceipt.kt` 및 세 repository. +- 콘텐츠 기준: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt`, `common/BaseEntity.kt`. +- `src/main/resources/application.yml`의 `MySQL8Dialect`·`ddl-auto: validate`는 로컬 설정 근거일 뿐 운영 DB 증거가 아니다. +- 문서 담당은 이 문서와 DDL만 작성한다. 병렬 Kotlin 구현의 최종 엔티티 대조는 상위 구현 게이트에서 다시 수행한다. + +## 2. 저장 계약 대조 + +물리명은 기존 Spring snake_case 매핑 기준이다. 길이 미지정 `String`은 JPA 기본 `VARCHAR(255)`를 따른다. +`jobId`는 UUID **문자열**이며 `BINARY(16)`으로 바꾸지 않는다. `N`은 NOT NULL, `Y`는 NULL 허용이다. + +| `SubtitleJob.kt` 속성 | DDL 컬럼·타입 | NULL | +|---|---|---| +| `jobId` | `job_id VARCHAR(36)` PK | N | +| `contentId` | `content_id BIGINT` signed | N | +| `sourceBucket`, `sourceKey` | `source_bucket`, `source_key VARCHAR(255)`; 전체 두 컬럼 unique | N | +| `outputKey` | `output_key VARCHAR(255)` | N | +| `status` | `status VARCHAR(32)`; enum STRING | N | +| `errorCode` | `error_code VARCHAR(255)` | Y | +| `errorMessage` | `error_message TEXT` | Y | +| `sourceUploadErrorCode` | `source_upload_error_code VARCHAR(255)` | Y | +| `sourceUploadErrorAt` | `source_upload_error_at TIMESTAMP`; 명시적 JPA TIMESTAMP | Y | +| `createdAt`, `updatedAt` | `created_at`, `updated_at TIMESTAMP` | N | + +| `Subtitle.kt` 속성 | DDL 컬럼·타입 | NULL | +|---|---|---| +| `id` | `id BIGINT` signed PK AUTO_INCREMENT | N | +| `jobId` | `job_id VARCHAR(36)` FK → `subtitle_job.job_id` | N | +| `language` | `language VARCHAR(35)`; `(job_id, language)` unique | N | +| `format` | `format VARCHAR(16)` | N | +| `s3Bucket`, `s3Key` | `s3_bucket`, `s3_key VARCHAR(255)` | N | + +| 기존 엔티티 속성 | DDL 컬럼·타입 | NULL | +|---|---|---| +| `SubtitleCallbackReceipt.eventId` | `event_id VARCHAR(255)` PK; UUID 제한 없음 | N | +| `SubtitleCallbackReceipt.jobId` | `job_id VARCHAR(36)` | N | +| `SubtitleCallbackReceipt.bodyHash` | `body_hash VARCHAR(64)` | N | +| `SubtitleCallbackReceipt.createdAt` | `created_at TIMESTAMP` | N | +| `AudioContent.latestSubtitleJobId` | `content.latest_subtitle_job_id VARCHAR(36)` | Y | + +### 미확정 차이와 적용 전 대조 + +1. 마지막 정적 대조에서 `Subtitle.kt`의 테이블명·6개 컬럼·길이·nullable·IDENTITY PK·unique가 합의 계약과 일치했다. + `SubtitleJob.kt`에서도 `koKey`/`jaKey` 제거를 확인했다. `Subtitle.jobId`는 scalar이고 JPA 관계/FK 선언은 없다. + DDL의 `subtitle.job_id` FK는 합의 계약에 따른 DB 제약이며 ORM 관계 선언 확인으로 보고하지 않는다. + `subtitle_job` CREATE에 `ko_key`/`ja_key`는 넣지 않는다. 미출시 초안 교체이며 기존 데이터 이관·컬럼 삭제 SQL은 없다. +2. 최초 대조에서는 `sourceUploadErrorAt`에 `columnDefinition`이 없어 차이로 기록했다. + 최종 구현은 `@Column(columnDefinition = "TIMESTAMP")`와 초 절삭을 명시했고 부모가 DDL과 다시 대조했다. + 이 로컬 mapping 차이는 해소됐지만 실제 MySQL schema 적용·왕복 검증은 여전히 운영 게이트다. +3. 나머지 시각은 엔티티의 명시적 `TIMESTAMP`와 초 절삭을 따른다. DDL 소수초 정밀도는 모두 0이다. + `created_at`은 `DEFAULT CURRENT_TIMESTAMP`, job의 `updated_at`은 같은 기본값과 `ON UPDATE CURRENT_TIMESTAMP`다. + `Subtitle.kt`에는 시각이나 `BaseEntity` 상속이 없으므로 결과 행에 임의 시각 컬럼을 추가하지 않는다. + UTC 생성값과 JDBC/session timezone 및 실제 MySQL 왕복은 미검증이다. +4. `AudioContent`는 `content` 테이블이며 `BaseEntity.id: Long?`의 IDENTITY를 상속한다. + 신규 `content_id`는 signed BIGINT로 맞췄지만 운영 `content.id`의 실제 타입·부호·엔진은 확인하지 않았다. + 합의된 `subtitle.job_id` FK만 둔다. receipt의 jobId 및 콘텐츠 연결은 현재 scalar 매핑이므로 FK를 임의 추가하지 않는다. + FK cascade는 없으며 기본 RESTRICT다. `(job_id, language)` unique가 결과 FK와 작업별 조회 인덱스를 겸한다. +5. 현재 callback 활성화는 `ko-KR`/`ja-JP`, 형식은 `SRT`, 결과 2/1/0개의 상태 판정은 그대로다. + 저장 구조만 언어별 행으로 바꾼다. 새 언어 활성화·재생성·새 관리 API·recovery 상태/테이블은 추가하지 않는다. + +### 식별자와 인덱스 전제 + +- 세 신규 테이블과 최신 job 컬럼은 `utf8mb4_0900_bin`을 쓴다. 대소문자·후행 공백을 구별하는 NO PAD 비교다. + eventId 및 bucket/key의 정확한 식별에 case-insensitive collation이나 PAD SPACE인 `utf8mb4_bin`을 대신 쓰지 않는다. +- MySQL 8.0.17 이상 및 해당 collation의 binary/NO PAD 속성, InnoDB DYNAMIC·3072-byte 키 지원을 운영자가 확인한다. + 16 KiB 이상 InnoDB page를 전제로 하며 4/8 KiB page 환경의 적합성을 주장하지 않는다. +- source unique 최대 키 데이터는 `(255 + 255) × 4 = 2040` bytes다. eventId PK는 `255 × 4 = 1020` bytes다. + 결과 unique는 `(36 + 35) × 4 = 284` bytes다. 보조 인덱스의 PK 포함분도 3072 bytes 미만이다. + prefix unique나 해시 식별자로 원래 bucket/key를 대체하지 않는다. +- 경로 컬럼은 현재 UUID 기반 계약에 맞는 `VARCHAR(255)`이며 일반 S3 최대 길이 전체 지원을 뜻하지 않는다. + job의 `(content_id, created_at)` 인덱스는 현재 `findFirstByContentIdOrderByCreatedAtAsc` 조회에 대응한다. + +## 3. Lambda handoff와 실제 환경 구분 + +아래 SHA-256은 **PRD §2의 기존 조사 기록 인용**이다. 문서 담당이 외부 저장소·ZIP·AWS를 다시 조회한 결과가 아니다. +원본 보고서 참조는 PRD에 적힌 `aws-subtitle-service/docs/FINAL_REPORT.md`다. + +| 정확한 artifact | SHA-256 | +|---|---| +| `start-subtitle-transcription.zip` | `a0d0b19b2ecb24c0f89caf528bf09fd039558563a99063f082ac64418704abfd` | +| `generate-subtitle.zip` | `bfa8742ca436bace853ec2540fc5f70daa24cbab49e75315565d980189fbf209` | + +로컬 artifact 일치 기록은 운영 함수 버전/alias·event routing·consumer·in-flight·Scheduler·DB 적용 증거가 아니다. +자격 증명/JWT/raw body 값은 이 문서나 증거 보고서에 기록하지 않는다. + +## 4. 운영 차단 항목 + +| 게이트 | 현재 | 해제에 필요한 증거 | +|---|---|---| +| 최종 엔티티·DB | BLOCKED | 위 차이 해소, 운영 기존 스키마·버전·collation·인덱스 확인, 별도 DDL 승인·적용 기록 | +| consumer·조정 배포 | BLOCKED | 운영자 inventory, 실행 중일 때만 pause/drain, backend·정확한 두 Lambda의 조정 배포 기록 | +| MediaConvert·CloudFront | BLOCKED | UUID 입력의 ID 추출/출력 확장자 및 private origin·서명 URL 실환경 검증 | +| controlled live | BLOCKED | ko/ja/mixed의 실제 S3 → callback → 콘텐츠/앱 결과와 word/item 언어 증거 | +| 대표 2시간 입력 | BLOCKED·미측정 | 신뢰 가능한 duration 근거 및 대표 입력의 전체 처리 예산 실측 | + +### 운영자 실행 순서: 별도 승인 후에만 + +1. 운영 consumer·event routing·함수 버전/alias·in-flight·기존 DB 상태를 inventory로 확정한다. + 실행 중인 consumer가 있을 때만 신규 유입을 pause하고 기존 작업·callback을 drain하는 전환 구간을 조율한다. + 실행 중인 대상이 없으면 그 증거와 함께 pause/drain을 생략한다. 미확인 상태를 비가동으로 간주하지 않는다. +2. 최종 JPA와 실제 `SHOW CREATE TABLE`·collation·인덱스 전제를 대조하고 DB 변경을 별도 승인받는다. + 기존 동명 테이블/컬럼이 있으면 중단하고 차이를 검토한다. `IF NOT EXISTS`로 불일치를 숨기지 않는다. + additive DDL 승인·적용 확인과 Phase 1 backend/두 Lambda의 배포를 같은 전환 계획으로 조율한다. + 구·신 consumer의 중복 실행이나 호환성 미확인 in-flight를 남긴 채 재개하지 않는다. +3. UUID 원본 `input/{contentId}/{UUID filename}`으로 외부 MediaConvert의 contentId 추출 규칙을 확인한다. + 등록은 안전한 실제 파일 확장자를 UUID 뒤에 보존한다. 실제 START ZIP은 지원 확장자를 검사하므로 + 누락/미지원 확장자의 자막 성공을 주장하지 않는다. 이를 위해 기존 업로드를 거부하거나 파일을 허위 변환하지 않는다. + 출력 경로·실제 확장자·완료 callback·기존 preview/알림 흐름의 호환 증거가 필요하다. + 로컬 `matchesOutput`의 허용 확장자는 `aac`/`m4a`/`mp3`이나 실제 외부 출력 계약이 확인된 것은 아니다. + 자막 bucket/key가 기존 private CloudFront origin/path에 정확히 매핑되고 권한 있는 사용자만 서명 URL로 + 열람하는지 확인한다. 두 항목 모두 현재 미검증이며 임의 prefix 허용이나 공개 S3 URL로 우회하지 않는다. + 현재 Spring의 자막 bucket allowlist는 기존 `cloud.aws.s3.content-bucket` 한 개다. + Lambda `MEDIA_BUCKET`과 `SUBTITLE_BUCKET`도 이 값에 맞춰야 하며, 별도 자막 bucket을 쓰려면 + allowlist와 CloudFront origin 계약을 먼저 변경·검증해야 한다. 현재 구현이 다중 bucket을 지원한다고 보지 않는다. +4. 일반 유입을 재개하기 전에 controlled ko/ja/mixed live를 수행한다. + S3 source binding, Transcribe 실제 word/item 언어, 결과 SRT, BOT callback, 언어별 콘텐츠/앱 표시를 연결해 기록한다. + mixed는 실제 word/item 언어 증거가 필수이며 synthetic fixture나 HTTP metadata 검증으로 대체할 수 없다. + 유료 preview-only 사용자의 전체 자막 차단과 private 접근도 확인한다. +5. 신뢰 가능한 기존 duration의 가용성과 입력 검증 경계를 확인하고 대표 2시간 입력의 처리 예산을 실측한다. + 구형/검증 불가능 원본은 수동 게이트로 남기며 임의 client duration을 신뢰하지 않는다. + 2시간은 입력 목표이지 SLA/지원 보장이 아니다. 미측정 상태에서 지원 또는 재개 승인을 하지 않는다. + 모든 차단 항목의 증거와 승인자를 기록한 뒤에만 일반 유입을 재개한다. + +purge·데이터 정리·적용 migration 수정·Scheduler fallback·자동 복구·Translate 50-call 정책은 이 절차에 없다. +재생성 전체와 새 언어 활성화 역시 제외한다. 미해결 작업을 삭제하거나 성공 처리해 drain을 가장하지 않는다. + +## 5. 문서 담당 검증 경계 + +문서 간 계약·실제 엔티티 속성·컬럼별 COMMENT·테이블 COMMENT·SQL 종류·공백을 read/rg로 정적으로 대조했다. +DDL은 CREATE TABLE 3개와 ALTER TABLE ADD COLUMN 1개뿐이며 23개 신규 컬럼·3개 신규 테이블에 COMMENT가 있다. +후행 공백·탭·CR·130자 초과 행은 발견하지 않았다. SQL 파싱/실행이나 물리 스키마 호환성을 검증한 결과가 아니다. +이 담당은 테스트·빌드·DB 실행·AWS 접근·외부 저장소 조회를 하지 않으며 성공 결과를 주장하지 않는다. +상위 구현 검증도 이번 작업에서 새로 작성한 테스트만 실행하도록 승인되어 있다. 기존 테스트 실행은 승인 범위가 아니다. +로컬 HTTP·새 테스트·빌드 결과는 구현 담당의 별도 증거이며, 그 성공도 위 운영 게이트를 해제하지 않는다. diff --git a/docs/20261110_자막Phase1연동/final-report.md b/docs/20261110_자막Phase1연동/final-report.md new file mode 100644 index 00000000..e6b8faa2 --- /dev/null +++ b/docs/20261110_자막Phase1연동/final-report.md @@ -0,0 +1,124 @@ +# 자막 Phase 1 Spring 구현 검증 기록 + +> 상태: **재생성 제외 로컬 backend 구현·신규 테스트 15개·독립 리뷰 완료**. 운영 배포 완료가 아니다. + +## 범위와 결정 + +- 이번 범위: 기존 업로드의 PENDING/source 선등록, 네 BOT 내부 endpoint, raw-body receipt, 기존 상세의 언어·권한별 자막 제공. +- 재생성은 사용자 결정으로 제외했다. 새 공개 재생성 API, 직접 START 호출, Scheduler/outbox를 만들지 않는다. +- 후속 요구로 언어별 결과는 `Subtitle` 행에 저장한다. 이번 callback 허용 언어/완료 판정은 ko/ja만 유지하며 다른 언어를 활성화하지 않는다. +- 테스트 실행은 사용자 후속 지시 이후 **이번 작업에서 새로 작성한 테스트만** 대상으로 한다. 기존 클래스에 추가한 사례도 메서드 selector로 한정한다. + +## 실제 조사와 보존 + +- 현재 Spring에 subtitle/recovery/자막 HMAC/재생성 action이 발견되지 않았다. 제거할 해당 코드가 없으므로 실제 삭제 없음. +- 공유 member login/JWT/MemberRole.BOT, Agora HMAC, 텍스트 TranslationJob/worker, S3Uploader와 기존 MediaConvert 관련 외부 코드/설정은 보존 대상이다. +- 기존 MediaConvert 완료 API의 ADMIN/BOT 권한은 유지한다. 새 원본 UUID에 대한 source-bound 경로 검증과 기존 파일 검증을 구분한다. 실제 consumer ID 추출·출력 규칙 호환성은 운영 확인 전 배포 BLOCKED다. +- Flyway/Liquibase 의존성 및 자막 migration은 현재 프로젝트에서 발견되지 않았다. 운영/공유 환경의 적용 schema/data 상태는 조회하지 않았다. additive SQL 문서만 준비하며 DB에는 적용하지 않는다. + +## Lambda와 운영 전환 + +명시된 `aws-subtitle-service/docs/FINAL_REPORT.md`와 실제 로컬 ZIP을 대조했다. + +| 산출물 | SHA-256 | +|---|---| +| `start-subtitle-transcription.zip` | `a0d0b19b2ecb24c0f89caf528bf09fd039558563a99063f082ac64418704abfd` | +| `generate-subtitle.zip` | `bfa8742ca436bace853ec2540fc5f70daa24cbab49e75315565d980189fbf209` | + +파일 hash 일치는 AWS 배포 증거가 아니다. 운영 legacy consumer/caller/in-flight/Scheduler/배포 버전은 미확인이다. AWS 호출·설정·배포·중지·삭제·재개를 수행하지 않았다. 운영자는 inventory 후 실제 가동 중인 경우에만 pause/drain하고 Phase 1 backend/Lambda를 조정 배포해야 한다. S3/DB purge나 legacy fake work API는 금지한다. + +## 실행 기록 + +| 시점 | 명령/검증 | 관찰 결과 | +|---|---|---| +| 테스트 범위 변경 지시 전 | 기존 `AudioContentServiceTest`, `MemberLoginResponseContractTest`, `TokenProviderTest` focused baseline | 부모 실행은 kapt 단계 200초 제한 종료, 테스트 판정 없음 | +| 테스트 범위 변경 지시 전 | 구현 담당자의 격리 focused 시도 | `red-focused.log`: 41 cases 중 새 사례의 JWT key setup 1건 실패. 기능 RED로 인정하지 않음. 기존 테스트 실행은 이후 중단 | +| RED | 새 `SubtitleHttpIntegrationTest.shouldRejectActualNonBotLoginJwt` | `red-http.log`: 실제 로그인한 non-BOT의 자막 접근 차단 실패를 재현. 최종 GREEN 아님 | +| 1차 새 테스트/HTTP | 부모가 신규 `SubtitleHttpIntegrationTest` 직접 재실행 | **13 passed, failures/errors/skipped 0**. XML timestamp `2026-11-10T14:33:59`, `BUILD SUCCESSFUL in 2m 21s` | +| bootJar/변경 범위 lint | 구현 담당 실제 실행 및 부모 같은 코드 재확인 | `build-lint.log`: `BUILD SUCCESSFUL in 1m 28s`; 부모 재확인은 UP-TO-DATE | +| 리뷰 수정 RED | 신규 HTTP 2개 | 빈 body 422 기대/200 실제, DB 실패 500 기대/200 실제 확인 | +| 최종 새 테스트/빌드/lint | `P1-R1` 수정 후 신규 자막 클래스 전체 | **15 passed, failures/errors/skipped 0**, bootJar/변경 lint 실제 실행 성공, 7m44s | +| 부모 최종 HTTP 재검증 | 추가된 새 2개 메서드만 직접 실행 | **2 passed**, 422/500 및 전체 rollback 확인. XML timestamp `2026-11-10T15:34:43`, 4m55s | +| 독립 리뷰 | 1차 REQUEST_CHANGES 1건 수정 후 delta 재검토 | **REV-P1-001 ADDRESSED / 로컬 backend APPROVE**, 추가 blocker 없음 | + +테스트 fixture는 로컬 합성 데이터와 H2, 실제 localhost HTTP, 외부 S3/서명 경계 대체를 사용한다. 실제 음성 또는 live S3 품질 검증이 아니다. LSP diagnostics 도구가 제공되지 않아 실제 Kotlin compile/lint 결과를 대체 증거로 기록했으며 LSP 성공을 주장하지 않는다. + +실제 검증한 HTTP 결과: BOT 허용 및 non-BOT 403/익명 401, unknown 404, callback 수락/replay 200, +같은 eventId의 변경 body 422, terminal 다른 결과 409, 동시 receipt 경합 200/422 및 loser rollback. +기존 콘텐츠 상세에서 preview-only의 자막 null, 구매/소유자/무료 및 공개 시점별 ko/ja 선택을 검증했다. +동일 job 결과는 언어별 행 ID를 보존하며, 이전 job 완료가 최신 선택값을 바꾸지 않는 것을 확인했다. + +실제 HTTP 증거는 테스트 JVM PID `23390`, Tomcat `127.0.0.1:59074`, XML timestamp +`2026-11-10T14:33:59`다. 실행 후 `ps -p 23390,20950,22207`, `lsof -iTCP:59074 -sTCP:LISTEN`, +작업 root를 대상으로 한 `pgrep`에서 남은 프로세스/listener가 없음을 확인했다. 임시 산출물은 리뷰 후 정리했다. + +후속 HTTP 검증은 PID `44707`, Tomcat port `59977`에서 실행했다. 빈 callback/started는 422이고, +receipt 저장 실패는 `{"error":"Subtitle request failed"}`와 500을 반환하며 상태/오류/결과/receipt가 rollback된다. +구현 담당의 첫 GREEN은 컴파일 600초 제한으로 종료됐으나 캐시 재사용 후 성공했다. 실패 이력은 삭제하지 않았다. +후속 PID `44707`/`28638`/`37727`/`39291`, port `59977`, 작업 root 참조 프로세스의 부재도 확인했다. + +## 내부 API와 저장 구조 + +| Method | Path | +|---|---| +| GET | `/internal/api/subtitle-jobs/resolve` | +| GET | `/internal/api/subtitle-jobs/{jobId}` | +| POST | `/internal/api/subtitle-jobs/{jobId}/started` | +| POST | `/internal/api/subtitle-jobs/callback` | + +네 API는 기존 로그인 JWT와 활성 DB BOT role을 요구한다. 조회는 최소 DTO이며 callback은 원문 bytes hash를 사용한다. +`subtitle_job`, 언어별 `subtitle`, `subtitle_callback_receipt`와 `content.latest_subtitle_job_id`의 +additive SQL만 작성했다. 실제 DB 적용은 하지 않았다. 미래 언어는 결과 행으로 저장할 수 있지만 +현재 허용 언어는 ko/ja뿐이고 향후 Lambda·API·조회 정책 변경 없이 새 언어가 자동 활성화되지는 않는다. + +공유 로그인/JWT/업로드/MediaConvert는 보존했다. 현재 저장소에는 삭제할 자막 recovery code가 없어서 삭제 파일은 0개다. +외부 프로젝트, AWS 리소스, 적용 migration 및 운영 데이터는 변경하지 않았다. commit/push도 수행하지 않았다. + +전체 변경 경로·신규 case 이름·재현 명령·실패/수정 이력은 [implementation-report.md](implementation-report.md)에 있다. +로컬 mapping의 업로드 오류 시각 TIMESTAMP 불일치는 수정·대조했으나 운영 MySQL 실행 증거는 아니다. +테스트 전체 Spring context에서 기존 worker의 H2/MySQL 구문 차이 로그가 발생했다. 해당 worker 성공을 주장하지 않는다. + +## 외부 차단 항목 + +| 항목 | 상태와 재개 조건 | +|---|---| +| 운영 DB | BLOCKED: 기존 schema 확인 및 additive DDL 승인·적용 필요 | +| old consumer coordination | BLOCKED: 원격 배포/사용/in-flight 조사와 운영자 조정 필요 | +| MediaConvert/CloudFront | BLOCKED: UUID ID 추출·출력 key 규칙 및 자막 private origin/서명 접근 확인 필요 | +| live ko/ja/mixed | BLOCKED: 승인된 실제 S3 입력부터 callback/콘텐츠/앱 표시 검증 필요. mixed는 실제 word/item 언어 evidence 필수 | +| 2시간 입력 목표 | BLOCKED: 실제 처리 시간/예산 미측정. 상한을 내리거나 SLA/지원 성공을 주장하지 않음 | + +전체 테스트는 실행하지 않는다. 사용자가 별도로 전체 검증을 원하면 `./gradlew test`를 실행할 수 있다. 기존 회귀 미실행에 따른 잔여 위험은 새 기능 테스트 성공과 구분한다. + +## 종료 정리와 재현 + +2026-11-11 리뷰 승인 후 이번 작업 소유 임시 root `subtitle-phase1.wiRcAJ`와 그 안의 build/cache/log/JAR를 제거했다. +`test ! -e` exit0으로 부재를 확인했고 공유 캐시·기존 worker는 변경하지 않았다. 위 임시 경로는 과거 검증 위치이며, +검증 결과와 실패 이력은 이 문서 및 구현/리뷰 보고서에 보존했다. + +정상 개발 환경에서 이번에 작성한 테스트만 다시 실행하는 명령: + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest' +``` + +## 후속 완료: generate-url 자막 응답 + +2026-11-11 요청에 따라 `GET /audio-content/{id}/generate-url`에도 상세와 동일한 nullable `subtitle`을 추가했다. +기존 `contentUrl`과 활성 구매 검사/음원 URL 동작은 유지하며 자막만 요청 언어·완료 상태·공개 시점 조건을 따른다. +이번 후속에서 새로 작성한 HTTP3개만 실행했고 부모 재실행도 3/0/0/0으로 통과했다. 독립 리뷰 APPROVE다. +컴파일/bootJar/테스트 lint는 성공했으나 production lint는 시작 전부터 있던 서비스 EOF 공백1건을 지적해 보존했다. +이 결과를 기존15개를 포함한 클래스 전체 재실행 성공으로 표현하지 않는다. +상세 명령/선택자/응답 예시/잔여 게이트는 [generate-url-report.md](generate-url-report.md)에 있다. + +## 커밋 검증 후속 기록 + +2026-11-11 별도 사용자 `/commit-with-validation` 요청으로 변경을 커밋했다. +기능 연동 커밋은 pre-commit `ktlintCheck`에서 위에 기록된 서비스 EOF 빈 줄 1건으로 처음 거부됐다. +해당 빈 줄만 제거한 뒤 재시도하여 staged Kotlin 대상 lint, `git diff --staged --check` 및 +`work/scripts/check-commit-message-rules.sh`의 커밋 직전·직후 검증이 통과했다. +기존 EOF 미해결 기록은 당시 상태이며 이 커밋 준비 과정에서 해소됐다. + +이번 커밋 작업에서는 기능 테스트·전체 테스트·bootJar를 재실행하지 않았다. +기존 테스트 결과는 위 실행 시점의 기록이며 운영 배포 BLOCKED는 유지한다. +사용자가 전체 테스트를 실행할 때의 명령은 `./gradlew test`다. diff --git a/docs/20261110_자막Phase1연동/generate-url-report.md b/docs/20261110_자막Phase1연동/generate-url-report.md new file mode 100644 index 00000000..841efcc7 --- /dev/null +++ b/docs/20261110_자막Phase1연동/generate-url-report.md @@ -0,0 +1,101 @@ +# generate-url 자막 후속 구현 검증 + +## 범위와 결과 + +- 기준: `prd.md` §7, `plan-task.md` `P2-T1`. Phase 1은 재구현하지 않았다. +- 이번 production 변경은 `content/GenerateUrlResponse.kt`의 기존 DTO import/nullable 필드 추가와 `content/AudioContentService.kt`의 `generateUrl` 본문뿐이다. DTO는 `GenerateUrlResponse(contentUrl: String, subtitle: ContentSubtitleResponse? = null)`이다. +- 기존 `orderRepository.isExistOrdered`를 그대로 사용한다. 구매 없는 소유자/무료, 비활성 KEEP, 만료 RENTAL은 빈 `contentUrl`과 명시적 null `subtitle`이다. 활성 구매의 기존 음원 경로/만료 기간/envelope는 유지했다. +- 구매자는 하나의 `expirationTime`을 음원 서명과 `SubtitleJobService.subtitle(audioContent, langContext.lang, expirationTime)`에 전달한다. 자막만 미지정/도래한 공개일 또는 소유자 조건을 적용한다. 예약 구매자의 기존 음원 URL은 유지한다. +- 기존 `SubtitleHttpIntegrationTest`의 15개 메서드/도우미를 보존하고 HTTP 테스트 3개와 세 테스트가 공용으로 사용하는 요청 도우미 1개만 추가했다. 다른 dirty 파일, PRD/plan/final-report, controller/DB/Lambda는 수정하지 않았다. + +## 새 HTTP 테스트 + +클래스: `kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest`. 실행은 아래 세 메서드 selector만 사용했다. + +1. `shouldIncludeRequestedSubtitleInGenerateUrl`: 활성 KEEP 구매/공개 완료 ko·ja, 정확한 `{contentUrl,subtitle}` 및 자막 3필드, 상세와 동일한 자막, 음원/SRT 경로와 같은 14,400,000ms 서명 인자, 기존 success envelope. +2. `shouldReturnNullSubtitleInGenerateUrlWhenUnavailable`: PENDING/최신 job 미등록/부분 완료 ko 누락/미지원 en의 명시적 null과 음원 URL 보존, 부분 완료 ja 정상 제공. +3. `shouldPreserveGenerateUrlPurchaseAndReleaseRestrictions`: 미구매자·소유자·무료 및 비활성/만료 주문 차단, 활성 RENTAL 구매자의 예약 음원 URL 보존/자막 차단, 활성 구매 소유자의 예약 예외, 공개일 null/과거 허용, 익명 401. + +실제 EMAIL 로그인/JWT, RANDOM_PORT Tomcat HTTP, H2와 기존 fixture/callback 도우미를 사용했다. 외부 S3/CloudFront 경계만 기존 mock을 재사용했다. 서명 URL은 전달 경로/만료 인자 검증이며 실제 CloudFront 암호학적 서명이나 다운로드 검증은 아니다. + +## 명령과 증거 + +소유 root는 아래 `VERIFY_ROOT`다. build/project-cache/TMPDIR/Node/Bun 캐시를 격리했으며 init script는 production 소스를 필터링하지 않는다. 테스트 compile만 이 클래스와 필요한 support 3파일로 한정한다. `test.outputs.upToDateWhen { false }`로 실제 실행을 강제한다. + +```bash +export VERIFY_ROOT=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/generate-url-subtitle.fSBkJM/implementer +export TMPDIR="$VERIFY_ROOT/tmp" NODE_COMPILE_CACHE="$VERIFY_ROOT/node-cache" +export npm_config_cache="$VERIFY_ROOT/node-cache" BUN_INSTALL_CACHE_DIR="$VERIFY_ROOT/bun-cache" +export AWS_EC2_METADATA_DISABLED=true +G=(./gradlew --offline --no-daemon --max-workers=2 + -Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs=-Xmx4g + --project-cache-dir "$VERIFY_ROOT/project-cache" + --init-script "$VERIFY_ROOT/verification.init.gradle") +S=(--tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldIncludeRequestedSubtitleInGenerateUrl' + --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldReturnNullSubtitleInGenerateUrlWhenUnavailable' + --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldPreserveGenerateUrlPurchaseAndReleaseRestrictions') + +# RED: production 변경 전 +"${G[@]}" test "${S[@]}" > "$VERIFY_ROOT/red-assertions.log" 2>&1 +# 첫 GREEN +"${G[@]}" test "${S[@]}" ktlintCheck bootJar > "$VERIFY_ROOT/green-build-lint.log" 2>&1 +# 입력 3파일을 고정/확인한 production+test lint +"${G[@]}" verifyLintScope ktlintCheck bootJar > "$VERIFY_ROOT/final-verified-scope-lint-build.log" 2>&1 +# 기존 dirty EOF 보존 후 최종 HTTP/컴파일/테스트 lint/bootJar +"${G[@]}" test "${S[@]}" verifyLintScope ktlintTestSourceSetCheck bootJar > "$VERIFY_ROOT/final-green-build-test-lint.log" 2>&1 +``` + +| 검증 | 결과/증거 | +|---|---| +| RED | exit 1, 50초, 3 tests/3 failures/0 errors/0 skipped. 모두 `has("subtitle")`에서 expected true/actual false. `red-assertions.log`, `red-summary.log` | +| 첫 GREEN | exit 0, `BUILD SUCCESSFUL in 3m 35s`, HTTP 3개 통과 및 compile/bootJar. lint의 정확한 입력 증거는 이후 고정한 실행을 기준으로 한다. | +| 정확한 변경 lint | 입력은 production 2파일+이 테스트 1파일, Kotlin script 0개임을 `verifyLintScope` assertion으로 확인. production lint는 **기존 EOF 빈 줄 1건 실패**: `AudioContentService.kt:1482:1 Needless blank line(s)`. 그 외 지적 없음. `final-verified-scope-lint-build.log` | +| 최종 GREEN | exit 0, `BUILD SUCCESSFUL in 3m 30s`, compileKotlin/compileTestKotlin/bootJar 실제 실행, 테스트 lint 통과. HTTP XML 3 tests/0 failures/0 errors/0 skipped, timestamp `2026-11-10T22:07:38` (UTC). `final-green-build-test-lint.log`, `green-summary.log` | + +최초 임시 init의 상대 source 경로로 `test NO-SOURCE`가 된 `red.log`는 RED 증거가 아니다. `project.file`로 수정 후 실제 assertion RED를 확인했다. `final-scope-lint-build.log`도 lint scope introspection assertion 실패 기록이며 HTTP 실패가 아니다. 이후 init에서 lint 입력 파일을 명시적으로 고정하고 검증했다. 이 이력과 로그를 삭제하지 않았다. + +## 보존 사항과 남은 게이트 + +- 시작 전 `git diff`에도 있던 서비스 EOF 빈 줄은 타 dirty 변경 보존 지시에 따라 유지했다. `git diff --check` 역시 같은 EOF 1건을 지적하므로 lint 전체 clean을 주장하지 않는다. 테스트/새 DTO lint 오류는 없다. +- LSP 도구는 제공되지 않았다. Kotlin compile과 실제 3파일 ktlint 결과로 대체했다. 기존 Kotlin 경고와 테스트 종료 중 Redis reconnect 경고가 있으나 새 테스트 실패/오류는 없다. +- 기존 15개 테스트/기타 기존 테스트/전체 test/check/build는 실행하지 않았다. 사용자 전체 테스트 명령은 `./gradlew test`이며 이번 승인된 자동 검증 범위 밖이다. +- RED PID 14462/HTTP 50806, 첫 GREEN PID 15417/HTTP 50833, 최종 GREEN PID 16741/HTTP 50879는 테스트 종료로 정상 종료했다. 세 PID와 HTTP/Redis port 50806/50833/50879/50803/50831/50877에 `ps`/`lsof` 조회 결과가 없었다. 공유 프로세스를 종료하거나 task 취소를 호출하지 않았다. 부모 재검증을 위해 소유 root/init/log/JAR는 남겼다. +- AWS/외부 저장소/운영 DB/배포/live ko·ja·mixed/2h 처리/앱/실제 CloudFront 다운로드는 미검증이다. 부모 재검증과 독립 리뷰도 이 보고서의 완료 주장에 포함하지 않는다. credential/JWT/request body는 로그/보고서에 출력하지 않았으며 최종 XML과 로그의 관련 패턴 검색 결과도 없었다. + +## 부모 재검증 + +- 같은 격리 init 및 정확히 새 메서드 3개 selector로 직접 재실행했다. `parent-verification.log`의 결과는 exit0, + `BUILD SUCCESSFUL in 37s`이며 test는 실제 실행, bootJar/테스트 lint는 동일 코드의 UP-TO-DATE 확인이다. +- XML: 3 tests/0 failures/0 errors/0 skipped, UTC timestamp `2026-11-10T22:12:51`. 실제 Tomcat PID17759, HTTP port50914다. +- 부모가 DTO·generateUrl의 전후 diff 및 추가 테스트 전체를 읽었다. 공유 자막 조회와 기존 구매 판정·음원 URL이 유지된다. +- 독립 리뷰와 임시 자원 정리는 아래 후속 기록으로 남긴다. 기존 production EOF lint1건은 성공으로 덮어쓰지 않는다. + +## 독립 리뷰 및 사용 계약 + +- 제한 독립 리뷰 `ses_e387b1e8fffensjb6dnoRk82ST`: **APPROVE**. 이번 production delta의 명세·품질 blocker나 새 회귀 없음. +- `data.contentUrl`과 기존 envelope는 유지하며 `data.subtitle`만 추가된다. 요청 언어는 기존 `Accept-Language`/`LangContext`를 따른다. +- 자막 응답 예시: + +```json +{ + "contentUrl": "https://cdn.example.com/audio-signed-url", + "subtitle": { + "language": "ko-KR", + "format": "SRT", + "url": "https://cdn.example.com/subtitle-signed-url" + } +} +``` + +- 구매/공개 조건을 충족하지 못하거나 미완료·해당 언어 자막이 없으면 `subtitle`은 명시적 null이다. 기존 URL 권한을 확대하지 않는다. +- 부모 HTTP PID17759 및 자식 PID14462/15417/16741, port50914, 소유 root 참조 프로세스는 `ps`/`lsof`/`pgrep`에서 부재를 확인했다. +- 리뷰 승인/결과 문서 보존 후 작업 소유 root `generate-url-subtitle.fSBkJM`과 하위 build/cache/log/JAR를 제거했고 `test ! -e` exit0으로 확인했다. 위 임시 log/init 경로는 과거 검증 위치이며 공유 캐시는 건드리지 않았다. + +일반 개발 환경에서 이번 후속 테스트만 실행하는 명령: + +```bash +./gradlew test \ + --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldIncludeRequestedSubtitleInGenerateUrl' \ + --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldReturnNullSubtitleInGenerateUrlWhenUnavailable' \ + --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldPreserveGenerateUrlPurchaseAndReleaseRestrictions' +``` diff --git a/docs/20261110_자막Phase1연동/implementation-report.md b/docs/20261110_자막Phase1연동/implementation-report.md new file mode 100644 index 00000000..cc1e3842 --- /dev/null +++ b/docs/20261110_자막Phase1연동/implementation-report.md @@ -0,0 +1,192 @@ +# 자막 Phase 1 Kotlin 구현 보고 + +## 결과와 검증 경계 + +- 재생성을 제외한 기존 업로드 등록, 네 BOT 내부 API, callback 원자 저장, 기존 콘텐츠 상세의 권한별 ko/ja 자막 조회를 구현했다. +- 새로 작성한 `SubtitleHttpIntegrationTest` 13개는 실제 RANDOM_PORT localhost HTTP/H2에서 전부 통과했다. 외부 AWS 클라이언트와 서명 URL 생성만 대체하고, 회원 서비스·로그인·비밀번호 검증·JWT·DB principal/role은 실제 경로를 사용했다. +- `bootJar`와 변경 Kotlin 파일에 한정한 `ktlintCheck`가 성공했다. LSP 도구는 제공되지 않아 실행하지 않았으며 실제 Kotlin/JPA 생성 코드·테스트 컴파일과 lint로 대체했다. +- PRD/계획과 SQL/deployment 문서는 병렬 담당자 소유다. 이 담당자는 해당 문서를 수정하지 않았으며 전체 Phase gate/독립 리뷰 완료를 대신 선언하지 않는다. + +## 구현 선택 + +1. `AudioContentService.createAudioContent` 두 overload와 AI 관리자 create orchestration은 `NOT_SUPPORTED`다. 필수 주입된 transaction manager의 `TransactionTemplate(REQUIRES_NEW)`가 content·tags·cover·source/job·latest pointer를 commit한 뒤 원본 S3 업로드를 시작한다. 업로드 후 content의 input 경로를 다시 대입하지 않으므로 조기 MediaConvert 완료 경로를 되돌리지 않는다. 성공 후 기존 언어 이벤트는 별도의 짧은 실제 쓰기 트랜잭션에서 발행한다. +2. 원본은 `input/{contentId}/{canonical UUID}.{safe actual extension}`이다. multipart filename의 경로는 버리고 영문/숫자 1~16자리 실제 확장자만 소문자로 보존한다. 예를 들어 `C:\client\dir/file.MP3`는 UUID `.mp3`로 등록된다. 확장자가 없거나 안전하게 보존할 수 없으면 UUID만 사용한다. 업로드 자체를 새로 거부하거나 `.mp3`로 허위 변경하지 않는다. 확장자는 형식 hint일 뿐 실제 오디오 검증 증거가 아니다. +3. `SubtitleJob.outputKey`는 확장자를 제외한 `output/{contentId}/{same UUID}` stem이다. 새 등록의 upload-complete는 이 정확한 stem과 `aac`/`m4a`/`mp3` 확장자 조합만 수락한다. 다른 콘텐츠, 다른 UUID, 추가 suffix, 임의 디렉터리, 비오디오 확장자는 거부한다. source가 없는 기존 콘텐츠의 동작은 기존 prefix 검증을 유지한다. 실제 consumer의 basename·ID 추출 호환성은 여전히 배포 전 확인 대상이다. +4. 언어 결과는 `Subtitle` 행에 저장한다. `jobId` scalar와 `unique(job_id, language)`, 실제 `language`·`format`·`s3Bucket`·`s3Key`를 보존한다. callback 허용 언어는 여전히 ko-KR/ja-JP이며 상태 규칙은 2/1/0개 그대로다. 미래 언어 활성화/새 provider framework는 추가하지 않았다. +5. callback은 원래 HTTP `ByteArray` SHA-256을 receipt와 함께 저장한다. `REQUIRES_NEW`/`READ_COMMITTED`에서 receipt를 먼저 조회하고 job lock 뒤 다시 조회한다. 기존 eventId로 unknown job을 보낸 변경 body도 422다. receipt는 JPA `Persistable.isNew=true`로 insert하여 merge가 경합한 다른 receipt를 update하지 못하게 했다. unique 경합은 rollback 종료 후 새 트랜잭션에서 receipt를 읽어 200/422를 판정한다. terminal의 동일 결과는 행 ID를 보존하고, 다른 결과는 409다. + +## 변경 경로 + +### 신규 production + +```text +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/domain/SubtitleJobStatus.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/dto/SubtitleJobDtos.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJob.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJobRepository.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/Subtitle.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleRepository.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceipt.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceiptRepository.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/application/SubtitleJobService.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleJobController.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleRequestParser.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleExceptionHandler.kt +``` + +### 기존 production 연결 + +```text +src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt +src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt +``` + +### 테스트와 보고 + +```text +src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/SubtitleHttpIntegrationTest.kt +src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt +src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt +src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/LegacyCreatorAdminAudioContentCharacterizationTest.kt +docs/20261110_자막Phase1연동/implementation-report.md +``` + +- 기존 수동 `AudioContentService` 생성자는 필수 자막 서비스/transaction manager 인자를 반영했다. +- 기존 생성 통합 테스트의 성공 fixture는 등록 전에 실제 commit되도록 조정했다. audio 업로드/후속 이벤트 실패 테스트는 이미 commit된 등록을 보존한다는 새 승인 계약에 맞췄다. 실패 테스트를 삭제하거나 성공 assertion을 느슨하게 하지 않았다. +- 이 기존 테스트들은 후속 사용자 지시 이후 **실행하지 않았다**. 관련 기존 5개 클래스는 새 테스트 실행의 컴파일 입력에 포함했지만 실행 selector는 신규 자막 패키지뿐이다. +- production 파일 삭제, 기존 JWT/member/텍스트 TranslationJob worker 변경, 외부 프로젝트 변경, 신규 AWS SDK 의존성, 재생성 API/START 호출은 없다. + +## 새 테스트 selector + +전체 신규 클래스만 실행하는 selector: + +```bash +--tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest' +``` + +개별 selector는 아래 클래스명 뒤에 각 메서드를 붙인다. 모두 이번 작업에서 새로 작성한 case다. + +```text +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldRejectActualNonBotLoginJwt +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldUseRealBotJwtAndStoreCallbackBeforeStartedWithExactByteReplay +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldStartMonotonicallyAndAcceptPartialAndFailedResults +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldReturn404WithoutDiscoveryAndCheckCurrentDatabaseRole +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldRejectMalformedContractsWithoutPersistingAnything +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldSerializeConcurrentCallbacksAndRollbackLosingJob +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldRollbackResultsAndStatusWhenReceiptInsertFails +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldKeepLatestPointerWhenOlderJobCompletes +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldCommitRegistrationBeforeUploadAndNeverRevertEarlyMediaConvertOutput +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldKeepAcceptedSourceAndTerminalResultWhenUploadReturnFails +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldPreserveOnlySafeActualSourceExtensionsWithoutClaimingConversion +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldExposeLanguageSubtitleOnlyWithReleasedOriginalAccessInExistingDetail +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldReadOnlyPersistedLanguageOfMatchingLatestCompletedJob +``` + +## 재현 명령과 아티팩트 + +작업 전용 root는 삭제하지 않고 부모 검증에 인계한다. + +```bash +ROOT=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/subtitle-phase1.wiRcAJ/backend.HX0j0B +export TMPDIR="$ROOT" +export NODE_COMPILE_CACHE="$ROOT/node" +export BUN_RUNTIME_TRANSPILER_CACHE_PATH="$ROOT/bun" + +./gradlew --no-daemon --max-workers=2 \ + -Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs=-Xmx4g \ + --project-cache-dir "$ROOT/gradle-cache" -I "$ROOT/isolation.gradle" \ + test --tests 'kr.co.vividnext.sodalive.v2.subtitle.*' + +./gradlew --no-daemon --max-workers=2 \ + -Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs=-Xmx4g \ + --project-cache-dir "$ROOT/gradle-cache" -I "$ROOT/isolation.gradle" \ + bootJar ktlintCheck +``` + +- 임시 init script는 build 출력과 Gradle project cache를 공유 출력에서 격리한다. 테스트 Kotlin 컴파일 입력은 신규 자막/직접 영향 기존 5개 클래스/support이며, lint filter도 변경 Kotlin 파일만 대상으로 한다. 전역 설정이나 저장소 build 설정은 변경하지 않았다. +- 성공 테스트: `$ROOT/green-new-tests-cached.log`, `BUILD SUCCESSFUL in 1m 32s`. 임시 script의 사용하지 않는 JWT 기본값을 제거한 뒤 다시 실행한 최종 `$ROOT/final-new-tests.log`도 `BUILD SUCCESSFUL in 2m 25s`다. XML: `$ROOT/build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.xml`, `tests=13`, `failures=0`, `errors=0`, `skipped=0`. +- 실제 두 job의 동일 eventId 경합에서 H2 SQLState `23505` unique 위반이 관찰됐고, 최종 HTTP 200/422와 loser job의 PENDING/결과 행 없음 assertion이 통과했다. 별도의 receipt 쓰기 실패 주입에서도 identity 생성으로 flush된 결과 행까지 rollback됨을 확인했다. +- 성공 빌드/lint: `$ROOT/build-lint.log`, `BUILD SUCCESSFUL in 1m 28s`. 실행 가능한 boot JAR은 `$ROOT/build/libs/sodalive-0.0.1-SNAPSHOT.jar`다. `jar tf`에서 새 컨트롤러/서비스/엔티티/repository/DTO가 `BOOT-INF/classes`에 포함된 것을 확인했다. +- 검증 완료 후 root를 참조하는 작업 소유 Gradle/HTTP/Redis 프로세스는 관찰되지 않았다. 기존 공유 worker PID `55393`과 그 Redis PID `55452`는 이 작업 소유가 아니며 종료하지 않았다. 테스트 실행 중 관찰된 작업 소유 executor PID `20950`과 최종 executor PID `22207`은 종료됐다. + +## RED와 실패 이력 + +- 첫 baseline/신규 RED 명령은 테스트 컴파일 중 600초 제한으로 종료됐다(`red.log`). 테스트 결과가 아니며 RED로 인정하지 않았다. +- `red-focused.log`는 이전 지시에 따른 기존 3개 회귀 클래스와 신규 1개 case 실행이다. 총 41개 중 1개가 테스트 JWT key setup에서 실패했다. 이를 기능 RED로 인정하지 않았다. 이 명령은 기존 테스트 실행 금지라는 후속 사용자 지시 전의 기록이며 현재 구현의 회귀 성공 증거로 사용하지 않는다. +- 테스트 전용 `DynamicPropertySource`로 유효 길이의 비운영 JWT key를 지정한 뒤 `red-http.log`에서 실제 로그인 성공 후 `403 기대 / 404 실제`를 확인했다. BOT 전용 경로가 없던 동작의 실제 RED다. +- 초기 `red-binding.log`는 JPA unique constraint의 logical/physical column 이름 오류로 context가 뜨지 못했다. 이를 RED로 인정하지 않고 엔티티에 명시적 column 이름을 붙였다. +- `red-binding-assertions.log`에서 새 두 case가 실제 assertion으로 실패했다: source 확장자 보존 실패, 기존 eventId의 unknown job 변경에 대해 `422 기대 / 404 실제`. 확장자 전달과 receipt 선조회/명시적 isolation 변경 후 같은 두 case를 포함한 13개 전체 신규 case가 GREEN이다. +- `green-first.log`는 사용자 지시 갱신을 위한 중단으로 완료 결과가 없다. `green-new-tests.log`는 compile을 통과한 뒤 test 시작 시점에 600초 도구 제한으로 종료됐다. 공유 프로세스를 종료하지 않고 같은 격리 경로의 캐시를 재사용한 `green-new-tests-cached.log`에서 완료 결과를 얻었다. +- `format.log`의 첫 변경 파일 format 실행은 길이 위반을 보고했다. 해당 새 줄만 정리한 뒤 최종 `ktlintCheck`가 통과했다. + +## 외부 게이트와 남은 미검증 + +1. 실제 MediaConvert consumer가 기존 contentId filename prefix를 읽는지, UUID basename·출력 확장자·디렉터리를 어떻게 구성하는지 운영 증거가 없다. 로컬에서는 정확한 UUID stem과 AAC/M4A/MP3 경로 검증만 확인했다. consumer의 운영 호환성을 확인하기 전 배포 전환은 BLOCKED다. +2. 부모가 승인된 ZIP에서 확인한 Transcribe 지원 확장자는 amr/flac/m4a/mp3/mp4/ogg/webm/wav다. Spring은 AAC/opus 등 기존 업로드를 임의로 거부하지 않는다. 이들 또는 누락/불안전 확장자에 대한 자막 성공은 보장하지 않으며 Lambda의 명시적 unsupported 결과/수동 확인 대상이다. 테스트의 한 바이트 파일은 source binding/transaction fixture일 뿐 실제 미디어 증거가 아니다. +3. S3 putObject 수락 후 getUrl 실패는 성공으로 숨기지 않는다. 원래 예외를 호출자에게 전파하고 `SOURCE_UPLOAD_UNCERTAIN` 및 초 정밀도 TIMESTAMP를 별도 기록한다. 이미 완료된 자막 상태/metadata와 content를 삭제하거나 회귀시키지 않는다. 실제 객체/MediaConvert 상태는 운영자 수동 확인이 필요하다. +4. 운영 MySQL DDL 적용, case-sensitive/NO PAD collation 및 FK/인덱스 검증, private CloudFront origin과 실제 SRT 서명 접근, 실서버 권한/앱 표시, live ko/ja/mixed 및 실제 duration/2시간 처리 예산은 미검증이다. AWS 호출·배포·DB 적용은 수행하지 않았다. +5. 전체 테스트와 후속 지시 이후의 기존 회귀 case 실행은 수행하지 않았다. H2 전체 Spring context의 기존 charge/translation scheduler는 MySQL `FOR UPDATE SKIP LOCKED` 구문 때문에 로그 오류를 남겼다. 이번 신규 case는 모두 통과했지만 그 worker 기능의 성공 증거는 아니다. 사용자가 별도로 전체 테스트를 원할 때만 `./gradlew test`를 실행한다. + +## 20261111 P1 후속 수정: body/DB 실패의 HTTP 200 오인 방지 + +### 수정 범위와 계약 + +- `SubtitleExceptionHandler.kt`의 기존 `SubtitleJobController` 전용 scope와 우선순위를 유지했다. `HttpMessageNotReadableException`은 HTTP 422와 기존 일반화된 거절 body로, 그 외 예상하지 못한 `Exception`은 HTTP 500과 `{"error":"Subtitle request failed"}`로 응답한다. +- 기존 `ResponseStatusException`의 404/409/422 처리는 변경하지 않았다. 공통 `SodaExceptionHandler`, JWT, 컨트롤러, 서비스 및 DB 저장 로직은 변경하지 않았으며 예외 원문·요청·토큰 로그도 추가하지 않았다. +- 수정 파일은 자막 advice, `SubtitleHttpIntegrationTest.kt` 및 이 보고서의 추가 기록뿐이다. 기존 직접 서비스 롤백 테스트 `shouldRollbackResultsAndStatusWhenReceiptInsertFails`와 기존 assertion은 그대로 보존했다. 부모 소유 PRD/계획/final-report는 수정하지 않았다. + +### 새 HTTP 두 사례 + +1. `shouldRejectEmptyCallbackAndStartedBodiesWithoutPersistingAnything`: 실제 BOT 로그인/JWT로 빈 callback·started body를 모두 전송해 422를 확인한다. job/receipt/결과 행 수, PENDING 상태, 갱신 시각 및 오류 필드가 변경되지 않고 해당 job 결과도 없음을 검증한다. +2. `shouldReturn500AndRollbackWhenReceiptInsertFailsOverHttp`: 실제 HTTP callback에서 기존 receipt repository SpyBean의 `saveAndFlush`에 DB 예외를 주입한다. ko-KR partial callback을 사용하여 상태뿐 아니라 오류 필드도 변경 대상에 포함하고, 일반화된 500 body 및 receipt·결과 부재, PENDING/갱신 시각/오류 필드 롤백을 DB 재조회로 확인한다. + +### RED/GREEN 명령과 증거 + +위 격리 `ROOT`와 `TMPDIR`/Node/Bun cache 환경을 그대로 사용했다. 외부 의존성 다운로드를 막는 `--offline`과 부모의 `verification.gradle`을 추가하여 테스트만 강제로 다시 실행했다. `clean` 및 무필터 test/check/build는 실행하지 않았다. + +advice 수정 전 RED와 수정 후 두 사례 GREEN에 사용한 동일 selector/명령: + +```bash +./gradlew --offline --no-daemon --max-workers=2 \ + -Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs=-Xmx4g \ + --project-cache-dir "$ROOT/gradle-cache" \ + -I "$ROOT/isolation.gradle" -I "$ROOT/../verification.gradle" \ + test \ + --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldRejectEmptyCallbackAndStartedBodiesWithoutPersistingAnything' \ + --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldReturn500AndRollbackWhenReceiptInsertFailsOverHttp' +``` + +- RED: `$ROOT/p1-red-two-http.log`, `BUILD FAILED in 4m 16s`, 정확히 2개 실행/2개 assertion 실패다. `$ROOT/p1-red-assertions.log`에 XML의 실패 증거를 보존했다. 빈 body는 `expected [422, 422] / actual [200, 200]`, DB 실패는 `expected 500 / actual 200`이었다. +- 첫 GREEN 시도 `$ROOT/p1-green-two-http.log`는 `compileKotlin` 도중 600초 도구 제한으로 종료됐다. 테스트가 실행되지 않아 GREEN으로 계산하지 않았다. 작업 소유 프로세스가 남지 않은 것을 확인한 후 같은 캐시와 1200초 제한으로 재실행했다. +- 두 사례 GREEN: `$ROOT/p1-green-two-http-retry.log`, `BUILD SUCCESSFUL in 13m 29s`. `$ROOT/p1-green-two-assertions.log`는 `tests=2`, `failures=0`, `errors=0`, `skipped=0`이다. + +이후 자막 신규 클래스 15개 전체와 기존 격리 script의 변경 Kotlin 파일 lint 및 boot JAR를 함께 검증했다: + +```bash +./gradlew --offline --no-daemon --max-workers=2 \ + -Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs=-Xmx4g \ + --project-cache-dir "$ROOT/gradle-cache" \ + -I "$ROOT/isolation.gradle" -I "$ROOT/../verification.gradle" \ + test --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest' \ + bootJar ktlintCheck +``` + +- 최종 `$ROOT/p1-green-15-build-lint.log`: `BUILD SUCCESSFUL in 7m 44s`. production/test 컴파일은 `UP-TO-DATE`였으며 test, bootJar, main/test 변경 파일 lint는 실제 실행됐다. +- 최종 XML `$ROOT/build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.xml`: `tests=15`, `failures=0`, `errors=0`, `skipped=0`. 실제 RANDOM_PORT HTTP에서 새 422/500과 기존 404/409/422 및 직접 서비스 롤백 case가 모두 통과했다. 기존 회귀 클래스는 컴파일 입력일 뿐 실행하지 않았다. +- `$ROOT/build/libs/sodalive-0.0.1-SNAPSHOT.jar`에 수정된 `SubtitleExceptionHandler.class`가 포함됨을 `jar tf`로 확인했다. LSP 도구는 제공되지 않아 사용하지 못했으며 Kotlin 컴파일과 변경 파일 lint를 확인했다. +- RED/GREEN/최종 테스트 executor PID `28638`/`37727`/`39291`은 모두 종료됐다(`$ROOT/p1-test-process-status.log`는 header만 존재). 완료 후 작업 root를 사용하는 Gradle/HTTP/Redis 프로세스는 관찰되지 않았다. 기존 공유 PID `55393`/`55452`는 그대로이며 어떤 task도 취소하거나 프로세스를 수동 종료하지 않았다. root와 로그는 부모 재검증용으로 보존했다. + +### 남은 검증 경계 + +- 기존 Kotlin 경고, Gradle deprecation, H2의 기존 charge/translation scheduler SQL 오류와 Redis 종료 시 재접속 경고는 관찰됐으나 수정 범위 밖이다. 신규 자막 15개 assertion은 전부 통과했다. +- 기존 회귀/전체 테스트, 운영 MySQL, AWS·MediaConvert·Lambda 실연동 및 배포 검증은 수행하지 않았다. 앞 절의 외부 게이트는 그대로 남으며, 전체 테스트 사용자 실행 명령은 `./gradlew test`다. + +## 부모 최종 검증·정리 추가 기록 + +- 부모가 처음 신규13개 및 P1의 두 HTTP 사례를 각각 직접 재실행해 모두 통과했고, 독립 리뷰의 유일 finding은 ADDRESSED 및 로컬 backend APPROVE 판정을 받았다. +- 부모의 P1 재실행은 `BUILD SUCCESSFUL in 4m 55s`, 실제 XML2/0/0/0, timestamp `2026-11-10T15:34:43`이다. +- 2026-11-11 작업 소유 PID/listener 부재를 확인한 후 위 임시 ROOT 전체를 제거했다. 따라서 ROOT 기반 로그·init script·JAR 경로는 과거 검증 위치이며 현재 파일로 남아 있지 않다. 명령/결과/실패 이력은 본 보고서와 final-report에 보존했다. +- 일반 개발 환경의 재현 명령은 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest'`다. 기존 테스트는 실행하지 않는 selector다. diff --git a/docs/20261110_자막Phase1연동/plan-task.md b/docs/20261110_자막Phase1연동/plan-task.md index 010c5390..54a5adc9 100644 --- a/docs/20261110_자막Phase1연동/plan-task.md +++ b/docs/20261110_자막Phase1연동/plan-task.md @@ -152,6 +152,8 @@ ## Progress +- 2026-11-11 별도 사용자 `/commit-with-validation` 요청으로 커밋 진행. 기능 연동 커밋의 pre-commit `ktlintCheck`가 기존 `AudioContentService.kt` EOF 빈 줄 1건으로 실패하여 해당 빈 줄만 제거했다. 재시도에서 staged Kotlin 대상 lint와 `git diff --staged --check`, 커밋 직전·직후 메시지 검증이 통과했다. 이번 커밋 작업에서는 기능/전체 테스트 및 bootJar를 재실행하지 않았으며 기존 실행 이력과 외부 배포 BLOCKED는 유지한다. 전체 테스트 사용자 명령은 `./gradlew test`다. + - 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을 주장하지 않는다. diff --git a/docs/20261110_자막Phase1연동/reviews/backend-gate-review.md b/docs/20261110_자막Phase1연동/reviews/backend-gate-review.md new file mode 100644 index 00000000..33d62ada --- /dev/null +++ b/docs/20261110_자막Phase1연동/reviews/backend-gate-review.md @@ -0,0 +1,47 @@ +# 자막 Phase 1 Backend Gate 리뷰 + +## 1. 리뷰 정보 + +- 일자: 2026-11-10 +- 대상: `test` working tree의 신규 `v2.subtitle` 및 기존 업로드/상세/보안 연결 +- 기준: [PRD](../prd.md), [계획](../plan-task.md), [구현 보고](../implementation-report.md) +- 리뷰어: 독립 Oracle (`ses_e3a1e039bffeXbBb7Nx0V7Hvk8`) +- 상태: 회귀 수정 검증 완료, 로컬 backend APPROVE +- 검토 제외: AWS/운영 DB/실음성/2시간 성능, 기존 전체 회귀 실행 + +## 2. 근거와 판정 + +부모 직접 실행의 실제 localhost HTTP 신규 테스트 13개가 0 failure/error/skip으로 통과했다. +reviewer는 코드·신규 테스트·실행 로그·DDL을 읽었고 테스트를 재실행하지 않았다. +저장/권한/언어별 결과/업로드/스키마는 부합 판정이나 HTTP 오류 경계 한 건으로 REQUEST_CHANGES다. + +| ID | 심각도 | 상태 | 내용 | 수정 Goal | +|---|---|---|---|---| +| REV-P1-001 | High | 수정 완료 / ADDRESSED | 자막 빈 body/저장 실패가 공통 advice에서 HTTP200으로 변환 | P1-R1 | + +## 3. REV-P1-001 + +- `SubtitleExceptionHandler.kt`는 ResponseStatusException만 처리한다. +- 기존 `common/SodaExceptionHandler.kt:73`과 `:94`는 DB 예외/일반 예외에 HTTP 상태 없이 ApiResponse.error를 반환한다. +- 필수 body가 비면 strict parser 이전에 HttpMessageNotReadableException이 발생한다. +- receipt 실패의 기존 새 테스트는 서비스 직접 호출 rollback만 검증하므로 HTTP200 문제를 포착하지 못했다. +- 영향: 결과가 저장되지 않았어도 Lambda가 HTTP 성공으로 해석할 수 있다. + +최소 수정은 자막 controller에 한정된 advice에서 body 읽기 오류를 422, 예상하지 못한 서버 오류를 +민감정보 없는 500으로 반환하는 것이다. 기존 404/409/422 및 공유 API 동작을 보존한다. + +## 4. 수정 검증 조건 + +1. 빈 callback/started 요청이 200이 되는 RED를 실제 HTTP로 확인한다. +2. receipt 실패 callback이 200이 되는 RED와 전체 DB rollback을 확인한다. +3. 수정 후 422/500 및 무부분반영을 검증하고 이번 작성 테스트만 재실행한다. +4. 같은 reviewer가 수정 delta와 새 증거를 재검토한다. + +## 5. 판정 기록 + +- 1차: REQUEST_CHANGES 1건. 부모가 공통 handler 코드를 직접 확인하고 `P1-R1`에 반영했다. +- 2026-11-11 수정 검증: 전용 advice만 수정하고 신규 HTTP 두 사례의 RED/GREEN을 확인했다. 이후 신규 자막15개·bootJar·변경 lint가 통과했고, 부모 직접 두 HTTP 사례 재실행도 2/0/0/0으로 통과했다. 기존 공통 handler와 직접 서비스 rollback 테스트는 보존했다. reviewer session continuation 유실로 기존 판정과 delta만 전달한 단일 재검토를 진행 중이다. +- 운영 배포는 코드 리뷰와 별개이며 [배포 게이트](../deployment-gates.md)의 차단 상태를 유지한다. +- 최종 제한 delta 리뷰 (`ses_e39e64c42ffeCOGUZjgeM4P1jf`): **REV-P1-001 ADDRESSED / 로컬 backend APPROVE**. + 전용 advice의 422/500, 기존 404/409/422 유지, 실제 HTTP 및 rollback RED/GREEN 증거를 확인했다. + 수정 delta에서 신규 버그/blocker는 발견하지 못했다. 추가 코드 수정 불필요 판정이며 전체 리뷰 반복이나 기존 테스트 실행은 하지 않았다. diff --git a/docs/20261110_자막Phase1연동/reviews/generate-url-review.md b/docs/20261110_자막Phase1연동/reviews/generate-url-review.md new file mode 100644 index 00000000..371cf2b0 --- /dev/null +++ b/docs/20261110_자막Phase1연동/reviews/generate-url-review.md @@ -0,0 +1,16 @@ +# generate-url 자막 응답 제한 리뷰 + +- 일자: 2026-11-11 +- 범위: `P2-T1`, `GenerateUrlResponse`와 `AudioContentService.generateUrl`, 새 HTTP 테스트3개. +- 기준: PRD §7, plan-task P2, 실제 메서드/DTO 전후 diff 및 `generate-url-report.md`의 RED/GREEN 증거. +- 리뷰어: `ses_e387b1e8fffensjb6dnoRk82ST`, read-only. 기존 Phase1 전체는 다시 리뷰하지 않았다. +- 판정: **APPROVE**, 이번 범위의 확정 finding/blocker/새 회귀 없음. + +확인한 기준: +1. contentUrl과 ApiResponse envelope 유지, 공유 nullable subtitle만 additive 추가. +2. 활성 구매(KEEP/유효 RENTAL) 정책 유지, 소유자·무료만으로 우회하지 않음. +3. 음원 URL은 기존대로이며 자막에만 상세와 동일한 공개일/소유자 조건 적용. +4. 동일 expirationTime 전달 및 공유 latest/언어/상태별 자막 조회 재사용. +5. 새 HTTP3개 RED → GREEN 및 부모 재실행3/0/0/0, 실제 localhost HTTP/JWT/H2 증거 확인. + +잔여사항: production lint의 기존 서비스 EOF 빈 줄1건은 보존했다. 기존 테스트/전체 테스트/LSP 및 실제 AWS 서명·다운로드/운영 배포는 이번 검증이 아니다. 수정 Task로 전환할 새로운 발견 사항은 없다.