feat: internationalize API error responses by locale

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

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

View File

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