From 20546599cc6c4f9ed0dd6ad4aef1686c35d329eb Mon Sep 17 00:00:00 2001 From: caadiq Date: Wed, 7 Jan 2026 23:54:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20YouTube=20API=20=ED=95=A0=EB=8B=B9?= =?UTF-8?q?=EB=9F=89=20=EA=B2=BD=EA=B3=A0=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RSS 방식에서 YouTube API 방식으로 변경 (최근 10개 영상 조회) - rss_url 컬럼 삭제 - Google Cloud Webhook으로 할당량 경고 수신 - 95% 도달 시 봇 자동 중지 - LA 시간 자정(할당량 리셋)에 봇 자동 재시작 - 봇 관리 페이지에 경고 배너 표시 --- backend/routes/admin.js | 152 +++++++++++++++++- backend/services/youtube-bot.js | 91 ++++++++++- frontend/src/pages/pc/admin/AdminSchedule.jsx | 20 ++- .../src/pages/pc/admin/AdminScheduleBots.jsx | 57 +++++++ frontend/src/stores/useScheduleStore.js | 7 +- 5 files changed, 316 insertions(+), 11 deletions(-) diff --git a/backend/routes/admin.js b/backend/routes/admin.js index db397fb..251c0a6 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -1748,7 +1748,7 @@ router.delete("/schedules/:id", authenticateToken, async (req, res) => { router.get("/bots", authenticateToken, async (req, res) => { try { const [bots] = await pool.query(` - SELECT b.*, c.channel_id, c.rss_url, c.channel_name + SELECT b.*, c.channel_id, c.channel_name FROM bots b LEFT JOIN bot_youtube_config c ON b.id = c.bot_id ORDER BY b.id ASC @@ -1806,4 +1806,154 @@ router.post("/bots/:id/sync-all", authenticateToken, async (req, res) => { } }); +// ===================================================== +// YouTube API 할당량 경고 Webhook +// ===================================================== + +// 메모리에 경고 상태 저장 (서버 재시작 시 초기화) +let quotaWarning = { + active: false, + timestamp: null, + message: null, + stoppedBots: [], // 할당량 초과로 중지된 봇 ID 목록 +}; + +// 자정 재시작 타이머 +let quotaResetTimer = null; + +/** + * 자정(LA 시간)에 봇 재시작 예약 + * YouTube 할당량은 LA 태평양 시간 자정에 리셋됨 + */ +async function scheduleQuotaReset() { + // 기존 타이머 취소 + if (quotaResetTimer) { + clearTimeout(quotaResetTimer); + } + + // LA 시간으로 다음 자정 계산 + const now = new Date(); + const laTime = new Date( + now.toLocaleString("en-US", { timeZone: "America/Los_Angeles" }) + ); + const tomorrow = new Date(laTime); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 1, 0, 0); // 자정 1분 후 (안전 마진) + + // 현재 LA 시간과 다음 자정까지의 밀리초 계산 + const nowLA = new Date( + now.toLocaleString("en-US", { timeZone: "America/Los_Angeles" }) + ); + const msUntilReset = tomorrow.getTime() - nowLA.getTime(); + + console.log( + `[Quota Reset] ${Math.round( + msUntilReset / 1000 / 60 + )}분 후 봇 재시작 예약됨` + ); + + quotaResetTimer = setTimeout(async () => { + console.log("[Quota Reset] 할당량 리셋 시간 도달, 봇 재시작 중..."); + + try { + // 할당량 초과로 중지된 봇들만 재시작 + for (const botId of quotaWarning.stoppedBots) { + await startBot(botId); + console.log(`[Quota Reset] Bot ${botId} 재시작됨`); + } + + // 경고 상태 초기화 + quotaWarning = { + active: false, + timestamp: null, + message: null, + stoppedBots: [], + }; + + console.log("[Quota Reset] 모든 봇 재시작 완료, 경고 상태 초기화"); + } catch (error) { + console.error("[Quota Reset] 봇 재시작 오류:", error.message); + } + }, msUntilReset); +} + +// Webhook 인증 정보 +const WEBHOOK_USERNAME = "fromis9_quota_webhook"; +const WEBHOOK_PASSWORD = "Qw8$kLm3nP2xVr7tYz!9"; + +// Basic Auth 검증 미들웨어 +const verifyWebhookAuth = (req, res, next) => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith("Basic ")) { + return res.status(401).json({ error: "인증이 필요합니다." }); + } + + const base64Credentials = authHeader.split(" ")[1]; + const credentials = Buffer.from(base64Credentials, "base64").toString( + "utf-8" + ); + const [username, password] = credentials.split(":"); + + if (username !== WEBHOOK_USERNAME || password !== WEBHOOK_PASSWORD) { + return res.status(401).json({ error: "인증 실패" }); + } + + next(); +}; + +// Google Cloud 할당량 경고 Webhook 수신 +router.post("/quota-alert", verifyWebhookAuth, async (req, res) => { + console.log("[Quota Alert] Google Cloud에서 할당량 경고 수신:", req.body); + + quotaWarning = { + active: true, + timestamp: new Date().toISOString(), + message: + "일일 할당량의 95%를 사용했습니다. (9,500 / 10,000 units) - 봇이 자동 중지되었습니다.", + }; + + // 모든 실행 중인 봇 중지 + try { + const [runningBots] = await pool.query( + "SELECT id, name FROM bots WHERE status = 'running'" + ); + + // 중지된 봇 ID 저장 (자정에 재시작용) + quotaWarning.stoppedBots = runningBots.map((bot) => bot.id); + + for (const bot of runningBots) { + await stopBot(bot.id); + console.log(`[Quota Alert] Bot ${bot.name} 중지됨`); + } + console.log( + `[Quota Alert] ${runningBots.length}개 봇이 할당량 초과로 중지됨` + ); + + // 자정에 봇 재시작 예약 (LA 시간 기준 = YouTube 할당량 리셋 시간) + scheduleQuotaReset(); + } catch (error) { + console.error("[Quota Alert] 봇 중지 오류:", error.message); + } + + res + .status(200) + .json({ success: true, message: "경고가 등록되고 봇이 중지되었습니다." }); +}); + +// 할당량 경고 상태 조회 (프론트엔드용) +router.get("/quota-warning", authenticateToken, (req, res) => { + res.json(quotaWarning); +}); + +// 할당량 경고 해제 (수동) +router.delete("/quota-warning", authenticateToken, (req, res) => { + quotaWarning = { + active: false, + timestamp: null, + message: null, + }; + res.json({ success: true, message: "경고가 해제되었습니다." }); +}); + export default router; diff --git a/backend/services/youtube-bot.js b/backend/services/youtube-bot.js index 4615b52..c76bb58 100644 --- a/backend/services/youtube-bot.js +++ b/backend/services/youtube-bot.js @@ -152,6 +152,83 @@ function parseDuration(duration) { return hours * 3600 + minutes * 60 + seconds; } +/** + * YouTube API로 최근 N개 영상 수집 (정기 동기화용) + * @param {string} channelId - 채널 ID + * @param {number} maxResults - 조회할 영상 수 (기본 10) + */ +export async function fetchRecentVideosFromAPI(channelId, maxResults = 10) { + const videos = []; + + try { + // 채널의 업로드 플레이리스트 ID 조회 + const channelResponse = await fetch( + `https://www.googleapis.com/youtube/v3/channels?part=contentDetails&id=${channelId}&key=${YOUTUBE_API_KEY}` + ); + const channelData = await channelResponse.json(); + + if (!channelData.items || channelData.items.length === 0) { + throw new Error("채널을 찾을 수 없습니다."); + } + + const uploadsPlaylistId = + channelData.items[0].contentDetails.relatedPlaylists.uploads; + + // 플레이리스트 아이템 조회 (최근 N개만) + const url = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&playlistId=${uploadsPlaylistId}&maxResults=${maxResults}&key=${YOUTUBE_API_KEY}`; + const response = await fetch(url); + const data = await response.json(); + + if (data.error) { + throw new Error(data.error.message); + } + + // 영상 ID 목록 추출 + const videoIds = data.items.map((item) => item.snippet.resourceId.videoId); + + // videos API로 duration 조회 (Shorts 판별용) + const videosResponse = await fetch( + `https://www.googleapis.com/youtube/v3/videos?part=contentDetails&id=${videoIds.join( + "," + )}&key=${YOUTUBE_API_KEY}` + ); + const videosData = await videosResponse.json(); + + // duration으로 Shorts 판별 맵 생성 + const durationMap = {}; + if (videosData.items) { + for (const v of videosData.items) { + const duration = v.contentDetails.duration; + const seconds = parseDuration(duration); + durationMap[v.id] = seconds <= 60 ? "shorts" : "video"; + } + } + + for (const item of data.items) { + const snippet = item.snippet; + const videoId = snippet.resourceId.videoId; + const publishedAt = toKST(new Date(snippet.publishedAt)); + const videoType = durationMap[videoId] || "video"; + + videos.push({ + videoId, + title: snippet.title, + description: snippet.description || "", + publishedAt, + date: formatDate(publishedAt), + time: formatTime(publishedAt), + videoUrl: getVideoUrl(videoId, videoType), + videoType, + }); + } + + return videos; + } catch (error) { + console.error("YouTube API 오류:", error); + throw error; + } +} + /** * YouTube API로 전체 영상 수집 (초기 동기화용) * Shorts 판별: duration이 60초 이하이면 Shorts @@ -356,14 +433,14 @@ function extractMemberIdsFromDescription(description, memberNameMap) { } /** - * 봇의 새 영상 동기화 (RSS 기반) + * 봇의 새 영상 동기화 (YouTube API 기반) */ export async function syncNewVideos(botId) { try { // 봇 정보 조회 (bot_youtube_config 조인) const [bots] = await pool.query( ` - SELECT b.*, c.channel_id, c.rss_url + SELECT b.*, c.channel_id FROM bots b LEFT JOIN bot_youtube_config c ON b.id = c.bot_id WHERE b.id = ? @@ -377,8 +454,8 @@ export async function syncNewVideos(botId) { const bot = bots[0]; - if (!bot.rss_url) { - throw new Error("RSS URL이 설정되지 않았습니다."); + if (!bot.channel_id) { + throw new Error("Channel ID가 설정되지 않았습니다."); } // 봇별 커스텀 설정 조회 @@ -386,8 +463,8 @@ export async function syncNewVideos(botId) { const categoryId = await getYoutubeCategory(); - // RSS 피드 파싱 - const videos = await parseRSSFeed(bot.rss_url); + // YouTube API로 최근 10개 영상 조회 + const videos = await fetchRecentVideosFromAPI(bot.channel_id, 10); let addedCount = 0; // 멤버 추출을 위한 이름 맵 조회 (필요 시) @@ -467,7 +544,7 @@ export async function syncAllVideos(botId) { // 봇 정보 조회 (bot_youtube_config 조인) const [bots] = await pool.query( ` - SELECT b.*, c.channel_id, c.rss_url + SELECT b.*, c.channel_id FROM bots b LEFT JOIN bot_youtube_config c ON b.id = c.bot_id WHERE b.id = ? diff --git a/frontend/src/pages/pc/admin/AdminSchedule.jsx b/frontend/src/pages/pc/admin/AdminSchedule.jsx index f698fe7..b45f6c4 100644 --- a/frontend/src/pages/pc/admin/AdminSchedule.jsx +++ b/frontend/src/pages/pc/admin/AdminSchedule.jsx @@ -31,12 +31,14 @@ function AdminSchedule() { selectedCategories, setSelectedCategories, selectedDate, setSelectedDate, currentDate, setCurrentDate, + scrollPosition, setScrollPosition, } = useScheduleStore(); // 로컬 상태 (페이지 이동 시 유지할 필요 없는 것들) const [loading, setLoading] = useState(false); const [user, setUser] = useState(null); const [toast, setToast] = useState(null); + const scrollContainerRef = useRef(null); const SEARCH_LIMIT = 5; // 테스트용 5개 // Intersection Observer for infinite scroll @@ -85,9 +87,9 @@ function AdminSchedule() { } }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]); - // selectedDate가 없으면 오늘 날짜로 초기화 + // selectedDate가 undefined이면 오늘 날짜로 초기화 (null은 전체보기이므로 유지) useEffect(() => { - if (!selectedDate) { + if (selectedDate === undefined) { setSelectedDate(getTodayKST()); } }, []); @@ -236,6 +238,18 @@ function AdminSchedule() { searchSchedules(searchTerm); } }, []); // 컴포넌트 마운트 시에만 + + // 스크롤 위치 복원 + useEffect(() => { + if (scrollContainerRef.current && scrollPosition > 0) { + scrollContainerRef.current.scrollTop = scrollPosition; + } + }, [loading]); // 로딩이 끝나면 스크롤 복원 + + // 스크롤 위치 저장 + const handleScroll = (e) => { + setScrollPosition(e.target.scrollTop); + }; // 카테고리 로드 함수 @@ -1019,6 +1033,8 @@ function AdminSchedule() { ) : (
diff --git a/frontend/src/pages/pc/admin/AdminScheduleBots.jsx b/frontend/src/pages/pc/admin/AdminScheduleBots.jsx index b5bc00b..0642b80 100644 --- a/frontend/src/pages/pc/admin/AdminScheduleBots.jsx +++ b/frontend/src/pages/pc/admin/AdminScheduleBots.jsx @@ -15,6 +15,7 @@ function AdminScheduleBots() { const [bots, setBots] = useState([]); const [loading, setLoading] = useState(true); const [syncing, setSyncing] = useState(null); // 동기화 중인 봇 ID + const [quotaWarning, setQuotaWarning] = useState(null); // 할당량 경고 상태 // Toast 자동 숨김 useEffect(() => { @@ -35,6 +36,7 @@ function AdminScheduleBots() { setUser(JSON.parse(userData)); fetchBots(); + fetchQuotaWarning(); }, [navigate]); // 봇 목록 조회 @@ -58,6 +60,38 @@ function AdminScheduleBots() { } }; + // 할당량 경고 상태 조회 + const fetchQuotaWarning = async () => { + try { + const token = localStorage.getItem('adminToken'); + const response = await fetch('/api/admin/quota-warning', { + headers: { Authorization: `Bearer ${token}` } + }); + if (response.ok) { + const data = await response.json(); + if (data.active) { + setQuotaWarning(data); + } + } + } catch (error) { + console.error('할당량 경고 조회 오류:', error); + } + }; + + // 할당량 경고 해제 + const dismissQuotaWarning = async () => { + try { + const token = localStorage.getItem('adminToken'); + await fetch('/api/admin/quota-warning', { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }); + setQuotaWarning(null); + } catch (error) { + console.error('할당량 경고 해제 오류:', error); + } + }; + const handleLogout = () => { localStorage.removeItem('adminToken'); localStorage.removeItem('adminUser'); @@ -265,6 +299,29 @@ function AdminScheduleBots() {
+ {/* API 할당량 경고 배너 */} + {quotaWarning && ( +
+
+
+ +
+
+

YouTube API 할당량 경고

+

+ {quotaWarning.message} +

+
+
+ +
+ )} + {/* 봇 목록 */}
diff --git a/frontend/src/stores/useScheduleStore.js b/frontend/src/stores/useScheduleStore.js index abe0115..42caef5 100644 --- a/frontend/src/stores/useScheduleStore.js +++ b/frontend/src/stores/useScheduleStore.js @@ -10,9 +10,12 @@ const useScheduleStore = create((set) => ({ // 필터 및 선택 selectedCategories: [], - selectedDate: null, // null이면 getTodayKST() 사용 + selectedDate: null, // null이면 전체보기, undefined이면 getTodayKST() 사용 currentDate: new Date(), + // 스크롤 위치 + scrollPosition: 0, + // 상태 업데이트 함수 setSearchInput: (value) => set({ searchInput: value }), setSearchTerm: (value) => set({ searchTerm: value }), @@ -20,6 +23,7 @@ const useScheduleStore = create((set) => ({ setSelectedCategories: (value) => set({ selectedCategories: value }), setSelectedDate: (value) => set({ selectedDate: value }), setCurrentDate: (value) => set({ currentDate: value }), + setScrollPosition: (value) => set({ scrollPosition: value }), // 상태 초기화 reset: () => @@ -30,6 +34,7 @@ const useScheduleStore = create((set) => ({ selectedCategories: [], selectedDate: null, currentDate: new Date(), + scrollPosition: 0, }), }));