where('model', $model)->first(); if ($modelRow && ($modelRow->charge_type ?? '') === 'per_call') { $priceJson = $modelRow->price_json; $priceRule = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson; if (is_array($priceRule) && isset($priceRule['price']) && is_numeric($priceRule['price'])) { return (int)max(0, round((float)$priceRule['price'])); } } } return self::CHAT_CHARGE_POINTS; } /** * 获取视频模型应扣积分数(公共方法) * * 计费规则从 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; } $resKey = $this->normalizeResolutionKey($resolution); $pricePerSecond = $prices[$resKey] ?? null; // sr_1080p 无单独定价时回退 720p 档 if ($pricePerSecond === null && $resKey === 'sr_1080p') { $pricePerSecond = $prices['720p'] ?? 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; } // kuaikuai(快快AI)返回格式:data.data.usage.total_tokens / completion_tokens // result_json 中已保留规范化后的 usage 字段(queryKuaikuaiTaskStatus 同步) if ($task->api_type === 'kuaikuai') { $tokens = $resultJson['usage']['total_tokens'] ?? $resultJson['data']['data']['usage']['total_tokens'] ?? $resultJson['usage']['completion_tokens'] ?? $resultJson['data']['data']['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); } } /** * 获取系统当前全部积分规则(公共方法) * * - chat:文生文(对话)规则,含全局默认扣分与各文本模型按 model 配置的扣分 * - image_models:图片模型计费规则(mp_image_models) * - video_models:视频模型计费规则(mp_video_models) * * @return array */ public function getPointsRules(): array { $textModels = DB::table('mp_text_models')->orderBy('order', 'desc')->orderBy('id', 'desc')->where('is_enabled', 1)->get(); $imageModels = DB::table('mp_image_models')->orderBy('order', 'desc')->orderBy('id', 'desc')->where('is_enabled', 1)->get(); $videoModels = DB::table('mp_video_models')->orderBy('order', 'desc')->orderBy('id', 'desc')->where('is_enabled', 1)->get(); // $textList = []; // foreach ($textModels as $row) { // $item = $this->formatModelRule($row); // $item['charge_points'] = $this->getChatChargePoints((string)$row->model); // $textList[] = $item; // } $textModel = DB::table('mp_text_models')->where('is_enabled', 1)->whereNotNull('price_json')->first(); if ($textModel) { $price = $this->getChatChargePoints($textModel->model); $charge_type = 'per_call'; }else { $price = self::CHAT_CHARGE_POINTS; $charge_type = 'per_call'; } return [ 'chat' => [ 'charge_type' => $charge_type, 'price_json' => ['price'=>$price], // 'models' => $textList, ], 'image_models' => array_map([$this, 'formatModelRule'], $imageModels->all()), 'video_models' => array_map([$this, 'formatModelRule'], $videoModels->all()), ]; } /** * 将模型表中的计费配置行整理为前端友好结构 * * price_json 统一解析为数组返回(空配置返回空对象)。 * * @param object $row * @return array */ private function formatModelRule($row): array { $priceJson = getProp($row, 'price_json'); $price = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson; if (!is_array($price)) { $price = new \stdClass(); } return [ // 'id' => (int)getProp($row, 'id', 0), 'model' => (string)getProp($row, 'model', ''), 'name' => (string)getProp($row, 'name', ''), // 'description' => (string)getProp($row, 'description', ''), // 'is_enabled' => (int)getProp($row, 'is_enabled', 1), // 'order' => (int)getProp($row, 'order', 0), 'is_multimodal' => (int)getProp($row, 'is_multimodal', 0), 'charge_type' => (string)getProp($row, 'charge_type', ''), 'price_json' => $price, ]; } /** * 更新积分规则(公共方法) * * 支持三个区块,均可选传,未传区块不处理: * - text_rules:文生文(对话)按 model 更新(不传 model 时更新全部模型),charge_type 仅支持 per_call,price_json 为 {"price": 积分} * - image_rules:图片按 model 更新,charge_type 仅支持 per_image,price_json 为 {"1k": 积分, "2k": 积分, "4k": 积分} * - video_rules:视频按 model 更新,charge_type 支持 per_second / per_call * per_second 的 price_json 为 {"场景": {"分辨率": 单价/秒}};per_call 的 price_json 为 {"price": 积分} * * 每条规则至少需传 charge_type 或 price_json 之一;未传的字段保留原值。 * * @param array $params * @return array 各区块实际更新的 model 列表 */ public function updatePointsRules(array $params): array { $updated = ['text' => [], 'image' => [], 'video' => []]; if (isset($params['text_rules']) && is_array($params['text_rules'])) { foreach ($params['text_rules'] as $rule) { $updated['text'] = array_merge( $updated['text'], $this->updateModelRule('mp_text_models', $rule, ['per_call'], false, true) ); } } if (isset($params['image_rules']) && is_array($params['image_rules'])) { foreach ($params['image_rules'] as $rule) { $updated['image'] = array_merge( $updated['image'], $this->updateModelRule('mp_image_models', $rule, ['per_image'], false) ); } } if (isset($params['video_rules']) && is_array($params['video_rules'])) { foreach ($params['video_rules'] as $rule) { $updated['video'] = array_merge( $updated['video'], $this->updateModelRule('mp_video_models', $rule, ['per_second', 'per_call'], true) ); } } return $updated; } /** * 更新单个模型的计费配置 * * @param string $table 模型表名 * @param array $rule 规则(model / charge_type / price_json) * @param array $chargeTypes 允许的计费方式 * @param bool $nestedPrice 是否允许嵌套价格结构(视频按场景+分辨率) * @param bool $allowAll 是否允许不传 model 时更新该表全部模型(仅文生文使用) * @return array 实际更新的 model 列表 */ private function updateModelRule(string $table, array $rule, array $chargeTypes, bool $nestedPrice, bool $allowAll = false): array { $model = trim((string)getProp($rule, 'model', '')); if ($model === '' && !$allowAll) { Utils::throwError('1002:规则缺少model参数'); } $data = []; if (array_key_exists('charge_type', $rule)) { $chargeType = (string)getProp($rule, 'charge_type', ''); if (!in_array($chargeType, $chargeTypes, true)) { Utils::throwError('1002:' . $table . '计费方式不合法:' . $chargeType); } $data['charge_type'] = $chargeType; } if (array_key_exists('price_json', $rule)) { $price = getProp($rule, 'price_json', null); $priceArr = $nestedPrice ? $this->normalizeNestedPrice($price) : $this->normalizeFlatPrice($price); $data['price_json'] = json_encode($priceArr, JSON_UNESCAPED_UNICODE); } if (empty($data)) { Utils::throwError('1002:' . $table . '规则请至少传入charge_type或price_json'); } if ($model !== '') { if (!DB::table($table)->where('model', $model)->exists()) { Utils::throwError('1002:模型不存在:' . $model); } DB::table($table)->where('model', $model)->update($data); return [$model]; } // 未传 model(仅文生文支持):更新该表全部模型 $allModels = DB::table($table)->pluck('model')->all(); if (empty($allModels)) { Utils::throwError('1002:' . $table . '没有可更新的模型'); } DB::table($table)->update($data); return $allModels; } /** * 校验并归一化一层价格结构(文生文/图片) * * 所有 key 对应的值必须为不小于 0 的数字;支持传入对象或 JSON 字符串。 * * @param mixed $price * @return array */ private function normalizeFlatPrice($price): array { $priceArr = is_string($price) ? json_decode($price, true) : $price; if (!is_array($priceArr) || empty($priceArr)) { Utils::throwError('1002:price格式不正确'); } $result = []; foreach ($priceArr as $key => $value) { if (!is_numeric($value) || (float)$value < 0) { Utils::throwError('1002:price.' . $key . '必须为不小于0的数字'); } $result[(string)$key] = (float)$value; } return $result; } /** * 校验并归一化嵌套价格结构(视频按场景+分辨率) * * 支持如 {"video_generation": {"720p": 10, "1080p": 25}} 或 {"price": 5}。 * * @param mixed $price * @return array */ private function normalizeNestedPrice($price): array { $priceArr = is_string($price) ? json_decode($price, true) : $price; if (!is_array($priceArr) || empty($priceArr)) { Utils::throwError('1002:price格式不正确'); } $result = []; foreach ($priceArr as $key => $value) { if (is_array($value)) { $result[(string)$key] = $this->normalizeFlatPrice($value); } elseif (is_numeric($value) && (float)$value >= 0) { $result[(string)$key] = (float)$value; } else { Utils::throwError('1002:price.' . $key . '必须为不小于0的数字或对象'); } } return $result; } /** * 获取用户积分流水 * * @param array $params uid/type/start_date/end_date/page_size * @return array */ public function getUserPointsRecords(array $params = []): array { $pageSize = (int)getProp($params, 'page_size', 15); if ($pageSize < 1 || $pageSize > 100) { $pageSize = 15; } // 基础查询(权限 + 筛选条件),records 与 summary 共用,保证汇总随筛选条件变化 [$base, $uid] = $this->buildPointsRecordsQuery($params); $records = (clone $base)->orderBy('d.created_at', 'desc') ->orderBy('d.id', 'desc') ->paginate($pageSize); // 汇总统计:随筛选条件变化;除 total_points_consumed 外,均排除带 test_mode 标记的测试记录 $excludeTestRecords = function ($query) { $query->whereNull('d.charge_info') ->orWhereNull('d.charge_info->test_mode') ->orWhere('d.charge_info->test_mode', '!=', 'true'); }; // points_balance 始终为当前登录用户自己的余额,与查询目标无关 $loginUid = (int)Site::getUid(); $user = $loginUid ? DB::table('mp_manage_users')->where('id', $loginUid)->first() : null; $summary = [ 'points_balance' => (int)getProp($user, 'points', 0), // 消耗积分(不排除测试数据,全量口径) 'total_points_consumed' => (int)(clone $base) ->where('d.points_consumed', '>', 0) ->sum('d.points_consumed'), // 消耗积分(排除测试数据) 'total_points_consumed_filter_test' => (int)(clone $base) ->where('d.points_consumed', '>', 0) ->where($excludeTestRecords) ->sum('d.points_consumed'), 'total_points_refunded' => (int)(clone $base) ->where('d.points_consumed', '<', 0) ->whereNotIn('d.type', [MpUserPointsDetail::TYPE_SYSTEM, MpUserPointsDetail::TYPE_COMPANY]) ->sum('d.points_consumed'), 'total_points_granted' => (int)abs((clone $base) ->whereIn('d.type', [MpUserPointsDetail::TYPE_SYSTEM, MpUserPointsDetail::TYPE_COMPANY]) ->where('d.points_consumed', '<', 0) ->sum('d.points_consumed')), // 消耗 token(不排除测试数据,全量口径) 'total_tokens_consumed' => (int)(clone $base) ->sum('d.tokens_consumed'), // 消耗 token(排除测试数据) 'total_tokens_consumed_filter_test' => (int)(clone $base) ->where($excludeTestRecords) ->sum('d.tokens_consumed'), 'total_count' => (int)(clone $base) ->count('d.id'), 'total_count_filter_test' => (int)(clone $base) ->where($excludeTestRecords) ->count('d.id'), ]; return [ 'summary' => $summary, 'records' => $records, ]; } /** * 导出积分明细 CSV(与列表筛选条件一致:uid/type/start_date/end_date) * * 列:类型、模型、积分变动、变动前、变动后、Token、备注、创建时间 * 末尾追加总计行:累计消耗积分 / 累计返还积分(基于筛选后的数据) * * @param array $params * @return void */ public function exportPointsRecords(array $params): void { // 权限与筛选条件与列表一致 [$base] = $this->buildPointsRecordsQuery($params); $startDate = (string)getProp($params, 'start_date', ''); $endDate = (string)getProp($params, 'end_date', ''); $rows = (clone $base)->orderBy('d.created_at', 'desc') ->orderBy('d.id', 'desc') ->get(); $headers = ['账号', '类型', '模型', '积分变动', '变动前', '变动后', 'Token', '备注', '创建时间']; $csvRows = []; $totalConsumed = 0; $totalRefunded = 0; foreach ($rows as $row) { $chargeInfo = getProp($row, 'charge_info'); if (is_string($chargeInfo)) { $chargeInfo = json_decode($chargeInfo, true); } $chargeInfo = is_array($chargeInfo) ? $chargeInfo : []; $typeCode = (string)getProp($row, 'type', ''); $pointsConsumed = (float)getProp($row, 'points_consumed', 0); if ($pointsConsumed > 0) { $pointsChange = '-' . $pointsConsumed; $totalConsumed += $pointsConsumed; } elseif ($pointsConsumed < 0) { $pointsChange = '+' . abs($pointsConsumed); // 返还 = 非发放(system/company)的负值记录,即退款类 if (!in_array($typeCode, [MpUserPointsDetail::TYPE_SYSTEM, MpUserPointsDetail::TYPE_COMPANY], true)) { $totalRefunded += abs($pointsConsumed); } } else { $pointsChange = '0'; } $csvRows[] = [ (string)getProp($row, 'account', ''), PointsTransformer::TYPE_LABELS[$typeCode] ?? $typeCode, (string)getProp($chargeInfo, 'model', ''), $pointsChange, (string)(float)getProp($row, 'points_before', 0), (string)(float)getProp($row, 'points_after', 0), (string)(int)getProp($row, 'tokens_consumed', 0), (string)getProp($row, 'remark', ''), (string)transDate(getProp($row, 'created_at')), ]; } // 总计(最后一行单行展示) $csvRows[] = ['总计', '', '', '', '', '', '', '累计消耗积分:' . (int)$totalConsumed . ';累计返还积分:' . (int)$totalRefunded, '']; $start = $startDate ?: date('Y-m-d'); $end = $endDate ?: date('Y-m-d'); exportCsv('points_records_' . str_replace('-', '', $start) . '_' . str_replace('-', '', $end), $headers, $csvRows); } /** * 当前角色可查看的积分明细范围:superadmin 全部;admin 本组织;user 仅自己;其他抛错。 * * @return array ['cpid' => int 组织限定(0 不限定), 'uid' => int 用户限定(0 不限定)] */ private function assertCanViewPoints(): array { $role = (string)Site::getRole(); if ($role === 'superadmin') { return ['cpid' => 0, 'uid' => 0]; } if ($role === 'admin') { return ['cpid' => (int)Site::getCpid(), 'uid' => 0]; } if ($role === 'user') { return ['cpid' => (int)Site::getCpid(), 'uid' => (int)Site::getUid()]; } Utils::throwError('1005:无权查看积分明细'); } /** * 构建积分明细查询(权限范围 + uid/type/日期/nickname 筛选)。 * * @param array $params * @return array [查询构建器, 生效的 uid(0 表示多用户视角)] */ private function buildPointsRecordsQuery(array $params): array { $scope = $this->assertCanViewPoints(); $uid = (int)getProp($params, 'uid', 0); $nickname = trim((string)getProp($params, 'nickname', '')); // 普通用户仅能查看自己,忽略传入的 uid/nickname 筛选 if ($scope['uid'] > 0) { $uid = $scope['uid']; } elseif ($scope['cpid'] > 0 && !$uid && $nickname === '') { // 管理员默认查看自己的记录;传了 uid 或 nickname 时按对应条件查组内 $uid = (int)Site::getUid(); } $base = MpUserPointsDetail::from('mp_user_points_details as d') ->leftJoin('mp_manage_users as u', 'u.id', '=', 'd.uid') ->select('d.*', 'u.account'); if ($uid) { $base->where('d.uid', $uid); } // 组织范围以用户表当前所属组织为准(与 token 统计口径一致) if ($scope['cpid'] > 0) { $base->where('u.cpid', $scope['cpid']); } // 昵称/账号模糊搜索(与 token 统计一致);普通用户仅查自己,忽略 nickname 避免异常传参查不到数据 if ($nickname !== '' && $scope['uid'] <= 0) { $base->where(function ($q) use ($nickname) { $q->where('u.nickname', 'like', '%' . $nickname . '%') ->orWhere('u.account', 'like', '%' . $nickname . '%'); }); } $type = getProp($params, 'type', ''); if ($type) { $base->where('d.type', $type); } $startDate = getProp($params, 'start_date', ''); if ($startDate) { $base->where('d.created_at', '>=', $startDate . ' 00:00:00'); } $endDate = getProp($params, 'end_date', ''); if ($endDate) { $base->where('d.created_at', '<=', $endDate . ' 23:59:59'); } return [$base, $uid]; } /** * 发放/回收积分(公共方法) * * 权限规则: * - superadmin(平台):仅可对 role=admin(组织)操作,明细 type=system * - admin(组织):仅可对同 cpid 的 role=user(组员)操作,明细 type=company * - 其他角色不允许操作 * * action=grant(发放,默认):目标用户积分增加,points_consumed 记负值(积分流入) * action=revoke(回收):目标用户积分减少,需校验目标积分充足(只针对组织/组员角色),points_consumed 记正值(积分流出) * * @param array $params uid(目标用户ID,支持多个ID用英文逗号隔开,如 "142857,142858")/ points(积分数,正整数)/ action(grant|revoke,默认 grant)/ remark(可选文案) * @return array */ public function grantPoints(array $params): array { $uidRaw = trim((string)getProp($params, 'uid', '')); $points = (float)getProp($params, 'points', 0); $action = (string)getProp($params, 'action', 'grant'); $remark = trim((string)getProp($params, 'remark', '')); // 解析 uid:单个ID或多个ID(英文逗号隔开),自动去重、忽略空白 $uids = array_values(array_unique(array_filter(array_map(function ($v) { return (int)trim((string)$v); }, explode(',', $uidRaw)), function ($v) { return $v > 0; }))); if (empty($uids)) { Utils::throwError('1002:请传入目标用户uid'); } $this->validatePoints($points); $this->validateAction($action); // 单用户:保持原有返回结构 if (count($uids) === 1) { try { DB::beginTransaction(); $result = $this->applyPointsChange($uids[0], $points, $action, $remark); DB::commit(); return $result; } catch (\Exception $e) { DB::rollBack(); throw $e; } } // 多用户:先对全部目标校验(只读,不写库),任一失败整体拒绝;再单事务统一写库,保证原子性 foreach ($uids as $uid) { $this->buildGrantContext($uid, $action, $points); } try { DB::beginTransaction(); $results = []; foreach ($uids as $uid) { $results[] = $this->applyPointsChange($uid, $points, $action, $remark); } DB::commit(); } catch (\Exception $e) { DB::rollBack(); throw $e; } dLog('points')->info('积分批量操作成功', [ 'action' => $action, 'count' => count($uids), 'points' => $points, ]); return [ 'action' => $action, 'points' => $points, 'success_count' => count($results), 'results' => $results, ]; } /** * 校验积分数(正整数) * * @param float $points * @return void */ private function validatePoints(float $points): void { if ($points <= 0) { Utils::throwError('1002:积分数必须大于0'); } if (floor($points) != $points) { Utils::throwError('1002:积分数必须为整数'); } } /** * 校验操作类型 * * @param string $action * @return void */ private function validateAction(string $action): void { if (!in_array($action, ['grant', 'revoke'], true)) { Utils::throwError('1002:action仅支持grant(发放)或revoke(回收)'); } } /** * 校验并构建单用户积分操作上下文(只读) * * 校验当前操作者角色、目标用户存在/启用、目标角色与公司归属; * 回收时额外校验目标用户积分充足;管理员发放时额外校验管理员自身可用积分充足。 * * @param int $targetUid * @param string $action * @param float $points * @return array */ private function buildGrantContext(int $targetUid, string $action, float $points): array { $fromUid = (int)Site::getUid(); if ($fromUid <= 0) { Utils::throwError(ErrorConst::NOT_LOGIN); } $fromRole = (string)Site::getRole(); $fromUser = DB::table('mp_manage_users')->where('id', $fromUid)->first(); if (!$fromUser) { Utils::throwError(ErrorConst::USER_IS_NOT_EXIST); } $targetUser = DB::table('mp_manage_users')->where('id', $targetUid)->first(); if (!$targetUser) { Utils::throwError('20003:目标用户不存在'); } if ((int)getProp($targetUser, 'is_enabled', 1) === 0) { Utils::throwError('20003:目标用户已被禁用'); } $targetRole = (string)getProp($targetUser, 'role', ''); $targetCpid = (int)getProp($targetUser, 'cpid', 0); $verb = $action === 'revoke' ? '回收' : '发放'; // 角色与操作对象校验 if ($fromRole === 'superadmin') { if ($targetRole !== 'admin') { Utils::throwError('1005:平台仅可给组织' . $verb . '积分'); } $type = MpUserPointsDetail::TYPE_SYSTEM; $defaultRemark = $action === 'revoke' ? '平台回收积分' : '平台发放积分'; } elseif ($fromRole === 'admin') { if ($targetRole !== 'user') { Utils::throwError('1005:组织仅可给同公司的组员' . $verb . '积分'); } if ($targetCpid !== (int)Site::getCpid()) { Utils::throwError('1005:只能给同一公司的组员' . $verb . '积分'); } $type = MpUserPointsDetail::TYPE_COMPANY; $defaultRemark = $action === 'revoke' ? '组织回收积分' : '组织发放积分'; } else { Utils::throwError('1005:当前角色无权操作积分'); } // 回收时校验目标积分充足(目标只能是组织/组员角色,超管不在操作范围内,无需判断) if ($action === 'revoke') { $balance = (float)getProp($targetUser, 'points', 0); if ($balance < $points) { Utils::throwError('1002:目标用户积分不足,无法回收'); } } // 管理员发放:积分从管理员自身扣减转移给组员,需校验管理员可用积分充足(平台发放不校验) if ($fromRole === 'admin' && $action === 'grant') { $operatorBalance = (float)getProp($fromUser, 'points', 0); if ($operatorBalance < $points) { Utils::throwError('1002:可用积分不足,无法发放'); } } return [ 'from_uid' => $fromUid, 'from_role' => $fromRole, 'from_cpid' => (int)getProp($fromUser, 'cpid', 0), 'from_user' => $fromUser, 'target_user' => $targetUser, 'target_role' => $targetRole, 'target_cpid' => $targetCpid, 'target_uid' => $targetUid, 'type' => $type, 'default_remark' => $defaultRemark, ]; } /** * 对单个用户执行积分变动并写入明细(须在事务内调用) * * @param int $targetUid * @param float $points * @param string $action grant|revoke * @param string $remark * @return array */ private function applyPointsChange(int $targetUid, float $points, string $action, string $remark): array { $ctx = $this->buildGrantContext($targetUid, $action, $points); $finalRemark = $remark !== '' ? $ctx['default_remark'] . ':' . $remark : $ctx['default_remark']; $chargeInfo = [ 'from_uid' => $ctx['from_uid'], 'from_role' => $ctx['from_role'], 'target_uid' => $targetUid, 'target_role' => $ctx['target_role'], 'target_cpid' => $ctx['target_cpid'], 'grant_type' => $ctx['type'], 'action' => $action, ]; $pointsBefore = (float)getProp($ctx['target_user'], 'points', 0); $delta = $action === 'revoke' ? -$points : $points; $pointsAfter = $pointsBefore + $delta; $now = date('Y-m-d H:i:s'); $operatorAfter = null; // 原子更新余额,避免并发覆盖;COALESCE 处理余额为 NULL 的用户(NULL ± X 在 MySQL 中仍为 NULL) if ($ctx['from_role'] === 'admin') { // 转移模式:积分在管理员与组员之间流转,整体数额不变 $operatorBefore = (float)getProp($ctx['from_user'], 'points', 0); if ($action === 'revoke') { // 组员扣减(带余额条件),管理员增加 $affected = DB::table('mp_manage_users') ->where('id', $targetUid) ->whereRaw('COALESCE(points, 0) >= ?', [(float)$points]) ->update([ 'points' => DB::raw('COALESCE(points, 0) - ' . (float)$points), 'updated_at' => $now, ]); if ($affected === 0) { Utils::throwError('1002:目标用户积分不足,无法回收'); } DB::table('mp_manage_users')->where('id', $ctx['from_uid'])->update([ 'points' => DB::raw('COALESCE(points, 0) + ' . (float)$points), 'updated_at' => $now, ]); $operatorDelta = $points; } else { // 管理员扣减(带余额条件,防止并发超发),组员增加 $affected = DB::table('mp_manage_users') ->where('id', $ctx['from_uid']) ->whereRaw('COALESCE(points, 0) >= ?', [(float)$points]) ->update([ 'points' => DB::raw('COALESCE(points, 0) - ' . (float)$points), 'updated_at' => $now, ]); if ($affected === 0) { Utils::throwError('1002:可用积分不足,无法发放'); } DB::table('mp_manage_users')->where('id', $targetUid)->update([ 'points' => DB::raw('COALESCE(points, 0) + ' . (float)$points), 'updated_at' => $now, ]); $operatorDelta = -$points; } $operatorAfter = $operatorBefore + $operatorDelta; // 管理员侧明细(方向与组员相反):发放记支出(正),回收记收入(负) DB::table('mp_user_points_details')->insert([ 'uid' => $ctx['from_uid'], 'cpid' => $ctx['from_cpid'], 'task_id' => null, 'type' => $ctx['type'], 'api_type' => $action === 'revoke' ? 'revoke' : 'grant', 'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE), 'points_before' => $operatorBefore, 'points_consumed' => -$operatorDelta, 'points_after' => $operatorAfter, 'tokens_consumed' => 0, 'remark' => $finalRemark, 'created_at' => $now, 'updated_at' => $now, ]); } elseif ($action === 'revoke') { // 平台回收:目标(组织)扣减,带余额条件 $affected = DB::table('mp_manage_users') ->where('id', $targetUid) ->whereRaw('COALESCE(points, 0) >= ?', [(float)$points]) ->update([ 'points' => DB::raw('COALESCE(points, 0) - ' . (float)$points), 'updated_at' => $now, ]); if ($affected === 0) { Utils::throwError('1002:目标用户积分不足,无法回收'); } } else { // 平台发放:目标(组织)增加(平台为积分源头,不校验平台余额) DB::table('mp_manage_users')->where('id', $targetUid)->update([ 'points' => DB::raw('COALESCE(points, 0) + ' . (float)$points), 'updated_at' => $now, ]); } // 目标侧明细:发放 points_consumed 记负值(流入),回收记正值(流出) DB::table('mp_user_points_details')->insert([ 'uid' => $targetUid, 'cpid' => $ctx['target_cpid'], 'task_id' => null, 'type' => $ctx['type'], 'api_type' => $action === 'revoke' ? 'revoke' : 'grant', 'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE), 'points_before' => $pointsBefore, 'points_consumed' => -$delta, 'points_after' => $pointsAfter, 'tokens_consumed' => 0, 'remark' => $finalRemark, 'created_at' => $now, 'updated_at' => $now, ]); $result = [ 'uid' => $targetUid, 'success' => true, 'action' => $action, 'type' => $ctx['type'], 'points' => $points, 'points_before' => $pointsBefore, 'points_after' => $pointsAfter, 'remark' => $finalRemark, ]; if ($operatorAfter !== null) { $result['operator_uid'] = $ctx['from_uid']; $result['operator_points_before'] = $operatorBefore; $result['operator_points_after'] = $operatorAfter; } return $result; } /** * 获取可发放积分的用户列表(公共方法) * * 同一接口按当前角色返回不同列表: * - superadmin(平台):所有组织(role=admin) * - admin(组织):同 cpid 的组员(role=user) * - 其他角色不允许查看 * * @return array */ public function getGrantUserList(): array { $role = (string)Site::getRole(); $query = DB::table('mp_manage_users')->where('is_enabled', 1); if ($role === 'superadmin') { $query->where('role', 'admin'); } elseif ($role === 'admin') { $query->where('role', 'user')->where('cpid', (int)Site::getCpid()); } else { Utils::throwError('1005:当前角色无权查看发放列表'); } $users = $query->orderBy('cpid', 'asc')->orderBy('id', 'asc') ->get(['id', 'account', 'nickname', 'role', 'cpid', 'points', 'is_enabled']) ->map(function ($user) { return [ 'id' => (int)getProp($user, 'id', 0), 'account' => (string)getProp($user, 'account', ''), 'nickname' => (string)getProp($user, 'nickname', ''), // 'role' => (string)getProp($user, 'role', ''), // 'cpid' => (int)getProp($user, 'cpid', 0), 'points' => (float)getProp($user, 'points', 0), // 'is_enabled' => (int)getProp($user, 'is_enabled', 1), ]; })->all(); return $users; } /** * 获取用户当前积分余额 * * @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 ); } /** * 仅记录 token 使用明细,不扣减积分、不写积分消耗 * * 用于不按积分计费的文生文接口(免费/不扣积分场景),复用 mp_user_points_details 表: * points_consumed=0,仅填写 tokens_consumed,保证累计 token 统计口径一致。 * uid<=0(无登录上下文)或 tokens<=0 时不落库。 * * @param int $uid 用户ID * @param int $tokens 消耗 token 数 * @param array $chargeInfo 计费/来源信息(model、source 等) * @param string $type 明细类型(默认 chat) * @param string $apiType API 类型/模型标识 * @param string $remark 备注 * @return bool 是否落库成功 */ public function recordTokenOnlyDetail(int $uid, int $tokens, array $chargeInfo = [], string $type = MpUserPointsDetail::TYPE_CHAT, string $apiType = '', string $remark = ''): bool { if ($uid <= 0 || $tokens <= 0) { return false; } try { $user = DB::table('mp_manage_users')->where('id', $uid)->first(); $cpid = (int)getProp($user, 'cpid', 0); $pointsBalance = (float)getProp($user, 'points', 0); // 测试用户标记:保证统计排除口径与扣费明细一致 $isTestUser = $this->isTestUser($uid, $cpid); if ($isTestUser) { $chargeInfo['test_mode'] = true; $remark = trim(($remark ? $remark . ';' : '') . '[测试]仅记账不扣费'); } DB::table('mp_user_points_details')->insert([ 'uid' => $uid, 'cpid' => $cpid, 'task_id' => null, 'type' => $type, 'api_type' => $apiType, 'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE), 'points_consumed' => 0, 'points_before' => $pointsBalance, 'points_after' => $pointsBalance, 'tokens_consumed' => $tokens, 'remark' => $remark, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s') ]); dLog('points')->info('token明细记录成功', [ 'uid' => $uid, 'tokens_consumed' => $tokens, 'source' => $chargeInfo['source'] ?? '', 'test_mode' => $isTestUser ]); return true; } catch (\Exception $e) { dLog('points')->error('token明细记录失败: ' . $e->getMessage(), ['uid' => $uid]); logDB('points', 'error', 'token明细记录失败', [ 'uid' => $uid, 'tokens_consumed' => $tokens, 'error' => $e->getMessage() ]); return false; } } /** * 图片生成成功后记录计费明细并扣减用户积分 * * 幂等处理:同一任务只允许计费一次(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) { // 原子扣减积分:基于数据库当前值执行(points = points - X), // 避免并发处理不同任务时基于旧快照覆盖写入导致丢失更新(少扣); // COALESCE 处理余额为 NULL 的用户(NULL - X 在 MySQL 中仍为 NULL) DB::table('mp_manage_users')->where('id', $uid)->update([ 'points' => DB::raw('COALESCE(points, 0) - ' . (float)$points), '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(); // 唯一索引冲突:同一任务已被其他请求计费,视为已计费(并发验重兜底,避免误报计费失败) if ($this->isDuplicateCharge($e)) { return ['charged' => false, 'reason' => 'already_charged', 'task_id' => $taskId]; } 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]; } } /** * 判断异常是否为数据库唯一键冲突(重复计费) * * @param \Exception $e * @return bool */ private function isDuplicateCharge(\Exception $e): bool { // MySQL 重复键:SQLSTATE 23000 / 错误码 1062(Duplicate entry) $code = $e->getCode(); if ($code === 23000 || $code === '23000' || $code === 1062 || $code === '1062') { return true; } return mb_strpos($e->getMessage(), 'Duplicate entry') !== false; } }