Files
opendance-web/web-server/test/scenes-degradation.spec.ts
zijunchiang f3b97fec53 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)。
2026-09-17 03:24:56 +08:00

132 lines
5.0 KiB
TypeScript
Raw Permalink 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.
import { describe, expect, it, vi } from 'vitest';
import { PublishStatus } from '../src/common/enums';
import { CatalogService } from '../src/catalog/catalog.service';
import { ReviewsService } from '../src/reviews/reviews.service';
import { ScenesService } from '../src/scenes/scenes.service';
/**
* 场景子系统必须**可降级**(不是可选项的健壮性,而是生产事故的直接教训)。
*
* 现场:数据库里没有 `scenes` 表(迁移没落到该库 / dist 里缺迁移文件),于是
* - `GET /api/scenes` → 500
* - `GET /api/catalog/export` → 500 ← 客户端**拉不到任何目录**,整站不可用
* - `GET /api/catalog/version` → 500
* - `GET /api/reviews?status=pending` → 500 ← 管理端待审列表也挂了
* 而 `/api/songs`、`/api/models` 都正常。
*
* 契约与文档都明确「**场景是可选资源**」(没有可用场景时客户端回退内置空场景),
* 因此场景表不可用**不能**连带打挂与场景无关的功能。这里守护这条底线:
* 降级为空场景 + 日志 + `/health` 如实暴露,而不是 500。
*/
describe('ScenesService 降级(scenes 表不可用时)', () => {
const boom = () => {
throw new Error("Table 'opendance.scenes' doesn't exist");
};
function buildScenes(repository: unknown) {
const service = new ScenesService(
repository as never,
{ findMany: vi.fn(async () => new Map()), url: () => '' } as never,
{} as never,
);
return service;
}
it('published() 在表缺失时返回空数组而不是抛错', async () => {
const service = buildScenes({ find: vi.fn(async () => boom()) });
await expect(service.published()).resolves.toEqual([]);
});
it('published() 同时记录可用性与原因(供 /health 暴露)', async () => {
const service = buildScenes({ find: vi.fn(async () => boom()) });
expect(service.diagnostics()).toEqual({ available: true, error: null });
await service.published();
const diag = service.diagnostics();
expect(diag.available).toBe(false);
expect(diag.error).toContain("doesn't exist");
});
it('表恢复后自动回到可用状态', async () => {
const find = vi.fn(async () => boom());
const service = buildScenes({ find });
await service.published();
expect(service.diagnostics().available).toBe(false);
// 表建好后:查询成功(空结果),可用性应自动恢复
find.mockImplementation(async () => [] as never);
await expect(service.published()).resolves.toEqual([]);
expect(service.diagnostics()).toEqual({ available: true, error: null });
});
it('错误信息截断到 300 字符(避免把整段 SQL 错误塞进 /health)', async () => {
const service = buildScenes({
find: vi.fn(async () => {
throw new Error('x'.repeat(1000));
}),
});
await service.published();
expect(service.diagnostics().error?.length).toBe(300);
});
});
describe('降级不会波及与场景无关的功能', () => {
it('catalog/export 仍然成功,scenes 为空数组(客户端可继续拉目录)', async () => {
const scenesService = new ScenesService(
{
find: vi.fn(async () => {
throw new Error("Table 'opendance.scenes' doesn't exist");
}),
} as never,
{ findMany: vi.fn(async () => new Map()), url: () => '' } as never,
{} as never,
);
const repository = (rows: unknown[]) => ({ find: vi.fn(async () => rows) });
const service = new CatalogService(
repository([]) as never,
repository([]) as never,
repository([]) as never,
{ toDtos: vi.fn(async (rows: unknown[]) => rows) } as never,
{ toDtos: vi.fn(async (rows: unknown[]) => rows) } as never,
{
toDtos: vi.fn(async (rows: unknown[]) => rows),
resolveDefaultModelId: vi.fn(async () => null),
} as never,
scenesService as never,
{ contractVersions: { 'rest-api': '0.5.0' } } as never,
);
const bundle = await service.exportBundle();
expect(bundle.scenes).toEqual([]);
expect(bundle.contractVersion['rest-api']).toBe('0.5.0');
const version = await service.version();
expect(version.revision).toMatch(/^[0-9a-f]{64}$/);
expect(scenesService.diagnostics().available).toBe(false);
});
it('待审列表跳过场景而不是整体 500', async () => {
const service = new ReviewsService(
{} as never,
{ find: vi.fn(async () => []) } as never, // songs
{ find: vi.fn(async () => []) } as never, // motions
{ find: vi.fn(async () => []) } as never, // models
{
find: vi.fn(async () => {
throw new Error("Table 'opendance.scenes' doesn't exist");
}),
} as never, // scenes
{} as never, // modelsService
);
const page = await service.listByStatus(PublishStatus.Pending, {
skip: 0,
pageSize: 20,
page: 1,
} as never);
expect(page.total).toBe(0);
expect(page.items).toEqual([]);
});
});