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 & { plan: SubscriptionPlan; }; export async function provisionSubscription(order: PaidOrder): Promise { return provisionSubscriptionWithDb(order, prisma); } export async function provisionSubscriptionWithDb( order: PaidOrder, db: DbClient, ): Promise { 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 { 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 { const items = await getNewPurchaseItems(order, db); const createdSubscriptionIds: string[] = []; const nodeIds = new Set(); 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 { 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 { 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 { 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 { 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); }