zijunchiang ccced34dd6 fix(models): 默认模型改用「压缩包 sha256」匹配(转换产物非字节级确定)
实测发现:同一份 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 正确解析。
2026-09-15 19:36:37 +08:00

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.
================================================================
OpenDance Studio —— Web 管理服务端 v0.1.0  使用说明
================================================================

本服务端是 OpenDance Studio 三端中的第一端,负责管理歌曲、VRM 模型与
动作来源,是 Windows 客户端与 Android 辅助 App 的唯一数据源与契约权威。

重要:本服务端 **不部署、不管理 MySQL**。数据库为部署方提供的现有实例,
连接信息通过 .env 注入。

----------------------------------------------------------------
一、部署到 Linux 服务器(业务服务,不含反向代理)
----------------------------------------------------------------

职责边界
  本端**只提供业务服务**:NestJS API + 管理端页面。
  **反向代理由另外的服务/团队负责**,本项目不安装、不配置任何反代软件,
  只在 deploy/proxy/ 下提供 IIS 与 nginx 的配置说明文档。

部署形态
  Node 业务服务由系统服务托管,同时监听**两个端口**,职责分开:

    软件 API 端口(默认 3000,给 Windows/Godot 客户端与 Android App)
      /api/        REST API
      /health      健康检查
      /static/     上传文件
      /contracts/  契约快照
      该端口**不提供管理页面**,/ 与 /songs 等一律返回 404(有意设计)

    管理端端口(默认 3001,给浏览器)
      管理页面(本服务自带,含 SPA 深链接回退)+ 上面全部接口

  反向代理按用途分别转发两个端口,每个端口各一条全匹配转发规则即可。

前置要求
  - Linux 服务器,有 root 权限(不需要 sudo,直接以 root 执行即可)
  - Node.js 22 或更高版本
  - 进程管理:systemd / OpenRC / SysVinit 均可;**三者都没有也能装**
    (自动降级为 nohup 后台进程 + PID 文件 + crontab @reboot 开机自启)
  - 反向代理:由另外的服务提供,本项目不要求、也不安装
  - 一个可连通的现有 MySQL / MariaDB 实例,并已创建数据库与账号:

      CREATE DATABASE IF NOT EXISTS dpendance
        CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
      CREATE USER IF NOT EXISTS 'dpendance'@'%' IDENTIFIED BY '<密码>';
      GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES
        ON dpendance.* TO 'dpendance'@'%';
      FLUSH PRIVILEGES;

  (本项目不会创建数据库本身,也不需要 GRANT OPTION / FILE / SUPER 等管理权限)

部署步骤
  tar -xzf opendance-web-v0.1.0.tar.gz
  cd opendance-web-v0.1.0
  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 / 后台进程)→ 等待健康检查

deploy/.env 初始化引导(deploy/init-env.sh)
  缺 deploy/.env 时,install.sh 会先调用本脚本引导填写;引导失败才中止安装
  (若安装目录里已有上次安装留下的 .env,则直接复用,不会重复提问)。
  也可以单独运行:逐项询问数据库与端口,回车保留默认值,写盘前做必填项与
  端口范围校验,并可用项目自带的 mysql2 做一次数据库连通性自检——口令只经
  环境变量传给子进程,出错信息与摘要一律显示 ***。

      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 或自动化里。

部署完成后(直接访问后端端口,<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/README.md            完整说明、自检清单与常见故障

  直连后端时,防火墙需同时放行两个端口(默认 3000 与 3001)。

  外部访问不了?安装脚本结尾会直接给出结论(监听地址、对外可达性、
  防火墙提示);也可以在服务器上跑一次自检脚本(只读,不改配置):
      bash deploy/check-access.sh            # 或 bash deploy/check-access.sh /opt/opendance
  它会打印 .env 里的 HOST/PORT、实际监听地址、回环与对外 IP 自测结果、
  80/443 是否有反代、防火墙放行情况,并直接给出修复命令。

可覆盖的安装参数
  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

升级
  解压新版本后重新执行 sudo bash deploy/install.sh 即可(会覆盖程序文件,
  保留 deploy/.env 与上传数据)。

卸载
  sudo bash deploy/uninstall.sh           # 停服务、禁用站点,保留数据
  sudo bash deploy/uninstall.sh --purge   # 一并删除程序目录与上传文件

  数据库始终由部署方自行管理,卸载脚本不做任何改动。

常用命令(按安装时自动检测到的初始化系统而定)
  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

  反代配置:不在本机,无需在此维护

----------------------------------------------------------------
二、本地开发
----------------------------------------------------------------

后端
  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

管理前端
  cd web-server/admin
  npm install
  npm run dev             # http://localhost:5173(/api 与 /health 代理到 3000)

----------------------------------------------------------------
三、验证
----------------------------------------------------------------

  cd web-server
  npm run contracts:check   # 契约版本一致性
  npm run typecheck         # TypeScript 严格检查
  npm run test              # 125 项单元测试 / 7 个文件(含 VMD 31、模型压缩包 22、
                            #   init-env 16、预处理 25)
  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。生产环境启动时若检测到 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)。需要接口对外、管理端只对本机时设为
                        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 模型包时**必须**能调用到,否则转换失败(422)
  MODEL_CONVERT_FORMAT  模型压缩包首选的目标格式(默认 fbx)。导出后会用
                        `assimp info` 回读校验,失败自动回退 glb
  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)

