================================================================
OpenDance Studio -- Web 管理服务端 v0.7.4  使用说明
================================================================

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

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

v0.7.4 修订 - 场景功能的真正根因: Scene 没有注册进 ENTITIES
  1. 生产实测 /health 的 scenes.error = 'No metadata for "Scene" was found.'
     -- src/entities/index.ts 的 ENTITIES 漏了 Scene。TypeORM 只按 ENTITIES 注册
     metadata, 于是启动完全正常、但第一次查 Scene 就抛错 => /api/scenes、
     /api/catalog/export、/api/catalog/version、/api/reviews?status=pending 全 500。
     此前"数据库缺 scenes 表"的判断也是错的: 表已由 AddScenes 迁移建好, 缺的是实体注册。
  2. 修复: ENTITIES 加入 Scene 并导出。
  3. 新增 test/entities-registry.spec.ts(4 项): 扫描 @Entity() 类与 ENTITIES 对账,
     漏一个即失败; 已反向验证(移除 Scene 立刻失败)。
  4. 部署后运行 node scripts/diagnose-scenes.mjs 可确认表与列的实际状态。

v0.7.3 修订 - 场景表不可用不再拖垮整站(生产事故修复)
  另修一个更直接的缺陷: ScenesService.create() 按内存存储写(判断 file.buffer),
  而上传中间件用的是 multer diskStorage(只有 path、没有 buffer) => 任何场景都传不上来,
  一律 400。现与 models/motions 同一套: requireUploadedFile + persistTempFile,
  需转换时 readFile(upload.path) 并在 finally 清理临时文件。

  1. 现场: 数据库缺 scenes 表 -> /api/scenes、/api/catalog/export、
     /api/catalog/version、/api/reviews?status=pending 全部 500,
     而 /api/songs、/api/models 正常 => 客户端拉不到任何目录、管理端待审列表打不开。
  2. 契约与文档都写明"场景是可选资源", 因此改为降级: ScenesService 的
     queryScenes() 在数据库层面失败时返回空结果 + 只记一次日志;
     表恢复后自动复位; ReviewsService 对待审列表里的场景查询单独 catch;
     /health 新增 scenes:{available,error}(降级但绝不静默)。
  3. 新增 scripts/diagnose-scenes.mjs(默认只读): 打印运行期真实连的库、表清单、
     迁移记录、代码实际加载的迁移、scenes 实际列 vs 实体期望列 diff;
     --fix 仅在缺表时用迁移同一份 DDL 幂等补表。
  4. 修正 models.controller 注释: 模型上传不接受 isPrimary/status(契约: 上传一律
     pending, 默认模型由审核通过时 setPrimary=true 或 /primary 设定),
     冒烟脚本的错误预期已同步修正。
  5. 新增 6 项测试。
  6. 更正一次误判: Gitea 附件接口的 HTTP 500 空消息("message":"")曾被我判成宿主机
     磁盘写满, 实际是**上传写法错误** -- 该接口 multipart 字段名是 attachment,
     用 file= 就会得到空消息 500。正确写法两种(attachment=@... 或
     --data-binary + ?name=)已写进 deploy/proxy/README.md 第九节。
     仍然真实存在的唯一限制是 IIS maxAllowedContentLength(默认 30,000,000 字节):
     927KB/14MB/16.4MB 通过, 30.5MB/38.2MB/40MB 一律 413。


v0.7.2 修订 - 修复产物"装得上、起不来" + /health 报错版本号
  1. CatalogModule 漏 import ScenesModule(致命): CatalogService 注入
     ScenesService, 但 CatalogModule 只列了 Songs/Motions/Models。Nest 的 DI
     按模块解析 -> node dist/main.js 在 bootstrap 直接抛
     UnknownDependenciesException -> v0.7.0/v0.7.1 的产物根本起不来。
     当时 build 通过、258 项测试全绿、打包完整性校验通过, 因为它们都不编译 AppModule。
  2. 新增启动冒烟 scripts/check-bootstrap.mjs(挂进 verify 与打包脚本):
     以正式部署方式(node dist/main.js, 无 npm 环境变量, 不需要数据库)拉起真实产物,
     断言无依赖解析错误 + 应用版本等于 package.json + 已推进到数据库连接阶段。
     已反向验证: 删掉 ScenesModule 后该检查立刻失败。
  3. /health 版本号在原生部署下永远是 0.1.0: 原来用 npm_package_version,
     该变量只在 npm 脚本下存在。新增 src/config/app-version.ts 作为运行时唯一
     解析点(APP_VERSION -> package.json(带包名校验) -> npm_package_version -> 0.0.0),
     main.ts 启动首行也打印版本号。
  4. 新增 scripts/check-version.mjs(并入 contracts:check): 校验三处版本一致并
     禁止 health.controller.ts 硬编码版本回退; 打包脚本构建管理端后再跑一次。
  5. 新增 12 项测试(含"模拟无 npm 环境变量的原生部署")。

