|
|
@@ -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` 原子命令,
|