# opendance-web OpenDance Studio **阶段一**: Web 管理服务端(NestJS + TypeORM + 现有 MySQL 实例)。 管理歌曲、VRM 模型与动作来源,是 Windows 客户端与 Android 辅助 App 的唯一数据源与**契约权威**。 > **本服务端不部署、不管理 MySQL。** 数据库为部署方提供的现有实例,连接信息通过 `.env` 注入。 > > **本服务端也不部署反向代理。** 只提供业务服务(API + 自带管理端页面),反代由另外的服务/团队负责,本仓库仅在 `deploy/proxy/` 下提供配置说明文档。 ## 状态 **阶段一已完成**: T-WEB-001 -> T-WEB-017 全部落地,当前版本 **v0.7.5**(契约 `rest-api 0.5.0`、`websocket-keypoints 0.2.0`、`data-models 0.7.0`、`model-formats 0.5.0`、`skeleton-topology 0.2.0`、`database 0.4.0`)。 v0.7.4 修订(**场景功能的真正根因:`Scene` 没有注册进 `ENTITIES`**): 1. **根因(生产实测)**:`/health` 的 `scenes.error` 返回 `No metadata for "Scene" was found.` —— `src/entities/index.ts` 的 `ENTITIES = [Asset, Song, Motion, CharacterModel, ReviewRecord]` **漏了 `Scene`**。 TypeORM 只按 `ENTITIES` 注册 metadata,于是: * **启动完全正常**(DI 图可解析、`/health` 正常、`migrationsPending 0`), * 但**第一次查 Scene 就抛**`No metadata`,于是 `/api/scenes`、 `/api/catalog/export`、`/api/catalog/version`、`/api/reviews?status=pending` 全部 500 —— 客户端拉不到任何目录、管理端待审列表打不开; * 我此前"数据库缺 scenes 表"的判断**也是错的**:表其实是由 AddScenes 迁移建好的, 缺的是**实体注册**; 2. **修复**:`ENTITIES` 加入 `Scene`(并导出)。这也解释了为什么 v0.7.3 的降级修复是必要的 —— 即使根因是漏注册,降级也保证了"场景坏了不拖垮整站"; 3. **新增静态防线**`test/entities-registry.spec.ts`(4 项):扫描 `src/entities/*.entity.ts` 的 `@Entity()` 类,**逐一与 `ENTITIES` 对账** (漏一个即失败)、检查无重复/无多余,并核对 `AddScenes` 迁移文件确实存在。 已做**反向验证**:把 `Scene` 从 `ENTITIES` 移除,该测试立刻失败。 这类"漏注册"既过得了 `tsc`,也过得了启动冒烟(应用能启动), 只能靠静态核对兜住; 4. `web-server/scripts/diagnose-scenes.mjs` 保持不变(默认只读),部署 v0.7.4 后运行它 可确认 `scenes` 表与列的实际情况。 v0.7.5 修订(**不可用模型的原因归因必须准确**): 1. `unusableReason()` 原来只要 `stats` 里有 humanoid 数字就归因于 humanoid, 于是一个**直接上传的 `.glb`**(骨架完全合规、`drivable = true`、只是没有 VRM 容器) 会被写成「humanoid 不达标(19/22)」—— 审核界面据此会把修复方向引到"重新绑定骨骼", 而实际要做的是"以 VRM 交付"; 2. 现在按 **`drivable`** 区分:确实不满足 16 根必需骨 → 仍报 humanoid 覆盖度; 骨架已达标但没 VRM 容器 → 明确说明"已达标,需重新以 VRM 交付"; 3. 新增 2 项单测(5 → 7 项):「humanoid 已达标时**不得**出现『humanoid 不达标』字样」 与「`drivable=false` 时仍归因于 humanoid」; 4. **上传落盘的对称兜底**:`persistTempFile`(动作/模型/场景上传走的通路)在去重命中时 会直接删掉刚上传的**唯一副本**并返回旧记录 —— 若记录指向的路径已不存在 (换过 `STORAGE_DIR`、只恢复数据库、手工删过文件),上传仍返回 201, 但**后续动作预处理 500**(实测 `ENOENT ... .vmd`)、模型分析与缩略图静默失败。 `persistBuffer` 早有兜底,这条通路漏了;现按记录里的原路径补写。 新增 `test/storage-persist.spec.ts`(5 项)覆盖首次上传 / 去重命中 / **文件已丢失补写** / 内存通路回归 / 空文件拒绝,并做了反向验证(还原修复后该用例立刻失败); 5. 跨端边界验收(Windows × Web)新增两类场景:不可用模型(真实 PMX 转 GLB 后入库) **不进入** `catalog/export` 且 `defaultModelId` 不变;场景 `config` 缺字段时服务端补齐默认值。 v0.7.4 之后的测试补强(**不改产物,故不新发版本**): 1. 新增 `web-server/test/real-samples.spec.ts`(6 项,opt-in):用仓库根 `示例文件/` 下的**真实素材**跑完整管线,补上"合成夹具证明不了真素材能出片"的缺口 —— * ① 模型→VRM:初音未来 19/22、奥黛塔 21/22 humanoid,`drivable = true`, 18 张贴图全内嵌、36 项双面材质补回,并逐字节校验 **POSITION 访问器的 `min`/`max` 等于真实包围盒**(glTF 规范唯一强制填写的边界,客户端据此剔除)、 蒙皮下标不越界; * ② 动作内部标准 + 骨骼重绑定:真实 VMD 31 条轨道、标准骨架 31/31 命中、 2901 帧产出 33+2 点与 9 个关节角,15 个直接关节置信度恒为 `1.0`(不退化成推算值); * 样例或 Assimp 缺失时整体跳过,CI 与无素材的开发机不受影响; 2. 修 `test/assimp-locate.spec.ts` 的非密闭性:该文件的用例断言的是 **PATH / 常见系统目录**的查找行为,但会继承外部 `ASSIMP_BIN` (`assimp-lib.sh` 里 `ASSIMP_BIN` 优先级高于 PATH)。开发者按本项目文档 在 shell 里设过 `ASSIMP_BIN` 时 `npm test` 会假失败,现已在 `runLib()` 里剔除; 3. 规模 **25 文件 / 295 项**(三个真实样例齐备并装好 Assimp 时的实测数)。 v0.7.3 修订(**场景表不可用不再拖垮整站** —— 生产事故修复): **另有一个更直接的缺陷同时修掉**:`ScenesService.create()` 当初按**内存存储**写, 判断的是 `file.buffer`,而上传中间件(`storage.module.ts`)用的是 multer 的 **diskStorage** —— 文件对象只有 `path`、没有 `buffer`,于是**任何场景都传不上来**, 一律 400「上传场景必须带 file 字段」。现在与 models/motions 走同一套: `requireUploadedFile()` 校验落盘并修正 latin1 文件名编码,直接导入走 `persistTempFile()`(顺带清理临时文件),需转换时 `readFile(upload.path)` 后再转, 并在 `finally` 中清掉临时文件; 1. **现场**:数据库里没有 `scenes` 表(迁移没落到该库 / 部署的 dist 里缺迁移文件),于是 `GET /api/scenes`、`/api/catalog/export`、`/api/catalog/version`、 `/api/reviews?status=pending` **全部 500**,而 `/api/songs`、`/api/models` 正常。 后果是**客户端拉不到任何目录**(Windows/Android 都用不了)、管理端待审列表也打不开; 2. **契约与文档都写明「场景是可选资源」**(没有可用场景时客户端回退内置空场景),所以 场景表不可用**不应该**连带打挂与场景无关的功能。本次改为**降级**: * `ScenesService` 新增 `queryScenes()` 包装:数据库层面失败时返回空结果、 **只记一次日志**(避免刷屏),并把原因记在 `diagnostics()` 里; * 表恢复后自动回到可用状态(下一次成功查询即清除降级标记); * `ReviewsService.listByStatus()` 对场景查询单独 catch,待审列表照常返回其它资源; * `/health` 新增 `scenes: { available, error }` —— **降级但绝不静默**; 3. **新增诊断脚本** `web-server/scripts/diagnose-scenes.mjs`(默认只读): 打印运行期真实连的库、表清单、`typeorm_migrations` 记录、**代码实际加载的迁移** (用于识别"迁移没被加载 → `/health` 显示 pending 0 但表不存在"这种最隐蔽的情况)、 以及 `scenes` 实际列 vs 实体期望列的逐列 diff;`--fix` 仅在缺表时用迁移里 同一份 DDL 幂等补表; 4. 顺带修正 `models.controller.ts` 里一句误导性注释:模型上传**不接受** `isPrimary`/`status`(契约:上传一律 `pending`,默认模型由审核通过时带 `setPrimary=true` 或 `POST /api/models/:id/primary` 设定) —— 冒烟脚本此前正是 按错误预期断言,已同步修正; 5. 新增 6 项测试(`test/scenes-degradation.spec.ts`)锁定降级行为。 6. **更正一次误判**:排查过程中曾把 Gitea 附件接口的 `HTTP 500 {"message":"","url":"…/api/swagger"}` (空消息)判成"宿主机磁盘写满"。**实际是上传写法错误** —— 该接口 multipart 字段名是 **`attachment`**,用 `file=` 就会得到这个空消息 500。实测对照:同一 1 KB 文件, `-F "attachment=@…"` → **201**,`--data-binary @…` + `?name=` → **201**, `-F "file=@…"` → **500**。两种正确写法已写进 `deploy/proxy/README.md` 第九节。 **仍然真实存在的唯一限制是 IIS `maxAllowedContentLength`(默认 30,000,000 字节)**: 927 KB / 14 MB / 16.4 MB 通过,30.5 MB / 38.2 MB / 40 MB 一律 HTTP 413。 v0.7.2 修订(**修复产物"装得上、起不来"+ `/health` 报错版本号**): 1. **`CatalogModule` 漏 import `ScenesModule`(致命)**:`CatalogService` 注入了 `ScenesService`(因为 `catalog/export` 要下发 `scenes[]`),但 `CatalogModule` 只在 `imports` 里列了 `SongsModule / MotionsModule / ModelsModule`。Nest 的 DI 是**按模块**解析的,于是 `node dist/main.js` 在 bootstrap 阶段直接抛 `UnknownDependenciesException` —— **v0.7.0 / v0.7.1 的产物根本起不来**(装得上、起不来)。而当时 `nest build` 通过、258 项单元测试全绿、打包脚本的产物完整性校验也通过,因为它们都**从不编译 AppModule**; 2. **新增启动冒烟 `scripts/check-bootstrap.mjs`(并挂进 `verify` 与打包脚本)**:以正式部署方式(`ExecStart=node dist/main.js`,**不带任何 npm 环境变量**、不需要数据库)拉起真实产物,断言①日志里没有依赖解析类错误(模块图可解析)②日志里的「应用版本 vX.Y.Z」等于 `package.json` 的 version ③启动确实推进到了数据库连接阶段。已做**反向验证**:把 `ScenesModule` 删掉后该检查立刻失败; 3. **`/health` 的版本号在原生部署下永远是 0.1.0**:原来用 `process.env.npm_package_version ?? '0.1.0'`,而该变量只在 npm 脚本下存在,正式部署是原生服务 → 为 `undefined` → 与管理端左下角展示的 vX.Y.Z 不一致,违反文档 §11.2 第 4 条「可核对」。新增 `src/config/app-version.ts` 作为**运行时唯一解析点**(`APP_VERSION` → 运行入口旁/工作目录的 `package.json`(带包名校验) → `npm_package_version` → 显式 `0.0.0`),`main.ts` 启动首行也打印版本号,便于与 `/health`、管理端核对; 4. **新增 `scripts/check-version.mjs`**(并入 `contracts:check`):校验 `web-server/package.json` = `admin/package.json` = `project.yaml`,**禁止** `health.controller.ts` 再出现硬编码版本回退;管理端已构建时断言产物 JS 内含 `vX.Y.Z`;打包脚本在构建管理端后再次执行; 5. 新增 12 项测试(`test/app-version.spec.ts`),其中一项显式模拟「无 npm 环境变量」的原生部署。 v0.7.1 修订(**契约补齐 + 文档补齐**,契约 `websocket-keypoints` 0.1.0 -> 0.2.0): 1. **补齐 `rest-api.yaml` 里漏写的场景结构**:`catalog/export` 自 v0.7.0 起就返回 `scenes[]`,`data-models.md` 7 也已冻结,但 `CatalogBundle` 漏了这个字段、`components` 里也没有 `Scene`/`SceneConfig`。本次只补规格、**不改线格式**,故六个契约版本号不变(rest-api 仍 0.5.0); 2. **契约 `websocket-keypoints` -> 0.2.0**(T-WEB-016,与 Android `T-AND-011` 的"两端一起改"配套):新增 1.1 **保活**(接收端必须声明 `silentTimeoutMs`/`heartbeatIntervalMs`,且**心跳 < 静默超时的一半**)、3.1 `hello.deviceId`、3.2/3.3 welcome/config 携带保活字段、3.6 应用层 `ping`/`pong`、5 **设备去重**、7 **关闭码与重连策略**、7.1 退避与 ±20% 抖动、8 **连接状态机**(两端同名)。子协议名与二进制帧 `version` 均不变,**向后兼容**; 3. **`deploy/proxy/README.md` 新增第九节**:托管 Gitea 的 IIS 站点附件上传上限(与一~五节的 Web 服务反代是**两个不同站点**)。实测 30.5 MB / 40 MB 返回 HTTP 413,根因是 IIS 默认 `maxAllowedContentLength = 30,000,000` 字节;给出放宽到 256 MB 的 IIS/ARR/Gitea `app.ini`/nginx 四处配置与复验命令 —— 这正是 Windows 端 v0.1.0 被拆成 `zip.part0/1/2` 的根因; 4. **补齐 `tasks/011` ~ `tasks/016`**:此前 T-WEB-011~015 已在代码中落地但缺少任务说明书与 `project.yaml` 条目,本次一并补上(T-WEB-011 版本号展示 / 012 VRM 优先 / 013 动作标准与骨骼重绑定 / 014 动作预览 / 015 场景管理 / 016 契约 0.2.0); 5. 规模 **18 文件 / 246 项**测试,`contracts:check` 通过、`check-routes` 35 条路由一致。 v0.7.0 修订(**管理端场景页**,补齐场景端到端可用): 1. **管理端「场景管理」页**(`/scenes`):列表(40px 缩略图 + **物理摘要**「重力 -9.8 | 风 5.0m/s + 阵风 2.5m/s@0.5Hz」+ 状态)、上传表单(**GLB 文件 + 名称 + 来源五元组 + 物理参数**:重力、风基础向量、阵风幅度/频率、地面摩擦/坡度、碰撞体、环境光)、详情面板(复用 3D 预览 + **参数可视化编辑并保存**); 2. 新增 `physicsSummary()` 纯函数与场景页样式(两栏自适应);路由 `/scenes` + 侧栏「场景管理」入口; 3. 新增 2 项测试;规模 **19 文件 / 245 项**。 v0.6.0 修订(**场景管理**,契约 rest-api -> 0.5.0、data-models -> 0.7.0、database -> 0.4.0): 1. **新增场景管理**(T-WEB-015):场景**以 GLB 为主**(不做 humanoid 重绑定、不转 VRM);上传 `.glb/.gltf` 直接导入,`.zip/.pmx/.fbx` 由服务端转 GLB; 2. **场景物理参数**(`SceneConfig` 冻结在契约 data-models 7.1):重力向量、**风向动态**(基础风 + 阵风幅度/频率/方向/坐标系)、地面摩擦/弹性/坡度、碰撞体来源(auto/mesh/none)、环境光/雾、出生点(≤16)、相机;**服务端只存参数、不跑物理**(仿真在客户端 Godot); 3. **入库即收敛**:越界夹紧、非法枚举回退默认值,保证客户端拿到的参数一定可解释;`scenes.config`/`stats` 用 MEDIUMTEXT; 4. **端点**:`GET/POST /api/scenes`、`GET/PATCH/DELETE /api/scenes/{id}`(共 35 条路由,`check-routes` 通过);审核复用 `/api/reviews/scene/:id/approve|reject`(上传一律 `pending`);`catalog/export` 增 `scenes[]` 且指纹纳入场景变更; 5. 缩略图复用模型光栅化器 + 分析复用模型检查器;新增 6 项测试;规模 **19 文件 / 243 项**。 v0.5.0 修订(**动作预览**,契约 data-models -> 0.6.0): 1. **动作骨架缩略图(服务端)**:新增 `motions/motion-thumbnail.ts`——把关键点序列画成正视图骨架 PNG(零原生依赖,复用抽出的 `common/png.ts`);随 `/preprocess` 生成并落库(`motions.thumbnail_asset_id`,迁移 `AddMotionThumbnail`),失败只记 warning 不影响预处理; 2. **动作 3D 回放(管理端,重要)**:新增 `MotionPreview`——懒加载 `GET /api/motions/:id/keypoints`(NDJSON),骨架线框 + 关节球体,支持**播放/暂停、上一帧/下一帧、时间轴拖动、0.25x-2x 变速、循环**,叠加"第 N/M 帧 · 当前时间 · 总时长",按整段包围盒自动取景;three.js 单独分包,主包体积不涨; 3. **动作管理列表**:新增 40px 骨架缩略图列(点击开预览)+「预览/收起预览」按钮; 4. 关键点解析与拓扑(管理端 `lib/keypoint-sequence.ts` / `lib/skeleton-topology.ts`)纯函数化,便于测试; 5. 新增 8 项测试;规模 **18 文件 / 237 项**。 v0.4.0 修订(**骨骼重绑定**:把"一律转 VRM"真正可用,契约 skeleton-topology -> 0.2.0): 1. **修复 humanoid 覆盖率恒为 0/22 的根因**:`normalizeBoneName()` 原先只保留 `a-z0-9`,**中日文字符被整个丢掉**(`上半身`/`左ひじ`/`首` 全变空串);现在保留 CJK/假名,并把全角 ASCII(`足IK`)折成半角; 2. **候选表补齐四类命名**:VRoid / Mixamo / **MMD 标准骨架**(`下半身`/`上半身`/`上半身2`/`首`/`頭`/`左肩`/`左腕`/`左ひじ`/`左手首`/`左足`/`左ひざ`/`左足首`/`左つま先`)/ 常见中文名;精确匹配保证 `左足IK` 不抢占 `左足`; 3. **服务端与 Windows 客户端逐字一致**:`humanoid_map.gd` 的 `CANDIDATES` 由服务端表生成写入,parity 测试断言; 4. **实测**:奥黛塔 PMX **0/22 → 21/22**、初音未来 PMX **0/22 → 19/22**,均 `drivable=true` 且产出 VRM; 5. 契约 `skeleton-topology` -> **0.2.0**:新增 §6「内部动作标准与模型骨骼重绑定」。 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;支持库沿用自研 `vrm-writer`/`vrm-inspector` + Assimp(可选 `@gltf-transform/core` 做结构校验); 2. **humanoid 门槛与可用性标记**(`usable`):必需骨齐全才产出 VRM;不达标(如 MMD 日文骨名 `0/22`)时回退交付 GLB、`usable=false` —— **可入库、可审核,但不进入 `catalog/export`**(`/api/catalog/version` 指纹同步过滤);待骨骼重绑定后重新导入即转为可用; 3. 模型列表与审核面板显示 **「不可用」及其原因**(`unusableReason`,例如 `humanoid 不达标(0/22),未产出 VRM`);`resolveDefaultModelId` 只在可用模型中选择; 4. **管理端左下角展示当前版本号**(v1.5 文档第十一章要求):构建期从 `web-server/package.json` 注入 `v__APP_VERSION__`,与 `GET /health` 的 version **同源**;顺带修掉右下角写死的 `契约 rest-api 0.1.0`; 5. 新增 5 项测试(`test/model-usability.spec.ts`);规模 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)`(MMD 常见 shininess=1 -> 0.99 哑光)、`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)处理透明度;解码失败或非 PNG 时回退纯色,绝不抛错; 2. 缩略图取景收紧(FRAMING 1.6 -> 1.3),40px 列表缩略图里主体更清楚; 3. 新增 6 项贴图/透明度/朝向测试(`test/model-thumbnail-texture.spec.ts`); 4. **说明**:GLB 数据本身没问题——贴图逐字节内嵌(18/18)、39 个材质的贴图对应关系与 PMX 完全一致、UV 取到的就是皮肤/五官区域;白模只出在缩略图渲染器。 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. **修复部署失败**:`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. 取得发布包并校验(**本仓库的真实发布地址**, 域名 `gitea.kasugano.cn`、属主 `zijunchiang` 属部署必需信息, 不是隐私项) ```bash curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.4/opendance-web-v0.7.4.tar.gz curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.4/SHA256SUMS sha256sum -c SHA256SUMS # 期望输出 "OK" ``` 4. 解压并生成配置(交互式引导,逐项问答,口令不回显) ```bash tar -xzf opendance-web-v0.7.4.tar.gz && cd opendance-web-v0.7.4 bash deploy/init-env.sh ``` 5. 安装(建用户与目录 -> 装依赖 -> 迁移 -> 注册系统服务 -> 等健康检查) ```bash bash deploy/install.sh ``` 6. 验证(把 `<服务器地址>` 换成实际地址 —— 它是**你们自己服务器的地址**, 不是被脱敏的信息;本项目的文档只对 Gitea 域名这类部署必需信息直接写明真实值) ```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` 开机自启) - Assimp 命令行工具(**可选但推荐**):`.pmx/.fbx` 模型转换与 FBX/glTF 动作预处理需要它; `install.sh` 会自动尝试安装(apt-get / dnf / yum / zypper / apk / pacman),装不上只告警不中止。 只上传 `.glb/.vrm` 模型与 `.bvh/.vmd` 动作则完全不需要 - 反向代理:由另外的服务提供,本项目不要求、也不安装 - 一个可连通的现有 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.7.4.tar.gz cd opendance-web-v0.7.4 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 且是 `` 为服务器 IP) | 入口 | 地址 | | --- | --- | | 客户端接口 | `http://:3000/api` | | 健康检查 | `http://:3000/health` | | 契约快照 | `http://:3000/contracts/` | | 管理端首页 | `http://:3001/` | 软件 API 端口上没有管理页面:`http://: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` / `.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"`)后重跑;`deploy/env-lib.sh` 会提前拦住并给出行号 | | 模型审核界面显示"未分析 / 无预览图" | 该模型是升级前入库的:调 `POST /api/models//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 # 契约版本一致性(6 项契约版本 + 35 条路由 + 三端版本号) npm run typecheck # TypeScript 严格检查 npm run test # 309 项单元测试 / 27 个文件。分布(VMD 31、模型检查器 29、 # 模型压缩包 27、预处理 25、admin-port 16、init-env 16、 # Assimp 定位与安装 13、应用版本 12、目录导出 12、.env 预检 12、 # 访问日志 9、MMD 材质对齐 9、stats 容量与编码 9、场景配置 9、 # 骨骼过滤 8、缩略图 8、动作预览 8、缩略图贴图采样 6、 # 真实素材回归 6、场景降级 6、VRM 检查器 6、模型可用性 5、 # 场景上传 5、实体注册 4、场景端到端(真实 sql.js 库) 4) # opt-in: test/real-samples.spec.ts 缺样例/Assimp 时跳过 3 项 npm run build # 生产构建 npm run start:prod & # 启动 npm run test:smoke # 81 项端到端冒烟(需要数据库可连通) cd ../admin && npm run build # 管理端构建 ``` 真实素材回归(需要仓库根的 `示例文件/` 与外部 Assimp;样例缺失时自动跳过): ```bash cd web-server ASSIMP_BIN=/usr/bin/assimp npm test -- test/real-samples.spec.ts # 样例放在别处: OPENDANCE_SAMPLES_DIR=/path/to/示例文件 ASSIMP_BIN=... npm test # 覆盖: 两个真实 PMX→VRM(初音 19/22、奥黛塔 21/22 humanoid) + 真实 VMD→33 点内部标准 ``` ## 环境变量 | 变量 | 默认 | 说明 | | --- | --- | --- | | `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` | Assimp 可执行文件(`install.sh` 会自动安装并解析出绝对路径写入此项)。`.pmx/.fbx` 模型转换与 FBX/glTF 动作预处理需要;缺失时上传会失败并给出安装命令,只上传 `.glb/.vrm` 不受影响 | | `MODEL_CONVERT_FORMAT` | `vrm` | 需要转换的模型(PMX/FBX)的交付格式:`glb`(两个客户端都原生加载)或 `vrm`。`vrm` = 在 GLB 之上补 `VRMC_vrm` 扩展(humanoid 骨骼 / 表情语义),humanoid 骨骼映射不全时自动回退交付 GLB 并写进 `conversion_note`。产物一律用 `assimp info` 回读校验 | | `MODEL_CONVERT_PMX` | `"Hatsune Miku.pmx"` | 压缩包内优先选用的 PMX 文件名(大小写不敏感);找不到时按「格式优先级 -> 目录最浅 -> 文件最大」挑选 | | `MODEL_SHADING` | `pbr` | 转换后模型的着色模式。`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`) | 端口配置非法(非 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////.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 + 平面着色 + **逐像素采样 baseColorTexture**,内置 PNG 解码器 -> 手写 PNG 编码),**零原生依赖**(不需要 canvas / headless-gl / puppeteer);按 `alphaMode` 处理 OPAQUE / MASK / BLEND。默认 512x512,写入 `models.thumbnail_asset_id` / `thumbnailUrl`;失败或非 glTF 容器时留空,列表页退化为格式占位图 | | 预览 | 管理端 3D 预览放开到 `vrm/glb/gltf`(`.fbx` 仍提示「仅归档,请先转换为 GLB/VRM」);列表页显示 40px 小缩略图,审核面板显示 116px 缩略图 | 实测:512x512 PNG / 15-42 KB(带贴图后变大),目视确认为居中的 T-pose 角色图且能看到肤色/眼睛/服装贴图;真实 10MB VRM(83 关节、5307 顶点)单张耗时 60-121ms(中位 79ms),27MB PMX 转换件约 200-400ms。 ### 审核流程(模型) 模型上传后为 `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:///` 的「关于」页面。