From ca9c6464fcd013ac8384594408a34591b3d9a13c Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Mon, 3 Aug 2026 11:54:46 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20=EC=88=8F=ED=8F=BC=20=ED=81=90=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D=20(7=EA=B0=9C=20?= =?UTF-8?q?=ED=83=9C=EC=8A=A4=ED=81=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-03-shortform-queue.md | 1045 +++++++++++++++++ 1 file changed, 1045 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-shortform-queue.md diff --git a/docs/superpowers/plans/2026-08-03-shortform-queue.md b/docs/superpowers/plans/2026-08-03-shortform-queue.md new file mode 100644 index 0000000..c1b1cb2 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-shortform-queue.md @@ -0,0 +1,1045 @@ +# 숏폼 큐(Opal 연동) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 유튜브 URL을 큐에 등록하고, Opal "숏폼 생성기" 출력 텍스트를 파싱해 구간별 capcut2 JSON으로 저장·조회하는 기능 + 클로드 브라우저 대행 스킬. + +**Architecture:** `domain/shortform/` 패키지(DDD 스타일)에 Job/Clip 엔티티와 파서·서비스·REST API를 두고, Thymeleaf `/shortform` 페이지가 이를 소비한다. 실행 엔진(Opal)은 외부에 있고 h-lab은 "원문 텍스트 → 구조화 저장"만 담당한다. `.claude/skills/shortform-queue`가 브라우저 대행 절차를 명문화한다. + +**Tech Stack:** Spring Boot 3.4 / Java 21 / JPA(ddl-auto: update, PostgreSQL) / Lombok / Jackson / Thymeleaf(layout-dialect) / JUnit5 + +## Global Constraints + +- 서버 포트 8088. 빌드·테스트는 `.\gradlew.bat`(Windows), `JAVA_HOME=D:\Development\app\JDK\jdk-21.0.5` +- JSON API는 `ApiResponse`로 감싼다 (`ApiResponse.ok/created/error`) +- 커밋 메시지: 타입 접두사(feat/fix/test/docs)는 영문, 제목·본문은 한글 +- `src/test`가 현재 없음 — 테스트는 `src/test/java/com/hlab/yanalyst/` 아래 신규 작성. DB 필요 테스트 금지(H2 없음) — 순수 단위 테스트만 +- 페이지 컨트롤러는 `currentPage` 모델 속성 필수 (사이드바 하이라이트) +- 텍스트 대용량 컬럼은 `@Column(columnDefinition = "TEXT")` + +--- + +### Task 1: Opal 출력 파서 (TDD) + +**Files:** +- Create: `src/main/java/com/hlab/yanalyst/domain/shortform/ParsedClip.java` +- Create: `src/main/java/com/hlab/yanalyst/domain/shortform/ShortformOutputParser.java` +- Test: `src/test/java/com/hlab/yanalyst/domain/shortform/ShortformOutputParserTest.java` +- 픽스처(이미 커밋됨): `src/test/resources/shortform/opal-output-sample.txt` + +**Interfaces:** +- Produces: `record ParsedClip(int clipNo, String capcutJson, String titleCandidates, String durationTable, String titleTop, String titleMain)` +- Produces: `ShortformOutputParser.parse(String raw) : List` — 빈/null 입력이면 빈 리스트. 섹션 구분자는 `N=====`(= 5개 이상). ```json 펜스가 없어도 중괄호 스캔으로 JSON을 찾는다. JSON 파싱 실패 시 titleTop/titleMain만 null이고 원문 조각은 보존. + +- [ ] **Step 1: 실패하는 테스트 작성** + +```java +package com.hlab.yanalyst.domain.shortform; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class ShortformOutputParserTest { + + private final ShortformOutputParser parser = new ShortformOutputParser(); + + private String sample() throws Exception { + return Files.readString(Path.of("src/test/resources/shortform/opal-output-sample.txt"), + StandardCharsets.UTF_8); + } + + @Test + void 실전_샘플에서_5개_섹션을_모두_파싱한다() throws Exception { + List clips = parser.parse(sample()); + + assertEquals(5, clips.size()); + for (ParsedClip c : clips) { + assertNotNull(c.capcutJson(), "clip " + c.clipNo() + " json"); + assertTrue(c.capcutJson().contains("\"cuts\"")); + assertNotNull(c.titleCandidates(), "clip " + c.clipNo() + " titles"); + assertNotNull(c.durationTable(), "clip " + c.clipNo() + " table"); + } + assertEquals(1, clips.get(0).clipNo()); + assertEquals(5, clips.get(4).clipNo()); + } + + @Test + void json펜스가_있는_섹션과_없는_섹션_모두_타이틀을_추출한다() throws Exception { + List clips = parser.parse(sample()); + + // 섹션 1: ```json 펜스 있음 + assertEquals("프나 신보 감상평", clips.get(0).titleTop()); + assertEquals("극락 가는 앨범입니다", clips.get(0).titleMain()); + // 섹션 3: 펜스 없이 생 JSON + assertEquals("프나 유치찬란 상황극", clips.get(2).titleTop()); + // 섹션 5: 펜스 없음 + url에 한글 플레이스홀더 + assertEquals("맑은 눈의 광기", clips.get(4).titleTop()); + } + + @Test + void 검산표는_총길이_줄까지_포함한다() throws Exception { + List clips = parser.parse(sample()); + assertTrue(clips.get(0).durationTable().contains("총 13컷 / 총 길이: 46.5초")); + } + + @Test + void 빈_입력은_빈_리스트를_반환한다() { + assertTrue(parser.parse(null).isEmpty()); + assertTrue(parser.parse(" ").isEmpty()); + assertTrue(parser.parse("구분자가 없는 텍스트").isEmpty()); + } +} +``` + +- [ ] **Step 2: 테스트 실패 확인** + +Run: `.\gradlew.bat test --tests "com.hlab.yanalyst.domain.shortform.ShortformOutputParserTest"` +Expected: 컴파일 실패 (ParsedClip/ShortformOutputParser 없음) + +- [ ] **Step 3: 구현** + +`ParsedClip.java`: + +```java +package com.hlab.yanalyst.domain.shortform; + +/** Opal 출력에서 파싱한 구간 하나. capcutJson 등 원문 조각은 파싱 실패해도 보존한다. */ +public record ParsedClip( + int clipNo, + String capcutJson, + String titleCandidates, + String durationTable, + String titleTop, + String titleMain +) {} +``` + +`ShortformOutputParser.java`: + +```java +package com.hlab.yanalyst.domain.shortform; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Opal "숏폼 생성기" Output 원문을 구간(ID N)별로 파싱한다. + * + *

실전 출력 변형에 대응한다: 섹션 구분자 {@code N=====}(= 5개 이상), ```json 펜스가 + * 있기도 없기도 함, 타이틀 후보 목록의 번호 유무 혼재. 어떤 조각이 깨져도 원문은 + * ShortformJob.rawOutput에 보존되므로 여기서는 최선 추출만 한다. + */ +@Component +public class ShortformOutputParser { + + private static final Pattern SECTION = Pattern.compile("(?m)^\\s*(\\d+)={5,}"); + private static final ObjectMapper OM = new ObjectMapper(); + + public List parse(String raw) { + List result = new ArrayList<>(); + if (raw == null || raw.isBlank()) return result; + + Matcher m = SECTION.matcher(raw); + List starts = new ArrayList<>(); + List ends = new ArrayList<>(); + List nos = new ArrayList<>(); + while (m.find()) { + starts.add(m.end()); + ends.add(m.start()); + nos.add(Integer.parseInt(m.group(1))); + } + for (int i = 0; i < starts.size(); i++) { + int to = (i + 1 < starts.size()) ? ends.get(i + 1) : raw.length(); + ParsedClip clip = parseSection(nos.get(i), raw.substring(starts.get(i), to)); + if (clip != null) result.add(clip); + } + return result; + } + + private ParsedClip parseSection(int no, String body) { + String json = extractJson(body); + String titles = extractBetween(body, "타이틀 후보", "검산표"); + String table = extractFromLine(body, "검산표"); + if (json == null && titles == null && table == null) return null; + + String top = null; + String main = null; + if (json != null) { + try { + JsonNode node = OM.readTree(json); + top = node.path("title_top").asText(null); + main = node.path("title_main").asText(null); + } catch (Exception ignored) { + // JSON이 깨져도 원문 조각은 보존한다 + } + } + return new ParsedClip(no, json, titles, table, top, main); + } + + /** ```json 펜스가 있으면 그 내부, 없으면 첫 '{'부터 중괄호 균형이 맞는 지점까지. */ + private String extractJson(String body) { + int fence = body.indexOf("```json"); + String scope = body; + if (fence >= 0) { + int from = fence + "```json".length(); + int close = body.indexOf("```", from); + scope = close > from ? body.substring(from, close) : body.substring(from); + } + int open = scope.indexOf('{'); + if (open < 0) return null; + int depth = 0; + boolean inString = false; + for (int i = open; i < scope.length(); i++) { + char c = scope.charAt(i); + if (inString) { + if (c == '\\') i++; + else if (c == '"') inString = false; + } else if (c == '"') { + inString = true; + } else if (c == '{') { + depth++; + } else if (c == '}') { + depth--; + if (depth == 0) return scope.substring(open, i + 1).trim(); + } + } + return null; + } + + /** startKey가 있는 줄부터 endKey가 있는 줄 직전까지. 없으면 null. */ + private String extractBetween(String body, String startKey, String endKey) { + int s = body.indexOf(startKey); + if (s < 0) return null; + int lineStart = body.lastIndexOf('\n', s) + 1; + int e = body.indexOf(endKey, s); + int end = e < 0 ? body.length() : body.lastIndexOf('\n', e) + 1; + String block = body.substring(lineStart, end).trim(); + return block.isEmpty() ? null : block; + } + + private String extractFromLine(String body, String key) { + int s = body.indexOf(key); + if (s < 0) return null; + int lineStart = body.lastIndexOf('\n', s) + 1; + String block = body.substring(lineStart).trim(); + return block.isEmpty() ? null : block; + } +} +``` + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `.\gradlew.bat test --tests "com.hlab.yanalyst.domain.shortform.ShortformOutputParserTest"` +Expected: 4개 모두 PASS. (주의: 픽스처의 검산표 끝에 후행 공백 줄이 있어도 trim으로 흡수됨. 섹션 1의 구분자 뒤 같은 줄에 ```json이 붙어 있는 케이스가 extractJson의 fence 경로로 커버되는지 확인) + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/java/com/hlab/yanalyst/domain/shortform src/test/java/com/hlab/yanalyst/domain/shortform +git commit -m "feat: Opal 숏폼 출력 파서 — 구간별 JSON·타이틀·검산표 추출" +``` + +--- + +### Task 2: 유튜브 videoId 추출 유틸 (TDD) + +**Files:** +- Create: `src/main/java/com/hlab/yanalyst/domain/shortform/YoutubeVideoId.java` +- Test: `src/test/java/com/hlab/yanalyst/domain/shortform/YoutubeVideoIdTest.java` + +**Interfaces:** +- Produces: `YoutubeVideoId.from(String url) : Optional` — watch?v= / youtu.be/ / shorts/ / embed/ / live/ 지원, 11자 ID만 유효 + +- [ ] **Step 1: 실패하는 테스트 작성** + +```java +package com.hlab.yanalyst.domain.shortform; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class YoutubeVideoIdTest { + + @Test + void 다양한_유튜브_url_형식에서_videoId를_추출한다() { + assertEquals("JW5mwZ8RsG8", YoutubeVideoId.from("https://www.youtube.com/watch?v=JW5mwZ8RsG8").orElseThrow()); + assertEquals("JW5mwZ8RsG8", YoutubeVideoId.from("https://www.youtube.com/watch?v=JW5mwZ8RsG8&t=10s").orElseThrow()); + assertEquals("JW5mwZ8RsG8", YoutubeVideoId.from("https://youtu.be/JW5mwZ8RsG8").orElseThrow()); + assertEquals("JW5mwZ8RsG8", YoutubeVideoId.from("https://www.youtube.com/shorts/JW5mwZ8RsG8").orElseThrow()); + assertEquals("JW5mwZ8RsG8", YoutubeVideoId.from("https://www.youtube.com/embed/JW5mwZ8RsG8").orElseThrow()); + assertEquals("JW5mwZ8RsG8", YoutubeVideoId.from("https://www.youtube.com/live/JW5mwZ8RsG8?feature=share").orElseThrow()); + } + + @Test + void 유튜브가_아니거나_id가_없으면_empty() { + assertTrue(YoutubeVideoId.from(null).isEmpty()); + assertTrue(YoutubeVideoId.from("").isEmpty()); + assertTrue(YoutubeVideoId.from("https://example.com/watch?v=JW5mwZ8RsG8").isEmpty()); + assertTrue(YoutubeVideoId.from("https://www.youtube.com/watch").isEmpty()); + assertTrue(YoutubeVideoId.from("https://www.youtube.com/watch?v=short").isEmpty()); + } +} +``` + +- [ ] **Step 2: 테스트 실패 확인** + +Run: `.\gradlew.bat test --tests "com.hlab.yanalyst.domain.shortform.YoutubeVideoIdTest"` +Expected: 컴파일 실패 + +- [ ] **Step 3: 구현** + +```java +package com.hlab.yanalyst.domain.shortform; + +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** 유튜브 URL에서 11자 videoId를 뽑는다. youtube.com / youtu.be 도메인만 인정. */ +public final class YoutubeVideoId { + + private static final Pattern PATTERN = Pattern.compile( + "^https?://(?:www\\.|m\\.)?(?:youtube\\.com/(?:watch\\?[^#]*\\bv=|shorts/|embed/|live/)|youtu\\.be/)" + + "([A-Za-z0-9_-]{11})(?:[?&#/].*)?$"); + + private YoutubeVideoId() {} + + public static Optional from(String url) { + if (url == null || url.isBlank()) return Optional.empty(); + Matcher m = PATTERN.matcher(url.trim()); + return m.matches() ? Optional.of(m.group(1)) : Optional.empty(); + } +} +``` + +- [ ] **Step 4: 테스트 통과 확인** + +Run: `.\gradlew.bat test --tests "com.hlab.yanalyst.domain.shortform.YoutubeVideoIdTest"` +Expected: PASS + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/java/com/hlab/yanalyst/domain/shortform/YoutubeVideoId.java src/test/java/com/hlab/yanalyst/domain/shortform/YoutubeVideoIdTest.java +git commit -m "feat: 유튜브 URL videoId 추출 유틸" +``` + +--- + +### Task 3: 엔티티 + 리포지토리 + +**Files:** +- Create: `src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJobStatus.java` +- Create: `src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java` +- Create: `src/main/java/com/hlab/yanalyst/domain/shortform/ShortformClip.java` +- Create: `src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJobRepository.java` + +**Interfaces:** +- Consumes: `ParsedClip` (Task 1) +- Produces: `ShortformJob.create(String youtubeUrl, String videoId)`, `job.applyResult(String raw, List)`, `job.markFailed(String raw)`, `ShortformJobRepository.findByVideoId(String)`, `findAllByOrderByCreatedAtDesc()`, `findByStatusOrderByCreatedAtAsc(ShortformJobStatus)` + +- [ ] **Step 1: 구현** (DB 필요 테스트는 불가 — 컴파일·빌드로 검증) + +`ShortformJobStatus.java`: + +```java +package com.hlab.yanalyst.domain.shortform; + +public enum ShortformJobStatus { PENDING, DONE, FAILED } +``` + +`ShortformJob.java`: + +```java +package com.hlab.yanalyst.domain.shortform; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 숏폼 큐 작업 하나 = 유튜브 영상 하나. Opal "숏폼 생성기" 실행 결과를 담는다. + * + *

