| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993 |
- 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<number> {
- 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<void> {
- await prisma.book.update({
- where: { id: parseInt(bookId) },
- data: {
- genStage: 'failed',
- failedStage: 'content_generating',
- errorMsg: `额度不足中断,已生成${currentWords}字`,
- progress: 0 // 重置进度,下次生成时重新计算
- }
- });
-
- console.log(`[QuotaCheck] 书籍${bookId}生成中断:已完成${currentWords}字,最后章节${lastChapterNumber}`);
- }
|