feat: YouTube API 할당량 경고 시스템 구현

- RSS 방식에서 YouTube API 방식으로 변경 (최근 10개 영상 조회)
- rss_url 컬럼 삭제
- Google Cloud Webhook으로 할당량 경고 수신
- 95% 도달 시 봇 자동 중지
- LA 시간 자정(할당량 리셋)에 봇 자동 재시작
- 봇 관리 페이지에 경고 배너 표시
This commit is contained in:
caadiq 2026-01-07 23:54:35 +09:00
parent 030c495c01
commit 20546599cc
5 changed files with 316 additions and 11 deletions

View file

@ -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;

View file

@ -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 = ?

View file

@ -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());
}
}, []);
@ -237,6 +239,18 @@ function AdminSchedule() {
}
}, []); //
//
useEffect(() => {
if (scrollContainerRef.current && scrollPosition > 0) {
scrollContainerRef.current.scrollTop = scrollPosition;
}
}, [loading]); //
//
const handleScroll = (e) => {
setScrollPosition(e.target.scrollTop);
};
//
const fetchCategories = async () => {
@ -1019,6 +1033,8 @@ function AdminSchedule() {
</div>
) : (
<div
ref={scrollContainerRef}
onScroll={handleScroll}
id="adminScheduleScrollContainer"
className="max-h-[calc(100vh-280px)] overflow-y-auto divide-y divide-gray-100 py-2"
>

View file

@ -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() {
</div>
</div>
{/* API 할당량 경고 배너 */}
{quotaWarning && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-8 flex items-start justify-between">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<XCircle size={18} className="text-red-500" />
</div>
<div>
<h3 className="font-bold text-red-700">YouTube API 할당량 경고</h3>
<p className="text-sm text-red-600 mt-0.5">
{quotaWarning.message}
</p>
</div>
</div>
<button
onClick={dismissQuotaWarning}
className="text-red-400 hover:text-red-600 transition-colors text-sm px-2 py-1"
>
닫기
</button>
</div>
)}
{/* 봇 목록 */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">

View file

@ -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,
}),
}));