收紧生成任务轮询与资产读取路径
空生成任务列表不再保持四秒轮询 读取资源字节优先通过read-url直连OSS 保留read-bytes作为OSS读取失败兜底 更新资产导出与项目记忆文档口径
This commit is contained in:
@@ -496,15 +496,44 @@ describe('assetReadUrlService', () => {
|
||||
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('readAssetBytes reads generated resources through same-origin bytes endpoint', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(new Uint8Array([104, 101, 108, 108, 111]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
test('readAssetBytes reads generated resources through signed OSS url first', async () => {
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
signedUrl: 'https://signed.example.com/match3d-item.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(
|
||||
new Response(new Uint8Array([104, 101, 108, 108, 111]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await readAssetBytes(
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
@@ -515,22 +544,53 @@ describe('assetReadUrlService', () => {
|
||||
expect(Array.from(bytes)).toEqual([104, 101, 108, 108, 111]);
|
||||
expect(response.headers.get('content-type')).toBe('image/png');
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-bytes?',
|
||||
'/api/assets/read-url?',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'legacyPublicPath=%2Fgenerated-match3d-assets%2Fsession%2Fprofile%2Fitems%2Fmatch3d-item-1-item%2Fimage.png',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[0])).toBe(
|
||||
'https://signed.example.com/match3d-item.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('readAssetBytes reads object-key resources through same-origin bytes endpoint', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
test('readAssetBytes reads object-key resources through signed OSS url first', async () => {
|
||||
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(
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await readAssetBytes('/generated-editor-images/image.png', {
|
||||
objectKey: 'generated-editor-images/project/image.png',
|
||||
@@ -540,7 +600,7 @@ describe('assetReadUrlService', () => {
|
||||
|
||||
expect(Array.from(bytes)).toEqual([1, 2, 3]);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-bytes?',
|
||||
'/api/assets/read-url?',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'objectKey=generated-editor-images%2Fproject%2Fimage.png',
|
||||
@@ -548,17 +608,49 @@ describe('assetReadUrlService', () => {
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).not.toContain(
|
||||
'legacyPublicPath=',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[0])).toBe(
|
||||
'https://signed.example.com/editor-image.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('readAssetBytes normalizes full OSS generated urls through bytes endpoint', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
test('readAssetBytes normalizes full OSS generated urls through signed read url', async () => {
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey:
|
||||
'generated-puzzle-assets/session/profile/covers/main.png',
|
||||
signedUrl: 'https://signed.example.com/main.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(
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await readAssetBytes(
|
||||
'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-puzzle-assets/session/profile/covers/main.png?x-oss-signature=abc',
|
||||
@@ -567,10 +659,70 @@ describe('assetReadUrlService', () => {
|
||||
|
||||
expect(response.headers.get('content-type')).toBe('image/png');
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-bytes?',
|
||||
'/api/assets/read-url?',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'legacyPublicPath=%2Fgenerated-puzzle-assets%2Fsession%2Fprofile%2Fcovers%2Fmain.png',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[0])).toBe(
|
||||
'https://signed.example.com/main.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('readAssetBytes falls back to same-origin bytes endpoint when OSS read fails', async () => {
|
||||
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(new Response('', { status: 403 }))
|
||||
.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(Array.from(bytes)).toEqual([9, 8, 7]);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-url?',
|
||||
);
|
||||
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?',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -548,17 +548,41 @@ export async function readAssetBytes(
|
||||
return response;
|
||||
}
|
||||
|
||||
// 中文注释:这里要拿图片字节转 Data URL,不能直接 fetch OSS 签名 URL,否则浏览器会受 bucket CORS 限制。
|
||||
const searchParams = buildAssetReadSearchParams({
|
||||
const readRequest = {
|
||||
objectKey,
|
||||
legacyPublicPath: objectKey ? undefined : legacyPath,
|
||||
expireSeconds: options.expireSeconds,
|
||||
};
|
||||
try {
|
||||
const signedUrl = await getSignedAssetReadUrl(readRequest, options.signal);
|
||||
const response = await fetch(signedUrl, { signal: options.signal });
|
||||
if (response.ok) {
|
||||
return response;
|
||||
}
|
||||
} catch {
|
||||
if (options.signal?.aborted) {
|
||||
throw createSignedReadUrlAbortError();
|
||||
}
|
||||
// 中文注释:浏览器直读 OSS 失败时再走同源字节代理兜底。
|
||||
}
|
||||
|
||||
return readAssetBytesViaFallbackApi(readRequest, options.signal);
|
||||
}
|
||||
|
||||
async function readAssetBytesViaFallbackApi(
|
||||
request: AssetReadUrlRequest,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const searchParams = buildAssetReadSearchParams({
|
||||
objectKey: request.objectKey,
|
||||
legacyPublicPath: request.legacyPublicPath,
|
||||
expireSeconds: request.expireSeconds,
|
||||
});
|
||||
const response = await fetchWithApiAuth(
|
||||
`${ASSET_READ_BYTES_API_PATH}?${searchParams.toString()}`,
|
||||
{
|
||||
method: 'GET',
|
||||
signal: options.signal,
|
||||
signal,
|
||||
},
|
||||
{
|
||||
...ASSET_READ_URL_BACKGROUND_OPTIONS,
|
||||
|
||||
@@ -27,14 +27,29 @@ describe('match3dGeneratedModelCache', () => {
|
||||
|
||||
test('预加载生成模型字节并复用本地缓存', async () => {
|
||||
setStoredAccessToken('test-access-token', { emit: false });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(new Uint8Array([103, 108, 84, 70]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'model/gltf-binary',
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
read: {
|
||||
signedUrl: 'https://oss.example.com/model.glb',
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(new Uint8Array([103, 108, 84, 70]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'model/gltf-binary',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await preloadMatch3DGeneratedModelAssets(
|
||||
[
|
||||
@@ -61,7 +76,13 @@ describe('match3dGeneratedModelCache', () => {
|
||||
);
|
||||
|
||||
expect(Array.from(new Uint8Array(bytes))).toEqual([103, 108, 84, 70]);
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-url',
|
||||
);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[0])).toBe(
|
||||
'https://oss.example.com/model.glb',
|
||||
);
|
||||
});
|
||||
|
||||
test('模型源列表会去重并兼容 modelObjectKey', () => {
|
||||
|
||||
Reference in New Issue
Block a user