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:
@@ -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、地区统计,并将事件标记为待处理、已确认或已解决,必要时可人工恢复被暂停的订阅。项目内置 `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 个省/地区暂停;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 部署
|
||||
|
||||
|
||||
@@ -730,35 +730,44 @@ model AuditLog {
|
||||
}
|
||||
|
||||
model AppConfig {
|
||||
id String @id @default("default")
|
||||
siteName String @default("J-Board")
|
||||
siteUrl String?
|
||||
subscriptionUrl String?
|
||||
allowRegistration Boolean @default(true)
|
||||
emailVerificationRequired Boolean @default(false)
|
||||
requireInviteCode Boolean @default(false)
|
||||
supportContact String?
|
||||
maintenanceNotice String?
|
||||
siteNotice String?
|
||||
autoReminderDispatchEnabled Boolean @default(true)
|
||||
reminderDispatchIntervalMinutes Int @default(60)
|
||||
trafficSyncEnabled Boolean @default(true)
|
||||
trafficSyncIntervalSeconds Int @default(60)
|
||||
inviteRewardCouponId String?
|
||||
inviteRewardRate Decimal @default(0) @db.Decimal(5, 2)
|
||||
inviteRewardEnabled Boolean @default(false)
|
||||
turnstileSiteKey String?
|
||||
turnstileSecretKey String?
|
||||
smtpEnabled Boolean @default(false)
|
||||
smtpHost String?
|
||||
smtpPort Int @default(587)
|
||||
smtpSecure Boolean @default(false)
|
||||
smtpUser String?
|
||||
smtpPassword String?
|
||||
smtpFromName String?
|
||||
smtpFromEmail String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default("default")
|
||||
siteName String @default("J-Board")
|
||||
siteUrl String?
|
||||
subscriptionUrl String?
|
||||
allowRegistration Boolean @default(true)
|
||||
emailVerificationRequired Boolean @default(false)
|
||||
requireInviteCode Boolean @default(false)
|
||||
supportContact String?
|
||||
maintenanceNotice String?
|
||||
siteNotice String?
|
||||
autoReminderDispatchEnabled Boolean @default(true)
|
||||
reminderDispatchIntervalMinutes Int @default(60)
|
||||
trafficSyncEnabled Boolean @default(true)
|
||||
trafficSyncIntervalSeconds Int @default(60)
|
||||
subscriptionRiskEnabled Boolean @default(true)
|
||||
subscriptionRiskAutoSuspend Boolean @default(true)
|
||||
subscriptionRiskWindowHours Int @default(24)
|
||||
subscriptionRiskCityWarning Int @default(4)
|
||||
subscriptionRiskCitySuspend Int @default(5)
|
||||
subscriptionRiskRegionWarning Int @default(2)
|
||||
subscriptionRiskRegionSuspend Int @default(3)
|
||||
subscriptionRiskIpLimitPerHour Int @default(180)
|
||||
subscriptionRiskTokenLimitPerHour Int @default(60)
|
||||
inviteRewardCouponId String?
|
||||
inviteRewardRate Decimal @default(0) @db.Decimal(5, 2)
|
||||
inviteRewardEnabled Boolean @default(false)
|
||||
turnstileSiteKey String?
|
||||
turnstileSecretKey String?
|
||||
smtpEnabled Boolean @default(false)
|
||||
smtpHost String?
|
||||
smtpPort Int @default(587)
|
||||
smtpSecure Boolean @default(false)
|
||||
smtpUser String?
|
||||
smtpPassword String?
|
||||
smtpFromName String?
|
||||
smtpFromEmail String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Announcement {
|
||||
|
||||
@@ -25,6 +25,15 @@ const settingsSchema = z.object({
|
||||
reminderDispatchIntervalMinutes: z.coerce.number().int().positive().optional(),
|
||||
trafficSyncEnabled: z.string().optional(),
|
||||
trafficSyncIntervalSeconds: z.coerce.number().int().min(10).optional(),
|
||||
subscriptionRiskEnabled: z.string().optional(),
|
||||
subscriptionRiskAutoSuspend: z.string().optional(),
|
||||
subscriptionRiskWindowHours: z.coerce.number().int().min(1).max(168).optional(),
|
||||
subscriptionRiskCityWarning: 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(),
|
||||
subscriptionRiskRegionSuspend: 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(),
|
||||
inviteRewardRate: z.coerce.number().min(0).max(100).optional(),
|
||||
inviteRewardCouponId: z.string().trim().optional(),
|
||||
@@ -102,6 +111,22 @@ function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Aw
|
||||
trafficSyncEnabled: parsed.trafficSyncEnabled === "true",
|
||||
trafficSyncIntervalSeconds:
|
||||
parsed.trafficSyncIntervalSeconds ?? current.trafficSyncIntervalSeconds,
|
||||
subscriptionRiskEnabled: parsed.subscriptionRiskEnabled === "true",
|
||||
subscriptionRiskAutoSuspend: parsed.subscriptionRiskAutoSuspend === "true",
|
||||
subscriptionRiskWindowHours:
|
||||
parsed.subscriptionRiskWindowHours ?? current.subscriptionRiskWindowHours,
|
||||
subscriptionRiskCityWarning:
|
||||
parsed.subscriptionRiskCityWarning ?? current.subscriptionRiskCityWarning,
|
||||
subscriptionRiskCitySuspend:
|
||||
parsed.subscriptionRiskCitySuspend ?? current.subscriptionRiskCitySuspend,
|
||||
subscriptionRiskRegionWarning:
|
||||
parsed.subscriptionRiskRegionWarning ?? current.subscriptionRiskRegionWarning,
|
||||
subscriptionRiskRegionSuspend:
|
||||
parsed.subscriptionRiskRegionSuspend ?? current.subscriptionRiskRegionSuspend,
|
||||
subscriptionRiskIpLimitPerHour:
|
||||
parsed.subscriptionRiskIpLimitPerHour ?? current.subscriptionRiskIpLimitPerHour,
|
||||
subscriptionRiskTokenLimitPerHour:
|
||||
parsed.subscriptionRiskTokenLimitPerHour ?? current.subscriptionRiskTokenLimitPerHour,
|
||||
inviteRewardEnabled: parsed.inviteRewardEnabled === "true",
|
||||
inviteRewardRate: parsed.inviteRewardRate ?? Number(current.inviteRewardRate),
|
||||
inviteRewardCouponId: parsed.inviteRewardCouponId || null,
|
||||
@@ -117,6 +142,13 @@ function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Aw
|
||||
smtpFromEmail: parsed.smtpFromEmail || null,
|
||||
};
|
||||
|
||||
if (next.subscriptionRiskCitySuspend < next.subscriptionRiskCityWarning) {
|
||||
throw new Error("城市暂停阈值不能小于城市警告阈值");
|
||||
}
|
||||
if (next.subscriptionRiskRegionSuspend < next.subscriptionRiskRegionWarning) {
|
||||
throw new Error("省/地区暂停阈值不能小于省/地区警告阈值");
|
||||
}
|
||||
|
||||
if (next.smtpEnabled || next.emailVerificationRequired) {
|
||||
if (!next.smtpHost || !next.smtpPort || !next.smtpFromEmail) {
|
||||
throw new Error("启用邮件服务或注册邮箱验证前,请完整填写 SMTP 主机、端口和发件邮箱");
|
||||
@@ -138,6 +170,8 @@ function revalidateSettingsViews() {
|
||||
revalidatePath("/admin/nodes");
|
||||
revalidatePath("/account");
|
||||
revalidatePath("/admin/commerce");
|
||||
revalidatePath("/admin/subscription-risk");
|
||||
revalidatePath("/admin/subscriptions");
|
||||
}
|
||||
|
||||
async function persistAppSettings(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type {
|
||||
AppConfig,
|
||||
Prisma,
|
||||
SubscriptionAccessKind,
|
||||
SubscriptionRiskLevel,
|
||||
@@ -10,12 +11,18 @@ import type { ClientRequestContext } from "@/lib/request-context";
|
||||
import { recordAuditLog } from "@/services/audit";
|
||||
import { createNotification } from "@/services/notifications";
|
||||
import { createPanelAdapter } from "@/services/node-panel/factory";
|
||||
import { getAppConfig } from "@/services/app-config";
|
||||
|
||||
const RISK_WINDOW_HOURS = 24;
|
||||
const CITY_WARNING_COUNT = 4;
|
||||
const CITY_SUSPEND_COUNT = 5;
|
||||
const REGION_WARNING_COUNT = 2;
|
||||
const REGION_SUSPEND_COUNT = 3;
|
||||
type SubscriptionRiskConfig = Pick<
|
||||
AppConfig,
|
||||
| "subscriptionRiskEnabled"
|
||||
| "subscriptionRiskAutoSuspend"
|
||||
| "subscriptionRiskWindowHours"
|
||||
| "subscriptionRiskCityWarning"
|
||||
| "subscriptionRiskCitySuspend"
|
||||
| "subscriptionRiskRegionWarning"
|
||||
| "subscriptionRiskRegionSuspend"
|
||||
>;
|
||||
|
||||
interface RecordSubscriptionAccessInput {
|
||||
kind: SubscriptionAccessKind;
|
||||
@@ -25,6 +32,7 @@ interface RecordSubscriptionAccessInput {
|
||||
allowed?: boolean;
|
||||
reason?: string | null;
|
||||
evaluateRisk?: boolean;
|
||||
riskConfig?: SubscriptionRiskConfig;
|
||||
}
|
||||
|
||||
interface RiskDecision {
|
||||
@@ -38,6 +46,14 @@ interface RiskEvaluationResult {
|
||||
eventId?: string;
|
||||
}
|
||||
|
||||
interface RiskThresholds {
|
||||
cityWarning: number;
|
||||
citySuspend: number;
|
||||
regionWarning: number;
|
||||
regionSuspend: number;
|
||||
autoSuspend: boolean;
|
||||
}
|
||||
|
||||
function normalizeLocationPart(value: string | null | undefined) {
|
||||
return value?.trim().toLowerCase() || null;
|
||||
}
|
||||
@@ -90,17 +106,17 @@ function riskMessage(options: {
|
||||
return `${scope}访问地区异常,24 小时内出现 ${locationSummary},最近 IP ${options.ip},已记录警告。`;
|
||||
}
|
||||
|
||||
function decideRisk(cityCount: number, regionCount: number): RiskDecision | null {
|
||||
if (regionCount >= REGION_SUSPEND_COUNT) {
|
||||
function decideRisk(cityCount: number, regionCount: number, thresholds: RiskThresholds): RiskDecision | null {
|
||||
if (thresholds.autoSuspend && regionCount >= thresholds.regionSuspend) {
|
||||
return { level: "SUSPENDED", reason: "REGION_VARIANCE_SUSPEND" };
|
||||
}
|
||||
if (cityCount >= CITY_SUSPEND_COUNT) {
|
||||
if (thresholds.autoSuspend && cityCount >= thresholds.citySuspend) {
|
||||
return { level: "SUSPENDED", reason: "CITY_VARIANCE_SUSPEND" };
|
||||
}
|
||||
if (regionCount >= REGION_WARNING_COUNT) {
|
||||
if (regionCount >= thresholds.regionWarning) {
|
||||
return { level: "WARNING", reason: "REGION_VARIANCE_WARNING" };
|
||||
}
|
||||
if (cityCount >= CITY_WARNING_COUNT) {
|
||||
if (cityCount >= thresholds.cityWarning) {
|
||||
return { level: "WARNING", reason: "CITY_VARIANCE_WARNING" };
|
||||
}
|
||||
|
||||
@@ -142,6 +158,7 @@ async function getTargetLabel(input: { userId?: string | null; subscriptionId?:
|
||||
|
||||
function revalidateRiskViews(subscriptionIds: string[] = []) {
|
||||
revalidatePath("/admin/audit-logs");
|
||||
revalidatePath("/admin/subscription-risk");
|
||||
revalidatePath("/admin/subscriptions");
|
||||
revalidatePath("/subscriptions");
|
||||
revalidatePath("/dashboard");
|
||||
@@ -323,11 +340,22 @@ async function evaluateSubscriptionRisk(input: {
|
||||
subscriptionId?: string | null;
|
||||
ip: string;
|
||||
db: DbClient;
|
||||
config?: SubscriptionRiskConfig;
|
||||
}): Promise<RiskEvaluationResult> {
|
||||
if (!input.userId) return { warned: false, suspended: false };
|
||||
if (input.kind === "SINGLE" && !input.subscriptionId) return { warned: false, suspended: false };
|
||||
|
||||
const windowStartedAt = new Date(Date.now() - RISK_WINDOW_HOURS * 60 * 60 * 1000);
|
||||
const config = input.config ?? await getAppConfig(input.db);
|
||||
if (!config.subscriptionRiskEnabled) return { warned: false, suspended: false };
|
||||
|
||||
const thresholds: RiskThresholds = {
|
||||
cityWarning: config.subscriptionRiskCityWarning,
|
||||
citySuspend: config.subscriptionRiskCitySuspend,
|
||||
regionWarning: config.subscriptionRiskRegionWarning,
|
||||
regionSuspend: config.subscriptionRiskRegionSuspend,
|
||||
autoSuspend: config.subscriptionRiskAutoSuspend,
|
||||
};
|
||||
const windowStartedAt = new Date(Date.now() - config.subscriptionRiskWindowHours * 60 * 60 * 1000);
|
||||
const logs = await input.db.subscriptionAccessLog.findMany({
|
||||
where: {
|
||||
allowed: true,
|
||||
@@ -371,7 +399,7 @@ 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);
|
||||
const decision = decideRisk(cityLabels.length, regionLabels.length, thresholds);
|
||||
if (!decision) return { warned: false, suspended: false };
|
||||
|
||||
const message = riskMessage({
|
||||
@@ -479,5 +507,6 @@ export async function recordSubscriptionAccess(
|
||||
subscriptionId: input.subscriptionId,
|
||||
ip: input.context.ip,
|
||||
db,
|
||||
config: input.riskConfig,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user