Initial commit

This commit is contained in:
JetSprow
2026-04-29 05:12:39 +10:00
commit 27dbca9cbf
379 changed files with 43486 additions and 0 deletions

View 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();
}

View 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");
}

View 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
View 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");
}

View 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("不支持的批量操作");
}
}
}

View 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
View 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");
}

View 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");
}

View 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");
}

View 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("不支持的批量操作");
}
}
}

View 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
View 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);
}
}

View 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
View 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");
}

View 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
View 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;
}

View 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");
}

View 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}`);
}

View 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,
};
}

View 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
View 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");
}