feat: internationalize API error responses by locale

Add shared error codes with zh/en/ms messages, coded app exceptions,
and locale-aware global filter. Frontends send X-Locale so error text
matches the active UI language.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-10 13:36:38 +08:00
parent 03f54ca689
commit 641c92a5f5
23 changed files with 1059 additions and 234 deletions

View File

@@ -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();

View File

@@ -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('<script') || sample.includes('javascript:') || /\son[a-z]+\s*=/.test(sample)) {
throw new BadRequestException('Unsafe SVG content is not allowed');
throw appBadRequest('UPLOAD_SVG_UNSAFE');
}
}
}
@@ -983,7 +982,7 @@ export class AdminController {
@Body() dto: ResetDatabaseDto,
) {
if (dto.confirmPhrase !== 'RESET') {
throw new BadRequestException('确认短语不正确,请输入 RESET');
throw appBadRequest('DB_RESET_PHRASE_INVALID');
}
const result = await this.databaseReset.resetDatabase();
await this.audit.log({
@@ -1510,7 +1509,7 @@ export class AdminController {
@RequirePermissions(P.matches)
async importMatches(@CurrentUser('id') operatorId: bigint, @Body() dto: ZhiboMatchesBundleExport) {
if (!isZhiboBundlePayload(dto)) {
throw new BadRequestException('Invalid import payload: matches[] required');
throw appBadRequest('IMPORT_MATCHES_REQUIRED');
}
const result = await this.matches.importZhiboMatchesBundle(dto, operatorId);
return jsonResponse(result);
@@ -1641,7 +1640,7 @@ export class AdminController {
async getWc2026OutrightLegacy() {
const list = await this.outright.listForAdmin();
const wc = list.find((e) => 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' });

View File

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

View File

@@ -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)

View File

@@ -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 });
}
}
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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({

View File

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

View File

@@ -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) ?? [];

View File

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

View File

@@ -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<Array<{ id: bigint; available_balance: Decimal; frozen_balance: Decimal; version: number }>>`
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);

View File

@@ -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);
}

View File

@@ -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<ReturnType<typeof this.prisma.cashbackBatch.create>>;
@@ -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) => {

View File

@@ -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<string>();
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;
}

View File

@@ -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,

View File

@@ -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();

View File

@@ -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'
);
}

View File

@@ -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<Response>();
const request = ctx.getRequest<Request>();
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,
});
}

View File

@@ -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);
}

View File

@@ -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;
});

View File

@@ -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': '玩家用户名仅可使用英文字母和数字332 位),不可含中文或特殊符号',
'en-US': 'Username must be 332 letters or digits only',
'ms-MY': 'Nama pengguna mesti 332 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<string, Record<Locale, string>>;
export type ApiErrorCode = keyof typeof API_ERROR_MESSAGES;
export type ApiErrorParams = Record<string, string | number>;
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;
}

View File

@@ -126,4 +126,8 @@ export interface ApiResponse<T = unknown> {
data?: T;
message?: string;
error?: string;
code?: string;
params?: Record<string, string | number> | null;
}
export * from './api-errors';