feat: make subscription risk controls configurable

This commit is contained in:
JetSprow
2026-04-29 15:27:16 +10:00
parent 46ce257b0b
commit ff15606d92
8 changed files with 333 additions and 115 deletions

View File

@@ -36,6 +36,15 @@ export default async function AdminSettingsPage() {
reminderDispatchIntervalMinutes: config.reminderDispatchIntervalMinutes,
trafficSyncEnabled: config.trafficSyncEnabled,
trafficSyncIntervalSeconds: config.trafficSyncIntervalSeconds,
subscriptionRiskEnabled: config.subscriptionRiskEnabled,
subscriptionRiskAutoSuspend: config.subscriptionRiskAutoSuspend,
subscriptionRiskWindowHours: config.subscriptionRiskWindowHours,
subscriptionRiskCityWarning: config.subscriptionRiskCityWarning,
subscriptionRiskCitySuspend: config.subscriptionRiskCitySuspend,
subscriptionRiskRegionWarning: config.subscriptionRiskRegionWarning,
subscriptionRiskRegionSuspend: config.subscriptionRiskRegionSuspend,
subscriptionRiskIpLimitPerHour: config.subscriptionRiskIpLimitPerHour,
subscriptionRiskTokenLimitPerHour: config.subscriptionRiskTokenLimitPerHour,
inviteRewardEnabled: config.inviteRewardEnabled,
inviteRewardRate: Number(config.inviteRewardRate),
inviteRewardCouponId: config.inviteRewardCouponId,

View File

@@ -25,6 +25,15 @@ interface AppConfig {
reminderDispatchIntervalMinutes: number;
trafficSyncEnabled: boolean;
trafficSyncIntervalSeconds: number;
subscriptionRiskEnabled: boolean;
subscriptionRiskAutoSuspend: boolean;
subscriptionRiskWindowHours: number;
subscriptionRiskCityWarning: number;
subscriptionRiskCitySuspend: number;
subscriptionRiskRegionWarning: number;
subscriptionRiskRegionSuspend: number;
subscriptionRiskIpLimitPerHour: number;
subscriptionRiskTokenLimitPerHour: number;
inviteRewardEnabled: boolean;
inviteRewardRate: number;
inviteRewardCouponId: string | null;
@@ -196,6 +205,121 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div>
</section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<ShieldAlert className="size-4 text-primary" /> 访
</div>
<p className="text-xs leading-5 text-muted-foreground">
访访
</p>
<div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="subscriptionRiskEnabled"></Label>
<select
id="subscriptionRiskEnabled"
name="subscriptionRiskEnabled"
defaultValue={String(config.subscriptionRiskEnabled)}
className={selectClassName}
>
<option value="true"></option>
<option value="false"></option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="subscriptionRiskAutoSuspend"></Label>
<select
id="subscriptionRiskAutoSuspend"
name="subscriptionRiskAutoSuspend"
defaultValue={String(config.subscriptionRiskAutoSuspend)}
className={selectClassName}
>
<option value="true"></option>
<option value="false"></option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="subscriptionRiskWindowHours"></Label>
<Input
id="subscriptionRiskWindowHours"
name="subscriptionRiskWindowHours"
type="number"
min={1}
max={168}
defaultValue={config.subscriptionRiskWindowHours}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subscriptionRiskCityWarning"></Label>
<Input
id="subscriptionRiskCityWarning"
name="subscriptionRiskCityWarning"
type="number"
min={2}
max={100}
defaultValue={config.subscriptionRiskCityWarning}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subscriptionRiskCitySuspend"></Label>
<Input
id="subscriptionRiskCitySuspend"
name="subscriptionRiskCitySuspend"
type="number"
min={2}
max={100}
defaultValue={config.subscriptionRiskCitySuspend}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subscriptionRiskRegionWarning">/</Label>
<Input
id="subscriptionRiskRegionWarning"
name="subscriptionRiskRegionWarning"
type="number"
min={2}
max={100}
defaultValue={config.subscriptionRiskRegionWarning}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subscriptionRiskRegionSuspend">/</Label>
<Input
id="subscriptionRiskRegionSuspend"
name="subscriptionRiskRegionSuspend"
type="number"
min={2}
max={100}
defaultValue={config.subscriptionRiskRegionSuspend}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subscriptionRiskIpLimitPerHour">IP /</Label>
<Input
id="subscriptionRiskIpLimitPerHour"
name="subscriptionRiskIpLimitPerHour"
type="number"
min={1}
max={100000}
defaultValue={config.subscriptionRiskIpLimitPerHour}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subscriptionRiskTokenLimitPerHour">/</Label>
<Input
id="subscriptionRiskTokenLimitPerHour"
name="subscriptionRiskTokenLimitPerHour"
type="number"
min={1}
max={100000}
defaultValue={config.subscriptionRiskTokenLimitPerHour}
/>
</div>
</div>
<p className="text-xs leading-5 text-muted-foreground">
24 4 5 2 /3 /IP 180 / 60 /
</p>
</section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<ShieldCheck className="size-4 text-primary" />

View File

@@ -4,9 +4,8 @@ import { prisma } from "@/lib/prisma";
import { rateLimit } from "@/lib/rate-limit";
import { getClientRequestContext } from "@/lib/request-context";
import { recordSubscriptionAccess } from "@/services/subscription-risk";
import { getAppConfig } from "@/services/app-config";
const SUBSCRIPTION_TOKEN_LIMIT = 60;
const SUBSCRIPTION_IP_LIMIT = 180;
const SUBSCRIPTION_RATE_WINDOW_SECONDS = 60 * 60;
function jsonError(message: string, status: number) {
@@ -22,33 +21,38 @@ export async function GET(
const token = url.searchParams.get("token");
const context = getClientRequestContext(req.headers);
const sub = await prisma.userSubscription.findUnique({
where: { id },
select: {
id: true,
userId: true,
downloadToken: true,
status: true,
plan: { select: { type: true } },
},
});
const [sub, config] = await Promise.all([
prisma.userSubscription.findUnique({
where: { id },
select: {
id: true,
userId: true,
downloadToken: true,
status: true,
plan: { select: { type: true } },
},
}),
getAppConfig(),
]);
const ipLimit = await rateLimit(
`ratelimit:subscription:ip:${context.ip}`,
SUBSCRIPTION_IP_LIMIT,
SUBSCRIPTION_RATE_WINDOW_SECONDS,
);
if (config.subscriptionRiskEnabled) {
const ipLimit = await rateLimit(
`ratelimit:subscription:ip:${context.ip}`,
config.subscriptionRiskIpLimitPerHour,
SUBSCRIPTION_RATE_WINDOW_SECONDS,
);
if (!ipLimit.success) {
await recordSubscriptionAccess({
kind: "SINGLE",
context,
userId: sub?.userId,
subscriptionId: sub?.id ?? id,
allowed: false,
reason: "rate_limited",
});
return jsonError("Too many subscription requests", 429);
if (!ipLimit.success) {
await recordSubscriptionAccess({
kind: "SINGLE",
context,
userId: sub?.userId,
subscriptionId: sub?.id ?? id,
allowed: false,
reason: "rate_limited",
});
return jsonError("Too many subscription requests", 429);
}
}
if (!token) {
@@ -75,22 +79,24 @@ export async function GET(
return jsonError("Invalid token", 401);
}
const tokenLimit = await rateLimit(
`ratelimit:subscription:single:${sub.id}`,
SUBSCRIPTION_TOKEN_LIMIT,
SUBSCRIPTION_RATE_WINDOW_SECONDS,
);
if (config.subscriptionRiskEnabled) {
const tokenLimit = await rateLimit(
`ratelimit:subscription:single:${sub.id}`,
config.subscriptionRiskTokenLimitPerHour,
SUBSCRIPTION_RATE_WINDOW_SECONDS,
);
if (!tokenLimit.success) {
await recordSubscriptionAccess({
kind: "SINGLE",
context,
userId: sub.userId,
subscriptionId: sub.id,
allowed: false,
reason: "rate_limited",
});
return jsonError("Too many subscription requests", 429);
if (!tokenLimit.success) {
await recordSubscriptionAccess({
kind: "SINGLE",
context,
userId: sub.userId,
subscriptionId: sub.id,
allowed: false,
reason: "rate_limited",
});
return jsonError("Too many subscription requests", 429);
}
}
if (sub.status !== "ACTIVE") {
@@ -110,7 +116,8 @@ export async function GET(
context,
userId: sub.userId,
subscriptionId: sub.id,
evaluateRisk: sub.plan.type === "PROXY",
evaluateRisk: config.subscriptionRiskEnabled && sub.plan.type === "PROXY",
riskConfig: config,
});
if (risk.suspended) {

View File

@@ -7,9 +7,8 @@ import { prisma } from "@/lib/prisma";
import { rateLimit } from "@/lib/rate-limit";
import { getClientRequestContext } from "@/lib/request-context";
import { recordSubscriptionAccess } from "@/services/subscription-risk";
import { getAppConfig } from "@/services/app-config";
const AGGREGATE_TOKEN_LIMIT = 60;
const SUBSCRIPTION_IP_LIMIT = 180;
const SUBSCRIPTION_RATE_WINDOW_SECONDS = 60 * 60;
function jsonError(message: string, status: number) {
@@ -21,22 +20,25 @@ export async function GET(req: Request) {
const userId = url.searchParams.get("userId") ?? url.searchParams.get("user");
const token = url.searchParams.get("token");
const context = getClientRequestContext(req.headers);
const config = await getAppConfig();
const ipLimit = await rateLimit(
`ratelimit:subscription:ip:${context.ip}`,
SUBSCRIPTION_IP_LIMIT,
SUBSCRIPTION_RATE_WINDOW_SECONDS,
);
if (config.subscriptionRiskEnabled) {
const ipLimit = await rateLimit(
`ratelimit:subscription:ip:${context.ip}`,
config.subscriptionRiskIpLimitPerHour,
SUBSCRIPTION_RATE_WINDOW_SECONDS,
);
if (!ipLimit.success) {
await recordSubscriptionAccess({
kind: "AGGREGATE",
context,
userId,
allowed: false,
reason: "rate_limited",
});
return jsonError("Too many subscription requests", 429);
if (!ipLimit.success) {
await recordSubscriptionAccess({
kind: "AGGREGATE",
context,
userId,
allowed: false,
reason: "rate_limited",
});
return jsonError("Too many subscription requests", 429);
}
}
if (!userId || !token) {
@@ -66,21 +68,23 @@ export async function GET(req: Request) {
return jsonError("Invalid subscription token", 401);
}
const tokenLimit = await rateLimit(
`ratelimit:subscription:aggregate:${userId}`,
AGGREGATE_TOKEN_LIMIT,
SUBSCRIPTION_RATE_WINDOW_SECONDS,
);
if (config.subscriptionRiskEnabled) {
const tokenLimit = await rateLimit(
`ratelimit:subscription:aggregate:${userId}`,
config.subscriptionRiskTokenLimitPerHour,
SUBSCRIPTION_RATE_WINDOW_SECONDS,
);
if (!tokenLimit.success) {
await recordSubscriptionAccess({
kind: "AGGREGATE",
context,
userId,
allowed: false,
reason: "rate_limited",
});
return jsonError("Too many subscription requests", 429);
if (!tokenLimit.success) {
await recordSubscriptionAccess({
kind: "AGGREGATE",
context,
userId,
allowed: false,
reason: "rate_limited",
});
return jsonError("Too many subscription requests", 429);
}
}
if (user.status !== "ACTIVE") {
@@ -98,6 +102,8 @@ export async function GET(req: Request) {
kind: "AGGREGATE",
context,
userId,
evaluateRisk: config.subscriptionRiskEnabled,
riskConfig: config,
});
if (risk.suspended) {