Files
zijunchiang 828c7cea49 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。
2026-09-17 03:28:31 +08:00

140 lines
5.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
});