feat: 숏폼 큐 서비스·REST API — 등록/조회/결과저장/import/삭제

status=PENDING 필터는 shortform-queue 스킬의 큐 조회용.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-03 12:00:20 +09:00
parent 005df3225b
commit db6a9831b6
3 changed files with 164 additions and 0 deletions

View File

@ -0,0 +1,51 @@
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<JobDetail> register(@RequestBody RegisterRequest request) {
return ApiResponse.created(shortformService.register(request.youtubeUrl()));
}
@GetMapping("/jobs")
public ApiResponse<List<JobSummary>> list(@RequestParam(required = false) ShortformJobStatus status) {
return ApiResponse.ok(shortformService.list(status));
}
@GetMapping("/jobs/{id}")
public ApiResponse<JobDetail> detail(@PathVariable Long id) {
return ApiResponse.ok(shortformService.detail(id));
}
@PostMapping("/jobs/{id}/result")
public ApiResponse<JobDetail> saveResult(@PathVariable Long id, @RequestBody ResultRequest request) {
return ApiResponse.ok(shortformService.saveResult(id, request.rawText()));
}
@PostMapping("/import")
public ApiResponse<JobDetail> importResult(@RequestBody ImportRequest request) {
return ApiResponse.ok(shortformService.importResult(request.youtubeUrl(), request.rawText()));
}
@DeleteMapping("/jobs/{id}")
public ApiResponse<Long> delete(@PathVariable Long id) {
shortformService.delete(id);
return ApiResponse.ok(id);
}
}

View File

@ -0,0 +1,67 @@
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<ParsedClip> 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<JobSummary> list(ShortformJobStatus status) {
List<ShortformJob> 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);
}
}

View File

@ -0,0 +1,46 @@
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<ClipDto> 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());
}
}
}