import type { Prisma, SubscriptionAccessKind, SubscriptionRiskEvent, SubscriptionRiskReason, } from "@prisma/client"; import { prisma, type DbClient } from "@/lib/prisma"; import { formatDate } from "@/lib/utils"; export const subscriptionRiskAccessLogSelect = { id: true, userId: true, subscriptionId: true, kind: true, ip: true, userAgent: true, country: true, region: true, regionCode: true, city: true, latitude: true, longitude: true, geoSource: true, allowed: true, reason: true, createdAt: true, } satisfies Prisma.SubscriptionAccessLogSelect; export type SubscriptionRiskAccessLog = Prisma.SubscriptionAccessLogGetPayload<{ select: typeof subscriptionRiskAccessLogSelect; }>; export type SubscriptionRiskUserBrief = { id: string; email: string; name: string | null; status?: string; createdAt?: Date; }; export type SubscriptionRiskSubscriptionBrief = { id: string; status: string; endDate: Date; plan: { name: string; type: string; }; }; export type SubscriptionRiskGeoPoint = { key: string; ip: string; country: string; region: string; city: string; latitude: number; longitude: number; accessCount: number; lastSeenAt: string; allowed: boolean; }; export type SubscriptionRiskCountrySummary = { country: string; accessCount: number; ipCount: number; regionCount: number; cityCount: number; topRegions: string[]; topCities: string[]; }; export type SubscriptionRiskRecentAccess = { id: string; ip: string; location: string; allowed: boolean; reason: string | null; userAgent: string | null; createdAt: string; }; export type SubscriptionRiskGeoSummary = { totalLogs: number; allowedLogs: number; blockedLogs: number; uniqueIpCount: number; uniqueCountryCount: number; uniqueRegionCount: number; uniqueCityCount: number; countries: SubscriptionRiskCountrySummary[]; points: SubscriptionRiskGeoPoint[]; recentAccesses: SubscriptionRiskRecentAccess[]; }; type RiskEventScope = Pick< SubscriptionRiskEvent, "kind" | "userId" | "subscriptionId" | "windowStartedAt" | "createdAt" >; function safeLabel(value: string | null | undefined, fallback: string) { const trimmed = value?.trim(); return trimmed || fallback; } function normalizeKey(value: string | null | undefined, fallback: string) { return safeLabel(value, fallback).toLowerCase(); } function locationLabel(log: Pick) { const parts = [log.country, log.region || log.regionCode, log.city] .map((part) => part?.trim()) .filter(Boolean); return parts.length > 0 ? parts.join(" / ") : "未知地区"; } function parseCoordinate(value: string | null | undefined, min: number, max: number) { if (!value) return null; const parsed = Number.parseFloat(value); if (!Number.isFinite(parsed) || parsed < min || parsed > max) return null; return parsed; } function uniquePreview(values: Iterable, limit = 4) { const list = Array.from(new Set(Array.from(values).filter(Boolean))); if (list.length <= limit) return list; return [...list.slice(0, limit), `等 ${list.length} 项`]; } export function reasonLabel(reason: SubscriptionRiskReason) { switch (reason) { case "CITY_VARIANCE_WARNING": return "城市异常警告"; case "CITY_VARIANCE_SUSPEND": return "城市异常暂停"; case "REGION_VARIANCE_WARNING": return "省/地区异常警告"; case "REGION_VARIANCE_SUSPEND": return "省/地区异常暂停"; case "COUNTRY_VARIANCE_WARNING": 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 "目标分散暂停"; } } export function riskKindLabel(kind: SubscriptionAccessKind) { return kind === "AGGREGATE" ? "总订阅" : "单订阅"; } export function getSubscriptionRiskLogWhere(event: RiskEventScope): Prisma.SubscriptionAccessLogWhereInput { const base: Prisma.SubscriptionAccessLogWhereInput = { createdAt: { gte: event.windowStartedAt, lte: event.createdAt, }, }; if (event.kind === "SINGLE" && event.subscriptionId) { return { ...base, kind: "SINGLE", subscriptionId: event.subscriptionId, }; } if (event.kind === "AGGREGATE" && event.userId) { return { ...base, kind: "AGGREGATE", userId: event.userId, }; } return { ...base, id: "__missing-risk-scope__", }; } export async function getSubscriptionRiskAccessLogsForEvent( event: RiskEventScope, db: DbClient = prisma, take = 120, ) { return db.subscriptionAccessLog.findMany({ where: getSubscriptionRiskLogWhere(event), select: subscriptionRiskAccessLogSelect, orderBy: { createdAt: "desc" }, take, }); } export function buildSubscriptionRiskGeoSummary(logs: SubscriptionRiskAccessLog[]): SubscriptionRiskGeoSummary { const uniqueIps = new Set(); const uniqueCountries = new Set(); const uniqueRegions = new Set(); const uniqueCities = new Set(); const countryMap = new Map; regions: Set; cities: Set; }>(); const pointMap = new Map(); for (const log of logs) { uniqueIps.add(log.ip); const country = safeLabel(log.country, "未知国家/地区"); const region = safeLabel(log.region || log.regionCode, "未知省/地区"); const city = safeLabel(log.city, "未知城市"); const countryKey = normalizeKey(log.country, "unknown-country"); const regionKey = [countryKey, normalizeKey(log.regionCode || log.region, "unknown-region")].join(":"); const cityKey = [regionKey, normalizeKey(log.city, "unknown-city")].join(":"); uniqueCountries.add(countryKey); if (log.region || log.regionCode) uniqueRegions.add(regionKey); if (log.city) uniqueCities.add(cityKey); const countryItem = countryMap.get(countryKey) ?? { country, accessCount: 0, ips: new Set(), regions: new Set(), cities: new Set(), }; countryItem.accessCount += 1; countryItem.ips.add(log.ip); if (log.region || log.regionCode) countryItem.regions.add(region); if (log.city) countryItem.cities.add(city); countryMap.set(countryKey, countryItem); const latitude = parseCoordinate(log.latitude, -90, 90); const longitude = parseCoordinate(log.longitude, -180, 180); if (latitude == null || longitude == null) continue; const pointKey = [log.ip, latitude.toFixed(3), longitude.toFixed(3)].join(":"); const existing = pointMap.get(pointKey); if (existing) { existing.accessCount += 1; if (new Date(log.createdAt).getTime() > new Date(existing.lastSeenAt).getTime()) { existing.lastSeenAt = log.createdAt.toISOString(); existing.allowed = log.allowed; } } else { pointMap.set(pointKey, { key: pointKey, ip: log.ip, country, region, city, latitude, longitude, accessCount: 1, lastSeenAt: log.createdAt.toISOString(), allowed: log.allowed, }); } } const countries = Array.from(countryMap.values()) .map((item) => ({ country: item.country, accessCount: item.accessCount, ipCount: item.ips.size, regionCount: item.regions.size, cityCount: item.cities.size, topRegions: uniquePreview(item.regions), topCities: uniquePreview(item.cities), })) .sort((a, b) => b.accessCount - a.accessCount || b.ipCount - a.ipCount || a.country.localeCompare(b.country)); return { totalLogs: logs.length, allowedLogs: logs.filter((log) => log.allowed).length, blockedLogs: logs.filter((log) => !log.allowed).length, uniqueIpCount: uniqueIps.size, uniqueCountryCount: uniqueCountries.size, uniqueRegionCount: uniqueRegions.size, uniqueCityCount: uniqueCities.size, countries, points: Array.from(pointMap.values()).sort((a, b) => b.accessCount - a.accessCount), recentAccesses: logs.slice(0, 12).map((log) => ({ id: log.id, ip: log.ip, location: locationLabel(log), allowed: log.allowed, reason: log.reason, userAgent: log.userAgent, createdAt: log.createdAt.toISOString(), })), }; } function formatCountrySummary(summary: SubscriptionRiskGeoSummary) { if (summary.countries.length === 0) return "暂无可识别地区。"; return summary.countries .slice(0, 8) .map((country) => { const regions = country.topRegions.length > 0 ? country.topRegions.join("、") : "未识别"; const cities = country.topCities.length > 0 ? country.topCities.join("、") : "未识别"; return `- ${country.country}:${country.ipCount} 个 IP,${country.regionCount} 个省/地区,${country.cityCount} 个城市,访问 ${country.accessCount} 次;省/地区:${regions};城市:${cities}`; }) .join("\n"); } function formatAccessEvidence(summary: SubscriptionRiskGeoSummary) { if (summary.recentAccesses.length === 0) return "暂无访问明细。"; return summary.recentAccesses .slice(0, 10) .map((access) => { const result = access.allowed ? "放行" : access.reason || "拦截"; return `- ${formatDate(access.createdAt)} | ${access.ip} | ${access.location} | ${result}`; }) .join("\n"); } export function buildSubscriptionRiskReport(input: { event: SubscriptionRiskEvent; logs: SubscriptionRiskAccessLog[]; user?: SubscriptionRiskUserBrief | null; subscription?: SubscriptionRiskSubscriptionBrief | null; }) { const { event, logs, user, subscription } = input; const summary = buildSubscriptionRiskGeoSummary(logs); const target = subscription ? `${subscription.plan.name}(${subscription.plan.type},当前状态:${subscription.status})` : "用户总订阅"; const userLabel = user ? `${user.email}${user.name ? `(${user.name})` : ""}` : event.userId ?? "未知用户"; const windowRange = `${formatDate(event.windowStartedAt)} 至 ${formatDate(event.createdAt)}`; const actionSuggestion = event.level === "SUSPENDED" ? "建议保持暂停,等待用户确认是否本人跨地区使用、订阅链接是否外泄或节点连接是否被共享,并在工单中补充说明后再解除限制。" : "建议先联系用户确认近期访问/连接来源;如果用户无法解释这些地区/IP,建议重置订阅链接并临时暂停相关订阅。"; return [ "订阅风控风险报告", "", `用户:${userLabel}`, `风控范围:${riskKindLabel(event.kind)} / ${target}`, `事件编号:${event.id}`, `触发时间:${formatDate(event.createdAt)}`, `检测窗口:${windowRange}`, `风险判定:${reasonLabel(event.reason)}(${event.level === "SUSPENDED" ? "已暂停" : "警告"})`, "", "触发原因", event.message, "", "地区与 IP 概览", `- 访问记录:${summary.totalLogs} 条,其中放行 ${summary.allowedLogs} 条,拦截 ${summary.blockedLogs} 条`, `- 不同 IP:${summary.uniqueIpCount} 个`, `- 不同国家/地区:${summary.uniqueCountryCount} 个,不同省/地区:${summary.uniqueRegionCount} 个,不同城市:${summary.uniqueCityCount} 个`, formatCountrySummary(summary), "", "关键访问证据", formatAccessEvidence(summary), "", "处理建议", actionSuggestion, ].join("\n"); } export async function getActiveSubscriptionRiskRestriction(userId: string, db: DbClient = prisma) { return db.subscriptionRiskEvent.findFirst({ where: { userId, userRestrictionActive: true, reportSentAt: { not: null }, }, orderBy: { reportSentAt: "desc" }, select: { id: true, level: true, reason: true, message: true, riskReport: true, reportSentAt: true, createdAt: true, }, }); }