重构 API 为 8 领域 + 应用层架构

将后端模块拆分为 domains、applications、shared 三层,结算计算器移入 domain 纯函数目录,API 路径与测试保持不变。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 14:48:41 +08:00
parent 14e49374ac
commit 4c92157299
47 changed files with 169 additions and 138 deletions

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { WalletService } from './wallet.service';
@Module({
providers: [WalletService],
exports: [WalletService],
})
export class WalletModule {}

View File

@@ -0,0 +1,222 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateTransactionId } from '../../shared/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 };
}
}