两个真实缺陷,都由本轮的「跨端边界验收」暴露:
一、上传落盘:去重命中时删掉了唯一副本(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。
248 lines
9.2 KiB
TypeScript
248 lines
9.2 KiB
TypeScript
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { createHash } from 'node:crypto';
|
||
import { createReadStream, existsSync } from 'node:fs';
|
||
import { copyFile, mkdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
||
import { dirname, extname, join, resolve, sep } from 'node:path';
|
||
import { In, Repository } from 'typeorm';
|
||
import { AssetKind } from '../common/enums';
|
||
import { AppConfig } from '../config/configuration';
|
||
import { Asset } from '../entities/asset.entity';
|
||
|
||
/** 上传产物落盘后的结果 */
|
||
export interface StoredAsset {
|
||
asset: Asset;
|
||
/** 可直接访问的相对 URL,如 /static/song-audio/2026/09/ab/abcd….mp3 */
|
||
url: string;
|
||
}
|
||
|
||
export function sha256File(path: string): Promise<string> {
|
||
return new Promise((resolvePromise, reject) => {
|
||
const hash = createHash('sha256');
|
||
const stream = createReadStream(path);
|
||
stream.on('error', reject);
|
||
stream.on('data', (chunk) => hash.update(chunk));
|
||
stream.on('end', () => resolvePromise(hash.digest('hex')));
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 文件存储服务。
|
||
*
|
||
* 约定(contracts/model-formats.md §3):
|
||
* - 存储路径 `${STORAGE_DIR}/{kind}/{yyyy}/{mm}/{sha256前2位}/{sha256}.{ext}`
|
||
* - 相同 sha256 复用同一 assets 记录,不重复落盘
|
||
* - 永不使用用户提供的原始文件名作为路径
|
||
*/
|
||
@Injectable()
|
||
export class StorageService {
|
||
private readonly logger = new Logger(StorageService.name);
|
||
private readonly root: string;
|
||
private readonly tmpDir: string;
|
||
|
||
constructor(
|
||
private readonly config: AppConfig,
|
||
@InjectRepository(Asset) private readonly assets: Repository<Asset>,
|
||
) {
|
||
this.root = resolve(this.config.storageDir);
|
||
this.tmpDir = join(this.root, 'tmp');
|
||
}
|
||
|
||
async onModuleInit(): Promise<void> {
|
||
await mkdir(this.tmpDir, { recursive: true });
|
||
this.logger.log(`存储根目录:${this.root}`);
|
||
}
|
||
|
||
get temporaryDir(): string {
|
||
return this.tmpDir;
|
||
}
|
||
|
||
/** 将相对存储路径解析为绝对路径;拒绝逃逸出存储根目录的路径 */
|
||
absolutePath(asset: Pick<Asset, 'storedPath'>): string {
|
||
const abs = resolve(this.root, asset.storedPath);
|
||
if (abs !== this.root && !abs.startsWith(this.root + sep)) {
|
||
throw new NotFoundException('非法的存储路径');
|
||
}
|
||
return abs;
|
||
}
|
||
|
||
/** 资产访问 URL(静态挂载点 /static) */
|
||
url(asset: Pick<Asset, 'storedPath'> | null | undefined): string | null {
|
||
if (!asset) return null;
|
||
return `/static/${asset.storedPath.split(sep).join('/')}`;
|
||
}
|
||
|
||
async findById(id: number | null | undefined): Promise<Asset | null> {
|
||
if (id === null || id === undefined) return null;
|
||
return this.assets.findOne({ where: { id } });
|
||
}
|
||
|
||
async findMany(ids: Array<number | null | undefined>): Promise<Map<number, Asset>> {
|
||
const wanted = [...new Set(ids.filter((v): v is number => typeof v === 'number' && v > 0))];
|
||
if (wanted.length === 0) return new Map();
|
||
const rows = await this.assets.find({ where: { id: In(wanted) } });
|
||
return new Map(rows.map((row) => [row.id, row]));
|
||
}
|
||
|
||
/** 批量解析 URL,避免 N+1 */
|
||
async urlMap(ids: Array<number | null | undefined>): Promise<Map<number, string>> {
|
||
const map = await this.findMany(ids);
|
||
return new Map([...map.entries()].map(([id, asset]) => [id, this.url(asset) as string]));
|
||
}
|
||
|
||
/** 保存 multer 落盘的临时文件(去重 + 归档),并清理临时文件 */
|
||
async persistTempFile(
|
||
kind: AssetKind,
|
||
tempPath: string,
|
||
originalName: string,
|
||
mime: string,
|
||
): Promise<StoredAsset> {
|
||
try {
|
||
const info = await stat(tempPath);
|
||
if (info.size === 0) throw new NotFoundException('上传文件为空');
|
||
const sha256 = await sha256File(tempPath);
|
||
|
||
const existing = await this.assets.findOne({ where: { sha256 } });
|
||
if (existing) {
|
||
// 去重命中时**必须确认文件真的还在**:换过 STORAGE_DIR、或只恢复了数据库
|
||
// 而没恢复上传目录时,旧记录会指向一个已经不存在的路径。此时若直接
|
||
// `safeUnlink(tempPath)` 把刚上传的唯一副本删掉,上传仍返回 201,
|
||
// 但后续预处理 / 模型分析 / 下载 / 缩略图全部 ENOENT
|
||
// (实测:动作上传成功后 `POST /api/motions/:id/preprocess` 直接 500)。
|
||
// 这里按**记录里的原路径**补写一份 —— 与 persistBuffer 的兜底一致。
|
||
const absolute = this.absolutePath(existing);
|
||
if (existsSync(absolute)) {
|
||
await this.safeUnlink(tempPath);
|
||
return { asset: existing, url: this.url(existing) as string };
|
||
}
|
||
await mkdir(dirname(absolute), { recursive: true });
|
||
try {
|
||
await rename(tempPath, absolute);
|
||
} catch {
|
||
await copyFile(tempPath, absolute);
|
||
await this.safeUnlink(tempPath);
|
||
}
|
||
return { asset: existing, url: this.url(existing) as string };
|
||
}
|
||
|
||
const ext = extname(originalName).toLowerCase();
|
||
const now = new Date();
|
||
const yyyy = String(now.getUTCFullYear());
|
||
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||
const relDir = join(kind, yyyy, mm, sha256.slice(0, 2));
|
||
const relPath = join(relDir, `${sha256}${ext}`);
|
||
const absDir = join(this.root, relDir);
|
||
await mkdir(absDir, { recursive: true });
|
||
|
||
const absTarget = join(absDir, `${sha256}${ext}`);
|
||
try {
|
||
await rename(tempPath, absTarget);
|
||
} catch {
|
||
// 跨设备时 rename 会失败,退化为复制
|
||
await copyFile(tempPath, absTarget);
|
||
await this.safeUnlink(tempPath);
|
||
}
|
||
|
||
const entity = this.assets.create({
|
||
kind,
|
||
originalName: originalName.slice(0, 255),
|
||
storedPath: relPath,
|
||
mime: mime || 'application/octet-stream',
|
||
size: info.size,
|
||
sha256,
|
||
});
|
||
|
||
try {
|
||
const saved = await this.assets.save(entity);
|
||
return { asset: saved, url: this.url(saved) as string };
|
||
} catch (error) {
|
||
// 并发上传同一文件:唯一索引冲突后回退到已存在记录
|
||
const raced = await this.assets.findOne({ where: { sha256 } });
|
||
if (raced) {
|
||
await this.safeUnlink(absTarget);
|
||
return { asset: raced, url: this.url(raced) as string };
|
||
}
|
||
throw error;
|
||
}
|
||
} catch (error) {
|
||
await this.safeUnlink(tempPath);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/** 保存内存中的内容(预处理产物等) */
|
||
async persistBuffer(
|
||
kind: AssetKind,
|
||
content: Buffer | string,
|
||
originalName: string,
|
||
mime: string,
|
||
): Promise<StoredAsset> {
|
||
const sha256 = createHash('sha256').update(content).digest('hex');
|
||
const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8');
|
||
|
||
const existing = await this.assets.findOne({ where: { sha256 } });
|
||
if (existing) {
|
||
// 去重命中,但要确认文件真的还在:换过 STORAGE_DIR、或只恢复了数据库没恢复
|
||
// 文件时,旧记录会指向一个不存在的路径。此时**按原路径补写一份**,
|
||
// 否则后续的模型分析 / 缩略图 / 下载都会静默失败(曾实测到"全 0 分析结果")。
|
||
const absolute = this.absolutePath(existing);
|
||
if (!existsSync(absolute)) {
|
||
await mkdir(dirname(absolute), { recursive: true });
|
||
await writeFile(absolute, buf);
|
||
}
|
||
return { asset: existing, url: this.url(existing) as string };
|
||
}
|
||
|
||
const ext = extname(originalName).toLowerCase();
|
||
const now = new Date();
|
||
const relDir = join(kind, String(now.getUTCFullYear()), String(now.getUTCMonth() + 1).padStart(2, '0'), sha256.slice(0, 2));
|
||
const relPath = join(relDir, `${sha256}${ext}`);
|
||
await mkdir(join(this.root, relDir), { recursive: true });
|
||
await writeFile(join(this.root, relPath), buf);
|
||
|
||
const entity = this.assets.create({
|
||
kind,
|
||
originalName: originalName.slice(0, 255),
|
||
storedPath: relPath,
|
||
mime: mime || 'application/octet-stream',
|
||
size: buf.byteLength,
|
||
sha256,
|
||
});
|
||
const saved = await this.assets.save(entity);
|
||
return { asset: saved, url: this.url(saved) as string };
|
||
}
|
||
|
||
/** 读取资产内容(供关键点下载等场景) */
|
||
async readAssetText(asset: Asset): Promise<string> {
|
||
const abs = this.absolutePath(asset);
|
||
const chunks: Buffer[] = [];
|
||
await new Promise<void>((resolvePromise, reject) => {
|
||
const stream = createReadStream(abs);
|
||
stream.on('data', (c) => chunks.push(c as Buffer));
|
||
stream.on('error', reject);
|
||
stream.on('end', () => resolvePromise());
|
||
});
|
||
return Buffer.concat(chunks).toString('utf8');
|
||
}
|
||
|
||
async stats(): Promise<{ files: number; totalBytes: number }> {
|
||
const row = await this.assets
|
||
.createQueryBuilder('a')
|
||
.select('COUNT(a.id)', 'files')
|
||
.addSelect('COALESCE(SUM(a.size), 0)', 'totalBytes')
|
||
.getRawOne<{ files: string; totalBytes: string }>();
|
||
return {
|
||
files: Number(row?.files ?? 0),
|
||
totalBytes: Number(row?.totalBytes ?? 0),
|
||
};
|
||
}
|
||
|
||
private async safeUnlink(path: string): Promise<void> {
|
||
try {
|
||
await unlink(path);
|
||
} catch {
|
||
/* 忽略:临时文件可能已被移走 */
|
||
}
|
||
}
|
||
}
|