初次提交
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s

This commit is contained in:
2026-09-06 08:19:02 +08:00
commit 4ce4649aaf
1061 changed files with 337258 additions and 0 deletions
+410
View File
@@ -0,0 +1,410 @@
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访问日志:`, {
apiKeyId: logData.apiKeyId,
endpoint: logData.endpoint,
method: logData.method,
ipAddress: logData.ipAddress,
statusCode: logData.statusCode,
responseTimeMs: logData.responseTimeMs,
userAgent: logData.userAgent?.substring(0, 50) + '...',
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
}
}
}
File diff suppressed because it is too large Load Diff
+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 }
}
+787
View File
@@ -0,0 +1,787 @@
import { db } from '~/drizzle/db'
import {
notifications,
notificationSettings,
playTimes,
schedules,
songCollaborators,
songs,
systemSettings,
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'
/**
* 创建联合投稿邀请通知
*/
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: 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
},
ipAddress?: string
) {
try {
// 获取系统设置,检查是否启用播出时段功能
const systemSettingsResult = await db.select().from(systemSettings).limit(1)
const systemConfig = systemSettingsResult[0]
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,
ipAddress,
'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
}
}
// 格式化日期为 yyyy-MM-dd 格式(北京时间)
function formatDate(date: Date): string {
return formatDateTime(date, 'YYYY-MM-DD')
}
/**
* 创建歌曲已播放的通知
*/
export async function createSongPlayedNotification(songId: number, ipAddress?: string) {
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,
ipAddress,
'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,
ipAddress?: string
) {
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,
ipAddress,
'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,
ipAddress?: 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,
ipAddress,
'notification.songRejected',
{
songTitle: songInfo.title,
songArtist: songInfo.artist,
reason: 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,
ipAddress?: 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, ipAddress)
} catch (error) {
console.error('创建歌曲备注清空通知失败:', error)
return []
}
}
/**
* 创建系统通知
*/
export async function createSystemNotification(
userId: number,
title: string,
content: string,
ipAddress?: 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 notificationResult = await db
.insert(notifications)
.values({
userId: userId,
type: 'SYSTEM_NOTICE',
message: content
})
.returning()
const notification = notificationResult[0]
// 同步发送 MeoW 通知
try {
await sendMeowNotificationToUser(userId, title, content)
} catch (error) {
console.error('发送 MeoW 通知失败:', error)
}
// 同步发送邮件通知
try {
await sendEmailNotificationToUser(
userId,
title,
content,
undefined,
undefined,
undefined,
ipAddress
)
} catch (error) {
console.error('发送邮件通知失败:', error)
}
return notification
} catch (err) {
return null
}
}
/**
* 批量创建系统通知
*/
export async function createBatchSystemNotifications(
userIds: number[],
title: string,
content: string,
ipAddress?: string
) {
try {
if (!userIds.length) {
return []
}
// 获取用户通知设置
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 (settings && !settings.enabled) {
continue
}
notificationsToCreate.push({
userId,
type: 'SYSTEM_NOTICE',
message: content
})
}
// 如果没有可发送的通知,直接返回
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,
undefined,
undefined,
ipAddress
)
} 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 },
ipAddress?: 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, ipAddress)
} catch (error) {
console.error('发送邮件通知失败:', error)
}
return notification
} catch (err) {
console.error('创建重播申请拒绝通知失败:', err)
return null
}
}
+708
View File
@@ -0,0 +1,708 @@
import { createSystemNotification } from './notificationService'
import { sendMeowNotificationToUser } from './meowNotificationService'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
// 账户锁定信息接口
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 accountLocks = new Map<string, AccountLockInfo>()
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 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
}
/**
* 清理过期的锁定记录
*/
function cleanupExpiredLocks() {
const now = new Date()
// 清理过期的账户锁定
for (const [username, lockInfo] of accountLocks.entries()) {
if (lockInfo.lockedUntil && lockInfo.lockedUntil <= now) {
accountLocks.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 = Date.now() - 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 = Date.now() - 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 = Date.now() - 10 * 60 * 1000
stats.windowTimestamps = stats.windowTimestamps.filter((t) => t >= cutoff)
if (stats.windowTimestamps.length === 0 && Date.now() - stats.lastUpdate > 60 * 60 * 1000) {
userVoteStats.delete(userId)
}
}
}
/**
* 检查账户是否被锁定
*/
export function isAccountLocked(username: string): boolean {
cleanupExpiredLocks()
const lockInfo = accountLocks.get(username)
if (!lockInfo || !lockInfo.lockedUntil) {
return false
}
return lockInfo.lockedUntil > new Date()
}
/**
* 获取账户锁定剩余时间(分钟)
*/
export function getAccountLockRemainingTime(username: string): number {
const lockInfo = accountLocks.get(username)
if (!lockInfo || !lockInfo.lockedUntil) {
return 0
}
const now = new Date()
if (lockInfo.lockedUntil <= now) {
return 0
}
return Math.ceil((lockInfo.lockedUntil.getTime() - now.getTime()) / (1000 * 60))
}
/**
* 检查IP是否被限制
*/
export function isIPBlocked(ip: string): boolean {
cleanupExpiredLocks()
const blockInfo = ipBlacklist.get(ip)
if (!blockInfo) {
return false
}
return blockInfo.blockedUntil > new Date()
}
/**
* 获取IP限制剩余时间(分钟)
*/
export function getIPBlockRemainingTime(ip: string): number {
const blockInfo = ipBlacklist.get(ip)
if (!blockInfo) {
return 0
}
const now = new Date()
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 = new Date()
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(Date.now() + minutes * 60 * 1000)
userBlockUntil.set(userId, until)
}
export function isUserBlocked(userId: number): boolean {
const until = userBlockUntil.get(userId)
if (!until) return false
return until > new Date()
}
export function getUserBlockRemainingTime(userId: number): number {
const until = userBlockUntil.get(userId)
if (!until) return 0
const now = Date.now()
if (until.getTime() <= now) return 0
return Math.ceil((until.getTime() - now) / (1000 * 60))
}
/**
* 记录登录失败
*/
export function recordLoginFailure(username: string, ip: string): void {
const now = new Date()
// 记录账户失败尝试
let lockInfo = accountLocks.get(username)
if (!lockInfo) {
lockInfo = {
failedAttempts: 0,
lockedUntil: null,
lastAttemptTime: now
}
accountLocks.set(username, lockInfo)
}
lockInfo.failedAttempts++
lockInfo.lastAttemptTime = now
// 检查是否需要锁定账户
if (lockInfo.failedAttempts >= SECURITY_CONFIG.MAX_FAILED_ATTEMPTS) {
lockInfo.lockedUntil = new Date(
now.getTime() + SECURITY_CONFIG.LOCK_DURATION_MINUTES * 60 * 1000
)
console.log(
`账户 ${username} 因连续 ${SECURITY_CONFIG.MAX_FAILED_ATTEMPTS} 次登录失败被锁定 ${SECURITY_CONFIG.LOCK_DURATION_MINUTES} 分钟`
)
}
// 记录IP监控信息
recordIPAttempt(ip, username)
}
/**
* 记录成功登录(清除失败记录)
*/
export function recordLoginSuccess(username: string, ip: string): void {
accountLocks.delete(username)
// 记录IP监控信息(成功登录也需要监控)
recordIPAttempt(ip, username)
}
export function recordAccountIpLogin(username: string, ip: string): boolean {
const now = Date.now()
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 = new Date()
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 = new Date()
const alertTitle = '安全警报:检测到异常登录行为'
const alertContent = `
检测时间:${now.toLocaleString('zh-CN')}
异常IP:${ip}
时间窗口:${SECURITY_CONFIG.IP_MONITOR_WINDOW_MINUTES}分钟内
尝试登录账户数:${attemptedAccounts.length}
涉及账户:${attemptedAccounts.join(', ')}
建议立即检查该IP的登录活动并采取必要的安全措施。
`.trim()
// Meow通知内容,包含完整的涉及账户信息
const meowAlertContent = `
检测时间:${now.toLocaleString('zh-CN')}
异常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 = new Date()
const alertTitle = '安全警报:账号短期内多IP登录'
const alertContent = `检测时间:${now.toLocaleString('zh-CN')}
账号:${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 > new Date()
}
export function getSongProtectRemainingSeconds(songId: number): number {
const until = songProtectUntil.get(songId)
if (!until) return 0
const now = Date.now()
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 = Date.now()
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 = Date.now()
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 = new Date()
const top = Array.from(buckets.entries()).sort((a, b) => b[1] - a[1])[0]
const alertTitle = '风险告警:歌曲投票短时激增'
const alertContent = `检测时间:${now.toLocaleString('zh-CN')}
歌曲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 = new Date()
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: new Date()
})
return
}
// 否则,立即发送第一条,并开启聚合窗口
const now = new Date()
const alertTitle = '风险告警:检测到异常投票速率'
const alertContent = `检测时间:${now.toLocaleString('zh-CN')}\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 function getSecurityStats() {
cleanupExpiredLocks()
return {
lockedAccounts: accountLocks.size,
monitoredIPs: ipMonitor.size,
blockedIPs: ipBlacklist.size,
config: SECURITY_CONFIG
}
}
// 定期清理过期记录(每5分钟执行一次)
setInterval(cleanupExpiredLocks, 5 * 60 * 1000)
+685
View File
@@ -0,0 +1,685 @@
import nodemailer from 'nodemailer'
import { db } from '~/drizzle/db'
import { emailTemplates, systemSettings, users } from '~/drizzle/schema'
import { and, eq, isNotNull } from 'drizzle-orm'
import { getSiteTitle } from '~~/server/utils/siteUtils'
import { formatIPForEmail } from '~~/server/utils/ip-utils'
/**
* 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}}
{{#if message}}
<p style="color: #555; white-space: pre-wrap;">{{message}}</p>
{{/if}}
`,
songPlayed: `
<p>您投稿的歌曲《{{songTitle}}》已播放。</p>
{{#if message}}
<p style="color: #555; white-space: pre-wrap;">{{message}}</p>
{{/if}}
`,
songVoted: `
<p>您投稿的歌曲《{{songTitle}}》获得了新的投票。</p>
<p>当前共有 <strong>{{votesCount}}</strong> 个投票。</p>
{{#if message}}
<p style="color: #555; white-space: pre-wrap;">{{message}}</p>
{{/if}}
`
}
/** 模板配置 */
private builtinTemplates: Record<
string,
{
name: string
subject: string
contentType: string
headerSubtitle: string
actionText?: string
}
> = {
'verification.code': {
name: '邮箱验证码',
subject: '邮箱验证码 | {{siteTitle}}',
contentType: 'verification',
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.songVoted': {
name: '收到新投票',
subject: '收到新投票 | {{siteTitle}}通知推送',
contentType: 'songVoted',
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 || '查看详情'
})
templates[key] = {
name: config.name,
subject: config.subject,
html
}
}
return templates
}
/**
* 初始化SMTP配置
*/
async initializeSmtpConfig(): Promise<boolean> {
try {
const settingsResult = await db.select().from(systemSettings).limit(1)
const settings = settingsResult[0]
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
}
// 根据端口和安全设置调整配置
if (port === 587 && !secure) {
// STARTTLS - 端口587通常使用STARTTLS
transporterConfig.requireTLS = true
transporterConfig.tls = {
// 不验证服务器证书(用于测试环境)
rejectUnauthorized: false
}
} else if (port === 465) {
// SSL/TLS - 端口465必须使用SSL
transporterConfig.secure = true
} else if (port === 25) {
// 通常不加密
transporterConfig.secure = false
transporterConfig.tls = {
rejectUnauthorized: false
}
}
// 创建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配置未初始化或无效')
}
// 如果提供了IP地址,在邮件内容中添加IP信息
let finalHtml = htmlContent
if (ipAddress) {
const formattedIP = formatIPForEmail(ipAddress)
// 在邮件末尾添加IP信息
finalHtml = htmlContent.replace(
/(<p[^>]*style="[^"]*text-align: center[^"]*"[^>]*>.*?此邮件由系统自动发送,请勿回复。.*?<\/p>)/s,
`$1`.replace(
'此邮件由系统自动发送,请勿回复。',
`此邮件由系统自动发送,请勿回复。<br><br>This email was requested from: ${formattedIP}`
)
)
}
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)
} else {
mergedData = await this.prepareTemplateData(data)
html = this.renderString(html, mergedData)
}
return {
subject: this.renderString(subject, mergedData),
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.message || '',
data.actionUrl,
formattedIP
)
const fallbackSubject = `${data.title || '通知'} | ${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 = `测试邮件 | ${templateData.siteTitle}通知推送`
const htmlContent = this.generateEmailTemplate(
'测试邮件',
'这是一封来自校园广播站系统的测试邮件。<br>如果您收到这封邮件,说明SMTP配置已经正确设置。',
undefined,
formattedIP
)
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
): string {
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;">${this.smtpConfig?.fromName || '校园广播站'}</h1>
<p style="color: #666; margin: 5px 0 0 0;">通知推送</p>
</div>
<h2 style="color: #333; margin-bottom: 20px;">${title}</h2>
<div style="color: #555; line-height: 1.6; margin-bottom: 30px;">
${content}
</div>
${
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;">查看详情</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>
如有疑问,请联系管理员。${formattedIP ? `<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;">${formattedIP}</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>): 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)
return v == null ? '' : String(v)
})
return tpl
}
/**
* 准备模板渲染数据
*/
private async prepareTemplateData(data: Record<string, any>): Promise<Record<string, any>> {
const siteTitle = await getSiteTitle()
return { fromName: this.smtpConfig?.fromName || '校园广播站', siteTitle, ...data }
}
}
/**
* 发送邮件通知给用户
*/
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, 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 }
}
+40
View File
@@ -0,0 +1,40 @@
import bcrypt from 'bcryptjs'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { getBeijingTime } from '~/utils/timeUtils'
/**
* 更新用户密码
* 包含:加密密码、更新密码修改时间、清除用户缓存
* @param userId 用户ID
* @param newPassword 新密码
* @param forceReset 是否强制用户下次登录时修改密码 (默认为 false)
*/
export async function updateUserPassword(
userId: number,
newPassword: string,
forceReset: boolean = false
): Promise<void> {
// 1. 加密新密码
const hashedNewPassword = await bcrypt.hash(newPassword, 12)
// 2. 更新数据库
await db
.update(users)
.set({
password: hashedNewPassword,
passwordChangedAt: forceReset ? null : getBeijingTime(),
forcePasswordChange: forceReset
})
.where(eq(users.id, userId))
// 3. 清除用户认证缓存
try {
const { userCache } = await import('~~/server/utils/cache-helpers')
await userCache.clearAuth(String(userId))
console.log(`[Cache] 用户认证缓存已清除(密码修改): ${userId}`)
} catch (cacheError) {
console.warn('[Cache] 清除用户认证缓存失败:', cacheError)
}
}