mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
Initial commit
This commit is contained in:
189
src/actions/admin/announcements.ts
Normal file
189
src/actions/admin/announcements.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/require-auth";
|
||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||
import {
|
||||
deleteAnnouncementNotifications,
|
||||
dispatchAnnouncementNotifications,
|
||||
syncAnnouncementNotifications,
|
||||
} from "@/services/announcements";
|
||||
|
||||
const announcementSchema = z.object({
|
||||
title: z.string().trim().min(1, "标题不能为空"),
|
||||
body: z.string().trim().min(1, "内容不能为空"),
|
||||
audience: z.enum(["PUBLIC", "USERS", "ADMINS", "SPECIFIC_USER"]),
|
||||
displayType: z.enum(["INLINE", "BIG", "POPUP"]).default("INLINE"),
|
||||
targetUserId: z.string().optional(),
|
||||
dismissible: z.string().optional(),
|
||||
sendNotification: z.string().optional(),
|
||||
startAt: z.string().optional(),
|
||||
endAt: z.string().optional(),
|
||||
});
|
||||
|
||||
function revalidateAnnouncementViews() {
|
||||
revalidatePath("/admin/announcements");
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/account");
|
||||
revalidatePath("/notifications");
|
||||
revalidatePath("/login");
|
||||
}
|
||||
|
||||
function parseAnnouncementInput(formData: FormData) {
|
||||
const data = announcementSchema.parse(Object.fromEntries(formData));
|
||||
|
||||
if (data.audience === "SPECIFIC_USER" && !data.targetUserId) {
|
||||
throw new Error("定向消息必须选择用户");
|
||||
}
|
||||
|
||||
const startAt = data.startAt ? new Date(data.startAt) : null;
|
||||
const endAt = data.endAt ? new Date(data.endAt) : null;
|
||||
if (startAt && Number.isNaN(startAt.getTime())) {
|
||||
throw new Error("开始时间格式无效");
|
||||
}
|
||||
if (endAt && Number.isNaN(endAt.getTime())) {
|
||||
throw new Error("结束时间格式无效");
|
||||
}
|
||||
if (startAt && endAt && endAt <= startAt) {
|
||||
throw new Error("结束时间必须晚于开始时间");
|
||||
}
|
||||
|
||||
return {
|
||||
title: data.title,
|
||||
body: data.body,
|
||||
audience: data.audience,
|
||||
displayType: data.displayType,
|
||||
targetUserId: data.audience === "SPECIFIC_USER" ? data.targetUserId ?? null : null,
|
||||
dismissible: data.dismissible === "true",
|
||||
sendNotification: data.sendNotification === "true",
|
||||
startAt,
|
||||
endAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAnnouncement(formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const data = parseAnnouncementInput(formData);
|
||||
|
||||
const announcement = await prisma.announcement.create({
|
||||
data: {
|
||||
title: data.title,
|
||||
body: data.body,
|
||||
audience: data.audience,
|
||||
displayType: data.displayType,
|
||||
targetUserId: data.targetUserId,
|
||||
createdById: session.user.id,
|
||||
isActive: true,
|
||||
dismissible: data.dismissible,
|
||||
sendNotification: data.sendNotification,
|
||||
startAt: data.startAt,
|
||||
endAt: data.endAt,
|
||||
},
|
||||
});
|
||||
|
||||
await dispatchAnnouncementNotifications(announcement.id);
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "announcement.create",
|
||||
targetType: "Announcement",
|
||||
targetId: announcement.id,
|
||||
targetLabel: announcement.title,
|
||||
message: `创建公告/消息 ${announcement.title}`,
|
||||
});
|
||||
|
||||
revalidateAnnouncementViews();
|
||||
}
|
||||
|
||||
export async function updateAnnouncement(id: string, formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const data = parseAnnouncementInput(formData);
|
||||
|
||||
const announcement = await prisma.announcement.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: data.title,
|
||||
body: data.body,
|
||||
audience: data.audience,
|
||||
displayType: data.displayType,
|
||||
targetUserId: data.targetUserId,
|
||||
dismissible: data.dismissible,
|
||||
sendNotification: data.sendNotification,
|
||||
startAt: data.startAt,
|
||||
endAt: data.endAt,
|
||||
},
|
||||
});
|
||||
|
||||
await syncAnnouncementNotifications(announcement.id);
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "announcement.update",
|
||||
targetType: "Announcement",
|
||||
targetId: announcement.id,
|
||||
targetLabel: announcement.title,
|
||||
message: `更新公告/消息 ${announcement.title}`,
|
||||
});
|
||||
|
||||
revalidateAnnouncementViews();
|
||||
}
|
||||
|
||||
export async function toggleAnnouncement(id: string, isActive: boolean) {
|
||||
const session = await requireAdmin();
|
||||
const announcement = await prisma.announcement.update({
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
});
|
||||
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: isActive ? "announcement.enable" : "announcement.disable",
|
||||
targetType: "Announcement",
|
||||
targetId: announcement.id,
|
||||
targetLabel: announcement.title,
|
||||
message: `${isActive ? "启用" : "停用"}公告/消息 ${announcement.title}`,
|
||||
});
|
||||
|
||||
if (isActive) {
|
||||
await dispatchAnnouncementNotifications(announcement.id);
|
||||
}
|
||||
|
||||
revalidateAnnouncementViews();
|
||||
}
|
||||
|
||||
export async function deleteAnnouncement(id: string) {
|
||||
const session = await requireAdmin();
|
||||
const announcement = await prisma.announcement.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!announcement) {
|
||||
throw new Error("公告不存在");
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await deleteAnnouncementNotifications(announcement.id, tx);
|
||||
await tx.announcement.delete({
|
||||
where: {
|
||||
id: announcement.id,
|
||||
},
|
||||
});
|
||||
await recordAuditLog(
|
||||
{
|
||||
actor: actorFromSession(session),
|
||||
action: "announcement.delete",
|
||||
targetType: "Announcement",
|
||||
targetId: announcement.id,
|
||||
targetLabel: announcement.title,
|
||||
message: `删除公告/消息 ${announcement.title}`,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
revalidateAnnouncementViews();
|
||||
}
|
||||
34
src/actions/admin/backups.ts
Normal file
34
src/actions/admin/backups.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireAdmin } from "@/lib/require-auth";
|
||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||
import { restoreDatabaseBackupFile, restoreDatabaseBackupSql } from "@/services/database-backup";
|
||||
|
||||
export async function restoreDatabaseBackup(formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const sqlText = String(formData.get("sqlText") || "").trim();
|
||||
const file = formData.get("sqlFile");
|
||||
const confirmation = String(formData.get("confirmation") || "");
|
||||
|
||||
if (confirmation !== "RESTORE") {
|
||||
throw new Error("请输入 RESTORE 确认恢复操作");
|
||||
}
|
||||
|
||||
if (file instanceof File && file.size > 0) {
|
||||
await restoreDatabaseBackupFile(file);
|
||||
} else if (sqlText) {
|
||||
await restoreDatabaseBackupSql(sqlText);
|
||||
} else {
|
||||
throw new Error("请上传 SQL 备份文件或粘贴 SQL 内容");
|
||||
}
|
||||
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "backup.restore",
|
||||
targetType: "Database",
|
||||
message: "执行数据库恢复",
|
||||
});
|
||||
|
||||
revalidatePath("/admin/backups");
|
||||
}
|
||||
127
src/actions/admin/commerce.ts
Normal file
127
src/actions/admin/commerce.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/require-auth";
|
||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||
|
||||
const optionalNumber = z.preprocess(
|
||||
(value) => (value === "" || value == null ? undefined : Number(value)),
|
||||
z.number().optional(),
|
||||
);
|
||||
const optionalInt = z.preprocess(
|
||||
(value) => (value === "" || value == null ? undefined : Number(value)),
|
||||
z.number().int().optional(),
|
||||
);
|
||||
|
||||
const couponSchema = z.object({
|
||||
code: z.string().trim().min(2).transform((value) => value.toUpperCase()),
|
||||
name: z.string().trim().min(1),
|
||||
description: z.string().trim().optional(),
|
||||
discountType: z.enum(["AMOUNT_OFF", "PERCENT_OFF"]),
|
||||
discountValue: z.coerce.number().positive(),
|
||||
thresholdAmount: optionalNumber,
|
||||
maxDiscountAmount: optionalNumber,
|
||||
totalLimit: optionalInt,
|
||||
perUserLimit: optionalInt,
|
||||
isPublic: z.string().optional(),
|
||||
});
|
||||
|
||||
const promotionSchema = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
thresholdAmount: z.coerce.number().positive(),
|
||||
discountAmount: z.coerce.number().positive(),
|
||||
sortOrder: z.coerce.number().int().default(100),
|
||||
});
|
||||
|
||||
export async function createCoupon(formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const data = couponSchema.parse(Object.fromEntries(formData));
|
||||
if (data.discountType === "PERCENT_OFF" && data.discountValue > 100) {
|
||||
throw new Error("折扣百分比不能超过 100");
|
||||
}
|
||||
const coupon = await prisma.coupon.create({
|
||||
data: {
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
description: data.description || null,
|
||||
discountType: data.discountType,
|
||||
discountValue: data.discountValue,
|
||||
thresholdAmount: data.thresholdAmount ?? null,
|
||||
maxDiscountAmount: data.maxDiscountAmount ?? null,
|
||||
totalLimit: data.totalLimit ?? null,
|
||||
perUserLimit: data.perUserLimit ?? null,
|
||||
isPublic: data.isPublic === "true",
|
||||
},
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "coupon.create",
|
||||
targetType: "Coupon",
|
||||
targetId: coupon.id,
|
||||
targetLabel: coupon.code,
|
||||
message: `创建优惠券 ${coupon.code}`,
|
||||
});
|
||||
revalidateCommerce();
|
||||
}
|
||||
|
||||
export async function toggleCoupon(id: string, isActive: boolean) {
|
||||
const session = await requireAdmin();
|
||||
const coupon = await prisma.coupon.update({ where: { id }, data: { isActive } });
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "coupon.toggle",
|
||||
targetType: "Coupon",
|
||||
targetId: id,
|
||||
targetLabel: coupon.code,
|
||||
message: `${isActive ? "启用" : "停用"}优惠券 ${coupon.code}`,
|
||||
});
|
||||
revalidateCommerce();
|
||||
}
|
||||
|
||||
export async function createPromotionRule(formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const data = promotionSchema.parse(Object.fromEntries(formData));
|
||||
if (data.discountAmount >= data.thresholdAmount) {
|
||||
throw new Error("满减金额应小于门槛金额");
|
||||
}
|
||||
const rule = await prisma.promotionRule.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
thresholdAmount: data.thresholdAmount,
|
||||
discountAmount: data.discountAmount,
|
||||
sortOrder: data.sortOrder,
|
||||
},
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "promotion.create",
|
||||
targetType: "PromotionRule",
|
||||
targetId: rule.id,
|
||||
targetLabel: rule.name,
|
||||
message: `创建满减规则 ${rule.name}`,
|
||||
});
|
||||
revalidateCommerce();
|
||||
}
|
||||
|
||||
export async function togglePromotionRule(id: string, isActive: boolean) {
|
||||
const session = await requireAdmin();
|
||||
const rule = await prisma.promotionRule.update({ where: { id }, data: { isActive } });
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "promotion.toggle",
|
||||
targetType: "PromotionRule",
|
||||
targetId: id,
|
||||
targetLabel: rule.name,
|
||||
message: `${isActive ? "启用" : "停用"}满减规则 ${rule.name}`,
|
||||
});
|
||||
revalidateCommerce();
|
||||
}
|
||||
|
||||
function revalidateCommerce() {
|
||||
revalidatePath("/admin/commerce");
|
||||
revalidatePath("/admin/plans");
|
||||
revalidatePath("/store");
|
||||
revalidatePath("/cart");
|
||||
}
|
||||
245
src/actions/admin/nodes.ts
Normal file
245
src/actions/admin/nodes.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
"use server";
|
||||
|
||||
import crypto from "crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/require-auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { testAndSyncNodeInbounds } from "@/services/node-panel/sync-inbounds";
|
||||
|
||||
const nodeSchema = z.object({
|
||||
name: z.string().trim().optional(),
|
||||
panelUrl: z.string().trim().min(1, "3x-ui 面板地址必填"),
|
||||
panelUsername: z.string().trim().min(1, "3x-ui 用户名必填"),
|
||||
panelPassword: z.string().trim().min(1, "3x-ui 密码必填"),
|
||||
});
|
||||
|
||||
function normalizePanelUrl(raw: string): string {
|
||||
try {
|
||||
let value = raw.trim();
|
||||
if (!/^https?:\/\//i.test(value)) {
|
||||
value = `http://${value}`;
|
||||
}
|
||||
|
||||
const url = new URL(value);
|
||||
let pathname = url.pathname.replace(/\/+$/, "");
|
||||
pathname = pathname.replace(/\/panel\/login$/i, "");
|
||||
pathname = pathname.replace(/\/panel$/i, "");
|
||||
pathname = pathname.replace(/\/login$/i, "");
|
||||
return `${url.origin}${pathname}`;
|
||||
} catch {
|
||||
throw new Error("面板地址格式不正确,请填写 IP:端口 或 http://IP:端口");
|
||||
}
|
||||
}
|
||||
|
||||
function parseNodeData(formData: FormData) {
|
||||
const raw = nodeSchema.parse(Object.fromEntries(formData));
|
||||
const panelUrl = normalizePanelUrl(raw.panelUrl);
|
||||
const panel = new URL(panelUrl);
|
||||
|
||||
const name = (raw.name || "").trim() || `节点-${panel.hostname}`;
|
||||
return {
|
||||
name,
|
||||
panelUrl,
|
||||
panelUsername: raw.panelUsername,
|
||||
panelPassword: raw.panelPassword,
|
||||
panelType: "3x-ui",
|
||||
};
|
||||
}
|
||||
|
||||
export async function createNode(formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const data = parseNodeData(formData);
|
||||
const node = await prisma.nodeServer.create({ data });
|
||||
const result = await testAndSyncNodeInbounds(node);
|
||||
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "node.create",
|
||||
targetType: "NodeServer",
|
||||
targetId: node.id,
|
||||
targetLabel: node.name,
|
||||
message: `创建 3x-ui 节点 ${node.name}`,
|
||||
});
|
||||
revalidatePath("/admin/nodes");
|
||||
return {
|
||||
...result,
|
||||
message: result.success ? `节点创建成功,${result.message}` : `节点已创建,但${result.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateNode(id: string, formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const data = parseNodeData(formData);
|
||||
const node = await prisma.nodeServer.update({ where: { id }, data });
|
||||
const result = await testAndSyncNodeInbounds(node);
|
||||
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "node.update",
|
||||
targetType: "NodeServer",
|
||||
targetId: node.id,
|
||||
targetLabel: node.name,
|
||||
message: `更新 3x-ui 节点 ${node.name}`,
|
||||
});
|
||||
revalidatePath("/admin/nodes");
|
||||
return {
|
||||
...result,
|
||||
message: result.success ? `节点已更新,${result.message}` : `节点已更新,但${result.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteNode(id: string) {
|
||||
const session = await requireAdmin();
|
||||
const node = await prisma.nodeServer.delete({ where: { id } });
|
||||
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "node.delete",
|
||||
targetType: "NodeServer",
|
||||
targetId: node.id,
|
||||
targetLabel: node.name,
|
||||
message: `删除节点 ${node.name}`,
|
||||
});
|
||||
revalidatePath("/admin/nodes");
|
||||
}
|
||||
|
||||
export async function testNodeConnection(id: string) {
|
||||
const session = await requireAdmin();
|
||||
const server = await prisma.nodeServer.findUniqueOrThrow({ where: { id } });
|
||||
const result = await testAndSyncNodeInbounds(server);
|
||||
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "node.test",
|
||||
targetType: "NodeServer",
|
||||
targetId: server.id,
|
||||
targetLabel: server.name,
|
||||
message: `测试 3x-ui 节点 ${server.name}:${result.message}`,
|
||||
});
|
||||
revalidatePath("/admin/nodes");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function batchTestNodeConnections(formData: FormData) {
|
||||
const nodeIds = formData.getAll("nodeIds").map(String).filter(Boolean);
|
||||
|
||||
if (nodeIds.length === 0) {
|
||||
throw new Error("请至少选择一个节点");
|
||||
}
|
||||
|
||||
for (const nodeId of nodeIds) {
|
||||
await testNodeConnection(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
function withInboundDisplayName(settings: unknown, displayName: string) {
|
||||
const base = settings && typeof settings === "object" && !Array.isArray(settings)
|
||||
? settings as Record<string, unknown>
|
||||
: {};
|
||||
|
||||
return { ...base, displayName: displayName.trim() };
|
||||
}
|
||||
|
||||
const inboundDisplayNameSchema = z.object({
|
||||
displayName: z.string().trim().min(1, "前台名称不能为空").max(60, "前台名称不能超过 60 个字符"),
|
||||
});
|
||||
|
||||
export async function updateInboundDisplayName(id: string, formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const { displayName } = inboundDisplayNameSchema.parse(Object.fromEntries(formData));
|
||||
const inbound = await prisma.nodeInbound.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { server: { select: { name: true } } },
|
||||
});
|
||||
|
||||
await prisma.nodeInbound.update({
|
||||
where: { id },
|
||||
data: { settings: withInboundDisplayName(inbound.settings, displayName) },
|
||||
});
|
||||
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "inbound.display_name.update",
|
||||
targetType: "NodeInbound",
|
||||
targetId: inbound.id,
|
||||
targetLabel: displayName,
|
||||
message: `更新节点 ${inbound.server.name} 的前台线路名称`,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/nodes");
|
||||
revalidatePath("/store");
|
||||
}
|
||||
|
||||
export async function deleteInbound(id: string) {
|
||||
const session = await requireAdmin();
|
||||
const inbound = await prisma.nodeInbound.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { server: true },
|
||||
});
|
||||
|
||||
await prisma.nodeInbound.delete({ where: { id } });
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "inbound.delete",
|
||||
targetType: "NodeInbound",
|
||||
targetId: inbound.id,
|
||||
targetLabel: `${inbound.protocol}:${inbound.port}`,
|
||||
message: `从本地移除节点 ${inbound.server.name} 的入站 ${inbound.protocol}:${inbound.port}`,
|
||||
});
|
||||
revalidatePath("/admin/nodes");
|
||||
}
|
||||
|
||||
export async function generateAgentToken(nodeId: string) {
|
||||
const session = await requireAdmin();
|
||||
const node = await prisma.nodeServer.findUniqueOrThrow({
|
||||
where: { id: nodeId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
const plainToken = crypto.randomBytes(32).toString("hex");
|
||||
const encrypted = encrypt(plainToken);
|
||||
|
||||
await prisma.nodeServer.update({
|
||||
where: { id: nodeId },
|
||||
data: { agentToken: encrypted },
|
||||
});
|
||||
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "node.probe_token.generate",
|
||||
targetType: "NodeServer",
|
||||
targetId: node.id,
|
||||
targetLabel: node.name,
|
||||
message: `为节点 ${node.name} 生成探测 Token`,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/nodes");
|
||||
return plainToken;
|
||||
}
|
||||
|
||||
export async function revokeAgentToken(nodeId: string) {
|
||||
const session = await requireAdmin();
|
||||
const node = await prisma.nodeServer.findUniqueOrThrow({
|
||||
where: { id: nodeId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
await prisma.nodeServer.update({
|
||||
where: { id: nodeId },
|
||||
data: { agentToken: null },
|
||||
});
|
||||
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "node.probe_token.revoke",
|
||||
targetType: "NodeServer",
|
||||
targetId: node.id,
|
||||
targetLabel: node.name,
|
||||
message: `撤销节点 ${node.name} 的探测 Token`,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/nodes");
|
||||
}
|
||||
92
src/actions/admin/orders.ts
Normal file
92
src/actions/admin/orders.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/require-auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { confirmPendingOrder } from "@/services/payment/process";
|
||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||
|
||||
export async function confirmOrder(orderId: string) {
|
||||
const session = await requireAdmin();
|
||||
const order = await prisma.order.findUniqueOrThrow({
|
||||
where: { id: orderId },
|
||||
select: { status: true, id: true },
|
||||
});
|
||||
if (order.status !== "PENDING") {
|
||||
throw new Error("订单状态不正确");
|
||||
}
|
||||
|
||||
const result = await confirmPendingOrder(orderId);
|
||||
if (result.finalStatus !== "PAID") {
|
||||
throw new Error(result.errorMessage ?? "订单处理失败");
|
||||
}
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "order.confirm",
|
||||
targetType: "Order",
|
||||
targetId: order.id,
|
||||
targetLabel: order.id,
|
||||
message: `确认订单 ${order.id}`,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/orders");
|
||||
}
|
||||
|
||||
export async function cancelOrder(orderId: string) {
|
||||
const session = await requireAdmin();
|
||||
await prisma.order.update({ where: { id: orderId }, data: { status: "CANCELLED" } });
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "order.cancel",
|
||||
targetType: "Order",
|
||||
targetId: orderId,
|
||||
targetLabel: orderId,
|
||||
message: `取消订单 ${orderId}`,
|
||||
});
|
||||
revalidatePath("/admin/orders");
|
||||
}
|
||||
|
||||
export async function updateOrderReview(
|
||||
orderId: string,
|
||||
reviewStatus: "NORMAL" | "FLAGGED" | "RESOLVED",
|
||||
reviewNote?: string,
|
||||
) {
|
||||
const session = await requireAdmin();
|
||||
const order = await prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
reviewStatus,
|
||||
reviewNote: reviewNote?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "order.review",
|
||||
targetType: "Order",
|
||||
targetId: order.id,
|
||||
targetLabel: order.id,
|
||||
message: `将订单 ${order.id} 标记为 ${reviewStatus}`,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/orders");
|
||||
}
|
||||
|
||||
export async function batchOrderOperation(formData: FormData) {
|
||||
const action = String(formData.get("action") || "");
|
||||
const orderIds = formData.getAll("orderIds").map(String).filter(Boolean);
|
||||
|
||||
if (orderIds.length === 0) {
|
||||
throw new Error("请至少选择一个订单");
|
||||
}
|
||||
|
||||
for (const orderId of orderIds) {
|
||||
if (action === "confirm") {
|
||||
await confirmOrder(orderId);
|
||||
} else if (action === "cancel") {
|
||||
await cancelOrder(orderId);
|
||||
} else {
|
||||
throw new Error("不支持的批量操作");
|
||||
}
|
||||
}
|
||||
}
|
||||
51
src/actions/admin/payments.ts
Normal file
51
src/actions/admin/payments.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/require-auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
normalizePaymentConfig,
|
||||
parsePaymentConfig,
|
||||
} from "@/services/payment/catalog";
|
||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||
import { z } from "zod";
|
||||
|
||||
export async function savePaymentConfig(
|
||||
provider: string,
|
||||
config: Record<string, string>,
|
||||
enabled: boolean
|
||||
) {
|
||||
const session = await requireAdmin();
|
||||
|
||||
const normalizedConfig = normalizePaymentConfig(config);
|
||||
let finalConfig = normalizedConfig as Record<string, string | number>;
|
||||
|
||||
if (enabled) {
|
||||
try {
|
||||
finalConfig = parsePaymentConfig(provider, normalizedConfig) as Record<string, string | number>;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const messages = error.issues.map((e) => e.message).join(";");
|
||||
throw new Error(messages);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const jsonConfig = JSON.parse(JSON.stringify(finalConfig));
|
||||
|
||||
await prisma.paymentConfig.upsert({
|
||||
where: { provider },
|
||||
create: { provider, config: jsonConfig, enabled },
|
||||
update: { config: jsonConfig, enabled },
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "payment.config",
|
||||
targetType: "PaymentConfig",
|
||||
targetId: provider,
|
||||
targetLabel: provider,
|
||||
message: `${enabled ? "启用并更新" : "更新"}支付配置 ${provider}`,
|
||||
});
|
||||
revalidatePath("/admin/payments");
|
||||
}
|
||||
689
src/actions/admin/plans.ts
Normal file
689
src/actions/admin/plans.ts
Normal file
@@ -0,0 +1,689 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/require-auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||
import { deleteSubscriptionPermanently } from "./subscriptions";
|
||||
|
||||
const optionalNumber = z.preprocess(
|
||||
(value) => (value === "" || value == null ? undefined : Number(value)),
|
||||
z.number().optional(),
|
||||
);
|
||||
const optionalInt = z.preprocess(
|
||||
(value) => (value === "" || value == null ? undefined : Number(value)),
|
||||
z.number().int().optional(),
|
||||
);
|
||||
const optionalBool = z.preprocess((value) => {
|
||||
if (value === "" || value == null) return undefined;
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === "true" || normalized === "1" || normalized === "on";
|
||||
}
|
||||
return Boolean(value);
|
||||
}, z.boolean().optional());
|
||||
|
||||
const planSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
type: z.enum(["STREAMING", "PROXY"]),
|
||||
description: z.string().optional(),
|
||||
durationDays: z.coerce.number().int().positive(),
|
||||
sortOrder: z.coerce.number().int().default(100),
|
||||
price: optionalNumber,
|
||||
nodeId: z.string().optional(),
|
||||
inboundId: z.string().optional(),
|
||||
inboundIds: z.string().optional(),
|
||||
streamingServiceId: z.string().optional(),
|
||||
pricingMode: z.enum(["TRAFFIC_SLIDER", "FIXED_PACKAGE"]).optional(),
|
||||
fixedTrafficGb: optionalInt,
|
||||
fixedPrice: optionalNumber,
|
||||
totalLimit: optionalInt,
|
||||
perUserLimit: optionalInt,
|
||||
totalTrafficGb: optionalInt,
|
||||
allowRenewal: optionalBool,
|
||||
allowTrafficTopup: optionalBool,
|
||||
renewalPrice: optionalNumber,
|
||||
renewalPricingMode: z.enum(["PER_DAY", "FIXED_DURATION"]).optional(),
|
||||
renewalDurationDays: optionalInt,
|
||||
renewalMinDays: optionalInt,
|
||||
renewalMaxDays: optionalInt,
|
||||
renewalTrafficGb: optionalInt,
|
||||
topupPricingMode: z.enum(["PER_GB", "FIXED_AMOUNT"]).optional(),
|
||||
topupPricePerGb: optionalNumber,
|
||||
topupFixedPrice: optionalNumber,
|
||||
minTopupGb: optionalInt,
|
||||
maxTopupGb: optionalInt,
|
||||
pricePerGb: optionalNumber,
|
||||
minTrafficGb: optionalInt,
|
||||
maxTrafficGb: optionalInt,
|
||||
});
|
||||
|
||||
function parseInboundIds(raw: string | undefined, fallbackInboundId?: string): string[] {
|
||||
const list = (raw ?? "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
if (list.length > 0) {
|
||||
return Array.from(new Set(list));
|
||||
}
|
||||
if (fallbackInboundId) {
|
||||
return [fallbackInboundId];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function assertProxyPricing(data: z.infer<typeof planSchema>) {
|
||||
const pricingMode = data.pricingMode ?? "TRAFFIC_SLIDER";
|
||||
if (pricingMode === "FIXED_PACKAGE") {
|
||||
if (data.fixedTrafficGb == null || data.fixedTrafficGb <= 0) {
|
||||
throw new Error("固定流量套餐必须填写固定流量");
|
||||
}
|
||||
if (data.fixedPrice == null || data.fixedPrice <= 0) {
|
||||
throw new Error("固定流量套餐必须填写固定价格");
|
||||
}
|
||||
} else {
|
||||
if (data.pricePerGb == null || data.pricePerGb <= 0) {
|
||||
throw new Error("自选流量套餐必须填写每 GB 价格");
|
||||
}
|
||||
if (data.minTrafficGb == null || data.minTrafficGb <= 0) {
|
||||
throw new Error("自选流量套餐必须填写最小流量");
|
||||
}
|
||||
if (data.maxTrafficGb == null || data.maxTrafficGb <= 0) {
|
||||
throw new Error("自选流量套餐必须填写最大流量");
|
||||
}
|
||||
if (data.maxTrafficGb < data.minTrafficGb) {
|
||||
throw new Error("最大流量不能小于最小流量");
|
||||
}
|
||||
}
|
||||
return pricingMode;
|
||||
}
|
||||
|
||||
type PlanFormData = z.infer<typeof planSchema>;
|
||||
|
||||
function getRenewalPolicy(data: PlanFormData, allowRenewal: boolean) {
|
||||
if (!allowRenewal) {
|
||||
return {
|
||||
renewalPrice: null,
|
||||
renewalPricingMode: "FIXED_DURATION",
|
||||
renewalDurationDays: null,
|
||||
renewalMinDays: null,
|
||||
renewalMaxDays: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (data.renewalPrice == null || data.renewalPrice <= 0) {
|
||||
throw new Error("开启续费时,续费金额必须大于 0");
|
||||
}
|
||||
|
||||
const renewalPricingMode = data.renewalPricingMode ?? "FIXED_DURATION";
|
||||
|
||||
if (renewalPricingMode === "FIXED_DURATION") {
|
||||
const renewalDurationDays = data.renewalDurationDays ?? data.durationDays;
|
||||
if (!renewalDurationDays || renewalDurationDays <= 0) {
|
||||
throw new Error("固定周期续费必须填写续费天数");
|
||||
}
|
||||
|
||||
return {
|
||||
renewalPrice: data.renewalPrice,
|
||||
renewalPricingMode,
|
||||
renewalDurationDays,
|
||||
renewalMinDays: renewalDurationDays,
|
||||
renewalMaxDays: renewalDurationDays,
|
||||
};
|
||||
}
|
||||
|
||||
const renewalMinDays = data.renewalMinDays ?? 1;
|
||||
const renewalMaxDays = data.renewalMaxDays ?? data.durationDays;
|
||||
|
||||
if (renewalMinDays <= 0 || renewalMaxDays <= 0) {
|
||||
throw new Error("续费天数范围必须大于 0");
|
||||
}
|
||||
if (renewalMaxDays < renewalMinDays) {
|
||||
throw new Error("续费最大天数不能小于最小天数");
|
||||
}
|
||||
|
||||
return {
|
||||
renewalPrice: data.renewalPrice,
|
||||
renewalPricingMode,
|
||||
renewalDurationDays: null,
|
||||
renewalMinDays,
|
||||
renewalMaxDays,
|
||||
};
|
||||
}
|
||||
|
||||
function getTopupPolicy(data: PlanFormData, allowTrafficTopup: boolean) {
|
||||
if (data.type !== "PROXY" || !allowTrafficTopup) {
|
||||
return {
|
||||
topupPricingMode: "PER_GB",
|
||||
topupPricePerGb: null,
|
||||
topupFixedPrice: null,
|
||||
minTopupGb: null,
|
||||
maxTopupGb: null,
|
||||
};
|
||||
}
|
||||
|
||||
const topupPricingMode = data.topupPricingMode ?? "PER_GB";
|
||||
if (topupPricingMode === "PER_GB") {
|
||||
if (data.topupPricePerGb == null || data.topupPricePerGb <= 0) {
|
||||
throw new Error("开启增流量时,每 GB 加流量价格必须大于 0");
|
||||
}
|
||||
} else if (data.topupFixedPrice == null || data.topupFixedPrice <= 0) {
|
||||
throw new Error("开启增流量时,固定加流量金额必须大于 0");
|
||||
}
|
||||
|
||||
const minTopupGb = data.minTopupGb ?? 1;
|
||||
const maxTopupGb = data.maxTopupGb ?? null;
|
||||
if (minTopupGb <= 0) {
|
||||
throw new Error("最小增流量必须大于 0");
|
||||
}
|
||||
if (maxTopupGb != null && maxTopupGb <= 0) {
|
||||
throw new Error("最大增流量必须大于 0");
|
||||
}
|
||||
if (maxTopupGb != null && maxTopupGb < minTopupGb) {
|
||||
throw new Error("最大增流量不能小于最小增流量");
|
||||
}
|
||||
|
||||
return {
|
||||
topupPricingMode,
|
||||
topupPricePerGb: topupPricingMode === "PER_GB" ? data.topupPricePerGb : null,
|
||||
topupFixedPrice: topupPricingMode === "FIXED_AMOUNT" ? data.topupFixedPrice : null,
|
||||
minTopupGb,
|
||||
maxTopupGb,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createPlan(formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const raw = Object.fromEntries(formData);
|
||||
const data = planSchema.parse(raw);
|
||||
const allowRenewal = data.allowRenewal ?? false;
|
||||
const allowTrafficTopup = data.allowTrafficTopup ?? false;
|
||||
|
||||
if (data.totalLimit != null && data.totalLimit <= 0) {
|
||||
throw new Error("总量上限必须大于 0");
|
||||
}
|
||||
if (data.perUserLimit != null && data.perUserLimit <= 0) {
|
||||
throw new Error("每用户限购必须大于 0");
|
||||
}
|
||||
if (data.totalTrafficGb != null && data.totalTrafficGb <= 0) {
|
||||
throw new Error("总流量池必须大于 0");
|
||||
}
|
||||
const renewalPolicy = getRenewalPolicy(data, allowRenewal);
|
||||
const topupPolicy = getTopupPolicy(data, allowTrafficTopup);
|
||||
|
||||
if (data.type === "PROXY") {
|
||||
const pricingMode = assertProxyPricing(data);
|
||||
if (!data.nodeId) throw new Error("代理套餐必须选择节点");
|
||||
|
||||
const inboundIds = parseInboundIds(data.inboundIds, data.inboundId);
|
||||
if (inboundIds.length === 0) {
|
||||
throw new Error("请至少配置一个可售入站");
|
||||
}
|
||||
|
||||
const inbounds = await prisma.nodeInbound.findMany({
|
||||
where: {
|
||||
id: { in: inboundIds },
|
||||
},
|
||||
select: { id: true, serverId: true, isActive: true },
|
||||
});
|
||||
if (inbounds.length !== inboundIds.length) {
|
||||
throw new Error("存在无效入站,请重新选择");
|
||||
}
|
||||
for (const inbound of inbounds) {
|
||||
if (inbound.serverId !== data.nodeId) {
|
||||
throw new Error("入站与节点不匹配");
|
||||
}
|
||||
if (!inbound.isActive) {
|
||||
throw new Error("入站未启用");
|
||||
}
|
||||
}
|
||||
|
||||
let createdPlanId = "";
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const plan = await tx.subscriptionPlan.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
type: "PROXY",
|
||||
description: data.description || null,
|
||||
durationDays: data.durationDays,
|
||||
sortOrder: data.sortOrder,
|
||||
totalLimit: data.totalLimit ?? null,
|
||||
perUserLimit: data.perUserLimit ?? null,
|
||||
totalTrafficGb: data.totalTrafficGb ?? null,
|
||||
allowRenewal,
|
||||
allowTrafficTopup,
|
||||
renewalPrice: renewalPolicy.renewalPrice,
|
||||
renewalPricingMode: renewalPolicy.renewalPricingMode,
|
||||
renewalDurationDays: renewalPolicy.renewalDurationDays,
|
||||
renewalMinDays: renewalPolicy.renewalMinDays,
|
||||
renewalMaxDays: renewalPolicy.renewalMaxDays,
|
||||
renewalTrafficGb: null,
|
||||
topupPricingMode: topupPolicy.topupPricingMode,
|
||||
topupPricePerGb: topupPolicy.topupPricePerGb,
|
||||
topupFixedPrice: topupPolicy.topupFixedPrice,
|
||||
minTopupGb: topupPolicy.minTopupGb,
|
||||
maxTopupGb: topupPolicy.maxTopupGb,
|
||||
nodeId: data.nodeId,
|
||||
inboundId: inboundIds[0],
|
||||
streamingServiceId: null,
|
||||
categoryId: null,
|
||||
pricingMode,
|
||||
fixedTrafficGb: pricingMode === "FIXED_PACKAGE" ? data.fixedTrafficGb : null,
|
||||
fixedPrice: pricingMode === "FIXED_PACKAGE" ? data.fixedPrice : null,
|
||||
price: null,
|
||||
pricePerGb: pricingMode === "TRAFFIC_SLIDER" ? data.pricePerGb : null,
|
||||
minTrafficGb: pricingMode === "TRAFFIC_SLIDER" ? data.minTrafficGb : null,
|
||||
maxTrafficGb: pricingMode === "TRAFFIC_SLIDER" ? data.maxTrafficGb : null,
|
||||
},
|
||||
});
|
||||
createdPlanId = plan.id;
|
||||
|
||||
await tx.planInboundOption.createMany({
|
||||
data: inboundIds.map((inboundId) => ({
|
||||
planId: plan.id,
|
||||
inboundId,
|
||||
})),
|
||||
});
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "plan.create",
|
||||
targetType: "SubscriptionPlan",
|
||||
targetId: createdPlanId,
|
||||
targetLabel: data.name,
|
||||
message: `创建代理套餐 ${data.name}`,
|
||||
});
|
||||
} else {
|
||||
if (!data.streamingServiceId) {
|
||||
throw new Error("流媒体套餐必须绑定一个流媒体服务");
|
||||
}
|
||||
|
||||
const service = await prisma.streamingService.findUnique({
|
||||
where: { id: data.streamingServiceId },
|
||||
select: { id: true, isActive: true },
|
||||
});
|
||||
if (!service || !service.isActive) {
|
||||
throw new Error("所选流媒体服务不存在或未启用");
|
||||
}
|
||||
|
||||
const plan = await prisma.subscriptionPlan.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
type: "STREAMING",
|
||||
description: data.description || null,
|
||||
durationDays: data.durationDays,
|
||||
sortOrder: data.sortOrder,
|
||||
totalLimit: data.totalLimit ?? null,
|
||||
perUserLimit: data.perUserLimit ?? null,
|
||||
totalTrafficGb: null,
|
||||
allowRenewal,
|
||||
allowTrafficTopup: false,
|
||||
renewalPrice: renewalPolicy.renewalPrice,
|
||||
renewalPricingMode: renewalPolicy.renewalPricingMode,
|
||||
renewalDurationDays: renewalPolicy.renewalDurationDays,
|
||||
renewalMinDays: renewalPolicy.renewalMinDays,
|
||||
renewalMaxDays: renewalPolicy.renewalMaxDays,
|
||||
renewalTrafficGb: null,
|
||||
topupPricingMode: "PER_GB",
|
||||
topupPricePerGb: null,
|
||||
topupFixedPrice: null,
|
||||
minTopupGb: null,
|
||||
maxTopupGb: null,
|
||||
streamingServiceId: data.streamingServiceId,
|
||||
categoryId: null,
|
||||
pricingMode: "TRAFFIC_SLIDER",
|
||||
fixedTrafficGb: null,
|
||||
fixedPrice: null,
|
||||
price: data.price ?? 0,
|
||||
nodeId: null,
|
||||
inboundId: null,
|
||||
pricePerGb: null,
|
||||
minTrafficGb: null,
|
||||
maxTrafficGb: null,
|
||||
},
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "plan.create",
|
||||
targetType: "SubscriptionPlan",
|
||||
targetId: plan.id,
|
||||
targetLabel: plan.name,
|
||||
message: `创建流媒体套餐 ${plan.name}`,
|
||||
});
|
||||
}
|
||||
revalidatePath("/admin/plans");
|
||||
revalidatePath("/store");
|
||||
}
|
||||
|
||||
export async function updatePlan(id: string, formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const raw = Object.fromEntries(formData);
|
||||
const data = planSchema.parse(raw);
|
||||
const allowRenewal = data.allowRenewal ?? false;
|
||||
const allowTrafficTopup = data.allowTrafficTopup ?? false;
|
||||
const existing = await prisma.subscriptionPlan.findUniqueOrThrow({
|
||||
where: { id },
|
||||
select: { type: true, nodeId: true },
|
||||
});
|
||||
|
||||
if (existing.type !== data.type) {
|
||||
throw new Error("暂不支持修改套餐类型,请新建套餐");
|
||||
}
|
||||
|
||||
if (data.totalLimit != null && data.totalLimit <= 0) {
|
||||
throw new Error("总量上限必须大于 0");
|
||||
}
|
||||
if (data.perUserLimit != null && data.perUserLimit <= 0) {
|
||||
throw new Error("每用户限购必须大于 0");
|
||||
}
|
||||
if (data.totalTrafficGb != null && data.totalTrafficGb <= 0) {
|
||||
throw new Error("总流量池必须大于 0");
|
||||
}
|
||||
const renewalPolicy = getRenewalPolicy(data, allowRenewal);
|
||||
const topupPolicy = getTopupPolicy(data, allowTrafficTopup);
|
||||
|
||||
if (data.type === "PROXY") {
|
||||
const pricingMode = assertProxyPricing(data);
|
||||
const nodeId = data.nodeId ?? existing.nodeId;
|
||||
|
||||
if (!nodeId) throw new Error("代理套餐必须选择节点");
|
||||
if (data.totalTrafficGb == null || data.totalTrafficGb <= 0) {
|
||||
throw new Error("代理套餐必须填写总流量池,且大于 0");
|
||||
}
|
||||
|
||||
const inboundIds = parseInboundIds(data.inboundIds, data.inboundId);
|
||||
if (inboundIds.length === 0) {
|
||||
throw new Error("请至少配置一个可售入站");
|
||||
}
|
||||
|
||||
const inbounds = await prisma.nodeInbound.findMany({
|
||||
where: {
|
||||
id: { in: inboundIds },
|
||||
},
|
||||
select: { id: true, serverId: true, isActive: true },
|
||||
});
|
||||
if (inbounds.length !== inboundIds.length) {
|
||||
throw new Error("存在无效入站,请重新选择");
|
||||
}
|
||||
for (const inbound of inbounds) {
|
||||
if (inbound.serverId !== nodeId) {
|
||||
throw new Error("入站与节点不匹配");
|
||||
}
|
||||
if (!inbound.isActive) {
|
||||
throw new Error("入站未启用");
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.subscriptionPlan.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description || null,
|
||||
durationDays: data.durationDays,
|
||||
sortOrder: data.sortOrder,
|
||||
totalLimit: data.totalLimit ?? null,
|
||||
perUserLimit: data.perUserLimit ?? null,
|
||||
totalTrafficGb: data.totalTrafficGb ?? null,
|
||||
allowRenewal,
|
||||
allowTrafficTopup,
|
||||
renewalPrice: renewalPolicy.renewalPrice,
|
||||
renewalPricingMode: renewalPolicy.renewalPricingMode,
|
||||
renewalDurationDays: renewalPolicy.renewalDurationDays,
|
||||
renewalMinDays: renewalPolicy.renewalMinDays,
|
||||
renewalMaxDays: renewalPolicy.renewalMaxDays,
|
||||
renewalTrafficGb: null,
|
||||
topupPricingMode: topupPolicy.topupPricingMode,
|
||||
topupPricePerGb: topupPolicy.topupPricePerGb,
|
||||
topupFixedPrice: topupPolicy.topupFixedPrice,
|
||||
minTopupGb: topupPolicy.minTopupGb,
|
||||
maxTopupGb: topupPolicy.maxTopupGb,
|
||||
nodeId,
|
||||
inboundId: inboundIds[0],
|
||||
streamingServiceId: null,
|
||||
categoryId: null,
|
||||
pricingMode,
|
||||
fixedTrafficGb: pricingMode === "FIXED_PACKAGE" ? data.fixedTrafficGb : null,
|
||||
fixedPrice: pricingMode === "FIXED_PACKAGE" ? data.fixedPrice : null,
|
||||
price: null,
|
||||
pricePerGb: pricingMode === "TRAFFIC_SLIDER" ? data.pricePerGb : null,
|
||||
minTrafficGb: pricingMode === "TRAFFIC_SLIDER" ? data.minTrafficGb : null,
|
||||
maxTrafficGb: pricingMode === "TRAFFIC_SLIDER" ? data.maxTrafficGb : null,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.planInboundOption.deleteMany({ where: { planId: id } });
|
||||
await tx.planInboundOption.createMany({
|
||||
data: inboundIds.map((inboundId) => ({ planId: id, inboundId })),
|
||||
});
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "plan.update",
|
||||
targetType: "SubscriptionPlan",
|
||||
targetId: id,
|
||||
targetLabel: data.name,
|
||||
message: `更新代理套餐 ${data.name}`,
|
||||
});
|
||||
} else {
|
||||
if (!data.streamingServiceId) {
|
||||
throw new Error("流媒体套餐必须绑定一个流媒体服务");
|
||||
}
|
||||
|
||||
const service = await prisma.streamingService.findUnique({
|
||||
where: { id: data.streamingServiceId },
|
||||
select: { id: true, isActive: true },
|
||||
});
|
||||
if (!service || !service.isActive) {
|
||||
throw new Error("所选流媒体服务不存在或未启用");
|
||||
}
|
||||
|
||||
await prisma.subscriptionPlan.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description || null,
|
||||
durationDays: data.durationDays,
|
||||
sortOrder: data.sortOrder,
|
||||
totalLimit: data.totalLimit ?? null,
|
||||
perUserLimit: data.perUserLimit ?? null,
|
||||
totalTrafficGb: null,
|
||||
allowRenewal,
|
||||
allowTrafficTopup: false,
|
||||
renewalPrice: renewalPolicy.renewalPrice,
|
||||
renewalPricingMode: renewalPolicy.renewalPricingMode,
|
||||
renewalDurationDays: renewalPolicy.renewalDurationDays,
|
||||
renewalMinDays: renewalPolicy.renewalMinDays,
|
||||
renewalMaxDays: renewalPolicy.renewalMaxDays,
|
||||
renewalTrafficGb: null,
|
||||
topupPricingMode: "PER_GB",
|
||||
topupPricePerGb: null,
|
||||
topupFixedPrice: null,
|
||||
minTopupGb: null,
|
||||
maxTopupGb: null,
|
||||
streamingServiceId: data.streamingServiceId,
|
||||
categoryId: null,
|
||||
pricingMode: "TRAFFIC_SLIDER",
|
||||
fixedTrafficGb: null,
|
||||
fixedPrice: null,
|
||||
price: data.price,
|
||||
nodeId: null,
|
||||
inboundId: null,
|
||||
pricePerGb: null,
|
||||
minTrafficGb: null,
|
||||
maxTrafficGb: null,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.planInboundOption.deleteMany({ where: { planId: id } });
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "plan.update",
|
||||
targetType: "SubscriptionPlan",
|
||||
targetId: id,
|
||||
targetLabel: data.name,
|
||||
message: `更新流媒体套餐 ${data.name}`,
|
||||
});
|
||||
}
|
||||
revalidatePath("/admin/plans");
|
||||
revalidatePath("/store");
|
||||
}
|
||||
|
||||
export async function deletePlan(id: string) {
|
||||
const session = await requireAdmin();
|
||||
const plan = await prisma.subscriptionPlan.update({
|
||||
where: { id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "plan.disable",
|
||||
targetType: "SubscriptionPlan",
|
||||
targetId: plan.id,
|
||||
targetLabel: plan.name,
|
||||
message: `下架套餐 ${plan.name}`,
|
||||
});
|
||||
revalidatePath("/admin/plans");
|
||||
revalidatePath("/store");
|
||||
}
|
||||
|
||||
export async function deletePlanPermanently(id: string) {
|
||||
const session = await requireAdmin();
|
||||
const actor = actorFromSession(session);
|
||||
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: {
|
||||
inboundOptions: {
|
||||
include: {
|
||||
inbound: {
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
clients: true,
|
||||
planOptions: true,
|
||||
plans: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
subscriptions: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const subscription of plan.subscriptions) {
|
||||
await deleteSubscriptionPermanently(subscription.id);
|
||||
}
|
||||
|
||||
const relatedOrders = await prisma.order.findMany({
|
||||
where: { planId: plan.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await prisma.order.deleteMany({
|
||||
where: { planId: plan.id },
|
||||
});
|
||||
|
||||
const deletableInboundIds: string[] = [];
|
||||
for (const option of plan.inboundOptions) {
|
||||
const inbound = option.inbound;
|
||||
const otherPlanRefs = inbound._count.planOptions - 1;
|
||||
const otherPlanDirectRefs = inbound._count.plans - (plan.inboundId === inbound.id ? 1 : 0);
|
||||
const hasClients = inbound._count.clients > 0;
|
||||
|
||||
if (otherPlanRefs <= 0 && otherPlanDirectRefs <= 0 && !hasClients) {
|
||||
deletableInboundIds.push(inbound.id);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.planInboundOption.deleteMany({
|
||||
where: { planId: plan.id },
|
||||
});
|
||||
|
||||
if (deletableInboundIds.length > 0) {
|
||||
await tx.nodeInbound.deleteMany({
|
||||
where: { id: { in: deletableInboundIds } },
|
||||
});
|
||||
}
|
||||
|
||||
await tx.subscriptionPlan.delete({
|
||||
where: { id: plan.id },
|
||||
});
|
||||
});
|
||||
|
||||
await recordAuditLog({
|
||||
actor,
|
||||
action: "plan.delete",
|
||||
targetType: "SubscriptionPlan",
|
||||
targetId: plan.id,
|
||||
targetLabel: plan.name,
|
||||
message: `彻底删除套餐 ${plan.name}`,
|
||||
metadata: {
|
||||
deletedOrderIds: relatedOrders.map((order) => order.id),
|
||||
deletedInboundIds: deletableInboundIds,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/admin/plans");
|
||||
revalidatePath("/store");
|
||||
revalidatePath("/admin/subscriptions");
|
||||
}
|
||||
|
||||
export async function togglePlan(id: string, isActive: boolean) {
|
||||
const session = await requireAdmin();
|
||||
const plan = await prisma.subscriptionPlan.update({
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: isActive ? "plan.enable" : "plan.disable",
|
||||
targetType: "SubscriptionPlan",
|
||||
targetId: plan.id,
|
||||
targetLabel: plan.name,
|
||||
message: `${isActive ? "上架" : "下架"}套餐 ${plan.name}`,
|
||||
});
|
||||
revalidatePath("/admin/plans");
|
||||
}
|
||||
|
||||
export async function batchPlanOperation(formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const action = String(formData.get("action") || "");
|
||||
const planIds = formData.getAll("planIds").map(String).filter(Boolean);
|
||||
|
||||
if (planIds.length === 0) {
|
||||
throw new Error("请至少选择一个套餐");
|
||||
}
|
||||
|
||||
if (action === "enable" || action === "disable") {
|
||||
const isActive = action === "enable";
|
||||
await prisma.subscriptionPlan.updateMany({
|
||||
where: { id: { in: planIds } },
|
||||
data: { isActive },
|
||||
});
|
||||
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: isActive ? "plan.batch_enable" : "plan.batch_disable",
|
||||
targetType: "SubscriptionPlan",
|
||||
message: `${isActive ? "批量上架" : "批量下架"} ${planIds.length} 个套餐`,
|
||||
metadata: { planIds },
|
||||
});
|
||||
} else if (action === "delete") {
|
||||
for (const planId of planIds) {
|
||||
await deletePlanPermanently(planId);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
throw new Error("不支持的批量操作");
|
||||
}
|
||||
|
||||
revalidatePath("/admin/plans");
|
||||
revalidatePath("/store");
|
||||
}
|
||||
153
src/actions/admin/services.ts
Normal file
153
src/actions/admin/services.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/require-auth";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||
import { createNotification } from "@/services/notifications";
|
||||
|
||||
const serviceSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
credentials: z.string().min(1),
|
||||
maxSlots: z.coerce.number().int().positive(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function createService(formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const data = serviceSchema.parse(Object.fromEntries(formData));
|
||||
const service = await prisma.streamingService.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
credentials: encrypt(data.credentials),
|
||||
maxSlots: data.maxSlots,
|
||||
description: data.description || null,
|
||||
},
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "service.create",
|
||||
targetType: "StreamingService",
|
||||
targetId: service.id,
|
||||
targetLabel: service.name,
|
||||
message: `创建流媒体服务 ${service.name}`,
|
||||
});
|
||||
revalidatePath("/admin/services");
|
||||
}
|
||||
|
||||
export async function updateService(id: string, formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const data = serviceSchema.parse(Object.fromEntries(formData));
|
||||
const affectedUsers = await prisma.streamingSlot.findMany({
|
||||
where: { serviceId: id },
|
||||
select: { userId: true },
|
||||
distinct: ["userId"],
|
||||
});
|
||||
const service = await prisma.streamingService.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: data.name,
|
||||
credentials: encrypt(data.credentials),
|
||||
maxSlots: data.maxSlots,
|
||||
description: data.description || null,
|
||||
},
|
||||
});
|
||||
for (const row of affectedUsers) {
|
||||
await createNotification({
|
||||
userId: row.userId,
|
||||
type: "SYSTEM",
|
||||
level: "INFO",
|
||||
title: "流媒体凭据已更新",
|
||||
body: `${service.name} 的共享凭据已更新,请重新查看最新账号信息。`,
|
||||
link: "/subscriptions",
|
||||
dedupeKey: `service-credential-update:${service.id}:${row.userId}:${Date.now()}`,
|
||||
});
|
||||
}
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "service.update",
|
||||
targetType: "StreamingService",
|
||||
targetId: service.id,
|
||||
targetLabel: service.name,
|
||||
message: `更新流媒体服务 ${service.name}`,
|
||||
});
|
||||
revalidatePath("/admin/services");
|
||||
}
|
||||
|
||||
export async function deleteService(id: string) {
|
||||
const session = await requireAdmin();
|
||||
const service = await prisma.streamingService.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { _count: { select: { slots: true } } },
|
||||
});
|
||||
|
||||
if (service._count.slots > 0) {
|
||||
throw new Error(`该服务仍有 ${service._count.slots} 个关联槽位,请先清理后再删除`);
|
||||
}
|
||||
|
||||
await prisma.streamingService.delete({ where: { id } });
|
||||
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "service.delete",
|
||||
targetType: "StreamingService",
|
||||
targetId: service.id,
|
||||
targetLabel: service.name,
|
||||
message: `彻底删除流媒体服务 ${service.name}`,
|
||||
});
|
||||
revalidatePath("/admin/services");
|
||||
revalidatePath("/store");
|
||||
}
|
||||
|
||||
export async function toggleServiceStatus(id: string, isActive: boolean) {
|
||||
const session = await requireAdmin();
|
||||
const service = await prisma.streamingService.update({
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: isActive ? "service.enable" : "service.disable",
|
||||
targetType: "StreamingService",
|
||||
targetId: service.id,
|
||||
targetLabel: service.name,
|
||||
message: `${isActive ? "启用" : "停用"}流媒体服务 ${service.name}`,
|
||||
});
|
||||
revalidatePath("/admin/services");
|
||||
revalidatePath("/store");
|
||||
}
|
||||
|
||||
export async function batchToggleServiceStatus(formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const isActive = String(formData.get("isActive")) === "true";
|
||||
const serviceIds = formData.getAll("serviceIds").map(String).filter(Boolean);
|
||||
|
||||
if (serviceIds.length === 0) {
|
||||
throw new Error("请至少选择一个服务");
|
||||
}
|
||||
|
||||
const services = await prisma.streamingService.findMany({
|
||||
where: { id: { in: serviceIds } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
await prisma.streamingService.updateMany({
|
||||
where: { id: { in: serviceIds } },
|
||||
data: { isActive },
|
||||
});
|
||||
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: isActive ? "service.batch_enable" : "service.batch_disable",
|
||||
targetType: "StreamingService",
|
||||
message: `${isActive ? "批量启用" : "批量停用"} ${services.length} 个流媒体服务`,
|
||||
metadata: {
|
||||
serviceIds,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/admin/services");
|
||||
revalidatePath("/store");
|
||||
}
|
||||
79
src/actions/admin/settings.ts
Normal file
79
src/actions/admin/settings.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/require-auth";
|
||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||
import { getAppConfig } from "@/services/app-config";
|
||||
import { normalizeSiteUrl } from "@/services/site-url";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
siteName: z.string().trim().min(1, "站点名称不能为空"),
|
||||
siteUrl: z.string().trim().optional(),
|
||||
supportContact: z.string().trim().optional(),
|
||||
maintenanceNotice: z.string().trim().optional(),
|
||||
siteNotice: z.string().trim().optional(),
|
||||
allowRegistration: z.string().optional(),
|
||||
requireInviteCode: z.string().optional(),
|
||||
autoReminderDispatchEnabled: z.string().optional(),
|
||||
reminderDispatchIntervalMinutes: z.coerce.number().int().positive().optional(),
|
||||
trafficSyncEnabled: z.string().optional(),
|
||||
trafficSyncIntervalSeconds: z.coerce.number().int().min(10).optional(),
|
||||
inviteRewardEnabled: z.string().optional(),
|
||||
inviteRewardRate: z.coerce.number().min(0).max(100).optional(),
|
||||
inviteRewardCouponId: z.string().trim().optional(),
|
||||
turnstileSiteKey: z.string().trim().optional(),
|
||||
turnstileSecretKey: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
export async function saveAppSettings(formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const parsed = settingsSchema.parse(Object.fromEntries(formData));
|
||||
const current = await getAppConfig();
|
||||
|
||||
const next = {
|
||||
siteName: parsed.siteName,
|
||||
siteUrl: normalizeSiteUrl(parsed.siteUrl) || null,
|
||||
supportContact: parsed.supportContact || null,
|
||||
maintenanceNotice: parsed.maintenanceNotice || null,
|
||||
siteNotice: parsed.siteNotice || null,
|
||||
allowRegistration: parsed.allowRegistration === "true",
|
||||
requireInviteCode: parsed.requireInviteCode === "true",
|
||||
autoReminderDispatchEnabled: parsed.autoReminderDispatchEnabled === "true",
|
||||
reminderDispatchIntervalMinutes:
|
||||
parsed.reminderDispatchIntervalMinutes ?? current.reminderDispatchIntervalMinutes,
|
||||
trafficSyncEnabled: parsed.trafficSyncEnabled === "true",
|
||||
trafficSyncIntervalSeconds:
|
||||
parsed.trafficSyncIntervalSeconds ?? current.trafficSyncIntervalSeconds,
|
||||
inviteRewardEnabled: parsed.inviteRewardEnabled === "true",
|
||||
inviteRewardRate: parsed.inviteRewardRate ?? Number(current.inviteRewardRate),
|
||||
inviteRewardCouponId: parsed.inviteRewardCouponId || null,
|
||||
turnstileSiteKey: parsed.turnstileSiteKey || null,
|
||||
turnstileSecretKey: parsed.turnstileSecretKey || null,
|
||||
};
|
||||
|
||||
await prisma.appConfig.upsert({
|
||||
where: { id: current.id },
|
||||
create: { id: current.id, ...next },
|
||||
update: next,
|
||||
});
|
||||
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "settings.update",
|
||||
targetType: "AppConfig",
|
||||
targetId: current.id,
|
||||
targetLabel: next.siteName,
|
||||
message: "更新系统设置",
|
||||
});
|
||||
|
||||
revalidatePath("/admin/settings");
|
||||
revalidatePath("/login");
|
||||
revalidatePath("/register");
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/subscriptions");
|
||||
revalidatePath("/admin/nodes");
|
||||
revalidatePath("/account");
|
||||
revalidatePath("/admin/commerce");
|
||||
}
|
||||
452
src/actions/admin/subscriptions.ts
Normal file
452
src/actions/admin/subscriptions.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/require-auth";
|
||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||
import { createNotification } from "@/services/notifications";
|
||||
import { createPanelAdapter } from "@/services/node-panel/factory";
|
||||
|
||||
async function setProxyClientEnabled(subscriptionId: string, enable: boolean) {
|
||||
const client = await prisma.nodeClient.findUnique({
|
||||
where: { subscriptionId },
|
||||
select: {
|
||||
id: true,
|
||||
uuid: true,
|
||||
inbound: {
|
||||
select: {
|
||||
serverId: true,
|
||||
panelInboundId: true,
|
||||
server: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (client.inbound.panelInboundId == null) {
|
||||
throw new Error("3x-ui 入站 ID 缺失,请重新同步节点入站");
|
||||
}
|
||||
|
||||
const adapter = createPanelAdapter(client.inbound.server);
|
||||
await adapter.login();
|
||||
await adapter.updateClientEnable(client.inbound.panelInboundId, client.uuid, enable);
|
||||
|
||||
await prisma.nodeClient.update({
|
||||
where: { id: client.id },
|
||||
data: { isEnabled: enable },
|
||||
});
|
||||
|
||||
return client.inbound.serverId;
|
||||
}
|
||||
|
||||
async function hardDeleteProxyClient(subscriptionId: string) {
|
||||
const client = await prisma.nodeClient.findUnique({
|
||||
where: { subscriptionId },
|
||||
select: {
|
||||
id: true,
|
||||
uuid: true,
|
||||
inbound: {
|
||||
select: {
|
||||
serverId: true,
|
||||
panelInboundId: true,
|
||||
server: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (client.inbound.panelInboundId == null) {
|
||||
throw new Error("3x-ui 入站 ID 缺失,请重新同步节点入站");
|
||||
}
|
||||
|
||||
const adapter = createPanelAdapter(client.inbound.server);
|
||||
await adapter.login();
|
||||
await adapter.deleteClient(client.inbound.panelInboundId, client.uuid);
|
||||
|
||||
await prisma.nodeClient.delete({
|
||||
where: { id: client.id },
|
||||
});
|
||||
|
||||
return client.inbound.serverId;
|
||||
}
|
||||
|
||||
async function hardDeleteSubscriptionInternal(
|
||||
subscriptionId: string,
|
||||
options: {
|
||||
actor: ReturnType<typeof actorFromSession>;
|
||||
revalidate?: boolean;
|
||||
},
|
||||
) {
|
||||
const subscription = await prisma.userSubscription.findUniqueOrThrow({
|
||||
where: { id: subscriptionId },
|
||||
include: {
|
||||
plan: true,
|
||||
user: true,
|
||||
streamingSlot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (subscription.plan.type === "PROXY") {
|
||||
await hardDeleteProxyClient(subscription.id);
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (subscription.streamingSlot) {
|
||||
await tx.streamingSlot.delete({
|
||||
where: { id: subscription.streamingSlot.id },
|
||||
});
|
||||
|
||||
await tx.streamingService.updateMany({
|
||||
where: {
|
||||
id: subscription.streamingSlot.serviceId,
|
||||
usedSlots: { gt: 0 },
|
||||
},
|
||||
data: {
|
||||
usedSlots: { decrement: 1 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await tx.order.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ targetSubscriptionId: subscription.id },
|
||||
{ subscriptionId: subscription.id },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userSubscription.delete({
|
||||
where: { id: subscription.id },
|
||||
});
|
||||
});
|
||||
|
||||
await createNotification({
|
||||
userId: subscription.userId,
|
||||
type: "SUBSCRIPTION",
|
||||
level: "WARNING",
|
||||
title: "订阅已被删除",
|
||||
body: `${subscription.plan.name} 已被管理员彻底删除。`,
|
||||
link: "/subscriptions",
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: options.actor,
|
||||
action: "subscription.delete",
|
||||
targetType: "UserSubscription",
|
||||
targetId: subscription.id,
|
||||
targetLabel: `${subscription.user.email} / ${subscription.plan.name}`,
|
||||
message: `彻底删除订阅 ${subscription.plan.name}`,
|
||||
});
|
||||
|
||||
if (options.revalidate !== false) {
|
||||
revalidateSubscriptionViews();
|
||||
}
|
||||
}
|
||||
|
||||
function revalidateSubscriptionViews() {
|
||||
revalidatePath("/admin/subscriptions");
|
||||
revalidatePath("/admin/traffic");
|
||||
revalidatePath("/subscriptions");
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/notifications");
|
||||
}
|
||||
|
||||
export async function suspendSubscription(subscriptionId: string) {
|
||||
const session = await requireAdmin();
|
||||
|
||||
const subscription = await prisma.userSubscription.findUniqueOrThrow({
|
||||
where: { id: subscriptionId },
|
||||
include: {
|
||||
plan: true,
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (subscription.status !== "ACTIVE") {
|
||||
throw new Error("仅活跃订阅可暂停");
|
||||
}
|
||||
|
||||
if (subscription.plan.type === "PROXY") {
|
||||
await setProxyClientEnabled(subscription.id, false);
|
||||
}
|
||||
|
||||
await prisma.userSubscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: { status: "SUSPENDED" },
|
||||
});
|
||||
|
||||
await createNotification({
|
||||
userId: subscription.userId,
|
||||
type: "SUBSCRIPTION",
|
||||
level: "WARNING",
|
||||
title: "订阅已暂停",
|
||||
body: `${subscription.plan.name} 已被管理员暂停,如有疑问请联系管理员。`,
|
||||
link: "/subscriptions",
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "subscription.suspend",
|
||||
targetType: "UserSubscription",
|
||||
targetId: subscription.id,
|
||||
targetLabel: `${subscription.user.email} / ${subscription.plan.name}`,
|
||||
message: `暂停订阅 ${subscription.plan.name}`,
|
||||
});
|
||||
|
||||
revalidateSubscriptionViews();
|
||||
}
|
||||
|
||||
export async function activateSubscription(subscriptionId: string) {
|
||||
const session = await requireAdmin();
|
||||
|
||||
const subscription = await prisma.userSubscription.findUniqueOrThrow({
|
||||
where: { id: subscriptionId },
|
||||
include: {
|
||||
plan: true,
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (subscription.status !== "SUSPENDED") {
|
||||
throw new Error("仅已暂停订阅可恢复");
|
||||
}
|
||||
if (subscription.endDate <= new Date()) {
|
||||
throw new Error("订阅已过期,无法恢复");
|
||||
}
|
||||
|
||||
if (subscription.plan.type === "PROXY") {
|
||||
await setProxyClientEnabled(subscription.id, true);
|
||||
}
|
||||
|
||||
await prisma.userSubscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: { status: "ACTIVE" },
|
||||
});
|
||||
|
||||
await createNotification({
|
||||
userId: subscription.userId,
|
||||
type: "SUBSCRIPTION",
|
||||
level: "SUCCESS",
|
||||
title: "订阅已恢复",
|
||||
body: `${subscription.plan.name} 已恢复为可用状态。`,
|
||||
link: "/subscriptions",
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "subscription.activate",
|
||||
targetType: "UserSubscription",
|
||||
targetId: subscription.id,
|
||||
targetLabel: `${subscription.user.email} / ${subscription.plan.name}`,
|
||||
message: `恢复订阅 ${subscription.plan.name}`,
|
||||
});
|
||||
|
||||
revalidateSubscriptionViews();
|
||||
}
|
||||
|
||||
export async function cancelSubscription(subscriptionId: string) {
|
||||
const session = await requireAdmin();
|
||||
|
||||
const subscription = await prisma.userSubscription.findUniqueOrThrow({
|
||||
where: { id: subscriptionId },
|
||||
include: {
|
||||
plan: true,
|
||||
user: true,
|
||||
streamingSlot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (subscription.status === "CANCELLED") {
|
||||
throw new Error("订阅已取消");
|
||||
}
|
||||
|
||||
if (subscription.plan.type === "PROXY") {
|
||||
await setProxyClientEnabled(subscription.id, false);
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (subscription.streamingSlot) {
|
||||
await tx.streamingSlot.delete({
|
||||
where: { id: subscription.streamingSlot.id },
|
||||
});
|
||||
|
||||
await tx.streamingService.update({
|
||||
where: { id: subscription.streamingSlot.serviceId },
|
||||
data: {
|
||||
usedSlots: {
|
||||
decrement: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await tx.userSubscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: { status: "CANCELLED" },
|
||||
});
|
||||
});
|
||||
|
||||
await createNotification({
|
||||
userId: subscription.userId,
|
||||
type: "SUBSCRIPTION",
|
||||
level: "WARNING",
|
||||
title: "订阅已取消",
|
||||
body: `${subscription.plan.name} 已被管理员取消。`,
|
||||
link: "/subscriptions",
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "subscription.cancel",
|
||||
targetType: "UserSubscription",
|
||||
targetId: subscription.id,
|
||||
targetLabel: `${subscription.user.email} / ${subscription.plan.name}`,
|
||||
message: `取消订阅 ${subscription.plan.name}`,
|
||||
});
|
||||
|
||||
revalidateSubscriptionViews();
|
||||
}
|
||||
|
||||
export async function deleteSubscriptionPermanently(subscriptionId: string) {
|
||||
const session = await requireAdmin();
|
||||
await hardDeleteSubscriptionInternal(subscriptionId, {
|
||||
actor: actorFromSession(session),
|
||||
});
|
||||
}
|
||||
|
||||
export async function reassignStreamingSlot(
|
||||
subscriptionId: string,
|
||||
targetServiceId: string,
|
||||
) {
|
||||
const session = await requireAdmin();
|
||||
|
||||
const subscription = await prisma.userSubscription.findUniqueOrThrow({
|
||||
where: { id: subscriptionId },
|
||||
include: {
|
||||
user: true,
|
||||
plan: true,
|
||||
streamingSlot: {
|
||||
include: {
|
||||
service: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (subscription.plan.type !== "STREAMING") {
|
||||
throw new Error("仅流媒体订阅支持调配槽位");
|
||||
}
|
||||
if (subscription.status === "CANCELLED" || subscription.status === "EXPIRED") {
|
||||
throw new Error("当前订阅状态不支持调配槽位");
|
||||
}
|
||||
|
||||
const targetService = await prisma.streamingService.findUniqueOrThrow({
|
||||
where: { id: targetServiceId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
isActive: true,
|
||||
usedSlots: true,
|
||||
maxSlots: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!targetService.isActive) {
|
||||
throw new Error("目标流媒体服务未启用");
|
||||
}
|
||||
if (subscription.streamingSlot?.serviceId === targetService.id) {
|
||||
throw new Error("已在当前服务上,无需重复调配");
|
||||
}
|
||||
if (targetService.usedSlots >= targetService.maxSlots) {
|
||||
throw new Error("目标流媒体服务已满");
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (subscription.streamingSlot) {
|
||||
await tx.streamingSlot.update({
|
||||
where: { id: subscription.streamingSlot.id },
|
||||
data: {
|
||||
serviceId: targetService.id,
|
||||
assignedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await tx.streamingService.updateMany({
|
||||
where: {
|
||||
id: subscription.streamingSlot.serviceId,
|
||||
usedSlots: { gt: 0 },
|
||||
},
|
||||
data: {
|
||||
usedSlots: { decrement: 1 },
|
||||
},
|
||||
});
|
||||
await tx.streamingService.update({
|
||||
where: { id: targetService.id },
|
||||
data: {
|
||||
usedSlots: { increment: 1 },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await tx.streamingSlot.create({
|
||||
data: {
|
||||
subscriptionId: subscription.id,
|
||||
userId: subscription.userId,
|
||||
serviceId: targetService.id,
|
||||
},
|
||||
});
|
||||
await tx.streamingService.update({
|
||||
where: { id: targetService.id },
|
||||
data: {
|
||||
usedSlots: { increment: 1 },
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await createNotification({
|
||||
userId: subscription.userId,
|
||||
type: "SUBSCRIPTION",
|
||||
level: "INFO",
|
||||
title: "流媒体服务已调整",
|
||||
body: `${subscription.plan.name} 已调整到服务 ${targetService.name}。`,
|
||||
link: "/subscriptions",
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "streaming-slot.reassign",
|
||||
targetType: "StreamingSlot",
|
||||
targetId: subscription.streamingSlot?.id ?? subscription.id,
|
||||
targetLabel: `${subscription.user.email} / ${subscription.plan.name}`,
|
||||
message: `将流媒体订阅 ${subscription.plan.name} 调配到 ${targetService.name}`,
|
||||
});
|
||||
|
||||
revalidateSubscriptionViews();
|
||||
revalidatePath("/admin/services");
|
||||
}
|
||||
|
||||
export async function batchSubscriptionOperation(formData: FormData) {
|
||||
const action = String(formData.get("action") || "");
|
||||
const subscriptionIds = formData.getAll("subscriptionIds").map(String).filter(Boolean);
|
||||
|
||||
if (subscriptionIds.length === 0) {
|
||||
throw new Error("请至少选择一个订阅");
|
||||
}
|
||||
|
||||
for (const subscriptionId of subscriptionIds) {
|
||||
if (action === "suspend") {
|
||||
await suspendSubscription(subscriptionId);
|
||||
} else if (action === "activate") {
|
||||
await activateSubscription(subscriptionId);
|
||||
} else if (action === "cancel") {
|
||||
await cancelSubscription(subscriptionId);
|
||||
} else if (action === "delete") {
|
||||
await deleteSubscriptionPermanently(subscriptionId);
|
||||
} else {
|
||||
throw new Error("不支持的批量操作");
|
||||
}
|
||||
}
|
||||
}
|
||||
158
src/actions/admin/support.ts
Normal file
158
src/actions/admin/support.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/require-auth";
|
||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||
import { createNotification } from "@/services/notifications";
|
||||
import {
|
||||
createSupportAttachments,
|
||||
deleteSupportTicketRecords,
|
||||
parseSupportAttachments,
|
||||
} from "@/services/support";
|
||||
|
||||
const replySchema = z.object({
|
||||
body: z.string().trim().min(1, "回复内容不能为空"),
|
||||
});
|
||||
|
||||
const supportStatusSchema = z.enum(["OPEN", "USER_REPLIED", "ADMIN_REPLIED", "CLOSED"]);
|
||||
const supportPrioritySchema = z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]);
|
||||
|
||||
export async function replySupportAsAdmin(ticketId: string, formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const data = replySchema.parse(Object.fromEntries(formData));
|
||||
const attachments = parseSupportAttachments(formData.getAll("attachments"));
|
||||
|
||||
const ticket = await prisma.supportTicket.findUniqueOrThrow({
|
||||
where: { id: ticketId },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, email: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (ticket.status === "CLOSED") {
|
||||
throw new Error("已关闭的工单不能继续回复,请先重新打开");
|
||||
}
|
||||
|
||||
const updated = await prisma.supportTicket.update({
|
||||
where: { id: ticket.id },
|
||||
data: {
|
||||
status: "ADMIN_REPLIED",
|
||||
closedAt: null,
|
||||
lastReplyAt: new Date(),
|
||||
replies: {
|
||||
create: {
|
||||
authorUserId: session.user.id,
|
||||
isAdmin: true,
|
||||
body: data.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
replies: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
const createdReply = updated.replies[0];
|
||||
if (createdReply && attachments.length > 0) {
|
||||
await createSupportAttachments({
|
||||
ticketId: ticket.id,
|
||||
replyId: createdReply.id,
|
||||
files: attachments,
|
||||
});
|
||||
}
|
||||
|
||||
await createNotification({
|
||||
userId: ticket.user.id,
|
||||
type: "SYSTEM",
|
||||
level: "INFO",
|
||||
title: "工单有新回复",
|
||||
body: `管理员已回复工单「${ticket.subject}」。`,
|
||||
link: `/support/${ticket.id}`,
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "support.reply",
|
||||
targetType: "SupportTicket",
|
||||
targetId: ticket.id,
|
||||
targetLabel: ticket.subject,
|
||||
message: `回复工单 ${ticket.subject}`,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/support");
|
||||
revalidatePath(`/admin/support/${ticket.id}`);
|
||||
revalidatePath(`/support/${ticket.id}`);
|
||||
}
|
||||
|
||||
export async function updateSupportTicketMeta(formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const ticketId = String(formData.get("ticketId") || "");
|
||||
const status = supportStatusSchema.parse(String(formData.get("status") || ""));
|
||||
const priority = supportPrioritySchema.parse(String(formData.get("priority") || ""));
|
||||
|
||||
if (!ticketId) {
|
||||
throw new Error("工单 ID 缺失");
|
||||
}
|
||||
|
||||
const ticket = await prisma.supportTicket.update({
|
||||
where: { id: ticketId },
|
||||
data: {
|
||||
status,
|
||||
priority,
|
||||
closedAt: status === "CLOSED" ? new Date() : null,
|
||||
},
|
||||
});
|
||||
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "support.update",
|
||||
targetType: "SupportTicket",
|
||||
targetId: ticket.id,
|
||||
targetLabel: ticket.subject,
|
||||
message: `更新工单 ${ticket.subject} 状态/优先级`,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/support");
|
||||
revalidatePath(`/admin/support/${ticket.id}`);
|
||||
revalidatePath(`/support/${ticket.id}`);
|
||||
}
|
||||
|
||||
export async function deleteSupportTicketAsAdmin(ticketId: string) {
|
||||
const session = await requireAdmin();
|
||||
const ticket = await prisma.supportTicket.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: {
|
||||
id: true,
|
||||
subject: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new Error("工单不存在");
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await deleteSupportTicketRecords(ticket.id, tx);
|
||||
await recordAuditLog(
|
||||
{
|
||||
actor: actorFromSession(session),
|
||||
action: "support.delete",
|
||||
targetType: "SupportTicket",
|
||||
targetId: ticket.id,
|
||||
targetLabel: ticket.subject,
|
||||
message: `管理员删除工单 ${ticket.subject}`,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
revalidatePath("/admin/support");
|
||||
revalidatePath(`/admin/support/${ticket.id}`);
|
||||
revalidatePath("/support");
|
||||
revalidatePath(`/support/${ticket.id}`);
|
||||
revalidatePath("/notifications");
|
||||
}
|
||||
110
src/actions/admin/tasks.ts
Normal file
110
src/actions/admin/tasks.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireAdmin } from "@/lib/require-auth";
|
||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||
import { dispatchSubscriptionReminders } from "@/services/notifications";
|
||||
import { confirmPendingOrder } from "@/services/payment/process";
|
||||
import { runTask, updateTaskRun } from "@/services/task-center";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function revalidateTaskViews() {
|
||||
revalidatePath("/admin/tasks");
|
||||
revalidatePath("/admin/health");
|
||||
revalidatePath("/admin/traffic");
|
||||
revalidatePath("/admin/audit-logs");
|
||||
revalidatePath("/notifications");
|
||||
}
|
||||
|
||||
export async function runReminderTask() {
|
||||
const session = await requireAdmin();
|
||||
const actor = actorFromSession(session);
|
||||
|
||||
const outcome = await runTask(
|
||||
{
|
||||
kind: "REMINDER_DISPATCH",
|
||||
title: "手动派发提醒任务",
|
||||
triggeredById: session.user.id,
|
||||
},
|
||||
async () => {
|
||||
await dispatchSubscriptionReminders();
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
|
||||
await recordAuditLog({
|
||||
actor,
|
||||
action: "task.run",
|
||||
targetType: "TaskRun",
|
||||
targetId: outcome.taskId,
|
||||
targetLabel: "REMINDER_DISPATCH",
|
||||
message: "手动执行提醒派发任务",
|
||||
});
|
||||
|
||||
revalidateTaskViews();
|
||||
}
|
||||
|
||||
export async function retryTaskRun(taskId: string) {
|
||||
const session = await requireAdmin();
|
||||
const actor = actorFromSession(session);
|
||||
const task = await prisma.taskRun.findUniqueOrThrow({
|
||||
where: { id: taskId },
|
||||
});
|
||||
|
||||
await updateTaskRun(task.id, {
|
||||
status: "RUNNING",
|
||||
errorMessage: null,
|
||||
startedAt: new Date(),
|
||||
retryCountIncrement: true,
|
||||
});
|
||||
|
||||
try {
|
||||
let result: unknown = { ok: true };
|
||||
|
||||
if (task.kind === "ORDER_PROVISION_RETRY") {
|
||||
const orderId = (task.payload as { orderId?: string } | null)?.orderId;
|
||||
if (!orderId) {
|
||||
throw new Error("任务缺少订单 ID");
|
||||
}
|
||||
result = await confirmPendingOrder(orderId);
|
||||
} else if (task.kind === "REMINDER_DISPATCH") {
|
||||
await dispatchSubscriptionReminders();
|
||||
}
|
||||
|
||||
await updateTaskRun(task.id, {
|
||||
status: "SUCCESS",
|
||||
finishedAt: new Date(),
|
||||
result: result as never,
|
||||
});
|
||||
|
||||
await recordAuditLog({
|
||||
actor,
|
||||
action: "task.retry",
|
||||
targetType: "TaskRun",
|
||||
targetId: task.id,
|
||||
targetLabel: task.kind,
|
||||
message: `重试任务 ${task.title}`,
|
||||
});
|
||||
} catch (error) {
|
||||
await updateTaskRun(task.id, {
|
||||
status: "FAILED",
|
||||
finishedAt: new Date(),
|
||||
errorMessage: error instanceof Error ? error.message : "重试失败",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidateTaskViews();
|
||||
}
|
||||
|
||||
export async function batchRetryTaskRuns(formData: FormData) {
|
||||
const taskIds = formData.getAll("taskIds").map(String).filter(Boolean);
|
||||
|
||||
if (taskIds.length === 0) {
|
||||
throw new Error("请至少选择一个任务");
|
||||
}
|
||||
|
||||
for (const taskId of taskIds) {
|
||||
await retryTaskRun(taskId);
|
||||
}
|
||||
}
|
||||
35
src/actions/admin/traffic.ts
Normal file
35
src/actions/admin/traffic.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireAdmin } from "@/lib/require-auth";
|
||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||
import { syncNodeClientTraffic } from "@/services/traffic-sync";
|
||||
|
||||
export async function syncTrafficViews() {
|
||||
const session = await requireAdmin();
|
||||
const result = await syncNodeClientTraffic({ maxAgeMs: 0 });
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "traffic.sync",
|
||||
targetType: "TrafficSync",
|
||||
message: `同步 3x-ui 流量:成功 ${result.synced} 个,失败 ${result.failed} 个`,
|
||||
metadata: {
|
||||
scanned: result.scanned,
|
||||
synced: result.synced,
|
||||
skipped: result.skipped,
|
||||
failed: result.failed,
|
||||
uploadDelta: result.uploadDelta,
|
||||
downloadDelta: result.downloadDelta,
|
||||
errors: result.errors,
|
||||
},
|
||||
});
|
||||
revalidatePath("/admin/traffic");
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/subscriptions");
|
||||
revalidatePath("/notifications");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function revalidateTrafficViews() {
|
||||
return syncTrafficViews();
|
||||
}
|
||||
134
src/actions/admin/users.ts
Normal file
134
src/actions/admin/users.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/require-auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||
|
||||
const createUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
name: z.string().optional(),
|
||||
role: z.enum(["ADMIN", "USER"]).default("USER"),
|
||||
});
|
||||
|
||||
const updateUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
role: z.enum(["ADMIN", "USER"]),
|
||||
});
|
||||
|
||||
export async function createUser(formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const data = createUserSchema.parse(Object.fromEntries(formData));
|
||||
const hashed = await bcrypt.hash(data.password, 12);
|
||||
const user = await prisma.user.create({
|
||||
data: { email: data.email, password: hashed, name: data.name || null, role: data.role },
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "user.create",
|
||||
targetType: "User",
|
||||
targetId: user.id,
|
||||
targetLabel: user.email,
|
||||
message: `创建用户 ${user.email}`,
|
||||
});
|
||||
revalidatePath("/admin/users");
|
||||
}
|
||||
|
||||
export async function updateUser(id: string, formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const data = updateUserSchema.parse(Object.fromEntries(formData));
|
||||
|
||||
const updateData: {
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: "ADMIN" | "USER";
|
||||
password?: string;
|
||||
} = {
|
||||
email: data.email,
|
||||
name: data.name || null,
|
||||
role: data.role,
|
||||
};
|
||||
|
||||
if (data.password && data.password.trim()) {
|
||||
updateData.password = await bcrypt.hash(data.password.trim(), 12);
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "user.update",
|
||||
targetType: "User",
|
||||
targetId: user.id,
|
||||
targetLabel: user.email,
|
||||
message: `更新用户 ${user.email}`,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/users");
|
||||
}
|
||||
|
||||
export async function updateUserStatus(id: string, status: "ACTIVE" | "DISABLED" | "BANNED") {
|
||||
const session = await requireAdmin();
|
||||
const user = await prisma.user.update({ where: { id }, data: { status } });
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "user.status",
|
||||
targetType: "User",
|
||||
targetId: user.id,
|
||||
targetLabel: user.email,
|
||||
message: `将用户 ${user.email} 状态改为 ${status}`,
|
||||
});
|
||||
revalidatePath("/admin/users");
|
||||
}
|
||||
|
||||
export async function deleteUser(id: string) {
|
||||
const session = await requireAdmin();
|
||||
const user = await prisma.user.delete({ where: { id } });
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "user.delete",
|
||||
targetType: "User",
|
||||
targetId: user.id,
|
||||
targetLabel: user.email,
|
||||
message: `删除用户 ${user.email}`,
|
||||
});
|
||||
revalidatePath("/admin/users");
|
||||
}
|
||||
|
||||
export async function batchUpdateUserStatus(formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const status = formData.get("status");
|
||||
const userIds = formData.getAll("userIds").map(String).filter(Boolean);
|
||||
|
||||
if (!status || !["ACTIVE", "DISABLED", "BANNED"].includes(String(status))) {
|
||||
throw new Error("批量状态无效");
|
||||
}
|
||||
if (userIds.length === 0) {
|
||||
throw new Error("请至少选择一个用户");
|
||||
}
|
||||
|
||||
await prisma.user.updateMany({
|
||||
where: { id: { in: userIds } },
|
||||
data: { status: status as "ACTIVE" | "DISABLED" | "BANNED" },
|
||||
});
|
||||
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "user.batch_status",
|
||||
targetType: "User",
|
||||
message: `批量更新 ${userIds.length} 个用户状态为 ${status}`,
|
||||
metadata: {
|
||||
userIds,
|
||||
status: String(status),
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/admin/users");
|
||||
}
|
||||
85
src/actions/user/account.ts
Normal file
85
src/actions/user/account.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
"use server";
|
||||
|
||||
import bcrypt from "bcryptjs";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { randomBytes } from "crypto";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAuth } from "@/lib/require-auth";
|
||||
|
||||
const profileSchema = z.object({
|
||||
name: z.string().trim().min(1, "昵称不能为空").max(50, "昵称不能超过 50 个字符"),
|
||||
});
|
||||
|
||||
const passwordSchema = z.object({
|
||||
currentPassword: z.string().min(6, "当前密码不能为空"),
|
||||
newPassword: z.string().min(6, "新密码至少 6 位"),
|
||||
confirmPassword: z.string().min(6, "确认密码至少 6 位"),
|
||||
});
|
||||
|
||||
async function generateUniqueInviteCode(): Promise<string> {
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const code = randomBytes(4).toString("hex").toUpperCase();
|
||||
const exists = await prisma.user.findUnique({
|
||||
where: { inviteCode: code },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!exists) {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("邀请码生成失败,请稍后重试");
|
||||
}
|
||||
|
||||
export async function updateAccountProfile(formData: FormData) {
|
||||
const session = await requireAuth();
|
||||
const data = profileSchema.parse(Object.fromEntries(formData));
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { name: data.name },
|
||||
});
|
||||
|
||||
revalidatePath("/account");
|
||||
}
|
||||
|
||||
export async function changeAccountPassword(formData: FormData) {
|
||||
const session = await requireAuth();
|
||||
const data = passwordSchema.parse(Object.fromEntries(formData));
|
||||
|
||||
if (data.newPassword !== data.confirmPassword) {
|
||||
throw new Error("两次输入的新密码不一致");
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: session.user.id },
|
||||
select: { password: true },
|
||||
});
|
||||
|
||||
const valid = await bcrypt.compare(data.currentPassword, user.password);
|
||||
if (!valid) {
|
||||
throw new Error("当前密码不正确");
|
||||
}
|
||||
|
||||
const hashed = await bcrypt.hash(data.newPassword, 12);
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { password: hashed },
|
||||
});
|
||||
|
||||
revalidatePath("/account");
|
||||
}
|
||||
|
||||
export async function generateInviteCode() {
|
||||
const session = await requireAuth();
|
||||
const code = await generateUniqueInviteCode();
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { inviteCode: code },
|
||||
});
|
||||
|
||||
revalidatePath("/account");
|
||||
return code;
|
||||
}
|
||||
266
src/actions/user/cart.ts
Normal file
266
src/actions/user/cart.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAuth } from "@/lib/require-auth";
|
||||
import { buildUnavailableMessage, getPlanAvailability } from "@/services/plan-availability";
|
||||
import { getPlanPurchasePrice, calculateCheckoutDiscounts } from "@/services/commerce";
|
||||
import { ensurePlanTrafficPoolCapacity } from "@/services/plan-traffic-pool";
|
||||
|
||||
async function assertNoPendingOrder(userId: string) {
|
||||
const pendingOrder = await prisma.order.findFirst({
|
||||
where: { userId, status: "PENDING" },
|
||||
select: { id: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (pendingOrder) {
|
||||
throw new Error("你还有一笔订单正在等待支付,请先完成或取消后再继续购买");
|
||||
}
|
||||
}
|
||||
|
||||
async function getProxyPlanForCart(planId: string) {
|
||||
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
|
||||
where: { id: planId },
|
||||
include: {
|
||||
inboundOptions: {
|
||||
include: {
|
||||
inbound: {
|
||||
select: { id: true, serverId: true, isActive: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (plan.type !== "PROXY") throw new Error("套餐类型错误");
|
||||
if (!plan.isActive) throw new Error("套餐已下架");
|
||||
return plan;
|
||||
}
|
||||
|
||||
function assertInboundSelectable(
|
||||
plan: Awaited<ReturnType<typeof getProxyPlanForCart>>,
|
||||
selectedInboundId: string,
|
||||
) {
|
||||
const selectableInboundIds = plan.inboundOptions
|
||||
.filter(
|
||||
(item) =>
|
||||
item.inbound.isActive
|
||||
&& (!plan.nodeId || item.inbound.serverId === plan.nodeId),
|
||||
)
|
||||
.map((item) => item.inboundId);
|
||||
|
||||
const selectable = selectableInboundIds.length > 0
|
||||
? selectableInboundIds
|
||||
: plan.inboundId
|
||||
? [plan.inboundId]
|
||||
: [];
|
||||
|
||||
if (!selectedInboundId || !selectable.includes(selectedInboundId)) {
|
||||
throw new Error("请选择有效的线路入口");
|
||||
}
|
||||
}
|
||||
|
||||
export async function addProxyPlanToCart(
|
||||
planId: string,
|
||||
trafficGb: number,
|
||||
selectedInboundId: string,
|
||||
) {
|
||||
const session = await requireAuth();
|
||||
const plan = await getProxyPlanForCart(planId);
|
||||
assertInboundSelectable(plan, selectedInboundId);
|
||||
|
||||
const price = getPlanPurchasePrice(plan, trafficGb);
|
||||
if (price.trafficGb != null) {
|
||||
await ensurePlanTrafficPoolCapacity(plan.id, price.trafficGb, {
|
||||
messagePrefix: "这款套餐额度暂时不足",
|
||||
});
|
||||
}
|
||||
|
||||
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
||||
if (!availability.available) {
|
||||
throw new Error(buildUnavailableMessage(availability));
|
||||
}
|
||||
|
||||
const existing = await prisma.shoppingCartItem.findFirst({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
planId,
|
||||
selectedInboundId,
|
||||
trafficGb: price.trafficGb,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.shoppingCartItem.update({
|
||||
where: { id: existing.id },
|
||||
data: { updatedAt: new Date() },
|
||||
});
|
||||
} else {
|
||||
await prisma.shoppingCartItem.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
planId,
|
||||
selectedInboundId,
|
||||
trafficGb: price.trafficGb,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/cart");
|
||||
revalidatePath("/store");
|
||||
}
|
||||
|
||||
export async function addStreamingPlanToCart(planId: string) {
|
||||
const session = await requireAuth();
|
||||
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({ where: { id: planId } });
|
||||
if (plan.type !== "STREAMING") throw new Error("套餐类型错误");
|
||||
if (!plan.isActive) throw new Error("套餐已下架");
|
||||
|
||||
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
||||
if (!availability.available) {
|
||||
throw new Error(buildUnavailableMessage(availability));
|
||||
}
|
||||
|
||||
const existing = await prisma.shoppingCartItem.findFirst({
|
||||
where: { userId: session.user.id, planId, selectedInboundId: null, trafficGb: null },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.shoppingCartItem.update({
|
||||
where: { id: existing.id },
|
||||
data: { updatedAt: new Date() },
|
||||
});
|
||||
} else {
|
||||
await prisma.shoppingCartItem.create({ data: { userId: session.user.id, planId } });
|
||||
}
|
||||
|
||||
revalidatePath("/cart");
|
||||
revalidatePath("/store");
|
||||
}
|
||||
|
||||
export async function removeCartItem(itemId: string) {
|
||||
const session = await requireAuth();
|
||||
await prisma.shoppingCartItem.deleteMany({
|
||||
where: { id: itemId, userId: session.user.id },
|
||||
});
|
||||
revalidatePath("/cart");
|
||||
revalidatePath("/store");
|
||||
}
|
||||
|
||||
export async function clearCart() {
|
||||
const session = await requireAuth();
|
||||
await prisma.shoppingCartItem.deleteMany({ where: { userId: session.user.id } });
|
||||
revalidatePath("/cart");
|
||||
revalidatePath("/store");
|
||||
}
|
||||
|
||||
export async function checkoutCart(couponCode?: string | null): Promise<string> {
|
||||
const session = await requireAuth();
|
||||
await assertNoPendingOrder(session.user.id);
|
||||
|
||||
const items = await prisma.shoppingCartItem.findMany({
|
||||
where: { userId: session.user.id },
|
||||
include: {
|
||||
plan: true,
|
||||
selectedInbound: true,
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
if (items.length === 0) throw new Error("购物车还是空的");
|
||||
|
||||
const orderItems: Array<{
|
||||
planId: string;
|
||||
selectedInboundId: string | null;
|
||||
trafficGb: number | null;
|
||||
unitAmount: number;
|
||||
amount: number;
|
||||
}> = [];
|
||||
const trafficByPlan = new Map<string, number>();
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.plan.isActive) throw new Error(`${item.plan.name} 已下架,请先移出购物车`);
|
||||
|
||||
const availability = await getPlanAvailability(item.plan, { userId: session.user.id });
|
||||
if (!availability.available) {
|
||||
throw new Error(`${item.plan.name}:${buildUnavailableMessage(availability)}`);
|
||||
}
|
||||
|
||||
if (item.plan.type === "PROXY") {
|
||||
if (!item.selectedInboundId) throw new Error(`${item.plan.name} 缺少线路入口`);
|
||||
const plan = await getProxyPlanForCart(item.planId);
|
||||
assertInboundSelectable(plan, item.selectedInboundId);
|
||||
const price = getPlanPurchasePrice(item.plan, item.trafficGb);
|
||||
if (!price.trafficGb) throw new Error(`${item.plan.name} 缺少流量配置`);
|
||||
trafficByPlan.set(item.planId, (trafficByPlan.get(item.planId) ?? 0) + price.trafficGb);
|
||||
orderItems.push({
|
||||
planId: item.planId,
|
||||
selectedInboundId: item.selectedInboundId,
|
||||
trafficGb: price.trafficGb,
|
||||
unitAmount: price.unitAmount,
|
||||
amount: price.amount,
|
||||
});
|
||||
} else {
|
||||
const price = getPlanPurchasePrice(item.plan);
|
||||
orderItems.push({
|
||||
planId: item.planId,
|
||||
selectedInboundId: null,
|
||||
trafficGb: null,
|
||||
unitAmount: price.unitAmount,
|
||||
amount: price.amount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [planId, trafficGb] of trafficByPlan) {
|
||||
await ensurePlanTrafficPoolCapacity(planId, trafficGb, {
|
||||
messagePrefix: "购物车中的代理套餐额度暂时不足",
|
||||
});
|
||||
}
|
||||
|
||||
const subtotal = orderItems.reduce((sum, item) => sum + item.amount, 0);
|
||||
const discounts = await calculateCheckoutDiscounts({
|
||||
userId: session.user.id,
|
||||
subtotal,
|
||||
couponCode,
|
||||
});
|
||||
|
||||
const order = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.order.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
planId: orderItems[0].planId,
|
||||
kind: "NEW_PURCHASE",
|
||||
amount: discounts.payable,
|
||||
subtotalAmount: discounts.subtotal,
|
||||
discountAmount: discounts.totalDiscount,
|
||||
couponId: discounts.coupon?.id ?? null,
|
||||
couponCode: discounts.coupon?.code ?? null,
|
||||
promotionName: discounts.promotion?.name ?? null,
|
||||
status: "PENDING",
|
||||
items: {
|
||||
create: orderItems,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (discounts.couponGrantId) {
|
||||
await tx.couponGrant.update({
|
||||
where: { id: discounts.couponGrantId },
|
||||
data: { usedOrderId: created.id, usedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
await tx.shoppingCartItem.deleteMany({ where: { userId: session.user.id } });
|
||||
return created;
|
||||
});
|
||||
|
||||
revalidatePath("/cart");
|
||||
revalidatePath("/store");
|
||||
revalidatePath("/orders");
|
||||
|
||||
return order.id;
|
||||
}
|
||||
34
src/actions/user/notifications.ts
Normal file
34
src/actions/user/notifications.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireAuth } from "@/lib/require-auth";
|
||||
import {
|
||||
deleteNotification,
|
||||
deleteReadNotifications,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
} from "@/services/notifications";
|
||||
|
||||
export async function markNotificationAsRead(notificationId: string) {
|
||||
const session = await requireAuth();
|
||||
await markNotificationRead(notificationId, session.user.id);
|
||||
revalidatePath("/notifications");
|
||||
}
|
||||
|
||||
export async function markEveryNotificationAsRead() {
|
||||
const session = await requireAuth();
|
||||
await markAllNotificationsRead(session.user.id);
|
||||
revalidatePath("/notifications");
|
||||
}
|
||||
|
||||
export async function removeNotification(notificationId: string) {
|
||||
const session = await requireAuth();
|
||||
await deleteNotification(notificationId, session.user.id);
|
||||
revalidatePath("/notifications");
|
||||
}
|
||||
|
||||
export async function removeReadNotifications() {
|
||||
const session = await requireAuth();
|
||||
await deleteReadNotifications(session.user.id);
|
||||
revalidatePath("/notifications");
|
||||
}
|
||||
71
src/actions/user/orders.ts
Normal file
71
src/actions/user/orders.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAuth } from "@/lib/require-auth";
|
||||
|
||||
async function findOwnPendingOrder(orderId: string, userId: string) {
|
||||
const order = await prisma.order.findFirst({
|
||||
where: {
|
||||
id: orderId,
|
||||
userId,
|
||||
},
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
throw new Error("订单不存在");
|
||||
}
|
||||
if (order.status !== "PENDING") {
|
||||
throw new Error("这笔订单已经不在待支付状态");
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
export async function cancelOwnPendingOrder(orderId: string) {
|
||||
const session = await requireAuth();
|
||||
await findOwnPendingOrder(orderId, session.user.id);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: "CANCELLED",
|
||||
paymentMethod: null,
|
||||
paymentRef: null,
|
||||
paymentUrl: null,
|
||||
tradeNo: null,
|
||||
expireAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.couponGrant.updateMany({
|
||||
where: { usedOrderId: orderId },
|
||||
data: { usedOrderId: null, usedAt: null },
|
||||
});
|
||||
});
|
||||
|
||||
revalidatePath("/orders");
|
||||
revalidatePath("/store");
|
||||
revalidatePath(`/pay/${orderId}`);
|
||||
}
|
||||
|
||||
export async function resetOwnPendingPaymentChoice(orderId: string) {
|
||||
const session = await requireAuth();
|
||||
await findOwnPendingOrder(orderId, session.user.id);
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
paymentMethod: null,
|
||||
paymentRef: null,
|
||||
paymentUrl: null,
|
||||
tradeNo: null,
|
||||
expireAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/orders");
|
||||
revalidatePath(`/pay/${orderId}`);
|
||||
}
|
||||
387
src/actions/user/purchase.ts
Normal file
387
src/actions/user/purchase.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
"use server";
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import {
|
||||
buildUnavailableMessage,
|
||||
formatAvailabilityDateTime,
|
||||
getPlanAvailability,
|
||||
} from "@/services/plan-availability";
|
||||
import {
|
||||
ensurePlanTrafficPoolCapacity,
|
||||
getPlanTrafficPoolState,
|
||||
} from "@/services/plan-traffic-pool";
|
||||
import { getPlanPurchasePrice, roundMoney } from "@/services/commerce";
|
||||
|
||||
async function assertNoPendingOrder(userId: string) {
|
||||
const pendingOrder = await prisma.order.findFirst({
|
||||
where: { userId, status: "PENDING" },
|
||||
select: { id: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (pendingOrder) {
|
||||
throw new Error("你还有一笔订单正在等待支付,请先完成或取消后再继续购买");
|
||||
}
|
||||
}
|
||||
|
||||
function getRenewalOrderPrice(
|
||||
plan: {
|
||||
durationDays: number;
|
||||
renewalPrice: unknown;
|
||||
renewalPricingMode: string;
|
||||
renewalDurationDays: number | null;
|
||||
renewalMinDays: number | null;
|
||||
renewalMaxDays: number | null;
|
||||
},
|
||||
requestedDays?: number,
|
||||
) {
|
||||
const unitPrice = Number(plan.renewalPrice ?? 0);
|
||||
if (!Number.isFinite(unitPrice) || unitPrice <= 0) {
|
||||
throw new Error("这款套餐暂时不支持续费");
|
||||
}
|
||||
|
||||
const mode = plan.renewalPricingMode === "PER_DAY" ? "PER_DAY" : "FIXED_DURATION";
|
||||
if (mode === "PER_DAY") {
|
||||
const minDays = plan.renewalMinDays ?? 1;
|
||||
const maxDays = plan.renewalMaxDays ?? plan.durationDays;
|
||||
const durationDays = requestedDays ?? minDays;
|
||||
if (!Number.isInteger(durationDays) || durationDays < minDays || durationDays > maxDays) {
|
||||
throw new Error(`续费天数范围: ${minDays}-${maxDays} 天`);
|
||||
}
|
||||
return {
|
||||
durationDays,
|
||||
amount: roundMoney(unitPrice * durationDays),
|
||||
};
|
||||
}
|
||||
|
||||
const unitDays = plan.renewalDurationDays ?? plan.durationDays;
|
||||
const minDays = plan.renewalMinDays ?? unitDays;
|
||||
const maxDays = plan.renewalMaxDays ?? unitDays;
|
||||
const durationDays = requestedDays ?? unitDays;
|
||||
if (!Number.isInteger(durationDays) || durationDays < minDays || durationDays > maxDays) {
|
||||
throw new Error(`续费天数范围: ${minDays}-${maxDays} 天`);
|
||||
}
|
||||
if (durationDays % unitDays !== 0) {
|
||||
throw new Error(`续费天数必须是 ${unitDays} 天的整数倍`);
|
||||
}
|
||||
|
||||
return {
|
||||
durationDays,
|
||||
amount: roundMoney(unitPrice * (durationDays / unitDays)),
|
||||
};
|
||||
}
|
||||
|
||||
function getTrafficTopupOrderPrice(
|
||||
plan: {
|
||||
topupPricingMode: string;
|
||||
topupPricePerGb: unknown;
|
||||
topupFixedPrice: unknown;
|
||||
minTopupGb: number | null;
|
||||
maxTopupGb: number | null;
|
||||
pricePerGb: unknown;
|
||||
},
|
||||
trafficGb: number,
|
||||
) {
|
||||
const minTopupGb = plan.minTopupGb ?? 1;
|
||||
const maxTopupGb = plan.maxTopupGb ?? null;
|
||||
if (trafficGb < minTopupGb) {
|
||||
throw new Error(`单次至少增加 ${minTopupGb} GB`);
|
||||
}
|
||||
if (maxTopupGb != null && trafficGb > maxTopupGb) {
|
||||
throw new Error(`单次最多增加 ${maxTopupGb} GB`);
|
||||
}
|
||||
|
||||
if (plan.topupPricingMode === "FIXED_AMOUNT") {
|
||||
const fixedAmount = Number(plan.topupFixedPrice ?? 0);
|
||||
if (!Number.isFinite(fixedAmount) || fixedAmount <= 0) {
|
||||
throw new Error("这款套餐暂时不能增加流量");
|
||||
}
|
||||
return roundMoney(fixedAmount);
|
||||
}
|
||||
|
||||
const pricePerGb = Number(plan.topupPricePerGb ?? plan.pricePerGb ?? 0);
|
||||
if (!Number.isFinite(pricePerGb) || pricePerGb <= 0) {
|
||||
throw new Error("这款套餐暂时不能增加流量");
|
||||
}
|
||||
return roundMoney(pricePerGb * trafficGb);
|
||||
}
|
||||
|
||||
export async function purchaseProxy(
|
||||
planId: string,
|
||||
trafficGb: number,
|
||||
selectedInboundId: string,
|
||||
): Promise<string> {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("未登录");
|
||||
await assertNoPendingOrder(session.user.id);
|
||||
|
||||
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
|
||||
where: { id: planId },
|
||||
include: {
|
||||
inboundOptions: {
|
||||
include: {
|
||||
inbound: {
|
||||
select: {
|
||||
id: true,
|
||||
isActive: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (plan.type !== "PROXY") throw new Error("套餐类型错误");
|
||||
if (!plan.isActive) throw new Error("套餐已下架");
|
||||
|
||||
const price = getPlanPurchasePrice(plan, trafficGb);
|
||||
|
||||
const poolState = await getPlanTrafficPoolState(plan.id);
|
||||
if (poolState.enabled && price.trafficGb != null) {
|
||||
await ensurePlanTrafficPoolCapacity(plan.id, price.trafficGb, {
|
||||
messagePrefix: "这款套餐额度暂时不足",
|
||||
});
|
||||
}
|
||||
|
||||
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
||||
if (!availability.available) {
|
||||
throw new Error(buildUnavailableMessage(availability));
|
||||
}
|
||||
|
||||
const selectableInboundIds = plan.inboundOptions
|
||||
.filter(
|
||||
(item) =>
|
||||
item.inbound.isActive
|
||||
&& (!plan.nodeId || item.inbound.serverId === plan.nodeId),
|
||||
)
|
||||
.map((item) => item.inboundId);
|
||||
|
||||
let fallbackInboundId = "";
|
||||
if (plan.inboundId && plan.nodeId) {
|
||||
const fallbackInbound = await prisma.nodeInbound.findFirst({
|
||||
where: {
|
||||
id: plan.inboundId,
|
||||
serverId: plan.nodeId,
|
||||
isActive: true,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
fallbackInboundId = fallbackInbound?.id ?? "";
|
||||
}
|
||||
|
||||
const selectable = selectableInboundIds.length > 0 ? selectableInboundIds : [fallbackInboundId];
|
||||
|
||||
if (!selectedInboundId || !selectable.filter(Boolean).includes(selectedInboundId)) {
|
||||
throw new Error("请选择有效的线路入口");
|
||||
}
|
||||
if (!selectable[0]) {
|
||||
throw new Error("这款套餐的线路入口正在整理中,暂时不能购买");
|
||||
}
|
||||
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
planId,
|
||||
kind: "NEW_PURCHASE",
|
||||
amount: price.amount,
|
||||
subtotalAmount: price.amount,
|
||||
discountAmount: 0,
|
||||
selectedInboundId,
|
||||
status: "PENDING",
|
||||
items: {
|
||||
create: {
|
||||
planId,
|
||||
selectedInboundId,
|
||||
trafficGb: price.trafficGb,
|
||||
unitAmount: price.unitAmount,
|
||||
amount: price.amount,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return order.id;
|
||||
}
|
||||
|
||||
export async function purchaseStreaming(planId: string): Promise<string> {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("未登录");
|
||||
await assertNoPendingOrder(session.user.id);
|
||||
|
||||
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
|
||||
where: { id: planId },
|
||||
});
|
||||
|
||||
if (plan.type !== "STREAMING") throw new Error("套餐类型错误");
|
||||
if (!plan.isActive) throw new Error("套餐已下架");
|
||||
|
||||
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
||||
if (!availability.available) {
|
||||
throw new Error(buildUnavailableMessage(availability));
|
||||
}
|
||||
|
||||
const price = getPlanPurchasePrice(plan);
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
planId,
|
||||
kind: "NEW_PURCHASE",
|
||||
amount: price.amount,
|
||||
subtotalAmount: price.amount,
|
||||
discountAmount: 0,
|
||||
status: "PENDING",
|
||||
items: {
|
||||
create: {
|
||||
planId,
|
||||
trafficGb: null,
|
||||
unitAmount: price.unitAmount,
|
||||
amount: price.amount,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return order.id;
|
||||
}
|
||||
|
||||
export type PurchaseRenewalResult =
|
||||
| { ok: true; orderId: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export async function purchaseRenewal(
|
||||
subscriptionId: string,
|
||||
renewalDays?: number,
|
||||
): Promise<PurchaseRenewalResult> {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("未登录");
|
||||
await assertNoPendingOrder(session.user.id);
|
||||
|
||||
const subscription = await prisma.userSubscription.findFirst({
|
||||
where: { id: subscriptionId, userId: session.user.id },
|
||||
include: {
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
if (!subscription) throw new Error("订阅不存在");
|
||||
if (subscription.status !== "ACTIVE" || subscription.endDate <= new Date()) {
|
||||
throw new Error("仅支持对活跃订阅续费");
|
||||
}
|
||||
if (!subscription.plan.allowRenewal) {
|
||||
throw new Error("这款套餐暂时不支持续费");
|
||||
}
|
||||
|
||||
const price = getRenewalOrderPrice(subscription.plan, renewalDays);
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
planId: subscription.planId,
|
||||
kind: "RENEWAL",
|
||||
targetSubscriptionId: subscription.id,
|
||||
amount: price.amount,
|
||||
subtotalAmount: price.amount,
|
||||
discountAmount: 0,
|
||||
durationDays: price.durationDays,
|
||||
status: "PENDING",
|
||||
},
|
||||
});
|
||||
return { ok: true, orderId: order.id };
|
||||
} catch (error) {
|
||||
return { ok: false, error: getErrorMessage(error, "创建续费订单失败") };
|
||||
}
|
||||
}
|
||||
|
||||
export async function purchaseTrafficTopup(
|
||||
subscriptionId: string,
|
||||
trafficGb: number,
|
||||
): Promise<string> {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("未登录");
|
||||
await assertNoPendingOrder(session.user.id);
|
||||
|
||||
if (!Number.isFinite(trafficGb) || trafficGb <= 0 || !Number.isInteger(trafficGb)) {
|
||||
throw new Error("增流量必须是正整数 GB");
|
||||
}
|
||||
|
||||
const subscription = await prisma.userSubscription.findFirst({
|
||||
where: { id: subscriptionId, userId: session.user.id },
|
||||
include: {
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
if (!subscription) throw new Error("订阅不存在");
|
||||
if (subscription.plan.type !== "PROXY") {
|
||||
throw new Error("仅代理订阅支持增流量");
|
||||
}
|
||||
if (subscription.status !== "ACTIVE" || subscription.endDate <= new Date()) {
|
||||
throw new Error("增流量仅在当前套餐有效期内生效");
|
||||
}
|
||||
if (!subscription.plan.allowTrafficTopup) {
|
||||
throw new Error("这款套餐暂时不支持增加流量");
|
||||
}
|
||||
|
||||
const amount = getTrafficTopupOrderPrice(subscription.plan, trafficGb);
|
||||
const poolState = await getPlanTrafficPoolState(subscription.planId);
|
||||
if (poolState.enabled) {
|
||||
const remainingGb = Math.max(0, Math.floor(poolState.remainingGb));
|
||||
if (trafficGb > remainingGb) {
|
||||
throw new Error(`剩余总流量不足,当前最多可增 ${remainingGb} GB`);
|
||||
}
|
||||
}
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
planId: subscription.planId,
|
||||
kind: "TRAFFIC_TOPUP",
|
||||
targetSubscriptionId: subscription.id,
|
||||
amount,
|
||||
subtotalAmount: amount,
|
||||
discountAmount: 0,
|
||||
trafficGb,
|
||||
status: "PENDING",
|
||||
},
|
||||
});
|
||||
return order.id;
|
||||
}
|
||||
|
||||
export async function queryPlanNextAvailability(planId: string): Promise<{
|
||||
available: boolean;
|
||||
message: string;
|
||||
nextAvailableAt: string | null;
|
||||
}> {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("未登录");
|
||||
|
||||
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
|
||||
where: { id: planId },
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
totalLimit: true,
|
||||
perUserLimit: true,
|
||||
streamingServiceId: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!plan.isActive) {
|
||||
return {
|
||||
available: false,
|
||||
message: "这款套餐暂时不可购买",
|
||||
nextAvailableAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
||||
return {
|
||||
available: availability.available,
|
||||
message: availability.available ? "这款套餐现在可以购买" : buildUnavailableMessage(availability),
|
||||
nextAvailableAt: availability.nextAvailableAt
|
||||
? formatAvailabilityDateTime(availability.nextAvailableAt)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
108
src/actions/user/subscription-security.ts
Normal file
108
src/actions/user/subscription-security.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { randomUUID } from "crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAuth } from "@/lib/require-auth";
|
||||
import { generateNodeClientCredential } from "@/services/node-client-credential";
|
||||
import { bytesToGb } from "@/lib/utils";
|
||||
import { createPanelAdapter } from "@/services/node-panel/factory";
|
||||
import { createNotification } from "@/services/notifications";
|
||||
import { recordAuditLog } from "@/services/audit";
|
||||
|
||||
function newDownloadToken() {
|
||||
return randomUUID().replace(/-/g, "");
|
||||
}
|
||||
|
||||
export async function rotateSubscriptionAccess(subscriptionId: string) {
|
||||
const session = await requireAuth();
|
||||
|
||||
const subscription = await prisma.userSubscription.findFirst({
|
||||
where: {
|
||||
id: subscriptionId,
|
||||
userId: session.user.id,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
include: {
|
||||
plan: true,
|
||||
nodeClient: {
|
||||
include: {
|
||||
inbound: {
|
||||
include: {
|
||||
server: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
throw new Error("订阅不存在或不可操作");
|
||||
}
|
||||
|
||||
const nextToken = newDownloadToken();
|
||||
if (subscription.plan.type === "PROXY" && subscription.nodeClient) {
|
||||
const nextCredential = generateNodeClientCredential(
|
||||
subscription.nodeClient.inbound.protocol,
|
||||
subscription.nodeClient.inbound.settings,
|
||||
);
|
||||
const panelInboundId = subscription.nodeClient.inbound.panelInboundId;
|
||||
if (panelInboundId == null) {
|
||||
throw new Error("3x-ui 入站 ID 缺失,请重新同步节点入站");
|
||||
}
|
||||
|
||||
const adapter = createPanelAdapter(subscription.nodeClient.inbound.server);
|
||||
await adapter.login();
|
||||
await adapter.deleteClient(panelInboundId, subscription.nodeClient.uuid);
|
||||
await adapter.addClient({
|
||||
inboundId: panelInboundId,
|
||||
email: subscription.nodeClient.email,
|
||||
uuid: nextCredential,
|
||||
subId: subscription.id,
|
||||
totalGB: subscription.trafficLimit ? bytesToGb(subscription.trafficLimit) : 0,
|
||||
expiryTime: subscription.endDate.getTime(),
|
||||
protocol: subscription.nodeClient.inbound.protocol,
|
||||
});
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.nodeClient.update({
|
||||
where: { id: subscription.nodeClient!.id },
|
||||
data: { uuid: nextCredential },
|
||||
});
|
||||
await tx.userSubscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: { downloadToken: nextToken },
|
||||
});
|
||||
});
|
||||
} else {
|
||||
await prisma.userSubscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: { downloadToken: nextToken },
|
||||
});
|
||||
}
|
||||
|
||||
await createNotification({
|
||||
userId: subscription.userId,
|
||||
type: "SUBSCRIPTION",
|
||||
level: "SUCCESS",
|
||||
title: "订阅访问已重置",
|
||||
body: `${subscription.plan.name} 的订阅链接和访问凭据已更新,旧配置已失效。`,
|
||||
link: "/subscriptions",
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: {
|
||||
userId: session.user.id,
|
||||
email: session.user.email ?? undefined,
|
||||
role: session.user.role === "ADMIN" || session.user.role === "USER" ? session.user.role : undefined,
|
||||
},
|
||||
action: "subscription.rotate_access",
|
||||
targetType: "UserSubscription",
|
||||
targetId: subscription.id,
|
||||
targetLabel: subscription.plan.name,
|
||||
message: `用户重置订阅访问 ${subscription.plan.name}`,
|
||||
});
|
||||
|
||||
revalidatePath("/subscriptions");
|
||||
revalidatePath(`/subscriptions/${subscriptionId}`);
|
||||
}
|
||||
238
src/actions/user/support.ts
Normal file
238
src/actions/user/support.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAuth } from "@/lib/require-auth";
|
||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||
import { createNotification } from "@/services/notifications";
|
||||
import {
|
||||
createSupportAttachments,
|
||||
deleteSupportTicketRecords,
|
||||
parseSupportAttachments,
|
||||
} from "@/services/support";
|
||||
|
||||
const createTicketSchema = z.object({
|
||||
subject: z.string().trim().min(1, "标题不能为空"),
|
||||
category: z.string().trim().optional(),
|
||||
priority: z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]).default("NORMAL"),
|
||||
body: z.string().trim().min(1, "内容不能为空"),
|
||||
});
|
||||
|
||||
export async function createSupportTicket(formData: FormData) {
|
||||
const session = await requireAuth();
|
||||
const data = createTicketSchema.parse(Object.fromEntries(formData));
|
||||
const attachments = parseSupportAttachments(formData.getAll("attachments"));
|
||||
|
||||
const ticket = await prisma.supportTicket.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
subject: data.subject,
|
||||
category: data.category || null,
|
||||
priority: data.priority,
|
||||
status: "OPEN",
|
||||
lastReplyAt: new Date(),
|
||||
replies: {
|
||||
create: {
|
||||
authorUserId: session.user.id,
|
||||
isAdmin: false,
|
||||
body: data.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
replies: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
const firstReply = ticket.replies[0];
|
||||
if (firstReply && attachments.length > 0) {
|
||||
await createSupportAttachments({
|
||||
ticketId: ticket.id,
|
||||
replyId: firstReply.id,
|
||||
files: attachments,
|
||||
});
|
||||
}
|
||||
|
||||
const admins = await prisma.user.findMany({
|
||||
where: { role: "ADMIN", status: "ACTIVE" },
|
||||
select: { id: true },
|
||||
});
|
||||
for (const admin of admins) {
|
||||
await createNotification({
|
||||
userId: admin.id,
|
||||
type: "SYSTEM",
|
||||
level: "INFO",
|
||||
title: "新工单待处理",
|
||||
body: `收到新工单:${data.subject}`,
|
||||
link: `/admin/support/${ticket.id}`,
|
||||
dedupeKey: `support-created:${ticket.id}:${admin.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/support");
|
||||
revalidatePath(`/support/${ticket.id}`);
|
||||
revalidatePath("/admin/support");
|
||||
}
|
||||
|
||||
export async function replySupportTicket(ticketId: string, formData: FormData) {
|
||||
const session = await requireAuth();
|
||||
const body = String(formData.get("body") || "").trim();
|
||||
const attachments = parseSupportAttachments(formData.getAll("attachments"));
|
||||
if (!body) {
|
||||
throw new Error("回复内容不能为空");
|
||||
}
|
||||
|
||||
const ticket = await prisma.supportTicket.findFirst({
|
||||
where: {
|
||||
id: ticketId,
|
||||
userId: session.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
subject: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
if (!ticket) {
|
||||
throw new Error("工单不存在");
|
||||
}
|
||||
if (ticket.status === "CLOSED") {
|
||||
throw new Error("已关闭的工单不能继续回复");
|
||||
}
|
||||
|
||||
const updated = await prisma.supportTicket.update({
|
||||
where: { id: ticket.id },
|
||||
data: {
|
||||
status: "USER_REPLIED",
|
||||
closedAt: null,
|
||||
lastReplyAt: new Date(),
|
||||
replies: {
|
||||
create: {
|
||||
authorUserId: session.user.id,
|
||||
isAdmin: false,
|
||||
body,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
replies: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
const createdReply = updated.replies[0];
|
||||
if (createdReply && attachments.length > 0) {
|
||||
await createSupportAttachments({
|
||||
ticketId: ticket.id,
|
||||
replyId: createdReply.id,
|
||||
files: attachments,
|
||||
});
|
||||
}
|
||||
|
||||
const admins = await prisma.user.findMany({
|
||||
where: { role: "ADMIN", status: "ACTIVE" },
|
||||
select: { id: true },
|
||||
});
|
||||
for (const admin of admins) {
|
||||
await createNotification({
|
||||
userId: admin.id,
|
||||
type: "SYSTEM",
|
||||
level: "INFO",
|
||||
title: "工单有新回复",
|
||||
body: `工单「${ticket.subject}」有用户新回复。`,
|
||||
link: `/admin/support/${ticket.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath(`/support/${ticket.id}`);
|
||||
revalidatePath("/support");
|
||||
revalidatePath(`/admin/support/${ticket.id}`);
|
||||
}
|
||||
|
||||
export async function closeSupportTicket(ticketId: string) {
|
||||
const session = await requireAuth();
|
||||
const ticket = await prisma.supportTicket.findFirst({
|
||||
where: {
|
||||
id: ticketId,
|
||||
userId: session.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
subject: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new Error("工单不存在");
|
||||
}
|
||||
|
||||
if (ticket.status === "CLOSED") {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.supportTicket.update({
|
||||
where: { id: ticket.id },
|
||||
data: {
|
||||
status: "CLOSED",
|
||||
closedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "support.close",
|
||||
targetType: "SupportTicket",
|
||||
targetId: ticket.id,
|
||||
targetLabel: ticket.subject,
|
||||
message: `用户关闭工单 ${ticket.subject}`,
|
||||
});
|
||||
|
||||
revalidatePath("/support");
|
||||
revalidatePath(`/support/${ticket.id}`);
|
||||
revalidatePath("/admin/support");
|
||||
revalidatePath(`/admin/support/${ticket.id}`);
|
||||
}
|
||||
|
||||
export async function deleteSupportTicket(ticketId: string) {
|
||||
const session = await requireAuth();
|
||||
const ticket = await prisma.supportTicket.findFirst({
|
||||
where: {
|
||||
id: ticketId,
|
||||
userId: session.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
subject: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new Error("工单不存在");
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await deleteSupportTicketRecords(ticket.id, tx);
|
||||
await recordAuditLog(
|
||||
{
|
||||
actor: actorFromSession(session),
|
||||
action: "support.delete",
|
||||
targetType: "SupportTicket",
|
||||
targetId: ticket.id,
|
||||
targetLabel: ticket.subject,
|
||||
message: `用户删除工单 ${ticket.subject}`,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
revalidatePath("/support");
|
||||
revalidatePath(`/support/${ticket.id}`);
|
||||
revalidatePath("/admin/support");
|
||||
revalidatePath(`/admin/support/${ticket.id}`);
|
||||
revalidatePath("/notifications");
|
||||
}
|
||||
Reference in New Issue
Block a user