v0.7.1 修订 - 契约补齐 + 文档补齐(契约 websocket-keypoints 0.1.0 -> 0.2.0)
  1. 补齐 rest-api.yaml 里漏写的场景结构(CatalogBundle.scenes[] + Scene +
     SceneConfig): catalog/export 自 v0.7.0 起就返回 scenes[],规格漏写会导致
     两端对字段边界的理解不一致。只补规格、不改线格式,六个契约版本号不变。
  2. 契约 websocket-keypoints -> 0.2.0(与 Android T-AND-011 配套):
     1.1 保活(接收端声明 silentTimeoutMs/heartbeatIntervalMs,心跳必须小于
     静默超时的一半)、3.1 hello.deviceId、3.6 应用层 ping/pong、
     5 设备去重、7 关闭码与重连策略、7.1 退避与 ±20% 抖动、8 连接状态机。
     子协议名与帧 version 均不变,向后兼容。
  3. deploy/proxy/README.md 新增第九节: 托管 Gitea 的 IIS 站点附件上传上限
     (与一~五节的 Web 服务反代是两个不同站点)。实测 30.5/40 MB 返回 HTTP 413,
     根因是 IIS 默认 maxAllowedContentLength = 30,000,000 字节;给出放宽到
     256 MB 的配置与复验命令。这是 Windows 端 v0.1.0 被拆成 zip.part0/1/2 的根因。
  4. 补齐 tasks/011 ~ 016 与 project.yaml 条目(此前已落地但缺任务说明书)。
  5. 规模 18 文件 / 246 项测试; contracts:check 通过, 35 条路由一致。

v0.7.0 修订 - 管理端场景页(补齐场景端到端可用)
  1. 新增「场景管理」页(/scenes): 列表(40px 缩略图 + 物理摘要 + 状态)、
     上传(GLB 文件 + 名称 + 来源五元组 + 物理参数: 重力/风/阵风/地面/碰撞体/环境光)、
     详情(复用 3D 预览 + 参数可视化编辑并保存)。
  2. 新增 physicsSummary() 纯函数、场景页样式与侧栏入口。
  3. 新增 2 项测试; 规模 19 文件 / 245 项。

v0.6.0 修订 - 场景管理(契约 rest-api 0.5.0 / data-models 0.7.0 / database 0.4.0)
  1. 场景以 GLB 为主(不做 humanoid 重绑定、不转 VRM): .glb/.gltf 直接导入,
     .zip/.pmx/.fbx 由服务端转 GLB。
  2. 场景物理参数(SceneConfig, 契约 data-models 7.1): 重力向量、风向动态
     (基础风 + 阵风幅度/频率/方向/坐标系)、地面摩擦/弹性/坡度、碰撞体来源、
     环境光/雾、出生点(<=16)、相机; 服务端只存参数不跑物理(仿真在客户端)。
  3. 入库即收敛: 越界夹紧、非法枚举回退默认; scenes.config/stats 用 MEDIUMTEXT。
  4. 端点: GET/POST /api/scenes、GET/PATCH/DELETE /api/scenes/{id}(共 35 条路由);
     审核复用 /api/reviews/scene/:id/approve|reject; catalog/export 增 scenes[]。
  5. 新增 6 项测试; 规模 19 文件 / 243 项。

