refactor: 발행/유통(발행 큐) 기능 제거 — 예능 전환

유통(내가 올려서 발행)에서 예능 소재 파이프라인으로 방향을 틀면서
발행 큐 기능을 제거한다. domain/publish 전체와 발행 준비 UI(재가공 화면),
사이드바 발행 큐 링크, 대시보드 발행 현황 위젯을 걷어냈다.

- domain/publish/* (Controller/Service/Repository/Entity) 삭제
- publish.html · PublishServiceTest 삭제
- rework.html 발행 준비 섹션·JS 제거
- dashboard.html · DashboardService 발행 집계 제거
- WebController /publish 라우트, sidebar 링크 제거
- ChannelVideo.publishedAt(원본 게시일)은 발행 기능과 무관하여 그대로 유지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-07-23 15:13:46 +09:00
parent fcfa4084e4
commit f22f7028c4
13 changed files with 20 additions and 582 deletions

View File

@ -1,53 +0,0 @@
package com.hlab.yanalyst.domain.publish;
import com.hlab.yanalyst.global.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/publish")
@RequiredArgsConstructor
@Tag(name = "Publish API", description = "발행(배포) 패키지 준비/추적")
public class PublishController {
private final PublishService publishService;
@GetMapping("/by-video/{channelVideoId}")
@Operation(summary = "영상별 발행 패키지 조회", description = "없으면 data=null.")
public ApiResponse<PublishPackage> getByVideo(@PathVariable Long channelVideoId) {
return ApiResponse.ok(publishService.getByVideo(channelVideoId));
}
@PostMapping("/by-video/{channelVideoId}")
@Operation(summary = "발행 패키지 저장(upsert)",
description = "body: {title, description, hashtags, platform, scheduledAt(ISO), status(DRAFT|READY|PUBLISHED)}")
public ApiResponse<PublishPackage> upsert(@PathVariable Long channelVideoId, @RequestBody Map<String, String> body) {
LocalDateTime scheduledAt = null;
String s = body.get("scheduledAt");
if (s != null && !s.isBlank()) {
scheduledAt = LocalDateTime.parse(s.length() == 16 ? s + ":00" : s);
}
PublishPackage p = publishService.upsert(channelVideoId,
body.get("title"), body.get("description"), body.get("hashtags"),
body.get("platform"), scheduledAt, body.get("status"));
return ApiResponse.ok(p);
}
@PostMapping("/{id}/published")
@Operation(summary = "발행 완료 처리", description = "body: {url} — 상태를 PUBLISHED 로 바꾸고 URL/시각 기록.")
public ApiResponse<PublishPackage> markPublished(@PathVariable Long id, @RequestBody Map<String, String> body) {
return ApiResponse.ok(publishService.markPublished(id, body.get("url")));
}
@GetMapping
@Operation(summary = "발행 큐 조회", description = "status(DRAFT|READY|PUBLISHED) 필터, 예약일 순.")
public ApiResponse<List<PublishPackage>> list(@RequestParam(required = false) String status) {
return ApiResponse.ok(publishService.list(status));
}
}

View File

@ -1,69 +0,0 @@
package com.hlab.yanalyst.domain.publish;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 발행(배포) 패키지 재가공한 영상을 어디에/언제/어떤 메타데이터로 올릴지 준비하고,
* 발행 결과(URL) 기록한다. 실제 업로드는 수동(또는 추후 플랫폼 API 연동) 여기는 준비·추적 단계.
* ChannelVideo 1:1.
*/
@Entity
@Table(name = "publish_packages",
uniqueConstraints = @UniqueConstraint(columnNames = "channel_video_id"))
@Getter
@Setter
@NoArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class PublishPackage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "channel_video_id", nullable = false)
private Long channelVideoId;
@Column(columnDefinition = "TEXT")
private String title;
@Column(columnDefinition = "TEXT")
private String description;
@Column(columnDefinition = "TEXT")
private String hashtags;
/** 대상 플랫폼: YOUTUBE / TIKTOK / REELS 등. */
@Column(length = 100)
private String platform = "YOUTUBE";
/** 발행 예약 일시(선택). */
@Column(name = "scheduled_at")
private LocalDateTime scheduledAt;
/** DRAFT(작성중) / READY(발행대기) / PUBLISHED(발행완료). */
@Column(length = 20)
private String status = "DRAFT";
/** 발행 완료 시 실제 업로드된 URL(수동 기록). */
@Column(name = "published_url", columnDefinition = "TEXT")
private String publishedUrl;
@Column(name = "published_at")
private LocalDateTime publishedAt;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}

View File

@ -1,22 +0,0 @@
package com.hlab.yanalyst.domain.publish;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
import java.util.Optional;
public interface PublishPackageRepository extends JpaRepository<PublishPackage, Long> {
Optional<PublishPackage> findByChannelVideoId(Long channelVideoId);
List<PublishPackage> findByStatus(String status, Sort sort);
long countByStatus(String status);
/**
* 전체 조회(정렬 포함). {@code findAll(Sort)} Criteria 기반이라 nullsLast/First 같은
* null precedence를 지원하지 않으므로(UnsupportedOperationException), Sort를 HQL ORDER BY로
* 붙이는 @Query 방식을 사용한다.
*/
@Query("select p from PublishPackage p")
List<PublishPackage> findAllSorted(Sort sort);
}

