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