Initial commit

This commit is contained in:
JetSprow
2026-04-29 05:12:39 +10:00
commit 27dbca9cbf
379 changed files with 43486 additions and 0 deletions

View File

@@ -0,0 +1,189 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import {
deleteAnnouncementNotifications,
dispatchAnnouncementNotifications,
syncAnnouncementNotifications,
} from "@/services/announcements";
const announcementSchema = z.object({
title: z.string().trim().min(1, "标题不能为空"),
body: z.string().trim().min(1, "内容不能为空"),
audience: z.enum(["PUBLIC", "USERS", "ADMINS", "SPECIFIC_USER"]),
displayType: z.enum(["INLINE", "BIG", "POPUP"]).default("INLINE"),
targetUserId: z.string().optional(),
dismissible: z.string().optional(),
sendNotification: z.string().optional(),
startAt: z.string().optional(),
endAt: z.string().optional(),
});
function revalidateAnnouncementViews() {
revalidatePath("/admin/announcements");
revalidatePath("/dashboard");
revalidatePath("/account");
revalidatePath("/notifications");
revalidatePath("/login");
}
function parseAnnouncementInput(formData: FormData) {
const data = announcementSchema.parse(Object.fromEntries(formData));
if (data.audience === "SPECIFIC_USER" && !data.targetUserId) {
throw new Error("定向消息必须选择用户");
}
const startAt = data.startAt ? new Date(data.startAt) : null;
const endAt = data.endAt ? new Date(data.endAt) : null;
if (startAt && Number.isNaN(startAt.getTime())) {
throw new Error("开始时间格式无效");
}
if (endAt && Number.isNaN(endAt.getTime())) {
throw new Error("结束时间格式无效");
}
if (startAt && endAt && endAt <= startAt) {
throw new Error("结束时间必须晚于开始时间");
}
return {
title: data.title,
body: data.body,
audience: data.audience,
displayType: data.displayType,
targetUserId: data.audience === "SPECIFIC_USER" ? data.targetUserId ?? null : null,
dismissible: data.dismissible === "true",
sendNotification: data.sendNotification === "true",
startAt,
endAt,
};
}
export async function createAnnouncement(formData: FormData) {
const session = await requireAdmin();
const data = parseAnnouncementInput(formData);
const announcement = await prisma.announcement.create({
data: {
title: data.title,
body: data.body,
audience: data.audience,
displayType: data.displayType,
targetUserId: data.targetUserId,
createdById: session.user.id,
isActive: true,
dismissible: data.dismissible,
sendNotification: data.sendNotification,
startAt: data.startAt,
endAt: data.endAt,
},
});
await dispatchAnnouncementNotifications(announcement.id);
await recordAuditLog({
actor: actorFromSession(session),
action: "announcement.create",
targetType: "Announcement",
targetId: announcement.id,
targetLabel: announcement.title,
message: `创建公告/消息 ${announcement.title}`,
});
revalidateAnnouncementViews();
}
export async function updateAnnouncement(id: string, formData: FormData) {
const session = await requireAdmin();
const data = parseAnnouncementInput(formData);
const announcement = await prisma.announcement.update({
where: { id },
data: {
title: data.title,
body: data.body,
audience: data.audience,
displayType: data.displayType,
targetUserId: data.targetUserId,
dismissible: data.dismissible,
sendNotification: data.sendNotification,
startAt: data.startAt,
endAt: data.endAt,
},
});
await syncAnnouncementNotifications(announcement.id);
await recordAuditLog({
actor: actorFromSession(session),
action: "announcement.update",
targetType: "Announcement",
targetId: announcement.id,
targetLabel: announcement.title,
message: `更新公告/消息 ${announcement.title}`,
});
revalidateAnnouncementViews();
}
export async function toggleAnnouncement(id: string, isActive: boolean) {
const session = await requireAdmin();
const announcement = await prisma.announcement.update({
where: { id },
data: { isActive },
});
await recordAuditLog({
actor: actorFromSession(session),
action: isActive ? "announcement.enable" : "announcement.disable",
targetType: "Announcement",
targetId: announcement.id,
targetLabel: announcement.title,
message: `${isActive ? "启用" : "停用"}公告/消息 ${announcement.title}`,
});
if (isActive) {
await dispatchAnnouncementNotifications(announcement.id);
}
revalidateAnnouncementViews();
}
export async function deleteAnnouncement(id: string) {
const session = await requireAdmin();
const announcement = await prisma.announcement.findUnique({
where: { id },
select: {
id: true,
title: true,
},
});
if (!announcement) {
throw new Error("公告不存在");
}
await prisma.$transaction(async (tx) => {
await deleteAnnouncementNotifications(announcement.id, tx);
await tx.announcement.delete({
where: {
id: announcement.id,
},
});
await recordAuditLog(
{
actor: actorFromSession(session),
action: "announcement.delete",
targetType: "Announcement",
targetId: announcement.id,
targetLabel: announcement.title,
message: `删除公告/消息 ${announcement.title}`,
},
tx,
);
});
revalidateAnnouncementViews();
}

View File

@@ -0,0 +1,34 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { restoreDatabaseBackupFile, restoreDatabaseBackupSql } from "@/services/database-backup";
export async function restoreDatabaseBackup(formData: FormData) {
const session = await requireAdmin();
const sqlText = String(formData.get("sqlText") || "").trim();
const file = formData.get("sqlFile");
const confirmation = String(formData.get("confirmation") || "");
if (confirmation !== "RESTORE") {
throw new Error("请输入 RESTORE 确认恢复操作");
}
if (file instanceof File && file.size > 0) {
await restoreDatabaseBackupFile(file);
} else if (sqlText) {
await restoreDatabaseBackupSql(sqlText);
} else {
throw new Error("请上传 SQL 备份文件或粘贴 SQL 内容");
}
await recordAuditLog({
actor: actorFromSession(session),
action: "backup.restore",
targetType: "Database",
message: "执行数据库恢复",
});
revalidatePath("/admin/backups");
}

View File