View File

@ -1,84 +0,0 @@
package com.hlab.yanalyst.domain.publish;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PublishService {
private final PublishPackageRepository repository;
private static final Set<String> ALLOWED_STATUS = Set.of("DRAFT", "READY", "PUBLISHED");
/** 대시보드 표시 순서가 고정되도록 명시적 순서 보장(Set.of 는 반복 순서 미정의). */
private static final List<String> STATUS_ORDER = List.of("DRAFT", "READY", "PUBLISHED");
public PublishPackage getByVideo(Long channelVideoId) {
return repository.findByChannelVideoId(channelVideoId).orElse(null);
}
/** 영상별 발행 패키지 upsert. */
@Transactional
public PublishPackage upsert(Long channelVideoId, String title, String description, String hashtags,
String platform, LocalDateTime scheduledAt, String status) {
if (status != null && !ALLOWED_STATUS.contains(status)) {
throw new IllegalArgumentException("허용되지 않은 상태값: " + status + " (가능: " + ALLOWED_STATUS + ")");
}
PublishPackage p = repository.findByChannelVideoId(channelVideoId).orElseGet(() -> {
PublishPackage np = new PublishPackage();
np.setChannelVideoId(channelVideoId);
return np;
});
p.setTitle(title);
p.setDescription(description);
p.setHashtags(hashtags);
if (StringUtils.hasText(platform)) p.setPlatform(platform);
p.setScheduledAt(scheduledAt);
if (StringUtils.hasText(status)) p.setStatus(status);
return repository.save(p);
}
/** 발행 완료 처리(실제 업로드 URL 기록). */
@Transactional
public PublishPackage markPublished(Long id, String url) {
PublishPackage p = repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Publish package not found: " + id));
p.setStatus("PUBLISHED");
p.setPublishedUrl(url);
p.setPublishedAt(LocalDateTime.now());
return repository.save(p);
}
/** 대시보드용 발행 요약: 상태별 카운트 + 최근 수정 5건. */
public java.util.Map<String, Object> dashboardSummary() {
java.util.Map<String, Long> byStatus = new java.util.LinkedHashMap<>();
for (String s : STATUS_ORDER) {
byStatus.put(s, repository.countByStatus(s));
}
// 최근 수정순으로 정렬해 상위 5건을 "최근" 으로 노출 (예약일 오름차순인 정렬과 구분).
List<PublishPackage> all = repository.findAllSorted(Sort.by(Sort.Order.desc("updatedAt")));
List<PublishPackage> recent = all.size() > 5 ? List.copyOf(all.subList(0, 5)) : all;
java.util.Map<String, Object> result = new java.util.LinkedHashMap<>();
result.put("byStatus", byStatus);
result.put("total", (long) all.size());
result.put("recent", recent);
return result;
}
/** 발행 큐: 상태(null이면 전체)로 필터, 예약일 → 수정일 순. */
public List<PublishPackage> list(String status) {
Sort sort = Sort.by(Sort.Order.asc("scheduledAt").nullsLast(), Sort.Order.desc("updatedAt"));
if (StringUtils.hasText(status)) {
return repository.findByStatus(status, sort);
}
return repository.findAllSorted(sort);
}
}

View File

