| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819 |
- <?php
- namespace App\Services;
- use App\Consts\ErrorConst;
- use App\Facade\Site;
- use App\Libs\PointsDisplay;
- use App\Libs\ScriptAnchor;
- use App\Libs\Utils;
- use App\Models\MpGeneratePicTask;
- use App\Models\MpGenerateVideoTask;
- use App\Models\MpUserPointsDetail;
- use App\Transformer\Points\PointsTransformer;
- use Illuminate\Support\Facades\DB;
- /**
- * 用户积分服务
- *
- * 负责视频生成等业务的积分扣费、积分使用明细记录以及用户积分余额更新。
- */
- class PointsService
- {
- /**
- * 视频生成单次默认扣费积分数
- * 视频模型未配置计费规则时的默认扣费积分数(兜底,避免未配置模型免费)
- */
- const DEFAULT_VIDEO_CHARGE_POINTS = 0;
- /**
- * AI对话(chatForAce / addChatForAce单剧集 / reGenerateAnimeForAce单剧集 / regenerateSegmentScript剧集模式)单次扣费积分数
- *
- * 该值为文生文(对话)的默认扣费积分数:
- * 各模型可在 mp_text_models.price_json 中按 model 配置 special/normal 两档({"special": 10, "normal": 3}),
- * 未配置或模型不存在时回退此默认值(special 档)。
- */
- const CHAT_CHARGE_POINTS = 10;
- /**
- * 文生文(对话)normal 档默认扣费积分数
- *
- * 其他文生文接口(chatWithFile / chatWithReasoner / chatChangeImg / generateText 纯文本等)统一按 normal 档计费,
- * 未配置或模型不存在时回退此默认值。
- */
- const NORMAL_CHAT_CHARGE_POINTS = 3;
- /**
- * 获取文生文(AI对话)单次应扣积分数(公共方法)
- *
- * 计费规则从 mp_text_models 表读取(charge_type=per_call,按次计费):
- * - 传入 model 且该模型配置了 price_json.{type} 时,返回对应档位配置值(允许 0,即免费)
- * - 兼容旧格式 price_json.price
- * - 模型未配置、模型不存在或未传 model 时,special 档返回 10、normal 档返回 3
- *
- * @param string $model 文本模型 ID(如 deepseek-reasoner / doubao-seed-2-0-mini-260215)
- * @param string $type 计费档位:special(主对话,默认)或 normal(其他文生文)
- * @return int
- */
- public function getChatChargePoints(string $model = '', string $type = 'special'): int
- {
- $defaultPoints = $type === 'normal' ? self::NORMAL_CHAT_CHARGE_POINTS : self::CHAT_CHARGE_POINTS;
- if ($model !== '') {
- $modelRow = DB::table('mp_text_models')->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)) {
- if (isset($priceRule[$type]) && is_numeric($priceRule[$type])) {
- return (int)max(0, round((float)$priceRule[$type]));
- }
- // 兼容旧格式 {"price": 10}
- if (isset($priceRule['price']) && is_numeric($priceRule['price'])) {
- return (int)max(0, round((float)$priceRule['price']));
- }
- }
- }
- }
- return $defaultPoints;
- }
- /**
- * 获取图生文(有参考图/视频输入的对话)单次应扣积分数(公共方法)
- *
- * 计费规则固定如下(未在映射表中的模型回退 normal 档,即 3):
- * - Gemini3.1 flash / pro 5 积分/次
- * - Gemini3.0 flash / pro 3 积分/次
- * - Gemini3.6 flash 10 积分/次
- * - GPT-5.4 / GPT-5.6-terra / GPT-5.6-sol / GPT-5.6-luna 3 积分/次
- * - doubao-seed-2.0 pro / lite / mini 3 积分/次
- *
- * @param string $model 文本模型 ID
- * @return int
- */
- public function getImageChatChargePoints(string $model = ''): int
- {
- $map = [
- 'gemini-3.1-flash-image-preview-t' => 5,
- 'gemini-3.1-pro-preview' => 5,
- 'gemini-3-flash-preview' => 3,
- 'gemini-3-pro-preview' => 3,
- 'gemini-3.6-flash' => 10,
- 'gpt-5.4' => 3,
- 'gpt-5.6-terra' => 3,
- 'gpt-5.6-sol' => 3,
- 'gpt-5.6-luna' => 3,
- 'doubao-seed-2-0-pro-260215' => 3,
- 'doubao-seed-2-0-lite-260215' => 3,
- 'doubao-seed-2-0-mini-260215' => 3,
- ];
- if (isset($map[$model])) {
- return $map[$model];
- }
- return $this->getChatChargePoints($model, 'normal');
- }
- /**
- * 获取视频模型应扣积分数(公共方法)
- *
- * 计费规则从 mp_video_models 表读取:
- * - 按秒计费(per_second):积分 = 单价/秒 × 视频时长(秒)
- * - 按次计费(per_call):积分 = 固定单价(如 Gemini 视频理解,暂未接入)
- * 分辨率会归一化为计费档位;480p(原始档)与 sr_720p(480p生成+本地超分720p)的生成请求均为480p,
- * 默认共用 480p 档;price_json 配置了 sr_720p 独立单价后按独立档计费(未配置时回退480p档)。
- *
- * @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;
- }
- // sr_720p 无单独定价时回退 480p 档(两者对外请求均为 480p 生成)
- if ($pricePerSecond === null && $resKey === 'sr_720p') {
- $pricePerSecond = $prices['480p'] ?? null;
- }
- if ($pricePerSecond === null || (float)$pricePerSecond <= 0) {
- return self::DEFAULT_VIDEO_CHARGE_POINTS;
- }
- return (int)max(1, round((float)$pricePerSecond * $duration));
- }
- /**
- * 将分辨率归一化为计费档位
- *
- * 480p(原始档)对应 480p 档;sr_720p / 超分720p 为独立档位,
- * price_json 未配置 sr_720p 单价时由调用方回退 480p 档(两者生成请求均为 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 'sr_720p';
- 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();
- $charge_type = 'per_call';
- if ($textModel) {
- $special = $this->getChatChargePoints($textModel->model, 'special');
- $normal = $this->getChatChargePoints($textModel->model, 'normal');
- } else {
- $special = self::CHAT_CHARGE_POINTS;
- $normal = self::NORMAL_CHAT_CHARGE_POINTS;
- }
- return [
- 'chat' => [
- 'charge_type' => $charge_type,
- 'price_json' => [
- 'special' => $special,
- 'normal' => $normal,
- ],
- // '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 为 {"special": 积分, "normal": 积分}(special=主对话档,normal=其他文生文档)
- * - 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)->where('is_deleted', 0)->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、备注、创建时间
- * 模型名做展示映射(隐藏百度/快快等供应商字样),分辨率按类型展示(视频 480p/超分720p、图片 宽*高)
- * 名称列取「动漫名优先,其次剧本名」
- * 末尾追加总计行:累计消耗积分 / 累计返还积分(基于筛选后的数据)
- *
- * @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,
- PointsDisplay::modelName((string)getProp($chargeInfo, 'model', '')),
- PointsDisplay::resolution($typeCode, $chargeInfo),
- // 名称:动漫名优先,其次剧本名
- (string)(getProp($row, 'anime_name', '') !== '' ? getProp($row, 'anime_name', '') : getProp($row, 'script_name', '')),
- $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/名称 筛选)。
- *
- * 名称筛选:anime_name,按「动漫名优先,其次剧本名」匹配(COALESCE(anime_name, script_name)),
- * 与展示字段 anime_name 口径一致;兼容旧的 script_name / name 入参(行为相同)。
- *
- * @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);
- }
- // 名称模糊搜索(anime_name):按「动漫名优先,其次剧本名」匹配(与动漫/剧本无关的记录两者为空,不会命中)
- $nameKeyword = trim((string)getProp($params, 'anime_name', ''));
- if ($nameKeyword === '') {
- $nameKeyword = trim((string)getProp($params, 'script_name', ''));
- }
- if ($nameKeyword === '') {
- $nameKeyword = trim((string)getProp($params, 'name', ''));
- }
- if ($nameKeyword !== '') {
- $base->whereRaw('COALESCE(d.anime_name, d.script_name) LIKE ?', ['%' . $nameKeyword . '%']);
- }
- $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)->where('is_deleted', 0)->first();
- if (!$fromUser) {
- Utils::throwError(ErrorConst::USER_IS_NOT_EXIST);
- }
- $targetUser = DB::table('mp_manage_users')->where('id', $targetUid)->where('is_deleted', 0)->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)->where('is_deleted', 0);
- 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)->where('is_deleted', 0)->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、3840x2160)、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)->where('is_deleted', 0)->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 . ';' : '') . '[测试]仅记账不扣费');
- }
- $scriptContext = $this->resolveScriptContext($chargeInfo, null, $type);
- // charge_info 与明细列口径一致:补写缺失的 anime_id / script_id(已有值保留)
- $chargeInfo = ScriptAnchor::fillChargeInfoAnchors($chargeInfo, $scriptContext['anime_id'], $scriptContext['script_id']);
- DB::table('mp_user_points_details')->insert([
- 'uid' => $uid,
- 'cpid' => $cpid,
- 'anime_id' => $scriptContext['anime_id'],
- 'anime_name' => $scriptContext['anime_name'],
- 'script_id' => $scriptContext['script_id'],
- 'script_name' => $scriptContext['script_name'],
- '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)->where('is_deleted', 0)->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')
- ]);
- }
- // 剧本维度(解析失败或与剧本无关时为空,不影响计费主流程)
- $scriptContext = $this->resolveScriptContext($chargeInfo, $taskId, $type);
- // charge_info 与明细列口径一致:补写缺失的 anime_id / script_id(已有值保留)
- $chargeInfo = ScriptAnchor::fillChargeInfoAnchors($chargeInfo, $scriptContext['anime_id'], $scriptContext['script_id']);
- // 记录积分使用明细
- DB::table('mp_user_points_details')->insert([
- 'uid' => $uid,
- 'cpid' => (int)getProp($user, 'cpid', 0),
- 'anime_id' => $scriptContext['anime_id'],
- 'anime_name' => $scriptContext['anime_name'],
- 'script_id' => $scriptContext['script_id'],
- 'script_name' => $scriptContext['script_name'],
- '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;
- }
- /**
- * 解析积分明细归属的动漫与剧本(anime_id + 剧本ID + 剧本名)
- *
- * 解析顺序(任一命中即返回):
- * 1. charge_info 中的动漫/剧集/剧幕/分镜锚点(ScriptAnchor 解析,含从 alias_act_id、alias_segment_id 反查)
- * → 剧本优先取动漫绑定的剧本(mp_animes.script_id)
- * 2. charge_info.script_id(动漫未绑定剧本时回退,AI对话等场景直接带剧本ID)
- * 3. charge_info.group_id → mp_script_episode_group.script_id(剧本分集分组)
- * 4. 任务表(视频/图片)的 alias_act_id / alias_segment_id(兼容未写锚点的历史任务)
- *
- * 与剧本无关(积分发放、无剧本上下文的对话等)或解析异常时返回空,
- * 动漫/剧本字段属于附加维度,任何异常都不能影响计费主流程。
- *
- * @param array $chargeInfo 计费信息
- * @param int|null $taskId 关联任务ID(视频/图片任务)
- * @param string $type 明细类型(video/image/chat/system/company)
- * @return array ['anime_id' => int|null, 'anime_name' => string|null, 'script_id' => int|null, 'script_name' => string|null]
- */
- public function resolveScriptContext(array $chargeInfo, ?int $taskId = null, string $type = ''): array
- {
- $empty = ['anime_id' => null, 'anime_name' => null, 'script_id' => null, 'script_name' => null];
- try {
- // 锚点解析:动漫/剧集/剧幕/分镜 + 剧本(动漫绑定剧本优先,其次上下文剧本ID)
- $anchor = ScriptAnchor::resolve($chargeInfo);
- $animeId = (int)$anchor['anime_id'];
- $scriptId = (int)$anchor['script_id'];
- // 动漫/剧集上下文缺失时,回查任务表保存的关联片段(历史数据兼容)
- if ($animeId <= 0 && $taskId) {
- $taskAnchor = ScriptAnchor::anchorByTask($type, (int)$taskId);
- $animeId = (int)$taskAnchor['anime_id'];
- if ($scriptId <= 0) {
- $scriptId = (int)$taskAnchor['script_id'];
- }
- }
- // 剧本仍未确定:按剧本分集分组回查
- if ($scriptId <= 0) {
- $groupId = (int)getProp($chargeInfo, 'group_id', 0);
- if ($groupId > 0) {
- $scriptId = ScriptAnchor::scriptIdByGroup($groupId);
- }
- }
- $scriptName = $scriptId > 0 ? ScriptAnchor::scriptNameById($scriptId) : '';
- $animeName = $animeId > 0 ? ScriptAnchor::animeNameById($animeId) : '';
- return [
- 'anime_id' => $animeId > 0 ? $animeId : null,
- 'anime_name' => $animeName !== '' ? $animeName : null,
- // 剧本未知或已不存在时只保留动漫维度
- 'script_id' => $scriptName !== '' ? $scriptId : null,
- 'script_name' => $scriptName !== '' ? $scriptName : null,
- ];
- } catch (\Throwable $e) {
- dLog('points')->warning('动漫/剧本信息解析失败(不影响计费): ' . $e->getMessage(), [
- 'task_id' => $taskId,
- 'type' => $type,
- ]);
- return $empty;
- }
- }
- }
|