From b57099df153b97c60e19a9a6a90b324dffd797f4 Mon Sep 17 00:00:00 2001 From: zhangxiaohua <827885272@qq.com> Date: Wed, 14 Jan 2026 16:12:54 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B93D=E6=A8=A1=E5=9E=8B=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package-lock.json | 34 +++++++++++ backend/package.json | 2 + backend/src/ai-3d/ai-3d.controller.ts | 88 +++++++++++++++++++++++---- backend/src/app.module.ts | 10 +++ backend/tsconfig.json | 1 + 5 files changed, 123 insertions(+), 12 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 723a8aa..a4a6932 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index d05860f..ff0ee04 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/ai-3d/ai-3d.controller.ts b/backend/src/ai-3d/ai-3d.controller.ts index 945e53a..fc3430f 100644 --- a/backend/src/ai-3d/ai-3d.controller.ts +++ b/backend/src/ai-3d/ai-3d.controller.ts @@ -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; } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 0a4bf9a..23d07d2 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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, diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 5df6ad0..0b7dfb6 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -7,6 +7,7 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, + "esModuleInterop": true, "target": "ES2021", "lib": ["ES2021"], "sourceMap": true,