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:
@@ -0,0 +1,139 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||
import { AssetKind, PublishStatus } from '../src/common/enums';
|
||||
import { ScenesService } from '../src/scenes/scenes.service';
|
||||
|
||||
/**
|
||||
* 场景上传必须兼容 multer 的 **diskStorage**。
|
||||
*
|
||||
* 现场(真实部署):上传中间件在 `storage.module.ts` 里配置为 `diskStorage`,
|
||||
* 落盘后的文件对象**只有 `path`、没有 `buffer`**;而 `ScenesService.create()`
|
||||
* 当初按内存存储写,判断的是 `!file.buffer`,于是**任何场景都传不上来**,
|
||||
* 一律 400「上传场景必须带 file 字段」—— 冒烟脚本里 6 项场景断言全红。
|
||||
*
|
||||
* 正确的做法与 models/motions 一致:`requireUploadedFile(file)` 校验落盘、
|
||||
* 修正 latin1 文件名编码,直接导入走 `persistTempFile()`(顺带清理临时文件),
|
||||
* 需要转换时再 `readFile(upload.path)`。
|
||||
*/
|
||||
const dir = mkdtempSync(join(tmpdir(), 'opendance-scene-upload-'));
|
||||
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
/** 一个最小的 GLB(JSON-only),仅用于让"直接导入"这条路径有文件可读 */
|
||||
function tinyGlb(): Buffer {
|
||||
const json = Buffer.from(
|
||||
JSON.stringify({
|
||||
asset: { version: '2.0' },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0] }],
|
||||
nodes: [{ name: 'Floor_col' }],
|
||||
meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }],
|
||||
accessors: [{ count: 3 }],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
const padded = Buffer.concat([json, Buffer.alloc((4 - (json.length % 4)) % 4, 0x20)]);
|
||||
const header = Buffer.alloc(12);
|
||||
header.write('glTF', 0, 'ascii');
|
||||
header.writeUInt32LE(2, 4);
|
||||
header.writeUInt32LE(12 + 8 + padded.length, 8);
|
||||
const chunkHeader = Buffer.alloc(8);
|
||||
chunkHeader.writeUInt32LE(padded.length, 0);
|
||||
chunkHeader.writeUInt32LE(0x4e4f534a, 4);
|
||||
return Buffer.concat([header, chunkHeader, padded]);
|
||||
}
|
||||
|
||||
/** 造一个 **diskStorage 形状**的上传文件:有 path,没有 buffer */
|
||||
function diskUpload(name = 'stage.glb') {
|
||||
const path = join(dir, `${Date.now()}-${Math.random().toString(16).slice(2)}${name.slice(name.lastIndexOf('.'))}`);
|
||||
writeFileSync(path, tinyGlb());
|
||||
const size = tinyGlb().length;
|
||||
return { originalname: name, mimetype: 'model/gltf-binary', path, size };
|
||||
}
|
||||
|
||||
const SOURCE = {
|
||||
sourceType: 'original',
|
||||
sourceName: '测试来源',
|
||||
sourceUrl: 'https://example.com/s',
|
||||
license: 'MIT',
|
||||
author: 'QA',
|
||||
};
|
||||
|
||||
function buildService() {
|
||||
const persisted = {
|
||||
id: 42,
|
||||
kind: AssetKind.Scene,
|
||||
sha256: 'a'.repeat(64),
|
||||
storedPath: 'scene/2026/09/aa/x.glb',
|
||||
size: 216,
|
||||
};
|
||||
const persistTempFile = vi.fn(async () => ({ asset: persisted, url: '/static/scene/x.glb' }));
|
||||
const persistBuffer = vi.fn(async () => ({ asset: persisted, url: '/static/scene/x.glb' }));
|
||||
const storage = {
|
||||
persistTempFile,
|
||||
persistBuffer,
|
||||
absolutePath: vi.fn(() => join(dir, 'not-a-real-file.glb')),
|
||||
url: vi.fn(() => '/static/scene/x.glb'),
|
||||
findMany: vi.fn(async () => new Map()),
|
||||
};
|
||||
const scenesRepository = {
|
||||
create: vi.fn((value: Record<string, unknown>) => value),
|
||||
save: vi.fn(async (value: Record<string, unknown>) => ({
|
||||
...value,
|
||||
id: 7,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deletedAt: null,
|
||||
})),
|
||||
find: vi.fn(async () => []),
|
||||
};
|
||||
const config = { uploadMaxBytes: 512 * 1024 * 1024, assimpBin: 'assimp', modelConvertTimeoutMs: 1000 };
|
||||
const service = new ScenesService(scenesRepository as never, storage as never, config as never);
|
||||
return { service, storage, persistTempFile, persistBuffer, scenesRepository };
|
||||
}
|
||||
|
||||
describe('场景上传兼容 multer diskStorage', () => {
|
||||
it('diskStorage 形状的文件(只有 path)不会再被判成"缺少 file 字段"', async () => {
|
||||
const { service, persistTempFile } = buildService();
|
||||
const dto = await service.create({ name: '冒烟场景', ...SOURCE }, diskUpload() as never);
|
||||
|
||||
expect(dto).toBeTruthy();
|
||||
// 直接导入走 persistTempFile(落盘的临时文件被归档)
|
||||
expect(persistTempFile).toHaveBeenCalledTimes(1);
|
||||
const call = persistTempFile.mock.calls[0] as unknown[];
|
||||
expect(call[0]).toBe(AssetKind.Scene);
|
||||
expect(String(call[1])).toMatch(/\.glb$/);
|
||||
expect(call[2]).toBe('stage.glb');
|
||||
});
|
||||
|
||||
it('GLB 直传仍标注"未做重编码",并落 pending 待审', async () => {
|
||||
const { service } = buildService();
|
||||
const dto = await service.create({ name: '冒烟场景', ...SOURCE }, diskUpload() as never);
|
||||
expect(String(dto.conversionNote)).toContain('直接导入');
|
||||
expect(dto.status).toBe(PublishStatus.Pending);
|
||||
});
|
||||
|
||||
it('文件名用 latin1 乱码传入时会被还原(与 models/motions 同一套)', async () => {
|
||||
const { service, persistTempFile } = buildService();
|
||||
// busboy 会把 UTF-8 文件名按 latin1 解出来
|
||||
const mangled = Buffer.from('舞台.glb', 'utf8').toString('latin1');
|
||||
await service.create({ name: '中文名场景', ...SOURCE }, diskUpload(mangled) as never);
|
||||
const call = persistTempFile.mock.calls[0] as unknown[];
|
||||
expect(call[2]).toBe('舞台.glb');
|
||||
});
|
||||
|
||||
it('缺少文件时仍然 400(错误语义没有被削弱)', async () => {
|
||||
const { service } = buildService();
|
||||
await expect(service.create({ name: '无文件', ...SOURCE }, undefined)).rejects.toThrow(/必须带 file 字段/);
|
||||
});
|
||||
|
||||
it('超过 UPLOAD_MAX_BYTES 时 400 且不落盘', async () => {
|
||||
const { service, persistTempFile } = buildService();
|
||||
const upload = diskUpload();
|
||||
await expect(
|
||||
service.create({ name: '超大场景', ...SOURCE }, { ...upload, size: 999 * 1024 * 1024 } as never),
|
||||
).rejects.toThrow(/超过上限/);
|
||||
expect(persistTempFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user