fix(installation-demo): stabilize tool model loading

This commit is contained in:
WuYongJie 2026-07-27 10:51:50 +08:00
parent 7e2f20bba9
commit d6e3be000b
5 changed files with 165 additions and 5 deletions

View File

@ -17,6 +17,7 @@ import {
resolveInstallationNodeLocalGeometryCenter,
resolveInstallationToolFollowTransform,
} from '../lib/installationNodeGeometry'
import { isInstallationToolObjAsset } from '../lib/installationToolAssetFormat'
interface ToolAttachmentRecord {
root: THREE.Group
@ -48,8 +49,7 @@ interface InstallationToolAttachmentOptions {
}
function loadToolAsset(tool: InstallationToolItem): Promise<THREE.Group> {
const normalizedUrl = tool.modelUrl.toLowerCase()
if (normalizedUrl.endsWith('.obj')) {
if (isInstallationToolObjAsset(tool)) {
return new Promise((resolve, reject) => {
new OBJLoader().load(tool.modelUrl, (object) => resolve(object), undefined, reject)
})

View File

@ -0,0 +1,30 @@
import { isInstallationToolObjAsset } from './installationToolAssetFormat'
describe('isInstallationToolObjAsset', () => {
it('recognizes a signed OBJ URL by its pathname', () => {
expect(
isInstallationToolObjAsset({
fileName: 'driver.obj',
modelUrl: 'https://cdn.example.com/tools/driver.obj?e=123&token=temporary',
}),
).toBe(true)
})
it('recognizes an OBJ object key behind the stable object access route', () => {
expect(
isInstallationToolObjAsset({
fileName: 'driver.obj',
modelUrl: '/api/storage/object?key=tools%2Fdriver.obj',
}),
).toBe(true)
})
it('does not route GLB assets through the OBJ loader', () => {
expect(
isInstallationToolObjAsset({
fileName: 'driver.glb',
modelUrl: '/api/storage/object?key=tools%2Fdriver.glb',
}),
).toBe(false)
})
})

View File

@ -0,0 +1,23 @@
export interface InstallationToolAssetReference {
fileName?: string | null
modelUrl: string
}
function hasObjExtension(value: string | null | undefined): boolean {
if (!value) return false
try {
const parsed = new URL(value, 'http://installation-tool.local')
const objectKey = parsed.searchParams.get('key')
return [parsed.pathname, objectKey].some((candidate) => /\.obj$/i.test(candidate ?? ''))
} catch {
return /\.obj(?:[?#]|$)/i.test(value)
}
}
export function isInstallationToolObjAsset({
fileName,
modelUrl,
}: InstallationToolAssetReference): boolean {
return hasObjExtension(fileName) || hasObjExtension(modelUrl)
}

View File

@ -0,0 +1,92 @@
jest.mock('@/lib/storage', () => ({
normalizeObjectStorageUrlForPersistence: jest.fn((value: string) =>
value.includes('driver.obj')
? '/api/storage/object?key=installation-tools%2Fdriver.obj'
: value,
),
}))
jest.mock('@/server/repositories/installation-demo-tool.repository', () => ({
installationDemoToolRepository: {
list: jest.fn(),
findById: jest.fn(),
findByToolCode: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
listDrawingConfigs: jest.fn(),
},
}))
jest.mock('./installation-demo-access.service', () => ({
installationDemoAccessService: {
getAccess: jest.fn(),
},
}))
import { normalizeObjectStorageUrlForPersistence } from '@/lib/storage'
import { installationDemoToolRepository } from '@/server/repositories/installation-demo-tool.repository'
import { installationDemoAccessService } from './installation-demo-access.service'
import { InstallationDemoToolService } from './installation-demo-tool.service'
const user = {
id: 'user-1',
email: 'user@example.com',
name: 'Test user',
role: 'ADMIN' as const,
businessRoles: [],
status: 'ACTIVE' as const,
}
const legacyTool = {
id: 'tool-1',
toolCode: 'TOOL-DRIVER',
name: 'Screwdriver',
modelUrl: 'https://cdn.example.com/installation-tools/driver.obj?e=123&token=temporary',
fileName: 'driver.obj',
mountOffset: [0, 0, 0],
mountRotation: [0, 0, 0, 1],
createdAt: new Date('2026-07-27T00:00:00.000Z'),
updatedAt: new Date('2026-07-27T00:00:00.000Z'),
}
describe('InstallationDemoToolService', () => {
const mockedRepository = jest.mocked(installationDemoToolRepository)
const mockedAccessService = jest.mocked(installationDemoAccessService)
const mockedNormalizeUrl = jest.mocked(normalizeObjectStorageUrlForPersistence)
beforeEach(() => {
jest.clearAllMocks()
mockedAccessService.getAccess.mockResolvedValue({ canView: true, canEdit: true, canShare: true })
})
it('returns a stable access URL for historical tools that still store a signed download URL', async () => {
mockedRepository.list.mockResolvedValue([legacyTool] as never)
const tools = await new InstallationDemoToolService().list(user)
expect(tools[0]?.modelUrl).toBe('/api/storage/object?key=installation-tools%2Fdriver.obj')
expect(mockedNormalizeUrl).toHaveBeenCalledWith(legacyTool.modelUrl)
})
it('persists a stable access URL when a new tool is saved', async () => {
mockedRepository.findByToolCode.mockResolvedValue(null)
mockedRepository.create.mockResolvedValue({
...legacyTool,
modelUrl: '/api/storage/object?key=installation-tools%2Fdriver.obj',
} as never)
await new InstallationDemoToolService().create(user, {
toolCode: legacyTool.toolCode,
name: legacyTool.name,
modelUrl: legacyTool.modelUrl,
fileName: legacyTool.fileName,
})
expect(mockedRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
modelUrl: '/api/storage/object?key=installation-tools%2Fdriver.obj',
}),
)
})
})

View File

@ -1,5 +1,6 @@
import 'server-only'
import { normalizeObjectStorageUrlForPersistence } from '@/lib/storage'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
import { installationDemoToolRepository } from '@/server/repositories/installation-demo-tool.repository'
@ -30,6 +31,19 @@ function normalizeQuaternion(value: unknown) {
return value as [number, number, number, number]
}
function normalizeToolModelUrl(value: unknown) {
return normalizeObjectStorageUrlForPersistence(normalizeRequiredText(value, '工具模型'))
}
function presentTool<T extends { modelUrl: string }>(tool: T): T {
return {
...tool,
// Older records may still contain an expiring direct download URL. Always
// return the stable application route so existing tools continue to load.
modelUrl: normalizeObjectStorageUrlForPersistence(tool.modelUrl),
}
}
function configReferencesTool(value: unknown, toolId: string): boolean {
if (value === toolId) return true
if (Array.isArray(value)) return value.some((item) => configReferencesTool(item, toolId))
@ -42,7 +56,8 @@ export class InstallationDemoToolService {
if (!(await installationDemoAccessService.getAccess(user)).canView) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限查看工具箱')
}
return installationDemoToolRepository.list()
const tools = await installationDemoToolRepository.list()
return tools.map(presentTool)
}
async create(user: AuthUser, input: Record<string, unknown>) {
@ -57,7 +72,7 @@ export class InstallationDemoToolService {
return installationDemoToolRepository.create({
toolCode,
name: normalizeRequiredText(input.name, '工具名称'),
modelUrl: normalizeRequiredText(input.modelUrl, '工具模型'),
modelUrl: normalizeToolModelUrl(input.modelUrl),
fileName: normalizeRequiredText(input.fileName, '模型文件名'),
mountOffset: normalizeVector3(input.mountOffset),
mountRotation: normalizeQuaternion(input.mountRotation),
@ -81,7 +96,7 @@ export class InstallationDemoToolService {
return installationDemoToolRepository.update(id, {
toolCode: nextToolCode,
name: input.name === undefined ? existing.name : normalizeRequiredText(input.name, '工具名称'),
modelUrl: input.modelUrl === undefined ? existing.modelUrl : normalizeRequiredText(input.modelUrl, '工具模型'),
modelUrl: input.modelUrl === undefined ? existing.modelUrl : normalizeToolModelUrl(input.modelUrl),
fileName: input.fileName === undefined ? existing.fileName : normalizeRequiredText(input.fileName, '模型文件名'),
mountOffset: input.mountOffset === undefined ? normalizeVector3(existing.mountOffset) : normalizeVector3(input.mountOffset),
mountRotation: input.mountRotation === undefined ? normalizeQuaternion(existing.mountRotation) : normalizeQuaternion(input.mountRotation),