mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-13 20:51:09 +00:00
fix(web): 错误提示改为后端 msg 优先,去掉业务码白名单耦合
- 普通错误分支与 handleError 统一为「data.msg → 业务码兜底 → 状态码兜底 → 通用」,
不再只在 code ∈ {1, 401, -1} 时展示后端文案(此前 4500/404/409 会退化为通用「请求失败」)
- Blob 下载错误分支同样优先解析错误体 msg;401 显式排除在解析外,避免影响令牌静默续期;
非 JSON Blob / 读取失败仍按状态码兜底且不抛额外异常
- 成功下载分支零改动(diff 中 responseType 判断增删行为 0)
背景:后端把错误码收敛为单一事实来源后,内部故障 code 由 -1 变为 4500、资源不存在变为 404,
旧的前端白名单会吞掉这些文案;此处改为 msg 优先后,后端可保持单一事实来源而不牺牲诊断信息。
验证:type-check exit 0;vitest 41 用例(含 HTTP 500+4500 → 「分页查询失败: 连接已断开」、
HTTP 404+404 → 「数据表 tables_0 不存在」、409+未知码 4090、Blob 各态、401 续期不回归)。
This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
/**
|
||||
* Web 端错误提示口径守卫
|
||||
*
|
||||
* 锁住两件事:
|
||||
* 1. 任意非成功业务码都**优先展示后端 `data.msg`**(后端错误码已收敛为单一事实来源
|
||||
* `RET.code → HTTP status`,前端不再按业务码白名单挑文案),只有后端没给 msg 时
|
||||
* 才按「业务码 → 状态码 → 通用」回退到本地文案;`handleError` 同口径。
|
||||
* 2. 既有分支行为不变:401 静默续期(含重放)、403 提示、请求取消、网络错误、Blob 下载。
|
||||
*
|
||||
* 实现方式:给 `request` 实例装一个自定义 adapter,让请求真实穿过响应拦截器,
|
||||
* 而不依赖 jsdom 的网络能力;`ElMessage` / i18n / Auth 全部替换为可断言替身。
|
||||
*
|
||||
* 注意:`ResultEnum` 是 `const enum`,在 isolatedModules 下无法在运行时导入
|
||||
* (见 smoke.spec.ts 的同类说明),因此本文件用字面量并在注释里标注对应枚举:
|
||||
* SUCCESS=0、ERROR=1、EXCEPTION=-1、UNAUTHORIZED=10403、TOKEN_EXPIRED=10401。
|
||||
*/
|
||||
import type { AxiosResponse, InternalAxiosRequestConfig } from "axios";
|
||||
import { AxiosError } from "axios";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// jsdom + undici 的 `Response` 不把 jsdom 的 Blob 当 BodyInit(会退化成 "[object Blob]"),
|
||||
// 而被测代码解析第三方错误体时只用到 `new Response(blob).text()`。
|
||||
// 这里覆盖为本测试用的最小实现,语义与浏览器里 `new Response(blob).text()` 一致。
|
||||
class ResponseShim {
|
||||
constructor(private readonly body: Blob) {}
|
||||
async text(): Promise<string> {
|
||||
return await this.body.text();
|
||||
}
|
||||
}
|
||||
try {
|
||||
Object.defineProperty(globalThis, "Response", {
|
||||
value: ResponseShim,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
} catch {
|
||||
// 环境不允许重定义 Response 时忽略:仅 Blob 用例会受环境差异影响
|
||||
}
|
||||
|
||||
// ────────────── 隔离重型依赖 ──────────────
|
||||
const mocks = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
refreshToken: vi.fn(),
|
||||
redirectToLogin: vi.fn(),
|
||||
setTokens: vi.fn(),
|
||||
getAccessToken: vi.fn(),
|
||||
getRefreshToken: vi.fn(),
|
||||
getRememberMe: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("element-plus", () => ({
|
||||
ElMessage: { error: mocks.error, success: mocks.success },
|
||||
}));
|
||||
|
||||
vi.mock("@/locales", () => ({
|
||||
// 用可断言前缀替代真实翻译,避免用例耦合 i18n 文案本身
|
||||
$t: (key: string) => `i18n:${key}`,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/auth", () => ({
|
||||
Auth: {
|
||||
getAccessToken: mocks.getAccessToken,
|
||||
getRefreshToken: mocks.getRefreshToken,
|
||||
getRememberMe: mocks.getRememberMe,
|
||||
setTokens: mocks.setTokens,
|
||||
},
|
||||
redirectToLogin: mocks.redirectToLogin,
|
||||
}));
|
||||
|
||||
vi.mock("@/api/module_system/auth", () => ({
|
||||
default: { refreshToken: mocks.refreshToken },
|
||||
}));
|
||||
|
||||
import { HttpError, handleError, request } from "@/utils/http";
|
||||
|
||||
// ────────────── adapter 脚手架 ──────────────
|
||||
|
||||
type Preset =
|
||||
| { kind: "ok"; data: unknown }
|
||||
| { kind: "fail"; status: number; data: unknown }
|
||||
| { kind: "throw"; error: Error };
|
||||
|
||||
let presets: Preset[] = [];
|
||||
/** 每次 adapter 被调用时收到的配置(用于断言令牌注入/重放) */
|
||||
let seenConfigs: InternalAxiosRequestConfig[] = [];
|
||||
|
||||
function preset(...items: Preset[]): void {
|
||||
presets.push(...items);
|
||||
}
|
||||
|
||||
function ok(data: unknown): Preset {
|
||||
return { kind: "ok", data };
|
||||
}
|
||||
|
||||
/** 构造带响应体的 AxiosError —— reject 后进入响应拦截器的错误分支 */
|
||||
function buildHttpError(
|
||||
config: InternalAxiosRequestConfig,
|
||||
status: number,
|
||||
data: unknown
|
||||
): AxiosError<ApiResponse> {
|
||||
const response = {
|
||||
data,
|
||||
status,
|
||||
statusText: String(status),
|
||||
headers: {},
|
||||
config,
|
||||
} as unknown as AxiosResponse<ApiResponse>;
|
||||
return new AxiosError<ApiResponse>(
|
||||
`Request failed with status code ${status}`,
|
||||
"ERR_BAD_RESPONSE",
|
||||
config,
|
||||
null,
|
||||
response
|
||||
);
|
||||
}
|
||||
|
||||
/** 捕获 reject 值,便于断言 message / code */
|
||||
async function captureError(promise: Promise<unknown>): Promise<unknown> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
throw new Error("预期请求失败,但请求成功了");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
presets = [];
|
||||
seenConfigs = [];
|
||||
mocks.getAccessToken.mockReturnValue("token-1");
|
||||
mocks.getRefreshToken.mockReturnValue("refresh-1");
|
||||
mocks.getRememberMe.mockReturnValue(false);
|
||||
|
||||
request.defaults.adapter = async (config) => {
|
||||
const requestConfig = config as InternalAxiosRequestConfig;
|
||||
seenConfigs.push(requestConfig);
|
||||
const next = presets.shift();
|
||||
if (!next) throw new Error("adapter: 没有更多预设响应");
|
||||
if (next.kind === "ok") {
|
||||
return {
|
||||
data: next.data,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config: requestConfig,
|
||||
} as AxiosResponse;
|
||||
}
|
||||
if (next.kind === "fail") throw buildHttpError(requestConfig, next.status, next.data);
|
||||
throw next.error;
|
||||
};
|
||||
});
|
||||
|
||||
// ────────────── 1. 新场景:非白名单业务码优先展示后端 msg ──────────────
|
||||
|
||||
describe("业务错误:优先展示后端 data.msg(不再按业务码白名单取文案)", () => {
|
||||
it("HTTP 500 + code=4500(服务端错误)展示后端 msg,而不是通用「请求失败」", async () => {
|
||||
preset({
|
||||
kind: "fail",
|
||||
status: 500,
|
||||
data: { code: 4500, msg: "分页查询失败: 连接已断开", data: null },
|
||||
});
|
||||
|
||||
const error = await captureError(request.get("/demo"));
|
||||
|
||||
expect(mocks.error).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.error).toHaveBeenCalledWith("分页查询失败: 连接已断开");
|
||||
expect((error as HttpError).message).toBe("分页查询失败: 连接已断开");
|
||||
expect(error).toBeInstanceOf(HttpError);
|
||||
expect((error as HttpError).code).toBe(500);
|
||||
});
|
||||
|
||||
it("HTTP 404 + code=404(资源不存在)展示后端 msg", async () => {
|
||||
preset({
|
||||
kind: "fail",
|
||||
status: 404,
|
||||
data: { code: 404, msg: "数据表 tables_0 不存在", data: null },
|
||||
});
|
||||
|
||||
const error = await captureError(request.get("/demo"));
|
||||
|
||||
expect(mocks.error).toHaveBeenCalledWith("数据表 tables_0 不存在");
|
||||
expect((error as HttpError).message).toBe("数据表 tables_0 不存在");
|
||||
expect((error as HttpError).code).toBe(404);
|
||||
});
|
||||
|
||||
it("HTTP 409 + 任意业务码也展示后端 msg(未来新增码不再被吞掉)", async () => {
|
||||
preset({
|
||||
kind: "fail",
|
||||
status: 409,
|
||||
data: { code: 4090, msg: "该菜单已被其他用户修改,请刷新后重试", data: null },
|
||||
});
|
||||
|
||||
await captureError(request.get("/demo"));
|
||||
|
||||
expect(mocks.error).toHaveBeenCalledWith("该菜单已被其他用户修改,请刷新后重试");
|
||||
});
|
||||
|
||||
it("后端未给 msg 时按状态码兜底(500 → internalServerError)", async () => {
|
||||
preset({ kind: "fail", status: 500, data: { code: 4500, data: null } });
|
||||
|
||||
await captureError(request.get("/demo"));
|
||||
|
||||
expect(mocks.error).toHaveBeenCalledWith("i18n:httpMsg.internalServerError");
|
||||
});
|
||||
|
||||
it("后端未给 msg 时按状态码兜底(404 → notFound)", async () => {
|
||||
preset({ kind: "fail", status: 404, data: { code: 404, data: null } });
|
||||
|
||||
await captureError(request.get("/demo"));
|
||||
|
||||
expect(mocks.error).toHaveBeenCalledWith("i18n:httpMsg.notFound");
|
||||
});
|
||||
|
||||
it("保留历史口径:HTTP 400 + code=1 且无 msg → 通用「请求失败」", async () => {
|
||||
preset({ kind: "fail", status: 400, data: { code: 1, data: null } });
|
||||
|
||||
const error = await captureError(request.get("/demo"));
|
||||
|
||||
expect(mocks.error).toHaveBeenCalledWith("i18n:httpMsg.requestFailed");
|
||||
expect((error as HttpError).code).toBe(400);
|
||||
});
|
||||
|
||||
it("保留历史口径:HTTP 500 + code=-1(EXCEPTION)且无 msg → internalServerError", async () => {
|
||||
preset({ kind: "fail", status: 500, data: { code: -1, data: null } });
|
||||
|
||||
await captureError(request.get("/demo"));
|
||||
|
||||
expect(mocks.error).toHaveBeenCalledWith("i18n:httpMsg.internalServerError");
|
||||
});
|
||||
|
||||
it("code=10403(UNAUTHORIZED)且 HTTP 非 401 时仍映射为未授权", async () => {
|
||||
preset({ kind: "fail", status: 200, data: { code: 10403, msg: "会话已失效", data: null } });
|
||||
|
||||
const error = await captureError(request.get("/demo"));
|
||||
|
||||
expect(mocks.error).toHaveBeenCalledWith("会话已失效");
|
||||
expect((error as HttpError).code).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────── 2. 既有分支:403 / 取消 / 网络错误 / Blob ──────────────
|
||||
|
||||
describe("既有分支行为不变", () => {
|
||||
it("403 优先展示后端 msg,code 仍为 403", async () => {
|
||||
preset({ kind: "fail", status: 403, data: { code: 4030, msg: "缺少 module_system:user:delete 权限" } });
|
||||
|
||||
const error = await captureError(request.get("/demo"));
|
||||
|
||||
expect(mocks.error).toHaveBeenCalledWith("缺少 module_system:user:delete 权限");
|
||||
expect((error as HttpError).code).toBe(403);
|
||||
});
|
||||
|
||||
it("403 无 msg 时回退到 forbidden 文案", async () => {
|
||||
preset({ kind: "fail", status: 403, data: { code: 4030 } });
|
||||
|
||||
await captureError(request.get("/demo"));
|
||||
|
||||
expect(mocks.error).toHaveBeenCalledWith("i18n:httpMsg.forbidden");
|
||||
});
|
||||
|
||||
it("请求取消(ERR_CANCELED)静默处理,不弹提示", async () => {
|
||||
const canceled = new AxiosError("canceled", "ERR_CANCELED");
|
||||
preset({ kind: "throw", error: canceled });
|
||||
|
||||
const error = await captureError(request.get("/demo"));
|
||||
|
||||
expect(mocks.error).not.toHaveBeenCalled();
|
||||
expect((error as HttpError).message).toBe("i18n:httpMsg.requestCancelled");
|
||||
});
|
||||
|
||||
it("网络错误(无 response)给出网络提示", async () => {
|
||||
preset({ kind: "throw", error: new AxiosError("Network Error") });
|
||||
|
||||
const error = await captureError(request.get("/demo"));
|
||||
|
||||
// 既有分支:命中 "Network Error" 给出专用文案(非 i18n key)
|
||||
expect(mocks.error).toHaveBeenCalledWith("网络连接错误,请检查您的网络设置");
|
||||
expect((error as Error).message).toBe("网络连接错误,请检查您的网络设置");
|
||||
});
|
||||
|
||||
it("Blob 下载错误:code=1 时展示错误体里的 msg(既有分支)", async () => {
|
||||
const blob = new Blob([JSON.stringify({ code: 1, msg: "文件不存在或已被删除" })]);
|
||||
preset({ kind: "fail", status: 400, data: blob });
|
||||
|
||||
const error = await captureError(request.get("/demo", { responseType: "blob" }));
|
||||
|
||||
expect(mocks.error).toHaveBeenCalledWith("文件不存在或已被删除");
|
||||
expect((error as Error).message).toBe("文件不存在或已被删除");
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────── 2b. Blob 下载错误:任意业务码都取后端 msg ──────────────
|
||||
|
||||
/** 构造下载响应:responseType=blob 的失败响应 */
|
||||
function presetBlobFailure(status: number, body: Blob | string): void {
|
||||
preset({ kind: "fail", status, data: typeof body === "string" ? new Blob([body]) : body });
|
||||
}
|
||||
|
||||
const blobExport = () => request.get("/demo/export", { responseType: "blob" });
|
||||
|
||||
describe("Blob 下载错误:解析错误体并优先展示后端 msg", () => {
|
||||
it("code=4500(内部故障)展示后端 msg,而不是通用文案", async () => {
|
||||
presetBlobFailure(500, JSON.stringify({ code: 4500, msg: "导出失败: 磁盘空间不足" }));
|
||||
|
||||
const error = await captureError(blobExport());
|
||||
|
||||
expect(mocks.error).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.error).toHaveBeenCalledWith("导出失败: 磁盘空间不足");
|
||||
expect((error as HttpError).message).toBe("导出失败: 磁盘空间不足");
|
||||
expect(error).toBeInstanceOf(HttpError);
|
||||
expect((error as HttpError).code).toBe(500);
|
||||
});
|
||||
|
||||
it("code=404(资源不存在)展示后端 msg", async () => {
|
||||
presetBlobFailure(404, JSON.stringify({ code: 404, msg: "待导出的记录已不存在" }));
|
||||
|
||||
const error = await captureError(blobExport());
|
||||
|
||||
expect(mocks.error).toHaveBeenCalledWith("待导出的记录已不存在");
|
||||
expect((error as HttpError).code).toBe(404);
|
||||
});
|
||||
|
||||
it("code=4090(未来新增的业务码)同样展示后端 msg", async () => {
|
||||
presetBlobFailure(409, JSON.stringify({ code: 4090, msg: "该导出任务已被取消" }));
|
||||
|
||||
await captureError(blobExport());
|
||||
|
||||
expect(mocks.error).toHaveBeenCalledWith("该导出任务已被取消");
|
||||
});
|
||||
|
||||
it("有业务码但无 msg 时保留原有 1/-1 兜底文案(code=1)", async () => {
|
||||
presetBlobFailure(400, JSON.stringify({ code: 1 }));
|
||||
|
||||
await captureError(blobExport());
|
||||
|
||||
expect(mocks.error).toHaveBeenCalledWith("i18n:httpMsg.requestFailed");
|
||||
});
|
||||
|
||||
it("有业务码但无 msg 时保留原有 1/-1 兜底文案(code=-1)", async () => {
|
||||
presetBlobFailure(500, JSON.stringify({ code: -1 }));
|
||||
|
||||
await captureError(blobExport());
|
||||
|
||||
expect(mocks.error).toHaveBeenCalledWith("i18n:httpMsg.internalServerError");
|
||||
});
|
||||
|
||||
it("非 1/-1 的业务码且无 msg 时按 HTTP 状态码兜底(500 → internalServerError)", async () => {
|
||||
presetBlobFailure(500, JSON.stringify({ code: 4500 }));
|
||||
|
||||
const error = await captureError(blobExport());
|
||||
|
||||
expect(mocks.error).toHaveBeenCalledWith("i18n:httpMsg.internalServerError");
|
||||
expect((error as HttpError).code).toBe(500);
|
||||
});
|
||||
|
||||
it("非 JSON 的二进制 Blob 不抛额外异常,按 HTTP 状态码兜底并 reject", async () => {
|
||||
// PNG magic bytes:真二进制内容,JSON.parse 必然失败
|
||||
presetBlobFailure(500, new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a])]));
|
||||
|
||||
const error = await captureError(blobExport());
|
||||
|
||||
expect(error).toBeInstanceOf(HttpError);
|
||||
expect((error as HttpError).message).toBe("i18n:httpMsg.internalServerError");
|
||||
expect((error as HttpError).code).toBe(500);
|
||||
expect(mocks.error).toHaveBeenCalledWith("i18n:httpMsg.internalServerError");
|
||||
});
|
||||
|
||||
it("空 Blob(下载中断/空响应体)不抛额外异常,按 HTTP 状态码兜底", async () => {
|
||||
presetBlobFailure(404, new Blob([]));
|
||||
|
||||
const error = await captureError(blobExport());
|
||||
|
||||
expect(error).toBeInstanceOf(HttpError);
|
||||
expect((error as HttpError).code).toBe(404);
|
||||
expect((error as HttpError).message).toBe("i18n:httpMsg.notFound");
|
||||
});
|
||||
|
||||
it("错误体读取本身失败(下载中断)不抛额外异常,按 HTTP 状态码兜底", async () => {
|
||||
const unreadable = new Blob(["partial"]);
|
||||
Object.defineProperty(unreadable, "text", {
|
||||
value: () => Promise.reject(new Error("download interrupted")),
|
||||
});
|
||||
presetBlobFailure(500, unreadable);
|
||||
|
||||
const error = await captureError(blobExport());
|
||||
|
||||
expect(error).toBeInstanceOf(HttpError);
|
||||
expect((error as HttpError).code).toBe(500);
|
||||
expect((error as HttpError).message).toBe("i18n:httpMsg.internalServerError");
|
||||
});
|
||||
|
||||
it("401 下载失败仍走静默续期,不被错误体 msg 短路", async () => {
|
||||
mocks.refreshToken.mockRejectedValue(new Error("refresh 401"));
|
||||
presetBlobFailure(401, JSON.stringify({ code: 10401, msg: "令牌已过期" }));
|
||||
|
||||
const error = await captureError(blobExport());
|
||||
|
||||
expect(mocks.refreshToken).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.redirectToLogin).toHaveBeenCalledWith("登录已失效,请重新登录");
|
||||
expect((error as HttpError).code).toBe(401);
|
||||
// 未被 Blob 错误体的 msg 短路
|
||||
expect(mocks.error).not.toHaveBeenCalledWith("令牌已过期");
|
||||
});
|
||||
|
||||
it("成功下载(200 + 二进制流)路径零改动:原样返回 Blob 且不弹提示", async () => {
|
||||
const binary = new Blob([new Uint8Array([0x50, 0x4b, 0x03, 0x04])]);
|
||||
preset(ok(binary));
|
||||
|
||||
const response = await blobExport();
|
||||
|
||||
expect(response.data).toBe(binary);
|
||||
expect(mocks.error).not.toHaveBeenCalled();
|
||||
expect(mocks.success).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────── 3. 401 静默续期(含重放) ──────────────
|
||||
|
||||
describe("401 静默续期", () => {
|
||||
it("401 时刷新令牌并重放原请求,不弹错误提示", async () => {
|
||||
mocks.refreshToken.mockResolvedValue({
|
||||
data: { data: { access_token: "new-token", refresh_token: "new-refresh" } },
|
||||
});
|
||||
mocks.getAccessToken.mockReturnValue("new-token");
|
||||
preset(
|
||||
{ kind: "fail", status: 401, data: { code: 10401, msg: "令牌已过期" } },
|
||||
ok({ code: 0, msg: "ok", data: { hit: true } })
|
||||
);
|
||||
|
||||
const response = await request.get("/demo");
|
||||
|
||||
expect(mocks.refreshToken).toHaveBeenCalledWith("refresh-1");
|
||||
expect(mocks.setTokens).toHaveBeenCalledWith("new-token", "new-refresh", false);
|
||||
expect(response.data).toEqual({ code: 0, msg: "ok", data: { hit: true } });
|
||||
expect(mocks.error).not.toHaveBeenCalled();
|
||||
// 第二次请求(重放)带着新令牌
|
||||
expect(seenConfigs).toHaveLength(2);
|
||||
expect(seenConfigs[1]?.headers.Authorization).toBe("Bearer new-token");
|
||||
});
|
||||
|
||||
it("刷新失败时跳转登录页并拒绝请求", async () => {
|
||||
mocks.refreshToken.mockRejectedValue(new Error("refresh 401"));
|
||||
|
||||
preset({ kind: "fail", status: 401, data: { code: 10401, msg: "令牌已过期" } });
|
||||
|
||||
const error = await captureError(request.get("/demo"));
|
||||
|
||||
expect(mocks.redirectToLogin).toHaveBeenCalledWith("令牌已过期");
|
||||
expect((error as HttpError).code).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────── 4. handleError 与拦截器同口径 ──────────────
|
||||
|
||||
describe("handleError(useTable 等使用的导出函数)", () => {
|
||||
const config = {} as InternalAxiosRequestConfig;
|
||||
|
||||
// handleError 是同步函数(throw,不返回 Promise),故用 toThrow 断言
|
||||
it("优先使用后端 msg,不再只按状态码映射文案", () => {
|
||||
const error = buildHttpError(config, 500, {
|
||||
code: 4500,
|
||||
msg: "导出失败: 磁盘空间不足",
|
||||
data: null,
|
||||
});
|
||||
|
||||
expect(() => handleError(error)).toThrow("导出失败: 磁盘空间不足");
|
||||
});
|
||||
|
||||
it("后端无 msg 时按状态码兜底", () => {
|
||||
const error = buildHttpError(config, 503, { code: 4503, data: null });
|
||||
|
||||
expect(() => handleError(error)).toThrow("i18n:httpMsg.serviceUnavailable");
|
||||
});
|
||||
|
||||
it("取消的请求不读取响应体,抛通用取消文案", () => {
|
||||
const canceled = new AxiosError<ApiResponse>("canceled", "ERR_CANCELED");
|
||||
|
||||
expect(() => handleError(canceled)).toThrow("i18n:httpMsg.requestCancelled");
|
||||
});
|
||||
|
||||
it("无响应体时抛网络错误文案", () => {
|
||||
expect(() => handleError(new AxiosError<ApiResponse>("Network Error"))).toThrow(
|
||||
"i18n:httpMsg.networkError"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────── 5. 成功路径不受影响 ──────────────
|
||||
|
||||
describe("成功路径", () => {
|
||||
it("code=0 的 GET 请求正常返回,且不弹任何提示", async () => {
|
||||
preset(ok({ code: 0, msg: "查询成功", data: { list: [] } }));
|
||||
|
||||
const response = await request.get("/demo");
|
||||
|
||||
expect(response.data.data).toEqual({ list: [] });
|
||||
expect(mocks.error).not.toHaveBeenCalled();
|
||||
expect(mocks.success).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -94,20 +94,78 @@ export class HttpError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const getErrorMessage = (status: number): string => {
|
||||
const errorMap: Record<number, string> = {
|
||||
[ApiStatus.unauthorized]: "httpMsg.unauthorized",
|
||||
[ApiStatus.forbidden]: "httpMsg.forbidden",
|
||||
[ApiStatus.notFound]: "httpMsg.notFound",
|
||||
[ApiStatus.methodNotAllowed]: "httpMsg.methodNotAllowed",
|
||||
[ApiStatus.requestTimeout]: "httpMsg.requestTimeout",
|
||||
[ApiStatus.internalServerError]: "httpMsg.internalServerError",
|
||||
[ApiStatus.badGateway]: "httpMsg.badGateway",
|
||||
[ApiStatus.serviceUnavailable]: "httpMsg.serviceUnavailable",
|
||||
[ApiStatus.gatewayTimeout]: "httpMsg.gatewayTimeout",
|
||||
};
|
||||
/**
|
||||
* HTTP 状态码 → 兜底文案 key。
|
||||
*
|
||||
* 这是**兜底**映射:只在后端没有返回 `msg` 时才使用。
|
||||
* (后端业务码已收敛为单一事实来源,`RET.code → HTTP status`,所以前端不再按业务码
|
||||
* 挑选文案,详情见下方响应拦截器的「业务错误」分支。)
|
||||
*
|
||||
* 显式收录 400 → 通用「请求失败」:旧实现在业务码未命中白名单时统一走 else 分支显示
|
||||
* 「请求失败」,收录后这条路径的文案与旧行为保持一致。
|
||||
* 未收录的状态码(409/422/429…)同样退化为通用「请求失败」,比「服务器内部错误」更准确。
|
||||
*/
|
||||
const STATUS_MESSAGE_KEYS: Record<number, string> = {
|
||||
[ApiStatus.error]: "httpMsg.requestFailed",
|
||||
[ApiStatus.unauthorized]: "httpMsg.unauthorized",
|
||||
[ApiStatus.forbidden]: "httpMsg.forbidden",
|
||||
[ApiStatus.notFound]: "httpMsg.notFound",
|
||||
[ApiStatus.methodNotAllowed]: "httpMsg.methodNotAllowed",
|
||||
[ApiStatus.requestTimeout]: "httpMsg.requestTimeout",
|
||||
[ApiStatus.internalServerError]: "httpMsg.internalServerError",
|
||||
[ApiStatus.badGateway]: "httpMsg.badGateway",
|
||||
[ApiStatus.serviceUnavailable]: "httpMsg.serviceUnavailable",
|
||||
[ApiStatus.gatewayTimeout]: "httpMsg.gatewayTimeout",
|
||||
};
|
||||
|
||||
return $t(errorMap[status] || "httpMsg.internalServerError");
|
||||
/** 状态码兜底文案(未收录的状态码退化为通用「请求失败」) */
|
||||
const getErrorMessage = (status: number): string =>
|
||||
$t(STATUS_MESSAGE_KEYS[status] || "httpMsg.requestFailed");
|
||||
|
||||
/**
|
||||
* 业务码兜底文案(仅用于后端未返回 `msg` 的情况)。
|
||||
* 未知业务码返回 `undefined`,让调用方继续按 HTTP 状态码回退。
|
||||
*/
|
||||
const getCodeFallbackMessage = (code?: number): string | undefined => {
|
||||
switch (code) {
|
||||
case ResultEnum.ERROR:
|
||||
return $t("httpMsg.requestFailed");
|
||||
case ResultEnum.EXCEPTION:
|
||||
return $t("httpMsg.internalServerError");
|
||||
case ResultEnum.UNAUTHORIZED:
|
||||
return $t("httpMsg.unauthorized");
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 Blob 错误体里读取后端 msg(文件下载失败场景)。
|
||||
*
|
||||
* `responseType: "blob"` 时,后端返回的 JSON 错误体也会被包成 Blob,所以这里主动解析一次:
|
||||
* - 能解析出对象且 `code` 是非成功业务码 → 返回其后端 `msg`(错误码已收敛为单一事实来源,
|
||||
* 不再只认 `ERROR(1)`/`EXCEPTION(-1)`,4500/404/4090 等同样能取到诊断信息);
|
||||
* - 有业务码但没 msg → 返回原有业务码兜底文案(保留 1/-1 的旧口径);
|
||||
* - 解析不出结构化错误体(真正的二进制内容 / 空内容 / 下载中断 / 非 JSON)→ 返回 `undefined`,
|
||||
* 由调用方按 HTTP 状态码兜底。
|
||||
*
|
||||
* 本函数保证不抛异常:任何异常都只记录日志并返回 `undefined`。
|
||||
*/
|
||||
const readBlobErrorMessage = async (blob: Blob): Promise<string | undefined> => {
|
||||
try {
|
||||
const text = await new Response(blob).text();
|
||||
if (!text.trim()) return undefined;
|
||||
|
||||
const jsonData = JSON.parse(text) as ApiResponse | null;
|
||||
if (!jsonData || typeof jsonData.code !== "number" || jsonData.code === ResultEnum.SUCCESS) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return jsonData.msg || getCodeFallbackMessage(jsonData.code);
|
||||
} catch (error) {
|
||||
console.error("[下载错误体解析失败]", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export function handleError(error: AxiosError<ApiResponse>): never {
|
||||
@@ -117,7 +175,6 @@ export function handleError(error: AxiosError<ApiResponse>): never {
|
||||
}
|
||||
|
||||
const statusCode = error.response?.status;
|
||||
const errorMessage = error.response?.data?.msg || error.message;
|
||||
const requestConfig = error.config;
|
||||
|
||||
if (!error.response) {
|
||||
@@ -127,9 +184,11 @@ export function handleError(error: AxiosError<ApiResponse>): never {
|
||||
});
|
||||
}
|
||||
|
||||
const message = statusCode
|
||||
? getErrorMessage(statusCode)
|
||||
: errorMessage || $t("httpMsg.requestFailed");
|
||||
// 与响应拦截器同口径:后端 msg 优先(保留其诊断信息),缺失时再按状态码兜底
|
||||
const message =
|
||||
error.response.data?.msg ||
|
||||
(statusCode ? getErrorMessage(statusCode) : "") ||
|
||||
$t("httpMsg.requestFailed");
|
||||
throw new HttpError(message, statusCode || ApiStatus.error, {
|
||||
data: error.response.data,
|
||||
url: requestConfig?.url,
|
||||
@@ -283,30 +342,23 @@ request.interceptors.response.use(
|
||||
}
|
||||
|
||||
const data = error.response?.data;
|
||||
const status = error.response.status;
|
||||
|
||||
// ── Blob 响应错误(文件下载场景) ──
|
||||
if (error.response?.config.responseType === "blob" && error.response.data instanceof Blob) {
|
||||
try {
|
||||
const text = await new Response(error.response.data).text();
|
||||
const jsonData: ApiResponse = JSON.parse(text);
|
||||
|
||||
if (jsonData.code === ResultEnum.ERROR) {
|
||||
ElMessage.error(jsonData.msg || $t("httpMsg.requestFailed"));
|
||||
return Promise.reject(new Error(jsonData.msg || $t("httpMsg.requestFailed")));
|
||||
} else if (jsonData.code === ResultEnum.EXCEPTION) {
|
||||
ElMessage.error(jsonData.msg || $t("httpMsg.internalServerError"));
|
||||
return Promise.reject(new Error(jsonData.msg || $t("httpMsg.internalServerError")));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("请求异常:", e);
|
||||
ElMessage.error($t("httpMsg.requestFailed"));
|
||||
return Promise.reject(new Error($t("httpMsg.requestFailed")));
|
||||
// 下载失败时后端返回的是 JSON 错误体,但 responseType=blob 让这里只能拿到 Blob,
|
||||
// 因此主动解析一次,使下载场景也能优先展示后端 msg(与下方业务错误分支同口径)。
|
||||
// 例外:HTTP 401 必须留给下面的「静默续期」,不能被这里的 msg 短路。
|
||||
if (status !== 401 && error.response.config.responseType === "blob" && data instanceof Blob) {
|
||||
const blobMessage = await readBlobErrorMessage(data);
|
||||
if (blobMessage) {
|
||||
ElMessage.error(blobMessage);
|
||||
return Promise.reject(new HttpError(blobMessage, status || ApiStatus.error));
|
||||
}
|
||||
// 拿不到结构化错误体(真正的二进制内容 / 空内容 / 下载中断):
|
||||
// 不抛额外异常,继续走下面的状态码兜底
|
||||
}
|
||||
|
||||
// ── 鉴权错误(401 / TOKEN_EXPIRED):静默续期 ──
|
||||
const status = error.response.status;
|
||||
|
||||
if (status === 401 || data?.code === ResultEnum.TOKEN_EXPIRED) {
|
||||
const config = error.config as InternalAxiosRequestConfig | undefined;
|
||||
|
||||
@@ -357,32 +409,31 @@ request.interceptors.response.use(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 业务错误(按 code 分类) ──
|
||||
// ── 无权限(403):保持既有分支与文案优先级不变 ──
|
||||
if (status === 403) {
|
||||
ElMessage.error(data?.msg || $t("httpMsg.forbidden"));
|
||||
return Promise.reject(
|
||||
new HttpError(data?.msg || $t("httpMsg.forbidden"), ApiStatus.forbidden)
|
||||
);
|
||||
}
|
||||
if (data?.code === ResultEnum.ERROR) {
|
||||
ElMessage.error(data.msg || $t("httpMsg.requestFailed"));
|
||||
return Promise.reject(
|
||||
new HttpError(data.msg || $t("httpMsg.requestFailed"), ApiStatus.error)
|
||||
);
|
||||
} else if (data?.code === ResultEnum.UNAUTHORIZED) {
|
||||
ElMessage.error(data.msg || $t("httpMsg.unauthorized"));
|
||||
return Promise.reject(
|
||||
new HttpError(data.msg || $t("httpMsg.unauthorized"), ApiStatus.unauthorized)
|
||||
);
|
||||
} else if (data?.code === ResultEnum.EXCEPTION) {
|
||||
ElMessage.error(data.msg || $t("httpMsg.internalServerError"));
|
||||
return Promise.reject(
|
||||
new HttpError(data.msg || $t("httpMsg.internalServerError"), ApiStatus.error)
|
||||
);
|
||||
} else {
|
||||
ElMessage.error($t("httpMsg.requestFailed"));
|
||||
return Promise.reject(new Error($t("httpMsg.requestFailed")));
|
||||
}
|
||||
|
||||
// ── 业务错误 ──
|
||||
// 后端业务码与 HTTP 状态码已收敛为单一事实来源(RET.code → status),前端不再按
|
||||
// 业务码白名单挑选文案:只要后端给了 msg 就优先展示(保留其诊断信息,如
|
||||
// 「分页查询失败: 连接已断开」),缺失时才按「业务码 → 状态码 → 通用」回退。
|
||||
const message =
|
||||
data?.msg ||
|
||||
getCodeFallbackMessage(data?.code) ||
|
||||
(status ? getErrorMessage(status) : "") ||
|
||||
$t("httpMsg.requestFailed");
|
||||
|
||||
ElMessage.error(message);
|
||||
return Promise.reject(
|
||||
new HttpError(
|
||||
message,
|
||||
data?.code === ResultEnum.UNAUTHORIZED ? ApiStatus.unauthorized : status || ApiStatus.error
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user