Explorar o código

初步完成生成视频的积分明细和用户明细接口及相关逻辑

lh hai 1 mes
pai
achega
c0b3e02b8b

+ 1 - 0
app/Consts/ErrorConst.php

@@ -52,6 +52,7 @@ class ErrorConst
     const USER_AUTH_FAIL                    = '20007:授权失败,请联系客服';
     const USER_IS_EXIST                     = '20007:用户已存在';
     const USER_NO_ACCESS_CHANNEL            = '20008:当前登录用户无此站点权限';
+    const POINTS_NOT_ENOUGH                 = '20009:积分不足,请先充值~';
 
     // Finance
     const FINANCE_ACCOUNT_EXISTED           = '60001:账户已在使用中';

+ 35 - 1
app/Http/Controllers/Anime/AnimeController.php

@@ -12,6 +12,7 @@ use App\Models\MpGenerateVideoTask;
 use App\Services\AIGeneration\AIImageGenerationService;
 use App\Services\AIGeneration\AIVideoGenerationService;
 use App\Services\Anime\AnimeService;
+use App\Services\PointsService;
 use Illuminate\Http\Request;
 use Illuminate\Routing\Controller as BaseController;
 use Illuminate\Support\Facades\DB;
@@ -24,15 +25,18 @@ class AnimeController extends BaseController
     protected $AnimeService;
     protected $AIImageGenerationService;
     protected $AIVideoGenerationService;
+    protected $pointsService;
 
     public function __construct(
         AnimeService $AnimeService,
         AIImageGenerationService $AIImageGenerationService,
-        AIVideoGenerationService $AIVideoGenerationService
+        AIVideoGenerationService $AIVideoGenerationService,
+        PointsService $pointsService
     ) {
         $this->AnimeService = $AnimeService;
         $this->AIImageGenerationService = $AIImageGenerationService;
         $this->AIVideoGenerationService = $AIVideoGenerationService;
+        $this->pointsService = $pointsService;
     }
 
     // 文字模型
@@ -677,6 +681,21 @@ class AnimeController extends BaseController
             Utils::throwError('20003:未找到分镜数据');
         }
         
+        // 余额预检:按实际需要生成的分镜数计算所需积分,不足直接报错
+        $needVideoCount = $segments->filter(function ($segment) {
+            return (int)$segment->current_type !== 2;
+        })->count();
+        if ($needVideoCount > 0) {
+            $needPoints = $this->pointsService->getVideoChargePoints([
+                'model' => $model,
+                'video_resolution' => '720P',
+                'video_duration' => -1,
+                'ratio' => $ratio,
+                'generate_audio' => (int)$generate_audio,
+            ]) * $needVideoCount;
+            $this->pointsService->checkUserPointsEnough($needPoints);
+        }
+        
         // 批量创建视频任务
         $taskIds = [];
         $segmentTasks = [];
@@ -2535,6 +2554,21 @@ class AnimeController extends BaseController
             }
         }   
         
+        // 余额预检:按实际需要生成的片段数计算所需积分,不足直接报错
+        $needVideoCount = $acts->filter(function ($act) {
+            return $act->video_task_status !== '生成中' && (int)$act->current_type !== 2;
+        })->count();
+        if ($needVideoCount > 0) {
+            $needPoints = $this->pointsService->getVideoChargePoints([
+                'model' => $model,
+                'video_resolution' => '720P',
+                'video_duration' => -1,
+                'ratio' => $ratio,
+                'generate_audio' => (int)$generate_audio,
+            ]) * $needVideoCount;
+            $this->pointsService->checkUserPointsEnough($needPoints);
+        }
+        
         // 批量创建视频任务
         $taskIds = [];
         $actTasks = [];

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

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Http\Controllers\Points;
+
+use App\Libs\ApiResponse;
+use App\Services\PointsService;
+use App\Transformer\Points\PointsTransformer;
+use Illuminate\Http\Request;
+use Illuminate\Routing\Controller as BaseController;
+
+class PointsController extends BaseController
+{
+    use ApiResponse;
+    protected $pointsService;
+
+    public function __construct(PointsService $pointsService)
+    {
+        $this->pointsService = $pointsService;
+    }
+
+    /**
+     * 积分流水列表(前端查看积分明细)
+     *
+     * @param Request $request
+     * @return mixed
+     */
+    public function records(Request $request)
+    {
+        $result = $this->pointsService->getUserPointsRecords($request->all());
+        $transformer = new PointsTransformer();
+
+        return $this->success([
+            'summary' => $result['summary'],
+            'meta' => getMeta($result['records']),
+            'list' => $transformer->newEachPointsRecord($result['records']),
+        ]);
+    }
+}

