subscription.service.test.ts 9.4 KB

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