PointsService.php 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775
  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. const CHAT_CHARGE_POINTS = 10;
  26. /**
  27. * 获取视频模型应扣积分数(公共方法)
  28. *
  29. * 计费规则从 mp_video_models 表读取:
  30. * - 按秒计费(per_second):积分 = 单价/秒 × 视频时长(秒)
  31. * - 按次计费(per_call):积分 = 固定单价(如 Gemini 视频理解,暂未接入)
  32. * 分辨率会归一化为计费档位;“超分720p”档对应生成分辨率 480p(任务更新代码会对 480p 直接超分到 720p)。
  33. *
  34. * @param array $chargeInfo 计费信息(model、video_resolution、video_duration、mode 等)
  35. * @return int
  36. */
  37. public function getVideoChargePoints(array $chargeInfo = []): int
  38. {
  39. $model = (string)getProp($chargeInfo, 'model', '');
  40. $resolution = strtolower((string)getProp($chargeInfo, 'video_resolution', '720p'));
  41. $duration = (int)getProp($chargeInfo, 'video_duration', 0);
  42. if ($duration <= 0) {
  43. $duration = 1; // 时长未知时按1秒兜底
  44. }
  45. $mode = (string)getProp($chargeInfo, 'mode', 'video_generation');
  46. // 从 mp_video_models 表读取计费规则
  47. $modelRow = DB::table('mp_video_models')->where('model', $model)->first();
  48. if (!$modelRow || empty($modelRow->charge_type)) {
  49. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  50. }
  51. $priceJson = $modelRow->price_json;
  52. $priceRule = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson;
  53. if (!is_array($priceRule) || empty($priceRule)) {
  54. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  55. }
  56. // 按次计费(如 Gemini 视频理解:每次固定积分)
  57. if ($modelRow->charge_type === 'per_call') {
  58. $price = (float)($priceRule['price'] ?? self::DEFAULT_VIDEO_CHARGE_POINTS);
  59. return (int)max(1, round($price));
  60. }
  61. // 按秒计费:按场景取分辨率价格表(默认视频生成场景)
  62. $prices = $priceRule[$mode] ?? $priceRule['video_generation'] ?? [];
  63. if (!is_array($prices) || empty($prices)) {
  64. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  65. }
  66. $pricePerSecond = $prices[$this->normalizeResolutionKey($resolution)] ?? null;
  67. if ($pricePerSecond === null || (float)$pricePerSecond <= 0) {
  68. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  69. }
  70. return (int)max(1, round((float)$pricePerSecond * $duration));
  71. }
  72. /**
  73. * 将分辨率归一化为计费档位
  74. *
  75. * “超分720p”档对应生成分辨率 480p(任务更新代码会对 480p 直接超分到 720p),
  76. * 因此 480p / sr_720p / 超分720p 均归一化为 480p 档。
  77. *
  78. * @param string $resolution
  79. * @return string
  80. */
  81. private function normalizeResolutionKey(string $resolution): string
  82. {
  83. $resolution = strtolower(trim($resolution));
  84. switch ($resolution) {
  85. case '4k':
  86. case '2160p':
  87. case '4096x2160':
  88. return '4k';
  89. case '1080p':
  90. return '1080p';
  91. case '720p':
  92. return '720p';
  93. case '480p':
  94. return '480p';
  95. case 'sr_720p':
  96. case 'sr720p':
  97. case '超分720p':
  98. return '480p';
  99. case 'sr_1080p':
  100. case 'sr1080p':
  101. case '超分1080p':
  102. return 'sr_1080p';
  103. default:
  104. return $resolution;
  105. }
  106. }
  107. /**
  108. * 获取视频生成任务实际消耗的 token 量
  109. *
  110. * 优先从接口返回的 result_json.usage 中读取;
  111. * 当前视频类接口暂未返回 token 用量,默认返回 0。
  112. *
  113. * @param MpGenerateVideoTask $task
  114. * @return int
  115. */
  116. public function getVideoTokensConsumed(MpGenerateVideoTask $task): int
  117. {
  118. $resultJson = $task->result_json;
  119. if (is_string($resultJson)) {
  120. $resultJson = json_decode($resultJson, true);
  121. }
  122. if (!is_array($resultJson)) {
  123. return 0;
  124. }
  125. // zzengine(智帧/统一API)返回格式:
  126. // data.task.detail.actual_token_total 或 data.task.detail.result.provider_token_total
  127. // 或 data.task.detail.result.billing_snapshot.token_total
  128. if ($task->api_type === 'zzengine') {
  129. $tokens = $resultJson['data']['task']['detail']['actual_token_total']
  130. ?? $resultJson['data']['task']['detail']['result']['provider_token_total']
  131. ?? $resultJson['data']['task']['detail']['result']['billing_snapshot']['token_total']
  132. ?? $resultJson['data']['task']['detail']['result']['billing_snapshot']['token_output']
  133. ?? 0;
  134. return (int)$tokens;
  135. }
  136. // seedance(豆包视频)返回格式:usage.total_tokens / usage.completion_tokens
  137. if ($task->api_type === 'seedance') {
  138. $tokens = $resultJson['usage']['total_tokens']
  139. ?? $resultJson['usage']['completion_tokens']
  140. ?? 0;
  141. return (int)$tokens;
  142. }
  143. // 其他API:优先从 usage 中读取
  144. $tokens = $resultJson['usage']['total_tokens']
  145. ?? $resultJson['usage']['completion_tokens']
  146. ?? $resultJson['content']['usage']['total_tokens']
  147. ?? 0;
  148. return (int)$tokens;
  149. }
  150. /**
  151. * 从接口返回结果中获取实际视频时长(秒)
  152. *
  153. * 各 API 返回格式不同,按 api_type 分别解析;
  154. * 用于 charge_info 中自动时长(-1/0)的任务在成功扣费时回填实际时长。
  155. *
  156. * @param MpGenerateVideoTask $task
  157. * @return int
  158. */
  159. public function getActualVideoDuration(MpGenerateVideoTask $task): int
  160. {
  161. $resultJson = $task->result_json;
  162. if (is_string($resultJson)) {
  163. $resultJson = json_decode($resultJson, true);
  164. }
  165. if (!is_array($resultJson)) {
  166. return 0;
  167. }
  168. switch ($task->api_type) {
  169. case 'zzengine':
  170. return (int)($resultJson['data']['task']['detail']['duration'] ?? 0);
  171. case 'jimeng':
  172. $data = $resultJson['data'] ?? [];
  173. if (!empty($data['duration'])) {
  174. return (int)$data['duration'];
  175. }
  176. if (isset($data['frames'], $data['framespersecond']) && (int)$data['framespersecond'] > 0) {
  177. return (int)floor((int)$data['frames'] / (int)$data['framespersecond']);
  178. }
  179. return 0;
  180. case 'keling':
  181. $taskData = $resultJson['data'] ?? [];
  182. $video = $taskData['task_result']['videos'][0] ?? [];
  183. if (!empty($video['duration'])) {
  184. return (int)$video['duration'];
  185. }
  186. if (!empty($taskData['duration'])) {
  187. return (int)$taskData['duration'];
  188. }
  189. if (isset($video['frames'], $video['framespersecond']) && (int)$video['framespersecond'] > 0) {
  190. return (int)floor((int)$video['frames'] / (int)$video['framespersecond']);
  191. }
  192. return 0;
  193. case 'seedance':
  194. default:
  195. return (int)($resultJson['duration'] ?? 0);
  196. }
  197. }
  198. /**
  199. * 获取用户积分流水
  200. *
  201. * @param array $params uid/type/start_date/end_date/page_size
  202. * @return array
  203. */
  204. public function getUserPointsRecords(array $params = []): array
  205. {
  206. $uid = (int)getProp($params, 'uid', 0);
  207. if (!$uid) {
  208. $uid = (int)Site::getUid();
  209. }
  210. $type = getProp($params, 'type', '');
  211. $startDate = getProp($params, 'start_date', '');
  212. $endDate = getProp($params, 'end_date', '');
  213. $pageSize = (int)getProp($params, 'page_size', 15);
  214. if ($pageSize < 1 || $pageSize > 100) {
  215. $pageSize = 15;
  216. }
  217. $query = MpUserPointsDetail::where('uid', $uid);
  218. if ($type) {
  219. $query->where('type', $type);
  220. }
  221. if ($startDate) {
  222. $query->where('created_at', '>=', $startDate . ' 00:00:00');
  223. }
  224. if ($endDate) {
  225. $query->where('created_at', '<=', $endDate . ' 23:59:59');
  226. }
  227. $records = $query->orderBy('created_at', 'desc')
  228. ->orderBy('id', 'desc')
  229. ->paginate($pageSize);
  230. // 汇总统计:当前积分余额、累计消耗/退回积分、累计消耗token
  231. // 排除带 test_mode 标记的测试记录,保证统计口径与用户表实际余额一致
  232. $excludeTestRecords = function ($query) {
  233. $query->whereNull('charge_info')
  234. ->orWhereNull('charge_info->test_mode')
  235. ->orWhere('charge_info->test_mode', '!=', 'true');
  236. };
  237. $user = DB::table('mp_manage_users')->where('id', $uid)->first();
  238. $summary = [
  239. 'points_balance' => (float)getProp($user, 'points', 0),
  240. 'total_points_consumed' => (float)MpUserPointsDetail::where('uid', $uid)
  241. ->where('points_consumed', '>', 0)
  242. ->where($excludeTestRecords)
  243. ->sum('points_consumed'),
  244. 'total_points_refunded' => (float)MpUserPointsDetail::where('uid', $uid)
  245. ->where('points_consumed', '<', 0)
  246. ->where($excludeTestRecords)
  247. ->sum('points_consumed'),
  248. 'total_tokens_consumed' => (int)MpUserPointsDetail::where('uid', $uid)
  249. ->where($excludeTestRecords)
  250. ->sum('tokens_consumed'),
  251. 'total_count' => (int)MpUserPointsDetail::where('uid', $uid)
  252. ->where($excludeTestRecords)
  253. ->count(),
  254. ];
  255. return [
  256. 'summary' => $summary,
  257. 'records' => $records,
  258. ];
  259. }
  260. /**
  261. * 获取用户当前积分余额
  262. *
  263. * @param int $uid 用户ID,缺省取当前登录用户
  264. * @return float
  265. */
  266. public function getUserPointsBalance(int $uid = 0): float
  267. {
  268. if (!$uid) {
  269. $uid = (int)Site::getUid();
  270. }
  271. if (!$uid) {
  272. Utils::throwError(ErrorConst::NOT_LOGIN);
  273. }
  274. $user = DB::table('mp_manage_users')->where('id', $uid)->first();
  275. if (!$user) {
  276. Utils::throwError(ErrorConst::USER_IS_NOT_EXIST);
  277. }
  278. return (float)getProp($user, 'points', 0);
  279. }
  280. /**
  281. * 余额预检(公共方法)
  282. *
  283. * 计算视频/其他业务所需积分数后,在创建任务前调用;
  284. * 积分不足时直接抛错(20009:积分不足),业务方无需自行处理。
  285. *
  286. * @param float|int $pointsNeeded 需要扣除的积分数
  287. * @param int $uid 用户ID,缺省取当前登录用户
  288. * @return void
  289. */
  290. public function checkUserPointsEnough($pointsNeeded, int $uid = 0): void
  291. {
  292. $pointsNeeded = (float)$pointsNeeded;
  293. if ($pointsNeeded <= 0) {
  294. return;
  295. }
  296. // 测试用户(TEST_CPID / TEST_UID 白名单)跳过余额预检:只记录明细,不实际扣费
  297. if ($this->isTestUser($uid)) {
  298. return;
  299. }
  300. $balance = $this->getUserPointsBalance($uid);
  301. if ($balance < $pointsNeeded) {
  302. Utils::throwError(ErrorConst::POINTS_NOT_ENOUGH);
  303. }
  304. }
  305. /**
  306. * 判断是否为测试用户(TEST_CPID / TEST_UID 白名单)
  307. *
  308. * TEST_CPID / TEST_UID 在 .env 中为英文逗号分隔的纯字符串(如 "1"、"142857,142858"),
  309. * 读取后先转换为数组再判断。uid 命中 TEST_UID 或 cpid 命中 TEST_CPID(任一命中)即视为测试用户:
  310. * 预检不再校验余额,扣费只记录明细、不实际扣减积分。
  311. *
  312. * @param int $uid 用户ID,缺省取当前登录用户
  313. * @param int $cpid 公司ID,缺省取当前上下文公司ID
  314. * @return bool
  315. */
  316. public function isTestUser(int $uid = 0, int $cpid = 0): bool
  317. {
  318. if (!$uid) {
  319. $uid = (int)Site::getUid();
  320. }
  321. if (!$cpid) {
  322. $cpid = (int)Site::getCpid();
  323. }
  324. $testUids = $this->parseEnvList(env('TEST_UID'));
  325. $testCpids = $this->parseEnvList(env('TEST_CPID'));
  326. if (!empty($testUids) && in_array((string)$uid, $testUids, true)) {
  327. return true;
  328. }
  329. if (!empty($testCpids) && in_array((string)$cpid, $testCpids, true)) {
  330. return true;
  331. }
  332. return false;
  333. }
  334. /**
  335. * 将 env 中英文逗号分隔的字符串转换为数组
  336. *
  337. * @param mixed $value
  338. * @return array
  339. */
  340. private function parseEnvList($value): array
  341. {
  342. if ($value === null || $value === '') {
  343. return [];
  344. }
  345. $items = array_map('trim', explode(',', (string)$value));
  346. return array_values(array_filter($items, function ($item) {
  347. return $item !== '';
  348. }));
  349. }
  350. /**
  351. * 视频生成成功后记录计费明细并扣减用户积分
  352. *
  353. * 幂等处理:同一任务只允许计费一次(type + task_id 唯一索引兜底)。
  354. *
  355. * @param MpGenerateVideoTask $task
  356. * @return array
  357. */
  358. public function recordVideoTaskCharge(MpGenerateVideoTask $task): array
  359. {
  360. // 重复计费保护
  361. $exists = DB::table('mp_user_points_details')
  362. ->where('type', MpUserPointsDetail::TYPE_VIDEO)
  363. ->where('task_id', $task->id)
  364. ->exists();
  365. if ($exists) {
  366. return ['charged' => false, 'reason' => 'already_charged', 'task_id' => $task->id];
  367. }
  368. $chargeInfo = $task->charge_info;
  369. if (is_string($chargeInfo)) {
  370. $chargeInfo = json_decode($chargeInfo, true);
  371. }
  372. if (!is_array($chargeInfo) || empty($chargeInfo['user_id'])) {
  373. dLog('points')->warning('视频任务缺少计费信息,跳过扣费', ['task_id' => $task->id]);
  374. return ['charged' => false, 'reason' => 'no_charge_info', 'task_id' => $task->id];
  375. }
  376. // 自动时长(-1/0)时,用接口返回的实际时长回填计费信息
  377. $requestedDuration = (int)($chargeInfo['video_duration'] ?? -1);
  378. $actualDuration = $this->getActualVideoDuration($task);
  379. $durationBackfilled = $requestedDuration <= 0 && $actualDuration > 0;
  380. if ($durationBackfilled) {
  381. $chargeInfo['video_duration'] = $actualDuration;
  382. }
  383. $uid = (int)$chargeInfo['user_id'];
  384. $points = (float)$this->getVideoChargePoints($chargeInfo);
  385. $tokens = $this->getVideoTokensConsumed($task);
  386. $result = $this->deductAndRecord(
  387. $uid,
  388. $task->id,
  389. MpUserPointsDetail::TYPE_VIDEO,
  390. (string)getProp($task, 'api_type', ''),
  391. $points,
  392. $tokens,
  393. $chargeInfo,
  394. ''
  395. );
  396. // 自动时长被实际时长覆盖时,计费成功后同步回填任务表的 charge_info
  397. if ($durationBackfilled && !empty($result['charged'])) {
  398. $task->update(['charge_info' => $chargeInfo]);
  399. }
  400. return $result;
  401. }
  402. /**
  403. * 获取图片生成单张应扣积分数
  404. *
  405. * 从 mp_image_models 表读取(charge_type=per_image),按分辨率档位(1k/2k/4k)取单张积分。
  406. *
  407. * @param array $chargeInfo 计费信息(model、resolution、width、height 等)
  408. * @return int
  409. */
  410. public function getImageChargePoints(array $chargeInfo = []): int
  411. {
  412. $model = (string)getProp($chargeInfo, 'model', '');
  413. $resolution = strtolower((string)getProp($chargeInfo, 'resolution', '2k'));
  414. $modelRow = DB::table('mp_image_models')->where('model', $model)->first();
  415. if (!$modelRow || ($modelRow->charge_type ?? '') !== 'per_image') {
  416. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  417. }
  418. $priceJson = $modelRow->price_json;
  419. $prices = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson;
  420. if (!is_array($prices) || empty($prices)) {
  421. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  422. }
  423. $price = $prices[$resolution] ?? null;
  424. if ($price === null || (float)$price <= 0) {
  425. return self::DEFAULT_VIDEO_CHARGE_POINTS;
  426. }
  427. return (int)max(1, round((float)$price));
  428. }
  429. /**
  430. * 根据图片宽高归一化分辨率档位(1k/2k/4k)
  431. *
  432. * 按宽高乘积(面积)分档更准确:各档面积几乎成平方关系——
  433. * 1k≈1MP(1024x1024)、2k≈4MP(2048x2048、1600x2848)、3k≈9MP(3072x3072、4096x2304)、4k≈16MP(4096x4096)。
  434. * 目前没有 3k 计费档,3k 尺寸直接按 4k 计费。
  435. *
  436. * @param int $width
  437. * @param int $height
  438. * @return string
  439. */
  440. public function normalizeImageResolutionKey(int $width, int $height): string
  441. {
  442. if ($width <= 0 || $height <= 0) {
  443. return '2k';
  444. }
  445. // 面积分档:<2MP 视为 1k;2MP~6MP 视为 2k;≥6MP(含3k/4k尺寸)按 4k 计费
  446. $area = $width * $height;
  447. if ($area < 2048 * 1024) {
  448. return '1k';
  449. }
  450. if ($area < 2048 * 3072) {
  451. return '2k';
  452. }
  453. return '4k';
  454. }
  455. /**
  456. * 获取图片生成任务实际消耗的 token 量
  457. *
  458. * 图片接口返回格式:result_json.usage.total_tokens / output_tokens
  459. *
  460. * @param MpGeneratePicTask $task
  461. * @return int
  462. */
  463. public function getImageTokensConsumed(MpGeneratePicTask $task): int
  464. {
  465. $resultJson = $task->result_json;
  466. if (is_string($resultJson)) {
  467. $resultJson = json_decode($resultJson, true);
  468. }
  469. if (!is_array($resultJson)) {
  470. return 0;
  471. }
  472. $tokens = $resultJson['usage']['total_tokens'] ?? $resultJson['usage']['output_tokens'] ?? 0;
  473. return (int)$tokens;
  474. }
  475. /**
  476. * 从 AI 对话接口返回的 usage 中提取消耗的 token 数
  477. *
  478. * DeepSeek/OpenAI 格式:usage.total_tokens / usage.completion_tokens
  479. * Gemini 格式:usageMetadata.totalTokenCount(含思考 token),缺省时按 prompt + candidates 合计
  480. *
  481. * @param mixed $usage
  482. * @return int
  483. */
  484. public function getTokensFromUsage($usage): int
  485. {
  486. if (is_string($usage)) {
  487. $usage = json_decode($usage, true);
  488. }
  489. if (!is_array($usage)) {
  490. return 0;
  491. }
  492. $tokens = $usage['total_tokens']
  493. ?? $usage['completion_tokens']
  494. ?? $usage['totalTokenCount']
  495. ?? (
  496. isset($usage['promptTokenCount']) || isset($usage['candidatesTokenCount'])
  497. ? (int)($usage['promptTokenCount'] ?? 0)
  498. + (int)($usage['candidatesTokenCount'] ?? 0)
  499. + (int)($usage['thoughtsTokenCount'] ?? 0)
  500. : 0
  501. );
  502. return (int)$tokens;
  503. }
  504. /**
  505. * AI对话调用成功后记录计费明细并扣减用户积分
  506. *
  507. * 无对应任务表,task_id 为 NULL((type, task_id) 唯一索引下多个 NULL 互不冲突)。
  508. *
  509. * @param int $uid
  510. * @param int $points
  511. * @param int $tokens
  512. * @param array $chargeInfo
  513. * @param string $remark 特殊备注(默认空,测试用户由内部追加[测试]仅记账不扣费)
  514. * @param string $apiType
  515. * @return array
  516. */
  517. public function recordChatCharge(int $uid, int $points, int $tokens, array $chargeInfo = [], string $remark = '', string $apiType = 'deepseek'): array
  518. {
  519. return $this->deductAndRecord(
  520. $uid,
  521. null,
  522. MpUserPointsDetail::TYPE_CHAT,
  523. $apiType,
  524. (float)$points,
  525. $tokens,
  526. $chargeInfo,
  527. $remark
  528. );
  529. }
  530. /**
  531. * 图片生成成功后记录计费明细并扣减用户积分
  532. *
  533. * 幂等处理:同一任务只允许计费一次(type + task_id 唯一索引兜底)。
  534. *
  535. * @param MpGeneratePicTask $task
  536. * @return array
  537. */
  538. public function recordImageTaskCharge(MpGeneratePicTask $task): array
  539. {
  540. // 重复计费保护
  541. $exists = DB::table('mp_user_points_details')
  542. ->where('type', MpUserPointsDetail::TYPE_IMAGE)
  543. ->where('task_id', $task->id)
  544. ->exists();
  545. if ($exists) {
  546. return ['charged' => false, 'reason' => 'already_charged', 'task_id' => $task->id];
  547. }
  548. $chargeInfo = $task->charge_info;
  549. if (is_string($chargeInfo)) {
  550. $chargeInfo = json_decode($chargeInfo, true);
  551. }
  552. if (!is_array($chargeInfo) || empty($chargeInfo['user_id'])) {
  553. dLog('points')->warning('图片任务缺少计费信息,跳过扣费', ['task_id' => $task->id]);
  554. return ['charged' => false, 'reason' => 'no_charge_info', 'task_id' => $task->id];
  555. }
  556. $uid = (int)$chargeInfo['user_id'];
  557. // 积分 = 单张价格 × 实际生成图片数
  558. $pointsPerImage = (float)$this->getImageChargePoints($chargeInfo);
  559. $imageCount = is_array($task->result_url) ? count($task->result_url) : 0;
  560. if ($imageCount <= 0) {
  561. $imageCount = (int)($chargeInfo['image_num'] ?? 1);
  562. }
  563. if ($imageCount <= 0) {
  564. $imageCount = 1;
  565. }
  566. // 单张价格 × 实际生成图片数(未配置价格时为0,仍记录明细与token)
  567. $points = (float)round($pointsPerImage * $imageCount);
  568. $tokens = $this->getImageTokensConsumed($task);
  569. return $this->deductAndRecord(
  570. $uid,
  571. $task->id,
  572. MpUserPointsDetail::TYPE_IMAGE,
  573. (string)getProp($task, 'model', ''),
  574. $points,
  575. $tokens,
  576. $chargeInfo,
  577. ''
  578. );
  579. }
  580. /**
  581. * 扣减积分并记录积分使用明细(视频/图片共用)
  582. *
  583. * @param int $uid
  584. * @param int $taskId
  585. * @param string $type
  586. * @param string $apiType
  587. * @param float $points
  588. * @param int $tokens
  589. * @param array $chargeInfo
  590. * @param string $remark
  591. * @return array
  592. */
  593. private function deductAndRecord(int $uid, ?int $taskId, string $type, string $apiType, float $points, int $tokens, array $chargeInfo, string $remark): array
  594. {
  595. try {
  596. DB::beginTransaction();
  597. $user = DB::table('mp_manage_users')->where('id', $uid)->first();
  598. if (!$user) {
  599. DB::rollBack();
  600. dLog('points')->error('扣费失败:用户不存在', ['task_id' => $taskId, 'uid' => $uid]);
  601. return ['charged' => false, 'reason' => 'user_not_found', 'task_id' => $taskId];
  602. }
  603. $pointsBefore = (float)getProp($user, 'points', 0);
  604. // 测试用户(TEST_CPID / TEST_UID 白名单):只记录明细,不实际扣减积分余额
  605. $isTestUser = $this->isTestUser($uid, (int)getProp($user, 'cpid', 0));
  606. $pointsAfter = $isTestUser ? $pointsBefore : $pointsBefore - $points;
  607. // 测试标记落库:charge_info 写入 test_mode,remark 追加 [测试],便于统计排除与历史排查
  608. if ($isTestUser) {
  609. $chargeInfo['test_mode'] = true;
  610. $remark = trim(($remark ? $remark . ';' : '') . '[测试]仅记账不扣费');
  611. }
  612. if (!$isTestUser && $pointsBefore < $points) {
  613. dLog('points')->warning('用户积分不足,扣费后积分为负数', [
  614. 'task_id' => $taskId,
  615. 'uid' => $uid,
  616. 'points_before' => $pointsBefore,
  617. 'points_consumed' => $points
  618. ]);
  619. }
  620. if (!$isTestUser) {
  621. // 原子扣减积分:基于数据库当前值执行(points = points - X),
  622. // 避免并发处理不同任务时基于旧快照覆盖写入导致丢失更新(少扣)
  623. DB::table('mp_manage_users')->where('id', $uid)->update([
  624. 'points' => DB::raw('points - ' . (float)$points),
  625. 'updated_at' => date('Y-m-d H:i:s')
  626. ]);
  627. }
  628. // 记录积分使用明细
  629. DB::table('mp_user_points_details')->insert([
  630. 'uid' => $uid,
  631. 'cpid' => (int)getProp($user, 'cpid', 0),
  632. 'task_id' => $taskId,
  633. 'type' => $type,
  634. 'api_type' => $apiType,
  635. 'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE),
  636. 'points_consumed' => $points,
  637. 'points_before' => $pointsBefore,
  638. 'points_after' => $pointsAfter,
  639. 'tokens_consumed' => $tokens,
  640. 'remark' => $remark,
  641. 'created_at' => date('Y-m-d H:i:s'),
  642. 'updated_at' => date('Y-m-d H:i:s')
  643. ]);
  644. DB::commit();
  645. dLog('points')->info($type . '计费成功', [
  646. 'task_id' => $taskId,
  647. 'uid' => $uid,
  648. 'points_consumed' => $points,
  649. 'points_after' => $pointsAfter,
  650. 'tokens_consumed' => $tokens,
  651. 'test_mode' => $isTestUser
  652. ]);
  653. return [
  654. 'charged' => true,
  655. 'task_id' => $taskId,
  656. 'uid' => $uid,
  657. 'points_consumed' => $points,
  658. 'points_before' => $pointsBefore,
  659. 'points_after' => $pointsAfter,
  660. 'tokens_consumed' => $tokens,
  661. 'test_mode' => $isTestUser
  662. ];
  663. } catch (\Exception $e) {
  664. DB::rollBack();
  665. // 唯一索引冲突:同一任务已被其他请求计费,视为已计费(并发验重兜底,避免误报计费失败)
  666. if ($this->isDuplicateCharge($e)) {
  667. return ['charged' => false, 'reason' => 'already_charged', 'task_id' => $taskId];
  668. }
  669. dLog('points')->error($type . '计费失败: ' . $e->getMessage(), ['task_id' => $taskId]);
  670. logDB('points', 'error', $type . '计费失败', [
  671. 'task_id' => $taskId,
  672. 'error' => $e->getMessage()
  673. ]);
  674. return ['charged' => false, 'reason' => 'exception: ' . $e->getMessage(), 'task_id' => $taskId];
  675. }
  676. }
  677. /**
  678. * 判断异常是否为数据库唯一键冲突(重复计费)
  679. *
  680. * @param \Exception $e
  681. * @return bool
  682. */
  683. private function isDuplicateCharge(\Exception $e): bool
  684. {
  685. // MySQL 重复键:SQLSTATE 23000 / 错误码 1062(Duplicate entry)
  686. $code = $e->getCode();
  687. if ($code === 23000 || $code === '23000' || $code === 1062 || $code === '1062') {
  688. return true;
  689. }
  690. return mb_strpos($e->getMessage(), 'Duplicate entry') !== false;
  691. }
  692. }