15 KiB
API 契约一致性审查(backend HTTP API)
审查于
feat/postgres-redis-migration工作树(fleet 改名之后)。覆盖api/client/(OpenAI 兼容面)、api/admin/(管理台面)、api/router.rs路由组织、infra/json.rs分页原语、以及前端frontend/src/api/消费侧的类型对齐。方法:codegraph + rg 定位,逐个 endpoint 对照 serde struct 与 axum 路由,前后端字段名两侧核对。状态:2026-07-11 已完成 P0 第 1 条、P1 第 2/3 条和 P2 第 4/7/8 条的代码实施与全量质量门禁。下文“现状”均指修复前审查基线。
先说结论:字段命名极其干净(全局 camelCase,95 处 rename_all = "camelCase",无一处 snake_case 泄漏),两套错误信封各自服务两类客户端且都不泄露内部错误细节,OpenAI 兼容面贴合官方契约。真问题集中在分页:infra/json.rs 里 cursor 与 page/offset 两套并存,导致三种矛盾——(1)keys endpoint 只有 cursor 一套,前端不翻页 → 超过 50 个 key 被静默截断(唯一的真数据丢失 bug);(2)同一 endpoint 的 data.page 形状随请求参数在两种 schema 间漂移,且用 untagged enum 无判别字段;(3)cursor 分支在 accounts/usage/ops 上已是前端不走的死代码。其余是 REST 方法语义(POST /delete vs DELETE)、版本化(admin 无 /v1)、compact 与 responses 的上游错误 status 映射分叉等风格/边角不一致。
P0 — 会导致客户端出错的真问题
1. /api/admin/keys 只有 cursor 分页,前端不跟游标 → 超过 50 个 key 静默丢失
- 现状:keys endpoint 是唯一只支持 cursor 一套的列表接口。
api_keys(api/admin/keys_routes.rs:114-139)只接收ApiKeysQuery { cursor, limit }(keys_routes.rs:26-31),不认page/pageSize;limit = clamp_limit(query.limit.unwrap_or(50))默认 50、上限 200。 - 前端
getApiKeys()(frontend/src/api/modules/api-keys.ts:5-10)不传任何参数,loadApiKeys直接apiKeys.value = data.items(frontend/src/views/api-keys/composables/useApiKeyMutations.ts:38-39),next_cursor被丢弃;useApiKeyFilters再对这已被截断的数组做纯客户端分页(frontend/src/views/api-keys/composables/useApiKeyFilters.ts:21-22)。 - 后果:一旦 key 总数 > 50,前端永远只看得到最新创建的 50 个,较旧密钥永久不可见,分页器还显示得像是全量。这是本次审查唯一的真数据可见性 bug,不是洁癖。
- 修复:keys 已统一实现
page/pageSize/search+count(*),前端直接消费服务端页和data.page.total,搜索覆盖名称、标签、ID 的完整数据集,不再对单页结果做本地搜索或二次分页。
P1 — 契约稳定性
2. 同一 endpoint 的 data.page 形状随请求参数在两套 schema 间漂移
- 现状:accounts / usage / ops 三个列表同时支持两种模式,按"请求里有没有
page/pageSize"运行时二选一:usage_records(api/admin/usage_routes.rs:197)use_numbered_page = page.is_some() || page_size.is_some();ops_errors(api/admin/ops_routes.rs:66)同款判定;accounts(api/admin/accounts_routes/mod.rs:525)同款判定。
- 响应用 untagged enum
PageMeta(api/admin/response.rs:210-215)承载两种截然不同的data.page:- Cursor 分支 →
{ limit, nextCursor }(response.rs:193-197); - Numbered 分支 →
{ page, pageSize, total, totalPages }(response.rs:200-207)。
- Cursor 分支 →
- 问题:同一个 URL,传
?cursor=与传?page=拿到的data.page是两种 schema;#[serde(untagged)]又不带判别字段(没有"mode":"cursor"之类),第三方 API 消费者只能靠"猜哪些字段在"来分辨。前端因为永远只传 numbered 参数(useUsageRecordsTable传page/pageSize、useAccountMutations传page/pageSize、useOpsErrorsTable读result.page.total/page/pageSize/totalPages)侥幸无感,但契约面是脆的。 - 建议:统一收敛到 numbered 一套(前端事实上只用它),响应
data.page定为单一稳定 schema;若要保留 cursor 供未来无限滚动,至少给PageMeta加显式判别字段,别用 untagged。 - 修复:accounts / keys / usage / ops 已统一为 numbered 分页,
PageMeta已收敛为单一结构{ page, pageSize, total, totalPages },默认请求也不再产生另一种 schema。
3. cursor 分页在 accounts/usage/ops 上已是前端不走的死代码
- 现状:
infra/json.rs同时导出 cursor 原语(Page/encode_cursor/decode_cursor,json.rs:11-19、36-46)与 page/offset 原语(NumberedPage/page_offset,json.rs:22-33、59-61)。每个双模 handler 都完整保留了 cursor 分支(如usage_routes.rs:230-253、ops_routes.rs:88-100、accounts_routes/mod.rs:557-579),但前端全部只走 numbered 分支。 - 唯一还真用 cursor 的调用方是
dashboard_summary内部list(None, LIMIT, ...)取最近 N 条(api/admin/dashboard_routes.rs:214)——它只要"第一页",本质是limit而非翻页。 - 额外死代码:
AccountUsageQueryService::list与PgAccountUsageStore::list_usage没有 HTTP 或其他生产调用方,同样只保留了无消费者的 cursor SQL。 - 问题:两套原语 + 每 handler 双分支,是这组契约不一致(第 1、2 条)的根因,也是持续的维护税与测试盲区。
- 建议:与第 2 条一起做——对外列表统一 numbered;cursor 若仅服务 dashboard 的"取前 N 条",可降级成一个不暴露翻页语义的
list_recent(limit),把Page/encode_cursor从公共分页原语里摘出去。 - 修复:Dashboard 已改用显式
list_recent(limit),Page/encode_cursor/decode_cursor、各列表 cursor 分支和账号用量 cursor 死路径均已删除。
P2 — 风格不统一 / 边角契约,视精力而定
4. /v1/responses 与 /v1/responses/compact 对同一上游失败映射出不同 HTTP status
- 现状:非流式
/v1/responses走responses_dispatch_error_response(api/client/responses.rs:316),内部用ResponseDispatchStatusMode::UpstreamFailureStatus,对Upstream(_)强制 502(api/client/errors.rs:266-276);compact 走responses_compact_dispatch_error_response(responses.rs:356),用ResponseDispatchStatusMode::Client,透传上游真实 status(如 500/503,errors.rs:118-132)。 - 问题:上游同一个 5xx,responses 返回 502、compact 返回 500——两个姊妹 endpoint 对"同类错误"给客户端不同 status。二者都自洽(代理返 502 更规范;compact 透传更贴上游),但彼此分叉。
- 建议:统一策略(建议都按代理语义规整为 502/503),或在文档里显式标注 compact 为"透传上游 status"。
- 修复:Responses 与 Compact 共用同一客户端状态策略。可由客户端修复的上游 4xx 保留原状态;上游 503 保留为 503;其他通用上游 5xx 统一为 502。PostgreSQL 运维日志的
client_status_code使用同一计算。
5. admin 写操作用 POST /xxx/delete、POST /xxx/update,与 settings 的 DELETE 语义自相矛盾
- 现状:同一 admin 面里,删除/更新混用两种风格:
- 动词塞进路径 + POST:
POST /api/admin/accounts/delete、/accounts/update、/keys/delete、/keys/update、/usage/records/delete(api/admin/router.rs:72-73、101、105-106); - 又存在规范的
DELETE /api/admin/settings/admin-api-key(router.rs:44)。
- 动词塞进路径 + POST:
- 问题:纯 REST 语义不一致(POST 造资源 vs DELETE 删资源被
POST /delete绕过)。因前端硬编码 method,不会真出错,属洁癖级。 - 建议:要么全面
DELETE/PATCH,要么承认"动作式 POST"约定并统一到底、连 settings 那个 DELETE 一起归拢;不必强改,但应二选一写进规范。
6. client 面 /v1 版本化,admin 面 /api/admin 无版本
- 现状:OpenAI 面全部
/v1/*(api/client/router.rs:17-27);admin 面全部/api/admin/*无版本段(api/admin/router.rs)。 - 问题:两面版本化策略不一致。admin 是内部管理台、前后端同仓齐发,无版本可接受;仅记录以免误会成疏漏。
- 建议:维持现状即可;若未来 admin 要对外开放,再引入
/api/admin/v1。(这个不做)
7. code: "codex_api_error" 在两种 type 下复用
- 现状:
responses_error_kind_for_status(api/client/errors.rs:249-264)对 4xx 上游返回{ type: "invalid_request_error", code: "codex_api_error" },对 5xx 返回常量CODEX_API_ERROR = { type: "server_error", code: "codex_api_error" }(errors.rs:85-88)。同一code字符串挂在两种type下。 - 问题:按
(type, code)对做分支的客户端会看到同 code 不同 type。非阻断。 - 建议:给 4xx 场景换一个独立 code(如
codex_client_error),让 code 与 type 一一对应。 - 修复:通用上游 4xx 使用
type: "invalid_request_error"+code: "codex_client_error";codex_api_error只用于服务端错误类型。
8. 流式与非流式对"无可用账号"给出不同 error code
- 现状:非流式
NoActiveAccount→ HTTP 503 +code: "no_available_accounts"(api/client/errors.rs:144-155);流式则以 SSE(HTTP 200)内嵌response.failed事件,kind 走UPSTREAM_UNAVAILABLE_ERROR→code: "upstream_unavailable"(errors.rs:53-56、338)。 - 问题:同一"无账号"条件,流式/非流式 code 命名不同(
upstream_unavailablevsno_available_accounts)。HTTP status 差异是流式固有(200 后无法改 status),但 code 字符串可以对齐。 - 建议:两条路径统一 code 命名。
- 修复:流式
response.failed与非流式响应均使用code: "no_available_accounts"。
9. 前端仅 system 模块有 TS 类型,其余全 any —— 无编译期漂移防护
- 现状:
system.ts定义了SystemVersion/SystemUpdateInfo等接口(frontend/src/api/modules/system.ts:7-33),经核对与后端VersionData(update/service.rs:43-54)、UpdateInfoData(update/release.rs:25-39)字段逐一吻合;但 accounts/usage/dashboard/keys/settings 模块的入参与返回全是any(如api/modules/accounts.ts:5、usage.ts:3)。 - 问题:高频实体(账号/key/用量)没有一处 TS 契约,后端改字段名前端零编译告警,只会运行时
undefined。当前无实际错配(camelCase 两侧一致,抽查OpsErrorDetailModal读failureClass/clientApiKeyId/upstreamRequestId与OpsErrorLogflatten 序列化对得上),但缺防护网。 - 建议:为账号/key/用量/设置补最小 TS interface(可从后端 struct 半自动生成),把
request<T>的泛型用起来。
做得好的(记录一下,免得反复怀疑)
- 字段命名全局统一 camelCase:95 处
rename_all = "camelCase",仅 3 处lowercase且都用在枚举变体序列化(telemetry/usage/types.rs:10、telemetry/dashboard.rs:28、update/service.rs:67,产出"info"/"success"这类小写值),用法正确,不算不一致。 - 内部错误细节不泄露(抽查确认):admin 各错误类型(
UsageQueryError/OpsQueryError/AccountManageError/KeyManageError)的Display全是静态文案(如"failed to list usage records"),AdminError::internal(error.to_string())落地的是这些静态串而非裸 DB 错误;settings 更是刻意把Database/Json/StoredField统一收敛成"Failed to persist settings"(api/admin/settings_routes.rs:155-164)。少数带 detail 的(FetchQuota/OAuthCodeExchange,accounts_routes/quota_routes.rs:38)是上游 Codex 报文且仅面向已鉴权 admin,可接受。 - 两套错误信封各服务其众且都自洽:admin 用
{ code:u32, message, data:null }+ 业务码段(400xx/401xx/409xx/500xx,api/admin/response.rs:13-29),OpenAI 面用{ error:{ message, type, code } }(api/client/errors.rs:91-107)贴官方结构。这是按受众分层,不是分裂。 - OpenAI 兼容度到位:
/v1/models返回{ object:"list", data:[{ id, object:"model", created, owned_by:"openai" }] }(api/client/models.rs:35-42、92-99),/v1/models/{id}返回单 model 对象,SSE 用text/event-stream+data: [DONE]\n\n收尾(api/client/sse.rs、upstream/openai/protocol/sse.rs:23),均合官方契约;/v1/responses是透明代理原样透传。 - admin 鉴权入口统一:
AdminAuth提取器一处实现(会话 Cookie 或x-api-key,api/admin/session.rs:45-58),所有 admin handler 走同一门。 - SPA fallback 对 API 路径正确回 404 JSON:
/api、/v1前缀命中时返回{ code:40401, message, data:null }而非把 index.html 当接口喂回(api/assets.rs:29-39),与 admin 错误信封同构。 - request_id 契约闭环:入站校验/生成、注入 extension、回写响应头
x-request-id(api/middleware/request_id.rs),前端request.ts从response.headers['x-request-id']取回挂到ApiError(frontend/src/api/request.ts:71)。
核实过的误报(排查后判定无问题)
system前端接口 vs 后端 struct 疑似漂移:逐字段核对SystemVersion/SystemUpdateInfo与VersionData/UpdateInfoData,camelCase 名称全部一致,无漂移。- query 参数疑似 snake_case:各
*Querystruct 字段名虽写作client_api_key_id/page_size/start_time,但 struct 上都有rename_all = "camelCase",实际接收的是clientApiKeyId/pageSize/startTime,与前端发送一致。 AdminAccountPageEnvelope是不是又一套分页 schema:它(accounts_routes/mod.rs:355-398)确实手写重复了AdminPageEnvelope的 cursor/numbered 逻辑,但产出的data.page形状与共享PageMeta完全一致,只是多挂了一个data.summary。属代码冗余(可复用AdminPageEnvelope),非契约不一致,故不计入 P 级问题。- client 面 401 消息"Missing client API key"用词:key 存在但非法时也回这句(
api/client/auth.rs只对外暴露 bool,失败原因仅进日志),消息略含糊但 status 正确、不泄露 key,属文案洁癖非契约错。
建议动手顺序
- P0 第 1 条:已完成。keys 使用服务端 numbered 分页、全量搜索和总数统计。
- P1 第 2、3 条:已完成。对外列表统一 numbered,Dashboard 使用
list_recent(limit),cursor 原语与死路径已删除。 - P2 第 4、7、8 条:已完成。Responses/Compact/流式错误状态与错误码已统一。
- P2 第 9 条(前端补 TS 类型)可作为独立收尾,给账号/key/用量/设置建最小 interface。
- P2 第 5、6 条(REST 方法语义 / 版本化)属规范决策,择期定调即可,不必强改。
实施验收
- Rust 格式检查通过。
cargo clippy --all-targets --all-features --locked -- -D warnings通过,0 警告。- 后端全量集成测试 623 项通过,0 失败。
- 前端 Prettier 检查、
vue-tsc和 Vite 生产构建通过,0 警告。 - Docker runtime 镜像
codex-proxy-rs:api-contract-review构建成功;容器健康检查为 healthy,GET /healthz返回 204,启动日志 WARN/ERROR 为 0。