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

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

View File

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

View File

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

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

View File

@@ -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,
});
}