import { prisma } from '../../models'; import { MemberLevel } from '../../types'; // ============================================ // TTS服务商成本配置(2026-05-20 更新:修正为阿里百炼官方定价) // 原配置存在单位错误(千→应为百万/万),已全部修正 // ============================================ export const TTS_COST_CONFIG = { // TTS服务商配置(成本:¥/万字符,基于阿里百炼官方定价) providers: { // 免费方案 iflytek_free: { name: '科大讯飞-基础版', costPer10kChars: 0, // 免费 quality: 'standard', voiceOptions: 10, freeQuota: 100000, novelRating: 3 }, // 阿里百炼 CosyVoice 标准版(默认,¥1/万字符) aliyun_cosyvoice: { name: '阿里百炼 cosyvoice-v3-flash', costPer10kChars: 1.0, // ¥1/万字符(阿里官方定价) quality: 'standard', voiceOptions: 25, freeQuota: 10000, // 1万字符免费/90天 novelRating: 4 }, // 阿里百炼 CosyVoice Plus(高质量) aliyun_cosyvoice_plus: { name: '阿里百炼 cosyvoice-v3-plus', costPer10kChars: 2.0, // ¥2/万字符 quality: 'high', voiceOptions: 40, freeQuota: 10000, novelRating: 5 }, // 阿里百炼 TTS Flash(备选) aliyun_tts_flash: { name: '阿里百炼 qwen3-tts-instruct-flash', costPer10kChars: 0.8, // ¥0.8/万字符 quality: 'standard', voiceOptions: 30, freeQuota: 10000, novelRating: 4 } }, // AI模型成本配置(¥/百万token,基于阿里百炼官方定价) aiModels: { qwen_plus: { name: '通义千问 Qwen-Plus', inputCostPerMToken: 0.8, // ¥0.8/百万token(阿里官方) outputCostPerMToken: 2.0, // ¥2.0/百万token(阿里官方,非思考模式) quality: 'standard', default: true }, qwen3_flash: { name: '通义千问 Qwen3.5-Flash', inputCostPerMToken: 0.2, // ¥0.2/百万token outputCostPerMToken: 2.0, // ¥2.0/百万token quality: 'standard' }, qwen3_plus: { name: '通义千问 Qwen3.6-Plus', inputCostPerMToken: 2.0, // ¥2/百万token outputCostPerMToken: 12.0, // ¥12/百万token quality: 'high' }, deepseek_v3: { name: 'DeepSeek V3.2(阿里百炼)', inputCostPerMToken: 2.0, // ¥2/百万token outputCostPerMToken: 3.0, // ¥3/百万token quality: 'standard' } }, // 各等级默认TTS方案 planTTSProvider: { 0: 'iflytek_free', // 免费版:科大讯飞免费 1: 'aliyun_cosyvoice', // 入门版:CosyVoice标准版 2: 'aliyun_cosyvoice', // 专业版:CosyVoice标准版 3: 'aliyun_cosyvoice_plus' // 旗舰版:CosyVoice Plus }, // 默认AI模型 defaultAIModel: 'qwen_plus' }; // ============================================ // 定价系数(可运营调整) // ============================================ export const PRICING_COEFFICIENT = 2.0; // 售价 = API成本 × 系数 // ============================================ // Token成本配置(AI生成成本 + TTS成本) // 2026-05-20 修正:基于阿里百炼官方定价,单位修正为百万token/万字符 // ============================================ export const TOKEN_COST_CONFIG = { // 基础成本(qwen-plus AI + cosyvoice-v3-flash TTS) // AI: 1.5万token/万字符 × ¥2/百万token输出 + ¥0.8/百万token输入 = ¥0.042/万字符 // TTS: cosyvoice-v3-flash ¥1/万字符 // 合计: ¥1.042/万字符 baseCostPer10kChars: 1.042, // AI模型(¥/百万token,阿里百炼官方定价) models: { qwen_plus: { name: '通义千问 Qwen-Plus', inputCostPerMToken: 0.8, // ¥0.8/百万token(非思考模式) outputCostPerMToken: 2.0, // ¥2.0/百万token quality: 'standard', default: true }, qwen3_flash: { name: '通义千问 Qwen3.5-Flash', inputCostPerMToken: 0.2, // ¥0.2/百万token outputCostPerMToken: 2.0, // ¥2.0/百万token quality: 'standard' }, qwen3_plus: { name: '通义千问 Qwen3.6-Plus', inputCostPerMToken: 2.0, // ¥2/百万token outputCostPerMToken: 12.0, // ¥12/百万token quality: 'high' }, deepseek_v3: { name: 'DeepSeek V3.2(阿里百炼)', inputCostPerMToken: 2.0, // ¥2/百万token outputCostPerMToken: 3.0, // ¥3/百万token quality: 'standard' } }, // TTS成本(¥/万字符) ttsModels: { cosyvoice: { name: 'cosyvoice-v3-flash(标准版)', costPer10kChars: 1.0 // ¥1/万字符 }, cosyvoice_plus: { name: 'cosyvoice-v3-plus', costPer10kChars: 2.0 // ¥2/万字符 }, qwen3_tts_flash: { name: 'qwen3-tts-instruct-flash', costPer10kChars: 0.8 // ¥0.8/万字符 } } }; // 计算Token生成成本(基于阿里百炼实际定价) export function calculateTokenCost( textLength: number, aiModel: string = 'qwen_plus', ttsModel: string = 'cosyvoice' ): { aiInputCost: number; aiOutputCost: number; ttsCost: number; totalCost: number; suggestedPrice: number; } { const modelConfig = TOKEN_COST_CONFIG.models[aiModel] || TOKEN_COST_CONFIG.models.qwen_plus; const ttsConfig = TOKEN_COST_CONFIG.ttsModels[ttsModel] || TOKEN_COST_CONFIG.ttsModels.cosyvoice; // 估算token数:中文1字≈1.5token const estimatedTokens = textLength * 1.5; // AI输入成本(prompt约占输出的10%) const aiInputCost = (estimatedTokens * 0.1 / 1_000_000) * modelConfig.inputCostPerMToken; // AI输出成本 const aiOutputCost = (estimatedTokens / 1_000_000) * modelConfig.outputCostPerMToken; // TTS合成成本 const ttsCost = (textLength / 10_000) * ttsConfig.costPer10kChars; // 总API成本 const totalCost = aiInputCost + aiOutputCost + ttsCost; // 建议售价 = 成本 × 定价系数 const suggestedPrice = totalCost * PRICING_COEFFICIENT; return { aiInputCost: Math.round(aiInputCost * 10000) / 10000, aiOutputCost: Math.round(aiOutputCost * 10000) / 10000, ttsCost: Math.round(ttsCost * 10000) / 10000, totalCost: Math.round(totalCost * 10000) / 10000, suggestedPrice: Math.round(suggestedPrice * 100) / 100 }; } // 默认套餐配置(基于阿里百炼实际成本 × PRICING_COEFFICIENT) // 定价公式: priceMonthly = monthlyMinutes × AUDIO_BILLING_CONFIG.pricing.monthly // 成本: cosyvoice-v3-flash ¥1/万字符 + qwen-plus AI = ¥0.016/分钟, 系数: 2, 售价: ¥0.032/分钟 // ⚠️ 实际按量计费,套餐只是包装展示 export const DEFAULT_PLANS = [ { name: '免费版', level: 0, priceMonthly: 0, priceYearly: 0, description: '适合轻度体验', features: JSON.stringify([ '每月10分钟音频', '基础音色5种', '标准音质' ]), isRecommended: false, sortOrder: 0, dailyGenerations: 3, perGenerationLimit: 10000, monthlyTokens: 1500, monthlyMinutes: 10, yearlyTokens: null, voiceOptions: 5, audioQuality: 'standard', apiAccess: false, batchProcessing: false, teamManagement: false, overageEnabled: false, overagePrice: 0 }, { name: '入门版', level: 1, priceMonthly: 1, priceYearly: 10, description: '适合轻度使用', features: JSON.stringify([ '每月30分钟音频', '全部音色', '高清音质', '超出¥0.048/分钟' ]), isRecommended: false, sortOrder: 1, dailyGenerations: 10, perGenerationLimit: 10000, monthlyTokens: 4500, monthlyMinutes: 30, yearlyTokens: null, voiceOptions: -1, audioQuality: 'high', apiAccess: false, batchProcessing: false, teamManagement: false, overageEnabled: true, overagePrice: 0.048 }, { name: '专业版', level: 2, priceMonthly: 3, priceYearly: 30, description: '适合内容创作者 ⭐推荐', features: JSON.stringify([ '每月100分钟音频', '全部音色+定制音色', '高清音质', '超出¥0.042/分钟' ]), isRecommended: true, sortOrder: 2, dailyGenerations: -1, perGenerationLimit: 10000, monthlyTokens: 15000, monthlyMinutes: 100, yearlyTokens: null, voiceOptions: -1, audioQuality: 'high', apiAccess: false, batchProcessing: false, teamManagement: false, overageEnabled: true, overagePrice: 0.042 }, { name: '旗舰版', level: 3, priceMonthly: 13, priceYearly: 130, description: '适合重度使用', features: JSON.stringify([ '每月400分钟音频', '全部音色+定制音色', '高清音质', 'VIP优先队列', '超出¥0.036/分钟' ]), isRecommended: false, sortOrder: 3, dailyGenerations: -1, perGenerationLimit: 10000, monthlyTokens: 60000, monthlyMinutes: 400, yearlyTokens: null, voiceOptions: -1, audioQuality: 'high', apiAccess: true, batchProcessing: false, teamManagement: false, overageEnabled: true, overagePrice: 0.036 }, { name: '企业版', level: 4, priceMonthly: 48, priceYearly: 480, description: '适合企业用户', features: JSON.stringify([ '每月1500分钟音频', '全部功能', '专属技术支持', '批量处理', '团队管理', '超出¥0.032/分钟' ]), isRecommended: false, sortOrder: 4, dailyGenerations: -1, perGenerationLimit: 10000, monthlyTokens: 225000, monthlyMinutes: 1500, yearlyTokens: null, voiceOptions: -1, audioQuality: 'high', apiAccess: true, batchProcessing: true, teamManagement: true, overageEnabled: true, overagePrice: 0.032 } ]; // 初始化套餐数据 export async function initializePlans() { const existingPlans = await prisma.subscriptionPlan.count(); if (existingPlans === 0) { console.log('初始化订阅套餐数据...'); for (const plan of DEFAULT_PLANS) { await prisma.subscriptionPlan.create({ data: plan }); } console.log('订阅套餐初始化完成'); } } // 获取所有套餐列表 export async function getPlans() { const plans = await prisma.subscriptionPlan.findMany({ where: { isActive: true }, orderBy: { sortOrder: 'asc' } }); return plans.map(plan => ({ ...plan, priceMonthly: Number(plan.priceMonthly), priceYearly: Number(plan.priceYearly), features: JSON.parse(plan.features || '[]') })); } // 获取单个套餐详情 export async function getPlanById(planId: number) { const plan = await prisma.subscriptionPlan.findUnique({ where: { id: planId } }); if (!plan) { throw new Error('套餐不存在'); } return { ...plan, priceMonthly: Number(plan.priceMonthly), priceYearly: Number(plan.priceYearly), features: JSON.parse(plan.features || '[]') }; } // 获取用户当前订阅信息 export async function getUserSubscription(userId: number) { const subscription = await prisma.subscription.findFirst({ where: { userId, status: 'active', endDate: { gt: new Date() } }, include: { plan: true }, orderBy: { endDate: 'desc' } }); return subscription; } // 获取用户Token余额 export async function getUserTokenBalance(userId: number) { let balance = await prisma.tokenBalance.findUnique({ where: { userId } }); if (!balance) { // 初始化Token余额 balance = await prisma.tokenBalance.create({ data: { userId, totalTokens: 10000, // 默认免费额度 usedTokens: 0 } }); } const remaining = balance.totalTokens - balance.usedTokens; return { totalTokens: balance.totalTokens, usedTokens: balance.usedTokens, remainingTokens: remaining, resetDate: balance.resetDate, isUnlimited: balance.totalTokens === -1 }; } // 获取Token使用记录 export async function getTokenUsageList(userId: number, page: number = 1, pageSize: number = 20) { const where = { userId }; const total = await prisma.tokenUsage.count({ where }); const list = await prisma.tokenUsage.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }); return { list, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }; } // 消耗Token export async function consumeToken( userId: number, amount: number, type: string, contentLength: number, description?: string ) { const balance = await prisma.tokenBalance.findUnique({ where: { userId } }); if (!balance) { throw new Error('用户Token余额不存在'); } if (balance.totalTokens !== -1 && balance.usedTokens + amount > balance.totalTokens) { throw new Error('Token余额不足'); } // 更新余额 await prisma.tokenBalance.update({ where: { userId }, data: { usedTokens: { increment: amount } } }); // 记录使用 const usage = await prisma.tokenUsage.create({ data: { userId, type, amount, contentLength, description } }); return usage; } // 获取用户配额(兼容旧接口) export async function getUserQuota(userId: number) { const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new Error('用户不存在'); } const subscription = await getUserSubscription(userId); const tokenBalance = await getUserTokenBalance(userId); const memberLevel = user.memberLevel as MemberLevel; const levelNames = ['免费版', '基础版', '专业版', '旗舰版']; return { level: memberLevel, levelName: levelNames[memberLevel] || '免费版', isValid: subscription !== null, expireAt: subscription?.endDate, plan: subscription?.plan ? { name: subscription.plan.name, features: JSON.parse(subscription.plan.features || '[]') } : null, tokenBalance, limits: subscription?.plan ? { dailyGenerations: subscription.plan.dailyGenerations, perGenerationLimit: subscription.plan.perGenerationLimit, monthlyTokens: subscription.plan.monthlyTokens, voiceOptions: subscription.plan.voiceOptions, audioQuality: subscription.plan.audioQuality } : { dailyGenerations: 3, perGenerationLimit: 2000, monthlyTokens: 10000, voiceOptions: 5, audioQuality: 'standard' } }; } // 检查用户配额 export async function checkQuota(userId: number, requiredTokens: number) { const quota = await getUserQuota(userId); const balance = quota.tokenBalance; if (balance.isUnlimited) { return { allowed: true, reason: null }; } if (balance.remainingTokens < requiredTokens) { return { allowed: false, reason: 'Token余额不足', required: requiredTokens, remaining: balance.remainingTokens }; } return { allowed: true, reason: null }; } // ============================================ // 音频时长计费系统(2026-05-20 修正:基于阿里百炼实际定价) // 成本 = AI文本(qwen-plus ¥2/百万token) + TTS(cosyvoice-v3-flash ¥1/万字符) // 每分钟(150字): AI≈¥0.0005 + TTS≈¥0.015 = ¥0.0155 // 定价 = 成本 × PRICING_COEFFICIENT(系数可运营调整) // ============================================ export const AUDIO_BILLING_CONFIG = { // 实际API成本:¥0.016/分钟(qwen-plus + cosyvoice-v3-flash) costPerMinute: 0.016, // 定价 = 成本 × 系数(系数可调整) // current: ¥0.016 × 2 = ¥0.032/分钟 pricing: { monthly: 0.032, // 售价 = 成本 × 系数 = ¥0.032/分钟 overage: 0.048 // 超额价 = 售价 × 1.5 = ¥0.048/分钟 }, // 语速配置(字/分钟) speakingRate: 150, // 各等级每月音频时长配额(分钟) monthlyMinutes: { 0: 10, // 免费版:10分钟/月 1: 30, // 入门版:30分钟/月 2: 100, // 专业版:100分钟/月 3: 400, // 旗舰版:400分钟/月 4: 1500 // 企业版:1500分钟/月 }, // 各等级超出配额单价(元/分钟),null=不支持超出 // 均为售价的1.5倍 overagePrice: { 0: null, // 免费版:不支持超出 1: 0.048, // 入门版:¥0.048/分钟 2: 0.042, // 专业版:¥0.042/分钟 3: 0.036, // 旗舰版:¥0.036/分钟 4: 0.032 // 企业版:¥0.032/分钟 } }; // 根据文本长度计算音频时长 export function calculateAudioDuration(textLength: number): number { return Math.ceil(textLength / AUDIO_BILLING_CONFIG.speakingRate); } // 计算音频生成费用(支持包月配额和按量计费) export function calculateAudioCost( textLength: number, memberLevel: number = 0, usedMinutes: number = 0 ): { textLength: number; audioMinutes: number; inQuotaMinutes: number; overageMinutes: number; inQuotaPrice: number; // 配额内价格 overagePrice: number; // 超额价格 totalPrice: number; estimatedCost: number; } { const audioMinutes = calculateAudioDuration(textLength); const monthlyMinutes = AUDIO_BILLING_CONFIG.monthlyMinutes[memberLevel] || 10; const overagePricePerMinute = AUDIO_BILLING_CONFIG.overagePrice[memberLevel]; // 计算配额内和超额分钟数 const remainingQuota = Math.max(0, monthlyMinutes - usedMinutes); const inQuotaMinutes = Math.min(audioMinutes, remainingQuota); const overageMinutes = Math.max(0, audioMinutes - inQuotaMinutes); // 配额内价格(包月批发价) const inQuotaPrice = inQuotaMinutes * AUDIO_BILLING_CONFIG.pricing.monthly; // 超额价格(按量零售价) const overagePrice = overageMinutes > 0 && overagePricePerMinute !== null ? overageMinutes * overagePricePerMinute : 0; const totalPrice = Math.round((inQuotaPrice + overagePrice) * 100) / 100; return { textLength, audioMinutes, inQuotaMinutes, overageMinutes, inQuotaPrice: Math.round(inQuotaPrice * 100) / 100, overagePrice: Math.round(overagePrice * 100) / 100, totalPrice, estimatedCost: audioMinutes * AUDIO_BILLING_CONFIG.costPerMinute }; } // 获取用户音频时长余额 export async function getUserAudioBalance(userId: number) { const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) { throw new Error('用户不存在'); } const memberLevel = user.memberLevel as MemberLevel; // 测试账号:memberLevel = -1 表示无限额度 if (memberLevel === -1) { return { memberLevel: -1, totalMinutes: -1, usedMinutes: 0, remainingMinutes: -1, overageEnabled: false, overagePrice: 0, resetDate: null }; } const monthlyMinutes = AUDIO_BILLING_CONFIG.monthlyMinutes[memberLevel] || 10; const overageEnabled = AUDIO_BILLING_CONFIG.overagePrice[memberLevel] !== null; const overagePrice = AUDIO_BILLING_CONFIG.overagePrice[memberLevel]; // 检查是否需要重置月度配额 const now = new Date(); const resetDate = user.subscriptionResetDate; let totalMinutes = monthlyMinutes; let usedMinutes = user.usedAudioMinutes || 0; // 每月1日重置配额 if (resetDate) { const lastReset = new Date(resetDate); if (now.getMonth() !== lastReset.getMonth() || now.getFullYear() !== lastReset.getFullYear()) { totalMinutes = monthlyMinutes; usedMinutes = 0; await prisma.user.update({ where: { id: userId }, data: { usedAudioMinutes: 0, subscriptionResetDate: now } }); } } return { memberLevel, totalMinutes, usedMinutes, remainingMinutes: totalMinutes - usedMinutes, overageEnabled, overagePrice: overagePrice || 0, resetDate: user.subscriptionResetDate }; } // 检查音频生成配额 // 检查音频生成配额(只检查月度总配额,无单次限制) export async function checkAudioQuota( userId: number, textLength: number ) { const audioMinutes = calculateAudioDuration(textLength); const balance = await getUserAudioBalance(userId); // 测试账号:无限额度 if (balance.totalMinutes === -1) { return { allowed: true, reason: null, audioMinutes, remainingMinutes: -1, totalMinutes: -1 }; } // 免费版不支持超出配额 if (balance.remainingMinutes < audioMinutes && !balance.overageEnabled) { return { allowed: false, reason: `音频时长不足,本月配额已用完(${balance.totalMinutes}分钟)`, audioMinutes, remainingMinutes: balance.remainingMinutes, totalMinutes: balance.totalMinutes, overageEnabled: false }; } return { allowed: true, reason: null, audioMinutes, inQuotaMinutes: Math.min(audioMinutes, balance.remainingMinutes), overageMinutes: Math.max(0, audioMinutes - balance.remainingMinutes), remainingMinutes: balance.remainingMinutes, totalMinutes: balance.totalMinutes, overageEnabled: balance.overageEnabled, overagePrice: balance.overagePrice }; } // 扣除音频时长(生成完成后调用) export async function consumeAudioMinutes( userId: number, textLength: number, description?: string ) { const audioMinutes = calculateAudioDuration(textLength); const balance = await getUserAudioBalance(userId); const cost = calculateAudioCost(textLength, balance.memberLevel, balance.usedMinutes); // 更新用户已使用时长 await prisma.user.update({ where: { id: userId }, data: { usedAudioMinutes: { increment: audioMinutes } } }); // 记录使用日志 const usage = await prisma.tokenUsage.create({ data: { userId, type: 'audio_generation', amount: audioMinutes, contentLength: textLength, description: description || `${audioMinutes}分钟音频` } }); return { usageId: usage.id, audioMinutes, textLength, inQuotaMinutes: cost.inQuotaMinutes, overageMinutes: cost.overageMinutes, inQuotaPrice: cost.inQuotaPrice, overagePrice: cost.overagePrice, totalPrice: cost.totalPrice }; } // 获取音频生成预估(用于UI展示) export async function getAudioEstimate( userId: number, textLength: number ) { const balance = await getUserAudioBalance(userId); const cost = calculateAudioCost(textLength, balance.memberLevel, balance.usedMinutes); const quota = await checkAudioQuota(userId, textLength); return { // 预估信息 textLength, audioMinutes: cost.audioMinutes, inQuotaMinutes: cost.inQuotaMinutes, overageMinutes: cost.overageMinutes, inQuotaPrice: cost.inQuotaPrice, overagePricePerMinute: cost.overagePrice, estimatedPrice: cost.totalPrice, estimatedCost: cost.estimatedCost, // 用户配额 remainingMinutes: balance.remainingMinutes, totalMinutes: balance.totalMinutes, usedMinutes: balance.usedMinutes, // 计费策略 overageEnabled: balance.overageEnabled, overagePrice: balance.overagePrice, // 是否允许生成 canGenerate: quota.allowed, reason: quota.reason, // 显示文本 displayText: quota.allowed ? cost.overageMinutes > 0 ? `预计${cost.audioMinutes}分钟(配额${cost.inQuotaMinutes}分钟+超出${cost.overageMinutes}分钟),约¥${cost.totalPrice}` : `预计${cost.audioMinutes}分钟,约¥${cost.totalPrice}` : quota.reason }; } // ============================================ // 书籍生成配额系统(字数预估 + 过程监控 + 中断保存) // ============================================ import { estimateBookWords as estimateBookWordsFromConfig, getBookTypeConfig as getBookTypeConfigFromNew } from '../book-generator/book-type-config'; // 根据书籍规模计算预估字数(使用统一配置) export function estimateBookWords(bookScale: string): { min: number; max: number; avg: number } { return estimateBookWordsFromConfig(bookScale); } // 根据预估字数计算预估音频时长 export function estimateAudioMinutesFromWords(wordCount: number): number { return Math.ceil(wordCount / AUDIO_BILLING_CONFIG.speakingRate); } // 检查书籍生成的配额(生成前预估) export async function checkBookGenerationQuota( userId: number, bookScale: string ): Promise<{ allowed: boolean; reason: string | null; // 预估信息 estimatedWords: { min: number; max: number; avg: number }; estimatedAudioMinutes: { min: number; max: number; avg: number }; // 配额信息 quota: { totalMinutes: number; usedMinutes: number; remainingMinutes: number; overageEnabled: boolean; overagePrice: number; }; // 费用预估 costEstimate: { inQuotaMinutes: number; overageMinutes: number; estimatedPrice: number; displayText: string; }; }> { const wordEstimate = estimateBookWords(bookScale); const audioMinutes = estimateAudioMinutesFromWords(wordEstimate.avg); const balance = await getUserAudioBalance(userId); // 免费版且预估超出配额 if (!balance.overageEnabled && balance.remainingMinutes < audioMinutes) { return { allowed: false, reason: `免费版不支持超出配额,请升级套餐或减少书籍规模`, estimatedWords: wordEstimate, estimatedAudioMinutes: { min: estimateAudioMinutesFromWords(wordEstimate.min), max: estimateAudioMinutesFromWords(wordEstimate.max), avg: audioMinutes }, quota: { totalMinutes: balance.totalMinutes, usedMinutes: balance.usedMinutes, remainingMinutes: balance.remainingMinutes, overageEnabled: balance.overageEnabled, overagePrice: balance.overagePrice }, costEstimate: { inQuotaMinutes: balance.remainingMinutes, overageMinutes: 0, estimatedPrice: 0, displayText: `预估需要${audioMinutes}分钟,超出您的配额${balance.remainingMinutes}分钟` } }; } // 计算费用 const inQuotaMinutes = Math.min(audioMinutes, balance.remainingMinutes); const overageMinutes = Math.max(0, audioMinutes - balance.remainingMinutes); const estimatedPrice = overageMinutes > 0 && balance.overagePrice ? overageMinutes * balance.overagePrice : 0; return { allowed: true, reason: null, estimatedWords: wordEstimate, estimatedAudioMinutes: { min: estimateAudioMinutesFromWords(wordEstimate.min), max: estimateAudioMinutesFromWords(wordEstimate.max), avg: audioMinutes }, quota: { totalMinutes: balance.totalMinutes, usedMinutes: balance.usedMinutes, remainingMinutes: balance.remainingMinutes, overageEnabled: balance.overageEnabled, overagePrice: balance.overagePrice }, costEstimate: { inQuotaMinutes, overageMinutes, estimatedPrice: Math.round(estimatedPrice * 100) / 100, displayText: overageMinutes > 0 ? `预估${audioMinutes}分钟(配额${inQuotaMinutes}分钟+超出${overageMinutes}分钟),预计额外费用¥${estimatedPrice}` : `预估${audioMinutes}分钟,配额内免费` } }; } // 检查当前额度是否足够生成指定字数(用于生成过程监控) export async function checkQuotaForWords(userId: number, wordCount: number): Promise<{ sufficient: boolean; reason: string | null; currentWords: number; maxAllowedWords: number; audioMinutesNeeded: number; remainingMinutes: number; }> { const audioMinutesNeeded = estimateAudioMinutesFromWords(wordCount); const balance = await getUserAudioBalance(userId); if (!balance.overageEnabled && balance.remainingMinutes < audioMinutesNeeded) { return { sufficient: false, reason: `额度不足:需要${audioMinutesNeeded}分钟,您剩余${balance.remainingMinutes}分钟`, currentWords: wordCount, maxAllowedWords: balance.remainingMinutes * AUDIO_BILLING_CONFIG.speakingRate, audioMinutesNeeded, remainingMinutes: balance.remainingMinutes }; } return { sufficient: true, reason: null, currentWords: wordCount, maxAllowedWords: balance.remainingMinutes * AUDIO_BILLING_CONFIG.speakingRate, audioMinutesNeeded, remainingMinutes: balance.remainingMinutes }; } // 获取当前用户已生成的字数(用于断点续传) export async function getGeneratedWordCount(bookId: string): Promise { const book = await prisma.book.findUnique({ where: { id: parseInt(bookId) }, include: { chapters: true } }); if (!book) return 0; // 统计已完成章节的字数(genStage 含内容/音频/视频阶段都算已完成) const completedGenStages = ['content_completed', 'audio_completed', 'audio_generating', 'video_generating', 'video_completed']; const completedWords = book.chapters .filter(c => completedGenStages.includes(c.genStage)) .reduce((sum, c) => sum + (c.wordCount || 0), 0); return completedWords; } // 记录生成中断状态(用于断点续传) export async function markGenerationInterrupted(bookId: string, lastChapterNumber: number, currentWords: number): Promise { await prisma.book.update({ where: { id: parseInt(bookId) }, data: { genStage: 'failed', failedStage: 'content_generating', errorMsg: `额度不足中断,已生成${currentWords}字`, progress: 0 // 重置进度,下次生成时重新计算 } }); console.log(`[QuotaCheck] 书籍${bookId}生成中断:已完成${currentWords}字,最后章节${lastChapterNumber}`); }