From f4083dcb6dd6d4fd367f03f15f46958130992d92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 11:48:48 +0800 Subject: [PATCH] =?UTF-8?q?3D=20=E6=9F=A5=E7=9C=8B=E5=99=A8=E7=BB=99?= =?UTF-8?q?=E7=AD=BE=E5=90=8D=E5=9C=B0=E5=9D=80=E7=9A=84=E5=85=84=E5=BC=9F?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E8=A1=A5=E5=9B=9E=E9=89=B4=E6=9D=83=20query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loader 新增 resolveModel3dViewerSourceQuery / resolveModel3dViewerResourceUrl,只补模型目录下的资源地址 - glTF / FBX 解析改用带 URL 修饰器的专用 LoadingManager,不动全局 DefaultLoadingManager - 补测试:query 提取与片段剔除、目录内外的补与不补、多文件 glTF 兄弟资源解析 --- packages/model3d-viewer/src/loader.test.ts | 114 ++++++++++++++++++++- packages/model3d-viewer/src/loader.ts | 73 ++++++++++++- 2 files changed, 184 insertions(+), 3 deletions(-) diff --git a/packages/model3d-viewer/src/loader.test.ts b/packages/model3d-viewer/src/loader.test.ts index 3419e616a..a5d6cb88a 100644 --- a/packages/model3d-viewer/src/loader.test.ts +++ b/packages/model3d-viewer/src/loader.test.ts @@ -1,14 +1,17 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { isModel3dViewerFormatSupported, isModel3dViewerOverSize, + loadModel3dViewerObject, MODEL3D_VIEWER_MAX_MODEL_BYTES, Model3dViewerAbortedError, readModel3dViewerSource, resolveModel3dViewerFormat, resolveModel3dViewerFormatFromBytes, resolveModel3dViewerFormatFromMimeType, + resolveModel3dViewerResourceUrl, + resolveModel3dViewerSourceQuery, } from './loader'; describe('模型格式承诺范围', () => { @@ -150,3 +153,112 @@ describe('模型下载可中止', () => { ]); }); }); + +/** + * 签名模型地址的兄弟资源补鉴权。 + * + * 宿主给的是私有对象的签名地址,token 在 query 里;`GLTFLoader` / `FBXLoader` 用 + * `path + uri` 拼多文件模型的兄弟 buffer 与贴图,那一层只拼目录、不带 query。 + */ +describe('签名模型地址的兄弟资源补鉴权', () => { + it('从模型地址的 query 里取出鉴权参数,丢掉片段', () => { + expect( + resolveModel3dViewerSourceQuery( + 'https://bucket.oss-cn-hangzhou.aliyuncs.com/editor/model3d/t-1/model.glb?token=abc&expires=1', + ), + ).toBe('?token=abc&expires=1'); + expect( + resolveModel3dViewerSourceQuery( + 'https://bucket.oss-cn-hangzhou.aliyuncs.com/m.glb?token=abc#frag', + ), + ).toBe('?token=abc'); + expect(resolveModel3dViewerSourceQuery('https://x/m.glb?')).toBeNull(); + expect(resolveModel3dViewerSourceQuery('https://x/m.glb')).toBeNull(); + expect(resolveModel3dViewerSourceQuery(null)).toBeNull(); + }); + + it('只补模型目录下的资源地址,并保留资源自己的 query', () => { + const basePath = + 'https://bucket.oss-cn-hangzhou.aliyuncs.com/editor/model3d/t-1/'; + const query = '?token=abc'; + expect( + resolveModel3dViewerResourceUrl({ + url: `${basePath}buffer.bin`, + basePath, + query, + }), + ).toBe(`${basePath}buffer.bin?token=abc`); + expect( + resolveModel3dViewerResourceUrl({ + url: `${basePath}textures/tex.png`, + basePath, + query, + }), + ).toBe(`${basePath}textures/tex.png?token=abc`); + expect( + resolveModel3dViewerResourceUrl({ + url: `${basePath}buffer.bin?v=2`, + basePath, + query, + }), + ).toBe(`${basePath}buffer.bin?v=2&token=abc`); + }); + + it('第三方 CDN、同源其它对象、无目录与空地址都原样返回', () => { + const basePath = 'https://bucket/editor/model3d/t-1/'; + const query = '?token=abc'; + for (const input of [ + { url: 'https://cdn.example.com/tex.png', basePath, query }, + { url: 'https://bucket/other/tex.png', basePath, query }, + { + url: 'https://bucket/editor/model3d/t-1/buffer.bin', + basePath: '', + query, + }, + { url: '', basePath, query }, + ]) { + expect(resolveModel3dViewerResourceUrl(input)).toBe(input.url); + } + }); + + it('多文件 glTF 加载时把 query 补到兄弟资源地址上', async () => { + const modelUrl = + 'https://bucket.oss-cn-hangzhou.aliyuncs.com/editor/model3d/t-1/model.gltf?token=abc'; + const resolvedUrls: string[] = []; + const originalFetch = globalThis.fetch; + // 动态 import 的替身只记录一次兄弟资源解析,不真的解析 glTF。 + vi.doMock('three/examples/jsm/loaders/GLTFLoader.js', () => ({ + GLTFLoader: class { + private readonly manager: { resolveURL: (url: string) => string }; + + constructor(manager: { resolveURL: (url: string) => string }) { + this.manager = manager; + } + + parse( + _payload: unknown, + path: string, + onLoad: (gltf: { scene: object }) => void, + ) { + resolvedUrls.push(this.manager.resolveURL(`${path}buffer.bin`)); + onLoad({ scene: {} }); + } + }, + })); + globalThis.fetch = (async () => + new Response(new TextEncoder().encode('{"asset":{"version":"2.0"}}'), { + headers: { 'content-type': 'model/gltf+json' }, + })) as typeof fetch; + + try { + await loadModel3dViewerObject({ kind: 'url', url: modelUrl }); + } finally { + globalThis.fetch = originalFetch; + vi.doUnmock('three/examples/jsm/loaders/GLTFLoader.js'); + } + + expect(resolvedUrls).toEqual([ + 'https://bucket.oss-cn-hangzhou.aliyuncs.com/editor/model3d/t-1/buffer.bin?token=abc', + ]); + }); +}); diff --git a/packages/model3d-viewer/src/loader.ts b/packages/model3d-viewer/src/loader.ts index 83a9a53d6..46707477a 100644 --- a/packages/model3d-viewer/src/loader.ts +++ b/packages/model3d-viewer/src/loader.ts @@ -266,6 +266,50 @@ function resolveModel3dViewerBasePath(source: Model3DViewerSource): string { return separatorIndex >= 0 ? source.url.slice(0, separatorIndex + 1) : ''; } +/** + * 模型地址 query 里的鉴权参数(含 `?`,丢掉 `#` 之后的部分);没有 query 返回 null。 + * + * 宿主给的多半是私有对象的签名地址(见 `getSignedAssetReadUrl`),token 就在 query 里。 + */ +export function resolveModel3dViewerSourceQuery( + url: string | null | undefined, +): string | null { + const value = url?.trim() ?? ''; + const queryIndex = value.indexOf('?'); + if (queryIndex < 0) { + return null; + } + const query = value.slice(queryIndex + 1).split('#')[0] ?? ''; + return query ? `?${query}` : null; +} + +/** + * 给模型目录下的兄弟资源地址补回模型地址的鉴权 query。 + * + * `GLTFLoader` / `FBXLoader` 用 `path + uri` 拼多文件模型的兄弟 buffer 与贴图地址, + * 地址落在模型目录里,但签名 token 在这一步丢掉了(目录本身不带 query),私有对象 + * 于是 401/403。这里只补模型目录下的地址:既不把 token 外发到第三方 CDN,也不把 + * 「为某个对象签出的 token」贴到同源的另一个对象上(OSS 会以 SignatureDoesNotMatch + * 拒绝),资源地址自己带的 query 也保留。 + */ +export function resolveModel3dViewerResourceUrl(input: { + url: string; + /** 模型地址所在目录,由 `resolveModel3dViewerBasePath` 得到;空串表示没有目录可补。 */ + basePath: string; + /** 模型地址的鉴权 query,由 `resolveModel3dViewerSourceQuery` 得到。 */ + query: string; +}): string { + const { url, basePath, query } = input; + if (!url || !basePath || !url.startsWith(basePath)) { + return url; + } + const hashIndex = url.indexOf('#'); + const withoutHash = hashIndex >= 0 ? url.slice(0, hashIndex) : url; + const hash = hashIndex >= 0 ? url.slice(hashIndex) : ''; + const separator = withoutHash.includes('?') ? '&' : '?'; + return `${withoutHash}${separator}${query.slice(1)}${hash}`; +} + /** * 解析模型字节为 three 对象。glb / 单文件 gltf 走 GLTFLoader,fbx 走 FBXLoader; * 多文件 gltf 的兄弟资源按模型地址目录解析,取不到时按加载失败处理。 @@ -296,18 +340,43 @@ export async function loadModel3dViewerObject( } const basePath = resolveModel3dViewerBasePath(source); + const resourceQuery = + source.kind === 'url' ? resolveModel3dViewerSourceQuery(source.url) : null; + + /** + * 兄弟资源(buffer / 贴图)的加载管理器:模型地址带 query 时才建,把同一份鉴权 + * query 补到模型目录下的资源地址上。用专用管理器而不是默认的那个,避免改动全局 + * `DefaultLoadingManager` 影响宿主其它加载器。 + */ + async function createResourceLoadingManager() { + if (!resourceQuery || !basePath) { + return undefined; + } + const { LoadingManager } = await import('three'); + const manager = new LoadingManager(); + manager.setURLModifier((url: string) => + resolveModel3dViewerResourceUrl({ + url, + basePath, + query: resourceQuery, + }), + ); + return manager; + } + try { if (format === 'fbx') { const { FBXLoader } = await import( 'three/examples/jsm/loaders/FBXLoader.js' ); - return new FBXLoader().parse(data, basePath); + const manager = await createResourceLoadingManager(); + return new FBXLoader(manager).parse(data, basePath); } const { GLTFLoader } = await import( 'three/examples/jsm/loaders/GLTFLoader.js' ); - const loader = new GLTFLoader(); + const loader = new GLTFLoader(await createResourceLoadingManager()); const payload: string | ArrayBuffer = format === 'gltf' ? new TextDecoder().decode(new Uint8Array(data)) : data; const gltf = await new Promise<{ scene: ThreeTypes.Object3D }>(