实测发现:同一份 zip 连续转换三次得到三个不同的产物 sha256(大小都是 1,152,464 字节,差异集中在 BIN chunk 的十几字节),说明 Assimp 的 GLB 导出不是字节级确定的。 因此原先把「转换产物 sha256」当作默认模型匹配键的设计是错的——换一次转换就失效。 - default-model.ts / default-model-source.ts:种子拆成 sourceSha256(上传压缩包的 sha256,稳定,主匹配键)与 fileSha256(产物 sha256,仅作记录) - models.service.ts:resolveDefaultModelId 依次按 压缩包 sha256 → 产物 sha256 → 名称 匹配,都落空才回落到 VRM 优先规则并告警 - ModelDto 增加 sourceSha256(非压缩包上传为 null) - scripts/import-default-model.mjs:写种子时用 sourceSha256;顺带修掉位置参数解析 (文件当第一个参数时识别不出,会打印用法) - 契约与文档同步:data-models §4.1、model-formats §1.1 记录该确定性结论; README / DSH / tasks/005 的「按产物 sha256 匹配」表述已更正 另附:把模型格式能力的实测矩阵写进 Release 说明(原生只支持 glTF 系; PMX→FBX 失败;VRM→GLB 丢 VRM 扩展;humanoid 骨骼命名覆盖率)。 验证:125 项单测通过;清理掉两个"文件只在本机临时目录"的模型(避免 sha256 去重 导致后续上传不落盘);种子按压缩包 sha256 命中后 defaultModelId 正确解析。
145 lines
7.7 KiB
Markdown
145 lines
7.7 KiB
Markdown
# T-WEB-005 模型上传 + VRM 优先 + 来源字段
|
||
|
||
- **状态**:done
|
||
- **依赖**:T-WEB-002
|
||
|
||
## 目标
|
||
|
||
上传角色模型,**VRM 优先**,识别 VRM 版本,并维护全局默认模型。
|
||
|
||
## load
|
||
|
||
```
|
||
DSH.md
|
||
contracts/rest-api.yaml # 锚点:#/paths/~1api~1models
|
||
contracts/data-models.md # 锚点:§4 Model、§4.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-convert.ts
|
||
web-server/src/models/glb-textures.ts
|
||
web-server/src/models/default-model-source.ts
|
||
web-server/src/config/default-model.ts
|
||
web-server/src/migrations/1789450000000-AddModelConversionFields.ts
|
||
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` | 详情 |
|
||
| PATCH | `/api/models/:id` | 更新 |
|
||
| DELETE | `/api/models/:id` | 软删除 |
|
||
| POST | `/api/models/:id/primary` | 设为全局默认模型 |
|
||
|
||
## 默认模型选择规则(`contracts/data-models.md` §4.1,已实现于 `resolveDefaultModelId()`)
|
||
|
||
```
|
||
0. 环境变量 DEFAULT_MODEL_ID 指定,且该模型存在且已发布
|
||
1. 源码种子 src/config/default-model.ts 的 DEFAULT_MODEL_SEED(按**上传压缩包**的 sha256 匹配;
|
||
转换产物 sha256 每次不同,只作记录)
|
||
2. format='vrm' 且 published 且 is_primary=1
|
||
3. format='vrm' 且 published,按 id 升序取第一个
|
||
4. published,按 (format='vrm' DESC, id ASC) 取第一个
|
||
5. 无 → 客户端使用内置占位模型
|
||
```
|
||
|
||
`POST /:id/primary` 在**同一事务**内先清零其余 `is_primary`,保证互斥。
|
||
第 0/1 步为本次(v0.3.0)新增:上传 MMD 压缩包转换成功后自动设为默认并写源码种子。
|
||
|
||
## 压缩包自动转换(`.zip` → FBX / GLB)
|
||
|
||
`POST /api/models` 的 `file` 接受 `.zip`,视为 MMD 模型包。链路:
|
||
|
||
```
|
||
.zip ─▶ readZipEntries() ─▶ pickModelFile() ─▶ Assimp 导出 ─▶ assimp info 回读 ─▶ glb-textures 内嵌
|
||
zip-reader.ts 优先 MODEL_CONVERT_PMX FBX 优先 失败回退 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` |
|
||
|
||
### 实测(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 命中)。
|
||
|
||
### 运维 CLI:`web-server/scripts/import-default-model.mjs`
|
||
|
||
服务进程在 systemd 加固(`ProtectSystem=strict`)下写不了程序目录,源码种子
|
||
通常由本脚本在沙箱外补写。需先 `npm run build`(复用 `dist/models/default-model-source.js`)。
|
||
|
||
```bash
|
||
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
|
||
|
||
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
|
||
```
|
||
|
||
上传时固定带 `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)
|
||
|
||
## 验收
|
||
|
||
```bash
|
||
cd web-server
|
||
npm run test # 单元测试 125 项 / 7 个文件
|
||
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()` 修正中文文件名乱码 |
|
||
| 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、按格式过滤列表、列表含来源信息(管理端展示用)。
|