v0.5.0 修订 - 动作预览(契约 data-models -> 0.6.0)
  1. 动作骨架缩略图(服务端): motion-thumbnail.ts 把关键点序列画成正视图骨架 PNG,
     随 /preprocess 生成并落库(motions.thumbnail_asset_id, 迁移 AddMotionThumbnail),
     失败只记 warning 不影响预处理。
  2. 动作 3D 回放(管理端, 重要): 新增 MotionPreview - 懒加载 NDJSON -> 骨架线框 +
     关节球体, 支持播放/暂停、上一帧/下一帧、时间轴拖动、0.25x-2x 变速、循环,
     叠加帧号/时间/总时长; three.js 单独分包, 主包体积不涨。
  3. 动作管理列表新增 40px 骨架缩略图列(点击预览)与「预览」按钮。
  4. 新增 8 项测试; 规模 18 文件 / 237 项。

v0.4.0 修订 - 骨骼重绑定(契约 skeleton-topology -> 0.2.0)
  1. 修复 humanoid 覆盖率恒为 0/22 的根因: normalizeBoneName() 原先只保留 a-z0-9,
     中日文字符被整个丢掉(上半身/左ひじ/首 全变空串)。现在保留 CJK/假名,
     并把全角 ASCII(足ＩＫ)折成半角。
  2. 候选表补齐四类命名: VRoid / Mixamo / MMD 标准骨架(下半身/上半身/上半身2/
     首/頭/左肩/左腕/左ひじ/左手首/左足/左ひざ/左足首/左つま先)/ 常见中文名;
     精确匹配保证 左足ＩＫ 不会抢占 左足。
  3. 服务端与 Windows 客户端逐字一致: humanoid_map.gd 的 CANDIDATES 由服务端表
     生成写入, normalize() 同步改造(parity 测试断言)。
  4. 实测: 奥黛塔 PMX 0/22 -> 21/22、初音未来 PMX 0/22 -> 19/22, 均 drivable=true
     并产出 VRM(此前只能回退 GLB 并标记不可用)。
  5. 契约 skeleton-topology -> 0.2.0: 新增第 6 节"内部动作标准与模型骨骼重绑定"。
  6. 新增 4 项测试; 规模 17 文件 / 229 项。

v0.3.0 修订 - VRM 主力化(契约 data-models / model-formats -> 0.5.0)
  1. 所有上传的模型一律转 VRM: MODEL_CONVERT_FORMAT 默认改为 vrm -
     .pmx/.fbx 经 assimp -> GLB -> VRM 1.0, glb/gltf 导入后包装为 VRM。
  2. humanoid 门槛与可用性标记(usable): 必需骨齐全才产出 VRM; 不达标
     (如 MMD 日文骨名 0/22)时回退交付 GLB、usable=false - 可入库、可审核,
     但不进入 catalog/export(指纹同步过滤); 待骨骼重绑定后重新导入即转为可用。
  3. 模型列表与审核面板显示"不可用"及原因; 默认模型只在可用模型中选择。
  4. 管理端左下角展示当前版本号(构建期从 web-server/package.json 注入,
     与 GET /health 的 version 同源)。
  5. 新增 5 项测试; 规模 16 文件 / 225 项。

v0.2.5 修订 - MMD 材质/着色对齐
  PMX 的着色模型(卡通阶梯 + 球面贴图 + 平坦环境光)与 glTF PBR 不是一回事,
  Assimp 转换会丢东西。实测样本 【奥黛塔】_by_原神.zip(39 个材质):
  1. 修复"看起来颜色/贴图不对"的主因: PMX 里 36 个材质带"不剔除背面"标志,
     而 Assimp 导出的 GLB 里 doubleSided 一个都没有 -> 头发/裙摆/睫毛这类
     薄片被单面剔除。现在新增 pmx-materials.ts(只读 PMX 材质段), 转换后按
     drawFlag 补回 doubleSided。
  2. PBR 参数规范化(glb-materials.ts): metallicFactor=0、
     roughnessFactor=clamp(1-shininess/100, 0.5, 1)、baseColorFactor 乘上 PMX
     diffuse(含 alpha); 清掉 Assimp 带出的、与 MMD 无关的 KHR_materials_specular
     / volume / transmission / ior / emissive_strength; 顺手修正 Assimp 写坏的
     accessor.min/max(实测 min 被写成 2.2e-308, Godot 用它算包围盒)。
  3. 新增 MODEL_SHADING(默认 pbr, 可选 unlit): unlit 给每个材质写
     KHR_materials_unlit, 颜色 = 贴图 x baseColorFactor、与场景光照无关,
     最接近 MMD 的平涂观感(three.js 与 Godot 都支持)。
  4. 能力边界: PMX 的球面贴图(该模型 15 项)与 toon 阶梯在 glTF core 里没有
     对应槽位, 任何转换器都无法原样保留; 数量会写进 conversion_note。
  5. 新增 9 项测试(test/mmd-materials.spec.ts); 规模 15 文件 / 220 项。
  6. 注意: 材质修正在转换时生效, 已有模型需重新导入(reanalyze-models.mjs
     只刷新分析与缩略图, 不会重转材质)。

