From cc85322eb5a21f1f349958afe9123a340875453f Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 11 Nov 2026 08:33:40 +0900 Subject: [PATCH 1/7] =?UTF-8?q?docs(subtitle):=20=EC=9E=90=EB=A7=89=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD?= =?UTF-8?q?=EA=B3=BC=20=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D=EC=9D=84=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20261110_자막Phase1연동/plan-task.md | 184 ++++++++++++++++++++++ docs/20261110_자막Phase1연동/prd.md | 140 ++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 docs/20261110_자막Phase1연동/plan-task.md create mode 100644 docs/20261110_자막Phase1연동/prd.md diff --git a/docs/20261110_자막Phase1연동/plan-task.md b/docs/20261110_자막Phase1연동/plan-task.md new file mode 100644 index 00000000..010c5390 --- /dev/null +++ b/docs/20261110_자막Phase1연동/plan-task.md @@ -0,0 +1,184 @@ +# 자막 Phase 1 Spring 연동 구현 계획 + +**Goal:** 기존 업로드와 권한을 보존하면서 자막 작업 등록, 네 BOT 내부 API, 언어별 안전한 자막 조회를 제공한다. + +**Architecture:** 기존 Kotlin/Spring/JPA와 member JWT를 재사용한다. 신규 자막 기능은 `v2.subtitle` 하위에 두고 기존 콘텐츠 서비스의 업로드 및 상세에만 연결한다. 원본 S3 쓰기 이전 commit과 callback receipt/status/metadata의 원자성을 보장한다. + +**Tech Stack:** Kotlin 1.6.21, Java 17, Spring Boot 2.7.14, JPA, MySQL 운영 DDL, H2 테스트. + +**Spec:** [prd.md](prd.md). 사용자 승인 범위이며 재생성 기능은 제외한다. + +## 현재 상태 + +- Phase 1 완료 기록 유지. Phase 2 generate-url 구현·새 HTTP3개·독립 리뷰·임시 자원 정리 완료. 활성 로컬 Goal 없음. 운영 배포/외부 게이트는 여전히 BLOCKED. +- 실행: subagent-driven-development, 공유 Kotlin 구현은 단일 담당자가 순차 처리한다. 엔티티 계약이 확보된 additive DDL·운영 문서는 별도 담당자가 병렬 준비하고 마지막에 대조한다. +- 새 worktree는 만들지 않는다. 기존 `test` 작업 트리는 조사 시 clean이며 main/master가 아니다. +- commit/push/배포/DB 적용은 이 계획의 실행 항목이 아니다. + +## 전역 제약 + +- PRD의 네 endpoint/payload/상태/경로와 BOT 인증을 그대로 구현한다. 공개 재생성 API와 직접 START 호출은 금지한다. +- 기존 upload/MediaConvert/preview/알림/JWT/텍스트 번역 worker를 보존한다. 기존 테스트를 삭제하거나 완화하지 않는다. +- additive DDL만 작성한다. 적용 migration 편집/삭제와 데이터 DROP 금지. +- 이번 작업에서 새로 작성한 테스트만 실행하며 `bootJar`로 빌드를 별도 확인한다. 기존 클래스에 추가한 새 테스트는 메서드 selector로 지정한다. 기존 focused 회귀도 실행하지 않는다. 전체 테스트 명령은 사용자용 `./gradlew test`다. +- AWS/live/mixed/2h/운영 DB/consumer 미검증을 성공으로 보고하지 않는다. + +## Review Focus + +1. 외부 호출자가 트랜잭션을 가지고 있어도 S3 이벤트 시점에는 job/source가 commit되어 있어야 한다. +2. UUID 파일명 변경 후 기존 MediaConvert 완료 콜백을 수락하되 다른 콘텐츠/경로를 허용하지 않아야 한다. +3. callback 중복/경합 때 receipt와 상태 및 자막이 원자 반영되고 older 결과가 최신을 덮지 않아야 한다. +4. JSON 숫자 coercion/누락 nullable 필드/추가 필드 및 raw-body 재직렬화로 계약이 약화되지 않아야 한다. +5. preview-only 사용자에게 전체 자막이 노출되거나 BOT 변경으로 기존 역할 권한이 넓어지지 않아야 한다. + +## Phase 1: 업로드부터 자막 조회까지 + +### Task 1.1: 자막 backend 연결 + +**Goal `P1-T1`:** 기존 콘텐츠 흐름에 durable source/job 등록과 네 내부 API, 안전한 자막 조회가 연결된다. + +- 시작 조건: 재생성 제외 승인, PRD 작성 완료, 업로드 transaction 경계 검토 완료. +- 완료 증거: 아래 RED/GREEN 기록과 5개 기능 그룹을 검증하는 새 테스트 통과. 기존 콘텐츠/로그인 테스트는 코드 대조만 하고 미실행으로 보고. +- 범위 밖: Lambda 코드, 앱 코드, 인프라, 재생성, 자동 복구. +- 신규 구현 파일은 `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/` 아래 `application`, `domain`, `adapter`, `dto` 책임별로 배치한다. 과도한 port/wrapper는 만들지 않는다. + +**Files:** + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/domain/SubtitleJobStatus.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJob.kt`, `SubtitleJobRepository.kt`, `SubtitleCallbackReceipt.kt`, `SubtitleCallbackReceiptRepository.kt` 및 필요한 최소 subtitle metadata 저장 타입. +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/Subtitle.kt`, `SubtitleRepository.kt`. 결과 행은 auto-increment Long PK와 `jobId`(36), `language`(35), `format`(16), `s3Bucket`(255), `s3Key`(255)를 저장하며 `(jobId, language)` unique와 job FK를 둔다. `findAllByJobId(jobId)` 및 `findByJobIdAndLanguage(jobId, language)`로 callback/조회에서 재사용한다. 언어별 컬럼을 제거하되 아직 미배포인 이번 draft만 교체하고 기존 적용 schema/data는 건드리지 않는다. +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/application/SubtitleJobService.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleJobController.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/dto/SubtitleJobDtos.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`, `AudioContent.kt`, `GetAudioContentDetailResponse.kt` 및 필요한 기존 호출자. +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`와 필요한 자막 전용 설정. +- Create: `docs/20261110_자막Phase1연동/subtitle-schema.sql`. +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/`의 기능 통합 테스트 및 기존 `content/AudioContentServiceTest.kt`, `member/MemberLoginResponseContractTest.kt`, `jwt/TokenProviderTest.kt` 관련 회귀. + +**Interfaces:** + +- Consumes: 기존 `AudioContent`, `S3Uploader`, `AudioContentCloudFront`, `LangContext`, member JWT/role 및 PRD의 exact Lambda HTTP 계약. +- Produces: PRD의 네 내부 HTTP endpoint; 기존 콘텐츠 상세의 nullable 자막 정보. source lookup은 최초 업로드 binding에 고정한다. +- 내부 함수/저장 형태는 기존 패턴과 transaction 검토에 맞추며 실제 파일/이름은 구현 기록에 갱신한다. 공개 계약은 변경하지 않는다. + +- [x] **RED:** 기존 관련 테스트의 의도를 먼저 읽고 새 실제 HTTP 테스트를 작성했다. 지시 이후 기존 baseline은 실행하지 않았다. +- [x] **RED 확인:** 실제 로그인 후 403 기대/404 실제, source 확장자 보존 실패 및 기존 eventId/unknown job의 422 기대/404 실제를 확인했다. compile/setup 실패는 RED로 인정하지 않았다. +- [x] **GREEN:** 최소 persistence/업로드 commit 경계/API/상세 연결 및 additive DDL을 구현했다. 언어별 결과는 `Subtitle` 행이다. +- [x] **GREEN 확인:** 부모 직접 실행에서 신규 `SubtitleHttpIntegrationTest` 13개 통과, 실패·오류·skip 0. +- [x] **REFACTOR:** Kotlin/JPA·직접 영향 test compile, 변경 파일 ktlint 및 bootJar 성공을 확인했다. 기존 회귀 case는 실행하지 않았다. + +검증 상세: [implementation-report.md](implementation-report.md). 부모 재실행 `parent-verification.log`는 +`BUILD SUCCESSFUL in 2m 21s`, XML timestamp `2026-11-10T14:33:59`, 13/0/0/0이다. + +### Task 1.2: 실제 HTTP와 독립 리뷰 + +**Goal `P1-T2`:** 구현 결과를 실제 HTTP 표면과 독립 검토로 확인하고 외부 미검증을 구분한다. + +- 시작 조건: `P1-T1` 구현 및 focused test 완료. +- 완료 증거: 실제 HTTP status/body assertion, `bootJar`, 변경 범위 lint/compile, 독립 리뷰의 blocker 해소. +- 범위 밖: 외부 AWS 환경에 대한 실제 운영 검증 및 전체 테스트. +- TDD 예외 사유: 검증/보고 task다. 대체 검증은 HTTP 실행과 코드/DDL/테스트 대조다. +- [x] 실제 localhost HTTP로 EMAIL BOT 로그인, 네 endpoint, 중복/경합/오류 및 기존 콘텐츠 상세를 실행했다. H2와 외부 S3/서명 경계 대체를 사용했다. +- [x] `bootJar`, 변경 파일 `ktlintCheck`, 신규 HTTP 테스트를 실행했다. LSP 미제공이며 Kotlin compile/lint를 대체 증거로 기록했다. +- [x] 독립 리뷰에서 HTTP 오류 경계 1건을 발견·수정하고, 제한 delta 재검토에서 ADDRESSED 및 로컬 backend APPROVE를 받았다. +- [x] `final-report.md`에 실제 수정·삭제·보존 경로, 명령/결과, DB 적용 및 consumer 상태, fixture 출처, live/mixed/2h BLOCKED를 기록했다. + +### Task 1.R1: 리뷰 회귀 수정 + +**Goal `P1-R1`:** 자막 API의 빈 body/저장 실패가 HTTP 200으로 숨겨지지 않는다. + +- 시작 조건: 독립 리뷰 P1 finding 및 `SodaExceptionHandler.kt`의 기존 HTTP200 변환 확인. +- 완료 증거: 새 실제 HTTP 테스트에서 빈 callback/started 422 및 DB 실패 5xx, receipt/status/results rollback 확인. +- 범위 밖: 기존 공통 exception handler나 다른 API 응답 변경. +- Files: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleExceptionHandler.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/SubtitleHttpIntegrationTest.kt`. +- [x] **RED:** 빈 body와 receipt 저장 실패의 실제 HTTP 테스트 2개를 추가했다. +- [x] **RED 확인:** `[422,422]` 기대에 `[200,200]`, `500` 기대에 `200` 실패를 확인했다. +- [x] **GREEN:** 자막 전용 advice에서 body 읽기 오류는 422, 예상치 못한 서버 오류는 일반화된 500으로 반환한다. +- [x] **GREEN 확인:** 신규 자막 15개 통과, 부모의 추가 두 HTTP 사례 재실행도 통과했다. 기존 서비스 직접 호출 rollback 테스트를 유지했다. +- [x] **REFACTOR:** 변경 lint/build 및 부모 HTTP 재검증 후 제한 delta 리뷰가 승인했다. 원 reviewer session 유실로 대체 reviewer가 기존 판정과 수정분만 재검토했다. + +### Phase 1 Gate + +**Goal `P1-GATE`:** 로컬 backend 완료와 운영 배포 준비 상태가 증거로 구분된다. + +- [x] `P1-T1`, `P1-T2`, `P1-R1` 완료 증거 확인. +- [x] 임시 QA 프로세스/listener 부재와 작업 소유 build/cache/log root 제거를 확인했다. 공유 프로세스·캐시는 변경하지 않았다. +- [x] 재생성 URI/worker/HMAC/파괴적 SQL/외부 프로젝트 변경이 없음을 diff 확인했다. +- [x] 운영 반영은 consumer inventory, DB additive DDL 검토, 조정 배포, controlled live ko/ja/mixed와 2h 측정이 필요하다고 보고했다. + +## Phase 2: generate-url 자막 응답 + +### Task 2.1: 기존 URL 권한을 유지한 자막 제공 + +**Goal `P2-T1`:** 구매자의 generate-url 응답에 상세와 동일한 요청 언어 자막이 포함되고 기존 contentUrl 계약이 보존된다. + +- 시작 조건: PRD §7 사용자 요청 확정, 기존 generateUrl/공유 subtitle 조회 및 새 HTTP fixture 확인. +- 범위 밖: 기존 구매 판정 확대, URI/DB/Lambda 변경, 재생성, 기존 테스트/전체 테스트 실행. +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/GenerateUrlResponse.kt`. +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`의 `generateUrl`만. +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/SubtitleHttpIntegrationTest.kt`에 이번 후속용 HTTP 사례 추가. +- Consumes: `SubtitleJobService.subtitle(content, lang, expirationTime)`, 기존 `LangContext`, 구매 검사와 `AudioContentCloudFront`. +- Produces: `data.contentUrl` 유지 + nullable `data.subtitle`의 language/format/url. +- 완료 증거: 아래 세 시나리오의 새 HTTP 사례 RED/GREEN, 컴파일/변경 lint, 범위 한정 독립 리뷰와 임시 자원 정리. + +시나리오: +1. 구매·공개 완료 콘텐츠: `Accept-Language: ko/ja` 각각에 해당 SRT 정보와 기존 음원 URL, 기존 envelope가 반환된다. +2. 미완료 job/해당 언어 없음/미지원 언어/자막 미등록: 음원 URL은 유지하고 subtitle은 명시적 null이다. +3. 미구매자(소유자·무료 포함)는 기존 빈 contentUrl과 null 자막을 받는다. 예약 공개 구매자의 subtitle은 공개 전 null이며 공개 후 제공된다. 기존 URL 반환은 그대로다. + +- [x] **RED:** 기존 HTTP fixture를 재사용한 새 메서드 3개만 추가했다. +- [x] **RED 확인:** 실제 응답의 `has("subtitle")`에서 3개 모두 assertion 실패함을 확인했다. +- [x] **GREEN:** DTO field와 generateUrl의 공유 자막 조회 연결만 구현했다. +- [x] **GREEN 확인:** 이번 새 HTTP 메서드 3개 통과, 부모 직접 재실행도 3/0/0/0. 이전 15개와 클래스 전체는 실행하지 않았다. +- [x] **REFACTOR:** 컴파일/bootJar/테스트 lint 및 제한 독립 리뷰 APPROVE를 확인했다. production lint의 기존 EOF 빈 줄1건을 별도 기록하고 dirty 변경을 보존했다. 자원 정리는 Phase2 Gate에서 확인한다. + +### Phase 2 Gate + +**Goal `P2-GATE`:** 추가 응답의 HTTP·권한·호환성 증거가 확보된다. + +- [x] 새 HTTP3개 통과와 독립 리뷰 APPROVE 확인. +- [x] 기존 코드 변경은 DTO와 generateUrl 본문뿐임을 실제 전후 diff로 대조했다. +- [x] 결과 보고를 보존하고 소유 테스트 PID/listener 및 임시 build/cache/root 부재를 확인했다. + +## Phase 1 실행 검증 이력 + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subtitle.*' +./gradlew bootJar +``` + +기존 클래스에 추가한 새 테스트는 실제 메서드명을 `--tests`에 지정한다. 기존 테스트 클래스 전체는 실행하지 않는다. 수동 전체 검증은 사용자 실행 `./gradlew test`다. 실제 명령/추가 영향 테스트/결과는 아래에 누적한다. + +## Progress + +- 2026-11-11 `P2-GATE` 종료: 부모 직접 HTTP3개 재실행 성공과 독립 승인 후 소유 root `generate-url-subtitle.fSBkJM`의 디렉터리/symlink 여부를 확인해 제거했다. `test ! -e` exit0이며 공유 캐시/다른 dirty 파일은 건드리지 않았다. 원래 EOF lint1건은 별도 미해결 관찰로 남긴다. 검증 로그 경로는 과거 위치이며 결과/명령/실패 이력은 보고서에 보존했다. + +- 2026-11-11 `P2-T1` 독립 리뷰 APPROVE: 기존 contentUrl/envelope/활성 구매 정책과 서명 만료 인자 유지, 자막만 공개 시점·언어·최신 결과 조건 적용, 새 HTTP3개 증거를 확인했다. 이번 delta blocker/새 회귀 없음. production EOF 공백은 작업 시작 전 dirty 상태의 잔여사항이며 전체 lint clean을 주장하지 않는다. + +- 2026-11-11 `P2-T1` 구현: DTO nullable field 및 generateUrl 본문만 수정. 새 HTTP3개 RED/GREEN 후 부모가 정확히 새3method selector로 직접 실행해 3 tests/0 failures/errors/skipped, `BUILD SUCCESSFUL in 37s`, XML UTC `2026-11-10T22:12:51` 확인. 실제 Tomcat PID17759/port50914. Kotlin compile/bootJar 및 테스트 lint 성공. 두 production 파일 lint에서는 작업 전부터 있던 서비스 EOF 빈 줄1건만 실패하므로 다른 dirty 변경으로 보존했다. 상세는 `generate-url-report.md`. 범위 한정 독립 리뷰 진행 중. + +- 2026-11-11 후속 요청: generate-url에도 상세와 같은 자막 응답 추가. `GenerateUrlResponse`는 현재 contentUrl만 있고 기존 URL 권한은 구매 검사다. `P2-T1`에서 additive field/공유 조회만 연결한다. 기존 Phase1 완료·검증 기록은 유지한다. 시작 시 추가 dirty 파일(기존 관리자 controller/query 테스트 등)을 발견했으며 다른 작업으로 보고 변경하지 않는다. + +- 2026-11-11 종료 정리: 소유 root `subtitle-phase1.wiRcAJ`가 실제 디렉터리이며 symlink가 아님을 확인한 후 제거했고 `test ! -e` exit0으로 부재 확인했다. 작업 소유 테스트 PID/HTTP port의 종료는 먼저 확인했다. 결과·명령·실패 이력·리뷰 판정은 저장소 문서에 보존했으며 임시 log/init/JAR 경로는 과거 검증 위치다. 모든 로컬 Goal 완료, 외부 배포 게이트는 별도 BLOCKED로 유지한다. + +- 2026-11-11 최종 제한 리뷰: `REV-P1-001 ADDRESSED`, 로컬 backend gate `APPROVE`. 신규 blocker 없음. 원 reviewer session 유실 때문에 기존 전체 리뷰 판정과 오류 advice/두 테스트 delta만 대체 reviewer가 확인했으며 전체 리뷰를 반복하지 않았다. 재생성·AWS·운영 DB·live·2h는 구현/승인 범위에서 분리했다. + +- 2026-11-11 `P1-R1` 검증: 2개 HTTP RED 후 수정, 신규 자막 15개 전체와 bootJar/변경 lint 통과 (`p1-green-15-build-lint.log`, 7m44s). 부모는 수정한 두 새 HTTP 사례만 재실행해 2/0/0/0 및 `BUILD SUCCESSFUL in 4m 55s` 확인. 실제 Tomcat port 59977/PID44707, XML timestamp `2026-11-10T15:34:43`. 기존 reviewer session continuation이 유실돼 이전 판정과 수정 delta만 전달한 단일 재리뷰를 진행 중이다. 같은 기능을 재설계하거나 전체 리뷰를 반복하지 않는다. + +- 2026-11-10 독립 gate 리뷰: REQUEST_CHANGES 1건. 자막 advice가 ResponseStatusException만 처리하므로 빈 body/저장 실패가 기존 공통 advice에서 HTTP200으로 변환될 수 있다. 부모가 실제 공통 handler를 확인했고 전용 advice+새 HTTP 사례의 `P1-R1`로 수정한다. 나머지 저장/권한/언어별 구조/업로드/DDL 범위는 reviewer가 부합 판정했다. + +- 2026-11-10 부모 검증: 격리 build/cache에서 신규 HTTP 13개를 up-to-date 무시하고 직접 재실행, 13 passed/0 failed/0 errors/0 skipped, `BUILD SUCCESSFUL in 2m 21s`. 같은 코드의 bootJar와 ktlint는 up-to-date 확인이며 구현 담당의 실제 실행 로그 `build-lint.log`는 `BUILD SUCCESSFUL in 1m 28s`다. 로컬 mapping의 `sourceUploadErrorAt` TIMESTAMP 차이는 해소했다. 기존 독립 reviewer 결과 회수 실패 및 session 유실로 승인을 주장하지 않고 대체 단일 reviewer를 실행 중이다. + +- 2026-11-10 실제 ZIP 계약 대조: `unzip -p .../start-subtitle-transcription.zip common/transcribe.py`로 확장자 없는 원본 key가 `UNSUPPORTED_MEDIA`임을 확인했다. `common/runtime.py::source_content`는 UUID 뒤 확장자를 허용한다. 원본의 안전한 확장자를 보존하도록 등록 seam/새 테스트를 수정한다. 이는 기존 artifact의 read-only 계약 확인이며 Lambda 변경·실행·AWS 호출이 아니다. 확장자는 형식 보장이 아니며 기존 업로드/MediaConvert 동작을 미지원 자막 형식 때문에 막지 않는다. + +- 2026-11-10 후속 설계 조정: 사용자에게 향후 언어 추가 가능성이 확인되어 고정 `koKey`/`jaKey` 대신 `Subtitle` 결과 행을 둔다. 현재 API 허용 언어/상태 판정은 ko/ja 고정 그대로다. 구현 담당과 DDL 담당을 잠시 중단하고 같은 session으로 갱신 계약을 전달한다. callback 원자성/terminal replay/조회 권한 새 테스트를 같은 구조로 갱신한다. 아래 Oracle의 고정 컬럼 권고는 당시 두 언어 고정 가정의 기록이며 이 결정으로 대체한다. + +- 2026-11-10 후속 지시: 테스트는 이번에 작성한 것만 실행. 지시 반영을 위해 실행 중 implementer를 일시 중단하고 같은 session으로 재개한다. Kotlin 공유 파일은 기존 담당자가 소유하고 DDL/운영 문서만 병렬 분리한다. 이전 baseline 실행 이력은 아래에 보존하되 재실행하지 않는다. + +- 2026-11-10: 현재 상태 조사 완료. Spring 자막/recovery 구현 없음, 기존 BOT 인증/업로드 보존. Phase 1 Lambda ZIP hash 대조 완료, AWS 상태는 미확인. 사용자 결정으로 재생성 전체 제외. +- 2026-11-10: PRD/계획 준비. 아직 production 코드 수정 및 테스트 실행 전이며 성공 결과를 주장하지 않는다. +- 2026-11-10: Oracle 경계 검토 완료. create orchestration은 `NOT_SUPPORTED`, 실제 등록은 필수 주입 `TransactionTemplate(REQUIRES_NEW)`로 분리한다. AI 관리자 create 바깥 transaction도 조정한다. content/source/job/최신 선택값을 한 번 저장한 뒤 S3 원본 업로드하고 후속 input 경로 재대입은 제거한다. 업로드 예외는 객체 생성 여부가 불명확하므로 처리 상태를 terminal로 덮지 않고 별도 업로드 오류만 기록해 수동 확인 대상으로 남긴다. +- 2026-11-10: 고정 두 언어 metadata는 job의 nullable 컬럼으로 최소 저장하고 별도 번역/checkpoint 구조는 만들지 않는다. receipt eventId는 case-sensitive unique, callback/started는 동일 job lock을 사용한다. 다른 job 사이 eventId 경합은 rollback 후 receipt 재조회로 판정한다. +- 2026-11-10: UUID 파일명 변경은 외부 MediaConvert의 ID 추출/output 명명 규칙 확인 전 **배포 BLOCKED**다. 현재 저장소에 외부 consumer의 운영 계약 증거가 없으며, 임의 prefix 완화로 숨기지 않는다. 자막 S3 bucket의 private CloudFront origin/서명 접근도 운영 확인 게이트다. +- 2026-11-10: baseline 명령 `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' --tests 'kr.co.vividnext.sodalive.member.MemberLoginResponseContractTest' --tests 'kr.co.vividnext.sodalive.jwt.TokenProviderTest'`는 `kaptGenerateStubsKotlin` 단계에서 200초 도구 제한으로 종료. 테스트 결과 없음. 기존 다른 Gradle 작업이 관찰되어 타 작업을 종료하지 않고 이 작업 전용 build/cache 경로로 검증을 격리한다. diff --git a/docs/20261110_자막Phase1연동/prd.md b/docs/20261110_자막Phase1연동/prd.md new file mode 100644 index 00000000..f585d49f --- /dev/null +++ b/docs/20261110_자막Phase1연동/prd.md @@ -0,0 +1,140 @@ +# 자막 Phase 1 Spring 연동 PRD + +## 문서 정보 + +- 작성일: 2026-11-10 +- 상태: 재생성 제외 로컬 backend 구현·검증 완료. 운영 배포/외부 게이트는 별도 차단 상태. +- 구현 계획: [plan-task.md](plan-task.md) +- 기준: 사용자 제공 Phase 1 계약 및 후속 결정 "이번 스코프에 재생성 기능은 추가하지 않고 나머지만 진행" + +## 1. 목표와 범위 + +기존 콘텐츠 업로드에 자막 원본 binding과 PENDING 작업을 연결하고, 기존 BOT JWT로 보호한 네 내부 API를 통해 Lambda 결과를 저장한다. 기존 콘텐츠 상세에서 요청 언어와 원본 열람 권한에 맞는 자막 정보만 반환한다. + +Spring은 업로드/작업 등록/결과 저장/콘텐츠 접근 제어를 맡는다. Lambda의 S3 이벤트, Transcribe, 번역, SRT 생성은 이미 별도 구현되어 있으므로 다시 구현하지 않는다. 앱 플레이어 구현은 이 Spring 저장소의 변경 대상이 아니며 실제 앱 표시 검증은 외부 게이트다. + +### 제외 + +- 사용자 결정에 따라 자막 재생성 전체: 새 공개 API, 새 job 생성 business action, START 직접 비동기 호출 모두 제외. +- Scheduler discovery, recovery/lease/fence/checkpoint/outbox/자동 복구, HMAC 인증, 신규 로그인/refresh 계약. +- 외부 프로젝트 수정, AWS 설정/배포/실데이터 변경, DB DROP, 적용 migration 편집/삭제. +- MediaConvert/AAC/preview/기존 알림의 기능 변경과 2GB 다운로드/probe pipeline. + +## 2. 실제 현재 상태 + +| 영역 | 확인 결과 | 처리 | +|---|---|---| +| Spring 작업 트리 | `test`, 조사 기준 `fe620aa9`, 변경 없음 | 현 작업 트리에서 최소 변경 | +| 로그인/JWT/BOT | `MemberService.login`, `MemberRole.BOT`, `JwtFilter`, `TokenProvider` 존재 | 재사용, 공유 인증 약화 금지 | +| 콘텐츠 | `AudioContent`의 numeric PK, 기존 multipart 업로드와 상세/서명 URL 존재 | 보존 및 필요한 연결만 추가 | +| 업로드 | `AudioContentService.createAudioContent` 트랜잭션 안에서 S3 업로드 | S3 이벤트 전에 committed PENDING/source가 보이도록 조정 | +| 업로드 완료 | `uploadComplete`가 파일명의 contentId prefix 검사 후 경로/duration/알림 처리 | 기존 파일은 유지, 새 UUID 원본과의 정확한 연결 검증 필요 | +| 자막/recovery | 자막 API/job/worker/HMAC 및 자막 migration 검색 결과 없음 | 실제 삭제 대상 없음, 공유 텍스트 TranslationJob/JWT/Agora 보존 | +| DB 관리 | Flyway/Liquibase 의존성 없음, 기존 문서 SQL 관례 | additive MySQL DDL 문서만 작성, 운영 적용 여부 미확인 | +| 재생성 | 기존 action 없음. `generate-url`은 재생 URL 발급 | 이번 범위 제외 | + +### Lambda handoff 확인 + +참조는 `/Users/klaus/Develop/sodalive/Lambda/generate-subtitle/aws-subtitle-service/docs/FINAL_REPORT.md`다. 실제 로컬 ZIP hash가 보고서 및 manifest와 일치함을 조사했다. + +- `start-subtitle-transcription.zip`: `a0d0b19b2ecb24c0f89caf528bf09fd039558563a99063f082ac64418704abfd` +- `generate-subtitle.zip`: `bfa8742ca436bace853ec2540fc5f70daa24cbab49e75315565d980189fbf209` + +이는 AWS 배포 상태의 증거가 아니다. 현재 운영 consumer/in-flight/Scheduler/DB 상태는 미확인이다. 이 저장소에 삭제할 legacy 자막 consumer는 발견되지 않았으며 외부 consumer 삭제/중지/교체는 하지 않는다. + +## 3. 고정 계약 + +### 식별자와 저장 경로 + +- `contentId`: 기존 Long PK, JSON number. `jobId`: Spring 생성 UUID string. +- 원본: `input/{contentId}/{UUID filename}`, 같은 key 덮어쓰기 금지. VersionId 생성/사용 없음. +- 원본 파일의 안전한 확장자를 UUID 뒤에 보존한다(예: `{UUID}.mp3`). 실제 Phase 1 START ZIP의 `common/transcribe.py::validate_binding`은 `amr/flac/m4a/mp3/mp4/ogg/webm/wav` 확장자를 요구하며, 확장자 없는 key는 `UNSUPPORTED_MEDIA`다. 확장자는 형식 힌트일 뿐 실제 미디어 검증을 대신하지 않는다. 기존 업로드 허용 동작을 임의로 축소하지 않고 미지원 형식은 자막 실패와 기존 업로드를 구분한다. +- transcript: `transcripts/{contentId}/{jobId}/transcript.json`. +- 자막: `subtitles/{contentId}/{jobId}/ko.srt`, `subtitles/{contentId}/{jobId}/ja.srt`. +- 상태: `PENDING`, `PROCESSING`, `COMPLETED`, `PARTIAL_COMPLETED`, `FAILED`. +- 등록과 원본 binding은 S3 원본 업로드 전에 commit한다. 업로드 실패를 성공으로 숨기지 않으며 이미 commit된 job의 실패를 기록한다. 자동 복구는 추가하지 않는다. + +### 내부 API: 정확히 네 개 + +모든 요청은 기존 `/member/login`에서 발급된 JWT의 BOT 권한을 검증한다. 일반 USER/CREATOR/ADMIN만으로 허용하지 않는다. unknown job/source는 404이며 조회 중 job을 만들지 않는다. + +| Method | Path | 계약 | +|---|---|---| +| GET | `/internal/api/subtitle-jobs/resolve?bucket=...&key=...` | 업로드의 정확한 source binding 조회 | +| GET | `/internal/api/subtitle-jobs/{jobId}` | UUID job 조회 | +| POST | `/internal/api/subtitle-jobs/{jobId}/started` | 정확히 `transcribeJobName`, `status` | +| POST | `/internal/api/subtitle-jobs/callback` | 정확히 아래 8개 필드 | + +조회 응답은 envelope 없이 `jobId`, numeric `contentId`, `status`, `source:{bucket,key,versionId:null}`다. recovery/provider/lease 정보는 노출하지 않는다. + +started의 이름은 `subtitle-{jobId}`, status는 `PROCESSING`만 허용한다. PENDING만 PROCESSING으로 전이하고 같은 PROCESSING 및 terminal은 no-op이다. 성공은 200 `{}` 또는 204 empty다. + +callback은 `eventId`, `jobId`, numeric `contentId`, `transcribeJobName`, `status`, `subtitles`, `errorCode`, `errorMessage`만 받는다. `eventId`는 비어 있지 않은 문자열로 UUID 제한을 하지 않는다. subtitle 항목은 `language`, `format`, `s3Bucket`, `s3Key`만 받는다. 언어는 `ko-KR`/`ja-JP`, format은 `SRT`, 언어 중복은 금지한다. 이름/job/content/configured bucket allowlist/정확한 자막 key를 검증한다. + +- 2개 자막이면 COMPLETED, 1개면 PARTIAL_COMPLETED, 0개면 FAILED만 허용한다. +- COMPLETED의 errorCode/errorMessage는 명시적 null, partial/failed는 구체적 비어 있지 않은 오류를 요구한다. nullable 필드도 요청에서 생략하지 않는다. +- started 전 callback도 허용한다. callback 검증은 SRT 내용 검사로 위장하지 않는다. 실제 빈 파일/무음 방지는 기존 Lambda 및 live 검증 책임이다. +- receipt의 eventId와 **원래 HTTP body bytes의 SHA-256**을 상태/자막 metadata와 같은 DB 트랜잭션으로 저장한다. hash는 인증이 아니다. +- 같은 eventId/hash는 200, 같은 eventId/다른 hash는 422, terminal job의 다른 결과는 409다. 회귀 및 부분 반영 금지. +- 오래된 job의 결과는 자체 저장하되 콘텐츠 최신 선택 결과를 대체하지 않는다. 재생성 API가 제외되어도 이 저장 불변식은 유지한다. + +### 로그인/권한/개인정보 + +기존 login 요청의 `isAdmin:false`, `isCreator:false`와 기존 `data.token` 응답을 바꾸지 않는다. 활성 EMAIL BOT의 DB role과 발급 JWT를 실제 인증 경로 테스트로 확인한다. Lambda의 success envelope/401 1회 갱신/403 갱신 금지 구현은 재작성하지 않는다. + +credentials/JWT/body를 코드/로그/보고서에 출력하지 않는다. BOT 운영 계정 준비는 운영자 책임이다. 기존 JWT 등록/회원 권한/다른 role 경로는 그대로 보존한다. + +### 언어별 저장 구조 + +추후 언어 추가 가능성을 반영해 `SubtitleJob`에 `koKey`/`jaKey` 같은 언어별 컬럼을 두지 않는다. `Subtitle` 결과 행에 `jobId`, `language`, `format`, `s3Bucket`, `s3Key`를 저장하고 `(jobId, language)` unique로 중복을 방지한다. 작업 상태/원본/오류는 `SubtitleJob`, callback 중복 방지는 receipt가 담당한다. callback transaction에서 결과 행도 함께 저장한다. + +이번 Phase 1의 외부 계약은 그대로 유지한다. callback은 `ko-KR`/`ja-JP`만 허용하며 2개/1개/0개 결과의 상태 판정도 변경하지 않는다. 다른 언어 활성화, 언어 관리 API, 동적 provider 설정은 구현하지 않는다. 새 언어는 향후 계약·Lambda·조회 정책 변경이 필요하지만 언어별 DB 컬럼 추가는 필요 없도록 한다. + +### 콘텐츠 상세 응답 + +기존 상세 API 응답에 nullable 자막 정보를 additive하게 제공한다. 기존 `LangContext`의 한국어/일본어에 맞는 완료 자막만 선택한다. 미지원 언어/미완료/없는 언어는 null이며 원문 복사 fallback을 만들지 않는다. + +원본 재생이 가능한 구매/소유자/무료 조건과 공개 시점 등 기존 접근 제한을 통과한 경우에만 서명된 SRT URL을 제공한다. 유료 preview만 볼 수 있는 사용자는 전체 자막을 받지 않는다. 새로운 공개 download URI를 만들지 않는다. + +## 4. 검증 시나리오 + +| 그룹 | 통과 조건 | 증거 | +|---|---|---| +| 1. 등록/조회 | S3 upload 경계에서 별도 transaction으로 PENDING/source 조회 가능, UUID/숫자 JSON 및 404 정확 | Spring DB/HTTP 테스트, upload 경계 assertion | +| 2. started | 단조 전이, duplicate/terminal no-op, 잘못된 이름/status 거부 | DB/HTTP 테스트 | +| 3. callback | complete/partial/fail, exact body hash replay, 422/409, callback-before-started, atomic rollback, older/latest guard | DB/HTTP 및 동시성 테스트 | +| 4. 로그인/보안 | 실제 active EMAIL BOT 로그인/JWT로 네 API 허용, 다른 role/익명 거부, 기존 envelope 보존 | 기존 로그인/JWT 회귀 및 통합 테스트 | +| 5. 콘텐츠 | ko/ja 선택 및 구매/소유자/무료/preview 권한 유지, 기존 업로드 완료/알림 회귀 없음 | 기존 콘텐츠 테스트와 추가 회귀 | + +실제 서버 HTTP 요청으로 핵심 endpoint를 검증하고, 이번 작업에서 새로 작성한 테스트와 `bootJar`를 분리 실행한다. 기존 테스트는 읽어 계약을 보존하되 실행하지 않는다. 기존 클래스에 새 테스트를 추가한 경우 해당 메서드만 지정한다. 전체 테스트는 사용자 실행 `./gradlew test`로 남긴다. LSP 도구가 제공되지 않으면 Kotlin compile/lint 대체 결과와 미실행 사실을 명시한다. + +## 5. 외부 게이트와 미검증 + +- AWS 접근/배포는 승인 없이 하지 않는다. 기존 consumer 운영 여부가 미확인이므로 삭제/전환/재개는 BLOCKED다. +- live ko/ja/mixed S3 -> callback -> 콘텐츠/앱 검증은 별도 승인 및 환경 필요. 실제 word/item 언어 evidence 없는 mixed 결과는 통과 불가. synthetic fixture는 대체 증거가 아니다. +- 2시간은 입력 목표이며 SLA/지원 보장이 아니다. 기존 신뢰 가능한 duration의 가용성과 검증 경계를 확인한다. 구형 또는 검증 불가능한 원본은 수동 게이트로 남기고 임의 client 값을 신뢰하지 않는다. +- 대표 2시간 처리 예산은 미측정이며 지원 검증 BLOCKED. 분할/상한 하향/Translate 50-call 제한을 추가하지 않는다. +- 운영 DB 적용 상태 미확인. DDL 적용/기존 데이터/미확인 환경에 대한 삭제는 하지 않는다. + +## 6. Decision Log + +| 날짜 | 결정 | 근거 | +|---|---|---| +| 2026-11-10 | 자막 재생성 기능 전체 제외, 나머지 진행 | 사용자 명시 결정 | +| 2026-11-10 | 현재 Spring 중심, 외부 참조는 명시된 Lambda handoff에 한정 | 사용자 범위 확인 | +| 2026-11-10 | 이미 있는 로그인/JWT/Lambda 재구현 금지, 자막 recovery 삭제 없음 | 실제 코드/ZIP 조사 | +| 2026-11-10 | 실행 테스트는 이번 작업에서 새로 작성한 사례만. 기존 focused 회귀 및 전체 테스트 실행 금지 | 사용자 후속 지시 | +| 2026-11-10 | 향후 언어 추가를 고려해 언어별 결과 행으로 저장, 이번 API는 ko/ja만 유지 | 사용자 확장 가능성 제시 및 중단 없이 진행 지시에 따른 최소 저장 구조 조정 | + +## 7. 후속 범위: generate-url 자막 응답 + +2026-11-11 사용자 요청에 따라 기존 `GET /audio-content/{id}/generate-url` 응답에도 콘텐츠 상세와 동일한 +nullable `subtitle`을 추가한다. 기존 `ApiResponse` envelope와 `data.contentUrl` 이름·타입·URL 생성 동작은 유지한다. + +- `GenerateUrlResponse`에 `subtitle: ContentSubtitleResponse? = null`을 추가한다. 필드는 `language`, `format`, `url`이다. +- 기존 `orderRepository.isExistOrdered` 검사를 유지한다. 구매가 없으면 기존처럼 `contentUrl`은 빈 문자열이며 `subtitle`은 null이다. +- 상세와 달리 현재 generate-url은 소유자/무료 콘텐츠만으로 구매 검사를 우회하지 않는다. 이 요청으로 기존 URL 권한을 확대하지 않는다. +- 구매가 확인된 사용자의 자막은 상세와 같은 공개 시점 조건(미지정/도래 또는 소유자)을 적용한다. 기존 `contentUrl` 동작은 바꾸지 않는다. +- `SubtitleJobService.subtitle`을 재사용해 요청 `LangContext`의 ko/ja, 최신 job, 완료/부분 완료 및 해당 언어 존재 여부를 처리한다. +- 자막 미완료·누락·미지원 언어면 null이며 원문 fallback이나 새 자막 생성은 하지 않는다. 음원과 자막 서명 URL에는 같은 만료 기간을 전달한다. +- 컨트롤러 URI/인증·DB 스키마·Lambda·재생성은 변경하지 않는다. 검증은 이번 후속 작업에서 새로 작성한 HTTP 테스트만 실행한다. From e097e27ac6e7ea76a4d6d20d45fdab17558ad258 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 11 Nov 2026 08:33:52 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat(subtitle):=20=EC=9E=90=EB=A7=89=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EC=A0=80=EC=9E=A5=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EC=99=80=20=EA=B3=84=EC=95=BD=EC=9D=84=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../subtitle-schema.sql | 53 +++++++++++++++++++ .../sodalive/content/AudioContent.kt | 3 ++ .../subtitle/adapter/persistence/Subtitle.kt | 28 ++++++++++ .../persistence/SubtitleCallbackReceipt.kt | 31 +++++++++++ .../SubtitleCallbackReceiptRepository.kt | 5 ++ .../adapter/persistence/SubtitleJob.kt | 47 ++++++++++++++++ .../persistence/SubtitleJobRepository.kt | 16 ++++++ .../adapter/persistence/SubtitleRepository.kt | 8 +++ .../v2/subtitle/domain/SubtitleJobStatus.kt | 8 +++ .../v2/subtitle/dto/SubtitleJobDtos.kt | 29 ++++++++++ 10 files changed, 228 insertions(+) create mode 100644 docs/20261110_자막Phase1연동/subtitle-schema.sql create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/Subtitle.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceipt.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceiptRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJob.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJobRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/domain/SubtitleJobStatus.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/dto/SubtitleJobDtos.kt diff --git a/docs/20261110_자막Phase1연동/subtitle-schema.sql b/docs/20261110_자막Phase1연동/subtitle-schema.sql new file mode 100644 index 00000000..0d521341 --- /dev/null +++ b/docs/20261110_자막Phase1연동/subtitle-schema.sql @@ -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'; diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt index eaa37f68..78f1d5e8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt @@ -52,6 +52,9 @@ data class AudioContent( var content: String? = null var coverImage: String? = null + @Column(length = 36) + var latestSubtitleJobId: String? = null + @OneToOne(fetch = FetchType.EAGER) @JoinColumn(name = "theme_id", nullable = false) var theme: AudioContentTheme? = null diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/Subtitle.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/Subtitle.kt new file mode 100644 index 00000000..e9fdac98 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/Subtitle.kt @@ -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(nullable = false, length = 255) + val s3Bucket: String, + @Column(nullable = false, length = 255) + val s3Key: String +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceipt.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceipt.kt new file mode 100644 index 00000000..3247cc36 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceipt.kt @@ -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 { + @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 +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceiptRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceiptRepository.kt new file mode 100644 index 00000000..71fdb656 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceiptRepository.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface SubtitleCallbackReceiptRepository : JpaRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJob.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJob.kt new file mode 100644 index 00000000..9ce093b2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJob.kt @@ -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 +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJobRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJobRepository.kt new file mode 100644 index 00000000..42ca1aeb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJobRepository.kt @@ -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 { + 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? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleRepository.kt new file mode 100644 index 00000000..41c3ab40 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleRepository.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface SubtitleRepository : JpaRepository { + fun findAllByJobId(jobId: String): List + fun findByJobIdAndLanguage(jobId: String, language: String): Subtitle? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/domain/SubtitleJobStatus.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/domain/SubtitleJobStatus.kt new file mode 100644 index 00000000..232d1a40 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/domain/SubtitleJobStatus.kt @@ -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 +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/dto/SubtitleJobDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/dto/SubtitleJobDtos.kt new file mode 100644 index 00000000..bfa4b8c1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/dto/SubtitleJobDtos.kt @@ -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, + val errorCode: String?, + val errorMessage: String? +) + +data class ContentSubtitleResponse(val language: String, val format: String, val url: String) From 9c1c544e45f53c8188a7f12b746b650fae5ba1c7 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 11 Nov 2026 08:34:54 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat(subtitle):=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=EC=99=80=20BOT=20=EC=BD=9C=EB=B0=B1=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9E=90=EB=A7=89=20=EC=9D=91=EB=8B=B5=EC=9D=84=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/configs/SecurityConfig.kt | 7 + .../sodalive/content/AudioContentService.kt | 308 +++--- .../sodalive/content/GenerateUrlResponse.kt | 5 +- .../content/GetAudioContentDetailResponse.kt | 4 +- .../AiCharacterAdminAudioContentFacade.kt | 3 +- .../adapter/web/SubtitleExceptionHandler.kt | 25 + .../adapter/web/SubtitleJobController.kt | 32 + .../adapter/web/SubtitleRequestParser.kt | 72 ++ .../application/SubtitleJobService.kt | 219 +++++ .../content/AudioContentServiceTest.kt | 7 + ...haracterAdminAudioContentControllerTest.kt | 59 +- .../AiCharacterAdminAudioContentCreateTest.kt | 59 +- .../AiCharacterAdminAudioContentQueryTest.kt | 3 +- ...orAdminAudioContentCharacterizationTest.kt | 23 +- .../subtitle/SubtitleHttpIntegrationTest.kt | 905 ++++++++++++++++++ 15 files changed, 1567 insertions(+), 164 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleExceptionHandler.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleJobController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleRequestParser.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/application/SubtitleJobService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/SubtitleHttpIntegrationTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt index b55e35f5..f1de5e02 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -157,6 +157,13 @@ class SecurityConfig( JwtFilter::class.java ) .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() .requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll() .antMatchers("/member/check/email").permitAll() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt index 2b18766e..5be729a1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -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.home.following.application.HomeFollowingNewsPublishService import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService +import kr.co.vividnext.sodalive.v2.subtitle.application.SubtitleJobService import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.cache.annotation.Cacheable import org.springframework.context.ApplicationEventPublisher import org.springframework.data.repository.findByIdOrNull 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.TransactionSynchronization import org.springframework.transaction.support.TransactionSynchronizationManager +import org.springframework.transaction.support.TransactionTemplate import org.springframework.web.multipart.MultipartFile import java.text.SimpleDateFormat import java.time.LocalDateTime @@ -93,6 +98,8 @@ class AudioContentService( private val contentThemeTranslationRepository: ContentThemeTranslationRepository, private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService, private val memberContentPreferenceService: MemberContentPreferenceService, + private val subtitleJobService: SubtitleJobService, + transactionManager: PlatformTransactionManager, @Value("\${cloud.aws.s3.content-bucket}") private val audioContentBucket: String, @@ -104,6 +111,9 @@ class AudioContentService( private val coverImageHost: String ) { private val log = LoggerFactory.getLogger(javaClass) + private val registrationTransaction = TransactionTemplate(transactionManager).apply { + propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW + } @Transactional fun audioContentLike(request: PutAudioContentLikeRequest, member: Member): PutAudioContentLikeResponse { @@ -220,7 +230,7 @@ class AudioContentService( pinContent?.isActive = false } - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) fun createAudioContent( contentFile: MultipartFile?, coverImage: MultipartFile?, @@ -245,7 +255,7 @@ class AudioContentService( return createAudioContent(contentFile, coverImage, request, releaseDate, member) } - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) fun createAudioContent( contentFile: MultipartFile?, coverImage: MultipartFile?, @@ -260,115 +270,118 @@ class AudioContentService( 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) - ?: throw SodaException(messageKey = "content.error.invalid_theme") + // 테마 체크 + val theme = themeQueryRepository.findThemeByIdAndActive(id = request.themeId) + ?: throw SodaException(messageKey = "content.error.invalid_theme") - if ((request.themeId == 12L || request.themeId == 13L || request.themeId == 14L) && request.price < 5) { - throw SodaException(messageKey = "content.error.alarm_theme_price_min") - } + if ((request.themeId == 12L || request.themeId == 13L || request.themeId == 14L) && request.price < 5) { + 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) { - request.isFullDetailVisible - } else { - true - } + val isFullDetailVisible = if (request.price >= 50) { + request.isFullDetailVisible + } else { + true + } - val purchaseOption = if (request.themeId == 12L || request.themeId == 13L || request.themeId == 14L) { - PurchaseOption.BUY_ONLY - } else { - request.purchaseOption - } + val purchaseOption = if (request.themeId == 12L || request.themeId == 13L || request.themeId == 14L) { + PurchaseOption.BUY_ONLY + } else { + request.purchaseOption + } - val isOnlyRental = if ( - (request.limited != null && request.limited > 0) || - purchaseOption == PurchaseOption.BUY_ONLY - ) { - false - } else if (request.purchaseOption == PurchaseOption.RENT_ONLY) { - true - } else { - request.isOnlyRental - } + val isOnlyRental = if ( + (request.limited != null && request.limited > 0) || + purchaseOption == PurchaseOption.BUY_ONLY + ) { + false + } else if (request.purchaseOption == PurchaseOption.RENT_ONLY) { + true + } else { + request.isOnlyRental + } - // DB에 값 추가 - val audioContent = AudioContent( - title = request.title.trim(), - detail = request.detail.trim(), - languageCode = request.languageCode, - price = if (request.price > 0) { - 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" + // DB에 값 추가 + val audioContent = AudioContent( + title = request.title.trim(), + detail = request.detail.trim(), + languageCode = request.languageCode, + price = if (request.price > 0) { + request.price } 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() - audioContentHashTag.audioContent = audioContent - audioContentHashTag.hashTag = hashTag + val hashTag = hashTagRepository.findByTag(tag) + ?: hashTagRepository.save(HashTag(tag)) - audioContentHashTag - }.toList() + val audioContentHashTag = AudioContentHashTag() + audioContentHashTag.audioContent = audioContent + audioContentHashTag.hashTag = hashTag - audioContent.audioContentHashTags.addAll(tags) - } + audioContentHashTag + }.toList() - var metadata = ObjectMetadata() - metadata.contentLength = coverImage.size + audioContent.audioContentHashTags.addAll(tags) + } - // 커버 이미지 파일명 생성 - val coverImageFileName = generateFileName(prefix = "${audioContent.id}-cover") + val metadata = ObjectMetadata() + metadata.contentLength = coverImage.size - // 커버 이미지 업로드 - val coverImagePath = s3Uploader.upload( - inputStream = coverImage.inputStream, - bucket = coverImageBucket, - filePath = "audio_content_cover/${audioContent.id}/$coverImageFileName", - metadata = metadata + // 커버 이미지 파일명 생성 + val coverImageFileName = generateFileName(prefix = "${audioContent.id}-cover") + + // 커버 이미지 업로드 + val coverImagePath = s3Uploader.upload( + inputStream = coverImage.inputStream, + bucket = coverImageBucket, + filePath = "audio_content_cover/${audioContent.id}/$coverImageFileName", + metadata = metadata + ) + + audioContent.coverImage = coverImagePath + audioContent to subtitleJobService.register(audioContent, contentFile.originalFilename) + } ) - - audioContent.coverImage = coverImagePath - - // 콘텐츠 파일명 생성 - val contentFileName = generateFileName(prefix = "${audioContent.id}-content") + val (audioContent, subtitleJob) = registration // 콘텐츠 파일 업로드 - metadata = ObjectMetadata() + val metadata = ObjectMetadata() metadata.contentLength = contentFile.size metadata.addUserMetadata( "generate_preview", @@ -384,38 +397,45 @@ class AudioContentService( metadata.addUserMetadata("preview_end_time", request.previewEndTime) } - val contentPath = s3Uploader.upload( - inputStream = contentFile.inputStream, - bucket = audioContentBucket, - filePath = "input/${audioContent.id}/$contentFileName", - 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 + try { + contentFile.inputStream.use { inputStream -> + s3Uploader.upload( + inputStream = inputStream, + bucket = audioContentBucket, + filePath = subtitleJob.sourceKey, + metadata = metadata ) - ) - } else { - applicationEventPublisher.publishEvent( - LanguageTranslationEvent( - id = audioContent.id!!, - targetType = LanguageTranslationTargetType.CONTENT + } + } catch (exception: Exception) { + subtitleJobService.recordUploadError(subtitleJob.jobId) + throw exception + } + + 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!!) @@ -481,8 +501,9 @@ class AudioContentService( @Transactional fun uploadComplete(contentId: Long, content: String, duration: String) { - val keyFileName = content.split("/").last() - if (!keyFileName.startsWith(contentId.toString())) throw SodaException(messageKey = "common.error.invalid_request") + val registeredOutputMatches = subtitleJobService.matchesOutput(contentId, content) + val validOutput = registeredOutputMatches ?: content.substringAfterLast("/").startsWith(contentId.toString()) + if (!validOutput) throw SodaException(messageKey = "common.error.invalid_request") val audioContent = repository.findByIdOrNull(contentId) ?: throw SodaException(messageKey = "common.error.invalid_request") @@ -978,7 +999,19 @@ class AudioContentService( nextContent = nextContent, buyerList = buyerList, 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!! ) - val contentUrl = if (isExistsAudioContent) { - audioContentCloudFront.generateSignedURL( - resourcePath = audioContent.content!!, - expirationTime = 1000 * 60 * 60 * (audioContent.duration!!.split(":")[0].toLong() + 2) + return if (isExistsAudioContent) { + val expirationTime = 1000 * 60 * 60 * (audioContent.duration!!.split(":")[0].toLong() + 2) + GenerateUrlResponse( + 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 { - "" + GenerateUrlResponse("") } - - return GenerateUrlResponse(contentUrl) } @Transactional(readOnly = true) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/GenerateUrlResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/GenerateUrlResponse.kt index 9a51cfea..381e2dba 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/GenerateUrlResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/GenerateUrlResponse.kt @@ -1,5 +1,8 @@ package kr.co.vividnext.sodalive.content +import kr.co.vividnext.sodalive.v2.subtitle.dto.ContentSubtitleResponse + data class GenerateUrlResponse( - val contentUrl: String + val contentUrl: String, + val subtitle: ContentSubtitleResponse? = null ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt index 69f83294..8dba2e79 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt @@ -4,6 +4,7 @@ import com.querydsl.core.annotations.QueryProjection import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListItem import kr.co.vividnext.sodalive.content.order.OrderType import kr.co.vividnext.sodalive.content.translation.TranslatedContent +import kr.co.vividnext.sodalive.v2.subtitle.dto.ContentSubtitleResponse data class GetAudioContentDetailResponse( val contentId: Long, @@ -42,7 +43,8 @@ data class GetAudioContentDetailResponse( val nextContent: OtherContentResponse?, val buyerList: List, val isAvailableUsePoint: Boolean, - val translated: TranslatedContent? + val translated: TranslatedContent?, + val subtitle: ContentSubtitleResponse? = null ) data class OtherContentResponse @QueryProjection constructor( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt index e4fbe1b2..30221531 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt @@ -19,6 +19,7 @@ import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminA import org.springframework.data.domain.PageRequest import org.springframework.http.HttpStatus import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation import org.springframework.transaction.annotation.Transactional import org.springframework.web.multipart.MultipartFile import java.time.DateTimeException @@ -158,7 +159,7 @@ class AiCharacterAdminAudioContentFacade( ) } - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) fun create( characterId: Long, coverImage: MultipartFile, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleExceptionHandler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleExceptionHandler.kt new file mode 100644 index 00000000..5dc5b0e2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleExceptionHandler.kt @@ -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> = + ResponseEntity.status(exception.status).body(mapOf("error" to "Subtitle request rejected")) + + @ExceptionHandler(HttpMessageNotReadableException::class) + fun handleUnreadableBody(): ResponseEntity> = + ResponseEntity.unprocessableEntity().body(mapOf("error" to "Subtitle request rejected")) + + @ExceptionHandler(Exception::class) + fun handleUnexpectedException(): ResponseEntity> = + ResponseEntity.internalServerError().body(mapOf("error" to "Subtitle request failed")) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleJobController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleJobController.kt new file mode 100644 index 00000000..a4908eb8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleJobController.kt @@ -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 { + service.started(jobId, parser.started(body)) + return emptyMap() + } + + @PostMapping("/callback", consumes = ["application/json"]) + fun callback(@RequestBody body: ByteArray): Map { + service.callback(parser.callback(body), body) + return emptyMap() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleRequestParser.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleRequestParser.kt new file mode 100644 index 00000000..4125f55b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleRequestParser.kt @@ -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) { + 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") +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/application/SubtitleJobService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/application/SubtitleJobService.kt new file mode 100644 index 00000000..c12255b6 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/application/SubtitleJobService.kt @@ -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") +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt index b04bc186..5704bf6f 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt @@ -27,6 +27,7 @@ import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference import kr.co.vividnext.sodalive.support.originalLocalizedNameService 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.subtitle.application.SubtitleJobService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse 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.OutputCaptureExtension import org.springframework.context.ApplicationEventPublisher +import org.springframework.transaction.PlatformTransactionManager import java.time.LocalDateTime import java.util.Optional @@ -66,6 +68,7 @@ class AudioContentServiceTest { private lateinit var contentThemeTranslationRepository: ContentThemeTranslationRepository private lateinit var homeFollowingNewsPublishService: HomeFollowingNewsPublishService private lateinit var memberContentPreferenceService: MemberContentPreferenceService + private lateinit var subtitleJobService: SubtitleJobService private lateinit var service: AudioContentService @@ -92,6 +95,8 @@ class AudioContentServiceTest { contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java) homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::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( repository = repository, @@ -119,6 +124,8 @@ class AudioContentServiceTest { contentThemeTranslationRepository = contentThemeTranslationRepository, homeFollowingNewsPublishService = homeFollowingNewsPublishService, memberContentPreferenceService = memberContentPreferenceService, + subtitleJobService = subtitleJobService, + transactionManager = Mockito.mock(PlatformTransactionManager::class.java), audioContentBucket = "audio-bucket", coverImageBucket = "cover-bucket", coverImageHost = "https://cdn.test" diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt index 7b146d31..4193c4fd 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt @@ -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.jsonPath 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.support.TransactionTemplate import java.net.URL import java.time.Instant import java.time.LocalDateTime @@ -66,6 +69,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( private val chatCharacterService: ChatCharacterService, private val audioContentService: AudioContentService, private val creatorAdminContentService: CreatorAdminContentService, + private val transactionTemplate: TransactionTemplate, private val entityManager: EntityManager ) { @MockBean @@ -79,6 +83,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( private lateinit var originalAudioContentServicePublisher: ApplicationEventPublisher private lateinit var originalCreatorAdminContentServicePublisher: ApplicationEventPublisher + private var originalContentIds: Set = emptySet() @BeforeEach fun replaceActualServicePublishers() { @@ -88,15 +93,46 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( creatorAdminContentService, applicationEventPublisher ) + originalContentIds = entityManager.createQuery("select c.id from AudioContent c", java.lang.Long::class.java) + .resultList.map { it.toLong() }.toSet() } @AfterEach fun restoreActualServicePublishers() { replacePublisher(audioContentService, originalAudioContentServicePublisher) 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 + @Transactional(propagation = Propagation.NOT_SUPPORTED) @DisplayName("생성은 레거시 multipart request로 target 소유 콘텐츠 ID를 반환한다") fun shouldCreateOwnedContentWithUtcMultipartRequest() { val character = chatCharacterService.createChatCharacterWithDetails( @@ -105,9 +141,12 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( description = "description", systemPrompt = "prompt" ) - val theme = AudioContentTheme(theme = "create-theme", image = "theme.png") - entityManager.persist(theme) - entityManager.flush() + val theme = transactionTemplate.execute { + val newTheme = AudioContentTheme(theme = "create-theme", image = "theme.png") + entityManager.persist(newTheme) + entityManager.flush() + newTheme + }!! Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString())) .thenReturn(URL("https://s3.example.com/test")) @@ -1006,12 +1045,14 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor( } private fun registerMysqlDateFunctions() { - entityManager.createNativeQuery( - "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" - ).executeUpdate() - entityManager.createNativeQuery( - "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" - ).executeUpdate() + transactionTemplate.executeWithoutResult { + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" + ).executeUpdate() + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" + ).executeUpdate() + } } private fun adminAuthentication() = authentication( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt index ee40bf3e..c2024f85 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt @@ -14,6 +14,7 @@ import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertSame 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.jsonPath 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.support.TransactionTemplate @@ -72,15 +74,42 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor( private lateinit var applicationEventPublisher: ApplicationEventPublisher private lateinit var originalAudioContentServicePublisher: ApplicationEventPublisher + private var originalContentIds: Set = emptySet() @BeforeEach fun replaceActualServicePublisher() { originalAudioContentServicePublisher = replaceAudioContentServicePublisher(applicationEventPublisher) + originalContentIds = entityManager.createQuery("select c.id from AudioContent c", java.lang.Long::class.java) + .resultList.map { it.toLong() }.toSet() } @AfterEach fun restoreActualServicePublisher() { 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 @@ -319,6 +348,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor( } @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) @DisplayName("생성은 존재하지 않는 themeId를 업로드 전 invalid request로 거부한다") fun shouldRejectMissingThemeBeforeUpload() { val character = chatCharacterService.createChatCharacterWithDetails( @@ -360,6 +390,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor( ], delimiter = '|' ) + @Transactional(propagation = Propagation.NOT_SUPPORTED) @DisplayName("생성은 잘못된 preview 시간을 업로드 전 기존 오류로 거부한다") fun shouldRejectInvalidPreviewTimeBeforeUpload(previewCase: String, language: String, message: String) { val character = chatCharacterService.createChatCharacterWithDetails( @@ -539,8 +570,8 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor( @Test @Transactional(propagation = Propagation.NOT_SUPPORTED) - @DisplayName("생성은 audio 업로드 실패 시 DB와 이벤트를 남기지 않고 cover 업로드 호출만 남긴다") - fun shouldRollBackDatabaseAndEventWhenAudioUploadFails() { + @DisplayName("audio 업로드 실패는 이미 commit된 등록을 보존하고 업로드 오류를 기록하며 이벤트는 발행하지 않는다") + fun shouldKeepCommittedRegistrationAndRecordErrorWhenAudioUploadFails() { val character = chatCharacterService.createChatCharacterWithDetails( characterUUID = "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.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 @Transactional(propagation = Propagation.NOT_SUPPORTED) - @DisplayName("생성은 이벤트 발행 실패 시 DB를 롤백하고 두 S3 업로드 호출만 남긴다") - fun shouldRollBackDatabaseWhenCreateEventPublishFails() { + @DisplayName("생성 후 이벤트 실패는 이미 commit된 원본/job binding을 롤백하지 않는다") + fun shouldKeepCommittedRegistrationWhenCreateEventPublishFails() { val character = chatCharacterService.createChatCharacterWithDetails( characterUUID = "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(applicationEventPublisher).publishEvent(Mockito.any(Any::class.java)) - assertNull(findAudioContentByTitle("audio")) + assertNotNull(findAudioContentByTitle("audio")!!.latestSubtitleJobId) } @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) @DisplayName("생성은 timezone 없이 UTC releaseDate를 저장하고 data.contentId만 반환한다") fun shouldCreateWithUtcReleaseDateAndReturnContentId() { val character = chatCharacterService.createChatCharacterWithDetails( @@ -660,6 +700,7 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor( } @ParameterizedTest + @Transactional(propagation = Propagation.NOT_SUPPORTED) @ValueSource(strings = ["", ",\"releaseDate\":null"]) @DisplayName("생성은 생략 또는 null releaseDate를 허용한다") fun shouldAllowOmittedOrNullReleaseDate(releaseDateField: String) { @@ -693,12 +734,12 @@ class AiCharacterAdminAudioContentCreateTest @Autowired constructor( .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") entityManager.persist(theme) entityManager.flush() - return theme - } + theme + }!! private fun requestPart( themeId: Long, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentQueryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentQueryTest.kt index 35645d33..117a9357 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentQueryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentQueryTest.kt @@ -182,7 +182,8 @@ class AiCharacterAdminAudioContentQueryTest @Autowired constructor( "nextContent", "buyerList", "isAvailableUsePoint", - "translated" + "translated", + "subtitle" ), data.fieldNames().asSequence().toSet() ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/LegacyCreatorAdminAudioContentCharacterizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/LegacyCreatorAdminAudioContentCharacterizationTest.kt index 69b2af34..7a72e73d 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/LegacyCreatorAdminAudioContentCharacterizationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/LegacyCreatorAdminAudioContentCharacterizationTest.kt @@ -35,7 +35,9 @@ import org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.PageRequest import org.springframework.mock.web.MockMultipartFile import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.annotation.Propagation import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionTemplate import java.net.URL import java.time.LocalDateTime import javax.persistence.EntityManager @@ -48,7 +50,8 @@ class LegacyCreatorAdminAudioContentCharacterizationTest @Autowired constructor( private val repository: CreatorAdminContentRepository, private val hashTagRepository: HashTagRepository, private val contentPriceChangeLogRepository: ContentPriceChangeLogRepository, - private val entityManager: EntityManager + private val entityManager: EntityManager, + private val transactionTemplate: TransactionTemplate ) { private lateinit var audioContentCloudFront: AudioContentCloudFront @@ -81,12 +84,16 @@ class LegacyCreatorAdminAudioContentCharacterizationTest @Autowired constructor( } @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) @DisplayName("기존 콘텐츠 생성은 표지와 오디오를 업로드하고 처리 대기 콘텐츠를 저장한다") fun shouldCreateContentThroughLegacyUploadPipeline() { - val owner = saveMember("legacy-create-owner") - val theme = AudioContentTheme(theme = "legacy-create-theme", image = "theme.png") - entityManager.persist(theme) - entityManager.flush() + val (owner, theme) = transactionTemplate.execute { + val owner = saveMember("legacy-create-owner") + val theme = AudioContentTheme(theme = "legacy-create-theme", image = "theme.png") + entityManager.persist(theme) + entityManager.flush() + owner to theme + }!! val response = audioContentService.createAudioContent( contentFile = MockMultipartFile("contentFile", "audio.mp3", "audio/mpeg", byteArrayOf(1, 2, 3)), @@ -96,9 +103,7 @@ class LegacyCreatorAdminAudioContentCharacterizationTest @Autowired constructor( """.trimIndent(), member = owner ) - entityManager.flush() - - val content = entityManager.find(AudioContent::class.java, response.contentId) + val content = transactionTemplate.execute { entityManager.find(AudioContent::class.java, response.contentId) }!! assertEquals("legacy created audio", content.title) assertEquals(owner.id, content.member!!.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( "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" ).executeUpdate() diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/SubtitleHttpIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/SubtitleHttpIntegrationTest.kt new file mode 100644 index 00000000..cb1fefb4 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/SubtitleHttpIntegrationTest.kt @@ -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(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(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(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(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(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(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 = 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 { + 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() + } + } +} From 4c1d876d5e06cc06b6f97c526eab5376df4f1998 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 11 Nov 2026 08:35:29 +0900 Subject: [PATCH 4/7] =?UTF-8?q?docs(subtitle):=20=EC=9E=90=EB=A7=89=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EA=B2=80=EC=A6=9D=EA=B3=BC=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=EC=A1=B0=EA=B1=B4=EC=9D=84=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../deployment-gates.md | 141 +++++++++++++ docs/20261110_자막Phase1연동/final-report.md | 124 +++++++++++ .../generate-url-report.md | 101 +++++++++ .../implementation-report.md | 192 ++++++++++++++++++ docs/20261110_자막Phase1연동/plan-task.md | 2 + .../reviews/backend-gate-review.md | 47 +++++ .../reviews/generate-url-review.md | 16 ++ 7 files changed, 623 insertions(+) create mode 100644 docs/20261110_자막Phase1연동/deployment-gates.md create mode 100644 docs/20261110_자막Phase1연동/final-report.md create mode 100644 docs/20261110_자막Phase1연동/generate-url-report.md create mode 100644 docs/20261110_자막Phase1연동/implementation-report.md create mode 100644 docs/20261110_자막Phase1연동/reviews/backend-gate-review.md create mode 100644 docs/20261110_자막Phase1연동/reviews/generate-url-review.md diff --git a/docs/20261110_자막Phase1연동/deployment-gates.md b/docs/20261110_자막Phase1연동/deployment-gates.md new file mode 100644 index 00000000..de4eddb5 --- /dev/null +++ b/docs/20261110_자막Phase1연동/deployment-gates.md @@ -0,0 +1,141 @@ +# 자막 Phase 1 배포 게이트 + +**현재 판정: 배포 BLOCKED.** 이 문서는 로컬 저장 계약과 배포 전 확인 항목이며 DB 적용·AWS 배포 증거가 아니다. +기준일은 2026-11-10이며 [PRD](prd.md), [구현 계획](plan-task.md), [DDL](subtitle-schema.sql)을 함께 본다. + +## 1. 검토 기준과 파일 경계 + +- persistence 기준 경로: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/`. +- 실제 읽은 파일: `SubtitleJob.kt`, `Subtitle.kt`, `SubtitleCallbackReceipt.kt` 및 세 repository. +- 콘텐츠 기준: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt`, `common/BaseEntity.kt`. +- `src/main/resources/application.yml`의 `MySQL8Dialect`·`ddl-auto: validate`는 로컬 설정 근거일 뿐 운영 DB 증거가 아니다. +- 문서 담당은 이 문서와 DDL만 작성한다. 병렬 Kotlin 구현의 최종 엔티티 대조는 상위 구현 게이트에서 다시 수행한다. + +## 2. 저장 계약 대조 + +물리명은 기존 Spring snake_case 매핑 기준이다. 길이 미지정 `String`은 JPA 기본 `VARCHAR(255)`를 따른다. +`jobId`는 UUID **문자열**이며 `BINARY(16)`으로 바꾸지 않는다. `N`은 NOT NULL, `Y`는 NULL 허용이다. + +| `SubtitleJob.kt` 속성 | DDL 컬럼·타입 | NULL | +|---|---|---| +| `jobId` | `job_id VARCHAR(36)` PK | N | +| `contentId` | `content_id BIGINT` signed | N | +| `sourceBucket`, `sourceKey` | `source_bucket`, `source_key VARCHAR(255)`; 전체 두 컬럼 unique | N | +| `outputKey` | `output_key VARCHAR(255)` | N | +| `status` | `status VARCHAR(32)`; enum STRING | N | +| `errorCode` | `error_code VARCHAR(255)` | Y | +| `errorMessage` | `error_message TEXT` | Y | +| `sourceUploadErrorCode` | `source_upload_error_code VARCHAR(255)` | Y | +| `sourceUploadErrorAt` | `source_upload_error_at TIMESTAMP`; 명시적 JPA TIMESTAMP | Y | +| `createdAt`, `updatedAt` | `created_at`, `updated_at TIMESTAMP` | N | + +| `Subtitle.kt` 속성 | DDL 컬럼·타입 | NULL | +|---|---|---| +| `id` | `id BIGINT` signed PK AUTO_INCREMENT | N | +| `jobId` | `job_id VARCHAR(36)` FK → `subtitle_job.job_id` | N | +| `language` | `language VARCHAR(35)`; `(job_id, language)` unique | N | +| `format` | `format VARCHAR(16)` | N | +| `s3Bucket`, `s3Key` | `s3_bucket`, `s3_key VARCHAR(255)` | N | + +| 기존 엔티티 속성 | DDL 컬럼·타입 | NULL | +|---|---|---| +| `SubtitleCallbackReceipt.eventId` | `event_id VARCHAR(255)` PK; UUID 제한 없음 | N | +| `SubtitleCallbackReceipt.jobId` | `job_id VARCHAR(36)` | N | +| `SubtitleCallbackReceipt.bodyHash` | `body_hash VARCHAR(64)` | N | +| `SubtitleCallbackReceipt.createdAt` | `created_at TIMESTAMP` | N | +| `AudioContent.latestSubtitleJobId` | `content.latest_subtitle_job_id VARCHAR(36)` | Y | + +### 미확정 차이와 적용 전 대조 + +1. 마지막 정적 대조에서 `Subtitle.kt`의 테이블명·6개 컬럼·길이·nullable·IDENTITY PK·unique가 합의 계약과 일치했다. + `SubtitleJob.kt`에서도 `koKey`/`jaKey` 제거를 확인했다. `Subtitle.jobId`는 scalar이고 JPA 관계/FK 선언은 없다. + DDL의 `subtitle.job_id` FK는 합의 계약에 따른 DB 제약이며 ORM 관계 선언 확인으로 보고하지 않는다. + `subtitle_job` CREATE에 `ko_key`/`ja_key`는 넣지 않는다. 미출시 초안 교체이며 기존 데이터 이관·컬럼 삭제 SQL은 없다. +2. 최초 대조에서는 `sourceUploadErrorAt`에 `columnDefinition`이 없어 차이로 기록했다. + 최종 구현은 `@Column(columnDefinition = "TIMESTAMP")`와 초 절삭을 명시했고 부모가 DDL과 다시 대조했다. + 이 로컬 mapping 차이는 해소됐지만 실제 MySQL schema 적용·왕복 검증은 여전히 운영 게이트다. +3. 나머지 시각은 엔티티의 명시적 `TIMESTAMP`와 초 절삭을 따른다. DDL 소수초 정밀도는 모두 0이다. + `created_at`은 `DEFAULT CURRENT_TIMESTAMP`, job의 `updated_at`은 같은 기본값과 `ON UPDATE CURRENT_TIMESTAMP`다. + `Subtitle.kt`에는 시각이나 `BaseEntity` 상속이 없으므로 결과 행에 임의 시각 컬럼을 추가하지 않는다. + UTC 생성값과 JDBC/session timezone 및 실제 MySQL 왕복은 미검증이다. +4. `AudioContent`는 `content` 테이블이며 `BaseEntity.id: Long?`의 IDENTITY를 상속한다. + 신규 `content_id`는 signed BIGINT로 맞췄지만 운영 `content.id`의 실제 타입·부호·엔진은 확인하지 않았다. + 합의된 `subtitle.job_id` FK만 둔다. receipt의 jobId 및 콘텐츠 연결은 현재 scalar 매핑이므로 FK를 임의 추가하지 않는다. + FK cascade는 없으며 기본 RESTRICT다. `(job_id, language)` unique가 결과 FK와 작업별 조회 인덱스를 겸한다. +5. 현재 callback 활성화는 `ko-KR`/`ja-JP`, 형식은 `SRT`, 결과 2/1/0개의 상태 판정은 그대로다. + 저장 구조만 언어별 행으로 바꾼다. 새 언어 활성화·재생성·새 관리 API·recovery 상태/테이블은 추가하지 않는다. + +### 식별자와 인덱스 전제 + +- 세 신규 테이블과 최신 job 컬럼은 `utf8mb4_0900_bin`을 쓴다. 대소문자·후행 공백을 구별하는 NO PAD 비교다. + eventId 및 bucket/key의 정확한 식별에 case-insensitive collation이나 PAD SPACE인 `utf8mb4_bin`을 대신 쓰지 않는다. +- MySQL 8.0.17 이상 및 해당 collation의 binary/NO PAD 속성, InnoDB DYNAMIC·3072-byte 키 지원을 운영자가 확인한다. + 16 KiB 이상 InnoDB page를 전제로 하며 4/8 KiB page 환경의 적합성을 주장하지 않는다. +- source unique 최대 키 데이터는 `(255 + 255) × 4 = 2040` bytes다. eventId PK는 `255 × 4 = 1020` bytes다. + 결과 unique는 `(36 + 35) × 4 = 284` bytes다. 보조 인덱스의 PK 포함분도 3072 bytes 미만이다. + prefix unique나 해시 식별자로 원래 bucket/key를 대체하지 않는다. +- 경로 컬럼은 현재 UUID 기반 계약에 맞는 `VARCHAR(255)`이며 일반 S3 최대 길이 전체 지원을 뜻하지 않는다. + job의 `(content_id, created_at)` 인덱스는 현재 `findFirstByContentIdOrderByCreatedAtAsc` 조회에 대응한다. + +## 3. Lambda handoff와 실제 환경 구분 + +아래 SHA-256은 **PRD §2의 기존 조사 기록 인용**이다. 문서 담당이 외부 저장소·ZIP·AWS를 다시 조회한 결과가 아니다. +원본 보고서 참조는 PRD에 적힌 `aws-subtitle-service/docs/FINAL_REPORT.md`다. + +| 정확한 artifact | SHA-256 | +|---|---| +| `start-subtitle-transcription.zip` | `a0d0b19b2ecb24c0f89caf528bf09fd039558563a99063f082ac64418704abfd` | +| `generate-subtitle.zip` | `bfa8742ca436bace853ec2540fc5f70daa24cbab49e75315565d980189fbf209` | + +로컬 artifact 일치 기록은 운영 함수 버전/alias·event routing·consumer·in-flight·Scheduler·DB 적용 증거가 아니다. +자격 증명/JWT/raw body 값은 이 문서나 증거 보고서에 기록하지 않는다. + +## 4. 운영 차단 항목 + +| 게이트 | 현재 | 해제에 필요한 증거 | +|---|---|---| +| 최종 엔티티·DB | BLOCKED | 위 차이 해소, 운영 기존 스키마·버전·collation·인덱스 확인, 별도 DDL 승인·적용 기록 | +| consumer·조정 배포 | BLOCKED | 운영자 inventory, 실행 중일 때만 pause/drain, backend·정확한 두 Lambda의 조정 배포 기록 | +| MediaConvert·CloudFront | BLOCKED | UUID 입력의 ID 추출/출력 확장자 및 private origin·서명 URL 실환경 검증 | +| controlled live | BLOCKED | ko/ja/mixed의 실제 S3 → callback → 콘텐츠/앱 결과와 word/item 언어 증거 | +| 대표 2시간 입력 | BLOCKED·미측정 | 신뢰 가능한 duration 근거 및 대표 입력의 전체 처리 예산 실측 | + +### 운영자 실행 순서: 별도 승인 후에만 + +1. 운영 consumer·event routing·함수 버전/alias·in-flight·기존 DB 상태를 inventory로 확정한다. + 실행 중인 consumer가 있을 때만 신규 유입을 pause하고 기존 작업·callback을 drain하는 전환 구간을 조율한다. + 실행 중인 대상이 없으면 그 증거와 함께 pause/drain을 생략한다. 미확인 상태를 비가동으로 간주하지 않는다. +2. 최종 JPA와 실제 `SHOW CREATE TABLE`·collation·인덱스 전제를 대조하고 DB 변경을 별도 승인받는다. + 기존 동명 테이블/컬럼이 있으면 중단하고 차이를 검토한다. `IF NOT EXISTS`로 불일치를 숨기지 않는다. + additive DDL 승인·적용 확인과 Phase 1 backend/두 Lambda의 배포를 같은 전환 계획으로 조율한다. + 구·신 consumer의 중복 실행이나 호환성 미확인 in-flight를 남긴 채 재개하지 않는다. +3. UUID 원본 `input/{contentId}/{UUID filename}`으로 외부 MediaConvert의 contentId 추출 규칙을 확인한다. + 등록은 안전한 실제 파일 확장자를 UUID 뒤에 보존한다. 실제 START ZIP은 지원 확장자를 검사하므로 + 누락/미지원 확장자의 자막 성공을 주장하지 않는다. 이를 위해 기존 업로드를 거부하거나 파일을 허위 변환하지 않는다. + 출력 경로·실제 확장자·완료 callback·기존 preview/알림 흐름의 호환 증거가 필요하다. + 로컬 `matchesOutput`의 허용 확장자는 `aac`/`m4a`/`mp3`이나 실제 외부 출력 계약이 확인된 것은 아니다. + 자막 bucket/key가 기존 private CloudFront origin/path에 정확히 매핑되고 권한 있는 사용자만 서명 URL로 + 열람하는지 확인한다. 두 항목 모두 현재 미검증이며 임의 prefix 허용이나 공개 S3 URL로 우회하지 않는다. + 현재 Spring의 자막 bucket allowlist는 기존 `cloud.aws.s3.content-bucket` 한 개다. + Lambda `MEDIA_BUCKET`과 `SUBTITLE_BUCKET`도 이 값에 맞춰야 하며, 별도 자막 bucket을 쓰려면 + allowlist와 CloudFront origin 계약을 먼저 변경·검증해야 한다. 현재 구현이 다중 bucket을 지원한다고 보지 않는다. +4. 일반 유입을 재개하기 전에 controlled ko/ja/mixed live를 수행한다. + S3 source binding, Transcribe 실제 word/item 언어, 결과 SRT, BOT callback, 언어별 콘텐츠/앱 표시를 연결해 기록한다. + mixed는 실제 word/item 언어 증거가 필수이며 synthetic fixture나 HTTP metadata 검증으로 대체할 수 없다. + 유료 preview-only 사용자의 전체 자막 차단과 private 접근도 확인한다. +5. 신뢰 가능한 기존 duration의 가용성과 입력 검증 경계를 확인하고 대표 2시간 입력의 처리 예산을 실측한다. + 구형/검증 불가능 원본은 수동 게이트로 남기며 임의 client duration을 신뢰하지 않는다. + 2시간은 입력 목표이지 SLA/지원 보장이 아니다. 미측정 상태에서 지원 또는 재개 승인을 하지 않는다. + 모든 차단 항목의 증거와 승인자를 기록한 뒤에만 일반 유입을 재개한다. + +purge·데이터 정리·적용 migration 수정·Scheduler fallback·자동 복구·Translate 50-call 정책은 이 절차에 없다. +재생성 전체와 새 언어 활성화 역시 제외한다. 미해결 작업을 삭제하거나 성공 처리해 drain을 가장하지 않는다. + +## 5. 문서 담당 검증 경계 + +문서 간 계약·실제 엔티티 속성·컬럼별 COMMENT·테이블 COMMENT·SQL 종류·공백을 read/rg로 정적으로 대조했다. +DDL은 CREATE TABLE 3개와 ALTER TABLE ADD COLUMN 1개뿐이며 23개 신규 컬럼·3개 신규 테이블에 COMMENT가 있다. +후행 공백·탭·CR·130자 초과 행은 발견하지 않았다. SQL 파싱/실행이나 물리 스키마 호환성을 검증한 결과가 아니다. +이 담당은 테스트·빌드·DB 실행·AWS 접근·외부 저장소 조회를 하지 않으며 성공 결과를 주장하지 않는다. +상위 구현 검증도 이번 작업에서 새로 작성한 테스트만 실행하도록 승인되어 있다. 기존 테스트 실행은 승인 범위가 아니다. +로컬 HTTP·새 테스트·빌드 결과는 구현 담당의 별도 증거이며, 그 성공도 위 운영 게이트를 해제하지 않는다. diff --git a/docs/20261110_자막Phase1연동/final-report.md b/docs/20261110_자막Phase1연동/final-report.md new file mode 100644 index 00000000..e6b8faa2 --- /dev/null +++ b/docs/20261110_자막Phase1연동/final-report.md @@ -0,0 +1,124 @@ +# 자막 Phase 1 Spring 구현 검증 기록 + +> 상태: **재생성 제외 로컬 backend 구현·신규 테스트 15개·독립 리뷰 완료**. 운영 배포 완료가 아니다. + +## 범위와 결정 + +- 이번 범위: 기존 업로드의 PENDING/source 선등록, 네 BOT 내부 endpoint, raw-body receipt, 기존 상세의 언어·권한별 자막 제공. +- 재생성은 사용자 결정으로 제외했다. 새 공개 재생성 API, 직접 START 호출, Scheduler/outbox를 만들지 않는다. +- 후속 요구로 언어별 결과는 `Subtitle` 행에 저장한다. 이번 callback 허용 언어/완료 판정은 ko/ja만 유지하며 다른 언어를 활성화하지 않는다. +- 테스트 실행은 사용자 후속 지시 이후 **이번 작업에서 새로 작성한 테스트만** 대상으로 한다. 기존 클래스에 추가한 사례도 메서드 selector로 한정한다. + +## 실제 조사와 보존 + +- 현재 Spring에 subtitle/recovery/자막 HMAC/재생성 action이 발견되지 않았다. 제거할 해당 코드가 없으므로 실제 삭제 없음. +- 공유 member login/JWT/MemberRole.BOT, Agora HMAC, 텍스트 TranslationJob/worker, S3Uploader와 기존 MediaConvert 관련 외부 코드/설정은 보존 대상이다. +- 기존 MediaConvert 완료 API의 ADMIN/BOT 권한은 유지한다. 새 원본 UUID에 대한 source-bound 경로 검증과 기존 파일 검증을 구분한다. 실제 consumer ID 추출·출력 규칙 호환성은 운영 확인 전 배포 BLOCKED다. +- Flyway/Liquibase 의존성 및 자막 migration은 현재 프로젝트에서 발견되지 않았다. 운영/공유 환경의 적용 schema/data 상태는 조회하지 않았다. additive SQL 문서만 준비하며 DB에는 적용하지 않는다. + +## Lambda와 운영 전환 + +명시된 `aws-subtitle-service/docs/FINAL_REPORT.md`와 실제 로컬 ZIP을 대조했다. + +| 산출물 | SHA-256 | +|---|---| +| `start-subtitle-transcription.zip` | `a0d0b19b2ecb24c0f89caf528bf09fd039558563a99063f082ac64418704abfd` | +| `generate-subtitle.zip` | `bfa8742ca436bace853ec2540fc5f70daa24cbab49e75315565d980189fbf209` | + +파일 hash 일치는 AWS 배포 증거가 아니다. 운영 legacy consumer/caller/in-flight/Scheduler/배포 버전은 미확인이다. AWS 호출·설정·배포·중지·삭제·재개를 수행하지 않았다. 운영자는 inventory 후 실제 가동 중인 경우에만 pause/drain하고 Phase 1 backend/Lambda를 조정 배포해야 한다. S3/DB purge나 legacy fake work API는 금지한다. + +## 실행 기록 + +| 시점 | 명령/검증 | 관찰 결과 | +|---|---|---| +| 테스트 범위 변경 지시 전 | 기존 `AudioContentServiceTest`, `MemberLoginResponseContractTest`, `TokenProviderTest` focused baseline | 부모 실행은 kapt 단계 200초 제한 종료, 테스트 판정 없음 | +| 테스트 범위 변경 지시 전 | 구현 담당자의 격리 focused 시도 | `red-focused.log`: 41 cases 중 새 사례의 JWT key setup 1건 실패. 기능 RED로 인정하지 않음. 기존 테스트 실행은 이후 중단 | +| RED | 새 `SubtitleHttpIntegrationTest.shouldRejectActualNonBotLoginJwt` | `red-http.log`: 실제 로그인한 non-BOT의 자막 접근 차단 실패를 재현. 최종 GREEN 아님 | +| 1차 새 테스트/HTTP | 부모가 신규 `SubtitleHttpIntegrationTest` 직접 재실행 | **13 passed, failures/errors/skipped 0**. XML timestamp `2026-11-10T14:33:59`, `BUILD SUCCESSFUL in 2m 21s` | +| bootJar/변경 범위 lint | 구현 담당 실제 실행 및 부모 같은 코드 재확인 | `build-lint.log`: `BUILD SUCCESSFUL in 1m 28s`; 부모 재확인은 UP-TO-DATE | +| 리뷰 수정 RED | 신규 HTTP 2개 | 빈 body 422 기대/200 실제, DB 실패 500 기대/200 실제 확인 | +| 최종 새 테스트/빌드/lint | `P1-R1` 수정 후 신규 자막 클래스 전체 | **15 passed, failures/errors/skipped 0**, bootJar/변경 lint 실제 실행 성공, 7m44s | +| 부모 최종 HTTP 재검증 | 추가된 새 2개 메서드만 직접 실행 | **2 passed**, 422/500 및 전체 rollback 확인. XML timestamp `2026-11-10T15:34:43`, 4m55s | +| 독립 리뷰 | 1차 REQUEST_CHANGES 1건 수정 후 delta 재검토 | **REV-P1-001 ADDRESSED / 로컬 backend APPROVE**, 추가 blocker 없음 | + +테스트 fixture는 로컬 합성 데이터와 H2, 실제 localhost HTTP, 외부 S3/서명 경계 대체를 사용한다. 실제 음성 또는 live S3 품질 검증이 아니다. LSP diagnostics 도구가 제공되지 않아 실제 Kotlin compile/lint 결과를 대체 증거로 기록했으며 LSP 성공을 주장하지 않는다. + +실제 검증한 HTTP 결과: BOT 허용 및 non-BOT 403/익명 401, unknown 404, callback 수락/replay 200, +같은 eventId의 변경 body 422, terminal 다른 결과 409, 동시 receipt 경합 200/422 및 loser rollback. +기존 콘텐츠 상세에서 preview-only의 자막 null, 구매/소유자/무료 및 공개 시점별 ko/ja 선택을 검증했다. +동일 job 결과는 언어별 행 ID를 보존하며, 이전 job 완료가 최신 선택값을 바꾸지 않는 것을 확인했다. + +실제 HTTP 증거는 테스트 JVM PID `23390`, Tomcat `127.0.0.1:59074`, XML timestamp +`2026-11-10T14:33:59`다. 실행 후 `ps -p 23390,20950,22207`, `lsof -iTCP:59074 -sTCP:LISTEN`, +작업 root를 대상으로 한 `pgrep`에서 남은 프로세스/listener가 없음을 확인했다. 임시 산출물은 리뷰 후 정리했다. + +후속 HTTP 검증은 PID `44707`, Tomcat port `59977`에서 실행했다. 빈 callback/started는 422이고, +receipt 저장 실패는 `{"error":"Subtitle request failed"}`와 500을 반환하며 상태/오류/결과/receipt가 rollback된다. +구현 담당의 첫 GREEN은 컴파일 600초 제한으로 종료됐으나 캐시 재사용 후 성공했다. 실패 이력은 삭제하지 않았다. +후속 PID `44707`/`28638`/`37727`/`39291`, port `59977`, 작업 root 참조 프로세스의 부재도 확인했다. + +## 내부 API와 저장 구조 + +| Method | Path | +|---|---| +| GET | `/internal/api/subtitle-jobs/resolve` | +| GET | `/internal/api/subtitle-jobs/{jobId}` | +| POST | `/internal/api/subtitle-jobs/{jobId}/started` | +| POST | `/internal/api/subtitle-jobs/callback` | + +네 API는 기존 로그인 JWT와 활성 DB BOT role을 요구한다. 조회는 최소 DTO이며 callback은 원문 bytes hash를 사용한다. +`subtitle_job`, 언어별 `subtitle`, `subtitle_callback_receipt`와 `content.latest_subtitle_job_id`의 +additive SQL만 작성했다. 실제 DB 적용은 하지 않았다. 미래 언어는 결과 행으로 저장할 수 있지만 +현재 허용 언어는 ko/ja뿐이고 향후 Lambda·API·조회 정책 변경 없이 새 언어가 자동 활성화되지는 않는다. + +공유 로그인/JWT/업로드/MediaConvert는 보존했다. 현재 저장소에는 삭제할 자막 recovery code가 없어서 삭제 파일은 0개다. +외부 프로젝트, AWS 리소스, 적용 migration 및 운영 데이터는 변경하지 않았다. commit/push도 수행하지 않았다. + +전체 변경 경로·신규 case 이름·재현 명령·실패/수정 이력은 [implementation-report.md](implementation-report.md)에 있다. +로컬 mapping의 업로드 오류 시각 TIMESTAMP 불일치는 수정·대조했으나 운영 MySQL 실행 증거는 아니다. +테스트 전체 Spring context에서 기존 worker의 H2/MySQL 구문 차이 로그가 발생했다. 해당 worker 성공을 주장하지 않는다. + +## 외부 차단 항목 + +| 항목 | 상태와 재개 조건 | +|---|---| +| 운영 DB | BLOCKED: 기존 schema 확인 및 additive DDL 승인·적용 필요 | +| old consumer coordination | BLOCKED: 원격 배포/사용/in-flight 조사와 운영자 조정 필요 | +| MediaConvert/CloudFront | BLOCKED: UUID ID 추출·출력 key 규칙 및 자막 private origin/서명 접근 확인 필요 | +| live ko/ja/mixed | BLOCKED: 승인된 실제 S3 입력부터 callback/콘텐츠/앱 표시 검증 필요. mixed는 실제 word/item 언어 evidence 필수 | +| 2시간 입력 목표 | BLOCKED: 실제 처리 시간/예산 미측정. 상한을 내리거나 SLA/지원 성공을 주장하지 않음 | + +전체 테스트는 실행하지 않는다. 사용자가 별도로 전체 검증을 원하면 `./gradlew test`를 실행할 수 있다. 기존 회귀 미실행에 따른 잔여 위험은 새 기능 테스트 성공과 구분한다. + +## 종료 정리와 재현 + +2026-11-11 리뷰 승인 후 이번 작업 소유 임시 root `subtitle-phase1.wiRcAJ`와 그 안의 build/cache/log/JAR를 제거했다. +`test ! -e` exit0으로 부재를 확인했고 공유 캐시·기존 worker는 변경하지 않았다. 위 임시 경로는 과거 검증 위치이며, +검증 결과와 실패 이력은 이 문서 및 구현/리뷰 보고서에 보존했다. + +정상 개발 환경에서 이번에 작성한 테스트만 다시 실행하는 명령: + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest' +``` + +## 후속 완료: generate-url 자막 응답 + +2026-11-11 요청에 따라 `GET /audio-content/{id}/generate-url`에도 상세와 동일한 nullable `subtitle`을 추가했다. +기존 `contentUrl`과 활성 구매 검사/음원 URL 동작은 유지하며 자막만 요청 언어·완료 상태·공개 시점 조건을 따른다. +이번 후속에서 새로 작성한 HTTP3개만 실행했고 부모 재실행도 3/0/0/0으로 통과했다. 독립 리뷰 APPROVE다. +컴파일/bootJar/테스트 lint는 성공했으나 production lint는 시작 전부터 있던 서비스 EOF 공백1건을 지적해 보존했다. +이 결과를 기존15개를 포함한 클래스 전체 재실행 성공으로 표현하지 않는다. +상세 명령/선택자/응답 예시/잔여 게이트는 [generate-url-report.md](generate-url-report.md)에 있다. + +## 커밋 검증 후속 기록 + +2026-11-11 별도 사용자 `/commit-with-validation` 요청으로 변경을 커밋했다. +기능 연동 커밋은 pre-commit `ktlintCheck`에서 위에 기록된 서비스 EOF 빈 줄 1건으로 처음 거부됐다. +해당 빈 줄만 제거한 뒤 재시도하여 staged Kotlin 대상 lint, `git diff --staged --check` 및 +`work/scripts/check-commit-message-rules.sh`의 커밋 직전·직후 검증이 통과했다. +기존 EOF 미해결 기록은 당시 상태이며 이 커밋 준비 과정에서 해소됐다. + +이번 커밋 작업에서는 기능 테스트·전체 테스트·bootJar를 재실행하지 않았다. +기존 테스트 결과는 위 실행 시점의 기록이며 운영 배포 BLOCKED는 유지한다. +사용자가 전체 테스트를 실행할 때의 명령은 `./gradlew test`다. diff --git a/docs/20261110_자막Phase1연동/generate-url-report.md b/docs/20261110_자막Phase1연동/generate-url-report.md new file mode 100644 index 00000000..841efcc7 --- /dev/null +++ b/docs/20261110_자막Phase1연동/generate-url-report.md @@ -0,0 +1,101 @@ +# generate-url 자막 후속 구현 검증 + +## 범위와 결과 + +- 기준: `prd.md` §7, `plan-task.md` `P2-T1`. Phase 1은 재구현하지 않았다. +- 이번 production 변경은 `content/GenerateUrlResponse.kt`의 기존 DTO import/nullable 필드 추가와 `content/AudioContentService.kt`의 `generateUrl` 본문뿐이다. DTO는 `GenerateUrlResponse(contentUrl: String, subtitle: ContentSubtitleResponse? = null)`이다. +- 기존 `orderRepository.isExistOrdered`를 그대로 사용한다. 구매 없는 소유자/무료, 비활성 KEEP, 만료 RENTAL은 빈 `contentUrl`과 명시적 null `subtitle`이다. 활성 구매의 기존 음원 경로/만료 기간/envelope는 유지했다. +- 구매자는 하나의 `expirationTime`을 음원 서명과 `SubtitleJobService.subtitle(audioContent, langContext.lang, expirationTime)`에 전달한다. 자막만 미지정/도래한 공개일 또는 소유자 조건을 적용한다. 예약 구매자의 기존 음원 URL은 유지한다. +- 기존 `SubtitleHttpIntegrationTest`의 15개 메서드/도우미를 보존하고 HTTP 테스트 3개와 세 테스트가 공용으로 사용하는 요청 도우미 1개만 추가했다. 다른 dirty 파일, PRD/plan/final-report, controller/DB/Lambda는 수정하지 않았다. + +## 새 HTTP 테스트 + +클래스: `kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest`. 실행은 아래 세 메서드 selector만 사용했다. + +1. `shouldIncludeRequestedSubtitleInGenerateUrl`: 활성 KEEP 구매/공개 완료 ko·ja, 정확한 `{contentUrl,subtitle}` 및 자막 3필드, 상세와 동일한 자막, 음원/SRT 경로와 같은 14,400,000ms 서명 인자, 기존 success envelope. +2. `shouldReturnNullSubtitleInGenerateUrlWhenUnavailable`: PENDING/최신 job 미등록/부분 완료 ko 누락/미지원 en의 명시적 null과 음원 URL 보존, 부분 완료 ja 정상 제공. +3. `shouldPreserveGenerateUrlPurchaseAndReleaseRestrictions`: 미구매자·소유자·무료 및 비활성/만료 주문 차단, 활성 RENTAL 구매자의 예약 음원 URL 보존/자막 차단, 활성 구매 소유자의 예약 예외, 공개일 null/과거 허용, 익명 401. + +실제 EMAIL 로그인/JWT, RANDOM_PORT Tomcat HTTP, H2와 기존 fixture/callback 도우미를 사용했다. 외부 S3/CloudFront 경계만 기존 mock을 재사용했다. 서명 URL은 전달 경로/만료 인자 검증이며 실제 CloudFront 암호학적 서명이나 다운로드 검증은 아니다. + +## 명령과 증거 + +소유 root는 아래 `VERIFY_ROOT`다. build/project-cache/TMPDIR/Node/Bun 캐시를 격리했으며 init script는 production 소스를 필터링하지 않는다. 테스트 compile만 이 클래스와 필요한 support 3파일로 한정한다. `test.outputs.upToDateWhen { false }`로 실제 실행을 강제한다. + +```bash +export VERIFY_ROOT=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/generate-url-subtitle.fSBkJM/implementer +export TMPDIR="$VERIFY_ROOT/tmp" NODE_COMPILE_CACHE="$VERIFY_ROOT/node-cache" +export npm_config_cache="$VERIFY_ROOT/node-cache" BUN_INSTALL_CACHE_DIR="$VERIFY_ROOT/bun-cache" +export AWS_EC2_METADATA_DISABLED=true +G=(./gradlew --offline --no-daemon --max-workers=2 + -Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs=-Xmx4g + --project-cache-dir "$VERIFY_ROOT/project-cache" + --init-script "$VERIFY_ROOT/verification.init.gradle") +S=(--tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldIncludeRequestedSubtitleInGenerateUrl' + --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldReturnNullSubtitleInGenerateUrlWhenUnavailable' + --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldPreserveGenerateUrlPurchaseAndReleaseRestrictions') + +# RED: production 변경 전 +"${G[@]}" test "${S[@]}" > "$VERIFY_ROOT/red-assertions.log" 2>&1 +# 첫 GREEN +"${G[@]}" test "${S[@]}" ktlintCheck bootJar > "$VERIFY_ROOT/green-build-lint.log" 2>&1 +# 입력 3파일을 고정/확인한 production+test lint +"${G[@]}" verifyLintScope ktlintCheck bootJar > "$VERIFY_ROOT/final-verified-scope-lint-build.log" 2>&1 +# 기존 dirty EOF 보존 후 최종 HTTP/컴파일/테스트 lint/bootJar +"${G[@]}" test "${S[@]}" verifyLintScope ktlintTestSourceSetCheck bootJar > "$VERIFY_ROOT/final-green-build-test-lint.log" 2>&1 +``` + +| 검증 | 결과/증거 | +|---|---| +| RED | exit 1, 50초, 3 tests/3 failures/0 errors/0 skipped. 모두 `has("subtitle")`에서 expected true/actual false. `red-assertions.log`, `red-summary.log` | +| 첫 GREEN | exit 0, `BUILD SUCCESSFUL in 3m 35s`, HTTP 3개 통과 및 compile/bootJar. lint의 정확한 입력 증거는 이후 고정한 실행을 기준으로 한다. | +| 정확한 변경 lint | 입력은 production 2파일+이 테스트 1파일, Kotlin script 0개임을 `verifyLintScope` assertion으로 확인. production lint는 **기존 EOF 빈 줄 1건 실패**: `AudioContentService.kt:1482:1 Needless blank line(s)`. 그 외 지적 없음. `final-verified-scope-lint-build.log` | +| 최종 GREEN | exit 0, `BUILD SUCCESSFUL in 3m 30s`, compileKotlin/compileTestKotlin/bootJar 실제 실행, 테스트 lint 통과. HTTP XML 3 tests/0 failures/0 errors/0 skipped, timestamp `2026-11-10T22:07:38` (UTC). `final-green-build-test-lint.log`, `green-summary.log` | + +최초 임시 init의 상대 source 경로로 `test NO-SOURCE`가 된 `red.log`는 RED 증거가 아니다. `project.file`로 수정 후 실제 assertion RED를 확인했다. `final-scope-lint-build.log`도 lint scope introspection assertion 실패 기록이며 HTTP 실패가 아니다. 이후 init에서 lint 입력 파일을 명시적으로 고정하고 검증했다. 이 이력과 로그를 삭제하지 않았다. + +## 보존 사항과 남은 게이트 + +- 시작 전 `git diff`에도 있던 서비스 EOF 빈 줄은 타 dirty 변경 보존 지시에 따라 유지했다. `git diff --check` 역시 같은 EOF 1건을 지적하므로 lint 전체 clean을 주장하지 않는다. 테스트/새 DTO lint 오류는 없다. +- LSP 도구는 제공되지 않았다. Kotlin compile과 실제 3파일 ktlint 결과로 대체했다. 기존 Kotlin 경고와 테스트 종료 중 Redis reconnect 경고가 있으나 새 테스트 실패/오류는 없다. +- 기존 15개 테스트/기타 기존 테스트/전체 test/check/build는 실행하지 않았다. 사용자 전체 테스트 명령은 `./gradlew test`이며 이번 승인된 자동 검증 범위 밖이다. +- RED PID 14462/HTTP 50806, 첫 GREEN PID 15417/HTTP 50833, 최종 GREEN PID 16741/HTTP 50879는 테스트 종료로 정상 종료했다. 세 PID와 HTTP/Redis port 50806/50833/50879/50803/50831/50877에 `ps`/`lsof` 조회 결과가 없었다. 공유 프로세스를 종료하거나 task 취소를 호출하지 않았다. 부모 재검증을 위해 소유 root/init/log/JAR는 남겼다. +- AWS/외부 저장소/운영 DB/배포/live ko·ja·mixed/2h 처리/앱/실제 CloudFront 다운로드는 미검증이다. 부모 재검증과 독립 리뷰도 이 보고서의 완료 주장에 포함하지 않는다. credential/JWT/request body는 로그/보고서에 출력하지 않았으며 최종 XML과 로그의 관련 패턴 검색 결과도 없었다. + +## 부모 재검증 + +- 같은 격리 init 및 정확히 새 메서드 3개 selector로 직접 재실행했다. `parent-verification.log`의 결과는 exit0, + `BUILD SUCCESSFUL in 37s`이며 test는 실제 실행, bootJar/테스트 lint는 동일 코드의 UP-TO-DATE 확인이다. +- XML: 3 tests/0 failures/0 errors/0 skipped, UTC timestamp `2026-11-10T22:12:51`. 실제 Tomcat PID17759, HTTP port50914다. +- 부모가 DTO·generateUrl의 전후 diff 및 추가 테스트 전체를 읽었다. 공유 자막 조회와 기존 구매 판정·음원 URL이 유지된다. +- 독립 리뷰와 임시 자원 정리는 아래 후속 기록으로 남긴다. 기존 production EOF lint1건은 성공으로 덮어쓰지 않는다. + +## 독립 리뷰 및 사용 계약 + +- 제한 독립 리뷰 `ses_e387b1e8fffensjb6dnoRk82ST`: **APPROVE**. 이번 production delta의 명세·품질 blocker나 새 회귀 없음. +- `data.contentUrl`과 기존 envelope는 유지하며 `data.subtitle`만 추가된다. 요청 언어는 기존 `Accept-Language`/`LangContext`를 따른다. +- 자막 응답 예시: + +```json +{ + "contentUrl": "https://cdn.example.com/audio-signed-url", + "subtitle": { + "language": "ko-KR", + "format": "SRT", + "url": "https://cdn.example.com/subtitle-signed-url" + } +} +``` + +- 구매/공개 조건을 충족하지 못하거나 미완료·해당 언어 자막이 없으면 `subtitle`은 명시적 null이다. 기존 URL 권한을 확대하지 않는다. +- 부모 HTTP PID17759 및 자식 PID14462/15417/16741, port50914, 소유 root 참조 프로세스는 `ps`/`lsof`/`pgrep`에서 부재를 확인했다. +- 리뷰 승인/결과 문서 보존 후 작업 소유 root `generate-url-subtitle.fSBkJM`과 하위 build/cache/log/JAR를 제거했고 `test ! -e` exit0으로 확인했다. 위 임시 log/init 경로는 과거 검증 위치이며 공유 캐시는 건드리지 않았다. + +일반 개발 환경에서 이번 후속 테스트만 실행하는 명령: + +```bash +./gradlew test \ + --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldIncludeRequestedSubtitleInGenerateUrl' \ + --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldReturnNullSubtitleInGenerateUrlWhenUnavailable' \ + --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldPreserveGenerateUrlPurchaseAndReleaseRestrictions' +``` diff --git a/docs/20261110_자막Phase1연동/implementation-report.md b/docs/20261110_자막Phase1연동/implementation-report.md new file mode 100644 index 00000000..cc1e3842 --- /dev/null +++ b/docs/20261110_자막Phase1연동/implementation-report.md @@ -0,0 +1,192 @@ +# 자막 Phase 1 Kotlin 구현 보고 + +## 결과와 검증 경계 + +- 재생성을 제외한 기존 업로드 등록, 네 BOT 내부 API, callback 원자 저장, 기존 콘텐츠 상세의 권한별 ko/ja 자막 조회를 구현했다. +- 새로 작성한 `SubtitleHttpIntegrationTest` 13개는 실제 RANDOM_PORT localhost HTTP/H2에서 전부 통과했다. 외부 AWS 클라이언트와 서명 URL 생성만 대체하고, 회원 서비스·로그인·비밀번호 검증·JWT·DB principal/role은 실제 경로를 사용했다. +- `bootJar`와 변경 Kotlin 파일에 한정한 `ktlintCheck`가 성공했다. LSP 도구는 제공되지 않아 실행하지 않았으며 실제 Kotlin/JPA 생성 코드·테스트 컴파일과 lint로 대체했다. +- PRD/계획과 SQL/deployment 문서는 병렬 담당자 소유다. 이 담당자는 해당 문서를 수정하지 않았으며 전체 Phase gate/독립 리뷰 완료를 대신 선언하지 않는다. + +## 구현 선택 + +1. `AudioContentService.createAudioContent` 두 overload와 AI 관리자 create orchestration은 `NOT_SUPPORTED`다. 필수 주입된 transaction manager의 `TransactionTemplate(REQUIRES_NEW)`가 content·tags·cover·source/job·latest pointer를 commit한 뒤 원본 S3 업로드를 시작한다. 업로드 후 content의 input 경로를 다시 대입하지 않으므로 조기 MediaConvert 완료 경로를 되돌리지 않는다. 성공 후 기존 언어 이벤트는 별도의 짧은 실제 쓰기 트랜잭션에서 발행한다. +2. 원본은 `input/{contentId}/{canonical UUID}.{safe actual extension}`이다. multipart filename의 경로는 버리고 영문/숫자 1~16자리 실제 확장자만 소문자로 보존한다. 예를 들어 `C:\client\dir/file.MP3`는 UUID `.mp3`로 등록된다. 확장자가 없거나 안전하게 보존할 수 없으면 UUID만 사용한다. 업로드 자체를 새로 거부하거나 `.mp3`로 허위 변경하지 않는다. 확장자는 형식 hint일 뿐 실제 오디오 검증 증거가 아니다. +3. `SubtitleJob.outputKey`는 확장자를 제외한 `output/{contentId}/{same UUID}` stem이다. 새 등록의 upload-complete는 이 정확한 stem과 `aac`/`m4a`/`mp3` 확장자 조합만 수락한다. 다른 콘텐츠, 다른 UUID, 추가 suffix, 임의 디렉터리, 비오디오 확장자는 거부한다. source가 없는 기존 콘텐츠의 동작은 기존 prefix 검증을 유지한다. 실제 consumer의 basename·ID 추출 호환성은 여전히 배포 전 확인 대상이다. +4. 언어 결과는 `Subtitle` 행에 저장한다. `jobId` scalar와 `unique(job_id, language)`, 실제 `language`·`format`·`s3Bucket`·`s3Key`를 보존한다. callback 허용 언어는 여전히 ko-KR/ja-JP이며 상태 규칙은 2/1/0개 그대로다. 미래 언어 활성화/새 provider framework는 추가하지 않았다. +5. callback은 원래 HTTP `ByteArray` SHA-256을 receipt와 함께 저장한다. `REQUIRES_NEW`/`READ_COMMITTED`에서 receipt를 먼저 조회하고 job lock 뒤 다시 조회한다. 기존 eventId로 unknown job을 보낸 변경 body도 422다. receipt는 JPA `Persistable.isNew=true`로 insert하여 merge가 경합한 다른 receipt를 update하지 못하게 했다. unique 경합은 rollback 종료 후 새 트랜잭션에서 receipt를 읽어 200/422를 판정한다. terminal의 동일 결과는 행 ID를 보존하고, 다른 결과는 409다. + +## 변경 경로 + +### 신규 production + +```text +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/domain/SubtitleJobStatus.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/dto/SubtitleJobDtos.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJob.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJobRepository.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/Subtitle.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleRepository.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceipt.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceiptRepository.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/application/SubtitleJobService.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleJobController.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleRequestParser.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleExceptionHandler.kt +``` + +### 기존 production 연결 + +```text +src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt +src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt +src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt +``` + +### 테스트와 보고 + +```text +src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/SubtitleHttpIntegrationTest.kt +src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt +src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt +src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/LegacyCreatorAdminAudioContentCharacterizationTest.kt +docs/20261110_자막Phase1연동/implementation-report.md +``` + +- 기존 수동 `AudioContentService` 생성자는 필수 자막 서비스/transaction manager 인자를 반영했다. +- 기존 생성 통합 테스트의 성공 fixture는 등록 전에 실제 commit되도록 조정했다. audio 업로드/후속 이벤트 실패 테스트는 이미 commit된 등록을 보존한다는 새 승인 계약에 맞췄다. 실패 테스트를 삭제하거나 성공 assertion을 느슨하게 하지 않았다. +- 이 기존 테스트들은 후속 사용자 지시 이후 **실행하지 않았다**. 관련 기존 5개 클래스는 새 테스트 실행의 컴파일 입력에 포함했지만 실행 selector는 신규 자막 패키지뿐이다. +- production 파일 삭제, 기존 JWT/member/텍스트 TranslationJob worker 변경, 외부 프로젝트 변경, 신규 AWS SDK 의존성, 재생성 API/START 호출은 없다. + +## 새 테스트 selector + +전체 신규 클래스만 실행하는 selector: + +```bash +--tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest' +``` + +개별 selector는 아래 클래스명 뒤에 각 메서드를 붙인다. 모두 이번 작업에서 새로 작성한 case다. + +```text +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldRejectActualNonBotLoginJwt +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldUseRealBotJwtAndStoreCallbackBeforeStartedWithExactByteReplay +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldStartMonotonicallyAndAcceptPartialAndFailedResults +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldReturn404WithoutDiscoveryAndCheckCurrentDatabaseRole +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldRejectMalformedContractsWithoutPersistingAnything +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldSerializeConcurrentCallbacksAndRollbackLosingJob +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldRollbackResultsAndStatusWhenReceiptInsertFails +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldKeepLatestPointerWhenOlderJobCompletes +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldCommitRegistrationBeforeUploadAndNeverRevertEarlyMediaConvertOutput +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldKeepAcceptedSourceAndTerminalResultWhenUploadReturnFails +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldPreserveOnlySafeActualSourceExtensionsWithoutClaimingConversion +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldExposeLanguageSubtitleOnlyWithReleasedOriginalAccessInExistingDetail +kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldReadOnlyPersistedLanguageOfMatchingLatestCompletedJob +``` + +## 재현 명령과 아티팩트 + +작업 전용 root는 삭제하지 않고 부모 검증에 인계한다. + +```bash +ROOT=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/subtitle-phase1.wiRcAJ/backend.HX0j0B +export TMPDIR="$ROOT" +export NODE_COMPILE_CACHE="$ROOT/node" +export BUN_RUNTIME_TRANSPILER_CACHE_PATH="$ROOT/bun" + +./gradlew --no-daemon --max-workers=2 \ + -Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs=-Xmx4g \ + --project-cache-dir "$ROOT/gradle-cache" -I "$ROOT/isolation.gradle" \ + test --tests 'kr.co.vividnext.sodalive.v2.subtitle.*' + +./gradlew --no-daemon --max-workers=2 \ + -Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs=-Xmx4g \ + --project-cache-dir "$ROOT/gradle-cache" -I "$ROOT/isolation.gradle" \ + bootJar ktlintCheck +``` + +- 임시 init script는 build 출력과 Gradle project cache를 공유 출력에서 격리한다. 테스트 Kotlin 컴파일 입력은 신규 자막/직접 영향 기존 5개 클래스/support이며, lint filter도 변경 Kotlin 파일만 대상으로 한다. 전역 설정이나 저장소 build 설정은 변경하지 않았다. +- 성공 테스트: `$ROOT/green-new-tests-cached.log`, `BUILD SUCCESSFUL in 1m 32s`. 임시 script의 사용하지 않는 JWT 기본값을 제거한 뒤 다시 실행한 최종 `$ROOT/final-new-tests.log`도 `BUILD SUCCESSFUL in 2m 25s`다. XML: `$ROOT/build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.xml`, `tests=13`, `failures=0`, `errors=0`, `skipped=0`. +- 실제 두 job의 동일 eventId 경합에서 H2 SQLState `23505` unique 위반이 관찰됐고, 최종 HTTP 200/422와 loser job의 PENDING/결과 행 없음 assertion이 통과했다. 별도의 receipt 쓰기 실패 주입에서도 identity 생성으로 flush된 결과 행까지 rollback됨을 확인했다. +- 성공 빌드/lint: `$ROOT/build-lint.log`, `BUILD SUCCESSFUL in 1m 28s`. 실행 가능한 boot JAR은 `$ROOT/build/libs/sodalive-0.0.1-SNAPSHOT.jar`다. `jar tf`에서 새 컨트롤러/서비스/엔티티/repository/DTO가 `BOOT-INF/classes`에 포함된 것을 확인했다. +- 검증 완료 후 root를 참조하는 작업 소유 Gradle/HTTP/Redis 프로세스는 관찰되지 않았다. 기존 공유 worker PID `55393`과 그 Redis PID `55452`는 이 작업 소유가 아니며 종료하지 않았다. 테스트 실행 중 관찰된 작업 소유 executor PID `20950`과 최종 executor PID `22207`은 종료됐다. + +## RED와 실패 이력 + +- 첫 baseline/신규 RED 명령은 테스트 컴파일 중 600초 제한으로 종료됐다(`red.log`). 테스트 결과가 아니며 RED로 인정하지 않았다. +- `red-focused.log`는 이전 지시에 따른 기존 3개 회귀 클래스와 신규 1개 case 실행이다. 총 41개 중 1개가 테스트 JWT key setup에서 실패했다. 이를 기능 RED로 인정하지 않았다. 이 명령은 기존 테스트 실행 금지라는 후속 사용자 지시 전의 기록이며 현재 구현의 회귀 성공 증거로 사용하지 않는다. +- 테스트 전용 `DynamicPropertySource`로 유효 길이의 비운영 JWT key를 지정한 뒤 `red-http.log`에서 실제 로그인 성공 후 `403 기대 / 404 실제`를 확인했다. BOT 전용 경로가 없던 동작의 실제 RED다. +- 초기 `red-binding.log`는 JPA unique constraint의 logical/physical column 이름 오류로 context가 뜨지 못했다. 이를 RED로 인정하지 않고 엔티티에 명시적 column 이름을 붙였다. +- `red-binding-assertions.log`에서 새 두 case가 실제 assertion으로 실패했다: source 확장자 보존 실패, 기존 eventId의 unknown job 변경에 대해 `422 기대 / 404 실제`. 확장자 전달과 receipt 선조회/명시적 isolation 변경 후 같은 두 case를 포함한 13개 전체 신규 case가 GREEN이다. +- `green-first.log`는 사용자 지시 갱신을 위한 중단으로 완료 결과가 없다. `green-new-tests.log`는 compile을 통과한 뒤 test 시작 시점에 600초 도구 제한으로 종료됐다. 공유 프로세스를 종료하지 않고 같은 격리 경로의 캐시를 재사용한 `green-new-tests-cached.log`에서 완료 결과를 얻었다. +- `format.log`의 첫 변경 파일 format 실행은 길이 위반을 보고했다. 해당 새 줄만 정리한 뒤 최종 `ktlintCheck`가 통과했다. + +## 외부 게이트와 남은 미검증 + +1. 실제 MediaConvert consumer가 기존 contentId filename prefix를 읽는지, UUID basename·출력 확장자·디렉터리를 어떻게 구성하는지 운영 증거가 없다. 로컬에서는 정확한 UUID stem과 AAC/M4A/MP3 경로 검증만 확인했다. consumer의 운영 호환성을 확인하기 전 배포 전환은 BLOCKED다. +2. 부모가 승인된 ZIP에서 확인한 Transcribe 지원 확장자는 amr/flac/m4a/mp3/mp4/ogg/webm/wav다. Spring은 AAC/opus 등 기존 업로드를 임의로 거부하지 않는다. 이들 또는 누락/불안전 확장자에 대한 자막 성공은 보장하지 않으며 Lambda의 명시적 unsupported 결과/수동 확인 대상이다. 테스트의 한 바이트 파일은 source binding/transaction fixture일 뿐 실제 미디어 증거가 아니다. +3. S3 putObject 수락 후 getUrl 실패는 성공으로 숨기지 않는다. 원래 예외를 호출자에게 전파하고 `SOURCE_UPLOAD_UNCERTAIN` 및 초 정밀도 TIMESTAMP를 별도 기록한다. 이미 완료된 자막 상태/metadata와 content를 삭제하거나 회귀시키지 않는다. 실제 객체/MediaConvert 상태는 운영자 수동 확인이 필요하다. +4. 운영 MySQL DDL 적용, case-sensitive/NO PAD collation 및 FK/인덱스 검증, private CloudFront origin과 실제 SRT 서명 접근, 실서버 권한/앱 표시, live ko/ja/mixed 및 실제 duration/2시간 처리 예산은 미검증이다. AWS 호출·배포·DB 적용은 수행하지 않았다. +5. 전체 테스트와 후속 지시 이후의 기존 회귀 case 실행은 수행하지 않았다. H2 전체 Spring context의 기존 charge/translation scheduler는 MySQL `FOR UPDATE SKIP LOCKED` 구문 때문에 로그 오류를 남겼다. 이번 신규 case는 모두 통과했지만 그 worker 기능의 성공 증거는 아니다. 사용자가 별도로 전체 테스트를 원할 때만 `./gradlew test`를 실행한다. + +## 20261111 P1 후속 수정: body/DB 실패의 HTTP 200 오인 방지 + +### 수정 범위와 계약 + +- `SubtitleExceptionHandler.kt`의 기존 `SubtitleJobController` 전용 scope와 우선순위를 유지했다. `HttpMessageNotReadableException`은 HTTP 422와 기존 일반화된 거절 body로, 그 외 예상하지 못한 `Exception`은 HTTP 500과 `{"error":"Subtitle request failed"}`로 응답한다. +- 기존 `ResponseStatusException`의 404/409/422 처리는 변경하지 않았다. 공통 `SodaExceptionHandler`, JWT, 컨트롤러, 서비스 및 DB 저장 로직은 변경하지 않았으며 예외 원문·요청·토큰 로그도 추가하지 않았다. +- 수정 파일은 자막 advice, `SubtitleHttpIntegrationTest.kt` 및 이 보고서의 추가 기록뿐이다. 기존 직접 서비스 롤백 테스트 `shouldRollbackResultsAndStatusWhenReceiptInsertFails`와 기존 assertion은 그대로 보존했다. 부모 소유 PRD/계획/final-report는 수정하지 않았다. + +### 새 HTTP 두 사례 + +1. `shouldRejectEmptyCallbackAndStartedBodiesWithoutPersistingAnything`: 실제 BOT 로그인/JWT로 빈 callback·started body를 모두 전송해 422를 확인한다. job/receipt/결과 행 수, PENDING 상태, 갱신 시각 및 오류 필드가 변경되지 않고 해당 job 결과도 없음을 검증한다. +2. `shouldReturn500AndRollbackWhenReceiptInsertFailsOverHttp`: 실제 HTTP callback에서 기존 receipt repository SpyBean의 `saveAndFlush`에 DB 예외를 주입한다. ko-KR partial callback을 사용하여 상태뿐 아니라 오류 필드도 변경 대상에 포함하고, 일반화된 500 body 및 receipt·결과 부재, PENDING/갱신 시각/오류 필드 롤백을 DB 재조회로 확인한다. + +### RED/GREEN 명령과 증거 + +위 격리 `ROOT`와 `TMPDIR`/Node/Bun cache 환경을 그대로 사용했다. 외부 의존성 다운로드를 막는 `--offline`과 부모의 `verification.gradle`을 추가하여 테스트만 강제로 다시 실행했다. `clean` 및 무필터 test/check/build는 실행하지 않았다. + +advice 수정 전 RED와 수정 후 두 사례 GREEN에 사용한 동일 selector/명령: + +```bash +./gradlew --offline --no-daemon --max-workers=2 \ + -Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs=-Xmx4g \ + --project-cache-dir "$ROOT/gradle-cache" \ + -I "$ROOT/isolation.gradle" -I "$ROOT/../verification.gradle" \ + test \ + --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldRejectEmptyCallbackAndStartedBodiesWithoutPersistingAnything' \ + --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldReturn500AndRollbackWhenReceiptInsertFailsOverHttp' +``` + +- RED: `$ROOT/p1-red-two-http.log`, `BUILD FAILED in 4m 16s`, 정확히 2개 실행/2개 assertion 실패다. `$ROOT/p1-red-assertions.log`에 XML의 실패 증거를 보존했다. 빈 body는 `expected [422, 422] / actual [200, 200]`, DB 실패는 `expected 500 / actual 200`이었다. +- 첫 GREEN 시도 `$ROOT/p1-green-two-http.log`는 `compileKotlin` 도중 600초 도구 제한으로 종료됐다. 테스트가 실행되지 않아 GREEN으로 계산하지 않았다. 작업 소유 프로세스가 남지 않은 것을 확인한 후 같은 캐시와 1200초 제한으로 재실행했다. +- 두 사례 GREEN: `$ROOT/p1-green-two-http-retry.log`, `BUILD SUCCESSFUL in 13m 29s`. `$ROOT/p1-green-two-assertions.log`는 `tests=2`, `failures=0`, `errors=0`, `skipped=0`이다. + +이후 자막 신규 클래스 15개 전체와 기존 격리 script의 변경 Kotlin 파일 lint 및 boot JAR를 함께 검증했다: + +```bash +./gradlew --offline --no-daemon --max-workers=2 \ + -Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs=-Xmx4g \ + --project-cache-dir "$ROOT/gradle-cache" \ + -I "$ROOT/isolation.gradle" -I "$ROOT/../verification.gradle" \ + test --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest' \ + bootJar ktlintCheck +``` + +- 최종 `$ROOT/p1-green-15-build-lint.log`: `BUILD SUCCESSFUL in 7m 44s`. production/test 컴파일은 `UP-TO-DATE`였으며 test, bootJar, main/test 변경 파일 lint는 실제 실행됐다. +- 최종 XML `$ROOT/build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.xml`: `tests=15`, `failures=0`, `errors=0`, `skipped=0`. 실제 RANDOM_PORT HTTP에서 새 422/500과 기존 404/409/422 및 직접 서비스 롤백 case가 모두 통과했다. 기존 회귀 클래스는 컴파일 입력일 뿐 실행하지 않았다. +- `$ROOT/build/libs/sodalive-0.0.1-SNAPSHOT.jar`에 수정된 `SubtitleExceptionHandler.class`가 포함됨을 `jar tf`로 확인했다. LSP 도구는 제공되지 않아 사용하지 못했으며 Kotlin 컴파일과 변경 파일 lint를 확인했다. +- RED/GREEN/최종 테스트 executor PID `28638`/`37727`/`39291`은 모두 종료됐다(`$ROOT/p1-test-process-status.log`는 header만 존재). 완료 후 작업 root를 사용하는 Gradle/HTTP/Redis 프로세스는 관찰되지 않았다. 기존 공유 PID `55393`/`55452`는 그대로이며 어떤 task도 취소하거나 프로세스를 수동 종료하지 않았다. root와 로그는 부모 재검증용으로 보존했다. + +### 남은 검증 경계 + +- 기존 Kotlin 경고, Gradle deprecation, H2의 기존 charge/translation scheduler SQL 오류와 Redis 종료 시 재접속 경고는 관찰됐으나 수정 범위 밖이다. 신규 자막 15개 assertion은 전부 통과했다. +- 기존 회귀/전체 테스트, 운영 MySQL, AWS·MediaConvert·Lambda 실연동 및 배포 검증은 수행하지 않았다. 앞 절의 외부 게이트는 그대로 남으며, 전체 테스트 사용자 실행 명령은 `./gradlew test`다. + +## 부모 최종 검증·정리 추가 기록 + +- 부모가 처음 신규13개 및 P1의 두 HTTP 사례를 각각 직접 재실행해 모두 통과했고, 독립 리뷰의 유일 finding은 ADDRESSED 및 로컬 backend APPROVE 판정을 받았다. +- 부모의 P1 재실행은 `BUILD SUCCESSFUL in 4m 55s`, 실제 XML2/0/0/0, timestamp `2026-11-10T15:34:43`이다. +- 2026-11-11 작업 소유 PID/listener 부재를 확인한 후 위 임시 ROOT 전체를 제거했다. 따라서 ROOT 기반 로그·init script·JAR 경로는 과거 검증 위치이며 현재 파일로 남아 있지 않다. 명령/결과/실패 이력은 본 보고서와 final-report에 보존했다. +- 일반 개발 환경의 재현 명령은 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest'`다. 기존 테스트는 실행하지 않는 selector다. diff --git a/docs/20261110_자막Phase1연동/plan-task.md b/docs/20261110_자막Phase1연동/plan-task.md index 010c5390..54a5adc9 100644 --- a/docs/20261110_자막Phase1연동/plan-task.md +++ b/docs/20261110_자막Phase1연동/plan-task.md @@ -152,6 +152,8 @@ ## Progress +- 2026-11-11 별도 사용자 `/commit-with-validation` 요청으로 커밋 진행. 기능 연동 커밋의 pre-commit `ktlintCheck`가 기존 `AudioContentService.kt` EOF 빈 줄 1건으로 실패하여 해당 빈 줄만 제거했다. 재시도에서 staged Kotlin 대상 lint와 `git diff --staged --check`, 커밋 직전·직후 메시지 검증이 통과했다. 이번 커밋 작업에서는 기능/전체 테스트 및 bootJar를 재실행하지 않았으며 기존 실행 이력과 외부 배포 BLOCKED는 유지한다. 전체 테스트 사용자 명령은 `./gradlew test`다. + - 2026-11-11 `P2-GATE` 종료: 부모 직접 HTTP3개 재실행 성공과 독립 승인 후 소유 root `generate-url-subtitle.fSBkJM`의 디렉터리/symlink 여부를 확인해 제거했다. `test ! -e` exit0이며 공유 캐시/다른 dirty 파일은 건드리지 않았다. 원래 EOF lint1건은 별도 미해결 관찰로 남긴다. 검증 로그 경로는 과거 위치이며 결과/명령/실패 이력은 보고서에 보존했다. - 2026-11-11 `P2-T1` 독립 리뷰 APPROVE: 기존 contentUrl/envelope/활성 구매 정책과 서명 만료 인자 유지, 자막만 공개 시점·언어·최신 결과 조건 적용, 새 HTTP3개 증거를 확인했다. 이번 delta blocker/새 회귀 없음. production EOF 공백은 작업 시작 전 dirty 상태의 잔여사항이며 전체 lint clean을 주장하지 않는다. diff --git a/docs/20261110_자막Phase1연동/reviews/backend-gate-review.md b/docs/20261110_자막Phase1연동/reviews/backend-gate-review.md new file mode 100644 index 00000000..33d62ada --- /dev/null +++ b/docs/20261110_자막Phase1연동/reviews/backend-gate-review.md @@ -0,0 +1,47 @@ +# 자막 Phase 1 Backend Gate 리뷰 + +## 1. 리뷰 정보 + +- 일자: 2026-11-10 +- 대상: `test` working tree의 신규 `v2.subtitle` 및 기존 업로드/상세/보안 연결 +- 기준: [PRD](../prd.md), [계획](../plan-task.md), [구현 보고](../implementation-report.md) +- 리뷰어: 독립 Oracle (`ses_e3a1e039bffeXbBb7Nx0V7Hvk8`) +- 상태: 회귀 수정 검증 완료, 로컬 backend APPROVE +- 검토 제외: AWS/운영 DB/실음성/2시간 성능, 기존 전체 회귀 실행 + +## 2. 근거와 판정 + +부모 직접 실행의 실제 localhost HTTP 신규 테스트 13개가 0 failure/error/skip으로 통과했다. +reviewer는 코드·신규 테스트·실행 로그·DDL을 읽었고 테스트를 재실행하지 않았다. +저장/권한/언어별 결과/업로드/스키마는 부합 판정이나 HTTP 오류 경계 한 건으로 REQUEST_CHANGES다. + +| ID | 심각도 | 상태 | 내용 | 수정 Goal | +|---|---|---|---|---| +| REV-P1-001 | High | 수정 완료 / ADDRESSED | 자막 빈 body/저장 실패가 공통 advice에서 HTTP200으로 변환 | P1-R1 | + +## 3. REV-P1-001 + +- `SubtitleExceptionHandler.kt`는 ResponseStatusException만 처리한다. +- 기존 `common/SodaExceptionHandler.kt:73`과 `:94`는 DB 예외/일반 예외에 HTTP 상태 없이 ApiResponse.error를 반환한다. +- 필수 body가 비면 strict parser 이전에 HttpMessageNotReadableException이 발생한다. +- receipt 실패의 기존 새 테스트는 서비스 직접 호출 rollback만 검증하므로 HTTP200 문제를 포착하지 못했다. +- 영향: 결과가 저장되지 않았어도 Lambda가 HTTP 성공으로 해석할 수 있다. + +최소 수정은 자막 controller에 한정된 advice에서 body 읽기 오류를 422, 예상하지 못한 서버 오류를 +민감정보 없는 500으로 반환하는 것이다. 기존 404/409/422 및 공유 API 동작을 보존한다. + +## 4. 수정 검증 조건 + +1. 빈 callback/started 요청이 200이 되는 RED를 실제 HTTP로 확인한다. +2. receipt 실패 callback이 200이 되는 RED와 전체 DB rollback을 확인한다. +3. 수정 후 422/500 및 무부분반영을 검증하고 이번 작성 테스트만 재실행한다. +4. 같은 reviewer가 수정 delta와 새 증거를 재검토한다. + +## 5. 판정 기록 + +- 1차: REQUEST_CHANGES 1건. 부모가 공통 handler 코드를 직접 확인하고 `P1-R1`에 반영했다. +- 2026-11-11 수정 검증: 전용 advice만 수정하고 신규 HTTP 두 사례의 RED/GREEN을 확인했다. 이후 신규 자막15개·bootJar·변경 lint가 통과했고, 부모 직접 두 HTTP 사례 재실행도 2/0/0/0으로 통과했다. 기존 공통 handler와 직접 서비스 rollback 테스트는 보존했다. reviewer session continuation 유실로 기존 판정과 delta만 전달한 단일 재검토를 진행 중이다. +- 운영 배포는 코드 리뷰와 별개이며 [배포 게이트](../deployment-gates.md)의 차단 상태를 유지한다. +- 최종 제한 delta 리뷰 (`ses_e39e64c42ffeCOGUZjgeM4P1jf`): **REV-P1-001 ADDRESSED / 로컬 backend APPROVE**. + 전용 advice의 422/500, 기존 404/409/422 유지, 실제 HTTP 및 rollback RED/GREEN 증거를 확인했다. + 수정 delta에서 신규 버그/blocker는 발견하지 못했다. 추가 코드 수정 불필요 판정이며 전체 리뷰 반복이나 기존 테스트 실행은 하지 않았다. diff --git a/docs/20261110_자막Phase1연동/reviews/generate-url-review.md b/docs/20261110_자막Phase1연동/reviews/generate-url-review.md new file mode 100644 index 00000000..371cf2b0 --- /dev/null +++ b/docs/20261110_자막Phase1연동/reviews/generate-url-review.md @@ -0,0 +1,16 @@ +# generate-url 자막 응답 제한 리뷰 + +- 일자: 2026-11-11 +- 범위: `P2-T1`, `GenerateUrlResponse`와 `AudioContentService.generateUrl`, 새 HTTP 테스트3개. +- 기준: PRD §7, plan-task P2, 실제 메서드/DTO 전후 diff 및 `generate-url-report.md`의 RED/GREEN 증거. +- 리뷰어: `ses_e387b1e8fffensjb6dnoRk82ST`, read-only. 기존 Phase1 전체는 다시 리뷰하지 않았다. +- 판정: **APPROVE**, 이번 범위의 확정 finding/blocker/새 회귀 없음. + +확인한 기준: +1. contentUrl과 ApiResponse envelope 유지, 공유 nullable subtitle만 additive 추가. +2. 활성 구매(KEEP/유효 RENTAL) 정책 유지, 소유자·무료만으로 우회하지 않음. +3. 음원 URL은 기존대로이며 자막에만 상세와 동일한 공개일/소유자 조건 적용. +4. 동일 expirationTime 전달 및 공유 latest/언어/상태별 자막 조회 재사용. +5. 새 HTTP3개 RED → GREEN 및 부모 재실행3/0/0/0, 실제 localhost HTTP/JWT/H2 증거 확인. + +잔여사항: production lint의 기존 서비스 EOF 빈 줄1건은 보존했다. 기존 테스트/전체 테스트/LSP 및 실제 AWS 서명·다운로드/운영 배포는 이번 검증이 아니다. 수정 Task로 전환할 새로운 발견 사항은 없다. From cf31c5702c24439b645535fb8f77a01607831a62 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 11 Nov 2026 10:57:42 +0900 Subject: [PATCH 5/7] =?UTF-8?q?docs(subtitle):=20=EC=BB=AC=EB=9F=BC?= =?UTF-8?q?=EB=AA=85=20=ED=9A=8C=EA=B7=80=20=EC=88=98=EC=A0=95=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=EC=82=AC=ED=95=AD=EA=B3=BC=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20261110_자막Phase1연동/plan-task.md | 35 +++++++++++++++++++++-- docs/20261110_자막Phase1연동/prd.md | 10 +++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/docs/20261110_자막Phase1연동/plan-task.md b/docs/20261110_자막Phase1연동/plan-task.md index 54a5adc9..ec7ad28e 100644 --- a/docs/20261110_자막Phase1연동/plan-task.md +++ b/docs/20261110_자막Phase1연동/plan-task.md @@ -10,7 +10,7 @@ ## 현재 상태 -- Phase 1 완료 기록 유지. Phase 2 generate-url 구현·새 HTTP3개·독립 리뷰·임시 자원 정리 완료. 활성 로컬 Goal 없음. 운영 배포/외부 게이트는 여전히 BLOCKED. +- Phase 1/2 완료 이력 유지. Phase3 컬럼 매핑 수정·신규 테스트·독립 리뷰·소유 임시 자원 정리 완료. 활성 로컬 Goal 없음. 운영 DB/외부 게이트는 별도 검증 대상이다. - 실행: subagent-driven-development, 공유 Kotlin 구현은 단일 담당자가 순차 처리한다. 엔티티 계약이 확보된 additive DDL·운영 문서는 별도 담당자가 병렬 준비하고 마지막에 대조한다. - 새 worktree는 만들지 않는다. 기존 `test` 작업 트리는 조사 시 clean이며 main/master가 아니다. - commit/push/배포/DB 적용은 이 계획의 실행 항목이 아니다. @@ -141,7 +141,24 @@ - [x] 기존 코드 변경은 DTO와 generateUrl 본문뿐임을 실제 전후 diff로 대조했다. - [x] 결과 보고를 보존하고 소유 테스트 PID/listener 및 임시 build/cache/root 부재를 확인했다. -## Phase 1 실행 검증 이력 +## 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.*' @@ -152,6 +169,14 @@ ## 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건은 별도 미해결 관찰로 남긴다. 검증 로그 경로는 과거 위치이며 결과/명령/실패 이력은 보고서에 보존했다. @@ -184,3 +209,9 @@ - 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`다. diff --git a/docs/20261110_자막Phase1연동/prd.md b/docs/20261110_자막Phase1연동/prd.md index f585d49f..6295fbec 100644 --- a/docs/20261110_자막Phase1연동/prd.md +++ b/docs/20261110_자막Phase1연동/prd.md @@ -138,3 +138,13 @@ nullable `subtitle`을 추가한다. 기존 `ApiResponse` envelope와 `data.cont - `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 재기동을 검증했다고 주장하지 않는다. From c3e5f19cb38a02d72c4f4b45c1926033545b7152 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 11 Nov 2026 10:57:52 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix(subtitle):=20S3=20=EC=BB=AC=EB=9F=BC?= =?UTF-8?q?=EB=AA=85=EC=9D=84=20=EA=B8=B0=EC=A1=B4=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=EC=99=80=20=EC=9D=BC=EC=B9=98=EC=8B=9C=ED=82=A8?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../subtitle/adapter/persistence/Subtitle.kt | 4 +- .../subtitle/SubtitleSchemaValidationTest.kt | 85 +++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/SubtitleSchemaValidationTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/Subtitle.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/Subtitle.kt index e9fdac98..15747a11 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/Subtitle.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/Subtitle.kt @@ -17,9 +17,9 @@ class Subtitle( val language: String, @Column(nullable = false, length = 16) val format: String, - @Column(nullable = false, length = 255) + @Column(name = "s3_bucket", nullable = false, length = 255) val s3Bucket: String, - @Column(nullable = false, length = 255) + @Column(name = "s3_key", nullable = false, length = 255) val s3Key: String ) { @Id diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/SubtitleSchemaValidationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/SubtitleSchemaValidationTest.kt new file mode 100644 index 00000000..0874cb41 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/SubtitleSchemaValidationTest.kt @@ -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) + } + } + } +} From 5eee580aaf51369285a0c5242b4bc524cbad16e1 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 11 Nov 2026 10:58:20 +0900 Subject: [PATCH 7/7] =?UTF-8?q?docs(subtitle):=20=EC=BB=AC=EB=9F=BC=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=20=EA=B2=80=EC=A6=9D=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EC=99=80=20=EB=B0=B0=ED=8F=AC=20=EC=A1=B0=EA=B1=B4=EC=9D=84=20?= =?UTF-8?q?=EC=A0=95=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../deployment-gates.md | 5 ++ docs/20261110_자막Phase1연동/final-report.md | 8 ++ .../reviews/schema-validation-review.md | 15 ++++ .../schema-validation-report.md | 86 +++++++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 docs/20261110_자막Phase1연동/reviews/schema-validation-review.md create mode 100644 docs/20261110_자막Phase1연동/schema-validation-report.md diff --git a/docs/20261110_자막Phase1연동/deployment-gates.md b/docs/20261110_자막Phase1연동/deployment-gates.md index de4eddb5..a140a345 100644 --- a/docs/20261110_자막Phase1연동/deployment-gates.md +++ b/docs/20261110_자막Phase1연동/deployment-gates.md @@ -37,6 +37,11 @@ | `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 | diff --git a/docs/20261110_자막Phase1연동/final-report.md b/docs/20261110_자막Phase1연동/final-report.md index e6b8faa2..40c1ac2c 100644 --- a/docs/20261110_자막Phase1연동/final-report.md +++ b/docs/20261110_자막Phase1연동/final-report.md @@ -111,6 +111,14 @@ additive SQL만 작성했다. 실제 DB 적용은 하지 않았다. 미래 언 이 결과를 기존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` 요청으로 변경을 커밋했다. diff --git a/docs/20261110_자막Phase1연동/reviews/schema-validation-review.md b/docs/20261110_자막Phase1연동/reviews/schema-validation-review.md new file mode 100644 index 00000000..599d37b4 --- /dev/null +++ b/docs/20261110_자막Phase1연동/reviews/schema-validation-review.md @@ -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 변경이나 배포 승인을 의미하지 않는다. diff --git a/docs/20261110_자막Phase1연동/schema-validation-report.md b/docs/20261110_자막Phase1연동/schema-validation-report.md new file mode 100644 index 00000000..98125544 --- /dev/null +++ b/docs/20261110_자막Phase1연동/schema-validation-report.md @@ -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`다.