feat(admin,api,player): 结算预览分页、统计图表与返水限额

完善结算计算与预览 API(含后端分页),加强管理端结算/返水/权限,并优化玩家端投注单与队徽展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-05 13:54:33 +08:00
parent 6264b8806c
commit efff7c27e6
40 changed files with 3560 additions and 578 deletions

View 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`);
}
}
}
}