@ -3,7 +3,6 @@ package com.hlab.yanalyst.service;
import com.hlab.yanalyst.domain.category.CategoryService; import com.hlab.yanalyst.domain.category.CategoryService;
import com.hlab.yanalyst.domain.channel.ChannelVideo; import com.hlab.yanalyst.domain.channel.ChannelVideo;
import com.hlab.yanalyst.domain.channel.ChannelVideoCurationService; import com.hlab.yanalyst.domain.channel.ChannelVideoCurationService;
import com.hlab.yanalyst.domain.publish.PublishService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@ -14,7 +13,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* 대시보드용 단일 집계 파이프라인(수집큐레이션발행) 현황을 번에 묶어 반환한다. * 대시보드용 단일 집계 파이프라인(수집큐레이션재가공) 현황을 번에 묶어 반환한다.
* 도메인 서비스의 집계를 조합만 한다(리포지토리 직접 접근 없음). * 도메인 서비스의 집계를 조합만 한다(리포지토리 직접 접근 없음).
*/ */
@Service @Service
@ -24,13 +23,11 @@ public class DashboardService {
private final ChannelVideoCurationService curationService; private final ChannelVideoCurationService curationService;
private final CategoryService categoryService; private final CategoryService categoryService;
private final PublishService publishService;
public Map<String, Object> summary() { public Map<String, Object> summary() {
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
result.put("pipeline", curationService.pipelineStats()); // total, byStatus, bySource, shorts, longForm result.put("pipeline", curationService.pipelineStats()); // total, byStatus, bySource, shorts, longForm
result.put("categories", categoryService.distribution()); // categories[], uncategorized result.put("categories", categoryService.distribution()); // categories[], uncategorized
result.put("publish", publishService.dashboardSummary()); // byStatus, total, recent
List<ChannelVideo> outperformers = curationService.findOutperformers(5, BigDecimal.ONE); List<ChannelVideo> outperformers = curationService.findOutperformers(5, BigDecimal.ONE);
result.put("outperformers", outperformers); result.put("outperformers", outperformers);
return result; return result;

View File

@ -21,7 +21,7 @@ public class DashboardApiController {
@GetMapping("/summary") @GetMapping("/summary")
@Operation(summary = "대시보드 요약", @Operation(summary = "대시보드 요약",
description = "파이프라인(수집/상태/출처/포맷) + 카테고리 분포 + 발행 현황 + 떡상 후보 TOP5 를 한 번에 반환.") description = "파이프라인(수집/상태/출처/포맷) + 카테고리 분포 + 떡상 후보 TOP5 를 한 번에 반환.")
public ApiResponse<Map<String, Object>> summary() { public ApiResponse<Map<String, Object>> summary() {
return ApiResponse.ok(dashboardService.summary()); return ApiResponse.ok(dashboardService.summary());
} }

View File

@ -58,12 +58,6 @@ public class WebController {
return "recommend"; return "recommend";
} }
@GetMapping("/publish")
public String publish(Model model) {
model.addAttribute("currentPage", "publish");
return "publish";
}
@GetMapping("/rework/{id}") @GetMapping("/rework/{id}")
public String rework(@org.springframework.web.bind.annotation.PathVariable Long id, Model model) { public String rework(@org.springframework.web.bind.annotation.PathVariable Long id, Model model) {
model.addAttribute("currentPage", "collection"); model.addAttribute("currentPage", "collection");

View File

@ -12,7 +12,7 @@
<div class="fd-masthead"> <div class="fd-masthead">
<div> <div>
<div class="fd-tick" style="margin-bottom:8px;">지금 할 일 중심 · 수집 → 큐레이션 → 재가공 → 발행</div> <div class="fd-tick" style="margin-bottom:8px;">지금 할 일 중심 · 수집 → 큐레이션 → 재가공</div>
<h1>대시보드</h1> <h1>대시보드</h1>
</div> </div>
<div class="fd-meta"> <div class="fd-meta">
@ -63,8 +63,6 @@
<div class="fd-pb" id="funnel"><div class="fd-loading">불러오는 중…</div></div> <div class="fd-pb" id="funnel"><div class="fd-loading">불러오는 중…</div></div>
<div class="fd-ph" style="border-top:1.5px solid var(--fd-rule-strong)"><h3 style="font-size:13px">출처 · 포맷</h3><a class="fd-tick" th:href="@{/collection}">수집함 →</a></div> <div class="fd-ph" style="border-top:1.5px solid var(--fd-rule-strong)"><h3 style="font-size:13px">출처 · 포맷</h3><a class="fd-tick" th:href="@{/collection}">수집함 →</a></div>
<div class="fd-pb" id="srcFmt"></div> <div class="fd-pb" id="srcFmt"></div>
<div class="fd-ph" style="border-top:1.5px solid var(--fd-rule-strong)"><h3 style="font-size:13px">발행 현황</h3><a class="fd-tick" th:href="@{/publish}">발행 큐 →</a></div>
<div class="fd-pb" id="pubBox"></div>
</div> </div>
</section> </section>
@ -223,7 +221,6 @@
const pipe = d.pipeline||{}, bs = pipe.byStatus||{}, src = pipe.bySource||{}; const pipe = d.pipeline||{}, bs = pipe.byStatus||{}, src = pipe.bySource||{};
const total = Number(pipe.total||0); const total = Number(pipe.total||0);
const pub = d.publish||{}, pbs = pub.byStatus||{};
const op = d.outperformers||[]; const op = d.outperformers||[];
// ----- 액션 바 ----- // ----- 액션 바 -----
@ -261,7 +258,7 @@
document.getElementById('pipeTotal').textContent = '총 ' + fmt(total); document.getElementById('pipeTotal').textContent = '총 ' + fmt(total);
const stages = [ const stages = [
['미검토', bs.NEW], ['검토중', bs.REVIEWING], ['작업대상', bs.TARGET], ['미검토', bs.NEW], ['검토중', bs.REVIEWING], ['작업대상', bs.TARGET],
['완료', bs.DONE], ['발행완료', pbs.PUBLISHED] ['완료', bs.DONE]
]; ];
let bottleneck = stages[0]; let bottleneck = stages[0];
for(const s of stages){ if(Number(s[1]||0) > Number(bottleneck[1]||0)) bottleneck = s; } for(const s of stages){ if(Number(s[1]||0) > Number(bottleneck[1]||0)) bottleneck = s; }
@ -270,7 +267,6 @@
fbar('검토중', bs.REVIEWING, total) + fbar('검토중', bs.REVIEWING, total) +
fbar('작업대상', bs.TARGET, total) + fbar('작업대상', bs.TARGET, total) +
fbar('완료', bs.DONE, total) + fbar('완료', bs.DONE, total) +
fbar('발행완료', pbs.PUBLISHED, total) +
`<div class="fd-bottleneck"><div class="msg">병목: <b>${esc(bottleneck[0])} ${fmt(bottleneck[1])}</b> — 다음 단계로 옮겨보세요</div><a class="fd-btn fd-sm fd-sig" href="/board">칸반 보드</a></div>`; `<div class="fd-bottleneck"><div class="msg">병목: <b>${esc(bottleneck[0])} ${fmt(bottleneck[1])}</b> — 다음 단계로 옮겨보세요</div><a class="fd-btn fd-sm fd-sig" href="/board">칸반 보드</a></div>`;
// ----- 출처/포맷 ----- // ----- 출처/포맷 -----
@ -279,14 +275,6 @@
`<div class="fd-row"><span>검색 수집</span><span class="v"><b>${fmt(src.SEARCH)}</b> · ${pct(src.SEARCH,total)}%</span></div>` + `<div class="fd-row"><span>검색 수집</span><span class="v"><b>${fmt(src.SEARCH)}</b> · ${pct(src.SEARCH,total)}%</span></div>` +
`<div class="fd-row"><span>Shorts</span><span class="v"><b>${fmt(pipe.shorts)}</b> · ${pct(pipe.shorts,total)}%</span></div>`; `<div class="fd-row"><span>Shorts</span><span class="v"><b>${fmt(pipe.shorts)}</b> · ${pct(pipe.shorts,total)}%</span></div>`;
// ----- 발행 -----
const ptotal = Number(pub.total||0);
document.getElementById('pubBox').innerHTML = ptotal===0
? '<div class="fd-empty">발행 패키지 없음 — 재가공 화면에서 발행안을 저장하세요.</div>'
: `<div class="fd-row"><span>작성중</span><span class="v"><b>${fmt(pbs.DRAFT)}</b></span></div>`+
`<div class="fd-row"><span>발행대기</span><span class="v"><b>${fmt(pbs.READY)}</b></span></div>`+
`<div class="fd-row"><span>발행완료</span><span class="v"><b>${fmt(pbs.PUBLISHED)}</b></span></div>`;
if(window.lucide) lucide.createIcons(); if(window.lucide) lucide.createIcons();
} }

View File

@ -41,9 +41,6 @@
<a th:href="@{/board}" class="nav-item" th:classappend="${currentPage == 'board'} ? 'active'"> <a th:href="@{/board}" class="nav-item" th:classappend="${currentPage == 'board'} ? 'active'">
<i data-lucide="kanban-square" class="nav-icon"></i><span class="nav-text">칸반 보드</span> <i data-lucide="kanban-square" class="nav-icon"></i><span class="nav-text">칸반 보드</span>
</a> </a>
<a th:href="@{/publish}" class="nav-item" th:classappend="${currentPage == 'publish'} ? 'active'">
<i data-lucide="send" class="nav-icon"></i><span class="nav-text">발행 큐</span>
</a>
<div class="nav-section">제작</div> <div class="nav-section">제작</div>
<a th:href="@{/production}" class="nav-item" th:classappend="${currentPage == 'production'} ? 'active'"> <a th:href="@{/production}" class="nav-item" th:classappend="${currentPage == 'production'} ? 'active'">

View File

@ -1,136 +0,0 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/base}">
<head>
<title>h-lab - 발행</title>
</head>
<body>
<div layout:fragment="content">
<div class="page-header">
<div>
<h1>발행 큐</h1>
<p class="sub">재가공한 영상의 발행 패키지를 단계별로 관리합니다. (실제 업로드는 수동 — 여기서 준비·추적)</p>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="openHelp()"><i data-lucide="help-circle" style="width:15px;"></i> 사용법</button>
</div>
</div>
<!-- 사용법 모달 -->
<div id="helpModal" class="modal-overlay" onclick="if(event.target===this) closeHelp()">
<div class="modal-card">
<div class="modal-head"><h3>📖 발행 큐 사용법</h3><button class="modal-close" onclick="closeHelp()">&times;</button></div>
<div class="modal-body">
<div class="help-item">
<div class="hi-ic"><i data-lucide="list-checks"></i></div>
<div><div class="hi-t">상태 탭</div><div class="hi-d"><b>작성중 → 발행대기 → 발행완료</b>로 발행 준비 단계를 거릅니다. 탭으로 상태별로 필터합니다.</div></div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="pencil"></i></div>
<div><div class="hi-t">발행안 만들기</div><div class="hi-d">발행 패키지는 <b>재가공 화면 하단</b>에서 제목·설명·해시태그·플랫폼·예약을 저장하면 생성됩니다. 표의 <b>✏️</b>로 다시 편집합니다.</div></div>
</div>
<div class="help-item">
<div class="hi-ic"><i data-lucide="upload-cloud"></i></div>
<div><div class="hi-t">업로드 & 기록</div><div class="hi-d">실제 업로드는 <b>플랫폼에서 수동</b>으로 합니다. 업로드 후 URL을 기록하면 ‘발행완료’로 추적됩니다. <b>📋</b>로 설명+해시태그를 복사하세요.</div></div>
</div>
</div>
</div>
</div>
<div class="flex gap-2 mb-4" id="tabs" style="flex-wrap:wrap; align-items:center;">
<button class="btn btn-secondary tab active" data-status="" onclick="setTab(this,'')">전체</button>
<button class="btn btn-secondary tab" data-status="DRAFT" onclick="setTab(this,'DRAFT')">작성중</button>
<button class="btn btn-secondary tab" data-status="READY" onclick="setTab(this,'READY')">발행대기</button>
<button class="btn btn-secondary tab" data-status="PUBLISHED" onclick="setTab(this,'PUBLISHED')">발행완료</button>
<span id="cnt" class="badge badge-muted" style="margin-left:auto;"></span>
</div>
<div class="card p-0" style="overflow-x:auto;">
<table class="w-full" style="border-collapse:collapse; text-align:left;">
<thead>
<tr>
<th>상태</th>
<th>플랫폼</th>
<th>제목</th>
<th>예약</th>
<th>발행 URL</th>
<th>관리</th>
</tr>
</thead>
<tbody id="body">
<tr><td colspan="6" class="p-8 text-center text-muted">로딩 중...</td></tr>
</tbody>
</table>
</div>
<style>
.tab { padding:0.5rem 1rem; }
.tab.active { background:var(--accent-soft); border-color:var(--accent); color:var(--accent); }
</style>
<script th:inline="javascript">
/*<![CDATA[*/
let curStatus = '';
const ST = { DRAFT:{t:'작성중',cls:'badge-muted'}, READY:{t:'발행대기',cls:'badge-warning'}, PUBLISHED:{t:'발행완료',cls:'badge-success'} };
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
async function api(url, opts){
const res = await fetch(url, opts);
const json = await res.json().catch(()=>({}));
if(!res.ok || (json && json.success===false)) throw new Error((json && json.message)||('HTTP '+res.status));
return json.data;
}
function setTab(btn, status){
curStatus = status;
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
btn.classList.add('active');
load();
}
async function load(){
const body = document.getElementById('body');
body.innerHTML = '<tr><td colspan="6" class="p-8 text-center text-muted">로딩 중...</td></tr>';
let data;
try { data = await api('/api/v1/publish' + (curStatus?('?status='+curStatus):'')) || []; }
catch(e){ body.innerHTML = '<tr><td colspan="6" class="p-8 text-center text-danger">불러오기 실패: '+esc(e.message)+'</td></tr>'; return; }
document.getElementById('cnt').textContent = data.length + '건';
if(data.length===0){ body.innerHTML = '<tr><td colspan="6" class="p-8 text-center text-muted">발행 패키지가 없습니다. 재가공 화면에서 발행안을 저장하세요.</td></tr>'; return; }
body.innerHTML = data.map(p=>{
const st = ST[p.status] || {t:p.status,cls:'badge-muted'};
const sched = p.scheduledAt ? String(p.scheduledAt).substring(0,16).replace('T',' ') : '-';
const urlCell = p.publishedUrl ? `<a href="${esc(p.publishedUrl)}" target="_blank" class="hover:underline" style="color:var(--accent);">열기</a>` : '-';
return `<tr style="border-bottom:1px solid var(--glass-border);">
<td class="p-3"><span class="badge ${st.cls}">${st.t}</span></td>
<td class="p-3 text-sm">${esc(p.platform||'')}</td>
<td class="p-3 text-sm" style="max-width:360px;"><div style="display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">${esc(p.title||'(제목 없음)')}</div></td>
<td class="p-3 text-sm text-muted">${sched}</td>
<td class="p-3 text-sm">${urlCell}</td>
<td class="p-3">
<div class="flex items-center gap-1">
<a class="btn btn-secondary p-2" title="재가공/편집" href="/rework/${p.channelVideoId}"><i data-lucide="pencil" style="width:15px;"></i></a>
<button class="btn btn-secondary p-2" title="설명 복사" onclick='copyDesc(${JSON.stringify((p.description||"")+ "\n\n" + (p.hashtags||""))})'><i data-lucide="copy" style="width:15px;"></i></button>
</div>
</td>
</tr>`;
}).join('');
if(window.lucide) lucide.createIcons();
}
function copyDesc(text){
navigator.clipboard.writeText(text).then(()=>alert('설명+해시태그가 복사되었습니다.'));
}
function openHelp(){ document.getElementById('helpModal').classList.add('open'); }
function closeHelp(){ document.getElementById('helpModal').classList.remove('open'); }
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeHelp(); });
load();
/*]]>*/
</script>
</div>
</body>
</html>

