Ver Fonte

1.优化图片任务超时时长并设置最大重试次数
2.图片队列任务改为按时间正序,失败任务放到队尾重试
3.尺寸映射表调整所有尺寸都为16的倍数以兼容gpt-image2

lh há 1 mês atrás
pai
commit
25b9a63dee

+ 9 - 5
app/Console/Commands/CheckImageGenerationTasksCommand.php

@@ -33,12 +33,16 @@ class CheckImageGenerationTasksCommand extends Command
     {
         dLog('command')->info('开始检查图片生成任务状态...');
 
-        // 将指定时间点之前的image2生成中图片任务设置为待生成以供重试(防止接口超时返回变成死任务)
+        // 将指定时间点之前的排队模型(火山API/image2)生成中图片任务设置为待生成以供重试
+        // (防止 worker 被强杀后任务长期停留在 processing 变成死任务)
+        // 同时写入 next_retry_at,使重置后的任务排到队列尾部,先让其它任务过一遍
         $time_ago = date('Y-m-d H:i:s', strtotime('-10 minutes'));
         $timeout_count = DB::table('mp_generate_pic_tasks')->whereNotNull('started_at')->where('started_at', '<', $time_ago)->where('status', 'processing')
-            ->whereIn('model', BaseConst::GPT_IMAGE2_MODELS)->update([
-                'started_at'    => null,
-                'status'        => 'pending',
+            ->whereIn('model', array_merge(BaseConst::GPT_IMAGE2_MODELS, BaseConst::VOLC_PIC_MODELS))
+            ->update([
+                'started_at'   => null,
+                'status'       => 'pending',
+                'extra_params' => DB::raw("JSON_SET(COALESCE(extra_params, JSON_OBJECT()), '$.next_retry_at', DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'))"),
             ]);
         dLog('command')->info('image2超时重置任务个数: '.$timeout_count);
 
@@ -229,4 +233,4 @@ class CheckImageGenerationTasksCommand extends Command
             ]);
         }
     }
-}
+}

+ 2 - 2
app/Consts/BaseConst.php

@@ -135,8 +135,8 @@ class BaseConst
             '21:9' => ['width' => 3136, 'height' => 1344],
             '2:1' => ['width' => 2048, 'height' => 1024],
             '1:2' => ['width' => 1024, 'height' => 2048],
-            '3:1' => ['width' => 2460, 'height' => 820],
-            '1:3' => ['width' => 820, 'height' => 2460],
+            '3:1' => ['width' => 2448, 'height' => 816],
+            '1:3' => ['width' => 816, 'height' => 2448],
         ],
         '3k' => [
             '1:1' => ['width' => 3072, 'height' => 3072],

+ 4 - 1
app/Jobs/ProcessGptImage2GenerationJob.php

@@ -24,9 +24,12 @@ class ProcessGptImage2GenerationJob implements ShouldQueue
     /**
      * 任务超时时间(秒)
      *
+     * 必须大于上游请求超时(AIImageGenerationService::REQUEST_TIMEOUT_SECONDS = 300s),
+     * 让请求先超时、由业务层记录错误并重新排队;supervisor 的 stopwaitsecs 需不小于该值。
+     *
      * @var int
      */
-    public $timeout = 300;
+    public $timeout = 360;
 
     /**
      * 任务ID

+ 4 - 1
app/Jobs/ProcessVolcImageGenerationJob.php

@@ -24,9 +24,12 @@ class ProcessVolcImageGenerationJob implements ShouldQueue
     /**
      * 任务超时时间(秒)
      *
+     * 必须大于上游请求超时(AIImageGenerationService::REQUEST_TIMEOUT_SECONDS = 300s),
+     * 让请求先超时、由业务层记录错误并重新排队;supervisor 的 stopwaitsecs 需不小于该值。
+     *
      * @var int
      */
-    public $timeout = 300;
+    public $timeout = 360;
 
     /**
      * 任务ID

+ 190 - 9
app/Services/AIGeneration/AIImageGenerationService.php

@@ -44,12 +44,32 @@ class AIImageGenerationService
     /** 派发锁分组:GPT-Image2 */
     const DISPATCH_GROUP_GPT_IMAGE2 = 'gpt_image2_pics';
 
+    /** 图片任务最大尝试次数:达到上限仍失败则直接判失败,并写入最后一次的错误原因 */
+    const MAX_TASK_ATTEMPTS = 10;
+    /**
+     * 单次上游请求超时(秒)
+     *
+     * 必须小于队列 job 的 timeout(360 秒),保证请求先超时、由业务层记录错误并重新排队,
+     * 而不是被 worker 强杀导致任务长期停留在 processing。
+     * 取值依据(近 3 天成功任务实测):gpt-image-2 生成耗时 p99 ≈ 261~286s、max ≈ 297s,
+     * 取 300s 基本不掐断正常请求(若取 240s 会掐掉 2.4%~5.4%,那部分请求上游往往已经生成
+     * 完成,重试等于重复生成、白花一次费用),并给 job 留 60s 收尾时间,故取 300s。
+     */
+    const REQUEST_TIMEOUT_SECONDS = 300;
+    /** 连接超时(秒) */
+    const REQUEST_CONNECT_TIMEOUT_SECONDS = 30;
+    /** extra_params 中记录重试信息的键名 */
+    const EXTRA_ATTEMPTS = 'attempts';
+    const EXTRA_LAST_ERROR = 'last_error';
+    const EXTRA_NEXT_RETRY_AT = 'next_retry_at';
+
     public function __construct(VolcEngineService $volcEngineService, PointsService $pointsService)
     {
         $this->volcEngineService = $volcEngineService;
         $this->pointsService = $pointsService;
         $this->httpClient = new Client([
-            'timeout' => 300,
+            'timeout'         => self::REQUEST_TIMEOUT_SECONDS,
+            'connect_timeout' => self::REQUEST_CONNECT_TIMEOUT_SECONDS,
         ]);
     }
 
@@ -397,6 +417,11 @@ class AIImageGenerationService
                 return;
             }
             
