Files
J-Board-Lite/src/actions/admin/plans.ts
2026-04-30 21:48:59 +10:00

687 lines
22 KiB
TypeScript

"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("代理套餐必须选择节点");
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");
}