From 5dcc61d7403124dad65b38afed3062f5a98044de Mon Sep 17 00:00:00 2001 From: son0307 <162094052+son0307@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:30:31 +0900 Subject: [PATCH 1/2] Add team news collection and translation --- frontend/src/api.ts | 44 +++ frontend/src/pages/TeamDetailPage.tsx | 353 +++++++++++++++++- frontend/src/styles.css | 289 ++++++++++++++ .../global/exception/ErrorCode.java | 3 + .../client/OpenAiTitleTranslationClient.java | 138 +++++++ .../news/client/SerpApiNewsClient.java | 182 +++++++++ .../news/config/NewsClientConfig.java | 26 ++ .../news/config/NewsProperties.java | 54 +++ .../controller/AdminTeamNewsController.java | 31 ++ .../news/controller/TeamNewsController.java | 26 ++ .../news/dto/TeamNewsListResponseDto.java | 10 + .../news/dto/TeamNewsResponseDto.java | 13 + .../news/entity/NewsArticle.java | 93 +++++ .../news/entity/TeamNewsArticle.java | 64 ++++ .../news/entity/TeamNewsCollectionState.java | 42 +++ .../repository/NewsArticleRepository.java | 44 +++ .../repository/TeamNewsArticleRepository.java | 49 +++ .../TeamNewsCollectionStateRepository.java | 7 + .../news/scheduler/NewsSyncScheduler.java | 48 +++ .../service/AdminNewsTranslationService.java | 53 +++ .../service/AdminTeamNewsRefreshService.java | 50 +++ .../news/service/NewsCleanupService.java | 32 ++ .../news/service/NewsCollectionService.java | 55 +++ .../news/service/NewsPersistenceService.java | 81 ++++ .../service/NewsTitleTranslationService.java | 71 ++++ .../NewsTranslationPersistenceService.java | 40 ++ .../news/service/NewsUrlNormalizer.java | 72 ++++ .../news/service/TeamNewsService.java | 53 +++ src/main/resources/application.yml | 24 ++ .../OpenAiTitleTranslationClientTest.java | 46 +++ .../news/client/SerpApiNewsClientTest.java | 84 +++++ .../AdminNewsTranslationServiceTest.java | 83 ++++ .../AdminTeamNewsRefreshServiceTest.java | 41 ++ .../news/service/NewsCleanupServiceTest.java | 45 +++ .../service/NewsCollectionServiceTest.java | 45 +++ .../service/NewsPersistenceServiceTest.java | 124 ++++++ .../NewsTitleTranslationServiceTest.java | 85 +++++ ...NewsTranslationPersistenceServiceTest.java | 50 +++ .../news/service/TeamNewsServiceTest.java | 69 ++++ 39 files changed, 2709 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/son/soccerStreaming/news/client/OpenAiTitleTranslationClient.java create mode 100644 src/main/java/com/son/soccerStreaming/news/client/SerpApiNewsClient.java create mode 100644 src/main/java/com/son/soccerStreaming/news/config/NewsClientConfig.java create mode 100644 src/main/java/com/son/soccerStreaming/news/config/NewsProperties.java create mode 100644 src/main/java/com/son/soccerStreaming/news/controller/AdminTeamNewsController.java create mode 100644 src/main/java/com/son/soccerStreaming/news/controller/TeamNewsController.java create mode 100644 src/main/java/com/son/soccerStreaming/news/dto/TeamNewsListResponseDto.java create mode 100644 src/main/java/com/son/soccerStreaming/news/dto/TeamNewsResponseDto.java create mode 100644 src/main/java/com/son/soccerStreaming/news/entity/NewsArticle.java create mode 100644 src/main/java/com/son/soccerStreaming/news/entity/TeamNewsArticle.java create mode 100644 src/main/java/com/son/soccerStreaming/news/entity/TeamNewsCollectionState.java create mode 100644 src/main/java/com/son/soccerStreaming/news/repository/NewsArticleRepository.java create mode 100644 src/main/java/com/son/soccerStreaming/news/repository/TeamNewsArticleRepository.java create mode 100644 src/main/java/com/son/soccerStreaming/news/repository/TeamNewsCollectionStateRepository.java create mode 100644 src/main/java/com/son/soccerStreaming/news/scheduler/NewsSyncScheduler.java create mode 100644 src/main/java/com/son/soccerStreaming/news/service/AdminNewsTranslationService.java create mode 100644 src/main/java/com/son/soccerStreaming/news/service/AdminTeamNewsRefreshService.java create mode 100644 src/main/java/com/son/soccerStreaming/news/service/NewsCleanupService.java create mode 100644 src/main/java/com/son/soccerStreaming/news/service/NewsCollectionService.java create mode 100644 src/main/java/com/son/soccerStreaming/news/service/NewsPersistenceService.java create mode 100644 src/main/java/com/son/soccerStreaming/news/service/NewsTitleTranslationService.java create mode 100644 src/main/java/com/son/soccerStreaming/news/service/NewsTranslationPersistenceService.java create mode 100644 src/main/java/com/son/soccerStreaming/news/service/NewsUrlNormalizer.java create mode 100644 src/main/java/com/son/soccerStreaming/news/service/TeamNewsService.java create mode 100644 src/test/java/com/son/soccerStreaming/news/client/OpenAiTitleTranslationClientTest.java create mode 100644 src/test/java/com/son/soccerStreaming/news/client/SerpApiNewsClientTest.java create mode 100644 src/test/java/com/son/soccerStreaming/news/service/AdminNewsTranslationServiceTest.java create mode 100644 src/test/java/com/son/soccerStreaming/news/service/AdminTeamNewsRefreshServiceTest.java create mode 100644 src/test/java/com/son/soccerStreaming/news/service/NewsCleanupServiceTest.java create mode 100644 src/test/java/com/son/soccerStreaming/news/service/NewsCollectionServiceTest.java create mode 100644 src/test/java/com/son/soccerStreaming/news/service/NewsPersistenceServiceTest.java create mode 100644 src/test/java/com/son/soccerStreaming/news/service/NewsTitleTranslationServiceTest.java create mode 100644 src/test/java/com/son/soccerStreaming/news/service/NewsTranslationPersistenceServiceTest.java create mode 100644 src/test/java/com/son/soccerStreaming/news/service/TeamNewsServiceTest.java diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 6638b04..66d38bb 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -375,6 +375,32 @@ export type TeamDetails = TeamSummary & { venue: TeamVenue | null; }; +export type TeamNewsArticle = { + articleId: number; + originalTitle: string; + translatedTitle: string | null; + publisherName: string; + originalUrl: string; + publishedAt: string | null; +}; + +export type TeamNewsResponse = { + lastCollectedAt: string | null; + articles: TeamNewsArticle[]; +}; + +export type TeamNewsRefreshResult = { + collectedArticles: number; + translationCandidates: number; + translatedArticles: number; + failedTranslations: number; +}; + +export type TeamNewsTranslationResult = { + articleId: number; + translatedTitle: string; +}; + export type TeamVenue = { venueId: number | null; venueName: string | null; @@ -802,6 +828,24 @@ export async function fetchTeamDetails(teamId: number): Promise { ); } +export async function fetchTeamNews(teamId: number): Promise { + return cachedGetJson( + `/api/v1/teams/${teamId}/news`, + "팀 뉴스를 불러오지 못했습니다.", + DETAIL_CACHE_TTL_MS, + ); +} + +export async function refreshTeamNews(teamId: number): Promise { + const result = await postJson(`/api/v1/admin/teams/${teamId}/news/refresh`, {}) as TeamNewsRefreshResult; + clearApiMemoryCache(); + return result; +} + +export async function translateTeamNewsArticle(teamId: number, articleId: number): Promise { + return await postJson(`/api/v1/admin/teams/${teamId}/news/${articleId}/translate`, {}) as TeamNewsTranslationResult; +} + export async function fetchTeamPlayers(teamId: number, season: number): Promise { return cachedGetJson( `/api/v1/teams/${teamId}/players?season=${normalizeSeason(season)}`, diff --git a/frontend/src/pages/TeamDetailPage.tsx b/frontend/src/pages/TeamDetailPage.tsx index e886792..07285a0 100644 --- a/frontend/src/pages/TeamDetailPage.tsx +++ b/frontend/src/pages/TeamDetailPage.tsx @@ -1,21 +1,26 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { Dispatch, SetStateAction } from "react"; -import { CalendarDays, Clock, Goal, Pencil, Star, Users } from "lucide-react"; +import { CalendarDays, Clock, ExternalLink, Goal, Languages, Newspaper, Pencil, RefreshCw, Star, Users } from "lucide-react"; import type { LucideIcon } from "lucide-react"; -import { Link, useParams } from "react-router-dom"; +import { Link, useParams, useSearchParams } from "react-router-dom"; import { ApiError, addFavoriteTeam, fetchFavoriteDashboard, fetchFixtures, fetchTeamDetails, + fetchTeamNews, fetchTeamPlayerRankings, fetchTeamPlayers, removeFavoriteTeam, + refreshTeamNews, + translateTeamNewsArticle, type CurrentUser, type FixtureSummary, type PlayerSummary, type TeamDetails, + type TeamNewsResponse, + type TeamNewsRefreshResult, type TeamPlayerRanking, } from "../api"; import type { AuthStatus } from "../App"; @@ -33,8 +38,13 @@ const TEAM_FIXTURE_PAGE_SIZE = 10; export function TeamDetailPage({ authStatus, currentUser, season }: { authStatus: AuthStatus; currentUser: CurrentUser | null; season: number }) { const { teamId } = useParams(); + const [searchParams] = useSearchParams(); const numericTeamId = Number(teamId); + const activeTab = searchParams.get("tab") === "news" ? "news" : "info"; const loadRequestId = useRef(0); + const newsRequestId = useRef(0); + const activeNewsTeamId = useRef(numericTeamId); + activeNewsTeamId.current = numericTeamId; const [teamState, setTeamState] = useState>({ data: null, error: "", @@ -55,11 +65,27 @@ export function TeamDetailPage({ authStatus, currentUser, season }: { authStatus error: "", isLoading: true, }); + const [newsState, setNewsState] = useState>({ + data: null, + error: "", + isLoading: false, + }); + const [newsLanguage, setNewsLanguage] = useState<"ko" | "en">("ko"); + const [isNewsRefreshing, setIsNewsRefreshing] = useState(false); + const [newsRefreshMessage, setNewsRefreshMessage] = useState(""); + const [newsRefreshError, setNewsRefreshError] = useState(""); + const [translatingArticleIds, setTranslatingArticleIds] = useState>(() => new Set()); + const [newsTranslationErrors, setNewsTranslationErrors] = useState>({}); const [fixturePage, setFixturePage] = useState(0); const [isFavorite, setIsFavorite] = useState(false); const [isFavoriteLoading, setIsFavoriteLoading] = useState(false); const [favoriteError, setFavoriteError] = useState(""); + useEffect(() => { + setTranslatingArticleIds(new Set()); + setNewsTranslationErrors({}); + }, [numericTeamId]); + useEffect(() => { if (!Number.isFinite(numericTeamId) || numericTeamId <= 0) { const error = "올바른 팀 ID가 아닙니다."; @@ -150,6 +176,51 @@ export function TeamDetailPage({ authStatus, currentUser, season }: { authStatus }; }, [numericTeamId, season]); + useEffect(() => { + setNewsLanguage("ko"); + setNewsState({ data: null, error: "", isLoading: false }); + setIsNewsRefreshing(false); + setNewsRefreshMessage(""); + setNewsRefreshError(""); + newsRequestId.current += 1; + }, [numericTeamId]); + + useEffect(() => { + if (activeTab !== "news") { + newsRequestId.current += 1; + return; + } + if (!Number.isFinite(numericTeamId) || numericTeamId <= 0) { + setNewsState({ data: null, error: "올바른 팀 ID가 아닙니다.", isLoading: false }); + return; + } + + const requestId = newsRequestId.current + 1; + newsRequestId.current = requestId; + let isCurrent = true; + setNewsState({ data: null, error: "", isLoading: true }); + + fetchTeamNews(numericTeamId) + .then((articles) => { + if (isCurrent && newsRequestId.current === requestId) { + setNewsState({ data: articles, error: "", isLoading: false }); + } + }) + .catch((error) => { + if (isCurrent && newsRequestId.current === requestId) { + setNewsState({ + data: null, + error: error instanceof Error ? error.message : "팀 뉴스를 불러오지 못했습니다.", + isLoading: false, + }); + } + }); + + return () => { + isCurrent = false; + }; + }, [activeTab, numericTeamId]); + useEffect(() => { if (!Number.isFinite(numericTeamId) || numericTeamId <= 0) { setIsFavorite(false); @@ -346,6 +417,108 @@ export function TeamDetailPage({ authStatus, currentUser, season }: { authStatus }); } + function retryNews() { + if (!Number.isFinite(numericTeamId) || numericTeamId <= 0) { + return; + } + + const requestId = newsRequestId.current + 1; + newsRequestId.current = requestId; + setNewsState({ data: null, error: "", isLoading: true }); + fetchTeamNews(numericTeamId) + .then((articles) => { + if (newsRequestId.current === requestId) { + setNewsState({ data: articles, error: "", isLoading: false }); + } + }) + .catch((error) => { + if (newsRequestId.current === requestId) { + setNewsState({ + data: null, + error: error instanceof Error ? error.message : "팀 뉴스를 불러오지 못했습니다.", + isLoading: false, + }); + } + }); + } + + async function refreshNewsFromProviders() { + if (!Number.isFinite(numericTeamId) || numericTeamId <= 0 || isNewsRefreshing) { + return; + } + + const requestId = newsRequestId.current + 1; + newsRequestId.current = requestId; + setIsNewsRefreshing(true); + setNewsRefreshMessage(""); + setNewsRefreshError(""); + try { + const result = await refreshTeamNews(numericTeamId); + if (newsRequestId.current !== requestId) { + return; + } + const articles = await fetchTeamNews(numericTeamId); + if (newsRequestId.current === requestId) { + setNewsState({ data: articles, error: "", isLoading: false }); + setNewsRefreshMessage(newsRefreshResultMessage(result)); + } + } catch (error) { + if (newsRequestId.current === requestId) { + setNewsRefreshError(error instanceof Error ? error.message : "뉴스 새로고침에 실패했습니다."); + } + } finally { + if (newsRequestId.current === requestId) { + setIsNewsRefreshing(false); + } + } + } + + async function translateNewsArticle(articleId: number) { + if (!Number.isFinite(numericTeamId) || numericTeamId <= 0 || translatingArticleIds.has(articleId)) { + return; + } + + const requestedTeamId = numericTeamId; + setTranslatingArticleIds((current) => new Set(current).add(articleId)); + setNewsTranslationErrors((current) => { + const next = { ...current }; + delete next[articleId]; + return next; + }); + try { + const result = await translateTeamNewsArticle(requestedTeamId, articleId); + if (activeNewsTeamId.current !== requestedTeamId) { + return; + } + setNewsState((current) => ({ + ...current, + data: current.data ? { + ...current.data, + articles: current.data.articles.map((article) => ( + article.articleId === result.articleId + ? { ...article, translatedTitle: result.translatedTitle } + : article + )), + } : null, + })); + } catch (error) { + if (activeNewsTeamId.current === requestedTeamId) { + setNewsTranslationErrors((current) => ({ + ...current, + [articleId]: error instanceof Error ? error.message : "뉴스 제목을 번역하지 못했습니다.", + })); + } + } finally { + if (activeNewsTeamId.current === requestedTeamId) { + setTranslatingArticleIds((current) => { + const next = new Set(current); + next.delete(articleId); + return next; + }); + } + } + } + if (teamState.isLoading) { return (
@@ -386,18 +559,161 @@ export function TeamDetailPage({ authStatus, currentUser, season }: { authStatus /> - - - + + {activeTab === "info" ? ( + <> + + + + + ) : ( + + )}
); } +function TeamNewsPanel({ + canRefresh, + isRefreshing, + language, + newsState, + onLanguageChange, + onRefresh, + onRetry, + onTranslate, + refreshError, + refreshMessage, + translatingArticleIds, + translationErrors, +}: { + canRefresh: boolean; + isRefreshing: boolean; + language: "ko" | "en"; + newsState: LoadState; + onLanguageChange: (language: "ko" | "en") => void; + onRefresh: () => void; + onRetry: () => void; + onTranslate: (articleId: number) => void; + refreshError: string; + refreshMessage: string; + translatingArticleIds: Set; + translationErrors: Record; +}) { + const articles = newsState.data?.articles ?? []; + + return ( +
+
+
+
+
+ {canRefresh ? ( + + ) : null} +
+ + +
+
+
+

+

+ {refreshMessage ?

{refreshMessage}

: null} + {refreshError ?

{refreshError}

: null} + {newsState.isLoading ?
팀 뉴스를 불러오는 중입니다.
: null} + {newsState.error ? : null} + {!newsState.isLoading && !newsState.error && !articles.length ? ( +
표시할 팀 뉴스가 없습니다.
+ ) : null} + {!newsState.isLoading && !newsState.error && articles.length ? ( +
+ {articles.map((article) => { + const title = language === "ko" ? article.translatedTitle ?? article.originalTitle : article.originalTitle; + const isTranslating = translatingArticleIds.has(article.articleId); + return ( +
+ +
+ {title} +

+ {article.publisherName} + {article.publishedAt ? : null} +

+
+
+ {canRefresh && !article.translatedTitle ? ( +
+ + {translationErrors[article.articleId] ? ( + {translationErrors[article.articleId]} + ) : null} +
+ ) : null} +
+ ); + })} +
+ ) : null} +
+ ); +} + function TeamVenueCard({ venue }: { venue: TeamDetails["venue"] }) { const [imageFailed, setImageFailed] = useState(false); @@ -797,6 +1113,23 @@ function compareNullableStringLast(left: string | null | undefined, right: strin return 0; } +function formatNewsDate(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return new Intl.DateTimeFormat("ko-KR", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "Asia/Seoul", + }).format(date); +} + +function newsRefreshResultMessage(result: TeamNewsRefreshResult) { + const failed = result.failedTranslations > 0 ? `, ${result.failedTranslations}건 실패` : ""; + return `${result.collectedArticles}건 확인, 번역 ${result.translatedArticles}/${result.translationCandidates}건 완료${failed}`; +} + function groupFixturesByDate(fixtures: FixtureSummary[]) { const groups = new Map(); fixtures.forEach((fixture) => { diff --git a/frontend/src/styles.css b/frontend/src/styles.css index a8928a9..e914101 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -3796,6 +3796,241 @@ h2 { gap: 16px; } +.team-detail-tabs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + overflow: hidden; + border: 1px solid #dce6da; + border-radius: 10px; + background: #ffffff; +} + +.team-detail-tabs a { + padding: 12px 16px; + color: #667269; + font-weight: 900; + text-align: center; +} + +.team-detail-tabs a:hover { + background: #f5f9f3; +} + +.team-detail-tabs a.active { + background: #1b5e20; + color: #ffffff; +} + +.team-news-panel { + overflow: hidden; +} + +.team-news-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid #edf1eb; + padding-right: 16px; +} + +.team-news-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.team-news-refresh { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 38px; + border: 1px solid #cbd9c9; + border-radius: 8px; + padding: 7px 11px; + background: #ffffff; + color: #1b5e20; + font-size: 0.78rem; + font-weight: 900; +} + +.team-news-refresh:hover:not(:disabled) { + background: #eef6ed; +} + +.team-news-refresh:disabled { + cursor: wait; + opacity: 0.65; +} + +.team-news-refresh .spinning { + animation: admin-spin 0.8s linear infinite; +} + +.team-news-refresh-status { + margin: 0; + border-bottom: 1px solid #edf1eb; + padding: 10px 18px; + font-size: 0.8rem; + font-weight: 800; +} + +.team-news-refresh-status.success { + background: #edf7ec; + color: #245b2a; +} + +.team-news-refresh-status.error { + background: #fff1f0; + color: #9f2d25; +} + +.team-news-language { + display: inline-flex; + border: 1px solid #d7e2d5; + border-radius: 8px; + padding: 3px; + background: #f5f8f4; +} + +.team-news-language button { + min-height: 32px; + border: 0; + border-radius: 6px; + padding: 6px 11px; + background: transparent; + color: #607065; + font-size: 0.78rem; + font-weight: 900; +} + +.team-news-language button.active { + background: #ffffff; + color: #1b5e20; + box-shadow: 0 1px 4px rgba(24, 56, 30, 0.14); +} + +.team-news-list { + display: grid; +} + +.team-news-collected-at { + display: flex; + align-items: center; + gap: 6px; + margin: 0; + border-top: 1px solid #edf1eb; + border-bottom: 1px solid #edf1eb; + padding: 10px 18px; + background: #fafcf9; + color: #68766b; + font-size: 0.76rem; + font-weight: 800; +} + +.team-news-row { + display: flex; + align-items: center; + gap: 18px; + border-bottom: 1px solid #edf1eb; + padding: 17px 18px; +} + +.team-news-row:last-child { + border-bottom: 0; +} + +.team-news-row:hover { + background: #fbfdf9; +} + +.team-news-link { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 18px; + min-width: 0; + flex: 1 1 auto; +} + +.team-news-link > div { + min-width: 0; +} + +.team-news-row strong { + display: block; + overflow-wrap: anywhere; + color: #213826; + font-size: 1rem; + line-height: 1.5; +} + +.team-news-row p { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin: 7px 0 0; + color: #68766b; + font-size: 0.78rem; + font-weight: 800; +} + +.team-news-row em { + border-radius: 999px; + padding: 3px 7px; + background: #fff3cd; + color: #745c0b; + font-size: 0.7rem; + font-style: normal; +} + +.team-news-link > svg { + color: #66756a; + flex: 0 0 auto; +} + +.team-news-translation-action { + display: grid; + justify-items: end; + gap: 6px; + flex: 0 0 auto; +} + +.team-news-translation-action button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 34px; + border: 1px solid #cbd8ca; + border-radius: 9px; + padding: 7px 10px; + background: #ffffff; + color: #2e6336; + font-size: 0.76rem; + font-weight: 900; +} + +.team-news-translation-action button:hover:not(:disabled) { + border-color: #7eaa82; + background: #f4faf3; +} + +.team-news-translation-action button:disabled { + cursor: wait; + opacity: 0.65; +} + +.team-news-translation-action span { + max-width: 220px; + color: #b42318; + font-size: 0.7rem; + font-weight: 800; + text-align: right; +} + .team-overview-grid { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr); @@ -3991,6 +4226,60 @@ h2 { } @media (max-width: 760px) { + .team-news-heading { + align-items: stretch; + flex-direction: column; + gap: 0; + padding: 0 14px 14px; + } + + .team-news-heading .detail-panel-heading { + padding-right: 0; + padding-left: 0; + } + + .team-news-language { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .team-news-actions { + display: grid; + grid-template-columns: 1fr; + } + + .team-news-refresh { + width: 100%; + } + + .team-news-row { + flex-direction: column; + align-items: start; + gap: 10px; + padding: 15px 14px; + } + + .team-news-link { + width: 100%; + gap: 10px; + } + + .team-news-translation-action { + display: flex; + align-items: center; + justify-content: flex-end; + width: 100%; + } + + .team-news-translation-action span { + max-width: min(220px, 65vw); + text-align: left; + } + + .team-news-row strong { + font-size: 0.94rem; + } + .team-venue-address-popover { right: 12px; left: auto; diff --git a/src/main/java/com/son/soccerStreaming/global/exception/ErrorCode.java b/src/main/java/com/son/soccerStreaming/global/exception/ErrorCode.java index c122694..2582244 100644 --- a/src/main/java/com/son/soccerStreaming/global/exception/ErrorCode.java +++ b/src/main/java/com/son/soccerStreaming/global/exception/ErrorCode.java @@ -32,9 +32,12 @@ public enum ErrorCode { USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."), ADMIN_SYNC_JOB_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 관리자 동기화 작업을 찾을 수 없습니다."), ADMIN_MEDIA_OBJECT_NOT_FOUND(HttpStatus.NOT_FOUND, "업로드된 이미지 객체를 찾을 수 없습니다."), + NEWS_ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 팀의 뉴스 기사를 찾을 수 없습니다."), // 503 SERVICE_UNAVAILABLE ADMIN_MEDIA_STORAGE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "이미지 저장소를 사용할 수 없습니다."), + NEWS_REFRESH_FAILED(HttpStatus.BAD_GATEWAY, "뉴스를 새로고침하지 못했습니다. 잠시 후 다시 시도해 주세요."), + NEWS_TRANSLATION_FAILED(HttpStatus.BAD_GATEWAY, "뉴스 제목을 번역하지 못했습니다. 잠시 후 다시 시도해 주세요."), // 500 INTERNAL_SERVER_ERROR INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "예기치 못한 서버 오류가 발생했습니다."), diff --git a/src/main/java/com/son/soccerStreaming/news/client/OpenAiTitleTranslationClient.java b/src/main/java/com/son/soccerStreaming/news/client/OpenAiTitleTranslationClient.java new file mode 100644 index 0000000..b3270df --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/client/OpenAiTitleTranslationClient.java @@ -0,0 +1,138 @@ +package com.son.soccerStreaming.news.client; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import com.son.soccerStreaming.news.config.NewsProperties; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +public class OpenAiTitleTranslationClient { + + private static final String SYSTEM_PROMPT = """ + You translate English football news headlines into natural Korean headlines. + Translate only the supplied title. Do not summarize, add facts, remove claims, or editorialize. + Preserve club, player, competition, and publication proper nouns accurately. + Return exactly one result for every supplied articleId. + """; + + private final RestClient restClient; + private final NewsProperties properties; + private final ObjectMapper objectMapper; + + public OpenAiTitleTranslationClient( + @Qualifier("openAiNewsRestClient") RestClient restClient, + NewsProperties properties, + ObjectMapper objectMapper + ) { + this.restClient = restClient; + this.properties = properties; + this.objectMapper = objectMapper; + } + + public Map translate(List inputs) { + if (inputs.isEmpty()) { + return Map.of(); + } + String apiKey = properties.getTranslation().getApiKey(); + if (!StringUtils.hasText(apiKey)) { + throw new IllegalStateException("OpenAI API key is not configured."); + } + + Map request = new LinkedHashMap<>(); + request.put("model", properties.getTranslation().getModel()); + request.put("store", false); + request.put("input", List.of( + Map.of("role", "system", "content", SYSTEM_PROMPT), + Map.of("role", "user", "content", writeJson(inputs)) + )); + request.put("text", Map.of("format", translationFormat())); + request.put("max_output_tokens", Math.max(1000, inputs.size() * 80)); + + JsonNode response = restClient.post() + .uri("/v1/responses") + .header("Authorization", "Bearer " + apiKey) + .body(request) + .retrieve() + .body(JsonNode.class); + + String outputText = extractOutputText(response); + try { + TranslationEnvelope envelope = objectMapper.readValue(outputText, TranslationEnvelope.class); + Map translated = new LinkedHashMap<>(); + if (envelope.translations() != null) { + envelope.translations().forEach(item -> { + if (item.articleId() != null && StringUtils.hasText(item.translatedTitle())) { + translated.put(item.articleId(), item.translatedTitle().trim()); + } + }); + } + return translated; + } catch (Exception e) { + throw new IllegalStateException("OpenAI returned an invalid title translation response.", e); + } + } + + private Map translationFormat() { + Map item = Map.of( + "type", "object", + "properties", Map.of( + "articleId", Map.of("type", "integer"), + "translatedTitle", Map.of("type", "string") + ), + "required", List.of("articleId", "translatedTitle"), + "additionalProperties", false + ); + Map schema = Map.of( + "type", "object", + "properties", Map.of( + "translations", Map.of("type", "array", "items", item) + ), + "required", List.of("translations"), + "additionalProperties", false + ); + return Map.of( + "type", "json_schema", + "name", "news_title_translations", + "strict", true, + "schema", schema + ); + } + + private String extractOutputText(JsonNode response) { + if (response != null) { + for (JsonNode output : response.path("output")) { + for (JsonNode content : output.path("content")) { + String text = content.path("text").asText(""); + if (StringUtils.hasText(text)) { + return text; + } + } + } + } + throw new IllegalStateException("OpenAI response did not contain output text."); + } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception e) { + throw new IllegalStateException("Could not serialize title translation input.", e); + } + } + + public record TranslationInput(Long articleId, String originalTitle) { + } + + record TranslationEnvelope(List translations) { + } + + record TranslationResult(Long articleId, String translatedTitle) { + } +} diff --git a/src/main/java/com/son/soccerStreaming/news/client/SerpApiNewsClient.java b/src/main/java/com/son/soccerStreaming/news/client/SerpApiNewsClient.java new file mode 100644 index 0000000..9437de3 --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/client/SerpApiNewsClient.java @@ -0,0 +1,182 @@ +package com.son.soccerStreaming.news.client; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.son.soccerStreaming.news.config.NewsProperties; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; + +import java.net.URI; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Component +public class SerpApiNewsClient { + + private final RestClient restClient; + private final NewsProperties properties; + + public SerpApiNewsClient( + @Qualifier("serpApiRestClient") RestClient restClient, + NewsProperties properties + ) { + this.restClient = restClient; + this.properties = properties; + } + + public List searchTeamNews(String teamName) { + String apiKey = properties.getSerpApi().getApiKey(); + if (!StringUtils.hasText(apiKey)) { + throw new IllegalStateException("SerpApi API key is not configured."); + } + + SerpApiResponse response = restClient.get() + .uri(uriBuilder -> uriBuilder + .path("/search.json") + .queryParam("engine", "google_news") + .queryParam("q", buildQuery(teamName)) + .queryParam("gl", "uk") + .queryParam("hl", "en") + .queryParam("api_key", apiKey) + .build()) + .retrieve() + .body(SerpApiResponse.class); + + if (response == null || response.newsResults() == null) { + return List.of(); + } + + List flattened = new ArrayList<>(); + response.newsResults().forEach(result -> flatten(result, flattened)); + + Map uniqueByUrl = new LinkedHashMap<>(); + flattened.stream() + .filter(article -> isAllowedUrl(article.url())) + .forEach(article -> uniqueByUrl.putIfAbsent(article.url(), article)); + + return uniqueByUrl.values().stream() + .limit(properties.getSerpApi().getMaxArticlesPerTeam()) + .toList(); + } + + String buildQuery(String teamName) { + String sites = properties.getSerpApi().getSearchSites().stream() + .filter(StringUtils::hasText) + .map(site -> "site:" + site.trim()) + .reduce((left, right) -> left + " OR " + right) + .map(value -> "(" + value + ")") + .orElseThrow(() -> new IllegalStateException("News search sites are not configured.")); + return "\"%s\" football when:%dd %s".formatted( + teamName, + properties.getSerpApi().getLookbackDays(), + sites + ); + } + + boolean isAllowedUrl(String value) { + if (!StringUtils.hasText(value)) { + return false; + } + try { + URI uri = URI.create(value); + if (!("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme()))) { + return false; + } + String host = normalizeHost(uri.getHost()); + String path = uri.getPath() == null ? "" : uri.getPath().toLowerCase(Locale.ROOT); + return switch (host) { + case "bbc.com" -> path.startsWith("/sport/football"); + case "skysports.com", "telegraph.co.uk", "theguardian.com" -> path.startsWith("/football"); + case "nytimes.com" -> path.startsWith("/athletic"); + case "goal.com", "football.london" -> true; + default -> false; + }; + } catch (IllegalArgumentException e) { + return false; + } + } + + public String publisherDomain(String value) { + String host = normalizeHost(URI.create(value).getHost()); + return host; + } + + private String normalizeHost(String host) { + if (host == null) { + return ""; + } + String normalized = host.toLowerCase(Locale.ROOT); + return normalized.startsWith("www.") ? normalized.substring(4) : normalized; + } + + private void flatten(SerpNewsResult result, List target) { + if (result == null) { + return; + } + if (result.highlight() != null) { + flatten(result.highlight(), target); + } + if (result.stories() != null) { + result.stories().forEach(story -> flatten(story, target)); + } + if (StringUtils.hasText(result.title()) && StringUtils.hasText(result.link())) { + String title = result.title().trim(); + String link = result.link().trim(); + if (title.length() > 1000 || link.length() > 2048 || !isAllowedUrl(link)) { + return; + } + String publisher = result.source() != null && StringUtils.hasText(result.source().name()) + ? result.source().name().trim() + : publisherDomain(link); + if (publisher.length() > 255) { + publisher = publisher.substring(0, 255); + } + target.add(new SearchArticle( + title, + link, + publisher, + parseInstant(result.isoDate()) + )); + } + } + + private Instant parseInstant(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + try { + return Instant.parse(value); + } catch (DateTimeParseException ignored) { + return null; + } + } + + public record SearchArticle(String title, String url, String publisherName, Instant publishedAt) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record SerpApiResponse(@JsonProperty("news_results") List newsResults) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record SerpNewsResult( + String title, + String link, + SerpSource source, + @JsonProperty("iso_date") String isoDate, + List stories, + SerpNewsResult highlight + ) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record SerpSource(String name) { + } +} diff --git a/src/main/java/com/son/soccerStreaming/news/config/NewsClientConfig.java b/src/main/java/com/son/soccerStreaming/news/config/NewsClientConfig.java new file mode 100644 index 0000000..c6458cf --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/config/NewsClientConfig.java @@ -0,0 +1,26 @@ +package com.son.soccerStreaming.news.config; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +@Configuration +public class NewsClientConfig { + + @Bean + @Qualifier("serpApiRestClient") + public RestClient serpApiRestClient(NewsProperties properties) { + return RestClient.builder() + .baseUrl(properties.getSerpApi().getBaseUrl()) + .build(); + } + + @Bean + @Qualifier("openAiNewsRestClient") + public RestClient openAiNewsRestClient(NewsProperties properties) { + return RestClient.builder() + .baseUrl(properties.getTranslation().getBaseUrl()) + .build(); + } +} diff --git a/src/main/java/com/son/soccerStreaming/news/config/NewsProperties.java b/src/main/java/com/son/soccerStreaming/news/config/NewsProperties.java new file mode 100644 index 0000000..ab7aa87 --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/config/NewsProperties.java @@ -0,0 +1,54 @@ +package com.son.soccerStreaming.news.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "news") +public class NewsProperties { + + private SerpApi serpApi = new SerpApi(); + private Translation translation = new Translation(); + private Sync sync = new Sync(); + + @Getter + @Setter + public static class SerpApi { + private String baseUrl = "https://serpapi.com"; + private String apiKey = ""; + private int lookbackDays = 7; + private int maxArticlesPerTeam = 20; + private List searchSites = new ArrayList<>(List.of( + "bbc.com/sport/football", + "skysports.com/football", + "theguardian.com/football", + "nytimes.com/athletic", + "goal.com", + "telegraph.co.uk/football", + "football.london" + )); + } + + @Getter + @Setter + public static class Translation { + private String baseUrl = "https://api.openai.com"; + private String apiKey = ""; + private String model = "gpt-5.6-luna"; + private int batchSize = 50; + } + + @Getter + @Setter + public static class Sync { + private boolean enabled = false; + private int retentionDays = 90; + } +} diff --git a/src/main/java/com/son/soccerStreaming/news/controller/AdminTeamNewsController.java b/src/main/java/com/son/soccerStreaming/news/controller/AdminTeamNewsController.java new file mode 100644 index 0000000..fab4ace --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/controller/AdminTeamNewsController.java @@ -0,0 +1,31 @@ +package com.son.soccerStreaming.news.controller; + +import com.son.soccerStreaming.news.service.AdminTeamNewsRefreshService; +import com.son.soccerStreaming.news.service.AdminNewsTranslationService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/teams/{teamId}/news") +@RequiredArgsConstructor +public class AdminTeamNewsController { + + private final AdminTeamNewsRefreshService refreshService; + private final AdminNewsTranslationService translationService; + + @PostMapping("/refresh") + public AdminTeamNewsRefreshService.RefreshResult refresh(@PathVariable Long teamId) { + return refreshService.refresh(teamId); + } + + @PostMapping("/{articleId}/translate") + public AdminNewsTranslationService.TranslationResult translate( + @PathVariable Long teamId, + @PathVariable Long articleId + ) { + return translationService.translate(teamId, articleId); + } +} diff --git a/src/main/java/com/son/soccerStreaming/news/controller/TeamNewsController.java b/src/main/java/com/son/soccerStreaming/news/controller/TeamNewsController.java new file mode 100644 index 0000000..01c074f --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/controller/TeamNewsController.java @@ -0,0 +1,26 @@ +package com.son.soccerStreaming.news.controller; + +import com.son.soccerStreaming.news.dto.TeamNewsListResponseDto; +import com.son.soccerStreaming.news.service.TeamNewsService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Team News", description = "Stored team news API") +@RestController +@RequestMapping("/api/v1/teams/{teamId}/news") +@RequiredArgsConstructor +public class TeamNewsController { + + private final TeamNewsService teamNewsService; + + @Operation(summary = "Get latest team news") + @GetMapping + public TeamNewsListResponseDto getTeamNews(@PathVariable Long teamId) { + return teamNewsService.getTeamNews(teamId); + } +} diff --git a/src/main/java/com/son/soccerStreaming/news/dto/TeamNewsListResponseDto.java b/src/main/java/com/son/soccerStreaming/news/dto/TeamNewsListResponseDto.java new file mode 100644 index 0000000..10c5549 --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/dto/TeamNewsListResponseDto.java @@ -0,0 +1,10 @@ +package com.son.soccerStreaming.news.dto; + +import java.time.Instant; +import java.util.List; + +public record TeamNewsListResponseDto( + Instant lastCollectedAt, + List articles +) { +} diff --git a/src/main/java/com/son/soccerStreaming/news/dto/TeamNewsResponseDto.java b/src/main/java/com/son/soccerStreaming/news/dto/TeamNewsResponseDto.java new file mode 100644 index 0000000..d5b3ef2 --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/dto/TeamNewsResponseDto.java @@ -0,0 +1,13 @@ +package com.son.soccerStreaming.news.dto; + +import java.time.Instant; + +public record TeamNewsResponseDto( + Long articleId, + String originalTitle, + String translatedTitle, + String publisherName, + String originalUrl, + Instant publishedAt +) { +} diff --git a/src/main/java/com/son/soccerStreaming/news/entity/NewsArticle.java b/src/main/java/com/son/soccerStreaming/news/entity/NewsArticle.java new file mode 100644 index 0000000..b2c2b01 --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/entity/NewsArticle.java @@ -0,0 +1,93 @@ +package com.son.soccerStreaming.news.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.Objects; + +@Entity +@Table(name = "news_article", indexes = { + @Index(name = "idx_news_article_published_at", columnList = "published_at") +}) +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +public class NewsArticle { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 64) + private String urlHash; + + @Column(nullable = false, length = 2048) + private String originalUrl; + + @Column(nullable = false, length = 1000) + private String originalTitle; + + @Column(length = 1000) + private String translatedTitle; + + // Null means the article predates one-time automatic translation tracking. + // Such legacy articles are translated only through an explicit administrator request. + private Boolean autoTranslationAttempted; + + // Kept only so installations created by the earlier schema can still insert rows. + // Translation selection and UI behavior no longer depend on these legacy columns. + @Column(name = "translation_status", nullable = false, length = 20) + @Builder.Default + private String legacyTranslationStatus = "UNTRACKED"; + + @Column(name = "translation_attempt_count", nullable = false) + @Builder.Default + private int legacyTranslationAttemptCount = 0; + + @Column(nullable = false) + private String publisherName; + + @Column(nullable = false) + private String publisherDomain; + + private Instant publishedAt; + + @Column(nullable = false) + private Instant firstSeenAt; + + @Column(nullable = false) + private Instant lastSeenAt; + + public void updateMetadata(String originalUrl, String originalTitle, String publisherName, + String publisherDomain, Instant publishedAt, Instant seenAt) { + this.originalUrl = originalUrl; + this.publisherName = publisherName; + this.publisherDomain = publisherDomain; + this.publishedAt = publishedAt; + this.lastSeenAt = seenAt; + if (!Objects.equals(this.originalTitle, originalTitle)) { + this.originalTitle = originalTitle; + this.translatedTitle = null; + } + } + + public void markTranslated(String translatedTitle) { + this.translatedTitle = translatedTitle; + } + + public void markAutoTranslationAttempted() { + this.autoTranslationAttempted = true; + } +} diff --git a/src/main/java/com/son/soccerStreaming/news/entity/TeamNewsArticle.java b/src/main/java/com/son/soccerStreaming/news/entity/TeamNewsArticle.java new file mode 100644 index 0000000..da47ed3 --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/entity/TeamNewsArticle.java @@ -0,0 +1,64 @@ +package com.son.soccerStreaming.news.entity; + +import com.son.soccerStreaming.team.entity.Team; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Entity +@Table(name = "team_news_article", + uniqueConstraints = @UniqueConstraint( + name = "uk_team_news_article_team_article", + columnNames = {"team_id", "article_id"} + ), + indexes = { + @Index(name = "idx_team_news_article_team_seen", columnList = "team_id, last_seen_at"), + @Index(name = "idx_team_news_article_article", columnList = "article_id") + }) +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +public class TeamNewsArticle { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "team_id", nullable = false) + private Team team; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "article_id", nullable = false) + private NewsArticle article; + + @Column(nullable = false) + private Instant firstSeenAt; + + @Column(nullable = false) + private Instant lastSeenAt; + + @Column(name = "result_position") + private Integer resultPosition; + + public void markSeen(Instant seenAt, int resultPosition) { + this.lastSeenAt = seenAt; + this.resultPosition = resultPosition; + } +} diff --git a/src/main/java/com/son/soccerStreaming/news/entity/TeamNewsCollectionState.java b/src/main/java/com/son/soccerStreaming/news/entity/TeamNewsCollectionState.java new file mode 100644 index 0000000..5bd1e22 --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/entity/TeamNewsCollectionState.java @@ -0,0 +1,42 @@ +package com.son.soccerStreaming.news.entity; + +import com.son.soccerStreaming.team.entity.Team; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Entity +@Table(name = "team_news_collection_state") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +public class TeamNewsCollectionState { + + @Id + private Long teamId; + + @MapsId + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "team_id") + private Team team; + + @Column(nullable = false) + private Instant lastCollectedAt; + + public void markCollected(Instant collectedAt) { + this.lastCollectedAt = collectedAt; + } +} diff --git a/src/main/java/com/son/soccerStreaming/news/repository/NewsArticleRepository.java b/src/main/java/com/son/soccerStreaming/news/repository/NewsArticleRepository.java new file mode 100644 index 0000000..59a5fcf --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/repository/NewsArticleRepository.java @@ -0,0 +1,44 @@ +package com.son.soccerStreaming.news.repository; + +import com.son.soccerStreaming.news.entity.NewsArticle; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface NewsArticleRepository extends JpaRepository { + + Optional findByUrlHash(String urlHash); + + @Query(""" + select n + from NewsArticle n + where n.translatedTitle is null + and n.autoTranslationAttempted = false + order by n.firstSeenAt asc, n.id asc + """) + List findTranslationCandidates(); + + @Query(""" + select distinct n + from TeamNewsArticle t + join t.article n + where t.team.teamId = :teamId + and n.translatedTitle is null + and n.autoTranslationAttempted = false + order by n.firstSeenAt asc, n.id asc + """) + List findTranslationCandidatesByTeamId(@Param("teamId") Long teamId); + + @Modifying + @Query(""" + delete from NewsArticle n + where not exists ( + select t.id from TeamNewsArticle t where t.article = n + ) + """) + int deleteOrphans(); +} diff --git a/src/main/java/com/son/soccerStreaming/news/repository/TeamNewsArticleRepository.java b/src/main/java/com/son/soccerStreaming/news/repository/TeamNewsArticleRepository.java new file mode 100644 index 0000000..347960e --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/repository/TeamNewsArticleRepository.java @@ -0,0 +1,49 @@ +package com.son.soccerStreaming.news.repository; + +import com.son.soccerStreaming.news.entity.NewsArticle; +import com.son.soccerStreaming.news.entity.TeamNewsArticle; +import com.son.soccerStreaming.team.entity.Team; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +public interface TeamNewsArticleRepository extends JpaRepository { + + Optional findByTeamAndArticle(Team team, NewsArticle article); + + @Query(""" + select t + from TeamNewsArticle t + join fetch t.article n + where t.team.teamId = :teamId + and n.id = :articleId + """) + Optional findByTeamIdAndArticleId( + @Param("teamId") Long teamId, + @Param("articleId") Long articleId + ); + + @Query(""" + select t + from TeamNewsArticle t + join fetch t.article n + where t.team.teamId = :teamId + and t.lastSeenAt = ( + select max(latestRelation.lastSeenAt) + from TeamNewsArticle latestRelation + where latestRelation.team.teamId = :teamId + ) + order by + case when t.resultPosition is null then 1 else 0 end, + t.resultPosition asc, + t.id asc + """) + List findLatestByTeamId(@Param("teamId") Long teamId, Pageable pageable); + + int deleteByLastSeenAtBefore(Instant cutoff); +} diff --git a/src/main/java/com/son/soccerStreaming/news/repository/TeamNewsCollectionStateRepository.java b/src/main/java/com/son/soccerStreaming/news/repository/TeamNewsCollectionStateRepository.java new file mode 100644 index 0000000..3500485 --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/repository/TeamNewsCollectionStateRepository.java @@ -0,0 +1,7 @@ +package com.son.soccerStreaming.news.repository; + +import com.son.soccerStreaming.news.entity.TeamNewsCollectionState; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TeamNewsCollectionStateRepository extends JpaRepository { +} diff --git a/src/main/java/com/son/soccerStreaming/news/scheduler/NewsSyncScheduler.java b/src/main/java/com/son/soccerStreaming/news/scheduler/NewsSyncScheduler.java new file mode 100644 index 0000000..0989816 --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/scheduler/NewsSyncScheduler.java @@ -0,0 +1,48 @@ +package com.son.soccerStreaming.news.scheduler; + +import com.son.soccerStreaming.news.service.NewsCleanupService; +import com.son.soccerStreaming.news.service.NewsCollectionService; +import com.son.soccerStreaming.news.service.NewsTitleTranslationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "news.sync.enabled", havingValue = "true") +public class NewsSyncScheduler { + + private final NewsCollectionService collectionService; + private final NewsTitleTranslationService translationService; + private final NewsCleanupService cleanupService; + + @Scheduled(cron = "${news.sync.cron:0 0 6 * * *}", zone = "${news.sync.zone:Asia/Seoul}") + public void syncNews() { + try { + NewsCollectionService.CollectionResult result = collectionService.collectAllTeams(); + log.info("Team news collection completed. totalTeams={}, succeededTeams={}, failedTeams={}, savedArticles={}", + result.totalTeams(), result.succeededTeams(), result.failedTeams(), result.savedArticles()); + } catch (Exception e) { + log.error("Team news collection run failed.", e); + } + + try { + NewsTitleTranslationService.TranslationRunResult result = translationService.translatePending(); + log.info("News title translation completed. candidates={}, translated={}, failed={}", + result.candidates(), result.translated(), result.failed()); + } catch (Exception e) { + log.error("News title translation run failed.", e); + } + + try { + NewsCleanupService.CleanupResult result = cleanupService.cleanupExpired(); + log.info("News cleanup completed. deletedRelations={}, deletedArticles={}", + result.deletedRelations(), result.deletedArticles()); + } catch (Exception e) { + log.error("News cleanup run failed.", e); + } + } +} diff --git a/src/main/java/com/son/soccerStreaming/news/service/AdminNewsTranslationService.java b/src/main/java/com/son/soccerStreaming/news/service/AdminNewsTranslationService.java new file mode 100644 index 0000000..5a7fad1 --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/service/AdminNewsTranslationService.java @@ -0,0 +1,53 @@ +package com.son.soccerStreaming.news.service; + +import com.son.soccerStreaming.global.exception.CustomException; +import com.son.soccerStreaming.global.exception.ErrorCode; +import com.son.soccerStreaming.news.client.OpenAiTitleTranslationClient; +import com.son.soccerStreaming.news.entity.NewsArticle; +import com.son.soccerStreaming.news.repository.TeamNewsArticleRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdminNewsTranslationService { + + private final TeamNewsArticleRepository teamNewsArticleRepository; + private final OpenAiTitleTranslationClient translationClient; + private final NewsTranslationPersistenceService persistenceService; + + public TranslationResult translate(Long teamId, Long articleId) { + NewsArticle article = teamNewsArticleRepository.findByTeamIdAndArticleId(teamId, articleId) + .orElseThrow(() -> new CustomException(ErrorCode.NEWS_ARTICLE_NOT_FOUND)) + .getArticle(); + if (StringUtils.hasText(article.getTranslatedTitle())) { + return new TranslationResult(articleId, article.getTranslatedTitle()); + } + + try { + Map translations = translationClient.translate(List.of( + new OpenAiTitleTranslationClient.TranslationInput(articleId, article.getOriginalTitle()) + )); + String translatedTitle = translations.get(articleId); + if (!StringUtils.hasText(translatedTitle) || translatedTitle.length() > 1000) { + throw new IllegalStateException("OpenAI did not return a valid translation for the requested article."); + } + String normalizedTitle = translatedTitle.trim(); + persistenceService.applyResults(List.of(articleId), Map.of(articleId, normalizedTitle)); + return new TranslationResult(articleId, normalizedTitle); + } catch (Exception e) { + log.warn("Manual news title translation failed. teamId={}, articleId={}, errorType={}", + teamId, articleId, e.getClass().getSimpleName()); + throw new CustomException(ErrorCode.NEWS_TRANSLATION_FAILED); + } + } + + public record TranslationResult(Long articleId, String translatedTitle) { + } +} diff --git a/src/main/java/com/son/soccerStreaming/news/service/AdminTeamNewsRefreshService.java b/src/main/java/com/son/soccerStreaming/news/service/AdminTeamNewsRefreshService.java new file mode 100644 index 0000000..e4620cc --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/service/AdminTeamNewsRefreshService.java @@ -0,0 +1,50 @@ +package com.son.soccerStreaming.news.service; + +import com.son.soccerStreaming.global.exception.CustomException; +import com.son.soccerStreaming.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +@Service +@RequiredArgsConstructor +public class AdminTeamNewsRefreshService { + + private final NewsCollectionService collectionService; + private final NewsTitleTranslationService translationService; + private final Set refreshingTeamIds = ConcurrentHashMap.newKeySet(); + + public RefreshResult refresh(Long teamId) { + if (!refreshingTeamIds.add(teamId)) { + throw new CustomException(ErrorCode.ADMIN_SYNC_TOO_FREQUENT); + } + try { + int collectedArticles = collectionService.collectTeam(teamId); + NewsTitleTranslationService.TranslationRunResult translation = + translationService.translatePendingForTeam(teamId); + return new RefreshResult( + collectedArticles, + translation.candidates(), + translation.translated(), + translation.failed() + ); + } catch (CustomException e) { + throw e; + } catch (Exception e) { + // Do not propagate a RestClient exception because the SerpApi URL contains the API key. + throw new CustomException(ErrorCode.NEWS_REFRESH_FAILED); + } finally { + refreshingTeamIds.remove(teamId); + } + } + + public record RefreshResult( + int collectedArticles, + int translationCandidates, + int translatedArticles, + int failedTranslations + ) { + } +} diff --git a/src/main/java/com/son/soccerStreaming/news/service/NewsCleanupService.java b/src/main/java/com/son/soccerStreaming/news/service/NewsCleanupService.java new file mode 100644 index 0000000..dbec6e0 --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/service/NewsCleanupService.java @@ -0,0 +1,32 @@ +package com.son.soccerStreaming.news.service; + +import com.son.soccerStreaming.news.config.NewsProperties; +import com.son.soccerStreaming.news.repository.NewsArticleRepository; +import com.son.soccerStreaming.news.repository.TeamNewsArticleRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +@Service +@RequiredArgsConstructor +public class NewsCleanupService { + + private final TeamNewsArticleRepository teamNewsArticleRepository; + private final NewsArticleRepository newsArticleRepository; + private final NewsProperties properties; + + @Transactional + public CleanupResult cleanupExpired() { + Instant cutoff = Instant.now().minus(properties.getSync().getRetentionDays(), ChronoUnit.DAYS); + int deletedRelations = teamNewsArticleRepository.deleteByLastSeenAtBefore(cutoff); + teamNewsArticleRepository.flush(); + int deletedArticles = newsArticleRepository.deleteOrphans(); + return new CleanupResult(deletedRelations, deletedArticles); + } + + public record CleanupResult(int deletedRelations, int deletedArticles) { + } +} diff --git a/src/main/java/com/son/soccerStreaming/news/service/NewsCollectionService.java b/src/main/java/com/son/soccerStreaming/news/service/NewsCollectionService.java new file mode 100644 index 0000000..9ee29c2 --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/service/NewsCollectionService.java @@ -0,0 +1,55 @@ +package com.son.soccerStreaming.news.service; + +import com.son.soccerStreaming.news.client.SerpApiNewsClient; +import com.son.soccerStreaming.global.exception.CustomException; +import com.son.soccerStreaming.global.exception.ErrorCode; +import com.son.soccerStreaming.team.entity.Team; +import com.son.soccerStreaming.team.repository.TeamRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NewsCollectionService { + + private final TeamRepository teamRepository; + private final SerpApiNewsClient serpApiNewsClient; + private final NewsPersistenceService newsPersistenceService; + + public CollectionResult collectAllTeams() { + List teams = teamRepository.findAllByOrderByNameAsc(); + int succeededTeams = 0; + int failedTeams = 0; + int savedArticles = 0; + + for (Team team : teams) { + try { + List articles = serpApiNewsClient.searchTeamNews(team.getName()); + savedArticles += newsPersistenceService.saveTeamArticles(team.getTeamId(), articles, Instant.now()); + succeededTeams++; + } catch (Exception e) { + failedTeams++; + // SerpApi authenticates with a query parameter, so never log the exception URL. + log.warn("Team news collection failed. teamId={}, teamName={}, errorType={}", + team.getTeamId(), team.getName(), e.getClass().getSimpleName()); + } + } + + return new CollectionResult(teams.size(), succeededTeams, failedTeams, savedArticles); + } + + public int collectTeam(Long teamId) { + Team team = teamRepository.findByTeamId(teamId) + .orElseThrow(() -> new CustomException(ErrorCode.TEAM_NOT_FOUND)); + List articles = serpApiNewsClient.searchTeamNews(team.getName()); + return newsPersistenceService.saveTeamArticles(team.getTeamId(), articles, Instant.now()); + } + + public record CollectionResult(int totalTeams, int succeededTeams, int failedTeams, int savedArticles) { + } +} diff --git a/src/main/java/com/son/soccerStreaming/news/service/NewsPersistenceService.java b/src/main/java/com/son/soccerStreaming/news/service/NewsPersistenceService.java new file mode 100644 index 0000000..0ebf6e0 --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/service/NewsPersistenceService.java @@ -0,0 +1,81 @@ +package com.son.soccerStreaming.news.service; + +import com.son.soccerStreaming.news.client.SerpApiNewsClient; +import com.son.soccerStreaming.news.entity.NewsArticle; +import com.son.soccerStreaming.news.entity.TeamNewsArticle; +import com.son.soccerStreaming.news.entity.TeamNewsCollectionState; +import com.son.soccerStreaming.news.repository.NewsArticleRepository; +import com.son.soccerStreaming.news.repository.TeamNewsArticleRepository; +import com.son.soccerStreaming.news.repository.TeamNewsCollectionStateRepository; +import com.son.soccerStreaming.team.entity.Team; +import com.son.soccerStreaming.team.repository.TeamRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class NewsPersistenceService { + + private final TeamRepository teamRepository; + private final NewsArticleRepository newsArticleRepository; + private final TeamNewsArticleRepository teamNewsArticleRepository; + private final TeamNewsCollectionStateRepository collectionStateRepository; + private final SerpApiNewsClient serpApiNewsClient; + + @Transactional + public int saveTeamArticles(Long teamId, List articles, Instant seenAt) { + Team team = teamRepository.findByTeamId(teamId).orElseThrow(); + int savedCount = 0; + for (int resultPosition = 0; resultPosition < articles.size(); resultPosition++) { + int currentPosition = resultPosition; + SerpApiNewsClient.SearchArticle item = articles.get(resultPosition); + String normalizedUrl = NewsUrlNormalizer.normalize(item.url()); + String urlHash = NewsUrlNormalizer.sha256(normalizedUrl); + NewsArticle article = newsArticleRepository.findByUrlHash(urlHash) + .orElseGet(() -> NewsArticle.builder() + .urlHash(urlHash) + .originalUrl(normalizedUrl) + .originalTitle(item.title()) + .autoTranslationAttempted(false) + .publisherName(item.publisherName()) + .publisherDomain(serpApiNewsClient.publisherDomain(normalizedUrl)) + .publishedAt(item.publishedAt()) + .firstSeenAt(seenAt) + .lastSeenAt(seenAt) + .build()); + article.updateMetadata( + normalizedUrl, + item.title(), + item.publisherName(), + serpApiNewsClient.publisherDomain(normalizedUrl), + item.publishedAt(), + seenAt + ); + NewsArticle savedArticle = newsArticleRepository.save(article); + + TeamNewsArticle relation = teamNewsArticleRepository.findByTeamAndArticle(team, savedArticle) + .orElseGet(() -> TeamNewsArticle.builder() + .team(team) + .article(savedArticle) + .firstSeenAt(seenAt) + .lastSeenAt(seenAt) + .resultPosition(currentPosition) + .build()); + relation.markSeen(seenAt, currentPosition); + teamNewsArticleRepository.save(relation); + savedCount++; + } + TeamNewsCollectionState collectionState = collectionStateRepository.findById(teamId) + .orElseGet(() -> TeamNewsCollectionState.builder() + .team(team) + .lastCollectedAt(seenAt) + .build()); + collectionState.markCollected(seenAt); + collectionStateRepository.save(collectionState); + return savedCount; + } +} diff --git a/src/main/java/com/son/soccerStreaming/news/service/NewsTitleTranslationService.java b/src/main/java/com/son/soccerStreaming/news/service/NewsTitleTranslationService.java new file mode 100644 index 0000000..a9e0fe8 --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/service/NewsTitleTranslationService.java @@ -0,0 +1,71 @@ +package com.son.soccerStreaming.news.service; + +import com.son.soccerStreaming.news.client.OpenAiTitleTranslationClient; +import com.son.soccerStreaming.news.config.NewsProperties; +import com.son.soccerStreaming.news.entity.NewsArticle; +import com.son.soccerStreaming.news.repository.NewsArticleRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NewsTitleTranslationService { + + private final NewsArticleRepository newsArticleRepository; + private final OpenAiTitleTranslationClient translationClient; + private final NewsTranslationPersistenceService persistenceService; + private final NewsProperties properties; + + public TranslationRunResult translatePending() { + List candidates = newsArticleRepository.findTranslationCandidates(); + return translateCandidates(candidates); + } + + public TranslationRunResult translatePendingForTeam(Long teamId) { + List candidates = newsArticleRepository.findTranslationCandidatesByTeamId(teamId); + return translateCandidates(candidates); + } + + private TranslationRunResult translateCandidates(List candidates) { + int translated = 0; + int failed = 0; + int batchSize = Math.max(1, properties.getTranslation().getBatchSize()); + + for (int start = 0; start < candidates.size(); start += batchSize) { + List batch = new ArrayList<>(candidates.subList( + start, + Math.min(start + batchSize, candidates.size()) + )); + List requestedIds = batch.stream().map(NewsArticle::getId).toList(); + persistenceService.markAutoTranslationAttempted(requestedIds); + try { + Map translations = translationClient.translate(batch.stream() + .map(article -> new OpenAiTitleTranslationClient.TranslationInput( + article.getId(), + article.getOriginalTitle() + )) + .toList()); + persistenceService.applyResults(requestedIds, translations); + translated += (int) requestedIds.stream() + .filter(id -> translations.get(id) != null && !translations.get(id).isBlank()) + .count(); + failed += requestedIds.size() - (int) requestedIds.stream() + .filter(id -> translations.get(id) != null && !translations.get(id).isBlank()) + .count(); + } catch (Exception e) { + failed += batch.size(); + log.warn("News title translation batch failed. batchSize={}", batch.size(), e); + } + } + return new TranslationRunResult(candidates.size(), translated, failed); + } + + public record TranslationRunResult(int candidates, int translated, int failed) { + } +} diff --git a/src/main/java/com/son/soccerStreaming/news/service/NewsTranslationPersistenceService.java b/src/main/java/com/son/soccerStreaming/news/service/NewsTranslationPersistenceService.java new file mode 100644 index 0000000..08cad3f --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/service/NewsTranslationPersistenceService.java @@ -0,0 +1,40 @@ +package com.son.soccerStreaming.news.service; + +import com.son.soccerStreaming.news.entity.NewsArticle; +import com.son.soccerStreaming.news.repository.NewsArticleRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.Collection; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class NewsTranslationPersistenceService { + + private final NewsArticleRepository newsArticleRepository; + + @Transactional + public void markAutoTranslationAttempted(Collection requestedIds) { + newsArticleRepository.findAllById(requestedIds) + .forEach(NewsArticle::markAutoTranslationAttempted); + } + + @Transactional + public void applyResults(Collection requestedIds, Map translations) { + Map articles = newsArticleRepository.findAllById(requestedIds).stream() + .collect(java.util.stream.Collectors.toMap(NewsArticle::getId, article -> article)); + for (Long requestedId : requestedIds) { + NewsArticle article = articles.get(requestedId); + if (article == null) { + continue; + } + String translatedTitle = translations.get(requestedId); + if (StringUtils.hasText(translatedTitle) && translatedTitle.length() <= 1000) { + article.markTranslated(translatedTitle.trim()); + } + } + } +} diff --git a/src/main/java/com/son/soccerStreaming/news/service/NewsUrlNormalizer.java b/src/main/java/com/son/soccerStreaming/news/service/NewsUrlNormalizer.java new file mode 100644 index 0000000..4ad94b7 --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/service/NewsUrlNormalizer.java @@ -0,0 +1,72 @@ +package com.son.soccerStreaming.news.service; + +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +final class NewsUrlNormalizer { + + private static final Set TRACKING_PARAMETERS = Set.of( + "gclid", "fbclid", "mc_cid", "mc_eid" + ); + + private NewsUrlNormalizer() { + } + + static String normalize(String value) { + try { + URI uri = URI.create(value.trim()); + String query = normalizeQuery(uri.getRawQuery()); + int port = uri.getPort(); + if (("https".equalsIgnoreCase(uri.getScheme()) && port == 443) + || ("http".equalsIgnoreCase(uri.getScheme()) && port == 80)) { + port = -1; + } + return new URI( + uri.getScheme().toLowerCase(Locale.ROOT), + uri.getUserInfo(), + uri.getHost().toLowerCase(Locale.ROOT), + port, + uri.getPath(), + query, + null + ).toASCIIString(); + } catch (IllegalArgumentException | URISyntaxException e) { + throw new IllegalArgumentException("Invalid news URL.", e); + } + } + + static String sha256(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(digest.length * 2); + for (byte item : digest) { + result.append(String.format("%02x", item)); + } + return result.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable.", e); + } + } + + private static String normalizeQuery(String query) { + if (query == null || query.isBlank()) { + return null; + } + String normalized = Arrays.stream(query.split("&")) + .filter(part -> { + String name = part.split("=", 2)[0].toLowerCase(Locale.ROOT); + return !name.startsWith("utm_") && !TRACKING_PARAMETERS.contains(name); + }) + .sorted() + .collect(Collectors.joining("&")); + return normalized.isBlank() ? null : normalized; + } +} diff --git a/src/main/java/com/son/soccerStreaming/news/service/TeamNewsService.java b/src/main/java/com/son/soccerStreaming/news/service/TeamNewsService.java new file mode 100644 index 0000000..039f37d --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/news/service/TeamNewsService.java @@ -0,0 +1,53 @@ +package com.son.soccerStreaming.news.service; + +import com.son.soccerStreaming.global.exception.CustomException; +import com.son.soccerStreaming.global.exception.ErrorCode; +import com.son.soccerStreaming.news.dto.TeamNewsResponseDto; +import com.son.soccerStreaming.news.dto.TeamNewsListResponseDto; +import com.son.soccerStreaming.news.repository.TeamNewsArticleRepository; +import com.son.soccerStreaming.news.repository.TeamNewsCollectionStateRepository; +import com.son.soccerStreaming.team.repository.TeamRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TeamNewsService { + + private static final int TEAM_NEWS_LIMIT = 20; + + private final TeamRepository teamRepository; + private final TeamNewsArticleRepository teamNewsArticleRepository; + private final TeamNewsCollectionStateRepository collectionStateRepository; + + public TeamNewsListResponseDto getTeamNews(Long teamId) { + if (teamRepository.findByTeamId(teamId).isEmpty()) { + throw new CustomException(ErrorCode.TEAM_NOT_FOUND); + } + List articles = teamNewsArticleRepository + .findLatestByTeamId(teamId, PageRequest.of(0, TEAM_NEWS_LIMIT)).stream() + .map(relation -> { + var article = relation.getArticle(); + return new TeamNewsResponseDto( + article.getId(), + article.getOriginalTitle(), + article.getTranslatedTitle(), + article.getPublisherName(), + article.getOriginalUrl(), + article.getPublishedAt() + ); + }) + .toList(); + return new TeamNewsListResponseDto( + collectionStateRepository.findById(teamId) + .map(state -> state.getLastCollectedAt()) + .orElse(null), + articles + ); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index cce2727..6dbb2ca 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,6 +16,30 @@ app: team-player-rankings-ttl: ${TEAM_PLAYER_RANKINGS_CACHE_TTL:10m} favorite-card-ttl: ${FAVORITE_CARD_CACHE_TTL:30s} +news: + serp-api: + base-url: ${SERPAPI_BASE_URL:https://serpapi.com} + api-key: ${SERPAPI_API_KEY:} + lookback-days: ${NEWS_LOOKBACK_DAYS:7} + max-articles-per-team: ${NEWS_MAX_ARTICLES_PER_TEAM:20} + search-sites: + - bbc.com/sport/football + - skysports.com/football + - theguardian.com/football + - nytimes.com/athletic + - goal.com + - telegraph.co.uk/football + translation: + base-url: ${OPENAI_BASE_URL:https://api.openai.com} + api-key: ${OPENAI_API_KEY:} + model: ${OPENAI_NEWS_TRANSLATION_MODEL:gpt-5.6-luna} + batch-size: ${NEWS_TRANSLATION_BATCH_SIZE:50} + sync: + enabled: ${NEWS_SYNC_ENABLED:false} + cron: ${NEWS_SYNC_CRON:0 0 6 * * *} + zone: ${NEWS_SYNC_ZONE:Asia/Seoul} + retention-days: ${NEWS_RETENTION_DAYS:90} + live: api-football: base-url: ${LIVE_API_FOOTBALL_BASE_URL:https://v3.football.api-sports.io} diff --git a/src/test/java/com/son/soccerStreaming/news/client/OpenAiTitleTranslationClientTest.java b/src/test/java/com/son/soccerStreaming/news/client/OpenAiTitleTranslationClientTest.java new file mode 100644 index 0000000..c85915b --- /dev/null +++ b/src/test/java/com/son/soccerStreaming/news/client/OpenAiTitleTranslationClientTest.java @@ -0,0 +1,46 @@ +package com.son.soccerStreaming.news.client; + +import com.son.soccerStreaming.news.config.NewsProperties; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; +import tools.jackson.databind.ObjectMapper; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +class OpenAiTitleTranslationClientTest { + + @Test + void sendsStrictStructuredOutputAndMapsArticleIds() { + NewsProperties properties = new NewsProperties(); + properties.getTranslation().setApiKey("openai-test-key"); + properties.getTranslation().setModel("test-model"); + RestClient.Builder builder = RestClient.builder().baseUrl("https://openai.test"); + MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + OpenAiTitleTranslationClient client = new OpenAiTitleTranslationClient( + builder.build(), properties, new ObjectMapper()); + + server.expect(requestTo("https://openai.test/v1/responses")) + .andExpect(header("Authorization", "Bearer openai-test-key")) + .andExpect(jsonPath("$.model").value("test-model")) + .andExpect(jsonPath("$.store").value(false)) + .andExpect(jsonPath("$.text.format.type").value("json_schema")) + .andExpect(jsonPath("$.text.format.strict").value(true)) + .andRespond(withSuccess(""" + {"output":[{"content":[{"type":"output_text","text":"{\\"translations\\":[{\\"articleId\\":7,\\"translatedTitle\\":\\"번역된 제목\\"}]}"}]}]} + """, MediaType.APPLICATION_JSON)); + + var result = client.translate(List.of( + new OpenAiTitleTranslationClient.TranslationInput(7L, "Original title"))); + + assertThat(result).containsExactlyEntriesOf(java.util.Map.of(7L, "번역된 제목")); + server.verify(); + } +} diff --git a/src/test/java/com/son/soccerStreaming/news/client/SerpApiNewsClientTest.java b/src/test/java/com/son/soccerStreaming/news/client/SerpApiNewsClientTest.java new file mode 100644 index 0000000..7101294 --- /dev/null +++ b/src/test/java/com/son/soccerStreaming/news/client/SerpApiNewsClientTest.java @@ -0,0 +1,84 @@ +package com.son.soccerStreaming.news.client; + +import com.son.soccerStreaming.news.config.NewsProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +class SerpApiNewsClientTest { + + private NewsProperties properties; + private SerpApiNewsClient client; + private MockRestServiceServer server; + + @BeforeEach + void setUp() { + properties = new NewsProperties(); + properties.getSerpApi().setApiKey("test-key"); + properties.getSerpApi().setSearchSites(java.util.List.of( + "bbc.com/sport/football", + "skysports.com/football", + "theguardian.com/football", + "nytimes.com/athletic", + "goal.com", + "telegraph.co.uk/football" + )); + RestClient.Builder builder = RestClient.builder().baseUrl("https://serpapi.test"); + server = MockRestServiceServer.bindTo(builder).build(); + client = new SerpApiNewsClient(builder.build(), properties); + } + + @Test + void buildsQuotedTeamAndTrustedSiteQuery() { + assertThat(client.buildQuery("Manchester City")) + .isEqualTo("\"Manchester City\" football when:7d " + + "(site:bbc.com/sport/football OR site:skysports.com/football OR " + + "site:theguardian.com/football OR site:nytimes.com/athletic OR " + + "site:goal.com OR site:telegraph.co.uk/football)"); + } + + @Test + void normalizesRegularGroupedAndHighlightResultsAndBlocksUntrustedUrls() { + server.expect(request -> { + String decoded = URLDecoder.decode(request.getURI().toString(), StandardCharsets.UTF_8); + assertThat(decoded).contains("engine=google_news"); + assertThat(decoded).contains("q=\"Arsenal\" football when:7d"); + }) + .andRespond(withSuccess(""" + {"news_results":[ + {"title":"BBC title","link":"https://www.bbc.com/sport/football/articles/1","iso_date":"2026-07-14T01:00:00Z","source":{"name":"BBC Sport"}}, + {"stories":[ + {"title":"Sky title","link":"https://www.skysports.com/football/news/2","iso_date":"2026-07-14T02:00:00Z","source":{"name":"Sky Sports"}}, + {"title":"Blocked","link":"https://example.com/football/3","iso_date":"2026-07-14T03:00:00Z"} + ]}, + {"highlight":{"title":"Guardian title","link":"https://www.theguardian.com/football/2026/jul/14/story","iso_date":"2026-07-14T04:00:00Z","source":{"name":"The Guardian"}}} + ]} + """, MediaType.APPLICATION_JSON)); + + var result = client.searchTeamNews("Arsenal"); + + assertThat(result).extracting(SerpApiNewsClient.SearchArticle::title) + .containsExactly("BBC title", "Sky title", "Guardian title"); + server.verify(); + } + + @Test + void requiresBothTrustedHostAndExpectedSectionPath() { + assertThat(client.isAllowedUrl("https://bbc.com/sport/football/articles/1")).isTrue(); + assertThat(client.isAllowedUrl("https://evil-bbc.com/sport/football/articles/1")).isFalse(); + assertThat(client.isAllowedUrl("https://bbc.com/news/articles/1")).isFalse(); + assertThat(client.isAllowedUrl("https://goal.com/en/news/sunderland-transfer/1")).isTrue(); + assertThat(client.isAllowedUrl("https://telegraph.co.uk/football/2026/07/14/sunderland-news/")).isTrue(); + assertThat(client.isAllowedUrl("https://telegraph.co.uk/news/2026/07/14/story/")).isFalse(); + assertThat(client.isAllowedUrl("javascript:alert(1)")).isFalse(); + } +} diff --git a/src/test/java/com/son/soccerStreaming/news/service/AdminNewsTranslationServiceTest.java b/src/test/java/com/son/soccerStreaming/news/service/AdminNewsTranslationServiceTest.java new file mode 100644 index 0000000..da2d8ca --- /dev/null +++ b/src/test/java/com/son/soccerStreaming/news/service/AdminNewsTranslationServiceTest.java @@ -0,0 +1,83 @@ +package com.son.soccerStreaming.news.service; + +import com.son.soccerStreaming.global.exception.CustomException; +import com.son.soccerStreaming.global.exception.ErrorCode; +import com.son.soccerStreaming.news.client.OpenAiTitleTranslationClient; +import com.son.soccerStreaming.news.entity.NewsArticle; +import com.son.soccerStreaming.news.entity.TeamNewsArticle; +import com.son.soccerStreaming.news.repository.TeamNewsArticleRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminNewsTranslationServiceTest { + + @Mock TeamNewsArticleRepository relationRepository; + @Mock OpenAiTitleTranslationClient translationClient; + @Mock NewsTranslationPersistenceService persistenceService; + + @Test + void translatesOnlyTheRequestedTeamArticle() { + NewsArticle article = NewsArticle.builder().id(7L).originalTitle("Original title").build(); + when(relationRepository.findByTeamIdAndArticleId(42L, 7L)) + .thenReturn(Optional.of(TeamNewsArticle.builder().article(article).build())); + when(translationClient.translate(any())).thenReturn(Map.of(7L, "번역 제목")); + + var result = service().translate(42L, 7L); + + assertThat(result).isEqualTo(new AdminNewsTranslationService.TranslationResult(7L, "번역 제목")); + verify(persistenceService).applyResults(List.of(7L), Map.of(7L, "번역 제목")); + } + + @Test + void returnsExistingTranslationWithoutCallingOpenAi() { + NewsArticle article = NewsArticle.builder() + .id(7L) + .originalTitle("Original title") + .translatedTitle("기존 번역") + .build(); + when(relationRepository.findByTeamIdAndArticleId(42L, 7L)) + .thenReturn(Optional.of(TeamNewsArticle.builder().article(article).build())); + + assertThat(service().translate(42L, 7L).translatedTitle()).isEqualTo("기존 번역"); + verifyNoInteractions(translationClient, persistenceService); + } + + @Test + void rejectsArticleThatIsNotConnectedToTheTeam() { + when(relationRepository.findByTeamIdAndArticleId(42L, 7L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service().translate(42L, 7L)) + .isInstanceOfSatisfying(CustomException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.NEWS_ARTICLE_NOT_FOUND)); + } + + @Test + void sanitizesTranslationClientFailure() { + NewsArticle article = NewsArticle.builder().id(7L).originalTitle("Original title").build(); + when(relationRepository.findByTeamIdAndArticleId(42L, 7L)) + .thenReturn(Optional.of(TeamNewsArticle.builder().article(article).build())); + when(translationClient.translate(any())).thenThrow(new IllegalStateException("provider details")); + + assertThatThrownBy(() -> service().translate(42L, 7L)) + .isInstanceOfSatisfying(CustomException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.NEWS_TRANSLATION_FAILED)); + } + + private AdminNewsTranslationService service() { + return new AdminNewsTranslationService(relationRepository, translationClient, persistenceService); + } +} diff --git a/src/test/java/com/son/soccerStreaming/news/service/AdminTeamNewsRefreshServiceTest.java b/src/test/java/com/son/soccerStreaming/news/service/AdminTeamNewsRefreshServiceTest.java new file mode 100644 index 0000000..7a117fb --- /dev/null +++ b/src/test/java/com/son/soccerStreaming/news/service/AdminTeamNewsRefreshServiceTest.java @@ -0,0 +1,41 @@ +package com.son.soccerStreaming.news.service; + +import com.son.soccerStreaming.global.exception.CustomException; +import com.son.soccerStreaming.global.exception.ErrorCode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminTeamNewsRefreshServiceTest { + + @Mock NewsCollectionService collectionService; + @Mock NewsTitleTranslationService translationService; + + @Test + void collectsAndTranslatesOnlyRequestedTeam() { + when(collectionService.collectTeam(42L)).thenReturn(8); + when(translationService.translatePendingForTeam(42L)) + .thenReturn(new NewsTitleTranslationService.TranslationRunResult(3, 2, 1)); + + var result = new AdminTeamNewsRefreshService(collectionService, translationService).refresh(42L); + + assertThat(result).isEqualTo(new AdminTeamNewsRefreshService.RefreshResult(8, 3, 2, 1)); + } + + @Test + void replacesExternalClientFailureWithSanitizedError() { + when(collectionService.collectTeam(42L)) + .thenThrow(new IllegalStateException("https://serpapi.example?api_key=secret")); + + assertThatThrownBy(() -> new AdminTeamNewsRefreshService(collectionService, translationService).refresh(42L)) + .isInstanceOfSatisfying(CustomException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.NEWS_REFRESH_FAILED)) + .hasMessageNotContaining("secret"); + } +} diff --git a/src/test/java/com/son/soccerStreaming/news/service/NewsCleanupServiceTest.java b/src/test/java/com/son/soccerStreaming/news/service/NewsCleanupServiceTest.java new file mode 100644 index 0000000..f621fbb --- /dev/null +++ b/src/test/java/com/son/soccerStreaming/news/service/NewsCleanupServiceTest.java @@ -0,0 +1,45 @@ +package com.son.soccerStreaming.news.service; + +import com.son.soccerStreaming.news.config.NewsProperties; +import com.son.soccerStreaming.news.repository.NewsArticleRepository; +import com.son.soccerStreaming.news.repository.TeamNewsArticleRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NewsCleanupServiceTest { + + @Mock TeamNewsArticleRepository relationRepository; + @Mock NewsArticleRepository articleRepository; + + @Test + void deletesExpiredRelationsBeforeDeletingOnlyOrphanArticles() { + NewsProperties properties = new NewsProperties(); + properties.getSync().setRetentionDays(90); + when(relationRepository.deleteByLastSeenAtBefore(argThat(this::isAboutNinetyDaysAgo))).thenReturn(3); + when(articleRepository.deleteOrphans()).thenReturn(1); + + var result = new NewsCleanupService(relationRepository, articleRepository, properties).cleanupExpired(); + + assertThat(result).isEqualTo(new NewsCleanupService.CleanupResult(3, 1)); + InOrder order = inOrder(relationRepository, articleRepository); + order.verify(relationRepository).deleteByLastSeenAtBefore(argThat(this::isAboutNinetyDaysAgo)); + order.verify(relationRepository).flush(); + order.verify(articleRepository).deleteOrphans(); + } + + private boolean isAboutNinetyDaysAgo(Instant value) { + long differenceSeconds = Math.abs(value.getEpochSecond() - Instant.now().minusSeconds(90L * 24 * 60 * 60).getEpochSecond()); + return differenceSeconds < 5; + } +} diff --git a/src/test/java/com/son/soccerStreaming/news/service/NewsCollectionServiceTest.java b/src/test/java/com/son/soccerStreaming/news/service/NewsCollectionServiceTest.java new file mode 100644 index 0000000..cdc00eb --- /dev/null +++ b/src/test/java/com/son/soccerStreaming/news/service/NewsCollectionServiceTest.java @@ -0,0 +1,45 @@ +package com.son.soccerStreaming.news.service; + +import com.son.soccerStreaming.news.client.SerpApiNewsClient; +import com.son.soccerStreaming.team.entity.Team; +import com.son.soccerStreaming.team.repository.TeamRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NewsCollectionServiceTest { + + @Mock TeamRepository teamRepository; + @Mock SerpApiNewsClient serpApiNewsClient; + @Mock NewsPersistenceService newsPersistenceService; + @InjectMocks NewsCollectionService service; + + @Test + void continuesWithNextTeamWhenOneSearchFails() { + Team first = Team.builder().teamId(1L).name("Arsenal").build(); + Team second = Team.builder().teamId(2L).name("Chelsea").build(); + var article = new SerpApiNewsClient.SearchArticle( + "Chelsea title", "https://bbc.com/sport/football/articles/1", "BBC Sport", Instant.now()); + when(teamRepository.findAllByOrderByNameAsc()).thenReturn(List.of(first, second)); + when(serpApiNewsClient.searchTeamNews("Arsenal")).thenThrow(new IllegalStateException("failed")); + when(serpApiNewsClient.searchTeamNews("Chelsea")).thenReturn(List.of(article)); + when(newsPersistenceService.saveTeamArticles(eq(2L), any(), any())).thenReturn(1); + + var result = service.collectAllTeams(); + + assertThat(result).isEqualTo(new NewsCollectionService.CollectionResult(2, 1, 1, 1)); + verify(newsPersistenceService).saveTeamArticles(eq(2L), eq(List.of(article)), any()); + } +} diff --git a/src/test/java/com/son/soccerStreaming/news/service/NewsPersistenceServiceTest.java b/src/test/java/com/son/soccerStreaming/news/service/NewsPersistenceServiceTest.java new file mode 100644 index 0000000..de80064 --- /dev/null +++ b/src/test/java/com/son/soccerStreaming/news/service/NewsPersistenceServiceTest.java @@ -0,0 +1,124 @@ +package com.son.soccerStreaming.news.service; + +import com.son.soccerStreaming.news.client.SerpApiNewsClient; +import com.son.soccerStreaming.news.entity.NewsArticle; +import com.son.soccerStreaming.news.entity.TeamNewsArticle; +import com.son.soccerStreaming.news.entity.TeamNewsCollectionState; +import com.son.soccerStreaming.news.repository.NewsArticleRepository; +import com.son.soccerStreaming.news.repository.TeamNewsArticleRepository; +import com.son.soccerStreaming.news.repository.TeamNewsCollectionStateRepository; +import com.son.soccerStreaming.team.entity.Team; +import com.son.soccerStreaming.team.repository.TeamRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NewsPersistenceServiceTest { + + @Mock TeamRepository teamRepository; + @Mock NewsArticleRepository articleRepository; + @Mock TeamNewsArticleRepository relationRepository; + @Mock TeamNewsCollectionStateRepository collectionStateRepository; + @Mock SerpApiNewsClient serpApiNewsClient; + + @Test + void reusesOneTranslatedArticleAcrossDifferentTeams() { + Team first = Team.builder().teamId(1L).name("Arsenal").build(); + Team second = Team.builder().teamId(2L).name("Chelsea").build(); + NewsArticle existing = NewsArticle.builder() + .id(7L) + .urlHash("hash") + .originalUrl("https://bbc.com/sport/football/articles/shared") + .originalTitle("Shared title") + .translatedTitle("공통 제목") + .publisherName("BBC Sport") + .publisherDomain("bbc.com") + .build(); + var item = new SerpApiNewsClient.SearchArticle( + "Shared title", + "https://bbc.com/sport/football/articles/shared?utm_source=test", + "BBC Sport", + Instant.parse("2026-07-14T01:00:00Z") + ); + when(teamRepository.findByTeamId(1L)).thenReturn(Optional.of(first)); + when(teamRepository.findByTeamId(2L)).thenReturn(Optional.of(second)); + when(articleRepository.findByUrlHash(any())).thenReturn(Optional.of(existing)); + when(articleRepository.save(existing)).thenReturn(existing); + when(serpApiNewsClient.publisherDomain(any())).thenReturn("bbc.com"); + when(relationRepository.findByTeamAndArticle(any(), any())).thenReturn(Optional.empty()); + + NewsPersistenceService service = new NewsPersistenceService( + teamRepository, articleRepository, relationRepository, collectionStateRepository, serpApiNewsClient); + service.saveTeamArticles(1L, List.of(item), Instant.now()); + service.saveTeamArticles(2L, List.of(item), Instant.now()); + + assertThat(existing.getTranslatedTitle()).isEqualTo("공통 제목"); + verify(articleRepository, times(2)).save(existing); + verify(relationRepository, times(2)).save(any()); + } + + @Test + void storesArticlesInSerpApiResultOrder() { + Team team = Team.builder().teamId(1L).name("Arsenal").build(); + var first = new SerpApiNewsClient.SearchArticle( + "First result", + "https://bbc.com/sport/football/articles/first", + "BBC Sport", + Instant.parse("2026-07-13T01:00:00Z") + ); + var second = new SerpApiNewsClient.SearchArticle( + "Second result", + "https://bbc.com/sport/football/articles/second", + "BBC Sport", + Instant.parse("2026-07-14T01:00:00Z") + ); + when(teamRepository.findByTeamId(1L)).thenReturn(Optional.of(team)); + when(articleRepository.findByUrlHash(any())).thenReturn(Optional.empty()); + when(articleRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(serpApiNewsClient.publisherDomain(any())).thenReturn("bbc.com"); + when(relationRepository.findByTeamAndArticle(any(), any())).thenReturn(Optional.empty()); + + NewsPersistenceService service = new NewsPersistenceService( + teamRepository, articleRepository, relationRepository, collectionStateRepository, serpApiNewsClient); + service.saveTeamArticles(1L, List.of(first, second), Instant.now()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TeamNewsArticle.class); + verify(relationRepository, times(2)).save(captor.capture()); + assertThat(captor.getAllValues()) + .extracting(TeamNewsArticle::getResultPosition) + .containsExactly(0, 1); + ArgumentCaptor articleCaptor = ArgumentCaptor.forClass(NewsArticle.class); + verify(articleRepository, times(2)).save(articleCaptor.capture()); + assertThat(articleCaptor.getAllValues()) + .extracting(NewsArticle::getAutoTranslationAttempted) + .containsOnly(false); + } + + @Test + void recordsCollectionTimeEvenWhenNoArticlesAreReturned() { + Team team = Team.builder().teamId(1L).name("Arsenal").build(); + Instant collectedAt = Instant.parse("2026-07-16T01:00:00Z"); + when(teamRepository.findByTeamId(1L)).thenReturn(Optional.of(team)); + + NewsPersistenceService service = new NewsPersistenceService( + teamRepository, articleRepository, relationRepository, collectionStateRepository, serpApiNewsClient); + service.saveTeamArticles(1L, List.of(), collectedAt); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TeamNewsCollectionState.class); + verify(collectionStateRepository).save(captor.capture()); + assertThat(captor.getValue().getLastCollectedAt()).isEqualTo(collectedAt); + } +} diff --git a/src/test/java/com/son/soccerStreaming/news/service/NewsTitleTranslationServiceTest.java b/src/test/java/com/son/soccerStreaming/news/service/NewsTitleTranslationServiceTest.java new file mode 100644 index 0000000..f3d0b4f --- /dev/null +++ b/src/test/java/com/son/soccerStreaming/news/service/NewsTitleTranslationServiceTest.java @@ -0,0 +1,85 @@ +package com.son.soccerStreaming.news.service; + +import com.son.soccerStreaming.news.client.OpenAiTitleTranslationClient; +import com.son.soccerStreaming.news.config.NewsProperties; +import com.son.soccerStreaming.news.entity.NewsArticle; +import com.son.soccerStreaming.news.repository.NewsArticleRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NewsTitleTranslationServiceTest { + + @Mock NewsArticleRepository newsArticleRepository; + @Mock OpenAiTitleTranslationClient translationClient; + @Mock NewsTranslationPersistenceService persistenceService; + private NewsProperties properties; + private NewsTitleTranslationService service; + + @BeforeEach + void setUp() { + properties = new NewsProperties(); + properties.getTranslation().setBatchSize(50); + service = new NewsTitleTranslationService(newsArticleRepository, translationClient, persistenceService, properties); + } + + @Test + void appliesOnlyRequestedIdsAndCountsMissingOrEmptyResultsAsFailures() { + NewsArticle first = article(1L, "First"); + NewsArticle second = article(2L, "Second"); + when(newsArticleRepository.findTranslationCandidates()).thenReturn(List.of(first, second)); + when(translationClient.translate(any())).thenReturn(Map.of( + 1L, "첫 번째", + 2L, " ", + 999L, "요청하지 않은 결과" + )); + + var result = service.translatePending(); + + assertThat(result).isEqualTo(new NewsTitleTranslationService.TranslationRunResult(2, 1, 1)); + verify(persistenceService).markAutoTranslationAttempted(List.of(1L, 2L)); + verify(persistenceService).applyResults(List.of(1L, 2L), Map.of( + 1L, "첫 번째", 2L, " ", 999L, "요청하지 않은 결과")); + verify(newsArticleRepository).findTranslationCandidates(); + } + + @Test + void leavesWholeBatchUntranslatedWhenClientCallFails() { + NewsArticle first = article(1L, "First"); + when(newsArticleRepository.findTranslationCandidates()).thenReturn(List.of(first)); + when(translationClient.translate(any())).thenThrow(new IllegalStateException("invalid response")); + + var result = service.translatePending(); + + assertThat(result).isEqualTo(new NewsTitleTranslationService.TranslationRunResult(1, 0, 1)); + verify(persistenceService).markAutoTranslationAttempted(List.of(1L)); + } + + @Test + void selectsTranslationCandidatesOnlyForRequestedTeam() { + when(newsArticleRepository.findTranslationCandidatesByTeamId(42L)).thenReturn(List.of()); + + var result = service.translatePendingForTeam(42L); + + assertThat(result).isEqualTo(new NewsTitleTranslationService.TranslationRunResult(0, 0, 0)); + verify(newsArticleRepository).findTranslationCandidatesByTeamId(42L); + } + + private NewsArticle article(Long id, String title) { + return NewsArticle.builder() + .id(id) + .originalTitle(title) + .build(); + } +} diff --git a/src/test/java/com/son/soccerStreaming/news/service/NewsTranslationPersistenceServiceTest.java b/src/test/java/com/son/soccerStreaming/news/service/NewsTranslationPersistenceServiceTest.java new file mode 100644 index 0000000..b04483e --- /dev/null +++ b/src/test/java/com/son/soccerStreaming/news/service/NewsTranslationPersistenceServiceTest.java @@ -0,0 +1,50 @@ +package com.son.soccerStreaming.news.service; + +import com.son.soccerStreaming.news.entity.NewsArticle; +import com.son.soccerStreaming.news.repository.NewsArticleRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NewsTranslationPersistenceServiceTest { + + @Mock NewsArticleRepository repository; + + @Test + void rejectsMissingEmptyAndUnknownIdsWithoutCorruptingMapping() { + NewsArticle first = article(1L); + NewsArticle second = article(2L); + when(repository.findAllById(List.of(1L, 2L))).thenReturn(List.of(first, second)); + NewsTranslationPersistenceService service = new NewsTranslationPersistenceService(repository); + + service.applyResults(List.of(1L, 2L), Map.of(1L, "번역 제목", 999L, "잘못된 ID")); + + assertThat(first.getTranslatedTitle()).isEqualTo("번역 제목"); + assertThat(second.getTranslatedTitle()).isNull(); + } + + @Test + void marksArticlesBeforeTheirOnlyAutomaticTranslationAttempt() { + NewsArticle article = article(1L); + when(repository.findAllById(List.of(1L))).thenReturn(List.of(article)); + + new NewsTranslationPersistenceService(repository).markAutoTranslationAttempted(List.of(1L)); + + assertThat(article.getAutoTranslationAttempted()).isTrue(); + } + + private NewsArticle article(Long id) { + return NewsArticle.builder() + .id(id) + .originalTitle("Title " + id) + .build(); + } +} diff --git a/src/test/java/com/son/soccerStreaming/news/service/TeamNewsServiceTest.java b/src/test/java/com/son/soccerStreaming/news/service/TeamNewsServiceTest.java new file mode 100644 index 0000000..2073d7c --- /dev/null +++ b/src/test/java/com/son/soccerStreaming/news/service/TeamNewsServiceTest.java @@ -0,0 +1,69 @@ +package com.son.soccerStreaming.news.service; + +import com.son.soccerStreaming.global.exception.CustomException; +import com.son.soccerStreaming.news.entity.NewsArticle; +import com.son.soccerStreaming.news.entity.TeamNewsArticle; +import com.son.soccerStreaming.news.entity.TeamNewsCollectionState; +import com.son.soccerStreaming.news.repository.TeamNewsArticleRepository; +import com.son.soccerStreaming.news.repository.TeamNewsCollectionStateRepository; +import com.son.soccerStreaming.team.entity.Team; +import com.son.soccerStreaming.team.repository.TeamRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TeamNewsServiceTest { + + @Mock TeamRepository teamRepository; + @Mock TeamNewsArticleRepository repository; + @Mock TeamNewsCollectionStateRepository collectionStateRepository; + + @Test + void returnsNullableTranslationAndRequestsOnlyLatestTwenty() { + Team team = Team.builder().teamId(42L).name("Arsenal").build(); + Instant publishedAt = Instant.parse("2026-07-14T01:00:00Z"); + NewsArticle article = NewsArticle.builder() + .id(7L) + .originalTitle("Original") + .translatedTitle(null) + .publisherName("BBC Sport") + .originalUrl("https://bbc.com/sport/football/articles/7") + .publishedAt(publishedAt) + .build(); + TeamNewsArticle relation = TeamNewsArticle.builder().team(team).article(article).build(); + when(teamRepository.findByTeamId(42L)).thenReturn(Optional.of(team)); + when(repository.findLatestByTeamId(42L, PageRequest.of(0, 20))).thenReturn(List.of(relation)); + when(collectionStateRepository.findById(42L)).thenReturn(Optional.of( + TeamNewsCollectionState.builder().team(team).lastCollectedAt(publishedAt).build() + )); + + var result = new TeamNewsService(teamRepository, repository, collectionStateRepository).getTeamNews(42L); + + assertThat(result.articles()).hasSize(1); + assertThat(result.articles().get(0).translatedTitle()).isNull(); + assertThat(result.articles().get(0).publishedAt()).isEqualTo(publishedAt); + assertThat(result.lastCollectedAt()).isEqualTo(publishedAt); + verify(repository).findLatestByTeamId(42L, PageRequest.of(0, 20)); + } + + @Test + void rejectsUnknownTeamWithoutReadingNews() { + when(teamRepository.findByTeamId(404L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> new TeamNewsService(teamRepository, repository, collectionStateRepository).getTeamNews(404L)) + .isInstanceOf(CustomException.class); + verify(teamRepository).findByTeamId(404L); + } +} From ea4f883defec901241384f1ee5071101d316acd1 Mon Sep 17 00:00:00 2001 From: son0307 <162094052+son0307@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:49:56 +0900 Subject: [PATCH 2/2] Retranslate news when headlines change --- .../news/entity/NewsArticle.java | 13 ++-- .../service/NewsPersistenceServiceTest.java | 69 +++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/son/soccerStreaming/news/entity/NewsArticle.java b/src/main/java/com/son/soccerStreaming/news/entity/NewsArticle.java index b2c2b01..3fda6e2 100644 --- a/src/main/java/com/son/soccerStreaming/news/entity/NewsArticle.java +++ b/src/main/java/com/son/soccerStreaming/news/entity/NewsArticle.java @@ -14,8 +14,6 @@ import lombok.NoArgsConstructor; import java.time.Instant; -import java.util.Objects; - @Entity @Table(name = "news_article", indexes = { @Index(name = "idx_news_article_published_at", columnList = "published_at") @@ -72,17 +70,24 @@ public class NewsArticle { public void updateMetadata(String originalUrl, String originalTitle, String publisherName, String publisherDomain, Instant publishedAt, Instant seenAt) { + boolean titleChanged = !normalizeTitleForComparison(this.originalTitle) + .equals(normalizeTitleForComparison(originalTitle)); this.originalUrl = originalUrl; + this.originalTitle = originalTitle; this.publisherName = publisherName; this.publisherDomain = publisherDomain; this.publishedAt = publishedAt; this.lastSeenAt = seenAt; - if (!Objects.equals(this.originalTitle, originalTitle)) { - this.originalTitle = originalTitle; + if (titleChanged) { this.translatedTitle = null; + this.autoTranslationAttempted = false; } } + private String normalizeTitleForComparison(String title) { + return title == null ? "" : title.strip().replaceAll("[\\s\\u00A0]+", " "); + } + public void markTranslated(String translatedTitle) { this.translatedTitle = translatedTitle; } diff --git a/src/test/java/com/son/soccerStreaming/news/service/NewsPersistenceServiceTest.java b/src/test/java/com/son/soccerStreaming/news/service/NewsPersistenceServiceTest.java index de80064..22555d9 100644 --- a/src/test/java/com/son/soccerStreaming/news/service/NewsPersistenceServiceTest.java +++ b/src/test/java/com/son/soccerStreaming/news/service/NewsPersistenceServiceTest.java @@ -70,6 +70,70 @@ void reusesOneTranslatedArticleAcrossDifferentTeams() { verify(relationRepository, times(2)).save(any()); } + @Test + void preservesTranslationWhenRecollectedTitleOnlyDiffersByWhitespace() { + Team team = Team.builder().teamId(1L).name("Arsenal").build(); + NewsArticle existing = NewsArticle.builder() + .id(7L) + .urlHash("hash") + .originalUrl("https://bbc.com/sport/football/articles/shared") + .originalTitle("Arsenal complete major signing") + .translatedTitle("아스널, 대형 영입 완료") + .autoTranslationAttempted(true) + .publisherName("BBC Sport") + .publisherDomain("bbc.com") + .build(); + var item = new SerpApiNewsClient.SearchArticle( + " Arsenal complete\u00a0major signing ", + existing.getOriginalUrl(), + "BBC Sport", + Instant.parse("2026-07-14T01:00:00Z") + ); + when(teamRepository.findByTeamId(1L)).thenReturn(Optional.of(team)); + when(articleRepository.findByUrlHash(any())).thenReturn(Optional.of(existing)); + when(articleRepository.save(existing)).thenReturn(existing); + when(serpApiNewsClient.publisherDomain(any())).thenReturn("bbc.com"); + when(relationRepository.findByTeamAndArticle(any(), any())).thenReturn(Optional.empty()); + + service().saveTeamArticles(1L, List.of(item), Instant.now()); + + assertThat(existing.getOriginalTitle()).isEqualTo(item.title()); + assertThat(existing.getTranslatedTitle()).isEqualTo("아스널, 대형 영입 완료"); + assertThat(existing.getAutoTranslationAttempted()).isTrue(); + } + + @Test + void queuesAutomaticRetranslationWhenRecollectedTitleMeaningfullyChanges() { + Team team = Team.builder().teamId(1L).name("Arsenal").build(); + NewsArticle existing = NewsArticle.builder() + .id(7L) + .urlHash("hash") + .originalUrl("https://bbc.com/sport/football/articles/shared") + .originalTitle("Arsenal agree deal in principle") + .translatedTitle("아스널, 원칙적 합의") + .autoTranslationAttempted(true) + .publisherName("BBC Sport") + .publisherDomain("bbc.com") + .build(); + var item = new SerpApiNewsClient.SearchArticle( + "Arsenal complete the signing", + existing.getOriginalUrl(), + "BBC Sport", + Instant.parse("2026-07-14T01:00:00Z") + ); + when(teamRepository.findByTeamId(1L)).thenReturn(Optional.of(team)); + when(articleRepository.findByUrlHash(any())).thenReturn(Optional.of(existing)); + when(articleRepository.save(existing)).thenReturn(existing); + when(serpApiNewsClient.publisherDomain(any())).thenReturn("bbc.com"); + when(relationRepository.findByTeamAndArticle(any(), any())).thenReturn(Optional.empty()); + + service().saveTeamArticles(1L, List.of(item), Instant.now()); + + assertThat(existing.getOriginalTitle()).isEqualTo(item.title()); + assertThat(existing.getTranslatedTitle()).isNull(); + assertThat(existing.getAutoTranslationAttempted()).isFalse(); + } + @Test void storesArticlesInSerpApiResultOrder() { Team team = Team.builder().teamId(1L).name("Arsenal").build(); @@ -121,4 +185,9 @@ void recordsCollectionTimeEvenWhenNoArticlesAreReturned() { verify(collectionStateRepository).save(captor.capture()); assertThat(captor.getValue().getLastCollectedAt()).isEqualTo(collectedAt); } + + private NewsPersistenceService service() { + return new NewsPersistenceService( + teamRepository, articleRepository, relationRepository, collectionStateRepository, serpApiNewsClient); + } }