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,7 +1,8 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY, PERMISSIONS_KEY } from '../../shared/common/decorators';
import { appForbidden, appUnauthorized } from '../../shared/common/app-error';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
@@ -19,7 +20,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
}
handleRequest<TUser = unknown>(err: Error | null, user: TUser): TUser {
if (err || !user) throw err || new UnauthorizedException();
if (err || !user) throw err || appUnauthorized('INVALID_CREDENTIALS');
return user;
}
}
@@ -36,17 +37,17 @@ export class PermissionsGuard implements CanActivate {
const { user } = context.switchToHttp().getRequest();
if (!user || user.userType !== 'ADMIN') {
throw new ForbiddenException('Admin access required');
throw appForbidden('ADMIN_ACCESS_REQUIRED');
}
if (user.role === 'SUPER_ADMIN') return true;
if (!required?.length) {
throw new ForbiddenException('Insufficient permissions');
throw appForbidden('INSUFFICIENT_PERMISSIONS');
}
const userPerms: string[] = user.permissions ?? [];
const hasAccess = required.some((p) => userPerms.includes(p));
if (!hasAccess) throw new ForbiddenException('Insufficient permissions');
if (!hasAccess) throw appForbidden('INSUFFICIENT_PERMISSIONS');
return true;
}
}
@@ -57,7 +58,7 @@ export function UserTypeGuard(...types: string[]) {
canActivate(context: ExecutionContext): boolean {
const { user } = context.switchToHttp().getRequest();
if (!types.includes(user?.userType)) {
throw new ForbiddenException('Access denied for this portal');
throw appForbidden('ACCESS_DENIED_PORTAL');
}
return true;
}
@@ -69,7 +70,7 @@ export function UserTypeGuard(...types: string[]) {
export class PlayerGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const { user } = context.switchToHttp().getRequest();
if (user?.userType !== 'PLAYER') throw new ForbiddenException('Player access only');
if (user?.userType !== 'PLAYER') throw appForbidden('PLAYER_ACCESS_ONLY');
return true;
}
}
@@ -78,7 +79,7 @@ export class PlayerGuard implements CanActivate {
export class AdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const { user } = context.switchToHttp().getRequest();
if (user?.userType !== 'ADMIN') throw new ForbiddenException('Admin access only');
if (user?.userType !== 'ADMIN') throw appForbidden('ADMIN_ACCESS_ONLY');
return true;
}
}
@@ -87,7 +88,7 @@ export class AdminGuard implements CanActivate {
export class AgentGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const { user } = context.switchToHttp().getRequest();
if (user?.userType !== 'AGENT') throw new ForbiddenException('Agent access only');
if (user?.userType !== 'AGENT') throw appForbidden('AGENT_ACCESS_ONLY');
return true;
}
}