feat(discover): add discovery page for finding rework candidates

New /discover page surfaces collected ChannelVideos for picking rework
targets, filtered by period/min-ratio/source/shorts/unprocessed and
sorted by ratio/velocity/views/recency (EXCLUDED always hidden, null
ratios sorted last). Reuses existing curation endpoints for row actions
(status, bookmark).

- ChannelVideoRepository.discover() JPQL query
- ChannelVideoCurationService.discover() (defaults, limit cap, nullsLast)
- GET /api/v1/channel-videos/discover endpoint
- /discover page route + discover.html + sidebar link
Verified by clean compileJava.
This commit is contained in:
hehih 2026-05-30 21:51:28 +09:00
parent c69a02dae8
commit f232d20f46
7 changed files with 303 additions and 1 deletions

View File

@ -11,7 +11,9 @@
"Bash(./gradlew.bat bootRun --console=plain)",
"Bash(echo \"bootRun started in background, PID $!\")",
"Bash(git -c user.name=\"hehih\" -c user.email=\"hehihoho86@gmail.com\" commit -q -m \"docs: add discovery page design spec\")",
"Bash(git -c user.name=\"hehih\" -c user.email=\"hehihoho86@gmail.com\" commit -q -m \"docs: add discovery page implementation plan\")"
"Bash(git -c user.name=\"hehih\" -c user.email=\"hehihoho86@gmail.com\" commit -q -m \"docs: add discovery page implementation plan\")",
"PowerShell($env:JAVA_HOME=\"D:\\\\Development\\\\app\\\\JDK\\\\jdk-21.0.5\"; .\\\\gradlew.bat clean compileJava --console=plain 2>&1 | Select-Object -Last 12)",
"Bash(git -c user.name=hehih -c user.email=hehihoho86@gmail.com commit -q -m 'feat\\(discover\\): add discovery page for finding rework candidates *)"
]
}
}

View File

@ -40,6 +40,22 @@ public class ChannelVideoCurationController {
return ApiResponse.ok(curationService.findOutperformers(limit, minRatio));
}
@GetMapping("/discover")
@Operation(summary = "발굴(Discovery) 조회",
description = "필터: periodDays(최근 N일), minRatio(배율 하한), shortsOnly, source(CHANNEL|SEARCH), "
+ "unprocessedOnly(NEW/REVIEWING만). 정렬 sortBy(기본 viewsPerSubRatio↓), limit(기본100). "
+ "EXCLUDED 는 항상 제외.")
public ApiResponse<List<ChannelVideo>> discover(
@RequestParam(required = false) Integer periodDays,
@RequestParam(required = false) java.math.BigDecimal minRatio,
@RequestParam(defaultValue = "false") boolean shortsOnly,
@RequestParam(required = false) String source,
@RequestParam(defaultValue = "false") boolean unprocessedOnly,
@RequestParam(required = false) String sortBy,
@RequestParam(required = false) Integer limit) {
return ApiResponse.ok(curationService.discover(periodDays, minRatio, shortsOnly, source, unprocessedOnly, sortBy, limit));
}
@PostMapping("/backfill")
@Operation(summary = "기존 수집 영상 지표 백필",
description = "새 컬럼 추가 이전에 수집된 영상의 파생 지표/큐레이션 기본값을 재계산(외부 API 호출 없음, 재실행 안전).")

View File

