feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+808
View File
@@ -0,0 +1,808 @@
import {
RedisStateUnavailableError,
setStore,
getStore,
delStore,
incrStore
} from '~~/server/utils/captchaStore'
import { createSystemNotification } from './notificationService'
import { sendMeowNotificationToUser } from './meowNotificationService'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { getServerTimestamp, getServerDate } from '~~/server/utils/serverTime'
// 账户锁定信息接口
interface AccountLockInfo {
failedAttempts: number
lockedUntil: Date | null
lastAttemptTime: Date
}
// IP监控信息接口
interface IPMonitorInfo {
attemptedAccounts: Set<string>
firstAttemptTime: Date
lastAttemptTime: Date
}
// IP黑名单信息接口
interface IPBlockInfo {
blockedUntil: Date
reason: string
blockedTime: Date
}
// 内存存储
const ipMonitor = new Map<string, IPMonitorInfo>()
const ipBlacklist = new Map<string, IPBlockInfo>()
const accountIpSwitchMonitor = new Map<
string,
{ ipMap: Map<string, number>; windowStart: number }
>()
const userBlockUntil = new Map<number, Date>()
const songVoteWindow = new Map<number, number[]>()
const songProtectUntil = new Map<number, Date>()
const songVoteIpBuckets = new Map<number, Map<string, number>>()
const userVoteStats = new Map<
number,
{ emaPerMin: number; lastUpdate: number; windowTimestamps: number[] }
>()
// 通知限流存储,记录最近发送的通知,避免重复发送
const notificationRateLimit = new Map<string, Date>()
const NOTIFICATION_RATE_LIMIT_MINUTES = 5 // 同一类型通知5分钟内只发送一次
const fallbackLoginFailures = new Map<string, { count: number; expiresAt: number }>()
const fallbackAccountLocks = new Map<string, number>()
const REDIS_FALLBACK_LOG_INTERVAL_MS = 60 * 1000
let lastRedisFallbackLogAt = 0
// 配置常量
const SECURITY_CONFIG = {
MAX_FAILED_ATTEMPTS: 5,
LOCK_DURATION_MINUTES: 10,
IP_MONITOR_WINDOW_MINUTES: 10,
IP_MAX_DIFFERENT_ACCOUNTS: 3,
IP_BLOCK_DURATION_MINUTES: 10
}
const RISK_CONTROL = {
IP_SWITCH_WINDOW_MS: 5 * 60 * 1000,
IP_SWITCH_THRESHOLD: 3,
IP_SWITCH_BLOCK_MINUTES: 10,
USER_BLOCK_MINUTES: 10,
SONG_VOTE_PROTECT_WINDOW_MS: 60 * 60 * 1000,
SONG_VOTE_PROTECT_THRESHOLD: 10,
SONG_VOTE_PROTECT_DURATION_MS: 10 * 60 * 1000,
USER_BASELINE_MIN_PER_MIN: 0.5,
USER_BASELINE_MULTIPLIER: 3,
EMA_ALPHA: 0.2,
SONG_IP_DOMINANCE_PERCENT: 0.6,
SONG_IP_MIN_SAMPLE: 8
}
const handleRedisStateUnavailable = (error: unknown) => {
if (!(error instanceof RedisStateUnavailableError)) throw error
const now = getServerTimestamp()
if (now - lastRedisFallbackLogAt >= REDIS_FALLBACK_LOG_INTERVAL_MS) {
console.warn('[Security] Redis 暂不可用,登录失败计数临时回退到当前实例内存')
lastRedisFallbackLogAt = now
}
}
const getFallbackFailureCount = (username: string) => {
const record = fallbackLoginFailures.get(username)
if (!record) return 0
if (record.expiresAt <= getServerTimestamp()) {
fallbackLoginFailures.delete(username)
return 0
}
return record.count
}
const incrementFallbackFailureCount = (username: string) => {
const now = getServerTimestamp()
if (fallbackLoginFailures.size >= 10000) {
for (const [key, record] of fallbackLoginFailures.entries()) {
if (record.expiresAt <= now) fallbackLoginFailures.delete(key)
}
if (fallbackLoginFailures.size >= 10000 && !fallbackLoginFailures.has(username)) {
const oldestKey = fallbackLoginFailures.keys().next().value
if (oldestKey !== undefined) fallbackLoginFailures.delete(oldestKey)
}
}
const current = fallbackLoginFailures.get(username)
const count = current && current.expiresAt > now ? current.count + 1 : 1
fallbackLoginFailures.set(username, {
count,
expiresAt: current && current.expiresAt > now ? current.expiresAt : now + 30 * 60 * 1000
})
return count
}
const getFallbackLockedUntil = (username: string) => {
const lockedUntil = fallbackAccountLocks.get(username) || 0
if (lockedUntil <= getServerTimestamp()) {
fallbackAccountLocks.delete(username)
return 0
}
return lockedUntil
}
/**
* 清理过期的锁定记录
*/
function cleanupExpiredLocks() {
const now = getServerDate()
for (const [username, record] of fallbackLoginFailures.entries()) {
if (record.expiresAt <= now.getTime()) fallbackLoginFailures.delete(username)
}
for (const [username, lockedUntil] of fallbackAccountLocks.entries()) {
if (lockedUntil <= now.getTime()) fallbackAccountLocks.delete(username)
}
// 清理过期的IP监控记录
for (const [ip, monitorInfo] of ipMonitor.entries()) {
const windowStart = new Date(
now.getTime() - SECURITY_CONFIG.IP_MONITOR_WINDOW_MINUTES * 60 * 1000
)
if (monitorInfo.firstAttemptTime < windowStart) {
ipMonitor.delete(ip)
}
}
// 清理过期的IP黑名单记录
for (const [ip, blockInfo] of ipBlacklist.entries()) {
if (blockInfo.blockedUntil <= now) {
ipBlacklist.delete(ip)
console.log(`IP ${ip} 已从黑名单中移除`)
}
}
for (const [username, monitor] of accountIpSwitchMonitor.entries()) {
const cutoff = getServerTimestamp() - RISK_CONTROL.IP_SWITCH_WINDOW_MS
for (const [ip, ts] of monitor.ipMap.entries()) {
if (ts < cutoff) {
monitor.ipMap.delete(ip)
}
}
if (monitor.ipMap.size === 0) {
accountIpSwitchMonitor.delete(username)
}
}
for (const [songId, timestamps] of songVoteWindow.entries()) {
const cutoff = getServerTimestamp() - RISK_CONTROL.SONG_VOTE_PROTECT_WINDOW_MS
const filtered = timestamps.filter((t) => t >= cutoff)
if (filtered.length) {
songVoteWindow.set(songId, filtered)
} else {
songVoteWindow.delete(songId)
}
}
for (const [userId, stats] of userVoteStats.entries()) {
const cutoff = getServerTimestamp() - 10 * 60 * 1000
stats.windowTimestamps = stats.windowTimestamps.filter((t) => t >= cutoff)
if (
stats.windowTimestamps.length === 0 &&
getServerTimestamp() - stats.lastUpdate > 60 * 60 * 1000
) {
userVoteStats.delete(userId)
}
}
}
/**
* 检查账户是否被锁定
*/
export async function isAccountLocked(username: string): Promise<boolean> {
const lockKey = `account_lock:${username}`
let redisLockedUntil = 0
try {
const lockInfoStr = await getStore(lockKey)
if (lockInfoStr) {
const parsed = parseInt(lockInfoStr, 10)
if (Number.isFinite(parsed)) redisLockedUntil = parsed
}
} catch (error) {
handleRedisStateUnavailable(error)
}
return Math.max(redisLockedUntil, getFallbackLockedUntil(username)) > getServerTimestamp()
}
/**
* 获取账户锁定剩余时间(分钟)
*/
export async function getAccountLockRemainingTime(username: string): Promise<number> {
const lockKey = `account_lock:${username}`
let redisLockedUntil = 0
try {
const lockInfoStr = await getStore(lockKey)
if (lockInfoStr) {
const parsed = parseInt(lockInfoStr, 10)
if (Number.isFinite(parsed)) redisLockedUntil = parsed
}
} catch (error) {
handleRedisStateUnavailable(error)
}
const now = getServerTimestamp()
const lockedUntil = Math.max(redisLockedUntil, getFallbackLockedUntil(username))
if (lockedUntil <= now) return 0
return Math.ceil((lockedUntil - now) / (1000 * 60))
}
/**
* 检查IP是否被限制
*/
export function isIPBlocked(ip: string): boolean {
cleanupExpiredLocks()
const blockInfo = ipBlacklist.get(ip)
if (!blockInfo) {
return false
}
return blockInfo.blockedUntil > getServerDate()
}
/**
* 获取IP限制剩余时间(分钟)
*/
export function getIPBlockRemainingTime(ip: string): number {
const blockInfo = ipBlacklist.get(ip)
if (!blockInfo) {
return 0
}
const now = getServerDate()
if (blockInfo.blockedUntil <= now) {
return 0
}
return Math.ceil((blockInfo.blockedUntil.getTime() - now.getTime()) / (1000 * 60))
}
/**
* 将IP加入黑名单
*/
function blockIP(ip: string, reason: string): void {
const now = getServerDate()
const blockedUntil = new Date(
now.getTime() + SECURITY_CONFIG.IP_BLOCK_DURATION_MINUTES * 60 * 1000
)
ipBlacklist.set(ip, {
blockedUntil,
reason,
blockedTime: now
})
console.log(
`IP ${ip} 已被加入黑名单,限制时长 ${SECURITY_CONFIG.IP_BLOCK_DURATION_MINUTES} 分钟,原因:${reason}`
)
}
export function blockUser(userId: number, minutes: number = RISK_CONTROL.USER_BLOCK_MINUTES): void {
const until = new Date(getServerTimestamp() + minutes * 60 * 1000)
userBlockUntil.set(userId, until)
}
export function isUserBlocked(userId: number): boolean {
const until = userBlockUntil.get(userId)
if (!until) return false
return until > getServerDate()
}
export function getUserBlockRemainingTime(userId: number): number {
const until = userBlockUntil.get(userId)
if (!until) return 0
const now = getServerTimestamp()
if (until.getTime() <= now) return 0
return Math.ceil((until.getTime() - now) / (1000 * 60))
}
/**
* 记录登录失败
*/
export async function recordLoginFailure(username: string, ip: string): Promise<void> {
const failKey = `login_fail:${username}`
const lockKey = `account_lock:${username}`
let failedAttempts: number | null = null
try {
// 增加失败计数,有效期为 30 分钟
failedAttempts = Math.max(await incrStore(failKey, 30 * 60), getFallbackFailureCount(username))
if (failedAttempts >= SECURITY_CONFIG.MAX_FAILED_ATTEMPTS) {
const lockedUntil = getServerTimestamp() + SECURITY_CONFIG.LOCK_DURATION_MINUTES * 60 * 1000
await setStore(lockKey, lockedUntil.toString(), SECURITY_CONFIG.LOCK_DURATION_MINUTES * 60)
}
} catch (error) {
handleRedisStateUnavailable(error)
failedAttempts = Math.max(failedAttempts || 0, incrementFallbackFailureCount(username))
if (failedAttempts >= SECURITY_CONFIG.MAX_FAILED_ATTEMPTS) {
fallbackAccountLocks.set(
username,
getServerTimestamp() + SECURITY_CONFIG.LOCK_DURATION_MINUTES * 60 * 1000
)
}
}
if (failedAttempts >= SECURITY_CONFIG.MAX_FAILED_ATTEMPTS) {
console.log(
`账户 ${username} 因连续 ${SECURITY_CONFIG.MAX_FAILED_ATTEMPTS} 次登录失败被锁定 ${SECURITY_CONFIG.LOCK_DURATION_MINUTES} 分钟`
)
}
// 记录IP监控信息
recordIPAttempt(ip, username)
}
/**
* 记录成功登录(清除失败记录)
*/
export async function recordLoginSuccess(username: string, ip: string): Promise<void> {
const failKey = `login_fail:${username}`
const lockKey = `account_lock:${username}`
try {
await Promise.all([delStore(failKey), delStore(lockKey)])
} catch (error) {
handleRedisStateUnavailable(error)
}
fallbackLoginFailures.delete(username)
fallbackAccountLocks.delete(username)
// 记录IP监控信息(成功登录也需要监控)
recordIPAttempt(ip, username)
}
export function recordAccountIpLogin(username: string, ip: string): boolean {
const now = getServerTimestamp()
let monitor = accountIpSwitchMonitor.get(username)
if (!monitor) {
monitor = { ipMap: new Map<string, number>(), windowStart: now }
accountIpSwitchMonitor.set(username, monitor)
}
const cutoff = now - RISK_CONTROL.IP_SWITCH_WINDOW_MS
for (const [k, ts] of monitor.ipMap.entries()) {
if (ts < cutoff) monitor.ipMap.delete(k)
}
monitor.ipMap.set(ip, now)
const exceeded = monitor.ipMap.size > RISK_CONTROL.IP_SWITCH_THRESHOLD
if (exceeded) {
blockIP(ip, '账号短期内多IP登录超限')
triggerAccountIpSwitchAlert(username, Array.from(monitor.ipMap.keys()))
}
return exceeded
}
/**
* 记录IP登录尝试
*/
function recordIPAttempt(ip: string, username: string): void {
const now = getServerDate()
const windowStart = new Date(
now.getTime() - SECURITY_CONFIG.IP_MONITOR_WINDOW_MINUTES * 60 * 1000
)
let monitorInfo = ipMonitor.get(ip)
if (!monitorInfo) {
monitorInfo = {
attemptedAccounts: new Set<string>(),
firstAttemptTime: now,
lastAttemptTime: now
}
ipMonitor.set(ip, monitorInfo)
}
// 如果监控窗口已过期,重置记录
if (monitorInfo.firstAttemptTime < windowStart) {
monitorInfo.attemptedAccounts.clear()
monitorInfo.firstAttemptTime = now
}
monitorInfo.attemptedAccounts.add(username)
monitorInfo.lastAttemptTime = now
// 检查是否触发异常行为警报
if (monitorInfo.attemptedAccounts.size > SECURITY_CONFIG.IP_MAX_DIFFERENT_ACCOUNTS) {
triggerSecurityAlert(ip, Array.from(monitorInfo.attemptedAccounts))
}
}
/**
* 触发安全警报
*/
async function triggerSecurityAlert(ip: string, attemptedAccounts: string[]): Promise<void> {
try {
const now = getServerDate()
const alertTitle = '安全警报:检测到异常登录行为'
const alertContent = `
检测时间:${now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}
异常IP:${ip}
时间窗口:${SECURITY_CONFIG.IP_MONITOR_WINDOW_MINUTES}分钟内
尝试登录账户数:${attemptedAccounts.length}
涉及账户:${attemptedAccounts.join(', ')}
建议立即检查该IP的登录活动并采取必要的安全措施。
`.trim()
// Meow通知内容,包含完整的涉及账户信息
const meowAlertContent = `
检测时间:${now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}
异常IP:${ip}
时间窗口:${SECURITY_CONFIG.IP_MONITOR_WINDOW_MINUTES}分钟内
尝试登录账户数:${attemptedAccounts.length}
涉及账户:${attemptedAccounts.join(', ')}
建议立即检查该IP的登录活动并采取必要的安全措施。
`.trim()
console.log(
`安全警报:IP ${ip} 在 ${SECURITY_CONFIG.IP_MONITOR_WINDOW_MINUTES} 分钟内尝试登录 ${attemptedAccounts.length} 个不同账户`
)
// 将触发警报的IP加入黑名单
blockIP(
ip,
`异常登录行为:${SECURITY_CONFIG.IP_MONITOR_WINDOW_MINUTES}分钟内尝试登录${attemptedAccounts.length}个不同账户`
)
// 获取所有超级管理员
const superAdmins = await db
.select({
id: users.id,
name: users.name,
meowNickname: users.meowNickname
})
.from(users)
.where(eq(users.role, 'SUPER_ADMIN'))
// 向所有超级管理员发送站内信
for (const admin of superAdmins) {
try {
await createSystemNotification(admin.id, alertTitle, alertContent)
console.log(`已向超级管理员 ${admin.name} 发送安全警报站内信`)
// 如果管理员绑定了Meow推送,同时发送推送通知
if (admin.meowNickname) {
const success = await sendMeowNotificationToUser(admin.id, alertTitle, meowAlertContent)
if (success) {
console.log(`已向超级管理员 ${admin.name} 发送Meow推送警报`)
} else {
console.log(`向超级管理员 ${admin.name} 发送Meow推送警报失败`)
}
}
} catch (error) {
console.error(`向超级管理员 ${admin.name} 发送安全警报失败:`, error)
}
}
// 重置该IP的监控记录,避免重复警报
ipMonitor.delete(ip)
} catch (error) {
console.error('触发安全警报时发生错误:', error)
}
}
async function triggerAccountIpSwitchAlert(username: string, ips: string[]): Promise<void> {
try {
const now = getServerDate()
const alertTitle = '安全警报:账号短期内多IP登录'
const alertContent = `检测时间:${now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}
账号:${username}
时间窗口:${Math.floor(RISK_CONTROL.IP_SWITCH_WINDOW_MS / 60000)}分钟内
涉及IP数:${ips.length}
建议立即检查该账号的登录活动并采取必要的安全措施。`
const meowAlertContent = alertContent
const superAdmins = await db
.select({
id: users.id,
name: users.name,
meowNickname: users.meowNickname
})
.from(users)
.where(eq(users.role, 'SUPER_ADMIN'))
for (const admin of superAdmins) {
try {
await createSystemNotification(admin.id, alertTitle, alertContent)
if (admin.meowNickname) {
await sendMeowNotificationToUser(admin.id, alertTitle, meowAlertContent)
}
} catch {}
}
} catch (error) {
console.error('触发账号IP切换警报时发生错误:', error)
}
}
function ipBucketOf(ip: string): string {
const parts = ip.split('.')
if (parts.length === 4) return `${parts[0]}.${parts[1]}.${parts[2]}`
return ip
}
export function isSongProtected(songId: number): boolean {
const until = songProtectUntil.get(songId)
if (!until) return false
return until > getServerDate()
}
export function getSongProtectRemainingSeconds(songId: number): number {
const until = songProtectUntil.get(songId)
if (!until) return 0
const now = getServerTimestamp()
if (until.getTime() <= now) return 0
return Math.ceil((until.getTime() - now) / 1000)
}
export function recordSongVote(songId: number, ip: string, userId: number): boolean {
const now = getServerTimestamp()
const arr = songVoteWindow.get(songId) || []
const cutoff = now - RISK_CONTROL.SONG_VOTE_PROTECT_WINDOW_MS
const filtered = arr.filter((t) => t >= cutoff)
filtered.push(now)
songVoteWindow.set(songId, filtered)
const buckets = songVoteIpBuckets.get(songId) || new Map<string, number>()
const b = ipBucketOf(ip)
buckets.set(b, (buckets.get(b) || 0) + 1)
songVoteIpBuckets.set(songId, buckets)
if (filtered.length > RISK_CONTROL.SONG_VOTE_PROTECT_THRESHOLD) {
songProtectUntil.set(songId, new Date(now + RISK_CONTROL.SONG_VOTE_PROTECT_DURATION_MS))
triggerSongVoteBurstAlert(songId, filtered.length, buckets)
return true
}
return false
}
export function recordUserVoteActivity(userId: number, songTitle?: string): { anomaly: boolean } {
const now = getServerTimestamp()
let stats = userVoteStats.get(userId)
if (!stats) {
stats = { emaPerMin: 0, lastUpdate: now, windowTimestamps: [] }
userVoteStats.set(userId, stats)
}
// 更新时间窗口,保留最近10分钟的投票记录
const cutoff = now - 10 * 60 * 1000
stats.windowTimestamps = stats.windowTimestamps.filter((t) => t >= cutoff)
stats.windowTimestamps.push(now)
// 计算当前10分钟窗口内的平均速率,而不仅仅是两次投票的间隔
// 如果只有一个投票记录,无法计算速率(时间跨度为0),且单个投票不构成"速率",直接视为0
let currentRate = 0
if (stats.windowTimestamps.length > 1) {
const windowDurationMin = Math.max(1e-6, (now - Math.min(...stats.windowTimestamps)) / 60000)
currentRate = stats.windowTimestamps.length / windowDurationMin
}
// 调整EMA_ALPHA值为0.1,使EMA更平滑,减少短期波动影响
const smoothedEmaAlpha = 0.1
stats.emaPerMin = smoothedEmaAlpha * currentRate + (1 - smoothedEmaAlpha) * stats.emaPerMin
stats.lastUpdate = now
// 动态调整阈值:基础阈值 + EMA的动态倍数
// 当EMA较低时,使用较高倍数;当EMA较高时,使用较低倍数,避免误报
const baseThreshold = 5 // 基础阈值:每分钟5票
const dynamicMultiplier = Math.max(2, 5 - Math.min(3, stats.emaPerMin / 100)) // 动态倍数:2-5倍
const threshold = Math.max(baseThreshold, stats.emaPerMin * dynamicMultiplier)
// 只有当当前速率显著超过阈值(1.5倍)时,才触发异常
const anomaly = currentRate > threshold * 1.5
if (anomaly) triggerVoteAnomalyAlert(userId, stats.emaPerMin, currentRate, songTitle)
return { anomaly }
}
async function triggerSongVoteBurstAlert(
songId: number,
count: number,
buckets: Map<string, number>
): Promise<void> {
try {
const now = getServerDate()
const top = Array.from(buckets.entries()).sort((a, b) => b[1] - a[1])[0]
const alertTitle = '风险告警:歌曲投票短时激增'
const alertContent = `检测时间:${now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}
歌曲ID:${songId}
窗口内票数:${count}
主导IP段:${top ? `${top[0]}.* (${top[1]})` : '无'}
已启动临时保护,暂停投票10分钟。`
const meowAlertContent = alertContent
const superAdmins = await db
.select({
id: users.id,
name: users.name,
meowNickname: users.meowNickname
})
.from(users)
.where(eq(users.role, 'SUPER_ADMIN'))
for (const admin of superAdmins) {
try {
await createSystemNotification(admin.id, alertTitle, alertContent)
if (admin.meowNickname) {
await sendMeowNotificationToUser(admin.id, alertTitle, meowAlertContent)
}
} catch {}
}
} catch (error) {
console.error('触发歌曲投票激增警报时发生错误:', error)
}
}
// 检查通知是否可以发送(限流控制)
function canSendNotification(key: string): boolean {
const now = getServerDate()
const lastSent = notificationRateLimit.get(key)
if (
lastSent &&
now.getTime() - lastSent.getTime() < NOTIFICATION_RATE_LIMIT_MINUTES * 60 * 1000
) {
return false
}
notificationRateLimit.set(key, now)
return true
}
interface AnomalyEvent {
userId: number
ema: number
rate: number
songTitle?: string
time: Date
}
const pendingAnomalies: AnomalyEvent[] = []
let anomalyAggregationTimer: NodeJS.Timeout | null = null
const ANOMALY_AGGREGATION_WINDOW_MS = 60 * 1000 // 1分钟聚合窗口
async function flushAnomalyAggregation() {
if (pendingAnomalies.length === 0) {
anomalyAggregationTimer = null
return
}
try {
const count = pendingAnomalies.length
// 统计涉及的歌曲
const songStats = new Map<string, number>()
pendingAnomalies.forEach((e) => {
const title = e.songTitle || '未知歌曲'
songStats.set(title, (songStats.get(title) || 0) + 1)
})
// 按次数排序,取前5
const topSongs = Array.from(songStats.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([title, c]) => `- ${title} (${c}人)`)
.join('\n')
const alertTitle = '风险告警:检测到多名用户异常投票(汇总)'
const alertContent = `在过去 1 分钟内,除已通知的用户外,还检测到 ${count} 名用户存在异常投票行为。
涉及主要歌曲:
${topSongs}
请关注后台日志或进行相关处理。`
const meowAlertContent = alertContent
const superAdmins = await db
.select({
id: users.id,
name: users.name,
meowNickname: users.meowNickname
})
.from(users)
.where(eq(users.role, 'SUPER_ADMIN'))
for (const admin of superAdmins) {
try {
await createSystemNotification(admin.id, alertTitle, alertContent)
if (admin.meowNickname) {
await sendMeowNotificationToUser(admin.id, alertTitle, meowAlertContent)
}
} catch {}
}
} catch (error) {
console.error('发送异常投票汇总通知失败:', error)
} finally {
// 清空队列和定时器
pendingAnomalies.length = 0
anomalyAggregationTimer = null
}
}
async function triggerVoteAnomalyAlert(
userId: number,
ema: number,
rate: number,
songTitle?: string
): Promise<void> {
try {
// 通知限流,同一用户的投票异常通知5分钟内只发送一次
const notificationKey = `vote_anomaly_${userId}`
if (!canSendNotification(notificationKey)) {
return
}
// 如果正在聚合窗口期,则加入缓冲区
if (anomalyAggregationTimer) {
pendingAnomalies.push({
userId,
ema,
rate,
songTitle,
time: getServerDate()
})
return
}
// 否则,立即发送第一条,并开启聚合窗口
const now = getServerDate()
const alertTitle = '风险告警:检测到异常投票速率'
const alertContent = `检测时间:${now.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}\n用户ID:${userId}\nEMA基线:${ema.toFixed(2)} 次/分钟\n当前速率:${rate.toFixed(2)} 次/分钟\n${songTitle ? `涉及歌曲:${songTitle}\n` : ''}\n提示:该用户的投票行为异常,已触发限流机制。`
const meowAlertContent = alertContent
const superAdmins = await db
.select({
id: users.id,
name: users.name,
meowNickname: users.meowNickname
})
.from(users)
.where(eq(users.role, 'SUPER_ADMIN'))
for (const admin of superAdmins) {
try {
await createSystemNotification(admin.id, alertTitle, alertContent)
if (admin.meowNickname) {
await sendMeowNotificationToUser(admin.id, alertTitle, meowAlertContent)
}
} catch {}
}
// 启动聚合定时器
anomalyAggregationTimer = setTimeout(flushAnomalyAggregation, ANOMALY_AGGREGATION_WINDOW_MS)
} catch (error) {
console.error('触发投票异常警报时发生错误:', error)
}
}
/**
* 获取安全统计信息
*/
export async function getSecurityStats() {
cleanupExpiredLocks()
return {
lockedAccounts: 0,
monitoredIPs: ipMonitor.size,
blockedIPs: ipBlacklist.size,
config: SECURITY_CONFIG
}
}
/**
* 获取账户当前的登录失败次数(用于判断是否需要图形验证码)
*/
export async function getLoginFailureCount(username: string): Promise<number> {
const failKey = `login_fail:${username}`
try {
const val = await getStore(failKey)
const redisCount = val ? parseInt(val, 10) : 0
return Math.max(Number.isFinite(redisCount) ? redisCount : 0, getFallbackFailureCount(username))
} catch (error) {
handleRedisStateUnavailable(error)
return getFallbackFailureCount(username)
}
}
// 定期清理过期记录(每5分钟执行一次)
setInterval(cleanupExpiredLocks, 5 * 60 * 1000)