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, BadRequestException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../../shared/prisma/prisma.service';
import { WalletService } from '../../ledger/wallet.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateBatchNo } from '../../../shared/common/decorators';
import { appBadRequest } from '../../../shared/common/app-error';
import {
resolveCashbackRateForBet,
type CashbackRuleRow,
@@ -214,14 +215,14 @@ export class CashbackService {
const start = this.normalizePeriodStart(periodStart);
const end = this.normalizePeriodEnd(periodEnd);
if (start > end) {
throw new BadRequestException('开始日期不能晚于结束日期');
throw appBadRequest('CASHBACK_DATE_RANGE_INVALID');
}
const alreadyPaid = await this.prisma.cashbackBatch.findFirst({
where: { status: 'CONFIRMED', periodStart: start, periodEnd: end },
});
if (alreadyPaid) {
throw new BadRequestException('该统计周期已发放返水,不可重复生成预览');
throw appBadRequest('CASHBACK_ALREADY_ISSUED');
}
const {
@@ -236,9 +237,9 @@ export class CashbackService {
if (items.length === 0 || totalAmount.lte(0)) {
if (eligibleBetCount > 0 && skippedClaimedCount >= eligibleBetCount) {
throw new BadRequestException('该周期内的有效注单均已计入其他返水批次,无法生成预览');
throw appBadRequest('CASHBACK_BETS_IN_OTHER_BATCH');
}
throw new BadRequestException('该周期内无符合条件的返水,无法生成预览');
throw appBadRequest('CASHBACK_NO_ELIGIBLE_BETS');
}
let batch!: Awaited<ReturnType<typeof this.prisma.cashbackBatch.create>>;
@@ -345,7 +346,7 @@ export class CashbackService {
items: { orderBy: { amount: 'desc' } },
},
});
if (!batch) throw new BadRequestException('Batch not found');
if (!batch) throw appBadRequest('CASHBACK_BATCH_NOT_FOUND');
const userIds = batch.items.map((i) => i.userId);
const users =
@@ -403,10 +404,10 @@ export class CashbackService {
where: { id: batchId },
include: { items: true, bets: true },
});
if (!batch) throw new BadRequestException('Batch not found');
if (batch.status !== 'PREVIEW') throw new BadRequestException('该批次不可发放');
if (!batch) throw appBadRequest('CASHBACK_BATCH_NOT_FOUND');
if (batch.status !== 'PREVIEW') throw appBadRequest('CASHBACK_BATCH_NOT_ISSUABLE');
if (batch.items.length === 0 || batch.totalAmount.lte(0)) {
throw new BadRequestException('批次无有效返水金额');
throw appBadRequest('CASHBACK_NO_AMOUNT');
}
const duplicate = await this.prisma.cashbackBatch.findFirst({
@@ -418,7 +419,7 @@ export class CashbackService {
},
});
if (duplicate) {
throw new BadRequestException('该统计周期已发放返水');
throw appBadRequest('CASHBACK_PERIOD_ALREADY_ISSUED');
}
const betIds = batch.bets.map((b) => b.betId);
@@ -430,7 +431,7 @@ export class CashbackService {
},
});
if (conflict) {
throw new BadRequestException('部分注单已在其他批次发放返水,请作废本预览后重新生成');
throw appBadRequest('CASHBACK_BETS_ALREADY_PAID');
}
}
@@ -457,9 +458,9 @@ export class CashbackService {
async cancelBatch(batchId: bigint) {
const batch = await this.prisma.cashbackBatch.findUnique({ where: { id: batchId } });
if (!batch) throw new BadRequestException('Batch not found');
if (!batch) throw appBadRequest('CASHBACK_BATCH_NOT_FOUND');
if (batch.status !== 'PREVIEW') {
throw new BadRequestException('只能作废待发放批次');
throw appBadRequest('CASHBACK_PREVIEW_ONLY_VOID');
}
await this.prisma.$transaction(async (tx) => {