504 lines
27 KiB
Markdown
504 lines
27 KiB
Markdown
# 발굴(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;">×</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
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 회피).
|