mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 09:14:11 +05:30
Initial commit
This commit is contained in:
85
src/actions/user/account.ts
Normal file
85
src/actions/user/account.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
"use server";
|
||||
|
||||
import bcrypt from "bcryptjs";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { randomBytes } from "crypto";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAuth } from "@/lib/require-auth";
|
||||
|
||||
const profileSchema = z.object({
|
||||
name: z.string().trim().min(1, "昵称不能为空").max(50, "昵称不能超过 50 个字符"),
|
||||
});
|
||||
|
||||
const passwordSchema = z.object({
|
||||
currentPassword: z.string().min(6, "当前密码不能为空"),
|
||||
newPassword: z.string().min(6, "新密码至少 6 位"),
|
||||
confirmPassword: z.string().min(6, "确认密码至少 6 位"),
|
||||
});
|
||||
|
||||
async function generateUniqueInviteCode(): Promise<string> {
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const code = randomBytes(4).toString("hex").toUpperCase();
|
||||
const exists = await prisma.user.findUnique({
|
||||
where: { inviteCode: code },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!exists) {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("邀请码生成失败,请稍后重试");
|
||||
}
|
||||
|
||||
export async function updateAccountProfile(formData: FormData) {
|
||||
const session = await requireAuth();
|
||||
const data = profileSchema.parse(Object.fromEntries(formData));
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { name: data.name },
|
||||
});
|
||||
|
||||
revalidatePath("/account");
|
||||
}
|
||||
|
||||
export async function changeAccountPassword(formData: FormData) {
|
||||
const session = await requireAuth();
|
||||
const data = passwordSchema.parse(Object.fromEntries(formData));
|
||||
|
||||
if (data.newPassword !== data.confirmPassword) {
|
||||
throw new Error("两次输入的新密码不一致");
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: session.user.id },
|
||||
select: { password: true },
|
||||
});
|
||||
|
||||
const valid = await bcrypt.compare(data.currentPassword, user.password);
|
||||
if (!valid) {
|
||||
throw new Error("当前密码不正确");
|
||||
}
|
||||
|
||||
const hashed = await bcrypt.hash(data.newPassword, 12);
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { password: hashed },
|
||||
});
|
||||
|
||||
revalidatePath("/account");
|
||||
}
|
||||
|
||||
export async function generateInviteCode() {
|
||||
const session = await requireAuth();
|
||||
const code = await generateUniqueInviteCode();
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { inviteCode: code },
|
||||
});
|
||||
|
||||
revalidatePath("/account");
|
||||
return code;
|
||||
}
|
||||
266
src/actions/user/cart.ts
Normal file
266
src/actions/user/cart.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAuth } from "@/lib/require-auth";
|
||||
import { buildUnavailableMessage, getPlanAvailability } from "@/services/plan-availability";
|
||||
import { getPlanPurchasePrice, calculateCheckoutDiscounts } from "@/services/commerce";
|
||||
import { ensurePlanTrafficPoolCapacity } from "@/services/plan-traffic-pool";
|
||||
|
||||
async function assertNoPendingOrder(userId: string) {
|
||||
const pendingOrder = await prisma.order.findFirst({
|
||||
where: { userId, status: "PENDING" },
|
||||
select: { id: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (pendingOrder) {
|
||||
throw new Error("你还有一笔订单正在等待支付,请先完成或取消后再继续购买");
|
||||
}
|
||||
}
|
||||
|
||||
async function getProxyPlanForCart(planId: string) {
|
||||
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
|
||||
where: { id: planId },
|
||||
include: {
|
||||
inboundOptions: {
|
||||
include: {
|
||||
inbound: {
|
||||
select: { id: true, serverId: true, isActive: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (plan.type !== "PROXY") throw new Error("套餐类型错误");
|
||||
if (!plan.isActive) throw new Error("套餐已下架");
|
||||
return plan;
|
||||
}
|
||||
|
||||
function assertInboundSelectable(
|
||||
plan: Awaited<ReturnType<typeof getProxyPlanForCart>>,
|
||||
selectedInboundId: string,
|
||||
) {
|
||||
const selectableInboundIds = plan.inboundOptions
|
||||
.filter(
|
||||
(item) =>
|
||||
item.inbound.isActive
|
||||
&& (!plan.nodeId || item.inbound.serverId === plan.nodeId),
|
||||
)
|
||||
.map((item) => item.inboundId);
|
||||
|
||||
const selectable = selectableInboundIds.length > 0
|
||||
? selectableInboundIds
|
||||
: plan.inboundId
|
||||
? [plan.inboundId]
|
||||
: [];
|
||||
|
||||
if (!selectedInboundId || !selectable.includes(selectedInboundId)) {
|
||||
throw new Error("请选择有效的线路入口");
|
||||
}
|
||||
}
|
||||
|
||||
export async function addProxyPlanToCart(
|
||||
planId: string,
|
||||
trafficGb: number,
|
||||
selectedInboundId: string,
|
||||
) {
|
||||
const session = await requireAuth();
|
||||
const plan = await getProxyPlanForCart(planId);
|
||||
assertInboundSelectable(plan, selectedInboundId);
|
||||
|
||||
const price = getPlanPurchasePrice(plan, trafficGb);
|
||||
if (price.trafficGb != null) {
|
||||
await ensurePlanTrafficPoolCapacity(plan.id, price.trafficGb, {
|
||||
messagePrefix: "这款套餐额度暂时不足",
|
||||
});
|
||||
}
|
||||
|
||||
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
||||
if (!availability.available) {
|
||||
throw new Error(buildUnavailableMessage(availability));
|
||||
}
|
||||
|
||||
const existing = await prisma.shoppingCartItem.findFirst({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
planId,
|
||||
selectedInboundId,
|
||||
trafficGb: price.trafficGb,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.shoppingCartItem.update({
|
||||
where: { id: existing.id },
|
||||
data: { updatedAt: new Date() },
|
||||
});
|
||||
} else {
|
||||
await prisma.shoppingCartItem.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
planId,
|
||||
selectedInboundId,
|
||||
trafficGb: price.trafficGb,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/cart");
|
||||
revalidatePath("/store");
|
||||
}
|
||||
|
||||
export async function addStreamingPlanToCart(planId: string) {
|
||||
const session = await requireAuth();
|
||||
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({ where: { id: planId } });
|
||||
if (plan.type !== "STREAMING") throw new Error("套餐类型错误");
|
||||
if (!plan.isActive) throw new Error("套餐已下架");
|
||||
|
||||
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
||||
if (!availability.available) {
|
||||
throw new Error(buildUnavailableMessage(availability));
|
||||
}
|
||||
|
||||
const existing = await prisma.shoppingCartItem.findFirst({
|
||||
where: { userId: session.user.id, planId, selectedInboundId: null, trafficGb: null },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.shoppingCartItem.update({
|
||||
where: { id: existing.id },
|
||||
data: { updatedAt: new Date() },
|
||||
});
|
||||
} else {
|
||||
await prisma.shoppingCartItem.create({ data: { userId: session.user.id, planId } });
|
||||
}
|
||||
|
||||
revalidatePath("/cart");
|
||||
revalidatePath("/store");
|
||||
}
|
||||
|
||||
export async function removeCartItem(itemId: string) {
|
||||
const session = await requireAuth();
|
||||
await prisma.shoppingCartItem.deleteMany({
|
||||
where: { id: itemId, userId: session.user.id },
|
||||
});
|
||||
revalidatePath("/cart");
|
||||
revalidatePath("/store");
|
||||
}
|
||||
|
||||
export async function clearCart() {
|
||||
const session = await requireAuth();
|
||||
await prisma.shoppingCartItem.deleteMany({ where: { userId: session.user.id } });
|
||||
revalidatePath("/cart");
|
||||
revalidatePath("/store");
|
||||
}
|
||||
|
||||
export async function checkoutCart(couponCode?: string | null): Promise<string> {
|
||||
const session = await requireAuth();
|
||||
await assertNoPendingOrder(session.user.id);
|
||||
|
||||
const items = await prisma.shoppingCartItem.findMany({
|
||||
where: { userId: session.user.id },
|
||||
include: {
|
||||
plan: true,
|
||||
selectedInbound: true,
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
if (items.length === 0) throw new Error("购物车还是空的");
|
||||
|
||||
const orderItems: Array<{
|
||||
planId: string;
|
||||
selectedInboundId: string | null;
|
||||
trafficGb: number | null;
|
||||
unitAmount: number;
|
||||
amount: number;
|
||||
}> = [];
|
||||
const trafficByPlan = new Map<string, number>();
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.plan.isActive) throw new Error(`${item.plan.name} 已下架,请先移出购物车`);
|
||||
|
||||
const availability = await getPlanAvailability(item.plan, { userId: session.user.id });
|
||||
if (!availability.available) {
|
||||
throw new Error(`${item.plan.name}:${buildUnavailableMessage(availability)}`);
|
||||
}
|
||||
|
||||
if (item.plan.type === "PROXY") {
|
||||
if (!item.selectedInboundId) throw new Error(`${item.plan.name} 缺少线路入口`);
|
||||
const plan = await getProxyPlanForCart(item.planId);
|
||||
assertInboundSelectable(plan, item.selectedInboundId);
|
||||
const price = getPlanPurchasePrice(item.plan, item.trafficGb);
|
||||
if (!price.trafficGb) throw new Error(`${item.plan.name} 缺少流量配置`);
|
||||
trafficByPlan.set(item.planId, (trafficByPlan.get(item.planId) ?? 0) + price.trafficGb);
|
||||
orderItems.push({
|
||||
planId: item.planId,
|
||||
selectedInboundId: item.selectedInboundId,
|
||||
trafficGb: price.trafficGb,
|
||||
unitAmount: price.unitAmount,
|
||||
amount: price.amount,
|
||||
});
|
||||
} else {
|
||||
const price = getPlanPurchasePrice(item.plan);
|
||||
orderItems.push({
|
||||
planId: item.planId,
|
||||
selectedInboundId: null,
|
||||
trafficGb: null,
|
||||
unitAmount: price.unitAmount,
|
||||
amount: price.amount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [planId, trafficGb] of trafficByPlan) {
|
||||
await ensurePlanTrafficPoolCapacity(planId, trafficGb, {
|
||||
messagePrefix: "购物车中的代理套餐额度暂时不足",
|
||||
});
|
||||
}
|
||||
|
||||
const subtotal = orderItems.reduce((sum, item) => sum + item.amount, 0);
|
||||
const discounts = await calculateCheckoutDiscounts({
|
||||
userId: session.user.id,
|
||||
subtotal,
|
||||
couponCode,
|
||||
});
|
||||
|
||||
const order = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.order.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
planId: orderItems[0].planId,
|
||||
kind: "NEW_PURCHASE",
|
||||
amount: discounts.payable,
|
||||
subtotalAmount: discounts.subtotal,
|
||||
discountAmount: discounts.totalDiscount,
|
||||
couponId: discounts.coupon?.id ?? null,
|
||||
couponCode: discounts.coupon?.code ?? null,
|
||||
promotionName: discounts.promotion?.name ?? null,
|
||||
status: "PENDING",
|
||||
items: {
|
||||
create: orderItems,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (discounts.couponGrantId) {
|
||||
await tx.couponGrant.update({
|
||||
where: { id: discounts.couponGrantId },
|
||||
data: { usedOrderId: created.id, usedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
await tx.shoppingCartItem.deleteMany({ where: { userId: session.user.id } });
|
||||
return created;
|
||||
});
|
||||
|
||||
revalidatePath("/cart");
|
||||
revalidatePath("/store");
|
||||
revalidatePath("/orders");
|
||||
|
||||
return order.id;
|
||||
}
|
||||
34
src/actions/user/notifications.ts
Normal file
34
src/actions/user/notifications.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireAuth } from "@/lib/require-auth";
|
||||
import {
|
||||
deleteNotification,
|
||||
deleteReadNotifications,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
} from "@/services/notifications";
|
||||
|
||||
export async function markNotificationAsRead(notificationId: string) {
|
||||
const session = await requireAuth();
|
||||
await markNotificationRead(notificationId, session.user.id);
|
||||
revalidatePath("/notifications");
|
||||
}
|
||||
|
||||
export async function markEveryNotificationAsRead() {
|
||||
const session = await requireAuth();
|
||||
await markAllNotificationsRead(session.user.id);
|
||||
revalidatePath("/notifications");
|
||||
}
|
||||
|
||||
export async function removeNotification(notificationId: string) {
|
||||
const session = await requireAuth();
|
||||
await deleteNotification(notificationId, session.user.id);
|
||||
revalidatePath("/notifications");
|
||||
}
|
||||
|
||||
export async function removeReadNotifications() {
|
||||
const session = await requireAuth();
|
||||
await deleteReadNotifications(session.user.id);
|
||||
revalidatePath("/notifications");
|
||||
}
|
||||
71
src/actions/user/orders.ts
Normal file
71
src/actions/user/orders.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAuth } from "@/lib/require-auth";
|
||||
|
||||
async function findOwnPendingOrder(orderId: string, userId: string) {
|
||||
const order = await prisma.order.findFirst({
|
||||
where: {
|
||||
id: orderId,
|
||||
userId,
|
||||
},
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
throw new Error("订单不存在");
|
||||
}
|
||||
if (order.status !== "PENDING") {
|
||||
throw new Error("这笔订单已经不在待支付状态");
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
export async function cancelOwnPendingOrder(orderId: string) {
|
||||
const session = await requireAuth();
|
||||
await findOwnPendingOrder(orderId, session.user.id);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: "CANCELLED",
|
||||
paymentMethod: null,
|
||||
paymentRef: null,
|
||||
paymentUrl: null,
|
||||
tradeNo: null,
|
||||
expireAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.couponGrant.updateMany({
|
||||
where: { usedOrderId: orderId },
|
||||
data: { usedOrderId: null, usedAt: null },
|
||||
});
|
||||
});
|
||||
|
||||
revalidatePath("/orders");
|
||||
revalidatePath("/store");
|
||||
revalidatePath(`/pay/${orderId}`);
|
||||
}
|
||||
|
||||
export async function resetOwnPendingPaymentChoice(orderId: string) {
|
||||
const session = await requireAuth();
|
||||
await findOwnPendingOrder(orderId, session.user.id);
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
paymentMethod: null,
|
||||
paymentRef: null,
|
||||
paymentUrl: null,
|
||||
tradeNo: null,
|
||||
expireAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/orders");
|
||||
revalidatePath(`/pay/${orderId}`);
|
||||
}
|
||||
387
src/actions/user/purchase.ts
Normal file
387
src/actions/user/purchase.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
"use server";
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import {
|
||||
buildUnavailableMessage,
|
||||
formatAvailabilityDateTime,
|
||||
getPlanAvailability,
|
||||
} from "@/services/plan-availability";
|
||||
import {
|
||||
ensurePlanTrafficPoolCapacity,
|
||||
getPlanTrafficPoolState,
|
||||
} from "@/services/plan-traffic-pool";
|
||||
import { getPlanPurchasePrice, roundMoney } from "@/services/commerce";
|
||||
|
||||
async function assertNoPendingOrder(userId: string) {
|
||||
const pendingOrder = await prisma.order.findFirst({
|
||||
where: { userId, status: "PENDING" },
|
||||
select: { id: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (pendingOrder) {
|
||||
throw new Error("你还有一笔订单正在等待支付,请先完成或取消后再继续购买");
|
||||
}
|
||||
}
|
||||
|
||||
function getRenewalOrderPrice(
|
||||
plan: {
|
||||
durationDays: number;
|
||||
renewalPrice: unknown;
|
||||
renewalPricingMode: string;
|
||||
renewalDurationDays: number | null;
|
||||
renewalMinDays: number | null;
|
||||
renewalMaxDays: number | null;
|
||||
},
|
||||
requestedDays?: number,
|
||||
) {
|
||||
const unitPrice = Number(plan.renewalPrice ?? 0);
|
||||
if (!Number.isFinite(unitPrice) || unitPrice <= 0) {
|
||||
throw new Error("这款套餐暂时不支持续费");
|
||||
}
|
||||
|
||||
const mode = plan.renewalPricingMode === "PER_DAY" ? "PER_DAY" : "FIXED_DURATION";
|
||||
if (mode === "PER_DAY") {
|
||||
const minDays = plan.renewalMinDays ?? 1;
|
||||
const maxDays = plan.renewalMaxDays ?? plan.durationDays;
|
||||
const durationDays = requestedDays ?? minDays;
|
||||
if (!Number.isInteger(durationDays) || durationDays < minDays || durationDays > maxDays) {
|
||||
throw new Error(`续费天数范围: ${minDays}-${maxDays} 天`);
|
||||
}
|
||||
return {
|
||||
durationDays,
|
||||
amount: roundMoney(unitPrice * durationDays),
|
||||
};
|
||||
}
|
||||
|
||||
const unitDays = plan.renewalDurationDays ?? plan.durationDays;
|
||||
const minDays = plan.renewalMinDays ?? unitDays;
|
||||
const maxDays = plan.renewalMaxDays ?? unitDays;
|
||||
const durationDays = requestedDays ?? unitDays;
|
||||
if (!Number.isInteger(durationDays) || durationDays < minDays || durationDays > maxDays) {
|
||||
throw new Error(`续费天数范围: ${minDays}-${maxDays} 天`);
|
||||
}
|
||||
if (durationDays % unitDays !== 0) {
|
||||
throw new Error(`续费天数必须是 ${unitDays} 天的整数倍`);
|
||||
}
|
||||
|
||||
return {
|
||||
durationDays,
|
||||
amount: roundMoney(unitPrice * (durationDays / unitDays)),
|
||||
};
|
||||
}
|
||||
|
||||
function getTrafficTopupOrderPrice(
|
||||
plan: {
|
||||
topupPricingMode: string;
|
||||
topupPricePerGb: unknown;
|
||||
topupFixedPrice: unknown;
|
||||
minTopupGb: number | null;
|
||||
maxTopupGb: number | null;
|
||||
pricePerGb: unknown;
|
||||
},
|
||||
trafficGb: number,
|
||||
) {
|
||||
const minTopupGb = plan.minTopupGb ?? 1;
|
||||
const maxTopupGb = plan.maxTopupGb ?? null;
|
||||
if (trafficGb < minTopupGb) {
|
||||
throw new Error(`单次至少增加 ${minTopupGb} GB`);
|
||||
}
|
||||
if (maxTopupGb != null && trafficGb > maxTopupGb) {
|
||||
throw new Error(`单次最多增加 ${maxTopupGb} GB`);
|
||||
}
|
||||
|
||||
if (plan.topupPricingMode === "FIXED_AMOUNT") {
|
||||
const fixedAmount = Number(plan.topupFixedPrice ?? 0);
|
||||
if (!Number.isFinite(fixedAmount) || fixedAmount <= 0) {
|
||||
throw new Error("这款套餐暂时不能增加流量");
|
||||
}
|
||||
return roundMoney(fixedAmount);
|
||||
}
|
||||
|
||||
const pricePerGb = Number(plan.topupPricePerGb ?? plan.pricePerGb ?? 0);
|
||||
if (!Number.isFinite(pricePerGb) || pricePerGb <= 0) {
|
||||
throw new Error("这款套餐暂时不能增加流量");
|
||||
}
|
||||
return roundMoney(pricePerGb * trafficGb);
|
||||
}
|
||||
|
||||
export async function purchaseProxy(
|
||||
planId: string,
|
||||
trafficGb: number,
|
||||
selectedInboundId: string,
|
||||
): Promise<string> {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("未登录");
|
||||
await assertNoPendingOrder(session.user.id);
|
||||
|
||||
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
|
||||
where: { id: planId },
|
||||
include: {
|
||||
inboundOptions: {
|
||||
include: {
|
||||
inbound: {
|
||||
select: {
|
||||
id: true,
|
||||
isActive: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (plan.type !== "PROXY") throw new Error("套餐类型错误");
|
||||
if (!plan.isActive) throw new Error("套餐已下架");
|
||||
|
||||
const price = getPlanPurchasePrice(plan, trafficGb);
|
||||
|
||||
const poolState = await getPlanTrafficPoolState(plan.id);
|
||||
if (poolState.enabled && price.trafficGb != null) {
|
||||
await ensurePlanTrafficPoolCapacity(plan.id, price.trafficGb, {
|
||||
messagePrefix: "这款套餐额度暂时不足",
|
||||
});
|
||||
}
|
||||
|
||||
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
||||
if (!availability.available) {
|
||||
throw new Error(buildUnavailableMessage(availability));
|
||||
}
|
||||
|
||||
const selectableInboundIds = plan.inboundOptions
|
||||
.filter(
|
||||
(item) =>
|
||||
item.inbound.isActive
|
||||
&& (!plan.nodeId || item.inbound.serverId === plan.nodeId),
|
||||
)
|
||||
.map((item) => item.inboundId);
|
||||
|
||||
let fallbackInboundId = "";
|
||||
if (plan.inboundId && plan.nodeId) {
|
||||
const fallbackInbound = await prisma.nodeInbound.findFirst({
|
||||
where: {
|
||||
id: plan.inboundId,
|
||||
serverId: plan.nodeId,
|
||||
isActive: true,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
fallbackInboundId = fallbackInbound?.id ?? "";
|
||||
}
|
||||
|
||||
const selectable = selectableInboundIds.length > 0 ? selectableInboundIds : [fallbackInboundId];
|
||||
|
||||
if (!selectedInboundId || !selectable.filter(Boolean).includes(selectedInboundId)) {
|
||||
throw new Error("请选择有效的线路入口");
|
||||
}
|
||||
if (!selectable[0]) {
|
||||
throw new Error("这款套餐的线路入口正在整理中,暂时不能购买");
|
||||
}
|
||||
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
planId,
|
||||
kind: "NEW_PURCHASE",
|
||||
amount: price.amount,
|
||||
subtotalAmount: price.amount,
|
||||
discountAmount: 0,
|
||||
selectedInboundId,
|
||||
status: "PENDING",
|
||||
items: {
|
||||
create: {
|
||||
planId,
|
||||
selectedInboundId,
|
||||
trafficGb: price.trafficGb,
|
||||
unitAmount: price.unitAmount,
|
||||
amount: price.amount,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return order.id;
|
||||
}
|
||||
|
||||
export async function purchaseStreaming(planId: string): Promise<string> {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("未登录");
|
||||
await assertNoPendingOrder(session.user.id);
|
||||
|
||||
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
|
||||
where: { id: planId },
|
||||
});
|
||||
|
||||
if (plan.type !== "STREAMING") throw new Error("套餐类型错误");
|
||||
if (!plan.isActive) throw new Error("套餐已下架");
|
||||
|
||||
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
||||
if (!availability.available) {
|
||||
throw new Error(buildUnavailableMessage(availability));
|
||||
}
|
||||
|
||||
const price = getPlanPurchasePrice(plan);
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
planId,
|
||||
kind: "NEW_PURCHASE",
|
||||
amount: price.amount,
|
||||
subtotalAmount: price.amount,
|
||||
discountAmount: 0,
|
||||
status: "PENDING",
|
||||
items: {
|
||||
create: {
|
||||
planId,
|
||||
trafficGb: null,
|
||||
unitAmount: price.unitAmount,
|
||||
amount: price.amount,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return order.id;
|
||||
}
|
||||
|
||||
export type PurchaseRenewalResult =
|
||||
| { ok: true; orderId: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export async function purchaseRenewal(
|
||||
subscriptionId: string,
|
||||
renewalDays?: number,
|
||||
): Promise<PurchaseRenewalResult> {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("未登录");
|
||||
await assertNoPendingOrder(session.user.id);
|
||||
|
||||
const subscription = await prisma.userSubscription.findFirst({
|
||||
where: { id: subscriptionId, userId: session.user.id },
|
||||
include: {
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
if (!subscription) throw new Error("订阅不存在");
|
||||
if (subscription.status !== "ACTIVE" || subscription.endDate <= new Date()) {
|
||||
throw new Error("仅支持对活跃订阅续费");
|
||||
}
|
||||
if (!subscription.plan.allowRenewal) {
|
||||
throw new Error("这款套餐暂时不支持续费");
|
||||
}
|
||||
|
||||
const price = getRenewalOrderPrice(subscription.plan, renewalDays);
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
planId: subscription.planId,
|
||||
kind: "RENEWAL",
|
||||
targetSubscriptionId: subscription.id,
|
||||
amount: price.amount,
|
||||
subtotalAmount: price.amount,
|
||||
discountAmount: 0,
|
||||
durationDays: price.durationDays,
|
||||
status: "PENDING",
|
||||
},
|
||||
});
|
||||
return { ok: true, orderId: order.id };
|
||||
} catch (error) {
|
||||
return { ok: false, error: getErrorMessage(error, "创建续费订单失败") };
|
||||
}
|
||||
}
|
||||
|
||||
export async function purchaseTrafficTopup(
|
||||
subscriptionId: string,
|
||||
trafficGb: number,
|
||||
): Promise<string> {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("未登录");
|
||||
await assertNoPendingOrder(session.user.id);
|
||||
|
||||
if (!Number.isFinite(trafficGb) || trafficGb <= 0 || !Number.isInteger(trafficGb)) {
|
||||
throw new Error("增流量必须是正整数 GB");
|
||||
}
|
||||
|
||||
const subscription = await prisma.userSubscription.findFirst({
|
||||
where: { id: subscriptionId, userId: session.user.id },
|
||||
include: {
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
if (!subscription) throw new Error("订阅不存在");
|
||||
if (subscription.plan.type !== "PROXY") {
|
||||
throw new Error("仅代理订阅支持增流量");
|
||||
}
|
||||
if (subscription.status !== "ACTIVE" || subscription.endDate <= new Date()) {
|
||||
throw new Error("增流量仅在当前套餐有效期内生效");
|
||||
}
|
||||
if (!subscription.plan.allowTrafficTopup) {
|
||||
throw new Error("这款套餐暂时不支持增加流量");
|
||||
}
|
||||
|
||||
const amount = getTrafficTopupOrderPrice(subscription.plan, trafficGb);
|
||||
const poolState = await getPlanTrafficPoolState(subscription.planId);
|
||||
if (poolState.enabled) {
|
||||
const remainingGb = Math.max(0, Math.floor(poolState.remainingGb));
|
||||
if (trafficGb > remainingGb) {
|
||||
throw new Error(`剩余总流量不足,当前最多可增 ${remainingGb} GB`);
|
||||
}
|
||||
}
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
planId: subscription.planId,
|
||||
kind: "TRAFFIC_TOPUP",
|
||||
targetSubscriptionId: subscription.id,
|
||||
amount,
|
||||
subtotalAmount: amount,
|
||||
discountAmount: 0,
|
||||
trafficGb,
|
||||
status: "PENDING",
|
||||
},
|
||||
});
|
||||
return order.id;
|
||||
}
|
||||
|
||||
export async function queryPlanNextAvailability(planId: string): Promise<{
|
||||
available: boolean;
|
||||
message: string;
|
||||
nextAvailableAt: string | null;
|
||||
}> {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("未登录");
|
||||
|
||||
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
|
||||
where: { id: planId },
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
totalLimit: true,
|
||||
perUserLimit: true,
|
||||
streamingServiceId: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!plan.isActive) {
|
||||
return {
|
||||
available: false,
|
||||
message: "这款套餐暂时不可购买",
|
||||
nextAvailableAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const availability = await getPlanAvailability(plan, { userId: session.user.id });
|
||||
return {
|
||||
available: availability.available,
|
||||
message: availability.available ? "这款套餐现在可以购买" : buildUnavailableMessage(availability),
|
||||
nextAvailableAt: availability.nextAvailableAt
|
||||
? formatAvailabilityDateTime(availability.nextAvailableAt)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
108
src/actions/user/subscription-security.ts
Normal file
108
src/actions/user/subscription-security.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { randomUUID } from "crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAuth } from "@/lib/require-auth";
|
||||
import { generateNodeClientCredential } from "@/services/node-client-credential";
|
||||
import { bytesToGb } from "@/lib/utils";
|
||||
import { createPanelAdapter } from "@/services/node-panel/factory";
|
||||
import { createNotification } from "@/services/notifications";
|
||||
import { recordAuditLog } from "@/services/audit";
|
||||
|
||||
function newDownloadToken() {
|
||||
return randomUUID().replace(/-/g, "");
|
||||
}
|
||||
|
||||
export async function rotateSubscriptionAccess(subscriptionId: string) {
|
||||
const session = await requireAuth();
|
||||
|
||||
const subscription = await prisma.userSubscription.findFirst({
|
||||
where: {
|
||||
id: subscriptionId,
|
||||
userId: session.user.id,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
include: {
|
||||
plan: true,
|
||||
nodeClient: {
|
||||
include: {
|
||||
inbound: {
|
||||
include: {
|
||||
server: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
throw new Error("订阅不存在或不可操作");
|
||||
}
|
||||
|
||||
const nextToken = newDownloadToken();
|
||||
if (subscription.plan.type === "PROXY" && subscription.nodeClient) {
|
||||
const nextCredential = generateNodeClientCredential(
|
||||
subscription.nodeClient.inbound.protocol,
|
||||
subscription.nodeClient.inbound.settings,
|
||||
);
|
||||
const panelInboundId = subscription.nodeClient.inbound.panelInboundId;
|
||||
if (panelInboundId == null) {
|
||||
throw new Error("3x-ui 入站 ID 缺失,请重新同步节点入站");
|
||||
}
|
||||
|
||||
const adapter = createPanelAdapter(subscription.nodeClient.inbound.server);
|
||||
await adapter.login();
|
||||
await adapter.deleteClient(panelInboundId, subscription.nodeClient.uuid);
|
||||
await adapter.addClient({
|
||||
inboundId: panelInboundId,
|
||||
email: subscription.nodeClient.email,
|
||||
uuid: nextCredential,
|
||||
subId: subscription.id,
|
||||
totalGB: subscription.trafficLimit ? bytesToGb(subscription.trafficLimit) : 0,
|
||||
expiryTime: subscription.endDate.getTime(),
|
||||
protocol: subscription.nodeClient.inbound.protocol,
|
||||
});
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.nodeClient.update({
|
||||
where: { id: subscription.nodeClient!.id },
|
||||
data: { uuid: nextCredential },
|
||||
});
|
||||
await tx.userSubscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: { downloadToken: nextToken },
|
||||
});
|
||||
});
|
||||
} else {
|
||||
await prisma.userSubscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: { downloadToken: nextToken },
|
||||
});
|
||||
}
|
||||
|
||||
await createNotification({
|
||||
userId: subscription.userId,
|
||||
type: "SUBSCRIPTION",
|
||||
level: "SUCCESS",
|
||||
title: "订阅访问已重置",
|
||||
body: `${subscription.plan.name} 的订阅链接和访问凭据已更新,旧配置已失效。`,
|
||||
link: "/subscriptions",
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: {
|
||||
userId: session.user.id,
|
||||
email: session.user.email ?? undefined,
|
||||
role: session.user.role === "ADMIN" || session.user.role === "USER" ? session.user.role : undefined,
|
||||
},
|
||||
action: "subscription.rotate_access",
|
||||
targetType: "UserSubscription",
|
||||
targetId: subscription.id,
|
||||
targetLabel: subscription.plan.name,
|
||||
message: `用户重置订阅访问 ${subscription.plan.name}`,
|
||||
});
|
||||
|
||||
revalidatePath("/subscriptions");
|
||||
revalidatePath(`/subscriptions/${subscriptionId}`);
|
||||
}
|
||||
238
src/actions/user/support.ts
Normal file
238
src/actions/user/support.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAuth } from "@/lib/require-auth";
|
||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||
import { createNotification } from "@/services/notifications";
|
||||
import {
|
||||
createSupportAttachments,
|
||||
deleteSupportTicketRecords,
|
||||
parseSupportAttachments,
|
||||
} from "@/services/support";
|
||||
|
||||
const createTicketSchema = z.object({
|
||||
subject: z.string().trim().min(1, "标题不能为空"),
|
||||
category: z.string().trim().optional(),
|
||||
priority: z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]).default("NORMAL"),
|
||||
body: z.string().trim().min(1, "内容不能为空"),
|
||||
});
|
||||
|
||||
export async function createSupportTicket(formData: FormData) {
|
||||
const session = await requireAuth();
|
||||
const data = createTicketSchema.parse(Object.fromEntries(formData));
|
||||
const attachments = parseSupportAttachments(formData.getAll("attachments"));
|
||||
|
||||
const ticket = await prisma.supportTicket.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
subject: data.subject,
|
||||
category: data.category || null,
|
||||
priority: data.priority,
|
||||
status: "OPEN",
|
||||
lastReplyAt: new Date(),
|
||||
replies: {
|
||||
create: {
|
||||
authorUserId: session.user.id,
|
||||
isAdmin: false,
|
||||
body: data.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
replies: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
const firstReply = ticket.replies[0];
|
||||
if (firstReply && attachments.length > 0) {
|
||||
await createSupportAttachments({
|
||||
ticketId: ticket.id,
|
||||
replyId: firstReply.id,
|
||||
files: attachments,
|
||||
});
|
||||
}
|
||||
|
||||
const admins = await prisma.user.findMany({
|
||||
where: { role: "ADMIN", status: "ACTIVE" },
|
||||
select: { id: true },
|
||||
});
|
||||
for (const admin of admins) {
|
||||
await createNotification({
|
||||
userId: admin.id,
|
||||
type: "SYSTEM",
|
||||
level: "INFO",
|
||||
title: "新工单待处理",
|
||||
body: `收到新工单:${data.subject}`,
|
||||
link: `/admin/support/${ticket.id}`,
|
||||
dedupeKey: `support-created:${ticket.id}:${admin.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/support");
|
||||
revalidatePath(`/support/${ticket.id}`);
|
||||
revalidatePath("/admin/support");
|
||||
}
|
||||
|
||||
export async function replySupportTicket(ticketId: string, formData: FormData) {
|
||||
const session = await requireAuth();
|
||||
const body = String(formData.get("body") || "").trim();
|
||||
const attachments = parseSupportAttachments(formData.getAll("attachments"));
|
||||
if (!body) {
|
||||
throw new Error("回复内容不能为空");
|
||||
}
|
||||
|
||||
const ticket = await prisma.supportTicket.findFirst({
|
||||
where: {
|
||||
id: ticketId,
|
||||
userId: session.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
subject: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
if (!ticket) {
|
||||
throw new Error("工单不存在");
|
||||
}
|
||||
if (ticket.status === "CLOSED") {
|
||||
throw new Error("已关闭的工单不能继续回复");
|
||||
}
|
||||
|
||||
const updated = await prisma.supportTicket.update({
|
||||
where: { id: ticket.id },
|
||||
data: {
|
||||
status: "USER_REPLIED",
|
||||
closedAt: null,
|
||||
lastReplyAt: new Date(),
|
||||
replies: {
|
||||
create: {
|
||||
authorUserId: session.user.id,
|
||||
isAdmin: false,
|
||||
body,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
replies: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
const createdReply = updated.replies[0];
|
||||
if (createdReply && attachments.length > 0) {
|
||||
await createSupportAttachments({
|
||||
ticketId: ticket.id,
|
||||
replyId: createdReply.id,
|
||||
files: attachments,
|
||||
});
|
||||
}
|
||||
|
||||
const admins = await prisma.user.findMany({
|
||||
where: { role: "ADMIN", status: "ACTIVE" },
|
||||
select: { id: true },
|
||||
});
|
||||
for (const admin of admins) {
|
||||
await createNotification({
|
||||
userId: admin.id,
|
||||
type: "SYSTEM",
|
||||
level: "INFO",
|
||||
title: "工单有新回复",
|
||||
body: `工单「${ticket.subject}」有用户新回复。`,
|
||||
link: `/admin/support/${ticket.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath(`/support/${ticket.id}`);
|
||||
revalidatePath("/support");
|
||||
revalidatePath(`/admin/support/${ticket.id}`);
|
||||
}
|
||||
|
||||
export async function closeSupportTicket(ticketId: string) {
|
||||
const session = await requireAuth();
|
||||
const ticket = await prisma.supportTicket.findFirst({
|
||||
where: {
|
||||
id: ticketId,
|
||||
userId: session.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
subject: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new Error("工单不存在");
|
||||
}
|
||||
|
||||
if (ticket.status === "CLOSED") {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.supportTicket.update({
|
||||
where: { id: ticket.id },
|
||||
data: {
|
||||
status: "CLOSED",
|
||||
closedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "support.close",
|
||||
targetType: "SupportTicket",
|
||||
targetId: ticket.id,
|
||||
targetLabel: ticket.subject,
|
||||
message: `用户关闭工单 ${ticket.subject}`,
|
||||
});
|
||||
|
||||
revalidatePath("/support");
|
||||
revalidatePath(`/support/${ticket.id}`);
|
||||
revalidatePath("/admin/support");
|
||||
revalidatePath(`/admin/support/${ticket.id}`);
|
||||
}
|
||||
|
||||
export async function deleteSupportTicket(ticketId: string) {
|
||||
const session = await requireAuth();
|
||||
const ticket = await prisma.supportTicket.findFirst({
|
||||
where: {
|
||||
id: ticketId,
|
||||
userId: session.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
subject: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new Error("工单不存在");
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await deleteSupportTicketRecords(ticket.id, tx);
|
||||
await recordAuditLog(
|
||||
{
|
||||
actor: actorFromSession(session),
|
||||
action: "support.delete",
|
||||
targetType: "SupportTicket",
|
||||
targetId: ticket.id,
|
||||
targetLabel: ticket.subject,
|
||||
message: `用户删除工单 ${ticket.subject}`,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
revalidatePath("/support");
|
||||
revalidatePath(`/support/${ticket.id}`);
|
||||
revalidatePath("/admin/support");
|
||||
revalidatePath(`/admin/support/${ticket.id}`);
|
||||
revalidatePath("/notifications");
|
||||
}
|
||||
Reference in New Issue
Block a user