feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+407
View File
@@ -0,0 +1,407 @@
import { db } from '~/drizzle/db'
import { apiKeys, apiLogs } from '~/drizzle/schema'
import { and, count, desc, eq, gte, like, lte, or, sql } from 'drizzle-orm'
/**
* API访问日志服务
* 提供日志记录、查询和统计功能
*/
export class ApiLogService {
/**
* 记录API访问日志
*/
static async logAccess(logData: {
apiKeyId: string | null
endpoint: string
method: string
ipAddress: string
userAgent: string
statusCode: number
responseTimeMs: number
requestBody?: string | null
responseBody?: string | null
errorMessage?: string
}) {
try {
console.log(`[ApiLogService] 记录API访问日志:`, {
endpoint: logData.endpoint,
method: logData.method,
statusCode: logData.statusCode,
responseTimeMs: logData.responseTimeMs,
hasError: !!logData.errorMessage
})
await db.insert(apiLogs).values({
apiKeyId: logData.apiKeyId,
endpoint: logData.endpoint,
method: logData.method,
ipAddress: logData.ipAddress,
userAgent: logData.userAgent,
statusCode: logData.statusCode,
responseTimeMs: logData.responseTimeMs,
requestBody: logData.requestBody,
responseBody: logData.responseBody,
errorMessage: logData.errorMessage
})
console.log(`[ApiLogService] API访问日志记录成功`)
} catch (error) {
// 静默处理日志记录错误,避免影响主要业务流程
console.error('[ApiLogService] API访问日志记录失败:', error)
}
}
/**
* 查询API访问日志
*/
static async getLogs(options: {
page?: number
limit?: number
apiKeyId?: string
endpoint?: string
method?: string
statusCode?: number
ipAddress?: string
startDate?: string
endDate?: string
search?: string
}) {
const {
page = 1,
limit = 20,
apiKeyId,
endpoint,
method,
statusCode,
ipAddress,
startDate,
endDate,
search
} = options
const offset = (page - 1) * limit
const whereConditions = []
// 构建查询条件
if (apiKeyId) {
whereConditions.push(eq(apiLogs.apiKeyId, apiKeyId))
}
if (endpoint) {
whereConditions.push(like(apiLogs.endpoint, `%${endpoint}%`))
}
if (method) {
whereConditions.push(eq(apiLogs.method, method.toUpperCase()))
}
if (statusCode) {
whereConditions.push(eq(apiLogs.statusCode, statusCode))
}
if (ipAddress) {
whereConditions.push(like(apiLogs.ipAddress, `%${ipAddress}%`))
}
if (startDate) {
whereConditions.push(gte(apiLogs.createdAt, new Date(startDate)))
}
if (endDate) {
const endDateTime = new Date(endDate)
endDateTime.setHours(23, 59, 59, 999)
whereConditions.push(lte(apiLogs.createdAt, endDateTime))
}
if (search) {
whereConditions.push(
or(
like(apiLogs.endpoint, `%${search}%`),
like(apiLogs.ipAddress, `%${search}%`),
like(apiLogs.userAgent, `%${search}%`),
like(apiLogs.errorMessage, `%${search}%`)
)
)
}
const whereClause =
whereConditions.length > 0
? whereConditions.length === 1
? whereConditions[0]
: and(...whereConditions)
: undefined
// 查询日志数据
const logsData = await db
.select({
id: apiLogs.id,
apiKeyId: apiLogs.apiKeyId,
apiKeyName: apiKeys.name,
endpoint: apiLogs.endpoint,
method: apiLogs.method,
ipAddress: apiLogs.ipAddress,
userAgent: apiLogs.userAgent,
statusCode: apiLogs.statusCode,
responseTimeMs: apiLogs.responseTimeMs,
requestBody: apiLogs.requestBody,
responseBody: apiLogs.responseBody,
errorMessage: apiLogs.errorMessage,
createdAt: apiLogs.createdAt
})
.from(apiLogs)
.leftJoin(apiKeys, eq(apiLogs.apiKeyId, apiKeys.id))
.where(whereClause)
.orderBy(desc(apiLogs.createdAt))
.limit(limit)
.offset(offset)
// 查询总数
const totalResult = await db.select({ count: count() }).from(apiLogs).where(whereClause)
const total = totalResult[0]?.count || 0
return {
logs: logsData,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
}
}
/**
* 获取API访问统计信息
*/
static async getStats(
options: {
apiKeyId?: string
startDate?: string
endDate?: string
groupBy?: 'hour' | 'day' | 'month'
} = {}
) {
const { apiKeyId, startDate, endDate, groupBy = 'day' } = options
const whereConditions = []
if (apiKeyId) {
whereConditions.push(eq(apiLogs.apiKeyId, apiKeyId))
}
if (startDate) {
whereConditions.push(gte(apiLogs.createdAt, new Date(startDate)))
}
if (endDate) {
const endDateTime = new Date(endDate)
endDateTime.setHours(23, 59, 59, 999)
whereConditions.push(lte(apiLogs.createdAt, endDateTime))
}
const whereClause =
whereConditions.length > 0
? whereConditions.length === 1
? whereConditions[0]
: and(...whereConditions)
: undefined
// 基础统计
const basicStats = await db
.select({
totalRequests: count(),
successRequests: count(
sql`CASE WHEN ${apiLogs.statusCode} >= 200 AND ${apiLogs.statusCode} < 300 THEN 1 END`
),
errorRequests: count(sql`CASE WHEN ${apiLogs.statusCode} >= 400 THEN 1 END`),
avgResponseTime: sql<number>`ROUND(AVG(${apiLogs.responseTimeMs}), 2)`,
maxResponseTime: sql<number>`MAX(${apiLogs.responseTimeMs})`,
minResponseTime: sql<number>`MIN(${apiLogs.responseTimeMs})`
})
.from(apiLogs)
.where(whereClause)
// 按状态码统计
const statusCodeStats = await db
.select({
statusCode: apiLogs.statusCode,
count: count()
})
.from(apiLogs)
.where(whereClause)
.groupBy(apiLogs.statusCode)
.orderBy(desc(count()))
// 按端点统计
const endpointStats = await db
.select({
endpoint: apiLogs.endpoint,
count: count(),
avgResponseTime: sql<number>`ROUND(AVG(${apiLogs.responseTimeMs}), 2)`
})
.from(apiLogs)
.where(whereClause)
.groupBy(apiLogs.endpoint)
.orderBy(desc(count()))
.limit(10)
// 按API Key统计
const apiKeyStats = await db
.select({
apiKeyId: apiLogs.apiKeyId,
apiKeyName: apiKeys.name,
count: count(),
avgResponseTime: sql<number>`ROUND(AVG(${apiLogs.responseTimeMs}), 2)`
})
.from(apiLogs)
.leftJoin(apiKeys, eq(apiLogs.apiKeyId, apiKeys.id))
.where(whereClause)
.groupBy(apiLogs.apiKeyId, apiKeys.name)
.orderBy(desc(count()))
.limit(10)
// 按时间分组统计
let timeGroupFormat: string
switch (groupBy) {
case 'hour':
timeGroupFormat = 'YYYY-MM-DD HH24:00:00'
break
case 'month':
timeGroupFormat = 'YYYY-MM-01'
break
default:
timeGroupFormat = 'YYYY-MM-DD'
}
const timeStats = await db
.select({
time: sql<string>`TO_CHAR(${apiLogs.createdAt}, ${sql.raw(`'${timeGroupFormat}'`)})`
})
.from(apiLogs)
.where(whereClause)
.groupBy(sql`TO_CHAR(${apiLogs.createdAt}, ${sql.raw(`'${timeGroupFormat}'`)})`)
.orderBy(sql`TO_CHAR(${apiLogs.createdAt}, ${sql.raw(`'${timeGroupFormat}'`)})`)
return {
basic: basicStats[0] || {
totalRequests: 0,
successRequests: 0,
errorRequests: 0,
avgResponseTime: 0,
maxResponseTime: 0,
minResponseTime: 0
},
statusCodes: statusCodeStats,
endpoints: endpointStats,
apiKeys: apiKeyStats,
timeline: timeStats
}
}
/**
* 获取热门端点统计
*/
static async getPopularEndpoints(
options: {
limit?: number
startDate?: string
endDate?: string
} = {}
) {
const { limit = 10, startDate, endDate } = options
const whereConditions = []
if (startDate) {
whereConditions.push(gte(apiLogs.createdAt, new Date(startDate)))
}
if (endDate) {
const endDateTime = new Date(endDate)
endDateTime.setHours(23, 59, 59, 999)
whereConditions.push(lte(apiLogs.createdAt, endDateTime))
}
const whereClause =
whereConditions.length > 0
? whereConditions.length === 1
? whereConditions[0]
: and(...whereConditions)
: undefined
return await db
.select({
endpoint: apiLogs.endpoint,
method: apiLogs.method,
totalRequests: count(),
successRequests: count(
sql`CASE WHEN ${apiLogs.statusCode} >= 200 AND ${apiLogs.statusCode} < 300 THEN 1 END`
),
errorRequests: count(sql`CASE WHEN ${apiLogs.statusCode} >= 400 THEN 1 END`),
avgResponseTime: sql<number>`ROUND(AVG(${apiLogs.responseTimeMs}), 2)`,
lastAccessed: sql<Date>`MAX(${apiLogs.createdAt})`
})
.from(apiLogs)
.where(whereClause)
.groupBy(apiLogs.endpoint, apiLogs.method)
.orderBy(desc(count()))
.limit(limit)
}
/**
* 获取错误日志统计
*/
static async getErrorStats(
options: {
limit?: number
startDate?: string
endDate?: string
} = {}
) {
const { limit = 10, startDate, endDate } = options
const whereConditions = [sql`${apiLogs.statusCode} >= 400`]
if (startDate) {
whereConditions.push(gte(apiLogs.createdAt, new Date(startDate)))
}
if (endDate) {
const endDateTime = new Date(endDate)
endDateTime.setHours(23, 59, 59, 999)
whereConditions.push(lte(apiLogs.createdAt, endDateTime))
}
const whereClause = and(...whereConditions)
return await db
.select({
errorMessage: apiLogs.errorMessage,
statusCode: apiLogs.statusCode,
endpoint: apiLogs.endpoint,
count: count(),
lastOccurred: sql<Date>`MAX(${apiLogs.createdAt})`
})
.from(apiLogs)
.where(whereClause)
.groupBy(apiLogs.errorMessage, apiLogs.statusCode, apiLogs.endpoint)
.orderBy(desc(count()))
.limit(limit)
}
/**
* 清理过期日志
*/
static async cleanupOldLogs(daysToKeep: number = 90) {
const cutoffDate = new Date()
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep)
try {
const result = await db.delete(apiLogs).where(lte(apiLogs.createdAt, cutoffDate))
console.log(`Cleaned up API logs older than ${daysToKeep} days`)
return result
} catch (error) {
console.error('Failed to cleanup old API logs:', error)
throw error
}
}
}
+510
View File
@@ -0,0 +1,510 @@
import { db } from '~/drizzle/db'
import {
backupHistory,
systemSettings,
apiKeys,
apiKeyPermissions,
apiLogs,
cardCodeRedeemLogs,
cardCodes,
collaborationLogs,
emailTemplates,
notifications,
notificationSettings,
playTimes,
requestTimes,
schedules,
semesters,
songBlacklists,
songCollaborators,
songReplayRequests,
songs,
users,
userIdentities,
userStatusLogs,
votes
} from '~/drizzle/schema'
import { createApiError } from '~~/server/utils/apiError'
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
import { uploadToS3 } from '~~/server/utils/s3Client'
import { desc, eq, lt, sql } from 'drizzle-orm'
/** 外部服务调用超时(毫秒) */
const UPLOAD_TIMEOUT = 120_000
/** 并发互斥锁,防止同时触发多次备份 */
let backupRunning = false
/** 获取备份锁,若已在执行则抛出 409 */
export function acquireBackupLock(): void {
if (backupRunning) {
throw createApiError(409, SERVER_ERROR_CODES.BACKUP_FAILED, '备份任务正在执行中,请稍后再试')
}
backupRunning = true
}
/** 释放备份锁 */
export function releaseBackupLock(): void {
backupRunning = false
}
/** 自动备份配置结构 */
export interface AutoBackupConfig {
methods: {
s3: {
enabled: boolean
endpoint: string
bucket: string
region: string
pathPrefix: string
accessKey: string
secretKey: string
}
webdav: {
enabled: boolean
url: string
username: string
password: string
path: string
}
telegram: {
enabled: boolean
botToken: string
chatId: string
}
email: {
enabled: boolean
recipient: string
}
}
}
/** 获取自动备份配置 */
export async function getAutoBackupConfig(): Promise<AutoBackupConfig | null> {
const [row] = await db
.select({ autoBackupConfig: systemSettings.autoBackupConfig })
.from(systemSettings)
.limit(1)
if (!row?.autoBackupConfig) return null
try {
return JSON.parse(row.autoBackupConfig)
} catch {
return null
}
}
/** 检查自动备份是否启用 */
export async function isAutoBackupEnabled(): Promise<boolean> {
const [row] = await db
.select({ autoBackupEnabled: systemSettings.autoBackupEnabled })
.from(systemSettings)
.limit(1)
return row?.autoBackupEnabled === true
}
/** 导出数据库备份数据 */
export async function exportBackupData(): Promise<{ json: string; filename: string; metadata: { totalRecords: number } }> {
const backupData = {
metadata: {
version: '1.0',
timestamp: new Date().toISOString(),
backupType: 'auto',
description: `自动备份 - ${new Date().toISOString()}`,
tables: [] as Array<{ name: string; description: string; recordCount: number }>,
totalRecords: 0
},
data: {} as Record<string, any>
}
const tablesToBackup: Record<string, { query: () => Promise<any[]>; description: string }> = {
users: { query: () => db.select().from(users), description: '用户数据' },
songs: { query: () => db.select().from(songs), description: '歌曲数据' },
schedules: { query: () => db.select().from(schedules), description: '排期数据' },
playTimes: { query: () => db.select().from(playTimes), description: '播出时段' },
requestTimes: { query: () => db.select().from(requestTimes), description: '请求时段' },
semesters: { query: () => db.select().from(semesters), description: '学期数据' },
notifications: { query: () => db.select().from(notifications), description: '通知数据' },
notificationSettings: { query: () => db.select().from(notificationSettings), description: '通知设置' },
songBlacklists: { query: () => db.select().from(songBlacklists), description: '歌曲黑名单' },
votes: { query: () => db.select().from(votes), description: '投票数据' },
cardCodes: { query: () => db.select().from(cardCodes), description: '点歌券数据' },
cardCodeRedeemLogs: { query: () => db.select().from(cardCodeRedeemLogs), description: '点歌券日志' },
songCollaborators: { query: () => db.select().from(songCollaborators), description: '联合投稿人' },
collaborationLogs: { query: () => db.select().from(collaborationLogs), description: '联合投稿审计日志' },
songReplayRequests: { query: () => db.select().from(songReplayRequests), description: '歌曲重播申请' },
userStatusLogs: { query: () => db.select().from(userStatusLogs), description: '用户状态变更日志' },
userIdentities: { query: () => db.select().from(userIdentities), description: '第三方身份关联' },
apiKeys: { query: () => db.select().from(apiKeys), description: 'API密钥' },
apiKeyPermissions: { query: () => db.select().from(apiKeyPermissions), description: 'API密钥权限' },
apiLogs: { query: () => db.select().from(apiLogs), description: 'API访问日志' },
emailTemplates: { query: () => db.select().from(emailTemplates), description: '邮件模板' },
systemSettings: {
query: async () => {
const rows = await db.select().from(systemSettings)
// 脱敏:移除敏感凭据字段,避免备份文件泄露密钥
const SENSITIVE_FIELDS = [
'smtpPassword', 'oauthStateSecret', 'githubClientSecret',
'casdoorClientSecret', 'googleClientSecret', 'customOAuthClientSecret',
'turnstileSecretKey', 'aggregateOAuthAppKey', 'autoBackupConfig'
]
return rows.map(row => {
const entries = Object.entries(row).filter(([k]) => !SENSITIVE_FIELDS.includes(k))
return Object.fromEntries(entries)
})
},
description: '系统设置(已脱敏)'
}
}
let totalRecords = 0
for (const [tableName, { query, description }] of Object.entries(tablesToBackup)) {
try {
const data = await query()
backupData.data[tableName] = data
backupData.metadata.tables.push({ name: tableName, description, recordCount: data.length })
totalRecords += data.length
} catch (error) {
console.warn(`备份表 ${tableName} 失败,已跳过:`, error)
backupData.metadata.tables.push({ name: tableName, description: `${description} (跳过)`, recordCount: 0 })
}
}
backupData.metadata.totalRecords = totalRecords
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `auto-backup-${timestamp}.json`
return { json: JSON.stringify(backupData, null, 2), filename, metadata: backupData.metadata }
}
/** 上传到 S3 */
async function doS3Upload(config: AutoBackupConfig['methods']['s3'], data: string, filename: string): Promise<void> {
const prefix = (config.pathPrefix || '').replace(/\/+$/, '')
const key = prefix ? `${prefix}/${filename}` : filename
await uploadToS3(config.endpoint, config.bucket, config.region, config.accessKey, config.secretKey, key, data)
console.log(`S3 上传完成: ${filename}`)
}
/** 规范化路径,去除首尾空格和多余斜杠 */
function normalizePath(p: string): string {
return (p || '').trim().replace(/^\/+|\/+$/g, '')
}
/** 递归创建 WebDAV 目录 */
async function ensureWebDAVDir(baseUrl: string, auth: string, dirPath: string): Promise<void> {
const normalized = normalizePath(dirPath)
if (!normalized) return
const segments = normalized.split('/')
let currentPath = baseUrl.replace(/\/$/, '')
for (const segment of segments) {
currentPath += `/${segment}`
const response = await fetch(currentPath, {
method: 'MKCOL',
headers: { Authorization: `Basic ${auth}` },
signal: AbortSignal.timeout(UPLOAD_TIMEOUT)
})
// 201 Created = 成功,405 = 目录已存在
if (response.status === 409) {
throw new Error(`WebDAV 父目录不存在,无法创建: ${currentPath}`)
}
if (!response.ok && response.status !== 405) {
throw new Error(`创建 WebDAV 目录失败: ${currentPath} (${response.status})`)
}
}
}
/** 上传到 WebDAV */
async function doWebDAVUpload(config: AutoBackupConfig['methods']['webdav'], data: string, filename: string): Promise<void> {
const baseUrl = config.url.replace(/\/$/, '')
const dirPath = normalizePath(config.path)
const auth = Buffer.from(`${config.username}:${config.password}`).toString('base64')
// 确保目录存在
await ensureWebDAVDir(baseUrl, auth, dirPath)
const filePath = dirPath ? `${baseUrl}/${dirPath}/${filename}` : `${baseUrl}/${filename}`
const response = await fetch(filePath, {
method: 'PUT',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json'
},
body: data,
signal: AbortSignal.timeout(UPLOAD_TIMEOUT)
})
if (!response.ok) {
throw new Error(`WebDAV 上传失败: ${response.status} ${response.statusText}`)
}
console.log(`WebDAV 上传完成: ${filename}`)
}
/** 导出 ensureWebDAVDir 供测试端点使用 */
export { ensureWebDAVDir }
/** 通过 Telegram Bot 发送 */
async function doTelegramSend(config: AutoBackupConfig['methods']['telegram'], data: string, filename: string): Promise<void> {
const formData = new FormData()
const blob = new Blob([data], { type: 'application/json' })
formData.append('chat_id', config.chatId)
formData.append('document', blob, filename)
formData.append('caption', `VoiceHub Auto Backup - ${new Date().toISOString()}`)
const response = await fetch(`https://api.telegram.org/bot${config.botToken}/sendDocument`, {
method: 'POST',
body: formData,
signal: AbortSignal.timeout(UPLOAD_TIMEOUT)
})
const result = await response.json() as any
if (!result.ok) {
throw new Error(`Telegram 发送失败: ${result.description}`)
}
console.log(`Telegram 发送完成: ${filename}`)
}
/** 通过邮件发送 */
async function doEmailSend(config: AutoBackupConfig['methods']['email'], data: string, filename: string): Promise<void> {
const { getSystemSettingsCached } = await import('~~/server/utils/system-settings-helper')
const settings = await getSystemSettingsCached()
const nodemailer = await import('nodemailer').then(m => m.default || m)
if (!settings || !settings.smtpHost || !settings.smtpUsername || !settings.smtpPassword) {
throw createApiError(500, SERVER_ERROR_CODES.SMTP_NOT_CONFIGURED, '邮件服务未配置,无法发送邮件备份')
}
const transporter = nodemailer.createTransport({
host: settings.smtpHost,
port: settings.smtpPort || 587,
secure: settings.smtpSecure || false,
auth: {
user: settings.smtpUsername,
pass: settings.smtpPassword
}
})
try {
await transporter.sendMail({
from: `"${settings.smtpFromName || 'VoiceHub'}" <${settings.smtpFromEmail || settings.smtpUsername}>`,
to: config.recipient,
subject: `VoiceHub 自动备份 - ${new Date().toISOString()}`,
text: '数据库自动备份文件见附件。',
attachments: [{
filename,
content: data,
contentType: 'application/json'
}]
})
console.log(`邮件发送完成: ${filename} -> ${config.recipient}`)
} finally {
transporter.close()
}
}
/** 准备备份:校验、导出数据、写入初始记录,返回上下文供后续上传使用 */
export async function prepareBackup(triggeredBy: string = 'api'): Promise<{
historyId: number
json: string
filename: string
metadata: { totalRecords: number }
backupSize: number
config: AutoBackupConfig
enabledMethods: Array<{ key: string; name: string; fn: () => Promise<void> }>
}> {
const enabled = await isAutoBackupEnabled()
if (!enabled) {
throw createApiError(400, SERVER_ERROR_CODES.BACKUP_DISABLED, '自动备份未启用')
}
const config = await getAutoBackupConfig()
if (!config) {
throw createApiError(400, SERVER_ERROR_CODES.BACKUP_NOT_CONFIGURED, '自动备份未配置')
}
const { json, filename, metadata } = await exportBackupData()
const methods = [
{ key: 's3' as const, name: 'S3', fn: () => doS3Upload(config.methods.s3, json, filename) },
{ key: 'webdav' as const, name: 'WebDAV', fn: () => doWebDAVUpload(config.methods.webdav, json, filename) },
{ key: 'telegram' as const, name: 'Telegram', fn: () => doTelegramSend(config.methods.telegram, json, filename) },
{ key: 'email' as const, name: 'Email', fn: () => doEmailSend(config.methods.email, json, filename) }
]
const enabledMethods = methods.filter(({ key }) => config.methods[key].enabled)
if (enabledMethods.length === 0) {
throw createApiError(400, SERVER_ERROR_CODES.NO_BACKUP_METHOD_ENABLED, '没有启用任何备份方式')
}
// 写入初始记录(error 为 null 表示未完成),确保超时中断也有历史
const backupSize = Buffer.byteLength(json)
const initialResults = enabledMethods.map(({ name }) => ({
method: name,
success: false,
error: null
}))
const [inserted] = await db.insert(backupHistory).values({
filename,
totalRecords: metadata.totalRecords,
backupSize,
success: false,
methods: JSON.stringify(initialResults),
triggeredBy
}).returning({ id: backupHistory.id })
if (!inserted) {
throw createApiError(500, SERVER_ERROR_CODES.BACKUP_FAILED, '写入备份记录失败')
}
console.log(`备份初始记录已写入: ${filename} (id=${inserted.id})`)
return {
historyId: inserted.id,
json,
filename,
metadata,
backupSize,
config,
enabledMethods
}
}
/** 执行上传并更新历史记录 */
export async function executeUploads(prepared: {
historyId: number
json: string
filename: string
config: AutoBackupConfig
enabledMethods: Array<{ key: string; name: string; fn: () => Promise<void> }>
}): Promise<{
success: boolean
results: Array<{ method: string; success: boolean; error?: string }>
}> {
const { historyId, filename, enabledMethods } = prepared
// 串行化 DB 更新,避免并行写入时的竞态条件
let updateChain: Promise<void> = Promise.resolve()
function updateMethodResult(index: number, result: { method: string; success: boolean; error?: string }) {
const prev = updateChain
updateChain = prev.then(async () => {
const [record] = await db
.select({ methods: backupHistory.methods })
.from(backupHistory)
.where(eq(backupHistory.id, historyId))
if (!record) return
const methods = JSON.parse(record.methods)
methods[index] = result
await db.update(backupHistory)
.set({ methods: JSON.stringify(methods) })
.where(eq(backupHistory.id, historyId))
}).catch(err => {
console.error(`更新备份方法结果失败 (index=${index}):`, err)
})
return updateChain
}
// 并行上传,每个完成后立即更新对应方法的结果
const tasks = enabledMethods.map(async ({ name, fn }, index) => {
try {
await fn()
await updateMethodResult(index, { method: name, success: true })
return { method: name, success: true }
} catch (error: any) {
console.error(`${name} 备份失败:`, error)
const errMsg = error.message || String(error) || 'Unknown error'
await updateMethodResult(index, { method: name, success: false, error: errMsg })
return { method: name, success: false, error: errMsg }
}
})
const results: Array<{ method: string; success: boolean; error?: string }> = await Promise.all(tasks)
const overallSuccess = results.some(r => r.success)
// 更新整体成功状态
await db.update(backupHistory)
.set({ success: overallSuccess })
.where(eq(backupHistory.id, historyId))
// 自动清理 30 天前的历史记录
cleanupOldHistory(30).catch(err => console.error('自动清理备份历史失败:', err))
console.log(`备份历史已更新: ${filename}`)
return {
success: overallSuccess,
results
}
}
/** 执行一次完整的自动备份(prepareBackup + executeUploads) */
export async function executeAutoBackup(triggeredBy: string = 'api'): Promise<{
success: boolean
results: Array<{ method: string; success: boolean; error?: string }>
}> {
acquireBackupLock()
try {
const prepared = await prepareBackup(triggeredBy)
return await executeUploads(prepared)
} finally {
releaseBackupLock()
}
}
/** 获取备份历史列表 */
export async function getBackupHistory(limit: number = 50): Promise<Array<{
id: number
createdAt: Date
filename: string
totalRecords: number
backupSize: number
methods: Array<{ method: string; success: boolean; error?: string }>
success: boolean
triggeredBy: string | null
}>> {
const rows = await db
.select()
.from(backupHistory)
.orderBy(desc(backupHistory.createdAt))
.limit(limit)
return rows.map((row) => ({
...row,
methods: JSON.parse(row.methods)
}))
}
/** 清理备份历史记录 */
export async function cleanupOldHistory(retentionDays: number = 30): Promise<number> {
if (retentionDays <= 0) {
const result = await db.select({ cnt: sql<number>`count(*)::int` }).from(backupHistory)
const count = result[0]?.cnt ?? 0
if (count > 0) {
await db.delete(backupHistory)
console.log(`清理了全部 ${count} 条备份历史记录`)
}
return count
}
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000)
const result = await db
.select({ cnt: sql<number>`count(*)::int` })
.from(backupHistory)
.where(lt(backupHistory.createdAt, cutoff))
const count = result[0]?.cnt ?? 0
if (count > 0) {
await db.delete(backupHistory).where(lt(backupHistory.createdAt, cutoff))
console.log(`清理了 ${count} 条过期备份历史记录`)
}
return count
}
+39
View File
@@ -0,0 +1,39 @@
import { inArray } from 'drizzle-orm'
import { createError } from 'h3'
import { db } from '~/drizzle/db'
import { cardCodes, songs } from '~/drizzle/schema'
export const deleteCardCodesByIds = async (ids: number[]) => {
const normalizedIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)))
if (!normalizedIds.length) {
return []
}
return await db.transaction(async (tx) => {
const existingRows = await tx
.select({ id: cardCodes.id })
.from(cardCodes)
.where(inArray(cardCodes.id, normalizedIds))
.for('update')
const existingIds = existingRows.map((row) => row.id)
if (!existingIds.length) {
return []
}
const referencedSongs = await tx
.select({ id: songs.id })
.from(songs)
.where(inArray(songs.cardCodeId, existingIds))
.limit(1)
if (referencedSongs.length > 0) {
throw createError({
statusCode: 409,
message: '已关联投稿的点歌券不能删除,请改为作废'
})
}
return await tx.delete(cardCodes).where(inArray(cardCodes.id, existingIds)).returning()
})
}
+168
View File
@@ -0,0 +1,168 @@
import { and, eq, inArray } from 'drizzle-orm'
import { createError } from 'h3'
import { cardCodeRedeemLogs, cardCodes } from '~/drizzle/schema'
type CardCodeTransitionContext = {
songId: number
cardCodeId: number | null | undefined
operatorId: number
at?: Date
}
const getTransitionTime = (at?: Date) => at || new Date()
const readCardCode = async (tx: any, cardCodeId: number) => {
const rows = await tx.select().from(cardCodes).where(eq(cardCodes.id, cardCodeId)).limit(1)
return rows[0] || null
}
export const redeemCardCodeForSchedule = async (
tx: any,
{ songId, cardCodeId, operatorId, at }: CardCodeTransitionContext
) => {
if (!cardCodeId) return { changed: false, reason: 'NO_CARD_CODE' }
const redeemedAt = getTransitionTime(at)
const redeemResult = await tx
.update(cardCodes)
.set({
status: 'REDEEMED',
redeemedBy: operatorId,
redeemedAt,
lockedBy: null,
lockedAt: null,
updatedAt: redeemedAt
})
.where(and(eq(cardCodes.id, cardCodeId), inArray(cardCodes.status, ['AVAILABLE', 'LOCKED'])))
.returning({ id: cardCodes.id, code: cardCodes.code, redeemedAt: cardCodes.redeemedAt })
if (redeemResult.length > 0) {
const redeemedCardCode = redeemResult[0]
await tx.insert(cardCodeRedeemLogs).values({
cardCodeId: redeemedCardCode.id,
codeSnapshot: redeemedCardCode.code,
redeemedBy: operatorId,
redeemedAt: redeemedCardCode.redeemedAt || redeemedAt,
source: 'SCHEDULE_AUTO',
songId
})
return { changed: true, status: 'REDEEMED' }
}
const current = await readCardCode(tx, cardCodeId)
if (!current) {
throw createError({ statusCode: 400, message: '关联的点歌券不存在,无法发布排期' })
}
if (current.status === 'REDEEMED') {
const existingLogs = await tx
.select({ id: cardCodeRedeemLogs.id })
.from(cardCodeRedeemLogs)
.where(
and(
eq(cardCodeRedeemLogs.cardCodeId, cardCodeId),
eq(cardCodeRedeemLogs.songId, songId),
eq(cardCodeRedeemLogs.source, 'SCHEDULE_AUTO')
)
)
.limit(1)
if (existingLogs.length > 0) {
return { changed: false, status: 'REDEEMED' }
}
throw createError({
statusCode: 409,
message: '该点歌券已被其他歌曲核销,无法用于此歌曲排期'
})
}
// 作废券继续排期会让财务/发放记录失真,因此这里让事务失败。
throw createError({
statusCode: 409,
message: `点歌券当前状态为 ${current.status},无法完成排期核销`
})
}
export const restoreCardCodeAfterScheduleRemoval = async (
tx: any,
{ songId, cardCodeId, operatorId, at }: CardCodeTransitionContext
) => {
if (!cardCodeId) return { changed: false, reason: 'NO_CARD_CODE' }
const restoredAt = getTransitionTime(at)
const current = await readCardCode(tx, cardCodeId)
if (!current) return { changed: false, reason: 'MISSING_CARD_CODE' }
if (current.status !== 'REDEEMED') {
return { changed: false, status: current.status }
}
const restoreResult = await tx
.update(cardCodes)
.set({
status: 'LOCKED',
lockedBy: current.lockedBy || operatorId,
lockedAt: current.lockedAt || restoredAt,
redeemedBy: null,
redeemedAt: null,
updatedAt: restoredAt
})
.where(and(eq(cardCodes.id, cardCodeId), eq(cardCodes.status, 'REDEEMED')))
.returning({ id: cardCodes.id, code: cardCodes.code })
if (restoreResult.length === 0) {
return { changed: false, reason: 'CONCURRENT_CHANGE' }
}
await tx.insert(cardCodeRedeemLogs).values({
cardCodeId,
codeSnapshot: restoreResult[0].code,
redeemedBy: operatorId,
redeemedAt: restoredAt,
source: 'SCHEDULE_REMOVE',
songId
})
return { changed: true, status: 'LOCKED' }
}
export const releaseCardCodeAfterSongWithdrawal = async (
tx: any,
{ songId, cardCodeId, operatorId, at }: CardCodeTransitionContext
) => {
if (!cardCodeId) return { changed: false, reason: 'NO_CARD_CODE' }
const releasedAt = getTransitionTime(at)
const current = await readCardCode(tx, cardCodeId)
if (!current) return { changed: false, reason: 'MISSING_CARD_CODE' }
if (!['LOCKED', 'REDEEMED'].includes(current.status)) {
return { changed: false, status: current.status }
}
const releaseResult = await tx
.update(cardCodes)
.set({
status: 'AVAILABLE',
lockedBy: null,
lockedAt: null,
redeemedBy: null,
redeemedAt: null,
updatedAt: releasedAt
})
.where(and(eq(cardCodes.id, cardCodeId), inArray(cardCodes.status, ['LOCKED', 'REDEEMED'])))
.returning({ id: cardCodes.id, code: cardCodes.code })
if (releaseResult.length === 0) {
return { changed: false, reason: 'CONCURRENT_CHANGE' }
}
await tx.insert(cardCodeRedeemLogs).values({
cardCodeId,
codeSnapshot: releaseResult[0].code,
redeemedBy: operatorId,
redeemedAt: releasedAt,
source: 'WITHDRAW',
songId
})
return { changed: true, status: 'AVAILABLE' }
}
@@ -0,0 +1,74 @@
import { db } from '~/drizzle/db'
import { songs } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { fetchSongDuration } from '~~/server/utils/songDurationFetcher'
import {
decideDurationOutcome,
type SongDurationDecision
} from '~~/server/utils/song-duration-policy'
/** 后台批量补齐的并发数 */
const BACKFILL_BATCH_SIZE = 5
/**
* 投稿后处理时长:缺失则按音源补齐,已有则校验一致性
* 仅返回决策,写库由调用方负责
*/
export async function reconcileSongDurationOnSubmit(
songId: number,
platform: string | null,
musicId: string | null,
storedDurationSeconds: number | null
): Promise<SongDurationDecision> {
if (!platform || !musicId) {
return { outcome: 'nocheck', durationSeconds: storedDurationSeconds }
}
try {
const actualDuration = await fetchSongDuration(platform, musicId)
const decision = decideDurationOutcome(storedDurationSeconds, actualDuration)
if (decision.outcome === 'clear') {
console.log(
`[投稿时长] #${songId} 时长不符 (提交: ${storedDurationSeconds}s, API: ${actualDuration}s),清除durationSeconds`
)
}
return decision
} catch (error) {
// 外部接口异常时保留原值,不因为后台问题影响用户投稿
console.error(`[投稿时长] #${songId} 处理失败:`, error)
return { outcome: 'nocheck', durationSeconds: storedDurationSeconds }
}
}
export interface SongDurationBackfillRow {
id: number
musicPlatform: string | null
musicId: string | null
}
type BackfillableRow = SongDurationBackfillRow & { musicPlatform: string; musicId: string }
/** 批量补齐缺失时长,单条失败不影响其余 */
export async function backfillMissingSongDurations(rows: SongDurationBackfillRow[], label: string): Promise<void> {
const targets = rows.filter(
(row): row is BackfillableRow => Boolean(row.musicPlatform) && Boolean(row.musicId)
)
for (let i = 0; i < targets.length; i += BACKFILL_BATCH_SIZE) {
const batch = targets.slice(i, i + BACKFILL_BATCH_SIZE)
await Promise.all(
batch.map(async (row) => {
try {
const duration = await fetchSongDuration(row.musicPlatform, row.musicId)
if (duration === null) {
console.warn(`[${label}] 歌曲 #${row.id} 未获取到有效时长`)
return
}
await db.update(songs).set({ durationSeconds: duration }).where(eq(songs.id, row.id))
} catch (error) {
console.warn(`[${label}] 歌曲 #${row.id} 时长补齐失败:`, error)
}
})
)
}
}
+221
View File
@@ -0,0 +1,221 @@
import { db } from '~/drizzle/db'
import { notificationSettings, users } from '~/drizzle/schema'
import { and, eq, inArray, isNotNull, ne } from 'drizzle-orm'
import { getSiteTitle } from '~~/server/utils/siteUtils'
/**
* 发送 MeoW 通知
*/
async function sendMeowNotification(
meowNickname: string,
title: string,
message: string,
url?: string
): Promise<boolean> {
try {
// URL 编码参数
const encodedNickname = encodeURIComponent(meowNickname)
const encodedTitle = encodeURIComponent(title)
const encodedMessage = encodeURIComponent(message)
// 构建请求 URL
let meowUrl = `https://api.chuckfang.com/${encodedNickname}/${encodedTitle}/${encodedMessage}`
if (url) {
meowUrl += `?url=${encodeURIComponent(url)}`
}
const response = await fetch(meowUrl, {
method: 'GET',
headers: {
'User-Agent': 'VoiceHub-Notification-Service'
}
})
if (response.ok) {
const result = await response.json()
console.log('MeoW 通知发送成功:', result)
return true
} else {
console.error('MeoW 通知发送失败:', response.status, response.statusText)
return false
}
} catch (error) {
console.error('发送 MeoW 通知时出错:', error)
return false
}
}
/**
* 为用户发送 MeoW 通知(如果已绑定)
*/
export async function sendMeowNotificationToUser(
userId: number,
notificationTitle: string,
notificationMessage: string,
url?: string
): Promise<boolean> {
try {
// 获取用户信息
const userResult = await db
.select({
name: users.name,
meowNickname: users.meowNickname,
id: users.id
})
.from(users)
.where(eq(users.id, userId))
.limit(1)
const user = userResult[0]
// 获取用户通知设置
let userNotificationSettings = null
if (user) {
const settingsResult = await db
.select({
enabled: notificationSettings.enabled
})
.from(notificationSettings)
.where(eq(notificationSettings.userId, userId))
.limit(1)
userNotificationSettings = settingsResult[0]
}
// 检查用户是否绑定了 MeoW 账号
if (!user?.meowNickname) {
return false
}
// 检查用户是否启用了通知
if (userNotificationSettings && !userNotificationSettings.enabled) {
return false
}
// 检查是否有其他用户绑定了相同的 MeoW ID
const usersWithSameMeowId = await db
.select({
name: users.name
})
.from(users)
.where(and(eq(users.meowNickname, user.meowNickname!), ne(users.id, userId)))
// 获取站点标题
const siteTitle = await getSiteTitle()
// 构建完整的通知标题
const fullTitle = `${notificationTitle} | ${siteTitle}通知推送`
// 如果有多个用户绑定了相同的 MeoW ID,在消息中加上用户名备注
let enhancedMessage = notificationMessage
if (usersWithSameMeowId.length > 0) {
// 检查消息是否以"您"开头,如果是则替换,否则添加前缀
if (notificationMessage.startsWith('您')) {
enhancedMessage = `您(${user.name})${notificationMessage.substring(1)}`
} else {
enhancedMessage = `您(${user.name})${notificationMessage}`
}
}
// 发送 MeoW 通知
return await sendMeowNotification(user.meowNickname, fullTitle, enhancedMessage, url)
} catch (error) {
console.error('发送用户 MeoW 通知失败:', error)
return false
}
}
/**
* 批量发送 MeoW 通知
*/
export async function sendBatchMeowNotifications(
userIds: number[],
notificationTitle: string,
notificationMessage: string,
url?: string
): Promise<{ success: number; failed: number }> {
let success = 0
let failed = 0
// 获取所有绑定了 MeoW 的用户
const usersWithMeow = await db
.select({
id: users.id,
name: users.name,
meowNickname: users.meowNickname
})
.from(users)
.where(and(inArray(users.id, userIds), isNotNull(users.meowNickname)))
// 获取这些用户的通知设置
const userNotificationSettingsMap = new Map()
if (usersWithMeow.length > 0) {
const settingsResults = await db
.select({
userId: notificationSettings.userId,
enabled: notificationSettings.enabled
})
.from(notificationSettings)
.where(
inArray(
notificationSettings.userId,
usersWithMeow.map((u) => u.id)
)
)
settingsResults.forEach((setting) => {
userNotificationSettingsMap.set(setting.userId, setting)
})
}
// 获取站点标题
const siteTitle = await getSiteTitle()
const fullTitle = `${notificationTitle} | ${siteTitle}通知推送`
// 统计每个 MeoW ID 绑定的用户数量
const meowIdCounts = new Map<string, number>()
usersWithMeow.forEach((user) => {
const count = meowIdCounts.get(user.meowNickname!) || 0
meowIdCounts.set(user.meowNickname!, count + 1)
})
// 并发发送通知(限制并发数)
const batchSize = 5
for (let i = 0; i < usersWithMeow.length; i += batchSize) {
const batch = usersWithMeow.slice(i, i + batchSize)
const promises = batch.map(async (user) => {
// 检查用户是否启用了通知
const userSettings = userNotificationSettingsMap.get(user.id)
if (userSettings && !userSettings.enabled) {
return false
}
// 如果该 MeoW ID 绑定了多个用户,在消息中加上用户名备注
let enhancedMessage = notificationMessage
const userCount = meowIdCounts.get(user.meowNickname!)
if (userCount && userCount > 1) {
// 检查消息是否以"您"开头,如果是则替换,否则添加前缀
if (notificationMessage.startsWith('您')) {
enhancedMessage = `您(${user.name})${notificationMessage.substring(1)}`
} else {
enhancedMessage = `您(${user.name})${notificationMessage}`
}
}
return await sendMeowNotification(user.meowNickname!, fullTitle, enhancedMessage, url)
})
const results = await Promise.allSettled(promises)
results.forEach((result) => {
if (result.status === 'fulfilled' && result.value) {
success++
} else {
failed++
}
})
}
return { success, failed }
}
+906
View File
@@ -0,0 +1,906 @@
import { db } from '~/drizzle/db'
import {
notifications,
notificationSettings,
playTimes,
schedules,
songCollaborators,
songs,
users,
votes
} from '~/drizzle/schema'
import { and, eq, gte, inArray } from 'drizzle-orm'
import { sendBatchMeowNotifications, sendMeowNotificationToUser } from './meowNotificationService'
import { sendBatchEmailNotifications, sendEmailNotificationToUser } from './smtpService'
import { formatDateTime, getBeijingTime } from '~/utils/timeUtils'
import { getSystemSettingsCached } from '~~/server/utils/system-settings-helper'
import {
createNotificationSenderSnapshot,
resolveNotificationSource,
shouldDeliverSystemNotification,
type NotificationSenderInput
} from '~~/server/utils/important-notification-policy'
import { randomUUID } from 'node:crypto'
/**
* 创建联合投稿邀请通知
*/
export async function createCollaborationInvitationNotification(
inviterId: number,
inviteeId: number,
songId: number,
songTitle: string
) {
try {
const inviter = await db
.select()
.from(users)
.where(eq(users.id, inviterId))
.limit(1)
.then((res) => res[0])
const message = `用户 ${inviter?.name || '未知用户'} 邀请您共同投稿歌曲《${songTitle}》。`
// 创建通知
const notificationResult = await db
.insert(notifications)
.values({
userId: inviteeId,
type: 'COLLABORATION_INVITE',
message,
songId
})
.returning()
// 发送外部通知(MeoW, Email等)
try {
await sendMeowNotificationToUser(inviteeId, '收到联合投稿邀请', message)
} catch (error) {
console.error('发送 MeoW 通知失败:', error)
}
// 同步发送邮件通知
try {
await sendEmailNotificationToUser(
inviteeId,
'收到联合投稿邀请',
message,
undefined,
'notification.collaborationInvite',
{
inviterName: inviter?.name || '未知用户',
songTitle
}
)
} catch (error) {
console.error('发送邮件通知失败:', error)
}
return notificationResult[0]
} catch (error) {
console.error('创建联合投稿邀请通知失败:', error)
return null
}
}
/**
* 创建联合投稿回复通知
*/
export async function createCollaborationResponseNotification(
inviteeId: number,
inviterId: number,
songTitle: string,
accepted: boolean
) {
try {
const invitee = await db
.select()
.from(users)
.where(eq(users.id, inviteeId))
.limit(1)
.then((res) => res[0])
const actionText = accepted ? '接受' : '拒绝'
const message = `用户 ${invitee?.name || '未知用户'} ${actionText}了您的歌曲《${songTitle}》的联合投稿邀请。`
// 创建通知
const notificationResult = await db
.insert(notifications)
.values({
userId: inviterId,
type: 'COLLABORATION_RESPONSE',
message
// songId // 这里可能不需要songId,或者需要传进来
})
.returning()
return notificationResult[0]
} catch (error) {
console.error('创建联合投稿回复通知失败:', error)
return null
}
}
/**
* 创建歌曲被选中的通知
*/
export async function createSongSelectedNotification(
userId: number,
songId: number,
songInfo: {
title: string
artist: string
playDate: Date
}
) {
try {
// 获取系统设置,检查是否启用播出时段功能
const systemConfig = await getSystemSettingsCached()
const isPlayTimeEnabled = systemConfig?.enablePlayTimeSelection || false
// 获取排期对应的播出时段
const scheduleResult = await db
.select({
id: schedules.id,
songId: schedules.songId,
playDate: schedules.playDate,
playTimeId: schedules.playTimeId,
playTime: {
id: playTimes.id,
name: playTimes.name,
startTime: playTimes.startTime,
endTime: playTimes.endTime
}
})
.from(schedules)
.leftJoin(playTimes, eq(schedules.playTimeId, playTimes.id))
.where(and(eq(schedules.songId, songId), eq(schedules.playDate, songInfo.playDate)))
.limit(1)
const schedule = scheduleResult[0]
// 获取用户通知设置
const settingsResult = await db
.select()
.from(notificationSettings)
.where(eq(notificationSettings.userId, userId))
.limit(1)
const settings = settingsResult[0]
// 如果用户关闭了此类通知,则不发送
if (settings && !settings.enabled) {
return null
}
// 创建通知,根据播出时段功能启用状态决定显示内容
let message = `您投稿的歌曲《${songInfo.title}》已被安排播放,播放日期:${formatDate(songInfo.playDate)}。`
// 只有在启用播出时段功能且有播出时段信息时,才添加播出时段详情
if (isPlayTimeEnabled && schedule?.playTime) {
let timeInfo = ''
// 根据开始和结束时间的情况,格式化显示
if (schedule.playTime.startTime && schedule.playTime.endTime) {
timeInfo = `(${schedule.playTime.startTime}-${schedule.playTime.endTime})`
} else if (schedule.playTime.startTime) {
timeInfo = `(开始时间:${schedule.playTime.startTime})`
} else if (schedule.playTime.endTime) {
timeInfo = `(结束时间:${schedule.playTime.endTime})`
}
message += `播出时段:${schedule.playTime.name}${timeInfo ? ' ' + timeInfo : ''}。`
}
let notification
try {
const notificationResult = await db
.insert(notifications)
.values({
userId,
type: 'SONG_SELECTED',
message,
songId
})
.returning()
notification = notificationResult[0]
} catch (error) {
throw error
}
// 同步发送 MeoW 通知
try {
await sendMeowNotificationToUser(userId, '收到新选中', message)
} catch (error) {
console.error('发送 MeoW 通知失败:', error)
}
// 同步发送邮件通知
try {
await sendEmailNotificationToUser(
userId,
'歌曲被选中',
message,
undefined,
'notification.songSelected',
{
songTitle: songInfo.title,
playDate: formatDate(songInfo.playDate),
playTimeName: isPlayTimeEnabled && schedule?.playTime ? schedule.playTime.name : '',
playTimeRange:
isPlayTimeEnabled &&
schedule?.playTime &&
(schedule.playTime.startTime || schedule.playTime.endTime)
? `${schedule.playTime.startTime || ''}${schedule.playTime.startTime && schedule.playTime.endTime ? '-' : ''}${schedule.playTime.endTime || ''}`
: ''
}
)
} catch (error) {
console.error('发送邮件通知失败:', error)
}
return notification
} catch (err) {
return null
}
}
/**
* 创建重播申请已安排排期的通知(发送给重播申请人)
*/
export async function createReplaySongSelectedNotification(
userId: number,
songId: number,
songInfo: {
title: string
artist: string
playDate: Date
},
scheduleId?: number
) {
try {
// 获取系统设置,检查是否启用播出时段功能
const systemConfig = await getSystemSettingsCached()
const isPlayTimeEnabled = systemConfig?.enablePlayTimeSelection || false
// 优先按排期 ID 精确查询,避免同日同歌多排期时取错时段
const scheduleResult = await db
.select({
id: schedules.id,
playTime: {
id: playTimes.id,
name: playTimes.name,
startTime: playTimes.startTime,
endTime: playTimes.endTime
}
})
.from(schedules)
.leftJoin(playTimes, eq(schedules.playTimeId, playTimes.id))
.where(
scheduleId
? eq(schedules.id, scheduleId)
: and(eq(schedules.songId, songId), eq(schedules.playDate, songInfo.playDate))
)
.limit(1)
const schedule = scheduleResult[0]
// 获取用户通知设置
const settingsResult = await db
.select()
.from(notificationSettings)
.where(eq(notificationSettings.userId, userId))
.limit(1)
const settings = settingsResult[0]
if (settings && !settings.enabled) {
return null
}
let message = `您申请重播的歌曲《${songInfo.title}》已安排播放,播放日期:${formatDate(songInfo.playDate)}。`
if (isPlayTimeEnabled && schedule?.playTime) {
let timeInfo = ''
if (schedule.playTime.startTime && schedule.playTime.endTime) {
timeInfo = `(${schedule.playTime.startTime}-${schedule.playTime.endTime})`
} else if (schedule.playTime.startTime) {
timeInfo = `(开始时间:${schedule.playTime.startTime})`
} else if (schedule.playTime.endTime) {
timeInfo = `(结束时间:${schedule.playTime.endTime})`
}
message += `播出时段:${schedule.playTime.name}${timeInfo ? ' ' + timeInfo : ''}。`
}
let notification
try {
const notificationResult = await db
.insert(notifications)
.values({
userId,
type: 'SONG_SELECTED',
message,
songId
})
.returning()
notification = notificationResult[0]
} catch (error) {
throw error
}
try {
await sendMeowNotificationToUser(userId, '重播已安排', message)
} catch (error) {
console.error('发送 MeoW 通知失败:', error)
}
try {
await sendEmailNotificationToUser(
userId,
'重播申请已安排',
message,
undefined,
'notification.replaySongSelected',
{
songTitle: songInfo.title,
playDate: formatDate(songInfo.playDate),
playTimeName: isPlayTimeEnabled && schedule?.playTime ? schedule.playTime.name : ''
}
)
} catch (error) {
console.error('发送邮件通知失败:', error)
}
return notification
} catch (err) {
return null
}
}
// 格式化日期为 yyyy-MM-dd 格式(北京时间)
function formatDate(date: Date): string {
return formatDateTime(date, 'YYYY-MM-DD')
}
/**
* 创建歌曲已播放的通知
*/
export async function createSongPlayedNotification(songId: number) {
try {
// 获取歌曲信息
const songResult = await db.select().from(songs).where(eq(songs.id, songId)).limit(1)
const song = songResult[0]
if (!song) {
return null
}
// 获取用户通知设置
const settingsResult = await db
.select()
.from(notificationSettings)
.where(eq(notificationSettings.userId, song.requesterId))
.limit(1)
const settings = settingsResult[0]
// 如果用户关闭了此类通知,则不发送
if (settings && !settings.songPlayedEnabled) {
return null
}
// 创建通知
const message = `您投稿的歌曲《${song.title}》已播放。`
// 获取所有关联用户(投稿人 + 联合投稿人)
const userIdsToNotify = [song.requesterId]
// 获取联合投稿人
const collaborators = await db
.select()
.from(songCollaborators)
.where(and(eq(songCollaborators.songId, songId), eq(songCollaborators.status, 'ACCEPTED')))
collaborators.forEach((c) => {
if (!userIdsToNotify.includes(c.userId)) {
userIdsToNotify.push(c.userId)
}
})
const notificationsCreated = []
for (const targetUserId of userIdsToNotify) {
try {
// 获取用户通知设置
const settingsResult = await db
.select()
.from(notificationSettings)
.where(eq(notificationSettings.userId, targetUserId))
.limit(1)
const settings = settingsResult[0]
// 如果用户关闭了此类通知,则不发送
if (settings && !settings.songPlayedEnabled) {
continue
}
const userMessage =
targetUserId === song.requesterId
? message
: `您参与联合投稿的歌曲《${song.title}》已播放。`
const notificationResult = await db
.insert(notifications)
.values({
userId: targetUserId,
type: 'SONG_PLAYED',
message: userMessage,
songId: songId
})
.returning()
notificationsCreated.push(notificationResult[0])
// 同步发送 MeoW 通知
try {
await sendMeowNotificationToUser(targetUserId, '歌曲已播放', userMessage)
} catch (error) {
console.error(`发送 MeoW 通知失败 (User: ${targetUserId}):`, error)
}
// 同步发送邮件通知
try {
await sendEmailNotificationToUser(
targetUserId,
'歌曲已播放',
userMessage,
undefined,
'notification.songPlayed',
{
songTitle: song.title
}
)
} catch (error) {
console.error(`发送邮件通知失败 (User: ${targetUserId}):`, error)
}
} catch (err) {
console.error(`处理播放通知失败 (User: ${targetUserId}):`, err)
}
}
return notificationsCreated.length > 0 ? notificationsCreated[0] : null
} catch (err) {
return null
}
}
/**
* 创建歌曲获得投票的通知
*/
export async function createSongVotedNotification(songId: number, voterId: number) {
try {
// 获取歌曲信息
const songResult = await db.select().from(songs).where(eq(songs.id, songId)).limit(1)
const song = songResult[0]
if (!song) {
return null
}
// 获取歌曲的投票信息
const songVotes = await db.select().from(votes).where(eq(votes.songId, songId))
// 获取投票用户信息
const voterResult = await db.select().from(users).where(eq(users.id, voterId)).limit(1)
const voter = voterResult[0]
if (!voter) {
return null
}
// 获取用户通知设置
const settingsResult = await db
.select()
.from(notificationSettings)
.where(eq(notificationSettings.userId, song.requesterId))
.limit(1)
const settings = settingsResult[0]
// 如果用户关闭了此类通知,则不发送
if (settings && !settings.songVotedEnabled) {
return null
}
// 不要给自己投票发通知
if (song.requesterId === voterId) {
return null
}
// 检查投票阈值
const threshold = settings?.songVotedThreshold || 1
if (songVotes.length % threshold !== 0) {
return null
}
const message = `您投稿的歌曲《${song.title}》获得了一个新的投票,当前共有 ${songVotes.length} 个投票。`
// 防重复通知:检查最近5分钟内是否有相同歌曲的投票通知
const fiveMinutesAgo = new Date(getBeijingTime().getTime() - 5 * 60 * 1000)
const existingNotificationResult = await db
.select()
.from(notifications)
.where(
and(
eq(notifications.userId, song.requesterId),
eq(notifications.type, 'SONG_VOTED'),
eq(notifications.songId, songId),
eq(notifications.read, false),
gte(notifications.createdAt, fiveMinutesAgo)
)
)
.orderBy(notifications.createdAt)
.limit(1)
const existingNotification = existingNotificationResult[0]
let notification
if (existingNotification) {
// 更新现有通知
const updateResult = await db
.update(notifications)
.set({
message
})
.where(eq(notifications.id, existingNotification.id))
.returning()
notification = updateResult[0]
} else {
// 创建新通知
const createResult = await db
.insert(notifications)
.values({
userId: song.requesterId,
type: 'SONG_VOTED',
message,
songId: songId
})
.returning()
notification = createResult[0]
}
// 同步发送 MeoW 通知
try {
await sendMeowNotificationToUser(song.requesterId, '收到新投票', message)
} catch (error) {
console.error('发送 MeoW 通知失败:', error)
}
// 同步发送邮件通知
try {
await sendEmailNotificationToUser(
song.requesterId,
'收到新投票',
message,
undefined,
'notification.songVoted',
{
songTitle: song.title,
votesCount: songVotes.length
}
)
} catch (error) {
console.error('发送邮件通知失败:', error)
}
return notification
} catch (err) {
return null
}
}
/**
* 创建歌曲驳回通知
*/
export async function createSongRejectedNotification(
userId: number,
songInfo: { title: string; artist: string },
reason: string
) {
try {
// 获取用户通知设置
const settingsResult = await db
.select()
.from(notificationSettings)
.where(eq(notificationSettings.userId, userId))
.limit(1)
const settings = settingsResult[0]
// 如果用户关闭了通知,则不发送
if (settings && !settings.enabled) {
return null
}
// 创建通知消息
const message = `您投稿的歌曲《${songInfo.title} - ${songInfo.artist}》已被管理员驳回。驳回原因:${reason}`
// 创建站内通知
let notification
try {
const notificationResult = await db
.insert(notifications)
.values({
userId,
type: 'SONG_REJECTED',
message
})
.returning()
notification = notificationResult[0]
} catch (error) {
throw error
}
// 同步发送 MeoW 通知
try {
await sendMeowNotificationToUser(userId, '歌曲被驳回', message)
} catch (error) {
console.error('发送 MeoW 通知失败:', error)
}
// 同步发送邮件通知
try {
await sendEmailNotificationToUser(
userId,
'歌曲被驳回',
message,
undefined,
'notification.songRejected',
{
songTitle: `${songInfo.title} - ${songInfo.artist}`,
reason
}
)
} catch (error) {
console.error('发送邮件通知失败:', error)
}
return notification
} catch (err) {
console.error('创建歌曲驳回通知失败:', err)
return null
}
}
export async function createSubmissionNoteClearedNotification(
userIds: number[],
songInfo: { title: string; artist: string },
reason?: string
) {
try {
const uniqueUserIds = [
...new Set(userIds.filter((userId) => Number.isInteger(userId) && userId > 0))
]
if (uniqueUserIds.length === 0) {
return []
}
const title = '歌曲备注已被管理员清空'
const content = reason?.trim()
? `您投稿歌曲《${songInfo.title} - ${songInfo.artist}》的备注已被管理员清空。原因:${reason.trim()}`
: `您投稿歌曲《${songInfo.title} - ${songInfo.artist}》的备注已被管理员清空。`
return await createBatchSystemNotifications(uniqueUserIds, title, content)
} catch (error) {
console.error('创建歌曲备注清空通知失败:', error)
return []
}
}
/**
* 创建系统通知
*/
export async function createSystemNotification(
userId: number,
title: string,
content: string,
important = false,
sender: NotificationSenderInput | null = null
) {
try {
const batchId = randomUUID()
const senderSnapshot = createNotificationSenderSnapshot(sender)
const source = resolveNotificationSource(sender)
// 获取用户通知设置
const settingsResult = await db
.select()
.from(notificationSettings)
.where(eq(notificationSettings.userId, userId))
.limit(1)
const settings = settingsResult[0]
// 如果用户关闭了通知,则不发送
if (!shouldDeliverSystemNotification(important, settings?.enabled)) {
return null
}
// 创建通知
const notificationResult = await db
.insert(notifications)
.values({
userId: userId,
type: 'SYSTEM_NOTICE',
batchId,
source,
...senderSnapshot,
title,
message: content,
important
})
.returning()
const notification = notificationResult[0]
// 同步发送 MeoW 通知
try {
await sendMeowNotificationToUser(userId, title, content)
} catch (error) {
console.error('发送 MeoW 通知失败:', error)
}
// 同步发送邮件通知
try {
await sendEmailNotificationToUser(userId, title, content)
} catch (error) {
console.error('发送邮件通知失败:', error)
}
return notification
} catch (err) {
return null
}
}
/**
* 批量创建系统通知
*/
export async function createBatchSystemNotifications(
userIds: number[],
title: string,
content: string,
important = false,
sender: NotificationSenderInput | null = null
) {
try {
if (!userIds.length) {
return []
}
const batchId = randomUUID()
const senderSnapshot = createNotificationSenderSnapshot(sender)
const source = resolveNotificationSource(sender)
// 获取用户通知设置
const userSettings = await db
.select()
.from(notificationSettings)
.where(inArray(notificationSettings.userId, userIds))
// 构建用户ID到通知设置的映射
const settingsMap = new Map()
userSettings.forEach((setting) => {
settingsMap.set(setting.userId, setting)
})
// 准备要创建的通知数据
const notificationsToCreate = []
for (const userId of userIds) {
const settings = settingsMap.get(userId)
// 如果用户关闭了通知,则不发送
if (!shouldDeliverSystemNotification(important, settings?.enabled)) {
continue
}
notificationsToCreate.push({
userId,
type: 'SYSTEM_NOTICE',
batchId,
source,
...senderSnapshot,
title,
message: content,
important
})
}
// 如果没有可发送的通知,直接返回
if (notificationsToCreate.length === 0) {
return []
}
// 批量创建通知
const createdNotifications = await db
.insert(notifications)
.values(notificationsToCreate)
.returning()
const notificationCount = { count: createdNotifications.length }
// 同步发送 MeoW 通知
let meowResults = { success: 0, failed: 0 }
try {
meowResults = await sendBatchMeowNotifications(userIds, title, content)
} catch (error) {
console.error('批量发送 MeoW 通知失败:', error)
}
// 同步发送邮件通知
let emailResults = { success: 0, failed: 0 }
try {
emailResults = await sendBatchEmailNotifications(userIds, title, content)
} catch (error) {
console.error('批量发送邮件通知失败:', error)
}
return {
count: notificationCount.count,
total: userIds.length,
meowNotifications: meowResults,
emailNotifications: emailResults
}
} catch (err) {
return null
}
}
/**
* 创建重播申请拒绝通知
*/
export async function createReplayRequestRejectedNotification(
userId: number,
songInfo: { title: string; artist: string }
) {
try {
// 获取用户通知设置
const settingsResult = await db
.select()
.from(notificationSettings)
.where(eq(notificationSettings.userId, userId))
.limit(1)
const settings = settingsResult[0]
// 如果用户关闭了通知,则不发送
if (settings && !settings.enabled) {
return null
}
// 创建通知消息
const message = `您的重播申请《${songInfo.title}》已被管理员拒绝。`
// 创建站内通知
let notification
try {
const notificationResult = await db
.insert(notifications)
.values({
userId,
type: 'REPLAY_REJECTED',
message
})
.returning()
notification = notificationResult[0]
} catch (error) {
throw error
}
// 同步发送 MeoW 通知
try {
await sendMeowNotificationToUser(userId, '重播申请已拒绝', message)
} catch (error) {
console.error('发送 MeoW 通知失败:', error)
}
// 同步发送邮件通知
try {
await sendEmailNotificationToUser(userId, '重播申请已拒绝', message)
} catch (error) {
console.error('发送邮件通知失败:', error)
}
return notification
} catch (err) {
console.error('创建重播申请拒绝通知失败:', err)
return null
}
}
+130
View File
@@ -0,0 +1,130 @@
import { db } from '~/drizzle/db'
import { systemSettings } from '~/drizzle/schema'
import { getAggregateOAuthLoginTypesOrDefault } from '~~/server/utils/oauth-providers'
export type { OAuthProvider } from '~~/server/utils/oauth-providers'
export {
SUPPORTED_OAUTH_PROVIDERS,
isSupportedOAuthProvider
} from '~~/server/utils/oauth-providers'
export interface ProviderRuntimeConfig {
clientId?: string
clientSecret?: string
endpoint?: string
authorizeUrl?: string
tokenUrl?: string
userInfoUrl?: string
scope?: string
displayName?: string
userIdField?: string
usernameField?: string
nameField?: string
emailField?: string
avatarField?: string
loginType?: string
loginTypes?: string[]
}
const getSettings = async () => {
const settingsResult = await db.select().from(systemSettings).limit(1)
return settingsResult[0] || null
}
export const isOAuthProviderEnabled = async (provider: OAuthProvider): Promise<boolean> => {
const settings = await getSettings()
if (!settings) return false
if (provider === 'github') return !!settings.githubOAuthEnabled
if (provider === 'casdoor') return !!settings.casdoorOAuthEnabled
if (provider === 'google') return !!settings.googleOAuthEnabled
if (provider === 'oauth2') return !!settings.customOAuthEnabled
if (provider === 'aggregate') return !!settings.aggregateOAuthEnabled
return false
}
export const getProviderRuntimeConfig = async (
provider: OAuthProvider
): Promise<ProviderRuntimeConfig> => {
const settings = await getSettings()
if (!settings) return {}
if (provider === 'github') {
return {
clientId: settings.githubClientId || undefined,
clientSecret: settings.githubClientSecret || undefined
}
}
if (provider === 'casdoor') {
return {
clientId: settings.casdoorClientId || undefined,
clientSecret: settings.casdoorClientSecret || undefined,
endpoint: settings.casdoorServerUrl || undefined
}
}
if (provider === 'oauth2') {
return {
clientId: settings.customOAuthClientId || undefined,
clientSecret: settings.customOAuthClientSecret || undefined,
authorizeUrl: settings.customOAuthAuthorizeUrl || undefined,
tokenUrl: settings.customOAuthTokenUrl || undefined,
userInfoUrl: settings.customOAuthUserInfoUrl || undefined,
scope: settings.customOAuthScope || undefined,
displayName: settings.customOAuthDisplayName || undefined,
userIdField: settings.customOAuthUserIdField || undefined,
usernameField: settings.customOAuthUsernameField || undefined,
nameField: settings.customOAuthNameField || undefined,
emailField: settings.customOAuthEmailField || undefined,
avatarField: settings.customOAuthAvatarField || undefined
}
}
if (provider === 'aggregate') {
const loginTypes = getAggregateOAuthLoginTypesOrDefault(settings.aggregateOAuthLoginType)
return {
clientId: settings.aggregateOAuthAppId || undefined,
clientSecret: settings.aggregateOAuthAppKey || undefined,
loginTypes,
endpoint: settings.aggregateOAuthEndpoint || undefined
}
}
return {
clientId: settings.googleClientId || undefined,
clientSecret: settings.googleClientSecret || undefined
}
}
export const getOAuthProviderDisplayName = async (provider: OAuthProvider): Promise<string> => {
if (provider !== 'oauth2') {
if (provider === 'github') return 'GitHub'
if (provider === 'casdoor') return 'Casdoor'
if (provider === 'google') return 'Google'
if (provider === 'aggregate') return '聚合登陆'
}
const settings = await getSettings()
return settings?.customOAuthDisplayName || '第三方 OAuth'
}
export const getOAuthBaseConfig = async (): Promise<{
stateSecret?: string
redirectUriTemplate?: string
}> => {
const settings = await getSettings()
if (!settings) {
return {
stateSecret: undefined,
redirectUriTemplate: undefined
}
}
return {
stateSecret: settings.oauthStateSecret || undefined,
redirectUriTemplate: settings.oauthRedirectUri || undefined
}
}
+115
View File
@@ -0,0 +1,115 @@
import { getRequestHeader } from 'h3'
import { db } from '~/drizzle/db'
import { passwordAuditLogs, passwordRateLimits } from '~/drizzle/schema'
import { lte, sql } from 'drizzle-orm'
import { getClientIP } from '~~/server/utils/ip-utils'
export const PASSWORD_AUDIT_ACTIONS = {
INITIAL_PASSWORD: 'INITIAL_PASSWORD',
CHANGE_PASSWORD: 'CHANGE_PASSWORD',
RESET_PASSWORD: 'RESET_PASSWORD'
} as const
const PASSWORD_RATE_WINDOW_SECONDS = 10 * 60
const PASSWORD_IP_LIMIT_MULTIPLIER = 20
export async function consumePasswordRateLimit(
userId: number,
ipAddress: string,
action: string,
limit: number
): Promise<{ allowed: boolean; retryAfterSeconds: number }> {
const now = new Date()
const resetAt = new Date(now.getTime() + PASSWORD_RATE_WINDOW_SECONDS * 1000)
const keys = [`user:${action}:${userId}`, `ip:${action}:${ipAddress}`]
const comparisonNow = sql.param(now, passwordRateLimits.resetAt)
const buckets = await db.transaction(async (tx) => {
const values: Array<{ count: number; resetAt: Date }> = []
for (const key of keys) {
// 使用 Node 统一生成的时间,并通过时间列编码器传入 SQL,避免数据库时区影响比较结果。
const [result] = await tx
.insert(passwordRateLimits)
.values({ key, count: 1, resetAt })
.onConflictDoUpdate({
target: passwordRateLimits.key,
set: {
count: sql<number>`CASE
WHEN ${passwordRateLimits.resetAt} <= ${comparisonNow} THEN 1
ELSE ${passwordRateLimits.count} + 1
END`,
resetAt: sql<Date>`CASE
WHEN ${passwordRateLimits.resetAt} <= ${comparisonNow} THEN excluded."resetAt"
ELSE ${passwordRateLimits.resetAt}
END`
}
})
.returning({
count: passwordRateLimits.count,
resetAt: passwordRateLimits.resetAt
})
values.push({
count: result?.count ?? 1,
resetAt: result?.resetAt ?? resetAt
})
}
// 惰性清理过期键,避免攻击者通过大量伪造 IP 使限流表无限增长。
if (Math.random() < 0.02) {
await tx.delete(passwordRateLimits).where(lte(passwordRateLimits.resetAt, now))
}
return values
})
const [userBucket, ipBucket] = buckets
const exceededBuckets = [
userBucket && userBucket.count > limit ? userBucket : null,
ipBucket && ipBucket.count > limit * PASSWORD_IP_LIMIT_MULTIPLIER ? ipBucket : null
].filter((bucket): bucket is { count: number; resetAt: Date } => !!bucket)
const retryAfterSeconds = exceededBuckets.length
? Math.max(
1,
...exceededBuckets.map((bucket) =>
Math.ceil((new Date(bucket.resetAt).getTime() - now.getTime()) / 1000)
)
)
: 0
return {
allowed: exceededBuckets.length === 0,
retryAfterSeconds
}
}
export async function recordPasswordAudit(
event: any,
userId: number,
action: string,
success: boolean,
failureReason?: string,
actorId?: number | null
): Promise<void> {
try {
await db.insert(passwordAuditLogs).values({
userId,
actorId: actorId ?? null,
action,
success,
ipAddress: getClientIP(event),
userAgent: getRequestHeader(event, 'user-agent') || null,
failureReason: failureReason || null
})
} catch (error) {
// 审计失败不应覆盖密码操作结果,但必须保留服务端告警便于补偿。
console.error('[PasswordAudit] 写入密码审计日志失败:', error)
}
}
export function getPasswordAuditContext(event: any) {
return {
ipAddress: getClientIP(event),
userAgent: getRequestHeader(event, 'user-agent') || null
}
}
+808
View File
@@ -0,0 +1,808 @@
import {
RedisStateUnavailableError,
setStore,
getStore,
delStore,
incrStore
} from '~~/server/utils/captchaStore'
import { createSystemNotification } from './notificationService'
import { sendMeowNotificationToUser } from './meowNotificationService'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { getServerTimestamp, getServerDate } from '~~/server/utils/serverTime'
// 账户锁定信息接口
interface AccountLockInfo {
failedAttempts: number
lockedUntil: Date | null
lastAttemptTime: Date
}
// IP监控信息接口
interface IPMonitorInfo {
attemptedAccounts: Set<string>
firstAttemptTime: Date
lastAttemptTime: Date
}
// IP黑名单信息接口
interface IPBlockInfo {
blockedUntil: Date
reason: string
blockedTime: Date
}
// 内存存储
const ipMonitor = new Map<string, IPMonitorInfo>()
const ipBlacklist = new Map<string, IPBlockInfo>()
const accountIpSwitchMonitor = new Map<
string,
{ ipMap: Map<string, number>; windowStart: number }
>()
const userBlockUntil = new Map<number, Date>()
const songVoteWindow = new Map<number, number[]>()
const songProtectUntil = new Map<number, Date>()
const songVoteIpBuckets = new Map<number, Map<string, number>>()
const userVoteStats = new Map<
number,
{ emaPerMin: number; lastUpdate: number; windowTimestamps: number[] }
>()
// 通知限流存储,记录最近发送的通知,避免重复发送
const notificationRateLimit = new Map<string, Date>()
const NOTIFICATION_RATE_LIMIT_MINUTES = 5 // 同一类型通知5分钟内只发送一次
const fallbackLoginFailures = new Map<string, { count: number; expiresAt: number }>()
const fallbackAccountLocks = new Map<string, number>()
const REDIS_FALLBACK_LOG_INTERVAL_MS = 60 * 1000
let lastRedisFallbackLogAt = 0
// 配置常量
const SECURITY_CONFIG = {
MAX_FAILED_ATTEMPTS: 5,
LOCK_DURATION_MINUTES: 10,
IP_MONITOR_WINDOW_MINUTES: 10,
IP_MAX_DIFFERENT_ACCOUNTS: 3,
IP_BLOCK_DURATION_MINUTES: 10
}
const RISK_CONTROL = {
IP_SWITCH_WINDOW_MS: 5 * 60 * 1000,
IP_SWITCH_THRESHOLD: 3,
IP_SWITCH_BLOCK_MINUTES: 10,
USER_BLOCK_MINUTES: 10,
SONG_VOTE_PROTECT_WINDOW_MS: 60 * 60 * 1000,
SONG_VOTE_PROTECT_THRESHOLD: 10,
SONG_VOTE_PROTECT_DURATION_MS: 10 * 60 * 1000,
USER_BASELINE_MIN_PER_MIN: 0.5,
USER_BASELINE_MULTIPLIER: 3,
EMA_ALPHA: 0.2,
SONG_IP_DOMINANCE_PERCENT: 0.6,
SONG_IP_MIN_SAMPLE: 8
}
const handleRedisStateUnavailable = (error: unknown) => {
if (!(error instanceof RedisStateUnavailableError)) throw error
const now = getServerTimestamp()
if (now - lastRedisFallbackLogAt >= REDIS_FALLBACK_LOG_INTERVAL_MS) {
console.warn('[Security] Redis 暂不可用,登录失败计数临时回退到当前实例内存')
lastRedisFallbackLogAt = now
}
}
const getFallbackFailureCount = (username: string) => {
const record = fallbackLoginFailures.get(username)
if (!record) return 0
if (record.expiresAt <= getServerTimestamp()) {
fallbackLoginFailures.delete(username)
return 0
}
return record.count
}
const incrementFallbackFailureCount = (username: string) => {
const now = getServerTimestamp()
if (fallbackLoginFailures.size >= 10000) {
for (const [key, record] of fallbackLoginFailures.entries()) {
if (record.expiresAt <= now) fallbackLoginFailures.delete(key)
}
if (fallbackLoginFailures.size >= 10000 && !fallbackLoginFailures.has(username)) {
const oldestKey = fallbackLoginFailures.keys().next().value
if (oldestKey !== undefined) fallbackLoginFailures.delete(oldestKey)
}
}
const current = fallbackLoginFailures.get(username)
const count = current && current.expiresAt > now ? current.count + 1 : 1
fallbackLoginFailures.set(username, {
count,
expiresAt: current && current.expiresAt > now ? current.expiresAt : now + 30 * 60 * 1000
})
return count
}
const getFallbackLockedUntil = (username: string) => {
const lockedUntil = fallbackAccountLocks.get(username) || 0
if (lockedUntil <= getServerTimestamp()) {
fallbackAccountLocks.delete(username)
return 0
}
return lockedUntil
}
/**
* 清理过期的锁定记录
*/
function cleanupExpiredLocks() {
const now = getServerDate()
for (const [username, record] of fallbackLoginFailures.entries()) {
if (record.expiresAt <= now.getTime()) fallbackLoginFailures.delete(username)
}
for (const [username, lockedUntil] of fallbackAccountLocks.entries()) {
if (lockedUntil <= now.getTime()) fallbackAccountLocks.delete(username)
}
// 清理过期的IP监控记录
for (const [ip, monitorInfo] of ipMonitor.entries()) {
const windowStart = new Date(
now.getTime() - SECURITY_CONFIG.IP_MONITOR_WINDOW_MINUTES * 60 * 1000
)
if (monitorInfo.firstAttemptTime < windowStart) {
ipMonitor.delete(ip)
}
}
// 清理过期的IP黑名单记录
for (const [ip, blockInfo] of ipBlacklist.entries()) {
if (blockInfo.blockedUntil <= now) {
ipBlacklist.delete(ip)
console.log(`IP ${ip} 已从黑名单中移除`)
}
}
for (const [username, monitor] of accountIpSwitchMonitor.entries()) {
const cutoff = getServerTimestamp() - RISK_CONTROL.IP_SWITCH_WINDOW_MS
for (const [ip, ts] of monitor.ipMap.entries()) {
if (ts < cutoff) {
monitor.ipMap.delete(ip)
}
}
if (monitor.ipMap.size === 0) {
accountIpSwitchMonitor.delete(username)
}
}
for (const [songId, timestamps] of songVoteWindow.entries()) {
const cutoff = getServerTimestamp() - RISK_CONTROL.SONG_VOTE_PROTECT_WINDOW_MS
const filtered = timestamps.filter((t) => t >= cutoff)
if (filtered.length) {
songVoteWindow.set(songId, filtered)
} else {
songVoteWindow.delete(songId)
}
}
for (const [userId, stats] of userVoteStats.entries()) {
const cutoff = getServerTimestamp() - 10 * 60 * 1000
stats.windowTimestamps = stats.windowTimestamps.filter((t) => t >= cutoff)
if (
stats.windowTimestamps.length === 0 &&
getServerTimestamp() - stats.lastUpdate > 60 * 60 * 1000
) {
userVoteStats.delete(userId)
}
}
}
/**
* 检查账户是否被锁定
*/
export async function isAccountLocked(username: string): Promise<boolean> {
const lockKey = `account_lock:${username}`
let redisLockedUntil = 0
try {
const lockInfoStr = await getStore(lockKey)
if (lockInfoStr) {
const parsed = parseInt(lockInfoStr, 10)
if (Number.isFinite(parsed)) redisLockedUntil = parsed
}
} catch (error) {
handleRedisStateUnavailable(error)
}
return Math.max(redisLockedUntil, getFallbackLockedUntil(username)) > getServerTimestamp()
}
/**
* 获取账户锁定剩余时间(分钟)
*/
export async function getAccountLockRemainingTime(username: string): Promise<number> {
const lockKey = `account_lock:${username}`
let redisLockedUntil = 0
try {
const lockInfoStr = await getStore(lockKey)
if (lockInfoStr) {
const parsed = parseInt(lockInfoStr, 10)
if (Number.isFinite(parsed)) redisLockedUntil = parsed
}
} catch (error) {
handleRedisStateUnavailable(error)
}
const now = getServerTimestamp()
const lockedUntil = Math.max(redisLockedUntil, getFallbackLockedUntil(username))
if (lockedUntil <= now) return 0
return Math.ceil((lockedUntil - now) / (1000 * 60))
}
/**
* 检查IP是否被限制
*/
export function isIPBlocked(ip: string): boolean {
cleanupExpiredLocks()
const blockInfo = ipBlacklist.get(ip)
if (!blockInfo) {
return false
}
return blockInfo.blockedUntil > getServerDate()
}
/**
* 获取IP限制剩余时间(分钟)
*/
export function getIPBlockRemainingTime(ip: string): number {
const blockInfo = ipBlacklist.get(ip)
if (!blockInfo) {
return 0
}
const now = getServerDate()
if (blockInfo.blockedUntil <= now) {
return 0
}
return Math.ceil((blockInfo.blockedUntil.getTime() - now.getTime()) / (1000 * 60))
}
/**
* 将IP加入黑名单
*/
function blockIP(ip: string, reason: string): void {
const now = getServerDate()
const blockedUntil = new Date(
now.getTime() + SECURITY_CONFIG.IP_BLOCK_DURATION_MINUTES * 60 * 1000
)
ipBlacklist.set(ip, {
blockedUntil,
reason,
blockedTime: now
})
console.log(
`IP ${ip} 已被加入黑名单,限制时长 ${SECURITY_CONFIG.IP_BLOCK_DURATION_MINUTES} 分钟,原因:${reason}`
)
}
export function blockUser(userId: number, minutes: number = RISK_CONTROL.USER_BLOCK_MINUTES): void {
const until = new Date(getServerTimestamp() + minutes * 60 * 1000)
userBlockUntil.set(userId, until)
}
export function isUserBlocked(userId: number): boolean {
const until = userBlockUntil.get(userId)
if (!until) return false
return until > getServerDate()
}
export function getUserBlockRemainingTime(userId: number): number {
const until = userBlockUntil.get(userId)
if (!until) return 0
const now = getServerTimestamp()
if (until.getTime() <= now) return 0
return Math.ceil((until.getTime() - now) / (1000 * 60))
}
/**
* 记录登录失败
*/
export async function recordLoginFailure(username: string, ip: string): Promise<void> {
const failKey = `login_fail:${username}`
const lockKey = `account_lock:${username}`
let failedAttempts: number | null = null
try {
// 增加失败计数,有效期为 30 分钟
failedAttempts = Math.max(await incrStore(failKey, 30 * 60), getFallbackFailureCount(username))
if (failedAttempts >= SECURITY_CONFIG.MAX_FAILED_ATTEMPTS) {
const lockedUntil = getServerTimestamp() + SECURITY_CONFIG.LOCK_DURATION_MINUTES * 60 * 1000
await setStore(lockKey, lockedUntil.toString(), SECURITY_CONFIG.LOCK_DURATION_MINUTES * 60)
}
} catch (error) {
handleRedisStateUnavailable(error)
failedAttempts = Math.max(failedAttempts || 0, incrementFallbackFailureCount(username))
if (failedAttempts >= SECURITY_CONFIG.MAX_FAILED_ATTEMPTS) {
fallbackAccountLocks.set(
username,
getServerTimestamp() + SECURITY_CONFIG.LOCK_DURATION_MINUTES * 60 * 1000
)
}
}
if (failedAttempts >= SECURITY_CONFIG.MAX_FAILED_ATTEMPTS) {
console.log(
`账户 ${username} 因连续 ${SECURITY_CONFIG.MAX_FAILED_ATTEMPTS} 次登录失败被锁定 ${SECURITY_CONFIG.LOCK_DURATION_MINUTES} 分钟`
)
}
// 记录IP监控信息
recordIPAttempt(ip, username)
}
/**
* 记录成功登录(清除失败记录)
*/
export async function recordLoginSuccess(username: string, ip: string): Promise<void> {
const failKey = `login_fail:${username}`
const lockKey = `account_lock:${username}`
try {
await Promise.all([delStore(failKey), delStore(lockKey)])
} catch (error) {
handleRedisStateUnavailable(error)
}
fallbackLoginFailures.delete(username)
fallbackAccountLocks.delete(username)
// 记录IP监控信息(成功登录也需要监控)
recordIPAttempt(ip, username)
}
export function recordAccountIpLogin(username: string, ip: string): boolean {
const now = getServerTimestamp()
let monitor = accountIpSwitchMonitor.get(username)
if (!monitor) {
monitor = { ipMap: new Map<string, number>(), windowStart: now }
accountIpSwitchMonitor.set(username, monitor)
}
const cutoff = now - RISK_CONTROL.IP_SWITCH_WINDOW_MS
for (const [k, ts] of monitor.ipMap.entries()) {
if (ts < cutoff) monitor.ipMap.delete(k)
}
monitor.ipMap.set(ip, now)
const exceeded = monitor.ipMap.size > RISK_CONTROL.IP_SWITCH_THRESHOLD
if (exceeded) {
blockIP(ip, '账号短期内多IP登录超限')
triggerAccountIpSwitchAlert(username, Array.from(monitor.ipMap.keys()))
}
return exceeded
}
/**
* 记录IP登录尝试
*/
function recordIPAttempt(ip: string, username: string): void {
const now = getServerDate()
const windowStart = new Date(
now.getTime() - SECURITY_CONFIG.IP_MONITOR_WINDOW_MINUTES * 60 * 1000
)
let monitorInfo = ipMonitor.get(ip)
if (!monitorInfo) {
monitorInfo = {
attemptedAccounts: new Set<string>(),
firstAttemptTime: now,
lastAttemptTime: now
}
ipMonitor.set(ip, monitorInfo)
}
// 如果监控窗口已过期,重置记录
if (monitorInfo.firstAttemptTime < windowStart) {
monitorInfo.attemptedAccounts.clear()
monitorInfo.firstAttemptTime = now
}
monitorInfo.attemptedAccounts.add(username)
monitorInfo.lastAttemptTime = now
// 检查是否触发异常行为警报
if (monitorInfo.attemptedAccounts.size > SECURITY_CONFIG.IP_MAX_DIFFERENT_ACCOUNTS) {
triggerSecurityAlert(ip, Array.from(monitorInfo.attemptedAccounts))
}
}
/**
* 触发安全警报
*/
async function triggerSecurityAlert(ip: string, attemptedAccounts: string[]): Promise<void> {
try {
const now = getServerDate()
const alertTitle = '安全警报:检测到异常登录行为'
const alertContent = `
检测时间:${now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}
异常IP:${ip}
时间窗口:${SECURITY_CONFIG.IP_MONITOR_WINDOW_MINUTES}分钟内
尝试登录账户数:${attemptedAccounts.length}
涉及账户:${attemptedAccounts.join(', ')}
建议立即检查该IP的登录活动并采取必要的安全措施。
`.trim()
// Meow通知内容,包含完整的涉及账户信息
const meowAlertContent = `
检测时间:${now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}
异常IP:${ip}
时间窗口:${SECURITY_CONFIG.IP_MONITOR_WINDOW_MINUTES}分钟内
尝试登录账户数:${attemptedAccounts.length}
涉及账户:${attemptedAccounts.join(', ')}
建议立即检查该IP的登录活动并采取必要的安全措施。
`.trim()
console.log(
`安全警报:IP ${ip} 在 ${SECURITY_CONFIG.IP_MONITOR_WINDOW_MINUTES} 分钟内尝试登录 ${attemptedAccounts.length} 个不同账户`
)
// 将触发警报的IP加入黑名单
blockIP(
ip,
`异常登录行为:${SECURITY_CONFIG.IP_MONITOR_WINDOW_MINUTES}分钟内尝试登录${attemptedAccounts.length}个不同账户`
)
// 获取所有超级管理员
const superAdmins = await db
.select({
id: users.id,
name: users.name,
meowNickname: users.meowNickname
})
.from(users)
.where(eq(users.role, 'SUPER_ADMIN'))
// 向所有超级管理员发送站内信
for (const admin of superAdmins) {
try {
await createSystemNotification(admin.id, alertTitle, alertContent)
console.log(`已向超级管理员 ${admin.name} 发送安全警报站内信`)
// 如果管理员绑定了Meow推送,同时发送推送通知
if (admin.meowNickname) {
const success = await sendMeowNotificationToUser(admin.id, alertTitle, meowAlertContent)
if (success) {
console.log(`已向超级管理员 ${admin.name} 发送Meow推送警报`)
} else {
console.log(`向超级管理员 ${admin.name} 发送Meow推送警报失败`)
}
}
} catch (error) {
console.error(`向超级管理员 ${admin.name} 发送安全警报失败:`, error)
}
}
// 重置该IP的监控记录,避免重复警报
ipMonitor.delete(ip)
} catch (error) {
console.error('触发安全警报时发生错误:', error)
}
}
async function triggerAccountIpSwitchAlert(username: string, ips: string[]): Promise<void> {
try {
const now = getServerDate()
const alertTitle = '安全警报:账号短期内多IP登录'
const alertContent = `检测时间:${now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}
账号:${username}
时间窗口:${Math.floor(RISK_CONTROL.IP_SWITCH_WINDOW_MS / 60000)}分钟内
涉及IP数:${ips.length}
建议立即检查该账号的登录活动并采取必要的安全措施。`
const meowAlertContent = alertContent
const superAdmins = await db
.select({
id: users.id,
name: users.name,
meowNickname: users.meowNickname
})
.from(users)
.where(eq(users.role, 'SUPER_ADMIN'))
for (const admin of superAdmins) {
try {
await createSystemNotification(admin.id, alertTitle, alertContent)
if (admin.meowNickname) {
await sendMeowNotificationToUser(admin.id, alertTitle, meowAlertContent)
}
} catch {}
}
} catch (error) {
console.error('触发账号IP切换警报时发生错误:', error)
}
}
function ipBucketOf(ip: string): string {
const parts = ip.split('.')
if (parts.length === 4) return `${parts[0]}.${parts[1]}.${parts[2]}`
return ip
}
export function isSongProtected(songId: number): boolean {
const until = songProtectUntil.get(songId)
if (!until) return false
return until > getServerDate()
}
export function getSongProtectRemainingSeconds(songId: number): number {
const until = songProtectUntil.get(songId)
if (!until) return 0
const now = getServerTimestamp()
if (until.getTime() <= now) return 0
return Math.ceil((until.getTime() - now) / 1000)
}
export function recordSongVote(songId: number, ip: string, userId: number): boolean {
const now = getServerTimestamp()
const arr = songVoteWindow.get(songId) || []
const cutoff = now - RISK_CONTROL.SONG_VOTE_PROTECT_WINDOW_MS
const filtered = arr.filter((t) => t >= cutoff)
filtered.push(now)
songVoteWindow.set(songId, filtered)
const buckets = songVoteIpBuckets.get(songId) || new Map<string, number>()
const b = ipBucketOf(ip)
buckets.set(b, (buckets.get(b) || 0) + 1)
songVoteIpBuckets.set(songId, buckets)
if (filtered.length > RISK_CONTROL.SONG_VOTE_PROTECT_THRESHOLD) {
songProtectUntil.set(songId, new Date(now + RISK_CONTROL.SONG_VOTE_PROTECT_DURATION_MS))
triggerSongVoteBurstAlert(songId, filtered.length, buckets)
return true
}
return false
}
export function recordUserVoteActivity(userId: number, songTitle?: string): { anomaly: boolean } {
const now = getServerTimestamp()
let stats = userVoteStats.get(userId)
if (!stats) {
stats = { emaPerMin: 0, lastUpdate: now, windowTimestamps: [] }
userVoteStats.set(userId, stats)
}
// 更新时间窗口,保留最近10分钟的投票记录
const cutoff = now - 10 * 60 * 1000
stats.windowTimestamps = stats.windowTimestamps.filter((t) => t >= cutoff)
stats.windowTimestamps.push(now)
// 计算当前10分钟窗口内的平均速率,而不仅仅是两次投票的间隔
// 如果只有一个投票记录,无法计算速率(时间跨度为0),且单个投票不构成"速率",直接视为0
let currentRate = 0
if (stats.windowTimestamps.length > 1) {
const windowDurationMin = Math.max(1e-6, (now - Math.min(...stats.windowTimestamps)) / 60000)
currentRate = stats.windowTimestamps.length / windowDurationMin
}
// 调整EMA_ALPHA值为0.1,使EMA更平滑,减少短期波动影响
const smoothedEmaAlpha = 0.1
stats.emaPerMin = smoothedEmaAlpha * currentRate + (1 - smoothedEmaAlpha) * stats.emaPerMin
stats.lastUpdate = now
// 动态调整阈值:基础阈值 + EMA的动态倍数
// 当EMA较低时,使用较高倍数;当EMA较高时,使用较低倍数,避免误报
const baseThreshold = 5 // 基础阈值:每分钟5票
const dynamicMultiplier = Math.max(2, 5 - Math.min(3, stats.emaPerMin / 100)) // 动态倍数:2-5倍
const threshold = Math.max(baseThreshold, stats.emaPerMin * dynamicMultiplier)
// 只有当当前速率显著超过阈值(1.5倍)时,才触发异常
const anomaly = currentRate > threshold * 1.5
if (anomaly) triggerVoteAnomalyAlert(userId, stats.emaPerMin, currentRate, songTitle)
return { anomaly }
}
async function triggerSongVoteBurstAlert(
songId: number,
count: number,
buckets: Map<string, number>
): Promise<void> {
try {
const now = getServerDate()
const top = Array.from(buckets.entries()).sort((a, b) => b[1] - a[1])[0]
const alertTitle = '风险告警:歌曲投票短时激增'
const alertContent = `检测时间:${now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}
歌曲ID:${songId}
窗口内票数:${count}
主导IP段:${top ? `${top[0]}.* (${top[1]})` : '无'}
已启动临时保护,暂停投票10分钟。`
const meowAlertContent = alertContent
const superAdmins = await db
.select({
id: users.id,
name: users.name,
meowNickname: users.meowNickname
})
.from(users)
.where(eq(users.role, 'SUPER_ADMIN'))
for (const admin of superAdmins) {
try {
await createSystemNotification(admin.id, alertTitle, alertContent)
if (admin.meowNickname) {
await sendMeowNotificationToUser(admin.id, alertTitle, meowAlertContent)
}
} catch {}
}
} catch (error) {
console.error('触发歌曲投票激增警报时发生错误:', error)
}
}
// 检查通知是否可以发送(限流控制)
function canSendNotification(key: string): boolean {
const now = getServerDate()
const lastSent = notificationRateLimit.get(key)
if (
lastSent &&
now.getTime() - lastSent.getTime() < NOTIFICATION_RATE_LIMIT_MINUTES * 60 * 1000
) {
return false
}
notificationRateLimit.set(key, now)
return true
}
interface AnomalyEvent {
userId: number
ema: number
rate: number
songTitle?: string
time: Date
}
const pendingAnomalies: AnomalyEvent[] = []
let anomalyAggregationTimer: NodeJS.Timeout | null = null
const ANOMALY_AGGREGATION_WINDOW_MS = 60 * 1000 // 1分钟聚合窗口
async function flushAnomalyAggregation() {
if (pendingAnomalies.length === 0) {
anomalyAggregationTimer = null
return
}
try {
const count = pendingAnomalies.length
// 统计涉及的歌曲
const songStats = new Map<string, number>()
pendingAnomalies.forEach((e) => {
const title = e.songTitle || '未知歌曲'
songStats.set(title, (songStats.get(title) || 0) + 1)
})
// 按次数排序,取前5
const topSongs = Array.from(songStats.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([title, c]) => `- ${title} (${c}人)`)
.join('\n')
const alertTitle = '风险告警:检测到多名用户异常投票(汇总)'
const alertContent = `在过去 1 分钟内,除已通知的用户外,还检测到 ${count} 名用户存在异常投票行为。
涉及主要歌曲:
${topSongs}
请关注后台日志或进行相关处理。`
const meowAlertContent = alertContent
const superAdmins = await db
.select({
id: users.id,
name: users.name,
meowNickname: users.meowNickname
})
.from(users)
.where(eq(users.role, 'SUPER_ADMIN'))
for (const admin of superAdmins) {
try {
await createSystemNotification(admin.id, alertTitle, alertContent)
if (admin.meowNickname) {
await sendMeowNotificationToUser(admin.id, alertTitle, meowAlertContent)
}
} catch {}
}
} catch (error) {
console.error('发送异常投票汇总通知失败:', error)
} finally {
// 清空队列和定时器
pendingAnomalies.length = 0
anomalyAggregationTimer = null
}
}
async function triggerVoteAnomalyAlert(
userId: number,
ema: number,
rate: number,
songTitle?: string
): Promise<void> {
try {
// 通知限流,同一用户的投票异常通知5分钟内只发送一次
const notificationKey = `vote_anomaly_${userId}`
if (!canSendNotification(notificationKey)) {
return
}
// 如果正在聚合窗口期,则加入缓冲区
if (anomalyAggregationTimer) {
pendingAnomalies.push({
userId,
ema,
rate,
songTitle,
time: getServerDate()
})
return
}
// 否则,立即发送第一条,并开启聚合窗口
const now = getServerDate()
const alertTitle = '风险告警:检测到异常投票速率'
const alertContent = `检测时间:${now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}\n用户ID:${userId}\nEMA基线:${ema.toFixed(2)} 次/分钟\n当前速率:${rate.toFixed(2)} 次/分钟\n${songTitle ? `涉及歌曲:${songTitle}\n` : ''}\n提示:该用户的投票行为异常,已触发限流机制。`
const meowAlertContent = alertContent
const superAdmins = await db
.select({
id: users.id,
name: users.name,
meowNickname: users.meowNickname
})
.from(users)
.where(eq(users.role, 'SUPER_ADMIN'))
for (const admin of superAdmins) {
try {
await createSystemNotification(admin.id, alertTitle, alertContent)
if (admin.meowNickname) {
await sendMeowNotificationToUser(admin.id, alertTitle, meowAlertContent)
}
} catch {}
}
// 启动聚合定时器
anomalyAggregationTimer = setTimeout(flushAnomalyAggregation, ANOMALY_AGGREGATION_WINDOW_MS)
} catch (error) {
console.error('触发投票异常警报时发生错误:', error)
}
}
/**
* 获取安全统计信息
*/
export async function getSecurityStats() {
cleanupExpiredLocks()
return {
lockedAccounts: 0,
monitoredIPs: ipMonitor.size,
blockedIPs: ipBlacklist.size,
config: SECURITY_CONFIG
}
}
/**
* 获取账户当前的登录失败次数(用于判断是否需要图形验证码)
*/
export async function getLoginFailureCount(username: string): Promise<number> {
const failKey = `login_fail:${username}`
try {
const val = await getStore(failKey)
const redisCount = val ? parseInt(val, 10) : 0
return Math.max(Number.isFinite(redisCount) ? redisCount : 0, getFallbackFailureCount(username))
} catch (error) {
handleRedisStateUnavailable(error)
return getFallbackFailureCount(username)
}
}
// 定期清理过期记录(每5分钟执行一次)
setInterval(cleanupExpiredLocks, 5 * 60 * 1000)
+813
View File
@@ -0,0 +1,813 @@
import nodemailer from 'nodemailer'
import { db } from '~/drizzle/db'
import { emailTemplates, users } from '~/drizzle/schema'
import { and, eq, isNotNull } from 'drizzle-orm'
import { getSiteTitle } from '~~/server/utils/siteUtils'
import { formatIPForEmail } from '~~/server/utils/ip-utils'
import { getSystemSettingsCached } from '~~/server/utils/system-settings-helper'
const HTML_ESCAPE_MAP: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}
const TRUSTED_HTML_TEMPLATE_KEYS = new Set(['contentBlock'])
const EMAIL_REQUEST_SOURCE_LABEL = 'This email was requested from:'
const escapeHtml = (value: unknown): string => {
return String(value).replace(/[&<>"']/g, (character) => HTML_ESCAPE_MAP[character] ?? character)
}
const normalizeEmailActionUrl = (value: unknown): string | undefined => {
if (typeof value !== 'string' || !value.trim()) return undefined
try {
const url = new URL(value.trim())
if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined
if (url.username || url.password) return undefined
return url.href
} catch {
return undefined
}
}
const normalizeEmailSubject = (value: unknown): string => {
return String(value ?? '')
.replaceAll('\r', ' ')
.replaceAll('\n', ' ')
.trim()
}
/**
* SMTP邮件服务
*/
export class SmtpService {
private static instance: SmtpService
public transporter: nodemailer.Transporter | null = null
public smtpConfig: any = null
/** 基础邮件模板结构 */
private baseTemplate = `
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif; background: #f9f9f9; padding: 20px;">
<div style="background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<div style="text-align: center; margin-bottom: 30px;">
<h1 style="color: #333; margin: 0;">{{fromName}}</h1>
<p style="color: #666; margin: 5px 0 0 0;">{{headerSubtitle}}</p>
</div>
{{#if title}}
<h2 style="color: #333; margin-bottom: 20px;">{{title}}</h2>
{{/if}}
<div style="color: #555; line-height: 1.6; margin-bottom: 30px;">
{{contentBlock}}
</div>
{{#if actionUrl}}
<div style="text-align: center; margin: 30px 0;">
<a href="{{actionUrl}}" style="display: inline-block; background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px;">{{actionText}}</a>
</div>
{{/if}}
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="color: #666; font-size: 12px; text-align: center;">
此邮件由系统自动发送,请勿回复。<br>
如有疑问,请联系管理员。{{#if ipAddress}}<br><br>This email was requested from: <span style="font-family: monospace; background: #f5f5f5; padding: 2px 4px; border-radius: 3px; color: #333; text-decoration: none; pointer-events: none;">{{ipAddress}}</span>{{/if}}
</p>
</div>
</div>
`
/** 内容块模板 */
private contentBlocks: Record<string, string> = {
verification: `
<p>您好,{{name}}!</p>
<p>您正在验证邮箱:<strong>{{email}}</strong></p>
<p>请在{{expiresInMinutes}}分钟内输入以下验证码完成验证:</p>
<div style="text-align: center; margin: 20px 0;">
<h2 style="letter-spacing: 4px; color: #007bff; background: #f8f9fa; padding: 15px; border-radius: 4px; display: inline-block;">{{code}}</h2>
</div>
<p style="color:#888">若非本人操作,请忽略本邮件。</p>
`,
generic: `
<div style="white-space: pre-wrap;">{{message}}</div>
`,
songSelected: `
<p>您投稿的歌曲《{{songTitle}}》已被安排播放。</p>
<p>播放日期:<strong>{{playDate}}</strong></p>
{{#if playTimeName}}
<p>播出时段:<strong>{{playTimeName}}</strong>{{#if playTimeRange}}({{playTimeRange}}){{/if}}</p>
{{/if}}
`,
songPlayed: `
<p>您投稿的歌曲《{{songTitle}}》已播放。</p>
`,
replaySongSelected: `
<p>您申请重播的歌曲《{{songTitle}}》已安排播放。</p>
<p>播放日期:<strong>{{playDate}}</strong></p>
{{#if playTimeName}}
<p>播出时段:<strong>{{playTimeName}}</strong></p>
{{/if}}
`,
songVoted: `
<p>您投稿的歌曲《{{songTitle}}》获得了新的投票。</p>
<p>当前共有 <strong>{{votesCount}}</strong> 个投票。</p>
`,
songRejected: `
<p>您投稿的歌曲《{{songTitle}}》已被管理员驳回。</p>
{{#if reason}}
<p>驳回原因:<strong>{{reason}}</strong></p>
{{/if}}
`,
collaborationInvite: `
<p>用户 <strong>{{inviterName}}</strong> 邀请您共同投稿歌曲《{{songTitle}}》。</p>
`
}
/** 模板配置 */
private builtinTemplates: Record<
string,
{
name: string
subject: string
contentType: string
headerSubtitle: string
actionText?: string
}
> = {
'verification.code': {
name: '邮箱验证码',
subject: '邮箱验证码 | {{siteTitle}}',
contentType: 'verification',
headerSubtitle: '邮箱验证'
},
'register': {
name: '注册通知',
subject: '{{title}} | {{siteTitle}}通知推送',
contentType: 'generic',
headerSubtitle: '注册通知'
},
'register-approved': {
name: '注册审核通过',
subject: '{{title}} | {{siteTitle}}通知推送',
contentType: 'generic',
headerSubtitle: '注册审核通知'
},
'register-rejected': {
name: '注册审核拒绝',
subject: '{{title}} | {{siteTitle}}通知推送',
contentType: 'generic',
headerSubtitle: '注册审核通知'
},
'notification.generic': {
name: '通用通知',
subject: '{{title}} | {{siteTitle}}通知推送',
contentType: 'generic',
headerSubtitle: '通知推送',
actionText: '查看详情'
},
'notification.songSelected': {
name: '歌曲被选中',
subject: '收到新选中 | {{siteTitle}}通知推送',
contentType: 'songSelected',
headerSubtitle: '通知推送'
},
'notification.songPlayed': {
name: '歌曲已播放',
subject: '歌曲已播放 | {{siteTitle}}通知推送',
contentType: 'songPlayed',
headerSubtitle: '通知推送'
},
'notification.replaySongSelected': {
name: '重播申请已安排',
subject: '重播已安排 | {{siteTitle}}通知推送',
contentType: 'replaySongSelected',
headerSubtitle: '通知推送'
},
'notification.songVoted': {
name: '收到新投票',
subject: '收到新投票 | {{siteTitle}}通知推送',
contentType: 'songVoted',
headerSubtitle: '通知推送'
},
'notification.songRejected': {
name: '歌曲被驳回',
subject: '歌曲被驳回 | {{siteTitle}}通知推送',
contentType: 'songRejected',
headerSubtitle: '通知推送'
},
'notification.collaborationInvite': {
name: '收到联合投稿邀请',
subject: '收到联合投稿邀请 | {{siteTitle}}通知推送',
contentType: 'collaborationInvite',
headerSubtitle: '通知推送'
}
}
private constructor() {}
static getInstance(): SmtpService {
if (!SmtpService.instance) {
SmtpService.instance = new SmtpService()
// 首次创建实例时,尝试初始化SMTP配置(异步执行,不阻塞)
SmtpService.instance.initializeSmtpConfig().catch((error) => {
console.log('SmtpService实例创建时初始化配置失败:', error.message)
})
}
return SmtpService.instance
}
// 暴露内置模板(只读)
getBuiltinTemplates(): Record<string, { name: string; subject: string; html: string }> {
const templates: Record<string, { name: string; subject: string; html: string }> = {}
for (const [key, config] of Object.entries(this.builtinTemplates)) {
const contentBlock = this.contentBlocks[config.contentType] || ''
const html = this.renderString(
this.baseTemplate,
{
contentBlock,
headerSubtitle: config.headerSubtitle,
actionText: config.actionText || '查看详情'
},
{ rawKeys: TRUSTED_HTML_TEMPLATE_KEYS }
)
templates[key] = {
name: config.name,
subject: config.subject,
html
}
}
return templates
}
/**
* 初始化SMTP配置
* 仅当配置为空或显式要求强制刷新时才执行
*/
async initializeSmtpConfig(forceRefresh: boolean = false): Promise<boolean> {
if (!forceRefresh && this.transporter) {
return true
}
try {
const settings = await getSystemSettingsCached()
if (!settings || !settings.smtpEnabled || !settings.smtpHost) {
this.smtpConfig = null
this.transporter = null
return false
}
const port = settings.smtpPort || 587
const secure = settings.smtpSecure || false
this.smtpConfig = {
host: settings.smtpHost,
port: port,
secure: secure,
auth:
settings.smtpUsername && settings.smtpPassword
? {
user: settings.smtpUsername,
pass: settings.smtpPassword
}
: undefined,
fromEmail: settings.smtpFromEmail || settings.smtpUsername,
fromName: settings.smtpFromName || '校园广播站'
}
// 创建transporter配置
const transporterConfig: any = {
host: this.smtpConfig.host,
port: this.smtpConfig.port,
secure: this.smtpConfig.secure,
auth: this.smtpConfig.auth
}
// 根据端口和安全设置调整配置
const isDevelopment = process.env.NODE_ENV === 'development'
if (port === 587 && !secure) {
// STARTTLS - 端口587通常使用STARTTLS
transporterConfig.requireTLS = true
transporterConfig.tls = {
// 仅在开发环境中跳过证书校验,生产环境必须校验
rejectUnauthorized: !isDevelopment
}
} else if (port === 465) {
// SSL/TLS - 端口465必须使用SSL
transporterConfig.secure = true
} else if (port === 25) {
// 通常不加密
transporterConfig.secure = false
transporterConfig.tls = {
// 仅在开发环境中跳过证书校验
rejectUnauthorized: !isDevelopment
}
}
// 创建transporter
this.transporter = nodemailer.createTransport(transporterConfig)
// 验证SMTP连接
await this.transporter.verify()
return true
} catch (error) {
console.error('初始化SMTP配置失败:', error instanceof Error ? error.message : '未知错误')
this.smtpConfig = null
this.transporter = null
return false
}
}
/**
* 发送邮件
*/
async sendMail(
to: string,
subject: string,
htmlContent: string,
textContent?: string,
ipAddress?: string
): Promise<boolean> {
// 确保配置已初始化
if (!(await this.ensureInitialized())) {
throw new Error('SMTP配置未初始化或无效')
}
// 兼容直接调用 sendMail 的邮件;模板已渲染来源 IP 时不重复追加
let finalHtml = htmlContent
if (ipAddress && !htmlContent.includes(EMAIL_REQUEST_SOURCE_LABEL)) {
const formattedIP = escapeHtml(formatIPForEmail(ipAddress))
finalHtml = htmlContent.replace(
/(<p[^>]*style="[^"]*text-align: center[^"]*"[^>]*>.*?此邮件由系统自动发送,请勿回复。.*?<\/p>)/s,
(footer) =>
footer.replace(
'此邮件由系统自动发送,请勿回复。',
`此邮件由系统自动发送,请勿回复。<br><br>${EMAIL_REQUEST_SOURCE_LABEL} <span style="font-family: monospace; background: #f5f5f5; padding: 2px 4px; border-radius: 3px; color: #333; text-decoration: none; pointer-events: none;">${formattedIP}</span>`
)
)
}
const mailOptions = {
from: {
name: this.smtpConfig.fromName,
address: this.smtpConfig.fromEmail
},
to,
subject,
html: finalHtml,
text: textContent || finalHtml.replace(/<[^>]*>/g, '') // 简单的HTML转文本
}
try {
const result = await this.transporter!.sendMail(mailOptions)
console.log(`邮件发送成功: ${result.messageId}`)
return true
} catch (error: any) {
// 自动重试机制:如果是因为发件人地址不匹配导致的553/501错误,尝试使用认证用户作为发件人
if (
(error.responseCode === 553 || error.responseCode === 501) &&
this.smtpConfig.auth?.user
) {
console.warn(
`SMTP发件人地址不匹配 (${error.responseCode}),正在尝试使用认证用户重试发送...`
)
try {
// 这里可以安全地使用 mailOptions,因为它在 try 块外部定义
const retryMailOptions = {
...mailOptions,
from: {
...mailOptions.from,
address: this.smtpConfig.auth.user
}
}
const result = await this.transporter!.sendMail(retryMailOptions)
console.log(`重试发送成功: ${result.messageId}`)
return true
} catch (retryError) {
console.error('重试发送失败:', retryError)
throw error // 如果重试也失败,抛出原始错误
}
}
console.error('发送邮件失败:', error)
throw error
}
}
/**
* 渲染模板:优先使用自定义模板,否则回退到内置模板
*/
async renderTemplate(
key: string,
data: Record<string, any>
): Promise<{ subject: string; html: string }> {
const builtin = this.builtinTemplates[key]
let subject = builtin?.subject || ''
let html = ''
try {
const rows = await db
.select()
.from(emailTemplates)
.where(eq(emailTemplates.key, key))
.limit(1)
const custom = rows[0]
if (custom) {
subject = custom.subject
html = custom.html
}
} catch (e) {
// 忽略读取失败,走内置
}
let mergedData: Record<string, any>
// 如果没有自定义模板,使用内置模板系统
if (!html && builtin) {
mergedData = await this.prepareTemplateData(data)
const contentBlock = this.renderString(
this.contentBlocks[builtin.contentType] || '',
mergedData
)
const templateData = {
...mergedData,
contentBlock,
headerSubtitle: builtin.headerSubtitle,
actionText: builtin.actionText || '查看详情'
}
html = this.renderString(this.baseTemplate, templateData, {
rawKeys: TRUSTED_HTML_TEMPLATE_KEYS
})
} else {
mergedData = await this.prepareTemplateData(data)
html = this.renderString(html, mergedData)
}
return {
subject: normalizeEmailSubject(
this.renderString(subject, mergedData, { escapeValues: false })
),
html
}
}
/**
* 渲染并发送模板
*/
async renderAndSend(
to: string,
key: string,
data: Record<string, any>,
ipAddress?: string
): Promise<boolean> {
try {
// 确保配置已初始化
if (!(await this.ensureInitialized())) {
return false
}
// 格式化IP地址用于模板渲染(统一处理)
const formattedIP = ipAddress ? formatIPForEmail(ipAddress) : undefined
const templateData = { ...data, ipAddress: formattedIP }
const { subject, html } = await this.renderTemplate(key, templateData)
if (!subject || !html) {
// 若模板缺失,退回到简单包装(传入已格式化的IP)
const mergedData = await this.prepareTemplateData(templateData)
const fallbackHtml = this.generateEmailTemplate(
data.title || data.fallbackTitle || '通知',
data.message || data.fallbackMessage || '',
data.actionUrl,
formattedIP
)
const fallbackSubject = normalizeEmailSubject(
`${data.title || data.fallbackTitle || '通知'} | ${mergedData.siteTitle}通知推送`
)
return await this.sendMail(to, fallbackSubject, fallbackHtml, undefined, ipAddress)
}
return await this.sendMail(to, subject, html, undefined, ipAddress)
} catch (error) {
console.error('渲染并发送邮件失败:', error)
return false
}
}
/**
* 测试SMTP连接
*/
async testConnection(): Promise<{ success: boolean; message: string; detail?: string }> {
if (!this.transporter) {
try {
const initialized = await this.initializeSmtpConfig()
if (!initialized) {
return { success: false, message: 'SMTP配置无效或未启用' }
}
} catch (error) {
return {
success: false,
message: '初始化SMTP配置失败',
detail: error instanceof Error ? error.message : '未知错误'
}
}
}
try {
await this.transporter!.verify()
return { success: true, message: 'SMTP连接测试成功' }
} catch (error) {
return {
success: false,
message: 'SMTP连接测试失败',
detail: error instanceof Error ? error.message : '未知错误'
}
}
}
/**
* 发送测试邮件
*/
async sendTestEmail(
to: string,
ipAddress?: string
): Promise<{ success: boolean; message: string; detail?: string }> {
try {
const formattedIP = ipAddress ? formatIPForEmail(ipAddress) : undefined
const templateData = await this.prepareTemplateData({ ipAddress: formattedIP })
const subject = normalizeEmailSubject(`测试邮件 | ${templateData.siteTitle}通知推送`)
const htmlContent = this.generateEmailTemplate(
'测试邮件',
'这是一封来自校园广播站系统的测试邮件。<br>如果您收到这封邮件,说明SMTP配置已经正确设置。',
undefined,
formattedIP,
true
)
const success = await this.sendMail(to, subject, htmlContent, undefined, ipAddress)
return {
success,
message: success ? '测试邮件发送成功' : '测试邮件发送失败'
}
} catch (error) {
return {
success: false,
message: '测试邮件发送失败',
detail: error instanceof Error ? error.message : '未知错误'
}
}
}
/**
* 生成邮件HTML模板
*/
generateEmailTemplate(
title: string,
content: string,
actionUrl?: string,
formattedIP?: string,
trustedHtmlContent = false
): string {
const safeFromName = escapeHtml(this.smtpConfig?.fromName || '校园广播站')
const safeTitle = escapeHtml(title)
const safeContent = trustedHtmlContent ? content : escapeHtml(content)
const safeActionUrl = normalizeEmailActionUrl(actionUrl)
const safeFormattedIP = formattedIP ? escapeHtml(formattedIP) : ''
return `
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif; background: #f9f9f9; padding: 20px;">
<div style="background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<div style="text-align: center; margin-bottom: 30px;">
<h1 style="color: #333; margin: 0;">${safeFromName}</h1>
<p style="color: #666; margin: 5px 0 0 0;">通知推送</p>
</div>
<h2 style="color: #333; margin-bottom: 20px;">${safeTitle}</h2>
<div style="color: #555; line-height: 1.6; margin-bottom: 30px; white-space: pre-wrap;">
${safeContent}
</div>
${
safeActionUrl
? `
<div style="text-align: center; margin: 30px 0;">
<a href="${escapeHtml(safeActionUrl)}" style="display: inline-block; background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px;">查看详情</a>
</div>
`
: ''
}
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="color: #666; font-size: 12px; text-align: center;">
此邮件由系统自动发送,请勿回复。<br>
如有疑问,请联系管理员。${safeFormattedIP ? `<br><br>This email was requested from: <span style="font-family: monospace; background: #f5f5f5; padding: 2px 4px; border-radius: 3px; color: #333; text-decoration: none; pointer-events: none;">${safeFormattedIP}</span>` : ''}
</p>
</div>
</div>
`
}
/**
* 确保SMTP配置已初始化
*/
private async ensureInitialized(): Promise<boolean> {
if (!this.transporter) {
return await this.initializeSmtpConfig()
}
return true
}
/**
* 基本占位符渲染:用 {{var}} 替换,支持 {{#if var}}...{{/if}} 嵌套条件
*/
private renderString(
tpl: string,
data: Record<string, any>,
options: { escapeValues?: boolean; rawKeys?: ReadonlySet<string> } = {}
): string {
// 递归处理嵌套的 if 块
const processIfBlocks = (template: string): string => {
let hasChanges = true
let result = template
while (hasChanges) {
hasChanges = false
// 从最内层开始处理,匹配不包含嵌套{{#if}}的if块
result = result.replace(
/\{\{#if\s+([a-zA-Z0-9_\.]+)\}\}((?:(?!\{\{#if)[\s\S])*?)\{\{\/if\}\}/g,
(match, key, inner) => {
const v = key
.split('.')
.reduce((acc: any, k: string) => (acc ? acc[k] : undefined), data)
hasChanges = true
return v ? inner : ''
}
)
}
return result
}
// 处理所有if块
tpl = processIfBlocks(tpl)
// 处理变量
tpl = tpl.replace(/\{\{\s*([a-zA-Z0-9_\.]+)\s*\}\}/g, (_, key) => {
const v = key.split('.').reduce((acc: any, k: string) => (acc ? acc[k] : undefined), data)
if (v == null) return ''
const renderedValue = String(v)
if (options.escapeValues === false || options.rawKeys?.has(key)) return renderedValue
return escapeHtml(renderedValue)
})
return tpl
}
/**
* 准备模板渲染数据
*/
private async prepareTemplateData(data: Record<string, any>): Promise<Record<string, any>> {
const siteTitle = await getSiteTitle()
return {
...data,
fromName: this.smtpConfig?.fromName || '校园广播站',
siteTitle,
actionUrl: normalizeEmailActionUrl(data.actionUrl)
}
}
}
/**
* 发送邮件通知给用户
*/
export async function sendEmailNotificationToUser(
userId: number,
notificationTitle: string,
notificationMessage: string,
url?: string,
templateKey?: string,
templateData?: Record<string, any>,
ipAddress?: string
): Promise<boolean> {
try {
const smtpService = SmtpService.getInstance()
// 获取用户信息
const userResult = await db
.select({
name: users.name,
email: users.email,
emailVerified: users.emailVerified
})
.from(users)
.where(eq(users.id, userId))
.limit(1)
const user = userResult[0]
// 仅检查用户是否有邮箱且已验证
if (!user?.email || !user.emailVerified) {
return false
}
// 使用指定模板,否则回退通用模板
if (templateKey) {
return await smtpService.renderAndSend(
user.email,
templateKey,
{
fallbackTitle: notificationTitle,
fallbackMessage: notificationMessage,
message: notificationMessage,
actionUrl: url,
...(templateData || {})
},
ipAddress
)
}
return await smtpService.renderAndSend(
user.email,
'notification.generic',
{
title: notificationTitle,
message: notificationMessage,
actionUrl: url
},
ipAddress
)
} catch (error) {
console.error('发送邮件通知失败:', error)
return false
}
}
/**
* 批量发送邮件通知
*/
export async function sendBatchEmailNotifications(
userIds: number[],
notificationTitle: string,
notificationMessage: string,
url?: string,
ipAddress?: string
): Promise<{ success: number; failed: number }> {
let success = 0
let failed = 0
const smtpService = SmtpService.getInstance()
// 获取有邮箱且已验证的用户
const usersWithEmail = await db
.select({
id: users.id,
name: users.name,
email: users.email
})
.from(users)
.where(and(eq(users.emailVerified, true), isNotNull(users.email)))
// 并发发送邮件(限制并发数)
const batchSize = 5
const targetUsers = usersWithEmail.filter((user) => userIds.includes(user.id))
for (let i = 0; i < targetUsers.length; i += batchSize) {
const batch = targetUsers.slice(i, i + batchSize)
const promises = batch.map(async (user) => {
// 确保 email 不为 null
if (!user.email) {
return false
}
const emailSuccess = await smtpService.renderAndSend(
user.email,
'notification.generic',
{
title: notificationTitle,
message: notificationMessage,
actionUrl: url
},
ipAddress
)
return emailSuccess
})
const results = await Promise.allSettled(promises)
results.forEach((result) => {
if (result.status === 'fulfilled' && result.value) {
success++
} else {
failed++
}
})
}
return { success, failed }
}
+645
View File
@@ -0,0 +1,645 @@
import {
collaborationLogs,
db,
playTimes,
requestTimes,
schedules,
semesters,
cardCodes,
songCollaborators,
songs,
users
} from '~/drizzle/db'
import { and, eq, gte, gt, inArray, lt, lte, or, sql } from 'drizzle-orm'
import { createError } from 'h3'
import { reconcileSongDurationOnSubmit } from '~~/server/services/durationValidationService'
import { createApiError } from '~~/server/utils/apiError'
import { createCollaborationInvitationNotification } from '~~/server/services/notificationService'
import {
isCardCodeLimitBypassActive,
isLimitReached
} from '~~/server/utils/submissionLimit'
import { getClientIP } from '~~/server/utils/ip-utils'
import { getBeijingTimeISOString } from '~/utils/timeUtils'
import { getSystemSettingsCached } from '~~/server/utils/system-settings-helper'
import { getServerDate } from '~~/server/utils/serverTime'
import { SERVER_ERROR_CODES, SONG_DURATION_MAX_SECONDS, SONG_DURATION_MIN_SECONDS, SUBMISSION_NOTE_STATUS } from '~~/server/config/constants'
import { normalizeForMatch } from '~~/server/utils/song-name-normalize'
import { resolveSubmissionRestrictionPolicy } from '~~/server/utils/submission-restriction-policy'
import { z } from 'zod'
type SongRequestUser = {
id: number
role: string
}
const songRequestBodySchema = z.object({
title: z.string().trim().min(1, '歌曲名称不能为空').max(200, '歌曲名称不能超过200个字符'),
artist: z.string().trim().min(1, '艺术家不能为空').max(200, '艺术家不能超过200个字符'),
cover: z
.string()
.trim()
.max(1000, '封面地址不能超过1000个字符')
// 封面必须是完整 http(s) 链接,防止音源返回 pic_id 等片段被误存导致封面无法显示
.refine((value) => value === '' || /^https?:\/\//i.test(value), '封面地址必须以 http(s):// 开头')
.optional()
.nullable(),
musicPlatform: z.string().trim().max(50, '音乐平台标识不能超过50个字符').optional().nullable(),
musicId: z.string().trim().max(200, '音乐 ID 不能超过200个字符').optional().nullable(),
bilibiliCid: z.string().trim().max(100, 'Bilibili CID 不能超过100个字符').optional().nullable(),
bilibiliPage: z.union([z.string(), z.number()]).optional().nullable(),
playUrl: z.string().trim().max(2000, '播放链接不能超过2000个字符').optional().nullable(),
durationSeconds: z.number().int().min(SONG_DURATION_MIN_SECONDS, '时长不能为负数').max(SONG_DURATION_MAX_SECONDS, '时长不能超过2小时').optional().nullable(),
submissionNote: z.string().trim().max(300, '备注留言不能超过300个字符').optional().nullable(),
submissionNotePublic: z.boolean().optional(),
preferredPlayTimeId: z.preprocess(
(value) => value === null || value === undefined || value === '' ? null : Number(value),
z.number().int().positive('播出时段 ID 无效').nullable()
).optional(),
cardCode: z.string().trim().max(100, 'CARD_CODE_TOO_LONG').optional().nullable(),
collaborators: z.array(z.union([z.string(), z.number()])).max(20, '联合投稿人不能超过20个').optional()
})
export async function requestSongForUser(event: any, user: SongRequestUser, body: any) {
const parsedBody = songRequestBodySchema.safeParse(body || {})
if (!parsedBody.success) {
const issues = parsedBody.error.issues || []
const cardCodeIssue = issues.find((issue) => String(issue.message).startsWith('CARD_CODE_'))
if (cardCodeIssue) {
throw createApiError(400, cardCodeIssue.message, 'Invalid request card')
}
throw createError({
statusCode: 400,
message: issues.length
? `请求参数验证失败:${issues.map((issue) => issue.message).join(', ')}`
: '请求参数验证失败'
})
}
const requestBody = parsedBody.data
try {
// 标准化后再比较,避免同一首歌因标点或空格差异绕过重复检查。
const normalizedTitle = normalizeForMatch(requestBody.title)
const normalizedArtist = normalizeForMatch(requestBody.artist)
const currentSemester = await getCurrentSemesterName()
const systemSettingsData = await getSystemSettingsCached()
const isAdmin = ['SUPER_ADMIN', 'ADMIN', 'SONG_ADMIN'].includes(user.role)
const restrictionPolicy = resolveSubmissionRestrictionPolicy(systemSettingsData)
const semesterRestrictionOn = restrictionPolicy.mode === 'semester' && !isAdmin
const isBilibili =
requestBody.musicPlatform === 'bilibili' ||
String(requestBody.musicId || '').startsWith('BV') ||
String(requestBody.musicId || '').startsWith('av')
// 仅在重复投稿限制开启且未配置冷却时长时,沿用同一学期内同一首歌只能投稿一次的规则;管理员始终豁免。
if (isBilibili && requestBody.musicId && semesterRestrictionOn) {
let fullMusicId = String(requestBody.musicId)
const bvId = fullMusicId.split(':')[0]
if (requestBody.bilibiliCid) {
const musicIdParts = [bvId, requestBody.bilibiliCid]
if (requestBody.bilibiliPage && Number(requestBody.bilibiliPage) > 1) {
musicIdParts.push(String(requestBody.bilibiliPage))
}
fullMusicId = musicIdParts.join(':')
}
const existingSongs = await db
.select({
id: songs.id,
musicId: songs.musicId,
played: songs.played
})
.from(songs)
.where(
and(
eq(songs.semester, currentSemester),
eq(songs.musicPlatform, 'bilibili'),
eq(songs.musicId, fullMusicId)
)
)
if (existingSongs.length > 0) {
throw createError({
statusCode: 400,
message: `《${requestBody.title}》已经在列表中,不能重复投稿`
})
}
} else if (semesterRestrictionOn) {
const allSongs = await db
.select({
id: songs.id,
title: songs.title,
artist: songs.artist,
semester: songs.semester,
played: songs.played
})
.from(songs)
.where(eq(songs.semester, currentSemester))
const matchingSongs = allSongs.filter((song) => {
const songTitle = normalizeForMatch(song.title)
const songArtist = normalizeForMatch(song.artist)
return songTitle === normalizedTitle && songArtist === normalizedArtist
})
if (matchingSongs.length > 0) {
throw createError({
statusCode: 400,
message: `《${requestBody.title}》已经在列表中,不能重复投稿`
})
}
}
// 重复投稿限制:同一首歌 / 同一歌手在排期后 N 小时内不可再次投稿
if (restrictionPolicy.mode === 'window' && !isAdmin) {
const now = getServerDate()
const { sameSongHours, sameArtistHours, scope } = restrictionPolicy
const maxHours = Math.max(sameSongHours || 0, sameArtistHours || 0)
const cutoff = new Date(now.getTime() - maxHours * 3600000)
// 按 scope 构建 where 子句(显式短路,避免依赖 and(undefined) 的版本行为)
const scopeFilter = scope === 'self' ? eq(songs.requesterId, user.id) : undefined
const whereClause = and(
eq(schedules.isDraft, false),
or(
and(lte(schedules.playDate, now), gte(schedules.playDate, cutoff)),
and(
gt(schedules.playDate, now),
or(gte(schedules.publishedAt, cutoff), gte(schedules.createdAt, cutoff))
)
),
...(scopeFilter ? [scopeFilter] : [])
)
const scheduledSongs = await db
.select({
sPlayDate: schedules.playDate,
sCreatedAt: schedules.createdAt,
sPublishedAt: schedules.publishedAt,
songTitle: songs.title,
songArtist: songs.artist,
songRequesterId: songs.requesterId
})
.from(schedules)
.innerJoin(songs, eq(schedules.songId, songs.id))
.where(whereClause)
for (const scheduled of scheduledSongs) {
const playDate = scheduled.sPlayDate instanceof Date ? scheduled.sPlayDate : new Date(scheduled.sPlayDate)
const createdAt = scheduled.sCreatedAt instanceof Date ? scheduled.sCreatedAt : new Date(scheduled.sCreatedAt)
const publishedAt = scheduled.sPublishedAt
? (scheduled.sPublishedAt instanceof Date ? scheduled.sPublishedAt : new Date(scheduled.sPublishedAt))
: null
// 已播放歌曲:窗口从播放时间起算;未播放歌曲:窗口从排期时间起算
const windowStart = playDate.getTime() <= now.getTime()
? playDate.getTime()
: (publishedAt || createdAt).getTime()
const songWindowMs = (windowStart + (sameSongHours || 0) * 3600000) - now.getTime()
const artistWindowMs = (windowStart + (sameArtistHours || 0) * 3600000) - now.getTime()
const songWindowActive = (sameSongHours || 0) > 0 && songWindowMs > 0
const artistWindowActive = (sameArtistHours || 0) > 0 && artistWindowMs > 0
if (!songWindowActive && !artistWindowActive) continue
const scheduledTitleNorm = normalizeForMatch(scheduled.songTitle || '')
const scheduledArtistNorm = normalizeForMatch(scheduled.songArtist || '')
if (songWindowActive && scheduledTitleNorm === normalizedTitle && scheduledArtistNorm === normalizedArtist) {
throw createApiError(
400,
SERVER_ERROR_CODES.SONG_RESTRICTION_SAME_SONG,
'同一首歌在排期后一段时间内不能重复投稿',
{ params: [sameSongHours, requestBody.title] }
)
}
if (artistWindowActive && scheduledArtistNorm === normalizedArtist) {
throw createApiError(
400,
SERVER_ERROR_CODES.SONG_RESTRICTION_SAME_ARTIST,
'同一歌手在排期后一段时间内不能重复投稿',
{ params: [sameArtistHours, scheduled.songArtist] }
)
}
}
}
if (systemSettingsData?.forceBlockAllRequests && !isAdmin) {
throw createError({
statusCode: 403,
message: '投稿功能已关闭'
})
}
let hitRequestTime: any = null
if (systemSettingsData?.enableRequestTimeLimitation && !isAdmin) {
const currentTime = getBeijingTimeISOString()
const hitRequestTimeResult = await db
.select()
.from(requestTimes)
.where(
and(
lte(requestTimes.startTime, currentTime),
gt(requestTimes.endTime, currentTime),
eq(requestTimes.enabled, true)
)
)
.limit(1)
hitRequestTime = hitRequestTimeResult[0]
if (!hitRequestTime) {
throw createError({
statusCode: 403,
message: '当前不在投稿开放时段'
})
}
if (hitRequestTime.expected > 0 && hitRequestTime.accepted >= hitRequestTime.expected) {
throw createError({
statusCode: 403,
message: `当前时段投稿名额已满(${hitRequestTime.accepted}/${hitRequestTime.expected})`
})
}
}
let effectiveLimit: number | null = null
let limitType: 'daily' | 'weekly' | 'monthly' | null = null
if (systemSettingsData?.enableSubmissionLimit && !isAdmin) {
const dailyLimit = systemSettingsData.dailySubmissionLimit
const weeklyLimit = systemSettingsData.weeklySubmissionLimit
const monthlyLimit = systemSettingsData.monthlySubmissionLimit
if (dailyLimit !== null && dailyLimit !== undefined) {
effectiveLimit = dailyLimit
limitType = 'daily'
} else if (weeklyLimit !== null && weeklyLimit !== undefined) {
effectiveLimit = weeklyLimit
limitType = 'weekly'
} else if (monthlyLimit !== null && monthlyLimit !== undefined) {
effectiveLimit = monthlyLimit
limitType = 'monthly'
}
if (effectiveLimit === 0) {
throw createError({
statusCode: 403,
message: '投稿功能已关闭'
})
}
}
// 强制使用点歌券仅在点歌券功能启用时生效
if (
systemSettingsData?.requireCardCodeForRequests &&
systemSettingsData?.enableCardCodeRequests &&
!isAdmin
) {
const providedCardCode = requestBody.cardCode ? requestBody.cardCode.trim().toUpperCase() : ''
if (!providedCardCode) {
throw createApiError(
403,
'CARD_CODE_REQUIRED_FOR_SITE',
'This site requires a valid request card to submit songs'
)
}
}
const isCardCodeEnabled = systemSettingsData?.enableCardCodeRequests === true
const excludeCardCodeRequestsFromLimit = isCardCodeLimitBypassActive(systemSettingsData)
if (requestBody.cardCode && requestBody.cardCode.trim() && !isCardCodeEnabled && !isAdmin) {
throw createApiError(400, 'CARD_CODE_DISABLED', 'Request card submissions are not enabled')
}
let preferredPlayTime = null
if (requestBody.preferredPlayTimeId) {
if (!systemSettingsData?.enablePlayTimeSelection) {
throw createError({
statusCode: 400,
message: '播出时段选择功能未启用'
})
}
const playTimeResult = await db
.select()
.from(playTimes)
.where(and(eq(playTimes.id, requestBody.preferredPlayTimeId), eq(playTimes.enabled, true)))
.limit(1)
preferredPlayTime = playTimeResult[0]
if (!preferredPlayTime) {
throw createError({
statusCode: 400,
message: '选择的播出时段不存在或未启用'
})
}
}
const rawSubmissionNote = requestBody.submissionNote || ''
const submissionNote =
systemSettingsData?.enableSubmissionRemarks && rawSubmissionNote ? rawSubmissionNote : null
// 公开留言审核:开关开启时投稿不立即公开,进入待审后由管理员通过
const noteRequiresApproval = systemSettingsData?.submissionNoteRequiresApproval === true
const wantsPublic = submissionNote !== null ? requestBody.submissionNotePublic !== false : false
const submissionNotePublic = noteRequiresApproval ? false : wantsPublic
// 仅用户勾选公开的留言进入审核,私密留言只供管理员查看
const submissionNotePublicStatus =
noteRequiresApproval && submissionNote !== null && wantsPublic
? SUBMISSION_NOTE_STATUS.PENDING
: null
const notificationsToSend: { userId: number; songId: number; songTitle: string }[] = []
const song = await db.transaction(async (tx) => {
let providedCardCodeId: number | null = null
const providedCardCode = requestBody.cardCode ? requestBody.cardCode.trim().toUpperCase() : ''
if (providedCardCode) {
const codeRows = await tx
.select()
.from(cardCodes)
.where(eq(cardCodes.code, providedCardCode))
.limit(1)
const found = codeRows[0]
if (!found || found.status !== 'AVAILABLE') {
throw createApiError(
400,
'CARD_CODE_INVALID_OR_USED',
'Request card is invalid or already used'
)
}
const lockResult = await tx
.update(cardCodes)
.set({ status: 'LOCKED', lockedBy: user.id, lockedAt: new Date() })
.where(and(eq(cardCodes.id, found.id), eq(cardCodes.status, 'AVAILABLE')))
.returning()
if (lockResult.length === 0) {
throw createApiError(
400,
'CARD_CODE_LOCKED_OR_UNAVAILABLE',
'Request card is locked or unavailable'
)
}
providedCardCodeId = found.id
}
if (
systemSettingsData?.enableSubmissionLimit &&
!isAdmin &&
!(excludeCardCodeRequestsFromLimit && providedCardCodeId) &&
effectiveLimit &&
effectiveLimit > 0 &&
limitType
) {
// 同一用户的限额检查必须串行,否则并发请求可能读取到相同计数并同时越过上限。
await tx
.select({ id: users.id })
.from(users)
.where(eq(users.id, user.id))
.for('update')
if (
await isLimitReached(tx as any, user.id, limitType, effectiveLimit, {
excludeCardCodeRequests: excludeCardCodeRequestsFromLimit
})
) {
const labelMap: Record<string, string> = { daily: '每日', weekly: '每周', monthly: '每月' }
const timeMap: Record<string, string> = { daily: '今日', weekly: '本周', monthly: '本月' }
throw createError({
statusCode: 400,
message: `${labelMap[limitType]}投稿限额为${effectiveLimit}首,您${timeMap[limitType]}已达到限额`
})
}
}
if (hitRequestTime) {
const latestRequestTimeResult = await tx
.select()
.from(requestTimes)
.where(eq(requestTimes.id, hitRequestTime.id))
.limit(1)
const latestRequestTime = latestRequestTimeResult[0]
if (!latestRequestTime || !latestRequestTime.enabled) {
throw createError({ statusCode: 403, message: '投稿时段已失效' })
}
const updateResult = await tx
.update(requestTimes)
.set({
accepted: sql`${requestTimes.accepted} + 1`
})
.where(
and(
eq(requestTimes.id, hitRequestTime.id),
latestRequestTime.expected > 0
? lt(requestTimes.accepted, latestRequestTime.expected)
: undefined
)
)
.returning()
if (updateResult.length === 0) {
throw createError({ statusCode: 403, message: '当前时段投稿名额已满' })
}
}
let finalMusicId = requestBody.musicId ? String(requestBody.musicId) : null
if (isBilibili) {
const bvId = finalMusicId?.split(':')[0]
if (bvId) {
const musicIdParts = [bvId]
if (requestBody.bilibiliCid) {
musicIdParts.push(requestBody.bilibiliCid)
if (requestBody.bilibiliPage && Number(requestBody.bilibiliPage) > 1) {
musicIdParts.push(String(requestBody.bilibiliPage))
}
}
finalMusicId = musicIdParts.join(':')
}
}
const songResult = await tx
.insert(songs)
.values({
title: requestBody.title,
artist: requestBody.artist,
requesterId: user.id,
preferredPlayTimeId: preferredPlayTime?.id || null,
semester: currentSemester,
cover: requestBody.cover || null,
musicPlatform: isBilibili ? 'bilibili' : requestBody.musicPlatform || null,
musicId: finalMusicId,
cardCodeId: providedCardCodeId || null,
playUrl: requestBody.playUrl || null,
durationSeconds: requestBody.durationSeconds || null,
submissionNote,
submissionNotePublic,
submissionNotePublicStatus,
hitRequestId: hitRequestTime?.id || null
})
.returning()
const newSong = songResult[0]
if (!newSong) {
throw createError({ statusCode: 500, message: '点歌失败,请稍后重试' })
}
if (
requestBody.collaborators &&
Array.isArray(requestBody.collaborators) &&
requestBody.collaborators.length > 0
) {
const collaboratorIds = requestBody.collaborators.map((id: any) => Number(id)) as number[]
const uniqueCollaboratorIds = [...new Set<number>(collaboratorIds)].filter(
(id) => !isNaN(id) && id !== user.id
)
if (uniqueCollaboratorIds.length > 0) {
const validUsers = await tx
.select({ id: users.id })
.from(users)
.where(inArray(users.id, uniqueCollaboratorIds))
const validUserIds = new Set(validUsers.map((item) => item.id))
for (const collaboratorId of uniqueCollaboratorIds) {
if (!validUserIds.has(collaboratorId)) continue
try {
const existingCollab = await tx
.select()
.from(songCollaborators)
.where(
and(
eq(songCollaborators.songId, newSong.id),
eq(songCollaborators.userId, collaboratorId)
)
)
.limit(1)
if (existingCollab.length > 0) continue
const collabResult = await tx
.insert(songCollaborators)
.values({
songId: newSong.id,
userId: collaboratorId,
status: 'PENDING'
})
.returning()
const collab = collabResult[0]
if (!collab) continue
await tx.insert(collaborationLogs).values({
collaboratorId: collab.id,
action: 'INVITE',
operatorId: user.id,
ipAddress: getClientIP(event)
})
notificationsToSend.push({
userId: collaboratorId,
songId: newSong.id,
songTitle: newSong.title
})
} catch (err) {
console.error(`邀请用户 ${collaboratorId} 失败:`, err)
}
}
}
}
return newSong
})
// 投稿后立即补齐或校验歌曲时长(不阻塞请求响应)
const submitDuration = song.durationSeconds
const submitPlatform = song.musicPlatform
const submitMusicId = song.musicId
if (submitPlatform && submitMusicId) {
const durationValidationTask = (async () => {
try {
const decision = await reconcileSongDurationOnSubmit(song.id, submitPlatform, submitMusicId, submitDuration)
if (decision.outcome === 'fill' || decision.outcome === 'clear') {
await db.update(songs).set({ durationSeconds: decision.durationSeconds }).where(eq(songs.id, song.id))
if (decision.outcome === 'fill') {
console.log(`[投稿时长] #${song.id} 已补齐时长 ${decision.durationSeconds}s`)
}
}
} catch (err) {
console.error(`[投稿时长] 后台任务 #${song.id} 异常:`, err)
}
})()
if (typeof event.waitUntil === 'function') {
event.waitUntil(durationValidationTask)
} else {
durationValidationTask.catch((err) => console.error(`[投稿时长] 后台任务 #${song.id} 异常:`, err))
}
}
for (const notification of notificationsToSend) {
try {
await createCollaborationInvitationNotification(
user.id,
notification.userId,
notification.songId,
notification.songTitle
)
} catch (error) {
console.error(`发送邀请通知给用户 ${notification.userId} 失败:`, error)
}
}
return song
} catch (error: any) {
console.error('点歌失败:', error)
if (error.statusCode) {
throw error
} else if (error.message === '未设置活跃学期') {
throw createError({
statusCode: 400,
message: '系统未设置当前活跃学期,请联系管理员'
})
} else {
throw createError({
statusCode: 500,
message: '点歌失败,请稍后重试'
})
}
}
}
async function getCurrentSemesterName() {
try {
const currentSemesterResult = await db
.select()
.from(semesters)
.where(eq(semesters.isActive, true))
.limit(1)
const currentSemester = currentSemesterResult[0]
if (currentSemester) {
return currentSemester.name
}
throw new Error('未设置活跃学期')
} catch (error) {
console.error('获取当前学期失败:', error)
throw error
}
}
+89
View File
@@ -0,0 +1,89 @@
import bcrypt from 'bcryptjs'
import { db } from '~/drizzle/db'
import { passwordAuditLogs, users } from '~/drizzle/schema'
import { and, eq, sql } from 'drizzle-orm'
import { getBeijingTime } from '~/utils/timeUtils'
import { createApiError } from '~~/server/utils/apiError'
interface PasswordAuditContext {
action: string
ipAddress: string
userAgent: string | null
actorId?: number | null
}
interface UpdateUserPasswordOptions {
forceReset?: boolean
expectedTokenVersion?: number
auditContext?: PasswordAuditContext
}
/**
* 更新用户密码
* 包含:加密密码、更新密码修改时间
* @param userId 用户ID
* @param newPassword 新密码
* @param options 密码更新选项,可用 tokenVersion 做并发条件更新
*/
export async function updateUserPassword(
userId: number,
newPassword: string,
options: UpdateUserPasswordOptions = {}
): Promise<{ passwordChangedAt: Date; tokenVersion: number }> {
// 1. 加密新密码
const hashedNewPassword = await bcrypt.hash(newPassword, 12)
// 2. 更新数据库
const passwordChangedAt = getBeijingTime()
const updated = await db.transaction(async (tx) => {
const result = await tx
.update(users)
.set({
password: hashedNewPassword,
passwordChangedAt,
forcePasswordChange: options.forceReset ?? false,
tokenVersion: sql`${users.tokenVersion} + 1`
})
.where(
options.expectedTokenVersion === undefined
? eq(users.id, userId)
: and(eq(users.id, userId), eq(users.tokenVersion, options.expectedTokenVersion))
)
.returning({
passwordChangedAt: users.passwordChangedAt,
tokenVersion: users.tokenVersion
})
const updatedUser = result[0]
if (!updatedUser) {
if (options.expectedTokenVersion !== undefined) {
throw createApiError(409, 'AUTH_PASSWORD_STATE_CHANGED', '密码状态已变化,请重新验证后再试')
}
throw createApiError(404, 'USER_NOT_FOUND', '用户不存在')
}
return updatedUser
})
// 3. 审计日志在事务外写入,失败不影响密码修改主流程
if (options.auditContext) {
try {
await db.insert(passwordAuditLogs).values({
userId,
actorId: options.auditContext.actorId ?? null,
action: options.auditContext.action,
success: true,
ipAddress: options.auditContext.ipAddress,
userAgent: options.auditContext.userAgent,
failureReason: null
})
} catch (error) {
console.error('[PasswordAudit] 写入密码审计日志失败:', error)
}
}
return {
passwordChangedAt: updated.passwordChangedAt || passwordChangedAt,
tokenVersion: updated.tokenVersion
}
}