feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,618 @@
|
||||
import { createError, defineEventHandler, readBody } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import {
|
||||
apiKeys,
|
||||
apiKeyPermissions,
|
||||
apiLogs,
|
||||
cardCodeRedeemLogs,
|
||||
cardCodes,
|
||||
collaborationLogs,
|
||||
emailTemplates,
|
||||
notifications,
|
||||
notificationSettings,
|
||||
playTimes,
|
||||
requestTimes,
|
||||
schedules,
|
||||
scheduleSongPool,
|
||||
semesters,
|
||||
songBlacklists,
|
||||
songCollaborators,
|
||||
songReplayRequests,
|
||||
songs,
|
||||
systemSettings,
|
||||
users,
|
||||
userIdentities,
|
||||
userStatusLogs,
|
||||
gradeClass,
|
||||
votes
|
||||
} from '~/drizzle/schema'
|
||||
import { inArray } from 'drizzle-orm'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以导出备份'
|
||||
})
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const { tables = 'all' } = body
|
||||
const includeSystemData =
|
||||
typeof body.includeSystemData === 'boolean' ? body.includeSystemData : tables !== 'users'
|
||||
|
||||
console.log('开始创建数据库备份...')
|
||||
|
||||
// 创建备份数据对象
|
||||
const backupData = {
|
||||
metadata: {
|
||||
version: '1.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
creator: user.username,
|
||||
description: '',
|
||||
backupType: 'custom',
|
||||
tables: [],
|
||||
totalRecords: 0
|
||||
},
|
||||
data: {}
|
||||
}
|
||||
|
||||
// 定义要备份的表和对应的查询
|
||||
const tablesToBackup = {
|
||||
users: {
|
||||
query: async () => {
|
||||
const usersData = await db.select().from(users)
|
||||
const settingsData = await db.select().from(notificationSettings)
|
||||
|
||||
// 手动关联通知设置
|
||||
return usersData.map((user) => ({
|
||||
...user,
|
||||
notificationSettings: settingsData.filter((setting) => setting.userId === user.id)
|
||||
}))
|
||||
},
|
||||
description: '用户数据'
|
||||
},
|
||||
songs: {
|
||||
query: async () => {
|
||||
const songsData = await db.select().from(songs)
|
||||
const usersData = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
name: users.name
|
||||
})
|
||||
.from(users)
|
||||
const votesData = await db.select().from(votes)
|
||||
const schedulesData = await db.select().from(schedules)
|
||||
const playTimesData = await db.select().from(playTimes)
|
||||
|
||||
// 手动关联数据
|
||||
return songsData.map((song) => ({
|
||||
...song,
|
||||
requester: usersData.find((user) => user.id === song.requesterId),
|
||||
votes: votesData
|
||||
.filter((vote) => vote.songId === song.id)
|
||||
.map((vote) => ({
|
||||
...vote,
|
||||
user: usersData.find((user) => user.id === vote.userId)
|
||||
})),
|
||||
schedules: schedulesData.filter((schedule) => schedule.songId === song.id),
|
||||
preferredPlayTime: playTimesData.find((pt) => pt.id === song.preferredPlayTimeId)
|
||||
}))
|
||||
},
|
||||
description: '歌曲数据'
|
||||
},
|
||||
cardCodes: {
|
||||
query: async () => {
|
||||
const codesData = await db.select().from(cardCodes)
|
||||
const usersData = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
name: users.name
|
||||
})
|
||||
.from(users)
|
||||
|
||||
return codesData.map((code) => ({
|
||||
...code,
|
||||
lockedByUser: code.lockedBy ? usersData.find((user) => user.id === code.lockedBy) : null,
|
||||
redeemedByUser: code.redeemedBy ? usersData.find((user) => user.id === code.redeemedBy) : null
|
||||
}))
|
||||
},
|
||||
description: '点歌券数据'
|
||||
},
|
||||
cardCodeRedeemLogs: {
|
||||
query: async () => {
|
||||
const logsData = await db.select().from(cardCodeRedeemLogs)
|
||||
const codesData = await db.select({ id: cardCodes.id, code: cardCodes.code }).from(cardCodes)
|
||||
const songsData = await db
|
||||
.select({
|
||||
id: songs.id,
|
||||
title: songs.title,
|
||||
artist: songs.artist
|
||||
})
|
||||
.from(songs)
|
||||
const usersData = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
name: users.name
|
||||
})
|
||||
.from(users)
|
||||
|
||||
return logsData.map((log) => ({
|
||||
...log,
|
||||
cardCode: codesData.find((code) => code.id === log.cardCodeId),
|
||||
redeemer: usersData.find((user) => user.id === log.redeemedBy),
|
||||
song: log.songId ? songsData.find((song) => song.id === log.songId) : null
|
||||
}))
|
||||
},
|
||||
description: '点歌券日志'
|
||||
},
|
||||
votes: {
|
||||
query: async () => {
|
||||
const votesData = await db.select().from(votes)
|
||||
const usersData = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
name: users.name
|
||||
})
|
||||
.from(users)
|
||||
const songsData = await db
|
||||
.select({
|
||||
id: songs.id,
|
||||
title: songs.title,
|
||||
artist: songs.artist
|
||||
})
|
||||
.from(songs)
|
||||
|
||||
return votesData.map((vote) => ({
|
||||
...vote,
|
||||
user: usersData.find((user) => user.id === vote.userId),
|
||||
song: songsData.find((song) => song.id === vote.songId)
|
||||
}))
|
||||
},
|
||||
description: '投票数据'
|
||||
},
|
||||
scheduleSongPool: {
|
||||
query: async () => {
|
||||
const poolData = await db.select().from(scheduleSongPool)
|
||||
if (poolData.length === 0) return []
|
||||
const poolSongIds = poolData.map((row) => row.songId)
|
||||
const songsData = await db.select({
|
||||
id: songs.id,
|
||||
title: songs.title,
|
||||
artist: songs.artist
|
||||
}).from(songs).where(inArray(songs.id, poolSongIds))
|
||||
const songsMap = new Map(songsData.map((s) => [s.id, s]))
|
||||
return poolData.map((row) => ({
|
||||
...row,
|
||||
song: songsMap.get(row.songId) || null
|
||||
}))
|
||||
},
|
||||
description: '排期备选池'
|
||||
},
|
||||
schedules: {
|
||||
query: async () => {
|
||||
const schedulesData = await db.select().from(schedules)
|
||||
const songsData = await db
|
||||
.select({
|
||||
id: songs.id,
|
||||
title: songs.title,
|
||||
artist: songs.artist
|
||||
})
|
||||
.from(songs)
|
||||
const playTimesData = await db.select().from(playTimes)
|
||||
|
||||
return schedulesData.map((schedule) => ({
|
||||
...schedule,
|
||||
song: songsData.find((song) => song.id === schedule.songId),
|
||||
playTime: playTimesData.find((pt) => pt.id === schedule.playTimeId)
|
||||
}))
|
||||
},
|
||||
description: '排期数据'
|
||||
},
|
||||
notifications: {
|
||||
query: async () => {
|
||||
const notificationsData = await db.select().from(notifications)
|
||||
const usersData = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
name: users.name
|
||||
})
|
||||
.from(users)
|
||||
const songsData = await db
|
||||
.select({
|
||||
id: songs.id,
|
||||
title: songs.title,
|
||||
artist: songs.artist
|
||||
})
|
||||
.from(songs)
|
||||
|
||||
return notificationsData.map((notification) => ({
|
||||
...notification,
|
||||
user: usersData.find((user) => user.id === notification.userId),
|
||||
song: notification.songId
|
||||
? songsData.find((song) => song.id === notification.songId)
|
||||
: null
|
||||
}))
|
||||
},
|
||||
description: '通知数据'
|
||||
},
|
||||
notificationSettings: {
|
||||
query: async () => {
|
||||
const settingsData = await db.select().from(notificationSettings)
|
||||
const usersData = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
name: users.name
|
||||
})
|
||||
.from(users)
|
||||
|
||||
return settingsData.map((setting) => ({
|
||||
...setting,
|
||||
user: usersData.find((user) => user.id === setting.userId)
|
||||
}))
|
||||
},
|
||||
description: '通知设置'
|
||||
},
|
||||
playTimes: {
|
||||
query: () => db.select().from(playTimes),
|
||||
description: '播出时段'
|
||||
},
|
||||
semesters: {
|
||||
query: () => db.select().from(semesters),
|
||||
description: '学期数据'
|
||||
},
|
||||
songBlacklist: {
|
||||
query: () => db.select().from(songBlacklists),
|
||||
description: '歌曲黑名单'
|
||||
},
|
||||
userStatusLogs: {
|
||||
query: async () => {
|
||||
const statusLogsData = await db.select().from(userStatusLogs)
|
||||
const usersData = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
name: users.name
|
||||
})
|
||||
.from(users)
|
||||
|
||||
return statusLogsData.map((log) => ({
|
||||
...log,
|
||||
user: usersData.find((user) => user.id === log.userId)
|
||||
}))
|
||||
},
|
||||
description: '用户状态变更日志'
|
||||
},
|
||||
gradeClass: {
|
||||
query: () => db.select().from(gradeClass),
|
||||
description: '年级班级配置'
|
||||
},
|
||||
userIdentities: {
|
||||
query: async () => {
|
||||
const identitiesData = await db.select().from(userIdentities)
|
||||
const usersData = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
name: users.name
|
||||
})
|
||||
.from(users)
|
||||
|
||||
return identitiesData.map((identity) => ({
|
||||
...identity,
|
||||
user: usersData.find((user) => user.id === identity.userId)
|
||||
}))
|
||||
},
|
||||
description: '第三方身份关联'
|
||||
},
|
||||
songCollaborators: {
|
||||
query: async () => {
|
||||
const collaboratorsData = await db.select().from(songCollaborators)
|
||||
return collaboratorsData
|
||||
},
|
||||
description: '联合投稿人'
|
||||
},
|
||||
collaborationLogs: {
|
||||
query: async () => {
|
||||
const logsData = await db.select().from(collaborationLogs)
|
||||
return logsData
|
||||
},
|
||||
description: '联合投稿审计日志'
|
||||
},
|
||||
songReplayRequests: {
|
||||
query: async () => {
|
||||
const requestsData = await db.select().from(songReplayRequests)
|
||||
const usersData = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
name: users.name
|
||||
})
|
||||
.from(users)
|
||||
const songsData = await db
|
||||
.select({
|
||||
id: songs.id,
|
||||
title: songs.title,
|
||||
artist: songs.artist
|
||||
})
|
||||
.from(songs)
|
||||
|
||||
return requestsData.map((req) => ({
|
||||
...req,
|
||||
user: usersData.find((user) => user.id === req.userId),
|
||||
song: songsData.find((song) => song.id === req.songId)
|
||||
}))
|
||||
},
|
||||
description: '歌曲重播申请'
|
||||
},
|
||||
apiKeys: {
|
||||
query: async () => {
|
||||
const apiKeysData = await db.select().from(apiKeys)
|
||||
const usersData = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
name: users.name
|
||||
})
|
||||
.from(users)
|
||||
const permissionsData = await db.select().from(apiKeyPermissions)
|
||||
|
||||
return apiKeysData.map((key) => ({
|
||||
...key,
|
||||
createdByUser: usersData.find((user) => user.id === key.createdByUserId),
|
||||
permissions: permissionsData.filter((perm) => perm.apiKeyId === key.id)
|
||||
}))
|
||||
},
|
||||
description: 'API密钥'
|
||||
},
|
||||
apiKeyPermissions: {
|
||||
query: () => db.select().from(apiKeyPermissions),
|
||||
description: 'API密钥权限'
|
||||
},
|
||||
apiLogs: {
|
||||
query: async () => {
|
||||
const logsData = await db.select().from(apiLogs)
|
||||
const apiKeysData = await db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
name: apiKeys.name
|
||||
})
|
||||
.from(apiKeys)
|
||||
|
||||
return logsData.map((log) => ({
|
||||
...log,
|
||||
apiKey: log.apiKeyId ? apiKeysData.find((key) => key.id === log.apiKeyId) : null
|
||||
}))
|
||||
},
|
||||
description: 'API访问日志'
|
||||
},
|
||||
emailTemplates: {
|
||||
query: async () => {
|
||||
const templatesData = await db.select().from(emailTemplates)
|
||||
const usersData = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
name: users.name
|
||||
})
|
||||
.from(users)
|
||||
|
||||
return templatesData.map((template) => ({
|
||||
...template,
|
||||
updatedByUser: template.updatedByUserId
|
||||
? usersData.find((user) => user.id === template.updatedByUserId)
|
||||
: null
|
||||
}))
|
||||
},
|
||||
description: '邮件模板'
|
||||
},
|
||||
requestTimes: {
|
||||
query: () => db.select().from(requestTimes),
|
||||
description: '请求时段'
|
||||
}
|
||||
}
|
||||
|
||||
// 如果包含系统数据,添加系统设置表
|
||||
if (includeSystemData) {
|
||||
tablesToBackup.systemSettings = {
|
||||
query: async () => {
|
||||
const settings = await db.select().from(systemSettings)
|
||||
return settings
|
||||
},
|
||||
description: '系统设置'
|
||||
}
|
||||
}
|
||||
|
||||
// 根据请求的表进行备份
|
||||
let tablesToProcess
|
||||
if (tables === 'all') {
|
||||
tablesToProcess = Object.keys(tablesToBackup)
|
||||
} else if (tables === 'users') {
|
||||
// 仅备份用户相关数据
|
||||
tablesToProcess = ['users', 'notificationSettings', 'userStatusLogs', 'userIdentities']
|
||||
// 如果包含系统数据,也添加到处理列表中
|
||||
if (includeSystemData) {
|
||||
tablesToProcess.push('systemSettings')
|
||||
}
|
||||
} else if (Array.isArray(tables)) {
|
||||
tablesToProcess = tables
|
||||
} else {
|
||||
tablesToProcess = [tables]
|
||||
}
|
||||
|
||||
if (
|
||||
includeSystemData &&
|
||||
tablesToBackup.systemSettings &&
|
||||
!tablesToProcess.includes('systemSettings')
|
||||
) {
|
||||
tablesToProcess.push('systemSettings')
|
||||
}
|
||||
|
||||
if (tablesToProcess.length === 1 && tablesToProcess[0] === 'systemSettings') {
|
||||
backupData.metadata.backupType = 'system'
|
||||
backupData.metadata.description = `系统配置备份 - ${new Date().toLocaleString('zh-CN')}`
|
||||
} else if (tables === 'users') {
|
||||
backupData.metadata.backupType = 'users'
|
||||
backupData.metadata.description = `用户数据备份 - ${new Date().toLocaleString('zh-CN')}`
|
||||
} else if (tables === 'all') {
|
||||
backupData.metadata.backupType = 'full'
|
||||
backupData.metadata.description = `完整数据库备份 - ${new Date().toLocaleString('zh-CN')}`
|
||||
} else if (tables === 'songs') {
|
||||
backupData.metadata.backupType = 'songs'
|
||||
backupData.metadata.description = `歌曲数据备份 - ${new Date().toLocaleString('zh-CN')}`
|
||||
} else {
|
||||
backupData.metadata.description = `自定义数据库备份 - ${new Date().toLocaleString('zh-CN')}`
|
||||
}
|
||||
|
||||
let totalRecords = 0
|
||||
|
||||
for (const tableName of tablesToProcess) {
|
||||
if (!tablesToBackup[tableName]) {
|
||||
console.warn(`未知的表名: ${tableName}`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`备份表: ${tableName}`)
|
||||
const tableData = await tablesToBackup[tableName].query()
|
||||
|
||||
backupData.data[tableName] = tableData
|
||||
backupData.metadata.tables.push({
|
||||
name: tableName,
|
||||
description: tablesToBackup[tableName].description,
|
||||
recordCount: tableData.length
|
||||
})
|
||||
|
||||
totalRecords += tableData.length
|
||||
console.log(`✅ ${tableName}: ${tableData.length} 条记录`)
|
||||
} catch (error) {
|
||||
// 检查是否为表不存在错误
|
||||
// Postgres 错误代码 42P01: undefined_table (表不存在)
|
||||
const isTableMissing =
|
||||
error.code === '42P01' ||
|
||||
(error.message && error.message.includes('does not exist')) ||
|
||||
(error.statusMessage && error.statusMessage.includes('does not exist'))
|
||||
|
||||
if (isTableMissing) {
|
||||
console.warn(`⚠️ 表 ${tableName} 不存在,跳过备份`)
|
||||
// 记录一个空的条目,以便知道跳过了
|
||||
backupData.metadata.tables.push({
|
||||
name: tableName,
|
||||
description: `${tablesToBackup[tableName].description} (表不存在,已跳过)`,
|
||||
recordCount: 0,
|
||||
skipped: true
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
console.error(`备份表 ${tableName} 失败:`, error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: `备份表 ${tableName} 失败:${error.message}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
backupData.metadata.totalRecords = totalRecords
|
||||
|
||||
// 生成备份文件名(用于下载)
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
let filePrefix = 'database-backup'
|
||||
|
||||
if (tables === 'users') {
|
||||
filePrefix = includeSystemData ? 'users-system-backup' : 'users-backup'
|
||||
} else if (tablesToProcess.length === 1 && tablesToProcess[0] === 'systemSettings') {
|
||||
filePrefix = 'system-settings-backup'
|
||||
} else if (tables === 'songs') {
|
||||
filePrefix = includeSystemData ? 'songs-system-backup' : 'songs-backup'
|
||||
}
|
||||
|
||||
const filename = `${filePrefix}-${timestamp}.json`
|
||||
|
||||
console.log(`✅ 备份完成: ${filename}`)
|
||||
console.log(`📊 总计备份 ${totalRecords} 条记录`)
|
||||
|
||||
// 检测运行环境
|
||||
const isVercel = process.env.VERCEL || process.env.VERCEL_ENV
|
||||
const isNetlify = process.env.NETLIFY
|
||||
const isServerless = isVercel || isNetlify
|
||||
|
||||
if (isServerless) {
|
||||
// 在无服务器环境中,直接返回备份数据供前端下载
|
||||
console.log('🌐 检测到无服务器环境,直接返回备份数据')
|
||||
|
||||
// 计算数据大小(估算)
|
||||
const dataSize = JSON.stringify(backupData).length
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '数据库备份创建成功',
|
||||
backup: {
|
||||
filename,
|
||||
data: backupData,
|
||||
size: dataSize,
|
||||
metadata: backupData.metadata,
|
||||
downloadMode: 'direct' // 标识为直接下载模式
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 在传统服务器环境中,保存到文件系统
|
||||
try {
|
||||
const backupDir = path.join(process.cwd(), 'backups')
|
||||
try {
|
||||
await fs.access(backupDir)
|
||||
} catch {
|
||||
await fs.mkdir(backupDir, { recursive: true })
|
||||
}
|
||||
|
||||
const filepath = path.join(backupDir, filename)
|
||||
await fs.writeFile(filepath, JSON.stringify(backupData, null, 2), 'utf8')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '数据库备份创建成功',
|
||||
backup: {
|
||||
filename,
|
||||
filepath,
|
||||
size: (await fs.stat(filepath)).size,
|
||||
metadata: backupData.metadata,
|
||||
downloadMode: 'file' // 标识为文件下载模式
|
||||
}
|
||||
}
|
||||
} catch (fsError) {
|
||||
console.warn('文件系统操作失败,回退到直接返回模式:', fsError.message)
|
||||
|
||||
// 如果文件系统操作失败,回退到直接返回模式
|
||||
const dataSize = JSON.stringify(backupData).length
|
||||
return {
|
||||
success: true,
|
||||
message: '数据库备份创建成功(直接下载模式)',
|
||||
backup: {
|
||||
filename,
|
||||
data: backupData,
|
||||
size: dataSize,
|
||||
metadata: backupData.metadata,
|
||||
downloadMode: 'direct'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建数据库备份失败:', error)
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
message: error.message || '创建数据库备份失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user