需求 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 实测可逆且数据无损恢复
7.7 KiB
7.7 KiB
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)。
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)
验收
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、按格式过滤列表、列表含来源信息(管理端展示用)。