PointsService.php 41 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105
  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. $pricePerSecond = $prices[$this->normalizeResolutionKey($resolution)] ?? null;
  95. if ($pricePerSecond === null || (float)$pricePerSecond <= 0) {
  96. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  97. }
  98. return (int)max(1, round((float)$pricePerSecond * $duration));
  99. }
  100. /**
  101. * 将分辨率归一化为计费档位
  102. *
  103. * “超分720p”档对应生成分辨率 480p(任务更新代码会对 480p 直接超分到 720p),
  104. * 因此 480p / sr_720p / 超分720p 均归一化为 480p 档。
  105. *
  106. * @param string $resolution
  107. * @return string
  108. */
  109. private function normalizeResolutionKey(string $resolution): string
  110. {
  111. $resolution = strtolower(trim($resolution));
  112. switch ($resolution) {
  113. case '4k':
  114. case '2160p':
  115. case '4096x2160':
  116. return '4k';
  117. case '1080p':
  118. return '1080p';
  119. case '720p':
  120. return '720p';
  121. case '480p':
  122. return '480p';
  123. case 'sr_720p':
  124. case 'sr720p':
  125. case '超分720p':
  126. return '480p';
  127. case 'sr_1080p':
  128. case 'sr1080p':
  129. case '超分1080p':
  130. return 'sr_1080p';
  131. default:
  132. return $resolution;
  133. }
  134. }
  135. /**
  136. * 获取视频生成任务实际消耗的 token 量
  137. *
  138. * 优先从接口返回的 result_json.usage 中读取;
  139. * 当前视频类接口暂未返回 token 用量,默认返回 0。
  140. *
  141. * @param MpGenerateVideoTask $task
  142. * @return int
  143. */
  144. public function getVideoTokensConsumed(MpGenerateVideoTask $task): int
  145. {
  146. $resultJson = $task->result_json;
  147. if (is_string($resultJson)) {
  148. $resultJson = json_decode($resultJson, true);
  149. }
  150. if (!is_array($resultJson)) {
  151. return 0;
  152. }
  153. // zzengine(智帧/统一API)返回格式:
  154. // data.task.detail.actual_token_total 或 data.task.detail.result.provider_token_total
  155. // 或 data.task.detail.result.billing_snapshot.token_total
  156. if ($task->api_type === 'zzengine') {
  157. $tokens = $resultJson['data']['task']['detail']['actual_token_total']
  158. ?? $resultJson['data']['task']['detail']['result']['provider_token_total']
  159. ?? $resultJson['data']['task']['detail']['result']['billing_snapshot']['token_total']
  160. ?? $resultJson['data']['task']['detail']['result']['billing_snapshot']['token_output']
  161. ?? 0;
  162. return (int)$tokens;
  163. }
  164. // seedance(豆包视频)返回格式:usage.total_tokens / usage.completion_tokens
  165. if ($task->api_type === 'seedance') {
  166. $tokens = $resultJson['usage']['total_tokens']
  167. ?? $resultJson['usage']['completion_tokens']
  168. ?? 0;
  169. return (int)$tokens;
  170. }
  171. // 其他API:优先从 usage 中读取
  172. $tokens = $resultJson['usage']['total_tokens']
  173. ?? $resultJson['usage']['completion_tokens']
  174. ?? $resultJson['content']['usage']['total_tokens']
  175. ?? 0;
  176. return (int)$tokens;
  177. }
  178. /**
  179. * 从接口返回结果中获取实际视频时长(秒)
  180. *
  181. * 各 API 返回格式不同,按 api_type 分别解析;
  182. * 用于 charge_info 中自动时长(-1/0)的任务在成功扣费时回填实际时长。
  183. *
  184. * @param MpGenerateVideoTask $task
  185. * @return int
  186. */
  187. public function getActualVideoDuration(MpGenerateVideoTask $task): int
  188. {
  189. $resultJson = $task->result_json;
  190. if (is_string($resultJson)) {
  191. $resultJson = json_decode($resultJson, true);
  192. }
  193. if (!is_array($resultJson)) {
  194. return 0;
  195. }
  196. switch ($task->api_type) {
  197. case 'zzengine':
  198. return (int)($resultJson['data']['task']['detail']['duration'] ?? 0);
  199. case 'jimeng':
  200. $data = $resultJson['data'] ?? [];
  201. if (!empty($data['duration'])) {
  202. return (int)$data['duration'];
  203. }
  204. if (isset($data['frames'], $data['framespersecond']) && (int)$data['framespersecond'] > 0) {
  205. return (int)floor((int)$data['frames'] / (int)$data['framespersecond']);
  206. }
  207. return 0;
  208. case 'keling':
  209. $taskData = $resultJson['data'] ?? [];
  210. $video = $taskData['task_result']['videos'][0] ?? [];
  211. if (!empty($video['duration'])) {
  212. return (int)$video['duration'];
  213. }
  214. if (!empty($taskData['duration'])) {
  215. return (int)$taskData['duration'];
  216. }
  217. if (isset($video['frames'], $video['framespersecond']) && (int)$video['framespersecond'] > 0) {
  218. return (int)floor((int)$video['frames'] / (int)$video['framespersecond']);
  219. }
  220. return 0;
  221. case 'seedance':
  222. default:
  223. return (int)($resultJson['duration'] ?? 0);
  224. }
  225. }
  226. /**
  227. * 获取系统当前全部积分规则(公共方法)
  228. *
  229. * - chat:文生文(对话)规则,含全局默认扣分与各文本模型按 model 配置的扣分
  230. * - image_models:图片模型计费规则(mp_image_models)
  231. * - video_models:视频模型计费规则(mp_video_models)
  232. *
  233. * @return array
  234. */
  235. public function getPointsRules(): array
  236. {
  237. $textModels = DB::table('mp_text_models')->orderBy('order', 'desc')->orderBy('id', 'desc')->where('is_enabled', 1)->get();
  238. $imageModels = DB::table('mp_image_models')->orderBy('order', 'desc')->orderBy('id', 'desc')->where('is_enabled', 1)->get();
  239. $videoModels = DB::table('mp_video_models')->orderBy('order', 'desc')->orderBy('id', 'desc')->where('is_enabled', 1)->get();
  240. // $textList = [];
  241. // foreach ($textModels as $row) {
  242. // $item = $this->formatModelRule($row);
  243. // $item['charge_points'] = $this->getChatChargePoints((string)$row->model);
  244. // $textList[] = $item;
  245. // }
  246. $textModel = DB::table('mp_text_models')->where('is_enabled', 1)->whereNotNull('price_json')->first();
  247. if ($textModel) {
  248. $price = $this->getChatChargePoints($textModel->model);
  249. $charge_type = 'per_call';
  250. }else {
  251. $price = self::CHAT_CHARGE_POINTS;
  252. $charge_type = 'per_call';
  253. }
  254. return [
  255. 'chat' => [
  256. 'charge_type' => $charge_type,
  257. 'price_json' => ['price'=>$price],
  258. // 'models' => $textList,
  259. ],
  260. 'image_models' => array_map([$this, 'formatModelRule'], $imageModels->all()),
  261. 'video_models' => array_map([$this, 'formatModelRule'], $videoModels->all()),
  262. ];
  263. }
  264. /**
  265. * 将模型表中的计费配置行整理为前端友好结构
  266. *
  267. * price_json 统一解析为数组返回(空配置返回空对象)。
  268. *
  269. * @param object $row
  270. * @return array
  271. */
  272. private function formatModelRule($row): array
  273. {
  274. $priceJson = getProp($row, 'price_json');
  275. $price = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson;
  276. if (!is_array($price)) {
  277. $price = new \stdClass();
  278. }
  279. return [
  280. // 'id' => (int)getProp($row, 'id', 0),
  281. 'model' => (string)getProp($row, 'model', ''),
  282. 'name' => (string)getProp($row, 'name', ''),
  283. // 'description' => (string)getProp($row, 'description', ''),
  284. // 'is_enabled' => (int)getProp($row, 'is_enabled', 1),
  285. // 'order' => (int)getProp($row, 'order', 0),
  286. 'is_multimodal' => (int)getProp($row, 'is_multimodal', 0),
  287. 'charge_type' => (string)getProp($row, 'charge_type', ''),
  288. 'price_json' => $price,
  289. ];
  290. }
  291. /**
  292. * 更新积分规则(公共方法)
  293. *
  294. * 支持三个区块,均可选传,未传区块不处理:
  295. * - text_rules:文生文(对话)按 model 更新(不传 model 时更新全部模型),charge_type 仅支持 per_call,price_json 为 {"price": 积分}
  296. * - image_rules:图片按 model 更新,charge_type 仅支持 per_image,price_json 为 {"1k": 积分, "2k": 积分, "4k": 积分}
  297. * - video_rules:视频按 model 更新,charge_type 支持 per_second / per_call
  298. * per_second 的 price_json 为 {"场景": {"分辨率": 单价/秒}};per_call 的 price_json 为 {"price": 积分}
  299. *
  300. * 每条规则至少需传 charge_type 或 price_json 之一;未传的字段保留原值。
  301. *
  302. * @param array $params
  303. * @return array 各区块实际更新的 model 列表
  304. */
  305. public function updatePointsRules(array $params): array
  306. {
  307. $updated = ['text' => [], 'image' => [], 'video' => []];
  308. if (isset($params['text_rules']) && is_array($params['text_rules'])) {
  309. foreach ($params['text_rules'] as $rule) {
  310. $updated['text'] = array_merge(
  311. $updated['text'],
  312. $this->updateModelRule('mp_text_models', $rule, ['per_call'], false, true)
  313. );
  314. }
  315. }
  316. if (isset($params['image_rules']) && is_array($params['image_rules'])) {
  317. foreach ($params['image_rules'] as $rule) {
  318. $updated['image'] = array_merge(
  319. $updated['image'],
  320. $this->updateModelRule('mp_image_models', $rule, ['per_image'], false)
  321. );
  322. }
  323. }
  324. if (isset($params['video_rules']) && is_array($params['video_rules'])) {
  325. foreach ($params['video_rules'] as $rule) {
  326. $updated['video'] = array_merge(
  327. $updated['video'],
  328. $this->updateModelRule('mp_video_models', $rule, ['per_second', 'per_call'], true)
  329. );
  330. }
  331. }
  332. return $updated;
  333. }
  334. /**
  335. * 更新单个模型的计费配置
  336. *
  337. * @param string $table 模型表名
  338. * @param array $rule 规则(model / charge_type / price_json)
  339. * @param array $chargeTypes 允许的计费方式
  340. * @param bool $nestedPrice 是否允许嵌套价格结构(视频按场景+分辨率)
  341. * @param bool $allowAll 是否允许不传 model 时更新该表全部模型(仅文生文使用)
  342. * @return array 实际更新的 model 列表
  343. */
  344. private function updateModelRule(string $table, array $rule, array $chargeTypes, bool $nestedPrice, bool $allowAll = false): array
  345. {
  346. $model = trim((string)getProp($rule, 'model', ''));
  347. if ($model === '' && !$allowAll) {
  348. Utils::throwError('1002:规则缺少model参数');
  349. }
  350. $data = [];
  351. if (array_key_exists('charge_type', $rule)) {
  352. $chargeType = (string)getProp($rule, 'charge_type', '');
  353. if (!in_array($chargeType, $chargeTypes, true)) {
  354. Utils::throwError('1002:' . $table . '计费方式不合法:' . $chargeType);
  355. }
  356. $data['charge_type'] = $chargeType;
  357. }
  358. if (array_key_exists('price_json', $rule)) {
  359. $price = getProp($rule, 'price_json', null);
  360. $priceArr = $nestedPrice
  361. ? $this->normalizeNestedPrice($price)
  362. : $this->normalizeFlatPrice($price);
  363. $data['price_json'] = json_encode($priceArr, JSON_UNESCAPED_UNICODE);
  364. }
  365. if (empty($data)) {
  366. Utils::throwError('1002:' . $table . '规则请至少传入charge_type或price_json');
  367. }
  368. if ($model !== '') {
  369. if (!DB::table($table)->where('model', $model)->exists()) {
  370. Utils::throwError('1002:模型不存在:' . $model);
  371. }
  372. DB::table($table)->where('model', $model)->update($data);
  373. return [$model];
  374. }
  375. // 未传 model(仅文生文支持):更新该表全部模型
  376. $allModels = DB::table($table)->pluck('model')->all();
  377. if (empty($allModels)) {
  378. Utils::throwError('1002:' . $table . '没有可更新的模型');
  379. }
  380. DB::table($table)->update($data);
  381. return $allModels;
  382. }
  383. /**
  384. * 校验并归一化一层价格结构(文生文/图片)
  385. *
  386. * 所有 key 对应的值必须为不小于 0 的数字;支持传入对象或 JSON 字符串。
  387. *
  388. * @param mixed $price
  389. * @return array
  390. */
  391. private function normalizeFlatPrice($price): array
  392. {
  393. $priceArr = is_string($price) ? json_decode($price, true) : $price;
  394. if (!is_array($priceArr) || empty($priceArr)) {
  395. Utils::throwError('1002:price格式不正确');
  396. }
  397. $result = [];
  398. foreach ($priceArr as $key => $value) {
  399. if (!is_numeric($value) || (float)$value < 0) {
  400. Utils::throwError('1002:price.' . $key . '必须为不小于0的数字');
  401. }
  402. $result[(string)$key] = (float)$value;
  403. }
  404. return $result;
  405. }
  406. /**
  407. * 校验并归一化嵌套价格结构(视频按场景+分辨率)
  408. *
  409. * 支持如 {"video_generation": {"720p": 10, "1080p": 25}} 或 {"price": 5}。
  410. *
  411. * @param mixed $price
  412. * @return array
  413. */
  414. private function normalizeNestedPrice($price): array
  415. {
  416. $priceArr = is_string($price) ? json_decode($price, true) : $price;
  417. if (!is_array($priceArr) || empty($priceArr)) {
  418. Utils::throwError('1002:price格式不正确');
  419. }
  420. $result = [];
  421. foreach ($priceArr as $key => $value) {
  422. if (is_array($value)) {
  423. $result[(string)$key] = $this->normalizeFlatPrice($value);
  424. } elseif (is_numeric($value) && (float)$value >= 0) {
  425. $result[(string)$key] = (float)$value;
  426. } else {
  427. Utils::throwError('1002:price.' . $key . '必须为不小于0的数字或对象');
  428. }
  429. }
  430. return $result;
  431. }
  432. /**
  433. * 获取用户积分流水
  434. *
  435. * @param array $params uid/type/start_date/end_date/page_size
  436. * @return array
  437. */
  438. public function getUserPointsRecords(array $params = []): array
  439. {
  440. $uid = (int)getProp($params, 'uid', 0);
  441. if (!$uid) {
  442. $uid = (int)Site::getUid();
  443. }
  444. $type = getProp($params, 'type', '');
  445. $startDate = getProp($params, 'start_date', '');
  446. $endDate = getProp($params, 'end_date', '');
  447. $pageSize = (int)getProp($params, 'page_size', 15);
  448. if ($pageSize < 1 || $pageSize > 100) {
  449. $pageSize = 15;
  450. }
  451. $query = MpUserPointsDetail::where('uid', $uid);
  452. if ($type) {
  453. $query->where('type', $type);
  454. }
  455. if ($startDate) {
  456. $query->where('created_at', '>=', $startDate . ' 00:00:00');
  457. }
  458. if ($endDate) {
  459. $query->where('created_at', '<=', $endDate . ' 23:59:59');
  460. }
  461. $records = $query->orderBy('created_at', 'desc')
  462. ->orderBy('id', 'desc')
  463. ->paginate($pageSize);
  464. // 汇总统计:当前积分余额、累计消耗/退回积分、累计消耗token
  465. // 排除带 test_mode 标记的测试记录,保证统计口径与用户表实际余额一致
  466. $excludeTestRecords = function ($query) {
  467. $query->whereNull('charge_info')
  468. ->orWhereNull('charge_info->test_mode')
  469. ->orWhere('charge_info->test_mode', '!=', 'true');
  470. };
  471. $user = DB::table('mp_manage_users')->where('id', $uid)->first();
  472. $summary = [
  473. 'points_balance' => (float)getProp($user, 'points', 0),
  474. 'total_points_consumed' => (float)MpUserPointsDetail::where('uid', $uid)
  475. ->where('points_consumed', '>', 0)
  476. ->where($excludeTestRecords)
  477. ->sum('points_consumed'),
  478. 'total_points_refunded' => (float)MpUserPointsDetail::where('uid', $uid)
  479. ->where('points_consumed', '<', 0)
  480. ->where($excludeTestRecords)
  481. ->sum('points_consumed'),
  482. 'total_tokens_consumed' => (int)MpUserPointsDetail::where('uid', $uid)
  483. ->where($excludeTestRecords)
  484. ->sum('tokens_consumed'),
  485. 'total_count' => (int)MpUserPointsDetail::where('uid', $uid)
  486. ->where($excludeTestRecords)
  487. ->count(),
  488. ];
  489. return [
  490. 'summary' => $summary,
  491. 'records' => $records,
  492. ];
  493. }
  494. /**
  495. * 获取用户当前积分余额
  496. *
  497. * @param int $uid 用户ID,缺省取当前登录用户
  498. * @return float
  499. */
  500. public function getUserPointsBalance(int $uid = 0): float
  501. {
  502. if (!$uid) {
  503. $uid = (int)Site::getUid();
  504. }
  505. if (!$uid) {
  506. Utils::throwError(ErrorConst::NOT_LOGIN);
  507. }
  508. $user = DB::table('mp_manage_users')->where('id', $uid)->first();
  509. if (!$user) {
  510. Utils::throwError(ErrorConst::USER_IS_NOT_EXIST);
  511. }
  512. return (float)getProp($user, 'points', 0);
  513. }
  514. /**
  515. * 余额预检(公共方法)
  516. *
  517. * 计算视频/其他业务所需积分数后,在创建任务前调用;
  518. * 积分不足时直接抛错(20009:积分不足),业务方无需自行处理。
  519. *
  520. * @param float|int $pointsNeeded 需要扣除的积分数
  521. * @param int $uid 用户ID,缺省取当前登录用户
  522. * @return void
  523. */
  524. public function checkUserPointsEnough($pointsNeeded, int $uid = 0): void
  525. {
  526. $pointsNeeded = (float)$pointsNeeded;
  527. if ($pointsNeeded <= 0) {
  528. return;
  529. }
  530. // 测试用户(TEST_CPID / TEST_UID 白名单)跳过余额预检:只记录明细,不实际扣费
  531. if ($this->isTestUser($uid)) {
  532. return;
  533. }
  534. $balance = $this->getUserPointsBalance($uid);
  535. if ($balance < $pointsNeeded) {
  536. Utils::throwError(ErrorConst::POINTS_NOT_ENOUGH);
  537. }
  538. }
  539. /**
  540. * 判断是否为测试用户(TEST_CPID / TEST_UID 白名单)
  541. *
  542. * TEST_CPID / TEST_UID 在 .env 中为英文逗号分隔的纯字符串(如 "1"、"142857,142858"),
  543. * 读取后先转换为数组再判断。uid 命中 TEST_UID 或 cpid 命中 TEST_CPID(任一命中)即视为测试用户:
  544. * 预检不再校验余额,扣费只记录明细、不实际扣减积分。
  545. *
  546. * @param int $uid 用户ID,缺省取当前登录用户
  547. * @param int $cpid 公司ID,缺省取当前上下文公司ID
  548. * @return bool
  549. */
  550. public function isTestUser(int $uid = 0, int $cpid = 0): bool
  551. {
  552. if (!$uid) {
  553. $uid = (int)Site::getUid();
  554. }
  555. if (!$cpid) {
  556. $cpid = (int)Site::getCpid();
  557. }
  558. $testUids = $this->parseEnvList(env('TEST_UID'));
  559. $testCpids = $this->parseEnvList(env('TEST_CPID'));
  560. if (!empty($testUids) && in_array((string)$uid, $testUids, true)) {
  561. return true;
  562. }
  563. if (!empty($testCpids) && in_array((string)$cpid, $testCpids, true)) {
  564. return true;
  565. }
  566. return false;
  567. }
  568. /**
  569. * 将 env 中英文逗号分隔的字符串转换为数组
  570. *
  571. * @param mixed $value
  572. * @return array
  573. */
  574. private function parseEnvList($value): array
  575. {
  576. if ($value === null || $value === '') {
  577. return [];
  578. }
  579. $items = array_map('trim', explode(',', (string)$value));
  580. return array_values(array_filter($items, function ($item) {
  581. return $item !== '';
  582. }));
  583. }
  584. /**
  585. * 视频生成成功后记录计费明细并扣减用户积分
  586. *
  587. * 幂等处理:同一任务只允许计费一次(type + task_id 唯一索引兜底)。
  588. *
  589. * @param MpGenerateVideoTask $task
  590. * @return array
  591. */
  592. public function recordVideoTaskCharge(MpGenerateVideoTask $task): array
  593. {
  594. // 重复计费保护
  595. $exists = DB::table('mp_user_points_details')
  596. ->where('type', MpUserPointsDetail::TYPE_VIDEO)
  597. ->where('task_id', $task->id)
  598. ->exists();
  599. if ($exists) {
  600. return ['charged' => false, 'reason' => 'already_charged', 'task_id' => $task->id];
  601. }
  602. $chargeInfo = $task->charge_info;
  603. if (is_string($chargeInfo)) {
  604. $chargeInfo = json_decode($chargeInfo, true);
  605. }
  606. if (!is_array($chargeInfo) || empty($chargeInfo['user_id'])) {
  607. dLog('points')->warning('视频任务缺少计费信息,跳过扣费', ['task_id' => $task->id]);
  608. return ['charged' => false, 'reason' => 'no_charge_info', 'task_id' => $task->id];
  609. }
  610. // 自动时长(-1/0)时,用接口返回的实际时长回填计费信息
  611. $requestedDuration = (int)($chargeInfo['video_duration'] ?? -1);
  612. $actualDuration = $this->getActualVideoDuration($task);
  613. $durationBackfilled = $requestedDuration <= 0 && $actualDuration > 0;
  614. if ($durationBackfilled) {
  615. $chargeInfo['video_duration'] = $actualDuration;
  616. }
  617. $uid = (int)$chargeInfo['user_id'];
  618. $points = (float)$this->getVideoChargePoints($chargeInfo);
  619. $tokens = $this->getVideoTokensConsumed($task);
  620. $result = $this->deductAndRecord(
  621. $uid,
  622. $task->id,
  623. MpUserPointsDetail::TYPE_VIDEO,
  624. (string)getProp($task, 'api_type', ''),
  625. $points,
  626. $tokens,
  627. $chargeInfo,
  628. ''
  629. );
  630. // 自动时长被实际时长覆盖时,计费成功后同步回填任务表的 charge_info
  631. if ($durationBackfilled && !empty($result['charged'])) {
  632. $task->update(['charge_info' => $chargeInfo]);
  633. }
  634. return $result;
  635. }
  636. /**
  637. * 获取图片生成单张应扣积分数
  638. *
  639. * 从 mp_image_models 表读取(charge_type=per_image),按分辨率档位(1k/2k/4k)取单张积分。
  640. *
  641. * @param array $chargeInfo 计费信息(model、resolution、width、height 等)
  642. * @return int
  643. */
  644. public function getImageChargePoints(array $chargeInfo = []): int
  645. {
  646. $model = (string)getProp($chargeInfo, 'model', '');
  647. $resolution = strtolower((string)getProp($chargeInfo, 'resolution', '2k'));
  648. $modelRow = DB::table('mp_image_models')->where('model', $model)->first();
  649. if (!$modelRow || ($modelRow->charge_type ?? '') !== 'per_image') {
  650. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  651. }
  652. $priceJson = $modelRow->price_json;
  653. $prices = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson;
  654. if (!is_array($prices) || empty($prices)) {
  655. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  656. }
  657. $price = $prices[$resolution] ?? null;
  658. if ($price === null || (float)$price <= 0) {
  659. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  660. }
  661. return (int)max(1, round((float)$price));
  662. }
  663. /**
  664. * 根据图片宽高归一化分辨率档位(1k/2k/4k)
  665. *
  666. * 按宽高乘积(面积)分档更准确:各档面积几乎成平方关系——
  667. * 1k≈1MP(1024x1024)、2k≈4MP(2048x2048、1600x2848)、3k≈9MP(3072x3072、4096x2304)、4k≈16MP(4096x4096)。
  668. * 目前没有 3k 计费档,3k 尺寸直接按 4k 计费。
  669. *
  670. * @param int $width
  671. * @param int $height
  672. * @return string
  673. */
  674. public function normalizeImageResolutionKey(int $width, int $height): string
  675. {
  676. if ($width <= 0 || $height <= 0) {
  677. return '2k';
  678. }
  679. // 面积分档:<2MP 视为 1k;2MP~6MP 视为 2k;≥6MP(含3k/4k尺寸)按 4k 计费
  680. $area = $width * $height;
  681. if ($area < 2048 * 1024) {
  682. return '1k';
  683. }
  684. if ($area < 2048 * 3072) {
  685. return '2k';
  686. }
  687. return '4k';
  688. }
  689. /**
  690. * 获取图片生成任务实际消耗的 token 量
  691. *
  692. * 图片接口返回格式:result_json.usage.total_tokens / output_tokens
  693. *
  694. * @param MpGeneratePicTask $task
  695. * @return int
  696. */
  697. public function getImageTokensConsumed(MpGeneratePicTask $task): int
  698. {
  699. $resultJson = $task->result_json;
  700. if (is_string($resultJson)) {
  701. $resultJson = json_decode($resultJson, true);
  702. }
  703. if (!is_array($resultJson)) {
  704. return 0;
  705. }
  706. $tokens = $resultJson['usage']['total_tokens'] ?? $resultJson['usage']['output_tokens'] ?? 0;
  707. return (int)$tokens;
  708. }
  709. /**
  710. * 从 AI 对话接口返回的 usage 中提取消耗的 token 数
  711. *
  712. * DeepSeek/OpenAI 格式:usage.total_tokens / usage.completion_tokens
  713. * Gemini 格式:usageMetadata.totalTokenCount(含思考 token),缺省时按 prompt + candidates 合计
  714. *
  715. * @param mixed $usage
  716. * @return int
  717. */
  718. public function getTokensFromUsage($usage): int
  719. {
  720. if (is_string($usage)) {
  721. $usage = json_decode($usage, true);
  722. }
  723. if (!is_array($usage)) {
  724. return 0;
  725. }
  726. $tokens = $usage['total_tokens']
  727. ?? $usage['completion_tokens']
  728. ?? $usage['totalTokenCount']
  729. ?? (
  730. isset($usage['promptTokenCount']) || isset($usage['candidatesTokenCount'])
  731. ? (int)($usage['promptTokenCount'] ?? 0)
  732. + (int)($usage['candidatesTokenCount'] ?? 0)
  733. + (int)($usage['thoughtsTokenCount'] ?? 0)
  734. : 0
  735. );
  736. return (int)$tokens;
  737. }
  738. /**
  739. * AI对话调用成功后记录计费明细并扣减用户积分
  740. *
  741. * 无对应任务表,task_id 为 NULL((type, task_id) 唯一索引下多个 NULL 互不冲突)。
  742. *
  743. * @param int $uid
  744. * @param int $points
  745. * @param int $tokens
  746. * @param array $chargeInfo
  747. * @param string $remark 特殊备注(默认空,测试用户由内部追加[测试]仅记账不扣费)
  748. * @param string $apiType
  749. * @return array
  750. */
  751. public function recordChatCharge(int $uid, int $points, int $tokens, array $chargeInfo = [], string $remark = '', string $apiType = 'deepseek'): array
  752. {
  753. return $this->deductAndRecord(
  754. $uid,
  755. null,
  756. MpUserPointsDetail::TYPE_CHAT,
  757. $apiType,
  758. (float)$points,
  759. $tokens,
  760. $chargeInfo,
  761. $remark
  762. );
  763. }
  764. /**
  765. * 仅记录 token 使用明细,不扣减积分、不写积分消耗
  766. *
  767. * 用于不按积分计费的文生文接口(免费/不扣积分场景),复用 mp_user_points_details 表:
  768. * points_consumed=0,仅填写 tokens_consumed,保证累计 token 统计口径一致。
  769. * uid<=0(无登录上下文)或 tokens<=0 时不落库。
  770. *
  771. * @param int $uid 用户ID
  772. * @param int $tokens 消耗 token 数
  773. * @param array $chargeInfo 计费/来源信息(model、source 等)
  774. * @param string $type 明细类型(默认 chat)
  775. * @param string $apiType API 类型/模型标识
  776. * @param string $remark 备注
  777. * @return bool 是否落库成功
  778. */
  779. public function recordTokenOnlyDetail(int $uid, int $tokens, array $chargeInfo = [], string $type = MpUserPointsDetail::TYPE_CHAT, string $apiType = '', string $remark = ''): bool
  780. {
  781. if ($uid <= 0 || $tokens <= 0) {
  782. return false;
  783. }
  784. try {
  785. $user = DB::table('mp_manage_users')->where('id', $uid)->first();
  786. $cpid = (int)getProp($user, 'cpid', 0);
  787. $pointsBalance = (float)getProp($user, 'points', 0);
  788. // 测试用户标记:保证统计排除口径与扣费明细一致
  789. $isTestUser = $this->isTestUser($uid, $cpid);
  790. if ($isTestUser) {
  791. $chargeInfo['test_mode'] = true;
  792. $remark = trim(($remark ? $remark . ';' : '') . '[测试]仅记账不扣费');
  793. }
  794. DB::table('mp_user_points_details')->insert([
  795. 'uid' => $uid,
  796. 'cpid' => $cpid,
  797. 'task_id' => null,
  798. 'type' => $type,
  799. 'api_type' => $apiType,
  800. 'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE),
  801. 'points_consumed' => 0,
  802. 'points_before' => $pointsBalance,
  803. 'points_after' => $pointsBalance,
  804. 'tokens_consumed' => $tokens,
  805. 'remark' => $remark,
  806. 'created_at' => date('Y-m-d H:i:s'),
  807. 'updated_at' => date('Y-m-d H:i:s')
  808. ]);
  809. dLog('points')->info('token明细记录成功', [
  810. 'uid' => $uid,
  811. 'tokens_consumed' => $tokens,
  812. 'source' => $chargeInfo['source'] ?? '',
  813. 'test_mode' => $isTestUser
  814. ]);
  815. return true;
  816. } catch (\Exception $e) {
  817. dLog('points')->error('token明细记录失败: ' . $e->getMessage(), ['uid' => $uid]);
  818. logDB('points', 'error', 'token明细记录失败', [
  819. 'uid' => $uid,
  820. 'tokens_consumed' => $tokens,
  821. 'error' => $e->getMessage()
  822. ]);
  823. return false;
  824. }
  825. }
  826. /**
  827. * 图片生成成功后记录计费明细并扣减用户积分
  828. *
  829. * 幂等处理:同一任务只允许计费一次(type + task_id 唯一索引兜底)。
  830. *
  831. * @param MpGeneratePicTask $task
  832. * @return array
  833. */
  834. public function recordImageTaskCharge(MpGeneratePicTask $task): array
  835. {
  836. // 重复计费保护
  837. $exists = DB::table('mp_user_points_details')
  838. ->where('type', MpUserPointsDetail::TYPE_IMAGE)
  839. ->where('task_id', $task->id)
  840. ->exists();
  841. if ($exists) {
  842. return ['charged' => false, 'reason' => 'already_charged', 'task_id' => $task->id];
  843. }
  844. $chargeInfo = $task->charge_info;
  845. if (is_string($chargeInfo)) {
  846. $chargeInfo = json_decode($chargeInfo, true);
  847. }
  848. if (!is_array($chargeInfo) || empty($chargeInfo['user_id'])) {
  849. dLog('points')->warning('图片任务缺少计费信息,跳过扣费', ['task_id' => $task->id]);
  850. return ['charged' => false, 'reason' => 'no_charge_info', 'task_id' => $task->id];
  851. }
  852. $uid = (int)$chargeInfo['user_id'];
  853. // 积分 = 单张价格 × 实际生成图片数
  854. $pointsPerImage = (float)$this->getImageChargePoints($chargeInfo);
  855. $imageCount = is_array($task->result_url) ? count($task->result_url) : 0;
  856. if ($imageCount <= 0) {
  857. $imageCount = (int)($chargeInfo['image_num'] ?? 1);
  858. }
  859. if ($imageCount <= 0) {
  860. $imageCount = 1;
  861. }
  862. // 单张价格 × 实际生成图片数(未配置价格时为0,仍记录明细与token)
  863. $points = (float)round($pointsPerImage * $imageCount);
  864. $tokens = $this->getImageTokensConsumed($task);
  865. return $this->deductAndRecord(
  866. $uid,
  867. $task->id,
  868. MpUserPointsDetail::TYPE_IMAGE,
  869. (string)getProp($task, 'model', ''),
  870. $points,
  871. $tokens,
  872. $chargeInfo,
  873. ''
  874. );
  875. }
  876. /**
  877. * 扣减积分并记录积分使用明细(视频/图片共用)
  878. *
  879. * @param int $uid
  880. * @param int $taskId
  881. * @param string $type
  882. * @param string $apiType
  883. * @param float $points
  884. * @param int $tokens
  885. * @param array $chargeInfo
  886. * @param string $remark
  887. * @return array
  888. */
  889. private function deductAndRecord(int $uid, ?int $taskId, string $type, string $apiType, float $points, int $tokens, array $chargeInfo, string $remark): array
  890. {
  891. try {
  892. DB::beginTransaction();
  893. $user = DB::table('mp_manage_users')->where('id', $uid)->first();
  894. if (!$user) {
  895. DB::rollBack();
  896. dLog('points')->error('扣费失败:用户不存在', ['task_id' => $taskId, 'uid' => $uid]);
  897. return ['charged' => false, 'reason' => 'user_not_found', 'task_id' => $taskId];
  898. }
  899. $pointsBefore = (float)getProp($user, 'points', 0);
  900. // 测试用户(TEST_CPID / TEST_UID 白名单):只记录明细,不实际扣减积分余额
  901. $isTestUser = $this->isTestUser($uid, (int)getProp($user, 'cpid', 0));
  902. $pointsAfter = $isTestUser ? $pointsBefore : $pointsBefore - $points;
  903. // 测试标记落库:charge_info 写入 test_mode,remark 追加 [测试],便于统计排除与历史排查
  904. if ($isTestUser) {
  905. $chargeInfo['test_mode'] = true;
  906. $remark = trim(($remark ? $remark . ';' : '') . '[测试]仅记账不扣费');
  907. }
  908. if (!$isTestUser && $pointsBefore < $points) {
  909. dLog('points')->warning('用户积分不足,扣费后积分为负数', [
  910. 'task_id' => $taskId,
  911. 'uid' => $uid,
  912. 'points_before' => $pointsBefore,
  913. 'points_consumed' => $points
  914. ]);
  915. }
  916. if (!$isTestUser) {
  917. // 原子扣减积分:基于数据库当前值执行(points = points - X),
  918. // 避免并发处理不同任务时基于旧快照覆盖写入导致丢失更新(少扣)
  919. DB::table('mp_manage_users')->where('id', $uid)->update([
  920. 'points' => DB::raw('points - ' . (float)$points),
  921. 'updated_at' => date('Y-m-d H:i:s')
  922. ]);
  923. }
  924. // 记录积分使用明细
  925. DB::table('mp_user_points_details')->insert([
  926. 'uid' => $uid,
  927. 'cpid' => (int)getProp($user, 'cpid', 0),
  928. 'task_id' => $taskId,
  929. 'type' => $type,
  930. 'api_type' => $apiType,
  931. 'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE),
  932. 'points_consumed' => $points,
  933. 'points_before' => $pointsBefore,
  934. 'points_after' => $pointsAfter,
  935. 'tokens_consumed' => $tokens,
  936. 'remark' => $remark,
  937. 'created_at' => date('Y-m-d H:i:s'),
  938. 'updated_at' => date('Y-m-d H:i:s')
  939. ]);
  940. DB::commit();
  941. dLog('points')->info($type . '计费成功', [
  942. 'task_id' => $taskId,
  943. 'uid' => $uid,
  944. 'points_consumed' => $points,
  945. 'points_after' => $pointsAfter,
  946. 'tokens_consumed' => $tokens,
  947. 'test_mode' => $isTestUser
  948. ]);
  949. return [
  950. 'charged' => true,
  951. 'task_id' => $taskId,
  952. 'uid' => $uid,
  953. 'points_consumed' => $points,
  954. 'points_before' => $pointsBefore,
  955. 'points_after' => $pointsAfter,
  956. 'tokens_consumed' => $tokens,
  957. 'test_mode' => $isTestUser
  958. ];
  959. } catch (\Exception $e) {
  960. DB::rollBack();
  961. // 唯一索引冲突:同一任务已被其他请求计费,视为已计费(并发验重兜底,避免误报计费失败)
  962. if ($this->isDuplicateCharge($e)) {
  963. return ['charged' => false, 'reason' => 'already_charged', 'task_id' => $taskId];
  964. }
  965. dLog('points')->error($type . '计费失败: ' . $e->getMessage(), ['task_id' => $taskId]);
  966. logDB('points', 'error', $type . '计费失败', [
  967. 'task_id' => $taskId,
  968. 'error' => $e->getMessage()
  969. ]);
  970. return ['charged' => false, 'reason' => 'exception: ' . $e->getMessage(), 'task_id' => $taskId];
  971. }
  972. }
  973. /**
  974. * 判断异常是否为数据库唯一键冲突(重复计费)
  975. *
  976. * @param \Exception $e
  977. * @return bool
  978. */
  979. private function isDuplicateCharge(\Exception $e): bool
  980. {
  981. // MySQL 重复键:SQLSTATE 23000 / 错误码 1062(Duplicate entry)
  982. $code = $e->getCode();
  983. if ($code === 23000 || $code === '23000' || $code === 1062 || $code === '1062') {
  984. return true;
  985. }
  986. return mb_strpos($e->getMessage(), 'Duplicate entry') !== false;
  987. }
  988. }