修改3D模型预览报错

This commit is contained in:
zhangxiaohua 2026-01-14 16:12:54 +08:00
parent 8210bf0ad3
commit b57099df15
5 changed files with 123 additions and 12 deletions

View File

@ -16,6 +16,7 @@
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.3",
"@nestjs/serve-static": "^4.0.0",
"@prisma/client": "^6.19.0",
"adm-zip": "^0.5.16",
"bcrypt": "^6.0.0",
@ -1914,6 +1915,39 @@
"dev": true,
"license": "MIT"
},
"node_modules/@nestjs/serve-static": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@nestjs/serve-static/-/serve-static-4.0.2.tgz",
"integrity": "sha512-cT0vdWN5ar7jDI2NKbhf4LcwJzU4vS5sVpMkVrHuyLcltbrz6JdGi1TfIMMatP2pNiq5Ie/uUdPSFDVaZX/URQ==",
"license": "MIT",
"dependencies": {
"path-to-regexp": "0.2.5"
},
"peerDependencies": {
"@fastify/static": "^6.5.0 || ^7.0.0",
"@nestjs/common": "^9.0.0 || ^10.0.0",
"@nestjs/core": "^9.0.0 || ^10.0.0",
"express": "^4.18.1",
"fastify": "^4.7.0"
},
"peerDependenciesMeta": {
"@fastify/static": {
"optional": true
},
"express": {
"optional": true
},
"fastify": {
"optional": true
}
}
},
"node_modules/@nestjs/serve-static/node_modules/path-to-regexp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.2.5.tgz",
"integrity": "sha512-l6qtdDPIkmAmzEO6egquYDfqQGPMRNGjYtrU13HAXb3YSRrt7HSb1sJY0pKp6o2bAa86tSB6iwaW2JbthPKr7Q==",
"license": "MIT"
},
"node_modules/@nestjs/testing": {
"version": "10.4.20",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.20.tgz",

View File

@ -52,6 +52,7 @@
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.3",
"@nestjs/serve-static": "^4.0.0",
"@prisma/client": "^6.19.0",
"adm-zip": "^0.5.16",
"bcrypt": "^6.0.0",
@ -75,6 +76,7 @@
"@types/node": "^20.11.5",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.36",
"@types/adm-zip": "^0.5.5",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^6.19.1",
"@typescript-eslint/parser": "^6.19.1",

View File

@ -12,6 +12,7 @@ import {
Res,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response } from 'express';
import axios from 'axios';
@ -26,6 +27,8 @@ import { Public } from '../auth/decorators/public.decorator';
@Controller('ai-3d')
@UseGuards(JwtAuthGuard)
export class AI3DController {
private readonly logger = new Logger(AI3DController.name);
constructor(private readonly ai3dService: AI3DService) {}
/**
@ -93,14 +96,18 @@ export class AI3DController {
throw new HttpException('URL参数不能为空', HttpStatus.BAD_REQUEST);
}
this.logger.log(`[proxy-model] 开始处理请求URL长度: ${url.length}`);
try {
// URL解码处理URL编码
let decodedUrl: string;
try {
decodedUrl = decodeURIComponent(url);
this.logger.log(`[proxy-model] URL解码成功`);
} catch (e) {
// 如果解码失败使用原始URL
decodedUrl = url;
this.logger.warn(`[proxy-model] URL解码失败使用原始URL`);
}
// 验证URL是否为腾讯云COS链接安全验证
@ -121,6 +128,8 @@ export class AI3DController {
throw new HttpException('URL格式无效', HttpStatus.BAD_REQUEST);
}
this.logger.log(`[proxy-model] 开始下载文件...`);
// 从源URL获取文件
const response = await axios.get(decodedUrl, {
responseType: 'arraybuffer', // 使用arraybuffer以便处理ZIP
@ -133,43 +142,92 @@ export class AI3DController {
},
});
this.logger.log(
`[proxy-model] 文件下载成功,大小: ${response.data.byteLength} bytes`,
);
let fileData: Buffer = Buffer.from(response.data);
let contentType =
response.headers['content-type'] || 'application/octet-stream';
let contentLength = fileData.length;
// 如果是ZIP文件解压并提取GLB文件
this.logger.log(`[proxy-model] Content-Type: ${contentType}`);
// 如果是ZIP文件解压并提取3D模型文件
if (
decodedUrl.toLowerCase().includes('.zip') ||
contentType.includes('zip') ||
contentType.includes('application/zip')
) {
this.logger.log(`[proxy-model] 检测到ZIP文件开始解压...`);
try {
const zip = new AdmZip(fileData);
const zipEntries = zip.getEntries();
// 查找ZIP中的GLB文件
const glbEntry = zipEntries.find(
(entry) =>
entry.entryName.toLowerCase().endsWith('.glb') ||
entry.entryName.toLowerCase().endsWith('.gltf'),
this.logger.log(
`[proxy-model] ZIP解压成功包含 ${zipEntries.length} 个文件`,
);
if (glbEntry) {
fileData = glbEntry.getData();
contentType = glbEntry.entryName.toLowerCase().endsWith('.glb')
? 'model/gltf-binary'
: 'model/gltf+json';
// 列出所有文件便于调试
const allFiles = zipEntries.map((e) => e.entryName);
this.logger.log(`[proxy-model] ZIP文件列表: ${allFiles.join(', ')}`);
// 按优先级查找3D模型文件: GLB > GLTF > OBJ
let modelEntry = zipEntries.find((entry) =>
entry.entryName.toLowerCase().endsWith('.glb'),
);
if (!modelEntry) {
modelEntry = zipEntries.find((entry) =>
entry.entryName.toLowerCase().endsWith('.gltf'),
);
}
if (!modelEntry) {
modelEntry = zipEntries.find((entry) =>
entry.entryName.toLowerCase().endsWith('.obj'),
);
}
if (modelEntry) {
this.logger.log(
`[proxy-model] 找到模型文件: ${modelEntry.entryName}`,
);
fileData = modelEntry.getData();
const entryName = modelEntry.entryName.toLowerCase();
let modelType = 'glb'; // 默认类型
if (entryName.endsWith('.glb')) {
contentType = 'model/gltf-binary';
modelType = 'glb';
} else if (entryName.endsWith('.gltf')) {
contentType = 'model/gltf+json';
modelType = 'gltf';
} else if (entryName.endsWith('.obj')) {
contentType = 'text/plain'; // OBJ 是文本格式
modelType = 'obj';
}
contentLength = fileData.length;
this.logger.log(
`[proxy-model] 模型类型: ${modelType}, 大小: ${contentLength} bytes`,
);
// 添加自定义头部,告知前端实际的模型类型
res.setHeader('X-Model-Type', modelType);
} else {
// 列出ZIP中的所有文件便于调试
const fileList = zipEntries.map((e) => e.entryName).join(', ');
this.logger.error(
`[proxy-model] ZIP中未找到3D模型文件。ZIP内容: ${fileList}`,
);
throw new HttpException(
`ZIP文件中未找到GLB或GLTF文件。ZIP内容: ${fileList}`,
`ZIP文件中未找到3D模型文件GLB/GLTF/OBJ。ZIP内容: ${fileList}`,
HttpStatus.BAD_REQUEST,
);
}
} catch (zipError: any) {
this.logger.error(
`[proxy-model] ZIP处理失败: ${zipError.message}`,
zipError.stack,
);
if (zipError instanceof HttpException) {
throw zipError;
}
@ -189,8 +247,14 @@ export class AI3DController {
res.setHeader('Cache-Control', 'public, max-age=3600'); // 缓存1小时
// 发送文件数据
this.logger.log(`[proxy-model] 发送响应,大小: ${contentLength} bytes`);
res.send(fileData);
} catch (error: any) {
this.logger.error(
`[proxy-model] 请求处理失败: ${error.message}`,
error.stack,
);
if (error instanceof HttpException) {
throw error;
}

View File

@ -1,6 +1,8 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ServeStaticModule } from '@nestjs/serve-static';
import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from '@nestjs/core';
import { join } from 'path';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
@ -35,6 +37,14 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
`.env.${process.env.NODE_ENV || 'development'}`, // 优先加载
],
}),
// 静态文件服务 - 提供 uploads 目录的访问
ServeStaticModule.forRoot({
rootPath: join(process.cwd(), 'uploads'),
serveRoot: '/api/uploads',
serveStaticOptions: {
index: false,
},
}),
PrismaModule,
AuthModule,
UsersModule,

View File

@ -7,6 +7,7 @@
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"target": "ES2021",
"lib": ["ES2021"],
"sourceMap": true,