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

180 lines
9.8 KiB
Markdown
Raw 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`,也过得了启动冒烟(应用能启动),因此**只能靠静态核对兜住**。