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
@@ -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
}
+114
View File
@@ -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: '自动备份配置已保存' }
})
+178
View File
@@ -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 || '删除备份文件失败'
})
}
})
+84
View File
@@ -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 || '下载备份文件失败'
})
}
})
+618
View File
@@ -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 } }
})
+14
View File
@@ -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 }
})
+158
View File
@@ -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 || '邮件发送失败' }
}
})
+58
View File
@@ -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 连接测试失败' }
}
})
+133
View File
@@ -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 || '上传备份文件失败'
})
}
})