subscription.service.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. /**
  2. * Subscription Service 单元测试
  3. * 测试配额计算、扣除、边界情况
  4. *
  5. * 注意:2026-05-29 实施"统一定价中心重构"(BILLING 常量统一系数 2.0x 月价)。
  6. * 测试期望值已与 BILLING.coefficients.monthly=2.0 对齐:
  7. * monthlyPrice = costPerMinute(0.016) × monthly(2.0) = 0.032/分钟
  8. * overageBase = 0.032 × overage(1.5) = 0.048/分钟
  9. * 配额分钟数(monthlyMinutes):0=100, 1=300, 2=1000, 3=4000, 4=15000
  10. */
  11. import { describe, it, expect, beforeEach, vi } from 'vitest';
  12. import {
  13. calculateAudioDuration,
  14. calculateAudioCost,
  15. AUDIO_BILLING_CONFIG,
  16. TOKEN_COST_CONFIG,
  17. calculateTokenCost,
  18. BILLING,
  19. } from '@/modules/subscription/subscription.service';
  20. // Mock Prisma
  21. vi.mock('@/models', () => ({
  22. prisma: {
  23. user: {
  24. findUnique: vi.fn(),
  25. update: vi.fn(),
  26. },
  27. tokenUsage: {
  28. create: vi.fn(),
  29. count: vi.fn(),
  30. },
  31. subscriptionPlan: {
  32. findUnique: vi.fn(),
  33. findMany: vi.fn(),
  34. },
  35. subscription: {
  36. findFirst: vi.fn(),
  37. },
  38. tokenBalance: {
  39. findUnique: vi.fn(),
  40. create: vi.fn(),
  41. update: vi.fn(),
  42. },
  43. },
  44. }));
  45. describe('AUDIO_BILLING_CONFIG', () => {
  46. it('包含所有会员等级的配置', () => {
  47. expect(AUDIO_BILLING_CONFIG.monthlyMinutes).toHaveProperty('0'); // 免费版
  48. expect(AUDIO_BILLING_CONFIG.monthlyMinutes).toHaveProperty('1'); // 入门版
  49. expect(AUDIO_BILLING_CONFIG.monthlyMinutes).toHaveProperty('2'); // 专业版
  50. expect(AUDIO_BILLING_CONFIG.monthlyMinutes).toHaveProperty('3'); // 旗舰版
  51. expect(AUDIO_BILLING_CONFIG.monthlyMinutes).toHaveProperty('4'); // 企业版
  52. });
  53. it('免费版不支持超额', () => {
  54. expect(AUDIO_BILLING_CONFIG.overagePrice[0]).toBeNull();
  55. });
  56. it('其他等级支持超额', () => {
  57. // 实际值:入门版¥0.048, 专业版¥0.042, 旗舰版¥0.036, 企业版¥0.032
  58. expect(AUDIO_BILLING_CONFIG.overagePrice[1]).toBe(0.048);
  59. expect(AUDIO_BILLING_CONFIG.overagePrice[2]).toBe(0.042);
  60. expect(AUDIO_BILLING_CONFIG.overagePrice[3]).toBe(0.036);
  61. expect(AUDIO_BILLING_CONFIG.overagePrice[4]).toBe(0.032);
  62. });
  63. });
  64. describe('BILLING 统一定价中心', () => {
  65. it('月价系数为 2.0(成本 × 2)', () => {
  66. expect(BILLING.coefficients.monthly).toBe(2.0);
  67. expect(BILLING.monthlyPrice).toBeCloseTo(0.032, 3);
  68. });
  69. it('超额基础价 = 月价 × 1.5', () => {
  70. expect(BILLING.coefficients.overage).toBe(1.5);
  71. expect(BILLING.overageBasePrice).toBeCloseTo(0.048, 3);
  72. });
  73. });
  74. describe('calculateAudioDuration', () => {
  75. it('按语速 150 字/分钟 计算', () => {
  76. expect(calculateAudioDuration(150)).toBe(1); // 150字 = 1分钟
  77. expect(calculateAudioDuration(300)).toBe(2); // 300字 = 2分钟
  78. expect(calculateAudioDuration(450)).toBe(3); // 450字 = 3分钟
  79. });
  80. it('向上取整', () => {
  81. expect(calculateAudioDuration(1)).toBe(1); // 不足1分钟按1分钟计
  82. expect(calculateAudioDuration(151)).toBe(2); // 151字按2分钟计
  83. expect(calculateAudioDuration(299)).toBe(2); // 299字按2分钟计
  84. });
  85. it('大文本计算正确', () => {
  86. expect(calculateAudioDuration(1500)).toBe(10); // 1500字 = 10分钟
  87. expect(calculateAudioDuration(15000)).toBe(100); // 15000字 = 100分钟
  88. });
  89. });
  90. describe('calculateAudioCost', () => {
  91. // 实际统一定价:¥0.032/分钟(成本¥0.016 × 2)
  92. const PRICE_PER_MIN = 0.032;
  93. describe('正常计算', () => {
  94. it('免费用户 0 已用,1500字生成(10分钟,配额内)', () => {
  95. const result = calculateAudioCost(1500, 0, 0); // 1500字,10分钟音频
  96. expect(result.audioMinutes).toBe(10);
  97. expect(result.inQuotaMinutes).toBe(10);
  98. expect(result.overageMinutes).toBe(0);
  99. expect(result.inQuotaPrice).toBeCloseTo(10 * PRICE_PER_MIN, 4);
  100. expect(result.overagePrice).toBe(0);
  101. expect(result.totalPrice).toBeCloseTo(result.inQuotaPrice, 4);
  102. });
  103. it('专业版用户 15000字生成(100分钟,专业版 1000 分钟配额内)', () => {
  104. const result = calculateAudioCost(15000, 2, 0); // 15000字,100分钟
  105. expect(result.audioMinutes).toBe(100);
  106. // 专业版 1000 分钟配额,剩余 1000 分钟
  107. expect(result.inQuotaMinutes).toBe(100);
  108. expect(result.overageMinutes).toBe(0);
  109. expect(result.inQuotaPrice).toBeCloseTo(100 * PRICE_PER_MIN, 3);
  110. expect(result.totalPrice).toBeCloseTo(result.inQuotaPrice, 3);
  111. });
  112. });
  113. describe('超额计费', () => {
  114. it('入门版用户:6000字(40分钟),已用 20 分钟,仍在 300 分钟配额内', () => {
  115. // 入门版 300 分钟配额,已用 20 分钟,剩余 280 分钟
  116. // 40 分钟音频 远小于 280 分钟剩余,全部配额内
  117. const result = calculateAudioCost(6000, 1, 20);
  118. expect(result.audioMinutes).toBe(40);
  119. expect(result.inQuotaMinutes).toBe(40); // 全部配额内
  120. expect(result.overageMinutes).toBe(0); // 不超额
  121. // 配额内价格 = 40 分钟 × ¥0.032 = ¥1.28
  122. expect(result.inQuotaPrice).toBeCloseTo(1.28, 3);
  123. expect(result.overagePrice).toBe(0);
  124. expect(result.totalPrice).toBeCloseTo(1.28, 3);
  125. });
  126. it('专业版用户:12000字(80分钟),已用 950 分钟 → 超额', () => {
  127. // 专业版 1000 分钟配额,已用 950 分钟,剩余 50 分钟
  128. // 80 分钟音频 → 50 分钟配额内 + 30 分钟超额
  129. const result = calculateAudioCost(12000, 2, 950);
  130. expect(result.audioMinutes).toBe(80);
  131. expect(result.inQuotaMinutes).toBe(50); // 剩余50分钟配额
  132. expect(result.overageMinutes).toBe(30); // 超额30分钟
  133. // 配额内价格 = 50 分钟 × ¥0.032 = ¥1.6
  134. expect(result.inQuotaPrice).toBeCloseTo(1.6, 3);
  135. // 超额价格 = 30 分钟 × ¥0.042 = ¥1.26
  136. expect(result.overagePrice).toBeCloseTo(1.26, 3);
  137. expect(result.totalPrice).toBeCloseTo(2.86, 3); // 1.6 + 1.26
  138. });
  139. });
  140. describe('边界值', () => {
  141. it('刚好等于配额上限(免费版 100 分钟配额 → 100 分钟音频)', () => {
  142. const result = calculateAudioCost(15000, 0, 0); // 100 分钟音频
  143. expect(result.audioMinutes).toBe(100);
  144. expect(result.inQuotaMinutes).toBe(100);
  145. expect(result.overageMinutes).toBe(0);
  146. expect(result.totalPrice).toBeCloseTo(100 * PRICE_PER_MIN, 3);
  147. });
  148. it('超出配额 1 分钟(免费版 100 分钟配额 → 101 分钟音频)', () => {
  149. const result = calculateAudioCost(15150, 0, 0); // 101 分钟音频
  150. expect(result.audioMinutes).toBe(101);
  151. expect(result.inQuotaMinutes).toBe(100); // 只能使用100分钟配额
  152. expect(result.overageMinutes).toBe(1); // 超额1分钟
  153. // 免费版不支持超额,所以超额价格为0
  154. expect(result.overagePrice).toBe(0);
  155. });
  156. it('配额已用尽(免费版 100 分钟配额 → 5 分钟音频,已用 100)', () => {
  157. const result = calculateAudioCost(750, 0, 100);
  158. expect(result.audioMinutes).toBe(5);
  159. expect(result.inQuotaMinutes).toBe(0);
  160. expect(result.overageMinutes).toBe(5);
  161. // 免费版不支持超额
  162. expect(result.overagePrice).toBe(0);
  163. });
  164. });
  165. describe('免费版不支持超额', () => {
  166. it('免费版超额部分价格为零', () => {
  167. const result = calculateAudioCost(150000, 0, 0); // 1000分钟音频
  168. expect(result.overageMinutes).toBe(900); // 1000 - 100 = 900
  169. expect(result.overagePrice).toBe(0); // 免费版不支持超额
  170. });
  171. it('免费版总价为配额内价格', () => {
  172. const result = calculateAudioCost(150000, 0, 0);
  173. expect(result.totalPrice).toBeCloseTo(result.inQuotaPrice, 4);
  174. });
  175. });
  176. describe('estimatedCost 计算', () => {
  177. it('estimatedCost 基于成本价计算', () => {
  178. const result = calculateAudioCost(1500, 0, 0);
  179. // 10分钟 × ¥0.016/分钟(成本) = ¥0.16
  180. expect(result.estimatedCost).toBeCloseTo(10 * AUDIO_BILLING_CONFIG.costPerMinute, 4);
  181. });
  182. });
  183. });
  184. describe('calculateTokenCost', () => {
  185. it('默认使用 qwen_plus 模型计算', () => {
  186. const result = calculateTokenCost(1000); // 1000字
  187. expect(result.aiInputCost).toBeGreaterThan(0);
  188. expect(result.aiOutputCost).toBeGreaterThan(0);
  189. const expected = result.aiInputCost + result.aiOutputCost + result.ttsCost;
  190. expect(result.totalCost).toBeCloseTo(expected, 4);
  191. expect(result.suggestedPrice).toBeGreaterThan(result.totalCost); // 有利润
  192. });
  193. it('不同模型成本不同', () => {
  194. const resultQwen = calculateTokenCost(1000, 'qwen_plus');
  195. const resultQwenFlash = calculateTokenCost(1000, 'qwen3_flash');
  196. const resultDeepseek = calculateTokenCost(1000, 'deepseek_v3');
  197. // 各模型定价不同,成本应该不同
  198. expect(resultQwen.totalCost).not.toBe(resultQwenFlash.totalCost);
  199. expect(resultQwen.totalCost).not.toBe(resultDeepseek.totalCost);
  200. expect(resultQwenFlash.totalCost).not.toBe(resultDeepseek.totalCost);
  201. });
  202. it('大文本计算正确', () => {
  203. const result = calculateTokenCost(10000);
  204. expect(result.totalCost).toBeGreaterThan(0);
  205. expect(result.ttsCost).toBeGreaterThan(0);
  206. });
  207. it('返回值格式正确', () => {
  208. const result = calculateTokenCost(1000);
  209. expect(result).toHaveProperty('aiInputCost');
  210. expect(result).toHaveProperty('aiOutputCost');
  211. expect(result).toHaveProperty('ttsCost');
  212. expect(result).toHaveProperty('totalCost');
  213. expect(result).toHaveProperty('suggestedPrice');
  214. });
  215. });
  216. describe('配额计算边界情况', () => {
  217. it('零文本', () => {
  218. const result = calculateAudioCost(0, 0, 0);
  219. expect(result.audioMinutes).toBe(0);
  220. expect(result.totalPrice).toBe(0);
  221. });
  222. it('负数文本返回0或负0', () => {
  223. const result = calculateAudioCost(-100, 0, 0);
  224. expect(result.audioMinutes).toBeLessThanOrEqual(0);
  225. });
  226. it('未知会员等级使用默认配额', () => {
  227. const result = calculateAudioCost(1500, 99, 0); // 未知等级
  228. expect(result.audioMinutes).toBe(10);
  229. });
  230. it('所有金额精确到分', () => {
  231. const result = calculateAudioCost(333, 1, 0); // 不规则数字
  232. // 检查金额没有精度问题
  233. const strTotal = result.totalPrice.toString();
  234. const decimals = strTotal.includes('.') ? strTotal.split('.')[1].length : 0;
  235. expect(decimals).toBeLessThanOrEqual(2);
  236. });
  237. });