139 lines
4.6 KiB
TypeScript
139 lines
4.6 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import { Decimal } from '@prisma/client/runtime/library';
|
|
import { PrismaService } from '../../shared/prisma/prisma.service';
|
|
import { appBadRequest } from '../../shared/common/app-error';
|
|
|
|
type TxClient = Prisma.TransactionClient;
|
|
type PrismaClientLike = PrismaService | TxClient;
|
|
|
|
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,
|
|
client: PrismaClientLike = this.prisma,
|
|
): Promise<number> {
|
|
const row = await client.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(tx?: TxClient): Promise<BettingLimits> {
|
|
const client = tx ?? this.prisma;
|
|
return {
|
|
minStake: await this.getNumber(BET_LIMIT_KEYS.minStake, DEFAULTS.minStake, client),
|
|
maxStakeSingle: await this.getNumber(BET_LIMIT_KEYS.maxStakeSingle, DEFAULTS.maxStakeSingle, client),
|
|
maxStakeParlay: await this.getNumber(BET_LIMIT_KEYS.maxStakeParlay, DEFAULTS.maxStakeParlay, client),
|
|
maxPayoutSingle: await this.getNumber(BET_LIMIT_KEYS.maxPayoutSingle, DEFAULTS.maxPayoutSingle, client),
|
|
maxPayoutParlay: await this.getNumber(BET_LIMIT_KEYS.maxPayoutParlay, DEFAULTS.maxPayoutParlay, client),
|
|
dailyStakeLimit: await this.getNumber(BET_LIMIT_KEYS.dailyStakeLimit, DEFAULTS.dailyStakeLimit, client),
|
|
};
|
|
}
|
|
|
|
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;
|
|
tx?: TxClient;
|
|
}) {
|
|
const client = params.tx ?? this.prisma;
|
|
const limits = await this.getLimits(params.tx);
|
|
const stake = params.stake;
|
|
|
|
if (stake < limits.minStake) {
|
|
throw appBadRequest('MIN_STAKE', { minStake: limits.minStake });
|
|
}
|
|
|
|
const maxStake = params.betType === 'PARLAY' ? limits.maxStakeParlay : limits.maxStakeSingle;
|
|
if (stake > maxStake) {
|
|
throw appBadRequest('MAX_STAKE', { maxStake });
|
|
}
|
|
|
|
const maxPayout =
|
|
params.betType === 'PARLAY' ? limits.maxPayoutParlay : limits.maxPayoutSingle;
|
|
if (params.potentialReturn.gt(maxPayout)) {
|
|
throw appBadRequest('MAX_PAYOUT', { 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 client.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 appBadRequest('DAILY_STAKE_LIMIT', { limit: limits.dailyStakeLimit });
|
|
}
|
|
}
|
|
}
|
|
}
|