feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { defineEventHandler } from 'h3'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { getAutoBackupConfig, isAutoBackupEnabled } from '~~/server/services/autoBackupService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.COMMON_INSUFFICIENT_PERMISSION, '只有超级管理员可以查看自动备份配置')
|
||||
}
|
||||
|
||||
const enabled = await isAutoBackupEnabled()
|
||||
const config = await getAutoBackupConfig()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
enabled,
|
||||
config: config ? sanitizeConfig(config) : null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** 移除敏感信息后返回,参考 OAuth 配置的密钥保护模式 */
|
||||
function sanitizeConfig(config: any) {
|
||||
const sanitized = JSON.parse(JSON.stringify(config))
|
||||
const configuredSecrets: Record<string, boolean> = {}
|
||||
|
||||
if (sanitized.methods?.s3) {
|
||||
configuredSecrets.s3SecretKey = !!sanitized.methods.s3.secretKey
|
||||
sanitized.methods.s3.secretKey = ''
|
||||
}
|
||||
if (sanitized.methods?.webdav) {
|
||||
configuredSecrets.webdavPassword = !!sanitized.methods.webdav.password
|
||||
sanitized.methods.webdav.password = ''
|
||||
}
|
||||
if (sanitized.methods?.telegram) {
|
||||
configuredSecrets.telegramBotToken = !!sanitized.methods.telegram.botToken
|
||||
sanitized.methods.telegram.botToken = ''
|
||||
}
|
||||
|
||||
sanitized.configuredSecrets = configuredSecrets
|
||||
return sanitized
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { defineEventHandler, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { systemSettings } from '~/drizzle/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { SYSTEM_SETTINGS_DEFAULTS } from '~~/server/utils/system-settings-defaults'
|
||||
|
||||
/** 备份配置结构校验 */
|
||||
const backupConfigSchema = z.object({
|
||||
methods: z.object({
|
||||
s3: z.object({
|
||||
enabled: z.boolean(),
|
||||
endpoint: z.string(),
|
||||
bucket: z.string(),
|
||||
region: z.string(),
|
||||
pathPrefix: z.string(),
|
||||
accessKey: z.string(),
|
||||
secretKey: z.string().optional()
|
||||
}),
|
||||
webdav: z.object({
|
||||
enabled: z.boolean(),
|
||||
url: z.string(),
|
||||
username: z.string(),
|
||||
password: z.string().optional(),
|
||||
path: z.string()
|
||||
}),
|
||||
telegram: z.object({
|
||||
enabled: z.boolean(),
|
||||
botToken: z.string().optional(),
|
||||
chatId: z.string()
|
||||
}),
|
||||
email: z.object({
|
||||
enabled: z.boolean(),
|
||||
recipient: z.string()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/** 密钥字段映射:前端留空时保留数据库中的现有值 */
|
||||
const SECRET_FIELDS: Record<string, string[]> = {
|
||||
s3: ['secretKey'],
|
||||
webdav: ['password'],
|
||||
telegram: ['botToken']
|
||||
}
|
||||
|
||||
function mergeSecrets(incoming: any, existing: any): any {
|
||||
if (!incoming || !existing) return incoming
|
||||
const merged = JSON.parse(JSON.stringify(incoming))
|
||||
for (const [method, fields] of Object.entries(SECRET_FIELDS)) {
|
||||
if (!merged.methods?.[method] || !existing.methods?.[method]) continue
|
||||
for (const field of fields) {
|
||||
if (!merged.methods[method][field] && existing.methods[method][field]) {
|
||||
merged.methods[method][field] = existing.methods[method][field]
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.COMMON_INSUFFICIENT_PERMISSION, '只有超级管理员可以修改自动备份配置')
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const { enabled, config } = body
|
||||
|
||||
if (typeof enabled !== 'boolean') {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'enabled 字段必填')
|
||||
}
|
||||
|
||||
// 校验 config 结构,防止畸形配置导致备份执行时崩溃
|
||||
if (config) {
|
||||
const parsed = backupConfigSchema.safeParse(config)
|
||||
if (!parsed.success) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, `备份配置结构无效: ${parsed.error.issues[0]?.message || '未知错误'}`)
|
||||
}
|
||||
}
|
||||
|
||||
const [existing] = await db.select({
|
||||
id: systemSettings.id,
|
||||
autoBackupConfig: systemSettings.autoBackupConfig
|
||||
}).from(systemSettings).limit(1)
|
||||
|
||||
// 合并密钥:前端留空时保留数据库中已有的值
|
||||
let finalConfig = config
|
||||
if (config && existing?.autoBackupConfig) {
|
||||
try {
|
||||
const existingConfig = JSON.parse(existing.autoBackupConfig)
|
||||
finalConfig = mergeSecrets(config, existingConfig)
|
||||
} catch { /* 解析失败则使用传入的 config */ }
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
await db.insert(systemSettings).values({
|
||||
...SYSTEM_SETTINGS_DEFAULTS,
|
||||
autoBackupEnabled: enabled,
|
||||
autoBackupConfig: finalConfig ? JSON.stringify(finalConfig) : null
|
||||
})
|
||||
} else {
|
||||
await db.update(systemSettings)
|
||||
.set({
|
||||
autoBackupEnabled: enabled,
|
||||
autoBackupConfig: finalConfig ? JSON.stringify(finalConfig) : null,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(systemSettings.id, existing.id))
|
||||
}
|
||||
|
||||
return { success: true, message: '自动备份配置已保存' }
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import { createError, defineEventHandler, readBody } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import {
|
||||
apiKeyPermissions,
|
||||
apiKeys,
|
||||
apiLogs,
|
||||
cardCodeRedeemLogs,
|
||||
cardCodes,
|
||||
collaborationLogs,
|
||||
emailTemplates,
|
||||
notifications,
|
||||
notificationSettings,
|
||||
playTimes,
|
||||
requestTimes,
|
||||
schedules,
|
||||
semesters,
|
||||
songBlacklists,
|
||||
songCollaborators,
|
||||
songReplayRequests,
|
||||
songs,
|
||||
systemSettings,
|
||||
userIdentities,
|
||||
users,
|
||||
userStatusLogs,
|
||||
votes
|
||||
} from '~/drizzle/schema'
|
||||
import { eq, inArray, isNull, notInArray, or } from 'drizzle-orm'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 验证管理员权限
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以清空数据库'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await readBody(event)
|
||||
const finalizeTempUser = body?.finalizeTempUser === true
|
||||
const overwriteSuperAdmin = body?.overwriteSuperAdmin === true
|
||||
const hasSuperAdminInBackup = body?.hasSuperAdminInBackup === true
|
||||
const shouldOverwriteSuperAdmin = overwriteSuperAdmin && hasSuperAdminInBackup
|
||||
|
||||
if (finalizeTempUser) {
|
||||
const currentUserId = Number(user.id)
|
||||
const currentUserApiKeys = await db
|
||||
.select({ id: apiKeys.id })
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.createdByUserId, currentUserId))
|
||||
const currentUserApiKeyIds = currentUserApiKeys.map((item) => item.id)
|
||||
|
||||
if (currentUserApiKeyIds.length > 0) {
|
||||
await db.delete(apiLogs).where(inArray(apiLogs.apiKeyId, currentUserApiKeyIds))
|
||||
await db
|
||||
.delete(apiKeyPermissions)
|
||||
.where(inArray(apiKeyPermissions.apiKeyId, currentUserApiKeyIds))
|
||||
}
|
||||
|
||||
await db.delete(apiKeys).where(eq(apiKeys.createdByUserId, currentUserId))
|
||||
await db.delete(notifications).where(eq(notifications.userId, currentUserId))
|
||||
await db.delete(notificationSettings).where(eq(notificationSettings.userId, currentUserId))
|
||||
await db.delete(cardCodeRedeemLogs).where(eq(cardCodeRedeemLogs.redeemedBy, currentUserId))
|
||||
await db.delete(userStatusLogs).where(eq(userStatusLogs.userId, currentUserId))
|
||||
await db.delete(userIdentities).where(eq(userIdentities.userId, currentUserId))
|
||||
await db.delete(users).where(eq(users.id, currentUserId))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '临时管理员账户已清理',
|
||||
finalized: true
|
||||
}
|
||||
}
|
||||
|
||||
let preservedSuperAdminIds: number[] = []
|
||||
let temporaryPreservedUserId: number | null = null
|
||||
if (!shouldOverwriteSuperAdmin) {
|
||||
const preservedUsers = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(or(eq(users.role, 'SUPER_ADMIN'), eq(users.id, 1)))
|
||||
preservedSuperAdminIds = preservedUsers.map((item) => item.id)
|
||||
} else {
|
||||
temporaryPreservedUserId = Number(user.id)
|
||||
}
|
||||
|
||||
console.log('清空现有数据...')
|
||||
|
||||
if (shouldOverwriteSuperAdmin || preservedSuperAdminIds.length === 0) {
|
||||
await db.delete(apiLogs)
|
||||
await db.delete(apiKeyPermissions)
|
||||
await db.delete(apiKeys)
|
||||
await db.delete(notifications)
|
||||
await db.delete(notificationSettings)
|
||||
await db.delete(cardCodeRedeemLogs)
|
||||
await db.delete(collaborationLogs)
|
||||
await db.delete(songCollaborators)
|
||||
await db.delete(songReplayRequests)
|
||||
await db.delete(schedules)
|
||||
await db.delete(votes)
|
||||
await db.delete(userStatusLogs)
|
||||
await db.delete(emailTemplates)
|
||||
await db.delete(songBlacklists)
|
||||
await db.delete(userIdentities)
|
||||
await db.delete(songs)
|
||||
await db.delete(cardCodes)
|
||||
await db.delete(playTimes)
|
||||
await db.delete(semesters)
|
||||
await db.delete(requestTimes)
|
||||
await db.delete(systemSettings)
|
||||
if (temporaryPreservedUserId) {
|
||||
await db.delete(users).where(notInArray(users.id, [temporaryPreservedUserId]))
|
||||
} else {
|
||||
await db.delete(users)
|
||||
}
|
||||
} else {
|
||||
const preservedApiKeys = await db
|
||||
.select({ id: apiKeys.id })
|
||||
.from(apiKeys)
|
||||
.where(inArray(apiKeys.createdByUserId, preservedSuperAdminIds))
|
||||
const preservedApiKeyIds = preservedApiKeys.map((item) => item.id)
|
||||
|
||||
if (preservedApiKeyIds.length > 0) {
|
||||
await db
|
||||
.delete(apiLogs)
|
||||
.where(or(isNull(apiLogs.apiKeyId), notInArray(apiLogs.apiKeyId, preservedApiKeyIds)))
|
||||
await db
|
||||
.delete(apiKeyPermissions)
|
||||
.where(notInArray(apiKeyPermissions.apiKeyId, preservedApiKeyIds))
|
||||
} else {
|
||||
await db.delete(apiLogs)
|
||||
await db.delete(apiKeyPermissions)
|
||||
}
|
||||
await db.delete(apiKeys).where(notInArray(apiKeys.createdByUserId, preservedSuperAdminIds))
|
||||
await db.delete(notifications).where(notInArray(notifications.userId, preservedSuperAdminIds))
|
||||
await db
|
||||
.delete(notificationSettings)
|
||||
.where(notInArray(notificationSettings.userId, preservedSuperAdminIds))
|
||||
await db.delete(cardCodeRedeemLogs)
|
||||
await db.delete(collaborationLogs)
|
||||
await db.delete(songCollaborators)
|
||||
await db.delete(songReplayRequests)
|
||||
await db.delete(schedules)
|
||||
await db.delete(votes)
|
||||
await db
|
||||
.delete(userStatusLogs)
|
||||
.where(notInArray(userStatusLogs.userId, preservedSuperAdminIds))
|
||||
await db.delete(emailTemplates)
|
||||
await db.delete(songBlacklists)
|
||||
await db
|
||||
.delete(userIdentities)
|
||||
.where(notInArray(userIdentities.userId, preservedSuperAdminIds))
|
||||
await db.delete(songs)
|
||||
await db.delete(cardCodes)
|
||||
await db.delete(playTimes)
|
||||
await db.delete(semesters)
|
||||
await db.delete(requestTimes)
|
||||
await db.delete(systemSettings)
|
||||
await db.delete(users).where(notInArray(users.id, preservedSuperAdminIds))
|
||||
}
|
||||
|
||||
console.log('✅ 现有数据已清空')
|
||||
return {
|
||||
success: true,
|
||||
message: '数据已清空',
|
||||
shouldOverwriteSuperAdmin,
|
||||
preservedSuperAdminIds,
|
||||
temporaryPreservedUserId
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('清空数据失败:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '清空数据失败:' + error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { createError, defineEventHandler, getRouterParam } from 'h3'
|
||||
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 filename = getRouterParam(event, 'filename')
|
||||
|
||||
if (!filename) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '请指定文件名'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证文件名安全性
|
||||
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的文件名'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证文件扩展名
|
||||
if (!filename.endsWith('.json')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '只能删除 JSON 格式的备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
const backupDir = path.join(process.cwd(), 'backups')
|
||||
const filepath = path.join(backupDir, filename)
|
||||
|
||||
// 检查文件是否存在
|
||||
try {
|
||||
await fs.access(filepath)
|
||||
} catch {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '备份文件不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
await fs.unlink(filepath)
|
||||
|
||||
console.log(`管理员 ${user.username} 删除了备份文件:${filename}`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '备份文件删除成功',
|
||||
filename
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除备份文件失败:', error)
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
message: error.message || '删除备份文件失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createError, defineEventHandler, getQuery, setHeader } from 'h3'
|
||||
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 query = getQuery(event)
|
||||
const { filename } = query
|
||||
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '缺少文件名参数'
|
||||
})
|
||||
}
|
||||
|
||||
// 安全检查:确保文件名不包含路径遍历字符
|
||||
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的文件名'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查文件是否为备份文件
|
||||
if (
|
||||
!filename.endsWith('.json') ||
|
||||
(!filename.startsWith('database-backup-') &&
|
||||
!filename.startsWith('users-backup-') &&
|
||||
!filename.startsWith('uploaded-'))
|
||||
) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
const backupDir = path.join(process.cwd(), 'backups')
|
||||
const filepath = path.join(backupDir, filename)
|
||||
|
||||
try {
|
||||
// 检查文件是否存在
|
||||
await fs.access(filepath)
|
||||
|
||||
// 读取文件内容
|
||||
const fileContent = await fs.readFile(filepath, 'utf8')
|
||||
const fileStats = await fs.stat(filepath)
|
||||
|
||||
// 设置响应头
|
||||
setHeader(event, 'Content-Type', 'application/json')
|
||||
setHeader(event, 'Content-Disposition', `attachment; filename="${filename}"`)
|
||||
setHeader(event, 'Content-Length', fileStats.size.toString())
|
||||
setHeader(event, 'Cache-Control', 'no-cache')
|
||||
|
||||
return fileContent
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '备份文件不存在'
|
||||
})
|
||||
}
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '读取备份文件失败'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('下载备份文件失败:', error)
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
message: error.message || '下载备份文件失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createError, defineEventHandler, getRouterParam, setHeader } from 'h3'
|
||||
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 filename = getRouterParam(event, 'filename')
|
||||
|
||||
if (!filename) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '请指定文件名'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证文件名安全性
|
||||
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的文件名'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证文件扩展名
|
||||
if (!filename.endsWith('.json')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '只能下载 JSON 格式的备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
const backupDir = path.join(process.cwd(), 'backups')
|
||||
const filepath = path.join(backupDir, filename)
|
||||
|
||||
// 检查文件是否存在
|
||||
try {
|
||||
await fs.access(filepath)
|
||||
} catch {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '备份文件不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取文件信息
|
||||
const stats = await fs.stat(filepath)
|
||||
|
||||
// 设置响应头
|
||||
setHeader(event, 'Content-Type', 'application/json')
|
||||
setHeader(event, 'Content-Disposition', `attachment; filename="${filename}"`)
|
||||
setHeader(event, 'Content-Length', stats.size.toString())
|
||||
setHeader(event, 'Cache-Control', 'no-cache')
|
||||
|
||||
// 读取并返回文件内容
|
||||
const fileContent = await fs.readFile(filepath)
|
||||
|
||||
console.log(`管理员 ${user.username} 下载了备份文件: ${filename}`)
|
||||
|
||||
return fileContent
|
||||
} catch (error) {
|
||||
console.error('下载备份文件失败:', error)
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
message: error.message || '下载备份文件失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 || '创建数据库备份失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineEventHandler } from 'h3'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { cleanupOldHistory } from '~~/server/services/autoBackupService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.COMMON_INSUFFICIENT_PERMISSION, '只有超级管理员可以清理备份历史')
|
||||
}
|
||||
|
||||
const count = await cleanupOldHistory(0) // 0 = 清理全部
|
||||
return { success: true, message: `已清理 ${count} 条备份历史记录`, data: { count } }
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineEventHandler } from 'h3'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { getBackupHistory } from '~~/server/services/autoBackupService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.COMMON_INSUFFICIENT_PERMISSION, '只有超级管理员可以查看备份历史')
|
||||
}
|
||||
|
||||
const history = await getBackupHistory()
|
||||
return { success: true, data: history }
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { createError, defineEventHandler } from 'h3'
|
||||
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 isVercel = process.env.VERCEL || process.env.VERCEL_ENV
|
||||
const isNetlify = process.env.NETLIFY
|
||||
const isServerless = isVercel || isNetlify
|
||||
|
||||
if (isServerless) {
|
||||
// 在无服务器环境中,无法持久化存储文件,返回空列表
|
||||
console.log('🌐 检测到无服务器环境,备份文件无法持久化存储')
|
||||
return {
|
||||
success: true,
|
||||
backups: [],
|
||||
total: 0,
|
||||
message: '无服务器环境中备份文件无法持久化存储,请使用直接下载功能'
|
||||
}
|
||||
}
|
||||
|
||||
const backupDir = path.join(process.cwd(), 'backups')
|
||||
|
||||
// 检查备份目录是否存在
|
||||
try {
|
||||
await fs.access(backupDir)
|
||||
} catch {
|
||||
// 如果目录不存在,尝试创建它
|
||||
try {
|
||||
await fs.mkdir(backupDir, { recursive: true })
|
||||
} catch (mkdirError) {
|
||||
console.warn('无法创建备份目录,可能在只读文件系统中:', mkdirError.message)
|
||||
return {
|
||||
success: true,
|
||||
backups: [],
|
||||
total: 0,
|
||||
message: '文件系统只读,备份文件无法持久化存储'
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
backups: [],
|
||||
total: 0
|
||||
}
|
||||
}
|
||||
|
||||
// 读取备份目录中的文件
|
||||
const files = await fs.readdir(backupDir)
|
||||
const backupFiles = files.filter(
|
||||
(file) =>
|
||||
file.endsWith('.json') &&
|
||||
(file.startsWith('database-backup-') ||
|
||||
file.startsWith('users-backup-') ||
|
||||
file.startsWith('users-system-backup-') ||
|
||||
file.startsWith('songs-backup-') ||
|
||||
file.startsWith('songs-system-backup-') ||
|
||||
file.startsWith('system-settings-backup-') ||
|
||||
file.startsWith('uploaded-'))
|
||||
)
|
||||
|
||||
const backups = []
|
||||
|
||||
for (const filename of backupFiles) {
|
||||
try {
|
||||
const filepath = path.join(backupDir, filename)
|
||||
const stats = await fs.stat(filepath)
|
||||
|
||||
// 尝试读取备份文件的元数据
|
||||
let metadata = null
|
||||
try {
|
||||
const fileContent = await fs.readFile(filepath, 'utf8')
|
||||
const backupData = JSON.parse(fileContent)
|
||||
|
||||
if (backupData.metadata) {
|
||||
metadata = backupData.metadata
|
||||
} else if (backupData.users) {
|
||||
// 兼容旧格式的用户备份文件
|
||||
metadata = {
|
||||
version: '0.1',
|
||||
timestamp: backupData.timestamp || stats.mtime.toISOString(),
|
||||
creator: 'system',
|
||||
description: '用户数据备份(旧格式)',
|
||||
tables: [
|
||||
{ name: 'users', recordCount: backupData.totalUsers || backupData.users.length }
|
||||
],
|
||||
totalRecords: backupData.totalUsers || backupData.users.length
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn(`无法解析备份文件 ${filename}:`, parseError.message)
|
||||
}
|
||||
|
||||
// 判断备份类型
|
||||
let type = 'users' // 默认为用户备份
|
||||
if (filename.startsWith('database-backup-')) {
|
||||
type = 'full'
|
||||
} else if (
|
||||
filename.startsWith('users-backup-') ||
|
||||
filename.startsWith('users-system-backup-')
|
||||
) {
|
||||
type = 'users'
|
||||
} else if (
|
||||
filename.startsWith('songs-backup-') ||
|
||||
filename.startsWith('songs-system-backup-')
|
||||
) {
|
||||
type = 'songs'
|
||||
} else if (filename.startsWith('system-settings-backup-')) {
|
||||
type = 'system'
|
||||
} else if (filename.startsWith('uploaded-')) {
|
||||
// 对于上传的文件,根据元数据判断类型
|
||||
if (metadata) {
|
||||
if (metadata.backupType) {
|
||||
type = metadata.backupType
|
||||
} else if (metadata.tables && metadata.tables.length > 1) {
|
||||
type = 'full'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
backups.push({
|
||||
filename,
|
||||
size: stats.size,
|
||||
createdAt: stats.mtime,
|
||||
metadata,
|
||||
type,
|
||||
isValid: metadata !== null
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`处理备份文件 ${filename} 失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// 按创建时间倒序排列
|
||||
backups.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
|
||||
return {
|
||||
success: true,
|
||||
backups,
|
||||
total: backups.length
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取备份文件列表失败:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '获取备份文件列表失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
import { defineEventHandler, readBody } from 'h3'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { getSystemSettingsCached } from '~~/server/utils/system-settings-helper'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.COMMON_INSUFFICIENT_PERMISSION, '只有超级管理员可以测试邮件发送')
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const { recipient } = body
|
||||
|
||||
if (!recipient) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '缺少必要参数:recipient')
|
||||
}
|
||||
|
||||
const settings = await getSystemSettingsCached()
|
||||
if (!settings?.smtpHost || !settings?.smtpUsername || !settings?.smtpPassword) {
|
||||
return { success: false, message: '邮件服务未配置,请先在站点配置中设置 SMTP' }
|
||||
}
|
||||
|
||||
try {
|
||||
const nodemailer = await import('nodemailer').then((m) => m.default || m)
|
||||
|
||||
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: recipient,
|
||||
subject: 'VoiceHub 邮件发送测试',
|
||||
text: '如果您收到此邮件,说明 VoiceHub 的邮件服务配置正确。'
|
||||
})
|
||||
|
||||
return { success: true, message: '测试邮件发送成功' }
|
||||
} finally {
|
||||
transporter.close()
|
||||
}
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message || '邮件发送失败' }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { defineEventHandler, readBody } from 'h3'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { uploadToS3, deleteFromS3 } from '~~/server/utils/s3Client'
|
||||
import { getAutoBackupConfig } from '~~/server/services/autoBackupService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.COMMON_INSUFFICIENT_PERMISSION, '只有超级管理员可以测试 S3 连接')
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
let { endpoint, bucket, region, accessKey, secretKey } = body
|
||||
|
||||
// 密钥为空时从已保存配置中获取
|
||||
if (!secretKey) {
|
||||
const config = await getAutoBackupConfig()
|
||||
secretKey = config?.methods?.s3?.secretKey || ''
|
||||
accessKey = accessKey || config?.methods?.s3?.accessKey || ''
|
||||
endpoint = endpoint || config?.methods?.s3?.endpoint || ''
|
||||
bucket = bucket || config?.methods?.s3?.bucket || ''
|
||||
region = region || config?.methods?.s3?.region || 'auto'
|
||||
}
|
||||
|
||||
if (!endpoint || !bucket || !accessKey || !secretKey) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '缺少必要参数:endpoint, bucket, accessKey, secretKey')
|
||||
}
|
||||
|
||||
const testKey = '.voicehub-test-connection'
|
||||
|
||||
try {
|
||||
await uploadToS3(
|
||||
endpoint,
|
||||
bucket,
|
||||
region || 'auto',
|
||||
accessKey,
|
||||
secretKey,
|
||||
testKey,
|
||||
'VoiceHub S3 connection test',
|
||||
'text/plain'
|
||||
)
|
||||
|
||||
// 清理测试文件
|
||||
await deleteFromS3(
|
||||
endpoint,
|
||||
bucket,
|
||||
region || 'auto',
|
||||
accessKey,
|
||||
secretKey,
|
||||
testKey
|
||||
).catch(() => {})
|
||||
|
||||
return { success: true, message: 'S3 连接测试成功' }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message || 'S3 连接测试失败' }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineEventHandler, readBody } from 'h3'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { getAutoBackupConfig } from '~~/server/services/autoBackupService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.COMMON_INSUFFICIENT_PERMISSION, '只有超级管理员可以测试 Telegram 发送')
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
let { botToken, chatId } = body
|
||||
|
||||
// botToken 为空时从已保存配置中获取
|
||||
if (!botToken) {
|
||||
const config = await getAutoBackupConfig()
|
||||
botToken = config?.methods?.telegram?.botToken || ''
|
||||
chatId = chatId || config?.methods?.telegram?.chatId || ''
|
||||
}
|
||||
|
||||
if (!botToken || !chatId) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '缺少必要参数:botToken, chatId')
|
||||
}
|
||||
|
||||
try {
|
||||
// 先发送一条文本消息确认连接
|
||||
const textRes = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: 'VoiceHub Telegram Bot 连接测试成功!'
|
||||
})
|
||||
})
|
||||
|
||||
const result = await textRes.json() as any
|
||||
if (!result.ok) {
|
||||
throw new Error(result.description || 'Telegram API 返回错误')
|
||||
}
|
||||
|
||||
return { success: true, message: 'Telegram Bot 连接测试成功' }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message || 'Telegram Bot 连接测试失败' }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { defineEventHandler, readBody } from 'h3'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { getAutoBackupConfig } from '~~/server/services/autoBackupService'
|
||||
|
||||
/** 规范化路径,去除首尾空格和多余斜杠 */
|
||||
function normalizePath(p: string): string {
|
||||
return (p || '').trim().replace(/^\/+|\/+$/g, '')
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createApiError(403, SERVER_ERROR_CODES.COMMON_INSUFFICIENT_PERMISSION, '只有超级管理员可以测试 WebDAV 连接')
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
let { url, username, password, path: dirPath } = body
|
||||
|
||||
// 密码为空时从已保存配置中获取
|
||||
if (!password) {
|
||||
const config = await getAutoBackupConfig()
|
||||
password = config?.methods?.webdav?.password || ''
|
||||
username = username || config?.methods?.webdav?.username || ''
|
||||
url = url || config?.methods?.webdav?.url || ''
|
||||
dirPath = dirPath || config?.methods?.webdav?.path || ''
|
||||
}
|
||||
|
||||
if (!url || !username || !password) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '缺少必要参数:url, username, password')
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = url.replace(/\/$/, '')
|
||||
const dir = normalizePath(dirPath)
|
||||
const auth = Buffer.from(`${username}:${password}`).toString('base64')
|
||||
|
||||
// 递归创建中间目录
|
||||
if (dir) {
|
||||
const segments = dir.split('/')
|
||||
let currentPath = baseUrl
|
||||
for (const segment of segments) {
|
||||
currentPath += `/${segment}`
|
||||
const res = await fetch(currentPath, {
|
||||
method: 'MKCOL',
|
||||
headers: { Authorization: `Basic ${auth}` }
|
||||
})
|
||||
if (!res.ok && res.status !== 405 && res.status !== 409) {
|
||||
throw new Error(`创建目录失败: ${currentPath} (${res.status})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const testUrl = dir ? `${baseUrl}/${dir}/.voicehub-test-connection` : `${baseUrl}/.voicehub-test-connection`
|
||||
|
||||
// 写入测试文件
|
||||
const putRes = await fetch(testUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`,
|
||||
'Content-Type': 'text/plain'
|
||||
},
|
||||
body: 'VoiceHub WebDAV connection test'
|
||||
})
|
||||
|
||||
if (!putRes.ok) {
|
||||
throw new Error(`WebDAV 写入失败: ${putRes.status} ${putRes.statusText}`)
|
||||
}
|
||||
|
||||
// 清理测试文件
|
||||
await fetch(testUrl, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Basic ${auth}` }
|
||||
}).catch(() => {})
|
||||
|
||||
return { success: true, message: 'WebDAV 连接测试成功' }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message || 'WebDAV 连接测试失败' }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import { createError, defineEventHandler } from 'h3'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import formidable from 'formidable'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以上传备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
// 创建备份目录
|
||||
const backupDir = path.join(process.cwd(), 'backups')
|
||||
try {
|
||||
await fs.access(backupDir)
|
||||
} catch {
|
||||
await fs.mkdir(backupDir, { recursive: true })
|
||||
}
|
||||
|
||||
// 解析上传的文件
|
||||
const form = formidable({
|
||||
uploadDir: backupDir,
|
||||
keepExtensions: true,
|
||||
maxFileSize: 100 * 1024 * 1024, // 100MB
|
||||
filter: ({ mimetype, originalFilename }) => {
|
||||
// 只允许 JSON 文件
|
||||
return (
|
||||
mimetype === 'application/json' ||
|
||||
(originalFilename && originalFilename.endsWith('.json'))
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const [fields, files] = await form.parse(event.node.req)
|
||||
|
||||
if (!files.file || !files.file[0]) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '请选择要上传的备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
const uploadedFile = files.file[0]
|
||||
|
||||
// 验证文件内容
|
||||
let backupData
|
||||
try {
|
||||
const fileContent = await fs.readFile(uploadedFile.filepath, 'utf8')
|
||||
backupData = JSON.parse(fileContent)
|
||||
} catch (error) {
|
||||
// 删除无效文件
|
||||
await fs.unlink(uploadedFile.filepath)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '备份文件格式无效'
|
||||
})
|
||||
}
|
||||
|
||||
// 验证备份文件结构
|
||||
if (!backupData.metadata && !backupData.users) {
|
||||
await fs.unlink(uploadedFile.filepath)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '不是有效的备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
// 生成新的文件名
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const originalName = uploadedFile.originalFilename || 'backup.json'
|
||||
const fileExtension = path.extname(originalName)
|
||||
const baseName = path.basename(originalName, fileExtension)
|
||||
const newFilename = `uploaded-${baseName}-${timestamp}${fileExtension}`
|
||||
const newFilepath = path.join(backupDir, newFilename)
|
||||
|
||||
// 移动文件到新位置
|
||||
await fs.rename(uploadedFile.filepath, newFilepath)
|
||||
|
||||
// 更新备份文件的元数据
|
||||
if (backupData.metadata) {
|
||||
// 新格式备份文件
|
||||
backupData.metadata.uploadedBy = user.username
|
||||
backupData.metadata.uploadedAt = new Date().toISOString()
|
||||
backupData.metadata.originalFilename = originalName
|
||||
backupData.metadata.creator = user.username // 确保创建者信息正确
|
||||
} else if (backupData.users) {
|
||||
// 旧格式备份文件,创建元数据
|
||||
backupData.metadata = {
|
||||
version: '0.1',
|
||||
timestamp: backupData.timestamp || new Date().toISOString(),
|
||||
creator: user.username,
|
||||
uploadedBy: user.username,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
originalFilename: originalName,
|
||||
description: '用户数据备份(上传)',
|
||||
tables: [{ name: 'users', recordCount: backupData.totalUsers || backupData.users.length }],
|
||||
totalRecords: backupData.totalUsers || backupData.users.length
|
||||
}
|
||||
}
|
||||
|
||||
// 重新保存文件
|
||||
await fs.writeFile(newFilepath, JSON.stringify(backupData, null, 2), 'utf8')
|
||||
|
||||
console.log(`管理员 ${user.username} 上传了备份文件:${newFilename}`)
|
||||
|
||||
// 获取文件信息
|
||||
const stats = await fs.stat(newFilepath)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '备份文件上传成功',
|
||||
backup: {
|
||||
filename: newFilename,
|
||||
originalFilename: originalName,
|
||||
size: stats.size,
|
||||
uploadedBy: user.username,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
metadata: backupData.metadata || null
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传备份文件失败:', error)
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
message: error.message || '上传备份文件失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user