fix(scenes): 场景上传一律 400 —— diskStorage 却按内存存储判断 file.buffer

现场(真实部署 v0.7.2,用 curl 对照验证过):
  POST /api/models  → 201(同一份 multipart 文件)
  POST /api/scenes  → 400「上传场景必须带 file 字段(.glb 为主…)」
即**任何场景都传不上来**(冒烟脚本里 6 项场景断言全红)。

根因:上传中间件在 storage.module.ts 里配置为 multer 的 **diskStorage**
(先落 ${STORAGE_DIR}/tmp,避免 512 MiB 级 VRM 打爆内存),落盘后的文件对象
**只有 path、没有 buffer**;而 ScenesService.create() 当初按内存存储写:

  if (!file || !file.buffer || file.buffer.length === 0) { …400… }

file.buffer 恒为 undefined,于是必然 400。

修复:与 models/motions 统一到同一套上传处理
- requireUploadedFile(file):校验确实落盘(path),并修正 busboy 按 latin1 解出的
  中文/日文文件名;
- 直接导入(.glb/.gltf):storage.persistTempFile(AssetKind.Scene, upload.path, …)
  —— 归档并顺带清理临时文件;
- 需转换(.zip/.pmx/.fbx):readFile(upload.path) 取 buffer 后转换 → persistBuffer,
  并在 finally 中 unlink(upload.path);
- 体积上限用 upload.size 判断(UPLOAD_MAX_BYTES),超限 400 且不落盘;
- UploadedSceneFile 改为 UploadedFileLike 的别名(有 path 无 buffer);
- 日志里的文件名改用解码后的 upload.originalname。

同时全局排查过同类写法:除这两处注释外,代码里已无按 file.buffer 处理上传的地方。

新增 test/scene-upload.spec.ts(5 项,修复前必失败):diskStorage 形状的文件不再
被判成「缺少 file 字段」、GLB 直传标注未做重编码且落 pending、中文文件名被还原、
缺文件仍 400、超限 400 且不落盘。

文档:README.md/.txt 的 v0.7.3 说明补上这条;tasks/015-scene-management.md 增
「缺陷修复记录三」。

