From b95e24618ea905b2174b34f967027463ffd130e6 Mon Sep 17 00:00:00 2001 From: zhangtao <9480807882@qq.com> Date: Sun, 4 Oct 2026 05:44:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E9=94=99=E8=AF=AF=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E6=94=B9=E4=B8=BA=E5=90=8E=E7=AB=AF=20msg=20=E4=BC=98?= =?UTF-8?q?=E5=85=88=EF=BC=8C=E5=8E=BB=E6=8E=89=E4=B8=9A=E5=8A=A1=E7=A0=81?= =?UTF-8?q?=E7=99=BD=E5=90=8D=E5=8D=95=E8=80=A6=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 普通错误分支与 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 续期不回归)。 --- .../src/__tests__/http-error-message.spec.ts | 502 ++++++++++++++++++ frontend/web/src/utils/http/index.ts | 161 ++++-- 2 files changed, 608 insertions(+), 55 deletions(-) create mode 100644 frontend/web/src/__tests__/http-error-message.spec.ts diff --git a/frontend/web/src/__tests__/http-error-message.spec.ts b/frontend/web/src/__tests__/http-error-message.spec.ts new file mode 100644 index 00000000..9f1a1e2a --- /dev/null +++ b/frontend/web/src/__tests__/http-error-message.spec.ts @@ -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 { + 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 { + const response = { + data, + status, + statusText: String(status), + headers: {}, + config, + } as unknown as AxiosResponse; + return new AxiosError( + `Request failed with status code ${status}`, + "ERR_BAD_RESPONSE", + config, + null, + response + ); +} + +/** 捕获 reject 值,便于断言 message / code */ +async function captureError(promise: Promise): Promise { + 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("canceled", "ERR_CANCELED"); + + expect(() => handleError(canceled)).toThrow("i18n:httpMsg.requestCancelled"); + }); + + it("无响应体时抛网络错误文案", () => { + expect(() => handleError(new AxiosError("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(); + }); +}); diff --git a/frontend/web/src/utils/http/index.ts b/frontend/web/src/utils/http/index.ts index c32b83a0..797e2939 100644 --- a/frontend/web/src/utils/http/index.ts +++ b/frontend/web/src/utils/http/index.ts @@ -94,20 +94,78 @@ export class HttpError extends Error { } } -const getErrorMessage = (status: number): string => { - const errorMap: Record = { - [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 = { + [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 => { + 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): never { @@ -117,7 +175,6 @@ export function handleError(error: AxiosError): 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): 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 + ) + ); } );