v0.2.4 修订
  1. 修复缩略图"白模": 缩略图渲染器原先刻意不解码贴图, 只用 baseColorFactor
     上色; 而 MMD(PMX 转换)模型的 baseColorFactor 基本都是白色, 于是列表页与
     审核面板里的缩略图全是白色模型。现在内置极简 PNG 解码器(zlib + 反滤波,
     8 位非隔行, colorType 0/2/3/4/6), 按 baseColorFactor x 贴图逐像素采样,
     并按 alphaMode(OPAQUE / MASK / BLEND)处理透明度; 解码失败回退纯色。
  2. 缩略图取景收紧(FRAMING 1.6 -> 1.3), 40px 列表缩略图里主体更清楚。
  3. 新增 6 项贴图/透明度测试(test/model-thumbnail-texture.spec.ts)。
  4. 说明: GLB 数据本身没问题 - 贴图逐字节内嵌(18/18)、39 个材质的贴图对应
     关系与 PMX 完全一致; 白模只出在缩略图渲染器。

v0.2.3 修订
  1. 修复上传 PMX 返回 500(models.stats 容量): 分析结果里的骨骼树按骨骼逐条
     展开, 实测一个 643 骨骼的 PMX(【奥黛塔】_by_原神.zip)就有 70,980 字节,
     而该列是 TEXT(上限 65,535), MySQL 严格模式直接拒绝写入(1406)导致 500。
     列迁移到 MEDIUMTEXT(AddModelStatsMediumText), 并新增
     shrinkStatsForStorage 兜底: 即使库没迁移, 也只截断骨骼树、保留面数/
     骨骼数/humanoid 覆盖/可驱动性, 绝不让上传失败。
  2. 修复 GBK 压缩包丢贴图: 文件名解码先试 Shift-JIS, 而 WHATWG 的 shift_jis
     会把未定义码位映射成私用区不可见字符(不是 U+FFFD), 于是像 tex/髮.png
     (GBK 字节 F3 8C)这样的名字被判为"解码成功"而永不回退 GBK, 贴图名对不上
     就丢图。现在私用区也算解错信号; 实测该模型贴图由 17/18 变为 18/18 全部内嵌。
  3. 新增 12 项测试(test/model-stats-limit.spec.ts 9 项 + 压缩包编码/目录条目 3 项)。

v0.2.2 修订
  1. 修复 Assimp 缺失时的报错与部署体验: 服务器没装 Assimp 时, 上传 .pmx/.fbx
     (或含它们的压缩包)只得到两条 导出失败(ENOENT): spawn assimp ENOENT,
     看不出缺什么、怎么装。
  2. 转换前先定位 Assimp(src/common/assimp.ts): 按 ASSIMP_BIN -> PATH ->
     /usr/bin、/usr/local/bin 等常见目录查找, 找不到就一次性给出各发行版安装
     命令、查过的路径与替代办法(只传 .glb/.vrm 不需要它), 不再打两条 ENOENT。
  3. install.sh 新增 Assimp 检查与自动安装(deploy/assimp-lib.sh): 按发行版调用
     apt-get / dnf / yum / zypper / apk / pacman; 装在非常规目录时自动把绝对
     路径写进 deploy/.env 的 ASSIMP_BIN; 装不上只告警不中止(可用
     SKIP_ASSIMP_INSTALL=1 跳过)。
  4. deploy/check-access.sh 自检增加 Assimp 一行; 管理端「概览 -> 服务健康」
     显示 Assimp 状态, 不可用时给出提示。
  5. 新增 13 项测试(test/assimp-locate.spec.ts)。

