feat: 迁移自定义功能至 1.5.9.9 版本
包含音乐源限制、CI 防 OOM 优化、数据库连接池优化、管理端权限及 UI 细节定制等功能
This commit is contained in:
@@ -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 || '恢复数据库备份失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user