9. 接口层:契约生成与 getAPI 调用
一句话定位
前端不手写 axios 请求:后端 Swagger 契约经 swagger-codegen 生成为 src/api-services/**,页面统一用 getAPI(XxxApi).apiXxxYyyPost(params) 调用。
目录结构
src/api-services/ # 每个「应用层/业务分组」一个独立目录
├── system/ # 框架主契约(swagger All Groups)
│ ├── api.ts # 桶文件:export * from './apis/xxx'
│ ├── index.ts # export * from api / configuration / models
│ ├── base.ts # BaseAPI、BASE_PATH(值为 "/")、RequestArgs
│ ├── configuration.ts # Configuration(basePath 等)
│ ├── apis/ # 61 个 API 类:sys-user-api.ts、sys-config-api.ts ...
│ └── models/ # 613 个模型:SysConfig、PageConfigInput、UpdateConfigInput ...
├── document/ # 文档中心(swagger/Document)
├── aiChat/ # AI 对话(swagger/AiChat)
├── dingTalk/ # 钉钉(swagger/DingTalk)
├── goView/ # 大屏(swagger/GoView)
├── dataApproval/ # 审批流(swagger/DataApproval)
└── paddleOCR/ # OCR(swagger/PaddleOCR)每个 apis/*.ts 里是标准的 swagger-codegen 三段式产物:
export const SysConfigApiAxiosParamCreator = ... // 拼 url/headers/body
export const SysConfigApiFp = ... // 函数式
export class SysConfigApi extends BaseAPI { // 我们只用它
public async apiSysConfigPagePost(body?: PageConfigInput, options?: AxiosRequestConfig) : Promise<AxiosResponse<AdminNETResultSqlSugarPagedListSysConfig>>
}方法命名规律:api + 控制器名 + 方法名 + HttpMethod,例如 SysConfigApi.apiSysConfigPagePost。
契约生成脚本(api_build)
前置条件 执行 产物
─────────────────────────────────────────────────────────────────────────
1. 后端服务已启动,可访问
http://localhost:5005/swagger 方式A(推荐)
cd Web && pnpm buildApi
2. 安装 Java 8+ 运行时 → script/build-api.js
设置 JAVA_HOME → api_build/build.bat(Win)
/ build.sh(Linux/Mac)
3. api_build/swagger-codegen-cli.jar
(3.0.68+,已在仓库中) 方式B(换地址)
cd Web/api_build && ./build_api.sh
→ 交互式选择 172.18.32.33:5050 / 127.0.0.1:5050 / localhost:5005
→ 生成到 src/api-services/system/核心命令(api_build/build.sh):
java -jar swagger-codegen-cli.jar generate \
-i http://localhost:5005/swagger/All%20Groups/swagger.json \
-l typescript-axios \
-o ../src/api-services/system/生成后脚本会删掉 .swagger-codegen/、.gitignore、package.json、tsconfig.json 等无用文件。
新增业务模块契约的标准做法
- 后端新建分组(Swagger 分组名即
swagger/{GroupName}/swagger.json,如Document)。 - 在
api_build/build.bat/build.sh中照抄一个分支(以build.bat为例):
set moduleName=document
set apiServicesPath=%dir%..\src\api-services\document\
set apiUrl=http://localhost:5005/swagger/Document/swagger.json- 命名脚本为
buildDocument.bat/buildDocument.sh,即可用pnpm buildApi document触发(script/build-api.js用build${moduleName}.bat拼文件名)。 - 不要把业务接口塞进
system/,保持「框架接口 / 业务接口」物理隔离。 - 生成后只增不改:如需特殊处理(如文件流
responseType: 'blob'),在页面调用处传options,不要改生成物。
getAPI 用法
// src/utils/axios-utils.ts
export function getAPI<T>(
apiType: new (configuration?: Configuration, basePath?: string, axiosInstance?: AxiosInstance) => T,
configuration: Configuration = serveConfig,
basePath: string = BASE_PATH, // "/"
axiosObject: AxiosInstance = axiosInstance
) {
return new apiType(configuration, basePath, axiosObject);
}标准调用(真实示例,src/views/system/config/index.vue):
<script lang="ts" setup name="sysConfig">
import { getAPI } from '/@/utils/axios-utils';
import { SysConfigApi } from '/@/api-services/system/api';
import { SysConfig, PageConfigInput } from '/@/api-services/system/models';
// 分页查询:返回 AxiosResponse,数据在 res.data.result
const handleQueryApi = async (page: any, sort: any) => {
const params = Object.assign(state.queryParams, {
page: page.currentPage,
pageSize: page.pageSize,
field: sort.field,
order: sort.order,
descStr: 'desc',
}) as PageConfigInput;
return getAPI(SysConfigApi).apiSysConfigPagePost(params);
};
// 增 / 改 / 删
await getAPI(SysConfigApi).apiSysConfigAddPost(state.ruleForm);
await getAPI(SysConfigApi).apiSysConfigUpdatePost(state.ruleForm);
await getAPI(SysConfigApi).apiSysConfigDeletePost({ id: row.id });
// 无参 GET
const res = await getAPI(SysConfigApi).apiSysConfigGroupListGet();
state.groupList = res.data.result ?? [];
</script>取值约定:res.data 是 AxiosResponse.data,即后端 AdminResult<T>,所以业务数据在 res.data.result,错误提示在 res.data.message、状态码在 res.data.code。
第二个参数可以传 axios 配置,用于文件流等场景:
const res = await getAPI(SysFileApi).apiSysFileDownloadFilePost({ id }, { responseType: 'blob' });
downloadStreamFile(res, fileName);拦截器逐行解读
src/utils/axios-utils.ts 对全局 axios 默认实例(globalAxios)装了两组拦截器,所有 getAPI 调用都会走。
请求拦截
axiosInstance.defaults.timeout = 1000 * 60 * 10; // 10 分钟
const accessToken = Local.get(accessTokenKey) ?? Session.get('token');
if (accessToken) {
conf.headers['Authorization'] = `Bearer ${accessToken}`;
const jwt = decryptJWT(accessToken);
const exp = getJWTDate(jwt.exp);
if (new Date() >= exp) { // accessToken 已过期
const refreshAccessToken = Local.get(refreshAccessTokenKey);
if (refreshAccessToken) conf.headers['X-Authorization'] = `Bearer ${refreshAccessToken}`;
}
}
const globalI18n = Local.get('themeConfig')?.globalI18n;
if (globalI18n) conf.headers['Accept-Language'] = globalI18n; // 后端据此返回多语言文案| 事项 | 说明 |
|---|---|
| Token | Local 的 access-token,兜底读 Session 的 token(Cookie) |
| 续期 | 过期时在 X-Authorization 头带 refreshToken,后端回写新 token |
| 语言 | 取自布局配置 globalI18n,不需要页面自己传 |
| 租户 | 租户 ID 在登录时写入 Local 的 tid,随登录/路由跳转进入后端上下文 |
响应拦截
if (res.status === 401) clearAccessTokens(); // ① HTTP 401:清 token
else if (res.status >= 400) throw new Error(res.statusText); // ② 其它 HTTP 错误
if (serve?.errors) throw new Error(JSON.stringify(serve.errors));// ③ 非规范化错误
const accessToken = res.headers['access-token'];
const refreshAccessToken = res.headers['x-access-token'];
if (accessToken === 'invalid_token') clearAccessTokens(); // ④ 无效 token
else if (refreshAccessToken && accessToken && accessToken !== 'invalid_token') {
Local.set('access-token', accessToken); // ⑤ 自动续期落盘
Local.set('x-access-token', refreshAccessToken);
}
if (serve.code === 401) clearAccessTokens();
else if (serve.code === undefined) return Promise.resolve(res); // ⑥ 非标准响应直接放行(如文件流)
else if (serve.code !== 200) { // ⑦ 业务失败
const message = handleResponse(serve.message, 5);
if (res.config?.customCatch) { ElMessage.error(...); throw new Error(serve.message); }
else ElMessage.error({ dangerouslyUseHTMLString: true, message });
}
return res;handleResponse(input, col = 5) 做的事:把 [D0010] 这类错误码前缀剥掉,递归摊平对象/数组,每 5 条一行用 ; 拼接、行间 <br/>,以 HTML 形式弹出。
⚠️ 注意第 ⑦ 步:默认只是 ElMessage.error,不抛异常。也就是说 await getAPI(...)... 会正常往下走,后续代码必须自己判断 res.data.code === 200。若希望「失败就中断」,就要用 customCatch 或 feature()。
网络错误节流
后端宕机时数十个并行请求会同时失败,showNetworkErrorThrottled() 保证 2 秒内只弹一次:
- 502/503/504 → 「后端服务暂不可用,请稍后重试(xxx)」
ERR_NETWORK→ 「网络连接异常,请检查网络或后端服务」ECONNABORTED/ timeout → 「请求超时,请稍后重试」
路由守卫里还会调 suppressNetworkErrorFor(5000),避免跳登录页后批量子请求刷屏。
customCatch:把失败变成异常
customCatch 是项目给 AxiosRequestConfig 扩展的自定义字段(src/types/axios.d.ts):
declare module 'axios' {
export interface AxiosRequestConfig { customCatch?: boolean; }
}try {
await getAPI(SysConfigApi).apiSysConfigDeletePost({ id }, { customCatch: true });
ElMessage.success('删除成功'); // 只有成功才会走到这里
} catch (e) {
// 拦截器已弹过错误提示,这里只做收尾(恢复按钮 loading、回滚 UI 等)
return;
}
handleQuery();行为差异:
customCatch: false(默认) | customCatch: true | |
|---|---|---|
| 提示 | 弹出 ElMessage | 同样弹出 |
| Promise | resolve,后续代码继续执行 | reject,可被 try/catch 中断 |
| 网络层错误 | 弹节流提示 | 不弹(交给调用方) |
feature():把异常变成返回值
不想写 try/catch 时用 feature()(灵感来自 Go 的错误处理):
import { feature } from '/@/utils/axios-utils';
const [err, res] = await feature(getAPI(SysUserApi).apiSysUserPagePost(params));
if (err) return; // 已弹过提示
state.list = res!.data.result?.items ?? [];签名:feature<T, U = Error>(promise, errorExt?) : Promise<[U, undefined] | [null, T]>。
常见错误排查
| 现象 | 原因 | 处理 |
|---|---|---|
Cannot read properties of undefined (reading 'result') | 业务失败但默认不抛异常,res.data.result 为空 | 加 customCatch: true 或用 feature() |
| 401 后反复跳登录 | Local/Session 的 token 被 clearAccessTokens() 清掉 | 先看后端是否返回 invalid_token;再查 X-Authorization 续期链路 |
| 接口 404 | BASE_PATH = "/",路径由后端契约决定 | 用 pnpm buildApi 重新生成,别手改 url |
| 开发环境跨域/502 | .env.development 的 VITE_API_URL 与后端实际端口不一致 | 改 .env.development,并确认 vite proxy 已代理 /api、/[Uu]pload、/[Ss]se |
| 生产环境请求打到 localhost | serveConfig.basePath = window.__env__.VITE_API_URL | 改 .env.production 后重新构建(config.js 是构建产物) |
| 改了后端接口前端没感知 | 契约未同步 | 跑 pnpm buildApi;检查 apis/ 里对应方法名 |
步骤清单:接一个新后端接口
- 后端加接口 + 写好 Swagger 注释(分组、方法名、模型字段)。
pnpm buildApi(或按分组pnpm buildApi document)。- 在页面
import { XxxApi } from '/@/api-services/xxx/api'(模型从models引)。 - 用
getAPI(XxxApi).apiXxxYyyPost(params)调用,从res.data.result取值。 - 需要中断语义时加
{ customCatch: true }或包一层feature()。 - 提交时只提交页面代码,契约目录的 diff 一并提交(生成物也入库)。
小结
- 契约是生成物,
api-services/**只读;要改先改后端注释再生成。 - 调用入口只有一个:
getAPI(Api类),业务数据在res.data.result。 - 拦截器包办了 token / 续期 / 语言 / 401 清理 / 统一报错,页面不要重复实现。
- 默认失败不抛异常 —— 需要中断就用
customCatch或feature()。