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 { 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 { 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): Promise { const desc: Record = { 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 }); } } } }