v0.2.1 修订
  1. 修复部署失败: 模板里的 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 项回归测试(web-server/test/env-file.spec.ts)。
  约定: 值里含空格或 shell 元字符时必须加引号(双引号内不要用 $( ) 或反引号)。

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

初次部署(从零跑起来)
  按顺序做, 每步都有验证方法:

   1. 准备一台 Linux 服务器, 以 root 登录(不需要 sudo), 装好 Node.js 22+
        node -v                      # 期望 v22 或更高
   2. 准备一个现有的 MySQL / MariaDB 实例, 并建好库与账号(见下方"前置要求")
        本项目不会创建数据库本身
   3. 取得发布包并校验(以下是本仓库的真实发布地址; 域名 gitea.kasugano.cn
      与属主 zijunchiang 属部署必需信息, 不是隐私项)
        curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.2/opendance-web-v0.7.4.tar.gz
        curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.2/SHA256SUMS
        sha256sum -c SHA256SUMS      # 期望输出 "OK"
   4. 解压并生成配置(交互式引导, 逐项问答, 口令不回显)
        tar -xzf opendance-web-v0.7.4.tar.gz && cd opendance-web-v0.7.4
        bash deploy/init-env.sh
   5. 安装(建用户与目录 -> 装依赖 -> 迁移 -> 注册系统服务 -> 等健康检查)
        bash deploy/install.sh
   6. 验证(把 <服务器地址> 换成实际地址 -- 它是你们自己服务器的地址,
      不是被脱敏的信息)
        curl -fsS http://<服务器地址>:3000/health          # 期望 {"status":"ok",...}
        curl -sI  http://<服务器地址>:3001/ | head -1      # 期望 200, 管理端页面
        curl -sI  http://<服务器地址>:3000/ | head -1      # 期望 404, API 端口不提供管理页
   7. 可选: 回填历史模型的分析与缩略图(新装可跳过)
        cd /opt/opendance/web-server
        node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000
   8. 浏览器打开 http://<服务器地址>:3001/ 进入管理端, 上传曲目/动作/模型并审核
        模型上传后状态为"待审核", 审核通过才会被客户端拉取

  安全约定: deploy/.env 已被 .gitignore 忽略, 不要提交; 文档、截图、工单里
  也不要贴真实的数据库口令、内网地址或域名, 需要时用 <占位符> 代替。

职责边界
  本端只提供业务服务: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 开机自启)
 - Assimp 命令行工具(可选但推荐): .pmx/.fbx 模型转换与 FBX/glTF 动作预处理
    需要它; install.sh 会自动尝试安装(apt-get / dnf / yum / zypper / apk /
    pacman), 装不上只告警不中止。只上传 .glb/.vrm 模型与 .bvh/.vmd 动作则不需要
 - 反向代理: 由另外的服务提供, 本项目不要求、也不安装
 - 一个可连通的现有 MySQL / MariaDB 实例, 并已创建数据库与账号:

      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 等管理权限)

部署步骤
  tar -xzf opendance-web-v0.7.4.tar.gz
  cd opendance-web-v0.7.4
  bash deploy/init-env.sh         # 首次启动引导: 逐项问答生成 deploy/.env(见下)
  bash deploy/install.sh          # 已以 root 登录, 不需要 sudo

  也可跳过引导手工填写:
  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

升级
  解压新版本后重新执行 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

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

常见问题(初次部署)
 - 安装脚本报"未找到 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 / .pmx / .fbx 报转换失败(ENOENT / Assimp 不可用)
     PMX/FBX 转换依赖外部 Assimp: apt-get install -y assimp-utils
     (RHEL 系 dnf install -y assimp; install.sh 现在会尝试自动安装)。
     装好后不需要重启服务; 若不在服务 PATH 中, 在 deploy/.env 里把 ASSIMP_BIN
     设为绝对路径。只上传 .glb / .vrm 则不需要它。
