Browse Source

feat: 为角色卡聊天添加访客限流(每会话 20 条 / 每 IP 每日 50 条)

feat/character-card
npmrun 2 months ago
parent
commit
fb7e08399e
  1. 7
      server/api/characters/[slug]/chat/index.post.ts
  2. 38
      server/service/character/chat-engine.ts
  3. 80
      server/service/character/rate-limit.ts

7
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);

38
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<string, string>) => void;
requestId?: string;
onClientClose?: (cb: () => void) => void;
}
@ -47,10 +50,32 @@ function truncateFirstLine(text: string): string {
export async function executeCharacterChat(
params: CharacterChatEngineParams,
): Promise<CharacterChatEngineResult> {
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));

80
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<string, RateCounter>();
const ipCounters = new Map<string, RateCounter>();
function getTodayStr(): string {
return new Date().toISOString().slice(0, 10);
}
function getCounter(map: Map<string, RateCounter>, 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;
Loading…
Cancel
Save