6 Commits
Author SHA1 Message Date
zijunchiang 06b476c4e2 test(accept): 产物级端到端验收自动化 + 实体注册核对落到 dist(v0.7.4 实测全通过)
背景:`npm run verify` 跑的是源码与刚构建的 dist,而交付给部署方的是
`dist-release/*.tar.gz`;两者之间隔着「解包 → npm install --omit=dev → 迁移 →
起进程」四步。本项目三次生产事故(v0.7.0/v0.7.1 起不来、v0.7.3 场景上传 400、
v0.7.4 `No metadata for "Scene"`)全都只在产物真正跑起来时才暴露。

一、`scripts/check-bootstrap.mjs` 新增「产物级实体注册核对」
   直接读包内 `dist/entities/`:逐个 import 每个 *.entity.js(@Entity() 装饰器
   在模块加载时写 metadata),要求
     * 每个实体文件里被 @Entity() 装饰的类都在 ENTITIES 里;
     * ENTITIES 里不允许有未装饰的成员;不允许重复;
     * Scene 必须注册(点名)。
   为什么必须单独有这一条:漏注册实体**不影响启动**(TypeORM 只按 ENTITIES 注册,
   DI 图照样解析、/health 正常、migrationsPending 0),只在第一次查该实体时抛
   `No metadata`,导致 /api/scenes、/api/catalog/export、/api/catalog/version、
   /api/reviews?status=pending 全部 500 —— 客户端一个目录都拉不到。
   原来的启动冒烟与 entities-registry.spec.ts(查 src/)都抓不到它。

   反向验证(在真实 v0.7.4 发布包里复现生产缺陷):把包内
   dist/entities/index.js 的 ENTITIES 去掉 Scene 后,核对立即失败并给出
   「scene.entity.js 里的 Scene 没有注册进 ENTITIES」与「ENTITIES 里没有 Scene」;
   还原后通过。

二、`scripts/accept-release.sh`(新增):一个 tar.gz 的产物级端到端验收
   解包 → 装生产依赖 → 跑迁移 → 起服务 → 校验 /health 关键字段
   (版本 / 数据库 / migrationsPending / scenes.available)→ 产物级实体注册核对
   (用仓库当前的 checker 覆盖包内旧版本,验收标准以最新为准)→ 跑发布包自带的
   test/smoke.mjs → 停服务。只需要一个现成的 MySQL/MariaDB,不装数据库
   (与项目「不部署 MySQL」的边界一致)。

   实测(opendance-web-v0.7.4.tar.gz + 真实 MySQL 9.7.2,全新库从零迁移):
     ✓ /health version=0.7.4 与 package.json 一致
     ✓ 数据库已连接(opendance_accept,pending=0)
     ✓ scenes.available=true(生产 v0.7.3 是 false + No metadata)
     ✓ 产物级实体注册核对通过:6 个实体文件全部注册进 ENTITIES(含 Scene)
     == 结果:80 通过 / 0 失败 ==
   冒烟 [7] 段即 ④ 验收:POST /api/scenes 201、一律 pending、GLB 直传不重编码、
   glbUrl 生成、重力/风/摩擦/坡度/出生点/相机模式按配置原样入库、越界夹紧与非法
   枚举回退、审核通过后 catalog/export 的 scenes[] 带完整物理参数与 GLB/缩略图字段、
   目录指纹纳入场景变更、详情、状态过滤、DELETE 204。

   `npm run verify` 全绿:契约 6 项 / 35 路由 / 版本一致 / 295 项测试 / build /
   产物级注册核对 / 启动冒烟。
2026-09-17 04:44:30 +08:00
zijunchiang 7a74f598be fix(entities): 场景功能的真正根因 —— Scene 未注册进 ENTITIES(v0.7.4)
部署 v0.7.3 后,/health 的 scenes.error 给出决定性线索:

  No metadata for "Scene" was found.

src/entities/index.ts 的 ENTITIES 一直漏了 Scene:
  export const ENTITIES = [Asset, Song, Motion, CharacterModel, ReviewRecord];

TypeORM 只按 ENTITIES 注册 metadata,漏注册的后果极其隐蔽:
- 启动完全正常(DI 可解析、/health 正常、migrationsPending 0);
- 迁移也正常(AddScenes 已执行,表其实是建好的);
- 但第一次查 Scene 就抛 No metadata,于是 /api/scenes、/api/catalog/export、
  /api/catalog/version、/api/reviews?status=pending 全部 500
  → 客户端拉不到任何目录、管理端待审列表打不开。

即:此前"数据库缺 scenes 表"的判断也是错的(v0.7.3 的降级与诊断脚本因此仍然值得保留
—— 它们把"场景坏了"限制成"场景不可用",而不是整站不可用)。

修复:ENTITIES 加入 Scene 并导出。

新增静态防线 test/entities-registry.spec.ts(4 项):扫描 src/entities/*.entity.ts 里的
@Entity() 类并逐一与 ENTITIES 对账(漏一个即失败)、检查无重复/无多余、核对 AddScenes
迁移文件存在。已做反向验证:把 Scene 从 ENTITIES 移除后该测试立刻失败。
这类缺陷既过得了 tsc、也过得了启动冒烟(应用能启动),只能靠静态核对兜住。

文档:README.md/.txt 增加 v0.7.4 修订说明;tasks/015-scene-management.md 增
「缺陷修复记录四」;开发文档 §4.12 补「新增实体必须加入 ENTITIES」的硬性要求。

验证:npm run verify 通过(23 文件 / 285 项测试 + 契约/路由/版本一致 + 启动冒烟);
发布产物 opendance-web-v0.7.4.tar.gz
sha256 e680c23fc382b6c39498608ea0154f8f80e9e6b5c13518f0f88095644ce88fe1。
2026-09-17 03:48:52 +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 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 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