feat(dashboard): add full-set pipeline summary with single endpoint

Add GET /api/dashboard/summary aggregating pipeline status, category
distribution, publish summary, and outperformers in one call. Rewrite
dashboard.html with 5 KPI cards, pipeline funnel, publish status, and
category/source-format breakdowns (CSS bars, no chart lib).

Backend: ChannelVideoRepository counts (shorts/uncategorized),
PublishPackageRepository.countByStatus, pipelineStats shorts/longForm,
CategoryService.distribution, PublishService.dashboardSummary, new
DashboardService + DashboardApiController.

Fix: PublishService.list(null) hit UnsupportedOperationException because
findAll(Sort) uses Criteria, which rejects nullsLast precedence. Route the
no-status path through a @Query method so Sort is appended as HQL ORDER BY
(supports NULLS LAST). Also fixes the latent bug in /api/v1/publish all-list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-05-30 22:31:21 +09:00
parent 92ae575646
commit 2ec3915789
8 changed files with 296 additions and 71 deletions

View File

@ -63,4 +63,21 @@ public class CategoryService {
public long countVideos(Long categoryId) {
return channelVideoRepository.countByCategoryId(categoryId);
}
/** 대시보드용 카테고리 분포: 카테고리별 영상 수 + 미분류 수. */
public java.util.Map<String, Object> distribution() {
java.util.List<java.util.Map<String, Object>> cats = new java.util.ArrayList<>();
for (Category c : getAll()) {
java.util.Map<String, Object> m = new java.util.LinkedHashMap<>();
m.put("id", c.getId());
m.put("name", c.getName());
m.put("color", c.getColor());
m.put("count", channelVideoRepository.countByCategoryId(c.getId()));
cats.add(m);
}
java.util.Map<String, Object> result = new java.util.LinkedHashMap<>();
result.put("categories", cats);
result.put("uncategorized", channelVideoRepository.countByCategoryIdIsNull());
return result;
}
}

View File

