10 Commits
Author SHA1 Message Date
zijunchiang 74332e20d8 docs(proxy): 更正「Gitea 附件通道坏了/磁盘写满」的误判 —— 实际是上传字段名写错
排查 Windows 整包上传时的 500 曾被判成"Gitea 宿主机磁盘写满"。**这个结论是错的**,
本次用对照实验确认并更正:Gitea 的 Release 附件接口 multipart 字段名是
**`attachment`**,用 `file=` 会得到

  HTTP 500 {"message":"","url":"https://gitea.kasugano.cn/api/swagger"}

空消息,极易误判成服务器故障。同一 1 KB 文件、同一 Release 的实测对照:
  -F "attachment=@probe.bin"                      → 201
  --data-binary @probe.bin (+?name=)              → 201
  -F "file=@probe.bin"                            → 500(空消息)

**仍然真实存在、且是唯一限制的是 IIS `maxAllowedContentLength`(默认 30,000,000 字节)**:
927 KB / 14 MB / 16,372,427 B 通过;30.5 MB / 38,245,602 B / 40 MB 一律 HTTP 413。

文档更正:
- deploy/proxy/README.md 第九节重写 9.1(含"误判更正"小节)与 9.3(复验命令改用
  正确字段名),并给出两种可直接照抄的上传写法;
- DSH 开发文档 v1.6 §5.7 的"阶段二"表格改为"误判更正"记录;
- opendance-windows/tasks/014-full-package.md 同步更正并附正确上传命令;
- 本仓库 README.md/.txt 的 v0.7.3 说明补第 6 条。

验证:npm run verify 通过(22 文件 / 281 项测试 + 启动冒烟);重新打包
opendance-web-v0.7.3.tar.gz(sha256 见 dist-release/SHA256SUMS)。
2026-09-17 03:39:36 +08:00
zijunchiang 828c7cea49 fix(scenes): 场景上传一律 400 —— diskStorage 却按内存存储判断 file.buffer
现场(真实部署 v0.7.2,用 curl 对照验证过):
  POST /api/models  → 201(同一份 multipart 文件)
  POST /api/scenes  → 400「上传场景必须带 file 字段(.glb 为主…)」
即**任何场景都传不上来**(冒烟脚本里 6 项场景断言全红)。

根因:上传中间件在 storage.module.ts 里配置为 multer 的 **diskStorage**
(先落 ${STORAGE_DIR}/tmp,避免 512 MiB 级 VRM 打爆内存),落盘后的文件对象
**只有 path、没有 buffer**;而 ScenesService.create() 当初按内存存储写:

  if (!file || !file.buffer || file.buffer.length === 0) { …400… }

file.buffer 恒为 undefined,于是必然 400。

修复:与 models/motions 统一到同一套上传处理
- requireUploadedFile(file):校验确实落盘(path),并修正 busboy 按 latin1 解出的
  中文/日文文件名;
- 直接导入(.glb/.gltf):storage.persistTempFile(AssetKind.Scene, upload.path, …)
  —— 归档并顺带清理临时文件;
- 需转换(.zip/.pmx/.fbx):readFile(upload.path) 取 buffer 后转换 → persistBuffer,
  并在 finally 中 unlink(upload.path);
- 体积上限用 upload.size 判断(UPLOAD_MAX_BYTES),超限 400 且不落盘;
- UploadedSceneFile 改为 UploadedFileLike 的别名(有 path 无 buffer);
- 日志里的文件名改用解码后的 upload.originalname。

同时全局排查过同类写法:除这两处注释外,代码里已无按 file.buffer 处理上传的地方。

新增 test/scene-upload.spec.ts(5 项,修复前必失败):diskStorage 形状的文件不再
被判成「缺少 file 字段」、GLB 直传标注未做重编码且落 pending、中文文件名被还原、
缺文件仍 400、超限 400 且不落盘。

文档:README.md/.txt 的 v0.7.3 说明补上这条;tasks/015-scene-management.md 增
「缺陷修复记录三」。

验证:npm run verify 通过(契约六项一致、35 条路由、22 文件 / 281 项测试、
nest build、启动冒烟);重新打包 opendance-web-v0.7.3.tar.gz
sha256 07463a3b9f971346d0af1f73919e6e4dfa98ef20fd160419405d57272e9d4524。
2026-09-17 03:28:31 +08:00
zijunchiang f3b97fec53 fix(scenes): 场景表不可用不再拖垮整站(生产事故修复)+ 诊断脚本
现场(真实部署 v0.7.2):数据库缺 scenes 表,于是
  /api/scenes、/api/scenes/:id、/api/catalog/export、/api/catalog/version、
  /api/reviews?status=pending 全部 500,而 /api/songs、/api/motions、/api/models
  正常。后果不是"场景不可用",而是客户端拉不到任何目录(Windows/Android 全废)、
管理端待审列表打不开。`/health` 却显示 database.connected=true、
migrationsPending=0 —— 因为 DataSource.showMigrations() 只比较**代码里加载到的**
迁移;部署的 dist 若缺 AddScenes.js,该迁移不在比较集合里,于是"没有待执行迁移"
但表不存在(最隐蔽的一种不一致)。

修复思路:契约与文档都写明「场景是可选资源」(没有可用场景时客户端回退内置空
场景),所以场景表的问题不允许影响与场景无关的功能 —— 改为降级:

- ScenesService 新增 queryScenes() 包装:数据库层面失败时返回空结果,**只记一次
  日志**(避免刷屏),原因写入 diagnostics();下一次查询成功即自动复位;
- ReviewsService.listByStatus() 对场景查询单独 catch,待审列表照常返回其它资源;
- /health 新增 scenes: { available, error } —— 降级但绝不静默;
- HealthModule 增加 imports: [ScenesModule](HealthController 注入了 ScenesService,
  Nest 的 DI 按模块解析,漏了会直接 UnknownDependenciesException;
  scripts/check-bootstrap.mjs 已能拦住这一类)。

新增 web-server/scripts/diagnose-scenes.mjs(默认只读):打印运行期真实连的库、
表清单、typeorm_migrations 记录、**代码实际加载的迁移**、scenes 实际列 vs 实体
期望列的逐列 diff;`--fix` 仅在缺表时用迁移里同一份 DDL 幂等补表。

顺带修正 models.controller.ts 的误导性注释:模型上传**不接受** isPrimary/status
(契约:上传一律 pending,默认模型由审核通过时带 setPrimary=true 或
POST /api/models/:id/primary 设定)。test/smoke.mjs 此前正是按错误预期断言
"isPrimary 已置位",已改为:上传断言 pending + isPrimary=false → 审核通过时带
setPrimary=true → 再断言 isPrimary=true;同时给目录导出/指纹等检查补上失败细节
(HTTP 状态 + 响应体),并把待审列表查询的 pageSize 放大到 200(避免库里待审项多
时新条目被挤出第一页)。

文档:README.md/.txt 增加 v0.7.3 修订说明;tasks/015-scene-management.md 增
「缺陷修复记录二」;开发文档 §4.12 补充硬性要求(场景不可用必须降级 + /health 暴露
+ showMigrations 的陷阱说明)。

测试:新增 test/scenes-degradation.spec.ts(6 项);npm run verify 通过
(契约六项一致、35 条路由、21 文件 / 276 项测试、nest build、启动冒烟);
发布产物解压后 npm ci --omit=dev + node dist/main.js 实测打印「应用版本 v0.7.3」。

注意:Gitea 附件上传通道仍故障(1 KB 也瞬时 500),因此本版**尚未上传 Release**,
产物先在本地交付(sha256 见 SHA256SUMS)。
2026-09-17 03:24:56 +08:00
zijunchiang 2224195d05 docs: 补齐部署必需的 Gitea 域名与属主(不再脱敏成占位符)
域名与仓库属主属于「部署必需且非隐私」的信息,此前写成 `<Gitea 地址>` / `<owner>`
会让文档无法照做(照抄的 clone / 下载命令直接失败)。本次把它们补成真实值:
gitea.kasugano.cn / zijunchiang。口令、访问令牌、数据库口令等**秘密项**继续用占位符。

改动:
- README.md / README.txt:发布包下载地址补成真实 Release 地址
  (https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/vX.Y.Z/…),
  并写明域名与属主是部署必需信息、不是隐私项;
- README.md / README.txt:说明 `<服务器地址>` 是**部署方自己的**服务端地址
  (部署方决定),不是被脱敏的信息,避免与「被隐藏的域名」混淆;
- deploy/proxy/README.md:第九节的附件接口复验 curl 补成真实 Gitea 域名与属主;
  自检清单里 `<反代地址>` 加一行说明(你们自己的反代域名或 IP);
- deploy/systemd/opendance-api.service:`Documentation=` 补成真实仓库地址。
2026-09-17 03:07:23 +08:00
zijunchiang 50688e7ce4 test: 补目录导出交付逻辑与场景端到端冒烟(①/④ 的验收证据)
目标 ④(场景管理)此前只有"上传/配置收敛"的测试,**没有任何测试覆盖
「场景是否真的随 catalog/export 下发到客户端」**;①(非 VRM 不进目录)也只有
实现没有断言。补上两层:

1. 新增 test/catalog-export.spec.ts(12 项,纯 mock、不依赖数据库)
   - ① 目录查询必须同时限定 status=published 与 format=vrm;
     /catalog/version 的模型查询同样只算 VRM;
     GLB/glTF/FBX 这类"未产出 VRM"的中间格式不得进入目录;
   - ④ exportBundle().scenes 来自 ScenesService.published(),且
     重力/风/地面/碰撞体等物理参数完整透传;无场景时是空数组而非 undefined;
   - ④ 场景新增/改名/换 GLB/更新时间变化都必须改变 catalog/version 指纹
     (否则客户端不会重新拉取);
   - defaultModelId 透传与空值回退。

2. test/smoke.mjs 新增 [7] 场景管理端到端段(18 项断言),并在 [9] 清理:
   - 上传 GLB 场景 → 一律 pending、GLB 直传不重编码、glbUrl 生成;
   - 合法物理参数原样入库;越界参数(重力 -9999、摩擦 5)被夹紧到 -200/1;
     非法枚举(colliders/相机模式)回退默认值;
   - 缺少来源五元组 → 400;
   - **待审场景不出现在客户端目录**;审核通过后才进入 scenes[],且带完整物理参数;
   - 目录指纹随场景发布而变化;GET /api/scenes/:id 与 ?status= 过滤可用。

   夹具可信性已单独验证:用**真实检查器**(inspectModelFile)解析 smoke 里同款
   构造方式生成的场景 GLB,得到 meshes=1 / vertices=3 / triangles=1 / nodes=1,
   证明该夹具是合法 GLB,而不是"写了个跑不通的脚本"。

顺带核查管理端构建产物:/scenes 路由、"场景管理"入口、"v0.7.2" 版本标签与
物理参数相关文案都在 bundle 中(Web 左下角版本号来自 package.json,见
scripts/check-version.mjs)。

验证:npm run verify 通过(契约六项一致、35 条路由一致、版本一致、
20 文件 / 269 项测试、nest build、启动冒烟)。
2026-09-17 01:56:20 +08:00
zijunchiang 7037abf066 docs(proxy): 修正 Gitea 附件上传失败的结论——与体积无关,指向宿主机磁盘
上一版把失败记成「两阶段 + 约 16.4 MB 边界」,那是**误判**:
当时磁盘正在被失败的请求逐步填满,所以看起来像有个体积阈值。补做与体积、
与仓库都无关的隔离测试后,结论要改:

- 1 KB 与 100 KB 也失败,且**瞬间**失败(0.14~0.3 s,不是读完请求体才判断)
  -> 不是 [attachment] MAX_SIZE 这类体积上限;
- opendance-web / opendance-windows / opendance-android 三个 Release 的资产
  上传接口**同样**失败 -> 与仓库无关;
- 创建 Release、PATCH Release 说明(纯 JSON)都成功 -> 只有「附件文件落盘」
  这条通道坏掉;
- 全部失败期间仍有 927 KB 的附件成功上传过一次 -> 间歇性。

最可能的原因:**Gitea 宿主机磁盘(或上传临时目录所在分区/tmpfs)几乎写满**
(Gitea 先把请求体写入数据目录的临时目录,空间不足时返回 500 而非 413)。

同时确认:IIS 那一关**已经放开且生效**(38,245,661 字节的请求体被完整接收,
不再 413),因此若仍失败应直接排查 Gitea 宿主机,而不是继续改 IIS。

deploy/proxy/README.md 第九节的 9.1 重写为上述隔离结论,并给出运维排查顺序
(df -h / Gitea 日志里的真实错误 / 清理临时文件 / 最后再改 MAX_SIZE)。

Windows 端 tasks/014-full-package.md 同步修正(原先的「16.4 MB 边界」表述作废)。
2026-09-17 01:51:32 +08:00
zijunchiang 2433cd22a5 fix(boot): CatalogModule 漏 import ScenesModule 导致产物起不来 + /health 版本号错
两个都是**只有真正启动服务才会暴露**的缺陷,v0.7.0 / v0.7.1 的产物受影响。

1. 【致命】CatalogModule 漏 import ScenesModule
   CatalogService 因为 catalog/export 要下发 scenes[] 而注入 ScenesService,但
   CatalogModule 的 imports 只列了 SongsModule / MotionsModule / ModelsModule。
   Nest 的 DI 按模块解析,于是 node dist/main.js 在 bootstrap 阶段直接抛
   UnknownDependenciesException: Nest can't resolve dependencies of the
   CatalogService (... ? ...) —— 服务完全起不来(装得上、起不来)。
   为什么三层检查全绿:nest build/tsc 看不出运行时 DI;258 项单元测试直接 new
   服务或测纯函数,从不编译 AppModule;打包脚本只校验文件清单与压缩包完整性。
   修复:CatalogModule.imports 补上 ScenesModule。

2. /health 的 version 在原生部署下永远是 0.1.0
   原实现 process.env.npm_package_version ?? '0.1.0',而该变量只在 npm 脚本下
   存在;正式部署是原生服务(ExecStart=node dist/main.js),npm 不参与 ->
   恒为 undefined -> 回退到硬编码 0.1.0,与管理端左下角展示的 vX.Y.Z 冲突,
   违反文档 11.2 第 4 条「可核对:Web 端至少与 GET /health 的 version 一致」。
   修复:新增 src/config/app-version.ts 作为运行时唯一解析点
   (APP_VERSION -> 运行入口旁/工作目录的 package.json(带包名校验)
   -> npm_package_version -> 显式 0.0.0),/health 改用 resolveAppVersion(),
   main.ts 启动首行也打印版本号。新增 12 项测试,其中一项显式模拟无 npm
   环境变量的原生部署。

防线(都挂进了 verify 与打包脚本)
- scripts/check-bootstrap.mjs:以正式部署方式(node dist/main.js、无 npm 环境
  变量、不需要数据库)拉起真实产物,断言①无依赖解析类错误②启动日志的应用版本
  等于 package.json ③已推进到数据库连接阶段。已做反向验证:删掉 ScenesModule
  后该检查立刻失败。
- scripts/check-version.mjs(并入 contracts:check):校验
  web-server/package.json = admin/package.json = project.yaml,正则禁止
  health.controller.ts 再出现硬编码版本回退,管理端已构建时断言产物 JS 含 vX.Y.Z。
- npm run verify 现在包含 bootstrap:check;package-release.sh 在构建后、打包前
  各跑一次(构建管理端后再跑 version:check,因为此时才需要校验产物内版本)。

文档:README.md / README.txt 增加 v0.7.2 修订说明;
tasks/011-version-display.md 与 tasks/015-scene-management.md 补「缺陷修复记录」
(含为什么当时没被发现的三层检查对照表)。

验证:npm run verify 通过(契约六项一致、35 条路由、258 项测试、nest build、
启动冒烟);产物 opendance-web-v0.7.2.tar.gz
sha256 f04ecc85d17517ec00754238616eb1237cdb2fe17440af6dce731827c0922b26。
2026-09-17 01:49:52 +08:00
zijunchiang 698d60bb65 docs(proxy): 记录 Gitea 附件上传上限的两阶段实测(IIS 放开后仍是 500)
按文档 §5.7 硬性要求 4「若用 A 方案,先在发布文档里记录实测上限」,把这次
Windows v0.2.0 整包(38,245,661 字节)发布的实测结果写进 deploy/proxy/README.md
第九节,并修正第九节里「只改 IIS 就够」的隐含假设:

第一阶段(IIS 未放开):14 MB 通过;30.5 / 40 MB 返回 HTTP 413。
第二阶段(IIS 已放开):38,245,661 字节的请求体被**完整接收**(115 s),
不再有 413 —— 说明 maxAllowedContentLength 已经够用;但随后 **Gitea 自己返回
HTTP 500**(响应体是 Gitea 的 API 错误信封,message 为空),瓶颈转移到了 Gitea 侧。

同接口边界测量:914 KB 与 16,372,427 B 通过;16,400,000 B 及以上失败;
且**在若干次大包失败之后,连 100 KB 都会在 0.3 秒内 500** ——
这不符合「上传体积上限」的特征(体积判断会先读完请求体),
更像上传临时目录 / 数据盘写满(Gitea 先写 data/tmp)。

因此文档明确要求运维按顺序查两件事:①Gitea 数据盘剩余空间(含 data/tmp,
以及它是否在小容量 tmpfs 上)并清理失败上传的残留临时文件;
②app.ini 的 [attachment] MAX_SIZE。并在 IIS 配置小节加醒目提示:
若放开 requestLimits 后仍为 500 而非 413,说明请求体已到达 Gitea,
要按上述两件事继续排查。
2026-09-17 01:40:27 +08:00
zijunchiang 3738bd91b9 release(v0.7.1): 契约 websocket-keypoints 0.2.0 + 补齐场景规格与任务文档
契约变更(T-WEB-016,与 Android T-AND-011「两端一起改」配套)
- websocket-keypoints 0.1.0 -> 0.2.0,均为向后兼容(子协议名与二进制帧
  version 均不变,只新增可选控制字段与一种新控制类型):
  1.1 保活:接收端必须声明 silentTimeoutMs 与 heartbeatIntervalMs,且心跳必须
      小于静默超时的一半。根因是客户端只能用 OkHttp 的协议层 ping,而它由
      底层库自动应答、**不暴露给应用层**,接收端根本看不到,无法续命;
  3.1 hello.deviceId(该安装实例的稳定标识,不使用任何硬件序列号);
  3.2/3.3 welcome/config 携带保活字段;
  3.6 应用层 ping/pong(回显 sentAtMs,pong 另带 serverAtMs 供算 RTT);
  5   设备去重:同一 deviceId 只保留最新连接,旧连接以 1000 关闭
      (刻意用 1000:对端据 §7 不会重连,否则形成"挤掉-重连-再挤掉"循环);
  7   关闭码与重连策略:1000/1001/1002 与主动停止不重连,其余重连;
  7.1 退避参数冻结:min(500ms*2^(n-1), 15000ms) + ±20% 抖动;
  8   连接状态机(两端同名,日志可直接对照)。
- contracts/VERSION 与 src/config/contract-versions.ts 同步到 0.2.0。

补齐 rest-api.yaml 漏写的场景结构(只补规格,不改线格式,版本号不变)
- CatalogBundle 增加 scenes[](标注 0.5.0 起下发);
- 新增 Scene / SceneConfig / Vec3 schemas。catalog/export 自 v0.7.0 起就返回
  scenes[],data-models 7 也已冻结,但规格漏写会让两端对字段边界
  (如 0~1、±200)的理解不一致。

deploy/proxy/README.md 新增第九节:Gitea 站点的附件上传上限
- 与一~五节的 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(交付缺陷)。

文档与脚本
- 补齐 tasks/011~016 与 project.yaml 条目(T-WEB-011~015 此前已在代码中落地
  却缺任务说明书);project.yaml 的 contracts_version 由单个 0.4.0 改为六项字典;
