重构 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,10 @@
import { Module } from '@nestjs/common';
import { BetsService } from './bets.service';
import { WalletModule } from '../ledger/wallet.module';
@Module({
imports: [WalletModule],
providers: [BetsService],
exports: [BetsService],
})
export class BetsModule {}

View File

@@ -0,0 +1,211 @@
import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { WalletService } from '../ledger/wallet.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateBetNo } from '../../shared/common/decorators';
import { isQuarterHandicapOrTotal } from '../settlement/domain/settlement-calculator';
import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS } from '@thebet365/shared';
interface BetSelectionInput {
selectionId: bigint;
oddsVersion: bigint;
stake?: number;
}
@Injectable()
export class BetsService {
constructor(
private prisma: PrismaService,
private wallet: WalletService,
) {}
private async validateSelection(selectionId: bigint, oddsVersion: bigint) {
const selection = await this.prisma.marketSelection.findUnique({
where: { id: selectionId },
include: { market: { include: { match: true } } },
});
if (!selection) throw new BadRequestException('Selection not found');
if (selection.status !== 'OPEN') throw new BadRequestException('Selection closed');
if (selection.market.status !== 'OPEN') throw new BadRequestException('Market closed');
if (selection.market.match.status !== 'PUBLISHED') {
throw new BadRequestException('Match not available for betting');
}
if (selection.oddsVersion !== oddsVersion) {
throw new BadRequestException('Odds changed, please confirm again');
}
return selection;
}
async placeSingleBet(
userId: bigint,
agentId: bigint | null,
selectionId: bigint,
oddsVersion: bigint,
stake: number,
requestId: string,
) {
if (stake <= 0) throw new BadRequestException('Invalid stake');
const existing = await this.prisma.bet.findUnique({
where: { userId_requestId: { userId, requestId } },
});
if (existing) return existing;
const selection = await this.validateSelection(selectionId, oddsVersion);
const odds = new Decimal(selection.odds.toString());
const stakeDec = new Decimal(stake);
const potentialReturn = stakeDec.mul(odds);
const betNo = generateBetNo();
const bet = await this.prisma.$transaction(async (tx) => {
const created = await tx.bet.create({
data: {
betNo,
userId,
agentId,
betType: 'SINGLE',
stake: stakeDec,
totalOdds: odds,
potentialReturn,
requestId,
selections: {
create: {
matchId: selection.market.matchId,
marketId: selection.marketId,
selectionId: selection.id,
marketType: selection.market.marketType,
period: selection.market.period,
selectionNameSnapshot: selection.selectionName,
handicapLine: selection.market.lineValue,
totalLine: selection.market.lineValue,
odds,
oddsVersion,
},
},
},
include: { selections: true },
});
await this.wallet.freezeForBet(userId, stakeDec, betNo);
return created;
});
return bet;
}
async placeParlayBet(
userId: bigint,
agentId: bigint | null,
legs: BetSelectionInput[],
stake: number,
requestId: string,
) {
if (stake <= 0) throw new BadRequestException('Invalid stake');
if (legs.length < PARLAY_MIN_LEGS || legs.length > PARLAY_MAX_LEGS) {
throw new BadRequestException(`Parlay must have ${PARLAY_MIN_LEGS}-${PARLAY_MAX_LEGS} legs`);
}
const existing = await this.prisma.bet.findUnique({
where: { userId_requestId: { userId, requestId } },
});
if (existing) return existing;
const selections: Awaited<ReturnType<typeof this.validateSelection>>[] = [];
const matchIds = new Set<string>();
for (const leg of legs) {
const sel = await this.validateSelection(leg.selectionId, leg.oddsVersion);
if (sel.market.marketType === 'OUTRIGHT_WINNER') {
throw new BadRequestException('Outright cannot be in parlay');
}
const line = sel.market.lineValue ? Number(sel.market.lineValue) : null;
if (
['FT_HANDICAP', 'HT_HANDICAP', 'FT_OVER_UNDER', 'HT_OVER_UNDER'].includes(
sel.market.marketType,
) &&
isQuarterHandicapOrTotal(line)
) {
throw new BadRequestException('Quarter line markets cannot be in parlay');
}
const matchKey = sel.market.matchId.toString();
if (matchIds.has(matchKey)) {
throw new BadRequestException('Same match cannot be in parlay');
}
matchIds.add(matchKey);
selections.push(sel);
}
let totalOdds = new Decimal(1);
for (const sel of selections) {
totalOdds = totalOdds.mul(sel.odds.toString());
}
const stakeDec = new Decimal(stake);
const potentialReturn = stakeDec.mul(totalOdds);
const betNo = generateBetNo();
const bet = await this.prisma.$transaction(async (tx) => {
const created = await tx.bet.create({
data: {
betNo,
userId,
agentId,
betType: 'PARLAY',
stake: stakeDec,
totalOdds,
potentialReturn,
requestId,
selections: {
create: selections.map((sel, i) => ({
matchId: sel.market.matchId,
marketId: sel.marketId,
selectionId: sel.id,
marketType: sel.market.marketType,
period: sel.market.period,
selectionNameSnapshot: sel.selectionName,
handicapLine: sel.market.lineValue,
totalLine: sel.market.lineValue,
odds: sel.odds,
oddsVersion: legs[i].oddsVersion,
sortOrder: i,
})),
},
},
include: { selections: true },
});
await this.wallet.freezeForBet(userId, stakeDec, betNo);
return created;
});
return bet;
}
async getUserBets(userId: bigint, status?: string, page = 1, pageSize = 20) {
const where = { userId, ...(status ? { status } : {}) };
const skip = (page - 1) * pageSize;
const [items, total] = await Promise.all([
this.prisma.bet.findMany({
where,
include: { selections: true },
orderBy: { placedAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.bet.count({ where }),
]);
return { items, total, page, pageSize };
}
async getBetByNo(betNo: string, userId?: bigint) {
return this.prisma.bet.findFirst({
where: { betNo, ...(userId ? { userId } : {}) },
include: { selections: true },
});
}
}