+            // 重试上限控制:已达上限直接判失败(error_message 内含最后一次失败原因)
+            if (!$this->beginTaskAttempt($task)) {
+                return;
+            }
+
             // 更新状态为处理中
             $task->updateStatus(MpGeneratePicTask::STATUS_PROCESSING);
             
@@ -418,8 +443,10 @@ class AIImageGenerationService
             
             // 创建专用客户端,优化配置
             $gptClient = new Client([
-                'timeout' => 1800, // 超时
-                'connect_timeout' => 120, // 连接超时10秒
+                // 超时必须小于队列 job 的 timeout(300s):请求先超时并抛出异常,
+                // 由业务层记录错误并重新排队,避免 worker 强杀导致任务长期悬挂
+                'timeout' => self::REQUEST_TIMEOUT_SECONDS,
+                'connect_timeout' => self::REQUEST_CONNECT_TIMEOUT_SECONDS,
                 'http_errors' => false, // 不自动抛出HTTP错误
                 'verify' => false, // 禁用SSL验证(如果有证书问题)
                 'allow_redirects' => true,
@@ -549,8 +576,8 @@ class AIImageGenerationService
                         'Content-Length: ' . strlen($postFields),
                         'Expect:', // 移除 Expect: 100-continue,避免等待
                     ],
-                    CURLOPT_TIMEOUT => 600,
-                    CURLOPT_CONNECTTIMEOUT => 30,
+                    CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT_SECONDS, // 与 job timeout(300s) 对齐,先超时再落状态
+                    CURLOPT_CONNECTTIMEOUT => self::REQUEST_CONNECT_TIMEOUT_SECONDS,
                     CURLOPT_SSL_VERIFYPEER => false,
                     CURLOPT_SSL_VERIFYHOST => false,
                     CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, // 强制使用 HTTP/1.1
@@ -576,6 +603,11 @@ class AIImageGenerationService
                     throw new \Exception('cURL错误: ' . $curlError . ' (错误码: ' . $curlErrno . ')');
                 }
                 
+                if ($statusCode >= 500) {
+                    // 上游 5xx:按可重试错误处理(放回队列,稍后再试)
+                    throw new \RuntimeException('上游服务异常,HTTP状态码: ' . $statusCode);
+                }
+                
                 if (empty($responseBody)) {
                     throw new \Exception('API返回空响应,HTTP状态码: ' . $statusCode);
                 }
@@ -616,6 +648,11 @@ class AIImageGenerationService
                 $elapsed = round(microtime(true) - $startTime, 2);
                 dLog('generate')->info('Guzzle请求完成', ['elapsed' => $elapsed . 's', 'http_code' => $statusCode]);
                 
