Files
LokiStack/docs/web-ui-guide/03-请求层与数据流.md
logan f9c6c1d826 feat: opsx-loki 全面升级 — 前端重构/用户与LDAP/部署向导/日志查询
基于 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 加密前缀保留(已加密凭证不受影响)。
2026-10-24 13:20:12 +08:00

285 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 是路由的灵魂。