API 契约交叉审计 · 汇总
5 个 agent 按 tag 分域并行审计 view/app 的 288 个调用点,方法见 README。
每域原始报告:chat.md、space-account.md、pulls-reviews.md、issues-repo.md、git-misc.md。
总览
| 域 | 调用点 | 操作 | 确认 | 可疑 | Q | B | P | R | G |
|---|---|---|---|---|---|---|---|---|---|
| chat / notifications | 62 | 58 | 3 | 4 | 0 | 1 | 0 | 2 | 0 |
| space / auth / account / user | 87 | 77 | 16 | 6 | 0 | 1 | 1 | 4 | 10 |
| pulls / reviews / protection | 54 | 54 | 1 | 4 | 0 | 0 | 0 | 1 | 0 |
| issues / repos / branches / tags / releases | 65 | 63 | 5 | 3 | 1 | 2 | 2 | 0 | 0 |
| git 底层(diff/blob/tree/commit/blame/lfs/remote/archive/cleanup/submodule/conflict/push) | 30 | 30 | 3 | 5 | 1 | 0 | 1 | 1 | 0 |
| 合计 | 298 | 282 | 28 | 22 | 2 | 4 | 4 | 8 | 10 |
28 条确认问题里,18 条已在本轮修掉,10 条是后端能力缺口(见「未修」)。
共性根因(比单条 bug 更值得记住)
OPS表的query/body标志位曾经是常量,params从不发给生成客户端 —— 上一轮修掉后, 所有 query 才第一次真正上线;本轮 4 条 Q 类(skip/max_count、sort=position、limit=200×2) 都是「参数第一次被发出去」后才暴露出来的。- 生成的 URL builder 对 path 参数零编码(355 个 builder 无一调用
encodeURIComponent)。 分支名、标签名、release tag 合法允许/,于是建得出来、改不掉也删不掉。 listOf/page?.items兜底把形状错误降级为静默空数据:list_pins裸数组当分页、list_emojis分页当数组(后者直接崩)、my_suspensions裸数组当分页、MemberSummary扁平当嵌套 —— 四例都是「不报错、只是永远空着」。utoipa文档与 handler 返回类型会脱节:list_pins的body = Page<MessageDto>与实际Vec<MessageDto>不符,生成客户端因此骗了前端。文档即契约,注释写错等于契约写错。- 单层
Option<T>被前端当三态用:null与「缺省」在服务端同义, 于是「清空」动作静默失效(房间限流、仓库简介/主页、保护规则 pattern、封禁到期)。
已修(18)
后端(lib/service/space/src/routes.rs):space 服务有 10 个 handler + utoipa 声明 + 生成客户端,
但路由没挂载 —— 挂上即可,无契约变更:
| 方法 | 路径 | handler |
|---|---|---|
| POST | /spaces/{s}/members | add_member |
| GET/PATCH/DELETE | /spaces/{s}/members/{member_id} | get_member / update_member / remove_member |
| GET | /spaces/{s}/bans | list_bans |
| GET/PATCH | /spaces/{s}/bans/{user_id} | get_ban / update_ban |
| GET/POST | /spaces/{s}/invites | list_invites / create_invite |
| DELETE | /spaces/{s}/invites/{invite_id} | revoke_invite |
前端:
| 类 | 位置 | 修法 |
|---|---|---|
| P | services/api/index.ts | path 参数统一 encodeURIComponent(一处修全量 355 个 builder 的斜杠问题) |
| Q | repo/branches/{branches-tab,tags-section}.tsx | PAGE_LIMIT 200 → 100(服务端上限) |
| Q | space-admin.ts | ADMIN_PAGE_LIMIT 200 → 100 |
| Q | repo/model/commits.ts | skip/max_count → offset/limit |
| Q | space-admin.ts | 删除 sort: 'position'(该端点不接受) |
| Q | history/ui/commits-tab.tsx | offset 到 5000 上限即停(越界是 400 不是截断) |
| R | chat/model/api.ts | list_pins 按裸数组读;list_emojis 按 page.items 读 |
| R | settings/model/api.ts | my_suspensions 按裸数组读 |
| R | chat/issues/repo-protection/space-admin 四处 | 新增 lib/member-row.ts:扁平 MemberSummary → { user } |
| R | pulls/model/api.ts | updateBranchPull 返回 {pull,result},取 .pull |
| R | history/ui/commit-view.tsx | 根提交不再拿 EMPTY_TREE 求 diff(服务端 rev_parse 404),改为显式空补丁 + 文案 |
| B | issues/ui/issue-form-modal.tsx | attachment_ids 只在 create 发,PATCH 不再带(UpdateIssueCmd 无此字段) |
| B | repo/.../general-settings.tsx | 简介/主页用空串清空(后端单层 Option<String>) |
| B | chat/ui/chat-room.tsx | 限流清空发 0 而不是 null(后端 0..=3600 合法,null=不变) |
| B | space-admin.ts | 封禁带 expires_at 时串一次 update_ban(BanMemberBody 只有 reason) |
| P | space-admin.ts | 成员三件套先 findSpaceMember 取会籍 UUID,再填 {member_id}(原来填数字 user_id) |
未修(10 + 22 可疑)
后端能力缺口(本轮不改,需要产品/后端决定):
diff_raw无法对空树求差:根提交的补丁需要git diff-tree --root之类支持,当前前端只能降级。BanMemberBody没有expires_at:现在靠封禁后补一次 PATCH 实现到期时间,两次调用非原子。CreateInviteBody没有email/extra_role_ids:邀请表单里的「按邮箱邀请」「附带角色」被静默丢弃。list_pins的utoipa响应类型写成Page<MessageDto>,实际是Vec<MessageDto>;下次重生成客户端前应修正注释。UpdateBranchPolicyCmd的protected_file_patterns等是单层Option,与 README 宣称的三态 PATCH 不符。
前端待决策(改动涉及 UI 取舍,本轮未动):
- 邀请弹窗的
email/ 角色选择(#3 的用户可见部分); protection.ts的update_policy仍把未改动的 pattern 补成null(等价于「不变」,属误导性写法);chat.update_room_setting/update_thread的null分支(当前无调用方触发);listRoomMembers本地空实现(后端确实没有该端点,不是契约错配)。
复现审计
# 机械扫描(Q/B/P/R 四类,秒级)
python3 scripts/gen-api-ops.py # 确认 OPS 表与生成客户端一致
# 语义复核(逐 handler 结构体比对)按 README 分域进行
bash scripts/contract-check.sh --client-only