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:
@@ -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) {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user