/** * 防爆破 —— 窗口计数 + 阈值判定的纯逻辑(计数存储走注入的 repo,不用进程内 Map)。 * * 判定逻辑(`evaluateAttempts`)是纯函数:给定窗口内已记录的失败次数、阈值、窗口长度, * 返回是否锁定与 `retryAfterMs`。落库/读库由 repo 完成,本文件只决策。 * * 默认阈值/窗口可由调用方覆写(开闭原则:改规则只动这里的常量或注入 opts)。 */ export const DEFAULT_MAX_ATTEMPTS = 5 export const DEFAULT_WINDOW_MS = 15 * 60 * 1000 // 15 分钟 /** * 把 email + ip 归一成防爆破计数 key(同标识合并计数)。 * @param {string} email * @param {string} [ip] * @returns {string} */ export function attemptKey(email, ip) { const e = String(email ?? '').trim().toLowerCase() return ip ? `${e}|${ip}` : e } /** * 纯判定:当前窗口失败次数是否已达阈值;若是,给出还需等待多久。 * * `retryAfterMs` = 窗口长度(保守估计:最早一条失败记录在窗口边界时需等待整个窗口; * 若提供 `oldestAt` 与 `now` 则给出精确剩余时间)。 * * @param {Object} args * @param {number} args.recentCount 窗口内已记录的失败次数 * @param {number} [args.maxAttempts] * @param {number} [args.windowMs] * @param {number} [args.oldestAt] 窗口内最早一条失败记录的时间戳(epoch ms),可选精确化 * @param {number} [args.now] * @returns {{ locked: boolean, retryAfterMs: number }} */ export function evaluateAttempts({ recentCount, maxAttempts = DEFAULT_MAX_ATTEMPTS, windowMs = DEFAULT_WINDOW_MS, oldestAt, now = Date.now(), }) { const locked = recentCount >= maxAttempts if (!locked) return { locked: false, retryAfterMs: 0 } let retryAfterMs = windowMs if (typeof oldestAt === 'number') { // 最早一条失败记录滚出窗口后即可重试。 retryAfterMs = Math.max(0, oldestAt + windowMs - now) } return { locked: true, retryAfterMs } }