Files
oci-portal-dash/.trellis/spec/frontend/api-and-state.md
T
wangdefa ec8a50c4c0
CI / test (push) Successful in 46s
Release / release (push) Successful in 45s
发布 v0.8.3
2026-07-30 12:41:55 +08:00

95 lines
7.7 KiB
Markdown
Raw 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.
# 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` 后写 StorePromise `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,
})
```