feat: Meilisearch 동기화 봇 추가 및 시간대 관련 수정

봇 시스템:
- Meilisearch 동기화 봇 추가 (meilisearch-bot.js)
- bots 테이블 type enum에 meilisearch 추가
- youtube-scheduler.js에 meilisearch 봇 분기 추가
- admin.js API에서 meilisearch 봇 지원

봇 관리 페이지 개선 (AdminScheduleBots.jsx):
- Meilisearch 공식 로고 아이콘 추가
- Meilisearch 봇 통계: 동기화 수/소요 시간 표시
- 봇 타입별 배경색 (X: 검정, Meilisearch: #ddf1fd, YouTube: 빨강)

시간대 정리:
- MariaDB KST 설정으로 DATE_ADD(NOW(), INTERVAL 9 HOUR) → NOW() 변경
- youtube-bot.js, x-bot.js에서 10곳 수정
This commit is contained in:
caadiq 2026-01-10 18:59:39 +09:00
parent 8780384164
commit 622839b0e8
6 changed files with 153 additions and 21 deletions

View file

@ -11,6 +11,7 @@ import {
import pool from "../lib/db.js";
import { syncNewVideos, syncAllVideos } from "../services/youtube-bot.js";
import { syncAllTweets } from "../services/x-bot.js";
import { syncAllSchedules } from "../services/meilisearch-bot.js";
import { startBot, stopBot } from "../services/youtube-scheduler.js";
import {
addOrUpdateSchedule,
@ -1810,6 +1811,8 @@ router.post("/bots/:id/sync-all", authenticateToken, async (req, res) => {
result = await syncAllVideos(id);
} else if (botType === "x") {
result = await syncAllTweets(id);
} else if (botType === "meilisearch") {
result = await syncAllSchedules(id);
} else {
return res
.status(400)

View file

@ -0,0 +1,84 @@
/**
* Meilisearch 동기화 서비스
* 모든 일정을 Meilisearch에 동기화
*/
import pool from "../lib/db.js";
import { addOrUpdateSchedule } from "./meilisearch.js";
/**
* 전체 일정 Meilisearch 동기화
*/
export async function syncAllSchedules(botId) {
try {
const startTime = Date.now();
// 모든 일정 조회
const [schedules] = await pool.query(`
SELECT s.id, s.title, s.description, s.date, s.time,
s.category_id, s.source_url, s.source_name,
c.name as category_name, c.color as category_color
FROM schedules s
LEFT JOIN schedule_categories c ON s.category_id = c.id
`);
let synced = 0;
for (const s of schedules) {
// 멤버 조회
const [members] = await pool.query(
"SELECT m.id, m.name FROM schedule_members sm JOIN members m ON sm.member_id = m.id WHERE sm.schedule_id = ?",
[s.id]
);
// Meilisearch 동기화
await addOrUpdateSchedule({
id: s.id,
title: s.title,
description: s.description || "",
date: s.date,
time: s.time,
category_id: s.category_id,
category_name: s.category_name || "",
category_color: s.category_color || "",
source_name: s.source_name,
source_url: s.source_url,
members: members,
});
synced++;
}
const elapsedMs = Date.now() - startTime;
const elapsedSec = (elapsedMs / 1000).toFixed(2);
// 봇 상태 업데이트 (schedules_added = 동기화 수, last_added_count = 소요시간 ms)
await pool.query(
`UPDATE bots SET
last_check_at = NOW(),
schedules_added = ?,
last_added_count = ?,
error_message = NULL
WHERE id = ?`,
[synced, elapsedMs, botId]
);
console.log(`[Meilisearch Bot] ${synced}개 동기화 완료 (${elapsedSec}초)`);
return { synced, elapsed: elapsedSec };
} catch (error) {
// 오류 상태 업데이트
await pool.query(
`UPDATE bots SET
last_check_at = NOW(),
status = 'error',
error_message = ?
WHERE id = ?`,
[error.message, botId]
);
throw error;
}
}
export default {
syncAllSchedules,
};

View file

@ -497,7 +497,7 @@ export async function syncNewTweets(botId) {
if (totalAdded > 0) {
await pool.query(
`UPDATE bots SET
last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR),
last_check_at = NOW(),
schedules_added = schedules_added + ?,
last_added_count = ?,
error_message = NULL
@ -507,7 +507,7 @@ export async function syncNewTweets(botId) {
} else {
await pool.query(
`UPDATE bots SET
last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR),
last_check_at = NOW(),
error_message = NULL
WHERE id = ?`,
[botId]
@ -519,7 +519,7 @@ export async function syncNewTweets(botId) {
// 오류 상태 업데이트
await pool.query(
`UPDATE bots SET
last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR),
last_check_at = NOW(),
status = 'error',
error_message = ?
WHERE id = ?`,
@ -590,7 +590,7 @@ export async function syncAllTweets(botId) {
if (totalAdded > 0) {
await pool.query(
`UPDATE bots SET
last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR),
last_check_at = NOW(),
schedules_added = schedules_added + ?,
last_added_count = ?,
error_message = NULL
@ -600,7 +600,7 @@ export async function syncAllTweets(botId) {
} else {
await pool.query(
`UPDATE bots SET
last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR),
last_check_at = NOW(),
error_message = NULL
WHERE id = ?`,
[botId]

View file

@ -515,7 +515,7 @@ export async function syncNewVideos(botId) {
if (addedCount > 0) {
await pool.query(
`UPDATE bots SET
last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR),
last_check_at = NOW(),
schedules_added = schedules_added + ?,
last_added_count = ?,
error_message = NULL
@ -525,7 +525,7 @@ export async function syncNewVideos(botId) {
} else {
await pool.query(
`UPDATE bots SET
last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR),
last_check_at = NOW(),
error_message = NULL
WHERE id = ?`,
[botId]
@ -537,7 +537,7 @@ export async function syncNewVideos(botId) {
// 오류 상태 업데이트
await pool.query(
`UPDATE bots SET
last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR),
last_check_at = NOW(),
status = 'error',
error_message = ?
WHERE id = ?`,
@ -630,7 +630,7 @@ export async function syncAllVideos(botId) {
if (addedCount > 0) {
await pool.query(
`UPDATE bots SET
last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR),
last_check_at = NOW(),
schedules_added = schedules_added + ?,
last_added_count = ?,
error_message = NULL
@ -640,7 +640,7 @@ export async function syncAllVideos(botId) {
} else {
await pool.query(
`UPDATE bots SET
last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR),
last_check_at = NOW(),
error_message = NULL
WHERE id = ?`,
[botId]

View file

@ -2,6 +2,7 @@ import cron from "node-cron";
import pool from "../lib/db.js";
import { syncNewVideos } from "./youtube-bot.js";
import { syncNewTweets } from "./x-bot.js";
import { syncAllSchedules } from "./meilisearch-bot.js";
// 봇별 스케줄러 인스턴스 저장
const schedulers = new Map();
@ -21,6 +22,8 @@ async function syncBot(botId) {
return await syncNewVideos(botId);
} else if (botType === "x") {
return await syncNewTweets(botId);
} else if (botType === "meilisearch") {
return await syncAllSchedules(botId);
} else {
throw new Error(`지원하지 않는 봇 타입: ${botType}`);
}

View file

@ -18,6 +18,29 @@ const XIcon = ({ size = 20, fill = "currentColor" }) => (
</svg>
);
// Meilisearch
const MeilisearchIcon = ({ size = 20 }) => (
<svg width={size} height={size} viewBox="0 108.4 512 295.2">
<defs>
<linearGradient id="meili-a" x1="488.157" x2="-21.055" y1="469.917" y2="179.001" gradientTransform="matrix(1 0 0 -1 0 514)" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#ff5caa"/>
<stop offset="1" stopColor="#ff4e62"/>
</linearGradient>
<linearGradient id="meili-b" x1="522.305" x2="13.094" y1="410.144" y2="119.228" gradientTransform="matrix(1 0 0 -1 0 514)" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#ff5caa"/>
<stop offset="1" stopColor="#ff4e62"/>
</linearGradient>
<linearGradient id="meili-c" x1="556.456" x2="47.244" y1="350.368" y2="59.452" gradientTransform="matrix(1 0 0 -1 0 514)" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#ff5caa"/>
<stop offset="1" stopColor="#ff4e62"/>
</linearGradient>
</defs>
<path d="m0 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z" fill="url(#meili-a)"/>
<path d="m138.8 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z" fill="url(#meili-b)"/>
<path d="m277.6 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z" fill="url(#meili-c)"/>
</svg>
);
function AdminScheduleBots() {
const navigate = useNavigate();
const [user, setUser] = useState(null);
@ -311,10 +334,12 @@ function AdminScheduleBots() {
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
bot.type === 'x' ? 'bg-black' : 'bg-red-50'
bot.type === 'x' ? 'bg-black' : bot.type === 'meilisearch' ? 'bg-[#ddf1fd]' : 'bg-red-50'
}`}>
{bot.type === 'x' ? (
<XIcon size={20} fill="white" />
) : bot.type === 'meilisearch' ? (
<MeilisearchIcon size={20} />
) : (
<Youtube size={20} className="text-red-500" />
)}
@ -334,16 +359,33 @@ function AdminScheduleBots() {
{/* 통계 정보 */}
<div className="grid grid-cols-3 divide-x divide-gray-100 bg-gray-50/50">
<div className="p-3 text-center">
<div className="text-lg font-bold text-gray-900">{bot.schedules_added}</div>
<div className="text-xs text-gray-400"> 추가</div>
</div>
<div className="p-3 text-center">
<div className={`text-lg font-bold ${bot.last_added_count > 0 ? 'text-green-500' : 'text-gray-400'}`}>
+{bot.last_added_count || 0}
</div>
<div className="text-xs text-gray-400">마지막</div>
</div>
{bot.type === 'meilisearch' ? (
<>
<div className="p-3 text-center">
<div className="text-lg font-bold text-gray-900">{bot.schedules_added || 0}</div>
<div className="text-xs text-gray-400">동기화 </div>
</div>
<div className="p-3 text-center">
<div className="text-lg font-bold text-gray-900">
{bot.last_added_count ? `${((bot.last_added_count / 1000) || 0).toFixed(1)}` : '-'}
</div>
<div className="text-xs text-gray-400">소요 시간</div>
</div>
</>
) : (
<>
<div className="p-3 text-center">
<div className="text-lg font-bold text-gray-900">{bot.schedules_added}</div>
<div className="text-xs text-gray-400"> 추가</div>
</div>
<div className="p-3 text-center">
<div className={`text-lg font-bold ${bot.last_added_count > 0 ? 'text-green-500' : 'text-gray-400'}`}>
+{bot.last_added_count || 0}
</div>
<div className="text-xs text-gray-400">마지막</div>
</div>
</>
)}
<div className="p-3 text-center">
<div className="text-lg font-bold text-gray-900">{formatInterval(bot.check_interval)}</div>
<div className="text-xs text-gray-400">업데이트 간격</div>