@@ -0,0 +1,127 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit";
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 couponSchema = z.object({
code: z.string().trim().min(2).transform((value) => value.toUpperCase()),
name: z.string().trim().min(1),
description: z.string().trim().optional(),
discountType: z.enum(["AMOUNT_OFF", "PERCENT_OFF"]),
discountValue: z.coerce.number().positive(),
thresholdAmount: optionalNumber,
maxDiscountAmount: optionalNumber,
totalLimit: optionalInt,
perUserLimit: optionalInt,
isPublic: z.string().optional(),
});
const promotionSchema = z.object({
name: z.string().trim().min(1),
thresholdAmount: z.coerce.number().positive(),
discountAmount: z.coerce.number().positive(),
sortOrder: z.coerce.number().int().default(100),
});
export async function createCoupon(formData: FormData) {
const session = await requireAdmin();
const data = couponSchema.parse(Object.fromEntries(formData));
if (data.discountType === "PERCENT_OFF" && data.discountValue > 100) {
throw new Error("折扣百分比不能超过 100");
}
const coupon = await prisma.coupon.create({
data: {
code: data.code,
name: data.name,
description: data.description || null,
discountType: data.discountType,
discountValue: data.discountValue,
thresholdAmount: data.thresholdAmount ?? null,
maxDiscountAmount: data.maxDiscountAmount ?? null,
totalLimit: data.totalLimit ?? null,
perUserLimit: data.perUserLimit ?? null,
isPublic: data.isPublic === "true",
},
});
await recordAuditLog({
actor: actorFromSession(session),
action: "coupon.create",
targetType: "Coupon",
targetId: coupon.id,
targetLabel: coupon.code,
message: `创建优惠券 ${coupon.code}`,
});
revalidateCommerce();
}
export async function toggleCoupon(id: string, isActive: boolean) {
const session = await requireAdmin();
const coupon = await prisma.coupon.update({ where: { id }, data: { isActive } });
await recordAuditLog({
actor: actorFromSession(session),
action: "coupon.toggle",
targetType: "Coupon",
targetId: id,
targetLabel: coupon.code,
message: `${isActive ? "启用" : "停用"}优惠券 ${coupon.code}`,
});
revalidateCommerce();
}
export async function createPromotionRule(formData: FormData) {
const session = await requireAdmin();
const data = promotionSchema.parse(Object.fromEntries(formData));
if (data.discountAmount >= data.thresholdAmount) {
throw new Error("满减金额应小于门槛金额");
}
const rule = await prisma.promotionRule.create({
data: {
name: data.name,
thresholdAmount: data.thresholdAmount,
discountAmount: data.discountAmount,
sortOrder: data.sortOrder,
},
});
await recordAuditLog({
actor: actorFromSession(session),
action: "promotion.create",
targetType: "PromotionRule",
targetId: rule.id,
targetLabel: rule.name,
message: `创建满减规则 ${rule.name}`,
});
revalidateCommerce();
}
export async function togglePromotionRule(id: string, isActive: boolean) {
const session = await requireAdmin();
const rule = await prisma.promotionRule.update({ where: { id }, data: { isActive } });
await recordAuditLog({
actor: actorFromSession(session),
action: "promotion.toggle",
targetType: "PromotionRule",
targetId: id,
targetLabel: rule.name,
message: `${isActive ? "启用" : "停用"}满减规则 ${rule.name}`,
});
revalidateCommerce();
}
function revalidateCommerce() {
revalidatePath("/admin/commerce");
revalidatePath("/admin/plans");
revalidatePath("/store");
revalidatePath("/cart");
}

245
src/actions/admin/nodes.ts Normal file
View File

