|
|
@@ -0,0 +1,359 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+namespace App\Services;
|
|
|
+
|
|
|
+use App\Consts\ErrorConst;
|
|
|
+use App\Facade\Site;
|
|
|
+use App\Libs\Utils;
|
|
|
+use App\Models\MpGenerateVideoTask;
|
|
|
+use App\Models\MpUserPointsDetail;
|
|
|
+use Illuminate\Support\Facades\DB;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 用户积分服务
|
|
|
+ *
|
|
|
+ * 负责视频生成等业务的积分扣费、积分使用明细记录以及用户积分余额更新。
|
|
|
+ */
|
|
|
+class PointsService
|
|
|
+{
|
|
|
+ /**
|
|
|
+ * 视频生成单次默认扣费积分数
|
|
|
+ * TODO: 后续接入积分映射表,根据 model / video_resolution / 视频时长 等计算具体积分
|
|
|
+ */
|
|
|
+ const DEFAULT_VIDEO_CHARGE_POINTS = 0;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取视频生成应扣积分数(公共方法)
|
|
|
+ *
|
|
|
+ * 后续积分映射表上线后,只需调整此方法的实现,业务调用方无需改动。
|
|
|
+ *
|
|
|
+ * @param array $chargeInfo 计费信息(user_id、model、video_resolution、video_duration 等)
|
|
|
+ * @return int
|
|
|
+ */
|
|
|
+ public function getVideoChargePoints(array $chargeInfo = []): int
|
|
|
+ {
|
|
|
+ // TODO: 根据映射表(model + video_resolution + video_duration)查询具体积分数
|
|
|
+ return self::DEFAULT_VIDEO_CHARGE_POINTS;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取视频生成任务实际消耗的 token 量
|
|
|
+ *
|
|
|
+ * 优先从接口返回的 result_json.usage 中读取;
|
|
|
+ * 当前视频类接口暂未返回 token 用量,默认返回 0。
|
|
|
+ *
|
|
|
+ * @param MpGenerateVideoTask $task
|
|
|
+ * @return int
|
|
|
+ */
|
|
|
+ public function getVideoTokensConsumed(MpGenerateVideoTask $task): int
|
|
|
+ {
|
|
|
+ $resultJson = $task->result_json;
|
|
|
+ if (is_string($resultJson)) {
|
|
|
+ $resultJson = json_decode($resultJson, true);
|
|
|
+ }
|
|
|
+ if (!is_array($resultJson)) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ // zzengine(智帧/统一API)返回格式:
|
|
|
+ // data.task.detail.actual_token_total 或 data.task.detail.result.provider_token_total
|
|
|
+ // 或 data.task.detail.result.billing_snapshot.token_total
|
|
|
+ if ($task->api_type === 'zzengine') {
|
|
|
+ $tokens = $resultJson['data']['task']['detail']['actual_token_total']
|
|
|
+ ?? $resultJson['data']['task']['detail']['result']['provider_token_total']
|
|
|
+ ?? $resultJson['data']['task']['detail']['result']['billing_snapshot']['token_total']
|
|
|
+ ?? $resultJson['data']['task']['detail']['result']['billing_snapshot']['token_output']
|
|
|
+ ?? 0;
|
|
|
+ return (int)$tokens;
|
|
|
+ }
|
|
|
+
|
|
|
+ // seedance(豆包视频)返回格式:usage.total_tokens / usage.completion_tokens
|
|
|
+ if ($task->api_type === 'seedance') {
|
|
|
+ $tokens = $resultJson['usage']['total_tokens']
|
|
|
+ ?? $resultJson['usage']['completion_tokens']
|
|
|
+ ?? 0;
|
|
|
+ return (int)$tokens;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 其他API:优先从 usage 中读取
|
|
|
+ $tokens = $resultJson['usage']['total_tokens']
|
|
|
+ ?? $resultJson['usage']['completion_tokens']
|
|
|
+ ?? $resultJson['content']['usage']['total_tokens']
|
|
|
+ ?? 0;
|
|
|
+
|
|
|
+ return (int)$tokens;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从接口返回结果中获取实际视频时长(秒)
|
|
|
+ *
|
|
|
+ * 各 API 返回格式不同,按 api_type 分别解析;
|
|
|
+ * 用于 charge_info 中自动时长(-1/0)的任务在成功扣费时回填实际时长。
|
|
|
+ *
|
|
|
+ * @param MpGenerateVideoTask $task
|
|
|
+ * @return int
|
|
|
+ */
|
|
|
+ public function getActualVideoDuration(MpGenerateVideoTask $task): int
|
|
|
+ {
|
|
|
+ $resultJson = $task->result_json;
|
|
|
+ if (is_string($resultJson)) {
|
|
|
+ $resultJson = json_decode($resultJson, true);
|
|
|
+ }
|
|
|
+ if (!is_array($resultJson)) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ switch ($task->api_type) {
|
|
|
+ case 'zzengine':
|
|
|
+ return (int)($resultJson['data']['task']['detail']['duration'] ?? 0);
|
|
|
+
|
|
|
+ case 'jimeng':
|
|
|
+ $data = $resultJson['data'] ?? [];
|
|
|
+ if (!empty($data['duration'])) {
|
|
|
+ return (int)$data['duration'];
|
|
|
+ }
|
|
|
+ if (isset($data['frames'], $data['framespersecond']) && (int)$data['framespersecond'] > 0) {
|
|
|
+ return (int)floor((int)$data['frames'] / (int)$data['framespersecond']);
|
|
|
+ }
|
|
|
+ return 0;
|
|
|
+
|
|
|
+ case 'keling':
|
|
|
+ $taskData = $resultJson['data'] ?? [];
|
|
|
+ $video = $taskData['task_result']['videos'][0] ?? [];
|
|
|
+ if (!empty($video['duration'])) {
|
|
|
+ return (int)$video['duration'];
|
|
|
+ }
|
|
|
+ if (!empty($taskData['duration'])) {
|
|
|
+ return (int)$taskData['duration'];
|
|
|
+ }
|
|
|
+ if (isset($video['frames'], $video['framespersecond']) && (int)$video['framespersecond'] > 0) {
|
|
|
+ return (int)floor((int)$video['frames'] / (int)$video['framespersecond']);
|
|
|
+ }
|
|
|
+ return 0;
|
|
|
+
|
|
|
+ case 'seedance':
|
|
|
+ default:
|
|
|
+ return (int)($resultJson['duration'] ?? 0);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取用户积分流水
|
|
|
+ *
|
|
|
+ * @param array $params uid/type/start_date/end_date/page_size
|
|
|
+ * @return array
|
|
|
+ */
|
|
|
+ public function getUserPointsRecords(array $params = []): array
|
|
|
+ {
|
|
|
+ $uid = (int)getProp($params, 'uid', 0);
|
|
|
+ if (!$uid) {
|
|
|
+ $uid = (int)Site::getUid();
|
|
|
+ }
|
|
|
+
|
|
|
+ $type = getProp($params, 'type', '');
|
|
|
+ $startDate = getProp($params, 'start_date', '');
|
|
|
+ $endDate = getProp($params, 'end_date', '');
|
|
|
+ $pageSize = (int)getProp($params, 'page_size', 15);
|
|
|
+ if ($pageSize < 1 || $pageSize > 100) {
|
|
|
+ $pageSize = 15;
|
|
|
+ }
|
|
|
+
|
|
|
+ $query = MpUserPointsDetail::where('uid', $uid);
|
|
|
+ if ($type) {
|
|
|
+ $query->where('type', $type);
|
|
|
+ }
|
|
|
+ if ($startDate) {
|
|
|
+ $query->where('created_at', '>=', $startDate . ' 00:00:00');
|
|
|
+ }
|
|
|
+ if ($endDate) {
|
|
|
+ $query->where('created_at', '<=', $endDate . ' 23:59:59');
|
|
|
+ }
|
|
|
+
|
|
|
+ $records = $query->orderBy('created_at', 'desc')
|
|
|
+ ->orderBy('id', 'desc')
|
|
|
+ ->paginate($pageSize);
|
|
|
+
|
|
|
+ // 汇总统计:当前积分余额、累计消耗/退回积分、累计消耗token
|
|
|
+ $user = DB::table('mp_manage_users')->where('id', $uid)->first();
|
|
|
+ $summary = [
|
|
|
+ 'points_balance' => (float)getProp($user, 'points', 0),
|
|
|
+ 'total_points_consumed' => (float)MpUserPointsDetail::where('uid', $uid)
|
|
|
+ ->where('points_consumed', '>', 0)
|
|
|
+ ->sum('points_consumed'),
|
|
|
+ 'total_points_refunded' => (float)MpUserPointsDetail::where('uid', $uid)
|
|
|
+ ->where('points_consumed', '<', 0)
|
|
|
+ ->sum('points_consumed'),
|
|
|
+ 'total_tokens_consumed' => (int)MpUserPointsDetail::where('uid', $uid)
|
|
|
+ ->sum('tokens_consumed'),
|
|
|
+ 'total_count' => (int)MpUserPointsDetail::where('uid', $uid)->count(),
|
|
|
+ ];
|
|
|
+
|
|
|
+ return [
|
|
|
+ 'summary' => $summary,
|
|
|
+ 'records' => $records,
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取用户当前积分余额
|
|
|
+ *
|
|
|
+ * @param int $uid 用户ID,缺省取当前登录用户
|
|
|
+ * @return float
|
|
|
+ */
|
|
|
+ public function getUserPointsBalance(int $uid = 0): float
|
|
|
+ {
|
|
|
+ if (!$uid) {
|
|
|
+ $uid = (int)Site::getUid();
|
|
|
+ }
|
|
|
+ if (!$uid) {
|
|
|
+ Utils::throwError(ErrorConst::NOT_LOGIN);
|
|
|
+ }
|
|
|
+
|
|
|
+ $user = DB::table('mp_manage_users')->where('id', $uid)->first();
|
|
|
+ if (!$user) {
|
|
|
+ Utils::throwError(ErrorConst::USER_IS_NOT_EXIST);
|
|
|
+ }
|
|
|
+
|
|
|
+ return (float)getProp($user, 'points', 0);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 余额预检(公共方法)
|
|
|
+ *
|
|
|
+ * 计算视频/其他业务所需积分数后,在创建任务前调用;
|
|
|
+ * 积分不足时直接抛错(20009:积分不足),业务方无需自行处理。
|
|
|
+ *
|
|
|
+ * @param float|int $pointsNeeded 需要扣除的积分数
|
|
|
+ * @param int $uid 用户ID,缺省取当前登录用户
|
|
|
+ * @return void
|
|
|
+ */
|
|
|
+ public function checkUserPointsEnough($pointsNeeded, int $uid = 0): void
|
|
|
+ {
|
|
|
+ $pointsNeeded = (float)$pointsNeeded;
|
|
|
+ if ($pointsNeeded <= 0) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ $balance = $this->getUserPointsBalance($uid);
|
|
|
+ if ($balance < $pointsNeeded) {
|
|
|
+ Utils::throwError(ErrorConst::POINTS_NOT_ENOUGH);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 视频生成成功后记录计费明细并扣减用户积分
|
|
|
+ *
|
|
|
+ * 幂等处理:同一任务只允许计费一次(task_id 唯一索引兜底)。
|
|
|
+ *
|
|
|
+ * @param MpGenerateVideoTask $task
|
|
|
+ * @return array
|
|
|
+ */
|
|
|
+ public function recordVideoTaskCharge(MpGenerateVideoTask $task): array
|
|
|
+ {
|
|
|
+ // 重复计费保护
|
|
|
+ $exists = DB::table('mp_user_points_details')->where('task_id', $task->id)->exists();
|
|
|
+ if ($exists) {
|
|
|
+ return ['charged' => false, 'reason' => 'already_charged', 'task_id' => $task->id];
|
|
|
+ }
|
|
|
+
|
|
|
+ $chargeInfo = $task->charge_info;
|
|
|
+ if (is_string($chargeInfo)) {
|
|
|
+ $chargeInfo = json_decode($chargeInfo, true);
|
|
|
+ }
|
|
|
+ if (!is_array($chargeInfo) || empty($chargeInfo['user_id'])) {
|
|
|
+ dLog('points')->warning('视频任务缺少计费信息,跳过扣费', ['task_id' => $task->id]);
|
|
|
+ return ['charged' => false, 'reason' => 'no_charge_info', 'task_id' => $task->id];
|
|
|
+ }
|
|
|
+
|
|
|
+ // 自动时长(-1/0)时,用接口返回的实际时长回填计费信息
|
|
|
+ $requestedDuration = (int)($chargeInfo['video_duration'] ?? -1);
|
|
|
+ $actualDuration = $this->getActualVideoDuration($task);
|
|
|
+ $durationBackfilled = $requestedDuration <= 0 && $actualDuration > 0;
|
|
|
+ if ($durationBackfilled) {
|
|
|
+ $chargeInfo['video_duration'] = $actualDuration;
|
|
|
+ }
|
|
|
+
|
|
|
+ $uid = (int)$chargeInfo['user_id'];
|
|
|
+ $points = (float)$this->getVideoChargePoints($chargeInfo);
|
|
|
+ $tokens = $this->getVideoTokensConsumed($task);
|
|
|
+
|
|
|
+ try {
|
|
|
+ DB::beginTransaction();
|
|
|
+
|
|
|
+ $user = DB::table('mp_manage_users')->where('id', $uid)->first();
|
|
|
+ if (!$user) {
|
|
|
+ DB::rollBack();
|
|
|
+ dLog('points')->error('扣费失败:用户不存在', ['task_id' => $task->id, 'uid' => $uid]);
|
|
|
+ return ['charged' => false, 'reason' => 'user_not_found', 'task_id' => $task->id];
|
|
|
+ }
|
|
|
+
|
|
|
+ $pointsBefore = (float)getProp($user, 'points', 0);
|
|
|
+ $pointsAfter = $pointsBefore - $points;
|
|
|
+
|
|
|
+ if ($pointsBefore < $points) {
|
|
|
+ dLog('points')->warning('用户积分不足,扣费后积分为负数', [
|
|
|
+ 'task_id' => $task->id,
|
|
|
+ 'uid' => $uid,
|
|
|
+ 'points_before' => $pointsBefore,
|
|
|
+ 'points_consumed' => $points
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 更新用户积分总额
|
|
|
+ DB::table('mp_manage_users')->where('id', $uid)->update([
|
|
|
+ 'points' => $pointsAfter,
|
|
|
+ 'updated_at' => date('Y-m-d H:i:s')
|
|
|
+ ]);
|
|
|
+
|
|
|
+ // 自动时长被实际时长覆盖时,同步回填任务表的 charge_info
|
|
|
+ if ($durationBackfilled) {
|
|
|
+ $task->update(['charge_info' => $chargeInfo]);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 记录积分使用明细
|
|
|
+ DB::table('mp_user_points_details')->insert([
|
|
|
+ 'uid' => $uid,
|
|
|
+ 'task_id' => $task->id,
|
|
|
+ 'type' => MpUserPointsDetail::TYPE_VIDEO,
|
|
|
+ 'api_type' => getProp($task, 'api_type', ''),
|
|
|
+ 'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE),
|
|
|
+ 'points_consumed' => $points,
|
|
|
+ 'points_before' => $pointsBefore,
|
|
|
+ 'points_after' => $pointsAfter,
|
|
|
+ 'tokens_consumed' => $tokens,
|
|
|
+ 'remark' => '视频生成成功计费',
|
|
|
+ 'created_at' => date('Y-m-d H:i:s'),
|
|
|
+ 'updated_at' => date('Y-m-d H:i:s')
|
|
|
+ ]);
|
|
|
+
|
|
|
+ DB::commit();
|
|
|
+
|
|
|
+ dLog('points')->info('视频生成计费成功', [
|
|
|
+ 'task_id' => $task->id,
|
|
|
+ 'uid' => $uid,
|
|
|
+ 'points_consumed' => $points,
|
|
|
+ 'points_after' => $pointsAfter,
|
|
|
+ 'tokens_consumed' => $tokens
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return [
|
|
|
+ 'charged' => true,
|
|
|
+ 'task_id' => $task->id,
|
|
|
+ 'uid' => $uid,
|
|
|
+ 'points_consumed' => $points,
|
|
|
+ 'points_before' => $pointsBefore,
|
|
|
+ 'points_after' => $pointsAfter,
|
|
|
+ 'tokens_consumed' => $tokens
|
|
|
+ ];
|
|
|
+
|
|
|
+ } catch (\Exception $e) {
|
|
|
+ DB::rollBack();
|
|
|
+ dLog('points')->error('视频生成计费失败: ' . $e->getMessage(), ['task_id' => $task->id]);
|
|
|
+ logDB('points', 'error', '视频生成计费失败', [
|
|
|
+ 'task_id' => $task->id,
|
|
|
+ 'error' => $e->getMessage()
|
|
|
+ ]);
|
|
|
+ return ['charged' => false, 'reason' => 'exception: ' . $e->getMessage(), 'task_id' => $task->id];
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|