From 51fcfffffa06485a7fa81885d8669723fafb7c77 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Sun, 16 Aug 2026 07:11:07 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=86=8C=EC=9E=AC=20=EC=A4=91=EC=8B=AC?= =?UTF-8?q?=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=EC=99=80=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EC=88=98=20=ED=98=84=ED=96=89=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/channel/ChannelService.java | 36 ++ .../channel/ChannelVideoRepository.java | 4 + .../domain/channel/FeedCollectionService.java | 20 + .../domain/channel/FeedController.java | 7 + .../domain/channel/dto/FeedItemDto.java | 2 + .../yanalyst/service/DashboardService.java | 20 + src/main/resources/templates/dashboard.html | 360 ++++-------------- src/main/resources/templates/feed.html | 22 ++ .../service/DashboardServiceTest.java | 7 +- 9 files changed, 181 insertions(+), 297 deletions(-) diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java index 40d50c0..95cec2b 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java @@ -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 videos) { + if (videos == null || videos.isEmpty()) return 0; + Map byVideoId = videos.stream().collect(java.util.stream.Collectors.toMap( + ChannelVideo::getVideoId, v -> v, (left, right) -> left)); + int updated = 0; + List ids = new ArrayList<>(byVideoId.keySet()); + for (int from = 0; from < ids.size(); from += 50) { + List 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); diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoRepository.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoRepository.java index d1718a2..cc885a4 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoRepository.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoRepository.java @@ -12,6 +12,10 @@ public interface ChannelVideoRepository extends JpaRepository findByChannelId(Long channelId); java.util.List findByChannelIdInOrderByPublishedAtDesc(java.util.List 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 findFeedVideosForRefresh(@Param("topic") String topic); + // --- 큐레이션/분석 조회 --- java.util.List findByCategoryId(Long categoryId); long countByCategoryId(Long categoryId); diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/FeedCollectionService.java b/src/main/java/com/hlab/yanalyst/domain/channel/FeedCollectionService.java index 1583051..9bc30a6 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/FeedCollectionService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/FeedCollectionService.java @@ -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 refreshViews(FeedTopic topic) { + FeedTopic normalized = topic == null ? FeedTopic.ENTERTAINMENT : topic; + List 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 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 한다. diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/FeedController.java b/src/main/java/com/hlab/yanalyst/domain/channel/FeedController.java index 19a80fb..6670779 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/FeedController.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/FeedController.java @@ -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> refreshViews( + @RequestParam(defaultValue = "ENTERTAINMENT") String topic) { + return ApiResponse.ok(feedCollectionService.refreshViews(FeedTopic.normalize(topic))); + } + // --- 시드(소스/경쟁 채널) 관리 --- @GetMapping("/seeds") diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedItemDto.java b/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedItemDto.java index 14a65c2..bc4ea62 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedItemDto.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedItemDto.java @@ -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), diff --git a/src/main/java/com/hlab/yanalyst/service/DashboardService.java b/src/main/java/com/hlab/yanalyst/service/DashboardService.java index f8f9582..0089100 100644 --- a/src/main/java/com/hlab/yanalyst/service/DashboardService.java +++ b/src/main/java/com/hlab/yanalyst/service/DashboardService.java @@ -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 summary() { Map result = new LinkedHashMap<>(); @@ -30,6 +33,23 @@ public class DashboardService { result.put("categories", categoryService.distribution()); // categories[], uncategorized List outperformers = curationService.findOutperformers(5, BigDecimal.ONE); result.put("outperformers", outperformers); + List entertainment = feedService.feed("SOURCE", "ENTERTAINMENT", 14, + null, null, false, null); + List politics = feedService.feed("SOURCE", "POLITICS", 14, + null, null, false, null); + List 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 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; } } diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index b450992..7aee638 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -1,308 +1,76 @@ - - - h-lab - 대시보드 - - + layout:decorate="~{layout/base}"> +h-lab - 소재 데스크 -
-
- -
-
-
지금 할 일 중심 · 수집 → 큐레이션 → 재가공
-

대시보드

-
-
-
PIPELINE STATUS
-
—
- -
+
+
+
+
+ MATERIAL DESK · 최근 14일 +

오늘 만들 소재

+

업로드 시점보다 지금 오르는 속도를 먼저 봅니다.

- -
- - -
- -
-
–
-
미검토 영상
NEW · 검토 대기
-
-
검토 시작 →
-
- -
-
–
-
떡상 후보
배율 상위
-
-
발굴 열기 →
-
- -
-
–
-
재가공 대기
TARGET
-
-
발굴에서 후보를 TARGET으로 보내세요 →
-
-
- - -
-
-

떡상 후보 — 바로 처리

발굴 전체 →
-
불러오는 중…
-
- -
-

파이프라인

총 —
-
불러오는 중…
-

출처 · 포맷

수집함 →
-
-
-
- +
+ 조회 중 + +
+
- -
-
-
- 미리보기 - -
-
-
+
+ + 검토할 소재—아직 손대지 않은 원본 + +
24시간 이내—골든타임
+
예능—최근 14일
+
정치—최근 14일
+
북마크—다시 볼 소재
+
+ +
+
+
VELOCITY RANK

지금 빠르게 오르는 소재

+
-
+ +
소재 데이터를 불러오는 중입니다.
+ - - .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; } - } - - - -
+ +
- diff --git a/src/main/resources/templates/feed.html b/src/main/resources/templates/feed.html index 1c8d284..585a363 100644 --- a/src/main/resources/templates/feed.html +++ b/src/main/resources/templates/feed.html @@ -21,6 +21,9 @@ + @@ -806,6 +809,25 @@ } // ---------- 수집 ---------- + async function refreshViews(){ + const btn = document.getElementById('refreshViewsBtn'); + const original = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ' 갱신 중...'; + 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; diff --git a/src/test/java/com/hlab/yanalyst/service/DashboardServiceTest.java b/src/test/java/com/hlab/yanalyst/service/DashboardServiceTest.java index fc7cf75..32a6e5d 100644 --- a/src/test/java/com/hlab/yanalyst/service/DashboardServiceTest.java +++ b/src/test/java/com/hlab/yanalyst/service/DashboardServiceTest.java @@ -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 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); } }