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

143 lines
8.1 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 且不落盘。该用例在修复前必然失败。