Просмотр исходного кода

修复部分接口使用AI文生文时未记录token的情况

lh 1 месяц назад
Родитель
Сommit
08c5bffa43

+ 20 - 0
app/Services/Anime/AnimeService.php

@@ -6503,6 +6503,26 @@ class AnimeService
             // 调用DeepSeek服务的volcEngineChatCompletion方法(非流式)
             $result = $this->DeepSeekService->volcEngineChatCompletion($params);
             
+            // 仅记录 token 使用明细(不扣积分)
+            $uid = 0;
+            try {
+                $uid = (int)Site::getUid();
+            } catch (\Throwable $e) {
+                // 非请求上下文(如命令行)下可能无法获取用户ID,置为0跳过记录
+            }
+            $this->pointsService->recordTokenOnlyDetail(
+                $uid,
+                $this->pointsService->getTokensFromUsage($result['usage'] ?? []),
+                [
+                    'model' => $model,
+                    'img_url' => $img_url,
+                    'source' => 'generateImagePromptFromUrl',
+                ],
+                'chat',
+                $model,
+                ''
+            );
+
             // 返回生成的内容
             return getProp($result, 'fullContent', '图片描述生成失败');
             

+ 136 - 1
app/Services/DeepSeek/DeepSeekService.php

@@ -9,6 +9,7 @@ use App\Libs\Utils;
 use GuzzleHttp\Client;
 use DateTime;
 use DateTimeZone;
+use App\Models\MpUserPointsDetail;
 use App\Services\AIGeneration\AIImageGenerationService;
 use App\Services\PointsService;
 use Illuminate\Support\Facades\DB;
@@ -332,7 +333,32 @@ class DeepSeekService
         if ($chargePoints > 0) {
             return $this->wrapGenerateTextCharge($streamGenerator, $uid, $model, $chargePoints, $chargeModality);
         }
