feat(admin,api,player): 结算预览分页、统计图表与返水限额
完善结算计算与预览 API(含后端分页),加强管理端结算/返水/权限,并优化玩家端投注单与队徽展示。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BetsService } from './bets.service';
|
||||
import { BettingLimitsService } from './betting-limits.service';
|
||||
import { WalletModule } from '../ledger/wallet.module';
|
||||
|
||||
@Module({
|
||||
imports: [WalletModule],
|
||||
providers: [BetsService],
|
||||
exports: [BetsService],
|
||||
providers: [BetsService, BettingLimitsService],
|
||||
exports: [BetsService, BettingLimitsService],
|
||||
})
|
||||
export class BetsModule {}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, BadRequestException, ConflictException, NotFoundException }
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../ledger/wallet.service';
|
||||
import { BettingLimitsService } from './betting-limits.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { generateBetNo } from '../../shared/common/decorators';
|
||||
import {
|
||||
@@ -10,8 +11,25 @@ import {
|
||||
canSelectForParlay,
|
||||
isPreMatchKickoff,
|
||||
isSupportedSport,
|
||||
resolveTranslationFallback,
|
||||
} from '@thebet365/shared';
|
||||
|
||||
type MatchContext = {
|
||||
matchLabel: string;
|
||||
leagueName: string;
|
||||
};
|
||||
|
||||
type BetSelectionRow = {
|
||||
matchId: bigint | null;
|
||||
marketType: string;
|
||||
period: string | null;
|
||||
selectionNameSnapshot: string;
|
||||
handicapLine: Decimal | null;
|
||||
totalLine: Decimal | null;
|
||||
odds: Decimal;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
interface BetSelectionInput {
|
||||
selectionId: bigint;
|
||||
oddsVersion: bigint;
|
||||
@@ -23,6 +41,7 @@ export class BetsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private wallet: WalletService,
|
||||
private bettingLimits: BettingLimitsService,
|
||||
) {}
|
||||
|
||||
private async validateSelection(
|
||||
@@ -92,6 +111,12 @@ export class BetsService {
|
||||
const odds = new Decimal(selection.odds.toString());
|
||||
const stakeDec = new Decimal(stake);
|
||||
const potentialReturn = stakeDec.mul(odds);
|
||||
await this.bettingLimits.validateBet({
|
||||
userId,
|
||||
betType: 'SINGLE',
|
||||
stake,
|
||||
potentialReturn,
|
||||
});
|
||||
const betNo = generateBetNo();
|
||||
|
||||
const bet = await this.prisma.$transaction(async (tx) => {
|
||||
@@ -148,16 +173,9 @@ export class BetsService {
|
||||
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, { forParlay: true });
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -168,6 +186,12 @@ export class BetsService {
|
||||
|
||||
const stakeDec = new Decimal(stake);
|
||||
const potentialReturn = stakeDec.mul(totalOdds);
|
||||
await this.bettingLimits.validateBet({
|
||||
userId,
|
||||
betType: 'PARLAY',
|
||||
stake,
|
||||
potentialReturn,
|
||||
});
|
||||
const betNo = generateBetNo();
|
||||
|
||||
const bet = await this.prisma.$transaction(async (tx) => {
|
||||
@@ -234,6 +258,105 @@ export class BetsService {
|
||||
return v?.toString() ?? '0';
|
||||
}
|
||||
|
||||
private resolveEntityName(
|
||||
translations: Array<{ entityType: string; entityId: bigint; locale: string; value: string }>,
|
||||
entityType: string,
|
||||
entityId: bigint,
|
||||
locale: string,
|
||||
) {
|
||||
const map = Object.fromEntries(
|
||||
translations
|
||||
.filter(
|
||||
(t) =>
|
||||
t.entityType === entityType && t.entityId.toString() === entityId.toString(),
|
||||
)
|
||||
.map((t) => [t.locale, t.value]),
|
||||
);
|
||||
return resolveTranslationFallback(map, locale);
|
||||
}
|
||||
|
||||
private async loadMatchContext(
|
||||
matchIds: bigint[],
|
||||
locale = 'zh-CN',
|
||||
): Promise<Map<string, MatchContext>> {
|
||||
const result = new Map<string, MatchContext>();
|
||||
if (matchIds.length === 0) return result;
|
||||
|
||||
const matches = await this.prisma.match.findMany({
|
||||
where: { id: { in: matchIds } },
|
||||
select: {
|
||||
id: true,
|
||||
matchName: true,
|
||||
leagueId: true,
|
||||
homeTeamId: true,
|
||||
awayTeamId: true,
|
||||
homeTeam: { select: { code: true } },
|
||||
awayTeam: { select: { code: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const entityIds = new Set<bigint>();
|
||||
for (const m of matches) {
|
||||
entityIds.add(m.leagueId);
|
||||
entityIds.add(m.homeTeamId);
|
||||
entityIds.add(m.awayTeamId);
|
||||
}
|
||||
|
||||
const translations =
|
||||
entityIds.size > 0
|
||||
? await this.prisma.entityTranslation.findMany({
|
||||
where: {
|
||||
entityId: { in: Array.from(entityIds) },
|
||||
entityType: { in: ['TEAM', 'LEAGUE'] },
|
||||
fieldName: 'name',
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
for (const m of matches) {
|
||||
const leagueName =
|
||||
this.resolveEntityName(translations, 'LEAGUE', m.leagueId, locale) || m.leagueId.toString();
|
||||
const homeName =
|
||||
this.resolveEntityName(translations, 'TEAM', m.homeTeamId, locale) || m.homeTeam.code;
|
||||
const awayName =
|
||||
this.resolveEntityName(translations, 'TEAM', m.awayTeamId, locale) || m.awayTeam.code;
|
||||
const matchLabel = m.matchName?.trim() || `${homeName} vs ${awayName}`;
|
||||
result.set(m.id.toString(), { matchLabel, leagueName });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private formatSelectionPreviews(
|
||||
selections: BetSelectionRow[],
|
||||
matchContext: Map<string, MatchContext>,
|
||||
) {
|
||||
return selections.map((s) => {
|
||||
const ctx = s.matchId ? matchContext.get(s.matchId.toString()) : undefined;
|
||||
return {
|
||||
matchId: s.matchId?.toString() ?? null,
|
||||
matchLabel: ctx?.matchLabel ?? '—',
|
||||
leagueName: ctx?.leagueName ?? '',
|
||||
marketType: s.marketType,
|
||||
period: s.period,
|
||||
selectionName: s.selectionNameSnapshot,
|
||||
odds: this.dec(s.odds),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private attachSelectionPreviews<T extends Record<string, unknown>>(
|
||||
row: T,
|
||||
selections: BetSelectionRow[],
|
||||
matchContext: Map<string, MatchContext>,
|
||||
) {
|
||||
const selectionPreviews = this.formatSelectionPreviews(selections, matchContext);
|
||||
const selectionSummary = selectionPreviews
|
||||
.map((p) => `${p.matchLabel} · ${p.selectionName}`)
|
||||
.join(';');
|
||||
return { ...row, selectionPreviews, selectionSummary };
|
||||
}
|
||||
|
||||
private formatBetListRow(
|
||||
b: {
|
||||
id: bigint;
|
||||
@@ -326,7 +449,7 @@ export class BetsService {
|
||||
parent: { select: { username: true } },
|
||||
},
|
||||
},
|
||||
_count: { select: { selections: true } },
|
||||
selections: { orderBy: { sortOrder: 'asc' } },
|
||||
},
|
||||
orderBy: { placedAt: 'desc' },
|
||||
skip,
|
||||
@@ -335,8 +458,26 @@ export class BetsService {
|
||||
this.prisma.bet.count({ where }),
|
||||
]);
|
||||
|
||||
const matchIds = [
|
||||
...new Set(
|
||||
items.flatMap((b) =>
|
||||
b.selections.map((s) => s.matchId).filter((id): id is bigint => id != null),
|
||||
),
|
||||
),
|
||||
];
|
||||
const matchContext = await this.loadMatchContext(matchIds);
|
||||
|
||||
return {
|
||||
items: items.map((b) => this.formatBetListRow(b)),
|
||||
items: items.map((b) =>
|
||||
this.attachSelectionPreviews(
|
||||
this.formatBetListRow({
|
||||
...b,
|
||||
_count: { selections: b.selections.length },
|
||||
}),
|
||||
b.selections,
|
||||
matchContext,
|
||||
),
|
||||
),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -359,27 +500,44 @@ export class BetsService {
|
||||
});
|
||||
if (!bet) throw new NotFoundException('注单不存在');
|
||||
|
||||
return {
|
||||
...this.formatBetListRow({
|
||||
const matchIds = bet.selections
|
||||
.map((s) => s.matchId)
|
||||
.filter((id): id is bigint => id != null);
|
||||
const matchContext = await this.loadMatchContext(matchIds);
|
||||
const selectionPreviews = this.formatSelectionPreviews(bet.selections, matchContext);
|
||||
|
||||
const base = this.attachSelectionPreviews(
|
||||
this.formatBetListRow({
|
||||
...bet,
|
||||
_count: { selections: bet.selections.length },
|
||||
}),
|
||||
bet.selections,
|
||||
matchContext,
|
||||
);
|
||||
|
||||
return {
|
||||
...base,
|
||||
requestId: bet.requestId,
|
||||
createdAt: bet.createdAt,
|
||||
updatedAt: bet.updatedAt,
|
||||
selections: bet.selections.map((s) => ({
|
||||
id: s.id.toString(),
|
||||
matchId: s.matchId?.toString() ?? null,
|
||||
marketType: s.marketType,
|
||||
period: s.period,
|
||||
selectionName: s.selectionNameSnapshot,
|
||||
handicapLine: s.handicapLine ? this.dec(s.handicapLine) : null,
|
||||
totalLine: s.totalLine ? this.dec(s.totalLine) : null,
|
||||
odds: this.dec(s.odds),
|
||||
resultStatus: s.resultStatus,
|
||||
effectiveOdds: s.effectiveOdds ? this.dec(s.effectiveOdds) : null,
|
||||
sortOrder: s.sortOrder,
|
||||
})),
|
||||
selections: bet.selections.map((s, index) => {
|
||||
const preview = selectionPreviews[index];
|
||||
return {
|
||||
id: s.id.toString(),
|
||||
matchId: s.matchId?.toString() ?? null,
|
||||
matchLabel: preview?.matchLabel ?? '—',
|
||||
leagueName: preview?.leagueName ?? '',
|
||||
marketType: s.marketType,
|
||||
period: s.period,
|
||||
selectionName: s.selectionNameSnapshot,
|
||||
handicapLine: s.handicapLine ? this.dec(s.handicapLine) : null,
|
||||
totalLine: s.totalLine ? this.dec(s.totalLine) : null,
|
||||
odds: this.dec(s.odds),
|
||||
resultStatus: s.resultStatus,
|
||||
effectiveOdds: s.effectiveOdds ? this.dec(s.effectiveOdds) : null,
|
||||
sortOrder: s.sortOrder,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
54
apps/api/src/domains/betting/betting-limits.service.spec.ts
Normal file
54
apps/api/src/domains/betting/betting-limits.service.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { BettingLimitsService } from './betting-limits.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
|
||||
describe('BettingLimitsService', () => {
|
||||
const prisma = {
|
||||
systemConfig: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
bet: {
|
||||
aggregate: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const service = new BettingLimitsService(prisma as never);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
prisma.systemConfig.findUnique.mockResolvedValue(null);
|
||||
prisma.bet.aggregate.mockResolvedValue({ _sum: { stake: new Decimal(0) } });
|
||||
});
|
||||
|
||||
it('rejects stake below minimum', async () => {
|
||||
await expect(
|
||||
service.validateBet({
|
||||
userId: BigInt(1),
|
||||
betType: 'SINGLE',
|
||||
stake: 0.5,
|
||||
potentialReturn: new Decimal(1),
|
||||
}),
|
||||
).rejects.toThrow('Minimum stake is 1');
|
||||
});
|
||||
|
||||
it('rejects stake above single max', async () => {
|
||||
await expect(
|
||||
service.validateBet({
|
||||
userId: BigInt(1),
|
||||
betType: 'SINGLE',
|
||||
stake: 60000,
|
||||
potentialReturn: new Decimal(70000),
|
||||
}),
|
||||
).rejects.toThrow('Maximum stake is 50000');
|
||||
});
|
||||
|
||||
it('rejects potential return above payout cap', async () => {
|
||||
await expect(
|
||||
service.validateBet({
|
||||
userId: BigInt(1),
|
||||
betType: 'SINGLE',
|
||||
stake: 100,
|
||||
potentialReturn: new Decimal(600000),
|
||||
}),
|
||||
).rejects.toThrow('Potential return exceeds limit');
|
||||
});
|
||||
});
|
||||
126
apps/api/src/domains/betting/betting-limits.service.ts
Normal file
126
apps/api/src/domains/betting/betting-limits.service.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
|
||||
export type BettingLimits = {
|
||||
minStake: number;
|
||||
maxStakeSingle: number;
|
||||
maxStakeParlay: number;
|
||||
maxPayoutSingle: number;
|
||||
maxPayoutParlay: number;
|
||||
dailyStakeLimit: number;
|
||||
};
|
||||
|
||||
export const BET_LIMIT_KEYS = {
|
||||
minStake: 'bet.min_stake',
|
||||
maxStakeSingle: 'bet.max_stake_single',
|
||||
maxStakeParlay: 'bet.max_stake_parlay',
|
||||
maxPayoutSingle: 'bet.max_payout_single',
|
||||
maxPayoutParlay: 'bet.max_payout_parlay',
|
||||
dailyStakeLimit: 'bet.daily_stake_limit',
|
||||
} as const;
|
||||
|
||||
const DEFAULTS: BettingLimits = {
|
||||
minStake: 1,
|
||||
maxStakeSingle: 50000,
|
||||
maxStakeParlay: 20000,
|
||||
maxPayoutSingle: 500000,
|
||||
maxPayoutParlay: 1000000,
|
||||
dailyStakeLimit: 200000,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BettingLimitsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
private async getNumber(key: string, fallback: number): Promise<number> {
|
||||
const row = await this.prisma.systemConfig.findUnique({ where: { configKey: key } });
|
||||
if (!row) return fallback;
|
||||
const n = Number(row.configValue);
|
||||
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
||||
}
|
||||
|
||||
private async setNumber(key: string, value: number, description: string) {
|
||||
await this.prisma.systemConfig.upsert({
|
||||
where: { configKey: key },
|
||||
create: { configKey: key, configValue: String(value), description },
|
||||
update: { configValue: String(value) },
|
||||
});
|
||||
}
|
||||
|
||||
async getLimits(): Promise<BettingLimits> {
|
||||
return {
|
||||
minStake: await this.getNumber(BET_LIMIT_KEYS.minStake, DEFAULTS.minStake),
|
||||
maxStakeSingle: await this.getNumber(BET_LIMIT_KEYS.maxStakeSingle, DEFAULTS.maxStakeSingle),
|
||||
maxStakeParlay: await this.getNumber(BET_LIMIT_KEYS.maxStakeParlay, DEFAULTS.maxStakeParlay),
|
||||
maxPayoutSingle: await this.getNumber(BET_LIMIT_KEYS.maxPayoutSingle, DEFAULTS.maxPayoutSingle),
|
||||
maxPayoutParlay: await this.getNumber(BET_LIMIT_KEYS.maxPayoutParlay, DEFAULTS.maxPayoutParlay),
|
||||
dailyStakeLimit: await this.getNumber(BET_LIMIT_KEYS.dailyStakeLimit, DEFAULTS.dailyStakeLimit),
|
||||
};
|
||||
}
|
||||
|
||||
async updateLimits(data: Partial<BettingLimits>): Promise<BettingLimits> {
|
||||
const desc: Record<keyof BettingLimits, string> = {
|
||||
minStake: '最小单注金额',
|
||||
maxStakeSingle: '单关最大投注额',
|
||||
maxStakeParlay: '串关最大投注额',
|
||||
maxPayoutSingle: '单关最高派彩',
|
||||
maxPayoutParlay: '串关最高派彩',
|
||||
dailyStakeLimit: '玩家每日投注上限',
|
||||
};
|
||||
for (const [field, key] of Object.entries(BET_LIMIT_KEYS) as Array<
|
||||
[keyof BettingLimits, string]
|
||||
>) {
|
||||
const val = data[field];
|
||||
if (val !== undefined) {
|
||||
await this.setNumber(key, val, desc[field]);
|
||||
}
|
||||
}
|
||||
return this.getLimits();
|
||||
}
|
||||
|
||||
async validateBet(params: {
|
||||
userId: bigint;
|
||||
betType: 'SINGLE' | 'PARLAY';
|
||||
stake: number;
|
||||
potentialReturn: Decimal;
|
||||
}) {
|
||||
const limits = await this.getLimits();
|
||||
const stake = params.stake;
|
||||
|
||||
if (stake < limits.minStake) {
|
||||
throw new BadRequestException(`Minimum stake is ${limits.minStake}`);
|
||||
}
|
||||
|
||||
const maxStake = params.betType === 'PARLAY' ? limits.maxStakeParlay : limits.maxStakeSingle;
|
||||
if (stake > maxStake) {
|
||||
throw new BadRequestException(`Maximum stake is ${maxStake}`);
|
||||
}
|
||||
|
||||
const maxPayout =
|
||||
params.betType === 'PARLAY' ? limits.maxPayoutParlay : limits.maxPayoutSingle;
|
||||
if (params.potentialReturn.gt(maxPayout)) {
|
||||
throw new BadRequestException(`Potential return exceeds limit of ${maxPayout}`);
|
||||
}
|
||||
|
||||
if (limits.dailyStakeLimit > 0) {
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date();
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
const agg = await this.prisma.bet.aggregate({
|
||||
where: {
|
||||
userId: params.userId,
|
||||
placedAt: { gte: startOfDay, lte: endOfDay },
|
||||
status: { notIn: ['VOID', 'CANCELLED'] },
|
||||
},
|
||||
_sum: { stake: true },
|
||||
});
|
||||
const todayStake = new Decimal(agg._sum.stake ?? 0);
|
||||
if (todayStake.add(stake).gt(limits.dailyStakeLimit)) {
|
||||
throw new BadRequestException(`Daily stake limit of ${limits.dailyStakeLimit} exceeded`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user