From 9ad2152816563e5dc4f931050ddf956401f75d97 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 23 Jun 2026 16:30:54 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BA=E5=9B=BE=E7=89=87=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E4=B8=8A=E4=BC=A0=E5=92=8C=E7=94=9F=E6=88=90=E5=8A=A0?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增图片编辑器请求重试配置 生成请求遇到429等临时错误自动重试 上传凭证、资产确认和直传上传共用重试策略 补充图片编辑器重试参数和直传429测试 --- .../editorMediaAssetUploadClient.test.ts | 27 +++++++ .../editorMediaAssetUploadClient.ts | 30 ++------ .../image-editor/editorProjectClient.test.ts | 31 ++++---- .../image-editor/editorProjectClient.ts | 33 +++----- .../editorReferenceUploadClient.test.ts | 7 ++ .../editorReferenceUploadClient.ts | 30 ++------ .../image-editor/editorRetryOptions.test.ts | 44 +++++++++++ .../image-editor/editorRetryOptions.ts | 76 +++++++++++++++++++ 8 files changed, 195 insertions(+), 83 deletions(-) create mode 100644 src/services/image-editor/editorRetryOptions.test.ts create mode 100644 src/services/image-editor/editorRetryOptions.ts diff --git a/src/services/image-editor/editorMediaAssetUploadClient.test.ts b/src/services/image-editor/editorMediaAssetUploadClient.test.ts index 58c06ea47..dcd5fab06 100644 --- a/src/services/image-editor/editorMediaAssetUploadClient.test.ts +++ b/src/services/image-editor/editorMediaAssetUploadClient.test.ts @@ -14,6 +14,11 @@ vi.mock('../assetReadUrlService', () => ({ const requestJsonMock = vi.mocked(requestJson); const getSignedAssetReadUrlMock = vi.mocked(getSignedAssetReadUrl); +const editorRetryOptionsExpectation = expect.objectContaining({ + maxRetries: 2, + retryUnsafeMethods: true, + retryableStatusCodes: expect.arrayContaining([429]), +}); describe('editorMediaAssetUploadClient', () => { beforeEach(() => { @@ -64,6 +69,17 @@ describe('editorMediaAssetUploadClient', () => { 'video', ); + expect(requestJsonMock).toHaveBeenNthCalledWith( + 1, + '/api/assets/direct-upload-tickets', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: expect.any(String), + }), + '创建素材上传凭证失败', + expect.objectContaining({ retry: editorRetryOptionsExpectation }), + ); const ticketBody = JSON.parse( (requestJsonMock.mock.calls[0]?.[1] as RequestInit).body as string, ); @@ -90,6 +106,17 @@ describe('editorMediaAssetUploadClient', () => { const confirmBody = JSON.parse( (requestJsonMock.mock.calls[1]?.[1] as RequestInit).body as string, ); + expect(requestJsonMock).toHaveBeenNthCalledWith( + 2, + '/api/assets/objects/confirm', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: expect.any(String), + }), + '确认素材资产失败', + expect.objectContaining({ retry: editorRetryOptionsExpectation }), + ); expect(confirmBody).toMatchObject({ bucket: 'bucket', objectKey: 'generated-character-drafts/editor/asset-library/video/demo.mp4', diff --git a/src/services/image-editor/editorMediaAssetUploadClient.ts b/src/services/image-editor/editorMediaAssetUploadClient.ts index bced35b7e..a90e40826 100644 --- a/src/services/image-editor/editorMediaAssetUploadClient.ts +++ b/src/services/image-editor/editorMediaAssetUploadClient.ts @@ -1,5 +1,9 @@ import { requestJson } from '../apiClient'; import { getSignedAssetReadUrl } from '../assetReadUrlService'; +import { + EDITOR_REQUEST_RETRY_OPTIONS, + postEditorDirectUploadFile, +} from './editorRetryOptions'; export type EditorMediaAssetUploadType = 'video' | 'audio'; @@ -48,28 +52,6 @@ function sanitizeEditorMediaFileName( return trimmedName || resolveFallbackFileName(mediaType); } -async function postDirectUploadFile( - upload: DirectUploadTicketResponse['upload'], - file: File, -) { - const formData = new FormData(); - Object.entries(upload.formFields).forEach(([key, value]) => { - if (value !== null && value !== undefined) { - formData.append(key, value); - } - }); - formData.append('file', file, file.name); - - const response = await fetch(upload.host, { - method: 'POST', - body: formData, - }); - - if (!response.ok) { - throw new Error('上传素材失败'); - } -} - export async function uploadEditorMediaAssetFile( file: File, mediaType: EditorMediaAssetUploadType, @@ -98,9 +80,10 @@ export async function uploadEditorMediaAssetFile( }), }, '创建素材上传凭证失败', + { retry: EDITOR_REQUEST_RETRY_OPTIONS }, ); - await postDirectUploadFile(ticket.upload, file); + await postEditorDirectUploadFile(ticket.upload, file, '上传素材失败'); const confirmed = await requestJson( '/api/assets/objects/confirm', @@ -118,6 +101,7 @@ export async function uploadEditorMediaAssetFile( }), }, '确认素材资产失败', + { retry: EDITOR_REQUEST_RETRY_OPTIONS }, ); // 中文注释:素材库立即预览使用短期 signed URL;持久化仍记录 legacy path 和 objectKey。 diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 3aa3b20e0..124627862 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -28,6 +28,11 @@ import { } from './editorProjectClient'; const requestJsonMock = vi.hoisted(() => vi.fn()); +const editorRetryOptionsExpectation = expect.objectContaining({ + maxRetries: 2, + retryUnsafeMethods: true, + retryableStatusCodes: expect.arrayContaining([429]), +}); vi.mock('../apiClient', () => ({ requestJson: requestJsonMock, @@ -585,7 +590,7 @@ describe('editorProjectClient', () => { '生成图片失败', expect.objectContaining({ timeoutMs: 1_200_000, - retry: { maxRetries: 0 }, + retry: editorRetryOptionsExpectation, }), ); }); @@ -645,7 +650,7 @@ describe('editorProjectClient', () => { '生成图片失败', expect.objectContaining({ timeoutMs: 1_200_000, - retry: { maxRetries: 0 }, + retry: editorRetryOptionsExpectation, }), ); }); @@ -708,7 +713,7 @@ describe('editorProjectClient', () => { '生成图片失败', expect.objectContaining({ timeoutMs: 1_200_000, - retry: { maxRetries: 0 }, + retry: editorRetryOptionsExpectation, }), ); }); @@ -753,7 +758,7 @@ describe('editorProjectClient', () => { '生成图标素材失败', expect.objectContaining({ timeoutMs: 1_200_000, - retry: { maxRetries: 0 }, + retry: editorRetryOptionsExpectation, }), ); }); @@ -807,7 +812,7 @@ describe('editorProjectClient', () => { '生成图标素材失败', expect.objectContaining({ timeoutMs: 1_200_000, - retry: { maxRetries: 0 }, + retry: editorRetryOptionsExpectation, }), ); }); @@ -867,7 +872,7 @@ describe('editorProjectClient', () => { '提取UI设计图素材失败', expect.objectContaining({ timeoutMs: 1_200_000, - retry: { maxRetries: 0 }, + retry: editorRetryOptionsExpectation, }), ); }); @@ -908,7 +913,7 @@ describe('editorProjectClient', () => { '生成图片失败', expect.objectContaining({ timeoutMs: 1_200_000, - retry: { maxRetries: 0 }, + retry: editorRetryOptionsExpectation, }), ); }); @@ -953,7 +958,7 @@ describe('editorProjectClient', () => { '生成图片失败', expect.objectContaining({ timeoutMs: 1_200_000, - retry: { maxRetries: 0 }, + retry: editorRetryOptionsExpectation, }), ); }); @@ -1012,7 +1017,7 @@ describe('editorProjectClient', () => { '生成角色动画失败', expect.objectContaining({ timeoutMs: 1_200_000, - retry: { maxRetries: 0 }, + retry: editorRetryOptionsExpectation, }), ); }); @@ -1062,7 +1067,7 @@ describe('editorProjectClient', () => { '生成视频失败', expect.objectContaining({ timeoutMs: 1_200_000, - retry: { maxRetries: 0 }, + retry: editorRetryOptionsExpectation, }), ); }); @@ -1162,7 +1167,7 @@ describe('editorProjectClient', () => { '生成游戏音效失败', expect.objectContaining({ timeoutMs: 1_200_000, - retry: { maxRetries: 0 }, + retry: editorRetryOptionsExpectation, }), ); }); @@ -1234,7 +1239,7 @@ describe('editorProjectClient', () => { '生成游戏背景音乐失败', expect.objectContaining({ timeoutMs: 1_200_000, - retry: { maxRetries: 0 }, + retry: editorRetryOptionsExpectation, }), ); }); @@ -1293,7 +1298,7 @@ describe('editorProjectClient', () => { '修改图片失败', expect.objectContaining({ timeoutMs: 1_200_000, - retry: { maxRetries: 0 }, + retry: editorRetryOptionsExpectation, }), ); }); diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index c7113d315..f983bc975 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -1,4 +1,5 @@ import { requestJson } from '../apiClient'; +import { EDITOR_REQUEST_RETRY_OPTIONS } from './editorRetryOptions'; const EDITOR_PROJECT_API_BASE = '/api/editor/projects'; const EDITOR_ASSET_API_BASE = '/api/editor/assets'; @@ -676,9 +677,7 @@ export async function generateEditorImage(input: EditorImageGenerationInput) { '生成图片失败', { timeoutMs: 1_200_000, - retry: { - maxRetries: 0, - }, + retry: EDITOR_REQUEST_RETRY_OPTIONS, }, ); } @@ -703,9 +702,7 @@ export async function generateEditorIconSpritesheet( '生成图标素材失败', { timeoutMs: 1_200_000, - retry: { - maxRetries: 0, - }, + retry: EDITOR_REQUEST_RETRY_OPTIONS, }, ); } @@ -731,9 +728,7 @@ export async function extractEditorUiDesignAssets( '提取UI设计图素材失败', { timeoutMs: 1_200_000, - retry: { - maxRetries: 0, - }, + retry: EDITOR_REQUEST_RETRY_OPTIONS, }, ); } @@ -760,9 +755,7 @@ export async function editEditorImage(input: EditorImageEditInput) { '修改图片失败', { timeoutMs: 1_200_000, - retry: { - maxRetries: 0, - }, + retry: EDITOR_REQUEST_RETRY_OPTIONS, }, ); } @@ -776,9 +769,7 @@ export async function generateEditorCharacterAnimation( '生成角色动画失败', { timeoutMs: 1_200_000, - retry: { - maxRetries: 0, - }, + retry: EDITOR_REQUEST_RETRY_OPTIONS, }, ); } @@ -808,9 +799,7 @@ export async function generateEditorVideo(input: EditorVideoGenerationInput) { '生成视频失败', { timeoutMs: 1_200_000, - retry: { - maxRetries: 0, - }, + retry: EDITOR_REQUEST_RETRY_OPTIONS, }, ); } @@ -828,9 +817,7 @@ export async function generateEditorSoundEffect( '生成游戏音效失败', { timeoutMs: 1_200_000, - retry: { - maxRetries: 0, - }, + retry: EDITOR_REQUEST_RETRY_OPTIONS, }, ); } @@ -847,9 +834,7 @@ export async function generateEditorBackgroundMusic( '生成游戏背景音乐失败', { timeoutMs: 1_200_000, - retry: { - maxRetries: 0, - }, + retry: EDITOR_REQUEST_RETRY_OPTIONS, }, ); } diff --git a/src/services/image-editor/editorReferenceUploadClient.test.ts b/src/services/image-editor/editorReferenceUploadClient.test.ts index dbefbcd1f..31b6ebe8f 100644 --- a/src/services/image-editor/editorReferenceUploadClient.test.ts +++ b/src/services/image-editor/editorReferenceUploadClient.test.ts @@ -14,6 +14,11 @@ vi.mock('../assetReadUrlService', () => ({ const requestJsonMock = vi.mocked(requestJson); const getSignedAssetReadUrlMock = vi.mocked(getSignedAssetReadUrl); +const editorRetryOptionsExpectation = expect.objectContaining({ + maxRetries: 2, + retryUnsafeMethods: true, + retryableStatusCodes: expect.arrayContaining([429]), +}); describe('editorReferenceUploadClient', () => { beforeEach(() => { @@ -73,6 +78,7 @@ describe('editorReferenceUploadClient', () => { body: expect.any(String), }), '创建参考素材上传凭证失败', + expect.objectContaining({ retry: editorRetryOptionsExpectation }), ); const ticketBody = JSON.parse( (requestJsonMock.mock.calls[0]?.[1] as RequestInit).body as string, @@ -111,6 +117,7 @@ describe('editorReferenceUploadClient', () => { body: expect.any(String), }), '确认参考素材资产失败', + expect.objectContaining({ retry: editorRetryOptionsExpectation }), ); const confirmBody = JSON.parse( (requestJsonMock.mock.calls[1]?.[1] as RequestInit).body as string, diff --git a/src/services/image-editor/editorReferenceUploadClient.ts b/src/services/image-editor/editorReferenceUploadClient.ts index d9a8bace8..c72b931a1 100644 --- a/src/services/image-editor/editorReferenceUploadClient.ts +++ b/src/services/image-editor/editorReferenceUploadClient.ts @@ -1,5 +1,9 @@ import { requestJson } from '../apiClient'; import { getSignedAssetReadUrl } from '../assetReadUrlService'; +import { + EDITOR_REQUEST_RETRY_OPTIONS, + postEditorDirectUploadFile, +} from './editorRetryOptions'; type EditorSeedanceReferenceMediaType = 'video' | 'audio'; @@ -48,28 +52,6 @@ function sanitizeReferenceFileName(fileName: string, mediaType: EditorSeedanceRe return mediaType === 'video' ? 'seedance-reference.mp4' : 'seedance-reference.mp3'; } -async function postDirectUploadFile( - upload: DirectUploadTicketResponse['upload'], - file: File, -) { - const formData = new FormData(); - Object.entries(upload.formFields).forEach(([key, value]) => { - if (value !== null && value !== undefined) { - formData.append(key, value); - } - }); - formData.append('file', file, file.name); - - const response = await fetch(upload.host, { - method: 'POST', - body: formData, - }); - - if (!response.ok) { - throw new Error('上传参考素材失败'); - } -} - export async function uploadEditorSeedanceReferenceFile( file: File, mediaType: EditorSeedanceReferenceMediaType, @@ -98,9 +80,10 @@ export async function uploadEditorSeedanceReferenceFile( }), }, '创建参考素材上传凭证失败', + { retry: EDITOR_REQUEST_RETRY_OPTIONS }, ); - await postDirectUploadFile(ticket.upload, file); + await postEditorDirectUploadFile(ticket.upload, file, '上传参考素材失败'); const confirmed = await requestJson( '/api/assets/objects/confirm', @@ -118,6 +101,7 @@ export async function uploadEditorSeedanceReferenceFile( }), }, '确认参考素材资产失败', + { retry: EDITOR_REQUEST_RETRY_OPTIONS }, ); // 中文注释:返回短期 signed URL 供前端立即预览;生成请求最终优先提交 objectKey,由后端统一重新签名。 diff --git a/src/services/image-editor/editorRetryOptions.test.ts b/src/services/image-editor/editorRetryOptions.test.ts new file mode 100644 index 000000000..fc7b3f8bf --- /dev/null +++ b/src/services/image-editor/editorRetryOptions.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { postEditorDirectUploadFile } from './editorRetryOptions'; + +describe('editorRetryOptions', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('retries direct editor uploads when the storage endpoint returns 429', async () => { + vi.useFakeTimers(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 429 })) + .mockResolvedValueOnce(new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + const uploadPromise = postEditorDirectUploadFile( + { + host: 'https://oss.example.com', + formFields: { key: 'editor/demo.mp4', policy: 'policy' }, + }, + new File(['video'], 'demo.mp4', { type: 'video/mp4' }), + '上传素材失败', + ); + + await Promise.resolve(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(800); + await uploadPromise; + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenLastCalledWith( + 'https://oss.example.com', + expect.objectContaining({ + method: 'POST', + body: expect.any(FormData), + }), + ); + }); +}); diff --git a/src/services/image-editor/editorRetryOptions.ts b/src/services/image-editor/editorRetryOptions.ts new file mode 100644 index 000000000..ecbd86d15 --- /dev/null +++ b/src/services/image-editor/editorRetryOptions.ts @@ -0,0 +1,76 @@ +import type { ApiRetryOptions } from '../apiClient'; + +const EDITOR_RETRYABLE_STATUS_CODES = [408, 425, 429, 502, 503, 504]; +const EDITOR_RETRY_MAX_RETRIES = 2; +const EDITOR_RETRY_BASE_DELAY_MS = 800; +const EDITOR_RETRY_MAX_DELAY_MS = 3000; + +export const EDITOR_REQUEST_RETRY_OPTIONS = { + maxRetries: EDITOR_RETRY_MAX_RETRIES, + baseDelayMs: EDITOR_RETRY_BASE_DELAY_MS, + maxDelayMs: EDITOR_RETRY_MAX_DELAY_MS, + retryUnsafeMethods: true, + retryableStatusCodes: EDITOR_RETRYABLE_STATUS_CODES, +} satisfies ApiRetryOptions; + +type EditorDirectUploadTarget = { + host: string; + formFields: Record; +}; + +function buildRetryDelayMs(attempt: number) { + return Math.min( + EDITOR_RETRY_MAX_DELAY_MS, + EDITOR_RETRY_BASE_DELAY_MS * Math.max(1, attempt), + ); +} + +function waitForRetryDelay(attempt: number) { + return new Promise((resolve) => { + setTimeout(resolve, buildRetryDelayMs(attempt)); + }); +} + +function isRetryableEditorUploadStatus(status: number) { + return EDITOR_RETRYABLE_STATUS_CODES.includes(status); +} + +function buildDirectUploadFormData( + upload: EditorDirectUploadTarget, + file: File, +) { + const formData = new FormData(); + Object.entries(upload.formFields).forEach(([key, value]) => { + if (value !== null && value !== undefined) { + formData.append(key, value); + } + }); + formData.append('file', file, file.name); + return formData; +} + +export async function postEditorDirectUploadFile( + upload: EditorDirectUploadTarget, + file: File, + errorMessage: string, +) { + for (let attempt = 0; ; attempt += 1) { + const response = await fetch(upload.host, { + method: 'POST', + body: buildDirectUploadFormData(upload, file), + }); + + if (response.ok) { + return; + } + + if ( + attempt >= EDITOR_RETRY_MAX_RETRIES || + !isRetryableEditorUploadStatus(response.status) + ) { + throw new Error(errorMessage); + } + + await waitForRetryDelay(attempt + 1); + } +}