初次提交
This commit is contained in:
@@ -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
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user