02-源代码文档.md 182 KB

==============================================================================

        盛果智能应用软件 V1.0 源代码文档

==============================================================================

软件全称:盛果智能应用软件 软件简称:盛果 版 本 号:V1.0

说明:本文档包含软件前30页和后30页源代码,每页50行。

  前30页为软件前端代码,后30页为软件后端代码。

##############################################################################

第一部分:前端源代码(前30页)

##############################################################################

// ═══════════════════════ // 文件: my-uniapp-vue3/src/main.ts // ═══════════════════════ import { createSSRApp } from 'vue'; import { createPinia } from 'pinia'; import App from './App.vue'; import { useUserStore } from './store/user'; // #ifdef H5 import VConsole from 'vconsole'; // #endif export function createApp() { const app = createSSRApp(App); const pinia = createPinia(); app.use(pinia); // 初始化用户状态 const userStore = useUserStore(); userStore.initUser(); // #ifdef H5 // vConsole 开启,用于调试微信支付问题 new VConsole(); // #endif return {

app,
pinia,

}; }

// ═══════════════════════ // 文件: my-uniapp-vue3/src/App.vue // ═══════════════════════ .mini-player { position: fixed; bottom: 120rpx; left: 16rpx; right: 16rpx; height: 96rpx; background: rgba(255, 255, 255, 0.9); backdrop-filter: blur(28px); -webkit-backdrop-filter: blur(28px); border-radius: 48rpx; box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.12), 0 0 0 0.5rpx rgba(0, 0, 0, 0.06); display: flex; align-items: center; padding: 0 16rpx;

// ═══════════════════════ // 文件: my-uniapp-vue3/src/components/AudioDownload.vue // ═══════════════════════

<slot>
  <view class="default-btn" :class="{ downloading: downloading }">
    <text class="icon">{{ downloading ? '⏳' : '⬇️' }}</text>
    <text class="text">{{ downloading ? `下载中 ${progress}%` : '下载音频' }}</text>
    <view v-if="downloading" class="progress-bar">
      <view class="progress-fill" :style="{ width: progress + '%' }"></view>
    </view>
  </view>
</slot>

.credit-modal-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); z-index: 9999; display: flex; align-items: flex-end; justify-content: center; opacity: 0; transition: opacity 0.25s ease; }

// ═══════════════════════ // 文件: my-uniapp-vue3/src/components/GenerationStatusBadge.vue // ═══════════════════════

<text class="gen-icon">{{ icon }}</text>
<text class="gen-label">{{ label }}</text>

.gen-badge { display: inline-flex; align-items: center; gap: 6rpx; padding: 6rpx 16rpx; border-radius: 20rpx; font-size: 22rpx; font-weight: 500; white-space: nowrap; transition: all 0.3s ease; user-select: none; } .gen-icon { font-size: 20rpx; } .gen-label { /* 由父级控制文字颜色 */ }

##############################################################################

第二部分:后端源代码(后30页)

##############################################################################

// ═══════════════════════ // 文件: server/src/app.ts // ═══════════════════════ // 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 } 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/audio-project/book-queue.processor'; import { startTtsQueue, stopTtsQueue } from './modules/audio-project/tts-queue'; import { startAudioScanner, stopAudioScanner } from './modules/audio-project/audio-scanner'; import { queueService } from './services/queue.service'; import { validateModelsJsonStructure, printValidationReport } from './config/models-validator'; 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 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/audio-project/langgraph-controller'; import aiGenerateRoutes from './modules/audio-project/ai-generate-controller'; import albumRoutes from './modules/audio-project/album-controller'; import albumManagementRoutes from './modules/audio-project/album-management.controller'; import bookGeneratorRoutes from './modules/audio-project/audio-project.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 { 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'],

// ═══════════════════════ // 文件: server/src/config/index.ts // ═══════════════════════ import dotenv from 'dotenv'; import path from 'path'; import fs from 'fs'; // 使用 process.cwd() 获取项目根目录(server目录) const projectRoot = process.cwd(); dotenv.config({ path: path.resolve(projectRoot, '.env') }); // 加载模型配置 const modelsConfig = JSON.parse(fs.readFileSync(path.join(__dirname, 'models.json'), 'utf-8')); // 获取所有模型(扁平化) function getAllModels() { const models: any[] = []; for (const [vendorKey, vendor] of Object.entries(modelsConfig.vendors) as [string, any][]) {

for (const model of vendor.models) {
  models.push({
    ...model,
    vendor: vendorKey,
    vendorName: vendor.name,
    baseUrl: vendor.baseUrl,
    apiKey: vendor.apiKey,
    apiType: vendor.apiType,
  });
}

} return models; } // 获取启用的模型 function getEnabledModels() { return getAllModels().filter((m: any) => m.enabled); } // 根据 ID 获取模型配置 function getModel(id: string) { return getAllModels().find((m: any) => m.id === id); } // 根据类型获取模型列表 (text, tts, image, video) function getModelsByType(type: string) { return getAllModels().filter((m: any) => m.input?.includes(type) && m.enabled); } // 检查模型是否可切换(根据错误类型判断是否需要切换) function shouldSwitchModel(error: any): boolean { if (!error) return false; const message = (error?.message || error?.error?.message || '').toLowerCase(); const status = error?.status || error?.response?.status || 0; // 不可切换的错误:认证/权限/参数问题,换供应商也没用 const nonSwitchablePatterns = [

'invalid api key', 'invalid api-key', 'authentication', 'unauthorized',
'invalid token', 'token expired',
'permission denied', 'access denied',
'invalid request', 'bad request',
'invalidparameter', 'invalid_parameter',  // TTS API 参数错误(如文本过短)

]; if (nonSwitchablePatterns.some(p => message.includes(p))) return false; if (status === 401) return false; // 认证失败 // 可切换的错误:限流、余额不足、服务不可用、模型不存在 const switchablePatterns = [

'rate limit', 'rate_limit', 'too many requests', '请求过于频繁',
'quota', 'balance', 'insufficient', 'usage limit',
'model not found', 'model not support', 'does not exist', 'invalid model',
'service unavailable', 'bad gateway', 'gateway timeout',
'internal server error',

]; if (switchablePatterns.some(p => message.includes(p))) return true; // HTTP 状态码判断 if ([429, 502, 503, 504, 500].includes(status)) return true; if (status === 403) return true; // 403 多数是配额/权限,换Key可能有效 if (status === 404) return true; // 模型不存在,换供应商 // 状态码文本匹配(兜底) if (['429', '502', '503', '504'].some(c => message.includes(c))) return true; return false; } // 获取下一个可用模型(用于自动切换) function getNextModel(currentModelId: string, type: string): string | null { const models = getModelsByType(type); const currentIndex = models.findIndex((m: any) => m.id === currentModelId); if (currentIndex === -1 || currentIndex >= models.length - 1) {

return null; // 没有下一个模型

} return models[currentIndex + 1].id; } export const config = { port: parseInt(process.env.PORT || process.env.SERVER_PORT || '3000', 10),

// ═══════════════════════ // 文件: server/src/models/index.ts // ═══════════════════════ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient({ datasources: {

db: {
  url: process.env.DATABASE_URL,
},

}, }); export async function connectDatabase(): Promise { try {

await prisma.$connect();
console.log(`📦 MySQL 连接成功 (${process.env.TZ || 'Asia/Shanghai'})`);

} catch (error) {

console.error('📦 MySQL 连接失败:', error);
throw error;

} } export { prisma };

// ═══════════════════════ // 文件: server/src/middleware/auth.ts // ═══════════════════════ import { Context, Next } from 'koa'; import jwt from 'jsonwebtoken'; import { config } from '../config'; import { UnauthorizedError, AppError } from './errorHandler'; import { JwtPayload } from '../types'; import { safeParseInt } from '../utils/safe-parse'; export async function authMiddleware(ctx: Context, next: Next): Promise { // 开发阶段默认跳过认证(除非显式设置 AUTH_ENABLED=true) const authEnabled = process.env.AUTH_ENABLED === 'true'; if (!authEnabled) {

ctx.state.user = {
  userId: '1',
  phone: 'test',
  memberLevel: 99,
};
await next();
return;

} const authHeader = ctx.get('Authorization'); if (!authHeader) {

throw new UnauthorizedError('缺少 Authorization 头');

} const parts = authHeader.split(' '); if (parts.length !== 2 || parts[0] !== 'Bearer') {

throw new UnauthorizedError('Authorization 格式错误');

} const token = parts[1]; try {

const secret = config.jwt.secret;
const payload = jwt.verify(token, secret) as JwtPayload;
ctx.state.user = payload;
await next();

} catch (err: unknown) {

const error = err as Error;
if (error.name === 'TokenExpiredError') {
  throw new UnauthorizedError('Token 已过期');
}
if (error.name === 'JsonWebTokenError') {
  throw new UnauthorizedError('Token 无效');
}
throw new AppError('认证失败', 401, 401);

} } // 可选认证(允许未登录访问,自动使用测试用户) export async function optionalAuth(ctx: Context, next: Next): Promise { const authHeader = ctx.get('Authorization'); if (authHeader) {

const parts = authHeader.split(' ');
if (parts.length === 2 && parts[0] === 'Bearer') {
  try {
    const payload = jwt.verify(parts[1], config.jwt.secret) as JwtPayload;
    ctx.state.user = payload;
  } catch {
    // 忽略错误,使用测试用户
    ctx.state.user = {
      userId: '1',
      phone: 'test',
      memberLevel: 99, // 超级VIP
    };
  }
}

} else {

// 没有认证头,使用测试用户(超级VIP)
ctx.state.user = {
  userId: '1',
  phone: 'test',
  memberLevel: 99, // 超级VIP
};

} await next(); }

