From fb7e08399e464329c58842217bfe2d929e658f14 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Sun, 16 Aug 2026 10:39:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=BA=E8=A7=92=E8=89=B2=E5=8D=A1?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E6=B7=BB=E5=8A=A0=E8=AE=BF=E5=AE=A2=E9=99=90?= =?UTF-8?q?=E6=B5=81=EF=BC=88=E6=AF=8F=E4=BC=9A=E8=AF=9D=2020=20=E6=9D=A1?= =?UTF-8?q?=20/=20=E6=AF=8F=20IP=20=E6=AF=8F=E6=97=A5=2050=20=E6=9D=A1?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/api/characters/[slug]/chat/index.post.ts | 7 ++- server/service/character/chat-engine.ts | 38 +++++++++++- server/service/character/rate-limit.ts | 80 +++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 server/service/character/rate-limit.ts diff --git a/server/api/characters/[slug]/chat/index.post.ts b/server/api/characters/[slug]/chat/index.post.ts index 2ae2ae8..f8643da 100644 --- a/server/api/characters/[slug]/chat/index.post.ts +++ b/server/api/characters/[slug]/chat/index.post.ts @@ -1,4 +1,4 @@ -import { defineEventHandler, getRouterParam, readBody } from "h3"; +import { defineEventHandler, getRouterParam, readBody, setResponseHeaders } from "h3"; import { getCurrentUser, getConfigGlobal } from "#server/utils/context"; import { resolveCharacterIdentity } from "#server/service/character/identity"; import { getCharacterSessionByIdAndUser } from "#server/service/character/session"; @@ -46,6 +46,7 @@ export default defineEventHandler(async (event) => { const preferredModelId = modelId ?? null; const globalDefaultModelId = (await getConfigGlobal("agentDefaultModelId")) ?? null; const user = identity.userId ? await getCurrentUser(event) : null; + const ip = getRequestIP(event, { xForwardedFor: true }) || "unknown"; const { response } = await executeCharacterChat({ card, @@ -54,6 +55,10 @@ export default defineEventHandler(async (event) => { content, preferredModelId, globalDefaultModelId, + ip, + setRateLimitHeaders: (headers) => { + setResponseHeaders(event, headers); + }, requestId: event.context.requestId, onClientClose: (cb) => { event.node.req.on("close", cb); diff --git a/server/service/character/chat-engine.ts b/server/service/character/chat-engine.ts index 5c4146b..1b97a8a 100644 --- a/server/service/character/chat-engine.ts +++ b/server/service/character/chat-engine.ts @@ -10,6 +10,7 @@ import { updateCharacterSession, } from "./session"; import { registerCharacterAbort, unregisterCharacterAbort } from "./abort"; +import { checkCharacterRateLimit, incrementCharacterRateLimit } from "./rate-limit"; import type { CharacterCardRow, CharacterSessionRow } from "./types"; import log4js from "logger"; @@ -22,6 +23,8 @@ export interface CharacterChatEngineParams { content: string; preferredModelId?: number | null; globalDefaultModelId?: number | null; + ip: string; + setRateLimitHeaders?: (headers: Record) => void; requestId?: string; onClientClose?: (cb: () => void) => void; } @@ -47,10 +50,32 @@ function truncateFirstLine(text: string): string { export async function executeCharacterChat( params: CharacterChatEngineParams, ): Promise { - const { card, session, user, content, requestId = "-" } = params; + const { card, session, user, content, ip, requestId = "-" } = params; const sessionId = session.id; const textContent = content.trim(); + // 0. 访客限流(仅未登录用户生效) + if (!user) { + const rateLimit = checkCharacterRateLimit(card.slug, sessionId, ip); + if (rateLimit.blocked) { + params.setRateLimitHeaders?.({ + "X-RateLimit-Session-Remaining": String(rateLimit.sessionRemaining), + "X-RateLimit-Ip-Remaining": String(rateLimit.ipRemaining), + }); + throw createError({ + statusCode: 429, + statusMessage: + rateLimit.sessionRemaining === 0 + ? "当前会话已达回复上限,请登录后继续" + : "今日回复次数已达上限,请登录或明日再试", + }); + } + params.setRateLimitHeaders?.({ + "X-RateLimit-Session-Remaining": String(rateLimit.sessionRemaining), + "X-RateLimit-Ip-Remaining": String(rateLimit.ipRemaining), + }); + } + // 1. 模型解析(复用 llm 原语):session > 本次请求覆盖 > card > 全局默认 const targetModelId = session.modelId ?? @@ -150,6 +175,14 @@ export async function executeCharacterChat( }); } await touchCharacterSession(sessionId); + if (!user) { + incrementCharacterRateLimit(card.slug, sessionId, ip); + const rateLimit = checkCharacterRateLimit(card.slug, sessionId, ip); + params.setRateLimitHeaders?.({ + "X-RateLimit-Session-Remaining": String(rateLimit.sessionRemaining), + "X-RateLimit-Ip-Remaining": String(rateLimit.ipRemaining), + }); + } unregisterCharacterAbort(sessionId); } catch (e) { logger.error("[%s] [CHAR-ENGINE] onFinish error: %s", requestId, e instanceof Error ? e.message : String(e)); @@ -169,6 +202,9 @@ export async function executeCharacterChat( }); } await touchCharacterSession(sessionId); + if (!user) { + incrementCharacterRateLimit(card.slug, sessionId, ip); + } unregisterCharacterAbort(sessionId); } catch (e) { logger.error("[%s] [CHAR-ENGINE] onAbort error: %s", requestId, e instanceof Error ? e.message : String(e)); diff --git a/server/service/character/rate-limit.ts b/server/service/character/rate-limit.ts new file mode 100644 index 0000000..65e6b1c --- /dev/null +++ b/server/service/character/rate-limit.ts @@ -0,0 +1,80 @@ +/** + * 角色卡限流(独立于 agent 的 rate-limit.ts)。 + * + * 仅对未登录用户(访客)生效,与 agent 采用相同的阈值与实现: + * - 每会话上限 20 条 + * - 每 IP 每日上限 50 条 + * 内存计数(Map),按日期字符串跨天自动重置;进程重启即清零(与 agent 一致)。 + */ + +const SESSION_LIMIT = 20; +const IP_DAILY_LIMIT = 50; + +interface RateCounter { + count: number; + date: string; +} + +const sessionCounters = new Map(); +const ipCounters = new Map(); + +function getTodayStr(): string { + return new Date().toISOString().slice(0, 10); +} + +function getCounter(map: Map, key: string): RateCounter { + const today = getTodayStr(); + let counter = map.get(key); + if (!counter || counter.date !== today) { + counter = { count: 0, date: today }; + map.set(key, counter); + } + return counter; +} + +function buildSessionKey(cardSlug: string, sessionId: string): string { + return `character:${cardSlug}:${sessionId}`; +} + +function buildIpKey(cardSlug: string, ip: string): string { + return `character:${cardSlug}:${ip}`; +} + +export interface CharacterRateLimitResult { + sessionRemaining: number; + ipRemaining: number; + blocked: boolean; +} + +export function checkCharacterRateLimit( + cardSlug: string, + sessionId: string, + ip: string, +): CharacterRateLimitResult { + const sessionKey = buildSessionKey(cardSlug, sessionId); + const ipKey = buildIpKey(cardSlug, ip); + const sessionCounter = getCounter(sessionCounters, sessionKey); + const ipCounter = getCounter(ipCounters, ipKey); + + const sessionRemaining = Math.max(0, SESSION_LIMIT - sessionCounter.count); + const ipRemaining = Math.max(0, IP_DAILY_LIMIT - ipCounter.count); + const blocked = sessionRemaining === 0 || ipRemaining === 0; + + return { sessionRemaining, ipRemaining, blocked }; +} + +export function incrementCharacterRateLimit( + cardSlug: string, + sessionId: string, + ip: string, +): void { + const sessionKey = buildSessionKey(cardSlug, sessionId); + const ipKey = buildIpKey(cardSlug, ip); + const sessionCounter = getCounter(sessionCounters, sessionKey); + const ipCounter = getCounter(ipCounters, ipKey); + sessionCounter.count++; + ipCounter.count++; +} + +export const CHARACTER_SESSION_LIMIT = SESSION_LIMIT; +export const CHARACTER_IP_DAILY_LIMIT = IP_DAILY_LIMIT;