Skip to content

axios 封装与 API 约定 ​

适用端:Web | 类型:机制

所有后端请求都走 src/config/axios 这一个实例:token、租户头、错误提示、令牌无感刷新、加解密全部在拦截器里做完,src/api/** 只写「URL + 参数」。本页讲清这套封装的边界与几个会咬人的地方。

前置阅读:接口约定与 CRUD 骨架(后端返回什么形状)、前端工程结构(@/ alias 与环境变量)。

它解决什么问题 ​

三句话概括分工:

  • api 层不碰 axios,只 import request from '@/config/axios',一个后端接口对应一个导出函数;
  • index.ts 负责「解包」:把 CommonResult 的 data 直接返回给调用方,页面拿到的就是业务数据;
  • service.ts 负责「兜底」:加 token、加租户头、弹错误提示、刷新令牌、解密响应。页面里几乎不需要写 try/catch 来提示用户——提示已经在拦截器里发过了。

代码在哪 ​

文件职责
src/config/axios/config.tsbase_url、result_code、request_timeout、default_headers 四个常量
src/config/axios/service.tsaxios 实例 + 请求/响应两个拦截器 + refreshToken() + handleAuthorized() + 导出 isRelogin
src/config/axios/index.ts对外默认导出:get/post/postOriginal/put/delete/download/upload
src/config/axios/errorCode.ts401/403/404/default 四条兜底文案(仅当后端没给 msg 时用到)
src/config/axios/service.spec.ts扫源码原文断言「业务错误那一支必须 reject Error 且必须弹通知」
src/utils/auth.tstoken / 租户 id 的读写(底层 useCache → localStorage)
src/hooks/web/useCache.tsCACHE_KEY 常量表 + deleteUserCache()
src/utils/encrypt.tsApiEncrypt(AES / RSA),被请求拦截器按需调用
src/utils/download.tsdownload.excel/word/zip/html/markdown/json/image,把 Blob 变成文件下载
types/global.d.tsPageParam / PageResult / IResponse 全局类型声明(无需 import)

核心机制 ​

baseURL 由两个环境变量拼出来 ​

ts
// src/config/axios/config.ts
const config = {
  base_url: import.meta.env.VITE_BASE_URL + import.meta.env.VITE_API_URL,
  result_code: 200,      // 接口成功返回状态码
  request_timeout: 30000,
  default_headers: 'application/json'
}

.env.local 里是 VITE_BASE_URL='http://localhost:48080' + VITE_API_URL=/admin-api,拼出 http://localhost:48080/admin-api。浏览器直连后端,不经 Vite 代理(vite.config.ts 的 proxy 段是注释掉的,理由写在原注释里:「server 端已经支持跨域」)。所以换后端地址只改环境变量,不要试图去开代理。

六个方法,返回值形状不一样 ​

ts
// src/config/axios/index.ts(节选)
get:    async <T = any>(option) => (await request({ method: 'GET',    ...option })).data as T,
post:   async <T = any>(option) => (await request({ method: 'POST',   ...option })).data as T,
put:    async <T = any>(option) => (await request({ method: 'PUT',    ...option })).data as T,
delete: async <T = any>(option) => (await request({ method: 'DELETE', ...option })).data as T,
download: async <T>(option) => request({ method: 'GET', responseType: 'blob', ...option }) as Promise<T>,
upload:   async <T>(option) => { option.headersType = 'multipart/form-data'; return request({ method: 'POST', ...option }) }
方法返回什么时候用
get/post/put/deleteCommonResult.data(已解包)99% 的接口
postOriginal整个 CommonResult需要读 code / msg 本身时
downloadBlob导出 Excel、下载文件,配 download.excel(data, 文件名)
upload整个响应对象上传文件(自动把 Content-Type 换成 multipart/form-data)

request() 内部只做一件事:把 headersType 提升成 Content-Type,再合并调用方传的 headers——这就是 headers: { isToken: false } / headers: { isEncrypt: true } 这类「非标准 axios 头」能生效的原因,它们是给拦截器看的暗号,不是真要发给后端(axios 会把它们一并发出去,后端忽略)。

请求拦截器做的五件事 ​

ts
// src/config/axios/service.ts(节选,完整实现见原文件)
let isToken = (config.headers || {}).isToken !== false
if (isToken && whiteList.some((v) => config.url?.includes(v))) isToken = false   // whiteList = ['/login', '/refresh-token']
if (getAccessToken() && isToken) config.headers.Authorization = 'Bearer ' + getAccessToken()

if (tenantEnable === 'true') {                       // VITE_APP_TENANT_ENABLE
  if (getTenantId()) config.headers['tenant-id'] = getTenantId()
  if (config.headers.Authorization && getVisitTenantId()) config.headers['visit-tenant-id'] = getVisitTenantId()
}
if (method === 'GET') { config.headers['Cache-Control'] = 'no-cache'; config.headers['Pragma'] = 'no-cache' }
if ((config.headers || {}).isEncrypt && !(config.headers || {}).isEncrypted) config.data = ApiEncrypt.encryptRequest(config.data)
动作触发条件备注
Authorization: Bearer <token>有 token 且不在白名单白名单是 url.includes() 匹配,/login、/refresh-token
tenant-idVITE_APP_TENANT_ENABLE === 'true' 且本地存了租户.env 里默认开着
visit-tenant-id已登录 + 存了访问租户平台「穿透到客户租户」用,未登录不发
GET 禁缓存method 是 GETCache-Control + Pragma 双写
请求体加密headers.isEncrypt === true见下方「加密链路当前是空转的」

另外实例级别还配了两项:withCredentials: false(不带 Cookie)与 paramsSerializer: qs.stringify(params, { allowDots: true })。后者意味着 GET 的嵌套对象会序列化成 a.b=c,数组默认是 ids[0]=1&ids[1]=2——所以现有代码里批量删除都写成 params: { ids: ids.join(',') },交给后端的 List<Long> 去解析逗号串。

响应拦截器:七个分支 ​

几个容易看漏的点:

  • 成功时 return data,返回的是整个 CommonResult,不是 data.data。解包发生在 index.ts 的 res.data。所以直接用 service(...) 会多套一层;
  • code === 0 也算成功(code !== 0 && code !== 200 才走错误分支),这是为了兼容部分接口返回 0;
  • 响应里没有 code 字段时按成功处理(data.code ?? result_code,result_code = 200);
  • ignoreMsgs 那一支 reject 的是字符串('无效的刷新令牌' / '刷新令牌已过期'),不是 Error——这是全文件唯一一处形状不一致,catch (e) 里取 e.message 会是 undefined;
  • HTTP 状态码恒为 200,业务码在 body 里(后端 GlobalExceptionHandler 的行为,见 接口约定与 CRUD 骨架)。因此 axios 的 error 回调只在网络层失败(断网、超时、非 2xx)时触发,它会把 Network Error / timeout / Request failed with status code XXX 翻译成本地化文案后 ElMessage.error。

401 无感刷新 ​

handleAuthorized() 弹一个不可关闭的 ElMessageBox(showCancelButton: false、closeOnClickModal: false、showClose: false、closeOnPressEscape: false),确认后依次 resetRouter() → deleteUserCache() → removeToken() → window.location.href = window.location.href 整页重载,让 router.beforeEach 重新走一遍校验。isRelogin.show 是防止重复弹窗的开关,permission.ts 在首次加载用户信息时也会临时把它置 true(避免加载中的 401 弹窗打断)。

若当前已经在登录页(window.location.href.includes('login')),直接 return,不弹窗。

数据模型 ​

api 层用到的类型都是全局声明(types/global.d.ts,整个文件包在 export {} + declare global {} 里),直接写、不要 import:

ts
interface PageParam { pageSize?: number; pageNo?: number }   // 两个字段都是可选
interface PageResult<T> { list: T; total: number }           // 注意 list 的类型参数没有加 []
interface IResponse<T = any> { code: string; data: T }
interface AxiosConfig { params?; data?; url?; method?; headersType?; responseType? }

src/api/types.ts 不存在

按上游习惯去找 @/api/types 会报找不到模块。PageParam / PageResult / IResponse 在 types/global.d.ts 里以全局 interface 声明,TS 直接可见。

PageResult<T> 的泛型参数没带 [],是上游的写法

声明是 list: T,所以严格标注时应写 PageResult<XxxVO[]>。实际代码里两种写法都有(PageResult<XxxVO> 也能过,因为 list 之后基本只做 v-for)。跟随同域现有文件的写法即可,不要为了「修正类型」批量改。

api 文件自己的 VO 类型写在同一个 index.ts 顶部并 export interface XxxVO(例:src/api/system/post/index.ts 的 PostVO)。字段可空性与后端 XxxSaveReqVO 保持一致,id 与 createTime 用可选。

标准写法:一个 api 文件长什么样 ​

ts
// src/api/system/post/index.ts
import request from '@/config/axios'

export interface PostVO { id?: number; name: string; code: string; sort: number; status: number; remark: string; createTime?: Date }

export const getPostPage      = async (params: PageParam) => await request.get({ url: '/system/post/page', params })
export const getSimplePostList = async (): Promise<PostVO[]> => await request.get({ url: '/system/post/simple-list' })
export const getPost          = async (id: number) => await request.get({ url: '/system/post/get?id=' + id })
export const createPost       = async (data: PostVO) => await request.post({ url: '/system/post/create', data })
export const updatePost       = async (data: PostVO) => await request.put({ url: '/system/post/update', data })
export const deletePost       = async (id: number) => await request.delete({ url: '/system/post/delete?id=' + id })
export const deletePostList   = async (ids: number[]) => await request.delete({ url: '/system/post/delete-list', params: { ids: ids.join(',') } })
export const exportPost       = async (params) => await request.download({ url: '/system/post/export-excel', params })

约定:

  • URL 不带 /admin-api 前缀(baseURL 里已经有了),但必须带模块段(/system/...、/infra/...),因为后端是按包名 **.controller.admin.** 加前缀的;
  • 函数名与后端 Controller 方法名同形:getXxxPage / getXxx / createXxx / updateXxx / deleteXxx / exportXxx / getSimpleXxxList;
  • 查询用 params(进 query string),写入用 data(进 body);
  • 单参数接口既有拼在 URL 上的(?id= + id),也有走 params 的,跟随同域现有写法即可。

怎么扩展 ​

加一个新接口:在对应的 src/api/<域>/<功能>/index.ts 里追加一个导出函数,URL 照后端 @RequestMapping + @XxxMapping 拼。前端不需要注册、不需要改任何配置。

导出文件(下载):

ts
// api 层
export const exportXxx = async (params) => await request.download({ url: '/<域>/<功能>/export-excel', params })

// 页面层
const data = await exportXxx(queryParams)
download.excel(data, 'xxx.xls')   // download 来自 src/utils/download.ts,需显式 import

download 方法固定 responseType: 'blob'。响应拦截器对 blob 有特殊处理:若返回的 type 是 application/json,说明后端其实是报错了,会把 blob 读成 JSON 继续按 code 判分支——所以导出失败时用户看到的是正常的错误通知,而不是下载到一个坏文件。

上传文件:用 request.upload({ url, data: formData }),它会自动把 Content-Type 设成 multipart/form-data。注意 upload 返回的是整个响应对象,不是解包后的 data。

发一个不带 token 的请求:headers: { isToken: false }。现有唯一例子是登录相关;也可以用 URL 命中白名单的方式,但白名单是 includes 匹配,容易误伤(任何 URL 里含 /login 字样的接口都会被摘掉 token)。

发一个加密请求:headers: { isEncrypt: true },由 ApiEncrypt.encryptRequest 按 .env 里的算法与密钥加密请求体,并打上 X-Api-Encrypt: true 头;响应若带同名头则自动解密。

加密链路当前是空转的

前端 .env 里 VITE_APP_API_ENCRYPT_ENABLE = true,但后端 application.yaml 的 enlev.api-encrypt.enable: false;更关键的是全仓没有任何一处传 isEncrypt: true(实测 grep:isEncrypt 只在 src/api/login/index.ts 出现过一次,值是 false)。也就是说这套加解密代码完整存在、但从未被触发。要真启用必须前后端同时开,并且给需要加密的接口逐个加 headers: { isEncrypt: true }。

让某个错误不弹提示:拦截器没有提供 per-request 的「静默」开关。想自己处理提示,只能读 postOriginal 拿 code/msg,或者在页面 catch 里做二次展示——但通知已经弹出去了,重复提示要自己权衡。

坑与注意 ​

  • catch (e) 里 e.message 可能是 undefined:ignoreMsgs 那一支 reject 的是字符串。业务错误分支已经统一成 reject(new Error(msg))(service.spec.ts 就是钉这个的,改回去会让测试失败),但 ignoreMsgs 分支仍是历史形状。
  • 业务错误分支的注释不要删:service.ts 里那段 🔴 注释解释了「为什么 reject Error 而不是字面量 'error'」以及「为什么 ElNotification 那一行也不能删」。service.spec.ts 同时断言这两点,且抓不到锚点就抛,重构这段时测试会直接失败而不是静默通过。
  • token 存在 localStorage:useCache() 默认 localStorage(ACCESS_TOKEN / REFRESH_TOKEN / tenantId / visitTenantId / user / roleRouters)。这是 XSS 可读的位置,写页面时不要引入不可信的 v-html。
  • deleteUserCache() 不删 token:它只清 user、roleRouters、visitTenantId,并特意保留 loginForm(注释:「注意,不要清理 LoginForm 登录表单」)。登出流程必须再调一次 removeToken()。
  • refreshToken() 用的是裸 axios,不是 service 实例:所以它不带请求拦截器的逻辑,只手动塞了 axios.defaults.headers.common['tenant-id']。这是刻意的——走实例会在 401 时递归。
  • 没有重复请求取消 / 竞态保护:封装里不含 cancel 逻辑。搜索框连续输入触发的并发 page 请求,返回顺序不保证,页面要自己做「只认最后一次」的处理。
  • 901 分支的提示文案里让用户「参考 https://doc.enlev.cn/ 教程」:这是改名时把上游文档域名整体替换的产物,指向的站点与本手册无关。真要改文案,注意它带 dangerouslyUseHTMLString: true。
  • getVisitTenantId 的命名不符合驼峰(其余都是 getXxx),改它要同步 service.ts 的引用。
  • config.ts 的 result_code 是数字 200,与「HTTP 状态码 200」是两回事,别混。
  • 改 base_url 会影响 refreshToken():它拼的是 base_url + '/system/auth/refresh-token?...',走的是完整的 /admin-api 前缀。

→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。

相关页面 ​