Files
opendance-web/web-server/test/storage-persist.spec.ts
T
zijunchiang 12041f2e58 fix(upload,models): 上传落盘补齐兜底 + 不可用模型归因准确(v0.7.5)
两个真实缺陷,都由本轮的「跨端边界验收」暴露:

一、上传落盘:去重命中时删掉了唯一副本(T-WEB-017)
persistTempFile(动作 / 模型 / 场景上传走的通路)在 sha256 去重命中时直接
safeUnlink(tempPath) 并返回旧记录 —— 一旦记录指向的路径不存在(换过 STORAGE_DIR、
只恢复数据库、手工删过文件),上传仍返回 201,但后续动作预处理 500
(实测 ENOENT .../<sha>.vmd)、模型分析与缩略图静默失败。持久化通路 persistBuffer
早在上一轮就补了「文件缺失则按原路径补写」,这条通路漏了;现在同样处理:
先 existsSync(absolutePath(existing)),缺失则按记录里的原路径 rename(跨设备退化为
copyFile)补写,只有确认目标存在才删临时文件。
新增 test/storage-persist.spec.ts(5 项):首次上传 / 去重命中且文件在 /
去重命中但文件已丢失(补写而非删除)/ persistBuffer 回归 / 空文件拒绝。
反向验证:临时还原修复后第 3 条立刻失败,恢复后 5 项全过。

二、不可用模型的原因归因不准确(T-WEB-017)
unusableReason() 只要 stats 里有 humanoid 数字就归因于 humanoid,于是一个直接上传的
.glb(骨架完全合规、drivable=true、只是没有 VRM 容器)会被写成
「humanoid 不达标(19/22)」,把审核引向"重新绑定骨骼"而不是"以 VRM 交付"。
实测(跨端验收用真实 PMX→Assimp→GLB,886 KB):
  修复前 → humanoid 不达标(19/22),未产出 VRM
  修复后 → 未产出 VRM(格式为 GLB;humanoid 覆盖 19/22 已达标),需重新以 VRM 交付才能进入客户端目录
现在按 drivable 区分:不满足 16 根必需骨才归因 humanoid;达标但没 VRM 容器则明确说"已达标"。
新增 2 项单测(model-usability 5 → 7 项)。

三、跨端边界验收(Windows × Web)新增两类场景,双方都断言
  * 不可用模型:真实 PMX 用 Assimp 转 GLB 后入库(等价"转换只产出 GLB")→
    usable=false、原因准确、**不进入 catalog/export**、defaultModelId 不变;
    包内客户端的 bundle.models 里没有任何非 VRM 模型;
  * 场景 config 只给 {physics:{gravity:{y:-9.8}}} → 服务端补齐默认
    (碰撞体 auto、地面 0.6/45°、静风、相机 follow、环境光 1、出生点 1 条),
    客户端解析后逐项一致。

实测:npm run verify 全绿(契约 6 项 / 35 路由 / 版本 0.7.5 / 27 文件 309 项测试 /
build / 产物级实体注册核对 / 启动冒烟);跨端集成验收在**发布包起的服务端**上全部通过。
版本 0.7.4 → 0.7.5。
2026-09-18 03:03:50 +08:00

