/** * `createAiLimit` —— 给烧钱的 AI 端点(LLM 续写 / TTS 合成 / 生图)挂 per-IP / 匿名基础 * 限流的 Fastify preHandler 工厂,超限返结构化 `429`。 * * 只做「按端点类别构造 RateQuery → consume → 超限成形 429」,桶数学 / store 在 @drama/ratelimit * 内。subject 取匿名 device-id(`x-device-id`)优先,否则回退客户端 IP(per-IP 防脚本薅穿)。 */ import { parse } from 'valibot' import { ApiError } from '@drama/contracts/platform' /** * @param {{ limiter: { consume: Function } }} deps @drama/ratelimit 的 limiter 实例 * @returns {(endpoint: 'llm'|'tts'|'image') => import('fastify').preHandlerHookHandler} */ export function createAiLimit({ limiter }) { if (!limiter || typeof limiter.consume !== 'function') { throw new Error('createAiLimit: 缺少注入的 limiter(需实现 consume)') } return function aiLimit(endpoint) { return async function preHandler(request, reply) { const subject = subjectOf(request) const decision = await limiter.consume(request.ctx, { endpoint, subject }) if (!decision.allowed) { const retryAfterSec = Math.ceil(decision.retryAfterMs / 1000) reply .code(429) .header('retry-after', String(retryAfterSec)) .header('x-ratelimit-remaining', String(Math.max(0, decision.remaining))) return reply.send( parse(ApiError, { error: { code: 'rate_limited', message: `Too many ${endpoint} requests, retry after ${retryAfterSec}s`, retryable: true, details: { endpoint, retryAfterMs: decision.retryAfterMs, ruleId: decision.ruleId }, }, }), ) } reply.header('x-ratelimit-remaining', String(Math.max(0, decision.remaining))) } } } /** * @param {import('fastify').FastifyRequest} request * @returns {string} */ function subjectOf(request) { const dev = request.headers['x-device-id'] if (typeof dev === 'string' && dev) return `anon:${dev}` return `ip:${request.ip || 'unknown'}` }