feat: add wallet and recharge cards

This commit is contained in:
JetSprow
2026-05-01 02:31:29 +10:00
parent 6d6489817d
commit 018bed3f36
32 changed files with 2058 additions and 170 deletions

486
src/services/wallet.ts Normal file
View File

@@ -0,0 +1,486 @@
import crypto from "crypto";
import { Prisma } from "@prisma/client";
import { prisma, type DbClient } from "@/lib/prisma";
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";
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 debitWalletForOrder(
db: DbClient,
input: {
userId: string;
orderId: string;
amount: number | string | Prisma.Decimal;
description?: string;
},
) {
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: "BALANCE_PAYMENT",
amount: amount.negated(),
balanceAfter: wallet.balance,
description: input.description ?? "余额支付订单",
orderId: input.orderId,
},
});
return wallet;
}
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;
}
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) {
const code = rawCode.trim().toUpperCase();
if (!code) throw new Error("请输入充值卡卡密");
return prisma.$transaction(async (tx) => {
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("这张充值卡已使用或已停用");
if (card.expiresAt && card.expiresAt < new Date()) {
await tx.rechargeCard.update({
where: { id: card.id },
data: { status: "EXPIRED" },
});
throw new Error("这张充值卡已过期");
}
if (card.type === "BALANCE") {
if (!card.balanceAmount || Number(card.balanceAmount) <= 0) {
throw new Error("余额充值卡金额无效");
}
await creditWallet(tx, {
userId,
amount: card.balanceAmount,
type: "CARD_REDEEM",
description: "兑换余额充值卡",
rechargeCardId: card.id,
});
} 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);
}
await tx.rechargeCard.update({
where: { id: card.id },
data: {
status: "REDEEMED",
redeemedById: userId,
redeemedAt: new Date(),
},
});
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 card.type;
});
}
export async function expireOldRechargeCards() {
await prisma.rechargeCard.updateMany({
where: {
status: "UNUSED",
expiresAt: { lt: new Date() },
},
data: { status: "EXPIRED" },
});
}