#!/usr/bin/env node import { execSync } from 'child_process' import fs from 'fs' import path from 'path' import { config } from 'dotenv' import postgres from 'postgres' config({ path: path.resolve(process.cwd(), '.env') }) const colors = { reset: '\x1b[0m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', cyan: '\x1b[36m' } const log = (msg, color = 'reset') => console.log(`${colors[color]}${msg}${colors.reset}`) const ok = (msg) => log(`✅ ${msg}`, 'green') const warn = (msg) => log(`⚠️ ${msg}`, 'yellow') const err = (msg) => log(`❌ ${msg}`, 'red') const NON_INTERACTIVE_ENV = { ...process.env, CI: 'true', DRIZZLE_KIT_FORCE: 'true', DRIZZLE_KIT_NON_INTERACTIVE: 'true', NODE_ENV: process.env.NODE_ENV || 'production' } function safeExec(command, options = {}) { try { execSync(command, { stdio: 'inherit', ...options }) return true } catch (e) { return false } } function fileExists(p) { try { return fs.existsSync(p) } catch { return false } } function ensureDrizzleFiles() { if (!fileExists('drizzle.config.ts')) throw new Error('Drizzle 配置文件不存在') if (!fileExists('app/drizzle/schema.ts')) throw new Error('Schema 文件不存在') if (!fileExists('app/drizzle/migrations/meta/_journal.json')) throw new Error('Drizzle journal 文件不存在') } function createSqlClient() { return postgres(process.env.DATABASE_URL, { max: 1 }) } async function isEmptyDatabase(sql) { const result = await sql` SELECT COUNT(*)::int AS count FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' AND table_name <> '__drizzle_migrations__' ` return result[0]?.count === 0 } async function hasMigrationRecords(sql) { const migrationTable = await sql` SELECT to_regclass('public.__drizzle_migrations__') AS table_name ` if (!migrationTable[0]?.table_name) { return false } const result = await sql` SELECT COUNT(*)::int AS count FROM public.__drizzle_migrations__ ` return (result[0]?.count || 0) > 0 } function loadMigrationJournalEntries() { const journalPath = path.resolve(process.cwd(), 'app/drizzle/migrations/meta/_journal.json') const journal = JSON.parse(fs.readFileSync(journalPath, 'utf8')) return [...journal.entries].sort((a, b) => a.when - b.when) } async function seedMissingMigrationRecords(sql) { const entries = loadMigrationJournalEntries() await sql`CREATE TABLE IF NOT EXISTS public.__drizzle_migrations__ ( id SERIAL PRIMARY KEY, hash text NOT NULL, created_at bigint )` for (const entry of entries) { await sql` INSERT INTO public.__drizzle_migrations__ (hash, created_at) SELECT ${`legacy:${entry.tag}`}, ${entry.when} WHERE NOT EXISTS ( SELECT 1 FROM public.__drizzle_migrations__ WHERE created_at = ${entry.when} ) ` } } async function enumExists(sql, enumName) { const result = await sql` SELECT EXISTS ( SELECT 1 FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE n.nspname = 'public' AND t.typname = ${enumName} AND t.typtype = 'e' ) AS exists ` return result[0]?.exists === true } async function enumValueExists(sql, enumName, enumValue) { const result = await sql` SELECT EXISTS ( SELECT 1 FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace JOIN pg_enum e ON e.enumtypid = t.oid WHERE n.nspname = 'public' AND t.typname = ${enumName} AND e.enumlabel = ${enumValue} ) AS exists ` return result[0]?.exists === true } // user_status 枚举新增值显式幂等补齐 async function ensureUserStatusEnumValues(sql) { const pendingExists = await enumValueExists(sql, 'user_status', 'pending') if (!pendingExists) { const withdrawnExists = await enumValueExists(sql, 'user_status', 'withdrawn') if (withdrawnExists) { await sql`ALTER TYPE "public"."user_status" ADD VALUE 'pending' BEFORE 'withdrawn'` } else { await sql`ALTER TYPE "public"."user_status" ADD VALUE 'pending'` } } const rejectedExists = await enumValueExists(sql, 'user_status', 'rejected') if (!rejectedExists) { await sql`ALTER TYPE "public"."user_status" ADD VALUE 'rejected'` } } async function tableExists(sql, tableName) { const result = await sql` SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = ${tableName} ) AS exists ` return result[0]?.exists === true } async function columnExists(sql, tableName, columnName) { const result = await sql` SELECT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ${tableName} AND column_name = ${columnName} ) AS exists ` return result[0]?.exists === true } async function indexExists(sql, tableName, indexName) { const result = await sql` SELECT EXISTS ( SELECT 1 FROM pg_indexes WHERE schemaname = 'public' AND tablename = ${tableName} AND indexname = ${indexName} ) AS exists ` return result[0]?.exists === true } // 重复 username 会阻塞 User_username_unique 唯一索引的创建(push/migrate 均会失败)。 // 同步前把多余行重命名为 username_<后缀>,保留最早一行,保证唯一索引可创建。 async function ensureNoDuplicateUsernames(sql) { if (!(await tableExists(sql, 'User'))) return const duplicates = await sql` SELECT username, array_agg(id ORDER BY id) AS ids FROM "User" WHERE username IS NOT NULL GROUP BY username HAVING COUNT(*) > 1 ` if (duplicates.length === 0) return warn('检测到 User.username 重复值,重命名多余行以允许创建唯一索引') for (const dup of duplicates) { const [keptId, ...extraIds] = dup.ids for (const id of extraIds) { let suffix = 2 let newUsername = `${dup.username}_${suffix}` while ((await sql`SELECT 1 FROM "User" WHERE username = ${newUsername} LIMIT 1`).length > 0) { suffix += 1 newUsername = `${dup.username}_${suffix}` } await sql`UPDATE "User" SET username = ${newUsername} WHERE id = ${id}` warn(`User#${id}: ${dup.username} -> ${newUsername}(保留最早记录 User#${keptId})`) } } } // 检查数据库schema是否包含当前代码依赖的关键对象。 async function checkSchemaConsistency(sql) { const requiredEnums = [ ['user_status', ['graduate', 'pending', 'rejected']], ['card_code_status', ['AVAILABLE', 'LOCKED', 'REDEEMED', 'INVALID']] ] const requiredTables = [ 'api_keys', 'api_key_permissions', 'api_logs', 'BackupHistory', 'CardCode', 'CardCodeRedeemLog', 'PasswordAuditLog', 'PasswordRateLimit', 'GradeClass', 'auth_sessions' ] // 关键唯一索引(legacy 库可能缺失导致并发竞态/迁移失败) const requiredIndexes = [['User', 'User_username_unique']] const requiredColumns = { User: [ 'status', 'statusChangedAt', 'statusChangedBy', 'email', 'emailVerified', 'tokenVersion', 'remark', 'avatarProvider', 'avatarProviderUserId' ], Song: ['playUrl', 'submissionNote', 'submissionNotePublic', 'submissionNotePublicStatus', 'hitRequestId', 'cardCodeId'], song_replay_requests: ['submission_note', 'submission_note_public', 'submission_note_public_status'], user_status_logs: ['username', 'name'], Schedule: ['isDraft', 'publishedAt'], SystemSettings: [ 'instance_id', 'telemetryEnabled', 'smtpEnabled', 'smtpHost', 'smtpPort', 'smtpSecure', 'smtpUsername', 'smtpPassword', 'smtpFromEmail', 'smtpFromName', 'enableRequestTimeLimitation', 'forceBlockAllRequests', 'enableReplayRequests', 'enableCollaborativeSubmission', 'enableSubmissionRemarks', 'enableCardCodeRequests', 'requireCardCodeForRequests', 'enableCardCodeLimitBypass', 'captchaProvider', 'turnstileSiteKey', 'turnstileSecretKey', 'forcePasswordChangeOnFirstLogin', 'allowOAuthRegistration', 'allowRegister', 'registerRequiresApproval', 'oauthRegisterRequiresApproval', 'registerEmailRequired', 'registerRequiresGradeClass', 'submissionNoteRequiresApproval', 'defaultTheme', 'enabledThemes', 'oauthRedirectUri', 'oauthStateSecret', 'oauthProviders', 'githubOAuthEnabled', 'githubClientId', 'githubClientSecret', 'casdoorOAuthEnabled', 'casdoorServerUrl', 'casdoorClientId', 'casdoorClientSecret', 'casdoorOrganizationName', 'googleOAuthEnabled', 'googleClientId', 'googleClientSecret', 'aggregateOAuthEnabled', 'aggregateOAuthAppId', 'aggregateOAuthAppKey', 'aggregateOAuthLoginType', 'aggregateOAuthEndpoint', 'customOAuthEnabled', 'customOAuthDisplayName', 'customOAuthAuthorizeUrl', 'customOAuthTokenUrl', 'customOAuthUserInfoUrl', 'customOAuthScope', 'customOAuthClientId', 'customOAuthClientSecret', 'customOAuthUserIdField', 'customOAuthUsernameField', 'customOAuthNameField', 'customOAuthEmailField', 'customOAuthAvatarField', 'captchaEnabled', 'captchaMaxFailures', 'autoBackupEnabled', 'autoBackupConfig' ], PasswordAuditLog: [ 'userId', 'actorId', 'action', 'success', 'ipAddress', 'userAgent', 'failureReason', 'createdAt' ], PasswordRateLimit: ['key', 'count', 'resetAt'] } const missing = [] for (const [enumName, enumValues] of requiredEnums) { if (!(await enumExists(sql, enumName))) { missing.push(`${enumName} enum type`) continue } for (const enumValue of enumValues) { if (!(await enumValueExists(sql, enumName, enumValue))) { missing.push(`${enumName}.${enumValue} enum value`) } } } for (const tableName of requiredTables) { if (!(await tableExists(sql, tableName))) { missing.push(`${tableName} table`) } } for (const [tableName, columns] of Object.entries(requiredColumns)) { for (const columnName of columns) { if (!(await columnExists(sql, tableName, columnName))) { missing.push(`${tableName}.${columnName} column`) } } } for (const [tableName, indexName] of requiredIndexes) { if (!(await indexExists(sql, tableName, indexName))) { missing.push(`${tableName}.${indexName} index`) } } if (missing.length > 0) { warn(`检测到数据库schema不完整,缺少: ${missing.join(', ')}`) return false } return true } async function repairSchemaWithPush(sql) { // 先补齐枚举值,再执行 push await ensureUserStatusEnumValues(sql) const pushCommand = 'pnpm exec drizzle-kit push --force --config=drizzle.config.ts' if ( !safeExec(pushCommand, { env: { ...NON_INTERACTIVE_ENV, DRIZZLE_KIT_NON_INTERACTIVE: 'true' } }) ) { err('数据库schema修复失败') return false } if (!(await checkSchemaConsistency(sql))) { err('push 后数据库schema仍不完整') return false } // push 只同步结构,不会写入迁移表;补齐记录可避免后续 migrate 重放已存在的结构。 await seedMissingMigrationRecords(sql) ok('强制同步完成,迁移记录已补齐') return true } async function main() { log('🔄 数据库同步', 'cyan') if (!process.env.DATABASE_URL) { warn('未设置 DATABASE_URL') process.exit(0) } ensureDrizzleFiles() const sql = createSqlClient() try { const emptyDb = await isEmptyDatabase(sql) if (emptyDb) { log('🆕 检测到空库,执行迁移 (migrate)...', 'cyan') if (!safeExec('pnpm run db:migrate', { env: NON_INTERACTIVE_ENV })) { err('数据库迁移失败') process.exit(1) } if (!(await checkSchemaConsistency(sql))) { err('空库迁移后数据库schema仍不完整') process.exit(1) } ok('空库迁移完成') } else { // 重复 username 会阻塞唯一索引创建(push/migrate 均失败),先修复数据再同步 await ensureNoDuplicateUsernames(sql) const migrationRecordsExist = await hasMigrationRecords(sql) if (migrationRecordsExist) { // 正常数据库必须先应用待执行迁移,再检查最终结构;否则新增字段会被误判为schema损坏。 log('🔁 检测到迁移记录,先执行 migrate 同步...', 'cyan') const migrateSuccess = safeExec('pnpm run db:migrate', { env: { ...NON_INTERACTIVE_ENV, DRIZZLE_KIT_NON_INTERACTIVE: 'true' } }) const schemaConsistent = migrateSuccess && (await checkSchemaConsistency(sql)) if (migrateSuccess && schemaConsistent) { ok('migrate 同步成功') } else { if (migrateSuccess) { warn('migrate 已执行,但数据库schema仍不完整。') } else { warn('migrate 同步失败,可能是由于数据库结构与迁移记录不一致。') } log('🔄 尝试使用 push --force 进行强制同步...', 'cyan') if (!(await repairSchemaWithPush(sql))) { err('数据库同步完全失败。请检查数据库连接或迁移文件。') process.exit(1) } } } else { warn('检测到 legacy 数据库迁移记录为空,检查schema并写入迁移基线。') const schemaConsistent = await checkSchemaConsistency(sql) if (!schemaConsistent) { log('🔄 legacy schema不完整,尝试使用 push --force 进行同步...', 'cyan') if (!(await repairSchemaWithPush(sql))) { process.exit(1) } } else { await seedMissingMigrationRecords(sql) } ok('legacy schema同步完成,迁移基线记录已写入') } } } finally { await sql.end() } ok('数据库同步流程完成') } main().catch((e) => { err(`同步异常: ${e.message || e}`) process.exit(1) })