Merge pull request 'Test' (#470) from test into main
Reviewed-on: #470
This commit was merged in pull request #470.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
# 자막 Phase 1 배포 게이트
|
||||
|
||||
**현재 판정: 배포 BLOCKED.** 이 문서는 로컬 저장 계약과 배포 전 확인 항목이며 DB 적용·AWS 배포 증거가 아니다.
|
||||
기준일은 2026-11-10이며 [PRD](prd.md), [구현 계획](plan-task.md), [DDL](subtitle-schema.sql)을 함께 본다.
|
||||
|
||||
## 1. 검토 기준과 파일 경계
|
||||
|
||||
- persistence 기준 경로: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/`.
|
||||
- 실제 읽은 파일: `SubtitleJob.kt`, `Subtitle.kt`, `SubtitleCallbackReceipt.kt` 및 세 repository.
|
||||
- 콘텐츠 기준: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt`, `common/BaseEntity.kt`.
|
||||
- `src/main/resources/application.yml`의 `MySQL8Dialect`·`ddl-auto: validate`는 로컬 설정 근거일 뿐 운영 DB 증거가 아니다.
|
||||
- 문서 담당은 이 문서와 DDL만 작성한다. 병렬 Kotlin 구현의 최종 엔티티 대조는 상위 구현 게이트에서 다시 수행한다.
|
||||
|
||||
## 2. 저장 계약 대조
|
||||
|
||||
물리명은 기존 Spring snake_case 매핑 기준이다. 길이 미지정 `String`은 JPA 기본 `VARCHAR(255)`를 따른다.
|
||||
`jobId`는 UUID **문자열**이며 `BINARY(16)`으로 바꾸지 않는다. `N`은 NOT NULL, `Y`는 NULL 허용이다.
|
||||
|
||||
| `SubtitleJob.kt` 속성 | DDL 컬럼·타입 | NULL |
|
||||
|---|---|---|
|
||||
| `jobId` | `job_id VARCHAR(36)` PK | N |
|
||||
| `contentId` | `content_id BIGINT` signed | N |
|
||||
| `sourceBucket`, `sourceKey` | `source_bucket`, `source_key VARCHAR(255)`; 전체 두 컬럼 unique | N |
|
||||
| `outputKey` | `output_key VARCHAR(255)` | N |
|
||||
| `status` | `status VARCHAR(32)`; enum STRING | N |
|
||||
| `errorCode` | `error_code VARCHAR(255)` | Y |
|
||||
| `errorMessage` | `error_message TEXT` | Y |
|
||||
| `sourceUploadErrorCode` | `source_upload_error_code VARCHAR(255)` | Y |
|
||||
| `sourceUploadErrorAt` | `source_upload_error_at TIMESTAMP`; 명시적 JPA TIMESTAMP | Y |
|
||||
| `createdAt`, `updatedAt` | `created_at`, `updated_at TIMESTAMP` | N |
|
||||
|
||||
| `Subtitle.kt` 속성 | DDL 컬럼·타입 | NULL |
|
||||
|---|---|---|
|
||||
| `id` | `id BIGINT` signed PK AUTO_INCREMENT | N |
|
||||
| `jobId` | `job_id VARCHAR(36)` FK → `subtitle_job.job_id` | N |
|
||||
| `language` | `language VARCHAR(35)`; `(job_id, language)` unique | N |
|
||||
| `format` | `format VARCHAR(16)` | N |
|
||||
| `s3Bucket`, `s3Key` | `s3_bucket`, `s3_key VARCHAR(255)` | N |
|
||||
|
||||
2026-11-11 정정: 이전 정적 대조는 `s3Bucket`의 숫자 경계 명명 규칙을 놓쳤다. 기본 naming strategy가
|
||||
`s3bucket`/`s3key`로 추론하므로 두 필드에 명시적 `@Column(name = "s3_bucket"/"s3_key")`가 필요하다.
|
||||
`P3-R1`에서 실제 `validate` 실패를 재현한 뒤 두 이름을 명시해 H2 validation/저장/재조회를 통과했다.
|
||||
독립 리뷰도 승인했다. 운영 DB에 `s3bucket` 컬럼을 추가한 것이 아니며 실 MySQL 기동은 별도 확인 대상이다.
|
||||
|
||||
| 기존 엔티티 속성 | DDL 컬럼·타입 | NULL |
|
||||
|---|---|---|
|
||||
| `SubtitleCallbackReceipt.eventId` | `event_id VARCHAR(255)` PK; UUID 제한 없음 | N |
|
||||
| `SubtitleCallbackReceipt.jobId` | `job_id VARCHAR(36)` | N |
|
||||
| `SubtitleCallbackReceipt.bodyHash` | `body_hash VARCHAR(64)` | N |
|
||||
| `SubtitleCallbackReceipt.createdAt` | `created_at TIMESTAMP` | N |
|
||||
| `AudioContent.latestSubtitleJobId` | `content.latest_subtitle_job_id VARCHAR(36)` | Y |
|
||||
|
||||
### 미확정 차이와 적용 전 대조
|
||||
|
||||
1. 마지막 정적 대조에서 `Subtitle.kt`의 테이블명·6개 컬럼·길이·nullable·IDENTITY PK·unique가 합의 계약과 일치했다.
|
||||
`SubtitleJob.kt`에서도 `koKey`/`jaKey` 제거를 확인했다. `Subtitle.jobId`는 scalar이고 JPA 관계/FK 선언은 없다.
|
||||
DDL의 `subtitle.job_id` FK는 합의 계약에 따른 DB 제약이며 ORM 관계 선언 확인으로 보고하지 않는다.
|
||||
`subtitle_job` CREATE에 `ko_key`/`ja_key`는 넣지 않는다. 미출시 초안 교체이며 기존 데이터 이관·컬럼 삭제 SQL은 없다.
|
||||
2. 최초 대조에서는 `sourceUploadErrorAt`에 `columnDefinition`이 없어 차이로 기록했다.
|
||||
최종 구현은 `@Column(columnDefinition = "TIMESTAMP")`와 초 절삭을 명시했고 부모가 DDL과 다시 대조했다.
|
||||
이 로컬 mapping 차이는 해소됐지만 실제 MySQL schema 적용·왕복 검증은 여전히 운영 게이트다.
|
||||
3. 나머지 시각은 엔티티의 명시적 `TIMESTAMP`와 초 절삭을 따른다. DDL 소수초 정밀도는 모두 0이다.
|
||||
`created_at`은 `DEFAULT CURRENT_TIMESTAMP`, job의 `updated_at`은 같은 기본값과 `ON UPDATE CURRENT_TIMESTAMP`다.
|
||||
`Subtitle.kt`에는 시각이나 `BaseEntity` 상속이 없으므로 결과 행에 임의 시각 컬럼을 추가하지 않는다.
|
||||
UTC 생성값과 JDBC/session timezone 및 실제 MySQL 왕복은 미검증이다.
|
||||
4. `AudioContent`는 `content` 테이블이며 `BaseEntity.id: Long?`의 IDENTITY를 상속한다.
|
||||
신규 `content_id`는 signed BIGINT로 맞췄지만 운영 `content.id`의 실제 타입·부호·엔진은 확인하지 않았다.
|
||||
합의된 `subtitle.job_id` FK만 둔다. receipt의 jobId 및 콘텐츠 연결은 현재 scalar 매핑이므로 FK를 임의 추가하지 않는다.
|
||||
FK cascade는 없으며 기본 RESTRICT다. `(job_id, language)` unique가 결과 FK와 작업별 조회 인덱스를 겸한다.
|
||||
5. 현재 callback 활성화는 `ko-KR`/`ja-JP`, 형식은 `SRT`, 결과 2/1/0개의 상태 판정은 그대로다.
|
||||
저장 구조만 언어별 행으로 바꾼다. 새 언어 활성화·재생성·새 관리 API·recovery 상태/테이블은 추가하지 않는다.
|
||||
|
||||
### 식별자와 인덱스 전제
|
||||
|
||||
- 세 신규 테이블과 최신 job 컬럼은 `utf8mb4_0900_bin`을 쓴다. 대소문자·후행 공백을 구별하는 NO PAD 비교다.
|
||||
eventId 및 bucket/key의 정확한 식별에 case-insensitive collation이나 PAD SPACE인 `utf8mb4_bin`을 대신 쓰지 않는다.
|
||||
- MySQL 8.0.17 이상 및 해당 collation의 binary/NO PAD 속성, InnoDB DYNAMIC·3072-byte 키 지원을 운영자가 확인한다.
|
||||
16 KiB 이상 InnoDB page를 전제로 하며 4/8 KiB page 환경의 적합성을 주장하지 않는다.
|
||||
- source unique 최대 키 데이터는 `(255 + 255) × 4 = 2040` bytes다. eventId PK는 `255 × 4 = 1020` bytes다.
|
||||
결과 unique는 `(36 + 35) × 4 = 284` bytes다. 보조 인덱스의 PK 포함분도 3072 bytes 미만이다.
|
||||
prefix unique나 해시 식별자로 원래 bucket/key를 대체하지 않는다.
|
||||
- 경로 컬럼은 현재 UUID 기반 계약에 맞는 `VARCHAR(255)`이며 일반 S3 최대 길이 전체 지원을 뜻하지 않는다.
|
||||
job의 `(content_id, created_at)` 인덱스는 현재 `findFirstByContentIdOrderByCreatedAtAsc` 조회에 대응한다.
|
||||
|
||||
## 3. Lambda handoff와 실제 환경 구분
|
||||
|
||||
아래 SHA-256은 **PRD §2의 기존 조사 기록 인용**이다. 문서 담당이 외부 저장소·ZIP·AWS를 다시 조회한 결과가 아니다.
|
||||
원본 보고서 참조는 PRD에 적힌 `aws-subtitle-service/docs/FINAL_REPORT.md`다.
|
||||
|
||||
| 정확한 artifact | SHA-256 |
|
||||
|---|---|
|
||||
| `start-subtitle-transcription.zip` | `a0d0b19b2ecb24c0f89caf528bf09fd039558563a99063f082ac64418704abfd` |
|
||||
| `generate-subtitle.zip` | `bfa8742ca436bace853ec2540fc5f70daa24cbab49e75315565d980189fbf209` |
|
||||
|
||||
로컬 artifact 일치 기록은 운영 함수 버전/alias·event routing·consumer·in-flight·Scheduler·DB 적용 증거가 아니다.
|
||||
자격 증명/JWT/raw body 값은 이 문서나 증거 보고서에 기록하지 않는다.
|
||||
|
||||
## 4. 운영 차단 항목
|
||||
|
||||
| 게이트 | 현재 | 해제에 필요한 증거 |
|
||||
|---|---|---|
|
||||
| 최종 엔티티·DB | BLOCKED | 위 차이 해소, 운영 기존 스키마·버전·collation·인덱스 확인, 별도 DDL 승인·적용 기록 |
|
||||
| consumer·조정 배포 | BLOCKED | 운영자 inventory, 실행 중일 때만 pause/drain, backend·정확한 두 Lambda의 조정 배포 기록 |
|
||||
| MediaConvert·CloudFront | BLOCKED | UUID 입력의 ID 추출/출력 확장자 및 private origin·서명 URL 실환경 검증 |
|
||||
| controlled live | BLOCKED | ko/ja/mixed의 실제 S3 → callback → 콘텐츠/앱 결과와 word/item 언어 증거 |
|
||||
| 대표 2시간 입력 | BLOCKED·미측정 | 신뢰 가능한 duration 근거 및 대표 입력의 전체 처리 예산 실측 |
|
||||
|
||||
### 운영자 실행 순서: 별도 승인 후에만
|
||||
|
||||
1. 운영 consumer·event routing·함수 버전/alias·in-flight·기존 DB 상태를 inventory로 확정한다.
|
||||
실행 중인 consumer가 있을 때만 신규 유입을 pause하고 기존 작업·callback을 drain하는 전환 구간을 조율한다.
|
||||
실행 중인 대상이 없으면 그 증거와 함께 pause/drain을 생략한다. 미확인 상태를 비가동으로 간주하지 않는다.
|
||||
2. 최종 JPA와 실제 `SHOW CREATE TABLE`·collation·인덱스 전제를 대조하고 DB 변경을 별도 승인받는다.
|
||||
기존 동명 테이블/컬럼이 있으면 중단하고 차이를 검토한다. `IF NOT EXISTS`로 불일치를 숨기지 않는다.
|
||||
additive DDL 승인·적용 확인과 Phase 1 backend/두 Lambda의 배포를 같은 전환 계획으로 조율한다.
|
||||
구·신 consumer의 중복 실행이나 호환성 미확인 in-flight를 남긴 채 재개하지 않는다.
|
||||
3. UUID 원본 `input/{contentId}/{UUID filename}`으로 외부 MediaConvert의 contentId 추출 규칙을 확인한다.
|
||||
등록은 안전한 실제 파일 확장자를 UUID 뒤에 보존한다. 실제 START ZIP은 지원 확장자를 검사하므로
|
||||
누락/미지원 확장자의 자막 성공을 주장하지 않는다. 이를 위해 기존 업로드를 거부하거나 파일을 허위 변환하지 않는다.
|
||||
출력 경로·실제 확장자·완료 callback·기존 preview/알림 흐름의 호환 증거가 필요하다.
|
||||
로컬 `matchesOutput`의 허용 확장자는 `aac`/`m4a`/`mp3`이나 실제 외부 출력 계약이 확인된 것은 아니다.
|
||||
자막 bucket/key가 기존 private CloudFront origin/path에 정확히 매핑되고 권한 있는 사용자만 서명 URL로
|
||||
열람하는지 확인한다. 두 항목 모두 현재 미검증이며 임의 prefix 허용이나 공개 S3 URL로 우회하지 않는다.
|
||||
현재 Spring의 자막 bucket allowlist는 기존 `cloud.aws.s3.content-bucket` 한 개다.
|
||||
Lambda `MEDIA_BUCKET`과 `SUBTITLE_BUCKET`도 이 값에 맞춰야 하며, 별도 자막 bucket을 쓰려면
|
||||
allowlist와 CloudFront origin 계약을 먼저 변경·검증해야 한다. 현재 구현이 다중 bucket을 지원한다고 보지 않는다.
|
||||
4. 일반 유입을 재개하기 전에 controlled ko/ja/mixed live를 수행한다.
|
||||
S3 source binding, Transcribe 실제 word/item 언어, 결과 SRT, BOT callback, 언어별 콘텐츠/앱 표시를 연결해 기록한다.
|
||||
mixed는 실제 word/item 언어 증거가 필수이며 synthetic fixture나 HTTP metadata 검증으로 대체할 수 없다.
|
||||
유료 preview-only 사용자의 전체 자막 차단과 private 접근도 확인한다.
|
||||
5. 신뢰 가능한 기존 duration의 가용성과 입력 검증 경계를 확인하고 대표 2시간 입력의 처리 예산을 실측한다.
|
||||
구형/검증 불가능 원본은 수동 게이트로 남기며 임의 client duration을 신뢰하지 않는다.
|
||||
2시간은 입력 목표이지 SLA/지원 보장이 아니다. 미측정 상태에서 지원 또는 재개 승인을 하지 않는다.
|
||||
모든 차단 항목의 증거와 승인자를 기록한 뒤에만 일반 유입을 재개한다.
|
||||
|
||||
purge·데이터 정리·적용 migration 수정·Scheduler fallback·자동 복구·Translate 50-call 정책은 이 절차에 없다.
|
||||
재생성 전체와 새 언어 활성화 역시 제외한다. 미해결 작업을 삭제하거나 성공 처리해 drain을 가장하지 않는다.
|
||||
|
||||
## 5. 문서 담당 검증 경계
|
||||
|
||||
문서 간 계약·실제 엔티티 속성·컬럼별 COMMENT·테이블 COMMENT·SQL 종류·공백을 read/rg로 정적으로 대조했다.
|
||||
DDL은 CREATE TABLE 3개와 ALTER TABLE ADD COLUMN 1개뿐이며 23개 신규 컬럼·3개 신규 테이블에 COMMENT가 있다.
|
||||
후행 공백·탭·CR·130자 초과 행은 발견하지 않았다. SQL 파싱/실행이나 물리 스키마 호환성을 검증한 결과가 아니다.
|
||||
이 담당은 테스트·빌드·DB 실행·AWS 접근·외부 저장소 조회를 하지 않으며 성공 결과를 주장하지 않는다.
|
||||
상위 구현 검증도 이번 작업에서 새로 작성한 테스트만 실행하도록 승인되어 있다. 기존 테스트 실행은 승인 범위가 아니다.
|
||||
로컬 HTTP·새 테스트·빌드 결과는 구현 담당의 별도 증거이며, 그 성공도 위 운영 게이트를 해제하지 않는다.
|
||||
@@ -0,0 +1,132 @@
|
||||
# 자막 Phase 1 Spring 구현 검증 기록
|
||||
|
||||
> 상태: **재생성 제외 로컬 backend 구현·신규 테스트 15개·독립 리뷰 완료**. 운영 배포 완료가 아니다.
|
||||
|
||||
## 범위와 결정
|
||||
|
||||
- 이번 범위: 기존 업로드의 PENDING/source 선등록, 네 BOT 내부 endpoint, raw-body receipt, 기존 상세의 언어·권한별 자막 제공.
|
||||
- 재생성은 사용자 결정으로 제외했다. 새 공개 재생성 API, 직접 START 호출, Scheduler/outbox를 만들지 않는다.
|
||||
- 후속 요구로 언어별 결과는 `Subtitle` 행에 저장한다. 이번 callback 허용 언어/완료 판정은 ko/ja만 유지하며 다른 언어를 활성화하지 않는다.
|
||||
- 테스트 실행은 사용자 후속 지시 이후 **이번 작업에서 새로 작성한 테스트만** 대상으로 한다. 기존 클래스에 추가한 사례도 메서드 selector로 한정한다.
|
||||
|
||||
## 실제 조사와 보존
|
||||
|
||||
- 현재 Spring에 subtitle/recovery/자막 HMAC/재생성 action이 발견되지 않았다. 제거할 해당 코드가 없으므로 실제 삭제 없음.
|
||||
- 공유 member login/JWT/MemberRole.BOT, Agora HMAC, 텍스트 TranslationJob/worker, S3Uploader와 기존 MediaConvert 관련 외부 코드/설정은 보존 대상이다.
|
||||
- 기존 MediaConvert 완료 API의 ADMIN/BOT 권한은 유지한다. 새 원본 UUID에 대한 source-bound 경로 검증과 기존 파일 검증을 구분한다. 실제 consumer ID 추출·출력 규칙 호환성은 운영 확인 전 배포 BLOCKED다.
|
||||
- Flyway/Liquibase 의존성 및 자막 migration은 현재 프로젝트에서 발견되지 않았다. 운영/공유 환경의 적용 schema/data 상태는 조회하지 않았다. additive SQL 문서만 준비하며 DB에는 적용하지 않는다.
|
||||
|
||||
## Lambda와 운영 전환
|
||||
|
||||
명시된 `aws-subtitle-service/docs/FINAL_REPORT.md`와 실제 로컬 ZIP을 대조했다.
|
||||
|
||||
| 산출물 | SHA-256 |
|
||||
|---|---|
|
||||
| `start-subtitle-transcription.zip` | `a0d0b19b2ecb24c0f89caf528bf09fd039558563a99063f082ac64418704abfd` |
|
||||
| `generate-subtitle.zip` | `bfa8742ca436bace853ec2540fc5f70daa24cbab49e75315565d980189fbf209` |
|
||||
|
||||
파일 hash 일치는 AWS 배포 증거가 아니다. 운영 legacy consumer/caller/in-flight/Scheduler/배포 버전은 미확인이다. AWS 호출·설정·배포·중지·삭제·재개를 수행하지 않았다. 운영자는 inventory 후 실제 가동 중인 경우에만 pause/drain하고 Phase 1 backend/Lambda를 조정 배포해야 한다. S3/DB purge나 legacy fake work API는 금지한다.
|
||||
|
||||
## 실행 기록
|
||||
|
||||
| 시점 | 명령/검증 | 관찰 결과 |
|
||||
|---|---|---|
|
||||
| 테스트 범위 변경 지시 전 | 기존 `AudioContentServiceTest`, `MemberLoginResponseContractTest`, `TokenProviderTest` focused baseline | 부모 실행은 kapt 단계 200초 제한 종료, 테스트 판정 없음 |
|
||||
| 테스트 범위 변경 지시 전 | 구현 담당자의 격리 focused 시도 | `red-focused.log`: 41 cases 중 새 사례의 JWT key setup 1건 실패. 기능 RED로 인정하지 않음. 기존 테스트 실행은 이후 중단 |
|
||||
| RED | 새 `SubtitleHttpIntegrationTest.shouldRejectActualNonBotLoginJwt` | `red-http.log`: 실제 로그인한 non-BOT의 자막 접근 차단 실패를 재현. 최종 GREEN 아님 |
|
||||
| 1차 새 테스트/HTTP | 부모가 신규 `SubtitleHttpIntegrationTest` 직접 재실행 | **13 passed, failures/errors/skipped 0**. XML timestamp `2026-11-10T14:33:59`, `BUILD SUCCESSFUL in 2m 21s` |
|
||||
| bootJar/변경 범위 lint | 구현 담당 실제 실행 및 부모 같은 코드 재확인 | `build-lint.log`: `BUILD SUCCESSFUL in 1m 28s`; 부모 재확인은 UP-TO-DATE |
|
||||
| 리뷰 수정 RED | 신규 HTTP 2개 | 빈 body 422 기대/200 실제, DB 실패 500 기대/200 실제 확인 |
|
||||
| 최종 새 테스트/빌드/lint | `P1-R1` 수정 후 신규 자막 클래스 전체 | **15 passed, failures/errors/skipped 0**, bootJar/변경 lint 실제 실행 성공, 7m44s |
|
||||
| 부모 최종 HTTP 재검증 | 추가된 새 2개 메서드만 직접 실행 | **2 passed**, 422/500 및 전체 rollback 확인. XML timestamp `2026-11-10T15:34:43`, 4m55s |
|
||||
| 독립 리뷰 | 1차 REQUEST_CHANGES 1건 수정 후 delta 재검토 | **REV-P1-001 ADDRESSED / 로컬 backend APPROVE**, 추가 blocker 없음 |
|
||||
|
||||
테스트 fixture는 로컬 합성 데이터와 H2, 실제 localhost HTTP, 외부 S3/서명 경계 대체를 사용한다. 실제 음성 또는 live S3 품질 검증이 아니다. LSP diagnostics 도구가 제공되지 않아 실제 Kotlin compile/lint 결과를 대체 증거로 기록했으며 LSP 성공을 주장하지 않는다.
|
||||
|
||||
실제 검증한 HTTP 결과: BOT 허용 및 non-BOT 403/익명 401, unknown 404, callback 수락/replay 200,
|
||||
같은 eventId의 변경 body 422, terminal 다른 결과 409, 동시 receipt 경합 200/422 및 loser rollback.
|
||||
기존 콘텐츠 상세에서 preview-only의 자막 null, 구매/소유자/무료 및 공개 시점별 ko/ja 선택을 검증했다.
|
||||
동일 job 결과는 언어별 행 ID를 보존하며, 이전 job 완료가 최신 선택값을 바꾸지 않는 것을 확인했다.
|
||||
|
||||
실제 HTTP 증거는 테스트 JVM PID `23390`, Tomcat `127.0.0.1:59074`, XML timestamp
|
||||
`2026-11-10T14:33:59`다. 실행 후 `ps -p 23390,20950,22207`, `lsof -iTCP:59074 -sTCP:LISTEN`,
|
||||
작업 root를 대상으로 한 `pgrep`에서 남은 프로세스/listener가 없음을 확인했다. 임시 산출물은 리뷰 후 정리했다.
|
||||
|
||||
후속 HTTP 검증은 PID `44707`, Tomcat port `59977`에서 실행했다. 빈 callback/started는 422이고,
|
||||
receipt 저장 실패는 `{"error":"Subtitle request failed"}`와 500을 반환하며 상태/오류/결과/receipt가 rollback된다.
|
||||
구현 담당의 첫 GREEN은 컴파일 600초 제한으로 종료됐으나 캐시 재사용 후 성공했다. 실패 이력은 삭제하지 않았다.
|
||||
후속 PID `44707`/`28638`/`37727`/`39291`, port `59977`, 작업 root 참조 프로세스의 부재도 확인했다.
|
||||
|
||||
## 내부 API와 저장 구조
|
||||
|
||||
| Method | Path |
|
||||
|---|---|
|
||||
| GET | `/internal/api/subtitle-jobs/resolve` |
|
||||
| GET | `/internal/api/subtitle-jobs/{jobId}` |
|
||||
| POST | `/internal/api/subtitle-jobs/{jobId}/started` |
|
||||
| POST | `/internal/api/subtitle-jobs/callback` |
|
||||
|
||||
네 API는 기존 로그인 JWT와 활성 DB BOT role을 요구한다. 조회는 최소 DTO이며 callback은 원문 bytes hash를 사용한다.
|
||||
`subtitle_job`, 언어별 `subtitle`, `subtitle_callback_receipt`와 `content.latest_subtitle_job_id`의
|
||||
additive SQL만 작성했다. 실제 DB 적용은 하지 않았다. 미래 언어는 결과 행으로 저장할 수 있지만
|
||||
현재 허용 언어는 ko/ja뿐이고 향후 Lambda·API·조회 정책 변경 없이 새 언어가 자동 활성화되지는 않는다.
|
||||
|
||||
공유 로그인/JWT/업로드/MediaConvert는 보존했다. 현재 저장소에는 삭제할 자막 recovery code가 없어서 삭제 파일은 0개다.
|
||||
외부 프로젝트, AWS 리소스, 적용 migration 및 운영 데이터는 변경하지 않았다. commit/push도 수행하지 않았다.
|
||||
|
||||
전체 변경 경로·신규 case 이름·재현 명령·실패/수정 이력은 [implementation-report.md](implementation-report.md)에 있다.
|
||||
로컬 mapping의 업로드 오류 시각 TIMESTAMP 불일치는 수정·대조했으나 운영 MySQL 실행 증거는 아니다.
|
||||
테스트 전체 Spring context에서 기존 worker의 H2/MySQL 구문 차이 로그가 발생했다. 해당 worker 성공을 주장하지 않는다.
|
||||
|
||||
## 외부 차단 항목
|
||||
|
||||
| 항목 | 상태와 재개 조건 |
|
||||
|---|---|
|
||||
| 운영 DB | BLOCKED: 기존 schema 확인 및 additive DDL 승인·적용 필요 |
|
||||
| old consumer coordination | BLOCKED: 원격 배포/사용/in-flight 조사와 운영자 조정 필요 |
|
||||
| MediaConvert/CloudFront | BLOCKED: UUID ID 추출·출력 key 규칙 및 자막 private origin/서명 접근 확인 필요 |
|
||||
| live ko/ja/mixed | BLOCKED: 승인된 실제 S3 입력부터 callback/콘텐츠/앱 표시 검증 필요. mixed는 실제 word/item 언어 evidence 필수 |
|
||||
| 2시간 입력 목표 | BLOCKED: 실제 처리 시간/예산 미측정. 상한을 내리거나 SLA/지원 성공을 주장하지 않음 |
|
||||
|
||||
전체 테스트는 실행하지 않는다. 사용자가 별도로 전체 검증을 원하면 `./gradlew test`를 실행할 수 있다. 기존 회귀 미실행에 따른 잔여 위험은 새 기능 테스트 성공과 구분한다.
|
||||
|
||||
## 종료 정리와 재현
|
||||
|
||||
2026-11-11 리뷰 승인 후 이번 작업 소유 임시 root `subtitle-phase1.wiRcAJ`와 그 안의 build/cache/log/JAR를 제거했다.
|
||||
`test ! -e` exit0으로 부재를 확인했고 공유 캐시·기존 worker는 변경하지 않았다. 위 임시 경로는 과거 검증 위치이며,
|
||||
검증 결과와 실패 이력은 이 문서 및 구현/리뷰 보고서에 보존했다.
|
||||
|
||||
정상 개발 환경에서 이번에 작성한 테스트만 다시 실행하는 명령:
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest'
|
||||
```
|
||||
|
||||
## 후속 완료: generate-url 자막 응답
|
||||
|
||||
2026-11-11 요청에 따라 `GET /audio-content/{id}/generate-url`에도 상세와 동일한 nullable `subtitle`을 추가했다.
|
||||
기존 `contentUrl`과 활성 구매 검사/음원 URL 동작은 유지하며 자막만 요청 언어·완료 상태·공개 시점 조건을 따른다.
|
||||
이번 후속에서 새로 작성한 HTTP3개만 실행했고 부모 재실행도 3/0/0/0으로 통과했다. 독립 리뷰 APPROVE다.
|
||||
컴파일/bootJar/테스트 lint는 성공했으나 production lint는 시작 전부터 있던 서비스 EOF 공백1건을 지적해 보존했다.
|
||||
이 결과를 기존15개를 포함한 클래스 전체 재실행 성공으로 표현하지 않는다.
|
||||
상세 명령/선택자/응답 예시/잔여 게이트는 [generate-url-report.md](generate-url-report.md)에 있다.
|
||||
|
||||
## 후속 회귀: s3 컬럼 schema-validation
|
||||
|
||||
2026-11-11 실제 기동의 missing `[s3bucket]` 오류를 독립 H2 table과 Hibernate `validate`로 재현했다.
|
||||
기본 명명전략이 숫자 경계를 분리하지 않는 것을 기존 정적 대조와 create-drop 테스트가 놓친 문제다.
|
||||
`Subtitle`의 두 Column 이름만 `s3_bucket`, `s3_key`로 명시했고 DDL/운영 DB/JSON 계약은 바꾸지 않았다.
|
||||
신규 회귀 테스트1개에서 validation과 Hibernate/JDBC 저장·재조회가 통과했으며 bootJar/변경 lint 및 독립 리뷰 APPROVE다.
|
||||
실제 운영 MySQL 재기동은 수행하지 않았다. 상세 원인·검증은 [schema-validation-report.md](schema-validation-report.md)에 기록했다.
|
||||
|
||||
## 커밋 검증 후속 기록
|
||||
|
||||
2026-11-11 별도 사용자 `/commit-with-validation` 요청으로 변경을 커밋했다.
|
||||
기능 연동 커밋은 pre-commit `ktlintCheck`에서 위에 기록된 서비스 EOF 빈 줄 1건으로 처음 거부됐다.
|
||||
해당 빈 줄만 제거한 뒤 재시도하여 staged Kotlin 대상 lint, `git diff --staged --check` 및
|
||||
`work/scripts/check-commit-message-rules.sh`의 커밋 직전·직후 검증이 통과했다.
|
||||
기존 EOF 미해결 기록은 당시 상태이며 이 커밋 준비 과정에서 해소됐다.
|
||||
|
||||
이번 커밋 작업에서는 기능 테스트·전체 테스트·bootJar를 재실행하지 않았다.
|
||||
기존 테스트 결과는 위 실행 시점의 기록이며 운영 배포 BLOCKED는 유지한다.
|
||||
사용자가 전체 테스트를 실행할 때의 명령은 `./gradlew test`다.
|
||||
@@ -0,0 +1,101 @@
|
||||
# generate-url 자막 후속 구현 검증
|
||||
|
||||
## 범위와 결과
|
||||
|
||||
- 기준: `prd.md` §7, `plan-task.md` `P2-T1`. Phase 1은 재구현하지 않았다.
|
||||
- 이번 production 변경은 `content/GenerateUrlResponse.kt`의 기존 DTO import/nullable 필드 추가와 `content/AudioContentService.kt`의 `generateUrl` 본문뿐이다. DTO는 `GenerateUrlResponse(contentUrl: String, subtitle: ContentSubtitleResponse? = null)`이다.
|
||||
- 기존 `orderRepository.isExistOrdered`를 그대로 사용한다. 구매 없는 소유자/무료, 비활성 KEEP, 만료 RENTAL은 빈 `contentUrl`과 명시적 null `subtitle`이다. 활성 구매의 기존 음원 경로/만료 기간/envelope는 유지했다.
|
||||
- 구매자는 하나의 `expirationTime`을 음원 서명과 `SubtitleJobService.subtitle(audioContent, langContext.lang, expirationTime)`에 전달한다. 자막만 미지정/도래한 공개일 또는 소유자 조건을 적용한다. 예약 구매자의 기존 음원 URL은 유지한다.
|
||||
- 기존 `SubtitleHttpIntegrationTest`의 15개 메서드/도우미를 보존하고 HTTP 테스트 3개와 세 테스트가 공용으로 사용하는 요청 도우미 1개만 추가했다. 다른 dirty 파일, PRD/plan/final-report, controller/DB/Lambda는 수정하지 않았다.
|
||||
|
||||
## 새 HTTP 테스트
|
||||
|
||||
클래스: `kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest`. 실행은 아래 세 메서드 selector만 사용했다.
|
||||
|
||||
1. `shouldIncludeRequestedSubtitleInGenerateUrl`: 활성 KEEP 구매/공개 완료 ko·ja, 정확한 `{contentUrl,subtitle}` 및 자막 3필드, 상세와 동일한 자막, 음원/SRT 경로와 같은 14,400,000ms 서명 인자, 기존 success envelope.
|
||||
2. `shouldReturnNullSubtitleInGenerateUrlWhenUnavailable`: PENDING/최신 job 미등록/부분 완료 ko 누락/미지원 en의 명시적 null과 음원 URL 보존, 부분 완료 ja 정상 제공.
|
||||
3. `shouldPreserveGenerateUrlPurchaseAndReleaseRestrictions`: 미구매자·소유자·무료 및 비활성/만료 주문 차단, 활성 RENTAL 구매자의 예약 음원 URL 보존/자막 차단, 활성 구매 소유자의 예약 예외, 공개일 null/과거 허용, 익명 401.
|
||||
|
||||
실제 EMAIL 로그인/JWT, RANDOM_PORT Tomcat HTTP, H2와 기존 fixture/callback 도우미를 사용했다. 외부 S3/CloudFront 경계만 기존 mock을 재사용했다. 서명 URL은 전달 경로/만료 인자 검증이며 실제 CloudFront 암호학적 서명이나 다운로드 검증은 아니다.
|
||||
|
||||
## 명령과 증거
|
||||
|
||||
소유 root는 아래 `VERIFY_ROOT`다. build/project-cache/TMPDIR/Node/Bun 캐시를 격리했으며 init script는 production 소스를 필터링하지 않는다. 테스트 compile만 이 클래스와 필요한 support 3파일로 한정한다. `test.outputs.upToDateWhen { false }`로 실제 실행을 강제한다.
|
||||
|
||||
```bash
|
||||
export VERIFY_ROOT=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/generate-url-subtitle.fSBkJM/implementer
|
||||
export TMPDIR="$VERIFY_ROOT/tmp" NODE_COMPILE_CACHE="$VERIFY_ROOT/node-cache"
|
||||
export npm_config_cache="$VERIFY_ROOT/node-cache" BUN_INSTALL_CACHE_DIR="$VERIFY_ROOT/bun-cache"
|
||||
export AWS_EC2_METADATA_DISABLED=true
|
||||
G=(./gradlew --offline --no-daemon --max-workers=2
|
||||
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs=-Xmx4g
|
||||
--project-cache-dir "$VERIFY_ROOT/project-cache"
|
||||
--init-script "$VERIFY_ROOT/verification.init.gradle")
|
||||
S=(--tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldIncludeRequestedSubtitleInGenerateUrl'
|
||||
--tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldReturnNullSubtitleInGenerateUrlWhenUnavailable'
|
||||
--tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldPreserveGenerateUrlPurchaseAndReleaseRestrictions')
|
||||
|
||||
# RED: production 변경 전
|
||||
"${G[@]}" test "${S[@]}" > "$VERIFY_ROOT/red-assertions.log" 2>&1
|
||||
# 첫 GREEN
|
||||
"${G[@]}" test "${S[@]}" ktlintCheck bootJar > "$VERIFY_ROOT/green-build-lint.log" 2>&1
|
||||
# 입력 3파일을 고정/확인한 production+test lint
|
||||
"${G[@]}" verifyLintScope ktlintCheck bootJar > "$VERIFY_ROOT/final-verified-scope-lint-build.log" 2>&1
|
||||
# 기존 dirty EOF 보존 후 최종 HTTP/컴파일/테스트 lint/bootJar
|
||||
"${G[@]}" test "${S[@]}" verifyLintScope ktlintTestSourceSetCheck bootJar > "$VERIFY_ROOT/final-green-build-test-lint.log" 2>&1
|
||||
```
|
||||
|
||||
| 검증 | 결과/증거 |
|
||||
|---|---|
|
||||
| RED | exit 1, 50초, 3 tests/3 failures/0 errors/0 skipped. 모두 `has("subtitle")`에서 expected true/actual false. `red-assertions.log`, `red-summary.log` |
|
||||
| 첫 GREEN | exit 0, `BUILD SUCCESSFUL in 3m 35s`, HTTP 3개 통과 및 compile/bootJar. lint의 정확한 입력 증거는 이후 고정한 실행을 기준으로 한다. |
|
||||
| 정확한 변경 lint | 입력은 production 2파일+이 테스트 1파일, Kotlin script 0개임을 `verifyLintScope` assertion으로 확인. production lint는 **기존 EOF 빈 줄 1건 실패**: `AudioContentService.kt:1482:1 Needless blank line(s)`. 그 외 지적 없음. `final-verified-scope-lint-build.log` |
|
||||
| 최종 GREEN | exit 0, `BUILD SUCCESSFUL in 3m 30s`, compileKotlin/compileTestKotlin/bootJar 실제 실행, 테스트 lint 통과. HTTP XML 3 tests/0 failures/0 errors/0 skipped, timestamp `2026-11-10T22:07:38` (UTC). `final-green-build-test-lint.log`, `green-summary.log` |
|
||||
|
||||
최초 임시 init의 상대 source 경로로 `test NO-SOURCE`가 된 `red.log`는 RED 증거가 아니다. `project.file`로 수정 후 실제 assertion RED를 확인했다. `final-scope-lint-build.log`도 lint scope introspection assertion 실패 기록이며 HTTP 실패가 아니다. 이후 init에서 lint 입력 파일을 명시적으로 고정하고 검증했다. 이 이력과 로그를 삭제하지 않았다.
|
||||
|
||||
## 보존 사항과 남은 게이트
|
||||
|
||||
- 시작 전 `git diff`에도 있던 서비스 EOF 빈 줄은 타 dirty 변경 보존 지시에 따라 유지했다. `git diff --check` 역시 같은 EOF 1건을 지적하므로 lint 전체 clean을 주장하지 않는다. 테스트/새 DTO lint 오류는 없다.
|
||||
- LSP 도구는 제공되지 않았다. Kotlin compile과 실제 3파일 ktlint 결과로 대체했다. 기존 Kotlin 경고와 테스트 종료 중 Redis reconnect 경고가 있으나 새 테스트 실패/오류는 없다.
|
||||
- 기존 15개 테스트/기타 기존 테스트/전체 test/check/build는 실행하지 않았다. 사용자 전체 테스트 명령은 `./gradlew test`이며 이번 승인된 자동 검증 범위 밖이다.
|
||||
- RED PID 14462/HTTP 50806, 첫 GREEN PID 15417/HTTP 50833, 최종 GREEN PID 16741/HTTP 50879는 테스트 종료로 정상 종료했다. 세 PID와 HTTP/Redis port 50806/50833/50879/50803/50831/50877에 `ps`/`lsof` 조회 결과가 없었다. 공유 프로세스를 종료하거나 task 취소를 호출하지 않았다. 부모 재검증을 위해 소유 root/init/log/JAR는 남겼다.
|
||||
- AWS/외부 저장소/운영 DB/배포/live ko·ja·mixed/2h 처리/앱/실제 CloudFront 다운로드는 미검증이다. 부모 재검증과 독립 리뷰도 이 보고서의 완료 주장에 포함하지 않는다. credential/JWT/request body는 로그/보고서에 출력하지 않았으며 최종 XML과 로그의 관련 패턴 검색 결과도 없었다.
|
||||
|
||||
## 부모 재검증
|
||||
|
||||
- 같은 격리 init 및 정확히 새 메서드 3개 selector로 직접 재실행했다. `parent-verification.log`의 결과는 exit0,
|
||||
`BUILD SUCCESSFUL in 37s`이며 test는 실제 실행, bootJar/테스트 lint는 동일 코드의 UP-TO-DATE 확인이다.
|
||||
- XML: 3 tests/0 failures/0 errors/0 skipped, UTC timestamp `2026-11-10T22:12:51`. 실제 Tomcat PID17759, HTTP port50914다.
|
||||
- 부모가 DTO·generateUrl의 전후 diff 및 추가 테스트 전체를 읽었다. 공유 자막 조회와 기존 구매 판정·음원 URL이 유지된다.
|
||||
- 독립 리뷰와 임시 자원 정리는 아래 후속 기록으로 남긴다. 기존 production EOF lint1건은 성공으로 덮어쓰지 않는다.
|
||||
|
||||
## 독립 리뷰 및 사용 계약
|
||||
|
||||
- 제한 독립 리뷰 `ses_e387b1e8fffensjb6dnoRk82ST`: **APPROVE**. 이번 production delta의 명세·품질 blocker나 새 회귀 없음.
|
||||
- `data.contentUrl`과 기존 envelope는 유지하며 `data.subtitle`만 추가된다. 요청 언어는 기존 `Accept-Language`/`LangContext`를 따른다.
|
||||
- 자막 응답 예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"contentUrl": "https://cdn.example.com/audio-signed-url",
|
||||
"subtitle": {
|
||||
"language": "ko-KR",
|
||||
"format": "SRT",
|
||||
"url": "https://cdn.example.com/subtitle-signed-url"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 구매/공개 조건을 충족하지 못하거나 미완료·해당 언어 자막이 없으면 `subtitle`은 명시적 null이다. 기존 URL 권한을 확대하지 않는다.
|
||||
- 부모 HTTP PID17759 및 자식 PID14462/15417/16741, port50914, 소유 root 참조 프로세스는 `ps`/`lsof`/`pgrep`에서 부재를 확인했다.
|
||||
- 리뷰 승인/결과 문서 보존 후 작업 소유 root `generate-url-subtitle.fSBkJM`과 하위 build/cache/log/JAR를 제거했고 `test ! -e` exit0으로 확인했다. 위 임시 log/init 경로는 과거 검증 위치이며 공유 캐시는 건드리지 않았다.
|
||||
|
||||
일반 개발 환경에서 이번 후속 테스트만 실행하는 명령:
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldIncludeRequestedSubtitleInGenerateUrl' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldReturnNullSubtitleInGenerateUrlWhenUnavailable' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldPreserveGenerateUrlPurchaseAndReleaseRestrictions'
|
||||
```
|
||||
@@ -0,0 +1,192 @@
|
||||
# 자막 Phase 1 Kotlin 구현 보고
|
||||
|
||||
## 결과와 검증 경계
|
||||
|
||||
- 재생성을 제외한 기존 업로드 등록, 네 BOT 내부 API, callback 원자 저장, 기존 콘텐츠 상세의 권한별 ko/ja 자막 조회를 구현했다.
|
||||
- 새로 작성한 `SubtitleHttpIntegrationTest` 13개는 실제 RANDOM_PORT localhost HTTP/H2에서 전부 통과했다. 외부 AWS 클라이언트와 서명 URL 생성만 대체하고, 회원 서비스·로그인·비밀번호 검증·JWT·DB principal/role은 실제 경로를 사용했다.
|
||||
- `bootJar`와 변경 Kotlin 파일에 한정한 `ktlintCheck`가 성공했다. LSP 도구는 제공되지 않아 실행하지 않았으며 실제 Kotlin/JPA 생성 코드·테스트 컴파일과 lint로 대체했다.
|
||||
- PRD/계획과 SQL/deployment 문서는 병렬 담당자 소유다. 이 담당자는 해당 문서를 수정하지 않았으며 전체 Phase gate/독립 리뷰 완료를 대신 선언하지 않는다.
|
||||
|
||||
## 구현 선택
|
||||
|
||||
1. `AudioContentService.createAudioContent` 두 overload와 AI 관리자 create orchestration은 `NOT_SUPPORTED`다. 필수 주입된 transaction manager의 `TransactionTemplate(REQUIRES_NEW)`가 content·tags·cover·source/job·latest pointer를 commit한 뒤 원본 S3 업로드를 시작한다. 업로드 후 content의 input 경로를 다시 대입하지 않으므로 조기 MediaConvert 완료 경로를 되돌리지 않는다. 성공 후 기존 언어 이벤트는 별도의 짧은 실제 쓰기 트랜잭션에서 발행한다.
|
||||
2. 원본은 `input/{contentId}/{canonical UUID}.{safe actual extension}`이다. multipart filename의 경로는 버리고 영문/숫자 1~16자리 실제 확장자만 소문자로 보존한다. 예를 들어 `C:\client\dir/file.MP3`는 UUID `.mp3`로 등록된다. 확장자가 없거나 안전하게 보존할 수 없으면 UUID만 사용한다. 업로드 자체를 새로 거부하거나 `.mp3`로 허위 변경하지 않는다. 확장자는 형식 hint일 뿐 실제 오디오 검증 증거가 아니다.
|
||||
3. `SubtitleJob.outputKey`는 확장자를 제외한 `output/{contentId}/{same UUID}` stem이다. 새 등록의 upload-complete는 이 정확한 stem과 `aac`/`m4a`/`mp3` 확장자 조합만 수락한다. 다른 콘텐츠, 다른 UUID, 추가 suffix, 임의 디렉터리, 비오디오 확장자는 거부한다. source가 없는 기존 콘텐츠의 동작은 기존 prefix 검증을 유지한다. 실제 consumer의 basename·ID 추출 호환성은 여전히 배포 전 확인 대상이다.
|
||||
4. 언어 결과는 `Subtitle` 행에 저장한다. `jobId` scalar와 `unique(job_id, language)`, 실제 `language`·`format`·`s3Bucket`·`s3Key`를 보존한다. callback 허용 언어는 여전히 ko-KR/ja-JP이며 상태 규칙은 2/1/0개 그대로다. 미래 언어 활성화/새 provider framework는 추가하지 않았다.
|
||||
5. callback은 원래 HTTP `ByteArray` SHA-256을 receipt와 함께 저장한다. `REQUIRES_NEW`/`READ_COMMITTED`에서 receipt를 먼저 조회하고 job lock 뒤 다시 조회한다. 기존 eventId로 unknown job을 보낸 변경 body도 422다. receipt는 JPA `Persistable.isNew=true`로 insert하여 merge가 경합한 다른 receipt를 update하지 못하게 했다. unique 경합은 rollback 종료 후 새 트랜잭션에서 receipt를 읽어 200/422를 판정한다. terminal의 동일 결과는 행 ID를 보존하고, 다른 결과는 409다.
|
||||
|
||||
## 변경 경로
|
||||
|
||||
### 신규 production
|
||||
|
||||
```text
|
||||
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/domain/SubtitleJobStatus.kt
|
||||
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/dto/SubtitleJobDtos.kt
|
||||
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJob.kt
|
||||
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJobRepository.kt
|
||||
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/Subtitle.kt
|
||||
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleRepository.kt
|
||||
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceipt.kt
|
||||
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceiptRepository.kt
|
||||
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/application/SubtitleJobService.kt
|
||||
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleJobController.kt
|
||||
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleRequestParser.kt
|
||||
src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleExceptionHandler.kt
|
||||
```
|
||||
|
||||
### 기존 production 연결
|
||||
|
||||
```text
|
||||
src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt
|
||||
src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt
|
||||
src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt
|
||||
src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt
|
||||
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentFacade.kt
|
||||
```
|
||||
|
||||
### 테스트와 보고
|
||||
|
||||
```text
|
||||
src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/SubtitleHttpIntegrationTest.kt
|
||||
src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt
|
||||
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentCreateTest.kt
|
||||
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/LegacyCreatorAdminAudioContentCharacterizationTest.kt
|
||||
docs/20261110_자막Phase1연동/implementation-report.md
|
||||
```
|
||||
|
||||
- 기존 수동 `AudioContentService` 생성자는 필수 자막 서비스/transaction manager 인자를 반영했다.
|
||||
- 기존 생성 통합 테스트의 성공 fixture는 등록 전에 실제 commit되도록 조정했다. audio 업로드/후속 이벤트 실패 테스트는 이미 commit된 등록을 보존한다는 새 승인 계약에 맞췄다. 실패 테스트를 삭제하거나 성공 assertion을 느슨하게 하지 않았다.
|
||||
- 이 기존 테스트들은 후속 사용자 지시 이후 **실행하지 않았다**. 관련 기존 5개 클래스는 새 테스트 실행의 컴파일 입력에 포함했지만 실행 selector는 신규 자막 패키지뿐이다.
|
||||
- production 파일 삭제, 기존 JWT/member/텍스트 TranslationJob worker 변경, 외부 프로젝트 변경, 신규 AWS SDK 의존성, 재생성 API/START 호출은 없다.
|
||||
|
||||
## 새 테스트 selector
|
||||
|
||||
전체 신규 클래스만 실행하는 selector:
|
||||
|
||||
```bash
|
||||
--tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest'
|
||||
```
|
||||
|
||||
개별 selector는 아래 클래스명 뒤에 각 메서드를 붙인다. 모두 이번 작업에서 새로 작성한 case다.
|
||||
|
||||
```text
|
||||
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldRejectActualNonBotLoginJwt
|
||||
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldUseRealBotJwtAndStoreCallbackBeforeStartedWithExactByteReplay
|
||||
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldStartMonotonicallyAndAcceptPartialAndFailedResults
|
||||
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldReturn404WithoutDiscoveryAndCheckCurrentDatabaseRole
|
||||
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldRejectMalformedContractsWithoutPersistingAnything
|
||||
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldSerializeConcurrentCallbacksAndRollbackLosingJob
|
||||
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldRollbackResultsAndStatusWhenReceiptInsertFails
|
||||
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldKeepLatestPointerWhenOlderJobCompletes
|
||||
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldCommitRegistrationBeforeUploadAndNeverRevertEarlyMediaConvertOutput
|
||||
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldKeepAcceptedSourceAndTerminalResultWhenUploadReturnFails
|
||||
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldPreserveOnlySafeActualSourceExtensionsWithoutClaimingConversion
|
||||
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldExposeLanguageSubtitleOnlyWithReleasedOriginalAccessInExistingDetail
|
||||
kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldReadOnlyPersistedLanguageOfMatchingLatestCompletedJob
|
||||
```
|
||||
|
||||
## 재현 명령과 아티팩트
|
||||
|
||||
작업 전용 root는 삭제하지 않고 부모 검증에 인계한다.
|
||||
|
||||
```bash
|
||||
ROOT=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/subtitle-phase1.wiRcAJ/backend.HX0j0B
|
||||
export TMPDIR="$ROOT"
|
||||
export NODE_COMPILE_CACHE="$ROOT/node"
|
||||
export BUN_RUNTIME_TRANSPILER_CACHE_PATH="$ROOT/bun"
|
||||
|
||||
./gradlew --no-daemon --max-workers=2 \
|
||||
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs=-Xmx4g \
|
||||
--project-cache-dir "$ROOT/gradle-cache" -I "$ROOT/isolation.gradle" \
|
||||
test --tests 'kr.co.vividnext.sodalive.v2.subtitle.*'
|
||||
|
||||
./gradlew --no-daemon --max-workers=2 \
|
||||
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs=-Xmx4g \
|
||||
--project-cache-dir "$ROOT/gradle-cache" -I "$ROOT/isolation.gradle" \
|
||||
bootJar ktlintCheck
|
||||
```
|
||||
|
||||
- 임시 init script는 build 출력과 Gradle project cache를 공유 출력에서 격리한다. 테스트 Kotlin 컴파일 입력은 신규 자막/직접 영향 기존 5개 클래스/support이며, lint filter도 변경 Kotlin 파일만 대상으로 한다. 전역 설정이나 저장소 build 설정은 변경하지 않았다.
|
||||
- 성공 테스트: `$ROOT/green-new-tests-cached.log`, `BUILD SUCCESSFUL in 1m 32s`. 임시 script의 사용하지 않는 JWT 기본값을 제거한 뒤 다시 실행한 최종 `$ROOT/final-new-tests.log`도 `BUILD SUCCESSFUL in 2m 25s`다. XML: `$ROOT/build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.xml`, `tests=13`, `failures=0`, `errors=0`, `skipped=0`.
|
||||
- 실제 두 job의 동일 eventId 경합에서 H2 SQLState `23505` unique 위반이 관찰됐고, 최종 HTTP 200/422와 loser job의 PENDING/결과 행 없음 assertion이 통과했다. 별도의 receipt 쓰기 실패 주입에서도 identity 생성으로 flush된 결과 행까지 rollback됨을 확인했다.
|
||||
- 성공 빌드/lint: `$ROOT/build-lint.log`, `BUILD SUCCESSFUL in 1m 28s`. 실행 가능한 boot JAR은 `$ROOT/build/libs/sodalive-0.0.1-SNAPSHOT.jar`다. `jar tf`에서 새 컨트롤러/서비스/엔티티/repository/DTO가 `BOOT-INF/classes`에 포함된 것을 확인했다.
|
||||
- 검증 완료 후 root를 참조하는 작업 소유 Gradle/HTTP/Redis 프로세스는 관찰되지 않았다. 기존 공유 worker PID `55393`과 그 Redis PID `55452`는 이 작업 소유가 아니며 종료하지 않았다. 테스트 실행 중 관찰된 작업 소유 executor PID `20950`과 최종 executor PID `22207`은 종료됐다.
|
||||
|
||||
## RED와 실패 이력
|
||||
|
||||
- 첫 baseline/신규 RED 명령은 테스트 컴파일 중 600초 제한으로 종료됐다(`red.log`). 테스트 결과가 아니며 RED로 인정하지 않았다.
|
||||
- `red-focused.log`는 이전 지시에 따른 기존 3개 회귀 클래스와 신규 1개 case 실행이다. 총 41개 중 1개가 테스트 JWT key setup에서 실패했다. 이를 기능 RED로 인정하지 않았다. 이 명령은 기존 테스트 실행 금지라는 후속 사용자 지시 전의 기록이며 현재 구현의 회귀 성공 증거로 사용하지 않는다.
|
||||
- 테스트 전용 `DynamicPropertySource`로 유효 길이의 비운영 JWT key를 지정한 뒤 `red-http.log`에서 실제 로그인 성공 후 `403 기대 / 404 실제`를 확인했다. BOT 전용 경로가 없던 동작의 실제 RED다.
|
||||
- 초기 `red-binding.log`는 JPA unique constraint의 logical/physical column 이름 오류로 context가 뜨지 못했다. 이를 RED로 인정하지 않고 엔티티에 명시적 column 이름을 붙였다.
|
||||
- `red-binding-assertions.log`에서 새 두 case가 실제 assertion으로 실패했다: source 확장자 보존 실패, 기존 eventId의 unknown job 변경에 대해 `422 기대 / 404 실제`. 확장자 전달과 receipt 선조회/명시적 isolation 변경 후 같은 두 case를 포함한 13개 전체 신규 case가 GREEN이다.
|
||||
- `green-first.log`는 사용자 지시 갱신을 위한 중단으로 완료 결과가 없다. `green-new-tests.log`는 compile을 통과한 뒤 test 시작 시점에 600초 도구 제한으로 종료됐다. 공유 프로세스를 종료하지 않고 같은 격리 경로의 캐시를 재사용한 `green-new-tests-cached.log`에서 완료 결과를 얻었다.
|
||||
- `format.log`의 첫 변경 파일 format 실행은 길이 위반을 보고했다. 해당 새 줄만 정리한 뒤 최종 `ktlintCheck`가 통과했다.
|
||||
|
||||
## 외부 게이트와 남은 미검증
|
||||
|
||||
1. 실제 MediaConvert consumer가 기존 contentId filename prefix를 읽는지, UUID basename·출력 확장자·디렉터리를 어떻게 구성하는지 운영 증거가 없다. 로컬에서는 정확한 UUID stem과 AAC/M4A/MP3 경로 검증만 확인했다. consumer의 운영 호환성을 확인하기 전 배포 전환은 BLOCKED다.
|
||||
2. 부모가 승인된 ZIP에서 확인한 Transcribe 지원 확장자는 amr/flac/m4a/mp3/mp4/ogg/webm/wav다. Spring은 AAC/opus 등 기존 업로드를 임의로 거부하지 않는다. 이들 또는 누락/불안전 확장자에 대한 자막 성공은 보장하지 않으며 Lambda의 명시적 unsupported 결과/수동 확인 대상이다. 테스트의 한 바이트 파일은 source binding/transaction fixture일 뿐 실제 미디어 증거가 아니다.
|
||||
3. S3 putObject 수락 후 getUrl 실패는 성공으로 숨기지 않는다. 원래 예외를 호출자에게 전파하고 `SOURCE_UPLOAD_UNCERTAIN` 및 초 정밀도 TIMESTAMP를 별도 기록한다. 이미 완료된 자막 상태/metadata와 content를 삭제하거나 회귀시키지 않는다. 실제 객체/MediaConvert 상태는 운영자 수동 확인이 필요하다.
|
||||
4. 운영 MySQL DDL 적용, case-sensitive/NO PAD collation 및 FK/인덱스 검증, private CloudFront origin과 실제 SRT 서명 접근, 실서버 권한/앱 표시, live ko/ja/mixed 및 실제 duration/2시간 처리 예산은 미검증이다. AWS 호출·배포·DB 적용은 수행하지 않았다.
|
||||
5. 전체 테스트와 후속 지시 이후의 기존 회귀 case 실행은 수행하지 않았다. H2 전체 Spring context의 기존 charge/translation scheduler는 MySQL `FOR UPDATE SKIP LOCKED` 구문 때문에 로그 오류를 남겼다. 이번 신규 case는 모두 통과했지만 그 worker 기능의 성공 증거는 아니다. 사용자가 별도로 전체 테스트를 원할 때만 `./gradlew test`를 실행한다.
|
||||
|
||||
## 20261111 P1 후속 수정: body/DB 실패의 HTTP 200 오인 방지
|
||||
|
||||
### 수정 범위와 계약
|
||||
|
||||
- `SubtitleExceptionHandler.kt`의 기존 `SubtitleJobController` 전용 scope와 우선순위를 유지했다. `HttpMessageNotReadableException`은 HTTP 422와 기존 일반화된 거절 body로, 그 외 예상하지 못한 `Exception`은 HTTP 500과 `{"error":"Subtitle request failed"}`로 응답한다.
|
||||
- 기존 `ResponseStatusException`의 404/409/422 처리는 변경하지 않았다. 공통 `SodaExceptionHandler`, JWT, 컨트롤러, 서비스 및 DB 저장 로직은 변경하지 않았으며 예외 원문·요청·토큰 로그도 추가하지 않았다.
|
||||
- 수정 파일은 자막 advice, `SubtitleHttpIntegrationTest.kt` 및 이 보고서의 추가 기록뿐이다. 기존 직접 서비스 롤백 테스트 `shouldRollbackResultsAndStatusWhenReceiptInsertFails`와 기존 assertion은 그대로 보존했다. 부모 소유 PRD/계획/final-report는 수정하지 않았다.
|
||||
|
||||
### 새 HTTP 두 사례
|
||||
|
||||
1. `shouldRejectEmptyCallbackAndStartedBodiesWithoutPersistingAnything`: 실제 BOT 로그인/JWT로 빈 callback·started body를 모두 전송해 422를 확인한다. job/receipt/결과 행 수, PENDING 상태, 갱신 시각 및 오류 필드가 변경되지 않고 해당 job 결과도 없음을 검증한다.
|
||||
2. `shouldReturn500AndRollbackWhenReceiptInsertFailsOverHttp`: 실제 HTTP callback에서 기존 receipt repository SpyBean의 `saveAndFlush`에 DB 예외를 주입한다. ko-KR partial callback을 사용하여 상태뿐 아니라 오류 필드도 변경 대상에 포함하고, 일반화된 500 body 및 receipt·결과 부재, PENDING/갱신 시각/오류 필드 롤백을 DB 재조회로 확인한다.
|
||||
|
||||
### RED/GREEN 명령과 증거
|
||||
|
||||
위 격리 `ROOT`와 `TMPDIR`/Node/Bun cache 환경을 그대로 사용했다. 외부 의존성 다운로드를 막는 `--offline`과 부모의 `verification.gradle`을 추가하여 테스트만 강제로 다시 실행했다. `clean` 및 무필터 test/check/build는 실행하지 않았다.
|
||||
|
||||
advice 수정 전 RED와 수정 후 두 사례 GREEN에 사용한 동일 selector/명령:
|
||||
|
||||
```bash
|
||||
./gradlew --offline --no-daemon --max-workers=2 \
|
||||
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs=-Xmx4g \
|
||||
--project-cache-dir "$ROOT/gradle-cache" \
|
||||
-I "$ROOT/isolation.gradle" -I "$ROOT/../verification.gradle" \
|
||||
test \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldRejectEmptyCallbackAndStartedBodiesWithoutPersistingAnything' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.shouldReturn500AndRollbackWhenReceiptInsertFailsOverHttp'
|
||||
```
|
||||
|
||||
- RED: `$ROOT/p1-red-two-http.log`, `BUILD FAILED in 4m 16s`, 정확히 2개 실행/2개 assertion 실패다. `$ROOT/p1-red-assertions.log`에 XML의 실패 증거를 보존했다. 빈 body는 `expected [422, 422] / actual [200, 200]`, DB 실패는 `expected 500 / actual 200`이었다.
|
||||
- 첫 GREEN 시도 `$ROOT/p1-green-two-http.log`는 `compileKotlin` 도중 600초 도구 제한으로 종료됐다. 테스트가 실행되지 않아 GREEN으로 계산하지 않았다. 작업 소유 프로세스가 남지 않은 것을 확인한 후 같은 캐시와 1200초 제한으로 재실행했다.
|
||||
- 두 사례 GREEN: `$ROOT/p1-green-two-http-retry.log`, `BUILD SUCCESSFUL in 13m 29s`. `$ROOT/p1-green-two-assertions.log`는 `tests=2`, `failures=0`, `errors=0`, `skipped=0`이다.
|
||||
|
||||
이후 자막 신규 클래스 15개 전체와 기존 격리 script의 변경 Kotlin 파일 lint 및 boot JAR를 함께 검증했다:
|
||||
|
||||
```bash
|
||||
./gradlew --offline --no-daemon --max-workers=2 \
|
||||
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs=-Xmx4g \
|
||||
--project-cache-dir "$ROOT/gradle-cache" \
|
||||
-I "$ROOT/isolation.gradle" -I "$ROOT/../verification.gradle" \
|
||||
test --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest' \
|
||||
bootJar ktlintCheck
|
||||
```
|
||||
|
||||
- 최종 `$ROOT/p1-green-15-build-lint.log`: `BUILD SUCCESSFUL in 7m 44s`. production/test 컴파일은 `UP-TO-DATE`였으며 test, bootJar, main/test 변경 파일 lint는 실제 실행됐다.
|
||||
- 최종 XML `$ROOT/build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest.xml`: `tests=15`, `failures=0`, `errors=0`, `skipped=0`. 실제 RANDOM_PORT HTTP에서 새 422/500과 기존 404/409/422 및 직접 서비스 롤백 case가 모두 통과했다. 기존 회귀 클래스는 컴파일 입력일 뿐 실행하지 않았다.
|
||||
- `$ROOT/build/libs/sodalive-0.0.1-SNAPSHOT.jar`에 수정된 `SubtitleExceptionHandler.class`가 포함됨을 `jar tf`로 확인했다. LSP 도구는 제공되지 않아 사용하지 못했으며 Kotlin 컴파일과 변경 파일 lint를 확인했다.
|
||||
- RED/GREEN/최종 테스트 executor PID `28638`/`37727`/`39291`은 모두 종료됐다(`$ROOT/p1-test-process-status.log`는 header만 존재). 완료 후 작업 root를 사용하는 Gradle/HTTP/Redis 프로세스는 관찰되지 않았다. 기존 공유 PID `55393`/`55452`는 그대로이며 어떤 task도 취소하거나 프로세스를 수동 종료하지 않았다. root와 로그는 부모 재검증용으로 보존했다.
|
||||
|
||||
### 남은 검증 경계
|
||||
|
||||
- 기존 Kotlin 경고, Gradle deprecation, H2의 기존 charge/translation scheduler SQL 오류와 Redis 종료 시 재접속 경고는 관찰됐으나 수정 범위 밖이다. 신규 자막 15개 assertion은 전부 통과했다.
|
||||
- 기존 회귀/전체 테스트, 운영 MySQL, AWS·MediaConvert·Lambda 실연동 및 배포 검증은 수행하지 않았다. 앞 절의 외부 게이트는 그대로 남으며, 전체 테스트 사용자 실행 명령은 `./gradlew test`다.
|
||||
|
||||
## 부모 최종 검증·정리 추가 기록
|
||||
|
||||
- 부모가 처음 신규13개 및 P1의 두 HTTP 사례를 각각 직접 재실행해 모두 통과했고, 독립 리뷰의 유일 finding은 ADDRESSED 및 로컬 backend APPROVE 판정을 받았다.
|
||||
- 부모의 P1 재실행은 `BUILD SUCCESSFUL in 4m 55s`, 실제 XML2/0/0/0, timestamp `2026-11-10T15:34:43`이다.
|
||||
- 2026-11-11 작업 소유 PID/listener 부재를 확인한 후 위 임시 ROOT 전체를 제거했다. 따라서 ROOT 기반 로그·init script·JAR 경로는 과거 검증 위치이며 현재 파일로 남아 있지 않다. 명령/결과/실패 이력은 본 보고서와 final-report에 보존했다.
|
||||
- 일반 개발 환경의 재현 명령은 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleHttpIntegrationTest'`다. 기존 테스트는 실행하지 않는 selector다.
|
||||
@@ -0,0 +1,217 @@
|
||||
# 자막 Phase 1 Spring 연동 구현 계획
|
||||
|
||||
**Goal:** 기존 업로드와 권한을 보존하면서 자막 작업 등록, 네 BOT 내부 API, 언어별 안전한 자막 조회를 제공한다.
|
||||
|
||||
**Architecture:** 기존 Kotlin/Spring/JPA와 member JWT를 재사용한다. 신규 자막 기능은 `v2.subtitle` 하위에 두고 기존 콘텐츠 서비스의 업로드 및 상세에만 연결한다. 원본 S3 쓰기 이전 commit과 callback receipt/status/metadata의 원자성을 보장한다.
|
||||
|
||||
**Tech Stack:** Kotlin 1.6.21, Java 17, Spring Boot 2.7.14, JPA, MySQL 운영 DDL, H2 테스트.
|
||||
|
||||
**Spec:** [prd.md](prd.md). 사용자 승인 범위이며 재생성 기능은 제외한다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
- Phase 1/2 완료 이력 유지. Phase3 컬럼 매핑 수정·신규 테스트·독립 리뷰·소유 임시 자원 정리 완료. 활성 로컬 Goal 없음. 운영 DB/외부 게이트는 별도 검증 대상이다.
|
||||
- 실행: subagent-driven-development, 공유 Kotlin 구현은 단일 담당자가 순차 처리한다. 엔티티 계약이 확보된 additive DDL·운영 문서는 별도 담당자가 병렬 준비하고 마지막에 대조한다.
|
||||
- 새 worktree는 만들지 않는다. 기존 `test` 작업 트리는 조사 시 clean이며 main/master가 아니다.
|
||||
- commit/push/배포/DB 적용은 이 계획의 실행 항목이 아니다.
|
||||
|
||||
## 전역 제약
|
||||
|
||||
- PRD의 네 endpoint/payload/상태/경로와 BOT 인증을 그대로 구현한다. 공개 재생성 API와 직접 START 호출은 금지한다.
|
||||
- 기존 upload/MediaConvert/preview/알림/JWT/텍스트 번역 worker를 보존한다. 기존 테스트를 삭제하거나 완화하지 않는다.
|
||||
- additive DDL만 작성한다. 적용 migration 편집/삭제와 데이터 DROP 금지.
|
||||
- 이번 작업에서 새로 작성한 테스트만 실행하며 `bootJar`로 빌드를 별도 확인한다. 기존 클래스에 추가한 새 테스트는 메서드 selector로 지정한다. 기존 focused 회귀도 실행하지 않는다. 전체 테스트 명령은 사용자용 `./gradlew test`다.
|
||||
- AWS/live/mixed/2h/운영 DB/consumer 미검증을 성공으로 보고하지 않는다.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. 외부 호출자가 트랜잭션을 가지고 있어도 S3 이벤트 시점에는 job/source가 commit되어 있어야 한다.
|
||||
2. UUID 파일명 변경 후 기존 MediaConvert 완료 콜백을 수락하되 다른 콘텐츠/경로를 허용하지 않아야 한다.
|
||||
3. callback 중복/경합 때 receipt와 상태 및 자막이 원자 반영되고 older 결과가 최신을 덮지 않아야 한다.
|
||||
4. JSON 숫자 coercion/누락 nullable 필드/추가 필드 및 raw-body 재직렬화로 계약이 약화되지 않아야 한다.
|
||||
5. preview-only 사용자에게 전체 자막이 노출되거나 BOT 변경으로 기존 역할 권한이 넓어지지 않아야 한다.
|
||||
|
||||
## Phase 1: 업로드부터 자막 조회까지
|
||||
|
||||
### Task 1.1: 자막 backend 연결
|
||||
|
||||
**Goal `P1-T1`:** 기존 콘텐츠 흐름에 durable source/job 등록과 네 내부 API, 안전한 자막 조회가 연결된다.
|
||||
|
||||
- 시작 조건: 재생성 제외 승인, PRD 작성 완료, 업로드 transaction 경계 검토 완료.
|
||||
- 완료 증거: 아래 RED/GREEN 기록과 5개 기능 그룹을 검증하는 새 테스트 통과. 기존 콘텐츠/로그인 테스트는 코드 대조만 하고 미실행으로 보고.
|
||||
- 범위 밖: Lambda 코드, 앱 코드, 인프라, 재생성, 자동 복구.
|
||||
- 신규 구현 파일은 `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/` 아래 `application`, `domain`, `adapter`, `dto` 책임별로 배치한다. 과도한 port/wrapper는 만들지 않는다.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/domain/SubtitleJobStatus.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJob.kt`, `SubtitleJobRepository.kt`, `SubtitleCallbackReceipt.kt`, `SubtitleCallbackReceiptRepository.kt` 및 필요한 최소 subtitle metadata 저장 타입.
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/Subtitle.kt`, `SubtitleRepository.kt`. 결과 행은 auto-increment Long PK와 `jobId`(36), `language`(35), `format`(16), `s3Bucket`(255), `s3Key`(255)를 저장하며 `(jobId, language)` unique와 job FK를 둔다. `findAllByJobId(jobId)` 및 `findByJobIdAndLanguage(jobId, language)`로 callback/조회에서 재사용한다. 언어별 컬럼을 제거하되 아직 미배포인 이번 draft만 교체하고 기존 적용 schema/data는 건드리지 않는다.
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/application/SubtitleJobService.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleJobController.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/dto/SubtitleJobDtos.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`, `AudioContent.kt`, `GetAudioContentDetailResponse.kt` 및 필요한 기존 호출자.
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`와 필요한 자막 전용 설정.
|
||||
- Create: `docs/20261110_자막Phase1연동/subtitle-schema.sql`.
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/`의 기능 통합 테스트 및 기존 `content/AudioContentServiceTest.kt`, `member/MemberLoginResponseContractTest.kt`, `jwt/TokenProviderTest.kt` 관련 회귀.
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: 기존 `AudioContent`, `S3Uploader`, `AudioContentCloudFront`, `LangContext`, member JWT/role 및 PRD의 exact Lambda HTTP 계약.
|
||||
- Produces: PRD의 네 내부 HTTP endpoint; 기존 콘텐츠 상세의 nullable 자막 정보. source lookup은 최초 업로드 binding에 고정한다.
|
||||
- 내부 함수/저장 형태는 기존 패턴과 transaction 검토에 맞추며 실제 파일/이름은 구현 기록에 갱신한다. 공개 계약은 변경하지 않는다.
|
||||
|
||||
- [x] **RED:** 기존 관련 테스트의 의도를 먼저 읽고 새 실제 HTTP 테스트를 작성했다. 지시 이후 기존 baseline은 실행하지 않았다.
|
||||
- [x] **RED 확인:** 실제 로그인 후 403 기대/404 실제, source 확장자 보존 실패 및 기존 eventId/unknown job의 422 기대/404 실제를 확인했다. compile/setup 실패는 RED로 인정하지 않았다.
|
||||
- [x] **GREEN:** 최소 persistence/업로드 commit 경계/API/상세 연결 및 additive DDL을 구현했다. 언어별 결과는 `Subtitle` 행이다.
|
||||
- [x] **GREEN 확인:** 부모 직접 실행에서 신규 `SubtitleHttpIntegrationTest` 13개 통과, 실패·오류·skip 0.
|
||||
- [x] **REFACTOR:** Kotlin/JPA·직접 영향 test compile, 변경 파일 ktlint 및 bootJar 성공을 확인했다. 기존 회귀 case는 실행하지 않았다.
|
||||
|
||||
검증 상세: [implementation-report.md](implementation-report.md). 부모 재실행 `parent-verification.log`는
|
||||
`BUILD SUCCESSFUL in 2m 21s`, XML timestamp `2026-11-10T14:33:59`, 13/0/0/0이다.
|
||||
|
||||
### Task 1.2: 실제 HTTP와 독립 리뷰
|
||||
|
||||
**Goal `P1-T2`:** 구현 결과를 실제 HTTP 표면과 독립 검토로 확인하고 외부 미검증을 구분한다.
|
||||
|
||||
- 시작 조건: `P1-T1` 구현 및 focused test 완료.
|
||||
- 완료 증거: 실제 HTTP status/body assertion, `bootJar`, 변경 범위 lint/compile, 독립 리뷰의 blocker 해소.
|
||||
- 범위 밖: 외부 AWS 환경에 대한 실제 운영 검증 및 전체 테스트.
|
||||
- TDD 예외 사유: 검증/보고 task다. 대체 검증은 HTTP 실행과 코드/DDL/테스트 대조다.
|
||||
- [x] 실제 localhost HTTP로 EMAIL BOT 로그인, 네 endpoint, 중복/경합/오류 및 기존 콘텐츠 상세를 실행했다. H2와 외부 S3/서명 경계 대체를 사용했다.
|
||||
- [x] `bootJar`, 변경 파일 `ktlintCheck`, 신규 HTTP 테스트를 실행했다. LSP 미제공이며 Kotlin compile/lint를 대체 증거로 기록했다.
|
||||
- [x] 독립 리뷰에서 HTTP 오류 경계 1건을 발견·수정하고, 제한 delta 재검토에서 ADDRESSED 및 로컬 backend APPROVE를 받았다.
|
||||
- [x] `final-report.md`에 실제 수정·삭제·보존 경로, 명령/결과, DB 적용 및 consumer 상태, fixture 출처, live/mixed/2h BLOCKED를 기록했다.
|
||||
|
||||
### Task 1.R1: 리뷰 회귀 수정
|
||||
|
||||
**Goal `P1-R1`:** 자막 API의 빈 body/저장 실패가 HTTP 200으로 숨겨지지 않는다.
|
||||
|
||||
- 시작 조건: 독립 리뷰 P1 finding 및 `SodaExceptionHandler.kt`의 기존 HTTP200 변환 확인.
|
||||
- 완료 증거: 새 실제 HTTP 테스트에서 빈 callback/started 422 및 DB 실패 5xx, receipt/status/results rollback 확인.
|
||||
- 범위 밖: 기존 공통 exception handler나 다른 API 응답 변경.
|
||||
- Files: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/web/SubtitleExceptionHandler.kt`,
|
||||
`src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/SubtitleHttpIntegrationTest.kt`.
|
||||
- [x] **RED:** 빈 body와 receipt 저장 실패의 실제 HTTP 테스트 2개를 추가했다.
|
||||
- [x] **RED 확인:** `[422,422]` 기대에 `[200,200]`, `500` 기대에 `200` 실패를 확인했다.
|
||||
- [x] **GREEN:** 자막 전용 advice에서 body 읽기 오류는 422, 예상치 못한 서버 오류는 일반화된 500으로 반환한다.
|
||||
- [x] **GREEN 확인:** 신규 자막 15개 통과, 부모의 추가 두 HTTP 사례 재실행도 통과했다. 기존 서비스 직접 호출 rollback 테스트를 유지했다.
|
||||
- [x] **REFACTOR:** 변경 lint/build 및 부모 HTTP 재검증 후 제한 delta 리뷰가 승인했다. 원 reviewer session 유실로 대체 reviewer가 기존 판정과 수정분만 재검토했다.
|
||||
|
||||
### Phase 1 Gate
|
||||
|
||||
**Goal `P1-GATE`:** 로컬 backend 완료와 운영 배포 준비 상태가 증거로 구분된다.
|
||||
|
||||
- [x] `P1-T1`, `P1-T2`, `P1-R1` 완료 증거 확인.
|
||||
- [x] 임시 QA 프로세스/listener 부재와 작업 소유 build/cache/log root 제거를 확인했다. 공유 프로세스·캐시는 변경하지 않았다.
|
||||
- [x] 재생성 URI/worker/HMAC/파괴적 SQL/외부 프로젝트 변경이 없음을 diff 확인했다.
|
||||
- [x] 운영 반영은 consumer inventory, DB additive DDL 검토, 조정 배포, controlled live ko/ja/mixed와 2h 측정이 필요하다고 보고했다.
|
||||
|
||||
## Phase 2: generate-url 자막 응답
|
||||
|
||||
### Task 2.1: 기존 URL 권한을 유지한 자막 제공
|
||||
|
||||
**Goal `P2-T1`:** 구매자의 generate-url 응답에 상세와 동일한 요청 언어 자막이 포함되고 기존 contentUrl 계약이 보존된다.
|
||||
|
||||
- 시작 조건: PRD §7 사용자 요청 확정, 기존 generateUrl/공유 subtitle 조회 및 새 HTTP fixture 확인.
|
||||
- 범위 밖: 기존 구매 판정 확대, URI/DB/Lambda 변경, 재생성, 기존 테스트/전체 테스트 실행.
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/GenerateUrlResponse.kt`.
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`의 `generateUrl`만.
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/SubtitleHttpIntegrationTest.kt`에 이번 후속용 HTTP 사례 추가.
|
||||
- Consumes: `SubtitleJobService.subtitle(content, lang, expirationTime)`, 기존 `LangContext`, 구매 검사와 `AudioContentCloudFront`.
|
||||
- Produces: `data.contentUrl` 유지 + nullable `data.subtitle`의 language/format/url.
|
||||
- 완료 증거: 아래 세 시나리오의 새 HTTP 사례 RED/GREEN, 컴파일/변경 lint, 범위 한정 독립 리뷰와 임시 자원 정리.
|
||||
|
||||
시나리오:
|
||||
1. 구매·공개 완료 콘텐츠: `Accept-Language: ko/ja` 각각에 해당 SRT 정보와 기존 음원 URL, 기존 envelope가 반환된다.
|
||||
2. 미완료 job/해당 언어 없음/미지원 언어/자막 미등록: 음원 URL은 유지하고 subtitle은 명시적 null이다.
|
||||
3. 미구매자(소유자·무료 포함)는 기존 빈 contentUrl과 null 자막을 받는다. 예약 공개 구매자의 subtitle은 공개 전 null이며 공개 후 제공된다. 기존 URL 반환은 그대로다.
|
||||
|
||||
- [x] **RED:** 기존 HTTP fixture를 재사용한 새 메서드 3개만 추가했다.
|
||||
- [x] **RED 확인:** 실제 응답의 `has("subtitle")`에서 3개 모두 assertion 실패함을 확인했다.
|
||||
- [x] **GREEN:** DTO field와 generateUrl의 공유 자막 조회 연결만 구현했다.
|
||||
- [x] **GREEN 확인:** 이번 새 HTTP 메서드 3개 통과, 부모 직접 재실행도 3/0/0/0. 이전 15개와 클래스 전체는 실행하지 않았다.
|
||||
- [x] **REFACTOR:** 컴파일/bootJar/테스트 lint 및 제한 독립 리뷰 APPROVE를 확인했다. production lint의 기존 EOF 빈 줄1건을 별도 기록하고 dirty 변경을 보존했다. 자원 정리는 Phase2 Gate에서 확인한다.
|
||||
|
||||
### Phase 2 Gate
|
||||
|
||||
**Goal `P2-GATE`:** 추가 응답의 HTTP·권한·호환성 증거가 확보된다.
|
||||
|
||||
- [x] 새 HTTP3개 통과와 독립 리뷰 APPROVE 확인.
|
||||
- [x] 기존 코드 변경은 DTO와 generateUrl 본문뿐임을 실제 전후 diff로 대조했다.
|
||||
- [x] 결과 보고를 보존하고 소유 테스트 PID/listener 및 임시 build/cache/root 부재를 확인했다.
|
||||
|
||||
## Phase 3: Schema-validation 회귀 수정
|
||||
|
||||
### Task 3.R1: s3 컬럼 물리명 일치
|
||||
|
||||
**Goal `P3-R1`:** 기존 `subtitle.s3_bucket`/`s3_key` 테이블을 Hibernate가 검증하고 정상 저장·조회한다.
|
||||
|
||||
- 시작 조건: 실제 사용자 에러, 명시적 이름이 없는 엔티티와 DDL 차이 확인.
|
||||
- Files: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/Subtitle.kt`.
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/subtitle/SubtitleSchemaValidationTest.kt` 신규.
|
||||
- 범위 밖: DDL 수정·운영 DB 접속/컬럼 추가·자동 update·전역 naming strategy·JSON/property rename·기존 테스트 실행.
|
||||
- 시나리오: 독립적으로 만든 s3_bucket/s3_key 테이블에 실제 Boot 기본 naming strategy를 사용한 Hibernate validate 성공, 두 값의 persist/reload/JDBC column 확인 성공.
|
||||
- [x] **RED:** 엔티티 자동 DDL 생성 없이 기존 컬럼명의 H2 table을 준비하고 실제 schema validate를 수행하는 새 테스트를 작성했다.
|
||||
- [x] **RED 확인:** 사용자 보고와 동일한 missing s3bucket 에러로 1개 실패를 확인했다. 컴파일 오류가 아니다.
|
||||
- [x] **GREEN:** `@Column(name = "s3_bucket")`, `@Column(name = "s3_key")`를 기존 속성에 명시했다.
|
||||
- [x] **GREEN 확인:** 새 테스트1개만 실행해 validation 및 Hibernate 저장/새 Session 조회/JDBC 두 컬럼 조회 성공을 확인했다.
|
||||
- [x] **REFACTOR:** 변경 compile/bootJar/lint와 독립 리뷰 APPROVE, 검증 기록 보존 및 소유 임시 자원 제거·부재 확인을 완료했다.
|
||||
|
||||
## 이전 기능 실행 검증 이력
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subtitle.*'
|
||||
./gradlew bootJar
|
||||
```
|
||||
|
||||
기존 클래스에 추가한 새 테스트는 실제 메서드명을 `--tests`에 지정한다. 기존 테스트 클래스 전체는 실행하지 않는다. 수동 전체 검증은 사용자 실행 `./gradlew test`다. 실제 명령/추가 영향 테스트/결과는 아래에 누적한다.
|
||||
|
||||
## Progress
|
||||
|
||||
- 2026-11-11 `P3-R1` 종료: 작업 root를 참조하는 프로세스가 없고 실제 디렉터리/symlink 아님을 확인한 뒤 소유 `subtitle-schema-fix.XcftjN`만 제거했다. `test ! -e` exit0, 공유캐시 미변경. 결과/명령/리뷰는 문서로 보존했다. 운영 재시작/DB 변경/커밋은 하지 않았다.
|
||||
|
||||
- 2026-11-11 `P3-R1` 독립 리뷰 APPROVE: 명세/품질 blocker 없음. 두@Column만 바뀌었고 기존 SQL/JSON 계약은 유지된다. 독립DDL+actualvalidate+새Session/JDBC 조회와 RED/GREEN 증거가 확인됐다. 실제MySQL/전체Spring검증은 별도 게이트다.
|
||||
|
||||
- 2026-11-11 `P3-R1` RED/GREEN: 실제 Hibernate validation의 missing[s3bucket] 실패를 수정 전 재현했다(1fail, 3m49s). 두@Column만 수정한 뒤 신규 테스트1/0/0/0, Kotlin compile/bootJar/변경2파일 lint 성공(3m29s). 실제 운영 DB는 조회·변경하지 않았다. `schema-validation-report.md`에 원인과 기존 create-drop 검증 공백을 기록했다. 독립 한정 리뷰 진행 중.
|
||||
|
||||
- 2026-11-11 `P3-R1` 등록: 사용자 보고 missing `[s3bucket]` 재현/수정. 기존 기본 명명전략이 숫자-대문자 경계에서 underscore를 넣는다고 가정한 정적 대조와 엔티티 생성 H2 테스트의 검증 공백을 기록한다. 기존 DDL은 유지하고 실제 validate 회귀 검증으로 보완한다.
|
||||
|
||||
- 2026-11-11 별도 사용자 `/commit-with-validation` 요청으로 커밋 진행. 기능 연동 커밋의 pre-commit `ktlintCheck`가 기존 `AudioContentService.kt` EOF 빈 줄 1건으로 실패하여 해당 빈 줄만 제거했다. 재시도에서 staged Kotlin 대상 lint와 `git diff --staged --check`, 커밋 직전·직후 메시지 검증이 통과했다. 이번 커밋 작업에서는 기능/전체 테스트 및 bootJar를 재실행하지 않았으며 기존 실행 이력과 외부 배포 BLOCKED는 유지한다. 전체 테스트 사용자 명령은 `./gradlew test`다.
|
||||
|
||||
- 2026-11-11 `P2-GATE` 종료: 부모 직접 HTTP3개 재실행 성공과 독립 승인 후 소유 root `generate-url-subtitle.fSBkJM`의 디렉터리/symlink 여부를 확인해 제거했다. `test ! -e` exit0이며 공유 캐시/다른 dirty 파일은 건드리지 않았다. 원래 EOF lint1건은 별도 미해결 관찰로 남긴다. 검증 로그 경로는 과거 위치이며 결과/명령/실패 이력은 보고서에 보존했다.
|
||||
|
||||
- 2026-11-11 `P2-T1` 독립 리뷰 APPROVE: 기존 contentUrl/envelope/활성 구매 정책과 서명 만료 인자 유지, 자막만 공개 시점·언어·최신 결과 조건 적용, 새 HTTP3개 증거를 확인했다. 이번 delta blocker/새 회귀 없음. production EOF 공백은 작업 시작 전 dirty 상태의 잔여사항이며 전체 lint clean을 주장하지 않는다.
|
||||
|
||||
- 2026-11-11 `P2-T1` 구현: DTO nullable field 및 generateUrl 본문만 수정. 새 HTTP3개 RED/GREEN 후 부모가 정확히 새3method selector로 직접 실행해 3 tests/0 failures/errors/skipped, `BUILD SUCCESSFUL in 37s`, XML UTC `2026-11-10T22:12:51` 확인. 실제 Tomcat PID17759/port50914. Kotlin compile/bootJar 및 테스트 lint 성공. 두 production 파일 lint에서는 작업 전부터 있던 서비스 EOF 빈 줄1건만 실패하므로 다른 dirty 변경으로 보존했다. 상세는 `generate-url-report.md`. 범위 한정 독립 리뷰 진행 중.
|
||||
|
||||
- 2026-11-11 후속 요청: generate-url에도 상세와 같은 자막 응답 추가. `GenerateUrlResponse`는 현재 contentUrl만 있고 기존 URL 권한은 구매 검사다. `P2-T1`에서 additive field/공유 조회만 연결한다. 기존 Phase1 완료·검증 기록은 유지한다. 시작 시 추가 dirty 파일(기존 관리자 controller/query 테스트 등)을 발견했으며 다른 작업으로 보고 변경하지 않는다.
|
||||
|
||||
- 2026-11-11 종료 정리: 소유 root `subtitle-phase1.wiRcAJ`가 실제 디렉터리이며 symlink가 아님을 확인한 후 제거했고 `test ! -e` exit0으로 부재 확인했다. 작업 소유 테스트 PID/HTTP port의 종료는 먼저 확인했다. 결과·명령·실패 이력·리뷰 판정은 저장소 문서에 보존했으며 임시 log/init/JAR 경로는 과거 검증 위치다. 모든 로컬 Goal 완료, 외부 배포 게이트는 별도 BLOCKED로 유지한다.
|
||||
|
||||
- 2026-11-11 최종 제한 리뷰: `REV-P1-001 ADDRESSED`, 로컬 backend gate `APPROVE`. 신규 blocker 없음. 원 reviewer session 유실 때문에 기존 전체 리뷰 판정과 오류 advice/두 테스트 delta만 대체 reviewer가 확인했으며 전체 리뷰를 반복하지 않았다. 재생성·AWS·운영 DB·live·2h는 구현/승인 범위에서 분리했다.
|
||||
|
||||
- 2026-11-11 `P1-R1` 검증: 2개 HTTP RED 후 수정, 신규 자막 15개 전체와 bootJar/변경 lint 통과 (`p1-green-15-build-lint.log`, 7m44s). 부모는 수정한 두 새 HTTP 사례만 재실행해 2/0/0/0 및 `BUILD SUCCESSFUL in 4m 55s` 확인. 실제 Tomcat port 59977/PID44707, XML timestamp `2026-11-10T15:34:43`. 기존 reviewer session continuation이 유실돼 이전 판정과 수정 delta만 전달한 단일 재리뷰를 진행 중이다. 같은 기능을 재설계하거나 전체 리뷰를 반복하지 않는다.
|
||||
|
||||
- 2026-11-10 독립 gate 리뷰: REQUEST_CHANGES 1건. 자막 advice가 ResponseStatusException만 처리하므로 빈 body/저장 실패가 기존 공통 advice에서 HTTP200으로 변환될 수 있다. 부모가 실제 공통 handler를 확인했고 전용 advice+새 HTTP 사례의 `P1-R1`로 수정한다. 나머지 저장/권한/언어별 구조/업로드/DDL 범위는 reviewer가 부합 판정했다.
|
||||
|
||||
- 2026-11-10 부모 검증: 격리 build/cache에서 신규 HTTP 13개를 up-to-date 무시하고 직접 재실행, 13 passed/0 failed/0 errors/0 skipped, `BUILD SUCCESSFUL in 2m 21s`. 같은 코드의 bootJar와 ktlint는 up-to-date 확인이며 구현 담당의 실제 실행 로그 `build-lint.log`는 `BUILD SUCCESSFUL in 1m 28s`다. 로컬 mapping의 `sourceUploadErrorAt` TIMESTAMP 차이는 해소했다. 기존 독립 reviewer 결과 회수 실패 및 session 유실로 승인을 주장하지 않고 대체 단일 reviewer를 실행 중이다.
|
||||
|
||||
- 2026-11-10 실제 ZIP 계약 대조: `unzip -p .../start-subtitle-transcription.zip common/transcribe.py`로 확장자 없는 원본 key가 `UNSUPPORTED_MEDIA`임을 확인했다. `common/runtime.py::source_content`는 UUID 뒤 확장자를 허용한다. 원본의 안전한 확장자를 보존하도록 등록 seam/새 테스트를 수정한다. 이는 기존 artifact의 read-only 계약 확인이며 Lambda 변경·실행·AWS 호출이 아니다. 확장자는 형식 보장이 아니며 기존 업로드/MediaConvert 동작을 미지원 자막 형식 때문에 막지 않는다.
|
||||
|
||||
- 2026-11-10 후속 설계 조정: 사용자에게 향후 언어 추가 가능성이 확인되어 고정 `koKey`/`jaKey` 대신 `Subtitle` 결과 행을 둔다. 현재 API 허용 언어/상태 판정은 ko/ja 고정 그대로다. 구현 담당과 DDL 담당을 잠시 중단하고 같은 session으로 갱신 계약을 전달한다. callback 원자성/terminal replay/조회 권한 새 테스트를 같은 구조로 갱신한다. 아래 Oracle의 고정 컬럼 권고는 당시 두 언어 고정 가정의 기록이며 이 결정으로 대체한다.
|
||||
|
||||
- 2026-11-10 후속 지시: 테스트는 이번에 작성한 것만 실행. 지시 반영을 위해 실행 중 implementer를 일시 중단하고 같은 session으로 재개한다. Kotlin 공유 파일은 기존 담당자가 소유하고 DDL/운영 문서만 병렬 분리한다. 이전 baseline 실행 이력은 아래에 보존하되 재실행하지 않는다.
|
||||
|
||||
- 2026-11-10: 현재 상태 조사 완료. Spring 자막/recovery 구현 없음, 기존 BOT 인증/업로드 보존. Phase 1 Lambda ZIP hash 대조 완료, AWS 상태는 미확인. 사용자 결정으로 재생성 전체 제외.
|
||||
- 2026-11-10: PRD/계획 준비. 아직 production 코드 수정 및 테스트 실행 전이며 성공 결과를 주장하지 않는다.
|
||||
- 2026-11-10: Oracle 경계 검토 완료. create orchestration은 `NOT_SUPPORTED`, 실제 등록은 필수 주입 `TransactionTemplate(REQUIRES_NEW)`로 분리한다. AI 관리자 create 바깥 transaction도 조정한다. content/source/job/최신 선택값을 한 번 저장한 뒤 S3 원본 업로드하고 후속 input 경로 재대입은 제거한다. 업로드 예외는 객체 생성 여부가 불명확하므로 처리 상태를 terminal로 덮지 않고 별도 업로드 오류만 기록해 수동 확인 대상으로 남긴다.
|
||||
- 2026-11-10: 고정 두 언어 metadata는 job의 nullable 컬럼으로 최소 저장하고 별도 번역/checkpoint 구조는 만들지 않는다. receipt eventId는 case-sensitive unique, callback/started는 동일 job lock을 사용한다. 다른 job 사이 eventId 경합은 rollback 후 receipt 재조회로 판정한다.
|
||||
- 2026-11-10: UUID 파일명 변경은 외부 MediaConvert의 ID 추출/output 명명 규칙 확인 전 **배포 BLOCKED**다. 현재 저장소에 외부 consumer의 운영 계약 증거가 없으며, 임의 prefix 완화로 숨기지 않는다. 자막 S3 bucket의 private CloudFront origin/서명 접근도 운영 확인 게이트다.
|
||||
- 2026-11-10: baseline 명령 `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' --tests 'kr.co.vividnext.sodalive.member.MemberLoginResponseContractTest' --tests 'kr.co.vividnext.sodalive.jwt.TokenProviderTest'`는 `kaptGenerateStubsKotlin` 단계에서 200초 도구 제한으로 종료. 테스트 결과 없음. 기존 다른 Gradle 작업이 관찰되어 타 작업을 종료하지 않고 이 작업 전용 build/cache 경로로 검증을 격리한다.
|
||||
|
||||
## Schema-validation 커밋 전 재검증
|
||||
|
||||
- 2026-11-11 별도 `/commit-with-validation` 요청으로 `./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleSchemaValidationTest'`를 실행했다.
|
||||
- 결과: `BUILD SUCCESSFUL in 46s`, 테스트 1개, 실패/오류/건너뜀 0개. 기존 컬럼명의 Hibernate validation과 저장·재조회를 재확인했다.
|
||||
- 전체 테스트와 실제 MySQL 기동은 실행하지 않았다. 사용자 전체 테스트 명령은 `./gradlew test`다.
|
||||
@@ -0,0 +1,150 @@
|
||||
# 자막 Phase 1 Spring 연동 PRD
|
||||
|
||||
## 문서 정보
|
||||
|
||||
- 작성일: 2026-11-10
|
||||
- 상태: 재생성 제외 로컬 backend 구현·검증 완료. 운영 배포/외부 게이트는 별도 차단 상태.
|
||||
- 구현 계획: [plan-task.md](plan-task.md)
|
||||
- 기준: 사용자 제공 Phase 1 계약 및 후속 결정 "이번 스코프에 재생성 기능은 추가하지 않고 나머지만 진행"
|
||||
|
||||
## 1. 목표와 범위
|
||||
|
||||
기존 콘텐츠 업로드에 자막 원본 binding과 PENDING 작업을 연결하고, 기존 BOT JWT로 보호한 네 내부 API를 통해 Lambda 결과를 저장한다. 기존 콘텐츠 상세에서 요청 언어와 원본 열람 권한에 맞는 자막 정보만 반환한다.
|
||||
|
||||
Spring은 업로드/작업 등록/결과 저장/콘텐츠 접근 제어를 맡는다. Lambda의 S3 이벤트, Transcribe, 번역, SRT 생성은 이미 별도 구현되어 있으므로 다시 구현하지 않는다. 앱 플레이어 구현은 이 Spring 저장소의 변경 대상이 아니며 실제 앱 표시 검증은 외부 게이트다.
|
||||
|
||||
### 제외
|
||||
|
||||
- 사용자 결정에 따라 자막 재생성 전체: 새 공개 API, 새 job 생성 business action, START 직접 비동기 호출 모두 제외.
|
||||
- Scheduler discovery, recovery/lease/fence/checkpoint/outbox/자동 복구, HMAC 인증, 신규 로그인/refresh 계약.
|
||||
- 외부 프로젝트 수정, AWS 설정/배포/실데이터 변경, DB DROP, 적용 migration 편집/삭제.
|
||||
- MediaConvert/AAC/preview/기존 알림의 기능 변경과 2GB 다운로드/probe pipeline.
|
||||
|
||||
## 2. 실제 현재 상태
|
||||
|
||||
| 영역 | 확인 결과 | 처리 |
|
||||
|---|---|---|
|
||||
| Spring 작업 트리 | `test`, 조사 기준 `fe620aa9`, 변경 없음 | 현 작업 트리에서 최소 변경 |
|
||||
| 로그인/JWT/BOT | `MemberService.login`, `MemberRole.BOT`, `JwtFilter`, `TokenProvider` 존재 | 재사용, 공유 인증 약화 금지 |
|
||||
| 콘텐츠 | `AudioContent`의 numeric PK, 기존 multipart 업로드와 상세/서명 URL 존재 | 보존 및 필요한 연결만 추가 |
|
||||
| 업로드 | `AudioContentService.createAudioContent` 트랜잭션 안에서 S3 업로드 | S3 이벤트 전에 committed PENDING/source가 보이도록 조정 |
|
||||
| 업로드 완료 | `uploadComplete`가 파일명의 contentId prefix 검사 후 경로/duration/알림 처리 | 기존 파일은 유지, 새 UUID 원본과의 정확한 연결 검증 필요 |
|
||||
| 자막/recovery | 자막 API/job/worker/HMAC 및 자막 migration 검색 결과 없음 | 실제 삭제 대상 없음, 공유 텍스트 TranslationJob/JWT/Agora 보존 |
|
||||
| DB 관리 | Flyway/Liquibase 의존성 없음, 기존 문서 SQL 관례 | additive MySQL DDL 문서만 작성, 운영 적용 여부 미확인 |
|
||||
| 재생성 | 기존 action 없음. `generate-url`은 재생 URL 발급 | 이번 범위 제외 |
|
||||
|
||||
### Lambda handoff 확인
|
||||
|
||||
참조는 `/Users/klaus/Develop/sodalive/Lambda/generate-subtitle/aws-subtitle-service/docs/FINAL_REPORT.md`다. 실제 로컬 ZIP hash가 보고서 및 manifest와 일치함을 조사했다.
|
||||
|
||||
- `start-subtitle-transcription.zip`: `a0d0b19b2ecb24c0f89caf528bf09fd039558563a99063f082ac64418704abfd`
|
||||
- `generate-subtitle.zip`: `bfa8742ca436bace853ec2540fc5f70daa24cbab49e75315565d980189fbf209`
|
||||
|
||||
이는 AWS 배포 상태의 증거가 아니다. 현재 운영 consumer/in-flight/Scheduler/DB 상태는 미확인이다. 이 저장소에 삭제할 legacy 자막 consumer는 발견되지 않았으며 외부 consumer 삭제/중지/교체는 하지 않는다.
|
||||
|
||||
## 3. 고정 계약
|
||||
|
||||
### 식별자와 저장 경로
|
||||
|
||||
- `contentId`: 기존 Long PK, JSON number. `jobId`: Spring 생성 UUID string.
|
||||
- 원본: `input/{contentId}/{UUID filename}`, 같은 key 덮어쓰기 금지. VersionId 생성/사용 없음.
|
||||
- 원본 파일의 안전한 확장자를 UUID 뒤에 보존한다(예: `{UUID}.mp3`). 실제 Phase 1 START ZIP의 `common/transcribe.py::validate_binding`은 `amr/flac/m4a/mp3/mp4/ogg/webm/wav` 확장자를 요구하며, 확장자 없는 key는 `UNSUPPORTED_MEDIA`다. 확장자는 형식 힌트일 뿐 실제 미디어 검증을 대신하지 않는다. 기존 업로드 허용 동작을 임의로 축소하지 않고 미지원 형식은 자막 실패와 기존 업로드를 구분한다.
|
||||
- transcript: `transcripts/{contentId}/{jobId}/transcript.json`.
|
||||
- 자막: `subtitles/{contentId}/{jobId}/ko.srt`, `subtitles/{contentId}/{jobId}/ja.srt`.
|
||||
- 상태: `PENDING`, `PROCESSING`, `COMPLETED`, `PARTIAL_COMPLETED`, `FAILED`.
|
||||
- 등록과 원본 binding은 S3 원본 업로드 전에 commit한다. 업로드 실패를 성공으로 숨기지 않으며 이미 commit된 job의 실패를 기록한다. 자동 복구는 추가하지 않는다.
|
||||
|
||||
### 내부 API: 정확히 네 개
|
||||
|
||||
모든 요청은 기존 `/member/login`에서 발급된 JWT의 BOT 권한을 검증한다. 일반 USER/CREATOR/ADMIN만으로 허용하지 않는다. unknown job/source는 404이며 조회 중 job을 만들지 않는다.
|
||||
|
||||
| Method | Path | 계약 |
|
||||
|---|---|---|
|
||||
| GET | `/internal/api/subtitle-jobs/resolve?bucket=...&key=...` | 업로드의 정확한 source binding 조회 |
|
||||
| GET | `/internal/api/subtitle-jobs/{jobId}` | UUID job 조회 |
|
||||
| POST | `/internal/api/subtitle-jobs/{jobId}/started` | 정확히 `transcribeJobName`, `status` |
|
||||
| POST | `/internal/api/subtitle-jobs/callback` | 정확히 아래 8개 필드 |
|
||||
|
||||
조회 응답은 envelope 없이 `jobId`, numeric `contentId`, `status`, `source:{bucket,key,versionId:null}`다. recovery/provider/lease 정보는 노출하지 않는다.
|
||||
|
||||
started의 이름은 `subtitle-{jobId}`, status는 `PROCESSING`만 허용한다. PENDING만 PROCESSING으로 전이하고 같은 PROCESSING 및 terminal은 no-op이다. 성공은 200 `{}` 또는 204 empty다.
|
||||
|
||||
callback은 `eventId`, `jobId`, numeric `contentId`, `transcribeJobName`, `status`, `subtitles`, `errorCode`, `errorMessage`만 받는다. `eventId`는 비어 있지 않은 문자열로 UUID 제한을 하지 않는다. subtitle 항목은 `language`, `format`, `s3Bucket`, `s3Key`만 받는다. 언어는 `ko-KR`/`ja-JP`, format은 `SRT`, 언어 중복은 금지한다. 이름/job/content/configured bucket allowlist/정확한 자막 key를 검증한다.
|
||||
|
||||
- 2개 자막이면 COMPLETED, 1개면 PARTIAL_COMPLETED, 0개면 FAILED만 허용한다.
|
||||
- COMPLETED의 errorCode/errorMessage는 명시적 null, partial/failed는 구체적 비어 있지 않은 오류를 요구한다. nullable 필드도 요청에서 생략하지 않는다.
|
||||
- started 전 callback도 허용한다. callback 검증은 SRT 내용 검사로 위장하지 않는다. 실제 빈 파일/무음 방지는 기존 Lambda 및 live 검증 책임이다.
|
||||
- receipt의 eventId와 **원래 HTTP body bytes의 SHA-256**을 상태/자막 metadata와 같은 DB 트랜잭션으로 저장한다. hash는 인증이 아니다.
|
||||
- 같은 eventId/hash는 200, 같은 eventId/다른 hash는 422, terminal job의 다른 결과는 409다. 회귀 및 부분 반영 금지.
|
||||
- 오래된 job의 결과는 자체 저장하되 콘텐츠 최신 선택 결과를 대체하지 않는다. 재생성 API가 제외되어도 이 저장 불변식은 유지한다.
|
||||
|
||||
### 로그인/권한/개인정보
|
||||
|
||||
기존 login 요청의 `isAdmin:false`, `isCreator:false`와 기존 `data.token` 응답을 바꾸지 않는다. 활성 EMAIL BOT의 DB role과 발급 JWT를 실제 인증 경로 테스트로 확인한다. Lambda의 success envelope/401 1회 갱신/403 갱신 금지 구현은 재작성하지 않는다.
|
||||
|
||||
credentials/JWT/body를 코드/로그/보고서에 출력하지 않는다. BOT 운영 계정 준비는 운영자 책임이다. 기존 JWT 등록/회원 권한/다른 role 경로는 그대로 보존한다.
|
||||
|
||||
### 언어별 저장 구조
|
||||
|
||||
추후 언어 추가 가능성을 반영해 `SubtitleJob`에 `koKey`/`jaKey` 같은 언어별 컬럼을 두지 않는다. `Subtitle` 결과 행에 `jobId`, `language`, `format`, `s3Bucket`, `s3Key`를 저장하고 `(jobId, language)` unique로 중복을 방지한다. 작업 상태/원본/오류는 `SubtitleJob`, callback 중복 방지는 receipt가 담당한다. callback transaction에서 결과 행도 함께 저장한다.
|
||||
|
||||
이번 Phase 1의 외부 계약은 그대로 유지한다. callback은 `ko-KR`/`ja-JP`만 허용하며 2개/1개/0개 결과의 상태 판정도 변경하지 않는다. 다른 언어 활성화, 언어 관리 API, 동적 provider 설정은 구현하지 않는다. 새 언어는 향후 계약·Lambda·조회 정책 변경이 필요하지만 언어별 DB 컬럼 추가는 필요 없도록 한다.
|
||||
|
||||
### 콘텐츠 상세 응답
|
||||
|
||||
기존 상세 API 응답에 nullable 자막 정보를 additive하게 제공한다. 기존 `LangContext`의 한국어/일본어에 맞는 완료 자막만 선택한다. 미지원 언어/미완료/없는 언어는 null이며 원문 복사 fallback을 만들지 않는다.
|
||||
|
||||
원본 재생이 가능한 구매/소유자/무료 조건과 공개 시점 등 기존 접근 제한을 통과한 경우에만 서명된 SRT URL을 제공한다. 유료 preview만 볼 수 있는 사용자는 전체 자막을 받지 않는다. 새로운 공개 download URI를 만들지 않는다.
|
||||
|
||||
## 4. 검증 시나리오
|
||||
|
||||
| 그룹 | 통과 조건 | 증거 |
|
||||
|---|---|---|
|
||||
| 1. 등록/조회 | S3 upload 경계에서 별도 transaction으로 PENDING/source 조회 가능, UUID/숫자 JSON 및 404 정확 | Spring DB/HTTP 테스트, upload 경계 assertion |
|
||||
| 2. started | 단조 전이, duplicate/terminal no-op, 잘못된 이름/status 거부 | DB/HTTP 테스트 |
|
||||
| 3. callback | complete/partial/fail, exact body hash replay, 422/409, callback-before-started, atomic rollback, older/latest guard | DB/HTTP 및 동시성 테스트 |
|
||||
| 4. 로그인/보안 | 실제 active EMAIL BOT 로그인/JWT로 네 API 허용, 다른 role/익명 거부, 기존 envelope 보존 | 기존 로그인/JWT 회귀 및 통합 테스트 |
|
||||
| 5. 콘텐츠 | ko/ja 선택 및 구매/소유자/무료/preview 권한 유지, 기존 업로드 완료/알림 회귀 없음 | 기존 콘텐츠 테스트와 추가 회귀 |
|
||||
|
||||
실제 서버 HTTP 요청으로 핵심 endpoint를 검증하고, 이번 작업에서 새로 작성한 테스트와 `bootJar`를 분리 실행한다. 기존 테스트는 읽어 계약을 보존하되 실행하지 않는다. 기존 클래스에 새 테스트를 추가한 경우 해당 메서드만 지정한다. 전체 테스트는 사용자 실행 `./gradlew test`로 남긴다. LSP 도구가 제공되지 않으면 Kotlin compile/lint 대체 결과와 미실행 사실을 명시한다.
|
||||
|
||||
## 5. 외부 게이트와 미검증
|
||||
|
||||
- AWS 접근/배포는 승인 없이 하지 않는다. 기존 consumer 운영 여부가 미확인이므로 삭제/전환/재개는 BLOCKED다.
|
||||
- live ko/ja/mixed S3 -> callback -> 콘텐츠/앱 검증은 별도 승인 및 환경 필요. 실제 word/item 언어 evidence 없는 mixed 결과는 통과 불가. synthetic fixture는 대체 증거가 아니다.
|
||||
- 2시간은 입력 목표이며 SLA/지원 보장이 아니다. 기존 신뢰 가능한 duration의 가용성과 검증 경계를 확인한다. 구형 또는 검증 불가능한 원본은 수동 게이트로 남기고 임의 client 값을 신뢰하지 않는다.
|
||||
- 대표 2시간 처리 예산은 미측정이며 지원 검증 BLOCKED. 분할/상한 하향/Translate 50-call 제한을 추가하지 않는다.
|
||||
- 운영 DB 적용 상태 미확인. DDL 적용/기존 데이터/미확인 환경에 대한 삭제는 하지 않는다.
|
||||
|
||||
## 6. Decision Log
|
||||
|
||||
| 날짜 | 결정 | 근거 |
|
||||
|---|---|---|
|
||||
| 2026-11-10 | 자막 재생성 기능 전체 제외, 나머지 진행 | 사용자 명시 결정 |
|
||||
| 2026-11-10 | 현재 Spring 중심, 외부 참조는 명시된 Lambda handoff에 한정 | 사용자 범위 확인 |
|
||||
| 2026-11-10 | 이미 있는 로그인/JWT/Lambda 재구현 금지, 자막 recovery 삭제 없음 | 실제 코드/ZIP 조사 |
|
||||
| 2026-11-10 | 실행 테스트는 이번 작업에서 새로 작성한 사례만. 기존 focused 회귀 및 전체 테스트 실행 금지 | 사용자 후속 지시 |
|
||||
| 2026-11-10 | 향후 언어 추가를 고려해 언어별 결과 행으로 저장, 이번 API는 ko/ja만 유지 | 사용자 확장 가능성 제시 및 중단 없이 진행 지시에 따른 최소 저장 구조 조정 |
|
||||
|
||||
## 7. 후속 범위: generate-url 자막 응답
|
||||
|
||||
2026-11-11 사용자 요청에 따라 기존 `GET /audio-content/{id}/generate-url` 응답에도 콘텐츠 상세와 동일한
|
||||
nullable `subtitle`을 추가한다. 기존 `ApiResponse` envelope와 `data.contentUrl` 이름·타입·URL 생성 동작은 유지한다.
|
||||
|
||||
- `GenerateUrlResponse`에 `subtitle: ContentSubtitleResponse? = null`을 추가한다. 필드는 `language`, `format`, `url`이다.
|
||||
- 기존 `orderRepository.isExistOrdered` 검사를 유지한다. 구매가 없으면 기존처럼 `contentUrl`은 빈 문자열이며 `subtitle`은 null이다.
|
||||
- 상세와 달리 현재 generate-url은 소유자/무료 콘텐츠만으로 구매 검사를 우회하지 않는다. 이 요청으로 기존 URL 권한을 확대하지 않는다.
|
||||
- 구매가 확인된 사용자의 자막은 상세와 같은 공개 시점 조건(미지정/도래 또는 소유자)을 적용한다. 기존 `contentUrl` 동작은 바꾸지 않는다.
|
||||
- `SubtitleJobService.subtitle`을 재사용해 요청 `LangContext`의 ko/ja, 최신 job, 완료/부분 완료 및 해당 언어 존재 여부를 처리한다.
|
||||
- 자막 미완료·누락·미지원 언어면 null이며 원문 fallback이나 새 자막 생성은 하지 않는다. 음원과 자막 서명 URL에는 같은 만료 기간을 전달한다.
|
||||
- 컨트롤러 URI/인증·DB 스키마·Lambda·재생성은 변경하지 않는다. 검증은 이번 후속 작업에서 새로 작성한 HTTP 테스트만 실행한다.
|
||||
|
||||
## 8. Schema-validation 회귀 수정
|
||||
|
||||
2026-11-11 실제 기동에서 `Schema-validation: missing column [s3bucket] in table [subtitle]`가 보고됐다.
|
||||
공개된 DDL의 `s3_bucket`/`s3_key`를 기준으로 엔티티의 물리 컬럼 이름을 명시한다.
|
||||
Kotlin 속성 및 JSON 이름 `s3Bucket`/`s3Key`는 바꾸지 않고 DB 컬럼 추가/rename이나 전역 naming strategy 변경도 하지 않는다.
|
||||
|
||||
기존 테스트의 `ddl-auto:create-drop`은 잘못 추론된 이름으로 테이블을 함께 생성해 이 불일치를 가렸다.
|
||||
신규 회귀 검증은 엔티티와 독립적으로 기존 DDL 컬럼 이름의 H2 테이블을 먼저 생성하고 실제 Hibernate
|
||||
`validate` 및 저장/재조회로 두 필드를 확인한다. 실제 운영 MySQL 재기동을 검증했다고 주장하지 않는다.
|
||||
@@ -0,0 +1,47 @@
|
||||
# 자막 Phase 1 Backend Gate 리뷰
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
- 일자: 2026-11-10
|
||||
- 대상: `test` working tree의 신규 `v2.subtitle` 및 기존 업로드/상세/보안 연결
|
||||
- 기준: [PRD](../prd.md), [계획](../plan-task.md), [구현 보고](../implementation-report.md)
|
||||
- 리뷰어: 독립 Oracle (`ses_e3a1e039bffeXbBb7Nx0V7Hvk8`)
|
||||
- 상태: 회귀 수정 검증 완료, 로컬 backend APPROVE
|
||||
- 검토 제외: AWS/운영 DB/실음성/2시간 성능, 기존 전체 회귀 실행
|
||||
|
||||
## 2. 근거와 판정
|
||||
|
||||
부모 직접 실행의 실제 localhost HTTP 신규 테스트 13개가 0 failure/error/skip으로 통과했다.
|
||||
reviewer는 코드·신규 테스트·실행 로그·DDL을 읽었고 테스트를 재실행하지 않았다.
|
||||
저장/권한/언어별 결과/업로드/스키마는 부합 판정이나 HTTP 오류 경계 한 건으로 REQUEST_CHANGES다.
|
||||
|
||||
| ID | 심각도 | 상태 | 내용 | 수정 Goal |
|
||||
|---|---|---|---|---|
|
||||
| REV-P1-001 | High | 수정 완료 / ADDRESSED | 자막 빈 body/저장 실패가 공통 advice에서 HTTP200으로 변환 | P1-R1 |
|
||||
|
||||
## 3. REV-P1-001
|
||||
|
||||
- `SubtitleExceptionHandler.kt`는 ResponseStatusException만 처리한다.
|
||||
- 기존 `common/SodaExceptionHandler.kt:73`과 `:94`는 DB 예외/일반 예외에 HTTP 상태 없이 ApiResponse.error를 반환한다.
|
||||
- 필수 body가 비면 strict parser 이전에 HttpMessageNotReadableException이 발생한다.
|
||||
- receipt 실패의 기존 새 테스트는 서비스 직접 호출 rollback만 검증하므로 HTTP200 문제를 포착하지 못했다.
|
||||
- 영향: 결과가 저장되지 않았어도 Lambda가 HTTP 성공으로 해석할 수 있다.
|
||||
|
||||
최소 수정은 자막 controller에 한정된 advice에서 body 읽기 오류를 422, 예상하지 못한 서버 오류를
|
||||
민감정보 없는 500으로 반환하는 것이다. 기존 404/409/422 및 공유 API 동작을 보존한다.
|
||||
|
||||
## 4. 수정 검증 조건
|
||||
|
||||
1. 빈 callback/started 요청이 200이 되는 RED를 실제 HTTP로 확인한다.
|
||||
2. receipt 실패 callback이 200이 되는 RED와 전체 DB rollback을 확인한다.
|
||||
3. 수정 후 422/500 및 무부분반영을 검증하고 이번 작성 테스트만 재실행한다.
|
||||
4. 같은 reviewer가 수정 delta와 새 증거를 재검토한다.
|
||||
|
||||
## 5. 판정 기록
|
||||
|
||||
- 1차: REQUEST_CHANGES 1건. 부모가 공통 handler 코드를 직접 확인하고 `P1-R1`에 반영했다.
|
||||
- 2026-11-11 수정 검증: 전용 advice만 수정하고 신규 HTTP 두 사례의 RED/GREEN을 확인했다. 이후 신규 자막15개·bootJar·변경 lint가 통과했고, 부모 직접 두 HTTP 사례 재실행도 2/0/0/0으로 통과했다. 기존 공통 handler와 직접 서비스 rollback 테스트는 보존했다. reviewer session continuation 유실로 기존 판정과 delta만 전달한 단일 재검토를 진행 중이다.
|
||||
- 운영 배포는 코드 리뷰와 별개이며 [배포 게이트](../deployment-gates.md)의 차단 상태를 유지한다.
|
||||
- 최종 제한 delta 리뷰 (`ses_e39e64c42ffeCOGUZjgeM4P1jf`): **REV-P1-001 ADDRESSED / 로컬 backend APPROVE**.
|
||||
전용 advice의 422/500, 기존 404/409/422 유지, 실제 HTTP 및 rollback RED/GREEN 증거를 확인했다.
|
||||
수정 delta에서 신규 버그/blocker는 발견하지 못했다. 추가 코드 수정 불필요 판정이며 전체 리뷰 반복이나 기존 테스트 실행은 하지 않았다.
|
||||
@@ -0,0 +1,16 @@
|
||||
# generate-url 자막 응답 제한 리뷰
|
||||
|
||||
- 일자: 2026-11-11
|
||||
- 범위: `P2-T1`, `GenerateUrlResponse`와 `AudioContentService.generateUrl`, 새 HTTP 테스트3개.
|
||||
- 기준: PRD §7, plan-task P2, 실제 메서드/DTO 전후 diff 및 `generate-url-report.md`의 RED/GREEN 증거.
|
||||
- 리뷰어: `ses_e387b1e8fffensjb6dnoRk82ST`, read-only. 기존 Phase1 전체는 다시 리뷰하지 않았다.
|
||||
- 판정: **APPROVE**, 이번 범위의 확정 finding/blocker/새 회귀 없음.
|
||||
|
||||
확인한 기준:
|
||||
1. contentUrl과 ApiResponse envelope 유지, 공유 nullable subtitle만 additive 추가.
|
||||
2. 활성 구매(KEEP/유효 RENTAL) 정책 유지, 소유자·무료만으로 우회하지 않음.
|
||||
3. 음원 URL은 기존대로이며 자막에만 상세와 동일한 공개일/소유자 조건 적용.
|
||||
4. 동일 expirationTime 전달 및 공유 latest/언어/상태별 자막 조회 재사용.
|
||||
5. 새 HTTP3개 RED → GREEN 및 부모 재실행3/0/0/0, 실제 localhost HTTP/JWT/H2 증거 확인.
|
||||
|
||||
잔여사항: production lint의 기존 서비스 EOF 빈 줄1건은 보존했다. 기존 테스트/전체 테스트/LSP 및 실제 AWS 서명·다운로드/운영 배포는 이번 검증이 아니다. 수정 Task로 전환할 새로운 발견 사항은 없다.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Subtitle 물리 컬럼명 회귀 리뷰
|
||||
|
||||
- 일자: 2026-11-11
|
||||
- 범위: `P3-R1`, Subtitle의 두 Column annotation과 신규 ORM schema-validation 테스트.
|
||||
- 기준: PRD §8, plan-task Phase3, 실제 RED/GREEN 로그와 production diff.
|
||||
- 리뷰어: `ses_e37b4db2bffeUyLw5vO2EtHHuG`, read-only.
|
||||
- 판정: **APPROVE**, 범위 내 확정 finding/blocker 없음.
|
||||
|
||||
검토 결과: `s3Bucket`/`s3Key`의 기본 추론 이름을 기존 DDL의 `s3_bucket`/`s3_key`로 명시했다.
|
||||
Kotlin/JSON 이름·타입/길이/nullable, DDL과 전역 전략은 그대로다. 신규 테스트는 독립 테이블에서 실제
|
||||
Hibernate validate, commit 후 새 Session 및 직접 JDBC 컬럼 조회를 수행한다. RED missing[s3bucket]과
|
||||
GREEN1/0/0/0, bootJar 및 변경 lint 성공을 확인했다. 기존 create-drop 테스트의 한계를 반복하지 않는다.
|
||||
|
||||
미검증: 실제 MySQL 기동/collation/FK, 전체 Spring 연동, 기존 테스트/전체 테스트, LSP.
|
||||
추가 수정 Task 없음. 운영 DB 변경이나 배포 승인을 의미하지 않는다.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Subtitle 컬럼명 schema-validation 회귀
|
||||
|
||||
## 원인과 범위
|
||||
|
||||
사용자가 보고한 `Schema-validation: missing column [s3bucket] in table [subtitle]`를 실제 Hibernate로 재현했다.
|
||||
`Subtitle.s3Bucket`/`s3Key`에 물리 컬럼 이름이 없었고, 기본 naming strategy는 숫자 `3` 다음 대문자 앞에
|
||||
underscore를 넣지 않아 `s3bucket`/`s3key`로 변환한다. 기존 DDL은 `s3_bucket`/`s3_key`이므로 불일치했다.
|
||||
|
||||
production 수정은 `Subtitle.kt`의 두 annotation뿐이다:
|
||||
|
||||
```kotlin
|
||||
@Column(name = "s3_bucket", nullable = false, length = 255)
|
||||
val s3Bucket: String,
|
||||
@Column(name = "s3_key", nullable = false, length = 255)
|
||||
val s3Key: String
|
||||
```
|
||||
|
||||
Kotlin/JSON 필드명, 기존 DDL, 전역 naming strategy, 운영 DB는 변경하지 않는다.
|
||||
잘못 추론된 이름의 DB 컬럼을 새로 만들거나 `ddl-auto`를 update/none으로 바꿔 오류를 숨기지 않는다.
|
||||
|
||||
## 기존 검증의 공백
|
||||
|
||||
기존 HTTP 테스트는 `ddl-auto:create-drop`으로 엔티티 기준 테이블을 자동 생성했으므로 잘못된 컬럼명으로
|
||||
테이블과 ORM이 동시에 일치했다. 앞선 정적 DDL/JPA 대조도 숫자-대문자 경계의 이름 변환을 놓쳤다.
|
||||
해당 완료 이력은 보존하되 이번 재현으로 그 검증 한계를 명확히 정정한다.
|
||||
|
||||
## 신규 회귀 테스트
|
||||
|
||||
`SubtitleSchemaValidationTest.shouldValidateAndUseExistingS3Columns` 한 개만 실행한다.
|
||||
|
||||
1. 엔티티와 독립적으로 H2에 `s3_bucket`/`s3_key`를 포함한 테이블을 직접 생성한다.
|
||||
2. `HibernateProperties`/`HibernateSettings`로 Boot 기본 설정을 얻고 실제 Hibernate의 `hbm2ddl.auto=validate`를 실행한다.
|
||||
3. `Subtitle`을 저장하고 새 Session 및 직접 JDBC SELECT로 두 컬럼의 값을 확인한다.
|
||||
|
||||
전체 Spring context/Redis/AWS 및 mock 없이 ORM 저장 표면을 검증한다. DB는 테스트 전용 메모리 H2이며
|
||||
실제 MySQL collation/FK/운영 기동 전부를 검증하는 테스트는 아니다.
|
||||
|
||||
## 실행 기록
|
||||
|
||||
- RED: 새 테스트1개/실패1개/오류0/skip0, XML UTC `2026-11-11T01:42:01`.
|
||||
- 실제 실패: `SchemaManagementException: Schema-validation: missing column [s3bucket] in table [subtitle]`.
|
||||
- RED 빌드 결과: `BUILD FAILED in 3m 49s`. 컴파일 오류가 아닌 보고된 운영 오류와 동일한 validation 실패다.
|
||||
- GREEN: 새 테스트1개/실패0/오류0/skip0, XML UTC `2026-11-11T01:46:49`.
|
||||
- 실제 Hibernate validation 성공 후 저장/new Session 조회/직접 JDBC 두 컬럼 조회가 통과했다.
|
||||
- `bootJar` 및 변경 엔티티/신규 테스트에 한정한 `ktlintCheck` 성공, `BUILD SUCCESSFUL in 3m 29s`.
|
||||
- LSP 도구 미제공으로 Kotlin 컴파일·변경 파일 lint를 사용했다.
|
||||
- 독립 한정 리뷰 `ses_e37b4db2bffeUyLw5vO2EtHHuG`: **APPROVE**. 두 annotation만 변경됐고 독립 DDL/실제 ORM roundtrip 테스트가 원인에 맞는 것을 확인했다. 신규 blocker 없음.
|
||||
|
||||
일반 환경의 신규 테스트 실행 명령:
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleSchemaValidationTest'
|
||||
```
|
||||
|
||||
격리 실행은 작업 소유 root `subtitle-schema-fix.XcftjN`의 init script로 build/project-cache와 테스트 compile/lint
|
||||
입력을 이 변경에 한정한다. production source는 필터링하지 않는다. 기존 테스트와 전체 테스트는 실행하지 않는다.
|
||||
|
||||
실제 격리 실행 명령(RED는 `test`까지만, GREEN은 아래 전체):
|
||||
|
||||
```bash
|
||||
export VERIFY_ROOT=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/subtitle-schema-fix.XcftjN
|
||||
export TMPDIR="$VERIFY_ROOT" NODE_COMPILE_CACHE="$VERIFY_ROOT/node-compile-cache"
|
||||
export BUN_RUNTIME_TRANSPILER_CACHE_PATH="$VERIFY_ROOT/bun-transpiler"
|
||||
./gradlew --offline --no-daemon --max-workers=2 \
|
||||
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs=-Xmx4g \
|
||||
--project-cache-dir "$VERIFY_ROOT/project-cache" --init-script "$VERIFY_ROOT/verification.init.gradle" \
|
||||
test --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleSchemaValidationTest' bootJar ktlintCheck
|
||||
```
|
||||
|
||||
로그는 위 root의 `red.log`, `green.log`다. 실제 운영 DB 접속·DDL 변경·서버 배포/재시작·기존 테스트 실행은 하지 않았다.
|
||||
|
||||
## 정리
|
||||
|
||||
검증·리뷰 후 작업 root를 참조하는 프로세스의 부재를 확인하고 소유 root/build/cache/log/JAR를 제거했다.
|
||||
`test ! -e` exit0으로 확인했으며 위 임시 경로는 과거 검증 위치다. 공유 캐시/다른 작업은 변경하지 않았다.
|
||||
새 테스트/원인/명령/결과와 리뷰 기록은 저장소에 남겼고 이번 수정은 커밋하지 않았다.
|
||||
|
||||
## 후속 커밋 검증
|
||||
|
||||
2026-11-11 별도 `/commit-with-validation` 요청으로 요구사항·계획은 `cf31c570`, 수정·회귀 테스트는
|
||||
`c3e5f19c`에 커밋했다. 두 커밋 모두 저장소 메시지 검증 스크립트의 직전·직후 검증을 통과했다.
|
||||
수정 커밋의 pre-commit hook에서 변경 Kotlin 2개 파일의 `ktlintCheck`도 통과했다.
|
||||
|
||||
`./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.v2.subtitle.SubtitleSchemaValidationTest'`를
|
||||
재실행해 `BUILD SUCCESSFUL in 46s`, 테스트 1개/실패 0/오류 0/건너뜀 0을 확인했다.
|
||||
전체 테스트·실제 MySQL 기동·bootJar는 이번 커밋 작업에서 실행하지 않았다. 전체 테스트 사용자 명령은 `./gradlew test`다.
|
||||
@@ -0,0 +1,53 @@
|
||||
-- 자막 Phase 1 미출시 초안. 운영 DB 적용/호환성은 확인하지 않았다.
|
||||
-- 실행 전 deployment-gates.md의 엔티티 대조 및 운영 DB 게이트 승인이 필요하다.
|
||||
-- MySQL 8.0.17 이상, utf8mb4_0900_bin(NO PAD), InnoDB 3072-byte 인덱스 지원이 전제다.
|
||||
-- 대소문자와 후행 공백을 보존하여 eventId 및 S3 bucket/key를 정확히 구별한다.
|
||||
-- source_upload_error_at은 최종 JPA의 TIMESTAMP 선언과 초 단위 정밀도를 맞췄다.
|
||||
|
||||
CREATE TABLE subtitle_job (
|
||||
job_id VARCHAR(36) NOT NULL COMMENT 'Spring 생성 자막 작업 UUID',
|
||||
content_id BIGINT NOT NULL COMMENT '기존 콘텐츠 Long 식별자',
|
||||
source_bucket VARCHAR(255) NOT NULL COMMENT '최초 등록 원본 S3 버킷',
|
||||
source_key VARCHAR(255) NOT NULL COMMENT '최초 등록 원본 S3 객체 키',
|
||||
output_key VARCHAR(255) NOT NULL COMMENT 'MediaConvert 출력 객체 키의 확장자 제외 경로',
|
||||
status VARCHAR(32) NOT NULL COMMENT '자막 작업 상태',
|
||||
error_code VARCHAR(255) NULL COMMENT '자막 처리 오류 코드',
|
||||
error_message TEXT NULL COMMENT '자막 처리 오류 설명',
|
||||
source_upload_error_code VARCHAR(255) NULL COMMENT '원본 업로드 불확실성 오류 코드',
|
||||
source_upload_error_at TIMESTAMP NULL DEFAULT NULL COMMENT '원본 업로드 오류 기록 시각',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
||||
PRIMARY KEY (job_id),
|
||||
UNIQUE KEY uk_subtitle_job_source (source_bucket, source_key),
|
||||
KEY ix_subtitle_job_content_created (content_id, created_at)
|
||||
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC
|
||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin
|
||||
COMMENT='원본 binding과 자막 작업 상태 및 오류';
|
||||
|
||||
CREATE TABLE subtitle (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '언어별 자막 결과 Long 식별자',
|
||||
job_id VARCHAR(36) NOT NULL COMMENT '소속 자막 작업 UUID',
|
||||
language VARCHAR(35) NOT NULL COMMENT '자막 언어 태그',
|
||||
format VARCHAR(16) NOT NULL COMMENT '자막 파일 형식',
|
||||
s3_bucket VARCHAR(255) NOT NULL COMMENT '자막 결과 S3 버킷',
|
||||
s3_key VARCHAR(255) NOT NULL COMMENT '자막 결과 S3 객체 키',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_subtitle_job_language (job_id, language),
|
||||
CONSTRAINT fk_subtitle_job FOREIGN KEY (job_id) REFERENCES subtitle_job (job_id)
|
||||
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC
|
||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin
|
||||
COMMENT='작업별 언어별 자막 결과';
|
||||
|
||||
CREATE TABLE subtitle_callback_receipt (
|
||||
event_id VARCHAR(255) NOT NULL COMMENT 'callback 중복 방지 이벤트 식별자',
|
||||
job_id VARCHAR(36) NOT NULL COMMENT 'callback 대상 자막 작업 UUID',
|
||||
body_hash VARCHAR(64) NOT NULL COMMENT '원래 HTTP body bytes의 SHA-256 소문자 16진수',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
||||
PRIMARY KEY (event_id)
|
||||
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC
|
||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin
|
||||
COMMENT='callback 이벤트와 원본 요청 본문 해시 수신 기록';
|
||||
|
||||
ALTER TABLE content
|
||||
ADD COLUMN latest_subtitle_job_id VARCHAR(36)
|
||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NULL DEFAULT NULL COMMENT '최신 선택 자막 작업 UUID';
|
||||
@@ -157,6 +157,13 @@ class SecurityConfig(
|
||||
JwtFilter::class.java
|
||||
)
|
||||
.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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<ContentBuyer>,
|
||||
val isAvailableUsePoint: Boolean,
|
||||
val translated: TranslatedContent?
|
||||
val translated: TranslatedContent?,
|
||||
val subtitle: ContentSubtitleResponse? = null
|
||||
)
|
||||
|
||||
data class OtherContentResponse @QueryProjection constructor(
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence
|
||||
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.GeneratedValue
|
||||
import javax.persistence.GenerationType
|
||||
import javax.persistence.Id
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
@Table(name = "subtitle", uniqueConstraints = [UniqueConstraint(columnNames = ["job_id", "language"])])
|
||||
class Subtitle(
|
||||
@Column(name = "job_id", nullable = false, length = 36)
|
||||
val jobId: String,
|
||||
@Column(nullable = false, length = 35)
|
||||
val language: String,
|
||||
@Column(nullable = false, length = 16)
|
||||
val format: String,
|
||||
@Column(name = "s3_bucket", nullable = false, length = 255)
|
||||
val s3Bucket: String,
|
||||
@Column(name = "s3_key", nullable = false, length = 255)
|
||||
val s3Key: String
|
||||
) {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
var id: Long? = null
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence
|
||||
|
||||
import org.springframework.data.domain.Persistable
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.temporal.ChronoUnit
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.Id
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.Transient
|
||||
|
||||
@Entity
|
||||
@Table(name = "subtitle_callback_receipt")
|
||||
class SubtitleCallbackReceipt(
|
||||
@Id
|
||||
@Column(length = 255)
|
||||
val eventId: String,
|
||||
@Column(nullable = false, length = 36)
|
||||
val jobId: String,
|
||||
@Column(nullable = false, length = 64)
|
||||
val bodyHash: String
|
||||
) : Persistable<String> {
|
||||
@Column(nullable = false, columnDefinition = "TIMESTAMP")
|
||||
val createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS)
|
||||
|
||||
override fun getId(): String = eventId
|
||||
|
||||
@Transient
|
||||
override fun isNew(): Boolean = true
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface SubtitleCallbackReceiptRepository : JpaRepository<SubtitleCallbackReceipt, String>
|
||||
+47
@@ -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
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Lock
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
interface SubtitleJobRepository : JpaRepository<SubtitleJob, String> {
|
||||
fun findBySourceBucketAndSourceKey(sourceBucket: String, sourceKey: String): SubtitleJob?
|
||||
fun findFirstByContentIdOrderByCreatedAtAsc(contentId: Long): SubtitleJob?
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select j from SubtitleJob j where j.jobId = :jobId")
|
||||
fun findForUpdate(@Param("jobId") jobId: String): SubtitleJob?
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface SubtitleRepository : JpaRepository<Subtitle, Long> {
|
||||
fun findAllByJobId(jobId: String): List<Subtitle>
|
||||
fun findByJobIdAndLanguage(jobId: String, language: String): Subtitle?
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package kr.co.vividnext.sodalive.v2.subtitle.adapter.web
|
||||
|
||||
import org.springframework.core.Ordered
|
||||
import org.springframework.core.annotation.Order
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
@RestControllerAdvice(assignableTypes = [SubtitleJobController::class])
|
||||
class SubtitleExceptionHandler {
|
||||
@ExceptionHandler(ResponseStatusException::class)
|
||||
fun handle(exception: ResponseStatusException): ResponseEntity<Map<String, String>> =
|
||||
ResponseEntity.status(exception.status).body(mapOf("error" to "Subtitle request rejected"))
|
||||
|
||||
@ExceptionHandler(HttpMessageNotReadableException::class)
|
||||
fun handleUnreadableBody(): ResponseEntity<Map<String, String>> =
|
||||
ResponseEntity.unprocessableEntity().body(mapOf("error" to "Subtitle request rejected"))
|
||||
|
||||
@ExceptionHandler(Exception::class)
|
||||
fun handleUnexpectedException(): ResponseEntity<Map<String, String>> =
|
||||
ResponseEntity.internalServerError().body(mapOf("error" to "Subtitle request failed"))
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package kr.co.vividnext.sodalive.v2.subtitle.adapter.web
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.application.SubtitleJobService
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/internal/api/subtitle-jobs")
|
||||
class SubtitleJobController(private val service: SubtitleJobService, private val parser: SubtitleRequestParser) {
|
||||
@GetMapping("/resolve")
|
||||
fun resolve(@RequestParam bucket: String, @RequestParam key: String) = service.resolve(bucket, key)
|
||||
|
||||
@GetMapping("/{jobId}")
|
||||
fun get(@PathVariable jobId: String) = service.get(jobId)
|
||||
|
||||
@PostMapping("/{jobId}/started", consumes = ["application/json"])
|
||||
fun started(@PathVariable jobId: String, @RequestBody body: ByteArray): Map<String, String> {
|
||||
service.started(jobId, parser.started(body))
|
||||
return emptyMap()
|
||||
}
|
||||
|
||||
@PostMapping("/callback", consumes = ["application/json"])
|
||||
fun callback(@RequestBody body: ByteArray): Map<String, String> {
|
||||
service.callback(parser.callback(body), body)
|
||||
return emptyMap()
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package kr.co.vividnext.sodalive.v2.subtitle.adapter.web
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.core.JsonProcessingException
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.dto.SubtitleCallbackRequest
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.dto.SubtitleResultRequest
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.dto.SubtitleStartedRequest
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
|
||||
@Component
|
||||
class SubtitleRequestParser(objectMapper: ObjectMapper) {
|
||||
private val mapper = objectMapper.copy()
|
||||
.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION)
|
||||
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
|
||||
|
||||
fun started(body: ByteArray): SubtitleStartedRequest {
|
||||
val node = parse(body)
|
||||
fields(node, setOf("transcribeJobName", "status"))
|
||||
return SubtitleStartedRequest(text(node, "transcribeJobName"), text(node, "status"))
|
||||
}
|
||||
|
||||
fun callback(body: ByteArray): SubtitleCallbackRequest {
|
||||
val node = parse(body)
|
||||
fields(
|
||||
node,
|
||||
setOf("eventId", "jobId", "contentId", "transcribeJobName", "status", "subtitles", "errorCode", "errorMessage")
|
||||
)
|
||||
val contentId = node.path("contentId")
|
||||
if (!contentId.isIntegralNumber || !contentId.canConvertToLong() || contentId.longValue() <= 0) throw invalid()
|
||||
val subtitles = node.path("subtitles")
|
||||
if (!subtitles.isArray || subtitles.size() > 2) throw invalid()
|
||||
return SubtitleCallbackRequest(
|
||||
eventId = text(node, "eventId"),
|
||||
jobId = text(node, "jobId"),
|
||||
contentId = contentId.longValue(),
|
||||
transcribeJobName = text(node, "transcribeJobName"),
|
||||
status = text(node, "status"),
|
||||
subtitles = subtitles.map {
|
||||
fields(it, setOf("language", "format", "s3Bucket", "s3Key"))
|
||||
SubtitleResultRequest(text(it, "language"), text(it, "format"), text(it, "s3Bucket"), text(it, "s3Key"))
|
||||
},
|
||||
errorCode = nullableText(node, "errorCode"),
|
||||
errorMessage = nullableText(node, "errorMessage")
|
||||
)
|
||||
}
|
||||
|
||||
private fun parse(body: ByteArray): JsonNode = try {
|
||||
mapper.readTree(body) ?: throw invalid()
|
||||
} catch (_: JsonProcessingException) {
|
||||
throw invalid()
|
||||
}
|
||||
|
||||
private fun fields(node: JsonNode, expected: Set<String>) {
|
||||
if (!node.isObject || node.fieldNames().asSequence().toSet() != expected) throw invalid()
|
||||
}
|
||||
|
||||
private fun text(node: JsonNode, field: String): String {
|
||||
val value = node.path(field)
|
||||
if (!value.isTextual || value.textValue().isBlank()) throw invalid()
|
||||
return value.textValue()
|
||||
}
|
||||
|
||||
private fun nullableText(node: JsonNode, field: String): String? =
|
||||
if (node.path(field).isNull) null else text(node, field)
|
||||
|
||||
private fun invalid() = ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Invalid subtitle request")
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
package kr.co.vividnext.sodalive.v2.subtitle.application
|
||||
|
||||
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
|
||||
import kr.co.vividnext.sodalive.content.AudioContent
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.Subtitle
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.SubtitleCallbackReceipt
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.SubtitleCallbackReceiptRepository
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.SubtitleJob
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.SubtitleJobRepository
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.SubtitleRepository
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.domain.SubtitleJobStatus
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.dto.ContentSubtitleResponse
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.dto.SubtitleCallbackRequest
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.dto.SubtitleJobResponse
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.dto.SubtitleResultRequest
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.dto.SubtitleSourceResponse
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.dto.SubtitleStartedRequest
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.TransactionDefinition
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import java.security.MessageDigest
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class SubtitleJobService(
|
||||
private val jobs: SubtitleJobRepository,
|
||||
private val receipts: SubtitleCallbackReceiptRepository,
|
||||
private val subtitles: SubtitleRepository,
|
||||
private val cloudFront: AudioContentCloudFront,
|
||||
transactionManager: PlatformTransactionManager,
|
||||
@Value("\${cloud.aws.s3.content-bucket}") private val bucket: String
|
||||
) {
|
||||
private val transaction = TransactionTemplate(transactionManager).apply {
|
||||
propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
|
||||
isolationLevel = TransactionDefinition.ISOLATION_READ_COMMITTED
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.MANDATORY)
|
||||
fun register(content: AudioContent, originalFilename: String?): SubtitleJob {
|
||||
val contentId = requireNotNull(content.id)
|
||||
val sourceName = UUID.randomUUID().toString()
|
||||
val extension = originalFilename?.replace('\\', '/')?.substringAfterLast('/')
|
||||
?.substringAfterLast('.', "")?.takeIf { it.matches(Regex("[A-Za-z0-9]{1,16}")) }
|
||||
?.lowercase(Locale.ROOT)
|
||||
val sourceFileName = if (extension == null) sourceName else "$sourceName.$extension"
|
||||
val job = jobs.save(
|
||||
SubtitleJob(
|
||||
jobId = UUID.randomUUID().toString(),
|
||||
contentId = contentId,
|
||||
sourceBucket = bucket,
|
||||
sourceKey = "input/$contentId/$sourceFileName",
|
||||
outputKey = "output/$contentId/$sourceName"
|
||||
)
|
||||
)
|
||||
content.latestSubtitleJobId = job.jobId
|
||||
content.content = job.sourceKey
|
||||
return job
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun resolve(bucket: String, key: String): SubtitleJobResponse =
|
||||
response(jobs.findBySourceBucketAndSourceKey(bucket, key) ?: throw missing())
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun get(jobId: String): SubtitleJobResponse = response(jobs.findByIdOrNull(jobId) ?: throw missing())
|
||||
|
||||
fun started(jobId: String, request: SubtitleStartedRequest) {
|
||||
if (request.transcribeJobName != "subtitle-$jobId" || request.status != "PROCESSING") throw invalid()
|
||||
transaction.executeWithoutResult {
|
||||
val job = jobs.findForUpdate(jobId) ?: throw missing()
|
||||
if (job.status == SubtitleJobStatus.PENDING) {
|
||||
job.status = SubtitleJobStatus.PROCESSING
|
||||
job.updatedAt = now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun callback(request: SubtitleCallbackRequest, rawBody: ByteArray) {
|
||||
if (request.eventId.isBlank() || request.eventId.length > 255) throw invalid()
|
||||
val hash = MessageDigest.getInstance("SHA-256").digest(rawBody).joinToString("") { "%02x".format(it) }
|
||||
try {
|
||||
transaction.executeWithoutResult {
|
||||
val existing = receipts.findByIdOrNull(request.eventId)
|
||||
if (existing != null) {
|
||||
replay(existing, hash)
|
||||
return@executeWithoutResult
|
||||
}
|
||||
val job = jobs.findForUpdate(request.jobId) ?: throw missing()
|
||||
val afterLock = receipts.findByIdOrNull(request.eventId)
|
||||
if (afterLock != null) {
|
||||
replay(afterLock, hash)
|
||||
return@executeWithoutResult
|
||||
}
|
||||
val status = validate(job, request)
|
||||
val existingResults = subtitles.findAllByJobId(job.jobId).map {
|
||||
SubtitleResultRequest(it.language, it.format, it.s3Bucket, it.s3Key)
|
||||
}.sortedBy { it.language }
|
||||
if (job.status.terminal && (
|
||||
job.status != status || existingResults != request.subtitles.sortedBy { it.language } ||
|
||||
job.errorCode != request.errorCode || job.errorMessage != request.errorMessage
|
||||
)
|
||||
) {
|
||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Subtitle job already finished")
|
||||
}
|
||||
|
||||
if (!job.status.terminal) {
|
||||
subtitles.saveAll(
|
||||
request.subtitles.map {
|
||||
Subtitle(job.jobId, it.language, it.format, it.s3Bucket, it.s3Key)
|
||||
}
|
||||
)
|
||||
job.status = status
|
||||
job.errorCode = request.errorCode
|
||||
job.errorMessage = request.errorMessage
|
||||
job.updatedAt = now()
|
||||
}
|
||||
receipts.saveAndFlush(SubtitleCallbackReceipt(request.eventId, job.jobId, hash))
|
||||
}
|
||||
} catch (exception: DataIntegrityViolationException) {
|
||||
transaction.executeWithoutResult {
|
||||
val receipt = receipts.findByIdOrNull(request.eventId) ?: throw exception
|
||||
replay(receipt, hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun recordUploadError(jobId: String) {
|
||||
transaction.executeWithoutResult {
|
||||
val job = jobs.findForUpdate(jobId) ?: throw missing()
|
||||
val recordedAt = now()
|
||||
job.sourceUploadErrorCode = "SOURCE_UPLOAD_UNCERTAIN"
|
||||
job.sourceUploadErrorAt = recordedAt
|
||||
job.updatedAt = recordedAt
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun matchesOutput(contentId: Long, outputKey: String): Boolean? =
|
||||
jobs.findFirstByContentIdOrderByCreatedAtAsc(contentId)?.let { job ->
|
||||
outputKey.substringBeforeLast('.', "") == job.outputKey &&
|
||||
outputKey.substringAfterLast('.', "") in setOf("aac", "m4a", "mp3")
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun subtitle(content: AudioContent, lang: Lang, expirationTime: Long): ContentSubtitleResponse? {
|
||||
val jobId = content.latestSubtitleJobId ?: return null
|
||||
val job = jobs.findByIdOrNull(jobId) ?: return null
|
||||
if (job.contentId != content.id || !job.status.terminal || job.status == SubtitleJobStatus.FAILED) return null
|
||||
val language = when (lang) {
|
||||
Lang.KO -> "ko-KR"
|
||||
Lang.JA -> "ja-JP"
|
||||
else -> return null
|
||||
}
|
||||
val subtitle = subtitles.findByJobIdAndLanguage(jobId, language) ?: return null
|
||||
return ContentSubtitleResponse(
|
||||
subtitle.language,
|
||||
subtitle.format,
|
||||
cloudFront.generateSignedURL(subtitle.s3Key, expirationTime)
|
||||
)
|
||||
}
|
||||
|
||||
private fun validate(job: SubtitleJob, request: SubtitleCallbackRequest): SubtitleJobStatus {
|
||||
if (request.contentId != job.contentId || request.transcribeJobName != "subtitle-${job.jobId}") throw invalid()
|
||||
if (request.subtitles.map { it.language }.distinct().size != request.subtitles.size) throw invalid()
|
||||
request.subtitles.forEach {
|
||||
val language = when (it.language) {
|
||||
"ko-KR" -> "ko"
|
||||
"ja-JP" -> "ja"
|
||||
else -> throw invalid()
|
||||
}
|
||||
if (it.format != "SRT" || it.s3Bucket != bucket ||
|
||||
it.s3Key != "subtitles/${job.contentId}/${job.jobId}/$language.srt"
|
||||
) {
|
||||
throw invalid()
|
||||
}
|
||||
}
|
||||
val status = when (request.subtitles.size) {
|
||||
2 -> SubtitleJobStatus.COMPLETED
|
||||
1 -> SubtitleJobStatus.PARTIAL_COMPLETED
|
||||
0 -> SubtitleJobStatus.FAILED
|
||||
else -> throw invalid()
|
||||
}
|
||||
if (request.status != status.name) throw invalid()
|
||||
if (status == SubtitleJobStatus.COMPLETED) {
|
||||
if (request.errorCode != null || request.errorMessage != null) throw invalid()
|
||||
} else if (request.errorCode.isNullOrBlank() || request.errorMessage.isNullOrBlank() || request.errorCode.length > 255) {
|
||||
throw invalid()
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
private fun replay(receipt: SubtitleCallbackReceipt, hash: String) {
|
||||
if (receipt.bodyHash != hash) throw invalid()
|
||||
}
|
||||
|
||||
private fun response(job: SubtitleJob) = SubtitleJobResponse(
|
||||
job.jobId,
|
||||
job.contentId,
|
||||
job.status,
|
||||
SubtitleSourceResponse(job.sourceBucket, job.sourceKey)
|
||||
)
|
||||
|
||||
private fun now() = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS)
|
||||
private fun missing() = ResponseStatusException(HttpStatus.NOT_FOUND, "Subtitle job not found")
|
||||
private fun invalid() = ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Invalid subtitle request")
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.v2.subtitle.domain
|
||||
|
||||
enum class SubtitleJobStatus {
|
||||
PENDING, PROCESSING, COMPLETED, PARTIAL_COMPLETED, FAILED;
|
||||
|
||||
val terminal: Boolean
|
||||
get() = this == COMPLETED || this == PARTIAL_COMPLETED || this == FAILED
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package kr.co.vividnext.sodalive.v2.subtitle.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.domain.SubtitleJobStatus
|
||||
|
||||
data class SubtitleSourceResponse(val bucket: String, val key: String, val versionId: String? = null)
|
||||
|
||||
data class SubtitleJobResponse(
|
||||
val jobId: String,
|
||||
val contentId: Long,
|
||||
val status: SubtitleJobStatus,
|
||||
val source: SubtitleSourceResponse
|
||||
)
|
||||
|
||||
data class SubtitleStartedRequest(val transcribeJobName: String, val status: String)
|
||||
|
||||
data class SubtitleResultRequest(val language: String, val format: String, val s3Bucket: String, val s3Key: String)
|
||||
|
||||
data class SubtitleCallbackRequest(
|
||||
val eventId: String,
|
||||
val jobId: String,
|
||||
val contentId: Long,
|
||||
val transcribeJobName: String,
|
||||
val status: String,
|
||||
val subtitles: List<SubtitleResultRequest>,
|
||||
val errorCode: String?,
|
||||
val errorMessage: String?
|
||||
)
|
||||
|
||||
data class ContentSubtitleResponse(val language: String, val format: String, val url: String)
|
||||
@@ -27,6 +27,7 @@ import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
|
||||
import kr.co.vividnext.sodalive.support.originalLocalizedNameService
|
||||
import kr.co.vividnext.sodalive.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"
|
||||
|
||||
+50
-9
@@ -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<Long> = 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(
|
||||
|
||||
+50
-9
@@ -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<Long> = 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,
|
||||
|
||||
+2
-1
@@ -182,7 +182,8 @@ class AiCharacterAdminAudioContentQueryTest @Autowired constructor(
|
||||
"nextContent",
|
||||
"buyerList",
|
||||
"isAvailableUsePoint",
|
||||
"translated"
|
||||
"translated",
|
||||
"subtitle"
|
||||
),
|
||||
data.fieldNames().asSequence().toSet()
|
||||
)
|
||||
|
||||
+14
-9
@@ -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()
|
||||
|
||||
@@ -0,0 +1,905 @@
|
||||
package kr.co.vividnext.sodalive.v2.subtitle
|
||||
|
||||
import com.amazonaws.services.s3.AmazonS3Client
|
||||
import com.amazonaws.services.s3.model.PutObjectRequest
|
||||
import com.amazonaws.services.s3.model.PutObjectResult
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
|
||||
import kr.co.vividnext.sodalive.content.AudioContent
|
||||
import kr.co.vividnext.sodalive.content.AudioContentService
|
||||
import kr.co.vividnext.sodalive.content.CreateAudioContentRequest
|
||||
import kr.co.vividnext.sodalive.content.order.Order
|
||||
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.SubtitleCallbackReceipt
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.SubtitleCallbackReceiptRepository
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.SubtitleJob
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.SubtitleJobRepository
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.SubtitleRepository
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.adapter.web.SubtitleRequestParser
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.application.SubtitleJobService
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.domain.SubtitleJobStatus
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.boot.test.mock.mockito.SpyBean
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.http.HttpEntity
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.mock.web.MockMultipartFile
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.test.context.DynamicPropertyRegistry
|
||||
import org.springframework.test.context.DynamicPropertySource
|
||||
import org.springframework.test.util.AopTestUtils
|
||||
import org.springframework.test.util.ReflectionTestUtils
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.TransactionDefinition
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Base64
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.Callable
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||
class SubtitleHttpIntegrationTest @Autowired constructor(
|
||||
private val http: TestRestTemplate,
|
||||
private val mapper: ObjectMapper,
|
||||
private val members: MemberRepository,
|
||||
private val passwordEncoder: PasswordEncoder,
|
||||
private val jobs: SubtitleJobRepository,
|
||||
private val receipts: SubtitleCallbackReceiptRepository,
|
||||
private val subtitles: SubtitleRepository,
|
||||
private val service: SubtitleJobService,
|
||||
private val contentService: AudioContentService,
|
||||
private val parser: SubtitleRequestParser,
|
||||
private val entityManager: EntityManager,
|
||||
transactionManager: PlatformTransactionManager
|
||||
) {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@DynamicPropertySource
|
||||
fun properties(registry: DynamicPropertyRegistry) {
|
||||
registry.add("jwt.secret") { Base64.getEncoder().encodeToString(ByteArray(64) { 7 }) }
|
||||
registry.add("cloud.aws.s3.content-bucket") { "subtitle-audio" }
|
||||
registry.add("cloud.aws.s3.bucket") { "subtitle-cover" }
|
||||
registry.add("spring.jpa.properties.hibernate.show_sql") { "false" }
|
||||
}
|
||||
}
|
||||
|
||||
@MockBean
|
||||
private lateinit var s3: AmazonS3Client
|
||||
|
||||
@MockBean
|
||||
private lateinit var cloudFront: AudioContentCloudFront
|
||||
|
||||
@SpyBean
|
||||
private lateinit var receiptWriter: SubtitleCallbackReceiptRepository
|
||||
|
||||
private val transaction = TransactionTemplate(transactionManager).apply {
|
||||
propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
|
||||
}
|
||||
private lateinit var originalPublisher: ApplicationEventPublisher
|
||||
private lateinit var publisher: ApplicationEventPublisher
|
||||
|
||||
@BeforeEach
|
||||
fun setupExternalBoundaries() {
|
||||
val target = AopTestUtils.getTargetObject<AudioContentService>(contentService)
|
||||
originalPublisher = ReflectionTestUtils.getField(target, "applicationEventPublisher") as ApplicationEventPublisher
|
||||
publisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||
ReflectionTestUtils.setField(target, "applicationEventPublisher", publisher)
|
||||
Mockito.`when`(s3.getUrl(Mockito.anyString(), Mockito.anyString())).thenReturn(URL("https://example.test/object"))
|
||||
Mockito.`when`(s3.putObject(Mockito.any(PutObjectRequest::class.java))).thenReturn(PutObjectResult())
|
||||
Mockito.`when`(cloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
|
||||
.thenAnswer { "https://signed.example.test/${it.arguments[0]}" }
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun restorePublisher() {
|
||||
ReflectionTestUtils.setField(
|
||||
AopTestUtils.getTargetObject<AudioContentService>(contentService),
|
||||
"applicationEventPublisher",
|
||||
originalPublisher
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("실제 EMAIL 로그인 JWT라도 BOT 외 역할은 내부 자막 경로에 접근할 수 없다")
|
||||
fun shouldRejectActualNonBotLoginJwt() {
|
||||
listOf(MemberRole.ADMIN, MemberRole.USER, MemberRole.CREATOR).forEach { role ->
|
||||
val token = login(role)
|
||||
assertEquals(403, get("/resolve?bucket=audio&key=unknown", token).statusCodeValue)
|
||||
assertEquals(403, post("/callback", token, "{}").statusCodeValue)
|
||||
assertEquals(403, get("/${UUID.randomUUID()}", token).statusCodeValue)
|
||||
assertEquals(403, post("/${UUID.randomUUID()}/started", token, "{}").statusCodeValue)
|
||||
}
|
||||
assertEquals(
|
||||
401,
|
||||
http.getForEntity("/internal/api/subtitle-jobs/resolve?bucket=a&key=b", String::class.java).statusCodeValue
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("활성 EMAIL BOT의 실제 JWT는 네 API를 허용하며 조회 응답은 envelope 없는 정확한 계약이다")
|
||||
fun shouldUseRealBotJwtAndStoreCallbackBeforeStartedWithExactByteReplay() {
|
||||
val token = login(MemberRole.BOT)
|
||||
val job = fixture()
|
||||
val lookup = get("/resolve?bucket=${job.sourceBucket}&key=${job.sourceKey}", token)
|
||||
assertEquals(200, lookup.statusCodeValue)
|
||||
val body = mapper.readTree(lookup.body)
|
||||
assertEquals(setOf("jobId", "contentId", "status", "source"), body.fieldNames().asSequence().toSet())
|
||||
assertTrue(body.path("contentId").isIntegralNumber)
|
||||
assertEquals(job.contentId, body.path("contentId").longValue())
|
||||
assertEquals(setOf("bucket", "key", "versionId"), body.path("source").fieldNames().asSequence().toSet())
|
||||
assertTrue(body.path("source").path("versionId").isNull)
|
||||
assertEquals("PENDING", body.path("status").textValue())
|
||||
assertEquals(lookup.body, get("/${job.jobId}", token).body)
|
||||
val raw = callback(job, eventId = "non-UUID/event-1")
|
||||
assertEquals(200, post("/callback", token, raw).statusCodeValue)
|
||||
assertEquals(200, post("/${job.jobId}/started", token, started(job)).statusCodeValue)
|
||||
assertEquals(200, post("/callback", token, raw).statusCodeValue)
|
||||
assertEquals(422, post("/callback", token, "$raw ").statusCodeValue)
|
||||
assertEquals(422, post("/callback", token, raw.replace(job.jobId, UUID.randomUUID().toString())).statusCodeValue)
|
||||
val resultIds = subtitles.findAllByJobId(job.jobId).map { it.id }.sortedBy { it }
|
||||
assertEquals(200, post("/callback", token, raw.replace("non-UUID/event-1", "same-result-new-event")).statusCodeValue)
|
||||
assertEquals(resultIds, subtitles.findAllByJobId(job.jobId).map { it.id }.sortedBy { it })
|
||||
assertEquals(409, post("/callback", token, callback(job, "new-result", emptyList())).statusCodeValue)
|
||||
val stored = jobs.findByIdOrNull(job.jobId)!!
|
||||
assertEquals(SubtitleJobStatus.COMPLETED, stored.status)
|
||||
assertEquals(
|
||||
"subtitles/${job.contentId}/${job.jobId}/ko.srt",
|
||||
subtitles.findByJobIdAndLanguage(job.jobId, "ko-KR")!!.s3Key
|
||||
)
|
||||
assertEquals(
|
||||
"subtitles/${job.contentId}/${job.jobId}/ja.srt",
|
||||
subtitles.findByJobIdAndLanguage(job.jobId, "ja-JP")!!.s3Key
|
||||
)
|
||||
assertEquals(0, stored.updatedAt.nano)
|
||||
val receipt = receipts.findByIdOrNull("non-UUID/event-1")!!
|
||||
assertEquals(
|
||||
MessageDigest.getInstance("SHA-256").digest(raw.toByteArray()).joinToString("") { "%02x".format(it) },
|
||||
receipt.bodyHash
|
||||
)
|
||||
assertEquals(0, receipt.createdAt.nano)
|
||||
assertEquals(
|
||||
job.jobId,
|
||||
transaction.execute { entityManager.find(AudioContent::class.java, job.contentId).latestSubtitleJobId }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("started는 PENDING만 전이하며 partial/failed는 정확한 언어 결과만 저장한다")
|
||||
fun shouldStartMonotonicallyAndAcceptPartialAndFailedResults() {
|
||||
val token = login(MemberRole.BOT)
|
||||
val partial = fixture()
|
||||
assertEquals(200, post("/${partial.jobId}/started", token, started(partial)).statusCodeValue)
|
||||
val processing = jobs.findByIdOrNull(partial.jobId)!!
|
||||
assertEquals(SubtitleJobStatus.PROCESSING, processing.status)
|
||||
assertEquals(200, post("/${partial.jobId}/started", token, started(partial)).statusCodeValue)
|
||||
assertEquals(processing.updatedAt, jobs.findByIdOrNull(partial.jobId)!!.updatedAt)
|
||||
assertEquals(200, post("/callback", token, callback(partial, languages = listOf("ja-JP"))).statusCodeValue)
|
||||
val storedPartial = jobs.findByIdOrNull(partial.jobId)!!
|
||||
assertEquals(SubtitleJobStatus.PARTIAL_COMPLETED, storedPartial.status)
|
||||
assertNull(subtitles.findByJobIdAndLanguage(partial.jobId, "ko-KR"))
|
||||
assertNotNull(subtitles.findByJobIdAndLanguage(partial.jobId, "ja-JP"))
|
||||
val failed = fixture()
|
||||
assertEquals(200, post("/callback", token, callback(failed, languages = emptyList())).statusCodeValue)
|
||||
assertEquals(SubtitleJobStatus.FAILED, jobs.findByIdOrNull(failed.jobId)!!.status)
|
||||
assertEquals(200, post("/${failed.jobId}/started", token, started(failed)).statusCodeValue)
|
||||
assertEquals(SubtitleJobStatus.FAILED, jobs.findByIdOrNull(failed.jobId)!!.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown source/job은 생성 없이 404이며 DB role 변경 및 비활성화는 기존 JWT도 차단한다")
|
||||
fun shouldReturn404WithoutDiscoveryAndCheckCurrentDatabaseRole() {
|
||||
val token = login(MemberRole.BOT)
|
||||
val count = jobs.count()
|
||||
val unknown = UUID.randomUUID().toString()
|
||||
assertEquals(404, get("/resolve?bucket=wrong&key=unknown", token).statusCodeValue)
|
||||
assertEquals(404, get("/$unknown", token).statusCodeValue)
|
||||
val unknownStarted = """{"transcribeJobName":"subtitle-$unknown","status":"PROCESSING"}"""
|
||||
assertEquals(
|
||||
404,
|
||||
post("/$unknown/started", token, unknownStarted).statusCodeValue
|
||||
)
|
||||
val job = fixture()
|
||||
assertEquals(404, post("/callback", token, callback(job).replace(job.jobId, unknown)).statusCodeValue)
|
||||
assertEquals(count + 1, jobs.count())
|
||||
val member = members.findAll().single { it.email == lastLoginEmail }
|
||||
member.role = MemberRole.ADMIN
|
||||
members.saveAndFlush(member)
|
||||
assertEquals(403, get("/${job.jobId}", token).statusCodeValue)
|
||||
member.role = MemberRole.BOT
|
||||
member.isActive = false
|
||||
members.saveAndFlush(member)
|
||||
assertEquals(403, post("/callback", token, callback(job)).statusCodeValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("callback JSON은 숫자 coercion, 누락 null, 추가/중복 필드와 잘못된 binding을 원자적으로 거부한다")
|
||||
fun shouldRejectMalformedContractsWithoutPersistingAnything() {
|
||||
val token = login(MemberRole.BOT)
|
||||
val job = fixture()
|
||||
val valid = callback(job, "strict-event")
|
||||
val invalidBodies = listOf(
|
||||
valid.replace("\"contentId\":${job.contentId}", "\"contentId\":\"${job.contentId}\""),
|
||||
valid.replace("\"contentId\":${job.contentId}", "\"contentId\":${job.contentId}.0"),
|
||||
valid.replace("\"contentId\":${job.contentId}", "\"contentId\":9223372036854775808"),
|
||||
valid.replace(",\"errorMessage\":null", ""),
|
||||
valid.dropLast(1) + ",\"unknown\":null}",
|
||||
valid.dropLast(1) + ",\"status\":\"COMPLETED\"}",
|
||||
valid.replace("\"language\":\"ko-KR\"", "\"language\":\"ko-KR\",\"language\":\"ko-KR\""),
|
||||
valid.replace("\"format\":\"SRT\"", "\"format\":\"SRT\",\"extra\":true"),
|
||||
valid.replace("subtitle-${job.jobId}", "wrong-name"),
|
||||
valid.replace("\"contentId\":${job.contentId}", "\"contentId\":${job.contentId + 1}"),
|
||||
valid.replace(job.sourceBucket, "other-bucket"),
|
||||
valid.replace("/${job.jobId}/ko.srt", "/${job.jobId}/../ko.srt"),
|
||||
valid.replace("ja-JP", "ko-KR"),
|
||||
valid.replace("\"SRT\"", "\"VTT\""),
|
||||
valid.replace("ko-KR", "en-US"),
|
||||
valid.replace("\"COMPLETED\"", "\"PARTIAL_COMPLETED\""),
|
||||
valid.replace("\"errorCode\":null", "\"errorCode\":\"unexpected\""),
|
||||
valid.replace("strict-event", ""),
|
||||
"$valid {}",
|
||||
callback(job, languages = emptyList()).replace("\"errorMessage\":\"pipeline failed\"", "\"errorMessage\":null")
|
||||
)
|
||||
invalidBodies.forEachIndexed { index, raw ->
|
||||
assertEquals(422, post("/callback", token, raw).statusCodeValue, "invalid case $index")
|
||||
}
|
||||
listOf(
|
||||
started(job).replace("PROCESSING", "COMPLETED"),
|
||||
started(job).replace("subtitle-${job.jobId}", "wrong"),
|
||||
started(job).dropLast(1) + ",\"extra\":true}",
|
||||
started(job).dropLast(1) + ",\"status\":\"PROCESSING\"}"
|
||||
).forEach { assertEquals(422, post("/${job.jobId}/started", token, it).statusCodeValue) }
|
||||
val stored = jobs.findByIdOrNull(job.jobId)!!
|
||||
assertEquals(SubtitleJobStatus.PENDING, stored.status)
|
||||
assertTrue(subtitles.findAllByJobId(job.jobId).isEmpty())
|
||||
assertNull(receipts.findByIdOrNull("strict-event"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("빈 callback과 started body는 422이며 job, receipt, 결과를 변경하지 않는다")
|
||||
fun shouldRejectEmptyCallbackAndStartedBodiesWithoutPersistingAnything() {
|
||||
val token = login(MemberRole.BOT)
|
||||
val job = fixture()
|
||||
val counts = listOf(jobs.count(), receipts.count(), subtitles.count())
|
||||
val responses = listOf("/callback", "/${job.jobId}/started").map { post(it, token, "") }
|
||||
assertEquals(listOf(422, 422), responses.map { it.statusCodeValue })
|
||||
assertEquals(counts, listOf(jobs.count(), receipts.count(), subtitles.count()))
|
||||
val stored = jobs.findByIdOrNull(job.jobId)!!
|
||||
assertEquals(SubtitleJobStatus.PENDING, stored.status)
|
||||
assertEquals(job.updatedAt, stored.updatedAt)
|
||||
assertNull(stored.errorCode)
|
||||
assertNull(stored.errorMessage)
|
||||
assertTrue(subtitles.findAllByJobId(job.jobId).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("HTTP callback의 receipt 쓰기 실패는 일반화된 500이며 상태, 오류, 결과와 receipt를 rollback한다")
|
||||
fun shouldReturn500AndRollbackWhenReceiptInsertFailsOverHttp() {
|
||||
val token = login(MemberRole.BOT)
|
||||
val job = fixture()
|
||||
Mockito.doThrow(DataIntegrityViolationException("receipt write failed"))
|
||||
.`when`(receiptWriter).saveAndFlush(
|
||||
Mockito.any(SubtitleCallbackReceipt::class.java) ?: SubtitleCallbackReceipt("unused", "unused", "unused")
|
||||
)
|
||||
val response = post("/callback", token, callback(job, "http-receipt-write-failure", listOf("ko-KR")))
|
||||
assertEquals(500, response.statusCodeValue)
|
||||
assertEquals(mapper.readTree("""{"error":"Subtitle request failed"}"""), mapper.readTree(response.body))
|
||||
val stored = jobs.findByIdOrNull(job.jobId)!!
|
||||
assertEquals(SubtitleJobStatus.PENDING, stored.status)
|
||||
assertEquals(job.updatedAt, stored.updatedAt)
|
||||
assertNull(stored.errorCode)
|
||||
assertNull(stored.errorMessage)
|
||||
assertTrue(subtitles.findAllByJobId(job.jobId).isEmpty())
|
||||
assertNull(receipts.findByIdOrNull("http-receipt-write-failure"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("동시 callback은 같은 event를 중복 반영하지 않으며 다른 job 간 unique 경합은 rollback 후 판정한다")
|
||||
fun shouldSerializeConcurrentCallbacksAndRollbackLosingJob() {
|
||||
val token = login(MemberRole.BOT)
|
||||
val first = fixture()
|
||||
val sameBody = callback(first, "concurrent-same")
|
||||
assertEquals(
|
||||
listOf(200, 200),
|
||||
race({ post("/callback", token, sameBody).statusCodeValue }, { post("/callback", token, sameBody).statusCodeValue })
|
||||
)
|
||||
val left = fixture()
|
||||
val right = fixture()
|
||||
val statuses = race(
|
||||
{ post("/callback", token, callback(left, "concurrent-cross-job")).statusCodeValue },
|
||||
{ post("/callback", token, callback(right, "concurrent-cross-job")).statusCodeValue }
|
||||
)
|
||||
assertEquals(listOf(200, 422), statuses)
|
||||
val winner = receipts.findByIdOrNull("concurrent-cross-job")!!.jobId
|
||||
val loser = if (left.jobId == winner) right else left
|
||||
assertEquals(SubtitleJobStatus.PENDING, jobs.findByIdOrNull(loser.jobId)!!.status)
|
||||
assertTrue(subtitles.findAllByJobId(loser.jobId).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("receipt 쓰기 실패 시 이미 flush된 결과 행도 상태와 함께 rollback된다")
|
||||
fun shouldRollbackResultsAndStatusWhenReceiptInsertFails() {
|
||||
val job = fixture()
|
||||
val raw = callback(job, "receipt-write-failure").toByteArray()
|
||||
Mockito.doThrow(DataIntegrityViolationException("receipt write failed"))
|
||||
.`when`(receiptWriter).saveAndFlush(
|
||||
Mockito.any(SubtitleCallbackReceipt::class.java) ?: SubtitleCallbackReceipt("unused", "unused", "unused")
|
||||
)
|
||||
assertThrows(DataIntegrityViolationException::class.java) {
|
||||
service.callback(parser.callback(raw), raw)
|
||||
}
|
||||
assertEquals(SubtitleJobStatus.PENDING, jobs.findByIdOrNull(job.jobId)!!.status)
|
||||
assertTrue(subtitles.findAllByJobId(job.jobId).isEmpty())
|
||||
assertNull(receipts.findByIdOrNull("receipt-write-failure"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("이전 job의 결과는 저장하지만 content의 최신 job 선택값을 변경하지 않는다")
|
||||
fun shouldKeepLatestPointerWhenOlderJobCompletes() {
|
||||
val token = login(MemberRole.BOT)
|
||||
val older = fixture()
|
||||
val latest = transaction.execute {
|
||||
service.register(entityManager.find(AudioContent::class.java, older.contentId), "later.mp3")
|
||||
}!!
|
||||
assertEquals(200, post("/callback", token, callback(older)).statusCodeValue)
|
||||
assertEquals(
|
||||
latest.jobId,
|
||||
transaction.execute { entityManager.find(AudioContent::class.java, older.contentId).latestSubtitleJobId }
|
||||
)
|
||||
assertEquals(SubtitleJobStatus.PENDING, jobs.findByIdOrNull(latest.jobId)!!.status)
|
||||
assertEquals(SubtitleJobStatus.COMPLETED, jobs.findByIdOrNull(older.jobId)!!.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("외부 transaction이 있어도 S3 원본 업로드 전에 content/source/PENDING이 별도 HTTP에서 보인다")
|
||||
fun shouldCommitRegistrationBeforeUploadAndNeverRevertEarlyMediaConvertOutput() {
|
||||
val token = login(MemberRole.BOT)
|
||||
val owner = members.saveAndFlush(Member(password = "unused", nickname = "upload-owner-${UUID.randomUUID()}"))
|
||||
val themeId = transaction.execute {
|
||||
AudioContentTheme(theme = "upload theme", image = "theme.png").also { entityManager.persist(it) }.id!!
|
||||
}!!
|
||||
var uploadedJob: SubtitleJob? = null
|
||||
Mockito.`when`(s3.putObject(Mockito.any(PutObjectRequest::class.java))).thenAnswer { invocation ->
|
||||
val request = invocation.arguments[0] as PutObjectRequest
|
||||
if (request.key.startsWith("input/")) {
|
||||
assertFalse(TransactionSynchronizationManager.isActualTransactionActive())
|
||||
val response = get("/resolve?bucket=${request.bucketName}&key=${request.key}", token)
|
||||
assertEquals(200, response.statusCodeValue)
|
||||
assertEquals("PENDING", mapper.readTree(response.body).path("status").textValue())
|
||||
val jobId = mapper.readTree(response.body).path("jobId").textValue()
|
||||
uploadedJob = jobs.findByIdOrNull(jobId)!!
|
||||
val sourceFileName = request.key.substringAfterLast('/')
|
||||
assertTrue(sourceFileName.endsWith(".mp3"))
|
||||
assertEquals(
|
||||
UUID.fromString(sourceFileName.substringBeforeLast('.')).toString(),
|
||||
sourceFileName.substringBeforeLast('.')
|
||||
)
|
||||
val content = transaction.execute { entityManager.find(AudioContent::class.java, uploadedJob!!.contentId) }!!
|
||||
assertEquals(request.key, content.content)
|
||||
assertNotNull(content.coverImage)
|
||||
assertEquals(jobId, content.latestSubtitleJobId)
|
||||
assertEquals(
|
||||
1,
|
||||
transaction.execute { entityManager.find(AudioContent::class.java, content.id).audioContentHashTags.size }
|
||||
)
|
||||
contentService.uploadComplete(content.id!!, "${uploadedJob!!.outputKey}.aac", "00:03:00")
|
||||
}
|
||||
PutObjectResult()
|
||||
}
|
||||
val request = CreateAudioContentRequest(
|
||||
title = "new uploaded",
|
||||
detail = "detail",
|
||||
tags = "#tag",
|
||||
price = 100,
|
||||
themeId = themeId,
|
||||
languageCode = "ko"
|
||||
)
|
||||
transaction.executeWithoutResult {
|
||||
contentService.createAudioContent(
|
||||
MockMultipartFile("contentFile", "C:\\unsafe\\nested/audio.MP3", "audio/mpeg", byteArrayOf(1)),
|
||||
MockMultipartFile("coverImage", "cover.png", "image/png", byteArrayOf(2)),
|
||||
request,
|
||||
LocalDateTime.now().minusMinutes(1),
|
||||
owner
|
||||
)
|
||||
}
|
||||
val job = uploadedJob!!
|
||||
val stored = transaction.execute { entityManager.find(AudioContent::class.java, job.contentId) }!!
|
||||
assertEquals("${job.outputKey}.aac", stored.content)
|
||||
assertEquals("00:03:00", stored.duration)
|
||||
assertTrue(stored.isActive)
|
||||
assertTrue(service.matchesOutput(job.contentId, "${job.outputKey}.aac")!!)
|
||||
assertTrue(service.matchesOutput(job.contentId, "${job.outputKey}.m4a")!!)
|
||||
assertTrue(service.matchesOutput(job.contentId, "${job.outputKey}.mp3")!!)
|
||||
listOf(
|
||||
"${job.outputKey}-other.aac",
|
||||
"${job.outputKey}.txt",
|
||||
"output/999/${job.outputKey.substringAfterLast('/')}.aac",
|
||||
"${job.outputKey}.aac/extra"
|
||||
).forEach {
|
||||
assertFalse(service.matchesOutput(job.contentId, it)!!)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("putObject 수락 후 getUrl 실패도 원본 오류만 기록하고 조기 완료 callback을 terminal에서 회귀시키지 않는다")
|
||||
fun shouldKeepAcceptedSourceAndTerminalResultWhenUploadReturnFails() {
|
||||
val token = login(MemberRole.BOT)
|
||||
val owner = members.saveAndFlush(Member(password = "unused", nickname = "uncertain-${UUID.randomUUID()}"))
|
||||
val themeId = transaction.execute {
|
||||
AudioContentTheme(theme = "uncertain theme", image = "theme.png").also { entityManager.persist(it) }.id!!
|
||||
}!!
|
||||
var uploadedJob: SubtitleJob? = null
|
||||
Mockito.`when`(s3.putObject(Mockito.any(PutObjectRequest::class.java))).thenAnswer { invocation ->
|
||||
val request = invocation.arguments[0] as PutObjectRequest
|
||||
if (request.key.startsWith("input/")) {
|
||||
uploadedJob = jobs.findBySourceBucketAndSourceKey(request.bucketName, request.key)!!
|
||||
assertEquals(200, post("/callback", token, callback(uploadedJob!!)).statusCodeValue)
|
||||
}
|
||||
PutObjectResult()
|
||||
}
|
||||
Mockito.`when`(s3.getUrl(Mockito.anyString(), Mockito.anyString())).thenAnswer { invocation ->
|
||||
if ((invocation.arguments[1] as String).startsWith("input/")) throw IllegalStateException("upload return failed")
|
||||
URL("https://example.test/cover")
|
||||
}
|
||||
assertThrows(IllegalStateException::class.java) {
|
||||
contentService.createAudioContent(
|
||||
MockMultipartFile("contentFile", byteArrayOf(1)),
|
||||
MockMultipartFile("coverImage", byteArrayOf(2)),
|
||||
CreateAudioContentRequest(title = "uncertain", detail = "detail", tags = "", price = 0, themeId = themeId),
|
||||
LocalDateTime.now(),
|
||||
owner
|
||||
)
|
||||
}
|
||||
val job = jobs.findByIdOrNull(uploadedJob!!.jobId)!!
|
||||
assertEquals(SubtitleJobStatus.COMPLETED, job.status)
|
||||
assertEquals("SOURCE_UPLOAD_UNCERTAIN", job.sourceUploadErrorCode)
|
||||
assertEquals(0, job.sourceUploadErrorAt!!.nano)
|
||||
assertEquals(2, subtitles.findAllByJobId(job.jobId).size)
|
||||
assertNotNull(transaction.execute { entityManager.find(AudioContent::class.java, job.contentId) })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("원본 filename의 안전한 확장자만 소문자로 보존하고 canonical UUID 이외 경로는 source로 전달하지 않는다")
|
||||
fun shouldPreserveOnlySafeActualSourceExtensionsWithoutClaimingConversion() {
|
||||
val filenames = listOf(
|
||||
"C:\\client\\dir/file.MP3" to "mp3",
|
||||
"../../voice.WAV" to "wav",
|
||||
"voice.FLAC" to "flac",
|
||||
"voice.aac" to "aac",
|
||||
"voice.opus" to "opus",
|
||||
"voice" to null,
|
||||
"voice.bad-extension" to null,
|
||||
null to null
|
||||
)
|
||||
filenames.forEach { (filename, extension) ->
|
||||
val job = fixture(filename)
|
||||
val sourceFile = job.sourceKey.substringAfterLast('/')
|
||||
val uuid = if (extension == null) sourceFile else sourceFile.removeSuffix(".$extension")
|
||||
assertEquals(UUID.fromString(uuid).toString(), uuid)
|
||||
if (extension != null) assertTrue(sourceFile.endsWith(".$extension"))
|
||||
assertEquals("input/${job.contentId}/$sourceFile", job.sourceKey)
|
||||
assertEquals("output/${job.contentId}/$uuid", job.outputKey)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("콘텐츠 상세는 preview에 자막을 숨기고 소유자/구매자/무료 원본 권한과 공개 시점 및 ko/ja를 적용한다")
|
||||
fun shouldExposeLanguageSubtitleOnlyWithReleasedOriginalAccessInExistingDetail() {
|
||||
val bot = login(MemberRole.BOT)
|
||||
val ownerToken = login(MemberRole.CREATOR)
|
||||
val owner = members.findByEmail(lastLoginEmail)!!
|
||||
val viewerToken = login(MemberRole.USER)
|
||||
val viewer = members.findByEmail(lastLoginEmail)!!
|
||||
val job = fixture(owner = owner)
|
||||
transaction.executeWithoutResult {
|
||||
entityManager.find(AudioContent::class.java, job.contentId).apply {
|
||||
price = 100
|
||||
content = "${job.outputKey}.aac"
|
||||
duration = "00:03:00"
|
||||
coverImage = "cover/test.png"
|
||||
isActive = true
|
||||
isCommentAvailable = false
|
||||
releaseDate = LocalDateTime.now().minusMinutes(1)
|
||||
}
|
||||
}
|
||||
assertEquals(200, post("/callback", bot, callback(job)).statusCodeValue)
|
||||
fun detail(token: String, language: String) = http.exchange(
|
||||
"/audio-content/${job.contentId}?timezone=UTC",
|
||||
HttpMethod.GET,
|
||||
HttpEntity<Void>(headers(token).apply { set(HttpHeaders.ACCEPT_LANGUAGE, language) }),
|
||||
String::class.java
|
||||
).let {
|
||||
assertEquals(200, it.statusCodeValue)
|
||||
val response = mapper.readTree(it.body)
|
||||
assertTrue(response.path("success").booleanValue(), response.path("message").asText())
|
||||
response.path("data")
|
||||
}
|
||||
assertTrue(detail(viewerToken, "ko").path("subtitle").isNull)
|
||||
val ownerKo = detail(ownerToken, "ko").path("subtitle")
|
||||
assertEquals(setOf("language", "format", "url"), ownerKo.fieldNames().asSequence().toSet())
|
||||
assertEquals("ko-KR", ownerKo.path("language").textValue())
|
||||
assertEquals("SRT", ownerKo.path("format").textValue())
|
||||
assertEquals(
|
||||
"https://signed.example.test/subtitles/${job.contentId}/${job.jobId}/ko.srt",
|
||||
ownerKo.path("url").textValue()
|
||||
)
|
||||
assertEquals("ja-JP", detail(ownerToken, "ja").path("subtitle").path("language").textValue())
|
||||
assertTrue(detail(ownerToken, "en").path("subtitle").isNull)
|
||||
transaction.executeWithoutResult {
|
||||
val content = entityManager.find(AudioContent::class.java, job.contentId)
|
||||
entityManager.persist(
|
||||
Order(type = OrderType.KEEP).apply {
|
||||
member = viewer
|
||||
creator = owner
|
||||
audioContent = content
|
||||
}
|
||||
)
|
||||
}
|
||||
assertEquals("ko-KR", detail(viewerToken, "ko").path("subtitle").path("language").textValue())
|
||||
transaction.executeWithoutResult {
|
||||
entityManager.find(AudioContent::class.java, job.contentId).releaseDate = LocalDateTime.now().plusDays(1)
|
||||
}
|
||||
assertTrue(detail(viewerToken, "ko").path("subtitle").isNull)
|
||||
assertEquals("ko-KR", detail(ownerToken, "ko").path("subtitle").path("language").textValue())
|
||||
transaction.executeWithoutResult {
|
||||
entityManager.find(AudioContent::class.java, job.contentId).apply {
|
||||
price = 0
|
||||
releaseDate = LocalDateTime.now().minusDays(1)
|
||||
}
|
||||
}
|
||||
val freeViewer = login(MemberRole.USER)
|
||||
assertEquals("ja-JP", detail(freeViewer, "ja").path("subtitle").path("language").textValue())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("자막 조회는 최신 job의 실제 언어 행만 선택하고 미완료/없는 언어/content 불일치는 null이다")
|
||||
fun shouldReadOnlyPersistedLanguageOfMatchingLatestCompletedJob() {
|
||||
val token = login(MemberRole.BOT)
|
||||
val job = fixture()
|
||||
val content = transaction.execute { entityManager.find(AudioContent::class.java, job.contentId) }!!
|
||||
assertNull(service.subtitle(content, kr.co.vividnext.sodalive.i18n.Lang.KO, 1000))
|
||||
assertEquals(200, post("/callback", token, callback(job, languages = listOf("ja-JP"))).statusCodeValue)
|
||||
assertNull(service.subtitle(content, kr.co.vividnext.sodalive.i18n.Lang.KO, 1000))
|
||||
assertEquals("ja-JP", service.subtitle(content, kr.co.vividnext.sodalive.i18n.Lang.JA, 1000)!!.language)
|
||||
val different = fixture()
|
||||
content.latestSubtitleJobId = different.jobId
|
||||
assertNull(service.subtitle(content, kr.co.vividnext.sodalive.i18n.Lang.JA, 1000))
|
||||
content.latestSubtitleJobId = null
|
||||
assertNull(service.subtitle(content, kr.co.vividnext.sodalive.i18n.Lang.JA, 1000))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("구매자의 generate-url은 ko/ja 자막과 기존 음원 URL 및 상세와 같은 자막 스키마를 반환한다")
|
||||
fun shouldIncludeRequestedSubtitleInGenerateUrl() {
|
||||
val bot = login(MemberRole.BOT)
|
||||
val owner = members.saveAndFlush(Member(password = "unused", nickname = "generate-owner-${UUID.randomUUID()}"))
|
||||
val token = login(MemberRole.USER)
|
||||
val buyer = members.findByEmail(lastLoginEmail)!!
|
||||
val job = fixture(owner = owner)
|
||||
transaction.executeWithoutResult {
|
||||
val content = entityManager.find(AudioContent::class.java, job.contentId).apply {
|
||||
price = 100
|
||||
this.content = "${job.outputKey}.aac"
|
||||
duration = "02:03:00"
|
||||
coverImage = "cover/test.png"
|
||||
isActive = true
|
||||
isCommentAvailable = false
|
||||
releaseDate = LocalDateTime.now().minusDays(1)
|
||||
}
|
||||
entityManager.persist(
|
||||
Order(type = OrderType.KEEP).apply {
|
||||
member = buyer
|
||||
creator = owner
|
||||
audioContent = content
|
||||
}
|
||||
)
|
||||
}
|
||||
assertEquals(200, post("/callback", bot, callback(job)).statusCodeValue)
|
||||
Mockito.`when`(cloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
|
||||
.thenAnswer { "https://signed.example.test/${it.arguments[0]}?expiresIn=${it.arguments[1]}" }
|
||||
listOf("ko" to "ko-KR", "ja" to "ja-JP").forEach { (language, expectedLanguage) ->
|
||||
val data = generateUrl(job.contentId, token, language)
|
||||
assertTrue(data.has("subtitle"))
|
||||
assertEquals(setOf("contentUrl", "subtitle"), data.fieldNames().asSequence().toSet())
|
||||
assertEquals(
|
||||
"https://signed.example.test/${job.outputKey}.aac?expiresIn=14400000",
|
||||
data.path("contentUrl").textValue()
|
||||
)
|
||||
val subtitle = data.path("subtitle")
|
||||
assertEquals(setOf("language", "format", "url"), subtitle.fieldNames().asSequence().toSet())
|
||||
assertEquals(expectedLanguage, subtitle.path("language").textValue())
|
||||
assertEquals("SRT", subtitle.path("format").textValue())
|
||||
assertEquals(
|
||||
"https://signed.example.test/subtitles/${job.contentId}/${job.jobId}/$language.srt?expiresIn=14400000",
|
||||
subtitle.path("url").textValue()
|
||||
)
|
||||
val detail = http.exchange(
|
||||
"/audio-content/${job.contentId}?timezone=UTC",
|
||||
HttpMethod.GET,
|
||||
HttpEntity<Void>(headers(token).apply { set(HttpHeaders.ACCEPT_LANGUAGE, language) }),
|
||||
String::class.java
|
||||
)
|
||||
assertEquals(200, detail.statusCodeValue)
|
||||
val detailBody = mapper.readTree(detail.body)
|
||||
assertTrue(detailBody.path("success").booleanValue())
|
||||
assertEquals(detailBody.path("data").path("subtitle"), subtitle)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("generate-url은 pending/미등록/없는 언어/미지원 언어의 자막을 명시적 null로 반환하고 음원 URL은 유지한다")
|
||||
fun shouldReturnNullSubtitleInGenerateUrlWhenUnavailable() {
|
||||
val bot = login(MemberRole.BOT)
|
||||
val owner = members.saveAndFlush(Member(password = "unused", nickname = "unavailable-owner-${UUID.randomUUID()}"))
|
||||
val token = login(MemberRole.USER)
|
||||
val buyer = members.findByEmail(lastLoginEmail)!!
|
||||
val job = fixture(owner = owner)
|
||||
transaction.executeWithoutResult {
|
||||
val content = entityManager.find(AudioContent::class.java, job.contentId).apply {
|
||||
this.content = "${job.outputKey}.aac"
|
||||
duration = "00:03:00"
|
||||
releaseDate = LocalDateTime.now().minusDays(1)
|
||||
}
|
||||
entityManager.persist(
|
||||
Order(type = OrderType.KEEP).apply {
|
||||
member = buyer
|
||||
creator = owner
|
||||
audioContent = content
|
||||
}
|
||||
)
|
||||
}
|
||||
val pending = generateUrl(job.contentId, token, "ko")
|
||||
assertTrue(pending.has("subtitle"))
|
||||
assertTrue(pending.path("subtitle").isNull)
|
||||
assertEquals("https://signed.example.test/${job.outputKey}.aac", pending.path("contentUrl").textValue())
|
||||
transaction.executeWithoutResult {
|
||||
entityManager.find(AudioContent::class.java, job.contentId).latestSubtitleJobId = null
|
||||
}
|
||||
val missing = generateUrl(job.contentId, token, "ko")
|
||||
assertTrue(missing.has("subtitle"))
|
||||
assertTrue(missing.path("subtitle").isNull)
|
||||
assertEquals(pending.path("contentUrl"), missing.path("contentUrl"))
|
||||
transaction.executeWithoutResult {
|
||||
entityManager.find(AudioContent::class.java, job.contentId).latestSubtitleJobId = job.jobId
|
||||
}
|
||||
assertEquals(200, post("/callback", bot, callback(job, languages = listOf("ja-JP"))).statusCodeValue)
|
||||
listOf("ko", "en").forEach { language ->
|
||||
val unavailable = generateUrl(job.contentId, token, language)
|
||||
assertTrue(unavailable.has("subtitle"))
|
||||
assertTrue(unavailable.path("subtitle").isNull)
|
||||
assertEquals(pending.path("contentUrl"), unavailable.path("contentUrl"))
|
||||
}
|
||||
val available = generateUrl(job.contentId, token, "ja")
|
||||
assertEquals("ja-JP", available.path("subtitle").path("language").textValue())
|
||||
assertEquals("SRT", available.path("subtitle").path("format").textValue())
|
||||
assertEquals(
|
||||
"https://signed.example.test/subtitles/${job.contentId}/${job.jobId}/ja.srt",
|
||||
available.path("subtitle").path("url").textValue()
|
||||
)
|
||||
assertEquals(pending.path("contentUrl"), available.path("contentUrl"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("generate-url은 소유자/무료도 활성 구매가 필요하며 예약 공개는 자막에만 상세의 공개 제한을 적용한다")
|
||||
fun shouldPreserveGenerateUrlPurchaseAndReleaseRestrictions() {
|
||||
val bot = login(MemberRole.BOT)
|
||||
val ownerToken = login(MemberRole.CREATOR)
|
||||
val owner = members.findByEmail(lastLoginEmail)!!
|
||||
val viewerToken = login(MemberRole.USER)
|
||||
val viewer = members.findByEmail(lastLoginEmail)!!
|
||||
val job = fixture(owner = owner)
|
||||
transaction.executeWithoutResult {
|
||||
entityManager.find(AudioContent::class.java, job.contentId).apply {
|
||||
price = 100
|
||||
content = "${job.outputKey}.aac"
|
||||
duration = "00:03:00"
|
||||
releaseDate = LocalDateTime.now().minusDays(1)
|
||||
}
|
||||
}
|
||||
assertEquals(200, post("/callback", bot, callback(job)).statusCodeValue)
|
||||
listOf(viewerToken, ownerToken).forEach { token ->
|
||||
val denied = generateUrl(job.contentId, token, "ko")
|
||||
assertTrue(denied.has("subtitle"))
|
||||
assertTrue(denied.path("subtitle").isNull)
|
||||
assertEquals("", denied.path("contentUrl").textValue())
|
||||
}
|
||||
transaction.executeWithoutResult { entityManager.find(AudioContent::class.java, job.contentId).price = 0 }
|
||||
listOf(viewerToken, ownerToken).forEach { token ->
|
||||
val free = generateUrl(job.contentId, token, "ko")
|
||||
assertTrue(free.has("subtitle"))
|
||||
assertTrue(free.path("subtitle").isNull)
|
||||
assertEquals("", free.path("contentUrl").textValue())
|
||||
}
|
||||
val expiredOrderId = transaction.execute {
|
||||
val content = entityManager.find(AudioContent::class.java, job.contentId)
|
||||
content.price = 100
|
||||
entityManager.persist(
|
||||
Order(type = OrderType.KEEP, isActive = false).apply {
|
||||
member = viewer
|
||||
creator = owner
|
||||
audioContent = content
|
||||
}
|
||||
)
|
||||
Order(type = OrderType.RENTAL).apply {
|
||||
member = viewer
|
||||
creator = owner
|
||||
audioContent = content
|
||||
entityManager.persist(this)
|
||||
entityManager.flush()
|
||||
endDate = LocalDateTime.now().minusDays(1)
|
||||
}.id!!
|
||||
}!!
|
||||
val expired = generateUrl(job.contentId, viewerToken, "ko")
|
||||
assertTrue(expired.has("subtitle"))
|
||||
assertTrue(expired.path("subtitle").isNull)
|
||||
assertEquals("", expired.path("contentUrl").textValue())
|
||||
transaction.executeWithoutResult {
|
||||
entityManager.find(Order::class.java, expiredOrderId).endDate = LocalDateTime.now().plusDays(1)
|
||||
val content = entityManager.find(AudioContent::class.java, job.contentId)
|
||||
content.releaseDate = LocalDateTime.now().plusDays(1)
|
||||
entityManager.persist(
|
||||
Order(type = OrderType.KEEP).apply {
|
||||
member = owner
|
||||
creator = owner
|
||||
audioContent = content
|
||||
}
|
||||
)
|
||||
}
|
||||
val scheduled = generateUrl(job.contentId, viewerToken, "ko")
|
||||
assertTrue(scheduled.has("subtitle"))
|
||||
assertTrue(scheduled.path("subtitle").isNull)
|
||||
assertEquals("https://signed.example.test/${job.outputKey}.aac", scheduled.path("contentUrl").textValue())
|
||||
val purchasedOwner = generateUrl(job.contentId, ownerToken, "ko")
|
||||
assertEquals(scheduled.path("contentUrl"), purchasedOwner.path("contentUrl"))
|
||||
assertEquals("ko-KR", purchasedOwner.path("subtitle").path("language").textValue())
|
||||
transaction.executeWithoutResult { entityManager.find(AudioContent::class.java, job.contentId).releaseDate = null }
|
||||
assertEquals("ko-KR", generateUrl(job.contentId, viewerToken, "ko").path("subtitle").path("language").textValue())
|
||||
transaction.executeWithoutResult {
|
||||
entityManager.find(AudioContent::class.java, job.contentId).releaseDate = LocalDateTime.now().minusDays(1)
|
||||
}
|
||||
val released = generateUrl(job.contentId, viewerToken, "ko")
|
||||
assertEquals(scheduled.path("contentUrl"), released.path("contentUrl"))
|
||||
assertEquals("ko-KR", released.path("subtitle").path("language").textValue())
|
||||
assertEquals(
|
||||
401,
|
||||
http.getForEntity("/audio-content/${job.contentId}/generate-url", String::class.java).statusCodeValue
|
||||
)
|
||||
}
|
||||
|
||||
private fun generateUrl(contentId: Long, token: String, language: String) = http.exchange(
|
||||
"/audio-content/$contentId/generate-url",
|
||||
HttpMethod.GET,
|
||||
HttpEntity<Void>(headers(token).apply { set(HttpHeaders.ACCEPT_LANGUAGE, language) }),
|
||||
String::class.java
|
||||
).let {
|
||||
assertEquals(200, it.statusCodeValue)
|
||||
val body = mapper.readTree(it.body)
|
||||
assertTrue(body.path("success").booleanValue())
|
||||
body.path("data")
|
||||
}
|
||||
|
||||
private var lastLoginEmail: String? = null
|
||||
|
||||
private fun login(role: MemberRole): String {
|
||||
val email = "subtitle-${UUID.randomUUID()}@example.test"
|
||||
lastLoginEmail = email
|
||||
members.saveAndFlush(
|
||||
Member(email = email, password = passwordEncoder.encode("test-password"), nickname = email, role = role)
|
||||
)
|
||||
val response = http.postForEntity(
|
||||
"/member/login",
|
||||
HttpEntity(
|
||||
"""{"email":"$email","password":"test-password","isAdmin":false,"isCreator":false}""",
|
||||
HttpHeaders().apply { contentType = MediaType.APPLICATION_JSON }
|
||||
),
|
||||
String::class.java
|
||||
)
|
||||
assertEquals(200, response.statusCodeValue)
|
||||
val body = mapper.readTree(response.body)
|
||||
assertTrue(body.path("success").booleanValue())
|
||||
assertFalse(body.path("data").path("isNew").booleanValue())
|
||||
return body.path("data").path("token").textValue().also { assertTrue(it.isNotBlank()) }
|
||||
}
|
||||
|
||||
private fun headers(token: String) = HttpHeaders().apply {
|
||||
setBearerAuth(token)
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
}
|
||||
|
||||
private fun get(path: String, token: String) = http.exchange(
|
||||
"/internal/api/subtitle-jobs$path",
|
||||
HttpMethod.GET,
|
||||
HttpEntity<Void>(headers(token)),
|
||||
String::class.java
|
||||
)
|
||||
|
||||
private fun post(path: String, token: String, raw: String) = http.exchange(
|
||||
"/internal/api/subtitle-jobs$path",
|
||||
HttpMethod.POST,
|
||||
HttpEntity(raw.toByteArray(), headers(token)),
|
||||
String::class.java
|
||||
)
|
||||
|
||||
private fun started(job: SubtitleJob) = """{"transcribeJobName":"subtitle-${job.jobId}","status":"PROCESSING"}"""
|
||||
|
||||
private fun callback(
|
||||
job: SubtitleJob,
|
||||
eventId: String = UUID.randomUUID().toString(),
|
||||
languages: List<String> = listOf(
|
||||
"ko-KR",
|
||||
"ja-JP"
|
||||
)
|
||||
): String {
|
||||
val subtitles = languages.joinToString(",") {
|
||||
val short = if (it == "ko-KR") "ko" else "ja"
|
||||
"""{"language":"$it","format":"SRT","s3Bucket":"${job.sourceBucket}",""" +
|
||||
""""s3Key":"subtitles/${job.contentId}/${job.jobId}/$short.srt"}"""
|
||||
}
|
||||
val status = when (languages.size) { 2 -> "COMPLETED"; 1 -> "PARTIAL_COMPLETED"; else -> "FAILED" }
|
||||
val error = if (languages.size == 2) "null" else "\"PIPELINE_FAILED\""
|
||||
val message = if (languages.size == 2) "null" else "\"pipeline failed\""
|
||||
return """{"eventId":"$eventId","jobId":"${job.jobId}","contentId":${job.contentId},""" +
|
||||
""""transcribeJobName":"subtitle-${job.jobId}","status":"$status","subtitles":[$subtitles],""" +
|
||||
""""errorCode":$error,"errorMessage":$message}"""
|
||||
}
|
||||
|
||||
private fun fixture(originalFilename: String? = "fixture.mp3", owner: Member? = null): SubtitleJob = transaction.execute {
|
||||
val creator = owner ?: Member(password = "unused", nickname = "fixture-${UUID.randomUUID()}").also {
|
||||
entityManager.persist(
|
||||
it
|
||||
)
|
||||
}
|
||||
val theme = AudioContentTheme(theme = "subtitle theme", image = "theme.png")
|
||||
entityManager.persist(theme)
|
||||
val content = AudioContent(title = "subtitle content", detail = "detail", languageCode = "ko").apply {
|
||||
member = creator
|
||||
this.theme = theme
|
||||
}
|
||||
entityManager.persist(content)
|
||||
service.register(content, originalFilename)
|
||||
}!!
|
||||
|
||||
private fun race(first: () -> Int, second: () -> Int): List<Int> {
|
||||
val pool = Executors.newFixedThreadPool(2)
|
||||
val gate = CountDownLatch(1)
|
||||
return try {
|
||||
val futures = listOf(first, second).map { action -> pool.submit(Callable { gate.await(); action() }) }
|
||||
gate.countDown()
|
||||
futures.map { it.get(30, TimeUnit.SECONDS) }.sorted()
|
||||
} finally {
|
||||
pool.shutdownNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package kr.co.vividnext.sodalive.v2.subtitle
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence.Subtitle
|
||||
import org.hibernate.boot.MetadataSources
|
||||
import org.hibernate.boot.registry.StandardServiceRegistryBuilder
|
||||
import org.hibernate.cfg.AvailableSettings
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.boot.autoconfigure.orm.jpa.HibernateProperties
|
||||
import org.springframework.boot.autoconfigure.orm.jpa.HibernateSettings
|
||||
import java.sql.DriverManager
|
||||
import java.util.UUID
|
||||
|
||||
class SubtitleSchemaValidationTest {
|
||||
@Test
|
||||
@DisplayName("기존 DDL의 s3_bucket과 s3_key를 실제 Hibernate validate 및 저장 조회에 사용한다")
|
||||
fun shouldValidateAndUseExistingS3Columns() {
|
||||
val url = "jdbc:h2:mem:subtitle-schema-${UUID.randomUUID()};MODE=MySQL"
|
||||
DriverManager.getConnection(url, "sa", "").use { connection ->
|
||||
connection.createStatement().use {
|
||||
it.execute(
|
||||
"""
|
||||
CREATE TABLE subtitle (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
job_id VARCHAR(36) NOT NULL,
|
||||
language VARCHAR(35) NOT NULL,
|
||||
format VARCHAR(16) NOT NULL,
|
||||
s3_bucket VARCHAR(255) NOT NULL,
|
||||
s3_key VARCHAR(255) NOT NULL,
|
||||
UNIQUE (job_id, language)
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
val bootProperties = HibernateProperties().determineHibernateProperties(emptyMap(), HibernateSettings())
|
||||
val registry = StandardServiceRegistryBuilder()
|
||||
.applySettings(bootProperties)
|
||||
.applySetting(AvailableSettings.DRIVER, "org.h2.Driver")
|
||||
.applySetting(AvailableSettings.URL, url)
|
||||
.applySetting(AvailableSettings.USER, "sa")
|
||||
.applySetting(AvailableSettings.PASS, "")
|
||||
.applySetting(AvailableSettings.DIALECT, "org.hibernate.dialect.H2Dialect")
|
||||
.applySetting(AvailableSettings.HBM2DDL_AUTO, "validate")
|
||||
.build()
|
||||
try {
|
||||
MetadataSources(registry).addAnnotatedClass(Subtitle::class.java)
|
||||
.buildMetadata().buildSessionFactory().use { factory ->
|
||||
val id = factory.openSession().use { session ->
|
||||
val transaction = session.beginTransaction()
|
||||
val subtitle = Subtitle(
|
||||
jobId = "550e8400-e29b-41d4-a716-446655440000",
|
||||
language = "ko-KR",
|
||||
format = "SRT",
|
||||
s3Bucket = "subtitle-media",
|
||||
s3Key = "subtitles/100/550e8400-e29b-41d4-a716-446655440000/ko.srt"
|
||||
)
|
||||
session.persist(subtitle)
|
||||
transaction.commit()
|
||||
requireNotNull(subtitle.id)
|
||||
}
|
||||
factory.openSession().use { session ->
|
||||
val stored = session.find(Subtitle::class.java, id)
|
||||
assertEquals("subtitle-media", stored.s3Bucket)
|
||||
assertEquals("subtitles/100/550e8400-e29b-41d4-a716-446655440000/ko.srt", stored.s3Key)
|
||||
}
|
||||
connection.prepareStatement("SELECT s3_bucket, s3_key FROM subtitle WHERE id = ?").use {
|
||||
it.setLong(1, id)
|
||||
it.executeQuery().use { result ->
|
||||
assertTrue(result.next())
|
||||
assertEquals("subtitle-media", result.getString("s3_bucket"))
|
||||
assertEquals(
|
||||
"subtitles/100/550e8400-e29b-41d4-a716-446655440000/ko.srt",
|
||||
result.getString("s3_key")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
StandardServiceRegistryBuilder.destroy(registry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user