Test #470

Merged
klaus merged 7 commits from test into main 2026-11-11 07:29:03 +00:00
36 changed files with 2982 additions and 164 deletions
@@ -0,0 +1,146 @@
# 자막 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 |
2026-11-11 정정: 이전 정적 대조는 `s3Bucket`의 숫자 경계 명명 규칙을 놓쳤다. 기본 naming strategy가
`s3bucket`/`s3key`로 추론하므로 두 필드에 명시적 `@Column(name = "s3_bucket"/"s3_key")`가 필요하다.
`P3-R1`에서 실제 `validate` 실패를 재현한 뒤 두 이름을 명시해 H2 validation/저장/재조회를 통과했다.
독립 리뷰도 승인했다. 운영 DB에 `s3bucket` 컬럼을 추가한 것이 아니며 실 MySQL 기동은 별도 확인 대상이다.
| 기존 엔티티 속성 | 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·새 테스트·빌드 결과는 구현 담당의 별도 증거이며, 그 성공도 위 운영 게이트를 해제하지 않는다.
@@ -0,0 +1,132 @@
# 자막 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)에 있다.
## 후속 회귀: s3 컬럼 schema-validation
2026-11-11 실제 기동의 missing `[s3bucket]` 오류를 독립 H2 table과 Hibernate `validate`로 재현했다.
기본 명명전략이 숫자 경계를 분리하지 않는 것을 기존 정적 대조와 create-drop 테스트가 놓친 문제다.
`Subtitle`의 두 Column 이름만 `s3_bucket`, `s3_key`로 명시했고 DDL/운영 DB/JSON 계약은 바꾸지 않았다.
신규 회귀 테스트1개에서 validation과 Hibernate/JDBC 저장·재조회가 통과했으며 bootJar/변경 lint 및 독립 리뷰 APPROVE다.
실제 운영 MySQL 재기동은 수행하지 않았다. 상세 원인·검증은 [schema-validation-report.md](schema-validation-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`다.
@@ -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'
```
@@ -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다.
@@ -0,0 +1,217 @@
# 자막 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/2 완료 이력 유지. Phase3 컬럼 매핑 수정·신규 테스트·독립 리뷰·소유 임시 자원 정리 완료. 활성 로컬 Goal 없음. 운영 DB/외부 게이트는 별도 검증 대상이다.
- 실행: 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 3: Schema-validation 회귀 수정
### Task 3.R1: s3 컬럼 물리명 일치
**Goal `P3-R1`:** 기존 `subtitle.s3_bucket`/`s3_key` 테이블을 Hibernate가 검증하고 정상 저장·조회한다.
- 시작 조건: 실제 사용자 에러, 명시적 이름이 없는 엔티티와 DDL 차이 확인.
- Files: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/Subtitle.kt`.
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/SubtitleSchemaValidationTest.kt` 신규.
- 범위 밖: DDL 수정·운영 DB 접속/컬럼 추가·자동 update·전역 naming strategy·JSON/property rename·기존 테스트 실행.
- 시나리오: 독립적으로 만든 s3_bucket/s3_key 테이블에 실제 Boot 기본 naming strategy를 사용한 Hibernate validate 성공, 두 값의 persist/reload/JDBC column 확인 성공.
- [x] **RED:** 엔티티 자동 DDL 생성 없이 기존 컬럼명의 H2 table을 준비하고 실제 schema validate를 수행하는 새 테스트를 작성했다.
- [x] **RED 확인:** 사용자 보고와 동일한 missing s3bucket 에러로 1개 실패를 확인했다. 컴파일 오류가 아니다.
- [x] **GREEN:** `@Column(name = "s3_bucket")`, `@Column(name = "s3_key")`를 기존 속성에 명시했다.
- [x] **GREEN 확인:** 새 테스트1개만 실행해 validation 및 Hibernate 저장/새 Session 조회/JDBC 두 컬럼 조회 성공을 확인했다.
- [x] **REFACTOR:** 변경 compile/bootJar/lint와 독립 리뷰 APPROVE, 검증 기록 보존 및 소유 임시 자원 제거·부재 확인을 완료했다.
## 이전 기능 실행 검증 이력
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subtitle.*'
./gradlew bootJar
```
기존 클래스에 추가한 새 테스트는 실제 메서드명을 `--tests`에 지정한다. 기존 테스트 클래스 전체는 실행하지 않는다. 수동 전체 검증은 사용자 실행 `./gradlew test`다. 실제 명령/추가 영향 테스트/결과는 아래에 누적한다.
## Progress
- 2026-11-11 `P3-R1` 종료: 작업 root를 참조하는 프로세스가 없고 실제 디렉터리/symlink 아님을 확인한 뒤 소유 `subtitle-schema-fix.XcftjN`만 제거했다. `test ! -e` exit0, 공유캐시 미변경. 결과/명령/리뷰는 문서로 보존했다. 운영 재시작/DB 변경/커밋은 하지 않았다.
- 2026-11-11 `P3-R1` 독립 리뷰 APPROVE: 명세/품질 blocker 없음. 두@Column만 바뀌었고 기존 SQL/JSON 계약은 유지된다. 독립DDL+actualvalidate+새Session/JDBC 조회와 RED/GREEN 증거가 확인됐다. 실제MySQL/전체Spring검증은 별도 게이트다.
- 2026-11-11 `P3-R1` RED/GREEN: 실제 Hibernate validation의 missing[s3bucket] 실패를 수정 전 재현했다(1fail, 3m49s). 두@Column만 수정한 뒤 신규 테스트1/0/0/0, Kotlin compile/bootJar/변경2파일 lint 성공(3m29s). 실제 운영 DB는 조회·변경하지 않았다. `schema-validation-report.md`에 원인과 기존 create-drop 검증 공백을 기록했다. 독립 한정 리뷰 진행 중.
- 2026-11-11 `P3-R1` 등록: 사용자 보고 missing `[s3bucket]` 재현/수정. 기존 기본 명명전략이 숫자-대문자 경계에서 underscore를 넣는다고 가정한 정적 대조와 엔티티 생성 H2 테스트의 검증 공백을 기록한다. 기존 DDL은 유지하고 실제 validate 회귀 검증으로 보완한다.
- 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을 주장하지 않는다.
- 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 경로로 검증을 격리한다.
## Schema-validation 커밋 전 재검증
- 2026-11-11 별도 `/commit-with-validation` 요청으로 `./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleSchemaValidationTest'`를 실행했다.
- 결과: `BUILD SUCCESSFUL in 46s`, 테스트 1개, 실패/오류/건너뜀 0개. 기존 컬럼명의 Hibernate validation과 저장·재조회를 재확인했다.
- 전체 테스트와 실제 MySQL 기동은 실행하지 않았다. 사용자 전체 테스트 명령은 `./gradlew test`다.
+150
View File
@@ -0,0 +1,150 @@
# 자막 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 재기동을 검증했다고 주장하지 않는다.
@@ -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는 발견하지 못했다. 추가 코드 수정 불필요 판정이며 전체 리뷰 반복이나 기존 테스트 실행은 하지 않았다.
@@ -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로 전환할 새로운 발견 사항은 없다.
@@ -0,0 +1,15 @@
# Subtitle 물리 컬럼명 회귀 리뷰
- 일자: 2026-11-11
- 범위: `P3-R1`, Subtitle의 두 Column annotation과 신규 ORM schema-validation 테스트.
- 기준: PRD §8, plan-task Phase3, 실제 RED/GREEN 로그와 production diff.
- 리뷰어: `ses_e37b4db2bffeUyLw5vO2EtHHuG`, read-only.
- 판정: **APPROVE**, 범위 내 확정 finding/blocker 없음.
검토 결과: `s3Bucket`/`s3Key`의 기본 추론 이름을 기존 DDL의 `s3_bucket`/`s3_key`로 명시했다.
Kotlin/JSON 이름·타입/길이/nullable, DDL과 전역 전략은 그대로다. 신규 테스트는 독립 테이블에서 실제
Hibernate validate, commit 후 새 Session 및 직접 JDBC 컬럼 조회를 수행한다. RED missing[s3bucket]과
GREEN1/0/0/0, bootJar 및 변경 lint 성공을 확인했다. 기존 create-drop 테스트의 한계를 반복하지 않는다.
미검증: 실제 MySQL 기동/collation/FK, 전체 Spring 연동, 기존 테스트/전체 테스트, LSP.
추가 수정 Task 없음. 운영 DB 변경이나 배포 승인을 의미하지 않는다.
@@ -0,0 +1,86 @@
# Subtitle 컬럼명 schema-validation 회귀
## 원인과 범위
사용자가 보고한 `Schema-validation: missing column [s3bucket] in table [subtitle]`를 실제 Hibernate로 재현했다.
`Subtitle.s3Bucket`/`s3Key`에 물리 컬럼 이름이 없었고, 기본 naming strategy는 숫자 `3` 다음 대문자 앞에
underscore를 넣지 않아 `s3bucket`/`s3key`로 변환한다. 기존 DDL은 `s3_bucket`/`s3_key`이므로 불일치했다.
production 수정은 `Subtitle.kt`의 두 annotation뿐이다:
```kotlin
@Column(name = "s3_bucket", nullable = false, length = 255)
val s3Bucket: String,
@Column(name = "s3_key", nullable = false, length = 255)
val s3Key: String
```
Kotlin/JSON 필드명, 기존 DDL, 전역 naming strategy, 운영 DB는 변경하지 않는다.
잘못 추론된 이름의 DB 컬럼을 새로 만들거나 `ddl-auto`를 update/none으로 바꿔 오류를 숨기지 않는다.
## 기존 검증의 공백
기존 HTTP 테스트는 `ddl-auto:create-drop`으로 엔티티 기준 테이블을 자동 생성했으므로 잘못된 컬럼명으로
테이블과 ORM이 동시에 일치했다. 앞선 정적 DDL/JPA 대조도 숫자-대문자 경계의 이름 변환을 놓쳤다.
해당 완료 이력은 보존하되 이번 재현으로 그 검증 한계를 명확히 정정한다.
## 신규 회귀 테스트
`SubtitleSchemaValidationTest.shouldValidateAndUseExistingS3Columns` 한 개만 실행한다.
1. 엔티티와 독립적으로 H2에 `s3_bucket`/`s3_key`를 포함한 테이블을 직접 생성한다.
2. `HibernateProperties`/`HibernateSettings`로 Boot 기본 설정을 얻고 실제 Hibernate의 `hbm2ddl.auto=validate`를 실행한다.
3. `Subtitle`을 저장하고 새 Session 및 직접 JDBC SELECT로 두 컬럼의 값을 확인한다.
전체 Spring context/Redis/AWS 및 mock 없이 ORM 저장 표면을 검증한다. DB는 테스트 전용 메모리 H2이며
실제 MySQL collation/FK/운영 기동 전부를 검증하는 테스트는 아니다.
## 실행 기록
- RED: 새 테스트1개/실패1개/오류0/skip0, XML UTC `2026-11-11T01:42:01`.
- 실제 실패: `SchemaManagementException: Schema-validation: missing column [s3bucket] in table [subtitle]`.
- RED 빌드 결과: `BUILD FAILED in 3m 49s`. 컴파일 오류가 아닌 보고된 운영 오류와 동일한 validation 실패다.
- GREEN: 새 테스트1개/실패0/오류0/skip0, XML UTC `2026-11-11T01:46:49`.
- 실제 Hibernate validation 성공 후 저장/new Session 조회/직접 JDBC 두 컬럼 조회가 통과했다.
- `bootJar` 및 변경 엔티티/신규 테스트에 한정한 `ktlintCheck` 성공, `BUILD SUCCESSFUL in 3m 29s`.
- LSP 도구 미제공으로 Kotlin 컴파일·변경 파일 lint를 사용했다.
- 독립 한정 리뷰 `ses_e37b4db2bffeUyLw5vO2EtHHuG`: **APPROVE**. 두 annotation만 변경됐고 독립 DDL/실제 ORM roundtrip 테스트가 원인에 맞는 것을 확인했다. 신규 blocker 없음.
일반 환경의 신규 테스트 실행 명령:
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleSchemaValidationTest'
```
격리 실행은 작업 소유 root `subtitle-schema-fix.XcftjN`의 init script로 build/project-cache와 테스트 compile/lint
입력을 이 변경에 한정한다. production source는 필터링하지 않는다. 기존 테스트와 전체 테스트는 실행하지 않는다.
실제 격리 실행 명령(RED는 `test`까지만, GREEN은 아래 전체):
```bash
export VERIFY_ROOT=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/subtitle-schema-fix.XcftjN
export TMPDIR="$VERIFY_ROOT" NODE_COMPILE_CACHE="$VERIFY_ROOT/node-compile-cache"
export BUN_RUNTIME_TRANSPILER_CACHE_PATH="$VERIFY_ROOT/bun-transpiler"
./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" \
test --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleSchemaValidationTest' bootJar ktlintCheck
```
로그는 위 root의 `red.log`, `green.log`다. 실제 운영 DB 접속·DDL 변경·서버 배포/재시작·기존 테스트 실행은 하지 않았다.
## 정리
검증·리뷰 후 작업 root를 참조하는 프로세스의 부재를 확인하고 소유 root/build/cache/log/JAR를 제거했다.
`test ! -e` exit0으로 확인했으며 위 임시 경로는 과거 검증 위치다. 공유 캐시/다른 작업은 변경하지 않았다.
새 테스트/원인/명령/결과와 리뷰 기록은 저장소에 남겼고 이번 수정은 커밋하지 않았다.
## 후속 커밋 검증
2026-11-11 별도 `/commit-with-validation` 요청으로 요구사항·계획은 `cf31c570`, 수정·회귀 테스트는
`c3e5f19c`에 커밋했다. 두 커밋 모두 저장소 메시지 검증 스크립트의 직전·직후 검증을 통과했다.
수정 커밋의 pre-commit hook에서 변경 Kotlin 2개 파일의 `ktlintCheck`도 통과했다.
`./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleSchemaValidationTest'`를
재실행해 `BUILD SUCCESSFUL in 46s`, 테스트 1개/실패 0/오류 0/건너뜀 0을 확인했다.
전체 테스트·실제 MySQL 기동·bootJar는 이번 커밋 작업에서 실행하지 않았다. 전체 테스트 사용자 명령은 `./gradlew test`다.
@@ -0,0 +1,53 @@
-- 자막 Phase 1 미출시 초안. 운영 DB 적용/호환성은 확인하지 않았다.
-- 실행 전 deployment-gates.md의 엔티티 대조 및 운영 DB 게이트 승인이 필요하다.
-- MySQL 8.0.17 이상, utf8mb4_0900_bin(NO PAD), InnoDB 3072-byte 인덱스 지원이 전제다.
-- 대소문자와 후행 공백을 보존하여 eventId 및 S3 bucket/key를 정확히 구별한다.
-- source_upload_error_at은 최종 JPA의 TIMESTAMP 선언과 초 단위 정밀도를 맞췄다.
CREATE TABLE subtitle_job (
job_id VARCHAR(36) NOT NULL COMMENT 'Spring 생성 자막 작업 UUID',
content_id BIGINT NOT NULL COMMENT '기존 콘텐츠 Long 식별자',
source_bucket VARCHAR(255) NOT NULL COMMENT '최초 등록 원본 S3 버킷',
source_key VARCHAR(255) NOT NULL COMMENT '최초 등록 원본 S3 객체 키',
output_key VARCHAR(255) NOT NULL COMMENT 'MediaConvert 출력 객체 키의 확장자 제외 경로',
status VARCHAR(32) NOT NULL COMMENT '자막 작업 상태',
error_code VARCHAR(255) NULL COMMENT '자막 처리 오류 코드',
error_message TEXT NULL COMMENT '자막 처리 오류 설명',
source_upload_error_code VARCHAR(255) NULL COMMENT '원본 업로드 불확실성 오류 코드',
source_upload_error_at TIMESTAMP NULL DEFAULT NULL COMMENT '원본 업로드 오류 기록 시각',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
PRIMARY KEY (job_id),
UNIQUE KEY uk_subtitle_job_source (source_bucket, source_key),
KEY ix_subtitle_job_content_created (content_id, created_at)
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin
COMMENT='원본 binding과 자막 작업 상태 및 오류';
CREATE TABLE subtitle (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '언어별 자막 결과 Long 식별자',
job_id VARCHAR(36) NOT NULL COMMENT '소속 자막 작업 UUID',
language VARCHAR(35) NOT NULL COMMENT '자막 언어 태그',
format VARCHAR(16) NOT NULL COMMENT '자막 파일 형식',
s3_bucket VARCHAR(255) NOT NULL COMMENT '자막 결과 S3 버킷',
s3_key VARCHAR(255) NOT NULL COMMENT '자막 결과 S3 객체 키',
PRIMARY KEY (id),
UNIQUE KEY uk_subtitle_job_language (job_id, language),
CONSTRAINT fk_subtitle_job FOREIGN KEY (job_id) REFERENCES subtitle_job (job_id)
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin
COMMENT='작업별 언어별 자막 결과';
CREATE TABLE subtitle_callback_receipt (
event_id VARCHAR(255) NOT NULL COMMENT 'callback 중복 방지 이벤트 식별자',
job_id VARCHAR(36) NOT NULL COMMENT 'callback 대상 자막 작업 UUID',
body_hash VARCHAR(64) NOT NULL COMMENT '원래 HTTP body bytes의 SHA-256 소문자 16진수',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
PRIMARY KEY (event_id)
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin
COMMENT='callback 이벤트와 원본 요청 본문 해시 수신 기록';
ALTER TABLE content
ADD COLUMN latest_subtitle_job_id VARCHAR(36)
CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NULL DEFAULT NULL COMMENT '최신 선택 자막 작업 UUID';
@@ -157,6 +157,13 @@ class SecurityConfig(
JwtFilter::class.java JwtFilter::class.java
) )
.authorizeRequests() .authorizeRequests()
.antMatchers("/internal/api/subtitle-jobs/**")
.access(
"hasRole('BOT') and " +
"principal instanceof T(kr.co.vividnext.sodalive.member.MemberAdapter) and " +
"principal.member.role == T(kr.co.vividnext.sodalive.member.MemberRole).BOT and " +
"principal.member.isActive"
)
.antMatchers("/readyz").permitAll() .antMatchers("/readyz").permitAll()
.requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll() .requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll()
.antMatchers("/member/check/email").permitAll() .antMatchers("/member/check/email").permitAll()
@@ -52,6 +52,9 @@ data class AudioContent(
var content: String? = null var content: String? = null
var coverImage: String? = null var coverImage: String? = null
@Column(length = 36)
var latestSubtitleJobId: String? = null
@OneToOne(fetch = FetchType.EAGER) @OneToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "theme_id", nullable = false) @JoinColumn(name = "theme_id", nullable = false)
var theme: AudioContentTheme? = null var theme: AudioContentTheme? = null
@@ -43,15 +43,20 @@ import kr.co.vividnext.sodalive.utils.generateFileName
import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService
import kr.co.vividnext.sodalive.v2.subtitle.application.SubtitleJobService
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.cache.annotation.Cacheable import org.springframework.cache.annotation.Cacheable
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionSynchronization import org.springframework.transaction.support.TransactionSynchronization
import org.springframework.transaction.support.TransactionSynchronizationManager import org.springframework.transaction.support.TransactionSynchronizationManager
import org.springframework.transaction.support.TransactionTemplate
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -93,6 +98,8 @@ class AudioContentService(
private val contentThemeTranslationRepository: ContentThemeTranslationRepository, private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService, private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService,
private val memberContentPreferenceService: MemberContentPreferenceService, private val memberContentPreferenceService: MemberContentPreferenceService,
private val subtitleJobService: SubtitleJobService,
transactionManager: PlatformTransactionManager,
@Value("\${cloud.aws.s3.content-bucket}") @Value("\${cloud.aws.s3.content-bucket}")
private val audioContentBucket: String, private val audioContentBucket: String,
@@ -104,6 +111,9 @@ class AudioContentService(
private val coverImageHost: String private val coverImageHost: String
) { ) {
private val log = LoggerFactory.getLogger(javaClass) private val log = LoggerFactory.getLogger(javaClass)
private val registrationTransaction = TransactionTemplate(transactionManager).apply {
propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
}
@Transactional @Transactional
fun audioContentLike(request: PutAudioContentLikeRequest, member: Member): PutAudioContentLikeResponse { fun audioContentLike(request: PutAudioContentLikeRequest, member: Member): PutAudioContentLikeResponse {
@@ -220,7 +230,7 @@ class AudioContentService(
pinContent?.isActive = false pinContent?.isActive = false
} }
@Transactional @Transactional(propagation = Propagation.NOT_SUPPORTED)
fun createAudioContent( fun createAudioContent(
contentFile: MultipartFile?, contentFile: MultipartFile?,
coverImage: MultipartFile?, coverImage: MultipartFile?,
@@ -245,7 +255,7 @@ class AudioContentService(
return createAudioContent(contentFile, coverImage, request, releaseDate, member) return createAudioContent(contentFile, coverImage, request, releaseDate, member)
} }
@Transactional @Transactional(propagation = Propagation.NOT_SUPPORTED)
fun createAudioContent( fun createAudioContent(
contentFile: MultipartFile?, contentFile: MultipartFile?,
coverImage: MultipartFile?, coverImage: MultipartFile?,
@@ -260,115 +270,118 @@ class AudioContentService(
throw SodaException(messageKey = "content.error.content_required") throw SodaException(messageKey = "content.error.content_required")
} }
validatePreviewTime(request.previewStartTime, request.previewEndTime) val registration = requireNotNull(
registrationTransaction.execute {
validatePreviewTime(request.previewStartTime, request.previewEndTime)
// 테마 체크 // 테마 체크
val theme = themeQueryRepository.findThemeByIdAndActive(id = request.themeId) val theme = themeQueryRepository.findThemeByIdAndActive(id = request.themeId)
?: throw SodaException(messageKey = "content.error.invalid_theme") ?: throw SodaException(messageKey = "content.error.invalid_theme")
if ((request.themeId == 12L || request.themeId == 13L || request.themeId == 14L) && request.price < 5) { if ((request.themeId == 12L || request.themeId == 13L || request.themeId == 14L) && request.price < 5) {
throw SodaException(messageKey = "content.error.alarm_theme_price_min") throw SodaException(messageKey = "content.error.alarm_theme_price_min")
} }
if (request.price in 1..4) throw SodaException(messageKey = "content.error.minimum_price") if (request.price in 1..4) throw SodaException(messageKey = "content.error.minimum_price")
val isFullDetailVisible = if (request.price >= 50) { val isFullDetailVisible = if (request.price >= 50) {
request.isFullDetailVisible request.isFullDetailVisible
} else { } else {
true true
} }
val purchaseOption = if (request.themeId == 12L || request.themeId == 13L || request.themeId == 14L) { val purchaseOption = if (request.themeId == 12L || request.themeId == 13L || request.themeId == 14L) {
PurchaseOption.BUY_ONLY PurchaseOption.BUY_ONLY
} else { } else {
request.purchaseOption request.purchaseOption
} }
val isOnlyRental = if ( val isOnlyRental = if (
(request.limited != null && request.limited > 0) || (request.limited != null && request.limited > 0) ||
purchaseOption == PurchaseOption.BUY_ONLY purchaseOption == PurchaseOption.BUY_ONLY
) { ) {
false false
} else if (request.purchaseOption == PurchaseOption.RENT_ONLY) { } else if (request.purchaseOption == PurchaseOption.RENT_ONLY) {
true true
} else { } else {
request.isOnlyRental request.isOnlyRental
} }
// DB에 값 추가 // DB에 값 추가
val audioContent = AudioContent( val audioContent = AudioContent(
title = request.title.trim(), title = request.title.trim(),
detail = request.detail.trim(), detail = request.detail.trim(),
languageCode = request.languageCode, languageCode = request.languageCode,
price = if (request.price > 0) { price = if (request.price > 0) {
request.price request.price
} else {
0
},
releaseDate = releaseDate,
limited = request.limited,
remaining = request.limited,
isAdult = request.isAdult,
purchaseOption = purchaseOption,
isGeneratePreview = request.isGeneratePreview,
isOnlyRental = isOnlyRental,
isPointAvailable = request.isPointAvailable,
isCommentAvailable = request.isCommentAvailable,
isFullDetailVisible = isFullDetailVisible
)
audioContent.theme = theme
audioContent.member = member
repository.save(audioContent)
// 태그 분리, #추가, 등록
if (request.tags.isNotBlank()) {
val tags = request.tags
.replace("#", " #")
.split(" ")
.asSequence()
.map { it.trim() }
.filter { it.isNotBlank() }
.map {
val tag = if (!it.startsWith("#")) {
"#$it"
} else { } else {
it 0
} },
releaseDate = releaseDate,
limited = request.limited,
remaining = request.limited,
isAdult = request.isAdult,
purchaseOption = purchaseOption,
isGeneratePreview = request.isGeneratePreview,
isOnlyRental = isOnlyRental,
isPointAvailable = request.isPointAvailable,
isCommentAvailable = request.isCommentAvailable,
isFullDetailVisible = isFullDetailVisible
)
audioContent.theme = theme
audioContent.member = member
repository.save(audioContent)
val hashTag = hashTagRepository.findByTag(tag) // 태그 분리, #추가, 등록
?: hashTagRepository.save(HashTag(tag)) if (request.tags.isNotBlank()) {
val tags = request.tags
.replace("#", " #")
.split(" ")
.asSequence()
.map { it.trim() }
.filter { it.isNotBlank() }
.map {
val tag = if (!it.startsWith("#")) {
"#$it"
} else {
it
}
val audioContentHashTag = AudioContentHashTag() val hashTag = hashTagRepository.findByTag(tag)
audioContentHashTag.audioContent = audioContent ?: hashTagRepository.save(HashTag(tag))
audioContentHashTag.hashTag = hashTag
audioContentHashTag val audioContentHashTag = AudioContentHashTag()
}.toList() audioContentHashTag.audioContent = audioContent
audioContentHashTag.hashTag = hashTag
audioContent.audioContentHashTags.addAll(tags) audioContentHashTag
} }.toList()
var metadata = ObjectMetadata() audioContent.audioContentHashTags.addAll(tags)
metadata.contentLength = coverImage.size }
// 커버 이미지 파일명 생성 val metadata = ObjectMetadata()
val coverImageFileName = generateFileName(prefix = "${audioContent.id}-cover") metadata.contentLength = coverImage.size
// 커버 이미지 업로드 // 커버 이미지 파일명 생성
val coverImagePath = s3Uploader.upload( val coverImageFileName = generateFileName(prefix = "${audioContent.id}-cover")
inputStream = coverImage.inputStream,
bucket = coverImageBucket, // 커버 이미지 업로드
filePath = "audio_content_cover/${audioContent.id}/$coverImageFileName", val coverImagePath = s3Uploader.upload(
metadata = metadata inputStream = coverImage.inputStream,
bucket = coverImageBucket,
filePath = "audio_content_cover/${audioContent.id}/$coverImageFileName",
metadata = metadata
)
audioContent.coverImage = coverImagePath
audioContent to subtitleJobService.register(audioContent, contentFile.originalFilename)
}
) )
val (audioContent, subtitleJob) = registration
audioContent.coverImage = coverImagePath
// 콘텐츠 파일명 생성
val contentFileName = generateFileName(prefix = "${audioContent.id}-content")
// 콘텐츠 파일 업로드 // 콘텐츠 파일 업로드
metadata = ObjectMetadata() val metadata = ObjectMetadata()
metadata.contentLength = contentFile.size metadata.contentLength = contentFile.size
metadata.addUserMetadata( metadata.addUserMetadata(
"generate_preview", "generate_preview",
@@ -384,38 +397,45 @@ class AudioContentService(
metadata.addUserMetadata("preview_end_time", request.previewEndTime) metadata.addUserMetadata("preview_end_time", request.previewEndTime)
} }
val contentPath = s3Uploader.upload( try {
inputStream = contentFile.inputStream, contentFile.inputStream.use { inputStream ->
bucket = audioContentBucket, s3Uploader.upload(
filePath = "input/${audioContent.id}/$contentFileName", inputStream = inputStream,
metadata = metadata bucket = audioContentBucket,
) filePath = subtitleJob.sourceKey,
metadata = metadata
audioContent.content = contentPath
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
if (audioContent.languageCode.isNullOrBlank()) {
val papagoQuery = listOf(
request.title.trim(),
request.detail.trim(),
request.tags.trim()
)
.filter { it.isNotBlank() }
.joinToString(" ")
applicationEventPublisher.publishEvent(
LanguageDetectEvent(
id = audioContent.id!!,
query = papagoQuery
) )
) }
} else { } catch (exception: Exception) {
applicationEventPublisher.publishEvent( subtitleJobService.recordUploadError(subtitleJob.jobId)
LanguageTranslationEvent( throw exception
id = audioContent.id!!, }
targetType = LanguageTranslationTargetType.CONTENT
registrationTransaction.executeWithoutResult {
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
if (audioContent.languageCode.isNullOrBlank()) {
val papagoQuery = listOf(
request.title.trim(),
request.detail.trim(),
request.tags.trim()
) )
) .filter { it.isNotBlank() }
.joinToString(" ")
applicationEventPublisher.publishEvent(
LanguageDetectEvent(
id = audioContent.id!!,
query = papagoQuery
)
)
} else {
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = audioContent.id!!,
targetType = LanguageTranslationTargetType.CONTENT
)
)
}
} }
return CreateAudioContentResponse(contentId = audioContent.id!!) return CreateAudioContentResponse(contentId = audioContent.id!!)
@@ -481,8 +501,9 @@ class AudioContentService(
@Transactional @Transactional
fun uploadComplete(contentId: Long, content: String, duration: String) { fun uploadComplete(contentId: Long, content: String, duration: String) {
val keyFileName = content.split("/").last() val registeredOutputMatches = subtitleJobService.matchesOutput(contentId, content)
if (!keyFileName.startsWith(contentId.toString())) throw SodaException(messageKey = "common.error.invalid_request") val validOutput = registeredOutputMatches ?: content.substringAfterLast("/").startsWith(contentId.toString())
if (!validOutput) throw SodaException(messageKey = "common.error.invalid_request")
val audioContent = repository.findByIdOrNull(contentId) val audioContent = repository.findByIdOrNull(contentId)
?: throw SodaException(messageKey = "common.error.invalid_request") ?: throw SodaException(messageKey = "common.error.invalid_request")
@@ -978,7 +999,19 @@ class AudioContentService(
nextContent = nextContent, nextContent = nextContent,
buyerList = buyerList, buyerList = buyerList,
isAvailableUsePoint = audioContent.isPointAvailable && audioContent.price > 0, isAvailableUsePoint = audioContent.isPointAvailable && audioContent.price > 0,
translated = translated translated = translated,
subtitle = if (
(isExistsAudioContent || creatorId == member.id || audioContent.price <= 0) &&
(audioContent.releaseDate == null || audioContent.releaseDate!! <= LocalDateTime.now() || creatorId == member.id)
) {
subtitleJobService.subtitle(
audioContent,
langContext.lang,
1000 * 60 * 60 * ((audioContent.duration ?: "00:00:00").split(":")[0].toLong() + 2)
)
} else {
null
}
) )
} }
@@ -1302,16 +1335,25 @@ class AudioContentService(
contentId = audioContent.id!! contentId = audioContent.id!!
) )
val contentUrl = if (isExistsAudioContent) { return if (isExistsAudioContent) {
audioContentCloudFront.generateSignedURL( val expirationTime = 1000 * 60 * 60 * (audioContent.duration!!.split(":")[0].toLong() + 2)
resourcePath = audioContent.content!!, GenerateUrlResponse(
expirationTime = 1000 * 60 * 60 * (audioContent.duration!!.split(":")[0].toLong() + 2) contentUrl = audioContentCloudFront.generateSignedURL(
resourcePath = audioContent.content!!,
expirationTime = expirationTime
),
subtitle = if (
audioContent.releaseDate == null || audioContent.releaseDate!! <= LocalDateTime.now() ||
audioContent.member!!.id == member.id
) {
subtitleJobService.subtitle(audioContent, langContext.lang, expirationTime)
} else {
null
}
) )
} else { } else {
"" GenerateUrlResponse("")
} }
return GenerateUrlResponse(contentUrl)
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
@@ -1,5 +1,8 @@
package kr.co.vividnext.sodalive.content package kr.co.vividnext.sodalive.content
import kr.co.vividnext.sodalive.v2.subtitle.dto.ContentSubtitleResponse
data class GenerateUrlResponse( data class GenerateUrlResponse(
val contentUrl: String val contentUrl: String,
val subtitle: ContentSubtitleResponse? = null
) )
@@ -4,6 +4,7 @@ import com.querydsl.core.annotations.QueryProjection
import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListItem import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListItem
import kr.co.vividnext.sodalive.content.order.OrderType import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.translation.TranslatedContent import kr.co.vividnext.sodalive.content.translation.TranslatedContent
import kr.co.vividnext.sodalive.v2.subtitle.dto.ContentSubtitleResponse
data class GetAudioContentDetailResponse( data class GetAudioContentDetailResponse(
val contentId: Long, val contentId: Long,
@@ -42,7 +43,8 @@ data class GetAudioContentDetailResponse(
val nextContent: OtherContentResponse?, val nextContent: OtherContentResponse?,
val buyerList: List<ContentBuyer>, val buyerList: List<ContentBuyer>,
val isAvailableUsePoint: Boolean, val isAvailableUsePoint: Boolean,
val translated: TranslatedContent? val translated: TranslatedContent?,
val subtitle: ContentSubtitleResponse? = null
) )
data class OtherContentResponse @QueryProjection constructor( data class OtherContentResponse @QueryProjection constructor(
@@ -19,6 +19,7 @@ import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminA
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.time.DateTimeException import java.time.DateTimeException
@@ -158,7 +159,7 @@ class AiCharacterAdminAudioContentFacade(
) )
} }
@Transactional @Transactional(propagation = Propagation.NOT_SUPPORTED)
fun create( fun create(
characterId: Long, characterId: Long,
coverImage: MultipartFile, coverImage: MultipartFile,
@@ -0,0 +1,28 @@
package kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.GeneratedValue
import javax.persistence.GenerationType
import javax.persistence.Id
import javax.persistence.Table
import javax.persistence.UniqueConstraint
@Entity
@Table(name = "subtitle", uniqueConstraints = [UniqueConstraint(columnNames = ["job_id", "language"])])
class Subtitle(
@Column(name = "job_id", nullable = false, length = 36)
val jobId: String,
@Column(nullable = false, length = 35)
val language: String,
@Column(nullable = false, length = 16)
val format: String,
@Column(name = "s3_bucket", nullable = false, length = 255)
val s3Bucket: String,
@Column(name = "s3_key", nullable = false, length = 255)
val s3Key: String
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
}
@@ -0,0 +1,31 @@
package kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence
import org.springframework.data.domain.Persistable
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.Id
import javax.persistence.Table
import javax.persistence.Transient
@Entity
@Table(name = "subtitle_callback_receipt")
class SubtitleCallbackReceipt(
@Id
@Column(length = 255)
val eventId: String,
@Column(nullable = false, length = 36)
val jobId: String,
@Column(nullable = false, length = 64)
val bodyHash: String
) : Persistable<String> {
@Column(nullable = false, columnDefinition = "TIMESTAMP")
val createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS)
override fun getId(): String = eventId
@Transient
override fun isNew(): Boolean = true
}
@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence
import org.springframework.data.jpa.repository.JpaRepository
interface SubtitleCallbackReceiptRepository : JpaRepository<SubtitleCallbackReceipt, String>
@@ -0,0 +1,47 @@
package kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence
import kr.co.vividnext.sodalive.v2.subtitle.domain.SubtitleJobStatus
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.Id
import javax.persistence.Table
import javax.persistence.UniqueConstraint
@Entity
@Table(name = "subtitle_job", uniqueConstraints = [UniqueConstraint(columnNames = ["source_bucket", "source_key"])])
class SubtitleJob(
@Id
@Column(length = 36)
val jobId: String,
@Column(nullable = false)
val contentId: Long,
@Column(name = "source_bucket", nullable = false)
val sourceBucket: String,
@Column(name = "source_key", nullable = false)
val sourceKey: String,
@Column(nullable = false)
val outputKey: String,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
var status: SubtitleJobStatus = SubtitleJobStatus.PENDING
) {
var errorCode: String? = null
@Column(columnDefinition = "TEXT")
var errorMessage: String? = null
var sourceUploadErrorCode: String? = null
@Column(columnDefinition = "TIMESTAMP")
var sourceUploadErrorAt: LocalDateTime? = null
@Column(nullable = false, columnDefinition = "TIMESTAMP")
val createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS)
@Column(nullable = false, columnDefinition = "TIMESTAMP")
var updatedAt: LocalDateTime = createdAt
}
@@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence
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 javax.persistence.LockModeType
interface SubtitleJobRepository : JpaRepository<SubtitleJob, String> {
fun findBySourceBucketAndSourceKey(sourceBucket: String, sourceKey: String): SubtitleJob?
fun findFirstByContentIdOrderByCreatedAtAsc(contentId: Long): SubtitleJob?
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select j from SubtitleJob j where j.jobId = :jobId")
fun findForUpdate(@Param("jobId") jobId: String): SubtitleJob?
}
@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence
import org.springframework.data.jpa.repository.JpaRepository
interface SubtitleRepository : JpaRepository<Subtitle, Long> {
fun findAllByJobId(jobId: String): List<Subtitle>
fun findByJobIdAndLanguage(jobId: String, language: String): Subtitle?
}
@@ -0,0 +1,25 @@
package kr.co.vividnext.sodalive.v2.subtitle.adapter.web
import org.springframework.core.Ordered
import org.springframework.core.annotation.Order
import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.server.ResponseStatusException
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(assignableTypes = [SubtitleJobController::class])
class SubtitleExceptionHandler {
@ExceptionHandler(ResponseStatusException::class)
fun handle(exception: ResponseStatusException): ResponseEntity<Map<String, String>> =
ResponseEntity.status(exception.status).body(mapOf("error" to "Subtitle request rejected"))
@ExceptionHandler(HttpMessageNotReadableException::class)
fun handleUnreadableBody(): ResponseEntity<Map<String, String>> =
ResponseEntity.unprocessableEntity().body(mapOf("error" to "Subtitle request rejected"))
@ExceptionHandler(Exception::class)
fun handleUnexpectedException(): ResponseEntity<Map<String, String>> =
ResponseEntity.internalServerError().body(mapOf("error" to "Subtitle request failed"))
}
@@ -0,0 +1,32 @@
package kr.co.vividnext.sodalive.v2.subtitle.adapter.web
import kr.co.vividnext.sodalive.v2.subtitle.application.SubtitleJobService
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/internal/api/subtitle-jobs")
class SubtitleJobController(private val service: SubtitleJobService, private val parser: SubtitleRequestParser) {
@GetMapping("/resolve")
fun resolve(@RequestParam bucket: String, @RequestParam key: String) = service.resolve(bucket, key)
@GetMapping("/{jobId}")
fun get(@PathVariable jobId: String) = service.get(jobId)
@PostMapping("/{jobId}/started", consumes = ["application/json"])
fun started(@PathVariable jobId: String, @RequestBody body: ByteArray): Map<String, String> {
service.started(jobId, parser.started(body))
return emptyMap()
}
@PostMapping("/callback", consumes = ["application/json"])
fun callback(@RequestBody body: ByteArray): Map<String, String> {
service.callback(parser.callback(body), body)
return emptyMap()
}
}
@@ -0,0 +1,72 @@
package kr.co.vividnext.sodalive.v2.subtitle.adapter.web
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.v2.subtitle.dto.SubtitleCallbackRequest
import kr.co.vividnext.sodalive.v2.subtitle.dto.SubtitleResultRequest
import kr.co.vividnext.sodalive.v2.subtitle.dto.SubtitleStartedRequest
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.server.ResponseStatusException
@Component
class SubtitleRequestParser(objectMapper: ObjectMapper) {
private val mapper = objectMapper.copy()
.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION)
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
fun started(body: ByteArray): SubtitleStartedRequest {
val node = parse(body)
fields(node, setOf("transcribeJobName", "status"))
return SubtitleStartedRequest(text(node, "transcribeJobName"), text(node, "status"))
}
fun callback(body: ByteArray): SubtitleCallbackRequest {
val node = parse(body)
fields(
node,
setOf("eventId", "jobId", "contentId", "transcribeJobName", "status", "subtitles", "errorCode", "errorMessage")
)
val contentId = node.path("contentId")
if (!contentId.isIntegralNumber || !contentId.canConvertToLong() || contentId.longValue() <= 0) throw invalid()
val subtitles = node.path("subtitles")
if (!subtitles.isArray || subtitles.size() > 2) throw invalid()
return SubtitleCallbackRequest(
eventId = text(node, "eventId"),
jobId = text(node, "jobId"),
contentId = contentId.longValue(),
transcribeJobName = text(node, "transcribeJobName"),
status = text(node, "status"),
subtitles = subtitles.map {
fields(it, setOf("language", "format", "s3Bucket", "s3Key"))
SubtitleResultRequest(text(it, "language"), text(it, "format"), text(it, "s3Bucket"), text(it, "s3Key"))
},
errorCode = nullableText(node, "errorCode"),
errorMessage = nullableText(node, "errorMessage")
)
}
private fun parse(body: ByteArray): JsonNode = try {
mapper.readTree(body) ?: throw invalid()
} catch (_: JsonProcessingException) {
throw invalid()
}
private fun fields(node: JsonNode, expected: Set<String>) {
if (!node.isObject || node.fieldNames().asSequence().toSet() != expected) throw invalid()
}
private fun text(node: JsonNode, field: String): String {
val value = node.path(field)
if (!value.isTextual || value.textValue().isBlank()) throw invalid()
return value.textValue()
}
private fun nullableText(node: JsonNode, field: String): String? =
if (node.path(field).isNull) null else text(node, field)
private fun invalid() = ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Invalid subtitle request")
}
@@ -0,0 +1,219 @@
package kr.co.vividnext.sodalive.v2.subtitle.application
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.Subtitle
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.SubtitleCallbackReceipt
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.SubtitleCallbackReceiptRepository
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.SubtitleJob
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.SubtitleJobRepository
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.SubtitleRepository
import kr.co.vividnext.sodalive.v2.subtitle.domain.SubtitleJobStatus
import kr.co.vividnext.sodalive.v2.subtitle.dto.ContentSubtitleResponse
import kr.co.vividnext.sodalive.v2.subtitle.dto.SubtitleCallbackRequest
import kr.co.vividnext.sodalive.v2.subtitle.dto.SubtitleJobResponse
import kr.co.vividnext.sodalive.v2.subtitle.dto.SubtitleResultRequest
import kr.co.vividnext.sodalive.v2.subtitle.dto.SubtitleSourceResponse
import kr.co.vividnext.sodalive.v2.subtitle.dto.SubtitleStartedRequest
import org.springframework.beans.factory.annotation.Value
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionTemplate
import org.springframework.web.server.ResponseStatusException
import java.security.MessageDigest
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
import java.util.Locale
import java.util.UUID
@Service
class SubtitleJobService(
private val jobs: SubtitleJobRepository,
private val receipts: SubtitleCallbackReceiptRepository,
private val subtitles: SubtitleRepository,
private val cloudFront: AudioContentCloudFront,
transactionManager: PlatformTransactionManager,
@Value("\${cloud.aws.s3.content-bucket}") private val bucket: String
) {
private val transaction = TransactionTemplate(transactionManager).apply {
propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
isolationLevel = TransactionDefinition.ISOLATION_READ_COMMITTED
}
@Transactional(propagation = Propagation.MANDATORY)
fun register(content: AudioContent, originalFilename: String?): SubtitleJob {
val contentId = requireNotNull(content.id)
val sourceName = UUID.randomUUID().toString()
val extension = originalFilename?.replace('\\', '/')?.substringAfterLast('/')
?.substringAfterLast('.', "")?.takeIf { it.matches(Regex("[A-Za-z0-9]{1,16}")) }
?.lowercase(Locale.ROOT)
val sourceFileName = if (extension == null) sourceName else "$sourceName.$extension"
val job = jobs.save(
SubtitleJob(
jobId = UUID.randomUUID().toString(),
contentId = contentId,
sourceBucket = bucket,
sourceKey = "input/$contentId/$sourceFileName",
outputKey = "output/$contentId/$sourceName"
)
)
content.latestSubtitleJobId = job.jobId
content.content = job.sourceKey
return job
}
@Transactional(readOnly = true)
fun resolve(bucket: String, key: String): SubtitleJobResponse =
response(jobs.findBySourceBucketAndSourceKey(bucket, key) ?: throw missing())
@Transactional(readOnly = true)
fun get(jobId: String): SubtitleJobResponse = response(jobs.findByIdOrNull(jobId) ?: throw missing())
fun started(jobId: String, request: SubtitleStartedRequest) {
if (request.transcribeJobName != "subtitle-$jobId" || request.status != "PROCESSING") throw invalid()
transaction.executeWithoutResult {
val job = jobs.findForUpdate(jobId) ?: throw missing()
if (job.status == SubtitleJobStatus.PENDING) {
job.status = SubtitleJobStatus.PROCESSING
job.updatedAt = now()
}
}
}
fun callback(request: SubtitleCallbackRequest, rawBody: ByteArray) {
if (request.eventId.isBlank() || request.eventId.length > 255) throw invalid()
val hash = MessageDigest.getInstance("SHA-256").digest(rawBody).joinToString("") { "%02x".format(it) }
try {
transaction.executeWithoutResult {
val existing = receipts.findByIdOrNull(request.eventId)
if (existing != null) {
replay(existing, hash)
return@executeWithoutResult
}
val job = jobs.findForUpdate(request.jobId) ?: throw missing()
val afterLock = receipts.findByIdOrNull(request.eventId)
if (afterLock != null) {
replay(afterLock, hash)
return@executeWithoutResult
}
val status = validate(job, request)
val existingResults = subtitles.findAllByJobId(job.jobId).map {
SubtitleResultRequest(it.language, it.format, it.s3Bucket, it.s3Key)
}.sortedBy { it.language }
if (job.status.terminal && (
job.status != status || existingResults != request.subtitles.sortedBy { it.language } ||
job.errorCode != request.errorCode || job.errorMessage != request.errorMessage
)
) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Subtitle job already finished")
}
if (!job.status.terminal) {
subtitles.saveAll(
request.subtitles.map {
Subtitle(job.jobId, it.language, it.format, it.s3Bucket, it.s3Key)
}
)
job.status = status
job.errorCode = request.errorCode
job.errorMessage = request.errorMessage
job.updatedAt = now()
}
receipts.saveAndFlush(SubtitleCallbackReceipt(request.eventId, job.jobId, hash))
}
} catch (exception: DataIntegrityViolationException) {
transaction.executeWithoutResult {
val receipt = receipts.findByIdOrNull(request.eventId) ?: throw exception
replay(receipt, hash)
}
}
}
fun recordUploadError(jobId: String) {
transaction.executeWithoutResult {
val job = jobs.findForUpdate(jobId) ?: throw missing()
val recordedAt = now()
job.sourceUploadErrorCode = "SOURCE_UPLOAD_UNCERTAIN"
job.sourceUploadErrorAt = recordedAt
job.updatedAt = recordedAt
}
}
@Transactional(readOnly = true)
fun matchesOutput(contentId: Long, outputKey: String): Boolean? =
jobs.findFirstByContentIdOrderByCreatedAtAsc(contentId)?.let { job ->
outputKey.substringBeforeLast('.', "") == job.outputKey &&
outputKey.substringAfterLast('.', "") in setOf("aac", "m4a", "mp3")
}
@Transactional(readOnly = true)
fun subtitle(content: AudioContent, lang: Lang, expirationTime: Long): ContentSubtitleResponse? {
val jobId = content.latestSubtitleJobId ?: return null
val job = jobs.findByIdOrNull(jobId) ?: return null
if (job.contentId != content.id || !job.status.terminal || job.status == SubtitleJobStatus.FAILED) return null
val language = when (lang) {
Lang.KO -> "ko-KR"
Lang.JA -> "ja-JP"
else -> return null
}
val subtitle = subtitles.findByJobIdAndLanguage(jobId, language) ?: return null
return ContentSubtitleResponse(
subtitle.language,
subtitle.format,
cloudFront.generateSignedURL(subtitle.s3Key, expirationTime)
)
}
private fun validate(job: SubtitleJob, request: SubtitleCallbackRequest): SubtitleJobStatus {
if (request.contentId != job.contentId || request.transcribeJobName != "subtitle-${job.jobId}") throw invalid()
if (request.subtitles.map { it.language }.distinct().size != request.subtitles.size) throw invalid()
request.subtitles.forEach {
val language = when (it.language) {
"ko-KR" -> "ko"
"ja-JP" -> "ja"
else -> throw invalid()
}
if (it.format != "SRT" || it.s3Bucket != bucket ||
it.s3Key != "subtitles/${job.contentId}/${job.jobId}/$language.srt"
) {
throw invalid()
}
}
val status = when (request.subtitles.size) {
2 -> SubtitleJobStatus.COMPLETED
1 -> SubtitleJobStatus.PARTIAL_COMPLETED
0 -> SubtitleJobStatus.FAILED
else -> throw invalid()
}
if (request.status != status.name) throw invalid()
if (status == SubtitleJobStatus.COMPLETED) {
if (request.errorCode != null || request.errorMessage != null) throw invalid()
} else if (request.errorCode.isNullOrBlank() || request.errorMessage.isNullOrBlank() || request.errorCode.length > 255) {
throw invalid()
}
return status
}
private fun replay(receipt: SubtitleCallbackReceipt, hash: String) {
if (receipt.bodyHash != hash) throw invalid()
}
private fun response(job: SubtitleJob) = SubtitleJobResponse(
job.jobId,
job.contentId,
job.status,
SubtitleSourceResponse(job.sourceBucket, job.sourceKey)
)
private fun now() = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS)
private fun missing() = ResponseStatusException(HttpStatus.NOT_FOUND, "Subtitle job not found")
private fun invalid() = ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Invalid subtitle request")
}
@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.v2.subtitle.domain
enum class SubtitleJobStatus {
PENDING, PROCESSING, COMPLETED, PARTIAL_COMPLETED, FAILED;
val terminal: Boolean
get() = this == COMPLETED || this == PARTIAL_COMPLETED || this == FAILED
}
@@ -0,0 +1,29 @@
package kr.co.vividnext.sodalive.v2.subtitle.dto
import kr.co.vividnext.sodalive.v2.subtitle.domain.SubtitleJobStatus
data class SubtitleSourceResponse(val bucket: String, val key: String, val versionId: String? = null)
data class SubtitleJobResponse(
val jobId: String,
val contentId: Long,
val status: SubtitleJobStatus,
val source: SubtitleSourceResponse
)
data class SubtitleStartedRequest(val transcribeJobName: String, val status: String)
data class SubtitleResultRequest(val language: String, val format: String, val s3Bucket: String, val s3Key: String)
data class SubtitleCallbackRequest(
val eventId: String,
val jobId: String,
val contentId: Long,
val transcribeJobName: String,
val status: String,
val subtitles: List<SubtitleResultRequest>,
val errorCode: String?,
val errorMessage: String?
)
data class ContentSubtitleResponse(val language: String, val format: String, val url: String)
@@ -27,6 +27,7 @@ import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
import kr.co.vividnext.sodalive.support.originalLocalizedNameService import kr.co.vividnext.sodalive.support.originalLocalizedNameService
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService
import kr.co.vividnext.sodalive.v2.subtitle.application.SubtitleJobService
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertNull
@@ -40,6 +41,7 @@ import org.mockito.Mockito
import org.springframework.boot.test.system.CapturedOutput import org.springframework.boot.test.system.CapturedOutput
import org.springframework.boot.test.system.OutputCaptureExtension import org.springframework.boot.test.system.OutputCaptureExtension
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import org.springframework.transaction.PlatformTransactionManager
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.Optional import java.util.Optional
@@ -66,6 +68,7 @@ class AudioContentServiceTest {
private lateinit var contentThemeTranslationRepository: ContentThemeTranslationRepository private lateinit var contentThemeTranslationRepository: ContentThemeTranslationRepository
private lateinit var homeFollowingNewsPublishService: HomeFollowingNewsPublishService private lateinit var homeFollowingNewsPublishService: HomeFollowingNewsPublishService
private lateinit var memberContentPreferenceService: MemberContentPreferenceService private lateinit var memberContentPreferenceService: MemberContentPreferenceService
private lateinit var subtitleJobService: SubtitleJobService
private lateinit var service: AudioContentService private lateinit var service: AudioContentService
@@ -92,6 +95,8 @@ class AudioContentServiceTest {
contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java) contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java)
homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java) homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java) memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
subtitleJobService = Mockito.mock(SubtitleJobService::class.java)
Mockito.`when`(subtitleJobService.matchesOutput(Mockito.anyLong(), Mockito.anyString())).thenReturn(null)
service = AudioContentService( service = AudioContentService(
repository = repository, repository = repository,
@@ -119,6 +124,8 @@ class AudioContentServiceTest {
contentThemeTranslationRepository = contentThemeTranslationRepository, contentThemeTranslationRepository = contentThemeTranslationRepository,
homeFollowingNewsPublishService = homeFollowingNewsPublishService, homeFollowingNewsPublishService = homeFollowingNewsPublishService,
memberContentPreferenceService = memberContentPreferenceService, memberContentPreferenceService = memberContentPreferenceService,
subtitleJobService = subtitleJobService,
transactionManager = Mockito.mock(PlatformTransactionManager::class.java),
audioContentBucket = "audio-bucket", audioContentBucket = "audio-bucket",
coverImageBucket = "cover-bucket", coverImageBucket = "cover-bucket",
coverImageHost = "https://cdn.test" coverImageHost = "https://cdn.test"
@@ -50,7 +50,10 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionTemplate
import java.net.URL import java.net.URL
import java.time.Instant import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -66,6 +69,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
private val chatCharacterService: ChatCharacterService, private val chatCharacterService: ChatCharacterService,
private val audioContentService: AudioContentService, private val audioContentService: AudioContentService,
private val creatorAdminContentService: CreatorAdminContentService, private val creatorAdminContentService: CreatorAdminContentService,
private val transactionTemplate: TransactionTemplate,
private val entityManager: EntityManager private val entityManager: EntityManager
) { ) {
@MockBean @MockBean
@@ -79,6 +83,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
private lateinit var originalAudioContentServicePublisher: ApplicationEventPublisher private lateinit var originalAudioContentServicePublisher: ApplicationEventPublisher
private lateinit var originalCreatorAdminContentServicePublisher: ApplicationEventPublisher private lateinit var originalCreatorAdminContentServicePublisher: ApplicationEventPublisher
private var originalContentIds: Set<Long> = emptySet()
@BeforeEach @BeforeEach
fun replaceActualServicePublishers() { fun replaceActualServicePublishers() {
@@ -88,15 +93,46 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
creatorAdminContentService, creatorAdminContentService,
applicationEventPublisher applicationEventPublisher
) )
originalContentIds = entityManager.createQuery("select c.id from AudioContent c", java.lang.Long::class.java)
.resultList.map { it.toLong() }.toSet()
} }
@AfterEach @AfterEach
fun restoreActualServicePublishers() { fun restoreActualServicePublishers() {
replacePublisher(audioContentService, originalAudioContentServicePublisher) replacePublisher(audioContentService, originalAudioContentServicePublisher)
replacePublisher(creatorAdminContentService, originalCreatorAdminContentServicePublisher) replacePublisher(creatorAdminContentService, originalCreatorAdminContentServicePublisher)
deleteCommittedAudioContents()
}
private fun deleteCommittedAudioContents() {
TransactionTemplate(transactionTemplate.transactionManager!!).apply {
propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
}.executeWithoutResult {
val created = entityManager.createQuery("select c from AudioContent c", AudioContent::class.java)
.resultList.filter { it.id !in originalContentIds }
created.forEach { content ->
content.latestSubtitleJobId = null
entityManager.flush()
val jobIds = entityManager.createQuery(
"select j.jobId from SubtitleJob j where j.contentId = :id",
String::class.java
)
.setParameter("id", content.id).resultList
if (jobIds.isNotEmpty()) {
entityManager.createQuery("delete from Subtitle where jobId in :ids")
.setParameter("ids", jobIds).executeUpdate()
entityManager.createQuery("delete from SubtitleCallbackReceipt where jobId in :ids")
.setParameter("ids", jobIds).executeUpdate()
entityManager.createQuery("delete from SubtitleJob where jobId in :ids")
.setParameter("ids", jobIds).executeUpdate()
}
entityManager.remove(content)
}
}
} }
@Test @Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@DisplayName("생성은 레거시 multipart request로 target 소유 콘텐츠 ID를 반환한다") @DisplayName("생성은 레거시 multipart request로 target 소유 콘텐츠 ID를 반환한다")
fun shouldCreateOwnedContentWithUtcMultipartRequest() { fun shouldCreateOwnedContentWithUtcMultipartRequest() {
val character = chatCharacterService.createChatCharacterWithDetails( val character = chatCharacterService.createChatCharacterWithDetails(
@@ -105,9 +141,12 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
description = "description", description = "description",
systemPrompt = "prompt" systemPrompt = "prompt"
) )
val theme = AudioContentTheme(theme = "create-theme", image = "theme.png") val theme = transactionTemplate.execute {
entityManager.persist(theme) val newTheme = AudioContentTheme(theme = "create-theme", image = "theme.png")
entityManager.flush() entityManager.persist(newTheme)
entityManager.flush()
newTheme
}!!
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
.thenReturn(URL("https://s3.example.com/test")) .thenReturn(URL("https://s3.example.com/test"))
@@ -1006,12 +1045,14 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
} }
private fun registerMysqlDateFunctions() { private fun registerMysqlDateFunctions() {
entityManager.createNativeQuery( transactionTemplate.executeWithoutResult {
"CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" entityManager.createNativeQuery(
).executeUpdate() "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'"
entityManager.createNativeQuery( ).executeUpdate()
"CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" entityManager.createNativeQuery(
).executeUpdate() "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'"
).executeUpdate()
}
} }
private fun adminAuthentication() = authentication( private fun adminAuthentication() = authentication(
@@ -14,6 +14,7 @@ import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals 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.assertNull
import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
@@ -46,6 +47,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multi
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.annotation.Propagation import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionTemplate import org.springframework.transaction.support.TransactionTemplate
@@ -72,15 +74,42 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
private lateinit var applicationEventPublisher: ApplicationEventPublisher private lateinit var applicationEventPublisher: ApplicationEventPublisher
private lateinit var originalAudioContentServicePublisher: ApplicationEventPublisher private lateinit var originalAudioContentServicePublisher: ApplicationEventPublisher
private var originalContentIds: Set<Long> = emptySet()
@BeforeEach @BeforeEach
fun replaceActualServicePublisher() { fun replaceActualServicePublisher() {
originalAudioContentServicePublisher = replaceAudioContentServicePublisher(applicationEventPublisher) originalAudioContentServicePublisher = replaceAudioContentServicePublisher(applicationEventPublisher)
originalContentIds = entityManager.createQuery("select c.id from AudioContent c", java.lang.Long::class.java)
.resultList.map { it.toLong() }.toSet()
} }
@AfterEach @AfterEach
fun restoreActualServicePublisher() { fun restoreActualServicePublisher() {
replaceAudioContentServicePublisher(originalAudioContentServicePublisher) replaceAudioContentServicePublisher(originalAudioContentServicePublisher)
TransactionTemplate(transactionTemplate.transactionManager!!).apply {
propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
}.executeWithoutResult {
val created = entityManager.createQuery("select c from AudioContent c", AudioContent::class.java)
.resultList.filter { it.id !in originalContentIds }
created.forEach { content ->
content.latestSubtitleJobId = null
entityManager.flush()
val jobIds = entityManager.createQuery(
"select j.jobId from SubtitleJob j where j.contentId = :id",
String::class.java
)
.setParameter("id", content.id).resultList
if (jobIds.isNotEmpty()) {
entityManager.createQuery("delete from Subtitle where jobId in :ids")
.setParameter("ids", jobIds).executeUpdate()
entityManager.createQuery("delete from SubtitleCallbackReceipt where jobId in :ids")
.setParameter("ids", jobIds).executeUpdate()
entityManager.createQuery("delete from SubtitleJob where jobId in :ids")
.setParameter("ids", jobIds).executeUpdate()
}
entityManager.remove(content)
}
}
} }
@ParameterizedTest @ParameterizedTest
@@ -319,6 +348,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
} }
@Test @Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@DisplayName("생성은 존재하지 않는 themeId를 업로드 전 invalid request로 거부한다") @DisplayName("생성은 존재하지 않는 themeId를 업로드 전 invalid request로 거부한다")
fun shouldRejectMissingThemeBeforeUpload() { fun shouldRejectMissingThemeBeforeUpload() {
val character = chatCharacterService.createChatCharacterWithDetails( val character = chatCharacterService.createChatCharacterWithDetails(
@@ -360,6 +390,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
], ],
delimiter = '|' delimiter = '|'
) )
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@DisplayName("생성은 잘못된 preview 시간을 업로드 전 기존 오류로 거부한다") @DisplayName("생성은 잘못된 preview 시간을 업로드 전 기존 오류로 거부한다")
fun shouldRejectInvalidPreviewTimeBeforeUpload(previewCase: String, language: String, message: String) { fun shouldRejectInvalidPreviewTimeBeforeUpload(previewCase: String, language: String, message: String) {
val character = chatCharacterService.createChatCharacterWithDetails( val character = chatCharacterService.createChatCharacterWithDetails(
@@ -539,8 +570,8 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
@Test @Test
@Transactional(propagation = Propagation.NOT_SUPPORTED) @Transactional(propagation = Propagation.NOT_SUPPORTED)
@DisplayName("생성은 audio 업로드 실패 시 DB와 이벤트를 남기지 않고 cover 업로드 호출만 남긴다") @DisplayName("audio 업로드 실패는 이미 commit된 등록을 보존하고 업로드 오류를 기록하며 이벤트는 발행하지 않는다")
fun shouldRollBackDatabaseAndEventWhenAudioUploadFails() { fun shouldKeepCommittedRegistrationAndRecordErrorWhenAudioUploadFails() {
val character = chatCharacterService.createChatCharacterWithDetails( val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-audio-failure-character", characterUUID = "v2-audio-create-audio-failure-character",
name = "v2-audio-create-audio-failure-character", name = "v2-audio-create-audio-failure-character",
@@ -568,13 +599,21 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
Mockito.verify(amazonS3Client, Mockito.times(2)).putObject(Mockito.any(PutObjectRequest::class.java)) Mockito.verify(amazonS3Client, Mockito.times(2)).putObject(Mockito.any(PutObjectRequest::class.java))
Mockito.verifyNoInteractions(applicationEventPublisher) Mockito.verifyNoInteractions(applicationEventPublisher)
assertNull(findAudioContentByTitle("audio")) val content = findAudioContentByTitle("audio")!!
assertNotNull(content.latestSubtitleJobId)
assertEquals(
"SOURCE_UPLOAD_UNCERTAIN",
entityManager.createQuery(
"select j.sourceUploadErrorCode from SubtitleJob j where j.jobId = :id",
String::class.java
).setParameter("id", content.latestSubtitleJobId).singleResult
)
} }
@Test @Test
@Transactional(propagation = Propagation.NOT_SUPPORTED) @Transactional(propagation = Propagation.NOT_SUPPORTED)
@DisplayName("생성은 이벤트 발행 실패 시 DB를 롤백하고 두 S3 업로드 호출만 남긴다") @DisplayName("생성 후 이벤트 실패는 이미 commit된 원본/job binding을 롤백하지 않는다")
fun shouldRollBackDatabaseWhenCreateEventPublishFails() { fun shouldKeepCommittedRegistrationWhenCreateEventPublishFails() {
val character = chatCharacterService.createChatCharacterWithDetails( val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-create-event-failure-character", characterUUID = "v2-audio-create-event-failure-character",
name = "v2-audio-create-event-failure-character", name = "v2-audio-create-event-failure-character",
@@ -607,10 +646,11 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
Mockito.verify(amazonS3Client, Mockito.times(2)).putObject(Mockito.any(PutObjectRequest::class.java)) Mockito.verify(amazonS3Client, Mockito.times(2)).putObject(Mockito.any(PutObjectRequest::class.java))
Mockito.verify(applicationEventPublisher).publishEvent(Mockito.any(Any::class.java)) Mockito.verify(applicationEventPublisher).publishEvent(Mockito.any(Any::class.java))
assertNull(findAudioContentByTitle("audio")) assertNotNull(findAudioContentByTitle("audio")!!.latestSubtitleJobId)
} }
@Test @Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@DisplayName("생성은 timezone 없이 UTC releaseDate를 저장하고 data.contentId만 반환한다") @DisplayName("생성은 timezone 없이 UTC releaseDate를 저장하고 data.contentId만 반환한다")
fun shouldCreateWithUtcReleaseDateAndReturnContentId() { fun shouldCreateWithUtcReleaseDateAndReturnContentId() {
val character = chatCharacterService.createChatCharacterWithDetails( val character = chatCharacterService.createChatCharacterWithDetails(
@@ -660,6 +700,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
} }
@ParameterizedTest @ParameterizedTest
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@ValueSource(strings = ["", ",\"releaseDate\":null"]) @ValueSource(strings = ["", ",\"releaseDate\":null"])
@DisplayName("생성은 생략 또는 null releaseDate를 허용한다") @DisplayName("생성은 생략 또는 null releaseDate를 허용한다")
fun shouldAllowOmittedOrNullReleaseDate(releaseDateField: String) { fun shouldAllowOmittedOrNullReleaseDate(releaseDateField: String) {
@@ -693,12 +734,12 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor(
.andExpect(jsonPath("$.data.contentId").isNumber) .andExpect(jsonPath("$.data.contentId").isNumber)
} }
private fun saveTheme(): AudioContentTheme { private fun saveTheme(): AudioContentTheme = transactionTemplate.execute {
val theme = AudioContentTheme(theme = "create-test-theme", image = "theme.png") val theme = AudioContentTheme(theme = "create-test-theme", image = "theme.png")
entityManager.persist(theme) entityManager.persist(theme)
entityManager.flush() entityManager.flush()
return theme theme
} }!!
private fun requestPart( private fun requestPart(
themeId: Long, themeId: Long,
@@ -182,7 +182,8 @@ class AiCharacterAdminAudioContentQueryTest @Autowired constructor(
"nextContent", "nextContent",
"buyerList", "buyerList",
"isAvailableUsePoint", "isAvailableUsePoint",
"translated" "translated",
"subtitle"
), ),
data.fieldNames().asSequence().toSet() data.fieldNames().asSequence().toSet()
) )
@@ -35,7 +35,9 @@ import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.mock.web.MockMultipartFile import org.springframework.mock.web.MockMultipartFile
import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionTemplate
import java.net.URL import java.net.URL
import java.time.LocalDateTime import java.time.LocalDateTime
import javax.persistence.EntityManager import javax.persistence.EntityManager
@@ -48,7 +50,8 @@ class LegacyCreatorAdminAudioContentCharacterizationTest @Autowired constructor(
private val repository: CreatorAdminContentRepository, private val repository: CreatorAdminContentRepository,
private val hashTagRepository: HashTagRepository, private val hashTagRepository: HashTagRepository,
private val contentPriceChangeLogRepository: ContentPriceChangeLogRepository, private val contentPriceChangeLogRepository: ContentPriceChangeLogRepository,
private val entityManager: EntityManager private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) { ) {
private lateinit var audioContentCloudFront: AudioContentCloudFront private lateinit var audioContentCloudFront: AudioContentCloudFront
@@ -81,12 +84,16 @@ class LegacyCreatorAdminAudioContentCharacterizationTest @Autowired constructor(
} }
@Test @Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@DisplayName("기존 콘텐츠 생성은 표지와 오디오를 업로드하고 처리 대기 콘텐츠를 저장한다") @DisplayName("기존 콘텐츠 생성은 표지와 오디오를 업로드하고 처리 대기 콘텐츠를 저장한다")
fun shouldCreateContentThroughLegacyUploadPipeline() { fun shouldCreateContentThroughLegacyUploadPipeline() {
val owner = saveMember("legacy-create-owner") val (owner, theme) = transactionTemplate.execute {
val theme = AudioContentTheme(theme = "legacy-create-theme", image = "theme.png") val owner = saveMember("legacy-create-owner")
entityManager.persist(theme) val theme = AudioContentTheme(theme = "legacy-create-theme", image = "theme.png")
entityManager.flush() entityManager.persist(theme)
entityManager.flush()
owner to theme
}!!
val response = audioContentService.createAudioContent( val response = audioContentService.createAudioContent(
contentFile = MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1, 2, 3)), contentFile = MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1, 2, 3)),
@@ -96,9 +103,7 @@ class LegacyCreatorAdminAudioContentCharacterizationTest @Autowired constructor(
""".trimIndent(), """.trimIndent(),
member = owner member = owner
) )
entityManager.flush() val content = transactionTemplate.execute { entityManager.find(AudioContent::class.java, response.contentId) }!!
val content = entityManager.find(AudioContent::class.java, response.contentId)
assertEquals("legacy created audio", content.title) assertEquals("legacy created audio", content.title)
assertEquals(owner.id, content.member!!.id) assertEquals(owner.id, content.member!!.id)
assertEquals(theme.id, content.theme!!.id) assertEquals(theme.id, content.theme!!.id)
@@ -240,7 +245,7 @@ class LegacyCreatorAdminAudioContentCharacterizationTest @Autowired constructor(
} }
} }
private fun registerMysqlDateFunctions() { private fun registerMysqlDateFunctions() = transactionTemplate.executeWithoutResult {
entityManager.createNativeQuery( entityManager.createNativeQuery(
"CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'"
).executeUpdate() ).executeUpdate()
@@ -0,0 +1,905 @@
package kr.co.vividnext.sodalive.v2.subtitle
import com.amazonaws.services.s3.AmazonS3Client
import com.amazonaws.services.s3.model.PutObjectRequest
import com.amazonaws.services.s3.model.PutObjectResult
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.AudioContentService
import kr.co.vividnext.sodalive.content.CreateAudioContentRequest
import kr.co.vividnext.sodalive.content.order.Order
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
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.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.SubtitleCallbackReceipt
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.SubtitleCallbackReceiptRepository
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.SubtitleJob
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.SubtitleJobRepository
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.SubtitleRepository
import kr.co.vividnext.sodalive.v2.subtitle.adapter.web.SubtitleRequestParser
import kr.co.vividnext.sodalive.v2.subtitle.application.SubtitleJobService
import kr.co.vividnext.sodalive.v2.subtitle.domain.SubtitleJobStatus
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
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.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.boot.test.mock.mockito.SpyBean
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.context.ApplicationEventPublisher
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.MediaType
import org.springframework.mock.web.MockMultipartFile
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.springframework.test.util.AopTestUtils
import org.springframework.test.util.ReflectionTestUtils
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.support.TransactionSynchronizationManager
import org.springframework.transaction.support.TransactionTemplate
import java.net.URL
import java.security.MessageDigest
import java.time.LocalDateTime
import java.util.Base64
import java.util.UUID
import java.util.concurrent.Callable
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.persistence.EntityManager
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class SubtitleHttpIntegrationTest @Autowired constructor(
private val http: TestRestTemplate,
private val mapper: ObjectMapper,
private val members: MemberRepository,
private val passwordEncoder: PasswordEncoder,
private val jobs: SubtitleJobRepository,
private val receipts: SubtitleCallbackReceiptRepository,
private val subtitles: SubtitleRepository,
private val service: SubtitleJobService,
private val contentService: AudioContentService,
private val parser: SubtitleRequestParser,
private val entityManager: EntityManager,
transactionManager: PlatformTransactionManager
) {
companion object {
@JvmStatic
@DynamicPropertySource
fun properties(registry: DynamicPropertyRegistry) {
registry.add("jwt.secret") { Base64.getEncoder().encodeToString(ByteArray(64) { 7 }) }
registry.add("cloud.aws.s3.content-bucket") { "subtitle-audio" }
registry.add("cloud.aws.s3.bucket") { "subtitle-cover" }
registry.add("spring.jpa.properties.hibernate.show_sql") { "false" }
}
}
@MockBean
private lateinit var s3: AmazonS3Client
@MockBean
private lateinit var cloudFront: AudioContentCloudFront
@SpyBean
private lateinit var receiptWriter: SubtitleCallbackReceiptRepository
private val transaction = TransactionTemplate(transactionManager).apply {
propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
}
private lateinit var originalPublisher: ApplicationEventPublisher
private lateinit var publisher: ApplicationEventPublisher
@BeforeEach
fun setupExternalBoundaries() {
val target = AopTestUtils.getTargetObject<AudioContentService>(contentService)
originalPublisher = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher
publisher = Mockito.mock(ApplicationEventPublisher::class.java)
ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher)
Mockito.`when`(s3.getUrl(Mockito.anyString(), Mockito.anyString())).thenReturn(URL("https://example.test/object"))
Mockito.`when`(s3.putObject(Mockito.any(PutObjectRequest::class.java))).thenReturn(PutObjectResult())
Mockito.`when`(cloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
.thenAnswer { "https://signed.example.test/${it.arguments[0]}" }
}
@AfterEach
fun restorePublisher() {
ReflectionTestUtils.setField(
AopTestUtils.getTargetObject<AudioContentService>(contentService),
"applicationEventPublisher",
originalPublisher
)
}
@Test
@DisplayName("실제 EMAIL 로그인 JWT라도 BOT 외 역할은 내부 자막 경로에 접근할 수 없다")
fun shouldRejectActualNonBotLoginJwt() {
listOf(MemberRole.ADMIN, MemberRole.USER, MemberRole.CREATOR).forEach { role ->
val token = login(role)
assertEquals(403, get("/resolve?bucket=audio&key=unknown", token).statusCodeValue)
assertEquals(403, post("/callback", token, "{}").statusCodeValue)
assertEquals(403, get("/${UUID.randomUUID()}", token).statusCodeValue)
assertEquals(403, post("/${UUID.randomUUID()}/started", token, "{}").statusCodeValue)
}
assertEquals(
401,
http.getForEntity("/internal/api/subtitle-jobs/resolve?bucket=a&key=b", String::class.java).statusCodeValue
)
}
@Test
@DisplayName("활성 EMAIL BOT의 실제 JWT는 네 API를 허용하며 조회 응답은 envelope 없는 정확한 계약이다")
fun shouldUseRealBotJwtAndStoreCallbackBeforeStartedWithExactByteReplay() {
val token = login(MemberRole.BOT)
val job = fixture()
val lookup = get("/resolve?bucket=${job.sourceBucket}&key=${job.sourceKey}", token)
assertEquals(200, lookup.statusCodeValue)
val body = mapper.readTree(lookup.body)
assertEquals(setOf("jobId", "contentId", "status", "source"), body.fieldNames().asSequence().toSet())
assertTrue(body.path("contentId").isIntegralNumber)
assertEquals(job.contentId, body.path("contentId").longValue())
assertEquals(setOf("bucket", "key", "versionId"), body.path("source").fieldNames().asSequence().toSet())
assertTrue(body.path("source").path("versionId").isNull)
assertEquals("PENDING", body.path("status").textValue())
assertEquals(lookup.body, get("/${job.jobId}", token).body)
val raw = callback(job, eventId = "non-UUID/event-1")
assertEquals(200, post("/callback", token, raw).statusCodeValue)
assertEquals(200, post("/${job.jobId}/started", token, started(job)).statusCodeValue)
assertEquals(200, post("/callback", token, raw).statusCodeValue)
assertEquals(422, post("/callback", token, "$raw ").statusCodeValue)
assertEquals(422, post("/callback", token, raw.replace(job.jobId, UUID.randomUUID().toString())).statusCodeValue)
val resultIds = subtitles.findAllByJobId(job.jobId).map { it.id }.sortedBy { it }
assertEquals(200, post("/callback", token, raw.replace("non-UUID/event-1", "same-result-new-event")).statusCodeValue)
assertEquals(resultIds, subtitles.findAllByJobId(job.jobId).map { it.id }.sortedBy { it })
assertEquals(409, post("/callback", token, callback(job, "new-result", emptyList())).statusCodeValue)
val stored = jobs.findByIdOrNull(job.jobId)!!
assertEquals(SubtitleJobStatus.COMPLETED, stored.status)
assertEquals(
"subtitles/${job.contentId}/${job.jobId}/ko.srt",
subtitles.findByJobIdAndLanguage(job.jobId, "ko-KR")!!.s3Key
)
assertEquals(
"subtitles/${job.contentId}/${job.jobId}/ja.srt",
subtitles.findByJobIdAndLanguage(job.jobId, "ja-JP")!!.s3Key
)
assertEquals(0, stored.updatedAt.nano)
val receipt = receipts.findByIdOrNull("non-UUID/event-1")!!
assertEquals(
MessageDigest.getInstance("SHA-256").digest(raw.toByteArray()).joinToString("") { "%02x".format(it) },
receipt.bodyHash
)
assertEquals(0, receipt.createdAt.nano)
assertEquals(
job.jobId,
transaction.execute { entityManager.find(AudioContent::class.java, job.contentId).latestSubtitleJobId }
)
}
@Test
@DisplayName("started는 PENDING만 전이하며 partial/failed는 정확한 언어 결과만 저장한다")
fun shouldStartMonotonicallyAndAcceptPartialAndFailedResults() {
val token = login(MemberRole.BOT)
val partial = fixture()
assertEquals(200, post("/${partial.jobId}/started", token, started(partial)).statusCodeValue)
val processing = jobs.findByIdOrNull(partial.jobId)!!
assertEquals(SubtitleJobStatus.PROCESSING, processing.status)
assertEquals(200, post("/${partial.jobId}/started", token, started(partial)).statusCodeValue)
assertEquals(processing.updatedAt, jobs.findByIdOrNull(partial.jobId)!!.updatedAt)
assertEquals(200, post("/callback", token, callback(partial, languages = listOf("ja-JP"))).statusCodeValue)
val storedPartial = jobs.findByIdOrNull(partial.jobId)!!
assertEquals(SubtitleJobStatus.PARTIAL_COMPLETED, storedPartial.status)
assertNull(subtitles.findByJobIdAndLanguage(partial.jobId, "ko-KR"))
assertNotNull(subtitles.findByJobIdAndLanguage(partial.jobId, "ja-JP"))
val failed = fixture()
assertEquals(200, post("/callback", token, callback(failed, languages = emptyList())).statusCodeValue)
assertEquals(SubtitleJobStatus.FAILED, jobs.findByIdOrNull(failed.jobId)!!.status)
assertEquals(200, post("/${failed.jobId}/started", token, started(failed)).statusCodeValue)
assertEquals(SubtitleJobStatus.FAILED, jobs.findByIdOrNull(failed.jobId)!!.status)
}
@Test
@DisplayName("unknown source/job은 생성 없이 404이며 DB role 변경 및 비활성화는 기존 JWT도 차단한다")
fun shouldReturn404WithoutDiscoveryAndCheckCurrentDatabaseRole() {
val token = login(MemberRole.BOT)
val count = jobs.count()
val unknown = UUID.randomUUID().toString()
assertEquals(404, get("/resolve?bucket=wrong&key=unknown", token).statusCodeValue)
assertEquals(404, get("/$unknown", token).statusCodeValue)
val unknownStarted = """{"transcribeJobName":"subtitle-$unknown","status":"PROCESSING"}"""
assertEquals(
404,
post("/$unknown/started", token, unknownStarted).statusCodeValue
)
val job = fixture()
assertEquals(404, post("/callback", token, callback(job).replace(job.jobId, unknown)).statusCodeValue)
assertEquals(count + 1, jobs.count())
val member = members.findAll().single { it.email == lastLoginEmail }
member.role = MemberRole.ADMIN
members.saveAndFlush(member)
assertEquals(403, get("/${job.jobId}", token).statusCodeValue)
member.role = MemberRole.BOT
member.isActive = false
members.saveAndFlush(member)
assertEquals(403, post("/callback", token, callback(job)).statusCodeValue)
}
@Test
@DisplayName("callback JSON은 숫자 coercion, 누락 null, 추가/중복 필드와 잘못된 binding을 원자적으로 거부한다")
fun shouldRejectMalformedContractsWithoutPersistingAnything() {
val token = login(MemberRole.BOT)
val job = fixture()
val valid = callback(job, "strict-event")
val invalidBodies = listOf(
valid.replace("\"contentId\":${job.contentId}", "\"contentId\":\"${job.contentId}\""),
valid.replace("\"contentId\":${job.contentId}", "\"contentId\":${job.contentId}.0"),
valid.replace("\"contentId\":${job.contentId}", "\"contentId\":9223372036854775808"),
valid.replace(",\"errorMessage\":null", ""),
valid.dropLast(1) + ",\"unknown\":null}",
valid.dropLast(1) + ",\"status\":\"COMPLETED\"}",
valid.replace("\"language\":\"ko-KR\"", "\"language\":\"ko-KR\",\"language\":\"ko-KR\""),
valid.replace("\"format\":\"SRT\"", "\"format\":\"SRT\",\"extra\":true"),
valid.replace("subtitle-${job.jobId}", "wrong-name"),
valid.replace("\"contentId\":${job.contentId}", "\"contentId\":${job.contentId + 1}"),
valid.replace(job.sourceBucket, "other-bucket"),
valid.replace("/${job.jobId}/ko.srt", "/${job.jobId}/../ko.srt"),
valid.replace("ja-JP", "ko-KR"),
valid.replace("\"SRT\"", "\"VTT\""),
valid.replace("ko-KR", "en-US"),
valid.replace("\"COMPLETED\"", "\"PARTIAL_COMPLETED\""),
valid.replace("\"errorCode\":null", "\"errorCode\":\"unexpected\""),
valid.replace("strict-event", ""),
"$valid {}",
callback(job, languages = emptyList()).replace("\"errorMessage\":\"pipeline failed\"", "\"errorMessage\":null")
)
invalidBodies.forEachIndexed { index, raw ->
assertEquals(422, post("/callback", token, raw).statusCodeValue, "invalid case $index")
}
listOf(
started(job).replace("PROCESSING", "COMPLETED"),
started(job).replace("subtitle-${job.jobId}", "wrong"),
started(job).dropLast(1) + ",\"extra\":true}",
started(job).dropLast(1) + ",\"status\":\"PROCESSING\"}"
).forEach { assertEquals(422, post("/${job.jobId}/started", token, it).statusCodeValue) }
val stored = jobs.findByIdOrNull(job.jobId)!!
assertEquals(SubtitleJobStatus.PENDING, stored.status)
assertTrue(subtitles.findAllByJobId(job.jobId).isEmpty())
assertNull(receipts.findByIdOrNull("strict-event"))
}
@Test
@DisplayName("빈 callback과 started body는 422이며 job, receipt, 결과를 변경하지 않는다")
fun shouldRejectEmptyCallbackAndStartedBodiesWithoutPersistingAnything() {
val token = login(MemberRole.BOT)
val job = fixture()
val counts = listOf(jobs.count(), receipts.count(), subtitles.count())
val responses = listOf("/callback", "/${job.jobId}/started").map { post(it, token, "") }
assertEquals(listOf(422, 422), responses.map { it.statusCodeValue })
assertEquals(counts, listOf(jobs.count(), receipts.count(), subtitles.count()))
val stored = jobs.findByIdOrNull(job.jobId)!!
assertEquals(SubtitleJobStatus.PENDING, stored.status)
assertEquals(job.updatedAt, stored.updatedAt)
assertNull(stored.errorCode)
assertNull(stored.errorMessage)
assertTrue(subtitles.findAllByJobId(job.jobId).isEmpty())
}
@Test
@DisplayName("HTTP callback의 receipt 쓰기 실패는 일반화된 500이며 상태, 오류, 결과와 receipt를 rollback한다")
fun shouldReturn500AndRollbackWhenReceiptInsertFailsOverHttp() {
val token = login(MemberRole.BOT)
val job = fixture()
Mockito.doThrow(DataIntegrityViolationException("receipt write failed"))
.`when`(receiptWriter).saveAndFlush(
Mockito.any(SubtitleCallbackReceipt::class.java) ?: SubtitleCallbackReceipt("unused", "unused", "unused")
)
val response = post("/callback", token, callback(job, "http-receipt-write-failure", listOf("ko-KR")))
assertEquals(500, response.statusCodeValue)
assertEquals(mapper.readTree("""{"error":"Subtitle request failed"}"""), mapper.readTree(response.body))
val stored = jobs.findByIdOrNull(job.jobId)!!
assertEquals(SubtitleJobStatus.PENDING, stored.status)
assertEquals(job.updatedAt, stored.updatedAt)
assertNull(stored.errorCode)
assertNull(stored.errorMessage)
assertTrue(subtitles.findAllByJobId(job.jobId).isEmpty())
assertNull(receipts.findByIdOrNull("http-receipt-write-failure"))
}
@Test
@DisplayName("동시 callback은 같은 event를 중복 반영하지 않으며 다른 job 간 unique 경합은 rollback 후 판정한다")
fun shouldSerializeConcurrentCallbacksAndRollbackLosingJob() {
val token = login(MemberRole.BOT)
val first = fixture()
val sameBody = callback(first, "concurrent-same")
assertEquals(
listOf(200, 200),
race({ post("/callback", token, sameBody).statusCodeValue }, { post("/callback", token, sameBody).statusCodeValue })
)
val left = fixture()
val right = fixture()
val statuses = race(
{ post("/callback", token, callback(left, "concurrent-cross-job")).statusCodeValue },
{ post("/callback", token, callback(right, "concurrent-cross-job")).statusCodeValue }
)
assertEquals(listOf(200, 422), statuses)
val winner = receipts.findByIdOrNull("concurrent-cross-job")!!.jobId
val loser = if (left.jobId == winner) right else left
assertEquals(SubtitleJobStatus.PENDING, jobs.findByIdOrNull(loser.jobId)!!.status)
assertTrue(subtitles.findAllByJobId(loser.jobId).isEmpty())
}
@Test
@DisplayName("receipt 쓰기 실패 시 이미 flush된 결과 행도 상태와 함께 rollback된다")
fun shouldRollbackResultsAndStatusWhenReceiptInsertFails() {
val job = fixture()
val raw = callback(job, "receipt-write-failure").toByteArray()
Mockito.doThrow(DataIntegrityViolationException("receipt write failed"))
.`when`(receiptWriter).saveAndFlush(
Mockito.any(SubtitleCallbackReceipt::class.java) ?: SubtitleCallbackReceipt("unused", "unused", "unused")
)
assertThrows(DataIntegrityViolationException::class.java) {
service.callback(parser.callback(raw), raw)
}
assertEquals(SubtitleJobStatus.PENDING, jobs.findByIdOrNull(job.jobId)!!.status)
assertTrue(subtitles.findAllByJobId(job.jobId).isEmpty())
assertNull(receipts.findByIdOrNull("receipt-write-failure"))
}
@Test
@DisplayName("이전 job의 결과는 저장하지만 content의 최신 job 선택값을 변경하지 않는다")
fun shouldKeepLatestPointerWhenOlderJobCompletes() {
val token = login(MemberRole.BOT)
val older = fixture()
val latest = transaction.execute {
service.register(entityManager.find(AudioContent::class.java, older.contentId), "later.mp3")
}!!
assertEquals(200, post("/callback", token, callback(older)).statusCodeValue)
assertEquals(
latest.jobId,
transaction.execute { entityManager.find(AudioContent::class.java, older.contentId).latestSubtitleJobId }
)
assertEquals(SubtitleJobStatus.PENDING, jobs.findByIdOrNull(latest.jobId)!!.status)
assertEquals(SubtitleJobStatus.COMPLETED, jobs.findByIdOrNull(older.jobId)!!.status)
}
@Test
@DisplayName("외부 transaction이 있어도 S3 원본 업로드 전에 content/source/PENDING이 별도 HTTP에서 보인다")
fun shouldCommitRegistrationBeforeUploadAndNeverRevertEarlyMediaConvertOutput() {
val token = login(MemberRole.BOT)
val owner = members.saveAndFlush(Member(password = "unused", nickname = "upload-owner-${UUID.randomUUID()}"))
val themeId = transaction.execute {
AudioContentTheme(theme = "upload theme", image = "theme.png").also { entityManager.persist(it) }.id!!
}!!
var uploadedJob: SubtitleJob? = null
Mockito.`when`(s3.putObject(Mockito.any(PutObjectRequest::class.java))).thenAnswer { invocation ->
val request = invocation.arguments[0] as PutObjectRequest
if (request.key.startsWith("input/")) {
assertFalse(TransactionSynchronizationManager.isActualTransactionActive())
val response = get("/resolve?bucket=${request.bucketName}&key=${request.key}", token)
assertEquals(200, response.statusCodeValue)
assertEquals("PENDING", mapper.readTree(response.body).path("status").textValue())
val jobId = mapper.readTree(response.body).path("jobId").textValue()
uploadedJob = jobs.findByIdOrNull(jobId)!!
val sourceFileName = request.key.substringAfterLast('/')
assertTrue(sourceFileName.endsWith(".mp3"))
assertEquals(
UUID.fromString(sourceFileName.substringBeforeLast('.')).toString(),
sourceFileName.substringBeforeLast('.')
)
val content = transaction.execute { entityManager.find(AudioContent::class.java, uploadedJob!!.contentId) }!!
assertEquals(request.key, content.content)
assertNotNull(content.coverImage)
assertEquals(jobId, content.latestSubtitleJobId)
assertEquals(
1,
transaction.execute { entityManager.find(AudioContent::class.java, content.id).audioContentHashTags.size }
)
contentService.uploadComplete(content.id!!, "${uploadedJob!!.outputKey}.aac", "00:03:00")
}
PutObjectResult()
}
val request = CreateAudioContentRequest(
title = "new uploaded",
detail = "detail",
tags = "#tag",
price = 100,
themeId = themeId,
languageCode = "ko"
)
transaction.executeWithoutResult {
contentService.createAudioContent(
MockMultipartFile("contentFile", "C:\\unsafe\\nested/audio.MP3", "audio/mpeg", byteArrayOf(1)),
MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(2)),
request,
LocalDateTime.now().minusMinutes(1),
owner
)
}
val job = uploadedJob!!
val stored = transaction.execute { entityManager.find(AudioContent::class.java, job.contentId) }!!
assertEquals("${job.outputKey}.aac", stored.content)
assertEquals("00:03:00", stored.duration)
assertTrue(stored.isActive)
assertTrue(service.matchesOutput(job.contentId, "${job.outputKey}.aac")!!)
assertTrue(service.matchesOutput(job.contentId, "${job.outputKey}.m4a")!!)
assertTrue(service.matchesOutput(job.contentId, "${job.outputKey}.mp3")!!)
listOf(
"${job.outputKey}-other.aac",
"${job.outputKey}.txt",
"output/999/${job.outputKey.substringAfterLast('/')}.aac",
"${job.outputKey}.aac/extra"
).forEach {
assertFalse(service.matchesOutput(job.contentId, it)!!)
}
}
@Test
@DisplayName("putObject 수락 후 getUrl 실패도 원본 오류만 기록하고 조기 완료 callback을 terminal에서 회귀시키지 않는다")
fun shouldKeepAcceptedSourceAndTerminalResultWhenUploadReturnFails() {
val token = login(MemberRole.BOT)
val owner = members.saveAndFlush(Member(password = "unused", nickname = "uncertain-${UUID.randomUUID()}"))
val themeId = transaction.execute {
AudioContentTheme(theme = "uncertain theme", image = "theme.png").also { entityManager.persist(it) }.id!!
}!!
var uploadedJob: SubtitleJob? = null
Mockito.`when`(s3.putObject(Mockito.any(PutObjectRequest::class.java))).thenAnswer { invocation ->
val request = invocation.arguments[0] as PutObjectRequest
if (request.key.startsWith("input/")) {
uploadedJob = jobs.findBySourceBucketAndSourceKey(request.bucketName, request.key)!!
assertEquals(200, post("/callback", token, callback(uploadedJob!!)).statusCodeValue)
}
PutObjectResult()
}
Mockito.`when`(s3.getUrl(Mockito.anyString(), Mockito.anyString())).thenAnswer { invocation ->
if ((invocation.arguments[1] as String).startsWith("input/")) throw IllegalStateException("upload return failed")
URL("https://example.test/cover")
}
assertThrows(IllegalStateException::class.java) {
contentService.createAudioContent(
MockMultipartFile("contentFile", byteArrayOf(1)),
MockMultipartFile("coverImage", byteArrayOf(2)),
CreateAudioContentRequest(title = "uncertain", detail = "detail", tags = "", price = 0, themeId = themeId),
LocalDateTime.now(),
owner
)
}
val job = jobs.findByIdOrNull(uploadedJob!!.jobId)!!
assertEquals(SubtitleJobStatus.COMPLETED, job.status)
assertEquals("SOURCE_UPLOAD_UNCERTAIN", job.sourceUploadErrorCode)
assertEquals(0, job.sourceUploadErrorAt!!.nano)
assertEquals(2, subtitles.findAllByJobId(job.jobId).size)
assertNotNull(transaction.execute { entityManager.find(AudioContent::class.java, job.contentId) })
}
@Test
@DisplayName("원본 filename의 안전한 확장자만 소문자로 보존하고 canonical UUID 이외 경로는 source로 전달하지 않는다")
fun shouldPreserveOnlySafeActualSourceExtensionsWithoutClaimingConversion() {
val filenames = listOf(
"C:\\client\\dir/file.MP3" to "mp3",
"../../voice.WAV" to "wav",
"voice.FLAC" to "flac",
"voice.aac" to "aac",
"voice.opus" to "opus",
"voice" to null,
"voice.bad-extension" to null,
null to null
)
filenames.forEach { (filename, extension) ->
val job = fixture(filename)
val sourceFile = job.sourceKey.substringAfterLast('/')
val uuid = if (extension == null) sourceFile else sourceFile.removeSuffix(".$extension")
assertEquals(UUID.fromString(uuid).toString(), uuid)
if (extension != null) assertTrue(sourceFile.endsWith(".$extension"))
assertEquals("input/${job.contentId}/$sourceFile", job.sourceKey)
assertEquals("output/${job.contentId}/$uuid", job.outputKey)
}
}
@Test
@DisplayName("콘텐츠 상세는 preview에 자막을 숨기고 소유자/구매자/무료 원본 권한과 공개 시점 및 ko/ja를 적용한다")
fun shouldExposeLanguageSubtitleOnlyWithReleasedOriginalAccessInExistingDetail() {
val bot = login(MemberRole.BOT)
val ownerToken = login(MemberRole.CREATOR)
val owner = members.findByEmail(lastLoginEmail)!!
val viewerToken = login(MemberRole.USER)
val viewer = members.findByEmail(lastLoginEmail)!!
val job = fixture(owner = owner)
transaction.executeWithoutResult {
entityManager.find(AudioContent::class.java, job.contentId).apply {
price = 100
content = "${job.outputKey}.aac"
duration = "00:03:00"
coverImage = "cover/test.png"
isActive = true
isCommentAvailable = false
releaseDate = LocalDateTime.now().minusMinutes(1)
}
}
assertEquals(200, post("/callback", bot, callback(job)).statusCodeValue)
fun detail(token: String, language: String) = http.exchange(
"/audio-content/${job.contentId}?timezone=UTC",
HttpMethod.GET,
HttpEntity<Void>(headers(token).apply { set(HttpHeaders.ACCEPT_LANGUAGE, language) }),
String::class.java
).let {
assertEquals(200, it.statusCodeValue)
val response = mapper.readTree(it.body)
assertTrue(response.path("success").booleanValue(), response.path("message").asText())
response.path("data")
}
assertTrue(detail(viewerToken, "ko").path("subtitle").isNull)
val ownerKo = detail(ownerToken, "ko").path("subtitle")
assertEquals(setOf("language", "format", "url"), ownerKo.fieldNames().asSequence().toSet())
assertEquals("ko-KR", ownerKo.path("language").textValue())
assertEquals("SRT", ownerKo.path("format").textValue())
assertEquals(
"https://signed.example.test/subtitles/${job.contentId}/${job.jobId}/ko.srt",
ownerKo.path("url").textValue()
)
assertEquals("ja-JP", detail(ownerToken, "ja").path("subtitle").path("language").textValue())
assertTrue(detail(ownerToken, "en").path("subtitle").isNull)
transaction.executeWithoutResult {
val content = entityManager.find(AudioContent::class.java, job.contentId)
entityManager.persist(
Order(type = OrderType.KEEP).apply {
member = viewer
creator = owner
audioContent = content
}
)
}
assertEquals("ko-KR", detail(viewerToken, "ko").path("subtitle").path("language").textValue())
transaction.executeWithoutResult {
entityManager.find(AudioContent::class.java, job.contentId).releaseDate = LocalDateTime.now().plusDays(1)
}
assertTrue(detail(viewerToken, "ko").path("subtitle").isNull)
assertEquals("ko-KR", detail(ownerToken, "ko").path("subtitle").path("language").textValue())
transaction.executeWithoutResult {
entityManager.find(AudioContent::class.java, job.contentId).apply {
price = 0
releaseDate = LocalDateTime.now().minusDays(1)
}
}
val freeViewer = login(MemberRole.USER)
assertEquals("ja-JP", detail(freeViewer, "ja").path("subtitle").path("language").textValue())
}
@Test
@DisplayName("자막 조회는 최신 job의 실제 언어 행만 선택하고 미완료/없는 언어/content 불일치는 null이다")
fun shouldReadOnlyPersistedLanguageOfMatchingLatestCompletedJob() {
val token = login(MemberRole.BOT)
val job = fixture()
val content = transaction.execute { entityManager.find(AudioContent::class.java, job.contentId) }!!
assertNull(service.subtitle(content, kr.co.vividnext.sodalive.i18n.Lang.KO, 1000))
assertEquals(200, post("/callback", token, callback(job, languages = listOf("ja-JP"))).statusCodeValue)
assertNull(service.subtitle(content, kr.co.vividnext.sodalive.i18n.Lang.KO, 1000))
assertEquals("ja-JP", service.subtitle(content, kr.co.vividnext.sodalive.i18n.Lang.JA, 1000)!!.language)
val different = fixture()
content.latestSubtitleJobId = different.jobId
assertNull(service.subtitle(content, kr.co.vividnext.sodalive.i18n.Lang.JA, 1000))
content.latestSubtitleJobId = null
assertNull(service.subtitle(content, kr.co.vividnext.sodalive.i18n.Lang.JA, 1000))
}
@Test
@DisplayName("구매자의 generate-url은 ko/ja 자막과 기존 음원 URL 및 상세와 같은 자막 스키마를 반환한다")
fun shouldIncludeRequestedSubtitleInGenerateUrl() {
val bot = login(MemberRole.BOT)
val owner = members.saveAndFlush(Member(password = "unused", nickname = "generate-owner-${UUID.randomUUID()}"))
val token = login(MemberRole.USER)
val buyer = members.findByEmail(lastLoginEmail)!!
val job = fixture(owner = owner)
transaction.executeWithoutResult {
val content = entityManager.find(AudioContent::class.java, job.contentId).apply {
price = 100
this.content = "${job.outputKey}.aac"
duration = "02:03:00"
coverImage = "cover/test.png"
isActive = true
isCommentAvailable = false
releaseDate = LocalDateTime.now().minusDays(1)
}
entityManager.persist(
Order(type = OrderType.KEEP).apply {
member = buyer
creator = owner
audioContent = content
}
)
}
assertEquals(200, post("/callback", bot, callback(job)).statusCodeValue)
Mockito.`when`(cloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
.thenAnswer { "https://signed.example.test/${it.arguments[0]}?expiresIn=${it.arguments[1]}" }
listOf("ko" to "ko-KR", "ja" to "ja-JP").forEach { (language, expectedLanguage) ->
val data = generateUrl(job.contentId, token, language)
assertTrue(data.has("subtitle"))
assertEquals(setOf("contentUrl", "subtitle"), data.fieldNames().asSequence().toSet())
assertEquals(
"https://signed.example.test/${job.outputKey}.aac?expiresIn=14400000",
data.path("contentUrl").textValue()
)
val subtitle = data.path("subtitle")
assertEquals(setOf("language", "format", "url"), subtitle.fieldNames().asSequence().toSet())
assertEquals(expectedLanguage, subtitle.path("language").textValue())
assertEquals("SRT", subtitle.path("format").textValue())
assertEquals(
"https://signed.example.test/subtitles/${job.contentId}/${job.jobId}/$language.srt?expiresIn=14400000",
subtitle.path("url").textValue()
)
val detail = http.exchange(
"/audio-content/${job.contentId}?timezone=UTC",
HttpMethod.GET,
HttpEntity<Void>(headers(token).apply { set(HttpHeaders.ACCEPT_LANGUAGE, language) }),
String::class.java
)
assertEquals(200, detail.statusCodeValue)
val detailBody = mapper.readTree(detail.body)
assertTrue(detailBody.path("success").booleanValue())
assertEquals(detailBody.path("data").path("subtitle"), subtitle)
}
}
@Test
@DisplayName("generate-url은 pending/미등록/없는 언어/미지원 언어의 자막을 명시적 null로 반환하고 음원 URL은 유지한다")
fun shouldReturnNullSubtitleInGenerateUrlWhenUnavailable() {
val bot = login(MemberRole.BOT)
val owner = members.saveAndFlush(Member(password = "unused", nickname = "unavailable-owner-${UUID.randomUUID()}"))
val token = login(MemberRole.USER)
val buyer = members.findByEmail(lastLoginEmail)!!
val job = fixture(owner = owner)
transaction.executeWithoutResult {
val content = entityManager.find(AudioContent::class.java, job.contentId).apply {
this.content = "${job.outputKey}.aac"
duration = "00:03:00"
releaseDate = LocalDateTime.now().minusDays(1)
}
entityManager.persist(
Order(type = OrderType.KEEP).apply {
member = buyer
creator = owner
audioContent = content
}
)
}
val pending = generateUrl(job.contentId, token, "ko")
assertTrue(pending.has("subtitle"))
assertTrue(pending.path("subtitle").isNull)
assertEquals("https://signed.example.test/${job.outputKey}.aac", pending.path("contentUrl").textValue())
transaction.executeWithoutResult {
entityManager.find(AudioContent::class.java, job.contentId).latestSubtitleJobId = null
}
val missing = generateUrl(job.contentId, token, "ko")
assertTrue(missing.has("subtitle"))
assertTrue(missing.path("subtitle").isNull)
assertEquals(pending.path("contentUrl"), missing.path("contentUrl"))
transaction.executeWithoutResult {
entityManager.find(AudioContent::class.java, job.contentId).latestSubtitleJobId = job.jobId
}
assertEquals(200, post("/callback", bot, callback(job, languages = listOf("ja-JP"))).statusCodeValue)
listOf("ko", "en").forEach { language ->
val unavailable = generateUrl(job.contentId, token, language)
assertTrue(unavailable.has("subtitle"))
assertTrue(unavailable.path("subtitle").isNull)
assertEquals(pending.path("contentUrl"), unavailable.path("contentUrl"))
}
val available = generateUrl(job.contentId, token, "ja")
assertEquals("ja-JP", available.path("subtitle").path("language").textValue())
assertEquals("SRT", available.path("subtitle").path("format").textValue())
assertEquals(
"https://signed.example.test/subtitles/${job.contentId}/${job.jobId}/ja.srt",
available.path("subtitle").path("url").textValue()
)
assertEquals(pending.path("contentUrl"), available.path("contentUrl"))
}
@Test
@DisplayName("generate-url은 소유자/무료도 활성 구매가 필요하며 예약 공개는 자막에만 상세의 공개 제한을 적용한다")
fun shouldPreserveGenerateUrlPurchaseAndReleaseRestrictions() {
val bot = login(MemberRole.BOT)
val ownerToken = login(MemberRole.CREATOR)
val owner = members.findByEmail(lastLoginEmail)!!
val viewerToken = login(MemberRole.USER)
val viewer = members.findByEmail(lastLoginEmail)!!
val job = fixture(owner = owner)
transaction.executeWithoutResult {
entityManager.find(AudioContent::class.java, job.contentId).apply {
price = 100
content = "${job.outputKey}.aac"
duration = "00:03:00"
releaseDate = LocalDateTime.now().minusDays(1)
}
}
assertEquals(200, post("/callback", bot, callback(job)).statusCodeValue)
listOf(viewerToken, ownerToken).forEach { token ->
val denied = generateUrl(job.contentId, token, "ko")
assertTrue(denied.has("subtitle"))
assertTrue(denied.path("subtitle").isNull)
assertEquals("", denied.path("contentUrl").textValue())
}
transaction.executeWithoutResult { entityManager.find(AudioContent::class.java, job.contentId).price = 0 }
listOf(viewerToken, ownerToken).forEach { token ->
val free = generateUrl(job.contentId, token, "ko")
assertTrue(free.has("subtitle"))
assertTrue(free.path("subtitle").isNull)
assertEquals("", free.path("contentUrl").textValue())
}
val expiredOrderId = transaction.execute {
val content = entityManager.find(AudioContent::class.java, job.contentId)
content.price = 100
entityManager.persist(
Order(type = OrderType.KEEP, isActive = false).apply {
member = viewer
creator = owner
audioContent = content
}
)
Order(type = OrderType.RENTAL).apply {
member = viewer
creator = owner
audioContent = content
entityManager.persist(this)
entityManager.flush()
endDate = LocalDateTime.now().minusDays(1)
}.id!!
}!!
val expired = generateUrl(job.contentId, viewerToken, "ko")
assertTrue(expired.has("subtitle"))
assertTrue(expired.path("subtitle").isNull)
assertEquals("", expired.path("contentUrl").textValue())
transaction.executeWithoutResult {
entityManager.find(Order::class.java, expiredOrderId).endDate = LocalDateTime.now().plusDays(1)
val content = entityManager.find(AudioContent::class.java, job.contentId)
content.releaseDate = LocalDateTime.now().plusDays(1)
entityManager.persist(
Order(type = OrderType.KEEP).apply {
member = owner
creator = owner
audioContent = content
}
)
}
val scheduled = generateUrl(job.contentId, viewerToken, "ko")
assertTrue(scheduled.has("subtitle"))
assertTrue(scheduled.path("subtitle").isNull)
assertEquals("https://signed.example.test/${job.outputKey}.aac", scheduled.path("contentUrl").textValue())
val purchasedOwner = generateUrl(job.contentId, ownerToken, "ko")
assertEquals(scheduled.path("contentUrl"), purchasedOwner.path("contentUrl"))
assertEquals("ko-KR", purchasedOwner.path("subtitle").path("language").textValue())
transaction.executeWithoutResult { entityManager.find(AudioContent::class.java, job.contentId).releaseDate = null }
assertEquals("ko-KR", generateUrl(job.contentId, viewerToken, "ko").path("subtitle").path("language").textValue())
transaction.executeWithoutResult {
entityManager.find(AudioContent::class.java, job.contentId).releaseDate = LocalDateTime.now().minusDays(1)
}
val released = generateUrl(job.contentId, viewerToken, "ko")
assertEquals(scheduled.path("contentUrl"), released.path("contentUrl"))
assertEquals("ko-KR", released.path("subtitle").path("language").textValue())
assertEquals(
401,
http.getForEntity("/audio-content/${job.contentId}/generate-url", String::class.java).statusCodeValue
)
}
private fun generateUrl(contentId: Long, token: String, language: String) = http.exchange(
"/audio-content/$contentId/generate-url",
HttpMethod.GET,
HttpEntity<Void>(headers(token).apply { set(HttpHeaders.ACCEPT_LANGUAGE, language) }),
String::class.java
).let {
assertEquals(200, it.statusCodeValue)
val body = mapper.readTree(it.body)
assertTrue(body.path("success").booleanValue())
body.path("data")
}
private var lastLoginEmail: String? = null
private fun login(role: MemberRole): String {
val email = "subtitle-${UUID.randomUUID()}@example.test"
lastLoginEmail = email
members.saveAndFlush(
Member(email = email, password = passwordEncoder.encode("test-password"), nickname = email, role = role)
)
val response = http.postForEntity(
"/member/login",
HttpEntity(
"""{"email":"$email","password":"test-password","isAdmin":false,"isCreator":false}""",
HttpHeaders().apply { contentType = MediaType.APPLICATION_JSON }
),
String::class.java
)
assertEquals(200, response.statusCodeValue)
val body = mapper.readTree(response.body)
assertTrue(body.path("success").booleanValue())
assertFalse(body.path("data").path("isNew").booleanValue())
return body.path("data").path("token").textValue().also { assertTrue(it.isNotBlank()) }
}
private fun headers(token: String) = HttpHeaders().apply {
setBearerAuth(token)
contentType = MediaType.APPLICATION_JSON
}
private fun get(path: String, token: String) = http.exchange(
"/internal/api/subtitle-jobs$path",
HttpMethod.GET,
HttpEntity<Void>(headers(token)),
String::class.java
)
private fun post(path: String, token: String, raw: String) = http.exchange(
"/internal/api/subtitle-jobs$path",
HttpMethod.POST,
HttpEntity(raw.toByteArray(), headers(token)),
String::class.java
)
private fun started(job: SubtitleJob) = """{"transcribeJobName":"subtitle-${job.jobId}","status":"PROCESSING"}"""
private fun callback(
job: SubtitleJob,
eventId: String = UUID.randomUUID().toString(),
languages: List<String> = listOf(
"ko-KR",
"ja-JP"
)
): String {
val subtitles = languages.joinToString(",") {
val short = if (it == "ko-KR") "ko" else "ja"
"""{"language":"$it","format":"SRT","s3Bucket":"${job.sourceBucket}",""" +
""""s3Key":"subtitles/${job.contentId}/${job.jobId}/$short.srt"}"""
}
val status = when (languages.size) { 2 -> "COMPLETED"; 1 -> "PARTIAL_COMPLETED"; else -> "FAILED" }
val error = if (languages.size == 2) "null" else "\"PIPELINE_FAILED\""
val message = if (languages.size == 2) "null" else "\"pipeline failed\""
return """{"eventId":"$eventId","jobId":"${job.jobId}","contentId":${job.contentId},""" +
""""transcribeJobName":"subtitle-${job.jobId}","status":"$status","subtitles":[$subtitles],""" +
""""errorCode":$error,"errorMessage":$message}"""
}
private fun fixture(originalFilename: String? = "fixture.mp3", owner: Member? = null): SubtitleJob = transaction.execute {
val creator = owner ?: Member(password = "unused", nickname = "fixture-${UUID.randomUUID()}").also {
entityManager.persist(
it
)
}
val theme = AudioContentTheme(theme = "subtitle theme", image = "theme.png")
entityManager.persist(theme)
val content = AudioContent(title = "subtitle content", detail = "detail", languageCode = "ko").apply {
member = creator
this.theme = theme
}
entityManager.persist(content)
service.register(content, originalFilename)
}!!
private fun race(first: () -> Int, second: () -> Int): List<Int> {
val pool = Executors.newFixedThreadPool(2)
val gate = CountDownLatch(1)
return try {
val futures = listOf(first, second).map { action -> pool.submit(Callable { gate.await(); action() }) }
gate.countDown()
futures.map { it.get(30, TimeUnit.SECONDS) }.sorted()
} finally {
pool.shutdownNow()
}
}
}
@@ -0,0 +1,85 @@
package kr.co.vividnext.sodalive.v2.subtitle
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.Subtitle
import org.hibernate.boot.MetadataSources
import org.hibernate.boot.registry.StandardServiceRegistryBuilder
import org.hibernate.cfg.AvailableSettings
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.boot.autoconfigure.orm.jpa.HibernateProperties
import org.springframework.boot.autoconfigure.orm.jpa.HibernateSettings
import java.sql.DriverManager
import java.util.UUID
class SubtitleSchemaValidationTest {
@Test
@DisplayName("기존 DDL의 s3_bucket과 s3_key를 실제 Hibernate validate 및 저장 조회에 사용한다")
fun shouldValidateAndUseExistingS3Columns() {
val url = "jdbc:h2:mem:subtitle-schema-${UUID.randomUUID()};MODE=MySQL"
DriverManager.getConnection(url, "sa", "").use { connection ->
connection.createStatement().use {
it.execute(
"""
CREATE TABLE subtitle (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
job_id VARCHAR(36) NOT NULL,
language VARCHAR(35) NOT NULL,
format VARCHAR(16) NOT NULL,
s3_bucket VARCHAR(255) NOT NULL,
s3_key VARCHAR(255) NOT NULL,
UNIQUE (job_id, language)
)
""".trimIndent()
)
}
val bootProperties = HibernateProperties().determineHibernateProperties(emptyMap(), HibernateSettings())
val registry = StandardServiceRegistryBuilder()
.applySettings(bootProperties)
.applySetting(AvailableSettings.DRIVER, "org.h2.Driver")
.applySetting(AvailableSettings.URL, url)
.applySetting(AvailableSettings.USER, "sa")
.applySetting(AvailableSettings.PASS, "")
.applySetting(AvailableSettings.DIALECT, "org.hibernate.dialect.H2Dialect")
.applySetting(AvailableSettings.HBM2DDL_AUTO, "validate")
.build()
try {
MetadataSources(registry).addAnnotatedClass(Subtitle::class.java)
.buildMetadata().buildSessionFactory().use { factory ->
val id = factory.openSession().use { session ->
val transaction = session.beginTransaction()
val subtitle = Subtitle(
jobId = "550e8400-e29b-41d4-a716-446655440000",
language = "ko-KR",
format = "SRT",
s3Bucket = "subtitle-media",
s3Key = "subtitles/100/550e8400-e29b-41d4-a716-446655440000/ko.srt"
)
session.persist(subtitle)
transaction.commit()
requireNotNull(subtitle.id)
}
factory.openSession().use { session ->
val stored = session.find(Subtitle::class.java, id)
assertEquals("subtitle-media", stored.s3Bucket)
assertEquals("subtitles/100/550e8400-e29b-41d4-a716-446655440000/ko.srt", stored.s3Key)
}
connection.prepareStatement("SELECT s3_bucket, s3_key FROM subtitle WHERE id = ?").use {
it.setLong(1, id)
it.executeQuery().use { result ->
assertTrue(result.next())
assertEquals("subtitle-media", result.getString("s3_bucket"))
assertEquals(
"subtitles/100/550e8400-e29b-41d4-a716-446655440000/ko.srt",
result.getString("s3_key")
)
}
}
}
} finally {
StandardServiceRegistryBuilder.destroy(registry)
}
}
}
}