mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: add country-level subscription risk controls
This commit is contained in:
@@ -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 部署
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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<typeof settingsSchema>, 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<typeof settingsSchema>, 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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</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">
|
||||
<Label htmlFor="subscriptionRiskIpLimitPerHour">IP 限流(次/小时)</Label>
|
||||
<Input
|
||||
@@ -316,7 +340,7 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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 "国家异常暂停";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 "国家异常暂停";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,13 +93,17 @@ 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")
|
||||
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)}`;
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user