- scripts/package-release.sh 的版本号改为从 web-server/package.json 读取
  (与 GET /health 的 version、管理端左下角展示同源),并与 project.yaml 交叉校验;
  同时补上 SHA256SUMS 与自校验(此前是手工生成的)。

验证:npm run verify 通过(contracts:check 六项一致 + 35 条路由一致、
typecheck、246 项测试、nest build);产物 opendance-web-v0.7.1.tar.gz
sha256 27f92cfb125bbcf29077d4ad90c44720cbca46d23ddef12cd7826d73bd8865e2。
2026-09-17 01:30:41 +08:00
zijunchiang 50073b97cf docs(contracts): 补齐 rest-api 里 CatalogBundle.scenes[] 与 Scene/SceneConfig 结构
`catalog/export` 自 v0.7.0 起就返回 `scenes[]`(catalog.service.ts 已实现,
data-models.md §7 也已冻结),但 rest-api.yaml 的 CatalogBundle 漏了这个字段,
且 components 里没有 Scene / SceneConfig 定义 —— 属于"实现已发布、规格漏写"。

本次只补规格,不改线格式,因此六个契约版本号均不变(rest-api 仍为 0.5.0,
与文档 v1.6 §5.6「客户端契约升级到 rest-api 0.5.0」一致):
- CatalogBundle 增加 scenes[](标注 0.5.0 起下发)
- 新增 Scene(含 glbUrl/thumbnailUrl/config/stats/来源五元组/status)
- 新增 SceneConfig(physics.gravity/wind/ground/colliders、environment、
  spawnPoints<=16、camera)与复用的 Vec3

注入依赖:Windows 端 T-WIN-013 需要按这份结构生成 GDScript 类型,
规格缺失会导致两端对字段边界(如 0~1、±200)的理解不一致。

