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