rules.js 3.45 KB
/**
 * 规则纯函数:校验规则表、按 (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<string, RateRule>}
 */
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<string, RateRule>} 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
}