Files
thebet365/apps/api/src/wallet/wallet.service.ts
Mars 14e49374ac 初始化足球投注平台 MVP Monorepo
包含 NestJS 后端、三端前端、Prisma 数据模型、结算引擎测试与 PRD 文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 14:35:48 +08:00

223 lines
6.5 KiB
TypeScript

import { Injectable, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateTransactionId } from '../common/decorators';
@Injectable()
export class WalletService {
constructor(private prisma: PrismaService) {}
async getWallet(userId: bigint) {
const wallet = await this.prisma.wallet.findUnique({ where: { userId } });
if (!wallet) throw new BadRequestException('Wallet not found');
return wallet;
}
async createWallet(userId: bigint, currency = 'USD') {
return this.prisma.wallet.create({
data: { userId, currency },
});
}
private async lockWallet(tx: Parameters<Parameters<PrismaService['$transaction']>[0]>[0], userId: bigint) {
const wallets = await tx.$queryRaw<Array<{ id: bigint; available_balance: Decimal; frozen_balance: Decimal; version: number }>>`
SELECT id, available_balance, frozen_balance, version FROM wallets WHERE user_id = ${userId} FOR UPDATE
`;
if (!wallets.length) throw new BadRequestException('Wallet not found');
return wallets[0];
}
async deposit(
userId: bigint,
amount: Decimal | number,
operatorId: bigint,
remark?: string,
referenceId?: string,
) {
const amt = new Decimal(amount);
if (amt.lte(0)) throw new BadRequestException('Amount must be positive');
return this.prisma.$transaction(async (tx) => {
const w = await this.lockWallet(tx, userId);
const balanceBefore = new Decimal(w.available_balance);
const balanceAfter = balanceBefore.add(amt);
await tx.wallet.update({
where: { id: w.id },
data: {
availableBalance: balanceAfter,
version: { increment: 1 },
},
});
await tx.walletTransaction.create({
data: {
transactionId: generateTransactionId(),
userId,
walletId: w.id,
transactionType: 'MANUAL_DEPOSIT',
amount: amt,
balanceBefore,
balanceAfter,
frozenBefore: w.frozen_balance,
frozenAfter: w.frozen_balance,
referenceType: 'DEPOSIT',
referenceId,
operatorId,
remark,
},
});
return { balanceAfter };
});
}
async withdraw(
userId: bigint,
amount: Decimal | number,
operatorId: bigint,
remark?: string,
referenceId?: string,
) {
const amt = new Decimal(amount);
if (amt.lte(0)) throw new BadRequestException('Amount must be positive');
return this.prisma.$transaction(async (tx) => {
const w = await this.lockWallet(tx, userId);
const balanceBefore = new Decimal(w.available_balance);
if (balanceBefore.lt(amt)) throw new BadRequestException('Insufficient balance');
const balanceAfter = balanceBefore.sub(amt);
await tx.wallet.update({
where: { id: w.id },
data: {
availableBalance: balanceAfter,
version: { increment: 1 },
},
});
await tx.walletTransaction.create({
data: {
transactionId: generateTransactionId(),
userId,
walletId: w.id,
transactionType: 'MANUAL_WITHDRAW',
amount: amt.neg(),
balanceBefore,
balanceAfter,
frozenBefore: w.frozen_balance,
frozenAfter: w.frozen_balance,
referenceType: 'WITHDRAW',
referenceId,
operatorId,
remark,
},
});
return { balanceAfter };
});
}
async freezeForBet(userId: bigint, stake: Decimal | number, betId: string) {
const amt = new Decimal(stake);
return this.prisma.$transaction(async (tx) => {
const w = await this.lockWallet(tx, userId);
const avail = new Decimal(w.available_balance);
if (avail.lt(amt)) throw new BadRequestException('Insufficient balance');
const balanceAfter = avail.sub(amt);
const frozenAfter = new Decimal(w.frozen_balance).add(amt);
await tx.wallet.update({
where: { id: w.id },
data: {
availableBalance: balanceAfter,
frozenBalance: frozenAfter,
version: { increment: 1 },
},
});
await tx.walletTransaction.create({
data: {
transactionId: generateTransactionId(),
userId,
walletId: w.id,
transactionType: 'BET_FREEZE',
amount: amt.neg(),
balanceBefore: avail,
balanceAfter,
frozenBefore: w.frozen_balance,
frozenAfter,
referenceType: 'BET',
referenceId: betId,
},
});
});
}
async settleBet(
userId: bigint,
stake: Decimal,
payout: Decimal,
betId: string,
result: 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE',
) {
const txTypeMap: Record<string, string> = {
WIN: 'BET_SETTLE_WIN',
LOSE: 'BET_SETTLE_LOSE',
PUSH: 'BET_SETTLE_PUSH',
VOID: 'BET_VOID_REFUND',
HALF_WIN: 'BET_SETTLE_WIN',
HALF_LOSE: 'BET_SETTLE_LOSE',
};
return this.prisma.$transaction(async (tx) => {
const w = await this.lockWallet(tx, userId);
const avail = new Decimal(w.available_balance);
const frozen = new Decimal(w.frozen_balance);
const frozenAfter = frozen.sub(stake);
const balanceAfter = avail.add(payout);
await tx.wallet.update({
where: { id: w.id },
data: {
availableBalance: balanceAfter,
frozenBalance: frozenAfter.lt(0) ? new Decimal(0) : frozenAfter,
version: { increment: 1 },
},
});
await tx.walletTransaction.create({
data: {
transactionId: generateTransactionId(),
userId,
walletId: w.id,
transactionType: txTypeMap[result] || 'BET_SETTLE_WIN',
amount: payout,
balanceBefore: avail,
balanceAfter,
frozenBefore: frozen,
frozenAfter: frozenAfter.lt(0) ? new Decimal(0) : frozenAfter,
referenceType: 'BET',
referenceId: betId,
},
});
});
}
async getTransactions(userId: bigint, page = 1, pageSize = 20) {
const skip = (page - 1) * pageSize;
const [items, total] = await Promise.all([
this.prisma.walletTransaction.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.walletTransaction.count({ where: { userId } }),
]);
return { items, total, page, pageSize };
}
}