Browse Source

新增AI任务中心逻辑

lh 1 month ago
parent
commit
51fb57a027

+ 83 - 0
app/Console/Commands/SyncTaskCenterCommand.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\MpTaskCenter;
+use App\Services\TaskCenterService;
+use Illuminate\Console\Command;
+
+class SyncTaskCenterCommand extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'taskCenter:sync';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '定时同步任务中心状态和结果';
+
+    /**
+     * Execute the console command.
+     *
+     * @param TaskCenterService $taskCenterService
+     * @return int
+     */
+    public function handle(TaskCenterService $taskCenterService)
+    {
+        dLog('command')->info('开始同步任务中心状态...');
+
+        try {
+            // 提前退出机制:没有排队(pending)或处理中(processing)的任务时直接返回
+            $pendingCount = MpTaskCenter::whereIn('status', [
+                MpTaskCenter::STATUS_PENDING,
+                MpTaskCenter::STATUS_PROCESSING,
+            ])
+                ->where('ref_task_id', '>', 0)
+                ->count('id');
+
+            if ($pendingCount <= 0) {
+                dLog('command')->info('任务中心没有待处理任务,直接返回');
+                return 0;
+            }
+
+            // 每5秒查询一轮结果,最大持续50秒
+            $timeStart = time();
+            $maxDuration = 50;
+            $checkInterval = 10;
+            $updated = 0;
+
+            while (time() - $timeStart < $maxDuration) {
+                $updated += $taskCenterService->syncTaskStatus();
+
+                // 本轮同步后重新统计,全部完成则提前退出
+                $pendingCount = MpTaskCenter::whereIn('status', [
+                    MpTaskCenter::STATUS_PENDING,
+                    MpTaskCenter::STATUS_PROCESSING,
+                ])
+                    ->where('ref_task_id', '>', 0)
+                    ->count('id');
+
+                if ($pendingCount <= 0) {
+                    dLog('command')->info('任务中心任务已全部处理完成,提前退出');
+                    break;
+                }
+
+                sleep($checkInterval);
+            }
+
+            dLog('command')->info('任务中心同步完成,更新记录数: ' . $updated);
+        } catch (\Exception $e) {
+            dLog('command')->error('任务中心同步失败: ' . $e->getMessage());
+            logDB('command', 'error', '任务中心同步失败', ['error' => $e->getMessage()]);
+            return 1;
+        }
+
+        return 0;
+    }
+}

+ 5 - 1
app/Console/Kernel.php

@@ -16,7 +16,8 @@ class Kernel extends ConsoleKernel
         AnimeCheckImgUrlCommand::class,
         Commands\ProcessImageGenerationQueueCommand::class,
         Commands\ProcessBatchEpisodeGenerationCommand::class,
-        SegmentCheckAudioCommand::class
+        SegmentCheckAudioCommand::class,
+        Commands\SyncTaskCenterCommand::class
     ];
 
 
@@ -45,6 +46,9 @@ class Kernel extends ConsoleKernel
         
         // 批量生成分集任务(每分钟执行一次,并发调度不同anime_id的子进程处理,默认并发5)
         $schedule->command('batch:generate-episodes')->everyMinute()->withoutOverlapping();
+
+        // 同步任务中心状态和结果(关联图片/视频任务)
+        $schedule->command('taskCenter:sync')->everyMinute()->withoutOverlapping(5);
     }
 
     /**

+ 51 - 1
app/Console/Test/TestCommand.php

@@ -26,7 +26,7 @@ class TestCommand extends Command
     /**
      * @var string
      */
-    protected $signature = 'test {--token=} {--uid=} {--eid=}';
+    protected $signature = 'test {--token=} {--uid=} {--eid=} {--balance}';
 
     /**
      * The console command description.
@@ -53,6 +53,12 @@ class TestCommand extends Command
      */
     public function handle()
     {
+        // 记录 DeepSeek 官方余额快照:php artisan test --balance
+        if ($this->option('balance')) {
+            $this->recordDeepSeekBalance();
+            return;
+        }
+
         // 测试视频超分功能
         // 使用示例:php artisan test --video_url=https://your-video-url.mp4
         $videoUrl = 'https://zw-audiobook.tos-cn-beijing.volces.com/video/ai_generation_1785144404_6a672454cabb0.mp4';
@@ -417,6 +423,50 @@ class TestCommand extends Command
         // \Log::info('generate_json: '.$generate_json);
     }
 
