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
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env node
/**
* 场景表(scenes)诊断 —— 只读,默认不改任何数据。
*
* 背景:`/api/scenes`、`/api/catalog/export`、`/api/catalog/version`、
* `/api/reviews?status=pending` 全部返回 500,而 `/api/songs`、`/api/models`
* 正常 —— 说明**凡是查 scenes 表就失败**。本脚本用来把原因定位到具体一格:
*
* 1. 运行期到底连的是哪个库(host / database / user);
* 2. `scenes` 表是否存在;
* 3. `typeorm_migrations` 里记录了哪些迁移(AddScenes 在不在);
* 4. 当前代码**加载**了哪些迁移(如果 AddScenes 没被加载,
* `showMigrations()` 会返回"没有待执行迁移",于是 `/health` 的
* `migrationsPending` 显示 0,但表其实不存在 —— 这是最隐蔽的一种情况);
* 5. 若表存在:实际列 vs 实体期望列,逐列 diff(缺列/多列/类型不符)。
*
* 用法(在安装目录的 web-server/ 下,用与服务端相同的环境变量):
*
* cd <APP_DIR>/web-server
* set -a; . ../deploy/.env; set +a # 与服务端同一份 .env
* node scripts/diagnose-scenes.mjs # 只读诊断
* node scripts/diagnose-scenes.mjs --fix # 仅在缺表时建表(幂等,写到库)
*
* 注意:`--fix` 只做一件事 —— 用迁移里**完全相同**的 DDL
* `CREATE TABLE IF NOT EXISTS scenes (...)` 补表;不删不改任何现有数据。
*/
import { createRequire } from 'node:module';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const serverDir = resolve(here, '..');
const require = createRequire(join(serverDir, 'package.json'));
const FIX = process.argv.includes('--fix');
function loadCompiled(relative) {
const path = join(serverDir, 'dist', relative);
try {
return require(path);
} catch (error) {
console.error(`✗ 无法加载编译产物 ${path}:${error.message}`);
console.error(' 请确认在安装目录的 web-server/ 下执行,且已 npm run build(发布包内含 dist/)。');
process.exit(1);
}
}
const dataSourceModule = loadCompiled('db/data-source.js');
const buildOptions = dataSourceModule.buildDataSourceOptions ?? dataSourceModule.default?.options;
if (typeof buildOptions !== 'function') {
console.error('✗ dist/db/data-source.js 未导出 buildDataSourceOptions(),无法按运行期同样的规则解析配置');
process.exit(1);
}
const options = buildOptions(process.env);
const mysql = require('mysql2/promise');
console.log('\n== OpenDance 场景表诊断 ==');
console.log(` 模式 : ${FIX ? '诊断 + 缺表时补表(--fix)' : '只读'}`);
console.log(` 连接目标 : ${options.username}@${options.host}:${options.port}/${options.database}`);
console.log(` DB_NAME 来源 : ${process.env.DB_NAME ? 'process.env(systemd EnvironmentFile / 手工 export)' : '.env 文件(dotenv)'}`);
console.log(` 工作目录 : ${process.cwd()}`);
console.log(` 编译产物目录 : ${join(serverDir, 'dist')}`);
// ------------------------------------------------------------------ 迁移加载情况
const { DataSource } = require('typeorm');
const dataSource = new DataSource(options);
let loadedMigrations = [];
try {
await dataSource.initialize();
loadedMigrations = (dataSource.migrations ?? []).map((m) => ({
name: m.name ?? m.constructor?.name ?? '(匿名)',
timestamp: m.timestamp ?? null,
}));
console.log(`\n 已加载迁移 : ${loadedMigrations.length} 个`);
for (const m of loadedMigrations) console.log(` [loaded] ${m.timestamp ?? '?'} ${m.name}`);
if (!loadedMigrations.some((m) => m.name.includes('AddScenes'))) {
console.log(' !! 未加载到 AddScenes 迁移 —— 说明 dist/migrations 里缺这个文件,');
console.log(' 或 data-source 的 migrations glob 没匹配到它。');
console.log(' (此时 /health 的 migrationsPending 会显示 0,但表并不存在)');
}
} catch (error) {
console.error(`\n✗ 连接数据库失败:${error.message}`);
console.error(' 请确认已 `set -a; . ../deploy/.env; set +a`(与服务端同一份环境变量)。');
process.exit(1);
}
// ------------------------------------------------------------------ 只读查询
const conn = await mysql.createConnection({
host: options.host,
port: options.port,
user: options.username,
password: options.password,
database: options.database,
charset: 'utf8mb4',
});
const [tableRows] = await conn.query('SHOW TABLES');
const tables = tableRows.map((row) => Object.values(row)[0]);
const hasScenes = tables.includes('scenes');
const hasMigrationsTable = tables.includes('typeorm_migrations');
console.log(`\n 库内表数量 : ${tables.length}`);
console.log(` scenes 表存在 : ${hasScenes ? '是' : '**否**'}`);
console.log(` typeorm_migrations 存在 : ${hasMigrationsTable ? '是' : '否'}`);
let executed = [];
if (hasMigrationsTable) {
const [rows] = await conn.query('SELECT id, timestamp, name FROM typeorm_migrations ORDER BY timestamp');
executed = rows;
console.log(`\n 已记录迁移 : ${rows.length} 个`);
for (const r of rows) console.log(` [done] ${r.timestamp} ${r.name}`);
const scenesRecorded = rows.some((r) => String(r.name).includes('AddScenes'));
console.log(` AddScenes 已记录: ${scenesRecorded ? '是' : '**否**'}`);
}
const pending = loadedMigrations.filter((m) => !executed.some((e) => e.name === m.name));
console.log(`\n 待执行迁移 : ${pending.length} 个`);
for (const m of pending) console.log(` [pending] ${m.timestamp ?? '?'} ${m.name}`);
// ------------------------------------------------------------------ 列 diff
let actualColumns = [];
if (hasScenes) {
const [cols] = await conn.query('SHOW COLUMNS FROM scenes');
actualColumns = cols.map((c) => ({ name: c.Field, type: String(c.Type), nullable: c.Null === 'YES' }));
console.log(`\n scenes 实际列 : ${actualColumns.length} 个`);
const sceneMetadata = dataSource.entityMetadatas.find((m) => m.tableName === 'scenes');
if (sceneMetadata) {
const expected = sceneMetadata.columns.map((c) => ({
name: c.databaseName,
type: c.type,
nullable: c.isNullable,
}));
console.log(` 实体期望列 : ${expected.length} 个`);
const missing = expected.filter((e) => !actualColumns.some((a) => a.name === e.name));
const extra = actualColumns.filter((a) => !expected.some((e) => e.name === a.name));
const typeMismatch = expected
.map((e) => ({ e, a: actualColumns.find((a) => a.name === e.name) }))
.filter(({ e, a }) => a && String(a.type).toLowerCase() !== String(e.type).toLowerCase());
console.log(` ✗ 库中缺列 : ${missing.length ? missing.map((m) => `${m.name}(${m.type})`).join(', ') : '无'}`);
console.log(` ✗ 库中多列 : ${extra.length ? extra.map((m) => `${m.name}(${m.type})`).join(', ') : '无'}`);
console.log(` ! 类型不一致 : ${typeMismatch.length ? typeMismatch.map(({ e, a }) => `${e.name}: 实体=${e.type} 库=${a.type}`).join('; ') : '无'}`);
} else {
console.log(' ! 实体元数据里找不到 scenes(entities 未包含 Scene?)');
}
}
// ------------------------------------------------------------------ 结论 / 修复
console.log('\n== 结论 ==');
if (!hasScenes) {
const scenesLoaded = loadedMigrations.some((m) => m.name.includes('AddScenes'));
const scenesRecorded = executed.some((r) => String(r.name).includes('AddScenes'));
if (scenesLoaded && !scenesRecorded) {
console.log(' scenes 表缺失,且 AddScenes 迁移被正确加载但**尚未执行**。');
console.log(' → 直接执行迁移即可:');
console.log(' cd <APP_DIR>/web-server && set -a && . ../deploy/.env && set +a && node scripts/migrate.mjs run');
} else if (scenesLoaded && scenesRecorded) {
console.log(' scenes 表缺失,但迁移已被记录为"已执行" —— 说明该迁移当初没有真正建表');
console.log(' (或跑向了另一个库:注意 deploy/.env 与 web-server/.env 的 DB_NAME 是否一致)。');
console.log(' → 用 `--fix` 幂等补表:node scripts/diagnose-scenes.mjs --fix');
} else if (!scenesLoaded) {
console.log(' scenes 表缺失,且**当前代码没有加载 AddScenes 迁移**。');
console.log(' → 检查 dist/migrations/1789650000000-AddScenes.js 是否存在;');
console.log(' 若不存在,说明部署的 dist 不是最新构建,请用最新发布包重跑 deploy/install.sh。');
console.log(' → 若文件存在却未加载,请把上方「已加载迁移」清单发回。');
}
} else {
const sceneMetadata = dataSource.entityMetadatas.find((m) => m.tableName === 'scenes');
const expected = sceneMetadata ? sceneMetadata.columns.map((c) => c.databaseName) : [];
const missing = expected.filter((e) => !actualColumns.some((a) => a.name === e));
if (missing.length > 0) {
console.log(` scenes 表存在但**缺少列**:${missing.join(', ')} —— 这就是 SELECT/INSERT 500 的原因。`);
console.log(' → 需要补列(手工 ALTER 或按最新发布包重跑迁移),请把本输出发回以给出精确 SQL。');
} else {
console.log(' scenes 表存在且列与实体一致 —— 那 500 的原因不在这张表上,请把服务端日志发回:');
console.log(' journalctl -u opendance-api -n 80 --no-pager');
}
}
if (FIX && !hasScenes) {
console.log('\n== 执行 --fix:用迁移中的同一份 DDL 幂等补表 ==');
await conn.query(`
CREATE TABLE IF NOT EXISTS \`scenes\` (
\`id\` bigint NOT NULL AUTO_INCREMENT,
\`name\` varchar(200) NOT NULL,
\`glb_asset_id\` bigint NULL,
\`thumbnail_asset_id\` bigint NULL,
\`config\` mediumtext NULL,
\`stats\` mediumtext NULL,
\`file_size\` bigint NULL,
\`file_sha256\` varchar(64) NULL,
\`conversion_note\` varchar(500) NULL,
\`source_type\` varchar(32) NOT NULL,
\`source_name\` varchar(200) NOT NULL,
\`source_url\` varchar(500) NOT NULL,
\`license\` varchar(100) NOT NULL,
\`author\` varchar(200) NOT NULL,
\`status\` varchar(16) NOT NULL DEFAULT 'pending',
\`created_at\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
\`updated_at\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
\`deleted_at\` datetime NULL,
INDEX \`idx_scenes_status\` (\`status\`),
PRIMARY KEY (\`id\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
const [after] = await conn.query('SHOW TABLES LIKE \'scenes\'');
console.log(after.length === 1 ? ' ✓ scenes 表已创建' : ' ✗ 建表后仍未出现,请把本输出发回');
console.log(' → 若这次是「迁移已记录但表缺失」,为确保一致性,建议再执行一次:');
console.log(' node scripts/migrate.mjs run');
}
await conn.end();
await dataSource.destroy();
console.log('');