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