From 641c92a5f5cd4a1740e2e1fd84731cab470fac71 Mon Sep 17 00:00:00 2001 From: Mars <3361409208a@gmail.com> Date: Wed, 10 Jun 2026 13:36:38 +0800 Subject: [PATCH] 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 --- apps/admin/src/api.ts | 8 +- .../applications/admin/admin.controller.ts | 27 +- apps/api/src/domains/agent/agents.service.ts | 104 ++- apps/api/src/domains/betting/bets.service.ts | 35 +- .../domains/betting/betting-limits.service.ts | 11 +- .../src/domains/catalog/matches.service.ts | 39 +- .../src/domains/catalog/outright.service.ts | 25 +- apps/api/src/domains/identity/auth.service.ts | 25 +- apps/api/src/domains/identity/guards.ts | 19 +- apps/api/src/domains/identity/jwt.strategy.ts | 5 +- .../api/src/domains/identity/users.service.ts | 37 +- apps/api/src/domains/ledger/wallet.service.ts | 15 +- apps/api/src/domains/odds/markets.service.ts | 17 +- .../operations/cashback/cashback.service.ts | 27 +- .../operations/content/content.service.ts | 28 +- .../domains/settlement/settlement.service.ts | 39 +- .../database/database-reset.service.ts | 5 +- apps/api/src/shared/common/app-error.ts | 45 ++ apps/api/src/shared/common/filters.ts | 35 +- .../shared/common/resolve-request-locale.ts | 22 + apps/player/src/api/index.ts | 4 + packages/shared/src/api-errors.ts | 717 ++++++++++++++++++ packages/shared/src/index.ts | 4 + 23 files changed, 1059 insertions(+), 234 deletions(-) create mode 100644 apps/api/src/shared/common/app-error.ts create mode 100644 apps/api/src/shared/common/resolve-request-locale.ts create mode 100644 packages/shared/src/api-errors.ts diff --git a/apps/admin/src/api.ts b/apps/admin/src/api.ts index 32dcd35..dcb5b91 100644 --- a/apps/admin/src/api.ts +++ b/apps/admin/src/api.ts @@ -2,13 +2,14 @@ import axios from 'axios'; import router from './router'; import { clearStaffSession, reconcileStaffSessionFromToken, useAuthStore } from './stores/auth'; import { ensureStaffSession, resetStaffSessionHydration } from './utils/session-hydrate'; +import { ADMIN_LOCALE_STORAGE_KEY } from './i18n'; const api = axios.create({ baseURL: '/api' }); let handling401 = false; let handling403Portal = false; -const PORTAL_MISMATCH_MESSAGES = new Set(['Admin access only', 'Agent access only']); +const PORTAL_MISMATCH_CODES = new Set(['ADMIN_ACCESS_ONLY', 'AGENT_ACCESS_ONLY']); function requestPath(config: { url?: string; baseURL?: string } | undefined): string { if (!config?.url) return ''; @@ -20,6 +21,9 @@ api.interceptors.request.use((config) => { const t = localStorage.getItem('manage_token'); if (t) config.headers.Authorization = `Bearer ${t}`; + const locale = localStorage.getItem(ADMIN_LOCALE_STORAGE_KEY) || 'zh-CN'; + config.headers['X-Locale'] = locale; + reconcileStaffSessionFromToken(); const auth = useAuthStore(); const path = requestPath(config); @@ -68,7 +72,7 @@ api.interceptors.response.use( if ( err.response?.status === 403 && !handling403Portal && - PORTAL_MISMATCH_MESSAGES.has(err.response?.data?.message) + PORTAL_MISMATCH_CODES.has(err.response?.data?.code) ) { handling403Portal = true; await ensureStaffSession(); diff --git a/apps/api/src/applications/admin/admin.controller.ts b/apps/api/src/applications/admin/admin.controller.ts index a073d79..5a5d393 100644 --- a/apps/api/src/applications/admin/admin.controller.ts +++ b/apps/api/src/applications/admin/admin.controller.ts @@ -1,8 +1,6 @@ import { - BadRequestException, Controller, Delete, - ForbiddenException, Get, Post, Put, @@ -23,6 +21,7 @@ import { JwtAuthGuard, AdminGuard, PermissionsGuard } from '../../domains/identi import { ContentService } from '../../domains/operations/content/content.service'; import { CurrentUser, RequirePermissions } from '../../shared/common/decorators'; import { jsonResponse } from '../../shared/common/filters'; +import { appBadRequest, appForbidden } from '../../shared/common/app-error'; import { getUploadRoot } from '../../shared/uploads/upload-paths'; import { UsersService } from '../../domains/identity/users.service'; import { AgentsService } from '../../domains/agent/agents.service'; @@ -84,7 +83,7 @@ function uploadCategory(value?: string): UploadCategory { if (UPLOAD_CATEGORIES.includes(category as UploadCategory)) { return category as UploadCategory; } - throw new BadRequestException('Unsupported upload category'); + throw appBadRequest('UPLOAD_CATEGORY_UNSUPPORTED'); } function requiredUploadPermission(category: UploadCategory) { @@ -95,21 +94,21 @@ function assertUploadPermission(user: AdminUploadUser | undefined, category: Upl if (user?.role === 'SUPER_ADMIN') return; const required = requiredUploadPermission(category); if (!user?.permissions?.includes(required)) { - throw new ForbiddenException('Insufficient permissions'); + throw appForbidden('INSUFFICIENT_PERMISSIONS'); } } function assertImageFile(file: UploadedImage | undefined): asserts file is UploadedImage { if (!file?.buffer?.length) { - throw new BadRequestException('Image file is required'); + throw appBadRequest('UPLOAD_IMAGE_REQUIRED'); } if (!IMAGE_MIME_EXT[file.mimetype]) { - throw new BadRequestException('Only PNG, JPG, WEBP, GIF or SVG images are allowed'); + throw appBadRequest('UPLOAD_IMAGE_TYPE_INVALID'); } if (file.mimetype === 'image/svg+xml') { const sample = file.buffer.toString('utf8', 0, Math.min(file.buffer.length, 8192)).toLowerCase(); if (sample.includes(' e.leagueCode === 'WC2026'); - if (!wc) throw new BadRequestException('WC2026 outright not found — run import'); + if (!wc) throw appBadRequest('WC_OUTRIGHT_NOT_FOUND'); return jsonResponse(await this.outright.getForAdmin(BigInt(wc.id))); } @@ -1654,7 +1653,7 @@ export class AdminController { ) { const list = await this.outright.listForAdmin(); const wc = list.find((e) => e.leagueCode === 'WC2026'); - if (!wc) throw new BadRequestException('WC2026 outright not found'); + if (!wc) throw appBadRequest('WC_OUTRIGHT_NOT_FOUND'); return jsonResponse( await this.outright.batchUpdateOdds(BigInt(wc.id), dto.updates, operatorId), ); @@ -1716,7 +1715,7 @@ export class AdminController { @Body() dto: AddOutrightSelectionsBatchDto, ) { if (!dto.items?.length) { - throw new BadRequestException('At least one team required'); + throw appBadRequest('OUTRIGHT_TEAMS_REQUIRED'); } const data = await this.outright.addSelectionsBatch( BigInt(matchId), @@ -2069,7 +2068,7 @@ export class AdminController { @Param('id') id: string, ) { const record = await this.prisma.uploadedFile.findUnique({ where: { id } }); - if (!record) throw new BadRequestException('File not found'); + if (!record) throw appBadRequest('FILE_NOT_FOUND'); assertUploadPermission(user, record.category as any); const root = getUploadRoot(); @@ -2085,7 +2084,7 @@ export class AdminController { @RequirePermissions(P.content, P.matches) async deleteFileByUrl(@Body() body: { url: string }) { const { url } = body; - if (!url || typeof url !== 'string') throw new BadRequestException('url is required'); + if (!url || typeof url !== 'string') throw appBadRequest('URL_REQUIRED'); const record = await this.prisma.uploadedFile.findFirst({ where: { url } }); if (!record) return jsonResponse({ ok: true, note: 'not_found' }); diff --git a/apps/api/src/domains/agent/agents.service.ts b/apps/api/src/domains/agent/agents.service.ts index 8f7709b..f8eb045 100644 --- a/apps/api/src/domains/agent/agents.service.ts +++ b/apps/api/src/domains/agent/agents.service.ts @@ -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; } diff --git a/apps/api/src/domains/betting/bets.service.ts b/apps/api/src/domains/betting/bets.service.ts index c24261a..5d8f383 100644 --- a/apps/api/src/domains/betting/bets.service.ts +++ b/apps/api/src/domains/betting/bets.service.ts @@ -1,10 +1,11 @@ -import { Injectable, BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; +import { Injectable, ConflictException, BadRequestException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../shared/prisma/prisma.service'; import { WalletService } from '../ledger/wallet.service'; import { BettingLimitsService } from './betting-limits.service'; import { Decimal } from '@prisma/client/runtime/library'; import { generateBetNo } from '../../shared/common/decorators'; +import { appBadRequest, appNotFound } from '../../shared/common/app-error'; import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS, @@ -54,20 +55,20 @@ export class BetsService { include: { market: { include: { match: true } } }, }); - if (!selection) throw new BadRequestException('Selection not found'); - if (selection.status !== 'OPEN') throw new BadRequestException('Selection closed'); - if (selection.market.status !== 'OPEN') throw new BadRequestException('Market closed'); + if (!selection) throw appBadRequest('SELECTION_NOT_FOUND'); + if (selection.status !== 'OPEN') throw appBadRequest('SELECTION_CLOSED'); + if (selection.market.status !== 'OPEN') throw appBadRequest('MARKET_CLOSED'); if (selection.market.match.status !== 'PUBLISHED') { - throw new BadRequestException('Match not available for betting'); + throw appBadRequest('MATCH_NOT_BETTING'); } if (!isSupportedSport(selection.market.match.sportType)) { - throw new BadRequestException('Only football betting is supported'); + throw appBadRequest('FOOTBALL_ONLY'); } if (!selection.market.match.isOutright && !isPreMatchKickoff(selection.market.match.startTime)) { - throw new BadRequestException('Pre-match betting only; match has started'); + throw appBadRequest('PRE_MATCH_ONLY'); } if (selection.oddsVersion !== oddsVersion) { - throw new BadRequestException('Odds changed, please confirm again'); + throw appBadRequest('ODDS_CHANGED'); } if (options?.forParlay) { @@ -79,13 +80,13 @@ export class BetsService { isOutright: selection.market.match.isOutright, }); if (!check.ok) { - const msg = + const code = check.reason === 'OUTRIGHT' - ? 'Outright cannot be in parlay' + ? 'PARLAY_OUTRIGHT_FORBIDDEN' : check.reason === 'QUARTER_LINE' - ? 'Quarter line markets cannot be in parlay' - : 'Market not allowed in parlay'; - throw new BadRequestException(msg); + ? 'PARLAY_QUARTER_LINE_FORBIDDEN' + : 'PARLAY_MARKET_NOT_ALLOWED'; + throw appBadRequest(code); } } @@ -100,7 +101,7 @@ export class BetsService { stake: number, requestId: string, ) { - if (stake <= 0) throw new BadRequestException('Invalid stake'); + if (stake <= 0) throw appBadRequest('INVALID_STAKE'); const existing = await this.prisma.bet.findUnique({ where: { userId_requestId: { userId, requestId } }, @@ -162,9 +163,9 @@ export class BetsService { stake: number, requestId: string, ) { - if (stake <= 0) throw new BadRequestException('Invalid stake'); + if (stake <= 0) throw appBadRequest('INVALID_STAKE'); if (legs.length < PARLAY_MIN_LEGS || legs.length > PARLAY_MAX_LEGS) { - throw new BadRequestException(`Parlay must have ${PARLAY_MIN_LEGS}-${PARLAY_MAX_LEGS} legs`); + throw appBadRequest('PARLAY_LEG_COUNT_INVALID', { min: PARLAY_MIN_LEGS, max: PARLAY_MAX_LEGS }); } const existing = await this.prisma.bet.findUnique({ @@ -527,7 +528,7 @@ export class BetsService { selections: { orderBy: { sortOrder: 'asc' } }, }, }); - if (!bet) throw new NotFoundException('注单不存在'); + if (!bet) throw appNotFound('BET_NOT_FOUND'); const matchIds = bet.selections .map((s) => s.matchId) diff --git a/apps/api/src/domains/betting/betting-limits.service.ts b/apps/api/src/domains/betting/betting-limits.service.ts index 71f50ed..e94464c 100644 --- a/apps/api/src/domains/betting/betting-limits.service.ts +++ b/apps/api/src/domains/betting/betting-limits.service.ts @@ -1,6 +1,7 @@ -import { Injectable, BadRequestException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Decimal } from '@prisma/client/runtime/library'; import { PrismaService } from '../../shared/prisma/prisma.service'; +import { appBadRequest } from '../../shared/common/app-error'; export type BettingLimits = { minStake: number; @@ -89,18 +90,18 @@ export class BettingLimitsService { const stake = params.stake; if (stake < limits.minStake) { - throw new BadRequestException(`Minimum stake is ${limits.minStake}`); + throw appBadRequest('MIN_STAKE', { minStake: limits.minStake }); } const maxStake = params.betType === 'PARLAY' ? limits.maxStakeParlay : limits.maxStakeSingle; if (stake > maxStake) { - throw new BadRequestException(`Maximum stake is ${maxStake}`); + throw appBadRequest('MAX_STAKE', { maxStake }); } const maxPayout = params.betType === 'PARLAY' ? limits.maxPayoutParlay : limits.maxPayoutSingle; if (params.potentialReturn.gt(maxPayout)) { - throw new BadRequestException(`Potential return exceeds limit of ${maxPayout}`); + throw appBadRequest('MAX_PAYOUT', { maxPayout }); } if (limits.dailyStakeLimit > 0) { @@ -119,7 +120,7 @@ export class BettingLimitsService { }); const todayStake = new Decimal(agg._sum.stake ?? 0); if (todayStake.add(stake).gt(limits.dailyStakeLimit)) { - throw new BadRequestException(`Daily stake limit of ${limits.dailyStakeLimit} exceeded`); + throw appBadRequest('DAILY_STAKE_LIMIT', { limit: limits.dailyStakeLimit }); } } } diff --git a/apps/api/src/domains/catalog/matches.service.ts b/apps/api/src/domains/catalog/matches.service.ts index b5826a2..33f8b1b 100644 --- a/apps/api/src/domains/catalog/matches.service.ts +++ b/apps/api/src/domains/catalog/matches.service.ts @@ -1,9 +1,10 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { isPreMatchKickoff, resolveTranslationFallback } from '@thebet365/shared'; import { Cron, CronExpression } from '@nestjs/schedule'; import { Prisma } from '@prisma/client'; import { Decimal } from '@prisma/client/runtime/library'; import { PrismaService } from '../../shared/prisma/prisma.service'; +import { appBadRequest, appNotFound } from '../../shared/common/app-error'; export type MatchBetStatsSummary = { betCount: number; @@ -218,7 +219,7 @@ export class MatchesService { const leagueEn = data.leagueEn.trim(); const leagueZh = data.leagueZh.trim(); if (!leagueEn && !leagueZh) { - throw new BadRequestException('请填写赛事名称(中文或英文至少一项)'); + throw appBadRequest('LEAGUE_NAME_REQUIRED'); } const league = await this.upsertLeagueFromZhiboExport({ type: 'FOOTBALL', @@ -268,12 +269,12 @@ export class MatchesService { const league = await this.prisma.league.findFirst({ where: { id: leagueId, deletedAt: null }, }); - if (!league) throw new NotFoundException('赛事不存在'); + if (!league) throw appNotFound('LEAGUE_NOT_FOUND'); const leagueEn = data.leagueEn.trim(); const leagueZh = data.leagueZh.trim(); if (!leagueEn && !leagueZh) { - throw new BadRequestException('请填写赛事名称(中文或英文至少一项)'); + throw appBadRequest('LEAGUE_NAME_REQUIRED'); } await this.upsertEntityTranslations('LEAGUE', leagueId, { @@ -289,7 +290,7 @@ export class MatchesService { if (data.displayOrder != null) updates.displayOrder = data.displayOrder; if (data.isActive !== undefined) { if (league.isActive && data.isActive === false) { - throw new BadRequestException('已发布的联赛不可下架'); + throw appBadRequest('LEAGUE_UNPUBLISH_FORBIDDEN'); } updates.isActive = data.isActive; } @@ -639,7 +640,7 @@ export class MatchesService { logoUrl?: string; }) { const code = data.code.trim().toUpperCase(); - if (!code) throw new BadRequestException('请填写球队代码'); + if (!code) throw appBadRequest('TEAM_CODE_REQUIRED'); const logoUrl = data.logoUrl?.trim() || undefined; const team = await this.prisma.team.upsert({ where: { code }, @@ -686,7 +687,7 @@ export class MatchesService { const awayZh = data.awayTeamZh.trim(); const awayMs = data.awayTeamMs?.trim() ?? ''; if ((!homeEn && !homeZh && !homeMs) || (!awayEn && !awayZh && !awayMs)) { - throw new BadRequestException('请填写主客队名称(中文、英文或马来文至少一项)'); + throw appBadRequest('TEAMS_NAME_REQUIRED'); } let league; @@ -694,13 +695,13 @@ export class MatchesService { league = await this.prisma.league.findFirst({ where: { id: data.leagueId, deletedAt: null }, }); - if (!league) throw new NotFoundException('赛事不存在'); + if (!league) throw appNotFound('LEAGUE_NOT_FOUND'); } else { const leagueEn = data.leagueEn?.trim() ?? ''; const leagueZh = data.leagueZh?.trim() ?? ''; const leagueMs = data.leagueMs?.trim() ?? ''; if (!leagueEn && !leagueZh && !leagueMs) { - throw new BadRequestException('请填写赛事名称(中文、英文或马来文至少一项)'); + throw appBadRequest('LEAGUE_NAME_REQUIRED'); } league = await this.upsertLeagueFromZhiboExport({ type: 'FOOTBALL', @@ -725,7 +726,7 @@ export class MatchesService { let awayTeam; if (homeCode && awayCode) { if (homeCode === awayCode) { - throw new BadRequestException('主客队不能为同一支球队,请选择不同的队伍'); + throw appBadRequest('TEAMS_SAME'); } homeTeam = await this.upsertTeamByCode({ code: homeCode, @@ -771,7 +772,7 @@ export class MatchesService { } if (homeTeam.id === awayTeam.id) { - throw new BadRequestException('主客队不能为同一支球队,请填写不同的队名'); + throw appBadRequest('TEAMS_SAME'); } const matchName = @@ -800,7 +801,7 @@ export class MatchesService { where: { id: matchId, deletedAt: null }, include: { homeTeam: true, awayTeam: true, league: true }, }); - if (!match) throw new NotFoundException('赛事不存在'); + if (!match) throw appNotFound('MATCH_NOT_FOUND'); return match; } @@ -902,10 +903,10 @@ export class MatchesService { ) { const match = await this.requireAdminMatch(matchId); if (match.isOutright) { - throw new BadRequestException('冠军盘请通过盘口管理维护'); + throw appBadRequest('OUTRIGHT_EDIT_VIA_MARKETS'); } if (!['DRAFT', 'PUBLISHED'].includes(match.status)) { - throw new BadRequestException('当前状态不可编辑'); + throw appBadRequest('MATCH_NOT_EDITABLE'); } const matchName = @@ -961,14 +962,14 @@ export class MatchesService { async deleteMatch(matchId: bigint) { const match = await this.requireAdminMatch(matchId); if (match.isOutright) { - throw new BadRequestException('冠军盘不可删除'); + throw appBadRequest('OUTRIGHT_DELETE_FORBIDDEN'); } if (match.status !== 'DRAFT') { - throw new BadRequestException('仅草稿状态可删除'); + throw appBadRequest('MATCH_DELETE_DRAFT_ONLY'); } const betCount = await this.prisma.betSelection.count({ where: { matchId } }); if (betCount > 0) { - throw new BadRequestException('该赛事已有注单关联,无法删除'); + throw appBadRequest('MATCH_HAS_BETS'); } return this.prisma.match.update({ where: { id: matchId }, @@ -1053,7 +1054,7 @@ export class MatchesService { async importZhiboMatchesBundle(bundle: ZhiboMatchesBundleExport, createdBy?: bigint) { if (!bundle.matches?.length) { - throw new BadRequestException('matches array is required'); + throw appBadRequest('MATCHES_ARRAY_REQUIRED'); } const results: Array<{ liveMatchId: string; id: string; status: string; skipped?: boolean; reason?: string }> = []; @@ -1309,7 +1310,7 @@ export class MatchesService { score: true, }, }); - if (!match) throw new NotFoundException('Match not found'); + if (!match) throw appNotFound('MATCH_NOT_FOUND'); return this.enrichMatch(match, locale); } diff --git a/apps/api/src/domains/catalog/outright.service.ts b/apps/api/src/domains/catalog/outright.service.ts index 8f8b394..dafb466 100644 --- a/apps/api/src/domains/catalog/outright.service.ts +++ b/apps/api/src/domains/catalog/outright.service.ts @@ -1,12 +1,11 @@ import { - BadRequestException, Injectable, - NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../../shared/prisma/prisma.service'; import { MarketsService } from '../odds/markets.service'; import { WC2026_LEAGUE_CODE, WC2026_OUTRIGHT_TEAMS } from './wc2026-outright-teams'; import { syncWc2026OutrightMarket } from './wc2026-outright.sync'; +import { appBadRequest, appNotFound } from '../../shared/common/app-error'; const PLACEHOLDER_TEAM_CODE = 'OUT'; const OUTRIGHT_MARKET_TYPE = 'OUTRIGHT_WINNER'; @@ -200,7 +199,7 @@ export class OutrightService { const league = await this.prisma.league.findUnique({ where: { id: leagueId }, }); - if (!league) throw new NotFoundException('League not found'); + if (!league) throw appNotFound('LEAGUE_NOT_FOUND'); const [leagueZh, leagueEn, leagueMs] = await Promise.all([ this.getTranslation('LEAGUE', leagueId, 'zh-CN'), this.getTranslation('LEAGUE', leagueId, 'en-US'), @@ -319,7 +318,7 @@ export class OutrightService { const league = await this.prisma.league.findUnique({ where: { id: data.leagueId }, }); - if (!league) throw new NotFoundException('League not found'); + if (!league) throw appNotFound('LEAGUE_NOT_FOUND'); const placeholder = await this.ensurePlaceholderTeam(); const status = data.status ?? 'PUBLISHED'; @@ -408,10 +407,10 @@ export class OutrightService { }, ) { if (!data.teamCode?.trim()) { - throw new BadRequestException('Team code required'); + throw appBadRequest('TEAM_CODE_REQUIRED'); } if (data.odds <= 1) { - throw new BadRequestException('Odds must be greater than 1'); + throw appBadRequest('ODDS_MIN'); } const match = await this.getOutrightMatchOrThrow(matchId); @@ -452,7 +451,7 @@ export class OutrightService { }); return this.getForAdmin(matchId); } - throw new BadRequestException('Selection already exists for this team code'); + throw appBadRequest('OUTRIGHT_SELECTION_EXISTS'); } const maxSort = await this.prisma.marketSelection.aggregate({ @@ -485,7 +484,7 @@ export class OutrightService { }>, ) { if (!items.length) { - throw new BadRequestException('At least one team required'); + throw appBadRequest('OUTRIGHT_TEAMS_REQUIRED'); } let added = 0; let skipped = 0; @@ -521,7 +520,7 @@ export class OutrightService { const sel = await this.prisma.marketSelection.findFirst({ where: { id: selectionId, marketId: market.id }, }); - if (!sel) throw new NotFoundException('Selection not found'); + if (!sel) throw appNotFound('SELECTION_NOT_FOUND'); const nextCode = data.teamCode?.trim().toUpperCase() || sel.selectionCode; if (nextCode !== sel.selectionCode) { @@ -533,7 +532,7 @@ export class OutrightService { }, }); if (dup) { - throw new BadRequestException('Selection already exists for this team code'); + throw appBadRequest('OUTRIGHT_SELECTION_EXISTS'); } await this.prisma.marketSelection.update({ where: { id: selectionId }, @@ -583,7 +582,7 @@ export class OutrightService { const sel = await this.prisma.marketSelection.findFirst({ where: { id: selectionId, marketId: market.id }, }); - if (!sel) throw new NotFoundException('Selection not found'); + if (!sel) throw appNotFound('SELECTION_NOT_FOUND'); await this.prisma.marketSelection.update({ where: { id: selectionId }, data: { status: 'CLOSED' }, @@ -609,7 +608,7 @@ export class OutrightService { for (const u of updates) { if (!allowed.has(u.selectionId)) { - throw new BadRequestException('Invalid selection for this outright event'); + throw appBadRequest('OUTRIGHT_SELECTION_INVALID'); } } @@ -740,7 +739,7 @@ export class OutrightService { const match = await this.prisma.match.findFirst({ where: { id: matchId, isOutright: true, deletedAt: null }, }); - if (!match) throw new NotFoundException('Outright event not found'); + if (!match) throw appNotFound('OUTRIGHT_EVENT_NOT_FOUND'); return match; } diff --git a/apps/api/src/domains/identity/auth.service.ts b/apps/api/src/domains/identity/auth.service.ts index 6320c3c..4fa5f74 100644 --- a/apps/api/src/domains/identity/auth.service.ts +++ b/apps/api/src/domains/identity/auth.service.ts @@ -1,4 +1,5 @@ -import { Injectable, UnauthorizedException, ForbiddenException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; +import { appForbidden, appUnauthorized } from '../../shared/common/app-error'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import * as bcrypt from 'bcryptjs'; @@ -31,7 +32,7 @@ export class AuthService { select: { userType: true }, }); if (!user || (user.userType !== 'ADMIN' && user.userType !== 'AGENT')) { - throw new UnauthorizedException('Invalid credentials'); + throw appUnauthorized('INVALID_CREDENTIALS'); } const portal = user.userType === 'ADMIN' ? 'admin' : 'agent'; return this.login(username, password, portal); @@ -44,20 +45,20 @@ export class AuthService { }); if (!user || !user.auth) { - throw new UnauthorizedException('Invalid credentials'); + throw appUnauthorized('INVALID_CREDENTIALS'); } const expectedType = portal === 'admin' ? 'ADMIN' : portal === 'agent' ? 'AGENT' : 'PLAYER'; if (user.userType !== expectedType) { - throw new UnauthorizedException('Invalid credentials'); + throw appUnauthorized('INVALID_CREDENTIALS'); } if (user.status === 'DISABLED') { - throw new ForbiddenException('Account disabled'); + throw appForbidden('ACCOUNT_DISABLED'); } if (portal === 'agent' && user.status === 'SUSPENDED') { - throw new ForbiddenException('Agent account suspended'); + throw appForbidden('AGENT_ACCOUNT_SUSPENDED'); } if (portal === 'player' && user.parentId) { @@ -68,13 +69,13 @@ export class AuthService { select: { userType: true, status: true }, }); if (parentAgent?.userType === 'AGENT' && parentAgent.status !== 'ACTIVE') { - throw new ForbiddenException('上级代理已停用,暂无法登录'); + throw appForbidden('PARENT_AGENT_SUSPENDED'); } } } if (user.auth.lockedUntil && user.auth.lockedUntil > new Date()) { - throw new ForbiddenException('Account locked, try again later'); + throw appForbidden('ACCOUNT_LOCKED'); } const valid = await bcrypt.compare(password, user.auth.passwordHash); @@ -86,7 +87,7 @@ export class AuthService { where: { userId: user.id }, data: { loginFailCount: failCount, lockedUntil }, }); - throw new UnauthorizedException('Invalid credentials'); + throw appUnauthorized('INVALID_CREDENTIALS'); } await this.prisma.userAuth.update({ @@ -125,15 +126,15 @@ export class AuthService { async changePassword(userId: bigint, oldPassword: string, newPassword: string) { const auth = await this.prisma.userAuth.findUnique({ where: { userId } }); - if (!auth) throw new UnauthorizedException('User not found'); + if (!auth) throw appUnauthorized('USER_NOT_FOUND'); const settings = await this.systemConfig.getPlayerAccountSettings(); if (!settings.allowPasswordChange) { - throw new ForbiddenException('当前平台未开放玩家自行修改密码'); + throw appForbidden('PASSWORD_CHANGE_DISABLED'); } const valid = await bcrypt.compare(oldPassword, auth.passwordHash); - if (!valid) throw new UnauthorizedException('Invalid old password'); + if (!valid) throw appUnauthorized('INVALID_OLD_PASSWORD'); const hash = await bcrypt.hash(newPassword, 10); await this.prisma.userAuth.update({ diff --git a/apps/api/src/domains/identity/guards.ts b/apps/api/src/domains/identity/guards.ts index 13a10ed..4cf1ac1 100644 --- a/apps/api/src/domains/identity/guards.ts +++ b/apps/api/src/domains/identity/guards.ts @@ -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(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; } } diff --git a/apps/api/src/domains/identity/jwt.strategy.ts b/apps/api/src/domains/identity/jwt.strategy.ts index 9c63a28..697ebb9 100644 --- a/apps/api/src/domains/identity/jwt.strategy.ts +++ b/apps/api/src/domains/identity/jwt.strategy.ts @@ -1,4 +1,5 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; +import { appUnauthorized } from '../../shared/common/app-error'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { ConfigService } from '@nestjs/config'; @@ -36,7 +37,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }, }); if (!user || user.status !== 'ACTIVE') { - throw new UnauthorizedException(); + throw appUnauthorized('INVALID_CREDENTIALS'); } const permissions = user.adminRole?.role?.permissions?.map((rp) => rp.permission.code) ?? []; diff --git a/apps/api/src/domains/identity/users.service.ts b/apps/api/src/domains/identity/users.service.ts index c954f84..ed80023 100644 --- a/apps/api/src/domains/identity/users.service.ts +++ b/apps/api/src/domains/identity/users.service.ts @@ -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 }, diff --git a/apps/api/src/domains/ledger/wallet.service.ts b/apps/api/src/domains/ledger/wallet.service.ts index 6063fad..94f6ef9 100644 --- a/apps/api/src/domains/ledger/wallet.service.ts +++ b/apps/api/src/domains/ledger/wallet.service.ts @@ -1,4 +1,5 @@ -import { Injectable, BadRequestException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; +import { appBadRequest } from '../../shared/common/app-error'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../shared/prisma/prisma.service'; import { Decimal } from '@prisma/client/runtime/library'; @@ -12,7 +13,7 @@ export class WalletService { async getWallet(userId: bigint) { const wallet = await this.prisma.wallet.findUnique({ where: { userId } }); - if (!wallet) throw new BadRequestException('Wallet not found'); + if (!wallet) throw appBadRequest('WALLET_NOT_FOUND'); return wallet; } @@ -26,7 +27,7 @@ export class WalletService { const wallets = await tx.$queryRaw>` SELECT id, available_balance, frozen_balance, version FROM wallets WHERE user_id = ${userId} FOR UPDATE `; - if (!wallets.length) throw new BadRequestException('Wallet not found'); + if (!wallets.length) throw appBadRequest('WALLET_NOT_FOUND'); return wallets[0]; } @@ -39,7 +40,7 @@ export class WalletService { transactionType = 'MANUAL_DEPOSIT', ) { const amt = new Decimal(amount); - if (amt.lte(0)) throw new BadRequestException('Amount must be positive'); + if (amt.lte(0)) throw appBadRequest('AMOUNT_MUST_BE_POSITIVE'); return this.prisma.$transaction(async (tx) => { const w = await this.lockWallet(tx, userId); @@ -84,12 +85,12 @@ export class WalletService { referenceId?: string, ) { const amt = new Decimal(amount); - if (amt.lte(0)) throw new BadRequestException('Amount must be positive'); + if (amt.lte(0)) throw appBadRequest('AMOUNT_MUST_BE_POSITIVE'); return this.prisma.$transaction(async (tx) => { const w = await this.lockWallet(tx, userId); const balanceBefore = new Decimal(w.available_balance); - if (balanceBefore.lt(amt)) throw new BadRequestException('Insufficient balance'); + if (balanceBefore.lt(amt)) throw appBadRequest('INSUFFICIENT_BALANCE'); const balanceAfter = balanceBefore.sub(amt); await tx.wallet.update({ @@ -128,7 +129,7 @@ export class WalletService { return this.prisma.$transaction(async (tx) => { const w = await this.lockWallet(tx, userId); const avail = new Decimal(w.available_balance); - if (avail.lt(amt)) throw new BadRequestException('Insufficient balance'); + if (avail.lt(amt)) throw appBadRequest('INSUFFICIENT_BALANCE'); const balanceAfter = avail.sub(amt); const frozenAfter = new Decimal(w.frozen_balance).add(amt); diff --git a/apps/api/src/domains/odds/markets.service.ts b/apps/api/src/domains/odds/markets.service.ts index 32d1436..69245cb 100644 --- a/apps/api/src/domains/odds/markets.service.ts +++ b/apps/api/src/domains/odds/markets.service.ts @@ -1,10 +1,11 @@ -import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { PrismaService } from '../../shared/prisma/prisma.service'; import { Decimal } from '@prisma/client/runtime/library'; import { FT_CORRECT_SCORE_TEMPLATE, HT_CORRECT_SCORE_TEMPLATE, } from '../settlement/domain/settlement-calculator'; +import { appBadRequest, appNotFound } from '../../shared/common/app-error'; @Injectable() export class MarketsService { @@ -12,7 +13,7 @@ export class MarketsService { async generateTemplates(matchId: bigint, marketTypes: string[]) { const match = await this.prisma.match.findUnique({ where: { id: matchId } }); - if (!match) throw new NotFoundException('Match not found'); + if (!match) throw appNotFound('MATCH_NOT_FOUND'); const created = []; @@ -181,7 +182,7 @@ export class MarketsService { }; const config = configs[marketType]; - if (!config) throw new BadRequestException(`Unknown market type: ${marketType}`); + if (!config) throw appBadRequest('UNKNOWN_MARKET_TYPE', { marketType }); return config; } @@ -189,8 +190,8 @@ export class MarketsService { const selection = await this.prisma.marketSelection.findUnique({ where: { id: selectionId }, }); - if (!selection) throw new NotFoundException('Selection not found'); - if (newOdds <= 1) throw new BadRequestException('Odds must be > 1.00'); + if (!selection) throw appNotFound('SELECTION_NOT_FOUND'); + if (newOdds <= 1) throw appBadRequest('ODDS_MIN'); const newVersion = selection.oddsVersion + BigInt(1); @@ -228,7 +229,7 @@ export class MarketsService { data: { promoLabel?: string | null; status?: string; lineValue?: number | null }, ) { const market = await this.prisma.market.findUnique({ where: { id: marketId } }); - if (!market) throw new NotFoundException('Market not found'); + if (!market) throw appNotFound('MARKET_NOT_FOUND'); return this.prisma.market.update({ where: { id: marketId }, @@ -248,10 +249,10 @@ export class MarketsService { const selection = await this.prisma.marketSelection.findUnique({ where: { id: selectionId }, }); - if (!selection) throw new NotFoundException('Selection not found'); + if (!selection) throw appNotFound('SELECTION_NOT_FOUND'); if (data.odds != null) { - if (!operatorId) throw new BadRequestException('Operator required for odds update'); + if (!operatorId) throw appBadRequest('OPERATOR_REQUIRED'); return this.updateOdds(selectionId, data.odds, operatorId); } diff --git a/apps/api/src/domains/operations/cashback/cashback.service.ts b/apps/api/src/domains/operations/cashback/cashback.service.ts index 81605cb..7986ad1 100644 --- a/apps/api/src/domains/operations/cashback/cashback.service.ts +++ b/apps/api/src/domains/operations/cashback/cashback.service.ts @@ -1,9 +1,10 @@ -import { Injectable, BadRequestException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../../shared/prisma/prisma.service'; import { WalletService } from '../../ledger/wallet.service'; import { Decimal } from '@prisma/client/runtime/library'; import { generateBatchNo } from '../../../shared/common/decorators'; +import { appBadRequest } from '../../../shared/common/app-error'; import { resolveCashbackRateForBet, type CashbackRuleRow, @@ -214,14 +215,14 @@ export class CashbackService { const start = this.normalizePeriodStart(periodStart); const end = this.normalizePeriodEnd(periodEnd); if (start > end) { - throw new BadRequestException('开始日期不能晚于结束日期'); + throw appBadRequest('CASHBACK_DATE_RANGE_INVALID'); } const alreadyPaid = await this.prisma.cashbackBatch.findFirst({ where: { status: 'CONFIRMED', periodStart: start, periodEnd: end }, }); if (alreadyPaid) { - throw new BadRequestException('该统计周期已发放返水,不可重复生成预览'); + throw appBadRequest('CASHBACK_ALREADY_ISSUED'); } const { @@ -236,9 +237,9 @@ export class CashbackService { if (items.length === 0 || totalAmount.lte(0)) { if (eligibleBetCount > 0 && skippedClaimedCount >= eligibleBetCount) { - throw new BadRequestException('该周期内的有效注单均已计入其他返水批次,无法生成预览'); + throw appBadRequest('CASHBACK_BETS_IN_OTHER_BATCH'); } - throw new BadRequestException('该周期内无符合条件的返水,无法生成预览'); + throw appBadRequest('CASHBACK_NO_ELIGIBLE_BETS'); } let batch!: Awaited>; @@ -345,7 +346,7 @@ export class CashbackService { items: { orderBy: { amount: 'desc' } }, }, }); - if (!batch) throw new BadRequestException('Batch not found'); + if (!batch) throw appBadRequest('CASHBACK_BATCH_NOT_FOUND'); const userIds = batch.items.map((i) => i.userId); const users = @@ -403,10 +404,10 @@ export class CashbackService { where: { id: batchId }, include: { items: true, bets: true }, }); - if (!batch) throw new BadRequestException('Batch not found'); - if (batch.status !== 'PREVIEW') throw new BadRequestException('该批次不可发放'); + if (!batch) throw appBadRequest('CASHBACK_BATCH_NOT_FOUND'); + if (batch.status !== 'PREVIEW') throw appBadRequest('CASHBACK_BATCH_NOT_ISSUABLE'); if (batch.items.length === 0 || batch.totalAmount.lte(0)) { - throw new BadRequestException('批次无有效返水金额'); + throw appBadRequest('CASHBACK_NO_AMOUNT'); } const duplicate = await this.prisma.cashbackBatch.findFirst({ @@ -418,7 +419,7 @@ export class CashbackService { }, }); if (duplicate) { - throw new BadRequestException('该统计周期已发放返水'); + throw appBadRequest('CASHBACK_PERIOD_ALREADY_ISSUED'); } const betIds = batch.bets.map((b) => b.betId); @@ -430,7 +431,7 @@ export class CashbackService { }, }); if (conflict) { - throw new BadRequestException('部分注单已在其他批次发放返水,请作废本预览后重新生成'); + throw appBadRequest('CASHBACK_BETS_ALREADY_PAID'); } } @@ -457,9 +458,9 @@ export class CashbackService { async cancelBatch(batchId: bigint) { const batch = await this.prisma.cashbackBatch.findUnique({ where: { id: batchId } }); - if (!batch) throw new BadRequestException('Batch not found'); + if (!batch) throw appBadRequest('CASHBACK_BATCH_NOT_FOUND'); if (batch.status !== 'PREVIEW') { - throw new BadRequestException('只能作废待发放批次'); + throw appBadRequest('CASHBACK_PREVIEW_ONLY_VOID'); } await this.prisma.$transaction(async (tx) => { diff --git a/apps/api/src/domains/operations/content/content.service.ts b/apps/api/src/domains/operations/content/content.service.ts index 1032773..db59554 100644 --- a/apps/api/src/domains/operations/content/content.service.ts +++ b/apps/api/src/domains/operations/content/content.service.ts @@ -1,9 +1,9 @@ import { BadRequestException, Injectable, - NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../../../shared/prisma/prisma.service'; +import { appBadRequest, appNotFound } from '../../../shared/common/app-error'; export const CONTENT_TYPES = ['BANNER', 'NOTICE', 'TICKER'] as const; export type ContentType = (typeof CONTENT_TYPES)[number]; @@ -50,21 +50,21 @@ export class ContentService { private assertContentType(type: string): ContentType { if (!CONTENT_TYPES.includes(type as ContentType)) { - throw new BadRequestException(`Invalid contentType: ${type}`); + throw appBadRequest('CONTENT_TYPE_INVALID', { type }); } return type as ContentType; } private assertStatus(status: string): ContentStatus { if (!CONTENT_STATUSES.includes(status as ContentStatus)) { - throw new BadRequestException(`Invalid status: ${status}`); + throw appBadRequest('CONTENT_STATUS_INVALID', { status }); } return status as ContentStatus; } private validateSchedule(startTime?: Date | null, endTime?: Date | null) { if (startTime && endTime && endTime <= startTime) { - throw new BadRequestException('endTime must be after startTime'); + throw appBadRequest('CONTENT_END_BEFORE_START'); } } @@ -74,16 +74,16 @@ export class ContentService { status: ContentStatus, ) { if (!translations.length) { - throw new BadRequestException('At least one translation required'); + throw appBadRequest('CONTENT_TRANSLATION_REQUIRED'); } const locales = new Set(); for (const tr of translations) { if (!tr.locale?.trim()) { - throw new BadRequestException('Translation locale required'); + throw appBadRequest('CONTENT_LOCALE_REQUIRED'); } if (locales.has(tr.locale)) { - throw new BadRequestException(`Duplicate locale: ${tr.locale}`); + throw appBadRequest('CONTENT_LOCALE_DUPLICATE', { locale: tr.locale }); } locales.add(tr.locale); } @@ -101,12 +101,12 @@ export class ContentService { }); if (!hasUsable) { - throw new BadRequestException( + throw appBadRequest( contentType === 'BANNER' - ? 'ACTIVE banner requires imageUrl in at least one locale' + ? 'CONTENT_ACTIVE_BANNER_INCOMPLETE' : contentType === 'NOTICE' - ? 'ACTIVE notice requires title or body in at least one locale' - : 'ACTIVE ticker requires body in at least one locale', + ? 'CONTENT_ACTIVE_NOTICE_INCOMPLETE' + : 'CONTENT_ACTIVE_TICKER_INCOMPLETE', ); } } @@ -114,10 +114,10 @@ export class ContentService { private validateLink(linkType?: string | null, linkTarget?: string | null) { if (!linkType) return; if (!CONTENT_LINK_TYPES.includes(linkType as ContentLinkType)) { - throw new BadRequestException(`Invalid linkType: ${linkType}`); + throw appBadRequest('CONTENT_LINK_TYPE_INVALID', { linkType }); } if (!linkTarget?.trim()) { - throw new BadRequestException('linkTarget required when linkType is set'); + throw appBadRequest('CONTENT_LINK_TARGET_REQUIRED'); } } @@ -197,7 +197,7 @@ export class ContentService { where: { id }, include: { translations: true }, }); - if (!item) throw new NotFoundException('Content not found'); + if (!item) throw appNotFound('CONTENT_NOT_FOUND'); return item; } diff --git a/apps/api/src/domains/settlement/settlement.service.ts b/apps/api/src/domains/settlement/settlement.service.ts index 82a13d2..8cf672a 100644 --- a/apps/api/src/domains/settlement/settlement.service.ts +++ b/apps/api/src/domains/settlement/settlement.service.ts @@ -1,8 +1,9 @@ -import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { PrismaService } from '../../shared/prisma/prisma.service'; import { WalletService } from '../ledger/wallet.service'; import { AgentsService } from '../agent/agents.service'; import { Decimal } from '@prisma/client/runtime/library'; +import { appBadRequest, appNotFound } from '../../shared/common/app-error'; import { generateBatchNo } from '../../shared/common/decorators'; import { settleSelection, @@ -95,14 +96,14 @@ export class SettlementService { const match = await this.prisma.match.findFirst({ where: { id: matchId, deletedAt: null }, }); - if (!match) throw new NotFoundException('Match not found'); + if (!match) throw appNotFound('MATCH_NOT_FOUND'); if (match.isOutright) { if (!winnerTeamId) { - throw new BadRequestException('冠军盘结算需指定获胜球队 winnerTeamId'); + throw appBadRequest('SETTLEMENT_WINNER_REQUIRED'); } const team = await this.prisma.team.findUnique({ where: { id: winnerTeamId } }); - if (!team) throw new BadRequestException('获胜球队不存在'); + if (!team) throw appBadRequest('SETTLEMENT_WINNER_NOT_FOUND'); const outrightSel = await this.prisma.marketSelection.findFirst({ where: { market: { matchId, marketType: 'OUTRIGHT_WINNER' }, @@ -110,7 +111,7 @@ export class SettlementService { }, }); if (!outrightSel) { - throw new BadRequestException('该球队不在本冠军盘选项中'); + throw appBadRequest('SETTLEMENT_WINNER_NOT_IN_MARKET'); } } @@ -149,7 +150,7 @@ export class SettlementService { opts?: { page?: number; pageSize?: number }, ) { const score = await this.prisma.matchScore.findUnique({ where: { matchId } }); - if (!score) throw new BadRequestException('Score not recorded'); + if (!score) throw appBadRequest('SCORE_NOT_RECORDED'); const computation = await this.computePreviewComputation(matchId); const batch = await this.prisma.settlementBatch.create({ @@ -176,9 +177,9 @@ export class SettlementService { opts?: { page?: number; pageSize?: number }, ) { const batch = await this.prisma.settlementBatch.findUnique({ where: { id: batchId } }); - if (!batch) throw new NotFoundException('Batch not found'); + if (!batch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND'); if (batch.status !== 'PREVIEW') { - throw new BadRequestException('Batch is not in preview'); + throw appBadRequest('SETTLEMENT_BATCH_NOT_PREVIEW'); } const computation = await this.computePreviewComputation(batch.matchId); @@ -270,7 +271,7 @@ export class SettlementService { private async computePreviewComputation(matchId: bigint) { const score = await this.prisma.matchScore.findUnique({ where: { matchId } }); - if (!score) throw new BadRequestException('Score not recorded'); + if (!score) throw appBadRequest('SCORE_NOT_RECORDED'); const scoreInput: ScoreInput = { htHome: score.htHomeScore ?? 0, @@ -484,13 +485,13 @@ export class SettlementService { where: { id: batchId }, include: { match: true }, }); - if (!batch) throw new NotFoundException('Batch not found'); - if (batch.status !== 'PREVIEW') throw new BadRequestException('Batch already confirmed'); + if (!batch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND'); + if (batch.status !== 'PREVIEW') throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED'); const score = await this.prisma.matchScore.findUnique({ where: { matchId: batch.matchId }, }); - if (!score) throw new BadRequestException('Score not found'); + if (!score) throw appBadRequest('SCORE_NOT_FOUND'); const scoreInput: ScoreInput = { htHome: score.htHomeScore ?? 0, @@ -708,7 +709,7 @@ export class SettlementService { const match = await this.prisma.match.findFirst({ where: { id: matchId, deletedAt: null }, }); - if (!match) throw new NotFoundException('Match not found'); + if (!match) throw appNotFound('MATCH_NOT_FOUND'); const legs = await this.prisma.betSelection.findMany({ where: { matchId }, @@ -908,7 +909,7 @@ export class SettlementService { legUpdates.set(sel.id.toString(), result); } else { if (!sel.resultStatus) { - throw new BadRequestException(`Parlay bet ${bet.id} has unsettled legs`); + throw appBadRequest('PARLAY_UNSETTLED_LEGS', { betId: bet.id.toString() }); } result = sel.resultStatus as SelectionResult; } @@ -936,9 +937,9 @@ export class SettlementService { const match = await this.prisma.match.findFirst({ where: { id: matchId, deletedAt: null }, }); - if (!match) throw new NotFoundException('Match not found'); + if (!match) throw appNotFound('MATCH_NOT_FOUND'); if (match.status !== 'SETTLED') { - throw new BadRequestException('Only settled matches can be resettled'); + throw appBadRequest('RESETTLE_SETTLED_ONLY'); } const winnerTeamCode = winnerTeamId @@ -1027,9 +1028,9 @@ export class SettlementService { where: { id: batchId }, include: { match: true }, }); - if (!batch) throw new NotFoundException('Batch not found'); - if (!batch.isResettle) throw new BadRequestException('Not a resettle batch'); - if (batch.status !== 'PREVIEW') throw new BadRequestException('Batch already confirmed'); + if (!batch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND'); + if (!batch.isResettle) throw appBadRequest('RESETTLE_BATCH_ONLY'); + if (batch.status !== 'PREVIEW') throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED'); const scoreInput: ScoreInput = { htHome: batch.htHomeScore ?? 0, diff --git a/apps/api/src/infrastructure/database/database-reset.service.ts b/apps/api/src/infrastructure/database/database-reset.service.ts index 0a2bbe6..f84945b 100644 --- a/apps/api/src/infrastructure/database/database-reset.service.ts +++ b/apps/api/src/infrastructure/database/database-reset.service.ts @@ -1,4 +1,5 @@ -import { ForbiddenException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; +import { appForbidden } from '../../shared/common/app-error'; import { PrismaService } from '../../shared/prisma/prisma.service'; import { DEMO_ACCOUNTS, runSeed } from './run-seed'; @@ -13,7 +14,7 @@ export class DatabaseResetService { async resetDatabase(): Promise<{ demoAccounts: readonly string[] }> { if (!this.isAllowed()) { - throw new ForbiddenException('生产环境禁止重置数据库(需设置 ALLOW_DB_RESET=true)'); + throw appForbidden('DB_RESET_FORBIDDEN'); } await this.truncateApplicationTables(); diff --git a/apps/api/src/shared/common/app-error.ts b/apps/api/src/shared/common/app-error.ts new file mode 100644 index 0000000..6b67d30 --- /dev/null +++ b/apps/api/src/shared/common/app-error.ts @@ -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' + ); +} diff --git a/apps/api/src/shared/common/filters.ts b/apps/api/src/shared/common/filters.ts index b4923fa..8c3ca58 100644 --- a/apps/api/src/shared/common/filters.ts +++ b/apps/api/src/shared/common/filters.ts @@ -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(); + const request = ctx.getRequest(); + 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, }); } diff --git a/apps/api/src/shared/common/resolve-request-locale.ts b/apps/api/src/shared/common/resolve-request-locale.ts new file mode 100644 index 0000000..3bcbe1b --- /dev/null +++ b/apps/api/src/shared/common/resolve-request-locale.ts @@ -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); +} diff --git a/apps/player/src/api/index.ts b/apps/player/src/api/index.ts index 20f4ea1..3f35d75 100644 --- a/apps/player/src/api/index.ts +++ b/apps/player/src/api/index.ts @@ -5,6 +5,10 @@ const api = axios.create({ baseURL: '/api' }); api.interceptors.request.use((config) => { const token = localStorage.getItem('token'); if (token) config.headers.Authorization = `Bearer ${token}`; + + const locale = localStorage.getItem('locale') || 'zh-CN'; + config.headers['X-Locale'] = locale; + return config; }); diff --git a/packages/shared/src/api-errors.ts b/packages/shared/src/api-errors.ts new file mode 100644 index 0000000..31500dc --- /dev/null +++ b/packages/shared/src/api-errors.ts @@ -0,0 +1,717 @@ +const DEFAULT_LOCALE = 'zh-CN' as const; +const SUPPORTED_LOCALES = ['zh-CN', 'ms-MY', 'en-US'] as const; +type Locale = (typeof SUPPORTED_LOCALES)[number]; + +/** 后端错误码 → 三语文案({param} 占位) */ +export const API_ERROR_MESSAGES = { + INTERNAL_SERVER_ERROR: { + 'zh-CN': '服务器内部错误', + 'en-US': 'Internal server error', + 'ms-MY': 'Ralat pelayan dalaman', + }, + INVALID_CREDENTIALS: { + 'zh-CN': '用户名或密码错误', + 'en-US': 'Invalid username or password', + 'ms-MY': 'Nama pengguna atau kata laluan tidak sah', + }, + ACCOUNT_DISABLED: { + 'zh-CN': '账号已停用', + 'en-US': 'Account disabled', + 'ms-MY': 'Akaun telah dinyahaktifkan', + }, + AGENT_ACCOUNT_SUSPENDED: { + 'zh-CN': '代理账号已停用', + 'en-US': 'Agent account suspended', + 'ms-MY': 'Akaun ejen digantung', + }, + PARENT_AGENT_SUSPENDED: { + 'zh-CN': '上级代理已停用,暂无法登录', + 'en-US': 'Parent agent is suspended; login unavailable', + 'ms-MY': 'Ejen induk digantung; log masuk tidak tersedia', + }, + ACCOUNT_LOCKED: { + 'zh-CN': '账号已锁定,请稍后再试', + 'en-US': 'Account locked, try again later', + 'ms-MY': 'Akaun dikunci, cuba lagi nanti', + }, + USER_NOT_FOUND: { + 'zh-CN': '用户不存在', + 'en-US': 'User not found', + 'ms-MY': 'Pengguna tidak dijumpai', + }, + PASSWORD_CHANGE_DISABLED: { + 'zh-CN': '当前平台未开放玩家自行修改密码', + 'en-US': 'Password change is disabled for players', + 'ms-MY': 'Pertukaran kata laluan pemain tidak dibenarkan', + }, + INVALID_OLD_PASSWORD: { + 'zh-CN': '当前密码不正确', + 'en-US': 'Current password is incorrect', + 'ms-MY': 'Kata laluan semasa tidak betul', + }, + ADMIN_ACCESS_REQUIRED: { + 'zh-CN': '需要管理员权限', + 'en-US': 'Admin access required', + 'ms-MY': 'Akses pentadbir diperlukan', + }, + INSUFFICIENT_PERMISSIONS: { + 'zh-CN': '权限不足', + 'en-US': 'Insufficient permissions', + 'ms-MY': 'Kebenaran tidak mencukupi', + }, + ACCESS_DENIED_PORTAL: { + 'zh-CN': '无权访问该门户', + 'en-US': 'Access denied for this portal', + 'ms-MY': 'Akses portal ditolak', + }, + PLAYER_ACCESS_ONLY: { + 'zh-CN': '仅玩家可访问', + 'en-US': 'Player access only', + 'ms-MY': 'Akses pemain sahaja', + }, + ADMIN_ACCESS_ONLY: { + 'zh-CN': '仅管理员可访问', + 'en-US': 'Admin access only', + 'ms-MY': 'Akses pentadbir sahaja', + }, + AGENT_ACCESS_ONLY: { + 'zh-CN': '仅代理可访问', + 'en-US': 'Agent access only', + 'ms-MY': 'Akses ejen sahaja', + }, + LEAGUE_NAME_REQUIRED: { + 'zh-CN': '请填写联赛名称(中文、英文或马来文至少一项)', + 'en-US': 'League name required (Chinese, English or Malay)', + 'ms-MY': 'Nama liga diperlukan (Cina, Inggeris atau Melayu)', + }, + LEAGUE_NOT_FOUND: { + 'zh-CN': '联赛不存在', + 'en-US': 'League not found', + 'ms-MY': 'Liga tidak dijumpai', + }, + LEAGUE_UNPUBLISH_FORBIDDEN: { + 'zh-CN': '已发布的联赛不可下架', + 'en-US': 'Published leagues cannot be unpublished', + 'ms-MY': 'Liga yang diterbitkan tidak boleh ditarik', + }, + TEAM_CODE_REQUIRED: { + 'zh-CN': '请填写球队代码', + 'en-US': 'Team code is required', + 'ms-MY': 'Kod pasukan diperlukan', + }, + TEAMS_NAME_REQUIRED: { + 'zh-CN': '请填写主客队名称(中文、英文或马来文至少一项)', + 'en-US': 'Home and away team names required', + 'ms-MY': 'Nama pasukan home/away diperlukan', + }, + TEAMS_SAME: { + 'zh-CN': '主客队不能相同,请填写不同的队名', + 'en-US': 'Home and away teams must be different', + 'ms-MY': 'Pasukan home dan away mesti berbeza', + }, + MATCH_NOT_FOUND: { + 'zh-CN': '赛事不存在', + 'en-US': 'Match not found', + 'ms-MY': 'Perlawanan tidak dijumpai', + }, + OUTRIGHT_EDIT_VIA_MARKETS: { + 'zh-CN': '冠军盘请通过盘口管理维护', + 'en-US': 'Edit outright markets in the markets page', + 'ms-MY': 'Urus outright melalui halaman pasaran', + }, + MATCH_NOT_EDITABLE: { + 'zh-CN': '当前状态不可编辑', + 'en-US': 'Match cannot be edited in current status', + 'ms-MY': 'Perlawanan tidak boleh diedit dalam status semasa', + }, + OUTRIGHT_DELETE_FORBIDDEN: { + 'zh-CN': '冠军盘不可删除', + 'en-US': 'Outright events cannot be deleted', + 'ms-MY': 'Acara outright tidak boleh dipadam', + }, + MATCH_DELETE_DRAFT_ONLY: { + 'zh-CN': '仅草稿状态可删除', + 'en-US': 'Only draft matches can be deleted', + 'ms-MY': 'Hanya draf boleh dipadam', + }, + MATCH_HAS_BETS: { + 'zh-CN': '该赛事已有注单关联,无法删除', + 'en-US': 'Match has bets and cannot be deleted', + 'ms-MY': 'Perlawanan mempunyai pertaruhan dan tidak boleh dipadam', + }, + MATCHES_ARRAY_REQUIRED: { + 'zh-CN': '请提供 matches 数组', + 'en-US': 'matches array is required', + 'ms-MY': 'Tatasusunan matches diperlukan', + }, + SELECTION_NOT_FOUND: { + 'zh-CN': '投注选项不存在', + 'en-US': 'Selection not found', + 'ms-MY': 'Pilihan tidak dijumpai', + }, + SELECTION_CLOSED: { + 'zh-CN': '投注选项已关闭', + 'en-US': 'Selection closed', + 'ms-MY': 'Pilihan ditutup', + }, + MARKET_CLOSED: { + 'zh-CN': '盘口已关闭', + 'en-US': 'Market closed', + 'ms-MY': 'Pasaran ditutup', + }, + MATCH_NOT_BETTING: { + 'zh-CN': '该赛事暂不可投注', + 'en-US': 'Match not available for betting', + 'ms-MY': 'Perlawanan tidak tersedia untuk pertaruhan', + }, + FOOTBALL_ONLY: { + 'zh-CN': '仅支持足球投注', + 'en-US': 'Only football betting is supported', + 'ms-MY': 'Hanya pertaruhan bola sepak disokong', + }, + PRE_MATCH_ONLY: { + 'zh-CN': '仅支持赛前投注,比赛已开始', + 'en-US': 'Pre-match betting only; match has started', + 'ms-MY': 'Pertaruhan pra-perlawanan sahaja; perlawanan telah bermula', + }, + ODDS_CHANGED: { + 'zh-CN': '赔率已变更,请重新确认', + 'en-US': 'Odds changed, please confirm again', + 'ms-MY': 'Odds berubah, sila sahkan semula', + }, + INVALID_STAKE: { + 'zh-CN': '投注金额无效', + 'en-US': 'Invalid stake', + 'ms-MY': 'Stake tidak sah', + }, + PARLAY_LEG_COUNT_INVALID: { + 'zh-CN': '串关场次须在 {min}–{max} 场之间', + 'en-US': 'Parlay must have {min}–{max} legs', + 'ms-MY': 'Parlay mesti {min}–{max} pilihan', + }, + PARLAY_OUTRIGHT_FORBIDDEN: { + 'zh-CN': '冠军盘不可加入串关', + 'en-US': 'Outright cannot be in parlay', + 'ms-MY': 'Outright tidak boleh dalam parlay', + }, + PARLAY_QUARTER_LINE_FORBIDDEN: { + 'zh-CN': '四分之一盘口不可加入串关', + 'en-US': 'Quarter line markets cannot be in parlay', + 'ms-MY': 'Pasaran suku baris tidak boleh dalam parlay', + }, + PARLAY_MARKET_NOT_ALLOWED: { + 'zh-CN': '该盘口不可加入串关', + 'en-US': 'Market not allowed in parlay', + 'ms-MY': 'Pasaran tidak dibenarkan dalam parlay', + }, + BET_NOT_FOUND: { + 'zh-CN': '注单不存在', + 'en-US': 'Bet not found', + 'ms-MY': 'Pertaruhan tidak dijumpai', + }, + MIN_STAKE: { + 'zh-CN': '最低投注额为 {minStake}', + 'en-US': 'Minimum stake is {minStake}', + 'ms-MY': 'Stake minimum ialah {minStake}', + }, + MAX_STAKE: { + 'zh-CN': '最高投注额为 {maxStake}', + 'en-US': 'Maximum stake is {maxStake}', + 'ms-MY': 'Stake maksimum ialah {maxStake}', + }, + MAX_PAYOUT: { + 'zh-CN': '潜在派彩超过限额 {maxPayout}', + 'en-US': 'Potential return exceeds limit of {maxPayout}', + 'ms-MY': 'Bayaran melebihi had {maxPayout}', + }, + DAILY_STAKE_LIMIT: { + 'zh-CN': '已超过日投注限额 {limit}', + 'en-US': 'Daily stake limit of {limit} exceeded', + 'ms-MY': 'Had stake harian {limit} telah melebihi', + }, + WALLET_NOT_FOUND: { + 'zh-CN': '钱包不存在', + 'en-US': 'Wallet not found', + 'ms-MY': 'Dompet tidak dijumpai', + }, + AMOUNT_MUST_BE_POSITIVE: { + 'zh-CN': '金额须大于 0', + 'en-US': 'Amount must be positive', + 'ms-MY': 'Jumlah mesti positif', + }, + INSUFFICIENT_BALANCE: { + 'zh-CN': '余额不足', + 'en-US': 'Insufficient balance', + 'ms-MY': 'Baki tidak mencukupi', + }, + AGENT_PROFILE_NOT_FOUND: { + 'zh-CN': '代理资料不存在', + 'en-US': 'Agent profile not found', + 'ms-MY': 'Profil ejen tidak dijumpai', + }, + AGENT_NOT_FOUND: { + 'zh-CN': '代理不存在', + 'en-US': 'Agent not found', + 'ms-MY': 'Ejen tidak dijumpai', + }, + CREDIT_LIMIT_NEGATIVE: { + 'zh-CN': '授信额度不能为负', + 'en-US': 'Credit limit cannot be negative', + 'ms-MY': 'Had kredit tidak boleh negatif', + }, + PLAYER_NOT_FOUND: { + 'zh-CN': '玩家不存在', + 'en-US': 'Player not found', + 'ms-MY': 'Pemain tidak dijumpai', + }, + MANAGE_DIRECT_PLAYERS_ONLY: { + 'zh-CN': '仅可管理直属玩家', + 'en-US': 'Can only manage direct players', + 'ms-MY': 'Hanya pemain terus boleh diurus', + }, + PARENT_AGENT_NOT_FOUND: { + 'zh-CN': '上级代理不存在', + 'en-US': 'Parent agent not found', + 'ms-MY': 'Ejen induk tidak dijumpai', + }, + CREDIT_EXCEEDS_PARENT: { + 'zh-CN': '下级代理授信不能超过上级授信额度', + 'en-US': 'Sub-agent credit cannot exceed parent limit', + 'ms-MY': 'Kredit sub-ejen tidak boleh melebihi induk', + }, + CASHBACK_RATE_NEGATIVE: { + 'zh-CN': '回水比例不能为负', + 'en-US': 'Cashback rate cannot be negative', + 'ms-MY': 'Kadar rebat tidak boleh negatif', + }, + CASHBACK_RATE_EXCEEDS_PARENT: { + 'zh-CN': '下级代理回水比例不能超过上级', + 'en-US': 'Sub-agent cashback rate cannot exceed parent', + 'ms-MY': 'Kadar rebat sub-ejen tidak boleh melebihi induk', + }, + BET_LIMIT_EXCEEDS_PARENT: { + 'zh-CN': '下级代理单笔限额不能超过上级', + 'en-US': 'Sub-agent bet limit cannot exceed parent', + 'ms-MY': 'Had pertaruhan sub-ejen tidak boleh melebihi induk', + }, + BET_LIMIT_NEGATIVE: { + 'zh-CN': '单笔限额不能为负', + 'en-US': 'Bet limit cannot be negative', + 'ms-MY': 'Had pertaruhan tidak boleh negatif', + }, + DAILY_LIMIT_EXCEEDS_PARENT: { + 'zh-CN': '下级代理日限额不能超过上级', + 'en-US': 'Sub-agent daily limit cannot exceed parent', + 'ms-MY': 'Had harian sub-ejen tidak boleh melebihi induk', + }, + DAILY_LIMIT_NEGATIVE: { + 'zh-CN': '日限额不能为负', + 'en-US': 'Daily limit cannot be negative', + 'ms-MY': 'Had harian tidak boleh negatif', + }, + CREDIT_TOPUP_EXCEEDED: { + 'zh-CN': '超过玩家上级代理可用授信,无法上分', + 'en-US': 'Exceeds parent agent available credit', + 'ms-MY': 'Melebihi kredit ejen induk yang tersedia', + }, + AGENT_SINGLE_TOPUP_LIMIT: { + 'zh-CN': '超过代理单笔上分限额', + 'en-US': 'Exceeds agent single top-up limit', + 'ms-MY': 'Melebihi had top-up tunggal ejen', + }, + AGENT_DAILY_TOPUP_LIMIT: { + 'zh-CN': '超过代理日上分限额', + 'en-US': 'Exceeds agent daily top-up limit', + 'ms-MY': 'Melebihi had top-up harian ejen', + }, + INSUFFICIENT_AGENT_CREDIT: { + 'zh-CN': '代理可用授信不足', + 'en-US': 'Insufficient agent credit', + 'ms-MY': 'Kredit ejen tidak mencukupi', + }, + INVALID_STATUS: { + 'zh-CN': '无效状态', + 'en-US': 'Invalid status', + 'ms-MY': 'Status tidak sah', + }, + USERNAME_REQUIRED: { + 'zh-CN': '账号名称不能为空', + 'en-US': 'Username is required', + 'ms-MY': 'Nama pengguna diperlukan', + }, + USERNAME_TAKEN: { + 'zh-CN': '账号名称已被占用', + 'en-US': 'Username already taken', + 'ms-MY': 'Nama pengguna sudah digunakan', + }, + USERNAME_FORMAT_INVALID: { + 'zh-CN': '玩家用户名仅可使用英文字母和数字(3–32 位),不可含中文或特殊符号', + 'en-US': 'Username must be 3–32 letters or digits only', + 'ms-MY': 'Nama pengguna mesti 3–32 huruf atau digit sahaja', + }, + PASSWORD_MIN_LENGTH: { + 'zh-CN': '密码至少 8 位', + 'en-US': 'Password must be at least 8 characters', + 'ms-MY': 'Kata laluan sekurang-kurangnya 8 aksara', + }, + AUTH_INFO_MISSING: { + 'zh-CN': '账号认证信息缺失', + 'en-US': 'Account auth info missing', + 'ms-MY': 'Maklumat auth akaun tiada', + }, + USERNAME_CHANGE_DISABLED: { + 'zh-CN': '当前平台未开放玩家自行修改账号名称', + 'en-US': 'Username change is disabled for players', + 'ms-MY': 'Pertukaran nama pengguna pemain tidak dibenarkan', + }, + INVALID_AVATAR: { + 'zh-CN': '无效头像', + 'en-US': 'Invalid avatar', + 'ms-MY': 'Avatar tidak sah', + }, + UNSUPPORTED_LOCALE: { + 'zh-CN': '不支持的语言', + 'en-US': 'Unsupported locale', + 'ms-MY': 'Locale tidak disokong', + }, + NOT_SUB_AGENT: { + 'zh-CN': '非您的下级代理', + 'en-US': 'Not your sub-agent', + 'ms-MY': 'Bukan sub-ejen anda', + }, + PROMOTE_PLAYER_ONLY: { + 'zh-CN': '仅玩家账号可设为代理', + 'en-US': 'Only player accounts can be promoted to agent', + 'ms-MY': 'Hanya pemain boleh dinaikkan ke ejen', + }, + ALREADY_AGENT: { + 'zh-CN': '该用户已是代理', + 'en-US': 'User is already an agent', + 'ms-MY': 'Pengguna sudah menjadi ejen', + }, + AGENT_LEVEL_INVALID: { + 'zh-CN': '代理级别须为 1 或 2', + 'en-US': 'Agent level must be 1 or 2', + 'ms-MY': 'Tahap ejen mesti 1 atau 2', + }, + LEVEL2_REQUIRES_PARENT: { + 'zh-CN': '二级代理必须指定上级代理', + 'en-US': 'Level 2 agent requires parent', + 'ms-MY': 'Ejen peringkat 2 memerlukan induk', + }, + TIER1_NO_PARENT_PLAYER: { + 'zh-CN': '一级代理不可设置上级玩家', + 'en-US': 'Tier-1 agents cannot have a parent player', + 'ms-MY': 'Ejen peringkat 1 tidak boleh ada pemain induk', + }, + PROMOTE_USE_CREDIT_NOT_BALANCE: { + 'zh-CN': '设为代理时请使用授信额度,勿填玩家初始余额', + 'en-US': 'Use credit limit when promoting to agent, not initial balance', + 'ms-MY': 'Guna had kredit apabila naik taraf ke ejen, bukan baki awal', + }, + TIER2_REQUIRES_PARENT_AGENT: { + 'zh-CN': '二级代理必须指定上级代理', + 'en-US': 'Tier-2 agent must specify parent agent', + 'ms-MY': 'Ejen peringkat 2 mesti nyatakan ejen induk', + }, + PARENT_MUST_BE_AGENT: { + 'zh-CN': '上级必须为代理账号', + 'en-US': 'Parent must be an agent account', + 'ms-MY': 'Induk mesti akaun ejen', + }, + CREATE_DIRECT_PLAYERS_ONLY: { + 'zh-CN': '仅可创建直属玩家', + 'en-US': 'Can only create direct players', + 'ms-MY': 'Hanya pemain terus boleh dicipta', + }, + DB_RESET_FORBIDDEN: { + 'zh-CN': '生产环境禁止重置数据库(需设置 ALLOW_DB_RESET=true)', + 'en-US': 'Database reset forbidden in production (set ALLOW_DB_RESET=true)', + 'ms-MY': 'Reset DB dilarang di produksi (set ALLOW_DB_RESET=true)', + }, + CASHBACK_DATE_RANGE_INVALID: { + 'zh-CN': '开始日期不能晚于结束日期', + 'en-US': 'Start date cannot be after end date', + 'ms-MY': 'Tarikh mula tidak boleh selepas tarikh tamat', + }, + CASHBACK_ALREADY_ISSUED: { + 'zh-CN': '该统计周期已发放返水,不可重复生成预览', + 'en-US': 'Cashback already issued for this period', + 'ms-MY': 'Rebat telah dikeluarkan untuk tempoh ini', + }, + CASHBACK_NO_ELIGIBLE_BETS: { + 'zh-CN': '该周期内无符合条件的返水,无法生成预览', + 'en-US': 'No eligible bets in period for cashback preview', + 'ms-MY': 'Tiada pertaruhan layak untuk pratonton rebat', + }, + CASHBACK_BETS_IN_OTHER_BATCH: { + 'zh-CN': '该周期内的有效注单均已计入其他返水批次,无法生成预览', + 'en-US': 'Eligible bets already in another cashback batch', + 'ms-MY': 'Pertaruhan layak sudah dalam batch rebat lain', + }, + CASHBACK_BATCH_NOT_FOUND: { + 'zh-CN': '返水批次不存在', + 'en-US': 'Cashback batch not found', + 'ms-MY': 'Batch rebat tidak dijumpai', + }, + CASHBACK_BATCH_NOT_ISSUABLE: { + 'zh-CN': '该批次不可发放', + 'en-US': 'Batch cannot be issued', + 'ms-MY': 'Batch tidak boleh dikeluarkan', + }, + CASHBACK_NO_AMOUNT: { + 'zh-CN': '批次无有效返水金额', + 'en-US': 'Batch has no valid cashback amount', + 'ms-MY': 'Batch tiada jumlah rebat sah', + }, + CASHBACK_PERIOD_ALREADY_ISSUED: { + 'zh-CN': '该统计周期已发放返水', + 'en-US': 'Cashback already issued for this period', + 'ms-MY': 'Rebat tempoh ini telah dikeluarkan', + }, + CASHBACK_BETS_ALREADY_PAID: { + 'zh-CN': '部分注单已在其他批次发放返水,请作废本预览后重新生成', + 'en-US': 'Some bets paid in another batch; void preview and regenerate', + 'ms-MY': 'Sebahagian pertaruhan dibayar dalam batch lain; batalkan pratonton', + }, + CASHBACK_PREVIEW_ONLY_VOID: { + 'zh-CN': '只能作废待发放批次', + 'en-US': 'Only preview batches can be voided', + 'ms-MY': 'Hanya batch pratonton boleh dibatalkan', + }, + UNKNOWN_MARKET_TYPE: { + 'zh-CN': '未知盘口类型:{marketType}', + 'en-US': 'Unknown market type: {marketType}', + 'ms-MY': 'Jenis pasaran tidak diketahui: {marketType}', + }, + ODDS_MIN: { + 'zh-CN': '赔率须大于 1.00', + 'en-US': 'Odds must be greater than 1.00', + 'ms-MY': 'Odds mesti lebih daripada 1.00', + }, + MARKET_NOT_FOUND: { + 'zh-CN': '盘口不存在', + 'en-US': 'Market not found', + 'ms-MY': 'Pasaran tidak dijumpai', + }, + OPERATOR_REQUIRED: { + 'zh-CN': '修改赔率须指定操作员', + 'en-US': 'Operator required for odds update', + 'ms-MY': 'Operator diperlukan untuk kemas kini odds', + }, + OUTRIGHT_SELECTION_EXISTS: { + 'zh-CN': '该球队代码已存在选项', + 'en-US': 'Selection already exists for this team code', + 'ms-MY': 'Pilihan untuk kod pasukan ini sudah wujud', + }, + OUTRIGHT_TEAMS_REQUIRED: { + 'zh-CN': '至少添加一支球队', + 'en-US': 'At least one team required', + 'ms-MY': 'Sekurang-kurangnya satu pasukan diperlukan', + }, + OUTRIGHT_SELECTION_INVALID: { + 'zh-CN': '无效的夺冠选项', + 'en-US': 'Invalid selection for this outright event', + 'ms-MY': 'Pilihan tidak sah untuk outright ini', + }, + OUTRIGHT_EVENT_NOT_FOUND: { + 'zh-CN': '冠军盘赛事不存在', + 'en-US': 'Outright event not found', + 'ms-MY': 'Acara outright tidak dijumpai', + }, + SETTLEMENT_WINNER_REQUIRED: { + 'zh-CN': '冠军盘结算需指定获胜球队', + 'en-US': 'Outright settlement requires winner team', + 'ms-MY': 'Penyelesaian outright memerlukan pasukan pemenang', + }, + SETTLEMENT_WINNER_NOT_FOUND: { + 'zh-CN': '获胜球队不存在', + 'en-US': 'Winner team not found', + 'ms-MY': 'Pasukan pemenang tidak dijumpai', + }, + SETTLEMENT_WINNER_NOT_IN_MARKET: { + 'zh-CN': '该球队不在本冠军盘选项中', + 'en-US': 'Team is not in this outright market', + 'ms-MY': 'Pasukan tiada dalam pasaran outright ini', + }, + SCORE_NOT_RECORDED: { + 'zh-CN': '尚未录入比分', + 'en-US': 'Score not recorded', + 'ms-MY': 'Skor belum direkod', + }, + SETTLEMENT_BATCH_NOT_FOUND: { + 'zh-CN': '结算批次不存在', + 'en-US': 'Settlement batch not found', + 'ms-MY': 'Batch penyelesaian tidak dijumpai', + }, + SETTLEMENT_BATCH_NOT_PREVIEW: { + 'zh-CN': '结算批次不在预览状态', + 'en-US': 'Batch is not in preview status', + 'ms-MY': 'Batch bukan dalam status pratonton', + }, + SETTLEMENT_BATCH_ALREADY_CONFIRMED: { + 'zh-CN': '结算批次已确认', + 'en-US': 'Batch already confirmed', + 'ms-MY': 'Batch sudah disahkan', + }, + SCORE_NOT_FOUND: { + 'zh-CN': '比分不存在', + 'en-US': 'Score not found', + 'ms-MY': 'Skor tidak dijumpai', + }, + PARLAY_UNSETTLED_LEGS: { + 'zh-CN': '串关注单 {betId} 仍有未结算场次', + 'en-US': 'Parlay bet {betId} has unsettled legs', + 'ms-MY': 'Parlay {betId} masih ada pilihan belum selesai', + }, + RESETTLE_SETTLED_ONLY: { + 'zh-CN': '仅已结算赛事可重结算', + 'en-US': 'Only settled matches can be resettled', + 'ms-MY': 'Hanya perlawanan selesai boleh diselesaikan semula', + }, + RESETTLE_BATCH_ONLY: { + 'zh-CN': '非重结算批次', + 'en-US': 'Not a resettle batch', + 'ms-MY': 'Bukan batch penyelesaian semula', + }, + UPLOAD_CATEGORY_UNSUPPORTED: { + 'zh-CN': '不支持的上传分类', + 'en-US': 'Unsupported upload category', + 'ms-MY': 'Kategori muat naik tidak disokong', + }, + UPLOAD_IMAGE_REQUIRED: { + 'zh-CN': '请选择图片文件', + 'en-US': 'Image file is required', + 'ms-MY': 'Fail imej diperlukan', + }, + UPLOAD_IMAGE_TYPE_INVALID: { + 'zh-CN': '仅支持 PNG、JPG、WEBP、GIF 或 SVG 图片', + 'en-US': 'Only PNG, JPG, WEBP, GIF or SVG images are allowed', + 'ms-MY': 'Hanya PNG, JPG, WEBP, GIF atau SVG dibenarkan', + }, + UPLOAD_SVG_UNSAFE: { + 'zh-CN': 'SVG 内容不安全,已拒绝', + 'en-US': 'Unsafe SVG content is not allowed', + 'ms-MY': 'Kandungan SVG tidak selamat', + }, + DB_RESET_PHRASE_INVALID: { + 'zh-CN': '确认短语不正确,请输入 RESET', + 'en-US': 'Invalid confirmation phrase; type RESET', + 'ms-MY': 'Frasa pengesahan salah; taip RESET', + }, + IMPORT_MATCHES_REQUIRED: { + 'zh-CN': '导入数据无效:需要 matches[]', + 'en-US': 'Invalid import payload: matches[] required', + 'ms-MY': 'Payload import tidak sah: matches[] diperlukan', + }, + WC_OUTRIGHT_NOT_FOUND: { + 'zh-CN': '未找到 WC2026 冠军盘,请先导入', + 'en-US': 'WC2026 outright not found — run import', + 'ms-MY': 'Outright WC2026 tidak dijumpai — jalankan import', + }, + FILE_NOT_FOUND: { + 'zh-CN': '文件不存在', + 'en-US': 'File not found', + 'ms-MY': 'Fail tidak dijumpai', + }, + URL_REQUIRED: { + 'zh-CN': '请提供 url 参数', + 'en-US': 'url is required', + 'ms-MY': 'url diperlukan', + }, + CONTENT_TYPE_INVALID: { + 'zh-CN': '无效内容类型:{type}', + 'en-US': 'Invalid contentType: {type}', + 'ms-MY': 'Jenis kandungan tidak sah: {type}', + }, + CONTENT_STATUS_INVALID: { + 'zh-CN': '无效状态:{status}', + 'en-US': 'Invalid status: {status}', + 'ms-MY': 'Status tidak sah: {status}', + }, + CONTENT_END_BEFORE_START: { + 'zh-CN': '结束时间须晚于开始时间', + 'en-US': 'endTime must be after startTime', + 'ms-MY': 'endTime mesti selepas startTime', + }, + CONTENT_TRANSLATION_REQUIRED: { + 'zh-CN': '至少填写一条翻译', + 'en-US': 'At least one translation required', + 'ms-MY': 'Sekurang-kurangnya satu terjemahan diperlukan', + }, + CONTENT_LOCALE_REQUIRED: { + 'zh-CN': '翻译语言不能为空', + 'en-US': 'Translation locale required', + 'ms-MY': 'Locale terjemahan diperlukan', + }, + CONTENT_LOCALE_DUPLICATE: { + 'zh-CN': '重复的语言:{locale}', + 'en-US': 'Duplicate locale: {locale}', + 'ms-MY': 'Locale pendua: {locale}', + }, + CONTENT_LINK_TYPE_INVALID: { + 'zh-CN': '无效链接类型:{linkType}', + 'en-US': 'Invalid linkType: {linkType}', + 'ms-MY': 'Jenis pautan tidak sah: {linkType}', + }, + CONTENT_LINK_TARGET_REQUIRED: { + 'zh-CN': '设置链接类型时须填写 linkTarget', + 'en-US': 'linkTarget required when linkType is set', + 'ms-MY': 'linkTarget diperlukan apabila linkType ditetapkan', + }, + CONTENT_NOT_FOUND: { + 'zh-CN': '内容不存在', + 'en-US': 'Content not found', + 'ms-MY': 'Kandungan tidak dijumpai', + }, + CONTENT_ACTIVE_BANNER_INCOMPLETE: { + 'zh-CN': '启用 Banner 须至少一种语言配置图片地址', + 'en-US': 'ACTIVE banner requires imageUrl in at least one locale', + 'ms-MY': 'Banner aktif memerlukan imageUrl sekurang-kurangnya satu locale', + }, + CONTENT_ACTIVE_NOTICE_INCOMPLETE: { + 'zh-CN': '启用公告须至少一种语言填写标题或正文', + 'en-US': 'ACTIVE notice requires title or body in at least one locale', + 'ms-MY': 'Notis aktif memerlukan tajuk atau kandungan', + }, + CONTENT_ACTIVE_TICKER_INCOMPLETE: { + 'zh-CN': '启用滚动公告须至少一种语言填写正文', + 'en-US': 'ACTIVE ticker requires body in at least one locale', + 'ms-MY': 'Ticker aktif memerlukan kandungan', + }, +} as const satisfies Record>; + +export type ApiErrorCode = keyof typeof API_ERROR_MESSAGES; + +export type ApiErrorParams = Record; + +export function normalizeLocale(input?: string | null): Locale { + const raw = String(input ?? '').trim(); + if (!raw) return DEFAULT_LOCALE; + const lower = raw.toLowerCase(); + if (lower.startsWith('zh')) return 'zh-CN'; + if (lower.startsWith('ms') || lower.startsWith('my')) return 'ms-MY'; + if (lower.startsWith('en')) return 'en-US'; + if ((SUPPORTED_LOCALES as readonly string[]).includes(raw)) return raw as Locale; + return DEFAULT_LOCALE; +} + +export function formatApiErrorMessage( + code: ApiErrorCode, + localeInput?: string | null, + params?: ApiErrorParams, +): string { + const locale = normalizeLocale(localeInput); + const template = + API_ERROR_MESSAGES[code]?.[locale] ?? + API_ERROR_MESSAGES[code]?.[DEFAULT_LOCALE] ?? + API_ERROR_MESSAGES.INTERNAL_SERVER_ERROR[locale]; + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (_match: string, key: string) => + String(params[key] ?? `{${key}}`), + ); +} + +export function isApiErrorCode(value: unknown): value is ApiErrorCode { + return typeof value === 'string' && value in API_ERROR_MESSAGES; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 89b0e55..ecec252 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -126,4 +126,8 @@ export interface ApiResponse { data?: T; message?: string; error?: string; + code?: string; + params?: Record | null; } + +export * from './api-errors';