docs(plan): 추천 채널 발굴 구현 계획(6 태스크)
RecommendedChannel 엔티티 → DiscoveryRanker(TDD) → ChannelDiscoveryService(검색+upsert) → 스케줄러 → REST → /recommend 페이지. 기존 검색/쿼터 가드 재사용. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9fed1e04b9
commit
ee4d556851
@ -0,0 +1,686 @@
|
|||||||
|
# 추천 채널 발굴 (떡상 Shorts) 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:** 지역(KR,JP,US) 인기 Shorts 를 주기적으로 검색해 "작은 구독자·고배율" 떡상 채널을 발굴하고, `/recommend` 전용 페이지에서 등록/제외할 수 있게 한다.
|
||||||
|
|
||||||
|
**Architecture:** 기존 `YoutubeSearchService`(지역별 Shorts 검색)와 `YoutubeQuotaGuard`(쿼터)를 재사용. 순수 랭킹 로직(`DiscoveryRanker`)으로 채널별 최고 배율 집계·필터 후 `RecommendedChannel` 에 upsert. 스케줄러가 일1회 실행, 전용 페이지 + REST 로 노출.
|
||||||
|
|
||||||
|
**Tech Stack:** Spring Boot 3.4 / Java 21, JPA(PostgreSQL, ddl-auto:update), Lombok, Thymeleaf(SSR), 기존 `ApiResponse<T>`/`@RestController` 패턴.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- 빌드/실행: `JAVA_HOME=D:/Development/app/JDK/jdk-21.0.5`, `./gradlew.bat`, 포트 8088.
|
||||||
|
- API 응답은 `global/common/ApiResponse<T>`(`ApiResponse.ok(...)`)로 감싼다.
|
||||||
|
- 엔티티는 Lombok + `@CreationTimestamp`/`@UpdateTimestamp`. 스키마는 ddl-auto:update(마이그레이션 없음).
|
||||||
|
- 배율(ratio) 정의 = `viewCount / subscriberCount`(구독자 > 0 일 때만).
|
||||||
|
- 단위테스트는 `src/test/java/com/hlab/yanalyst/...`, JUnit5 + AssertJ(spring-boot-starter-test).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `RecommendedChannel` 엔티티 + Repository
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannel.java`
|
||||||
|
- Create: `src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelRepository.java`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces:
|
||||||
|
- `RecommendedChannel` 엔티티(필드: id, channelId, channelTitle, thumbnailUrl, subscriberCount, status, topVideoId, topVideoTitle, topVideoViewCount, ratio, region, discoveredAt, updatedAt).
|
||||||
|
- `RecommendedChannelRepository extends JpaRepository<RecommendedChannel, Long>`:
|
||||||
|
`Optional<RecommendedChannel> findByChannelId(String channelId)`,
|
||||||
|
`List<RecommendedChannel> findByStatusOrderByRatioDesc(String status)`,
|
||||||
|
`boolean existsByChannelIdAndStatus(String channelId, String status)`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: 엔티티 작성**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.hlab.yanalyst.domain.channel;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 자동 발굴된 추천 채널(떡상 Shorts 기반). status: NEW|REGISTERED|EXCLUDED. */
|
||||||
|
@Entity
|
||||||
|
@Table(name = "recommended_channels")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class RecommendedChannel {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true)
|
||||||
|
private String channelId;
|
||||||
|
|
||||||
|
private String channelTitle;
|
||||||
|
|
||||||
|
@Column(length = 2083)
|
||||||
|
private String thumbnailUrl; // 대표 떡상 영상의 썸네일
|
||||||
|
|
||||||
|
private Long subscriberCount;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String status = "NEW";
|
||||||
|
|
||||||
|
// 대표(최고 배율) 영상
|
||||||
|
private String topVideoId;
|
||||||
|
@Column(length = 500)
|
||||||
|
private String topVideoTitle;
|
||||||
|
private Long topVideoViewCount;
|
||||||
|
private Double ratio; // topVideo viewCount / subscriberCount
|
||||||
|
|
||||||
|
private String region; // 발견 지역(KR/JP/US)
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(updatable = false)
|
||||||
|
private LocalDateTime discoveredAt;
|
||||||
|
|
||||||
|
@UpdateTimestamp
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Repository 작성**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.hlab.yanalyst.domain.channel;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface RecommendedChannelRepository extends JpaRepository<RecommendedChannel, Long> {
|
||||||
|
Optional<RecommendedChannel> findByChannelId(String channelId);
|
||||||
|
List<RecommendedChannel> findByStatusOrderByRatioDesc(String status);
|
||||||
|
boolean existsByChannelIdAndStatus(String channelId, String status);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 컴파일 확인**
|
||||||
|
|
||||||
|
Run: `JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat compileJava --console=plain`
|
||||||
|
Expected: `BUILD SUCCESSFUL`
|
||||||
|
|
||||||
|
- [ ] **Step 4: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannel.java \
|
||||||
|
src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelRepository.java
|
||||||
|
git commit -m "feat(discover): RecommendedChannel 엔티티+Repository 추가"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: `DiscoveryRanker` 순수 랭킹 로직 (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/com/hlab/yanalyst/domain/channel/DiscoveryRanker.java`
|
||||||
|
- Test: `src/test/java/com/hlab/yanalyst/domain/channel/DiscoveryRankerTest.java`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `com.hlab.yanalyst.web.dto.YoutubeSearchResultDto`
|
||||||
|
(getter: `getChannelId()`, `getChannelTitle()`, `getThumbnailUrl()`, `getSubscriberCount():Long`,
|
||||||
|
`getViewCount():Long`, `getVideoId()`, `getTitle()`, `getChannelCountry()`).
|
||||||
|
- Produces:
|
||||||
|
- `DiscoveryRanker.Candidate` record(channelId, channelTitle, thumbnailUrl, subscriberCount, topVideoId, topVideoTitle, topVideoViewCount, ratio(double), region).
|
||||||
|
- `static List<Candidate> rank(List<YoutubeSearchResultDto> items, long maxSubscribers, double minRatio, java.util.Set<String> excludeChannelIds)`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: 실패 테스트 작성**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.hlab.yanalyst.domain.channel;
|
||||||
|
|
||||||
|
import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class DiscoveryRankerTest {
|
||||||
|
|
||||||
|
private YoutubeSearchResultDto item(String ch, long subs, String vid, long views) {
|
||||||
|
YoutubeSearchResultDto d = new YoutubeSearchResultDto();
|
||||||
|
d.setChannelId(ch); d.setChannelTitle(ch + "_title"); d.setThumbnailUrl("thumb_" + vid);
|
||||||
|
d.setSubscriberCount(subs); d.setVideoId(vid); d.setTitle("v_" + vid); d.setViewCount(views);
|
||||||
|
d.setChannelCountry("KR");
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rank_채널별_최고배율_선정_필터_정렬() {
|
||||||
|
List<YoutubeSearchResultDto> items = List.of(
|
||||||
|
item("A", 1000, "a1", 50000), // ratio 50 (작은채널·고배율)
|
||||||
|
item("A", 1000, "a2", 10000), // ratio 10 (A의 비대표)
|
||||||
|
item("B", 5_000_000, "b1", 60_000_000), // subs 과다 → 제외(maxSubs)
|
||||||
|
item("C", 2000, "c1", 4000), // ratio 2 → minRatio 미달 제외
|
||||||
|
item("D", 1000, "d1", 30000) // ratio 30
|
||||||
|
);
|
||||||
|
|
||||||
|
List<DiscoveryRanker.Candidate> out =
|
||||||
|
DiscoveryRanker.rank(items, 100_000, 5.0, Set.of());
|
||||||
|
|
||||||
|
// A(50), D(30) 만 남고 배율 내림차순. A는 최고배율 영상 a1 선택.
|
||||||
|
assertThat(out).extracting(DiscoveryRanker.Candidate::channelId).containsExactly("A", "D");
|
||||||
|
assertThat(out.get(0).topVideoId()).isEqualTo("a1");
|
||||||
|
assertThat(out.get(0).ratio()).isEqualTo(50.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rank_제외채널_및_구독자0_제거() {
|
||||||
|
List<YoutubeSearchResultDto> items = List.of(
|
||||||
|
item("A", 1000, "a1", 50000), // ratio 50 이지만 제외목록
|
||||||
|
item("E", 0, "e1", 9999), // subs 0 → 배율 계산 불가 제외
|
||||||
|
item("F", 1000, "f1", 20000) // ratio 20
|
||||||
|
);
|
||||||
|
|
||||||
|
List<DiscoveryRanker.Candidate> out =
|
||||||
|
DiscoveryRanker.rank(items, 100_000, 5.0, Set.of("A"));
|
||||||
|
|
||||||
|
assertThat(out).extracting(DiscoveryRanker.Candidate::channelId).containsExactly("F");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실패 확인**
|
||||||
|
|
||||||
|
Run: `JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat test --tests "com.hlab.yanalyst.domain.channel.DiscoveryRankerTest" --console=plain`
|
||||||
|
Expected: FAIL — `DiscoveryRanker` 클래스 없음(cannot find symbol).
|
||||||
|
|
||||||
|
- [ ] **Step 3: `DiscoveryRanker` 구현**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.hlab.yanalyst.domain.channel;
|
||||||
|
|
||||||
|
import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** 검색된 Shorts 목록 → 채널별 최고 배율 후보 집계·필터·정렬(순수 로직). */
|
||||||
|
public final class DiscoveryRanker {
|
||||||
|
|
||||||
|
private DiscoveryRanker() {}
|
||||||
|
|
||||||
|
public record Candidate(String channelId, String channelTitle, String thumbnailUrl, Long subscriberCount,
|
||||||
|
String topVideoId, String topVideoTitle, Long topVideoViewCount,
|
||||||
|
double ratio, String region) {}
|
||||||
|
|
||||||
|
public static List<Candidate> rank(List<YoutubeSearchResultDto> items, long maxSubscribers,
|
||||||
|
double minRatio, Set<String> excludeChannelIds) {
|
||||||
|
Map<String, Candidate> best = new LinkedHashMap<>();
|
||||||
|
for (YoutubeSearchResultDto it : items) {
|
||||||
|
String ch = it.getChannelId();
|
||||||
|
Long subs = it.getSubscriberCount();
|
||||||
|
Long views = it.getViewCount();
|
||||||
|
if (ch == null || subs == null || subs <= 0 || views == null) continue;
|
||||||
|
if (excludeChannelIds.contains(ch)) continue;
|
||||||
|
if (subs > maxSubscribers) continue;
|
||||||
|
|
||||||
|
double ratio = (double) views / subs;
|
||||||
|
if (ratio < minRatio) continue;
|
||||||
|
|
||||||
|
Candidate prev = best.get(ch);
|
||||||
|
if (prev == null || ratio > prev.ratio()) {
|
||||||
|
best.put(ch, new Candidate(ch, it.getChannelTitle(), it.getThumbnailUrl(), subs,
|
||||||
|
it.getVideoId(), it.getTitle(), views, ratio, it.getChannelCountry()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<Candidate> out = new ArrayList<>(best.values());
|
||||||
|
out.sort(Comparator.comparingDouble(Candidate::ratio).reversed());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 통과 확인**
|
||||||
|
|
||||||
|
Run: `JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat test --tests "com.hlab.yanalyst.domain.channel.DiscoveryRankerTest" --console=plain`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/com/hlab/yanalyst/domain/channel/DiscoveryRanker.java \
|
||||||
|
src/test/java/com/hlab/yanalyst/domain/channel/DiscoveryRankerTest.java
|
||||||
|
git commit -m "feat(discover): 채널 발굴 랭킹 로직 DiscoveryRanker + 단위테스트"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `ChannelDiscoveryService` (검색 재사용 + upsert) + 설정
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java`
|
||||||
|
- Modify: `src/main/resources/application.yml` (discovery 설정 추가)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes:
|
||||||
|
- `YoutubeSearchService.searchYoutubeVideos(YoutubeSearchCondition)` → `YoutubeSearchPageDto.getItems():List<YoutubeSearchResultDto>`.
|
||||||
|
`YoutubeSearchCondition` 필드: `setRegions(List<String>)`, `setFormat("SHORTS")`, `setKeyword(String)`, `setPeriodDays(Integer)`.
|
||||||
|
- `YoutubeQuotaGuard.remaining():long`, `tryConsume(long):boolean`.
|
||||||
|
- `ChannelRepository`(기존) — 등록된 channelId 제외용. 메서드 `existsByChannelId(String)` 가 없으면 이 태스크에서 추가.
|
||||||
|
- `RecommendedChannelRepository`(Task 1), `DiscoveryRanker.rank(...)`(Task 2).
|
||||||
|
- Produces:
|
||||||
|
- `Map<String,Object> runDiscovery()` — 검색→랭킹→upsert 후 요약 반환.
|
||||||
|
|
||||||
|
- [ ] **Step 1: `ChannelRepository` 에 존재여부 메서드 보강(없으면)**
|
||||||
|
|
||||||
|
`src/main/java/com/hlab/yanalyst/domain/channel/ChannelRepository.java` 에 다음이 없으면 추가:
|
||||||
|
|
||||||
|
```java
|
||||||
|
boolean existsByChannelId(String channelId);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 설정 추가** — `application.yml` 의 `hlab:` 블록 아래에 병합
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
hlab:
|
||||||
|
scheduler:
|
||||||
|
channel-discovery:
|
||||||
|
enabled: ${CHANNEL_DISCOVERY_ENABLED:true}
|
||||||
|
cron: ${CHANNEL_DISCOVERY_CRON:0 30 4 * * *} # 매일 04:30
|
||||||
|
discovery:
|
||||||
|
regions: ${DISCOVERY_REGIONS:KR,JP,US}
|
||||||
|
max-subscribers: ${DISCOVERY_MAX_SUBS:100000}
|
||||||
|
min-ratio: ${DISCOVERY_MIN_RATIO:5.0}
|
||||||
|
period-days: ${DISCOVERY_PERIOD_DAYS:14} # 최근 N일 영상 대상
|
||||||
|
top-n: ${DISCOVERY_TOP_N:30}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 주의: 기존 `hlab.scheduler.*`, `hlab.youtube.*` 키와 같은 `hlab:` 트리에 병합할 것(중복 `hlab:` 매핑 금지).
|
||||||
|
|
||||||
|
- [ ] **Step 3: 서비스 구현**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.hlab.yanalyst.domain.channel;
|
||||||
|
|
||||||
|
import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard;
|
||||||
|
import com.hlab.yanalyst.service.YoutubeSearchService;
|
||||||
|
import com.hlab.yanalyst.web.dto.YoutubeSearchCondition;
|
||||||
|
import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** 지역 인기 Shorts 검색 → 떡상 채널 발굴 → RecommendedChannel upsert. */
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ChannelDiscoveryService {
|
||||||
|
|
||||||
|
/** search.list 1회 추정 쿼터(units). */
|
||||||
|
private static final long SEARCH_QUOTA = 100;
|
||||||
|
|
||||||
|
private final YoutubeSearchService youtubeSearchService;
|
||||||
|
private final YoutubeQuotaGuard quotaGuard;
|
||||||
|
private final ChannelRepository channelRepository;
|
||||||
|
private final RecommendedChannelRepository recommendedChannelRepository;
|
||||||
|
|
||||||
|
@Value("${hlab.discovery.regions:KR,JP,US}")
|
||||||
|
private String regionsCsv;
|
||||||
|
@Value("${hlab.discovery.max-subscribers:100000}")
|
||||||
|
private long maxSubscribers;
|
||||||
|
@Value("${hlab.discovery.min-ratio:5.0}")
|
||||||
|
private double minRatio;
|
||||||
|
@Value("${hlab.discovery.period-days:14}")
|
||||||
|
private int periodDays;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> runDiscovery() {
|
||||||
|
List<String> regions = Arrays.stream(regionsCsv.split(","))
|
||||||
|
.map(String::trim).filter(s -> !s.isBlank()).toList();
|
||||||
|
|
||||||
|
List<YoutubeSearchResultDto> all = new ArrayList<>();
|
||||||
|
List<String> searchedRegions = new ArrayList<>();
|
||||||
|
for (String region : regions) {
|
||||||
|
if (!quotaGuard.tryConsume(SEARCH_QUOTA)) {
|
||||||
|
log.warn("[Discovery] 쿼터 예산 소진 — 지역 {} 이후 건너뜀 (잔여 {})", region, quotaGuard.remaining());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
YoutubeSearchCondition cond = new YoutubeSearchCondition();
|
||||||
|
cond.setRegions(List.of(region));
|
||||||
|
cond.setFormat("SHORTS");
|
||||||
|
cond.setPeriodDays(periodDays);
|
||||||
|
// 광범위 검색: 키워드 없이 인기 Shorts. (구현 시 q 없이도 동작하는지 확인,
|
||||||
|
// 필요하면 region 인기영상으로 보강 — 스펙 §4.2)
|
||||||
|
var page = youtubeSearchService.searchYoutubeVideos(cond);
|
||||||
|
if (page != null && page.getItems() != null) all.addAll(page.getItems());
|
||||||
|
searchedRegions.add(region);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[Discovery] 지역 {} 검색 실패 — 건너뜀", region, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 등록/제외한 채널 제외
|
||||||
|
Set<String> exclude = new HashSet<>();
|
||||||
|
for (var rc : recommendedChannelRepository.findByStatusOrderByRatioDesc("EXCLUDED")) exclude.add(rc.getChannelId());
|
||||||
|
|
||||||
|
List<DiscoveryRanker.Candidate> ranked = DiscoveryRanker.rank(all, maxSubscribers, minRatio, exclude);
|
||||||
|
|
||||||
|
int saved = 0;
|
||||||
|
for (DiscoveryRanker.Candidate c : ranked) {
|
||||||
|
if (channelRepository.existsByChannelId(c.channelId())) continue; // 이미 내 채널
|
||||||
|
RecommendedChannel rc = recommendedChannelRepository.findByChannelId(c.channelId())
|
||||||
|
.orElseGet(RecommendedChannel::new);
|
||||||
|
if ("EXCLUDED".equals(rc.getStatus()) || "REGISTERED".equals(rc.getStatus())) continue;
|
||||||
|
// 더 좋은(높은 배율) 메트릭일 때만 갱신
|
||||||
|
if (rc.getId() != null && rc.getRatio() != null && c.ratio() <= rc.getRatio()) continue;
|
||||||
|
rc.setChannelId(c.channelId());
|
||||||
|
rc.setChannelTitle(c.channelTitle());
|
||||||
|
rc.setThumbnailUrl(c.thumbnailUrl());
|
||||||
|
rc.setSubscriberCount(c.subscriberCount());
|
||||||
|
rc.setStatus("NEW");
|
||||||
|
rc.setTopVideoId(c.topVideoId());
|
||||||
|
rc.setTopVideoTitle(c.topVideoTitle());
|
||||||
|
rc.setTopVideoViewCount(c.topVideoViewCount());
|
||||||
|
rc.setRatio(c.ratio());
|
||||||
|
rc.setRegion(c.region());
|
||||||
|
recommendedChannelRepository.save(rc);
|
||||||
|
saved++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> summary = new LinkedHashMap<>();
|
||||||
|
summary.put("regions", searchedRegions);
|
||||||
|
summary.put("candidates", ranked.size());
|
||||||
|
summary.put("saved", saved);
|
||||||
|
summary.put("quotaRemaining", quotaGuard.remaining());
|
||||||
|
log.info("[Discovery] 완료: {}", summary);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 컴파일 확인 + 기존 단위테스트 통과**
|
||||||
|
|
||||||
|
Run: `JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat test --tests "com.hlab.yanalyst.domain.channel.DiscoveryRankerTest" --console=plain`
|
||||||
|
Expected: `BUILD SUCCESSFUL`(컴파일 + 테스트). 컴파일 에러 시 `YoutubeSearchCondition` setter/`YoutubeSearchPageDto.getItems()` 시그니처 재확인.
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/com/hlab/yanalyst/domain/channel/ChannelDiscoveryService.java \
|
||||||
|
src/main/java/com/hlab/yanalyst/domain/channel/ChannelRepository.java \
|
||||||
|
src/main/resources/application.yml
|
||||||
|
git commit -m "feat(discover): ChannelDiscoveryService(검색→떡상 채널 upsert) + 설정"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: 스케줄러 연결
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/com/hlab/yanalyst/global/schedule/ScheduledCollectionService.java`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `ChannelDiscoveryService.runDiscovery()`(Task 3).
|
||||||
|
|
||||||
|
- [ ] **Step 1: 의존성 주입 + 스케줄 메서드 추가**
|
||||||
|
|
||||||
|
`ScheduledCollectionService` 의 final 필드에 추가(생성자는 @RequiredArgsConstructor):
|
||||||
|
|
||||||
|
```java
|
||||||
|
private final com.hlab.yanalyst.domain.channel.ChannelDiscoveryService channelDiscoveryService;
|
||||||
|
|
||||||
|
@org.springframework.beans.factory.annotation.Value("${hlab.scheduler.channel-discovery.enabled:true}")
|
||||||
|
private boolean discoveryEnabled;
|
||||||
|
|
||||||
|
@org.springframework.scheduling.annotation.Scheduled(cron = "${hlab.scheduler.channel-discovery.cron:0 30 4 * * *}")
|
||||||
|
public void scheduledDiscovery() {
|
||||||
|
if (!discoveryEnabled) {
|
||||||
|
log.info("[Scheduler] 추천 채널 발굴 비활성화됨");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var result = channelDiscoveryService.runDiscovery();
|
||||||
|
log.info("[Scheduler] 추천 채널 발굴 완료: {}", result);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 컴파일 확인**
|
||||||
|
|
||||||
|
Run: `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/global/schedule/ScheduledCollectionService.java
|
||||||
|
git commit -m "feat(discover): 추천 채널 발굴 일별 스케줄 연결"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: `RecommendedChannelController` (목록/등록/제외/수동실행)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelController.java`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `RecommendedChannelRepository`(Task1), `ChannelDiscoveryService.runDiscovery()`(Task3),
|
||||||
|
`ChannelService.saveChannelFromUrl(String)`(기존) — URL `https://www.youtube.com/channel/{channelId}`.
|
||||||
|
- Produces(REST):
|
||||||
|
- `GET /api/recommended-channels` → `ApiResponse<List<RecommendedChannel>>`(status=NEW, 배율 desc).
|
||||||
|
- `POST /api/recommended-channels/{id}/register` → `ApiResponse<Void>`(Channel 생성 + status=REGISTERED).
|
||||||
|
- `POST /api/recommended-channels/{id}/exclude` → `ApiResponse<Void>`(status=EXCLUDED).
|
||||||
|
- `POST /api/recommended-channels/run` → `ApiResponse<Map<String,Object>>`(수동 발굴).
|
||||||
|
|
||||||
|
- [ ] **Step 1: 컨트롤러 작성**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package com.hlab.yanalyst.domain.channel;
|
||||||
|
|
||||||
|
import com.hlab.yanalyst.global.common.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/recommended-channels")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Recommended Channel API", description = "자동 발굴된 추천 채널(떡상 Shorts)")
|
||||||
|
public class RecommendedChannelController {
|
||||||
|
|
||||||
|
private final RecommendedChannelRepository repository;
|
||||||
|
private final ChannelDiscoveryService discoveryService;
|
||||||
|
private final ChannelService channelService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "추천 채널 목록", description = "status=NEW 를 배율 내림차순으로 반환")
|
||||||
|
public ApiResponse<List<RecommendedChannel>> list() {
|
||||||
|
return ApiResponse.ok(repository.findByStatusOrderByRatioDesc("NEW"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/register")
|
||||||
|
@Operation(summary = "내 채널 등록", description = "추천 채널을 내 채널로 등록하고 REGISTERED 처리")
|
||||||
|
@Transactional
|
||||||
|
public ApiResponse<Void> register(@PathVariable Long id) {
|
||||||
|
RecommendedChannel rc = repository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("추천 채널을 찾을 수 없습니다: " + id));
|
||||||
|
channelService.saveChannelFromUrl("https://www.youtube.com/channel/" + rc.getChannelId());
|
||||||
|
rc.setStatus("REGISTERED");
|
||||||
|
repository.save(rc);
|
||||||
|
return ApiResponse.ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/exclude")
|
||||||
|
@Operation(summary = "추천 제외", description = "다음 발굴에서 다시 뜨지 않게 EXCLUDED 처리")
|
||||||
|
@Transactional
|
||||||
|
public ApiResponse<Void> exclude(@PathVariable Long id) {
|
||||||
|
RecommendedChannel rc = repository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("추천 채널을 찾을 수 없습니다: " + id));
|
||||||
|
rc.setStatus("EXCLUDED");
|
||||||
|
repository.save(rc);
|
||||||
|
return ApiResponse.ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/run")
|
||||||
|
@Operation(summary = "수동 발굴 실행", description = "스케줄과 동일한 발굴을 즉시 1회 실행")
|
||||||
|
public ApiResponse<Map<String, Object>> run() {
|
||||||
|
return ApiResponse.ok(discoveryService.runDiscovery());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 컴파일 확인**
|
||||||
|
|
||||||
|
Run: `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/RecommendedChannelController.java
|
||||||
|
git commit -m "feat(discover): 추천 채널 REST(목록/등록/제외/수동실행)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: `/recommend` 전용 페이지 + 라우트 + 사이드바 메뉴
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/resources/templates/recommend.html`
|
||||||
|
- Modify: `src/main/java/com/hlab/yanalyst/web/WebController.java` (라우트 추가)
|
||||||
|
- Modify: `src/main/resources/templates/layout/sidebar.html` (메뉴 추가)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes(JS fetch): `GET /api/recommended-channels`, `POST /{id}/register`, `POST /{id}/exclude`, `POST /run`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: WebController 라우트 추가** (`discover()` 메서드 옆에 동일 패턴)
|
||||||
|
|
||||||
|
```java
|
||||||
|
@GetMapping("/recommend")
|
||||||
|
public String recommend(org.springframework.ui.Model model) {
|
||||||
|
model.addAttribute("currentPage", "recommend");
|
||||||
|
return "recommend";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 사이드바 메뉴 추가** — `sidebar.html` 의 발굴(`/discover`) 항목 바로 아래에 삽입
|
||||||
|
|
||||||
|
```html
|
||||||
|
<a th:href="@{/recommend}" class="nav-item" th:classappend="${currentPage == 'recommend'} ? 'active'">
|
||||||
|
<i data-lucide="sparkles" class="nav-icon"></i><span class="nav-text">추천 채널</span>
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 페이지 작성** (기존 `discover.html` 의 layout/base 패턴 따름)
|
||||||
|
|
||||||
|
```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">
|
||||||
|
<div class="page-header">
|
||||||
|
<div><h1>추천 채널</h1><p class="sub">지역(KR·JP·US) 인기 Shorts에서 자동 발굴한 떡상 채널</p></div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-secondary" onclick="runDiscovery()"><i data-lucide="refresh-cw" style="width:15px;"></i> 지금 발굴</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status" class="text-sm mb-3" style="display:none;"></div>
|
||||||
|
<div id="grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:1rem;"></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.rc-card { background:var(--surface); border:1px solid var(--glass-border); border-radius:var(--radius-lg); overflow:hidden; }
|
||||||
|
.rc-thumb { width:100%; aspect-ratio:16/9; object-fit:cover; background:#000; }
|
||||||
|
.rc-body { padding:12px; }
|
||||||
|
.rc-ratio { display:inline-block; background:#ef4444; color:#fff; font-weight:800; font-size:0.8rem; padding:2px 8px; border-radius:999px; }
|
||||||
|
.rc-actions { display:flex; gap:8px; margin-top:10px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script th:inline="javascript">
|
||||||
|
/*<![CDATA[*/
|
||||||
|
const API = '/api/recommended-channels';
|
||||||
|
async function api(url, opts){ const r=await fetch(url,opts); const j=await r.json().catch(()=>({})); if(!r.ok||(j&&j.success===false)) throw new Error((j&&j.message)||('HTTP '+r.status)); return j.data; }
|
||||||
|
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
function fmt(n){ return (n==null)?'-':Number(n).toLocaleString(); }
|
||||||
|
|
||||||
|
async function load(){
|
||||||
|
const grid=document.getElementById('grid');
|
||||||
|
try {
|
||||||
|
const list=await api(API);
|
||||||
|
if(!list.length){ grid.innerHTML='<div class="text-muted">아직 발굴된 추천 채널이 없습니다. ‘지금 발굴’을 눌러보세요.</div>'; return; }
|
||||||
|
grid.innerHTML=list.map(c=>
|
||||||
|
'<div class="rc-card" id="rc-'+c.id+'">'
|
||||||
|
+ '<a href="https://www.youtube.com/watch?v='+esc(c.topVideoId)+'" target="_blank"><img class="rc-thumb" src="'+esc(c.thumbnailUrl)+'" loading="lazy"></a>'
|
||||||
|
+ '<div class="rc-body">'
|
||||||
|
+ '<div class="font-bold" style="line-height:1.4;">'+esc(c.channelTitle)+'</div>'
|
||||||
|
+ '<div class="text-sm text-muted">👤 '+fmt(c.subscriberCount)+'명 · '+esc(c.region||'')+'</div>'
|
||||||
|
+ '<div class="mt-2"><span class="rc-ratio">🔥 배율 '+(c.ratio?Number(c.ratio).toFixed(1):'-')+'x</span> <span class="text-sm text-muted">👁 '+fmt(c.topVideoViewCount)+'</span></div>'
|
||||||
|
+ '<div class="text-sm text-muted mt-1" style="line-height:1.4;">'+esc(c.topVideoTitle||'')+'</div>'
|
||||||
|
+ '<div class="rc-actions">'
|
||||||
|
+ '<button class="btn btn-primary px-3 py-2" onclick="register('+c.id+')"><i data-lucide="user-plus" style="width:14px;"></i> 내 채널 등록</button>'
|
||||||
|
+ '<button class="btn btn-secondary px-3 py-2" onclick="exclude('+c.id+')"><i data-lucide="x" style="width:14px;"></i> 제외</button>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '</div></div>').join('');
|
||||||
|
if(window.lucide) lucide.createIcons();
|
||||||
|
} catch(e){ grid.innerHTML='<div style="color:#f87171;">불러오기 실패: '+esc(e.message)+'</div>'; }
|
||||||
|
}
|
||||||
|
async function register(id){ try{ await api(API+'/'+id+'/register',{method:'POST'}); document.getElementById('rc-'+id)?.remove(); }catch(e){ alert('등록 실패: '+e.message); } }
|
||||||
|
async function exclude(id){ try{ await api(API+'/'+id+'/exclude',{method:'POST'}); document.getElementById('rc-'+id)?.remove(); }catch(e){ alert('제외 실패: '+e.message); } }
|
||||||
|
async function runDiscovery(){
|
||||||
|
const s=document.getElementById('status'); s.style.display='block'; s.style.color='#facc15'; s.textContent='발굴 중… (지역별 Shorts 검색, 잠시 걸립니다)';
|
||||||
|
try{ const r=await api(API+'/run',{method:'POST'}); s.style.color='#4ade80'; s.textContent='발굴 완료 · 신규 '+(r.saved??0)+'개 · 지역 '+((r.regions||[]).join(','))+''; await load(); }
|
||||||
|
catch(e){ s.style.color='#f87171'; s.textContent='발굴 실패: '+e.message; }
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
/*]]>*/
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 실행 검증**
|
||||||
|
|
||||||
|
Run(앱 기동):
|
||||||
|
```bash
|
||||||
|
JAVA_HOME="D:/Development/app/JDK/jdk-21.0.5" ./gradlew.bat bootRun --console=plain # 백그라운드
|
||||||
|
```
|
||||||
|
- 브라우저/`curl`로 `http://localhost:8088/recommend` 200, 사이드바에 "추천 채널" 노출 확인.
|
||||||
|
- `curl -X POST http://localhost:8088/api/recommended-channels/run` → `{success:true, data:{regions,candidates,saved,...}}` 확인(쿼터/검색 동작). 결과가 빈약하면 스펙 §4.2(검색 API 보강) 적용.
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/resources/templates/recommend.html \
|
||||||
|
src/main/java/com/hlab/yanalyst/web/WebController.java \
|
||||||
|
src/main/resources/templates/layout/sidebar.html
|
||||||
|
git commit -m "feat(discover): 추천 채널 전용 페이지 /recommend + 사이드바 메뉴"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- **Spec coverage:** §3 흐름→Task3/4, §4.1 엔티티→Task1, 랭킹/필터/dedup→Task2/3, §4.4 REST→Task5, §4.5 페이지/메뉴→Task6, §5 설정→Task3, §6 쿼터→Task3(quotaGuard), §8 테스트→Task2. 누락 없음.
|
||||||
|
- **검색 API 리스크(§4.2):** Task3/Task6 Step4 에 "결과 빈약 시 보강" 명시. 키워드 없는 search.list 가 빈 결과면 `YoutubeSearchService` 에 region 인기영상 경로 추가가 후속 필요(플랜 외 보강 포인트로 표시).
|
||||||
|
- **Type 일관성:** `DiscoveryRanker.Candidate` accessor(record) 명칭이 Task2 정의와 Task3 사용처 일치(channelId(), ratio() 등). `RecommendedChannel` setter 명칭이 Task1 필드와 일치.
|
||||||
|
- **Placeholder:** 모든 코드 step 실제 코드 포함. TBD 없음.
|
||||||
Loading…
Reference in New Issue
Block a user