diff --git a/src/components/image-editor/model3d-preview/Model3dViewerModal.test.tsx b/src/components/image-editor/model3d-preview/Model3dViewerModal.test.tsx index bf75637e3..824bdc871 100644 --- a/src/components/image-editor/model3d-preview/Model3dViewerModal.test.tsx +++ b/src/components/image-editor/model3d-preview/Model3dViewerModal.test.tsx @@ -65,6 +65,7 @@ describe('Model3dViewerModal', () => { resolvedUrl: source, isResolving: false, shouldResolve: Boolean(source), + resolvedObjectKey: source, })); // 最小替身:透出「有没有传 format」、拿得到 resetView 句柄、并按用例上报状态。 model3dViewerMock.mockImplementation( @@ -159,6 +160,7 @@ describe('Model3dViewerModal', () => { resolvedUrl: '', isResolving: true, shouldResolve: true, + resolvedObjectKey: '', })); renderModal(createLayer()); @@ -167,6 +169,26 @@ describe('Model3dViewerModal', () => { expect(screen.queryByTestId('model3d-viewer')).toBeNull(); }); + it('换模型的那一帧不拿上一个模型的地址渲染查看器', () => { + // 钩子先返回旧模型的地址、下一轮才换成新模型的:地址字符串没变,查看器不会重跑加载。 + useResolvedAssetReadUrlMock.mockImplementation(() => ({ + resolvedUrl: 'https://signed.example.com/old-model.glb', + isResolving: false, + shouldResolve: true, + resolvedObjectKey: 'editor/model3d/old-task/model.glb', + })); + + renderModal( + createLayer({ + objectKey: 'editor/model3d/new-task/model.glb', + resourceId: 'resource-3d-new', + }), + ); + + expect(screen.queryByTestId('model3d-viewer')).toBeNull(); + expect(screen.getByRole('status').textContent).toBe('正在准备模型地址…'); + }); + it('重置视角只在查看器就绪后可点', () => { renderModal(createLayer()); diff --git a/src/components/image-editor/model3d-preview/Model3dViewerModal.tsx b/src/components/image-editor/model3d-preview/Model3dViewerModal.tsx index e33d1201b..76423978d 100644 --- a/src/components/image-editor/model3d-preview/Model3dViewerModal.tsx +++ b/src/components/image-editor/model3d-preview/Model3dViewerModal.tsx @@ -76,10 +76,16 @@ export function Model3dViewerModal({ onClose, }: Model3dViewerModalProps) { const modelObjectKey = layer?.objectKey?.trim() ?? ''; - const { resolvedUrl, isResolving } = useResolvedAssetReadUrl(modelObjectKey, { - objectKey: layer?.objectKey, - refreshKey: `${layer?.taskId ?? layer?.resourceId ?? ''}:model3d`, - }); + const { resolvedUrl, isResolving, resolvedObjectKey } = + useResolvedAssetReadUrl(modelObjectKey, { + objectKey: layer?.objectKey, + refreshKey: `${layer?.taskId ?? layer?.resourceId ?? ''}:model3d`, + }); + // 取地址是异步的:换模型的那一帧里,钩子手上还是上一个模型的地址。地址字符串没变时 + // 查看器不会重跑加载,屏幕上会继续显示上一个模型(评审 #85),所以只有确认这个地址 + // 确实属于当前模型(钩子按 objectKey 记着它换签时用的是哪一个)才把地址交给查看器。 + const modelUrl = + modelObjectKey && resolvedObjectKey === modelObjectKey ? resolvedUrl : ''; const viewerRef = useRef(null); const layerIdentity = layer?.id ?? null; // 查看器状态连同"它属于哪个模型"一起存:换了模型,旧状态自动失效。 @@ -92,7 +98,7 @@ export function Model3dViewerModal({ const modelSource = resolveModel3dViewerSource({ objectKey: modelObjectKey, - resolvedUrl, + resolvedUrl: modelUrl, }); const viewerIdentity = modelSource ? `${layerIdentity ?? ''}:${modelSource.url}` @@ -101,10 +107,15 @@ export function Model3dViewerModal({ Boolean(viewerIdentity) && viewerStatus?.identity === viewerIdentity && viewerStatus.status === 'ready'; + // 「手上有个地址,但它还不是当前模型的」这一帧同样属于准备中:否则会闪一句「地址不可用」。 + const isModelUrlPending = + Boolean(modelObjectKey) && + !modelUrl && + (isResolving || Boolean(resolvedUrl)); const hintText = resolveModel3dViewerHint({ hasModelSource: Boolean(modelObjectKey), - isResolving, - resolvedUrl, + isResolving: isModelUrlPending, + resolvedUrl: modelUrl, }); const errorText = resolveModel3dViewerFailureText( viewerStatus?.identity === viewerIdentity ? viewerStatus.detail : null, diff --git a/src/hooks/useResolvedAssetReadUrl.test.tsx b/src/hooks/useResolvedAssetReadUrl.test.tsx index 0ffd68ffd..5034e7fac 100644 --- a/src/hooks/useResolvedAssetReadUrl.test.tsx +++ b/src/hooks/useResolvedAssetReadUrl.test.tsx @@ -372,6 +372,60 @@ describe('useResolvedAssetReadUrl', () => { ).toBe('/creation-type-references/puzzle.webp'); }); + test('换 objectKey 时旧地址的归属先清空,不会冒充新资源', async () => { + const signedResponse = (signedUrl: string) => + new Response( + JSON.stringify({ + ok: true, + data: { + read: { + objectKey: 'oss/first.png', + signedUrl, + 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' } }, + ); + // 第二个 objectKey 的换签一直挂起:这正是「新资源 + 旧地址」同时存在的那一帧。 + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + signedResponse('https://signed.example.com/first.png'), + ) + .mockImplementationOnce(() => new Promise(() => {})); + + function ObjectKeyProbe({ objectKey }: { objectKey: string }) { + const { resolvedUrl, resolvedObjectKey } = useResolvedAssetReadUrl(null, { + objectKey, + }); + return ( + {`${resolvedObjectKey}|${resolvedUrl}`} + ); + } + + const { rerender } = render(); + await waitFor(() => { + expect(screen.getByLabelText('资源地址').textContent).toBe( + 'oss/first.png|https://signed.example.com/first.png', + ); + }); + + rerender(); + + await waitFor(() => { + expect(screen.getByLabelText('资源地址').textContent).toBe('|'); + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + test('普通封面加载失败时切换到显式 fallbackSrc', async () => { render( { if (!normalizedSource && !normalizedObjectKey) { setResolvedUrl(''); setIsResolving(false); + setResolvedObjectKey(''); return; } if (!shouldResolve) { setResolvedUrl(normalizedSource); setIsResolving(false); + // 这一步没有换签,地址就是入参本身,不对应任何 objectKey。 + setResolvedObjectKey(''); return; } @@ -46,6 +53,8 @@ export function useResolvedAssetReadUrl( // 生成资源通常是 OSS 私有对象;签名 URL 未就绪前不能把裸 generated 路径交给 img 触发无鉴权 GET。 setResolvedUrl(''); setIsResolving(true); + // 换签期间清掉归属:调用方据此知道手上的旧地址已经不是当前资源的。 + setResolvedObjectKey(''); const resolvePromise = normalizedObjectKey ? getSignedAssetReadUrl( @@ -67,12 +76,14 @@ export function useResolvedAssetReadUrl( .then((nextUrl) => { if (!cancelled) { setResolvedUrl(nextUrl); + setResolvedObjectKey(normalizedObjectKey); } }) .catch(() => { if (!cancelled) { // 签名失败时保持空 src,避免继续请求无签名的私有对象兼容路径。 setResolvedUrl(''); + setResolvedObjectKey(''); } }) .finally(() => { @@ -96,5 +107,7 @@ export function useResolvedAssetReadUrl( resolvedUrl, isResolving, shouldResolve, + /** `resolvedUrl` 对应的 objectKey;地址与当前资源不匹配或未就绪时为空串。 */ + resolvedObjectKey, }; }