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

@@ -0,0 +1,45 @@
import {
BadRequestException,
ForbiddenException,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import type { ApiErrorCode, ApiErrorParams } from '@thebet365/shared';
function body(code: ApiErrorCode, params?: ApiErrorParams) {
const clean = params
? Object.fromEntries(
Object.entries(params)
.filter(([, v]) => v !== undefined && v !== null)
.map(([k, v]) => [k, String(v)]),
)
: undefined;
return { code, params: clean };
}
export function appBadRequest(code: ApiErrorCode, params?: ApiErrorParams) {
return new BadRequestException(body(code, params));
}
export function appNotFound(code: ApiErrorCode, params?: ApiErrorParams) {
return new NotFoundException(body(code, params));
}
export function appForbidden(code: ApiErrorCode, params?: ApiErrorParams) {
return new ForbiddenException(body(code, params));
}
export function appUnauthorized(code: ApiErrorCode, params?: ApiErrorParams) {
return new UnauthorizedException(body(code, params));
}
export function isCodedExceptionResponse(
res: unknown,
): res is { code: ApiErrorCode; params?: ApiErrorParams } {
return (
typeof res === 'object' &&
res !== null &&
'code' in res &&
typeof (res as { code: unknown }).code === 'string'
);
}

View File

@@ -5,38 +5,61 @@ import {
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
import { Request, Response } from 'express';
import {
formatApiErrorMessage,
isApiErrorCode,
type ApiErrorCode,
type ApiErrorParams,
} from '@thebet365/shared';
import { serializeBigInt } from './decorators';
import { isCodedExceptionResponse } from './app-error';
import { resolveRequestLocale } from './resolve-request-locale';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const locale = resolveRequestLocale(request);
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
let code: ApiErrorCode = 'INTERNAL_SERVER_ERROR';
let params: ApiErrorParams | undefined;
let message = formatApiErrorMessage('INTERNAL_SERVER_ERROR', locale);
if (exception instanceof HttpException) {
status = exception.getStatus();
const res = exception.getResponse();
if (typeof res === 'string') {
if (isCodedExceptionResponse(res) && isApiErrorCode(res.code)) {
code = res.code;
params = res.params;
message = formatApiErrorMessage(code, locale, params);
} else if (typeof res === 'string') {
message = res;
} else if (typeof res === 'object' && res !== null) {
const body = res as { message?: string | string[] };
if (Array.isArray(body.message)) {
const body = res as { message?: string | string[]; code?: string; params?: ApiErrorParams };
if (body.code && isApiErrorCode(body.code)) {
code = body.code;
params = body.params;
message = formatApiErrorMessage(code, locale, params);
} else if (Array.isArray(body.message)) {
message = body.message.join('');
} else if (typeof body.message === 'string' && body.message.trim()) {
message = body.message;
}
}
} else if (exception instanceof Error) {
} else if (exception instanceof Error && exception.message.trim()) {
message = exception.message;
}
response.status(status).json({
success: false,
error: message,
code,
params: params ?? null,
data: null,
});
}

View File

@@ -0,0 +1,22 @@
import type { Request } from 'express';
import { normalizeLocale } from '@thebet365/shared';
export function resolveRequestLocale(req: Request): string {
const xLocale = req.headers['x-locale'];
if (typeof xLocale === 'string' && xLocale.trim()) {
return normalizeLocale(xLocale);
}
const queryLocale = req.query?.locale;
if (typeof queryLocale === 'string' && queryLocale.trim()) {
return normalizeLocale(queryLocale);
}
const accept = req.headers['accept-language'];
if (typeof accept === 'string' && accept.trim()) {
const first = accept.split(',')[0]?.trim();
if (first) return normalizeLocale(first);
}
return normalizeLocale(undefined);
}