refactor(festival-bot): active_months 기능 제거
축제봇이 대학+일반 행사를 통합 수집하게 되면서 연중 실행이 필요해 활성 월 제한 기능 제거. 스케줄러 게이트/라우트/유틸/DB 컬럼/다이얼로그 월 선택 UI/'대기 중' 상태 표시 모두 정리. 동기화 간격만 유지. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
73a008f4bc
commit
d1ee28e308
8 changed files with 9 additions and 155 deletions
|
|
@ -1,6 +0,0 @@
|
||||||
-- 축제 봇 실행 활성 월 (시즌성 대응)
|
|
||||||
-- active_months: JSON 정수 배열 (예: [4,5,8,9]) — 해당 월에만 동기화 실행
|
|
||||||
-- NULL 또는 12개 전체 = 모든 월 실행 (제한 없음)
|
|
||||||
-- 대학 축제는 학기 중 한정 기간에만 열리므로, 비시즌에 불필요한
|
|
||||||
-- 크롤링/Gemini 호출(RPD 제한)을 막기 위해 축제 봇에만 적용.
|
|
||||||
ALTER TABLE bot_festival ADD COLUMN active_months JSON DEFAULT NULL AFTER cron_interval;
|
|
||||||
|
|
@ -2,24 +2,13 @@ import fp from 'fastify-plugin';
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import staticBots from '../config/bots.js';
|
import staticBots from '../config/bots.js';
|
||||||
import { syncAllSchedules } from '../services/meilisearch/index.js';
|
import { syncAllSchedules } from '../services/meilisearch/index.js';
|
||||||
import { nowKST, monthKST } from '../utils/date.js';
|
import { nowKST } from '../utils/date.js';
|
||||||
import { parseActiveMonths } from '../utils/botMonths.js';
|
|
||||||
import { logActivity } from '../utils/log.js';
|
import { logActivity } from '../utils/log.js';
|
||||||
|
|
||||||
const REDIS_PREFIX = 'bot:status:';
|
const REDIS_PREFIX = 'bot:status:';
|
||||||
const TIMEZONE = 'Asia/Seoul';
|
const TIMEZONE = 'Asia/Seoul';
|
||||||
const MAX_CONSECUTIVE_ERRORS = 10;
|
const MAX_CONSECUTIVE_ERRORS = 10;
|
||||||
|
|
||||||
/**
|
|
||||||
* 현재 KST 월이 봇의 활성 월에 포함되는지
|
|
||||||
* - activeMonths가 null/빈배열/12개 전체 → 항상 실행
|
|
||||||
*/
|
|
||||||
function isActiveMonth(bot) {
|
|
||||||
const months = bot.activeMonths;
|
|
||||||
if (!months || months.length === 0 || months.length >= 12) return true;
|
|
||||||
return months.includes(monthKST());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DB JSON 컬럼 안전 파싱. 깨진 값 하나가 getAllBots 전체를 막지 않도록 fallback 반환.
|
* DB JSON 컬럼 안전 파싱. 깨진 값 하나가 getAllBots 전체를 막지 않도록 fallback 반환.
|
||||||
*/
|
*/
|
||||||
|
|
@ -145,7 +134,6 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
cron: intervalToCron(row.cron_interval),
|
cron: intervalToCron(row.cron_interval),
|
||||||
cronInterval: row.cron_interval,
|
cronInterval: row.cron_interval,
|
||||||
enabled: row.enabled === 1,
|
enabled: row.enabled === 1,
|
||||||
activeMonths: parseActiveMonths(row.active_months),
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -379,11 +367,6 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
|
|
||||||
// cron 태스크 등록 (한국 시간 기준)
|
// cron 태스크 등록 (한국 시간 기준)
|
||||||
const task = cron.schedule(bot.cron, async () => {
|
const task = cron.schedule(bot.cron, async () => {
|
||||||
// 활성 월이 아니면 스킵 (매 실행 시점의 현재 월로 재평가)
|
|
||||||
if (!isActiveMonth(bot)) {
|
|
||||||
fastify.log.info(`[${botId}] 비활성 월(${monthKST()}월) - 동기화 스킵`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fastify.log.info(`[${botId}] 동기화 시작`);
|
fastify.log.info(`[${botId}] 동기화 시작`);
|
||||||
if (bot.weeklySchedule) {
|
if (bot.weeklySchedule) {
|
||||||
await startWeeklyBurst(botId, bot, syncFn);
|
await startWeeklyBurst(botId, bot, syncFn);
|
||||||
|
|
@ -396,8 +379,8 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
await updateStatus(botId, { status: 'running' });
|
await updateStatus(botId, { status: 'running' });
|
||||||
fastify.log.info(`[${botId}] 스케줄 시작 (cron: ${bot.cron})`);
|
fastify.log.info(`[${botId}] 스케줄 시작 (cron: ${bot.cron})`);
|
||||||
|
|
||||||
// 즉시 1회 실행: meilisearch와 weekly 모드는 제외 (weekly는 지정 시각에만), 비활성 월도 제외
|
// 즉시 1회 실행: meilisearch와 weekly 모드는 제외 (weekly는 지정 시각에만)
|
||||||
if (bot.type !== 'meilisearch' && !bot.weeklySchedule && isActiveMonth(bot)) {
|
if (bot.type !== 'meilisearch' && !bot.weeklySchedule) {
|
||||||
await runSync(botId, bot, syncFn, { setRunningStatus: false });
|
await runSync(botId, bot, syncFn, { setRunningStatus: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@ const botResponse = {
|
||||||
text_filters: { type: 'array', items: { type: 'string' } },
|
text_filters: { type: 'array', items: { type: 'string' } },
|
||||||
// 축제 봇 전용 필드
|
// 축제 봇 전용 필드
|
||||||
search_url: { type: 'string' },
|
search_url: { type: 'string' },
|
||||||
active_months: { type: ['array', 'null'], items: { type: 'integer' } },
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -145,7 +144,6 @@ export default async function botsRoutes(fastify) {
|
||||||
botData.db_id = bot.dbId;
|
botData.db_id = bot.dbId;
|
||||||
botData.search_url = bot.searchUrl;
|
botData.search_url = bot.searchUrl;
|
||||||
botData.cron_interval = checkInterval;
|
botData.cron_interval = checkInterval;
|
||||||
botData.active_months = bot.activeMonths || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result.push(botData);
|
result.push(botData);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { errorResponse } from '../../schemas/index.js';
|
import { errorResponse } from '../../schemas/index.js';
|
||||||
import { badRequest, notFound } from '../../utils/error.js';
|
import { badRequest, notFound } from '../../utils/error.js';
|
||||||
import { logActivity } from '../../utils/log.js';
|
import { logActivity } from '../../utils/log.js';
|
||||||
import { parseActiveMonths, serializeActiveMonths } from '../../utils/botMonths.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 축제 봇 응답 스키마
|
* 축제 봇 응답 스키마
|
||||||
|
|
@ -13,7 +12,6 @@ const festivalBotResponse = {
|
||||||
name: { type: 'string' },
|
name: { type: 'string' },
|
||||||
search_url: { type: 'string' },
|
search_url: { type: 'string' },
|
||||||
cron_interval: { type: 'integer' },
|
cron_interval: { type: 'integer' },
|
||||||
active_months: { type: ['array', 'null'], items: { type: 'integer' } },
|
|
||||||
enabled: { type: 'boolean' },
|
enabled: { type: 'boolean' },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -35,7 +33,6 @@ function formatBotResponse(row) {
|
||||||
name: row.name,
|
name: row.name,
|
||||||
search_url: row.search_url,
|
search_url: row.search_url,
|
||||||
cron_interval: row.cron_interval,
|
cron_interval: row.cron_interval,
|
||||||
active_months: parseActiveMonths(row.active_months),
|
|
||||||
enabled: row.enabled === 1,
|
enabled: row.enabled === 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -100,7 +97,6 @@ export default async function festivalBotsRoutes(fastify) {
|
||||||
name: { type: 'string' },
|
name: { type: 'string' },
|
||||||
search_url: { type: 'string' },
|
search_url: { type: 'string' },
|
||||||
cron_interval: { type: 'integer', default: 360 },
|
cron_interval: { type: 'integer', default: 360 },
|
||||||
active_months: { type: ['array', 'null'], items: { type: 'integer' } },
|
|
||||||
},
|
},
|
||||||
required: ['name', 'search_url'],
|
required: ['name', 'search_url'],
|
||||||
},
|
},
|
||||||
|
|
@ -108,16 +104,16 @@ export default async function festivalBotsRoutes(fastify) {
|
||||||
},
|
},
|
||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { name, search_url, cron_interval = 360, active_months } = request.body;
|
const { name, search_url, cron_interval = 360 } = request.body;
|
||||||
|
|
||||||
if (!name?.trim() || !search_url?.trim()) {
|
if (!name?.trim() || !search_url?.trim()) {
|
||||||
return badRequest(reply, '이름과 크롤링 URL은 필수입니다.');
|
return badRequest(reply, '이름과 크롤링 URL은 필수입니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [result] = await db.query(
|
const [result] = await db.query(
|
||||||
`INSERT INTO bot_festival (name, search_url, cron_interval, active_months, enabled)
|
`INSERT INTO bot_festival (name, search_url, cron_interval, enabled)
|
||||||
VALUES (?, ?, ?, ?, 1)`,
|
VALUES (?, ?, ?, 1)`,
|
||||||
[name.trim(), search_url.trim(), cron_interval, serializeActiveMonths(active_months)]
|
[name.trim(), search_url.trim(), cron_interval]
|
||||||
);
|
);
|
||||||
|
|
||||||
scheduler.invalidateCache();
|
scheduler.invalidateCache();
|
||||||
|
|
@ -156,7 +152,6 @@ export default async function festivalBotsRoutes(fastify) {
|
||||||
name: { type: 'string' },
|
name: { type: 'string' },
|
||||||
search_url: { type: 'string' },
|
search_url: { type: 'string' },
|
||||||
cron_interval: { type: 'integer' },
|
cron_interval: { type: 'integer' },
|
||||||
active_months: { type: ['array', 'null'], items: { type: 'integer' } },
|
|
||||||
enabled: { type: 'boolean' },
|
enabled: { type: 'boolean' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -186,10 +181,6 @@ export default async function festivalBotsRoutes(fastify) {
|
||||||
fields.push('cron_interval = ?');
|
fields.push('cron_interval = ?');
|
||||||
values.push(updates.cron_interval);
|
values.push(updates.cron_interval);
|
||||||
}
|
}
|
||||||
if (updates.active_months !== undefined) {
|
|
||||||
fields.push('active_months = ?');
|
|
||||||
values.push(serializeActiveMonths(updates.active_months));
|
|
||||||
}
|
|
||||||
if (updates.enabled !== undefined) {
|
if (updates.enabled !== undefined) {
|
||||||
fields.push('enabled = ?');
|
fields.push('enabled = ?');
|
||||||
values.push(updates.enabled ? 1 : 0);
|
values.push(updates.enabled ? 1 : 0);
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
/**
|
|
||||||
* 봇 활성 월(active_months) 파싱/직렬화 유틸
|
|
||||||
* - 저장 형식: JSON 정수 배열 (예: [4,5,8,9]) 또는 NULL(전체 월 = 항상 실행)
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DB 값(JSON 문자열/배열) → 정수 배열 또는 null
|
|
||||||
*/
|
|
||||||
export function parseActiveMonths(raw) {
|
|
||||||
if (!raw) return null;
|
|
||||||
try {
|
|
||||||
const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
||||||
if (!Array.isArray(arr) || arr.length === 0) return null;
|
|
||||||
const months = arr.map(Number).filter((m) => m >= 1 && m <= 12);
|
|
||||||
return months.length > 0 ? months : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 입력 배열 → DB 저장용 JSON 문자열 또는 null
|
|
||||||
* - 빈 배열 / 12개 전체 선택은 NULL(제한 없음)로 정규화
|
|
||||||
*/
|
|
||||||
export function serializeActiveMonths(input) {
|
|
||||||
if (!Array.isArray(input)) return null;
|
|
||||||
const months = [...new Set(input.map(Number).filter((m) => m >= 1 && m <= 12))].sort(
|
|
||||||
(a, b) => a - b
|
|
||||||
);
|
|
||||||
if (months.length === 0 || months.length >= 12) return null;
|
|
||||||
return JSON.stringify(months);
|
|
||||||
}
|
|
||||||
|
|
@ -36,13 +36,6 @@ export function nowKST() {
|
||||||
return dayjs().tz(KST).format();
|
return dayjs().tz(KST).format();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 현재 KST 기준 월 반환 (1~12)
|
|
||||||
*/
|
|
||||||
export function monthKST() {
|
|
||||||
return dayjs().tz(KST).month() + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nitter 날짜 문자열 파싱
|
* Nitter 날짜 문자열 파싱
|
||||||
* 예: "Jan 15, 2026 · 10:30 PM UTC"
|
* 예: "Jan 15, 2026 · 10:30 PM UTC"
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,6 @@ const INTERVAL_OPTIONS = [
|
||||||
{ value: 1440, label: '24시간' },
|
{ value: 1440, label: '24시간' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALL_MONTHS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 커스텀 드롭다운 (Portal 사용)
|
* 커스텀 드롭다운 (Portal 사용)
|
||||||
*/
|
*/
|
||||||
|
|
@ -120,17 +118,8 @@ function FestivalBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [searchUrl, setSearchUrl] = useState('');
|
const [searchUrl, setSearchUrl] = useState('');
|
||||||
const [interval, setInterval] = useState(360);
|
const [interval, setInterval] = useState(360);
|
||||||
const [activeMonths, setActiveMonths] = useState(ALL_MONTHS);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const allMonthsSelected = activeMonths.length === ALL_MONTHS.length;
|
|
||||||
const toggleMonth = (m) =>
|
|
||||||
setActiveMonths((prev) =>
|
|
||||||
prev.includes(m) ? prev.filter((x) => x !== m) : [...prev, m].sort((a, b) => a - b)
|
|
||||||
);
|
|
||||||
const toggleAllMonths = () =>
|
|
||||||
setActiveMonths(allMonthsSelected ? [] : [...ALL_MONTHS]);
|
|
||||||
|
|
||||||
// 축제 봇 상세 조회 (수정 모드)
|
// 축제 봇 상세 조회 (수정 모드)
|
||||||
const { data: bot, isLoading: botLoading } = useQuery({
|
const { data: bot, isLoading: botLoading } = useQuery({
|
||||||
queryKey: ['admin', 'festival-bot', botId],
|
queryKey: ['admin', 'festival-bot', botId],
|
||||||
|
|
@ -148,18 +137,11 @@ function FestivalBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
setName(bot.name || '');
|
setName(bot.name || '');
|
||||||
setSearchUrl(bot.search_url || '');
|
setSearchUrl(bot.search_url || '');
|
||||||
setInterval(bot.cron_interval || 360);
|
setInterval(bot.cron_interval || 360);
|
||||||
// active_months: null(전체) → 전체 선택, 배열이면 그대로
|
|
||||||
setActiveMonths(
|
|
||||||
Array.isArray(bot.active_months) && bot.active_months.length > 0
|
|
||||||
? bot.active_months
|
|
||||||
: [...ALL_MONTHS]
|
|
||||||
);
|
|
||||||
} else if (!botId) {
|
} else if (!botId) {
|
||||||
// 추가 모드 (기본: 전체 월 = 항상 실행)
|
// 추가 모드
|
||||||
setName('');
|
setName('');
|
||||||
setSearchUrl('');
|
setSearchUrl('');
|
||||||
setInterval(360);
|
setInterval(360);
|
||||||
setActiveMonths([...ALL_MONTHS]);
|
|
||||||
}
|
}
|
||||||
}, [isOpen, bot, botId]);
|
}, [isOpen, bot, botId]);
|
||||||
|
|
||||||
|
|
@ -174,8 +156,6 @@ function FestivalBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
search_url: searchUrl.trim(),
|
search_url: searchUrl.trim(),
|
||||||
cron_interval: interval,
|
cron_interval: interval,
|
||||||
// 전체 선택(또는 미선택)은 제한 없음(null)으로 저장
|
|
||||||
active_months: allMonthsSelected ? null : activeMonths,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
|
|
@ -282,41 +262,6 @@ function FestivalBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 활성 월 */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
|
||||||
<label className="text-sm font-medium text-gray-700">활성 월</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleAllMonths}
|
|
||||||
className="text-xs font-medium text-amber-600 hover:text-amber-700"
|
|
||||||
>
|
|
||||||
{allMonthsSelected ? '전체 해제' : '전체 선택'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-6 gap-1.5">
|
|
||||||
{ALL_MONTHS.map((m) => {
|
|
||||||
const on = activeMonths.includes(m);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={m}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleMonth(m)}
|
|
||||||
className={`py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
||||||
on
|
|
||||||
? 'bg-amber-500 text-white'
|
|
||||||
: 'bg-gray-100 text-gray-400 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{m}월
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400 mt-1.5">
|
|
||||||
선택한 월에만 봇이 실행됩니다. 전체 선택 시 항상 실행됩니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useState, useEffect, useMemo } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Home, ChevronRight, Bot, CheckCircle, XCircle, RefreshCw, Plus, Youtube, PartyPopper, Clock } from 'lucide-react';
|
import { Home, ChevronRight, Bot, CheckCircle, XCircle, RefreshCw, Plus, Youtube, PartyPopper } from 'lucide-react';
|
||||||
import { Toast, Tooltip, AnimatedNumber } from '@/components/common';
|
import { Toast, Tooltip, AnimatedNumber } from '@/components/common';
|
||||||
import { AdminLayout, XIcon, MeilisearchIcon, BotTableRow, BotTable, YouTubeBotDialog, XBotDialog, FestivalBotDialog } from '@/components/pc/admin';
|
import { AdminLayout, XIcon, MeilisearchIcon, BotTableRow, BotTable, YouTubeBotDialog, XBotDialog, FestivalBotDialog } from '@/components/pc/admin';
|
||||||
import { useAdminAuth } from '@/hooks/pc/admin';
|
import { useAdminAuth } from '@/hooks/pc/admin';
|
||||||
|
|
@ -191,26 +191,8 @@ function ScheduleBots() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 상태 아이콘 및 색상
|
// 상태 아이콘 및 색상
|
||||||
// 봇이 켜져 있지만 현재 달이 활성 월이 아니면(축제 봇) 이번 달은 동기화하지 않음
|
|
||||||
const isInactiveMonth = (bot) => {
|
|
||||||
const months = bot?.active_months;
|
|
||||||
if (!Array.isArray(months) || months.length === 0 || months.length >= 12) return false;
|
|
||||||
const currentMonth = new Date().getMonth() + 1;
|
|
||||||
return !months.includes(currentMonth);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusInfo = (bot) => {
|
const getStatusInfo = (bot) => {
|
||||||
const status = bot?.status;
|
const status = bot?.status;
|
||||||
// 실행 중이지만 비활성 월 → 대기 중
|
|
||||||
if (status === 'running' && isInactiveMonth(bot)) {
|
|
||||||
return {
|
|
||||||
icon: <Clock size={16} />,
|
|
||||||
text: '대기 중',
|
|
||||||
color: 'text-amber-500',
|
|
||||||
bg: 'bg-amber-50',
|
|
||||||
dot: 'bg-amber-400',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'running':
|
case 'running':
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue