From 86296bdb0e41d2e7d7d3f4b710dfb020c32eec6e Mon Sep 17 00:00:00 2001 From: Frontend Developer Date: Fri, 3 Jul 2026 21:55:30 +0800 Subject: [PATCH] start sprint 4 error code mapping --- admin/tests/test_errors.py | 9 +++ apps/web/src/lib/api-client.test.ts | 76 +++++++++++++++++ apps/web/src/lib/api-client.ts | 24 +++++- apps/web/src/lib/error-messages.ts | 20 +++++ packages/i18n/zh-CN/errors.json | 121 ++++++++++++++++++++++++++++ 5 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/lib/api-client.test.ts create mode 100644 apps/web/src/lib/error-messages.ts create mode 100644 packages/i18n/zh-CN/errors.json diff --git a/admin/tests/test_errors.py b/admin/tests/test_errors.py index 0d7f0cf..473ff56 100644 --- a/admin/tests/test_errors.py +++ b/admin/tests/test_errors.py @@ -9,6 +9,9 @@ from __future__ import annotations +import json +from pathlib import Path + import pytest from fastapi.testclient import TestClient @@ -158,6 +161,12 @@ class TestRegistry: """ assert len(MESSAGES_ZH_CN) == 17 + def test_frontend_i18n_catalog_matches_registry(self): + catalog_path = Path(__file__).resolve().parents[2] / "packages" / "i18n" / "zh-CN" / "errors.json" + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + expected = {code: message.to_dict() for code, message in MESSAGES_ZH_CN.items()} + assert catalog == expected + # ---------------- exceptions.py ---------------- diff --git a/apps/web/src/lib/api-client.test.ts b/apps/web/src/lib/api-client.test.ts new file mode 100644 index 0000000..9a3c7bb --- /dev/null +++ b/apps/web/src/lib/api-client.test.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { apiClient, HttpError } from './api-client'; + +const errorCases = [ + ['E01101', '用户名或密码不正确'], + ['E01102', '账号已被停用'], + ['E01201', '登录状态已过期'], + ['E01202', '登录凭证无效'], + ['E01301', '当前账号无访问权限'], + ['E02001', '未找到该订单'], + ['E02002', '订单仍在数据保留期内'], + ['E02301', '订单当前状态不支持该操作'], + ['E02501', '请求过于频繁'], + ['E03001', '请求数据未通过校验'], + ['E03002', '未找到对应的数据'], + ['E03003', '数据保存失败'], + ['E04001', '外部服务暂时不可用'], + ['E04002', '外部服务响应超时'], + ['E05001', '系统内部异常'], + ['E05002', '系统配置缺失'], + ['E05003', '系统资源不足'], +] as const; + +describe('apiClient error handling', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each(errorCases)('maps backend error code %s to localized user copy', async (code, message) => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + code, + message: `backend raw message for ${code}`, + details: { requestId: 'req-1' }, + }), + { + status: 422, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ), + ); + + await expect(apiClient.get('/boom', z.object({ ok: z.boolean() }))).rejects.toMatchObject({ + name: 'HttpError', + status: 422, + code, + message, + details: { requestId: 'req-1' }, + }); + }); + + it('falls back to the backend message for unknown error codes', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 'E09999', message: 'backend fallback', details: null }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + + await expect(apiClient.get('/unknown', z.object({ ok: z.boolean() }))).rejects.toMatchObject({ + name: 'HttpError', + status: 500, + code: 'E09999', + message: 'backend fallback', + details: null, + } satisfies Partial); + }); +}); diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 6ab93f3..6268e6d 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -4,27 +4,43 @@ * * G1 闸门: * - 0 any (所有 response 强类型, 通过 zod 校验) - * - 错误码 → i18n 文案 (Sprint 4 完善映射, 现在先返回原始错误) + * - 错误码 → i18n 文案 */ import { ZodError, type ZodType, type ZodTypeDef } from 'zod'; +import { getLocalizedApiErrorMessage, type ApiErrorSeverity } from './error-messages'; export interface ApiError extends Error { status: number; code?: string; details?: unknown; + suggestion?: string; + severity?: ApiErrorSeverity; + retryable?: boolean; } export class HttpError extends Error implements ApiError { readonly status: number; readonly code: string | undefined; readonly details: unknown; + readonly suggestion: string | undefined; + readonly severity: ApiErrorSeverity | undefined; + readonly retryable: boolean | undefined; - constructor(message: string, status: number, code?: string, details?: unknown) { + constructor( + message: string, + status: number, + code?: string, + details?: unknown, + options: Pick = {}, + ) { super(message); this.name = 'HttpError'; this.status = status; this.code = code; this.details = details; + this.suggestion = options.suggestion; + this.severity = options.severity; + this.retryable = options.retryable; } } @@ -67,11 +83,13 @@ async function request( } catch { // response body 不是 JSON } + const localizedError = getLocalizedApiErrorMessage(errorBody.code); throw new HttpError( - errorBody.message ?? `HTTP ${res.status}`, + localizedError?.message ?? errorBody.message ?? `HTTP ${res.status}`, res.status, errorBody.code, errorBody.details, + localizedError, ); } diff --git a/apps/web/src/lib/error-messages.ts b/apps/web/src/lib/error-messages.ts new file mode 100644 index 0000000..abeda99 --- /dev/null +++ b/apps/web/src/lib/error-messages.ts @@ -0,0 +1,20 @@ +import zhCNErrorMessages from '../../../../packages/i18n/zh-CN/errors.json'; + +export type ApiErrorSeverity = 'info' | 'warn' | 'error'; + +export interface LocalizedApiErrorMessage { + code: string; + message: string; + suggestion: string; + severity: ApiErrorSeverity; + retryable: boolean; +} + +const errorMessages = zhCNErrorMessages as Record; + +export function getLocalizedApiErrorMessage(code: string | undefined): LocalizedApiErrorMessage | undefined { + if (code === undefined) { + return undefined; + } + return errorMessages[code]; +} diff --git a/packages/i18n/zh-CN/errors.json b/packages/i18n/zh-CN/errors.json new file mode 100644 index 0000000..b829fef --- /dev/null +++ b/packages/i18n/zh-CN/errors.json @@ -0,0 +1,121 @@ +{ + "E01101": { + "code": "E01101", + "message": "用户名或密码不正确", + "suggestion": "请检查大小写和输入法,确认无误后重新登录。忘记密码请联系管理员重置。", + "severity": "warn", + "retryable": false + }, + "E01102": { + "code": "E01102", + "message": "账号已被停用", + "suggestion": "请联系管理员确认账号状态,启用后再登录。", + "severity": "warn", + "retryable": false + }, + "E01201": { + "code": "E01201", + "message": "登录状态已过期", + "suggestion": "请重新登录后再继续操作。系统默认会话有效期 5 分钟。", + "severity": "warn", + "retryable": false + }, + "E01202": { + "code": "E01202", + "message": "登录凭证无效", + "suggestion": "请重新登录。如反复出现,请清除浏览器 Cookie 后重试。", + "severity": "warn", + "retryable": false + }, + "E01301": { + "code": "E01301", + "message": "当前账号无访问权限", + "suggestion": "请使用具备相应权限的账号登录,或联系管理员开通权限。", + "severity": "warn", + "retryable": false + }, + "E02001": { + "code": "E02001", + "message": "未找到该订单", + "suggestion": "请检查订单号是否正确,或在订单列表中通过筛选条件重新查询。", + "severity": "warn", + "retryable": false + }, + "E02002": { + "code": "E02002", + "message": "订单仍在数据保留期内", + "suggestion": "请等待保留期(服务完成后 180 天)结束,或走支付争议/法务豁免审批流程后再处理。", + "severity": "warn", + "retryable": false + }, + "E02301": { + "code": "E02301", + "message": "订单当前状态不支持该操作", + "suggestion": "请刷新订单详情查看最新状态,或前往订单列表查看状态流转。", + "severity": "warn", + "retryable": false + }, + "E02501": { + "code": "E02501", + "message": "请求过于频繁", + "suggestion": "请稍候 30 秒后再试。如持续触发,可联系管理员调整配额。", + "severity": "warn", + "retryable": true + }, + "E03001": { + "code": "E03001", + "message": "请求数据未通过校验", + "suggestion": "请检查必填字段和格式。鼠标悬停字段标签可查看填写要求。", + "severity": "warn", + "retryable": false + }, + "E03002": { + "code": "E03002", + "message": "未找到对应的数据", + "suggestion": "该记录可能已被删除或尚未创建,请刷新页面或返回列表确认。", + "severity": "warn", + "retryable": false + }, + "E03003": { + "code": "E03003", + "message": "数据保存失败", + "suggestion": "请稍后重试。如反复失败,请联系技术支持并保留错误码。", + "severity": "error", + "retryable": true + }, + "E04001": { + "code": "E04001", + "message": "外部服务暂时不可用", + "suggestion": "我们正在同步上游状态,请稍后重试;如紧急可联系客服。", + "severity": "error", + "retryable": true + }, + "E04002": { + "code": "E04002", + "message": "外部服务响应超时", + "suggestion": "请稍后重试,或在网络稳定的环境下再次尝试。", + "severity": "error", + "retryable": true + }, + "E05001": { + "code": "E05001", + "message": "系统内部异常", + "suggestion": "请稍后重试。如反复出现,请联系技术支持并提供错误码。", + "severity": "error", + "retryable": true + }, + "E05002": { + "code": "E05002", + "message": "系统配置缺失", + "suggestion": "请联系运维人员检查服务配置,确认环境变量已正确注入。", + "severity": "error", + "retryable": false + }, + "E05003": { + "code": "E05003", + "message": "系统资源不足", + "suggestion": "当前访问量过高,请稍后再试;如紧急可联系运维扩容。", + "severity": "error", + "retryable": true + } +}