mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: enhance subscription risk review workflow
This commit is contained in:
385
src/services/subscription-risk-review.ts
Normal file
385
src/services/subscription-risk-review.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user