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]; } } }