# 숏폼 큐(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 안 맨 아래에 `