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

@@ -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) {

View File

@@ -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,

View File

@@ -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>

View File

@@ -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 "国家异常暂停";
}
}

View File

@@ -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 "国家异常暂停";
}
}

View File

@@ -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({