@ -129,6 +129,29 @@ public class ChannelVideoCurationService {
org.springframework.data.domain.PageRequest.of(0, size));
}
/**
* 발굴(Discovery) 조회 필터 + 정렬 + 개수 제한.
* @param periodDays 최근 N일만 (null/0이면 전체)
* @param minRatio viewsPerSubRatio 하한 (null이면 미적용)
* @param shortsOnly Shorts만
* @param source CHANNEL | SEARCH (빈값이면 전체)
* @param unprocessedOnly true면 NEW/REVIEWING
* @param sortBy 정렬 필드(ALLOWED_SORT, 기본 viewsPerSubRatio 내림차순, null은 항상 마지막)
* @param limit 최대 개수(기본 100, 최대 200)
*/
public List<ChannelVideo> discover(Integer periodDays, java.math.BigDecimal minRatio,
boolean shortsOnly, String source, boolean unprocessedOnly,
String sortBy, Integer limit) {
java.time.LocalDateTime publishedAfter =
(periodDays == null || periodDays <= 0) ? null : java.time.LocalDateTime.now().minusDays(periodDays);
String sourceFilter = StringUtils.hasText(source) ? source : null;
String sortField = StringUtils.hasText(sortBy) && ALLOWED_SORT.contains(sortBy) ? sortBy : "viewsPerSubRatio";
int size = (limit == null || limit <= 0) ? 100 : Math.min(limit, 200);
Sort sort = Sort.by(Sort.Order.desc(sortField).nullsLast());
return channelVideoRepository.discover(publishedAfter, minRatio, sourceFilter, shortsOnly, unprocessedOnly,
org.springframework.data.domain.PageRequest.of(0, size, sort));
}
/**
* 기존 수집 영상의 파생 지표/큐레이션 기본값 백필.
* 컬럼 추가 이전에 수집된 행들은 값이 null 이라 통계/필터/떡상 발굴에 잡히지 않으므로,

View File

@ -41,4 +41,27 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
@Param("shortsOnly") boolean shortsOnly,
@Param("bookmarkedOnly") boolean bookmarkedOnly,
org.springframework.data.domain.Sort sort);
/**
* 발굴(Discovery) 조회. EXCLUDED 항상 제외. null/false 조건은 무시.
* 정렬·개수 제한은 호출부의 Pageable(sort + size) 처리한다.
* @param publishedAfter 시각 이후 업로드분만 (null이면 전체)
* @param minRatio viewsPerSubRatio 하한 (null이면 미적용; null 배율 행은 자동 제외)
* @param shortsOnly true면 Shorts만
* @param source CHANNEL | SEARCH (null이면 전체)
* @param unprocessedOnly true면 상태가 NEW/REVIEWING 것만
*/
@Query("select v from ChannelVideo v where "
+ "v.interestStatus <> 'EXCLUDED' and "
+ "(:publishedAfter is null or v.publishedAt >= :publishedAfter) and "
+ "(:minRatio is null or v.viewsPerSubRatio >= :minRatio) and "
+ "(:source is null or v.source = :source) and "
+ "(:shortsOnly = false or v.isShorts = true) and "
+ "(:unprocessedOnly = false or v.interestStatus in ('NEW','REVIEWING'))")
java.util.List<ChannelVideo> discover(@Param("publishedAfter") java.time.LocalDateTime publishedAfter,
@Param("minRatio") java.math.BigDecimal minRatio,
@Param("source") String source,
@Param("shortsOnly") boolean shortsOnly,
@Param("unprocessedOnly") boolean unprocessedOnly,
org.springframework.data.domain.Pageable pageable);
}

View File