View File

@ -85,7 +85,7 @@
</div> </div>
<div class="help-item"> <div class="help-item">
<div class="hi-ic"><i data-lucide="send"></i></div> <div class="hi-ic"><i data-lucide="send"></i></div>
<div><div class="hi-t">재작성 · 발행</div><div class="hi-d">‘재작성(내 버전)’에 각색해 저장하면 상태가 <b>TARGET</b>으로 바뀝니다. 하단 <b>발행 준비</b>에서 제목·해시태그를 저장하면 발행 큐로 들어갑니다.</div></div> <div><div class="hi-t">재작성</div><div class="hi-d">‘재작성(내 버전)’에 각색해 저장하면 상태가 <b>TARGET</b>으로 바뀝니다.</div></div>
</div> </div>
</div> </div>
</div> </div>
@ -121,7 +121,10 @@
<div class="font-bold" id="vTitle" style="line-height:1.4;"></div> <div class="font-bold" id="vTitle" style="line-height:1.4;"></div>
<div class="text-muted" id="vChannel"></div> <div class="text-muted" id="vChannel"></div>
<div class="flex gap-3 mt-2 text-muted" id="vStats"></div> <div class="flex gap-3 mt-2 text-muted" id="vStats"></div>
<a id="vLink" href="#" target="_blank" class="text-sm hover:underline mt-2" style="color:#60a5fa;">YouTube에서 열기 →</a> <div class="flex items-center gap-3 mt-2" style="flex-wrap:wrap;">
<a id="vLink" href="#" target="_blank" class="text-sm hover:underline" style="color:#60a5fa;">YouTube에서 열기 →</a>
<button id="vCopyBtn" type="button" onclick="copyVideoUrl(this)" class="text-sm hover:underline" style="color:#60a5fa; background:none; border:none; cursor:pointer; padding:0;">URL 복사</button>
</div>
</div> </div>
</div> </div>
@ -265,66 +268,10 @@
style="width:100%; min-height:280px; resize:vertical; padding:12px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:var(--text); outline:none; font-size:0.95rem; line-height:1.7;"></textarea> style="width:100%; min-height:280px; resize:vertical; padding:12px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:var(--text); outline:none; font-size:0.95rem; line-height:1.7;"></textarea>
<div class="text-sm text-muted mt-2" id="saveInfo"></div> <div class="text-sm text-muted mt-2" id="saveInfo"></div>
</div> </div>
<!-- 발행 준비 -->
<div class="card">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-bold">📤 발행 준비</h3>
<span id="pubStatusBadge" class="text-sm text-muted"></span>
</div>
<div class="flex flex-col gap-3">
<div>
<label class="text-sm text-muted">발행 제목</label>
<input id="pubTitle" type="text" class="pub-in" placeholder="업로드할 새 제목">
</div>
<div>
<label class="text-sm text-muted">설명</label>
<textarea id="pubDesc" class="pub-in" style="min-height:80px; resize:vertical;" placeholder="영상 설명"></textarea>
</div>
<div>
<label class="text-sm text-muted">해시태그</label>
<input id="pubTags" type="text" class="pub-in" placeholder="#태그1 #태그2">
</div>
<div class="flex gap-3" style="flex-wrap:wrap;">
<div style="flex:1; min-width:120px;">
<label class="text-sm text-muted">플랫폼</label>
<select id="pubPlatform" class="pub-in">
<option value="YOUTUBE">YouTube</option>
<option value="TIKTOK">TikTok</option>
<option value="REELS">Instagram Reels</option>
</select>
</div>
<div style="flex:1; min-width:160px;">
<label class="text-sm text-muted">예약 일시</label>
<input id="pubSchedule" type="datetime-local" class="pub-in">
</div>
<div style="flex:1; min-width:120px;">
<label class="text-sm text-muted">상태</label>
<select id="pubStatus" class="pub-in">
<option value="DRAFT">작성중</option>
<option value="READY">발행대기</option>
<option value="PUBLISHED">발행완료</option>
</select>
</div>
</div>
<div class="flex gap-2 items-center">
<button class="btn btn-primary px-4 py-2 flex items-center gap-1" onclick="savePublish()" id="pubSaveBtn">
<i data-lucide="save" style="width:15px;"></i> 발행안 저장
</button>
<button class="btn btn-secondary px-4 py-2 flex items-center gap-1" onclick="markPublished()" id="pubDoneBtn">
<i data-lucide="check-circle" style="width:15px;"></i> 발행 완료(URL 기록)
</button>
<a th:href="@{/publish}" class="text-sm text-muted hover:text-white" style="margin-left:auto;">발행 큐 →</a>
</div>
<div id="pubInfo" class="text-sm text-muted"></div>
</div>
</div>
</div> </div>
</div> </div>
<style> <style>
.pub-in { width:100%; padding:9px; background:var(--surface-2); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:var(--text); outline:none; font-size:0.9rem; }
.pub-in option { background:var(--surface); color:var(--text); }
</style> </style>
<script th:inline="javascript"> <script th:inline="javascript">
@ -334,6 +281,15 @@
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); } function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function fmt(n){ return (n==null)?'-':Number(n).toLocaleString(); } function fmt(n){ return (n==null)?'-':Number(n).toLocaleString(); }
async function copyVideoUrl(btn){
const url = document.getElementById('vLink').href;
if(!url || url.endsWith('#')){ return; }
try {
if(navigator.clipboard && window.isSecureContext){ await navigator.clipboard.writeText(url); }
else { const ta=document.createElement('textarea'); ta.value=url; ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); }
const prev = btn.textContent; btn.textContent='복사됨!'; setTimeout(()=>{ btn.textContent=prev; }, 1500);
} catch(e){ alert('복사 실패: '+e.message); }
}
async function api(url, opts){ async function api(url, opts){
const res = await fetch(url, opts); const res = await fetch(url, opts);
const json = await res.json().catch(()=>({})); const json = await res.json().catch(()=>({}));
@ -806,64 +762,6 @@
finally { btn.disabled = false; btn.innerHTML = orig; if(window.lucide) lucide.createIcons(); } finally { btn.disabled = false; btn.innerHTML = orig; if(window.lucide) lucide.createIcons(); }
} }
// ===== 발행 준비 =====
let pubId = null;
async function loadPublish(videoTitle){
let p = null;
try { p = await api('/api/v1/publish/by-video/' + VIDEO_ID); } catch(e){}
if(p){
pubId = p.id;
document.getElementById('pubTitle').value = p.title || '';
document.getElementById('pubDesc').value = p.description || '';
document.getElementById('pubTags').value = p.hashtags || '';
document.getElementById('pubPlatform').value = p.platform || 'YOUTUBE';
document.getElementById('pubStatus').value = p.status || 'DRAFT';
if(p.scheduledAt) document.getElementById('pubSchedule').value = String(p.scheduledAt).substring(0,16);
document.getElementById('pubStatusBadge').textContent = '상태: ' + (p.status||'DRAFT') + (p.publishedUrl?(' · '+p.publishedUrl):'');
} else {
// 신규: 제목 기본값으로 원본 제목 제안
document.getElementById('pubTitle').value = videoTitle || '';
document.getElementById('pubStatusBadge').textContent = '미작성';
}
}
async function savePublish(){
const btn = document.getElementById('pubSaveBtn');
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '저장 중...';
try {
const p = await api('/api/v1/publish/by-video/' + VIDEO_ID, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
title: document.getElementById('pubTitle').value,
description: document.getElementById('pubDesc').value,
hashtags: document.getElementById('pubTags').value,
platform: document.getElementById('pubPlatform').value,
scheduledAt: document.getElementById('pubSchedule').value,
status: document.getElementById('pubStatus').value
})
});
pubId = p.id;
document.getElementById('pubStatusBadge').textContent = '상태: ' + (p.status||'DRAFT');
document.getElementById('pubInfo').textContent = '저장됨 · ' + new Date().toLocaleTimeString();
} catch(e){ alert('저장 실패: ' + e.message); }
finally { btn.disabled = false; btn.innerHTML = orig; if(window.lucide) lucide.createIcons(); }
}
async function markPublished(){
if(!pubId){ alert('먼저 발행안을 저장하세요.'); return; }
const url = prompt('업로드한 영상 URL을 입력하세요:');
if(url === null) return;
try {
const p = await api('/api/v1/publish/' + pubId + '/published', {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url })
});
document.getElementById('pubStatus').value = 'PUBLISHED';
document.getElementById('pubStatusBadge').textContent = '상태: PUBLISHED · ' + (p.publishedUrl||'');
document.getElementById('pubInfo').textContent = '발행 완료 기록됨';
} catch(e){ alert('발행 완료 처리 실패: ' + e.message); }
}
function openHelp(){ document.getElementById('helpModal').classList.add('open'); } function openHelp(){ document.getElementById('helpModal').classList.add('open'); }
function closeHelp(){ document.getElementById('helpModal').classList.remove('open'); } function closeHelp(){ document.getElementById('helpModal').classList.remove('open'); }
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeHelp(); }); document.addEventListener('keydown', e => { if(e.key === 'Escape') closeHelp(); });
@ -871,7 +769,7 @@
// ?help=1 딥링크로 들어오면 사용법을 바로 펼친다(공유/안내용). // ?help=1 딥링크로 들어오면 사용법을 바로 펼친다(공유/안내용).
if (new URLSearchParams(location.search).get('help') === '1') openHelp(); if (new URLSearchParams(location.search).get('help') === '1') openHelp();
(async ()=>{ await load(); await loadPublish(document.getElementById('vTitle').textContent); })(); (async ()=>{ await load(); })();
/*]]>*/ /*]]>*/
</script> </script>
</div> </div>

