feat(festival-bot): active_months API + 축제 봇 전용으로 한정
- botMonths 유틸(parse/serialize) 추가, 빈배열·전체선택은 NULL 정규화 - 축제 봇 라우트 조회/생성/수정에 active_months 반영 (수정 시 봇 재시작으로 즉시 적용) - 봇별 설정이 이미 분리돼 있어 x/youtube에는 미적용 — DB 컬럼/스케줄러 매퍼에서 제외 - 스케줄러 isActiveMonth 게이트는 범용 유지(미설정 봇은 항상 실행) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
fa37891ab3
commit
8b36e9b5f7
4 changed files with 49 additions and 24 deletions
|
|
@ -1,7 +1,6 @@
|
||||||
-- 봇 실행 활성 월 (시즌성 봇 대응)
|
-- 축제 봇 실행 활성 월 (시즌성 대응)
|
||||||
-- active_months: JSON 정수 배열 (예: [4,5,8,9]) — 해당 월에만 동기화 실행
|
-- active_months: JSON 정수 배열 (예: [4,5,8,9]) — 해당 월에만 동기화 실행
|
||||||
-- NULL 또는 12개 전체 = 모든 월 실행 (제한 없음)
|
-- NULL 또는 12개 전체 = 모든 월 실행 (제한 없음)
|
||||||
-- 대학 축제 봇처럼 특정 시즌에만 도는 봇의 불필요한 API 호출(특히 Gemini RPD)을 절약
|
-- 대학 축제는 학기 중 한정 기간에만 열리므로, 비시즌에 불필요한
|
||||||
|
-- 크롤링/Gemini 호출(RPD 제한)을 막기 위해 축제 봇에만 적용.
|
||||||
ALTER TABLE bot_festival ADD COLUMN active_months JSON DEFAULT NULL AFTER cron_interval;
|
ALTER TABLE bot_festival ADD COLUMN active_months JSON DEFAULT NULL AFTER cron_interval;
|
||||||
ALTER TABLE bot_x ADD COLUMN active_months JSON DEFAULT NULL AFTER cron_interval;
|
|
||||||
ALTER TABLE bot_youtube ADD COLUMN active_months JSON DEFAULT NULL AFTER cron_interval;
|
|
||||||
|
|
|
||||||
|
|
@ -3,26 +3,13 @@ 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, monthKST } 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;
|
||||||
|
|
||||||
/**
|
|
||||||
* active_months 컬럼 파싱 (JSON 정수 배열 또는 null)
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
return arr.map(Number).filter((m) => m >= 1 && m <= 12);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 현재 KST 월이 봇의 활성 월에 포함되는지
|
* 현재 KST 월이 봇의 활성 월에 포함되는지
|
||||||
* - activeMonths가 null/빈배열/12개 전체 → 항상 실행
|
* - activeMonths가 null/빈배열/12개 전체 → 항상 실행
|
||||||
|
|
@ -97,7 +84,6 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
: [],
|
: [],
|
||||||
extractMembersFromDesc: row.extract_members_from_desc === 1,
|
extractMembersFromDesc: row.extract_members_from_desc === 1,
|
||||||
extractMembersFromTitle: row.extract_members_from_title === 1,
|
extractMembersFromTitle: row.extract_members_from_title === 1,
|
||||||
activeMonths: parseActiveMonths(row.active_months),
|
|
||||||
autoScheduleNext: row.auto_schedule_config
|
autoScheduleNext: row.auto_schedule_config
|
||||||
? (typeof row.auto_schedule_config === 'string'
|
? (typeof row.auto_schedule_config === 'string'
|
||||||
? JSON.parse(row.auto_schedule_config)
|
? JSON.parse(row.auto_schedule_config)
|
||||||
|
|
@ -133,7 +119,6 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
includeRetweets: row.include_retweets === 1,
|
includeRetweets: row.include_retweets === 1,
|
||||||
extractYoutube: row.extract_youtube === 1,
|
extractYoutube: row.extract_youtube === 1,
|
||||||
excludeManagedChannels: row.exclude_managed_channels === 1,
|
excludeManagedChannels: row.exclude_managed_channels === 1,
|
||||||
activeMonths: parseActiveMonths(row.active_months),
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 축제 봇 응답 스키마
|
* 축제 봇 응답 스키마
|
||||||
|
|
@ -12,6 +13,7 @@ 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' },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -33,6 +35,7 @@ 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -97,6 +100,7 @@ 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'],
|
||||||
},
|
},
|
||||||
|
|
@ -104,16 +108,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 } = request.body;
|
const { name, search_url, cron_interval = 360, active_months } = 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, enabled)
|
`INSERT INTO bot_festival (name, search_url, cron_interval, active_months, enabled)
|
||||||
VALUES (?, ?, ?, 1)`,
|
VALUES (?, ?, ?, ?, 1)`,
|
||||||
[name.trim(), search_url.trim(), cron_interval]
|
[name.trim(), search_url.trim(), cron_interval, serializeActiveMonths(active_months)]
|
||||||
);
|
);
|
||||||
|
|
||||||
scheduler.invalidateCache();
|
scheduler.invalidateCache();
|
||||||
|
|
@ -152,6 +156,7 @@ 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' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -181,6 +186,10 @@ 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);
|
||||||
|
|
|
||||||
32
backend/src/utils/botMonths.js
Normal file
32
backend/src/utils/botMonths.js
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
/**
|
||||||
|
* 봇 활성 월(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);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue