feat: release v3.0.0 risk telemetry

This commit is contained in:
JetSprow
2026-04-29 18:30:49 +10:00
parent e109f6b246
commit a5f962db84
31 changed files with 1367 additions and 80 deletions

View File

@@ -91,7 +91,7 @@ export function NodeActions({ node, siteUrl }: { node: NodeActionValue; siteUrl:
size="sm"
variant="outline"
title="撤销这个探测 Token"
description="撤销后,延迟线路探测程序将无法继续上报数据。"
description="撤销后,延迟线路探测和节点日志风控程序将无法继续上报数据。"
confirmLabel="撤销 Token"
successMessage="探测 Token 已撤销"
errorMessage="撤销失败"
@@ -170,7 +170,7 @@ export function NodeActions({ node, siteUrl }: { node: NodeActionValue; siteUrl:
</p>
)}
<p className="text-xs leading-5 text-muted-foreground">
Agent `/api/agent/latency` `/api/agent/trace` 3x-ui API
Agent `/api/agent/latency``/api/agent/trace` 3x-ui/Xray access logAgent 3x-ui
</p>
</div>
</DialogContent>

View File

@@ -48,6 +48,11 @@ export default async function AdminSettingsPage() {
subscriptionRiskCountrySuspend: config.subscriptionRiskCountrySuspend,
subscriptionRiskIpLimitPerHour: config.subscriptionRiskIpLimitPerHour,
subscriptionRiskTokenLimitPerHour: config.subscriptionRiskTokenLimitPerHour,
nodeAccessRiskEnabled: config.nodeAccessRiskEnabled,
nodeAccessConnectionWarning: config.nodeAccessConnectionWarning,
nodeAccessConnectionSuspend: config.nodeAccessConnectionSuspend,
nodeAccessUniqueTargetWarning: config.nodeAccessUniqueTargetWarning,
nodeAccessUniqueTargetSuspend: config.nodeAccessUniqueTargetSuspend,
inviteRewardEnabled: config.inviteRewardEnabled,
inviteRewardRate: Number(config.inviteRewardRate),
inviteRewardCouponId: config.inviteRewardCouponId,

View File

@@ -37,6 +37,11 @@ interface AppConfig {
subscriptionRiskCountrySuspend: number;
subscriptionRiskIpLimitPerHour: number;
subscriptionRiskTokenLimitPerHour: number;
nodeAccessRiskEnabled: boolean;
nodeAccessConnectionWarning: number;
nodeAccessConnectionSuspend: number;
nodeAccessUniqueTargetWarning: number;
nodeAccessUniqueTargetSuspend: number;
inviteRewardEnabled: boolean;
inviteRewardRate: number;
inviteRewardCouponId: string | null;
@@ -389,9 +394,37 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
defaultValue={config.subscriptionRiskTokenLimitPerHour}
/>
</div>
<div className="space-y-2">
<Label htmlFor="nodeAccessRiskEnabled"></Label>
<select
id="nodeAccessRiskEnabled"
name="nodeAccessRiskEnabled"
defaultValue={String(config.nodeAccessRiskEnabled)}
className={selectClassName}
>
<option value="true"> Agent Xray </option>
<option value="false"></option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="nodeAccessConnectionWarning"></Label>
<Input id="nodeAccessConnectionWarning" name="nodeAccessConnectionWarning" type="number" min={1} max={100000} defaultValue={config.nodeAccessConnectionWarning} />
</div>
<div className="space-y-2">
<Label htmlFor="nodeAccessConnectionSuspend"></Label>
<Input id="nodeAccessConnectionSuspend" name="nodeAccessConnectionSuspend" type="number" min={1} max={100000} defaultValue={config.nodeAccessConnectionSuspend} />
</div>
<div className="space-y-2">
<Label htmlFor="nodeAccessUniqueTargetWarning"></Label>
<Input id="nodeAccessUniqueTargetWarning" name="nodeAccessUniqueTargetWarning" type="number" min={1} max={100000} defaultValue={config.nodeAccessUniqueTargetWarning} />
</div>
<div className="space-y-2">
<Label htmlFor="nodeAccessUniqueTargetSuspend"></Label>
<Input id="nodeAccessUniqueTargetSuspend" name="nodeAccessUniqueTargetSuspend" type="number" min={1} max={100000} defaultValue={config.nodeAccessUniqueTargetSuspend} />
</div>
</div>
<p className="text-xs leading-5 text-muted-foreground">
24 4 5 2 /3 /2 3 IP 180 / 60 /
24 4 5 2 /3 /2 3 IP 180 / 60 / Agent XRAY_ACCESS_LOG_PATH Agent
</p>
</div>
)}

View File

@@ -44,7 +44,7 @@ function WorldRiskMap({ summary }: { summary: SubscriptionRiskGeoSummary }) {
viewBox="0 0 360 180"
className="h-[15rem] w-full bg-[radial-gradient(circle_at_30%_20%,color-mix(in_oklch,var(--primary)_10%,transparent),transparent_30%),linear-gradient(135deg,var(--muted),var(--card))]"
role="img"
aria-label="订阅访问 IP 世界地图分布"
aria-label="订阅访问与节点连接 IP 世界地图分布"
>
<rect width="360" height="180" rx="12" fill="transparent" />
{[-120, -60, 0, 60, 120].map((longitude) => {
@@ -164,7 +164,7 @@ export function SubscriptionRiskGeoDetails({ summary }: { summary: SubscriptionR
<details className="group rounded-xl border border-border/70 bg-muted/20">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2 text-sm font-medium [&::-webkit-details-marker]:hidden">
<span className="flex items-center gap-2">
<MapPin className="size-4 text-primary" /> IP 访
<MapPin className="size-4 text-primary" /> IP 访/
</span>
<ChevronDown className="size-4 text-muted-foreground transition-transform group-open:rotate-180" />
</summary>

View File

@@ -31,6 +31,14 @@ function reasonLabel(reason: SubscriptionRiskEvent["reason"]) {
return "国家异常警告";
case "COUNTRY_VARIANCE_SUSPEND":
return "国家异常暂停";
case "NODE_ACCESS_VOLUME_WARNING":
return "节点高频警告";
case "NODE_ACCESS_VOLUME_SUSPEND":
return "节点高频暂停";
case "NODE_ACCESS_TARGET_WARNING":
return "目标分散警告";
case "NODE_ACCESS_TARGET_SUSPEND":
return "目标分散暂停";
}
}
@@ -255,7 +263,7 @@ export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEven
return (
<EmptyState
title="暂无订阅风控事件"
description="订阅链接出现跨城市、跨省份或跨国家访问异常后,会在这里进入人工跟进队列。"
description="订阅链接或节点真实连接出现跨城市、跨省份或跨国家异常后,会在这里进入人工跟进队列。"
/>
);
}

View File

@@ -7,7 +7,7 @@ import { getSubscriptionRiskEvents } from "./risk-data";
export const metadata: Metadata = {
title: "订阅风控",
description: "查看订阅访问异常、关联用户和人工处理状态。",
description: "查看订阅访问与节点连接异常、关联用户和人工处理状态。",
};
export default async function AdminSubscriptionRiskPage({
@@ -22,7 +22,7 @@ export default async function AdminSubscriptionRiskPage({
<PageHeader
eyebrow="商品与订单"
title="订阅风控"
description="订阅链接跨城市跨省份访问异常后,会进入这里供管理员确认、备注、恢复或继续处置。"
description="订阅链接或节点真实连接出现跨城市跨省份访问异常后,会进入这里供管理员确认、备注、恢复或继续处置。"
/>
<AdminFilterBar

View File

@@ -67,6 +67,14 @@ function reasonLabel(reason: SubscriptionRiskEvent["reason"]) {
return "国家异常警告";
case "COUNTRY_VARIANCE_SUSPEND":
return "国家异常暂停";
case "NODE_ACCESS_VOLUME_WARNING":
return "节点高频警告";
case "NODE_ACCESS_VOLUME_SUSPEND":
return "节点高频暂停";
case "NODE_ACCESS_TARGET_WARNING":
return "目标分散警告";
case "NODE_ACCESS_TARGET_SUSPEND":
return "目标分散暂停";
}
}
@@ -113,7 +121,7 @@ export function SubscriptionAccessRiskSection({
</span>
<div>
<h3 className="text-lg font-semibold tracking-[-0.02em]">访</h3>
<p className="mt-0.5 text-sm text-muted-foreground"> IP</p>
<p className="mt-0.5 text-sm text-muted-foreground"> IP</p>
</div>
</div>
<Link href="/admin/subscription-risk" className={buttonVariants({ variant: "outline", size: "sm" })}>

View File

@@ -47,7 +47,7 @@ export default async function SupportPage({
subject: "订阅风控复核申请",
category: "订阅风控",
priority: "HIGH" as const,
body: "我需要复核订阅风控限制。\n\n请在这里补充近期访问订阅的设备、所在城市/国家、是否出差或旅行、是否曾分享订阅链接。\n\n系统判定" + reasonLabel(riskEvent.reason) + "\n" + riskEvent.message,
body: "我需要复核订阅风控限制。\n\n请在这里补充近期访问订阅的设备、所在城市/国家、是否出差或旅行、是否曾分享订阅链接或通过其他设备转发节点。\n\n系统判定" + reasonLabel(riskEvent.reason) + "\n" + riskEvent.message,
}
: undefined;

View File

@@ -0,0 +1,148 @@
import { isIP } from "node:net";
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { authenticateAgent, isAuthError } from "@/lib/agent-auth";
import { getIpGeoContext } from "@/lib/request-context";
import { getAppConfig } from "@/services/app-config";
import { evaluateNodeAccessAbuseRisk, recordSubscriptionAccess } from "@/services/subscription-risk";
const MAX_EVENTS = 500;
const MAX_TEXT_LENGTH = 200;
const nodeAccessEventSchema = z.object({
clientEmail: z.string().trim().min(1).max(320),
sourceIp: z.string().trim().refine((value) => isIP(value) !== 0, "sourceIp 必须是有效 IP"),
inboundTag: z.string().trim().max(MAX_TEXT_LENGTH).optional().nullable(),
network: z.string().trim().max(16).optional().nullable(),
targetHost: z.string().trim().max(MAX_TEXT_LENGTH).optional().nullable(),
targetPort: z.coerce.number().int().min(0).max(65535).optional().nullable(),
action: z.string().trim().max(32).optional().nullable(),
connectionCount: z.coerce.number().int().min(1).max(100000).optional().default(1),
uniqueTargetCount: z.coerce.number().int().min(0).max(100000).optional().default(0),
firstSeenAt: z.string().trim().max(64).optional().nullable(),
lastSeenAt: z.string().trim().max(64).optional().nullable(),
});
const nodeAccessPayloadSchema = z.object({
events: z.array(nodeAccessEventSchema).min(1).max(MAX_EVENTS),
});
function compactText(value: string | null | undefined) {
const text = value?.trim();
return text ? text.slice(0, MAX_TEXT_LENGTH) : null;
}
function normalizeAction(action: string | null | undefined) {
const normalized = action?.trim().toLowerCase();
return normalized === "rejected" ? "rejected" : "accepted";
}
function buildReason(event: z.infer<typeof nodeAccessEventSchema>, nodeId: string) {
const parts = [
"来源:节点 Xray access log",
"节点:" + nodeId,
event.inboundTag ? "入站:" + event.inboundTag : null,
event.network ? "网络:" + event.network : null,
event.targetPort ? "目标端口:" + event.targetPort : null,
event.targetHost ? "样本目标:" + event.targetHost : null,
"连接数:" + event.connectionCount,
event.uniqueTargetCount ? "不同目标:" + event.uniqueTargetCount : null,
event.firstSeenAt ? "首次:" + event.firstSeenAt : null,
event.lastSeenAt ? "最近:" + event.lastSeenAt : null,
].filter(Boolean);
return parts.join("").slice(0, 1000);
}
export async function POST(req: Request) {
const auth = await authenticateAgent(req);
if (isAuthError(auth)) return auth;
const { nodeId } = auth;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "请求体不是有效 JSON期望格式{ events: [...] }" }, { status: 400 });
}
const parsed = nodeAccessPayloadSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "节点访问日志格式无效" }, { status: 400 });
}
const config = await getAppConfig();
if (!config.subscriptionRiskEnabled || !config.nodeAccessRiskEnabled) {
return NextResponse.json({ ok: true, skipped: parsed.data.events.length, reason: "node_access_risk_disabled" });
}
const clientEmails = [...new Set(parsed.data.events.map((event) => event.clientEmail))];
const clients = await prisma.nodeClient.findMany({
where: {
email: { in: clientEmails },
inbound: { serverId: nodeId },
},
select: {
email: true,
userId: true,
subscriptionId: true,
},
});
const clientByEmail = new Map(clients.map((client) => [client.email, client]));
let processed = 0;
let skipped = 0;
let warnings = 0;
let suspended = 0;
for (const event of parsed.data.events) {
const client = clientByEmail.get(event.clientEmail);
if (!client) {
skipped++;
continue;
}
const action = normalizeAction(event.action);
const allowed = action === "accepted";
const result = await recordSubscriptionAccess({
kind: "SINGLE",
userId: client.userId,
subscriptionId: client.subscriptionId,
context: {
ip: event.sourceIp,
userAgent: "jboard-agent/xray-access-log",
geo: getIpGeoContext(event.sourceIp),
},
allowed,
reason: buildReason({
...event,
inboundTag: compactText(event.inboundTag),
network: compactText(event.network),
targetHost: compactText(event.targetHost),
action,
}, nodeId),
evaluateRisk: allowed,
riskConfig: config,
sourceLabel: "节点真实连接",
});
const abuseResult = allowed
? await evaluateNodeAccessAbuseRisk({
userId: client.userId,
subscriptionId: client.subscriptionId,
ip: event.sourceIp,
connectionCount: event.connectionCount,
uniqueTargetCount: event.uniqueTargetCount,
targetHost: compactText(event.targetHost),
targetPort: event.targetPort ?? null,
config,
})
: { warned: false, suspended: false };
processed++;
if (result.warned || abuseResult.warned) warnings++;
if (result.suspended || abuseResult.suspended) suspended++;
}
return NextResponse.json({ ok: true, processed, skipped, warnings, suspended });
}

View File

@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { authenticateAgent, isAuthError } from "@/lib/agent-auth";
import { normalizeTraceHops, normalizeTraceText } from "@/lib/trace-normalize";
import { classifyTraceRoute } from "@/lib/route-classify";
export async function POST(req: Request) {
const auth = await authenticateAgent(req);
@@ -32,7 +33,8 @@ export async function POST(req: Request) {
for (const trace of body.traces) {
if (!validCarriers.has(trace.carrier)) continue;
const normalizedHops = normalizeTraceHops(trace.hops);
const normalizedSummary = normalizeTraceText(trace.summary) || "路由信息";
const submittedSummary = normalizeTraceText(trace.summary);
const normalizedSummary = classifyTraceRoute({ summary: submittedSummary, hops: normalizedHops });
const hopCount = Number(trace.hopCount);
const normalizedHopCount =
Number.isFinite(hopCount) && hopCount > 0

View File

@@ -1,5 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { classifyTraceRoute } from "@/lib/route-classify";
import { normalizeTraceHops } from "@/lib/trace-normalize";
const MAX_NODE_IDS = 100;
@@ -24,7 +26,7 @@ export async function GET(req: Request) {
}
result[row.nodeId].push({
carrier: row.carrier,
summary: row.summary,
summary: classifyTraceRoute({ summary: row.summary, hops: normalizeTraceHops(row.hops) }),
hopCount: row.hopCount,
hops: row.hops,
updatedAt: row.updatedAt.toISOString(),