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:
582
src/services/provision.ts
Normal file
582
src/services/provision.ts
Normal file
@@ -0,0 +1,582 @@
|
||||
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(`Unsupported order kind: ${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("续费失败:目标订阅已过期或不可用");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user