验证:npm run verify 通过(契约六项一致、35 条路由、22 文件 / 281 项测试、
nest build、启动冒烟);重新打包 opendance-web-v0.7.3.tar.gz
sha256 07463a3b9f971346d0af1f73919e6e4dfa98ef20fd160419405d57272e9d4524。
This commit is contained in:
2026-09-17 03:28:31 +08:00
parent f3b97fec53
commit 828c7cea49
5 changed files with 228 additions and 24 deletions
+48 -24
View File
@@ -1,24 +1,27 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { existsSync } from 'node:fs';
import { readFile, unlink } from 'node:fs/promises';
import { Repository } from 'typeorm';
import { AssetKind, ModelFormat, PublishStatus } from '../common/enums';
import { assertSourceTupleComplete, type SourceTuple } from '../common/source.dto';
import { requireUploadedFile, type UploadedFileLike } from '../common/upload.util';
import { AppConfig } from '../config/configuration';
import { Scene } from '../entities/scene.entity';
import { convertModelArchive, convertModelFile } from '../models/model-convert';
import { inspectModelFile } from '../models/model-inspector';
import { renderModelThumbnail } from '../models/model-thumbnail';
import { StorageService } from '../storage/storage.service';
import type { StoredAsset } from '../storage/storage.service';
import { normalizeSceneConfig, SCENE_CONFIG_DEFAULTS, type SceneConfig } from './scene-config';
import { type SceneDto, toSceneDto } from './scene.serializer';
export interface UploadedSceneFile {
buffer: Buffer;
originalname: string;
size: number;
mimetype?: string;
}
/**
* 上传文件的最小信息 —— 与 models/motions 共用 `UploadedFileLike`。
* 注意上传中间件是 multer 的 **diskStorage**:文件只有 `path`,**没有 `buffer`**,
* 所以不能按 `file.buffer` 判断(那会让场景永远传不上来)。
*/
export type UploadedSceneFile = UploadedFileLike;
const SUPPORTED_DIRECT = new Set(['.glb', '.gltf']);
@@ -96,9 +99,14 @@ export class ScenesService {
async create(body: Record<string, unknown>, file: UploadedSceneFile | undefined): Promise<SceneDto> {
if (!file || !file.buffer || file.buffer.length === 0) {
if (!file) {
throw new BadRequestException('上传场景必须带 file 字段(.glb 为主,也接受 .gltf/.zip/.fbx/.pmx)');
}
// 上传中间件用的是 **diskStorage**(见 storage.module.ts),文件只有 `path`、没有
// `buffer`。这里必须与 models/motions 走同一套:requireUploadedFile 校验落盘 +
// 修正 latin1 文件名编码,后续要么 persistTempFile 直接归档(顺带清理临时文件),
// 要么读成 buffer 再转换。此前按 `file.buffer` 判断,导致**任何场景都传不上来**(400)。
const upload = requireUploadedFile(file, 'file');
const name = typeof body.name === 'string' && body.name.trim().length > 0 ? body.name.trim() : '';
if (name.length === 0) throw new BadRequestException('场景名称 name 必填');
const tuple = assertSourceTupleComplete({
@@ -113,47 +121,63 @@ export class ScenesService {
}
const parsed = this.parseConfigInput(body);
const ext = extOf(file.originalname || '');
let buffer: Buffer;
const ext = extOf(upload.originalname || '');
if (upload.size && upload.size > this.config.uploadMaxBytes) {
throw new BadRequestException(
`上传文件超过上限 ${Math.floor(this.config.uploadMaxBytes / 1024 / 1024)} MiB`,
);
}
let stored: StoredAsset;
let conversionNote: string;
let storedSize: number;
if (SUPPORTED_DIRECT.has(ext)) {
// 场景主力格式:GLB 直接导入(不重编码)
buffer = file.buffer;
// 场景主力格式:GLB 直接导入(不重编码)——直接归档 multer 落盘的临时文件
stored = await this.storage.persistTempFile(
AssetKind.Scene,
upload.path,
upload.originalname || `scene-${Date.now()}.glb`,
upload.mimetype || 'model/gltf-binary',
);
conversionNote = `直接导入(${ext.slice(1).toUpperCase()},未做重编码)`;
storedSize = stored.asset.size;
} else {
// 其余格式统一转 GLB(场景不做 VRM)
// 其余格式统一转 GLB(场景不做 VRM);转换需要 buffer,读完再转换,
// 无论成败都要清掉临时文件(persistTempFile 那条路会自己清)
try {
const source = await readFile(upload.path);
const converted =
ext === '.zip'
? await convertModelArchive(file.buffer, {
? await convertModelArchive(source, {
assimpBin: this.config.assimpBin,
timeoutMs: this.config.modelConvertTimeoutMs,
preferFormat: 'glb',
})
: await convertModelFile(file.buffer, file.originalname, {
: await convertModelFile(source, upload.originalname, {
assimpBin: this.config.assimpBin,
timeoutMs: this.config.modelConvertTimeoutMs,
preferFormat: 'glb',
});
buffer = converted.buffer;
stored = await this.storage.persistBuffer(
AssetKind.Scene,
converted.buffer,
upload.originalname || `scene-${Date.now()}.glb`,
'model/gltf-binary',
);
storedSize = converted.buffer.length;
conversionNote = converted.note.slice(0, 500);
} catch (error) {
throw new BadRequestException(
`场景转换失败(${ext || '未知扩展名'}):${error instanceof Error ? error.message : String(error)}`,
);
} finally {
await unlink(upload.path).catch(() => undefined);
}
}
const stored = await this.storage.persistBuffer(
AssetKind.Scene,
buffer,
file.originalname || `scene-${Date.now()}.glb`,
'model/gltf-binary',
);
const absolutePath = this.storage.absolutePath(stored.asset);
const stats = await this.inspectSafely(absolutePath, buffer.length, name);
const stats = await this.inspectSafely(absolutePath, storedSize, name);
const thumbnailAssetId = await this.thumbnailSafely(absolutePath);
const scene = this.scenes.create({
@@ -175,7 +199,7 @@ export class ScenesService {
});
const saved = await this.scenes.save(scene);
this.logger.log(
`场景已入库待审:#${saved.id} ${name}(${file.originalname},${conversionNote})` +
`场景已入库待审:#${saved.id} ${name}(${upload.originalname},${conversionNote})` +
` 重力=(${parsed.physics.gravity.x},${parsed.physics.gravity.y},${parsed.physics.gravity.z})`,
);
const [dto] = await this.toDtos([saved]);