feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+496
View File
@@ -0,0 +1,496 @@
#!/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)
})