Files
BYW-VoiceHub/server/api/admin/system-settings/index.post.ts
T

1128 lines
41 KiB
TypeScript

import { db } from '~/drizzle/db'
import { systemSettings } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { SMTP_PASSWORD_MASK, SECRET_FIELD_MASK, maskSystemSettingsSecrets } from './secretMask'
import { SYSTEM_SETTINGS_DEFAULTS } from '~~/server/utils/system-settings-defaults'
import {
getAggregateOAuthLoginTypesOrDefault,
isSafeAggregateOAuthUrl,
normalizeAggregateOAuthLoginTypes
} from '~~/server/utils/oauth-providers'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES, MUSIC_SOURCE_PLATFORMS, DEFAULT_THEMES } from '~~/server/config/constants'
import { parseThemeArray, validateThemeConfig } from '~~/server/utils/theme-config'
import { fetchGradeClassOptions } from '~~/server/utils/grade-class-options'
/**
* 解析数据库中存储的平台数组(历史脏数据/异常写入时回退默认值)
*/
const parsePlatformStored = (value: unknown): string[] => {
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value
return Array.isArray(parsed) && parsed.length > 0 ? (parsed as string[]) : [...MUSIC_SOURCE_PLATFORMS]
} catch {
return [...MUSIC_SOURCE_PLATFORMS]
}
}
/**
* 校验平台数组 JSON 字符串
* @param fieldName 字段名(用于错误消息)
* @param value 原始值(应为 JSON 字符串)
* @param requireUnique 是否要求无重复(仅 platformOrder 需要)
* @returns 校验后的数组
*/
const validatePlatformArray = (fieldName: string, value: unknown, requireUnique = false): string[] => {
if (typeof value !== 'string') {
throw createApiError(
400,
SERVER_ERROR_CODES.COMMON_INVALID_PARAMS,
`${fieldName} 必须是 JSON 字符串`
)
}
let parsed: unknown
try {
parsed = JSON.parse(value)
} catch {
throw createApiError(
400,
SERVER_ERROR_CODES.COMMON_INVALID_PARAMS,
`${fieldName} 格式无效,应为合法 JSON 数组`
)
}
if (!Array.isArray(parsed)) {
throw createApiError(
400,
SERVER_ERROR_CODES.COMMON_INVALID_PARAMS,
`${fieldName} 必须是数组`
)
}
const invalid = parsed.filter((p: unknown) => !(MUSIC_SOURCE_PLATFORMS as readonly string[]).includes(p as string))
if (invalid.length > 0) {
throw createApiError(
400,
SERVER_ERROR_CODES.COMMON_INVALID_PARAMS,
`${fieldName} 包含无效的平台: ${(invalid as string[]).join(', ')}`
)
}
if (parsed.length === 0) {
throw createApiError(
400,
SERVER_ERROR_CODES.COMMON_INVALID_PARAMS,
`${fieldName} 至少保留一个平台`
)
}
if (requireUnique && parsed.length !== new Set(parsed as string[]).size) {
throw createApiError(
400,
SERVER_ERROR_CODES.COMMON_INVALID_PARAMS,
`${fieldName} 中不能包含重复的平台`
)
}
return parsed as string[]
}
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
const user = event.context.user
if (!user) {
throw createError({
statusCode: 401,
message: '未授权访问'
})
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
statusCode: 403,
message: '只有管理员才能更新系统设置'
})
}
try {
const body = await readBody(event)
// 验证请求体
const updateData: any = {}
// 获取当前设置,用于验证依赖配置的完整性
const settingsResult = await db.select().from(systemSettings).limit(1)
let settings = settingsResult[0]
if (body.defaultTheme !== undefined || body.enabledThemes !== undefined) {
if (user.role !== 'SUPER_ADMIN') {
// 主题设置仅超级管理员可修改:普通管理员请求剥离主题字段,其余配置照常处理
delete body.defaultTheme
delete body.enabledThemes
} else {
const enabledThemes = body.enabledThemes !== undefined
? validateThemeConfig(body.defaultTheme ?? settings?.defaultTheme ?? 'System', body.enabledThemes)
: parseThemeArray(settings?.enabledThemes, DEFAULT_THEMES)
const defaultTheme = body.defaultTheme !== undefined ? body.defaultTheme : settings?.defaultTheme || 'System'
validateThemeConfig(defaultTheme, JSON.stringify(enabledThemes))
if (body.defaultTheme !== undefined) updateData.defaultTheme = defaultTheme
updateData.enabledThemes = JSON.stringify(enabledThemes)
}
}
if (body.telemetryEnabled !== undefined) {
if (typeof body.telemetryEnabled !== 'boolean') {
throw createError({
statusCode: 400,
message: 'telemetryEnabled 必须是布尔值'
})
}
updateData.telemetryEnabled = body.telemetryEnabled
}
if (body.hideStudentInfo !== undefined) {
if (typeof body.hideStudentInfo !== 'boolean') {
throw createError({
statusCode: 400,
message: 'hideStudentInfo 必须是布尔值'
})
}
updateData.hideStudentInfo = body.hideStudentInfo
}
if (body.enablePlayTimeSelection !== undefined) {
if (typeof body.enablePlayTimeSelection !== 'boolean') {
throw createError({
statusCode: 400,
message: 'enablePlayTimeSelection 必须是布尔值'
})
}
updateData.enablePlayTimeSelection = body.enablePlayTimeSelection
}
if (body.siteTitle !== undefined) {
updateData.siteTitle = body.siteTitle
}
if (body.siteLogoUrl !== undefined) {
updateData.siteLogoUrl = body.siteLogoUrl
}
if (body.schoolLogoHomeUrl !== undefined) {
updateData.schoolLogoHomeUrl = body.schoolLogoHomeUrl
}
if (body.schoolLogoPrintUrl !== undefined) {
updateData.schoolLogoPrintUrl = body.schoolLogoPrintUrl
}
if (body.siteDescription !== undefined) {
updateData.siteDescription = body.siteDescription
}
if (body.submissionGuidelines !== undefined) {
updateData.submissionGuidelines = body.submissionGuidelines
}
if (body.icpNumber !== undefined) {
updateData.icpNumber = body.icpNumber
}
if (body.gonganNumber !== undefined) {
updateData.gonganNumber = body.gonganNumber
}
if (body.statisticsCode !== undefined) {
if (body.statisticsCode !== null && typeof body.statisticsCode !== 'string') {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'statisticsCode 必须是字符串或 null')
}
updateData.statisticsCode = body.statisticsCode
}
if (body.statisticsCodeEnabled !== undefined) {
if (typeof body.statisticsCodeEnabled !== 'boolean') {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'statisticsCodeEnabled 必须是布尔值')
}
updateData.statisticsCodeEnabled = body.statisticsCodeEnabled
}
if (body.showBeianIcon !== undefined) {
if (typeof body.showBeianIcon !== 'boolean') {
throw createError({
statusCode: 400,
message: 'showBeianIcon 必须是布尔值'
})
}
updateData.showBeianIcon = body.showBeianIcon
}
if (body.enableSubmissionLimit !== undefined) {
if (typeof body.enableSubmissionLimit !== 'boolean') {
throw createError({
statusCode: 400,
message: 'enableSubmissionLimit 必须是布尔值'
})
}
updateData.enableSubmissionLimit = body.enableSubmissionLimit
}
// 点歌券点歌相关设置
if (body.enableCardCodeRequests !== undefined) {
if (typeof body.enableCardCodeRequests !== 'boolean') {
throw createError({
statusCode: 400,
message: 'enableCardCodeRequests 必须是布尔值'
})
}
updateData.enableCardCodeRequests = body.enableCardCodeRequests
}
if (body.requireCardCodeForRequests !== undefined) {
if (typeof body.requireCardCodeForRequests !== 'boolean') {
throw createError({
statusCode: 400,
message: 'requireCardCodeForRequests 必须是布尔值'
})
}
updateData.requireCardCodeForRequests = body.requireCardCodeForRequests
}
if (body.enableCardCodeLimitBypass !== undefined) {
if (typeof body.enableCardCodeLimitBypass !== 'boolean') {
throw createError({
statusCode: 400,
message: 'enableCardCodeLimitBypass 必须是布尔值'
})
}
updateData.enableCardCodeLimitBypass = body.enableCardCodeLimitBypass
}
// 重复投稿限制
if (body.enableSubmissionRestriction !== undefined) {
if (typeof body.enableSubmissionRestriction !== 'boolean') {
throw createApiError(
400,
SERVER_ERROR_CODES.COMMON_INVALID_PARAMS,
'enableSubmissionRestriction 必须是布尔值'
)
}
updateData.enableSubmissionRestriction = body.enableSubmissionRestriction
}
if (body.submissionRestrictionScope !== undefined) {
if (body.submissionRestrictionScope !== 'self' && body.submissionRestrictionScope !== 'all') {
throw createApiError(
400,
SERVER_ERROR_CODES.COMMON_INVALID_PARAMS,
'submissionRestrictionScope 必须是 self 或 all'
)
}
updateData.submissionRestrictionScope = body.submissionRestrictionScope
}
if (body.sameSongRestrictionHours !== undefined) {
if (
body.sameSongRestrictionHours !== null &&
(!Number.isInteger(body.sameSongRestrictionHours) || body.sameSongRestrictionHours < 1 || body.sameSongRestrictionHours > 720)
) {
throw createApiError(
400,
SERVER_ERROR_CODES.COMMON_INVALID_PARAMS,
'sameSongRestrictionHours 必须是 1-720 的正整数或 null'
)
}
updateData.sameSongRestrictionHours = body.sameSongRestrictionHours
}
if (body.sameArtistRestrictionHours !== undefined) {
if (
body.sameArtistRestrictionHours !== null &&
(!Number.isInteger(body.sameArtistRestrictionHours) || body.sameArtistRestrictionHours < 1 || body.sameArtistRestrictionHours > 720)
) {
throw createApiError(
400,
SERVER_ERROR_CODES.COMMON_INVALID_PARAMS,
'sameArtistRestrictionHours 必须是 1-720 的正整数或 null'
)
}
updateData.sameArtistRestrictionHours = body.sameArtistRestrictionHours
}
if (body.dailySubmissionLimit !== undefined) {
if (
body.dailySubmissionLimit !== null &&
(!Number.isInteger(body.dailySubmissionLimit) || body.dailySubmissionLimit < 0)
) {
throw createError({
statusCode: 400,
message: 'dailySubmissionLimit 必须是非负整数或null'
})
}
updateData.dailySubmissionLimit = body.dailySubmissionLimit
}
if (body.weeklySubmissionLimit !== undefined) {
if (
body.weeklySubmissionLimit !== null &&
(!Number.isInteger(body.weeklySubmissionLimit) || body.weeklySubmissionLimit < 0)
) {
throw createError({
statusCode: 400,
message: 'weeklySubmissionLimit 必须是非负整数或null'
})
}
updateData.weeklySubmissionLimit = body.weeklySubmissionLimit
}
if (body.monthlySubmissionLimit !== undefined) {
if (
body.monthlySubmissionLimit !== null &&
(!Number.isInteger(body.monthlySubmissionLimit) || body.monthlySubmissionLimit < 0)
) {
throw createError({
statusCode: 400,
message: 'monthlySubmissionLimit 必须是非负整数或null'
})
}
updateData.monthlySubmissionLimit = body.monthlySubmissionLimit
}
if (body.scheduleDaysBeforeEnabled !== undefined) {
if (typeof body.scheduleDaysBeforeEnabled !== 'boolean') {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'scheduleDaysBeforeEnabled 必须是布尔值')
}
updateData.scheduleDaysBeforeEnabled = body.scheduleDaysBeforeEnabled
}
if (body.scheduleDaysAfterEnabled !== undefined) {
if (typeof body.scheduleDaysAfterEnabled !== 'boolean') {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'scheduleDaysAfterEnabled 必须是布尔值')
}
updateData.scheduleDaysAfterEnabled = body.scheduleDaysAfterEnabled
}
const nextScheduleDaysBeforeEnabled = body.scheduleDaysBeforeEnabled !== undefined
? body.scheduleDaysBeforeEnabled
: (settings?.scheduleDaysBeforeEnabled ?? false)
const nextScheduleDaysAfterEnabled = body.scheduleDaysAfterEnabled !== undefined
? body.scheduleDaysAfterEnabled
: (settings?.scheduleDaysAfterEnabled ?? false)
if (body.scheduleDaysBefore !== undefined) {
if (!Number.isInteger(body.scheduleDaysBefore) || body.scheduleDaysBefore < 1 || body.scheduleDaysBefore > 730) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'scheduleDaysBefore 必须是 1-730 的正整数')
}
updateData.scheduleDaysBefore = body.scheduleDaysBefore
}
if (body.scheduleDaysAfter !== undefined) {
if (!Number.isInteger(body.scheduleDaysAfter) || body.scheduleDaysAfter < 1 || body.scheduleDaysAfter > 730) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'scheduleDaysAfter 必须是 1-730 的正整数')
}
updateData.scheduleDaysAfter = body.scheduleDaysAfter
}
if (nextScheduleDaysBeforeEnabled && body.scheduleDaysBefore === undefined && !(settings?.scheduleDaysBefore >= 1)) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '启用前方排期限制时,必须填写正整数天数')
}
if (nextScheduleDaysAfterEnabled && body.scheduleDaysAfter === undefined && !(settings?.scheduleDaysAfter >= 1)) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '启用后方排期限制时,必须填写正整数天数')
}
if (body.showBlacklistKeywords !== undefined) {
if (typeof body.showBlacklistKeywords !== 'boolean') {
throw createError({
statusCode: 400,
message: 'showBlacklistKeywords 必须是布尔值'
})
}
updateData.showBlacklistKeywords = body.showBlacklistKeywords
}
if (body.enableReplayRequests !== undefined) {
if (typeof body.enableReplayRequests !== 'boolean') {
throw createError({
statusCode: 400,
message: 'enableReplayRequests 必须是布尔值'
})
}
updateData.enableReplayRequests = body.enableReplayRequests
}
if (body.enableCollaborativeSubmission !== undefined) {
if (typeof body.enableCollaborativeSubmission !== 'boolean') {
throw createError({
statusCode: 400,
message: 'enableCollaborativeSubmission 必须是布尔值'
})
}
updateData.enableCollaborativeSubmission = body.enableCollaborativeSubmission
}
if (body.enableSubmissionRemarks !== undefined) {
if (typeof body.enableSubmissionRemarks !== 'boolean') {
throw createError({
statusCode: 400,
message: 'enableSubmissionRemarks 必须是布尔值'
})
}
updateData.enableSubmissionRemarks = body.enableSubmissionRemarks
}
if (body.captchaEnabled !== undefined) {
if (typeof body.captchaEnabled !== 'boolean') {
throw createError({
statusCode: 400,
message: 'captchaEnabled 必须是布尔值'
})
}
updateData.captchaEnabled = body.captchaEnabled
}
if (body.captchaMaxFailures !== undefined) {
if (!Number.isInteger(body.captchaMaxFailures) || body.captchaMaxFailures < 1) {
throw createError({
statusCode: 400,
message: 'captchaMaxFailures 必须是正整数'
})
}
updateData.captchaMaxFailures = body.captchaMaxFailures
}
if (body.captchaProvider !== undefined) {
if (body.captchaProvider !== 'graphic' && body.captchaProvider !== 'turnstile') {
throw createError({
statusCode: 400,
message: 'captchaProvider 必须是 graphic 或 turnstile'
})
}
const nextTurnstileSiteKey =
body.turnstileSiteKey !== undefined ? body.turnstileSiteKey : settings?.turnstileSiteKey
const nextTurnstileSecretKey =
body.turnstileSecretKey !== undefined && body.turnstileSecretKey !== SECRET_FIELD_MASK
? body.turnstileSecretKey
: settings?.turnstileSecretKey
if (
body.captchaProvider === 'turnstile' &&
(!nextTurnstileSiteKey || !nextTurnstileSecretKey)
) {
throw createError({
statusCode: 400,
message: '启用 Turnstile 验证前,请先配置 Site Key 和 Secret Key'
})
}
updateData.captchaProvider = body.captchaProvider
}
if (body.turnstileSiteKey !== undefined) {
updateData.turnstileSiteKey = body.turnstileSiteKey
}
if (body.turnstileSecretKey !== undefined && body.turnstileSecretKey !== SECRET_FIELD_MASK) {
updateData.turnstileSecretKey = body.turnstileSecretKey
}
if (body.enableRequestTimeLimitation !== undefined) {
if (typeof body.enableRequestTimeLimitation !== 'boolean') {
throw createError({
statusCode: 400,
message: 'enableRequestTimeLimitation 必须是布尔值'
})
}
updateData.enableRequestTimeLimitation = body.enableRequestTimeLimitation
}
if (body.forceBlockAllRequests !== undefined) {
if (typeof body.forceBlockAllRequests !== 'boolean') {
throw createError({
statusCode: 400,
message: 'forceBlockAllRequests 必须是布尔值'
})
}
updateData.forceBlockAllRequests = body.forceBlockAllRequests
}
if (body.forcePasswordChangeOnFirstLogin !== undefined) {
if (typeof body.forcePasswordChangeOnFirstLogin !== 'boolean') {
throw createError({
statusCode: 400,
message: 'forcePasswordChangeOnFirstLogin 必须是布尔值'
})
}
updateData.forcePasswordChangeOnFirstLogin = body.forcePasswordChangeOnFirstLogin
}
// 平台管理配置
if (body.enabledPlatforms !== undefined || body.platformOrder !== undefined) {
const enabled = body.enabledPlatforms !== undefined
? validatePlatformArray('enabledPlatforms', body.enabledPlatforms, true)
: parsePlatformStored(settings?.enabledPlatforms)
const order = body.platformOrder !== undefined
? validatePlatformArray('platformOrder', body.platformOrder, true)
: parsePlatformStored(settings?.platformOrder)
// 交叉一致性:排序中必须至少包含一个已启用的平台,避免“无可用平台”死锁
if (!order.some((p) => enabled.includes(p))) {
throw createApiError(
400,
SERVER_ERROR_CODES.COMMON_INVALID_PARAMS,
'platformOrder 必须包含至少一个已启用的平台'
)
}
if (body.enabledPlatforms !== undefined) {
updateData.enabledPlatforms = body.enabledPlatforms
}
if (body.platformOrder !== undefined) {
updateData.platformOrder = body.platformOrder
}
}
// SMTP配置字段
if (body.smtpEnabled !== undefined) {
if (typeof body.smtpEnabled !== 'boolean') {
throw createError({
statusCode: 400,
message: 'smtpEnabled 必须是布尔值'
})
}
updateData.smtpEnabled = body.smtpEnabled
}
if (body.smtpHost !== undefined) {
updateData.smtpHost = body.smtpHost
}
if (body.smtpPort !== undefined) {
if (
body.smtpPort !== null &&
(!Number.isInteger(body.smtpPort) || body.smtpPort < 1 || body.smtpPort > 65535)
) {
throw createError({
statusCode: 400,
message: 'smtpPort 必须是1-65535之间的整数'
})
}
updateData.smtpPort = body.smtpPort
}
if (body.smtpSecure !== undefined) {
if (typeof body.smtpSecure !== 'boolean') {
throw createError({
statusCode: 400,
message: 'smtpSecure 必须是布尔值'
})
}
updateData.smtpSecure = body.smtpSecure
}
if (body.smtpUsername !== undefined) {
updateData.smtpUsername = body.smtpUsername
}
if (body.smtpPassword !== undefined && body.smtpPassword !== SMTP_PASSWORD_MASK) {
updateData.smtpPassword = body.smtpPassword
}
if (body.smtpFromEmail !== undefined) {
updateData.smtpFromEmail = body.smtpFromEmail
}
if (body.smtpFromName !== undefined) {
updateData.smtpFromName = body.smtpFromName
}
// OAuth 配置字段
if (body.allowOAuthRegistration !== undefined) {
if (typeof body.allowOAuthRegistration !== 'boolean') {
throw createError({
statusCode: 400,
message: 'allowOAuthRegistration 必须是布尔值'
})
}
updateData.allowOAuthRegistration = body.allowOAuthRegistration
}
// 注册配置字段
if (body.allowRegister !== undefined) {
if (typeof body.allowRegister !== 'boolean') {
throw createError({
statusCode: 400,
message: 'allowRegister 必须是布尔值'
})
}
updateData.allowRegister = body.allowRegister
}
if (body.registerRequiresApproval !== undefined) {
if (typeof body.registerRequiresApproval !== 'boolean') {
throw createError({
statusCode: 400,
message: 'registerRequiresApproval 必须是布尔值'
})
}
updateData.registerRequiresApproval = body.registerRequiresApproval
}
if (body.oauthRegisterRequiresApproval !== undefined) {
if (typeof body.oauthRegisterRequiresApproval !== 'boolean') {
throw createError({
statusCode: 400,
message: 'oauthRegisterRequiresApproval 必须是布尔值'
})
}
updateData.oauthRegisterRequiresApproval = body.oauthRegisterRequiresApproval
}
if (body.submissionNoteRequiresApproval !== undefined) {
if (typeof body.submissionNoteRequiresApproval !== 'boolean') {
throw createError({
statusCode: 400,
message: 'submissionNoteRequiresApproval 必须是布尔值'
})
}
updateData.submissionNoteRequiresApproval = body.submissionNoteRequiresApproval
}
// 注册子开关(邮箱必填 / 年级班级必填)以注册入口开启为前提:
// 两个入口均关闭时子开关无实际作用,直接落 false,避免整单保存被拒且子开关已隐藏无法取消
const registerEntryOpen = Boolean(
body.allowRegister ?? settings?.allowRegister
) || Boolean(body.allowOAuthRegistration ?? settings?.allowOAuthRegistration)
if (body.registerEmailRequired !== undefined) {
if (typeof body.registerEmailRequired !== 'boolean') {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'registerEmailRequired 必须是布尔值')
}
// 开启时 SMTP 必须已配置并启用(合并提交值校验)
const nextEmailRequired = registerEntryOpen && body.registerEmailRequired
if (nextEmailRequired) {
const smtpEnabled = body.smtpEnabled ?? settings?.smtpEnabled
const smtpHost = body.smtpHost ?? settings?.smtpHost
if (!smtpEnabled || !smtpHost) {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, '请先配置并开启 SMTP 邮件服务,再启用注册邮箱必填')
}
}
updateData.registerEmailRequired = nextEmailRequired
}
if (body.registerRequiresGradeClass !== undefined) {
if (typeof body.registerRequiresGradeClass !== 'boolean') {
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, 'registerRequiresGradeClass 必须是布尔值')
}
// 开启时系统内必须已有可选年级班级组合,否则注册表单无项可选、注册请求全部被拒
const nextGradeClassRequired = registerEntryOpen && body.registerRequiresGradeClass
if (nextGradeClassRequired && (await fetchGradeClassOptions()).length === 0) {
throw createApiError(
400,
SERVER_ERROR_CODES.SETTINGS_GRADE_CLASS_OPTIONS_MISSING,
'请先在年级班级管理中配置年级班级,再启用注册时必须选择年级班级'
)
}
updateData.registerRequiresGradeClass = nextGradeClassRequired
}
if (body.oauthRedirectUri !== undefined) {
const normalizedOauthRedirectUri =
typeof body.oauthRedirectUri === 'string'
? body.oauthRedirectUri.trim()
: body.oauthRedirectUri
if (normalizedOauthRedirectUri !== null && normalizedOauthRedirectUri !== '') {
try {
const uri = new URL(normalizedOauthRedirectUri)
// 支持 broker 回调 (包含 /callback 或 /api/auth/[provider]/callback 结构)
const validPathPattern = /(?:\/api)?\/auth\/[^/]+\/callback\/?$|\/callback\/?$/
if (!validPathPattern.test(uri.pathname)) {
throw createError({
statusCode: 400,
message:
'oauthRedirectUri 必须是回调地址,例如 https://yourdomain.com/api/auth/[provider]/callback'
})
}
} catch (error: any) {
if (error?.statusCode === 400) throw error
throw createError({
statusCode: 400,
message:
'oauthRedirectUri 不是合法URL,示例:https://yourdomain.com/api/auth/[provider]/callback'
})
}
}
updateData.oauthRedirectUri =
normalizedOauthRedirectUri === '' ? null : normalizedOauthRedirectUri
}
if (body.oauthStateSecret !== undefined && body.oauthStateSecret !== SECRET_FIELD_MASK) {
updateData.oauthStateSecret = body.oauthStateSecret
}
const nextOauthRedirectUri =
body.oauthRedirectUri !== undefined ? updateData.oauthRedirectUri : settings?.oauthRedirectUri
const nextOauthStateSecret =
body.oauthStateSecret !== undefined && body.oauthStateSecret !== SECRET_FIELD_MASK
? body.oauthStateSecret
: settings?.oauthStateSecret
const nextGithubOAuthEnabled =
body.githubOAuthEnabled !== undefined
? body.githubOAuthEnabled
: (settings?.githubOAuthEnabled ?? false)
const nextCasdoorOAuthEnabled =
body.casdoorOAuthEnabled !== undefined
? body.casdoorOAuthEnabled
: (settings?.casdoorOAuthEnabled ?? false)
const nextGoogleOAuthEnabled =
body.googleOAuthEnabled !== undefined
? body.googleOAuthEnabled
: (settings?.googleOAuthEnabled ?? false)
const nextAggregateOAuthEnabled =
body.aggregateOAuthEnabled !== undefined
? body.aggregateOAuthEnabled
: (settings?.aggregateOAuthEnabled ?? false)
const nextCustomOAuthEnabled =
body.customOAuthEnabled !== undefined
? body.customOAuthEnabled
: (settings?.customOAuthEnabled ?? false)
if (
nextGithubOAuthEnabled ||
nextCasdoorOAuthEnabled ||
nextGoogleOAuthEnabled ||
nextAggregateOAuthEnabled ||
nextCustomOAuthEnabled
) {
if (!nextOauthRedirectUri || !nextOauthStateSecret) {
throw createError({
statusCode: 400,
message:
'启用 OAuth 登录方式前,请先在管理员后台配置 OAuth 重定向 URI 和 OAuth State 密钥'
})
}
}
// GitHub OAuth
if (body.githubOAuthEnabled !== undefined) {
if (typeof body.githubOAuthEnabled !== 'boolean') {
throw createError({
statusCode: 400,
message: 'githubOAuthEnabled 必须是布尔值'
})
}
if (body.githubOAuthEnabled && !body.githubClientId && !settings?.githubClientId) {
throw createError({ statusCode: 400, message: '启用 GitHub 登录时必须提供 Client ID' })
}
if (body.githubOAuthEnabled && !body.githubClientSecret && !settings?.githubClientSecret) {
throw createError({ statusCode: 400, message: '启用 GitHub 登录时必须提供 Client Secret' })
}
updateData.githubOAuthEnabled = body.githubOAuthEnabled
}
if (body.githubClientId !== undefined) {
updateData.githubClientId = body.githubClientId
}
if (body.githubClientSecret !== undefined && body.githubClientSecret !== SECRET_FIELD_MASK) {
updateData.githubClientSecret = body.githubClientSecret
}
// Casdoor OAuth
if (body.casdoorOAuthEnabled !== undefined) {
if (typeof body.casdoorOAuthEnabled !== 'boolean') {
throw createError({
statusCode: 400,
message: 'casdoorOAuthEnabled 必须是布尔值'
})
}
if (body.casdoorOAuthEnabled && !body.casdoorServerUrl && !settings?.casdoorServerUrl) {
throw createError({ statusCode: 400, message: '启用 Casdoor 登录时必须提供服务器 URL' })
}
if (body.casdoorOAuthEnabled && !body.casdoorClientId && !settings?.casdoorClientId) {
throw createError({ statusCode: 400, message: '启用 Casdoor 登录时必须提供 Client ID' })
}
if (body.casdoorOAuthEnabled && !body.casdoorClientSecret && !settings?.casdoorClientSecret) {
throw createError({ statusCode: 400, message: '启用 Casdoor 登录时必须提供 Client Secret' })
}
if (
body.casdoorOAuthEnabled &&
!body.casdoorOrganizationName &&
!settings?.casdoorOrganizationName
) {
throw createError({ statusCode: 400, message: '启用 Casdoor 登录时必须提供组织名称' })
}
updateData.casdoorOAuthEnabled = body.casdoorOAuthEnabled
}
if (body.casdoorServerUrl !== undefined) {
updateData.casdoorServerUrl = body.casdoorServerUrl
}
if (body.casdoorClientId !== undefined) {
updateData.casdoorClientId = body.casdoorClientId
}
if (body.casdoorClientSecret !== undefined && body.casdoorClientSecret !== SECRET_FIELD_MASK) {
updateData.casdoorClientSecret = body.casdoorClientSecret
}
if (body.casdoorOrganizationName !== undefined) {
updateData.casdoorOrganizationName = body.casdoorOrganizationName
}
// Google OAuth
if (body.googleOAuthEnabled !== undefined) {
if (typeof body.googleOAuthEnabled !== 'boolean') {
throw createError({
statusCode: 400,
message: 'googleOAuthEnabled 必须是布尔值'
})
}
if (body.googleOAuthEnabled && !body.googleClientId && !settings?.googleClientId) {
throw createError({ statusCode: 400, message: '启用 Google 登录时必须提供 Client ID' })
}
if (body.googleOAuthEnabled && !body.googleClientSecret && !settings?.googleClientSecret) {
throw createError({ statusCode: 400, message: '启用 Google 登录时必须提供 Client Secret' })
}
updateData.googleOAuthEnabled = body.googleOAuthEnabled
}
if (body.googleClientId !== undefined) {
updateData.googleClientId = body.googleClientId
}
if (body.googleClientSecret !== undefined && body.googleClientSecret !== SECRET_FIELD_MASK) {
updateData.googleClientSecret = body.googleClientSecret
}
// 聚合登陆
const normalizeOptionalText = (value: any) => (typeof value === 'string' ? value.trim() : value)
const storedAggregateLoginTypes = getAggregateOAuthLoginTypesOrDefault(
settings?.aggregateOAuthLoginType
)
const nextAggregateLoginTypes =
body.aggregateOAuthLoginType !== undefined
? normalizeAggregateOAuthLoginTypes(body.aggregateOAuthLoginType)
: storedAggregateLoginTypes
const nextAggregateAppId =
body.aggregateOAuthAppId !== undefined
? normalizeOptionalText(body.aggregateOAuthAppId)
: settings?.aggregateOAuthAppId
const nextAggregateAppKey =
body.aggregateOAuthAppKey !== undefined && body.aggregateOAuthAppKey !== SECRET_FIELD_MASK
? normalizeOptionalText(body.aggregateOAuthAppKey)
: settings?.aggregateOAuthAppKey
const nextAggregateEndpoint =
body.aggregateOAuthEndpoint !== undefined
? normalizeOptionalText(body.aggregateOAuthEndpoint)
: settings?.aggregateOAuthEndpoint || ''
if (body.aggregateOAuthEnabled !== undefined) {
if (typeof body.aggregateOAuthEnabled !== 'boolean') {
throw createError({
statusCode: 400,
message: 'aggregateOAuthEnabled 必须是布尔值'
})
}
updateData.aggregateOAuthEnabled = body.aggregateOAuthEnabled
}
if (nextAggregateOAuthEnabled && !nextAggregateAppId) {
throw createError({ statusCode: 400, message: '启用聚合登陆时必须提供 AppID' })
}
if (nextAggregateOAuthEnabled && !nextAggregateAppKey) {
throw createError({ statusCode: 400, message: '启用聚合登陆时必须提供 AppKey' })
}
if (nextAggregateOAuthEnabled && !nextAggregateEndpoint) {
throw createError({ statusCode: 400, message: '启用聚合登陆时必须提供接口地址' })
}
if (nextAggregateOAuthEnabled && nextAggregateLoginTypes.length === 0) {
throw createError({ statusCode: 400, message: '启用聚合登陆时至少选择一种登录方式' })
}
if (body.aggregateOAuthAppId !== undefined) {
updateData.aggregateOAuthAppId = nextAggregateAppId || null
}
if (
body.aggregateOAuthAppKey !== undefined &&
body.aggregateOAuthAppKey !== SECRET_FIELD_MASK
) {
updateData.aggregateOAuthAppKey = nextAggregateAppKey || null
}
if (body.aggregateOAuthLoginType !== undefined) {
updateData.aggregateOAuthLoginType = JSON.stringify(nextAggregateLoginTypes)
}
if (body.aggregateOAuthEndpoint !== undefined) {
if (nextAggregateEndpoint) {
if (!isSafeAggregateOAuthUrl(nextAggregateEndpoint)) {
throw createError({
statusCode: 400,
message: 'aggregateOAuthEndpoint 公网地址必须使用 HTTPS,内网地址可使用 HTTP'
})
}
}
updateData.aggregateOAuthEndpoint = nextAggregateEndpoint || null
}
// Custom OAuth2
if (body.customOAuthEnabled !== undefined) {
if (typeof body.customOAuthEnabled !== 'boolean') {
throw createError({
statusCode: 400,
message: 'customOAuthEnabled 必须是布尔值'
})
}
if (body.customOAuthEnabled) {
const requiredCustomFields = [
{ key: 'customOAuthAuthorizeUrl', label: '授权端点 URL' },
{ key: 'customOAuthTokenUrl', label: 'Token 端点 URL' },
{ key: 'customOAuthUserInfoUrl', label: '用户信息端点 URL' },
{ key: 'customOAuthClientId', label: 'Client ID' },
{ key: 'customOAuthClientSecret', label: 'Client Secret' },
{ key: 'customOAuthUserIdField', label: '用户 ID 字段名' }
]
for (const field of requiredCustomFields) {
if (!body[field.key] && !settings?.[field.key]) {
throw createError({
statusCode: 400,
message: `启用自定义 OAuth2 登录时必须提供 ${field.label}`
})
}
}
}
updateData.customOAuthEnabled = body.customOAuthEnabled
}
if (body.customOAuthDisplayName !== undefined) {
updateData.customOAuthDisplayName = body.customOAuthDisplayName
}
if (body.customOAuthAuthorizeUrl !== undefined) {
if (body.customOAuthAuthorizeUrl) {
try {
new URL(body.customOAuthAuthorizeUrl)
} catch {
throw createError({ statusCode: 400, message: 'customOAuthAuthorizeUrl 不是合法URL' })
}
}
updateData.customOAuthAuthorizeUrl = body.customOAuthAuthorizeUrl
}
if (body.customOAuthTokenUrl !== undefined) {
if (body.customOAuthTokenUrl) {
try {
new URL(body.customOAuthTokenUrl)
} catch {
throw createError({ statusCode: 400, message: 'customOAuthTokenUrl 不是合法URL' })
}
}
updateData.customOAuthTokenUrl = body.customOAuthTokenUrl
}
if (body.customOAuthUserInfoUrl !== undefined) {
if (body.customOAuthUserInfoUrl) {
try {
new URL(body.customOAuthUserInfoUrl)
} catch {
throw createError({ statusCode: 400, message: 'customOAuthUserInfoUrl 不是合法URL' })
}
}
updateData.customOAuthUserInfoUrl = body.customOAuthUserInfoUrl
}
if (body.customOAuthScope !== undefined) {
updateData.customOAuthScope = body.customOAuthScope
}
if (body.customOAuthClientId !== undefined) {
updateData.customOAuthClientId = body.customOAuthClientId
}
if (
body.customOAuthClientSecret !== undefined &&
body.customOAuthClientSecret !== SECRET_FIELD_MASK
) {
updateData.customOAuthClientSecret = body.customOAuthClientSecret
}
if (body.customOAuthUserIdField !== undefined) {
updateData.customOAuthUserIdField = body.customOAuthUserIdField
}
if (body.customOAuthUsernameField !== undefined) {
updateData.customOAuthUsernameField = body.customOAuthUsernameField
}
if (body.customOAuthNameField !== undefined) {
updateData.customOAuthNameField = body.customOAuthNameField
}
if (body.customOAuthEmailField !== undefined) {
updateData.customOAuthEmailField = body.customOAuthEmailField
}
if (body.customOAuthAvatarField !== undefined) {
updateData.customOAuthAvatarField = body.customOAuthAvatarField
}
// 验证每日、每周和每月限额三选一逻辑
const limitSettings = [
body.dailySubmissionLimit,
body.weeklySubmissionLimit,
body.monthlySubmissionLimit
].filter((limit) => limit !== undefined && limit !== null)
if (body.enableSubmissionLimit && limitSettings.length > 1) {
throw createError({
statusCode: 400,
message: '每日限额、每周限额和每月限额只能选择其中一种,其他必须设置为空'
})
}
// 重复投稿限制交叉校验:开关关闭即完全不做限制;开启且未设置时长时沿用本学期同一首歌不可重复投稿的旧规则。
const nextSameSongHours = body.sameSongRestrictionHours !== undefined
? body.sameSongRestrictionHours
: settings?.sameSongRestrictionHours ?? null
const nextSameArtistHours = body.sameArtistRestrictionHours !== undefined
? body.sameArtistRestrictionHours
: settings?.sameArtistRestrictionHours ?? null
const anyRestrictionHoursPositive =
(typeof nextSameSongHours === 'number' && nextSameSongHours >= 1) ||
(typeof nextSameArtistHours === 'number' && nextSameArtistHours >= 1)
// 显式关闭但仍有有效时长:拒绝,提示用户先清除时长
if (body.enableSubmissionRestriction === false && anyRestrictionHoursPositive) {
throw createApiError(
400,
SERVER_ERROR_CODES.COMMON_INVALID_PARAMS,
'关闭重复投稿限制时,同一首歌和同一歌手的限制时间必须同时清空'
)
}
// 时长>0 且 enable 未显式提交 ⇒ 自动开启,维持"时长与开关自洽"不变量
if (anyRestrictionHoursPositive && body.enableSubmissionRestriction === undefined) {
updateData.enableSubmissionRestriction = true
}
if (!settings) {
const newSettingsResult = await db
.insert(systemSettings)
.values({
...SYSTEM_SETTINGS_DEFAULTS,
scheduleDaysBeforeEnabled: SYSTEM_SETTINGS_DEFAULTS.scheduleDaysBeforeEnabled,
scheduleDaysBefore: SYSTEM_SETTINGS_DEFAULTS.scheduleDaysBefore,
scheduleDaysAfterEnabled: SYSTEM_SETTINGS_DEFAULTS.scheduleDaysAfterEnabled,
scheduleDaysAfter: SYSTEM_SETTINGS_DEFAULTS.scheduleDaysAfter,
...updateData
})
.returning()
settings = newSettingsResult[0]
} else {
// 如果存在,更新设置
const updatedSettingsResult = await db
.update(systemSettings)
.set(updateData)
.where(eq(systemSettings.id, settings.id))
.returning()
settings = updatedSettingsResult[0]
}
if (updateData.telemetryEnabled !== undefined) {
try {
const { setTelemetryEnabledCache } = await import('~~/server/utils/telemetry')
setTelemetryEnabledCache(updateData.telemetryEnabled)
} catch (telemetryError) {
console.warn('[Telemetry] 遥测开关缓存更新失败:', telemetryError)
}
}
try {
const { SmtpService } = await import('~~/server/services/smtpService')
// 强制刷新:早退检查会导致修改授权码后单例仍持有旧凭据
await SmtpService.getInstance().initializeSmtpConfig(true)
console.log('[SMTP] SMTP配置已重新加载(更新系统设置)')
} catch (smtpError) {
console.warn('[SMTP] SMTP配置重载失败:', smtpError)
}
return maskSystemSettingsSecrets(settings)
} catch (error) {
console.error('更新系统设置失败:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
message: '更新系统设置失败'
})
}
})