+ 3 - 1
app/Models/MpGeneratePicTask.php

@@ -28,7 +28,8 @@ class MpGeneratePicTask extends Model
         'created_at',
         'updated_at',
         'result_json',
-        'model'
+        'model',
+        'charge_info',   // 新增: 计费关联数据
     ];
 
     protected $casts = [
@@ -37,6 +38,7 @@ class MpGeneratePicTask extends Model
         'mask_img_url' => 'array',
         'extra_params' => 'array',
         'result_json' => 'array',
+        'charge_info' => 'array',
         'started_at' => 'datetime',
         'completed_at' => 'datetime',
     ];

+ 2 - 0
app/Models/MpGenerateVideoTask.php

@@ -35,11 +35,13 @@ class MpGenerateVideoTask extends Model
         'last_frame_url', // 新增:尾帧图片URL(用于return_last_frame功能)
         'alias_segment_id', // 新增:关联分镜ID
         'alias_act_id', // 新增: 关联片段ID
+        'charge_info', // 新增: 计费关联数据
     ];
 
     protected $casts = [
         'extra_params' => 'array',
         'result_json'  => 'array',
+        'charge_info'  => 'array',
     ];
 
     /**

+ 33 - 0
app/Models/MpUserPointsDetail.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class MpUserPointsDetail extends Model
+{
+    protected $table = 'mp_user_points_details';
+
+    const TYPE_VIDEO = 'video';
+
+    protected $fillable = [
+        'uid',
+        'task_id',
+        'type',
+        'api_type',
+        'charge_info',
+        'points_consumed',
+        'points_before',
+        'points_after',
+        'tokens_consumed',
+        'remark',
+    ];
+
+    protected $casts = [
+        'charge_info' => 'array',
+        'points_consumed' => 'float',
+        'points_before' => 'float',
+        'points_after' => 'float',
+        'tokens_consumed' => 'integer',
+    ];
+}

+ 72 - 5
app/Services/AIGeneration/AIVideoGenerationService.php

@@ -2,9 +2,11 @@
 
 namespace App\Services\AIGeneration;
 
+use App\Facade\Site;
 use App\Libs\Utils;
 use App\Models\MpGenerateVideoTask;
 use App\Models\AIElement;
+use App\Services\PointsService;
 use App\Services\VolcEngineService;
 use GuzzleHttp\Client;
 use Illuminate\Support\Facades\DB;
@@ -14,9 +16,53 @@ class AIVideoGenerationService
 {
     private $volcEngineService;
 
-    public function __construct(VolcEngineService $volcEngineService)
+    /** @var PointsService */
+    private $pointsService;
+
+    public function __construct(VolcEngineService $volcEngineService, PointsService $pointsService)
     {
         $this->volcEngineService = $volcEngineService;
+        $this->pointsService = $pointsService;
+    }
+
+    /**
+     * 构建视频任务计费信息
+     *
+     * @param array $params 创建任务参数
+     * @param string $apiType API类型(seedance/jimeng/keling/zzengine)
+     * @return array
+     */
+    private function buildChargeInfo(array $params, string $apiType): array
+    {
+        $uid = 0;
+        try {
+            $uid = (int)Site::getUid();
+        } catch (\Throwable $e) {
+            // 非请求上下文(如命令行)下可能无法获取用户ID,置为0
+        }
+
+        // 兼容不同创建方法的字段名
+        $model = $params['model'] ?? $params['model_code'] ?? '';
+        $ratio = $params['ratio'] ?? $params['video_ratio'] ?? $params['aspect_ratio'] ?? '';
+
+        // zzengine(智帧)的 generate_audio 在 parameters 嵌套参数中,需兼容读取
+        $generateAudio = isset($params['generate_audio'])
+            ? (bool)$params['generate_audio']
+            : (isset($params['parameters']['generate_audio']) ? (bool)$params['parameters']['generate_audio'] : false);
+
+        return [
+            'user_id' => $uid,
+            'model' => $model,
+            'video_resolution' => strtolower($params['video_resolution'] ?? '720P'),
+            'video_duration' => $params['video_duration'] ?? -1,
+            'ratio' => $ratio,
+            'generate_audio' => $generateAudio,
+            'api_type' => $apiType,
+            'source' => !empty($params['alias_segment_id']) ? 'segment' : (!empty($params['alias_act_id']) ? 'act' : ''),
+            'alias_segment_id' => $params['alias_segment_id'] ?? '',
+            'alias_act_id' => $params['alias_act_id'] ?? '',
+            'created_at' => date('Y-m-d H:i:s'),
+        ];
     }
 
     /**
@@ -41,6 +87,7 @@ class AIVideoGenerationService
             'video_resolution' => $params['video_resolution'] ?? '720P',
             'status' => MpGenerateVideoTask::STATUS_PENDING,
             'api_type' => 'jimeng',
+            'charge_info' => $this->buildChargeInfo($params, 'jimeng'),
         ]);
 
         // 发送异步请求到即梦AI API创建任务
@@ -382,6 +429,9 @@ class AIVideoGenerationService
                 // 没有segment_id的情况下,直接更新任务状态
                 $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
             }
+            
+            // 视频生成成功,记录计费明细并扣减用户积分
+            $this->pointsService->recordVideoTaskCharge($task);
         } elseif ($statusInfo['status'] === 'failed') {
             logDB('generate', 'error', '即梦AI视频生成任务失败', ['id' => $task->id, 'error' => $statusInfo['error_message']]);
             $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, $statusInfo);
@@ -470,7 +520,8 @@ class AIVideoGenerationService
                 'duration' => $params['video_duration'] ?? 5,
                 'camera_fixed' => $params['camera_fixed'] ?? false,
                 'watermark' => $params['watermark'] ?? false,
-            ]
+            ],
+            'charge_info' => $this->buildChargeInfo($params, 'seedance'),
         ]);
 
         // 提交任务到火山引擎 API
@@ -655,6 +706,9 @@ class AIVideoGenerationService
                 // 没有segment_id的情况下,直接更新任务状态
                 $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
             }
+            
+            // 视频生成成功,记录计费明细并扣减用户积分
+            $this->pointsService->recordVideoTaskCharge($task);
         } elseif ($statusInfo['status'] === 'failed') {
             logDB('generate', 'error', 'Seedance视频生成任务失败', ['id' => $task->id, 'error' => $statusInfo['error_message']]);
             $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
@@ -1070,6 +1124,11 @@ class AIVideoGenerationService
                     ]);
             }
             
+            // 视频生成成功,记录计费明细并扣减用户积分
+            if ($taskStatus === MpGenerateVideoTask::STATUS_SUCCESS) {
+                $this->pointsService->recordVideoTaskCharge($task);
+            }
+            
             dLog('generate')->info('Seedance 回调处理完成: ' . $taskId . ', 状态: ' . $taskStatus);
             logDB('generate', 'info', 'Seedance回调处理完成', ['task_id' => $taskId, 'status' => $taskStatus]);
             
@@ -1229,7 +1288,8 @@ class AIVideoGenerationService
                 'duration' => $params['video_duration'] ?? 5,
                 'mode' => $params['mode'] ?? 'std',
                 'callback_url' => $params['callback_url'] ?? '',
-            ]
+            ],
+            'charge_info' => $this->buildChargeInfo($params, 'keling'),
         ]);
 
         // 提交任务到可灵AI Omni API
@@ -1556,6 +1616,9 @@ class AIVideoGenerationService
                 // 没有segment_id的情况下,直接更新任务状态
                 $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
             }
+            
+            // 视频生成成功,记录计费明细并扣减用户积分
+            $this->pointsService->recordVideoTaskCharge($task);
         } elseif ($statusInfo['status'] === 'failed') {
             $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
                 'error_message' => $statusInfo['error_message'],
@@ -2268,7 +2331,8 @@ class AIVideoGenerationService
                 'video_duration' => $params['video_duration'] ?? 5,
                 'video_ratio' => $params['video_ratio'] ?? '9:16',
                 'parameters' => $params['parameters'] ?? [],
-            ]
+            ],
+            'charge_info' => $this->buildChargeInfo($params, 'zzengine'),
         ]);
 
         // 提交任务到统一API
@@ -2699,6 +2763,9 @@ class AIVideoGenerationService
                 // 没有segment_id的情况下,直接更新任务状态
                 $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, $statusInfo);
             }
+            
+            // 视频生成成功,记录计费明细并扣减用户积分
+            $this->pointsService->recordVideoTaskCharge($task);
         } elseif ($statusInfo['status'] === 'failed') {
             logDB('generate', 'error', '统一API视频生成任务失败', ['id' => $task->id, 'error' => $statusInfo['error_message']]);
             $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
@@ -3236,4 +3303,4 @@ class AIVideoGenerationService
         }
     }
 }
-    
+    

+ 28 - 2
app/Services/Anime/AnimeService.php

@@ -10,6 +10,7 @@ use App\Models\MpGeneratePicTask;
 use App\Services\AIGeneration\AIImageGenerationService;
 use App\Services\AIGeneration\AIVideoGenerationService;
 use App\Services\DeepSeek\DeepSeekService;
+use App\Services\PointsService;
 use Dflydev\DotAccessData\Util;
 use GuzzleHttp\Client;
 use Illuminate\Support\Facades\DB;
@@ -23,6 +24,7 @@ class AnimeService
     protected $aiImageGenerationService;
     protected $aiVideoGenerationService;
     protected $DeepSeekService;
+    protected $pointsService;
     private $url;
     private $api_key;
     private $headers;
@@ -30,11 +32,13 @@ class AnimeService
     public function __construct(
         AIImageGenerationService $aiImageGenerationService,
         AIVideoGenerationService $aiVideoGenerationService,
-        DeepSeekService $DeepSeekService
+        DeepSeekService $DeepSeekService,
+        PointsService $pointsService
     ) {
         $this->aiImageGenerationService = $aiImageGenerationService;
         $this->aiVideoGenerationService = $aiVideoGenerationService;
         $this->DeepSeekService = $DeepSeekService;
+        $this->pointsService = $pointsService;
         $this->url = 'https://api.deepseek.com/chat/completions';
         $this->api_key = env('DEEPSEEK_API_KEY');
         $this->headers = [
@@ -4646,6 +4650,17 @@ class AnimeService
         // 音频传入的前提:必须有至少一张图片或一个视频
         $canUseAudio = ($hasImage || $hasVideo) && !empty($audioUrl);
 
+        // 余额预检:积分不足直接报错
+        $this->pointsService->checkUserPointsEnough(
+            $this->pointsService->getVideoChargePoints([
+                'model' => $model,
+                'video_resolution' => $videoParams['video_resolution'] ?? '720P',
+                'video_duration' => $videoDuration,
+                'ratio' => $ratio,
+                'generate_audio' => $current_generate_audio,
+            ])
+        );
+
         // dd($videoParams);
         
         // 根据模型选择不同的视频生成方法
@@ -4863,6 +4878,17 @@ class AnimeService
             'video_model' => $model,
             'updated_at' => date('Y-m-d H:i:s')
         ]);
+
+        // 余额预检:积分不足直接报错
+        $this->pointsService->checkUserPointsEnough(
+            $this->pointsService->getVideoChargePoints([
+                'model' => $model,
+                'video_resolution' => $video_resolution ?? '720P',
+                'video_duration' => $videoDuration,
+                'ratio' => $ratio,
+                'generate_audio' => $current_generate_audio,
+            ])
+        );
         
         // 构建视频生成参数
         $videoParams = [
@@ -4879,7 +4905,7 @@ class AnimeService
             'camera_fixed' => false,
             // 'callback_url' => 'http://mpaudio.yqsd.cn/api/video/seedanceCallback'
         ];
-        
+
         // 如果分镜有图片,作为首帧
         $hasImage = !empty(getProp($act, 'img_url')) || $first_frame_url;
         $hasVideo = !empty(getProp($act, 'video_url'));

+ 359 - 0
app/Services/PointsService.php

@@ -0,0 +1,359 @@
+<?php
+
+namespace App\Services;
+
+use App\Consts\ErrorConst;
+use App\Facade\Site;
+use App\Libs\Utils;
+use App\Models\MpGenerateVideoTask;
+use App\Models\MpUserPointsDetail;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * 用户积分服务
+ *
+ * 负责视频生成等业务的积分扣费、积分使用明细记录以及用户积分余额更新。
+ */
+class PointsService
+{
+    /**
+     * 视频生成单次默认扣费积分数
+     * TODO: 后续接入积分映射表,根据 model / video_resolution / 视频时长 等计算具体积分
+     */
+    const DEFAULT_VIDEO_CHARGE_POINTS = 0;
+
+    /**
+     * 获取视频生成应扣积分数(公共方法)
+     *
+     * 后续积分映射表上线后,只需调整此方法的实现,业务调用方无需改动。
+     *
+     * @param array $chargeInfo 计费信息(user_id、model、video_resolution、video_duration 等)
+     * @return int
+     */
+    public function getVideoChargePoints(array $chargeInfo = []): int
+    {
+        // TODO: 根据映射表(model + video_resolution + video_duration)查询具体积分数
+        return self::DEFAULT_VIDEO_CHARGE_POINTS;
+    }
+
+    /**
+     * 获取视频生成任务实际消耗的 token 量
+     *
+     * 优先从接口返回的 result_json.usage 中读取;
+     * 当前视频类接口暂未返回 token 用量,默认返回 0。
+     *
+     * @param MpGenerateVideoTask $task
+     * @return int
+     */
+    public function getVideoTokensConsumed(MpGenerateVideoTask $task): int
+    {
+        $resultJson = $task->result_json;
+        if (is_string($resultJson)) {
+            $resultJson = json_decode($resultJson, true);
+        }
+        if (!is_array($resultJson)) {
+            return 0;
+        }
+
+        // zzengine(智帧/统一API)返回格式:
+        // data.task.detail.actual_token_total 或 data.task.detail.result.provider_token_total
+        // 或 data.task.detail.result.billing_snapshot.token_total
+        if ($task->api_type === 'zzengine') {
+            $tokens = $resultJson['data']['task']['detail']['actual_token_total']
+                ?? $resultJson['data']['task']['detail']['result']['provider_token_total']
+                ?? $resultJson['data']['task']['detail']['result']['billing_snapshot']['token_total']
+                ?? $resultJson['data']['task']['detail']['result']['billing_snapshot']['token_output']
+                ?? 0;
+            return (int)$tokens;
+        }
+
+        // seedance(豆包视频)返回格式:usage.total_tokens / usage.completion_tokens
+        if ($task->api_type === 'seedance') {
+            $tokens = $resultJson['usage']['total_tokens']
+                ?? $resultJson['usage']['completion_tokens']
+                ?? 0;
+            return (int)$tokens;
+        }
+
+        // 其他API:优先从 usage 中读取
+        $tokens = $resultJson['usage']['total_tokens']
+            ?? $resultJson['usage']['completion_tokens']
+            ?? $resultJson['content']['usage']['total_tokens']
+            ?? 0;
+
+        return (int)$tokens;
+    }
+
+    /**
+     * 从接口返回结果中获取实际视频时长(秒)
+     *
+     * 各 API 返回格式不同,按 api_type 分别解析;
+     * 用于 charge_info 中自动时长(-1/0)的任务在成功扣费时回填实际时长。
+     *
+     * @param MpGenerateVideoTask $task
+     * @return int
+     */
+    public function getActualVideoDuration(MpGenerateVideoTask $task): int
+    {
+        $resultJson = $task->result_json;
+        if (is_string($resultJson)) {
+            $resultJson = json_decode($resultJson, true);
+        }
+        if (!is_array($resultJson)) {
+            return 0;
+        }
+
+        switch ($task->api_type) {
+            case 'zzengine':
+                return (int)($resultJson['data']['task']['detail']['duration'] ?? 0);
+
+            case 'jimeng':
+                $data = $resultJson['data'] ?? [];
+                if (!empty($data['duration'])) {
+                    return (int)$data['duration'];
+                }
+                if (isset($data['frames'], $data['framespersecond']) && (int)$data['framespersecond'] > 0) {
+                    return (int)floor((int)$data['frames'] / (int)$data['framespersecond']);
+                }
+                return 0;
+
+            case 'keling':
+                $taskData = $resultJson['data'] ?? [];
+                $video = $taskData['task_result']['videos'][0] ?? [];
+                if (!empty($video['duration'])) {
+                    return (int)$video['duration'];
+                }
+                if (!empty($taskData['duration'])) {
+                    return (int)$taskData['duration'];
+                }
+                if (isset($video['frames'], $video['framespersecond']) && (int)$video['framespersecond'] > 0) {
+                    return (int)floor((int)$video['frames'] / (int)$video['framespersecond']);
+                }
+                return 0;
+
+            case 'seedance':
+            default:
+                return (int)($resultJson['duration'] ?? 0);
+        }
+    }
+
+    /**
+     * 获取用户积分流水
+     *
+     * @param array $params uid/type/start_date/end_date/page_size
+     * @return array
+     */
+    public function getUserPointsRecords(array $params = []): array
+    {
+        $uid = (int)getProp($params, 'uid', 0);
+        if (!$uid) {
+            $uid = (int)Site::getUid();
+        }
+
+        $type = getProp($params, 'type', '');
+        $startDate = getProp($params, 'start_date', '');
+        $endDate = getProp($params, 'end_date', '');
+        $pageSize = (int)getProp($params, 'page_size', 15);
+        if ($pageSize < 1 || $pageSize > 100) {
+            $pageSize = 15;
+        }
+
+        $query = MpUserPointsDetail::where('uid', $uid);
+        if ($type) {
+            $query->where('type', $type);
+        }
+        if ($startDate) {
+            $query->where('created_at', '>=', $startDate . ' 00:00:00');
+        }
+        if ($endDate) {
+            $query->where('created_at', '<=', $endDate . ' 23:59:59');
+        }
+
+        $records = $query->orderBy('created_at', 'desc')
+            ->orderBy('id', 'desc')
+            ->paginate($pageSize);
+
+        // 汇总统计:当前积分余额、累计消耗/退回积分、累计消耗token
+        $user = DB::table('mp_manage_users')->where('id', $uid)->first();
+        $summary = [
+            'points_balance' => (float)getProp($user, 'points', 0),
+            'total_points_consumed' => (float)MpUserPointsDetail::where('uid', $uid)
+                ->where('points_consumed', '>', 0)
+                ->sum('points_consumed'),
+            'total_points_refunded' => (float)MpUserPointsDetail::where('uid', $uid)
+                ->where('points_consumed', '<', 0)
+                ->sum('points_consumed'),
+            'total_tokens_consumed' => (int)MpUserPointsDetail::where('uid', $uid)
+                ->sum('tokens_consumed'),
+            'total_count' => (int)MpUserPointsDetail::where('uid', $uid)->count(),
+        ];
+
+        return [
+            'summary' => $summary,
+            'records' => $records,
+        ];
+    }
+
+    /**
+     * 获取用户当前积分余额
+     *
+     * @param int $uid 用户ID,缺省取当前登录用户
+     * @return float
+     */
+    public function getUserPointsBalance(int $uid = 0): float
+    {
+        if (!$uid) {
+            $uid = (int)Site::getUid();
+        }
+        if (!$uid) {
+            Utils::throwError(ErrorConst::NOT_LOGIN);
+        }
+
+        $user = DB::table('mp_manage_users')->where('id', $uid)->first();
+        if (!$user) {
+            Utils::throwError(ErrorConst::USER_IS_NOT_EXIST);
+        }
+
+        return (float)getProp($user, 'points', 0);
+    }
+
+    /**
+     * 余额预检(公共方法)
+     *
+     * 计算视频/其他业务所需积分数后,在创建任务前调用;
+     * 积分不足时直接抛错(20009:积分不足),业务方无需自行处理。
+     *
+     * @param float|int $pointsNeeded 需要扣除的积分数
+     * @param int $uid 用户ID,缺省取当前登录用户
+     * @return void
+     */
+    public function checkUserPointsEnough($pointsNeeded, int $uid = 0): void
+    {
+        $pointsNeeded = (float)$pointsNeeded;
+        if ($pointsNeeded <= 0) {
+            return;
+        }
+
+        $balance = $this->getUserPointsBalance($uid);
+        if ($balance < $pointsNeeded) {
+            Utils::throwError(ErrorConst::POINTS_NOT_ENOUGH);
+        }
+    }
+
+    /**
+     * 视频生成成功后记录计费明细并扣减用户积分
+     *
+     * 幂等处理:同一任务只允许计费一次(task_id 唯一索引兜底)。
+     *
+     * @param MpGenerateVideoTask $task
+     * @return array
+     */
+    public function recordVideoTaskCharge(MpGenerateVideoTask $task): array
+    {
+        // 重复计费保护
+        $exists = DB::table('mp_user_points_details')->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];
+        }
+
+        // 自动时长(-1/0)时,用接口返回的实际时长回填计费信息
+        $requestedDuration = (int)($chargeInfo['video_duration'] ?? -1);
+        $actualDuration = $this->getActualVideoDuration($task);
+        $durationBackfilled = $requestedDuration <= 0 && $actualDuration > 0;
+        if ($durationBackfilled) {
+            $chargeInfo['video_duration'] = $actualDuration;
+        }
+
+        $uid = (int)$chargeInfo['user_id'];
+        $points = (float)$this->getVideoChargePoints($chargeInfo);
+        $tokens = $this->getVideoTokensConsumed($task);
+
+        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];
+            }
+
+            $pointsBefore = (float)getProp($user, 'points', 0);
+            $pointsAfter = $pointsBefore - $points;
+
+            if ($pointsBefore < $points) {
+                dLog('points')->warning('用户积分不足,扣费后积分为负数', [
+                    'task_id' => $task->id,
+                    'uid' => $uid,
+                    'points_before' => $pointsBefore,
+                    'points_consumed' => $points
+                ]);
+            }
+
+            // 更新用户积分总额
+            DB::table('mp_manage_users')->where('id', $uid)->update([
+                'points' => $pointsAfter,
+                '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', ''),
+                'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE),
+                'points_consumed' => $points,
+                'points_before' => $pointsBefore,
+                'points_after' => $pointsAfter,
+                'tokens_consumed' => $tokens,
+                '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,
+                'uid' => $uid,
+                'points_consumed' => $points,
+                'points_after' => $pointsAfter,
+                'tokens_consumed' => $tokens
+            ]);
+
+            return [
+                'charged' => true,
+                'task_id' => $task->id,
+                'uid' => $uid,
+                'points_consumed' => $points,
+                'points_before' => $pointsBefore,
+                'points_after' => $pointsAfter,
+                'tokens_consumed' => $tokens
+            ];
+
+        } catch (\Exception $e) {
+            DB::rollBack();
+            dLog('points')->error('视频生成计费失败: ' . $e->getMessage(), ['task_id' => $task->id]);
+            logDB('points', 'error', '视频生成计费失败', [
+                'task_id' => $task->id,
+                'error' => $e->getMessage()
+            ]);
+            return ['charged' => false, 'reason' => 'exception: ' . $e->getMessage(), 'task_id' => $task->id];
+        }
+    }
+}

