Files
opendance-web/tasks/005-model-upload.md
T
zijunchiang e4351c5062 feat(models): 模型压缩包自动转换 + 默认模型写进源码;首次启动 .env 引导;HOST 默认 0.0.0.0
需求 1:模型上传支持压缩包(MMD 模型包)
- common/zip-reader.ts:纯 TS 极简 ZIP 读取器(EOCD/中央目录、STORE+DEFLATE、
  ZIP64 大小字段、Shift-JIS/GBK 文件名回退、拒绝 zip-slip 与加密条目)
- models/model-convert.ts:解压 → 选主模型(指名字/最浅/最大)→ Assimp 导出 FBX
  → **assimp info 回读校验**(Assimp 对带蒙皮大骨架模型会写出损坏文件:
  实测 714 骨骼 PMX 导出退出码 11、产物回读报 FBX-DOM no Objects dictionary found)
  → 失败自动回退 GLB;两种都失败则 422
- models/glb-textures.ts:把 Assimp 写成外部 URI 的贴图内嵌进 GLB 的 BIN chunk,
  保证模型库里单文件即可加载(否则是白模)
- 溯源:压缩包原件存为 model-archive 资产;models 新增 source_asset_id /
  conversion_note(迁移 1789450000000-AddModelConversionFields,已在真实库上
  验证 up/down 可逆)
- 默认模型:转换成功且 autoDefault 不为 false → is_primary=1 + published,
  并把身份(名称/格式/产物 sha256)写进源码 src/config/default-model.ts;
  resolveDefaultModelId 增加 DEFAULT_MODEL_ID 与源码种子两步(按 sha256 匹配,
  换库仍有效),实测库中有 2 个 VRM 时默认模型仍解析为该 GLB
- scripts/import-default-model.mjs:运维 CLI(上传 zip 并写源码 / --write-source-only)
- 修复 multipart 中文文件名乱码(upload.util.decodeUploadName)
- DTO/契约新增 conversionNote、fileSha256;管理端 accept 增加 .zip 并展示转换来源

需求 2:首次启动 .env 引导
- deploy/init-env.sh:交互式逐项填写 8 个键(回车保留默认、口令不回显不加引号、
  端口校验、mysql2 连通性自检且口令只经环境变量传递、只改这 8 个键并保留模板注释、
  chmod 600、结尾打码摘要);--force/--check/-y/-i/--skip-db-check 等开关;
  非交互(-y、OPENDANCE_NONINTERACTIVE、stdin 关闭)绝不提问、读取带超时
- install.sh 缺 .env 时改为调用引导,失败才 die

需求 3:默认监听 0.0.0.0
- 代码默认值、两份 .env 模板、install.sh、init-env.sh 五处一致,并新增测试守护
  (未设置 HOST 时为 0.0.0.0;两份模板都写着 HOST=0.0.0.0)

契约:rest-api / data-models / model-formats → 0.3.0,database → 0.2.0(另两库已同步)

验证:125 项单测通过(7 文件);契约 6 项与 29 条路由一致;typecheck/build 通过;
真实包端到端 HTTP 201 / 0.89s / format=glb / isPrimary=true / published;
GLB 1,152,464 字节、1 mesh / 240 nodes / 1 skin(238 joints) / 2 张内嵌贴图;
迁移 revert→run 实测可逆且数据无损恢复
2026-09-15 13:55:54 +08:00

144 lines
7.7 KiB
Markdown
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.
# 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 匹配)
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、按格式过滤列表、列表含来源信息(管理端展示用)。