feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'child_process'
|
||||
import { config } from 'dotenv'
|
||||
import path from 'path'
|
||||
|
||||
config({ path: path.resolve(process.cwd(), '.env'), quiet: true })
|
||||
|
||||
const BUILD_MEMORY_MB = 6144
|
||||
const DEFAULT_NODE_OPTIONS = `--max-old-space-size=${BUILD_MEMORY_MB}`
|
||||
const HIDDEN_VALUE = '[已隐藏]'
|
||||
const SUPPORTED_AGGREGATE_LOGIN_TYPES = [
|
||||
'qq',
|
||||
'wx',
|
||||
'alipay',
|
||||
'sina',
|
||||
'baidu',
|
||||
'douyin',
|
||||
'huawei',
|
||||
'xiaomi',
|
||||
'gitee',
|
||||
'gitea',
|
||||
'bilibili',
|
||||
'kuaishou'
|
||||
]
|
||||
const SUPPORTED_ENV_VARIABLES = [
|
||||
'NODE_ENV',
|
||||
'NODE_OPTIONS',
|
||||
'NITRO_PRESET',
|
||||
'VERCEL',
|
||||
'VERCEL_ENV',
|
||||
'NETLIFY',
|
||||
'PREBUILT',
|
||||
'SKIP_INSTALL',
|
||||
'SKIP_BUILD',
|
||||
'CI',
|
||||
'DATABASE_URL',
|
||||
'JWT_SECRET',
|
||||
'REDIS_URL',
|
||||
'DEBUG_SQL',
|
||||
'WEBAUTHN_RP_ID',
|
||||
'WEBAUTHN_ORIGIN',
|
||||
'GITHUB_CLIENT_ID',
|
||||
'GITHUB_CLIENT_SECRET',
|
||||
'CASDOOR_CLIENT_ID',
|
||||
'CASDOOR_CLIENT_SECRET',
|
||||
'CASDOOR_ENDPOINT',
|
||||
'CASDOOR_ORGANIZATION_NAME',
|
||||
'GOOGLE_CLIENT_ID',
|
||||
'GOOGLE_CLIENT_SECRET',
|
||||
'AGGREGATE_OAUTH_APP_ID',
|
||||
'AGGREGATE_OAUTH_APP_KEY',
|
||||
'AGGREGATE_OAUTH_LOGIN_TYPE',
|
||||
'AGGREGATE_OAUTH_ENDPOINT',
|
||||
'OAUTH_REDIRECT_URI',
|
||||
'OAUTH_STATE_SECRET',
|
||||
'NUXT_PUBLIC_HOST',
|
||||
'NUXT_PUBLIC_SITE_TITLE',
|
||||
'NUXT_PUBLIC_SITE_DESCRIPTION',
|
||||
'NUXT_PUBLIC_SITE_LOGO',
|
||||
'NUXT_PUBLIC_SEO_CONFIG',
|
||||
'SENTRY_DSN',
|
||||
'SENTRY_ENVIRONMENT',
|
||||
'SENTRY_RELEASE',
|
||||
'SENTRY_TRACES_SAMPLE_RATE',
|
||||
'NUXT_PUBLIC_SENTRY_DSN',
|
||||
'NUXT_PUBLIC_SENTRY_ENVIRONMENT',
|
||||
'NUXT_PUBLIC_SENTRY_RELEASE',
|
||||
'NUXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE',
|
||||
'NUXT_PUBLIC_SENTRY_REPLAYS_SESSION_SAMPLE_RATE',
|
||||
'NUXT_PUBLIC_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE',
|
||||
'VERCEL_GIT_COMMIT_SHA',
|
||||
'COMMIT_REF'
|
||||
]
|
||||
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
cyan: '\x1b[36m',
|
||||
dim: '\x1b[2m'
|
||||
}
|
||||
|
||||
function log(message, color = 'reset') {
|
||||
console.log(`${colors[color]}${message}${colors.reset}`)
|
||||
}
|
||||
|
||||
function isProvided(value) {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
}
|
||||
|
||||
function normalizeBlankEnvironment() {
|
||||
for (const name of SUPPORTED_ENV_VARIABLES) {
|
||||
const value = process.env[name]
|
||||
if (typeof value === 'string' && value.length > 0 && value.trim().length === 0) {
|
||||
delete process.env[name]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function providedState(value) {
|
||||
return isProvided(value) ? '已配置' : '未配置'
|
||||
}
|
||||
|
||||
function strictBoolean(value) {
|
||||
return value === 'true'
|
||||
}
|
||||
|
||||
function numberInUnitInterval(value, fallback) {
|
||||
if (!isProvided(value)) return { value: fallback, source: '默认值' }
|
||||
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
|
||||
return { value: fallback, source: `无效值 ${JSON.stringify(value)},已回退` }
|
||||
}
|
||||
|
||||
return { value: parsed, source: `来自 ${JSON.stringify(value)}` }
|
||||
}
|
||||
|
||||
function displayValue(value, fallback = '未配置') {
|
||||
return isProvided(value) ? JSON.stringify(value) : fallback
|
||||
}
|
||||
|
||||
function hiddenValue(value) {
|
||||
return isProvided(value) ? HIDDEN_VALUE : '未配置'
|
||||
}
|
||||
|
||||
function resolveAggregateLoginTypeState(value) {
|
||||
if (!isProvided(value)) return '使用 qq(默认值)'
|
||||
let values
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
values = Array.isArray(parsed) ? parsed : [parsed]
|
||||
} catch {
|
||||
values = value.split(',')
|
||||
}
|
||||
const normalized = [
|
||||
...new Set(
|
||||
values
|
||||
.filter((item) => typeof item === 'string')
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter((item) => SUPPORTED_AGGREGATE_LOGIN_TYPES.includes(item))
|
||||
)
|
||||
]
|
||||
return normalized.length > 0
|
||||
? '配置有效(具体值已隐藏)'
|
||||
: '未识别到受支持的登录方式,导入时回退到 qq'
|
||||
}
|
||||
|
||||
function printItem(name, rawState, result) {
|
||||
log(` ${name}: ${rawState} -> ${result}`)
|
||||
}
|
||||
|
||||
function printSensitiveItem(name, value, result) {
|
||||
printItem(name, hiddenValue(value), result ?? providedState(value))
|
||||
}
|
||||
|
||||
function resolveNitroPreset() {
|
||||
if (process.env.VERCEL) return 'vercel(由 VERCEL 检测)'
|
||||
if (process.env.NETLIFY) return 'netlify(由 NETLIFY 检测)'
|
||||
return `${process.env.NITRO_PRESET || 'node-server'}${process.env.NITRO_PRESET ? '' : '(默认值)'}`
|
||||
}
|
||||
|
||||
function resolveNodeOptions(value) {
|
||||
const options = isProvided(value) ? value.trim() : ''
|
||||
const hasMemoryLimit = /(?:^|\s)--max[-_]old[-_]space[-_]size(?:=|\s|$)/.test(options)
|
||||
|
||||
if (hasMemoryLimit) return options
|
||||
return options ? `${options} ${DEFAULT_NODE_OPTIONS}` : DEFAULT_NODE_OPTIONS
|
||||
}
|
||||
|
||||
function resolveSeoConfig() {
|
||||
if (!isProvided(process.env.NUXT_PUBLIC_SEO_CONFIG)) {
|
||||
return { state: '未配置', result: '使用分项配置或默认值' }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(process.env.NUXT_PUBLIC_SEO_CONFIG)
|
||||
const fields = ['title', 'shortName', 'description', 'logo'].filter((field) =>
|
||||
isProvided(parsed?.[field])
|
||||
)
|
||||
return {
|
||||
state: '已配置',
|
||||
result: fields.length > 0 ? `解析成功:${fields.join(', ')}` : '解析成功,但未提供有效字段'
|
||||
}
|
||||
} catch {
|
||||
return { state: '已配置', result: '解析失败,将回退到分项配置或默认值' }
|
||||
}
|
||||
}
|
||||
|
||||
function printBuildEnvironment(rawNodeOptions) {
|
||||
const backendTraces = numberInUnitInterval(process.env.SENTRY_TRACES_SAMPLE_RATE, 0.1)
|
||||
const frontendTraces = numberInUnitInterval(
|
||||
process.env.NUXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE || process.env.SENTRY_TRACES_SAMPLE_RATE,
|
||||
0.1
|
||||
)
|
||||
const sessionReplay = numberInUnitInterval(
|
||||
process.env.NUXT_PUBLIC_SENTRY_REPLAYS_SESSION_SAMPLE_RATE,
|
||||
0
|
||||
)
|
||||
const errorReplay = numberInUnitInterval(
|
||||
process.env.NUXT_PUBLIC_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE,
|
||||
0
|
||||
)
|
||||
const production = process.env.NODE_ENV === 'production'
|
||||
const seoConfig = resolveSeoConfig()
|
||||
|
||||
log('\n🔎 构建环境变量诊断', 'cyan')
|
||||
log('仅列出 VoiceHub 支持的变量;敏感值始终隐藏。', 'dim')
|
||||
|
||||
log('\n构建与部署:', 'cyan')
|
||||
printItem('NODE_ENV', displayValue(process.env.NODE_ENV), process.env.NODE_ENV || '由 Nuxt 决定')
|
||||
printItem('NODE_OPTIONS', displayValue(rawNodeOptions), process.env.NODE_OPTIONS)
|
||||
printItem('NITRO_PRESET', displayValue(process.env.NITRO_PRESET), resolveNitroPreset())
|
||||
printItem('VERCEL', displayValue(process.env.VERCEL), process.env.VERCEL ? '已检测' : '未检测')
|
||||
printItem('VERCEL_ENV', displayValue(process.env.VERCEL_ENV), process.env.VERCEL_ENV || '未检测')
|
||||
printItem('NETLIFY', displayValue(process.env.NETLIFY), process.env.NETLIFY ? '已检测' : '未检测')
|
||||
printItem(
|
||||
'PREBUILT',
|
||||
displayValue(process.env.PREBUILT),
|
||||
strictBoolean(process.env.PREBUILT) ? '启用' : '停用'
|
||||
)
|
||||
printItem(
|
||||
'SKIP_INSTALL',
|
||||
displayValue(process.env.SKIP_INSTALL),
|
||||
strictBoolean(process.env.SKIP_INSTALL) ? '启用' : '停用'
|
||||
)
|
||||
printItem(
|
||||
'SKIP_BUILD',
|
||||
displayValue(process.env.SKIP_BUILD),
|
||||
strictBoolean(process.env.SKIP_BUILD) ? '启用' : '停用'
|
||||
)
|
||||
printItem('CI', displayValue(process.env.CI), process.env.CI ? 'CI 环境' : '非 CI 环境')
|
||||
|
||||
log('\n服务能力:', 'cyan')
|
||||
printSensitiveItem('DATABASE_URL', process.env.DATABASE_URL)
|
||||
printSensitiveItem('JWT_SECRET', process.env.JWT_SECRET)
|
||||
printSensitiveItem(
|
||||
'REDIS_URL',
|
||||
process.env.REDIS_URL,
|
||||
isProvided(process.env.REDIS_URL) ? 'Redis 启用' : 'Redis 停用'
|
||||
)
|
||||
printItem(
|
||||
'DEBUG_SQL',
|
||||
displayValue(process.env.DEBUG_SQL),
|
||||
strictBoolean(process.env.DEBUG_SQL) ? '启用' : '停用'
|
||||
)
|
||||
printItem(
|
||||
'WEBAUTHN_RP_ID',
|
||||
displayValue(process.env.WEBAUTHN_RP_ID),
|
||||
providedState(process.env.WEBAUTHN_RP_ID)
|
||||
)
|
||||
printItem(
|
||||
'WEBAUTHN_ORIGIN',
|
||||
displayValue(process.env.WEBAUTHN_ORIGIN),
|
||||
providedState(process.env.WEBAUTHN_ORIGIN)
|
||||
)
|
||||
|
||||
log('\nOAuth 环境变量(仅用于后台导入):', 'cyan')
|
||||
printItem(
|
||||
'GitHub CLIENT_ID',
|
||||
hiddenValue(process.env.GITHUB_CLIENT_ID),
|
||||
isProvided(process.env.GITHUB_CLIENT_ID) ? 'OAuth 启用' : 'OAuth 停用'
|
||||
)
|
||||
printSensitiveItem('GitHub CLIENT_SECRET', process.env.GITHUB_CLIENT_SECRET)
|
||||
printItem(
|
||||
'Casdoor CLIENT_ID',
|
||||
hiddenValue(process.env.CASDOOR_CLIENT_ID),
|
||||
isProvided(process.env.CASDOOR_CLIENT_ID) ? 'OAuth 启用' : 'OAuth 停用'
|
||||
)
|
||||
printSensitiveItem('Casdoor CLIENT_SECRET', process.env.CASDOOR_CLIENT_SECRET)
|
||||
printItem(
|
||||
'Google CLIENT_ID',
|
||||
hiddenValue(process.env.GOOGLE_CLIENT_ID),
|
||||
isProvided(process.env.GOOGLE_CLIENT_ID) ? 'OAuth 启用' : 'OAuth 停用'
|
||||
)
|
||||
printSensitiveItem('Google CLIENT_SECRET', process.env.GOOGLE_CLIENT_SECRET)
|
||||
printItem(
|
||||
'聚合登陆 APP_ID',
|
||||
hiddenValue(process.env.AGGREGATE_OAUTH_APP_ID),
|
||||
isProvided(process.env.AGGREGATE_OAUTH_APP_ID) ? 'OAuth 启用' : 'OAuth 停用'
|
||||
)
|
||||
printSensitiveItem('聚合登陆 APP_KEY', process.env.AGGREGATE_OAUTH_APP_KEY)
|
||||
printSensitiveItem(
|
||||
'AGGREGATE_OAUTH_LOGIN_TYPE',
|
||||
process.env.AGGREGATE_OAUTH_LOGIN_TYPE,
|
||||
resolveAggregateLoginTypeState(process.env.AGGREGATE_OAUTH_LOGIN_TYPE)
|
||||
)
|
||||
printSensitiveItem(
|
||||
'AGGREGATE_OAUTH_ENDPOINT',
|
||||
process.env.AGGREGATE_OAUTH_ENDPOINT,
|
||||
isProvided(process.env.AGGREGATE_OAUTH_ENDPOINT)
|
||||
? '使用自定义接口地址(值已隐藏)'
|
||||
: '使用默认接口地址'
|
||||
)
|
||||
printItem(
|
||||
'CASDOOR_ENDPOINT',
|
||||
displayValue(process.env.CASDOOR_ENDPOINT),
|
||||
providedState(process.env.CASDOOR_ENDPOINT)
|
||||
)
|
||||
printItem(
|
||||
'CASDOOR_ORGANIZATION_NAME',
|
||||
displayValue(process.env.CASDOOR_ORGANIZATION_NAME),
|
||||
process.env.CASDOOR_ORGANIZATION_NAME || 'built-in(默认值)'
|
||||
)
|
||||
printSensitiveItem('OAUTH_REDIRECT_URI', process.env.OAUTH_REDIRECT_URI)
|
||||
printSensitiveItem('OAUTH_STATE_SECRET', process.env.OAUTH_STATE_SECRET)
|
||||
|
||||
log('\n站点公开配置:', 'cyan')
|
||||
printItem(
|
||||
'NUXT_PUBLIC_HOST',
|
||||
displayValue(process.env.NUXT_PUBLIC_HOST),
|
||||
providedState(process.env.NUXT_PUBLIC_HOST)
|
||||
)
|
||||
printItem(
|
||||
'NUXT_PUBLIC_SITE_TITLE',
|
||||
displayValue(process.env.NUXT_PUBLIC_SITE_TITLE),
|
||||
providedState(process.env.NUXT_PUBLIC_SITE_TITLE)
|
||||
)
|
||||
printItem(
|
||||
'NUXT_PUBLIC_SITE_DESCRIPTION',
|
||||
displayValue(process.env.NUXT_PUBLIC_SITE_DESCRIPTION),
|
||||
providedState(process.env.NUXT_PUBLIC_SITE_DESCRIPTION)
|
||||
)
|
||||
printItem(
|
||||
'NUXT_PUBLIC_SITE_LOGO',
|
||||
displayValue(process.env.NUXT_PUBLIC_SITE_LOGO),
|
||||
providedState(process.env.NUXT_PUBLIC_SITE_LOGO)
|
||||
)
|
||||
printItem('NUXT_PUBLIC_SEO_CONFIG', seoConfig.state, seoConfig.result)
|
||||
|
||||
log('\nSentry:', 'cyan')
|
||||
printItem(
|
||||
'运行状态',
|
||||
displayValue(process.env.NODE_ENV),
|
||||
production ? '启用(仍受后台遥测开关控制)' : '停用'
|
||||
)
|
||||
printSensitiveItem(
|
||||
'SENTRY_DSN',
|
||||
process.env.SENTRY_DSN,
|
||||
isProvided(process.env.SENTRY_DSN) ? '使用自定义后端 DSN' : '使用内置后端 DSN'
|
||||
)
|
||||
printSensitiveItem(
|
||||
'NUXT_PUBLIC_SENTRY_DSN',
|
||||
process.env.NUXT_PUBLIC_SENTRY_DSN,
|
||||
isProvided(process.env.NUXT_PUBLIC_SENTRY_DSN) ? '使用自定义前端 DSN' : '使用内置前端 DSN'
|
||||
)
|
||||
printItem('SENTRY_TRACES_SAMPLE_RATE', backendTraces.source, backendTraces.value)
|
||||
printItem('NUXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE', frontendTraces.source, frontendTraces.value)
|
||||
printItem(
|
||||
'NUXT_PUBLIC_SENTRY_REPLAYS_SESSION_SAMPLE_RATE',
|
||||
sessionReplay.source,
|
||||
sessionReplay.value
|
||||
)
|
||||
printItem(
|
||||
'NUXT_PUBLIC_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE',
|
||||
errorReplay.source,
|
||||
errorReplay.value
|
||||
)
|
||||
printItem(
|
||||
'SENTRY_ENVIRONMENT',
|
||||
displayValue(process.env.SENTRY_ENVIRONMENT),
|
||||
process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || 'development'
|
||||
)
|
||||
printItem(
|
||||
'NUXT_PUBLIC_SENTRY_ENVIRONMENT',
|
||||
displayValue(process.env.NUXT_PUBLIC_SENTRY_ENVIRONMENT),
|
||||
process.env.NUXT_PUBLIC_SENTRY_ENVIRONMENT ||
|
||||
process.env.SENTRY_ENVIRONMENT ||
|
||||
process.env.NODE_ENV ||
|
||||
'development'
|
||||
)
|
||||
printItem(
|
||||
'SENTRY_RELEASE',
|
||||
providedState(process.env.SENTRY_RELEASE),
|
||||
process.env.SENTRY_RELEASE ||
|
||||
process.env.VERCEL_GIT_COMMIT_SHA ||
|
||||
process.env.COMMIT_REF ||
|
||||
'未设置'
|
||||
)
|
||||
printItem(
|
||||
'NUXT_PUBLIC_SENTRY_RELEASE',
|
||||
providedState(process.env.NUXT_PUBLIC_SENTRY_RELEASE),
|
||||
process.env.NUXT_PUBLIC_SENTRY_RELEASE ||
|
||||
process.env.SENTRY_RELEASE ||
|
||||
process.env.VERCEL_GIT_COMMIT_SHA ||
|
||||
process.env.COMMIT_REF ||
|
||||
'未设置'
|
||||
)
|
||||
log('')
|
||||
}
|
||||
|
||||
function runNuxtBuild() {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const finish = (success) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(success)
|
||||
}
|
||||
let child
|
||||
try {
|
||||
child = spawn(process.execPath, ['./node_modules/nuxt/bin/nuxt.mjs', 'build'], {
|
||||
stdio: 'inherit',
|
||||
env: process.env
|
||||
})
|
||||
} catch {
|
||||
finish(false)
|
||||
return
|
||||
}
|
||||
const forwardSignal = (signal) => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill(signal)
|
||||
}
|
||||
const onSigterm = () => forwardSignal('SIGTERM')
|
||||
const onSigint = () => forwardSignal('SIGINT')
|
||||
const cleanup = () => {
|
||||
process.off('SIGTERM', onSigterm)
|
||||
process.off('SIGINT', onSigint)
|
||||
}
|
||||
|
||||
process.once('SIGTERM', onSigterm)
|
||||
process.once('SIGINT', onSigint)
|
||||
child.once('error', () => {
|
||||
cleanup()
|
||||
finish(false)
|
||||
})
|
||||
child.once('exit', (code) => {
|
||||
cleanup()
|
||||
finish(code === 0)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function build() {
|
||||
const rawNodeOptions = process.env.NODE_OPTIONS
|
||||
normalizeBlankEnvironment()
|
||||
process.env.NODE_OPTIONS = resolveNodeOptions(rawNodeOptions)
|
||||
printBuildEnvironment(rawNodeOptions)
|
||||
|
||||
if (process.argv.includes('--diagnostics-only')) return
|
||||
|
||||
log('🔨 开始执行 Nuxt 构建...', 'cyan')
|
||||
if (!(await runNuxtBuild())) throw new Error('Nuxt 构建失败')
|
||||
log('✅ Nuxt 构建完成', 'green')
|
||||
}
|
||||
|
||||
build().catch((error) => {
|
||||
log(`❌ ${error.message}`, 'yellow')
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
// 颜色输出函数
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m'
|
||||
}
|
||||
|
||||
function log(message, color = 'reset') {
|
||||
console.log(`${colors[color]}${message}${colors.reset}`)
|
||||
}
|
||||
|
||||
function logSuccess(message) {
|
||||
log(`✅ ${message}`, 'green')
|
||||
}
|
||||
|
||||
function logWarning(message) {
|
||||
log(`⚠️ ${message}`, 'yellow')
|
||||
}
|
||||
|
||||
function logError(message) {
|
||||
log(`❌ ${message}`, 'red')
|
||||
}
|
||||
|
||||
function logInfo(message) {
|
||||
log(`ℹ️ ${message}`, 'blue')
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
function fileExists(filePath) {
|
||||
try {
|
||||
return fs.existsSync(filePath)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 检查目录大小
|
||||
function getDirectorySize(dirPath) {
|
||||
try {
|
||||
const stats = fs.statSync(dirPath)
|
||||
if (stats.isFile()) {
|
||||
return stats.size
|
||||
} else if (stats.isDirectory()) {
|
||||
let totalSize = 0
|
||||
const files = fs.readdirSync(dirPath)
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dirPath, file)
|
||||
totalSize += getDirectorySize(filePath)
|
||||
}
|
||||
return totalSize
|
||||
}
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// 检查 Drizzle 配置
|
||||
function checkDrizzleConfig() {
|
||||
log('🔍 检查 Drizzle 配置...', 'cyan')
|
||||
|
||||
const drizzleConfigPath = 'drizzle.config.ts'
|
||||
const drizzleSchemaPath = 'app/drizzle/schema.ts'
|
||||
|
||||
if (!fileExists(drizzleConfigPath)) {
|
||||
logError('Drizzle 配置文件不存在')
|
||||
return false
|
||||
}
|
||||
|
||||
if (!fileExists(drizzleSchemaPath)) {
|
||||
logError('Drizzle schema 文件不存在')
|
||||
return false
|
||||
}
|
||||
|
||||
logSuccess('Drizzle 配置检查通过')
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查环境变量
|
||||
function checkEnvironmentVariables() {
|
||||
log('🔍 检查环境变量...', 'cyan')
|
||||
|
||||
const requiredVars = ['DATABASE_URL', 'JWT_SECRET']
|
||||
|
||||
const optionalVars = ['NODE_ENV']
|
||||
|
||||
let allRequired = true
|
||||
|
||||
// 检查必需的环境变量
|
||||
requiredVars.forEach((varName) => {
|
||||
if (process.env[varName]) {
|
||||
logSuccess(`${varName} 已设置`)
|
||||
} else {
|
||||
logError(`缺少必需的环境变量: ${varName}`)
|
||||
allRequired = false
|
||||
}
|
||||
})
|
||||
|
||||
// 检查可选的环境变量
|
||||
optionalVars.forEach((varName) => {
|
||||
if (process.env[varName]) {
|
||||
logInfo(`${varName} 已设置`)
|
||||
} else {
|
||||
logWarning(`可选环境变量未设置: ${varName}`)
|
||||
}
|
||||
})
|
||||
|
||||
return allRequired
|
||||
}
|
||||
|
||||
// 检查数据库连接
|
||||
async function checkDatabaseConnection() {
|
||||
log('🔍 检查数据库连接...', 'cyan')
|
||||
|
||||
try {
|
||||
// 检查数据库连接文件是否存在
|
||||
const dbPath = 'app/drizzle/db.ts'
|
||||
if (!fileExists(dbPath)) {
|
||||
logError('数据库连接文件不存在')
|
||||
return false
|
||||
}
|
||||
|
||||
logSuccess('数据库配置检查通过')
|
||||
return true
|
||||
} catch (error) {
|
||||
logWarning('数据库连接测试失败(这在某些部署环境中是正常的)')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 检查关键文件
|
||||
function checkCriticalFiles() {
|
||||
log('🔍 检查关键文件...', 'cyan')
|
||||
|
||||
const criticalFiles = [
|
||||
'nuxt.config.ts',
|
||||
'drizzle.config.ts',
|
||||
'app/drizzle/schema.ts',
|
||||
'package.json'
|
||||
]
|
||||
|
||||
let allExists = true
|
||||
|
||||
criticalFiles.forEach((file) => {
|
||||
if (fileExists(file)) {
|
||||
logSuccess(`${file} 存在`)
|
||||
} else {
|
||||
logError(`关键文件缺失: ${file}`)
|
||||
allExists = false
|
||||
}
|
||||
})
|
||||
|
||||
return allExists
|
||||
}
|
||||
|
||||
// 生成部署报告
|
||||
function generateDeploymentReport(checks) {
|
||||
log('\n📊 部署检查报告', 'bright')
|
||||
log('='.repeat(50), 'cyan')
|
||||
|
||||
const passed = checks.filter((check) => check.passed).length
|
||||
const total = checks.length
|
||||
|
||||
checks.forEach((check) => {
|
||||
const status = check.passed ? '✅' : '❌'
|
||||
const color = check.passed ? 'green' : 'red'
|
||||
log(`${status} ${check.name}`, color)
|
||||
if (check.details) {
|
||||
log(` ${check.details}`, 'reset')
|
||||
}
|
||||
})
|
||||
|
||||
log(`\n总计: ${passed}/${total} 项检查通过`, passed === total ? 'green' : 'yellow')
|
||||
|
||||
if (passed === total) {
|
||||
log('🎉 所有检查都通过了!部署应该可以正常工作。', 'green')
|
||||
} else {
|
||||
log('⚠️ 部分检查未通过,可能会影响应用运行。', 'yellow')
|
||||
}
|
||||
}
|
||||
|
||||
// 主检查函数
|
||||
async function checkDeployment() {
|
||||
log('🔍 开始部署后检查...', 'bright')
|
||||
|
||||
const checks = [
|
||||
{
|
||||
name: '关键文件检查',
|
||||
passed: checkCriticalFiles(),
|
||||
details: null
|
||||
},
|
||||
{
|
||||
name: 'Drizzle 配置检查',
|
||||
passed: checkDrizzleConfig(),
|
||||
details: null
|
||||
},
|
||||
{
|
||||
name: '环境变量检查',
|
||||
passed: checkEnvironmentVariables(),
|
||||
details: null
|
||||
}
|
||||
]
|
||||
|
||||
// 数据库连接检查(异步)
|
||||
const dbConnected = await checkDatabaseConnection()
|
||||
checks.push({
|
||||
name: '数据库连接检查',
|
||||
passed: dbConnected,
|
||||
details: dbConnected ? null : '在某些部署环境中这是正常的'
|
||||
})
|
||||
|
||||
generateDeploymentReport(checks)
|
||||
|
||||
const criticalFailed = checks.filter(
|
||||
(check) => !check.passed && !check.name.includes('数据库连接')
|
||||
).length
|
||||
|
||||
if (criticalFailed > 0) {
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 运行检查
|
||||
checkDeployment().catch((error) => {
|
||||
logError(`检查过程中发生错误: ${error.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* 数据库清空脚本
|
||||
* 这个脚本会清空所有表的数据,但保留表结构
|
||||
*/
|
||||
import { sql } from 'drizzle-orm'
|
||||
import {
|
||||
apiKeyPermissions,
|
||||
apiKeys,
|
||||
apiLogs,
|
||||
cardCodeRedeemLogs,
|
||||
cardCodes,
|
||||
collaborationLogs,
|
||||
db,
|
||||
emailTemplates,
|
||||
notifications,
|
||||
notificationSettings,
|
||||
passwordAuditLogs,
|
||||
passwordRateLimits,
|
||||
playTimes,
|
||||
requestTimes,
|
||||
scheduleSongPool,
|
||||
schedules,
|
||||
semesters,
|
||||
songBlacklists,
|
||||
songCollaborators,
|
||||
songReplayRequests,
|
||||
songs,
|
||||
systemSettings,
|
||||
userIdentities,
|
||||
users,
|
||||
userStatusLogs,
|
||||
votes
|
||||
} from '../app/drizzle/db.ts'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { closeConnection } from '../app/drizzle/db.ts'
|
||||
|
||||
function getFirstRow(result) {
|
||||
return result?.rows?.[0] ?? result?.[0]
|
||||
}
|
||||
|
||||
function quoteIdentifierPart(value) {
|
||||
const normalized =
|
||||
value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1).replace(/""/g, '"') : value
|
||||
|
||||
return `"${normalized.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
function toQualifiedIdentifier(value) {
|
||||
return sql.raw(value.split('.').map(quoteIdentifierPart).join('.'))
|
||||
}
|
||||
|
||||
function toPgRelationName(value) {
|
||||
return value.split('.').map(quoteIdentifierPart).join('.')
|
||||
}
|
||||
|
||||
function toRegclass(value) {
|
||||
return sql.raw(`'${value.replace(/'/g, "''")}'::regclass`)
|
||||
}
|
||||
|
||||
// 重置所有表的自增序列
|
||||
async function resetAutoIncrementSequences() {
|
||||
const tables = [
|
||||
'User',
|
||||
'Song',
|
||||
'Vote',
|
||||
'Schedule',
|
||||
'Notification',
|
||||
'NotificationSettings',
|
||||
'PlayTime',
|
||||
'Semester',
|
||||
'SystemSettings',
|
||||
'SongBlacklist',
|
||||
'CardCode',
|
||||
'CardCodeRedeemLog',
|
||||
'SongCollaborator',
|
||||
'CollaborationLog',
|
||||
'SongReplayRequest',
|
||||
'RequestTime',
|
||||
'ScheduleSongPool',
|
||||
'UserIdentity',
|
||||
'PasswordAuditLog',
|
||||
'PasswordRateLimit',
|
||||
'EmailTemplate',
|
||||
'user_status_logs'
|
||||
]
|
||||
|
||||
console.log('重置自增序列...')
|
||||
|
||||
for (const table of tables) {
|
||||
try {
|
||||
const maxIdResult = await db.execute(
|
||||
sql`SELECT MAX(id) as max_id FROM ${toQualifiedIdentifier(table)}`
|
||||
)
|
||||
const maxId = Number(getFirstRow(maxIdResult)?.max_id || 0)
|
||||
|
||||
const sequenceNameResult = await db.execute(
|
||||
sql`SELECT pg_get_serial_sequence(${toPgRelationName(table)}, 'id') as sequence_name`
|
||||
)
|
||||
const sequenceName = getFirstRow(sequenceNameResult)?.sequence_name
|
||||
|
||||
if (sequenceName) {
|
||||
if (maxId === 0) {
|
||||
await db.execute(sql`ALTER SEQUENCE ${toQualifiedIdentifier(sequenceName)} RESTART WITH 1`)
|
||||
} else {
|
||||
const newSequenceValue = maxId
|
||||
await db.execute(sql`SELECT setval(${toRegclass(sequenceName)}, ${newSequenceValue})`)
|
||||
}
|
||||
} else {
|
||||
console.warn(`未找到表 ${table} 的序列`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`重置 ${table} 表序列失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('开始清空数据库...')
|
||||
|
||||
// 清空所有表数据
|
||||
try {
|
||||
// 按照关联关系顺序删除数据
|
||||
await db.delete(notifications)
|
||||
await db.delete(notificationSettings)
|
||||
await db.delete(votes)
|
||||
await db.delete(collaborationLogs)
|
||||
await db.delete(schedules)
|
||||
await db.delete(scheduleSongPool)
|
||||
await db.delete(songCollaborators)
|
||||
await db.delete(songReplayRequests)
|
||||
await db.delete(requestTimes)
|
||||
await db.delete(cardCodeRedeemLogs)
|
||||
await db.delete(userIdentities)
|
||||
await db.delete(passwordAuditLogs)
|
||||
await db.delete(passwordRateLimits)
|
||||
await db.delete(songs)
|
||||
await db.delete(cardCodes)
|
||||
await db.delete(playTimes)
|
||||
await db.delete(semesters)
|
||||
await db.delete(systemSettings)
|
||||
await db.delete(songBlacklists)
|
||||
await db.delete(apiLogs)
|
||||
await db.delete(apiKeyPermissions)
|
||||
await db.delete(apiKeys)
|
||||
await db.delete(userStatusLogs)
|
||||
await db.delete(emailTemplates)
|
||||
await db.delete(users)
|
||||
|
||||
console.log('数据库已清空,开始重置自增序列...')
|
||||
|
||||
// 先重置所有自增序列
|
||||
await resetAutoIncrementSequences()
|
||||
|
||||
console.log('开始创建默认超级管理员账户...')
|
||||
|
||||
// 创建默认管理员账户
|
||||
const hashedPassword = await bcrypt.hash('admin123', 10)
|
||||
|
||||
// 创建超级管理员用户
|
||||
const [admin] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
name: '超级管理员',
|
||||
username: 'admin',
|
||||
password: hashedPassword,
|
||||
role: 'SUPER_ADMIN',
|
||||
forcePasswordChange: false
|
||||
})
|
||||
.returning()
|
||||
|
||||
console.log('默认超级管理员账户已创建:')
|
||||
console.log('用户名: admin')
|
||||
console.log('密码: admin123')
|
||||
console.log(`管理员ID: ${admin.id}`)
|
||||
|
||||
// 调整User表的自增序列,确保下一个用户从admin.id + 1开始
|
||||
try {
|
||||
const sequenceNameResult = await db.execute(
|
||||
sql`SELECT pg_get_serial_sequence(${toPgRelationName('User')}, 'id') as sequence_name`
|
||||
)
|
||||
const sequenceName = getFirstRow(sequenceNameResult)?.sequence_name
|
||||
|
||||
if (sequenceName) {
|
||||
const nextId = admin.id
|
||||
await db.execute(sql`SELECT setval(${toRegclass(sequenceName)}, ${nextId})`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`调整User表序列失败: ${error.message}`)
|
||||
}
|
||||
|
||||
console.log('🎉 数据库清空、初始化和序列重置全部完成!')
|
||||
} catch (error) {
|
||||
console.error('清空数据库时出错:', error)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
await closeConnection()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { config } from 'dotenv'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { client, db, notificationSettings, systemSettings, users } from '../app/drizzle/db.ts'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
config({ path: path.resolve(process.cwd(), '.env') })
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error('❌ DATABASE_URL 环境变量未设置')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
// 检查是否已有超级管理员
|
||||
const existingSuperAdmin = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.role, 'SUPER_ADMIN'))
|
||||
.limit(1)
|
||||
|
||||
if (existingSuperAdmin.length > 0) {
|
||||
console.log('✅ 管理员已存在')
|
||||
return existingSuperAdmin[0]
|
||||
}
|
||||
|
||||
// 没有超级管理员但 username=admin 已被其他角色占用时跳过创建,避免产生重复 username
|
||||
const existingAdminName = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, 'admin'))
|
||||
.limit(1)
|
||||
|
||||
if (existingAdminName.length > 0) {
|
||||
console.warn(
|
||||
`⚠️ username=admin 已被用户#${existingAdminName[0].id}(role=${existingAdminName[0].role})占用,跳过创建管理员`
|
||||
)
|
||||
return existingAdminName[0]
|
||||
}
|
||||
|
||||
// 创建管理员
|
||||
const hashedPassword = await bcrypt.hash('admin123', 10)
|
||||
const [admin] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
username: 'admin',
|
||||
name: '超级管理员',
|
||||
password: hashedPassword,
|
||||
role: 'SUPER_ADMIN',
|
||||
forcePasswordChange: false
|
||||
})
|
||||
.returning()
|
||||
|
||||
// 创建通知设置
|
||||
await db
|
||||
.insert(notificationSettings)
|
||||
.values({
|
||||
userId: admin.id,
|
||||
enabled: true,
|
||||
songRequestEnabled: true,
|
||||
songVotedEnabled: true,
|
||||
songPlayedEnabled: true,
|
||||
refreshInterval: 60,
|
||||
songVotedThreshold: 1
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
|
||||
// 创建系统设置
|
||||
await db
|
||||
.insert(systemSettings)
|
||||
.values({
|
||||
id: 1,
|
||||
enablePlayTimeSelection: false
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
|
||||
console.log('✅ 管理员创建成功 (admin/admin123)')
|
||||
return admin
|
||||
} catch (error) {
|
||||
console.error('❌ 创建管理员失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await client.end()
|
||||
process.exit(0)
|
||||
})
|
||||
.catch(async (error) => {
|
||||
console.error('❌ 初始化失败:', error)
|
||||
await client.end()
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execSync, spawn } from 'child_process'
|
||||
import fs from 'fs'
|
||||
import { config } from 'dotenv'
|
||||
import path from 'path'
|
||||
|
||||
// 加载环境变量(从项目根目录)
|
||||
config({ path: path.resolve(process.cwd(), '.env') })
|
||||
|
||||
// 如果设置了 PREBUILT=true,则自动跳过安装和构建
|
||||
if (process.env.PREBUILT === 'true') {
|
||||
process.env.SKIP_INSTALL = 'true'
|
||||
process.env.SKIP_BUILD = 'true'
|
||||
}
|
||||
|
||||
// Vercel 会在 buildCommand 前执行 installCommand,避免部署时重复安装全部依赖。
|
||||
if (process.env.VERCEL) {
|
||||
process.env.SKIP_INSTALL = 'true'
|
||||
}
|
||||
|
||||
// 颜色输出函数
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
cyan: '\x1b[36m'
|
||||
}
|
||||
|
||||
function log(message, color = 'reset') {
|
||||
console.log(`${colors[color]}${message}${colors.reset}`)
|
||||
}
|
||||
|
||||
function logStep(step, message) {
|
||||
log(`${step} ${message}`, 'cyan')
|
||||
}
|
||||
|
||||
function logSuccess(message) {
|
||||
log(`✅ ${message}`, 'green')
|
||||
}
|
||||
|
||||
function logWarning(message) {
|
||||
log(`⚠️ ${message}`, 'yellow')
|
||||
}
|
||||
|
||||
function logError(message) {
|
||||
log(`❌ ${message}`, 'red')
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
function fileExists(filePath) {
|
||||
try {
|
||||
return fs.existsSync(filePath)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 安全执行命令
|
||||
function safeExec(command, options = {}) {
|
||||
try {
|
||||
execSync(command, { stdio: 'inherit', ...options })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function execAsync(command, args = [], options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const { signal, ...spawnOptions } = options
|
||||
let abortHandler
|
||||
let settled = false
|
||||
const finish = (success) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (signal && abortHandler) signal.removeEventListener('abort', abortHandler)
|
||||
resolve(success)
|
||||
}
|
||||
let child
|
||||
try {
|
||||
child = spawn(command, args, {
|
||||
stdio: 'inherit',
|
||||
detached: Boolean(signal) && process.platform !== 'win32',
|
||||
...spawnOptions
|
||||
})
|
||||
} catch {
|
||||
finish(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
abortHandler = () => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return
|
||||
if (process.platform === 'win32') {
|
||||
const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
killer.once('error', () => child.kill('SIGTERM'))
|
||||
killer.once('exit', (code) => {
|
||||
if (code !== 0 && child.exitCode === null) child.kill('SIGTERM')
|
||||
})
|
||||
} else {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM')
|
||||
} catch {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
}
|
||||
}
|
||||
signal.addEventListener('abort', abortHandler, { once: true })
|
||||
if (signal.aborted) abortHandler()
|
||||
}
|
||||
|
||||
child.once('error', () => finish(false))
|
||||
child.once('exit', (code) => finish(code === 0))
|
||||
})
|
||||
}
|
||||
|
||||
function settleTask(task) {
|
||||
return task.then(
|
||||
() => ({ success: true }),
|
||||
(error) => ({ success: false, error })
|
||||
)
|
||||
}
|
||||
|
||||
async function syncDatabase() {
|
||||
logStep('🗄️', '同步数据库...')
|
||||
if (!process.env.DATABASE_URL) {
|
||||
logWarning('未设置 DATABASE_URL,跳过数据库迁移')
|
||||
return
|
||||
}
|
||||
|
||||
const nonInteractiveEnv = {
|
||||
...process.env,
|
||||
DRIZZLE_KIT_FORCE: 'true',
|
||||
CI: 'true',
|
||||
NODE_ENV: 'production'
|
||||
}
|
||||
if (!(await execAsync(process.execPath, ['scripts/db-sync.js'], { env: nonInteractiveEnv }))) {
|
||||
throw new Error('数据库同步失败,已终止部署以避免运行时schema不一致')
|
||||
}
|
||||
logSuccess('数据库同步成功')
|
||||
|
||||
if (fileExists('scripts/create-admin.js')) {
|
||||
logStep('👤', '检查管理员账户...')
|
||||
if (
|
||||
!(await execAsync('pnpm run create-admin', [], {
|
||||
env: nonInteractiveEnv,
|
||||
shell: true
|
||||
}))
|
||||
) {
|
||||
throw new Error('创建或检查管理员账户失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function buildApplication(signal) {
|
||||
if (process.env.SKIP_BUILD === 'true') {
|
||||
logStep('🔨', '跳过应用构建 (SKIP_BUILD=true)...')
|
||||
return
|
||||
}
|
||||
|
||||
logStep('🔨', '构建应用...')
|
||||
if (!(await execAsync(process.execPath, ['scripts/build.js'], { env: process.env, signal }))) {
|
||||
throw new Error('应用构建失败')
|
||||
}
|
||||
logSuccess('应用构建完成')
|
||||
}
|
||||
|
||||
// 检查环境变量
|
||||
function checkEnvironment() {
|
||||
logStep('🔍', '检查环境配置...')
|
||||
|
||||
const requiredEnvVars = ['DATABASE_URL', 'JWT_SECRET']
|
||||
const missingVars = []
|
||||
|
||||
requiredEnvVars.forEach((varName) => {
|
||||
if (!process.env[varName]) {
|
||||
missingVars.push(varName)
|
||||
}
|
||||
})
|
||||
|
||||
if (missingVars.length > 0) {
|
||||
logError(`缺少必需的环境变量: ${missingVars.join(', ')}`)
|
||||
logError('请通过以下方式之一设置环境变量:')
|
||||
logError('1. 创建 .env 文件并配置 DATABASE_URL、JWT_SECRET')
|
||||
logError('2. 使用 docker run -e DATABASE_URL=xxx -e JWT_SECRET=xxx 传递环境变量')
|
||||
logError('3. 在 docker-compose.yml 中配置 environment')
|
||||
throw new Error('环境变量配置不完整')
|
||||
} else {
|
||||
logSuccess('环境变量检查通过')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 主部署流程
|
||||
async function deploy() {
|
||||
log('🚀 开始部署...', 'cyan')
|
||||
|
||||
try {
|
||||
// 0. 检查环境(必须通过)
|
||||
checkEnvironment()
|
||||
|
||||
// 1. 安装依赖
|
||||
if (process.env.SKIP_INSTALL === 'true') {
|
||||
const reason = process.env.VERCEL ? 'Vercel 已完成依赖安装' : 'SKIP_INSTALL=true'
|
||||
logStep('📦', `跳过依赖安装 (${reason})...`)
|
||||
} else {
|
||||
logStep('📦', '安装依赖...')
|
||||
let installed = false
|
||||
|
||||
// 优先尝试 pnpm install --frozen-lockfile
|
||||
if (fileExists('pnpm-lock.yaml')) {
|
||||
log('尝试使用 pnpm install --frozen-lockfile 安装...', 'cyan')
|
||||
if (safeExec('pnpm install --frozen-lockfile')) {
|
||||
installed = true
|
||||
logSuccess('依赖安装完成 (pnpm install --frozen-lockfile)')
|
||||
} else {
|
||||
logWarning('pnpm install --frozen-lockfile 安装失败,准备回退到 pnpm install...')
|
||||
}
|
||||
} else {
|
||||
logWarning('未检测到 pnpm-lock.yaml,跳过冻结锁文件安装,直接使用 pnpm install...')
|
||||
}
|
||||
|
||||
// 如果冻结锁文件安装没运行或失败,使用 pnpm install
|
||||
if (!installed) {
|
||||
log('正在使用 pnpm install 安装...', 'cyan')
|
||||
if (!safeExec('pnpm install')) {
|
||||
throw new Error('依赖安装失败')
|
||||
}
|
||||
logSuccess('依赖安装完成 (pnpm install)')
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查 Drizzle 配置
|
||||
if (
|
||||
!fileExists('drizzle.config.ts') ||
|
||||
!fileExists('app/drizzle/schema.ts') ||
|
||||
!fileExists('app/drizzle/db.ts')
|
||||
) {
|
||||
throw new Error('Drizzle 配置文件不完整')
|
||||
}
|
||||
|
||||
// 2.1. 确保迁移目录存在
|
||||
if (!fileExists('app/drizzle/migrations')) {
|
||||
fs.mkdirSync('app/drizzle/migrations', { recursive: true })
|
||||
}
|
||||
|
||||
// 数据库操作主要等待网络,与 CPU 密集的 Nuxt 构建并行可缩短 serverless 部署时间。
|
||||
const buildController = new AbortController()
|
||||
const databaseTask = syncDatabase().catch((error) => {
|
||||
buildController.abort()
|
||||
throw error
|
||||
})
|
||||
|
||||
// 数据库失败时主动取消构建;构建失败时等待迁移安全结束,避免中断数据库事务。
|
||||
const [databaseResult, buildResult] = await Promise.all([
|
||||
settleTask(databaseTask),
|
||||
settleTask(buildApplication(buildController.signal))
|
||||
])
|
||||
if (!databaseResult.success) throw databaseResult.error
|
||||
if (!buildResult.success) throw buildResult.error
|
||||
|
||||
log('🎉 部署完成!', 'green')
|
||||
} catch (error) {
|
||||
logError(`部署失败: ${error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 运行部署
|
||||
deploy().catch((error) => {
|
||||
logError(`未预期的错误: ${error.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
{ "version": "7", "dialect": "postgresql", "entries": [] }
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'child_process'
|
||||
import fs from 'fs'
|
||||
|
||||
// 颜色输出
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
cyan: '\x1b[36m'
|
||||
}
|
||||
|
||||
function log(message, color = 'reset') {
|
||||
console.log(`${colors[color]}${message}${colors.reset}`)
|
||||
}
|
||||
|
||||
function logStep(step, message) {
|
||||
log(`${step} ${message}`, 'cyan')
|
||||
}
|
||||
|
||||
function logSuccess(message) {
|
||||
log(`✅ ${message}`, 'green')
|
||||
}
|
||||
|
||||
function logWarning(message) {
|
||||
log(`⚠️ ${message}`, 'yellow')
|
||||
}
|
||||
|
||||
function logError(message) {
|
||||
log(`❌ ${message}`, 'red')
|
||||
}
|
||||
|
||||
function execAsync(command, args = [], options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const { signal, ...spawnOptions } = options
|
||||
let abortHandler
|
||||
let settled = false
|
||||
const finish = (success) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (signal && abortHandler) signal.removeEventListener('abort', abortHandler)
|
||||
resolve(success)
|
||||
}
|
||||
let child
|
||||
try {
|
||||
child = spawn(command, args, {
|
||||
stdio: 'inherit',
|
||||
detached: Boolean(signal) && process.platform !== 'win32',
|
||||
...spawnOptions
|
||||
})
|
||||
} catch {
|
||||
finish(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
abortHandler = () => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return
|
||||
if (process.platform === 'win32') {
|
||||
const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
killer.once('error', () => child.kill('SIGTERM'))
|
||||
killer.once('exit', (code) => {
|
||||
if (code !== 0 && child.exitCode === null) child.kill('SIGTERM')
|
||||
})
|
||||
} else {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM')
|
||||
} catch {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
}
|
||||
}
|
||||
signal.addEventListener('abort', abortHandler, { once: true })
|
||||
if (signal.aborted) abortHandler()
|
||||
}
|
||||
|
||||
child.once('error', () => finish(false))
|
||||
child.once('exit', (code) => finish(code === 0))
|
||||
})
|
||||
}
|
||||
|
||||
function settleTask(task) {
|
||||
return task.then(
|
||||
() => ({ success: true }),
|
||||
(error) => ({ success: false, error })
|
||||
)
|
||||
}
|
||||
|
||||
async function syncDatabase() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
logWarning('未设置 DATABASE_URL')
|
||||
return
|
||||
}
|
||||
|
||||
logStep('🗄️', '同步数据库...')
|
||||
const env = { ...process.env, CI: 'true', DRIZZLE_KIT_FORCE: 'true', NODE_ENV: 'production' }
|
||||
if (!(await execAsync(process.execPath, ['scripts/db-sync.js'], { env }))) {
|
||||
throw new Error('数据库同步失败,已终止构建以避免运行时schema不一致')
|
||||
}
|
||||
logSuccess('数据库同步成功')
|
||||
|
||||
if (fileExists('scripts/create-admin.js')) {
|
||||
logStep('👤', '检查管理员账户...')
|
||||
if (!(await execAsync('pnpm run create-admin', [], { env, shell: true }))) {
|
||||
throw new Error('创建或检查管理员账户失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function buildApplication(signal) {
|
||||
logStep('🔨', '构建应用...')
|
||||
if (!(await execAsync(process.execPath, ['scripts/build.js'], { env: process.env, signal }))) {
|
||||
throw new Error('构建失败')
|
||||
}
|
||||
logSuccess('构建完成')
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
function fileExists(filePath) {
|
||||
try {
|
||||
return fs.existsSync(filePath)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Netlify 构建流程
|
||||
async function netlifyBuild() {
|
||||
log('🚀 Netlify 构建', 'cyan')
|
||||
|
||||
try {
|
||||
// 1. 设置环境变量
|
||||
process.env.NETLIFY = 'true'
|
||||
process.env.NITRO_PRESET = 'netlify'
|
||||
|
||||
// Netlify 会在 build.command 前完成依赖安装,保留平台缓存可显著缩短后续部署。
|
||||
logStep('📦', '使用 Netlify 已安装的依赖和构建缓存...')
|
||||
|
||||
// 2. 检查 Drizzle 配置
|
||||
if (!fileExists('drizzle.config.ts') || !fileExists('app/drizzle/schema.ts')) {
|
||||
throw new Error('Drizzle 配置文件不完整')
|
||||
}
|
||||
|
||||
// 3. 确保迁移目录存在
|
||||
if (!fileExists('app/drizzle/migrations')) {
|
||||
fs.mkdirSync('app/drizzle/migrations', { recursive: true })
|
||||
}
|
||||
|
||||
// 数据库操作主要等待网络,与 CPU 密集的 Nuxt 构建并行可缩短 serverless 部署时间。
|
||||
const buildController = new AbortController()
|
||||
const databaseTask = syncDatabase().catch((error) => {
|
||||
buildController.abort()
|
||||
throw error
|
||||
})
|
||||
|
||||
// 数据库失败时主动取消构建;构建失败时等待迁移安全结束,避免中断数据库事务。
|
||||
const [databaseResult, buildResult] = await Promise.all([
|
||||
settleTask(databaseTask),
|
||||
settleTask(buildApplication(buildController.signal))
|
||||
])
|
||||
if (!databaseResult.success) throw databaseResult.error
|
||||
if (!buildResult.success) throw buildResult.error
|
||||
|
||||
// 5. 验证构建输出
|
||||
const hasNetlifyFunctions = fileExists('.netlify/functions-internal/server')
|
||||
const hasOutputPublic = fileExists('.output/public')
|
||||
|
||||
if (hasNetlifyFunctions) {
|
||||
logSuccess('Netlify Functions 生成成功')
|
||||
}
|
||||
|
||||
if (hasOutputPublic) {
|
||||
logSuccess('静态资源生成成功')
|
||||
}
|
||||
|
||||
if (!hasNetlifyFunctions && !hasOutputPublic) {
|
||||
throw new Error('构建输出目录不存在')
|
||||
}
|
||||
|
||||
log('🎉 构建完成!', 'green')
|
||||
} catch (error) {
|
||||
logError(`构建失败: ${error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 运行构建
|
||||
netlifyBuild().catch((error) => {
|
||||
logError(`未预期的错误: ${error.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "voicehub-scripts",
|
||||
"version": "1.0.0",
|
||||
"description": "VoiceHub管理脚本",
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"create-admin": "pnpm exec tsx create-admin.js",
|
||||
"clear-db": "pnpm exec tsx clear-database.js",
|
||||
"safe-migrate": "pnpm exec tsx safe-migrate.js",
|
||||
"db:generate": "cd .. && pnpm exec drizzle-kit generate --config=drizzle.config.ts",
|
||||
"db:migrate": "cd .. && pnpm exec drizzle-kit migrate --config=drizzle.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"chalk": "^5.3.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"prompts": "^2.4.2",
|
||||
"drizzle-orm": "^0.36.4",
|
||||
"postgres": "^3.4.5",
|
||||
"tsx": "^4.7.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'fs'
|
||||
import { execSync } from 'child_process'
|
||||
|
||||
const isCI = process.env.CI || process.env.NETLIFY || process.env.VERCEL
|
||||
|
||||
if (isCI) {
|
||||
console.log('🔧 CI 环境检测,跳过将在正式构建中重复执行的 nuxt prepare')
|
||||
|
||||
try {
|
||||
if (fs.existsSync('drizzle.config.ts') && fs.existsSync('app/drizzle/schema.ts')) {
|
||||
console.log('✅ Drizzle 配置正常')
|
||||
} else {
|
||||
console.warn('⚠️ Drizzle 配置不完整')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 配置检查失败:', error.message)
|
||||
}
|
||||
} else {
|
||||
console.log('📝 本地开发环境,准备 Nuxt 类型和配置')
|
||||
execSync('nuxt prepare', { stdio: 'inherit' })
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'dotenv/config'
|
||||
import { createClient } from 'redis'
|
||||
|
||||
const APPLY = process.argv.includes('--apply')
|
||||
const CONFIRMED = process.env.REDIS_LEGACY_CLEANUP_CONFIRM === 'VOICEHUB'
|
||||
const DELETE_BATCH_SIZE = 200
|
||||
|
||||
const LEGACY_PATTERNS = [
|
||||
'songs:list:*',
|
||||
'songs:count:*',
|
||||
'voicehub:songs:*',
|
||||
'voicehub:song_count:*',
|
||||
'voicehub:user_votes:*',
|
||||
'voicehub:schedules:*',
|
||||
'voicehub:schedule_date:*',
|
||||
'voicehub:system:*',
|
||||
'voicehub:playtimes:*',
|
||||
'public_schedules:*',
|
||||
'open:*',
|
||||
'sys:*',
|
||||
'user:*',
|
||||
'schedule:*',
|
||||
'song:*',
|
||||
'stats:*',
|
||||
'auth:*',
|
||||
'vote:*',
|
||||
'system:*',
|
||||
'playtimes:*',
|
||||
'admin_stats:*',
|
||||
'realtime_stats',
|
||||
'active_users:*'
|
||||
]
|
||||
|
||||
if (!process.env.REDIS_URL) {
|
||||
console.error('未配置 REDIS_URL,无法扫描 Redis')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (APPLY && !CONFIRMED) {
|
||||
console.error('执行清理前必须设置 REDIS_LEGACY_CLEANUP_CONFIRM=VOICEHUB')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const client = createClient({ url: process.env.REDIS_URL })
|
||||
client.on('error', (error) => console.error('[Redis] 客户端错误:', error.message))
|
||||
|
||||
try {
|
||||
await client.connect()
|
||||
const seen = new Set()
|
||||
|
||||
for (const pattern of LEGACY_PATTERNS) {
|
||||
const keys = []
|
||||
for await (const batch of client.scanIterator({ MATCH: pattern, COUNT: 200 })) {
|
||||
for (const key of batch) {
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
keys.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
if (keys.length === 0) {
|
||||
console.log(`[Redis Legacy] ${pattern}: 0`)
|
||||
continue
|
||||
}
|
||||
|
||||
const samples = []
|
||||
for (const key of keys.slice(0, 5)) {
|
||||
samples.push({ key, ttl: await client.ttl(key) })
|
||||
}
|
||||
console.log(`[Redis Legacy] ${pattern}: ${keys.length}`, samples)
|
||||
|
||||
if (APPLY) {
|
||||
for (let index = 0; index < keys.length; index += DELETE_BATCH_SIZE) {
|
||||
await client.del(keys.slice(index, index + DELETE_BATCH_SIZE))
|
||||
}
|
||||
console.log(`[Redis Legacy] 已删除 ${keys.length} 个键`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(APPLY ? '旧 Redis 键清理完成' : 'dry-run 完成,未删除任何键')
|
||||
} finally {
|
||||
await client.quit().catch(() => {})
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 数据库完全重置脚本
|
||||
* 删除所有表(含迁移记录表),然后从头执行迁移
|
||||
* 使用方式: pnpm run db:reset
|
||||
*/
|
||||
import { execSync } from 'child_process'
|
||||
import path from 'path'
|
||||
import { config } from 'dotenv'
|
||||
import postgres from 'postgres'
|
||||
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
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 args = process.argv.slice(2)
|
||||
const forceFlag = args.includes('--force')
|
||||
|
||||
// 禁止在 CI 中执行(防止误触发导致生产库被清空)
|
||||
if (process.env.CI && !forceFlag) {
|
||||
err('拒绝在 CI 环境中执行数据库重置;如确需强制,请使用 --force 标志并确认 CI 配置正确')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!forceFlag) {
|
||||
err('危险操作:此脚本将清空整库数据')
|
||||
err('请添加 --force 标志确认执行:pnpm run db:reset -- --force')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
config({ path: path.resolve(process.cwd(), '.env') })
|
||||
|
||||
const NON_INTERACTIVE_ENV = {
|
||||
...process.env,
|
||||
CI: 'true',
|
||||
DRIZZLE_KIT_FORCE: 'true',
|
||||
NODE_ENV: process.env.NODE_ENV || 'production'
|
||||
}
|
||||
|
||||
function safeExec(command, options = {}) {
|
||||
try {
|
||||
execSync(command, { stdio: 'inherit', ...options })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function createSqlClient() {
|
||||
return postgres(process.env.DATABASE_URL, { max: 1 })
|
||||
}
|
||||
|
||||
async function dropAllTables(sql) {
|
||||
log('获取 public schema 下所有表...', 'cyan')
|
||||
|
||||
const tables = await sql`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name
|
||||
`
|
||||
|
||||
if (tables.length === 0) {
|
||||
warn('没有找到任何表')
|
||||
return
|
||||
}
|
||||
|
||||
log(`发现 ${tables.length} 张表,准备逐个删除(含 __drizzle_migrations__)...`, 'cyan')
|
||||
|
||||
for (const row of tables) {
|
||||
const tableName = row.table_name
|
||||
await sql.unsafe(`DROP TABLE IF EXISTS "${tableName}" CASCADE`)
|
||||
log(` - ${tableName}`, 'reset')
|
||||
}
|
||||
|
||||
ok(`已删除 ${tables.length} 张表`)
|
||||
}
|
||||
|
||||
async function dropAllEnums(sql) {
|
||||
log('清理枚举类型...', 'cyan')
|
||||
|
||||
const enums = await sql`
|
||||
SELECT t.typname
|
||||
FROM pg_type t
|
||||
JOIN pg_namespace n ON n.oid = t.typnamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND t.typtype = 'e'
|
||||
ORDER BY t.typname
|
||||
`
|
||||
|
||||
if (enums.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const row of enums) {
|
||||
await sql.unsafe(`DROP TYPE IF EXISTS "${row.typname}" CASCADE`)
|
||||
log(` - ${row.typname}`, 'reset')
|
||||
}
|
||||
|
||||
ok(`已清理 ${enums.length} 个枚举类型`)
|
||||
}
|
||||
|
||||
async function dropAllSequences(sql) {
|
||||
log('清理序列...', 'cyan')
|
||||
|
||||
const sequences = await sql`
|
||||
SELECT sequence_name
|
||||
FROM information_schema.sequences
|
||||
WHERE sequence_schema = 'public'
|
||||
ORDER BY sequence_name
|
||||
`
|
||||
|
||||
if (sequences.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const row of sequences) {
|
||||
await sql.unsafe(`DROP SEQUENCE IF EXISTS "${row.sequence_name}"`)
|
||||
log(` - ${row.sequence_name}`, 'reset')
|
||||
}
|
||||
|
||||
ok(`已清理 ${sequences.length} 个序列`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
err('未设置 DATABASE_URL 环境变量')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
log('⚠️ 此操作将删除数据库所有表(含迁移记录),并从头重建', 'red')
|
||||
const dbUrl = (process.env.DATABASE_URL || '')
|
||||
.replace(/(postgresql:\/\/[^:@]+):[^@]+@/, '$1:***@')
|
||||
.replace(/(postgres:\/\/[^:@]+):[^@]+@/, '$1:***@')
|
||||
log(`目标数据库: ${dbUrl}`, 'cyan')
|
||||
log('🔄 开始数据库完全重置...', 'bright')
|
||||
|
||||
const sql = createSqlClient()
|
||||
|
||||
try {
|
||||
// 1. 删除所有表
|
||||
await dropAllTables(sql)
|
||||
|
||||
// 2. 删除所有枚举
|
||||
await dropAllEnums(sql)
|
||||
|
||||
// 3. 删除所有序列
|
||||
await dropAllSequences(sql)
|
||||
|
||||
// 4. 从头执行迁移
|
||||
log('📋 从头执行数据库迁移...', 'cyan')
|
||||
if (!safeExec('pnpm run db:migrate', { env: NON_INTERACTIVE_ENV })) {
|
||||
err('数据库迁移失败')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
ok('数据库完全重置并迁移成功!')
|
||||
ok('数据库现已处于全新初始状态')
|
||||
} catch (error) {
|
||||
err(`重置失败: ${error.message || error}`)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
await sql.end()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
err(`未预期的错误: ${e.message || e}`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execSync } from 'child_process'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { config } from 'dotenv'
|
||||
|
||||
// 加载环境变量
|
||||
config({ path: path.resolve(process.cwd(), '.env') })
|
||||
|
||||
// 颜色输出函数
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
cyan: '\x1b[36m'
|
||||
}
|
||||
|
||||
function log(message, color = 'reset') {
|
||||
console.log(`${colors[color]}${message}${colors.reset}`)
|
||||
}
|
||||
|
||||
function logSuccess(message) {
|
||||
log(`✅ ${message}`, 'green')
|
||||
}
|
||||
|
||||
function logWarning(message) {
|
||||
log(`⚠️ ${message}`, 'yellow')
|
||||
}
|
||||
|
||||
function logError(message) {
|
||||
log(`❌ ${message}`, 'red')
|
||||
}
|
||||
|
||||
// 检查环境变量
|
||||
if (!process.env.DATABASE_URL) {
|
||||
logError('DATABASE_URL 环境变量未设置')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// 安全执行命令
|
||||
function safeExec(command, options = {}) {
|
||||
try {
|
||||
execSync(command, { stdio: 'inherit', ...options })
|
||||
return true
|
||||
} catch (error) {
|
||||
logError(`命令执行失败: ${command}`)
|
||||
logError(error.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
function fileExists(filePath) {
|
||||
try {
|
||||
return fs.existsSync(filePath)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理数据冲突的函数
|
||||
async function handleDataConflicts() {
|
||||
try {
|
||||
// 在Vercel环境中,我们主要关注schema同步而不是数据冲突处理
|
||||
log('检查数据库连接...', 'cyan')
|
||||
|
||||
// 简单的连接测试
|
||||
if (!safeExec('cd .. && pnpm exec drizzle-kit check --config=drizzle.config.ts', { stdio: 'pipe' })) {
|
||||
logWarning('数据库schema检查失败,将尝试强制同步')
|
||||
}
|
||||
|
||||
logSuccess('数据冲突检查完成')
|
||||
} catch (error) {
|
||||
logWarning(`数据冲突处理警告: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function safeMigrate() {
|
||||
log('🔄 开始安全数据库迁移流程...', 'bright')
|
||||
|
||||
try {
|
||||
// 获取项目根目录路径
|
||||
const projectRoot = path.resolve(process.cwd(), '..')
|
||||
const drizzleConfigPath = path.join(projectRoot, 'drizzle.config.ts')
|
||||
const schemaPath = path.join(projectRoot, 'app/drizzle/schema.ts')
|
||||
const migrationsPath = path.join(projectRoot, 'app/drizzle/migrations')
|
||||
|
||||
// 1. 确保drizzle配置存在
|
||||
if (!fileExists(drizzleConfigPath)) {
|
||||
throw new Error(`drizzle.config.ts 配置文件不存在: ${drizzleConfigPath}`)
|
||||
}
|
||||
|
||||
// 2. 检查schema文件
|
||||
if (!fileExists(schemaPath)) {
|
||||
throw new Error(`app/drizzle/schema.ts 文件不存在: ${schemaPath}`)
|
||||
}
|
||||
|
||||
// 3. 创建迁移目录(如果不存在)
|
||||
if (!fileExists(migrationsPath)) {
|
||||
log('创建迁移目录...', 'cyan')
|
||||
fs.mkdirSync(migrationsPath, { recursive: true })
|
||||
}
|
||||
|
||||
// 自动执行 generate 命令并处理交互
|
||||
async function runGenerateWithAutoConfirm(env) {
|
||||
return new Promise((resolve) => {
|
||||
const { spawn } = require('child_process')
|
||||
const child = spawn('pnpm', ['run', 'db:generate'], {
|
||||
env,
|
||||
shell: true
|
||||
})
|
||||
|
||||
// 监听输出,透传给用户,并自动响应提示
|
||||
child.stdout.on('data', (data) => {
|
||||
process.stdout.write(data)
|
||||
const str = data.toString()
|
||||
// 如果检测到交互提示(通常包含问号或选项),自动发送回车
|
||||
if (str.includes('?') || str.includes('renamed') || str.includes('created')) {
|
||||
try {
|
||||
// 发送回车以选择默认选项(通常是 Created)
|
||||
child.stdin.write('\n')
|
||||
} catch (e) {
|
||||
// 忽略写入错误
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
process.stderr.write(data)
|
||||
})
|
||||
|
||||
child.on('close', (code) => {
|
||||
resolve(code === 0)
|
||||
})
|
||||
|
||||
child.on('error', () => {
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 生成迁移文件(如果需要)
|
||||
log('生成数据库迁移文件...', 'cyan')
|
||||
|
||||
// 设置非交互式环境变量
|
||||
const nonInteractiveEnv = {
|
||||
...process.env,
|
||||
DRIZZLE_KIT_FORCE: 'true',
|
||||
CI: 'true',
|
||||
NODE_ENV: 'production'
|
||||
}
|
||||
|
||||
// 使用新的自动确认函数
|
||||
const generateSuccess = await runGenerateWithAutoConfirm(nonInteractiveEnv)
|
||||
|
||||
if (!generateSuccess) {
|
||||
logWarning('迁移文件生成可能遇到问题,尝试继续...')
|
||||
} else {
|
||||
logSuccess('迁移文件生成完成')
|
||||
}
|
||||
|
||||
// 5. 预处理数据冲突
|
||||
log('🔍 检查并处理数据冲突...', 'cyan')
|
||||
await handleDataConflicts()
|
||||
|
||||
// 6. 检查是否为全新部署(数据库为空)
|
||||
log('📋 检查数据库状态...', 'cyan')
|
||||
|
||||
// 设置非交互式环境变量
|
||||
const env = {
|
||||
...process.env,
|
||||
DRIZZLE_KIT_FORCE: 'true',
|
||||
CI: 'true',
|
||||
NODE_ENV: 'production'
|
||||
}
|
||||
|
||||
// 检查数据库是否有任何表(基于 introspect 输出更稳健的判断)
|
||||
let isEmptyDatabase = false
|
||||
try {
|
||||
const checkResult = execSync(
|
||||
'cd .. && pnpm exec drizzle-kit introspect --config=drizzle.config.ts',
|
||||
{
|
||||
stdio: 'pipe',
|
||||
env,
|
||||
encoding: 'utf8'
|
||||
}
|
||||
)
|
||||
|
||||
// 优先解析 "<n> tables" 提示,其次检查是否包含 columns 索引等摘要
|
||||
const tablesMatch = checkResult.match(/(\d+)\s+tables/i)
|
||||
const hasTablesCount = tablesMatch && Number(tablesMatch[1]) > 0
|
||||
const listsTables = /\bcolumns\b|\bindexes\b|\bfks\b/i.test(checkResult)
|
||||
|
||||
// 数据库为空当且仅当:统计为0且没有任何表摘要
|
||||
isEmptyDatabase = !(hasTablesCount || listsTables)
|
||||
} catch (error) {
|
||||
// introspect 失败时不轻易判定为空库,改为保守:视为非空库,走 push 路径
|
||||
logWarning('数据库状态检查失败,按非空库处理以避免重复建表')
|
||||
isEmptyDatabase = false
|
||||
}
|
||||
|
||||
if (isEmptyDatabase) {
|
||||
log('🆕 检测到全新部署,执行标准迁移...', 'cyan')
|
||||
// 对于全新部署,直接使用migrate避免交互式提示
|
||||
if (!safeExec('cd .. && pnpm run db:migrate', { env })) {
|
||||
throw new Error('数据库迁移失败')
|
||||
}
|
||||
logSuccess('全新数据库迁移成功')
|
||||
} else {
|
||||
log('🔄 检测到现有数据库,执行schema同步...', 'cyan')
|
||||
// 对于现有数据库,使用push进行增量更新
|
||||
if (safeExec('cd .. && pnpm exec drizzle-kit push --force --config=drizzle.config.ts', { env })) {
|
||||
logSuccess('数据库schema同步成功')
|
||||
} else {
|
||||
logWarning('schema同步失败,尝试标准迁移...')
|
||||
|
||||
// 7. 执行迁移(作为后备)
|
||||
if (!safeExec('cd .. && pnpm run db:migrate', { env })) {
|
||||
throw new Error('数据库迁移完全失败')
|
||||
}
|
||||
logSuccess('数据库迁移成功')
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 验证迁移结果
|
||||
log('✅ 数据库迁移流程完成!', 'green')
|
||||
} catch (error) {
|
||||
logError(`迁移失败: ${error.message}`)
|
||||
logError('请检查数据库连接和迁移文件')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 运行迁移
|
||||
safeMigrate().catch((error) => {
|
||||
logError(`未预期的错误: ${error.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user