-        return $streamGenerator;
+
+        // 纯文本(不扣积分):流式生成成功后仅记录 token 使用明细
+        return $this->wrapGenerateTextTokenRecord($streamGenerator, $uid, $model);
+    }
+
+    /**
+     * 流式生成成功后仅记录 token 明细(纯文本免费场景,不扣积分)
+     *
+     * @param \Generator $generator
+     * @param int        $uid
+     * @param string     $model
+     * @return \Generator
+     */
+    private function wrapGenerateTextTokenRecord(\Generator $generator, int $uid, string $model): \Generator
+    {
+        $recorded = false;
+        foreach ($generator as $chunk) {
+            if (!$recorded && isset($chunk['type']) && $chunk['type'] === 'done') {
+                $this->recordTextTokenOnly($uid, $this->pointsService->getTokensFromUsage(getProp($chunk, 'usage', [])), [
+                    'model' => $model,
+                    'source' => 'generateText',
+                ], $model);
+                $recorded = true;
+            }
+            yield $chunk;
+        }
     }
 
     /**
@@ -509,6 +535,53 @@ class DeepSeekService
             throw new \Exception('AI对话计费失败: ' . (string)($result['reason'] ?? 'unknown'));
         }
     }
+
+    /**
+     * 安全获取当前登录用户ID
+     *
+     * 命令行/队列等无用户上下文的场景返回 0,由记录方法自行跳过。
+     *
+     * @return int
+     */
+    private function getCurrentUid(): int
+    {
+        try {
+            return (int)Site::getUid();
+        } catch (\Throwable $e) {
+            return 0;
+        }
+    }
+
+    /**
+     * 记录文生文 token 使用明细(仅记录,不扣积分)
+     *
+     * 用于不按积分计费的文生文接口;记录失败不影响主流程。
+     *
+     * @param int $uid 用户ID(0 时跳过)
+     * @param int $tokens 消耗 token 数
+     * @param array $chargeInfo 计费/来源信息(model、source 等)
+     * @param string $apiType API 类型/模型标识
+     * @return void
+     */
+    private function recordTextTokenOnly(int $uid, int $tokens, array $chargeInfo, string $apiType = ''): void
+    {
+        try {
+            $this->pointsService->recordTokenOnlyDetail(
+                $uid,
+                $tokens,
+                $chargeInfo,
+                MpUserPointsDetail::TYPE_CHAT,
+                $apiType,
+                ''
+            );
+        } catch (\Throwable $e) {
+            dLog('points')->warning('token明细记录异常(不影响主流程): ' . $e->getMessage(), [
+                'uid' => $uid,
+                'source' => $chargeInfo['source'] ?? '',
+            ]);
+        }
+    }
+
     /**
      * 拆分剧本内容并校验章节总数与标题连续性
      *
@@ -680,6 +753,12 @@ class DeepSeekService
             if (preg_match('/"invalid_index"\s*:\s*(\d+)/i', $content, $match)) {
                 $result['invalid_index'] = (int)$match[1];
             }
+
+            // 仅记录 token 使用明细(不扣积分):该校验为流程内的额外模型调用
+            $this->recordTextTokenOnly($this->getCurrentUid(), $this->pointsService->getTokensFromUsage($result['usage'] ?? []), [
+                'model' => 'deepseek-v4-flash',
+                'source' => 'validateChapterSequenceByAI',
+            ], 'deepseek-v4-flash');
         } catch (\Exception $e) {
             dLog('deepseek')->error('章节连续性AI校验失败: ' . $e->getMessage());
         }
@@ -3028,6 +3107,12 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
             'words'             => getProp($script_content, 'words'),
         ];
 
+        // 仅记录 token 使用明细(不扣积分)
+        $this->recordTextTokenOnly($this->getCurrentUid(), $this->pointsService->getTokensFromUsage($usage), [
+            'model' => $model,
+            'source' => 'chatWithReasoner',
+        ], $model);
+
         return $result;
     }
 
@@ -4116,6 +4201,14 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
         }
         DB::commit();
 
+        // 仅记录 token 使用明细(不扣积分)
+        $this->recordTextTokenOnly($this->getCurrentUid(), $this->pointsService->getTokensFromUsage($usage), [
+            'model' => $model,
+            'script_id' => $script_id,
+            'group_id' => $group_id,
+            'source' => 'generateEpisodes',
+        ], $model);
+
         yield [
             'type' => 'done',
             'script' => $script_arr,
@@ -4407,6 +4500,14 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
         }
         DB::commit();
 
+        // 仅记录 token 使用明细(不扣积分)
+        $this->recordTextTokenOnly($this->getCurrentUid(), $this->pointsService->getTokensFromUsage($usage), [
+            'model' => $model,
+            'script_id' => $script_id,
+            'group_id' => $group_id,
+            'source' => 'chatWithFileStream',
+        ], $model);
+
         yield [
             'type' => 'done',
             'script' => $script_arr,
@@ -4546,6 +4647,13 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
             'updated_at' => date('Y-m-d H:i:s')
         ]);
 
+        // 仅记录 token 使用明细(不扣积分)
+        $this->recordTextTokenOnly($this->getCurrentUid(), $this->pointsService->getTokensFromUsage($usage), [
+            'model' => $model,
+            'script_id' => $script_id,
+            'source' => 'chatWithFile',
+        ], $model);
+
         return [
             'script'        => $script_arr,
             'answer'        => $fullContent,
@@ -5799,6 +5907,8 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
                             $this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
                                 'anime_id' => $anime_id,
                                 'episode_id' => $episode_id ?? 0,
+                                'model' => $model,
+                                'source' => 'addChat',
                             ]);
                         }
                     }
@@ -6611,6 +6721,8 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
                             $this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
                                 'anime_id' => $anime_id,
                                 'episode_id' => $episode_id ?? 0,
+                                'model' => $model,
+                                'source' => 'reGenerateAnime',
                             ]);
                         }
                     }
@@ -7383,6 +7495,7 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
                 'anime_id' => $anime_id,
                 'episode_number' => $episode_number,
                 'model' => $model,
+                'source' => 'chat',
             ]);
         }catch (\Exception $e) {
             DB::rollBack();
@@ -8317,6 +8430,8 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
                 $this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
                     'anime_id' => $anime_id,
                     'episode_id' => $episode_id ?? 0,
+                    'model' => $model,
+                    'source' => 'addChatForAce',
                 ]);
             }
         }catch (\Exception $e) {
@@ -9303,6 +9418,8 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
                 $this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
                     'anime_id' => $anime_id,
                     'episode_id' => $episode_id ?? 0,
+                    'model' => $model,
+                    'source' => 'reGenerateAnimeForAce',
                 ]);
             }
             
@@ -10807,6 +10924,7 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
                 'anime_id' => $anime_id,
                 'episode_number' => $episode_number,
                 'model' => $model,
+                'source' => 'chatForAce',
             ]);
         }catch (\Exception $e) {
             DB::rollBack();
@@ -11993,6 +12111,8 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
                     $this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
                         'anime_id' => $target_anime_id,
                         'episode_id' => $target_episode_id,
+                        'model' => $model,
+                        'source' => 'regenerateSegmentScript',
                     ]);
                     
                     DB::commit();
@@ -13716,6 +13836,12 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
             Utils::throwError('20003: 接口调用失败!');
         }
 
+        // 仅记录 token 使用明细(不扣积分)
+        $this->recordTextTokenOnly($this->getCurrentUid(), $this->pointsService->getTokensFromUsage($usage), [
+            'model' => $model,
+            'source' => 'chatChangeImg',
+        ], $model);
+
         return [
             'type' => 'done',
             // 'episode' => $episode,
@@ -14289,6 +14415,7 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
 
         // 根据模型类型选择调用方法
         $fullContent = '';
+        $usage = [];
         
         if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) {
             // DeepSeek 官方模型使用 DeepSeek API
@@ -14303,8 +14430,16 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
         
         if (is_array($chatResult)) {
             $fullContent = $chatResult['fullContent'];
+            $usage = $chatResult['usage'] ?? [];
         }
 
+        // 仅记录 token 使用明细(不扣积分)
+        $this->recordTextTokenOnly($this->getCurrentUid(), $this->pointsService->getTokensFromUsage($usage), [
+            'model' => $model,
+            'cid' => $cid,
+            'source' => 'generateScriptWords',
+        ], $model);
+
         return $fullContent;
     }
 

+ 69 - 0
app/Services/PointsService.php

@@ -591,6 +591,75 @@ class PointsService
     }
 
     /**
+     * 仅记录 token 使用明细,不扣减积分、不写积分消耗
+     *
+     * 用于不按积分计费的文生文接口(免费/不扣积分场景),复用 mp_user_points_details 表:
+     * points_consumed=0,仅填写 tokens_consumed,保证累计 token 统计口径一致。
+     * uid<=0(无登录上下文)或 tokens<=0 时不落库。
+     *
+     * @param int $uid 用户ID
+     * @param int $tokens 消耗 token 数
+     * @param array $chargeInfo 计费/来源信息(model、source 等)
+     * @param string $type 明细类型(默认 chat)
+     * @param string $apiType API 类型/模型标识
+     * @param string $remark 备注
+     * @return bool 是否落库成功
+     */
+    public function recordTokenOnlyDetail(int $uid, int $tokens, array $chargeInfo = [], string $type = MpUserPointsDetail::TYPE_CHAT, string $apiType = '', string $remark = ''): bool
+    {
+        if ($uid <= 0 || $tokens <= 0) {
+            return false;
+        }
+
+        try {
+            $user = DB::table('mp_manage_users')->where('id', $uid)->first();
+            $cpid = (int)getProp($user, 'cpid', 0);
+            $pointsBalance = (float)getProp($user, 'points', 0);
+
+            // 测试用户标记:保证统计排除口径与扣费明细一致
+            $isTestUser = $this->isTestUser($uid, $cpid);
+            if ($isTestUser) {
+                $chargeInfo['test_mode'] = true;
+                $remark = trim(($remark ? $remark . ';' : '') . '[测试]仅记账不扣费');
+            }
+
+            DB::table('mp_user_points_details')->insert([
+                'uid' => $uid,
+                'cpid' => $cpid,
+                'task_id' => null,
+                'type' => $type,
+                'api_type' => $apiType,
+                'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE),
+                'points_consumed' => 0,
+                'points_before' => $pointsBalance,
+                'points_after' => $pointsBalance,
+                'tokens_consumed' => $tokens,
+                'remark' => $remark,
+                'created_at' => date('Y-m-d H:i:s'),
+                'updated_at' => date('Y-m-d H:i:s')
+            ]);
+
+            dLog('points')->info('token明细记录成功', [
+                'uid' => $uid,
+                'tokens_consumed' => $tokens,
+                'source' => $chargeInfo['source'] ?? '',
+                'test_mode' => $isTestUser
+            ]);
+
+            return true;
+
+        } catch (\Exception $e) {
+            dLog('points')->error('token明细记录失败: ' . $e->getMessage(), ['uid' => $uid]);
+            logDB('points', 'error', 'token明细记录失败', [
+                'uid' => $uid,
+                'tokens_consumed' => $tokens,
+                'error' => $e->getMessage()
+            ]);
+            return false;
+        }
+    }
+
+    /**
      * 图片生成成功后记录计费明细并扣减用户积分
      *
      * 幂等处理:同一任务只允许计费一次(type + task_id 唯一索引兜底)。