+    /**
+     * 记录 DeepSeek 官方余额快照(当前时间 + 余额),追加写入独立日志文件
+     * 用法:php artisan test --balance
+     */
+    private function recordDeepSeekBalance()
+    {
+        $apiKey = env('DEEPSEEK_API_KEY');
+        if (empty($apiKey)) {
+            $this->error('DEEPSEEK_API_KEY 未配置');
+            return;
+        }
+
+        try {
+            $client   = new Client(['timeout' => 30, 'verify' => false]);
+            $response = $client->get('https://api.deepseek.com/user/balance', [
+                'headers' => [
+                    'Authorization' => 'Bearer ' . $apiKey,
+                    'Accept'        => 'application/json',
+                ],
+            ]);
+
+            $data = json_decode($response->getBody()->getContents(), true);
+            $info = $data['balance_infos'][0] ?? [];
+
+            $line = sprintf(
+                "[%s] 当前余额:%s %s(充值余额:%s;赠送余额:%s),账户%s",
+                date('Y-m-d H:i:s'),
+                getProp($info, 'total_balance', '0.00'),
+                getProp($info, 'currency', 'CNY') === 'CNY' ? '元' : getProp($info, 'currency', 'CNY'),
+                getProp($info, 'topped_up_balance', '0.00'),
+                getProp($info, 'granted_balance', '0.00'),
+                !empty($data['is_available']) ? '可用' : '不可用'
+            ) . PHP_EOL;
+
+            $logPath = storage_path('logs/deepseek_balance.log');
+            file_put_contents($logPath, $line, FILE_APPEND | LOCK_EX);
+
+            $this->info('已记录 DeepSeek 余额快照: ' . trim($line));
+            $this->info('日志文件: ' . $logPath);
+        } catch (\Exception $e) {
+            $this->error('DeepSeek 余额查询失败: ' . $e->getMessage());
+        }
+    }
+
     private function testVideoEnhance($video_url) {
         $startTime = microtime(true); // 开始计时
         

+ 135 - 13
app/Http/Controllers/Anime/AnimeController.php

@@ -13,6 +13,7 @@ use App\Services\AIGeneration\AIImageGenerationService;
 use App\Services\AIGeneration\AIVideoGenerationService;
 use App\Services\Anime\AnimeService;
 use App\Services\PointsService;
+use App\Services\TaskCenterService;
 use Illuminate\Http\Request;
 use Illuminate\Routing\Controller as BaseController;
 use Illuminate\Support\Facades\DB;
@@ -26,17 +27,20 @@ class AnimeController extends BaseController
     protected $AIImageGenerationService;
     protected $AIVideoGenerationService;
     protected $pointsService;
+    protected $taskCenterService;
 
     public function __construct(
         AnimeService $AnimeService,
         AIImageGenerationService $AIImageGenerationService,
         AIVideoGenerationService $AIVideoGenerationService,
-        PointsService $pointsService
+        PointsService $pointsService,
+        TaskCenterService $taskCenterService
     ) {
         $this->AnimeService = $AnimeService;
         $this->AIImageGenerationService = $AIImageGenerationService;
         $this->AIVideoGenerationService = $AIVideoGenerationService;
         $this->pointsService = $pointsService;
+        $this->taskCenterService = $taskCenterService;
     }
 
     // 文字模型
@@ -1248,19 +1252,127 @@ class AnimeController extends BaseController
 
         $data = $request->all();
 
-        $result = $this->AnimeService->generateImg($data);
+        // 兼容开关:stream=1 走 SSE 流式输出;默认不传时保持原有普通 JSON 响应
+        $useStream = (int)($request->input('stream', 0)) === 1;
 
-        // 多张图片:data为首图URL,image_urls为全部图片URL数组
-        if (is_array($result) && isset($result['img_url']) && isset($result['image_urls'])) {
-            return $this->respond([
-                'msg'        => '',
-                'code'       => 0,
-                'data'       => $result['img_url'],
-                'image_urls' => $result['image_urls']
-            ]);
+        if (!$useStream) {
+            // 原有逻辑:同步等待结果,普通 JSON 响应(格式保持不变)
+            $result = $this->AnimeService->generateImg($data);
+
+            // 多张图片:data为首图URL,image_urls为全部图片URL数组,task_center_id为任务中心ID
+            if (is_array($result) && isset($result['img_url']) && isset($result['image_urls'])) {
+                return $this->respond([
+                    'msg'            => '',
+                    'code'           => 0,
+                    'data'           => $result['img_url'],
+                    'image_urls'     => $result['image_urls'],
+                    'task_center_id' => $result['task_id'] ?? 0,
+                ]);
+            }
+
+            // 单张图片:data为图片URL,task_center_id为任务中心ID
+            if (is_array($result) && isset($result['img_url'])) {
+                return $this->respond([
+                    'msg'            => '',
+                    'code'           => 0,
+                    'data'           => $result['img_url'],
+                    'task_center_id' => $result['task_id'] ?? 0,
+                ]);
+            }
+
+            return $this->success($result);
         }
 
-        return $this->success($result);
+        // 设置 SSE 响应头
+        return response()->stream(function () use ($data) {
+            // 禁用所有输出缓冲
+            if (ob_get_level()) {
+                ob_end_clean();
+            }
+            ini_set('output_buffering', 'off');
+            ini_set('zlib.output_compression', 'off');
+            if (function_exists('apache_setenv')) {
+                apache_setenv('no-gzip', '1');
+            }
+
+            try {
+                // 创建图片生成任务,任务创建后立即回调发送init消息(包含任务中心ID和任务信息)
+                $result = $this->AnimeService->generateImg($data, function ($taskCenterId, $taskInfo) {
+                    echo "data: " . json_encode([
+                        'type'           => 'init',
+                        'data'           => $taskInfo,
+                        'task_center_id' => $taskCenterId,
+                    ], JSON_UNESCAPED_UNICODE) . "\n\n";
+                    if (ob_get_level() > 0) {
+                        ob_flush();
+                    }
+                    flush();
+                }, function ($taskCenterId, $taskId, $elapsedTime) {
+                    // 每分钟发送一次队列状态消息
+                    echo "data: " . json_encode([
+                        'type' => 'queue',
+                        'data' => [
+                            'task_id'      => $taskId,
+                            'status'       => 'processing',
+                            'elapsed_time' => $elapsedTime,
+                            'message'               => '任务正在队列中执行...',
+                        ],
+                    ], JSON_UNESCAPED_UNICODE) . "\n\n";
+                    if (ob_get_level() > 0) {
+                        ob_flush();
+                    }
+                    flush();
+                });
+
+                // 最终结果:保持原有返回格式(msg/code/data/image_urls/task_center_id)
+                if (is_array($result) && isset($result['img_url']) && isset($result['image_urls'])) {
+                    $finalData = [
+                        'msg'            => '',
+                        'code'           => 0,
+                        'data'           => $result['img_url'],
+                        'image_urls'     => $result['image_urls'],
+                        'task_center_id' => $result['task_id'] ?? 0,
+                    ];
+                } elseif (is_array($result) && isset($result['img_url'])) {
+                    $finalData = [
+                        'msg'            => '',
+                        'code'           => 0,
+                        'data'           => $result['img_url'],
+                        'task_center_id' => $result['task_id'] ?? 0,
+                    ];
+                } else {
+                    $finalData = [
+                        'msg'  => '',
+                        'code' => 0,
+                        'data' => $result,
+                    ];
+                }
+
+                echo "data: " . json_encode([
+                    'type' => 'completed',
+                    'data' => $finalData,
+                ], JSON_UNESCAPED_UNICODE) . "\n\n";
+                if (ob_get_level() > 0) {
+                    ob_flush();
+                }
+                flush();
+
+            } catch (\Exception $e) {
+                echo "data: " . json_encode([
+                    'type'    => 'error',
+                    'message' => $e->getMessage(),
+                ], JSON_UNESCAPED_UNICODE) . "\n\n";
+                if (ob_get_level() > 0) {
+                    ob_flush();
+                }
+                flush();
+            }
+        }, 200, [
+            'Content-Type'    => 'text/event-stream',
+            'Cache-Control'   => 'no-cache',
+            'Connection'      => 'keep-alive',
+            'X-Accel-Buffering' => 'no',
+        ]);
     }
 
     /**
@@ -2473,12 +2585,22 @@ class AnimeController extends BaseController
         $result = $this->AnimeService->generateVideo($data);
         $taskId = $result['task_id'];
 
+        // 创建任务中心记录并关联视频任务ID
+        $taskCenter = $this->taskCenterService->createTask('video', [
+            'title'       => '文生视频',
+            'ref_task_id' => $taskId,
+            // 'prompt'      => getProp($data, 'prompt', ''),
+            // 'params'      => json_encode($data, JSON_UNESCAPED_UNICODE),
+        ]);
+        $taskCenterId = $taskCenter->id;
+
         // 设置 SSE 响应头
-        return response()->stream(function () use ($taskId, $result) {
+        return response()->stream(function () use ($taskId, $taskCenterId, $result) {
             // 立即发送 init 消息
             echo "data: " . json_encode([
                 'type' => 'init',
-                'data' => $result
+                'data' => $result,
+                'task_center_id' => $taskCenterId,
             ]) . "\n\n";
             if (ob_get_level() > 0) ob_flush();
             flush();

+ 48 - 1
app/Http/Controllers/DeepSeek/DeepSeekController.php

@@ -9,6 +9,7 @@ use App\Exceptions\ApiException;
 use App\Libs\ApiResponse;
 use App\Libs\Utils;
 use App\Services\DeepSeek\DeepSeekService;
+use App\Services\TaskCenterService;
 use Illuminate\Http\Request;
 use Illuminate\Routing\Controller as BaseController;
 use Illuminate\Support\Facades\DB;
@@ -19,11 +20,14 @@ class DeepSeekController extends BaseController
 {
     use ApiResponse;
     protected $deepseekService;
+    protected $taskCenterService;
 
     public function __construct(
-        DeepSeekService $deepseekService
+        DeepSeekService $deepseekService,
+        TaskCenterService $taskCenterService
     ) {
         $this->deepseekService = $deepseekService;
+        $this->taskCenterService = $taskCenterService;
     }
 
     /**
@@ -1066,9 +1070,37 @@ class DeepSeekController extends BaseController
             }
 
             try {
+                // 构建可序列化的参数快照(剔除上传文件对象)
+                $paramsSnapshot = $data;
+                unset($paramsSnapshot['images'], $paramsSnapshot['videos']);
+
+                // 创建任务中心记录(文字任务直接更新任务结果)
+                $taskCenter = $this->taskCenterService->createTask('text', [
+                    'title'  => '文生文',
+                    'prompt' => getProp($data, 'prompt', ''),
+                    'params' => json_encode($paramsSnapshot, JSON_UNESCAPED_UNICODE),
+                ]);
+                $taskCenterId = $taskCenter->id;
+
                 $generator = $this->deepseekService->generateText($data);
+                $fullContent = '';
+                $firstChunk = true;
                 
                 foreach ($generator as $chunk) {
+                    // 第一条消息附加任务中心ID(不改变原消息结构和输出顺序)
+                    if ($firstChunk) {
+                        $chunk['task_center_id'] = $taskCenterId;
+                        $firstChunk = false;
+                    }
+
+                    // 累积完整文本内容
+                    if (isset($chunk['type']) && $chunk['type'] === 'content') {
+                        $fullContent .= (string)getProp($chunk, 'content', '');
+                    }
+                    if (isset($chunk['type']) && $chunk['type'] === 'done') {
+                        $fullContent = (string)getProp($chunk, 'full_content', $fullContent);
+                    }
+
                     // 发送 SSE 格式的数据
                     $jsonData = json_encode($chunk, JSON_UNESCAPED_UNICODE);
                     echo "data: {$jsonData}\n\n";
@@ -1091,8 +1123,23 @@ class DeepSeekController extends BaseController
                     ob_flush();
                 }
                 flush();
+
+                // 文字任务直接更新任务中心结果
+                $this->taskCenterService->updateTask($taskCenterId, [
+                    'status' => 'success',
+                    'result' => $fullContent,
+                    'error_message' => null,
+                ]);
                 
             } catch (\Exception $e) {
+                // 任务失败,更新任务中心状态(任务中心记录可能未创建成功)
+                if (isset($taskCenterId)) {
+                    $this->taskCenterService->updateTask($taskCenterId, [
+                        'status'        => 'failed',
+                        'error_message' => $e->getMessage(),
+                    ]);
+                }
+
                 // 发送错误信息
                 $error = [
                     'type' => 'error',

+ 101 - 0
app/Http/Controllers/TaskCenter/TaskCenterController.php

@@ -0,0 +1,101 @@
+<?php
+
+namespace App\Http\Controllers\TaskCenter;
+
+use App\Libs\ApiResponse;
+use App\Libs\Utils;
+use App\Services\TaskCenterService;
+use Illuminate\Http\Request;
+use Illuminate\Routing\Controller as BaseController;
+
+class TaskCenterController extends BaseController
+{
+    use ApiResponse;
+
+    protected $taskCenterService;
+
+    public function __construct(TaskCenterService $taskCenterService)
+    {
+        $this->taskCenterService = $taskCenterService;
+    }
+
+    /**
+     * 查询任务详情
+     *
+     * @param Request $request
+     * @return mixed
+     */
+    public function detail(Request $request)
+    {
+        $taskId = (int)$request->input('task_id', 0);
+        if ($taskId <= 0) {
+            Utils::throwError('1002:task_id不能为空');
+        }
+
+        $task = $this->taskCenterService->getTaskDetail($taskId);
+        if (!$task) {
+            Utils::throwError('20003:任务不存在');
+        }
+
+        return $this->success([
+            'task_id'    => $task->id,
+            'task_type'  => $task->task_type,
+            'title'      => $task->title,
+            'status'     => $task->status,
+            'result'     => $this->parseResult($task->result) ?? '',
+            'error_message' => $task->error_message ?? '',
+            'ref_task_id'=> $task->ref_task_id,
+            'created_at' => $task->created_at ? $task->created_at->format('Y-m-d H:i:s') : '',
+            'updated_at' => $task->updated_at ? $task->updated_at->format('Y-m-d H:i:s') : '',
+        ]);
+    }
+
+    /**
+     * 分页查询任务列表(默认每页20条,按创建时间倒序)
+     *
+     * @param Request $request
+     * @return mixed
+     */
+    public function taskList(Request $request)
+    {
+        $params = $request->only(['task_id', 'status', 'task_type', 'page', 'page_size']);
+
+        $tasks = $this->taskCenterService->getTaskList($params);
+
+        $list = $tasks->map(function ($task) {
+            return [
+                'task_id'       => $task->id,
+                'task_type'     => $task->task_type,
+                'title'         => $task->title,
+                'status'        => $task->status,
+                'result'        => $this->parseResult($task->result),
+                'error_message' => $task->error_message,
+                'ref_task_id'   => $task->ref_task_id,
+                'created_at'    => $task->created_at ? $task->created_at->format('Y-m-d H:i:s') : '',
+                'updated_at'    => $task->updated_at ? $task->updated_at->format('Y-m-d H:i:s') : '',
+            ];
+        });
+
+        return $this->success([
+            'list'         => $list,
+            'total'        => $tasks->total(),
+            'per_page'     => $tasks->perPage(),
+            'current_page' => $tasks->currentPage(),
+            'last_page'    => $tasks->lastPage(),
+        ]);
+    }
+
+    /**
+     * 解析结果字段(JSON字符串转换为数组,保持兼容)
+     *
+     * @param mixed $result
+     * @return mixed
+     */
+    private function parseResult($result)
+    {
+        if (is_string($result) && is_json($result)) {
+            return json_decode($result, true);
+        }
+        return $result;
+    }
+}

