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 可能被直连源站请求伪造。
|
订阅域名套 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 部署
|
||||||
|
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 "国家异常暂停";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 "国家异常暂停";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user