Files
LokiStack/docs/web-ui-guide/03-请求层与数据流.md
T
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

13 KiB
Raw Blame History

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,而是和后端签一份响应契约。两种主流格式:

// 格式 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 的杀手锏:错误展示分级

// 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 精髓

// 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,业务侧只实现五个钩子:

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)

// 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)

// 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 双重防护(列表页标准实现)

// 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 范式)

// 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

  1. 列表页是否全部走「取消 + 版本号」方案?手写裸请求循环的页面列出迁移清单
  2. 错误提示是否分级(showType / silent 逃生口)?登录页与轮询是否静默?
  3. token/用户信息持久化是否 pick 白名单?菜单/路由是否落盘(不应落盘)?
  4. 字典/枚举是否有缓存层?重复并发请求同一资源的情况列出

P2

  1. 是否值得升级到档 2/3(五钩子工厂 / RequestClient 类)?判断标准:多实例需求或错误契约复杂度
  2. 是否引入 axios-retry(幂等 GET)与错误码 env 化?

下一篇:04-路由与权限控制 —— meta 是路由的灵魂。