+ 32 - 0
app/Models/MpTaskCenter.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class MpTaskCenter extends Model
+{
+    protected $table = 'mp_task_center';
+
+    const STATUS_PENDING = 'pending';
+    const STATUS_PROCESSING = 'processing';
+    const STATUS_SUCCESS = 'success';
+    const STATUS_FAILED = 'failed';
+
+    const TYPE_TEXT = 'text';
+    const TYPE_IMAGE = 'image';
+    const TYPE_VIDEO = 'video';
+
+    protected $fillable = [
+        'uid',
+        'cpid',
+        'task_type',
+        'title',
+        'ref_task_id',
+        'status',
+        'result',
+        'error_message',
+        'prompt',
+        'params',
+    ];
+}

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

@@ -11,6 +11,7 @@ use App\Services\AIGeneration\AIImageGenerationService;
 use App\Services\AIGeneration\AIVideoGenerationService;
 use App\Services\DeepSeek\DeepSeekService;
 use App\Services\PointsService;
+use App\Services\TaskCenterService;
 use Dflydev\DotAccessData\Util;
 use GuzzleHttp\Client;
 use Illuminate\Support\Facades\DB;
@@ -25,6 +26,7 @@ class AnimeService
     protected $aiVideoGenerationService;
     protected $DeepSeekService;
     protected $pointsService;
