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: '更新系统设置失败' }) } })