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

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

读取资源字节优先通过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
@@ -20,6 +20,7 @@
- 背景:外部生成队列已经承载画板和玩法的付费生成,但前端只展示排队概览,缺少可追溯任务列表、后端确认状态、完成提示补弹和退款记录到任务的追踪关系。
- 决策:`external_generation_job` 同时作为正式生成任务列表事实源,保存 `price_mud_points``refund_ledger_id``notification_acknowledged_at`;新增 `external_generation_job_event` 追加状态转换审计。BFF 新增当前账号任务列表和 acknowledge 接口;前端只展示后端任务状态,完成 / 失败提示关闭时由后端写确认时间,未确认终态任务在下次登录后按列表集中弹出。任务触发的钱包扣费 / 退款流水 metadata 必须写 `externalGenerationJobId`,本机退款 outbox 重放也保留该任务 ID。
- 2026-06-25 追加:平台壳的当前账号任务列表只在登录、网络恢复、页面回到前台或已有 queued/running/未确认终态任务时刷新;空队列刷新一次后不保持 4 秒轮询,避免 `/api/runtime/external-generation/jobs` 在无任务时持续请求。
- 影响范围:`spacetime-module` 外部生成 schema / procedure、`spacetime-client` bindings/facade、`api-server` 外部生成 BFF、worker 失败回写和资产计费退款链路、平台入口“我的”页任务卡和完成提示弹窗。
- 验证方式:运行 `npm run spacetime:generate``npm run check:spacetime-schema``cargo test -p spacetime-module external_generation --manifest-path server-rs/Cargo.toml``cargo test -p api-server wallet_refund_outbox --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
@@ -1030,6 +1031,7 @@
- 背景:生成图片如果以完整 OSS 私有 bucket URL 进入前端,浏览器会裸连 OSS 并遇到 403 或绕过现有 `/api/assets/read-url` 签名缓存;同时旧对象缺少 `Cache-Control` 时只能走 `ETag` / `Last-Modified` 协商缓存,容易被误解为需要 api-server 本地磁盘缓存。
- 决策:OSS 继续作为 generated 私有资产源站,api-server 只签发短期读 URL,不做本地磁盘静态资源兜底。前端收到同 bucket 的 `https://*.oss-*.aliyuncs.com/generated-*` 地址时,必须先归一为 legacy public path,再复用 `/api/assets/read-url` 和本地 signed URL 缓存。新上传 generated 私有对象默认写入 `Cache-Control: public, max-age=31536000, immutable`,缓存职责交给 OSS 对象头、浏览器 / WebView HTTP 缓存和后续 CDN。
- 2026-06-25 追加:前端需要读取 generated/private 资源字节时,也应先通过 `/api/assets/read-url` 获取 signed OSS URL 并由浏览器直接下载字节;`/api/assets/read-bytes` 只作为换签或 OSS 读取失败后的 fallback,不作为默认文件代理路径。
- 影响范围:`src/services/assetReadUrlService.ts``server-rs/crates/platform-oss``shared-contracts` direct upload form fields、`api-server` assets DTO 映射、后端契约文档和开发运维排障口径。
- 验证方式:完整 OSS generated URL 应触发 `/api/assets/read-url?legacyPublicPath=...`,同一路径、同一 `refreshKey` 版本且未临近过期时复用本地 signed URL;`platform-oss``PostObject` policy / form fields 和 `PutObject` 请求头都应包含 immutable `Cache-Control`,且 `PutObject` V4 签名的 `AdditionalHeaders` 包含该普通请求头。
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md``server-rs/crates/platform-oss/README.md`
@@ -147,7 +147,7 @@
- 现象:图片画布快速编辑站内示例图、历史 generated 图或 OSS generated 图时,后端返回 `修改图片参考图必须是图片 Data URL。`
- 原因:快速编辑直接把图层 `src` 塞进 `/api/editor/images/generations``referenceImageSrcs`;默认示例图和部分持久化图层的 `src``/creation-type-references/*.webp``/generated-*` 或 OSS URL,而 `api-server` 的编辑参考图解析只接收 `data:image/*;base64,...`
- 处理:前端统一通过 `resolveEditorImageReferenceDataUrl(...)` 在提交前读取图片字节并转成图片 Data URL;Data URL 原样透传,`/generated-*` 和 generated OSS URL 走 `/api/assets/read-bytes` 避免 CORS,普通 public 路径直接 fetch。
- 处理:前端统一通过 `resolveEditorImageReferenceDataUrl(...)` 在提交前读取图片字节并转成图片 Data URL;Data URL 原样透传,`/generated-*` 和 generated OSS URL `/api/assets/read-url` 换签后由浏览器直读 OSS,直读失败时才 fallback 到 `/api/assets/read-bytes`,普通 public 路径直接 fetch。
- 验证:`npm run test -- src/services/image-editor/editorImageReference.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx -t "editorImageReference|converts non-data-url quick edit source images before submitting references"`
- 关联:`src/services/image-editor/editorImageReference.ts``src/components/image-editor/ImageCanvasEditorView.tsx``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
@@ -166,7 +166,7 @@ assetObjectId > objectKey > sourceAssetId > src
4. 对每个素材源读取 Blob
- `data:image/...` 直接转换为 Blob。
- 同源或可访问 URL 使用 `fetch` 拉取 Blob。
- 私有 generated / OSS 素材必须走同源 `/api/assets/read-bytes` 读取字节;不要在导出流程里直接 `fetch` OSS 签名 URL,避免浏览器 CORS 拦截
- 私有 generated / OSS 素材先走 `/api/assets/read-url` 换签并由浏览器直接 `fetch` OSS 签名 URL;只有换签或 OSS 字节读取失败时,才 fallback 到同源 `/api/assets/read-bytes`
- `mediaType="image-sequence"` 逐帧读取 `imageSequenceFrames`,写入 `sequences/<编号-标题>/frames/`
- 画布素材 ZIP 内的 `mediaType="image-sequence"` 同步写入 `skeleton.json`,供 Spine Editor 或 runtime 以 slot attachment timeline 方式播放序列帧。
- 单图层普通序列帧导出写入 `preview.gif`,由前端基于成功读取的帧生成动画预览,不依赖压缩包读取时再临时播放 PNG。
@@ -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', () => {