feat: internationalize API error responses by locale
Add shared error codes with zh/en/ms messages, coded app exceptions, and locale-aware global filter. Frontends send X-Locale so error text matches the active UI language. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { Injectable, BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
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 { appBadRequest, appNotFound } from '../../shared/common/app-error';
|
||||
import {
|
||||
PARLAY_MIN_LEGS,
|
||||
PARLAY_MAX_LEGS,
|
||||
@@ -54,20 +55,20 @@ export class BetsService {
|
||||
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) throw appBadRequest('SELECTION_NOT_FOUND');
|
||||
if (selection.status !== 'OPEN') throw appBadRequest('SELECTION_CLOSED');
|
||||
if (selection.market.status !== 'OPEN') throw appBadRequest('MARKET_CLOSED');
|
||||
if (selection.market.match.status !== 'PUBLISHED') {
|
||||
throw new BadRequestException('Match not available for betting');
|
||||
throw appBadRequest('MATCH_NOT_BETTING');
|
||||
}
|
||||
if (!isSupportedSport(selection.market.match.sportType)) {
|
||||
throw new BadRequestException('Only football betting is supported');
|
||||
throw appBadRequest('FOOTBALL_ONLY');
|
||||
}
|
||||
if (!selection.market.match.isOutright && !isPreMatchKickoff(selection.market.match.startTime)) {
|
||||
throw new BadRequestException('Pre-match betting only; match has started');
|
||||
throw appBadRequest('PRE_MATCH_ONLY');
|
||||
}
|
||||
if (selection.oddsVersion !== oddsVersion) {
|
||||
throw new BadRequestException('Odds changed, please confirm again');
|
||||
throw appBadRequest('ODDS_CHANGED');
|
||||
}
|
||||
|
||||
if (options?.forParlay) {
|
||||
@@ -79,13 +80,13 @@ export class BetsService {
|
||||
isOutright: selection.market.match.isOutright,
|
||||
});
|
||||
if (!check.ok) {
|
||||
const msg =
|
||||
const code =
|
||||
check.reason === 'OUTRIGHT'
|
||||
? 'Outright cannot be in parlay'
|
||||
? 'PARLAY_OUTRIGHT_FORBIDDEN'
|
||||
: check.reason === 'QUARTER_LINE'
|
||||
? 'Quarter line markets cannot be in parlay'
|
||||
: 'Market not allowed in parlay';
|
||||
throw new BadRequestException(msg);
|
||||
? 'PARLAY_QUARTER_LINE_FORBIDDEN'
|
||||
: 'PARLAY_MARKET_NOT_ALLOWED';
|
||||
throw appBadRequest(code);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +101,7 @@ export class BetsService {
|
||||
stake: number,
|
||||
requestId: string,
|
||||
) {
|
||||
if (stake <= 0) throw new BadRequestException('Invalid stake');
|
||||
if (stake <= 0) throw appBadRequest('INVALID_STAKE');
|
||||
|
||||
const existing = await this.prisma.bet.findUnique({
|
||||
where: { userId_requestId: { userId, requestId } },
|
||||
@@ -162,9 +163,9 @@ export class BetsService {
|
||||
stake: number,
|
||||
requestId: string,
|
||||
) {
|
||||
if (stake <= 0) throw new BadRequestException('Invalid stake');
|
||||
if (stake <= 0) throw appBadRequest('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`);
|
||||
throw appBadRequest('PARLAY_LEG_COUNT_INVALID', { min: PARLAY_MIN_LEGS, max: PARLAY_MAX_LEGS });
|
||||
}
|
||||
|
||||
const existing = await this.prisma.bet.findUnique({
|
||||
@@ -527,7 +528,7 @@ export class BetsService {
|
||||
selections: { orderBy: { sortOrder: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!bet) throw new NotFoundException('注单不存在');
|
||||
if (!bet) throw appNotFound('BET_NOT_FOUND');
|
||||
|
||||
const matchIds = bet.selections
|
||||
.map((s) => s.matchId)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { appBadRequest } from '../../shared/common/app-error';
|
||||
|
||||
export type BettingLimits = {
|
||||
minStake: number;
|
||||
@@ -89,18 +90,18 @@ export class BettingLimitsService {
|
||||
const stake = params.stake;
|
||||
|
||||
if (stake < limits.minStake) {
|
||||
throw new BadRequestException(`Minimum stake is ${limits.minStake}`);
|
||||
throw appBadRequest('MIN_STAKE', { minStake: limits.minStake });
|
||||
}
|
||||
|
||||
const maxStake = params.betType === 'PARLAY' ? limits.maxStakeParlay : limits.maxStakeSingle;
|
||||
if (stake > maxStake) {
|
||||
throw new BadRequestException(`Maximum stake is ${maxStake}`);
|
||||
throw appBadRequest('MAX_STAKE', { maxStake });
|
||||
}
|
||||
|
||||
const maxPayout =
|
||||
params.betType === 'PARLAY' ? limits.maxPayoutParlay : limits.maxPayoutSingle;
|
||||
if (params.potentialReturn.gt(maxPayout)) {
|
||||
throw new BadRequestException(`Potential return exceeds limit of ${maxPayout}`);
|
||||
throw appBadRequest('MAX_PAYOUT', { maxPayout });
|
||||
}
|
||||
|
||||
if (limits.dailyStakeLimit > 0) {
|
||||
@@ -119,7 +120,7 @@ export class BettingLimitsService {
|
||||
});
|
||||
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`);
|
||||
throw appBadRequest('DAILY_STAKE_LIMIT', { limit: limits.dailyStakeLimit });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user