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.ts | base_url、result_code、request_timeout、default_headers 四个常量 |
src/config/axios/service.ts | axios 实例 + 请求/响应两个拦截器 + refreshToken() + handleAuthorized() + 导出 isRelogin |
src/config/axios/index.ts | 对外默认导出:get/post/postOriginal/put/delete/download/upload |
src/config/axios/errorCode.ts | 401/403/404/default 四条兜底文案(仅当后端没给 msg 时用到) |
src/config/axios/service.spec.ts | 扫源码原文断言「业务错误那一支必须 reject Error 且必须弹通知」 |
src/utils/auth.ts | token / 租户 id 的读写(底层 useCache → localStorage) |
src/hooks/web/useCache.ts | CACHE_KEY 常量表 + deleteUserCache() |
src/utils/encrypt.ts | ApiEncrypt(AES / RSA),被请求拦截器按需调用 |
src/utils/download.ts | download.excel/word/zip/html/markdown/json/image,把 Blob 变成文件下载 |
types/global.d.ts | PageParam / PageResult / IResponse 全局类型声明(无需 import) |
核心机制
baseURL 由两个环境变量拼出来
// 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 端已经支持跨域」)。所以换后端地址只改环境变量,不要试图去开代理。
六个方法,返回值形状不一样
// 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/delete | CommonResult.data(已解包) | 99% 的接口 |
postOriginal | 整个 CommonResult | 需要读 code / msg 本身时 |
download | Blob | 导出 Excel、下载文件,配 download.excel(data, 文件名) |
upload | 整个响应对象 | 上传文件(自动把 Content-Type 换成 multipart/form-data) |
request() 内部只做一件事:把 headersType 提升成 Content-Type,再合并调用方传的 headers——这就是 headers: { isToken: false } / headers: { isEncrypt: true } 这类「非标准 axios 头」能生效的原因,它们是给拦截器看的暗号,不是真要发给后端(axios 会把它们一并发出去,后端忽略)。
请求拦截器做的五件事
// 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-id | VITE_APP_TENANT_ENABLE === 'true' 且本地存了租户 | .env 里默认开着 |
visit-tenant-id | 已登录 + 存了访问租户 | 平台「穿透到客户租户」用,未登录不发 |
| GET 禁缓存 | method 是 GET | Cache-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:
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 文件长什么样
// 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 拼。前端不需要注册、不需要改任何配置。
导出文件(下载):
// 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,需显式 importdownload 方法固定 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里那段 🔴 注释解释了「为什么 rejectError而不是字面量'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前缀。
→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。
相关页面
- 接口约定与 CRUD 骨架:后端
CommonResult/ 错误码 / 全局异常处理; - 路由与权限机制:
resetRouter()与deleteUserCache()在登出链路里的位置; - 环境准备与启动:
.env与.env.local的加载顺序、mock token 调试; - 通用组件与 hooks:
useMessage(ElMessage的封装)与useCache。
