/** * Scheduler Service * * 주간 자동 영상 생성을 위한 cron job 스케줄러 * - 매 시간마다 예약된 작업 확인 * - generation_queue에 작업 추가 * - 워커가 순차적으로 처리 */ const cron = require('node-cron'); const db = require('../db'); const path = require('path'); /** * 예약된 자동 생성 작업 확인 */ const getPendingGenerationJobs = () => { return new Promise((resolve, reject) => { const now = new Date().toISOString(); db.all(` SELECT ags.*, u.username, u.email, pp.brand_name, pp.description, pp.address, pp.pension_types FROM auto_generation_settings ags JOIN users u ON ags.user_id = u.id LEFT JOIN pension_profiles pp ON ags.pension_id = pp.id WHERE ags.enabled = 1 AND ags.next_scheduled_at <= ? AND NOT EXISTS ( SELECT 1 FROM generation_queue gq WHERE gq.user_id = ags.user_id AND gq.status IN ('pending', 'processing') ) `, [now], (err, rows) => { if (err) { console.error('예약 작업 조회 실패:', err); return resolve([]); } resolve(rows || []); }); }); }; /** * 작업 큐에 추가 */ const addToQueue = (userId, pensionId) => { return new Promise((resolve, reject) => { db.run(` INSERT INTO generation_queue (user_id, pension_id, status, scheduled_at) VALUES (?, ?, 'pending', datetime('now')) `, [userId, pensionId], function(err) { if (err) { console.error('큐 추가 실패:', err); return resolve(null); } resolve(this.lastID); }); }); }; /** * 다음 예약 시간 업데이트 */ const updateNextScheduledTime = (userId, dayOfWeek, timeOfDay) => { return new Promise((resolve, reject) => { const now = new Date(); const [hours, minutes] = (timeOfDay || '09:00').split(':').map(Number); let next = new Date(now); next.setHours(hours, minutes, 0, 0); // 다음 주 같은 요일로 설정 const daysUntilNext = (dayOfWeek - now.getDay() + 7) % 7 || 7; next.setDate(next.getDate() + daysUntilNext); const nextIso = next.toISOString(); db.run(` UPDATE auto_generation_settings SET next_scheduled_at = ?, last_generated_at = datetime('now') WHERE user_id = ? `, [nextIso, userId], (err) => { if (err) console.error('다음 예약 시간 업데이트 실패:', err); resolve(nextIso); }); }); }; /** * 큐에서 대기 중인 작업 처리 */ const processPendingQueue = async () => { return new Promise((resolve, reject) => { db.get(` SELECT gq.*, pp.brand_name, pp.description, pp.address, pp.pension_types, pp.user_id FROM generation_queue gq LEFT JOIN pension_profiles pp ON gq.pension_id = pp.id WHERE gq.status = 'pending' ORDER BY gq.scheduled_at ASC LIMIT 1 `, async (err, job) => { if (err || !job) { return resolve(null); } console.log(`[Scheduler] 작업 처리 시작: 사용자 ${job.user_id}, 펜션 ${job.pension_id}`); // 상태를 processing으로 변경 db.run(` UPDATE generation_queue SET status = 'processing', started_at = datetime('now') WHERE id = ? `, [job.id]); try { // 영상 생성 로직 실행 const result = await generateVideoForJob(job); // 성공 시 상태 업데이트 db.run(` UPDATE generation_queue SET status = 'completed', completed_at = datetime('now'), result_video_path = ?, result_history_id = ? WHERE id = ? `, [result.videoPath, result.historyId, job.id]); console.log(`[Scheduler] 작업 완료: ${job.id}`); resolve(result); } catch (error) { console.error(`[Scheduler] 작업 실패: ${job.id}`, error); // 실패 시 상태 업데이트 db.run(` UPDATE generation_queue SET status = 'failed', completed_at = datetime('now'), error_message = ? WHERE id = ? `, [error.message, job.id]); resolve(null); } }); }); }; /** * 실제 영상 생성 실행 * TODO: 실제 영상 생성 로직과 연동 필요 */ const generateVideoForJob = async (job) => { // 펜션 정보 기반으로 영상 생성 const businessInfo = { name: job.brand_name || '펜션', description: job.description || '', address: job.address || '', pensionCategories: job.pension_types ? JSON.parse(job.pension_types) : [] }; // 실제 영상 생성 API 호출 (내부 API 사용) // 현재는 placeholder - 실제 구현 시 render API 호출 필요 console.log('[Scheduler] 영상 생성 요청:', businessInfo.name); // TODO: 실제 영상 생성 로직 구현 // const response = await fetch('http://localhost:3001/render', { ... }); return { videoPath: null, // 실제 생성된 비디오 경로 historyId: null // 히스토리 ID }; }; /** * 스케줄러 시작 */ const startScheduler = () => { console.log('[Scheduler] 자동 생성 스케줄러 시작'); // 매 시간 정각에 실행 (0분) cron.schedule('0 * * * *', async () => { console.log(`[Scheduler] 예약 작업 확인 중... ${new Date().toISOString()}`); try { // 예약된 작업 확인 const pendingJobs = await getPendingGenerationJobs(); console.log(`[Scheduler] ${pendingJobs.length}개의 예약 작업 발견`); for (const job of pendingJobs) { // 큐에 작업 추가 await addToQueue(job.user_id, job.pension_id); // 다음 예약 시간 업데이트 await updateNextScheduledTime(job.user_id, job.day_of_week, job.time_of_day); console.log(`[Scheduler] 작업 예약됨: 사용자 ${job.user_id}`); } // 대기 중인 작업 처리 await processPendingQueue(); } catch (error) { console.error('[Scheduler] 스케줄러 오류:', error); } }); // 10분마다 큐 처리 (백업) cron.schedule('*/10 * * * *', async () => { try { await processPendingQueue(); } catch (error) { console.error('[Scheduler] 큐 처리 오류:', error); } }); console.log('[Scheduler] 스케줄러 등록 완료 - 매 시간 정각에 실행'); }; /** * 수동으로 특정 사용자의 자동 생성 실행 */ const triggerManualGeneration = async (userId) => { return new Promise((resolve, reject) => { db.get(` SELECT ags.*, pp.id as pension_id FROM auto_generation_settings ags LEFT JOIN pension_profiles pp ON ags.pension_id = pp.id OR (pp.user_id = ags.user_id AND pp.is_default = 1) WHERE ags.user_id = ? `, [userId], async (err, settings) => { if (err || !settings) { return reject(new Error('자동 생성 설정을 찾을 수 없습니다.')); } const queueId = await addToQueue(userId, settings.pension_id); if (!queueId) { return reject(new Error('큐 추가 실패')); } // 즉시 처리 const result = await processPendingQueue(); resolve(result); }); }); }; module.exports = { startScheduler, getPendingGenerationJobs, processPendingQueue, triggerManualGeneration };