/** * limiter 编排:规则匹配 → key 构造 → 调注入 store 的 take/peek → 成形 RateDecision。 * * 本文件只做编排与判定成形,不碰桶数学(在 token-bucket.js)、不碰存储细节 * (在 store 实现里)、不读 env、不建连接——store 与 clock 全由工厂参数注入。 * * 时钟可注入(`nowMs`),默认 `Date.now`,仅在此装配层引用一次,便于测试精确 * 推进时间;桶数学文件本身不调时钟。 * * @typedef {import('./rules.js').RateRule} RateRule * * @typedef {Object} RateQuery * @property {string} endpoint 端点类别 * @property {string} subject 主体(IP 或匿名 device-id),不透明字符串 * * @typedef {Object} RateDecision * @property {boolean} allowed * @property {number} remaining 剩余配额(整数) * @property {number} retryAfterMs 超限时建议等待毫秒;放行时为 0 * @property {string} ruleId 命中的规则 id(无匹配规则时为 '') * * RateLimitStore(注入): * { * take(key, cost, rule, nowMs): Promise<{ allowed, remaining, retryAfterMs }>, * peek(key, rule, nowMs): Promise<{ remaining, retryAfterMs }> * } */ import { RateLimitConfigError } from './errors.js' import { validateRules, indexRules, selectRule, ruleIdOf } from './rules.js' import { bucketKey } from './key.js' /** 一次扣减的令牌数。一期固定为 1(每次 AI 调用扣 1)。 */ const COST = 1 /** * 无匹配规则时的放行决策(fail-open):没给某端点配规则即不限流, * 而不是默默拒绝合法流量。规则缺失是配置问题,应在 review 时发现,不在运行时静默拒绝。 * @returns {RateDecision} */ function unlimited() { return { allowed: true, remaining: Number.POSITIVE_INFINITY, retryAfterMs: 0, ruleId: '' } } /** * 校验 query 的最小形态。limiter 是内部边界,仅做轻量自检(HTTP 入口的 * Valibot 校验在 @app/server 路由层)。 * @param {unknown} q * @returns {{ endpoint: string, subject: string }} */ function requireQuery(q) { if (!q || typeof q !== 'object') { throw new RateLimitConfigError('query 必须是 { endpoint, subject } 对象') } const { endpoint, subject } = /** @type {Record} */ (q) if (typeof endpoint !== 'string' || endpoint.length === 0) { throw new RateLimitConfigError('query.endpoint 必须是非空字符串') } if (typeof subject !== 'string' || subject.length === 0) { throw new RateLimitConfigError('query.subject 必须是非空字符串') } return { endpoint, subject } } /** * 限流器工厂。store 与规则由调用方注入;包内不读 env、不建连接。 * @param {Object} cfg * @param {{ take: Function, peek: Function }} cfg.store 计数后端(注入) * @param {RateRule[]} cfg.rules 规则表 * @param {() => number} [cfg.now] 时钟(注入,默认 Date.now) * @returns {{ * check(ctx: unknown, q: RateQuery): Promise, * consume(ctx: unknown, q: RateQuery): Promise * }} * @throws {RateLimitConfigError} */ export function createRateLimiter(cfg) { if (!cfg || typeof cfg !== 'object') { throw new RateLimitConfigError('createRateLimiter 需要配置对象 { store, rules }') } const { store, rules, now } = cfg if (!store || typeof store.take !== 'function' || typeof store.peek !== 'function') { throw new RateLimitConfigError('store 缺失或未实现 take/peek(RateLimitStore)') } const indexed = indexRules(validateRules(rules)) const clock = typeof now === 'function' ? now : Date.now return { /** * 只读判定,不扣令牌。ctx 仅作日志/trace 透传(本包不直接依赖 observability)。 * @param {unknown} _ctx * @param {RateQuery} q * @returns {Promise} */ async check(_ctx, q) { const { endpoint, subject } = requireQuery(q) const rule = selectRule(indexed, endpoint) if (!rule) return unlimited() const key = bucketKey(rule, subject) const r = await store.peek(key, rule, clock()) return { allowed: r.retryAfterMs === 0, remaining: r.remaining, retryAfterMs: r.retryAfterMs, ruleId: ruleIdOf(rule), } }, /** * 扣令牌并判定。超限返回 allowed:false + retryAfterMs,供路由层做 429 Retry-After。 * @param {unknown} _ctx * @param {RateQuery} q * @returns {Promise} */ async consume(_ctx, q) { const { endpoint, subject } = requireQuery(q) const rule = selectRule(indexed, endpoint) if (!rule) return unlimited() const key = bucketKey(rule, subject) const r = await store.take(key, COST, rule, clock()) return { allowed: r.allowed, remaining: r.remaining, retryAfterMs: r.retryAfterMs, ruleId: ruleIdOf(rule), } }, } }