合并主分支

合并画布素材导出主线,保留钱包与充值修复。
This commit is contained in:
2026-08-06 13:15:54 +08:00
9 changed files with 413 additions and 12 deletions
@@ -298,6 +298,78 @@ describe('ImageCanvasExportModel', () => {
}
});
it('falls back when OSS returns 206 but its response body fails during export', async () => {
const originalFetch = globalThis.fetch;
const signedResponseBlob = vi
.fn()
.mockRejectedValue(new TypeError('Failed to fetch'));
const fetchMock = vi.fn(async (url: string) => {
if (url.startsWith('/api/assets/read-url?')) {
return new Response(
JSON.stringify({
ok: true,
data: {
read: {
objectKey: 'generated/export-body-failure.png',
signedUrl: 'https://signed.example.com/export-body-failure.png',
expiresAt: '2099-01-01T00:10:00Z',
},
},
error: null,
meta: {
apiVersion: '2026-06-16',
routeVersion: '2026-06-16',
latencyMs: 1,
timestamp: '2099-01-01T00:00:00Z',
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
);
}
if (url === 'https://signed.example.com/export-body-failure.png') {
return {
ok: true,
status: 206,
statusText: 'Partial Content',
headers: new Headers({
'Content-Range': 'bytes 0-2/3',
'Content-Type': 'image/png',
}),
blob: signedResponseBlob,
} as unknown as Response;
}
if (url.startsWith('/api/assets/read-bytes?')) {
return new Response(new Blob(['fallback'], { type: 'image/png' }));
}
return new Response(null, { status: 404 });
});
globalThis.fetch = fetchMock as typeof fetch;
try {
const blob = await readLayerAssetBlob(
buildLayer({
src: '/generated-editor-images/export-body-failure.png',
objectKey: 'generated/export-body-failure.png',
}),
);
expect(signedResponseBlob).toHaveBeenCalledTimes(1);
await expect(blob.text()).resolves.toBe('fallback');
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining(
'/api/assets/read-bytes?objectKey=generated%2Fexport-body-failure.png',
),
expect.any(Object),
);
} finally {
globalThis.fetch = originalFetch;
}
});
it('reads private image-sequence frame object keys through same-origin bytes', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url: string) => {
+197
View File
@@ -725,4 +725,201 @@ describe('assetReadUrlService', () => {
'/api/assets/read-bytes?',
);
});
test('readAssetBytes falls back when a successful OSS response body fails to read', async () => {
const signedResponseBlob = vi
.fn()
.mockRejectedValue(new TypeError('Failed to fetch'));
const signedResponse = {
ok: true,
status: 206,
statusText: 'Partial Content',
headers: new Headers({
'Content-Range': 'bytes 0-2/3',
'Content-Type': 'image/png',
}),
blob: signedResponseBlob,
} as unknown as Response;
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(
new Response(
JSON.stringify({
ok: true,
data: {
read: {
objectKey: 'generated-editor-images/project/image.png',
signedUrl: 'https://signed.example.com/editor-image.png',
expiresAt: '2099-01-01T00:10:00Z',
},
},
error: null,
meta: {
apiVersion: '2026-06-16',
routeVersion: '2026-06-16',
latencyMs: 1,
timestamp: '2099-01-01T00:00:00Z',
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
),
)
.mockResolvedValueOnce(signedResponse)
.mockResolvedValueOnce(
new Response(new Uint8Array([9, 8, 7]), {
status: 200,
headers: {
'Content-Type': 'image/png',
},
}),
);
const response = await readAssetBytes(
'/generated-editor-images/image.png',
{
objectKey: 'generated-editor-images/project/image.png',
expireSeconds: 300,
},
);
const bytes = new Uint8Array(await response.arrayBuffer());
expect(signedResponseBlob).toHaveBeenCalledTimes(1);
expect(Array.from(bytes)).toEqual([9, 8, 7]);
expect(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[0])).toBe(
'https://signed.example.com/editor-image.png',
);
expect(String(vi.mocked(globalThis.fetch).mock.calls[2]?.[0])).toContain(
'/api/assets/read-bytes?',
);
});
test('readAssetBytes rejects a partial 206 fragment and uses the full-byte fallback', async () => {
const signedResponseBlob = vi.fn();
const signedResponse = {
ok: true,
status: 206,
statusText: 'Partial Content',
headers: new Headers({
'Content-Range': 'bytes 0-1/3',
'Content-Type': 'image/png',
}),
blob: signedResponseBlob,
} as unknown as Response;
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(
new Response(
JSON.stringify({
ok: true,
data: {
read: {
objectKey: 'generated-editor-images/project/image.png',
signedUrl: 'https://signed.example.com/editor-image.png',
expiresAt: '2099-01-01T00:10:00Z',
},
},
error: null,
meta: {
apiVersion: '2026-06-16',
routeVersion: '2026-06-16',
latencyMs: 1,
timestamp: '2099-01-01T00:00:00Z',
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
),
)
.mockResolvedValueOnce(signedResponse)
.mockResolvedValueOnce(
new Response(new Uint8Array([9, 8, 7]), {
status: 200,
headers: {
'Content-Type': 'image/png',
},
}),
);
const response = await readAssetBytes(
'/generated-editor-images/image.png',
{
objectKey: 'generated-editor-images/project/image.png',
expireSeconds: 300,
},
);
const bytes = new Uint8Array(await response.arrayBuffer());
expect(signedResponseBlob).not.toHaveBeenCalled();
expect(Array.from(bytes)).toEqual([9, 8, 7]);
expect(String(vi.mocked(globalThis.fetch).mock.calls[2]?.[0])).toContain(
'/api/assets/read-bytes?',
);
});
test('readAssetBytes preserves aborts during OSS body reads without fallback', async () => {
const abortController = new AbortController();
const signedResponseBlob = vi.fn().mockImplementation(async () => {
abortController.abort();
throw new DOMException('The operation was aborted.', 'AbortError');
});
const signedResponse = {
ok: true,
status: 200,
statusText: 'OK',
headers: new Headers({
'Content-Type': 'image/png',
}),
blob: signedResponseBlob,
} as unknown as Response;
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(
new Response(
JSON.stringify({
ok: true,
data: {
read: {
objectKey: 'generated-editor-images/project/image.png',
signedUrl: 'https://signed.example.com/editor-image.png',
expiresAt: '2099-01-01T00:10:00Z',
},
},
error: null,
meta: {
apiVersion: '2026-06-16',
routeVersion: '2026-06-16',
latencyMs: 1,
timestamp: '2099-01-01T00:00:00Z',
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
),
)
.mockResolvedValueOnce(signedResponse);
await expect(
readAssetBytes('/generated-editor-images/image.png', {
objectKey: 'generated-editor-images/project/image.png',
expireSeconds: 300,
signal: abortController.signal,
}),
).rejects.toMatchObject({ name: 'AbortError' });
expect(signedResponseBlob).toHaveBeenCalledTimes(1);
expect(vi.mocked(globalThis.fetch)).toHaveBeenCalledTimes(2);
});
});
+34 -2
View File
@@ -556,8 +556,15 @@ export async function readAssetBytes(
try {
const signedUrl = await getSignedAssetReadUrl(readRequest, options.signal);
const response = await fetch(signedUrl, { signal: options.signal });
if (response.ok) {
return response;
if (isCompleteAssetReadResponse(response)) {
// OSS 可能先返回 200/206 响应头、再在读取响应体时失败。必须在这里完整消费响应体,
// 才能让读取失败进入同源字节代理兜底;返回新 Response,避免调用方拿到已消费的响应体。
const body = await response.blob();
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
} catch {
if (options.signal?.aborted) {
@@ -569,6 +576,31 @@ export async function readAssetBytes(
return readAssetBytesViaFallbackApi(readRequest, options.signal);
}
function isCompleteAssetReadResponse(response: Response) {
if (response.status === 200) {
return true;
}
if (response.status !== 206) {
return false;
}
// 全量字节读取不能把媒体预览或浏览器缓存留下的局部分片当成完整素材。
// 只有 Content-Range 明确覆盖 0..total-1 时才接受 206;跨域未暴露该头时安全回退同源代理。
const contentRange = response.headers.get('content-range')?.trim() ?? '';
const match = /^bytes\s+0-(\d+)\/(\d+)$/iu.exec(contentRange);
if (!match) {
return false;
}
const end = Number(match[1]);
const total = Number(match[2]);
return (
Number.isSafeInteger(end) &&
Number.isSafeInteger(total) &&
total > 0 &&
end + 1 === total
);
}
async function readAssetBytesViaFallbackApi(
request: AssetReadUrlRequest,
signal?: AbortSignal,