// 公开检测的限流(KV 绑定 LIMITS)。每次检测要往对方接口发约 17 个请求,不能让这里变成压测工具: // · 每个 IP:10 分钟最多 3 次、每个 UTC 日最多 20 次、同一时间最多 1 个在跑; // · 每个目标主机:每个 UTC 日最多 60 次。 // 内部的 /run(EVAL_TOKEN)不走这里。 // // KV 是最终一致的:同一个键在不同机房之间几十秒才同步,同一机房里读自己刚写的一般能读到。所以这些上限是**近似**的, // 同一个 IP 从不同机房并发打进来,可能多跑一两次;对「别拿它当压测工具」够用,不追求精确。计数键都带过期时间, // 「在跑」的标记最多 10 分钟(检测有整轮超时,正常一两分钟就清掉;Worker 被中断时靠过期兜底)。 export interface LimitsKV { get(key: string): Promise; put(key: string, value: string, options?: { expirationTtl?: number }): Promise; delete(key: string): Promise; } export const LIMIT_IP_10M = 3; export const LIMIT_IP_DAY = 20; export const LIMIT_HOST_DAY = 60; const WINDOW_10M_S = 600; const INFLIGHT_TTL_S = 600; const DAY_TTL_S = 90_000; // 一天多一点,过了零点的旧键自己消失 export type LimitReason = "ip10m" | "ipDay" | "host" | "inflight"; export type LimitDecision = | { ok: true; release: () => Promise } | { ok: false; reason: LimitReason; retryAfterS: number }; function utcDay(nowMs: number): string { return new Date(nowMs).toISOString().slice(0, 10); } function secondsToUtcMidnight(nowMs: number): number { const d = new Date(nowMs); const next = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1); return Math.max(1, Math.ceil((next - nowMs) / 1000)); } async function count(kv: LimitsKV, key: string): Promise { const v = await kv.get(key); const n = v === null ? 0 : Number.parseInt(v, 10); return Number.isFinite(n) ? n : 0; } /** 检查并占位:都没超就把三个计数各加一、标记「在跑」,回一个 release(检测结束时调,清掉「在跑」)。 */ export async function reserve(kv: LimitsKV, ip: string, host: string, nowMs: number = Date.now()): Promise { const day = utcDay(nowMs); const bucket = Math.floor(nowMs / (WINDOW_10M_S * 1000)); const kInflight = `inflight:${ip}`; const k10m = `ip10m:${ip}:${bucket}`; const kDay = `ipday:${ip}:${day}`; const kHost = `host:${host}:${day}`; const [inflight, n10m, nDay, nHost] = await Promise.all([kv.get(kInflight), count(kv, k10m), count(kv, kDay), count(kv, kHost)]); if (inflight !== null) return { ok: false, reason: "inflight", retryAfterS: 30 }; if (n10m >= LIMIT_IP_10M) { const end = (bucket + 1) * WINDOW_10M_S * 1000; return { ok: false, reason: "ip10m", retryAfterS: Math.max(1, Math.ceil((end - nowMs) / 1000)) }; } if (nDay >= LIMIT_IP_DAY) return { ok: false, reason: "ipDay", retryAfterS: secondsToUtcMidnight(nowMs) }; if (nHost >= LIMIT_HOST_DAY) return { ok: false, reason: "host", retryAfterS: secondsToUtcMidnight(nowMs) }; await Promise.all([ kv.put(kInflight, "1", { expirationTtl: INFLIGHT_TTL_S }), kv.put(k10m, String(n10m + 1), { expirationTtl: WINDOW_10M_S + 60 }), kv.put(kDay, String(nDay + 1), { expirationTtl: DAY_TTL_S }), kv.put(kHost, String(nHost + 1), { expirationTtl: DAY_TTL_S }), ]); let released = false; return { ok: true, release: async () => { if (released) return; released = true; await kv.delete(kInflight); }, }; }