PointsService.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  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\MpGenerateVideoTask;
  7. use App\Models\MpUserPointsDetail;
  8. use Illuminate\Support\Facades\DB;
  9. /**
  10. * 用户积分服务
  11. *
  12. * 负责视频生成等业务的积分扣费、积分使用明细记录以及用户积分余额更新。
  13. */
  14. class PointsService
  15. {
  16. /**
  17. * 视频生成单次默认扣费积分数
  18. * 视频模型未配置计费规则时的默认扣费积分数(兜底,避免未配置模型免费)
  19. */
  20. const DEFAULT_VIDEO_CHARGE_POINTS = 0;
  21. /**
  22. * 获取视频模型应扣积分数(公共方法)
  23. *
  24. * 计费规则从 mp_video_models 表读取:
  25. * - 按秒计费(per_second):积分 = 单价/秒 × 视频时长(秒)
  26. * - 按次计费(per_call):积分 = 固定单价(如 Gemini 视频理解,暂未接入)
  27. * 分辨率会归一化为计费档位;“超分720p”档对应生成分辨率 480p(任务更新代码会对 480p 直接超分到 720p)。
  28. *
  29. * @param array $chargeInfo 计费信息(model、video_resolution、video_duration、mode 等)
  30. * @return int
  31. */
  32. public function getVideoChargePoints(array $chargeInfo = []): int
  33. {
  34. $model = (string)getProp($chargeInfo, 'model', '');
  35. $resolution = strtolower((string)getProp($chargeInfo, 'video_resolution', '720p'));
  36. $duration = (int)getProp($chargeInfo, 'video_duration', 0);
  37. if ($duration <= 0) {
  38. $duration = 1; // 时长未知时按1秒兜底
  39. }
  40. $mode = (string)getProp($chargeInfo, 'mode', 'video_generation');
  41. // 从 mp_video_models 表读取计费规则
  42. $modelRow = DB::table('mp_video_models')->where('model', $model)->first();
  43. if (!$modelRow || empty($modelRow->charge_type)) {
  44. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  45. }
  46. $priceJson = $modelRow->price_json;
  47. $priceRule = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson;
  48. if (!is_array($priceRule) || empty($priceRule)) {
  49. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  50. }
  51. // 按次计费(如 Gemini 视频理解:每次固定积分)
  52. if ($modelRow->charge_type === 'per_call') {
  53. $price = (float)($priceRule['price'] ?? self::DEFAULT_VIDEO_CHARGE_POINTS);
  54. return (int)max(1, round($price));
  55. }
  56. // 按秒计费:按场景取分辨率价格表(默认视频生成场景)
  57. $prices = $priceRule[$mode] ?? $priceRule['video_generation'] ?? [];
  58. if (!is_array($prices) || empty($prices)) {
  59. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  60. }
  61. $pricePerSecond = $prices[$this->normalizeResolutionKey($resolution)] ?? null;
  62. if ($pricePerSecond === null || (float)$pricePerSecond <= 0) {
  63. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  64. }
  65. return (int)max(1, round((float)$pricePerSecond * $duration));
  66. }
  67. /**
  68. * 将分辨率归一化为计费档位
  69. *
  70. * “超分720p”档对应生成分辨率 480p(任务更新代码会对 480p 直接超分到 720p),
  71. * 因此 480p / sr_720p / 超分720p 均归一化为 480p 档。
  72. *
  73. * @param string $resolution
  74. * @return string
  75. */
  76. private function normalizeResolutionKey(string $resolution): string
  77. {
  78. $resolution = strtolower(trim($resolution));
  79. switch ($resolution) {
  80. case '4k':
  81. case '2160p':
  82. case '4096x2160':
  83. return '4k';
  84. case '1080p':
  85. return '1080p';
  86. case '720p':
  87. return '720p';
  88. case '480p':
  89. return '480p';
  90. case 'sr_720p':
  91. case 'sr720p':
  92. case '超分720p':
  93. return '480p';
  94. case 'sr_1080p':
  95. case 'sr1080p':
  96. case '超分1080p':
  97. return 'sr_1080p';
  98. default:
  99. return $resolution;
  100. }
  101. }
  102. /**
  103. * 获取视频生成任务实际消耗的 token 量
  104. *
  105. * 优先从接口返回的 result_json.usage 中读取;
  106. * 当前视频类接口暂未返回 token 用量,默认返回 0。
  107. *
  108. * @param MpGenerateVideoTask $task
  109. * @return int
  110. */
  111. public function getVideoTokensConsumed(MpGenerateVideoTask $task): int
  112. {
  113. $resultJson = $task->result_json;
  114. if (is_string($resultJson)) {
  115. $resultJson = json_decode($resultJson, true);
  116. }
  117. if (!is_array($resultJson)) {
  118. return 0;
  119. }
  120. // zzengine(智帧/统一API)返回格式:
  121. // data.task.detail.actual_token_total 或 data.task.detail.result.provider_token_total
  122. // 或 data.task.detail.result.billing_snapshot.token_total
  123. if ($task->api_type === 'zzengine') {
  124. $tokens = $resultJson['data']['task']['detail']['actual_token_total']
  125. ?? $resultJson['data']['task']['detail']['result']['provider_token_total']
  126. ?? $resultJson['data']['task']['detail']['result']['billing_snapshot']['token_total']
  127. ?? $resultJson['data']['task']['detail']['result']['billing_snapshot']['token_output']
  128. ?? 0;
  129. return (int)$tokens;
  130. }
  131. // seedance(豆包视频)返回格式:usage.total_tokens / usage.completion_tokens
  132. if ($task->api_type === 'seedance') {
  133. $tokens = $resultJson['usage']['total_tokens']
  134. ?? $resultJson['usage']['completion_tokens']
  135. ?? 0;
  136. return (int)$tokens;
  137. }
  138. // 其他API:优先从 usage 中读取
  139. $tokens = $resultJson['usage']['total_tokens']
  140. ?? $resultJson['usage']['completion_tokens']
  141. ?? $resultJson['content']['usage']['total_tokens']
  142. ?? 0;
  143. return (int)$tokens;
  144. }
  145. /**
  146. * 从接口返回结果中获取实际视频时长(秒)
  147. *
  148. * 各 API 返回格式不同,按 api_type 分别解析;
  149. * 用于 charge_info 中自动时长(-1/0)的任务在成功扣费时回填实际时长。
  150. *
  151. * @param MpGenerateVideoTask $task
  152. * @return int
  153. */
  154. public function getActualVideoDuration(MpGenerateVideoTask $task): int
  155. {
  156. $resultJson = $task->result_json;
  157. if (is_string($resultJson)) {
  158. $resultJson = json_decode($resultJson, true);
  159. }
  160. if (!is_array($resultJson)) {
  161. return 0;
  162. }
  163. switch ($task->api_type) {
  164. case 'zzengine':
  165. return (int)($resultJson['data']['task']['detail']['duration'] ?? 0);
  166. case 'jimeng':
  167. $data = $resultJson['data'] ?? [];
  168. if (!empty($data['duration'])) {
  169. return (int)$data['duration'];
  170. }
  171. if (isset($data['frames'], $data['framespersecond']) && (int)$data['framespersecond'] > 0) {
  172. return (int)floor((int)$data['frames'] / (int)$data['framespersecond']);
  173. }
  174. return 0;
  175. case 'keling':
  176. $taskData = $resultJson['data'] ?? [];
  177. $video = $taskData['task_result']['videos'][0] ?? [];
  178. if (!empty($video['duration'])) {
  179. return (int)$video['duration'];
  180. }
  181. if (!empty($taskData['duration'])) {
  182. return (int)$taskData['duration'];
  183. }
  184. if (isset($video['frames'], $video['framespersecond']) && (int)$video['framespersecond'] > 0) {
  185. return (int)floor((int)$video['frames'] / (int)$video['framespersecond']);
  186. }
  187. return 0;
  188. case 'seedance':
  189. default:
  190. return (int)($resultJson['duration'] ?? 0);
  191. }
  192. }
  193. /**
  194. * 获取用户积分流水
  195. *
  196. * @param array $params uid/type/start_date/end_date/page_size
  197. * @return array
  198. */
  199. public function getUserPointsRecords(array $params = []): array
  200. {
  201. $uid = (int)getProp($params, 'uid', 0);
  202. if (!$uid) {
  203. $uid = (int)Site::getUid();
  204. }
  205. $type = getProp($params, 'type', '');
  206. $startDate = getProp($params, 'start_date', '');
  207. $endDate = getProp($params, 'end_date', '');
  208. $pageSize = (int)getProp($params, 'page_size', 15);
  209. if ($pageSize < 1 || $pageSize > 100) {
  210. $pageSize = 15;
  211. }
  212. $query = MpUserPointsDetail::where('uid', $uid);
  213. if ($type) {
  214. $query->where('type', $type);
  215. }
  216. if ($startDate) {
  217. $query->where('created_at', '>=', $startDate . ' 00:00:00');
  218. }
  219. if ($endDate) {
  220. $query->where('created_at', '<=', $endDate . ' 23:59:59');
  221. }
  222. $records = $query->orderBy('created_at', 'desc')
  223. ->orderBy('id', 'desc')
  224. ->paginate($pageSize);
  225. // 汇总统计:当前积分余额、累计消耗/退回积分、累计消耗token
  226. $user = DB::table('mp_manage_users')->where('id', $uid)->first();
  227. $summary = [
  228. 'points_balance' => (float)getProp($user, 'points', 0),
  229. 'total_points_consumed' => (float)MpUserPointsDetail::where('uid', $uid)
  230. ->where('points_consumed', '>', 0)
  231. ->sum('points_consumed'),
  232. 'total_points_refunded' => (float)MpUserPointsDetail::where('uid', $uid)
  233. ->where('points_consumed', '<', 0)
  234. ->sum('points_consumed'),
  235. 'total_tokens_consumed' => (int)MpUserPointsDetail::where('uid', $uid)
  236. ->sum('tokens_consumed'),
  237. 'total_count' => (int)MpUserPointsDetail::where('uid', $uid)->count(),
  238. ];
  239. return [
  240. 'summary' => $summary,
  241. 'records' => $records,
  242. ];
  243. }
  244. /**
  245. * 获取用户当前积分余额
  246. *
  247. * @param int $uid 用户ID,缺省取当前登录用户
  248. * @return float
  249. */
  250. public function getUserPointsBalance(int $uid = 0): float
  251. {
  252. if (!$uid) {
  253. $uid = (int)Site::getUid();
  254. }
  255. if (!$uid) {
  256. Utils::throwError(ErrorConst::NOT_LOGIN);
  257. }
  258. $user = DB::table('mp_manage_users')->where('id', $uid)->first();
  259. if (!$user) {
  260. Utils::throwError(ErrorConst::USER_IS_NOT_EXIST);
  261. }
  262. return (float)getProp($user, 'points', 0);
  263. }
  264. /**
  265. * 余额预检(公共方法)
  266. *
  267. * 计算视频/其他业务所需积分数后,在创建任务前调用;
  268. * 积分不足时直接抛错(20009:积分不足),业务方无需自行处理。
  269. *
  270. * @param float|int $pointsNeeded 需要扣除的积分数
  271. * @param int $uid 用户ID,缺省取当前登录用户
  272. * @return void
  273. */
  274. public function checkUserPointsEnough($pointsNeeded, int $uid = 0): void
  275. {
  276. $pointsNeeded = (float)$pointsNeeded;
  277. if ($pointsNeeded <= 0) {
  278. return;
  279. }
  280. $balance = $this->getUserPointsBalance($uid);
  281. if ($balance < $pointsNeeded) {
  282. Utils::throwError(ErrorConst::POINTS_NOT_ENOUGH);
  283. }
  284. }
  285. /**
  286. * 视频生成成功后记录计费明细并扣减用户积分
  287. *
  288. * 幂等处理:同一任务只允许计费一次(task_id 唯一索引兜底)。
  289. *
  290. * @param MpGenerateVideoTask $task
  291. * @return array
  292. */
  293. public function recordVideoTaskCharge(MpGenerateVideoTask $task): array
  294. {
  295. // 重复计费保护
  296. $exists = DB::table('mp_user_points_details')->where('task_id', $task->id)->exists();
  297. if ($exists) {
  298. return ['charged' => false, 'reason' => 'already_charged', 'task_id' => $task->id];
  299. }
  300. $chargeInfo = $task->charge_info;
  301. if (is_string($chargeInfo)) {
  302. $chargeInfo = json_decode($chargeInfo, true);
  303. }
  304. if (!is_array($chargeInfo) || empty($chargeInfo['user_id'])) {
  305. dLog('points')->warning('视频任务缺少计费信息,跳过扣费', ['task_id' => $task->id]);
  306. return ['charged' => false, 'reason' => 'no_charge_info', 'task_id' => $task->id];
  307. }
  308. // 自动时长(-1/0)时,用接口返回的实际时长回填计费信息
  309. $requestedDuration = (int)($chargeInfo['video_duration'] ?? -1);
  310. $actualDuration = $this->getActualVideoDuration($task);
  311. $durationBackfilled = $requestedDuration <= 0 && $actualDuration > 0;
  312. if ($durationBackfilled) {
  313. $chargeInfo['video_duration'] = $actualDuration;
  314. }
  315. $uid = (int)$chargeInfo['user_id'];
  316. $points = (float)$this->getVideoChargePoints($chargeInfo);
  317. $tokens = $this->getVideoTokensConsumed($task);
  318. try {
  319. DB::beginTransaction();
  320. $user = DB::table('mp_manage_users')->where('id', $uid)->first();
  321. if (!$user) {
  322. DB::rollBack();
  323. dLog('points')->error('扣费失败:用户不存在', ['task_id' => $task->id, 'uid' => $uid]);
  324. return ['charged' => false, 'reason' => 'user_not_found', 'task_id' => $task->id];
  325. }
  326. $pointsBefore = (float)getProp($user, 'points', 0);
  327. $pointsAfter = $pointsBefore - $points;
  328. if ($pointsBefore < $points) {
  329. dLog('points')->warning('用户积分不足,扣费后积分为负数', [
  330. 'task_id' => $task->id,
  331. 'uid' => $uid,
  332. 'points_before' => $pointsBefore,
  333. 'points_consumed' => $points
  334. ]);
  335. }
  336. // 更新用户积分总额
  337. DB::table('mp_manage_users')->where('id', $uid)->update([
  338. 'points' => $pointsAfter,
  339. 'updated_at' => date('Y-m-d H:i:s')
  340. ]);
  341. // 自动时长被实际时长覆盖时,同步回填任务表的 charge_info
  342. if ($durationBackfilled) {
  343. $task->update(['charge_info' => $chargeInfo]);
  344. }
  345. // 记录积分使用明细
  346. DB::table('mp_user_points_details')->insert([
  347. 'uid' => $uid,
  348. 'task_id' => $task->id,
  349. 'type' => MpUserPointsDetail::TYPE_VIDEO,
  350. 'api_type' => getProp($task, 'api_type', ''),
  351. 'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE),
  352. 'points_consumed' => $points,
  353. 'points_before' => $pointsBefore,
  354. 'points_after' => $pointsAfter,
  355. 'tokens_consumed' => $tokens,
  356. 'remark' => '视频生成成功计费',
  357. 'created_at' => date('Y-m-d H:i:s'),
  358. 'updated_at' => date('Y-m-d H:i:s')
  359. ]);
  360. DB::commit();
  361. dLog('points')->info('视频生成计费成功', [
  362. 'task_id' => $task->id,
  363. 'uid' => $uid,
  364. 'points_consumed' => $points,
  365. 'points_after' => $pointsAfter,
  366. 'tokens_consumed' => $tokens
  367. ]);
  368. return [
  369. 'charged' => true,
  370. 'task_id' => $task->id,
  371. 'uid' => $uid,
  372. 'points_consumed' => $points,
  373. 'points_before' => $pointsBefore,
  374. 'points_after' => $pointsAfter,
  375. 'tokens_consumed' => $tokens
  376. ];
  377. } catch (\Exception $e) {
  378. DB::rollBack();
  379. dLog('points')->error('视频生成计费失败: ' . $e->getMessage(), ['task_id' => $task->id]);
  380. logDB('points', 'error', '视频生成计费失败', [
  381. 'task_id' => $task->id,
  382. 'error' => $e->getMessage()
  383. ]);
  384. return ['charged' => false, 'reason' => 'exception: ' . $e->getMessage(), 'task_id' => $task->id];
  385. }
  386. }
  387. }