View File

@ -1,67 +0,0 @@
package com.hlab.yanalyst.domain.publish;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Sort;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 발행 대시보드 요약: 상태별 카운트 + 최근 5건(전체는 많아도 5개로 잘림).
*/
@ExtendWith(MockitoExtension.class)
class PublishServiceTest {
@Mock PublishPackageRepository repository;
@InjectMocks PublishService publishService;
@Test
void dashboardSummary_countsByStatusAndTakesFirstFiveAsRecent() {
when(repository.countByStatus("DRAFT")).thenReturn(2L);
when(repository.countByStatus("READY")).thenReturn(3L);
when(repository.countByStatus("PUBLISHED")).thenReturn(4L);
// list(null) -> repository.findAllSorted(sort); return 7 so recent should cap at 5
List<PublishPackage> seven = Collections.nCopies(7, mock(PublishPackage.class));
when(repository.findAllSorted(any(Sort.class))).thenReturn(seven);
Map<String, Object> summary = publishService.dashboardSummary();
@SuppressWarnings("unchecked")
Map<String, Long> byStatus = (Map<String, Long>) summary.get("byStatus");
assertThat(byStatus)
.containsEntry("DRAFT", 2L)
.containsEntry("READY", 3L)
.containsEntry("PUBLISHED", 4L);
assertThat(summary.get("total")).isEqualTo(7L);
@SuppressWarnings("unchecked")
List<PublishPackage> recent = (List<PublishPackage>) summary.get("recent");
assertThat(recent).hasSize(5);
}
@Test
void dashboardSummary_recentKeepsAllWhenFiveOrFewer() {
when(repository.countByStatus(any())).thenReturn(0L);
when(repository.findAllSorted(any(Sort.class)))
.thenReturn(Collections.nCopies(3, mock(PublishPackage.class)));
Map<String, Object> summary = publishService.dashboardSummary();
assertThat(summary.get("total")).isEqualTo(3L);
@SuppressWarnings("unchecked")
List<PublishPackage> recent = (List<PublishPackage>) summary.get("recent");
assertThat(recent).hasSize(3);
}
}

