PointsService.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  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. * TODO: 后续接入积分映射表,根据 model / video_resolution / 视频时长 等计算具体积分
  19. */
  20. const DEFAULT_VIDEO_CHARGE_POINTS = 0;
  21. /**
  22. * 获取视频生成应扣积分数(公共方法)
  23. *
  24. * 后续积分映射表上线后,只需调整此方法的实现,业务调用方无需改动。
  25. *
  26. * @param array $chargeInfo 计费信息(user_id、model、video_resolution、video_duration 等)
  27. * @return int
  28. */
  29. public function getVideoChargePoints(array $chargeInfo = []): int
  30. {
  31. // TODO: 根据映射表(model + video_resolution + video_duration)查询具体积分数
  32. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  33. }
  34. /**
  35. * 获取视频生成任务实际消耗的 token 量
  36. *
  37. * 优先从接口返回的 result_json.usage 中读取;
  38. * 当前视频类接口暂未返回 token 用量,默认返回 0。
  39. *
  40. * @param MpGenerateVideoTask $task
  41. * @return int
  42. */
  43. public function getVideoTokensConsumed(MpGenerateVideoTask $task): int
  44. {
  45. $resultJson = $task->result_json;
  46. if (is_string($resultJson)) {
  47. $resultJson = json_decode($resultJson, true);
  48. }
  49. if (!is_array($resultJson)) {
  50. return 0;
  51. }
  52. // zzengine(智帧/统一API)返回格式:
  53. // data.task.detail.actual_token_total 或 data.task.detail.result.provider_token_total
  54. // 或 data.task.detail.result.billing_snapshot.token_total
  55. if ($task->api_type === 'zzengine') {
  56. $tokens = $resultJson['data']['task']['detail']['actual_token_total']
  57. ?? $resultJson['data']['task']['detail']['result']['provider_token_total']
  58. ?? $resultJson['data']['task']['detail']['result']['billing_snapshot']['token_total']
  59. ?? $resultJson['data']['task']['detail']['result']['billing_snapshot']['token_output']
  60. ?? 0;
  61. return (int)$tokens;
  62. }
  63. // seedance(豆包视频)返回格式:usage.total_tokens / usage.completion_tokens
  64. if ($task->api_type === 'seedance') {
  65. $tokens = $resultJson['usage']['total_tokens']
  66. ?? $resultJson['usage']['completion_tokens']
  67. ?? 0;
  68. return (int)$tokens;
  69. }
  70. // 其他API:优先从 usage 中读取
  71. $tokens = $resultJson['usage']['total_tokens']
  72. ?? $resultJson['usage']['completion_tokens']
  73. ?? $resultJson['content']['usage']['total_tokens']
  74. ?? 0;
  75. return (int)$tokens;
  76. }
  77. /**
  78. * 从接口返回结果中获取实际视频时长(秒)
  79. *
  80. * 各 API 返回格式不同,按 api_type 分别解析;
  81. * 用于 charge_info 中自动时长(-1/0)的任务在成功扣费时回填实际时长。
  82. *
  83. * @param MpGenerateVideoTask $task
  84. * @return int
  85. */
  86. public function getActualVideoDuration(MpGenerateVideoTask $task): int
  87. {
  88. $resultJson = $task->result_json;
  89. if (is_string($resultJson)) {
  90. $resultJson = json_decode($resultJson, true);
  91. }
  92. if (!is_array($resultJson)) {
  93. return 0;
  94. }
  95. switch ($task->api_type) {
  96. case 'zzengine':
  97. return (int)($resultJson['data']['task']['detail']['duration'] ?? 0);
  98. case 'jimeng':
  99. $data = $resultJson['data'] ?? [];
  100. if (!empty($data['duration'])) {
  101. return (int)$data['duration'];
  102. }
  103. if (isset($data['frames'], $data['framespersecond']) && (int)$data['framespersecond'] > 0) {
  104. return (int)floor((int)$data['frames'] / (int)$data['framespersecond']);
  105. }
  106. return 0;
  107. case 'keling':
  108. $taskData = $resultJson['data'] ?? [];
  109. $video = $taskData['task_result']['videos'][0] ?? [];
  110. if (!empty($video['duration'])) {
  111. return (int)$video['duration'];
  112. }
  113. if (!empty($taskData['duration'])) {
  114. return (int)$taskData['duration'];
  115. }
  116. if (isset($video['frames'], $video['framespersecond']) && (int)$video['framespersecond'] > 0) {
  117. return (int)floor((int)$video['frames'] / (int)$video['framespersecond']);
  118. }
  119. return 0;
  120. case 'seedance':
  121. default:
  122. return (int)($resultJson['duration'] ?? 0);
  123. }
  124. }
  125. /**
  126. * 获取用户积分流水
  127. *
  128. * @param array $params uid/type/start_date/end_date/page_size
  129. * @return array
  130. */
  131. public function getUserPointsRecords(array $params = []): array
  132. {
  133. $uid = (int)getProp($params, 'uid', 0);
  134. if (!$uid) {
  135. $uid = (int)Site::getUid();
  136. }
  137. $type = getProp($params, 'type', '');
  138. $startDate = getProp($params, 'start_date', '');
  139. $endDate = getProp($params, 'end_date', '');
  140. $pageSize = (int)getProp($params, 'page_size', 15);
  141. if ($pageSize < 1 || $pageSize > 100) {
  142. $pageSize = 15;
  143. }
  144. $query = MpUserPointsDetail::where('uid', $uid);
  145. if ($type) {
  146. $query->where('type', $type);
  147. }
  148. if ($startDate) {
  149. $query->where('created_at', '>=', $startDate . ' 00:00:00');
  150. }
  151. if ($endDate) {
  152. $query->where('created_at', '<=', $endDate . ' 23:59:59');
  153. }
  154. $records = $query->orderBy('created_at', 'desc')
  155. ->orderBy('id', 'desc')
  156. ->paginate($pageSize);
  157. // 汇总统计:当前积分余额、累计消耗/退回积分、累计消耗token
  158. $user = DB::table('mp_manage_users')->where('id', $uid)->first();
  159. $summary = [
  160. 'points_balance' => (float)getProp($user, 'points', 0),
  161. 'total_points_consumed' => (float)MpUserPointsDetail::where('uid', $uid)
  162. ->where('points_consumed', '>', 0)
  163. ->sum('points_consumed'),
  164. 'total_points_refunded' => (float)MpUserPointsDetail::where('uid', $uid)
  165. ->where('points_consumed', '<', 0)
  166. ->sum('points_consumed'),
  167. 'total_tokens_consumed' => (int)MpUserPointsDetail::where('uid', $uid)
  168. ->sum('tokens_consumed'),
  169. 'total_count' => (int)MpUserPointsDetail::where('uid', $uid)->count(),
  170. ];
  171. return [
  172. 'summary' => $summary,
  173. 'records' => $records,
  174. ];
  175. }
  176. /**
  177. * 获取用户当前积分余额
  178. *
  179. * @param int $uid 用户ID,缺省取当前登录用户
  180. * @return float
  181. */
  182. public function getUserPointsBalance(int $uid = 0): float
  183. {
  184. if (!$uid) {
  185. $uid = (int)Site::getUid();
  186. }
  187. if (!$uid) {
  188. Utils::throwError(ErrorConst::NOT_LOGIN);
  189. }
  190. $user = DB::table('mp_manage_users')->where('id', $uid)->first();
  191. if (!$user) {
  192. Utils::throwError(ErrorConst::USER_IS_NOT_EXIST);
  193. }
  194. return (float)getProp($user, 'points', 0);
  195. }
  196. /**
  197. * 余额预检(公共方法)
  198. *
  199. * 计算视频/其他业务所需积分数后,在创建任务前调用;
  200. * 积分不足时直接抛错(20009:积分不足),业务方无需自行处理。
  201. *
  202. * @param float|int $pointsNeeded 需要扣除的积分数
  203. * @param int $uid 用户ID,缺省取当前登录用户
  204. * @return void
  205. */
  206. public function checkUserPointsEnough($pointsNeeded, int $uid = 0): void
  207. {
  208. $pointsNeeded = (float)$pointsNeeded;
  209. if ($pointsNeeded <= 0) {
  210. return;
  211. }
  212. $balance = $this->getUserPointsBalance($uid);
  213. if ($balance < $pointsNeeded) {
  214. Utils::throwError(ErrorConst::POINTS_NOT_ENOUGH);
  215. }
  216. }
  217. /**
  218. * 视频生成成功后记录计费明细并扣减用户积分
  219. *
  220. * 幂等处理:同一任务只允许计费一次(task_id 唯一索引兜底)。
  221. *
  222. * @param MpGenerateVideoTask $task
  223. * @return array
  224. */
  225. public function recordVideoTaskCharge(MpGenerateVideoTask $task): array
  226. {
  227. // 重复计费保护
  228. $exists = DB::table('mp_user_points_details')->where('task_id', $task->id)->exists();
  229. if ($exists) {
  230. return ['charged' => false, 'reason' => 'already_charged', 'task_id' => $task->id];
  231. }
  232. $chargeInfo = $task->charge_info;
  233. if (is_string($chargeInfo)) {
  234. $chargeInfo = json_decode($chargeInfo, true);
  235. }
  236. if (!is_array($chargeInfo) || empty($chargeInfo['user_id'])) {
  237. dLog('points')->warning('视频任务缺少计费信息,跳过扣费', ['task_id' => $task->id]);
  238. return ['charged' => false, 'reason' => 'no_charge_info', 'task_id' => $task->id];
  239. }
  240. // 自动时长(-1/0)时,用接口返回的实际时长回填计费信息
  241. $requestedDuration = (int)($chargeInfo['video_duration'] ?? -1);
  242. $actualDuration = $this->getActualVideoDuration($task);
  243. $durationBackfilled = $requestedDuration <= 0 && $actualDuration > 0;
  244. if ($durationBackfilled) {
  245. $chargeInfo['video_duration'] = $actualDuration;
  246. }
  247. $uid = (int)$chargeInfo['user_id'];
  248. $points = (float)$this->getVideoChargePoints($chargeInfo);
  249. $tokens = $this->getVideoTokensConsumed($task);
  250. try {
  251. DB::beginTransaction();
  252. $user = DB::table('mp_manage_users')->where('id', $uid)->first();
  253. if (!$user) {
  254. DB::rollBack();
  255. dLog('points')->error('扣费失败:用户不存在', ['task_id' => $task->id, 'uid' => $uid]);
  256. return ['charged' => false, 'reason' => 'user_not_found', 'task_id' => $task->id];
  257. }
  258. $pointsBefore = (float)getProp($user, 'points', 0);
  259. $pointsAfter = $pointsBefore - $points;
  260. if ($pointsBefore < $points) {
  261. dLog('points')->warning('用户积分不足,扣费后积分为负数', [
  262. 'task_id' => $task->id,
  263. 'uid' => $uid,
  264. 'points_before' => $pointsBefore,
  265. 'points_consumed' => $points
  266. ]);
  267. }
  268. // 更新用户积分总额
  269. DB::table('mp_manage_users')->where('id', $uid)->update([
  270. 'points' => $pointsAfter,
  271. 'updated_at' => date('Y-m-d H:i:s')
  272. ]);
  273. // 自动时长被实际时长覆盖时,同步回填任务表的 charge_info
  274. if ($durationBackfilled) {
  275. $task->update(['charge_info' => $chargeInfo]);
  276. }
  277. // 记录积分使用明细
  278. DB::table('mp_user_points_details')->insert([
  279. 'uid' => $uid,
  280. 'task_id' => $task->id,
  281. 'type' => MpUserPointsDetail::TYPE_VIDEO,
  282. 'api_type' => getProp($task, 'api_type', ''),
  283. 'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE),
  284. 'points_consumed' => $points,
  285. 'points_before' => $pointsBefore,
  286. 'points_after' => $pointsAfter,
  287. 'tokens_consumed' => $tokens,
  288. 'remark' => '视频生成成功计费',
  289. 'created_at' => date('Y-m-d H:i:s'),
  290. 'updated_at' => date('Y-m-d H:i:s')
  291. ]);
  292. DB::commit();
  293. dLog('points')->info('视频生成计费成功', [
  294. 'task_id' => $task->id,
  295. 'uid' => $uid,
  296. 'points_consumed' => $points,
  297. 'points_after' => $pointsAfter,
  298. 'tokens_consumed' => $tokens
  299. ]);
  300. return [
  301. 'charged' => true,
  302. 'task_id' => $task->id,
  303. 'uid' => $uid,
  304. 'points_consumed' => $points,
  305. 'points_before' => $pointsBefore,
  306. 'points_after' => $pointsAfter,
  307. 'tokens_consumed' => $tokens
  308. ];
  309. } catch (\Exception $e) {
  310. DB::rollBack();
  311. dLog('points')->error('视频生成计费失败: ' . $e->getMessage(), ['task_id' => $task->id]);
  312. logDB('points', 'error', '视频生成计费失败', [
  313. 'task_id' => $task->id,
  314. 'error' => $e->getMessage()
  315. ]);
  316. return ['charged' => false, 'reason' => 'exception: ' . $e->getMessage(), 'task_id' => $task->id];
  317. }
  318. }
  319. }