Procházet zdrojové kódy

1.新增生成策划文档的计费规则2.新增chat类型的计费类型与逻辑

lh před 1 měsícem
rodič
revize
5fa8153c70

+ 1 - 0
app/Models/MpUserPointsDetail.php

@@ -10,6 +10,7 @@ class MpUserPointsDetail extends Model
 
     const TYPE_VIDEO = 'video';
     const TYPE_IMAGE = 'image';
+    const TYPE_CHAT = 'chat';
 
     protected $fillable = [
         'uid',

+ 5 - 5
app/Services/Anime/AnimeService.php

@@ -4880,11 +4880,11 @@ class AnimeService
         if (!DB::table('mp_video_models')->where('model', $model)->where('is_enabled', 1)->exists()) {
             $model = 'zhizhen-20';
         }
-        // 保存到episode表
-        DB::table('mp_anime_episodes')->where('id', $episode_id)->update([
-            'video_model' => $model,
-            'updated_at' => date('Y-m-d H:i:s')
-        ]);
+        // 保存到episode表(仅在专用方法中更新模型)
+        // DB::table('mp_anime_episodes')->where('id', $episode_id)->update([
+        //     'video_model' => $model,
+        //     'updated_at' => date('Y-m-d H:i:s')
+        // ]);
 
         // 余额预检:按预估时长计算所需积分,不足直接报错
         $chargeDuration = (int)$videoDuration;

+ 98 - 3
app/Services/DeepSeek/DeepSeekService.php

@@ -10,6 +10,7 @@ use GuzzleHttp\Client;
 use DateTime;
 use DateTimeZone;
 use App\Services\AIGeneration\AIImageGenerationService;
+use App\Services\PointsService;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Redis;
@@ -29,9 +30,11 @@ class DeepSeekService
     private $valid_text_models;
     private $gpt_text_models;
     protected $aiImageGenerationService;
+    protected $pointsService;
 
-    public function __construct(AIImageGenerationService $aiImageGenerationService) {
+    public function __construct(AIImageGenerationService $aiImageGenerationService, PointsService $pointsService) {
         $this->aiImageGenerationService = $aiImageGenerationService;
+        $this->pointsService = $pointsService;
         $this->url = 'https://api.deepseek.com/chat/completions';   // DeepSeek API请求地址
         $this->api_key = env('DEEPSEEK_API_KEY');
         $this->headers = [
@@ -305,6 +308,29 @@ class DeepSeekService
             'script_name' => $script_name
         ];
     }
+
+    /**
+     * AI对话调用成功后的积分扣费(返回结果前调用)
+     *
+     * 计费失败抛错,由调用方事务回滚,避免“成功但未扣费”。
+     *
+     * @param int $uid
+     * @param int $tokens
+     * @param array $chargeInfo
+     * @return void
+     */
+    private function chargeChatSuccess(int $uid, int $tokens, array $chargeInfo): void
+    {
+        $result = $this->pointsService->recordChatCharge(
+            $uid,
+            PointsService::CHAT_CHARGE_POINTS,
+            $tokens,
+            $chargeInfo
+        );
+        if (empty($result['charged'])) {
+            throw new \Exception('AI对话计费失败: ' . (string)($result['reason'] ?? 'unknown'));
+        }
+    }
     
     /**
      * 通用文生文方法(非流式版本)- 支持多模型、图片输入、JSON输出和模板提示词
@@ -1193,6 +1219,8 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
     private function deepSeekStreamResponse($post_data) {
         // 设置最大输出tokens
         $post_data['max_tokens'] = 300000;
+        // 流式请求显式声明返回 usage(在最后一个 chunk 中返回)
+        $post_data['stream_options'] = ['include_usage' => true];
 
         $client = new Client(['timeout' => 1800, 'verify' => false]);
         $response = $client->post($this->url, [
@@ -1324,6 +1352,8 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
             unset($post_data['reasoning_effort']);
         }
         $post_data['max_completion_tokens'] = 100000;
+        // 流式请求显式声明返回 usage(在最后一个 chunk 中返回)
+        $post_data['stream_options'] = ['include_usage' => true];
 
         // 备用中转站地址1: https://token.ithinkai.cn/v1/chat/completions
         // 备用中转站地址2: https://api.nonelinear.com/v1/chat/completions
@@ -5866,6 +5896,11 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
         $prompt = getProp($data, 'prompt');
         $is_multi = getProp($anime, 'is_multi');
         $is_single = (int)$is_multi !== 1;
+
+        // 积分余额预检(仅单剧集模式收费)
+        if ($is_single) {
+            $this->pointsService->checkUserPointsEnough(PointsService::CHAT_CHARGE_POINTS, $uid);
+        }
         $extra_products = getProp($data, 'products', []);
         if (!$extra_products) {
             $extra_products = getProp($anime, 'extra_products');
@@ -6728,6 +6763,14 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
                     Utils::throwError('20003:单剧集分镜记录保存失败');
                 }
             }
+
+            // 单剧集模式调用成功,返回结果前扣除积分并记录明细(与保存同事务)
+            if ($is_single && !empty($single_episode)) {
+                $this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
+                    'anime_id' => $anime_id,
+                    'episode_id' => $episode_id ?? 0,
+                ]);
+            }
         }catch (\Exception $e) {
             DB::rollBack();
             dLog('deepseek')->info('新建对话错误信息: '.$e->getMessage());
@@ -6785,6 +6828,11 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
         }
         $is_multi = getProp($anime, 'is_multi', 1);
         $is_single = (int)$is_multi !== 1;
+
+        // 积分余额预检(仅单剧集模式收费)
+        if ($is_single) {
+            $this->pointsService->checkUserPointsEnough(PointsService::CHAT_CHARGE_POINTS, $uid);
+        }
         $extra_products = getProp($data, 'products', []);
         if (!$extra_products) {
             $extra_products = getProp($anime, 'extra_products');
@@ -7697,6 +7745,14 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
                     Utils::throwError('20003:单剧集分镜记录保存失败');
                 }
             }
+
+            // 单剧集模式调用成功,返回结果前扣除积分并记录明细(与保存同事务)
+            if ($is_single && !empty($single_episode)) {
+                $this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
+                    'anime_id' => $anime_id,
+                    'episode_id' => $episode_id ?? 0,
+                ]);
+            }
             
         }catch (\Exception $e) {
             DB::rollBack();
@@ -7743,6 +7799,10 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
 
     public function chatForAce($data) {
         $uid = Site::getUid();
+
+        // 积分余额预检(不足直接报错)
+        $this->pointsService->checkUserPointsEnough(PointsService::CHAT_CHARGE_POINTS, $uid);
+
         $anime_id = getProp($data, 'anime_id');
         $file = getProp($data, 'file');
         $content = getProp($data, 'content', '');
@@ -8877,6 +8937,13 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
                 $update_anime_data['content'] = $uploaded_content;
             }
             DB::table('mp_animes')->where('id', $anime_id)->update($update_anime_data);
+
+            // 调用成功,返回结果前扣除积分并记录明细(与保存同事务)
+            $this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
+                'anime_id' => $anime_id,
+                'episode_number' => $episode_number,
+                'model' => $model,
+            ]);
         }catch (\Exception $e) {
             DB::rollBack();
             dLog('deepseek')->info('新建对话错误信息: '.$e->getMessage());
@@ -9043,7 +9110,22 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
         if ($end_episode_sequence && $endEpisodeNumber > $end_episode_sequence) {
             Utils::throwError("20003:总集数只有{$end_episode_sequence}集,无法生成到第{$endEpisodeNumber}集");
         }
-        
+
+        // 计算本次实际需要创建的任务数(跳过已存在且未失败的任务;失败任务会删除重建,算作创建)
+        $existingSkippedCount = DB::table('mp_batch_episode_generation_details')
+            ->where('anime_id', $anime_id)
+            ->whereBetween('episode_number', [$startEpisodeNumber, $endEpisodeNumber])
+            ->whereIn('status', ['pending', 'processing', 'completed'])
+            ->count();
+
+        $needEpisodeCount = ($endEpisodeNumber - $startEpisodeNumber + 1) - $existingSkippedCount;
+
+        // 积分余额预检:每个剧集生成成功将扣 CHAT_CHARGE_POINTS 积分。
+        // 此处仅预检不扣费,实际扣费由定时任务调用 chatForAce(经 chatForAceNonStream)时按集扣取,避免重复扣费。
+        if ($needEpisodeCount > 0) {
+            $this->pointsService->checkUserPointsEnough(PointsService::CHAT_CHARGE_POINTS * $needEpisodeCount, $uid);
+        }
+
         $now = date('Y-m-d H:i:s');
         $createdTasks = [];
         $skippedTasks = [];
@@ -9412,6 +9494,11 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
         if (!empty($act_id) && !empty($episode_id)) {
             Utils::throwError('20003:片段ID和剧集ID只能选择一个');
         }
+
+        // 积分余额预检(仅剧集ID模式收费)
+        if (!empty($episode_id)) {
+            $this->pointsService->checkUserPointsEnough(PointsService::CHAT_CHARGE_POINTS, $uid);
+        }
         
         // 如果提供了template_id,获取模板提示词
         $template_prompt = '';
@@ -9992,6 +10079,12 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
                     if (!empty($segments_to_insert)) {
                         DB::table('mp_episode_segments')->insert($segments_to_insert);
                     }
+
+                    // 剧集ID模式调用成功,返回结果前扣除积分并记录明细(与保存同事务)
+                    $this->chargeChatSuccess($uid, $this->pointsService->getTokensFromUsage($usage), [
+                        'anime_id' => $target_anime_id,
+                        'episode_id' => $target_episode_id,
+                    ]);
                     
                     DB::commit();
                     
@@ -12406,9 +12499,11 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
         try {
             // 判断是否为流式输出
             $isStream = isset($params['stream']) && $params['stream'] === true;
-
+            
             if ($isStream) {
                 // 流式输出
+                // 流式请求默认声明返回 usage(未显式传参时)
+                $requestData['stream_options'] = $requestData['stream_options'] ?? ['include_usage' => true];
                 return $this->volcEngineChatCompletionStream($client, $apiKey, $requestData);
             } else {
                 // 非流式输出

+ 53 - 1
app/Services/PointsService.php

@@ -24,6 +24,11 @@ class PointsService
     const DEFAULT_VIDEO_CHARGE_POINTS = 0;
 
     /**
+     * AI对话(chatForAce / addChatForAce单剧集 / reGenerateAnimeForAce单剧集 / regenerateSegmentScript剧集模式)单次扣费积分数
+     */
+    const CHAT_CHARGE_POINTS = 10;
+
+    /**
      * 获取视频模型应扣积分数(公共方法)
      *
      * 计费规则从 mp_video_models 表读取:
@@ -458,6 +463,53 @@ class PointsService
     }
 
     /**
+     * 从 AI 对话接口返回的 usage 中提取消耗的 token 数
+     *
+     * DeepSeek/OpenAI 格式:usage.total_tokens / usage.completion_tokens
+     *
+     * @param mixed $usage
+     * @return int
+     */
+    public function getTokensFromUsage($usage): int
+    {
+        if (is_string($usage)) {
+            $usage = json_decode($usage, true);
+        }
+        if (!is_array($usage)) {
+            return 0;
+        }
+
+        $tokens = $usage['total_tokens'] ?? $usage['completion_tokens'] ?? 0;
+        return (int)$tokens;
+    }
+
+    /**
+     * AI对话调用成功后记录计费明细并扣减用户积分
+     *
+     * 无对应任务表,task_id 为 NULL((type, task_id) 唯一索引下多个 NULL 互不冲突)。
+     *
+     * @param int $uid
+     * @param int $points
+     * @param int $tokens
+     * @param array $chargeInfo
+     * @param string $remark
+     * @return array
+     */
+    public function recordChatCharge(int $uid, int $points, int $tokens, array $chargeInfo = [], string $remark = 'AI对话生成成功计费'): array
+    {
+        return $this->deductAndRecord(
+            $uid,
+            null,
+            MpUserPointsDetail::TYPE_CHAT,
+            'deepseek',
+            (float)$points,
+            $tokens,
+            $chargeInfo,
+            $remark
+        );
+    }
+
+    /**
      * 图片生成成功后记录计费明细并扣减用户积分
      *
      * 幂等处理:同一任务只允许计费一次(type + task_id 唯一索引兜底)。
@@ -525,7 +577,7 @@ class PointsService
      * @param string $remark
      * @return array
      */
-    private function deductAndRecord(int $uid, int $taskId, string $type, string $apiType, float $points, int $tokens, array $chargeInfo, string $remark): array
+    private function deductAndRecord(int $uid, ?int $taskId, string $type, string $apiType, float $points, int $tokens, array $chargeInfo, string $remark): array
     {
         try {
             DB::beginTransaction();

+ 30 - 0
database/migrations/2026_08_05_000001_make_user_points_details_task_id_nullable.php

@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+
+class MakeUserPointsDetailsTaskIdNullable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * AI对话等无任务表的计费场景 task_id 允许为 NULL(唯一索引下多个 NULL 互不冲突),
+     * 视频/图片任务仍使用各自的 task_id 保证幂等。
+     *
+     * @return void
+     */
+    public function up()
+    {
+        DB::statement('ALTER TABLE mp_user_points_details MODIFY task_id BIGINT UNSIGNED NULL COMMENT \'任务ID(视频/图片任务;AI对话等无任务场景为NULL)\'');
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        DB::statement('ALTER TABLE mp_user_points_details MODIFY task_id BIGINT UNSIGNED NOT NULL');
+    }
+}