- 导入后的模型颜色/观感与 MMD 里不一致
      PMX 的着色(卡通阶梯、球面贴图、平坦环境光)在 glTF 里没有等价实现。
      v0.2.5 起会补回双面、规范化 PBR 参数并清掉无关扩展; 想要最接近 MMD 的
      平涂颜色, 把 MODEL_SHADING=unlit 写进 deploy/.env 后重新导入模型。
      球面贴图与 toon 阶梯无法保留, 数量会记在模型的"转换说明"里。
 - 列表/审核里的缩略图是"白模"(纯白模型)
      v0.2.4 前的缩略图渲染器不采样贴图, 而 MMD 模型的 baseColorFactor 是白色。
      升级到 v0.2.4 后重新生成缩略图: cd /opt/opendance/web-server &&
      node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 --force
 - 导入模型返回 500 Internal server error
      老库上多为 models.stats 超长(骨骼树按骨骼展开, 643 骨骼即 70,980 字节
      > TEXT 上限): 执行 ALTER TABLE models MODIFY stats MEDIUMTEXT NULL;
      或部署 v0.2.3(install.sh 会自动跑迁移)。v0.2.3 起服务端另有兜底,
      不会再 500。
 - 安装时报 command not found / 退出码 127, 且提示指向 deploy/.env 某一行
      该行的值含空格或 shell 元字符却没加引号, 被 shell 当成命令执行了。按预检
      提示给这个值加引号(如 MODEL_CONVERT_PMX="Hatsune Miku.pmx")后重跑。
  - 模型审核界面显示"未分析 / 无预览图"
     该模型是升级前入库的: 调 POST /api/models/<id>/reanalyze, 或批量跑
     node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000。

--
二、本地开发
--

后端
  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              # 245 项单元测试 / 19 个文件(VMD 31、模型压缩包 24、
                            #   模型检查器 25、缩略图 8、骨骼过滤 8、
                            #   预处理 25、init-env 16、admin-port 16、
                            #   access-log 9、VRM 检查器 6、.env 预检 12、
                            #   Assimp 定位与安装 13、stats 容量与编码 12、缩略图贴图 6、
                            #   MMD 材质对齐 9、模型可用性 5、MMD 骨名重绑定 4、动作预览 8、场景管理 6、场景页 2)
  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
  .env 写法约定: install.sh / ctl.sh 用 `set -a; . deploy/.env` 载入配置, shell 会
  真的执行这个文件, 因此普通值不加引号(PORT=3000), 值里含空格或 shell 元字符
  (& | ; $ ` < > ( ) 等)时必须加引号, 例如 MODEL_CONVERT_PMX="Hatsune Miku.pmx";
  双引号内不要用 $( ) 或反引号(会被真的执行), 需要字面量时用单引号 KEY='a b'。
  两个脚本在 source 之前都会先跑 deploy/env-lib.sh 的语法预检, 不安全写法会直接
  指出行号、原文与改法。

  端口配置非法(非 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 可执行文件(install.sh 会自动安装并把解析出的绝对
                        路径写入此项)。.pmx/.fbx 模型转换与 FBX/glTF 动作预处理
                        需要; 缺失时上传会失败并给出安装命令, 只上传 .glb/.vrm 与
                        .bvh/.vmd 动作不受影响
  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_SHADING         转换后模型的着色模式: pbr(默认, 标准 PBR 并按 PMX 参数修正
                        doubleSided/metallic/roughness)| unlit(写 KHR_materials_unlit,
                        颜色 = 贴图 x baseColorFactor, 与场景光照无关, 最接近 MMD
                        的平涂观感)
  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                          上传模型 + 来源信息(.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 的版本后, 旧模型还没有分析与预览图。服务启动后执行一次:
    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 容器时留空, 列表页退化为格式占位图。
            实测: 512x512 PNG / 15,043 字节, 目视确认为居中的 T-pose 角色图;
            真实 10MB VRM(83 关节、5307 顶点)单张耗时 60-121ms(中位 79ms)。
  预览      管理端 3D 预览放开到 vrm/glb/gltf(.fbx 仍提示「仅归档, 请先转换为
            GLB/VRM」); 列表页显示 40px 小缩略图, 审核面板显示 116px 缩略图。

审核流程(模型)
  模型上传后为 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)。

    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 或 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         .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>/ 的「关于」页面。
