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 // 排除带 test_mode 标记的测试记录,保证统计口径与用户表实际余额一致 $excludeTestRecords = function ($query) { $query->whereNull('charge_info') ->orWhereNull('charge_info->test_mode') ->orWhere('charge_info->test_mode', '!=', 'true'); }; $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) ->where($excludeTestRecords) ->sum('points_consumed'), 'total_points_refunded' => (float)MpUserPointsDetail::where('uid', $uid) ->where('points_consumed', '<', 0) ->where($excludeTestRecords) ->sum('points_consumed'), 'total_tokens_consumed' => (int)MpUserPointsDetail::where('uid', $uid) ->where($excludeTestRecords) ->sum('tokens_consumed'), 'total_count' => (int)MpUserPointsDetail::where('uid', $uid) ->where($excludeTestRecords) ->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; } // 测试用户(TEST_CPID / TEST_UID 白名单)跳过余额预检:只记录明细,不实际扣费 if ($this->isTestUser($uid)) { return; } $balance = $this->getUserPointsBalance($uid); if ($balance < $pointsNeeded) { Utils::throwError(ErrorConst::POINTS_NOT_ENOUGH); } } /** * 判断是否为测试用户(TEST_CPID / TEST_UID 白名单) * * TEST_CPID / TEST_UID 在 .env 中为英文逗号分隔的纯字符串(如 "1"、"142857,142858"), * 读取后先转换为数组再判断。uid 命中 TEST_UID 或 cpid 命中 TEST_CPID(任一命中)即视为测试用户: * 预检不再校验余额,扣费只记录明细、不实际扣减积分。 * * @param int $uid 用户ID,缺省取当前登录用户 * @param int $cpid 公司ID,缺省取当前上下文公司ID * @return bool */ public function isTestUser(int $uid = 0, int $cpid = 0): bool { if (!$uid) { $uid = (int)Site::getUid(); } if (!$cpid) { $cpid = (int)Site::getCpid(); } $testUids = $this->parseEnvList(env('TEST_UID')); $testCpids = $this->parseEnvList(env('TEST_CPID')); if (!empty($testUids) && in_array((string)$uid, $testUids, true)) { return true; } if (!empty($testCpids) && in_array((string)$cpid, $testCpids, true)) { return true; } return false; } /** * 将 env 中英文逗号分隔的字符串转换为数组 * * @param mixed $value * @return array */ private function parseEnvList($value): array { if ($value === null || $value === '') { return []; } $items = array_map('trim', explode(',', (string)$value)); return array_values(array_filter($items, function ($item) { return $item !== ''; })); } /** * 视频生成成功后记录计费明细并扣减用户积分 * * 幂等处理:同一任务只允许计费一次(type + task_id 唯一索引兜底)。 * * @param MpGenerateVideoTask $task * @return array */ public function recordVideoTaskCharge(MpGenerateVideoTask $task): array { // 重复计费保护 $exists = DB::table('mp_user_points_details') ->where('type', MpUserPointsDetail::TYPE_VIDEO) ->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); $result = $this->deductAndRecord( $uid, $task->id, MpUserPointsDetail::TYPE_VIDEO, (string)getProp($task, 'api_type', ''), $points, $tokens, $chargeInfo, '' ); // 自动时长被实际时长覆盖时,计费成功后同步回填任务表的 charge_info if ($durationBackfilled && !empty($result['charged'])) { $task->update(['charge_info' => $chargeInfo]); } return $result; } /** * 获取图片生成单张应扣积分数 * * 从 mp_image_models 表读取(charge_type=per_image),按分辨率档位(1k/2k/4k)取单张积分。 * * @param array $chargeInfo 计费信息(model、resolution、width、height 等) * @return int */ public function getImageChargePoints(array $chargeInfo = []): int { $model = (string)getProp($chargeInfo, 'model', ''); $resolution = strtolower((string)getProp($chargeInfo, 'resolution', '2k')); $modelRow = DB::table('mp_image_models')->where('model', $model)->first(); if (!$modelRow || ($modelRow->charge_type ?? '') !== 'per_image') { return self::DEFAULT_VIDEO_CHARGE_POINTS; } $priceJson = $modelRow->price_json; $prices = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson; if (!is_array($prices) || empty($prices)) { return self::DEFAULT_VIDEO_CHARGE_POINTS; } $price = $prices[$resolution] ?? null; if ($price === null || (float)$price <= 0) { return self::DEFAULT_VIDEO_CHARGE_POINTS; } return (int)max(1, round((float)$price)); } /** * 根据图片宽高归一化分辨率档位(1k/2k/4k) * * 按宽高乘积(面积)分档更准确:各档面积几乎成平方关系—— * 1k≈1MP(1024x1024)、2k≈4MP(2048x2048、1600x2848)、3k≈9MP(3072x3072、4096x2304)、4k≈16MP(4096x4096)。 * 目前没有 3k 计费档,3k 尺寸直接按 4k 计费。 * * @param int $width * @param int $height * @return string */ public function normalizeImageResolutionKey(int $width, int $height): string { if ($width <= 0 || $height <= 0) { return '2k'; } // 面积分档:<2MP 视为 1k;2MP~6MP 视为 2k;≥6MP(含3k/4k尺寸)按 4k 计费 $area = $width * $height; if ($area < 2048 * 1024) { return '1k'; } if ($area < 2048 * 3072) { return '2k'; } return '4k'; } /** * 获取图片生成任务实际消耗的 token 量 * * 图片接口返回格式:result_json.usage.total_tokens / output_tokens * * @param MpGeneratePicTask $task * @return int */ public function getImageTokensConsumed(MpGeneratePicTask $task): int { $resultJson = $task->result_json; if (is_string($resultJson)) { $resultJson = json_decode($resultJson, true); } if (!is_array($resultJson)) { return 0; } $tokens = $resultJson['usage']['total_tokens'] ?? $resultJson['usage']['output_tokens'] ?? 0; return (int)$tokens; } /** * 从 AI 对话接口返回的 usage 中提取消耗的 token 数 * * DeepSeek/OpenAI 格式:usage.total_tokens / usage.completion_tokens * Gemini 格式:usageMetadata.totalTokenCount(含思考 token),缺省时按 prompt + candidates 合计 * * @param mixed $usage * @return int */ public function getTokensFromUsage($usage): int { if (is_string($usage)) { $usage = json_decode($usage, true); } if (!is_array($usage)) { return 0; } $tokens = $usage['total_tokens'] ?? $usage['completion_tokens'] ?? $usage['totalTokenCount'] ?? ( isset($usage['promptTokenCount']) || isset($usage['candidatesTokenCount']) ? (int)($usage['promptTokenCount'] ?? 0) + (int)($usage['candidatesTokenCount'] ?? 0) + (int)($usage['thoughtsTokenCount'] ?? 0) : 0 ); return (int)$tokens; } /** * AI对话调用成功后记录计费明细并扣减用户积分 * * 无对应任务表,task_id 为 NULL((type, task_id) 唯一索引下多个 NULL 互不冲突)。 * * @param int $uid * @param int $points * @param int $tokens * @param array $chargeInfo * @param string $remark 特殊备注(默认空,测试用户由内部追加[测试]仅记账不扣费) * @param string $apiType * @return array */ public function recordChatCharge(int $uid, int $points, int $tokens, array $chargeInfo = [], string $remark = '', string $apiType = 'deepseek'): array { return $this->deductAndRecord( $uid, null, MpUserPointsDetail::TYPE_CHAT, $apiType, (float)$points, $tokens, $chargeInfo, $remark ); } /** * 图片生成成功后记录计费明细并扣减用户积分 * * 幂等处理:同一任务只允许计费一次(type + task_id 唯一索引兜底)。 * * @param MpGeneratePicTask $task * @return array */ public function recordImageTaskCharge(MpGeneratePicTask $task): array { // 重复计费保护 $exists = DB::table('mp_user_points_details') ->where('type', MpUserPointsDetail::TYPE_IMAGE) ->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]; } $uid = (int)$chargeInfo['user_id']; // 积分 = 单张价格 × 实际生成图片数 $pointsPerImage = (float)$this->getImageChargePoints($chargeInfo); $imageCount = is_array($task->result_url) ? count($task->result_url) : 0; if ($imageCount <= 0) { $imageCount = (int)($chargeInfo['image_num'] ?? 1); } if ($imageCount <= 0) { $imageCount = 1; } // 单张价格 × 实际生成图片数(未配置价格时为0,仍记录明细与token) $points = (float)round($pointsPerImage * $imageCount); $tokens = $this->getImageTokensConsumed($task); return $this->deductAndRecord( $uid, $task->id, MpUserPointsDetail::TYPE_IMAGE, (string)getProp($task, 'model', ''), $points, $tokens, $chargeInfo, '' ); } /** * 扣减积分并记录积分使用明细(视频/图片共用) * * @param int $uid * @param int $taskId * @param string $type * @param string $apiType * @param float $points * @param int $tokens * @param array $chargeInfo * @param string $remark * @return array */ private function deductAndRecord(int $uid, ?int $taskId, string $type, string $apiType, float $points, int $tokens, array $chargeInfo, string $remark): array { try { DB::beginTransaction(); $user = DB::table('mp_manage_users')->where('id', $uid)->first(); if (!$user) { DB::rollBack(); dLog('points')->error('扣费失败:用户不存在', ['task_id' => $taskId, 'uid' => $uid]); return ['charged' => false, 'reason' => 'user_not_found', 'task_id' => $taskId]; } $pointsBefore = (float)getProp($user, 'points', 0); // 测试用户(TEST_CPID / TEST_UID 白名单):只记录明细,不实际扣减积分余额 $isTestUser = $this->isTestUser($uid, (int)getProp($user, 'cpid', 0)); $pointsAfter = $isTestUser ? $pointsBefore : $pointsBefore - $points; // 测试标记落库:charge_info 写入 test_mode,remark 追加 [测试],便于统计排除与历史排查 if ($isTestUser) { $chargeInfo['test_mode'] = true; $remark = trim(($remark ? $remark . ';' : '') . '[测试]仅记账不扣费'); } if (!$isTestUser && $pointsBefore < $points) { dLog('points')->warning('用户积分不足,扣费后积分为负数', [ 'task_id' => $taskId, 'uid' => $uid, 'points_before' => $pointsBefore, 'points_consumed' => $points ]); } if (!$isTestUser) { // 更新用户积分总额 DB::table('mp_manage_users')->where('id', $uid)->update([ 'points' => $pointsAfter, 'updated_at' => date('Y-m-d H:i:s') ]); } // 记录积分使用明细 DB::table('mp_user_points_details')->insert([ 'uid' => $uid, 'cpid' => (int)getProp($user, 'cpid', 0), 'task_id' => $taskId, 'type' => $type, 'api_type' => $apiType, 'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE), 'points_consumed' => $points, 'points_before' => $pointsBefore, 'points_after' => $pointsAfter, 'tokens_consumed' => $tokens, 'remark' => $remark, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s') ]); DB::commit(); dLog('points')->info($type . '计费成功', [ 'task_id' => $taskId, 'uid' => $uid, 'points_consumed' => $points, 'points_after' => $pointsAfter, 'tokens_consumed' => $tokens, 'test_mode' => $isTestUser ]); return [ 'charged' => true, 'task_id' => $taskId, 'uid' => $uid, 'points_consumed' => $points, 'points_before' => $pointsBefore, 'points_after' => $pointsAfter, 'tokens_consumed' => $tokens, 'test_mode' => $isTestUser ]; } catch (\Exception $e) { DB::rollBack(); dLog('points')->error($type . '计费失败: ' . $e->getMessage(), ['task_id' => $taskId]); logDB('points', 'error', $type . '计费失败', [ 'task_id' => $taskId, 'error' => $e->getMessage() ]); return ['charged' => false, 'reason' => 'exception: ' . $e->getMessage(), 'task_id' => $taskId]; } } }