mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: make subscription risk controls configurable
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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" /> 注册策略
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user