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

1.新增获取和修改积分规则的接口2.对话接口兼容按模型收费

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

+ 63 - 0
app/Http/Controllers/Points/PointsController.php

@@ -3,10 +3,12 @@
 namespace App\Http\Controllers\Points;
 
 use App\Libs\ApiResponse;
+use App\Libs\Utils;
 use App\Services\PointsService;
 use App\Transformer\Points\PointsTransformer;
 use Illuminate\Http\Request;
 use Illuminate\Routing\Controller as BaseController;
+use Illuminate\Support\Facades\Validator;
 
 class PointsController extends BaseController
 {
@@ -35,4 +37,65 @@ class PointsController extends BaseController
             'list' => $transformer->newEachPointsRecord($result['records']),
         ]);
     }
+
+    /**
+     * 获取系统当前全部积分规则(文生文/图片/视频)
+     *
+     * 供前端积分规则管理页展示:
+     * - chat:文生文(对话)扣分规则(默认10,支持按模型配置)
+     * - image_models:图片模型计费规则
+     * - video_models:视频模型计费规则
+     *
+     * @param Request $request
+     * @return mixed
+     */
+    public function rules(Request $request)
+    {
+        return $this->success($this->pointsService->getPointsRules());
+    }
+
+    /**
+     * 编辑积分规则(文生文/图片/视频)
+     *
+     * 入参三个区块均可选传,单次至少传一个:
+     * - text_rules:文生文规则列表 [{model?, charge_type?, price_json?}],model 可不传,不传时更新全部文生文模型
+     * - image_rules:图片规则列表 [{model, charge_type?, price_json?}]
+     * - video_rules:视频规则列表 [{model, charge_type?, price_json?}]
+     * 图片/视频规则 model 必填;每条规则 charge_type / price_json 至少传一个(未传字段保留原值)。
+     *
+     * @param Request $request
+     * @return mixed
+     */
+    public function editRules(Request $request)
+    {
+        $data = $request->all();
+
+        $validator = Validator::make($data, [
+            'text_rules' => 'nullable|array',
+            'image_rules' => 'nullable|array',
+            'video_rules' => 'nullable|array',
+        ], [
+            'text_rules.array' => 'text_rules必须为数组',
+            'image_rules.array' => 'image_rules必须为数组',
+            'video_rules.array' => 'video_rules必须为数组',
+        ]);
+
+        if ($validator->fails()) {
+            Utils::throwError('1002:' . $validator->errors()->first());
+        }
+
+        $hasText = isset($data['text_rules']) && !empty($data['text_rules']);
+        $hasImage = isset($data['image_rules']) && !empty($data['image_rules']);
+        $hasVideo = isset($data['video_rules']) && !empty($data['video_rules']);
+        if (!$hasText && !$hasImage && !$hasVideo) {
+            Utils::throwError('1002:请至少传入text_rules、image_rules或video_rules之一');
+        }
+
+        $updated = $this->pointsService->updatePointsRules($data);
+
+        return $this->success([
+            'updated' => $updated,
+            'remark' => '计费规则已更新,后续生成任务按新规则扣费',
+        ]);
+    }
 }

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

@@ -5468,7 +5468,7 @@ class AnimeService
                     $count = 0;
                 }
             }
-            $points = PointsService::CHAT_CHARGE_POINTS * $count;
+            $points = $this->pointsService->getChatChargePoints((string)$model) * $count;
             $items[] = [
                 'type' => 'chat',
                 'count' => $count,

+ 10 - 10
app/Services/DeepSeek/DeepSeekService.php

@@ -527,7 +527,7 @@ class DeepSeekService
     {
         $result = $this->pointsService->recordChatCharge(
             $uid,
-            PointsService::CHAT_CHARGE_POINTS,
+            $this->pointsService->getChatChargePoints((string)getProp($chargeInfo, 'model', '')),
             $tokens,
             $chargeInfo
         );
@@ -1034,7 +1034,7 @@ class DeepSeekService
 
         // 积分余额预检(仅存在有效剧本时按 chat 类型计费,不足直接报错)
         if ($hasValidScript) {
-            $this->pointsService->checkUserPointsEnough(PointsService::CHAT_CHARGE_POINTS, $uid);
+            $this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints((string)$model), $uid);
         }
 
         // 根据模型类型选择调用方法(复用现有的chatOnly方法)
@@ -5240,7 +5240,7 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
 
         // 积分余额预检(仅单剧集模式收费)
         if ($is_single) {
-            $this->pointsService->checkUserPointsEnough(PointsService::CHAT_CHARGE_POINTS, $uid);
+            $this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints((string)$model), $uid);
         }
         $question = getProp($data, 'question', $is_single ? "请根据实例格式完成单剧集剧本及分镜\n" : "请根据实例格式完成剧本大纲\n");
         if ($prompt) {
@@ -5989,7 +5989,7 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
 
         // 积分余额预检(仅单剧集模式收费)
         if ($is_single) {
-            $this->pointsService->checkUserPointsEnough(PointsService::CHAT_CHARGE_POINTS, $uid);
+            $this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints((string)$model), $uid);
         }
         
         // 单剧集模式下,如果已经生成了剧集,则不允许继续调整大纲
