feat: 소재 중심 대시보드와 조회수 현행화

This commit is contained in:
hehihoho3@gmail.com 2026-08-16 07:11:07 +09:00
parent 381ec71291
commit 51fcfffffa
9 changed files with 181 additions and 297 deletions

View File

@ -27,6 +27,7 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
@ -50,6 +51,41 @@ public class ChannelService {
@Value("${python.base-url:http://h-python.tolag.shop}") // Python 마이크로서비스(자막/전사) 베이스 URL
private String pythonBaseUrl;
/** 저장된 피드 영상의 조회수·좋아요와 파생 지표를 videos.list 배치 응답으로 현행화한다. */
@Transactional
public int refreshVideoStats(List<ChannelVideo> videos) {
if (videos == null || videos.isEmpty()) return 0;
Map<String, ChannelVideo> byVideoId = videos.stream().collect(java.util.stream.Collectors.toMap(
ChannelVideo::getVideoId, v -> v, (left, right) -> left));
int updated = 0;
List<String> ids = new ArrayList<>(byVideoId.keySet());
for (int from = 0; from < ids.size(); from += 50) {
List<String> batch = ids.subList(from, Math.min(from + 50, ids.size()));
String apiUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos")
.queryParam("part", "statistics")
.queryParam("id", String.join(",", batch))
.queryParam("key", youtubeApiKey)
.toUriString();
JsonNode root = restTemplate.getForObject(apiUrl, JsonNode.class);
for (JsonNode item : root.path("items")) {
ChannelVideo video = byVideoId.get(item.path("id").asText());
if (video == null) continue;
JsonNode statistics = item.path("statistics");
long views = statistics.path("viewCount").asLong(video.getViewCount() == null ? 0 : video.getViewCount());
long likes = statistics.path("likeCount").asLong(video.getLikeCount() == null ? 0 : video.getLikeCount());
video.update(video.getTitle(), video.getThumbnailUrl(), views, likes);
video.applyMetrics(video.getDurationSec(), video.getIsShorts(),
VideoMetrics.viewsPerHour(views, video.getPublishedAt()));
video.applyFeedInfo(video.getYtChannelId(), video.getChannelTitle(), video.getSubscriberCount(),
VideoMetrics.viewsPerSubRatio(views, video.getSubscriberCount()),
video.getSource(), video.feedTopicOrDefault());
channelVideoRepository.save(video);
updated++;
}
}
return updated;
}
@Transactional
public Channel saveChannelFromUrl(String url) {
String identifier = extractIdentifier(url);

View File

@ -12,6 +12,10 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
java.util.List<ChannelVideo> findByChannelId(Long channelId);
java.util.List<ChannelVideo> findByChannelIdInOrderByPublishedAtDesc(java.util.List<Long> channelIds);
@Query("select v from ChannelVideo v where coalesce(v.feedTopic, 'ENTERTAINMENT') = :topic "
+ "and v.source in ('SOURCE','RIVAL') order by v.publishedAt desc")
java.util.List<ChannelVideo> findFeedVideosForRefresh(@Param("topic") String topic);
// --- 큐레이션/분석 조회 ---
java.util.List<ChannelVideo> findByCategoryId(Long categoryId);
long countByCategoryId(Long categoryId);

View File

@ -31,6 +31,7 @@ public class FeedCollectionService {
private static final long EST_UNITS_PER_CHANNEL = 2;
private final ChannelRepository channelRepository;
private final ChannelVideoRepository channelVideoRepository;
private final ChannelService channelService;
private final YoutubeQuotaGuard quotaGuard;
private final FeedAlertService feedAlertService;
@ -119,6 +120,25 @@ public class FeedCollectionService {
return summary;
}
/** 저장된 소재 피드 영상의 조회수만 저비용 videos.list 배치로 현행화한다. */
public Map<String, Object> refreshViews(FeedTopic topic) {
FeedTopic normalized = topic == null ? FeedTopic.ENTERTAINMENT : topic;
List<ChannelVideo> videos = channelVideoRepository.findFeedVideosForRefresh(normalized.name());
int batches = (videos.size() + 49) / 50;
if (!quotaGuard.tryConsume(batches)) {
throw new IllegalStateException("YouTube API 쿼터가 부족합니다. 필요 " + batches
+ " units · 잔여 " + quotaGuard.remaining() + " units");
}
int updated = channelService.refreshVideoStats(videos);
Map<String, Object> result = new LinkedHashMap<>();
result.put("requested", videos.size());
result.put("updated", updated);
result.put("quotaUsed", batches);
result.put("quotaRemaining", quotaGuard.remaining());
result.put("refreshedAt", LocalDateTime.now());
return result;
}
/**
* 수집 성공 표시(실패 카운터 리셋).
* 같은 빈 안에서 호출되므로 @Transactional 프록시가 적용되지 않는다 — 명시적으로 save 한다.

View File

@ -86,6 +86,13 @@ public class FeedController {
return ApiResponse.ok(feedCollectionService.collect(FeedTopic.normalize(topic)));
}
@PostMapping("/refresh-views")
@Operation(summary = "소재 조회수 현행화", description = "저장된 피드 영상 조회수를 videos.list 50개 배치로 갱신한다")
public ApiResponse<Map<String, Object>> refreshViews(
@RequestParam(defaultValue = "ENTERTAINMENT") String topic) {
return ApiResponse.ok(feedCollectionService.refreshViews(FeedTopic.normalize(topic)));
}
// --- 시드(소스/경쟁 채널) 관리 ---
@GetMapping("/seeds")

View File

@ -24,6 +24,7 @@ public record FeedItemDto(
String interestStatus,
boolean bookmarked,
String source,
String topic,
/** 인물 추적으로 걸렸다면 그 인물명. 인물 탭 카드에 배지로 표시한다. */
String matchedPerson,
/** 사이트 안에서 재생 가능한가. null 이면 아직 모름 — 일단 재생을 시도한다. */
@ -52,6 +53,7 @@ public record FeedItemDto(
v.getInterestStatus(),
v.isBookmarked(),
v.getSource(),
v.feedTopicOrDefault().name(),
v.getMatchedPerson(),
v.getEmbeddable(),
FeedBadges.isGoldenTime(v.getPublishedAt(), now),

View File

@ -3,6 +3,8 @@ 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.channel.FeedService;
import com.hlab.yanalyst.domain.channel.dto.FeedItemDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -23,6 +25,7 @@ public class DashboardService {
private final ChannelVideoCurationService curationService;
private final CategoryService categoryService;
private final FeedService feedService;
public Map<String, Object> summary() {
Map<String, Object> result = new LinkedHashMap<>();
@ -30,6 +33,23 @@ public class DashboardService {
result.put("categories", categoryService.distribution()); // categories[], uncategorized
List<ChannelVideo> outperformers = curationService.findOutperformers(5, BigDecimal.ONE);
result.put("outperformers", outperformers);
List<FeedItemDto> entertainment = feedService.feed("SOURCE", "ENTERTAINMENT", 14,
null, null, false, null);
List<FeedItemDto> politics = feedService.feed("SOURCE", "POLITICS", 14,
null, null, false, null);
List<FeedItemDto> all = new java.util.ArrayList<>(entertainment);
all.addAll(politics);
all.sort(java.util.Comparator.comparing(FeedItemDto::viewsPerHour,
java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder())));
Map<String, Object> feedDesk = new LinkedHashMap<>();
feedDesk.put("total", all.size());
feedDesk.put("entertainment", entertainment.size());
feedDesk.put("politics", politics.size());
feedDesk.put("golden", all.stream().filter(FeedItemDto::goldenTime).count());
feedDesk.put("unworked", all.stream().filter(v -> !v.worked()).count());
feedDesk.put("bookmarked", all.stream().filter(FeedItemDto::bookmarked).count());
feedDesk.put("top", all.stream().limit(10).toList());
result.put("feedDesk", feedDesk);
return result;
}
}

View File

@ -1,308 +1,76 @@
<!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>
layout:decorate="~{layout/base}">
<head><title>h-lab - 소재 데스크</title></head>
<body>
<div layout:fragment="content">
<div class="fd-dash">
<div class="fd-masthead">
<div>
<div class="fd-tick" style="margin-bottom:8px;">지금 할 일 중심 · 수집 → 큐레이션 → 재가공</div>
<h1>대시보드</h1>
</div>
<div class="fd-meta">
<div class="fd-tick">PIPELINE STATUS</div>
<div class="fd-big" id="fdDate">—</div>
<button class="fd-btn fd-sm" onclick="loadDashboard()" style="margin-top:8px;">
<i data-lucide="refresh-cw" style="width:13px;"></i> 새로고침
</button>
</div>
<div layout:fragment="content">
<main class="md-desk">
<header class="md-head">
<div>
<span class="md-kicker">MATERIAL DESK · 최근 14일</span>
<h1>오늘 만들 소재</h1>
<p>업로드 시점보다 지금 오르는 속도를 먼저 봅니다.</p>
</div>
<div class="fd-body fd-stagger">
<!-- ACTION BAR -->
<section class="fd-actions" style="animation-delay:.02s">
<a class="fd-act fd-hot" th:href="@{/board}">
<div class="fd-act-top">
<div class="fd-num" id="aNew">–</div>
<div class="fd-act-lab"><div class="t">미검토 영상</div><div class="s" id="aNewSub">NEW · 검토 대기</div></div>
</div>
<div class="fd-cta-row"><span class="fd-btn fd-sig">검토 시작 →</span></div>
</a>
<a class="fd-act" th:href="@{/discover}">
<div class="fd-act-top">
<div class="fd-num" id="aOut" style="color:var(--fd-signal)">–</div>
<div class="fd-act-lab"><div class="t">떡상 후보</div><div class="s" id="aOutSub">배율 상위</div></div>
</div>
<div class="fd-cta-row"><span class="fd-btn">발굴 열기 →</span></div>
</a>
<a class="fd-act" th:href="@{/collection(status='TARGET')}">
<div class="fd-act-top">
<div class="fd-num" id="aTarget" style="color:var(--fd-ink-3)">–</div>
<div class="fd-act-lab"><div class="t">재가공 대기</div><div class="s">TARGET</div></div>
</div>
<div class="fd-muted-cta" id="aTargetCta">발굴에서 후보를 TARGET으로 보내세요 →</div>
</a>
</section>
<!-- WORK SURFACE -->
<section class="fd-cols" style="animation-delay:.1s">
<div class="fd-panel">
<div class="fd-ph"><h3>떡상 후보 — 바로 처리</h3><a class="fd-tick" style="color:var(--fd-signal)" th:href="@{/discover}">발굴 전체 →</a></div>
<div class="fd-pb" id="leadList"><div class="fd-loading">불러오는 중…</div></div>
</div>
<div class="fd-panel">
<div class="fd-ph"><h3>파이프라인</h3><span class="fd-tick" id="pipeTotal">총 —</span></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-pb" id="srcFmt"></div>
</div>
</section>
<div class="md-actions">
<span id="updatedAt">조회 중</span>
<button class="md-btn" id="refreshViewsBtn" onclick="refreshViews()">조회수 현행화</button>
<button class="md-btn ghost" onclick="loadDashboard()">화면 새로고침</button>
</div>
</header>
<!-- 영상 미리보기 모달 -->
<div id="videoModal" class="fd-vmodal" onclick="if(event.target===this) closeVideoModal()">
<div class="fd-vmodal-card">
<div class="fd-vmodal-head">
<span class="fd-vmodal-title" id="vmTitle">미리보기</span>
<button class="fd-vmodal-x" onclick="closeVideoModal()" aria-label="닫기">&times;</button>
</div>
<div id="vmFrame" class="fd-vmodal-frame"></div>
</div>
<section class="md-score" aria-label="소재 요약">
<a href="/feed" class="md-score-main">
<span>검토할 소재</span><strong id="sUnworked">—</strong><small>아직 손대지 않은 원본</small>
</a>
<div class="md-metric hot"><span>24시간 이내</span><strong id="sGolden">—</strong><small>골든타임</small></div>
<div class="md-metric"><span>예능</span><strong id="sEntertainment">—</strong><small>최근 14일</small></div>
<div class="md-metric"><span>정치</span><strong id="sPolitics">—</strong><small>최근 14일</small></div>
<div class="md-metric"><span>북마크</span><strong id="sBookmarked">—</strong><small>다시 볼 소재</small></div>
</section>
<section class="md-board">
<div class="md-board-head">
<div><span class="md-kicker">VELOCITY RANK</span><h2>지금 빠르게 오르는 소재</h2></div>
<div class="md-tabs"><a href="/feed">예능 피드</a><a href="/politics-feed">정치 피드</a></div>
</div>
</div>
<div class="md-columns" aria-hidden="true"><span>순위 / 영상</span><span>업로드</span><span>조회수</span><span>시간당</span></div>
<div id="materialList" class="md-list"><div class="md-empty">소재 데이터를 불러오는 중입니다.</div></div>
</section>
<style>
/* ===== Editorial identity — scoped to .fd-dash ===== */
.fd-dash{
--fd-paper:#f1ede3; --fd-panel:#fbfaf6; --fd-ink:#17160f; --fd-ink-2:#6f6a5c; --fd-ink-3:#a8a293;
--fd-rule:#ddd7c8; --fd-rule-strong:#17160f; --fd-signal:#ff4d23; --fd-signal-soft:#ffe6df; --fd-lime:#1f7a3a;
--fd-disp:'Bricolage Grotesque', sans-serif; --fd-body:'Hanken Grotesk', sans-serif; --fd-mono:'JetBrains Mono', monospace;
font-family:var(--fd-body); color:var(--fd-ink);
margin:-2.5rem; padding:0; min-height:100vh; background:var(--fd-paper); position:relative;
}
:root[data-theme="dark"] .fd-dash{
--fd-paper:#100f0c; --fd-panel:#191712; --fd-ink:#eceae2; --fd-ink-2:#a39d8d; --fd-ink-3:#6b6657;
--fd-rule:#2b281f; --fd-rule-strong:#3a362a;
}
.fd-dash::before{ content:""; position:absolute; inset:0; pointer-events:none; opacity:.05;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); }
.fd-dash a{ text-decoration:none; color:inherit; }
.fd-tick{ font-family:var(--fd-mono); font-size:10.5px; font-weight:500; letter-spacing:.18em; text-transform:uppercase; color:var(--fd-ink-3); }
<footer class="md-foot">
<span>조회수 현행화는 YouTube videos.list를 50개씩 묶어 호출합니다.</span>
<a href="/board">선택한 소재 검토하기 →</a>
</footer>
</main>
.fd-masthead{ display:flex; justify-content:space-between; align-items:flex-end; padding:24px 34px 16px; border-bottom:1.5px solid var(--fd-rule-strong); position:relative; }
.fd-masthead h1{ font-family:var(--fd-disp); font-weight:800; font-size:32px; letter-spacing:-.025em; line-height:.95; }
.fd-meta{ text-align:right; }
.fd-meta .fd-big{ font-family:var(--fd-mono); font-weight:700; font-size:13px; }
<style>
.md-desk{--navy:#102a43;--blue:#176b87;--cyan:#34b3c0;--ice:#e8f4f5;--paper:#f7faf9;--line:#ccdadd;--ink:#16252d;--muted:#6c7d83;margin:-2.5rem;min-height:100vh;padding:34px;background:linear-gradient(125deg,#f8fbfa 0%,#edf5f5 62%,#e4f0f2 100%);color:var(--ink);font-family:'Hanken Grotesk',sans-serif}
:root[data-theme="dark"] .md-desk{--paper:#142228;--line:#30434a;--ink:#edf6f6;--muted:#92a6ac;--ice:#17343c;background:#0e1a20}
.md-head{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:24px;border-bottom:3px solid var(--navy)}
.md-kicker{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.16em;color:var(--blue)}
.md-head h1{font-family:'Bricolage Grotesque',sans-serif;font-size:42px;line-height:1;margin:8px 0 6px;letter-spacing:-.04em}.md-head p{color:var(--muted);margin:0}
.md-actions{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--muted)}
.md-btn{border:1px solid var(--navy);background:var(--navy);color:white;padding:9px 13px;font-weight:700;cursor:pointer}.md-btn.ghost{background:transparent;color:var(--ink)}.md-btn:disabled{opacity:.5;cursor:wait}
.md-score{display:grid;grid-template-columns:1.45fr repeat(4,1fr);margin:24px 0;border:1px solid var(--navy);background:var(--paper)}
.md-score>a{text-decoration:none;color:inherit}.md-score-main,.md-metric{padding:20px;border-right:1px solid var(--line);display:flex;flex-direction:column}.md-metric:last-child{border-right:0}.md-score-main{background:var(--navy);color:white}.md-score span{font-size:12px;font-weight:700}.md-score strong{font-family:'Bricolage Grotesque',sans-serif;font-size:38px;line-height:1;margin:12px 0 7px}.md-score small{color:var(--muted)}.md-score-main small{color:#bdd3dc}.md-metric.hot strong{color:#e85636}
.md-board{border:1px solid var(--navy);background:var(--paper)}.md-board-head{display:flex;justify-content:space-between;align-items:flex-end;padding:18px 20px;border-bottom:1px solid var(--navy)}.md-board-head h2{font-size:20px;margin:5px 0 0}.md-tabs{display:flex;gap:14px}.md-tabs a{font-size:12px;font-weight:700;color:var(--blue)}
.md-columns,.md-row{display:grid;grid-template-columns:minmax(320px,1fr) 110px 110px 110px;gap:14px;align-items:center}.md-columns{padding:8px 20px;background:var(--ice);font:10px 'JetBrains Mono',monospace;color:var(--muted);text-transform:uppercase}.md-row{padding:12px 20px;border-top:1px solid var(--line);text-decoration:none;color:inherit}.md-row:hover{background:var(--ice)}
.md-video{display:grid;grid-template-columns:28px 96px minmax(0,1fr);gap:12px;align-items:center}.md-rank{font:800 20px 'Bricolage Grotesque',sans-serif;color:var(--blue)}.md-thumb{width:96px;aspect-ratio:16/9;object-fit:cover}.md-title{font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.md-sub{font-size:11px;color:var(--muted);margin-top:4px}.md-cell{font:12px 'JetBrains Mono',monospace}.md-speed{font-weight:800;color:var(--blue)}.md-tag{display:inline-block;padding:2px 6px;margin-right:6px;background:var(--ice);color:var(--blue);font-size:10px;font-weight:800}.md-tag.politics{background:#f4e9e5;color:#a1442e}
.md-empty{padding:36px;text-align:center;color:var(--muted)}.md-foot{display:flex;justify-content:space-between;padding:14px 2px;color:var(--muted);font-size:11px}.md-foot a{color:var(--blue);font-weight:700}
@media(max-width:900px){.md-desk{margin:-1.25rem;padding:18px}.md-head{align-items:flex-start;gap:18px}.md-actions{flex-wrap:wrap;justify-content:flex-end}.md-score{grid-template-columns:repeat(2,1fr)}.md-score-main{grid-column:1/-1}.md-metric{border-top:1px solid var(--line)}.md-columns{display:none}.md-row{grid-template-columns:1fr 90px}.md-row .md-cell:nth-child(2),.md-row .md-cell:nth-child(3){display:none}}
@media(max-width:600px){.md-head{display:block}.md-head h1{font-size:34px}.md-actions{margin-top:16px;justify-content:flex-start}.md-score{grid-template-columns:1fr 1fr}.md-video{grid-template-columns:24px 72px minmax(0,1fr)}.md-thumb{width:72px}.md-row{padding:11px}.md-foot{display:block}.md-foot a{display:block;margin-top:8px}}
</style>
.fd-body{ padding:24px 34px 40px; position:relative; }
.fd-stagger > *{ opacity:0; transform:translateY(10px); animation:fdRise .7s cubic-bezier(.2,.7,.2,1) forwards; }
@keyframes fdRise{ to{ opacity:1; transform:none; } }
@media (prefers-reduced-motion: reduce){ .fd-stagger > *{ animation:none; opacity:1; transform:none; } }
.fd-btn{ display:inline-flex; align-items:center; gap:7px; font-family:var(--fd-mono); font-weight:700; font-size:11.5px; letter-spacing:.04em; padding:8px 13px; border:1.5px solid var(--fd-rule-strong); background:var(--fd-panel); color:var(--fd-ink); cursor:pointer; }
.fd-btn:hover{ background:var(--fd-ink); color:var(--fd-paper); }
.fd-btn.fd-sig{ background:var(--fd-signal); border-color:var(--fd-signal); color:#fff; }
.fd-btn.fd-sig:hover{ background:#e23c14; color:#fff; }
.fd-btn.fd-sm{ padding:5px 9px; font-size:10.5px; }
.fd-actions{ display:grid; grid-template-columns:1.3fr 1fr 1fr; border:1.5px solid var(--fd-rule-strong); background:var(--fd-panel); margin-bottom:22px; }
.fd-act{ padding:20px 22px; border-right:1.5px solid var(--fd-rule-strong); display:flex; flex-direction:column; gap:12px; position:relative; overflow:hidden; }
.fd-act:last-child{ border-right:none; }
.fd-act:hover{ background:#f3eee2; }
:root[data-theme="dark"] .fd-act:hover{ background:#211e17; }
.fd-act-top{ display:flex; align-items:baseline; gap:12px; }
.fd-num{ font-family:var(--fd-disp); font-weight:800; font-size:60px; line-height:.8; letter-spacing:-.04em; font-variant-numeric:tabular-nums; }
.fd-act-lab .t{ font-weight:700; font-size:14px; }
.fd-act-lab .s{ font-family:var(--fd-mono); font-size:10.5px; color:var(--fd-ink-3); margin-top:3px; }
.fd-hot::after{ content:""; position:absolute; right:-60px; top:-60px; width:200px; height:200px; border-radius:50%; background:radial-gradient(circle, rgba(255,77,35,.14), transparent 62%); }
.fd-cta-row{ display:flex; gap:8px; z-index:1; }
.fd-muted-cta{ font-family:var(--fd-mono); font-size:10.5px; color:var(--fd-ink-3); line-height:1.5; }
.fd-cols{ display:grid; grid-template-columns:1.55fr 1fr; gap:22px; align-items:start; }
.fd-panel{ border:1.5px solid var(--fd-rule-strong); background:var(--fd-panel); }
.fd-ph{ display:flex; justify-content:space-between; align-items:center; padding:13px 20px; border-bottom:1.5px solid var(--fd-rule-strong); }
.fd-ph h3{ font-family:var(--fd-disp); font-weight:700; font-size:15px; letter-spacing:-.01em; }
.fd-pb{ padding:4px 20px 12px; }
.fd-loading{ font-family:var(--fd-mono); font-size:11px; color:var(--fd-ink-3); padding:20px 0; text-align:center; }
.fd-lead{ display:grid; grid-template-columns:30px 52px 1fr auto auto; gap:13px; align-items:center; padding:11px 0; border-bottom:1px solid var(--fd-rule); }
.fd-lead:last-child{ border-bottom:none; }
.fd-lead:hover{ background:#f3eee2; }
:root[data-theme="dark"] .fd-lead:hover{ background:#211e17; }
.fd-rk{ font-family:var(--fd-disp); font-weight:800; font-size:23px; line-height:1; font-variant-numeric:tabular-nums; }
.fd-lead:first-child .fd-rk{ color:var(--fd-signal); }
.fd-lead .fd-th{ width:52px; height:30px; object-fit:cover; background:#e7e1d2; border:1px solid var(--fd-rule); }
.fd-lead .fd-t{ font-weight:600; font-size:12.5px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:230px; }
.fd-lead .fd-s{ font-family:var(--fd-mono); font-size:10px; color:var(--fd-ink-3); margin-top:2px; }
.fd-ratio{ font-family:var(--fd-disp); font-weight:800; font-size:17px; color:var(--fd-signal); font-variant-numeric:tabular-nums; }
.fd-actcell{ display:flex; gap:6px; opacity:.4; transition:opacity .15s; }
.fd-lead:hover .fd-actcell{ opacity:1; }
.fd-fbar{ margin-bottom:11px; }
.fd-fbar .top{ display:flex; justify-content:space-between; align-items:baseline; margin-bottom:5px; }
.fd-fbar .top .k{ font-size:12px; font-weight:600; }
.fd-fbar .top .v{ font-family:var(--fd-mono); font-size:11px; color:var(--fd-ink-2); }
.fd-fbar .track{ height:8px; background:var(--fd-rule); border:1px solid var(--fd-rule); position:relative; }
.fd-fbar .track > i{ position:absolute; left:0; top:0; bottom:0; background:var(--fd-ink); }
.fd-fbar.fd-sigbar .track > i{ background:var(--fd-signal); }
.fd-bottleneck{ display:flex; justify-content:space-between; align-items:center; margin-top:12px; padding-top:12px; border-top:1px dashed var(--fd-rule-strong); }
.fd-bottleneck .msg{ font-family:var(--fd-mono); font-size:10.5px; color:var(--fd-ink-2); }
.fd-bottleneck .msg b{ color:var(--fd-signal); }
.fd-row{ display:flex; justify-content:space-between; align-items:center; padding:9px 0; border-bottom:1px solid var(--fd-rule); font-size:12.5px; }
.fd-row:last-child{ border-bottom:none; }
.fd-row .v{ font-family:var(--fd-mono); color:var(--fd-ink-2); }
.fd-row .v b{ color:var(--fd-ink); }
.fd-empty{ font-family:var(--fd-mono); font-size:11px; color:var(--fd-ink-3); padding:14px 0; }
/* 영상 미리보기 모달 */
.fd-vmodal{ display:none; position:fixed; inset:0; z-index:1000; background:rgba(10,10,12,.86); align-items:center; justify-content:center; padding:20px; }
.fd-vmodal.open{ display:flex; }
.fd-vmodal-card{ background:var(--fd-panel); border:1.5px solid var(--fd-rule-strong); box-shadow:0 20px 60px rgba(0,0,0,.4); }
.fd-vmodal-head{ display:flex; justify-content:space-between; align-items:center; gap:14px; padding:11px 14px; border-bottom:1.5px solid var(--fd-rule-strong); }
.fd-vmodal-title{ font-weight:600; font-size:13px; color:var(--fd-ink); max-width:300px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.fd-vmodal-x{ background:none; border:none; font-size:24px; line-height:1; cursor:pointer; color:var(--fd-ink-2); padding:0 2px; }
.fd-vmodal-x:hover{ color:var(--fd-ink); }
.fd-vmodal-frame{ width:min(360px,90vw); aspect-ratio:9/16; max-height:78vh; background:#000; }
.fd-vmodal-frame iframe{ width:100%; height:100%; border:0; display:block; }
@media (max-width:1100px){ .fd-cols{ grid-template-columns:1fr; } .fd-actions{ grid-template-columns:1fr; } .fd-act{ border-right:none; border-bottom:1.5px solid var(--fd-rule-strong); } }
@media (max-width:680px){
.fd-dash{ margin:-1.25rem; }
.fd-masthead{ flex-direction:column; align-items:stretch; gap:8px; padding:16px 16px 12px; }
.fd-masthead .fd-tick{ display:none; } /* 긴 브레드크럼/라벨 숨김 */
.fd-masthead h1{ font-size:26px; }
.fd-meta{ display:flex; align-items:center; gap:12px; }
.fd-meta .fd-big{ margin:0; }
.fd-body{ padding:16px 16px 28px; }
.fd-num{ font-size:44px; }
.fd-act{ padding:16px 16px; }
/* 리더보드 행: 배율까지 한 줄, 액션 버튼은 아랫줄 우측 정렬 */
.fd-lead{ grid-template-columns:24px 46px 1fr auto; column-gap:10px; }
.fd-lead .fd-actcell{ grid-column:1 / -1; justify-content:flex-end; opacity:1; padding-top:2px; }
.fd-lead .fd-t{ white-space:normal; max-width:none; }
}
</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;'); }
function fmt(n){ return (n==null)?'0':Number(n).toLocaleString(); }
function pct(n,t){ return t>0?Math.round((Number(n||0)/t)*100):0; }
async function getData(url, opts){
const r = await fetch(url, opts);
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;
}
(function(){ const d=new Date(); const p=n=>String(n).padStart(2,'0');
document.getElementById('fdDate').textContent = d.getFullYear()+' · '+p(d.getMonth()+1)+' · '+p(d.getDate()); })();
function fbar(label, count, total, signal){
const p = pct(count,total);
return `<div class="fd-fbar ${signal?'fd-sigbar':''}"><div class="top"><span class="k">${esc(label)}</span><span class="v">${fmt(count)} · ${p}%</span></div><div class="track"><i style="width:${p}%"></i></div></div>`;
}
async function loadDashboard(){
let d;
try { d = await getData('/api/dashboard/summary'); }
catch(e){ document.getElementById('leadList').innerHTML = '<div class="fd-empty" style="color:var(--fd-signal)">불러오기 실패: '+esc(e.message)+'</div>'; return; }
const pipe = d.pipeline||{}, bs = pipe.byStatus||{}, src = pipe.bySource||{};
const total = Number(pipe.total||0);
const op = d.outperformers||[];
// ----- 액션 바 -----
document.getElementById('aNew').textContent = fmt(bs.NEW);
document.getElementById('aNewSub').textContent = 'NEW · 검토 대기' + (bs.REVIEWING?(' · 검토중 '+fmt(bs.REVIEWING)):'');
document.getElementById('aOut').textContent = op.length ? fmt(op.length) : '0';
document.getElementById('aOutSub').textContent = op.length ? ('배율 상위 · 최고 '+(op[0].viewsPerSubRatio!=null?Number(op[0].viewsPerSubRatio).toFixed(0)+'×':'-')) : '아직 없음';
document.getElementById('aTarget').textContent = fmt(bs.TARGET);
document.getElementById('aTargetCta').textContent = Number(bs.TARGET||0)>0 ? '재가공 작업공간에서 이어서 →' : '발굴에서 후보를 TARGET으로 보내세요 →';
// ----- 떡상 리더보드 (실데이터 + 액션) -----
const lead = document.getElementById('leadList');
if(op.length===0){ lead.innerHTML = '<div class="fd-empty">아직 떡상 후보가 없습니다. 채널을 수집하면 배율 상위 영상이 여기 모입니다.</div>'; }
else {
lead.innerHTML = op.slice(0,6).map((v,i)=>{
const ratio = v.viewsPerSubRatio!=null ? Number(v.viewsPerSubRatio).toFixed(0)+'×' : '–';
return `<div class="fd-lead" data-id="${v.id}">
<div class="fd-rk">${i+1}</div>
<img class="fd-th" src="${esc(v.thumbnailUrl)}" alt="" title="미리보기" style="cursor:pointer;"
data-vid="${v.videoId}" data-title="${esc(v.title)}" onclick="openVideoModal(this.dataset.vid, this.dataset.title)">
<div style="min-width:0;">
<div class="fd-t"><a href="https://www.youtube.com/watch?v=${v.videoId}" target="_blank">${esc(v.title)}</a></div>
<div class="fd-s">SHORTS · ${esc(v.channelTitle||'')} · ${fmt(v.viewCount)}</div>
</div>
<div class="fd-ratio">${ratio}</div>
<div class="fd-actcell">
<a class="fd-btn fd-sm fd-sig" href="/rework/${v.id}">재가공 →</a>
<button class="fd-btn fd-sm" onclick="excludeVideo(${v.id})">제외</button>
</div>
</div>`;
}).join('');
}
// ----- 파이프라인 + 병목 -----
document.getElementById('pipeTotal').textContent = '총 ' + fmt(total);
const stages = [
['미검토', bs.NEW], ['검토중', bs.REVIEWING], ['작업대상', bs.TARGET],
['완료', bs.DONE]
];
let bottleneck = stages[0];
for(const s of stages){ if(Number(s[1]||0) > Number(bottleneck[1]||0)) bottleneck = s; }
document.getElementById('funnel').innerHTML =
fbar('미검토', bs.NEW, total, true) +
fbar('검토중', bs.REVIEWING, total) +
fbar('작업대상', bs.TARGET, total) +
fbar('완료', bs.DONE, 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>`;
// ----- 출처/포맷 -----
document.getElementById('srcFmt').innerHTML =
`<div class="fd-row"><span>채널 수집</span><span class="v"><b>${fmt(src.CHANNEL)}</b> · ${pct(src.CHANNEL,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>`;
if(window.lucide) lucide.createIcons();
}
async function excludeVideo(id){
if(!confirm('이 영상을 제외(EXCLUDED) 처리할까요? 떡상 후보에서 사라집니다.')) return;
try {
await getData('/api/v1/channel-videos/'+id+'/status', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({status:'EXCLUDED'}) });
loadDashboard();
} catch(e){ alert('제외 실패: '+e.message); }
}
// ----- 영상 미리보기 (수집함과 동일) -----
function openVideoModal(vid, title){
document.getElementById('vmTitle').textContent = title || '미리보기';
document.getElementById('vmFrame').innerHTML =
'<iframe src="https://www.youtube.com/embed/' + vid + '?autoplay=1" allow="autoplay; encrypted-media" allowfullscreen></iframe>';
document.getElementById('videoModal').classList.add('open');
}
function closeVideoModal(){
document.getElementById('vmFrame').innerHTML = '';
document.getElementById('videoModal').classList.remove('open');
}
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeVideoModal(); });
loadDashboard();
/*]]>*/
</script>
</div>
<script>
const fmt=n=>Number(n||0).toLocaleString();
const esc=s=>String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const ago=iso=>{if(!iso)return'-';const h=Math.max(0,Math.floor((Date.now()-new Date(iso))/36e5));return h<24?h+'시간 전':Math.floor(h/24)+'일 전'};
async function api(url,opts){const r=await fetch(url,opts);const j=await r.json();if(!r.ok||j.success===false)throw new Error(j.message||('HTTP '+r.status));return j.data}
function renderRow(v,i){const topic=v.topic==='POLITICS'?'정치':'예능';const cls=v.topic==='POLITICS'?' politics':'';const href=v.topic==='POLITICS'?'/politics-feed':'/feed';return `<a class="md-row" href="${href}"><div class="md-video"><span class="md-rank">${String(i+1).padStart(2,'0')}</span><img class="md-thumb" src="${esc(v.thumbnailUrl)}" alt=""><div><div class="md-title">${esc(v.title)}</div><div class="md-sub"><span class="md-tag${cls}">${topic}</span>${esc(v.channelTitle||'')} ${v.goldenTime?'· 지금 선점':''}</div></div></div><span class="md-cell">${ago(v.publishedAt)}</span><span class="md-cell">${fmt(v.viewCount)}</span><span class="md-cell md-speed">${fmt(Math.round(Number(v.viewsPerHour||0)))}/h</span></a>`}
async function loadDashboard(){try{const d=await api('/api/dashboard/summary');const f=d.feedDesk||{};sUnworked.textContent=fmt(f.unworked);sGolden.textContent=fmt(f.golden);sEntertainment.textContent=fmt(f.entertainment);sPolitics.textContent=fmt(f.politics);sBookmarked.textContent=fmt(f.bookmarked);materialList.innerHTML=(f.top||[]).length?(f.top||[]).map(renderRow).join(''):'<div class="md-empty">최근 14일 소재가 없습니다. 소재 피드에서 수집을 실행하세요.</div>';updatedAt.textContent='화면 갱신 '+new Date().toLocaleTimeString('ko-KR',{hour:'2-digit',minute:'2-digit'});}catch(e){materialList.innerHTML='<div class="md-empty">불러오기 실패 · '+esc(e.message)+'</div>'}}
async function refreshViews(){const b=refreshViewsBtn;b.disabled=true;b.textContent='조회수 갱신 중';try{const [e,p]=await Promise.all([api('/api/feed/refresh-views?topic=ENTERTAINMENT',{method:'POST'}),api('/api/feed/refresh-views?topic=POLITICS',{method:'POST'})]);updatedAt.textContent=`${fmt(e.updated+p.updated)}개 갱신 완료`;await loadDashboard()}catch(err){alert('조회수 갱신 실패: '+err.message)}finally{b.disabled=false;b.textContent='조회수 현행화'}}
loadDashboard();
</script>
</div>
</body>
</html>

View File

@ -21,6 +21,9 @@
<button class="btn btn-secondary" onclick="openSeeds()">
<i data-lucide="list-plus" style="width:15px;"></i> 시드 관리
</button>
<button class="btn btn-secondary" id="refreshViewsBtn" onclick="refreshViews()">
<i data-lucide="bar-chart-3" style="width:15px;"></i> 조회수 현행화
</button>
<button class="btn btn-primary" id="collectBtn" onclick="runCollect()">
<i data-lucide="refresh-cw" style="width:15px;"></i> 지금 수집
</button>
@ -806,6 +809,25 @@
}
// ---------- 수집 ----------
async function refreshViews(){
const btn = document.getElementById('refreshViewsBtn');
const original = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" style="width:15px;" class="animate-spin"></i> 갱신 중...';
if(window.lucide) lucide.createIcons();
try {
const r = await api(API + '/refresh-views?topic=' + FEED_TOPIC, { method:'POST' });
toast(`조회수 현행화 완료 — ${r.updated}개 · 쿼터 ${r.quotaUsed} units`);
await loadFeed();
} catch(e) {
toast('조회수 현행화 실패: ' + e.message, true);
} finally {
btn.disabled = false;
btn.innerHTML = original;
if(window.lucide) lucide.createIcons();
}
}
async function runCollect(){
const btn = document.getElementById('collectBtn');
btn.disabled = true;

View File

@ -3,6 +3,7 @@ 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.channel.FeedService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@ -25,6 +26,7 @@ class DashboardServiceTest {
@Mock ChannelVideoCurationService curationService;
@Mock CategoryService categoryService;
@Mock FeedService feedService;
@InjectMocks DashboardService dashboardService;
@ -37,12 +39,15 @@ class DashboardServiceTest {
when(curationService.pipelineStats()).thenReturn(pipeline);
when(categoryService.distribution()).thenReturn(categories);
when(curationService.findOutperformers(5, BigDecimal.ONE)).thenReturn(outperformers);
when(feedService.feed("SOURCE", "ENTERTAINMENT", 14, null, null, false, null)).thenReturn(List.of());
when(feedService.feed("SOURCE", "POLITICS", 14, null, null, false, null)).thenReturn(List.of());
Map<String, Object> result = dashboardService.summary();
assertThat(result).containsOnlyKeys("pipeline", "categories", "outperformers");
assertThat(result).containsOnlyKeys("pipeline", "categories", "outperformers", "feedDesk");
assertThat(result.get("pipeline")).isSameAs(pipeline);
assertThat(result.get("categories")).isSameAs(categories);
assertThat(result.get("outperformers")).isSameAs(outperformers);
assertThat(result.get("feedDesk")).isInstanceOf(Map.class);
}
}