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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user