Files
opendance-web/README.md
T
opendance ea58520fc2 fix(deploy): .env 中带空格的值未加引号导致 install.sh 以 127 失败
现象:
  ./deploy/.env: line 53: Miku.pmx: command not found
  [install] 第 53 行执行失败(退出码 127)

根因:deploy/.env.example 的 MODEL_CONVERT_PMX=Hatsune Miku.pmx 值含空格
却没加引号,install.sh 用 `set -a; . deploy/.env` 载入时被切成
「赋值 + 命令」,Miku.pmx 被当成命令执行。

修复:
1. 模板改为 MODEL_CONVERT_PMX="Hatsune Miku.pmx"(占位符值同样加引号);
2. 新增 deploy/env-lib.sh:.env 语法预检,install.sh / ctl.sh 在 source
   之前检查,报错给出「哪一行 / 原文 / 改法」, 与反引号建议单引号;
3. install.sh 的 ERR trap 补上出错文件名(lineno 属于被 source 的 .env,
   只报行号无法定位);
4. 新增 test/env-file.spec.ts 12 项回归测试,锁住「模板可被 shell 原样载入」;
5. 版本升到 0.2.1,README(.md/.txt)/DSH.md 补 .env 写法约定、FAQ 与测试数量。
2026-09-15 22:26:58 +08:00

540 lines
32 KiB
Markdown