@ -46,6 +46,12 @@ public class WebController {
return "board";
}
@GetMapping("/discover")
public String discover(Model model) {
model.addAttribute("currentPage", "discover");
return "discover";
}
@GetMapping("/publish")
public String publish(Model model) {
model.addAttribute("currentPage", "publish");

View File

@ -0,0 +1,225 @@
<!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">
<header class="mb-4">
<h1 class="text-2xl font-bold mb-2">발굴 (Discovery)</h1>
<p class="text-muted">수집한 영상 중 재가공할 떡상 후보를 빠르게 골라냅니다. (제외 처리한 영상은 숨겨집니다)</p>
</header>
<!-- 필터 바 -->
<div class="card mb-4">
<div style="display:flex; flex-wrap:wrap; gap:16px; align-items:flex-end;">
<div style="display:flex; flex-direction:column; gap:4px; min-width:120px;">
<label class="text-sm text-muted">기간</label>
<select id="fPeriod" onchange="loadVideos()" class="filter-sel">
<option value="">전체</option>
<option value="7">최근 7일</option>
<option value="14">최근 14일</option>
<option value="30">최근 30일</option>
</select>
</div>
<div style="display:flex; flex-direction:column; gap:4px; min-width:120px;">
<label class="text-sm text-muted">최소 배율</label>
<select id="fMinRatio" onchange="loadVideos()" class="filter-sel">
<option value="">전체</option>
<option value="2">≥ 2x</option>
<option value="3">≥ 3x</option>
<option value="5">≥ 5x</option>
</select>
</div>
<div style="display:flex; flex-direction:column; gap:4px; min-width:120px;">
<label class="text-sm text-muted">출처</label>
<select id="fSource" onchange="loadVideos()" class="filter-sel">
<option value="">전체</option>
<option value="CHANNEL">채널 수집</option>
<option value="SEARCH">검색 수집</option>
</select>
</div>
<div style="display:flex; flex-direction:column; gap:4px; min-width:160px;">
<label class="text-sm text-muted">정렬</label>
<select id="fSort" onchange="loadVideos()" class="filter-sel">
<option value="viewsPerSubRatio">배율(조회/구독) ↓</option>
<option value="viewsPerHour">시간당 조회수 ↓</option>
<option value="viewCount">조회수 ↓</option>
<option value="publishedAt">최신순 ↓</option>
</select>
</div>
<label class="flex items-center gap-2 cursor-pointer" style="height:42px;">
<input type="checkbox" id="fShorts" onchange="loadVideos()"> <span class="text-sm">Shorts만</span>
</label>
<label class="flex items-center gap-2 cursor-pointer" style="height:42px;">
<input type="checkbox" id="fUnprocessed" onchange="loadVideos()"> <span class="text-sm">미처리만(NEW/검토중)</span>
</label>
<button class="btn btn-secondary px-4 py-2 flex items-center gap-1" onclick="loadVideos()">
<i data-lucide="refresh-cw" style="width:16px;"></i> 새로고침
</button>
<span id="resultCount" class="text-sm text-muted" style="margin-left:auto; height:42px; display:flex; align-items:center;"></span>
</div>
</div>
<!-- 결과 테이블 -->
<div class="card p-0" style="overflow-x:auto;">
<table class="w-full" style="border-collapse:collapse; text-align:left;">
<thead style="background:rgba(255,255,255,0.02); border-bottom:1px solid var(--glass-border);">
<tr>
<th class="p-3 text-sm font-bold text-muted">썸네일</th>
<th class="p-3 text-sm font-bold text-muted">제목</th>
<th class="p-3 text-sm font-bold text-muted">채널</th>
<th class="p-3 text-sm font-bold text-muted">구독자</th>
<th class="p-3 text-sm font-bold text-muted">조회수</th>
<th class="p-3 text-sm font-bold text-muted">시간당</th>
<th class="p-3 text-sm font-bold text-muted">배율</th>
<th class="p-3 text-sm font-bold text-muted">업로드</th>
<th class="p-3 text-sm font-bold text-muted">상태</th>
<th class="p-3 text-sm font-bold text-muted">관리</th>
</tr>
</thead>
<tbody id="resultBody">
<tr><td colspan="10" class="p-8 text-center text-muted">로딩 중...</td></tr>
</tbody>
</table>
</div>
<!-- 영상 모달 -->
<div id="videoModal" style="display:none; position:fixed; z-index:1000; left:0; top:0; width:100%; height:100%; background:rgba(0,0,0,0.85); align-items:center; justify-content:center;" onclick="if(event.target===this) closeVideoModal()">
<div style="position:relative; width:95%; max-width:900px; background:var(--glass-bg,#1e1e2d); border-radius:12px; padding:16px; border:1px solid var(--glass-border);">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
<h3 class="text-lg font-bold truncate pr-4" id="modalTitle">Video</h3>
<button onclick="closeVideoModal()" class="text-muted hover:text-white" style="font-size:28px; line-height:1; background:none; border:none; cursor:pointer;">&times;</button>
</div>
<div id="iframeContainer" style="width:100%; aspect-ratio:9/16; max-height:70vh; border-radius:8px; overflow:hidden; background:#000; margin:0 auto;"></div>
</div>
</div>
<style>
.filter-sel { padding:8px; height:42px; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:white; outline:none; }
.filter-sel option { background:#1e1e2d; color:white; }
.row-sel { padding:4px 6px; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:6px; color:white; font-size:0.8rem; outline:none; }
.row-sel option { background:#1e1e2d; color:white; }
</style>
<script th:inline="javascript">
/*<![CDATA[*/
const API = '/api/v1/channel-videos';
const STATUS_LABEL = { NEW:'NEW', REVIEWING:'검토중', TARGET:'작업대상', DONE:'완료', EXCLUDED:'제외' };
const STATUS_KEYS = ['NEW','REVIEWING','TARGET','DONE','EXCLUDED'];
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) ? '-' : Number(n).toLocaleString(); }
function fmtDate(s){ return s ? String(s).substring(0,10) : '-'; }
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;
}
async function loadVideos(){
const body = document.getElementById('resultBody');
body.innerHTML = '<tr><td colspan="10" class="p-8 text-center text-muted">로딩 중...</td></tr>';
const p = new URLSearchParams();
const period = document.getElementById('fPeriod').value;
const minRatio = document.getElementById('fMinRatio').value;
const src = document.getElementById('fSource').value;
if(period) p.set('periodDays', period);
if(minRatio) p.set('minRatio', minRatio);
if(src) p.set('source', src);
if(document.getElementById('fShorts').checked) p.set('shortsOnly','true');
if(document.getElementById('fUnprocessed').checked) p.set('unprocessedOnly','true');
p.set('sortBy', document.getElementById('fSort').value);
p.set('limit', '100');
let data;
try { data = await api(API + '/discover?' + p.toString()); }
catch(e){
body.innerHTML = '<tr><td colspan="10" class="p-8 text-center text-danger">불러오기 실패: '+esc(e.message)+'</td></tr>';
return;
}
renderRows(data || []);
}
function ratioBadge(r){
if(r==null) return '<span class="text-muted">-</span>';
const v = Number(r);
let color = '#10b981';
if(v>=10) color = '#ef4444'; else if(v>=2) color = '#f59e0b';
return `<span style="color:${color}; font-weight:bold;">${v.toFixed(1)}x</span>`;
}
function renderRows(list){
const body = document.getElementById('resultBody');
document.getElementById('resultCount').textContent = list.length + '건';
if(list.length===0){
body.innerHTML = '<tr><td colspan="10" class="p-8 text-center text-muted">조건에 맞는 발굴 후보가 없습니다.</td></tr>';
return;
}
body.innerHTML = list.map(v=>{
const statusOpts = STATUS_KEYS.map(k=>
`<option value="${k}" ${v.interestStatus===k?'selected':''}>${STATUS_LABEL[k]}</option>`).join('');
const star = v.bookmarked ? '#f59e0b' : 'var(--text-muted)';
const shortsBadge = v.isShorts ? '<span style="font-size:0.65rem; background:#7C3AED33; color:#a78bfa; padding:1px 5px; border-radius:4px; margin-left:4px;">Shorts</span>' : '';
return `<tr style="border-bottom:1px solid var(--glass-border);">
<td class="p-3">
<div style="position:relative; cursor:pointer; width:96px;" data-vid="${v.videoId}" data-title="${esc(v.title)}" onclick="openVideoModal(this.dataset.vid, this.dataset.title)">
<img src="${esc(v.thumbnailUrl)}" style="width:96px; height:54px; object-fit:cover; border-radius:6px;">
</div>
</td>
<td class="p-3" style="max-width:280px;">
<div class="font-bold text-sm" style="display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;">
<a href="https://www.youtube.com/watch?v=${v.videoId}" target="_blank" class="hover:underline text-white">${esc(v.title)}</a>${shortsBadge}
</div>
</td>
<td class="p-3 text-sm text-muted" style="max-width:130px;">
<a href="${v.ytChannelId?('https://www.youtube.com/channel/'+v.ytChannelId):'#'}" target="_blank" class="hover:underline truncate" style="color:#e2e8f0;">${esc(v.channelTitle||'-')}</a>
</td>
<td class="p-3 text-sm">${fmt(v.subscriberCount)}</td>
<td class="p-3 text-sm">${fmt(v.viewCount)}</td>
<td class="p-3 text-sm">${fmt(v.viewsPerHour)}</td>
<td class="p-3 text-sm">${ratioBadge(v.viewsPerSubRatio)}</td>
<td class="p-3 text-sm text-muted">${fmtDate(v.publishedAt)}</td>
<td class="p-3"><select class="row-sel" onchange="setStatus(${v.id}, this.value)">${statusOpts}</select></td>
<td class="p-3">
<div class="flex items-center gap-1">
<a class="btn btn-primary p-2" title="재가공" href="/rework/${v.id}"><i data-lucide="wand-2" style="width:15px;"></i></a>
<button class="btn btn-secondary p-2" title="북마크" onclick="toggleBookmark(${v.id}, ${!!v.bookmarked})"><i data-lucide="star" style="width:15px; color:${star};"></i></button>
</div>
</td>
</tr>`;
}).join('');
if(window.lucide) lucide.createIcons();
}
async function setStatus(id, value){
try { await api(API+'/'+id+'/status', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({status:value})}); }
catch(e){ alert('상태 변경 실패: '+e.message); }
}
async function toggleBookmark(id, current){
try { await api(API+'/'+id+'/bookmark', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({bookmarked: !current})}); loadVideos(); }
catch(e){ alert('북마크 실패: '+e.message); }
}
function openVideoModal(vid, title){
document.getElementById('modalTitle').textContent = title;
document.getElementById('iframeContainer').innerHTML = '<iframe width="100%" height="100%" src="https://www.youtube.com/embed/'+vid+'?autoplay=1" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>';
document.getElementById('videoModal').style.display = 'flex';
}
function closeVideoModal(){
document.getElementById('iframeContainer').innerHTML = '';
document.getElementById('videoModal').style.display = 'none';
}
loadVideos();
/*]]>*/
</script>
</div>
</body>
</html>

View File

@ -57,6 +57,13 @@
<span class="nav-text">수집함</span>
</a>
</li>
<li>
<a th:href="@{/discover}" class="nav-item"
th:classappend="${currentPage == 'discover'} ? 'active'">
<i data-lucide="radar" class="nav-icon"></i>
<span class="nav-text">발굴</span>
</a>
</li>
<li>
<a th:href="@{/board}" class="nav-item"
th:classappend="${currentPage == 'board'} ? 'active'">