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:
@@ -60,6 +60,11 @@ enum SubscriptionRiskReviewStatus {
|
|||||||
RESOLVED
|
RESOLVED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SubscriptionRiskFinalAction {
|
||||||
|
RESTORE_ACCESS
|
||||||
|
KEEP_RESTRICTED
|
||||||
|
}
|
||||||
|
|
||||||
enum PlanPricingMode {
|
enum PlanPricingMode {
|
||||||
TRAFFIC_SLIDER
|
TRAFFIC_SLIDER
|
||||||
FIXED_PACKAGE
|
FIXED_PACKAGE
|
||||||
@@ -329,28 +334,37 @@ model SubscriptionAccessLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model SubscriptionRiskEvent {
|
model SubscriptionRiskEvent {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String?
|
userId String?
|
||||||
subscriptionId String?
|
subscriptionId String?
|
||||||
kind SubscriptionAccessKind
|
kind SubscriptionAccessKind
|
||||||
level SubscriptionRiskLevel
|
level SubscriptionRiskLevel
|
||||||
reason SubscriptionRiskReason
|
reason SubscriptionRiskReason
|
||||||
ip String?
|
ip String?
|
||||||
countryCount Int @default(0)
|
countryCount Int @default(0)
|
||||||
regionCount Int @default(0)
|
regionCount Int @default(0)
|
||||||
cityCount Int @default(0)
|
cityCount Int @default(0)
|
||||||
countryKeys Json?
|
countryKeys Json?
|
||||||
regionKeys Json?
|
regionKeys Json?
|
||||||
cityKeys Json?
|
cityKeys Json?
|
||||||
message String
|
message String
|
||||||
dedupeKey String @unique
|
dedupeKey String @unique
|
||||||
windowStartedAt DateTime
|
windowStartedAt DateTime
|
||||||
reviewStatus SubscriptionRiskReviewStatus @default(OPEN)
|
reviewStatus SubscriptionRiskReviewStatus @default(OPEN)
|
||||||
reviewNote String?
|
reviewNote String?
|
||||||
reviewedAt DateTime?
|
reviewedAt DateTime?
|
||||||
reviewedById String?
|
reviewedById String?
|
||||||
reviewedByEmail String?
|
reviewedByEmail String?
|
||||||
createdAt DateTime @default(now())
|
riskReport String?
|
||||||
|
reportGeneratedAt DateTime?
|
||||||
|
reportSentAt DateTime?
|
||||||
|
userRestrictionActive Boolean @default(false)
|
||||||
|
userRestrictionResolvedAt DateTime?
|
||||||
|
finalAction SubscriptionRiskFinalAction?
|
||||||
|
finalActionAt DateTime?
|
||||||
|
finalActionById String?
|
||||||
|
finalActionByEmail String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([subscriptionId, createdAt])
|
@@index([subscriptionId, createdAt])
|
||||||
@@index([userId, kind, createdAt])
|
@@index([userId, kind, createdAt])
|
||||||
@@ -358,6 +372,8 @@ model SubscriptionRiskEvent {
|
|||||||
@@index([reason, createdAt])
|
@@index([reason, createdAt])
|
||||||
@@index([reviewStatus, createdAt])
|
@@index([reviewStatus, createdAt])
|
||||||
@@index([reviewedById])
|
@@index([reviewedById])
|
||||||
|
@@index([userRestrictionActive, reportSentAt])
|
||||||
|
@@index([finalAction, finalActionAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model StreamingService {
|
model StreamingService {
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import type { SubscriptionRiskReviewStatus } from "@prisma/client";
|
import type {
|
||||||
|
SubscriptionRiskFinalAction,
|
||||||
|
SubscriptionRiskReviewStatus,
|
||||||
|
} from "@prisma/client";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireAdmin } from "@/lib/require-auth";
|
import { requireAdmin } from "@/lib/require-auth";
|
||||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
|
import { createNotification } from "@/services/notifications";
|
||||||
|
import {
|
||||||
|
buildSubscriptionRiskReport,
|
||||||
|
getSubscriptionRiskAccessLogsForEvent,
|
||||||
|
reasonLabel,
|
||||||
|
riskKindLabel,
|
||||||
|
} from "@/services/subscription-risk-review";
|
||||||
import { activateSubscription } from "./subscriptions";
|
import { activateSubscription } from "./subscriptions";
|
||||||
|
|
||||||
const REVIEW_STATUSES = ["OPEN", "ACKNOWLEDGED", "RESOLVED"] as const;
|
const REVIEW_STATUSES = ["OPEN", "ACKNOWLEDGED", "RESOLVED"] as const;
|
||||||
|
const FINAL_ACTIONS = ["RESTORE_ACCESS", "KEEP_RESTRICTED"] as const;
|
||||||
|
|
||||||
function assertReviewStatus(status: string): asserts status is SubscriptionRiskReviewStatus {
|
function assertReviewStatus(status: string): asserts status is SubscriptionRiskReviewStatus {
|
||||||
if (!REVIEW_STATUSES.includes(status as SubscriptionRiskReviewStatus)) {
|
if (!REVIEW_STATUSES.includes(status as SubscriptionRiskReviewStatus)) {
|
||||||
@@ -15,6 +26,12 @@ function assertReviewStatus(status: string): asserts status is SubscriptionRiskR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertFinalAction(action: string): asserts action is SubscriptionRiskFinalAction {
|
||||||
|
if (!FINAL_ACTIONS.includes(action as SubscriptionRiskFinalAction)) {
|
||||||
|
throw new Error("不支持的最终处置");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function reviewStatusLabel(status: SubscriptionRiskReviewStatus) {
|
function reviewStatusLabel(status: SubscriptionRiskReviewStatus) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "OPEN":
|
case "OPEN":
|
||||||
@@ -26,16 +43,30 @@ function reviewStatusLabel(status: SubscriptionRiskReviewStatus) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function finalActionLabel(action: SubscriptionRiskFinalAction) {
|
||||||
|
switch (action) {
|
||||||
|
case "RESTORE_ACCESS":
|
||||||
|
return "解除限制";
|
||||||
|
case "KEEP_RESTRICTED":
|
||||||
|
return "保持限制";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeNote(note: string | null | undefined) {
|
function normalizeNote(note: string | null | undefined) {
|
||||||
const value = note?.trim();
|
const value = note?.trim();
|
||||||
return value ? value.slice(0, 1000) : null;
|
return value ? value.slice(0, 1000) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function revalidateRiskViews(subscriptionId?: string | null) {
|
function revalidateRiskViews(subscriptionId?: string | null, userId?: string | null) {
|
||||||
revalidatePath("/admin/subscription-risk");
|
revalidatePath("/admin/subscription-risk");
|
||||||
revalidatePath("/admin/audit-logs");
|
revalidatePath("/admin/audit-logs");
|
||||||
revalidatePath("/admin/subscriptions");
|
revalidatePath("/admin/subscriptions");
|
||||||
|
revalidatePath("/admin/users");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
revalidatePath("/support");
|
||||||
|
revalidatePath("/notifications");
|
||||||
if (subscriptionId) revalidatePath(`/admin/subscriptions/${subscriptionId}`);
|
if (subscriptionId) revalidatePath(`/admin/subscriptions/${subscriptionId}`);
|
||||||
|
if (userId) revalidatePath(`/admin/users/${userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRiskTargetLabel(input: {
|
async function getRiskTargetLabel(input: {
|
||||||
@@ -65,6 +96,106 @@ async function getRiskTargetLabel(input: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getRiskEventContext(eventId: string) {
|
||||||
|
const event = await prisma.subscriptionRiskEvent.findUniqueOrThrow({
|
||||||
|
where: { id: eventId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const [user, subscription, logs] = await Promise.all([
|
||||||
|
event.userId
|
||||||
|
? prisma.user.findUnique({
|
||||||
|
where: { id: event.userId },
|
||||||
|
select: { id: true, email: true, name: true, status: true, createdAt: true },
|
||||||
|
})
|
||||||
|
: Promise.resolve(null),
|
||||||
|
event.subscriptionId
|
||||||
|
? prisma.userSubscription.findUnique({
|
||||||
|
where: { id: event.subscriptionId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
endDate: true,
|
||||||
|
plan: { select: { name: true, type: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: Promise.resolve(null),
|
||||||
|
getSubscriptionRiskAccessLogsForEvent(event),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { event, user, subscription, logs };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildAndSaveRiskReport(eventId: string) {
|
||||||
|
const { event, user, subscription, logs } = await getRiskEventContext(eventId);
|
||||||
|
const report = buildSubscriptionRiskReport({ event, user, subscription, logs });
|
||||||
|
const updated = await prisma.subscriptionRiskEvent.update({
|
||||||
|
where: { id: event.id },
|
||||||
|
data: {
|
||||||
|
riskReport: report,
|
||||||
|
reportGeneratedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { event: updated, user, subscription, logs, report };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyUserWithRiskReport(input: {
|
||||||
|
eventId: string;
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
|
await createNotification({
|
||||||
|
userId: input.userId,
|
||||||
|
type: "SUBSCRIPTION",
|
||||||
|
level: "ERROR",
|
||||||
|
title: "订阅风控处理通知",
|
||||||
|
body: "你的订阅访问存在异常地区/IP 记录,账户操作已临时限制。请新建工单联系客服核验。",
|
||||||
|
link: `/support?riskEventId=${input.eventId}`,
|
||||||
|
dedupeKey: `risk:report-sent:${input.eventId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreSubscriptionsForEvent(event: {
|
||||||
|
userId: string | null;
|
||||||
|
subscriptionId: string | null;
|
||||||
|
kind: "SINGLE" | "AGGREGATE";
|
||||||
|
}) {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (event.kind === "SINGLE" && event.subscriptionId) {
|
||||||
|
const subscription = await prisma.userSubscription.findUnique({
|
||||||
|
where: { id: event.subscriptionId },
|
||||||
|
select: { id: true, status: true, endDate: true },
|
||||||
|
});
|
||||||
|
if (subscription?.status === "SUSPENDED" && subscription.endDate > now) {
|
||||||
|
await activateSubscription(subscription.id);
|
||||||
|
return [subscription.id];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === "AGGREGATE" && event.userId) {
|
||||||
|
const subscriptions = await prisma.userSubscription.findMany({
|
||||||
|
where: {
|
||||||
|
userId: event.userId,
|
||||||
|
status: "SUSPENDED",
|
||||||
|
endDate: { gt: now },
|
||||||
|
plan: { type: "PROXY" },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoredIds: string[] = [];
|
||||||
|
for (const subscription of subscriptions) {
|
||||||
|
await activateSubscription(subscription.id);
|
||||||
|
restoredIds.push(subscription.id);
|
||||||
|
}
|
||||||
|
return restoredIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateSubscriptionRiskReview(
|
export async function updateSubscriptionRiskReview(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
status: SubscriptionRiskReviewStatus,
|
status: SubscriptionRiskReviewStatus,
|
||||||
@@ -108,6 +239,12 @@ export async function updateSubscriptionRiskReview(
|
|||||||
reviewedAt,
|
reviewedAt,
|
||||||
reviewedById: actor.userId ?? null,
|
reviewedById: actor.userId ?? null,
|
||||||
reviewedByEmail: actor.email ?? null,
|
reviewedByEmail: actor.email ?? null,
|
||||||
|
...(status === "RESOLVED"
|
||||||
|
? {
|
||||||
|
userRestrictionActive: false,
|
||||||
|
userRestrictionResolvedAt: reviewedAt,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,6 +272,189 @@ export async function updateSubscriptionRiskReview(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidateRiskViews(event.subscriptionId);
|
revalidateRiskViews(event.subscriptionId, event.userId);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateSubscriptionRiskReport(eventId: string) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const actor = actorFromSession(session);
|
||||||
|
const { event, report } = await buildAndSaveRiskReport(eventId);
|
||||||
|
const targetLabel = await getRiskTargetLabel({
|
||||||
|
userId: event.userId,
|
||||||
|
subscriptionId: event.subscriptionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor,
|
||||||
|
action: "risk.subscription.report.generate",
|
||||||
|
targetType: event.subscriptionId ? "UserSubscription" : "User",
|
||||||
|
targetId: event.subscriptionId ?? event.userId ?? event.id,
|
||||||
|
targetLabel,
|
||||||
|
message: "生成订阅风控风险报告",
|
||||||
|
metadata: {
|
||||||
|
eventId: event.id,
|
||||||
|
kind: event.kind,
|
||||||
|
level: event.level,
|
||||||
|
reason: event.reason,
|
||||||
|
reportLength: report.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateRiskViews(event.subscriptionId, event.userId);
|
||||||
|
return { ok: true, report };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendSubscriptionRiskReport(eventId: string) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const actor = actorFromSession(session);
|
||||||
|
let { event, user, report } = await getRiskEventContext(eventId).then((context) => ({
|
||||||
|
event: context.event,
|
||||||
|
user: context.user,
|
||||||
|
report: context.event.riskReport,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!event.userId || !user) {
|
||||||
|
throw new Error("该风控事件没有关联用户,无法发送用户通知");
|
||||||
|
}
|
||||||
|
const reportUserId = event.userId;
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
const generated = await buildAndSaveRiskReport(event.id);
|
||||||
|
event = generated.event;
|
||||||
|
user = generated.user;
|
||||||
|
report = generated.report;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await prisma.subscriptionRiskEvent.update({
|
||||||
|
where: { id: event.id },
|
||||||
|
data: {
|
||||||
|
reportSentAt: now,
|
||||||
|
userRestrictionActive: true,
|
||||||
|
reviewStatus: event.reviewStatus === "OPEN" ? "ACKNOWLEDGED" : event.reviewStatus,
|
||||||
|
reviewedAt: event.reviewStatus === "OPEN" ? now : event.reviewedAt,
|
||||||
|
reviewedById: event.reviewStatus === "OPEN" ? actor.userId ?? null : event.reviewedById,
|
||||||
|
reviewedByEmail: event.reviewStatus === "OPEN" ? actor.email ?? null : event.reviewedByEmail,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifyUserWithRiskReport({ eventId: event.id, userId: reportUserId });
|
||||||
|
|
||||||
|
const targetLabel = await getRiskTargetLabel({
|
||||||
|
userId: event.userId,
|
||||||
|
subscriptionId: event.subscriptionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor,
|
||||||
|
action: "risk.subscription.report.send",
|
||||||
|
targetType: event.subscriptionId ? "UserSubscription" : "User",
|
||||||
|
targetId: event.subscriptionId ?? event.userId ?? event.id,
|
||||||
|
targetLabel,
|
||||||
|
message: `向用户发送订阅风控报告并启用强制通知:${user?.email ?? event.userId}`,
|
||||||
|
metadata: {
|
||||||
|
eventId: event.id,
|
||||||
|
reason: event.reason,
|
||||||
|
riskReasonLabel: reasonLabel(event.reason),
|
||||||
|
riskKind: riskKindLabel(event.kind),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateRiskViews(event.subscriptionId, event.userId);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function finalizeSubscriptionRiskDecision(
|
||||||
|
eventId: string,
|
||||||
|
action: SubscriptionRiskFinalAction,
|
||||||
|
note?: string,
|
||||||
|
options: { notifyUser?: boolean } = {},
|
||||||
|
) {
|
||||||
|
assertFinalAction(action);
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const actor = actorFromSession(session);
|
||||||
|
let { event, report } = await getRiskEventContext(eventId).then((context) => ({
|
||||||
|
event: context.event,
|
||||||
|
report: context.event.riskReport,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (options.notifyUser && !event.userId) {
|
||||||
|
throw new Error("该风控事件没有关联用户,无法发送用户通知");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.notifyUser && !report) {
|
||||||
|
const generated = await buildAndSaveRiskReport(event.id);
|
||||||
|
event = generated.event;
|
||||||
|
report = generated.report;
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoredSubscriptionIds = action === "RESTORE_ACCESS"
|
||||||
|
? await restoreSubscriptionsForEvent(event)
|
||||||
|
: [];
|
||||||
|
const normalizedNote = normalizeNote(note);
|
||||||
|
const now = new Date();
|
||||||
|
const shouldKeepRestriction = action === "KEEP_RESTRICTED" && (event.userRestrictionActive || options.notifyUser);
|
||||||
|
|
||||||
|
await prisma.subscriptionRiskEvent.update({
|
||||||
|
where: { id: event.id },
|
||||||
|
data: {
|
||||||
|
reviewStatus: "RESOLVED",
|
||||||
|
reviewNote: normalizedNote,
|
||||||
|
reviewedAt: now,
|
||||||
|
reviewedById: actor.userId ?? null,
|
||||||
|
reviewedByEmail: actor.email ?? null,
|
||||||
|
finalAction: action,
|
||||||
|
finalActionAt: now,
|
||||||
|
finalActionById: actor.userId ?? null,
|
||||||
|
finalActionByEmail: actor.email ?? null,
|
||||||
|
userRestrictionActive: shouldKeepRestriction,
|
||||||
|
userRestrictionResolvedAt: action === "RESTORE_ACCESS" ? now : event.userRestrictionResolvedAt,
|
||||||
|
...(options.notifyUser
|
||||||
|
? {
|
||||||
|
reportSentAt: event.reportSentAt ?? now,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (action === "RESTORE_ACCESS" && event.userId) {
|
||||||
|
await createNotification({
|
||||||
|
userId: event.userId,
|
||||||
|
type: "SUBSCRIPTION",
|
||||||
|
level: "SUCCESS",
|
||||||
|
title: "订阅风控限制已解除",
|
||||||
|
body: "管理员已完成订阅风控复核,你的账户操作限制已解除。",
|
||||||
|
link: "/subscriptions",
|
||||||
|
dedupeKey: `risk:restriction-restored:${event.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "KEEP_RESTRICTED" && options.notifyUser && event.userId && report) {
|
||||||
|
await notifyUserWithRiskReport({ eventId: event.id, userId: event.userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetLabel = await getRiskTargetLabel({
|
||||||
|
userId: event.userId,
|
||||||
|
subscriptionId: event.subscriptionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor,
|
||||||
|
action: "risk.subscription.finalize",
|
||||||
|
targetType: event.subscriptionId ? "UserSubscription" : "User",
|
||||||
|
targetId: event.subscriptionId ?? event.userId ?? event.id,
|
||||||
|
targetLabel,
|
||||||
|
message: `订阅风控最终处置:${finalActionLabel(action)}`,
|
||||||
|
metadata: {
|
||||||
|
eventId: event.id,
|
||||||
|
finalAction: action,
|
||||||
|
notifyUser: options.notifyUser === true,
|
||||||
|
note: normalizedNote,
|
||||||
|
restoredSubscriptionIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateRiskViews(event.subscriptionId, event.userId);
|
||||||
|
return { ok: true, restoredSubscriptionIds };
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export async function createUser(formData: FormData) {
|
|||||||
message: `创建用户 ${user.email}`,
|
message: `创建用户 ${user.email}`,
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/users");
|
revalidatePath("/admin/users");
|
||||||
|
revalidatePath(`/admin/users/${user.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUser(id: string, formData: FormData) {
|
export async function updateUser(id: string, formData: FormData) {
|
||||||
@@ -72,6 +73,7 @@ export async function updateUser(id: string, formData: FormData) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/users");
|
revalidatePath("/admin/users");
|
||||||
|
revalidatePath(`/admin/users/${user.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUserStatus(id: string, status: "ACTIVE" | "DISABLED" | "BANNED") {
|
export async function updateUserStatus(id: string, status: "ACTIVE" | "DISABLED" | "BANNED") {
|
||||||
@@ -86,6 +88,7 @@ export async function updateUserStatus(id: string, status: "ACTIVE" | "DISABLED"
|
|||||||
message: `将用户 ${user.email} 状态改为 ${status}`,
|
message: `将用户 ${user.email} 状态改为 ${status}`,
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/users");
|
revalidatePath("/admin/users");
|
||||||
|
revalidatePath(`/admin/users/${user.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteUser(id: string) {
|
export async function deleteUser(id: string) {
|
||||||
@@ -100,6 +103,7 @@ export async function deleteUser(id: string) {
|
|||||||
message: `删除用户 ${user.email}`,
|
message: `删除用户 ${user.email}`,
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/users");
|
revalidatePath("/admin/users");
|
||||||
|
revalidatePath(`/admin/users/${user.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function batchUpdateUserStatus(formData: FormData) {
|
export async function batchUpdateUserStatus(formData: FormData) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getServerSession } from "next-auth";
|
import { requireAuth } from "@/lib/require-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getErrorMessage } from "@/lib/errors";
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
import {
|
import {
|
||||||
@@ -114,8 +113,7 @@ export async function purchaseProxy(
|
|||||||
trafficGb: number,
|
trafficGb: number,
|
||||||
selectedInboundId: string,
|
selectedInboundId: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await requireAuth();
|
||||||
if (!session) throw new Error("未登录");
|
|
||||||
await assertNoPendingOrder(session.user.id);
|
await assertNoPendingOrder(session.user.id);
|
||||||
|
|
||||||
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
|
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
|
||||||
@@ -208,8 +206,7 @@ export async function purchaseProxy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function purchaseStreaming(planId: string): Promise<string> {
|
export async function purchaseStreaming(planId: string): Promise<string> {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await requireAuth();
|
||||||
if (!session) throw new Error("未登录");
|
|
||||||
await assertNoPendingOrder(session.user.id);
|
await assertNoPendingOrder(session.user.id);
|
||||||
|
|
||||||
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
|
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
|
||||||
@@ -257,8 +254,7 @@ export async function purchaseRenewal(
|
|||||||
renewalDays?: number,
|
renewalDays?: number,
|
||||||
): Promise<PurchaseRenewalResult> {
|
): Promise<PurchaseRenewalResult> {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await requireAuth();
|
||||||
if (!session) throw new Error("未登录");
|
|
||||||
await assertNoPendingOrder(session.user.id);
|
await assertNoPendingOrder(session.user.id);
|
||||||
|
|
||||||
const subscription = await prisma.userSubscription.findFirst({
|
const subscription = await prisma.userSubscription.findFirst({
|
||||||
@@ -299,8 +295,7 @@ export async function purchaseTrafficTopup(
|
|||||||
subscriptionId: string,
|
subscriptionId: string,
|
||||||
trafficGb: number,
|
trafficGb: number,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await requireAuth();
|
||||||
if (!session) throw new Error("未登录");
|
|
||||||
await assertNoPendingOrder(session.user.id);
|
await assertNoPendingOrder(session.user.id);
|
||||||
|
|
||||||
if (!Number.isFinite(trafficGb) || trafficGb <= 0 || !Number.isInteger(trafficGb)) {
|
if (!Number.isFinite(trafficGb) || trafficGb <= 0 || !Number.isInteger(trafficGb)) {
|
||||||
@@ -353,8 +348,7 @@ export async function queryPlanNextAvailability(planId: string): Promise<{
|
|||||||
message: string;
|
message: string;
|
||||||
nextAvailableAt: string | null;
|
nextAvailableAt: string | null;
|
||||||
}> {
|
}> {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await requireAuth();
|
||||||
if (!session) throw new Error("未登录");
|
|
||||||
|
|
||||||
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
|
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
|
||||||
where: { id: planId },
|
where: { id: planId },
|
||||||
|
|||||||
@@ -17,26 +17,45 @@ const createTicketSchema = z.object({
|
|||||||
category: z.string().trim().optional(),
|
category: z.string().trim().optional(),
|
||||||
priority: z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]).default("NORMAL"),
|
priority: z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]).default("NORMAL"),
|
||||||
body: z.string().trim().min(1, "内容不能为空"),
|
body: z.string().trim().min(1, "内容不能为空"),
|
||||||
|
riskEventId: z.string().trim().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function createSupportTicket(formData: FormData) {
|
export async function createSupportTicket(formData: FormData) {
|
||||||
const session = await requireAuth();
|
const session = await requireAuth({ allowDuringRiskRestriction: true });
|
||||||
const data = createTicketSchema.parse(Object.fromEntries(formData));
|
const data = createTicketSchema.parse(Object.fromEntries(formData));
|
||||||
const attachments = parseSupportAttachments(formData.getAll("attachments"));
|
const attachments = parseSupportAttachments(formData.getAll("attachments"));
|
||||||
|
const riskEvent = data.riskEventId
|
||||||
|
? await prisma.subscriptionRiskEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
id: data.riskEventId,
|
||||||
|
userId: session.user.id,
|
||||||
|
reportSentAt: { not: null },
|
||||||
|
},
|
||||||
|
select: { id: true, message: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (data.riskEventId && !riskEvent) {
|
||||||
|
throw new Error("关联风控事件不存在或不属于当前用户");
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = riskEvent
|
||||||
|
? data.body + "\n\n关联订阅风控事件:" + riskEvent.id + "\n系统判定:" + riskEvent.message
|
||||||
|
: data.body;
|
||||||
|
|
||||||
const ticket = await prisma.supportTicket.create({
|
const ticket = await prisma.supportTicket.create({
|
||||||
data: {
|
data: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
subject: data.subject,
|
subject: data.subject,
|
||||||
category: data.category || null,
|
category: data.category || (riskEvent ? "订阅风控" : null),
|
||||||
priority: data.priority,
|
priority: riskEvent ? "HIGH" : data.priority,
|
||||||
status: "OPEN",
|
status: "OPEN",
|
||||||
lastReplyAt: new Date(),
|
lastReplyAt: new Date(),
|
||||||
replies: {
|
replies: {
|
||||||
create: {
|
create: {
|
||||||
authorUserId: session.user.id,
|
authorUserId: session.user.id,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
body: data.body,
|
body,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -66,7 +85,7 @@ export async function createSupportTicket(formData: FormData) {
|
|||||||
type: "SYSTEM",
|
type: "SYSTEM",
|
||||||
level: "INFO",
|
level: "INFO",
|
||||||
title: "新工单待处理",
|
title: "新工单待处理",
|
||||||
body: `收到新工单:${data.subject}`,
|
body: riskEvent ? `收到订阅风控复核工单:${data.subject}` : `收到新工单:${data.subject}`,
|
||||||
link: `/admin/support/${ticket.id}`,
|
link: `/admin/support/${ticket.id}`,
|
||||||
dedupeKey: `support-created:${ticket.id}:${admin.id}`,
|
dedupeKey: `support-created:${ticket.id}:${admin.id}`,
|
||||||
});
|
});
|
||||||
@@ -78,7 +97,7 @@ export async function createSupportTicket(formData: FormData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function replySupportTicket(ticketId: string, formData: FormData) {
|
export async function replySupportTicket(ticketId: string, formData: FormData) {
|
||||||
const session = await requireAuth();
|
const session = await requireAuth({ allowDuringRiskRestriction: true });
|
||||||
const body = String(formData.get("body") || "").trim();
|
const body = String(formData.get("body") || "").trim();
|
||||||
const attachments = parseSupportAttachments(formData.getAll("attachments"));
|
const attachments = parseSupportAttachments(formData.getAll("attachments"));
|
||||||
if (!body) {
|
if (!body) {
|
||||||
@@ -154,7 +173,7 @@ export async function replySupportTicket(ticketId: string, formData: FormData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function closeSupportTicket(ticketId: string) {
|
export async function closeSupportTicket(ticketId: string) {
|
||||||
const session = await requireAuth();
|
const session = await requireAuth({ allowDuringRiskRestriction: true });
|
||||||
const ticket = await prisma.supportTicket.findFirst({
|
const ticket = await prisma.supportTicket.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: ticketId,
|
id: ticketId,
|
||||||
@@ -199,7 +218,7 @@ export async function closeSupportTicket(ticketId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSupportTicket(ticketId: string) {
|
export async function deleteSupportTicket(ticketId: string) {
|
||||||
const session = await requireAuth();
|
const session = await requireAuth({ allowDuringRiskRestriction: true });
|
||||||
const ticket = await prisma.supportTicket.findFirst({
|
const ticket = await prisma.supportTicket.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: ticketId,
|
id: ticketId,
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { ChevronDown, Globe2, MapPin } from "lucide-react";
|
||||||
|
import { StatusBadge } from "@/components/shared/status-badge";
|
||||||
|
import { formatDate } from "@/lib/utils";
|
||||||
|
import type { SubscriptionRiskGeoSummary } from "@/services/subscription-risk-review";
|
||||||
|
|
||||||
|
function projectPoint(latitude: number, longitude: number) {
|
||||||
|
return {
|
||||||
|
x: ((longitude + 180) / 360) * 360,
|
||||||
|
y: ((90 - latitude) / 180) * 180,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function radiusForAccess(count: number) {
|
||||||
|
return Math.min(7, Math.max(3, 2 + Math.sqrt(count)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorldRiskMap({ summary }: { summary: SubscriptionRiskGeoSummary }) {
|
||||||
|
const points = summary.points.slice(0, 60);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-lg border border-border/70 bg-muted/20">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 360 180"
|
||||||
|
className="h-48 w-full"
|
||||||
|
role="img"
|
||||||
|
aria-label="订阅访问 IP 世界地图分布"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="risk-map-water" x1="0" x2="1" y1="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="var(--muted)" stopOpacity="0.68" />
|
||||||
|
<stop offset="100%" stopColor="var(--card)" stopOpacity="0.94" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="360" height="180" rx="12" fill="url(#risk-map-water)" />
|
||||||
|
{[-120, -60, 0, 60, 120].map((longitude) => {
|
||||||
|
const x = ((longitude + 180) / 360) * 360;
|
||||||
|
return <line key={longitude} x1={x} x2={x} y1="12" y2="168" stroke="var(--border)" strokeDasharray="2 5" strokeOpacity="0.7" />;
|
||||||
|
})}
|
||||||
|
{[-45, 0, 45].map((latitude) => {
|
||||||
|
const y = ((90 - latitude) / 180) * 180;
|
||||||
|
return <line key={latitude} x1="12" x2="348" y1={y} y2={y} stroke="var(--border)" strokeDasharray="2 5" strokeOpacity="0.7" />;
|
||||||
|
})}
|
||||||
|
<g fill="var(--card)" stroke="var(--border)" strokeWidth="1" opacity="0.92">
|
||||||
|
<path d="M39 50 61 35 91 39 116 57 106 78 82 80 72 106 54 97 36 69Z" />
|
||||||
|
<path d="M86 93 105 103 113 129 100 158 82 144 75 118Z" />
|
||||||
|
<path d="M132 47 160 36 191 41 215 35 251 48 286 58 303 79 276 95 246 89 220 102 190 91 169 98 144 82Z" />
|
||||||
|
<path d="M174 89 198 98 208 126 195 158 172 139 158 107Z" />
|
||||||
|
<path d="M281 116 306 110 326 126 316 147 289 145 270 130Z" />
|
||||||
|
<path d="M295 72 330 69 342 83 325 94 300 89Z" />
|
||||||
|
<path d="M126 33 146 26 168 32 151 42Z" />
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
{points.map((point) => {
|
||||||
|
const { x, y } = projectPoint(point.latitude, point.longitude);
|
||||||
|
return (
|
||||||
|
<g key={point.key}>
|
||||||
|
<title>{point.ip + " / " + point.country + " / " + point.region + " / " + point.city}</title>
|
||||||
|
<circle
|
||||||
|
cx={x}
|
||||||
|
cy={y}
|
||||||
|
r={radiusForAccess(point.accessCount) + 2}
|
||||||
|
fill={point.allowed ? "var(--primary)" : "var(--destructive)"}
|
||||||
|
opacity="0.16"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={x}
|
||||||
|
cy={y}
|
||||||
|
r={radiusForAccess(point.accessCount)}
|
||||||
|
fill={point.allowed ? "var(--primary)" : "var(--destructive)"}
|
||||||
|
stroke="var(--card)"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<div className="flex items-center justify-between border-t border-border/60 px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
<span>{points.length > 0 ? "已标注 " + points.length + " 个坐标点" : "没有可用经纬度坐标"}</span>
|
||||||
|
<span>圆点越大表示访问越集中</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubscriptionRiskGeoDetails({ summary }: { summary: SubscriptionRiskGeoSummary }) {
|
||||||
|
return (
|
||||||
|
<details className="group min-w-[24rem] rounded-lg border border-border/70 bg-muted/20 text-sm">
|
||||||
|
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2 [&::-webkit-details-marker]:hidden">
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-background text-primary">
|
||||||
|
<Globe2 className="size-4" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block font-medium">地区与 IP 证据</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{summary.uniqueCountryCount} 国 / {summary.uniqueRegionCount} 省区 / {summary.uniqueCityCount} 城市 / {summary.uniqueIpCount} IP
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="size-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 p-3">
|
||||||
|
<div className="grid gap-3 xl:grid-cols-[minmax(20rem,1.1fr)_minmax(16rem,0.9fr)]">
|
||||||
|
<WorldRiskMap summary={summary} />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="rounded-md border border-border/60 bg-background/70 p-2">
|
||||||
|
<p className="text-xs text-muted-foreground">访问记录</p>
|
||||||
|
<p className="mt-1 font-mono text-base font-semibold">{summary.totalLogs}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border/60 bg-background/70 p-2">
|
||||||
|
<p className="text-xs text-muted-foreground">不同 IP</p>
|
||||||
|
<p className="mt-1 font-mono text-base font-semibold">{summary.uniqueIpCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-44 space-y-2 overflow-auto pr-1">
|
||||||
|
{summary.countries.length === 0 ? (
|
||||||
|
<p className="rounded-md border border-dashed border-border/70 p-3 text-xs text-muted-foreground">
|
||||||
|
暂无可识别地区数据,可能是 GeoIP 未命中或访问来源未携带有效 IP。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
summary.countries.map((country) => (
|
||||||
|
<div key={country.country} className="rounded-md border border-border/60 bg-background/70 p-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="break-words font-medium">{country.country}</p>
|
||||||
|
<span className="shrink-0 font-mono text-xs text-muted-foreground">{country.accessCount} 次</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||||
|
{country.ipCount} IP · {country.regionCount} 省/地区 · {country.cityCount} 城市
|
||||||
|
</p>
|
||||||
|
{(country.topRegions.length > 0 || country.topCities.length > 0) && (
|
||||||
|
<p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||||
|
省区:{country.topRegions.join("、") || "未识别"};城市:{country.topCities.join("、") || "未识别"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||||
|
<MapPin className="size-3.5" /> 最近访问明细
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 lg:grid-cols-2">
|
||||||
|
{summary.recentAccesses.length === 0 ? (
|
||||||
|
<p className="rounded-md border border-dashed border-border/70 p-3 text-xs text-muted-foreground">暂无访问明细。</p>
|
||||||
|
) : (
|
||||||
|
summary.recentAccesses.slice(0, 6).map((access) => (
|
||||||
|
<div key={access.id} className="min-w-0 rounded-md border border-border/60 bg-background/70 p-2 text-xs leading-5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate font-mono">{access.ip}</span>
|
||||||
|
<StatusBadge tone={access.allowed ? "success" : "warning"}>
|
||||||
|
{access.allowed ? "放行" : access.reason || "拦截"}
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 break-words text-muted-foreground">{access.location}</p>
|
||||||
|
<p className="text-muted-foreground">{formatDate(access.createdAt)}</p>
|
||||||
|
{access.userAgent && <p className="mt-1 truncate text-muted-foreground">{access.userAgent}</p>}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "@/components/shared/domain-badges";
|
} from "@/components/shared/domain-badges";
|
||||||
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
|
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
|
||||||
import { SubscriptionRiskReviewActions } from "@/components/subscriptions/subscription-risk-review-actions";
|
import { SubscriptionRiskReviewActions } from "@/components/subscriptions/subscription-risk-review-actions";
|
||||||
|
import { SubscriptionRiskGeoDetails } from "./subscription-risk-geo-details";
|
||||||
import { formatDate, formatDateShort } from "@/lib/utils";
|
import { formatDate, formatDateShort } from "@/lib/utils";
|
||||||
import type { SubscriptionRiskEventRow } from "../risk-data";
|
import type { SubscriptionRiskEventRow } from "../risk-data";
|
||||||
|
|
||||||
@@ -58,6 +59,17 @@ function reviewStatusTone(status: SubscriptionRiskEvent["reviewStatus"]): Status
|
|||||||
return "warning";
|
return "warning";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function finalActionLabel(action: SubscriptionRiskEvent["finalAction"]) {
|
||||||
|
switch (action) {
|
||||||
|
case "RESTORE_ACCESS":
|
||||||
|
return "已解除限制";
|
||||||
|
case "KEEP_RESTRICTED":
|
||||||
|
return "保持封禁/暂停";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function EventScope({ event }: { event: SubscriptionRiskEventRow }) {
|
function EventScope({ event }: { event: SubscriptionRiskEventRow }) {
|
||||||
if (!event.subscription) {
|
if (!event.subscription) {
|
||||||
return (
|
return (
|
||||||
@@ -91,7 +103,9 @@ function UserCell({ event }: { event: SubscriptionRiskEventRow }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="max-w-56 break-all font-medium">{event.user.email}</p>
|
<Link href={"/admin/users/" + event.user.id} className="block max-w-56 break-all font-medium text-foreground hover:underline">
|
||||||
|
{event.user.email}
|
||||||
|
</Link>
|
||||||
<p className="max-w-52 break-words text-xs text-muted-foreground">{event.user.name || "未设置昵称"}</p>
|
<p className="max-w-52 break-words text-xs text-muted-foreground">{event.user.name || "未设置昵称"}</p>
|
||||||
<UserStatusBadge status={event.user.status} />
|
<UserStatusBadge status={event.user.status} />
|
||||||
</div>
|
</div>
|
||||||
@@ -138,11 +152,14 @@ export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEven
|
|||||||
</div>
|
</div>
|
||||||
</DataTableCell>
|
</DataTableCell>
|
||||||
<DataTableCell>
|
<DataTableCell>
|
||||||
<div className="space-y-1 text-sm">
|
<div className="space-y-2">
|
||||||
<p className="font-mono text-xs">{event.ip || "未知 IP"}</p>
|
<div className="space-y-1 text-sm">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="font-mono text-xs">{event.ip || "未知 IP"}</p>
|
||||||
城市 {event.cityCount} · 省/地区 {event.regionCount} · 国家 {event.countryCount}
|
<p className="text-xs text-muted-foreground">
|
||||||
</p>
|
城市 {event.cityCount} · 省/地区 {event.regionCount} · 国家 {event.countryCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<SubscriptionRiskGeoDetails summary={event.geoSummary} />
|
||||||
</div>
|
</div>
|
||||||
</DataTableCell>
|
</DataTableCell>
|
||||||
<DataTableCell>
|
<DataTableCell>
|
||||||
@@ -150,6 +167,17 @@ export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEven
|
|||||||
<StatusBadge tone={reviewStatusTone(event.reviewStatus)}>
|
<StatusBadge tone={reviewStatusTone(event.reviewStatus)}>
|
||||||
{reviewStatusLabel(event.reviewStatus)}
|
{reviewStatusLabel(event.reviewStatus)}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
|
{(event.reportSentAt || event.userRestrictionActive || event.finalAction) && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{event.reportSentAt && <StatusBadge tone="info">已发送报告</StatusBadge>}
|
||||||
|
{event.userRestrictionActive && <StatusBadge tone="danger">用户端限制中</StatusBadge>}
|
||||||
|
{finalActionLabel(event.finalAction) && (
|
||||||
|
<StatusBadge tone={event.finalAction === "RESTORE_ACCESS" ? "success" : "warning"}>
|
||||||
|
{finalActionLabel(event.finalAction)}
|
||||||
|
</StatusBadge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{(event.reviewedByEmail || event.reviewNote) && (
|
{(event.reviewedByEmail || event.reviewNote) && (
|
||||||
<div className="max-w-52 text-xs leading-5 text-muted-foreground">
|
<div className="max-w-52 text-xs leading-5 text-muted-foreground">
|
||||||
{event.reviewedByEmail && <p className="break-all">{event.reviewedByEmail}</p>}
|
{event.reviewedByEmail && <p className="break-all">{event.reviewedByEmail}</p>}
|
||||||
@@ -165,6 +193,11 @@ export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEven
|
|||||||
eventId={event.id}
|
eventId={event.id}
|
||||||
reviewStatus={event.reviewStatus}
|
reviewStatus={event.reviewStatus}
|
||||||
canRestoreSubscription={event.canRestoreSubscription}
|
canRestoreSubscription={event.canRestoreSubscription}
|
||||||
|
restorableSubscriptionCount={event.restorableSubscriptionCount}
|
||||||
|
riskReport={event.riskReport}
|
||||||
|
reportSentAt={event.reportSentAt}
|
||||||
|
userRestrictionActive={event.userRestrictionActive}
|
||||||
|
finalAction={event.finalAction}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DataTableCell>
|
</DataTableCell>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { Prisma, SubscriptionRiskEvent } from "@prisma/client";
|
import type { Prisma, SubscriptionRiskEvent } from "@prisma/client";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { parsePage } from "@/lib/utils";
|
import { parsePage } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
buildSubscriptionRiskGeoSummary,
|
||||||
|
getSubscriptionRiskAccessLogsForEvent,
|
||||||
|
type SubscriptionRiskGeoSummary,
|
||||||
|
} from "@/services/subscription-risk-review";
|
||||||
|
|
||||||
type RiskUser = {
|
type RiskUser = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -24,6 +29,8 @@ export type SubscriptionRiskEventRow = SubscriptionRiskEvent & {
|
|||||||
user: RiskUser | null;
|
user: RiskUser | null;
|
||||||
subscription: RiskSubscription | null;
|
subscription: RiskSubscription | null;
|
||||||
canRestoreSubscription: boolean;
|
canRestoreSubscription: boolean;
|
||||||
|
restorableSubscriptionCount: number;
|
||||||
|
geoSummary: SubscriptionRiskGeoSummary;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function searchRelatedIds(q: string) {
|
async function searchRelatedIds(q: string) {
|
||||||
@@ -102,7 +109,8 @@ export async function getSubscriptionRiskEvents(
|
|||||||
const eventUserIds = Array.from(new Set(events.map((event) => event.userId).filter(Boolean))) as string[];
|
const eventUserIds = Array.from(new Set(events.map((event) => event.userId).filter(Boolean))) as string[];
|
||||||
const eventSubscriptionIds = Array.from(new Set(events.map((event) => event.subscriptionId).filter(Boolean))) as string[];
|
const eventSubscriptionIds = Array.from(new Set(events.map((event) => event.subscriptionId).filter(Boolean))) as string[];
|
||||||
|
|
||||||
const [users, subscriptions] = await Promise.all([
|
const now = new Date();
|
||||||
|
const [users, subscriptions, restorableSubscriptions, geoSummaries] = await Promise.all([
|
||||||
eventUserIds.length > 0
|
eventUserIds.length > 0
|
||||||
? prisma.user.findMany({
|
? prisma.user.findMany({
|
||||||
where: { id: { in: eventUserIds } },
|
where: { id: { in: eventUserIds } },
|
||||||
@@ -121,25 +129,57 @@ export async function getSubscriptionRiskEvents(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
|
eventUserIds.length > 0
|
||||||
|
? prisma.userSubscription.findMany({
|
||||||
|
where: {
|
||||||
|
userId: { in: eventUserIds },
|
||||||
|
status: "SUSPENDED",
|
||||||
|
endDate: { gt: now },
|
||||||
|
plan: { type: "PROXY" },
|
||||||
|
},
|
||||||
|
select: { id: true, userId: true },
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
Promise.all(
|
||||||
|
events.map(async (event) => {
|
||||||
|
const logs = await getSubscriptionRiskAccessLogsForEvent(event);
|
||||||
|
return [event.id, buildSubscriptionRiskGeoSummary(logs)] as const;
|
||||||
|
}),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const userById = new Map(users.map((user) => [user.id, user]));
|
const userById = new Map(users.map((user) => [user.id, user]));
|
||||||
const subscriptionById = new Map(subscriptions.map((subscription) => [subscription.id, subscription]));
|
const subscriptionById = new Map(subscriptions.map((subscription) => [subscription.id, subscription]));
|
||||||
const now = new Date();
|
const geoSummaryByEventId = new Map(geoSummaries);
|
||||||
|
const restorableCountByUserId = new Map<string, number>();
|
||||||
|
for (const subscription of restorableSubscriptions) {
|
||||||
|
restorableCountByUserId.set(
|
||||||
|
subscription.userId,
|
||||||
|
(restorableCountByUserId.get(subscription.userId) ?? 0) + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const rows: SubscriptionRiskEventRow[] = events.map((event) => {
|
const rows: SubscriptionRiskEventRow[] = events.map((event) => {
|
||||||
const subscription = event.subscriptionId ? subscriptionById.get(event.subscriptionId) ?? null : null;
|
const subscription = event.subscriptionId ? subscriptionById.get(event.subscriptionId) ?? null : null;
|
||||||
const user = subscription?.user ?? (event.userId ? userById.get(event.userId) ?? null : null);
|
const user = subscription?.user ?? (event.userId ? userById.get(event.userId) ?? null : null);
|
||||||
|
const singleRestorable = Boolean(
|
||||||
|
subscription
|
||||||
|
&& subscription.status === "SUSPENDED"
|
||||||
|
&& subscription.endDate > now
|
||||||
|
&& event.reviewStatus !== "RESOLVED",
|
||||||
|
);
|
||||||
|
const aggregateRestorableCount = event.kind === "AGGREGATE" && event.userId
|
||||||
|
? restorableCountByUserId.get(event.userId) ?? 0
|
||||||
|
: 0;
|
||||||
|
const restorableSubscriptionCount = singleRestorable ? 1 : aggregateRestorableCount;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...event,
|
...event,
|
||||||
user,
|
user,
|
||||||
subscription,
|
subscription,
|
||||||
canRestoreSubscription: Boolean(
|
canRestoreSubscription: restorableSubscriptionCount > 0 && event.reviewStatus !== "RESOLVED",
|
||||||
subscription
|
restorableSubscriptionCount,
|
||||||
&& subscription.status === "SUSPENDED"
|
geoSummary: geoSummaryByEventId.get(event.id) ?? buildSubscriptionRiskGeoSummary([]),
|
||||||
&& subscription.endDate > now
|
|
||||||
&& event.reviewStatus !== "RESOLVED",
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export function SubscriptionAccessRiskSection({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-sm">
|
<div className="space-y-1 text-sm">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="break-all font-medium">{owner.email}</span>
|
<Link href={"/admin/users/" + owner.id} className="break-all font-medium hover:underline">{owner.email}</Link>
|
||||||
<UserStatusBadge status={owner.status} />
|
<UserStatusBadge status={owner.status} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">{owner.name || "未设置昵称"}</p>
|
<p className="text-muted-foreground">{owner.name || "未设置昵称"}</p>
|
||||||
@@ -179,6 +179,11 @@ export function SubscriptionAccessRiskSection({
|
|||||||
eventId={event.id}
|
eventId={event.id}
|
||||||
reviewStatus={event.reviewStatus}
|
reviewStatus={event.reviewStatus}
|
||||||
canRestoreSubscription={canRestoreFromEvent(event, subscription)}
|
canRestoreSubscription={canRestoreFromEvent(event, subscription)}
|
||||||
|
restorableSubscriptionCount={canRestoreFromEvent(event, subscription) ? 1 : 0}
|
||||||
|
riskReport={event.riskReport}
|
||||||
|
reportSentAt={event.reportSentAt}
|
||||||
|
userRestrictionActive={event.userRestrictionActive}
|
||||||
|
finalAction={event.finalAction}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
260
src/app/(admin)/admin/users/[id]/page.tsx
Normal file
260
src/app/(admin)/admin/users/[id]/page.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell";
|
||||||
|
import { DataTableShell } from "@/components/admin/data-table-shell";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableBody,
|
||||||
|
DataTableCell,
|
||||||
|
DataTableHead,
|
||||||
|
DataTableHeadCell,
|
||||||
|
DataTableHeaderRow,
|
||||||
|
DataTableRow,
|
||||||
|
} from "@/components/shared/data-table";
|
||||||
|
import {
|
||||||
|
OrderStatusBadge,
|
||||||
|
SubscriptionStatusBadge,
|
||||||
|
SubscriptionTypeBadge,
|
||||||
|
UserRoleBadge,
|
||||||
|
UserStatusBadge,
|
||||||
|
orderKindLabels,
|
||||||
|
} from "@/components/shared/domain-badges";
|
||||||
|
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
|
||||||
|
import {
|
||||||
|
SupportTicketPriorityBadge,
|
||||||
|
SupportTicketStatusBadge,
|
||||||
|
} from "@/components/support/ticket-badges";
|
||||||
|
import { formatBytes, formatDate, formatDateShort } from "@/lib/utils";
|
||||||
|
import { reasonLabel } from "@/services/subscription-risk-review";
|
||||||
|
import { UserActions } from "../user-actions";
|
||||||
|
import { getAdminUserDetail } from "./user-detail-data";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "用户详情",
|
||||||
|
description: "查看用户资料、订阅、订单、工单和风控记录。",
|
||||||
|
};
|
||||||
|
|
||||||
|
function MetricCard({ label, value, hint }: { label: string; value: ReactNode; hint?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border/70 bg-card p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<div className="mt-2 min-h-7 break-words text-xl font-semibold tracking-[-0.02em]">{value}</div>
|
||||||
|
{hint && <div className="mt-1 text-xs leading-5 text-muted-foreground">{hint}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewStatusLabel(status: "OPEN" | "ACKNOWLEDGED" | "RESOLVED") {
|
||||||
|
if (status === "RESOLVED") return "已解决";
|
||||||
|
if (status === "ACKNOWLEDGED") return "已确认";
|
||||||
|
return "待处理";
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewStatusTone(status: "OPEN" | "ACKNOWLEDGED" | "RESOLVED"): StatusTone {
|
||||||
|
if (status === "RESOLVED") return "success";
|
||||||
|
if (status === "ACKNOWLEDGED") return "info";
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminUserDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const data = await getAdminUserDetail(id);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, subscriptions, orders, riskEvents, supportTickets } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="用户详情"
|
||||||
|
title={user.email}
|
||||||
|
description={user.name || "未设置昵称"}
|
||||||
|
actions={<UserActions user={user} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||||
|
<MetricCard
|
||||||
|
label="账号状态"
|
||||||
|
value={(
|
||||||
|
<span className="flex flex-wrap gap-2">
|
||||||
|
<UserStatusBadge status={user.status} />
|
||||||
|
<UserRoleBadge role={user.role} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
hint={user.emailVerifiedAt ? "邮箱已验证" : "邮箱未验证"}
|
||||||
|
/>
|
||||||
|
<MetricCard label="订阅" value={user._count.subscriptions} hint="用户持有的全部订阅" />
|
||||||
|
<MetricCard label="订单" value={user._count.orders} hint="历史订单数量" />
|
||||||
|
<MetricCard label="工单" value={user._count.supportTickets} hint="售后沟通记录" />
|
||||||
|
<MetricCard label="注册时间" value={formatDateShort(user.createdAt)} hint={user.invitedBy ? "邀请人:" + user.invitedBy.email : "非邀请注册或邀请人已删除"} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-border/70 bg-card p-4 text-sm leading-6">
|
||||||
|
<SectionHeader
|
||||||
|
title="账号资料"
|
||||||
|
description="用于风控判断时快速确认用户基础信息。"
|
||||||
|
actions={<Link href={"/admin/subscription-risk?q=" + encodeURIComponent(user.email)} className="text-sm font-medium text-primary hover:underline">查看该用户风控</Link>}
|
||||||
|
/>
|
||||||
|
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">用户 ID</p>
|
||||||
|
<p className="mt-1 break-all font-mono text-xs">{user.id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">邀请码</p>
|
||||||
|
<p className="mt-1 break-all font-mono text-xs">{user.inviteCode || "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">注册时间</p>
|
||||||
|
<p className="mt-1">{formatDate(user.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">更新时间</p>
|
||||||
|
<p className="mt-1">{formatDate(user.updatedAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<SectionHeader title="订阅" description="最近创建的订阅与当前状态。" />
|
||||||
|
<DataTableShell isEmpty={subscriptions.length === 0} emptyTitle="暂无订阅" emptyDescription="这个用户还没有订阅记录。">
|
||||||
|
<DataTable aria-label="用户订阅" className="min-w-[820px]">
|
||||||
|
<DataTableHead>
|
||||||
|
<DataTableHeaderRow>
|
||||||
|
<DataTableHeadCell>套餐</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>类型</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>状态</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>流量</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>到期</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>创建时间</DataTableHeadCell>
|
||||||
|
</DataTableHeaderRow>
|
||||||
|
</DataTableHead>
|
||||||
|
<DataTableBody>
|
||||||
|
{subscriptions.map((subscription) => (
|
||||||
|
<DataTableRow key={subscription.id}>
|
||||||
|
<DataTableCell>
|
||||||
|
<Link href={"/admin/subscriptions/" + subscription.id} className="font-medium hover:underline">
|
||||||
|
{subscription.plan.name}
|
||||||
|
</Link>
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell><SubscriptionTypeBadge type={subscription.plan.type} /></DataTableCell>
|
||||||
|
<DataTableCell><SubscriptionStatusBadge status={subscription.status} /></DataTableCell>
|
||||||
|
<DataTableCell className="text-xs text-muted-foreground">
|
||||||
|
{formatBytes(subscription.trafficUsed)} / {subscription.trafficLimit ? formatBytes(subscription.trafficLimit) : "不限"}
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell className="whitespace-nowrap text-muted-foreground">{formatDateShort(subscription.endDate)}</DataTableCell>
|
||||||
|
<DataTableCell className="whitespace-nowrap text-muted-foreground">{formatDateShort(subscription.createdAt)}</DataTableCell>
|
||||||
|
</DataTableRow>
|
||||||
|
))}
|
||||||
|
</DataTableBody>
|
||||||
|
</DataTable>
|
||||||
|
</DataTableShell>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<SectionHeader title="近期风控" description="这个用户最近触发的订阅访问风控事件。" />
|
||||||
|
<DataTableShell isEmpty={riskEvents.length === 0} emptyTitle="暂无风控事件" emptyDescription="目前没有该用户的订阅风控记录。">
|
||||||
|
<DataTable aria-label="用户风控事件" className="min-w-[760px]">
|
||||||
|
<DataTableHead>
|
||||||
|
<DataTableHeaderRow>
|
||||||
|
<DataTableHeadCell>时间</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>判定</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>地区/IP</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>状态</DataTableHeadCell>
|
||||||
|
</DataTableHeaderRow>
|
||||||
|
</DataTableHead>
|
||||||
|
<DataTableBody>
|
||||||
|
{riskEvents.map((event) => (
|
||||||
|
<DataTableRow key={event.id}>
|
||||||
|
<DataTableCell className="whitespace-nowrap text-muted-foreground">{formatDate(event.createdAt)}</DataTableCell>
|
||||||
|
<DataTableCell>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<StatusBadge tone={event.level === "SUSPENDED" ? "danger" : "warning"}>{reasonLabel(event.reason)}</StatusBadge>
|
||||||
|
<p className="max-w-lg text-xs leading-5 text-muted-foreground">{event.message}</p>
|
||||||
|
</div>
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell className="text-xs text-muted-foreground">
|
||||||
|
<p className="font-mono text-foreground">{event.ip || "未知 IP"}</p>
|
||||||
|
<p>城市 {event.cityCount} · 省/地区 {event.regionCount} · 国家 {event.countryCount}</p>
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell>
|
||||||
|
<StatusBadge tone={reviewStatusTone(event.reviewStatus)}>{reviewStatusLabel(event.reviewStatus)}</StatusBadge>
|
||||||
|
</DataTableCell>
|
||||||
|
</DataTableRow>
|
||||||
|
))}
|
||||||
|
</DataTableBody>
|
||||||
|
</DataTable>
|
||||||
|
</DataTableShell>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<SectionHeader title="近期订单" description="最近的购买、续费和增流量订单。" />
|
||||||
|
<DataTableShell isEmpty={orders.length === 0} emptyTitle="暂无订单" emptyDescription="这个用户还没有订单记录。">
|
||||||
|
<DataTable aria-label="用户订单" className="min-w-[680px]">
|
||||||
|
<DataTableHead>
|
||||||
|
<DataTableHeaderRow>
|
||||||
|
<DataTableHeadCell>套餐</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>类型</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>金额</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>状态</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>时间</DataTableHeadCell>
|
||||||
|
</DataTableHeaderRow>
|
||||||
|
</DataTableHead>
|
||||||
|
<DataTableBody>
|
||||||
|
{orders.map((order) => (
|
||||||
|
<DataTableRow key={order.id}>
|
||||||
|
<DataTableCell className="max-w-52 truncate font-medium">{order.plan.name}</DataTableCell>
|
||||||
|
<DataTableCell>{orderKindLabels[order.kind]}</DataTableCell>
|
||||||
|
<DataTableCell className="font-mono">¥{Number(order.amount).toFixed(2)}</DataTableCell>
|
||||||
|
<DataTableCell><OrderStatusBadge status={order.status} /></DataTableCell>
|
||||||
|
<DataTableCell className="whitespace-nowrap text-muted-foreground">{formatDateShort(order.createdAt)}</DataTableCell>
|
||||||
|
</DataTableRow>
|
||||||
|
))}
|
||||||
|
</DataTableBody>
|
||||||
|
</DataTable>
|
||||||
|
</DataTableShell>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<SectionHeader title="近期工单" description="用户与客服之间的最近沟通。" />
|
||||||
|
<DataTableShell isEmpty={supportTickets.length === 0} emptyTitle="暂无工单" emptyDescription="这个用户还没有提交工单。">
|
||||||
|
<DataTable aria-label="用户工单" className="min-w-[680px]">
|
||||||
|
<DataTableHead>
|
||||||
|
<DataTableHeaderRow>
|
||||||
|
<DataTableHeadCell>标题</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>状态</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>优先级</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>更新</DataTableHeadCell>
|
||||||
|
</DataTableHeaderRow>
|
||||||
|
</DataTableHead>
|
||||||
|
<DataTableBody>
|
||||||
|
{supportTickets.map((ticket) => (
|
||||||
|
<DataTableRow key={ticket.id}>
|
||||||
|
<DataTableCell>
|
||||||
|
<Link href={"/admin/support/" + ticket.id} className="max-w-72 truncate font-medium hover:underline">
|
||||||
|
{ticket.subject}
|
||||||
|
</Link>
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell><SupportTicketStatusBadge status={ticket.status} /></DataTableCell>
|
||||||
|
<DataTableCell><SupportTicketPriorityBadge priority={ticket.priority} /></DataTableCell>
|
||||||
|
<DataTableCell className="whitespace-nowrap text-muted-foreground">{formatDateShort(ticket.updatedAt)}</DataTableCell>
|
||||||
|
</DataTableRow>
|
||||||
|
))}
|
||||||
|
</DataTableBody>
|
||||||
|
</DataTable>
|
||||||
|
</DataTableShell>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/app/(admin)/admin/users/[id]/user-detail-data.ts
Normal file
82
src/app/(admin)/admin/users/[id]/user-detail-data.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
const adminUserDetailInclude = {
|
||||||
|
invitedBy: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
subscriptions: true,
|
||||||
|
orders: true,
|
||||||
|
invitedUsers: true,
|
||||||
|
supportTickets: true,
|
||||||
|
notifications: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.UserInclude;
|
||||||
|
|
||||||
|
export type AdminUserDetail = Prisma.UserGetPayload<{
|
||||||
|
include: typeof adminUserDetailInclude;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export async function getAdminUserDetail(userId: string) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: adminUserDetailInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const [subscriptions, orders, riskEvents, supportTickets] = await Promise.all([
|
||||||
|
prisma.userSubscription.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
endDate: true,
|
||||||
|
trafficUsed: true,
|
||||||
|
trafficLimit: true,
|
||||||
|
createdAt: true,
|
||||||
|
plan: { select: { name: true, type: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 12,
|
||||||
|
}),
|
||||||
|
prisma.order.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
amount: true,
|
||||||
|
status: true,
|
||||||
|
kind: true,
|
||||||
|
createdAt: true,
|
||||||
|
plan: { select: { name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 8,
|
||||||
|
}),
|
||||||
|
prisma.subscriptionRiskEvent.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 8,
|
||||||
|
}),
|
||||||
|
prisma.supportTicket.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
subject: true,
|
||||||
|
status: true,
|
||||||
|
priority: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: 8,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { user, subscriptions, orders, riskEvents, supportTickets };
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
@@ -26,5 +27,10 @@ export default async function PaymentLayout({
|
|||||||
redirect("/admin/dashboard");
|
redirect("/admin/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const restriction = await getActiveSubscriptionRiskRestriction(session.user.id);
|
||||||
|
if (restriction) {
|
||||||
|
redirect("/support?riskEventId=" + restriction.id);
|
||||||
|
}
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { UserMobileNav } from "@/components/user/mobile-nav";
|
|||||||
import { AnnouncementLoader } from "@/components/announcements/announcement-loader";
|
import { AnnouncementLoader } from "@/components/announcements/announcement-loader";
|
||||||
import { getUnreadNotificationCount } from "./notifications/notifications-data";
|
import { getUnreadNotificationCount } from "./notifications/notifications-data";
|
||||||
import { PageTransition } from "@/components/shared/page-transition";
|
import { PageTransition } from "@/components/shared/page-transition";
|
||||||
|
import { SubscriptionRiskRestrictionGate } from "@/components/user/subscription-risk-restriction-gate";
|
||||||
|
import { getActiveSubscriptionRiskRestriction, reasonLabel } from "@/services/subscription-risk-review";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
@@ -31,7 +33,20 @@ export default async function UserLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userName = session.user.name || session.user.email || "";
|
const userName = session.user.name || session.user.email || "";
|
||||||
const unreadCount = await getUnreadNotificationCount(session.user.id);
|
const [unreadCount, activeRestriction] = await Promise.all([
|
||||||
|
getUnreadNotificationCount(session.user.id),
|
||||||
|
getActiveSubscriptionRiskRestriction(session.user.id),
|
||||||
|
]);
|
||||||
|
const restrictionNotice = activeRestriction
|
||||||
|
? {
|
||||||
|
id: activeRestriction.id,
|
||||||
|
level: activeRestriction.level,
|
||||||
|
reasonLabel: reasonLabel(activeRestriction.reason),
|
||||||
|
message: activeRestriction.message,
|
||||||
|
riskReport: activeRestriction.riskReport,
|
||||||
|
reportSentAt: activeRestriction.reportSentAt?.toISOString() ?? null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[100dvh] overflow-hidden p-0 md:p-3">
|
<div className="flex h-[100dvh] overflow-hidden p-0 md:p-3">
|
||||||
@@ -41,6 +56,7 @@ export default async function UserLayout({
|
|||||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden md:pl-3">
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden md:pl-3">
|
||||||
<UserMobileNav userName={userName} unreadCount={unreadCount} />
|
<UserMobileNav userName={userName} unreadCount={unreadCount} />
|
||||||
<main className="flex-1 overflow-auto px-3 py-4 sm:px-5 sm:py-6 md:pt-0 lg:px-7 lg:pb-7">
|
<main className="flex-1 overflow-auto px-3 py-4 sm:px-5 sm:py-6 md:pt-0 lg:px-7 lg:pb-7">
|
||||||
|
<SubscriptionRiskRestrictionGate restriction={restrictionNotice} />
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<AnnouncementLoader userId={session.user.id} role="USER" />
|
<AnnouncementLoader userId={session.user.id} role="USER" />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -10,8 +10,22 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
|
|
||||||
const ATTACHMENT_ACCEPT = "image/jpeg,image/png,image/webp,image/gif,image/avif";
|
const ATTACHMENT_ACCEPT = "image/jpeg,image/png,image/webp,image/gif,image/avif";
|
||||||
|
|
||||||
export function CreateSupportTicketForm() {
|
type SupportTicketPreset = {
|
||||||
const [open, setOpen] = useState(false);
|
riskEventId?: string;
|
||||||
|
subject?: string;
|
||||||
|
category?: string;
|
||||||
|
priority?: "LOW" | "NORMAL" | "HIGH" | "URGENT";
|
||||||
|
body?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CreateSupportTicketForm({
|
||||||
|
defaultOpen = false,
|
||||||
|
preset,
|
||||||
|
}: {
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
preset?: SupportTicketPreset;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
return (
|
return (
|
||||||
@@ -29,7 +43,8 @@ export function CreateSupportTicketForm() {
|
|||||||
className="surface-card space-y-5 rounded-[2rem] p-5 sm:p-6"
|
className="surface-card space-y-5 rounded-[2rem] p-5 sm:p-6"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-lg font-semibold">新建工单</h3>
|
<h3 className="text-lg font-semibold">{preset?.riskEventId ? "订阅风控复核工单" : "新建工单"}</h3>
|
||||||
|
{!preset?.riskEventId && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
@@ -37,18 +52,19 @@ export function CreateSupportTicketForm() {
|
|||||||
>
|
>
|
||||||
<X className="size-4" />
|
<X className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-5 md:grid-cols-3">
|
<div className="grid gap-5 md:grid-cols-3">
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<Label htmlFor="subject">标题</Label>
|
<Label htmlFor="subject">标题</Label>
|
||||||
<Input id="subject" name="subject" placeholder="一句话描述遇到的问题" required />
|
<Input id="subject" name="subject" placeholder="一句话描述遇到的问题" defaultValue={preset?.subject} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="priority">优先级</Label>
|
<Label htmlFor="priority">优先级</Label>
|
||||||
<select
|
<select
|
||||||
id="priority"
|
id="priority"
|
||||||
name="priority"
|
name="priority"
|
||||||
defaultValue="NORMAL"
|
defaultValue={preset?.priority ?? "NORMAL"}
|
||||||
className="h-11 w-full px-3 text-sm outline-none"
|
className="h-11 w-full px-3 text-sm outline-none"
|
||||||
>
|
>
|
||||||
<option value="LOW">低</option>
|
<option value="LOW">低</option>
|
||||||
@@ -60,11 +76,11 @@ export function CreateSupportTicketForm() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="category">分类</Label>
|
<Label htmlFor="category">分类</Label>
|
||||||
<Input id="category" name="category" placeholder="例如:支付 / 节点 / 流媒体 / 账户" />
|
<Input id="category" name="category" placeholder="例如:支付 / 节点 / 流媒体 / 账户" defaultValue={preset?.category} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="body">问题描述</Label>
|
<Label htmlFor="body">问题描述</Label>
|
||||||
<Textarea id="body" name="body" rows={5} placeholder="补充问题背景、错误提示或你已经尝试过的步骤" required />
|
<Textarea id="body" name="body" rows={5} placeholder="补充问题背景、错误提示或你已经尝试过的步骤" defaultValue={preset?.body} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="attachments">附件(最多 3 张,仅支持图片,每张不超过 3MB)</Label>
|
<Label htmlFor="attachments">附件(最多 3 张,仅支持图片,每张不超过 3MB)</Label>
|
||||||
@@ -76,6 +92,7 @@ export function CreateSupportTicketForm() {
|
|||||||
accept={ATTACHMENT_ACCEPT}
|
accept={ATTACHMENT_ACCEPT}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{preset?.riskEventId && <input type="hidden" name="riskEventId" value={preset.riskEventId} />}
|
||||||
<Button type="submit" size="lg">提交工单</Button>
|
<Button type="submit" size="lg">提交工单</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { reasonLabel } from "@/services/subscription-risk-review";
|
||||||
import { CreateSupportTicketForm } from "./_components/create-support-ticket-form";
|
import { CreateSupportTicketForm } from "./_components/create-support-ticket-form";
|
||||||
import { UserSupportTicketTable } from "./_components/user-support-ticket-table";
|
import { UserSupportTicketTable } from "./_components/user-support-ticket-table";
|
||||||
import { getUserSupportTickets } from "./support-data";
|
import { getUserSupportTickets } from "./support-data";
|
||||||
@@ -11,9 +13,41 @@ export const metadata: Metadata = {
|
|||||||
description: "提交问题并跟踪工单处理进度。",
|
description: "提交问题并跟踪工单处理进度。",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function SupportPage() {
|
export default async function SupportPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
const tickets = await getUserSupportTickets(session!.user.id);
|
const resolvedSearchParams = await searchParams;
|
||||||
|
const riskEventId = typeof resolvedSearchParams.riskEventId === "string" ? resolvedSearchParams.riskEventId : "";
|
||||||
|
const [tickets, riskEvent] = await Promise.all([
|
||||||
|
getUserSupportTickets(session!.user.id),
|
||||||
|
riskEventId
|
||||||
|
? prisma.subscriptionRiskEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
id: riskEventId,
|
||||||
|
userId: session!.user.id,
|
||||||
|
reportSentAt: { not: null },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
reason: true,
|
||||||
|
message: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
const preset = riskEvent
|
||||||
|
? {
|
||||||
|
riskEventId: riskEvent.id,
|
||||||
|
subject: "订阅风控复核申请",
|
||||||
|
category: "订阅风控",
|
||||||
|
priority: "HIGH" as const,
|
||||||
|
body: "我需要复核订阅风控限制。\n\n请在这里补充:近期访问订阅的设备、所在城市/国家、是否出差或旅行、是否曾分享订阅链接。\n\n系统判定:" + reasonLabel(riskEvent.reason) + "\n" + riskEvent.message,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -22,7 +56,7 @@ export default async function SupportPage() {
|
|||||||
title="需要帮助?"
|
title="需要帮助?"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CreateSupportTicketForm />
|
<CreateSupportTicketForm defaultOpen={Boolean(preset)} preset={preset} />
|
||||||
<UserSupportTicketTable tickets={tickets} />
|
<UserSupportTicketTable tickets={tickets} />
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { jsonError, jsonOk } from "@/lib/api-response";
|
|||||||
import { getPaymentAdapter } from "@/services/payment/factory";
|
import { getPaymentAdapter } from "@/services/payment/factory";
|
||||||
import { rateLimit } from "@/lib/rate-limit";
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
import { getSiteBaseUrl } from "@/services/site-url";
|
import { getSiteBaseUrl } from "@/services/site-url";
|
||||||
|
import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
const createPaymentSchema = z.object({
|
const createPaymentSchema = z.object({
|
||||||
@@ -32,6 +33,11 @@ export async function POST(req: Request) {
|
|||||||
return jsonError("未登录", { status: 401 });
|
return jsonError("未登录", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const restriction = await getActiveSubscriptionRiskRestriction(session.user.id);
|
||||||
|
if (restriction) {
|
||||||
|
return jsonError("账户存在未处理的订阅风控限制,请先新建工单联系客服", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const { success, remaining } = await rateLimit(
|
const { success, remaining } = await rateLimit(
|
||||||
`ratelimit:payment:${session.user.id}`,
|
`ratelimit:payment:${session.user.id}`,
|
||||||
5,
|
5,
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { SubscriptionRiskReviewStatus } from "@prisma/client";
|
import type {
|
||||||
import { CheckCircle2, RotateCcw, ShieldCheck } from "lucide-react";
|
SubscriptionRiskFinalAction,
|
||||||
|
SubscriptionRiskReviewStatus,
|
||||||
|
} from "@prisma/client";
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
LockKeyhole,
|
||||||
|
RotateCcw,
|
||||||
|
Send,
|
||||||
|
ShieldCheck,
|
||||||
|
UnlockKeyhole,
|
||||||
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { updateSubscriptionRiskReview } from "@/actions/admin/subscription-risk";
|
import {
|
||||||
|
finalizeSubscriptionRiskDecision,
|
||||||
|
generateSubscriptionRiskReport,
|
||||||
|
sendSubscriptionRiskReport,
|
||||||
|
updateSubscriptionRiskReview,
|
||||||
|
} from "@/actions/admin/subscription-risk";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -24,10 +39,10 @@ interface RiskReviewMode {
|
|||||||
label: string;
|
label: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: "ack" | "resolve" | "open";
|
icon: "ack" | "open";
|
||||||
}
|
}
|
||||||
|
|
||||||
const modes: Record<SubscriptionRiskReviewStatus, RiskReviewMode> = {
|
const modes: Record<"OPEN" | "ACKNOWLEDGED", RiskReviewMode> = {
|
||||||
OPEN: {
|
OPEN: {
|
||||||
status: "OPEN",
|
status: "OPEN",
|
||||||
label: "重新打开",
|
label: "重新打开",
|
||||||
@@ -39,76 +54,193 @@ const modes: Record<SubscriptionRiskReviewStatus, RiskReviewMode> = {
|
|||||||
status: "ACKNOWLEDGED",
|
status: "ACKNOWLEDGED",
|
||||||
label: "确认跟进",
|
label: "确认跟进",
|
||||||
title: "确认正在处理",
|
title: "确认正在处理",
|
||||||
description: "适合先记录已看到、正在核查,暂不恢复或关闭事件。",
|
description: "适合先记录已看到、正在核查,暂不解除或关闭事件。",
|
||||||
icon: "ack",
|
icon: "ack",
|
||||||
},
|
},
|
||||||
RESOLVED: {
|
|
||||||
status: "RESOLVED",
|
|
||||||
label: "标记解决",
|
|
||||||
title: "标记风控事件已解决",
|
|
||||||
description: "适合已联系用户、确认误判或已经完成必要处置后关闭事件。",
|
|
||||||
icon: "resolve",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DialogState =
|
||||||
|
| { type: "review"; mode: RiskReviewMode }
|
||||||
|
| { type: "final"; action: SubscriptionRiskFinalAction }
|
||||||
|
| { type: "report" }
|
||||||
|
| null;
|
||||||
|
|
||||||
function ModeIcon({ icon }: { icon: RiskReviewMode["icon"] }) {
|
function ModeIcon({ icon }: { icon: RiskReviewMode["icon"] }) {
|
||||||
if (icon === "open") return <RotateCcw className="size-4" />;
|
if (icon === "open") return <RotateCcw className="size-4" />;
|
||||||
if (icon === "resolve") return <CheckCircle2 className="size-4" />;
|
|
||||||
return <ShieldCheck className="size-4" />;
|
return <ShieldCheck className="size-4" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function finalActionCopy(action: SubscriptionRiskFinalAction, restorableSubscriptionCount: number) {
|
||||||
|
if (action === "RESTORE_ACCESS") {
|
||||||
|
return {
|
||||||
|
icon: <UnlockKeyhole className="size-4" />,
|
||||||
|
label: "解除限制",
|
||||||
|
title: "确认解除风控限制?",
|
||||||
|
description: restorableSubscriptionCount > 0
|
||||||
|
? "会恢复可恢复的暂停订阅,并关闭用户端强制通知。"
|
||||||
|
: "会关闭用户端强制通知,并把事件记录为已解除;当前没有可自动恢复的暂停订阅。",
|
||||||
|
confirm: "确认解除",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
icon: <LockKeyhole className="size-4" />,
|
||||||
|
label: "保持封禁/暂停",
|
||||||
|
title: "确认保持封禁或暂停?",
|
||||||
|
description: "订阅和用户限制会维持当前处置,适合确认订阅链接外泄、公共代理滥用或用户无法解释异常访问来源的情况。",
|
||||||
|
confirm: "保持限制",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function SubscriptionRiskReviewActions({
|
export function SubscriptionRiskReviewActions({
|
||||||
eventId,
|
eventId,
|
||||||
reviewStatus,
|
reviewStatus,
|
||||||
canRestoreSubscription = false,
|
canRestoreSubscription = false,
|
||||||
|
restorableSubscriptionCount = 0,
|
||||||
|
riskReport = null,
|
||||||
|
reportSentAt = null,
|
||||||
|
userRestrictionActive = false,
|
||||||
|
finalAction = null,
|
||||||
}: {
|
}: {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
reviewStatus: SubscriptionRiskReviewStatus;
|
reviewStatus: SubscriptionRiskReviewStatus;
|
||||||
canRestoreSubscription?: boolean;
|
canRestoreSubscription?: boolean;
|
||||||
|
restorableSubscriptionCount?: number;
|
||||||
|
riskReport?: string | null;
|
||||||
|
reportSentAt?: Date | string | null;
|
||||||
|
userRestrictionActive?: boolean;
|
||||||
|
finalAction?: SubscriptionRiskFinalAction | null;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [mode, setMode] = useState<RiskReviewMode | null>(null);
|
const [dialog, setDialog] = useState<DialogState>(null);
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const [restoreSubscription, setRestoreSubscription] = useState(false);
|
const [notifyUser, setNotifyUser] = useState(Boolean(reportSentAt || userRestrictionActive));
|
||||||
const [loading, setLoading] = useState(false);
|
const [reportPreview, setReportPreview] = useState(riskReport ?? "");
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
const availableModes = useMemo(() => {
|
const availableModes = useMemo(() => {
|
||||||
return [modes.ACKNOWLEDGED, modes.RESOLVED, modes.OPEN].filter((item) => item.status !== reviewStatus);
|
return [modes.ACKNOWLEDGED, modes.OPEN].filter((item) => item.status !== reviewStatus);
|
||||||
}, [reviewStatus]);
|
}, [reviewStatus]);
|
||||||
|
|
||||||
function openDialog(nextMode: RiskReviewMode) {
|
const activeFinalCopy = dialog?.type === "final"
|
||||||
setMode(nextMode);
|
? finalActionCopy(dialog.action, restorableSubscriptionCount)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
function openReviewDialog(mode: RiskReviewMode) {
|
||||||
|
setDialog({ type: "review", mode });
|
||||||
setNote("");
|
setNote("");
|
||||||
setRestoreSubscription(nextMode.status === "RESOLVED" && canRestoreSubscription);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
function openFinalDialog(action: SubscriptionRiskFinalAction) {
|
||||||
if (!mode) return;
|
setDialog({ type: "final", action });
|
||||||
|
setNote("");
|
||||||
|
setNotifyUser(action === "KEEP_RESTRICTED" ? true : Boolean(reportSentAt || userRestrictionActive));
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
function handleGenerateReport(openAfterGenerate = true) {
|
||||||
try {
|
startTransition(async () => {
|
||||||
await updateSubscriptionRiskReview(eventId, mode.status, note, {
|
try {
|
||||||
restoreSubscription: mode.status === "RESOLVED" && restoreSubscription,
|
const result = await generateSubscriptionRiskReport(eventId);
|
||||||
});
|
setReportPreview(result.report);
|
||||||
toast.success("风控事件已更新");
|
toast.success("风险报告已生成");
|
||||||
setMode(null);
|
if (openAfterGenerate) setDialog({ type: "report" });
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(getErrorMessage(error, "更新风控事件失败"));
|
toast.error(getErrorMessage(error, "生成风险报告失败"));
|
||||||
} finally {
|
}
|
||||||
setLoading(false);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSendReport() {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await sendSubscriptionRiskReport(eventId);
|
||||||
|
toast.success("已发送用户端强制通知");
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, "发送用户通知失败"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitReview() {
|
||||||
|
if (dialog?.type !== "review") return;
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await updateSubscriptionRiskReview(eventId, dialog.mode.status, note);
|
||||||
|
toast.success("风控事件已更新");
|
||||||
|
setDialog(null);
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, "更新风控事件失败"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitFinalDecision() {
|
||||||
|
if (dialog?.type !== "final") return;
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await finalizeSubscriptionRiskDecision(eventId, dialog.action, note, {
|
||||||
|
notifyUser: dialog.action === "KEEP_RESTRICTED" && notifyUser,
|
||||||
|
});
|
||||||
|
toast.success(dialog.action === "RESTORE_ACCESS" ? "已解除限制" : "已保持限制并记录处置");
|
||||||
|
setDialog(null);
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, "保存最终处置失败"));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex max-w-72 flex-wrap justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => handleGenerateReport(true)}
|
||||||
|
>
|
||||||
|
<FileText className="size-4" />
|
||||||
|
{reportPreview ? "重生成报告" : "生成报告"}
|
||||||
|
</Button>
|
||||||
|
{reportPreview && (
|
||||||
|
<Button size="sm" variant="ghost" disabled={pending} onClick={() => setDialog({ type: "report" })}>
|
||||||
|
查看报告
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="outline" disabled={pending} onClick={handleSendReport}>
|
||||||
|
<Send className="size-4" />
|
||||||
|
{reportSentAt ? "重新发送" : "发送用户"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={canRestoreSubscription || userRestrictionActive ? "default" : "outline"}
|
||||||
|
disabled={pending || (!canRestoreSubscription && !userRestrictionActive && reviewStatus === "RESOLVED")}
|
||||||
|
onClick={() => openFinalDialog("RESTORE_ACCESS")}
|
||||||
|
>
|
||||||
|
<UnlockKeyhole className="size-4" />
|
||||||
|
解除限制
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={pending || finalAction === "KEEP_RESTRICTED"}
|
||||||
|
onClick={() => openFinalDialog("KEEP_RESTRICTED")}
|
||||||
|
>
|
||||||
|
<LockKeyhole className="size-4" />
|
||||||
|
保持封禁
|
||||||
|
</Button>
|
||||||
{availableModes.map((item) => (
|
{availableModes.map((item) => (
|
||||||
<Button
|
<Button
|
||||||
key={item.status}
|
key={item.status}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={item.status === "RESOLVED" ? "default" : "outline"}
|
variant="ghost"
|
||||||
onClick={() => openDialog(item)}
|
disabled={pending}
|
||||||
|
onClick={() => openReviewDialog(item)}
|
||||||
>
|
>
|
||||||
<ModeIcon icon={item.icon} />
|
<ModeIcon icon={item.icon} />
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -116,54 +248,124 @@ export function SubscriptionRiskReviewActions({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={mode != null} onOpenChange={(open) => !loading && !open && setMode(null)}>
|
<Dialog open={dialog != null} onOpenChange={(open) => !pending && !open && setDialog(null)}>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className={dialog?.type === "report" ? "sm:max-w-3xl" : "sm:max-w-lg"}>
|
||||||
{mode && (
|
{dialog?.type === "review" && (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="mb-1 flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
<div className="mb-1 flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||||
<ModeIcon icon={mode.icon} />
|
<ModeIcon icon={dialog.mode.icon} />
|
||||||
</div>
|
</div>
|
||||||
<DialogTitle>{mode.title}</DialogTitle>
|
<DialogTitle>{dialog.mode.title}</DialogTitle>
|
||||||
<DialogDescription>{mode.description}</DialogDescription>
|
<DialogDescription>{dialog.mode.description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={"risk-note-" + eventId}>处理备注</Label>
|
||||||
|
<Textarea
|
||||||
|
id={"risk-note-" + eventId}
|
||||||
|
value={note}
|
||||||
|
onChange={(event) => setNote(event.target.value)}
|
||||||
|
maxLength={1000}
|
||||||
|
placeholder="例如:已联系用户确认是出差;或确认订阅链接外泄,继续限制。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setDialog(null)} disabled={pending}>
|
||||||
|
先不处理
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={submitReview} disabled={pending}>
|
||||||
|
{pending ? "保存中..." : dialog.mode.label}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dialog?.type === "final" && activeFinalCopy && (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="mb-1 flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||||
|
{activeFinalCopy.icon}
|
||||||
|
</div>
|
||||||
|
<DialogTitle>{activeFinalCopy.title}</DialogTitle>
|
||||||
|
<DialogDescription>{activeFinalCopy.description}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
{dialog.action === "RESTORE_ACCESS" && restorableSubscriptionCount > 0 && (
|
||||||
<Label htmlFor={`risk-note-${eventId}`}>处理备注</Label>
|
<div className="rounded-lg border border-primary/20 bg-primary/8 p-3 text-sm leading-6 text-primary">
|
||||||
<Textarea
|
将尝试恢复 {restorableSubscriptionCount} 个仍在有效期内的暂停代理订阅。
|
||||||
id={`risk-note-${eventId}`}
|
</div>
|
||||||
value={note}
|
)}
|
||||||
onChange={(event) => setNote(event.target.value)}
|
{dialog.action === "KEEP_RESTRICTED" && (
|
||||||
maxLength={1000}
|
|
||||||
placeholder="例如:已联系用户确认是出差;或确认订阅链接外泄,已重置/暂停处理。"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mode.status === "RESOLVED" && canRestoreSubscription && (
|
|
||||||
<label className="flex items-start gap-3 rounded-lg border border-border/70 bg-muted/30 p-3 text-sm leading-6">
|
<label className="flex items-start gap-3 rounded-lg border border-border/70 bg-muted/30 p-3 text-sm leading-6">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
checked={restoreSubscription}
|
checked={notifyUser}
|
||||||
onChange={(event) => setRestoreSubscription(event.target.checked)}
|
onChange={(event) => setNotifyUser(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
同时恢复这个已暂停订阅
|
同时发送用户端强制通知
|
||||||
<span className="block text-xs text-muted-foreground">
|
<span className="block text-xs text-muted-foreground">
|
||||||
会同步重新启用 3x-ui 客户端;如果远端面板失败,事件不会被误标记为已解决。
|
用户会看到全屏不可关闭说明,只能进入工单页面联系客服。
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={"risk-final-note-" + eventId}>最终处理备注</Label>
|
||||||
|
<Textarea
|
||||||
|
id={"risk-final-note-" + eventId}
|
||||||
|
value={note}
|
||||||
|
onChange={(event) => setNote(event.target.value)}
|
||||||
|
maxLength={1000}
|
||||||
|
placeholder="记录最终判断依据,例如:用户提交工单证明为本人出差;或确认链接被多人共享,保持限制。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => setMode(null)} disabled={loading}>
|
<Button type="button" variant="outline" onClick={() => setDialog(null)} disabled={pending}>
|
||||||
先不处理
|
返回
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" onClick={() => void submit()} disabled={loading}>
|
<Button
|
||||||
{loading ? "保存中..." : mode.label}
|
type="button"
|
||||||
|
variant={dialog.action === "KEEP_RESTRICTED" ? "destructive" : "default"}
|
||||||
|
onClick={submitFinalDecision}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
{pending ? "保存中..." : activeFinalCopy.confirm}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dialog?.type === "report" && (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="mb-1 flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||||
|
<FileText className="size-4" />
|
||||||
|
</div>
|
||||||
|
<DialogTitle>风险报告总结</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
可作为人工复核依据,也可以发送给用户端形成强制通知。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<pre className="max-h-[28rem] overflow-auto whitespace-pre-wrap rounded-lg border border-border/70 bg-muted/30 p-4 text-xs leading-6 text-foreground">
|
||||||
|
{reportPreview || "尚未生成风险报告。"}
|
||||||
|
</pre>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setDialog(null)} disabled={pending}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => handleGenerateReport(false)} disabled={pending}>
|
||||||
|
{pending ? "生成中..." : "重新生成"}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={handleSendReport} disabled={pending}>
|
||||||
|
<Send className="size-4" />
|
||||||
|
发送用户
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
|
|||||||
106
src/components/user/subscription-risk-restriction-gate.tsx
Normal file
106
src/components/user/subscription-risk-restriction-gate.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { AlertTriangle, FileText, LifeBuoy } from "lucide-react";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export type SubscriptionRiskRestrictionNotice = {
|
||||||
|
id: string;
|
||||||
|
level: "WARNING" | "SUSPENDED";
|
||||||
|
reasonLabel: string;
|
||||||
|
message: string;
|
||||||
|
riskReport: string | null;
|
||||||
|
reportSentAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function supportHref(id: string) {
|
||||||
|
return "/support?riskEventId=" + encodeURIComponent(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubscriptionRiskRestrictionGate({
|
||||||
|
restriction,
|
||||||
|
}: {
|
||||||
|
restriction: SubscriptionRiskRestrictionNotice | null;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
if (!restriction) return null;
|
||||||
|
|
||||||
|
const isSupportPath = pathname === "/support" || pathname.startsWith("/support/");
|
||||||
|
|
||||||
|
if (isSupportPath) {
|
||||||
|
return (
|
||||||
|
<section className="mb-5 rounded-xl border border-destructive/25 bg-destructive/8 p-4 text-sm leading-6">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10 text-destructive">
|
||||||
|
<AlertTriangle className="size-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-destructive">订阅风控限制处理中</p>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
请在工单中说明近期订阅访问来源。管理员解除前,其他用户中心操作会被临时限制。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-destructive/20 px-2.5 py-1 text-xs font-medium text-destructive">
|
||||||
|
{restriction.reasonLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[80] flex items-center justify-center overflow-y-auto bg-background/96 p-4 backdrop-blur-md">
|
||||||
|
<div className="w-full max-w-3xl overflow-hidden rounded-2xl border border-destructive/25 bg-card shadow-2xl">
|
||||||
|
<div className="border-b border-border/70 bg-destructive/8 p-5 sm:p-6">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-destructive/10 text-destructive">
|
||||||
|
<AlertTriangle className="size-5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-destructive">订阅风控强制通知</p>
|
||||||
|
<h2 className="mt-1 text-xl font-semibold tracking-[-0.02em]">账户操作已临时限制</h2>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||||
|
系统检测到订阅链接出现异常地区或 IP 访问。管理员解除前,你只能新建工单联系客服完成核验。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="w-fit rounded-full border border-destructive/20 px-3 py-1 text-xs font-medium text-destructive">
|
||||||
|
{restriction.reasonLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 p-5 sm:p-6">
|
||||||
|
<div className="rounded-xl border border-border/70 bg-muted/25 p-4">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<FileText className="size-4 text-primary" /> 风险摘要
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">{restriction.message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{restriction.riskReport && (
|
||||||
|
<pre className="max-h-72 overflow-auto whitespace-pre-wrap rounded-xl border border-border/70 bg-muted/30 p-4 text-xs leading-6 text-foreground">
|
||||||
|
{restriction.riskReport}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border border-primary/20 bg-primary/8 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="text-sm leading-6">
|
||||||
|
<p className="font-semibold text-primary">下一步</p>
|
||||||
|
<p className="text-muted-foreground">新建工单说明访问来源、所在地区和是否共享过订阅链接。</p>
|
||||||
|
</div>
|
||||||
|
<Link href={supportHref(restriction.id)} className={buttonVariants({ size: "lg" })}>
|
||||||
|
<LifeBuoy className="size-4" />
|
||||||
|
新建工单联系客服
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "./auth";
|
import { authOptions } from "./auth";
|
||||||
|
import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review";
|
||||||
|
|
||||||
export async function requireAdmin() {
|
export async function requireAdmin() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
@@ -9,10 +10,18 @@ export async function requireAdmin() {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireAuth() {
|
export async function requireAuth(options: { allowDuringRiskRestriction?: boolean } = {}) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error("未登录");
|
throw new Error("未登录");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (session.user.role !== "ADMIN" && !options.allowDuringRiskRestriction) {
|
||||||
|
const restriction = await getActiveSubscriptionRiskRestriction(session.user.id);
|
||||||
|
if (restriction) {
|
||||||
|
throw new Error("账户存在未处理的订阅风控限制,请先新建工单联系客服");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|||||||
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