feat: add country-level subscription risk controls

This commit is contained in:
JetSprow
2026-04-29 15:40:50 +10:00
parent 40ca3e27ad
commit 086934198a
8 changed files with 81 additions and 11 deletions

View File

@@ -210,7 +210,7 @@ server {
订阅域名套 Cloudflare 时,源站应只允许 Cloudflare 回源或通过 Cloudflare Tunnel 暴露服务,并正确传递 `CF-Connecting-IP` / `X-Forwarded-For`。否则后续订阅访问风控中的真实 IP 可能被直连源站请求伪造。 订阅域名套 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 部署 ### 手动 Docker 部署

View File

@@ -50,6 +50,8 @@ enum SubscriptionRiskReason {
CITY_VARIANCE_SUSPEND CITY_VARIANCE_SUSPEND
REGION_VARIANCE_WARNING REGION_VARIANCE_WARNING
REGION_VARIANCE_SUSPEND REGION_VARIANCE_SUSPEND
COUNTRY_VARIANCE_WARNING
COUNTRY_VARIANCE_SUSPEND
} }
enum SubscriptionRiskReviewStatus { enum SubscriptionRiskReviewStatus {
@@ -751,6 +753,8 @@ model AppConfig {
subscriptionRiskCitySuspend Int @default(5) subscriptionRiskCitySuspend Int @default(5)
subscriptionRiskRegionWarning Int @default(2) subscriptionRiskRegionWarning Int @default(2)
subscriptionRiskRegionSuspend Int @default(3) subscriptionRiskRegionSuspend Int @default(3)
subscriptionRiskCountryWarning Int @default(2)
subscriptionRiskCountrySuspend Int @default(3)
subscriptionRiskIpLimitPerHour Int @default(180) subscriptionRiskIpLimitPerHour Int @default(180)
subscriptionRiskTokenLimitPerHour Int @default(60) subscriptionRiskTokenLimitPerHour Int @default(60)
inviteRewardCouponId String? inviteRewardCouponId String?

View File

@@ -33,6 +33,8 @@ const settingsSchema = z.object({
subscriptionRiskCitySuspend: 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(), subscriptionRiskRegionWarning: z.coerce.number().int().min(2).max(100).optional(),
subscriptionRiskRegionSuspend: 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(), subscriptionRiskIpLimitPerHour: z.coerce.number().int().min(1).max(100000).optional(),
subscriptionRiskTokenLimitPerHour: z.coerce.number().int().min(1).max(100000).optional(), subscriptionRiskTokenLimitPerHour: z.coerce.number().int().min(1).max(100000).optional(),
inviteRewardEnabled: z.string().optional(), inviteRewardEnabled: z.string().optional(),
@@ -119,6 +121,10 @@ function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Aw
parsed.subscriptionRiskRegionWarning ?? current.subscriptionRiskRegionWarning, parsed.subscriptionRiskRegionWarning ?? current.subscriptionRiskRegionWarning,
subscriptionRiskRegionSuspend: subscriptionRiskRegionSuspend:
parsed.subscriptionRiskRegionSuspend ?? current.subscriptionRiskRegionSuspend, parsed.subscriptionRiskRegionSuspend ?? current.subscriptionRiskRegionSuspend,
subscriptionRiskCountryWarning:
parsed.subscriptionRiskCountryWarning ?? current.subscriptionRiskCountryWarning,
subscriptionRiskCountrySuspend:
parsed.subscriptionRiskCountrySuspend ?? current.subscriptionRiskCountrySuspend,
subscriptionRiskIpLimitPerHour: subscriptionRiskIpLimitPerHour:
parsed.subscriptionRiskIpLimitPerHour ?? current.subscriptionRiskIpLimitPerHour, parsed.subscriptionRiskIpLimitPerHour ?? current.subscriptionRiskIpLimitPerHour,
subscriptionRiskTokenLimitPerHour: subscriptionRiskTokenLimitPerHour:
@@ -144,6 +150,9 @@ function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Aw
if (next.subscriptionRiskRegionSuspend < next.subscriptionRiskRegionWarning) { if (next.subscriptionRiskRegionSuspend < next.subscriptionRiskRegionWarning) {
throw new Error("省/地区暂停阈值不能小于省/地区警告阈值"); throw new Error("省/地区暂停阈值不能小于省/地区警告阈值");
} }
if (next.subscriptionRiskCountrySuspend < next.subscriptionRiskCountryWarning) {
throw new Error("国家暂停阈值不能小于国家警告阈值");
}
if (next.smtpEnabled || next.emailVerificationRequired) { if (next.smtpEnabled || next.emailVerificationRequired) {
if (!next.smtpHost || !next.smtpPort || !next.smtpFromEmail) { if (!next.smtpHost || !next.smtpPort || !next.smtpFromEmail) {

View File

@@ -43,6 +43,8 @@ export default async function AdminSettingsPage() {
subscriptionRiskCitySuspend: config.subscriptionRiskCitySuspend, subscriptionRiskCitySuspend: config.subscriptionRiskCitySuspend,
subscriptionRiskRegionWarning: config.subscriptionRiskRegionWarning, subscriptionRiskRegionWarning: config.subscriptionRiskRegionWarning,
subscriptionRiskRegionSuspend: config.subscriptionRiskRegionSuspend, subscriptionRiskRegionSuspend: config.subscriptionRiskRegionSuspend,
subscriptionRiskCountryWarning: config.subscriptionRiskCountryWarning,
subscriptionRiskCountrySuspend: config.subscriptionRiskCountrySuspend,
subscriptionRiskIpLimitPerHour: config.subscriptionRiskIpLimitPerHour, subscriptionRiskIpLimitPerHour: config.subscriptionRiskIpLimitPerHour,
subscriptionRiskTokenLimitPerHour: config.subscriptionRiskTokenLimitPerHour, subscriptionRiskTokenLimitPerHour: config.subscriptionRiskTokenLimitPerHour,
inviteRewardEnabled: config.inviteRewardEnabled, inviteRewardEnabled: config.inviteRewardEnabled,

View File

@@ -32,6 +32,8 @@ interface AppConfig {
subscriptionRiskCitySuspend: number; subscriptionRiskCitySuspend: number;
subscriptionRiskRegionWarning: number; subscriptionRiskRegionWarning: number;
subscriptionRiskRegionSuspend: number; subscriptionRiskRegionSuspend: number;
subscriptionRiskCountryWarning: number;
subscriptionRiskCountrySuspend: number;
subscriptionRiskIpLimitPerHour: number; subscriptionRiskIpLimitPerHour: number;
subscriptionRiskTokenLimitPerHour: number; subscriptionRiskTokenLimitPerHour: number;
inviteRewardEnabled: boolean; inviteRewardEnabled: boolean;
@@ -292,6 +294,28 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
defaultValue={config.subscriptionRiskRegionSuspend} defaultValue={config.subscriptionRiskRegionSuspend}
/> />
</div> </div>
<div className="space-y-2">
<Label htmlFor="subscriptionRiskCountryWarning"></Label>
<Input
id="subscriptionRiskCountryWarning"
name="subscriptionRiskCountryWarning"
type="number"
min={2}
max={100}
defaultValue={config.subscriptionRiskCountryWarning}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subscriptionRiskCountrySuspend"></Label>
<Input
id="subscriptionRiskCountrySuspend"
name="subscriptionRiskCountrySuspend"
type="number"
min={2}
max={100}
defaultValue={config.subscriptionRiskCountrySuspend}
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="subscriptionRiskIpLimitPerHour">IP /</Label> <Label htmlFor="subscriptionRiskIpLimitPerHour">IP /</Label>
<Input <Input
@@ -316,7 +340,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
</div> </div>
<p className="text-xs leading-5 text-muted-foreground"> <p className="text-xs leading-5 text-muted-foreground">
24 4 5 2 /3 /IP 180 / 60 / 24 4 5 2 /3 /2 3 IP 180 / 60 /
</p> </p>
</section> </section>

View File

@@ -34,6 +34,10 @@ function reasonLabel(reason: SubscriptionRiskEvent["reason"]) {
return "省/地区异常警告"; return "省/地区异常警告";
case "REGION_VARIANCE_SUSPEND": case "REGION_VARIANCE_SUSPEND":
return "省/地区异常暂停"; return "省/地区异常暂停";
case "COUNTRY_VARIANCE_WARNING":
return "国家异常警告";
case "COUNTRY_VARIANCE_SUSPEND":
return "国家异常暂停";
} }
} }

View File

@@ -63,6 +63,10 @@ function reasonLabel(reason: SubscriptionRiskEvent["reason"]) {
return "省/地区异常警告"; return "省/地区异常警告";
case "REGION_VARIANCE_SUSPEND": case "REGION_VARIANCE_SUSPEND":
return "省/地区异常暂停"; return "省/地区异常暂停";
case "COUNTRY_VARIANCE_WARNING":
return "国家异常警告";
case "COUNTRY_VARIANCE_SUSPEND":
return "国家异常暂停";
} }
} }

View File

@@ -22,6 +22,8 @@ type SubscriptionRiskConfig = Pick<
| "subscriptionRiskCitySuspend" | "subscriptionRiskCitySuspend"
| "subscriptionRiskRegionWarning" | "subscriptionRiskRegionWarning"
| "subscriptionRiskRegionSuspend" | "subscriptionRiskRegionSuspend"
| "subscriptionRiskCountryWarning"
| "subscriptionRiskCountrySuspend"
>; >;
interface RecordSubscriptionAccessInput { interface RecordSubscriptionAccessInput {
@@ -51,6 +53,8 @@ interface RiskThresholds {
citySuspend: number; citySuspend: number;
regionWarning: number; regionWarning: number;
regionSuspend: number; regionSuspend: number;
countryWarning: number;
countrySuspend: number;
autoSuspend: boolean; autoSuspend: boolean;
} }
@@ -89,13 +93,17 @@ function riskMessage(options: {
decision: RiskDecision; decision: RiskDecision;
kind: SubscriptionAccessKind; kind: SubscriptionAccessKind;
ip: string; ip: string;
cityCount: number; countryCount: number;
regionCount: number; regionCount: number;
cityLabels: string[]; cityCount: number;
countryLabels: string[];
regionLabels: string[]; regionLabels: string[];
cityLabels: string[];
}) { }) {
const scope = getScopeLabel(options.kind); const scope = getScopeLabel(options.kind);
const locationSummary = options.decision.reason.startsWith("REGION") const locationSummary = options.decision.reason.startsWith("COUNTRY")
? `${options.countryCount} 个国家/地区:${formatKeyPreview(options.countryLabels)}`
: options.decision.reason.startsWith("REGION")
? `${options.regionCount} 个省/地区:${formatKeyPreview(options.regionLabels)}` ? `${options.regionCount} 个省/地区:${formatKeyPreview(options.regionLabels)}`
: `${options.cityCount} 个城市:${formatKeyPreview(options.cityLabels)}`; : `${options.cityCount} 个城市:${formatKeyPreview(options.cityLabels)}`;
@@ -106,13 +114,24 @@ function riskMessage(options: {
return `${scope}访问地区异常24 小时内出现 ${locationSummary},最近 IP ${options.ip},已记录警告。`; 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) { if (thresholds.autoSuspend && regionCount >= thresholds.regionSuspend) {
return { level: "SUSPENDED", reason: "REGION_VARIANCE_SUSPEND" }; return { level: "SUSPENDED", reason: "REGION_VARIANCE_SUSPEND" };
} }
if (thresholds.autoSuspend && cityCount >= thresholds.citySuspend) { if (thresholds.autoSuspend && cityCount >= thresholds.citySuspend) {
return { level: "SUSPENDED", reason: "CITY_VARIANCE_SUSPEND" }; return { level: "SUSPENDED", reason: "CITY_VARIANCE_SUSPEND" };
} }
if (countryCount >= thresholds.countryWarning) {
return { level: "WARNING", reason: "COUNTRY_VARIANCE_WARNING" };
}
if (regionCount >= thresholds.regionWarning) { if (regionCount >= thresholds.regionWarning) {
return { level: "WARNING", reason: "REGION_VARIANCE_WARNING" }; return { level: "WARNING", reason: "REGION_VARIANCE_WARNING" };
} }
@@ -353,6 +372,8 @@ async function evaluateSubscriptionRisk(input: {
citySuspend: config.subscriptionRiskCitySuspend, citySuspend: config.subscriptionRiskCitySuspend,
regionWarning: config.subscriptionRiskRegionWarning, regionWarning: config.subscriptionRiskRegionWarning,
regionSuspend: config.subscriptionRiskRegionSuspend, regionSuspend: config.subscriptionRiskRegionSuspend,
countryWarning: config.subscriptionRiskCountryWarning,
countrySuspend: config.subscriptionRiskCountrySuspend,
autoSuspend: config.subscriptionRiskAutoSuspend, autoSuspend: config.subscriptionRiskAutoSuspend,
}; };
const windowStartedAt = new Date(Date.now() - config.subscriptionRiskWindowHours * 60 * 60 * 1000); 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 countryLabels = Array.from(countryMap.values());
const regionLabels = Array.from(regionMap.values()); const regionLabels = Array.from(regionMap.values());
const cityLabels = Array.from(cityMap.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 }; if (!decision) return { warned: false, suspended: false };
const message = riskMessage({ const message = riskMessage({
decision, decision,
kind: input.kind, kind: input.kind,
ip: input.ip, ip: input.ip,
cityCount: cityLabels.length, countryCount: countryLabels.length,
regionCount: regionLabels.length, regionCount: regionLabels.length,
cityLabels, cityCount: cityLabels.length,
countryLabels,
regionLabels, regionLabels,
cityLabels,
}); });
const { event, created } = await createRiskEvent({ const { event, created } = await createRiskEvent({