----------------------------------------------------------------
五、主要 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                          上传模型 + 来源信息(VRM/GLB/FBX,
                                              或含 .pmx 的 .zip 模型包)
  POST   /api/models/:id/primary              设为全局默认模型
  GET    /api/reviews                         审核流水 / 待审列表
  POST   /api/reviews/:type/:id/approve       审核通过
  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 上传)
  VRM          .vrm          首选(P0);glTF 2.0 二进制 + VRM 扩展
  glTF Binary  .glb          P1;需含骨骼
  glTF         .gltf         P2;多文件,建议打包后上传
  FBX          .fbx          P2;需 Assimp,管理端仅归档
  MMD 压缩包   .zip          P1;内含 .pmx(+ textures/),服务端自动解压转换

模型压缩包(.zip)自动转换
  上传的 .zip 视为 MMD 模型包,走"解压 → 选主模型 → 转换 → 回读校验 → 入库",
  而不是把压缩包当模型文件存下来。

    选主模型   优先 MODEL_CONVERT_PMX 指定的文件名(默认 Hatsune Miku.pmx,
               大小写不敏感);否则取目录最浅、其次文件最大者。只认 .pmx。
    转换       先试 MODEL_CONVERT_FORMAT(默认 fbx),调 ASSIMP_BIN 导出。
    校验       导出后**必须**用 `assimp info` 回读校验。Assimp 的 FBX 导出器
               对带蒙皮的大骨架模型会"写出损坏文件却返回非 0 退出码",只信
               退出码会把坏文件入库。
    回退       FBX 失败(导出失败或回读失败)→ 自动改用 GLB,两次尝试写入
               models.conversion_note。
    贴图       GLB 产物里 Assimp 会把贴图写成外部 URI(还带反斜杠),服务端
               把压缩包内的贴图内嵌进 GLB 的二进制块,保证单文件可加载。
    默认模型   转换成功且 autoDefault 不为 false(默认 true)→ is_primary=1 +
               status=published,并把身份写进源码 src/config/default-model.ts。
    溯源       压缩包原件以 model-archive 资产留存,写入 models.source_asset_id;
               转换说明写入 models.conversion_note(迁移 AddModelConversionFields)。
    失败       压缩包内没有 .pmx、Assimp 不可用、两种格式都失败 → HTTP 422,
               不产生模型记录。

    文件名编码 upload.util.ts 的 decodeUploadName() 修正 multipart 文件名的
               latin1/UTF-8 乱码(中文文件名原先会存成 模å_…)。
    管理端     上传框 accept 增加 .zip,提示会说明"会自动转换并设为默认";
               预览面板新增「转换来源」与「文件 sha256」。
    新增字段   DTO/契约新增 conversionNote、fileSha256。

  实测:上传 示例文件/模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip
  (1,093,100 字节)→ HTTP 201、0.89s、format=glb、isPrimary=true、
  status=published。服务端日志显示 Assimp 6.0.4 导出 FBX 失败(退出码 11),
  回退 GLB 后回读通过:1,152,464 字节、238 关节、已内嵌 2 张贴图,
  conversionNote = `Hatsune Miku.pmx → GLB,已内嵌 2 张贴图`。

默认模型是怎么定的
  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. 无可用模型 → 客户端用内置占位模型

  当前种子指向 id=16、name=`初音未来 Hatsune Miku`、format=glb、
  sha256=`6d0ab0dce97c43dd7de84aba29b09f3b64f2953722d31b5421a39189ff2a8464`。
  实测:库里有 2 个 VRM 的情况下,defaultModelId 仍解析为 16。
  也可手工指定:POST /api/models/:id/primary。
  第 1 步的源码写入通常由服务端在转换成功时完成;生产 systemd 加固
  (ProtectSystem=strict)下服务进程写不了程序目录,可改用下面的 CLI 补写。

导入 / 补写默认模型(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 模型使用条款(见压缩包内说明)" \
      --source-name "模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip" \
      --source-url "本地文件:模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip" \
      --source-type other
    # 上传 → 服务端转换入库 → 设为默认 → 把身份写进 src/config/default-model.ts

    node scripts/import-default-model.mjs --write-source-only --model-id 16
    # 只根据已入库模型补写源码,不调上传 API

    node scripts/import-default-model.mjs --write-source-only --model-id 16 --dry-run
    # 只打印将写入的内容,不落盘

  说明:--url 默认 http://127.0.0.1:3000,--source-type 默认 other;
  上传时脚本固定带 autoDefault=true。

支持的动作格式(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、关键点序列无异常:

      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 走正式通路。

----------------------------------------------------------------
六、目录结构
----------------------------------------------------------------

  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         压缩包 → FBX/GLB 转换
  web-server/src/models/glb-textures.ts          GLB 贴图内嵌
  web-server/src/config/default-model.ts         默认模型种子(服务端/CLI 写入)
  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. 上传的模型与动作默认需填写来源五元组并通过审核后才对客户端可见;上传 .zip
   模型包且未关闭 autoDefault 时,转换成功会直接设为已发布的默认模型(见 §五)。
5. 未使用 SMPL 拓扑,统一 MediaPipe 33 点,规避非商用许可限制。

----------------------------------------------------------------
八、第三方开源声明
----------------------------------------------------------------

见 THIRD_PARTY.md,或访问站点 http://<host>/ 的「关于」页面。
S
Description
OpenDance Studio - opendance-web
Readme
1.5 MiB
2026-09-18 03:03:50 +08:00
Languages
TypeScript 80.3%
Shell 9.3%
JavaScript 7.4%
CSS 2.3%
Python 0.6%