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,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user