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
+450
View File
@@ -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)
})
+245
View File
@@ -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)
})
+203
View File
@@ -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)
})
+99
View File
@@ -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)
})
+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)
})
+279
View File
@@ -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": [] }
+196
View File
@@ -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)
})
+23
View File
@@ -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"
}
}
+23
View File
@@ -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' })
}
+83
View File
@@ -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(() => {})
}
+181
View File
@@ -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)
})
+241
View File
@@ -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)
})