@@ -6773,7 +6773,7 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
         $uid = Site::getUid();
 
         // 积分余额预检(不足直接报错)
-        $this->pointsService->checkUserPointsEnough(PointsService::CHAT_CHARGE_POINTS, $uid);
+        $this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints(), $uid);
 
         $anime_id = getProp($data, 'anime_id');
         $file = getProp($data, 'file');
@@ -7556,7 +7556,7 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
 
         // 积分余额预检(仅单剧集模式收费)
         if ($is_single) {
-            $this->pointsService->checkUserPointsEnough(PointsService::CHAT_CHARGE_POINTS, $uid);
+            $this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints((string)$model), $uid);
         }
         $extra_products = getProp($data, 'products', []);
         if (!$extra_products) {
@@ -8494,7 +8494,7 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
 
         // 积分余额预检(仅单剧集模式收费)
         if ($is_single) {
-            $this->pointsService->checkUserPointsEnough(PointsService::CHAT_CHARGE_POINTS, $uid);
+            $this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints((string)$model), $uid);
         }
         $extra_products = getProp($data, 'products', []);
         if (!$extra_products) {
@@ -9470,7 +9470,7 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
         $uid = Site::getUid();
 
         // 积分余额预检(不足直接报错)
-        $this->pointsService->checkUserPointsEnough(PointsService::CHAT_CHARGE_POINTS, $uid);
+        $this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints(), $uid);
 
         $anime_id = getProp($data, 'anime_id');
         $file = getProp($data, 'file');
@@ -11117,7 +11117,7 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
         // 积分余额预检:每个剧集生成成功将扣 CHAT_CHARGE_POINTS 积分。
         // 此处仅预检不扣费,实际扣费由定时任务调用 chatForAce(经 chatForAceNonStream)时按集扣取,避免重复扣费。
         if ($needEpisodeCount > 0) {
-            $this->pointsService->checkUserPointsEnough(PointsService::CHAT_CHARGE_POINTS * $needEpisodeCount, $uid);
+            $this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints() * $needEpisodeCount, $uid);
         }
 
         $now = date('Y-m-d H:i:s');
@@ -11524,7 +11524,7 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
 
         // 积分余额预检(仅剧集ID模式收费)
         if (!empty($episode_id)) {
-            $this->pointsService->checkUserPointsEnough(PointsService::CHAT_CHARGE_POINTS, $uid);
+            $this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints(), $uid);
         }
         
         // 如果提供了template_id,获取模板提示词

+ 261 - 0
app/Services/PointsService.php

