feat: internationalize API error responses by locale
Add shared error codes with zh/en/ms messages, coded app exceptions, and locale-aware global filter. Frontends send X-Locale so error text matches the active UI language. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,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' });
|
||||
|
||||
Reference in New Issue
Block a user