From 086934198a785252401aac78fb649b6b551aad04 Mon Sep 17 00:00:00 2001 From: JetSprow Date: Wed, 29 Apr 2026 15:40:50 +1000 Subject: [PATCH] feat: add country-level subscription risk controls --- README.md | 2 +- prisma/schema.prisma | 4 ++ src/actions/admin/settings.ts | 9 ++++ src/app/(admin)/admin/settings/page.tsx | 2 + .../(admin)/admin/settings/settings-form.tsx | 26 +++++++++++- .../_components/subscription-risk-table.tsx | 4 ++ .../subscription-access-risk-section.tsx | 4 ++ src/services/subscription-risk.ts | 41 +++++++++++++++---- 8 files changed, 81 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4f6ee63..7bf5416 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 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,不会触发地区变化规则。 +J-Board 会记录订阅 API 的真实 IP、User-Agent 和可用的地理位置头,并按后台“系统设置 -> 订阅访问风控”配置执行限流、跨城市/省份告警与自动暂停。默认规则保持为:24 小时内 4 个城市警告、5 个城市暂停;2 个省/地区警告、3 个省/地区暂停;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 d880b71..5c8a7a7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -50,6 +50,8 @@ enum SubscriptionRiskReason { CITY_VARIANCE_SUSPEND REGION_VARIANCE_WARNING REGION_VARIANCE_SUSPEND + COUNTRY_VARIANCE_WARNING + COUNTRY_VARIANCE_SUSPEND } enum SubscriptionRiskReviewStatus { @@ -751,6 +753,8 @@ model AppConfig { subscriptionRiskCitySuspend Int @default(5) subscriptionRiskRegionWarning Int @default(2) subscriptionRiskRegionSuspend Int @default(3) + subscriptionRiskCountryWarning Int @default(2) + subscriptionRiskCountrySuspend Int @default(3) subscriptionRiskIpLimitPerHour Int @default(180) subscriptionRiskTokenLimitPerHour Int @default(60) inviteRewardCouponId String? diff --git a/src/actions/admin/settings.ts b/src/actions/admin/settings.ts index 9970e17..42d1adc 100644 --- a/src/actions/admin/settings.ts +++ b/src/actions/admin/settings.ts @@ -33,6 +33,8 @@ const settingsSchema = z.object({ 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(), + subscriptionRiskCountryWarning: z.coerce.number().int().min(2).max(100).optional(), + subscriptionRiskCountrySuspend: 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(), @@ -119,6 +121,10 @@ function buildSettingsUpdate(parsed: z.infer, current: Aw parsed.subscriptionRiskRegionWarning ?? current.subscriptionRiskRegionWarning, subscriptionRiskRegionSuspend: parsed.subscriptionRiskRegionSuspend ?? current.subscriptionRiskRegionSuspend, + subscriptionRiskCountryWarning: + parsed.subscriptionRiskCountryWarning ?? current.subscriptionRiskCountryWarning, + subscriptionRiskCountrySuspend: + parsed.subscriptionRiskCountrySuspend ?? current.subscriptionRiskCountrySuspend, subscriptionRiskIpLimitPerHour: parsed.subscriptionRiskIpLimitPerHour ?? current.subscriptionRiskIpLimitPerHour, subscriptionRiskTokenLimitPerHour: @@ -144,6 +150,9 @@ function buildSettingsUpdate(parsed: z.infer, current: Aw if (next.subscriptionRiskRegionSuspend < next.subscriptionRiskRegionWarning) { throw new Error("省/地区暂停阈值不能小于省/地区警告阈值"); } + if (next.subscriptionRiskCountrySuspend < next.subscriptionRiskCountryWarning) { + throw new Error("国家暂停阈值不能小于国家警告阈值"); + } if (next.smtpEnabled || next.emailVerificationRequired) { if (!next.smtpHost || !next.smtpPort || !next.smtpFromEmail) { diff --git a/src/app/(admin)/admin/settings/page.tsx b/src/app/(admin)/admin/settings/page.tsx index c538fcc..7589da6 100644 --- a/src/app/(admin)/admin/settings/page.tsx +++ b/src/app/(admin)/admin/settings/page.tsx @@ -43,6 +43,8 @@ export default async function AdminSettingsPage() { subscriptionRiskCitySuspend: config.subscriptionRiskCitySuspend, subscriptionRiskRegionWarning: config.subscriptionRiskRegionWarning, subscriptionRiskRegionSuspend: config.subscriptionRiskRegionSuspend, + subscriptionRiskCountryWarning: config.subscriptionRiskCountryWarning, + subscriptionRiskCountrySuspend: config.subscriptionRiskCountrySuspend, subscriptionRiskIpLimitPerHour: config.subscriptionRiskIpLimitPerHour, subscriptionRiskTokenLimitPerHour: config.subscriptionRiskTokenLimitPerHour, inviteRewardEnabled: config.inviteRewardEnabled, diff --git a/src/app/(admin)/admin/settings/settings-form.tsx b/src/app/(admin)/admin/settings/settings-form.tsx index 8c3456a..302d1b4 100644 --- a/src/app/(admin)/admin/settings/settings-form.tsx +++ b/src/app/(admin)/admin/settings/settings-form.tsx @@ -32,6 +32,8 @@ interface AppConfig { subscriptionRiskCitySuspend: number; subscriptionRiskRegionWarning: number; subscriptionRiskRegionSuspend: number; + subscriptionRiskCountryWarning: number; + subscriptionRiskCountrySuspend: number; subscriptionRiskIpLimitPerHour: number; subscriptionRiskTokenLimitPerHour: number; inviteRewardEnabled: boolean; @@ -292,6 +294,28 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons: defaultValue={config.subscriptionRiskRegionSuspend} /> +
+ + +
+
+ + +

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

diff --git a/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-table.tsx b/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-table.tsx index 6a72718..9060d64 100644 --- a/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-table.tsx +++ b/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-table.tsx @@ -34,6 +34,10 @@ function reasonLabel(reason: SubscriptionRiskEvent["reason"]) { return "省/地区异常警告"; case "REGION_VARIANCE_SUSPEND": return "省/地区异常暂停"; + case "COUNTRY_VARIANCE_WARNING": + return "国家异常警告"; + case "COUNTRY_VARIANCE_SUSPEND": + return "国家异常暂停"; } } diff --git a/src/app/(admin)/admin/subscriptions/[id]/_components/subscription-access-risk-section.tsx b/src/app/(admin)/admin/subscriptions/[id]/_components/subscription-access-risk-section.tsx index c0f6ee1..d417903 100644 --- a/src/app/(admin)/admin/subscriptions/[id]/_components/subscription-access-risk-section.tsx +++ b/src/app/(admin)/admin/subscriptions/[id]/_components/subscription-access-risk-section.tsx @@ -63,6 +63,10 @@ function reasonLabel(reason: SubscriptionRiskEvent["reason"]) { return "省/地区异常警告"; case "REGION_VARIANCE_SUSPEND": return "省/地区异常暂停"; + case "COUNTRY_VARIANCE_WARNING": + return "国家异常警告"; + case "COUNTRY_VARIANCE_SUSPEND": + return "国家异常暂停"; } } diff --git a/src/services/subscription-risk.ts b/src/services/subscription-risk.ts index 249c5ae..baac9fc 100644 --- a/src/services/subscription-risk.ts +++ b/src/services/subscription-risk.ts @@ -22,6 +22,8 @@ type SubscriptionRiskConfig = Pick< | "subscriptionRiskCitySuspend" | "subscriptionRiskRegionWarning" | "subscriptionRiskRegionSuspend" + | "subscriptionRiskCountryWarning" + | "subscriptionRiskCountrySuspend" >; interface RecordSubscriptionAccessInput { @@ -51,6 +53,8 @@ interface RiskThresholds { citySuspend: number; regionWarning: number; regionSuspend: number; + countryWarning: number; + countrySuspend: number; autoSuspend: boolean; } @@ -89,15 +93,19 @@ function riskMessage(options: { decision: RiskDecision; kind: SubscriptionAccessKind; ip: string; - cityCount: number; + countryCount: number; regionCount: number; - cityLabels: string[]; + cityCount: number; + countryLabels: string[]; regionLabels: string[]; + cityLabels: string[]; }) { const scope = getScopeLabel(options.kind); - const locationSummary = options.decision.reason.startsWith("REGION") - ? `${options.regionCount} 个省/地区:${formatKeyPreview(options.regionLabels)}` - : `${options.cityCount} 个城市:${formatKeyPreview(options.cityLabels)}`; + const locationSummary = options.decision.reason.startsWith("COUNTRY") + ? `${options.countryCount} 个国家/地区:${formatKeyPreview(options.countryLabels)}` + : options.decision.reason.startsWith("REGION") + ? `${options.regionCount} 个省/地区:${formatKeyPreview(options.regionLabels)}` + : `${options.cityCount} 个城市:${formatKeyPreview(options.cityLabels)}`; if (options.decision.level === "SUSPENDED") { return `${scope}访问地区异常,24 小时内出现 ${locationSummary},最近 IP ${options.ip},已自动暂停。`; @@ -106,13 +114,24 @@ function riskMessage(options: { return `${scope}访问地区异常,24 小时内出现 ${locationSummary},最近 IP ${options.ip},已记录警告。`; } -function decideRisk(cityCount: number, regionCount: number, thresholds: RiskThresholds): RiskDecision | null { +function decideRisk( + countryCount: number, + regionCount: number, + cityCount: number, + thresholds: RiskThresholds, +): RiskDecision | null { + if (thresholds.autoSuspend && countryCount >= thresholds.countrySuspend) { + return { level: "SUSPENDED", reason: "COUNTRY_VARIANCE_SUSPEND" }; + } if (thresholds.autoSuspend && regionCount >= thresholds.regionSuspend) { return { level: "SUSPENDED", reason: "REGION_VARIANCE_SUSPEND" }; } if (thresholds.autoSuspend && cityCount >= thresholds.citySuspend) { return { level: "SUSPENDED", reason: "CITY_VARIANCE_SUSPEND" }; } + if (countryCount >= thresholds.countryWarning) { + return { level: "WARNING", reason: "COUNTRY_VARIANCE_WARNING" }; + } if (regionCount >= thresholds.regionWarning) { return { level: "WARNING", reason: "REGION_VARIANCE_WARNING" }; } @@ -353,6 +372,8 @@ async function evaluateSubscriptionRisk(input: { citySuspend: config.subscriptionRiskCitySuspend, regionWarning: config.subscriptionRiskRegionWarning, regionSuspend: config.subscriptionRiskRegionSuspend, + countryWarning: config.subscriptionRiskCountryWarning, + countrySuspend: config.subscriptionRiskCountrySuspend, autoSuspend: config.subscriptionRiskAutoSuspend, }; const windowStartedAt = new Date(Date.now() - config.subscriptionRiskWindowHours * 60 * 60 * 1000); @@ -399,17 +420,19 @@ 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, thresholds); + const decision = decideRisk(countryLabels.length, regionLabels.length, cityLabels.length, thresholds); if (!decision) return { warned: false, suspended: false }; const message = riskMessage({ decision, kind: input.kind, ip: input.ip, - cityCount: cityLabels.length, + countryCount: countryLabels.length, regionCount: regionLabels.length, - cityLabels, + cityCount: cityLabels.length, + countryLabels, regionLabels, + cityLabels, }); const { event, created } = await createRiskEvent({