subscription.service.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993
  1. import { prisma } from '../../models';
  2. import { MemberLevel } from '../../types';
  3. // ============================================
  4. // TTS服务商成本配置(2026-05-20 更新:修正为阿里百炼官方定价)
  5. // 原配置存在单位错误(千→应为百万/万),已全部修正
  6. // ============================================
  7. export const TTS_COST_CONFIG = {
  8. // TTS服务商配置(成本:¥/万字符,基于阿里百炼官方定价)
  9. providers: {
  10. // 免费方案
  11. iflytek_free: {
  12. name: '科大讯飞-基础版',
  13. costPer10kChars: 0, // 免费
  14. quality: 'standard',
  15. voiceOptions: 10,
  16. freeQuota: 100000,
  17. novelRating: 3
  18. },
  19. // 阿里百炼 CosyVoice 标准版(默认,¥1/万字符)
  20. aliyun_cosyvoice: {
  21. name: '阿里百炼 cosyvoice-v3-flash',
  22. costPer10kChars: 1.0, // ¥1/万字符(阿里官方定价)
  23. quality: 'standard',
  24. voiceOptions: 25,
  25. freeQuota: 10000, // 1万字符免费/90天
  26. novelRating: 4
  27. },
  28. // 阿里百炼 CosyVoice Plus(高质量)
  29. aliyun_cosyvoice_plus: {
  30. name: '阿里百炼 cosyvoice-v3-plus',
  31. costPer10kChars: 2.0, // ¥2/万字符
  32. quality: 'high',
  33. voiceOptions: 40,
  34. freeQuota: 10000,
  35. novelRating: 5
  36. },
  37. // 阿里百炼 TTS Flash(备选)
  38. aliyun_tts_flash: {
  39. name: '阿里百炼 qwen3-tts-instruct-flash',
  40. costPer10kChars: 0.8, // ¥0.8/万字符
  41. quality: 'standard',
  42. voiceOptions: 30,
  43. freeQuota: 10000,
  44. novelRating: 4
  45. }
  46. },
  47. // AI模型成本配置(¥/百万token,基于阿里百炼官方定价)
  48. aiModels: {
  49. qwen_plus: {
  50. name: '通义千问 Qwen-Plus',
  51. inputCostPerMToken: 0.8, // ¥0.8/百万token(阿里官方)
  52. outputCostPerMToken: 2.0, // ¥2.0/百万token(阿里官方,非思考模式)
  53. quality: 'standard',
  54. default: true
  55. },
  56. qwen3_flash: {
  57. name: '通义千问 Qwen3.5-Flash',
  58. inputCostPerMToken: 0.2, // ¥0.2/百万token
  59. outputCostPerMToken: 2.0, // ¥2.0/百万token
  60. quality: 'standard'
  61. },
  62. qwen3_plus: {
  63. name: '通义千问 Qwen3.6-Plus',
  64. inputCostPerMToken: 2.0, // ¥2/百万token
  65. outputCostPerMToken: 12.0, // ¥12/百万token
  66. quality: 'high'
  67. },
  68. deepseek_v3: {
  69. name: 'DeepSeek V3.2(阿里百炼)',
  70. inputCostPerMToken: 2.0, // ¥2/百万token
  71. outputCostPerMToken: 3.0, // ¥3/百万token
  72. quality: 'standard'
  73. }
  74. },
  75. // 各等级默认TTS方案
  76. planTTSProvider: {
  77. 0: 'iflytek_free', // 免费版:科大讯飞免费
  78. 1: 'aliyun_cosyvoice', // 入门版:CosyVoice标准版
  79. 2: 'aliyun_cosyvoice', // 专业版:CosyVoice标准版
  80. 3: 'aliyun_cosyvoice_plus' // 旗舰版:CosyVoice Plus
  81. },
  82. // 默认AI模型
  83. defaultAIModel: 'qwen_plus'
  84. };
  85. // ============================================
  86. // 定价系数(可运营调整)
  87. // ============================================
  88. export const PRICING_COEFFICIENT = 2.0; // 售价 = API成本 × 系数
  89. // ============================================
  90. // Token成本配置(AI生成成本 + TTS成本)
  91. // 2026-05-20 修正:基于阿里百炼官方定价,单位修正为百万token/万字符
  92. // ============================================
  93. export const TOKEN_COST_CONFIG = {
  94. // 基础成本(qwen-plus AI + cosyvoice-v3-flash TTS)
  95. // AI: 1.5万token/万字符 × ¥2/百万token输出 + ¥0.8/百万token输入 = ¥0.042/万字符
  96. // TTS: cosyvoice-v3-flash ¥1/万字符
  97. // 合计: ¥1.042/万字符
  98. baseCostPer10kChars: 1.042,
  99. // AI模型(¥/百万token,阿里百炼官方定价)
  100. models: {
  101. qwen_plus: {
  102. name: '通义千问 Qwen-Plus',
  103. inputCostPerMToken: 0.8, // ¥0.8/百万token(非思考模式)
  104. outputCostPerMToken: 2.0, // ¥2.0/百万token
  105. quality: 'standard',
  106. default: true
  107. },
  108. qwen3_flash: {
  109. name: '通义千问 Qwen3.5-Flash',
  110. inputCostPerMToken: 0.2, // ¥0.2/百万token
  111. outputCostPerMToken: 2.0, // ¥2.0/百万token
  112. quality: 'standard'
  113. },
  114. qwen3_plus: {
  115. name: '通义千问 Qwen3.6-Plus',
  116. inputCostPerMToken: 2.0, // ¥2/百万token
  117. outputCostPerMToken: 12.0, // ¥12/百万token
  118. quality: 'high'
  119. },
  120. deepseek_v3: {
  121. name: 'DeepSeek V3.2(阿里百炼)',
  122. inputCostPerMToken: 2.0, // ¥2/百万token
  123. outputCostPerMToken: 3.0, // ¥3/百万token
  124. quality: 'standard'
  125. }
  126. },
  127. // TTS成本(¥/万字符)
  128. ttsModels: {
  129. cosyvoice: {
  130. name: 'cosyvoice-v3-flash(标准版)',
  131. costPer10kChars: 1.0 // ¥1/万字符
  132. },
  133. cosyvoice_plus: {
  134. name: 'cosyvoice-v3-plus',
  135. costPer10kChars: 2.0 // ¥2/万字符
  136. },
  137. qwen3_tts_flash: {
  138. name: 'qwen3-tts-instruct-flash',
  139. costPer10kChars: 0.8 // ¥0.8/万字符
  140. }
  141. }
  142. };
  143. // 计算Token生成成本(基于阿里百炼实际定价)
  144. export function calculateTokenCost(
  145. textLength: number,
  146. aiModel: string = 'qwen_plus',
  147. ttsModel: string = 'cosyvoice'
  148. ): {
  149. aiInputCost: number;
  150. aiOutputCost: number;
  151. ttsCost: number;
  152. totalCost: number;
  153. suggestedPrice: number;
  154. } {
  155. const modelConfig = TOKEN_COST_CONFIG.models[aiModel] || TOKEN_COST_CONFIG.models.qwen_plus;
  156. const ttsConfig = TOKEN_COST_CONFIG.ttsModels[ttsModel] || TOKEN_COST_CONFIG.ttsModels.cosyvoice;
  157. // 估算token数:中文1字≈1.5token
  158. const estimatedTokens = textLength * 1.5;
  159. // AI输入成本(prompt约占输出的10%)
  160. const aiInputCost = (estimatedTokens * 0.1 / 1_000_000) * modelConfig.inputCostPerMToken;
  161. // AI输出成本
  162. const aiOutputCost = (estimatedTokens / 1_000_000) * modelConfig.outputCostPerMToken;
  163. // TTS合成成本
  164. const ttsCost = (textLength / 10_000) * ttsConfig.costPer10kChars;
  165. // 总API成本
  166. const totalCost = aiInputCost + aiOutputCost + ttsCost;
  167. // 建议售价 = 成本 × 定价系数
  168. const suggestedPrice = totalCost * PRICING_COEFFICIENT;
  169. return {
  170. aiInputCost: Math.round(aiInputCost * 10000) / 10000,
  171. aiOutputCost: Math.round(aiOutputCost * 10000) / 10000,
  172. ttsCost: Math.round(ttsCost * 10000) / 10000,
  173. totalCost: Math.round(totalCost * 10000) / 10000,
  174. suggestedPrice: Math.round(suggestedPrice * 100) / 100
  175. };
  176. }
  177. // 默认套餐配置(基于阿里百炼实际成本 × PRICING_COEFFICIENT)
  178. // 定价公式: priceMonthly = monthlyMinutes × AUDIO_BILLING_CONFIG.pricing.monthly
  179. // 成本: cosyvoice-v3-flash ¥1/万字符 + qwen-plus AI = ¥0.016/分钟, 系数: 2, 售价: ¥0.032/分钟
  180. // ⚠️ 实际按量计费,套餐只是包装展示
  181. export const DEFAULT_PLANS = [
  182. {
  183. name: '免费版',
  184. level: 0,
  185. priceMonthly: 0,
  186. priceYearly: 0,
  187. description: '适合轻度体验',
  188. features: JSON.stringify([
  189. '每月10分钟音频',
  190. '基础音色5种',
  191. '标准音质'
  192. ]),
  193. isRecommended: false,
  194. sortOrder: 0,
  195. dailyGenerations: 3,
  196. perGenerationLimit: 10000,
  197. monthlyTokens: 1500,
  198. monthlyMinutes: 10,
  199. yearlyTokens: null,
  200. voiceOptions: 5,
  201. audioQuality: 'standard',
  202. apiAccess: false,
  203. batchProcessing: false,
  204. teamManagement: false,
  205. overageEnabled: false,
  206. overagePrice: 0
  207. },
  208. {
  209. name: '入门版',
  210. level: 1,
  211. priceMonthly: 1,
  212. priceYearly: 10,
  213. description: '适合轻度使用',
  214. features: JSON.stringify([
  215. '每月30分钟音频',
  216. '全部音色',
  217. '高清音质',
  218. '超出¥0.048/分钟'
  219. ]),
  220. isRecommended: false,
  221. sortOrder: 1,
  222. dailyGenerations: 10,
  223. perGenerationLimit: 10000,
  224. monthlyTokens: 4500,
  225. monthlyMinutes: 30,
  226. yearlyTokens: null,
  227. voiceOptions: -1,
  228. audioQuality: 'high',
  229. apiAccess: false,
  230. batchProcessing: false,
  231. teamManagement: false,
  232. overageEnabled: true,
  233. overagePrice: 0.048
  234. },
  235. {
  236. name: '专业版',
  237. level: 2,
  238. priceMonthly: 3,
  239. priceYearly: 30,
  240. description: '适合内容创作者 ⭐推荐',
  241. features: JSON.stringify([
  242. '每月100分钟音频',
  243. '全部音色+定制音色',
  244. '高清音质',
  245. '超出¥0.042/分钟'
  246. ]),
  247. isRecommended: true,
  248. sortOrder: 2,
  249. dailyGenerations: -1,
  250. perGenerationLimit: 10000,
  251. monthlyTokens: 15000,
  252. monthlyMinutes: 100,
  253. yearlyTokens: null,
  254. voiceOptions: -1,
  255. audioQuality: 'high',
  256. apiAccess: false,
  257. batchProcessing: false,
  258. teamManagement: false,
  259. overageEnabled: true,
  260. overagePrice: 0.042
  261. },
  262. {
  263. name: '旗舰版',
  264. level: 3,
  265. priceMonthly: 13,
  266. priceYearly: 130,
  267. description: '适合重度使用',
  268. features: JSON.stringify([
  269. '每月400分钟音频',
  270. '全部音色+定制音色',
  271. '高清音质',
  272. 'VIP优先队列',
  273. '超出¥0.036/分钟'
  274. ]),
  275. isRecommended: false,
  276. sortOrder: 3,
  277. dailyGenerations: -1,
  278. perGenerationLimit: 10000,
  279. monthlyTokens: 60000,
  280. monthlyMinutes: 400,
  281. yearlyTokens: null,
  282. voiceOptions: -1,
  283. audioQuality: 'high',
  284. apiAccess: true,
  285. batchProcessing: false,
  286. teamManagement: false,
  287. overageEnabled: true,
  288. overagePrice: 0.036
  289. },
  290. {
  291. name: '企业版',
  292. level: 4,
  293. priceMonthly: 48,
  294. priceYearly: 480,
  295. description: '适合企业用户',
  296. features: JSON.stringify([
  297. '每月1500分钟音频',
  298. '全部功能',
  299. '专属技术支持',
  300. '批量处理',
  301. '团队管理',
  302. '超出¥0.032/分钟'
  303. ]),
  304. isRecommended: false,
  305. sortOrder: 4,
  306. dailyGenerations: -1,
  307. perGenerationLimit: 10000,
  308. monthlyTokens: 225000,
  309. monthlyMinutes: 1500,
  310. yearlyTokens: null,
  311. voiceOptions: -1,
  312. audioQuality: 'high',
  313. apiAccess: true,
  314. batchProcessing: true,
  315. teamManagement: true,
  316. overageEnabled: true,
  317. overagePrice: 0.032
  318. }
  319. ];
  320. // 初始化套餐数据
  321. export async function initializePlans() {
  322. const existingPlans = await prisma.subscriptionPlan.count();
  323. if (existingPlans === 0) {
  324. console.log('初始化订阅套餐数据...');
  325. for (const plan of DEFAULT_PLANS) {
  326. await prisma.subscriptionPlan.create({ data: plan });
  327. }
  328. console.log('订阅套餐初始化完成');
  329. }
  330. }
  331. // 获取所有套餐列表
  332. export async function getPlans() {
  333. const plans = await prisma.subscriptionPlan.findMany({
  334. where: { isActive: true },
  335. orderBy: { sortOrder: 'asc' }
  336. });
  337. return plans.map(plan => ({
  338. ...plan,
  339. priceMonthly: Number(plan.priceMonthly),
  340. priceYearly: Number(plan.priceYearly),
  341. features: JSON.parse(plan.features || '[]')
  342. }));
  343. }
  344. // 获取单个套餐详情
  345. export async function getPlanById(planId: number) {
  346. const plan = await prisma.subscriptionPlan.findUnique({
  347. where: { id: planId }
  348. });
  349. if (!plan) {
  350. throw new Error('套餐不存在');
  351. }
  352. return {
  353. ...plan,
  354. priceMonthly: Number(plan.priceMonthly),
  355. priceYearly: Number(plan.priceYearly),
  356. features: JSON.parse(plan.features || '[]')
  357. };
  358. }
  359. // 获取用户当前订阅信息
  360. export async function getUserSubscription(userId: number) {
  361. const subscription = await prisma.subscription.findFirst({
  362. where: {
  363. userId,
  364. status: 'active',
  365. endDate: { gt: new Date() }
  366. },
  367. include: {
  368. plan: true
  369. },
  370. orderBy: { endDate: 'desc' }
  371. });
  372. return subscription;
  373. }
  374. // 获取用户Token余额
  375. export async function getUserTokenBalance(userId: number) {
  376. let balance = await prisma.tokenBalance.findUnique({
  377. where: { userId }
  378. });
  379. if (!balance) {
  380. // 初始化Token余额
  381. balance = await prisma.tokenBalance.create({
  382. data: {
  383. userId,
  384. totalTokens: 10000, // 默认免费额度
  385. usedTokens: 0
  386. }
  387. });
  388. }
  389. const remaining = balance.totalTokens - balance.usedTokens;
  390. return {
  391. totalTokens: balance.totalTokens,
  392. usedTokens: balance.usedTokens,
  393. remainingTokens: remaining,
  394. resetDate: balance.resetDate,
  395. isUnlimited: balance.totalTokens === -1
  396. };
  397. }
  398. // 获取Token使用记录
  399. export async function getTokenUsageList(userId: number, page: number = 1, pageSize: number = 20) {
  400. const where = { userId };
  401. const total = await prisma.tokenUsage.count({ where });
  402. const list = await prisma.tokenUsage.findMany({
  403. where,
  404. orderBy: { createdAt: 'desc' },
  405. skip: (page - 1) * pageSize,
  406. take: pageSize
  407. });
  408. return {
  409. list,
  410. total,
  411. page,
  412. pageSize,
  413. totalPages: Math.ceil(total / pageSize)
  414. };
  415. }
  416. // 消耗Token
  417. export async function consumeToken(
  418. userId: number,
  419. amount: number,
  420. type: string,
  421. contentLength: number,
  422. description?: string
  423. ) {
  424. const balance = await prisma.tokenBalance.findUnique({
  425. where: { userId }
  426. });
  427. if (!balance) {
  428. throw new Error('用户Token余额不存在');
  429. }
  430. if (balance.totalTokens !== -1 && balance.usedTokens + amount > balance.totalTokens) {
  431. throw new Error('Token余额不足');
  432. }
  433. // 更新余额
  434. await prisma.tokenBalance.update({
  435. where: { userId },
  436. data: {
  437. usedTokens: { increment: amount }
  438. }
  439. });
  440. // 记录使用
  441. const usage = await prisma.tokenUsage.create({
  442. data: {
  443. userId,
  444. type,
  445. amount,
  446. contentLength,
  447. description
  448. }
  449. });
  450. return usage;
  451. }
  452. // 获取用户配额(兼容旧接口)
  453. export async function getUserQuota(userId: number) {
  454. const user = await prisma.user.findUnique({
  455. where: { id: userId }
  456. });
  457. if (!user) {
  458. throw new Error('用户不存在');
  459. }
  460. const subscription = await getUserSubscription(userId);
  461. const tokenBalance = await getUserTokenBalance(userId);
  462. const memberLevel = user.memberLevel as MemberLevel;
  463. const levelNames = ['免费版', '基础版', '专业版', '旗舰版'];
  464. return {
  465. level: memberLevel,
  466. levelName: levelNames[memberLevel] || '免费版',
  467. isValid: subscription !== null,
  468. expireAt: subscription?.endDate,
  469. plan: subscription?.plan ? {
  470. name: subscription.plan.name,
  471. features: JSON.parse(subscription.plan.features || '[]')
  472. } : null,
  473. tokenBalance,
  474. limits: subscription?.plan ? {
  475. dailyGenerations: subscription.plan.dailyGenerations,
  476. perGenerationLimit: subscription.plan.perGenerationLimit,
  477. monthlyTokens: subscription.plan.monthlyTokens,
  478. voiceOptions: subscription.plan.voiceOptions,
  479. audioQuality: subscription.plan.audioQuality
  480. } : {
  481. dailyGenerations: 3,
  482. perGenerationLimit: 2000,
  483. monthlyTokens: 10000,
  484. voiceOptions: 5,
  485. audioQuality: 'standard'
  486. }
  487. };
  488. }
  489. // 检查用户配额
  490. export async function checkQuota(userId: number, requiredTokens: number) {
  491. const quota = await getUserQuota(userId);
  492. const balance = quota.tokenBalance;
  493. if (balance.isUnlimited) {
  494. return { allowed: true, reason: null };
  495. }
  496. if (balance.remainingTokens < requiredTokens) {
  497. return {
  498. allowed: false,
  499. reason: 'Token余额不足',
  500. required: requiredTokens,
  501. remaining: balance.remainingTokens
  502. };
  503. }
  504. return { allowed: true, reason: null };
  505. }
  506. // ============================================
  507. // 音频时长计费系统(2026-05-20 修正:基于阿里百炼实际定价)
  508. // 成本 = AI文本(qwen-plus ¥2/百万token) + TTS(cosyvoice-v3-flash ¥1/万字符)
  509. // 每分钟(150字): AI≈¥0.0005 + TTS≈¥0.015 = ¥0.0155
  510. // 定价 = 成本 × PRICING_COEFFICIENT(系数可运营调整)
  511. // ============================================
  512. export const AUDIO_BILLING_CONFIG = {
  513. // 实际API成本:¥0.016/分钟(qwen-plus + cosyvoice-v3-flash)
  514. costPerMinute: 0.016,
  515. // 定价 = 成本 × 系数(系数可调整)
  516. // current: ¥0.016 × 2 = ¥0.032/分钟
  517. pricing: {
  518. monthly: 0.032, // 售价 = 成本 × 系数 = ¥0.032/分钟
  519. overage: 0.048 // 超额价 = 售价 × 1.5 = ¥0.048/分钟
  520. },
  521. // 语速配置(字/分钟)
  522. speakingRate: 150,
  523. // 各等级每月音频时长配额(分钟)
  524. monthlyMinutes: {
  525. 0: 10, // 免费版:10分钟/月
  526. 1: 30, // 入门版:30分钟/月
  527. 2: 100, // 专业版:100分钟/月
  528. 3: 400, // 旗舰版:400分钟/月
  529. 4: 1500 // 企业版:1500分钟/月
  530. },
  531. // 各等级超出配额单价(元/分钟),null=不支持超出
  532. // 均为售价的1.5倍
  533. overagePrice: {
  534. 0: null, // 免费版:不支持超出
  535. 1: 0.048, // 入门版:¥0.048/分钟
  536. 2: 0.042, // 专业版:¥0.042/分钟
  537. 3: 0.036, // 旗舰版:¥0.036/分钟
  538. 4: 0.032 // 企业版:¥0.032/分钟
  539. }
  540. };
  541. // 根据文本长度计算音频时长
  542. export function calculateAudioDuration(textLength: number): number {
  543. return Math.ceil(textLength / AUDIO_BILLING_CONFIG.speakingRate);
  544. }
  545. // 计算音频生成费用(支持包月配额和按量计费)
  546. export function calculateAudioCost(
  547. textLength: number,
  548. memberLevel: number = 0,
  549. usedMinutes: number = 0
  550. ): {
  551. textLength: number;
  552. audioMinutes: number;
  553. inQuotaMinutes: number;
  554. overageMinutes: number;
  555. inQuotaPrice: number; // 配额内价格
  556. overagePrice: number; // 超额价格
  557. totalPrice: number;
  558. estimatedCost: number;
  559. } {
  560. const audioMinutes = calculateAudioDuration(textLength);
  561. const monthlyMinutes = AUDIO_BILLING_CONFIG.monthlyMinutes[memberLevel] || 10;
  562. const overagePricePerMinute = AUDIO_BILLING_CONFIG.overagePrice[memberLevel];
  563. // 计算配额内和超额分钟数
  564. const remainingQuota = Math.max(0, monthlyMinutes - usedMinutes);
  565. const inQuotaMinutes = Math.min(audioMinutes, remainingQuota);
  566. const overageMinutes = Math.max(0, audioMinutes - inQuotaMinutes);
  567. // 配额内价格(包月批发价)
  568. const inQuotaPrice = inQuotaMinutes * AUDIO_BILLING_CONFIG.pricing.monthly;
  569. // 超额价格(按量零售价)
  570. const overagePrice = overageMinutes > 0 && overagePricePerMinute !== null
  571. ? overageMinutes * overagePricePerMinute
  572. : 0;
  573. const totalPrice = Math.round((inQuotaPrice + overagePrice) * 100) / 100;
  574. return {
  575. textLength,
  576. audioMinutes,
  577. inQuotaMinutes,
  578. overageMinutes,
  579. inQuotaPrice: Math.round(inQuotaPrice * 100) / 100,
  580. overagePrice: Math.round(overagePrice * 100) / 100,
  581. totalPrice,
  582. estimatedCost: audioMinutes * AUDIO_BILLING_CONFIG.costPerMinute
  583. };
  584. }
  585. // 获取用户音频时长余额
  586. export async function getUserAudioBalance(userId: number) {
  587. const user = await prisma.user.findUnique({
  588. where: { id: userId }
  589. });
  590. if (!user) {
  591. throw new Error('用户不存在');
  592. }
  593. const memberLevel = user.memberLevel as MemberLevel;
  594. // 测试账号:memberLevel = -1 表示无限额度
  595. if (memberLevel === -1) {
  596. return { memberLevel: -1, totalMinutes: -1, usedMinutes: 0, remainingMinutes: -1, overageEnabled: false, overagePrice: 0, resetDate: null };
  597. }
  598. const monthlyMinutes = AUDIO_BILLING_CONFIG.monthlyMinutes[memberLevel] || 10;
  599. const overageEnabled = AUDIO_BILLING_CONFIG.overagePrice[memberLevel] !== null;
  600. const overagePrice = AUDIO_BILLING_CONFIG.overagePrice[memberLevel];
  601. // 检查是否需要重置月度配额
  602. const now = new Date();
  603. const resetDate = user.subscriptionResetDate;
  604. let totalMinutes = monthlyMinutes;
  605. let usedMinutes = user.usedAudioMinutes || 0;
  606. // 每月1日重置配额
  607. if (resetDate) {
  608. const lastReset = new Date(resetDate);
  609. if (now.getMonth() !== lastReset.getMonth() || now.getFullYear() !== lastReset.getFullYear()) {
  610. totalMinutes = monthlyMinutes;
  611. usedMinutes = 0;
  612. await prisma.user.update({
  613. where: { id: userId },
  614. data: {
  615. usedAudioMinutes: 0,
  616. subscriptionResetDate: now
  617. }
  618. });
  619. }
  620. }
  621. return {
  622. memberLevel,
  623. totalMinutes,
  624. usedMinutes,
  625. remainingMinutes: totalMinutes - usedMinutes,
  626. overageEnabled,
  627. overagePrice: overagePrice || 0,
  628. resetDate: user.subscriptionResetDate
  629. };
  630. }
  631. // 检查音频生成配额
  632. // 检查音频生成配额(只检查月度总配额,无单次限制)
  633. export async function checkAudioQuota(
  634. userId: number,
  635. textLength: number
  636. ) {
  637. const audioMinutes = calculateAudioDuration(textLength);
  638. const balance = await getUserAudioBalance(userId);
  639. // 测试账号:无限额度
  640. if (balance.totalMinutes === -1) {
  641. return { allowed: true, reason: null, audioMinutes, remainingMinutes: -1, totalMinutes: -1 };
  642. }
  643. // 免费版不支持超出配额
  644. if (balance.remainingMinutes < audioMinutes && !balance.overageEnabled) {
  645. return {
  646. allowed: false,
  647. reason: `音频时长不足,本月配额已用完(${balance.totalMinutes}分钟)`,
  648. audioMinutes,
  649. remainingMinutes: balance.remainingMinutes,
  650. totalMinutes: balance.totalMinutes,
  651. overageEnabled: false
  652. };
  653. }
  654. return {
  655. allowed: true,
  656. reason: null,
  657. audioMinutes,
  658. inQuotaMinutes: Math.min(audioMinutes, balance.remainingMinutes),
  659. overageMinutes: Math.max(0, audioMinutes - balance.remainingMinutes),
  660. remainingMinutes: balance.remainingMinutes,
  661. totalMinutes: balance.totalMinutes,
  662. overageEnabled: balance.overageEnabled,
  663. overagePrice: balance.overagePrice
  664. };
  665. }
  666. // 扣除音频时长(生成完成后调用)
  667. export async function consumeAudioMinutes(
  668. userId: number,
  669. textLength: number,
  670. description?: string
  671. ) {
  672. const audioMinutes = calculateAudioDuration(textLength);
  673. const balance = await getUserAudioBalance(userId);
  674. const cost = calculateAudioCost(textLength, balance.memberLevel, balance.usedMinutes);
  675. // 更新用户已使用时长
  676. await prisma.user.update({
  677. where: { id: userId },
  678. data: {
  679. usedAudioMinutes: { increment: audioMinutes }
  680. }
  681. });
  682. // 记录使用日志
  683. const usage = await prisma.tokenUsage.create({
  684. data: {
  685. userId,
  686. type: 'audio_generation',
  687. amount: audioMinutes,
  688. contentLength: textLength,
  689. description: description || `${audioMinutes}分钟音频`
  690. }
  691. });
  692. return {
  693. usageId: usage.id,
  694. audioMinutes,
  695. textLength,
  696. inQuotaMinutes: cost.inQuotaMinutes,
  697. overageMinutes: cost.overageMinutes,
  698. inQuotaPrice: cost.inQuotaPrice,
  699. overagePrice: cost.overagePrice,
  700. totalPrice: cost.totalPrice
  701. };
  702. }
  703. // 获取音频生成预估(用于UI展示)
  704. export async function getAudioEstimate(
  705. userId: number,
  706. textLength: number
  707. ) {
  708. const balance = await getUserAudioBalance(userId);
  709. const cost = calculateAudioCost(textLength, balance.memberLevel, balance.usedMinutes);
  710. const quota = await checkAudioQuota(userId, textLength);
  711. return {
  712. // 预估信息
  713. textLength,
  714. audioMinutes: cost.audioMinutes,
  715. inQuotaMinutes: cost.inQuotaMinutes,
  716. overageMinutes: cost.overageMinutes,
  717. inQuotaPrice: cost.inQuotaPrice,
  718. overagePricePerMinute: cost.overagePrice,
  719. estimatedPrice: cost.totalPrice,
  720. estimatedCost: cost.estimatedCost,
  721. // 用户配额
  722. remainingMinutes: balance.remainingMinutes,
  723. totalMinutes: balance.totalMinutes,
  724. usedMinutes: balance.usedMinutes,
  725. // 计费策略
  726. overageEnabled: balance.overageEnabled,
  727. overagePrice: balance.overagePrice,
  728. // 是否允许生成
  729. canGenerate: quota.allowed,
  730. reason: quota.reason,
  731. // 显示文本
  732. displayText: quota.allowed
  733. ? cost.overageMinutes > 0
  734. ? `预计${cost.audioMinutes}分钟(配额${cost.inQuotaMinutes}分钟+超出${cost.overageMinutes}分钟),约¥${cost.totalPrice}`
  735. : `预计${cost.audioMinutes}分钟,约¥${cost.totalPrice}`
  736. : quota.reason
  737. };
  738. }
  739. // ============================================
  740. // 书籍生成配额系统(字数预估 + 过程监控 + 中断保存)
  741. // ============================================
  742. import { estimateBookWords as estimateBookWordsFromConfig, getBookTypeConfig as getBookTypeConfigFromNew } from '../book-generator/book-type-config';
  743. // 根据书籍规模计算预估字数(使用统一配置)
  744. export function estimateBookWords(bookScale: string): { min: number; max: number; avg: number } {
  745. return estimateBookWordsFromConfig(bookScale);
  746. }
  747. // 根据预估字数计算预估音频时长
  748. export function estimateAudioMinutesFromWords(wordCount: number): number {
  749. return Math.ceil(wordCount / AUDIO_BILLING_CONFIG.speakingRate);
  750. }
  751. // 检查书籍生成的配额(生成前预估)
  752. export async function checkBookGenerationQuota(
  753. userId: number,
  754. bookScale: string
  755. ): Promise<{
  756. allowed: boolean;
  757. reason: string | null;
  758. // 预估信息
  759. estimatedWords: { min: number; max: number; avg: number };
  760. estimatedAudioMinutes: { min: number; max: number; avg: number };
  761. // 配额信息
  762. quota: {
  763. totalMinutes: number;
  764. usedMinutes: number;
  765. remainingMinutes: number;
  766. overageEnabled: boolean;
  767. overagePrice: number;
  768. };
  769. // 费用预估
  770. costEstimate: {
  771. inQuotaMinutes: number;
  772. overageMinutes: number;
  773. estimatedPrice: number;
  774. displayText: string;
  775. };
  776. }> {
  777. const wordEstimate = estimateBookWords(bookScale);
  778. const audioMinutes = estimateAudioMinutesFromWords(wordEstimate.avg);
  779. const balance = await getUserAudioBalance(userId);
  780. // 免费版且预估超出配额
  781. if (!balance.overageEnabled && balance.remainingMinutes < audioMinutes) {
  782. return {
  783. allowed: false,
  784. reason: `免费版不支持超出配额,请升级套餐或减少书籍规模`,
  785. estimatedWords: wordEstimate,
  786. estimatedAudioMinutes: {
  787. min: estimateAudioMinutesFromWords(wordEstimate.min),
  788. max: estimateAudioMinutesFromWords(wordEstimate.max),
  789. avg: audioMinutes
  790. },
  791. quota: {
  792. totalMinutes: balance.totalMinutes,
  793. usedMinutes: balance.usedMinutes,
  794. remainingMinutes: balance.remainingMinutes,
  795. overageEnabled: balance.overageEnabled,
  796. overagePrice: balance.overagePrice
  797. },
  798. costEstimate: {
  799. inQuotaMinutes: balance.remainingMinutes,
  800. overageMinutes: 0,
  801. estimatedPrice: 0,
  802. displayText: `预估需要${audioMinutes}分钟,超出您的配额${balance.remainingMinutes}分钟`
  803. }
  804. };
  805. }
  806. // 计算费用
  807. const inQuotaMinutes = Math.min(audioMinutes, balance.remainingMinutes);
  808. const overageMinutes = Math.max(0, audioMinutes - balance.remainingMinutes);
  809. const estimatedPrice = overageMinutes > 0 && balance.overagePrice
  810. ? overageMinutes * balance.overagePrice
  811. : 0;
  812. return {
  813. allowed: true,
  814. reason: null,
  815. estimatedWords: wordEstimate,
  816. estimatedAudioMinutes: {
  817. min: estimateAudioMinutesFromWords(wordEstimate.min),
  818. max: estimateAudioMinutesFromWords(wordEstimate.max),
  819. avg: audioMinutes
  820. },
  821. quota: {
  822. totalMinutes: balance.totalMinutes,
  823. usedMinutes: balance.usedMinutes,
  824. remainingMinutes: balance.remainingMinutes,
  825. overageEnabled: balance.overageEnabled,
  826. overagePrice: balance.overagePrice
  827. },
  828. costEstimate: {
  829. inQuotaMinutes,
  830. overageMinutes,
  831. estimatedPrice: Math.round(estimatedPrice * 100) / 100,
  832. displayText: overageMinutes > 0
  833. ? `预估${audioMinutes}分钟(配额${inQuotaMinutes}分钟+超出${overageMinutes}分钟),预计额外费用¥${estimatedPrice}`
  834. : `预估${audioMinutes}分钟,配额内免费`
  835. }
  836. };
  837. }
  838. // 检查当前额度是否足够生成指定字数(用于生成过程监控)
  839. export async function checkQuotaForWords(userId: number, wordCount: number): Promise<{
  840. sufficient: boolean;
  841. reason: string | null;
  842. currentWords: number;
  843. maxAllowedWords: number;
  844. audioMinutesNeeded: number;
  845. remainingMinutes: number;
  846. }> {
  847. const audioMinutesNeeded = estimateAudioMinutesFromWords(wordCount);
  848. const balance = await getUserAudioBalance(userId);
  849. if (!balance.overageEnabled && balance.remainingMinutes < audioMinutesNeeded) {
  850. return {
  851. sufficient: false,
  852. reason: `额度不足:需要${audioMinutesNeeded}分钟,您剩余${balance.remainingMinutes}分钟`,
  853. currentWords: wordCount,
  854. maxAllowedWords: balance.remainingMinutes * AUDIO_BILLING_CONFIG.speakingRate,
  855. audioMinutesNeeded,
  856. remainingMinutes: balance.remainingMinutes
  857. };
  858. }
  859. return {
  860. sufficient: true,
  861. reason: null,
  862. currentWords: wordCount,
  863. maxAllowedWords: balance.remainingMinutes * AUDIO_BILLING_CONFIG.speakingRate,
  864. audioMinutesNeeded,
  865. remainingMinutes: balance.remainingMinutes
  866. };
  867. }
  868. // 获取当前用户已生成的字数(用于断点续传)
  869. export async function getGeneratedWordCount(bookId: string): Promise<number> {
  870. const book = await prisma.book.findUnique({
  871. where: { id: parseInt(bookId) },
  872. include: { chapters: true }
  873. });
  874. if (!book) return 0;
  875. // 统计已完成章节的字数(genStage 含内容/音频/视频阶段都算已完成)
  876. const completedGenStages = ['content_completed', 'audio_completed', 'audio_generating', 'video_generating', 'video_completed'];
  877. const completedWords = book.chapters
  878. .filter(c => completedGenStages.includes(c.genStage))
  879. .reduce((sum, c) => sum + (c.wordCount || 0), 0);
  880. return completedWords;
  881. }
  882. // 记录生成中断状态(用于断点续传)
  883. export async function markGenerationInterrupted(bookId: string, lastChapterNumber: number, currentWords: number): Promise<void> {
  884. await prisma.book.update({
  885. where: { id: parseInt(bookId) },
  886. data: {
  887. genStage: 'failed',
  888. failedStage: 'content_generating',
  889. errorMsg: `额度不足中断,已生成${currentWords}字`,
  890. progress: 0 // 重置进度,下次生成时重新计算
  891. }
  892. });
  893. console.log(`[QuotaCheck] 书籍${bookId}生成中断:已完成${currentWords}字,最后章节${lastChapterNumber}`);
  894. }