diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1dd1293..0e8b454 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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\")" ] } } diff --git a/docs/superpowers/plans/2026-05-30-discovery-page.md b/docs/superpowers/plans/2026-05-30-discovery-page.md new file mode 100644 index 0000000..643c823 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-discovery-page.md @@ -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 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 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> 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 + + + + + h-lab - 발굴 + + + +
+
+

발굴 (Discovery)

+

수집한 영상 중 재가공할 떡상 후보를 빠르게 골라냅니다. (제외 처리한 영상은 숨겨집니다)

+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + +
썸네일제목채널구독자조회수시간당배율업로드상태관리
로딩 중...
+
+ + + + + + + +
+ + + +``` + +- [ ] **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: "발굴" 네비 항목 추가** + +"수집함" `
  • `(닫는 `
  • `)와 "보드" `
  • ` 사이에 아래 블록을 삽입한다: + +```html +
  • + + + 발굴 + +
  • +``` + +- [ ] **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 회피).