View File

@ -3,7 +3,6 @@ package com.hlab.yanalyst.service;
import com.hlab.yanalyst.domain.category.CategoryService; import com.hlab.yanalyst.domain.category.CategoryService;
import com.hlab.yanalyst.domain.channel.ChannelVideo; import com.hlab.yanalyst.domain.channel.ChannelVideo;
import com.hlab.yanalyst.domain.channel.ChannelVideoCurationService; import com.hlab.yanalyst.domain.channel.ChannelVideoCurationService;
import com.hlab.yanalyst.domain.publish.PublishService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
@ -26,7 +25,6 @@ class DashboardServiceTest {
@Mock ChannelVideoCurationService curationService; @Mock ChannelVideoCurationService curationService;
@Mock CategoryService categoryService; @Mock CategoryService categoryService;
@Mock PublishService publishService;
@InjectMocks DashboardService dashboardService; @InjectMocks DashboardService dashboardService;
@ -34,20 +32,17 @@ class DashboardServiceTest {
void summary_composesEachServiceUnderItsKey() { void summary_composesEachServiceUnderItsKey() {
Map<String, Object> pipeline = Map.of("total", 200L); Map<String, Object> pipeline = Map.of("total", 200L);
Map<String, Object> categories = Map.of("uncategorized", 200L); Map<String, Object> categories = Map.of("uncategorized", 200L);
Map<String, Object> publish = Map.of("total", 0L);
List<ChannelVideo> outperformers = List.of(mock(ChannelVideo.class), mock(ChannelVideo.class)); List<ChannelVideo> outperformers = List.of(mock(ChannelVideo.class), mock(ChannelVideo.class));
when(curationService.pipelineStats()).thenReturn(pipeline); when(curationService.pipelineStats()).thenReturn(pipeline);
when(categoryService.distribution()).thenReturn(categories); when(categoryService.distribution()).thenReturn(categories);
when(publishService.dashboardSummary()).thenReturn(publish);
when(curationService.findOutperformers(5, BigDecimal.ONE)).thenReturn(outperformers); when(curationService.findOutperformers(5, BigDecimal.ONE)).thenReturn(outperformers);
Map<String, Object> result = dashboardService.summary(); Map<String, Object> result = dashboardService.summary();
assertThat(result).containsOnlyKeys("pipeline", "categories", "publish", "outperformers"); assertThat(result).containsOnlyKeys("pipeline", "categories", "outperformers");
assertThat(result.get("pipeline")).isSameAs(pipeline); assertThat(result.get("pipeline")).isSameAs(pipeline);
assertThat(result.get("categories")).isSameAs(categories); assertThat(result.get("categories")).isSameAs(categories);
assertThat(result.get("publish")).isSameAs(publish);
assertThat(result.get("outperformers")).isSameAs(outperformers); assertThat(result.get("outperformers")).isSameAs(outperformers);
} }
} }