feat(subtitle): 자막 작업 저장 구조와 계약을 추가한다
This commit is contained in:
@@ -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';
|
||||||
@@ -52,6 +52,9 @@ data class AudioContent(
|
|||||||
var content: String? = null
|
var content: String? = null
|
||||||
var coverImage: String? = null
|
var coverImage: String? = null
|
||||||
|
|
||||||
|
@Column(length = 36)
|
||||||
|
var latestSubtitleJobId: String? = null
|
||||||
|
|
||||||
@OneToOne(fetch = FetchType.EAGER)
|
@OneToOne(fetch = FetchType.EAGER)
|
||||||
@JoinColumn(name = "theme_id", nullable = false)
|
@JoinColumn(name = "theme_id", nullable = false)
|
||||||
var theme: AudioContentTheme? = null
|
var theme: AudioContentTheme? = null
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+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?
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user