@@ -0,0 +1,245 @@
"use server";
import crypto from "crypto";
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 { encrypt } from "@/lib/crypto";
import { testAndSyncNodeInbounds } from "@/services/node-panel/sync-inbounds";
const nodeSchema = z.object({
name: z.string().trim().optional(),
panelUrl: z.string().trim().min(1, "3x-ui 面板地址必填"),
panelUsername: z.string().trim().min(1, "3x-ui 用户名必填"),
panelPassword: z.string().trim().min(1, "3x-ui 密码必填"),
});
function normalizePanelUrl(raw: string): string {
try {
let value = raw.trim();
if (!/^https?:\/\//i.test(value)) {
value = `http://${value}`;
}
const url = new URL(value);
let pathname = url.pathname.replace(/\/+$/, "");
pathname = pathname.replace(/\/panel\/login$/i, "");
pathname = pathname.replace(/\/panel$/i, "");
pathname = pathname.replace(/\/login$/i, "");
return `${url.origin}${pathname}`;
} catch {
throw new Error("面板地址格式不正确,请填写 IP:端口 或 http://IP:端口");
}
}
function parseNodeData(formData: FormData) {
const raw = nodeSchema.parse(Object.fromEntries(formData));
const panelUrl = normalizePanelUrl(raw.panelUrl);
const panel = new URL(panelUrl);
const name = (raw.name || "").trim() || `节点-${panel.hostname}`;
return {
name,
panelUrl,
panelUsername: raw.panelUsername,
panelPassword: raw.panelPassword,
panelType: "3x-ui",
};
}
export async function createNode(formData: FormData) {
const session = await requireAdmin();
const data = parseNodeData(formData);
const node = await prisma.nodeServer.create({ data });
const result = await testAndSyncNodeInbounds(node);
await recordAuditLog({
actor: actorFromSession(session),
action: "node.create",
targetType: "NodeServer",
targetId: node.id,
targetLabel: node.name,
message: `创建 3x-ui 节点 ${node.name}`,
});
revalidatePath("/admin/nodes");
return {
...result,
message: result.success ? `节点创建成功,${result.message}` : `节点已创建,但${result.message}`,
};
}
export async function updateNode(id: string, formData: FormData) {
const session = await requireAdmin();
const data = parseNodeData(formData);
const node = await prisma.nodeServer.update({ where: { id }, data });
const result = await testAndSyncNodeInbounds(node);
await recordAuditLog({
actor: actorFromSession(session),
action: "node.update",
targetType: "NodeServer",
targetId: node.id,
targetLabel: node.name,
message: `更新 3x-ui 节点 ${node.name}`,
});
revalidatePath("/admin/nodes");
return {
...result,
message: result.success ? `节点已更新,${result.message}` : `节点已更新,但${result.message}`,
};
}
export async function deleteNode(id: string) {
const session = await requireAdmin();
const node = await prisma.nodeServer.delete({ where: { id } });
await recordAuditLog({
actor: actorFromSession(session),
action: "node.delete",
targetType: "NodeServer",
targetId: node.id,
targetLabel: node.name,
message: `删除节点 ${node.name}`,
});
revalidatePath("/admin/nodes");
}
export async function testNodeConnection(id: string) {
const session = await requireAdmin();
const server = await prisma.nodeServer.findUniqueOrThrow({ where: { id } });
const result = await testAndSyncNodeInbounds(server);
await recordAuditLog({
actor: actorFromSession(session),
action: "node.test",
targetType: "NodeServer",
targetId: server.id,
targetLabel: server.name,
message: `测试 3x-ui 节点 ${server.name}${result.message}`,
});
revalidatePath("/admin/nodes");
return result;
}
export async function batchTestNodeConnections(formData: FormData) {
const nodeIds = formData.getAll("nodeIds").map(String).filter(Boolean);
if (nodeIds.length === 0) {
throw new Error("请至少选择一个节点");
}
for (const nodeId of nodeIds) {
await testNodeConnection(nodeId);
}
}
function withInboundDisplayName(settings: unknown, displayName: string) {
const base = settings && typeof settings === "object" && !Array.isArray(settings)
? settings as Record<string, unknown>
: {};
return { ...base, displayName: displayName.trim() };
}
const inboundDisplayNameSchema = z.object({
displayName: z.string().trim().min(1, "前台名称不能为空").max(60, "前台名称不能超过 60 个字符"),
});
export async function updateInboundDisplayName(id: string, formData: FormData) {
const session = await requireAdmin();
const { displayName } = inboundDisplayNameSchema.parse(Object.fromEntries(formData));
const inbound = await prisma.nodeInbound.findUniqueOrThrow({
where: { id },
include: { server: { select: { name: true } } },
});
await prisma.nodeInbound.update({
where: { id },
data: { settings: withInboundDisplayName(inbound.settings, displayName) },
});
await recordAuditLog({
actor: actorFromSession(session),
action: "inbound.display_name.update",
targetType: "NodeInbound",
targetId: inbound.id,
targetLabel: displayName,
message: `更新节点 ${inbound.server.name} 的前台线路名称`,
});
revalidatePath("/admin/nodes");
revalidatePath("/store");
}
export async function deleteInbound(id: string) {
const session = await requireAdmin();
const inbound = await prisma.nodeInbound.findUniqueOrThrow({
where: { id },
include: { server: true },
});
await prisma.nodeInbound.delete({ where: { id } });
await recordAuditLog({
actor: actorFromSession(session),
action: "inbound.delete",
targetType: "NodeInbound",
targetId: inbound.id,
targetLabel: `${inbound.protocol}:${inbound.port}`,
message: `从本地移除节点 ${inbound.server.name} 的入站 ${inbound.protocol}:${inbound.port}`,
});
revalidatePath("/admin/nodes");
}
export async function generateAgentToken(nodeId: string) {
const session = await requireAdmin();
const node = await prisma.nodeServer.findUniqueOrThrow({
where: { id: nodeId },
select: { id: true, name: true },
});
const plainToken = crypto.randomBytes(32).toString("hex");
const encrypted = encrypt(plainToken);
await prisma.nodeServer.update({
where: { id: nodeId },
data: { agentToken: encrypted },
});
await recordAuditLog({
actor: actorFromSession(session),
action: "node.probe_token.generate",
targetType: "NodeServer",
targetId: node.id,
targetLabel: node.name,
message: `为节点 ${node.name} 生成探测 Token`,
});
revalidatePath("/admin/nodes");
return plainToken;
}
export async function revokeAgentToken(nodeId: string) {
const session = await requireAdmin();
const node = await prisma.nodeServer.findUniqueOrThrow({
where: { id: nodeId },
select: { id: true, name: true },
});
await prisma.nodeServer.update({
where: { id: nodeId },
data: { agentToken: null },
});
await recordAuditLog({
actor: actorFromSession(session),
action: "node.probe_token.revoke",
targetType: "NodeServer",
targetId: node.id,
targetLabel: node.name,
message: `撤销节点 ${node.name} 的探测 Token`,
});
revalidatePath("/admin/nodes");
}

View File

@@ -0,0 +1,92 @@
"use server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { revalidatePath } from "next/cache";
import { confirmPendingOrder } from "@/services/payment/process";
import { actorFromSession, recordAuditLog } from "@/services/audit";
export async function confirmOrder(orderId: string) {
const session = await requireAdmin();
const order = await prisma.order.findUniqueOrThrow({
where: { id: orderId },
select: { status: true, id: true },
});
if (order.status !== "PENDING") {
throw new Error("订单状态不正确");
}
const result = await confirmPendingOrder(orderId);
if (result.finalStatus !== "PAID") {
throw new Error(result.errorMessage ?? "订单处理失败");
}
await recordAuditLog({
actor: actorFromSession(session),
action: "order.confirm",
targetType: "Order",
targetId: order.id,
targetLabel: order.id,
message: `确认订单 ${order.id}`,
});
revalidatePath("/admin/orders");
}
export async function cancelOrder(orderId: string) {
const session = await requireAdmin();
await prisma.order.update({ where: { id: orderId }, data: { status: "CANCELLED" } });
await recordAuditLog({
actor: actorFromSession(session),
action: "order.cancel",
targetType: "Order",
targetId: orderId,
targetLabel: orderId,
message: `取消订单 ${orderId}`,
});
revalidatePath("/admin/orders");
}
export async function updateOrderReview(
orderId: string,
reviewStatus: "NORMAL" | "FLAGGED" | "RESOLVED",
reviewNote?: string,
) {
const session = await requireAdmin();
const order = await prisma.order.update({
where: { id: orderId },
data: {
reviewStatus,
reviewNote: reviewNote?.trim() || null,
},
});
await recordAuditLog({
actor: actorFromSession(session),
action: "order.review",
targetType: "Order",
targetId: order.id,
targetLabel: order.id,
message: `将订单 ${order.id} 标记为 ${reviewStatus}`,
});
revalidatePath("/admin/orders");
}
export async function batchOrderOperation(formData: FormData) {
const action = String(formData.get("action") || "");
const orderIds = formData.getAll("orderIds").map(String).filter(Boolean);
if (orderIds.length === 0) {
throw new Error("请至少选择一个订单");
}
for (const orderId of orderIds) {
if (action === "confirm") {
await confirmOrder(orderId);
} else if (action === "cancel") {
await cancelOrder(orderId);
} else {
throw new Error("不支持的批量操作");
}
}
}

