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