PointsService.php 57 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503
  1. <?php
  2. namespace App\Services;
  3. use App\Consts\ErrorConst;
  4. use App\Facade\Site;
  5. use App\Libs\Utils;
  6. use App\Models\MpGeneratePicTask;
  7. use App\Models\MpGenerateVideoTask;
  8. use App\Models\MpUserPointsDetail;
  9. use Illuminate\Support\Facades\DB;
  10. /**
  11. * 用户积分服务
  12. *
  13. * 负责视频生成等业务的积分扣费、积分使用明细记录以及用户积分余额更新。
  14. */
  15. class PointsService
  16. {
  17. /**
  18. * 视频生成单次默认扣费积分数
  19. * 视频模型未配置计费规则时的默认扣费积分数(兜底,避免未配置模型免费)
  20. */
  21. const DEFAULT_VIDEO_CHARGE_POINTS = 0;
  22. /**
  23. * AI对话(chatForAce / addChatForAce单剧集 / reGenerateAnimeForAce单剧集 / regenerateSegmentScript剧集模式)单次扣费积分数
  24. *
  25. * 该值为文生文(对话)的默认扣费积分数:
  26. * 各模型可在 mp_text_models.price_json 中按 model 单独配置({"price": 10}),
  27. * 未配置或模型不存在时回退此默认值。
  28. */
  29. const CHAT_CHARGE_POINTS = 10;
  30. /**
  31. * 获取文生文(AI对话)单次应扣积分数(公共方法)
  32. *
  33. * 计费规则从 mp_text_models 表读取(charge_type=per_call,按次计费):
  34. * - 传入 model 且该模型配置了 price_json.price 时,返回配置值(允许 0,即免费)
  35. * - 模型未配置、模型不存在或未传 model 时,返回默认值 10
  36. *
  37. * @param string $model 文本模型 ID(如 deepseek-reasoner / doubao-seed-2-0-mini-260215)
  38. * @return int
  39. */
  40. public function getChatChargePoints(string $model = ''): int
  41. {
  42. if ($model !== '') {
  43. $modelRow = DB::table('mp_text_models')->where('model', $model)->first();
  44. if ($modelRow && ($modelRow->charge_type ?? '') === 'per_call') {
  45. $priceJson = $modelRow->price_json;
  46. $priceRule = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson;
  47. if (is_array($priceRule) && isset($priceRule['price']) && is_numeric($priceRule['price'])) {
  48. return (int)max(0, round((float)$priceRule['price']));
  49. }
  50. }
  51. }
  52. return self::CHAT_CHARGE_POINTS;
  53. }
  54. /**
  55. * 获取视频模型应扣积分数(公共方法)
  56. *
  57. * 计费规则从 mp_video_models 表读取:
  58. * - 按秒计费(per_second):积分 = 单价/秒 × 视频时长(秒)
  59. * - 按次计费(per_call):积分 = 固定单价(如 Gemini 视频理解,暂未接入)
  60. * 分辨率会归一化为计费档位;“超分720p”档对应生成分辨率 480p(任务更新代码会对 480p 直接超分到 720p)。
  61. *
  62. * @param array $chargeInfo 计费信息(model、video_resolution、video_duration、mode 等)
  63. * @return int
  64. */
  65. public function getVideoChargePoints(array $chargeInfo = []): int
  66. {
  67. $model = (string)getProp($chargeInfo, 'model', '');
  68. $resolution = strtolower((string)getProp($chargeInfo, 'video_resolution', '720p'));
  69. $duration = (int)getProp($chargeInfo, 'video_duration', 0);
  70. if ($duration <= 0) {
  71. $duration = 1; // 时长未知时按1秒兜底
  72. }
  73. $mode = (string)getProp($chargeInfo, 'mode', 'video_generation');
  74. // 从 mp_video_models 表读取计费规则
  75. $modelRow = DB::table('mp_video_models')->where('model', $model)->first();
  76. if (!$modelRow || empty($modelRow->charge_type)) {
  77. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  78. }
  79. $priceJson = $modelRow->price_json;
  80. $priceRule = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson;
  81. if (!is_array($priceRule) || empty($priceRule)) {
  82. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  83. }
  84. // 按次计费(如 Gemini 视频理解:每次固定积分)
  85. if ($modelRow->charge_type === 'per_call') {
  86. $price = (float)($priceRule['price'] ?? self::DEFAULT_VIDEO_CHARGE_POINTS);
  87. return (int)max(1, round($price));
  88. }
  89. // 按秒计费:按场景取分辨率价格表(默认视频生成场景)
  90. $prices = $priceRule[$mode] ?? $priceRule['video_generation'] ?? [];
  91. if (!is_array($prices) || empty($prices)) {
  92. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  93. }
  94. $resKey = $this->normalizeResolutionKey($resolution);
  95. $pricePerSecond = $prices[$resKey] ?? null;
  96. // sr_1080p 无单独定价时回退 720p 档
  97. if ($pricePerSecond === null && $resKey === 'sr_1080p') {
  98. $pricePerSecond = $prices['720p'] ?? null;
  99. }
  100. if ($pricePerSecond === null || (float)$pricePerSecond <= 0) {
  101. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  102. }
  103. return (int)max(1, round((float)$pricePerSecond * $duration));
  104. }
  105. /**
  106. * 将分辨率归一化为计费档位
  107. *
  108. * “超分720p”档对应生成分辨率 480p(任务更新代码会对 480p 直接超分到 720p),
  109. * 因此 480p / sr_720p / 超分720p 均归一化为 480p 档。
  110. *
  111. * @param string $resolution
  112. * @return string
  113. */
  114. private function normalizeResolutionKey(string $resolution): string
  115. {
  116. $resolution = strtolower(trim($resolution));
  117. switch ($resolution) {
  118. case '4k':
  119. case '2160p':
  120. case '4096x2160':
  121. return '4k';
  122. case '1080p':
  123. return '1080p';
  124. case '720p':
  125. return '720p';
  126. case '480p':
  127. return '480p';
  128. case 'sr_720p':
  129. case 'sr720p':
  130. case '超分720p':
  131. return '480p';
  132. case 'sr_1080p':
  133. case 'sr1080p':
  134. case '超分1080p':
  135. return 'sr_1080p';
  136. default:
  137. return $resolution;
  138. }
  139. }
  140. /**
  141. * 获取视频生成任务实际消耗的 token 量
  142. *
  143. * 优先从接口返回的 result_json.usage 中读取;
  144. * 当前视频类接口暂未返回 token 用量,默认返回 0。
  145. *
  146. * @param MpGenerateVideoTask $task
  147. * @return int
  148. */
  149. public function getVideoTokensConsumed(MpGenerateVideoTask $task): int
  150. {
  151. $resultJson = $task->result_json;
  152. if (is_string($resultJson)) {
  153. $resultJson = json_decode($resultJson, true);
  154. }
  155. if (!is_array($resultJson)) {
  156. return 0;
  157. }
  158. // zzengine(智帧/统一API)返回格式:
  159. // data.task.detail.actual_token_total 或 data.task.detail.result.provider_token_total
  160. // 或 data.task.detail.result.billing_snapshot.token_total
  161. if ($task->api_type === 'zzengine') {
  162. $tokens = $resultJson['data']['task']['detail']['actual_token_total']
  163. ?? $resultJson['data']['task']['detail']['result']['provider_token_total']
  164. ?? $resultJson['data']['task']['detail']['result']['billing_snapshot']['token_total']
  165. ?? $resultJson['data']['task']['detail']['result']['billing_snapshot']['token_output']
  166. ?? 0;
  167. return (int)$tokens;
  168. }
  169. // seedance(豆包视频)返回格式:usage.total_tokens / usage.completion_tokens
  170. if ($task->api_type === 'seedance') {
  171. $tokens = $resultJson['usage']['total_tokens']
  172. ?? $resultJson['usage']['completion_tokens']
  173. ?? 0;
  174. return (int)$tokens;
  175. }
  176. // kuaikuai(快快AI)返回格式:data.data.usage.total_tokens / completion_tokens
  177. // result_json 中已保留规范化后的 usage 字段(queryKuaikuaiTaskStatus 同步)
  178. if ($task->api_type === 'kuaikuai') {
  179. $tokens = $resultJson['usage']['total_tokens']
  180. ?? $resultJson['data']['data']['usage']['total_tokens']
  181. ?? $resultJson['usage']['completion_tokens']
  182. ?? $resultJson['data']['data']['usage']['completion_tokens']
  183. ?? 0;
  184. return (int)$tokens;
  185. }
  186. // 其他API:优先从 usage 中读取
  187. $tokens = $resultJson['usage']['total_tokens']
  188. ?? $resultJson['usage']['completion_tokens']
  189. ?? $resultJson['content']['usage']['total_tokens']
  190. ?? 0;
  191. return (int)$tokens;
  192. }
  193. /**
  194. * 从接口返回结果中获取实际视频时长(秒)
  195. *
  196. * 各 API 返回格式不同,按 api_type 分别解析;
  197. * 用于 charge_info 中自动时长(-1/0)的任务在成功扣费时回填实际时长。
  198. *
  199. * @param MpGenerateVideoTask $task
  200. * @return int
  201. */
  202. public function getActualVideoDuration(MpGenerateVideoTask $task): int
  203. {
  204. $resultJson = $task->result_json;
  205. if (is_string($resultJson)) {
  206. $resultJson = json_decode($resultJson, true);
  207. }
  208. if (!is_array($resultJson)) {
  209. return 0;
  210. }
  211. switch ($task->api_type) {
  212. case 'zzengine':
  213. return (int)($resultJson['data']['task']['detail']['duration'] ?? 0);
  214. case 'jimeng':
  215. $data = $resultJson['data'] ?? [];
  216. if (!empty($data['duration'])) {
  217. return (int)$data['duration'];
  218. }
  219. if (isset($data['frames'], $data['framespersecond']) && (int)$data['framespersecond'] > 0) {
  220. return (int)floor((int)$data['frames'] / (int)$data['framespersecond']);
  221. }
  222. return 0;
  223. case 'keling':
  224. $taskData = $resultJson['data'] ?? [];
  225. $video = $taskData['task_result']['videos'][0] ?? [];
  226. if (!empty($video['duration'])) {
  227. return (int)$video['duration'];
  228. }
  229. if (!empty($taskData['duration'])) {
  230. return (int)$taskData['duration'];
  231. }
  232. if (isset($video['frames'], $video['framespersecond']) && (int)$video['framespersecond'] > 0) {
  233. return (int)floor((int)$video['frames'] / (int)$video['framespersecond']);
  234. }
  235. return 0;
  236. case 'seedance':
  237. default:
  238. return (int)($resultJson['duration'] ?? 0);
  239. }
  240. }
  241. /**
  242. * 获取系统当前全部积分规则(公共方法)
  243. *
  244. * - chat:文生文(对话)规则,含全局默认扣分与各文本模型按 model 配置的扣分
  245. * - image_models:图片模型计费规则(mp_image_models)
  246. * - video_models:视频模型计费规则(mp_video_models)
  247. *
  248. * @return array
  249. */
  250. public function getPointsRules(): array
  251. {
  252. $textModels = DB::table('mp_text_models')->orderBy('order', 'desc')->orderBy('id', 'desc')->where('is_enabled', 1)->get();
  253. $imageModels = DB::table('mp_image_models')->orderBy('order', 'desc')->orderBy('id', 'desc')->where('is_enabled', 1)->get();
  254. $videoModels = DB::table('mp_video_models')->orderBy('order', 'desc')->orderBy('id', 'desc')->where('is_enabled', 1)->get();
  255. // $textList = [];
  256. // foreach ($textModels as $row) {
  257. // $item = $this->formatModelRule($row);
  258. // $item['charge_points'] = $this->getChatChargePoints((string)$row->model);
  259. // $textList[] = $item;
  260. // }
  261. $textModel = DB::table('mp_text_models')->where('is_enabled', 1)->whereNotNull('price_json')->first();
  262. if ($textModel) {
  263. $price = $this->getChatChargePoints($textModel->model);
  264. $charge_type = 'per_call';
  265. }else {
  266. $price = self::CHAT_CHARGE_POINTS;
  267. $charge_type = 'per_call';
  268. }
  269. return [
  270. 'chat' => [
  271. 'charge_type' => $charge_type,
  272. 'price_json' => ['price'=>$price],
  273. // 'models' => $textList,
  274. ],
  275. 'image_models' => array_map([$this, 'formatModelRule'], $imageModels->all()),
  276. 'video_models' => array_map([$this, 'formatModelRule'], $videoModels->all()),
  277. ];
  278. }
  279. /**
  280. * 将模型表中的计费配置行整理为前端友好结构
  281. *
  282. * price_json 统一解析为数组返回(空配置返回空对象)。
  283. *
  284. * @param object $row
  285. * @return array
  286. */
  287. private function formatModelRule($row): array
  288. {
  289. $priceJson = getProp($row, 'price_json');
  290. $price = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson;
  291. if (!is_array($price)) {
  292. $price = new \stdClass();
  293. }
  294. return [
  295. // 'id' => (int)getProp($row, 'id', 0),
  296. 'model' => (string)getProp($row, 'model', ''),
  297. 'name' => (string)getProp($row, 'name', ''),
  298. // 'description' => (string)getProp($row, 'description', ''),
  299. // 'is_enabled' => (int)getProp($row, 'is_enabled', 1),
  300. // 'order' => (int)getProp($row, 'order', 0),
  301. 'is_multimodal' => (int)getProp($row, 'is_multimodal', 0),
  302. 'charge_type' => (string)getProp($row, 'charge_type', ''),
  303. 'price_json' => $price,
  304. ];
  305. }
  306. /**
  307. * 更新积分规则(公共方法)
  308. *
  309. * 支持三个区块,均可选传,未传区块不处理:
  310. * - text_rules:文生文(对话)按 model 更新(不传 model 时更新全部模型),charge_type 仅支持 per_call,price_json 为 {"price": 积分}
  311. * - image_rules:图片按 model 更新,charge_type 仅支持 per_image,price_json 为 {"1k": 积分, "2k": 积分, "4k": 积分}
  312. * - video_rules:视频按 model 更新,charge_type 支持 per_second / per_call
  313. * per_second 的 price_json 为 {"场景": {"分辨率": 单价/秒}};per_call 的 price_json 为 {"price": 积分}
  314. *
  315. * 每条规则至少需传 charge_type 或 price_json 之一;未传的字段保留原值。
  316. *
  317. * @param array $params
  318. * @return array 各区块实际更新的 model 列表
  319. */
  320. public function updatePointsRules(array $params): array
  321. {
  322. $updated = ['text' => [], 'image' => [], 'video' => []];
  323. if (isset($params['text_rules']) && is_array($params['text_rules'])) {
  324. foreach ($params['text_rules'] as $rule) {
  325. $updated['text'] = array_merge(
  326. $updated['text'],
  327. $this->updateModelRule('mp_text_models', $rule, ['per_call'], false, true)
  328. );
  329. }
  330. }
  331. if (isset($params['image_rules']) && is_array($params['image_rules'])) {
  332. foreach ($params['image_rules'] as $rule) {
  333. $updated['image'] = array_merge(
  334. $updated['image'],
  335. $this->updateModelRule('mp_image_models', $rule, ['per_image'], false)
  336. );
  337. }
  338. }
  339. if (isset($params['video_rules']) && is_array($params['video_rules'])) {
  340. foreach ($params['video_rules'] as $rule) {
  341. $updated['video'] = array_merge(
  342. $updated['video'],
  343. $this->updateModelRule('mp_video_models', $rule, ['per_second', 'per_call'], true)
  344. );
  345. }
  346. }
  347. return $updated;
  348. }
  349. /**
  350. * 更新单个模型的计费配置
  351. *
  352. * @param string $table 模型表名
  353. * @param array $rule 规则(model / charge_type / price_json)
  354. * @param array $chargeTypes 允许的计费方式
  355. * @param bool $nestedPrice 是否允许嵌套价格结构(视频按场景+分辨率)
  356. * @param bool $allowAll 是否允许不传 model 时更新该表全部模型(仅文生文使用)
  357. * @return array 实际更新的 model 列表
  358. */
  359. private function updateModelRule(string $table, array $rule, array $chargeTypes, bool $nestedPrice, bool $allowAll = false): array
  360. {
  361. $model = trim((string)getProp($rule, 'model', ''));
  362. if ($model === '' && !$allowAll) {
  363. Utils::throwError('1002:规则缺少model参数');
  364. }
  365. $data = [];
  366. if (array_key_exists('charge_type', $rule)) {
  367. $chargeType = (string)getProp($rule, 'charge_type', '');
  368. if (!in_array($chargeType, $chargeTypes, true)) {
  369. Utils::throwError('1002:' . $table . '计费方式不合法:' . $chargeType);
  370. }
  371. $data['charge_type'] = $chargeType;
  372. }
  373. if (array_key_exists('price_json', $rule)) {
  374. $price = getProp($rule, 'price_json', null);
  375. $priceArr = $nestedPrice
  376. ? $this->normalizeNestedPrice($price)
  377. : $this->normalizeFlatPrice($price);
  378. $data['price_json'] = json_encode($priceArr, JSON_UNESCAPED_UNICODE);
  379. }
  380. if (empty($data)) {
  381. Utils::throwError('1002:' . $table . '规则请至少传入charge_type或price_json');
  382. }
  383. if ($model !== '') {
  384. if (!DB::table($table)->where('model', $model)->exists()) {
  385. Utils::throwError('1002:模型不存在:' . $model);
  386. }
  387. DB::table($table)->where('model', $model)->update($data);
  388. return [$model];
  389. }
  390. // 未传 model(仅文生文支持):更新该表全部模型
  391. $allModels = DB::table($table)->pluck('model')->all();
  392. if (empty($allModels)) {
  393. Utils::throwError('1002:' . $table . '没有可更新的模型');
  394. }
  395. DB::table($table)->update($data);
  396. return $allModels;
  397. }
  398. /**
  399. * 校验并归一化一层价格结构(文生文/图片)
  400. *
  401. * 所有 key 对应的值必须为不小于 0 的数字;支持传入对象或 JSON 字符串。
  402. *
  403. * @param mixed $price
  404. * @return array
  405. */
  406. private function normalizeFlatPrice($price): array
  407. {
  408. $priceArr = is_string($price) ? json_decode($price, true) : $price;
  409. if (!is_array($priceArr) || empty($priceArr)) {
  410. Utils::throwError('1002:price格式不正确');
  411. }
  412. $result = [];
  413. foreach ($priceArr as $key => $value) {
  414. if (!is_numeric($value) || (float)$value < 0) {
  415. Utils::throwError('1002:price.' . $key . '必须为不小于0的数字');
  416. }
  417. $result[(string)$key] = (float)$value;
  418. }
  419. return $result;
  420. }
  421. /**
  422. * 校验并归一化嵌套价格结构(视频按场景+分辨率)
  423. *
  424. * 支持如 {"video_generation": {"720p": 10, "1080p": 25}} 或 {"price": 5}。
  425. *
  426. * @param mixed $price
  427. * @return array
  428. */
  429. private function normalizeNestedPrice($price): array
  430. {
  431. $priceArr = is_string($price) ? json_decode($price, true) : $price;
  432. if (!is_array($priceArr) || empty($priceArr)) {
  433. Utils::throwError('1002:price格式不正确');
  434. }
  435. $result = [];
  436. foreach ($priceArr as $key => $value) {
  437. if (is_array($value)) {
  438. $result[(string)$key] = $this->normalizeFlatPrice($value);
  439. } elseif (is_numeric($value) && (float)$value >= 0) {
  440. $result[(string)$key] = (float)$value;
  441. } else {
  442. Utils::throwError('1002:price.' . $key . '必须为不小于0的数字或对象');
  443. }
  444. }
  445. return $result;
  446. }
  447. /**
  448. * 获取用户积分流水
  449. *
  450. * @param array $params uid/type/start_date/end_date/page_size
  451. * @return array
  452. */
  453. public function getUserPointsRecords(array $params = []): array
  454. {
  455. $uid = (int)getProp($params, 'uid', 0);
  456. if (!$uid) {
  457. $uid = (int)Site::getUid();
  458. }
  459. $type = getProp($params, 'type', '');
  460. $startDate = getProp($params, 'start_date', '');
  461. $endDate = getProp($params, 'end_date', '');
  462. $pageSize = (int)getProp($params, 'page_size', 15);
  463. if ($pageSize < 1 || $pageSize > 100) {
  464. $pageSize = 15;
  465. }
  466. $query = MpUserPointsDetail::where('uid', $uid);
  467. if ($type) {
  468. $query->where('type', $type);
  469. }
  470. if ($startDate) {
  471. $query->where('created_at', '>=', $startDate . ' 00:00:00');
  472. }
  473. if ($endDate) {
  474. $query->where('created_at', '<=', $endDate . ' 23:59:59');
  475. }
  476. $records = $query->orderBy('created_at', 'desc')
  477. ->orderBy('id', 'desc')
  478. ->paginate($pageSize);
  479. // 汇总统计:当前积分余额、累计消耗/退回积分、累计消耗token
  480. // 排除带 test_mode 标记的测试记录,保证统计口径与用户表实际余额一致
  481. $excludeTestRecords = function ($query) {
  482. $query->whereNull('charge_info')
  483. ->orWhereNull('charge_info->test_mode')
  484. ->orWhere('charge_info->test_mode', '!=', 'true');
  485. };
  486. $user = DB::table('mp_manage_users')->where('id', $uid)->first();
  487. $summary = [
  488. 'points_balance' => (int)getProp($user, 'points', 0),
  489. 'total_points_consumed' => (int)MpUserPointsDetail::where('uid', $uid)
  490. ->where('points_consumed', '>', 0)
  491. ->where($excludeTestRecords)
  492. ->sum('points_consumed'),
  493. 'total_points_refunded' => (int)MpUserPointsDetail::where('uid', $uid)
  494. ->where('points_consumed', '<', 0)
  495. ->whereNotIn('type', [MpUserPointsDetail::TYPE_SYSTEM, MpUserPointsDetail::TYPE_COMPANY])
  496. ->where($excludeTestRecords)
  497. ->sum('points_consumed'),
  498. 'total_points_granted' => (int)abs(MpUserPointsDetail::where('uid', $uid)
  499. ->whereIn('type', [MpUserPointsDetail::TYPE_SYSTEM, MpUserPointsDetail::TYPE_COMPANY])
  500. ->where('points_consumed', '<', 0)
  501. ->where($excludeTestRecords)
  502. ->sum('points_consumed')),
  503. 'total_tokens_consumed' => (int)MpUserPointsDetail::where('uid', $uid)
  504. ->where($excludeTestRecords)
  505. ->sum('tokens_consumed'),
  506. 'total_count' => (int)MpUserPointsDetail::where('uid', $uid)
  507. ->where($excludeTestRecords)
  508. ->count(),
  509. ];
  510. return [
  511. 'summary' => $summary,
  512. 'records' => $records,
  513. ];
  514. }
  515. /**
  516. * 发放/回收积分(公共方法)
  517. *
  518. * 权限规则:
  519. * - superadmin(平台):仅可对 role=admin(组织)操作,明细 type=system
  520. * - admin(组织):仅可对同 cpid 的 role=user(组员)操作,明细 type=company
  521. * - 其他角色不允许操作
  522. *
  523. * action=grant(发放,默认):目标用户积分增加,points_consumed 记负值(积分流入)
  524. * action=revoke(回收):目标用户积分减少,需校验目标积分充足(只针对组织/组员角色),points_consumed 记正值(积分流出)
  525. *
  526. * @param array $params uid(目标用户ID,支持多个ID用英文逗号隔开,如 "142857,142858")/ points(积分数,正整数)/ action(grant|revoke,默认 grant)/ remark(可选文案)
  527. * @return array
  528. */
  529. public function grantPoints(array $params): array
  530. {
  531. $uidRaw = trim((string)getProp($params, 'uid', ''));
  532. $points = (float)getProp($params, 'points', 0);
  533. $action = (string)getProp($params, 'action', 'grant');
  534. $remark = trim((string)getProp($params, 'remark', ''));
  535. // 解析 uid:单个ID或多个ID(英文逗号隔开),自动去重、忽略空白
  536. $uids = array_values(array_unique(array_filter(array_map(function ($v) {
  537. return (int)trim((string)$v);
  538. }, explode(',', $uidRaw)), function ($v) {
  539. return $v > 0;
  540. })));
  541. if (empty($uids)) {
  542. Utils::throwError('1002:请传入目标用户uid');
  543. }
  544. $this->validatePoints($points);
  545. $this->validateAction($action);
  546. // 单用户:保持原有返回结构
  547. if (count($uids) === 1) {
  548. try {
  549. DB::beginTransaction();
  550. $result = $this->applyPointsChange($uids[0], $points, $action, $remark);
  551. DB::commit();
  552. return $result;
  553. } catch (\Exception $e) {
  554. DB::rollBack();
  555. throw $e;
  556. }
  557. }
  558. // 多用户:先对全部目标校验(只读,不写库),任一失败整体拒绝;再单事务统一写库,保证原子性
  559. foreach ($uids as $uid) {
  560. $this->buildGrantContext($uid, $action, $points);
  561. }
  562. try {
  563. DB::beginTransaction();
  564. $results = [];
  565. foreach ($uids as $uid) {
  566. $results[] = $this->applyPointsChange($uid, $points, $action, $remark);
  567. }
  568. DB::commit();
  569. } catch (\Exception $e) {
  570. DB::rollBack();
  571. throw $e;
  572. }
  573. dLog('points')->info('积分批量操作成功', [
  574. 'action' => $action,
  575. 'count' => count($uids),
  576. 'points' => $points,
  577. ]);
  578. return [
  579. 'action' => $action,
  580. 'points' => $points,
  581. 'success_count' => count($results),
  582. 'results' => $results,
  583. ];
  584. }
  585. /**
  586. * 校验积分数(正整数)
  587. *
  588. * @param float $points
  589. * @return void
  590. */
  591. private function validatePoints(float $points): void
  592. {
  593. if ($points <= 0) {
  594. Utils::throwError('1002:积分数必须大于0');
  595. }
  596. if (floor($points) != $points) {
  597. Utils::throwError('1002:积分数必须为整数');
  598. }
  599. }
  600. /**
  601. * 校验操作类型
  602. *
  603. * @param string $action
  604. * @return void
  605. */
  606. private function validateAction(string $action): void
  607. {
  608. if (!in_array($action, ['grant', 'revoke'], true)) {
  609. Utils::throwError('1002:action仅支持grant(发放)或revoke(回收)');
  610. }
  611. }
  612. /**
  613. * 校验并构建单用户积分操作上下文(只读)
  614. *
  615. * 校验当前操作者角色、目标用户存在/启用、目标角色与公司归属;
  616. * 回收时额外校验目标用户积分充足;管理员发放时额外校验管理员自身可用积分充足。
  617. *
  618. * @param int $targetUid
  619. * @param string $action
  620. * @param float $points
  621. * @return array
  622. */
  623. private function buildGrantContext(int $targetUid, string $action, float $points): array
  624. {
  625. $fromUid = (int)Site::getUid();
  626. if ($fromUid <= 0) {
  627. Utils::throwError(ErrorConst::NOT_LOGIN);
  628. }
  629. $fromRole = (string)Site::getRole();
  630. $fromUser = DB::table('mp_manage_users')->where('id', $fromUid)->first();
  631. if (!$fromUser) {
  632. Utils::throwError(ErrorConst::USER_IS_NOT_EXIST);
  633. }
  634. $targetUser = DB::table('mp_manage_users')->where('id', $targetUid)->first();
  635. if (!$targetUser) {
  636. Utils::throwError('20003:目标用户不存在');
  637. }
  638. if ((int)getProp($targetUser, 'is_enabled', 1) === 0) {
  639. Utils::throwError('20003:目标用户已被禁用');
  640. }
  641. $targetRole = (string)getProp($targetUser, 'role', '');
  642. $targetCpid = (int)getProp($targetUser, 'cpid', 0);
  643. $verb = $action === 'revoke' ? '回收' : '发放';
  644. // 角色与操作对象校验
  645. if ($fromRole === 'superadmin') {
  646. if ($targetRole !== 'admin') {
  647. Utils::throwError('1005:平台仅可给组织' . $verb . '积分');
  648. }
  649. $type = MpUserPointsDetail::TYPE_SYSTEM;
  650. $defaultRemark = $action === 'revoke' ? '平台回收积分' : '平台发放积分';
  651. } elseif ($fromRole === 'admin') {
  652. if ($targetRole !== 'user') {
  653. Utils::throwError('1005:组织仅可给同公司的组员' . $verb . '积分');
  654. }
  655. if ($targetCpid !== (int)Site::getCpid()) {
  656. Utils::throwError('1005:只能给同一公司的组员' . $verb . '积分');
  657. }
  658. $type = MpUserPointsDetail::TYPE_COMPANY;
  659. $defaultRemark = $action === 'revoke' ? '组织回收积分' : '组织发放积分';
  660. } else {
  661. Utils::throwError('1005:当前角色无权操作积分');
  662. }
  663. // 回收时校验目标积分充足(目标只能是组织/组员角色,超管不在操作范围内,无需判断)
  664. if ($action === 'revoke') {
  665. $balance = (float)getProp($targetUser, 'points', 0);
  666. if ($balance < $points) {
  667. Utils::throwError('1002:目标用户积分不足,无法回收');
  668. }
  669. }
  670. // 管理员发放:积分从管理员自身扣减转移给组员,需校验管理员可用积分充足(平台发放不校验)
  671. if ($fromRole === 'admin' && $action === 'grant') {
  672. $operatorBalance = (float)getProp($fromUser, 'points', 0);
  673. if ($operatorBalance < $points) {
  674. Utils::throwError('1002:可用积分不足,无法发放');
  675. }
  676. }
  677. return [
  678. 'from_uid' => $fromUid,
  679. 'from_role' => $fromRole,
  680. 'from_cpid' => (int)getProp($fromUser, 'cpid', 0),
  681. 'from_user' => $fromUser,
  682. 'target_user' => $targetUser,
  683. 'target_role' => $targetRole,
  684. 'target_cpid' => $targetCpid,
  685. 'target_uid' => $targetUid,
  686. 'type' => $type,
  687. 'default_remark' => $defaultRemark,
  688. ];
  689. }
  690. /**
  691. * 对单个用户执行积分变动并写入明细(须在事务内调用)
  692. *
  693. * @param int $targetUid
  694. * @param float $points
  695. * @param string $action grant|revoke
  696. * @param string $remark
  697. * @return array
  698. */
  699. private function applyPointsChange(int $targetUid, float $points, string $action, string $remark): array
  700. {
  701. $ctx = $this->buildGrantContext($targetUid, $action, $points);
  702. $finalRemark = $remark !== '' ? $ctx['default_remark'] . ':' . $remark : $ctx['default_remark'];
  703. $chargeInfo = [
  704. 'from_uid' => $ctx['from_uid'],
  705. 'from_role' => $ctx['from_role'],
  706. 'target_uid' => $targetUid,
  707. 'target_role' => $ctx['target_role'],
  708. 'target_cpid' => $ctx['target_cpid'],
  709. 'grant_type' => $ctx['type'],
  710. 'action' => $action,
  711. ];
  712. $pointsBefore = (float)getProp($ctx['target_user'], 'points', 0);
  713. $delta = $action === 'revoke' ? -$points : $points;
  714. $pointsAfter = $pointsBefore + $delta;
  715. $now = date('Y-m-d H:i:s');
  716. $operatorAfter = null;
  717. // 原子更新余额,避免并发覆盖;COALESCE 处理余额为 NULL 的用户(NULL ± X 在 MySQL 中仍为 NULL)
  718. if ($ctx['from_role'] === 'admin') {
  719. // 转移模式:积分在管理员与组员之间流转,整体数额不变
  720. $operatorBefore = (float)getProp($ctx['from_user'], 'points', 0);
  721. if ($action === 'revoke') {
  722. // 组员扣减(带余额条件),管理员增加
  723. $affected = DB::table('mp_manage_users')
  724. ->where('id', $targetUid)
  725. ->whereRaw('COALESCE(points, 0) >= ?', [(float)$points])
  726. ->update([
  727. 'points' => DB::raw('COALESCE(points, 0) - ' . (float)$points),
  728. 'updated_at' => $now,
  729. ]);
  730. if ($affected === 0) {
  731. Utils::throwError('1002:目标用户积分不足,无法回收');
  732. }
  733. DB::table('mp_manage_users')->where('id', $ctx['from_uid'])->update([
  734. 'points' => DB::raw('COALESCE(points, 0) + ' . (float)$points),
  735. 'updated_at' => $now,
  736. ]);
  737. $operatorDelta = $points;
  738. } else {
  739. // 管理员扣减(带余额条件,防止并发超发),组员增加
  740. $affected = DB::table('mp_manage_users')
  741. ->where('id', $ctx['from_uid'])
  742. ->whereRaw('COALESCE(points, 0) >= ?', [(float)$points])
  743. ->update([
  744. 'points' => DB::raw('COALESCE(points, 0) - ' . (float)$points),
  745. 'updated_at' => $now,
  746. ]);
  747. if ($affected === 0) {
  748. Utils::throwError('1002:可用积分不足,无法发放');
  749. }
  750. DB::table('mp_manage_users')->where('id', $targetUid)->update([
  751. 'points' => DB::raw('COALESCE(points, 0) + ' . (float)$points),
  752. 'updated_at' => $now,
  753. ]);
  754. $operatorDelta = -$points;
  755. }
  756. $operatorAfter = $operatorBefore + $operatorDelta;
  757. // 管理员侧明细(方向与组员相反):发放记支出(正),回收记收入(负)
  758. DB::table('mp_user_points_details')->insert([
  759. 'uid' => $ctx['from_uid'],
  760. 'cpid' => $ctx['from_cpid'],
  761. 'task_id' => null,
  762. 'type' => $ctx['type'],
  763. 'api_type' => $action === 'revoke' ? 'revoke' : 'grant',
  764. 'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE),
  765. 'points_before' => $operatorBefore,
  766. 'points_consumed' => -$operatorDelta,
  767. 'points_after' => $operatorAfter,
  768. 'tokens_consumed' => 0,
  769. 'remark' => $finalRemark,
  770. 'created_at' => $now,
  771. 'updated_at' => $now,
  772. ]);
  773. } elseif ($action === 'revoke') {
  774. // 平台回收:目标(组织)扣减,带余额条件
  775. $affected = DB::table('mp_manage_users')
  776. ->where('id', $targetUid)
  777. ->whereRaw('COALESCE(points, 0) >= ?', [(float)$points])
  778. ->update([
  779. 'points' => DB::raw('COALESCE(points, 0) - ' . (float)$points),
  780. 'updated_at' => $now,
  781. ]);
  782. if ($affected === 0) {
  783. Utils::throwError('1002:目标用户积分不足,无法回收');
  784. }
  785. } else {
  786. // 平台发放:目标(组织)增加(平台为积分源头,不校验平台余额)
  787. DB::table('mp_manage_users')->where('id', $targetUid)->update([
  788. 'points' => DB::raw('COALESCE(points, 0) + ' . (float)$points),
  789. 'updated_at' => $now,
  790. ]);
  791. }
  792. // 目标侧明细:发放 points_consumed 记负值(流入),回收记正值(流出)
  793. DB::table('mp_user_points_details')->insert([
  794. 'uid' => $targetUid,
  795. 'cpid' => $ctx['target_cpid'],
  796. 'task_id' => null,
  797. 'type' => $ctx['type'],
  798. 'api_type' => $action === 'revoke' ? 'revoke' : 'grant',
  799. 'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE),
  800. 'points_before' => $pointsBefore,
  801. 'points_consumed' => -$delta,
  802. 'points_after' => $pointsAfter,
  803. 'tokens_consumed' => 0,
  804. 'remark' => $finalRemark,
  805. 'created_at' => $now,
  806. 'updated_at' => $now,
  807. ]);
  808. $result = [
  809. 'uid' => $targetUid,
  810. 'success' => true,
  811. 'action' => $action,
  812. 'type' => $ctx['type'],
  813. 'points' => $points,
  814. 'points_before' => $pointsBefore,
  815. 'points_after' => $pointsAfter,
  816. 'remark' => $finalRemark,
  817. ];
  818. if ($operatorAfter !== null) {
  819. $result['operator_uid'] = $ctx['from_uid'];
  820. $result['operator_points_before'] = $operatorBefore;
  821. $result['operator_points_after'] = $operatorAfter;
  822. }
  823. return $result;
  824. }
  825. /**
  826. * 获取可发放积分的用户列表(公共方法)
  827. *
  828. * 同一接口按当前角色返回不同列表:
  829. * - superadmin(平台):所有组织(role=admin)
  830. * - admin(组织):同 cpid 的组员(role=user)
  831. * - 其他角色不允许查看
  832. *
  833. * @return array
  834. */
  835. public function getGrantUserList(): array
  836. {
  837. $role = (string)Site::getRole();
  838. $query = DB::table('mp_manage_users')->where('is_enabled', 1);
  839. if ($role === 'superadmin') {
  840. $query->where('role', 'admin');
  841. } elseif ($role === 'admin') {
  842. $query->where('role', 'user')->where('cpid', (int)Site::getCpid());
  843. } else {
  844. Utils::throwError('1005:当前角色无权查看发放列表');
  845. }
  846. $users = $query->orderBy('cpid', 'asc')->orderBy('id', 'asc')
  847. ->get(['id', 'account', 'nickname', 'role', 'cpid', 'points', 'is_enabled'])
  848. ->map(function ($user) {
  849. return [
  850. 'id' => (int)getProp($user, 'id', 0),
  851. 'account' => (string)getProp($user, 'account', ''),
  852. 'nickname' => (string)getProp($user, 'nickname', ''),
  853. // 'role' => (string)getProp($user, 'role', ''),
  854. // 'cpid' => (int)getProp($user, 'cpid', 0),
  855. 'points' => (float)getProp($user, 'points', 0),
  856. // 'is_enabled' => (int)getProp($user, 'is_enabled', 1),
  857. ];
  858. })->all();
  859. return $users;
  860. }
  861. /**
  862. * 获取用户当前积分余额
  863. *
  864. * @param int $uid 用户ID,缺省取当前登录用户
  865. * @return float
  866. */
  867. public function getUserPointsBalance(int $uid = 0): float
  868. {
  869. if (!$uid) {
  870. $uid = (int)Site::getUid();
  871. }
  872. if (!$uid) {
  873. Utils::throwError(ErrorConst::NOT_LOGIN);
  874. }
  875. $user = DB::table('mp_manage_users')->where('id', $uid)->first();
  876. if (!$user) {
  877. Utils::throwError(ErrorConst::USER_IS_NOT_EXIST);
  878. }
  879. return (float)getProp($user, 'points', 0);
  880. }
  881. /**
  882. * 余额预检(公共方法)
  883. *
  884. * 计算视频/其他业务所需积分数后,在创建任务前调用;
  885. * 积分不足时直接抛错(20009:积分不足),业务方无需自行处理。
  886. *
  887. * @param float|int $pointsNeeded 需要扣除的积分数
  888. * @param int $uid 用户ID,缺省取当前登录用户
  889. * @return void
  890. */
  891. public function checkUserPointsEnough($pointsNeeded, int $uid = 0): void
  892. {
  893. $pointsNeeded = (float)$pointsNeeded;
  894. if ($pointsNeeded <= 0) {
  895. return;
  896. }
  897. // 测试用户(TEST_CPID / TEST_UID 白名单)跳过余额预检:只记录明细,不实际扣费
  898. if ($this->isTestUser($uid)) {
  899. return;
  900. }
  901. $balance = $this->getUserPointsBalance($uid);
  902. if ($balance < $pointsNeeded) {
  903. Utils::throwError(ErrorConst::POINTS_NOT_ENOUGH);
  904. }
  905. }
  906. /**
  907. * 判断是否为测试用户(TEST_CPID / TEST_UID 白名单)
  908. *
  909. * TEST_CPID / TEST_UID 在 .env 中为英文逗号分隔的纯字符串(如 "1"、"142857,142858"),
  910. * 读取后先转换为数组再判断。uid 命中 TEST_UID 或 cpid 命中 TEST_CPID(任一命中)即视为测试用户:
  911. * 预检不再校验余额,扣费只记录明细、不实际扣减积分。
  912. *
  913. * @param int $uid 用户ID,缺省取当前登录用户
  914. * @param int $cpid 公司ID,缺省取当前上下文公司ID
  915. * @return bool
  916. */
  917. public function isTestUser(int $uid = 0, int $cpid = 0): bool
  918. {
  919. if (!$uid) {
  920. $uid = (int)Site::getUid();
  921. }
  922. if (!$cpid) {
  923. $cpid = (int)Site::getCpid();
  924. }
  925. $testUids = $this->parseEnvList(env('TEST_UID'));
  926. $testCpids = $this->parseEnvList(env('TEST_CPID'));
  927. if (!empty($testUids) && in_array((string)$uid, $testUids, true)) {
  928. return true;
  929. }
  930. if (!empty($testCpids) && in_array((string)$cpid, $testCpids, true)) {
  931. return true;
  932. }
  933. return false;
  934. }
  935. /**
  936. * 将 env 中英文逗号分隔的字符串转换为数组
  937. *
  938. * @param mixed $value
  939. * @return array
  940. */
  941. private function parseEnvList($value): array
  942. {
  943. if ($value === null || $value === '') {
  944. return [];
  945. }
  946. $items = array_map('trim', explode(',', (string)$value));
  947. return array_values(array_filter($items, function ($item) {
  948. return $item !== '';
  949. }));
  950. }
  951. /**
  952. * 视频生成成功后记录计费明细并扣减用户积分
  953. *
  954. * 幂等处理:同一任务只允许计费一次(type + task_id 唯一索引兜底)。
  955. *
  956. * @param MpGenerateVideoTask $task
  957. * @return array
  958. */
  959. public function recordVideoTaskCharge(MpGenerateVideoTask $task): array
  960. {
  961. // 重复计费保护
  962. $exists = DB::table('mp_user_points_details')
  963. ->where('type', MpUserPointsDetail::TYPE_VIDEO)
  964. ->where('task_id', $task->id)
  965. ->exists();
  966. if ($exists) {
  967. return ['charged' => false, 'reason' => 'already_charged', 'task_id' => $task->id];
  968. }
  969. $chargeInfo = $task->charge_info;
  970. if (is_string($chargeInfo)) {
  971. $chargeInfo = json_decode($chargeInfo, true);
  972. }
  973. if (!is_array($chargeInfo) || empty($chargeInfo['user_id'])) {
  974. dLog('points')->warning('视频任务缺少计费信息,跳过扣费', ['task_id' => $task->id]);
  975. return ['charged' => false, 'reason' => 'no_charge_info', 'task_id' => $task->id];
  976. }
  977. // 自动时长(-1/0)时,用接口返回的实际时长回填计费信息
  978. $requestedDuration = (int)($chargeInfo['video_duration'] ?? -1);
  979. $actualDuration = $this->getActualVideoDuration($task);
  980. $durationBackfilled = $requestedDuration <= 0 && $actualDuration > 0;
  981. if ($durationBackfilled) {
  982. $chargeInfo['video_duration'] = $actualDuration;
  983. }
  984. $uid = (int)$chargeInfo['user_id'];
  985. $points = (float)$this->getVideoChargePoints($chargeInfo);
  986. $tokens = $this->getVideoTokensConsumed($task);
  987. $result = $this->deductAndRecord(
  988. $uid,
  989. $task->id,
  990. MpUserPointsDetail::TYPE_VIDEO,
  991. (string)getProp($task, 'api_type', ''),
  992. $points,
  993. $tokens,
  994. $chargeInfo,
  995. ''
  996. );
  997. // 自动时长被实际时长覆盖时,计费成功后同步回填任务表的 charge_info
  998. if ($durationBackfilled && !empty($result['charged'])) {
  999. $task->update(['charge_info' => $chargeInfo]);
  1000. }
  1001. return $result;
  1002. }
  1003. /**
  1004. * 获取图片生成单张应扣积分数
  1005. *
  1006. * 从 mp_image_models 表读取(charge_type=per_image),按分辨率档位(1k/2k/4k)取单张积分。
  1007. *
  1008. * @param array $chargeInfo 计费信息(model、resolution、width、height 等)
  1009. * @return int
  1010. */
  1011. public function getImageChargePoints(array $chargeInfo = []): int
  1012. {
  1013. $model = (string)getProp($chargeInfo, 'model', '');
  1014. $resolution = strtolower((string)getProp($chargeInfo, 'resolution', '2k'));
  1015. $modelRow = DB::table('mp_image_models')->where('model', $model)->first();
  1016. if (!$modelRow || ($modelRow->charge_type ?? '') !== 'per_image') {
  1017. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  1018. }
  1019. $priceJson = $modelRow->price_json;
  1020. $prices = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson;
  1021. if (!is_array($prices) || empty($prices)) {
  1022. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  1023. }
  1024. $price = $prices[$resolution] ?? null;
  1025. if ($price === null || (float)$price <= 0) {
  1026. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  1027. }
  1028. return (int)max(1, round((float)$price));
  1029. }
  1030. /**
  1031. * 根据图片宽高归一化分辨率档位(1k/2k/4k)
  1032. *
  1033. * 按宽高乘积(面积)分档更准确:各档面积几乎成平方关系——
  1034. * 1k≈1MP(1024x1024)、2k≈4MP(2048x2048、1600x2848)、3k≈9MP(3072x3072、4096x2304)、4k≈16MP(4096x4096)。
  1035. * 目前没有 3k 计费档,3k 尺寸直接按 4k 计费。
  1036. *
  1037. * @param int $width
  1038. * @param int $height
  1039. * @return string
  1040. */
  1041. public function normalizeImageResolutionKey(int $width, int $height): string
  1042. {
  1043. if ($width <= 0 || $height <= 0) {
  1044. return '2k';
  1045. }
  1046. // 面积分档:<2MP 视为 1k;2MP~6MP 视为 2k;≥6MP(含3k/4k尺寸)按 4k 计费
  1047. $area = $width * $height;
  1048. if ($area < 2048 * 1024) {
  1049. return '1k';
  1050. }
  1051. if ($area < 2048 * 3072) {
  1052. return '2k';
  1053. }
  1054. return '4k';
  1055. }
  1056. /**
  1057. * 获取图片生成任务实际消耗的 token 量
  1058. *
  1059. * 图片接口返回格式:result_json.usage.total_tokens / output_tokens
  1060. *
  1061. * @param MpGeneratePicTask $task
  1062. * @return int
  1063. */
  1064. public function getImageTokensConsumed(MpGeneratePicTask $task): int
  1065. {
  1066. $resultJson = $task->result_json;
  1067. if (is_string($resultJson)) {
  1068. $resultJson = json_decode($resultJson, true);
  1069. }
  1070. if (!is_array($resultJson)) {
  1071. return 0;
  1072. }
  1073. $tokens = $resultJson['usage']['total_tokens'] ?? $resultJson['usage']['output_tokens'] ?? 0;
  1074. return (int)$tokens;
  1075. }
  1076. /**
  1077. * 从 AI 对话接口返回的 usage 中提取消耗的 token 数
  1078. *
  1079. * DeepSeek/OpenAI 格式:usage.total_tokens / usage.completion_tokens
  1080. * Gemini 格式:usageMetadata.totalTokenCount(含思考 token),缺省时按 prompt + candidates 合计
  1081. *
  1082. * @param mixed $usage
  1083. * @return int
  1084. */
  1085. public function getTokensFromUsage($usage): int
  1086. {
  1087. if (is_string($usage)) {
  1088. $usage = json_decode($usage, true);
  1089. }
  1090. if (!is_array($usage)) {
  1091. return 0;
  1092. }
  1093. $tokens = $usage['total_tokens']
  1094. ?? $usage['completion_tokens']
  1095. ?? $usage['totalTokenCount']
  1096. ?? (
  1097. isset($usage['promptTokenCount']) || isset($usage['candidatesTokenCount'])
  1098. ? (int)($usage['promptTokenCount'] ?? 0)
  1099. + (int)($usage['candidatesTokenCount'] ?? 0)
  1100. + (int)($usage['thoughtsTokenCount'] ?? 0)
  1101. : 0
  1102. );
  1103. return (int)$tokens;
  1104. }
  1105. /**
  1106. * AI对话调用成功后记录计费明细并扣减用户积分
  1107. *
  1108. * 无对应任务表,task_id 为 NULL((type, task_id) 唯一索引下多个 NULL 互不冲突)。
  1109. *
  1110. * @param int $uid
  1111. * @param int $points
  1112. * @param int $tokens
  1113. * @param array $chargeInfo
  1114. * @param string $remark 特殊备注(默认空,测试用户由内部追加[测试]仅记账不扣费)
  1115. * @param string $apiType
  1116. * @return array
  1117. */
  1118. public function recordChatCharge(int $uid, int $points, int $tokens, array $chargeInfo = [], string $remark = '', string $apiType = 'deepseek'): array
  1119. {
  1120. return $this->deductAndRecord(
  1121. $uid,
  1122. null,
  1123. MpUserPointsDetail::TYPE_CHAT,
  1124. $apiType,
  1125. (float)$points,
  1126. $tokens,
  1127. $chargeInfo,
  1128. $remark
  1129. );
  1130. }
  1131. /**
  1132. * 仅记录 token 使用明细,不扣减积分、不写积分消耗
  1133. *
  1134. * 用于不按积分计费的文生文接口(免费/不扣积分场景),复用 mp_user_points_details 表:
  1135. * points_consumed=0,仅填写 tokens_consumed,保证累计 token 统计口径一致。
  1136. * uid<=0(无登录上下文)或 tokens<=0 时不落库。
  1137. *
  1138. * @param int $uid 用户ID
  1139. * @param int $tokens 消耗 token 数
  1140. * @param array $chargeInfo 计费/来源信息(model、source 等)
  1141. * @param string $type 明细类型(默认 chat)
  1142. * @param string $apiType API 类型/模型标识
  1143. * @param string $remark 备注
  1144. * @return bool 是否落库成功
  1145. */
  1146. public function recordTokenOnlyDetail(int $uid, int $tokens, array $chargeInfo = [], string $type = MpUserPointsDetail::TYPE_CHAT, string $apiType = '', string $remark = ''): bool
  1147. {
  1148. if ($uid <= 0 || $tokens <= 0) {
  1149. return false;
  1150. }
  1151. try {
  1152. $user = DB::table('mp_manage_users')->where('id', $uid)->first();
  1153. $cpid = (int)getProp($user, 'cpid', 0);
  1154. $pointsBalance = (float)getProp($user, 'points', 0);
  1155. // 测试用户标记:保证统计排除口径与扣费明细一致
  1156. $isTestUser = $this->isTestUser($uid, $cpid);
  1157. if ($isTestUser) {
  1158. $chargeInfo['test_mode'] = true;
  1159. $remark = trim(($remark ? $remark . ';' : '') . '[测试]仅记账不扣费');
  1160. }
  1161. DB::table('mp_user_points_details')->insert([
  1162. 'uid' => $uid,
  1163. 'cpid' => $cpid,
  1164. 'task_id' => null,
  1165. 'type' => $type,
  1166. 'api_type' => $apiType,
  1167. 'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE),
  1168. 'points_consumed' => 0,
  1169. 'points_before' => $pointsBalance,
  1170. 'points_after' => $pointsBalance,
  1171. 'tokens_consumed' => $tokens,
  1172. 'remark' => $remark,
  1173. 'created_at' => date('Y-m-d H:i:s'),
  1174. 'updated_at' => date('Y-m-d H:i:s')
  1175. ]);
  1176. dLog('points')->info('token明细记录成功', [
  1177. 'uid' => $uid,
  1178. 'tokens_consumed' => $tokens,
  1179. 'source' => $chargeInfo['source'] ?? '',
  1180. 'test_mode' => $isTestUser
  1181. ]);
  1182. return true;
  1183. } catch (\Exception $e) {
  1184. dLog('points')->error('token明细记录失败: ' . $e->getMessage(), ['uid' => $uid]);
  1185. logDB('points', 'error', 'token明细记录失败', [
  1186. 'uid' => $uid,
  1187. 'tokens_consumed' => $tokens,
  1188. 'error' => $e->getMessage()
  1189. ]);
  1190. return false;
  1191. }
  1192. }
  1193. /**
  1194. * 图片生成成功后记录计费明细并扣减用户积分
  1195. *
  1196. * 幂等处理:同一任务只允许计费一次(type + task_id 唯一索引兜底)。
  1197. *
  1198. * @param MpGeneratePicTask $task
  1199. * @return array
  1200. */
  1201. public function recordImageTaskCharge(MpGeneratePicTask $task): array
  1202. {
  1203. // 重复计费保护
  1204. $exists = DB::table('mp_user_points_details')
  1205. ->where('type', MpUserPointsDetail::TYPE_IMAGE)
  1206. ->where('task_id', $task->id)
  1207. ->exists();
  1208. if ($exists) {
  1209. return ['charged' => false, 'reason' => 'already_charged', 'task_id' => $task->id];
  1210. }
  1211. $chargeInfo = $task->charge_info;
  1212. if (is_string($chargeInfo)) {
  1213. $chargeInfo = json_decode($chargeInfo, true);
  1214. }
  1215. if (!is_array($chargeInfo) || empty($chargeInfo['user_id'])) {
  1216. dLog('points')->warning('图片任务缺少计费信息,跳过扣费', ['task_id' => $task->id]);
  1217. return ['charged' => false, 'reason' => 'no_charge_info', 'task_id' => $task->id];
  1218. }
  1219. $uid = (int)$chargeInfo['user_id'];
  1220. // 积分 = 单张价格 × 实际生成图片数
  1221. $pointsPerImage = (float)$this->getImageChargePoints($chargeInfo);
  1222. $imageCount = is_array($task->result_url) ? count($task->result_url) : 0;
  1223. if ($imageCount <= 0) {
  1224. $imageCount = (int)($chargeInfo['image_num'] ?? 1);
  1225. }
  1226. if ($imageCount <= 0) {
  1227. $imageCount = 1;
  1228. }
  1229. // 单张价格 × 实际生成图片数(未配置价格时为0,仍记录明细与token)
  1230. $points = (float)round($pointsPerImage * $imageCount);
  1231. $tokens = $this->getImageTokensConsumed($task);
  1232. return $this->deductAndRecord(
  1233. $uid,
  1234. $task->id,
  1235. MpUserPointsDetail::TYPE_IMAGE,
  1236. (string)getProp($task, 'model', ''),
  1237. $points,
  1238. $tokens,
  1239. $chargeInfo,
  1240. ''
  1241. );
  1242. }
  1243. /**
  1244. * 扣减积分并记录积分使用明细(视频/图片共用)
  1245. *
  1246. * @param int $uid
  1247. * @param int $taskId
  1248. * @param string $type
  1249. * @param string $apiType
  1250. * @param float $points
  1251. * @param int $tokens
  1252. * @param array $chargeInfo
  1253. * @param string $remark
  1254. * @return array
  1255. */
  1256. private function deductAndRecord(int $uid, ?int $taskId, string $type, string $apiType, float $points, int $tokens, array $chargeInfo, string $remark): array
  1257. {
  1258. try {
  1259. DB::beginTransaction();
  1260. $user = DB::table('mp_manage_users')->where('id', $uid)->first();
  1261. if (!$user) {
  1262. DB::rollBack();
  1263. dLog('points')->error('扣费失败:用户不存在', ['task_id' => $taskId, 'uid' => $uid]);
  1264. return ['charged' => false, 'reason' => 'user_not_found', 'task_id' => $taskId];
  1265. }
  1266. $pointsBefore = (float)getProp($user, 'points', 0);
  1267. // 测试用户(TEST_CPID / TEST_UID 白名单):只记录明细,不实际扣减积分余额
  1268. $isTestUser = $this->isTestUser($uid, (int)getProp($user, 'cpid', 0));
  1269. $pointsAfter = $isTestUser ? $pointsBefore : $pointsBefore - $points;
  1270. // 测试标记落库:charge_info 写入 test_mode,remark 追加 [测试],便于统计排除与历史排查
  1271. if ($isTestUser) {
  1272. $chargeInfo['test_mode'] = true;
  1273. $remark = trim(($remark ? $remark . ';' : '') . '[测试]仅记账不扣费');
  1274. }
  1275. if (!$isTestUser && $pointsBefore < $points) {
  1276. dLog('points')->warning('用户积分不足,扣费后积分为负数', [
  1277. 'task_id' => $taskId,
  1278. 'uid' => $uid,
  1279. 'points_before' => $pointsBefore,
  1280. 'points_consumed' => $points
  1281. ]);
  1282. }
  1283. if (!$isTestUser) {
  1284. // 原子扣减积分:基于数据库当前值执行(points = points - X),
  1285. // 避免并发处理不同任务时基于旧快照覆盖写入导致丢失更新(少扣);
  1286. // COALESCE 处理余额为 NULL 的用户(NULL - X 在 MySQL 中仍为 NULL)
  1287. DB::table('mp_manage_users')->where('id', $uid)->update([
  1288. 'points' => DB::raw('COALESCE(points, 0) - ' . (float)$points),
  1289. 'updated_at' => date('Y-m-d H:i:s')
  1290. ]);
  1291. }
  1292. // 记录积分使用明细
  1293. DB::table('mp_user_points_details')->insert([
  1294. 'uid' => $uid,
  1295. 'cpid' => (int)getProp($user, 'cpid', 0),
  1296. 'task_id' => $taskId,
  1297. 'type' => $type,
  1298. 'api_type' => $apiType,
  1299. 'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE),
  1300. 'points_consumed' => $points,
  1301. 'points_before' => $pointsBefore,
  1302. 'points_after' => $pointsAfter,
  1303. 'tokens_consumed' => $tokens,
  1304. 'remark' => $remark,
  1305. 'created_at' => date('Y-m-d H:i:s'),
  1306. 'updated_at' => date('Y-m-d H:i:s')
  1307. ]);
  1308. DB::commit();
  1309. dLog('points')->info($type . '计费成功', [
  1310. 'task_id' => $taskId,
  1311. 'uid' => $uid,
  1312. 'points_consumed' => $points,
  1313. 'points_after' => $pointsAfter,
  1314. 'tokens_consumed' => $tokens,
  1315. 'test_mode' => $isTestUser
  1316. ]);
  1317. return [
  1318. 'charged' => true,
  1319. 'task_id' => $taskId,
  1320. 'uid' => $uid,
  1321. 'points_consumed' => $points,
  1322. 'points_before' => $pointsBefore,
  1323. 'points_after' => $pointsAfter,
  1324. 'tokens_consumed' => $tokens,
  1325. 'test_mode' => $isTestUser
  1326. ];
  1327. } catch (\Exception $e) {
  1328. DB::rollBack();
  1329. // 唯一索引冲突:同一任务已被其他请求计费,视为已计费(并发验重兜底,避免误报计费失败)
  1330. if ($this->isDuplicateCharge($e)) {
  1331. return ['charged' => false, 'reason' => 'already_charged', 'task_id' => $taskId];
  1332. }
  1333. dLog('points')->error($type . '计费失败: ' . $e->getMessage(), ['task_id' => $taskId]);
  1334. logDB('points', 'error', $type . '计费失败', [
  1335. 'task_id' => $taskId,
  1336. 'error' => $e->getMessage()
  1337. ]);
  1338. return ['charged' => false, 'reason' => 'exception: ' . $e->getMessage(), 'task_id' => $taskId];
  1339. }
  1340. }
  1341. /**
  1342. * 判断异常是否为数据库唯一键冲突(重复计费)
  1343. *
  1344. * @param \Exception $e
  1345. * @return bool
  1346. */
  1347. private function isDuplicateCharge(\Exception $e): bool
  1348. {
  1349. // MySQL 重复键:SQLSTATE 23000 / 错误码 1062(Duplicate entry)
  1350. $code = $e->getCode();
  1351. if ($code === 23000 || $code === '23000' || $code === 1062 || $code === '1062') {
  1352. return true;
  1353. }
  1354. return mb_strpos($e->getMessage(), 'Duplicate entry') !== false;
  1355. }
  1356. }