mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
583 lines
15 KiB
TypeScript
583 lines
15 KiB
TypeScript
import { prisma, type DbClient } from "@/lib/prisma";
|
|
import { bytesToGb, gbToBytes } from "@/lib/utils";
|
|
import { addDays } from "date-fns";
|
|
import {
|
|
ensurePlanTrafficPoolCapacityByBytes,
|
|
getPlanTrafficPoolState,
|
|
} from "@/services/plan-traffic-pool";
|
|
import { recordAuditLog } from "@/services/audit";
|
|
import { createNotification } from "@/services/notifications";
|
|
import { generateNodeClientCredential } from "@/services/node-client-credential";
|
|
import { createPanelAdapter } from "@/services/node-panel/factory";
|
|
import type {
|
|
NodeServer,
|
|
Order,
|
|
Protocol,
|
|
OrderItem,
|
|
SubscriptionPlan,
|
|
User,
|
|
} from "@prisma/client";
|
|
|
|
|
|
type PaidOrder = Order & { plan: SubscriptionPlan; user: User };
|
|
type NewOrderItem = Pick<OrderItem, "planId" | "selectedInboundId" | "trafficGb"> & {
|
|
plan: SubscriptionPlan;
|
|
};
|
|
|
|
export async function provisionSubscription(order: PaidOrder): Promise<string[]> {
|
|
return provisionSubscriptionWithDb(order, prisma);
|
|
}
|
|
|
|
export async function provisionSubscriptionWithDb(
|
|
order: PaidOrder,
|
|
db: DbClient,
|
|
): Promise<string[]> {
|
|
if (order.kind === "NEW_PURCHASE") {
|
|
return provisionNewSubscription(order, db);
|
|
}
|
|
if (order.kind === "RENEWAL") {
|
|
return applyRenewal(order, db);
|
|
}
|
|
if (order.kind === "TRAFFIC_TOPUP") {
|
|
return applyTrafficTopup(order, db);
|
|
}
|
|
|
|
throw new Error(`开通订阅失败:不支持的订单类型 ${String(order.kind)}`);
|
|
}
|
|
|
|
async function getNewPurchaseItems(order: PaidOrder, db: DbClient): Promise<NewOrderItem[]> {
|
|
const items = await db.orderItem.findMany({
|
|
where: { orderId: order.id },
|
|
include: { plan: true },
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
|
|
if (items.length > 0) return items;
|
|
|
|
return [
|
|
{
|
|
planId: order.planId,
|
|
selectedInboundId: order.selectedInboundId,
|
|
trafficGb: order.trafficGb,
|
|
plan: order.plan,
|
|
},
|
|
];
|
|
}
|
|
|
|
async function provisionNewSubscription(order: PaidOrder, db: DbClient): Promise<string[]> {
|
|
const items = await getNewPurchaseItems(order, db);
|
|
const createdSubscriptionIds: string[] = [];
|
|
const nodeIds = new Set<string>();
|
|
|
|
for (const item of items) {
|
|
const trafficBytes = item.trafficGb ? gbToBytes(item.trafficGb) : null;
|
|
if (item.plan.type === "PROXY") {
|
|
if (!item.trafficGb || item.trafficGb <= 0 || !trafficBytes) {
|
|
throw new Error("代理订单缺少可用流量配置");
|
|
}
|
|
const poolState = await getPlanTrafficPoolState(item.planId, { db });
|
|
if (poolState.enabled) {
|
|
await ensurePlanTrafficPoolCapacityByBytes(
|
|
item.planId,
|
|
trafficBytes,
|
|
{
|
|
db,
|
|
excludePendingOrderId: order.id,
|
|
messagePrefix: "支付成功但套餐总流量池不足",
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
const subscription = await db.userSubscription.create({
|
|
data: {
|
|
userId: order.userId,
|
|
planId: item.planId,
|
|
startDate: new Date(),
|
|
endDate: addDays(new Date(), item.plan.durationDays),
|
|
trafficLimit: trafficBytes,
|
|
status: "ACTIVE",
|
|
},
|
|
});
|
|
createdSubscriptionIds.push(subscription.id);
|
|
|
|
if (createdSubscriptionIds.length === 1) {
|
|
await db.order.update({
|
|
where: { id: order.id },
|
|
data: { subscriptionId: subscription.id },
|
|
});
|
|
}
|
|
|
|
if (item.plan.type === "PROXY") {
|
|
const nodeId = await provisionProxyClient(
|
|
subscription.id,
|
|
order.user,
|
|
item.plan,
|
|
item.trafficGb,
|
|
item.selectedInboundId,
|
|
db,
|
|
);
|
|
if (nodeId) {
|
|
nodeIds.add(nodeId);
|
|
}
|
|
} else {
|
|
await provisionStreamingSlot(subscription.id, order.userId, item.plan, db);
|
|
}
|
|
|
|
await recordAuditLog(
|
|
{
|
|
actor: {
|
|
userId: order.userId,
|
|
email: order.user.email,
|
|
role: order.user.role,
|
|
},
|
|
action: "subscription.create",
|
|
targetType: "UserSubscription",
|
|
targetId: subscription.id,
|
|
targetLabel: item.plan.name,
|
|
message: `开通订阅 ${item.plan.name}`,
|
|
metadata: {
|
|
orderId: order.id,
|
|
planId: item.planId,
|
|
},
|
|
},
|
|
db,
|
|
);
|
|
}
|
|
|
|
await createNotification(
|
|
{
|
|
userId: order.userId,
|
|
type: "SUBSCRIPTION",
|
|
level: "SUCCESS",
|
|
title: items.length > 1 ? "订阅已全部开通" : "订阅已开通",
|
|
body:
|
|
items.length > 1
|
|
? `本次订单的 ${items.length} 个订阅已开通成功,请前往订阅页面查看。`
|
|
: `${items[0]?.plan.name ?? order.plan.name} 已开通成功,请前往订阅页面查看。`,
|
|
link: "/subscriptions",
|
|
dedupeKey: `subscription-created:${order.id}`,
|
|
},
|
|
db,
|
|
);
|
|
|
|
return [...nodeIds];
|
|
}
|
|
|
|
async function applyRenewal(order: PaidOrder, db: DbClient): Promise<string[]> {
|
|
if (!order.plan.allowRenewal) {
|
|
throw new Error("该套餐当前未开放续费");
|
|
}
|
|
if (!order.targetSubscriptionId) {
|
|
throw new Error("续费订单缺少目标订阅");
|
|
}
|
|
|
|
const subscription = await db.userSubscription.findUniqueOrThrow({
|
|
where: { id: order.targetSubscriptionId },
|
|
include: {
|
|
nodeClient: {
|
|
include: {
|
|
inbound: {
|
|
include: {
|
|
server: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (subscription.userId !== order.userId || subscription.planId !== order.planId) {
|
|
throw new Error("续费目标订阅与订单不匹配");
|
|
}
|
|
if (subscription.status !== "ACTIVE" || subscription.endDate <= new Date()) {
|
|
throw new Error(`续费失败:目标订阅状态为 ${subscription.status},到期时间为 ${subscription.endDate.toISOString()}`);
|
|
}
|
|
|
|
const now = new Date();
|
|
const renewalDays = order.durationDays ?? order.plan.renewalDurationDays ?? order.plan.durationDays;
|
|
if (!Number.isInteger(renewalDays) || renewalDays <= 0) {
|
|
throw new Error("续费订单天数配置缺失");
|
|
}
|
|
|
|
const nextEndDate =
|
|
subscription.endDate > now
|
|
? addDays(subscription.endDate, renewalDays)
|
|
: addDays(now, renewalDays);
|
|
|
|
await db.userSubscription.update({
|
|
where: { id: subscription.id },
|
|
data: {
|
|
endDate: nextEndDate,
|
|
status: "ACTIVE",
|
|
},
|
|
});
|
|
|
|
const proxyNodeId =
|
|
order.plan.type === "PROXY"
|
|
? await syncProxyClientQuota(
|
|
subscription,
|
|
nextEndDate,
|
|
db,
|
|
subscription.trafficLimit,
|
|
)
|
|
: null;
|
|
|
|
await createNotification(
|
|
{
|
|
userId: order.userId,
|
|
type: "SUBSCRIPTION",
|
|
level: "SUCCESS",
|
|
title: "订阅续费成功",
|
|
body: `${order.plan.name} 已续费成功,新的到期时间已更新。`,
|
|
link: "/subscriptions",
|
|
dedupeKey: `subscription-renewal:${order.id}`,
|
|
},
|
|
db,
|
|
);
|
|
await recordAuditLog(
|
|
{
|
|
actor: {
|
|
userId: order.userId,
|
|
email: order.user.email,
|
|
role: order.user.role,
|
|
},
|
|
action: "subscription.renew",
|
|
targetType: "UserSubscription",
|
|
targetId: subscription.id,
|
|
targetLabel: order.plan.name,
|
|
message: `续费订阅 ${order.plan.name}`,
|
|
metadata: {
|
|
orderId: order.id,
|
|
},
|
|
},
|
|
db,
|
|
);
|
|
|
|
return proxyNodeId ? [proxyNodeId] : [];
|
|
}
|
|
|
|
async function applyTrafficTopup(order: PaidOrder, db: DbClient): Promise<string[]> {
|
|
if (!order.plan.allowTrafficTopup) {
|
|
throw new Error("该套餐当前未开放增流量");
|
|
}
|
|
if (!order.targetSubscriptionId) {
|
|
throw new Error("增流量订单缺少目标订阅");
|
|
}
|
|
if (!order.trafficGb || order.trafficGb <= 0) {
|
|
throw new Error("增流量订单流量无效");
|
|
}
|
|
const poolState = await getPlanTrafficPoolState(order.planId, { db });
|
|
|
|
const subscription = await db.userSubscription.findUniqueOrThrow({
|
|
where: { id: order.targetSubscriptionId },
|
|
include: {
|
|
nodeClient: {
|
|
include: {
|
|
inbound: {
|
|
include: {
|
|
server: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (subscription.userId !== order.userId || subscription.planId !== order.planId) {
|
|
throw new Error("增流量目标订阅与订单不匹配");
|
|
}
|
|
if (subscription.status !== "ACTIVE" || subscription.endDate <= new Date()) {
|
|
throw new Error("增流量仅在当前套餐有效期内生效");
|
|
}
|
|
|
|
const topupBytes = gbToBytes(order.trafficGb);
|
|
if (poolState.enabled) {
|
|
await ensurePlanTrafficPoolCapacityByBytes(order.planId, topupBytes, {
|
|
db,
|
|
excludePendingOrderId: order.id,
|
|
messagePrefix: "增流量后将超过套餐总流量池",
|
|
});
|
|
}
|
|
|
|
const nextTrafficLimit = (subscription.trafficLimit ?? BigInt(0)) + topupBytes;
|
|
await db.userSubscription.update({
|
|
where: { id: subscription.id },
|
|
data: {
|
|
trafficLimit: nextTrafficLimit,
|
|
status: "ACTIVE",
|
|
},
|
|
});
|
|
|
|
const nodeId = await syncProxyClientQuota(
|
|
subscription,
|
|
subscription.endDate,
|
|
db,
|
|
nextTrafficLimit,
|
|
);
|
|
|
|
await createNotification(
|
|
{
|
|
userId: order.userId,
|
|
type: "TRAFFIC",
|
|
level: "SUCCESS",
|
|
title: "流量已到账",
|
|
body: `${order.plan.name} 已增加 ${order.trafficGb} GB 流量。`,
|
|
link: "/subscriptions",
|
|
dedupeKey: `subscription-topup:${order.id}`,
|
|
},
|
|
db,
|
|
);
|
|
await recordAuditLog(
|
|
{
|
|
actor: {
|
|
userId: order.userId,
|
|
email: order.user.email,
|
|
role: order.user.role,
|
|
},
|
|
action: "subscription.topup",
|
|
targetType: "UserSubscription",
|
|
targetId: subscription.id,
|
|
targetLabel: order.plan.name,
|
|
message: `增加订阅流量 ${order.plan.name}`,
|
|
metadata: {
|
|
orderId: order.id,
|
|
trafficGb: order.trafficGb,
|
|
},
|
|
},
|
|
db,
|
|
);
|
|
|
|
return nodeId ? [nodeId] : [];
|
|
}
|
|
|
|
async function syncProxyClientQuota(
|
|
subscription: {
|
|
id: string;
|
|
nodeClient: {
|
|
id: string;
|
|
uuid: string;
|
|
email: string;
|
|
inbound: {
|
|
tag: string;
|
|
protocol: Protocol;
|
|
panelInboundId: number | null;
|
|
server: NodeServer;
|
|
};
|
|
} | null;
|
|
},
|
|
endDate: Date,
|
|
db: DbClient,
|
|
trafficLimitBytes?: bigint | null,
|
|
options?: { resetTraffic?: boolean },
|
|
): Promise<string | null> {
|
|
if (!subscription.nodeClient) return null;
|
|
|
|
await db.nodeClient.update({
|
|
where: { id: subscription.nodeClient.id },
|
|
data: {
|
|
expiryTime: endDate,
|
|
isEnabled: true,
|
|
},
|
|
});
|
|
|
|
const panelInboundId = subscription.nodeClient.inbound.panelInboundId;
|
|
if (panelInboundId == null) {
|
|
throw new Error("3x-ui 入站 ID 缺失,请重新同步节点入站");
|
|
}
|
|
|
|
const adapter = createPanelAdapter(subscription.nodeClient.inbound.server);
|
|
await adapter.login();
|
|
await adapter.updateClient({
|
|
inboundId: panelInboundId,
|
|
email: subscription.nodeClient.email,
|
|
uuid: subscription.nodeClient.uuid,
|
|
subId: subscription.id,
|
|
totalGB: trafficLimitBytes ? bytesToGb(trafficLimitBytes) : 0,
|
|
expiryTime: endDate.getTime(),
|
|
protocol: subscription.nodeClient.inbound.protocol,
|
|
enable: true,
|
|
});
|
|
|
|
if (options?.resetTraffic) {
|
|
await adapter.resetClientTraffic(panelInboundId, subscription.nodeClient.email);
|
|
await db.nodeClient.update({
|
|
where: { id: subscription.nodeClient.id },
|
|
data: {
|
|
trafficUp: BigInt(0),
|
|
trafficDown: BigInt(0),
|
|
},
|
|
});
|
|
}
|
|
|
|
return subscription.nodeClient.inbound.server.id;
|
|
}
|
|
|
|
async function provisionProxyClient(
|
|
subscriptionId: string,
|
|
user: User,
|
|
plan: SubscriptionPlan,
|
|
trafficGb: number | null,
|
|
selectedInboundId: string | null,
|
|
db: DbClient,
|
|
): Promise<string> {
|
|
if (!plan.nodeId) throw new Error("Proxy plan has no node assigned");
|
|
|
|
const server = await db.nodeServer.findUniqueOrThrow({
|
|
where: { id: plan.nodeId },
|
|
});
|
|
|
|
let inbound = null;
|
|
if (selectedInboundId) {
|
|
inbound = await db.nodeInbound.findFirst({
|
|
where: {
|
|
id: selectedInboundId,
|
|
serverId: plan.nodeId,
|
|
isActive: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (!inbound && plan.inboundId) {
|
|
inbound = await db.nodeInbound.findFirst({
|
|
where: {
|
|
id: plan.inboundId,
|
|
serverId: plan.nodeId,
|
|
isActive: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (!inbound) {
|
|
const option = await db.planInboundOption.findFirst({
|
|
where: { planId: plan.id, inbound: { isActive: true, serverId: plan.nodeId } },
|
|
select: { inboundId: true },
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
|
|
if (option?.inboundId) {
|
|
inbound = await db.nodeInbound.findFirst({
|
|
where: { id: option.inboundId, serverId: plan.nodeId, isActive: true },
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!inbound) {
|
|
inbound = await db.nodeInbound.findFirst({
|
|
where: { serverId: plan.nodeId, isActive: true },
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
}
|
|
|
|
if (!inbound) throw new Error("No active inbound on this node");
|
|
|
|
const clientUuid = generateNodeClientCredential(inbound.protocol, inbound.settings);
|
|
const clientEmail = `${user.email}-${subscriptionId.slice(0, 8)}`;
|
|
|
|
const sub = await db.userSubscription.findUniqueOrThrow({
|
|
where: { id: subscriptionId },
|
|
});
|
|
|
|
if (inbound.panelInboundId == null) {
|
|
throw new Error("3x-ui 入站 ID 缺失,请先同步节点入站");
|
|
}
|
|
|
|
const adapter = createPanelAdapter(server);
|
|
await adapter.login();
|
|
await adapter.addClient({
|
|
inboundId: inbound.panelInboundId,
|
|
email: clientEmail,
|
|
uuid: clientUuid,
|
|
subId: subscriptionId,
|
|
totalGB: trafficGb || 0,
|
|
expiryTime: sub.endDate.getTime(),
|
|
protocol: inbound.protocol,
|
|
});
|
|
|
|
await db.nodeClient.create({
|
|
data: {
|
|
inboundId: inbound.id,
|
|
userId: user.id,
|
|
subscriptionId,
|
|
email: clientEmail,
|
|
uuid: clientUuid,
|
|
expiryTime: sub.endDate,
|
|
},
|
|
});
|
|
|
|
return server.id;
|
|
}
|
|
|
|
async function provisionStreamingSlot(
|
|
subscriptionId: string,
|
|
userId: string,
|
|
plan: SubscriptionPlan,
|
|
db: DbClient,
|
|
) {
|
|
const run = async (client: DbClient) => {
|
|
let selectedServiceId: string | null = null;
|
|
|
|
if (plan.streamingServiceId) {
|
|
const service = await client.streamingService.findUnique({
|
|
where: { id: plan.streamingServiceId },
|
|
select: { id: true, maxSlots: true, isActive: true },
|
|
});
|
|
if (!service || !service.isActive) {
|
|
throw new Error("绑定的流媒体服务不可用");
|
|
}
|
|
|
|
const updated = await client.streamingService.updateMany({
|
|
where: {
|
|
id: service.id,
|
|
isActive: true,
|
|
usedSlots: { lt: service.maxSlots },
|
|
},
|
|
data: { usedSlots: { increment: 1 } },
|
|
});
|
|
if (updated.count === 0) {
|
|
throw new Error("绑定的流媒体服务名额已满");
|
|
}
|
|
|
|
selectedServiceId = service.id;
|
|
} else {
|
|
const services = await client.streamingService.findMany({
|
|
where: { isActive: true },
|
|
select: { id: true, maxSlots: true },
|
|
orderBy: [{ usedSlots: "asc" }, { createdAt: "asc" }],
|
|
});
|
|
|
|
for (const service of services) {
|
|
const updated = await client.streamingService.updateMany({
|
|
where: {
|
|
id: service.id,
|
|
isActive: true,
|
|
usedSlots: { lt: service.maxSlots },
|
|
},
|
|
data: { usedSlots: { increment: 1 } },
|
|
});
|
|
if (updated.count > 0) {
|
|
selectedServiceId = service.id;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!selectedServiceId) {
|
|
throw new Error("暂无可用流媒体名额");
|
|
}
|
|
}
|
|
|
|
await client.streamingSlot.create({
|
|
data: { serviceId: selectedServiceId, userId, subscriptionId },
|
|
});
|
|
};
|
|
|
|
if ("$transaction" in db) {
|
|
await db.$transaction(async (tx) => {
|
|
await run(tx);
|
|
});
|
|
return;
|
|
}
|
|
|
|
await run(db);
|
|
}
|