同时补 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(交付缺陷)。
2026-09-17 00:46:35 +08:00
35 changed files with 2578 additions and 80 deletions
+65 -8
View File
@@ -10,7 +10,63 @@ OpenDance Studio **阶段一**: Web 管理服务端(NestJS + TypeORM + 现有 My
## 状态
**阶段一已完成**: T-WEB-001 -> T-WEB-010 全部落地,当前版本 **v0.7.0**(契约 `rest-api 0.5.0`、`data-models 0.7.0`、`model-formats 0.5.0`、`skeleton-topology 0.2.0`、`database 0.4.0`)。
**阶段一已完成**: T-WEB-001 -> T-WEB-016 全部落地,当前版本 **v0.7.3**(契约 `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.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 修订(**管理端场景页**,补齐场景端到端可用):
@@ -112,18 +168,18 @@ v0.2.0 主要变更:
2. 准备一个现有的 MySQL / MariaDB 实例,并建好库与账号(见下方[前置要求](#前置要求))
本项目**不会创建数据库本身**
3. 取得发布包并校验(地址用你们自己的制品库替换; 下面是本仓库的发布地址)
3. 取得发布包并校验(**本仓库的真实发布地址**, 域名 `gitea.kasugano.cn`、属主 `zijunchiang` 属部署必需信息, 不是隐私项)
```bash
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.0/opendance-web-v0.7.0.tar.gz
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.0/SHA256SUMS
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.3/opendance-web-v0.7.3.tar.gz
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.3/SHA256SUMS
sha256sum -c SHA256SUMS # 期望输出 "OK"
```
4. 解压并生成配置(交互式引导,逐项问答,口令不回显)
```bash
tar -xzf opendance-web-v0.7.0.tar.gz && cd opendance-web-v0.7.0
tar -xzf opendance-web-v0.7.3.tar.gz && cd opendance-web-v0.7.3
bash deploy/init-env.sh
```
@@ -133,7 +189,8 @@ v0.2.0 主要变更:
bash deploy/install.sh
```
6. 验证(把 `<服务器地址>` 换成实际地址)
6. 验证(把 `<服务器地址>` 换成实际地址 —— 它是**你们自己服务器的地址**,
不是被脱敏的信息;本项目的文档只对 Gitea 域名这类部署必需信息直接写明真实值)
```bash
curl -fsS http://<服务器地址>:3000/health # 期望 {"status":"ok",...}
@@ -193,8 +250,8 @@ FLUSH PRIVILEGES;
### 5. 部署步骤
```bash
tar -xzf opendance-web-v0.7.0.tar.gz
cd opendance-web-v0.7.0
tar -xzf opendance-web-v0.7.3.tar.gz
cd opendance-web-v0.7.3
bash deploy/init-env.sh # 首次启动引导: 逐项问答生成 deploy/.env(见下)
bash deploy/install.sh # 已以 root 登录, 不需要 sudo
```
+72 -8
View File
@@ -1,5 +1,5 @@
================================================================
OpenDance Studio -- Web 管理服务端 v0.7.0 使用说明
OpenDance Studio -- Web 管理服务端 v0.7.3 使用说明
================================================================
本服务端是 OpenDance Studio 三端中的第一端, 负责管理歌曲、VRM 模型与
@@ -8,6 +8,68 @@ OpenDance Studio -- Web 管理服务端 v0.7.0 使用说明
重要: 本服务端 不部署、不管理 MySQL。数据库为部署方提供的现有实例,
连接信息通过 .env 注入。
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 文件 + 名称 + 来源五元组 + 物理参数: 重力/风/阵风/地面/碰撞体/环境光)、
@@ -142,16 +204,18 @@ v0.2.1 修订
node -v # 期望 v22 或更高
2. 准备一个现有的 MySQL / MariaDB 实例, 并建好库与账号(见下方"前置要求")
本项目不会创建数据库本身
3. 取得发布包并校验(地址用你们自己的制品库替换)
curl -LO https://<制品库地址>/opendance-web-v0.7.0.tar.gz
curl -LO https://<制品库地址>/SHA256SUMS
3. 取得发布包并校验(以下是本仓库的真实发布地址; 域名 gitea.kasugano.cn
与属主 zijunchiang 属部署必需信息, 不是隐私项)
curl -LO https://gitea.kasugano.cn/zijunchiang/opendance-web/releases/download/v0.7.2/opendance-web-v0.7.3.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.0.tar.gz && cd opendance-web-v0.7.0
tar -xzf opendance-web-v0.7.3.tar.gz && cd opendance-web-v0.7.3
bash deploy/init-env.sh
5. 安装(建用户与目录 -> 装依赖 -> 迁移 -> 注册系统服务 -> 等健康检查)
bash deploy/install.sh
6. 验证(把 <服务器地址> 换成实际地址)
6. 验证(把 <服务器地址> 换成实际地址 -- 它是你们自己服务器的地址,
不是被脱敏的信息)
curl -fsS http://<服务器地址>:3000/health # 期望 {"status":"ok",...}
curl -sI http://<服务器地址>:3001/ | head -1 # 期望 200, 管理端页面
curl -sI http://<服务器地址>:3000/ | head -1 # 期望 404, API 端口不提供管理页
@@ -205,8 +269,8 @@ v0.2.1 修订
(本项目不会创建数据库本身, 也不需要 GRANT OPTION / FILE / SUPER 等管理权限)
部署步骤
tar -xzf opendance-web-v0.7.0.tar.gz
cd opendance-web-v0.7.0
tar -xzf opendance-web-v0.7.3.tar.gz
cd opendance-web-v0.7.3
bash deploy/init-env.sh # 首次启动引导: 逐项问答生成 deploy/.env(见下)
bash deploy/install.sh # 已以 root 登录, 不需要 sudo
+1 -1
View File
@@ -1,7 +1,7 @@
# OpenDance Studio 契约版本
# 规则:任何字段级变更必须提升对应条目版本,并回到 opendance-web 修改后再向另两端建迁移任务。
rest-api: 0.5.0
websocket-keypoints: 0.1.0
websocket-keypoints: 0.2.0
data-models: 0.7.0
skeleton-topology: 0.2.0
model-formats: 0.5.0
+92
View File
@@ -940,4 +940,96 @@ components:
songs: { type: array, items: { $ref: '#/components/schemas/Song' } }
motions: { type: array, items: { $ref: '#/components/schemas/Motion' } }
models: { type: array, items: { $ref: '#/components/schemas/Model' } }
scenes:
type: array
description: 已发布场景(rest-api 0.5.0 起随目录下发;结构见 data-models.md 7)
items: { $ref: '#/components/schemas/Scene' }
defaultModelId: { type: integer, format: int64, nullable: true }
Scene:
allOf:
- $ref: '#/components/schemas/SourceFields'
- type: object
properties:
id: { type: integer, format: int64 }
name: { type: string }
glbUrl: { type: string, nullable: true, description: 场景 GLB 下载地址 }
thumbnailUrl: { type: string, nullable: true }
fileSize: { type: integer, nullable: true }
fileSha256: { type: string, nullable: true }
conversionNote:
type: string
nullable: true
description: 由 .zip/.pmx/.fbx 转换而来时的说明;直接上传 GLB 为 null
config:
allOf: [{ $ref: '#/components/schemas/SceneConfig' }]
nullable: true
description: 物理 / 环境 / 出生点 / 相机;服务端只存参数,仿真在客户端
stats:
type: object
nullable: true
description: 场景 GLB 分析(面数 / 网格 / 材质 / warnings)
status: { $ref: '#/components/schemas/PublishStatus' }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
SceneConfig:
type: object
description: >-
data-models.md 7.1 冻结结构。服务端入库时对每项做边界收敛
(越界夹紧、非法枚举回退默认值),客户端拿到的参数一定可解释。
properties:
physics:
type: object
properties:
gravity:
type: object
description: 米/秒^2,各分量 ±200
properties:
x: { type: number }
y: { type: number }
z: { type: number }
wind:
type: object
properties:
base: { $ref: '#/components/schemas/Vec3' }
gustAmplitude: { type: number, description: 0~100 }
gustFrequency: { type: number, description: Hz,0.01~5 }
gustDirection: { $ref: '#/components/schemas/Vec3' }
space: { type: string, enum: [world, local] }
ground:
type: object
properties:
friction: { type: number, description: 0~1 }
restitution: { type: number, description: 0~1 }
slopeLimit: { type: number, description: 0~89 度 }
colliders: { type: string, enum: [auto, mesh, none] }
environment:
type: object
properties:
ambientColor: { type: string, description: '#RRGGBB' }
ambientIntensity: { type: number, description: 0~4 }
fogDensity: { type: number, description: 0~1,0=关闭 }
spawnPoints:
type: array
maxItems: 16
items:
type: object
properties:
x: { type: number }
y: { type: number }
z: { type: number }
yaw: { type: number, description: 朝向(度,绕 Y 轴) }
camera:
type: object
properties:
mode: { type: string, enum: [follow, fixed, orbit] }
distance: { type: number, description: 0.5~50 }
height: { type: number, description: 0~20 }
Vec3:
type: object
properties:
x: { type: number }
y: { type: number }
z: { type: number }
+133 -5
View File
@@ -1,4 +1,4 @@
# contracts/websocket-keypoints.md - 关键点传输契约 v0.1.0
# contracts/websocket-keypoints.md - 关键点传输契约 v0.2.0
Android 辅助 App(发送端)-> Windows 客户端(接收端)。
服务端不参与实时链路,但 T-WEB-006 预处理产出的序列必须与此结构同构。
@@ -14,6 +14,22 @@ ws://<windows-host>:8765/pose
- 二进制帧传输关键点,文本帧传输控制消息(JSON)。
- 默认 30 FPS;发送端允许丢帧,接收端必须按 `frame_id` 检测丢包并插值补齐。
### 1.1 保活(v0.2.0 新增,向后兼容)
接收端有**静默超时**:超过 `silentTimeoutMs` 没有收到任何数据帧或文本帧即断开
(`close(1001, "timeout")`)。为此:
- 接收端必须在 `welcome`(以及需要变更时的 `config`)中声明
`silentTimeoutMs` 与期望的 `heartbeatIntervalMs`;
- 发送端按 `heartbeatIntervalMs` 周期发送 `ping`(见 §3.6),
且 **`heartbeatIntervalMs` 必须小于 `silentTimeoutMs` 的一半**;
- 发送端每帧关键点与每秒 `stats` 同样算作活动,`ping` 只是兜底,
保证「画面里没有人 / 相机暂停」时连接不会被误判掉线。
> 子协议名与二进制帧 `version` 均**不变**:本版本只**新增可选**控制字段与
> 一种新的控制类型,遵循 §5 的兼容规则;仅实现 v0.1.0 的一端仍可正常互通
> (它不发 `ping`、不读 `silentTimeoutMs`,但每帧与每秒 stats 已足够保活)。
## 2. 二进制帧:239 字节(冻结)
**小端序(little-endian)**。
@@ -76,26 +92,54 @@ func decode(buf: PackedByteArray) -> Dictionary:
{
"type": "hello",
"protocol": "opendance.pose.v1",
"deviceId": "a1b2c3d4e5f60718",
"device": "Pixel 8",
"appVersion": "0.1.0",
"appVersion": "0.2.0",
"cameraFacing": "front",
"targetFps": 30,
"resolution": "640x480"
}
```
- `deviceId`(v0.2.0 新增,**可选但强烈建议**):该安装实例的稳定标识
(建议 16 位十六进制,取自 `ANDROID_ID` 的哈希,不使用任何硬件序列号)。
接收端**必须按 `deviceId` 去重**:同一 `deviceId` 的新连接替换旧连接,
设备数按去重后计数(否则一次「反复重连」会被显示成多台设备)。
未提供 `deviceId` 时接收端退回按连接计数。
### 3.2 `welcome` - Windows -> Android
```json
{ "type": "welcome", "protocol": "opendance.pose.v1", "sessionId": "uuid", "expectedFps": 30 }
{
"type": "welcome",
"protocol": "opendance.pose.v1",
"sessionId": "uuid",
"expectedFps": 30,
"silentTimeoutMs": 15000,
"heartbeatIntervalMs": 5000
}
```
- `silentTimeoutMs` / `heartbeatIntervalMs`(v0.2.0 新增):见 §1.1。
发送端若未读到这两个字段,应使用保守默认值(超时 5000、心跳 1000)。
### 3.3 `config` - Windows -> Android,运行中调整
```json
{ "type": "config", "mirror": true, "targetFps": 30, "upperBodyOnly": false, "resolution": "640x480" }
{
"type": "config",
"mirror": true,
"targetFps": 30,
"upperBodyOnly": false,
"resolution": "640x480",
"silentTimeoutMs": 15000,
"heartbeatIntervalMs": 5000
}
```
- `silentTimeoutMs` / `heartbeatIntervalMs` 为 v0.2.0 新增的可选字段,
接收端在运行中调整保活参数时一并下发。
### 3.4 `stats` - Android -> Windows,每秒一次
```json
@@ -108,6 +152,31 @@ func decode(buf: PackedByteArray) -> Dictionary:
{ "type": "bye", "reason": "user_stop" }
```
`bye` 表示**主动**结束:发出方在发送后立即关闭连接,接收方据此关闭且
**不得**自动重连(见 §6)。
### 3.6 `ping` / `pong` - 保活(v0.2.0 新增)
Android -> Windows:
```json
{ "type": "ping", "sentAtMs": 1730000000000 }
```
Windows -> Android(可选回复,用于 RTT):
```json
{ "type": "pong", "sentAtMs": 1730000000000, "serverAtMs": 1730000000042 }
```
- `sentAtMs` 为发送方的本地毫秒时间戳,原样回传;发送端用
`now - sentAtMs` 估算 RTT。
- 接收端收到任何一帧(二进制或文本)都算活动,`ping` 无需额外应答即可保活;
`pong` 只用于诊断。
- **不要**依赖 WebSocket 协议层的 ping 帧(`Sec-WebSocket-Ping`):它由底层
库自动处理,**不会**暴露给应用层,因此不能被接收端的静默超时逻辑看到。
这也是必须定义应用层 `ping` 的原因。
## 4. JSON 调试帧(可选,仅开发期)
```json
@@ -123,9 +192,68 @@ func decode(buf: PackedByteArray) -> Dictionary:
生产链路必须使用二进制帧;JSON 帧仅用于联调,接收端可选择性支持。
## 5. 兼容性规则
## 5. 设备去重(v0.2.0 新增)
接收端按 §3.1 的 `deviceId` 去重:
| 情况 | 行为 |
|--|--|
| 新连接的 `deviceId` 已存在 | 关闭旧连接(`close(1000, "replaced by new connection")`),保留新连接;设备数不变 |
| `deviceId` 缺失或为空 | 不做去重,按连接计数 |
| 同一 `deviceId` 的旧连接已断开 | 正常替换,不影响设备数 |
这样「手机闪断后立刻重连」不会在接收端显示成两台设备,也不会让
「已连接 N 台」抖动。
## 6. 兼容性规则
- `version` 不同的大版本接收端必须拒绝并在 `bye.reason` 中说明。
- `flags` 新增位不得改变既有位语义;旧接收端必须忽略未知位。
- 33 点顺序一旦冻结不得插入或重排;新增点只能追加新 `version`。
- 服务端 `motions.keypoint_format` 字段取值 `mediapipe33@0.1.0` 对应本契约。
## 7. 关闭码与重连策略(v0.2.0 新增,两端必须一致)
| 关闭码 / 情形 | 含义 | 发送端是否自动重连 |
|--|--|--|
| `1000` 正常关闭 | 任一方主动结束(含收到 `bye`) | **否** |
| `1001` going away | 接收端主动关闭(服务重启、`stop()`、静默超时踢人) | **否**(见下方说明) |
| `1002` 协议错误 | 子协议 / 协议版本不匹配 | **否**(重连也不会成功) |
| 连接失败 / 超时 / `1006` 异常断开 | 网络抖动、对端进程退出、Wi-Fi 切换 | **是** |
| 客户端主动 `disconnect()` | 用户点「结束推流」 | **否** |
- 静默超时由接收端发 `close(1001, "timeout")`;正常情况下发送端按 §1.1 保活,
**不应**触发它。若仍频繁触发,属于保活参数或实现缺陷,需按 §7 排查,
而不是靠重连掩盖。
- 接收端因「同一 `deviceId` 被新连接替换」关闭旧连接时用 `1000`,
旧连接不重连。
### 7.1 退避参数(冻结)
```
第 n 次重连等待 = min(500ms * 2^(n-1), 15000ms) * (1 ± 20% 抖动)
即 0.5s → 1s → 2s → 4s → 8s → 15s(封顶),每次叠加 ±20% 抖动
连接成功后计数清零
```
## 8. 连接状态机(v0.2.0 新增,两端同名,便于对照日志)
```
idle → resolving → connecting → handshaking → streaming → degraded
↑ │
└──────── reconnecting ←───────────┘
stopped ← 主动停止 / 1000 / 1001 / 1002(终态,不再重连)
```
| 状态 | 含义 | 进入条件(发送端) |
|--|--|--|
| `idle` | 尚未开始连接 | 初始状态 |
| `resolving` | 解析主机地址(DNS / 局域网发现) | `connect()` 且主机名非 IP 字面量 |
| `connecting` | TCP + WebSocket 升级中 | 已发起请求 |
| `handshaking` | 已连接,等待 `welcome` | `onOpen`,已发送 `hello` |
| `streaming` | 正常推流 | 收到 `welcome` 或已成功发送首帧 |
| `degraded` | 仍连接但异常(丢弃帧增长 / 发送失败) | `send` 失败或持续丢帧 |
| `reconnecting` | 退避等待中 | 非主动断开 |
| `stopped` | 已停止(终态) | 主动停止 / 1000 / 1001 / 1002 |
接收端(Windows)在日志与状态面板中使用同名状态描述每个客户端连接。
+146
View File
@@ -179,6 +179,7 @@ curl -i http://127.0.0.1:3000/health
curl -i http://127.0.0.1:3001/health
# 2. 经反代可访问
# <反代地址> 指你们自己的反代域名或 IP(由部署方决定),不是被脱敏的信息。
curl -i http://<反代地址>/health
# 3. 管理端页面可打开(应返回 HTML)
@@ -204,6 +205,7 @@ curl -i http://<反代地址>/api/catalog/version
| 客户端接口全 404 | 把客户端的转发规则指到了管理端口之外,或误把 `/api` 前缀重写掉;客户端应转发到软件 API 端口 |
| 管理页面 404 | 把浏览器的转发规则指到了**软件 API 端口**(该端口不提供管理页面,这是有意设计);改指 `ADMIN_PORT` |
| 两个端口都转发到同一条规则 | 若确实想合并两个端口,设置 `ADMIN_PORT=PORT` 走单端口模式,而不是让反代去猜 |
| **Gitea 的 Release 附件上传 413** | 这是**另一台站点**(托管 Gitea 的 IIS),本服务的 `UPLOAD_MAX_BYTES` 管不到它;见第九节 |
## 八、还没有反代时:直连后端怎么访问
@@ -226,3 +228,147 @@ bash deploy/check-access.sh /opt/opendance # 指定安装目录
脚本会打印 `.env` 中的 `HOST`/`PORT`、实际监听地址(重点看是否只监听
`127.0.0.1`)、回环与对外 IP 两次自测结果、80/443 上有没有反代、
firewalld / ufw / iptables 的放行情况,并直接给出修复命令。
---
## 九、Gitea 站点的附件上传上限(Windows 客户端整包必须放开)
> 本节与第一~五节是**两个不同的站点**:前者是本 Web 服务的反代,后者是托管
> **Gitea**(源码仓库 + Release 附件)的站点。Windows 客户端的发布整包走 Gitea,
> 因此**必须**单独放开 Gitea 站点的上限。
### 9.1 实测结论(含一次**误判更正**,请以本节为准)
#### A. IIS 的 `maxAllowedContentLength` 是**真实且当前仍在生效**的上限
| 上传体积 | 结果 |
|--|--|
| 927 KB / 14 MB / 16,372,427 B | 通过(HTTP 201) |
| 30.5 MB | **失败 HTTP 413**(IIS「请求实体过大」页面) |
| 38,245,602 B(Windows 整包) | **失败 HTTP 413** |
| 40 MB | **失败 HTTP 413** |
→ IIS 的默认值 **`maxAllowedContentLength = 30,000,000` 字节(≈28.6 MiB)** 没有被放开,
这就是 Windows 端整包(约 37 MB)至今还要拆分卷/无法上传的**唯一真实原因**。
(`exe` 约 104 MB 未压缩 + `pck`;`deploy/proxy` 之外的另一台站点,见本节开头说明。)
**放开办法**:见 9.2 的四条配置(IIS `requestLimits` + `uploadReadAheadSize`、
ARR `maxRequestBytes`、Gitea `app.ini` 的 `[attachment] MAX_SIZE`、前置 nginx 的
`client_max_body_size`),改完 `iisreset /noforce`。
#### B. 【更正】Gitea 附件接口是好的 —— 之前"HTTP 500 / 磁盘写满"是**请求写法错误**
排查过程中我一度判定"Gitea 附件通道坏了、宿主机磁盘写满",**这个结论是错的**,特此更正,
并把踩坑点记录下来:
Gitea `POST /repos/{owner}/{repo}/releases/{id}/assets` 的 multipart 字段名是
**`attachment`**(见 `/api/v1/swagger`),不是 `file`。用 `-F "file=@…"` 提交时
**Gitea 返回 HTTP 500,且响应体是空消息**:
```json
{"message":"","url":"https://<gitea>/api/swagger"}
```
这个空消息极像"服务器内部错误 / 磁盘写满",实际只是"没有找到名为 `attachment` 的表单字段"。
实测对照(同一个 1 KB 文件、同一个 Release):
| 写法 | 结果 |
|--|--|
| `-F "attachment=@probe.bin"` | **201** |
| `--data-binary @probe.bin` + `-H "Content-Type: application/octet-stream"`(URL 带 `?name=`) | **201** |
| `-F "file=@probe.bin"` | **500**(空消息) |
**正确的两种上传写法**(都可直接照抄):
```bash
# 写法 1:multipart,字段名必须是 attachment
curl -f -X POST \
-H "Authorization: token <token>" \
-F "attachment=@opendance-windows-vX.Y.Z.zip" \
"https://gitea.kasugano.cn/api/v1/repos/zijunchiang/opendance-windows/releases/<id>/assets?name=opendance-windows-vX.Y.Z.zip"
# 写法 2:裸 body + ?name=(大文件更省内存)
curl -f -X POST \
-H "Authorization: token <token>" \
-H "Content-Type: application/octet-stream" \
--data-binary @SHA256SUMS \
"https://gitea.kasugano.cn/api/v1/repos/zijunchiang/opendance-web/releases/<id>/assets?name=SHA256SUMS"
```
> 另:`[attachment] MAX_SIZE` 仍然是需要放开的一项(默认可能只有 4~32 MB),
> 但它超限时返回的是**带消息**的错误,而不是上面那种空消息 500。
### 9.2 正确做法:放开 Gitea 站点上限到 256 MB
1. **IIS**(托管 Gitea 的站点根目录 `web.config`):
```xml
<configuration>
<system.webServer>
<security>
<requestFiltering>
<!-- 256 MiB;默认 30,000,000 字节,Windows 整包会被 413 掉 -->
<requestLimits maxAllowedContentLength="268435456" />
</requestFiltering>
</security>
<serverRuntime uploadReadAheadSize="268435456" />
</system.webServer>
</configuration>
```
`uploadReadAheadSize` 必须一起改(SSL 场景下默认仅 48 KB,会中途失败)。
2. **ARR**(服务器级,不是站点级)在 `applicationHost.config` 中同步放宽请求体上限:
```xml
<system.webServer>
<proxy>
<forwarding />
</proxy>
</system.webServer>
<webProxy enabled="true" timeout="00:05:00" />
<serverRuntime uploadReadAheadSize="268435456" />
```
并在 IIS 管理器 → Application Request Routing Cache → Server Proxy Settings
中确认请求体上限(`maxRequestBytes`)≥ `268435456`。
3. **Gitea 自身** `app.ini`:
```ini
[attachment]
; 默认通常为 4~32 MB,必须一起放大,否则 IIS 放行了 Gitea 仍会拒绝
MAX_SIZE = 256
```
4. **若 Gitea 前面还有 nginx**(IIS 之外或之内):
```nginx
client_max_body_size 256m;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
```
5. 改完执行 `iisreset /noforce`(或只重启站点),再按下节复验。
> **注意**:`requestLimits` 只是第一道闸。IIS 放开后若变成 **500**(而不是 413),
> 说明请求体已经到达 Gitea —— 此时按 9.1 的结论排查 **Gitea 宿主机磁盘空间**
> (1 KB 也失败就说明与体积无关),然后再看 `[attachment] MAX_SIZE`。
### 9.3 复验方法(用**正确**的字段名,否则会得到误导性的空消息 500)
```bash
# 造一个 40 MB 文件,直接打 Gitea 的附件接口
head -c 40000000 /dev/urandom > /tmp/probe.bin
curl -i -X POST \
-H "Authorization: token <token>" \
-F "attachment=@/tmp/probe.bin" \
"https://gitea.kasugano.cn/api/v1/repos/zijunchiang/<repo>/releases/<id>/assets?name=probe.bin"
```
预期:
* 放开 IIS 前 → **HTTP 413**(IIS 的"请求实体过大")
* 放开 IIS 后 → **HTTP 201**(返回资产 JSON)
* 若得到 **HTTP 500 + `{"message":"","url":"…/api/swagger"}`** → 十有八九是**字段名写错**
(用了 `file` 而不是 `attachment`),不是服务器故障
+1 -1
View File
@@ -1,6 +1,6 @@
[Unit]
Description=OpenDance Studio Web 管理服务端(NestJS)
Documentation=https://<Gitea 地址>/<owner>/opendance-web
Documentation=https://gitea.kasugano.cn/zijunchiang/opendance-web
After=network-online.target
Wants=network-online.target
+34 -4
View File
@@ -1,8 +1,14 @@
project: opendance-web-server
version: 0.7.0
version: 0.7.3
repository: opendance-web
default_branch: main
contracts_version: 0.4.0
contracts_version:
rest-api: 0.5.0
websocket-keypoints: 0.2.0
data-models: 0.7.0
skeleton-topology: 0.2.0
model-formats: 0.5.0
database: 0.4.0
# 契约变更流程(文档 7.2):
# 任何跨端契约变更必须先在本项目修改 contracts/ 并提升版本,
@@ -58,15 +64,39 @@ tasks:
file: tasks/010-deploy.md
scope: 部署脚本(双端口:软件 API 3000 + 管理端 3001,不部署 MySQL)+ deploy/.env 初始化引导(init-env.sh,HOST 默认 0.0.0.0)
status: done
- id: T-WEB-011
file: tasks/011-version-display.md
scope: 管理端左下角展示当前版本号(唯一来源 package.json)
status: done
- id: T-WEB-012
file: tasks/012-vrm-first.md
scope: 模型主力格式全面转为 VRM(非 VRM 入库标记不可用,不进已发布目录)
status: done
- id: T-WEB-013
file: tasks/013-bone-rebinding.md
scope: 动作内部标准(33 点)+ 模型骨骼按名重绑定(含 MMD/中文名)
status: done
- id: T-WEB-014
file: tasks/014-motion-preview.md
scope: 动作管理预览(离线骨架缩略图 + 管理端 3D 回放)
status: done
- id: T-WEB-015
file: tasks/015-scene-management.md
scope: 场景管理(GLB + 重力/风/物理参数,服务端只存参数)
status: done
- id: T-WEB-016
file: tasks/016-websocket-contract-020.md
scope: 契约 websocket-keypoints 0.2.0(保活 / 设备去重 / 状态机,与 T-AND-011 对齐)
status: done
deliverable:
artifact: opendance-web-v0.7.0.tar.gz
artifact: opendance-web-v0.7.3.tar.gz
packaging: Linux 业务服务(系统服务托管,双端口:软件 API + 管理端,不含 MySQL、不含反向代理、不使用 Docker)
includes:
- web-server/ # NestJS 源码 + 构建产物(含 BVH / VMD 内置解析、ZIP 读取与模型压缩包转换)
- admin/ # React 管理端构建产物
- deploy/ # install.sh / init-env.sh / systemd / openrc / initd / ctl.sh / .env.example(PORT、ADMIN_PORT、HOST)
- deploy/proxy/ # 反向代理说明(nginx / IIS,仅供参考不部署)
- contracts/ # 当前版本契约快照(rest-api 0.5.0 / data-models 0.7.0 / database 0.4.0)
- contracts/ # 当前版本契约快照(rest-api 0.5.0 / websocket-keypoints 0.2.0 / data-models 0.7.0 / database 0.4.0)
- THIRD_PARTY.md # 开源声明
- README.txt # 使用说明
+27 -1
View File
@@ -21,7 +21,20 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
VERSION="${1:-0.7.0}"
# 版本号唯一来源:web-server/package.json 的 version(与 GET /health 的 version 同源,
# 也是管理端左下角展示的那个值)。不传参数时自动读取,并与 project.yaml 交叉校验。
PKG_VERSION="$(node -p "require('./web-server/package.json').version" 2>/dev/null || true)"
YAML_VERSION="$(sed -n 's/^version:[[:space:]]*//p' project.yaml | head -1)"
VERSION="${1:-$PKG_VERSION}"
VERSION="${VERSION:-0.7.1}"
if [ -n "$PKG_VERSION" ] && [ "$VERSION" != "$PKG_VERSION" ]; then
echo "[error] 版本号不一致:参数=$VERSION,web-server/package.json=$PKG_VERSION" >&2
exit 1
fi
if [ -n "$YAML_VERSION" ] && [ "$VERSION" != "$YAML_VERSION" ]; then
echo "[error] 版本号不一致:参数=$VERSION,project.yaml=$YAML_VERSION" >&2
exit 1
fi
NAME="opendance-web-v${VERSION}"
OUT_DIR="$REPO_ROOT/dist-release"
STAGE="$OUT_DIR/$NAME"
@@ -36,11 +49,16 @@ log "校验契约版本一致性..."
# ---------- 2. 后端构建 ----------
log "构建后端..."
( cd web-server && npm run build )
# 构建后**启动冒烟**:产物能不能真正 boot 起来(模块 DI 图可解析 + 版本号正确)。
# v0.7.0/v0.7.1 就是漏了这一步才把「装得上、起不来」的产物发了出去。
( cd web-server && npm run bootstrap:check )
# ---------- 3. 管理端构建 ----------
if [ -d web-server/admin/node_modules ]; then
log "构建管理端..."
( cd web-server/admin && npm run build )
# 构建后再次校验版本一致性:此时管理端产物已存在,会额外断言产物 JS 内含 vX.Y.Z
( cd web-server && npm run version:check )
else
log "警告:web-server/admin/node_modules 不存在,跳过管理端构建(产物将缺少 admin/)"
fi
@@ -169,7 +187,15 @@ if [ "$fail" -ne 0 ]; then
die "产物完整性校验失败,已删除有问题的压缩包"
fi
# ---- SHA256SUMS(裸文件名,下载目录内 sha256sum -c 直接可用) ----
( cd "$OUT_DIR" && sha256sum "$NAME.tar.gz" > SHA256SUMS )
log "完成:$OUT_DIR/$NAME.tar.gz"
log "已生成 $OUT_DIR/SHA256SUMS"
if ( cd "$OUT_DIR" && sha256sum -c SHA256SUMS --status ); then
log "sha256 自校验:OK"
else
die "sha256 自校验失败"
fi
echo
echo "产物内容:"
# 先把清单读入变量,避免 tar 因管道提前关闭(SIGPIPE)而误报 "write error"
+61
View File
@@ -0,0 +1,61 @@
# T-WEB-011 管理端展示当前版本号
- **状态**: done
- **依赖**: T-WEB-010
- **文档**: v1.6 §11(版本号展示)
## 目标
管理端页面**左下角**展示当前运行版本 `vX.Y.Z`,且与 `GET /health` 的 `version`
同源(文档 §11.1 的硬性要求:单一来源、可核对)。
## 方案
| 项 | 做法 |
|--|--|
| 唯一来源 | `web-server/package.json` 的 `version`(同时是 `/health` 的 `version`) |
| 注入方式 | Vite 构建期 `define: { __APP_VERSION__: <package.json version> }` |
| 展示位置 | `admin/src/components/Layout.tsx` 侧边栏底部 `APP_VERSION_LABEL` |
| 禁止重复 | `admin/src/lib/version.ts` 是唯一读取点,界面不出现版本号字面量 |
`/health` 的 `version` 取 `process.env.npm_package_version`(由 npm 脚本注入),
与构建期注入管理端的值来自同一个 `package.json`,因此**不可能不一致**。
## 验收(文档 §11.3)
| 验收项 | 结果 |
|--|--|
| 管理端任一页面左下角可见 `vX.Y.Z` | ✅ 侧边栏底部固定显示 |
| 与 `GET /health` 的 `version` 相同 | ✅ 同源于 `web-server/package.json` |
| 与产物文件名一致 | ✅ `opendance-web-vX.Y.Z.tar.gz`;打包脚本读取同一个 package.json |
## 缺陷修复记录(v0.7.2)
**缺陷**:`/health` 的 `version` 在正式部署里**永远是 `0.1.0`**。
原实现是 `process.env.npm_package_version ?? '0.1.0'`。这个环境变量
**只在通过 npm 脚本启动时才存在**,而本项目的正式部署方式是原生服务
(systemd / OpenRC / initd,`ExecStart=node dist/main.js`)—— npm 不参与,
于是它恒为 `undefined`,`/health` 就回退到硬编码的 `0.1.0`。
这直接违反文档 §11.2 第 4 条「可核对:Web 端至少与 `GET /health` 的 `version` 一致」:
管理端左下角显示 v0.7.x,而 `/health` 说 0.1.0。
**修复**:
* 新增 `src/config/app-version.ts` 作为**运行时唯一解析点**,按优先级尝试
`APP_VERSION` 环境变量 → 运行入口旁的 `package.json` → 工作目录的
`package.json` → `npm_package_version`,每一步都做**包名校验**
(必须是 `opendance-web-server`),避免读到上层仓库的 `package.json`;
全都拿不到时返回显式的 `0.0.0`,而不是伪装成某个具体版本;
* `/health` 改为 `version: resolveAppVersion()`;
* `main.ts` 启动首行也打印 `应用版本 vX.Y.Z`,即使后面数据库连不上、进程退出,
日志里也一定能看到本次运行的版本号。
**防线**:
* `scripts/check-version.mjs`(并入 `npm run contracts:check`)校验
`web-server/package.json` = `admin/package.json` = `project.yaml`,并
**禁止** `health.controller.ts` 再出现硬编码版本回退(正则拦截 `?? 'x.y.z'`);
管理端已构建时还会断言产物 JS 内确实含 `vX.Y.Z`;
* `test/app-version.spec.ts` 12 项,其中一项**显式模拟无 npm 环境变量**的原生部署;
* `scripts/check-bootstrap.mjs` 用真实的 `node dist/main.js` 验证启动日志里的版本号。
+32
View File
@@ -0,0 +1,32 @@
# T-WEB-012 模型主力格式:全面转为 VRM
- **状态**: done
- **依赖**: T-WEB-005 / T-WEB-007
- **文档**: v1.6 §4.9
- **契约**: `model-formats 0.5.0`、`data-models 0.7.0`
## 目标
所有入库模型统一转成 **VRM**(glTF 2.0 容器 + humanoid 扩展),
客户端只消费 VRM,避免三种格式各自的骨骼命名/坐标系差异在客户端叠加。
## 方案
| 项 | 做法 |
|--|--|
| 转换默认值 | `MODEL_CONVERT_FORMAT` 默认 `vrm`(`.env.example` 同步) |
| 直传 | `.vrm` / `.glb` 已经是 glTF 容器:直接入库、**不重编码** |
| 需转换 | `.pmx` / `.fbx` / `.zip` 由 Assimp 转 GLB,再补齐 VRM 扩展 |
| 可用性判定 | `isUsableModel()`:`format === 'vrm'` 才可用 |
| 不可用模型 | **入库但标记为「不可用」**,`unusableReason()` 说明原因;不进已发布目录 |
| 目录过滤 | `catalog/export` 只下发 `format = vrm` 的模型;`catalog/version` 指纹同样只算可用的 |
| 默认模型 | `resolveDefaultModelId()` 只在可用模型里挑 |
## 验收
| 验收项 | 结果 |
|--|--|
| 上传 PMX/FBX/zip 后产出 VRM | ✅ Assimp → GLB → VRM 扩展补齐;实测 MMD 模型(初音未来 / 奥黛塔)转换成功 |
| 非 VRM 模型不出现在客户端目录 | ✅ `catalog/export` 过滤 + 指纹过滤;`test/model-usability.spec.ts` 覆盖 |
| 不可用模型有明确原因 | ✅ DTO 增 `usable` / `unusableReason` 字段 |
| 三端契约一致 | ✅ `data-models 0.7.0` / `model-formats 0.5.0` 快照三端逐字节一致 |
+42
View File
@@ -0,0 +1,42 @@
# T-WEB-013 动作内部标准与模型骨骼重绑定
- **状态**: done
- **依赖**: T-WEB-006 / T-WEB-012
- **文档**: v1.6 §4.10
- **契约**: `skeleton-topology 0.2.0`、`data-models 0.7.0`
## 目标
1. **动作内部标准**:所有动作(BVH / VMD / FBX / glTF)统一预处理成
MediaPipe 33 点关键点序列(`mediapipe33@0.1.0`),客户端只认这一种;
2. **模型骨骼重绑定**:任意命名风格的模型都能被关键点驱动 —— 按**名字**映射到
canonical humanoid 骨骼,而不是要求模型符合某一种命名规范。
## 方案
### 动作内部标准
`preprocess.service.ts` 按扩展名分派解析器,统一产出 NDJSON 关键点序列 +
9 个关节角特征(108 维),与 Android 端推流结构完全同构 —— 因此客户端
DTW 的两侧输入是同一套特征。
### 骨骼重绑定
| 项 | 做法 |
|--|--|
| 归一化 | `normalizeBoneName()`:保留 CJK 与假名(**不能再只留 `a-z0-9`**),全角 ASCII 折叠(`足IK` → `足ik`) |
| 候选表 | `HUMANOID_CANDIDATES` 覆盖 VRM/VRoid、Mixamo、MMD 日文名、中文常见名 |
| 生成而非手写 | Godot 端 `src/model/humanoid_map.gd` 的 CANDIDATES **由服务端表生成**,并有跨端一致性测试 |
| 覆盖度 | `coverage()` / `missing_bones()` 暴露给审核界面与客户端 |
> **踩坑记录**:第一版 `normalizeBoneName()` 只保留 `[a-z0-9]`,把 CJK 名字
> 整个抹成空串,导致 MMD 模型 humanoid 覆盖 **0/22**。修复后实测
> 奥黛塔 **21/22**、初音未来 **19/22**,两者 `drivable = true` 且 VRM 正常产出。
## 验收
| 验收项 | 结果 |
|--|--|
| MMD(日文骨骼名)模型可驱动 | ✅ 实测 21/22、19/22 覆盖;客户端可正常驱动 |
| Godot 端候选表与服务端一致 | ✅ 一致性测试(跨仓库生成 + 校验) |
| 动作统一为 33 点 | ✅ `keypoint_format = mediapipe33@0.1.0`;Android 端推流同构 |
+30
View File
@@ -0,0 +1,30 @@
# T-WEB-014 动作管理:预览功能
- **状态**: done
- **依赖**: T-WEB-004 / T-WEB-006
- **文档**: v1.6 §4.11(重要)
- **契约**: `data-models 0.6.0`(新增 `thumbnailUrl`)
## 目标
审核动作时能**直接看到动作长什么样**,而不是只看文件名与帧数 ——
动作是否可用、是否与曲目匹配,靠肉眼判断最快。
## 方案
| 项 | 做法 |
|--|--|
| 服务端缩略图 | `motions/motion-thumbnail.ts` 用关键点序列离线渲染骨架 PNG(前端视角的"动作剪影"),零原生依赖 |
| 入库时机 | 预处理 `run()` 末尾生成(**非致命**:失败不阻塞预处理完成,只记录) |
| 契约 | `data-models 0.6.0` 给 Motion 增加 `thumbnailUrl`;迁移 `1789600000000-AddMotionThumbnail` |
| 管理端预览 | `admin/src/components/MotionPreview.tsx`:3D 骨架回放(Three.js),可拖动时间轴逐帧查看 |
| 依赖数据 | `admin/src/lib/keypoint-sequence.ts` 解析 NDJSON,`skeleton-topology.ts` 提供父节点表 |
## 验收
| 验收项 | 结果 |
|--|--|
| 动作列表显示缩略图 | ✅ 离线渲染的骨架 PNG |
| 可逐帧回放查看动作 | ✅ `MotionPreview` 时间轴 + 3D 骨架 |
| 缩略图生成失败不影响预处理 | ✅ 非致命分支;错误写入日志 |
| 契约三端一致 | ✅ `data-models 0.6.0` → 后续 0.7.0;快照逐字节一致 |
+142
View File
@@ -0,0 +1,142 @@
# T-WEB-015 场景管理(新增模块)
- **状态**: done
- **依赖**: T-WEB-007(审核流程)
- **文档**: v1.6 §4.12
- **契约**: `rest-api 0.5.0`、`data-models 0.7.0`、`database 0.4.0`
## 目标
新增**场景**这一类资源:客户端跟练时的 3D 环境(GLB)+ 物理参数
(重力 / 风 / 地面 / 碰撞体 / 环境 / 出生点 / 相机)。
服务端**只存参数、不跑物理**,仿真在客户端(Windows 端用 Godot 内置物理)。
## 与模型的区别
| 项 | 模型 | 场景 |
|--|--|--|
| 主力格式 | **VRM**(humanoid 重绑定) | **GLB**(不做重绑定、不转 VRM) |
| 转换 | PMX/FBX/zip → GLB → VRM | `.glb`/`.gltf` 直接导入;`.zip`/`.pmx`/`.fbx` → GLB |
| 附加数据 | 骨骼 / 材质 / 缩略图 | **物理参数** `SceneConfig` |
## 交付物
| 文件 | 说明 |
|--|--|
| `contracts/data-models.md` §7 | `Scene` 与冻结的 `SceneConfig` 结构 |
| `contracts/rest-api.yaml` | `/api/scenes` 增删改查 + 审核复用 `/api/reviews/scene/:id/approve\|reject` + `catalog/export.scenes[]` |
| `migrations/1789650000000-AddScenes.ts` | `scenes` 表;`config` / `stats` 用 **MEDIUMTEXT** |
| `src/scenes/scene-config.ts` | `normalizeSceneConfig()`:缺项默认、越界夹紧、非法枚举回退,**永不抛错** |
| `src/scenes/{scenes.service,scene.serializer,scenes.controller,scenes.module}.ts` | 业务实现(上传一律 `pending`;删除时清理源文件/GLB/缩略图) |
| `src/models/model-inspector.ts` | 场景复用模型检查器做 GLB 分析 |
| `src/models/model-thumbnail.ts` | 场景复用模型光栅化器出缩略图 |
| `admin/src/pages/Scenes.tsx` | 列表 / 上传 / 审核 / 删除 / 物理参数编辑(编辑期即按同一规则夹紧) |
| `admin/src/lib/scene-physics.ts` | 与后端同规则的收敛(保存前就不会产生非法枚举) |
> **MEDIUMTEXT 的原因**:`models.stats` 曾在 TEXT(65,535 字节)上溢出
> (实测 70,980 字节 → MySQL 1406 → 500)。场景的 `stats` 与 `config` 同样可能很大,
> 因此**一开始就用 MEDIUMTEXT**,并保留 `shrinkStatsForStorage()` 兜底。
## 验收
| 验收项 | 结果 |
|--|--|
| 场景可上传(GLB 直传 / zip 自动解包 / PMX 转换) | ✅ 与模型同一套转换管线 |
| 物理参数可在管理端可视化编辑 | ✅ 重力 / 风 / 摩擦 / 弹性 / 碰撞体 / 环境 / 出生点 / 相机 |
| 非法参数不会入库 | ✅ 前后端同规则收敛;`test/scene-config.spec.ts` 9 项边界用例 |
| 客户端能拿到场景 | ✅ `catalog/export.scenes[]`;目录指纹纳入场景变更 |
| 路由与契约一致 | ✅ `check-routes` 共 **35** 条 |
## 缺陷修复记录(v0.7.2)
**缺陷**:`CatalogModule` 的 `imports` 里漏了 `ScenesModule`。
`CatalogService` 因为 `catalog/export` 要下发 `scenes[]` 而注入了 `ScenesService`,
但 Nest 的 DI 是**按模块**解析的 —— 不在同一个模块的 `imports` 里,就解析不到。
后果是 `node dist/main.js` 在 bootstrap 阶段直接抛
`UnknownDependenciesException: Nest can't resolve dependencies of the CatalogService (... ? ...)`,
**服务完全起不来**(v0.7.0 / v0.7.1 的产物都是这样)。
**为什么当时没被发现**:三层检查全都"绿"——
| 检查 | 为什么没抓到 |
|--|--|
| `nest build` / `tsc --noEmit` | 模块 import 缺失是**运行时** DI 问题,类型系统看不出来 |
| 258 项单元测试 | 它们直接 new 服务或测纯函数,**从不编译 `AppModule`** |
| 打包脚本的产物完整性校验 | 只校验文件清单、压缩包结构与 PE/魔数,不启动服务 |
**修复**:在 `CatalogModule.imports` 中补上 `ScenesModule`。
**防线**:新增 `scripts/check-bootstrap.mjs`,以正式部署方式
(`ExecStart=node dist/main.js`、**不带任何 npm 环境变量**、**不需要数据库**)
拉起真实产物,断言①无依赖解析类错误②启动日志里的应用版本等于 `package.json`
③启动确实推进到数据库连接阶段。已挂进 `npm run verify` 与
`scripts/package-release.sh`(构建后、打包前),并做过**反向验证**:
把 `ScenesModule` 删除后该检查立刻失败。
## 缺陷修复记录二(v0.7.3):场景表不可用会拖垮**整站**
**现场**(真实部署,Web v0.7.2):
| 请求 | 结果 |
|--|--|
| `GET /api/songs` / `/api/motions` / `/api/models` | 200 |
| `GET /api/scenes`、`/api/scenes/1`、`/api/scenes?status=draft` | **500** |
| `GET /api/catalog/export`、`/api/catalog/version` | **500** |
| `GET /api/reviews?status=pending`(待审列表) | **500** |
| `GET /api/reviews`(仅审核流水) | 200 |
`/health` 显示 `version 0.7.2`、`database.connected true`、`migrationsPending 0` ——
看起来一切正常,但**凡是要查 `scenes` 表的请求全部 500**。后果不是"场景不可用",
而是**客户端拉不到任何目录**(Windows/Android 双双不可用)、管理端待审列表打不开。
**为什么 `migrationsPending` 是 0 却有表缺失**:`DataSource.showMigrations()` 只比较
**代码里加载到的**迁移与数据库记录;如果部署的 `dist/migrations/` 里没有
`AddScenes.js`(或 glob 没匹配到),这个迁移根本不在比较集合里,于是"没有待执行迁移"
—— 表却不存在。这种"静默不一致"靠 `/health` 的 `pending` 是发现不了的。
**修复(降级,而不是继续让它 500)**:
契约与文档都写明**场景是可选资源**(没有可用场景时客户端回退内置空场景),
所以场景表的问题**不允许**影响与场景无关的功能:
1. `ScenesService.queryScenes()`:数据库层面失败 → 返回空结果、**只记一次日志**
(避免刷屏)、把原因写入 `diagnostics()`;下一次查询成功即自动复位;
2. `ReviewsService.listByStatus()`:场景查询单独 `catch`,待审列表照常返回歌曲/动作/模型;
3. `/health` 新增 `scenes: { available, error }` —— **降级但绝不静默**;
4. 新增诊断脚本 `web-server/scripts/diagnose-scenes.mjs`(默认只读):
打印运行期真实连的库、表清单、迁移记录、**代码实际加载的迁移**、
`scenes` 实际列 vs 实体期望列的逐列 diff;`--fix` 仅在缺表时用迁移里
同一份 DDL 幂等补表。
**测试**:`test/scenes-degradation.spec.ts`(6 项)锁定「表缺失 → 空场景而不是抛错」、
「原因被记录并被 `/health` 暴露」、「表恢复后自动复位」、「`catalog/export` 与
待审列表在降级下仍可用」。
## 缺陷修复记录三(v0.7.3):场景**根本传不上来**(diskStorage vs 内存存储)
**现场**:`POST /api/scenes` 一律返回 400「上传场景必须带 file 字段(.glb 为主…)」,
而同一份 multipart 请求打 `/api/models` 返回 **201**(已经用 curl 对照验证)。
**根因**:上传中间件在 `storage.module.ts` 里配置为 multer 的 **`diskStorage`**
(先落 `${STORAGE_DIR}/tmp`,避免 512 MiB 级 VRM 打爆内存),落盘后的文件对象
**只有 `path`、没有 `buffer`**。而 `ScenesService.create()` 当初按内存存储写:
```ts
if (!file || !file.buffer || file.buffer.length === 0) { …400… }
```
`file.buffer` 恒为 undefined → **任何场景都传不上来**。
**修复**:与 models / motions 统一到同一套上传处理:
| 步骤 | 做法 |
|--|--|
| 校验 | `requireUploadedFile(file)` —— 校验确实落盘(`path` 存在),并修正 busboy 按 latin1 解出的中文/日文文件名 |
| 直接导入(`.glb`/`.gltf`) | `storage.persistTempFile(AssetKind.Scene, upload.path, …)` —— 直接归档并**顺带清理临时文件** |
| 需转换(`.zip`/`.pmx`/`.fbx`) | `readFile(upload.path)` 取 buffer 后转换 → `persistBuffer`,并在 `finally` 中 `unlink(upload.path)` |
| 体积上限 | 用 `upload.size` 判断(`UPLOAD_MAX_BYTES`),超限 400 且不落盘 |
**测试**:`test/scene-upload.spec.ts`(5 项)—— diskStorage 形状的文件不再被判成
"缺少 file 字段"、GLB 直传标注"未做重编码"且落 `pending`、中文文件名被还原、
缺文件仍 400、超限 400 且不落盘。该用例在修复前必然失败。
+48
View File
@@ -0,0 +1,48 @@
# T-WEB-016 契约提升:websocket-keypoints 0.2.0(保活 / 去重 / 状态机)
- **状态**: done
- **依赖**: T-WEB-003(本项目为契约源)
- **文档**: v1.6 §6.5 / §7.2
- **触发**: Android 端 T-AND-011「连接状态异常、频繁重连」要求**两端一起改**
## 背景
按文档 §7.2,任何跨端契约变更必须**先在本项目修改 `contracts/` 并提升版本**,
再向另两端建迁移任务。本次 Android 端排查出"频繁重连"的根因在协议层:
接收端有静默超时,但**契约里没有声明超时值,也没有定义任何应用层保活消息**。
## 变更内容(均为**向后兼容**:子协议名与二进制帧 `version` 均不变)
| 章节 | 新增 | 为什么必须写进契约 |
|--|--|--|
| §1.1 保活 | 接收端必须在 `welcome`(及需要变更时的 `config`)中声明 `silentTimeoutMs` 与 `heartbeatIntervalMs`;且**心跳必须小于静默超时的一半** | 客户端原先只能用 OkHttp 的**协议层** ping,而它由底层库自动应答、**不暴露给应用层**,接收端根本看不到,无法续命 |
| §3.1 | `hello.deviceId`(该安装实例的稳定标识,**不使用任何硬件序列号**) | 没有它,一次闪断重连会被接收端显示成两台设备 |
| §3.2 / §3.3 | `welcome` / `config` 携带上述保活字段 | 让客户端按服务端节奏保活,而不是各写一套魔数 |
| §3.6 | 应用层 `ping` / `pong`(回显 `sentAtMs`,`pong` 另带 `serverAtMs` 供算 RTT) | 协议层 ping 对应用层不可见,必须自定义 |
| §5 | 设备去重规则:同一 `deviceId` 只保留最新连接,旧连接以 `1000` 关闭 | 用 `1000` 是刻意的:对端据 §7 不会重连,否则会形成"挤掉-重连-再挤掉"的循环 |
| §7 | 关闭码与重连策略:`1000` / `1001` / `1002` 与主动停止**不重连**;其余重连 | 原实现"只要能重连就重连",服务端重启或协议不匹配时也会疯狂重连 |
| §7.1 | 退避参数冻结:`min(500ms * 2^(n-1), 15000ms)` + **±20% 抖动** | 避免同网段多台设备同时重连打尖峰 |
| §8 | 连接状态机 `idle → resolving → connecting → handshaking → streaming → degraded → reconnecting → stopped` | 两端同名,日志可直接对照 |
## 兼容性
* 子协议 `opendance.pose.v1` 与二进制帧 `version = 0x01` **均不变**;
* 只**新增可选**控制字段与一种新控制类型;
* 仅实现 v0.1.0 的一端仍可正常互通(它不发 `ping`、不读 `silentTimeoutMs`,
但每帧关键点 + 每秒 `stats` 已足够保活)。
## 改动清单
| 文件 | 改动 |
|--|--|
| `contracts/websocket-keypoints.md` | 上文全部章节 |
| `contracts/VERSION` | `websocket-keypoints: 0.1.0` → `0.2.0` |
| `web-server/src/config/contract-versions.ts` | 编译期常量同步为 0.2.0(`check-contracts.mjs` 强制) |
## 验收
| 验收项 | 结果 |
|--|--|
| `npm run contracts:check` 通过 | ✅ 六项契约版本一致,35 条路由与契约一致 |
| 三端快照逐字节一致 | ✅ windows / android 的 `contracts/` md5 与 web 完全相同 |
| 另两端的迁移任务已建立 | ✅ Windows `T-WIN-015`、Android `T-AND-011` 均已落地并引用本契约 |
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "opendance-admin",
"private": true,
"version": "0.7.0",
"version": "0.7.3",
"type": "module",
"description": "OpenDance Studio 管理端(React + TypeScript + Vite)",
"scripts": {
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "opendance-web-server",
"version": "0.2.0",
"version": "0.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "opendance-web-server",
"version": "0.2.0",
"version": "0.7.2",
"license": "MIT",
"dependencies": {
"@nestjs/common": "^11.0.12",
@@ -35,7 +35,7 @@
"vitest": "^3.0.8"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.0.0"
}
},
"node_modules/@angular-devkit/core": {
+6 -4
View File
@@ -1,6 +1,6 @@
{
"name": "opendance-web-server",
"version": "0.7.0",
"version": "0.7.3",
"description": "OpenDance Studio Web 管理服务端(NestJS + TypeORM + 现有 MySQL 实例)",
"license": "MIT",
"private": true,
@@ -16,13 +16,15 @@
"test": "vitest run",
"test:watch": "vitest",
"test:smoke": "node test/smoke.mjs",
"contracts:check": "node scripts/check-contracts.mjs && node scripts/check-routes.mjs",
"verify": "npm run contracts:check && npm run typecheck && npm run test && npm run build",
"contracts:check": "node scripts/check-contracts.mjs && node scripts/check-routes.mjs && node scripts/check-version.mjs",
"verify": "npm run contracts:check && npm run typecheck && npm run test && npm run build && npm run bootstrap:check",
"typeorm": "node scripts/migrate.mjs",
"migration:generate": "node scripts/migrate.mjs generate",
"migration:run": "node scripts/migrate.mjs run",
"migration:revert": "node scripts/migrate.mjs revert",
"migration:show": "node scripts/migrate.mjs show"
"migration:show": "node scripts/migrate.mjs show",
"version:check": "node scripts/check-version.mjs",
"bootstrap:check": "node scripts/check-bootstrap.mjs"
},
"dependencies": {
"@nestjs/common": "^11.0.12",
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env node
/**
* 启动冒烟检查:**编译产物能不能真正 boot 起来**(不需要数据库)。
*
* 为什么必须有这一层:`src/catalog/catalog.module.ts` 曾经漏了 `ScenesModule`,
* 而 `CatalogService` 注入了 `ScenesService` —— Nest 的 DI 是**按模块**解析的,
* 于是 `node dist/main.js` 在 bootstrap 阶段直接抛 `UnknownDependenciesException`,
* **服务根本起不来**。但当时所有检查都是绿的:
* - `nest build` 通过(类型没问题);
* - 258 项单元测试通过(它们直接 new 服务 / 测纯函数,从不编译 AppModule);
* - 打包脚本只校验文件清单与压缩包完整性。
* 结果就是 v0.7.0 / v0.7.1 的产物「装得上、起不来」。
*
* 本脚本以**正式部署的方式**(systemd 的 `ExecStart=node dist/main.js`,
* 不带任何 npm 环境变量)拉起真实产物,然后:
* 1. 断言日志里**没有**依赖解析类错误(UnknownDependenciesException 等)→ 模块图可解析;
* 2. 断言日志里的「应用版本 vX.Y.Z」等于 `package.json` 的 version
* → 原生部署下版本号解析正确(原先会退化成硬编码的 0.1.0);
* 3. 断言启动确实**推进到了数据库连接阶段** → 证明 Nest 已把整个 DI 图解析完,
* 而不是中途失败。
*
* 不需要数据库:TypeORM 连不上时日志里会出现明确的连接错误,那就够了 ——
* 本脚本的职责是「启动能否走到连库这一步」,真连库的端到端验收由
* `test/smoke.mjs`(部署好服务端后运行)负责。
*
* 用法:node scripts/check-bootstrap.mjs
*/
import { spawn } from 'node:child_process';
import { mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const serverDir = join(here, '..');
const entry = join(serverDir, 'dist', 'main.js');
const EXPECTED_VERSION = JSON.parse(
readFileSync(join(serverDir, 'package.json'), 'utf8'),
).version;
/** 依赖解析失败的标志(任一出现即失败) */
const DI_ERROR_PATTERNS = [
/UnknownDependenciesException/,
/Nest can't resolve dependencies/i,
/cannot resolve dependencies/i,
/Nest can't resolve/i,
];
/** 走到数据库连接阶段的标志 */
const DB_STAGE_PATTERNS = [
/Unable to connect to the database/i,
/ECONNREFUSED/,
/getaddrinfo/i,
/Access denied for user/i,
/Unknown database/i,
/服务已?启动/,
/已启动,监听/,
];
const TIMEOUT_MS = Number(process.env.BOOTSTRAP_TIMEOUT_MS ?? 40_000);
function freePort(base) {
// 用一个不常见的端口即可;连库失败是预期的,端口只要不撞上真实服务
return base + Math.floor(Math.random() * 200);
}
async function main() {
const scratch = mkdtempSync(join(tmpdir(), 'opendance-boot-'));
// 模拟 systemd:删掉 npm_* 环境变量(ExecStart=node dist/main.js 时它们不存在)
const env = { ...process.env };
for (const key of Object.keys(env)) {
if (key.startsWith('npm_')) delete env[key];
}
delete env.APP_VERSION; // 也不允许用显式覆盖,必须走 package.json
Object.assign(env, {
NODE_ENV: 'development', // 免得 validateEnv 在生产模式直接 exit(1)
HOST: '127.0.0.1',
PORT: String(freePort(34000)),
ADMIN_PORT: String(freePort(35000)),
DB_HOST: '127.0.0.1',
DB_PORT: '3399', // 故意指向一个没有 MySQL 的端口
DB_USER: 'opendance_probe',
DB_PASSWORD: 'opendance_probe',
DB_NAME: 'opendance_probe',
DB_LOGGING: 'false',
STORAGE_DIR: join(scratch, 'storage'),
ADMIN_DIR: join(scratch, 'admin'),
});
const child = spawn(process.execPath, [entry], {
cwd: serverDir,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
let finished = false;
const decide = () => {
if (finished) return null;
const diError = DI_ERROR_PATTERNS.find((re) => re.test(output));
if (diError) {
finished = true;
return { ok: false, reason: 'di', pattern: String(diError) };
}
const versionLine = output.match(/应用版本 v(\S+)/);
const dbStage = DB_STAGE_PATTERNS.some((re) => re.test(output));
if (versionLine && dbStage) {
finished = true;
return { ok: true, version: versionLine[1] };
}
return null;
};
const result = await new Promise((resolvePromise) => {
const timer = setTimeout(() => {
resolvePromise(decide() ?? { ok: false, reason: 'timeout' });
}, TIMEOUT_MS);
const onData = (chunk) => {
output += chunk.toString();
const decided = decide();
if (decided) {
clearTimeout(timer);
child.kill('SIGKILL');
resolvePromise(decided);
}
};
child.stdout.on('data', onData);
child.stderr.on('data', onData);
child.on('exit', () => {
clearTimeout(timer);
resolvePromise(decide() ?? { ok: false, reason: 'exit' });
});
child.on('error', (error) => {
clearTimeout(timer);
resolvePromise({ ok: false, reason: 'spawn', error: error.message });
});
});
if (result.ok) {
if (result.version !== EXPECTED_VERSION) {
console.error(
`✗ 启动日志里的应用版本 v${result.version} 与 web-server/package.json 的 ${EXPECTED_VERSION} 不一致`,
);
console.error(' (原生部署下版本号必须从 package.json 解析,不能依赖 npm 环境变量)');
process.exit(1);
}
console.log(
`✓ 启动冒烟通过:模块依赖图可解析(无 UnknownDependenciesException)、` +
`应用版本 v${result.version} 与 package.json 一致、已推进到数据库连接阶段`,
);
return;
}
console.error('✗ 启动冒烟失败:');
if (result.reason === 'di') {
console.error(` 依赖解析错误(匹配 ${result.pattern})—— 有模块忘记 import 提供者了。`);
console.error(' 提示:检查各 @Module 的 imports 是否覆盖了构造函数里注入的服务。');
} else if (result.reason === 'timeout') {
console.error(' 进程既没有报依赖错误,也没有推进到数据库连接阶段(超时)。');
} else if (result.reason === 'exit') {
console.error(' 进程提前退出,且日志里既没有依赖错误也没有数据库阶段标志。');
} else if (result.reason === 'spawn') {
console.error(` 无法启动 ${entry}:${result.error}`);
}
console.error('\n---- 进程输出(末尾 40 行)----');
console.error(output.split('\n').slice(-40).join('\n'));
process.exit(1);
}
await main();
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env node
/**
* 版本号一致性校验(文档 §11.2「单一来源 / 可核对」)。
*
* 守护的缺陷:`/health` 曾经用
* process.env.npm_package_version ?? '0.1.0'
* 而正式部署方式是原生服务(`ExecStart=node dist/main.js`),npm 不参与,
* 该环境变量为 undefined —— 于是真实部署里 `/health` 永远报 **0.1.0**,
* 与管理端左下角展示的 vX.Y.Z 不一致。
*
* 校验项:
* 1. web-server/package.json 的 version === project.yaml 的 version
* 2. web-server/admin/package.json 的 version === web-server/package.json 的 version
* 3. src/config/app-version.ts 存在,且 health.controller 通过它取版本
* 4. health.controller 里不得再出现硬编码的版本回退(如 `?? '0.1.0'`)
* 5. 若管理端已构建(web-server/admin/dist 存在),其 JS 包内必须含 `v<version>`
*
* 用法:node scripts/check-version.mjs
*/
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const serverDir = join(here, '..');
const repoDir = join(serverDir, '..');
const problems = [];
const notes = [];
function readJson(file) {
try {
return JSON.parse(readFileSync(file, 'utf8'));
} catch (error) {
problems.push(`无法解析 ${file}:${error.message}`);
return null;
}
}
function readYamlVersion(file) {
if (!existsSync(file)) {
problems.push(`缺少 ${file}`);
return null;
}
const line = readFileSync(file, 'utf8')
.split(/\r?\n/)
.find((l) => l.trimStart().startsWith('version:'));
if (!line) {
problems.push(`无法在 ${file} 中定位 version:`);
return null;
}
return line.slice(line.indexOf(':') + 1).trim();
}
// ---- 1. package.json 与 project.yaml ----
const serverPkg = readJson(join(serverDir, 'package.json'));
const yamlVersion = readYamlVersion(join(repoDir, 'project.yaml'));
if (serverPkg && yamlVersion && serverPkg.version !== yamlVersion) {
problems.push(`版本不一致:web-server/package.json=${serverPkg.version} vs project.yaml=${yamlVersion}`);
}
// ---- 2. 管理端 package.json 与之同源 ----
const adminPkgPath = join(serverDir, 'admin', 'package.json');
if (existsSync(adminPkgPath)) {
const adminPkg = readJson(adminPkgPath);
if (serverPkg && adminPkg && adminPkg.version !== serverPkg.version) {
problems.push(
`版本不一致:web-server/admin/package.json=${adminPkg.version} vs web-server/package.json=${serverPkg.version}`,
);
}
}
// ---- 3 & 4. /health 的版本必须来自 package.json,不能再硬编码 ----
const healthPath = join(serverDir, 'src', 'health', 'health.controller.ts');
const versionModulePath = join(serverDir, 'src', 'config', 'app-version.ts');
if (!existsSync(versionModulePath)) {
problems.push('缺少 src/config/app-version.ts(版本号运行时唯一解析点)');
}
if (!existsSync(healthPath)) {
problems.push('缺少 src/health/health.controller.ts');
} else {
const health = readFileSync(healthPath, 'utf8');
if (!health.includes('resolveAppVersion')) {
problems.push('health.controller.ts 未使用 resolveAppVersion(),/health 的版本可能不是单一来源');
}
// 硬编码版本回退:`?? 'x.y.z'` 或 `|| "x.y.z"`
const hardcoded = health.match(/\?\?\s*['"]\d+\.\d+\.\d+['"]|\|\|\s*['"]\d+\.\d+\.\d+['"]/);
if (hardcoded) {
problems.push(
`health.controller.ts 存在硬编码版本回退 ${hardcoded[0]};应使用 resolveAppVersion()(否则原生部署下 /health 会报错版本)`,
);
}
}
// ---- 5. 管理端构建产物必须带上与 package.json 相同的版本 ----
const adminDist = join(serverDir, 'admin', 'dist', 'assets');
if (serverPkg && existsSync(adminDist)) {
const files = readdirSync(adminDist).filter((f) => f.endsWith('.js'));
const found = new Set();
for (const file of files) {
const text = readFileSync(join(adminDist, file), 'utf8');
for (const m of text.matchAll(/v\d+\.\d+\.\d+/g)) found.add(m[0]);
}
const expected = `v${serverPkg.version}`;
if (found.size === 0) {
problems.push(`管理端构建产物中找不到任何 vX.Y.Z(期望 ${expected})`);
} else if (!found.has(expected)) {
problems.push(`管理端构建产物中的版本 ${[...found].join('/')} 与 package.json 的 ${expected} 不一致`);
} else {
notes.push(`管理端构建产物含 ${expected}`);
}
} else {
notes.push('管理端尚未构建,跳过产物内版本校验(打包脚本会在构建后再次执行本脚本)');
}
if (problems.length > 0) {
console.error('✗ 版本一致性校验失败:');
for (const p of problems) console.error(` - ${p}`);
console.error('\n请同步 web-server/package.json、web-server/admin/package.json、project.yaml;');
console.error('/health 的版本必须来自 src/config/app-version.ts(读 package.json),不得硬编码。');
process.exit(1);
}
for (const n of notes) console.log(` · ${n}`);
console.log(
`✓ 版本一致(${serverPkg?.version}):web-server/package.json = admin/package.json = project.yaml,` +
`/health 由 resolveAppVersion() 读取 package.json`,
);
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env node
/**
* 场景表(scenes)诊断 —— 只读,默认不改任何数据。
*
* 背景:`/api/scenes`、`/api/catalog/export`、`/api/catalog/version`、
* `/api/reviews?status=pending` 全部返回 500,而 `/api/songs`、`/api/models`
* 正常 —— 说明**凡是查 scenes 表就失败**。本脚本用来把原因定位到具体一格:
*
* 1. 运行期到底连的是哪个库(host / database / user);
* 2. `scenes` 表是否存在;
* 3. `typeorm_migrations` 里记录了哪些迁移(AddScenes 在不在);
* 4. 当前代码**加载**了哪些迁移(如果 AddScenes 没被加载,
* `showMigrations()` 会返回"没有待执行迁移",于是 `/health` 的
* `migrationsPending` 显示 0,但表其实不存在 —— 这是最隐蔽的一种情况);
* 5. 若表存在:实际列 vs 实体期望列,逐列 diff(缺列/多列/类型不符)。
*
* 用法(在安装目录的 web-server/ 下,用与服务端相同的环境变量):
*
* cd <APP_DIR>/web-server
* set -a; . ../deploy/.env; set +a # 与服务端同一份 .env
* node scripts/diagnose-scenes.mjs # 只读诊断
* node scripts/diagnose-scenes.mjs --fix # 仅在缺表时建表(幂等,写到库)
*
* 注意:`--fix` 只做一件事 —— 用迁移里**完全相同**的 DDL
* `CREATE TABLE IF NOT EXISTS scenes (...)` 补表;不删不改任何现有数据。
*/
import { createRequire } from 'node:module';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const serverDir = resolve(here, '..');
const require = createRequire(join(serverDir, 'package.json'));
const FIX = process.argv.includes('--fix');
function loadCompiled(relative) {
const path = join(serverDir, 'dist', relative);
try {
return require(path);
} catch (error) {
console.error(`✗ 无法加载编译产物 ${path}:${error.message}`);
console.error(' 请确认在安装目录的 web-server/ 下执行,且已 npm run build(发布包内含 dist/)。');
process.exit(1);
}
}
const dataSourceModule = loadCompiled('db/data-source.js');
const buildOptions = dataSourceModule.buildDataSourceOptions ?? dataSourceModule.default?.options;
if (typeof buildOptions !== 'function') {
console.error('✗ dist/db/data-source.js 未导出 buildDataSourceOptions(),无法按运行期同样的规则解析配置');
process.exit(1);
}
const options = buildOptions(process.env);
const mysql = require('mysql2/promise');
console.log('\n== OpenDance 场景表诊断 ==');
console.log(` 模式 : ${FIX ? '诊断 + 缺表时补表(--fix)' : '只读'}`);
console.log(` 连接目标 : ${options.username}@${options.host}:${options.port}/${options.database}`);
console.log(` DB_NAME 来源 : ${process.env.DB_NAME ? 'process.env(systemd EnvironmentFile / 手工 export)' : '.env 文件(dotenv)'}`);
console.log(` 工作目录 : ${process.cwd()}`);
console.log(` 编译产物目录 : ${join(serverDir, 'dist')}`);
// ------------------------------------------------------------------ 迁移加载情况
const { DataSource } = require('typeorm');
const dataSource = new DataSource(options);
let loadedMigrations = [];
try {
await dataSource.initialize();
loadedMigrations = (dataSource.migrations ?? []).map((m) => ({
name: m.name ?? m.constructor?.name ?? '(匿名)',
timestamp: m.timestamp ?? null,
}));
console.log(`\n 已加载迁移 : ${loadedMigrations.length} 个`);
for (const m of loadedMigrations) console.log(` [loaded] ${m.timestamp ?? '?'} ${m.name}`);
if (!loadedMigrations.some((m) => m.name.includes('AddScenes'))) {
console.log(' !! 未加载到 AddScenes 迁移 —— 说明 dist/migrations 里缺这个文件,');
console.log(' 或 data-source 的 migrations glob 没匹配到它。');
console.log(' (此时 /health 的 migrationsPending 会显示 0,但表并不存在)');
}
} catch (error) {
console.error(`\n✗ 连接数据库失败:${error.message}`);
console.error(' 请确认已 `set -a; . ../deploy/.env; set +a`(与服务端同一份环境变量)。');
process.exit(1);
}
// ------------------------------------------------------------------ 只读查询
const conn = await mysql.createConnection({
host: options.host,
port: options.port,
user: options.username,
password: options.password,
database: options.database,
charset: 'utf8mb4',
});
const [tableRows] = await conn.query('SHOW TABLES');
const tables = tableRows.map((row) => Object.values(row)[0]);
const hasScenes = tables.includes('scenes');
const hasMigrationsTable = tables.includes('typeorm_migrations');
console.log(`\n 库内表数量 : ${tables.length}`);
console.log(` scenes 表存在 : ${hasScenes ? '是' : '**否**'}`);
console.log(` typeorm_migrations 存在 : ${hasMigrationsTable ? '是' : '否'}`);
let executed = [];
if (hasMigrationsTable) {
const [rows] = await conn.query('SELECT id, timestamp, name FROM typeorm_migrations ORDER BY timestamp');
executed = rows;
console.log(`\n 已记录迁移 : ${rows.length} 个`);
for (const r of rows) console.log(` [done] ${r.timestamp} ${r.name}`);
const scenesRecorded = rows.some((r) => String(r.name).includes('AddScenes'));
console.log(` AddScenes 已记录: ${scenesRecorded ? '是' : '**否**'}`);
}
const pending = loadedMigrations.filter((m) => !executed.some((e) => e.name === m.name));
console.log(`\n 待执行迁移 : ${pending.length} 个`);
for (const m of pending) console.log(` [pending] ${m.timestamp ?? '?'} ${m.name}`);
// ------------------------------------------------------------------ 列 diff
let actualColumns = [];
if (hasScenes) {
const [cols] = await conn.query('SHOW COLUMNS FROM scenes');
actualColumns = cols.map((c) => ({ name: c.Field, type: String(c.Type), nullable: c.Null === 'YES' }));
console.log(`\n scenes 实际列 : ${actualColumns.length} 个`);
const sceneMetadata = dataSource.entityMetadatas.find((m) => m.tableName === 'scenes');
if (sceneMetadata) {
const expected = sceneMetadata.columns.map((c) => ({
name: c.databaseName,
type: c.type,
nullable: c.isNullable,
}));
console.log(` 实体期望列 : ${expected.length} 个`);
const missing = expected.filter((e) => !actualColumns.some((a) => a.name === e.name));
const extra = actualColumns.filter((a) => !expected.some((e) => e.name === a.name));
const typeMismatch = expected
.map((e) => ({ e, a: actualColumns.find((a) => a.name === e.name) }))
.filter(({ e, a }) => a && String(a.type).toLowerCase() !== String(e.type).toLowerCase());
console.log(` ✗ 库中缺列 : ${missing.length ? missing.map((m) => `${m.name}(${m.type})`).join(', ') : '无'}`);
console.log(` ✗ 库中多列 : ${extra.length ? extra.map((m) => `${m.name}(${m.type})`).join(', ') : '无'}`);
console.log(` ! 类型不一致 : ${typeMismatch.length ? typeMismatch.map(({ e, a }) => `${e.name}: 实体=${e.type} 库=${a.type}`).join('; ') : '无'}`);
} else {
console.log(' ! 实体元数据里找不到 scenes(entities 未包含 Scene?)');
}
}
// ------------------------------------------------------------------ 结论 / 修复
console.log('\n== 结论 ==');
if (!hasScenes) {
const scenesLoaded = loadedMigrations.some((m) => m.name.includes('AddScenes'));
const scenesRecorded = executed.some((r) => String(r.name).includes('AddScenes'));
if (scenesLoaded && !scenesRecorded) {
console.log(' scenes 表缺失,且 AddScenes 迁移被正确加载但**尚未执行**。');
console.log(' → 直接执行迁移即可:');
console.log(' cd <APP_DIR>/web-server && set -a && . ../deploy/.env && set +a && node scripts/migrate.mjs run');
} else if (scenesLoaded && scenesRecorded) {
console.log(' scenes 表缺失,但迁移已被记录为"已执行" —— 说明该迁移当初没有真正建表');
console.log(' (或跑向了另一个库:注意 deploy/.env 与 web-server/.env 的 DB_NAME 是否一致)。');
console.log(' → 用 `--fix` 幂等补表:node scripts/diagnose-scenes.mjs --fix');
} else if (!scenesLoaded) {
console.log(' scenes 表缺失,且**当前代码没有加载 AddScenes 迁移**。');
console.log(' → 检查 dist/migrations/1789650000000-AddScenes.js 是否存在;');
console.log(' 若不存在,说明部署的 dist 不是最新构建,请用最新发布包重跑 deploy/install.sh。');
console.log(' → 若文件存在却未加载,请把上方「已加载迁移」清单发回。');
}
} else {
const sceneMetadata = dataSource.entityMetadatas.find((m) => m.tableName === 'scenes');
const expected = sceneMetadata ? sceneMetadata.columns.map((c) => c.databaseName) : [];
const missing = expected.filter((e) => !actualColumns.some((a) => a.name === e));
if (missing.length > 0) {
console.log(` scenes 表存在但**缺少列**:${missing.join(', ')} —— 这就是 SELECT/INSERT 500 的原因。`);
console.log(' → 需要补列(手工 ALTER 或按最新发布包重跑迁移),请把本输出发回以给出精确 SQL。');
} else {
console.log(' scenes 表存在且列与实体一致 —— 那 500 的原因不在这张表上,请把服务端日志发回:');
console.log(' journalctl -u opendance-api -n 80 --no-pager');
}
}
if (FIX && !hasScenes) {
console.log('\n== 执行 --fix:用迁移中的同一份 DDL 幂等补表 ==');
await conn.query(`
CREATE TABLE IF NOT EXISTS \`scenes\` (
\`id\` bigint NOT NULL AUTO_INCREMENT,
\`name\` varchar(200) NOT NULL,
\`glb_asset_id\` bigint NULL,
\`thumbnail_asset_id\` bigint NULL,
\`config\` mediumtext NULL,
\`stats\` mediumtext NULL,
\`file_size\` bigint NULL,
\`file_sha256\` varchar(64) NULL,
\`conversion_note\` varchar(500) NULL,
\`source_type\` varchar(32) NOT NULL,
\`source_name\` varchar(200) NOT NULL,
\`source_url\` varchar(500) NOT NULL,
\`license\` varchar(100) NOT NULL,
\`author\` varchar(200) NOT NULL,
\`status\` varchar(16) NOT NULL DEFAULT 'pending',
\`created_at\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
\`updated_at\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
\`deleted_at\` datetime NULL,
INDEX \`idx_scenes_status\` (\`status\`),
PRIMARY KEY (\`id\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
const [after] = await conn.query('SHOW TABLES LIKE \'scenes\'');
console.log(after.length === 1 ? ' ✓ scenes 表已创建' : ' ✗ 建表后仍未出现,请把本输出发回');
console.log(' → 若这次是「迁移已记录但表缺失」,为确保一致性,建议再执行一次:');
console.log(' node scripts/migrate.mjs run');
}
await conn.end();
await dataSource.destroy();
console.log('');
+6
View File
@@ -5,6 +5,7 @@ import { Motion } from '../entities/motion.entity';
import { Song } from '../entities/song.entity';
import { ModelsModule } from '../models/models.module';
import { MotionsModule } from '../motions/motions.module';
import { ScenesModule } from '../scenes/scenes.module';
import { SongsModule } from '../songs/songs.module';
import { CatalogController } from './catalog.controller';
import { CatalogService } from './catalog.service';
@@ -15,6 +16,11 @@ import { CatalogService } from './catalog.service';
SongsModule,
MotionsModule,
ModelsModule,
// CatalogService 注入 ScenesService(catalog/export 要下发 scenes[])。
// Nest 的 DI 是**按模块**解析的:不在这里 import,bootstrap 时会直接
// UnknownDependenciesException,服务根本起不来 —— 由 scripts/check-bootstrap.mjs
// 在构建后强制拦截(v0.7.0/v0.7.1 就是漏了这一步才把「装得上、起不来」的产物发了出去)。
ScenesModule,
],
controllers: [CatalogController],
providers: [CatalogService],
+129
View File
@@ -0,0 +1,129 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
/**
* 应用版本号的**运行时唯一解析点**(文档 §11.1 / §11.2)。
*
* 为什么不能只用 `process.env.npm_package_version`:
* 那个环境变量**只在通过 npm 脚本启动时**才存在(`npm start` / `npm run start:prod`)。
* 而本项目的正式部署方式是**原生服务**(systemd / OpenRC / initd),
* `ExecStart=node dist/main.js` —— npm 不参与,于是 `npm_package_version` 为 undefined,
* 原来那句 `?? '0.1.0'` 会让 `GET /health` 在真实部署里**永远报 0.1.0**,
* 与「管理端左下角展示 vX.Y.Z、且必须与 /health 一致」的硬性要求直接冲突。
*
* 因此改为从 **`package.json` 实体文件**读取(构建期注入管理端的也是这同一份文件),
* 依次尝试多个候选路径,取第一个「包名匹配」的结果:
*
* 1. `APP_VERSION` 环境变量(显式覆盖,给运维留一个不重新打包即可改的口子)
* 2. 运行入口所在目录的 `../package.json`(`dist/main.js` → `web-server/package.json`)
* 3. 进程工作目录下的 `package.json`(systemd 的 `WorkingDirectory=.../web-server`)
* 4. `npm_package_version`(npm 脚本启动时的兜底)
* 5. 都没有则 `'0.0.0'` —— 显式可见的“未知版本”,绝不伪装成某个具体版本
*
* 候选路径都必须通过 [parsePackageVersion] 的**包名校验**,
* 避免把上层仓库或别的 Node 项目的 `package.json` 误当成自己。
*/
/** 本包的 `name`,用于校验读到的 package.json 确实是本项目 */
export const PACKAGE_NAME = 'opendance-web-server';
/** 无法解析时的显式占位值(不是任何真实版本,便于一眼看出异常) */
export const UNKNOWN_VERSION = '0.0.0';
/**
* 从 `package.json` 文本中解析版本号。
* **纯函数**:只在「JSON 合法 + 包名匹配 + version 形如 x.y.z」时才返回版本,
* 否则返回 null(调用方继续尝试下一个候选路径)。
*/
export function parsePackageVersion(content: string): string | null {
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
return null;
}
if (typeof parsed !== 'object' || parsed === null) return null;
const pkg = parsed as Record<string, unknown>;
if (pkg.name !== PACKAGE_NAME) return null;
const version = pkg.version;
if (typeof version !== 'string') return null;
const trimmed = version.trim();
// 形如 X.Y.Z(允许预发布后缀),防止读到 'workspace:*' 之类的非版本值
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(trimmed)) return null;
return trimmed;
}
/** 收集候选 `package.json` 路径(顺序即优先级) */
export function packageJsonCandidates(): string[] {
const out: string[] = [];
// 2. 运行入口:CJS 下可用 require.main;ESM/测试环境下跳过
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const main = typeof require !== 'undefined' ? require.main : undefined;
const filename: unknown = main?.filename;
if (typeof filename === 'string' && filename.length > 0) {
out.push(resolve(dirname(filename), '..', 'package.json'));
}
} catch {
/* 非 CJS 环境,忽略 */
}
// 2b. 编译产物相对位置:dist/config/app-version.js → web-server/package.json
try {
// eslint-disable-next-line @typescript-eslint/no-undef
const here = typeof __dirname === 'string' ? __dirname : '';
if (here) out.push(resolve(here, '..', '..', 'package.json'));
} catch {
/* ESM 下 __dirname 未定义,忽略 */
}
// 3. 工作目录(systemd 的 WorkingDirectory=.../web-server)
out.push(join(process.cwd(), 'package.json'));
return [...new Set(out)];
}
function readVersion(file: string): string | null {
if (!existsSync(file)) return null;
try {
return parsePackageVersion(readFileSync(file, 'utf8'));
} catch {
return null;
}
}
/** 解析当前运行版本;结果对同一进程稳定(缓存) */
let cached: string | null = null;
export function resolveAppVersion(): string {
if (cached !== null) return cached;
const explicit = process.env.APP_VERSION?.trim();
if (explicit) {
cached = explicit;
return cached;
}
for (const file of packageJsonCandidates()) {
const version = readVersion(file);
if (version) {
cached = version;
return cached;
}
}
const npmVersion = process.env.npm_package_version?.trim();
if (npmVersion && /^\d+\.\d+\.\d+/.test(npmVersion)) {
cached = npmVersion;
return cached;
}
cached = UNKNOWN_VERSION;
return cached;
}
/** 测试用:清空缓存(生产代码不要调用) */
export function resetAppVersionCache(): void {
cached = null;
}
+1 -1
View File
@@ -10,7 +10,7 @@ import { join } from 'node:path';
*/
export const CONTRACT_VERSIONS: Readonly<Record<string, string>> = Object.freeze({
'rest-api': '0.5.0',
'websocket-keypoints': '0.1.0',
'websocket-keypoints': '0.2.0',
'data-models': '0.7.0',
'skeleton-topology': '0.2.0',
'model-formats': '0.5.0',
+17 -1
View File
@@ -2,8 +2,10 @@ import { Controller, Get, Res, ServiceUnavailableException } from '@nestjs/commo
import { InjectDataSource } from '@nestjs/typeorm';
import { Response } from 'express';
import { DataSource } from 'typeorm';
import { resolveAppVersion } from '../config/app-version';
import { AppConfig } from '../config/configuration';
import { detectAssimp } from '../preprocess/assimp';
import { ScenesService } from '../scenes/scenes.service';
export interface HealthReport {
status: 'ok' | 'degraded';
@@ -29,6 +31,15 @@ export interface HealthReport {
version?: string;
error?: string;
};
/**
* 场景子系统可用性(T-WEB-015)。
* 场景是**可选资源**:scenes 表不可用时服务降级为「无场景」而不是整站 500,
* 但这里必须如实暴露,否则问题会被静默吞掉。
*/
scenes: {
available: boolean;
error: string | null;
};
}
@Controller('health')
@@ -36,6 +47,7 @@ export class HealthController {
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly config: AppConfig,
private readonly scenesService: ScenesService,
) {}
/**
@@ -69,7 +81,10 @@ export class HealthController {
const report: HealthReport = {
status: database.connected ? 'ok' : 'degraded',
uptimeSec: Math.round(process.uptime() * 1000) / 1000,
version: process.env.npm_package_version ?? '0.1.0',
// 版本号唯一解析点:读 package.json 实体文件,而不是只在 npm 脚本下才存在的
// npm_package_version(原生部署 ExecStart=node dist/main.js 时它是 undefined,
// 会让 /health 永远报一个陈旧的硬编码版本,与管理端左下角的展示冲突)。
version: resolveAppVersion(),
contractVersion: this.config.contractVersions,
database,
storage: {
@@ -78,6 +93,7 @@ export class HealthController {
totalBytes: 0,
},
assimp,
scenes: this.scenesService.diagnostics(),
};
if (!database.connected) {
+5
View File
@@ -1,7 +1,12 @@
import { Module } from '@nestjs/common';
import { ScenesModule } from '../scenes/scenes.module';
import { HealthController } from './health.controller';
@Module({
// HealthController 注入 ScenesService(用于暴露场景子系统可用性)。
// Nest 的 DI 按模块解析:少了这个 imports,bootstrap 会直接
// UnknownDependenciesException —— 见 scripts/check-bootstrap.mjs。
imports: [ScenesModule],
controllers: [HealthController],
})
export class HealthModule {}
+7
View File
@@ -7,6 +7,7 @@ import { existsSync, mkdirSync } from 'node:fs';
import { extname, join, resolve } from 'node:path';
import { AppModule } from './app.module';
import { describeDualAccess, preferredUrl } from './config/access-hint';
import { resolveAppVersion } from './config/app-version';
import {
AppConfig,
configuration,
@@ -37,6 +38,12 @@ function listenServer(server: Server, port: number, host: string): Promise<void>
async function bootstrap(): Promise<void> {
const logger = new Logger('Bootstrap');
// 先打版本号:这是「当前运行版本」的唯一来源(web-server/package.json,
// 由 resolveAppVersion() 解析,不依赖 npm 环境变量)。
// 放在最前面还有一个作用:即使后面数据库连不上、进程退出,
// 日志里也一定能看到本次运行的版本号(便于与 /health、管理端左下角核对)。
logger.log(`OpenDance Studio Web 服务端 应用版本 v${resolveAppVersion()}`);
// 启动前校验环境变量(生产环境禁止 DB_SYNCHRONIZE=true、端口必须合法等)
const { errors, warnings } = validateEnv(configuration());
for (const warning of warnings) logger.warn(`环境变量提示:${warning}`);
+5 -1
View File
@@ -29,7 +29,11 @@ export class ModelsController {
return this.models.list(query);
}
/** multipart/form-data:file + name + 来源五元组 + isPrimary/status */
/**
* multipart/form-data:file + name + 来源五元组。
* **上传一律 pending,且不接受 isPrimary/status**(契约 rest-api POST /api/models):
* 默认模型请在审核通过时带 `setPrimary=true`,或调用 `POST /api/models/{id}/primary`。
*/
@Post()
@UseInterceptors(FileInterceptor('file'))
create(
+14 -1
View File
@@ -2,6 +2,7 @@ import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
@@ -64,6 +65,8 @@ interface Reviewable {
@Injectable()
export class ReviewsService {
private readonly logger = new Logger(ReviewsService.name);
constructor(
@InjectRepository(ReviewRecord) private readonly records: Repository<ReviewRecord>,
@InjectRepository(Song) private readonly songs: Repository<Song>,
@@ -90,7 +93,17 @@ export class ReviewsService {
this.songs.find({ where: { status } }),
this.motions.find({ where: { status } }),
this.models.find({ where: { status } }),
this.scenes.find({ where: { status } }),
// 场景是**可选资源**:scenes 表缺失/结构不一致时不能让整个待审列表 500
// (那会连带打挂管理端与客户端目录)。降级为空列表,原因由
// ScenesService 记日志并在 /health 暴露。
this.scenes
.find({ where: { status } })
.catch((error: unknown) => {
this.logger.error(
`待审列表中的场景查询失败,已跳过场景:${error instanceof Error ? error.message : String(error)}`,
);
return [] as Scene[];
}),
]);
rows.push(...songs.map((s) => toPending(TargetType.Song, s, s.title)));
+109 -29
View File
@@ -1,24 +1,27 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { existsSync } from 'node:fs';
import { readFile, unlink } from 'node:fs/promises';
import { Repository } from 'typeorm';
import { AssetKind, ModelFormat, PublishStatus } from '../common/enums';
import { assertSourceTupleComplete, type SourceTuple } from '../common/source.dto';
import { requireUploadedFile, type UploadedFileLike } from '../common/upload.util';
import { AppConfig } from '../config/configuration';
import { Scene } from '../entities/scene.entity';
import { convertModelArchive, convertModelFile } from '../models/model-convert';
import { inspectModelFile } from '../models/model-inspector';
import { renderModelThumbnail } from '../models/model-thumbnail';
import { StorageService } from '../storage/storage.service';
import type { StoredAsset } from '../storage/storage.service';
import { normalizeSceneConfig, SCENE_CONFIG_DEFAULTS, type SceneConfig } from './scene-config';
import { type SceneDto, toSceneDto } from './scene.serializer';
export interface UploadedSceneFile {
buffer: Buffer;
originalname: string;
size: number;
mimetype?: string;
}
/**
* 上传文件的最小信息 —— 与 models/motions 共用 `UploadedFileLike`。
* 注意上传中间件是 multer 的 **diskStorage**:文件只有 `path`,**没有 `buffer`**,
* 所以不能按 `file.buffer` 判断(那会让场景永远传不上来)。
*/
export type UploadedSceneFile = UploadedFileLike;
const SUPPORTED_DIRECT = new Set(['.glb', '.gltf']);
@@ -35,6 +38,13 @@ function extOf(name: string): string {
* * 场景携带**物理参数**(重力 / 风向动态 / 地面 / 碰撞体)与环境、出生点、相机;
* * 服务端**只存参数、不跑物理**:仿真在客户端(Godot 内置物理)。
*/
/** 场景子系统的可用性(供 /health 与降级逻辑使用) */
export interface ScenesAvailability {
available: boolean;
/** 不可用时的原因(数据库错误信息,已截断) */
error: string | null;
}
@Injectable()
export class ScenesService {
private readonly logger = new Logger(ScenesService.name);
@@ -45,10 +55,58 @@ export class ScenesService {
private readonly config: AppConfig,
) {}
/**
* 场景表是否可用。
*
* **场景是可选资源**(文档 §5.6:没有可用场景时客户端回退内置空场景),因此
* 「场景表缺失 / 列与实体不一致 / 账号无权限」**不允许**把 `catalog/export`、
* `catalog/version`、`/api/reviews?status=pending` 这些与场景无关的功能一起带崩 ——
* 曾经就是这样:数据库里没有 scenes 表,于是客户端拉不到任何目录(整站不可用)。
*
* 这里记住最后一次探测结果:出错时**只记一次日志**(避免刷屏)并降级为「无场景」,
* 同时把原因暴露给 `/health`,保证问题依然可见、只是不再致命。
*/
private availability: ScenesAvailability = { available: true, error: null };
/** 场景子系统当前是否可用(/health 展示) */
diagnostics(): ScenesAvailability {
return { ...this.availability };
}
/** 执行一次场景查询;数据库层面失败时降级为空结果并记录原因 */
private async queryScenes<T>(label: string, run: () => Promise<T>, fallback: T): Promise<T> {
try {
const result = await run();
if (!this.availability.available) {
this.logger.log(`场景子系统已恢复(${label})`);
this.availability = { available: true, error: null };
}
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (this.availability.available || this.availability.error !== message) {
this.logger.error(
`场景查询失败(${label}),已降级为「无场景」:${message}。` +
'常见原因:scenes 表不存在 / 列与实体不一致 / 数据库账号无权限。' +
'排查:cd <APP_DIR>/web-server && set -a && . ../deploy/.env && set +a && ' +
'node scripts/diagnose-scenes.mjs',
);
}
this.availability = { available: false, error: message.slice(0, 300) };
return fallback;
}
}
async create(body: Record<string, unknown>, file: UploadedSceneFile | undefined): Promise<SceneDto> {
if (!file || !file.buffer || file.buffer.length === 0) {
if (!file) {
throw new BadRequestException('上传场景必须带 file 字段(.glb 为主,也接受 .gltf/.zip/.fbx/.pmx)');
}
// 上传中间件用的是 **diskStorage**(见 storage.module.ts),文件只有 `path`、没有
// `buffer`。这里必须与 models/motions 走同一套:requireUploadedFile 校验落盘 +
// 修正 latin1 文件名编码,后续要么 persistTempFile 直接归档(顺带清理临时文件),
// 要么读成 buffer 再转换。此前按 `file.buffer` 判断,导致**任何场景都传不上来**(400)。
const upload = requireUploadedFile(file, 'file');
const name = typeof body.name === 'string' && body.name.trim().length > 0 ? body.name.trim() : '';
if (name.length === 0) throw new BadRequestException('场景名称 name 必填');
const tuple = assertSourceTupleComplete({
@@ -63,47 +121,63 @@ export class ScenesService {
}
const parsed = this.parseConfigInput(body);
const ext = extOf(file.originalname || '');
let buffer: Buffer;
const ext = extOf(upload.originalname || '');
if (upload.size && upload.size > this.config.uploadMaxBytes) {
throw new BadRequestException(
`上传文件超过上限 ${Math.floor(this.config.uploadMaxBytes / 1024 / 1024)} MiB`,
);
}
let stored: StoredAsset;
let conversionNote: string;
let storedSize: number;
if (SUPPORTED_DIRECT.has(ext)) {
// 场景主力格式:GLB 直接导入(不重编码)
buffer = file.buffer;
// 场景主力格式:GLB 直接导入(不重编码)——直接归档 multer 落盘的临时文件
stored = await this.storage.persistTempFile(
AssetKind.Scene,
upload.path,
upload.originalname || `scene-${Date.now()}.glb`,
upload.mimetype || 'model/gltf-binary',
);
conversionNote = `直接导入(${ext.slice(1).toUpperCase()},未做重编码)`;
storedSize = stored.asset.size;
} else {
// 其余格式统一转 GLB(场景不做 VRM)
// 其余格式统一转 GLB(场景不做 VRM);转换需要 buffer,读完再转换,
// 无论成败都要清掉临时文件(persistTempFile 那条路会自己清)
try {
const source = await readFile(upload.path);
const converted =
ext === '.zip'
? await convertModelArchive(file.buffer, {
? await convertModelArchive(source, {
assimpBin: this.config.assimpBin,
timeoutMs: this.config.modelConvertTimeoutMs,
preferFormat: 'glb',
})
: await convertModelFile(file.buffer, file.originalname, {
: await convertModelFile(source, upload.originalname, {
assimpBin: this.config.assimpBin,
timeoutMs: this.config.modelConvertTimeoutMs,
preferFormat: 'glb',
});
buffer = converted.buffer;
stored = await this.storage.persistBuffer(
AssetKind.Scene,
converted.buffer,
upload.originalname || `scene-${Date.now()}.glb`,
'model/gltf-binary',
);
storedSize = converted.buffer.length;
conversionNote = converted.note.slice(0, 500);
} catch (error) {
throw new BadRequestException(
`场景转换失败(${ext || '未知扩展名'}):${error instanceof Error ? error.message : String(error)}`,
);
} finally {
await unlink(upload.path).catch(() => undefined);
}
}
const stored = await this.storage.persistBuffer(
AssetKind.Scene,
buffer,
file.originalname || `scene-${Date.now()}.glb`,
'model/gltf-binary',
);
const absolutePath = this.storage.absolutePath(stored.asset);
const stats = await this.inspectSafely(absolutePath, buffer.length, name);
const stats = await this.inspectSafely(absolutePath, storedSize, name);
const thumbnailAssetId = await this.thumbnailSafely(absolutePath);
const scene = this.scenes.create({
@@ -125,7 +199,7 @@ export class ScenesService {
});
const saved = await this.scenes.save(scene);
this.logger.log(
`场景已入库待审:#${saved.id} ${name}(${file.originalname},${conversionNote})` +
`场景已入库待审:#${saved.id} ${name}(${upload.originalname},${conversionNote})` +
` 重力=(${parsed.physics.gravity.x},${parsed.physics.gravity.y},${parsed.physics.gravity.z})`,
);
const [dto] = await this.toDtos([saved]);
@@ -253,10 +327,16 @@ export class ScenesService {
/** 供 catalog 使用:已发布场景(含物理参数) */
async published(): Promise<SceneDto[]> {
const rows = await this.scenes.find({
where: { status: PublishStatus.Published },
order: { id: 'ASC' },
});
return this.toDtos(rows);
return this.queryScenes(
'published',
async () => {
const rows = await this.scenes.find({
where: { status: PublishStatus.Published },
order: { id: 'ASC' },
});
return this.toDtos(rows);
},
[],
);
}
}
+111
View File
@@ -0,0 +1,111 @@
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
PACKAGE_NAME,
UNKNOWN_VERSION,
packageJsonCandidates,
parsePackageVersion,
resetAppVersionCache,
resolveAppVersion,
} from '../src/config/app-version';
/**
* 版本号展示的**运行时唯一来源**(文档 §11.1 / §11.2)。
*
* 这里守护的是一个真实发生过的缺陷:原来 `/health` 用
* `process.env.npm_package_version ?? '0.1.0'`,而正式部署方式是原生服务
* (`ExecStart=node dist/main.js`),npm 不参与 → 该环境变量为 undefined →
* `/health` 在真实部署里永远报 **0.1.0**,与管理端左下角展示的 vX.Y.Z 不一致,
* 直接违反「可核对:Web 端至少与 GET /health 的 version 一致」。
*/
const serverPackageJson = JSON.parse(
readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8'),
) as { name: string; version: string };
afterEach(() => {
delete process.env.APP_VERSION;
resetAppVersionCache();
});
describe('parsePackageVersion(纯函数:只在包名与格式都对时才认)', () => {
it('接受本项目的 package.json', () => {
expect(
parsePackageVersion(JSON.stringify({ name: PACKAGE_NAME, version: '1.2.3' })),
).toBe('1.2.3');
});
it('接受带预发布后缀的版本', () => {
expect(
parsePackageVersion(JSON.stringify({ name: PACKAGE_NAME, version: '1.2.3-rc.1' })),
).toBe('1.2.3-rc.1');
});
it('拒绝包名不匹配的文件(避免读到上层仓库的 package.json)', () => {
expect(parsePackageVersion(JSON.stringify({ name: 'some-other-pkg', version: '9.9.9' }))).toBeNull();
});
it('拒绝缺少 name / version 的文件', () => {
expect(parsePackageVersion(JSON.stringify({ version: '1.2.3' }))).toBeNull();
expect(parsePackageVersion(JSON.stringify({ name: PACKAGE_NAME }))).toBeNull();
expect(parsePackageVersion(JSON.stringify({ name: PACKAGE_NAME, version: 123 }))).toBeNull();
});
it('拒绝非版本串(例如 workspace:*)', () => {
expect(parsePackageVersion(JSON.stringify({ name: PACKAGE_NAME, version: 'workspace:*' }))).toBeNull();
expect(parsePackageVersion(JSON.stringify({ name: PACKAGE_NAME, version: '^1.0.0' }))).toBeNull();
});
it('拒绝非法 JSON 与非对象', () => {
expect(parsePackageVersion('{ 不是 JSON')).toBeNull();
expect(parsePackageVersion('"字符串"')).toBeNull();
expect(parsePackageVersion('null')).toBeNull();
});
});
describe('resolveAppVersion(运行时解析)', () => {
it('解析结果等于 web-server/package.json 的 version(单一来源)', () => {
expect(resolveAppVersion()).toBe(serverPackageJson.version);
});
it('与 package.json 里声明的包名一致(校验逻辑没有把别的包读进来)', () => {
expect(serverPackageJson.name).toBe(PACKAGE_NAME);
});
it('未通过 npm 启动时也能拿到真实版本(不依赖 npm_package_version)', () => {
// 模拟原生部署:systemd 直接跑 node dist/main.js,没有 npm 环境变量
const saved = process.env.npm_package_version;
delete process.env.npm_package_version;
delete process.env.APP_VERSION;
resetAppVersionCache();
try {
const version = resolveAppVersion();
expect(version).toBe(serverPackageJson.version);
expect(version).not.toBe('0.1.0');
expect(version).not.toBe(UNKNOWN_VERSION);
} finally {
if (saved !== undefined) process.env.npm_package_version = saved;
resetAppVersionCache();
}
});
it('APP_VERSION 环境变量可显式覆盖(运维不重新打包也能改)', () => {
process.env.APP_VERSION = '9.8.7';
resetAppVersionCache();
expect(resolveAppVersion()).toBe('9.8.7');
});
it('候选路径包含工作目录(systemd WorkingDirectory=web-server)', () => {
const candidates = packageJsonCandidates();
expect(candidates).toContain(join(process.cwd(), 'package.json'));
// 去重:同一路径不应出现两次
expect(new Set(candidates).size).toBe(candidates.length);
});
it('结果稳定(同一进程内缓存,不会来回变)', () => {
const first = resolveAppVersion();
const second = resolveAppVersion();
expect(first).toBe(second);
});
});
+255
View File
@@ -0,0 +1,255 @@
import { describe, expect, it, vi } from 'vitest';
import { ModelFormat, PublishStatus } from '../src/common/enums';
import { CatalogService } from '../src/catalog/catalog.service';
import { SCENE_CONFIG_DEFAULTS, normalizeSceneConfig } from '../src/scenes/scene-config';
/**
* 目录导出(`GET /api/catalog/export` 与 `/api/catalog/version`)的**交付逻辑**。
*
* 这里守护两条硬性需求:
*
* 1. **① 非 VRM 模型不得进入客户端目录**:查询必须同时限定
* `status = published` 与 `format = vrm` —— 即使库里有已审核通过的 PMX/GLB,
* 也不能出现在 `models[]` 里(文档 §4.9:非 VRM 入库但标记不可用、
* 不进入已发布目录);
* 2. **④ 场景(含物理参数)必须随目录下发**,且场景变更要体现在
* `catalog/version` 的指纹里,否则客户端不会重新拉取。
*
* 之所以要单独测这一层:`CatalogService` 注入 `ScenesService` 时曾在
* `CatalogModule` 里漏了 `imports`,导致**整个服务起不来**(v0.7.0/v0.7.1)。
* 服务能起来之后,这里再保证「起来之后的目录内容是对的」。
*/
interface Stub {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}
function repository(rows: unknown[]): Stub {
return {
find: vi.fn(async () => rows),
};
}
function build(overrides: {
songs?: unknown[];
motions?: unknown[];
models?: unknown[];
scenes?: unknown[];
sceneDtos?: unknown[];
defaultModelId?: number | null;
} = {}) {
const songsRepo = repository(overrides.songs ?? []);
const motionsRepo = repository(overrides.motions ?? []);
const modelsRepo = repository(overrides.models ?? []);
const scenesService = {
published: vi.fn(async () => overrides.sceneDtos ?? []),
};
const songsService = { toDtos: vi.fn(async (rows: unknown[]) => rows) };
const motionsService = { toDtos: vi.fn(async (rows: unknown[]) => rows) };
const modelsService = {
toDtos: vi.fn(async (rows: unknown[]) => rows),
resolveDefaultModelId: vi.fn(async () => overrides.defaultModelId ?? null),
};
const config = {
contractVersions: {
'rest-api': '0.5.0',
'websocket-keypoints': '0.2.0',
'data-models': '0.7.0',
'skeleton-topology': '0.2.0',
'model-formats': '0.5.0',
database: '0.4.0',
},
};
const service = new CatalogService(
songsRepo as never,
motionsRepo as never,
modelsRepo as never,
songsService as never,
motionsService as never,
modelsService as never,
scenesService as never,
config as never,
);
return { service, songsRepo, motionsRepo, modelsRepo, scenesService, modelsService };
}
/**
* 一个"已审核通过但不是 VRM"的模型,用来验证它不会进入目录。
* 库里可能同时存在 GLB / glTF / FBX(未产出 VRM 的中间产物),
* 契约 §4.9 要求它们**不得**出现在客户端目录里。
*/
const NON_VRM_MODEL = {
id: 7,
name: '未产出 VRM 的模型(GLB)',
format: ModelFormat.Glb,
status: PublishStatus.Published,
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
};
const VRM_MODEL = {
id: 8,
name: 'VRoid 模型',
format: ModelFormat.Vrm,
status: PublishStatus.Published,
isPrimary: true,
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
};
const SCENE_DTO = {
id: 3,
name: '舞台',
glbUrl: '/static/scene/2026/01/ab/abcd.glb',
thumbnailUrl: '/static/scene/2026/01/ab/abcd.png',
fileSize: 2048,
fileSha256: 'abcdef0123456789',
conversionNote: '直接导入(GLB,未做重编码)',
config: normalizeSceneConfig({
physics: {
gravity: { x: 0, y: -3.5, z: 0 },
wind: { base: { x: 2, y: 0, z: -1 }, gustAmplitude: 4, gustFrequency: 0.5, space: 'world' },
ground: { friction: 0.8, restitution: 0.1, slopeLimit: 30 },
colliders: 'auto',
},
}),
stats: { meshes: 1 },
sourceType: 'original',
sourceName: '自建场景',
sourceUrl: 'https://example.com/scene',
license: 'MIT',
author: 'OpenDance',
status: PublishStatus.Published,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
};
describe('① 目录只交付已产出 VRM 的模型', () => {
it('查询同时限定 status=published 与 format=vrm', async () => {
const { service, modelsRepo } = build({ models: [VRM_MODEL] });
await service.exportBundle();
expect(modelsRepo.find).toHaveBeenCalledTimes(1);
const options = modelsRepo.find.mock.calls[0][0] as {
where: { status: string; format: string };
order: { id: string };
};
expect(options.where.status).toBe(PublishStatus.Published);
expect(options.where.format).toBe(ModelFormat.Vrm);
expect(options.order).toEqual({ id: 'ASC' });
});
it('/api/catalog/version 的模型查询同样只算 VRM(指纹不应受非 VRM 影响)', async () => {
const { service, modelsRepo } = build({ models: [VRM_MODEL] });
await service.version();
const options = modelsRepo.find.mock.calls[0][0] as { where: { format: string } };
expect(options.where.format).toBe(ModelFormat.Vrm);
});
it('库里的 GLB/glTF/FBX 都不会进入目录(只有 vrm 被允许)', async () => {
const { service, modelsRepo } = build({ models: [VRM_MODEL] });
await service.exportBundle();
const options = modelsRepo.find.mock.calls[0][0] as { where: { format: string } };
// 这三种是"尚未产出 VRM"的中间格式,契约 §4.9 明确要求它们不进已发布目录
for (const format of [ModelFormat.Glb, ModelFormat.Gltf, ModelFormat.Fbx]) {
expect(format).not.toBe(options.where.format);
}
expect(NON_VRM_MODEL.format).not.toBe(options.where.format);
});
it('defaultModelId 直接透传服务端解析结果', async () => {
const { service } = build({ models: [VRM_MODEL], defaultModelId: 8 });
const bundle = await service.exportBundle();
expect(bundle.defaultModelId).toBe(8);
});
it('没有任何可用模型时 defaultModelId 为 null(客户端自行回退)', async () => {
const { service } = build({ defaultModelId: null });
const bundle = await service.exportBundle();
expect(bundle.defaultModelId).toBeNull();
});
});
describe('④ 场景随目录下发(含物理参数)', () => {
it('exportBundle().scenes 来自 ScenesService.published()', async () => {
const { service, scenesService } = build({ sceneDtos: [SCENE_DTO] });
const bundle = await service.exportBundle();
expect(scenesService.published).toHaveBeenCalledTimes(1);
expect(bundle.scenes).toHaveLength(1);
expect(bundle.scenes[0].id).toBe(3);
expect(bundle.scenes[0].name).toBe('舞台');
});
it('场景的物理参数完整透传(客户端据此落地重力/风/地面)', async () => {
const { service } = build({ sceneDtos: [SCENE_DTO] });
const bundle = await service.exportBundle();
const config = bundle.scenes[0].config;
expect(config).not.toBeNull();
expect(config?.physics.gravity.y).toBeCloseTo(-3.5);
expect(config?.physics.wind.base.x).toBeCloseTo(2);
expect(config?.physics.wind.gustAmplitude).toBeCloseTo(4);
expect(config?.physics.ground.friction).toBeCloseTo(0.8);
expect(config?.physics.colliders).toBe('auto');
});
it('scenes 字段在没有任何已发布场景时是空数组而不是 undefined', async () => {
const { service } = build({ sceneDtos: [] });
const bundle = await service.exportBundle();
expect(Array.isArray(bundle.scenes)).toBe(true);
expect(bundle.scenes).toHaveLength(0);
});
it('契约版本随目录一并下发(客户端据此判断协议兼容)', async () => {
const { service } = build({ sceneDtos: [SCENE_DTO] });
const bundle = await service.exportBundle();
expect(bundle.contractVersion['rest-api']).toBe('0.5.0');
expect(bundle.contractVersion['data-models']).toBe('0.7.0');
expect(bundle.contractVersion['websocket-keypoints']).toBe('0.2.0');
});
});
describe('④ 场景变更必须改变 catalog/version 指纹', () => {
const scene = (overrides: Partial<{ id: number; name: string; updatedAt: string; glbUrl: string }>) => ({
id: 3,
name: '舞台',
updatedAt: '2026-01-01T00:00:00.000Z',
glbUrl: '/static/scene/a.glb',
status: PublishStatus.Published,
...overrides,
});
it('相同目录 → 指纹稳定;新增/改名/换 GLB → 指纹变化', async () => {
const before = await build({ sceneDtos: [scene({})] }).service.version();
const same = await build({ sceneDtos: [scene({})] }).service.version();
expect(same.revision).toBe(before.revision);
const renamed = await build({ sceneDtos: [scene({ name: '舞台 2' })] }).service.version();
expect(renamed.revision).not.toBe(before.revision);
const replaced = await build({
sceneDtos: [scene({ glbUrl: '/static/scene/b.glb' })],
}).service.version();
expect(replaced.revision).not.toBe(before.revision);
const touched = await build({
sceneDtos: [scene({ updatedAt: '2026-02-02T00:00:00.000Z' })],
}).service.version();
expect(touched.revision).not.toBe(before.revision);
});
it('未发布任何场景时指纹仍可计算(64 位十六进制)', async () => {
const { service } = build({ sceneDtos: [] });
const version = await service.version();
expect(version.revision).toMatch(/^[0-9a-f]{64}$/);
});
it('默认场景配置是冻结默认值(无场景时客户端按默认物理参数回退)', () => {
expect(SCENE_CONFIG_DEFAULTS.physics.gravity.y).toBeCloseTo(-9.8);
expect(SCENE_CONFIG_DEFAULTS.physics.wind.gustAmplitude).toBe(0);
expect(SCENE_CONFIG_DEFAULTS.camera.mode).toBe('follow');
});
});
+139
View File
@@ -0,0 +1,139 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, describe, expect, it, vi } from 'vitest';
import { AssetKind, PublishStatus } from '../src/common/enums';
import { ScenesService } from '../src/scenes/scenes.service';
/**
* 场景上传必须兼容 multer 的 **diskStorage**。
*
* 现场(真实部署):上传中间件在 `storage.module.ts` 里配置为 `diskStorage`,
* 落盘后的文件对象**只有 `path`、没有 `buffer`**;而 `ScenesService.create()`
* 当初按内存存储写,判断的是 `!file.buffer`,于是**任何场景都传不上来**,
* 一律 400「上传场景必须带 file 字段」—— 冒烟脚本里 6 项场景断言全红。
*
* 正确的做法与 models/motions 一致:`requireUploadedFile(file)` 校验落盘、
* 修正 latin1 文件名编码,直接导入走 `persistTempFile()`(顺带清理临时文件),
* 需要转换时再 `readFile(upload.path)`。
*/
const dir = mkdtempSync(join(tmpdir(), 'opendance-scene-upload-'));
afterAll(() => rmSync(dir, { recursive: true, force: true }));
/** 一个最小的 GLB(JSON-only),仅用于让"直接导入"这条路径有文件可读 */
function tinyGlb(): Buffer {
const json = Buffer.from(
JSON.stringify({
asset: { version: '2.0' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ name: 'Floor_col' }],
meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }],
accessors: [{ count: 3 }],
}),
'utf8',
);
const padded = Buffer.concat([json, Buffer.alloc((4 - (json.length % 4)) % 4, 0x20)]);
const header = Buffer.alloc(12);
header.write('glTF', 0, 'ascii');
header.writeUInt32LE(2, 4);
header.writeUInt32LE(12 + 8 + padded.length, 8);
const chunkHeader = Buffer.alloc(8);
chunkHeader.writeUInt32LE(padded.length, 0);
chunkHeader.writeUInt32LE(0x4e4f534a, 4);
return Buffer.concat([header, chunkHeader, padded]);
}
/** 造一个 **diskStorage 形状**的上传文件:有 path,没有 buffer */
function diskUpload(name = 'stage.glb') {
const path = join(dir, `${Date.now()}-${Math.random().toString(16).slice(2)}${name.slice(name.lastIndexOf('.'))}`);
writeFileSync(path, tinyGlb());
const size = tinyGlb().length;
return { originalname: name, mimetype: 'model/gltf-binary', path, size };
}
const SOURCE = {
sourceType: 'original',
sourceName: '测试来源',
sourceUrl: 'https://example.com/s',
license: 'MIT',
author: 'QA',
};
function buildService() {
const persisted = {
id: 42,
kind: AssetKind.Scene,
sha256: 'a'.repeat(64),
storedPath: 'scene/2026/09/aa/x.glb',
size: 216,
};
const persistTempFile = vi.fn(async () => ({ asset: persisted, url: '/static/scene/x.glb' }));
const persistBuffer = vi.fn(async () => ({ asset: persisted, url: '/static/scene/x.glb' }));
const storage = {
persistTempFile,
persistBuffer,
absolutePath: vi.fn(() => join(dir, 'not-a-real-file.glb')),
url: vi.fn(() => '/static/scene/x.glb'),
findMany: vi.fn(async () => new Map()),
};
const scenesRepository = {
create: vi.fn((value: Record<string, unknown>) => value),
save: vi.fn(async (value: Record<string, unknown>) => ({
...value,
id: 7,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
})),
find: vi.fn(async () => []),
};
const config = { uploadMaxBytes: 512 * 1024 * 1024, assimpBin: 'assimp', modelConvertTimeoutMs: 1000 };
const service = new ScenesService(scenesRepository as never, storage as never, config as never);
return { service, storage, persistTempFile, persistBuffer, scenesRepository };
}
describe('场景上传兼容 multer diskStorage', () => {
it('diskStorage 形状的文件(只有 path)不会再被判成"缺少 file 字段"', async () => {
const { service, persistTempFile } = buildService();
const dto = await service.create({ name: '冒烟场景', ...SOURCE }, diskUpload() as never);
expect(dto).toBeTruthy();
// 直接导入走 persistTempFile(落盘的临时文件被归档)
expect(persistTempFile).toHaveBeenCalledTimes(1);
const call = persistTempFile.mock.calls[0] as unknown[];
expect(call[0]).toBe(AssetKind.Scene);
expect(String(call[1])).toMatch(/\.glb$/);
expect(call[2]).toBe('stage.glb');
});
it('GLB 直传仍标注"未做重编码",并落 pending 待审', async () => {
const { service } = buildService();
const dto = await service.create({ name: '冒烟场景', ...SOURCE }, diskUpload() as never);
expect(String(dto.conversionNote)).toContain('直接导入');
expect(dto.status).toBe(PublishStatus.Pending);
});
it('文件名用 latin1 乱码传入时会被还原(与 models/motions 同一套)', async () => {
const { service, persistTempFile } = buildService();
// busboy 会把 UTF-8 文件名按 latin1 解出来
const mangled = Buffer.from('舞台.glb', 'utf8').toString('latin1');
await service.create({ name: '中文名场景', ...SOURCE }, diskUpload(mangled) as never);
const call = persistTempFile.mock.calls[0] as unknown[];
expect(call[2]).toBe('舞台.glb');
});
it('缺少文件时仍然 400(错误语义没有被削弱)', async () => {
const { service } = buildService();
await expect(service.create({ name: '无文件', ...SOURCE }, undefined)).rejects.toThrow(/必须带 file 字段/);
});
it('超过 UPLOAD_MAX_BYTES 时 400 且不落盘', async () => {
const { service, persistTempFile } = buildService();
const upload = diskUpload();
await expect(
service.create({ name: '超大场景', ...SOURCE }, { ...upload, size: 999 * 1024 * 1024 } as never),
).rejects.toThrow(/超过上限/);
expect(persistTempFile).not.toHaveBeenCalled();
});
});
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from 'vitest';
import { PublishStatus } from '../src/common/enums';
import { CatalogService } from '../src/catalog/catalog.service';
import { ReviewsService } from '../src/reviews/reviews.service';
import { ScenesService } from '../src/scenes/scenes.service';
/**
* 场景子系统必须**可降级**(不是可选项的健壮性,而是生产事故的直接教训)。
*
* 现场:数据库里没有 `scenes` 表(迁移没落到该库 / dist 里缺迁移文件),于是
* - `GET /api/scenes` → 500
* - `GET /api/catalog/export` → 500 ← 客户端**拉不到任何目录**,整站不可用
* - `GET /api/catalog/version` → 500
* - `GET /api/reviews?status=pending` → 500 ← 管理端待审列表也挂了
* 而 `/api/songs`、`/api/models` 都正常。
*
* 契约与文档都明确「**场景是可选资源**」(没有可用场景时客户端回退内置空场景),
* 因此场景表不可用**不能**连带打挂与场景无关的功能。这里守护这条底线:
* 降级为空场景 + 日志 + `/health` 如实暴露,而不是 500。
*/
describe('ScenesService 降级(scenes 表不可用时)', () => {
const boom = () => {
throw new Error("Table 'opendance.scenes' doesn't exist");
};
function buildScenes(repository: unknown) {
const service = new ScenesService(
repository as never,
{ findMany: vi.fn(async () => new Map()), url: () => '' } as never,
{} as never,
);
return service;
}
it('published() 在表缺失时返回空数组而不是抛错', async () => {
const service = buildScenes({ find: vi.fn(async () => boom()) });
await expect(service.published()).resolves.toEqual([]);
});
it('published() 同时记录可用性与原因(供 /health 暴露)', async () => {
const service = buildScenes({ find: vi.fn(async () => boom()) });
expect(service.diagnostics()).toEqual({ available: true, error: null });
await service.published();
const diag = service.diagnostics();
expect(diag.available).toBe(false);
expect(diag.error).toContain("doesn't exist");
});
it('表恢复后自动回到可用状态', async () => {
const find = vi.fn(async () => boom());
const service = buildScenes({ find });
await service.published();
expect(service.diagnostics().available).toBe(false);
// 表建好后:查询成功(空结果),可用性应自动恢复
find.mockImplementation(async () => [] as never);
await expect(service.published()).resolves.toEqual([]);
expect(service.diagnostics()).toEqual({ available: true, error: null });
});
it('错误信息截断到 300 字符(避免把整段 SQL 错误塞进 /health)', async () => {
const service = buildScenes({
find: vi.fn(async () => {
throw new Error('x'.repeat(1000));
}),
});
await service.published();
expect(service.diagnostics().error?.length).toBe(300);
});
});
describe('降级不会波及与场景无关的功能', () => {
it('catalog/export 仍然成功,scenes 为空数组(客户端可继续拉目录)', async () => {
const scenesService = new ScenesService(
{
find: vi.fn(async () => {
throw new Error("Table 'opendance.scenes' doesn't exist");
}),
} as never,
{ findMany: vi.fn(async () => new Map()), url: () => '' } as never,
{} as never,
);
const repository = (rows: unknown[]) => ({ find: vi.fn(async () => rows) });
const service = new CatalogService(
repository([]) as never,
repository([]) as never,
repository([]) as never,
{ toDtos: vi.fn(async (rows: unknown[]) => rows) } as never,
{ toDtos: vi.fn(async (rows: unknown[]) => rows) } as never,
{
toDtos: vi.fn(async (rows: unknown[]) => rows),
resolveDefaultModelId: vi.fn(async () => null),
} as never,
scenesService as never,
{ contractVersions: { 'rest-api': '0.5.0' } } as never,
);
const bundle = await service.exportBundle();
expect(bundle.scenes).toEqual([]);
expect(bundle.contractVersion['rest-api']).toBe('0.5.0');
const version = await service.version();
expect(version.revision).toMatch(/^[0-9a-f]{64}$/);
expect(scenesService.diagnostics().available).toBe(false);
});
it('待审列表跳过场景而不是整体 500', async () => {
const service = new ReviewsService(
{} as never,
{ find: vi.fn(async () => []) } as never, // songs
{ find: vi.fn(async () => []) } as never, // motions
{ find: vi.fn(async () => []) } as never, // models
{
find: vi.fn(async () => {
throw new Error("Table 'opendance.scenes' doesn't exist");
}),
} as never, // scenes
{} as never, // modelsService
);
const page = await service.listByStatus(PublishStatus.Pending, {
skip: 0,
pageSize: 20,
page: 1,
} as never);
expect(page.total).toBe(0);
expect(page.items).toEqual([]);
});
});
+194 -11
View File
@@ -194,12 +194,15 @@ async function main() {
});
const modelRes = await request('POST', '/api/models', {
form: fileForm({ name: '冒烟 VRM 模型', isPrimary: true, ...SOURCE }, 'avatar.vrm', vrmFile, 'model/gltf-binary'),
form: fileForm({ name: '冒烟 VRM 模型', ...SOURCE }, 'avatar.vrm', vrmFile, 'model/gltf-binary'),
});
check('POST /api/models 返回 201', modelRes.status === 201, `实际 ${modelRes.status} ${JSON.stringify(modelRes.body)}`);
const modelId = modelRes.body?.id;
check('识别 VRM 版本为 1.0', modelRes.body?.vrmVersion === '1.0', String(modelRes.body?.vrmVersion));
check('isPrimary 已置位', modelRes.body?.isPrimary === true);
check('上传后状态为待审核', modelRes.body?.status === 'pending', String(modelRes.body?.status));
// 契约 data-models §4 / rest-api POST /api/models:**上传一律 pending,且不设默认模型**;
// 默认模型只能由「审核通过时带 setPrimary=true」或 POST /api/models/{id}/primary 设定。
check('上传阶段不置默认模型(isPrimary=false)', modelRes.body?.isPrimary === false, String(modelRes.body?.isPrimary));
const modelMissingSource = await request('POST', '/api/models', {
form: fileForm({ name: '缺少来源' }, 'x.vrm', vrmFile),
@@ -215,8 +218,13 @@ async function main() {
const submit = await request('POST', `/api/reviews/song/${songId}/submit`, { json: { reviewer: 'qa' } });
check('提交审核 → pending', submit.status === 200 && submit.body?.status === 'pending', `实际 ${submit.status} ${JSON.stringify(submit.body)}`);
const pending = await request('GET', '/api/reviews?status=pending');
check('待审列表包含该曲目', pending.body?.items?.some((i) => i.targetType === 'song' && i.targetId === songId));
// 待审列表默认分页:显式放大 pageSize,避免库里已有大量待审项时新条目被挤出第一页
const pending = await request('GET', '/api/reviews?status=pending&pageSize=200');
check(
'待审列表包含该曲目',
pending.status === 200 && pending.body?.items?.some((i) => i.targetType === 'song' && i.targetId === songId),
`HTTP ${pending.status},total=${pending.body?.total},条数=${pending.body?.items?.length},${JSON.stringify(pending.body).slice(0, 200)}`,
);
const badReject = await request('POST', `/api/reviews/song/${songId}/reject`, { json: { reviewer: 'qa' } });
check('驳回未填理由 → 400', badReject.status === 400, `实际 ${badReject.status}`);
@@ -233,21 +241,191 @@ async function main() {
await request('POST', `/api/reviews/motion/${motionId}/submit`, { json: { reviewer: 'qa' } });
await request('POST', `/api/reviews/motion/${motionId}/approve`, { json: { reviewer: 'qa' } });
await request('POST', `/api/reviews/model/${modelId}/submit`, { json: { reviewer: 'qa' } });
await request('POST', `/api/reviews/model/${modelId}/approve`, { json: { reviewer: 'qa' } });
// 默认模型按契约在此设定:审核通过时带 setPrimary=true(等价于 POST /api/models/:id/primary)
const modelApprove = await request('POST', `/api/reviews/model/${modelId}/approve`, {
json: { reviewer: 'qa', setPrimary: true },
});
check(
'模型审核通过并设为默认(setPrimary=true)',
modelApprove.status === 200 && modelApprove.body?.status === 'published',
`HTTP ${modelApprove.status} ${JSON.stringify(modelApprove.body).slice(0, 160)}`,
);
const modelAfter = await request('GET', `/api/models/${modelId}`);
check('审核通过后 isPrimary 已置位', modelAfter.body?.isPrimary === true, String(modelAfter.body?.isPrimary));
// ---------- 6. 目录导出 ----------
console.log('\n[6] 客户端目录导出');
const bundle = await request('GET', '/api/catalog/export');
check('GET /api/catalog/export 返回 200', bundle.status === 200);
check(
'GET /api/catalog/export 返回 200',
bundle.status === 200,
`HTTP ${bundle.status} ${JSON.stringify(bundle.body).slice(0, 200)}`,
);
check('仅含已发布曲目', bundle.body?.songs?.some((s) => s.id === songId));
check('defaultModelId 解析为 VRM 模型', bundle.body?.defaultModelId === modelId, String(bundle.body?.defaultModelId));
check('bundle 含契约版本', Boolean(bundle.body?.contractVersion?.['data-models']));
const version = await request('GET', '/api/catalog/version');
check('GET /api/catalog/version 返回目录指纹', /^[0-9a-f]{64}$/.test(version.body?.revision ?? ''), String(version.body?.revision).slice(0, 16));
check(
'GET /api/catalog/version 返回目录指纹',
/^[0-9a-f]{64}$/.test(version.body?.revision ?? ''),
`HTTP ${version.status} ${JSON.stringify(version.body).slice(0, 200)}`,
);
// ---------- 7. 负向用例 ----------
console.log('\n[7] 负向用例');
// ---------- 7. T-WEB-015 场景管理(GLB + 物理参数) ----------
console.log('\n[7] T-WEB-015 场景管理(GLB + 物理参数)');
const sceneConfig = {
physics: {
gravity: { x: 0, y: -3.5, z: 0 },
wind: {
base: { x: 2, y: 0, z: -1 },
gustAmplitude: 4,
gustFrequency: 0.5,
gustDirection: { x: 1, y: 0, z: 0 },
space: 'world',
},
ground: { friction: 0.8, restitution: 0.1, slopeLimit: 30 },
colliders: 'auto',
},
environment: { ambientColor: '#112233', ambientIntensity: 2, fogDensity: 0.2 },
spawnPoints: [{ x: 1, y: 0, z: -2, yaw: 90 }],
camera: { mode: 'orbit', distance: 6, height: 2 },
};
// 场景 GLB:一个 mesh(POSITION=3)+ 一个按客户端 `colliders: auto` 命名约定
// 命名的节点(*_col)。分析/缩略图失败是非致命的,下面的断言只依赖入库结果。
const sceneGlb = buildGlb({
asset: { version: '2.0' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ name: 'Floor_col' }],
meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }],
accessors: [{ count: 3 }],
});
const sceneRes = await request('POST', '/api/scenes', {
form: fileForm(
{ name: `冒烟场景 ${Date.now()}`, config: JSON.stringify(sceneConfig), ...SOURCE },
'stage.glb',
sceneGlb,
'model/gltf-binary',
),
});
check('POST /api/scenes 返回 201', sceneRes.status === 201, `实际 ${sceneRes.status} ${JSON.stringify(sceneRes.body)}`);
const sceneId = sceneRes.body?.id;
check('上传场景一律待审核', sceneRes.body?.status === 'pending', String(sceneRes.body?.status));
check(
'GLB 直传不重编码(conversionNote 说明)',
typeof sceneRes.body?.conversionNote === 'string' && sceneRes.body.conversionNote.includes('直接导入'),
String(sceneRes.body?.conversionNote),
);
check(
'glbUrl 已生成',
typeof sceneRes.body?.glbUrl === 'string' && sceneRes.body.glbUrl.startsWith('/static/'),
String(sceneRes.body?.glbUrl),
);
// 物理参数入库即收敛:合法值原样保留
const sceneCfg = sceneRes.body?.config;
check('重力按配置入库', sceneCfg?.physics?.gravity?.y === -3.5, JSON.stringify(sceneCfg?.physics?.gravity));
check('风基础向量入库', sceneCfg?.physics?.wind?.base?.x === 2, JSON.stringify(sceneCfg?.physics?.wind));
check(
'地面摩擦/坡度入库',
sceneCfg?.physics?.ground?.friction === 0.8 && sceneCfg?.physics?.ground?.slopeLimit === 30,
JSON.stringify(sceneCfg?.physics?.ground),
);
check(
'出生点与相机模式入库',
sceneCfg?.spawnPoints?.[0]?.yaw === 90 && sceneCfg?.camera?.mode === 'orbit',
JSON.stringify({ spawn: sceneCfg?.spawnPoints, camera: sceneCfg?.camera }),
);
// 物理参数入库即收敛:越界夹紧、非法枚举回退
const clampRes = await request('POST', '/api/scenes', {
form: fileForm(
{
name: `冒烟越界场景 ${Date.now()}`,
config: JSON.stringify({
physics: { gravity: { y: -9999 }, ground: { friction: 5 }, colliders: '不存在的枚举' },
camera: { mode: '无此模式' },
}),
...SOURCE,
},
'stage-clamped.glb',
sceneGlb,
'model/gltf-binary',
),
});
const clampId = clampRes.body?.id;
check(
'越界参数被夹紧(不会把非法物理下发到客户端)',
clampRes.body?.config?.physics?.gravity?.y === -200 && clampRes.body?.config?.physics?.ground?.friction === 1,
JSON.stringify(clampRes.body?.config?.physics),
);
check(
'非法枚举回退默认值',
clampRes.body?.config?.physics?.colliders === 'auto' && clampRes.body?.config?.camera?.mode === 'follow',
JSON.stringify({ colliders: clampRes.body?.config?.physics?.colliders, camera: clampRes.body?.config?.camera?.mode }),
);
const sceneMissingSource = await request('POST', '/api/scenes', {
form: fileForm({ name: '缺来源' }, 'x.glb', sceneGlb, 'model/gltf-binary'),
});
check('场景缺少来源五元组 → 400', sceneMissingSource.status === 400, `实际 ${sceneMissingSource.status}`);
// 未审核前不得进入客户端目录
const versionBeforeScene = await request('GET', '/api/catalog/version');
const bundleBeforeScene = await request('GET', '/api/catalog/export');
check(
'待审场景不出现在客户端目录',
!bundleBeforeScene.body?.scenes?.some((s) => s.id === sceneId),
JSON.stringify(bundleBeforeScene.body?.scenes?.map((s) => s.id)),
);
await request('POST', `/api/reviews/scene/${sceneId}/submit`, { json: { reviewer: 'qa' } });
const sceneApprove = await request('POST', `/api/reviews/scene/${sceneId}/approve`, {
json: { reviewer: 'qa', comment: '通过' },
});
check(
'场景审核通过 → published',
sceneApprove.status === 200 && sceneApprove.body?.status === 'published',
`实际 ${sceneApprove.status} ${JSON.stringify(sceneApprove.body)}`,
);
const bundleAfterScene = await request('GET', '/api/catalog/export');
const publishedScene = bundleAfterScene.body?.scenes?.find((s) => s.id === sceneId);
check(
'发布后场景进入客户端目录 scenes[]',
Boolean(publishedScene),
JSON.stringify(bundleAfterScene.body?.scenes?.map((s) => s.id)),
);
check(
'目录里的场景带完整物理参数',
publishedScene?.config?.physics?.gravity?.y === -3.5 && publishedScene?.config?.physics?.wind?.gustAmplitude === 4,
JSON.stringify(publishedScene?.config?.physics),
);
check(
'目录里的场景带 GLB 与缩略图字段',
typeof publishedScene?.glbUrl === 'string' && 'thumbnailUrl' in (publishedScene ?? {}),
JSON.stringify({ glbUrl: publishedScene?.glbUrl, thumbnailUrl: publishedScene?.thumbnailUrl }),
);
const versionAfterScene = await request('GET', '/api/catalog/version');
check(
'目录指纹纳入场景变更(客户端会因此重新拉取)',
versionAfterScene.body?.revision !== versionBeforeScene.body?.revision,
`${String(versionBeforeScene.body?.revision).slice(0, 12)} -> ${String(versionAfterScene.body?.revision).slice(0, 12)}`,
);
const sceneDetail = await request('GET', `/api/scenes/${sceneId}`);
check('GET /api/scenes/:id 返回详情', sceneDetail.status === 200 && sceneDetail.body?.id === sceneId);
const sceneList = await request('GET', '/api/scenes?status=published');
check(
'场景列表可按状态过滤',
sceneList.status === 200 && sceneList.body?.items?.some((s) => s.id === sceneId),
`实际 ${sceneList.status}`,
);
// ---------- 8. 负向用例 ----------
console.log('\n[8] 负向用例');
const notFound = await request('GET', '/api/songs/99999999');
check('不存在的曲目 → 404', notFound.status === 404);
const badEnum = await request('POST', '/api/songs', { json: { title: 'x', artist: 'y', ...SOURCE, sourceType: 'nope' } });
@@ -255,14 +433,19 @@ async function main() {
const badUrl = await request('POST', '/api/songs', { json: { title: 'x', artist: 'y', ...SOURCE, sourceUrl: 'not-a-url' } });
check('非法 sourceUrl → 400', badUrl.status === 400, `实际 ${badUrl.status}`);
// ---------- 8. 清理 ----------
console.log('\n[8] 清理');
// ---------- 9. 清理 ----------
console.log('\n[9] 清理');
const del = await request('DELETE', `/api/songs/${songId}`);
check('DELETE /api/songs/:id → 204', del.status === 204, `实际 ${del.status}`);
const afterDelete = await request('GET', '/api/songs?q=冒烟');
check('软删除后列表不再包含', !afterDelete.body?.items?.some((s) => s.id === songId));
await request('DELETE', `/api/motions/${motionId}`);
await request('DELETE', `/api/models/${modelId}`);
if (sceneId) {
const delScene = await request('DELETE', `/api/scenes/${sceneId}`);
check('DELETE /api/scenes/:id → 204', delScene.status === 204, `实际 ${delScene.status}`);
}
if (clampId) await request('DELETE', `/api/scenes/${clampId}`);
console.log(`\n== 结果:${passed} 通过 / ${failed} 失败 ==`);
if (failed > 0) {