// ═══════════════════════ // 文件: server/src/middleware/security.ts // ═══════════════════════ /**

  • 安全中间件
  • 包含:XSS 防护、SQL 注入防护、敏感数据脱敏 */ // XSS 防护 export function xssProtection() { return async (ctx: any, next: any) => { // 过滤请求体中的 XSS if (ctx.request.body && typeof ctx.request.body === 'object') { sanitizeObject(ctx.request.body); } // 过滤查询参数中的 XSS if (ctx.request.query && typeof ctx.request.query === 'object') { sanitizeObject(ctx.request.query); } // 设置安全响应头 ctx.set('X-XSS-Protection', '1; mode=block'); ctx.set('X-Content-Type-Options', 'nosniff'); ctx.set('X-Frame-Options', 'DENY'); ctx.set('Content-Security-Policy', "default-src 'self'"); await next(); }; } /**
  • 递归清理对象中的 XSS */ function sanitizeObject(obj: any): void { for (const key in obj) { if (typeof obj[key] === 'string') { obj[key] = sanitizeString(obj[key]); } else if (typeof obj[key] === 'object' && obj[key] !== null) { sanitizeObject(obj[key]); } } } /**
  • 清理字符串中的 XSS 攻击代码 / function sanitizeString(str: string): string { return str .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') .replace(/\//g, '/') .replace(/javascript:/gi, '') .replace(/on\w+=/gi, '') .replace(/]>[\s\S]?<\/script>/gi, '') .replace(/]>[\s\S]?<\/iframe>/gi, '') .replace(/]>[\s\S]?<\/object>/gi, '') .replace(/]>[\s\S]*?<\/embed>/gi, ''); } // SQL 注入防护 export function sqlInjectionProtection() { return async (ctx: any, next: any) => { // ===== DEBUG: 记录所有请求 ===== if (ctx.path?.includes('/wechat/jsapi')) { console.log('[SECURITY-DEBUG] JSAPI请求到达security中间件'); console.log('[SECURITY-DEBUG] method:', ctx.method); console.log('[SECURITY-DEBUG] path:', ctx.path); console.log('[SECURITY-DEBUG] query:', JSON.stringify(ctx.request.query)); console.log('[SECURITY-DEBUG] body:', JSON.stringify(ctx.request.body)); console.log('[SECURITY-DEBUG] headers[content-type]:', ctx.request.headers['content-type']); } // 检查请求参数 const params = { ...ctx.request.query, ...(ctx.request.body || {}), }; for (const key in params) { if (typeof params[key] === 'string') { if (detectSQLInjection(params[key])) { console.log('[SECURITY-DEBUG] ❌ JSAPI请求被SQL注入拦截, key:', key, 'value:', params[key].substring(0, 100)); ctx.status = 400; ctx.body = { code: 400, message: '请求包含非法字符', }; return; }

    // ═══════════════════════ // 文件: server/src/middleware/errorHandler.ts // ═══════════════════════ import { Context, Next } from 'koa'; export async function errorHandler(ctx: Context, next: Next): Promise { try {

    await next();
    

    } catch (err: unknown) {

    const error = err as Error & { status?: number; code?: number };
    console.error('❌ Error:', error.message);
    console.error('Stack:', error.stack);
    ctx.status = error.status || 500;
    ctx.body = {
      code: error.code || 500,
      message: error.message || '服务器内部错误',
      data: null,
    };
    // 开发环境返回详细错误
    if (process.env.NODE_ENV === 'development') {
      (ctx.body as Record<string, unknown>).stack = error.stack;
    }
    

    } } // 自定义错误类 export class AppError extends Error { public code: number; public status: number; constructor(message: string, code: number = 500, status: number = 500) {

    super(message);
    this.code = code;
    this.status = status;
    this.name = 'AppError';
    

    } } export class UnauthorizedError extends AppError { constructor(message: string = '未授权访问') {

    super(message, 401, 401);
    

    } } export class ForbiddenError extends AppError { constructor(message: string = '禁止访问') {

    super(message, 403, 403);
    

    } } export class NotFoundError extends AppError { constructor(message: string = '资源不存在') {

    super(message, 404, 404);
    

    } } export class BadRequestError extends AppError { constructor(message: string = '请求参数错误') {

    super(message, 400, 400);
    

    } } export class QuotaExceededError extends AppError { constructor(message: string = '使用次数已达上限') {

    super(message, 429, 429);
    

    } }

    // ═══════════════════════ // 文件: server/src/middleware/rate-limiter.ts // ═══════════════════════ import { RateLimiterMemory, RateLimiterRedis } from 'rate-limiter-flexible'; import { redisService } from '../services/redis.service'; interface RateLimitOptions { points: number; // 限流点数 duration: number; // 时间窗口(秒) blockDuration?: number; // 封锁时间(秒) keyGenerator?: (ctx: any) => string; // 自定义键生成器 } // 内存限流器(Redis 不可用时使用) const memoryLimiters = new Map(); // Redis 限流器 const redisLimiters = new Map(); /**

    • 获取或创建限流器 */ function getLimiter(name: string, options: RateLimitOptions) { if (redisService.isAvailable()) { if (!redisLimiters.has(name)) { const limiter = new RateLimiterRedis({ storeClient: redisService['client'], keyPrefix: rate_limit:${name}:, points: options.points, duration: options.duration, blockDuration: options.blockDuration || options.duration * 2, }); redisLimiters.set(name, limiter); } return redisLimiters.get(name)!; } else { if (!memoryLimiters.has(name)) { const limiter = new RateLimiterMemory({ points: options.points, duration: options.duration, }); memoryLimiters.set(name, limiter); } return memoryLimiters.get(name)!; } } /**
    • 创建限流中间件
    • @param options 限流配置 */ export function createRateLimiter(options: RateLimitOptions) { const limiter = getLimiter('default', options); return async (ctx: any, next: any) => { try { const key = options.keyGenerator ? options.keyGenerator(ctx) : ctx.ip; await limiter.consume(key, 1); return await next(); } catch (rejRes: any) { const retrySecs = Math.round(rejRes.msBeforeNext / 1000) || 1; ctx.set('Retry-After', String(retrySecs)); ctx.status = 429; ctx.body = { code: 429, message: '请求过于频繁,请稍后再试', retryAfter: retrySecs, }; } }; } /**
    • API 全局限流(每分钟 100 次) */ export const apiRateLimiter = createRateLimiter({ points: 100, duration: 60, keyGenerator: (ctx) => api:${ctx.ip}, }); /**
    • 登录接口限流(每分钟 5 次) */ export const loginRateLimiter = createRateLimiter({ points: 5, duration: 60, blockDuration: 300, // 封锁 5 分钟 keyGenerator: (ctx) => login:${ctx.ip},

    // ═══════════════════════ // 文件: server/src/modules/auth/auth.controller.ts // ═══════════════════════ import Router from '@koa/router'; import { Context } from 'koa'; import * as AuthService from './auth.service'; import { BadRequestError } from '../../middleware/errorHandler'; import { authMiddleware } from '../../middleware/auth'; import { prisma } from '../../models'; import { safeParseInt } from '../../utils/safe-parse'; const router = new Router(); // 发送验证码 router.post('/send-code', async (ctx: Context) => { const { phone } = ctx.request.body as { phone: string }; if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {

    throw new BadRequestError('请输入正确的手机号');
    

    } const code = AuthService.generateSmsCode(phone); // 开发环境直接返回验证码 ctx.body = {

    code: 0,
    message: '验证码发送成功',
    data: {
      phone,
      // 总是返回验证码(方便测试)
      code,
    },
    

    }; }); // 手机号登录 router.post('/login', async (ctx: Context) => { const { phone, code, inviteCode, openid } = ctx.request.body as { phone: string; code?: string; inviteCode?: string; openid?: string }; if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {

    throw new BadRequestError('请输入正确的手机号');
    

    } // 关闭验证码验证(线上线下都关闭) if (!code || !/^\d{4,6}$/.test(code)) {

    // 跳过验证码检查,直接登录
    

    } const result = await AuthService.loginWithPhone(phone, code, inviteCode, openid); ctx.body = {

    code: 0,
    message: '登录成功',
    data: result,
    

    }; }); // 根据 openid 自动登录(公众号场景) router.post('/login-by-openid', async (ctx: Context) => { const { openid } = ctx.request.body as { openid: string }; if (!openid) {

    throw new BadRequestError('openid 不能为空');
    

    } const result = await AuthService.loginWithOpenid(openid); if (!result) {

    ctx.body = {
      code: -1,
      message: '该微信未绑定账号,请先登录绑定',
      data: null,
    };
    return;
    

    } ctx.body = {

    code: 0,
    message: '自动登录成功',
    data: result,
    

    }; }); // 获取用户信息 router.get('/user-info', authMiddleware, async (ctx: Context) => { const userId = ctx.state.user.userId; const userInfo = await AuthService.getUserInfo(userId); ctx.body = {

    code: 0,
    message: 'success',
    data: userInfo,
    

    }; }); // 更新用户信息 router.put('/user-info', authMiddleware, async (ctx: Context) => { const userId = ctx.state.user.userId; const uid = safeParseInt(userId); const { nickname, avatar } = ctx.request.body as { nickname?: string; avatar?: string }; const user = await prisma.user.findUnique({ where: { id: uid } });

    // ═══════════════════════ // 文件: server/src/modules/auth/auth.service.ts // ═══════════════════════ import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import { config } from '../../config'; import { prisma } from '../../models'; import { JwtPayload } from '../../types'; import { handleInvite } from '../invite/invite.service'; import { grantSignupBonus } from '../subscription/subscription.service'; // 验证码存储(生产环境应使用 Redis) const smsCodes = new Map(); // 生成验证码 export function generateSmsCode(phone: string): string { const code = Math.random().toString().slice(2, 6); smsCodes.set(phone, {

    code,
    expireAt: Date.now() + 5 * 60 * 1000, // 5分钟有效
    

    }); console.log(📱 验证码已生成: ${phone} -> ${code}); return code; } // 验证验证码 export function verifySmsCode(phone: string, code: string): boolean { const stored = smsCodes.get(phone); if (!stored) return false; if (Date.now() > stored.expireAt) {

    smsCodes.delete(phone);
    return false;
    

    } if (stored.code !== code) return false; smsCodes.delete(phone); return true; } // 生成 JWT Token export function generateToken(userId: string, phone?: string): string { const secret = config.jwt.secret; const payload: Omit = { userId, phone }; return jwt.sign(payload, secret, {

    expiresIn: '7d',
    

    }); } // 手机号登录/注册 export async function loginWithPhone(phone: string, code?: string, inviteCode?: string, openid?: string): Promise<{ token: string; user: {

    id: string;
    phone: string;
    nickname: string;
    avatar: string;
    memberLevel: number;
    isNewUser: boolean;
    

    }; }> { // 免密登录:code 为空或为"123456"时直接登录(线上线下都关闭验证) const skipVerify = !code || code === '123456'; if (!skipVerify) {

    // 验证验证码
    const isValid = verifySmsCode(phone, code || '');
    if (!isValid) {
      throw new Error('验证码错误或已过期');
    }
    

    } // 查找或创建用户 let user = await prisma.user.findFirst({ where: { phone } }); let isNewUser = false; if (!user) {

    user = await prisma.user.create({
      data: {
        phone,
        openid: openid || null, // 首次注册时绑定 openid
        nickname: `用户${phone.slice(-4)}`,
        avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${phone}`,
        memberLevel: 0,
        dailyUsage: 0,
        lastUsageDate: '',
      },
    });
    isNewUser = true;
    // 新用户注册赠送积分
    await grantSignupBonus(user.id);
    // 处理邀请关系
    if (inviteCode) {
    

    // ═══════════════════════ // 文件: server/src/modules/audio-project/audio-project.controller.ts // ═══════════════════════ /**

    • 批量转换 - API 路由
    • 包含一键完整生成 API */ import Router from '@koa/router'; import { Context } from 'koa'; import { bookStore } from './audio-project.store'; import { createBatchGenerationTask, setCancellationFlag, GenerationStep } from './audio-project.service'; // 任务存储(用于取消操作) const runningTasks = new Map(); const router = new Router(); /**
    • POST /api/audio-project/books/:id/batch-generate
    • 一键完整批量转换(音频、字幕视频合成、合并视频) */ router.post('/books/:id/batch-generate', async (ctx: Context) => { try { const bookId = ctx.params.id as string; const body = ctx.request.body as { steps?: GenerationStep[]; }; // 验证项目存在 const book = await bookStore.getById(bookId); if (!book) { ctx.status = 404; ctx.body = { code: 1, message: '项目不存在' }; return; } // 默认执行全部步骤 const steps: GenerationStep[] = body.steps || [ 'generate_content', 'generate_audio', 'merge_audio', 'generate_video', 'merge_video' ]; // 验证步骤 const validSteps: GenerationStep[] = [ 'generate_content', 'generate_audio', 'merge_audio', 'generate_video', 'merge_video' ]; for (const step of steps) { if (!validSteps.includes(step)) { ctx.status = 400; ctx.body = { code: 1, message: 无效的步骤: ${step} }; return; } } // 检查是否有正在运行的批量生成任务 const existingTask = runningTasks.get(bookId); if (existingTask) { ctx.status = 400; ctx.body = { code: 1, message: '项目已有批量转换任务在运行,请先取消后再试', data: { taskId: existingTask.taskId } }; return; } // 创建批量生成任务 const { taskId, orchestrator } = await createBatchGenerationTask(bookId, steps); // 存储任务信息 runningTasks.set(bookId, { taskId, bookId }); // 后台执行任务 (async () => { try { const result = await orchestrator.execute(); console.log([BatchGen][${taskId}] 任务完成:, result); // 发送完成消息 const { pushBatchGenerationProgress } = await import('../../services/websocket.service.js'); pushBatchGenerationProgress(taskId, 'completed', 100); } catch (error: any) { console.error([BatchGen][${taskId}] 任务异常:, error);
    • // ═══════════════════════ // 文件: server/src/modules/audio-project/audio-project.service.ts // ═══════════════════════ /**

      • 批量转换编排服务
      • 负责按顺序执行生成步骤并推送进度 */ import { bookStore } from './audio-project.store'; import { pushBatchGenerationProgress } from '../../services/websocket.service.js'; import { createVideoProjectFromBook, generateVideoForProject } from '../video-generator/video-generator.service'; import { mergeChapterAudios } from '../player/player.service'; import { prisma } from '../../models'; import { advanceChapter, regenerateChapter } from './stage-manager'; import { estimateBookWords, estimateAudioMinutesFromWords, atomicReserveQuota, releaseQuota, getUserMonthlyCost, AUDIO_BILLING_CONFIG } from '../subscription/subscription.service'; // 步骤类型 export type GenerationStep = 'generate_content' | 'generate_audio' | 'merge_audio' | 'generate_video' | 'merge_video'; // 取消标志 const cancellationFlags = new Map(); /**
      • 设置取消标志 */ export function setCancellationFlag(taskId: string): void { cancellationFlags.set(taskId, true); } /**
      • 清除取消标志 */ export function clearCancellationFlag(taskId: string): void { cancellationFlags.delete(taskId); } /**
      • 检查是否已取消 */ export function isTaskCancelled(taskId: string): boolean { return cancellationFlags.get(taskId) === true; } /**
      • 批量生成编排器 */ export class BatchGenerationOrchestrator { private taskId: string; private bookId: string; private steps: GenerationStep[]; constructor(taskId: string, bookId: string, steps: GenerationStep[]) { this.taskId = taskId; this.bookId = bookId; this.steps = steps; } /**
        • 推送进度 */ private pushProgress(step: string, progress: number, message: string): void { pushBatchGenerationProgress(this.taskId, step, progress); console.log([BatchGen][${this.taskId}] ${step}: ${progress}% - ${message}); } /**
        • 检查是否已取消 */ private checkCancellation(): void { if (isTaskCancelled(this.taskId)) { console.log([BatchGen][${this.taskId}] 任务已取消); throw new Error('TASK_CANCELLED'); } } /**
        • 执行所有步骤 */ async execute(): Promise<{ success: boolean; completedSteps: GenerationStep[]; failedStep?: string; error?: string }> { const completedSteps: GenerationStep[] = []; try { // 获取项目信息 const book = await bookStore.getById(this.bookId); if (!book) { return { success: false, completedSteps, failedStep: 'init', error: '项目不存在' }; } // 执行每个步骤 for (let i = 0; i < this.steps.length; i++) { this.checkCancellation(); const step = this.steps[i]; const stepProgress = Math.round((i / this.steps.length) * 100); this.pushProgress(step, stepProgress, '开始执行'); try { switch (step) {
      • // ═══════════════════════ // 文件: server/src/modules/audio-project/langgraph-controller.ts // ═══════════════════════ /**

        • LangGraph 批量转换 - API 路由
        • 支持项目创建时的预估显示 */ import Router from '@koa/router'; import { Context } from 'koa'; import { langGraphGenerator, getScaleConfig, resolveGenLevel, mapBookTypeToGenLevel } from './index'; import { bookStore, cancelAudioGeneration } from './audio-project.store'; import { prisma } from '../../models'; import { estimateBookWords, estimateAudioMinutesFromWords, checkBookGenerationQuota, checkAudioQuota, atomicReserveQuota, releaseQuota, AUDIO_BILLING_CONFIG } from '../subscription/subscription.service'; import { optionalAuth } from '../../middleware/auth'; import { getAllBookTypes, getDetectableTypes, getBookTypeConfig, BOOK_TYPE_CONFIG, DETECTABLE_TYPES } from './book-type-config'; import { callLLMWithMessages, callLLM, ChatMessage } from '../../services/llm'; import { cleanLlmShortText, extractJsonFromResponse } from '../../services/llm/response-cleaner'; import { runWithContext } from '../../services/llm-context'; import { createVideoProjectFromBook, generateVideoForProject } from '../video-generator/video-generator.service'; import { mergeChapterAudios } from '../player/player.service'; import { exec } from 'child_process'; import { promisify } from 'util'; import fs from 'fs/promises'; import path from 'path'; import { advanceChapter, regenerateChapter } from './stage-manager'; import { deepPlanBookNode } from './nodes/deep-plan.node'; import { richOutlineNode } from './nodes/rich-outline.node'; import { writeChaptersParallelNode } from './nodes/content.node'; import { continuityEditNode } from './nodes/continuity-edit.node'; import { GraphState } from './graph'; const execPromise = promisify(exec); // 开发环境测试用户ID const TEST_USER_ID = '1'; const router = new Router(); /**
        • GET /api/audio-project/langgraph/estimate
        • 获取项目规模预估信息 */ router.get('/estimate', async (ctx: Context) => { const { scale, userId, bookType } = ctx.query as { scale?: string; userId?: string; bookType?: string }; if (!scale) { ctx.status = 400; ctx.body = { code: 1, message: '请提供项目规模' }; return; } const scaleConfig = getScaleConfig(scale); const totalWords = scaleConfig.totalWords; const audioMinutes = estimateAudioMinutesFromWords(totalWords); // 章节数 const estimatedChapters = scaleConfig.isShortArticle ? 1 : scaleConfig.chapters; // 大纲层级(优先使用项目类型映射) const genLevel = bookType && bookType !== 'auto' ? mapBookTypeToGenLevel(bookType)

          : resolveGenLevel(scale); const genLevelDescriptions: Record = { 1: { label: '仅章', desc: '无节和小节,适合短文、长篇文本' }, 2: { label: '章→节', desc: '有节无小节,适合科普、商业书' }, 3: { label: '章→节→小节', desc: '完整三级结构,适合教材、技术书' }, }; const result: any = { scale, scaleConfig, audioMinutes: { min: estimateAudioMinutesFromWords(Math.round(totalWords * 0.8)), max: estimateAudioMinutesFromWords(Math.round(totalWords * 1.2)), avg: audioMinutes }, estimatedChapters, defaultGenLevel: genLevel, genLevelInfo: genLevelDescriptions[genLevel] || { label: '章→节', desc: '' }, }; // 如果提供了 userId,同时检查用户配额 if (userId) { const quotaCheck = await checkBookGenerationQuota(parseInt(userId), scale); result.quotaCheck = quotaCheck; } ctx.body = { code: 0, message: 'success', data: result }; }); /**

        • // ═══════════════════════ // 文件: server/src/modules/audio-project/audio-project.store.ts // ═══════════════════════ /**

          • 批量转换模块 - Prisma 数据库存储 */ import crypto from 'crypto'; import { prisma } from '../../models'; import { Book, BookOutline, Chapter, ChapterGenStage, BookGenStage } from './audio-project.types'; import { Prisma } from '@prisma/client'; import { generateAudio } from '../tts/tts.service'; import { callLLMWithMessages, callLLMWithTools, ChatMessage } from '../../services/llm'; import { createBookTools } from '../../services/llm/tools'; import { SUBSECTION_CONTENT_SYSTEM_PROMPT } from './prompts/templates'; import { countWords } from './utils'; import { cleanThinkingText } from './utils/content-cleaner'; import { advanceChapter, regenerateChapter, safeTransitionChapter } from './stage-manager'; import { mergeChapterAudios } from '../player/player.service'; import { consumeAudioMinutes, canUseTtsProvider } from '../subscription/subscription.service'; import { runWithContext } from '../../services/llm-context'; /**
          • 取消项目所有章节的音频生成(将 pending/processing 任务标记为 cancelled,回退章节阶段) */ export async function cancelAudioGeneration(bookId: string): Promise<{ cancelledCount: number; rolledBackChapters: number[] }> { const chapters = await prisma.bookChapter.findMany({ where: { bookId: Number(bookId) }, select: { id: true, level: true }, }); if (chapters.length === 0) { return { cancelledCount: 0, rolledBackChapters: [] }; } const maxLevel = Math.max(...chapters.map(c => c.level || 0)); const leafChapterIds = chapters.filter(c => c.level === maxLevel).map(c => c.id); // 找出所有 pending/processing 状态的 TTS 任务 const activeTasks = await prisma.ttsTask.findMany({ where: { chapterId: { in: leafChapterIds }, taskType: 'tts', status: { in: ['pending', 'processing'] }, }, select: { id: true, chapterId: true }, }); if (activeTasks.length === 0) { return { cancelledCount: 0, rolledBackChapters: [] }; } const taskIds = activeTasks.map(t => t.id); const affectedChapterIds = [...new Set(activeTasks.map(t => t.chapterId))]; // 批量标记任务为 cancelled await prisma.ttsTask.updateMany({ where: { id: { in: taskIds } }, data: { status: 'cancelled' }, }); // 回退受影响章节的 genStage 到 content_completed await prisma.bookChapter.updateMany({ where: { id: { in: affectedChapterIds }, genStage: 'audio_generating' }, data: { genStage: 'content_completed' }, }); return { cancelledCount: taskIds.length, rolledBackChapters: affectedChapterIds, }; } // ============ 删除项目 ============ /**
          • 根据所有章节状态计算项目阶段
          • 项目阶段 = 所有章节中最低的阶段(最落后的章节决定了项目的进度) */ function computeBookGenStage(chapters: { genStage: string }[]): BookGenStage { if (chapters.length === 0) return 'draft'; // 章节阶段顺序(索引越大越"后") const stageOrder = ['idle', 'outline_completed', 'content_generating', 'content_completed', 'audio_generating', 'audio_completed', 'video_generating', 'video_completed', 'failed']; // 找出最低阶段的索引 let minIdx = stageOrder.length; // 默认最大 for (const ch of chapters) { const idx = stageOrder.indexOf(ch.genStage); if (idx === -1) { console.warn([BookStore] 未知章节阶段: chapterId=${(ch as any).id}, genStage="${ch.genStage}",跳过该章节); continue; } if (idx < minIdx) { minIdx = idx; } }

          // ═══════════════════════ // 文件: server/src/modules/audio-project/audio-project.types.ts // ═══════════════════════ /**

          • 批量转换模块 - 类型定义
          • 定义项目、章节、任务的数据结构 */ // ============ 核心类型 ============ /** 批量转换阶段 */ export type BookGenStage = | 'draft' | 'outlining' | 'outline_completed' | 'content_generating' | 'content_completed' | 'audio_generating' | 'audio_completed' | 'video_generating' | 'video_completed' | 'failed'; /** 章节生成阶段(唯一类型定义,stage-manager.ts 从此文件导入) */ export type ChapterGenStage = | 'idle' | 'outline_completed' | 'content_generating' | 'content_completed' | 'audio_generating' | 'audio_completed' | 'video_generating' | 'video_completed' | 'failed'; /** 项目基础信息 */ export interface BookBase { id: string; title: string; // 标题 subtitle?: string; // 副标题 description: string; // 项目描述/用户输入 targetAudience: string; // 目标受众 style: string; // 文本风格 bookScale: string; // 项目规模:纯数字字符串,如 1000, 2000, 130000, 340000 等 totalChapters: number; // 总章节数 estimatedWords: number; // 预估总字数 genStage?: BookGenStage; // 线性阶段状态 failedStage?: string; // 失败阶段 createdAt: Date; updatedAt: Date; } /** 项目完整信息 */ export interface Book extends BookBase { userId?: number; // 用户ID(用于配额检查) progress: number; // 处理进度 0-100 isPublished: boolean; // 是否已公开 chapters: Chapter[]; // 章节列表 outline?: BookOutline; // 项目大纲 metadata?: BookMetadata; // 元数据 error?: string; // 错误信息 bookAnalysis?: string; // AI 项目规划结果(planBookNode 输出) } /** 项目大纲 */ export interface BookOutline { bookType?: 'textbook' | 'novel' | 'popular' | 'essay'; // 文本类型(AI判断) mainTheme: string; // 主题主线 structureLogic: string; // 结构逻辑 chapters: OutlineChapter[]; // 章节大纲 } /** 章节大纲(规划阶段)*/ export interface OutlineChapter { number: number; title: string; summary: string; // 章节概述 keyPoints: string[]; // 核心知识点 estimatedWords: number; // 预估字数 stories?: string[]; // 故事/案例 sections?: { // 节(章下第一级) number: number; // 节序号 title: string; summary?: string; // 本节概述 keyPoints?: string[]; // 本节要点 estimatedWords?: number; // 本节预估字数 subsections?: { // 小节(节下第二级) number: number; // 小节序号 title: string; summary?: string; // 本小节概述

          // ═══════════════════════ // 文件: server/src/modules/audio-project/graph.ts // ═══════════════════════ /**

          • LangGraph 状态定义和工作流 */ import { Annotation, StateGraph, END } from '@langchain/langgraph'; // ============ 状态定义(借鉴 OpenMAIC Annotation 模式)============ /**
          • 进度 reducer:只增不减,防止中间步骤回退导致进度丢失 */ const maxReducer = (prev: number, update: number) => Math.max(prev, update); /**
          • 章节完成数 reducer:累加而非覆盖 */ const appendReducer = (prev: T[], update: T | T[] | undefined) => { if (!update) return prev; const items = Array.isArray(update) ? update : [update]; return [...prev, ...items]; }; export const GraphState = Annotation.Root({ bookId: Annotation({ reducer: (_prev, update) => update ?? _prev, default: () => '' as string, }), userId: Annotation({ reducer: (_prev, update) => update ?? _prev, default: () => '' as string, }), topic: Annotation({ reducer: (_prev, update) => update ?? _prev, default: () => '' as string, }), bookScale: Annotation({ reducer: (_prev, update) => update ?? _prev, default: () => '1000' as string, }), /**
            • 大纲层级:1=仅章,2=章→节,3=章→节→小节。
            • reducer: update ?? _prev — 仅当 update 非 null/undefined 时才覆盖旧值。
            • 0/false 被视为 falsy 会保留旧值;合法的 genLevel 值是 1/2/3,不受此限制。 */ genLevel: Annotation({ reducer: (_prev, update) => update ?? _prev, default: () => 2, }), /** 用户明确指定的大纲层级(undefined=未指定/自动,1/2/3=用户主动选择)。
            • 用于区分"用户主动选了2"和"系统默认2",防止 AI 规划时误覆盖。 */ userSpecifiedGenLevel: Annotation({ reducer: (_prev, update) => update ?? _prev, default: () => undefined as number | undefined, }), description: Annotation({ reducer: (_prev, update) => update ?? _prev, default: () => '' as string, }), /** AI 项目规划结果(planBookNode 输出) */ bookPlan: Annotation({ reducer: (_prev, update) => update ?? _prev, default: () => undefined, }), /** 当前正在处理的章节号 */ currentChapter: Annotation({ reducer: maxReducer, default: () => 0, }), /** 已成功完成的章节数(只增不减) */ completedChapters: Annotation({ reducer: appendReducer, default: () => [] as number[], }), finished: Annotation({ reducer: (_prev, update) => update ?? _prev, default: () => false, }), error: Annotation({ reducer: (_prev, update) => update ?? _prev, default: () => undefined, }), /** 生成进度 0-100,只增不减 */ progress: Annotation({ reducer: maxReducer, default: () => 0,

              // ═══════════════════════ // 文件: server/src/modules/audio-project/nodes/content.node.ts // ═══════════════════════ /**

              • 文本转换节点
              • 为所有叶节点(没有子节点的节点)进行文本分段处理 */ import { GraphState } from '../graph'; import { bookStore } from '../audio-project.store'; import { prisma } from '../../../models'; import { createBookTools } from '../../../services/llm/tools'; import { checkQuotaForWords, markGenerationInterrupted, getGeneratedWordCount } from '../../subscription/subscription.service'; import { PROGRESS, countWords } from '../utils'; import { advanceChapter, regenerateChapter } from '../stage-manager'; import { getBookWordLimit, getWordUpperLimit } from '../book-type-config'; import { AsyncPool } from '../utils/async-pool'; import { cleanThinkingText, truncateAtBoundary } from '../utils/content-cleaner'; import { safeTransitionChapter } from '../stage-manager'; import { BookConsistencyTracker } from '../utils/content-consistency'; import { withGlobalLLMConcurrency, getBookConcurrency } from '../utils/llm-concurrency'; /** 全局术语一致性跟踪器(单例,跨整本书的章节共享) */ const globalConsistencyTracker = new BookConsistencyTracker(); /**
              • 为被截断的内容补一个自然结尾句,
              • 避免截断后看起来像没写完。 / function appendNaturalClosure(content: string): string { if (!content || content.length < 50) return content; const trimmed = content.trimEnd(); // 已有完整结尾标点的不补 const naturalEndings = /[。!?.!?)」"”']\s$/; if (naturalEndings.test(trimmed)) return trimmed; // 从内容末尾提取关键词,生成一个简短收尾句 const lastSentences = trimmed.split(/[。!?.!?]/).filter(Boolean); const lastTopic = lastSentences.length > 0 ? lastSentences[lastSentences.length - 1].substring(0, 30).trim()

                : ''; if (lastTopic.length > 3) { return trimmed + 。以上就是关于${lastTopic}的讨论。; } return trimmed + '。以上就是本章的主要内容。'; } /**

              • 安全更新父章节状态:仅在父节点下所有子节点都已完成时,才推进父节点状态。
              • 替代原先的 updateMany 批量操作,避免失败子节点污染已完成父节点状态。 */ async function safelyCompleteParentChapters(bookIdNum: number): Promise { const parents = await prisma.bookChapter.findMany({ where: { bookId: bookIdNum, level: { in: [1, 2] } }, select: { id: true, genStage: true }, }); const allChildren = await prisma.bookChapter.findMany({ where: { bookId: bookIdNum, parentId: { in: parents.map(p => p.id) } }, select: { id: true, genStage: true, parentId: true }, }); // 按 parentId 分组 const childrenByParent = new Map(); for (const child of allChildren) { if (!child.parentId) continue; if (!childrenByParent.has(child.parentId)) childrenByParent.set(child.parentId, []); childrenByParent.get(child.parentId)!.push(child); } for (const parent of parents) { const children = childrenByParent.get(parent.id) || []; if (children.length === 0) continue; // 无子节点(单层大纲),跳过 const allChildrenDone = children.every( c => c.genStage === 'content_completed' || c.genStage === 'audio_generating' || c.genStage === 'audio_completed' || c.genStage === 'video_generating' || c.genStage === 'video_completed' ); if (allChildrenDone && parent.genStage !== 'content_completed') { try { await safeTransitionChapter(parent.id, parent.genStage as any, 'content_completed'); } catch (err: any) { console.warn([Content] 父节点 #${parent.id} 状态转移失败:, err.message); } } } } /**
              • 文本转换节点
              • 为所有叶节点(没有子节点的节点)进行文本分段处理 */
              • // ═══════════════════════ // 文件: server/src/modules/audio-project/nodes/plan.node.ts // ═══════════════════════ /**

                • 项目规划节点(planBookNode)
                • 在文本转换之前,由 AI 对整篇文本做分析规划:
                • - 分析文本类型、目标受众、内容深度
                • - 决定分段层级(1/2/3层)
                • - 规划文本结构和分段逻辑
                • - 输出完整的分段方案 *
                • 该节点的输出(planResult)会被存储到 GraphState,供后续节点参考。 */ import { GraphState } from '../graph'; import { bookStore } from '../audio-project.store'; import { callLLMWithMessages, ChatMessage } from '../../../services/llm'; import { PROGRESS } from '../utils'; import { getScaleConfig } from '../book-type-config'; /**
                • 项目规划输出接口 */ export interface BookPlan { /** 最终确定的大纲层级 */ genLevel: number; /** 文本类型分析 */ bookTypeAnalysis: string; /** 推荐的写作风格 */ writingStyle: string; /** 结构逻辑说明 */ structureLogic: string; /** 内容深度评估 */ contentDepth: string; /** 目标读者分析 */ targetAudienceAnalysis: string; /** 规划理由 */ reasoning: string; } /**
                • 从描述中提取明确的字数要求
                • 如 "生成200字文本" → 200 */ function extractWordCountFromDescription(description: string): number | null { const text = description.toLowerCase(); // 匹配 "200字"、"500字" 等 const match = text.match(/(\d+)\s*字/i); if (match) { const n = parseInt(match[1]); return isNaN(n) ? null : n; } return null; } /**
                • 构建规划提示词 */ function buildPlanPrompt( title: string, description: string, bookScale: string ): string { const config = getScaleConfig(bookScale); const chapters = config?.chapters || 17; // 如果 description 里有明确字数要求,优先用它 const descWordCount = extractWordCountFromDescription(description); const totalWords = descWordCount !== null ? descWordCount

                  : (config?.totalWords || 130000); return `你对以下文本做全面分析规划。

                  文本信息

                • 标题:${title}

                • 字数规模:约${totalWords}字,约${chapters}章

                • 描述:${description}

                  你的职责

                  对整篇文本做完整的结构分析。

                  分析维度

                  1. 文本类型分析

                  判断这篇文本属于以下哪种类型:

                • 教材/学术类:系统教学、理论知识体系完整

                • 技术教程类:有操作步骤、实践指南

                • 文学类:叙事性、描述性内容

                • 商业/经管类:管理、营销、投资、创业

                • 科普/大众类:普及科学知识,通俗易懂

                • 其他类型

                  2. 大纲层级决策

                // ═══════════════════════ // 文件: server/src/modules/audio-project/nodes/outline.node.ts // ═══════════════════════ /**

                • 大纲生成节点(集成容错机制) */ import { GraphState } from '../graph'; import { bookStore } from '../audio-project.store'; import { callLLMWithMessages } from '../../../services/llm'; import { parseOutline } from '../parsers/outline.parser'; import { buildOutlineMessages, SCALE_CHAPTER_RANGE } from '../prompts/builder'; import { PROGRESS } from '../utils'; import { callLLMWithRetry, executeNodeWithTimeout, FAULT_TOLERANCE_CONFIG } from '../fault-tolerance'; import { getChapterRange, calcChapters } from '../book-type-config'; export async function generateOutlineNode(state: typeof GraphState.State): Promise> { console.log('[LangGraph] 生成大纲, bookId:', state.bookId, 'scale:', state.bookScale); // 读取前序规划节点的分析结果,用于指导大纲生成 let bookPlan: any = null; if (state.bookPlan) { try { bookPlan = JSON.parse(state.bookPlan); console.log('[LangGraph] 使用规划结果指导大纲:', bookPlan.structureLogic, bookPlan.writingStyle); } catch { console.warn('[LangGraph] bookPlan 解析失败,忽略'); } } try { // 使用容错包装器执行AI调用 const response = await executeNodeWithTimeout( state.bookId, 'generate_outline', async () => { return callLLMWithRetry( buildOutlineMessages(state.topic, state.bookScale, state.description, bookPlan), undefined, { bookId: state.bookId, nodeId: 'generate_outline', attempt: 0, maxAttempts: FAULT_TOLERANCE_CONFIG.aiRetry.maxRetries, } ); }, FAULT_TOLERANCE_CONFIG.nodeTimeout.generate_outline ); const outline = parseOutline(response); if (!outline) throw new Error('大纲解析失败'); // 校验章节数:允许 ±20% 浮动,超出才截断/补充 const targetChapters = SCALE_CHAPTER_RANGE[state.bookScale as keyof typeof SCALE_CHAPTER_RANGE]; if (targetChapters) { const { min: minAllowed, max: maxAllowed } = getChapterRange(targetChapters); const actual = outline.chapters.length; if (actual > maxAllowed) { console.warn([LangGraph] AI 返回章节数 ${actual} 超出上限 ${maxAllowed},截断); outline.chapters = outline.chapters.slice(0, maxAllowed); } else if (actual < minAllowed) { console.warn([LangGraph] AI 返回章节数 ${actual} 少于下限 ${minAllowed},使用话题相关的默认章节补充); const topicHint = state.topic?.substring(0, 30) || ''; const perChapterWords = Math.round(parseInt(state.bookScale) / minAllowed); const gapTopics = [ 概述与背景, 核心概念, 深入探究, 应用与实践, 总结与展望, ]; while (outline.chapters.length < minAllowed) { const idx = outline.chapters.length; const gapTitle = gapTopics[idx % gapTopics.length]; outline.chapters.push({ number: idx + 1, title: topicHint ? 第${idx + 1}章 ${topicHint} - ${gapTitle} : 第${idx + 1}章 ${gapTitle}, summary: 本章围绕"${topicHint || gapTitle}"展开,介绍${gapTitle}相关内容。, keyPoints: [gapTitle, '关键知识点', '实践要点'], estimatedWords: perChapterWords, }); } } // 在范围内(±20%)不做任何处理,让 AI 自己决定 } // 短文类至少1个章节 if (outline.chapters.length === 0) { console.warn('[LangGraph] AI 返回章节数为0,使用话题创建默认章节');
                • // ═══════════════════════ // 文件: server/src/modules/audio-project/nodes/quality-check.node.ts // ═══════════════════════ /**

                  • 质量校验节点(qualityCheckNode) *
                  • 在正文内容并行生成完成后,对所有章节做四维质量评估:
                  • - 通顺性(fluency)
                  • - 逻辑性(logic)
                  • - 是否跑题(relevance)
                  • - 是否达标(completeness) *
                  • 输出每个章节的评分和问题列表,决定是否进入重写环节。
                  • 与 rewrite.node.ts 组成质量闭环:校验→不通过→重写→再校验 */ import { GraphState } from '../graph'; import { bookStore } from '../audio-project.store'; import { callLLMWithMessages, ChatMessage } from '../../../services/llm'; import { callLLMWithRetry, executeNodeWithTimeout, FAULT_TOLERANCE_CONFIG } from '../fault-tolerance'; import { QUALITY_CHECK_SYSTEM_PROMPT } from '../prompts/templates'; import { PROGRESS, countWords } from '../utils'; import { prisma } from '../../../models'; // ============ 类型定义 ============ export interface QualityScore { fluency: number; // 通顺 0-25 logic: number; // 逻辑 0-25 relevance: number; // 跑题 0-25 completeness: number; // 达标 0-25 } export interface QualityIssue { dimension: 'fluency' | 'logic' | 'relevance' | 'completeness'; severity: 'high' | 'medium' | 'low'; description: string; location: string; suggestion: string; } export interface FailedChapter { chapterNumber: number; chapterTitle: string; scores: QualityScore; totalScore: number; issues: QualityIssue[]; rewriteInstructions: string; } export interface QualityCheckResult { overallScore: number; overallAssessment: string; passedChapters: number[]; failedChapters: FailedChapter[]; } // ============ 常量 ============ /** 合格分数线 */ const PASS_THRESHOLD = 80; /** 每批最大发送字符数(避免 token 超限)*/ const MAX_BATCH_CHARS = 60000; // ============ 核心逻辑 ============ /**
                  • 构建单批质量校验消息 */ function buildBatchQualityCheckMessages( bookTitle: string, bookTopic: string, batchChapters: Array<{ number: number; title: string; content: string; summary?: string; estimatedWords?: number }> ): ChatMessage[] { let chaptersText = ''; for (const ch of batchChapters) { const header = \n## 第${ch.number}章:${ch.title}\n> 概述:${ch.summary || '无'}\n> 预估字数:${ch.estimatedWords || 0} | 实际字数:${countWords(ch.content)}\n\n; const content = ch.content || '(空)'; chaptersText += header + content; } return [ { role: 'system', content: QUALITY_CHECK_SYSTEM_PROMPT }, { role: 'user', content: 书名:《${bookTitle}》 主题:${bookTopic} 以下是 ${batchChapters.length} 个章节的完整内容,请逐一评估质量: ${chaptersText} 请输出 JSON 格式的质量评估报告。, }, ]; } /**

                  // ═══════════════════════ // 文件: server/src/modules/tts/tts.service.ts // ═══════════════════════ import path from 'path'; import fs from 'fs'; import { v4 as uuidv4 } from 'uuid'; import { config } from '../../config'; import { prisma } from '../../models'; import { VoiceParams, Voice } from '../../types'; import { AudioMerger } from './audio-merger'; import { aiSummaryService } from './ai-summary.service'; import { storageService } from '../../services/storage.service'; import { getTtsRegistry, getAvailableTtsProvider, startTtsHealthCheck } from './provider.registry'; import { ITtsProvider } from './provider.interface'; import { CircuitBreakerOpenError } from '../../common/circuit-breaker'; import { ProviderNode } from '../../common/provider-registry'; import { ttsLogger } from './tts-logger'; import axios from 'axios'; // ============ 统一音色定义(10个固定音色,前端使用)============ // 前端使用统一 ID,后端根据 Provider 类型映射到真实音色 const UNIFIED_VOICES: Voice[] = [ { id: 'voice_01', name: '温柔女声', gender: 'female', description: '柔和温暖,适合情感故事' }, { id: 'voice_02', name: '磁性男声', gender: 'male', description: '低沉有力,适合悬疑推理' }, { id: 'voice_03', name: '活泼女声', gender: 'female', description: '清新明亮,适合儿童故事' }, { id: 'voice_04', name: '知性女声', gender: 'female', description: '知性稳重,适合科普知识' }, { id: 'voice_05', name: '阳光男声', gender: 'male', description: '阳光活力,适合校园青春' }, { id: 'voice_06', name: '沧桑男声', gender: 'male', description: '成熟沧桑,适合历史军事' }, { id: 'voice_07', name: '甜美女声', gender: 'female', description: '甜美可爱,适合爱情都市' }, { id: 'voice_08', name: '清朗男声', gender: 'male', description: '清朗干练,适合职场商战' }, { id: 'voice_09', name: '亲切女声', gender: 'female', description: '亲切自然,适合日常叙事' }, { id: 'voice_10', name: '稚嫩童声', gender: 'female', description: '稚嫩天真,适合童话寓言' }, ]; // 统一音色 → 阿里云 真实音色映射 const ALIYUN_VOICE_MAP: Record = { voice_01: 'longanyang', voice_02: 'longsanshu_v3', voice_03: 'longhuhu_v3', voice_04: 'longyue_v3', voice_05: 'longyichen_v3', voice_06: 'longlaobo_v3', voice_07: 'longmiao_v3', voice_08: 'longshuo_v3', voice_09: 'longwan_v3', voice_10: 'longhuhu_v3', }; // 统一音色 → Edge-TTS 真实音色映射 const EDGE_VOICE_MAP: Record = { voice_01: 'zh-CN-XiaoxiaoNeural', // 温柔女声 → 晓晓 voice_02: 'zh-CN-YunxiNeural', // 磁性男声 → 云希 voice_03: 'zh-CN-XiaoyiNeural', // 活泼女声 → 晓依 voice_04: 'zh-CN-YunyangNeural', // 知性女声 → 云扬(新闻风格) voice_05: 'zh-CN-YunjianNeural', // 阳光男声 → 云健 voice_06: 'zh-CN-YunyangNeural', // 沧桑男声 → 云扬 voice_07: 'zh-CN-XiaoxiaoNeural', // 甜美女声 → 晓晓 voice_08: 'zh-CN-YunxiNeural', // 清朗男声 → 云希 voice_09: 'zh-CN-XiaoyiNeural', // 亲切女声 → 晓依 voice_10: 'zh-CN-XiaoshuangNeural', // 稚嫩童声 → 晓双(童声) }; function mapToProviderVoice(unifiedVoiceId: string, providerVendor: string): string { // Edge-TTS: 使用微软免费音色 if (providerVendor === 'edge') {

                  const mapped = EDGE_VOICE_MAP[unifiedVoiceId];
                  if (mapped) return mapped;
                  console.warn(`⚠️ [VoiceMap] Edge 未识别的音色ID: "${unifiedVoiceId}",降级使用默认音色`);
                  return 'zh-CN-XiaoxiaoNeural';
                  

                  } // 默认: 阿里云百炼 CosyVoice const mapped = ALIYUN_VOICE_MAP[unifiedVoiceId]; if (mapped) return mapped; // 不在映射表中(如遗留的 'cherry' 等旧 MiniMax 音色)→ 用 CosyVoice 默认音色 console.warn(⚠️ [VoiceMap] 未识别的音色ID: "${unifiedVoiceId}",降级使用默认音色 longyingling_v3); return 'longyingling_v3'; } // ============ Aliyun Instruct 情感/场景控制 ============ // 后期优化功能,暂不启用。启用时改为 true const INSTRUCT_ENABLED = false; interface EmotionScene { emotion: string; // neutral | fearful | angry | sad | surprised | happy | disgusted scene: string; // 闲聊互动 | 新闻播报 | 广告促销 | 比赛解说 | 一些儿童内容解说 | 语音导航 | 脱口秀表演 } // 情感关键词库 const EMOTION_KEYWORDS: { emotion: string; keywords: string[] }[] = [ { emotion: 'fearful', keywords: ['恐怖', '可怕', '惊悚', '恐惧', '阴森', '黑暗', '鬼', '死亡', '谋杀', '悬疑', '危险', '深渊', '噩梦'] }, { emotion: 'sad', keywords: ['悲伤', '难过', '哭泣', '眼泪', '心痛', '遗憾', '孤独', '寂寞', '失落', '离别', '思念', '哀伤', '去世', '失去'] }, { emotion: 'angry', keywords: ['愤怒', '生气', '怒火', '仇恨', '战斗', '厮杀', '复仇', '战争', '侵略', '暴怒'] }, { emotion: 'happy', keywords: ['快乐', '开心', '幸福', '欢笑', '庆祝', '美好', '甜蜜', '温暖', '阳光', '喜悦', '高兴', '浪漫', '恋爱', '美好'] }, { emotion: 'surprised', keywords: ['惊奇', '惊喜', '意外', '奇迹', '神奇', '魔法', '童话', '幻想', '奇妙'] }, { emotion: 'disgusted', keywords: ['恶心', '厌恶', '肮脏', '丑陋', '卑鄙'] }, ];

                  // ═══════════════════════ // 文件: server/src/modules/player/player.controller.ts // ═══════════════════════ import Router from '@koa/router'; import { Context } from 'koa'; import * as PlayerService from './player.service'; import { BadRequestError } from '../../middleware/errorHandler'; import { optionalAuth } from '../../middleware/auth'; import { prisma } from '../../models'; // 测试用户ID(开发环境使用) const TEST_USER_ID = '1'; const router = new Router(); // 获取播放进度列表 router.get('/progress', optionalAuth, async (ctx: Context) => { // 开发环境使用测试用户ID const userId = ctx.state.user?.userId || TEST_USER_ID; const { audioId } = ctx.query as { audioId?: string }; const records = await PlayerService.getPlayProgress(

                  userId,
                  audioId ? parseInt(audioId) : undefined
                  

                  ); ctx.body = {

                  code: 0,
                  message: 'success',
                  data: records,
                  

                  }; }); // 保存播放进度 router.post('/progress', optionalAuth, async (ctx: Context) => { // 开发环境使用测试用户ID const userId = ctx.state.user?.userId || TEST_USER_ID; const body = ctx.request.body as {

                  audioId: number;
                  progress: number;
                  duration: number;
                  

                  }; const { audioId, progress, duration } = body; if (!audioId || typeof progress !== 'number' || typeof duration !== 'number') {

                  throw new BadRequestError('参数错误');
                  

                  } const record = await PlayerService.savePlayProgress(userId, audioId, progress, duration); ctx.body = {

                  code: 0,
                  message: 'success',
                  data: record,
                  

                  }; }); // 更新播放进度 (在 DELETE 路由之前定义) router.put('/progress/:audioId', optionalAuth, async (ctx: Context) => { // 开发环境使用测试用户ID const userId = ctx.state.user?.userId || TEST_USER_ID; const audioId = parseInt(ctx.params.audioId as string); const body = ctx.request.body as { progress: number; duration?: number }; const { progress, duration } = body; if (typeof progress !== 'number') {

                  throw new BadRequestError('进度参数错误');
                  

                  } const record = await PlayerService.updatePlayProgress(userId, audioId, progress, duration); ctx.body = {

                  code: 0,
                  message: 'success',
                  data: record,
                  

                  }; }); // 删除播放记录 router.delete('/progress/:audioId', optionalAuth, async (ctx: Context) => { // 开发环境使用测试用户ID const userId = ctx.state.user?.userId || TEST_USER_ID; const audioId = parseInt(ctx.params.audioId as string); await PlayerService.deletePlayRecord(userId, audioId); ctx.body = {

                  code: 0,
                  message: '删除成功',
                  

                  }; }); // 批量删除播放记录 router.delete('/progress/batch', optionalAuth, async (ctx: Context) => { const userId = ctx.state.user?.userId || TEST_USER_ID; const { audioIds } = ctx.request.body as { audioIds: number[] }; if (!audioIds || !Array.isArray(audioIds)) {

                  throw new BadRequestError('参数错误');
                  

                  } for (const audioId of audioIds) {

                  // ═══════════════════════ // 文件: server/src/modules/member/member.controller.ts // ═══════════════════════ import Router from '@koa/router'; import { Context } from 'koa'; import * as MemberService from './member.service'; import { BadRequestError, NotFoundError } from '../../middleware/errorHandler'; import { authMiddleware } from '../../middleware/auth'; const router = new Router(); // 获取会员权益信息 router.get('/benefits', async (ctx: Context) => { const benefits = MemberService.getMemberBenefits(); ctx.body = {

                  code: 0,
                  message: 'success',
                  data: benefits,
                  

                  }; }); // 获取用户会员信息(兼容前端 /api/member/info) router.get('/info', authMiddleware, async (ctx: Context) => { const userId = ctx.state.user.userId; const status = await MemberService.getMemberStatus(userId); ctx.body = {

                  code: 0,
                  message: 'success',
                  data: status,
                  

                  }; }); // 获取用户会员状态 router.get('/status', authMiddleware, async (ctx: Context) => { const userId = ctx.state.user.userId; const status = await MemberService.getMemberStatus(userId); ctx.body = {

                  code: 0,
                  message: 'success',
                  data: status,
                  

                  }; }); // 创建订单 router.post('/order', authMiddleware, async (ctx: Context) => { const userId = ctx.state.user.userId; const { productType } = ctx.request.body as { productType: 'monthly' | 'yearly' }; if (!['monthly', 'yearly'].includes(productType)) {

                  throw new BadRequestError('无效的产品类型');
                  

                  } const result = await MemberService.createOrder(userId, productType); ctx.body = {

                  code: 0,
                  message: '订单创建成功',
                  data: result,
                  

                  }; }); // 模拟支付(仅开发环境) router.post('/pay/mock', authMiddleware, async (ctx: Context) => { if (process.env.NODE_ENV === 'production') {

                  throw new BadRequestError('生产环境不可用');
                  

                  } const userId = ctx.state.user.userId; const { orderNo } = ctx.request.body as { orderNo: string }; if (!orderNo) {

                  throw new BadRequestError('订单号不能为空');
                  

                  } const result = await MemberService.mockPaymentSuccess(orderNo, userId); ctx.body = {

                  code: 0,
                  message: '支付成功',
                  data: result,
                  

                  }; }); // 获取订单列表 router.get('/orders', authMiddleware, async (ctx: Context) => { const userId = ctx.state.user.userId; const { page = 1, pageSize = 10 } = ctx.query as { page?: string; pageSize?: string }; const result = await MemberService.getOrders(

                  userId,
                  Number(page) || 1,
                  Number(pageSize) || 10
                  

                  ); ctx.body = {

                  code: 0,
                  message: 'success',
                  data: result,
                  

                  };

                  // ═══════════════════════ // 文件: server/src/modules/subscription/subscription.controller.ts // ═══════════════════════ import Router from '@koa/router'; import { Context } from 'koa'; import * as SubscriptionService from './subscription.service'; import { BadRequestError } from '../../middleware/errorHandler'; import { authMiddleware, optionalAuth } from '../../middleware/auth'; import { prisma } from '../../models'; const router = new Router(); // 获取所有套餐列表 router.get('/plans', async (ctx: Context) => { const plans = await SubscriptionService.getPlans(); ctx.body = {

                  code: 0,
                  message: 'success',
                  data: { plans }
                  

                  }; }); // 获取单个套餐详情 router.get('/plans/:id', async (ctx: Context) => { const planId = parseInt(ctx.params.id); const plan = await SubscriptionService.getPlanById(planId); ctx.body = {

                  code: 0,
                  message: 'success',
                  data: { plan }
                  

                  }; }); // 获取用户订阅信息 router.get('/subscription', authMiddleware, async (ctx: Context) => { const userId = parseInt(ctx.state.user.userId); const subscription = await SubscriptionService.getUserSubscription(userId); ctx.body = {

                  code: 0,
                  message: 'success',
                  data: { subscription }
                  

                  }; }); // 获取用户Token余额 router.get('/balance', authMiddleware, async (ctx: Context) => { const userId = parseInt(ctx.state.user.userId); const balance = await SubscriptionService.getUserTokenBalance(userId); ctx.body = {

                  code: 0,
                  message: 'success',
                  data: balance
                  

                  }; }); // 获取Token使用记录 router.get('/usage', authMiddleware, async (ctx: Context) => { const userId = parseInt(ctx.state.user.userId); const { page = '1', pageSize = '20' } = ctx.query as { page?: string; pageSize?: string }; const result = await SubscriptionService.getTokenUsageList(

                  userId,
                  Number(page) || 1,
                  Number(pageSize) || 20
                  

                  ); ctx.body = {

                  code: 0,
                  message: 'success',
                  data: result
                  

                  }; }); // 获取用户配额(兼容旧接口) router.get('/quota', authMiddleware, async (ctx: Context) => { const userId = parseInt(ctx.state.user.userId); const quota = await SubscriptionService.getUserQuota(userId); ctx.body = {

                  code: 0,
                  message: 'success',
                  data: quota
                  

                  }; }); // 检查配额 router.post('/check-quota', authMiddleware, async (ctx: Context) => { const userId = parseInt(ctx.state.user.userId); const { tokens } = ctx.request.body as { tokens: number }; if (!tokens || tokens <= 0) {

                  throw new BadRequestError('请提供正确的Token数量');
                  

                  } const result = await SubscriptionService.checkQuota(userId, tokens); ctx.body = {

                  // ═══════════════════════ // 文件: server/src/modules/payment/payment.controller.ts // ═══════════════════════ import Router from '@koa/router'; import { Context } from 'koa'; import * as PaymentService from './payment.service'; import { BadRequestError } from '../../middleware/errorHandler'; import { authMiddleware } from '../../middleware/auth'; import { prisma } from '../../models'; import { safeParseInt } from '../../utils/safe-parse'; const router = new Router(); // 创建支付订单 router.post('/create', authMiddleware, async (ctx: Context) => { const userId = safeParseInt(ctx.state.user.userId); const { planId, paymentMethod, period = 'monthly', returnUrl } = ctx.request.body as {

                  planId: number;
                  paymentMethod: 'alipay' | 'wechat' | 'mock';
                  period?: 'monthly' | 'yearly';
                  returnUrl?: string;
                  

                  }; if (!planId) {

                  throw new BadRequestError('请选择套餐');
                  

                  } if (!['alipay', 'wechat', 'mock'].includes(paymentMethod)) {

                  throw new BadRequestError('请选择支付方式');
                  

                  } const result = await PaymentService.createPaymentOrder(userId, planId, paymentMethod, period, returnUrl); ctx.body = {

                  code: 0,
                  message: '订单创建成功',
                  data: result
                  

                  }; }); // 创建 Token 包支付订单 router.post('/token-packs/create', authMiddleware, async (ctx: Context) => { const userId = safeParseInt(ctx.state.user.userId); const { quantity, paymentMethod, returnUrl } = ctx.request.body as {

                  quantity: number;
                  paymentMethod: 'alipay' | 'wechat' | 'mock';
                  returnUrl?: string;
                  

                  }; if (!quantity || quantity < 1) {

                  throw new BadRequestError('购买数量至少为1');
                  

                  } if (!['alipay', 'wechat', 'mock'].includes(paymentMethod)) {

                  throw new BadRequestError('请选择支付方式');
                  

                  } const result = await PaymentService.createTokenPackOrder(userId, quantity, paymentMethod, returnUrl); ctx.body = {

                  code: 0,
                  message: '订单创建成功',
                  data: result
                  

                  }; }); // 模拟支付(仅开发环境) router.post('/mock', authMiddleware, async (ctx: Context) => { if (process.env.NODE_ENV === 'production') {

                  throw new BadRequestError('生产环境不可用');
                  

                  } const userId = safeParseInt(ctx.state.user.userId); const { orderNo } = ctx.request.body as { orderNo: string }; if (!orderNo) {

                  throw new BadRequestError('订单号不能为空');
                  

                  } const result = await PaymentService.mockPaymentSuccess(orderNo, userId); ctx.body = {

                  code: 0,
                  message: result.message,
                  data: result
                  

                  }; }); // 支付宝异步通知回调 router.post('/alipay/notify', async (ctx: Context) => { const params = ctx.request.body as Record; console.log('[Alipay Notify] 收到异步通知:', params); // 验证签名 const signVerified = PaymentService.verifyAlipaySign(params); if (!signVerified) {

                  console.error('[Alipay Notify] 签名验证失败');
                  ctx.status = 400;
                  ctx.body = 'fail';
                  return;
                  

                  }

                  // ═══════════════════════ // 文件: server/src/modules/favorites/favorites.controller.ts // ═══════════════════════ import Router from '@koa/router'; import { Context } from 'koa'; import * as FavoritesService from './favorites.service'; import { BadRequestError } from '../../middleware/errorHandler'; import { optionalAuth } from '../../middleware/auth'; // 测试用户ID(开发环境使用) const TEST_USER_ID = '1'; const router = new Router(); // 获取收藏列表(兼容前端 /api/favorites/list) router.get('/list', optionalAuth, async (ctx: Context) => { // 开发环境使用测试用户ID const userId = ctx.state.user?.userId || TEST_USER_ID; const favorites = await FavoritesService.getFavorites(userId); ctx.body = {

                  code: 0,
                  message: 'success',
                  data: favorites,
                  

                  }; }); // 获取收藏列表 router.get('/', optionalAuth, async (ctx: Context) => { // 开发环境使用测试用户ID const userId = ctx.state.user?.userId || TEST_USER_ID; const favorites = await FavoritesService.getFavorites(userId); ctx.body = {

                  code: 0,
                  message: 'success',
                  data: favorites,
                  

                  }; }); // 添加收藏 router.post('/', optionalAuth, async (ctx: Context) => { // 开发环境使用测试用户ID const userId = ctx.state.user?.userId || TEST_USER_ID; const body = ctx.request.body as { audioId: number }; const { audioId } = body; if (!audioId) {

                  throw new BadRequestError('音频ID不能为空');
                  

                  } const favorite = await FavoritesService.addFavorite(userId, audioId); ctx.body = {

                  code: 0,
                  message: '收藏成功',
                  data: favorite,
                  

                  }; }); // 取消收藏 router.delete('/:audioId', optionalAuth, async (ctx: Context) => { // 开发环境使用测试用户ID const userId = ctx.state.user?.userId || TEST_USER_ID; const audioId = parseInt(ctx.params.audioId as string); await FavoritesService.removeFavorite(userId, audioId); ctx.body = {

                  code: 0,
                  message: '已取消收藏',
                  

                  }; }); // 检查是否已收藏 router.get('/check/:audioId', optionalAuth, async (ctx: Context) => { // 开发环境使用测试用户ID const userId = ctx.state.user?.userId || TEST_USER_ID; const audioId = parseInt(ctx.params.audioId as string); const isFavorited = await FavoritesService.isFavorited(userId, audioId); ctx.body = {

                  code: 0,
                  message: 'success',
                  data: { isFavorited },
                  

                  }; }); export default router;

                  // ═══════════════════════ // 文件: server/src/modules/history/history.controller.ts // ═══════════════════════ import Router from '@koa/router'; import { Context } from 'koa'; import { optionalAuth } from '../../middleware/auth'; import { prisma } from '../../models'; const TEST_USER_ID = '1'; const router = new Router(); // 获取音频生成历史列表(兼容前端 /api/history/list) router.get('/list', optionalAuth, async (ctx: Context) => { // 开发环境使用测试用户ID const userId = ctx.state.user?.userId || TEST_USER_ID; const page = parseInt(ctx.query.page as string) || 1; const pageSize = parseInt(ctx.query.pageSize as string) || 20; const startDate = ctx.query.startDate as string; const where: any = {}; if (startDate) {

                  where.createdAt = { gte: new Date(startDate) };
                  

                  } const [records, total] = await Promise.all([

                  prisma.audioRecord.findMany({
                    where,
                    orderBy: { createdAt: 'desc' },
                    skip: (page - 1) * pageSize,
                    take: pageSize,
                  }),
                  prisma.audioRecord.count({ where }),
                  

                  ]); const list = records.map((r) => ({

                  _id: r.audioId,
                  id: r.audioId,
                  title: r.title,
                  text: r.text || '',
                  summary: '',
                  tags: [],
                  audioUrl: r.audioUrl || '',
                  audioDuration: r.audioDuration,
                  audioSize: r.audioSize,
                  wordCount: r.wordCount,
                  voiceId: r.voiceId,
                  voiceParams: r.voiceParams ? JSON.parse(r.voiceParams) : { speed: 1, pitch: 0, volume: 50 },
                  status: r.status,
                  isFavorite: false,
                  bookId: r.bookId,
                  createdAt: r.createdAt.toISOString(),
                  updatedAt: r.updatedAt.toISOString(),
                  

                  })); ctx.body = {

                  code: 0,
                  message: 'success',
                  data: {
                    list,
                    total,
                    page,
                    pageSize,
                    totalPages: Math.ceil(total / pageSize),
                  },
                  

                  }; }); // 获取音频生成历史列表 router.get('/', optionalAuth, async (ctx: Context) => { // 开发环境使用测试用户ID const userId = ctx.state.user?.userId || TEST_USER_ID; const page = parseInt(ctx.query.page as string) || 1; const pageSize = parseInt(ctx.query.pageSize as string) || 20; const startDate = ctx.query.startDate as string; const where: any = {}; if (startDate) {

                  where.createdAt = { gte: new Date(startDate) };
                  

                  } const [records, total] = await Promise.all([

                  prisma.audioRecord.findMany({
                    where,
                    orderBy: { createdAt: 'desc' },
                    skip: (page - 1) * pageSize,
                    take: pageSize,
                  }),
                  prisma.audioRecord.count({ where }),
                  

                  ]); const list = records.map((r) => ({

                  _id: r.audioId,
                  id: r.audioId,
                  

                  // ═══════════════════════ // 文件: server/src/modules/search/search.controller.ts // ═══════════════════════ import Router from '@koa/router'; import { searchService } from './search.service'; const router = new Router(); /**

                  • 搜索音频
                  • GET /api/search?q=关键词 */ router.get('/', async (ctx) => { try { const { q, limit } = ctx.query as { q?: string; limit?: string }; if (!q || q.trim() === '') { ctx.body = { code: 0, message: 'success', data: [], }; return; } const results = await searchService.searchAudio(q, parseInt(limit || '20')); ctx.body = { code: 0, message: 'success', data: results, }; } catch (error: any) { ctx.body = { code: 400, message: error.message || '搜索失败', }; } }); /**
                  • 获取热门搜索词
                  • GET /api/search/hot */ router.get('/hot', async (ctx) => { try { const { limit } = ctx.query as { limit?: string }; const hotSearches = await searchService.getHotSearches(parseInt(limit || '10')); ctx.body = { code: 0, message: 'success', data: hotSearches, }; } catch (error: any) { ctx.body = { code: 400, message: error.message || '获取热门搜索失败', }; } }); /**
                  • 获取搜索历史
                  • GET /api/search/history?userId=1 */ router.get('/history', async (ctx) => { try { const { userId, limit } = ctx.query as { userId?: string; limit?: string }; const userIdNum = parseInt(userId || '0') || 0; const history = await searchService.getSearchHistory(userIdNum, parseInt(limit || '5')); ctx.body = { code: 0, message: 'success', data: history, }; } catch (error: any) { ctx.body = { code: 400, message: error.message || '获取搜索历史失败', }; } }); /**
                  • 保存搜索历史
                  • POST /api/search/history */ router.post('/history', async (ctx) => { try { const { userId, keyword } = ctx.request.body as { userId?: number; keyword?: string }; if (!keyword || keyword.trim() === '') {

                  // ═══════════════════════ // 文件: server/src/services/oss.service.ts // ═══════════════════════ import OSS from 'ali-oss'; import path from 'path'; import fs from 'fs'; interface OSSConfig { region: string; accessKeyId: string; accessKeySecret: string; bucket: string; endpoint?: string; } class OSSService { private client: OSS; private bucket: string; private cdnDomain?: string; constructor() {

                  const config: OSSConfig = {
                    region: process.env.OSS_REGION || 'oss-cn-hangzhou',
                    accessKeyId: process.env.OSS_ACCESS_KEY_ID || '',
                    accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET || '',
                    bucket: process.env.OSS_BUCKET_NAME || '',
                    endpoint: process.env.OSS_ENDPOINT || 'oss-cn-hangzhou.aliyuncs.com',
                  };
                  this.client = new OSS(config);
                  this.bucket = config.bucket;
                  this.cdnDomain = process.env.OSS_CDN_DOMAIN;
                  

                  } /**

                  • 上传文件到 OSS
                  • @param localPath 本地文件路径
                  • @param objectKey OSS 对象键(路径)
                  • @returns OSS 文件 URL */ async uploadFile(localPath: string, objectKey: string): Promise { try { // 确保 objectKey 格式正确 const normalizedKey = objectKey.replace(/\/g, '/').replace(/^\/+/, ''); const result = await this.client.put(normalizedKey, localPath, { headers: { 'Content-Type': this.getContentType(normalizedKey), }, }); console.log([OSS] 文件上传成功: ${normalizedKey}); // 返回文件 URL return this.getFileUrl(normalizedKey); } catch (error) { console.error('[OSS] 文件上传失败:', error); throw new Error(OSS 上传失败: ${(error as Error).message}); } } /**
                  • 上传 Buffer 到 OSS
                  • @param buffer 文件 Buffer
                  • @param objectKey OSS 对象键
                  • @param contentType 内容类型
                  • @returns OSS 文件 URL */ async uploadBuffer(buffer: Buffer, objectKey: string, contentType?: string): Promise { try { const normalizedKey = objectKey.replace(/\/g, '/').replace(/^\/+/, ''); const result = await this.client.put(normalizedKey, buffer, { headers: { 'Content-Type': contentType || this.getContentType(normalizedKey), }, }); console.log([OSS] Buffer 上传成功: ${normalizedKey}); return this.getFileUrl(normalizedKey); } catch (error) { console.error('[OSS] Buffer 上传失败:', error); throw new Error(OSS Buffer 上传失败: ${(error as Error).message}); } } /**
                  • 上传音频文件
                  • @param localPath 本地音频文件路径
                  • @param audioId 音频 ID
                  • @returns OSS 音频 URL */ async uploadAudio(localPath: string, audioId: string): Promise { const objectKey = audio/${audioId}/${path.basename(localPath)}; return this.uploadFile(localPath, objectKey);
                  • // ═══════════════════════ // 文件: server/src/services/queue.service.ts // ═══════════════════════ /**

                    • 队列服务
                    • 【架构职责】
                    • 队列只负责:排队 + 并发控制
                    • 【职责边界】
                    • - ✅ 做:任务排队、并发限制、任务分发
                    • - ❌ 不做:失败重试、超时管理、业务逻辑
                    • 【设计原则】
                    • - 单一职责:队列只管排队,不管其他
                    • - 失败重试 → 容错层(fault-tolerance.ts)
                    • - 超时管理 → AI服务层(llm.service.ts)
                    • - 业务逻辑 → 领域层(langgraph-generator.ts) */ import Queue from 'bull'; import { redisService } from './redis.service'; import { memoryQueue } from './memory-queue'; // 任务队列类型 export enum QueueType { AUDIO_GENERATION = 'audio:generation', VIDEO_GENERATION = 'video:generation', BOOK_GENERATION = 'book:generation', EMAIL_SEND = 'email:send', } // 任务状态 export enum TaskStatus { WAITING = 'waiting', ACTIVE = 'active', COMPLETED = 'completed', FAILED = 'failed', DELAYED = 'delayed', } // 任务数据接口 interface TaskData { userId?: number;

                    } // 任务进度回调 export type ProgressCallback = (progress: number, data?: any) => void; class QueueService { private queues: Map = new Map(); private progressCallbacks: Map = new Map(); private queueAvailable: boolean = true; constructor() {

                    // 如果 Redis 不可用,记录警告但不阻止程序运行
                    if (!redisService.isAvailable()) {
                      console.warn('[Queue] Redis 不可用,任务队列将无法正常工作');
                      this.queueAvailable = false;
                    }
                    

                    } /**

                    • 检查队列是否可用 */ isQueueAvailable(): boolean { return this.queueAvailable && redisService.isAvailable(); } /**
                    • 获取或创建队列
                    • @param queueName 队列名称 */ private getQueue(queueName: string): Queue.Queue | null { if (!this.isQueueAvailable()) { return null; } if (!this.queues.has(queueName)) { try { const queue = new Queue(queueName, { redis: { host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379'), password: process.env.REDIS_PASSWORD || undefined, db: parseInt(process.env.REDIS_DB || '0'), maxRetriesPerRequest: null, }, defaultJobOptions: { removeOnComplete: 100, removeOnFail: 50, },

                    // ═══════════════════════ // 文件: server/src/services/websocket.service.ts // ═══════════════════════ /**

                    • WebSocket 服务
                    • 用于推送音频/视频生成完成事件 */ import { Server as HttpServer } from 'http'; import WebSocket, { WebSocketServer } from 'ws'; const wss = new WebSocketServer({ noServer: true }); // 客户端连接管理 const clients = new Map(); // ============ 客户端管理 ============ /**
                    • 注册客户端连接 */ export function addClient(clientId: string, ws: WebSocket) { clients.set(clientId, ws); console.log([WS] 客户端连接: ${clientId}, 当前在线: ${clients.size}); } /**
                    • 移除客户端连接 */ export function removeClient(clientId: string) { clients.delete(clientId); console.log([WS] 客户端断开: ${clientId}, 当前在线: ${clients.size}); } /**
                    • 通过 clientId 发送消息 */ export function sendToClient(clientId: string, event: string, data: any): boolean { const ws = clients.get(clientId); if (!ws || ws.readyState !== WebSocket.OPEN) { return false; } try { ws.send(JSON.stringify({ event, data })); return true; } catch (error) { console.error([WS] 发送消息失败: ${clientId}, error); return false; } } /**
                    • 广播消息到所有客户端 */ export function broadcast(event: string, data: any) { const message = JSON.stringify({ event, data }); clients.forEach((ws, clientId) => { if (ws.readyState === WebSocket.OPEN) { try { ws.send(message); } catch (error) { console.error([WS] 广播失败: ${clientId}, error); } } }); } // ============ 事件推送 ============ /**
                    • 推送音频生成完成事件 */ export function pushAudioGenerationComplete(bookId: string, chapterId: number, status: 'completed' | 'failed') { const event = 'audio_generation_complete'; const data = { bookId, chapterId, status }; console.log([WS] 推送 ${event}:, data); // 广播给所有客户端(前端可根据 bookId 过滤) broadcast(event, data); } /**
                    • 推送视频生成完成事件 */ export function pushVideoGenerationComplete(bookId: string, chapterId: number, status: 'completed' | 'failed') { const event = 'video_generation_complete'; const data = { bookId, chapterId, status }; console.log([WS] 推送 ${event}:, data); broadcast(event, data); } /**
                    • 推送批量生成进度 */ export function pushBatchGenerationProgress(taskId: string, step: string, progress: number) { const event = 'batch_generation_progress';
                    • ##############################################################################

                      统计信息

                      ##############################################################################

                      前端文件数: 30

                      后端文件数: 30

                      文档总行数: 4751