+                if ($statusCode >= 500) {
+                    // 上游 5xx:按可重试错误处理(放回队列,稍后再试)
+                    throw new \RuntimeException('上游服务异常,HTTP状态码: ' . $statusCode . ',响应: ' . mb_substr((string)$responseBody, 0, 200));
+                }
+                
                 if (empty($responseBody)) {
                     throw new \Exception('API返回空响应,HTTP状态码: ' . $statusCode);
                 }
@@ -660,8 +697,17 @@ class AIImageGenerationService
                 'file' => $e->getFile(),
                 'line' => $e->getLine()
             ]);
+
+            $errorMessage = 'API请求失败: ' . $e->getMessage();
+
+            if ($this->isRetryableApiError($e)) {
+                // 超时/连接失败等可重试错误:放回队列尾部重试,先让其它任务执行
+                $this->requeueTaskForRetry($task, $errorMessage);
+                return;
+            }
+
             $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
-                'error_message' => 'API请求失败: ' . $e->getMessage()
+                'error_message' => $errorMessage
             ]);
         }
     }
@@ -880,6 +926,11 @@ class AIImageGenerationService
                 return;
             }
             
+            // 重试上限控制:已达上限直接判失败(error_message 内含最后一次失败原因)
+            if (!$this->beginTaskAttempt($task)) {
+                return;
+            }
+
             // 更新状态为处理中
             $task->updateStatus(MpGeneratePicTask::STATUS_PROCESSING);
                 
@@ -947,8 +998,17 @@ class AIImageGenerationService
             // 记录错误
             dLog('generate')->error('火山API请求失败: ' . $e->getMessage());
             logDB('generate', 'error', '火山图片生成任务异常', ['task_id' => $task->task_id, 'error' => $e->getMessage()]);
+
+            $errorMessage = 'API请求失败: ' . $e->getMessage();
+
+            if ($this->isRetryableApiError($e)) {
+                // 超时/连接失败等可重试错误:放回队列尾部重试,先让其它任务执行
+                $this->requeueTaskForRetry($task, $errorMessage);
+                return;
+            }
+
             $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
-                'error_message' => 'API请求失败: ' . $e->getMessage()
+                'error_message' => $errorMessage
             ]);
         }
     }
@@ -1627,7 +1687,7 @@ class AIImageGenerationService
         // 1. 处理火山API的pending任务(批量提交到队列)
         $volcPendingTasks = MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PENDING)
             ->whereIn('model', BaseConst::VOLC_PIC_MODELS)
-            ->orderBy('created_at', 'asc')
+            ->orderByRaw($this->dispatchOrderByRaw())
             ->limit(self::DISPATCH_CANDIDATE_LIMIT) // 候选窗口需大于每轮派发上限,避免已入队的任务挡住后面的任务
             ->get();
 
@@ -1649,7 +1709,7 @@ class AIImageGenerationService
         // 2. 处理GPT-Image2的pending任务(批量提交到队列)
         $gptImage2PendingTasks = MpGeneratePicTask::where('status', MpGeneratePicTask::STATUS_PENDING)
             ->whereIn('model', BaseConst::GPT_IMAGE2_MODELS)
-            ->orderBy('created_at', 'asc')
+            ->orderByRaw($this->dispatchOrderByRaw())
             ->limit(self::DISPATCH_CANDIDATE_LIMIT) // 候选窗口需大于每轮派发上限,避免已入队的任务挡住后面的任务
             ->get();
 
