feat(models): 发布 v0.2.0 —— GLB/VRM 主力格式、自动缩略图与分析、模型审核与骨骼预览

需求 1:系统确定以 GLB / VRM 为模型主力格式
- 服务端新增支持库:
  - model-inspector.ts:解析 GLB/VRM 的面数、顶点、材质、贴图、形变目标、
    **骨骼树(名称/层级/是否蒙皮/humanoid 映射/子节点数)**、humanoid 覆盖率与可驱动性
  - model-thumbnail.ts:**纯 TS 软件光栅化**(z-buffer + Lambert + 完整蒙皮矩阵)
    生成 512×512 PNG 缩略图,零原生依赖、不装 headless-gl
  - vrm-writer.ts:GLB → VRM 1.0(写入 VRMC_vrm 扩展与 humanoid 映射)
  - gltf-packer.ts:glTF(.gltf + .bin + 贴图)→ 单文件 GLB
- humanoid 候选表与 Godot 客户端 humanoid_map.gd **逐字对齐**,并有单测断言两边一致
  (服务端判定的"可驱动" = 客户端实际能否驱动)

需求 2:模型管理
- .glb / .vrm / .gltf **直接导入**(不重编码,GLB 含 VRM 扩展会自动识别为 VRM)
- .pmx / .fbx 服务端转换:assimp → .glb →(失败)→ .gltf 后自行打包成 GLB
  →(MODEL_CONVERT_FORMAT=vrm 时)补 VRMC_vrm 产出 VRM 1.0;每步 assimp info 回读校验
- .zip **自动搜索包内可用模型**:.vrm → .glb → .gltf(直接导入)优先,
  其次 .pmx → .fbx(转换);同优先级内按"指名字 → 目录最浅 → 文件最大"
- 入库后自动分析(models.stats)与生成缩略图(models.thumbnail_asset_id),
  失败均不阻断入库;管理端列表显示小缩略图、预览放开到 vrm/glb/gltf

需求 3:审核功能
- 上传的模型状态**一律 pending**(不再自动发布、不再自动设默认)
- 审核界面新增模型面板:体积、面数、顶点、材质、贴图、骨骼、形变目标等指标,
  humanoid 覆盖进度、可驱动性结论(缺哪些必需骨骼)、warnings,以及
  **骨骼详细预览**(层级缩进、蒙皮/Humanoid 徽章、子节点数、搜索保留祖先链、
  仅看蒙皮、限高滚动 + 只渲染前 400 行)
- 审核通过可带 setPrimary=true 一并设为默认模型(此时才写源码种子);
  POST /api/models/:id/primary 仅允许 published(否则 409)
- scripts/import-default-model.mjs 改为"上传 → 审核通过 + 设默认"流程

其它:
- models 新增 stats 列(迁移 AddModelStats1789460000000,已在真实库执行)
- 契约升至 rest-api/data-models/model-formats 0.4.0、database 0.3.0(另两库已同步)
- 版本号 0.1.0 → 0.2.0(package.json ×2 + lockfile + project.yaml)
- 文档:README.txt / DSH.md / project.yaml / tasks 005·007·010 全面更新;
  顶层开发书更新为 v1.4