+    protected $taskCenterService;
     private $url;
     private $api_key;
     private $headers;
@@ -33,12 +35,14 @@ class AnimeService
         AIImageGenerationService $aiImageGenerationService,
         AIVideoGenerationService $aiVideoGenerationService,
         DeepSeekService $DeepSeekService,
-        PointsService $pointsService
+        PointsService $pointsService,
+        TaskCenterService $taskCenterService
     ) {
         $this->aiImageGenerationService = $aiImageGenerationService;
         $this->aiVideoGenerationService = $aiVideoGenerationService;
         $this->DeepSeekService = $DeepSeekService;
         $this->pointsService = $pointsService;
+        $this->taskCenterService = $taskCenterService;
         $this->url = 'https://api.deepseek.com/chat/completions';
         $this->api_key = env('DEEPSEEK_API_KEY');
         $this->headers = [
@@ -6001,7 +6005,7 @@ class AnimeService
         return (int)$innerPromptType === 2 ? [] : '';
     }
 
-    public function generateImg($data) {
+    public function generateImg($data, $onTaskCreated = null, $onPoll = null) {
         $prompt = getProp($data, 'prompt');
         $episode_id = getProp($data, 'episode_id');
         $ref_img_urls = getProp($data, 'ref_img_urls');
@@ -6117,7 +6121,31 @@ class AnimeService
             if (!$task_id) {
                 Utils::throwError('20003:创建图片生成任务失败');
             }
-            
+
+            // 创建任务中心记录并关联图片任务ID
+            $taskCenter = $this->taskCenterService->createTask('image', [
+                'title'       => '文生图',
+                'ref_task_id' => $task_id,
+                // 'prompt'      => $prompt,
+                // 'params'      => json_encode($data, JSON_UNESCAPED_UNICODE),
+            ]);
+            $taskCenterId = $taskCenter->id;
+
+            // 组装本次任务信息(用于init消息,类似generateVideo)
+            $taskInfo = [
+                'task_id'    => $task_id,
+                'status'     => getProp($task, 'status', 'processing'),
+                'model'      => $model,
+                'ratio'      => $ratio,
+                'resolution' => $resolution,
+                'image_num'  => $imageNum,
+            ];
+
+            // 创建图片任务后立即回调(类似generateVideo的init消息,返回任务中心ID和任务信息)
+            if (is_callable($onTaskCreated)) {
+                $onTaskCreated($taskCenterId, $taskInfo);
+            }
+
             // 检查任务模型类型(火山模型和GPT模型都支持直接检查任务状态)
             $model = getProp($task, 'model');
             $isVolcModel = in_array($model, \App\Consts\BaseConst::VOLC_PIC_MODELS);
@@ -6129,10 +6157,21 @@ class AnimeService
             $timeout = 300; // 5分钟
             $img_url = '';
             $img_urls = [];
+            $lastQueueMessageTime = $start_time; // 记录上次发送队列消息的时间
+            $queueMessageInterval = 60; // 队列消息发送间隔(秒)
             
             while (time() - $start_time < $timeout) {
                 sleep(3);
 
+                // 每分钟发送一次队列状态消息(仅流式模式)
+                if (is_callable($onPoll)) {
+                    $currentTime = time();
+                    if ($currentTime - $lastQueueMessageTime >= $queueMessageInterval) {
+                        $onPoll($taskCenterId, $task_id, $currentTime - $start_time);
+                        $lastQueueMessageTime = $currentTime;
+                    }
+                }
+
                 // 火山API和GPT-Image2 API是同步返回结果的,直接检查任务状态
                 if ($isSyncModel) {
                     // 刷新任务状态
@@ -6196,11 +6235,15 @@ class AnimeService
             if (count($img_urls) > 1) {
                 return [
                     'img_url'    => $img_url,
-                    'image_urls' => $img_urls
+                    'image_urls' => $img_urls,
+                    'task_id'    => $taskCenterId,
                 ];
             }
             
-            return $img_url;
+            return [
+                'img_url' => $img_url,
+                'task_id' => $taskCenterId,
+            ];
             
         } catch (\Exception $e) {
             dLog('anime')->error('更新角色或场景失败', [

+ 300 - 0
app/Services/TaskCenterService.php

@@ -0,0 +1,300 @@
+<?php
+
+namespace App\Services;
+
+use App\Facade\Site;
+use App\Models\MpTaskCenter;
+use Illuminate\Support\Facades\DB;
+
+class TaskCenterService
+{
+    /**
+     * 创建任务中心记录
+     *
+     * @param string $type 任务类型:text/image/video
+     * @param array  $data 任务数据(title/ref_task_id/prompt/params等)
+     * @return MpTaskCenter
+     */
+    public function createTask(string $type, array $data = [])
+    {
+        $uid = 0;
+        $cpid = 0;
+        try {
+            $uid = (int)Site::getUid();
+            $cpid = (int)Site::getCpid();
+        } catch (\Throwable $e) {
+            // 非请求上下文(如命令行)下可能无法获取用户ID,置为0
+        }
+
+        return MpTaskCenter::create([
+            'uid'          => $data['uid'] ?? $uid,
+            '$cpid'          => $data['cpid'] ?? $cpid,
+            'task_type'    => $type,
+            'title'        => $data['title'] ?? '',
+            'ref_task_id'  => $data['ref_task_id'] ?? 0,
+            'status'       => $data['status'] ?? MpTaskCenter::STATUS_PROCESSING,
+            'result'       => $data['result'] ?? null,
+            'error_message'=> $data['error_message'] ?? null,
+            'prompt'       => $data['prompt'] ?? null,
+            'params'       => $data['params'] ?? null,
+        ]);
+    }
+
+    /**
+     * 更新任务中心记录
+     *
+     * @param int   $taskId
+     * @param array $data
+     * @return bool
+     */
+    public function updateTask(int $taskId, array $data = []): bool
+    {
+        return (bool)MpTaskCenter::where('id', $taskId)->update($data);
+    }
+
+    /**
+     * 查询任务详情
+     *
+     * @param int $taskId
+     * @return MpTaskCenter|null
+     */
+    public function getTaskDetail(int $taskId)
+    {
+        $query = MpTaskCenter::where('id', $taskId);
+
+        // 请求上下文下按当前用户过滤
+        $uid = 0;
+        try {
+            $uid = (int)Site::getUid();
+        } catch (\Throwable $e) {
+        }
+        if ($uid > 0) {
+            $query->where('uid', $uid);
+        }
+
+        return $query->first();
+    }
+
+    /**
+     * 分页查询任务列表
+     *
+     * @param array $params
+     * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
+     */
+    public function getTaskList(array $params = [])
+    {
+        $query = MpTaskCenter::query();
+
+        // 请求上下文下按当前用户过滤
+        $uid = 0;
+        try {
+            $uid = (int)Site::getUid();
+        } catch (\Throwable $e) {
+        }
+        if ($uid > 0) {
+            $query->where('uid', $uid);
+        }
+
+        // 按任务ID筛选
+        if (!empty($params['task_id'])) {
+            $query->where('id', (int)$params['task_id']);
+        }
+
+        // 按任务状态筛选(支持逗号分隔多状态)
+        if (!empty($params['status'])) {
+            $statuses = is_array($params['status'])
+                ? $params['status']
+                : array_filter(array_map('trim', explode(',', (string)$params['status'])));
+            if (!empty($statuses)) {
+                $query->whereIn('status', $statuses);
+            }
+        }
+
+        // 按任务类型筛选
+        if (!empty($params['task_type'])) {
+            $query->where('task_type', $params['task_type']);
+        }
+
+        $pageSize = (int)($params['page_size'] ?? 20);
+        if ($pageSize <= 0 || $pageSize > 100) {
+            $pageSize = 20;
+        }
+
+        return $query->orderBy('created_at', 'desc')->orderBy('id', 'desc')->paginate($pageSize);
+    }
+
+    /**
+     * 定时同步任务中心状态和结果
+     *
+     * 图片任务关联 mp_generate_pic_tasks,视频任务关联 mp_generate_video_tasks,
+     * 将底层任务的最新状态、结果和错误信息同步到任务中心。
+     *
+     * @return int 同步更新的记录数
+     */
+    public function syncTaskStatus(): int
+    {
+        $updated = 0;
+
+        // 只同步尚未结束的任务(避免重复扫描已完成记录)
+        $tasks = MpTaskCenter::whereIn('status', [
+            MpTaskCenter::STATUS_PENDING,
+            MpTaskCenter::STATUS_PROCESSING,
+        ])
+            ->where('ref_task_id', '>', 0)
+            ->orderBy('id', 'desc')
+            ->limit(500)
+            ->get();
+
+        foreach ($tasks as $task) {
+            try {
+                if ($task->task_type === MpTaskCenter::TYPE_IMAGE) {
+                    $updated += $this->syncImageTask($task);
+                } elseif ($task->task_type === MpTaskCenter::TYPE_VIDEO) {
+                    $updated += $this->syncVideoTask($task);
+                }
+            } catch (\Exception $e) {
+                dLog('command')->error('任务中心同步失败: ' . $e->getMessage(), [
+                    'task_id'     => $task->id,
+                    'ref_task_id' => $task->ref_task_id,
+                    'task_type'   => $task->task_type,
+                ]);
+            }
+        }
+
+        return $updated;
+    }
+
+    /**
+     * 同步图片任务状态到任务中心
+     *
+     * @param MpTaskCenter $task
+     * @return int
+     */
+    private function syncImageTask(MpTaskCenter $task): int
+    {
+        $ref = DB::table('mp_generate_pic_tasks')->where('id', $task->ref_task_id)->first();
+        if (!$ref) {
+            return 0;
+        }
+
+        $result = null;
+        if (!empty($ref->result_url)) {
+            $urls = $this->normalizeResultUrls($ref->result_url);
+            $urls = array_values(array_filter($urls));
+
+            // 与文生图 completed 返回格式保持一致
+            $resultData = [
+                'msg'            => '',
+                'code'           => 0,
+                'data'           => $urls[0] ?? '',
+                'task_center_id' => $task->id,
+            ];
+            if (count($urls) > 1) {
+                $resultData['image_urls'] = $urls;
+            }
+            $result = json_encode($resultData, JSON_UNESCAPED_UNICODE);
+        }
+
+        return $this->applyRefStatus($task, $ref->status, $result, $ref->error_message);
+    }
+
+    /**
+     * 规范化图片结果URL(兼容字符串/数组/JSON字符串/双重编码)
+     *
+     * @param mixed $resultUrl
+     * @return array
+     */
+    private function normalizeResultUrls($resultUrl): array
+    {
+        if (is_array($resultUrl)) {
+            return array_values(array_filter($resultUrl));
+        }
+
+        $value = (string)$resultUrl;
+        // 最多解析两层 JSON(防御双重编码)
+        for ($i = 0; $i < 2; $i++) {
+            if (!is_string($value) || !is_json($value)) {
+                break;
+            }
+            $decoded = json_decode($value, true);
+            if (!is_array($decoded)) {
+                // 解码结果是字符串且仍是JSON,继续解析下一层
+                if (is_string($decoded) && is_json($decoded)) {
+                    $value = $decoded;
+                    continue;
+                }
+                $value = $decoded;
+                break;
+            }
+            $value = $decoded;
+        }
+
+        if (is_array($value)) {
+            return array_values(array_filter($value));
+        }
+        if (is_string($value) && $value !== '') {
+            return [$value];
+        }
+        return [];
+    }
+
+    /**
+     * 同步视频任务状态到任务中心
+     *
+     * @param MpTaskCenter $task
+     * @return int
+     */
+    private function syncVideoTask(MpTaskCenter $task): int
+    {
+        $ref = DB::table('mp_generate_video_tasks')->where('id', $task->ref_task_id)->first();
+        if (!$ref) {
+            return 0;
+        }
+
+        $result = null;
+        if (!empty($ref->result_url) || !empty($ref->compressed_url) || !empty($ref->last_frame_url)) {
+            // 与文生视频 completed 返回格式保持一致
+            $result = json_encode([
+                'task_id'          => $ref->id,
+                'status'           => $ref->status,
+                'video_url'        => $ref->compressed_url ?: $ref->result_url,
+                'origin_video_url' => $ref->result_url,
+                'last_frame_url'   => $ref->last_frame_url,
+                'error_message'    => $ref->error_message ? mapErrorMessage($ref->error_message) : '',
+            ], JSON_UNESCAPED_UNICODE);
+        }
+
+        return $this->applyRefStatus($task, $ref->status, $result, $ref->error_message);
+    }
+
+    /**
+     * 将底层任务状态应用到任务中心记录
+     *
+     * @param MpTaskCenter $task
+     * @param string       $refStatus
+     * @param string|null  $result
+     * @param string|null  $errorMessage
+     * @return int
+     */
+    private function applyRefStatus(MpTaskCenter $task, string $refStatus, $result, $errorMessage): int
+    {
+        $statusMap = [
+            'pending'    => MpTaskCenter::STATUS_PENDING,
+            'processing' => MpTaskCenter::STATUS_PROCESSING,
+            'success'    => MpTaskCenter::STATUS_SUCCESS,
+            'failed'     => MpTaskCenter::STATUS_FAILED,
+        ];
+
+        $newStatus = $statusMap[$refStatus] ?? $task->status;
+        $updateData = ['status' => $newStatus];
+
+        if ($result !== null) {
+            $updateData['result'] = $result;
+        }
+        if ($errorMessage !== null) {
+            $updateData['error_message'] = $errorMessage;
+        }
+
+        return $this->updateTask((int)$task->id, $updateData) ? 1 : 0;
+    }
+}

+ 5 - 0
routes/api.php

@@ -10,6 +10,7 @@ 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 App\Http\Controllers\TaskCenter\TaskCenterController;
 use Illuminate\Support\Facades\Route;
 
 /*
@@ -263,6 +264,10 @@ Route::group(['middleware' => ['bindToken', 'bindExportToken', 'checkLogin']], f
             Route::post('createPromptTemplate', [PromptTemplateController::class, 'createTemplate']);     // 创建提示词模板
             Route::post('editPromptTemplate', [PromptTemplateController::class, 'editTemplate']);         // 编辑提示词模板
             Route::get('deletePromptTemplate', [PromptTemplateController::class, 'deleteTemplate']);      // 删除提示词模板
+
+            // 任务中心
+            Route::get('taskCenter/detail', [TaskCenterController::class, 'detail']);      // 查询任务详情
+            Route::get('taskCenter/list', [TaskCenterController::class, 'taskList']);      // 任务列表(分页)
         });
 
         // 画布模式