| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434 |
- <?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
- {
- /**
- * 视频生成单次默认扣费积分数
- * 视频模型未配置计费规则时的默认扣费积分数(兜底,避免未配置模型免费)
- */
- const DEFAULT_VIDEO_CHARGE_POINTS = 0;
- /**
- * 获取视频模型应扣积分数(公共方法)
- *
- * 计费规则从 mp_video_models 表读取:
- * - 按秒计费(per_second):积分 = 单价/秒 × 视频时长(秒)
- * - 按次计费(per_call):积分 = 固定单价(如 Gemini 视频理解,暂未接入)
- * 分辨率会归一化为计费档位;“超分720p”档对应生成分辨率 480p(任务更新代码会对 480p 直接超分到 720p)。
- *
- * @param array $chargeInfo 计费信息(model、video_resolution、video_duration、mode 等)
- * @return int
- */
- public function getVideoChargePoints(array $chargeInfo = []): int
- {
- $model = (string)getProp($chargeInfo, 'model', '');
- $resolution = strtolower((string)getProp($chargeInfo, 'video_resolution', '720p'));
- $duration = (int)getProp($chargeInfo, 'video_duration', 0);
- if ($duration <= 0) {
- $duration = 1; // 时长未知时按1秒兜底
- }
- $mode = (string)getProp($chargeInfo, 'mode', 'video_generation');
- // 从 mp_video_models 表读取计费规则
- $modelRow = DB::table('mp_video_models')->where('model', $model)->first();
- if (!$modelRow || empty($modelRow->charge_type)) {
- return self::DEFAULT_VIDEO_CHARGE_POINTS;
- }
- $priceJson = $modelRow->price_json;
- $priceRule = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson;
- if (!is_array($priceRule) || empty($priceRule)) {
- return self::DEFAULT_VIDEO_CHARGE_POINTS;
- }
- // 按次计费(如 Gemini 视频理解:每次固定积分)
- if ($modelRow->charge_type === 'per_call') {
- $price = (float)($priceRule['price'] ?? self::DEFAULT_VIDEO_CHARGE_POINTS);
- return (int)max(1, round($price));
- }
- // 按秒计费:按场景取分辨率价格表(默认视频生成场景)
- $prices = $priceRule[$mode] ?? $priceRule['video_generation'] ?? [];
- if (!is_array($prices) || empty($prices)) {
- return self::DEFAULT_VIDEO_CHARGE_POINTS;
- }
- $pricePerSecond = $prices[$this->normalizeResolutionKey($resolution)] ?? null;
- if ($pricePerSecond === null || (float)$pricePerSecond <= 0) {
- return self::DEFAULT_VIDEO_CHARGE_POINTS;
- }
- return (int)max(1, round((float)$pricePerSecond * $duration));
- }
- /**
- * 将分辨率归一化为计费档位
- *
- * “超分720p”档对应生成分辨率 480p(任务更新代码会对 480p 直接超分到 720p),
- * 因此 480p / sr_720p / 超分720p 均归一化为 480p 档。
- *
- * @param string $resolution
- * @return string
- */
- private function normalizeResolutionKey(string $resolution): string
- {
- $resolution = strtolower(trim($resolution));
- switch ($resolution) {
- case '4k':
- case '2160p':
- case '4096x2160':
- return '4k';
- case '1080p':
- return '1080p';
- case '720p':
- return '720p';
- case '480p':
- return '480p';
- case 'sr_720p':
- case 'sr720p':
- case '超分720p':
- return '480p';
- case 'sr_1080p':
- case 'sr1080p':
- case '超分1080p':
- return 'sr_1080p';
- default:
- return $resolution;
- }
- }
- /**
- * 获取视频生成任务实际消耗的 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];
- }
- }
- }
|