h-lab/src/main/java/com/hlab/yanalyst/domain/social/SocialFeedService.java

198 lines
9.7 KiB
Java

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<SocialFeedClient> 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<ChannelDto> channels(String topic) {
return channelRepository.findAllByTopicOrderByIdAsc(FeedTopic.normalize(topic)).stream()
.map(ChannelDto::from).toList();
}
@Transactional(readOnly = true)
public List<VideoDto> 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<String, Object> collect(String topic) {
FeedTopic normalized = FeedTopic.normalize(topic);
List<SocialChannel> 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<String, Object> 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<SocialVideo> 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<SocialVideo> 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<SocialVideo> 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);
}
}