PointsService.php 77 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914
  1. <?php
  2. namespace App\Services;
  3. use App\Consts\ErrorConst;
  4. use App\Facade\Site;
  5. use App\Libs\PointsDisplay;
  6. use App\Libs\ScriptAnchor;
  7. use App\Libs\Utils;
  8. use App\Models\MpGeneratePicTask;
  9. use App\Models\MpGenerateVideoTask;
  10. use App\Models\MpUserPointsDetail;
  11. use App\Transformer\Points\PointsTransformer;
  12. use Illuminate\Support\Facades\DB;
  13. /**
  14. * 用户积分服务
  15. *
  16. * 负责视频生成等业务的积分扣费、积分使用明细记录以及用户积分余额更新。
  17. */
  18. class PointsService
  19. {
  20. /**
  21. * token 明细兜底归属用户ID
  22. *
  23. * 上下文拿不到真实 uid(命令行/队列/请求上下文缺失)时,把“只记 token 不扣积分”的明细
  24. * 挂到该账号,避免因 uid 缺失导致 token 无法入账(对账口径);可用 env POINTS_TOKEN_FALLBACK_UID 覆盖。
  25. */
  26. const TOKEN_FALLBACK_UID = 142857;
  27. /**
  28. * 视频生成单次默认扣费积分数
  29. * 视频模型未配置计费规则时的默认扣费积分数(兜底,避免未配置模型免费)
  30. */
  31. const DEFAULT_VIDEO_CHARGE_POINTS = 0;
  32. /**
  33. * AI对话(chatForAce / addChatForAce单剧集 / reGenerateAnimeForAce单剧集 / regenerateSegmentScript剧集模式)单次扣费积分数
  34. *
  35. * 该值为文生文(对话)的默认扣费积分数:
  36. * 各模型可在 mp_text_models.price_json 中按 model 配置 special/normal 两档({"special": 10, "normal": 3}),
  37. * 未配置或模型不存在时回退此默认值(special 档)。
  38. */
  39. const CHAT_CHARGE_POINTS = 10;
  40. /**
  41. * 文生文(对话)normal 档默认扣费积分数
  42. *
  43. * 其他文生文接口(chatWithFile / chatWithReasoner / chatChangeImg / generateText 纯文本等)统一按 normal 档计费,
  44. * 未配置或模型不存在时回退此默认值。
  45. */
  46. const NORMAL_CHAT_CHARGE_POINTS = 3;
  47. /**
  48. * 获取文生文(AI对话)单次应扣积分数(公共方法)
  49. *
  50. * 计费规则从 mp_text_models 表读取(charge_type=per_call,按次计费):
  51. * - 传入 model 且该模型配置了 price_json.{type} 时,返回对应档位配置值(允许 0,即免费)
  52. * - 兼容旧格式 price_json.price
  53. * - 模型未配置、模型不存在或未传 model 时,special 档返回 10、normal 档返回 3
  54. *
  55. * @param string $model 文本模型 ID(如 deepseek-reasoner / doubao-seed-2-0-mini-260215)
  56. * @param string $type 计费档位:special(主对话,默认)或 normal(其他文生文)
  57. * @return int
  58. */
  59. public function getChatChargePoints(string $model = '', string $type = 'special'): int
  60. {
  61. $defaultPoints = $type === 'normal' ? self::NORMAL_CHAT_CHARGE_POINTS : self::CHAT_CHARGE_POINTS;
  62. if ($model !== '') {
  63. $modelRow = DB::table('mp_text_models')->where('model', $model)->first();
  64. if ($modelRow && ($modelRow->charge_type ?? '') === 'per_call') {
  65. $priceJson = $modelRow->price_json;
  66. $priceRule = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson;
  67. if (is_array($priceRule)) {
  68. if (isset($priceRule[$type]) && is_numeric($priceRule[$type])) {
  69. return (int)max(0, round((float)$priceRule[$type]));
  70. }
  71. // 兼容旧格式 {"price": 10}
  72. if (isset($priceRule['price']) && is_numeric($priceRule['price'])) {
  73. return (int)max(0, round((float)$priceRule['price']));
  74. }
  75. }
  76. }
  77. }
  78. return $defaultPoints;
  79. }
  80. /**
  81. * 获取图生文(有参考图/视频输入的对话)单次应扣积分数(公共方法)
  82. *
  83. * 计费规则固定如下(未在映射表中的模型回退 normal 档,即 3):
  84. * - Gemini3.1 flash / pro 5 积分/次
  85. * - Gemini3.0 flash / pro 3 积分/次
  86. * - Gemini3.6 flash 10 积分/次
  87. * - GPT-5.4 / GPT-5.6-terra / GPT-5.6-sol / GPT-5.6-luna 3 积分/次
  88. * - doubao-seed-2.0 pro / lite / mini 3 积分/次
  89. *
  90. * @param string $model 文本模型 ID
  91. * @return int
  92. */
  93. public function getImageChatChargePoints(string $model = ''): int
  94. {
  95. $map = [
  96. 'gemini-3.1-flash-image-preview-t' => 5,
  97. 'gemini-3.1-pro-preview' => 5,
  98. 'gemini-3-flash-preview' => 3,
  99. 'gemini-3-pro-preview' => 3,
  100. 'gemini-3.6-flash' => 10,
  101. 'gpt-5.4' => 3,
  102. 'gpt-5.6-terra' => 3,
  103. 'gpt-5.6-sol' => 3,
  104. 'gpt-5.6-luna' => 3,
  105. 'doubao-seed-2-0-pro-260215' => 3,
  106. 'doubao-seed-2-0-lite-260215' => 3,
  107. 'doubao-seed-2-0-mini-260215' => 3,
  108. ];
  109. if (isset($map[$model])) {
  110. return $map[$model];
  111. }
  112. return $this->getChatChargePoints($model, 'normal');
  113. }
  114. /**
  115. * 获取视频模型应扣积分数(公共方法)
  116. *
  117. * 计费规则从 mp_video_models 表读取:
  118. * - 按秒计费(per_second):积分 = 单价/秒 × 视频时长(秒)
  119. * - 按次计费(per_call):积分 = 固定单价(如 Gemini 视频理解,暂未接入)
  120. * 分辨率会归一化为计费档位;480p(原始档)与 sr_720p(480p生成+本地超分720p)的生成请求均为480p,
  121. * 默认共用 480p 档;price_json 配置了 sr_720p 独立单价后按独立档计费(未配置时回退480p档)。
  122. *
  123. * @param array $chargeInfo 计费信息(model、video_resolution、video_duration、mode 等)
  124. * @return int
  125. */
  126. public function getVideoChargePoints(array $chargeInfo = []): int
  127. {
  128. $model = (string)getProp($chargeInfo, 'model', '');
  129. $resolution = strtolower((string)getProp($chargeInfo, 'video_resolution', '720p'));
  130. $duration = (int)getProp($chargeInfo, 'video_duration', 0);
  131. if ($duration <= 0) {
  132. $duration = 1; // 时长未知时按1秒兜底
  133. }
  134. $mode = (string)getProp($chargeInfo, 'mode', 'video_generation');
  135. // 从 mp_video_models 表读取计费规则
  136. $modelRow = DB::table('mp_video_models')->where('model', $model)->first();
  137. if (!$modelRow || empty($modelRow->charge_type)) {
  138. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  139. }
  140. $priceJson = $modelRow->price_json;
  141. $priceRule = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson;
  142. if (!is_array($priceRule) || empty($priceRule)) {
  143. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  144. }
  145. // 按次计费(如 Gemini 视频理解:每次固定积分)
  146. if ($modelRow->charge_type === 'per_call') {
  147. $price = (float)($priceRule['price'] ?? self::DEFAULT_VIDEO_CHARGE_POINTS);
  148. return (int)max(1, round($price));
  149. }
  150. // 按秒计费:按场景取分辨率价格表(默认视频生成场景)
  151. $prices = $priceRule[$mode] ?? $priceRule['video_generation'] ?? [];
  152. if (!is_array($prices) || empty($prices)) {
  153. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  154. }
  155. $resKey = $this->normalizeResolutionKey($resolution);
  156. $pricePerSecond = $prices[$resKey] ?? null;
  157. // sr_1080p 无单独定价时回退 720p 档
  158. if ($pricePerSecond === null && $resKey === 'sr_1080p') {
  159. $pricePerSecond = $prices['720p'] ?? null;
  160. }
  161. // sr_720p 无单独定价时回退 480p 档(两者对外请求均为 480p 生成)
  162. if ($pricePerSecond === null && $resKey === 'sr_720p') {
  163. $pricePerSecond = $prices['480p'] ?? null;
  164. }
  165. if ($pricePerSecond === null || (float)$pricePerSecond <= 0) {
  166. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  167. }
  168. return (int)max(1, round((float)$pricePerSecond * $duration));
  169. }
  170. /**
  171. * 将分辨率归一化为计费档位
  172. *
  173. * 480p(原始档)对应 480p 档;sr_720p / 超分720p 为独立档位,
  174. * price_json 未配置 sr_720p 单价时由调用方回退 480p 档(两者生成请求均为 480p)。
  175. *
  176. * @param string $resolution
  177. * @return string
  178. */
  179. private function normalizeResolutionKey(string $resolution): string
  180. {
  181. $resolution = strtolower(trim($resolution));
  182. switch ($resolution) {
  183. case '4k':
  184. case '2160p':
  185. case '4096x2160':
  186. return '4k';
  187. case '1080p':
  188. return '1080p';
  189. case '720p':
  190. return '720p';
  191. case '480p':
  192. return '480p';
  193. case 'sr_720p':
  194. case 'sr720p':
  195. case '超分720p':
  196. return 'sr_720p';
  197. case 'sr_1080p':
  198. case 'sr1080p':
  199. case '超分1080p':
  200. return 'sr_1080p';
  201. default:
  202. return $resolution;
  203. }
  204. }
  205. /**
  206. * 获取视频生成任务实际消耗的 token 量
  207. *
  208. * 优先从接口返回的 result_json.usage 中读取;
  209. * 当前视频类接口暂未返回 token 用量,默认返回 0。
  210. *
  211. * @param MpGenerateVideoTask $task
  212. * @return int
  213. */
  214. public function getVideoTokensConsumed(MpGenerateVideoTask $task): int
  215. {
  216. $resultJson = $task->result_json;
  217. if (is_string($resultJson)) {
  218. $resultJson = json_decode($resultJson, true);
  219. }
  220. if (!is_array($resultJson)) {
  221. return 0;
  222. }
  223. // zzengine(智帧/统一API)返回格式:
  224. // data.task.detail.actual_token_total 或 data.task.detail.result.provider_token_total
  225. // 或 data.task.detail.result.billing_snapshot.token_total
  226. if ($task->api_type === 'zzengine') {
  227. $tokens = $resultJson['data']['task']['detail']['actual_token_total']
  228. ?? $resultJson['data']['task']['detail']['result']['provider_token_total']
  229. ?? $resultJson['data']['task']['detail']['result']['billing_snapshot']['token_total']
  230. ?? $resultJson['data']['task']['detail']['result']['billing_snapshot']['token_output']
  231. ?? 0;
  232. return (int)$tokens;
  233. }
  234. // seedance(豆包视频)返回格式:usage.total_tokens / usage.completion_tokens
  235. if ($task->api_type === 'seedance') {
  236. $tokens = $resultJson['usage']['total_tokens']
  237. ?? $resultJson['usage']['completion_tokens']
  238. ?? 0;
  239. return (int)$tokens;
  240. }
  241. // kuaikuai(快快AI)返回格式:data.data.usage.total_tokens / completion_tokens
  242. // result_json 中已保留规范化后的 usage 字段(queryKuaikuaiTaskStatus 同步)
  243. if ($task->api_type === 'kuaikuai') {
  244. $tokens = $resultJson['usage']['total_tokens']
  245. ?? $resultJson['data']['data']['usage']['total_tokens']
  246. ?? $resultJson['usage']['completion_tokens']
  247. ?? $resultJson['data']['data']['usage']['completion_tokens']
  248. ?? 0;
  249. return (int)$tokens;
  250. }
  251. // 其他API:优先从 usage 中读取
  252. $tokens = $resultJson['usage']['total_tokens']
  253. ?? $resultJson['usage']['completion_tokens']
  254. ?? $resultJson['content']['usage']['total_tokens']
  255. ?? 0;
  256. return (int)$tokens;
  257. }
  258. /**
  259. * 从接口返回结果中获取实际视频时长(秒)
  260. *
  261. * 各 API 返回格式不同,按 api_type 分别解析;
  262. * 用于 charge_info 中自动时长(-1/0)的任务在成功扣费时回填实际时长。
  263. *
  264. * @param MpGenerateVideoTask $task
  265. * @return int
  266. */
  267. public function getActualVideoDuration(MpGenerateVideoTask $task): int
  268. {
  269. $resultJson = $task->result_json;
  270. if (is_string($resultJson)) {
  271. $resultJson = json_decode($resultJson, true);
  272. }
  273. if (!is_array($resultJson)) {
  274. return 0;
  275. }
  276. switch ($task->api_type) {
  277. case 'zzengine':
  278. return (int)($resultJson['data']['task']['detail']['duration'] ?? 0);
  279. case 'jimeng':
  280. $data = $resultJson['data'] ?? [];
  281. if (!empty($data['duration'])) {
  282. return (int)$data['duration'];
  283. }
  284. if (isset($data['frames'], $data['framespersecond']) && (int)$data['framespersecond'] > 0) {
  285. return (int)floor((int)$data['frames'] / (int)$data['framespersecond']);
  286. }
  287. return 0;
  288. case 'keling':
  289. $taskData = $resultJson['data'] ?? [];
  290. $video = $taskData['task_result']['videos'][0] ?? [];
  291. if (!empty($video['duration'])) {
  292. return (int)$video['duration'];
  293. }
  294. if (!empty($taskData['duration'])) {
  295. return (int)$taskData['duration'];
  296. }
  297. if (isset($video['frames'], $video['framespersecond']) && (int)$video['framespersecond'] > 0) {
  298. return (int)floor((int)$video['frames'] / (int)$video['framespersecond']);
  299. }
  300. return 0;
  301. case 'seedance':
  302. default:
  303. return (int)($resultJson['duration'] ?? 0);
  304. }
  305. }
  306. /**
  307. * 获取系统当前全部积分规则(公共方法)
  308. *
  309. * - chat:文生文(对话)规则,含全局默认扣分与各文本模型按 model 配置的扣分
  310. * - image_models:图片模型计费规则(mp_image_models)
  311. * - video_models:视频模型计费规则(mp_video_models)
  312. *
  313. * @return array
  314. */
  315. public function getPointsRules(): array
  316. {
  317. $textModels = DB::table('mp_text_models')->orderBy('order', 'desc')->orderBy('id', 'desc')->where('is_enabled', 1)->get();
  318. $imageModels = DB::table('mp_image_models')->orderBy('order', 'desc')->orderBy('id', 'desc')->where('is_enabled', 1)->get();
  319. $videoModels = DB::table('mp_video_models')->orderBy('order', 'desc')->orderBy('id', 'desc')->where('is_enabled', 1)->get();
  320. // $textList = [];
  321. // foreach ($textModels as $row) {
  322. // $item = $this->formatModelRule($row);
  323. // $item['charge_points'] = $this->getChatChargePoints((string)$row->model);
  324. // $textList[] = $item;
  325. // }
  326. $textModel = DB::table('mp_text_models')->where('is_enabled', 1)->whereNotNull('price_json')->first();
  327. $charge_type = 'per_call';
  328. if ($textModel) {
  329. $special = $this->getChatChargePoints($textModel->model, 'special');
  330. $normal = $this->getChatChargePoints($textModel->model, 'normal');
  331. } else {
  332. $special = self::CHAT_CHARGE_POINTS;
  333. $normal = self::NORMAL_CHAT_CHARGE_POINTS;
  334. }
  335. return [
  336. 'chat' => [
  337. 'charge_type' => $charge_type,
  338. 'price_json' => [
  339. 'special' => $special,
  340. 'normal' => $normal,
  341. ],
  342. // 'models' => $textList,
  343. ],
  344. 'image_models' => array_map([$this, 'formatModelRule'], $imageModels->all()),
  345. 'video_models' => array_map([$this, 'formatModelRule'], $videoModels->all()),
  346. ];
  347. }
  348. /**
  349. * 将模型表中的计费配置行整理为前端友好结构
  350. *
  351. * price_json 统一解析为数组返回(空配置返回空对象)。
  352. *
  353. * @param object $row
  354. * @return array
  355. */
  356. private function formatModelRule($row): array
  357. {
  358. $priceJson = getProp($row, 'price_json');
  359. $price = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson;
  360. if (!is_array($price)) {
  361. $price = new \stdClass();
  362. }
  363. return [
  364. // 'id' => (int)getProp($row, 'id', 0),
  365. 'model' => (string)getProp($row, 'model', ''),
  366. 'name' => (string)getProp($row, 'name', ''),
  367. // 'description' => (string)getProp($row, 'description', ''),
  368. // 'is_enabled' => (int)getProp($row, 'is_enabled', 1),
  369. // 'order' => (int)getProp($row, 'order', 0),
  370. 'is_multimodal' => (int)getProp($row, 'is_multimodal', 0),
  371. 'charge_type' => (string)getProp($row, 'charge_type', ''),
  372. 'price_json' => $price,
  373. ];
  374. }
  375. /**
  376. * 更新积分规则(公共方法)
  377. *
  378. * 支持三个区块,均可选传,未传区块不处理:
  379. * - text_rules:文生文(对话)按 model 更新(不传 model 时更新全部模型),charge_type 仅支持 per_call,
  380. * price_json 为 {"special": 积分, "normal": 积分}(special=主对话档,normal=其他文生文档)
  381. * - image_rules:图片按 model 更新,charge_type 仅支持 per_image,price_json 为 {"1k": 积分, "2k": 积分, "4k": 积分}
  382. * - video_rules:视频按 model 更新,charge_type 支持 per_second / per_call
  383. * per_second 的 price_json 为 {"场景": {"分辨率": 单价/秒}};per_call 的 price_json 为 {"price": 积分}
  384. *
  385. * 每条规则至少需传 charge_type 或 price_json 之一;未传的字段保留原值。
  386. *
  387. * @param array $params
  388. * @return array 各区块实际更新的 model 列表
  389. */
  390. public function updatePointsRules(array $params): array
  391. {
  392. $updated = ['text' => [], 'image' => [], 'video' => []];
  393. if (isset($params['text_rules']) && is_array($params['text_rules'])) {
  394. foreach ($params['text_rules'] as $rule) {
  395. $updated['text'] = array_merge(
  396. $updated['text'],
  397. $this->updateModelRule('mp_text_models', $rule, ['per_call'], false, true)
  398. );
  399. }
  400. }
  401. if (isset($params['image_rules']) && is_array($params['image_rules'])) {
  402. foreach ($params['image_rules'] as $rule) {
  403. $updated['image'] = array_merge(
  404. $updated['image'],
  405. $this->updateModelRule('mp_image_models', $rule, ['per_image'], false)
  406. );
  407. }
  408. }
  409. if (isset($params['video_rules']) && is_array($params['video_rules'])) {
  410. foreach ($params['video_rules'] as $rule) {
  411. $updated['video'] = array_merge(
  412. $updated['video'],
  413. $this->updateModelRule('mp_video_models', $rule, ['per_second', 'per_call'], true)
  414. );
  415. }
  416. }
  417. return $updated;
  418. }
  419. /**
  420. * 更新单个模型的计费配置
  421. *
  422. * @param string $table 模型表名
  423. * @param array $rule 规则(model / charge_type / price_json)
  424. * @param array $chargeTypes 允许的计费方式
  425. * @param bool $nestedPrice 是否允许嵌套价格结构(视频按场景+分辨率)
  426. * @param bool $allowAll 是否允许不传 model 时更新该表全部模型(仅文生文使用)
  427. * @return array 实际更新的 model 列表
  428. */
  429. private function updateModelRule(string $table, array $rule, array $chargeTypes, bool $nestedPrice, bool $allowAll = false): array
  430. {
  431. $model = trim((string)getProp($rule, 'model', ''));
  432. if ($model === '' && !$allowAll) {
  433. Utils::throwError('1002:规则缺少model参数');
  434. }
  435. $data = [];
  436. if (array_key_exists('charge_type', $rule)) {
  437. $chargeType = (string)getProp($rule, 'charge_type', '');
  438. if (!in_array($chargeType, $chargeTypes, true)) {
  439. Utils::throwError('1002:' . $table . '计费方式不合法:' . $chargeType);
  440. }
  441. $data['charge_type'] = $chargeType;
  442. }
  443. if (array_key_exists('price_json', $rule)) {
  444. $price = getProp($rule, 'price_json', null);
  445. $priceArr = $nestedPrice
  446. ? $this->normalizeNestedPrice($price)
  447. : $this->normalizeFlatPrice($price);
  448. $data['price_json'] = json_encode($priceArr, JSON_UNESCAPED_UNICODE);
  449. }
  450. if (empty($data)) {
  451. Utils::throwError('1002:' . $table . '规则请至少传入charge_type或price_json');
  452. }
  453. if ($model !== '') {
  454. if (!DB::table($table)->where('model', $model)->exists()) {
  455. Utils::throwError('1002:模型不存在:' . $model);
  456. }
  457. DB::table($table)->where('model', $model)->update($data);
  458. return [$model];
  459. }
  460. // 未传 model(仅文生文支持):更新该表全部模型
  461. $allModels = DB::table($table)->pluck('model')->all();
  462. if (empty($allModels)) {
  463. Utils::throwError('1002:' . $table . '没有可更新的模型');
  464. }
  465. DB::table($table)->update($data);
  466. return $allModels;
  467. }
  468. /**
  469. * 校验并归一化一层价格结构(文生文/图片)
  470. *
  471. * 所有 key 对应的值必须为不小于 0 的数字;支持传入对象或 JSON 字符串。
  472. *
  473. * @param mixed $price
  474. * @return array
  475. */
  476. private function normalizeFlatPrice($price): array
  477. {
  478. $priceArr = is_string($price) ? json_decode($price, true) : $price;
  479. if (!is_array($priceArr) || empty($priceArr)) {
  480. Utils::throwError('1002:price格式不正确');
  481. }
  482. $result = [];
  483. foreach ($priceArr as $key => $value) {
  484. if (!is_numeric($value) || (float)$value < 0) {
  485. Utils::throwError('1002:price.' . $key . '必须为不小于0的数字');
  486. }
  487. $result[(string)$key] = (float)$value;
  488. }
  489. return $result;
  490. }
  491. /**
  492. * 校验并归一化嵌套价格结构(视频按场景+分辨率)
  493. *
  494. * 支持如 {"video_generation": {"720p": 10, "1080p": 25}} 或 {"price": 5}。
  495. *
  496. * @param mixed $price
  497. * @return array
  498. */
  499. private function normalizeNestedPrice($price): array
  500. {
  501. $priceArr = is_string($price) ? json_decode($price, true) : $price;
  502. if (!is_array($priceArr) || empty($priceArr)) {
  503. Utils::throwError('1002:price格式不正确');
  504. }
  505. $result = [];
  506. foreach ($priceArr as $key => $value) {
  507. if (is_array($value)) {
  508. $result[(string)$key] = $this->normalizeFlatPrice($value);
  509. } elseif (is_numeric($value) && (float)$value >= 0) {
  510. $result[(string)$key] = (float)$value;
  511. } else {
  512. Utils::throwError('1002:price.' . $key . '必须为不小于0的数字或对象');
  513. }
  514. }
  515. return $result;
  516. }
  517. /**
  518. * 获取用户积分流水
  519. *
  520. * @param array $params uid/type/start_date/end_date/page_size
  521. * @return array
  522. */
  523. public function getUserPointsRecords(array $params = []): array
  524. {
  525. $pageSize = (int)getProp($params, 'page_size', 15);
  526. if ($pageSize < 1 || $pageSize > 100) {
  527. $pageSize = 15;
  528. }
  529. // 基础查询(权限 + 筛选条件),records 与 summary 共用,保证汇总随筛选条件变化
  530. [$base, $uid] = $this->buildPointsRecordsQuery($params);
  531. $records = (clone $base)->orderBy('d.created_at', 'desc')
  532. ->orderBy('d.id', 'desc')
  533. ->paginate($pageSize);
  534. // 汇总统计:随筛选条件变化;除 total_points_consumed 外,均排除带 test_mode 标记的测试记录
  535. $excludeTestRecords = function ($query) {
  536. $query->whereNull('d.charge_info')
  537. ->orWhereNull('d.charge_info->test_mode')
  538. ->orWhere('d.charge_info->test_mode', '!=', 'true');
  539. };
  540. // points_balance 始终为当前登录用户自己的余额,与查询目标无关
  541. $loginUid = (int)Site::getUid();
  542. $user = $loginUid ? DB::table('mp_manage_users')->where('id', $loginUid)->where('is_deleted', 0)->first() : null;
  543. $summary = [
  544. 'points_balance' => (int)getProp($user, 'points', 0),
  545. // 消耗积分(不排除测试数据,全量口径)
  546. 'total_points_consumed' => (int)(clone $base)
  547. ->where('d.points_consumed', '>', 0)
  548. ->sum('d.points_consumed'),
  549. // 消耗积分(排除测试数据)
  550. 'total_points_consumed_filter_test' => (int)(clone $base)
  551. ->where('d.points_consumed', '>', 0)
  552. ->where($excludeTestRecords)
  553. ->sum('d.points_consumed'),
  554. 'total_points_refunded' => (int)(clone $base)
  555. ->where('d.points_consumed', '<', 0)
  556. ->whereNotIn('d.type', [MpUserPointsDetail::TYPE_SYSTEM, MpUserPointsDetail::TYPE_COMPANY])
  557. ->sum('d.points_consumed'),
  558. 'total_points_granted' => (int)abs((clone $base)
  559. ->whereIn('d.type', [MpUserPointsDetail::TYPE_SYSTEM, MpUserPointsDetail::TYPE_COMPANY])
  560. ->where('d.points_consumed', '<', 0)
  561. ->sum('d.points_consumed')),
  562. // 消耗 token(不排除测试数据,全量口径)
  563. 'total_tokens_consumed' => (int)(clone $base)
  564. ->sum('d.tokens_consumed'),
  565. // 消耗 token(排除测试数据)
  566. 'total_tokens_consumed_filter_test' => (int)(clone $base)
  567. ->where($excludeTestRecords)
  568. ->sum('d.tokens_consumed'),
  569. 'total_count' => (int)(clone $base)
  570. ->count('d.id'),
  571. 'total_count_filter_test' => (int)(clone $base)
  572. ->where($excludeTestRecords)
  573. ->count('d.id'),
  574. ];
  575. return [
  576. 'summary' => $summary,
  577. 'records' => $records,
  578. ];
  579. }
  580. /**
  581. * 导出积分明细 CSV(与列表筛选条件一致:uid/type/start_date/end_date)
  582. *
  583. * 列:账号、类型、模型、分辨率、动漫/剧本、分集、来源、积分变动、变动前、变动后、Token、备注、创建时间
  584. * 模型名做展示映射(隐藏百度/快快等供应商字样),分辨率按类型展示(视频 480p/超分720p、图片 宽*高)
  585. * 名称列取「动漫名优先,其次剧本名」
  586. * 末尾追加总计行:累计消耗积分 / 累计返还积分(基于筛选后的数据)
  587. *
  588. * @param array $params
  589. * @return void
  590. */
  591. public function exportPointsRecords(array $params): void
  592. {
  593. // 权限与筛选条件与列表一致
  594. [$base] = $this->buildPointsRecordsQuery($params);
  595. $startDate = (string)getProp($params, 'start_date', '');
  596. $endDate = (string)getProp($params, 'end_date', '');
  597. $rows = (clone $base)->orderBy('d.created_at', 'desc')
  598. ->orderBy('d.id', 'desc')
  599. ->get();
  600. $headers = ['账号', '类型', '模型', '分辨率', '动漫/剧本', '分集', '来源', '积分变动', '变动前', '变动后', 'Token', '备注', '创建时间'];
  601. $csvRows = [];
  602. $totalConsumed = 0;
  603. $totalRefunded = 0;
  604. foreach ($rows as $row) {
  605. $chargeInfo = getProp($row, 'charge_info');
  606. if (is_string($chargeInfo)) {
  607. $chargeInfo = json_decode($chargeInfo, true);
  608. }
  609. $chargeInfo = is_array($chargeInfo) ? $chargeInfo : [];
  610. $typeCode = (string)getProp($row, 'type', '');
  611. $pointsConsumed = (float)getProp($row, 'points_consumed', 0);
  612. if ($pointsConsumed > 0) {
  613. $pointsChange = '-' . $pointsConsumed;
  614. $totalConsumed += $pointsConsumed;
  615. } elseif ($pointsConsumed < 0) {
  616. $pointsChange = '+' . abs($pointsConsumed);
  617. // 返还 = 非发放(system/company)的负值记录,即退款类
  618. if (!in_array($typeCode, [MpUserPointsDetail::TYPE_SYSTEM, MpUserPointsDetail::TYPE_COMPANY], true)) {
  619. $totalRefunded += abs($pointsConsumed);
  620. }
  621. } else {
  622. $pointsChange = '0';
  623. }
  624. $csvRows[] = [
  625. (string)getProp($row, 'account', ''),
  626. PointsTransformer::TYPE_LABELS[$typeCode] ?? $typeCode,
  627. PointsDisplay::modelName((string)getProp($chargeInfo, 'model', '')),
  628. PointsDisplay::resolution($typeCode, $chargeInfo),
  629. // 名称:动漫名优先,其次剧本名
  630. (string)(getProp($row, 'anime_name', '') !== '' ? getProp($row, 'anime_name', '') : getProp($row, 'script_name', '')),
  631. (string)(int)getProp($row, 'episode_number', 0),
  632. PointsDisplay::sourceFrom($chargeInfo, getProp($row, 'anime_id', 0), getProp($row, 'script_id', 0)),
  633. $pointsChange,
  634. (string)(float)getProp($row, 'points_before', 0),
  635. (string)(float)getProp($row, 'points_after', 0),
  636. (string)(int)getProp($row, 'tokens_consumed', 0),
  637. (string)getProp($row, 'remark', ''),
  638. (string)transDate(getProp($row, 'created_at')),
  639. ];
  640. }
  641. // 总计(最后一行单行展示,列数与表头对齐:备注列放汇总文案)
  642. $csvRows[] = [
  643. '总计', '', '', '', '', '', '', '', '', '', '',
  644. '累计消耗积分:' . (int)$totalConsumed . ';累计返还积分:' . (int)$totalRefunded,
  645. '',
  646. ];
  647. $start = $startDate ?: date('Y-m-d');
  648. $end = $endDate ?: date('Y-m-d');
  649. exportCsv('points_records_' . str_replace('-', '', $start) . '_' . str_replace('-', '', $end), $headers, $csvRows);
  650. }
  651. /**
  652. * 当前角色可查看的积分明细范围:superadmin 全部;admin 本组织;user 仅自己;其他抛错。
  653. *
  654. * @return array ['cpid' => int 组织限定(0 不限定), 'uid' => int 用户限定(0 不限定)]
  655. */
  656. private function assertCanViewPoints(): array
  657. {
  658. $role = (string)Site::getRole();
  659. if ($role === 'superadmin') {
  660. return ['cpid' => 0, 'uid' => 0];
  661. }
  662. if ($role === 'admin') {
  663. return ['cpid' => (int)Site::getCpid(), 'uid' => 0];
  664. }
  665. if ($role === 'user') {
  666. return ['cpid' => (int)Site::getCpid(), 'uid' => (int)Site::getUid()];
  667. }
  668. Utils::throwError('1005:无权查看积分明细');
  669. }
  670. /**
  671. * 构建积分明细查询(权限范围 + uid/type/日期/nickname/名称 筛选)。
  672. *
  673. * 名称筛选:anime_name,按「动漫名优先,其次剧本名」匹配(COALESCE(anime_name, script_name)),
  674. * 与展示字段 anime_name 口径一致;兼容旧的 script_name / name 入参(行为相同)。
  675. *
  676. * @param array $params
  677. * @return array [查询构建器, 生效的 uid(0 表示多用户视角)]
  678. */
  679. private function buildPointsRecordsQuery(array $params): array
  680. {
  681. $scope = $this->assertCanViewPoints();
  682. $uid = (int)getProp($params, 'uid', 0);
  683. $nickname = trim((string)getProp($params, 'nickname', ''));
  684. // 普通用户仅能查看自己,忽略传入的 uid/nickname 筛选
  685. if ($scope['uid'] > 0) {
  686. $uid = $scope['uid'];
  687. } elseif ($scope['cpid'] > 0 && !$uid && $nickname === '') {
  688. // 管理员默认查看自己的记录;传了 uid 或 nickname 时按对应条件查组内
  689. $uid = (int)Site::getUid();
  690. }
  691. $base = MpUserPointsDetail::from('mp_user_points_details as d')
  692. ->leftJoin('mp_manage_users as u', 'u.id', '=', 'd.uid')
  693. ->select('d.*', 'u.account');
  694. if ($uid) {
  695. $base->where('d.uid', $uid);
  696. }
  697. // 组织范围以用户表当前所属组织为准(与 token 统计口径一致)
  698. if ($scope['cpid'] > 0) {
  699. $base->where('u.cpid', $scope['cpid']);
  700. }
  701. // 昵称/账号模糊搜索(与 token 统计一致);普通用户仅查自己,忽略 nickname 避免异常传参查不到数据
  702. if ($nickname !== '' && $scope['uid'] <= 0) {
  703. $base->where(function ($q) use ($nickname) {
  704. $q->where('u.nickname', 'like', '%' . $nickname . '%')
  705. ->orWhere('u.account', 'like', '%' . $nickname . '%');
  706. });
  707. }
  708. $type = getProp($params, 'type', '');
  709. if ($type) {
  710. $base->where('d.type', $type);
  711. }
  712. // 名称模糊搜索(anime_name):按「动漫名优先,其次剧本名」匹配(与动漫/剧本无关的记录两者为空,不会命中)
  713. $nameKeyword = trim((string)getProp($params, 'anime_name', ''));
  714. if ($nameKeyword === '') {
  715. $nameKeyword = trim((string)getProp($params, 'script_name', ''));
  716. }
  717. if ($nameKeyword === '') {
  718. $nameKeyword = trim((string)getProp($params, 'name', ''));
  719. }
  720. if ($nameKeyword !== '') {
  721. $base->whereRaw('COALESCE(d.anime_name, d.script_name) LIKE ?', ['%' . $nameKeyword . '%']);
  722. }
  723. $startDate = getProp($params, 'start_date', '');
  724. if ($startDate) {
  725. $base->where('d.created_at', '>=', $startDate . ' 00:00:00');
  726. }
  727. $endDate = getProp($params, 'end_date', '');
  728. if ($endDate) {
  729. $base->where('d.created_at', '<=', $endDate . ' 23:59:59');
  730. }
  731. return [$base, $uid];
  732. }
  733. /**
  734. * 发放/回收积分(公共方法)
  735. *
  736. * 权限规则:
  737. * - superadmin(平台):仅可对 role=admin(组织)操作,明细 type=system
  738. * - admin(组织):仅可对同 cpid 的 role=user(组员)操作,明细 type=company
  739. * - 其他角色不允许操作
  740. *
  741. * action=grant(发放,默认):目标用户积分增加,points_consumed 记负值(积分流入)
  742. * action=revoke(回收):目标用户积分减少,需校验目标积分充足(只针对组织/组员角色),points_consumed 记正值(积分流出)
  743. *
  744. * @param array $params uid(目标用户ID,支持多个ID用英文逗号隔开,如 "142857,142858")/ points(积分数,正整数)/ action(grant|revoke,默认 grant)/ remark(可选文案)
  745. * @return array
  746. */
  747. public function grantPoints(array $params): array
  748. {
  749. $uidRaw = trim((string)getProp($params, 'uid', ''));
  750. $points = (float)getProp($params, 'points', 0);
  751. $action = (string)getProp($params, 'action', 'grant');
  752. $remark = trim((string)getProp($params, 'remark', ''));
  753. // 解析 uid:单个ID或多个ID(英文逗号隔开),自动去重、忽略空白
  754. $uids = array_values(array_unique(array_filter(array_map(function ($v) {
  755. return (int)trim((string)$v);
  756. }, explode(',', $uidRaw)), function ($v) {
  757. return $v > 0;
  758. })));
  759. if (empty($uids)) {
  760. Utils::throwError('1002:请传入目标用户uid');
  761. }
  762. $this->validatePoints($points);
  763. $this->validateAction($action);
  764. // 单用户:保持原有返回结构
  765. if (count($uids) === 1) {
  766. try {
  767. DB::beginTransaction();
  768. $result = $this->applyPointsChange($uids[0], $points, $action, $remark);
  769. DB::commit();
  770. return $result;
  771. } catch (\Exception $e) {
  772. DB::rollBack();
  773. throw $e;
  774. }
  775. }
  776. // 多用户:先对全部目标校验(只读,不写库),任一失败整体拒绝;再单事务统一写库,保证原子性
  777. foreach ($uids as $uid) {
  778. $this->buildGrantContext($uid, $action, $points);
  779. }
  780. try {
  781. DB::beginTransaction();
  782. $results = [];
  783. foreach ($uids as $uid) {
  784. $results[] = $this->applyPointsChange($uid, $points, $action, $remark);
  785. }
  786. DB::commit();
  787. } catch (\Exception $e) {
  788. DB::rollBack();
  789. throw $e;
  790. }
  791. dLog('points')->info('积分批量操作成功', [
  792. 'action' => $action,
  793. 'count' => count($uids),
  794. 'points' => $points,
  795. ]);
  796. return [
  797. 'action' => $action,
  798. 'points' => $points,
  799. 'success_count' => count($results),
  800. 'results' => $results,
  801. ];
  802. }
  803. /**
  804. * 校验积分数(正整数)
  805. *
  806. * @param float $points
  807. * @return void
  808. */
  809. private function validatePoints(float $points): void
  810. {
  811. if ($points <= 0) {
  812. Utils::throwError('1002:积分数必须大于0');
  813. }
  814. if (floor($points) != $points) {
  815. Utils::throwError('1002:积分数必须为整数');
  816. }
  817. }
  818. /**
  819. * 校验操作类型
  820. *
  821. * @param string $action
  822. * @return void
  823. */
  824. private function validateAction(string $action): void
  825. {
  826. if (!in_array($action, ['grant', 'revoke'], true)) {
  827. Utils::throwError('1002:action仅支持grant(发放)或revoke(回收)');
  828. }
  829. }
  830. /**
  831. * 校验并构建单用户积分操作上下文(只读)
  832. *
  833. * 校验当前操作者角色、目标用户存在/启用、目标角色与公司归属;
  834. * 回收时额外校验目标用户积分充足;管理员发放时额外校验管理员自身可用积分充足。
  835. *
  836. * @param int $targetUid
  837. * @param string $action
  838. * @param float $points
  839. * @return array
  840. */
  841. private function buildGrantContext(int $targetUid, string $action, float $points): array
  842. {
  843. $fromUid = (int)Site::getUid();
  844. if ($fromUid <= 0) {
  845. Utils::throwError(ErrorConst::NOT_LOGIN);
  846. }
  847. $fromRole = (string)Site::getRole();
  848. $fromUser = DB::table('mp_manage_users')->where('id', $fromUid)->where('is_deleted', 0)->first();
  849. if (!$fromUser) {
  850. Utils::throwError(ErrorConst::USER_IS_NOT_EXIST);
  851. }
  852. $targetUser = DB::table('mp_manage_users')->where('id', $targetUid)->where('is_deleted', 0)->first();
  853. if (!$targetUser) {
  854. Utils::throwError('20003:目标用户不存在');
  855. }
  856. if ((int)getProp($targetUser, 'is_enabled', 1) === 0) {
  857. Utils::throwError('20003:目标用户已被禁用');
  858. }
  859. $targetRole = (string)getProp($targetUser, 'role', '');
  860. $targetCpid = (int)getProp($targetUser, 'cpid', 0);
  861. $verb = $action === 'revoke' ? '回收' : '发放';
  862. // 角色与操作对象校验
  863. if ($fromRole === 'superadmin') {
  864. if ($targetRole !== 'admin') {
  865. Utils::throwError('1005:平台仅可给组织' . $verb . '积分');
  866. }
  867. $type = MpUserPointsDetail::TYPE_SYSTEM;
  868. $defaultRemark = $action === 'revoke' ? '平台回收积分' : '平台发放积分';
  869. } elseif ($fromRole === 'admin') {
  870. if ($targetRole !== 'user') {
  871. Utils::throwError('1005:组织仅可给同公司的组员' . $verb . '积分');
  872. }
  873. if ($targetCpid !== (int)Site::getCpid()) {
  874. Utils::throwError('1005:只能给同一公司的组员' . $verb . '积分');
  875. }
  876. $type = MpUserPointsDetail::TYPE_COMPANY;
  877. $defaultRemark = $action === 'revoke' ? '组织回收积分' : '组织发放积分';
  878. } else {
  879. Utils::throwError('1005:当前角色无权操作积分');
  880. }
  881. // 回收时校验目标积分充足(目标只能是组织/组员角色,超管不在操作范围内,无需判断)
  882. if ($action === 'revoke') {
  883. $balance = (float)getProp($targetUser, 'points', 0);
  884. if ($balance < $points) {
  885. Utils::throwError('1002:目标用户积分不足,无法回收');
  886. }
  887. }
  888. // 管理员发放:积分从管理员自身扣减转移给组员,需校验管理员可用积分充足(平台发放不校验)
  889. if ($fromRole === 'admin' && $action === 'grant') {
  890. $operatorBalance = (float)getProp($fromUser, 'points', 0);
  891. if ($operatorBalance < $points) {
  892. Utils::throwError('1002:可用积分不足,无法发放');
  893. }
  894. }
  895. return [
  896. 'from_uid' => $fromUid,
  897. 'from_role' => $fromRole,
  898. 'from_cpid' => (int)getProp($fromUser, 'cpid', 0),
  899. 'from_user' => $fromUser,
  900. 'target_user' => $targetUser,
  901. 'target_role' => $targetRole,
  902. 'target_cpid' => $targetCpid,
  903. 'target_uid' => $targetUid,
  904. 'type' => $type,
  905. 'default_remark' => $defaultRemark,
  906. ];
  907. }
  908. /**
  909. * 对单个用户执行积分变动并写入明细(须在事务内调用)
  910. *
  911. * @param int $targetUid
  912. * @param float $points
  913. * @param string $action grant|revoke
  914. * @param string $remark
  915. * @return array
  916. */
  917. private function applyPointsChange(int $targetUid, float $points, string $action, string $remark): array
  918. {
  919. $ctx = $this->buildGrantContext($targetUid, $action, $points);
  920. $finalRemark = $remark !== '' ? $ctx['default_remark'] . ':' . $remark : $ctx['default_remark'];
  921. $chargeInfo = [
  922. 'from_uid' => $ctx['from_uid'],
  923. 'from_role' => $ctx['from_role'],
  924. 'target_uid' => $targetUid,
  925. 'target_role' => $ctx['target_role'],
  926. 'target_cpid' => $ctx['target_cpid'],
  927. 'grant_type' => $ctx['type'],
  928. 'action' => $action,
  929. ];
  930. $pointsBefore = (float)getProp($ctx['target_user'], 'points', 0);
  931. $delta = $action === 'revoke' ? -$points : $points;
  932. $pointsAfter = $pointsBefore + $delta;
  933. $now = date('Y-m-d H:i:s');
  934. $operatorAfter = null;
  935. // 原子更新余额,避免并发覆盖;COALESCE 处理余额为 NULL 的用户(NULL ± X 在 MySQL 中仍为 NULL)
  936. if ($ctx['from_role'] === 'admin') {
  937. // 转移模式:积分在管理员与组员之间流转,整体数额不变
  938. $operatorBefore = (float)getProp($ctx['from_user'], 'points', 0);
  939. if ($action === 'revoke') {
  940. // 组员扣减(带余额条件),管理员增加
  941. $affected = DB::table('mp_manage_users')
  942. ->where('id', $targetUid)
  943. ->whereRaw('COALESCE(points, 0) >= ?', [(float)$points])
  944. ->update([
  945. 'points' => DB::raw('COALESCE(points, 0) - ' . (float)$points),
  946. 'updated_at' => $now,
  947. ]);
  948. if ($affected === 0) {
  949. Utils::throwError('1002:目标用户积分不足,无法回收');
  950. }
  951. DB::table('mp_manage_users')->where('id', $ctx['from_uid'])->update([
  952. 'points' => DB::raw('COALESCE(points, 0) + ' . (float)$points),
  953. 'updated_at' => $now,
  954. ]);
  955. $operatorDelta = $points;
  956. } else {
  957. // 管理员扣减(带余额条件,防止并发超发),组员增加
  958. $affected = DB::table('mp_manage_users')
  959. ->where('id', $ctx['from_uid'])
  960. ->whereRaw('COALESCE(points, 0) >= ?', [(float)$points])
  961. ->update([
  962. 'points' => DB::raw('COALESCE(points, 0) - ' . (float)$points),
  963. 'updated_at' => $now,
  964. ]);
  965. if ($affected === 0) {
  966. Utils::throwError('1002:可用积分不足,无法发放');
  967. }
  968. DB::table('mp_manage_users')->where('id', $targetUid)->update([
  969. 'points' => DB::raw('COALESCE(points, 0) + ' . (float)$points),
  970. 'updated_at' => $now,
  971. ]);
  972. $operatorDelta = -$points;
  973. }
  974. $operatorAfter = $operatorBefore + $operatorDelta;
  975. // 管理员侧明细(方向与组员相反):发放记支出(正),回收记收入(负)
  976. DB::table('mp_user_points_details')->insert([
  977. 'uid' => $ctx['from_uid'],
  978. 'cpid' => $ctx['from_cpid'],
  979. 'task_id' => null,
  980. 'type' => $ctx['type'],
  981. 'api_type' => $action === 'revoke' ? 'revoke' : 'grant',
  982. 'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE),
  983. 'points_before' => $operatorBefore,
  984. 'points_consumed' => -$operatorDelta,
  985. 'points_after' => $operatorAfter,
  986. 'tokens_consumed' => 0,
  987. 'remark' => $finalRemark,
  988. 'created_at' => $now,
  989. 'updated_at' => $now,
  990. ]);
  991. } elseif ($action === 'revoke') {
  992. // 平台回收:目标(组织)扣减,带余额条件
  993. $affected = DB::table('mp_manage_users')
  994. ->where('id', $targetUid)
  995. ->whereRaw('COALESCE(points, 0) >= ?', [(float)$points])
  996. ->update([
  997. 'points' => DB::raw('COALESCE(points, 0) - ' . (float)$points),
  998. 'updated_at' => $now,
  999. ]);
  1000. if ($affected === 0) {
  1001. Utils::throwError('1002:目标用户积分不足,无法回收');
  1002. }
  1003. } else {
  1004. // 平台发放:目标(组织)增加(平台为积分源头,不校验平台余额)
  1005. DB::table('mp_manage_users')->where('id', $targetUid)->update([
  1006. 'points' => DB::raw('COALESCE(points, 0) + ' . (float)$points),
  1007. 'updated_at' => $now,
  1008. ]);
  1009. }
  1010. // 目标侧明细:发放 points_consumed 记负值(流入),回收记正值(流出)
  1011. DB::table('mp_user_points_details')->insert([
  1012. 'uid' => $targetUid,
  1013. 'cpid' => $ctx['target_cpid'],
  1014. 'task_id' => null,
  1015. 'type' => $ctx['type'],
  1016. 'api_type' => $action === 'revoke' ? 'revoke' : 'grant',
  1017. 'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE),
  1018. 'points_before' => $pointsBefore,
  1019. 'points_consumed' => -$delta,
  1020. 'points_after' => $pointsAfter,
  1021. 'tokens_consumed' => 0,
  1022. 'remark' => $finalRemark,
  1023. 'created_at' => $now,
  1024. 'updated_at' => $now,
  1025. ]);
  1026. $result = [
  1027. 'uid' => $targetUid,
  1028. 'success' => true,
  1029. 'action' => $action,
  1030. 'type' => $ctx['type'],
  1031. 'points' => $points,
  1032. 'points_before' => $pointsBefore,
  1033. 'points_after' => $pointsAfter,
  1034. 'remark' => $finalRemark,
  1035. ];
  1036. if ($operatorAfter !== null) {
  1037. $result['operator_uid'] = $ctx['from_uid'];
  1038. $result['operator_points_before'] = $operatorBefore;
  1039. $result['operator_points_after'] = $operatorAfter;
  1040. }
  1041. return $result;
  1042. }
  1043. /**
  1044. * 获取可发放积分的用户列表(公共方法)
  1045. *
  1046. * 同一接口按当前角色返回不同列表:
  1047. * - superadmin(平台):所有组织(role=admin)
  1048. * - admin(组织):同 cpid 的组员(role=user)
  1049. * - 其他角色不允许查看
  1050. *
  1051. * @return array
  1052. */
  1053. public function getGrantUserList(): array
  1054. {
  1055. $role = (string)Site::getRole();
  1056. $query = DB::table('mp_manage_users')->where('is_enabled', 1)->where('is_deleted', 0);
  1057. if ($role === 'superadmin') {
  1058. $query->where('role', 'admin');
  1059. } elseif ($role === 'admin') {
  1060. $query->where('role', 'user')->where('cpid', (int)Site::getCpid());
  1061. } else {
  1062. Utils::throwError('1005:当前角色无权查看发放列表');
  1063. }
  1064. $users = $query->orderBy('cpid', 'asc')->orderBy('id', 'asc')
  1065. ->get(['id', 'account', 'nickname', 'role', 'cpid', 'points', 'is_enabled'])
  1066. ->map(function ($user) {
  1067. return [
  1068. 'id' => (int)getProp($user, 'id', 0),
  1069. 'account' => (string)getProp($user, 'account', ''),
  1070. 'nickname' => (string)getProp($user, 'nickname', ''),
  1071. // 'role' => (string)getProp($user, 'role', ''),
  1072. // 'cpid' => (int)getProp($user, 'cpid', 0),
  1073. 'points' => (float)getProp($user, 'points', 0),
  1074. // 'is_enabled' => (int)getProp($user, 'is_enabled', 1),
  1075. ];
  1076. })->all();
  1077. return $users;
  1078. }
  1079. /**
  1080. * 获取用户当前积分余额
  1081. *
  1082. * @param int $uid 用户ID,缺省取当前登录用户
  1083. * @return float
  1084. */
  1085. public function getUserPointsBalance(int $uid = 0): float
  1086. {
  1087. if (!$uid) {
  1088. $uid = (int)Site::getUid();
  1089. }
  1090. if (!$uid) {
  1091. Utils::throwError(ErrorConst::NOT_LOGIN);
  1092. }
  1093. $user = DB::table('mp_manage_users')->where('id', $uid)->where('is_deleted', 0)->first();
  1094. if (!$user) {
  1095. Utils::throwError(ErrorConst::USER_IS_NOT_EXIST);
  1096. }
  1097. return (float)getProp($user, 'points', 0);
  1098. }
  1099. /**
  1100. * 余额预检(公共方法)
  1101. *
  1102. * 计算视频/其他业务所需积分数后,在创建任务前调用;
  1103. * 积分不足时直接抛错(20009:积分不足),业务方无需自行处理。
  1104. *
  1105. * @param float|int $pointsNeeded 需要扣除的积分数
  1106. * @param int $uid 用户ID,缺省取当前登录用户
  1107. * @return void
  1108. */
  1109. public function checkUserPointsEnough($pointsNeeded, int $uid = 0): void
  1110. {
  1111. $pointsNeeded = (float)$pointsNeeded;
  1112. if ($pointsNeeded <= 0) {
  1113. return;
  1114. }
  1115. // 测试用户(TEST_CPID / TEST_UID 白名单)跳过余额预检:只记录明细,不实际扣费
  1116. if ($this->isTestUser($uid)) {
  1117. return;
  1118. }
  1119. $balance = $this->getUserPointsBalance($uid);
  1120. if ($balance < $pointsNeeded) {
  1121. Utils::throwError(ErrorConst::POINTS_NOT_ENOUGH);
  1122. }
  1123. }
  1124. /**
  1125. * 判断是否为测试用户(TEST_CPID / TEST_UID 白名单)
  1126. *
  1127. * TEST_CPID / TEST_UID 在 .env 中为英文逗号分隔的纯字符串(如 "1"、"142857,142858"),
  1128. * 读取后先转换为数组再判断。uid 命中 TEST_UID 或 cpid 命中 TEST_CPID(任一命中)即视为测试用户:
  1129. * 预检不再校验余额,扣费只记录明细、不实际扣减积分。
  1130. *
  1131. * @param int $uid 用户ID,缺省取当前登录用户
  1132. * @param int $cpid 公司ID,缺省取当前上下文公司ID
  1133. * @return bool
  1134. */
  1135. public function isTestUser(int $uid = 0, int $cpid = 0): bool
  1136. {
  1137. if (!$uid) {
  1138. $uid = (int)Site::getUid();
  1139. }
  1140. if (!$cpid) {
  1141. $cpid = (int)Site::getCpid();
  1142. }
  1143. $testUids = $this->parseEnvList(env('TEST_UID'));
  1144. $testCpids = $this->parseEnvList(env('TEST_CPID'));
  1145. if (!empty($testUids) && in_array((string)$uid, $testUids, true)) {
  1146. return true;
  1147. }
  1148. if (!empty($testCpids) && in_array((string)$cpid, $testCpids, true)) {
  1149. return true;
  1150. }
  1151. return false;
  1152. }
  1153. /**
  1154. * 将 env 中英文逗号分隔的字符串转换为数组
  1155. *
  1156. * @param mixed $value
  1157. * @return array
  1158. */
  1159. private function parseEnvList($value): array
  1160. {
  1161. if ($value === null || $value === '') {
  1162. return [];
  1163. }
  1164. $items = array_map('trim', explode(',', (string)$value));
  1165. return array_values(array_filter($items, function ($item) {
  1166. return $item !== '';
  1167. }));
  1168. }
  1169. /**
  1170. * 视频生成成功后记录计费明细并扣减用户积分
  1171. *
  1172. * 幂等处理:同一任务只允许计费一次(type + task_id 唯一索引兜底)。
  1173. *
  1174. * @param MpGenerateVideoTask $task
  1175. * @param int|null $tokensOverride 指定写入明细的 token 数(null 时按 result_json 解析);
  1176. * 巡检补扣时会传 0,表示 token 此前已按“只记token”补记过,避免重复计入
  1177. * @return array
  1178. */
  1179. public function recordVideoTaskCharge(MpGenerateVideoTask $task, ?int $tokensOverride = null): array
  1180. {
  1181. // 重复计费保护
  1182. $exists = DB::table('mp_user_points_details')
  1183. ->where('type', MpUserPointsDetail::TYPE_VIDEO)
  1184. ->where('task_id', $task->id)
  1185. ->exists();
  1186. if ($exists) {
  1187. return ['charged' => false, 'reason' => 'already_charged', 'task_id' => $task->id];
  1188. }
  1189. $chargeInfo = $task->charge_info;
  1190. if (is_string($chargeInfo)) {
  1191. $chargeInfo = json_decode($chargeInfo, true);
  1192. }
  1193. if (!is_array($chargeInfo) || empty($chargeInfo['user_id'])) {
  1194. dLog('points')->warning('视频任务缺少计费信息,跳过扣费', ['task_id' => $task->id]);
  1195. return ['charged' => false, 'reason' => 'no_charge_info', 'task_id' => $task->id];
  1196. }
  1197. // 自动时长(-1/0)时,用接口返回的实际时长回填计费信息
  1198. $requestedDuration = (int)($chargeInfo['video_duration'] ?? -1);
  1199. $actualDuration = $this->getActualVideoDuration($task);
  1200. $durationBackfilled = $requestedDuration <= 0 && $actualDuration > 0;
  1201. if ($durationBackfilled) {
  1202. $chargeInfo['video_duration'] = $actualDuration;
  1203. }
  1204. $uid = (int)$chargeInfo['user_id'];
  1205. $points = (float)$this->getVideoChargePoints($chargeInfo);
  1206. $tokens = $tokensOverride === null ? $this->getVideoTokensConsumed($task) : (int)$tokensOverride;
  1207. $result = $this->deductAndRecord(
  1208. $uid,
  1209. $task->id,
  1210. MpUserPointsDetail::TYPE_VIDEO,
  1211. (string)getProp($task, 'api_type', ''),
  1212. $points,
  1213. $tokens,
  1214. $chargeInfo,
  1215. ''
  1216. );
  1217. // 自动时长被实际时长覆盖时,计费成功后同步回填任务表的 charge_info
  1218. if ($durationBackfilled && !empty($result['charged'])) {
  1219. $task->update(['charge_info' => $chargeInfo]);
  1220. }
  1221. return $result;
  1222. }
  1223. /**
  1224. * 获取图片生成单张应扣积分数
  1225. *
  1226. * 从 mp_image_models 表读取(charge_type=per_image),按分辨率档位(1k/2k/4k)取单张积分。
  1227. *
  1228. * @param array $chargeInfo 计费信息(model、resolution、width、height 等)
  1229. * @return int
  1230. */
  1231. public function getImageChargePoints(array $chargeInfo = []): int
  1232. {
  1233. $model = (string)getProp($chargeInfo, 'model', '');
  1234. $resolution = strtolower((string)getProp($chargeInfo, 'resolution', '2k'));
  1235. $modelRow = DB::table('mp_image_models')->where('model', $model)->first();
  1236. if (!$modelRow || ($modelRow->charge_type ?? '') !== 'per_image') {
  1237. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  1238. }
  1239. $priceJson = $modelRow->price_json;
  1240. $prices = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson;
  1241. if (!is_array($prices) || empty($prices)) {
  1242. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  1243. }
  1244. $price = $prices[$resolution] ?? null;
  1245. if ($price === null || (float)$price <= 0) {
  1246. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  1247. }
  1248. return (int)max(1, round((float)$price));
  1249. }
  1250. /**
  1251. * 根据图片宽高归一化分辨率档位(1k/2k/4k)
  1252. *
  1253. * 按宽高乘积(面积)分档更准确:各档面积几乎成平方关系——
  1254. * 1k≈1MP(1024x1024)、2k≈4MP(2048x2048、1600x2848)、3k≈9MP(3072x3072、3840x2160)、4k≈16MP(4096x4096)。
  1255. * 目前没有 3k 计费档,3k 尺寸直接按 4k 计费。
  1256. *
  1257. * @param int $width
  1258. * @param int $height
  1259. * @return string
  1260. */
  1261. public function normalizeImageResolutionKey(int $width, int $height): string
  1262. {
  1263. if ($width <= 0 || $height <= 0) {
  1264. return '2k';
  1265. }
  1266. // 面积分档:<2MP 视为 1k;2MP~6MP 视为 2k;≥6MP(含3k/4k尺寸)按 4k 计费
  1267. $area = $width * $height;
  1268. if ($area < 2048 * 1024) {
  1269. return '1k';
  1270. }
  1271. if ($area < 2048 * 3072) {
  1272. return '2k';
  1273. }
  1274. return '4k';
  1275. }
  1276. /**
  1277. * 获取图片生成任务实际消耗的 token 量
  1278. *
  1279. * 图片接口返回格式:result_json.usage.total_tokens / output_tokens
  1280. *
  1281. * @param MpGeneratePicTask $task
  1282. * @return int
  1283. */
  1284. public function getImageTokensConsumed(MpGeneratePicTask $task): int
  1285. {
  1286. $resultJson = $task->result_json;
  1287. if (is_string($resultJson)) {
  1288. $resultJson = json_decode($resultJson, true);
  1289. }
  1290. if (!is_array($resultJson)) {
  1291. return 0;
  1292. }
  1293. $tokens = $resultJson['usage']['total_tokens'] ?? $resultJson['usage']['output_tokens'] ?? 0;
  1294. return (int)$tokens;
  1295. }
  1296. /**
  1297. * 从 AI 对话接口返回的 usage 中提取消耗的 token 数
  1298. *
  1299. * DeepSeek/OpenAI 格式:usage.total_tokens / usage.completion_tokens
  1300. * Gemini 格式:usageMetadata.totalTokenCount(含思考 token),缺省时按 prompt + candidates 合计
  1301. *
  1302. * @param mixed $usage
  1303. * @return int
  1304. */
  1305. public function getTokensFromUsage($usage): int
  1306. {
  1307. if (is_string($usage)) {
  1308. $usage = json_decode($usage, true);
  1309. }
  1310. if (!is_array($usage)) {
  1311. return 0;
  1312. }
  1313. $tokens = $usage['total_tokens']
  1314. ?? $usage['completion_tokens']
  1315. ?? $usage['totalTokenCount']
  1316. ?? (
  1317. isset($usage['promptTokenCount']) || isset($usage['candidatesTokenCount'])
  1318. ? (int)($usage['promptTokenCount'] ?? 0)
  1319. + (int)($usage['candidatesTokenCount'] ?? 0)
  1320. + (int)($usage['thoughtsTokenCount'] ?? 0)
  1321. : 0
  1322. );
  1323. return (int)$tokens;
  1324. }
  1325. /**
  1326. * AI对话调用成功后记录计费明细并扣减用户积分
  1327. *
  1328. * 无对应任务表,task_id 为 NULL((type, task_id) 唯一索引下多个 NULL 互不冲突)。
  1329. *
  1330. * @param int $uid
  1331. * @param int $points
  1332. * @param int $tokens
  1333. * @param array $chargeInfo
  1334. * @param string $remark 特殊备注(默认空,测试用户由内部追加[测试]仅记账不扣费)
  1335. * @param string $apiType
  1336. * @return array
  1337. */
  1338. public function recordChatCharge(int $uid, int $points, int $tokens, array $chargeInfo = [], string $remark = '', string $apiType = 'deepseek'): array
  1339. {
  1340. return $this->deductAndRecord(
  1341. $uid,
  1342. null,
  1343. MpUserPointsDetail::TYPE_CHAT,
  1344. $apiType,
  1345. (float)$points,
  1346. $tokens,
  1347. $chargeInfo,
  1348. $remark
  1349. );
  1350. }
  1351. /**
  1352. * 仅记录 token 使用明细,不扣减积分、不写积分消耗
  1353. *
  1354. * 用于不按积分计费的文生文接口(免费/不扣积分场景),复用 mp_user_points_details 表:
  1355. * points_consumed=0,仅填写 tokens_consumed,保证累计 token 统计口径一致。
  1356. * uid<=0(无登录上下文)或 tokens<=0 时不落库。
  1357. *
  1358. * @param int $uid 用户ID
  1359. * @param int $tokens 消耗 token 数
  1360. * @param array $chargeInfo 计费/来源信息(model、source 等)
  1361. * @param string $type 明细类型(默认 chat)
  1362. * @param string $apiType API 类型/模型标识
  1363. * @param string $remark 备注
  1364. * @return bool 是否落库成功
  1365. */
  1366. public function recordTokenOnlyDetail(int $uid, int $tokens, array $chargeInfo = [], string $type = MpUserPointsDetail::TYPE_CHAT, string $apiType = '', string $remark = ''): bool
  1367. {
  1368. if ($uid <= 0 || $tokens <= 0) {
  1369. return false;
  1370. }
  1371. try {
  1372. $user = DB::table('mp_manage_users')->where('id', $uid)->where('is_deleted', 0)->first();
  1373. $cpid = (int)getProp($user, 'cpid', 0);
  1374. $pointsBalance = (float)getProp($user, 'points', 0);
  1375. // 测试用户标记:保证统计排除口径与扣费明细一致
  1376. $isTestUser = $this->isTestUser($uid, $cpid);
  1377. if ($isTestUser) {
  1378. $chargeInfo['test_mode'] = true;
  1379. $remark = trim(($remark ? $remark . ';' : '') . '[测试]仅记账不扣费');
  1380. }
  1381. $scriptContext = $this->resolveScriptContext($chargeInfo, null, $type);
  1382. // charge_info 与明细列口径一致:补写缺失的 anime_id / episode_number / script_id(已有值保留)
  1383. $chargeInfo = ScriptAnchor::fillChargeInfoAnchors($chargeInfo, $scriptContext['anime_id'], $scriptContext['script_id'], $scriptContext['episode_number']);
  1384. DB::table('mp_user_points_details')->insert([
  1385. 'uid' => $uid,
  1386. 'cpid' => $cpid,
  1387. 'anime_id' => $scriptContext['anime_id'],
  1388. 'anime_name' => $scriptContext['anime_name'],
  1389. 'episode_number' => $scriptContext['episode_number'],
  1390. 'script_id' => $scriptContext['script_id'],
  1391. 'script_name' => $scriptContext['script_name'],
  1392. 'task_id' => null,
  1393. 'type' => $type,
  1394. 'api_type' => $apiType,
  1395. 'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE),
  1396. 'points_consumed' => 0,
  1397. 'points_before' => $pointsBalance,
  1398. 'points_after' => $pointsBalance,
  1399. 'tokens_consumed' => $tokens,
  1400. 'remark' => $remark,
  1401. 'created_at' => date('Y-m-d H:i:s'),
  1402. 'updated_at' => date('Y-m-d H:i:s')
  1403. ]);
  1404. dLog('points')->info('token明细记录成功', [
  1405. 'uid' => $uid,
  1406. 'tokens_consumed' => $tokens,
  1407. 'source' => $chargeInfo['source'] ?? '',
  1408. 'test_mode' => $isTestUser
  1409. ]);
  1410. return true;
  1411. } catch (\Exception $e) {
  1412. dLog('points')->error('token明细记录失败: ' . $e->getMessage(), ['uid' => $uid]);
  1413. logDB('points', 'error', 'token明细记录失败', [
  1414. 'uid' => $uid,
  1415. 'tokens_consumed' => $tokens,
  1416. 'error' => $e->getMessage()
  1417. ]);
  1418. return false;
  1419. }
  1420. }
  1421. /**
  1422. * 记录“未计费”的模型调用尝试消耗的 token(只写 token 明细,points_consumed=0,不扣积分)
  1423. *
  1424. * 适用场景:重试被丢弃的尝试、重试耗尽的最终失败、上游返回 usage 但内容为空/无效的调用。
  1425. * 注意:已成功计费的那次调用(recordChatCharge / recordImageTaskCharge / recordVideoTaskCharge)
  1426. * 不要再调用本方法,否则同一份 usage 会在 mp_user_points_details 里重复计入。
  1427. *
  1428. * @param int $uid 用户ID
  1429. * @param int $tokens 上游 usage 折算出的 token 数
  1430. * @param string $type 明细类型(chat / image / video)
  1431. * @param string $apiType 模型标识(写入 api_type)
  1432. * @param array $chargeInfo 计费/来源上下文(model、source、anime_id 等)
  1433. * @param string $remark 备注(说明为何只记 token)
  1434. * @return bool 是否落库成功
  1435. */
  1436. public function recordUnchargedAttemptTokens(int $uid, int $tokens, string $type, string $apiType, array $chargeInfo, string $remark): bool
  1437. {
  1438. if ($tokens <= 0) {
  1439. // 上游没有返回 usage(如纯传输层失败:连接中断/SSL 失败/超时):没有 token 数可记,
  1440. // 不写 tokens_consumed=0 的噪音行,由调用方在文件日志/system_logs 里留痕
  1441. dLog('points')->info('上游未返回usage,跳过token记账', [
  1442. 'type' => $type,
  1443. 'api_type' => $apiType,
  1444. 'remark' => $remark,
  1445. 'uid' => $uid,
  1446. ]);
  1447. return false;
  1448. }
  1449. // 拿不到真实 uid(命令行/队列/请求上下文缺失)时兜底挂到指定账号,尽量保证 token 不丢账
  1450. if ($uid <= 0) {
  1451. $uid = (int)env('POINTS_TOKEN_FALLBACK_UID', self::TOKEN_FALLBACK_UID);
  1452. $chargeInfo['fallback_uid'] = true;
  1453. dLog('points')->warning('token明细归属用户缺失,使用兜底uid记账', [
  1454. 'fallback_uid' => $uid,
  1455. 'type' => $type,
  1456. 'api_type' => $apiType,
  1457. 'remark' => $remark,
  1458. ]);
  1459. }
  1460. if ($uid <= 0) {
  1461. return false;
  1462. }
  1463. return $this->recordTokenOnlyDetail($uid, $tokens, $chargeInfo, $type, $apiType, $remark);
  1464. }
  1465. /**
  1466. * 判断某个视频任务此前是否已按“只记 token 不扣积分”的方式补记过 usage
  1467. *
  1468. * 用途:巡检回填成功后要补扣积分时,如果 token 已经记过,就只补扣积分(tokens 传 0),
  1469. * 避免同一份 usage 在 mp_user_points_details 里被重复统计。
  1470. *
  1471. * @param string $gatewayTaskId 上游网关任务ID(mp_generate_video_tasks.task_id)
  1472. * @return bool
  1473. */
  1474. public function hasUnchargedVideoTokenRecord(string $gatewayTaskId): bool
  1475. {
  1476. if ($gatewayTaskId === '') {
  1477. return false;
  1478. }
  1479. return DB::table('mp_user_points_details')
  1480. ->where('type', MpUserPointsDetail::TYPE_VIDEO)
  1481. ->where('points_consumed', 0)
  1482. ->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(charge_info, '$.task_id')) = ?", [$gatewayTaskId])
  1483. ->exists();
  1484. }
  1485. /**
  1486. * 图片生成成功后记录计费明细并扣减用户积分
  1487. *
  1488. * 幂等处理:同一任务只允许计费一次(type + task_id 唯一索引兜底)。
  1489. *
  1490. * @param MpGeneratePicTask $task
  1491. * @return array
  1492. */
  1493. public function recordImageTaskCharge(MpGeneratePicTask $task): array
  1494. {
  1495. // 重复计费保护
  1496. $exists = DB::table('mp_user_points_details')
  1497. ->where('type', MpUserPointsDetail::TYPE_IMAGE)
  1498. ->where('task_id', $task->id)
  1499. ->exists();
  1500. if ($exists) {
  1501. return ['charged' => false, 'reason' => 'already_charged', 'task_id' => $task->id];
  1502. }
  1503. $chargeInfo = $task->charge_info;
  1504. if (is_string($chargeInfo)) {
  1505. $chargeInfo = json_decode($chargeInfo, true);
  1506. }
  1507. if (!is_array($chargeInfo) || empty($chargeInfo['user_id'])) {
  1508. dLog('points')->warning('图片任务缺少计费信息,跳过扣费', ['task_id' => $task->id]);
  1509. return ['charged' => false, 'reason' => 'no_charge_info', 'task_id' => $task->id];
  1510. }
  1511. $uid = (int)$chargeInfo['user_id'];
  1512. // 积分 = 单张价格 × 实际生成图片数
  1513. $pointsPerImage = (float)$this->getImageChargePoints($chargeInfo);
  1514. $imageCount = is_array($task->result_url) ? count($task->result_url) : 0;
  1515. if ($imageCount <= 0) {
  1516. $imageCount = (int)($chargeInfo['image_num'] ?? 1);
  1517. }
  1518. if ($imageCount <= 0) {
  1519. $imageCount = 1;
  1520. }
  1521. // 单张价格 × 实际生成图片数(未配置价格时为0,仍记录明细与token)
  1522. $points = (float)round($pointsPerImage * $imageCount);
  1523. $tokens = $this->getImageTokensConsumed($task);
  1524. return $this->deductAndRecord(
  1525. $uid,
  1526. $task->id,
  1527. MpUserPointsDetail::TYPE_IMAGE,
  1528. (string)getProp($task, 'model', ''),
  1529. $points,
  1530. $tokens,
  1531. $chargeInfo,
  1532. ''
  1533. );
  1534. }
  1535. /**
  1536. * 扣减积分并记录积分使用明细(视频/图片共用)
  1537. *
  1538. * @param int $uid
  1539. * @param int $taskId
  1540. * @param string $type
  1541. * @param string $apiType
  1542. * @param float $points
  1543. * @param int $tokens
  1544. * @param array $chargeInfo
  1545. * @param string $remark
  1546. * @return array
  1547. */
  1548. private function deductAndRecord(int $uid, ?int $taskId, string $type, string $apiType, float $points, int $tokens, array $chargeInfo, string $remark): array
  1549. {
  1550. try {
  1551. DB::beginTransaction();
  1552. $user = DB::table('mp_manage_users')->where('id', $uid)->where('is_deleted', 0)->first();
  1553. if (!$user) {
  1554. DB::rollBack();
  1555. dLog('points')->error('扣费失败:用户不存在', ['task_id' => $taskId, 'uid' => $uid]);
  1556. return ['charged' => false, 'reason' => 'user_not_found', 'task_id' => $taskId];
  1557. }
  1558. $pointsBefore = (float)getProp($user, 'points', 0);
  1559. // 测试用户(TEST_CPID / TEST_UID 白名单):只记录明细,不实际扣减积分余额
  1560. $isTestUser = $this->isTestUser($uid, (int)getProp($user, 'cpid', 0));
  1561. $pointsAfter = $isTestUser ? $pointsBefore : $pointsBefore - $points;
  1562. // 测试标记落库:charge_info 写入 test_mode,remark 追加 [测试],便于统计排除与历史排查
  1563. if ($isTestUser) {
  1564. $chargeInfo['test_mode'] = true;
  1565. $remark = trim(($remark ? $remark . ';' : '') . '[测试]仅记账不扣费');
  1566. }
  1567. if (!$isTestUser && $pointsBefore < $points) {
  1568. dLog('points')->warning('用户积分不足,扣费后积分为负数', [
  1569. 'task_id' => $taskId,
  1570. 'uid' => $uid,
  1571. 'points_before' => $pointsBefore,
  1572. 'points_consumed' => $points
  1573. ]);
  1574. }
  1575. if (!$isTestUser) {
  1576. // 原子扣减积分:基于数据库当前值执行(points = points - X),
  1577. // 避免并发处理不同任务时基于旧快照覆盖写入导致丢失更新(少扣);
  1578. // COALESCE 处理余额为 NULL 的用户(NULL - X 在 MySQL 中仍为 NULL)
  1579. DB::table('mp_manage_users')->where('id', $uid)->update([
  1580. 'points' => DB::raw('COALESCE(points, 0) - ' . (float)$points),
  1581. 'updated_at' => date('Y-m-d H:i:s')
  1582. ]);
  1583. }
  1584. // 剧本维度(解析失败或与剧本无关时为空,不影响计费主流程)
  1585. $scriptContext = $this->resolveScriptContext($chargeInfo, $taskId, $type);
  1586. // charge_info 与明细列口径一致:补写缺失的 anime_id / episode_number / script_id(已有值保留)
  1587. $chargeInfo = ScriptAnchor::fillChargeInfoAnchors($chargeInfo, $scriptContext['anime_id'], $scriptContext['script_id'], $scriptContext['episode_number']);
  1588. // 记录积分使用明细
  1589. DB::table('mp_user_points_details')->insert([
  1590. 'uid' => $uid,
  1591. 'cpid' => (int)getProp($user, 'cpid', 0),
  1592. 'anime_id' => $scriptContext['anime_id'],
  1593. 'anime_name' => $scriptContext['anime_name'],
  1594. 'episode_number' => $scriptContext['episode_number'],
  1595. 'script_id' => $scriptContext['script_id'],
  1596. 'script_name' => $scriptContext['script_name'],
  1597. 'task_id' => $taskId,
  1598. 'type' => $type,
  1599. 'api_type' => $apiType,
  1600. 'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE),
  1601. 'points_consumed' => $points,
  1602. 'points_before' => $pointsBefore,
  1603. 'points_after' => $pointsAfter,
  1604. 'tokens_consumed' => $tokens,
  1605. 'remark' => $remark,
  1606. 'created_at' => date('Y-m-d H:i:s'),
  1607. 'updated_at' => date('Y-m-d H:i:s')
  1608. ]);
  1609. DB::commit();
  1610. dLog('points')->info($type . '计费成功', [
  1611. 'task_id' => $taskId,
  1612. 'uid' => $uid,
  1613. 'points_consumed' => $points,
  1614. 'points_after' => $pointsAfter,
  1615. 'tokens_consumed' => $tokens,
  1616. 'test_mode' => $isTestUser
  1617. ]);
  1618. return [
  1619. 'charged' => true,
  1620. 'task_id' => $taskId,
  1621. 'uid' => $uid,
  1622. 'points_consumed' => $points,
  1623. 'points_before' => $pointsBefore,
  1624. 'points_after' => $pointsAfter,
  1625. 'tokens_consumed' => $tokens,
  1626. 'test_mode' => $isTestUser
  1627. ];
  1628. } catch (\Exception $e) {
  1629. DB::rollBack();
  1630. // 唯一索引冲突:同一任务已被其他请求计费,视为已计费(并发验重兜底,避免误报计费失败)
  1631. if ($this->isDuplicateCharge($e)) {
  1632. return ['charged' => false, 'reason' => 'already_charged', 'task_id' => $taskId];
  1633. }
  1634. dLog('points')->error($type . '计费失败: ' . $e->getMessage(), ['task_id' => $taskId]);
  1635. logDB('points', 'error', $type . '计费失败', [
  1636. 'task_id' => $taskId,
  1637. 'error' => $e->getMessage()
  1638. ]);
  1639. return ['charged' => false, 'reason' => 'exception: ' . $e->getMessage(), 'task_id' => $taskId];
  1640. }
  1641. }
  1642. /**
  1643. * 判断异常是否为数据库唯一键冲突(重复计费)
  1644. *
  1645. * @param \Exception $e
  1646. * @return bool
  1647. */
  1648. private function isDuplicateCharge(\Exception $e): bool
  1649. {
  1650. // MySQL 重复键:SQLSTATE 23000 / 错误码 1062(Duplicate entry)
  1651. $code = $e->getCode();
  1652. if ($code === 23000 || $code === '23000' || $code === 1062 || $code === '1062') {
  1653. return true;
  1654. }
  1655. return mb_strpos($e->getMessage(), 'Duplicate entry') !== false;
  1656. }
  1657. /**
  1658. * 解析积分明细归属的动漫与剧本(anime_id + 剧本ID + 剧本名)
  1659. *
  1660. * 解析顺序(任一命中即返回):
  1661. * 1. charge_info 中的动漫/剧集/剧幕/分镜锚点(ScriptAnchor 解析,含从 alias_act_id、alias_segment_id 反查)
  1662. * → 剧本优先取动漫绑定的剧本(mp_animes.script_id)
  1663. * 2. charge_info.script_id(动漫未绑定剧本时回退,AI对话等场景直接带剧本ID)
  1664. * 3. charge_info.group_id → mp_script_episode_group.script_id(剧本分集分组)
  1665. * 4. 任务表(视频/图片)的 alias_act_id / alias_segment_id(兼容未写锚点的历史任务)
  1666. *
  1667. * 与剧本无关(积分发放、无剧本上下文的对话等)或解析异常时返回空,
  1668. * 动漫/剧本字段属于附加维度,任何异常都不能影响计费主流程。
  1669. *
  1670. * @param array $chargeInfo 计费信息
  1671. * @param int|null $taskId 关联任务ID(视频/图片任务)
  1672. * @param string $type 明细类型(video/image/chat/system/company)
  1673. * @return array ['anime_id' => int|null, 'anime_name' => string|null, 'script_id' => int|null, 'script_name' => string|null]
  1674. */
  1675. public function resolveScriptContext(array $chargeInfo, ?int $taskId = null, string $type = ''): array
  1676. {
  1677. $empty = ['anime_id' => null, 'anime_name' => null, 'episode_id' => null, 'episode_number' => null, 'script_id' => null, 'script_name' => null];
  1678. try {
  1679. // 锚点解析:动漫/剧集/剧幕/分镜 + 剧本(动漫绑定剧本优先,其次上下文剧本ID)
  1680. $anchor = ScriptAnchor::resolve($chargeInfo);
  1681. $animeId = (int)$anchor['anime_id'];
  1682. $episodeId = (int)$anchor['episode_id'];
  1683. $episodeNumber = (int)$anchor['episode_number'];
  1684. $scriptId = (int)$anchor['script_id'];
  1685. // 动漫/分集上下文缺失时,回查任务表保存的关联片段(历史数据兼容)
  1686. if (($animeId <= 0 || $episodeNumber <= 0) && $taskId) {
  1687. $taskAnchor = ScriptAnchor::anchorByTask($type, (int)$taskId);
  1688. if ($animeId <= 0) {
  1689. $animeId = (int)$taskAnchor['anime_id'];
  1690. }
  1691. if ($episodeId <= 0) {
  1692. $episodeId = (int)$taskAnchor['episode_id'];
  1693. }
  1694. if ($episodeNumber <= 0) {
  1695. $episodeNumber = (int)$taskAnchor['episode_number'];
  1696. }
  1697. if ($scriptId <= 0) {
  1698. $scriptId = (int)$taskAnchor['script_id'];
  1699. }
  1700. }
  1701. // 剧本仍未确定:按剧本分集分组回查
  1702. if ($scriptId <= 0) {
  1703. $groupId = (int)getProp($chargeInfo, 'group_id', 0);
  1704. if ($groupId > 0) {
  1705. $scriptId = ScriptAnchor::scriptIdByGroup($groupId);
  1706. }
  1707. }
  1708. $scriptName = $scriptId > 0 ? ScriptAnchor::scriptNameById($scriptId) : '';
  1709. $animeName = $animeId > 0 ? ScriptAnchor::animeNameById($animeId) : '';
  1710. return [
  1711. 'anime_id' => $animeId > 0 ? $animeId : null,
  1712. 'anime_name' => $animeName !== '' ? $animeName : null,
  1713. 'episode_id' => $episodeId > 0 ? $episodeId : null,
  1714. 'episode_number' => $episodeNumber > 0 ? $episodeNumber : null,
  1715. // 剧本未知或已不存在时只保留动漫维度
  1716. 'script_id' => $scriptName !== '' ? $scriptId : null,
  1717. 'script_name' => $scriptName !== '' ? $scriptName : null,
  1718. ];
  1719. } catch (\Throwable $e) {
  1720. dLog('points')->warning('动漫/剧本信息解析失败(不影响计费): ' . $e->getMessage(), [
  1721. 'task_id' => $taskId,
  1722. 'type' => $type,
  1723. ]);
  1724. return $empty;
  1725. }
  1726. }
  1727. }