start sprint 4 error code mapping
This commit is contained in:
@@ -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 ----------------
|
||||
|
||||
|
||||
76
apps/web/src/lib/api-client.test.ts
Normal file
76
apps/web/src/lib/api-client.test.ts
Normal file
@@ -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<HttpError>);
|
||||
});
|
||||
});
|
||||
@@ -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<ApiError, 'suggestion' | 'severity' | 'retryable'> = {},
|
||||
) {
|
||||
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<TResponse, TBody = unknown>(
|
||||
} 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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
20
apps/web/src/lib/error-messages.ts
Normal file
20
apps/web/src/lib/error-messages.ts
Normal file
@@ -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<string, LocalizedApiErrorMessage>;
|
||||
|
||||
export function getLocalizedApiErrorMessage(code: string | undefined): LocalizedApiErrorMessage | undefined {
|
||||
if (code === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return errorMessages[code];
|
||||
}
|
||||
121
packages/i18n/zh-CN/errors.json
Normal file
121
packages/i18n/zh-CN/errors.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user