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 可能被直连源站请求伪造。
|
订阅域名套 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 部署
|
### 手动 Docker 部署
|
||||||
|
|
||||||
|
|||||||
@@ -730,35 +730,44 @@ model AuditLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model AppConfig {
|
model AppConfig {
|
||||||
id String @id @default("default")
|
id String @id @default("default")
|
||||||
siteName String @default("J-Board")
|
siteName String @default("J-Board")
|
||||||
siteUrl String?
|
siteUrl String?
|
||||||
subscriptionUrl String?
|
subscriptionUrl String?
|
||||||
allowRegistration Boolean @default(true)
|
allowRegistration Boolean @default(true)
|
||||||
emailVerificationRequired Boolean @default(false)
|
emailVerificationRequired Boolean @default(false)
|
||||||
requireInviteCode Boolean @default(false)
|
requireInviteCode Boolean @default(false)
|
||||||
supportContact String?
|
supportContact String?
|
||||||
maintenanceNotice String?
|
maintenanceNotice String?
|
||||||
siteNotice String?
|
siteNotice String?
|
||||||
autoReminderDispatchEnabled Boolean @default(true)
|
autoReminderDispatchEnabled Boolean @default(true)
|
||||||
reminderDispatchIntervalMinutes Int @default(60)
|
reminderDispatchIntervalMinutes Int @default(60)
|
||||||
trafficSyncEnabled Boolean @default(true)
|
trafficSyncEnabled Boolean @default(true)
|
||||||
trafficSyncIntervalSeconds Int @default(60)
|
trafficSyncIntervalSeconds Int @default(60)
|
||||||
inviteRewardCouponId String?
|
subscriptionRiskEnabled Boolean @default(true)
|
||||||
inviteRewardRate Decimal @default(0) @db.Decimal(5, 2)
|
subscriptionRiskAutoSuspend Boolean @default(true)
|
||||||
inviteRewardEnabled Boolean @default(false)
|
subscriptionRiskWindowHours Int @default(24)
|
||||||
turnstileSiteKey String?
|
subscriptionRiskCityWarning Int @default(4)
|
||||||
turnstileSecretKey String?
|
subscriptionRiskCitySuspend Int @default(5)
|
||||||
smtpEnabled Boolean @default(false)
|
subscriptionRiskRegionWarning Int @default(2)
|
||||||
smtpHost String?
|
subscriptionRiskRegionSuspend Int @default(3)
|
||||||
smtpPort Int @default(587)
|
subscriptionRiskIpLimitPerHour Int @default(180)
|
||||||
smtpSecure Boolean @default(false)
|
subscriptionRiskTokenLimitPerHour Int @default(60)
|
||||||
smtpUser String?
|
inviteRewardCouponId String?
|
||||||
smtpPassword String?
|
inviteRewardRate Decimal @default(0) @db.Decimal(5, 2)
|
||||||
smtpFromName String?
|
inviteRewardEnabled Boolean @default(false)
|
||||||
smtpFromEmail String?
|
turnstileSiteKey String?
|
||||||
createdAt DateTime @default(now())
|
turnstileSecretKey String?
|
||||||
updatedAt DateTime @updatedAt
|
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 {
|
model Announcement {
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ const settingsSchema = z.object({
|
|||||||
reminderDispatchIntervalMinutes: z.coerce.number().int().positive().optional(),
|
reminderDispatchIntervalMinutes: z.coerce.number().int().positive().optional(),
|
||||||
trafficSyncEnabled: z.string().optional(),
|
trafficSyncEnabled: z.string().optional(),
|
||||||
trafficSyncIntervalSeconds: z.coerce.number().int().min(10).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(),
|
inviteRewardEnabled: z.string().optional(),
|
||||||
inviteRewardRate: z.coerce.number().min(0).max(100).optional(),
|
inviteRewardRate: z.coerce.number().min(0).max(100).optional(),
|
||||||
inviteRewardCouponId: z.string().trim().optional(),
|
inviteRewardCouponId: z.string().trim().optional(),
|
||||||
@@ -102,6 +111,22 @@ function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Aw
|
|||||||
trafficSyncEnabled: parsed.trafficSyncEnabled === "true",
|
trafficSyncEnabled: parsed.trafficSyncEnabled === "true",
|
||||||
trafficSyncIntervalSeconds:
|
trafficSyncIntervalSeconds:
|
||||||
parsed.trafficSyncIntervalSeconds ?? current.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",
|
inviteRewardEnabled: parsed.inviteRewardEnabled === "true",
|
||||||
inviteRewardRate: parsed.inviteRewardRate ?? Number(current.inviteRewardRate),
|
inviteRewardRate: parsed.inviteRewardRate ?? Number(current.inviteRewardRate),
|
||||||
inviteRewardCouponId: parsed.inviteRewardCouponId || null,
|
inviteRewardCouponId: parsed.inviteRewardCouponId || null,
|
||||||
@@ -117,6 +142,13 @@ function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Aw
|
|||||||
smtpFromEmail: parsed.smtpFromEmail || null,
|
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.smtpEnabled || next.emailVerificationRequired) {
|
||||||
if (!next.smtpHost || !next.smtpPort || !next.smtpFromEmail) {
|
if (!next.smtpHost || !next.smtpPort || !next.smtpFromEmail) {
|
||||||
throw new Error("启用邮件服务或注册邮箱验证前,请完整填写 SMTP 主机、端口和发件邮箱");
|
throw new Error("启用邮件服务或注册邮箱验证前,请完整填写 SMTP 主机、端口和发件邮箱");
|
||||||
@@ -138,6 +170,8 @@ function revalidateSettingsViews() {
|
|||||||
revalidatePath("/admin/nodes");
|
revalidatePath("/admin/nodes");
|
||||||
revalidatePath("/account");
|
revalidatePath("/account");
|
||||||
revalidatePath("/admin/commerce");
|
revalidatePath("/admin/commerce");
|
||||||
|
revalidatePath("/admin/subscription-risk");
|
||||||
|
revalidatePath("/admin/subscriptions");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistAppSettings(
|
async function persistAppSettings(
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ export default async function AdminSettingsPage() {
|
|||||||
reminderDispatchIntervalMinutes: config.reminderDispatchIntervalMinutes,
|
reminderDispatchIntervalMinutes: config.reminderDispatchIntervalMinutes,
|
||||||
trafficSyncEnabled: config.trafficSyncEnabled,
|
trafficSyncEnabled: config.trafficSyncEnabled,
|
||||||
trafficSyncIntervalSeconds: config.trafficSyncIntervalSeconds,
|
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,
|
inviteRewardEnabled: config.inviteRewardEnabled,
|
||||||
inviteRewardRate: Number(config.inviteRewardRate),
|
inviteRewardRate: Number(config.inviteRewardRate),
|
||||||
inviteRewardCouponId: config.inviteRewardCouponId,
|
inviteRewardCouponId: config.inviteRewardCouponId,
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ interface AppConfig {
|
|||||||
reminderDispatchIntervalMinutes: number;
|
reminderDispatchIntervalMinutes: number;
|
||||||
trafficSyncEnabled: boolean;
|
trafficSyncEnabled: boolean;
|
||||||
trafficSyncIntervalSeconds: number;
|
trafficSyncIntervalSeconds: number;
|
||||||
|
subscriptionRiskEnabled: boolean;
|
||||||
|
subscriptionRiskAutoSuspend: boolean;
|
||||||
|
subscriptionRiskWindowHours: number;
|
||||||
|
subscriptionRiskCityWarning: number;
|
||||||
|
subscriptionRiskCitySuspend: number;
|
||||||
|
subscriptionRiskRegionWarning: number;
|
||||||
|
subscriptionRiskRegionSuspend: number;
|
||||||
|
subscriptionRiskIpLimitPerHour: number;
|
||||||
|
subscriptionRiskTokenLimitPerHour: number;
|
||||||
inviteRewardEnabled: boolean;
|
inviteRewardEnabled: boolean;
|
||||||
inviteRewardRate: number;
|
inviteRewardRate: number;
|
||||||
inviteRewardCouponId: string | null;
|
inviteRewardCouponId: string | null;
|
||||||
@@ -196,6 +205,121 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<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">
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
<ShieldCheck className="size-4 text-primary" /> 注册策略
|
<ShieldCheck className="size-4 text-primary" /> 注册策略
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { rateLimit } from "@/lib/rate-limit";
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
import { getClientRequestContext } from "@/lib/request-context";
|
import { getClientRequestContext } from "@/lib/request-context";
|
||||||
import { recordSubscriptionAccess } from "@/services/subscription-risk";
|
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;
|
const SUBSCRIPTION_RATE_WINDOW_SECONDS = 60 * 60;
|
||||||
|
|
||||||
function jsonError(message: string, status: number) {
|
function jsonError(message: string, status: number) {
|
||||||
@@ -22,33 +21,38 @@ export async function GET(
|
|||||||
const token = url.searchParams.get("token");
|
const token = url.searchParams.get("token");
|
||||||
const context = getClientRequestContext(req.headers);
|
const context = getClientRequestContext(req.headers);
|
||||||
|
|
||||||
const sub = await prisma.userSubscription.findUnique({
|
const [sub, config] = await Promise.all([
|
||||||
where: { id },
|
prisma.userSubscription.findUnique({
|
||||||
select: {
|
where: { id },
|
||||||
id: true,
|
select: {
|
||||||
userId: true,
|
id: true,
|
||||||
downloadToken: true,
|
userId: true,
|
||||||
status: true,
|
downloadToken: true,
|
||||||
plan: { select: { type: true } },
|
status: true,
|
||||||
},
|
plan: { select: { type: true } },
|
||||||
});
|
},
|
||||||
|
}),
|
||||||
|
getAppConfig(),
|
||||||
|
]);
|
||||||
|
|
||||||
const ipLimit = await rateLimit(
|
if (config.subscriptionRiskEnabled) {
|
||||||
`ratelimit:subscription:ip:${context.ip}`,
|
const ipLimit = await rateLimit(
|
||||||
SUBSCRIPTION_IP_LIMIT,
|
`ratelimit:subscription:ip:${context.ip}`,
|
||||||
SUBSCRIPTION_RATE_WINDOW_SECONDS,
|
config.subscriptionRiskIpLimitPerHour,
|
||||||
);
|
SUBSCRIPTION_RATE_WINDOW_SECONDS,
|
||||||
|
);
|
||||||
|
|
||||||
if (!ipLimit.success) {
|
if (!ipLimit.success) {
|
||||||
await recordSubscriptionAccess({
|
await recordSubscriptionAccess({
|
||||||
kind: "SINGLE",
|
kind: "SINGLE",
|
||||||
context,
|
context,
|
||||||
userId: sub?.userId,
|
userId: sub?.userId,
|
||||||
subscriptionId: sub?.id ?? id,
|
subscriptionId: sub?.id ?? id,
|
||||||
allowed: false,
|
allowed: false,
|
||||||
reason: "rate_limited",
|
reason: "rate_limited",
|
||||||
});
|
});
|
||||||
return jsonError("Too many subscription requests", 429);
|
return jsonError("Too many subscription requests", 429);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -75,22 +79,24 @@ export async function GET(
|
|||||||
return jsonError("Invalid token", 401);
|
return jsonError("Invalid token", 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenLimit = await rateLimit(
|
if (config.subscriptionRiskEnabled) {
|
||||||
`ratelimit:subscription:single:${sub.id}`,
|
const tokenLimit = await rateLimit(
|
||||||
SUBSCRIPTION_TOKEN_LIMIT,
|
`ratelimit:subscription:single:${sub.id}`,
|
||||||
SUBSCRIPTION_RATE_WINDOW_SECONDS,
|
config.subscriptionRiskTokenLimitPerHour,
|
||||||
);
|
SUBSCRIPTION_RATE_WINDOW_SECONDS,
|
||||||
|
);
|
||||||
|
|
||||||
if (!tokenLimit.success) {
|
if (!tokenLimit.success) {
|
||||||
await recordSubscriptionAccess({
|
await recordSubscriptionAccess({
|
||||||
kind: "SINGLE",
|
kind: "SINGLE",
|
||||||
context,
|
context,
|
||||||
userId: sub.userId,
|
userId: sub.userId,
|
||||||
subscriptionId: sub.id,
|
subscriptionId: sub.id,
|
||||||
allowed: false,
|
allowed: false,
|
||||||
reason: "rate_limited",
|
reason: "rate_limited",
|
||||||
});
|
});
|
||||||
return jsonError("Too many subscription requests", 429);
|
return jsonError("Too many subscription requests", 429);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sub.status !== "ACTIVE") {
|
if (sub.status !== "ACTIVE") {
|
||||||
@@ -110,7 +116,8 @@ export async function GET(
|
|||||||
context,
|
context,
|
||||||
userId: sub.userId,
|
userId: sub.userId,
|
||||||
subscriptionId: sub.id,
|
subscriptionId: sub.id,
|
||||||
evaluateRisk: sub.plan.type === "PROXY",
|
evaluateRisk: config.subscriptionRiskEnabled && sub.plan.type === "PROXY",
|
||||||
|
riskConfig: config,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (risk.suspended) {
|
if (risk.suspended) {
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { rateLimit } from "@/lib/rate-limit";
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
import { getClientRequestContext } from "@/lib/request-context";
|
import { getClientRequestContext } from "@/lib/request-context";
|
||||||
import { recordSubscriptionAccess } from "@/services/subscription-risk";
|
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;
|
const SUBSCRIPTION_RATE_WINDOW_SECONDS = 60 * 60;
|
||||||
|
|
||||||
function jsonError(message: string, status: number) {
|
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 userId = url.searchParams.get("userId") ?? url.searchParams.get("user");
|
||||||
const token = url.searchParams.get("token");
|
const token = url.searchParams.get("token");
|
||||||
const context = getClientRequestContext(req.headers);
|
const context = getClientRequestContext(req.headers);
|
||||||
|
const config = await getAppConfig();
|
||||||
|
|
||||||
const ipLimit = await rateLimit(
|
if (config.subscriptionRiskEnabled) {
|
||||||
`ratelimit:subscription:ip:${context.ip}`,
|
const ipLimit = await rateLimit(
|
||||||
SUBSCRIPTION_IP_LIMIT,
|
`ratelimit:subscription:ip:${context.ip}`,
|
||||||
SUBSCRIPTION_RATE_WINDOW_SECONDS,
|
config.subscriptionRiskIpLimitPerHour,
|
||||||
);
|
SUBSCRIPTION_RATE_WINDOW_SECONDS,
|
||||||
|
);
|
||||||
|
|
||||||
if (!ipLimit.success) {
|
if (!ipLimit.success) {
|
||||||
await recordSubscriptionAccess({
|
await recordSubscriptionAccess({
|
||||||
kind: "AGGREGATE",
|
kind: "AGGREGATE",
|
||||||
context,
|
context,
|
||||||
userId,
|
userId,
|
||||||
allowed: false,
|
allowed: false,
|
||||||
reason: "rate_limited",
|
reason: "rate_limited",
|
||||||
});
|
});
|
||||||
return jsonError("Too many subscription requests", 429);
|
return jsonError("Too many subscription requests", 429);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userId || !token) {
|
if (!userId || !token) {
|
||||||
@@ -66,21 +68,23 @@ export async function GET(req: Request) {
|
|||||||
return jsonError("Invalid subscription token", 401);
|
return jsonError("Invalid subscription token", 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenLimit = await rateLimit(
|
if (config.subscriptionRiskEnabled) {
|
||||||
`ratelimit:subscription:aggregate:${userId}`,
|
const tokenLimit = await rateLimit(
|
||||||
AGGREGATE_TOKEN_LIMIT,
|
`ratelimit:subscription:aggregate:${userId}`,
|
||||||
SUBSCRIPTION_RATE_WINDOW_SECONDS,
|
config.subscriptionRiskTokenLimitPerHour,
|
||||||
);
|
SUBSCRIPTION_RATE_WINDOW_SECONDS,
|
||||||
|
);
|
||||||
|
|
||||||
if (!tokenLimit.success) {
|
if (!tokenLimit.success) {
|
||||||
await recordSubscriptionAccess({
|
await recordSubscriptionAccess({
|
||||||
kind: "AGGREGATE",
|
kind: "AGGREGATE",
|
||||||
context,
|
context,
|
||||||
userId,
|
userId,
|
||||||
allowed: false,
|
allowed: false,
|
||||||
reason: "rate_limited",
|
reason: "rate_limited",
|
||||||
});
|
});
|
||||||
return jsonError("Too many subscription requests", 429);
|
return jsonError("Too many subscription requests", 429);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.status !== "ACTIVE") {
|
if (user.status !== "ACTIVE") {
|
||||||
@@ -98,6 +102,8 @@ export async function GET(req: Request) {
|
|||||||
kind: "AGGREGATE",
|
kind: "AGGREGATE",
|
||||||
context,
|
context,
|
||||||
userId,
|
userId,
|
||||||
|
evaluateRisk: config.subscriptionRiskEnabled,
|
||||||
|
riskConfig: config,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (risk.suspended) {
|
if (risk.suspended) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import type {
|
import type {
|
||||||
|
AppConfig,
|
||||||
Prisma,
|
Prisma,
|
||||||
SubscriptionAccessKind,
|
SubscriptionAccessKind,
|
||||||
SubscriptionRiskLevel,
|
SubscriptionRiskLevel,
|
||||||
@@ -10,12 +11,18 @@ import type { ClientRequestContext } from "@/lib/request-context";
|
|||||||
import { recordAuditLog } from "@/services/audit";
|
import { recordAuditLog } from "@/services/audit";
|
||||||
import { createNotification } from "@/services/notifications";
|
import { createNotification } from "@/services/notifications";
|
||||||
import { createPanelAdapter } from "@/services/node-panel/factory";
|
import { createPanelAdapter } from "@/services/node-panel/factory";
|
||||||
|
import { getAppConfig } from "@/services/app-config";
|
||||||
|
|
||||||
const RISK_WINDOW_HOURS = 24;
|
type SubscriptionRiskConfig = Pick<
|
||||||
const CITY_WARNING_COUNT = 4;
|
AppConfig,
|
||||||
const CITY_SUSPEND_COUNT = 5;
|
| "subscriptionRiskEnabled"
|
||||||
const REGION_WARNING_COUNT = 2;
|
| "subscriptionRiskAutoSuspend"
|
||||||
const REGION_SUSPEND_COUNT = 3;
|
| "subscriptionRiskWindowHours"
|
||||||
|
| "subscriptionRiskCityWarning"
|
||||||
|
| "subscriptionRiskCitySuspend"
|
||||||
|
| "subscriptionRiskRegionWarning"
|
||||||
|
| "subscriptionRiskRegionSuspend"
|
||||||
|
>;
|
||||||
|
|
||||||
interface RecordSubscriptionAccessInput {
|
interface RecordSubscriptionAccessInput {
|
||||||
kind: SubscriptionAccessKind;
|
kind: SubscriptionAccessKind;
|
||||||
@@ -25,6 +32,7 @@ interface RecordSubscriptionAccessInput {
|
|||||||
allowed?: boolean;
|
allowed?: boolean;
|
||||||
reason?: string | null;
|
reason?: string | null;
|
||||||
evaluateRisk?: boolean;
|
evaluateRisk?: boolean;
|
||||||
|
riskConfig?: SubscriptionRiskConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RiskDecision {
|
interface RiskDecision {
|
||||||
@@ -38,6 +46,14 @@ interface RiskEvaluationResult {
|
|||||||
eventId?: string;
|
eventId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RiskThresholds {
|
||||||
|
cityWarning: number;
|
||||||
|
citySuspend: number;
|
||||||
|
regionWarning: number;
|
||||||
|
regionSuspend: number;
|
||||||
|
autoSuspend: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeLocationPart(value: string | null | undefined) {
|
function normalizeLocationPart(value: string | null | undefined) {
|
||||||
return value?.trim().toLowerCase() || null;
|
return value?.trim().toLowerCase() || null;
|
||||||
}
|
}
|
||||||
@@ -90,17 +106,17 @@ function riskMessage(options: {
|
|||||||
return `${scope}访问地区异常,24 小时内出现 ${locationSummary},最近 IP ${options.ip},已记录警告。`;
|
return `${scope}访问地区异常,24 小时内出现 ${locationSummary},最近 IP ${options.ip},已记录警告。`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function decideRisk(cityCount: number, regionCount: number): RiskDecision | null {
|
function decideRisk(cityCount: number, regionCount: number, thresholds: RiskThresholds): RiskDecision | null {
|
||||||
if (regionCount >= REGION_SUSPEND_COUNT) {
|
if (thresholds.autoSuspend && regionCount >= thresholds.regionSuspend) {
|
||||||
return { level: "SUSPENDED", reason: "REGION_VARIANCE_SUSPEND" };
|
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" };
|
return { level: "SUSPENDED", reason: "CITY_VARIANCE_SUSPEND" };
|
||||||
}
|
}
|
||||||
if (regionCount >= REGION_WARNING_COUNT) {
|
if (regionCount >= thresholds.regionWarning) {
|
||||||
return { level: "WARNING", reason: "REGION_VARIANCE_WARNING" };
|
return { level: "WARNING", reason: "REGION_VARIANCE_WARNING" };
|
||||||
}
|
}
|
||||||
if (cityCount >= CITY_WARNING_COUNT) {
|
if (cityCount >= thresholds.cityWarning) {
|
||||||
return { level: "WARNING", reason: "CITY_VARIANCE_WARNING" };
|
return { level: "WARNING", reason: "CITY_VARIANCE_WARNING" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +158,7 @@ async function getTargetLabel(input: { userId?: string | null; subscriptionId?:
|
|||||||
|
|
||||||
function revalidateRiskViews(subscriptionIds: string[] = []) {
|
function revalidateRiskViews(subscriptionIds: string[] = []) {
|
||||||
revalidatePath("/admin/audit-logs");
|
revalidatePath("/admin/audit-logs");
|
||||||
|
revalidatePath("/admin/subscription-risk");
|
||||||
revalidatePath("/admin/subscriptions");
|
revalidatePath("/admin/subscriptions");
|
||||||
revalidatePath("/subscriptions");
|
revalidatePath("/subscriptions");
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
@@ -323,11 +340,22 @@ async function evaluateSubscriptionRisk(input: {
|
|||||||
subscriptionId?: string | null;
|
subscriptionId?: string | null;
|
||||||
ip: string;
|
ip: string;
|
||||||
db: DbClient;
|
db: DbClient;
|
||||||
|
config?: SubscriptionRiskConfig;
|
||||||
}): Promise<RiskEvaluationResult> {
|
}): Promise<RiskEvaluationResult> {
|
||||||
if (!input.userId) return { warned: false, suspended: false };
|
if (!input.userId) return { warned: false, suspended: false };
|
||||||
if (input.kind === "SINGLE" && !input.subscriptionId) 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({
|
const logs = await input.db.subscriptionAccessLog.findMany({
|
||||||
where: {
|
where: {
|
||||||
allowed: true,
|
allowed: true,
|
||||||
@@ -371,7 +399,7 @@ 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);
|
const decision = decideRisk(cityLabels.length, regionLabels.length, thresholds);
|
||||||
if (!decision) return { warned: false, suspended: false };
|
if (!decision) return { warned: false, suspended: false };
|
||||||
|
|
||||||
const message = riskMessage({
|
const message = riskMessage({
|
||||||
@@ -479,5 +507,6 @@ export async function recordSubscriptionAccess(
|
|||||||
subscriptionId: input.subscriptionId,
|
subscriptionId: input.subscriptionId,
|
||||||
ip: input.context.ip,
|
ip: input.context.ip,
|
||||||
db,
|
db,
|
||||||
|
config: input.riskConfig,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user