feat: enhance subscription risk review workflow

This commit is contained in:
JetSprow
2026-04-29 16:12:51 +10:00
parent 086934198a
commit 823b31363a
20 changed files with 1866 additions and 138 deletions

View File

@@ -0,0 +1,385 @@
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<SubscriptionRiskAccessLog, "country" | "region" | "regionCode" | "city">) {
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<string>, 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 "国家异常暂停";
}
}
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<string>();
const uniqueCountries = new Set<string>();
const uniqueRegions = new Set<string>();
const uniqueCities = new Set<string>();
const countryMap = new Map<string, {
country: string;
accessCount: number;
ips: Set<string>;
regions: Set<string>;
cities: Set<string>;
}>();
const pointMap = new Map<string, SubscriptionRiskGeoPoint>();
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<string>(),
regions: new Set<string>(),
cities: new Set<string>(),
};
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,
},
});
}