package com.hlab.yanalyst.domain.channel; import com.hlab.yanalyst.domain.channel.dto.FeedItemDto; import com.hlab.yanalyst.domain.channel.dto.FeedProgramDto; import com.hlab.yanalyst.domain.channel.dto.FeedSeedDto; import com.hlab.yanalyst.domain.channel.dto.TrackedPersonDto; 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.web.bind.annotation.*; import java.util.List; import java.util.Map; /** 소재 발굴 피드 API. 소스(웹예능 롱폼) / 경쟁(예능짤 쇼츠) 신규 업로드를 최신순으로 제공한다. */ @RestController @RequestMapping("/api/feed") @RequiredArgsConstructor @Tag(name = "Source Feed API", description = "소재 원본·경쟁 채널의 신규 업로드 피드") public class FeedController { private final FeedService feedService; private final FeedCollectionService feedCollectionService; private final SeedSuggestService seedSuggestService; private final PersonCollectionService personCollectionService; @GetMapping @Operation(summary = "피드 조회", description = "tab=SOURCE(롱폼 소재)|RIVAL(경쟁 쇼츠)|PERSON(인물 추적 롱폼). 항상 최신순.") public ApiResponse> feed(@RequestParam(defaultValue = "SOURCE") String tab, @RequestParam(defaultValue = "ENTERTAINMENT") String topic, @RequestParam(required = false) Integer days, @RequestParam(required = false) String channelId, @RequestParam(required = false) String lengthBucket, @RequestParam(defaultValue = "false") boolean hideWorked, @RequestParam(required = false) String person) { return ApiResponse.ok(feedService.feed(tab, topic, days, channelId, lengthBucket, hideWorked, person)); } // --- 인물 추적 --- @GetMapping("/persons") @Operation(summary = "추적 인물 목록", description = "인물별 담긴 영상 수와 마지막 수집 결과 포함") public ApiResponse> persons() { return ApiResponse.ok(feedService.persons()); } @PostMapping("/persons") @Operation(summary = "추적 인물 추가", description = "1명당 검색 100 units 라 활성 인원에 상한이 있다") public ApiResponse addPerson(@RequestBody Map body) { return ApiResponse.created(feedService.addPerson(body.get("name"))); } @PostMapping("/persons/{id}/toggle") @Operation(summary = "추적 on/off", description = "끄면 수집 대상에서 빠지고 이미 담은 영상은 남는다") public ApiResponse togglePerson(@PathVariable Long id, @RequestParam boolean enabled) { feedService.togglePerson(id, enabled); return ApiResponse.ok(null); } @DeleteMapping("/persons/{id}") @Operation(summary = "추적 해제", description = "인물만 목록에서 지우고 담긴 영상은 남긴다") public ApiResponse removePerson(@PathVariable Long id) { feedService.removePerson(id); return ApiResponse.ok(null); } @PostMapping("/persons/collect") @Operation(summary = "인물 수집 실행", description = "활성 인물 전체를 즉시 1회 수집. 1명당 101 units.") public ApiResponse> collectPersons() { return ApiResponse.ok(personCollectionService.collectAll()); } @GetMapping("/programs") @Operation(summary = "프로그램 목록", description = "피드 필터용 원본 채널 목록(영상 많은 순)") public ApiResponse> programs(@RequestParam(defaultValue = "SOURCE") String tab, @RequestParam(defaultValue = "ENTERTAINMENT") String topic) { return ApiResponse.ok(feedService.programs(tab, topic)); } @PostMapping("/collect") @Operation(summary = "수동 수집", description = "스케줄과 동일한 피드 수집을 즉시 1회 실행") public ApiResponse> collect( @RequestParam(defaultValue = "ENTERTAINMENT") String topic) { return ApiResponse.ok(feedCollectionService.collect(FeedTopic.normalize(topic))); } @PostMapping("/refresh-views") @Operation(summary = "소재 조회수 현행화", description = "저장된 피드 영상 조회수를 videos.list 50개 배치로 갱신한다") public ApiResponse> refreshViews( @RequestParam(defaultValue = "ENTERTAINMENT") String topic) { return ApiResponse.ok(feedCollectionService.refreshViews(FeedTopic.normalize(topic))); } // --- 시드(소스/경쟁 채널) 관리 --- @GetMapping("/seeds") @Operation(summary = "시드 목록", description = "등록된 소재 원본·경쟁 채널") public ApiResponse> seeds( @RequestParam(defaultValue = "ENTERTAINMENT") String topic) { return ApiResponse.ok(feedService.seeds(topic)); } @PostMapping("/seeds") @Operation(summary = "시드 등록", description = "채널 URL 을 소재 원본(SOURCE) 또는 경쟁(RIVAL) 시드로 등록") public ApiResponse addSeed(@RequestBody Map body) { String url = body.get("url"); if (url == null || url.isBlank()) throw new IllegalArgumentException("채널 URL 이 필요합니다."); return ApiResponse.created(feedService.addSeed(url, body.getOrDefault("role", ChannelRole.SOURCE), body.getOrDefault("topic", FeedTopic.ENTERTAINMENT.name()), body.getOrDefault("format", FeedFormat.LONG_FORM.name()))); } @DeleteMapping("/seeds/{id}") @Operation(summary = "시드 해제", description = "채널과 그 채널에서 수집한 피드 영상을 함께 제거") public ApiResponse removeSeed(@PathVariable Long id) { feedService.removeSeed(id); return ApiResponse.ok(null); } @PostMapping("/seeds/{id}/reset-failure") @Operation(summary = "실패 카운터 초기화", description = "연속 실패로 자동 스킵 중인 시드를 다시 활성화") public ApiResponse resetFailure(@PathVariable Long id) { feedService.resetSeedFailure(id); return ApiResponse.ok(null); } // --- 시드 자동 발굴(내 채널 역분석) --- @GetMapping("/seeds/keywords") @Operation(summary = "해시태그 분석", description = "내 채널 영상의 해시태그 빈도(쿼터 소비 없음)") public ApiResponse> keywords() { return ApiResponse.ok(seedSuggestService.analyzeKeywords()); } @PostMapping("/seeds/suggest") @Operation(summary = "소재 원본 후보 발굴", description = "해시태그 상위 키워드로 공식채널을 검색해 추천 목록(roleHint=SOURCE)에 쌓는다. 키워드당 100 units 소비.") public ApiResponse> suggest(@RequestBody(required = false) Map body) { @SuppressWarnings("unchecked") List keywords = body == null ? null : (List) body.get("keywords"); return ApiResponse.ok(seedSuggestService.suggestSourceSeeds(keywords)); } }