收紧生成任务轮询与资产读取路径

空生成任务列表不再保持四秒轮询

读取资源字节优先通过read-url直连OSS

保留read-bytes作为OSS读取失败兜底

更新资产导出与项目记忆文档口径
This commit is contained in:
2026-06-25 23:01:24 +08:00
parent a61e604921
commit c5295a619c
9 changed files with 387 additions and 47 deletions
@@ -4,6 +4,7 @@ import { createMiniGameDraftGenerationState } from '../../services/miniGameDraft
import {
buildExternalGenerationQueuePresentation,
buildExternalGenerationQueueStatus,
shouldUseFastExternalGenerationTaskPolling,
} from './platformExternalGenerationQueueStatusModel';
import {
resolveFinishedMiniGameDraftGenerationState,
@@ -129,4 +130,50 @@ describe('buildExternalGenerationQueueStatus', () => {
}).shouldShow,
).toBe(false);
});
test('只在队列活跃或存在未确认终态任务时使用快速轮询', () => {
expect(
shouldUseFastExternalGenerationTaskPolling({
pendingCount: 0,
runningCount: 0,
unacknowledgedTerminalCount: 0,
tasks: [],
}),
).toBe(false);
expect(
shouldUseFastExternalGenerationTaskPolling({
currentStatus: 'queued',
pendingCount: 0,
runningCount: 0,
unacknowledgedTerminalCount: 0,
tasks: [],
}),
).toBe(true);
expect(
shouldUseFastExternalGenerationTaskPolling({
pendingCount: 0,
runningCount: 0,
unacknowledgedTerminalCount: 0,
tasks: [
{
jobId: 'extgen-completed',
jobKind: 'editor_image_generation',
sourceModule: 'editor',
sourceEntityId: 'project-1',
requestLabel: '图片生成',
status: 'completed',
phaseLabel: '已完成',
phaseDetail: '已完成',
progress: 100,
priceMudPoints: 1,
createdAt: '2026-06-25T00:00:00.000Z',
updatedAt: '2026-06-25T00:00:00.000Z',
updatedAtMicros: 1_782_348_800_000_000,
},
],
}),
).toBe(true);
});
});
@@ -544,7 +544,10 @@ import type {
} from './platformEntryTypes';
import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView';
import { PlatformErrorDialog } from './PlatformErrorDialog';
import { buildExternalGenerationQueueStatus } from './platformExternalGenerationQueueStatusModel';
import {
buildExternalGenerationQueueStatus,
shouldUseFastExternalGenerationTaskPolling,
} from './platformExternalGenerationQueueStatusModel';
import { resolvePlatformGenerationProgressTickDecision } from './platformGenerationProgressTickModel';
import {
sendPlatformHostDraftNotificationToHost,
@@ -707,6 +710,7 @@ type PuzzleBackgroundCompileTask = {
generationState: MiniGameDraftGenerationState;
error: string | null;
};
const EXTERNAL_GENERATION_TASK_FAST_POLL_INTERVAL_MS = 4_000;
function isUnacknowledgedExternalGenerationTerminalTask(
task: ExternalGenerationTaskRecord,
@@ -4967,6 +4971,15 @@ export function PlatformEntryFlowShellImpl({
woodenFishGenerationState,
);
const isHostNetworkOnline = useHostNetworkOnline();
const shouldFastPollKnownExternalGenerationTask =
puzzleOperation?.queueState?.status === 'queued' ||
puzzleOperation?.queueState?.status === 'running' ||
jumpHopQueueState?.status === 'queued' ||
jumpHopQueueState?.status === 'running' ||
puzzleClearQueueState?.status === 'queued' ||
puzzleClearQueueState?.status === 'running' ||
woodenFishQueueState?.status === 'queued' ||
woodenFishQueueState?.status === 'running';
const shouldPollExternalGenerationTasks =
platformBootstrap.canReadProtectedData && Boolean(authUi?.user?.id);
const externalGenerationQueueOwnerKey =
@@ -4991,6 +5004,21 @@ export function PlatformEntryFlowShellImpl({
let disposed = false;
let controller: AbortController | null = null;
let timerId: number | null = null;
const scheduleNextRefresh = (delayMs: number) => {
if (disposed) {
return;
}
if (timerId != null) {
window.clearTimeout(timerId);
timerId = null;
}
if (delayMs <= 0) {
return;
}
timerId = window.setTimeout(refreshTaskList, delayMs);
};
const refreshTaskList = () => {
controller?.abort();
@@ -5010,6 +5038,18 @@ export function PlatformEntryFlowShellImpl({
),
);
setExternalGenerationTaskOverview(response.overview);
scheduleNextRefresh(
shouldFastPollKnownExternalGenerationTask ||
shouldUseFastExternalGenerationTaskPolling(
buildExternalGenerationQueueStatus(
response.overview,
null,
response.tasks,
),
)
? EXTERNAL_GENERATION_TASK_FAST_POLL_INTERVAL_MS
: 0,
);
}
})
.catch(() => {
@@ -5021,16 +5061,28 @@ export function PlatformEntryFlowShellImpl({
};
refreshTaskList();
const intervalId = window.setInterval(refreshTaskList, 4000);
const handleResume = () => {
if (document.visibilityState === 'hidden') {
return;
}
refreshTaskList();
};
window.addEventListener('focus', handleResume);
document.addEventListener('visibilitychange', handleResume);
return () => {
disposed = true;
controller?.abort();
window.clearInterval(intervalId);
if (timerId != null) {
window.clearTimeout(timerId);
}
window.removeEventListener('focus', handleResume);
document.removeEventListener('visibilitychange', handleResume);
};
}, [
externalGenerationQueueOwnerKey,
isHostNetworkOnline,
shouldFastPollKnownExternalGenerationTask,
shouldPollExternalGenerationTasks,
]);
const activeExternalGenerationJobState = useMemo(() => {
@@ -55,6 +55,48 @@ export function buildExternalGenerationQueueStatus(
};
}
function isActiveExternalGenerationStatus(
status: ExternalGenerationQueueStatus['currentStatus'],
) {
return status === 'queued' || status === 'running';
}
function isUnacknowledgedExternalGenerationTerminalTask(
task: ExternalGenerationTaskRecord,
) {
return (
(task.status === 'completed' || task.status === 'failed') &&
!task.notificationAcknowledgedAt
);
}
export function shouldUseFastExternalGenerationTaskPolling(
status: ExternalGenerationQueueStatus | null | undefined,
) {
const pendingCount = normalizeExternalGenerationQueueCount(
status?.pendingCount,
);
const runningCount = normalizeExternalGenerationQueueCount(
status?.runningCount,
);
const unacknowledgedTerminalCount = normalizeExternalGenerationQueueCount(
status?.unacknowledgedTerminalCount,
);
return Boolean(
isActiveExternalGenerationStatus(status?.currentStatus ?? null) ||
pendingCount > 0 ||
runningCount > 0 ||
unacknowledgedTerminalCount > 0 ||
(status?.tasks ?? []).some(
(task) =>
task.status === 'queued' ||
task.status === 'running' ||
isUnacknowledgedExternalGenerationTerminalTask(task),
),
);
}
export function resolveExternalGenerationQueueStatusLabel(
status: ExternalGenerationQueueStatus['currentStatus'],
) {
+182 -30
View File
@@ -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?',
);
});
});
+27 -3
View File
@@ -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', () => {