/** * 规则纯函数:校验规则表、按 (endpoint, by) 选中规则、生成稳定 ruleId。 * * 零 IO、不碰桶数学、不碰存储。规则形态见 §3: * `{ endpoint, by, capacity, refillPerSec }`。一期 `by ∈ {'ip','anon'}`。 * * @typedef {Object} RateRule * @property {string} endpoint 端点类别,如 'llm' | 'tts' | 'image' * @property {'ip'|'anon'} by 一期主体维度 * @property {number} capacity 桶容量(突发上限),> 0 * @property {number} refillPerSec 每秒补充令牌数(稳态速率),>= 0 */ import { RateLimitConfigError } from './errors.js' /** 一期合法的主体维度。二期扩 'user' 等只动这里与 key.js。 */ export const ALLOWED_BY = ['ip', 'anon'] /** * 生成规则的稳定标识,用于决策回传与日志定位。形如 `llm:ip`。 * @param {{ endpoint: string, by: string }} rule * @returns {string} */ export function ruleIdOf(rule) { return `${rule.endpoint}:${rule.by}` } /** * 校验整张规则表,非法立即抛 `RateLimitConfigError`。 * 校验项:非空数组、每条字段类型/取值合法(capacity>0、refillPerSec>=0、 * by 在白名单、endpoint 非空字符串)、ruleId 不重复(同 endpoint+by 只能配一条)。 * @param {unknown} rules * @returns {RateRule[]} 原样返回(校验通过) * @throws {RateLimitConfigError} */ export function validateRules(rules) { if (!Array.isArray(rules) || rules.length === 0) { throw new RateLimitConfigError('rules 必须是非空数组') } const seen = new Set() for (const r of rules) { if (!r || typeof r !== 'object') { throw new RateLimitConfigError('rule 必须是对象') } if (typeof r.endpoint !== 'string' || r.endpoint.length === 0) { throw new RateLimitConfigError('rule.endpoint 必须是非空字符串') } if (!ALLOWED_BY.includes(r.by)) { throw new RateLimitConfigError( `rule.by 非法: ${String(r.by)}(一期仅支持 ${ALLOWED_BY.join('/')})`, ) } if (typeof r.capacity !== 'number' || !(r.capacity > 0) || !Number.isFinite(r.capacity)) { throw new RateLimitConfigError(`rule.capacity 必须是正有限数: ${ruleIdOf(r)}`) } if ( typeof r.refillPerSec !== 'number' || r.refillPerSec < 0 || !Number.isFinite(r.refillPerSec) ) { throw new RateLimitConfigError(`rule.refillPerSec 必须是非负有限数: ${ruleIdOf(r)}`) } const id = ruleIdOf(r) if (seen.has(id)) { throw new RateLimitConfigError(`规则重复: ${id}(同 endpoint+by 只能配一条)`) } seen.add(id) } return /** @type {RateRule[]} */ (rules) } /** * 把规则表编入按 ruleId 索引的 Map,供 limiter 选规则。 * 假定 `rules` 已通过 `validateRules`。 * @param {RateRule[]} rules * @returns {Map} */ export function indexRules(rules) { const m = new Map() for (const r of rules) m.set(ruleIdOf(r), r) return m } /** * 在已编入的规则集中选中匹配 `endpoint` 的规则。 * 一期一个 endpoint 只配一条规则(其 by 维度即该端点的限流维度),故按 * endpoint 唯一选中;找不到返回 null(调用方决定 fail-open 还是 fail-closed)。 * @param {Map} indexed * @param {string} endpoint * @returns {RateRule | null} */ export function selectRule(indexed, endpoint) { for (const rule of indexed.values()) { if (rule.endpoint === endpoint) return rule } return null }