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,4 @@
import {
Injectable,
BadRequestException,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { Injectable, BadRequestException } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
@@ -13,6 +8,7 @@ import { SystemConfigService } from '../../shared/config/system-config.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateBatchNo } from '../../shared/common/decorators';
import { assertPlayerUsername } from '@thebet365/shared';
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
function dec(v: Decimal | null | undefined) {
return v?.toString() ?? '0';
@@ -35,7 +31,7 @@ export class AgentsService {
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: agentId },
});
if (!profile) throw new BadRequestException('Agent profile not found');
if (!profile) throw appBadRequest('AGENT_PROFILE_NOT_FOUND');
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
return { ...profile, availableCredit: available };
}
@@ -90,11 +86,11 @@ export class AgentsService {
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: agentId },
});
if (!profile) throw new BadRequestException('Agent not found');
if (!profile) throw appBadRequest('AGENT_NOT_FOUND');
const creditBefore = profile.creditLimit;
const creditAfter = creditBefore.add(amt);
if (creditAfter.lt(0)) throw new BadRequestException('Credit limit cannot be negative');
if (creditAfter.lt(0)) throw appBadRequest('CREDIT_LIMIT_NEGATIVE');
if (profile.parentAgentId) {
await this.assertChildCreditWithinParent(profile.parentAgentId, profile, creditAfter);
@@ -133,9 +129,9 @@ export class AgentsService {
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
include: { auth: true, wallet: true, preferences: true },
});
if (!player) throw new NotFoundException('玩家不存在');
if (!player) throw appNotFound('PLAYER_NOT_FOUND');
if (player.parentId !== agentId) {
throw new ForbiddenException('Can only manage direct players');
throw appForbidden('MANAGE_DIRECT_PLAYERS_ONLY');
}
return player;
}
@@ -152,40 +148,40 @@ export class AgentsService {
const parent = await this.prisma.agentProfile.findUnique({
where: { userId: parentAgentId },
});
if (!parent) throw new BadRequestException('上级代理不存在');
if (!parent) throw appBadRequest('PARENT_AGENT_NOT_FOUND');
if (child.creditLimit !== undefined) {
const limit = new Decimal(child.creditLimit);
if (limit.lt(0)) throw new BadRequestException('授信额度不能为负');
if (limit.lt(0)) throw appBadRequest('CREDIT_LIMIT_NEGATIVE');
if (limit.gt(parent.creditLimit)) {
throw new BadRequestException('下级代理授信不能超过上级授信额度');
throw appBadRequest('CREDIT_EXCEEDS_PARENT');
}
}
if (child.cashbackRate !== undefined) {
const rate = new Decimal(child.cashbackRate);
if (rate.lt(0)) throw new BadRequestException('回水比例不能为负');
if (rate.lt(0)) throw appBadRequest('CASHBACK_RATE_NEGATIVE');
if (rate.gt(parent.cashbackRate)) {
throw new BadRequestException('下级代理回水比例不能超过上级');
throw appBadRequest('CASHBACK_RATE_EXCEEDS_PARENT');
}
}
if (child.maxSingleDeposit != null && parent.maxSingleDeposit != null) {
if (new Decimal(child.maxSingleDeposit).gt(parent.maxSingleDeposit)) {
throw new BadRequestException('下级代理单笔限额不能超过上级');
throw appBadRequest('BET_LIMIT_EXCEEDS_PARENT');
}
}
if (child.maxSingleDeposit != null && new Decimal(child.maxSingleDeposit).lt(0)) {
throw new BadRequestException('单笔限额不能为负');
throw appBadRequest('BET_LIMIT_NEGATIVE');
}
if (child.maxDailyDeposit != null && parent.maxDailyDeposit != null) {
if (new Decimal(child.maxDailyDeposit).gt(parent.maxDailyDeposit)) {
throw new BadRequestException('下级代理日限额不能超过上级');
throw appBadRequest('DAILY_LIMIT_EXCEEDS_PARENT');
}
}
if (child.maxDailyDeposit != null && new Decimal(child.maxDailyDeposit).lt(0)) {
throw new BadRequestException('日限额不能为负');
throw appBadRequest('DAILY_LIMIT_NEGATIVE');
}
}
@@ -235,7 +231,7 @@ export class AgentsService {
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
const amt = new Decimal(amount);
if (available.lt(amt)) {
throw new BadRequestException('超过玩家上级代理可用授信,无法上分');
throw appBadRequest('CREDIT_TOPUP_EXCEEDED');
}
}
@@ -299,7 +295,7 @@ export class AgentsService {
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
include: { wallet: true },
});
if (!player) throw new NotFoundException('玩家不存在');
if (!player) throw appNotFound('PLAYER_NOT_FOUND');
if (options.actingAgentId) {
await this.requireDirectPlayer(options.actingAgentId, playerId);
@@ -375,7 +371,7 @@ export class AgentsService {
const { maxSingleDeposit, maxDailyDeposit } = this.resolveEffectiveDepositLimits(profile, parent);
if (maxSingleDeposit && amount.gt(maxSingleDeposit)) {
throw new BadRequestException('超过代理单笔上分限额');
throw appBadRequest('AGENT_SINGLE_TOPUP_LIMIT');
}
if (maxDailyDeposit) {
@@ -391,7 +387,7 @@ export class AgentsService {
});
const dailyTotal = new Decimal(dailyAgg._sum.amount ?? 0).add(amount);
if (dailyTotal.gt(maxDailyDeposit)) {
throw new BadRequestException('超过代理日上分限额');
throw appBadRequest('AGENT_DAILY_TOPUP_LIMIT');
}
}
}
@@ -409,7 +405,7 @@ export class AgentsService {
const newExposure = Decimal.max(creditAfter, childProfile.usedCredit);
const exposureDelta = newExposure.sub(oldExposure);
if (exposureDelta.gt(0) && exposureDelta.gt(parentAvailable)) {
throw new BadRequestException('上级可用授信不足');
throw appBadRequest('INSUFFICIENT_AGENT_CREDIT');
}
}
@@ -427,7 +423,7 @@ export class AgentsService {
const amt = new Decimal(amount);
if (available.lt(amt)) {
throw new BadRequestException('Insufficient agent credit');
throw appBadRequest('INSUFFICIENT_AGENT_CREDIT');
}
await this.assertAgentDepositLimits(agentId, amt);
@@ -496,20 +492,20 @@ export class AgentsService {
const user = await this.requireDirectPlayer(agentId, playerId);
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 },
@@ -519,8 +515,8 @@ export class AgentsService {
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 },
@@ -688,7 +684,7 @@ export class AgentsService {
where: { userId: agentId },
include: { user: { include: { preferences: true, auth: true } } },
});
if (!profile) throw new NotFoundException('代理不存在');
if (!profile) throw appNotFound('AGENT_NOT_FOUND');
const [directPlayerCount, childAgentCount, recentCredits] = await Promise.all([
this.prisma.user.count({
@@ -912,19 +908,19 @@ export class AgentsService {
where: { userId: agentId },
include: { user: true },
});
if (!profile) throw new NotFoundException('代理不存在');
if (!profile) throw appNotFound('AGENT_NOT_FOUND');
if (data.status && !['ACTIVE', 'SUSPENDED'].includes(data.status)) {
throw new BadRequestException('无效状态');
throw appBadRequest('INVALID_STATUS');
}
// Handle username change
if (data.username !== undefined) {
const nextUsername = data.username.trim();
if (!nextUsername) throw new BadRequestException('账号名称不能为空');
if (!nextUsername) throw appBadRequest('USERNAME_REQUIRED');
if (nextUsername !== profile.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: agentId },
data: { username: nextUsername },
@@ -935,7 +931,7 @@ export class AgentsService {
// Handle password change
if (data.password !== undefined) {
const nextPassword = data.password;
if (nextPassword.length < 8) throw new BadRequestException('密码至少 8 位');
if (nextPassword.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
const hash = await bcrypt.hash(nextPassword, 10);
await this.prisma.userAuth.upsert({
where: { userId: agentId },
@@ -1082,13 +1078,13 @@ export class AgentsService {
include: { agentProfile: true, preferences: true },
});
if (!user || user.deletedAt) {
throw new NotFoundException('用户不存在');
throw appNotFound('USER_NOT_FOUND');
}
if (user.userType !== 'PLAYER') {
throw new BadRequestException('仅玩家账号可设为代理');
throw appBadRequest('PROMOTE_PLAYER_ONLY');
}
if (user.agentProfile) {
throw new BadRequestException('该用户已是代理');
throw appBadRequest('ALREADY_AGENT');
}
const oldParentId = user.parentId;
@@ -1150,7 +1146,7 @@ export class AgentsService {
const updated = await this.prisma.user.findUnique({ where: { id: userId } });
if (!updated) {
throw new NotFoundException('用户不存在');
throw appNotFound('USER_NOT_FOUND');
}
return updated;
}
@@ -1172,10 +1168,10 @@ export class AgentsService {
},
) {
if (data.level !== 1 && data.level !== 2) {
throw new BadRequestException('Agent level must be 1 or 2');
throw appBadRequest('AGENT_LEVEL_INVALID');
}
if (data.level === 2 && !data.parentAgentId) {
throw new BadRequestException('Level 2 agent requires parent');
throw appBadRequest('LEVEL2_REQUIRES_PARENT');
}
if (data.parentAgentId) {
@@ -1279,10 +1275,10 @@ export class AgentsService {
) {
if (data.asTier1Agent) {
if (data.parentId != null) {
throw new BadRequestException('一级代理不可设置上级玩家');
throw appBadRequest('TIER1_NO_PARENT_PLAYER');
}
if (data.initialDeposit && data.initialDeposit > 0) {
throw new BadRequestException('设为代理时请使用授信额度,勿填玩家初始余额');
throw appBadRequest('PROMOTE_USE_CREDIT_NOT_BALANCE');
}
return this.createAgent(operatorId, {
username: data.username,
@@ -1298,10 +1294,10 @@ export class AgentsService {
if (data.asSubAgent) {
if (data.parentAgentId == null && data.parentId == null) {
throw new BadRequestException('二级代理必须指定上级代理');
throw appBadRequest('TIER2_REQUIRES_PARENT_AGENT');
}
if (data.initialDeposit && data.initialDeposit > 0) {
throw new BadRequestException('设为代理时请使用授信额度,勿填玩家初始余额');
throw appBadRequest('PROMOTE_USE_CREDIT_NOT_BALANCE');
}
const parentAgentId = data.parentAgentId ?? data.parentId;
return this.createAgent(operatorId, {
@@ -1323,20 +1319,20 @@ export class AgentsService {
if (data.parentId != null) {
const parent = await this.prisma.user.findUnique({ where: { id: data.parentId } });
if (!parent || parent.userType !== 'AGENT') {
throw new BadRequestException('上级必须为代理账号');
throw appBadRequest('PARENT_MUST_BE_AGENT');
}
parentId = data.parentId;
const operator = await this.prisma.user.findUnique({ where: { id: operatorId } });
if (operator?.userType === 'AGENT' && parentId !== operatorId) {
throw new ForbiddenException('Can only create direct players');
throw appForbidden('CREATE_DIRECT_PLAYERS_ONLY');
}
}
try {
assertPlayerUsername(data.username);
} catch (e) {
throw new BadRequestException(e instanceof Error ? e.message : '玩家用户名格式无效');
} catch {
throw appBadRequest('USERNAME_FORMAT_INVALID');
}
const hash = await this.auth.hashPassword(data.password);
@@ -1454,7 +1450,7 @@ export class AgentsService {
where: { userId: subAgentId },
});
if (!profile || profile.parentAgentId !== parentAgentId) {
throw new ForbiddenException('Not your sub-agent');
throw appForbidden('NOT_SUB_AGENT');
}
return profile;
}