mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
Initial commit
This commit is contained in:
689
src/actions/admin/plans.ts
Normal file
689
src/actions/admin/plans.ts
Normal file
@@ -0,0 +1,689 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/require-auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||
import { deleteSubscriptionPermanently } from "./subscriptions";
|
||||
|
||||
const optionalNumber = z.preprocess(
|
||||
(value) => (value === "" || value == null ? undefined : Number(value)),
|
||||
z.number().optional(),
|
||||
);
|
||||
const optionalInt = z.preprocess(
|
||||
(value) => (value === "" || value == null ? undefined : Number(value)),
|
||||
z.number().int().optional(),
|
||||
);
|
||||
const optionalBool = z.preprocess((value) => {
|
||||
if (value === "" || value == null) return undefined;
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === "true" || normalized === "1" || normalized === "on";
|
||||
}
|
||||
return Boolean(value);
|
||||
}, z.boolean().optional());
|
||||
|
||||
const planSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
type: z.enum(["STREAMING", "PROXY"]),
|
||||
description: z.string().optional(),
|
||||
durationDays: z.coerce.number().int().positive(),
|
||||
sortOrder: z.coerce.number().int().default(100),
|
||||
price: optionalNumber,
|
||||
nodeId: z.string().optional(),
|
||||
inboundId: z.string().optional(),
|
||||
inboundIds: z.string().optional(),
|
||||
streamingServiceId: z.string().optional(),
|
||||
pricingMode: z.enum(["TRAFFIC_SLIDER", "FIXED_PACKAGE"]).optional(),
|
||||
fixedTrafficGb: optionalInt,
|
||||
fixedPrice: optionalNumber,
|
||||
totalLimit: optionalInt,
|
||||
perUserLimit: optionalInt,
|
||||
totalTrafficGb: optionalInt,
|
||||
allowRenewal: optionalBool,
|
||||
allowTrafficTopup: optionalBool,
|
||||
renewalPrice: optionalNumber,
|
||||
renewalPricingMode: z.enum(["PER_DAY", "FIXED_DURATION"]).optional(),
|
||||
renewalDurationDays: optionalInt,
|
||||
renewalMinDays: optionalInt,
|
||||
renewalMaxDays: optionalInt,
|
||||
renewalTrafficGb: optionalInt,
|
||||
topupPricingMode: z.enum(["PER_GB", "FIXED_AMOUNT"]).optional(),
|
||||
topupPricePerGb: optionalNumber,
|
||||
topupFixedPrice: optionalNumber,
|
||||
minTopupGb: optionalInt,
|
||||
maxTopupGb: optionalInt,
|
||||
pricePerGb: optionalNumber,
|
||||
minTrafficGb: optionalInt,
|
||||
maxTrafficGb: optionalInt,
|
||||
});
|
||||
|
||||
function parseInboundIds(raw: string | undefined, fallbackInboundId?: string): string[] {
|
||||
const list = (raw ?? "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
if (list.length > 0) {
|
||||
return Array.from(new Set(list));
|
||||
}
|
||||
if (fallbackInboundId) {
|
||||
return [fallbackInboundId];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function assertProxyPricing(data: z.infer<typeof planSchema>) {
|
||||
const pricingMode = data.pricingMode ?? "TRAFFIC_SLIDER";
|
||||
if (pricingMode === "FIXED_PACKAGE") {
|
||||
if (data.fixedTrafficGb == null || data.fixedTrafficGb <= 0) {
|
||||
throw new Error("固定流量套餐必须填写固定流量");
|
||||
}
|
||||
if (data.fixedPrice == null || data.fixedPrice <= 0) {
|
||||
throw new Error("固定流量套餐必须填写固定价格");
|
||||
}
|
||||
} else {
|
||||
if (data.pricePerGb == null || data.pricePerGb <= 0) {
|
||||
throw new Error("自选流量套餐必须填写每 GB 价格");
|
||||
}
|
||||
if (data.minTrafficGb == null || data.minTrafficGb <= 0) {
|
||||
throw new Error("自选流量套餐必须填写最小流量");
|
||||
}
|
||||
if (data.maxTrafficGb == null || data.maxTrafficGb <= 0) {
|
||||
throw new Error("自选流量套餐必须填写最大流量");
|
||||
}
|
||||
if (data.maxTrafficGb < data.minTrafficGb) {
|
||||
throw new Error("最大流量不能小于最小流量");
|
||||
}
|
||||
}
|
||||
return pricingMode;
|
||||
}
|
||||
|
||||
type PlanFormData = z.infer<typeof planSchema>;
|
||||
|
||||
function getRenewalPolicy(data: PlanFormData, allowRenewal: boolean) {
|
||||
if (!allowRenewal) {
|
||||
return {
|
||||
renewalPrice: null,
|
||||
renewalPricingMode: "FIXED_DURATION",
|
||||
renewalDurationDays: null,
|
||||
renewalMinDays: null,
|
||||
renewalMaxDays: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (data.renewalPrice == null || data.renewalPrice <= 0) {
|
||||
throw new Error("开启续费时,续费金额必须大于 0");
|
||||
}
|
||||
|
||||
const renewalPricingMode = data.renewalPricingMode ?? "FIXED_DURATION";
|
||||
|
||||
if (renewalPricingMode === "FIXED_DURATION") {
|
||||
const renewalDurationDays = data.renewalDurationDays ?? data.durationDays;
|
||||
if (!renewalDurationDays || renewalDurationDays <= 0) {
|
||||
throw new Error("固定周期续费必须填写续费天数");
|
||||
}
|
||||
|
||||
return {
|
||||
renewalPrice: data.renewalPrice,
|
||||
renewalPricingMode,
|
||||
renewalDurationDays,
|
||||
renewalMinDays: renewalDurationDays,
|
||||
renewalMaxDays: renewalDurationDays,
|
||||
};
|
||||
}
|
||||
|
||||
const renewalMinDays = data.renewalMinDays ?? 1;
|
||||
const renewalMaxDays = data.renewalMaxDays ?? data.durationDays;
|
||||
|
||||
if (renewalMinDays <= 0 || renewalMaxDays <= 0) {
|
||||
throw new Error("续费天数范围必须大于 0");
|
||||
}
|
||||
if (renewalMaxDays < renewalMinDays) {
|
||||
throw new Error("续费最大天数不能小于最小天数");
|
||||
}
|
||||
|
||||
return {
|
||||
renewalPrice: data.renewalPrice,
|
||||
renewalPricingMode,
|
||||
renewalDurationDays: null,
|
||||
renewalMinDays,
|
||||
renewalMaxDays,
|
||||
};
|
||||
}
|
||||
|
||||
function getTopupPolicy(data: PlanFormData, allowTrafficTopup: boolean) {
|
||||
if (data.type !== "PROXY" || !allowTrafficTopup) {
|
||||
return {
|
||||
topupPricingMode: "PER_GB",
|
||||
topupPricePerGb: null,
|
||||
topupFixedPrice: null,
|
||||
minTopupGb: null,
|
||||
maxTopupGb: null,
|
||||
};
|
||||
}
|
||||
|
||||
const topupPricingMode = data.topupPricingMode ?? "PER_GB";
|
||||
if (topupPricingMode === "PER_GB") {
|
||||
if (data.topupPricePerGb == null || data.topupPricePerGb <= 0) {
|
||||
throw new Error("开启增流量时,每 GB 加流量价格必须大于 0");
|
||||
}
|
||||
} else if (data.topupFixedPrice == null || data.topupFixedPrice <= 0) {
|
||||
throw new Error("开启增流量时,固定加流量金额必须大于 0");
|
||||
}
|
||||
|
||||
const minTopupGb = data.minTopupGb ?? 1;
|
||||
const maxTopupGb = data.maxTopupGb ?? null;
|
||||
if (minTopupGb <= 0) {
|
||||
throw new Error("最小增流量必须大于 0");
|
||||
}
|
||||
if (maxTopupGb != null && maxTopupGb <= 0) {
|
||||
throw new Error("最大增流量必须大于 0");
|
||||
}
|
||||
if (maxTopupGb != null && maxTopupGb < minTopupGb) {
|
||||
throw new Error("最大增流量不能小于最小增流量");
|
||||
}
|
||||
|
||||
return {
|
||||
topupPricingMode,
|
||||
topupPricePerGb: topupPricingMode === "PER_GB" ? data.topupPricePerGb : null,
|
||||
topupFixedPrice: topupPricingMode === "FIXED_AMOUNT" ? data.topupFixedPrice : null,
|
||||
minTopupGb,
|
||||
maxTopupGb,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createPlan(formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const raw = Object.fromEntries(formData);
|
||||
const data = planSchema.parse(raw);
|
||||
const allowRenewal = data.allowRenewal ?? false;
|
||||
const allowTrafficTopup = data.allowTrafficTopup ?? false;
|
||||
|
||||
if (data.totalLimit != null && data.totalLimit <= 0) {
|
||||
throw new Error("总量上限必须大于 0");
|
||||
}
|
||||
if (data.perUserLimit != null && data.perUserLimit <= 0) {
|
||||
throw new Error("每用户限购必须大于 0");
|
||||
}
|
||||
if (data.totalTrafficGb != null && data.totalTrafficGb <= 0) {
|
||||
throw new Error("总流量池必须大于 0");
|
||||
}
|
||||
const renewalPolicy = getRenewalPolicy(data, allowRenewal);
|
||||
const topupPolicy = getTopupPolicy(data, allowTrafficTopup);
|
||||
|
||||
if (data.type === "PROXY") {
|
||||
const pricingMode = assertProxyPricing(data);
|
||||
if (!data.nodeId) throw new Error("代理套餐必须选择节点");
|
||||
|
||||
const inboundIds = parseInboundIds(data.inboundIds, data.inboundId);
|
||||
if (inboundIds.length === 0) {
|
||||
throw new Error("请至少配置一个可售入站");
|
||||
}
|
||||
|
||||
const inbounds = await prisma.nodeInbound.findMany({
|
||||
where: {
|
||||
id: { in: inboundIds },
|
||||
},
|
||||
select: { id: true, serverId: true, isActive: true },
|
||||
});
|
||||
if (inbounds.length !== inboundIds.length) {
|
||||
throw new Error("存在无效入站,请重新选择");
|
||||
}
|
||||
for (const inbound of inbounds) {
|
||||
if (inbound.serverId !== data.nodeId) {
|
||||
throw new Error("入站与节点不匹配");
|
||||
}
|
||||
if (!inbound.isActive) {
|
||||
throw new Error("入站未启用");
|
||||
}
|
||||
}
|
||||
|
||||
let createdPlanId = "";
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const plan = await tx.subscriptionPlan.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
type: "PROXY",
|
||||
description: data.description || null,
|
||||
durationDays: data.durationDays,
|
||||
sortOrder: data.sortOrder,
|
||||
totalLimit: data.totalLimit ?? null,
|
||||
perUserLimit: data.perUserLimit ?? null,
|
||||
totalTrafficGb: data.totalTrafficGb ?? null,
|
||||
allowRenewal,
|
||||
allowTrafficTopup,
|
||||
renewalPrice: renewalPolicy.renewalPrice,
|
||||
renewalPricingMode: renewalPolicy.renewalPricingMode,
|
||||
renewalDurationDays: renewalPolicy.renewalDurationDays,
|
||||
renewalMinDays: renewalPolicy.renewalMinDays,
|
||||
renewalMaxDays: renewalPolicy.renewalMaxDays,
|
||||
renewalTrafficGb: null,
|
||||
topupPricingMode: topupPolicy.topupPricingMode,
|
||||
topupPricePerGb: topupPolicy.topupPricePerGb,
|
||||
topupFixedPrice: topupPolicy.topupFixedPrice,
|
||||
minTopupGb: topupPolicy.minTopupGb,
|
||||
maxTopupGb: topupPolicy.maxTopupGb,
|
||||
nodeId: data.nodeId,
|
||||
inboundId: inboundIds[0],
|
||||
streamingServiceId: null,
|
||||
categoryId: null,
|
||||
pricingMode,
|
||||
fixedTrafficGb: pricingMode === "FIXED_PACKAGE" ? data.fixedTrafficGb : null,
|
||||
fixedPrice: pricingMode === "FIXED_PACKAGE" ? data.fixedPrice : null,
|
||||
price: null,
|
||||
pricePerGb: pricingMode === "TRAFFIC_SLIDER" ? data.pricePerGb : null,
|
||||
minTrafficGb: pricingMode === "TRAFFIC_SLIDER" ? data.minTrafficGb : null,
|
||||
maxTrafficGb: pricingMode === "TRAFFIC_SLIDER" ? data.maxTrafficGb : null,
|
||||
},
|
||||
});
|
||||
createdPlanId = plan.id;
|
||||
|
||||
await tx.planInboundOption.createMany({
|
||||
data: inboundIds.map((inboundId) => ({
|
||||
planId: plan.id,
|
||||
inboundId,
|
||||
})),
|
||||
});
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "plan.create",
|
||||
targetType: "SubscriptionPlan",
|
||||
targetId: createdPlanId,
|
||||
targetLabel: data.name,
|
||||
message: `创建代理套餐 ${data.name}`,
|
||||
});
|
||||
} else {
|
||||
if (!data.streamingServiceId) {
|
||||
throw new Error("流媒体套餐必须绑定一个流媒体服务");
|
||||
}
|
||||
|
||||
const service = await prisma.streamingService.findUnique({
|
||||
where: { id: data.streamingServiceId },
|
||||
select: { id: true, isActive: true },
|
||||
});
|
||||
if (!service || !service.isActive) {
|
||||
throw new Error("所选流媒体服务不存在或未启用");
|
||||
}
|
||||
|
||||
const plan = await prisma.subscriptionPlan.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
type: "STREAMING",
|
||||
description: data.description || null,
|
||||
durationDays: data.durationDays,
|
||||
sortOrder: data.sortOrder,
|
||||
totalLimit: data.totalLimit ?? null,
|
||||
perUserLimit: data.perUserLimit ?? null,
|
||||
totalTrafficGb: null,
|
||||
allowRenewal,
|
||||
allowTrafficTopup: false,
|
||||
renewalPrice: renewalPolicy.renewalPrice,
|
||||
renewalPricingMode: renewalPolicy.renewalPricingMode,
|
||||
renewalDurationDays: renewalPolicy.renewalDurationDays,
|
||||
renewalMinDays: renewalPolicy.renewalMinDays,
|
||||
renewalMaxDays: renewalPolicy.renewalMaxDays,
|
||||
renewalTrafficGb: null,
|
||||
topupPricingMode: "PER_GB",
|
||||
topupPricePerGb: null,
|
||||
topupFixedPrice: null,
|
||||
minTopupGb: null,
|
||||
maxTopupGb: null,
|
||||
streamingServiceId: data.streamingServiceId,
|
||||
categoryId: null,
|
||||
pricingMode: "TRAFFIC_SLIDER",
|
||||
fixedTrafficGb: null,
|
||||
fixedPrice: null,
|
||||
price: data.price ?? 0,
|
||||
nodeId: null,
|
||||
inboundId: null,
|
||||
pricePerGb: null,
|
||||
minTrafficGb: null,
|
||||
maxTrafficGb: null,
|
||||
},
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "plan.create",
|
||||
targetType: "SubscriptionPlan",
|
||||
targetId: plan.id,
|
||||
targetLabel: plan.name,
|
||||
message: `创建流媒体套餐 ${plan.name}`,
|
||||
});
|
||||
}
|
||||
revalidatePath("/admin/plans");
|
||||
revalidatePath("/store");
|
||||
}
|
||||
|
||||
export async function updatePlan(id: string, formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const raw = Object.fromEntries(formData);
|
||||
const data = planSchema.parse(raw);
|
||||
const allowRenewal = data.allowRenewal ?? false;
|
||||
const allowTrafficTopup = data.allowTrafficTopup ?? false;
|
||||
const existing = await prisma.subscriptionPlan.findUniqueOrThrow({
|
||||
where: { id },
|
||||
select: { type: true, nodeId: true },
|
||||
});
|
||||
|
||||
if (existing.type !== data.type) {
|
||||
throw new Error("暂不支持修改套餐类型,请新建套餐");
|
||||
}
|
||||
|
||||
if (data.totalLimit != null && data.totalLimit <= 0) {
|
||||
throw new Error("总量上限必须大于 0");
|
||||
}
|
||||
if (data.perUserLimit != null && data.perUserLimit <= 0) {
|
||||
throw new Error("每用户限购必须大于 0");
|
||||
}
|
||||
if (data.totalTrafficGb != null && data.totalTrafficGb <= 0) {
|
||||
throw new Error("总流量池必须大于 0");
|
||||
}
|
||||
const renewalPolicy = getRenewalPolicy(data, allowRenewal);
|
||||
const topupPolicy = getTopupPolicy(data, allowTrafficTopup);
|
||||
|
||||
if (data.type === "PROXY") {
|
||||
const pricingMode = assertProxyPricing(data);
|
||||
const nodeId = data.nodeId ?? existing.nodeId;
|
||||
|
||||
if (!nodeId) throw new Error("代理套餐必须选择节点");
|
||||
if (data.totalTrafficGb == null || data.totalTrafficGb <= 0) {
|
||||
throw new Error("代理套餐必须填写总流量池,且大于 0");
|
||||
}
|
||||
|
||||
const inboundIds = parseInboundIds(data.inboundIds, data.inboundId);
|
||||
if (inboundIds.length === 0) {
|
||||
throw new Error("请至少配置一个可售入站");
|
||||
}
|
||||
|
||||
const inbounds = await prisma.nodeInbound.findMany({
|
||||
where: {
|
||||
id: { in: inboundIds },
|
||||
},
|
||||
select: { id: true, serverId: true, isActive: true },
|
||||
});
|
||||
if (inbounds.length !== inboundIds.length) {
|
||||
throw new Error("存在无效入站,请重新选择");
|
||||
}
|
||||
for (const inbound of inbounds) {
|
||||
if (inbound.serverId !== nodeId) {
|
||||
throw new Error("入站与节点不匹配");
|
||||
}
|
||||
if (!inbound.isActive) {
|
||||
throw new Error("入站未启用");
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.subscriptionPlan.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description || null,
|
||||
durationDays: data.durationDays,
|
||||
sortOrder: data.sortOrder,
|
||||
totalLimit: data.totalLimit ?? null,
|
||||
perUserLimit: data.perUserLimit ?? null,
|
||||
totalTrafficGb: data.totalTrafficGb ?? null,
|
||||
allowRenewal,
|
||||
allowTrafficTopup,
|
||||
renewalPrice: renewalPolicy.renewalPrice,
|
||||
renewalPricingMode: renewalPolicy.renewalPricingMode,
|
||||
renewalDurationDays: renewalPolicy.renewalDurationDays,
|
||||
renewalMinDays: renewalPolicy.renewalMinDays,
|
||||
renewalMaxDays: renewalPolicy.renewalMaxDays,
|
||||
renewalTrafficGb: null,
|
||||
topupPricingMode: topupPolicy.topupPricingMode,
|
||||
topupPricePerGb: topupPolicy.topupPricePerGb,
|
||||
topupFixedPrice: topupPolicy.topupFixedPrice,
|
||||
minTopupGb: topupPolicy.minTopupGb,
|
||||
maxTopupGb: topupPolicy.maxTopupGb,
|
||||
nodeId,
|
||||
inboundId: inboundIds[0],
|
||||
streamingServiceId: null,
|
||||
categoryId: null,
|
||||
pricingMode,
|
||||
fixedTrafficGb: pricingMode === "FIXED_PACKAGE" ? data.fixedTrafficGb : null,
|
||||
fixedPrice: pricingMode === "FIXED_PACKAGE" ? data.fixedPrice : null,
|
||||
price: null,
|
||||
pricePerGb: pricingMode === "TRAFFIC_SLIDER" ? data.pricePerGb : null,
|
||||
minTrafficGb: pricingMode === "TRAFFIC_SLIDER" ? data.minTrafficGb : null,
|
||||
maxTrafficGb: pricingMode === "TRAFFIC_SLIDER" ? data.maxTrafficGb : null,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.planInboundOption.deleteMany({ where: { planId: id } });
|
||||
await tx.planInboundOption.createMany({
|
||||
data: inboundIds.map((inboundId) => ({ planId: id, inboundId })),
|
||||
});
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "plan.update",
|
||||
targetType: "SubscriptionPlan",
|
||||
targetId: id,
|
||||
targetLabel: data.name,
|
||||
message: `更新代理套餐 ${data.name}`,
|
||||
});
|
||||
} else {
|
||||
if (!data.streamingServiceId) {
|
||||
throw new Error("流媒体套餐必须绑定一个流媒体服务");
|
||||
}
|
||||
|
||||
const service = await prisma.streamingService.findUnique({
|
||||
where: { id: data.streamingServiceId },
|
||||
select: { id: true, isActive: true },
|
||||
});
|
||||
if (!service || !service.isActive) {
|
||||
throw new Error("所选流媒体服务不存在或未启用");
|
||||
}
|
||||
|
||||
await prisma.subscriptionPlan.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description || null,
|
||||
durationDays: data.durationDays,
|
||||
sortOrder: data.sortOrder,
|
||||
totalLimit: data.totalLimit ?? null,
|
||||
perUserLimit: data.perUserLimit ?? null,
|
||||
totalTrafficGb: null,
|
||||
allowRenewal,
|
||||
allowTrafficTopup: false,
|
||||
renewalPrice: renewalPolicy.renewalPrice,
|
||||
renewalPricingMode: renewalPolicy.renewalPricingMode,
|
||||
renewalDurationDays: renewalPolicy.renewalDurationDays,
|
||||
renewalMinDays: renewalPolicy.renewalMinDays,
|
||||
renewalMaxDays: renewalPolicy.renewalMaxDays,
|
||||
renewalTrafficGb: null,
|
||||
topupPricingMode: "PER_GB",
|
||||
topupPricePerGb: null,
|
||||
topupFixedPrice: null,
|
||||
minTopupGb: null,
|
||||
maxTopupGb: null,
|
||||
streamingServiceId: data.streamingServiceId,
|
||||
categoryId: null,
|
||||
pricingMode: "TRAFFIC_SLIDER",
|
||||
fixedTrafficGb: null,
|
||||
fixedPrice: null,
|
||||
price: data.price,
|
||||
nodeId: null,
|
||||
inboundId: null,
|
||||
pricePerGb: null,
|
||||
minTrafficGb: null,
|
||||
maxTrafficGb: null,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.planInboundOption.deleteMany({ where: { planId: id } });
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "plan.update",
|
||||
targetType: "SubscriptionPlan",
|
||||
targetId: id,
|
||||
targetLabel: data.name,
|
||||
message: `更新流媒体套餐 ${data.name}`,
|
||||
});
|
||||
}
|
||||
revalidatePath("/admin/plans");
|
||||
revalidatePath("/store");
|
||||
}
|
||||
|
||||
export async function deletePlan(id: string) {
|
||||
const session = await requireAdmin();
|
||||
const plan = await prisma.subscriptionPlan.update({
|
||||
where: { id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: "plan.disable",
|
||||
targetType: "SubscriptionPlan",
|
||||
targetId: plan.id,
|
||||
targetLabel: plan.name,
|
||||
message: `下架套餐 ${plan.name}`,
|
||||
});
|
||||
revalidatePath("/admin/plans");
|
||||
revalidatePath("/store");
|
||||
}
|
||||
|
||||
export async function deletePlanPermanently(id: string) {
|
||||
const session = await requireAdmin();
|
||||
const actor = actorFromSession(session);
|
||||
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: {
|
||||
inboundOptions: {
|
||||
include: {
|
||||
inbound: {
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
clients: true,
|
||||
planOptions: true,
|
||||
plans: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
subscriptions: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const subscription of plan.subscriptions) {
|
||||
await deleteSubscriptionPermanently(subscription.id);
|
||||
}
|
||||
|
||||
const relatedOrders = await prisma.order.findMany({
|
||||
where: { planId: plan.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await prisma.order.deleteMany({
|
||||
where: { planId: plan.id },
|
||||
});
|
||||
|
||||
const deletableInboundIds: string[] = [];
|
||||
for (const option of plan.inboundOptions) {
|
||||
const inbound = option.inbound;
|
||||
const otherPlanRefs = inbound._count.planOptions - 1;
|
||||
const otherPlanDirectRefs = inbound._count.plans - (plan.inboundId === inbound.id ? 1 : 0);
|
||||
const hasClients = inbound._count.clients > 0;
|
||||
|
||||
if (otherPlanRefs <= 0 && otherPlanDirectRefs <= 0 && !hasClients) {
|
||||
deletableInboundIds.push(inbound.id);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.planInboundOption.deleteMany({
|
||||
where: { planId: plan.id },
|
||||
});
|
||||
|
||||
if (deletableInboundIds.length > 0) {
|
||||
await tx.nodeInbound.deleteMany({
|
||||
where: { id: { in: deletableInboundIds } },
|
||||
});
|
||||
}
|
||||
|
||||
await tx.subscriptionPlan.delete({
|
||||
where: { id: plan.id },
|
||||
});
|
||||
});
|
||||
|
||||
await recordAuditLog({
|
||||
actor,
|
||||
action: "plan.delete",
|
||||
targetType: "SubscriptionPlan",
|
||||
targetId: plan.id,
|
||||
targetLabel: plan.name,
|
||||
message: `彻底删除套餐 ${plan.name}`,
|
||||
metadata: {
|
||||
deletedOrderIds: relatedOrders.map((order) => order.id),
|
||||
deletedInboundIds: deletableInboundIds,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/admin/plans");
|
||||
revalidatePath("/store");
|
||||
revalidatePath("/admin/subscriptions");
|
||||
}
|
||||
|
||||
export async function togglePlan(id: string, isActive: boolean) {
|
||||
const session = await requireAdmin();
|
||||
const plan = await prisma.subscriptionPlan.update({
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
});
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: isActive ? "plan.enable" : "plan.disable",
|
||||
targetType: "SubscriptionPlan",
|
||||
targetId: plan.id,
|
||||
targetLabel: plan.name,
|
||||
message: `${isActive ? "上架" : "下架"}套餐 ${plan.name}`,
|
||||
});
|
||||
revalidatePath("/admin/plans");
|
||||
}
|
||||
|
||||
export async function batchPlanOperation(formData: FormData) {
|
||||
const session = await requireAdmin();
|
||||
const action = String(formData.get("action") || "");
|
||||
const planIds = formData.getAll("planIds").map(String).filter(Boolean);
|
||||
|
||||
if (planIds.length === 0) {
|
||||
throw new Error("请至少选择一个套餐");
|
||||
}
|
||||
|
||||
if (action === "enable" || action === "disable") {
|
||||
const isActive = action === "enable";
|
||||
await prisma.subscriptionPlan.updateMany({
|
||||
where: { id: { in: planIds } },
|
||||
data: { isActive },
|
||||
});
|
||||
|
||||
await recordAuditLog({
|
||||
actor: actorFromSession(session),
|
||||
action: isActive ? "plan.batch_enable" : "plan.batch_disable",
|
||||
targetType: "SubscriptionPlan",
|
||||
message: `${isActive ? "批量上架" : "批量下架"} ${planIds.length} 个套餐`,
|
||||
metadata: { planIds },
|
||||
});
|
||||
} else if (action === "delete") {
|
||||
for (const planId of planIds) {
|
||||
await deletePlanPermanently(planId);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
throw new Error("不支持的批量操作");
|
||||
}
|
||||
|
||||
revalidatePath("/admin/plans");
|
||||
revalidatePath("/store");
|
||||
}
|
||||
Reference in New Issue
Block a user