From 12041f2e588c6655eac9b8b298fec4f532aa781a Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Fri, 18 Sep 2026 03:03:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(upload,models):=20=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E8=90=BD=E7=9B=98=E8=A1=A5=E9=BD=90=E5=85=9C=E5=BA=95=20+=20?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E7=94=A8=E6=A8=A1=E5=9E=8B=E5=BD=92=E5=9B=A0?= =?UTF-8?q?=E5=87=86=E7=A1=AE=EF=BC=88v0.7.5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个真实缺陷,都由本轮的「跨端边界验收」暴露: 一、上传落盘:去重命中时删掉了唯一副本(T-WEB-017) persistTempFile(动作 / 模型 / 场景上传走的通路)在 sha256 去重命中时直接 safeUnlink(tempPath) 并返回旧记录 —— 一旦记录指向的路径不存在(换过 STORAGE_DIR、 只恢复数据库、手工删过文件),上传仍返回 201,但后续动作预处理 500 (实测 ENOENT .../.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。 --- README.md | 24 ++++- project.yaml | 8 +- tasks/017-model-unusable-reason.md | 72 ++++++++++++++ web-server/admin/package.json | 2 +- web-server/package.json | 2 +- web-server/src/models/model.serializer.ts | 21 +++- web-server/src/storage/storage.service.ts | 19 +++- web-server/test/model-usability.spec.ts | 36 ++++++- web-server/test/storage-persist.spec.ts | 116 ++++++++++++++++++++++ 9 files changed, 290 insertions(+), 10 deletions(-) create mode 100644 tasks/017-model-unusable-reason.md create mode 100644 web-server/test/storage-persist.spec.ts diff --git a/README.md b/README.md index ca57fd3..5ffc697 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ OpenDance Studio **阶段一**: Web 管理服务端(NestJS + TypeORM + 现有 My ## 状态 -**阶段一已完成**: T-WEB-001 -> T-WEB-016 全部落地,当前版本 **v0.7.4**(契约 `rest-api 0.5.0`、`websocket-keypoints 0.2.0`、`data-models 0.7.0`、`model-formats 0.5.0`、`skeleton-topology 0.2.0`、`database 0.4.0`)。 +**阶段一已完成**: T-WEB-001 -> T-WEB-017 全部落地,当前版本 **v0.7.5**(契约 `rest-api 0.5.0`、`websocket-keypoints 0.2.0`、`data-models 0.7.0`、`model-formats 0.5.0`、`skeleton-topology 0.2.0`、`database 0.4.0`)。 v0.7.4 修订(**场景功能的真正根因:`Scene` 没有注册进 `ENTITIES`**): @@ -35,6 +35,26 @@ v0.7.4 修订(**场景功能的真正根因:`Scene` 没有注册进 `ENTITIES`** 4. `web-server/scripts/diagnose-scenes.mjs` 保持不变(默认只读),部署 v0.7.4 后运行它 可确认 `scenes` 表与列的实际情况。 +v0.7.5 修订(**不可用模型的原因归因必须准确**): + +1. `unusableReason()` 原来只要 `stats` 里有 humanoid 数字就归因于 humanoid, + 于是一个**直接上传的 `.glb`**(骨架完全合规、`drivable = true`、只是没有 VRM 容器) + 会被写成「humanoid 不达标(19/22)」—— 审核界面据此会把修复方向引到"重新绑定骨骼", + 而实际要做的是"以 VRM 交付"; +2. 现在按 **`drivable`** 区分:确实不满足 16 根必需骨 → 仍报 humanoid 覆盖度; + 骨架已达标但没 VRM 容器 → 明确说明"已达标,需重新以 VRM 交付"; +3. 新增 2 项单测(5 → 7 项):「humanoid 已达标时**不得**出现『humanoid 不达标』字样」 + 与「`drivable=false` 时仍归因于 humanoid」; +4. **上传落盘的对称兜底**:`persistTempFile`(动作/模型/场景上传走的通路)在去重命中时 + 会直接删掉刚上传的**唯一副本**并返回旧记录 —— 若记录指向的路径已不存在 + (换过 `STORAGE_DIR`、只恢复数据库、手工删过文件),上传仍返回 201, + 但**后续动作预处理 500**(实测 `ENOENT ... .vmd`)、模型分析与缩略图静默失败。 + `persistBuffer` 早有兜底,这条通路漏了;现按记录里的原路径补写。 + 新增 `test/storage-persist.spec.ts`(5 项)覆盖首次上传 / 去重命中 / **文件已丢失补写** + / 内存通路回归 / 空文件拒绝,并做了反向验证(还原修复后该用例立刻失败); +5. 跨端边界验收(Windows × Web)新增两类场景:不可用模型(真实 PMX 转 GLB 后入库) + **不进入** `catalog/export` 且 `defaultModelId` 不变;场景 `config` 缺字段时服务端补齐默认值。 + v0.7.4 之后的测试补强(**不改产物,故不新发版本**): 1. 新增 `web-server/test/real-samples.spec.ts`(6 项,opt-in):用仓库根 @@ -464,7 +484,7 @@ npm run dev # http://localhost:5173(/api 与 /health 代理到 3000) cd web-server npm run contracts:check # 契约版本一致性(6 项契约版本 + 35 条路由 + 三端版本号) npm run typecheck # TypeScript 严格检查 -npm run test # 295 项单元测试 / 25 个文件。分布(VMD 31、模型检查器 29、 +npm run test # 309 项单元测试 / 27 个文件。分布(VMD 31、模型检查器 29、 # 模型压缩包 27、预处理 25、admin-port 16、init-env 16、 # Assimp 定位与安装 13、应用版本 12、目录导出 12、.env 预检 12、 # 访问日志 9、MMD 材质对齐 9、stats 容量与编码 9、场景配置 9、 diff --git a/project.yaml b/project.yaml index 23b6b7f..9b5d99f 100644 --- a/project.yaml +++ b/project.yaml @@ -1,5 +1,5 @@ project: opendance-web-server -version: 0.7.4 +version: 0.7.5 repository: opendance-web default_branch: main contracts_version: @@ -88,9 +88,13 @@ tasks: file: tasks/016-websocket-contract-020.md scope: 契约 websocket-keypoints 0.2.0(保活 / 设备去重 / 状态机,与 T-AND-011 对齐) status: done + - id: T-WEB-017 + file: tasks/017-model-unusable-reason.md + scope: 不可用模型的原因归因必须准确(按 drivable 判断)+ 跨端边界验收(不可用模型不进目录 / 场景 config 缺字段收敛) + status: done deliverable: - artifact: opendance-web-v0.7.4.tar.gz + artifact: opendance-web-v0.7.5.tar.gz packaging: Linux 业务服务(系统服务托管,双端口:软件 API + 管理端,不含 MySQL、不含反向代理、不使用 Docker) includes: - web-server/ # NestJS 源码 + 构建产物(含 BVH / VMD 内置解析、ZIP 读取与模型压缩包转换) diff --git a/tasks/017-model-unusable-reason.md b/tasks/017-model-unusable-reason.md new file mode 100644 index 0000000..6d0ebe1 --- /dev/null +++ b/tasks/017-model-unusable-reason.md @@ -0,0 +1,72 @@ +# T-WEB-017:不可用模型的原因归因必须准确 + +- **状态**: done +- **依赖**: T-WEB-012(模型一律转 VRM) +- **文档**: v1.6 §4.9 / §7.6 +- **契约**: `model-formats 0.5.0`、`data-models 0.7.0` + +## 缺陷 + +`unusableReason()` 原来只要 `stats` 里有 humanoid 数字就归因于 humanoid: + +```ts +if (stats && typeof stats.humanoidMapped === 'number' && typeof stats.humanoidTotal === 'number') { + return `humanoid 不达标(${stats.humanoidMapped}/${stats.humanoidTotal}),未产出 VRM`; +} +``` + +于是一个**直接上传的 `.glb`**(骨架完全合规、`drivable = true`、只是没有 VRM 容器) +会被写成「**humanoid 不达标(19/22)**」—— 审核界面据此会把修复方向引到 +「重新绑定骨骼」,而实际要做的是「以 VRM 交付」。 + +## 修复 + +按 **`drivable`** 区分两种不可用: + +| 情形 | 原因文案 | +|--|--| +| `drivable !== true`(确实不满足 16 根必需骨) | `humanoid 不达标(m/22),未产出 VRM` | +| `drivable === true` 但没有 VRM 容器(直接上传的 GLB / 转换只产出 GLB) | `未产出 VRM(格式为 GLB;humanoid 覆盖 m/22 已达标),需重新以 VRM 交付才能进入客户端目录` | +| 没有 stats(分析失败等) | `未产出 VRM(格式为 GLB),不可用于客户端`(不变) | + +新增两条单测(`test/model-usability.spec.ts`,5 → 7 项): +「humanoid 已达标时**不得**出现『humanoid 不达标』字样」与「`drivable=false` 时仍归因于 humanoid」。 + +## 顺带修掉:上传落盘的对称兜底(`persistTempFile`) + +做跨端边界验收时,动作预处理在**换了 `STORAGE_DIR`** 的服务端上直接 500: + +``` +ERROR [PreprocessService] 动作 #7 预处理失败:ENOENT ... /web075/storage/motion/2026/09/a8/.vmd +``` + +根因:`persistTempFile`(动作 / 模型 / 场景上传走的通路)在去重命中时 +**直接 `safeUnlink(tempPath)` 删掉刚上传的唯一副本**并返回旧记录;一旦记录指向的路径 +不存在(换过 `STORAGE_DIR`、只恢复数据库、手工删过文件),上传仍返回 **201**, +但后续动作预处理 500、模型分析与缩略图静默失败。`persistBuffer` 早在上一轮就补了 +「文件缺失则按原路径补写」,这条通路漏了。 + +**修复**:去重命中时先 `existsSync(absolutePath(existing))`;缺失则按记录里的原路径 +`rename`(跨设备退化为 `copyFile`)补写,只有确认目标存在才删临时文件。 + +新增 `test/storage-persist.spec.ts`(5 项):首次上传 / 去重命中且文件在 / +**去重命中但文件已丢失(补写而非删除)** / `persistBuffer` 回归 / 空文件拒绝。 +**反向验证**:临时还原修复后,第 3 条用例立即失败(`existsSync(absolute) === false`), +恢复后 5 项全过。 + +## 跨端边界验收(Windows × Web) + +`accept-integration.sh` 的播种脚本新增两个**边界**场景,并用发布包里的客户端断言: + +1. **不可用模型**:把真实 PMX 用 Assimp 转成 GLB(等价于"转换只产出 GLB"的入库形态), + 上传并审核通过(**不**设为默认模型),断言 + * `/api/models/{id}` 的 `usable === false` 且 `unusableReason` 不谎报 humanoid; + * 该模型**不出现在** `GET /api/catalog/export`; + * `defaultModelId` 仍是可用的 VRM 模型; + * 包内客户端的 `bundle.models` 里**没有**任何非 VRM 模型(① 的消费侧形态)。 +2. **场景 config 缺字段**:只给 `physics.gravity`,断言服务端补齐默认值 + (碰撞体 `auto`、地面默认摩擦/坡度、静默风、相机 `follow`…),客户端解析后同样一致。 + +## 实测 + +见 `build/accept-integration.sh` 输出(需要已部署的 Web 服务端)。 diff --git a/web-server/admin/package.json b/web-server/admin/package.json index c88efb4..413a365 100644 --- a/web-server/admin/package.json +++ b/web-server/admin/package.json @@ -1,7 +1,7 @@ { "name": "opendance-admin", "private": true, - "version": "0.7.4", + "version": "0.7.5", "type": "module", "description": "OpenDance Studio 管理端(React + TypeScript + Vite)", "scripts": { diff --git a/web-server/package.json b/web-server/package.json index 9cd3570..6bc33be 100644 --- a/web-server/package.json +++ b/web-server/package.json @@ -1,6 +1,6 @@ { "name": "opendance-web-server", - "version": "0.7.4", + "version": "0.7.5", "description": "OpenDance Studio Web 管理服务端(NestJS + TypeORM + 现有 MySQL 实例)", "license": "MIT", "private": true, diff --git a/web-server/src/models/model.serializer.ts b/web-server/src/models/model.serializer.ts index b94db35..ceaa140 100644 --- a/web-server/src/models/model.serializer.ts +++ b/web-server/src/models/model.serializer.ts @@ -56,13 +56,30 @@ export function isUsableModel(model: CharacterModel): boolean { return model.format === ModelFormat.Vrm; } -/** 不可用的原因(可用时返回 null);从 stats 取 humanoid 覆盖度,便于人工定位 */ +/** + * 不可用的原因(可用时返回 null)。 + * + * **归因必须准确**:审核界面靠这句话判断"要不要重新绑定骨骼"还是"只是没转 VRM"。 + * 早期实现只要 `stats` 里有 humanoid 数字就说「humanoid 不达标」—— 于是一个 + * **直接上传的 `.glb`**(骨架完全合规、`drivable = true`、只是没有 VRM 容器) + * 也会被写成「humanoid 不达标(19/22)」,把审核引向错误的修复方向。 + * 现在只有**确实不满足可驱动门槛**(`drivable !== true`)时才归因于 humanoid。 + */ export function unusableReason(model: CharacterModel): string | null { if (isUsableModel(model)) return null; const stats = (model.stats ?? null) as Partial | null; - if (stats && typeof stats.humanoidMapped === 'number' && typeof stats.humanoidTotal === 'number') { + const hasHumanoid = + !!stats && + typeof stats.humanoidMapped === 'number' && + typeof stats.humanoidTotal === 'number'; + if (hasHumanoid && stats.drivable !== true) { return `humanoid 不达标(${stats.humanoidMapped}/${stats.humanoidTotal}),未产出 VRM`; } + if (hasHumanoid) { + // 骨架达标却没交付 VRM:多见于是直接上传的 .glb(或转换只产出了 GLB) + return `未产出 VRM(格式为 GLB;humanoid 覆盖 ${stats.humanoidMapped}/${stats.humanoidTotal} 已达标),` + + '需重新以 VRM 交付才能进入客户端目录'; + } return '未产出 VRM(格式为 GLB),不可用于客户端'; } diff --git a/web-server/src/storage/storage.service.ts b/web-server/src/storage/storage.service.ts index 39bb483..f81beb0 100644 --- a/web-server/src/storage/storage.service.ts +++ b/web-server/src/storage/storage.service.ts @@ -104,7 +104,24 @@ export class StorageService { const existing = await this.assets.findOne({ where: { sha256 } }); if (existing) { - await this.safeUnlink(tempPath); + // 去重命中时**必须确认文件真的还在**:换过 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 }; } diff --git a/web-server/test/model-usability.spec.ts b/web-server/test/model-usability.spec.ts index b6cdda7..8d534d4 100644 --- a/web-server/test/model-usability.spec.ts +++ b/web-server/test/model-usability.spec.ts @@ -49,7 +49,7 @@ describe('模型可用性(usable)', () => { const glb = model({ format: ModelFormat.Glb, vrmVersion: null, - stats: { humanoidMapped: 0, humanoidTotal: 22 } as Record, + stats: { humanoidMapped: 0, humanoidTotal: 22, drivable: false } as Record, }); expect(isUsableModel(glb)).toBe(false); expect(unusableReason(glb)).toBe('humanoid 不达标(0/22),未产出 VRM'); @@ -58,6 +58,40 @@ describe('模型可用性(usable)', () => { expect(dto.unusableReason).toContain('0/22'); }); + it('GLB 但 **humanoid 已达标** → 不能归因成「humanoid 不达标」(归因准确)', () => { + // 直接上传的 .glb:骨架合规(drivable=true)、只是没有 VRM 容器。 + // 早期实现会写成「humanoid 不达标(19/22)」,把审核引向错误的修复方向。 + const glb = model({ + format: ModelFormat.Glb, + vrmVersion: null, + stats: { + humanoidMapped: 19, + humanoidTotal: 22, + drivable: true, + missingRequiredBones: [], + } as Record, + }); + const reason = unusableReason(glb); + expect(reason).not.toContain('humanoid 不达标'); + expect(reason).toContain('19/22'); + expect(reason).toContain('已达标'); + expect(reason).toContain('未产出 VRM'); + }); + + it('GLB 且 humanoid 未达标(drivable=false)→ 仍归因于 humanoid', () => { + const glb = model({ + format: ModelFormat.Glb, + vrmVersion: null, + stats: { + humanoidMapped: 12, + humanoidTotal: 22, + drivable: false, + missingRequiredBones: ['leftHand'], + } as Record, + }); + expect(unusableReason(glb)).toBe('humanoid 不达标(12/22),未产出 VRM'); + }); + it('GLB 且没有 stats → 给出通用原因(不抛错)', () => { const glb = model({ format: ModelFormat.Glb, vrmVersion: null, stats: null }); expect(unusableReason(glb)).toContain('未产出 VRM'); diff --git a/web-server/test/storage-persist.spec.ts b/web-server/test/storage-persist.spec.ts new file mode 100644 index 0000000..8cbdd1b --- /dev/null +++ b/web-server/test/storage-persist.spec.ts @@ -0,0 +1,116 @@ +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>; + +function buildService(): StorageService { + const repo = { + findOne: vi.fn(async ({ where }: { where: { sha256?: string } }) => + assets.find((a) => a.sha256 === where.sha256) ?? null), + create: (entity: Record) => entity, + save: vi.fn(async (entity: Record) => { + 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); + }); +});