#!/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 /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 /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('');