Files
opendance-web/tasks/015-scene-management.md
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

245 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 且不落盘。该用例在修复前必然失败。
## 缺陷修复记录四(v0.7.4):**真正的根因** —— `Scene` 没注册进 `ENTITIES`
部署 v0.7.3 后,`/health` 的 `scenes.error` 给出了决定性线索:
```
No metadata for "Scene" was found.
```
`src/entities/index.ts` 是:
```ts
export const ENTITIES = [Asset, Song, Motion, CharacterModel, ReviewRecord]; // ← 少了 Scene
```
TypeORM 的 DataSource **只按 `ENTITIES` 注册 metadata**,漏注册的后果非常隐蔽:
| 环节 | 表现 |
|--|--|
| 启动 | **完全正常** —— DI 图可解析、`/health` 正常、`migrationsPending 0` |
| 迁移 | 正常(`AddScenes` 已执行,**表其实是建好的**) |
| 第一次查该实体 | 抛 `No metadata for "Scene" was found.` |
| 连带影响 | `/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` 对账**
(漏一个即失败;已反向验证:移除 `Scene` 后测试立刻失败);
* 检查 `ENTITIES` 无重复、无多余项;
* 核对 `AddScenes` 迁移文件确实存在(迁移文件缺失时 `showMigrations()` 仍会显示 0 个待执行)。
这类"漏注册"既过得了 `tsc`,也过得了启动冒烟(应用能启动),因此**只能靠静态核对兜住**。
## 产物级端到端验收(v0.7.4 发布包实测)
上面那条"只能靠静态核对兜住"其实**还不够**:静态核对查的是 `src/`,
而交付给部署方的是 `dist-release/*.tar.gz`。两者之间隔着
「解包 → npm install --omit=dev → 迁移 → 起进程」四步,
`nest build` 没重编译、打包带了旧的 `dist/` 都会让校验全绿而产物仍是坏的。
因此补了两层,并把整条验收自动化:
### 1. 产物级实体注册核对(`scripts/check-bootstrap.mjs` 新增一段)
直接读**产物**里的 `dist/entities/`:逐个 `import` 每个 `*.entity.js`
(`@Entity()` 装饰器在模块加载时写 metadata),要求
- 每个实体文件里被 `@Entity()` 装饰的类都在 `ENTITIES` 里;
- `ENTITIES` 里不允许出现没有 `@Entity()` 装饰的成员;
- `ENTITIES` 不允许重复;
- `Scene` 必须注册(点名,便于一眼看出是不是又漏了场景)。
原先的启动冒烟只证明"DI 图可解析",而**漏注册实体恰恰不影响启动** ——
所以这条必须单独存在。已接入 `npm run verify`。
**反向验证**(在真实 v0.7.4 发布包里复现生产缺陷):把产物
`dist/entities/index.js` 的 `ENTITIES` 去掉 `Scene` 后:
```
✗ 产物级实体注册核对失败(dist/entities 与 ENTITIES 不一致):
- scene.entity.js 里的 Scene 没有注册进 ENTITIES —— 查询该实体会抛
`No metadata for "..." was found.`(接口直接 500)
- ENTITIES 里没有 Scene —— 场景管理相关接口会全部 500(生产事故根因)
退出码=1
```
### 2. 发布包级端到端验收(`scripts/accept-release.sh`,新增)
对一个 tar.gz 一键跑完:解包 → 装生产依赖 → 迁移 → 起服务 → 检查
`/health` 关键字段(版本 / 数据库 / `migrationsPending` / **`scenes.available`**)
→ 产物级实体注册核对 → 跑**发布包自带的** `test/smoke.mjs` → 停服务。
需要一个可连的 MySQL/MariaDB(脚本不装数据库,与项目边界一致)。
```bash
DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=opendance DB_PASS=*** DB_NAME=opendance_accept \
ASSIMP_BIN=/usr/bin/assimp bash scripts/accept-release.sh dist-release/opendance-web-v0.7.4.tar.gz
```
**实测(v0.7.4 发布包 + 真实 MySQL 9.7.2)**:
```
✓ /health 的 version=0.7.4 与 package.json 的 0.7.4 一致
✓ 数据库已连接(opendance_accept,pending=0)
✓ 没有未执行的迁移
✓ scenes.available=true(false 时 error=null)
✓ 产物级实体注册核对通过:dist/entities/ 下 6 个实体文件全部注册进 ENTITIES(含 Scene)
== 结果:80 通过 / 0 失败
产物级验收全部通过
```
其中冒烟的 `[7]` 段就是本任务的验收:`POST /api/scenes` 201、一律 `pending`、
GLB 直传不重编码、`glbUrl` 生成、**重力/风/地面摩擦/坡度/出生点/相机模式按配置原样入库**、
越界值夹紧与非法枚举回退、审核通过后 `catalog/export` 的 `scenes[]` 带完整物理参数与
GLB/缩略图字段、**目录指纹纳入场景变更**、`GET /api/scenes/:id`、按状态过滤、`DELETE` 204。
> 这一步同时排除了「Linux 上导出/打包产物与源码不一致」的可能:
> 跑的是发布包里**自带的** `dist/`、`scripts/`、`test/`,不是仓库源码。