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) => {

View File

@@ -1,9 +1,9 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../../shared/prisma/prisma.service';
import { appBadRequest, appNotFound } from '../../../shared/common/app-error';
export const CONTENT_TYPES = ['BANNER', 'NOTICE', 'TICKER'] as const;
export type ContentType = (typeof CONTENT_TYPES)[number];
@@ -50,21 +50,21 @@ export class ContentService {
private assertContentType(type: string): ContentType {
if (!CONTENT_TYPES.includes(type as ContentType)) {
throw new BadRequestException(`Invalid contentType: ${type}`);
throw appBadRequest('CONTENT_TYPE_INVALID', { type });
}
return type as ContentType;
}
private assertStatus(status: string): ContentStatus {
if (!CONTENT_STATUSES.includes(status as ContentStatus)) {
throw new BadRequestException(`Invalid status: ${status}`);
throw appBadRequest('CONTENT_STATUS_INVALID', { status });
}
return status as ContentStatus;
}
private validateSchedule(startTime?: Date | null, endTime?: Date | null) {
if (startTime && endTime && endTime <= startTime) {
throw new BadRequestException('endTime must be after startTime');
throw appBadRequest('CONTENT_END_BEFORE_START');
}
}
@@ -74,16 +74,16 @@ export class ContentService {
status: ContentStatus,
) {
if (!translations.length) {
throw new BadRequestException('At least one translation required');
throw appBadRequest('CONTENT_TRANSLATION_REQUIRED');
}
const locales = new Set<string>();
for (const tr of translations) {
if (!tr.locale?.trim()) {
throw new BadRequestException('Translation locale required');
throw appBadRequest('CONTENT_LOCALE_REQUIRED');
}
if (locales.has(tr.locale)) {
throw new BadRequestException(`Duplicate locale: ${tr.locale}`);
throw appBadRequest('CONTENT_LOCALE_DUPLICATE', { locale: tr.locale });
}
locales.add(tr.locale);
}
@@ -101,12 +101,12 @@ export class ContentService {
});
if (!hasUsable) {
throw new BadRequestException(
throw appBadRequest(
contentType === 'BANNER'
? 'ACTIVE banner requires imageUrl in at least one locale'
? 'CONTENT_ACTIVE_BANNER_INCOMPLETE'
: contentType === 'NOTICE'
? 'ACTIVE notice requires title or body in at least one locale'
: 'ACTIVE ticker requires body in at least one locale',
? 'CONTENT_ACTIVE_NOTICE_INCOMPLETE'
: 'CONTENT_ACTIVE_TICKER_INCOMPLETE',
);
}
}
@@ -114,10 +114,10 @@ export class ContentService {
private validateLink(linkType?: string | null, linkTarget?: string | null) {
if (!linkType) return;
if (!CONTENT_LINK_TYPES.includes(linkType as ContentLinkType)) {
throw new BadRequestException(`Invalid linkType: ${linkType}`);
throw appBadRequest('CONTENT_LINK_TYPE_INVALID', { linkType });
}
if (!linkTarget?.trim()) {
throw new BadRequestException('linkTarget required when linkType is set');
throw appBadRequest('CONTENT_LINK_TARGET_REQUIRED');
}
}
@@ -197,7 +197,7 @@ export class ContentService {
where: { id },
include: { translations: true },
});
if (!item) throw new NotFoundException('Content not found');
if (!item) throw appNotFound('CONTENT_NOT_FOUND');
return item;
}