View File

@@ -0,0 +1,51 @@
"use server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { revalidatePath } from "next/cache";
import {
normalizePaymentConfig,
parsePaymentConfig,
} from "@/services/payment/catalog";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { z } from "zod";
export async function savePaymentConfig(
provider: string,
config: Record<string, string>,
enabled: boolean
) {
const session = await requireAdmin();
const normalizedConfig = normalizePaymentConfig(config);
let finalConfig = normalizedConfig as Record<string, string | number>;
if (enabled) {
try {
finalConfig = parsePaymentConfig(provider, normalizedConfig) as Record<string, string | number>;
} catch (error) {
if (error instanceof z.ZodError) {
const messages = error.issues.map((e) => e.message).join("");
throw new Error(messages);
}
throw error;
}
}
const jsonConfig = JSON.parse(JSON.stringify(finalConfig));
await prisma.paymentConfig.upsert({
where: { provider },
create: { provider, config: jsonConfig, enabled },
update: { config: jsonConfig, enabled },
});
await recordAuditLog({
actor: actorFromSession(session),
action: "payment.config",
targetType: "PaymentConfig",
targetId: provider,
targetLabel: provider,
message: `${enabled ? "启用并更新" : "更新"}支付配置 ${provider}`,
});
revalidatePath("/admin/payments");
}

689
src/actions/admin/plans.ts Normal file
View 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");
}

View File

@@ -0,0 +1,153 @@
"use server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { encrypt } from "@/lib/crypto";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { createNotification } from "@/services/notifications";
const serviceSchema = z.object({
name: z.string().min(1),
credentials: z.string().min(1),
maxSlots: z.coerce.number().int().positive(),
description: z.string().optional(),
});
export async function createService(formData: FormData) {
const session = await requireAdmin();
const data = serviceSchema.parse(Object.fromEntries(formData));
const service = await prisma.streamingService.create({
data: {
name: data.name,
credentials: encrypt(data.credentials),
maxSlots: data.maxSlots,
description: data.description || null,
},
});
await recordAuditLog({
actor: actorFromSession(session),
action: "service.create",
targetType: "StreamingService",
targetId: service.id,
targetLabel: service.name,
message: `创建流媒体服务 ${service.name}`,
});
revalidatePath("/admin/services");
}
export async function updateService(id: string, formData: FormData) {
const session = await requireAdmin();
const data = serviceSchema.parse(Object.fromEntries(formData));
const affectedUsers = await prisma.streamingSlot.findMany({
where: { serviceId: id },
select: { userId: true },
distinct: ["userId"],
});
const service = await prisma.streamingService.update({
where: { id },
data: {
name: data.name,
credentials: encrypt(data.credentials),
maxSlots: data.maxSlots,
description: data.description || null,
},
});
for (const row of affectedUsers) {
await createNotification({
userId: row.userId,
type: "SYSTEM",
level: "INFO",
title: "流媒体凭据已更新",
body: `${service.name} 的共享凭据已更新,请重新查看最新账号信息。`,
link: "/subscriptions",
dedupeKey: `service-credential-update:${service.id}:${row.userId}:${Date.now()}`,
});
}
await recordAuditLog({
actor: actorFromSession(session),
action: "service.update",
targetType: "StreamingService",
targetId: service.id,
targetLabel: service.name,
message: `更新流媒体服务 ${service.name}`,
});
revalidatePath("/admin/services");
}
export async function deleteService(id: string) {
const session = await requireAdmin();
const service = await prisma.streamingService.findUniqueOrThrow({
where: { id },
include: { _count: { select: { slots: true } } },
});
if (service._count.slots > 0) {
throw new Error(`该服务仍有 ${service._count.slots} 个关联槽位,请先清理后再删除`);
}
await prisma.streamingService.delete({ where: { id } });
await recordAuditLog({
actor: actorFromSession(session),
action: "service.delete",
targetType: "StreamingService",
targetId: service.id,
targetLabel: service.name,
message: `彻底删除流媒体服务 ${service.name}`,
});
revalidatePath("/admin/services");
revalidatePath("/store");
}
export async function toggleServiceStatus(id: string, isActive: boolean) {
const session = await requireAdmin();
const service = await prisma.streamingService.update({
where: { id },
data: { isActive },
});
await recordAuditLog({
actor: actorFromSession(session),
action: isActive ? "service.enable" : "service.disable",
targetType: "StreamingService",
targetId: service.id,
targetLabel: service.name,
message: `${isActive ? "启用" : "停用"}流媒体服务 ${service.name}`,
});
revalidatePath("/admin/services");
revalidatePath("/store");
}
export async function batchToggleServiceStatus(formData: FormData) {
const session = await requireAdmin();
const isActive = String(formData.get("isActive")) === "true";
const serviceIds = formData.getAll("serviceIds").map(String).filter(Boolean);
if (serviceIds.length === 0) {
throw new Error("请至少选择一个服务");
}
const services = await prisma.streamingService.findMany({
where: { id: { in: serviceIds } },
select: { id: true, name: true },
});
await prisma.streamingService.updateMany({
where: { id: { in: serviceIds } },
data: { isActive },
});
await recordAuditLog({
actor: actorFromSession(session),
action: isActive ? "service.batch_enable" : "service.batch_disable",
targetType: "StreamingService",
message: `${isActive ? "批量启用" : "批量停用"} ${services.length} 个流媒体服务`,
metadata: {
serviceIds,
},
});
revalidatePath("/admin/services");
revalidatePath("/store");
}

