package com.hlab.yanalyst.domain.social; import com.hlab.yanalyst.domain.channel.FeedTopic; import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import org.springframework.data.domain.PageRequest; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.*; @Slf4j @Service @RequiredArgsConstructor public class SocialFeedService { private static final int MAX_FEED_ITEMS = 200; private final SocialChannelRepository channelRepository; private final SocialVideoRepository videoRepository; private final List clients; private final SocialFeedAlertService alertService; private final SocialVideoMetadataResolver metadataResolver; @Value("${hlab.social-feed.enabled:true}") private boolean enabled; @Value("${hlab.social-feed.seed.phutunet-enabled:true}") private boolean phutunetSeedEnabled; @Value("${hlab.social-feed.seed.phutunet-url:https://www.facebook.com/phutunet/reels/}") private String phutunetUrl; public record ChannelDto(Long id, String platform, String sourceUrl, String externalChannelId, String title, String topic, boolean notificationEnabled, LocalDateTime lastCollectedAt, int lastFound, int failCount, String lastError) { static ChannelDto from(SocialChannel channel) { return new ChannelDto(channel.getId(), channel.getPlatform().name(), channel.getSourceUrl(), channel.getExternalChannelId(), channel.getTitle(), channel.getTopic().name(), channel.isNotificationEnabled(), channel.getLastCollectedAt(), channel.getLastFound(), channel.getFailCount(), channel.getLastError()); } } public record VideoDto(Long id, String externalVideoId, String url, String title, String thumbnailUrl, Integer durationSec, LocalDateTime publishedAt, LocalDateTime collectedAt, String channelTitle, String platform, String topic) { static VideoDto from(SocialVideo video) { SocialChannel channel = video.getChannel(); return new VideoDto(video.getId(), video.getExternalVideoId(), video.getUrl(), video.getTitle(), video.getThumbnailUrl(), video.getDurationSec(), video.getPublishedAt(), video.getCollectedAt(), channel.getTitle(), channel.getPlatform().name(), channel.getTopic().name()); } } @EventListener(ApplicationReadyEvent.class) @Transactional public void registerDefaultChannels() { if (!phutunetSeedEnabled || phutunetUrl == null || phutunetUrl.isBlank()) return; String normalized = SocialPlatform.normalizeChannelUrl(phutunetUrl); if (channelRepository.findBySourceUrl(normalized).isPresent()) return; SocialChannel channel = SocialChannel.register(normalized, FeedTopic.DISTRIBUTION.name(), true); channel.updateMetadata("Phutu Crafts", "321133534899178"); channelRepository.save(channel); log.info("[SocialFeed] 기본 Facebook 유통 채널 등록: {}", normalized); } @Scheduled(cron = "${hlab.social-feed.cron:0 */15 * * * *}") public void scheduledCollect() { if (!enabled) return; log.info("[SocialFeed] 자동 수집 완료: {}", collect(FeedTopic.DISTRIBUTION.name())); } @Transactional(readOnly = true) public List channels(String topic) { return channelRepository.findAllByTopicOrderByIdAsc(FeedTopic.normalize(topic)).stream() .map(ChannelDto::from).toList(); } @Transactional(readOnly = true) public List videos(String topic) { return videoRepository.findFeed(FeedTopic.normalize(topic), PageRequest.of(0, MAX_FEED_ITEMS)).stream() .map(VideoDto::from).toList(); } @Transactional public ChannelDto addChannel(String url, String topic, boolean notificationEnabled) { String normalized = SocialPlatform.normalizeChannelUrl(url); SocialChannel channel = channelRepository.findBySourceUrl(normalized) .orElseGet(() -> SocialChannel.register(normalized, topic, notificationEnabled)); channel.configure(topic, notificationEnabled); return ChannelDto.from(channelRepository.save(channel)); } @Transactional public void removeChannel(Long id) { SocialChannel channel = getChannel(id); videoRepository.deleteAllByChannelId(id); channelRepository.delete(channel); } public Map collect(String topic) { FeedTopic normalized = FeedTopic.normalize(topic); List channels = channelRepository.findAllByTopicOrderByIdAsc(normalized); int succeeded = 0, failed = 0, saved = 0, alerted = 0; for (SocialChannel channel : channels) { try { CollectionResult result = collectOne(channel.getId()); succeeded++; saved += result.saved(); alerted += result.alerted(); } catch (Exception e) { failed++; markFailure(channel.getId(), friendly(e)); log.warn("[SocialFeed] 채널 수집 실패: id={}, url={}", channel.getId(), channel.getSourceUrl(), e); } } Map summary = new LinkedHashMap<>(); summary.put("channels", channels.size()); summary.put("collected", succeeded); summary.put("savedVideos", saved); summary.put("alerted", alerted); summary.put("failed", failed); return summary; } @Transactional public CollectionResult collectOne(Long channelId) { SocialChannel channel = getChannel(channelId); LocalDateTime previousCollection = channel.getLastCollectedAt(); boolean baselineCollection = previousCollection == null; SocialFeedClient client = clients.stream().filter(c -> c.supports(channel.getPlatform())).findFirst() .orElseThrow(() -> new IllegalStateException(channel.getPlatform() + " 수집기가 없습니다.")); SocialFeedBatch batch = client.fetch(channel); channel.updateMetadata(batch.channelTitle(), batch.externalChannelId()); List inserted = new ArrayList<>(); for (SocialVideoCandidate original : batch.videos()) { SocialVideoCandidate candidate = original; if (candidate.externalVideoId() == null || candidate.externalVideoId().isBlank() || candidate.url() == null || candidate.url().isBlank()) { continue; } Optional existing = videoRepository.findByChannelIdAndExternalVideoId( channelId, candidate.externalVideoId()); if (existing.isPresent()) { SocialVideo stored = existing.get(); if (stored.getDurationSec() == null) { Integer durationSec = candidate.durationSec() == null ? metadataResolver.resolveDuration(candidate) : candidate.durationSec(); stored.fillDuration(durationSec); if (stored.getDurationSec() != null) videoRepository.save(stored); } continue; } if (candidate.durationSec() == null) { candidate = candidate.withDurationSec(metadataResolver.resolveDuration(candidate)); } inserted.add(videoRepository.save(SocialVideo.from(channel, candidate))); } channel.collectionSucceeded(inserted.size()); channelRepository.save(channel); // 최초 수집은 현재 목록을 기준점으로만 저장한다. 다음 수집부터 생긴 항목만 알린다. List alertCandidates = baselineCollection ? List.of() : inserted.stream() // 플러그인 목록 순서가 바뀌며 뒤늦게 발견된 과거 영상을 신규로 오인하지 않는다. .filter(video -> video.getPublishedAt() == null || !video.getPublishedAt().isBefore(previousCollection.minusMinutes(5))) .toList(); int alerted = baselineCollection ? 0 : alertService.notifyNewVideos(channel, alertCandidates); return new CollectionResult(inserted.size(), alerted, baselineCollection); } public record CollectionResult(int saved, int alerted, boolean baselineCollection) { } @Transactional protected void markFailure(Long channelId, String message) { channelRepository.findById(channelId).ifPresent(channel -> { channel.collectionFailed(message); channelRepository.save(channel); }); } private SocialChannel getChannel(Long id) { return channelRepository.findById(id) .orElseThrow(() -> new EntityNotFoundException("소셜 채널을 찾을 수 없습니다: " + id)); } private static String friendly(Exception e) { Throwable cause = e; while (cause.getCause() != null && cause.getMessage() == null) cause = cause.getCause(); String message = cause.getMessage(); if (message == null || message.isBlank()) return "수집에 실패했습니다."; return message.length() <= 1200 ? message : message.substring(message.length() - 1200); } }