|
|
@@ -0,0 +1,93 @@
|
|
|
+/**
|
|
|
+ * 数据库 Schema 自检
|
|
|
+ *
|
|
|
+ * 治根:之前反复出现 'column does not exist' 报错(最近一次持续 2 个月,2026-05 ~ 2026-07)。
|
|
|
+ * 根因是 Prisma client 的 metadata 跟数据库实际 schema 长期漂移,每次都靠手工 prisma generate 救场。
|
|
|
+ *
|
|
|
+ * 启动时强制自检:直接查 INFORMATION_SCHEMA 对比 Prisma 期望的列,不匹配立即拒绝启动。
|
|
|
+ *
|
|
|
+ * 设计取舍:
|
|
|
+ * - 不依赖 prisma db pull(那需要 schema.prisma,dist 里不一定带)
|
|
|
+ * - 不做"全表全列"比对(太重),只校验核心表的关键列(业务必读 + 历史上踩过坑的)
|
|
|
+ * - mismatch 时 hard fail(throw),绝不静默放行
|
|
|
+ * - 校验通过时只输出一行 ✅,不刷屏
|
|
|
+ */
|
|
|
+
|
|
|
+import { prisma } from '../models';
|
|
|
+
|
|
|
+/**
|
|
|
+ * 核心列清单:每项是 [表名, 列名, 类型, 业务含义]
|
|
|
+ *
|
|
|
+ * 添加规则:
|
|
|
+ * - 只列"业务必读 + 历史上因漂移出过 production 事故"的列
|
|
|
+ * - 新表/新列如果出错会立刻被业务请求打到(prisma 抛 P2022),不必进清单
|
|
|
+ * - 类型变更(如 int → bigint)不会自动检测,只校验"是否存在"
|
|
|
+ */
|
|
|
+const CRITICAL_COLUMNS: Array<{ table: string; column: string; note: string }> = [
|
|
|
+ { table: 'BookChapter', column: 'parentId', note: '修复 #prisma-parentId-202605:客户端有但 DB 没有会爆' },
|
|
|
+ { table: 'BookChapter', column: 'audioUrl', note: '章节音频地址,OSS 化迁移后高频读' },
|
|
|
+ { table: 'BookChapter', column: 'genStage', note: '线性阶段机(见 linear-stage-state-model)' },
|
|
|
+ { table: 'BookChapter', column: 'lrcLyrics', note: '歌词时间轴(晚于主表新增列)' },
|
|
|
+ { table: 'Book', column: 'genStage', note: 'Book 阶段机' },
|
|
|
+ { table: 'Book', column: 'failedStage', note: '失败时记录哪一步崩的' },
|
|
|
+ { table: 'Book', column: 'errorMsg', note: '前端轮询会读,缺它会显示空白' },
|
|
|
+ { table: 'TtsTask', column: 'voiceId', note: '音频音色 ID' },
|
|
|
+ { table: 'AudioRecord', column: 'provider', note: 'edge/bailian 标识(最近有脏数据争议)' },
|
|
|
+ { table: 'AudioRecord', column: 'voiceId', note: '同上' },
|
|
|
+];
|
|
|
+
|
|
|
+export interface SchemaCheckResult {
|
|
|
+ ok: boolean;
|
|
|
+ checked: number;
|
|
|
+ missing: Array<{ table: string; column: string; note: string }>;
|
|
|
+ durationMs: number;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 启动时调用。失败抛错(让进程退出),成功返回 result。
|
|
|
+ */
|
|
|
+export async function checkDbSchema(): Promise<SchemaCheckResult> {
|
|
|
+ const t0 = Date.now();
|
|
|
+
|
|
|
+ // 一次性查所有需要的 (TABLE, COLUMN)
|
|
|
+ const rows = await prisma.$queryRawUnsafe<Array<{ TABLE_NAME: string; COLUMN_NAME: string }>>(
|
|
|
+ `SELECT TABLE_NAME, COLUMN_NAME
|
|
|
+ FROM INFORMATION_SCHEMA.COLUMNS
|
|
|
+ WHERE TABLE_SCHEMA = DATABASE()
|
|
|
+ AND (${CRITICAL_COLUMNS.map(() => '(TABLE_NAME = ? AND COLUMN_NAME = ?)').join(' OR ')})`,
|
|
|
+ ...CRITICAL_COLUMNS.flatMap(c => [c.table, c.column])
|
|
|
+ );
|
|
|
+
|
|
|
+ const present = new Set(rows.map(r => `${r.TABLE_NAME}.${r.COLUMN_NAME}`));
|
|
|
+ const missing = CRITICAL_COLUMNS.filter(c => !present.has(`${c.table}.${c.column}`));
|
|
|
+
|
|
|
+ const result: SchemaCheckResult = {
|
|
|
+ ok: missing.length === 0,
|
|
|
+ checked: CRITICAL_COLUMNS.length,
|
|
|
+ missing: missing.map(m => ({ table: m.table, column: m.column, note: m.note })),
|
|
|
+ durationMs: Date.now() - t0,
|
|
|
+ };
|
|
|
+
|
|
|
+ if (!result.ok) {
|
|
|
+ // 用醒目的格式输出,方便运维直接定位
|
|
|
+ console.error('');
|
|
|
+ console.error('╔══════════════════════════════════════════════════════════════╗');
|
|
|
+ console.error('║ 🚨 DB Schema 自检失败 — 服务拒绝启动 ║');
|
|
|
+ console.error('╚══════════════════════════════════════════════════════════════╝');
|
|
|
+ console.error(`[DbSchemaCheck] 缺 ${missing.length} 个核心列(共校验 ${CRITICAL_COLUMNS.length}):`);
|
|
|
+ for (const m of missing) {
|
|
|
+ console.error(` ❌ ${m.table}.${m.column} (${m.note})`);
|
|
|
+ }
|
|
|
+ console.error('');
|
|
|
+ console.error('修复步骤(在 server/ 目录下执行):');
|
|
|
+ console.error(' 1. npx prisma db push --accept-data-loss (把 schema 推到 DB)');
|
|
|
+ console.error(' 2. pm2 restart server (让新 client 生效)');
|
|
|
+ console.error('');
|
|
|
+ console.error('如果 db push 报错说 "column already exists",说明 DB 比 schema 多列,');
|
|
|
+ console.error('需要手工 ALTER 或同步 schema.prisma 后再 push。');
|
|
|
+ } else {
|
|
|
+ console.log(`✅ [DbSchemaCheck] ${result.checked} 个核心列全部存在(${result.durationMs}ms)`);
|
|
|
+ }
|
|
|
+
|
|
|
+ return result;
|
|
|
+}
|