Files
BYW-VoiceHub/server/services/autoBackupService.ts
T

510 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { db } from '~/drizzle/db'
import {
backupHistory,
systemSettings,
apiKeys,
apiKeyPermissions,
apiLogs,
cardCodeRedeemLogs,
cardCodes,
collaborationLogs,
emailTemplates,
notifications,
notificationSettings,
playTimes,
requestTimes,
schedules,
semesters,
songBlacklists,
songCollaborators,
songReplayRequests,
songs,
users,
userIdentities,
userStatusLogs,
votes
} from '~/drizzle/schema'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
import { uploadToS3 } from '~~/server/utils/s3Client'
import { desc, eq, lt, sql } from 'drizzle-orm'
/** 外部服务调用超时(毫秒) */
const UPLOAD_TIMEOUT = 120_000
/** 并发互斥锁,防止同时触发多次备份 */
let backupRunning = false
/** 获取备份锁,若已在执行则抛出 409 */
export function acquireBackupLock(): void {
if (backupRunning) {
throw createApiError(409, SERVER_ERROR_CODES.BACKUP_FAILED, '备份任务正在执行中,请稍后再试')
}
backupRunning = true
}
/** 释放备份锁 */
export function releaseBackupLock(): void {
backupRunning = false
}
/** 自动备份配置结构 */
export interface AutoBackupConfig {
methods: {
s3: {
enabled: boolean
endpoint: string
bucket: string
region: string
pathPrefix: string
accessKey: string
secretKey: string
}
webdav: {
enabled: boolean
url: string
username: string
password: string
path: string
}
telegram: {
enabled: boolean
botToken: string
chatId: string
}
email: {
enabled: boolean
recipient: string
}
}
}
/** 获取自动备份配置 */
export async function getAutoBackupConfig(): Promise<AutoBackupConfig | null> {
const [row] = await db
.select({ autoBackupConfig: systemSettings.autoBackupConfig })
.from(systemSettings)
.limit(1)
if (!row?.autoBackupConfig) return null
try {
return JSON.parse(row.autoBackupConfig)
} catch {
return null
}
}
/** 检查自动备份是否启用 */
export async function isAutoBackupEnabled(): Promise<boolean> {
const [row] = await db
.select({ autoBackupEnabled: systemSettings.autoBackupEnabled })
.from(systemSettings)
.limit(1)
return row?.autoBackupEnabled === true
}
/** 导出数据库备份数据 */
export async function exportBackupData(): Promise<{ json: string; filename: string; metadata: { totalRecords: number } }> {
const backupData = {
metadata: {
version: '1.0',
timestamp: new Date().toISOString(),
backupType: 'auto',
description: `自动备份 - ${new Date().toISOString()}`,
tables: [] as Array<{ name: string; description: string; recordCount: number }>,
totalRecords: 0
},
data: {} as Record<string, any>
}
const tablesToBackup: Record<string, { query: () => Promise<any[]>; description: string }> = {
users: { query: () => db.select().from(users), description: '用户数据' },
songs: { query: () => db.select().from(songs), description: '歌曲数据' },
schedules: { query: () => db.select().from(schedules), description: '排期数据' },
playTimes: { query: () => db.select().from(playTimes), description: '播出时段' },
requestTimes: { query: () => db.select().from(requestTimes), description: '请求时段' },
semesters: { query: () => db.select().from(semesters), description: '学期数据' },
notifications: { query: () => db.select().from(notifications), description: '通知数据' },
notificationSettings: { query: () => db.select().from(notificationSettings), description: '通知设置' },
songBlacklists: { query: () => db.select().from(songBlacklists), description: '歌曲黑名单' },
votes: { query: () => db.select().from(votes), description: '投票数据' },
cardCodes: { query: () => db.select().from(cardCodes), description: '点歌券数据' },
cardCodeRedeemLogs: { query: () => db.select().from(cardCodeRedeemLogs), description: '点歌券日志' },
songCollaborators: { query: () => db.select().from(songCollaborators), description: '联合投稿人' },
collaborationLogs: { query: () => db.select().from(collaborationLogs), description: '联合投稿审计日志' },
songReplayRequests: { query: () => db.select().from(songReplayRequests), description: '歌曲重播申请' },
userStatusLogs: { query: () => db.select().from(userStatusLogs), description: '用户状态变更日志' },
userIdentities: { query: () => db.select().from(userIdentities), description: '第三方身份关联' },
apiKeys: { query: () => db.select().from(apiKeys), description: 'API密钥' },
apiKeyPermissions: { query: () => db.select().from(apiKeyPermissions), description: 'API密钥权限' },
apiLogs: { query: () => db.select().from(apiLogs), description: 'API访问日志' },
emailTemplates: { query: () => db.select().from(emailTemplates), description: '邮件模板' },
systemSettings: {
query: async () => {
const rows = await db.select().from(systemSettings)
// 脱敏:移除敏感凭据字段,避免备份文件泄露密钥
const SENSITIVE_FIELDS = [
'smtpPassword', 'oauthStateSecret', 'githubClientSecret',
'casdoorClientSecret', 'googleClientSecret', 'customOAuthClientSecret',
'turnstileSecretKey', 'aggregateOAuthAppKey', 'autoBackupConfig'
]
return rows.map(row => {
const entries = Object.entries(row).filter(([k]) => !SENSITIVE_FIELDS.includes(k))
return Object.fromEntries(entries)
})
},
description: '系统设置(已脱敏)'
}
}
let totalRecords = 0
for (const [tableName, { query, description }] of Object.entries(tablesToBackup)) {
try {
const data = await query()
backupData.data[tableName] = data
backupData.metadata.tables.push({ name: tableName, description, recordCount: data.length })
totalRecords += data.length
} catch (error) {
console.warn(`备份表 ${tableName} 失败,已跳过:`, error)
backupData.metadata.tables.push({ name: tableName, description: `${description} (跳过)`, recordCount: 0 })
}
}
backupData.metadata.totalRecords = totalRecords
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `auto-backup-${timestamp}.json`
return { json: JSON.stringify(backupData, null, 2), filename, metadata: backupData.metadata }
}
/** 上传到 S3 */
async function doS3Upload(config: AutoBackupConfig['methods']['s3'], data: string, filename: string): Promise<void> {
const prefix = (config.pathPrefix || '').replace(/\/+$/, '')
const key = prefix ? `${prefix}/${filename}` : filename
await uploadToS3(config.endpoint, config.bucket, config.region, config.accessKey, config.secretKey, key, data)
console.log(`S3 上传完成: ${filename}`)
}
/** 规范化路径,去除首尾空格和多余斜杠 */
function normalizePath(p: string): string {
return (p || '').trim().replace(/^\/+|\/+$/g, '')
}
/** 递归创建 WebDAV 目录 */
async function ensureWebDAVDir(baseUrl: string, auth: string, dirPath: string): Promise<void> {
const normalized = normalizePath(dirPath)
if (!normalized) return
const segments = normalized.split('/')
let currentPath = baseUrl.replace(/\/$/, '')
for (const segment of segments) {
currentPath += `/${segment}`
const response = await fetch(currentPath, {
method: 'MKCOL',
headers: { Authorization: `Basic ${auth}` },
signal: AbortSignal.timeout(UPLOAD_TIMEOUT)
})
// 201 Created = 成功,405 = 目录已存在
if (response.status === 409) {
throw new Error(`WebDAV 父目录不存在,无法创建: ${currentPath}`)
}
if (!response.ok && response.status !== 405) {
throw new Error(`创建 WebDAV 目录失败: ${currentPath} (${response.status})`)
}
}
}
/** 上传到 WebDAV */
async function doWebDAVUpload(config: AutoBackupConfig['methods']['webdav'], data: string, filename: string): Promise<void> {
const baseUrl = config.url.replace(/\/$/, '')
const dirPath = normalizePath(config.path)
const auth = Buffer.from(`${config.username}:${config.password}`).toString('base64')
// 确保目录存在
await ensureWebDAVDir(baseUrl, auth, dirPath)
const filePath = dirPath ? `${baseUrl}/${dirPath}/${filename}` : `${baseUrl}/${filename}`
const response = await fetch(filePath, {
method: 'PUT',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json'
},
body: data,
signal: AbortSignal.timeout(UPLOAD_TIMEOUT)
})
if (!response.ok) {
throw new Error(`WebDAV 上传失败: ${response.status} ${response.statusText}`)
}
console.log(`WebDAV 上传完成: ${filename}`)
}
/** 导出 ensureWebDAVDir 供测试端点使用 */
export { ensureWebDAVDir }
/** 通过 Telegram Bot 发送 */
async function doTelegramSend(config: AutoBackupConfig['methods']['telegram'], data: string, filename: string): Promise<void> {
const formData = new FormData()
const blob = new Blob([data], { type: 'application/json' })
formData.append('chat_id', config.chatId)
formData.append('document', blob, filename)
formData.append('caption', `VoiceHub Auto Backup - ${new Date().toISOString()}`)
const response = await fetch(`https://api.telegram.org/bot${config.botToken}/sendDocument`, {
method: 'POST',
body: formData,
signal: AbortSignal.timeout(UPLOAD_TIMEOUT)
})
const result = await response.json() as any
if (!result.ok) {
throw new Error(`Telegram 发送失败: ${result.description}`)
}
console.log(`Telegram 发送完成: ${filename}`)
}
/** 通过邮件发送 */
async function doEmailSend(config: AutoBackupConfig['methods']['email'], data: string, filename: string): Promise<void> {
const { getSystemSettingsCached } = await import('~~/server/utils/system-settings-helper')
const settings = await getSystemSettingsCached()
const nodemailer = await import('nodemailer').then(m => m.default || m)
if (!settings || !settings.smtpHost || !settings.smtpUsername || !settings.smtpPassword) {
throw createApiError(500, SERVER_ERROR_CODES.SMTP_NOT_CONFIGURED, '邮件服务未配置,无法发送邮件备份')
}
const transporter = nodemailer.createTransport({
host: settings.smtpHost,
port: settings.smtpPort || 587,
secure: settings.smtpSecure || false,
auth: {
user: settings.smtpUsername,
pass: settings.smtpPassword
}
})
try {
await transporter.sendMail({
from: `"${settings.smtpFromName || 'VoiceHub'}" <${settings.smtpFromEmail || settings.smtpUsername}>`,
to: config.recipient,
subject: `VoiceHub 自动备份 - ${new Date().toISOString()}`,
text: '数据库自动备份文件见附件。',
attachments: [{
filename,
content: data,
contentType: 'application/json'
}]
})
console.log(`邮件发送完成: ${filename} -> ${config.recipient}`)
} finally {
transporter.close()
}
}
/** 准备备份:校验、导出数据、写入初始记录,返回上下文供后续上传使用 */
export async function prepareBackup(triggeredBy: string = 'api'): Promise<{
historyId: number
json: string
filename: string
metadata: { totalRecords: number }
backupSize: number
config: AutoBackupConfig
enabledMethods: Array<{ key: string; name: string; fn: () => Promise<void> }>
}> {
const enabled = await isAutoBackupEnabled()
if (!enabled) {
throw createApiError(400, SERVER_ERROR_CODES.BACKUP_DISABLED, '自动备份未启用')
}
const config = await getAutoBackupConfig()
if (!config) {
throw createApiError(400, SERVER_ERROR_CODES.BACKUP_NOT_CONFIGURED, '自动备份未配置')
}
const { json, filename, metadata } = await exportBackupData()
const methods = [
{ key: 's3' as const, name: 'S3', fn: () => doS3Upload(config.methods.s3, json, filename) },
{ key: 'webdav' as const, name: 'WebDAV', fn: () => doWebDAVUpload(config.methods.webdav, json, filename) },
{ key: 'telegram' as const, name: 'Telegram', fn: () => doTelegramSend(config.methods.telegram, json, filename) },
{ key: 'email' as const, name: 'Email', fn: () => doEmailSend(config.methods.email, json, filename) }
]
const enabledMethods = methods.filter(({ key }) => config.methods[key].enabled)
if (enabledMethods.length === 0) {
throw createApiError(400, SERVER_ERROR_CODES.NO_BACKUP_METHOD_ENABLED, '没有启用任何备份方式')
}
// 写入初始记录(error 为 null 表示未完成),确保超时中断也有历史
const backupSize = Buffer.byteLength(json)
const initialResults = enabledMethods.map(({ name }) => ({
method: name,
success: false,
error: null
}))
const [inserted] = await db.insert(backupHistory).values({
filename,
totalRecords: metadata.totalRecords,
backupSize,
success: false,
methods: JSON.stringify(initialResults),
triggeredBy
}).returning({ id: backupHistory.id })
if (!inserted) {
throw createApiError(500, SERVER_ERROR_CODES.BACKUP_FAILED, '写入备份记录失败')
}
console.log(`备份初始记录已写入: ${filename} (id=${inserted.id})`)
return {
historyId: inserted.id,
json,
filename,
metadata,
backupSize,
config,
enabledMethods
}
}
/** 执行上传并更新历史记录 */
export async function executeUploads(prepared: {
historyId: number
json: string
filename: string
config: AutoBackupConfig
enabledMethods: Array<{ key: string; name: string; fn: () => Promise<void> }>
}): Promise<{
success: boolean
results: Array<{ method: string; success: boolean; error?: string }>
}> {
const { historyId, filename, enabledMethods } = prepared
// 串行化 DB 更新,避免并行写入时的竞态条件
let updateChain: Promise<void> = Promise.resolve()
function updateMethodResult(index: number, result: { method: string; success: boolean; error?: string }) {
const prev = updateChain
updateChain = prev.then(async () => {
const [record] = await db
.select({ methods: backupHistory.methods })
.from(backupHistory)
.where(eq(backupHistory.id, historyId))
if (!record) return
const methods = JSON.parse(record.methods)
methods[index] = result
await db.update(backupHistory)
.set({ methods: JSON.stringify(methods) })
.where(eq(backupHistory.id, historyId))
}).catch(err => {
console.error(`更新备份方法结果失败 (index=${index}):`, err)
})
return updateChain
}
// 并行上传,每个完成后立即更新对应方法的结果
const tasks = enabledMethods.map(async ({ name, fn }, index) => {
try {
await fn()
await updateMethodResult(index, { method: name, success: true })
return { method: name, success: true }
} catch (error: any) {
console.error(`${name} 备份失败:`, error)
const errMsg = error.message || String(error) || 'Unknown error'
await updateMethodResult(index, { method: name, success: false, error: errMsg })
return { method: name, success: false, error: errMsg }
}
})
const results: Array<{ method: string; success: boolean; error?: string }> = await Promise.all(tasks)
const overallSuccess = results.some(r => r.success)
// 更新整体成功状态
await db.update(backupHistory)
.set({ success: overallSuccess })
.where(eq(backupHistory.id, historyId))
// 自动清理 30 天前的历史记录
cleanupOldHistory(30).catch(err => console.error('自动清理备份历史失败:', err))
console.log(`备份历史已更新: ${filename}`)
return {
success: overallSuccess,
results
}
}
/** 执行一次完整的自动备份(prepareBackup + executeUploads) */
export async function executeAutoBackup(triggeredBy: string = 'api'): Promise<{
success: boolean
results: Array<{ method: string; success: boolean; error?: string }>
}> {
acquireBackupLock()
try {
const prepared = await prepareBackup(triggeredBy)
return await executeUploads(prepared)
} finally {
releaseBackupLock()
}
}
/** 获取备份历史列表 */
export async function getBackupHistory(limit: number = 50): Promise<Array<{
id: number
createdAt: Date
filename: string
totalRecords: number
backupSize: number
methods: Array<{ method: string; success: boolean; error?: string }>
success: boolean
triggeredBy: string | null
}>> {
const rows = await db
.select()
.from(backupHistory)
.orderBy(desc(backupHistory.createdAt))
.limit(limit)
return rows.map((row) => ({
...row,
methods: JSON.parse(row.methods)
}))
}
/** 清理备份历史记录 */
export async function cleanupOldHistory(retentionDays: number = 30): Promise<number> {
if (retentionDays <= 0) {
const result = await db.select({ cnt: sql<number>`count(*)::int` }).from(backupHistory)
const count = result[0]?.cnt ?? 0
if (count > 0) {
await db.delete(backupHistory)
console.log(`清理了全部 ${count} 条备份历史记录`)
}
return count
}
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000)
const result = await db
.select({ cnt: sql<number>`count(*)::int` })
.from(backupHistory)
.where(lt(backupHistory.createdAt, cutoff))
const count = result[0]?.cnt ?? 0
if (count > 0) {
await db.delete(backupHistory).where(lt(backupHistory.createdAt, cutoff))
console.log(`清理了 ${count} 条过期备份历史记录`)
}
return count
}