View File

@@ -0,0 +1,79 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { getAppConfig } from "@/services/app-config";
import { normalizeSiteUrl } from "@/services/site-url";
const settingsSchema = z.object({
siteName: z.string().trim().min(1, "站点名称不能为空"),
siteUrl: z.string().trim().optional(),
supportContact: z.string().trim().optional(),
maintenanceNotice: z.string().trim().optional(),
siteNotice: z.string().trim().optional(),
allowRegistration: z.string().optional(),
requireInviteCode: z.string().optional(),
autoReminderDispatchEnabled: z.string().optional(),
reminderDispatchIntervalMinutes: z.coerce.number().int().positive().optional(),
trafficSyncEnabled: z.string().optional(),
trafficSyncIntervalSeconds: z.coerce.number().int().min(10).optional(),
inviteRewardEnabled: z.string().optional(),
inviteRewardRate: z.coerce.number().min(0).max(100).optional(),
inviteRewardCouponId: z.string().trim().optional(),
turnstileSiteKey: z.string().trim().optional(),
turnstileSecretKey: z.string().trim().optional(),
});
export async function saveAppSettings(formData: FormData) {
const session = await requireAdmin();
const parsed = settingsSchema.parse(Object.fromEntries(formData));
const current = await getAppConfig();
const next = {
siteName: parsed.siteName,
siteUrl: normalizeSiteUrl(parsed.siteUrl) || null,
supportContact: parsed.supportContact || null,
maintenanceNotice: parsed.maintenanceNotice || null,
siteNotice: parsed.siteNotice || null,
allowRegistration: parsed.allowRegistration === "true",
requireInviteCode: parsed.requireInviteCode === "true",
autoReminderDispatchEnabled: parsed.autoReminderDispatchEnabled === "true",
reminderDispatchIntervalMinutes:
parsed.reminderDispatchIntervalMinutes ?? current.reminderDispatchIntervalMinutes,
trafficSyncEnabled: parsed.trafficSyncEnabled === "true",
trafficSyncIntervalSeconds:
parsed.trafficSyncIntervalSeconds ?? current.trafficSyncIntervalSeconds,
inviteRewardEnabled: parsed.inviteRewardEnabled === "true",
inviteRewardRate: parsed.inviteRewardRate ?? Number(current.inviteRewardRate),
inviteRewardCouponId: parsed.inviteRewardCouponId || null,
turnstileSiteKey: parsed.turnstileSiteKey || null,
turnstileSecretKey: parsed.turnstileSecretKey || null,
};
await prisma.appConfig.upsert({
where: { id: current.id },
create: { id: current.id, ...next },
update: next,
});
await recordAuditLog({
actor: actorFromSession(session),
action: "settings.update",
targetType: "AppConfig",
targetId: current.id,
targetLabel: next.siteName,
message: "更新系统设置",
});
revalidatePath("/admin/settings");
revalidatePath("/login");
revalidatePath("/register");
revalidatePath("/dashboard");
revalidatePath("/subscriptions");
revalidatePath("/admin/nodes");
revalidatePath("/account");
revalidatePath("/admin/commerce");
}

View File