+ 68 - 0
app/Transformer/Points/PointsTransformer.php

@@ -0,0 +1,68 @@
+<?php
+
+namespace App\Transformer\Points;
+
+class PointsTransformer
+{
+    const TYPE_LABELS = [
+        'video' => '视频生成',
+        'video_refund' => '视频生成退款',
+    ];
+
+    /**
+     * 积分流水列表
+     *
+     * @param $data
+     * @return array
+     */
+    public function newBuildPointsRecords($data): array
+    {
+        return [
+            'meta' => getMeta($data),
+            'list' => $this->newEachPointsRecord($data),
+        ];
+    }
+
+    /**
+     * 单条积分流水
+     *
+     * @param $list
+     * @return array
+     */
+    public function newEachPointsRecord($list): array
+    {
+        $result = [];
+        if (empty($list)) return $result;
+
+        foreach ($list as $item) {
+            $chargeInfo = getProp($item, 'charge_info');
+            if (is_string($chargeInfo)) {
+                $chargeInfo = json_decode($chargeInfo, true);
+            }
+            $chargeInfo = is_array($chargeInfo) ? $chargeInfo : [];
+
+            $type = getProp($item, 'type', '');
+            $result[] = [
+                'id' => (int)getProp($item, 'id'),
+                'type' => $type,
+                'type_label' => self::TYPE_LABELS[$type] ?? $type,
+                'api_type' => getProp($item, 'api_type'),
+                'task_id' => (int)getProp($item, 'task_id'),
+                'charge_info' => [
+                    'model' => getProp($chargeInfo, 'model'),
+                    'video_resolution' => getProp($chargeInfo, 'video_resolution'),
+                    'video_duration' => getProp($chargeInfo, 'video_duration'),
+                    'ratio' => getProp($chargeInfo, 'ratio'),
+                ],
+                'points_consumed' => (float)getProp($item, 'points_consumed', 0),
+                'points_before' => (float)getProp($item, 'points_before', 0),
+                'points_after' => (float)getProp($item, 'points_after', 0),
+                'tokens_consumed' => (int)getProp($item, 'tokens_consumed', 0),
+                'remark' => getProp($item, 'remark'),
+                'created_at' => getProp($item, 'created_at'),
+            ];
+        }
+
+        return $result;
+    }
+}

