|
|
@@ -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
|