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:
2026-06-10 13:36:38 +08:00
parent 03f54ca689
commit 641c92a5f5
23 changed files with 1059 additions and 234 deletions

View File

@@ -1,12 +1,11 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { MarketsService } from '../odds/markets.service';
import { WC2026_LEAGUE_CODE, WC2026_OUTRIGHT_TEAMS } from './wc2026-outright-teams';
import { syncWc2026OutrightMarket } from './wc2026-outright.sync';
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
const PLACEHOLDER_TEAM_CODE = 'OUT';
const OUTRIGHT_MARKET_TYPE = 'OUTRIGHT_WINNER';
@@ -200,7 +199,7 @@ export class OutrightService {
const league = await this.prisma.league.findUnique({
where: { id: leagueId },
});
if (!league) throw new NotFoundException('League not found');
if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
const [leagueZh, leagueEn, leagueMs] = await Promise.all([
this.getTranslation('LEAGUE', leagueId, 'zh-CN'),
this.getTranslation('LEAGUE', leagueId, 'en-US'),
@@ -319,7 +318,7 @@ export class OutrightService {
const league = await this.prisma.league.findUnique({
where: { id: data.leagueId },
});
if (!league) throw new NotFoundException('League not found');
if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
const placeholder = await this.ensurePlaceholderTeam();
const status = data.status ?? 'PUBLISHED';
@@ -408,10 +407,10 @@ export class OutrightService {
},
) {
if (!data.teamCode?.trim()) {
throw new BadRequestException('Team code required');
throw appBadRequest('TEAM_CODE_REQUIRED');
}
if (data.odds <= 1) {
throw new BadRequestException('Odds must be greater than 1');
throw appBadRequest('ODDS_MIN');
}
const match = await this.getOutrightMatchOrThrow(matchId);
@@ -452,7 +451,7 @@ export class OutrightService {
});
return this.getForAdmin(matchId);
}
throw new BadRequestException('Selection already exists for this team code');
throw appBadRequest('OUTRIGHT_SELECTION_EXISTS');
}
const maxSort = await this.prisma.marketSelection.aggregate({
@@ -485,7 +484,7 @@ export class OutrightService {
}>,
) {
if (!items.length) {
throw new BadRequestException('At least one team required');
throw appBadRequest('OUTRIGHT_TEAMS_REQUIRED');
}
let added = 0;
let skipped = 0;
@@ -521,7 +520,7 @@ export class OutrightService {
const sel = await this.prisma.marketSelection.findFirst({
where: { id: selectionId, marketId: market.id },
});
if (!sel) throw new NotFoundException('Selection not found');
if (!sel) throw appNotFound('SELECTION_NOT_FOUND');
const nextCode = data.teamCode?.trim().toUpperCase() || sel.selectionCode;
if (nextCode !== sel.selectionCode) {
@@ -533,7 +532,7 @@ export class OutrightService {
},
});
if (dup) {
throw new BadRequestException('Selection already exists for this team code');
throw appBadRequest('OUTRIGHT_SELECTION_EXISTS');
}
await this.prisma.marketSelection.update({
where: { id: selectionId },
@@ -583,7 +582,7 @@ export class OutrightService {
const sel = await this.prisma.marketSelection.findFirst({
where: { id: selectionId, marketId: market.id },
});
if (!sel) throw new NotFoundException('Selection not found');
if (!sel) throw appNotFound('SELECTION_NOT_FOUND');
await this.prisma.marketSelection.update({
where: { id: selectionId },
data: { status: 'CLOSED' },
@@ -609,7 +608,7 @@ export class OutrightService {
for (const u of updates) {
if (!allowed.has(u.selectionId)) {
throw new BadRequestException('Invalid selection for this outright event');
throw appBadRequest('OUTRIGHT_SELECTION_INVALID');
}
}
@@ -740,7 +739,7 @@ export class OutrightService {
const match = await this.prisma.match.findFirst({
where: { id: matchId, isOutright: true, deletedAt: null },
});
if (!match) throw new NotFoundException('Outright event not found');
if (!match) throw appNotFound('OUTRIGHT_EVENT_NOT_FOUND');
return match;
}