feat: 迁移自定义功能至 1.5.9.9 版本

包含音乐源限制、CI 防 OOM 优化、数据库连接池优化、管理端权限及 UI 细节定制等功能
This commit is contained in:
2026-09-12 19:26:42 +08:00
parent 34f0679117
commit 1106aeea6c
161 changed files with 18963 additions and 27900 deletions
+13 -16
View File
@@ -4,8 +4,6 @@ import {
apiKeyPermissions,
apiKeys,
apiLogs,
cardCodeRedeemLogs,
cardCodes,
collaborationLogs,
emailTemplates,
notifications,
@@ -29,10 +27,18 @@ 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') {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有超级管理员可以清空数据库'
statusMessage: '权限不足'
})
}
// 只有超级管理员可以清空数据库
if (user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
statusMessage: '只有超级管理员可以清空数据库'
})
}
@@ -61,7 +67,6 @@ export default defineEventHandler(async (event) => {
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))
@@ -93,7 +98,6 @@ export default defineEventHandler(async (event) => {
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)
@@ -104,7 +108,6 @@ export default defineEventHandler(async (event) => {
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)
@@ -137,22 +140,16 @@ export default defineEventHandler(async (event) => {
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(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(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)
@@ -172,7 +169,7 @@ export default defineEventHandler(async (event) => {
console.error('清空数据失败:', error)
throw createError({
statusCode: 500,
message: '清空数据失败:' + error.message
statusMessage: '清空数据失败: ' + error.message
})
}
})
@@ -6,10 +6,10 @@ export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有超级管理员可以管理备份文件'
statusMessage: '权限不足'
})
}
@@ -18,7 +18,7 @@ export default defineEventHandler(async (event) => {
if (!filename) {
throw createError({
statusCode: 400,
message: '请指定文件名'
statusMessage: '请指定文件名'
})
}
@@ -26,7 +26,7 @@ export default defineEventHandler(async (event) => {
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
throw createError({
statusCode: 400,
message: '无效的文件名'
statusMessage: '无效的文件名'
})
}
@@ -34,7 +34,7 @@ export default defineEventHandler(async (event) => {
if (!filename.endsWith('.json')) {
throw createError({
statusCode: 400,
message: '只能删除 JSON 格式的备份文件'
statusMessage: '只能删除JSON格式的备份文件'
})
}
@@ -47,14 +47,14 @@ export default defineEventHandler(async (event) => {
} catch {
throw createError({
statusCode: 404,
message: '备份文件不存在'
statusMessage: '备份文件不存在'
})
}
// 删除文件
await fs.unlink(filepath)
console.log(`管理员 ${user.username} 删除了备份文件:${filename}`)
console.log(`管理员 ${user.username} 删除了备份文件: ${filename}`)
return {
success: true,
@@ -65,7 +65,7 @@ export default defineEventHandler(async (event) => {
console.error('删除备份文件失败:', error)
throw createError({
statusCode: error.statusCode || 500,
message: error.message || '删除备份文件失败'
statusMessage: error.statusMessage || '删除备份文件失败'
})
}
})
+8 -8
View File
@@ -6,10 +6,10 @@ export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有超级管理员可以下载备份文件'
statusMessage: '权限不足'
})
}
@@ -19,7 +19,7 @@ export default defineEventHandler(async (event) => {
if (!filename || typeof filename !== 'string') {
throw createError({
statusCode: 400,
message: '缺少文件名参数'
statusMessage: '缺少文件名参数'
})
}
@@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
throw createError({
statusCode: 400,
message: '无效的文件名'
statusMessage: '无效的文件名'
})
}
@@ -40,7 +40,7 @@ export default defineEventHandler(async (event) => {
) {
throw createError({
statusCode: 400,
message: '无效的备份文件'
statusMessage: '无效的备份文件'
})
}
@@ -66,19 +66,19 @@ export default defineEventHandler(async (event) => {
if (error.code === 'ENOENT') {
throw createError({
statusCode: 404,
message: '备份文件不存在'
statusMessage: '备份文件不存在'
})
}
throw createError({
statusCode: 500,
message: '读取备份文件失败'
statusMessage: '读取备份文件失败'
})
}
} catch (error) {
console.error('下载备份文件失败:', error)
throw createError({
statusCode: error.statusCode || 500,
message: error.message || '下载备份文件失败'
statusMessage: error.statusMessage || '下载备份文件失败'
})
}
})
@@ -6,10 +6,10 @@ export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有超级管理员可以下载备份文件'
statusMessage: '权限不足'
})
}
@@ -18,7 +18,7 @@ export default defineEventHandler(async (event) => {
if (!filename) {
throw createError({
statusCode: 400,
message: '请指定文件名'
statusMessage: '请指定文件名'
})
}
@@ -26,7 +26,7 @@ export default defineEventHandler(async (event) => {
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
throw createError({
statusCode: 400,
message: '无效的文件名'
statusMessage: '无效的文件名'
})
}
@@ -34,7 +34,7 @@ export default defineEventHandler(async (event) => {
if (!filename.endsWith('.json')) {
throw createError({
statusCode: 400,
message: '只能下载 JSON 格式的备份文件'
statusMessage: '只能下载JSON格式的备份文件'
})
}
@@ -47,7 +47,7 @@ export default defineEventHandler(async (event) => {
} catch {
throw createError({
statusCode: 404,
message: '备份文件不存在'
statusMessage: '备份文件不存在'
})
}
@@ -70,7 +70,7 @@ export default defineEventHandler(async (event) => {
console.error('下载备份文件失败:', error)
throw createError({
statusCode: error.statusCode || 500,
message: error.message || '下载备份文件失败'
statusMessage: error.statusMessage || '下载备份文件失败'
})
}
})
+6 -85
View File
@@ -4,8 +4,6 @@ import {
apiKeys,
apiKeyPermissions,
apiLogs,
cardCodeRedeemLogs,
cardCodes,
collaborationLogs,
emailTemplates,
notifications,
@@ -13,7 +11,6 @@ import {
playTimes,
requestTimes,
schedules,
scheduleSongPool,
semesters,
songBlacklists,
songCollaborators,
@@ -23,10 +20,8 @@ import {
users,
userIdentities,
userStatusLogs,
gradeClass,
votes
} from '~/drizzle/schema'
import { inArray } from 'drizzle-orm'
import { promises as fs } from 'fs'
import path from 'path'
@@ -34,17 +29,15 @@ export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有超级管理员可以导出备份'
statusMessage: '权限不足'
})
}
const body = await readBody(event)
const { tables = 'all' } = body
const includeSystemData =
typeof body.includeSystemData === 'boolean' ? body.includeSystemData : tables !== 'users'
const { tables = 'all', includeSystemData = true } = body
console.log('开始创建数据库备份...')
@@ -107,53 +100,6 @@ export default defineEventHandler(async (event) => {
},
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)
@@ -180,24 +126,6 @@ export default defineEventHandler(async (event) => {
},
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)
@@ -294,10 +222,6 @@ export default defineEventHandler(async (event) => {
},
description: '用户状态变更日志'
},
gradeClass: {
query: () => db.select().from(gradeClass),
description: '年级班级配置'
},
userIdentities: {
query: async () => {
const identitiesData = await db.select().from(userIdentities)
@@ -426,10 +350,7 @@ export default defineEventHandler(async (event) => {
// 如果包含系统数据,添加系统设置表
if (includeSystemData) {
tablesToBackup.systemSettings = {
query: async () => {
const settings = await db.select().from(systemSettings)
return settings
},
query: () => db.select().from(systemSettings),
description: '系统设置'
}
}
@@ -519,7 +440,7 @@ export default defineEventHandler(async (event) => {
console.error(`备份表 ${tableName} 失败:`, error)
throw createError({
statusCode: 500,
message: `备份表 ${tableName} 失败:${error.message}`
statusMessage: `备份表 ${tableName} 失败: ${error.message}`
})
}
}
@@ -612,7 +533,7 @@ export default defineEventHandler(async (event) => {
console.error('创建数据库备份失败:', error)
throw createError({
statusCode: error.statusCode || 500,
message: error.message || '创建数据库备份失败'
statusMessage: error.statusMessage || '创建数据库备份失败'
})
}
})
+4 -7
View File
@@ -6,10 +6,10 @@ export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有超级管理员可以查看备份文件'
statusMessage: '权限不足'
})
}
@@ -109,10 +109,7 @@ export default defineEventHandler(async (event) => {
filename.startsWith('users-system-backup-')
) {
type = 'users'
} else if (
filename.startsWith('songs-backup-') ||
filename.startsWith('songs-system-backup-')
) {
} else if (filename.startsWith('songs-backup-') || filename.startsWith('songs-system-backup-')) {
type = 'songs'
} else if (filename.startsWith('system-settings-backup-')) {
type = 'system'
@@ -152,7 +149,7 @@ export default defineEventHandler(async (event) => {
console.error('获取备份文件列表失败:', error)
throw createError({
statusCode: 500,
message: '获取备份文件列表失败'
statusMessage: '获取备份文件列表失败'
})
}
})
+15 -437
View File
@@ -1,10 +1,6 @@
import { createError, defineEventHandler, readBody } from 'h3'
import { db } from '~/drizzle/db'
import {
cardCodeRedeemLogs,
cardCodes,
emailTemplates,
gradeClass,
notificationSettings,
notifications,
playTimes,
@@ -19,21 +15,14 @@ import {
votes
} from '~/drizzle/schema'
import { and, eq } from 'drizzle-orm'
import { restoreScheduleSongPoolRecord } from '~~/server/utils/restoreScheduleSongPool'
import { omitMaskedSystemSettingsSecrets } from '~~/server/api/admin/system-settings/secretMask'
import { createApiError } from '~~/server/utils/apiError'
import { validateThemeConfig } from '~~/server/utils/theme-config'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
import { normalizeScheduleVisibilitySettings } from '~~/server/utils/system-settings-defaults'
import { syncAllSequences } from '~~/server/utils/sequence-sync'
export default defineEventHandler(async (event) => {
// 验证管理员权限
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有超级管理员可以恢复备份'
statusMessage: '权限不足'
})
}
@@ -50,14 +39,14 @@ export default defineEventHandler(async (event) => {
if (overwriteSuperAdmin && user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
message: '仅超级管理员可以覆盖超级管理员账号数据'
statusMessage: '仅超级管理员可以覆盖超级管理员账号数据'
})
}
if (!tableName || !records || !Array.isArray(records)) {
throw createError({
statusCode: 400,
message: '无效的请求参数'
statusMessage: '无效的请求参数'
})
}
@@ -68,9 +57,6 @@ export default defineEventHandler(async (event) => {
const songIdMapping = new Map(
Object.entries(mappings?.songs || {}).map(([k, v]) => [Number(k), Number(v)])
)
const cardCodeIdMapping = new Map(
Object.entries(mappings?.cardCodes || {}).map(([k, v]) => [Number(k), Number(v)])
)
const preservedSuperAdminIds = new Set(
(mappings?.meta?.preservedSuperAdminIds || []).map((id) => Number(id))
)
@@ -81,8 +67,7 @@ export default defineEventHandler(async (event) => {
const newMappings = {
users: {},
songs: {},
cardCodes: {}
songs: {}
}
const stats = {
@@ -114,10 +99,7 @@ export default defineEventHandler(async (event) => {
'meowNickname',
'forcePasswordChange',
'status',
'statusChangedBy',
'remark',
'avatarProvider',
'avatarProviderUserId'
'statusChangedBy'
]
const dateFields = [
'createdAt',
@@ -131,7 +113,7 @@ export default defineEventHandler(async (event) => {
userFields.forEach((field) => {
if (record.hasOwnProperty(field)) {
if (field === 'role') {
if (!['USER', 'SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(record[field])) {
if (!['USER', 'TEACHER', 'SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(record[field])) {
userData[field] = 'USER'
} else {
userData[field] = record[field]
@@ -285,14 +267,10 @@ export default defineEventHandler(async (event) => {
provider: record.provider,
providerUserId: record.providerUserId,
providerUsername: record.providerUsername,
avatar: record.avatar || null,
createdAt: record.createdAt ? new Date(record.createdAt) : new Date()
}
if (
!shouldOverwriteSuperAdmin &&
preservedSuperAdminIds.has(Number(validIdentityUserId))
) {
if (!shouldOverwriteSuperAdmin && preservedSuperAdminIds.has(Number(validIdentityUserId))) {
stats.updated++
break
}
@@ -352,12 +330,10 @@ export default defineEventHandler(async (event) => {
const userStatusLogData = {
userId: validUserId,
username: record.username ?? null,
name: record.name ?? null,
oldStatus: record.oldStatus || record.previousStatus || null,
previousStatus: record.previousStatus || null,
newStatus: record.newStatus,
reason: record.reason || null,
operatorId: record.operatorId || record.changedBy || null,
changedBy: record.changedBy || null,
createdAt: record.createdAt ? new Date(record.createdAt) : new Date()
}
@@ -391,83 +367,6 @@ export default defineEventHandler(async (event) => {
break
}
case 'cardCodes': {
const cardCodeData: any = {}
const cardCodeFields = ['code', 'status', 'note']
cardCodeFields.forEach((field) => {
if (record.hasOwnProperty(field)) {
cardCodeData[field] = record[field]
}
})
if (!cardCodeData.code) return
const mapUserId = async (field: 'lockedBy' | 'redeemedBy') => {
if (!record[field]) return null
const mappedUserId = userIdMapping.get(record[field])
if (mappedUserId) return mappedUserId
if (mode === 'merge') return null
const userExists = await tx.query.users.findFirst({
where: eq(users.id, record[field])
})
return userExists ? record[field] : null
}
cardCodeData.lockedBy = await mapUserId('lockedBy')
cardCodeData.redeemedBy = await mapUserId('redeemedBy')
cardCodeData.lockedAt = record.lockedAt ? new Date(record.lockedAt) : null
cardCodeData.redeemedAt = record.redeemedAt ? new Date(record.redeemedAt) : null
cardCodeData.createdAt = record.createdAt ? new Date(record.createdAt) : new Date()
cardCodeData.updatedAt = record.updatedAt ? new Date(record.updatedAt) : new Date()
let restoredCardCode
if (mode === 'merge') {
const existing = await tx.query.cardCodes.findFirst({
where: eq(cardCodes.code, cardCodeData.code)
})
if (existing) {
restoredCardCode = (
await tx
.update(cardCodes)
.set(cardCodeData)
.where(eq(cardCodes.id, existing.id))
.returning()
)[0]
stats.updated++
} else {
restoredCardCode = (await tx.insert(cardCodes).values(cardCodeData).returning())[0]
stats.created++
}
} else {
const existing = await tx.query.cardCodes.findFirst({
where: eq(cardCodes.id, record.id)
})
if (existing) {
restoredCardCode = (
await tx
.update(cardCodes)
.set(cardCodeData)
.where(eq(cardCodes.id, record.id))
.returning()
)[0]
stats.updated++
} else {
restoredCardCode = (
await tx
.insert(cardCodes)
.values({ ...cardCodeData, id: record.id })
.returning()
)[0]
stats.created++
}
}
if (record.id && restoredCardCode?.id) {
newMappings.cardCodes[record.id] = restoredCardCode.id
}
break
}
case 'songs': {
let validRequesterId = record.requesterId
let validPreferredPlayTimeId = record.preferredPlayTimeId
@@ -498,25 +397,9 @@ export default defineEventHandler(async (event) => {
}
}
let validCardCodeId = record.cardCodeId || null
if (record.cardCodeId) {
const mappedCardCodeId = cardCodeIdMapping.get(record.cardCodeId)
if (mappedCardCodeId) {
validCardCodeId = mappedCardCodeId
} else {
const cardCodeExists = await tx.query.cardCodes.findFirst({
where: eq(cardCodes.id, record.cardCodeId)
})
if (!cardCodeExists) {
validCardCodeId = null
}
}
}
const songData: any = {
requesterId: validRequesterId,
preferredPlayTimeId: validPreferredPlayTimeId,
cardCodeId: validCardCodeId,
played: record.hasOwnProperty('played') ? record.played : false
}
@@ -527,10 +410,8 @@ export default defineEventHandler(async (event) => {
'cover',
'musicPlatform',
'musicId',
'durationSeconds',
'submissionNote',
'submissionNotePublic',
'submissionNotePublicStatus'
'submissionNotePublic'
]
songFields.forEach((field) => {
if (record.hasOwnProperty(field)) {
@@ -700,52 +581,10 @@ export default defineEventHandler(async (event) => {
break
}
case 'gradeClass': {
const gradeClassData: any = {}
const gradeClassFields = ['grade', 'class']
gradeClassFields.forEach((field) => {
if (record.hasOwnProperty(field)) {
gradeClassData[field] =
typeof record[field] === 'string' ? record[field].trim() : record[field]
}
})
// 空行直接跳过,避免空值配置行锁死配置优先校验
if (!gradeClassData.grade || !gradeClassData.class) {
stats.warnings.push(`gradeClass 记录 ${record.id ?? ''} 年级或班级为空,已跳过`)
break
}
if (mode === 'merge') {
const existingGradeClass = await tx.query.gradeClass.findFirst({
where: and(
eq(gradeClass.grade, gradeClassData.grade),
eq(gradeClass.class, gradeClassData.class)
)
})
if (!existingGradeClass) {
await tx.insert(gradeClass).values(gradeClassData)
stats.created++
}
} else {
const existing = await tx.query.gradeClass.findFirst({
where: eq(gradeClass.id, record.id)
})
if (!existing) {
await tx.insert(gradeClass).values({ ...gradeClassData, id: record.id })
stats.created++
}
}
break
}
case 'systemSettings': {
let systemSettingsData: any = {}
const systemSettingsData: any = {}
const fields = [
'enablePlayTimeSelection',
'instanceId',
'defaultTheme',
'enabledThemes',
'telemetryEnabled',
'siteTitle',
'siteLogoUrl',
'schoolLogoHomeUrl',
@@ -758,25 +597,14 @@ export default defineEventHandler(async (event) => {
'dailySubmissionLimit',
'weeklySubmissionLimit',
'monthlySubmissionLimit',
'scheduleDaysBeforeEnabled',
'scheduleDaysBefore',
'scheduleDaysAfterEnabled',
'scheduleDaysAfter',
'showBlacklistKeywords',
'hideStudentInfo',
'enableReplayRequests',
'enableCollaborativeSubmission',
'enableSubmissionRemarks',
'enableCardCodeRequests',
'requireCardCodeForRequests',
'enableCardCodeLimitBypass',
'enableSubmissionRestriction',
'submissionRestrictionScope',
'sameSongRestrictionHours',
'sameArtistRestrictionHours',
'enableRequestTimeLimitation',
'requestTimeLimitation',
'forceBlockAllRequests',
'forcePasswordChangeOnFirstLogin',
'smtpEnabled',
'smtpHost',
'smtpPort',
@@ -784,79 +612,11 @@ export default defineEventHandler(async (event) => {
'smtpUsername',
'smtpPassword',
'smtpFromEmail',
'smtpFromName',
'allowOAuthRegistration',
'allowRegister',
'registerRequiresApproval',
'registerEmailRequired',
'registerRequiresGradeClass',
'oauthRegisterRequiresApproval',
'submissionNoteRequiresApproval',
'oauthRedirectUri',
'oauthStateSecret',
'oauthProviders',
'githubOAuthEnabled',
'githubClientId',
'githubClientSecret',
'casdoorOAuthEnabled',
'casdoorServerUrl',
'casdoorClientId',
'casdoorClientSecret',
'casdoorOrganizationName',
'googleOAuthEnabled',
'googleClientId',
'googleClientSecret',
'aggregateOAuthEnabled',
'aggregateOAuthAppId',
'aggregateOAuthAppKey',
'aggregateOAuthLoginType',
'aggregateOAuthEndpoint',
'customOAuthEnabled',
'customOAuthDisplayName',
'customOAuthAuthorizeUrl',
'customOAuthTokenUrl',
'customOAuthUserInfoUrl',
'customOAuthScope',
'customOAuthClientId',
'customOAuthClientSecret',
'customOAuthUserIdField',
'customOAuthUsernameField',
'customOAuthNameField',
'customOAuthEmailField',
'customOAuthAvatarField',
'captchaEnabled',
'captchaMaxFailures',
'captchaProvider',
'turnstileSiteKey',
'turnstileSecretKey',
'autoBackupEnabled',
'autoBackupConfig',
'statisticsCodeEnabled',
'statisticsCode',
'enabledPlatforms',
'platformOrder'
'smtpFromName'
]
fields.forEach((field) => {
if (record.hasOwnProperty(field)) systemSettingsData[field] = record[field]
})
if (Object.prototype.hasOwnProperty.call(systemSettingsData, 'defaultTheme') || Object.prototype.hasOwnProperty.call(systemSettingsData, 'enabledThemes')) {
if (!Object.prototype.hasOwnProperty.call(systemSettingsData, 'defaultTheme') || !Object.prototype.hasOwnProperty.call(systemSettingsData, 'enabledThemes')) {
throw createApiError(400, SERVER_ERROR_CODES.THEME_INVALID_LIST, '主题配置必须同时包含默认主题和启用主题列表')
}
systemSettingsData.enabledThemes = JSON.stringify(validateThemeConfig(systemSettingsData.defaultTheme, systemSettingsData.enabledThemes))
}
for (const field of ['scheduleDaysBeforeEnabled', 'scheduleDaysAfterEnabled']) {
if (Object.prototype.hasOwnProperty.call(systemSettingsData, field) && systemSettingsData[field] !== null && typeof systemSettingsData[field] !== 'boolean') {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, `${field} 必须是布尔值`)
}
}
for (const field of ['scheduleDaysBefore', 'scheduleDaysAfter']) {
if (Object.prototype.hasOwnProperty.call(systemSettingsData, field) && (systemSettingsData[field] !== null && (!Number.isInteger(systemSettingsData[field]) || systemSettingsData[field] < 1 || systemSettingsData[field] > 730))) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, `${field} 必须是 1-730 的正整数`)
}
}
systemSettingsData = normalizeScheduleVisibilitySettings(systemSettingsData)
systemSettingsData = omitMaskedSystemSettingsSecrets(systemSettingsData)
if (mode === 'merge') {
const existing = await tx.query.systemSettings.findFirst()
@@ -888,49 +648,6 @@ export default defineEventHandler(async (event) => {
break
}
case 'emailTemplates': {
// 邮件模板(按 key 去重:merge 模式同名 key 覆盖)
if (!record.key || !record.name || !record.subject) return
const templateData: any = {
key: record.key,
name: record.name,
subject: record.subject,
html: record.html || ''
}
if (record.contentType) templateData.contentType = record.contentType
if (record.headerSubtitle) templateData.headerSubtitle = record.headerSubtitle
if (record.actionText) templateData.actionText = record.actionText
if (record.actionUrl) templateData.actionUrl = record.actionUrl
if (record.updatedByUserId) templateData.updatedByUserId = record.updatedByUserId
if (record.createdAt) templateData.createdAt = new Date(record.createdAt)
if (record.updatedAt) templateData.updatedAt = new Date(record.updatedAt)
if (mode === 'merge') {
const existing = await tx.query.emailTemplates.findFirst({
where: eq(emailTemplates.key, record.key)
})
if (existing) {
await tx.update(emailTemplates).set(templateData).where(eq(emailTemplates.id, existing.id))
stats.updated++
} else {
await tx.insert(emailTemplates).values(templateData)
stats.created++
}
} else {
const existing = await tx.query.emailTemplates.findFirst({
where: eq(emailTemplates.id, record.id)
})
if (existing) {
await tx.update(emailTemplates).set(templateData).where(eq(emailTemplates.id, record.id))
stats.updated++
} else {
await tx.insert(emailTemplates).values({ ...templateData, id: record.id })
stats.created++
}
}
break
}
case 'schedules': {
if (!record.songId || !record.playDate) return
@@ -1085,43 +802,11 @@ export default defineEventHandler(async (event) => {
}
const notificationData: any = { userId: validUserId }
const fields = [
'batchId',
'source',
'senderName',
'senderUsername',
'title',
'message',
'type',
'userDeleted'
]
const fields = ['title', 'message', 'type']
fields.forEach((field) => {
if (record.hasOwnProperty(field)) notificationData[field] = record[field]
})
// senderId 需要重新映射到目标库的用户 ID;映射未命中时回查同 ID 用户并比对用户名快照,
// 不一致则置空,避免跨库恢复时将发送人归属到错误用户,展示仍靠快照字段
if (Object.prototype.hasOwnProperty.call(record, 'senderId')) {
if (record.senderId) {
const mappedSenderId = userIdMapping.get(record.senderId)
if (mappedSenderId) {
notificationData.senderId = mappedSenderId
} else {
const senderExists = await tx.query.users.findFirst({
where: eq(users.id, record.senderId)
})
notificationData.senderId =
senderExists && senderExists.username === record.senderUsername
? record.senderId
: null
}
} else {
notificationData.senderId = null
}
}
notificationData.read = record.hasOwnProperty('read') ? record.read : false
notificationData.important = Object.prototype.hasOwnProperty.call(record, 'important')
? record.important
: false
notificationData.createdAt = record.createdAt ? new Date(record.createdAt) : new Date()
notificationData.updatedAt = record.updatedAt ? new Date(record.updatedAt) : new Date()
@@ -1219,79 +904,6 @@ export default defineEventHandler(async (event) => {
break
}
case 'cardCodeRedeemLogs': {
let validCardCodeId = record.cardCodeId || null
if (record.cardCodeId) {
const mappedCardCodeId = cardCodeIdMapping.get(record.cardCodeId)
if (mappedCardCodeId) {
validCardCodeId = mappedCardCodeId
} else {
const cardCodeExists = await tx.query.cardCodes.findFirst({
where: eq(cardCodes.id, record.cardCodeId)
})
if (!cardCodeExists) validCardCodeId = null
}
}
let validRedeemedBy = record.redeemedBy
if (record.redeemedBy) {
const mappedUserId = userIdMapping.get(record.redeemedBy)
if (mappedUserId) {
validRedeemedBy = mappedUserId
} else {
const userExists = await tx.query.users.findFirst({
where: eq(users.id, record.redeemedBy)
})
if (!userExists) return
}
} else return
let validSongId = record.songId || null
if (record.songId) {
const mappedSongId = songIdMapping.get(record.songId)
if (mappedSongId) {
validSongId = mappedSongId
} else {
const songExists = await tx.query.songs.findFirst({
where: eq(songs.id, record.songId)
})
if (!songExists) validSongId = null
}
}
const logData: any = {
cardCodeId: validCardCodeId,
codeSnapshot: record.codeSnapshot,
redeemedBy: validRedeemedBy,
redeemedAt: record.redeemedAt ? new Date(record.redeemedAt) : new Date(),
source: record.source || 'UNKNOWN',
songId: validSongId,
createdAt: record.createdAt ? new Date(record.createdAt) : new Date()
}
if (!logData.codeSnapshot) return
if (mode === 'merge') {
await tx.insert(cardCodeRedeemLogs).values(logData)
stats.created++
} else {
const existing = await tx.query.cardCodeRedeemLogs.findFirst({
where: eq(cardCodeRedeemLogs.id, record.id)
})
if (existing) {
await tx
.update(cardCodeRedeemLogs)
.set(logData)
.where(eq(cardCodeRedeemLogs.id, record.id))
stats.updated++
} else {
await tx.insert(cardCodeRedeemLogs).values({ ...logData, id: record.id })
stats.created++
}
}
break
}
case 'votes': {
let validUserId = record.userId
if (record.userId) {
@@ -1337,11 +949,6 @@ export default defineEventHandler(async (event) => {
}
break
}
case 'scheduleSongPool': {
await restoreScheduleSongPoolRecord(tx, record, songIdMapping, userIdMapping, stats, () => { stats.created++ })
break
}
}
})
stats.processed++
@@ -1352,35 +959,6 @@ export default defineEventHandler(async (event) => {
}
}
// 防锁死:恢复系统设置块后校验 SMTP 已配置,否则剥离 registerEmailRequired
// (避免恢复后注册必填邮箱却无法发码)
if (tableName === 'systemSettings') {
try {
const restoredSettings = await db.select().from(systemSettings).limit(1)
if (
restoredSettings[0]?.registerEmailRequired &&
(!restoredSettings[0]?.smtpEnabled || !restoredSettings[0]?.smtpHost)
) {
await db
.update(systemSettings)
.set({ registerEmailRequired: false })
.where(eq(systemSettings.id, restoredSettings[0].id))
stats.warnings.push('SMTP 未配置,已剥离 registerEmailRequired,避免注册邮箱流程不可用')
}
} catch (settingsError) {
console.error('恢复后设置一致性校验失败:', settingsError)
}
}
// 恢复可能携带显式 id,完成后同步所有自增序列,避免后续插入主键冲突
try {
await syncAllSequences()
} catch (seqError) {
const seqErrorMsg = seqError instanceof Error ? seqError.message : '未知错误'
stats.warnings.push(`自增序列同步失败: ${seqErrorMsg}`)
console.error('自增序列同步失败:', seqError)
}
return {
success: true,
newMappings,
+42 -505
View File
@@ -4,17 +4,13 @@ import {
apiKeyPermissions,
apiKeys,
apiLogs,
cardCodeRedeemLogs,
cardCodes,
collaborationLogs,
emailTemplates,
gradeClass,
notificationSettings,
notifications,
playTimes,
requestTimes,
schedules,
scheduleSongPool,
semesters,
songBlacklists,
songCollaborators,
@@ -28,23 +24,18 @@ import {
} from '~/drizzle/schema'
import { promises as fs } from 'fs'
import path from 'path'
import { CacheService } from '../../../services/cacheService'
import { SmtpService } from '../../../services/smtpService'
import { and, eq, inArray, isNull, notInArray, or } from 'drizzle-orm'
import { restoreScheduleSongPoolRecord } from '~~/server/utils/restoreScheduleSongPool'
import { omitMaskedSystemSettingsSecrets } from '~~/server/api/admin/system-settings/secretMask'
import { validateThemeConfig } from '~~/server/utils/theme-config'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
import { normalizeScheduleVisibilitySettings } from '~~/server/utils/system-settings-defaults'
export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有超级管理员可以恢复备份'
statusMessage: '权限不足'
})
}
@@ -63,7 +54,7 @@ export default defineEventHandler(async (event) => {
if (!formData) {
throw createError({
statusCode: 400,
message: '请上传备份文件'
statusMessage: '请上传备份文件'
})
}
@@ -83,7 +74,7 @@ export default defineEventHandler(async (event) => {
if (!fileData) {
throw createError({
statusCode: 400,
message: '请上传备份文件'
statusMessage: '请上传备份文件'
})
}
@@ -92,7 +83,7 @@ export default defineEventHandler(async (event) => {
} catch (error) {
throw createError({
statusCode: 400,
message: '备份文件格式错误'
statusMessage: '备份文件格式错误'
})
}
@@ -110,7 +101,7 @@ export default defineEventHandler(async (event) => {
if (!filename) {
throw createError({
statusCode: 400,
message: '请指定备份文件名或上传备份文件'
statusMessage: '请指定备份文件名或上传备份文件'
})
}
@@ -118,7 +109,7 @@ export default defineEventHandler(async (event) => {
if (safeFilename !== filename) {
throw createError({
statusCode: 400,
message: '备份文件名不合法'
statusMessage: '备份文件名不合法'
})
}
@@ -126,7 +117,7 @@ export default defineEventHandler(async (event) => {
clearExisting = bodyClearExisting
overwriteSuperAdmin = bodyOverwriteSuperAdmin
console.log(`开始恢复数据库备份:${safeFilename}`)
console.log(`开始恢复数据库备份: ${safeFilename}`)
// 读取备份文件
const backupDir = path.join(process.cwd(), 'backups')
@@ -138,7 +129,7 @@ export default defineEventHandler(async (event) => {
} catch (error) {
throw createError({
statusCode: 404,
message: '备份文件不存在或格式错误'
statusMessage: '备份文件不存在或格式错误'
})
}
}
@@ -147,12 +138,12 @@ export default defineEventHandler(async (event) => {
if (!backupData.metadata || !backupData.data) {
throw createError({
statusCode: 400,
message: '备份文件格式无效'
statusMessage: '备份文件格式无效'
})
}
console.log(`备份文件信息:`)
console.log(`- 版本:${backupData.metadata.version}`)
console.log(`- 版本: ${backupData.metadata.version}`)
console.log(`- 创建时间: ${backupData.metadata.timestamp}`)
console.log(`- 创建者: ${backupData.metadata.creator}`)
console.log(`- 总记录数: ${backupData.metadata.totalRecords}`)
@@ -162,7 +153,7 @@ export default defineEventHandler(async (event) => {
if (overwriteSuperAdmin && user.role !== 'SUPER_ADMIN') {
throw createError({
statusCode: 403,
message: '仅超级管理员可以覆盖超级管理员账号数据'
statusMessage: '仅超级管理员可以覆盖超级管理员账号数据'
})
}
const shouldOverwriteSuperAdmin =
@@ -190,15 +181,12 @@ export default defineEventHandler(async (event) => {
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(scheduleSongPool)
await db.delete(schedules)
await db.delete(votes)
await db.delete(songs)
await db.delete(cardCodes)
await db.delete(songBlacklists)
await db.delete(userStatusLogs)
await db.delete(emailTemplates)
@@ -239,23 +227,15 @@ export default defineEventHandler(async (event) => {
}
if (preservedUserIdList.length > 0) {
await db.delete(cardCodeRedeemLogs)
await db.delete(apiKeys).where(notInArray(apiKeys.createdByUserId, preservedUserIdList))
await db
.delete(notifications)
.where(notInArray(notifications.userId, preservedUserIdList))
await db.delete(notifications).where(notInArray(notifications.userId, preservedUserIdList))
await db
.delete(notificationSettings)
.where(notInArray(notificationSettings.userId, preservedUserIdList))
await db
.delete(userStatusLogs)
.where(notInArray(userStatusLogs.userId, preservedUserIdList))
await db
.delete(userIdentities)
.where(notInArray(userIdentities.userId, preservedUserIdList))
await db.delete(userStatusLogs).where(notInArray(userStatusLogs.userId, preservedUserIdList))
await db.delete(userIdentities).where(notInArray(userIdentities.userId, preservedUserIdList))
await db.delete(users).where(notInArray(users.id, preservedUserIdList))
} else {
await db.delete(cardCodeRedeemLogs)
await db.delete(apiKeys)
await db.delete(notifications)
await db.delete(notificationSettings)
@@ -267,11 +247,9 @@ export default defineEventHandler(async (event) => {
await db.delete(collaborationLogs)
await db.delete(songCollaborators)
await db.delete(songReplayRequests)
await db.delete(scheduleSongPool)
await db.delete(schedules)
await db.delete(votes)
await db.delete(songs)
await db.delete(cardCodes)
await db.delete(songBlacklists)
await db.delete(emailTemplates)
await db.delete(playTimes)
@@ -289,7 +267,6 @@ export default defineEventHandler(async (event) => {
// 建立ID映射表
const userIdMapping = new Map() // 备份ID -> 当前数据库ID
const songIdMapping = new Map() // 备份ID -> 当前数据库ID
const cardCodeIdMapping = new Map() // 备份ID -> 当前数据库ID
// 定义恢复顺序(考虑外键依赖)
const restoreOrder = [
@@ -297,21 +274,17 @@ export default defineEventHandler(async (event) => {
'playTimes',
'semesters',
'requestTimes',
'gradeClass',
'users',
'userIdentities',
'emailTemplates',
'userStatusLogs',
'songBlacklist',
'cardCodes',
'songs',
'songCollaborators',
'collaborationLogs',
'songReplayRequests',
'votes',
'schedules',
'scheduleSongPool',
'cardCodeRedeemLogs',
'notificationSettings',
'notifications',
'apiKeys',
@@ -349,10 +322,6 @@ export default defineEventHandler(async (event) => {
try {
await db.$transaction(
async (tx) => {
const stats = {
created: 0,
warnings: restoreResults.details.warnings
}
// 根据表名选择恢复策略
switch (tableName) {
case 'users':
@@ -371,10 +340,7 @@ export default defineEventHandler(async (event) => {
'forcePasswordChange',
'meowNickname',
'status',
'statusChangedBy',
'remark',
'avatarProvider',
'avatarProviderUserId'
'statusChangedBy'
]
// 处理日期字段
@@ -393,7 +359,7 @@ export default defineEventHandler(async (event) => {
// 确保角色为有效的硬编码角色
if (field === 'role') {
if (
!['USER', 'SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(
!['USER', 'TEACHER', 'SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(
record[field]
)
) {
@@ -483,8 +449,7 @@ export default defineEventHandler(async (event) => {
}
} else {
const isBackupSuperAdminRecord =
record.role === 'SUPER_ADMIN' ||
preservedSuperAdminIds.has(Number(record.id))
record.role === 'SUPER_ADMIN' || preservedSuperAdminIds.has(Number(record.id))
if (!shouldOverwriteSuperAdmin && isBackupSuperAdminRecord) {
if (record.id) {
const existingProtectedUser = await tx
@@ -494,12 +459,12 @@ export default defineEventHandler(async (event) => {
.limit(1)
if (existingProtectedUser.length > 0) {
userIdMapping.set(record.id, existingProtectedUser[0].id)
restoreResults.details.warnings.push(
`已保留超级管理员账户 ${record.username || `#${record.id}`}`
)
break
}
}
restoreResults.details.warnings.push(
`已保留超级管理员账户 ${record.username || `#${record.id}`}`
)
break
}
// 完全恢复模式,检查ID是否已存在
@@ -600,7 +565,6 @@ export default defineEventHandler(async (event) => {
provider: record.provider,
providerUserId: record.providerUserId,
providerUsername: record.providerUsername,
avatar: record.avatar || null,
createdAt: record.createdAt ? new Date(record.createdAt) : new Date()
}
@@ -671,11 +635,9 @@ export default defineEventHandler(async (event) => {
return // 跳过此记录,因为userId是必需的
}
// 构建用户状态日志数据(含快照列,删除用户后审计可追溯)
// 构建用户状态日志数据
const userStatusLogData = {
userId: validUserStatusLogUserId,
username: record.username ?? null,
name: record.name ?? null,
oldStatus: record.oldStatus || record.previousStatus || null,
newStatus: record.newStatus,
reason: record.reason || null,
@@ -716,98 +678,6 @@ export default defineEventHandler(async (event) => {
}
break
case 'cardCodes': {
const cardCodeData: any = {}
const cardCodeFields = ['code', 'status', 'note']
cardCodeFields.forEach((field) => {
if (record.hasOwnProperty(field)) {
cardCodeData[field] = record[field]
}
})
if (!cardCodeData.code) {
console.warn('点歌券缺少 code,跳过此记录')
return
}
const mapUserId = async (field: 'lockedBy' | 'redeemedBy') => {
if (!record[field]) return null
const mappedUserId = userIdMapping.get(record[field])
if (mappedUserId) return mappedUserId
if (mode === 'merge') return null
const userExists = await tx
.select({ id: users.id })
.from(users)
.where(eq(users.id, record[field]))
.limit(1)
return userExists.length > 0 ? record[field] : null
}
cardCodeData.lockedBy = await mapUserId('lockedBy')
cardCodeData.redeemedBy = await mapUserId('redeemedBy')
cardCodeData.lockedAt = record.lockedAt ? new Date(record.lockedAt) : null
cardCodeData.redeemedAt = record.redeemedAt
? new Date(record.redeemedAt)
: null
cardCodeData.createdAt = record.createdAt
? new Date(record.createdAt)
: new Date()
cardCodeData.updatedAt = record.updatedAt
? new Date(record.updatedAt)
: new Date()
let restoredCardCode
if (mode === 'merge') {
const existingCardCode = await tx
.select()
.from(cardCodes)
.where(eq(cardCodes.code, cardCodeData.code))
.limit(1)
if (existingCardCode.length > 0) {
restoredCardCode = (
await tx
.update(cardCodes)
.set(cardCodeData)
.where(eq(cardCodes.id, existingCardCode[0].id))
.returning()
)[0]
} else {
restoredCardCode = (
await tx.insert(cardCodes).values(cardCodeData).returning()
)[0]
}
} else {
const existingCardCodeWithId = await tx
.select()
.from(cardCodes)
.where(eq(cardCodes.id, record.id))
.limit(1)
if (existingCardCodeWithId.length > 0) {
restoredCardCode = (
await tx
.update(cardCodes)
.set(cardCodeData)
.where(eq(cardCodes.id, record.id))
.returning()
)[0]
} else {
restoredCardCode = (
await tx
.insert(cardCodes)
.values({ ...cardCodeData, id: record.id })
.returning()
)[0]
}
}
if (record.id && restoredCardCode?.id) {
cardCodeIdMapping.set(record.id, restoredCardCode.id)
}
break
}
case 'songs':
// 验证外键约束
let validRequesterId = record.requesterId
@@ -852,31 +722,10 @@ export default defineEventHandler(async (event) => {
}
}
let validCardCodeId = record.cardCodeId || null
if (record.cardCodeId) {
const mappedCardCodeId = cardCodeIdMapping.get(record.cardCodeId)
if (mappedCardCodeId) {
validCardCodeId = mappedCardCodeId
} else {
const cardCodeExists = await tx
.select({ id: cardCodes.id })
.from(cardCodes)
.where(eq(cardCodes.id, record.cardCodeId))
.limit(1)
if (cardCodeExists.length === 0) {
console.warn(
`歌曲 ${record.title} 的点歌券ID ${record.cardCodeId} 不存在,将设为null`
)
validCardCodeId = null
}
}
}
// 动态构建歌曲数据,自动跳过不存在的字段
const songData = {
requesterId: validRequesterId, // 必需字段
preferredPlayTimeId: validPreferredPlayTimeId, // 已验证的字段
cardCodeId: validCardCodeId
preferredPlayTimeId: validPreferredPlayTimeId // 已验证的字段
}
// 基本字段
@@ -887,10 +736,8 @@ export default defineEventHandler(async (event) => {
'cover',
'musicPlatform',
'musicId',
'durationSeconds',
'submissionNote',
'submissionNotePublic',
'submissionNotePublicStatus'
'submissionNotePublic'
]
songFields.forEach((field) => {
if (record.hasOwnProperty(field)) {
@@ -1134,58 +981,11 @@ export default defineEventHandler(async (event) => {
}
break
case 'gradeClass':
// 年级班级配置(唯一约束 grade+class)
const gradeClassData = {
grade: typeof record.grade === 'string' ? record.grade.trim() : '',
class: typeof record.class === 'string' ? record.class.trim() : ''
}
// 空行直接跳过,避免空值配置行锁死配置优先校验
if (!gradeClassData.grade || !gradeClassData.class) {
stats.warnings.push(`gradeClass 记录 ${record.id ?? ''} 年级或班级为空,已跳过`)
break
}
if (mode === 'merge') {
const existingGradeClass = await tx
.select()
.from(gradeClass)
.where(
and(
eq(gradeClass.grade, gradeClassData.grade),
eq(gradeClass.class, gradeClassData.class)
)
)
.limit(1)
if (existingGradeClass.length === 0) {
await tx.insert(gradeClass).values(gradeClassData)
}
} else {
// 完全恢复模式,检查ID是否已存在
const existingGradeClassWithId = await tx
.select()
.from(gradeClass)
.where(eq(gradeClass.id, record.id))
.limit(1)
if (existingGradeClassWithId.length === 0) {
await tx.insert(gradeClass).values({
...gradeClassData,
id: record.id
})
}
}
break
case 'systemSettings':
// 动态构建系统设置数据,自动跳过不存在的字段
let systemSettingsData = {}
const systemSettingsData = {}
const systemSettingsFields = [
'enablePlayTimeSelection',
'instanceId',
'defaultTheme',
'enabledThemes',
'telemetryEnabled',
'siteTitle',
'siteLogoUrl',
'schoolLogoHomeUrl',
@@ -1198,24 +998,13 @@ export default defineEventHandler(async (event) => {
'dailySubmissionLimit',
'weeklySubmissionLimit',
'monthlySubmissionLimit',
'scheduleDaysBeforeEnabled',
'scheduleDaysBefore',
'scheduleDaysAfterEnabled',
'scheduleDaysAfter',
'showBlacklistKeywords',
'enableRequestTimeLimitation',
'requestTimeLimitation',
'forceBlockAllRequests',
'forcePasswordChangeOnFirstLogin',
'enableReplayRequests',
'enableCollaborativeSubmission',
'enableSubmissionRemarks',
'enableCardCodeRequests',
'requireCardCodeForRequests',
'enableCardCodeLimitBypass',
'enableSubmissionRestriction',
'submissionRestrictionScope',
'sameSongRestrictionHours',
'sameArtistRestrictionHours',
'hideStudentInfo',
'smtpEnabled',
'smtpHost',
@@ -1224,57 +1013,7 @@ export default defineEventHandler(async (event) => {
'smtpUsername',
'smtpPassword',
'smtpFromEmail',
'smtpFromName',
'allowOAuthRegistration',
'allowRegister',
'registerRequiresApproval',
'registerEmailRequired',
'registerRequiresGradeClass',
'oauthRegisterRequiresApproval',
'submissionNoteRequiresApproval',
'oauthRedirectUri',
'oauthStateSecret',
'oauthProviders',
'githubOAuthEnabled',
'githubClientId',
'githubClientSecret',
'casdoorOAuthEnabled',
'casdoorServerUrl',
'casdoorClientId',
'casdoorClientSecret',
'casdoorOrganizationName',
'googleOAuthEnabled',
'googleClientId',
'googleClientSecret',
'aggregateOAuthEnabled',
'aggregateOAuthAppId',
'aggregateOAuthAppKey',
'aggregateOAuthLoginType',
'aggregateOAuthEndpoint',
'customOAuthEnabled',
'customOAuthDisplayName',
'customOAuthAuthorizeUrl',
'customOAuthTokenUrl',
'customOAuthUserInfoUrl',
'customOAuthScope',
'customOAuthClientId',
'customOAuthClientSecret',
'customOAuthUserIdField',
'customOAuthUsernameField',
'customOAuthNameField',
'customOAuthEmailField',
'customOAuthAvatarField',
'captchaEnabled',
'captchaMaxFailures',
'captchaProvider',
'turnstileSiteKey',
'turnstileSecretKey',
'autoBackupEnabled',
'autoBackupConfig',
'statisticsCodeEnabled',
'statisticsCode',
'enabledPlatforms',
'platformOrder'
'smtpFromName'
]
// 只添加备份数据中存在的字段
@@ -1283,24 +1022,6 @@ export default defineEventHandler(async (event) => {
systemSettingsData[field] = record[field]
}
})
if (Object.prototype.hasOwnProperty.call(systemSettingsData, 'defaultTheme') || Object.prototype.hasOwnProperty.call(systemSettingsData, 'enabledThemes')) {
if (!Object.prototype.hasOwnProperty.call(systemSettingsData, 'defaultTheme') || !Object.prototype.hasOwnProperty.call(systemSettingsData, 'enabledThemes')) {
throw createApiError(400, SERVER_ERROR_CODES.THEME_INVALID_LIST, '主题配置必须同时包含默认主题和启用主题列表')
}
systemSettingsData.enabledThemes = JSON.stringify(validateThemeConfig(systemSettingsData.defaultTheme, systemSettingsData.enabledThemes))
}
for (const field of ['scheduleDaysBeforeEnabled', 'scheduleDaysAfterEnabled']) {
if (Object.prototype.hasOwnProperty.call(systemSettingsData, field) && systemSettingsData[field] !== null && typeof systemSettingsData[field] !== 'boolean') {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, `${field} 必须是布尔值`)
}
}
for (const field of ['scheduleDaysBefore', 'scheduleDaysAfter']) {
if (Object.prototype.hasOwnProperty.call(systemSettingsData, field) && (systemSettingsData[field] !== null && (!Number.isInteger(systemSettingsData[field]) || systemSettingsData[field] < 1 || systemSettingsData[field] > 730))) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, `${field} 必须是 1-730 的正整数`)
}
}
systemSettingsData = normalizeScheduleVisibilitySettings(systemSettingsData)
systemSettingsData = omitMaskedSystemSettingsSecrets(systemSettingsData)
if (mode === 'merge') {
// 检查是否存在系统设置记录(通常只有一条记录)
@@ -1407,26 +1128,6 @@ export default defineEventHandler(async (event) => {
? record.sequence
: 1
// 重播申请绑定:申请先于排期恢复,校验绑定存在且属于同一首歌,避免悬空绑定
let validScheduleReplayRequestId = null
if (record.replayRequestId) {
const boundReplayRequest = await tx
.select({
id: songReplayRequests.id,
songId: songReplayRequests.songId
})
.from(songReplayRequests)
.where(eq(songReplayRequests.id, record.replayRequestId))
.limit(1)
if (
boundReplayRequest.length > 0 &&
boundReplayRequest[0].songId === validSongId
) {
validScheduleReplayRequestId = record.replayRequestId
}
}
scheduleData.replayRequestId = validScheduleReplayRequestId
if (mode === 'merge') {
// 检查是否存在相同的排期(按歌曲ID和播放日期)
const existingSchedule = await tx
@@ -1603,16 +1304,7 @@ export default defineEventHandler(async (event) => {
// 动态构建通知数据,自动跳过不存在的字段
const notificationData = { userId: validNotificationUserId }
const notificationDataFields = [
'batchId',
'source',
'senderName',
'senderUsername',
'title',
'message',
'type',
'userDeleted'
]
const notificationDataFields = ['title', 'message', 'type']
notificationDataFields.forEach((field) => {
if (record.hasOwnProperty(field)) {
@@ -1620,38 +1312,8 @@ export default defineEventHandler(async (event) => {
}
})
// senderId 需要重新映射到目标库的用户 ID;映射未命中时回查同 ID 用户并比对用户名快照,
// 不一致则置空,避免跨库恢复时将发送人归属到错误用户,展示仍靠快照字段
if (Object.prototype.hasOwnProperty.call(record, 'senderId')) {
if (record.senderId) {
const mappedSenderId = userIdMapping.get(record.senderId)
if (mappedSenderId) {
notificationData.senderId = mappedSenderId
} else {
const senderExists = await tx
.select({ id: users.id, username: users.username })
.from(users)
.where(eq(users.id, record.senderId))
.limit(1)
notificationData.senderId =
senderExists.length > 0 &&
senderExists[0].username === record.senderUsername
? record.senderId
: null
}
} else {
notificationData.senderId = null
}
}
// 布尔字段,提供默认值
notificationData.read = record.hasOwnProperty('read') ? record.read : false
notificationData.important = Object.prototype.hasOwnProperty.call(
record,
'important'
)
? record.important
: false
// 日期字段
notificationData.createdAt = record.createdAt
@@ -1864,28 +1526,9 @@ export default defineEventHandler(async (event) => {
if (mappedSongId) validReplaySongId = mappedSongId
}
// 校验期望播出时段是否存在(可选字段)
let validReplayPlayTimeId = record.preferredPlayTimeId || null
if (validReplayPlayTimeId) {
const replayPlayTimeExists = await tx
.select({ id: playTimes.id })
.from(playTimes)
.where(eq(playTimes.id, validReplayPlayTimeId))
.limit(1)
if (replayPlayTimeExists.length === 0) {
validReplayPlayTimeId = null
}
}
// 保留原始状态与申请元数据,避免历史已履行记录恢复为 PENDING 触发部分唯一索引冲突
const replayData = {
songId: validReplaySongId,
userId: validReplayUserId,
status: record.status || 'PENDING',
preferredPlayTimeId: validReplayPlayTimeId,
submissionNote: record.submissionNote ?? null,
submissionNotePublic: record.submissionNotePublic === true,
submissionNotePublicStatus: record.submissionNotePublicStatus ?? null,
createdAt: record.createdAt ? new Date(record.createdAt) : new Date(),
updatedAt: record.updatedAt ? new Date(record.updatedAt) : new Date()
}
@@ -1920,101 +1563,6 @@ export default defineEventHandler(async (event) => {
}
break
case 'cardCodeRedeemLogs': {
let validCardCodeId = record.cardCodeId || null
if (record.cardCodeId) {
const mappedCardCodeId = cardCodeIdMapping.get(record.cardCodeId)
if (mappedCardCodeId) {
validCardCodeId = mappedCardCodeId
} else {
const cardCodeExists = await tx
.select({ id: cardCodes.id })
.from(cardCodes)
.where(eq(cardCodes.id, record.cardCodeId))
.limit(1)
if (cardCodeExists.length === 0) {
validCardCodeId = null
}
}
}
let validRedeemedBy = record.redeemedBy
if (record.redeemedBy) {
const mappedUserId = userIdMapping.get(record.redeemedBy)
if (mappedUserId) {
validRedeemedBy = mappedUserId
} else {
const userExists = await tx
.select({ id: users.id })
.from(users)
.where(eq(users.id, record.redeemedBy))
.limit(1)
if (userExists.length === 0) {
console.warn(
`点歌券日志的操作用户ID ${record.redeemedBy} 不存在,跳过此记录`
)
return
}
}
} else {
console.warn('点歌券日志缺少redeemedBy,跳过此记录')
return
}
let validLogSongId = record.songId || null
if (record.songId) {
const mappedSongId = songIdMapping.get(record.songId)
if (mappedSongId) {
validLogSongId = mappedSongId
} else {
const songExists = await tx
.select({ id: songs.id })
.from(songs)
.where(eq(songs.id, record.songId))
.limit(1)
if (songExists.length === 0) {
validLogSongId = null
}
}
}
const logData = {
cardCodeId: validCardCodeId,
codeSnapshot: record.codeSnapshot,
redeemedBy: validRedeemedBy,
redeemedAt: record.redeemedAt ? new Date(record.redeemedAt) : new Date(),
source: record.source || 'UNKNOWN',
songId: validLogSongId,
createdAt: record.createdAt ? new Date(record.createdAt) : new Date()
}
if (!logData.codeSnapshot) {
console.warn('点歌券日志缺少codeSnapshot,跳过此记录')
return
}
if (mode === 'merge') {
await tx.insert(cardCodeRedeemLogs).values(logData)
} else {
const existingLogWithId = await tx
.select()
.from(cardCodeRedeemLogs)
.where(eq(cardCodeRedeemLogs.id, record.id))
.limit(1)
if (existingLogWithId.length > 0) {
await tx
.update(cardCodeRedeemLogs)
.set(logData)
.where(eq(cardCodeRedeemLogs.id, record.id))
} else {
await tx
.insert(cardCodeRedeemLogs)
.values({ ...logData, id: record.id })
}
}
break
}
case 'votes':
// 验证外键约束
let validVoteUserId = record.userId
@@ -2120,11 +1668,6 @@ export default defineEventHandler(async (event) => {
}
break
case 'scheduleSongPool': {
await restoreScheduleSongPoolRecord(tx, record, songIdMapping, userIdMapping, stats)
break
}
case 'requestTimes':
// requestTimes表没有外键依赖
const requestTimeData = {
@@ -2572,23 +2115,6 @@ export default defineEventHandler(async (event) => {
console.log(`📊 处理了 ${restoreResults.details.tablesProcessed} 个表`)
console.log(`📊 恢复了 ${restoreResults.details.recordsRestored} 条记录`)
// 防锁死:恢复开关前校验 SMTP 已配置,否则剥离 registerEmailRequired(避免恢复后注册必填邮箱却无法发码)
try {
const restoredSettings = await db.select().from(systemSettings).limit(1)
if (
restoredSettings[0]?.registerEmailRequired &&
(!restoredSettings[0]?.smtpEnabled || !restoredSettings[0]?.smtpHost)
) {
await db
.update(systemSettings)
.set({ registerEmailRequired: false })
.where(eq(systemSettings.id, restoredSettings[0].id))
console.warn('⚠️ SMTP 未配置,已剥离 registerEmailRequired,避免注册邮箱流程不可用')
}
} catch (settingsError) {
console.error('恢复后设置一致性校验失败:', settingsError)
}
// 重置所有自增序列
console.log(`🔄 开始重置自增序列...`)
const sequenceResetResults = []
@@ -2647,6 +2173,17 @@ export default defineEventHandler(async (event) => {
restoreResults.message = '数据恢复完成,但存在错误'
}
// 清除所有缓存
try {
const cacheService = CacheService.getInstance()
await cacheService.clearAllCaches()
console.log('数据恢复后缓存已清除')
} catch (cacheError) {
console.warn('清除缓存失败:', cacheError)
restoreResults.details.warnings = restoreResults.details.warnings || []
restoreResults.details.warnings.push('清除缓存失败')
}
try {
const smtpService = SmtpService.getInstance()
const initialized = await smtpService.initializeSmtpConfig()
@@ -2666,7 +2203,7 @@ export default defineEventHandler(async (event) => {
console.error('恢复数据库备份失败:', error)
throw createError({
statusCode: error.statusCode || 500,
message: error.message || '恢复数据库备份失败'
statusMessage: error.statusMessage || '恢复数据库备份失败'
})
}
})
+8 -8
View File
@@ -7,10 +7,10 @@ export default defineEventHandler(async (event) => {
try {
// 验证管理员权限
const user = event.context.user
if (!user || user.role !== 'SUPER_ADMIN') {
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有超级管理员可以上传备份文件'
statusMessage: '权限不足'
})
}
@@ -28,7 +28,7 @@ export default defineEventHandler(async (event) => {
keepExtensions: true,
maxFileSize: 100 * 1024 * 1024, // 100MB
filter: ({ mimetype, originalFilename }) => {
// 只允许 JSON 文件
// 只允许JSON文件
return (
mimetype === 'application/json' ||
(originalFilename && originalFilename.endsWith('.json'))
@@ -41,7 +41,7 @@ export default defineEventHandler(async (event) => {
if (!files.file || !files.file[0]) {
throw createError({
statusCode: 400,
message: '请选择要上传的备份文件'
statusMessage: '请选择要上传的备份文件'
})
}
@@ -57,7 +57,7 @@ export default defineEventHandler(async (event) => {
await fs.unlink(uploadedFile.filepath)
throw createError({
statusCode: 400,
message: '备份文件格式无效'
statusMessage: '备份文件格式无效'
})
}
@@ -66,7 +66,7 @@ export default defineEventHandler(async (event) => {
await fs.unlink(uploadedFile.filepath)
throw createError({
statusCode: 400,
message: '不是有效的备份文件'
statusMessage: '不是有效的备份文件'
})
}
@@ -106,7 +106,7 @@ export default defineEventHandler(async (event) => {
// 重新保存文件
await fs.writeFile(newFilepath, JSON.stringify(backupData, null, 2), 'utf8')
console.log(`管理员 ${user.username} 上传了备份文件:${newFilename}`)
console.log(`管理员 ${user.username} 上传了备份文件: ${newFilename}`)
// 获取文件信息
const stats = await fs.stat(newFilepath)
@@ -127,7 +127,7 @@ export default defineEventHandler(async (event) => {
console.error('上传备份文件失败:', error)
throw createError({
statusCode: error.statusCode || 500,
message: error.message || '上传备份文件失败'
statusMessage: error.statusMessage || '上传备份文件失败'
})
}
})