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

1.新增图片积分计费细则2.新增图片积分消耗明细3.新增生成图片积分预检公共方法

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

+ 1 - 0
app/Models/MpUserPointsDetail.php

@@ -9,6 +9,7 @@ class MpUserPointsDetail extends Model
     protected $table = 'mp_user_points_details';
 
     const TYPE_VIDEO = 'video';
+    const TYPE_IMAGE = 'image';
 
     protected $fillable = [
         'uid',

+ 5 - 1
app/Providers/AppServiceProvider.php

@@ -3,6 +3,7 @@
 namespace App\Providers;
 
 use App\Services\AIGeneration\AIImageGenerationService;
+use App\Services\PointsService;
 use App\Services\VolcEngineService;
 use Illuminate\Support\Facades\Validator;
 use Illuminate\Support\ServiceProvider;
@@ -18,7 +19,10 @@ class AppServiceProvider extends ServiceProvider
     {
         //
         $this->app->singleton(AIImageGenerationService::class, function ($app) {
-            return new AIImageGenerationService($app->make(VolcEngineService::class));
+            return new AIImageGenerationService(
+                $app->make(VolcEngineService::class),
+                $app->make(PointsService::class)
+            );
         });
     }
 

+ 87 - 2
app/Services/AIGeneration/AIImageGenerationService.php

@@ -2,20 +2,24 @@
 
 namespace App\Services\AIGeneration;
 
+use App\Facade\Site;
+use App\Consts\BaseConst;
 use App\Models\MpGeneratePicTask;
 use App\Models\MpAsset;
+use App\Services\PointsService;
 use App\Services\VolcEngineService;
 use GuzzleHttp\Client;
 use GuzzleHttp\Exception\GuzzleException;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Redis;
-use App\Consts\BaseConst;
 
 class AIImageGenerationService
 {
     private $volcEngineService;
     private $httpClient;
+    /** @var PointsService */
+    private $pointsService;
 
     // 模型常量
     const MODEL_JIMENG_4 = 'jimeng_4.0';
@@ -23,15 +27,77 @@ class AIImageGenerationService
     const MODEL_NANO_BANANA_PRO = 'NanoBananaPro';
     const MODEL_GPT_IMAGE_2 = 'GptImage2';
 
-    public function __construct(VolcEngineService $volcEngineService)
+    public function __construct(VolcEngineService $volcEngineService, PointsService $pointsService)
     {
         $this->volcEngineService = $volcEngineService;
+        $this->pointsService = $pointsService;
         $this->httpClient = new Client([
             'timeout' => 300,
         ]);
     }
 
     /**
+     * 构建图片任务计费信息
+     *
+     * @param array $params
+     * @param string $model
+     * @return array
+     */
+    private function buildImageChargeInfo(array $params, string $model): array
+    {
+        $uid = 0;
+        try {
+            $uid = (int)Site::getUid();
+        } catch (\Throwable $e) {
+            // 非请求上下文(如命令行)下可能无法获取用户ID,置为0
+        }
+
+        $width = (int)getProp($params, 'width', 1600);
+        $height = (int)getProp($params, 'height', 2848);
+
+        return [
+            'user_id' => $uid,
+            'model' => $model,
+            'width' => $width,
+            'height' => $height,
+            'resolution' => $this->pointsService->normalizeImageResolutionKey($width, $height),
+            'image_num' => (int)getProp($params, 'image_num', 1),
+            'source' => !empty($params['alias_segment_id']) ? 'segment' : (!empty($params['alias_act_id']) ? 'act' : ''),
+            'alias_segment_id' => $params['alias_segment_id'] ?? '',
+            'created_at' => date('Y-m-d H:i:s'),
+        ];
+    }
+
+    /**
+     * 图片生成余额预检
+     *
+     * 有用户上下文时校验本次生成所需积分(单价 × 生成张数),不足直接抛错;
+     * 后台命令/队列等无用户上下文的场景跳过预检。
+     *
+     * @param array $chargeInfo
+     * @return void
+     */
+    private function preCheckImageBalance(array $chargeInfo): void
+    {
+        $uid = (int)($chargeInfo['user_id'] ?? 0);
+        if (!$uid) {
+            return;
+        }
+
+        $imageNum = (int)($chargeInfo['image_num'] ?? 1);
+        if ($imageNum <= 0) {
+            $imageNum = 1;
+        }
+
+        $needPoints = $this->pointsService->getImageChargePoints($chargeInfo) * $imageNum;
+        if ($needPoints <= 0) {
+            return;
+        }
+
+        $this->pointsService->checkUserPointsEnough($needPoints, $uid);
+    }
+
+    /**
      * 异步创建图片生成任务
      *
      * @param array $params
@@ -44,6 +110,10 @@ class AIImageGenerationService
 
         // 获取模型类型,默认使用即梦seedream5.0-lite
         $model = $params['model'] ?? 'doubao-seedream-5-0-lite-260128';
+
+        // 构建计费信息并余额预检(不足直接报错)
+        $chargeInfo = $this->buildImageChargeInfo($params, $model);
+        $this->preCheckImageBalance($chargeInfo);
         
         // 检查是否有正在处理的任务(仅即梦AI需要排队)
         if ($model === self::MODEL_JIMENG_4 && $this->hasProcessingTask()) {
@@ -68,6 +138,7 @@ class AIImageGenerationService
             'extra_params' => $params['extra_params'] ?? null,
             'status' => MpGeneratePicTask::STATUS_PENDING,
             'model' => $model,
+            'charge_info' => $chargeInfo,
         ]);
 
         // 即梦AI和NanoBanana系列立即提交任务,火山API在定时任务中提交
@@ -105,6 +176,10 @@ class AIImageGenerationService
         // 获取模型类型
         $model = $params['model'] ?? self::MODEL_JIMENG_4;
 
+        // 构建计费信息并余额预检(不足直接报错)
+        $chargeInfo = $this->buildImageChargeInfo($params, $model);
+        $this->preCheckImageBalance($chargeInfo);
+
         // 创建排队状态的任务记录
         return MpGeneratePicTask::create([
             'task_id' => $taskId,
@@ -119,6 +194,7 @@ class AIImageGenerationService
             'extra_params' => $params['extra_params'] ?? null,
             'status' => MpGeneratePicTask::STATUS_PENDING,
             'model' => $model,
+            'charge_info' => $chargeInfo,
         ]);
     }
 
@@ -564,6 +640,9 @@ class AIImageGenerationService
                 'result_json' => $cleanedResponseData // 保存清理后的数据
             ]);
 
+            // 图片生成成功,记录计费明细并扣减用户积分
+            $this->pointsService->recordImageTaskCharge($task);
+
             // 同步调整分镜图片状态和结果
             $segment_id = getProp($task, 'alias_segment_id');
             if ($segment_id) {
@@ -733,6 +812,9 @@ class AIImageGenerationService
                 'result_json' => $responseData
             ]);
 
+            // 图片生成成功,记录计费明细并扣减用户积分
+            $this->pointsService->recordImageTaskCharge($task);
+
             // 同步调整分镜图片状态和结果
             $segment_id = getProp($task, 'alias_segment_id');
             if ($segment_id) {
@@ -1407,6 +1489,9 @@ class AIImageGenerationService
                     'result_json'   => $statusInfo['result_json'] ?? []
                 ]);
 
+                // 图片生成成功,记录计费明细并扣减用户积分
+                $this->pointsService->recordImageTaskCharge($task);
+
                 // 同步调整分镜图片状态和结果
                 $segment_id = getProp($task, 'alias_segment_id');
                 if ($segment_id) {

+ 189 - 21
app/Services/PointsService.php

@@ -5,6 +5,7 @@ namespace App\Services;
 use App\Consts\ErrorConst;
 use App\Facade\Site;
 use App\Libs\Utils;
+use App\Models\MpGeneratePicTask;
 use App\Models\MpGenerateVideoTask;
 use App\Models\MpUserPointsDetail;
 use Illuminate\Support\Facades\DB;
@@ -318,7 +319,7 @@ class PointsService
     /**
      * 视频生成成功后记录计费明细并扣减用户积分
      *
-     * 幂等处理:同一任务只允许计费一次(task_id 唯一索引兜底)。
+     * 幂等处理:同一任务只允许计费一次(type + task_id 唯一索引兜底)。
      *
      * @param MpGenerateVideoTask $task
      * @return array
@@ -326,7 +327,10 @@ class PointsService
     public function recordVideoTaskCharge(MpGenerateVideoTask $task): array
     {
         // 重复计费保护
-        $exists = DB::table('mp_user_points_details')->where('task_id', $task->id)->exists();
+        $exists = DB::table('mp_user_points_details')
+            ->where('type', MpUserPointsDetail::TYPE_VIDEO)
+            ->where('task_id', $task->id)
+            ->exists();
         if ($exists) {
             return ['charged' => false, 'reason' => 'already_charged', 'task_id' => $task->id];
         }
@@ -352,14 +356,183 @@ class PointsService
         $points = (float)$this->getVideoChargePoints($chargeInfo);
         $tokens = $this->getVideoTokensConsumed($task);
 
+        $result = $this->deductAndRecord(
+            $uid,
+            $task->id,
+            MpUserPointsDetail::TYPE_VIDEO,
+            (string)getProp($task, 'api_type', ''),
+            $points,
+            $tokens,
+            $chargeInfo,
+            '视频生成成功计费'
+        );
+
+        // 自动时长被实际时长覆盖时,计费成功后同步回填任务表的 charge_info
+        if ($durationBackfilled && !empty($result['charged'])) {
+            $task->update(['charge_info' => $chargeInfo]);
+        }
+
+        return $result;
+    }
+
+    /**
+     * 获取图片生成单张应扣积分数
+     *
+     * 从 mp_image_models 表读取(charge_type=per_image),按分辨率档位(1k/2k/4k)取单张积分。
+     *
+     * @param array $chargeInfo 计费信息(model、resolution、width、height 等)
+     * @return int
+     */
+    public function getImageChargePoints(array $chargeInfo = []): int
+    {
+        $model = (string)getProp($chargeInfo, 'model', '');
+        $resolution = strtolower((string)getProp($chargeInfo, 'resolution', '2k'));
+
+        $modelRow = DB::table('mp_image_models')->where('model', $model)->first();
+        if (!$modelRow || ($modelRow->charge_type ?? '') !== 'per_image') {
+            return self::DEFAULT_VIDEO_CHARGE_POINTS;
+        }
+
+        $priceJson = $modelRow->price_json;
+        $prices = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson;
+        if (!is_array($prices) || empty($prices)) {
+            return self::DEFAULT_VIDEO_CHARGE_POINTS;
+        }
+
+        $price = $prices[$resolution] ?? null;
+        if ($price === null || (float)$price <= 0) {
+            return self::DEFAULT_VIDEO_CHARGE_POINTS;
+        }
+
+        return (int)max(1, round((float)$price));
+    }
+
+    /**
+     * 根据图片宽高归一化分辨率档位(1k/2k/4k)
+     *
+     * 2k 常见尺寸:2048x2048、1600x2848、2560x1440 等;
+     * 4k:4096x4096、4992x3328、3040x5504 等;其余更小尺寸归为 1k。
+     *
+     * @param int $width
+     * @param int $height
+     * @return string
+     */
+    public function normalizeImageResolutionKey(int $width, int $height): string
+    {
+        if ($width <= 0 || $height <= 0) {
+            return '2k';
+        }
+        $maxDim = max($width, $height);
+        $area = $width * $height;
+        if ($maxDim > 4096 || $area >= 4096 * 4096) {
+            return '4k';
+        }
+        if ($maxDim > 1536) {
+            return '2k';
+        }
+        return '1k';
+    }
+
+    /**
+     * 获取图片生成任务实际消耗的 token 量
+     *
+     * 图片接口返回格式:result_json.usage.total_tokens / output_tokens
+     *
+     * @param MpGeneratePicTask $task
+     * @return int
+     */
+    public function getImageTokensConsumed(MpGeneratePicTask $task): int
+    {
+        $resultJson = $task->result_json;
+        if (is_string($resultJson)) {
+            $resultJson = json_decode($resultJson, true);
+        }
+        if (!is_array($resultJson)) {
+            return 0;
+        }
+
+        $tokens = $resultJson['usage']['total_tokens'] ?? $resultJson['usage']['output_tokens'] ?? 0;
+        return (int)$tokens;
+    }
+
+    /**
+     * 图片生成成功后记录计费明细并扣减用户积分
+     *
+     * 幂等处理:同一任务只允许计费一次(type + task_id 唯一索引兜底)。
+     *
+     * @param MpGeneratePicTask $task
+     * @return array
+     */
+    public function recordImageTaskCharge(MpGeneratePicTask $task): array
+    {
+        // 重复计费保护
+        $exists = DB::table('mp_user_points_details')
+            ->where('type', MpUserPointsDetail::TYPE_IMAGE)
+            ->where('task_id', $task->id)
+            ->exists();
+        if ($exists) {
+            return ['charged' => false, 'reason' => 'already_charged', 'task_id' => $task->id];
+        }
+
+        $chargeInfo = $task->charge_info;
+        if (is_string($chargeInfo)) {
+            $chargeInfo = json_decode($chargeInfo, true);
+        }
+        if (!is_array($chargeInfo) || empty($chargeInfo['user_id'])) {
+            dLog('points')->warning('图片任务缺少计费信息,跳过扣费', ['task_id' => $task->id]);
+            return ['charged' => false, 'reason' => 'no_charge_info', 'task_id' => $task->id];
+        }
+
+        $uid = (int)$chargeInfo['user_id'];
+
+        // 积分 = 单张价格 × 实际生成图片数
+        $pointsPerImage = (float)$this->getImageChargePoints($chargeInfo);
+        $imageCount = is_array($task->result_url) ? count($task->result_url) : 0;
+        if ($imageCount <= 0) {
+            $imageCount = (int)($chargeInfo['image_num'] ?? 1);
+        }
+        if ($imageCount <= 0) {
+            $imageCount = 1;
+        }
+        // 单张价格 × 实际生成图片数(未配置价格时为0,仍记录明细与token)
+        $points = (float)round($pointsPerImage * $imageCount);
+        $tokens = $this->getImageTokensConsumed($task);
+
+        return $this->deductAndRecord(
+            $uid,
+            $task->id,
+            MpUserPointsDetail::TYPE_IMAGE,
+            (string)getProp($task, 'model', ''),
+            $points,
+            $tokens,
+            $chargeInfo,
+            '图片生成成功计费'
+        );
+    }
+
+    /**
+     * 扣减积分并记录积分使用明细(视频/图片共用)
+     *
+     * @param int $uid
+     * @param int $taskId
+     * @param string $type
+     * @param string $apiType
+     * @param float $points
+     * @param int $tokens
+     * @param array $chargeInfo
+     * @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
+    {
         try {
             DB::beginTransaction();
 
             $user = DB::table('mp_manage_users')->where('id', $uid)->first();
             if (!$user) {
                 DB::rollBack();
-                dLog('points')->error('扣费失败:用户不存在', ['task_id' => $task->id, 'uid' => $uid]);
-                return ['charged' => false, 'reason' => 'user_not_found', 'task_id' => $task->id];
+                dLog('points')->error('扣费失败:用户不存在', ['task_id' => $taskId, 'uid' => $uid]);
+                return ['charged' => false, 'reason' => 'user_not_found', 'task_id' => $taskId];
             }
 
             $pointsBefore = (float)getProp($user, 'points', 0);
@@ -367,7 +540,7 @@ class PointsService
 
             if ($pointsBefore < $points) {
                 dLog('points')->warning('用户积分不足,扣费后积分为负数', [
-                    'task_id' => $task->id,
+                    'task_id' => $taskId,
                     'uid' => $uid,
                     'points_before' => $pointsBefore,
                     'points_consumed' => $points
@@ -380,31 +553,26 @@ class PointsService
                 'updated_at' => date('Y-m-d H:i:s')
             ]);
 
-            // 自动时长被实际时长覆盖时,同步回填任务表的 charge_info
-            if ($durationBackfilled) {
-                $task->update(['charge_info' => $chargeInfo]);
-            }
-
             // 记录积分使用明细
             DB::table('mp_user_points_details')->insert([
                 'uid' => $uid,
-                'task_id' => $task->id,
-                'type' => MpUserPointsDetail::TYPE_VIDEO,
-                'api_type' => getProp($task, 'api_type', ''),
+                'task_id' => $taskId,
+                'type' => $type,
+                'api_type' => $apiType,
                 'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE),
                 'points_consumed' => $points,
                 'points_before' => $pointsBefore,
                 'points_after' => $pointsAfter,
                 'tokens_consumed' => $tokens,
-                'remark' => '视频生成成功计费',
+                'remark' => $remark,
                 'created_at' => date('Y-m-d H:i:s'),
                 'updated_at' => date('Y-m-d H:i:s')
             ]);
 
             DB::commit();
 
-            dLog('points')->info('视频生成计费成功', [
-                'task_id' => $task->id,
+            dLog('points')->info($type . '计费成功', [
+                'task_id' => $taskId,
                 'uid' => $uid,
                 'points_consumed' => $points,
                 'points_after' => $pointsAfter,
@@ -413,7 +581,7 @@ class PointsService
 
             return [
                 'charged' => true,
-                'task_id' => $task->id,
+                'task_id' => $taskId,
                 'uid' => $uid,
                 'points_consumed' => $points,
                 'points_before' => $pointsBefore,
@@ -423,12 +591,12 @@ class PointsService
 
         } catch (\Exception $e) {
             DB::rollBack();
-            dLog('points')->error('视频生成计费失败: ' . $e->getMessage(), ['task_id' => $task->id]);
-            logDB('points', 'error', '视频生成计费失败', [
-                'task_id' => $task->id,
+            dLog('points')->error($type . '计费失败: ' . $e->getMessage(), ['task_id' => $taskId]);
+            logDB('points', 'error', $type . '计费失败', [
+                'task_id' => $taskId,
                 'error' => $e->getMessage()
             ]);
-            return ['charged' => false, 'reason' => 'exception: ' . $e->getMessage(), 'task_id' => $task->id];
+            return ['charged' => false, 'reason' => 'exception: ' . $e->getMessage(), 'task_id' => $taskId];
         }
     }
 }

+ 0 - 2
routes/api.php

@@ -275,8 +275,6 @@ Route::group(['middleware' => ['bindToken', 'bindExportToken', 'checkLogin']], f
     
 });
 
-// Route::post('addChat', [DeepSeekController::class, 'addChat']);
-// Route::post('testChat', [DeepSeekController::class, 'testChat']);
 Route::get('login', [AccountController::class, 'login']); // 登录
 Route::get('logout', [AccountController::class, 'logout']); // 退出
 Route::get('sseLink', [DeepSeekController::class, 'sseLink']); // sseLink