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
@@ -5,6 +5,7 @@ import { DataSource } from 'typeorm';
import { resolveAppVersion } from '../config/app-version';
import { AppConfig } from '../config/configuration';
import { detectAssimp } from '../preprocess/assimp';
import { ScenesService } from '../scenes/scenes.service';
export interface HealthReport {
status: 'ok' | 'degraded';
@@ -30,6 +31,15 @@ export interface HealthReport {
version?: string;
error?: string;
};
/**
* 场景子系统可用性(T-WEB-015)。
* 场景是**可选资源**:scenes 表不可用时服务降级为「无场景」而不是整站 500,
* 但这里必须如实暴露,否则问题会被静默吞掉。
*/
scenes: {
available: boolean;
error: string | null;
};
}
@Controller('health')
@@ -37,6 +47,7 @@ export class HealthController {
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly config: AppConfig,
private readonly scenesService: ScenesService,
) {}
/**
@@ -82,6 +93,7 @@ export class HealthController {
totalBytes: 0,
},
assimp,
scenes: this.scenesService.diagnostics(),
};
if (!database.connected) {
+5
View File
@@ -1,7 +1,12 @@
import { Module } from '@nestjs/common';
import { ScenesModule } from '../scenes/scenes.module';
import { HealthController } from './health.controller';
@Module({
// HealthController 注入 ScenesService(用于暴露场景子系统可用性)。
// Nest 的 DI 按模块解析:少了这个 imports,bootstrap 会直接
// UnknownDependenciesException —— 见 scripts/check-bootstrap.mjs。
imports: [ScenesModule],
controllers: [HealthController],
})
export class HealthModule {}
+5 -1
View File
@@ -29,7 +29,11 @@ export class ModelsController {
return this.models.list(query);
}
/** multipart/form-data:file + name + 来源五元组 + isPrimary/status */
/**
* multipart/form-data:file + name + 来源五元组。
* **上传一律 pending,且不接受 isPrimary/status**(契约 rest-api POST /api/models):
* 默认模型请在审核通过时带 `setPrimary=true`,或调用 `POST /api/models/{id}/primary`。
*/
@Post()
@UseInterceptors(FileInterceptor('file'))
create(
+14 -1
View File
@@ -2,6 +2,7 @@ import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
@@ -64,6 +65,8 @@ interface Reviewable {
@Injectable()
export class ReviewsService {
private readonly logger = new Logger(ReviewsService.name);
constructor(
@InjectRepository(ReviewRecord) private readonly records: Repository<ReviewRecord>,
@InjectRepository(Song) private readonly songs: Repository<Song>,
@@ -90,7 +93,17 @@ export class ReviewsService {
this.songs.find({ where: { status } }),
this.motions.find({ where: { status } }),
this.models.find({ where: { status } }),
this.scenes.find({ where: { status } }),
// 场景是**可选资源**:scenes 表缺失/结构不一致时不能让整个待审列表 500
// (那会连带打挂管理端与客户端目录)。降级为空列表,原因由
// ScenesService 记日志并在 /health 暴露。
this.scenes
.find({ where: { status } })
.catch((error: unknown) => {
this.logger.error(
`待审列表中的场景查询失败,已跳过场景:${error instanceof Error ? error.message : String(error)}`,
);
return [] as Scene[];
}),
]);
rows.push(...songs.map((s) => toPending(TargetType.Song, s, s.title)));
+61 -5
View File
@@ -35,6 +35,13 @@ function extOf(name: string): string {
* * 场景携带**物理参数**(重力 / 风向动态 / 地面 / 碰撞体)与环境、出生点、相机;
* * 服务端**只存参数、不跑物理**:仿真在客户端(Godot 内置物理)。
*/
/** 场景子系统的可用性(供 /health 与降级逻辑使用) */
export interface ScenesAvailability {
available: boolean;
/** 不可用时的原因(数据库错误信息,已截断) */
error: string | null;
}
@Injectable()
export class ScenesService {
private readonly logger = new Logger(ScenesService.name);
@@ -45,6 +52,49 @@ export class ScenesService {
private readonly config: AppConfig,
) {}
/**
* 场景表是否可用。
*
* **场景是可选资源**(文档 §5.6:没有可用场景时客户端回退内置空场景),因此
* 「场景表缺失 / 列与实体不一致 / 账号无权限」**不允许**把 `catalog/export`、
* `catalog/version`、`/api/reviews?status=pending` 这些与场景无关的功能一起带崩 ——
* 曾经就是这样:数据库里没有 scenes 表,于是客户端拉不到任何目录(整站不可用)。
*
* 这里记住最后一次探测结果:出错时**只记一次日志**(避免刷屏)并降级为「无场景」,
* 同时把原因暴露给 `/health`,保证问题依然可见、只是不再致命。
*/
private availability: ScenesAvailability = { available: true, error: null };
/** 场景子系统当前是否可用(/health 展示) */
diagnostics(): ScenesAvailability {
return { ...this.availability };
}
/** 执行一次场景查询;数据库层面失败时降级为空结果并记录原因 */
private async queryScenes<T>(label: string, run: () => Promise<T>, fallback: T): Promise<T> {
try {
const result = await run();
if (!this.availability.available) {
this.logger.log(`场景子系统已恢复(${label})`);
this.availability = { available: true, error: null };
}
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (this.availability.available || this.availability.error !== message) {
this.logger.error(
`场景查询失败(${label}),已降级为「无场景」:${message}。` +
'常见原因:scenes 表不存在 / 列与实体不一致 / 数据库账号无权限。' +
'排查:cd <APP_DIR>/web-server && set -a && . ../deploy/.env && set +a && ' +
'node scripts/diagnose-scenes.mjs',
);
}
this.availability = { available: false, error: message.slice(0, 300) };
return fallback;
}
}
async create(body: Record<string, unknown>, file: UploadedSceneFile | undefined): Promise<SceneDto> {
if (!file || !file.buffer || file.buffer.length === 0) {
throw new BadRequestException('上传场景必须带 file 字段(.glb 为主,也接受 .gltf/.zip/.fbx/.pmx)');
@@ -253,10 +303,16 @@ export class ScenesService {
/** 供 catalog 使用:已发布场景(含物理参数) */
async published(): Promise<SceneDto[]> {
const rows = await this.scenes.find({
where: { status: PublishStatus.Published },
order: { id: 'ASC' },
});
return this.toDtos(rows);
return this.queryScenes(
'published',
async () => {
const rows = await this.scenes.find({
where: { status: PublishStatus.Published },
order: { id: 'ASC' },
});
return this.toDtos(rows);
},
[],
);
}
}