PointsService.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602
  1. <?php
  2. namespace App\Services;
  3. use App\Consts\ErrorConst;
  4. use App\Facade\Site;
  5. use App\Libs\Utils;
  6. use App\Models\MpGeneratePicTask;
  7. use App\Models\MpGenerateVideoTask;
  8. use App\Models\MpUserPointsDetail;
  9. use Illuminate\Support\Facades\DB;
  10. /**
  11. * 用户积分服务
  12. *
  13. * 负责视频生成等业务的积分扣费、积分使用明细记录以及用户积分余额更新。
  14. */
  15. class PointsService
  16. {
  17. /**
  18. * 视频生成单次默认扣费积分数
  19. * 视频模型未配置计费规则时的默认扣费积分数(兜底,避免未配置模型免费)
  20. */
  21. const DEFAULT_VIDEO_CHARGE_POINTS = 0;
  22. /**
  23. * 获取视频模型应扣积分数(公共方法)
  24. *
  25. * 计费规则从 mp_video_models 表读取:
  26. * - 按秒计费(per_second):积分 = 单价/秒 × 视频时长(秒)
  27. * - 按次计费(per_call):积分 = 固定单价(如 Gemini 视频理解,暂未接入)
  28. * 分辨率会归一化为计费档位;“超分720p”档对应生成分辨率 480p(任务更新代码会对 480p 直接超分到 720p)。
  29. *
  30. * @param array $chargeInfo 计费信息(model、video_resolution、video_duration、mode 等)
  31. * @return int
  32. */
  33. public function getVideoChargePoints(array $chargeInfo = []): int
  34. {
  35. $model = (string)getProp($chargeInfo, 'model', '');
  36. $resolution = strtolower((string)getProp($chargeInfo, 'video_resolution', '720p'));
  37. $duration = (int)getProp($chargeInfo, 'video_duration', 0);
  38. if ($duration <= 0) {
  39. $duration = 1; // 时长未知时按1秒兜底
  40. }
  41. $mode = (string)getProp($chargeInfo, 'mode', 'video_generation');
  42. // 从 mp_video_models 表读取计费规则
  43. $modelRow = DB::table('mp_video_models')->where('model', $model)->first();
  44. if (!$modelRow || empty($modelRow->charge_type)) {
  45. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  46. }
  47. $priceJson = $modelRow->price_json;
  48. $priceRule = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson;
  49. if (!is_array($priceRule) || empty($priceRule)) {
  50. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  51. }
  52. // 按次计费(如 Gemini 视频理解:每次固定积分)
  53. if ($modelRow->charge_type === 'per_call') {
  54. $price = (float)($priceRule['price'] ?? self::DEFAULT_VIDEO_CHARGE_POINTS);
  55. return (int)max(1, round($price));
  56. }
  57. // 按秒计费:按场景取分辨率价格表(默认视频生成场景)
  58. $prices = $priceRule[$mode] ?? $priceRule['video_generation'] ?? [];
  59. if (!is_array($prices) || empty($prices)) {
  60. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  61. }
  62. $pricePerSecond = $prices[$this->normalizeResolutionKey($resolution)] ?? null;
  63. if ($pricePerSecond === null || (float)$pricePerSecond <= 0) {
  64. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  65. }
  66. return (int)max(1, round((float)$pricePerSecond * $duration));
  67. }
  68. /**
  69. * 将分辨率归一化为计费档位
  70. *
  71. * “超分720p”档对应生成分辨率 480p(任务更新代码会对 480p 直接超分到 720p),
  72. * 因此 480p / sr_720p / 超分720p 均归一化为 480p 档。
  73. *
  74. * @param string $resolution
  75. * @return string
  76. */
  77. private function normalizeResolutionKey(string $resolution): string
  78. {
  79. $resolution = strtolower(trim($resolution));
  80. switch ($resolution) {
  81. case '4k':
  82. case '2160p':
  83. case '4096x2160':
  84. return '4k';
  85. case '1080p':
  86. return '1080p';
  87. case '720p':
  88. return '720p';
  89. case '480p':
  90. return '480p';
  91. case 'sr_720p':
  92. case 'sr720p':
  93. case '超分720p':
  94. return '480p';
  95. case 'sr_1080p':
  96. case 'sr1080p':
  97. case '超分1080p':
  98. return 'sr_1080p';
  99. default:
  100. return $resolution;
  101. }
  102. }
  103. /**
  104. * 获取视频生成任务实际消耗的 token 量
  105. *
  106. * 优先从接口返回的 result_json.usage 中读取;
  107. * 当前视频类接口暂未返回 token 用量,默认返回 0。
  108. *
  109. * @param MpGenerateVideoTask $task
  110. * @return int
  111. */
  112. public function getVideoTokensConsumed(MpGenerateVideoTask $task): int
  113. {
  114. $resultJson = $task->result_json;
  115. if (is_string($resultJson)) {
  116. $resultJson = json_decode($resultJson, true);
  117. }
  118. if (!is_array($resultJson)) {
  119. return 0;
  120. }
  121. // zzengine(智帧/统一API)返回格式:
  122. // data.task.detail.actual_token_total 或 data.task.detail.result.provider_token_total
  123. // 或 data.task.detail.result.billing_snapshot.token_total
  124. if ($task->api_type === 'zzengine') {
  125. $tokens = $resultJson['data']['task']['detail']['actual_token_total']
  126. ?? $resultJson['data']['task']['detail']['result']['provider_token_total']
  127. ?? $resultJson['data']['task']['detail']['result']['billing_snapshot']['token_total']
  128. ?? $resultJson['data']['task']['detail']['result']['billing_snapshot']['token_output']
  129. ?? 0;
  130. return (int)$tokens;
  131. }
  132. // seedance(豆包视频)返回格式:usage.total_tokens / usage.completion_tokens
  133. if ($task->api_type === 'seedance') {
  134. $tokens = $resultJson['usage']['total_tokens']
  135. ?? $resultJson['usage']['completion_tokens']
  136. ?? 0;
  137. return (int)$tokens;
  138. }
  139. // 其他API:优先从 usage 中读取
  140. $tokens = $resultJson['usage']['total_tokens']
  141. ?? $resultJson['usage']['completion_tokens']
  142. ?? $resultJson['content']['usage']['total_tokens']
  143. ?? 0;
  144. return (int)$tokens;
  145. }
  146. /**
  147. * 从接口返回结果中获取实际视频时长(秒)
  148. *
  149. * 各 API 返回格式不同,按 api_type 分别解析;
  150. * 用于 charge_info 中自动时长(-1/0)的任务在成功扣费时回填实际时长。
  151. *
  152. * @param MpGenerateVideoTask $task
  153. * @return int
  154. */
  155. public function getActualVideoDuration(MpGenerateVideoTask $task): int
  156. {
  157. $resultJson = $task->result_json;
  158. if (is_string($resultJson)) {
  159. $resultJson = json_decode($resultJson, true);
  160. }
  161. if (!is_array($resultJson)) {
  162. return 0;
  163. }
  164. switch ($task->api_type) {
  165. case 'zzengine':
  166. return (int)($resultJson['data']['task']['detail']['duration'] ?? 0);
  167. case 'jimeng':
  168. $data = $resultJson['data'] ?? [];
  169. if (!empty($data['duration'])) {
  170. return (int)$data['duration'];
  171. }
  172. if (isset($data['frames'], $data['framespersecond']) && (int)$data['framespersecond'] > 0) {
  173. return (int)floor((int)$data['frames'] / (int)$data['framespersecond']);
  174. }
  175. return 0;
  176. case 'keling':
  177. $taskData = $resultJson['data'] ?? [];
  178. $video = $taskData['task_result']['videos'][0] ?? [];
  179. if (!empty($video['duration'])) {
  180. return (int)$video['duration'];
  181. }
  182. if (!empty($taskData['duration'])) {
  183. return (int)$taskData['duration'];
  184. }
  185. if (isset($video['frames'], $video['framespersecond']) && (int)$video['framespersecond'] > 0) {
  186. return (int)floor((int)$video['frames'] / (int)$video['framespersecond']);
  187. }
  188. return 0;
  189. case 'seedance':
  190. default:
  191. return (int)($resultJson['duration'] ?? 0);
  192. }
  193. }
  194. /**
  195. * 获取用户积分流水
  196. *
  197. * @param array $params uid/type/start_date/end_date/page_size
  198. * @return array
  199. */
  200. public function getUserPointsRecords(array $params = []): array
  201. {
  202. $uid = (int)getProp($params, 'uid', 0);
  203. if (!$uid) {
  204. $uid = (int)Site::getUid();
  205. }
  206. $type = getProp($params, 'type', '');
  207. $startDate = getProp($params, 'start_date', '');
  208. $endDate = getProp($params, 'end_date', '');
  209. $pageSize = (int)getProp($params, 'page_size', 15);
  210. if ($pageSize < 1 || $pageSize > 100) {
  211. $pageSize = 15;
  212. }
  213. $query = MpUserPointsDetail::where('uid', $uid);
  214. if ($type) {
  215. $query->where('type', $type);
  216. }
  217. if ($startDate) {
  218. $query->where('created_at', '>=', $startDate . ' 00:00:00');
  219. }
  220. if ($endDate) {
  221. $query->where('created_at', '<=', $endDate . ' 23:59:59');
  222. }
  223. $records = $query->orderBy('created_at', 'desc')
  224. ->orderBy('id', 'desc')
  225. ->paginate($pageSize);
  226. // 汇总统计:当前积分余额、累计消耗/退回积分、累计消耗token
  227. $user = DB::table('mp_manage_users')->where('id', $uid)->first();
  228. $summary = [
  229. 'points_balance' => (float)getProp($user, 'points', 0),
  230. 'total_points_consumed' => (float)MpUserPointsDetail::where('uid', $uid)
  231. ->where('points_consumed', '>', 0)
  232. ->sum('points_consumed'),
  233. 'total_points_refunded' => (float)MpUserPointsDetail::where('uid', $uid)
  234. ->where('points_consumed', '<', 0)
  235. ->sum('points_consumed'),
  236. 'total_tokens_consumed' => (int)MpUserPointsDetail::where('uid', $uid)
  237. ->sum('tokens_consumed'),
  238. 'total_count' => (int)MpUserPointsDetail::where('uid', $uid)->count(),
  239. ];
  240. return [
  241. 'summary' => $summary,
  242. 'records' => $records,
  243. ];
  244. }
  245. /**
  246. * 获取用户当前积分余额
  247. *
  248. * @param int $uid 用户ID,缺省取当前登录用户
  249. * @return float
  250. */
  251. public function getUserPointsBalance(int $uid = 0): float
  252. {
  253. if (!$uid) {
  254. $uid = (int)Site::getUid();
  255. }
  256. if (!$uid) {
  257. Utils::throwError(ErrorConst::NOT_LOGIN);
  258. }
  259. $user = DB::table('mp_manage_users')->where('id', $uid)->first();
  260. if (!$user) {
  261. Utils::throwError(ErrorConst::USER_IS_NOT_EXIST);
  262. }
  263. return (float)getProp($user, 'points', 0);
  264. }
  265. /**
  266. * 余额预检(公共方法)
  267. *
  268. * 计算视频/其他业务所需积分数后,在创建任务前调用;
  269. * 积分不足时直接抛错(20009:积分不足),业务方无需自行处理。
  270. *
  271. * @param float|int $pointsNeeded 需要扣除的积分数
  272. * @param int $uid 用户ID,缺省取当前登录用户
  273. * @return void
  274. */
  275. public function checkUserPointsEnough($pointsNeeded, int $uid = 0): void
  276. {
  277. $pointsNeeded = (float)$pointsNeeded;
  278. if ($pointsNeeded <= 0) {
  279. return;
  280. }
  281. $balance = $this->getUserPointsBalance($uid);
  282. if ($balance < $pointsNeeded) {
  283. Utils::throwError(ErrorConst::POINTS_NOT_ENOUGH);
  284. }
  285. }
  286. /**
  287. * 视频生成成功后记录计费明细并扣减用户积分
  288. *
  289. * 幂等处理:同一任务只允许计费一次(type + task_id 唯一索引兜底)。
  290. *
  291. * @param MpGenerateVideoTask $task
  292. * @return array
  293. */
  294. public function recordVideoTaskCharge(MpGenerateVideoTask $task): array
  295. {
  296. // 重复计费保护
  297. $exists = DB::table('mp_user_points_details')
  298. ->where('type', MpUserPointsDetail::TYPE_VIDEO)
  299. ->where('task_id', $task->id)
  300. ->exists();
  301. if ($exists) {
  302. return ['charged' => false, 'reason' => 'already_charged', 'task_id' => $task->id];
  303. }
  304. $chargeInfo = $task->charge_info;
  305. if (is_string($chargeInfo)) {
  306. $chargeInfo = json_decode($chargeInfo, true);
  307. }
  308. if (!is_array($chargeInfo) || empty($chargeInfo['user_id'])) {
  309. dLog('points')->warning('视频任务缺少计费信息,跳过扣费', ['task_id' => $task->id]);
  310. return ['charged' => false, 'reason' => 'no_charge_info', 'task_id' => $task->id];
  311. }
  312. // 自动时长(-1/0)时,用接口返回的实际时长回填计费信息
  313. $requestedDuration = (int)($chargeInfo['video_duration'] ?? -1);
  314. $actualDuration = $this->getActualVideoDuration($task);
  315. $durationBackfilled = $requestedDuration <= 0 && $actualDuration > 0;
  316. if ($durationBackfilled) {
  317. $chargeInfo['video_duration'] = $actualDuration;
  318. }
  319. $uid = (int)$chargeInfo['user_id'];
  320. $points = (float)$this->getVideoChargePoints($chargeInfo);
  321. $tokens = $this->getVideoTokensConsumed($task);
  322. $result = $this->deductAndRecord(
  323. $uid,
  324. $task->id,
  325. MpUserPointsDetail::TYPE_VIDEO,
  326. (string)getProp($task, 'api_type', ''),
  327. $points,
  328. $tokens,
  329. $chargeInfo,
  330. '视频生成成功计费'
  331. );
  332. // 自动时长被实际时长覆盖时,计费成功后同步回填任务表的 charge_info
  333. if ($durationBackfilled && !empty($result['charged'])) {
  334. $task->update(['charge_info' => $chargeInfo]);
  335. }
  336. return $result;
  337. }
  338. /**
  339. * 获取图片生成单张应扣积分数
  340. *
  341. * 从 mp_image_models 表读取(charge_type=per_image),按分辨率档位(1k/2k/4k)取单张积分。
  342. *
  343. * @param array $chargeInfo 计费信息(model、resolution、width、height 等)
  344. * @return int
  345. */
  346. public function getImageChargePoints(array $chargeInfo = []): int
  347. {
  348. $model = (string)getProp($chargeInfo, 'model', '');
  349. $resolution = strtolower((string)getProp($chargeInfo, 'resolution', '2k'));
  350. $modelRow = DB::table('mp_image_models')->where('model', $model)->first();
  351. if (!$modelRow || ($modelRow->charge_type ?? '') !== 'per_image') {
  352. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  353. }
  354. $priceJson = $modelRow->price_json;
  355. $prices = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson;
  356. if (!is_array($prices) || empty($prices)) {
  357. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  358. }
  359. $price = $prices[$resolution] ?? null;
  360. if ($price === null || (float)$price <= 0) {
  361. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  362. }
  363. return (int)max(1, round((float)$price));
  364. }
  365. /**
  366. * 根据图片宽高归一化分辨率档位(1k/2k/4k)
  367. *
  368. * 2k 常见尺寸:2048x2048、1600x2848、2560x1440 等;
  369. * 4k:4096x4096、4992x3328、3040x5504 等;其余更小尺寸归为 1k。
  370. *
  371. * @param int $width
  372. * @param int $height
  373. * @return string
  374. */
  375. public function normalizeImageResolutionKey(int $width, int $height): string
  376. {
  377. if ($width <= 0 || $height <= 0) {
  378. return '2k';
  379. }
  380. $maxDim = max($width, $height);
  381. $area = $width * $height;
  382. if ($maxDim > 4096 || $area >= 4096 * 4096) {
  383. return '4k';
  384. }
  385. if ($maxDim > 1536) {
  386. return '2k';
  387. }
  388. return '1k';
  389. }
  390. /**
  391. * 获取图片生成任务实际消耗的 token 量
  392. *
  393. * 图片接口返回格式:result_json.usage.total_tokens / output_tokens
  394. *
  395. * @param MpGeneratePicTask $task
  396. * @return int
  397. */
  398. public function getImageTokensConsumed(MpGeneratePicTask $task): int
  399. {
  400. $resultJson = $task->result_json;
  401. if (is_string($resultJson)) {
  402. $resultJson = json_decode($resultJson, true);
  403. }
  404. if (!is_array($resultJson)) {
  405. return 0;
  406. }
  407. $tokens = $resultJson['usage']['total_tokens'] ?? $resultJson['usage']['output_tokens'] ?? 0;
  408. return (int)$tokens;
  409. }
  410. /**
  411. * 图片生成成功后记录计费明细并扣减用户积分
  412. *
  413. * 幂等处理:同一任务只允许计费一次(type + task_id 唯一索引兜底)。
  414. *
  415. * @param MpGeneratePicTask $task
  416. * @return array
  417. */
  418. public function recordImageTaskCharge(MpGeneratePicTask $task): array
  419. {
  420. // 重复计费保护
  421. $exists = DB::table('mp_user_points_details')
  422. ->where('type', MpUserPointsDetail::TYPE_IMAGE)
  423. ->where('task_id', $task->id)
  424. ->exists();
  425. if ($exists) {
  426. return ['charged' => false, 'reason' => 'already_charged', 'task_id' => $task->id];
  427. }
  428. $chargeInfo = $task->charge_info;
  429. if (is_string($chargeInfo)) {
  430. $chargeInfo = json_decode($chargeInfo, true);
  431. }
  432. if (!is_array($chargeInfo) || empty($chargeInfo['user_id'])) {
  433. dLog('points')->warning('图片任务缺少计费信息,跳过扣费', ['task_id' => $task->id]);
  434. return ['charged' => false, 'reason' => 'no_charge_info', 'task_id' => $task->id];
  435. }
  436. $uid = (int)$chargeInfo['user_id'];
  437. // 积分 = 单张价格 × 实际生成图片数
  438. $pointsPerImage = (float)$this->getImageChargePoints($chargeInfo);
  439. $imageCount = is_array($task->result_url) ? count($task->result_url) : 0;
  440. if ($imageCount <= 0) {
  441. $imageCount = (int)($chargeInfo['image_num'] ?? 1);
  442. }
  443. if ($imageCount <= 0) {
  444. $imageCount = 1;
  445. }
  446. // 单张价格 × 实际生成图片数(未配置价格时为0,仍记录明细与token)
  447. $points = (float)round($pointsPerImage * $imageCount);
  448. $tokens = $this->getImageTokensConsumed($task);
  449. return $this->deductAndRecord(
  450. $uid,
  451. $task->id,
  452. MpUserPointsDetail::TYPE_IMAGE,
  453. (string)getProp($task, 'model', ''),
  454. $points,
  455. $tokens,
  456. $chargeInfo,
  457. '图片生成成功计费'
  458. );
  459. }
  460. /**
  461. * 扣减积分并记录积分使用明细(视频/图片共用)
  462. *
  463. * @param int $uid
  464. * @param int $taskId
  465. * @param string $type
  466. * @param string $apiType
  467. * @param float $points
  468. * @param int $tokens
  469. * @param array $chargeInfo
  470. * @param string $remark
  471. * @return array
  472. */
  473. private function deductAndRecord(int $uid, int $taskId, string $type, string $apiType, float $points, int $tokens, array $chargeInfo, string $remark): array
  474. {
  475. try {
  476. DB::beginTransaction();
  477. $user = DB::table('mp_manage_users')->where('id', $uid)->first();
  478. if (!$user) {
  479. DB::rollBack();
  480. dLog('points')->error('扣费失败:用户不存在', ['task_id' => $taskId, 'uid' => $uid]);
  481. return ['charged' => false, 'reason' => 'user_not_found', 'task_id' => $taskId];
  482. }
  483. $pointsBefore = (float)getProp($user, 'points', 0);
  484. $pointsAfter = $pointsBefore - $points;
  485. if ($pointsBefore < $points) {
  486. dLog('points')->warning('用户积分不足,扣费后积分为负数', [
  487. 'task_id' => $taskId,
  488. 'uid' => $uid,
  489. 'points_before' => $pointsBefore,
  490. 'points_consumed' => $points
  491. ]);
  492. }
  493. // 更新用户积分总额
  494. DB::table('mp_manage_users')->where('id', $uid)->update([
  495. 'points' => $pointsAfter,
  496. 'updated_at' => date('Y-m-d H:i:s')
  497. ]);
  498. // 记录积分使用明细
  499. DB::table('mp_user_points_details')->insert([
  500. 'uid' => $uid,
  501. 'task_id' => $taskId,
  502. 'type' => $type,
  503. 'api_type' => $apiType,
  504. 'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE),
  505. 'points_consumed' => $points,
  506. 'points_before' => $pointsBefore,
  507. 'points_after' => $pointsAfter,
  508. 'tokens_consumed' => $tokens,
  509. 'remark' => $remark,
  510. 'created_at' => date('Y-m-d H:i:s'),
  511. 'updated_at' => date('Y-m-d H:i:s')
  512. ]);
  513. DB::commit();
  514. dLog('points')->info($type . '计费成功', [
  515. 'task_id' => $taskId,
  516. 'uid' => $uid,
  517. 'points_consumed' => $points,
  518. 'points_after' => $pointsAfter,
  519. 'tokens_consumed' => $tokens
  520. ]);
  521. return [
  522. 'charged' => true,
  523. 'task_id' => $taskId,
  524. 'uid' => $uid,
  525. 'points_consumed' => $points,
  526. 'points_before' => $pointsBefore,
  527. 'points_after' => $pointsAfter,
  528. 'tokens_consumed' => $tokens
  529. ];
  530. } catch (\Exception $e) {
  531. DB::rollBack();
  532. dLog('points')->error($type . '计费失败: ' . $e->getMessage(), ['task_id' => $taskId]);
  533. logDB('points', 'error', $type . '计费失败', [
  534. 'task_id' => $taskId,
  535. 'error' => $e->getMessage()
  536. ]);
  537. return ['charged' => false, 'reason' => 'exception: ' . $e->getMessage(), 'task_id' => $taskId];
  538. }
  539. }
  540. }