117 lines
5.0 KiB
TypeScript
Raw 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, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AssetKind } from '../src/common/enums';
import { StorageService } from '../src/storage/storage.service';
/**
* 上传落盘的两条通路都必须能处理「数据库里已有记录,但文件已经不在了」。
*
* 实测缺陷(T-WEB-017 期间发现):`persistTempFile`(动作 / 模型 / 场景上传走它)
* 在去重命中时直接 `safeUnlink(tempPath)` 把刚上传的**唯一副本**删掉并返回旧记录;
* 一旦记录指向的路径不存在(换过 `STORAGE_DIR`、只恢复了数据库、或手工删过文件),
* 上传仍然返回 201,但后续动作预处理 500(`ENOENT ... .vmd`)、模型分析与缩略图
* 静默失败。`persistBuffer` 早已有兜底,`persistTempFile` 漏了 —— 本用例把它钉住。
*/
let root: string;
let assets: Array<Record<string, unknown>>;
function buildService(): StorageService {
const repo = {
findOne: vi.fn(async ({ where }: { where: { sha256?: string } }) =>
assets.find((a) => a.sha256 === where.sha256) ?? null),
create: (entity: Record<string, unknown>) => entity,
save: vi.fn(async (entity: Record<string, unknown>) => {
const saved = { id: assets.length + 1, ...entity };
assets.push(saved);
return saved;
}),
};
// StorageService 的构造函数只依赖配置与仓储,这里用最小替身
return new StorageService({ storageDir: root } as never, repo as never);
}
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'opendance-storage-'));
assets = [];
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
describe('上传落盘:去重与文件缺失兜底', () => {
it('首次上传:写入文件并登记记录', async () => {
const service = buildService();
const temp = join(root, 'upload.tmp');
writeFileSync(temp, 'vmd-bytes');
const stored = await service.persistTempFile(AssetKind.Motion, temp, 'a.vmd', 'application/octet-stream');
expect(existsSync(service.absolutePath(stored.asset))).toBe(true);
expect(readFileSync(service.absolutePath(stored.asset), 'utf8')).toBe('vmd-bytes');
expect(existsSync(temp)).toBe(false); // 临时文件已被移走
});
it('去重命中且文件仍在:丢弃临时文件、复用记录', async () => {
const service = buildService();
const first = join(root, 'a.tmp');
writeFileSync(first, 'same-bytes');
const stored = await service.persistTempFile(AssetKind.Motion, first, 'a.vmd', 'application/octet-stream');
const second = join(root, 'b.tmp');
writeFileSync(second, 'same-bytes');
const again = await service.persistTempFile(AssetKind.Motion, second, 'a.vmd', 'application/octet-stream');
expect(again.asset.id).toBe(stored.asset.id);
expect(existsSync(second)).toBe(false);
expect(assets).toHaveLength(1);
});
it('去重命中但**文件已丢失**:按原路径补写,而不是删掉唯一副本', async () => {
const service = buildService();
const first = join(root, 'a.tmp');
writeFileSync(first, 'lost-bytes');
const stored = await service.persistTempFile(AssetKind.Motion, first, 'a.vmd', 'application/octet-stream');
const absolute = service.absolutePath(stored.asset);
// 模拟「换过 STORAGE_DIR / 只恢复了数据库」:记录还在,文件没了
rmSync(absolute, { force: true });
expect(existsSync(absolute)).toBe(false);
const second = join(root, 'b.tmp');
writeFileSync(second, 'lost-bytes');
const again = await service.persistTempFile(AssetKind.Motion, second, 'a.vmd', 'application/octet-stream');
expect(again.asset.id).toBe(stored.asset.id);
expect(existsSync(absolute)).toBe(true); // 文件被补写回来
expect(readFileSync(absolute, 'utf8')).toBe('lost-bytes');
expect(existsSync(second)).toBe(false); // 临时文件已转移
});
it('persistBuffer 的内存通路同样兜底(回归保护)', async () => {
const service = buildService();
const content = Buffer.from('generated-bytes');
const stored = await service.persistBuffer(AssetKind.Keypoints, content, 'k.ndjson', 'application/x-ndjson');
const absolute = service.absolutePath(stored.asset);
rmSync(absolute, { force: true });
const again = await service.persistBuffer(AssetKind.Keypoints, content, 'k.ndjson', 'application/x-ndjson');
expect(again.asset.id).toBe(stored.asset.id);
expect(existsSync(absolute)).toBe(true);
});
it('空文件被拒绝(不会登记成一条空资产)', async () => {
const service = buildService();
const temp = join(root, 'empty.tmp');
mkdirSync(root, { recursive: true });
writeFileSync(temp, '');
await expect(
service.persistTempFile(AssetKind.Motion, temp, 'empty.vmd', 'application/octet-stream'),
).rejects.toThrow(/空/);
expect(assets).toHaveLength(0);
});
});