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 { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, BadRequestException } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import { SUPPORTED_LOCALES, isValidAvatarKey, assertPlayerUsername } from '@thebet365/shared';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
import { AgentsService } from '../agent/agents.service';
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
export type PlayerListFilters = {
keyword?: string;
@@ -118,23 +119,23 @@ export class UsersService {
where: { id: userId },
include: { preferences: true },
});
if (!user) throw new NotFoundException('User not found');
if (!user) throw appNotFound('USER_NOT_FOUND');
if (data.username !== undefined) {
const nextUsername = data.username.trim();
if (!nextUsername) throw new BadRequestException('账号名称不能为空');
if (!nextUsername) throw appBadRequest('USERNAME_REQUIRED');
try {
assertPlayerUsername(nextUsername);
} catch (e) {
throw new BadRequestException(e instanceof Error ? e.message : '玩家用户名格式无效');
} catch {
throw appBadRequest('USERNAME_FORMAT_INVALID');
}
const settings = await this.systemConfig.getPlayerAccountSettings();
if (!settings.allowUsernameChange) {
throw new ForbiddenException('当前平台未开放玩家自行修改账号名称');
throw appForbidden('USERNAME_CHANGE_DISABLED');
}
if (nextUsername !== user.username) {
const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } });
if (taken) throw new BadRequestException('账号名称已被占用');
if (taken) throw appBadRequest('USERNAME_TAKEN');
await this.prisma.user.update({
where: { id: userId },
data: { username: nextUsername },
@@ -148,7 +149,7 @@ export class UsersService {
if (data.avatarKey !== undefined) {
avatarKey = data.avatarKey?.trim() || null;
if (avatarKey && !isValidAvatarKey(avatarKey)) {
throw new BadRequestException('无效头像');
throw appBadRequest('INVALID_AVATAR');
}
}
@@ -176,7 +177,7 @@ export class UsersService {
async updateLocale(userId: bigint, locale: string) {
if (!(SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
throw new BadRequestException('Unsupported locale');
throw appBadRequest('UNSUPPORTED_LOCALE');
}
await this.prisma.user.update({
where: { id: userId },
@@ -266,7 +267,7 @@ export class UsersService {
auth: { select: { lastLoginAt: true, loginFailCount: true, lockedUntil: true } },
},
});
if (!user) throw new NotFoundException('玩家不存在');
if (!user) throw appNotFound('PLAYER_NOT_FOUND');
const [betCount, betStake] = await Promise.all([
this.prisma.bet.count({ where: { userId: playerId } }),
@@ -302,23 +303,23 @@ export class UsersService {
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
include: { auth: true },
});
if (!user) throw new NotFoundException('玩家不存在');
if (!user) throw appNotFound('PLAYER_NOT_FOUND');
if (data.status && !['ACTIVE', 'SUSPENDED'].includes(data.status)) {
throw new BadRequestException('无效状态');
throw appBadRequest('INVALID_STATUS');
}
if (data.username !== undefined) {
const nextUsername = data.username.trim();
if (!nextUsername) throw new BadRequestException('账号名称不能为空');
if (!nextUsername) throw appBadRequest('USERNAME_REQUIRED');
try {
assertPlayerUsername(nextUsername);
} catch (e) {
throw new BadRequestException(e instanceof Error ? e.message : '玩家用户名格式无效');
} catch {
throw appBadRequest('USERNAME_FORMAT_INVALID');
}
if (nextUsername !== user.username) {
const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } });
if (taken) throw new BadRequestException('账号名称已被占用');
if (taken) throw appBadRequest('USERNAME_TAKEN');
await this.prisma.user.update({
where: { id: playerId },
data: { username: nextUsername },
@@ -328,8 +329,8 @@ export class UsersService {
if (data.password !== undefined) {
const nextPassword = data.password;
if (nextPassword.length < 8) throw new BadRequestException('密码至少 8 位');
if (!user.auth) throw new BadRequestException('账号认证信息缺失');
if (nextPassword.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
if (!user.auth) throw appBadRequest('AUTH_INFO_MISSING');
const hash = await bcrypt.hash(nextPassword, 10);
await this.prisma.userAuth.update({
where: { userId: playerId },