基于 WEB-UI-Development-Guide 标准的整体升级,并更名 LokiDeploy → opsx-loki。 前端(Gin+React+shadcn/ui): - 按 02/07 章重构:页面域化拆分(无超 400 行文件)、PageContainer/ListQuery、 反馈分级(成功静默/失败 toast/危险确认)、401 单飞刷新+队列重放 - 暗色模式(next-themes)+ 语义 Token(--primary 品牌色/success/warning/info) - 部署向导(状态推导四步:环境→存储→Loki→采集,内嵌表单闭环) - 日志查询页:LogQL 标签构建器/拖拽直方图/日志上下文/Live Tail/查询历史 - 按用户+标签策略的查询授权(服务端强制合并策略) 后端(Go+Gin+JSON DB): - 简单用户管理(仅 admin,无 RBAC)+ TokenVersion 会话吊销 + 登录 IP 限流 - LDAP 接入:配置(AES 加密存储/hasPassword 脱敏)/测试连接/批量导入/登录 - 存储配置测试连接(SigV4 零依赖)+ 桶不存在自动创建 - Loki 三模式部署修复:SSH 密钥认证、3.x 配置模板、ruler 对象存储嵌套块、 凭证解密、孤儿 deploying 状态恢复、Grafana 部署(可选默认关)+面板导入 - DaemonSet 采集纯 YAML(去 helm 依赖)+ DNS 自愈回退 ClusterIP + 集群名标签 - 日志查询代理:cluster-svc 经 kube-apiserver proxy/url 直连 + cursor 翻页 - 统一 log 命名空间;部署就绪探活;Grafana 访问地址自动记录 修复:run.sh 增量编译检测、Sanitized 脱敏补全、LokiDeployment Update 白名单、 健康检查 CreatedAt 零值等 10+ 存量 bug。 数据兼容:DB 沿用旧 lokideploy.json(新名优先);localStorage 一次性迁移; HKDF 加密前缀保留(已加密凭证不受影响)。
285 lines
13 KiB
Markdown
285 lines
13 KiB
Markdown
# 03 — 请求层与数据流
|
||
|
||
> 参考:vue-vben-admin(RequestClient + refresh 队列)、soybean-admin(五钩子契约 + flat 返回)、vue-pure-admin(PureHttp 请求类)、ant-design-pro(ErrorShowType 契约);补充:gin-vue-admin(loading 防抖、401 事件总线)
|
||
|
||
---
|
||
|
||
## 一、统一响应契约
|
||
|
||
### 1.1 契约即协议
|
||
|
||
请求层的第一件事不是封装 axios,而是**和后端签一份响应契约**。两种主流格式:
|
||
|
||
```js
|
||
// 格式 A:Go Gin / 多数国内后端惯例(gva 等)
|
||
{ code: 0, data: {...}, msg: 'success' } // code === 0 成功
|
||
// 细分扩展:0 成功 / 200 成功 / 2 MFA 待验证 / 3 强制改密
|
||
|
||
// 格式 B:adp(含错误展示声明)
|
||
{ success: true, data: {...},
|
||
errorCode?: number, errorMessage?: string, showType?: ErrorShowType }
|
||
```
|
||
|
||
### 1.2 adp 的杀手锏:错误展示分级
|
||
|
||
```ts
|
||
// ant-design-pro: src/requestErrorConfig.ts
|
||
enum ErrorShowType {
|
||
SILENT = 0, // 静默:调用方自行处理
|
||
WARN_MESSAGE = 1, // 轻提示 message.warning
|
||
ERROR_MESSAGE = 2, // 错误提示 message.error
|
||
NOTIFICATION = 3, // 通知栏 notification
|
||
REDIRECT = 9, // 重定向到错误页
|
||
}
|
||
```
|
||
|
||
- 后端在响应里**声明这个错误该怎么展示**,前端按枚举分发——错误处理策略从「前端猜」变成「契约说」
|
||
- 前端保留逃生口:`request(url, { skipErrorHandler: true })` 单请求关闭全局处理
|
||
|
||
> **推荐**:在 `{code, data, msg}` 上加可选字段 `show_type`,前端默认 `ERROR_MESSAGE`,登录页/轮询接口传 `SILENT`。改动只在请求层一处。
|
||
|
||
---
|
||
|
||
## 二、请求层结构:从单例到客户端类
|
||
|
||
### 2.1 三档演进
|
||
|
||
```
|
||
档1 单例 + 双拦截器(gva,230 行单文件)
|
||
适合:单应用、单一鉴权方式
|
||
档2 工厂五钩子(soybean:RequestOption 契约 + createFlatRequest)
|
||
适合:错误契约复杂、想要扁平返回与错误码 env 化
|
||
档3 客户端类 + 预设拦截器(vben RequestClient / pure PureHttp)
|
||
适合:多实例(业务 client / 裸 client)、上传下载 SSE 一等公民
|
||
```
|
||
|
||
### 2.2 vben 的 RequestClient 精髓
|
||
|
||
```ts
|
||
// vben: packages/effects/request/src/request-client/request-client.ts
|
||
class RequestClient {
|
||
private instance: AxiosInstance
|
||
addRequestInterceptor / addResponseInterceptor // 拦截器不写死,外部组装
|
||
upload / download / postSSE // 传输形态一等公民
|
||
}
|
||
|
||
// 预设拦截器(preset-interceptors.ts)按需装配:
|
||
defaultResponseInterceptor({ codeField, dataField, successCode }) // 解包策略可配置
|
||
authenticateResponseInterceptor // 401 + refresh
|
||
errorMessageResponseInterceptor // status→文案
|
||
```
|
||
|
||
应用组装处(`apps/web-antd/src/api/request.ts`)导出两个实例:
|
||
|
||
- `requestClient`:`responseReturn: 'data'`,业务用(自动剥壳)
|
||
- `baseRequestClient`:`responseReturn: 'raw'`,刷新 token 等裸接口用(**刷新接口自己不能被自己的拦截器拦**)
|
||
|
||
### 2.3 soybean 的五钩子契约与 flat 返回(★★★★☆ 工厂化范式)
|
||
|
||
soybean 把请求协议下沉为 workspace 包 `packages/axios`,业务侧只实现五个钩子:
|
||
|
||
```ts
|
||
interface RequestOption<ResponseData, ApiData> {
|
||
transform: (res) => ApiData // 解包
|
||
onRequest: (config) => config // 注入 token
|
||
isBackendSuccess: (res) => boolean // 业务 code 判定
|
||
onBackendFail: (res, instance) => Promise<AxiosResponse | null> // 失败处理(可重发)
|
||
onError: (error) => void // 错误提示
|
||
}
|
||
// createRequest:失败抛异常(传统风格)
|
||
// createFlatRequest:返回 { data, error, response },业务层 if (!error) —— Rust 风格扁平返回
|
||
```
|
||
|
||
另两条可迁移细节:**错误码策略 env 化**(`VITE_SERVICE_LOGOUT_CODES` 直接登出 / `MODAL_LOGOUT_CODES` 模态登出 / `EXPIRED_TOKEN_CODES` 刷新重发,三级以配置区分);**错误消息栈去重**(errMsgStack 防并发轰炸)。pure 的 `PureHttp` 类(196 行单文件)则是轻量级范本:qs 序列化数组参数、请求前本地判断 expires **提前刷新**(而非 401 后补救)、per-request 回调可覆盖全局拦截器。
|
||
|
||
---
|
||
|
||
## 三、Token 与 401:单飞刷新是铁律
|
||
|
||
### 3.1 问题:并发 401 的竞态
|
||
|
||
```
|
||
请求A ──401──▶ 刷新token(旧1) ──▶ 重放A ✔
|
||
请求B ──401──▶ 刷新token(旧1) ──▶ 后端拒(旧1已消费)──▶ B 被踢出登录 ✘
|
||
```
|
||
|
||
### 3.2 正确姿势:单飞标志 + 等待队列(vben)
|
||
|
||
```ts
|
||
// vben: authenticateResponseInterceptor
|
||
if (client.isRefreshing) {
|
||
return new Promise((resolve) => client.refreshTokenQueue.push((token) => {
|
||
config.headers.Authorization = `Bearer ${token}`
|
||
resolve(client.instance.request(config)) // 拿新 token 后重放
|
||
}))
|
||
}
|
||
client.isRefreshing = true
|
||
try { const newToken = await doRefresh(...) } finally { client.isRefreshing = false }
|
||
config.__isRetryRequest = true // 防无限循环
|
||
```
|
||
|
||
pure 用同构的静态成员实现(`isRefreshing` + `requests[]` 队列,刷新成功 `requests.forEach(cb => cb(token))` 重放);soybean 用共享 Promise 单飞(`state.refreshTokenPromise`,1 秒后置 null)。**三家殊途同归:并发 N 个过期请求只触发一次刷新。**
|
||
|
||
pure 的补充思路:**提前刷新**——token 三元组(accessToken/expires/refreshToken)存 Cookie,请求前本地判断 `expires - now <= 0` 主动刷新,把过期处理从「响应后补救」变成「请求前预防」(白名单短路 `/refresh-token`、`/login` 防死循环)。
|
||
|
||
### 3.3 刷新失败后的三种落法
|
||
|
||
| 落法 | 出处 | 适用 |
|
||
|------|------|------|
|
||
| 全局错误对话框确认后清 token 跳登录(事件总线单例防重复弹) | gva `emitter.emit('show-error')` | 多请求并发过期时体验最好(只弹一个框) |
|
||
| 登录过期 Modal(原页面保留,重登后继续) | vben `loginExpiredMode: 'modal'` | 长表单场景,避免填一半全丢 |
|
||
| 防抖单次跳转(最简可靠) | 常见轻实现 | 跳转加防抖标志;错误救援页(500/403)只清不跳,防按钮失效 |
|
||
|
||
> 共同底线:**并发 N 个 401 只产生一次副作用**(一个弹窗/一次跳转);登录页与轮询接口静默(`SILENT`)。
|
||
|
||
---
|
||
|
||
## 四、全局 loading 防抖(补充模式:gva)
|
||
|
||
```js
|
||
// gva: utils/request.js(要点提炼)
|
||
let activeAxios = 0 // 并发计数器
|
||
let timer = null
|
||
const showLoading = (persist) => {
|
||
if (timer) clearTimeout(timer)
|
||
activeAxios++
|
||
if (persist) { ... return } // 长任务:立即显示且不自动关
|
||
timer = setTimeout(() => showFullScreenLoading(), 400) // 400ms 防闪烁
|
||
}
|
||
const closeLoading = () => {
|
||
activeAxios--
|
||
if (activeAxios <= 0) { clearTimeout(timer); hide(); } // 全部结束才隐藏
|
||
}
|
||
// 兜底:30s 强制关闭;beforeunload 重置;单请求 donNotShowLoading 关闭
|
||
```
|
||
|
||
三个数字都是经验值:**400ms**(快请求不闪 loading)、**30s**(loading 不可能永远转)、**计数器**(并发请求只显示一个 loading)。若引入全局 loading,按此方案。
|
||
|
||
---
|
||
|
||
## 五、取消与防错序
|
||
|
||
### 5.1 为什么列表页必须取消旧请求
|
||
|
||
```
|
||
用户输入 "a" → 请求1发出
|
||
用户补输入 "ab" → 请求2发出
|
||
请求2先回 → 渲染 "ab" 结果 ✔
|
||
请求1后回 → 覆盖渲染 "a" 结果 ✘ ← 错序覆盖(stale response)
|
||
```
|
||
|
||
### 5.2 双重防护(列表页标准实现)
|
||
|
||
```js
|
||
// composables/usePagedQuery.js —— 取消 + 版本号双保险
|
||
let abortController = null
|
||
let requestVersion = 0
|
||
|
||
const fetchList = async () => {
|
||
abortController?.abort() // ① AbortController 取消旧请求
|
||
const currentVersion = ++requestVersion // ② 版本号令牌
|
||
const res = await fetcher(params)
|
||
if (currentVersion !== requestVersion) return // 过期响应直接丢弃
|
||
tableData.value = res.results
|
||
}
|
||
```
|
||
|
||
这是列表页的**强制标准**:所有带筛选/分页的列表页必须走「AbortController + 版本号」双重防护,**禁止**页面手写裸请求循环。
|
||
|
||
注:vben v5 并未内置请求去重(取消能力委托 axios 原生 config,`axios.isCancel` 静默透传)——**不要假设大而全的框架替你解决了这个问题**。soybean 另有 `cancelAllRequest`(nanoid 请求 ID + AbortControllerMap)作页面级批量取消。
|
||
|
||
### 5.3 重试与缓存策略
|
||
|
||
- **重试**:仅幂等 GET 且网络类错误(timeout / network error)自动重试,1-2 次指数退避(soybean 内置 axios-retry;需要时引入 axios-retry,**禁止**手写循环重试)。**禁止**对 POST / PUT / DELETE 自动重试
|
||
- **缓存**:字典 / 枚举类走缓存型 store(gva `dictionary.js` 范式:缓存 key 生成 + 参数校验,避免重复请求);列表页交给组件态 + 取消,不做全局缓存;React 端用 React Query 的 `queryKey` 失效模型(adp 范式:mutation 成功 → `invalidateQueries`)
|
||
- **判定**:同一数据被两个页面重复请求同一接口 = 该进缓存层,或该提升到全局 store 了
|
||
|
||
---
|
||
|
||
## 六、状态分层:什么数据进 store
|
||
|
||
### 6.1 判定表
|
||
|
||
| 数据 | 归属 | 标杆范式 |
|
||
|------|------|----------|
|
||
| token / 用户信息 / 权限码 | 全局 store,**持久化 pick 白名单**(只挑必要字段) | vben `persist.pick`;pure Cookie 三元组 + localStorage 用户信息 |
|
||
| 服务端菜单 / 动态路由表 | store(内存),**不落盘**(重登重拉,防止旧菜单闪现) | vben access store;pure 可选缓存需显式配置 |
|
||
| 列表数据 / 详情 | **不进全局 store**:组件内 usePagedQuery / React Query | adp:`queryKey:[资源名]`,mutation 成功 `invalidateQueries` |
|
||
| 字典 / 短枚举 | 缓存型 store,带缓存 key 去重请求 | gva `dictionary.js`、soybean dict |
|
||
| 主题 / UI 偏好 | 独立于业务 store 的偏好系统 | vben PreferenceManager(见 06 章) |
|
||
| 筛选 / 分页 / Tab 状态 | **URL query**,永远不复制进 store | 通用 |
|
||
|
||
### 6.2 登录编排(vben 范式)
|
||
|
||
```ts
|
||
// store/auth.ts —— 登录是一个编排函数,不是散落的事件
|
||
async function authLogin(params) {
|
||
const { accessToken } = await loginApi(params)
|
||
setAccessToken(accessToken)
|
||
const [userInfo, accessCodes] = await Promise.all([fetchUserInfo(), getAccessCodesApi()])
|
||
router.push(homePath)
|
||
}
|
||
```
|
||
|
||
配套要求:**禁止**在生产日志打印 token 片段;`isLoggedIn` 以内存态 + token 存在性为准,不依赖 localStorage 残留字段。pure 的 `useXxxStoreHook()` 模式保证拦截器/守卫等 setup 外代码也能安全取 store。
|
||
|
||
---
|
||
|
||
## 七、双端一致性要求(迁移期)
|
||
|
||
同产品双栈并存时,请求层**必须**满足:
|
||
|
||
| 项 | 要求 |
|
||
|----|------|
|
||
| token key | 两端同名(推荐 `<app>.auth.token`;迁移期「读旧写新」一个版本) |
|
||
| refresh 机制 | 两端同构(单飞 + 队列重放),副端不允许退化成「401 直接跳登录」 |
|
||
| 响应契约 | 同一份 code/data/msg 约定与错误分级 |
|
||
| mock | **显式模式**(dev 开关/页面级声明),**禁止**运行时因网络错误静默回退假数据(adp:mock 是构建模式不是运行时兜底) |
|
||
|
||
---
|
||
|
||
## 八、最佳实践清单
|
||
|
||
✅ **要做**
|
||
|
||
1. 组件永远 import `api/<域>` 函数,不直接碰 axios 实例(**禁止** store 绕过 API 层裸调)
|
||
2. 每个请求函数带注释(用途/参数/返回)+ 类型(`request<Api.Xxx>` 或 JSDoc)
|
||
3. 刷新 token 的请求用裸 client,绕过业务拦截器
|
||
4. 401 副作用全局单例(一个弹窗/一次跳转),登录页与轮询接口静默
|
||
5. 列表请求一律可取消(AbortController)+ 版本号防错序
|
||
|
||
❌ **不要**
|
||
|
||
1. 不要在拦截器里 `return error` 而非 `Promise.reject(error)`(gva 现存 bug,调用方 catch 不到)
|
||
2. 不要在组件里 try/catch 后吞错不提示(错误要么展示要么显式静默)
|
||
3. 不要把 token 存两套 key
|
||
4. 不要在请求层 import 具体页面组件(保持依赖单向)
|
||
5. 不要对同一资源做无缓存的重复并发请求
|
||
|
||
---
|
||
|
||
## 九、AI 检查清单(对比目标工程逐项核对)
|
||
|
||
**P0**
|
||
|
||
1. 是否存在组件/store 绕过 API 层直接调 axios 实例的代码?
|
||
2. 401 刷新是否单飞(isRefreshing/队列/共享 Promise 任一实现)?并发过期是否只产生一次副作用?
|
||
3. 是否有「网络错误静默回退 mock」类逻辑在生产路径上?
|
||
4. 拦截器错误分支是否 `Promise.reject`?是否有 token 打印类调试日志残留?
|
||
|
||
**P1**
|
||
|
||
5. 列表页是否全部走「取消 + 版本号」方案?手写裸请求循环的页面列出迁移清单
|
||
6. 错误提示是否分级(showType / silent 逃生口)?登录页与轮询是否静默?
|
||
7. token/用户信息持久化是否 pick 白名单?菜单/路由是否落盘(不应落盘)?
|
||
8. 字典/枚举是否有缓存层?重复并发请求同一资源的情况列出
|
||
|
||
**P2**
|
||
|
||
9. 是否值得升级到档 2/3(五钩子工厂 / RequestClient 类)?判断标准:多实例需求或错误契约复杂度
|
||
10. 是否引入 axios-retry(幂等 GET)与错误码 env 化?
|
||
|
||
---
|
||
|
||
> 下一篇:[04-路由与权限控制](04-路由与权限控制.md) —— meta 是路由的灵魂。
|