验证:168 项单测通过(10 文件);契约 6 项与 29 条路由一致;typecheck/build 通过;
真实端到端:MMD 压缩包 → GLB + 分析(面数 9071/骨骼 238/humanoid 0-22) + 缩略图(15KB PNG);
GLB 直接导入 sha256 与源文件一致;审核通过后 defaultModelId 由 VRM 变为该模型
This commit is contained in:
2026-09-15 20:37:53 +08:00
parent ccced34dd6
commit 53bf182476
47 changed files with 5610 additions and 628 deletions
+116 -72
View File
@@ -1,11 +1,14 @@
# T-WEB-005 模型上传 + VRM 优先 + 来源字段
# T-WEB-005 模型上传(GLB/VRM 直接导入 + PMX/FBX 转换 + zip 自动搜索 + 分析/缩略图 + 上传即待审)
- **状态**:done
- **状态**:done(v0.2.0 重构:GLB/VRM 主力、上传即待审)
- **依赖**:T-WEB-002
## 目标
上传角色模型,**VRM 优先**,识别 VRM 版本,并维护全局默认模型。
系统以 **GLB / VRM 为主力格式**(服务端与客户端都一等公民):`.glb`/`.vrm`/`.gltf`
**直接导入、不重编码**,`.pmx`/`.fbx` 服务端转换(**优先 GLB、其次 VRM**),`.zip`
**自动搜索包内可用模型**。入库后自动**分析**(面数 / 骨骼树 / humanoid 覆盖 / 可驱动性)
与生成**缩略图**;状态**一律 `pending`**,经审核后才可能成为默认模型。
## load
@@ -13,15 +16,20 @@
DSH.md
contracts/rest-api.yaml # 锚点:#/paths/~1api~1models
contracts/data-models.md # 锚点:§4 Model、§4.1 默认模型选择规则
contracts/model-formats.md # 锚点:§1 角色模型、§1.1 模型压缩包转换
contracts/model-formats.md # 锚点:§1 角色模型、§1.1 上传与转换策略
web-server/src/models/*
web-server/src/models/vrm-inspector.ts
web-server/src/common/zip-reader.ts
web-server/src/models/model-inspector.ts
web-server/src/models/model-thumbnail.ts
web-server/src/models/vrm-writer.ts
web-server/src/models/gltf-packer.ts
web-server/src/models/model-convert.ts
web-server/src/models/glb-textures.ts
web-server/src/models/default-model-source.ts
web-server/src/common/zip-reader.ts
web-server/src/config/default-model.ts
web-server/src/migrations/1789450000000-AddModelConversionFields.ts
web-server/src/migrations/1789460000000-AddModelStats.ts
web-server/src/reviews/review.dto.ts
web-server/scripts/import-default-model.mjs
```
@@ -30,13 +38,67 @@ web-server/scripts/import-default-model.mjs
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/models` | 分页 + `q`/`format`/`status`/`primaryOnly` |
| POST | `/api/models` | multipart 上传(`file` + 名称 + 来源五元组 + `isPrimary`);`file` 也接受 `.zip`(MMD 模型包,自动转换) |
| GET | `/api/models/:id` | 详情 |
| POST | `/api/models` | multipart 上传(`file` + 名称 + 来源五元组);`file` 接受 `.glb`/`.vrm`/`.gltf`/`.pmx`/`.fbx`/`.zip`;**入库一律 pending** |
| GET | `/api/models/:id` | 详情(含 `stats`、`thumbnailUrl`、`conversionNote`、`fileSha256`、`sourceSha256`) |
| PATCH | `/api/models/:id` | 更新 |
| DELETE | `/api/models/:id` | 软删除 |
| POST | `/api/models/:id/primary` | 设为全局默认模型 |
| POST | `/api/models/:id/primary` | 设为全局默认模型(**仅 `published` 允许,否则 409**) |
## 默认模型选择规则(`contracts/data-models.md` §4.1,已实现于 `resolveDefaultModelId()`)
## 格式策略(服务端与客户端一致)
| 输入 | 行为 |
|---|---|
| `.glb` / `.vrm` | **直接导入**,不重编码;`.vrm` = GLB + VRM 扩展 |
| `.gltf` | **直接导入**(分离的 `.bin` / 贴图不在同一文件里,会缺资源,建议随 zip 上传) |
| `.pmx` / `.fbx` | 服务端转换:优先 GLB、其次 VRM(见下) |
| `.zip` | **自动搜索包内可用模型**(见下) |
客户端(Godot)本来就用内置 `GLTFDocument` 加载 glTF/GLB/VRM
(`opendance-windows/src/model/vrm_loader.gd`),**无需改动**。服务端 humanoid 候选表
与客户端 `humanoid_map.gd` **逐字对齐**,由 `test/model-inspector.spec.ts` 断言两边一致。
## 压缩包自动搜索与转换
```
.zip ─▶ readZipEntries() ─▶ 自动搜索主模型 ─┬─ .vrm/.glb/.gltf ─▶ 直接导入
zip-reader.ts .vrm>.glb> │
.gltf>.pmx>.fbx └─ .pmx/.fbx ─▶ Assimp 转换
├─ 1) → .glb
├─ 2) → .gltf → gltf-packer → GLB
├─ 3) MODEL_CONVERT_FORMAT=vrm → vrm-writer
└─ 每步 assimp info 回读
```
| 环节 | 实现 | 规则 |
|---|---|---|
| ZIP 读取 | `common/zip-reader.ts` | 中央目录/EOCD、STORE(0)+DEFLATE(8)、ZIP64 大小字段、Shift-JIS/GBK 文件名回退;拒绝 zip-slip 与加密条目;不支持的压缩方式报错 |
| 选主模型 | `model-convert.ts` | 格式优先级 `.vrm` → `.glb` → `.gltf` → `.pmx` → `.fbx`;同一优先级内"`MODEL_CONVERT_PMX` 指名字(默认 `Hatsune Miku.pmx`,大小写不敏感)→ 目录最浅 → 文件最大" |
| 直接导入 | `importModelFile()` | `.vrm`/`.glb`/`.gltf` 原样入库;`.glb` 若含 `VRMC_vrm`/`VRM` 扩展会被识别为 VRM |
| 转换 | `convertModelFile()` / `convertModelArchive()` | `MODEL_CONVERT_FORMAT=glb`(默认)|`vrm`;超时 `MODEL_CONVERT_TIMEOUT_MS`(默认 180000) |
| 回读校验 | `exportAndValidate()` | 每步产物**必须** `assimp info` 回读;退出码 0 但产物损坏也判失败 |
| GLB 打包 | `gltf-packer.ts` | GLB 直出失败时导出 `.gltf`,把 `.bin` 与外部贴图自行合成单文件 GLB |
| VRM 封装 | `vrm-writer.ts` | `MODEL_CONVERT_FORMAT=vrm` 时只改 JSON chunk 补 `extensions.VRMC_vrm`;humanoid 骨骼不全 → **回退交付 GLB**,缺失骨骼写进说明 |
| 贴图内嵌 | `glb-textures.ts` | 把 GLB 中外部 URI 贴图追加 bufferView 并内嵌进 BIN chunk(4 字节对齐) |
| 溯源 | 迁移 `AddModelConversionFields1789450000000` | 压缩包原件存为 `model-archive` 资产 → `models.source_asset_id`;说明 → `models.conversion_note` |
| 失败 | `models.service.ts` | 无可用模型 / Assimp 不可用 / 两条路径都失败 → **HTTP 422**,不产生模型记录 |
| 文件名编码 | `common/upload.util.ts` `decodeUploadName()` | 修正 multipart 文件名的 latin1/UTF-8 乱码(中文文件名原先存成 `模å_…`) |
| DTO | `conversionNote` / `fileSha256` / `sourceSha256` / `stats` / `thumbnailUrl` | 契约 `data-models.md` §4、`rest-api.yaml` |
## 入库后自动:分析 + 缩略图(尽力而为,失败不影响入库)
| 项 | 实现 | 说明 |
|---|---|---|
| 分析 | `model-inspector.ts` → `models.stats` | 面数 / 顶点 / 网格 / 材质 / 贴图 / 节点 / 形变目标 / 动画 / 蒙皮骨骼数 / **骨骼树** / humanoid 覆盖 / **可驱动性(缺哪些必需骨骼)** / warnings;迁移 `AddModelStats1789460000000` 新增 `models.stats TEXT` |
| 缩略图 | `model-thumbnail.ts` → `models.thumbnail_asset_id` | 纯 TS 软件光栅化 512×512 PNG(读 GLB/glTF → 节点层级与蒙皮 → 包围盒构图 → 透视投影 → z-buffer 平面着色 → 手写 PNG),**零原生依赖**;失败或非 glTF 容器留空,前端退化为格式占位图 |
## 状态与默认模型(硬性)
- **上传一律 `status=pending`**(`models.service.ts` 固定 `PublishStatus.Pending`),
`is_primary=false`:**不再自动发布、不再自动设为默认**;服务端不再识别 `autoDefault`。
- 默认模型只在下列时机设置,并把身份写进源码 `src/config/default-model.ts`:
1. 审核通过带 `setPrimary:true`:`POST /api/reviews/model/:id/approve`;
2. 显式设置:`POST /api/models/:id/primary`(**仅 `published` 允许,否则 409**)。
- `GET /api/catalog/export` 的 `defaultModelId` 解析顺序(`resolveDefaultModelId()`):
```
0. 环境变量 DEFAULT_MODEL_ID 指定,且该模型存在且已发布
@@ -49,45 +111,30 @@ web-server/scripts/import-default-model.mjs
```
`POST /:id/primary` 在**同一事务**内先清零其余 `is_primary`,保证互斥。
第 0/1 步为本次(v0.3.0)新增:上传 MMD 压缩包转换成功后自动设为默认并写源码种子。
## 压缩包自动转换(`.zip` → FBX / GLB)
## VRM 版本识别
`POST /api/models` 的 `file` 接受 `.zip`,视为 MMD 模型包。链路:
`vrm-inspector.ts` 直接读取 GLB 头部 JSON chunk,不依赖完整 3D 库:
```
.zip ─▶ readZipEntries() ─▶ pickModelFile() ─▶ Assimp 导出 ─▶ assimp info 回读 ─▶ glb-textures 内嵌
zip-reader.ts 优先 MODEL_CONVERT_PMX FBX 优先 失败回退 GLB
```
- `extensions.VRMC_vrm` → VRM **1.0**(取 `specVersion`)
- `extensions.VRM` → VRM **0.x**
- 两者皆无 → `null`(普通 GLB)
| 环节 | 实现 | 规则 |
|---|---|---|
| ZIP 读取 | `common/zip-reader.ts` | 中央目录/EOCD、STORE(0)+DEFLATE(8)、ZIP64 大小字段、Shift-JIS/GBK 文件名回退;拒绝 zip-slip 与加密条目;不支持的压缩方式报错 |
| 选主模型 | `models/model-convert.ts` `pickModelFile()` | 优先 `MODEL_CONVERT_PMX`(默认 `Hatsune Miku.pmx`,大小写不敏感);否则目录最浅,其次文件最大;只认 `.pmx` |
| 转换 | `model-convert.ts` | `MODEL_CONVERT_FORMAT`(默认 `fbx`)→ `ASSIMP_BIN`;超时 `MODEL_CONVERT_TIMEOUT_MS`(默认 180000) |
| 回读校验 | `exportAndValidate()` | 导出后必须 `assimp info` 回读;退出码为 0 但产物损坏也判失败 |
| 回退 | `convertModelArchive()` | FBX 失败 → GLB;两次尝试写入 `models.conversion_note` |
| 贴图内嵌 | `models/glb-textures.ts` | 把 GLB 中外部 URI 贴图追加 bufferView 并内嵌进 BIN chunk(4 字节对齐) |
| 默认模型 | `models.service.ts` `createFromArchive()` | `autoDefault` 默认 true → `is_primary=1` + `status=published`,并写 `src/config/default-model.ts` |
| 溯源 | 迁移 `AddModelConversionFields1789450000000` | 压缩包原件存为 `model-archive` 资产 → `models.source_asset_id`;说明 → `models.conversion_note` |
| 失败 | `createFromArchive()` | 无 `.pmx` / Assimp 不可用 / 两种格式都失败 → **HTTP 422**,不产生模型记录 |
| 文件名编码 | `common/upload.util.ts` `decodeUploadName()` | 修正 multipart 文件名的 latin1/UTF-8 乱码(中文文件名原先存成 `模å_…`) |
| 管理端 | `admin/src/pages/Models.tsx` | `accept` 增加 `.zip`;提示会自动转换并设为默认;预览面板显示「转换来源」与「文件 sha256」 |
| DTO | `conversionNote` / `fileSha256` | 契约 `data-models.md` §4、`rest-api.yaml` |
## 实测(v0.2.0)
### 实测(Assimp 6.0.4)
- 上传 `示例文件/模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip`(1,093,100 字节)
→ **HTTP 201**、0.89s、`format=glb`、`isPrimary=true`、`status=published`。
- 服务端日志:FBX 导出失败(退出码 11,产物回读报
`FBX-DOM no Objects dictionary found`)→ 自动回退 GLB,回读通过。
- 入库产物:**1,152,464 字节、238 关节、2 张贴图已内嵌**,
`conversionNote` = `Hatsune Miku.pmx → GLB,已内嵌 2 张贴图`。
- 源码种子:id=16、`初音未来 Hatsune Miku`、`glb`、
sha256=`6d0ab0dce97c43dd7de84aba29b09f3b64f2953722d31b5421a39189ff2a8464`、
`convertedFrom` = `Hatsune Miku.pmx → GLB`。
- 默认模型解析:库里有 2 个 VRM 的情况下,`GET /api/catalog/export` 的
`defaultModelId` 仍为 **16**(源码种子按 sha256 命中)。
- 上传含 `Hatsune Miku.pmx` 的 MMD 压缩包 → **HTTP 201 / 0.86s**、`format=glb`、
`status=pending`、`conversionNote=Hatsune Miku.pmx → GLB,已内嵌 2 张贴图`。
分析结果:`vertices=7155 triangles=9071 materials=3 textures=2 images=2 nodes=240
bones=238 skins=1 morphTargets=0 humanoidMapped=0/22 drivable=false maxBoneDepth=23`;
`missingRequiredBones` 为**全部 16 根**(日文 MMD 骨名无法映射到 humanoid)。
- 直接上传 `.glb` → **HTTP 201 / 0.36s**、`conversionNote=直接导入(GLB,未做重编码)`,
入库文件 sha256 与源文件**一致**(不重编码)。
- 缩略图:512×512 PNG / 15,043 字节,服务端纯 TS 光栅化(含蒙皮),目视确认为居中的
T-pose 角色图;真实 10MB VRM(83 关节、5307 顶点)单张耗时 60–121ms(中位 79ms)。
- 审核:`POST /api/reviews/model/22/approve` 带 `setPrimary:true` → 200,
模型 `status=published`、`isPrimary=true`,源码种子写入 `sourceSha256=72eb6882…`;
重建后 `GET /api/catalog/export` 的 `defaultModelId` 由 VRM 变为 **22**
(库中另有 2 个已发布 VRM)。
### 运维 CLI:`web-server/scripts/import-default-model.mjs`
@@ -99,46 +146,43 @@ cd web-server
node scripts/import-default-model.mjs 模型.zip --url http://127.0.0.1:3000 \
--name "初音未来 Hatsune Miku" --author "Boku Wa Luka" \
--license "MMD 模型使用条款(见压缩包内说明)" --source-type other
# 上传 → 服务端转换入库 → 设为默认 → 写 src/config/default-model.ts
# 上传 → 服务端转换入库(pending)→ 把身份写进 src/config/default-model.ts
node scripts/import-default-model.mjs --write-source-only --model-id 16 # 只补写源码
node scripts/import-default-model.mjs --write-source-only --model-id 16 --dry-run
node scripts/import-default-model.mjs --write-source-only --model-id 22 # 只补写源码
node scripts/import-default-model.mjs --write-source-only --model-id 22 --dry-run
```
上传时固定带 `autoDefault=true`;`--url` 默认 `http://127.0.0.1:3000`,
`--source-type` 默认 `other`。
## VRM 版本识别
`vrm-inspector.ts` 直接读取 GLB 头部 JSON chunk,不依赖完整 3D 库:
- `extensions.VRMC_vrm` → VRM **1.0**(取 `specVersion`)
- `extensions.VRM` → VRM **0.x**
- 两者皆无 → `null`(普通 GLB)
上传一律 pending,因此要让 CLI 写出的种子在 `defaultModelId` 中生效,仍需审核通过
(勾选「同时设为全局默认模型」)或调用 `POST /api/models/:id/primary`;
`--url` 默认 `http://127.0.0.1:3000`,`--source-type` 默认 `other`。
## 验收
```bash
cd web-server
npm run test # 单元测试 125 项 / 7 个文件
npx vitest run # 10 个文件 / 168 项通过
npm run test:smoke # 冒烟 [4] 段落
```
| 验收项 | 状态 |
|---|---|
| 上传 `.zip` 自动转换入库 | ✅ 实测 HTTP 201、0.89s、`format=glb`、`isPrimary=true`、`status=published` |
| FBX 失败自动回退 GLB | ✅ Assimp 6.0.4 退出码 11 + 回读失败 → 回退 GLB,`conversionNote` 记录两次尝试 |
| GLB 贴图内嵌 | ✅ 2 张贴图内嵌进 BIN chunk(产物 1,152,464 字节、238 关节) |
| 默认模型写入源码并按种子解析 | ✅ 种子 id=16;库里有 2 个 VRM 时 `defaultModelId` 仍为 16 |
| 失败语义 | ✅ 无 `.pmx` / Assimp 不可用 / 两格式均失败 → HTTP 422,不产生模型记录 |
| ZIP 安全 | ✅ 拒绝 zip-slip、加密条目、未知压缩方式(`model-archive.spec.ts`) |
| 文件名编码修复 | ✅ `decodeUploadName()` 修正中文文件名乱码 |
| `.glb`/`.vrm`/`.gltf` 直接导入不重编码 | ✅ 实测 GLB 上传 201、`conversionNote=直接导入(GLB,未做重编码)`、入库 sha256 与源文件一致 |
| `.zip` 自动搜索包内模型 | ✅ 优先级 `.vrm`>`glb`>`.gltf`>`.pmx`>`.fbx`;同优先级按指名字→目录最浅→文件最大 |
| `.pmx` 转换优先 GLB | ✅ 实测 201 / 0.86s、`format=glb`、2 张贴图内嵌 |
| GLB 失败回退 glTF→GLB 打包 | ✅ GLB 直出失败时 `gltf-packer.ts` 自行打包;每步 `assimp info` 回读校验 |
| `MODEL_CONVERT_FORMAT=vrm` 封装 | ✅ `vrm-writer.ts` 补 `VRMC_vrm`;humanoid 不全回退 GLB |
| 自动分析写入 `models.stats` | ✅ 实测 `triangles=9071 bones=238 humanoidMapped=0/22 drivable=false` |
| 缩略图生成且不影响入库 | ✅ 512×512 / 15,043 B;失败只告警 |
| 上传一律 pending、不自动设默认 | ✅ `create()` 固定 `PublishStatus.Pending` + `isPrimary=false` |
| `setPrimary` 审核通过时生效 | ✅ `POST /api/reviews/model/22/approve` + `setPrimary:true` → 200、`isPrimary=true` |
| 非 published 设默认 | ✅ `POST /api/models/:id/primary` → 409 |
| 失败语义 | ✅ 无可用模型 / Assimp 不可用 / 两条路径均失败 → HTTP 422,不产生模型记录 |
| ZIP 安全 | ✅ 拒绝 zip-slip、加密条目、未知压缩方式(`model-archive.spec.ts` 24 项) |
| humanoid 与客户端一致 | ✅ `model-inspector.spec.ts` 抽 `humanoid_map.gd` 逐键比对(25 项) |
| VRM 版本识别 | ✅ `test/vrm-inspector.spec.ts`(6 例) |
`test/model-archive.spec.ts`(**22 项**)覆盖:ZIP 解析 / zip-slip / 加密 / 未知压缩
方式、主模型挑选规则、GLB 贴图内嵌、用**假 Assimp** 对照「FBX 导出失败」与
「退出码 0 但产物损坏」两种回退路径、源码渲染 + `tsc` 语法校验。
`test/vrm-inspector.spec.ts`(6 例)覆盖:真实 GLB 构造下识别 1.0 / 0.0 / null、
文本 glTF 不误判、过小文件报错、非 JSON 首 chunk 报错、上传 201、`isPrimary`
置位、缺来源 400、按格式过滤列表、列表含来源信息(管理端展示用)。
`test/model-archive.spec.ts`(**24 项**)覆盖 ZIP 解析 / zip-slip / 加密 / 未知压缩方式、
主模型挑选优先级、GLB 贴图内嵌、用**假 Assimp** 对照多种回退路径、源码渲染 + `tsc` 校验。
`test/model-inspector.spec.ts`(**25 项**)覆盖 GLB 统计与骨骼树、VRM 识别、必需 16 根
humanoid 骨、非 glTF 容器降级、截断与健壮性、与客户端映射表逐字一致。
`test/model-thumbnail.spec.ts`(**8 项**)覆盖缩略图 PNG 结构、蒙皮与降级路径。
+53 -4
View File
@@ -1,21 +1,24 @@
# T-WEB-007 上传审核流程
- **状态**:done
- **状态**:done(v0.2.0 追加:模型审核面板 / 骨骼详细界面 / `setPrimary`)
- **依赖**:T-WEB-003 / 004 / 005
## 目标
对歌曲、动作、模型三类资产实施统一的状态机与审核流水,确保只有审核通过的资产
被客户端消费。
被客户端消费。模型上传**一律 `pending`**,审核是模型可见与成为默认模型的唯一入口。
## load
```
DSH.md
contracts/rest-api.yaml # 锚点:#/paths/~1api~1reviews
contracts/data-models.md # 锚点:§1.2 PublishStatus、§5 ReviewRecord
contracts/data-models.md # 锚点:§1.2 PublishStatus、§4.1 默认模型选择规则、§5 ReviewRecord
contracts/database.md # 锚点:状态机
web-server/src/reviews/*
web-server/src/models/model-inspector.ts
web-server/admin/src/components/ModelReviewPanel.tsx
web-server/admin/src/lib/bone-tree.ts
```
## 状态机(契约冻结)
@@ -31,7 +34,7 @@ draft ──submit──▶ pending ──approve──▶ published
| GET | `/api/reviews` | 不传 `status` → 审核流水;传 `status` → 该状态待审资产(三类合并) |
| GET | `/api/reviews/:targetType/:id/history` | 单个资产的审核历史 |
| POST | `/api/reviews/:targetType/:id/submit` | → `pending` |
| POST | `/api/reviews/:targetType/:id/approve` | → `published` |
| POST | `/api/reviews/:targetType/:id/approve` | → `published`;**模型**可带 `setPrimary:true` |
| POST | `/api/reviews/:targetType/:id/reject` | → `rejected`,**必须填写意见** |
| POST | `/api/reviews/:targetType/:id/withdraw` | → `draft` |
@@ -44,12 +47,58 @@ draft ──submit──▶ pending ──approve──▶ published
`reviewer` / `comment`。
- 三类资产的直连 PATCH 也会校验状态机,避免绕过审核流程。
- `/api/catalog/export` 与 `/api/catalog/version` 只导出 `published`。
- **模型上传只能 pending**:`ModelsService.create()` 固定
`status=PublishStatus.Pending` + `isPrimary=false`,请求体里的 `autoDefault`
等旁路一律不生效(硬性约束,见 DSH.md §6.9)。
## 模型审核面板(v0.2.0)
待审列表只有 `PendingItem`(不含模型详情),面板用 `item.targetId` 现拉
`GET /api/models/:id`(`admin/src/components/ModelReviewPanel.tsx`):
| 区域 | 内容 |
|---|---|
| 基本 | 缩略图、格式、状态、文件地址、转换说明 |
| 指标 | 体积(`fileSize`)/ 格式 / 容器 / VRM 版本 / 面数 / 顶点 / 网格 / 材质 / 贴图 / 骨骼 / 节点 / 形变目标 / 动画 / 图元 / 蒙皮 / 骨骼最大层级 |
| 可驱动性 | 结论横幅:16 根必需 humanoid 骨齐全 → 可驱动;不全 → 列出缺哪些(直接标红);非 glTF 容器 → 只能审核来源与体积 |
| humanoid 覆盖 | 已映射 / 总数(22)+ 进度条 + 已命中规范名徽章明细 |
| warnings | `ModelStats.warnings` 逐条列出 |
| 骨骼详细界面 | 扁平骨骼树按 `depth` 缩进(14px/层),显示骨骼名、是否蒙皮徽章、humanoid 映射徽章、子节点数;支持按名搜索(保留祖先链)、「仅显示蒙皮骨骼」开关、限高滚动 + **只渲染前 400 行**(超出提示还有多少根),千根骨骼不卡 |
| stats 为空 | 提示"暂无分析数据(旧数据或后端尚未完成分析)",只能按格式/体积/来源判断 |
### 通过 / 驳回 / 设为默认
- 面板底部勾选「同时设为全局默认模型」→ 点「通过」时请求体带 `setPrimary:true`:
`ReviewsService.transition()` 保存模型为 `published` 后调用
`ModelsService.setPrimary(id)`(同一事务清其余 `is_primary`),并把身份写进源码
`src/config/default-model.ts`。
- 单独设置走 `POST /api/models/:id/primary`:**仅 `published` 允许,否则 409**。
- 驳回必须填意见(400)。
### 骨骼树过滤(`admin/src/lib/bone-tree.ts`,纯函数、可单测)
- `filterBoneTree(tree, keyword, onlySkin)`:先按关键字(大小写不敏感)与 `onlySkin`
取命中集合,再沿父链补齐**祖先链**,保证按 `depth` 缩进的树不断裂;空关键字 +
非 `onlySkin` 时直接返回副本。
- `boneTreeStats(tree)`:总数 / 蒙皮数 / 已映射 humanoid 数 / 最大层级。
## 验收
```bash
cd web-server
npx vitest run # 10 个文件 / 168 项通过(含 admin-bone-filter 8 项)
npm run test:smoke
```
冒烟 [5] 段落:submit → pending、待审列表包含、驳回无意见 400、approve → published、
重复审核 409、流水恰好 2 条。
| 验收项 | 状态 |
|---|---|
| 模型上传只能是 pending | ✅ `create()` 固定 `PublishStatus.Pending`,不自动发布/设默认 |
| 模型审核面板展示指标 + 分析 | ✅ 体积/格式/容器/VRM 版本/面数/顶点/网格/材质/贴图/骨骼/节点/形变目标/动画 |
| 骨骼详细界面与过滤 | ✅ 缩进 + 蒙皮/映射徽章 + 搜索保留祖先链 + 仅蒙皮开关 + 限高 + 前 400 行(`admin-bone-filter.spec.ts` 8 项) |
| `setPrimary` 随审核通过生效 | ✅ `POST /api/reviews/model/22/approve` + `setPrimary:true` → 200、`status=published`、`isPrimary=true`,源码种子写入 `sourceSha256=72eb6882…` |
| 单独设默认仅 published | ✅ `POST /api/models/:id/primary` 非 published → 409 |
| 驳回必须填意见 | ✅ 无 `comment` → 400 |
| 目录重建后默认模型 | ✅ `GET /api/catalog/export` 的 `defaultModelId` 由 VRM 变为 **22**(库中另有 2 个已发布 VRM) |
+22 -3
View File
@@ -178,8 +178,19 @@ install.sh 检测到缺 deploy/.env
`ReadWritePaths=/var/lib/opendance`,进程只能写自己的存储目录
- **上传上限两侧一致**:后端 `UPLOAD_MAX_BYTES=536870912`(512 MiB),
反代侧 `client_max_body_size` / `maxAllowedContentLength` 必须不小于此值
- **模型转换配置**(`deploy/.env.example` 已写入):
- `ASSIMP_BIN=assimp`:上传 `.zip` 模型包或 `.pmx`/`.fbx` 单文件时**必须**能调用到,
否则转换失败 **422**
- `MODEL_CONVERT_FORMAT=glb`(默认)|`vrm`:只影响需要转换的 PMX/FBX 的交付格式。
`glb` 通用,两个客户端都原生加载;`vrm` 在 GLB 之上补 `VRMC_vrm` 扩展
(humanoid 骨骼 / 表情语义),**humanoid 骨骼名映射不全时自动回退交付 GLB**
并写进 `models.conversion_note`。产物一律 `assimp info` 回读校验
- `MODEL_CONVERT_PMX` / `MODEL_CONVERT_TIMEOUT_MS` 语义见 README §四
- **迁移用编译产物**:`web-server/scripts/migrate.mjs` 检测到 `dist/` 时走
`typeorm -d dist/db/data-source.js`,无需在服务器上装 devDependencies
`typeorm -d dist/db/data-source.js`,无需在服务器上装 devDependencies;
本版本迁移共 3 个:`InitSchema1789382903060`、
`AddModelConversionFields1789450000000`、**`AddModelStats1789460000000`**
(新增 `models.stats TEXT`,存模型分析结果,已在真实库执行)
- **依赖安装显式带 dev**:安装脚本先 `set -a; . deploy/.env` 导出了
`NODE_ENV=production`,会让 npm 跳过 devDependencies 导致 `tsc: not found`;
故用 `env -u NODE_ENV npm ci --include=dev`
@@ -202,7 +213,8 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200
|---|---|
| `deploy/install.sh` 脚本语法与幂等性 | ✅ `bash -n` 通过;重复执行会覆盖程序文件、保留 `.env` |
| 连接现有 MySQL 成功 | ✅ 已用真实实例验证(`/health` 200,`database.connected=true`) |
| TypeORM migration 执行成功 | ✅ 5 张业务表已创建并验证 |
| TypeORM migration 执行成功 | ✅ 5 张业务表已创建并验证;v0.2.0 的 `AddModelStats1789460000000`(`models.stats TEXT`)已在真实库执行 |
| 模型转换配置 | ✅ `deploy/.env.example` 写入 `ASSIMP_BIN` / `MODEL_CONVERT_FORMAT=glb|vrm` / `MODEL_CONVERT_PMX` / `MODEL_CONVERT_TIMEOUT_MS`;不合规输入转换失败 422 |
| `curl http://<host>:3000/health` 通过 | ✅ 直连后端端口已验证 |
| 软件 API 端口 `/` 返回 404 | ✅ `PORT=3000` 上 `/`、`/songs` 均 404(有意设计,无管理页面) |
| 管理端端口 `/` 返回 200 | ✅ `ADMIN_PORT=3001` 上 `/`、`/songs`、`/models` 均返回同一份 HTML(200) |
@@ -236,7 +248,7 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200
```bash
./scripts/package-release.sh
# → dist-release/opendance-web-v0.1.0.tar.gz
# → dist-release/opendance-web-v0.2.0.tar.gz
```
内容:`web-server/`(源码 + dist)、`admin/`(构建产物)、`deploy/`
@@ -244,3 +256,10 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200
打包脚本会对产物做硬校验:`src/**/*.ts` 必须都有对应 `dist/*.js`,
关键文件(`dist/main.js`、`dist/storage/storage.module.js`、`admin/index.html`、
`deploy/proxy/*`)必须齐全,且不得含真实 `.env` 与 Docker/nginx 安装配置。
v0.2.0 起校验清单追加模型新模块:`dist/models/model-inspector.js`、
`dist/models/model-thumbnail.js`、`dist/models/vrm-writer.js`、
`dist/models/gltf-packer.js`、`dist/migrations/1789460000000-AddModelStats.js`。
> **发布包名变更**:`opendance-web-v0.1.0.tar.gz` → **`opendance-web-v0.2.0.tar.gz`**
> (`scripts/package-release.sh` 的 `VERSION` 默认值已改为 `0.2.0`,
> 与 `project.yaml` 的 `version: 0.2.0`、`web-server/package.json` 一致)。