rawOutput은 파싱 성공 여부와 무관하게 항상 원문을 보존한다 — 파서가 놓친 정보는 + * 여기서 복구할 수 있다. + */ +@Entity +@Table(name = "shortform_job", + indexes = @Index(name = "idx_sfj_video_id", columnList = "videoId", unique = true)) +@Getter +@NoArgsConstructor +public class ShortformJob { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 500) + private String youtubeUrl; + + @Column(nullable = false, length = 20) + private String videoId; + + /** 결과의 첫 클립 title_main. 목록 표시에 쓴다. */ + @Column(length = 300) + private String title; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ShortformJobStatus status = ShortformJobStatus.PENDING; + + @Column(columnDefinition = "TEXT") + private String rawOutput; + + @CreationTimestamp + @Column(updatable = false) + private LocalDateTime createdAt; + + private LocalDateTime completedAt; + + @OneToMany(mappedBy = "job", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("clipNo ASC") + private List clips = new ArrayList<>(); + + public static ShortformJob create(String youtubeUrl, String videoId) { + ShortformJob job = new ShortformJob(); + job.youtubeUrl = youtubeUrl; + job.videoId = videoId; + return job; + } + + public void applyResult(String raw, List parsed) { + this.rawOutput = raw; + this.clips.clear(); + for (ParsedClip p : parsed) { + this.clips.add(ShortformClip.of(this, p)); + } + this.title = parsed.isEmpty() ? this.title : parsed.get(0).titleMain(); + this.status = ShortformJobStatus.DONE; + this.completedAt = LocalDateTime.now(); + } + + public void markFailed(String raw) { + this.rawOutput = raw; + this.status = ShortformJobStatus.FAILED; + this.completedAt = LocalDateTime.now(); + } +} +``` + +`ShortformClip.java`: + +```java +package com.hlab.yanalyst.domain.shortform; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** 작업 하나에서 나온 구간(ID 1~5) 하나. capcutJson이 실제 편집 프로그램에 붙여넣는 값. */ +@Entity +@Table(name = "shortform_clip") +@Getter +@NoArgsConstructor +public class ShortformClip { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "job_id") + private ShortformJob job; + + @Column(nullable = false) + private Integer clipNo; + + @Column(length = 100) + private String titleTop; + + @Column(length = 200) + private String titleMain; + + @Column(columnDefinition = "TEXT") + private String capcutJson; + + @Column(columnDefinition = "TEXT") + private String titleCandidates; + + @Column(columnDefinition = "TEXT") + private String durationTable; + + static ShortformClip of(ShortformJob job, ParsedClip p) { + ShortformClip clip = new ShortformClip(); + clip.job = job; + clip.clipNo = p.clipNo(); + clip.titleTop = p.titleTop(); + clip.titleMain = p.titleMain(); + clip.capcutJson = p.capcutJson(); + clip.titleCandidates = p.titleCandidates(); + clip.durationTable = p.durationTable(); + return clip; + } +} +``` + +`ShortformJobRepository.java`: + +```java +package com.hlab.yanalyst.domain.shortform; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ShortformJobRepository extends JpaRepository { + Optional findByVideoId(String videoId); + List findAllByOrderByCreatedAtDesc(); + List findByStatusOrderByCreatedAtAsc(ShortformJobStatus status); +} +``` + +- [ ] **Step 2: 빌드 확인** + +Run: `.\gradlew.bat compileJava` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: 커밋** + +```bash +git add src/main/java/com/hlab/yanalyst/domain/shortform +git commit -m "feat: 숏폼 큐 엔티티(ShortformJob/Clip)와 리포지토리" +``` + +--- + +### Task 4: 서비스 + REST API + +**Files:** +- Create: `src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java` +- Create: `src/main/java/com/hlab/yanalyst/domain/shortform/ShortformController.java` +- Create: `src/main/java/com/hlab/yanalyst/domain/shortform/dto/ShortformDtos.java` + +**Interfaces:** +- Consumes: Task 1~3 전부 (`ShortformOutputParser.parse`, `YoutubeVideoId.from`, `ShortformJob.*`, `ShortformJobRepository.*`) +- Produces: REST API + - `POST /api/shortform/jobs` body `{"youtubeUrl": "..."}` → `ApiResponse` (중복 videoId면 기존 작업 반환) + - `GET /api/shortform/jobs` → `ApiResponse>` + - `GET /api/shortform/jobs?status=PENDING` → 상태 필터 (스킬이 큐 조회에 사용) + - `GET /api/shortform/jobs/{id}` → `ApiResponse` + - `POST /api/shortform/jobs/{id}/result` body `{"rawText": "..."}` → `ApiResponse` + - `POST /api/shortform/import` body `{"youtubeUrl": "...", "rawText": "..."}` → `ApiResponse` + - `DELETE /api/shortform/jobs/{id}` → `ApiResponse` + +- [ ] **Step 1: 구현** + +`dto/ShortformDtos.java` (record 묶음 — 파일 하나로 응집): + +```java +package com.hlab.yanalyst.domain.shortform.dto; + +import com.hlab.yanalyst.domain.shortform.ShortformClip; +import com.hlab.yanalyst.domain.shortform.ShortformJob; + +import java.time.LocalDateTime; +import java.util.List; + +public final class ShortformDtos { + + private ShortformDtos() {} + + public record RegisterRequest(String youtubeUrl) {} + + public record ResultRequest(String rawText) {} + + public record ImportRequest(String youtubeUrl, String rawText) {} + + public record JobSummary(Long id, String youtubeUrl, String videoId, String title, + String status, int clipCount, + LocalDateTime createdAt, LocalDateTime completedAt) { + public static JobSummary from(ShortformJob job) { + return new JobSummary(job.getId(), job.getYoutubeUrl(), job.getVideoId(), + job.getTitle(), job.getStatus().name(), job.getClips().size(), + job.getCreatedAt(), job.getCompletedAt()); + } + } + + public record ClipDto(int clipNo, String titleTop, String titleMain, + String capcutJson, String titleCandidates, String durationTable) { + public static ClipDto from(ShortformClip clip) { + return new ClipDto(clip.getClipNo(), clip.getTitleTop(), clip.getTitleMain(), + clip.getCapcutJson(), clip.getTitleCandidates(), clip.getDurationTable()); + } + } + + public record JobDetail(Long id, String youtubeUrl, String videoId, String title, + String status, LocalDateTime createdAt, LocalDateTime completedAt, + List clips) { + public static JobDetail from(ShortformJob job) { + return new JobDetail(job.getId(), job.getYoutubeUrl(), job.getVideoId(), + job.getTitle(), job.getStatus().name(), job.getCreatedAt(), + job.getCompletedAt(), job.getClips().stream().map(ClipDto::from).toList()); + } + } +} +``` + +`ShortformService.java`: + +```java +package com.hlab.yanalyst.domain.shortform; + +import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobDetail; +import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobSummary; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ShortformService { + + private final ShortformJobRepository jobRepository; + private final ShortformOutputParser parser; + + /** URL 등록. 같은 videoId가 이미 있으면 그 작업을 그대로 반환한다(중복 방지). */ + @Transactional + public JobDetail register(String youtubeUrl) { + String videoId = YoutubeVideoId.from(youtubeUrl) + .orElseThrow(() -> new IllegalArgumentException("유튜브 URL이 아닙니다: " + youtubeUrl)); + ShortformJob job = jobRepository.findByVideoId(videoId) + .orElseGet(() -> jobRepository.save(ShortformJob.create(youtubeUrl.trim(), videoId))); + return JobDetail.from(job); + } + + /** Opal 출력 원문을 파싱해 저장. 클립이 하나도 안 나오면 FAILED로 남긴다. */ + @Transactional + public JobDetail saveResult(Long jobId, String rawText) { + ShortformJob job = jobRepository.findById(jobId) + .orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId)); + List parsed = parser.parse(rawText); + if (parsed.isEmpty()) { + job.markFailed(rawText); + } else { + job.applyResult(rawText, parsed); + } + return JobDetail.from(job); + } + + /** 등록 + 결과 저장 한 번에 (Opal 수동 실행 후 붙여넣기 경로). */ + @Transactional + public JobDetail importResult(String youtubeUrl, String rawText) { + JobDetail registered = register(youtubeUrl); + return saveResult(registered.id(), rawText); + } + + @Transactional(readOnly = true) + public List list(ShortformJobStatus status) { + List jobs = (status == null) + ? jobRepository.findAllByOrderByCreatedAtDesc() + : jobRepository.findByStatusOrderByCreatedAtAsc(status); + return jobs.stream().map(JobSummary::from).toList(); + } + + @Transactional(readOnly = true) + public JobDetail detail(Long jobId) { + return jobRepository.findById(jobId).map(JobDetail::from) + .orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId)); + } + + @Transactional + public void delete(Long jobId) { + jobRepository.deleteById(jobId); + } +} +``` + +`ShortformController.java`: + +```java +package com.hlab.yanalyst.domain.shortform; + +import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.ImportRequest; +import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobDetail; +import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobSummary; +import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.RegisterRequest; +import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.ResultRequest; +import com.hlab.yanalyst.global.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/shortform") +@RequiredArgsConstructor +public class ShortformController { + + private final ShortformService shortformService; + + @PostMapping("/jobs") + public ApiResponse register(@RequestBody RegisterRequest request) { + return ApiResponse.created(shortformService.register(request.youtubeUrl())); + } + + @GetMapping("/jobs") + public ApiResponse> list(@RequestParam(required = false) ShortformJobStatus status) { + return ApiResponse.ok(shortformService.list(status)); + } + + @GetMapping("/jobs/{id}") + public ApiResponse detail(@PathVariable Long id) { + return ApiResponse.ok(shortformService.detail(id)); + } + + @PostMapping("/jobs/{id}/result") + public ApiResponse saveResult(@PathVariable Long id, @RequestBody ResultRequest request) { + return ApiResponse.ok(shortformService.saveResult(id, request.rawText())); + } + + @PostMapping("/import") + public ApiResponse importResult(@RequestBody ImportRequest request) { + return ApiResponse.ok(shortformService.importResult(request.youtubeUrl(), request.rawText())); + } + + @DeleteMapping("/jobs/{id}") + public ApiResponse delete(@PathVariable Long id) { + shortformService.delete(id); + return ApiResponse.ok(id); + } +} +``` + +- [ ] **Step 2: 빌드 + 전체 테스트** + +Run: `.\gradlew.bat build` +Expected: BUILD SUCCESSFUL (Task 1·2 테스트 포함 전부 PASS) + +- [ ] **Step 3: 커밋** + +```bash +git add src/main/java/com/hlab/yanalyst/domain/shortform +git commit -m "feat: 숏폼 큐 서비스·REST API — 등록/조회/결과저장/import/삭제" +``` + +--- + +### Task 5: /shortform 페이지 + 사이드바 + +**Files:** +- Modify: `src/main/java/com/hlab/yanalyst/web/WebController.java` (feed 매핑 아래에 추가) +- Modify: `src/main/resources/templates/layout/sidebar.html` (소재 섹션의 "소재 피드" 항목 아래) +- Create: `src/main/resources/templates/shortform.html` + +**Interfaces:** +- Consumes: Task 4의 REST API 전부 (fetch로 호출) + +- [ ] **Step 1: WebController 매핑 추가** + +```java + @GetMapping("/shortform") + public String shortform(Model model) { + model.addAttribute("currentPage", "shortform"); + return "shortform"; + } +``` + +- [ ] **Step 2: 사이드바 메뉴 추가** — `소재 피드` `` 태그 바로 아래에: + +```html + + 숏폼 큐 + +``` + +- [ ] **Step 3: shortform.html 작성** + +```html + + + + + h-lab - 숏폼 큐 + + + + +

+ + +
+ + +
+ +
+ + + +
+ +
+
+ + + + + + +``` + +주의: `layout:fragment="script"` 블록 이름은 `layout/base.html`이 실제 정의한 fragment 이름을 따라야 한다. 구현 시 base.html을 열어 확인하고, script fragment가 없으면 content fragment 안 맨 아래에 `