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,8 +1,9 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { WalletService } from '../ledger/wallet.service';
import { AgentsService } from '../agent/agents.service';
import { Decimal } from '@prisma/client/runtime/library';
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
import { generateBatchNo } from '../../shared/common/decorators';
import {
settleSelection,
@@ -95,14 +96,14 @@ export class SettlementService {
const match = await this.prisma.match.findFirst({
where: { id: matchId, deletedAt: null },
});
if (!match) throw new NotFoundException('Match not found');
if (!match) throw appNotFound('MATCH_NOT_FOUND');
if (match.isOutright) {
if (!winnerTeamId) {
throw new BadRequestException('冠军盘结算需指定获胜球队 winnerTeamId');
throw appBadRequest('SETTLEMENT_WINNER_REQUIRED');
}
const team = await this.prisma.team.findUnique({ where: { id: winnerTeamId } });
if (!team) throw new BadRequestException('获胜球队不存在');
if (!team) throw appBadRequest('SETTLEMENT_WINNER_NOT_FOUND');
const outrightSel = await this.prisma.marketSelection.findFirst({
where: {
market: { matchId, marketType: 'OUTRIGHT_WINNER' },
@@ -110,7 +111,7 @@ export class SettlementService {
},
});
if (!outrightSel) {
throw new BadRequestException('该球队不在本冠军盘选项中');
throw appBadRequest('SETTLEMENT_WINNER_NOT_IN_MARKET');
}
}
@@ -149,7 +150,7 @@ export class SettlementService {
opts?: { page?: number; pageSize?: number },
) {
const score = await this.prisma.matchScore.findUnique({ where: { matchId } });
if (!score) throw new BadRequestException('Score not recorded');
if (!score) throw appBadRequest('SCORE_NOT_RECORDED');
const computation = await this.computePreviewComputation(matchId);
const batch = await this.prisma.settlementBatch.create({
@@ -176,9 +177,9 @@ export class SettlementService {
opts?: { page?: number; pageSize?: number },
) {
const batch = await this.prisma.settlementBatch.findUnique({ where: { id: batchId } });
if (!batch) throw new NotFoundException('Batch not found');
if (!batch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND');
if (batch.status !== 'PREVIEW') {
throw new BadRequestException('Batch is not in preview');
throw appBadRequest('SETTLEMENT_BATCH_NOT_PREVIEW');
}
const computation = await this.computePreviewComputation(batch.matchId);
@@ -270,7 +271,7 @@ export class SettlementService {
private async computePreviewComputation(matchId: bigint) {
const score = await this.prisma.matchScore.findUnique({ where: { matchId } });
if (!score) throw new BadRequestException('Score not recorded');
if (!score) throw appBadRequest('SCORE_NOT_RECORDED');
const scoreInput: ScoreInput = {
htHome: score.htHomeScore ?? 0,
@@ -484,13 +485,13 @@ export class SettlementService {
where: { id: batchId },
include: { match: true },
});
if (!batch) throw new NotFoundException('Batch not found');
if (batch.status !== 'PREVIEW') throw new BadRequestException('Batch already confirmed');
if (!batch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND');
if (batch.status !== 'PREVIEW') throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED');
const score = await this.prisma.matchScore.findUnique({
where: { matchId: batch.matchId },
});
if (!score) throw new BadRequestException('Score not found');
if (!score) throw appBadRequest('SCORE_NOT_FOUND');
const scoreInput: ScoreInput = {
htHome: score.htHomeScore ?? 0,
@@ -708,7 +709,7 @@ export class SettlementService {
const match = await this.prisma.match.findFirst({
where: { id: matchId, deletedAt: null },
});
if (!match) throw new NotFoundException('Match not found');
if (!match) throw appNotFound('MATCH_NOT_FOUND');
const legs = await this.prisma.betSelection.findMany({
where: { matchId },
@@ -908,7 +909,7 @@ export class SettlementService {
legUpdates.set(sel.id.toString(), result);
} else {
if (!sel.resultStatus) {
throw new BadRequestException(`Parlay bet ${bet.id} has unsettled legs`);
throw appBadRequest('PARLAY_UNSETTLED_LEGS', { betId: bet.id.toString() });
}
result = sel.resultStatus as SelectionResult;
}
@@ -936,9 +937,9 @@ export class SettlementService {
const match = await this.prisma.match.findFirst({
where: { id: matchId, deletedAt: null },
});
if (!match) throw new NotFoundException('Match not found');
if (!match) throw appNotFound('MATCH_NOT_FOUND');
if (match.status !== 'SETTLED') {
throw new BadRequestException('Only settled matches can be resettled');
throw appBadRequest('RESETTLE_SETTLED_ONLY');
}
const winnerTeamCode = winnerTeamId
@@ -1027,9 +1028,9 @@ export class SettlementService {
where: { id: batchId },
include: { match: true },
});
if (!batch) throw new NotFoundException('Batch not found');
if (!batch.isResettle) throw new BadRequestException('Not a resettle batch');
if (batch.status !== 'PREVIEW') throw new BadRequestException('Batch already confirmed');
if (!batch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND');
if (!batch.isResettle) throw appBadRequest('RESETTLE_BATCH_ONLY');
if (batch.status !== 'PREVIEW') throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED');
const scoreInput: ScoreInput = {
htHome: batch.htHomeScore ?? 0,