Files
J-Board-Lite/src/services/provision.ts
2026-04-29 15:03:00 +10:00

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