mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
553 lines
16 KiB
TypeScript
553 lines
16 KiB
TypeScript
import crypto from "crypto";
|
|
import { Prisma } from "@prisma/client";
|
|
import { prisma, type DbClient } from "@/lib/prisma";
|
|
import { formatDate } from "@/lib/utils";
|
|
import { createNotification } from "@/services/notifications";
|
|
import { provisionSubscriptionWithDb } from "@/services/provision";
|
|
import { getPaymentProviderName } from "@/services/payment/catalog";
|
|
import { getPlanAvailability, type PlanAvailability } from "@/services/plan-availability";
|
|
|
|
const MONEY_SCALE = 100;
|
|
|
|
function toMoneyDecimal(value: number | string | Prisma.Decimal) {
|
|
const amount = value instanceof Prisma.Decimal ? value.toNumber() : Number(value);
|
|
if (!Number.isFinite(amount) || amount <= 0) {
|
|
throw new Error("金额必须大于 0");
|
|
}
|
|
return new Prisma.Decimal(Math.round(amount * MONEY_SCALE) / MONEY_SCALE);
|
|
}
|
|
|
|
function generateRedeemCode(prefix = "JB") {
|
|
const random = crypto.randomUUID().replace(/-/g, "").slice(0, 18).toUpperCase();
|
|
return `${prefix}-${random.slice(0, 6)}-${random.slice(6, 12)}-${random.slice(12, 18)}`;
|
|
}
|
|
|
|
async function createUniqueRedeemCode(db: DbClient, prefix?: string) {
|
|
for (let index = 0; index < 8; index += 1) {
|
|
const code = generateRedeemCode(prefix);
|
|
const existing = await db.rechargeCard.findUnique({
|
|
where: { code },
|
|
select: { id: true },
|
|
});
|
|
if (!existing) return code;
|
|
}
|
|
throw new Error("充值卡卡密生成失败,请重试");
|
|
}
|
|
|
|
export async function getOrCreateWallet(db: DbClient, userId: string) {
|
|
return db.walletAccount.upsert({
|
|
where: { userId },
|
|
update: {},
|
|
create: { userId, balance: 0 },
|
|
});
|
|
}
|
|
|
|
export async function getWalletBalance(userId: string) {
|
|
const wallet = await getOrCreateWallet(prisma, userId);
|
|
return Number(wallet.balance);
|
|
}
|
|
|
|
export async function creditWallet(
|
|
db: DbClient,
|
|
input: {
|
|
userId: string;
|
|
amount: number | string | Prisma.Decimal;
|
|
type: "BALANCE_RECHARGE" | "CARD_REDEEM" | "ADMIN_ADJUST" | "REFUND" | "SUBSCRIPTION_TRANSFER_REFUND";
|
|
description?: string;
|
|
orderId?: string | null;
|
|
rechargeOrderId?: string | null;
|
|
rechargeCardId?: string | null;
|
|
metadata?: Prisma.InputJsonValue;
|
|
},
|
|
) {
|
|
const amount = toMoneyDecimal(input.amount);
|
|
await getOrCreateWallet(db, input.userId);
|
|
|
|
const wallet = await db.walletAccount.update({
|
|
where: { userId: input.userId },
|
|
data: { balance: { increment: amount } },
|
|
});
|
|
|
|
await db.walletTransaction.create({
|
|
data: {
|
|
walletId: wallet.id,
|
|
userId: input.userId,
|
|
type: input.type,
|
|
amount,
|
|
balanceAfter: wallet.balance,
|
|
description: input.description ?? null,
|
|
orderId: input.orderId ?? null,
|
|
rechargeOrderId: input.rechargeOrderId ?? null,
|
|
rechargeCardId: input.rechargeCardId ?? null,
|
|
metadata: input.metadata ?? undefined,
|
|
},
|
|
});
|
|
|
|
return wallet;
|
|
}
|
|
|
|
export async function debitWallet(
|
|
db: DbClient,
|
|
input: {
|
|
userId: string;
|
|
amount: number | string | Prisma.Decimal;
|
|
type: "BALANCE_PAYMENT" | "SUBSCRIPTION_TRANSFER_FEE";
|
|
description?: string;
|
|
orderId?: string | null;
|
|
metadata?: Prisma.InputJsonValue;
|
|
},
|
|
) {
|
|
const amount = toMoneyDecimal(input.amount);
|
|
await getOrCreateWallet(db, input.userId);
|
|
|
|
const claimed = await db.walletAccount.updateMany({
|
|
where: {
|
|
userId: input.userId,
|
|
balance: { gte: amount },
|
|
},
|
|
data: { balance: { decrement: amount } },
|
|
});
|
|
|
|
if (claimed.count === 0) {
|
|
throw new Error("余额不足,请先充值后再操作");
|
|
}
|
|
|
|
const wallet = await db.walletAccount.findUniqueOrThrow({
|
|
where: { userId: input.userId },
|
|
});
|
|
|
|
await db.walletTransaction.create({
|
|
data: {
|
|
walletId: wallet.id,
|
|
userId: input.userId,
|
|
type: input.type,
|
|
amount: amount.negated(),
|
|
balanceAfter: wallet.balance,
|
|
description: input.description ?? "余额扣费",
|
|
orderId: input.orderId ?? null,
|
|
metadata: input.metadata ?? undefined,
|
|
},
|
|
});
|
|
|
|
return wallet;
|
|
}
|
|
|
|
export async function debitWalletForOrder(
|
|
db: DbClient,
|
|
input: {
|
|
userId: string;
|
|
orderId: string;
|
|
amount: number | string | Prisma.Decimal;
|
|
description?: string;
|
|
},
|
|
) {
|
|
const amount = toMoneyDecimal(input.amount);
|
|
return debitWallet(db, {
|
|
userId: input.userId,
|
|
orderId: input.orderId,
|
|
amount,
|
|
type: "BALANCE_PAYMENT",
|
|
description: input.description ?? "余额支付订单",
|
|
});
|
|
}
|
|
|
|
type PlanCardSnapshot = {
|
|
type: "PROXY" | "STREAMING";
|
|
nodeId: string | null;
|
|
inboundId: string | null;
|
|
fixedTrafficGb: number | null;
|
|
minTrafficGb: number | null;
|
|
totalTrafficGb: number | null;
|
|
inboundOptions?: Array<{ inboundId: string; inbound: { isActive: boolean; serverId: string } }>;
|
|
};
|
|
|
|
function resolvePlanCardConfig(card: {
|
|
selectedInboundId: string | null;
|
|
trafficGb: number | null;
|
|
plan: PlanCardSnapshot;
|
|
}) {
|
|
const plan = card.plan;
|
|
if (plan.type === "STREAMING") {
|
|
return { selectedInboundId: null, trafficGb: null };
|
|
}
|
|
|
|
const selectedInboundId =
|
|
card.selectedInboundId
|
|
?? plan.inboundId
|
|
?? card.plan.inboundOptions?.find(
|
|
(item) => item.inbound.isActive && (!plan.nodeId || item.inbound.serverId === plan.nodeId),
|
|
)?.inboundId
|
|
?? null;
|
|
|
|
const trafficGb =
|
|
card.trafficGb
|
|
?? plan.fixedTrafficGb
|
|
?? plan.minTrafficGb
|
|
?? plan.totalTrafficGb
|
|
?? null;
|
|
|
|
if (!selectedInboundId) {
|
|
throw new Error("套餐充值卡缺少可用线路,无法自动开通代理套餐");
|
|
}
|
|
if (!trafficGb || trafficGb <= 0) {
|
|
throw new Error("套餐充值卡缺少可用流量配置");
|
|
}
|
|
|
|
return { selectedInboundId, trafficGb };
|
|
}
|
|
|
|
function getPlanCardGenerationLimit(planType: "PROXY" | "STREAMING", availability: PlanAvailability) {
|
|
const limits: number[] = [];
|
|
if (availability.remainingByPlanLimit != null) {
|
|
limits.push(availability.remainingByPlanLimit);
|
|
}
|
|
if (planType === "STREAMING" && availability.remainingByServiceCapacity != null) {
|
|
limits.push(availability.remainingByServiceCapacity);
|
|
}
|
|
|
|
return limits.length > 0 ? Math.min(...limits) : null;
|
|
}
|
|
|
|
function getUnavailableRechargeCardMessage(card: {
|
|
status: "UNUSED" | "REDEEMED" | "EXPIRED" | "DISABLED";
|
|
redeemedAt: Date | null;
|
|
expiresAt: Date | null;
|
|
}) {
|
|
if (card.status === "REDEEMED") {
|
|
return card.redeemedAt
|
|
? `这张充值卡已在 ${formatDate(card.redeemedAt)} 兑换,不能重复使用。`
|
|
: "这张充值卡已兑换,不能重复使用。";
|
|
}
|
|
if (card.status === "EXPIRED") {
|
|
return card.expiresAt
|
|
? `这张充值卡已于 ${formatDate(card.expiresAt)} 过期。`
|
|
: "这张充值卡已过期。";
|
|
}
|
|
if (card.status === "DISABLED") {
|
|
return "这张充值卡已停用,请联系管理员处理。";
|
|
}
|
|
return "这张充值卡当前不可兑换。";
|
|
}
|
|
|
|
export type RedeemRechargeCardResult =
|
|
| {
|
|
type: "BALANCE";
|
|
amount: number;
|
|
balanceAfter: number;
|
|
}
|
|
| {
|
|
type: "PLAN";
|
|
planName: string;
|
|
};
|
|
|
|
export async function payOrderWithWallet(orderId: string, userId: string) {
|
|
const tradeNo = `BAL-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
|
|
|
return prisma.$transaction(async (tx) => {
|
|
const config = await tx.paymentConfig.findUnique({
|
|
where: { provider: "balance" },
|
|
select: { enabled: true },
|
|
});
|
|
if (config && !config.enabled) {
|
|
throw new Error("余额支付当前已关闭");
|
|
}
|
|
|
|
const order = await tx.order.findUnique({
|
|
where: { id: orderId },
|
|
include: { plan: true, user: true },
|
|
});
|
|
|
|
if (!order || order.userId !== userId) {
|
|
throw new Error("订单不存在");
|
|
}
|
|
if (order.status !== "PENDING") {
|
|
throw new Error("这笔订单已经不在待支付状态");
|
|
}
|
|
|
|
const amount = toMoneyDecimal(order.amount);
|
|
await debitWalletForOrder(tx, {
|
|
userId,
|
|
orderId,
|
|
amount,
|
|
description: `余额支付 ${order.plan.name}`,
|
|
});
|
|
|
|
const paidOrder = await tx.order.update({
|
|
where: { id: orderId },
|
|
data: {
|
|
status: "PAID",
|
|
paymentMethod: "balance",
|
|
paymentRef: tradeNo,
|
|
paymentUrl: null,
|
|
tradeNo,
|
|
expireAt: null,
|
|
note: null,
|
|
},
|
|
include: { plan: true, user: true },
|
|
});
|
|
|
|
const affectedNodeIds = await provisionSubscriptionWithDb(paidOrder, tx);
|
|
return { tradeNo, affectedNodeIds };
|
|
});
|
|
}
|
|
|
|
export async function createWalletRechargeOrder(userId: string, amountValue: number) {
|
|
const amount = toMoneyDecimal(amountValue);
|
|
return prisma.walletRechargeOrder.create({
|
|
data: { userId, amount },
|
|
});
|
|
}
|
|
|
|
export async function processWalletRechargeSuccess(
|
|
tradeNo: string,
|
|
paidAmount: number,
|
|
paymentRef?: string,
|
|
) {
|
|
if (!Number.isFinite(paidAmount) || paidAmount <= 0) {
|
|
return { processed: false, finalStatus: null as null | "PENDING" | "PAID" };
|
|
}
|
|
|
|
const rechargeOrder = await prisma.walletRechargeOrder.findUnique({
|
|
where: { tradeNo },
|
|
});
|
|
if (!rechargeOrder) {
|
|
return { processed: false, finalStatus: null as null | "PENDING" | "PAID" };
|
|
}
|
|
|
|
const expectedAmount = Number(rechargeOrder.amount);
|
|
if (Math.abs(expectedAmount - paidAmount) > 0.01) {
|
|
throw new Error("支付金额与充值金额不一致");
|
|
}
|
|
|
|
return prisma.$transaction(async (tx) => {
|
|
const claimed = await tx.walletRechargeOrder.updateMany({
|
|
where: { id: rechargeOrder.id, status: "PENDING" },
|
|
data: {
|
|
status: "PAID",
|
|
paymentRef: paymentRef ?? rechargeOrder.paymentRef,
|
|
note: null,
|
|
},
|
|
});
|
|
|
|
if (claimed.count === 0) {
|
|
const current = await tx.walletRechargeOrder.findUnique({
|
|
where: { id: rechargeOrder.id },
|
|
select: { status: true },
|
|
});
|
|
return { processed: false, finalStatus: current?.status ?? null };
|
|
}
|
|
|
|
await creditWallet(tx, {
|
|
userId: rechargeOrder.userId,
|
|
amount: rechargeOrder.amount,
|
|
type: "BALANCE_RECHARGE",
|
|
description: `${getPaymentProviderName(rechargeOrder.paymentMethod ?? "")} 余额充值`,
|
|
rechargeOrderId: rechargeOrder.id,
|
|
metadata: { tradeNo },
|
|
});
|
|
|
|
await createNotification(
|
|
{
|
|
userId: rechargeOrder.userId,
|
|
type: "ORDER",
|
|
level: "SUCCESS",
|
|
title: "余额充值成功",
|
|
body: `已充值 ¥${expectedAmount.toFixed(2)} 到账户余额。`,
|
|
link: "/wallet",
|
|
dedupeKey: `wallet-recharge:${rechargeOrder.id}`,
|
|
},
|
|
tx,
|
|
);
|
|
|
|
return { processed: true, finalStatus: "PAID" as const };
|
|
});
|
|
}
|
|
|
|
export async function createRechargeCards(input: {
|
|
createdById: string;
|
|
type: "BALANCE" | "PLAN";
|
|
quantity: number;
|
|
balanceAmount?: number;
|
|
planId?: string;
|
|
batchName?: string | null;
|
|
expiresAt?: Date | null;
|
|
}) {
|
|
const quantity = Math.min(Math.max(input.quantity, 1), 200);
|
|
|
|
return prisma.$transaction(async (tx) => {
|
|
let plan:
|
|
| (NonNullable<Awaited<ReturnType<typeof tx.subscriptionPlan.findUnique>>> & {
|
|
inboundOptions: Array<{ inboundId: string; inbound: { isActive: boolean; serverId: string } }>;
|
|
})
|
|
| null = null;
|
|
|
|
if (input.type === "PLAN") {
|
|
if (!input.planId) throw new Error("请选择套餐");
|
|
plan = await tx.subscriptionPlan.findUnique({
|
|
where: { id: input.planId },
|
|
include: {
|
|
inboundOptions: {
|
|
include: { inbound: { select: { isActive: true, serverId: true } } },
|
|
},
|
|
},
|
|
});
|
|
if (!plan) throw new Error("套餐不存在");
|
|
if (!plan.isActive) throw new Error("只能为上架中的套餐生成兑换卡");
|
|
|
|
const availability = await getPlanAvailability(plan, { db: tx });
|
|
const remaining = getPlanCardGenerationLimit(plan.type, availability);
|
|
if (remaining != null && quantity > remaining) {
|
|
throw new Error(`套餐剩余库存不足,当前最多可生成 ${remaining} 张`);
|
|
}
|
|
}
|
|
|
|
const cards = [];
|
|
for (let index = 0; index < quantity; index += 1) {
|
|
const code = await createUniqueRedeemCode(tx, input.type === "PLAN" ? "JP" : "JB");
|
|
cards.push(
|
|
await tx.rechargeCard.create({
|
|
data: {
|
|
code,
|
|
type: input.type,
|
|
balanceAmount:
|
|
input.type === "BALANCE" ? toMoneyDecimal(input.balanceAmount ?? 0) : null,
|
|
planId: input.type === "PLAN" ? input.planId! : null,
|
|
trafficGb: input.type === "PLAN" && plan?.type === "PROXY"
|
|
? plan.fixedTrafficGb ?? plan.minTrafficGb ?? plan.totalTrafficGb
|
|
: null,
|
|
selectedInboundId: input.type === "PLAN" && plan?.type === "PROXY"
|
|
? plan.inboundId
|
|
?? plan.inboundOptions.find(
|
|
(item) => item.inbound.isActive && (!plan?.nodeId || item.inbound.serverId === plan.nodeId),
|
|
)?.inboundId
|
|
?? null
|
|
: null,
|
|
batchName: input.batchName || null,
|
|
expiresAt: input.expiresAt ?? null,
|
|
createdById: input.createdById,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
return cards;
|
|
});
|
|
}
|
|
|
|
export async function redeemRechargeCard(userId: string, rawCode: string): Promise<RedeemRechargeCardResult> {
|
|
const code = rawCode.trim().toUpperCase();
|
|
if (!code) throw new Error("请输入充值卡卡密");
|
|
|
|
return prisma.$transaction(async (tx) => {
|
|
const redeemedAt = new Date();
|
|
const card = await tx.rechargeCard.findUnique({
|
|
where: { code },
|
|
include: {
|
|
plan: {
|
|
include: {
|
|
inboundOptions: {
|
|
include: { inbound: { select: { isActive: true, serverId: true } } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!card) throw new Error("充值卡不存在");
|
|
if (card.status !== "UNUSED") throw new Error(getUnavailableRechargeCardMessage(card));
|
|
if (card.expiresAt && card.expiresAt < redeemedAt) {
|
|
await tx.rechargeCard.updateMany({
|
|
where: { id: card.id, status: "UNUSED" },
|
|
data: { status: "EXPIRED" },
|
|
});
|
|
throw new Error(getUnavailableRechargeCardMessage({ ...card, status: "EXPIRED" }));
|
|
}
|
|
|
|
const claimed = await tx.rechargeCard.updateMany({
|
|
where: { id: card.id, status: "UNUSED" },
|
|
data: {
|
|
status: "REDEEMED",
|
|
redeemedById: userId,
|
|
redeemedAt,
|
|
},
|
|
});
|
|
if (claimed.count === 0) {
|
|
throw new Error("这张充值卡刚刚已被兑换,请刷新后查看最新状态。");
|
|
}
|
|
|
|
let result: RedeemRechargeCardResult;
|
|
if (card.type === "BALANCE") {
|
|
if (!card.balanceAmount || Number(card.balanceAmount) <= 0) {
|
|
throw new Error("余额充值卡金额无效");
|
|
}
|
|
|
|
const wallet = await creditWallet(tx, {
|
|
userId,
|
|
amount: card.balanceAmount,
|
|
type: "CARD_REDEEM",
|
|
description: "兑换余额充值卡",
|
|
rechargeCardId: card.id,
|
|
});
|
|
result = {
|
|
type: "BALANCE",
|
|
amount: Number(card.balanceAmount),
|
|
balanceAfter: Number(wallet.balance),
|
|
};
|
|
} else {
|
|
if (!card.plan) throw new Error("套餐充值卡绑定的套餐不存在");
|
|
const { selectedInboundId, trafficGb } = resolvePlanCardConfig({
|
|
selectedInboundId: card.selectedInboundId,
|
|
trafficGb: card.trafficGb,
|
|
plan: card.plan,
|
|
});
|
|
const order = await tx.order.create({
|
|
data: {
|
|
userId,
|
|
planId: card.plan.id,
|
|
kind: "NEW_PURCHASE",
|
|
selectedInboundId,
|
|
trafficGb,
|
|
amount: 0,
|
|
subtotalAmount: 0,
|
|
discountAmount: 0,
|
|
status: "PAID",
|
|
paymentMethod: "recharge_card",
|
|
paymentRef: card.code,
|
|
tradeNo: `CARD-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`,
|
|
},
|
|
include: { plan: true, user: true },
|
|
});
|
|
|
|
await provisionSubscriptionWithDb(order, tx);
|
|
result = { type: "PLAN", planName: card.plan.name };
|
|
}
|
|
|
|
await createNotification(
|
|
{
|
|
userId,
|
|
type: "ORDER",
|
|
level: "SUCCESS",
|
|
title: card.type === "BALANCE" ? "余额充值卡兑换成功" : "套餐充值卡兑换成功",
|
|
body: card.type === "BALANCE"
|
|
? `已充值 ¥${Number(card.balanceAmount).toFixed(2)} 到账户余额。`
|
|
: `${card.plan?.name ?? "套餐"} 已激活。`,
|
|
link: card.type === "BALANCE" ? "/wallet" : "/subscriptions",
|
|
dedupeKey: `recharge-card-redeemed:${card.id}`,
|
|
},
|
|
tx,
|
|
);
|
|
|
|
return result;
|
|
});
|
|
}
|
|
|
|
export async function expireOldRechargeCards() {
|
|
await prisma.rechargeCard.updateMany({
|
|
where: {
|
|
status: "UNUSED",
|
|
expiresAt: { lt: new Date() },
|
|
},
|
|
data: { status: "EXPIRED" },
|
|
});
|
|
}
|