# opendance-web
OpenDance Studio **阶段一**: Web 管理服务端(NestJS + TypeORM + 现有 MySQL 实例)。
管理歌曲、VRM 模型与动作来源,是 Windows 客户端与 Android 辅助 App 的唯一数据源与**契约权威**。
> **本服务端不部署、不管理 MySQL。** 数据库为部署方提供的现有实例,连接信息通过 `.env` 注入。
>
> **本服务端也不部署反向代理。** 只提供业务服务(API + 自带管理端页面),反代由另外的服务/团队负责,本仓库仅在 `deploy/proxy/` 下提供配置说明文档。
## 状态
**阶段一已完成**: T-WEB-001 -> T-WEB-010 全部落地,当前版本 **v0.2.1**(契约 `rest-api / data-models / model-formats 0.4.0`、`database 0.3.0`)。
v0.2.1 修订:
1. **修复部署失败**:`deploy/.env.example` 里 `MODEL_CONVERT_PMX=Hatsune Miku.pmx` 的值含空格却没加引号,`install.sh` 用 `set -a; . deploy/.env` 载入时把 `Miku.pmx` 当命令执行,报 `command not found`(退出码 127);模板改为带引号写法;
2. **新增 `.env` 语法预检**(`deploy/env-lib.sh`):`install.sh` 与 `ctl.sh` 在 source 之前先检查,直接给出哪一行、原文、改法,不再出现看不懂的 127 报错;
3. 安装失败提示带上出错文件名(`.env` 被 source 时行号属于 `.env`,只报行号无法定位);
4. 新增 12 项回归测试(`test/env-file.spec.ts`),锁住「模板可被 shell 原样载入」这条线。
v0.2.0 主要变更:
1. **GLB / VRM 确定为模型主力格式**,服务端与客户端分别落实支持库;
2. **模型管理**:`.glb/.vrm` 直接导入;`.pmx/.fbx` 服务端**优先转 GLB、其次 VRM**;压缩包**自动搜索**包内可用模型;入库自动生成**缩略图**与**模型分析**;
3. **模型审核**:上传一律 `pending`,审核界面含**骨骼详细预览**(后期评分依据);
4. 新增**历史模型回填**(`POST /api/models/:id/reanalyze` + `scripts/reanalyze-models.mjs`);
5. **管理端口与软件 API 端口分开**(`PORT` / `ADMIN_PORT`),动作数据支持 **VMD**,模型支持压缩包**转换导入**。
## 部署到 Linux 服务器(业务服务,不含反向代理)
### 1. 初次部署(从零跑起来)
按顺序做,每步都有验证方法:
1. 准备一台 Linux 服务器,**以 root 登录**(不需要 sudo),装好 Node.js 22+
```bash
node -v # 期望 v22 或更高
```
2. 准备一个现有的 MySQL / MariaDB 实例,并建好库与账号(见下方[前置要求](#前置要求))
本项目**不会创建数据库本身**
3. 取得发布包并校验(地址用你们自己的制品库替换; 下面是本仓库的发布地址)
```bash
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.2.1/opendance-web-v0.2.1.tar.gz
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.2.1/SHA256SUMS
sha256sum -c SHA256SUMS # 期望输出 "OK"
```
4. 解压并生成配置(交互式引导,逐项问答,口令不回显)
```bash
tar -xzf opendance-web-v0.2.1.tar.gz && cd opendance-web-v0.2.1
bash deploy/init-env.sh
```
5. 安装(建用户与目录 -> 装依赖 -> 迁移 -> 注册系统服务 -> 等健康检查)
```bash
bash deploy/install.sh
```
6. 验证(把 `<服务器地址>` 换成实际地址)
```bash
curl -fsS http://<服务器地址>:3000/health # 期望 {"status":"ok",...}
curl -sI http://<服务器地址>:3001/ | head -1 # 期望 200,管理端页面
curl -sI http://<服务器地址>:3000/ | head -1 # 期望 404,API 端口不提供管理页
```
7. 可选:回填历史模型的分析与缩略图(新装可跳过)
```bash
cd /opt/opendance/web-server
node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000
```
8. 浏览器打开 `http://<服务器地址>:3001/` 进入管理端,上传曲目/动作/模型并审核
模型上传后状态为**待审核**,审核通过才会被客户端拉取
**安全约定**:`deploy/.env` 已被 `.gitignore` 忽略,不要提交;文档、截图、工单里也不要贴真实的数据库口令、内网地址或域名,需要时用 `<占位符>` 代替。
### 2. 职责边界
本端只提供业务服务:NestJS API + 管理端页面。反向代理由另外的服务/团队负责,本项目不安装、不配置任何反代软件,只在 `deploy/proxy/` 下提供 IIS 与 nginx 的配置说明文档。
### 3. 部署形态
Node 业务服务由系统服务托管,**同时监听两个端口**,职责分开:
| 端口 | 默认 | 提供内容 |
| --- | --- | --- |
| 软件 API 端口(给 Windows/Godot 客户端与 Android App) | 3000 | `/api/` REST API、`/health` 健康检查、`/static/` 上传文件、`/contracts/` 契约快照;该端口**不提供管理页面**,`/` 与 `/songs` 等一律返回 404(有意设计) |
| 管理端端口(给浏览器) | 3001 | 管理页面(本服务自带,含 SPA 深链接回退)+ 上面全部接口 |
反向代理按用途分别转发两个端口,每个端口各一条全匹配转发规则即可。
### 4. 前置要求
- Linux 服务器,有 **root** 权限(不需要 sudo,直接以 root 执行即可)
- **Node.js 22** 或更高版本
- 进程管理:`systemd` / `OpenRC` / `SysVinit` 均可;三者都没有也能装(自动降级为 `nohup` 后台进程 + PID 文件 + crontab `@reboot` 开机自启)
- 反向代理:由另外的服务提供,本项目不要求、也不安装
- 一个可连通的现有 MySQL / MariaDB 实例,并已创建数据库与账号:
```sql
CREATE DATABASE IF NOT EXISTS opendance
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS 'opendance_app'@'%' IDENTIFIED BY '<数据库口令>';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES
ON opendance.* TO 'opendance_app'@'%';
FLUSH PRIVILEGES;
```
本项目不会创建数据库本身,也不需要 `GRANT OPTION` / `FILE` / `SUPER` 等管理权限。
### 5. 部署步骤
```bash
tar -xzf opendance-web-v0.2.1.tar.gz
cd opendance-web-v0.2.1
bash deploy/init-env.sh # 首次启动引导: 逐项问答生成 deploy/.env(见下)
bash deploy/install.sh # 已以 root 登录, 不需要 sudo
```
也可跳过引导手工填写:
```bash
cp deploy/.env.example deploy/.env
vi deploy/.env # 填写 DB_HOST / DB_PORT / DB_USER / DB_PASS / DB_NAME
```
脚本会依次:校验环境与 `.env` -> 创建服务用户与目录 -> 复制程序文件 -> 安装运行期依赖 -> 执行 TypeORM 迁移 -> 注册并启动系统服务(systemd / OpenRC / SysVinit / 后台进程)-> 等待健康检查。
### 6. `deploy/.env` 初始化引导(deploy/init-env.sh)
缺 `deploy/.env` 时,`install.sh` 会先调用本脚本引导填写;引导失败才中止安装(若安装目录里已有上次安装留下的 `.env`,则直接复用,不会重复提问)。也可以单独运行:逐项询问数据库与端口,回车保留默认值,写盘前做必填项与端口范围校验,并可用项目自带的 `mysql2` 做一次数据库连通性自检 —— 口令只经环境变量传给子进程,出错信息与摘要一律显示 `*`。
```bash
bash deploy/init-env.sh # 交互引导(终端里逐项询问)
bash deploy/init-env.sh --force # 已有 .env 也重新逐项询问
bash deploy/init-env.sh --check # 只校验, 不改动、不提问(适合复核)
```
逐项询问的字段(共 8 个):`DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASS` / `DB_NAME` / `PORT` / `ADMIN_PORT` / `HOST`。
`DB_PASS` 用 `read -s` 不回显;所有字段按约定写成 `键=值`(不加引号),因此口令请勿含会破坏 shell 语法的字符(`& $ ` \ 空格 ; | < > () !`),脚本检测到会告警。写回只替换或追加这 8 个键、保留模板注释,写完 `chmod 600` 并回读校验;结尾打印摘要(口令 `*`)与本次变更列表。
开关:
| 开关 | 说明 |
| --- | --- |
| `--force` | 已有 `.env` 也重新逐项询问 |
| `--check` | 只校验,不修改文件、不提问 |
| `-y`, `--yes`, `--non-interactive` | 非交互:只校验缺字段与端口,绝不提问 |
| `-i`, `--interactive` | 即使 stdin 不是 TTY 也强制提问(读取超时仍生效) |
| `--skip-db-check` | 跳过数据库连通性自检 |
| `--dir DIR` | 等价于 `--env-file DIR/.env --example DIR/.env.example` |
| `--env-file PATH` | 指定 `.env` 路径(默认脚本目录下的 `.env`) |
| `--example PATH` | 指定模板路径(默认 `.env` 同目录的 `.env.example`) |
| `--timeout SECONDS` | 非 TTY 下每次读取的超时秒数(默认 10) |
| `-h`, `--help` | 显示帮助 |
环境变量:`OPENDANCE_NONINTERACTIVE` / `OPENDANCE_INTERACTIVE` / `OPENDANCE_SKIP_DB_CHECK` / `OPENDANCE_ENV_FILE` / `OPENDANCE_ENV_EXAMPLE` / `OPENDANCE_PROMPT_TIMEOUT`。
非交互绝不挂住:stdin 非 TTY 且是 `</dev/null` 或已关闭,或带 `-y/--yes/--non-interactive` 时只校验不提问;所有 `read` 都带超时,EOF 或超时立即 `exit 1`,不会卡在 CI 或自动化里。
### 7. 部署完成后(直接访问后端端口,`<host>` 为服务器 IP)
| 入口 | 地址 |
| --- | --- |
| 客户端接口 | `http://<host>:3000/api` |
| 健康检查 | `http://<host>:3000/health` |
| 契约快照 | `http://<host>:3000/contracts/` |
| 管理端首页 | `http://<host>:3001/` |
软件 API 端口上没有管理页面:`http://<host>:3000/` 返回 **404**,属正常。
### 8. 对外访问
由反向代理按用途分别转发两个端口:
```text
浏览器管理界面 -> http://<后端地址>:3001
Windows/Godot 客户端与 Android App -> http://<后端地址>:3000
```
配置样例与说明(含两个端口的转发写法):
| 文件 | 用途 |
| --- | --- |
| `deploy/proxy/nginx.conf.example` | nginx 站点配置 |
| `deploy/proxy/web.config.example` | IIS(ARR + URL Rewrite)配置 |
| `deploy/proxy/README.md` | 完整说明、自检清单与常见故障 |
直连后端时,防火墙需**同时放行两个端口**(默认 3000 与 3001)。
外部访问不了?安装脚本结尾会直接给出结论(监听地址、对外可达性、防火墙提示);也可以在服务器上跑一次自检脚本(只读,不改配置):
```bash
bash deploy/check-access.sh # 或 bash deploy/check-access.sh /opt/opendance
```
它会打印 `.env` 里的 `HOST`/`PORT`、实际监听地址、回环与对外 IP 自测结果、80/443 是否有反代、防火墙放行情况,并直接给出修复命令。
### 9. 可覆盖的安装参数
| 参数 | 默认值 | 说明 |
| --- | --- | --- |
| `APP_DIR` | `/opt/opendance` | 程序目录 |
| `SERVICE_USER` | `opendance` | 运行服务的系统用户 |
| `STORAGE_DIR` | `/var/lib/opendance/storage` | 上传文件目录 |
| `PORT` | `3000` | 软件 API 端口 |
| `ADMIN_PORT` | `3001` | 管理端端口(默认 `PORT + 1`) |
例:`APP_DIR=/srv/opendance bash deploy/install.sh`。
### 10. 升级
解压新版本后重新执行 `bash deploy/install.sh` 即可(会覆盖程序文件,保留 `deploy/.env` 与上传数据)。
### 11. 卸载
```bash
sudo bash deploy/uninstall.sh # 停服务、禁用站点, 保留数据
sudo bash deploy/uninstall.sh --purge # 一并删除程序目录与上传文件
```
数据库始终由部署方自行管理,卸载脚本不做任何改动。
### 12. 常用命令(按安装时自动检测到的初始化系统而定)
```bash
# systemd
systemctl status opendance-api # 服务状态
journalctl -u opendance-api -f # 实时日志
systemctl restart opendance-api # 重启
# OpenRC
rc-service opendance-api status / restart
tail -f /var/log/opendance-api.log
# SysVinit
/etc/init.d/opendance-api status / restart
# 无 init 系统(降级模式)
bash /opt/opendance/deploy/ctl.sh status | restart | logs
```
反代配置不在本机,无需在此维护。
### 13. 常见问题(初次部署)
| 现象 | 处理 |
| --- | --- |
| 安装脚本报"未找到 node"或 Node 版本过低 | 装 Node.js 22+ 后重跑;脚本会打印检测到的版本号 |
| 安装脚本报"`deploy/.env` 中 `DB_*` 未设置" | 先跑 `bash deploy/init-env.sh` 生成配置,或手工 `cp deploy/.env.example deploy/.env` 后填写 |
| 健康检查返回 503 | 服务起来了但连不上数据库:核对 `deploy/.env` 的 `DB_HOST/DB_PORT/DB_USER/DB_PASS/DB_NAME`,以及数据库账号是否允许从本机 IP 连接(`GRANT ... TO 'user'@'%'` 或具体主机) |
| 服务本机 curl 通、外部访问不了 | `bash deploy/check-access.sh` 一次定位:打印 `HOST`/`PORT`、实际监听地址、对外 IP 自测、80/443 有无反代、防火墙放行情况,并给出修复命令 |
| 管理页面打开是 404 | 说明访问的是软件 API 端口(默认 3000);管理页面在管理端端口(默认 3001) |
| 上传 `.zip` 模型包报 422 且提示 Assimp 不可用 | PMX/FBX 转换依赖 Assimp:`apt-get install -y assimp-utils libassimp6`;只上传 `.glb` / `.vrm` 则不需要它 |
| 安装时报 `command not found` / 退出码 127,且提示指向 `deploy/.env` 某一行 | 该行的值含空格或 shell 元字符却没加引号,被 shell 当成命令执行了。按预检提示给这个值加引号(如 `MODEL_CONVERT_PMX="Hatsune Miku.pmx"`)后重跑;`deploy/env-lib.sh` 会提前拦住并给出行号 |
| 模型审核界面显示"未分析 / 无预览图" | 该模型是升级前入库的:调 `POST /api/models/<id>/reanalyze`,或批量跑 `node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000` |
## 本地开发
后端:
```bash
cd web-server
cp .env.example .env # 填写 DB_*
npm install
npm run migration:run # 首次需要
npm run start:dev # API http://localhost:3000; 管理端 http://localhost:3001
```
管理前端:
```bash
cd web-server/admin
npm install
npm run dev # http://localhost:5173(/api 与 /health 代理到 3000)
```
## 验证
```bash
cd web-server
npm run contracts:check # 契约版本一致性
npm run typecheck # TypeScript 严格检查
npm run test # 180 项单元测试 / 11 个文件(VMD 31、模型压缩包 24、
# 模型检查器 25、缩略图 8、骨骼过滤 8、
# 预处理 25、init-env 16、admin-port 16、
# access-log 9、VRM 检查器 6、.env 预检 12)
npm run build # 生产构建
npm run start:prod & # 启动
npm run test:smoke # 57 项端到端冒烟(需要数据库可连通)
cd ../admin && npm run build # 管理端构建
```
## 环境变量
| 变量 | 默认 | 说明 |
| --- | --- | --- |
| `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASS` / `DB_NAME` | - | 现有 MySQL 实例连接信息(**必填**) |
| `DB_SYNCHRONIZE` | `false` | 必须为 `false`。生产环境启动时若检测到 `true` 会直接中止启动;所有表结构变更通过 TypeORM migration 执行 |
| `DB_LOGGING` | `false` | 是否打印 SQL |
| `PORT` | `3000` | 软件 API 监听端口。给 Windows/Godot 客户端与 Android App 调用,只提供 `/api`、`/health`、`/static`、`/contracts`,不提供管理页面 |
| `ADMIN_PORT` | `PORT + 1`(3001) | 管理端端口。浏览器访问管理界面(管理页面 + 上面全部接口);设为与 `PORT` 相同即为单端口兼容模式,日志会提示「未分端口」 |
| `HOST` | `0.0.0.0` | 监听地址(软件 API 端口),因此装好即可被客户端访问(此时请用防火墙限制来源)。反代与后端同机时建议改为 `127.0.0.1`,让后端不直接对外暴露 |
| `ADMIN_HOST` | 跟随 `HOST` | 管理端监听地址(可选;`HOST` 未设置时同样回落 `0.0.0.0`)。需要接口对外、管理端只对本机时设为 `127.0.0.1` |
| `ADMIN_DIR` | `../admin`(即 `/opt/opendance/admin`) | 管理端静态站点目录 |
| `STORAGE_DIR` | `/var/lib/opendance/storage` | 上传文件存储目录(原生部署默认值) |
| `UPLOAD_MAX_BYTES` | `536870912`(512 MiB) | 单文件上限。反向代理层的同名限制必须不小于此值,否则大模型上传会在代理层被拒(nginx `client_max_body_size` / IIS `requestLimits`),详见 `deploy/proxy/README.md` |
| `ASSIMP_BIN` | - | 可选:Assimp 可执行文件。FBX/glTF 动作预处理需要;上传 `.zip` 模型包、或 `.pmx/.fbx` 单文件模型时必须能调用到,否则转换失败(422) |
| `MODEL_CONVERT_FORMAT` | `glb` | 需要转换的模型(PMX/FBX)的交付格式:`glb`(两个客户端都原生加载)或 `vrm`。`vrm` = 在 GLB 之上补 `VRMC_vrm` 扩展(humanoid 骨骼 / 表情语义),humanoid 骨骼映射不全时自动回退交付 GLB 并写进 `conversion_note`。产物一律用 `assimp info` 回读校验 |
| `MODEL_CONVERT_PMX` | `"Hatsune Miku.pmx"` | 压缩包内优先选用的 PMX 文件名(大小写不敏感);找不到时按「格式优先级 -> 目录最浅 -> 文件最大」挑选 |
| `MODEL_CONVERT_TIMEOUT_MS` | `180000` | 单次转换超时(毫秒) |
| `DEFAULT_MODEL_ID` | - | 可选:显式指定默认模型 id;不设置时按源码种子 `src/config/default-model.ts` 匹配,再回落到 VRM 优先规则 |
| `CONTRACTS_DIR` | - | 可选:契约目录,设置后 `/health` 反映实时契约版本(原生部署默认指向 `/opt/opendance/contracts`) |
端口配置非法(非 1..65535 整数)时,生产环境启动会直接中止;`ADMIN_PORT` 设为与 `PORT` 相同只打印提示,不中止。
**`.env` 写法约定**:`install.sh` / `ctl.sh` 用 `set -a; . deploy/.env` 载入配置,shell 会**真的执行**这个文件,因此
- 普通值不加引号:`PORT=3000`、`DB_HOST=127.0.0.1`;
- 值里含**空格或 shell 元字符**(`& | ; $ \` < > ( )` 等)时**必须加引号**,例如 `MODEL_CONVERT_PMX="Hatsune Miku.pmx"`;
- 双引号内不要用 `$(...)` 或反引号(会被真的执行),需要字面量时用单引号:`KEY='a b'`。
两个脚本在 source 之前都会先跑 `deploy/env-lib.sh` 的语法预检,不安全写法会直接指出行号、原文与改法。
## 主要 API
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/health` | 健康检查(含数据库连通性) |
| `GET` | `/api/songs` | 曲目列表(分页 / 检索) |
| `POST` | `/api/songs` | 新建曲目 |
| `GET` | `/api/songs/:id` | 曲目详情 |
| `PATCH` | `/api/songs/:id` | 更新曲目 |
| `DELETE` | `/api/songs/:id` | 删除曲目(软删除) |
| `POST` | `/api/songs/:id/audio` | 上传音频 |
| `GET` | `/api/motions` | 动作列表 |
| `POST` | `/api/motions` | 上传动作 + 来源信息 |
| `POST` | `/api/motions/:id/preprocess` | BVH/VMD/FBX -> MediaPipe33 关键点 |
| `GET` | `/api/motions/:id/keypoints` | 下载关键点序列(NDJSON) |
| `GET` | `/api/models` | 模型列表 |
| `POST` | `/api/models` | 上传模型 + 来源信息(`.glb/.vrm/.gltf` 直接导入;`.pmx/.fbx` 服务端转换;`.zip` 自动搜索包内模型) |
| `POST` | `/api/models/:id/primary` | 设为全局默认模型(仅 `published` 允许,否则 409) |
| `GET` | `/api/reviews` | 审核流水 / 待审列表 |
| `POST` | `/api/reviews/:type/:id/approve` | 审核通过(模型可带 `setPrimary:true`) |
| `POST` | `/api/reviews/:type/:id/reject` | 审核驳回(必须填意见) |
| `GET` | `/api/catalog/export` | 客户端一次性拉取已发布目录 |
| `GET` | `/api/catalog/version` | 契约版本 + 目录指纹 |
完整契约(OpenAPI 3.0)见 `contracts/rest-api.yaml`。
上传文件通过 `/static/<kind>/<yyyy>/<mm>/<sha256>.ext` 访问,相同内容只落盘一次(sha256 去重)。
### 支持的模型格式(`POST /api/models` 上传)
系统以 **GLB / VRM 为主力格式**:服务端与客户端都把这两种格式作为一等公民(Godot 客户端用内置 `GLTFDocument` 加载 glTF/GLB/VRM,见 `opendance-windows/src/model/vrm_loader.gd`,本次无需改动)。
| 格式 | 扩展名 | 优先级 | 处理方式 |
| --- | --- | --- | --- |
| glTF Binary | `.glb` | 主力(P0) | **直接导入,不重编码** |
| VRM | `.vrm` | 主力(P0) | **直接导入**;GLB + VRM 扩展(humanoid / 表情 / springBone / meta) |
| glTF | `.gltf` | P1 | **直接导入**(分离的 `.bin` / 贴图需随 `.zip` 一起上传) |
| MMD 压缩包 | `.zip` | P1 | **自动搜索**包内可用模型(见下) |
| MMD 模型 | `.pmx` | P2(输入) | 服务端转换,优先 GLB、其次 VRM |
| FBX | `.fbx` | P2(输入) | 服务端转换,优先 GLB、其次 VRM |
入库后一律 `status=pending`(待审核):上传不再自动发布、不再自动设为默认,必须经审核通过(模型审核面板可勾选「同时设为全局默认模型」)才对客户端可见。
### 升级后回填已有模型(面数 / 骨骼 / 缩略图)
升级到带 `models.stats` 的版本后,旧模型还没有分析与预览图。服务启动后执行一次:
```bash
cd /opt/opendance/web-server
node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000
# 只处理一个: --model-id 14 只列清单: --dry-run 全部重算: --force
```
也可对单个模型调 `POST /api/models/:id/reanalyze`(只改 `stats` 与缩略图两个字段)。若模型文件在存储中已丢失,接口返回 **409** 并提示先恢复文件,不会用空结果覆盖已有数据。
### 模型压缩包(`.zip`)自动搜索与转换
上传的 `.zip` 不再"只找 `.pmx`",而是自动搜索包内可用模型,走"解压 -> 选主模型 -> 直接导入或转换 -> 回读校验 -> 入库",而不是把压缩包当模型存下来。
| 环节 | 行为 |
| --- | --- |
| 选主模型 | 优先级 `.vrm` -> `.glb` -> `.gltf` -> `.pmx` -> `.fbx`;同一优先级内按"`MODEL_CONVERT_PMX` 指名字 -> 目录最浅 -> 文件最大"。命中 `.vrm/.glb/.gltf` 时直接导入(不重编码) |
| 转换 | 只有 `.pmx/.fbx` 需要转换。链路:`assimp export -> .glb`(首选);失败则 `assimp export -> .gltf`,再由 `gltf-packer.ts` 自行打包回 GLB;`MODEL_CONVERT_FORMAT=vrm` 时在 GLB 之上补 `VRMC_vrm` 扩展封装成 **VRM 1.0**(humanoid 骨骼不全则回退交付 GLB,缺失骨骼写进说明) |
| 校验 | 每一步产物必须用 `assimp info` **回读校验**。Assimp 会"写出损坏文件"(实测 PMX 导出 FBX 退出码 11、回读报 FBX-DOM 错误),只看退出码会把坏文件入库 |
| 贴图 | GLB 产物里 Assimp 会把贴图写成外部 URI(还带反斜杠),服务端把压缩包内 / `.gltf` 同目录的贴图**内嵌**进 GLB 的二进制块,保证单文件可加载 |
| 说明 | 每次尝试写入 `models.conversion_note`,例如 `Hatsune Miku.pmx -> GLB, 已内嵌 2 张贴图` |
| 溯源 | 压缩包原件以 `model-archive` 资产留存,写入 `models.source_asset_id`;迁移 `AddModelConversionFields1789450000000` |
| 失败 | 包内没有可用模型、Assimp 不可用、两条路径都失败 -> HTTP **422**,不产生模型记录 |
| 文件名编码 | `upload.util.ts` 的 `decodeUploadName()` 修正 multipart 文件名的 latin1/UTF-8 乱码(中文文件名原先会存成乱码) |
| 管理端 | 上传框 `accept` 为 `.vrm,.glb,.gltf,.fbx,.zip`;预览面板显示「转换来源」、「文件 sha256」与面数 / 顶点 / 骨骼分析摘要 |
| 新增字段 | DTO/契约新增 `conversionNote`、`fileSha256`、`stats`、`thumbnailUrl` |
实测(Assimp 6.0.4):
- 上传含 `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 与源文件一致(不重编码,字节级保留)。
### 模型分析、缩略图与 3D 预览(入库后自动执行,失败不影响入库)
| 能力 | 实现 |
| --- | --- |
| 分析 | `model-inspector.ts` 解析 GLB/VRM:面数 / 顶点 / 网格 / 材质 / 贴图 / 节点 / 形变目标 / 动画 / 蒙皮骨骼数 / 骨骼树 / humanoid 覆盖 / 可驱动性(缺哪些必需骨骼)/ warnings,写入 `models.stats`(迁移 `AddModelStats1789460000000`,新增 `models.stats TEXT`) |
| 缩略图 | `model-thumbnail.ts` 用**纯 TS 软件光栅化**生成 PNG(读 GLB/glTF -> 应用节点层级与蒙皮 -> 包围盒自动构图 -> 透视投影 -> z-buffer 逐三角形平面着色 -> 手写 PNG 编码),**零原生依赖**(不需要 canvas / headless-gl / puppeteer)。默认 512x512,写入 `models.thumbnail_asset_id` / `thumbnailUrl`;失败或非 glTF 容器时留空,列表页退化为格式占位图 |
| 预览 | 管理端 3D 预览放开到 `vrm/glb/gltf`(`.fbx` 仍提示「仅归档,请先转换为 GLB/VRM」);列表页显示 40px 小缩略图,审核面板显示 116px 缩略图 |
实测:512x512 PNG / 15,043 字节,目视确认为居中的 T-pose 角色图;真实 10MB VRM(83 关节、5307 顶点)单张耗时 60-121ms(中位 79ms)。
### 审核流程(模型)
模型上传后为 `pending`,在管理端「审核」页的模型审核面板里查看并决定:
| 项 | 内容 |
| --- | --- |
| 面板内容 | 体积 / 格式 / 容器 / VRM 版本 / 面数 / 顶点 / 网格 / 材质 / 贴图 / 骨骼 / 节点 / 形变目标 / 动画 / humanoid 覆盖(已映射/总数 + 明细)/ 可驱动性结论(缺哪些必需骨骼)/ warnings |
| 骨骼预览 | 骨骼详细界面:扁平骨骼树按 depth 缩进、显示骨骼名、是否参与蒙皮、humanoid 映射徽章、子节点数;支持按名搜索(保留祖先链)、「仅显示蒙皮骨骼」开关、限高滚动 + 只渲染前 400 行(千根骨骼不卡) |
| 通过 | 勾选「同时设为全局默认模型」-> 请求体带 `setPrimary:true`(`POST /api/reviews/model/:id/approve`);此时才写库 `is_primary` 并把默认模型身份写进源码 `src/config/default-model.ts` |
| 单独设置 | `POST /api/models/:id/primary`(仅 `published` 允许,否则 409) |
| 驳回 | 必须填写审核意见,否则 400 |
### 默认模型是怎么定的
`GET /api/catalog/export` 的 `defaultModelId` 按以下顺序解析:
0. 环境变量 `DEFAULT_MODEL_ID`(且模型存在且已发布)
1. 源码种子 `src/config/default-model.ts` 的 `DEFAULT_MODEL_SEED` —— 按**上传压缩包的 sha256** 匹配已发布模型(用户文件永远稳定;不能用转换产物的 sha256,Assimp 每次导出的字节都略有不同),换库 / 重建库后仍有效
2. `format='vrm'` 且 `published` 且 `is_primary=1`
3. 任意 `published` 的 VRM(id 升序取第一个)
4. id 最小的已发布模型
5. 无可用模型 -> 客户端用内置占位模型
源码种子 `src/config/default-model.ts` 当前为**空种子**(占位:`id=null`、sha256 空串),此时 `defaultModelId` 直接从第 2 步的 VRM 优先规则开始解析;按下面的方式设定默认模型后,服务端会把种子写成真实内容。
实测(v0.2.0):`POST /api/reviews/model/22/approve` 带 `setPrimary:true` -> 200,模型 `status=published`、`isPrimary=true`,源码种子写入 `sourceSha256=72eb6882...`;重建后 `GET /api/catalog/export` 的 `defaultModelId` 由 VRM 变为 22(库中另有 2 个已发布 VRM)。也可手工指定:`POST /api/models/:id/primary`。
第 1 步的源码写入由服务端在审核通过并勾选 `setPrimary`(或调用 primary 接口)时完成;生产 systemd 加固(`ProtectSystem=strict`)下服务进程写不了程序目录,可改用下面的 CLI 补写。
### 导入 / 补写默认模型(`web-server/scripts/import-default-model.mjs`)
需先 `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-name "模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip" \
--source-url "本地文件: 模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip" \
--source-type other
# 上传 -> 服务端转换入库(pending)-> 把身份写进 src/config/default-model.ts
node scripts/import-default-model.mjs --write-source-only --model-id 22
# 只根据已入库模型补写源码, 不调上传 API
node scripts/import-default-model.mjs --write-source-only --model-id 22 --dry-run
# 只打印将写入的内容, 不落盘
```
说明:`--url` 默认 `http://127.0.0.1:3000`,`--source-type` 默认 `other`。v0.2.0 起上传一律 `pending`,因此 CLI 上传路径只负责转换入库与写源码种子;要让它在 `defaultModelId` 中生效,仍需在管理端审核通过(勾选「同时设为全局默认模型」)或调用 `POST /api/models/:id/primary`。
### 支持的动作格式(`POST /api/motions` 上传 -> `POST /api/motions/:id/preprocess` 预处理)
| 格式 | 扩展名 | 处理方式 |
| --- | --- | --- |
| BVH | `.bvh` | 内置纯 TS 解析器,保留源帧率 |
| VMD | `.vmd` | 内置解析(MikuMikuDance),固定 30fps;腿部按「足IK是否带位移」自动选 IK 或 FK |
| FBX | `.fbx` | 需可选的 Assimp 先转 BVH |
| glTF / GLB | `.gltf` / `.glb` | 同上 |
| 关键点 JSON | `.json` | 已是 MediaPipe33 结构,只做校验与规范化 |
### VMD 动作上传前后自查
服务端对 `.vmd` 是内置解析,不依赖 Assimp。上传前可先在 `web-server` 目录下跑诊断工具(只读:不写数据库、不访问网络),确认骨骼名能识别、腿走 IK 还是 FK、关键点序列无异常:
```bash
cd web-server
npm run build
node scripts/inspect-vmd.mjs 动作.vmd
node scripts/inspect-vmd.mjs 动作.vmd --frames 300 # 只采样前 300 帧
node scripts/inspect-vmd.mjs 动作.vmd --json out.json # 导出关键点序列
```
它会打印头部 / 模型名 / 帧数 / 轨道数、未识别骨骼、走 IK 还是 FK、关键点序列异常统计与膝肘角分布。上传后再调用 `/preprocess` 走正式通路。
## 目录结构
```text
project.yaml 任务清单与状态
DSH.md 本项目 DSH 执行约束
tasks/ 单任务说明书(T-WEB-001 ~ 010)
contracts/ 跨端契约(权威, 改接口先改这里)
web-server/ NestJS 服务端源码
web-server/admin/ React 管理端
web-server/src/common/zip-reader.ts 纯 TS ZIP 读取器
web-server/src/models/model-convert.ts .zip/.pmx/.fbx -> GLB/VRM 转换
web-server/src/models/gltf-packer.ts glTF(分离文件)-> GLB 打包
web-server/src/models/glb-textures.ts GLB 贴图内嵌
web-server/src/models/vrm-writer.ts GLB -> VRM 1.0 封装(写 VRMC_vrm 扩展)
web-server/src/models/model-inspector.ts 模型检查器(面数/骨骼树/humanoid 覆盖)
web-server/src/models/model-thumbnail.ts 纯 TS 光栅化缩略图(零原生依赖)
web-server/src/config/default-model.ts 默认模型种子(服务端/CLI 写入)
web-server/src/migrations/1789460000000-AddModelStats.ts
models.stats 迁移(审核分析结果)
web-server/admin/src/components/ModelReviewPanel.tsx
模型审核面板(含骨骼详细界面)
web-server/admin/src/lib/bone-tree.ts 骨骼树过滤(搜索保留祖先链)
web-server/scripts/import-default-model.mjs 默认模型导入 / 补写 CLI
deploy/ 业务服务部署(系统服务, 不含 MySQL、不含反向代理)
deploy/init-env.sh 首次启动的 .env 交互引导(install.sh 会调用)
deploy/proxy/ 反向代理配置说明(nginx / IIS, 仅供参考不部署)
THIRD_PARTY.md 第三方开源项目声明
```
## 安全提示
1. `.env` 已被 `.gitignore` 忽略,禁止提交;仓库中只保留 `.env.example`。
2. 数据库账号使用最小权限,不授予 `GRANT OPTION` / `FILE` / `SUPER`。
3. 本版本未内置管理端登录认证,请部署在内网,或在反向代理层加 Basic Auth / SSO。
4. 上传的模型与动作默认需填写来源五元组并通过审核后才对客户端可见。模型上传后一律 `pending`:不再自动发布、不再自动设为默认;默认模型只在审核通过时勾选「同时设为全局默认模型」(`setPrimary:true`)或调用 `POST /api/models/:id/primary`(仅 `published`)时才设置(见[审核流程](#审核流程模型))。
5. 未使用 SMPL 拓扑,统一 MediaPipe 33 点,规避非商用许可限制。
## 第三方开源声明
见 `THIRD_PARTY.md`,或访问站点 `http://<host>/` 的「关于」页面。