+ 44 - 0
database/migrations/2026_08_03_000001_create_mp_user_points_details_table.php

@@ -0,0 +1,44 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateMpUserPointsDetailsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('mp_user_points_details', function (Blueprint $table) {
+            $table->id();
+            $table->unsignedBigInteger('uid')->comment('用户ID');
+            $table->unsignedBigInteger('task_id')->comment('视频任务ID');
+            $table->string('type', 50)->default('video_generation')->comment('明细类型:video_generation-视频生成');
+            $table->string('api_type', 50)->nullable()->comment('视频API类型:seedance/jimeng/keling/zzengine');
+            $table->json('charge_info')->nullable()->comment('视频计费信息JSON');
+            $table->decimal('points_consumed', 13, 1)->default(0)->comment('消耗积分数');
+            $table->decimal('points_before', 13, 1)->default(0)->comment('扣减前积分');
+            $table->decimal('points_after', 13, 1)->default(0)->comment('扣减后积分');
+            $table->bigInteger('tokens_consumed')->default(0)->comment('消耗token数');
+            $table->string('remark', 500)->nullable()->comment('备注');
+            $table->timestamps();
+
+            $table->index('uid');
+            $table->unique('task_id');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('mp_user_points_details');
+    }
+}

+ 6 - 0
routes/api.php

@@ -9,6 +9,7 @@ use App\Http\Controllers\AIGeneration\VideoGenerationController;
 use App\Http\Controllers\Anime\AnimeController;
 use App\Http\Controllers\Canvas\CanvasController;
 use App\Http\Controllers\PromptTemplate\PromptTemplateController;
+use App\Http\Controllers\Points\PointsController;
 use Illuminate\Support\Facades\Route;
 
 /*
@@ -80,6 +81,11 @@ Route::group(['middleware' => ['bindToken', 'bindExportToken', 'checkLogin']], f
         Route::get('delEmotionGroup', [TimbreController::class, 'delEmotionGroup']);
     });
 
+    Route::group(['prefix' => 'points'], function () {
+        // 积分流水
+        Route::get('records', [PointsController::class, 'records']);
+    });
+
 
     Route::group(['prefix' => 'deepseek'], function () {
         Route::post('chatWithReasoner', [DeepSeekController::class, 'chatWithReasoner']);