docs: add discovery page implementation plan

This commit is contained in:
hehih 2026-05-30 21:48:03 +09:00
parent 50afa4ae51
commit c69a02dae8
2 changed files with 505 additions and 1 deletions

View File

@ -10,7 +10,8 @@
"Bash(export JAVA_HOME=\"D:/Development/app/JDK/jdk-21.0.5\")",
"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 design spec\")",
"Bash(git -c user.name=\"hehih\" -c user.email=\"hehihoho86@gmail.com\" commit -q -m \"docs: add discovery page implementation plan\")"
]
}
}

View File

@ -0,0 +1,503 @@
# 발굴(Discovery) 전용 페이지 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 수집한 영상 중 "재가공할 떡상 후보"를 필터·정렬로 빠르게 골라내는 발굴 전용 페이지(`/discover`)를 추가한다.
**Architecture:** 기존 `ChannelVideo` + 큐레이션 API 위에 발굴 조회 쿼리 1개·서비스 메서드 1개·엔드포인트 1개·페이지 1개·사이드바 링크 1개를 더한다. 행별 액션(상태변경·북마크)은 기존 엔드포인트를 재사용한다. 새 외부 호출·새 데이터 없음(비용 0).
**Tech Stack:** Spring Boot 3.4 / Java 21 / Spring Data JPA(JPQL) / Thymeleaf(layout-dialect) / 바닐라 JS(fetch).
> **검증 방식 주의:** 이 프로젝트는 테스트 프레임워크가 없고(`src/test` 없음) 원격 PostgreSQL에 연결된다. 따라서 각 태스크의 검증은 단위테스트 대신 **컴파일 + 기동 + 수동 확인**으로 한다. JAVA_HOME 은 `D:\Development\app\JDK\jdk-21.0.5`.
---
## File Structure
- **수정** `src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoRepository.java``discover(...)` JPQL 쿼리 추가.
- **수정** `src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java``discover(...)` 메서드 추가(파라미터 정규화·기본값·limit 캡·nullsLast 정렬).
- **수정** `src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java``GET /discover` 엔드포인트 추가.
- **수정** `src/main/java/com/hlab/yanalyst/web/WebController.java``GET /discover` 페이지 라우트 추가.
- **생성** `src/main/resources/templates/discover.html` — 발굴 페이지(필터 바 + 결과 테이블 + 행 액션 + 영상 모달).
- **수정** `src/main/resources/templates/layout/sidebar.html` — "발굴" 네비 링크 추가.
---
## Task 1: 발굴 조회 쿼리 (Repository)
**Files:**
- Modify: `src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoRepository.java`
- [ ] **Step 1: `discover` 쿼리 메서드 추가**
`search(...)` 메서드 정의 바로 뒤(파일 내 마지막 메서드 다음, 닫는 `}` 앞)에 아래를 추가한다:
```java
/**
* 발굴(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);
```
- [ ] **Step 2: 컴파일로 검증**
Run: `$env:JAVA_HOME="D:\Development\app\JDK\jdk-21.0.5"; .\gradlew.bat compileJava --console=plain`
Expected: `BUILD SUCCESSFUL` (deprecated API 경고는 무시).
- [ ] **Step 3: 커밋**
```bash
git add src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoRepository.java
git commit -m "feat(discover): add ChannelVideo discovery query"
```
---
## Task 2: 발굴 서비스 메서드 (Service)
**Files:**
- Modify: `src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java`
- [ ] **Step 1: `discover` 메서드 추가**
`findOutperformers(...)` 메서드 정의 바로 뒤에 아래를 추가한다. (이미 import 된 `Sort`, `StringUtils`, `PageRequest`(`org.springframework.data.domain.PageRequest` 는 풀패키지로 사용), `BigDecimal` 사용)
```java
/**
* 발굴(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));
}
```
- [ ] **Step 2: 컴파일로 검증**
Run: `$env:JAVA_HOME="D:\Development\app\JDK\jdk-21.0.5"; .\gradlew.bat compileJava --console=plain`
Expected: `BUILD SUCCESSFUL`.
- [ ] **Step 3: 커밋**
```bash
git add src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java
git commit -m "feat(discover): add discover() service method with defaults and limit cap"
```
---
## Task 3: 발굴 엔드포인트 (Controller)
**Files:**
- Modify: `src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java`
- [ ] **Step 1: `GET /discover` 추가**
`/outperformers` 핸들러(`outperformers(...)`) 바로 뒤에 아래를 추가한다:
```java
@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));
}
```
- [ ] **Step 2: 컴파일로 검증**
Run: `$env:JAVA_HOME="D:\Development\app\JDK\jdk-21.0.5"; .\gradlew.bat compileJava --console=plain`
Expected: `BUILD SUCCESSFUL`.
- [ ] **Step 3: 커밋**
```bash
git add src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java
git commit -m "feat(discover): add GET /api/v1/channel-videos/discover endpoint"
```
---
## Task 4: 페이지 라우트 (WebController)
**Files:**
- Modify: `src/main/java/com/hlab/yanalyst/web/WebController.java`
- [ ] **Step 1: `/discover` 라우트 추가**
`board(...)` 핸들러 바로 뒤에 아래를 추가한다:
```java
@GetMapping("/discover")
public String discover(Model model) {
model.addAttribute("currentPage", "discover");
return "discover";
}
```
- [ ] **Step 2: 컴파일로 검증**
Run: `$env:JAVA_HOME="D:\Development\app\JDK\jdk-21.0.5"; .\gradlew.bat compileJava --console=plain`
Expected: `BUILD SUCCESSFUL`.
- [ ] **Step 3: 커밋**
```bash
git add src/main/java/com/hlab/yanalyst/web/WebController.java
git commit -m "feat(discover): add /discover page route"
```
---
## Task 5: 발굴 페이지 템플릿 (discover.html)
**Files:**
- Create: `src/main/resources/templates/discover.html`
- [ ] **Step 1: 템플릿 작성**
아래 내용 그대로 새 파일로 생성한다:
```html
<!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>
```
- [ ] **Step 2: 커밋**
```bash
git add src/main/resources/templates/discover.html
git commit -m "feat(discover): add discovery page template"
```
---
## Task 6: 사이드바 링크
**Files:**
- Modify: `src/main/resources/templates/layout/sidebar.html`
- [ ] **Step 1: "발굴" 네비 항목 추가**
"수집함" `<li>`(닫는 `</li>`)와 "보드" `<li>` 사이에 아래 블록을 삽입한다:
```html
<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>
```
- [ ] **Step 2: 커밋**
```bash
git add src/main/resources/templates/layout/sidebar.html
git commit -m "feat(discover): add sidebar nav link"
```
---
## Task 7: 통합 검증 (기동 + 수동 확인)
**Files:** 없음 (검증 전용)
- [ ] **Step 1: 클린 컴파일**
Run: `$env:JAVA_HOME="D:\Development\app\JDK\jdk-21.0.5"; .\gradlew.bat clean compileJava --console=plain`
Expected: `BUILD SUCCESSFUL`.
- [ ] **Step 2: 기동 확인 (포트 8088 점유 시 기존 인스턴스 종료 후)**
Run: `$env:JAVA_HOME="D:\Development\app\JDK\jdk-21.0.5"; .\gradlew.bat bootRun --console=plain`
Expected: 로그에 `Started HLabApplication` 또는 최소한 `Tomcat started on port 8088` / `JPA EntityManagerFactory` 정상. 빈 에러(`UnsatisfiedDependency`, `BeanCreationException`) 없음.
(이미 8088을 다른 인스턴스가 점유 중이면 그 프로세스가 곧 새 코드로 재기동되었는지 확인하거나, 점유 인스턴스를 종료 후 재기동.)
- [ ] **Step 3: 수동 확인 (브라우저)**
1. `http://localhost:8088/discover` 접속 → 사이드바 "발굴" active, 진입 즉시 배율 내림차순으로 영상 표시.
2. 기간/최소배율/출처/Shorts/미처리만/정렬 변경 시 목록 갱신 확인.
3. 행에서 상태 드롭다운 변경 → 새로고침해도 유지(저장 확인). 북마크 토글 → 별 색 변경. 🪄 재가공 클릭 → `/rework/{id}` 이동. 썸네일 클릭 → 모달 재생.
4. EXCLUDED 로 바꾼 영상이 새로고침 후 목록에서 사라지는지 확인.
5. (선택) Swagger `http://localhost:8088/swagger-ui.html` 에서 `/api/v1/channel-videos/discover` 응답 확인.
- [ ] **Step 4: (이상 없으면) 마무리 커밋 / 정리**
추가 변경이 없으면 커밋할 것 없음. 기동 로그 파일 등 임시 산출물은 `build/`(gitignore) 에 두거나 삭제.
---
## Self-Review 결과
- **스펙 커버리지:** 백엔드(쿼리 T1 / 서비스 T2 / 엔드포인트 T3), 프런트(라우트 T4 / 페이지 T5 / 사이드바 T6), 검증(T7) — 스펙의 5개 컴포넌트 + 기본값 + 에러/빈 상태 + 검증방법 모두 태스크에 매핑됨.
- **기본값 일치:** 서비스 기본 sortField=`viewsPerSubRatio`, UI 기본 정렬 옵션도 `viewsPerSubRatio` 첫번째. unprocessed/shorts 기본 off, 기간/배율 기본 전체 — 스펙과 일치.
- **타입/시그니처 일치:** `discover` 시그니처가 Controller→Service→Repository 3계층에서 동일(파라미터 순서: periodDays/minRatio/shortsOnly/source/unprocessedOnly/sortBy/limit → 서비스 → publishedAfter/minRatio/source/shortsOnly/unprocessedOnly/Pageable). `EXCLUDED` 제외는 Repository에서 처리. `ALLOWED_SORT`/`Sort`/`StringUtils` 는 서비스에 이미 존재.
- **플레이스홀더:** 없음(모든 스텝에 실제 코드/명령 포함).
- **null 정렬:** `Sort.Order.desc(field).nullsLast()` 로 배율 null 행이 상단에 뜨지 않게 처리(Postgres 기본 NULLS FIRST 회피).