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)。
This commit is contained in:
2026-09-17 03:24:56 +08:00
parent 2224195d05
commit f3b97fec53
14 changed files with 571 additions and 29 deletions
+33 -7
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,18 +241,36 @@ 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. T-WEB-015 场景管理(GLB + 物理参数) ----------
console.log('\n[7] T-WEB-015 场景管理(GLB + 物理参数)');