From e097e27ac6e7ea76a4d6d20d45fdab17558ad258 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 11 Nov 2026 08:33:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(subtitle):=20=EC=9E=90=EB=A7=89=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EC=A0=80=EC=9E=A5=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EC=99=80=20=EA=B3=84=EC=95=BD=EC=9D=84=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../subtitle-schema.sql | 53 +++++++++++++++++++ .../sodalive/content/AudioContent.kt | 3 ++ .../subtitle/adapter/persistence/Subtitle.kt | 28 ++++++++++ .../persistence/SubtitleCallbackReceipt.kt | 31 +++++++++++ .../SubtitleCallbackReceiptRepository.kt | 5 ++ .../adapter/persistence/SubtitleJob.kt | 47 ++++++++++++++++ .../persistence/SubtitleJobRepository.kt | 16 ++++++ .../adapter/persistence/SubtitleRepository.kt | 8 +++ .../v2/subtitle/domain/SubtitleJobStatus.kt | 8 +++ .../v2/subtitle/dto/SubtitleJobDtos.kt | 29 ++++++++++ 10 files changed, 228 insertions(+) create mode 100644 docs/20261110_자막Phase1연동/subtitle-schema.sql create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/Subtitle.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceipt.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceiptRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJob.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJobRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/domain/SubtitleJobStatus.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/dto/SubtitleJobDtos.kt diff --git a/docs/20261110_자막Phase1연동/subtitle-schema.sql b/docs/20261110_자막Phase1연동/subtitle-schema.sql new file mode 100644 index 00000000..0d521341 --- /dev/null +++ b/docs/20261110_자막Phase1연동/subtitle-schema.sql @@ -0,0 +1,53 @@ +-- 자막 Phase 1 미출시 초안. 운영 DB 적용/호환성은 확인하지 않았다. +-- 실행 전 deployment-gates.md의 엔티티 대조 및 운영 DB 게이트 승인이 필요하다. +-- MySQL 8.0.17 이상, utf8mb4_0900_bin(NO PAD), InnoDB 3072-byte 인덱스 지원이 전제다. +-- 대소문자와 후행 공백을 보존하여 eventId 및 S3 bucket/key를 정확히 구별한다. +-- source_upload_error_at은 최종 JPA의 TIMESTAMP 선언과 초 단위 정밀도를 맞췄다. + +CREATE TABLE subtitle_job ( + job_id VARCHAR(36) NOT NULL COMMENT 'Spring 생성 자막 작업 UUID', + content_id BIGINT NOT NULL COMMENT '기존 콘텐츠 Long 식별자', + source_bucket VARCHAR(255) NOT NULL COMMENT '최초 등록 원본 S3 버킷', + source_key VARCHAR(255) NOT NULL COMMENT '최초 등록 원본 S3 객체 키', + output_key VARCHAR(255) NOT NULL COMMENT 'MediaConvert 출력 객체 키의 확장자 제외 경로', + status VARCHAR(32) NOT NULL COMMENT '자막 작업 상태', + error_code VARCHAR(255) NULL COMMENT '자막 처리 오류 코드', + error_message TEXT NULL COMMENT '자막 처리 오류 설명', + source_upload_error_code VARCHAR(255) NULL COMMENT '원본 업로드 불확실성 오류 코드', + source_upload_error_at TIMESTAMP NULL DEFAULT NULL COMMENT '원본 업로드 오류 기록 시각', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각', + PRIMARY KEY (job_id), + UNIQUE KEY uk_subtitle_job_source (source_bucket, source_key), + KEY ix_subtitle_job_content_created (content_id, created_at) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin + COMMENT='원본 binding과 자막 작업 상태 및 오류'; + +CREATE TABLE subtitle ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '언어별 자막 결과 Long 식별자', + job_id VARCHAR(36) NOT NULL COMMENT '소속 자막 작업 UUID', + language VARCHAR(35) NOT NULL COMMENT '자막 언어 태그', + format VARCHAR(16) NOT NULL COMMENT '자막 파일 형식', + s3_bucket VARCHAR(255) NOT NULL COMMENT '자막 결과 S3 버킷', + s3_key VARCHAR(255) NOT NULL COMMENT '자막 결과 S3 객체 키', + PRIMARY KEY (id), + UNIQUE KEY uk_subtitle_job_language (job_id, language), + CONSTRAINT fk_subtitle_job FOREIGN KEY (job_id) REFERENCES subtitle_job (job_id) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin + COMMENT='작업별 언어별 자막 결과'; + +CREATE TABLE subtitle_callback_receipt ( + event_id VARCHAR(255) NOT NULL COMMENT 'callback 중복 방지 이벤트 식별자', + job_id VARCHAR(36) NOT NULL COMMENT 'callback 대상 자막 작업 UUID', + body_hash VARCHAR(64) NOT NULL COMMENT '원래 HTTP body bytes의 SHA-256 소문자 16진수', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각', + PRIMARY KEY (event_id) +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin + COMMENT='callback 이벤트와 원본 요청 본문 해시 수신 기록'; + +ALTER TABLE content + ADD COLUMN latest_subtitle_job_id VARCHAR(36) + CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NULL DEFAULT NULL COMMENT '최신 선택 자막 작업 UUID'; diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt index eaa37f68..78f1d5e8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt @@ -52,6 +52,9 @@ data class AudioContent( var content: String? = null var coverImage: String? = null + @Column(length = 36) + var latestSubtitleJobId: String? = null + @OneToOne(fetch = FetchType.EAGER) @JoinColumn(name = "theme_id", nullable = false) var theme: AudioContentTheme? = null diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/Subtitle.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/Subtitle.kt new file mode 100644 index 00000000..e9fdac98 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/Subtitle.kt @@ -0,0 +1,28 @@ +package kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence + +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.GeneratedValue +import javax.persistence.GenerationType +import javax.persistence.Id +import javax.persistence.Table +import javax.persistence.UniqueConstraint + +@Entity +@Table(name = "subtitle", uniqueConstraints = [UniqueConstraint(columnNames = ["job_id", "language"])]) +class Subtitle( + @Column(name = "job_id", nullable = false, length = 36) + val jobId: String, + @Column(nullable = false, length = 35) + val language: String, + @Column(nullable = false, length = 16) + val format: String, + @Column(nullable = false, length = 255) + val s3Bucket: String, + @Column(nullable = false, length = 255) + val s3Key: String +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceipt.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceipt.kt new file mode 100644 index 00000000..3247cc36 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceipt.kt @@ -0,0 +1,31 @@ +package kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence + +import org.springframework.data.domain.Persistable +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.temporal.ChronoUnit +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.Id +import javax.persistence.Table +import javax.persistence.Transient + +@Entity +@Table(name = "subtitle_callback_receipt") +class SubtitleCallbackReceipt( + @Id + @Column(length = 255) + val eventId: String, + @Column(nullable = false, length = 36) + val jobId: String, + @Column(nullable = false, length = 64) + val bodyHash: String +) : Persistable { + @Column(nullable = false, columnDefinition = "TIMESTAMP") + val createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS) + + override fun getId(): String = eventId + + @Transient + override fun isNew(): Boolean = true +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceiptRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceiptRepository.kt new file mode 100644 index 00000000..71fdb656 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleCallbackReceiptRepository.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface SubtitleCallbackReceiptRepository : JpaRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJob.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJob.kt new file mode 100644 index 00000000..9ce093b2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJob.kt @@ -0,0 +1,47 @@ +package kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence + +import kr.co.vividnext.sodalive.v2.subtitle.domain.SubtitleJobStatus +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.temporal.ChronoUnit +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.Id +import javax.persistence.Table +import javax.persistence.UniqueConstraint + +@Entity +@Table(name = "subtitle_job", uniqueConstraints = [UniqueConstraint(columnNames = ["source_bucket", "source_key"])]) +class SubtitleJob( + @Id + @Column(length = 36) + val jobId: String, + @Column(nullable = false) + val contentId: Long, + @Column(name = "source_bucket", nullable = false) + val sourceBucket: String, + @Column(name = "source_key", nullable = false) + val sourceKey: String, + @Column(nullable = false) + val outputKey: String, + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + var status: SubtitleJobStatus = SubtitleJobStatus.PENDING +) { + var errorCode: String? = null + + @Column(columnDefinition = "TEXT") + var errorMessage: String? = null + var sourceUploadErrorCode: String? = null + + @Column(columnDefinition = "TIMESTAMP") + var sourceUploadErrorAt: LocalDateTime? = null + + @Column(nullable = false, columnDefinition = "TIMESTAMP") + val createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS) + + @Column(nullable = false, columnDefinition = "TIMESTAMP") + var updatedAt: LocalDateTime = createdAt +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJobRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJobRepository.kt new file mode 100644 index 00000000..42ca1aeb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleJobRepository.kt @@ -0,0 +1,16 @@ +package kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import javax.persistence.LockModeType + +interface SubtitleJobRepository : JpaRepository { + fun findBySourceBucketAndSourceKey(sourceBucket: String, sourceKey: String): SubtitleJob? + fun findFirstByContentIdOrderByCreatedAtAsc(contentId: Long): SubtitleJob? + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select j from SubtitleJob j where j.jobId = :jobId") + fun findForUpdate(@Param("jobId") jobId: String): SubtitleJob? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleRepository.kt new file mode 100644 index 00000000..41c3ab40 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/adapter/persistence/SubtitleRepository.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.v2.subtitle.adapter.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface SubtitleRepository : JpaRepository { + fun findAllByJobId(jobId: String): List + fun findByJobIdAndLanguage(jobId: String, language: String): Subtitle? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/domain/SubtitleJobStatus.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/domain/SubtitleJobStatus.kt new file mode 100644 index 00000000..232d1a40 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/domain/SubtitleJobStatus.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.v2.subtitle.domain + +enum class SubtitleJobStatus { + PENDING, PROCESSING, COMPLETED, PARTIAL_COMPLETED, FAILED; + + val terminal: Boolean + get() = this == COMPLETED || this == PARTIAL_COMPLETED || this == FAILED +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/dto/SubtitleJobDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/dto/SubtitleJobDtos.kt new file mode 100644 index 00000000..bfa4b8c1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subtitle/dto/SubtitleJobDtos.kt @@ -0,0 +1,29 @@ +package kr.co.vividnext.sodalive.v2.subtitle.dto + +import kr.co.vividnext.sodalive.v2.subtitle.domain.SubtitleJobStatus + +data class SubtitleSourceResponse(val bucket: String, val key: String, val versionId: String? = null) + +data class SubtitleJobResponse( + val jobId: String, + val contentId: Long, + val status: SubtitleJobStatus, + val source: SubtitleSourceResponse +) + +data class SubtitleStartedRequest(val transcribeJobName: String, val status: String) + +data class SubtitleResultRequest(val language: String, val format: String, val s3Bucket: String, val s3Key: String) + +data class SubtitleCallbackRequest( + val eventId: String, + val jobId: String, + val contentId: Long, + val transcribeJobName: String, + val status: String, + val subtitles: List, + val errorCode: String?, + val errorMessage: String? +) + +data class ContentSubtitleResponse(val language: String, val format: String, val url: String)