@ -190,7 +190,12 @@ public class ChannelVideoCurationService {
/** 대시보드/칸반용 파이프라인 통계: 총계 + 상태별/출처별 분포. */
public java.util.Map<String, Object> pipelineStats() {
java.util.Map<String, Object> stats = new java.util.LinkedHashMap<>();
stats.put("total", channelVideoRepository.count());
long total = channelVideoRepository.count();
stats.put("total", total);
long shorts = channelVideoRepository.countByIsShortsTrue();
stats.put("shorts", shorts);
stats.put("longForm", Math.max(0, total - shorts));
java.util.Map<String, Long> byStatus = new java.util.LinkedHashMap<>();
for (String s : ALLOWED_STATUS) {

View File

@ -17,6 +17,8 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
long countByCategoryId(Long categoryId);
long countByInterestStatus(String interestStatus);
long countBySource(String source);
long countByIsShortsTrue();
long countByCategoryIdIsNull();
/** 떡상 후보: 구독자 대비 조회수 비율이 높은 Shorts (제외 처리된 것은 빼고). */
@Query("select v from ChannelVideo v where v.isShorts = true "

View File

@ -2,6 +2,7 @@ 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;
@ -9,4 +10,13 @@ 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

@ -55,12 +55,27 @@ public class PublishService {
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 : ALLOWED_STATUS) {
byStatus.put(s, repository.countByStatus(s));
}
List<PublishPackage> all = list(null);
List<PublishPackage> recent = all.size() > 5 ? 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.findAll(sort);
return repository.findAllSorted(sort);
}
}

View File

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

View File

@ -0,0 +1,28 @@
package com.hlab.yanalyst.web;
import com.hlab.yanalyst.global.common.ApiResponse;
import com.hlab.yanalyst.service.DashboardService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/dashboard")
@RequiredArgsConstructor
@Tag(name = "Dashboard API", description = "홈 대시보드 집계")
public class DashboardApiController {
private final DashboardService dashboardService;
@GetMapping("/summary")
@Operation(summary = "대시보드 요약",
description = "파이프라인(수집/상태/출처/포맷) + 카테고리 분포 + 발행 현황 + 떡상 후보 TOP5 를 한 번에 반환.")
public ApiResponse<Map<String, Object>> summary() {
return ApiResponse.ok(dashboardService.summary());
}
}

View File

@ -9,112 +9,222 @@
<body>
<div layout:fragment="content">
<header class="mb-4">
<h1 class="text-xl font-bold mb-4">Dashboard</h1>
<p class="text-muted">Overview of your YouTube analytics and tracking.</p>
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-bold mb-1">Dashboard</h1>
<p class="text-muted">수집 → 큐레이션 → 재가공 → 발행 파이프라인 현황</p>
</div>
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" onclick="loadDashboard()">
<i data-lucide="refresh-cw" style="width:16px;"></i> 새로고침
</button>
</div>
</header>
<!-- Stats Grid -->
<div class="flex gap-4 mb-4"
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
<!-- KPI 카드 -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1.25rem; margin-bottom: 1.5rem;">
<div class="card flex flex-col justify-between">
<div class="flex justify-between items-start mb-4">
<div>
<p class="text-sm text-muted">수집 영상</p>
<h3 class="text-xl font-bold" id="statTotal">-</h3>
</div>
<div style="padding: 0.5rem; border-radius: 8px; background: var(--bg-hover);">
<i data-lucide="library" color="var(--primary)"></i>
</div>
<div class="flex justify-between items-start mb-3">
<div><p class="text-sm text-muted">수집 영상</p><h3 class="text-xl font-bold" id="kTotal">-</h3></div>
<div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover);"><i data-lucide="library" color="var(--primary)"></i></div>
</div>
<div class="text-sm text-muted" id="statSourceCaption">채널 + 검색 수집</div>
<div class="text-sm text-muted" id="kTotalCap">채널 - · 검색 -</div>
</div>
<div class="card flex flex-col justify-between">
<div class="flex justify-between items-start mb-4">
<div>
<p class="text-sm text-muted">작업대상 (TARGET)</p>
<h3 class="text-xl font-bold" id="statTarget">-</h3>
</div>
<div style="padding: 0.5rem; border-radius: 8px; background: var(--bg-hover);">
<i data-lucide="clapperboard" color="var(--primary)"></i>
</div>
<div class="flex justify-between items-start mb-3">
<div><p class="text-sm text-muted">미검토 (NEW)</p><h3 class="text-xl font-bold" id="kNew">-</h3></div>
<div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover);"><i data-lucide="inbox" color="var(--primary)"></i></div>
</div>
<div class="text-sm text-muted" id="statReviewCaption">검토중 -</div>
<div class="text-sm text-muted" id="kReviewCap">검토중 -</div>
</div>
<div class="card flex flex-col justify-between">
<div class="flex justify-between items-start mb-4">
<div>
<p class="text-sm text-muted">미검토 (NEW)</p>
<h3 class="text-xl font-bold" id="statNew">-</h3>
</div>
<div style="padding: 0.5rem; border-radius: 8px; background: var(--bg-hover);">
<i data-lucide="inbox" color="var(--primary)"></i>
</div>
<div class="flex justify-between items-start mb-3">
<div><p class="text-sm text-muted">작업대상 (TARGET)</p><h3 class="text-xl font-bold" id="kTarget">-</h3></div>
<div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover);"><i data-lucide="clapperboard" color="var(--primary)"></i></div>
</div>
<div class="text-sm text-muted" id="statExcludedCaption">제외 -</div>
<div class="text-sm text-muted">재가공 대상</div>
</div>
<div class="card flex flex-col justify-between">
<div class="flex justify-between items-start mb-3">
<div><p class="text-sm text-muted">완료 (DONE)</p><h3 class="text-xl font-bold" id="kDone">-</h3></div>
<div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover);"><i data-lucide="check-circle" color="var(--primary)"></i></div>
</div>
<div class="text-sm text-muted" id="kExcludedCap">제외 -</div>
</div>
<div class="card flex flex-col justify-between">
<div class="flex justify-between items-start mb-3">
<div><p class="text-sm text-muted">발행완료</p><h3 class="text-xl font-bold" id="kPublished">-</h3></div>
<div style="padding:0.5rem; border-radius:8px; background:var(--bg-hover);"><i data-lucide="send" color="var(--primary)"></i></div>
</div>
<div class="text-sm text-muted" id="kPublishCap">대기 - · 작성중 -</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem;">
<!-- 파이프라인 깔때기 -->
<div class="card mb-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold">파이프라인 현황</h3>
<span class="text-sm text-muted" id="funnelExcluded"></span>
</div>
<div id="funnel" class="flex flex-col gap-3">
<p class="text-muted text-sm">로딩 중...</p>
</div>
</div>
<!-- 떡상 / 발행 -->
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="card">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold">🚀 떡상 후보 TOP</h3>
<a th:href="@{/collection}" class="text-sm text-muted hover:text-white">수집함 →</a>
</div>
<div class="flex flex-col gap-2" id="opList">
<p class="text-muted text-sm">로딩 중...</p>
<a th:href="@{/discover}" class="text-sm text-muted hover:text-white">발굴 →</a>
</div>
<div class="flex flex-col gap-2" id="opList"><p class="text-muted text-sm">로딩 중...</p></div>
</div>
<div class="card">
<h3 class="text-lg font-bold mb-4">수집 출처</h3>
<div class="flex flex-col gap-2" id="sourceList">
<p class="text-muted text-sm">로딩 중...</p>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold">발행 현황</h3>
<a th:href="@{/publish}" class="text-sm text-muted hover:text-white">발행 큐 →</a>
</div>
<div id="publishBars" class="flex flex-col gap-2 mb-3"></div>
<div id="publishRecent" class="flex flex-col gap-1"></div>
</div>
</div>
<!-- 카테고리 / 출처·포맷 -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem;">
<div class="card">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold">카테고리 분포</h3>
<a th:href="@{/collection}" class="text-sm text-muted hover:text-white">수집함 →</a>
</div>
<div id="catList" class="flex flex-col gap-2"><p class="text-muted text-sm">로딩 중...</p></div>
</div>
<div class="card">
<h3 class="text-lg font-bold mb-4">수집 출처 &amp; 포맷</h3>
<div id="sourceFormat" class="flex flex-col gap-2"><p class="text-muted text-sm">로딩 중...</p></div>
</div>
</div>
<style>
.bar-track { flex:1; height:10px; background:rgba(255,255,255,0.06); border-radius:999px; overflow:hidden; }
.bar-fill { height:100%; border-radius:999px; }
.st-badge { font-size:0.65rem; padding:1px 7px; border-radius:999px; font-weight:bold; }
</style>
<script th:inline="javascript">
/*<![CDATA[*/
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
async function getData(url){ const r = await fetch(url); const j = await r.json().catch(()=>({})); return j.data; }
function fmt(n){ return (n==null) ? '0' : Number(n).toLocaleString(); }
function pct(n, total){ return total > 0 ? Math.round((Number(n||0)/total)*100) : 0; }
(async ()=>{
try {
const s = await getData('/api/v1/channel-videos/stats');
if(s){
document.getElementById('statTotal').textContent = Number(s.total||0).toLocaleString();
const bs = s.byStatus||{}, src = s.bySource||{};
document.getElementById('statTarget').textContent = Number(bs.TARGET||0).toLocaleString();
document.getElementById('statNew').textContent = Number(bs.NEW||0).toLocaleString();
document.getElementById('statReviewCaption').textContent = '검토중 ' + (bs.REVIEWING||0);
document.getElementById('statExcludedCaption').textContent = '제외 ' + (bs.EXCLUDED||0);
document.getElementById('statSourceCaption').textContent = '채널 ' + (src.CHANNEL||0) + ' · 검색 ' + (src.SEARCH||0);
document.getElementById('sourceList').innerHTML =
'<div class="flex items-center justify-between p-3" style="border-radius:var(--radius-md); background:rgba(255,255,255,0.03);"><span class="text-sm">채널 수집</span><span class="text-sm font-bold">'+(src.CHANNEL||0)+'</span></div>' +
'<div class="flex items-center justify-between p-3" style="border-radius:var(--radius-md); background:rgba(255,255,255,0.03);"><span class="text-sm">검색 수집</span><span class="text-sm font-bold">'+(src.SEARCH||0)+'</span></div>';
}
} catch(e){ console.error(e); }
async function getData(url){
const r = await fetch(url);
const j = await r.json().catch(()=>({}));
if(!r.ok || (j && j.success===false)) throw new Error((j && j.message) || ('HTTP '+r.status));
return j.data;
}
try {
const op = await getData('/api/v1/channel-videos/outperformers?limit=5&minRatio=1') || [];
const list = document.getElementById('opList');
if(op.length===0){ list.innerHTML = '<p class="text-muted text-sm">아직 떡상 후보가 없습니다. 채널을 수집해 보세요.</p>'; return; }
list.innerHTML = op.map(v=>{
function bar(label, count, total, color){
const p = pct(count, total);
return `<div class="flex items-center gap-3">
<span class="text-sm" style="width:92px; flex-shrink:0;">${esc(label)}</span>
<div class="bar-track"><div class="bar-fill" style="width:${p}%; background:${color};"></div></div>
<span class="text-sm font-bold" style="width:70px; text-align:right; flex-shrink:0;">${fmt(count)} <span class="text-muted" style="font-weight:normal;">(${p}%)</span></span>
</div>`;
}
async function loadDashboard(){
let d;
try { d = await getData('/api/dashboard/summary'); }
catch(e){
document.getElementById('funnel').innerHTML = '<p class="text-danger text-sm">불러오기 실패: '+esc(e.message)+'</p>';
return;
}
const pipe = d.pipeline || {};
const bs = pipe.byStatus || {}, src = pipe.bySource || {};
const total = Number(pipe.total || 0);
const pub = d.publish || {}, pbs = pub.byStatus || {};
// KPI
document.getElementById('kTotal').textContent = fmt(total);
document.getElementById('kTotalCap').textContent = '채널 ' + fmt(src.CHANNEL) + ' · 검색 ' + fmt(src.SEARCH);
document.getElementById('kNew').textContent = fmt(bs.NEW);
document.getElementById('kReviewCap').textContent = '검토중 ' + fmt(bs.REVIEWING);
document.getElementById('kTarget').textContent = fmt(bs.TARGET);
document.getElementById('kDone').textContent = fmt(bs.DONE);
document.getElementById('kExcludedCap').textContent = '제외 ' + fmt(bs.EXCLUDED);
document.getElementById('kPublished').textContent = fmt(pbs.PUBLISHED);
document.getElementById('kPublishCap').textContent = '대기 ' + fmt(pbs.READY) + ' · 작성중 ' + fmt(pbs.DRAFT);
// 깔때기 (총 수집 대비)
document.getElementById('funnelExcluded').textContent = '총 ' + fmt(total) + '건 · 제외 ' + fmt(bs.EXCLUDED);
document.getElementById('funnel').innerHTML =
bar('미검토', bs.NEW, total, '#64748b') +
bar('검토중', bs.REVIEWING, total, '#38bdf8') +
bar('작업대상', bs.TARGET, total, '#f59e0b') +
bar('완료', bs.DONE, total, '#10b981') +
bar('발행완료', pbs.PUBLISHED, total, '#7C3AED');
// 떡상 TOP
const op = d.outperformers || [];
const opList = document.getElementById('opList');
if(op.length===0){ opList.innerHTML = '<p class="text-muted text-sm">아직 떡상 후보가 없습니다. 채널을 수집해 보세요.</p>'; }
else {
opList.innerHTML = op.map(v=>{
const ratio = v.viewsPerSubRatio!=null ? Number(v.viewsPerSubRatio).toFixed(1)+'x' : '-';
return '<a href="https://www.youtube.com/watch?v='+v.videoId+'" target="_blank" class="flex items-center justify-between p-3" style="border-radius:var(--radius-md); background:rgba(255,255,255,0.03); text-decoration:none;">' +
'<div class="flex items-center gap-2" style="min-width:0;">' +
'<img src="'+esc(v.thumbnailUrl)+'" style="width:48px;height:27px;object-fit:cover;border-radius:4px;flex-shrink:0;">' +
'<div style="min-width:0;"><div class="text-sm font-bold truncate" style="max-width:280px;">'+esc(v.title)+'</div>' +
'<div class="text-muted" style="font-size:0.7rem;">'+esc(v.channelTitle||'')+' · 조회 '+Number(v.viewCount||0).toLocaleString()+'</div></div>' +
'<div style="min-width:0;"><div class="text-sm font-bold truncate" style="max-width:300px;">'+esc(v.title)+'</div>' +
'<div class="text-muted" style="font-size:0.7rem;">'+esc(v.channelTitle||'')+' · 조회 '+fmt(v.viewCount)+'</div></div>' +
'</div>' +
'<span style="color:#ef4444; font-weight:bold; font-size:0.8rem; flex-shrink:0;">'+ratio+'</span></a>';
}).join('');
} catch(e){ console.error(e); }
})();
}
// 발행 현황
const ptotal = Number(pub.total || 0);
document.getElementById('publishBars').innerHTML =
bar('작성중', pbs.DRAFT, ptotal, '#64748b') +
bar('발행대기', pbs.READY, ptotal, '#f59e0b') +
bar('발행완료', pbs.PUBLISHED, ptotal, '#10b981');
const recent = pub.recent || [];
const PUB_ST = { DRAFT:{t:'작성중',c:'#64748b'}, READY:{t:'대기',c:'#f59e0b'}, PUBLISHED:{t:'완료',c:'#10b981'} };
document.getElementById('publishRecent').innerHTML = recent.length===0
? '<p class="text-muted text-sm" style="margin-top:4px;">발행 패키지가 없습니다.</p>'
: '<div class="text-sm text-muted" style="margin:6px 0 2px;">최근</div>' + recent.map(p=>{
const st = PUB_ST[p.status] || {t:p.status,c:'#64748b'};
return '<a href="/rework/'+p.channelVideoId+'" class="flex items-center justify-between p-2" style="border-radius:6px; background:rgba(255,255,255,0.03); text-decoration:none;">' +
'<span class="text-sm truncate" style="max-width:170px; color:#e2e8f0;">'+esc(p.title||'(제목 없음)')+'</span>' +
'<span class="st-badge" style="background:'+st.c+'22; color:'+st.c+'; flex-shrink:0;">'+st.t+'</span></a>';
}).join('');
// 카테고리 분포
const cats = (d.categories && d.categories.categories) || [];
const uncat = (d.categories && d.categories.uncategorized) || 0;
const catList = document.getElementById('catList');
if(cats.length===0 && !uncat){ catList.innerHTML = '<p class="text-muted text-sm">분류된 영상이 없습니다.</p>'; }
else {
catList.innerHTML = cats.map(c => bar(c.name, c.count, total, c.color || '#7C3AED')).join('') +
bar('미분류', uncat, total, '#475569');
}
// 출처 & 포맷
const shorts = Number(pipe.shorts || 0), longForm = Number(pipe.longForm || 0);
document.getElementById('sourceFormat').innerHTML =
'<div class="text-sm text-muted" style="margin-bottom:2px;">출처</div>' +
bar('채널 수집', src.CHANNEL, total, '#38bdf8') +
bar('검색 수집', src.SEARCH, total, '#a78bfa') +
'<div class="text-sm text-muted" style="margin:10px 0 2px;">포맷</div>' +
bar('Shorts', shorts, total, '#ef4444') +
bar('롱폼', longForm, total, '#10b981');
if(window.lucide) lucide.createIcons();
}
loadDashboard();
/*]]>*/
</script>
</div>
</body>
</html>
</html>