@@ -25,10 +25,40 @@ class PointsService
 
     /**
      * AI对话(chatForAce / addChatForAce单剧集 / reGenerateAnimeForAce单剧集 / regenerateSegmentScript剧集模式)单次扣费积分数
+     *
+     * 该值为文生文(对话)的默认扣费积分数:
+     * 各模型可在 mp_text_models.price_json 中按 model 单独配置({"price": 10}),
+     * 未配置或模型不存在时回退此默认值。
      */
     const CHAT_CHARGE_POINTS = 10;
 
     /**
+     * 获取文生文(AI对话)单次应扣积分数(公共方法)
+     *
+     * 计费规则从 mp_text_models 表读取(charge_type=per_call,按次计费):
+     * - 传入 model 且该模型配置了 price_json.price 时,返回配置值(允许 0,即免费)
+     * - 模型未配置、模型不存在或未传 model 时,返回默认值 10
+     *
+     * @param string $model 文本模型 ID(如 deepseek-reasoner / doubao-seed-2-0-mini-260215)
+     * @return int
+     */
+    public function getChatChargePoints(string $model = ''): int
+    {
+        if ($model !== '') {
+            $modelRow = DB::table('mp_text_models')->where('model', $model)->first();
+            if ($modelRow && ($modelRow->charge_type ?? '') === 'per_call') {
+                $priceJson = $modelRow->price_json;
+                $priceRule = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson;
+                if (is_array($priceRule) && isset($priceRule['price']) && is_numeric($priceRule['price'])) {
+                    return (int)max(0, round((float)$priceRule['price']));
+                }
+            }
+        }
+
+        return self::CHAT_CHARGE_POINTS;
+    }
+
+    /**
      * 获取视频模型应扣积分数(公共方法)
      *
      * 计费规则从 mp_video_models 表读取:
@@ -219,6 +249,237 @@ class PointsService
     }
 
     /**
+     * 获取系统当前全部积分规则(公共方法)
+     *
+     * - chat:文生文(对话)规则,含全局默认扣分与各文本模型按 model 配置的扣分
+     * - image_models:图片模型计费规则(mp_image_models)
+     * - video_models:视频模型计费规则(mp_video_models)
+     *
+     * @return array
+     */
+    public function getPointsRules(): array
+    {
+        $textModels = DB::table('mp_text_models')->orderBy('order', 'desc')->orderBy('id', 'desc')->where('is_enabled', 1)->get();
+        $imageModels = DB::table('mp_image_models')->orderBy('order', 'desc')->orderBy('id', 'desc')->where('is_enabled', 1)->get();
+        $videoModels = DB::table('mp_video_models')->orderBy('order', 'desc')->orderBy('id', 'desc')->where('is_enabled', 1)->get();
+
+        // $textList = [];
+        // foreach ($textModels as $row) {
+        //     $item = $this->formatModelRule($row);
+        //     $item['charge_points'] = $this->getChatChargePoints((string)$row->model);
+        //     $textList[] = $item;
+        // }
+
+        $textModel = DB::table('mp_text_models')->where('is_enabled', 1)->whereNotNull('price_json')->first();
+        if ($textModel) {
+            $price = $this->getChatChargePoints($textModel->model);
+            $charge_type = 'per_call';
+        }else {
+            $price = self::CHAT_CHARGE_POINTS;
+            $charge_type = 'per_call';
+        }
+
+        return [
+            'chat' => [
+                'charge_type' => $charge_type,
+                'price_json' => ['price'=>$price],
+                // 'models' => $textList,
+            ],
+            'image_models' => array_map([$this, 'formatModelRule'], $imageModels->all()),
+            'video_models' => array_map([$this, 'formatModelRule'], $videoModels->all()),
+        ];
+    }
+
+    /**
+     * 将模型表中的计费配置行整理为前端友好结构
+     *
+     * price_json 统一解析为数组返回(空配置返回空对象)。
+     *
+     * @param object $row
+     * @return array
+     */
+    private function formatModelRule($row): array
+    {
+        $priceJson = getProp($row, 'price_json');
+        $price = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson;
+        if (!is_array($price)) {
+            $price = new \stdClass();
+        }
+
+        return [
+            // 'id' => (int)getProp($row, 'id', 0),
+            'model' => (string)getProp($row, 'model', ''),
+            'name' => (string)getProp($row, 'name', ''),
+            // 'description' => (string)getProp($row, 'description', ''),
+            // 'is_enabled' => (int)getProp($row, 'is_enabled', 1),
+            // 'order' => (int)getProp($row, 'order', 0),
+            'is_multimodal' => (int)getProp($row, 'is_multimodal', 0),
+            'charge_type' => (string)getProp($row, 'charge_type', ''),
+            'price_json' => $price,
+        ];
+    }
+
+    /**
+     * 更新积分规则(公共方法)
+     *
+     * 支持三个区块,均可选传,未传区块不处理:
+     * - text_rules:文生文(对话)按 model 更新(不传 model 时更新全部模型),charge_type 仅支持 per_call,price_json 为 {"price": 积分}
+     * - image_rules:图片按 model 更新,charge_type 仅支持 per_image,price_json 为 {"1k": 积分, "2k": 积分, "4k": 积分}
+     * - video_rules:视频按 model 更新,charge_type 支持 per_second / per_call
+     *   per_second 的 price_json 为 {"场景": {"分辨率": 单价/秒}};per_call 的 price_json 为 {"price": 积分}
+     *
+     * 每条规则至少需传 charge_type 或 price_json 之一;未传的字段保留原值。
+     *
+     * @param array $params
+     * @return array 各区块实际更新的 model 列表
+     */
+    public function updatePointsRules(array $params): array
+    {
+        $updated = ['text' => [], 'image' => [], 'video' => []];
+
+        if (isset($params['text_rules']) && is_array($params['text_rules'])) {
+            foreach ($params['text_rules'] as $rule) {
+                $updated['text'] = array_merge(
+                    $updated['text'],
+                    $this->updateModelRule('mp_text_models', $rule, ['per_call'], false, true)
+                );
+            }
+        }
+
+        if (isset($params['image_rules']) && is_array($params['image_rules'])) {
+            foreach ($params['image_rules'] as $rule) {
+                $updated['image'] = array_merge(
+                    $updated['image'],
+                    $this->updateModelRule('mp_image_models', $rule, ['per_image'], false)
+                );
+            }
+        }
+
+        if (isset($params['video_rules']) && is_array($params['video_rules'])) {
+            foreach ($params['video_rules'] as $rule) {
+                $updated['video'] = array_merge(
+                    $updated['video'],
+                    $this->updateModelRule('mp_video_models', $rule, ['per_second', 'per_call'], true)
+                );
+            }
+        }
+
+        return $updated;
+    }
+
+    /**
+     * 更新单个模型的计费配置
+     *
+     * @param string $table 模型表名
+     * @param array  $rule  规则(model / charge_type / price_json)
+     * @param array  $chargeTypes 允许的计费方式
+     * @param bool   $nestedPrice 是否允许嵌套价格结构(视频按场景+分辨率)
+     * @param bool   $allowAll 是否允许不传 model 时更新该表全部模型(仅文生文使用)
+     * @return array 实际更新的 model 列表
+     */
+    private function updateModelRule(string $table, array $rule, array $chargeTypes, bool $nestedPrice, bool $allowAll = false): array
+    {
+        $model = trim((string)getProp($rule, 'model', ''));
+        if ($model === '' && !$allowAll) {
+            Utils::throwError('1002:规则缺少model参数');
+        }
+
+        $data = [];
+
+        if (array_key_exists('charge_type', $rule)) {
+            $chargeType = (string)getProp($rule, 'charge_type', '');
+            if (!in_array($chargeType, $chargeTypes, true)) {
+                Utils::throwError('1002:' . $table . '计费方式不合法:' . $chargeType);
+            }
+            $data['charge_type'] = $chargeType;
+        }
+
+        if (array_key_exists('price_json', $rule)) {
+            $price = getProp($rule, 'price_json', null);
+            $priceArr = $nestedPrice
+                ? $this->normalizeNestedPrice($price)
+                : $this->normalizeFlatPrice($price);
+            $data['price_json'] = json_encode($priceArr, JSON_UNESCAPED_UNICODE);
+        }
+
+        if (empty($data)) {
+            Utils::throwError('1002:' . $table . '规则请至少传入charge_type或price_json');
+        }
+
+        if ($model !== '') {
+            if (!DB::table($table)->where('model', $model)->exists()) {
+                Utils::throwError('1002:模型不存在:' . $model);
+            }
+            DB::table($table)->where('model', $model)->update($data);
+            return [$model];
+        }
+
+        // 未传 model(仅文生文支持):更新该表全部模型
+        $allModels = DB::table($table)->pluck('model')->all();
+        if (empty($allModels)) {
+            Utils::throwError('1002:' . $table . '没有可更新的模型');
+        }
+        DB::table($table)->update($data);
+
+        return $allModels;
+    }
+
+    /**
+     * 校验并归一化一层价格结构(文生文/图片)
+     *
+     * 所有 key 对应的值必须为不小于 0 的数字;支持传入对象或 JSON 字符串。
+     *
+     * @param mixed $price
+     * @return array
+     */
+    private function normalizeFlatPrice($price): array
+    {
+        $priceArr = is_string($price) ? json_decode($price, true) : $price;
+        if (!is_array($priceArr) || empty($priceArr)) {
+            Utils::throwError('1002:price格式不正确');
+        }
+
+        $result = [];
+        foreach ($priceArr as $key => $value) {
+            if (!is_numeric($value) || (float)$value < 0) {
+                Utils::throwError('1002:price.' . $key . '必须为不小于0的数字');
+            }
+            $result[(string)$key] = (float)$value;
+        }
+
+        return $result;
+    }
+
+    /**
+     * 校验并归一化嵌套价格结构(视频按场景+分辨率)
+     *
+     * 支持如 {"video_generation": {"720p": 10, "1080p": 25}} 或 {"price": 5}。
+     *
+     * @param mixed $price
+     * @return array
+     */
+    private function normalizeNestedPrice($price): array
+    {
+        $priceArr = is_string($price) ? json_decode($price, true) : $price;
+        if (!is_array($priceArr) || empty($priceArr)) {
+            Utils::throwError('1002:price格式不正确');
+        }
+
+        $result = [];
+        foreach ($priceArr as $key => $value) {
+            if (is_array($value)) {
+                $result[(string)$key] = $this->normalizeFlatPrice($value);
+            } elseif (is_numeric($value) && (float)$value >= 0) {
+                $result[(string)$key] = (float)$value;
+            } else {
+                Utils::throwError('1002:price.' . $key . '必须为不小于0的数字或对象');
+            }
+        }
+
+        return $result;
+    }
+
+    /**
      * 获取用户积分流水
      *
      * @param array $params uid/type/start_date/end_date/page_size

+ 3 - 0
routes/api.php

@@ -86,6 +86,9 @@ Route::group(['middleware' => ['bindToken', 'bindExportToken', 'checkLogin']], f
     Route::group(['prefix' => 'points'], function () {
         // 积分流水
         Route::get('records', [PointsController::class, 'records']);
+
+        Route::get('rules', [PointsController::class, 'rules']);            // 获取积分规则
+        Route::post('editRule', [PointsController::class, 'editRules']);        // 编辑积分规则
     });