diff --git a/README.md b/README.md index 433a472..4f6ee63 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ server { 订阅域名套 Cloudflare 时,源站应只允许 Cloudflare 回源或通过 Cloudflare Tunnel 暴露服务,并正确传递 `CF-Connecting-IP` / `X-Forwarded-For`。否则后续订阅访问风控中的真实 IP 可能被直连源站请求伪造。 -J-Board 会记录订阅 API 的真实 IP、User-Agent 和可用的地理位置头,并在 24 小时窗口内按城市/省份变化触发风控:4 个城市警告、5 个城市暂停;2 个省/地区警告、3 个省/地区暂停。管理员可在后台“订阅风控”查看关联用户、订阅、IP、地区统计,并将事件标记为待处理、已确认或已解决,必要时可人工恢复被暂停的订阅。项目内置 `data/GeoLite2-City.mmdb` 作为本地 GeoIP 城市库,默认通过 `GEOIP_MMDB_PATH=data/GeoLite2-City.mmdb` 读取;如果反代或 CDN 已传地理位置头,系统优先使用请求头,并用 MMDB 补齐缺失字段。Cloudflare 场景建议在 Rules -> Settings -> Managed Transforms 开启 Add visitor location headers,让回源请求带上 `cf-ipcity`、`cf-region`、`cf-region-code` 等字段;未提供城市/省份字段且 MMDB 不可用时,系统只记录 IP,不会触发地区变化规则。 +J-Board 会记录订阅 API 的真实 IP、User-Agent 和可用的地理位置头,并按后台“系统设置 -> 订阅访问风控”配置执行限流、跨城市/省份告警与自动暂停。默认规则保持为:24 小时内 4 个城市警告、5 个城市暂停;2 个省/地区警告、3 个省/地区暂停;IP 180 次/小时、订阅 60 次/小时。管理员可以关闭风控总控,或关闭自动暂停改为只记录警告;也可以在后台“订阅风控”查看关联用户、订阅、IP、地区统计,并将事件标记为待处理、已确认或已解决,必要时可人工恢复被暂停的订阅。项目内置 `data/GeoLite2-City.mmdb` 作为本地 GeoIP 城市库,默认通过 `GEOIP_MMDB_PATH=data/GeoLite2-City.mmdb` 读取;如果反代或 CDN 已传地理位置头,系统优先使用请求头,并用 MMDB 补齐缺失字段。Cloudflare 场景建议在 Rules -> Settings -> Managed Transforms 开启 Add visitor location headers,让回源请求带上 `cf-ipcity`、`cf-region`、`cf-region-code` 等字段;未提供城市/省份字段且 MMDB 不可用时,系统只记录 IP,不会触发地区变化规则。 ### 手动 Docker 部署 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f1f9f8b..aafefd5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -730,35 +730,44 @@ model AuditLog { } model AppConfig { - id String @id @default("default") - siteName String @default("J-Board") - siteUrl String? - subscriptionUrl String? - allowRegistration Boolean @default(true) - emailVerificationRequired Boolean @default(false) - requireInviteCode Boolean @default(false) - supportContact String? - maintenanceNotice String? - siteNotice String? - autoReminderDispatchEnabled Boolean @default(true) - reminderDispatchIntervalMinutes Int @default(60) - trafficSyncEnabled Boolean @default(true) - trafficSyncIntervalSeconds Int @default(60) - inviteRewardCouponId String? - inviteRewardRate Decimal @default(0) @db.Decimal(5, 2) - inviteRewardEnabled Boolean @default(false) - turnstileSiteKey String? - turnstileSecretKey String? - smtpEnabled Boolean @default(false) - smtpHost String? - smtpPort Int @default(587) - smtpSecure Boolean @default(false) - smtpUser String? - smtpPassword String? - smtpFromName String? - smtpFromEmail String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default("default") + siteName String @default("J-Board") + siteUrl String? + subscriptionUrl String? + allowRegistration Boolean @default(true) + emailVerificationRequired Boolean @default(false) + requireInviteCode Boolean @default(false) + supportContact String? + maintenanceNotice String? + siteNotice String? + autoReminderDispatchEnabled Boolean @default(true) + reminderDispatchIntervalMinutes Int @default(60) + trafficSyncEnabled Boolean @default(true) + trafficSyncIntervalSeconds Int @default(60) + subscriptionRiskEnabled Boolean @default(true) + subscriptionRiskAutoSuspend Boolean @default(true) + subscriptionRiskWindowHours Int @default(24) + subscriptionRiskCityWarning Int @default(4) + subscriptionRiskCitySuspend Int @default(5) + subscriptionRiskRegionWarning Int @default(2) + subscriptionRiskRegionSuspend Int @default(3) + subscriptionRiskIpLimitPerHour Int @default(180) + subscriptionRiskTokenLimitPerHour Int @default(60) + inviteRewardCouponId String? + inviteRewardRate Decimal @default(0) @db.Decimal(5, 2) + inviteRewardEnabled Boolean @default(false) + turnstileSiteKey String? + turnstileSecretKey String? + smtpEnabled Boolean @default(false) + smtpHost String? + smtpPort Int @default(587) + smtpSecure Boolean @default(false) + smtpUser String? + smtpPassword String? + smtpFromName String? + smtpFromEmail String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model Announcement { diff --git a/src/actions/admin/settings.ts b/src/actions/admin/settings.ts index 0873508..242bfa8 100644 --- a/src/actions/admin/settings.ts +++ b/src/actions/admin/settings.ts @@ -25,6 +25,15 @@ const settingsSchema = z.object({ reminderDispatchIntervalMinutes: z.coerce.number().int().positive().optional(), trafficSyncEnabled: z.string().optional(), trafficSyncIntervalSeconds: z.coerce.number().int().min(10).optional(), + subscriptionRiskEnabled: z.string().optional(), + subscriptionRiskAutoSuspend: z.string().optional(), + subscriptionRiskWindowHours: z.coerce.number().int().min(1).max(168).optional(), + subscriptionRiskCityWarning: z.coerce.number().int().min(2).max(100).optional(), + subscriptionRiskCitySuspend: z.coerce.number().int().min(2).max(100).optional(), + subscriptionRiskRegionWarning: z.coerce.number().int().min(2).max(100).optional(), + subscriptionRiskRegionSuspend: z.coerce.number().int().min(2).max(100).optional(), + subscriptionRiskIpLimitPerHour: z.coerce.number().int().min(1).max(100000).optional(), + subscriptionRiskTokenLimitPerHour: z.coerce.number().int().min(1).max(100000).optional(), inviteRewardEnabled: z.string().optional(), inviteRewardRate: z.coerce.number().min(0).max(100).optional(), inviteRewardCouponId: z.string().trim().optional(), @@ -102,6 +111,22 @@ function buildSettingsUpdate(parsed: z.infer, current: Aw trafficSyncEnabled: parsed.trafficSyncEnabled === "true", trafficSyncIntervalSeconds: parsed.trafficSyncIntervalSeconds ?? current.trafficSyncIntervalSeconds, + subscriptionRiskEnabled: parsed.subscriptionRiskEnabled === "true", + subscriptionRiskAutoSuspend: parsed.subscriptionRiskAutoSuspend === "true", + subscriptionRiskWindowHours: + parsed.subscriptionRiskWindowHours ?? current.subscriptionRiskWindowHours, + subscriptionRiskCityWarning: + parsed.subscriptionRiskCityWarning ?? current.subscriptionRiskCityWarning, + subscriptionRiskCitySuspend: + parsed.subscriptionRiskCitySuspend ?? current.subscriptionRiskCitySuspend, + subscriptionRiskRegionWarning: + parsed.subscriptionRiskRegionWarning ?? current.subscriptionRiskRegionWarning, + subscriptionRiskRegionSuspend: + parsed.subscriptionRiskRegionSuspend ?? current.subscriptionRiskRegionSuspend, + subscriptionRiskIpLimitPerHour: + parsed.subscriptionRiskIpLimitPerHour ?? current.subscriptionRiskIpLimitPerHour, + subscriptionRiskTokenLimitPerHour: + parsed.subscriptionRiskTokenLimitPerHour ?? current.subscriptionRiskTokenLimitPerHour, inviteRewardEnabled: parsed.inviteRewardEnabled === "true", inviteRewardRate: parsed.inviteRewardRate ?? Number(current.inviteRewardRate), inviteRewardCouponId: parsed.inviteRewardCouponId || null, @@ -117,6 +142,13 @@ function buildSettingsUpdate(parsed: z.infer, current: Aw smtpFromEmail: parsed.smtpFromEmail || null, }; + if (next.subscriptionRiskCitySuspend < next.subscriptionRiskCityWarning) { + throw new Error("城市暂停阈值不能小于城市警告阈值"); + } + if (next.subscriptionRiskRegionSuspend < next.subscriptionRiskRegionWarning) { + throw new Error("省/地区暂停阈值不能小于省/地区警告阈值"); + } + if (next.smtpEnabled || next.emailVerificationRequired) { if (!next.smtpHost || !next.smtpPort || !next.smtpFromEmail) { throw new Error("启用邮件服务或注册邮箱验证前,请完整填写 SMTP 主机、端口和发件邮箱"); @@ -138,6 +170,8 @@ function revalidateSettingsViews() { revalidatePath("/admin/nodes"); revalidatePath("/account"); revalidatePath("/admin/commerce"); + revalidatePath("/admin/subscription-risk"); + revalidatePath("/admin/subscriptions"); } async function persistAppSettings( diff --git a/src/app/(admin)/admin/settings/page.tsx b/src/app/(admin)/admin/settings/page.tsx index f824193..c538fcc 100644 --- a/src/app/(admin)/admin/settings/page.tsx +++ b/src/app/(admin)/admin/settings/page.tsx @@ -36,6 +36,15 @@ export default async function AdminSettingsPage() { reminderDispatchIntervalMinutes: config.reminderDispatchIntervalMinutes, trafficSyncEnabled: config.trafficSyncEnabled, trafficSyncIntervalSeconds: config.trafficSyncIntervalSeconds, + subscriptionRiskEnabled: config.subscriptionRiskEnabled, + subscriptionRiskAutoSuspend: config.subscriptionRiskAutoSuspend, + subscriptionRiskWindowHours: config.subscriptionRiskWindowHours, + subscriptionRiskCityWarning: config.subscriptionRiskCityWarning, + subscriptionRiskCitySuspend: config.subscriptionRiskCitySuspend, + subscriptionRiskRegionWarning: config.subscriptionRiskRegionWarning, + subscriptionRiskRegionSuspend: config.subscriptionRiskRegionSuspend, + subscriptionRiskIpLimitPerHour: config.subscriptionRiskIpLimitPerHour, + subscriptionRiskTokenLimitPerHour: config.subscriptionRiskTokenLimitPerHour, inviteRewardEnabled: config.inviteRewardEnabled, inviteRewardRate: Number(config.inviteRewardRate), inviteRewardCouponId: config.inviteRewardCouponId, diff --git a/src/app/(admin)/admin/settings/settings-form.tsx b/src/app/(admin)/admin/settings/settings-form.tsx index 60c6a36..137d1bc 100644 --- a/src/app/(admin)/admin/settings/settings-form.tsx +++ b/src/app/(admin)/admin/settings/settings-form.tsx @@ -25,6 +25,15 @@ interface AppConfig { reminderDispatchIntervalMinutes: number; trafficSyncEnabled: boolean; trafficSyncIntervalSeconds: number; + subscriptionRiskEnabled: boolean; + subscriptionRiskAutoSuspend: boolean; + subscriptionRiskWindowHours: number; + subscriptionRiskCityWarning: number; + subscriptionRiskCitySuspend: number; + subscriptionRiskRegionWarning: number; + subscriptionRiskRegionSuspend: number; + subscriptionRiskIpLimitPerHour: number; + subscriptionRiskTokenLimitPerHour: number; inviteRewardEnabled: boolean; inviteRewardRate: number; inviteRewardCouponId: string | null; @@ -196,6 +205,121 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons: +
+
+ 订阅访问风控 +
+

+ 控制订阅接口限流、跨地区访问告警和自动暂停。关闭总控后仍会保留访问日志,但不会限流、告警或自动暂停。 +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

+ 默认值对应原规则:24 小时内 4 城市警告、5 城市暂停;2 省/地区警告、3 省/地区暂停;IP 180 次/小时,订阅 60 次/小时。 +

+
+
注册策略 diff --git a/src/app/api/subscription/[id]/route.ts b/src/app/api/subscription/[id]/route.ts index 130db15..e952e23 100644 --- a/src/app/api/subscription/[id]/route.ts +++ b/src/app/api/subscription/[id]/route.ts @@ -4,9 +4,8 @@ import { prisma } from "@/lib/prisma"; import { rateLimit } from "@/lib/rate-limit"; import { getClientRequestContext } from "@/lib/request-context"; import { recordSubscriptionAccess } from "@/services/subscription-risk"; +import { getAppConfig } from "@/services/app-config"; -const SUBSCRIPTION_TOKEN_LIMIT = 60; -const SUBSCRIPTION_IP_LIMIT = 180; const SUBSCRIPTION_RATE_WINDOW_SECONDS = 60 * 60; function jsonError(message: string, status: number) { @@ -22,33 +21,38 @@ export async function GET( const token = url.searchParams.get("token"); const context = getClientRequestContext(req.headers); - const sub = await prisma.userSubscription.findUnique({ - where: { id }, - select: { - id: true, - userId: true, - downloadToken: true, - status: true, - plan: { select: { type: true } }, - }, - }); + const [sub, config] = await Promise.all([ + prisma.userSubscription.findUnique({ + where: { id }, + select: { + id: true, + userId: true, + downloadToken: true, + status: true, + plan: { select: { type: true } }, + }, + }), + getAppConfig(), + ]); - const ipLimit = await rateLimit( - `ratelimit:subscription:ip:${context.ip}`, - SUBSCRIPTION_IP_LIMIT, - SUBSCRIPTION_RATE_WINDOW_SECONDS, - ); + if (config.subscriptionRiskEnabled) { + const ipLimit = await rateLimit( + `ratelimit:subscription:ip:${context.ip}`, + config.subscriptionRiskIpLimitPerHour, + SUBSCRIPTION_RATE_WINDOW_SECONDS, + ); - if (!ipLimit.success) { - await recordSubscriptionAccess({ - kind: "SINGLE", - context, - userId: sub?.userId, - subscriptionId: sub?.id ?? id, - allowed: false, - reason: "rate_limited", - }); - return jsonError("Too many subscription requests", 429); + if (!ipLimit.success) { + await recordSubscriptionAccess({ + kind: "SINGLE", + context, + userId: sub?.userId, + subscriptionId: sub?.id ?? id, + allowed: false, + reason: "rate_limited", + }); + return jsonError("Too many subscription requests", 429); + } } if (!token) { @@ -75,22 +79,24 @@ export async function GET( return jsonError("Invalid token", 401); } - const tokenLimit = await rateLimit( - `ratelimit:subscription:single:${sub.id}`, - SUBSCRIPTION_TOKEN_LIMIT, - SUBSCRIPTION_RATE_WINDOW_SECONDS, - ); + if (config.subscriptionRiskEnabled) { + const tokenLimit = await rateLimit( + `ratelimit:subscription:single:${sub.id}`, + config.subscriptionRiskTokenLimitPerHour, + SUBSCRIPTION_RATE_WINDOW_SECONDS, + ); - if (!tokenLimit.success) { - await recordSubscriptionAccess({ - kind: "SINGLE", - context, - userId: sub.userId, - subscriptionId: sub.id, - allowed: false, - reason: "rate_limited", - }); - return jsonError("Too many subscription requests", 429); + if (!tokenLimit.success) { + await recordSubscriptionAccess({ + kind: "SINGLE", + context, + userId: sub.userId, + subscriptionId: sub.id, + allowed: false, + reason: "rate_limited", + }); + return jsonError("Too many subscription requests", 429); + } } if (sub.status !== "ACTIVE") { @@ -110,7 +116,8 @@ export async function GET( context, userId: sub.userId, subscriptionId: sub.id, - evaluateRisk: sub.plan.type === "PROXY", + evaluateRisk: config.subscriptionRiskEnabled && sub.plan.type === "PROXY", + riskConfig: config, }); if (risk.suspended) { diff --git a/src/app/api/subscription/all/route.ts b/src/app/api/subscription/all/route.ts index 0895fd3..fe9050d 100644 --- a/src/app/api/subscription/all/route.ts +++ b/src/app/api/subscription/all/route.ts @@ -7,9 +7,8 @@ import { prisma } from "@/lib/prisma"; import { rateLimit } from "@/lib/rate-limit"; import { getClientRequestContext } from "@/lib/request-context"; import { recordSubscriptionAccess } from "@/services/subscription-risk"; +import { getAppConfig } from "@/services/app-config"; -const AGGREGATE_TOKEN_LIMIT = 60; -const SUBSCRIPTION_IP_LIMIT = 180; const SUBSCRIPTION_RATE_WINDOW_SECONDS = 60 * 60; function jsonError(message: string, status: number) { @@ -21,22 +20,25 @@ export async function GET(req: Request) { const userId = url.searchParams.get("userId") ?? url.searchParams.get("user"); const token = url.searchParams.get("token"); const context = getClientRequestContext(req.headers); + const config = await getAppConfig(); - const ipLimit = await rateLimit( - `ratelimit:subscription:ip:${context.ip}`, - SUBSCRIPTION_IP_LIMIT, - SUBSCRIPTION_RATE_WINDOW_SECONDS, - ); + if (config.subscriptionRiskEnabled) { + const ipLimit = await rateLimit( + `ratelimit:subscription:ip:${context.ip}`, + config.subscriptionRiskIpLimitPerHour, + SUBSCRIPTION_RATE_WINDOW_SECONDS, + ); - if (!ipLimit.success) { - await recordSubscriptionAccess({ - kind: "AGGREGATE", - context, - userId, - allowed: false, - reason: "rate_limited", - }); - return jsonError("Too many subscription requests", 429); + if (!ipLimit.success) { + await recordSubscriptionAccess({ + kind: "AGGREGATE", + context, + userId, + allowed: false, + reason: "rate_limited", + }); + return jsonError("Too many subscription requests", 429); + } } if (!userId || !token) { @@ -66,21 +68,23 @@ export async function GET(req: Request) { return jsonError("Invalid subscription token", 401); } - const tokenLimit = await rateLimit( - `ratelimit:subscription:aggregate:${userId}`, - AGGREGATE_TOKEN_LIMIT, - SUBSCRIPTION_RATE_WINDOW_SECONDS, - ); + if (config.subscriptionRiskEnabled) { + const tokenLimit = await rateLimit( + `ratelimit:subscription:aggregate:${userId}`, + config.subscriptionRiskTokenLimitPerHour, + SUBSCRIPTION_RATE_WINDOW_SECONDS, + ); - if (!tokenLimit.success) { - await recordSubscriptionAccess({ - kind: "AGGREGATE", - context, - userId, - allowed: false, - reason: "rate_limited", - }); - return jsonError("Too many subscription requests", 429); + if (!tokenLimit.success) { + await recordSubscriptionAccess({ + kind: "AGGREGATE", + context, + userId, + allowed: false, + reason: "rate_limited", + }); + return jsonError("Too many subscription requests", 429); + } } if (user.status !== "ACTIVE") { @@ -98,6 +102,8 @@ export async function GET(req: Request) { kind: "AGGREGATE", context, userId, + evaluateRisk: config.subscriptionRiskEnabled, + riskConfig: config, }); if (risk.suspended) { diff --git a/src/services/subscription-risk.ts b/src/services/subscription-risk.ts index 11e04eb..249c5ae 100644 --- a/src/services/subscription-risk.ts +++ b/src/services/subscription-risk.ts @@ -1,5 +1,6 @@ import { revalidatePath } from "next/cache"; import type { + AppConfig, Prisma, SubscriptionAccessKind, SubscriptionRiskLevel, @@ -10,12 +11,18 @@ import type { ClientRequestContext } from "@/lib/request-context"; import { recordAuditLog } from "@/services/audit"; import { createNotification } from "@/services/notifications"; import { createPanelAdapter } from "@/services/node-panel/factory"; +import { getAppConfig } from "@/services/app-config"; -const RISK_WINDOW_HOURS = 24; -const CITY_WARNING_COUNT = 4; -const CITY_SUSPEND_COUNT = 5; -const REGION_WARNING_COUNT = 2; -const REGION_SUSPEND_COUNT = 3; +type SubscriptionRiskConfig = Pick< + AppConfig, + | "subscriptionRiskEnabled" + | "subscriptionRiskAutoSuspend" + | "subscriptionRiskWindowHours" + | "subscriptionRiskCityWarning" + | "subscriptionRiskCitySuspend" + | "subscriptionRiskRegionWarning" + | "subscriptionRiskRegionSuspend" +>; interface RecordSubscriptionAccessInput { kind: SubscriptionAccessKind; @@ -25,6 +32,7 @@ interface RecordSubscriptionAccessInput { allowed?: boolean; reason?: string | null; evaluateRisk?: boolean; + riskConfig?: SubscriptionRiskConfig; } interface RiskDecision { @@ -38,6 +46,14 @@ interface RiskEvaluationResult { eventId?: string; } +interface RiskThresholds { + cityWarning: number; + citySuspend: number; + regionWarning: number; + regionSuspend: number; + autoSuspend: boolean; +} + function normalizeLocationPart(value: string | null | undefined) { return value?.trim().toLowerCase() || null; } @@ -90,17 +106,17 @@ function riskMessage(options: { return `${scope}访问地区异常,24 小时内出现 ${locationSummary},最近 IP ${options.ip},已记录警告。`; } -function decideRisk(cityCount: number, regionCount: number): RiskDecision | null { - if (regionCount >= REGION_SUSPEND_COUNT) { +function decideRisk(cityCount: number, regionCount: number, thresholds: RiskThresholds): RiskDecision | null { + if (thresholds.autoSuspend && regionCount >= thresholds.regionSuspend) { return { level: "SUSPENDED", reason: "REGION_VARIANCE_SUSPEND" }; } - if (cityCount >= CITY_SUSPEND_COUNT) { + if (thresholds.autoSuspend && cityCount >= thresholds.citySuspend) { return { level: "SUSPENDED", reason: "CITY_VARIANCE_SUSPEND" }; } - if (regionCount >= REGION_WARNING_COUNT) { + if (regionCount >= thresholds.regionWarning) { return { level: "WARNING", reason: "REGION_VARIANCE_WARNING" }; } - if (cityCount >= CITY_WARNING_COUNT) { + if (cityCount >= thresholds.cityWarning) { return { level: "WARNING", reason: "CITY_VARIANCE_WARNING" }; } @@ -142,6 +158,7 @@ async function getTargetLabel(input: { userId?: string | null; subscriptionId?: function revalidateRiskViews(subscriptionIds: string[] = []) { revalidatePath("/admin/audit-logs"); + revalidatePath("/admin/subscription-risk"); revalidatePath("/admin/subscriptions"); revalidatePath("/subscriptions"); revalidatePath("/dashboard"); @@ -323,11 +340,22 @@ async function evaluateSubscriptionRisk(input: { subscriptionId?: string | null; ip: string; db: DbClient; + config?: SubscriptionRiskConfig; }): Promise { if (!input.userId) return { warned: false, suspended: false }; if (input.kind === "SINGLE" && !input.subscriptionId) return { warned: false, suspended: false }; - const windowStartedAt = new Date(Date.now() - RISK_WINDOW_HOURS * 60 * 60 * 1000); + const config = input.config ?? await getAppConfig(input.db); + if (!config.subscriptionRiskEnabled) return { warned: false, suspended: false }; + + const thresholds: RiskThresholds = { + cityWarning: config.subscriptionRiskCityWarning, + citySuspend: config.subscriptionRiskCitySuspend, + regionWarning: config.subscriptionRiskRegionWarning, + regionSuspend: config.subscriptionRiskRegionSuspend, + autoSuspend: config.subscriptionRiskAutoSuspend, + }; + const windowStartedAt = new Date(Date.now() - config.subscriptionRiskWindowHours * 60 * 60 * 1000); const logs = await input.db.subscriptionAccessLog.findMany({ where: { allowed: true, @@ -371,7 +399,7 @@ async function evaluateSubscriptionRisk(input: { const countryLabels = Array.from(countryMap.values()); const regionLabels = Array.from(regionMap.values()); const cityLabels = Array.from(cityMap.values()); - const decision = decideRisk(cityLabels.length, regionLabels.length); + const decision = decideRisk(cityLabels.length, regionLabels.length, thresholds); if (!decision) return { warned: false, suspended: false }; const message = riskMessage({ @@ -479,5 +507,6 @@ export async function recordSubscriptionAccess( subscriptionId: input.subscriptionId, ip: input.context.ip, db, + config: input.riskConfig, }); }