95 lines
7.7 KiB
Markdown
95 lines
7.7 KiB
Markdown
# API 层与状态管理规范
|
||
|
||
- 所有请求经统一 request 封装(`src/api/request`):注入 JWT、401 统一跳登录、错误统一提示;组件内不直接 `fetch`/裸 axios。
|
||
- `request` 的 `body` 传**对象**,封装内部统一 `JSON.stringify`;调用方自己 stringify 会双重编码,后端报 `cannot unmarshal string into …`(2026-07-14 ai-settings 开关踩坑)。
|
||
- 跨页面共享状态才进 Pinia;页面内状态用 `ref`/`computed` 就地管理,不把一切塞 store。
|
||
- 请求函数返回类型化数据(`Promise<Instance[]>`),错误向上抛由封装层兜底,不在每个调用点重复 try/catch。
|
||
- 消息提示统一走 `useToast`(`src/composables/useToast`),禁止组件直接 `useMessage`:超长错误(如 OCI 透传)自动折叠成「标题 + 展开详情」,显式传 detail 可自定义折叠内容。
|
||
- 账单域(round14 起):发票/付款方式 DTO 在 `types/api.ts`,请求走 `api/billing.ts`;发票 PDF 接口带 JWT 不能裸 `<a href>`,统一经 `downloadInvoicePdf`(rawFetch→blob→触发保存)。发票 tab 的 403 不当普通错误弹 toast,转受限卡提示 osp-gateway policy;「未付余额」口径只计 OPEN/PAST_DUE,PAYMENT_SUBMITTED 已提交扣款不计入。
|
||
- 发票列表按年懒加载(round15 起):`listInvoices(id, year)` 初始拉当年、空则自动往前探,手动逐年加载,连空 2 年停;明细/PDF 预览/付款方式一律弹窗(NModal),不用 NDrawer。对象存储 Archive 层:查看器「下载」在非 Restored 时禁用、「分享」一律禁用(PAR 对 Archive 无效);列表有 Restoring 对象时 30s 静默轮询自动停;公共读桶(visibility=ObjectRead)「复制 URI」变「复制 URL」(对象名整体 encodeURIComponent 拼公网直链)。
|
||
- Usage API 的成本时序对无出账记录的时段**不返回行**:图表轴不能用返回数据的 key 排序生成,须前端按范围生成完整标签再补零(CostTab `fullLabels()`),否则断轴缺刻度。
|
||
- 大列表禁止全量拉取(2026-07-21 PAR 复盘):几千行进 `ref` + NDataTable 会全量响应式代理化 + 建树,主线程打满数秒——服务端有游标就走 remote 分页,交互复用 ObjectBrowser 的「游标栈 + 上一页/下一页」模式(游标分页拿不到总数,头部计数用「本页 N(+)」表述);`listPars` 返回 `{items, nextPage}`,每页 100。OCI 写后读有秒级最终一致性(换 IP 后 GetVnic 仍回旧值):变更成功不能只重查一次,要带预期值开限时轮询窗口(VnicPanel `ipExpect` 90s,收敛后再 emit changed 联动父级刷新)。
|
||
- 表单先上传临时远端资源、再创建业务对象时:上传响应保存 `{cfgId, domainId, url, fileName}`
|
||
原始元组,用请求序号丢弃迟到响应;替换、放弃表单或提交未引用它时按元组尽力删除,
|
||
失败静默(孤儿只是自己租户里一张无入口小图)。创建请求引用了它且返回 2xx
|
||
(含 `201 + setupWarning`)即视为已采用不再清理;清理复杂度须与资源价值匹配,
|
||
勿为低价值资源建冻结所有权/结果未知协议的状态机(2026-07 IdP 图标精简教训)。
|
||
域相关异步前置设置未加载完成前禁用提交,后端仍是同一约束的权威校验。
|
||
- 可重入异步加载(分页、自动刷新、详情弹窗、依赖切换)不经 useAsync 时必须带**请求代次守卫**(`let seq = 0; const n = ++seq; … if (n !== seq) return`),晚到响应丢弃、loading 只由最新请求清;作用域标识(如 cfg/region/bucket)变更时同步重置从属状态(前缀/游标/选中/弹窗)。会创建远端会话的打开流程,过期响应要就地删除刚建的会话(SerialConsolePanel)。(2026-07-22 审查 #5/#6/#10/#17)
|
||
- 401 登出前比对**发出请求时快照的 token** 与当前 token:不一致说明会话已更新(如 OAuth 绑定回跳),旧令牌的迟到 401 不得登出新会话(request.ts `handleUnauthorized`)。敏感写操作还须遵守下方「会话换发与并发 401」契约。含时间比较的登录态判定用普通函数,不用 computed(时间流逝不触发重算)。(#4/#15)
|
||
- 固定周期轮询用 `setTimeout` 自链(上一轮 finally 里排下一轮),不用 `setInterval`:慢响应会堆叠请求且可能永久饥饿。(#16)
|
||
- `window.open` 只能在用户手势内同步调用,异步回调(如签发 PAR 后)现开会被弹窗策略拦截且返回 null:需要新窗口的异步流程在点击处理器里先 `window.open('', '_blank')` 预开、拿到结果后 `location.replace` 导航,失败关闭空白窗;任何 `window.open` 返回 null 都要如实报错并给重试(重试点击是新手势),不得标记成功。(2026-07-22 审查 #19,ObjectBrowser 下载)
|
||
- 写请求(非 GET)在 `request.ts` 层按 method+url+body 做 **in-flight 去重**(2026-07-23 起):前一发未返回时同 key 复用同一 Promise 不再发第二发,完成即移除;GET 不去重(useAsync seq 后发优先)。这是防连点的系统级兜底,组件级 busy/disabled 仍必须做(见 typescript-vue.md);合法的「连续两次相同写」(极少)需改变 body 或等待前次完成。
|
||
|
||
## 场景:会话换发与并发 401
|
||
|
||
### 1. Scope / Trigger
|
||
|
||
后端成功后会令旧 JWT 失效并返回新会话的认证写接口(改密、登录策略、TOTP、
|
||
身份/通行密钥变更、撤销全部会话及钱包绑定)必须使用本契约。它防止并发旧请求的
|
||
401 先到时清空即将换发的新会话;不用于普通写接口,也不把所有写请求全局串行化。
|
||
|
||
### 2. Signatures
|
||
|
||
```ts
|
||
interface RequestOptions {
|
||
refreshesSession?: boolean
|
||
}
|
||
|
||
request<SessionRefresh>(path, {
|
||
method: 'POST',
|
||
body,
|
||
refreshesSession: true,
|
||
})
|
||
```
|
||
|
||
`SessionRefresh` 的成功响应必须包含字符串 `token` 与 `expiresAt`。
|
||
|
||
### 3. Contracts
|
||
|
||
- 有旧 Token 的 `refreshesSession` 请求按“发出时 Token”登记在途换发。
|
||
- 2xx 响应须在 Promise 返回、换发登记释放前由请求层先写入 Auth Store;组件可为
|
||
mock 流程重复写同值。
|
||
- 其他请求的 401 若发现同一旧 Token 仍有换发在途,须等待全部结束后再比较 Store;
|
||
Token 已变化则只抛业务错误,不登出。
|
||
- 换发请求自身收到 401 时先幂等退出登记再处理 401,避免两个失败换发互相等待。
|
||
- 公开登录没有旧 Token,不由请求层提前建立会话;页面仍负责登录后导航。
|
||
|
||
### 4. Validation & Error Matrix
|
||
|
||
| 条件 | 行为 |
|
||
| --- | --- |
|
||
| 2xx 且 `token` / `expiresAt` 合法 | 先 `setSession`,再释放等待者 |
|
||
| 网络错误、畸形 JSON、非 2xx | 不写 Store,必须释放登记 |
|
||
| 并发 401 等待后 Token 已变化 | 保留新会话,不跳登录页 |
|
||
| 等待后仍是请求时 Token | 登出并带当前地址跳转登录页 |
|
||
| 多个换发请求均返回 401 | 全部拒绝,不死锁,只执行一次有效登出 |
|
||
|
||
### 5. Good / Base / Bad Cases
|
||
|
||
- Good:改密成功响应与旧列表请求 401 并发,成功响应先落 Store,旧 401 不登出。
|
||
- Base:没有换发在途的当前 Token 收到 401,按原流程登出。
|
||
- Bad:仅在组件 `await` 后写 Store;Promise `finally` 与组件 continuation 之间存在
|
||
微任务窗口,迟到 401 可抢先登出。
|
||
|
||
### 6. Tests Required
|
||
|
||
- 401 先登记等待、换发随后成功:断言 Store 为新 Token 且不导航。
|
||
- 换发 Promise 完成边界:断言返回调用方前 Store 已更新。
|
||
- 两个换发请求同时 401:断言均结束、无死锁、仅一次导航。
|
||
- 无旧 Token 的公开登录:断言请求层不提前写 Store。
|
||
|
||
### 7. Wrong vs Correct
|
||
|
||
```ts
|
||
// Wrong:后端会换 Token,却没有加入协调
|
||
return request('/auth/totp/activate', { method: 'POST', body })
|
||
|
||
// Correct:请求层在释放并发 401 前先落新会话
|
||
return request('/auth/totp/activate', {
|
||
method: 'POST',
|
||
body,
|
||
refreshesSession: true,
|
||
})
|
||
```
|