@@ -0,0 +1,452 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { createNotification } from "@/services/notifications";
import { createPanelAdapter } from "@/services/node-panel/factory";
async function setProxyClientEnabled(subscriptionId: string, enable: boolean) {
const client = await prisma.nodeClient.findUnique({
where: { subscriptionId },
select: {
id: true,
uuid: true,
inbound: {
select: {
serverId: true,
panelInboundId: true,
server: true,
},
},
},
});
if (!client) {
return null;
}
if (client.inbound.panelInboundId == null) {
throw new Error("3x-ui 入站 ID 缺失,请重新同步节点入站");
}
const adapter = createPanelAdapter(client.inbound.server);
await adapter.login();
await adapter.updateClientEnable(client.inbound.panelInboundId, client.uuid, enable);
await prisma.nodeClient.update({
where: { id: client.id },
data: { isEnabled: enable },
});
return client.inbound.serverId;
}
async function hardDeleteProxyClient(subscriptionId: string) {
const client = await prisma.nodeClient.findUnique({
where: { subscriptionId },
select: {
id: true,
uuid: true,
inbound: {
select: {
serverId: true,
panelInboundId: true,
server: true,
},
},
},
});
if (!client) {
return null;
}
if (client.inbound.panelInboundId == null) {
throw new Error("3x-ui 入站 ID 缺失,请重新同步节点入站");
}
const adapter = createPanelAdapter(client.inbound.server);
await adapter.login();
await adapter.deleteClient(client.inbound.panelInboundId, client.uuid);
await prisma.nodeClient.delete({
where: { id: client.id },
});
return client.inbound.serverId;
}
async function hardDeleteSubscriptionInternal(
subscriptionId: string,
options: {
actor: ReturnType<typeof actorFromSession>;
revalidate?: boolean;
},
) {
const subscription = await prisma.userSubscription.findUniqueOrThrow({
where: { id: subscriptionId },
include: {
plan: true,
user: true,
streamingSlot: true,
},
});
if (subscription.plan.type === "PROXY") {
await hardDeleteProxyClient(subscription.id);
}
await prisma.$transaction(async (tx) => {
if (subscription.streamingSlot) {
await tx.streamingSlot.delete({
where: { id: subscription.streamingSlot.id },
});
await tx.streamingService.updateMany({
where: {
id: subscription.streamingSlot.serviceId,
usedSlots: { gt: 0 },
},
data: {
usedSlots: { decrement: 1 },
},
});
}
await tx.order.deleteMany({
where: {
OR: [
{ targetSubscriptionId: subscription.id },
{ subscriptionId: subscription.id },
],
},
});
await tx.userSubscription.delete({
where: { id: subscription.id },
});
});
await createNotification({
userId: subscription.userId,
type: "SUBSCRIPTION",
level: "WARNING",
title: "订阅已被删除",
body: `${subscription.plan.name} 已被管理员彻底删除。`,
link: "/subscriptions",
});
await recordAuditLog({
actor: options.actor,
action: "subscription.delete",
targetType: "UserSubscription",
targetId: subscription.id,
targetLabel: `${subscription.user.email} / ${subscription.plan.name}`,
message: `彻底删除订阅 ${subscription.plan.name}`,
});
if (options.revalidate !== false) {
revalidateSubscriptionViews();
}
}
function revalidateSubscriptionViews() {
revalidatePath("/admin/subscriptions");
revalidatePath("/admin/traffic");
revalidatePath("/subscriptions");
revalidatePath("/dashboard");
revalidatePath("/notifications");
}
export async function suspendSubscription(subscriptionId: string) {
const session = await requireAdmin();
const subscription = await prisma.userSubscription.findUniqueOrThrow({
where: { id: subscriptionId },
include: {
plan: true,
user: true,
},
});
if (subscription.status !== "ACTIVE") {
throw new Error("仅活跃订阅可暂停");
}
if (subscription.plan.type === "PROXY") {
await setProxyClientEnabled(subscription.id, false);
}
await prisma.userSubscription.update({
where: { id: subscription.id },
data: { status: "SUSPENDED" },
});
await createNotification({
userId: subscription.userId,
type: "SUBSCRIPTION",
level: "WARNING",
title: "订阅已暂停",
body: `${subscription.plan.name} 已被管理员暂停,如有疑问请联系管理员。`,
link: "/subscriptions",
});
await recordAuditLog({
actor: actorFromSession(session),
action: "subscription.suspend",
targetType: "UserSubscription",
targetId: subscription.id,
targetLabel: `${subscription.user.email} / ${subscription.plan.name}`,
message: `暂停订阅 ${subscription.plan.name}`,
});
revalidateSubscriptionViews();
}
export async function activateSubscription(subscriptionId: string) {
const session = await requireAdmin();
const subscription = await prisma.userSubscription.findUniqueOrThrow({
where: { id: subscriptionId },
include: {
plan: true,
user: true,
},
});
if (subscription.status !== "SUSPENDED") {
throw new Error("仅已暂停订阅可恢复");
}
if (subscription.endDate <= new Date()) {
throw new Error("订阅已过期,无法恢复");
}
if (subscription.plan.type === "PROXY") {
await setProxyClientEnabled(subscription.id, true);
}
await prisma.userSubscription.update({
where: { id: subscription.id },
data: { status: "ACTIVE" },
});
await createNotification({
userId: subscription.userId,
type: "SUBSCRIPTION",
level: "SUCCESS",
title: "订阅已恢复",
body: `${subscription.plan.name} 已恢复为可用状态。`,
link: "/subscriptions",
});
await recordAuditLog({
actor: actorFromSession(session),
action: "subscription.activate",
targetType: "UserSubscription",
targetId: subscription.id,
targetLabel: `${subscription.user.email} / ${subscription.plan.name}`,
message: `恢复订阅 ${subscription.plan.name}`,
});
revalidateSubscriptionViews();
}
export async function cancelSubscription(subscriptionId: string) {
const session = await requireAdmin();
const subscription = await prisma.userSubscription.findUniqueOrThrow({
where: { id: subscriptionId },
include: {
plan: true,
user: true,
streamingSlot: true,
},
});
if (subscription.status === "CANCELLED") {
throw new Error("订阅已取消");
}
if (subscription.plan.type === "PROXY") {
await setProxyClientEnabled(subscription.id, false);
}
await prisma.$transaction(async (tx) => {
if (subscription.streamingSlot) {
await tx.streamingSlot.delete({
where: { id: subscription.streamingSlot.id },
});
await tx.streamingService.update({
where: { id: subscription.streamingSlot.serviceId },
data: {
usedSlots: {
decrement: 1,
},
},
});
}
await tx.userSubscription.update({
where: { id: subscription.id },
data: { status: "CANCELLED" },
});
});
await createNotification({
userId: subscription.userId,
type: "SUBSCRIPTION",
level: "WARNING",
title: "订阅已取消",
body: `${subscription.plan.name} 已被管理员取消。`,
link: "/subscriptions",
});
await recordAuditLog({
actor: actorFromSession(session),
action: "subscription.cancel",
targetType: "UserSubscription",
targetId: subscription.id,
targetLabel: `${subscription.user.email} / ${subscription.plan.name}`,
message: `取消订阅 ${subscription.plan.name}`,
});
revalidateSubscriptionViews();
}
export async function deleteSubscriptionPermanently(subscriptionId: string) {
const session = await requireAdmin();
await hardDeleteSubscriptionInternal(subscriptionId, {
actor: actorFromSession(session),
});
}
export async function reassignStreamingSlot(
subscriptionId: string,
targetServiceId: string,
) {
const session = await requireAdmin();
const subscription = await prisma.userSubscription.findUniqueOrThrow({
where: { id: subscriptionId },
include: {
user: true,
plan: true,
streamingSlot: {
include: {
service: true,
},
},
},
});
if (subscription.plan.type !== "STREAMING") {
throw new Error("仅流媒体订阅支持调配槽位");
}
if (subscription.status === "CANCELLED" || subscription.status === "EXPIRED") {
throw new Error("当前订阅状态不支持调配槽位");
}
const targetService = await prisma.streamingService.findUniqueOrThrow({
where: { id: targetServiceId },
select: {
id: true,
name: true,
isActive: true,
usedSlots: true,
maxSlots: true,
},
});
if (!targetService.isActive) {
throw new Error("目标流媒体服务未启用");
}
if (subscription.streamingSlot?.serviceId === targetService.id) {
throw new Error("已在当前服务上,无需重复调配");
}
if (targetService.usedSlots >= targetService.maxSlots) {
throw new Error("目标流媒体服务已满");
}
await prisma.$transaction(async (tx) => {
if (subscription.streamingSlot) {
await tx.streamingSlot.update({
where: { id: subscription.streamingSlot.id },
data: {
serviceId: targetService.id,
assignedAt: new Date(),
},
});
await tx.streamingService.updateMany({
where: {
id: subscription.streamingSlot.serviceId,
usedSlots: { gt: 0 },
},
data: {
usedSlots: { decrement: 1 },
},
});
await tx.streamingService.update({
where: { id: targetService.id },
data: {
usedSlots: { increment: 1 },
},
});
} else {
await tx.streamingSlot.create({
data: {
subscriptionId: subscription.id,
userId: subscription.userId,
serviceId: targetService.id,
},
});
await tx.streamingService.update({
where: { id: targetService.id },
data: {
usedSlots: { increment: 1 },
},
});
}
});
await createNotification({
userId: subscription.userId,
type: "SUBSCRIPTION",
level: "INFO",
title: "流媒体服务已调整",
body: `${subscription.plan.name} 已调整到服务 ${targetService.name}`,
link: "/subscriptions",
});
await recordAuditLog({
actor: actorFromSession(session),
action: "streaming-slot.reassign",
targetType: "StreamingSlot",
targetId: subscription.streamingSlot?.id ?? subscription.id,
targetLabel: `${subscription.user.email} / ${subscription.plan.name}`,
message: `将流媒体订阅 ${subscription.plan.name} 调配到 ${targetService.name}`,
});
revalidateSubscriptionViews();
revalidatePath("/admin/services");
}
export async function batchSubscriptionOperation(formData: FormData) {
const action = String(formData.get("action") || "");
const subscriptionIds = formData.getAll("subscriptionIds").map(String).filter(Boolean);
if (subscriptionIds.length === 0) {
throw new Error("请至少选择一个订阅");
}
for (const subscriptionId of subscriptionIds) {
if (action === "suspend") {
await suspendSubscription(subscriptionId);
} else if (action === "activate") {
await activateSubscription(subscriptionId);
} else if (action === "cancel") {
await cancelSubscription(subscriptionId);
} else if (action === "delete") {
await deleteSubscriptionPermanently(subscriptionId);
} else {
throw new Error("不支持的批量操作");
}
}
}

