app.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. // Auto-deploy test comment
  2. // 设置进程时区(从.env读取,确保日志和数据库时间一致)
  3. process.env.TZ = process.env.TZ || 'Asia/Shanghai';
  4. import Koa from 'koa';
  5. import cors from '@koa/cors';
  6. import Router from '@koa/router';
  7. import bodyParser from '@koa/bodyparser';
  8. import koaBody from 'koa-body';
  9. import serve from 'koa-static';
  10. import mount from 'koa-mount';
  11. import path from 'path';
  12. import fs from 'fs';
  13. import http from 'http';
  14. import { config } from './config';
  15. import { connectDatabase, prisma } from './models';
  16. import { errorHandler } from './middleware/errorHandler';
  17. import { requestLogger } from './services/requestLogger';
  18. import { httpLogger } from './services/logger.service';
  19. import { redisService } from './services/redis.service';
  20. import { ossService } from './services/oss.service';
  21. import { storageService } from './services/storage.service';
  22. import { FFmpegProcessor } from './services/ffmpeg.processor';
  23. import { resumeInterruptedTasks } from './modules/book-generator/book-queue.processor';
  24. import { startTtsQueue, stopTtsQueue } from './modules/book-generator/tts-queue';
  25. import { startAudioScanner, stopAudioScanner } from './modules/book-generator/audio-scanner';
  26. import { startBookRecoveryScanner, stopBookRecoveryScanner } from './modules/book-generator/book-recovery-scanner';
  27. import { queueService } from './services/queue.service';
  28. import { validateModelsJsonStructure, printValidationReport } from './config/models-validator';
  29. import { checkDbSchema } from './services/db-schema-check';
  30. import { initSentry, sentryErrorHandler } from './services/sentry.service';
  31. import { xssProtection, sqlInjectionProtection } from './middleware/security';
  32. import { performanceMonitor, getMetrics } from './middleware/performance';
  33. import { apiRateLimiter } from './middleware/rate-limiter';
  34. import logRoutes from './services/log.controller';
  35. import authRoutes from './modules/auth/auth.controller';
  36. import ttsRoutes from './modules/tts/tts.controller';
  37. import testToolsRoutes from './modules/test-tools/llm-tools.controller';
  38. import memberRoutes from './modules/member/member.controller';
  39. import shareRoutes from './modules/share/share.controller';
  40. import playerRoutes from './modules/player/player.controller';
  41. import favoritesRoutes from './modules/favorites/favorites.controller';
  42. import preferencesRoutes from './modules/preferences/preferences.controller';
  43. import searchRoutes from './modules/search/search.controller';
  44. import categoriesRoutes from './modules/categories/categories.controller';
  45. import commentsRoutes from './modules/comments/comments.controller';
  46. import notificationsRoutes from './modules/notifications/notifications.controller';
  47. import bgmRoutes from './modules/bgm/bgm.controller';
  48. import audioEditRoutes from './modules/audioedit/audioedit.controller';
  49. import langGraphRoutes from './modules/book-generator/langgraph-controller';
  50. import aiGenerateRoutes from './modules/book-generator/ai-generate-controller';
  51. import albumRoutes from './modules/book-generator/album-controller';
  52. import albumManagementRoutes from './modules/book-generator/album-management.controller';
  53. import bookGeneratorRoutes from './modules/book-generator/book-generator.controller';
  54. import chapterReadRoutes from './modules/book-generator/chapter-read.controller';
  55. import playlistRoutes from './modules/player/playlist.controller';
  56. import draftsRoutes from './modules/drafts/drafts.controller';
  57. import videoGeneratorRoutes from './modules/video-generator/video-generator.controller';
  58. import publishRoutes from './modules/publish/publish.controller';
  59. import signRoutes from './modules/sign/sign.controller';
  60. import subscriptionRoutes from './modules/subscription/subscription.controller';
  61. import paymentRoutes from './modules/payment/payment.controller';
  62. import historyRoutes from './modules/history/history.controller';
  63. import historyBatchRoutes from './modules/history/history-batch.controller';
  64. import feedbackRoutes from './modules/feedback/feedback.controller';
  65. import queueRoutes from './modules/queue/queue.controller';
  66. import inviteRoutes from './modules/invite/invite.controller';
  67. import textToVideoRoutes from './modules/text-to-video/text-to-video.controller';
  68. import pixelleVideoRoutes from './modules/pixelle-video/pixelle-video.controller';
  69. import navigationRoutes from './modules/navigation/navigation.controller';
  70. import { initializePlans } from './modules/subscription/subscription.service';
  71. import { initWebSocket } from './services/websocket.service.js';
  72. const app = new Koa();
  73. const router = new Router();
  74. // 创建 HTTP 服务器
  75. const server = http.createServer(app.callback());
  76. // 中间件
  77. app.use(errorHandler);
  78. app.use(sentryErrorHandler()); // Sentry 错误监控
  79. app.use(performanceMonitor()); // 性能监控
  80. app.use(httpLogger); // Winston 日志
  81. app.use(xssProtection()); // XSS 防护
  82. app.use(sqlInjectionProtection()); // SQL 注入防护
  83. app.use(cors({
  84. origin: '*',
  85. allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  86. allowHeaders: ['Content-Type', 'Authorization'],
  87. }));
  88. // app.use(apiRateLimiter); // 全局限流 - 已关闭
  89. // 确保上传目录存在
  90. const uploadDir = path.join(process.cwd(), 'uploads', 'materials');
  91. if (!fs.existsSync(uploadDir)) {
  92. fs.mkdirSync(uploadDir, { recursive: true });
  93. console.log(`[App] 创建上传目录: ${uploadDir}`);
  94. }
  95. app.use(koaBody({
  96. multipart: true,
  97. formidable: {
  98. uploadDir,
  99. keepExtensions: true,
  100. maxFileSize: 100 * 1024 * 1024, // 100MB
  101. },
  102. }));
  103. // 请求日志(记录 body 参数,必须在 koaBody 之后注册)
  104. app.use(requestLogger);
  105. // 静态文件服务 - 音频文件
  106. app.use(mount('/uploads', serve(config.upload.dir)));
  107. // 静态文件服务 - 视频文件
  108. app.use(mount('/videos', serve(path.join(process.cwd(), 'public', 'videos'))));
  109. // 静态文件服务 - Pixabay 图片缓存
  110. app.use(mount('/cache/pixabay', serve(path.join(process.cwd(), 'cache', 'pixabay'))));
  111. // 静态文件服务 - 音色试听样本
  112. app.use(mount('/tts/voices', serve(path.join(process.cwd(), '..', 'voices'))));
  113. // 静态文件服务 - 管理后台页面
  114. app.use(mount('/admin', serve(path.join(process.cwd(), 'public', 'admin'))));
  115. // 静态文件服务 - 硬件测试工具
  116. app.use(mount('/test', serve(path.join(process.cwd(), 'public', 'test'))));
  117. // 健康检查
  118. router.get('/health', (ctx) => {
  119. ctx.body = { code: 0, message: 'ok', data: { status: 'healthy' } };
  120. });
  121. // 性能指标
  122. router.get('/api/metrics', getMetrics);
  123. // 注册路由
  124. router.use('/api/auth', authRoutes.routes());
  125. router.use('/api/tts', ttsRoutes.routes());
  126. router.use('/api/test-tools', testToolsRoutes.routes());
  127. router.use('/api/member', memberRoutes.routes());
  128. router.use('/api/share', shareRoutes.routes());
  129. router.use('/api/player', playerRoutes.routes());
  130. router.use('/api/favorites', favoritesRoutes.routes());
  131. router.use('/api/user/preferences', preferencesRoutes.routes());
  132. router.use('/api/search', searchRoutes.routes());
  133. router.use('/api/categories', categoriesRoutes.routes());
  134. router.use('/api/comments', commentsRoutes.routes());
  135. router.use('/api/notifications', notificationsRoutes.routes());
  136. router.use('/api/bgm', bgmRoutes.routes());
  137. router.use('/api/audio', audioEditRoutes.routes());
  138. router.use('/api/book-generator/langgraph', langGraphRoutes.routes());
  139. router.use('/api/book-generator', aiGenerateRoutes.routes());
  140. router.use('/api/book-generator', albumRoutes.routes());
  141. router.use('/api/book-generator', bookGeneratorRoutes.routes());
  142. router.use('/api/book-generator', chapterReadRoutes.routes());
  143. router.use('/api/book-generator/album', albumManagementRoutes.routes());
  144. router.use('/api/playlists', playlistRoutes.routes());
  145. router.use('/api/drafts', draftsRoutes.routes());
  146. router.use('/api/history', historyRoutes.routes());
  147. router.use('/api/history', historyBatchRoutes.routes());
  148. router.use('/api/video', videoGeneratorRoutes.routes());
  149. router.use('/api/sign', signRoutes.routes());
  150. router.use('/api/subscription', subscriptionRoutes.routes());
  151. router.use('/api/payment', paymentRoutes.routes());
  152. router.use('/api/publish', publishRoutes.routes());
  153. router.use('/api/feedback', feedbackRoutes.routes());
  154. router.use('/api/queue', queueRoutes.routes());
  155. router.use('/api/invite', inviteRoutes.routes());
  156. router.use('/api/text-to-video', textToVideoRoutes.routes());
  157. router.use('/api/pixelle-video', pixelleVideoRoutes.routes());
  158. router.use('/api/navigation', navigationRoutes.routes());
  159. router.use('/api', logRoutes.routes());
  160. app.use(router.routes()).use(router.allowedMethods());
  161. // 启动服务
  162. async function start() {
  163. try {
  164. // 0. 初始化 Sentry
  165. initSentry();
  166. // 1. 连接数据库
  167. await connectDatabase();
  168. console.log('✅ MySQL 连接成功');
  169. // 1.1 数据库 Schema 自检(治根:防 Prisma client vs DB schema 漂移)
  170. // 历史上曾因 'BookChapter.parentId does not exist' 卡住所有 outline 生成 2 个月
  171. // 不通过 → 抛错让进程退出,绝不放行
  172. await checkDbSchema();
  173. // 1.5. 校验 models.json 配置
  174. const modelIssues = validateModelsJsonStructure();
  175. if (modelIssues.length > 0) {
  176. printValidationReport(modelIssues);
  177. const hasErrors = modelIssues.some(i => i.severity === 'error');
  178. if (hasErrors) {
  179. console.error('❌ models.json 配置错误,服务无法启动');
  180. }
  181. }
  182. // 2. 测试 Redis 连接
  183. const redisOk = await redisService.testConnection();
  184. console.log(redisOk ? '✅ Redis 连接成功' : '⚠️ Redis 连接失败(缓存功能将不可用)');
  185. // 3. 测试存储连接
  186. const storageOk = await storageService.testConnection();
  187. const storageType = storageService.getStorageType();
  188. console.log(storageOk
  189. ? `✅ 存储连接成功 (${storageType === 'oss' ? '阿里云 OSS' : '本地存储'})`
  190. : '⚠️ 存储连接失败');
  191. // 4. 初始化订阅套餐数据
  192. await initializePlans();
  193. // 5. 初始化 WebSocket 服务
  194. initWebSocket(server);
  195. // 5.5 启动巡检:扫脏数据 audioUrl,防止 #audio-play-404-on-prod 复发
  196. // 生产 nginx 不反代 /uploads/,任何 audioUrl 仍是 /uploads/... 的章节前端播放必 404。
  197. // 这里只报警不 fail boot(避免历史脏数据导致服务起不来)。
  198. try {
  199. const dirty = await prisma.bookChapter.count({
  200. where: { audioUrl: { startsWith: '/uploads/' } },
  201. });
  202. if (dirty > 0) {
  203. console.error(`🚨 [BootWatchdog] 发现 ${dirty} 个 chapter 的 audioUrl 是本地路径 (/uploads/...)!`);
  204. console.error(`🚨 [BootWatchdog] 生产 nginx 不反代 /uploads/,这些章节播放必 404。`);
  205. console.error(`🚨 [BootWatchdog] 修复: cd server && STORAGE_TYPE=oss npx tsx scripts/fix-local-audio-urls.ts`);
  206. // 列出前 5 个脏数据 chapter id 方便定位
  207. const samples = await prisma.bookChapter.findMany({
  208. where: { audioUrl: { startsWith: '/uploads/' } },
  209. select: { id: true, level: true, audioUrl: true, bookId: true },
  210. take: 5,
  211. orderBy: { id: 'asc' },
  212. });
  213. console.error(`🚨 [BootWatchdog] 样例:`, JSON.stringify(samples));
  214. } else {
  215. console.log('✅ [BootWatchdog] 脏数据巡检通过:无 /uploads/... 本地路径 audioUrl');
  216. }
  217. } catch (e: any) {
  218. console.warn('[BootWatchdog] 巡检异常(不阻塞启动):', e?.message || e);
  219. }
  220. // 6. 启动服务
  221. // 清理 temp 目录中的残留临时文件
  222. FFmpegProcessor.cleanupAllTempFiles();
  223. // 6.1 全局异常兜底:异步任务中的异常不应让进程崩溃
  224. process.on('unhandledRejection', (reason: any) => {
  225. console.error('[unhandledRejection]', reason?.message || reason);
  226. });
  227. process.on('uncaughtException', (err: Error) => {
  228. console.error('[uncaughtException]', err?.message || err);
  229. });
  230. server.listen(config.port, () => {
  231. console.log(`🚀 服务启动成功: http://localhost:${config.port}`);
  232. console.log(`📁 上传目录: ${config.upload.dir}`);
  233. console.log(`💾 存储模式: ${storageType === 'oss' ? '阿里云 OSS' : '本地存储'}`);
  234. console.log(`⚡ 缓存模式: ${redisOk ? 'Redis' : '内存'}`);
  235. console.log(`\n💡 提示: 修改 .env 中的 STORAGE_TYPE 可切换存储模式`);
  236. });
  237. // 7. 自动恢复中断的任务
  238. resumeInterruptedTasks();
  239. // 8. 启动数据库队列处理器
  240. startTtsQueue(); // TTS 音频生成队列 (taskType=tts)
  241. // 内容生成已改为由 LangGraph 内部直接调用 LLM,不再使用队列
  242. startAudioScanner(); // 音频扫描器(兜底:扫描遗漏章节 + 修复卡死状态)
  243. startBookRecoveryScanner(); // 书籍级自愈(兜底:扫描卡在 outline_ready / content_generating 无人推进的书)
  244. // 9. 优雅关闭
  245. process.on('SIGTERM', async () => {
  246. console.log('收到 SIGTERM 信号,正在关闭服务...');
  247. stopBookRecoveryScanner();
  248. stopAudioScanner();
  249. stopTtsQueue();
  250. await queueService.closeAll();
  251. await redisService.disconnect();
  252. process.exit(0);
  253. });
  254. process.on('SIGINT', async () => {
  255. console.log('收到 SIGINT 信号,正在关闭服务...');
  256. stopBookRecoveryScanner();
  257. stopAudioScanner();
  258. stopTtsQueue();
  259. await queueService.closeAll();
  260. await redisService.disconnect();
  261. process.exit(0);
  262. });
  263. } catch (error) {
  264. console.error('❌ 服务启动失败:', error);
  265. process.exit(1);
  266. }
  267. }
  268. start();