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,9 +1,10 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { isPreMatchKickoff, resolveTranslationFallback } from '@thebet365/shared';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Prisma } from '@prisma/client';
import { Decimal } from '@prisma/client/runtime/library';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
export type MatchBetStatsSummary = {
betCount: number;
@@ -218,7 +219,7 @@ export class MatchesService {
const leagueEn = data.leagueEn.trim();
const leagueZh = data.leagueZh.trim();
if (!leagueEn && !leagueZh) {
throw new BadRequestException('请填写赛事名称(中文或英文至少一项)');
throw appBadRequest('LEAGUE_NAME_REQUIRED');
}
const league = await this.upsertLeagueFromZhiboExport({
type: 'FOOTBALL',
@@ -268,12 +269,12 @@ export class MatchesService {
const league = await this.prisma.league.findFirst({
where: { id: leagueId, deletedAt: null },
});
if (!league) throw new NotFoundException('赛事不存在');
if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
const leagueEn = data.leagueEn.trim();
const leagueZh = data.leagueZh.trim();
if (!leagueEn && !leagueZh) {
throw new BadRequestException('请填写赛事名称(中文或英文至少一项)');
throw appBadRequest('LEAGUE_NAME_REQUIRED');
}
await this.upsertEntityTranslations('LEAGUE', leagueId, {
@@ -289,7 +290,7 @@ export class MatchesService {
if (data.displayOrder != null) updates.displayOrder = data.displayOrder;
if (data.isActive !== undefined) {
if (league.isActive && data.isActive === false) {
throw new BadRequestException('已发布的联赛不可下架');
throw appBadRequest('LEAGUE_UNPUBLISH_FORBIDDEN');
}
updates.isActive = data.isActive;
}
@@ -639,7 +640,7 @@ export class MatchesService {
logoUrl?: string;
}) {
const code = data.code.trim().toUpperCase();
if (!code) throw new BadRequestException('请填写球队代码');
if (!code) throw appBadRequest('TEAM_CODE_REQUIRED');
const logoUrl = data.logoUrl?.trim() || undefined;
const team = await this.prisma.team.upsert({
where: { code },
@@ -686,7 +687,7 @@ export class MatchesService {
const awayZh = data.awayTeamZh.trim();
const awayMs = data.awayTeamMs?.trim() ?? '';
if ((!homeEn && !homeZh && !homeMs) || (!awayEn && !awayZh && !awayMs)) {
throw new BadRequestException('请填写主客队名称(中文、英文或马来文至少一项)');
throw appBadRequest('TEAMS_NAME_REQUIRED');
}
let league;
@@ -694,13 +695,13 @@ export class MatchesService {
league = await this.prisma.league.findFirst({
where: { id: data.leagueId, deletedAt: null },
});
if (!league) throw new NotFoundException('赛事不存在');
if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
} else {
const leagueEn = data.leagueEn?.trim() ?? '';
const leagueZh = data.leagueZh?.trim() ?? '';
const leagueMs = data.leagueMs?.trim() ?? '';
if (!leagueEn && !leagueZh && !leagueMs) {
throw new BadRequestException('请填写赛事名称(中文、英文或马来文至少一项)');
throw appBadRequest('LEAGUE_NAME_REQUIRED');
}
league = await this.upsertLeagueFromZhiboExport({
type: 'FOOTBALL',
@@ -725,7 +726,7 @@ export class MatchesService {
let awayTeam;
if (homeCode && awayCode) {
if (homeCode === awayCode) {
throw new BadRequestException('主客队不能为同一支球队,请选择不同的队伍');
throw appBadRequest('TEAMS_SAME');
}
homeTeam = await this.upsertTeamByCode({
code: homeCode,
@@ -771,7 +772,7 @@ export class MatchesService {
}
if (homeTeam.id === awayTeam.id) {
throw new BadRequestException('主客队不能为同一支球队,请填写不同的队名');
throw appBadRequest('TEAMS_SAME');
}
const matchName =
@@ -800,7 +801,7 @@ export class MatchesService {
where: { id: matchId, deletedAt: null },
include: { homeTeam: true, awayTeam: true, league: true },
});
if (!match) throw new NotFoundException('赛事不存在');
if (!match) throw appNotFound('MATCH_NOT_FOUND');
return match;
}
@@ -902,10 +903,10 @@ export class MatchesService {
) {
const match = await this.requireAdminMatch(matchId);
if (match.isOutright) {
throw new BadRequestException('冠军盘请通过盘口管理维护');
throw appBadRequest('OUTRIGHT_EDIT_VIA_MARKETS');
}
if (!['DRAFT', 'PUBLISHED'].includes(match.status)) {
throw new BadRequestException('当前状态不可编辑');
throw appBadRequest('MATCH_NOT_EDITABLE');
}
const matchName =
@@ -961,14 +962,14 @@ export class MatchesService {
async deleteMatch(matchId: bigint) {
const match = await this.requireAdminMatch(matchId);
if (match.isOutright) {
throw new BadRequestException('冠军盘不可删除');
throw appBadRequest('OUTRIGHT_DELETE_FORBIDDEN');
}
if (match.status !== 'DRAFT') {
throw new BadRequestException('仅草稿状态可删除');
throw appBadRequest('MATCH_DELETE_DRAFT_ONLY');
}
const betCount = await this.prisma.betSelection.count({ where: { matchId } });
if (betCount > 0) {
throw new BadRequestException('该赛事已有注单关联,无法删除');
throw appBadRequest('MATCH_HAS_BETS');
}
return this.prisma.match.update({
where: { id: matchId },
@@ -1053,7 +1054,7 @@ export class MatchesService {
async importZhiboMatchesBundle(bundle: ZhiboMatchesBundleExport, createdBy?: bigint) {
if (!bundle.matches?.length) {
throw new BadRequestException('matches array is required');
throw appBadRequest('MATCHES_ARRAY_REQUIRED');
}
const results: Array<{ liveMatchId: string; id: string; status: string; skipped?: boolean; reason?: string }> = [];
@@ -1309,7 +1310,7 @@ export class MatchesService {
score: true,
},
});
if (!match) throw new NotFoundException('Match not found');
if (!match) throw appNotFound('MATCH_NOT_FOUND');
return this.enrichMatch(match, locale);
}

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;
}