// Auto-deploy test comment // 设置进程时区(从.env读取,确保日志和数据库时间一致) process.env.TZ = process.env.TZ || 'Asia/Shanghai'; import Koa from 'koa'; import cors from '@koa/cors'; import Router from '@koa/router'; import bodyParser from '@koa/bodyparser'; import koaBody from 'koa-body'; import serve from 'koa-static'; import mount from 'koa-mount'; import path from 'path'; import fs from 'fs'; import http from 'http'; import { config } from './config'; import { connectDatabase, prisma } from './models'; import { errorHandler } from './middleware/errorHandler'; import { requestLogger } from './services/requestLogger'; import { httpLogger } from './services/logger.service'; import { redisService } from './services/redis.service'; import { ossService } from './services/oss.service'; import { storageService } from './services/storage.service'; import { FFmpegProcessor } from './services/ffmpeg.processor'; import { resumeInterruptedTasks } from './modules/book-generator/book-queue.processor'; import { startTtsQueue, stopTtsQueue } from './modules/book-generator/tts-queue'; import { startAudioScanner, stopAudioScanner } from './modules/book-generator/audio-scanner'; import { startBookRecoveryScanner, stopBookRecoveryScanner } from './modules/book-generator/book-recovery-scanner'; import { queueService } from './services/queue.service'; import { validateModelsJsonStructure, printValidationReport } from './config/models-validator'; import { checkDbSchema } from './services/db-schema-check'; import { initSentry, sentryErrorHandler } from './services/sentry.service'; import { xssProtection, sqlInjectionProtection } from './middleware/security'; import { performanceMonitor, getMetrics } from './middleware/performance'; import { apiRateLimiter } from './middleware/rate-limiter'; import logRoutes from './services/log.controller'; import authRoutes from './modules/auth/auth.controller'; import ttsRoutes from './modules/tts/tts.controller'; import testToolsRoutes from './modules/test-tools/llm-tools.controller'; import memberRoutes from './modules/member/member.controller'; import shareRoutes from './modules/share/share.controller'; import playerRoutes from './modules/player/player.controller'; import favoritesRoutes from './modules/favorites/favorites.controller'; import preferencesRoutes from './modules/preferences/preferences.controller'; import searchRoutes from './modules/search/search.controller'; import categoriesRoutes from './modules/categories/categories.controller'; import commentsRoutes from './modules/comments/comments.controller'; import notificationsRoutes from './modules/notifications/notifications.controller'; import bgmRoutes from './modules/bgm/bgm.controller'; import audioEditRoutes from './modules/audioedit/audioedit.controller'; import langGraphRoutes from './modules/book-generator/langgraph-controller'; import aiGenerateRoutes from './modules/book-generator/ai-generate-controller'; import albumRoutes from './modules/book-generator/album-controller'; import albumManagementRoutes from './modules/book-generator/album-management.controller'; import bookGeneratorRoutes from './modules/book-generator/book-generator.controller'; import chapterReadRoutes from './modules/book-generator/chapter-read.controller'; import playlistRoutes from './modules/player/playlist.controller'; import draftsRoutes from './modules/drafts/drafts.controller'; import videoGeneratorRoutes from './modules/video-generator/video-generator.controller'; import publishRoutes from './modules/publish/publish.controller'; import signRoutes from './modules/sign/sign.controller'; import subscriptionRoutes from './modules/subscription/subscription.controller'; import paymentRoutes from './modules/payment/payment.controller'; import historyRoutes from './modules/history/history.controller'; import historyBatchRoutes from './modules/history/history-batch.controller'; import feedbackRoutes from './modules/feedback/feedback.controller'; import queueRoutes from './modules/queue/queue.controller'; import inviteRoutes from './modules/invite/invite.controller'; import textToVideoRoutes from './modules/text-to-video/text-to-video.controller'; import pixelleVideoRoutes from './modules/pixelle-video/pixelle-video.controller'; import navigationRoutes from './modules/navigation/navigation.controller'; import { initializePlans } from './modules/subscription/subscription.service'; import { initWebSocket } from './services/websocket.service.js'; const app = new Koa(); const router = new Router(); // 创建 HTTP 服务器 const server = http.createServer(app.callback()); // 中间件 app.use(errorHandler); app.use(sentryErrorHandler()); // Sentry 错误监控 app.use(performanceMonitor()); // 性能监控 app.use(httpLogger); // Winston 日志 app.use(xssProtection()); // XSS 防护 app.use(sqlInjectionProtection()); // SQL 注入防护 app.use(cors({ origin: '*', allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowHeaders: ['Content-Type', 'Authorization'], })); // app.use(apiRateLimiter); // 全局限流 - 已关闭 // 确保上传目录存在 const uploadDir = path.join(process.cwd(), 'uploads', 'materials'); if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); console.log(`[App] 创建上传目录: ${uploadDir}`); } app.use(koaBody({ multipart: true, formidable: { uploadDir, keepExtensions: true, maxFileSize: 100 * 1024 * 1024, // 100MB }, })); // 请求日志(记录 body 参数,必须在 koaBody 之后注册) app.use(requestLogger); // 静态文件服务 - 音频文件 app.use(mount('/uploads', serve(config.upload.dir))); // 静态文件服务 - 视频文件 app.use(mount('/videos', serve(path.join(process.cwd(), 'public', 'videos')))); // 静态文件服务 - Pixabay 图片缓存 app.use(mount('/cache/pixabay', serve(path.join(process.cwd(), 'cache', 'pixabay')))); // 静态文件服务 - 音色试听样本 app.use(mount('/tts/voices', serve(path.join(process.cwd(), '..', 'voices')))); // 静态文件服务 - 管理后台页面 app.use(mount('/admin', serve(path.join(process.cwd(), 'public', 'admin')))); // 静态文件服务 - 硬件测试工具 app.use(mount('/test', serve(path.join(process.cwd(), 'public', 'test')))); // 健康检查 router.get('/health', (ctx) => { ctx.body = { code: 0, message: 'ok', data: { status: 'healthy' } }; }); // 性能指标 router.get('/api/metrics', getMetrics); // 注册路由 router.use('/api/auth', authRoutes.routes()); router.use('/api/tts', ttsRoutes.routes()); router.use('/api/test-tools', testToolsRoutes.routes()); router.use('/api/member', memberRoutes.routes()); router.use('/api/share', shareRoutes.routes()); router.use('/api/player', playerRoutes.routes()); router.use('/api/favorites', favoritesRoutes.routes()); router.use('/api/user/preferences', preferencesRoutes.routes()); router.use('/api/search', searchRoutes.routes()); router.use('/api/categories', categoriesRoutes.routes()); router.use('/api/comments', commentsRoutes.routes()); router.use('/api/notifications', notificationsRoutes.routes()); router.use('/api/bgm', bgmRoutes.routes()); router.use('/api/audio', audioEditRoutes.routes()); router.use('/api/book-generator/langgraph', langGraphRoutes.routes()); router.use('/api/book-generator', aiGenerateRoutes.routes()); router.use('/api/book-generator', albumRoutes.routes()); router.use('/api/book-generator', bookGeneratorRoutes.routes()); router.use('/api/book-generator', chapterReadRoutes.routes()); router.use('/api/book-generator/album', albumManagementRoutes.routes()); router.use('/api/playlists', playlistRoutes.routes()); router.use('/api/drafts', draftsRoutes.routes()); router.use('/api/history', historyRoutes.routes()); router.use('/api/history', historyBatchRoutes.routes()); router.use('/api/video', videoGeneratorRoutes.routes()); router.use('/api/sign', signRoutes.routes()); router.use('/api/subscription', subscriptionRoutes.routes()); router.use('/api/payment', paymentRoutes.routes()); router.use('/api/publish', publishRoutes.routes()); router.use('/api/feedback', feedbackRoutes.routes()); router.use('/api/queue', queueRoutes.routes()); router.use('/api/invite', inviteRoutes.routes()); router.use('/api/text-to-video', textToVideoRoutes.routes()); router.use('/api/pixelle-video', pixelleVideoRoutes.routes()); router.use('/api/navigation', navigationRoutes.routes()); router.use('/api', logRoutes.routes()); app.use(router.routes()).use(router.allowedMethods()); // 启动服务 async function start() { try { // 0. 初始化 Sentry initSentry(); // 1. 连接数据库 await connectDatabase(); console.log('✅ MySQL 连接成功'); // 1.1 数据库 Schema 自检(治根:防 Prisma client vs DB schema 漂移) // 历史上曾因 'BookChapter.parentId does not exist' 卡住所有 outline 生成 2 个月 // 不通过 → 抛错让进程退出,绝不放行 await checkDbSchema(); // 1.5. 校验 models.json 配置 const modelIssues = validateModelsJsonStructure(); if (modelIssues.length > 0) { printValidationReport(modelIssues); const hasErrors = modelIssues.some(i => i.severity === 'error'); if (hasErrors) { console.error('❌ models.json 配置错误,服务无法启动'); } } // 2. 测试 Redis 连接 const redisOk = await redisService.testConnection(); console.log(redisOk ? '✅ Redis 连接成功' : '⚠️ Redis 连接失败(缓存功能将不可用)'); // 3. 测试存储连接 const storageOk = await storageService.testConnection(); const storageType = storageService.getStorageType(); console.log(storageOk ? `✅ 存储连接成功 (${storageType === 'oss' ? '阿里云 OSS' : '本地存储'})` : '⚠️ 存储连接失败'); // 4. 初始化订阅套餐数据 await initializePlans(); // 5. 初始化 WebSocket 服务 initWebSocket(server); // 5.5 启动巡检:扫脏数据 audioUrl,防止 #audio-play-404-on-prod 复发 // 生产 nginx 不反代 /uploads/,任何 audioUrl 仍是 /uploads/... 的章节前端播放必 404。 // 这里只报警不 fail boot(避免历史脏数据导致服务起不来)。 try { const dirty = await prisma.bookChapter.count({ where: { audioUrl: { startsWith: '/uploads/' } }, }); if (dirty > 0) { console.error(`🚨 [BootWatchdog] 发现 ${dirty} 个 chapter 的 audioUrl 是本地路径 (/uploads/...)!`); console.error(`🚨 [BootWatchdog] 生产 nginx 不反代 /uploads/,这些章节播放必 404。`); console.error(`🚨 [BootWatchdog] 修复: cd server && STORAGE_TYPE=oss npx tsx scripts/fix-local-audio-urls.ts`); // 列出前 5 个脏数据 chapter id 方便定位 const samples = await prisma.bookChapter.findMany({ where: { audioUrl: { startsWith: '/uploads/' } }, select: { id: true, level: true, audioUrl: true, bookId: true }, take: 5, orderBy: { id: 'asc' }, }); console.error(`🚨 [BootWatchdog] 样例:`, JSON.stringify(samples)); } else { console.log('✅ [BootWatchdog] 脏数据巡检通过:无 /uploads/... 本地路径 audioUrl'); } } catch (e: any) { console.warn('[BootWatchdog] 巡检异常(不阻塞启动):', e?.message || e); } // 6. 启动服务 // 清理 temp 目录中的残留临时文件 FFmpegProcessor.cleanupAllTempFiles(); // 6.1 全局异常兜底:异步任务中的异常不应让进程崩溃 process.on('unhandledRejection', (reason: any) => { console.error('[unhandledRejection]', reason?.message || reason); }); process.on('uncaughtException', (err: Error) => { console.error('[uncaughtException]', err?.message || err); }); server.listen(config.port, () => { console.log(`🚀 服务启动成功: http://localhost:${config.port}`); console.log(`📁 上传目录: ${config.upload.dir}`); console.log(`💾 存储模式: ${storageType === 'oss' ? '阿里云 OSS' : '本地存储'}`); console.log(`⚡ 缓存模式: ${redisOk ? 'Redis' : '内存'}`); console.log(`\n💡 提示: 修改 .env 中的 STORAGE_TYPE 可切换存储模式`); }); // 7. 自动恢复中断的任务 resumeInterruptedTasks(); // 8. 启动数据库队列处理器 startTtsQueue(); // TTS 音频生成队列 (taskType=tts) // 内容生成已改为由 LangGraph 内部直接调用 LLM,不再使用队列 startAudioScanner(); // 音频扫描器(兜底:扫描遗漏章节 + 修复卡死状态) startBookRecoveryScanner(); // 书籍级自愈(兜底:扫描卡在 outline_ready / content_generating 无人推进的书) // 9. 优雅关闭 process.on('SIGTERM', async () => { console.log('收到 SIGTERM 信号,正在关闭服务...'); stopBookRecoveryScanner(); stopAudioScanner(); stopTtsQueue(); await queueService.closeAll(); await redisService.disconnect(); process.exit(0); }); process.on('SIGINT', async () => { console.log('收到 SIGINT 信号,正在关闭服务...'); stopBookRecoveryScanner(); stopAudioScanner(); stopTtsQueue(); await queueService.closeAll(); await redisService.disconnect(); process.exit(0); }); } catch (error) { console.error('❌ 服务启动失败:', error); process.exit(1); } } start();