Files
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

217 lines
11 KiB
JavaScript
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.
#!/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('');