@@ -1739,6 +1799,127 @@ class AIImageGenerationService
     }
 
     /**
+     * 记录一次真实提交尝试(在真正调用上游接口前调用),并做重试上限控制
+     *
+     * - 每次实际提交前 attempts + 1(记录在 extra_params.attempts)
+     * - attempts 达到 MAX_TASK_ATTEMPTS 后直接判失败,
+     *   并把最后一次的错误原因写进 error_message,供前端展示失败原因;
+     * - 计数放在“真正提交”这一步而不是“派发”这一步:队列积压时同一任务可能被重复派发,
+     *   按派发计数会凭空消耗重试次数,导致任务提前被判失败。
+     *
+     * @param MpGeneratePicTask $task
+     * @return bool 是否继续提交本次请求(false 表示已达重试上限,任务已判失败)
+     */
+    private function beginTaskAttempt(MpGeneratePicTask $task): bool
+    {
+        $fresh = MpGeneratePicTask::find($task->id);
+        $extra = ($fresh && is_array($fresh->extra_params)) ? $fresh->extra_params : [];
+        $attempts = (int)getProp($extra, self::EXTRA_ATTEMPTS, 0);
+
+        // 重试次数已达上限:直接判失败,避免任务无限重试并长期占用队列
+        if ($attempts >= self::MAX_TASK_ATTEMPTS) {
+            $lastError = (string)getProp($extra, self::EXTRA_LAST_ERROR, '');
+            $message = '图片生成失败:已重试' . self::MAX_TASK_ATTEMPTS . '次仍未成功';
+            if ($lastError !== '') {
+                $message .= ';最后错误:' . $lastError;
+            }
+
+            $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, ['error_message' => $message]);
+
+            logDB('generate', 'error', '图片任务重试次数已达上限,标记失败', [
+                'task_id'    => $task->task_id,
+                'model'      => $task->model,
+                'attempts'   => $attempts,
+                'last_error' => $lastError,
+            ]);
+
+            return false;
+        }
+
+        // 记录本次尝试次数(重试上限以此计数为准)
+        $extra[self::EXTRA_ATTEMPTS] = $attempts + 1;
+        ($fresh ?: $task)->update(['extra_params' => $extra]);
+
+        return true;
+    }
+
+    /**
+     * 图片任务派发顺序:优先按 extra_params.next_retry_at(失败后重排时间)升序,
+     * 没有重排时间的按创建时间升序。这样失败重试的任务会排到当前队列尾部,
+     * 先让其它任务各过一遍,单个任务卡住不会一直堵住后面的任务。
+     *
+     * @return string
+     */
+    private function dispatchOrderByRaw(): string
+    {
+        return "COALESCE(STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(extra_params, '$.next_retry_at')), '%Y-%m-%d %H:%i:%s'), created_at) ASC";
+    }
+
+    /**
+     * 判断异常是否属于可重试错误(上游超时、连接失败、5xx 等)
+     *
+     * @param \Throwable $e
+     * @return bool
+     */
+    private function isRetryableApiError(\Throwable $e): bool
+    {
+        // Guzzle:连接失败/请求超时
+        if ($e instanceof \GuzzleHttp\Exception\ConnectException) {
+            return true;
+        }
+
+        // Guzzle:上游 5xx
+        if ($e instanceof \GuzzleHttp\Exception\ServerException) {
+            return true;
+        }
+
+        // 原生 cURL 分支的超时、空响应、网关 5xx 统一按可重试处理
+        // 注意:这里只做语义匹配,不能用裸数字(如 504)匹配,否则会被尺寸文本(3040x5504)误命中
+        $message = mb_strtolower($e->getMessage());
+        foreach (['timed out', 'timeout', 'curl错误: 28', '空响应', 'empty response', '上游服务异常'] as $keyword) {
+            if (mb_strpos($message, $keyword) !== false) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * 可重试失败:把任务放回 pending,记录最后一次错误与重排时间,
+     * 使其排到队列尾部等待下一次尝试;尝试次数在真正提交前统一累加并在达到上限时判失败。
+     *
+     * @param MpGeneratePicTask $task
+     * @param string $lastError
+     * @return void
+     */
+    private function requeueTaskForRetry(MpGeneratePicTask $task, string $lastError): void
+    {
+        $fresh = MpGeneratePicTask::find($task->id);
+        if (!$fresh) {
+            return;
+        }
+
+        $extra = is_array($fresh->extra_params) ? $fresh->extra_params : [];
+        $extra[self::EXTRA_LAST_ERROR] = mb_substr($lastError, 0, 200);
+        $extra[self::EXTRA_NEXT_RETRY_AT] = date('Y-m-d H:i:s');
+
+        $fresh->update([
+            'status'       => MpGeneratePicTask::STATUS_PENDING,
+            'started_at'   => null,
+            'completed_at' => null,
+            'extra_params' => $extra,
+        ]);
+
+        dLog('generate')->info('图片任务失败已放回队列尾部等待重试', [
+            'task_id'    => $task->task_id,
+            'model'      => $task->model,
+            'attempts'   => (int)getProp($extra, self::EXTRA_ATTEMPTS, 0),
+            'last_error' => $extra[self::EXTRA_LAST_ERROR],
+        ]);
+    }
+
+    /**
      * 获取任务派发锁
      *
      * 在队列所在的 Redis 连接上执行原生 `SET key value EX ttl NX` 原子命令,