View File

@@ -0,0 +1,158 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { createNotification } from "@/services/notifications";
import {
createSupportAttachments,
deleteSupportTicketRecords,
parseSupportAttachments,
} from "@/services/support";
const replySchema = z.object({
body: z.string().trim().min(1, "回复内容不能为空"),
});
const supportStatusSchema = z.enum(["OPEN", "USER_REPLIED", "ADMIN_REPLIED", "CLOSED"]);
const supportPrioritySchema = z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]);
export async function replySupportAsAdmin(ticketId: string, formData: FormData) {
const session = await requireAdmin();
const data = replySchema.parse(Object.fromEntries(formData));
const attachments = parseSupportAttachments(formData.getAll("attachments"));
const ticket = await prisma.supportTicket.findUniqueOrThrow({
where: { id: ticketId },
include: {
user: {
select: { id: true, email: true },
},
},
});
if (ticket.status === "CLOSED") {
throw new Error("已关闭的工单不能继续回复,请先重新打开");
}
const updated = await prisma.supportTicket.update({
where: { id: ticket.id },
data: {
status: "ADMIN_REPLIED",
closedAt: null,
lastReplyAt: new Date(),
replies: {
create: {
authorUserId: session.user.id,
isAdmin: true,
body: data.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,
});
}
await createNotification({
userId: ticket.user.id,
type: "SYSTEM",
level: "INFO",
title: "工单有新回复",
body: `管理员已回复工单「${ticket.subject}」。`,
link: `/support/${ticket.id}`,
});
await recordAuditLog({
actor: actorFromSession(session),
action: "support.reply",
targetType: "SupportTicket",
targetId: ticket.id,
targetLabel: ticket.subject,
message: `回复工单 ${ticket.subject}`,
});
revalidatePath("/admin/support");
revalidatePath(`/admin/support/${ticket.id}`);
revalidatePath(`/support/${ticket.id}`);
}
export async function updateSupportTicketMeta(formData: FormData) {
const session = await requireAdmin();
const ticketId = String(formData.get("ticketId") || "");
const status = supportStatusSchema.parse(String(formData.get("status") || ""));
const priority = supportPrioritySchema.parse(String(formData.get("priority") || ""));
if (!ticketId) {
throw new Error("工单 ID 缺失");
}
const ticket = await prisma.supportTicket.update({
where: { id: ticketId },
data: {
status,
priority,
closedAt: status === "CLOSED" ? new Date() : null,
},
});
await recordAuditLog({
actor: actorFromSession(session),
action: "support.update",
targetType: "SupportTicket",
targetId: ticket.id,
targetLabel: ticket.subject,
message: `更新工单 ${ticket.subject} 状态/优先级`,
});
revalidatePath("/admin/support");
revalidatePath(`/admin/support/${ticket.id}`);
revalidatePath(`/support/${ticket.id}`);
}
export async function deleteSupportTicketAsAdmin(ticketId: string) {
const session = await requireAdmin();
const ticket = await prisma.supportTicket.findUnique({
where: { id: ticketId },
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("/admin/support");
revalidatePath(`/admin/support/${ticket.id}`);
revalidatePath("/support");
revalidatePath(`/support/${ticket.id}`);
revalidatePath("/notifications");
}

110
src/actions/admin/tasks.ts Normal file
View File

@@ -0,0 +1,110 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { dispatchSubscriptionReminders } from "@/services/notifications";
import { confirmPendingOrder } from "@/services/payment/process";
import { runTask, updateTaskRun } from "@/services/task-center";
import { prisma } from "@/lib/prisma";
function revalidateTaskViews() {
revalidatePath("/admin/tasks");
revalidatePath("/admin/health");
revalidatePath("/admin/traffic");
revalidatePath("/admin/audit-logs");
revalidatePath("/notifications");
}
export async function runReminderTask() {
const session = await requireAdmin();
const actor = actorFromSession(session);
const outcome = await runTask(
{
kind: "REMINDER_DISPATCH",
title: "手动派发提醒任务",
triggeredById: session.user.id,
},
async () => {
await dispatchSubscriptionReminders();
return { ok: true };
},
);
await recordAuditLog({
actor,
action: "task.run",
targetType: "TaskRun",
targetId: outcome.taskId,
targetLabel: "REMINDER_DISPATCH",
message: "手动执行提醒派发任务",
});
revalidateTaskViews();
}
export async function retryTaskRun(taskId: string) {
const session = await requireAdmin();
const actor = actorFromSession(session);
const task = await prisma.taskRun.findUniqueOrThrow({
where: { id: taskId },
});
await updateTaskRun(task.id, {
status: "RUNNING",
errorMessage: null,
startedAt: new Date(),
retryCountIncrement: true,
});
try {
let result: unknown = { ok: true };
if (task.kind === "ORDER_PROVISION_RETRY") {
const orderId = (task.payload as { orderId?: string } | null)?.orderId;
if (!orderId) {
throw new Error("任务缺少订单 ID");
}
result = await confirmPendingOrder(orderId);
} else if (task.kind === "REMINDER_DISPATCH") {
await dispatchSubscriptionReminders();
}
await updateTaskRun(task.id, {
status: "SUCCESS",
finishedAt: new Date(),
result: result as never,
});
await recordAuditLog({
actor,
action: "task.retry",
targetType: "TaskRun",
targetId: task.id,
targetLabel: task.kind,
message: `重试任务 ${task.title}`,
});
} catch (error) {
await updateTaskRun(task.id, {
status: "FAILED",
finishedAt: new Date(),
errorMessage: error instanceof Error ? error.message : "重试失败",
});
throw error;
}
revalidateTaskViews();
}
export async function batchRetryTaskRuns(formData: FormData) {
const taskIds = formData.getAll("taskIds").map(String).filter(Boolean);
if (taskIds.length === 0) {
throw new Error("请至少选择一个任务");
}
for (const taskId of taskIds) {
await retryTaskRun(taskId);
}
}

View File

@@ -0,0 +1,35 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import { syncNodeClientTraffic } from "@/services/traffic-sync";
export async function syncTrafficViews() {
const session = await requireAdmin();
const result = await syncNodeClientTraffic({ maxAgeMs: 0 });
await recordAuditLog({
actor: actorFromSession(session),
action: "traffic.sync",
targetType: "TrafficSync",
message: `同步 3x-ui 流量:成功 ${result.synced} 个,失败 ${result.failed}`,
metadata: {
scanned: result.scanned,
synced: result.synced,
skipped: result.skipped,
failed: result.failed,
uploadDelta: result.uploadDelta,
downloadDelta: result.downloadDelta,
errors: result.errors,
},
});
revalidatePath("/admin/traffic");
revalidatePath("/dashboard");
revalidatePath("/subscriptions");
revalidatePath("/notifications");
return result;
}
export async function revalidateTrafficViews() {
return syncTrafficViews();
}

134
src/actions/admin/users.ts Normal file
View File

@@ -0,0 +1,134 @@
"use server";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { revalidatePath } from "next/cache";
import bcrypt from "bcryptjs";
import { z } from "zod";
import { actorFromSession, recordAuditLog } from "@/services/audit";
const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
name: z.string().optional(),
role: z.enum(["ADMIN", "USER"]).default("USER"),
});
const updateUserSchema = z.object({
email: z.string().email(),
password: z.string().optional(),
name: z.string().optional(),
role: z.enum(["ADMIN", "USER"]),
});
export async function createUser(formData: FormData) {
const session = await requireAdmin();
const data = createUserSchema.parse(Object.fromEntries(formData));
const hashed = await bcrypt.hash(data.password, 12);
const user = await prisma.user.create({
data: { email: data.email, password: hashed, name: data.name || null, role: data.role },
});
await recordAuditLog({
actor: actorFromSession(session),
action: "user.create",
targetType: "User",
targetId: user.id,
targetLabel: user.email,
message: `创建用户 ${user.email}`,
});
revalidatePath("/admin/users");
}
export async function updateUser(id: string, formData: FormData) {
const session = await requireAdmin();
const data = updateUserSchema.parse(Object.fromEntries(formData));
const updateData: {
email: string;
name: string | null;
role: "ADMIN" | "USER";
password?: string;
} = {
email: data.email,
name: data.name || null,
role: data.role,
};
if (data.password && data.password.trim()) {
updateData.password = await bcrypt.hash(data.password.trim(), 12);
}
const user = await prisma.user.update({
where: { id },
data: updateData,
});
await recordAuditLog({
actor: actorFromSession(session),
action: "user.update",
targetType: "User",
targetId: user.id,
targetLabel: user.email,
message: `更新用户 ${user.email}`,
});
revalidatePath("/admin/users");
}
export async function updateUserStatus(id: string, status: "ACTIVE" | "DISABLED" | "BANNED") {
const session = await requireAdmin();
const user = await prisma.user.update({ where: { id }, data: { status } });
await recordAuditLog({
actor: actorFromSession(session),
action: "user.status",
targetType: "User",
targetId: user.id,
targetLabel: user.email,
message: `将用户 ${user.email} 状态改为 ${status}`,
});
revalidatePath("/admin/users");
}
export async function deleteUser(id: string) {
const session = await requireAdmin();
const user = await prisma.user.delete({ where: { id } });
await recordAuditLog({
actor: actorFromSession(session),
action: "user.delete",
targetType: "User",
targetId: user.id,
targetLabel: user.email,
message: `删除用户 ${user.email}`,
});
revalidatePath("/admin/users");
}
export async function batchUpdateUserStatus(formData: FormData) {
const session = await requireAdmin();
const status = formData.get("status");
const userIds = formData.getAll("userIds").map(String).filter(Boolean);
if (!status || !["ACTIVE", "DISABLED", "BANNED"].includes(String(status))) {
throw new Error("批量状态无效");
}
if (userIds.length === 0) {
throw new Error("请至少选择一个用户");
}
await prisma.user.updateMany({
where: { id: { in: userIds } },
data: { status: status as "ACTIVE" | "DISABLED" | "BANNED" },
});
await recordAuditLog({
actor: actorFromSession(session),
action: "user.batch_status",
targetType: "User",
message: `批量更新 ${userIds.length} 个用户状态为 ${status}`,
metadata: {
userIds,
status: String(status),
},
});
revalidatePath("/admin/users");
}