docs(audit): record the five-domain API contract audit
`audit/README.md` fixes the rubric (Q/B/P/R/G, evidence requirements, report shape), each domain owns one report, and `SUMMARY.md` rolls up the 28 confirmed problems, the five shared root causes, what this batch fixed, and the ten items that need a backend or product decision. The reports are read-only findings — no business code was touched while producing them.
ZhenYi committed
Added audit/chat.md audit/chat.md +63 −0 View audit/chat.md
@@ -0,0 +1,63 @@
1# chat / notifications
2
3## 结论
4- 检查调用点 62 个 / 操作 58 个(chat 56 + notifications 2)
5- 确认问题 3 条(Q 0 / B 1 / P 0 / R 2 / G 0),可疑 4 条,未实证 58 个操作
6- 未实证原因:本机服务在 7051 已起(`GET /healthz` → 200),但 chat 与 `/me/notifications` 全部要求会话;
7 匿名调用直接 401,取不到 400 的字段级报错。原文(`curl -s 'http://localhost:7051/api/v1/chat/spaces/demo/rooms'`):
8 `{"code":401,"data":null,"error":null,"error_code":"unauthenticated","message":"authentication required","request_id":"01a19e44-534f-760a-a5db-0e708cfff6d5"}`
9 需要:任意已登录用户会话 Cookie(`session`),且该用户是目标空间成员(`require_space_member` / `require_room_readable`)。
10 下文所有「后端依据」均为源码取证,未走 HTTP 实测。
11
12## 确认问题
13| # | 类 | 位置 | 操作 | 现象 | 后端依据 | 建议修法 |
14|---|---|---|---|---|---|---|
15| 1 | R | `view/app/src/domains/chat/model/api.ts:200`(消费方 `view/app/src/domains/chat/model/use-room-data.ts:70`) | `chat.list_pins` | 前端按分页对象取 `page?.items ?? []`,但后端返回**裸数组**,`.items` 恒为 `undefined`,置顶列表恒为空数组——即使房间里有置顶消息也一条都不显示 | `lib/service/chat/src/handlers/messages.rs:195-202` → `Result<AppResponseData<Vec<MessageDto>>, ApiError>`;`lib/service/chat/src/services/messages.rs:1329-1343` → `list_pins(...) -> Result<Vec<MessageDto>, ChatError>`,`build_message_dto(rows, caller)` 直接返回 `Vec`;路由 `lib/service/chat/src/routes.rs:65` `GET /chat/rooms/{room_id}/pins`。生成类型 `packages/apiclient/index.schemas.ts` 的 `EnvelopePageMessageDto`(前端 `PinnedPage = EnvelopePageMessageDto['data']`,`chat.ts:54`)把响应描述成 `{items,total,limit,offset}`,与 Rust 不符 | 前端改 `listOf<ChatMessage>(...)` 或 `unwrapApi<ChatMessage[]>` 并直接返回数组(`PinnedPage` 类型同时删除/改为 `ChatMessage[]`) |
16| 2 | R | `view/app/src/domains/chat/model/api.ts:348-352`(崩溃点 `view/app/src/domains/chat/ui/emoji-picker.tsx:91`) | `chat.list_emojis` | 前端用 `listOf<CustomEmoji>` 当作**裸数组**(注释写「The endpoint answers with a bare array」),但后端返回 `Page<EmojiDto>`。`listOf` 的 `rows ?? []` 把整个分页对象当数组放行,最终经 `chat-room.tsx:632/651/741` → `emoji-picker.tsx:91` 执行 `custom.filter(...)`,对普通对象调用 `.filter` 抛 `TypeError`,表情面板整体不可用 | `lib/service/chat/src/handlers/emojis.rs:24-32` → `Result<AppResponseData<Page<EmojiDto>>, ApiError>`;`lib/service/chat/src/services/emojis.rs:65-98` → `list_emojis(...) -> Result<Page<EmojiDto>, ChatError>`,末尾 `Ok(Page::new(items, total, limit, offset))`;路由 `routes.rs:130-133` `GET /chat/spaces/{space_name}/emojis`;生成类型 `EnvelopePageEmojiDto`(前端 `CustomEmoji = EnvelopePageEmojiDto['data']['items'][number]`,`chat.ts:52`,本身也承认是分页) | 改读 `page?.items ?? []`(与 `CustomEmoji` 的生成类型一致),删掉那条与源码相反的注释 |
17| 3 | B | `view/app/src/domains/chat/model/api.ts:53-63`;触发点 `view/app/src/domains/chat/ui/chat-room.tsx:981-986` | `chat.update_room` | `rate_limit_per_user` 的三态在前后端不一致:前端在「限流值 ≤ 0」时发 `null`(`Number.isFinite(rate) && rate > 0 ? rate : null`),意在**清零**;后端字段是 `Option<i32>` 且 `#[serde(default)]`,`null` 与「缺省」同为 `None`,服务里 `if let Some(...)` 直接跳过 —— 房间限流一旦设上就再也无法通过编辑弹窗关掉(`topic: null` 清空同理) | `lib/service/chat/src/services/rooms.rs:49-66` `UpdateRoomCmd { name: Option<String>, topic: Option<String>, is_private: Option<bool>, nsfw: Option<bool>, rate_limit_per_user: Option<i32>, position: Option<i64>, parent_id: Option<Option<Uuid>> }` —— 只有 `parent_id` 用 `deserialize_opt_opt`,其余是单层 `Option`;`api::serde_patch::deserialize_opt_opt`(`lib/infra/api/src/serde_patch.rs`)才是三态实现。对端 `moveRoom`(`view/app/src/domains/space/management/model/api.ts:73-86`)正是靠它才能把房间拖回空间根 | 后端把 `rate_limit_per_user` / `topic` 改成 `Option<Option<T>>` + `deserialize_opt_opt` 并在服务里处理 `Some(None)`;或前端改为发送哨兵值(如 `rate_limit_per_user: 0`,需后端把 0 视为清空),二选一即可 |
18
19## 可疑(需人工确认)
20| # | 位置 | 操作 | 疑点 | 已排除的可能 |
21|---|---|---|---|---|
22| 1 | `view/app/src/domains/chat/model/api.ts:293-294` | `chat.unfollow_thread` | 前端传 `data: { notify_level: null }`,但 `OPS` 表该条是 `path: 1, query: false, body: false`(`view/app/src/services/api/ops.ts:186`),`invoke()` 根本不会把 `data` 放进实参——这份 body 是死载荷 | 不会 400:后端 `unfollow_thread`(`lib/service/chat/src/handlers/threads.rs:166-173`)本身只有 `Path(thread_id)`,无 `Json` 提取器,忽略任何 body;且 `leave_thread`(`services/threads.rs:416-445`)语义就是退出话题,与 `notify_level` 无关。属冗余代码而非契约错误 |
23| 2 | `view/app/src/domains/chat/model/api.ts:73-82` | `chat.update_room_setting` | 声明了 `mute_minutes?: number \| null`、`notification_level?: ... \| null`,注释表示 `null` 可清空;后端 `UpdateRoomSettingCmd` 两者都是单层 `Option`(`lib/service/chat/src/services/settings.rs:23-34`),`null` 等同缺省 → 静默不生效 | 当前无调用方传这两个字段:仅 `chat-room.tsx:506/511` 传 `{muted}` 与 `{favorite}`,未触发。解除静音走后端 `mute_minutes: 0` 分支(`minutes > 0` 判否即清),前端并未使用 |
24| 3 | `view/app/src/domains/chat/model/api.ts:279-282` | `chat.update_thread` | cmd 类型写 `name?: string \| null`、`archived?: boolean \| null`、`locked?: boolean \| null`;后端 `UpdateThreadCmd`(`lib/service/chat/src/services/threads.rs:48-55`)是 `Option<String>` / `Option<bool>`,`null` 与缺省同义,无法清空话题名(后端要靠空串才置 `None`,`threads.rs:331-337`) | 唯一调用点 `side-panel.tsx:288` 只传 `{ locked: !thread.locked }`,未使用 `null` 分支;归档/改名 UI 不存在,暂不构成线上故障 |
25| 4 | `view/app/src/domains/chat/model/api.ts:67` | (无)`listRoomMembers` | chat 域的成员列表是本地空实现 `Promise.resolve([])`,而空间域已有 `space.list_space_members`;房间成员面板因此恒空 | 后端 chat 路由表里**确实没有** `/chat/rooms/{room_id}/members`(`lib/service/chat/src/routes.rs` 全文),不是被删端点,不存在 404/501;`list_space_members` 已在 `api.ts:326` 另作他用。属未接线而非契约错配 |
26
27## 已核对无问题
28| 操作 | 依据 |
29|---|---|
30| `chat.list_messages` | query `before_seq/after_seq/limit/offset/thread_id/author_id` 与 `ListMessageQuery` 逐字一致(`lib/service/chat/src/services/messages.rs:207-216`,且带 `deny_unknown_fields`,前端未多送键);响应 `MessagePage { items,total,limit,offset,has_more,oldest_seq,newest_seq }`(`messages.rs:141-149`)与前端兜底对象 `api.ts:115` 完全对齐 |
31| `chat.send_message` | body `content/nonce/reply_to_id/thread_id/thread_broadcast/attachments/urgent` 与 `SendMessageCmd`(`messages.rs:169-188`)一致;`attachments` 前后端都是 `string[]` ↔ `Vec<Uuid>`(前端 `SendMessageInput.attachments?: string[]`,`chat.ts:65`);`nonce` 走 `nz()`,空值省略而非发 `null`,符合 `Option<String>` |
32| `chat.get_message` | `pathParams{room_id,message_id}` ↔ `Path((room_id, message_id))`,路由 `/chat/rooms/{room_id}/messages/{message_id}`(`routes.rs:56-60`) |
33| `chat.update_message` / `chat.delete_message` | body `{content}` ↔ `UpdateMessageCmd { content: String }`(`messages.rs:190-193`);path 2 段一致 |
34| `chat.bulk_delete_messages` | body `{ message_ids }` ↔ `BulkDeleteCmd { message_ids: Vec<Uuid> }`(`messages.rs:195-198`);路由 `/messages/bulk-delete`(`routes.rs:51-54`) |
35| `chat.forward_message` | body `{ target_room_id, comment }` ↔ `ForwardMessageCmd`(`messages.rs:200-206`) |
36| `chat.add_reaction` / `chat.remove_reaction` | body `{ emoji }` ↔ `ReactionCmd { emoji: String }`;响应为**裸** `Vec<ReactionAggDto>`(`handlers/reactions.rs:32,59`),前端 `listOf<ReactionAgg>` 取法正确 |
37| `chat.list_reactions` | 裸 `Vec<ReactionUserDto>`(`handlers/reactions.rs:81-90`),前端 `listOf` 正确 |
38| `chat.set_message_pin` | body `{ pinned }` ↔ `PinCmd { pinned: bool }`(`messages.rs:200`),路由 `PUT .../pin`(`routes.rs:61-64`) |
39| `chat.get_read_state` / `chat.mark_read` | `MarkReadCmd { message_id?, seq? }`(`services/read_state.rs:34-41`);响应 `ReadStateDto`(同文件 18-26) |
40| `chat.list_space_read_states` | 响应 `SpaceReadStates { items: Vec<ReadStateDto> }`(`read_state.rs:28-31`),前端 `state?.items ?? []` 与之一致 |
41| `chat.get_draft` / `chat.delete_draft` | query `thread_id` ↔ `DraftQuery { thread_id: Option<Uuid> }`(`handlers/drafts.rs:10-13`),路由同路径 GET/DELETE(`routes.rs:150-155`) |
42| `chat.upsert_draft` | body `{ content, thread_id }` ↔ `UpsertDraftCmd { thread_id: Option<Uuid>, content: String }`(`services/drafts.rs:20-25`) |
43| `chat.create_thread` | path `(room_id, message_id)` + body `{ name }` ↔ `CreateThreadCmd { name: Option<String> }`(`services/threads.rs:42`),路由 `/messages/{message_id}/threads`(`routes.rs:74-77`) |
44| `chat.list_threads` / `chat.list_my_threads` | `list_threads` 响应 `ThreadPage = Page<ThreadDto>`(`threads.rs:39`),前端读 `page.items` 正确;`list_my_threads` 用 `PageQuery`(`handlers/threads.rs:74-83`),前端只送 `limit` 合法 |
45| `chat.get_thread` / `chat.update_thread` | path 1 段 ↔ `/chat/threads/{thread_id}`(`routes.rs:80-83`);`UpdateThreadCmd` 字段名一致(`null` 三态问题见可疑 #3) |
46| `chat.follow_thread` | body `{ notify_level }` ↔ `ThreadMemberCmd { notify_level: Option<ThreadNotifyLevel> }`(`threads.rs:57-61`);枚举值 `"None" \| "All"`(`packages/apiclient/chat/follow-thread.schemas.ts:105`)与前端 `'All' \| 'None' \| null` 一致 |
47| `chat.mark_thread_read` | body `{ message_id }` ↔ `MarkThreadReadCmd { message_id: Option<Uuid> }`(`handlers/read_state.rs:76-80`) |
48| `chat.list_space_presences` / `chat.get_presence` | 分别为裸 `Vec<PresenceDto>` 与 `PresenceDto`(`handlers/presence.rs:62-72,41-48`);前端 `listOf` / `unwrapApi` 取法对应 |
49| `chat.create_attachment` | body `{ filename, content_type, size }` ↔ `CreateAttachmentCmd { filename, content_type, size: i64, description?: Option<String> }`(`services/attachments.rs:59-65`),响应 `UploadIntentDto { id, upload_url, expires_secs }` |
50| `chat.finalize_attachment` | body `{ width, height, duration_secs }` ↔ `FinalizeAttachmentCmd`(`attachments.rs:75-82`),path 1 段 ↔ `/chat/attachments/{attachment_id}/finalize`(`routes.rs:122-125`) |
51| `chat.voice_config` / `chat.voice_token` | 路由 `/chat/voice/config`(path 0)与 `POST /chat/rooms/{room_id}/voice/token`(path 1)(`routes.rs:186-187`),与 `OPS` 表 `path` 值一致 |
52| `chat.voice_participants` / `chat.voice_space_states` | 裸 `Vec<VoiceParticipantDto>` 与 `VoiceSpaceStatesDto`(`handlers/voice.rs:70-99`),前端取法对应 |
53| `chat.list_hands` / `raise_hand` / `cancel_hand` | `VoiceHandsDto { hands, can_moderate, my_state }`(`services/voice.rs:149-153`);三个动作 path 1 段,`/@me` 删除走 `cancel_hand`(`routes.rs:212`) |
54| `chat.approve_hand` / `deny_hand` / `revoke_hand` / `kick_participant` | 均为 path 2 段 `(room_id, user_id)` ↔ `Path<(Uuid, i64)>`(`handlers/voice.rs:223-231,271-279`) |
55| `chat.moderate_mute` | `params: { muted }` 走 query(`OPS` 标 `query: true`)↔ 后端 `Query(ModerateQuery { muted: bool })`(`handlers/voice.rs:281-313`),位置正确 |
56| `chat.get_voice_layout` / `chat.save_voice_layout` | 响应 `VoiceLayoutDto { tiles }`(`services/voice.rs:64-68`),前端 `data?.tiles ?? {}` 正确;请求 `VoiceLayoutPatchDto { tiles, removed }`(`voice.rs:73-80`)与前端 `{ tiles, removed }` 一致 |
57| `chat.update_room`(空间侧 `moveRoom`) | `parent_id` 三态由 `deserialize_opt_opt`(`services/rooms.rs:62-65`)真正支持,`null` 回空间根有效 |
58| `chat.create_room` | `CreateRoomCmd { name: String, kind?, topic?, is_private, nsfw, user_limit?, rate_limit_per_user?, parent_id? }`(`services/rooms.rs:501-517`),path 1 段 `space_name` ↔ `/chat/spaces/{space_name}/rooms`(`routes.rs:39-42`) |
59| `chat.list_space_rooms` | 后端裸 `Vec<RoomBriefDto>`(`services/rooms.rs:69-73`,`handlers/rooms.rs:24`),前端 `unwrapApi<RoomBrief[]>`(`shell-api.ts:40`)取法正确 |
60| `chat.list_dms` / `chat.open_dm` / `chat.close_dm` | `Page<DmDto>` + `PageQuery`(`handlers/dms.rs:46-53`),前端读 `items` 并送 `limit` 合法;`OpenDmCmd { peer_user_id?: Option<i64> }`(`services/dms.rs:34-39`)与前端 `{ peer_user_id }` 一致;`close_dm` DELETE `/chat/dms/{room_id}`(`routes.rs:119`) |
61| `notifications.list_notifications` | query `limit/offset/unread/type` ↔ `ListNotificationsQuery { limit, offset, #[serde(rename="type")] notification_type, unread }`(`lib/service/auth/src/handlers/list_notifications.rs:13-20`,带 `deny_unknown_fields`);响应 `Page<NotificationDto>`,前端读 `page.items` 正确;`realtime/client.ts:294` 的 `{limit:1,offset:0}` 同样合法 |
62| `notifications.delete_notification` | `DELETE /me/notifications/{notification_id}`(`handlers/delete_notification.rs:9-26`),path 1 段一致 |
63| 实时回填一致性 | `services/realtime/client.ts:316` 的 `message-create/message-update` 回填用 `get_message{room_id,message_id}`,与 REST 同一 DTO(`MessageDto`);`client.ts:294` 的 `notification-create` 回填用 `list_notifications?limit=1` 取 `items[0]`——单条字段与 `NotificationDto` 同源,形状一致 |
Added audit/git-misc.md audit/git-misc.md +73 −0 View audit/git-misc.md
@@ -0,0 +1,73 @@
1# git-misc
2
3## 结论
4
5- 检查调用点 30 个 / 操作 30 个
6- 确认问题 3 条(Q 1 / B 0 / P 1 / R 1 / G 0),可疑 5 条,未实证 16 个操作
7- 14 个操作在本机 `http://localhost:7051` 上拿到真实响应(含 3 条 400 原文与 2 条 404 原文);
8 其余 16 个是写操作或需空间所有者身份(`require_writer` / `require_owner`),
9 匿名 `demo/demorepos` 够不到,只做源码逐字比对,见文末「未实证清单」。
10
11## 确认问题
12
13| # | 类 | 位置 | 操作 | 现象 | 后端依据 | 建议修法 |
14|---|---|---|---|---|---|---|
15| 1 | R | `view/app/src/domains/repo/history/ui/commit-view.tsx:99-104`;`view/app/src/domains/repo/model/commits.ts:69-70` | `diffs.diff_raw` | 根提交(`parents: []`)走 `const base = commit.parents[0] ?? EMPTY_TREE`,`EMPTY_TREE = 4b825dc642cb6eb9a060e54bf8d69288fbee4904`(`model/diff.ts:39`)。实测该 base 返回 **404**,而 `fetchRawDiff` 没有 `.catch()`,`Promise.all` 整体 reject → 根提交页整页失败(同批的 numstat 加了 `.catch(() => [])`,patch 没有)。实证:`GET /repos/demo/demorepos/diff/raw?base=4b825dc642cb6eb9a060e54bf8d69288fbee4904&head=27c58541bd555b8b9791ed6e2d8d7bdb252230ca` → `{"code":404,"message":"not found","data":null,"error":null,"error_code":null,...}`;同一 head 换真实父提交则 200 | `lib/service/git/src/handler/diffs/raw.rs:10-13,25`(返回 `AppResponseData<String>`);`lib/service/git/src/service/diffs.rs:96-117` `diff_request` 对空树做字符串特判后直接交给 baby diff,对象不在库即 `GitError::NotFound` → 404。根提交 `27c5854…` 的 `parents: []` 实证自 `GET /commits?limit=2&offset=2599` | 给 `fetchRawDiff` 补 `.catch(() => '')`(与 numstat 同策略),或根提交不请求 diff 而由清单自行渲染 |
16| 2 | P | `view/app/src/domains/repo/model/archives.ts:46-48`(`revision`);`view/app/src/domains/repo/model/tree.ts:91-93`(`revision`);`view/app/src/domains/repo/model/remotes.ts:25-26`(`name`) | `archives.get_archive` / `trees.get_tree` / `remotes.upsert_remote` | path 参数里含 `/` 时路径多切一段,路由整体不匹配。实证:`GET /repos/demo/demorepos/tree/HEAD/extra` → `{"code":404,"message":"unknown api route","data":null,"error":null,"error_code":"not_found",...}`;`GET /repos/demo/demorepos/archives/main/1.0?format=Tar` → 同样 `unknown api route`。UI 触发器明确:`archives-settings.tsx:115-120` 的 revision 是自由文本输入,placeholder 就是 `main / v1.0 / sha`。根因在 `packages/apiclient`:355 个 `get*Url` 全部裸插值,`encodeURIComponent` 出现 0 次(`grep -c` 实测)。**query 参数不受影响**——生成器用 `URLSearchParams`,编码正确 | 路由本身要求单段:`lib/service/git/src/handler/archives/download.rs:12` `/archives/{revision}`、`handler/trees/read.rs:36` `/tree/{revision}`、`handler/remotes/add.rs:23` `/remotes/{name}`。编码后能正确进 handler(实证 `GET /tree/feature%2Ffoo` 返回业务 `404 not found` 而非 `unknown api route`,说明已到达 handler 层) | 在调用点对 path 参数做 `encodeURIComponent`(`invoke()` 只把 `pathParams` 的值传给 builder,编码值会原样拼进 URL,`%2F` 已验证可解析) |
17| 3 | Q | `view/app/src/domains/repo/history/ui/commits-tab.tsx:232` | `commits.list_commits` | 字段名本身正确,但 **值域越界**:`onClick={() => setSkip(skip + PAGE_SIZE)}` 只受 `!page.hasMore` 约束,没有 5000 上限;提交数 >5000 的仓库翻到第 101 页后请求 `offset=5050`。实证:`GET /commits?offset=5001` → `{"code":400,"message":"offset must not exceed 5000","data":null,...}`;`GET /commits?limit=101` → `{"code":400,"message":"limit must be between 1 and 100",...}`。`PAGE_SIZE = 50`(同文件 :10),`limit` 在范围内没问题 | `lib/service/git/src/handler/commits/list.rs:19-20`(`limit` 1-100、`offset` ≤5000);`service/commits.rs:24-39` `ListCommitsCmd` 字段确为 `revision/path/limit/offset/since/until/first_parent`,与前端发送的 `revision, offset, limit, first_parent, path` 逐字一致 | `disabled` 增加 `skip + PAGE_SIZE > 5000` 分支并提示「请用 path/revision 缩小范围」,或改成 cursor 分页 |
18
19## 可疑(需人工确认)
20
21| # | 位置 | 操作 | 疑点 | 已排除的可能 |
22|---|---|---|---|---|
23| 1 | `view/app/src/domains/repo/history/ui/commit-view.tsx:101-104` | `diffs.get_diff` | 前端注释(`model/commits.ts:46`)称「`shortstat` 500s on the server today, so totals are summed client-side」,据此放弃 `view=shortstat` 改本地累加。实测 `GET /diff?view=shortstat` → **200**,返回 `{"additions":76,"deletions":1,"files_changed":2}` | 不是 400/500 契约问题(端点正常);也不是形状误用(`ShortStat` 字段与本地累加结果同义)。属过时前提,功能不受影响,故未计入确认问题 |
24| 2 | `view/app/src/domains/repo/model/blame.ts:32-39` | `submodules.submodule_resolve` | 注释写「Resolve a submodule path at `revision` to its **repository path (space/name)**」,但后端返回的是 gitlink 提交 sha。实证 `GET /submodules/resolve?revision=HEAD&path=browser-extension` → `{"code":200,...,"data":"385d36c0807221e6674d1325ef06505746c49ceb",...}` | 返回类型 `string` 与 `AppResponseData<String>` 一致,UI(`submodules-section.tsx:51`)只把它当不透明字符串渲染,不会崩。故只是注释失配,不是契约错配 |
25| 3 | `view/app/src/domains/repo/branches/ui/submodules-section.tsx:49` | `submodules.submodule_resolve` | 后端只在**根树**里按 `e.name == path` 精确匹配(`service/submodules.rs:71-79`:`get_tree_entries(&revision, Path::new(""))`)。`.gitmodules` 允许嵌套路径(如 `vendor/lib`),这类 entry 会被传进来但根树里没有同名条目 → `GitError::NotFound` | 已实证 `path=server`(非子模块)→ 404、`path=browser-extension`/`embed` → 200;本仓库没有嵌套子模块,无法造出「嵌套路径应当成功却 404」的反例,故列可疑 |
26| 4 | `view/app/src/domains/repo/model/tree.ts:89-97` | `trees.get_tree` | 参数 `show_trees: true` 在非递归分支里是**空转**:`handler/trees/read.rs:86-92` 的 `tree_read` 根本不接收 `show_trees`(只有 `recurse` 分支的 `tree_walk` 用它)。实证 `GET /tree/HEAD?path=` 与 `?path=&show_trees=true` 都返回 32 条、`modes=['Blob','Submodule','Tree']`,完全一致 | 不是功能缺陷——`tree_read` 默认就包含 Tree 条目,前端去掉也不会坏。只是参数无效,易误导读代码的人 |
27| 5 | `view/app/src/domains/repo/model/tree.ts:5-12` | `trees.get_tree` | 文件头注释断言 `oid` 是 `{ "Sha1": [20 raw bytes] }`、`name` 已含仓库根相对路径(`assets/logo.jpg`)。实测两者都不成立:`oid` 是纯 hex 字符串(`"8358e08199cf3c8db41dacbe06298fca81a10b40"`),子目录列举时 `name` 是 basename(`path=.github` 返回 `[{"name":"FUNDING.yml",...}]` 而非 `.github/FUNDING.yml`) | `normalizeTreeRows` / `oidToHex` 同时兼容 string 与 `{Sha1:[…]}`、basename 与 root-relative 两种形态,两种现实都归一正确,未产生实际错配。属文档漂移 |
28
29补充一条环境观察(不计入上表):`GET /diff?…&bogus=1` 返回 **200**,未知 query 参数被静默忽略,与 openapi 前言「未知参数同样返回 400」不符。影响取证——本域内 Q 类错误不会以 400 形式暴露,只能靠结构体逐字比对发现。
30
31## 已核对无问题
32
33| 操作 | 依据 |
34|---|---|
35| `diffs.get_diff`(`view` 枚举) | 后端 `handler/diffs/get_diff.rs:12-19` `DiffView` 为 `#[serde(rename_all="lowercase")]` 的 `Shortstat/Numstat/Files/Tree`,前端 `commits.ts:52` 发 `'numstat'` 正确。大小写实证:`view=Numstat` → `{"code":400,...,"error":"Query deserialize error: unknown variant `Numstat`, expected one of `shortstat`, `numstat`, `files`, `tree`"}`;`view=bogus` 同。生成类型 `DiffView` 与之一致 |
36| `diffs.get_diff`(`use_merge_base`) | 仅 Tree 分支使用(`get_diff.rs:87-92`);前端不传(`commits.ts:52` 只发 base/head/view)。实证 `view=numstat&use_merge_base=true` → 200(被接受并忽略),无副作用 |
37| `diffs.diff_raw`(返回形状) | 后端 `handler/diffs/raw.rs:25` 声明 `AppResponseData<String>`,实测是 **JSON envelope 内 data 为字符串**(`{"code":200,...,"data":"diff --git a/...`),并非裸 `text/plain`。前端 `unwrapApi<string>`(`commits.ts:70`)消费方式正确 |
38| `diffs.get_diff`(numstat 行字段) | `rpcgit/diff/types.rs:60-65` `NumStat{additions,deletions,path,old_path}`;前端 `model/diff.ts:184-189` `NumstatRow` 同名同义。实证返回 `{"additions":73,"deletions":0,"old_path":null,"path":"server/…"}` |
39| `diffs.get_diff`(非法 base/head) | 不可解析 revision → 404(实证 `base=doesnotexist` → `{"code":404,"message":"not found"}`),不是 400;前端按通用错误处理,无形状假设 |
40| `trees.get_tree`(query 名) | `handler/trees/read.rs:20-32` 允许 `path/recurse/resolve/show_trees/view`;前端 `{path, show_trees}` 均在集合内。注意后端是 `recurse` 不是 `recursive`,前端未误用 |
41| `trees.get_tree`(path 语义) | `path` 为空字符串时被 `invoke()` 的 `query()` 丢弃(`services/api/index.ts:61-70` 过滤 `''`),后端 `#[serde(default)]` 补 `""`,等价。子目录实证 `path=.github` 正常返回 |
42| `blobs.blob_content` | `service/blobs.rs:30-36` `BlobContentDto{oid,data:Vec<u8>,size,is_binary,truncated}` → `data` 序列化为数字数组。实证 `data:[118,45,101,110,118,...]`。前端 `readme.ts:150-153` 用 `Array.from(blob.data)` 转字节、`blob.is_binary`/`blob.truncated`/`blob.size` 全部按此消费。路径 3 段 `{space_name, repo_name, oid}` 与 `/blobs/{oid}/content` 一致 |
43| `commits.list_commits`(字段) | 前端发 `revision/offset/limit/first_parent/path`,`service/commits.rs:24-39` `ListCommitsCmd` 逐字包含;`limit:50`、`offset:0` 边界内。实证 200 且 `limit=1` 生效 |
44| `commits.list_commits`(`has_more`) | `service/commits.rs:74-95` `CommitsPage{items,total,limit,offset,has_more,diff_view_style}`;前端 `commits.ts:32-36` 读 `page.items/total/has_more`。实证 `"total":2601,"limit":1,"offset":0,"has_more":true` |
45| `blame.blame` | `handler/blame.rs:10-18` query=`revision,path,range_start,range_end`;前端只发 `revision,path`,合法。返回 `Vec<BlameRangeDto>`,实测 `{"starting_line":1,"ending_line":2,"commit_sha":"ac0b1d4…","abbreviated_oid":"ac0b1d4","path":null}`,前端 `blameLineMap`(`blame.ts:54-61`)用的 `starting_line/ending_line` 存在 |
46| `conflicts.get_conflicts` | `handler/conflicts/get_conflicts.rs:10-16` query=`base,head,ancestor`;前端 `pulls/model/api.ts:156-160` 发 `{base,head}` 合法。实证 `{"mergeable":true,"conflicts":[],"fast_forwardable":true}`,与 `MergeStatusDto` 一致 |
47| `conflicts.conflict_detail` | `handler/conflicts/detail.rs:10-15` query=`base,head,path`;前端 `blame.ts:26-29` 三字段齐全。返回 `ConflictDetailDto{path,content:Option<String>}` 与前端 `{path, content?}` 一致。**正例未实证**:`demo/demorepos` 无冲突对,`base=HEAD~1&head=HEAD&path=README.md` 返回 404 |
48| `submodules.submodule_list` | `handler/submodules/list.rs:10-13` query=`revision`;前端 `blame.ts:44-47` 只发 `revision`。返回 `Vec<SubmoduleDto{path,url,branch,commit_sha}>`,实证 `[{"path":"browser-extension","url":"","branch":null,"commit_sha":"385d36c…"},…]`,`submodules-section.tsx` 消费的四个字段全部存在 |
49| `archives.get_archive`(format 枚举) | `service/archives.rs:21-25` 的 `format` 是 `rpcgit::archive::types::ArchiveFormat`(`rpcgit/archive/types.rs:5-11`,**无** `rename_all`)→ serde 只认 `Tar/TarGz/TarBz2/Zip`。前端 `archives.ts:30-35` `QUERY_FORMAT` 映射正是这四个值,正确。实证 `format=tar_gz` → `{"code":400,...,"error":"Query deserialize error: unknown variant `tar_gz`, expected one of `Tar`, `TarGz`, `TarBz2`, `Zip`"}`;`format=Tar` → 通过解析,仅因无产物报 `invalid argument: archive artifact not found` |
50| `archives.create_archive`(body) | `handler/archives/create.rs:30-36` `ArchiveCreateCmd{revision,format:ArchiveFormatDto,path_filter}`,而 `ArchiveFormatDto` 是 `#[serde(rename_all="snake_case")]`(同文件 :11)→ 只认 `tar/tar_gz/tar_bz2/zip`。前端 POST 传 `cmd.format` 的 snake_case 值、GET 才映射 PascalCase,两边分别正确。`path_filter: null` 对 `Option<String> + #[serde(default)]` 等价于 None |
51| `archives.get_archive`(返回值/无轮询) | `ArchiveResultDto{format:String,size:i64,cache_key,download_url:Option<String>}` **没有状态字段**;前端 `archives-settings.tsx:140-183` 三处都是一次性调用后读 `download_url`/`size`,不做轮询,不存在「轮询错字段」 |
52| `archives.create_bundle` | `handler/archives/create_bundle.rs:10-24` 无 query/body,路径 2 段;前端 `archives.ts:26` 仅传 pathParams,一致 |
53| `remotes.list_remotes` | 返回裸 `Vec<RemoteDto>`(`handler/remotes/list.rs:17,24`),前端 `unwrapApi<RemoteRow[]>`(`remotes.ts:21`)按数组消费。实证 `"data":[]` |
54| `remotes.upsert_remote` / `remove` | `handler/remotes/add.rs:16-23`、`remove.rs:16-19`:path=`{space_name,repo_name,name}` 3 段、body 仅 `{url}`;前端 `remotes.ts:25-27`、`:32` 一致。返回 `AppResponseData<NoData>`,前端 `unwrapApi<null>` 与 `data:null` 对应 |
55| `remotes.fetch` / `push` / `clone_import` | body 分别为 `RemoteFetchBody{remote,auth}`、`RemotePushBody{remote,refspec,auth}`、`RemoteImportBody{url,auth}`(`handler/remotes/{fetch,push,clone_import}.rs`)。`RemoteAuthDto{token:Option<String>,username:Option<String>}`(`service/remotes.rs:28-34`)⊇ 前端 `auth: {token}`;`auth: null` 与 `Option<RemoteAuthDto> + #[serde(default)]` 等价。`refspec: null` 同理 |
56| `lfs.list_locks` | `handler/lfs/list_locks.rs:10-16` query=`cursor,limit`;前端 `lfs.ts:15` 发 `{cursor, limit:50}`(`cursor` 为空时回落 `undefined` 因而被丢弃)。返回 `{locks,next_cursor?}`(`lfs/types.rs:110-115`),实证 `{"locks":[]}`(无 `next_cursor` 时不出现,`skip_serializing_if`),前端 `page.data?.locks ?? []` 兼容 |
57| `lfs.verify_locks` | `handler/lfs/verify_locks.rs:10-14` query=`limit`,是 **POST**;生成签名 `verifyLocks(spaceName, repoName, params?, options?)` 与 OPS `{query:true, body:false}` 吻合。返回 `{ours,theirs,next_cursor?}`,前端 `locks-settings.tsx:58-61` 读 `ours`/`theirs` 正确 |
58| `lfs.lock` | `handler/lfs/lock.rs:10-15` body=`{path,oid}`;前端 `lfs.ts:23` 发 `{path, oid: null}`,`Option<String> + #[serde(default)]` 接受 null |
59| `lfs.unlock` / `unlock_by_id` | `handler/lfs/unlock.rs:9-14` query=`path,force`;`unlock_by_id.rs:9-20` path=`{space_name,repo_name,lock_id}` + query=`force`。前端 `lfs.ts:31`、`:39` 键名与顺序一致。**注意 `path` 走 query(`URLSearchParams` 编码),不受上文 P 类缺陷影响** |
60| `cleanup.list_cleanup_objects` | `lib/service/webapi/src/cleanup.rs:250,257` 返回**裸数组** `AppResponseData<Vec<CleanupObjectDto>>`;前端 `rows<CleanupObject>`(`space-admin.ts:376`)按数组消费,正确。`path:1` = `/spaces/{space_name}/cleanup` 单段一致 |
61| `cleanup.set_cleanup_visibility` / `set_cleanup_archive` | body 分别 `SetCleanupVisibilityCmd{is_private}`(`cleanup.rs:71-74`)、`SetRepoArchivedCmd{archived}`(`:76-79`);前端 `space-admin.ts:382`、`:390` 逐字一致。`path:3` 分别对应 `/visibility` 与 `/archive` 两个后缀 |
62| `cleanup.delete_cleanup_object` / `clear_cleanup_overwrites` | 无 body,`path:3` 对应 `:539` 与 `:668` 的 `{kind}/{object_id}[/overwrites]`;前端 `space-admin.ts:396`、`:403` 一致。返回 `AppResponseData<NoData>` ↔ `unwrapApi<null>` |
63| `push.push_config` | `lib/service/user/src/handlers/push_config.rs:13-17,21` → `PushConfigDto{enabled,public_key}`、`path="/push/config"`、无参数。前端 `notifications-section.tsx:26-31` 类型 `{enabled, public_key}` 一致。实证 `{"enabled":true,"public_key":"BCRWNH5S0jM0wMo0…"}` |
64
65## 未实证清单
66
67| 操作 | 原因 |
68|---|---|
69| `remotes.upsert_remote` / `remove` / `fetch` / `push` / `clone_import` | 写操作,需目标仓库写权限;匿名会话被拒。仅做源码比对 |
70| `lfs.lock` / `unlock` / `unlock_by_id` / `verify_locks` | 写操作(`require_writer`),同上 |
71| `archives.create_archive` / `create_bundle` | `AuthedUser` + `require_writer`,同上;`format` 枚举已在 GET 上实证同一 serde 规则 |
72| `cleanup.*`(4 写 + 1 读,共 5 个) | `require_owner`(仅空间所有者);`list_cleanup_objects` 同样受 `AuthedUser + require_owner` 限制,匿名不可达 |
73| `conflicts.conflict_detail` 正例 | 被测仓库无冲突文件对,只能确认 query 名与 DTO 字段 |
Added audit/issues-repo.md audit/issues-repo.md +57 −0 View audit/issues-repo.md
@@ -0,0 +1,57 @@
1# issues / analysis / repos / branches / tags / releases
2
3## 结论
4- 检查调用点 65 个 / 操作 63 个(issues 33、repos 12、branches 5、tags 4、releases 5、analysis 4)
5- 确认问题 5 条(Q 1 / B 2 / P 2 / R 0 / G 0),可疑 3 条,未实证 2 条
6- 基线说明:工作区存在他人未提交修改(`git status` 显示 `branches-tab.tsx`、`tags-section.tsx` 的 `PAGE_LIMIT` 已由 200 改为 100),
7 确认问题 #5 以 **HEAD 提交版本**判定;该条在工作区当前状态已被改掉,报告按 HEAD 记录以便回归
8
9> 实证环境:`http://localhost:7051`(`/healthz` 200)。`demo/demorepos` 可匿名读,branches / tags / releases / repos / analysis 全部实测;
10> issues 全域与 repos 写操作需登录或 owner,运行时未实证,仅源码取证(即确认问题 #3、#4)。
11
12## 确认问题
13
14| # | 类 | 位置 | 操作 | 现象 | 后端依据 | 建议修法 |
15|---|---|---|---|---|---|---|
16| 1 | P | `view/app/src/domains/repo/model/refs.ts:41`(renameBranch)、`:50`(deleteBranch)、`:58`(setDefaultBranch)、`:76`(getTag)、`:96`(deleteTag) | `branches.rename_branch` / `branches.delete_branch` / `branches.set_default_branch` / `tags.get_tag` / `tags.delete_tag` | 分支名/标签名含 `/`(`feature/login`)时路径被拆成多段,命中不了单段路由,返回 **404 `unknown api route`**,UI 显示"接口不存在"而不是"找不到分支"。生成客户端对 pathParams 是裸插值,不做任何 percent-encoding | 生成器:`packages/apiclient/branches/rename-branch.ts:106-112` → `` `/api/v1/repos/${spaceName}/${repoName}/branches/${name}` ``;`packages/apiclient/tags/get-tag.ts:106-112` 同形。路由单段:`lib/service/git/src/routes.rs:125`(`/branches/{name}`)、`:137`(`/tags/{name}`)。实证:`GET /api/v1/repos/demo/demorepos/branches/no/such` → `404 {"code":404,"message":"unknown api route","error_code":"not_found"}`;同段编码后 `GET …/branches/no%2Fsuch` → `400 {"code":400,"message":"revision ... branch not found ... is malformed or ambiguous"}`(已路由到 handler)。`refs.ts:76` 的 `no%2Fsuch` 返回 500(见可疑 1),说明编码形式能进业务层 | 在 `model/refs.ts` 的这 5 个函数里对 `name` 做 `encodeURIComponent(name)` 再传 `pathParams`(或在 `services/api/index.ts` 的 `invoke()` 里统一编码 pathParams 值)。注意 `validateBranchName`(`domains/repo/branches/model/branch.ts:9`)明确允许含 `/` 的名字,创建能成功、之后改名/删除/查看必 404 |
17| 2 | P | `view/app/src/domains/repo/model/releases.ts:30`(getRelease)、`:41`(updateRelease)、`:49`(deleteRelease) | `releases.get_release` / `releases.update_release` / `releases.delete_release` | 同上:`tag` 含 `/` 时 404。release 的 tag 名来自 `list_releases` 的 `tag_name`(`releases-tab.tsx:168` 直接 `onSelect(release.tag_name)`),带斜杠的 tag 是常态 | 路由单段:`lib/service/git/src/routes.rs:176`(`/releases/{tag}`)。实证对照:`GET …/releases/no/such` → `404 {"code":404,"message":"unknown api route","error_code":"not_found"}`;`GET …/releases/no%2Fsuch` → `404 {"code":404,"message":"not found"}`(业务层 404,路由已命中) | 同 #1,对 `tag` 做 `encodeURIComponent` |
18| 3 | B | `view/app/src/domains/issues/ui/issue-form-modal.tsx:237`(`payload.attachment_ids`)→ `view/app/src/domains/issues/model/api.ts:130-136`(`updateIssue` 把整个 `cmd` 原样当 `data`) | `issues.update_issue` | 同一个 `payload` 同时喂 `createIssue` 与 `updateIssue`,于是 **PATCH 体里多带一个 `attachment_ids`**。`UpdateIssueCmd` 没有该字段且未加 `deny_unknown_fields`,actix `web::Json` 静默丢弃 → 编辑工单时新上传的附件静默不绑定(不报错、不生效),只有新建时生效 | `lib/service/issues/src/services/issues.rs:102-123` `UpdateIssueCmd {title, description, content, milestone_id, priority, due_at, assignee_ids, label_ids}`——无 `attachment_ids`;对照 `CreateIssueCmd`(同文件 `:78-100`)确有 `attachment_ids: Vec<Uuid>`。生成类型 `packages/apiclient/issues/update-issue.schemas.ts:150-166` 同样无该字段。TS 不报错是因为 `payload` 是变量而非对象字面量(结构化类型允许多余键)。**未实证**:`PATCH …/issues/{id}` 需登录(匿名 `GET …/issues` 已实测 `401 {"error_code":"unauthenticated"}`) | 编辑分支不要构造 `attachment_ids`(`issue-form-modal.tsx:228` 的 `payload` 拆成 create/patch 两份);若产品需要编辑期加附件,后端 `UpdateIssueCmd` 增加 `attachment_ids: Option<Vec<Uuid>>` 并落 `issue_attachment` |
19| 4 | B | `view/app/src/domains/repo/settings/ui/sections/general-settings.tsx:101-102`(`description: form.description.trim() \|\| null`、`website: … \|\| null`)→ `view/app/src/domains/repo/model/repo.ts:29` | `repos.update_repo` | 前端把"清空"编码成 `null`(PATCH 三态语义:null=清空),但后端 `UpdateRepoCmd.description/website` 是**单层** `Option<String>`,反序列化后 `null` 与"字段缺省"都是 `None`,服务端只在 `Some` 时落库 → **描述/主页永远清不掉**,改完返回 200 且回显旧值 | `lib/service/git/src/service/repos.rs:221-238` `UpdateRepoCmd`(`description: Option<String>`、`website: Option<String>`,无 `deserialize_opt_opt`);同文件 `:656-661` `if let Some(desc) = cmd.description { am.description = Set(Some(desc)); }`——`None` 即不动。反例:看板的 `UpdateBoardCmd`(`lib/service/issues/src/services/boards.rs:50-59`)对 `prefix/icon/description` 用了 `deserialize_opt_opt`,三态才成立。**未实证**:`PATCH /repos/{space}/{repo}` 要求 space owner(`repos.rs:644-646`) | 后端把 `UpdateRepoCmd.description/website` 改成 `Option<Option<String>>` + `#[serde(default, deserialize_with = "api::serde_patch::deserialize_opt_opt")]`,与 `UpdateBoardCmd` 对齐;前端保持不变 |
20| 5 | Q | `view/app/src/domains/repo/branches/ui/branches-tab.tsx:14`、`view/app/src/domains/repo/branches/ui/tags-section.tsx:10`(HEAD 版本 `PAGE_LIMIT = 200`,经 `refs.ts:22` / `:63` 传入 `params: { limit }`) | `branches.list_branches` / `tags.list_tags` | **query 值越界**:两个分页端点服务端上限是 100,`limit=200` 直接被拒,分支页与标签页整体 `400`、列表空白。同形状的 400 与未知参数 400 同源(`PageQuery` 的 bounds 校验),因此归在 query 类 | `lib/infra/api/src/pagination.rs:8` `MAX_PAGE_LIMIT = 100`,`:43-45` `if limit == 0 \|\| limit > max { return Err(...) }`;`branches/list.rs` 与 `tags/list.rs` 都用 `Query(page): Query<api::pagination::PageQuery>` + `page.bounds()`。实证:`GET …/branches?limit=101` → `400 {"code":400,"message":"limit must be between 1 and 100"}`。**注:工作区未提交修改已把两处改成 100,HEAD 仍是 200** | 已按工作区改法保留 `PAGE_LIMIT = 100`(等于上限即可,无需分页回退);若产品要看到 100 支以上的分支,需要补 offset 翻页而不是抬 limit |
21
22## 可疑(需人工确认)
23
24| # | 位置 | 操作 | 疑点 | 已排除的可能 |
25|---|---|---|---|---|
26| 1 | `view/app/src/domains/repo/model/refs.ts:76` → `branches/ui/tags-section.tsx:126`(fetchTag 详情) | `tags.get_tag` | 标签不存在时后端返回 **500** 而非 404:`GET /api/v1/repos/demo/demorepos/tags/no%2Fsuch` → `500 {"code":500,"message":"internal server error","data":null,"error":null,"error_code":null}`。前端 `unwrapApi` 会把 500 渲染成「服务未响应(网关错误)」,掩盖"标签已被删"的真实原因 | 已排除路由问题:同路径用短名会进 handler(对照 `/branches/no%2Fsuch` 返回 400 业务错误)。500 出在 tag 查询/`TagInfo` 转换层,不在路由或契约字段 |
27| 2 | `view/app/src/domains/repo/model/releases.ts:17-29` | `releases.list_releases` | 该函数**绕过 `unwrapApi`**,直接 `res as unknown as {data?: ReleaseListPage}` 再 `res.data ?? {items:[],…}`:任何 4xx/5xx(如 `limit` 越界、无权限)都被折叠成"空发布列表",`releases-tab.tsx:47` 的 `list.error` 永远拿不到错误,用户看到"暂无发布" | 已排除形状错用:实测 `GET …/releases?limit=50` 返回 `{"items":[],"total":0,"limit":50,"offset":0}`,`data.items/total` 字段确实存在,解析没错;问题只在错误通道 |
28| 3 | `lib/service/git/src/handler/branches/get.rs:39` vs `branches/list.rs` | `branches.get_branch` | 同一 `BranchDto.name` 两种形态:`list` 返回短名 `"master"`,`get` 返回全 ref `"refs/heads/master"`(实测两条 curl 均 200)。若后续有人把 `get_branch` 的 `name` 回填进路径参数,拼出的 `/branches/refs/heads/master` 会直接 404 | 已排除当前可触发:65 个调用点均未调用 `branches.get_branch`(`ops.ts:108` 有该 op,前端无调用方),现有删除/改名/浏览全部取自 `list_branches` 的短名 |
29
30## 已核对无问题
31
32| 操作 | 依据 |
33|---|---|
34| `issues.list_issues`(`issues/model/api.ts:84-105`) | query 逐字对齐 `lib/service/issues/src/services/issues.rs:142-163` `ListIssueQuery`:`state / author_id / milestone_id / label_id / assignee_id / priority / sort / q / limit / offset`,前端发出的 10 个键一个不多一个不少;结构体带 `#[serde(deny_unknown_fields)]`(`:143`),多传即 400,前端未多传。`sort` 走 `SORT_PARAMS`(`model/meta.ts:206-212`)= `created_at/updated_at/priority/due_at/number`,与 `IssueSort`(`issues.rs:125-133`,`rename_all="snake_case"`)5 个取值完全一致;未知值被 `SORT_PARAMS[k]` 取成 `undefined` 后由 `query()` 丢弃,不会打出 400 |
35| `issues.list_issues` 的 `priority` / `milestone_id` 多值 | `csv()` 合并成 `a,b`,后端 `parse_filter_priorities`(`issues.rs:181-197`)按 `PRIORITY_KEYS`(`:224-230` = none/low/medium/high/urgent)逐项校验,与 UI 的 5 个 `PRIORITY_KEYS` 同拼写;`none` 兜底走 `parse_milestone_filter`(`:201-`)的 `include_no_milestone`,前端 `board-filters.tsx:277` 传的就是 `none` |
36| `issues.list_labels` / `list_milestones` / `list_events` | 均为 `pageOf()`(`api.ts:54-60` 读 `items/total/limit/offset`),后端三者都是 `Page`:`labels/list_labels.rs:17`、`milestones/list_milestones.rs:17`、`events/list_events.rs:16`。query 也对齐:`ListLabelQuery`(`labels.rs:82-87` 有 `is_archived`)、`ListMilestoneQuery`(`milestones.rs:63-` 有 `is_closed`) |
37| 列表 `limit` 未被当数组用(上一轮踩过的坑,全量复查) | 6 个分页端点的消费方全部取 `.items`,无一处 `data?.find/?.map` 直接作用在分页对象上:`shell-api.ts:47,53,62`、`repo.ts:23`、`refs.ts:22,65`、`releases.ts:17`、`commits-tab.tsx:90`(`tags.data?.items`)、`tags-section.tsx:41`、`branches-tab.tsx`。反向的 5 处 `unwrapApi<T[]>`(`insights.ts:16,20,23`、`topics.ts:10`、`remotes.ts:21`)对应端点确实返回数组:`analysis/languages.rs:12`、`languages_read.rs:12`、`contributors.rs:12` 均 `Vec<…Dto>` |
38| 列表 `limit`(除确认问题 #5 外未越界) | 实测上限 100:`GET …/branches?limit=101` 与 `?limit=0` → `400 {"message":"limit must be between 1 and 100"}`,`…/releases?limit=101` 同。逐项核对取值:`ISSUE_PAGE_SIZE=50`(`use-board-data.ts:21`,`board-view.tsx:111`)、`listLabels/listMilestones=100`(`api.ts:213,269`,issues `max_pagination=100` 见 `config/default/issues.json:2`)、`listEvents=50`(`api.ts:201`)、`listReleases=50`(`releases-tab.tsx:45`)、`listSpaceRepos=100`(`repo.ts:25`)、`fetchSpaceRepos=50`/`fetchTags=10`(`shell-api.ts:47,62`)、`listBranches/listTags` 默认 `100`(`refs.ts:19,63`)——除 `branches-tab.tsx`/`tags-section.tsx` 的 HEAD 值 200 见 #5 外全部 ≤100 |
39| `issues.update_issue` 三态(`milestone_id` / `due_at` / `description`) | `IssuePatchCmd`(`model/issue.ts:159-168`)字段名与 `UpdateIssueCmd`(`issues.rs:102-123`)逐一对应;调用方真的按三态发:`issue-detail.tsx:514` `{ milestone_id: value \|\| null }`(选空=null=解除)、`:519` `{ due_at: next }`(清空日期=null)。后端 `Some(mid_opt)` 分支(`issues.rs:919-935`)确认是 `Option<Option<Uuid>>` |
40| `issues.update_board` 三态 | `board-manage-modal.tsx:122-126` 发 `prefix: … \|\| null`、`description: … \|\| null`,后端 `UpdateBoardCmd`(`boards.rs:50-59`)这两字段是 `deserialize_opt_opt` 的 `Option<Option<String>>`,null→清空成立;`name` 每次必发(`:123`)为 `Option<String>`,无副作用 |
41| `issues` 的 UUID / 看板编号双寻址 | 前端混用但后端两种都认:`board-view.tsx:250` 打开时传 `issueRef(board, issue.number)`(`CAD-12` 或 `42`),`issue-detail.tsx:335` 等变更用 `issue.id`(UUID)。后端 `lookup_issue_by_ref`(`lib/service/issues/src/share/lookup.rs:46-77`)依次尝试 `Uuid::parse_str` → `parse::<i32>` → `PREFIX-N`,三种形态皆可;路径参数类型为 `String` 而非 `Uuid`(`issues.rs:233-238` `IssuePath`,`:30` `WatchPath`,`comments.rs:85`,`events.rs:42`),不会因编号 400 |
42| `issues.create_issue` | `api.ts:112-128` 发的 10 个键对齐 `CreateIssueCmd`(`issues.rs:78-100`),含 `attachment_ids`,null 折成 `Option::None` 合法 |
43| `issues.bulk_issues` | `BulkIssuesCmd { action, issue_refs }`(`issues.rs:1169-1173`,`action` 为 `close/reopen`)+ `1..=100` 校验(`:1189`);**前端无任何调用点**(`ops.ts:265` 注册了,65 处调用里 0 处使用),批量改状态是逐条 `close_issue`,故无契约风险 |
44| `issues.list_issue_attachments` / `list_labels_for_issue` / `list_comments_tree` | 前端 `listOf()`(读数组),后端三者确为 `Vec`:`attachments/list_attachments.rs:16`、`labels/list_labels_for_issue.rs:16`、`comments/list_comments_tree.rs:17` |
45| 附件三段式(intent → PUT → finalize) | `create_issue_attachment` 无路径段(`ops.ts:271-276` `path:0`)+ body `{space_name, filename, content_type, size}`;`finalize/delete/download` 各 1 段(`ops.ts:287-310`),与 `attachments/create_attachment.rs:11`、`finalize_attachment.rs:12`、`delete_attachment.rs:11` 一致;前端 `api.ts:343` 手写的下载 URL 也做了 `encodeURIComponent` |
46| `issues` 标签/里程碑 CRUD | `create_label` 发 `{name,color,description}`,`CreateLabelCmd`(`labels.rs:53-62`)匹配;`update_label` 的 `description?: string\|null` 对应 `UpdateLabelCmd.description: Option<Option<String>>`(`labels.rs:74-75`)三态成立;`update_milestone` 的 `due_at: dateApiValue(...)`(`board-manage-modal.tsx:446`)清空时给 `null`,对应 `Option<Option<OffsetDateTime>>`(`milestones.rs:53-57`) |
47| `issues.upsert_issue_watch` / `repos.upsert_watch` | 都只发 `{ mode }`,对齐 `UpsertWatchCmd { mode }`(`issues/src/services/watches.rs:25-27`、`git/src/service/watches.rs:27-29`);`WatchMode` 带 `#[serde(rename_all="snake_case")]`(`lib/share/models/src/common/watch_mode.rs:16-19`)= `unwatched/watching/ignore`,与前端 `meta.ts:194-204` 同拼写 |
48| `repos.list_repos` | query 只用 `limit/offset/sort`,对齐 `RepoListQuery`(`git/src/service/repos.rs:43-48`);`sort:'name'` 实测 `200`(`GET /api/v1/repos/demo?limit=100&sort=name`);`PageQuery` 未知键会 400 已实测:`…/branches?limit=10&sort=name` → `400 {"error":"Query deserialize error: unknown field \`sort\`, expected \`limit\` or \`offset\`"}` |
49| `repos.create_repo` / `update_repo` 字段名 | `create_repo` 发的 8 个键与 `CreateRepoCmd`(`repos.rs:203-219`)逐一对应(含 `parent_dir_id`);`update_repo` 的 `description/website/is_private/enable_lfs/default_branch/tab_order/allow_pull_requests` 与 `UpdateRepoCmd`(`:221-238`)一一对应,无错名(语义问题见确认问题 #4)。`features-settings.tsx:67` 把 `RepoDetail` 展开当 body 虽显冗余,但 `UpdateRepoCmd` 未开 `deny_unknown_fields`,多余键被忽略,不是 400 |
50| `branches.list_branches` / `tags.list_tags` 响应 | 实测 `Page` 形状:`{"items":[{…}],"total":1,"limit":100,"offset":0}`;前端 `{items,total}` 取值正确,`total` 缺失时兜底 `items.length` 不会误判 |
51| `tags.create_tag` / `create_branch` / `rename_branch` body 名 | `createTag` 发 `{name,target,message,annotated}`,对齐 `CreateTagCmd`(`git/src/service/tags.rs:23-30`,`annotated: bool` 而非 `annotate`);`createBranch` 发 `{name,start_point}`,对齐 `CreateBranchCmd`(`service/branches.rs:43-47`);`renameBranch` 发 `{new_name}`,对齐 `RenameBranchCmd`(`handler/branches/rename.rs:14-17`) |
52| `tags.list_tags` 的 `tag_type` 判定 | 前端 `tags-section.tsx:98` 比较 `'Annotated'`,后端 `TagType`(`rpcgit/tags/types.rs:18-22`)**没有** `rename_all`,序列化即 `"Lightweight"` / `"Annotated"`,首字母大写,比较成立(不是 snake_case 陷阱) |
53| `releases` 的 assets / 字段名 | 后端 `ReleaseDto`(`git/src/service/releases.rs:21-36`)**没有 `assets` 字段**,前端也没用(`releases-tab.tsx` 只用 `tag_name/title/num_commits/is_draft/is_prerelease/publisher_name/created_at`),无"把不存在的 assets 当数组遍历";`sha` 与 `target` 并存,前端不取 `sha`,无 `sha` vs `id` 误用 |
54| `releases.list_releases` query | 发 `{limit,offset,draft}`,对齐 `ListReleasesQuery`(`releases.rs:40-50`,`deny_unknown_fields`,含 `draft: Option<bool>`);实测 `?draft=false` → `200`,`?sort=x` → `400 {"error":"Query deserialize error: unknown field \`sort\`, expected one of \`limit\`, \`offset\`, \`draft\`"}`,前端确实不发 `sort` |
55| `analysis.*` | `languages_read`(GET)与 `languages`(POST)同路径不同方法,`ops.ts:46-49` 与 `handler/analysis/languages.rs:12`(POST 重扫)、`languages_read.rs:12`(GET 缓存)对应,`rescanLanguages` 用 POST 正确;两者响应都是 `Vec<LanguageStatDto>`,前端 `unwrapApi<LanguageStat[]>` 正确(实测 `GET …/analysis/languages` 返回数组)。`code_activity` 的 `branch` 对齐 `CodeActivityQuery`(`code_activity.rs:11-14`)。`contributors` 无 query、返回 `Vec`(`contributors.rs:17`) |
56| `repos` topics | `add/remove` 的 `topic_name` 为单段路径,路由 `/{space_name}/{repo_name}/topics/{topic_name}`(`routes.rs:287`),topic 名不合法含 `/`,无编码风险;`list_repo_topics` 后端返回 `Vec`,前端 `unwrapApi<TopicRow[]>` 正确 |
57| OPS 路径段数与路由段数 | 抽查全部所用 op:`get_board/update_board/delete_board`=2、`list_issues`=2、`get/update/close/reopen/delete_issue`=3、`bind/unbind_label`=4、`branches.*`=2 或 3、`releases.*`=2 或 3、`analysis.*`=2,与对应路由声明一一吻合;无一处"段数不足"。65 个调用点全部命中 `OPS` 表,`MISSING_OPS` 与 `ALIASES` 无一处兜底,故无 G 类幽灵端点 |
Added audit/pulls-reviews.md audit/pulls-reviews.md +58 −0 View audit/pulls-reviews.md
@@ -0,0 +1,58 @@
1# pulls · reviews · protection(含 branch_protection / tag_protection)
2
3## 结论
4- 检查调用点 54 个 / 操作 54 个(pulls 35、reviews 8、protection 5、branch_protection 3、tag_protection 3;每操作恰好 1 处调用,集中在 `view/app/src/domains/pulls/model/api.ts` 43 处与 `view/app/src/domains/repo/model/protection.ts` 11 处)
5- 确认问题 1 条(Q 0 / B 0 / P 0 / R 1 / G 0),可疑 4 条,未实证 51 条
6- 实证面:`pulls.list_pulls`、`pulls.list_label_options` 拿到 200;`pulls.list_pull_comments` 拿到 400(query 字段面)。其余 51 条因需登录会话(写操作 / `require_repo_admin`)且 `demo/demorepos` 无任何 PR 数据,未能取得有效响应
7- 关键结论:本域 query 与 body 的**字段名**全部与后端一致(`ListPullQuery` 的 10 个字段、`CreatePullCommentCmd` 的 7 个字段、保护规则 15 个字段、whitelist 3 个字段均逐一对齐);唯一确认的错配是响应形状(R)。
8
9## 确认问题
10
11| # | 类 | 位置 | 操作 | 现象 | 后端依据 | 建议修法 |
12|---|---|---|---|---|---|---|
13| 1 | R | `view/app/src/domains/pulls/model/api.ts:145-146`(调用点 `view/app/src/domains/pulls/ui/pull-detail.tsx:481`) | `pulls.update_branch_pull`(`POST …/pulls/{pull_ref}/update-branch`) | 前端用 `oneOf<Pull>` 解包,按 `PullDto` 使用返回值(`res.title`/`res.state`/`res.number`)。实际 `data` 是 `{pull, result}`,顶层没有这些字段。当前调用点只 `await` 不读返回值,所以尚未暴露;一旦有人读返回值即拿到 `undefined` | `lib/service/pulls/src/handlers/pulls/update_branch_pull.rs:13-16` `pub struct UpdateBranchDto { pull: PullDto, result: MergeResultDto }`;同文件 `:25` `body = AppResponseData<UpdateBranchDto>`;生成类型 `packages/apiclient/pulls/update-branch-pull.schemas.ts` `EnvelopeUpdateBranchDtoData = { pull: PullDto; result: MergeResultDto }` | 改为 `oneOf<NonNullable<EnvelopeUpdateBranchDto['data']>>` 并在调用点取 `res.pull`;或让函数直接 `return unwrapApi(...).then(r => r.pull)` |
14
15## 可疑(需人工确认)
16
17| # | 位置 | 操作 | 疑点 | 已排除的可能 |
18|---|---|---|---|---|
19| 1 | `view/app/src/domains/pulls/model/api.ts:96-110`(尤其 `:105`),触发点 `view/app/src/domains/pulls/model/filters.tsx:10-41` 的 `all` 选项 + `pulls-tab.tsx:74` | `pulls.list_my_pulls`(`GET /pulls/@me/{scope}`) | 收件箱把 `state` 写成 `opts.state !== 'all' && opts.state !== 'draft' ? opts.state : 'open'`,即 `all` 被折成 `open`。后端 `list_inbox` 明确支持 `"all" => base`(不加状态过滤),因此“全部”页签静默丢掉 closed/merged 的 PR;`draft` 有客户端补偿过滤(`pulls-tab.tsx:78` `pull.is_draft`),`all` 没有 | 不是 400 名称不符——`all` 是后端合法值(`lib/service/pulls/src/services/inbox.rs:68-77` 的 match 分支);也不是注释里说明的 `draft` 映射(注释只解释了 draft) |
20| 2 | `view/app/src/domains/repo/model/protection.ts:1-6`(注释)、`:101-121` | `tag_protection.create_tag_policy` / `get_tag_policy` / `update_tag_policy` / `delete_tag_policy` / `list_tag_policies` | 领域层注释断言“tag *policies* themselves have no HTTP surface yet — whitelist rows can only be managed against a known policy UUID”,据此 `TagProtectionPanel` 要求用户手抄 policy UUID(`protection-settings.tsx:419-424` 的 `tagPolicyIdPlaceholder` 输入框)。实际后端五个 tag 策略 CRUD 端点齐全,且 OPS 表也已注册 | 不是幽灵端点:路由在 `lib/service/git/src/routes.rs:310-319`(`/tag-protection`、`/tag-protection/{policy_id}`),handler 在 `lib/service/git/src/handler/tag_protection/*`,OPS 条目在 `view/app/src/services/api/ops.ts:588-604`。属“后端有能力、前端未接”的覆盖缺口,注释已过时 |
21| 3 | `view/app/src/domains/repo/model/protection.ts:57-67` | `protection.update_policy`(`PATCH …/branch-protection/{policy_id}`) | `updatePolicy` 无条件把 `protected_file_patterns` / `unprotected_file_patterns` 补成 `null`(`cmd.x ?? null`),被当成“清空”语义发送。后端 `UpdateBranchPolicyCmd` 这两个字段是 `Option<String>` 而非 `Option<Option<String>>`,`null` 与“缺省”在反序列化后同为 `None`,`apply_flags` 走 `if let Some(v)` 直接跳过 → 发 `null` 等于不变。文档宣称的三态 PATCH(README/envelope 注释“field null → cleared”)在本端点不成立 | 不是 B 类字段名错配——字段名前后端与生成类型完全一致;也不是当前就会踩的 bug:UI 清空走的是空串 `''`(`'' ?? null` = `''`),由 `apply_flags` 真正写空 |
22| 4 | `view/app/src/domains/repo/settings/ui/sections/protection-settings.tsx:48`(label 取 `repo.protection.field.enableForcePush`)、i18n `zh-CN/repo.ts:437` = “禁止强推(force push)” | `protection.create_policy` / `update_policy` 的 `enable_force_push` | 字段名是 `enable_force_push`(“启用强推”),UI 文案与其 chip 逻辑(`policy.enable_force_push ? '禁止强推' : null`,`protection-settings.tsx:226`)却按“该位为真=已禁止强推”呈现。若 KenShell 侧语义是“允许强推”,则勾选“禁止强推”会反向打开强推 | 不是字段名/类型错配——`proto/kenshell/v1/ref.proto:91` 确有 `bool enable_force_push = 14`,前后端与生成类型一致。语义无法在本仓库取证:执行侧在 KenShell,proto 该字段无注释,`lib/**` 内除 handler 外无消费点 |
23
24## 已核对无问题
25
26| 操作 | 依据 |
27|---|---|
28| `pulls.list_pulls` | 前端 `api.ts:79-91` 发 `state/draft/author/base/head/search/sort/order/limit/offset`;后端 `ListPullQuery`(`lib/service/pulls/src/services/pulls.rs:99-120`,`:100` `#[serde(deny_unknown_fields)]`)字段逐一对应,无多无少。实证 200:`curl 'http://localhost:7051/api/v1/repos/demo/demorepos/pulls?state=all&draft=false&sort=created&order=desc&limit=20&offset=0'` → `{"code":200,...,"limit":20,"offset":0}`。另实证前端 `state:'draft'→'open'` 的映射是必需的:`?state=draft` → 400 `{"code":400,"message":"unknown state \`draft\`",...,"error_code":"bad_request"}` |
29| `pulls.list_pulls` 未知字段 | 实证 400 原文点名允许集,证明“未知 query 是 400 不是忽略”:````{"code":400,"message":"invalid query parameters","data":null,"error":"Query deserialize error: unknown field `bogus`, expected one of `state`, `author`, `base`, `head`, `draft`, `search`, `sort`, `order`, `limit`, `offset`","error_code":null}```` |
30| `pulls.list_pulls` 的 `label`/`milestone` | 该端点**不接受**标签与里程碑过滤,实证:`?label=bug` → 400 `Query deserialize error: unknown field 'label'`。前端当前未发这两项(若后续加筛选必须改后端)。`sort` 只接受 `number\|created\|updated`(`?sort=bogus` → 400 `unknown variant 'bogus', expected one of 'number','created','updated'`),与生成类型 `PullSort` 一致 |
31| `pulls.list_pulls` 分页边界 | `limit=0` → 400 `limit must be between 1 and 100`;`offset=5001` → 400 `offset must not exceed 5000`。前端 `pulls-tab.tsx:14` `PAGE_SIZE = 20`、`limit: 50`(收件箱)均在 1..=100 内 |
32| `pulls.list_my_pulls` 路径与 query | 路径 `/pulls/@me/{scope}`(`lib/service/pulls/src/routes.rs:118`)与生成 URL 一致;`scope` 取值 `created\|review-requested\|reviewed` 与 `InboxPath` 文档 + `:41-49` 的 match 分支完全一致;query 仅 `state/limit/offset`,与 `InboxQuery`(`services/inbox.rs:22-32`)一致 |
33| `pulls.list_pull_comments` / `list_pull_events` 的 query | 后端均用 `PageQuery`(`lib/infra/api/src/pagination.rs:10-15`,`#[serde(deny_unknown_fields)]`,仅 `limit`/`offset`),与生成 `ListPullCommentsParams`/`ListPullEventsParams` 一致。实证:`…/pulls/1/comments?bogus=1` → 400 `Query deserialize error: unknown field \`bogus\`, expected \`limit\` or \`offset\``;不带未知字段则越过 query 校验进入业务(`…?limit=1` → 404 `pull request not found`,因 demo 仓库无 PR) |
34| `pulls.create_pull` | `api.ts:118-125` 发 `title/head_branch/base_branch/body/draft/issue_id`;`CreatePullCmd`(`services/pulls.rs:122-132`)字段名与类型一致,`issue_id:null`→`None` 合法 |
35| `pulls.update_pull`(PATCH 三态) | `api.ts:130` 直传 `PullPatchCmd`;`UpdatePullCmd`(`services/pulls.rs:134-149`)对 `body`/`auto_merge`/`issue_id` 用 `deserialize_opt_opt`,缺省=不变、`null`=清空、值=设置。前端 `pull-detail.tsx:638` 的 `body` 恒为字符串(清空发 `""`),`title: undefined` 会被 JSON 丢弃 → 均落在预期语义 |
36| `pulls.close_pull` / `reopen_pull` | 后端 `POST…/close`、`POST…/reopen` 返回 `PullDto`(`close_pull.rs:18`、`reopen_pull.rs:18`),与前端 `oneOf<Pull>` 一致 |
37| `pulls.merge_pull` | 路径 `{ITEM}/merge` + `POST`;body `MergePullCmd`(`lib/service/pulls/src/services/merge.rs:14-30`)6 个字段与前端 `MergeCmd` 一致;`method` 枚举 `MergeMethod = "merge"\|"squash"\|"rebase"`(snake_case),与 `models::pull_request::pull_requests::MergeMethod` 的序列化一致;响应 `MergePullDto{pull,result,summary}` 与前端 `MergeOutcome` 一致 |
38| `pulls.lock_pull` / `unlock_pull` | `PUT …/lock` 与 `DELETE …/lock`(`routes.rs:60`),两个生成客户端 URL 相同、方法不同,OPS 表分别映射正确;响应被 `ignore()` 丢弃,不受形状影响 |
39| `pulls.create_pull_comment`(行内评论) | `api.ts:194-205` 发 `body/parent_comment_id/path/line/side/commit_sha/review_id`;`CreatePullCommentCmd`(`lib/service/pulls/src/services/comments.rs:25-41`)7 个字段名与类型逐一对应,`line: i32` vs 前端 `number`、`side: Option<String>` 自由值 `left\|right` 一致。`files-tab.tsx:88` 的 `commit_sha: headSha` 取自 `pull-detail.tsx:234` `headSha={pull.head.sha}`(是 sha 不是 branch/label),未误用 |
40| `pulls.update_pull_comment` / `delete` / `resolve` / `unresolve` | 路径 `{ITEM}/comments/{comment_id}`(PATCH/DELETE)与 `…/resolve`(PUT/DELETE);`CommentPath`(`handlers/comments/update_pull_comment.rs:11-17`)段序 `space_name/repo_name/pull_ref/comment_id` 与前端 `{...pathOf(ref), comment_id}` 一致;`UpdatePullCommentCmd{body:String}` 与前端 `{body}` 一致 |
41| `pulls.list_pull_comment_tree` | 返回 `Vec<PullCommentThreadDto>`(`list_pull_comment_tree.rs:18`),前端按数组解包(`listOf`),`PullCommentThread = {root, replies}`,`conversation.tsx:64/218/282` 正好读 `root`/`replies` |
42| `pulls.list_pull_commits` / `get_pull_files` | 均为数组(`Vec<MergeCommitDto>` / `Vec<ChangedPathDiffDto>`),前端 `listOf`;`commits-tab.tsx:34-58` 读 `sha/subject/author_name/author_email/author_at/parents` 与 `MergeCommitDto` 一致;`model/meta.ts:137-145` 正确把 `file.path.new_path`/`old_path` 与 `lines_added/removed` 映射出来(未把 `path` 对象当字符串用) |
43| `pulls.get_pull_checks` / `get_pull_gate` | `PullChecksDto{head_sha,statuses}`、`PullGateDto{head_sha,mergeable,conflicts,gate,pending_review_requests}`;`pull-detail.tsx:458/469` 读 `g.required_approvals`/`g.approvals`,二者确实在 `MergeGateDto`(`lib/service/pulls/src/services/approvals.rs:17-26`)内 |
44| `pulls.refresh_pull_summary` | 后端 `POST {ITEM}/summary` 返回 `PullSummaryDto`(`refresh_pull_summary.rs:19`),前端 `oneOf<PullSummary>` 对应 `EnvelopePullSummaryDto['data']` |
45| `pulls.list_pull_events` 响应 | 后端 `Page<PullEventDto>`(`services/events.rs:37-47`),前端手写的 `PullEvent`(`pull.ts:72-80`)字段与 `PullEventDto` 一一对应(`id/pull_request_id/event_type/actor/commit_sha/content/created_at`) |
46| `pulls.list_pull_assignees` / `get_pull` 等单对象端点 | 返回 `PullDto`/`Vec<PullAssigneeDto>`,前端 `oneOf`/`listOf` 分支正确;`pageOf` 只用于真分页端点(`list_pulls`/`list_my_pulls`/`comments`/`events`) |
47| `pulls.add_pull_assignee` / `remove_pull_assignee` | 路径 `{ITEM}/assignees/{user_ref}`(`add_pull_assignee.rs:11-17`,`user_ref: String` 接受用户名或 UUID),POST/DELETE 双方法;前端 `pathOf+user_ref` 段序一致 |
48| `pulls.attach_pull_label` / `detach_pull_label` / `list_pull_labels` / `list_label_options` | `AttachLabelCmd{label:String}`(`packages/apiclient/pulls/attach-pull-label.schemas.ts`)与前端 `{label}` 一致;`label_ref:String`(`detach_pull_label.rs:10-16`);`list_label_options` 路由注册在 `{ITEM}` 之前(`routes.rs:55-57`),实证 `GET /api/v1/repos/demo/demorepos/pulls/label-options` → 200(未被 `{pull_ref}` 吞掉) |
49| `pulls.link_issue_pull` / `unlink_issue_pull` / `list_issue_pulls` | 路径段顺序 `issues/{space_name}/boards/{board_id}/issues/{issue_id}/pulls[/{relation_id}]` 与生成 URL 完全一致;`LinkIssueCmd{space_name,repo_name,pull_ref}` 与前端 `data` 一致;`board_id/relation_id` 为 `Uuid`,前端传字符串 UUID |
50| `pulls.get_pull_patch` | 前端不走 `Apis`,`api.ts:163-164` 手工拼 `/api/v1/repos/{s}/{r}/pulls/{pull}/patch`,与 `routes.rs:58` 的 `{ITEM}/patch` 一致(浏览器直连下载,不涉及信封) |
51| `reviews.submit_review` / `publish_review` | body 均为 `SubmitReviewCmd`(`services/reviews.rs:49-56`):`state: ReviewIntent`(`approve\|request_changes\|comment\|pending`,`#[serde(rename_all="snake_case")]` 见 `:21-28`)、`body`、`comments: Vec<InlineDraft>`;`InlineDraft{path,line: i32,side,body}` 与前端 `{path,line,side,body}` 一致。PATCH 语义不适用(POST 整体提交) |
52| `reviews.get_review` / `list_reviews` | `GET {ITEM}/reviews/{review_id}` 单对象、`GET {ITEM}/reviews` 数组(`list_reviews.rs:18`),前端分别 `oneOf`/`listOf`;`PullReviewDto` 的正文叫 `content`(`:65`),前端 `conversation.tsx:332` `body: review.content ?? null` 用的是正确字段名;`ReviewState` 取值 `approved\|changes_requested\|commented\|pending\|dismissed` 与 `conversation.tsx:370/397` 的判断一致 |
53| `reviews.dismiss_review` | `PUT {ITEM}/reviews/{review_id}/dismiss`,`ReviewPath{space_name,repo_name,pull_ref,review_id:Uuid}` 段序与前端 `pathOf+review_id` 一致,无 body |
54| `reviews.request_reviewers` | `RequestReviewersCmd`(`services/reviewers.rs:52-58`):`reviewers: Vec<String>`、`required: bool` 且 `#[serde(default = "default_required")]` 默认 **true**。前端 `api.ts:249` 发 `required: required ?? undefined`,但唯一调用点 `pull-detail.tsx:718/735` 的 `useState(true)` 恒传布尔 → 与后端默认一致,未踩“缺省即计入必需审批”的坑 |
55| `reviews.list_requested_reviewers` / `delete_requested_reviewer` | 均返回/作用于 `Vec<PullReviewerDto>`;路径 `…/requested-reviewers/{user_id}`,`ReviewerPath.user_id: i64`,前端传 `number`(`:253-254`)一致 |
56| `protection.*` 5 个操作(分支策略 CRUD) | 生成 URL `/api/v1/repos/{s}/{r}/branch-protection[/{policy_id}]`(`packages/apiclient/protection/*.ts`)与 `lib/service/git/src/routes.rs:291-299` 完全一致(注意:openapi tag 名是 `protection`,路径前缀是 `branch-protection`,两者不冲突);body 15 个字段名与 `CreateBranchPolicyCmd`(`services/protection.rs:101-131`)、`UpdateBranchPolicyCmd`(`:157-188`)逐一对应,`required_approvals` 前后端同名(不存在 `required_reviewers`),生成类型 `CreateBranchPolicyCmd`/`UpdateBranchPolicyCmd`(`packages/apiclient/protection/*.schemas.ts`)亦一致;`list_policies` 返回数组(`EnvelopeVecBranchPolicyDto`)、`get/create/update` 返回单对象,前端 `listPolicies` 用数组分支、其余 `oneOf`,正确(未实证:`require_repo_admin`,匿名 401) |
57| `branch_protection.*` 3 个操作 | 路径 `/branch-protection/{policy_id}/whitelists[/{whitelist_id}]` 与 `routes.rs:301-307` 一致;`AddBranchWhitelistCmd{kind, user_id: Option<i64>, group_id: Option<Uuid>}`(`services/whitelists.rs:49-53`)与前端 `data` 一致,缺省字段反序列化为 `None`(serde 对 `Option` 缺省即 `None`),前端显式补 `null` 同样安全;`WhitelistKind` 取值 `Push\|Merge\|Approval` **PascalCase**(`services/whitelists.rs:12-16`,无 `rename_all`),前端 `KIND_LABEL` 与 `useState<WhitelistKind>('Push')`(`protection-settings.tsx:28-36/292`)完全吻合——虽与“枚举一律 snake_case”的约定不符,但前后端内部自洽,不构成错配 |
58| `tag_protection.*` 3 个操作(whitelist) | 路径 `/tag-protection/{policy_id}/whitelists[/{user_id}]` 与 `routes.rs:321-326` 一致;`AddTagWhitelistCmd{user_id: i64}`(`services/whitelists.rs:63-66`)与前端 `{user_id: number}` 一致,`user_id` 来自 `Number(...)` 而非字符串(`protection-settings.tsx:430`),不会触发 i64 反序列化失败 |
Added audit/README.md audit/README.md +61 −0 View audit/README.md
@@ -0,0 +1,61 @@
1# API 契约交叉审计(view/app ⇄ KenApi)
2
3本轮目标:找出与 `8dfc4c1b` 同类的接口契约错配——即「前端发的东西后端不认,或后端返回的东西前端按错的形状用」。
4每个域一份报告:`audit/<domain>.md`,只读源码,不改业务代码。
5
6## 判定基线
7
8前端的唯一入口是 `view/app/src/services/api`:
9
10- `Apis.<tag>.<op>({ pathParams, params, data })` 走 `invoke()`,`OPS` 表决定 `path`/`query`/`body` 的位置。
11 该表由 `scripts/gen-api-ops.py` 从 `packages/apiclient` 的签名生成,**已与生成客户端一致**——所以本轮的
12 错配只可能出在「前端调用方填的字段名/形状」与「后端真契约」之间。
13- 响应信封:`{ code, message, data, error, error_code, request_id }`,`code == HTTP status`;
14 `data: null` 是成功无内容。列表统一 `{ items, total, limit, offset }`(chat 另有 `has_more` 等)。
15- 401 → `/auth/login?callback_url=…`,无令牌刷新。
16
17## 五类问题(每条发现必须归到其中一类)
18
19| 类 | 名称 | 含义 |
20|---|---|---|
21| Q | query 名不符 | `params` 里的字段名不在后端 query 结构体 / 生成 `*Params` 类型里,会被 400 拒绝 |
22| B | body 名不符 | `data` 里的字段名或嵌套形状与生成的 `*Body/*Cmd` 类型不一致(PATCH 三态:缺省=不变、null=清空、值=设置) |
23| P | path 顺序/数量 | `pathParams` 的键序与路由段顺序不一致,或段数不足 |
24| R | 响应形状误用 | 把分页对象当数组、把数组当分页、字段名过时(如 `sha` vs `id`)、`data: null` 未处理 |
25| G | 幽灵端点 | 调用了已被删除/改名的端点,或 `ALIASES`/`MISSING_OPS` 命不中,实际返回 501/404 |
26
27## 每条的取证要求
28
291. 前端:`view/app/src/...:line` 原始调用片段。
302. 后端:`lib/.../*.rs` 的 query/body 结构体或 `utoipa::path` 声明(路径 + 行号)。
313. 生成类型:`packages/apiclient/<tag>/<op>.schemas.ts` 的字段清单。
324. 能实证就给 curl:`http://localhost:7051/api/v1/...`(公开仓库 `demo/demorepos` 可匿名读;
33 需要登录的端点记「未实证」并说明原因)。400 的响应体会直接点名字段,最有力。
34
35## 报告格式(严格)
36
37```md
38# <domain>
39
40## 结论
41- 检查调用点 N 个 / 操作 M 个
42- 确认问题 X 条(Q a / B b / P c / R d / G e),可疑 Y 条,未实证 Z 条
43
44## 确认问题
45| # | 类 | 位置 | 操作 | 现象 | 后端依据 | 建议修法 |
46|---|---|---|---|---|---|---|
47
48## 可疑(需人工确认)
49| # | 位置 | 操作 | 疑点 | 已排除的可能 |
50|---|---|---|---|---|
51
52## 已核对无问题
53| 操作 | 依据 |
54|---|---|
55```
56
57## 禁止
58
59- 不改 `view/app/src`、`packages/apiclient`、`lib/**`(本轮只出结论)。
60- 不把「风格问题」「性能问题」「a11y」写进报告——只写契约错配。
61- 不臆测:没找到后端结构体就标「未实证」。
Added audit/space-account.md audit/space-account.md +69 −0 View audit/space-account.md
@@ -0,0 +1,69 @@
1# space-account
2
3## 结论
4
5- 检查调用点 87 个(去重后 77 个 `tag.op`)/ 操作 77 个
6- 确认问题 16 条(Q 0 / B 1 / P 1 / R 4 / G 10),可疑 6 条,未实证 2 条(#6、#10 的 HTTP 层被方法级 401 掩蔽)
7
8主线结论:space 服务有 10 条 `utoipa::path` 已声明、`openapi.rs` 已收录(因此生成客户端与 `OPS` 表都有),但 `routes.rs` 没有挂载——其中 4 条已用 curl 拿到 `unknown api route`,最严重的是「创建邀请」整条链路不可用。
9
10## 确认问题
11
12| # | 类 | 位置 | 操作 | 现象 | 后端依据 | 建议修法 |
13|---|---|---|---|---|---|---|
14| 1 | G | `view/app/src/domains/space/management/model/api.ts:82-85`(调用方 `ui/space-header.tsx:269`) | `space.space_invite_create` | `POST /api/v1/spaces/demo/invites` 实测 → 404:`{"code":404,"message":"unknown api route","error_code":"not_found"}`。「邀请成员」对话框提交即失败,邀请令牌永远拿不到 | `lib/service/space/src/handlers/space_admin.rs:1413-1445` 声明 `path="/spaces/{space_name}/invites"`、`operation_id="space_invite_create"`;`lib/service/space/src/routes.rs` 全文无此路径(只有 133 行 `/spaces/invites/redeem`);`lib/service/space/src/openapi.rs:38` 收录 → 客户端有、服务端无 | 在 `routes.rs::protected()` 补 `.route("/spaces/{space_name}/invites", post(space_admin::create_invite))` |
15| 2 | G | `domains/space/management/model/space-admin.ts:175-176` | `space.space_invite_create` | 同一操作的第二个包装(`createSpaceInvite`),同样 404;当前无 UI 消费方,属潜伏 | 同 #1 | 同 #1(或删掉重复包装,只留 `api.ts:82`) |
16| 3 | G | `domains/space/management/model/space-admin.ts:162-168` | `space.space_invite_list` | `GET /api/v1/spaces/demo/invites` 实测 → 404 `unknown api route`,邀请列表恒空 | `space_admin.rs:1455-1497`(`path` 1459 行、`operation_id` 1461 行);`routes.rs` 无该路径 | `routes.rs` 补 `get(space_admin::list_invites)` |
17| 4 | G | `domains/space/management/model/space-admin.ts:170-173` | `space.space_invite_revoke` | `DELETE /api/v1/spaces/demo/invites/{uuid}` 实测 → 404 `unknown api route`,撤销邀请无效 | `space_admin.rs:1499-1540`(`path` 1503 行、`operation_id` 1505 行);`routes.rs` 无该路径 | `routes.rs` 补 `delete(space_admin::revoke_invite)` |
18| 5 | G | `domains/space/management/model/space-admin.ts:231-237`(调用方 `ui/space-settings/bans-section.tsx:48`) | `space.space_ban_list` | `GET /api/v1/spaces/demo/bans` 实测 → 404 `unknown api route`,封禁列表永远为空,封禁管理面板整段失效 | `space_admin.rs:814-829`(`path` 816 行、`operation_id` 818 行);`routes.rs` 只挂了 `/bans/{user_id}` 的 DELETE | `routes.rs` 补 `get(space_admin::list_bans)` |
19| 6 | G | `domains/space/management/model/space-admin.ts:261-262`(调用方 `bans-section.tsx:192`) | `space.space_ban_update` | `PATCH /spaces/{space}/bans/{user_id}` 未挂载;HTTP 层被同路径 DELETE 命中的中间件 401 掩蔽(实测 401 而非 404),故记「未实证」,代码证据充分 | `space_admin.rs:889-928`(`path` 891 行、`operation_id` 893 行)声明 PATCH;`routes.rs:187-191` 对同一路径只注册了 `delete(unban_by_user)` | `routes.rs` 补 `patch(space_admin::update_ban)` 到该路径 |
20| 7 | G | `domains/space/management/model/space-admin.ts:99-100`(调用方 `ui/space-settings/my-membership-section.tsx:27`) | `space.space_member_get` | `GET /api/v1/spaces/demo/members/00000000-...` 实测 → 404 `unknown api route`;调用方 `getSpaceMember(...).catch(() => null)` 把 404 吞成 null,「我的会籍」面板静默空 | `space_admin.rs:465-502`(`path` 467 行、`operation_id` 469 行);`routes.rs:164-171` 只挂了 `.../members`(GET)与 `.../members/@me`(PATCH),无 `.../members/{member_id}` | `routes.rs` 补 `get(space_admin::get_member)` |
21| 8 | G | `domains/space/management/model/space-admin.ts:105-108` | `space.space_member_update` | `PATCH /spaces/{space}/members/{member_id}` 未挂载(与 #7 同路径,该路径 GET 已实测 404) | `space_admin.rs:541-581`(`path` 543 行、`operation_id` 545 行);`routes.rs` 无该路由 | `routes.rs` 补 `patch(space_admin::update_member)` |
22| 9 | G | `domains/space/management/model/space-admin.ts:114-115` | `space.space_member_remove` | `DELETE /spaces/{space}/members/{member_id}` 未挂载(同 #7 路径) | `space_admin.rs:621-661`(`path` 623 行、`operation_id` 625 行);`routes.rs` 无该路由 | `routes.rs` 补 `delete(space_admin::remove_member)` |
23| 10 | G | `domains/space/management/model/space-admin.ts:102-103` | `space.space_member_add` | `POST /spaces/{space}/members` 未挂载;实测返回 401(同路径 GET 命中中间件),故「未实证」,代码证据充分 | `space_admin.rs:583-619`(`path` 585 行、`operation_id` 587 行);`routes.rs:164-167` 对该路径只注册了 GET | `routes.rs` 把该路径改成 `get(list_members).post(add_member)` |
24| 11 | P | `domains/space/management/model/space-admin.ts:99-100 / 105-108 / 114-115`;实参来源 `my-membership-section.tsx:27` 的 `viewerId` | `space.space_member_get/update/remove` | `pathParams` 键序对(space_name → 第 2 段),但第 2 段值语义错:后端该段是 **Uuid 会籍 id**,前端填的是 **数字 user_id**(`getSpaceMember(spaceName, userId: number)`)。补上路由后仍会因 `Uuid` 解析失败 400 | `space_admin.rs:16-20` `SpaceMemberPath { space_name: String, member_id: Uuid }`;对照 `space_admin.rs:28-32` `SpaceBanPath { user_id: i64 }`(封禁族才是数字 id) | 三个包装都先 `findSpaceMember(spaceName, userId)` 取 `member.id`,再填第 2 段(`banSpaceUser` 已经是这个写法,照抄即可) |
25| 12 | R | `domains/chat/model/api.ts:311-336`(`SpaceMemberRow.user`) | `space.space_member_list` | 把成员行当嵌套 `{ user: { user_id } }` 读:`row.user?.user_id ?? ''` 恒为 `''`,随后 `.filter(row => Boolean(row.user_id))` 全过滤 → 加人选择器永远没有候选(`listSpaceMembers` 恒 `[]`) | 后端 `MemberSummary` 是**扁平**行:`lib/service/space/src/services/types.rs:131-149` 有 `user_id: i64`、`username`、`alias_name`,没有 `user`;生成类型 `packages/apiclient/space/space-member-list.schemas.ts` 的 `EnvelopePageMemberSummaryDataItemsItem` 同样无 `user` 键 | 直接 `map((row) => ({ ...row, user_id: row.user_id }))`,`user_id` 取扁平字段 |
26| 13 | R | `domains/issues/model/api.ts:306-307` + 类型 `domains/issues/model/issue.ts:84-88` | `space.space_member_list` | `SpaceMember { user?: UserDto }` 同源误用,问题集指派人候选恒空(`use-board-data.ts:52` 消费) | 同 #12 | 同 #12:行类型改成扁平 `user_id/username/alias_name` |
27| 14 | R | `domains/repo/model/protection.ts:148-156` | `space.space_member_list` | `row.user` 恒 `undefined`,`.map(row => row.user).filter(...)` 全过滤 → 保护规则白名单用户候选恒空 | 同 #12 | 同 #12 |
28| 15 | R | `domains/settings/model/api.ts:105-107` | `user.my_suspensions` | 后端返回**裸数组**,前端按 `{ items }` 读:`page?.items ?? []` 恒 `[]`,处罚历史永远空 | `lib/service/user/src/handlers/my_suspensions.rs:14-31` `body = AppResponseData<Vec<SuspensionDto>>`;生成 `packages/apiclient/user/my-suspensions.schemas.ts` 的 `EnvelopeVecSuspensionDto.data` 是数组 | 改成 `unwrapApi<SuspensionRow[]>(...)` 后直接返回该数组 |
29| 16 | B | `domains/space/management/model/space-admin.ts:239-250`(调用方 `bans-section.tsx:83-88` 传 `expires_at`) | `space.space_member_ban` | 前端表单收集「封禁到期时间」并在 cmd 里声明 `expires_at`,实际 body 只发 `{ reason }`;后端 `BanMemberBody` 也没有该字段 → 到期时间被静默丢弃,封禁恒为永久 | `space_admin.rs:281-284` `BanMemberBody { reason: Option<String> }`;生成 `space-member-ban.schemas.ts` 的 `BanMemberBody` 只有 `reason` | 优先给 `BanMemberBody` 补 `expires_at`(service 侧已支持,`UpdateBanBody` 有 `expires_at` 三态);或在 `banSpaceUser` 里封禁成功后串一次 `update_ban` 带上 `expires_at` |
30
31## 可疑(需人工确认)
32
33| # | 位置 | 操作 | 疑点 | 已排除的可能 |
34|---|---|---|---|---|
35| 1 | `ui/space-header.tsx:263-274` | `space.space_invite_create` | 邀请表单同时收集 `email` 与 `groups`(角色),但提交的 body 只有 `target_user_id / max_uses / ttl_secs`;`CreateInviteBody` 根本没有 `email`、`extra_role_ids` 字段,handler 又固定传 `email=None`、`extra_role_ids=&[]` → 「按邮箱邀请」退化成无目标裸链接,且不能带角色 | 不是字段名拼错:`space_admin.rs:111-118` 的 `CreateInviteBody` 里压根没有这两个键;service 层 `services/invite.rs:53-64` 的 `create_invite_with_extras` 是支持 `email` / `extra_role_ids` 的,只是没暴露到 HTTP |
36| 2 | `domains/space/management/model/space-admin.ts:170-173` | `space.space_invite_revoke` | 返回类型写成 `SpaceInvite \| null`,后端是 `AppResponseData<bool>`(`space_admin.rs:1513-1522`) | 当前无 UI 消费方调 `revokeSpaceInvite`,不会立刻炸;属类型谎报 |
37| 3 | `domains/space/management/model/space-admin.ts:42` + `:162-168` | `space.space_invite_list` | `SpaceInvite = NonNullable<EnvelopeInviteSummary['data']>`,而 `EnvelopeInviteSummary` 在 `services/api/index.ts:208` 被定义为 `EnvelopeCreateInviteResponse`,其 `data` 是 `{ invite, token }`;但 `list_invites` 的 items 是扁平 `InviteSummary` | 无消费方,且该端点本身 404(#3),当前不可观察 |
38| 4 | `domains/settings/model/api.ts:28-29` | `account.update_profile` | 声明返回 `UserPublic`,后端 `PATCH /me` 返回 `AppResponseData<NoData>`(`handlers/update_profile.rs:18-33`),实际 `data: null` | 调用方 `profile-section.tsx:128`、`account-section.tsx:119` 都是 `await` 后丢弃返回值,再 `onMeChanged()` 重拉,当前无运行时后果 |
39| 5 | `ui/space-settings/bans-section.tsx:28-32` | `space.space_ban_update` | 只接了 `allow_speak / allow_push_repo / allow_comment_any` 三个放行位,`UpdateBanBody` 还有 `allow_agent_join` 未暴露 | 字段名一致,属覆盖缺口而非错配(`allow_*` 才是请求侧正确命名,`ban_allow_*` 是响应侧命名,此处没混用) |
40| 6 | `domains/settings/model/api.ts:106` | `user.my_suspensions` | 不传 `include_revoked`,默认 `false` 只返回仍生效的处罚;而 UI 文案是「处罚历史」 | 键名正确(`IncludeRevokedQuery`),只是筛选语义可能不符预期 |
41
42## 已核对无问题
43
44| 操作 | 依据 |
45|---|---|
46| `space.explore_spaces` | `join_requests.rs:75-83` `ExploreQuery { q, sort, order, limit, offset }`(`deny_unknown_fields`);前端 `discovery/model/api.ts:76-81` 只发这 4 个且显式省略 `undefined`。实测 `?limit=100` → 200,`?limit=200` → 400、`?offset=5001` → 400、`?per_page=10` → 400 |
47| `space.space_create` / `space_update` | `types.rs:217-263` 两个 body 覆盖 `allow_join / allow_join_level / allow_invite / join_policy`;UI 只发 `allow_join / allow_join_level / allow_invite`(`general-section.tsx:51-53`、`create-space-modal.tsx:57`),均为合法键,`join_policy` 缺省即不改 |
48| `space.space_get` / `space_delete` / `space_leave` | 路由 `routes.rs` 的 `/spaces/{space_name}` GET/DELETE 与 `/leave` 均已挂载;`DeleteOrgQuery` 的 `force/reason` 前端不发(`#[serde(default)]`);`space_leave` 返回 `bool`,前端 `unwrapApi<boolean>` 一致 |
49| `space.get_space_dir_tree` | 后端 `DirTreeQuery { parent_id: Option<Uuid> }`(`get_space_dir_tree.rs:12-14`),前端传 `params: {}`,无未知键、无分页截断 |
50| `space.create_dir_node` / `move_dir_node` / `update_dir_node` | `move_dir_node` 前端发 `{ parent_dir_id, position }`(`api.ts:56-61`),与后端 `MoveDirNodeBody` 对齐;路径 3 段顺序正确 |
51| `space.list_groups` / `create_group` / `update_group` / `delete_group` | `list_groups` 用 `PageQuery`(limit 1..=100),前端 `limit: ADMIN_PAGE_LIMIT = 100` 合法;`CreateGroupCmd`/`UpdateGroupCmd` 字段与 UI 一致 |
52| `space.assign_group` / `unassign_group` / `join_group_self` / `leave_group_self` | 路径第 2 段确为 `user_id: i64`(`unassign_group.rs:10-15` 的 `MemberGroupPath`),前端传数字 user_id 正确;`AssignGroupCmd { group_id: Uuid }` 一致 |
53| `space.list_join_rules` / `create_join_rule` / `update_join_rule` / `delete_join_rule` | 列表返回裸数组 `Vec<JoinRuleDto>`(`list_join_rules.rs:17-31`),前端 `rows<SpaceJoinRule>` 按数组读,一致;`CreateJoinRuleCmd { role_id, trigger, matcher }`、`UpdateJoinRuleCmd { enabled, matcher }` 与生成类型一致 |
54| `space.create_join_request` / `withdraw_join_request` / `list_join_requests` / `approve_join_request` / `reject_join_request` | 路径段顺序正确;`ListJoinRequestsQuery { status, limit, offset }` 与前端可选参数一致(显式省略 undefined,避免字符串 `"null"`) |
55| `space.space_invite_redeem` | 路由 `routes.rs:131-136` 已挂载;body `{ token }` 与 `RedeemInviteBody`(`space_admin.rs:120-123`)逐字一致;响应 `{ organization_id, member_id, joined }`,前端按 `unknown` 读,不误用 |
56| `space.get_space_image` / `delete_space_image` | 路径 2 段(space_name, kind)顺序正确;`kind` 枚举与 `SpaceImageKind` 一致 |
57| `space.list_dir_group_overwrites` / `list_dir_user_overwrites` / `set_*` / `remove_*` | `SetOverwriteCmd { allow_mask: i64, deny_mask: i64 }`(`services/dir.rs:147-150`)与前端 `{ allow_mask, deny_mask }` 一致;路径段数/顺序正确 |
58| `account.me` | 后端 `MeResp { user: UserMeDto, profile: Option<ProfileDto> }`(`handlers/me.rs:12-15`);前端 `me.user.user_id / display_name / username / avatar_url`、`me.profile` 均命中 `UserMeDto`(`services/profile.rs:13-28`)与 `ProfileDto`(`:31-44`) |
59| `account.get_preferences` / `account.update_preferences` | `UserPreferencesDto` 含 `diff_view_style`(`services/preferences.rs:11-24`),`repo/model/commits.ts:64` 读的就是该名;`UpdatePreferencesCmd` 的 `mute_until` 是三态(`deserialize_opt_opt_datetime`),前端 PATCH 只发变更键,符合语义 |
60| `account.list_devices` | `handlers/devices.rs:53-70` 用 `PageQuery`(limit 1..=100),前端 `limit: 100` 合法;响应 `Page<DeviceDto>`,前端按 `{ items }` 读一致 |
61| `account.update_profile`(body) / `account.update_username` | `UpdateProfileCmd`(`services/profile.rs:47-65`)覆盖 UI 发来的 `display_name/locale/language/timezone/real_name/bio/pronouns/website/location/company/date_of_birth/show_*/visibility`;`update_username` 的 body `{ username }` 与 `UpdateUsernameCmd` 一致,返回 `UserPublic` 与前端声明一致 |
62| `account.mark_notifications_read` | `MarkNotificationsReadCmd { ids?: Uuid[], all: bool }`(`handlers/mark_notifications_read.rs:11-16`);UI 发 `{ ids: [id] }` 与 `{ all: true }`,均合法 |
63| `auth.auth_ssh_key_list` / `add` / `delete` | 路径是 `/auth/ssh-keys`(不是 `/me/ssh-keys`),前端用的是 `auth.*` 这三个 op,路径正确;`PageQuery` 上限 100,前端 `limit: 100` 合法;`SshKeyAddRequest { name, description, public_key, expires_at }` 与前端 `{ name, public_key }` 一致(请求侧没有 `algorithm`/`key_type` 之争,`algorithm` 只在响应 `SshKeySummary` 里);`sshkeys-section.tsx:95` 读的正是 `key.algorithm` |
64| `auth.change_password` / `totp_status` / `totp_setup` / `totp_enable` / `totp_disable` / `recovery_codes` / `regenerate_recovery_codes` | `ChangePasswordCmd { old_password, new_password }`、`TotpEnableCmd { code, password }`、`TotpCodeCmd { code }`、`RegenerateRecoveryCodesCmd { password }` 与 UI 实参逐字一致 |
65| `user.list_sessions` / `revoke_session` / `revoke_all_sessions` | `SessionQuery { kind, include_revoked, limit, offset }`(`handlers/sessions.rs:41-61`),前端 `{ limit: 25 }` 合法;`revoke_session` 路径 1 段 `session_id` 顺序正确 |
66| `user.my_spaces` | `PageQuery` 上限 100,前端 `limit: 100` 合法;响应 `Page<MySpaceDto>`,前端按 `{ items }` 读一致 |
67| `user.list_push_subscriptions` / `delete_push_subscription` / `upsert_push_subscription` | `PageQuery` 上限 100,前端 `limit: 100` 合法;`delete` 路径 1 段 `push_id` 顺序正确 |
68| `users.get_user_by_username` | 后端 `GET /users/by-username/{username}`(`handlers/users.rs:42-60`,路由 `routes.rs:56-59`),前端 `space-admin.ts:87` 去掉 `@` 前缀后填入,路径 1 段且顺序正确 |
69| `account.account_deletion_status` / `request_account_deletion` / `cancel_account_deletion` | 三个 op 存在于 `OPS`,但 `view/app/src` 全文无任何调用点(仅 `ops.ts` 生成条目),无前端形状可核对,故不计入问题(不可观察) |
Added audit/SUMMARY.md audit/SUMMARY.md +93 −0 View audit/SUMMARY.md
@@ -0,0 +1,93 @@
1# API 契约交叉审计 · 汇总
2
35 个 agent 按 tag 分域并行审计 `view/app` 的 288 个调用点,方法见 [README](./README.md)。
4每域原始报告:`chat.md`、`space-account.md`、`pulls-reviews.md`、`issues-repo.md`、`git-misc.md`。
5
6## 总览
7
8| 域 | 调用点 | 操作 | 确认 | 可疑 | Q | B | P | R | G |
9|---|---|---|---|---|---|---|---|---|---|
10| chat / notifications | 62 | 58 | 3 | 4 | 0 | 1 | 0 | 2 | 0 |
11| space / auth / account / user | 87 | 77 | 16 | 6 | 0 | 1 | 1 | 4 | 10 |
12| pulls / reviews / protection | 54 | 54 | 1 | 4 | 0 | 0 | 0 | 1 | 0 |
13| issues / repos / branches / tags / releases | 65 | 63 | 5 | 3 | 1 | 2 | 2 | 0 | 0 |
14| git 底层(diff/blob/tree/commit/blame/lfs/remote/archive/cleanup/submodule/conflict/push) | 30 | 30 | 3 | 5 | 1 | 0 | 1 | 1 | 0 |
15| **合计** | **298** | **282** | **28** | **22** | **2** | **4** | **4** | **8** | **10** |
16
1728 条确认问题里,18 条已在本轮修掉,10 条是后端能力缺口(见「未修」)。
18
19## 共性根因(比单条 bug 更值得记住)
20
211. **`OPS` 表的 `query`/`body` 标志位曾经是常量**,`params` 从不发给生成客户端 —— 上一轮修掉后,
22 所有 query 才第一次真正上线;本轮 4 条 Q 类(`skip`/`max_count`、`sort=position`、`limit=200`×2)
23 都是「参数第一次被发出去」后才暴露出来的。
242. **生成的 URL builder 对 path 参数零编码**(355 个 builder 无一调用 `encodeURIComponent`)。
25 分支名、标签名、release tag 合法允许 `/`,于是建得出来、改不掉也删不掉。
263. **`listOf` / `page?.items` 兜底把形状错误降级为静默空数据**:`list_pins` 裸数组当分页、
27 `list_emojis` 分页当数组(后者直接崩)、`my_suspensions` 裸数组当分页、`MemberSummary` 扁平当嵌套 ——
28 四例都是「不报错、只是永远空着」。
294. **`utoipa` 文档与 handler 返回类型会脱节**:`list_pins` 的 `body = Page<MessageDto>` 与实际
30 `Vec<MessageDto>` 不符,生成客户端因此骗了前端。文档即契约,注释写错等于契约写错。
315. **单层 `Option<T>` 被前端当三态用**:`null` 与「缺省」在服务端同义,
32 于是「清空」动作静默失效(房间限流、仓库简介/主页、保护规则 pattern、封禁到期)。
33
34## 已修(18)
35
36后端(`lib/service/space/src/routes.rs`):space 服务有 10 个 handler + utoipa 声明 + 生成客户端,
37但路由没挂载 —— 挂上即可,无契约变更:
38
39| 方法 | 路径 | handler |
40|---|---|---|
41| POST | `/spaces/{s}/members` | `add_member` |
42| GET/PATCH/DELETE | `/spaces/{s}/members/{member_id}` | `get_member` / `update_member` / `remove_member` |
43| GET | `/spaces/{s}/bans` | `list_bans` |
44| GET/PATCH | `/spaces/{s}/bans/{user_id}` | `get_ban` / `update_ban` |
45| GET/POST | `/spaces/{s}/invites` | `list_invites` / `create_invite` |
46| DELETE | `/spaces/{s}/invites/{invite_id}` | `revoke_invite` |
47
48前端:
49
50| 类 | 位置 | 修法 |
51|---|---|---|
52| P | `services/api/index.ts` | path 参数统一 `encodeURIComponent`(一处修全量 355 个 builder 的斜杠问题) |
53| Q | `repo/branches/{branches-tab,tags-section}.tsx` | `PAGE_LIMIT` 200 → 100(服务端上限) |
54| Q | `space-admin.ts` | `ADMIN_PAGE_LIMIT` 200 → 100 |
55| Q | `repo/model/commits.ts` | `skip`/`max_count` → `offset`/`limit` |
56| Q | `space-admin.ts` | 删除 `sort: 'position'`(该端点不接受) |
57| Q | `history/ui/commits-tab.tsx` | offset 到 5000 上限即停(越界是 400 不是截断) |
58| R | `chat/model/api.ts` | `list_pins` 按裸数组读;`list_emojis` 按 `page.items` 读 |
59| R | `settings/model/api.ts` | `my_suspensions` 按裸数组读 |
60| R | `chat/issues/repo-protection/space-admin` 四处 | 新增 `lib/member-row.ts`:扁平 `MemberSummary` → `{ user }` |
61| R | `pulls/model/api.ts` | `updateBranchPull` 返回 `{pull,result}`,取 `.pull` |
62| R | `history/ui/commit-view.tsx` | 根提交不再拿 `EMPTY_TREE` 求 diff(服务端 `rev_parse` 404),改为显式空补丁 + 文案 |
63| B | `issues/ui/issue-form-modal.tsx` | `attachment_ids` 只在 create 发,PATCH 不再带(`UpdateIssueCmd` 无此字段) |
64| B | `repo/.../general-settings.tsx` | 简介/主页用空串清空(后端单层 `Option<String>`) |
65| B | `chat/ui/chat-room.tsx` | 限流清空发 `0` 而不是 `null`(后端 `0..=3600` 合法,`null`=不变) |
66| B | `space-admin.ts` | 封禁带 `expires_at` 时串一次 `update_ban`(`BanMemberBody` 只有 `reason`) |
67| P | `space-admin.ts` | 成员三件套先 `findSpaceMember` 取会籍 UUID,再填 `{member_id}`(原来填数字 `user_id`) |
68
69## 未修(10 + 22 可疑)
70
71**后端能力缺口**(本轮不改,需要产品/后端决定):
72
731. `diff_raw` 无法对空树求差:根提交的补丁需要 `git diff-tree --root` 之类支持,当前前端只能降级。
742. `BanMemberBody` 没有 `expires_at`:现在靠封禁后补一次 PATCH 实现到期时间,两次调用非原子。
753. `CreateInviteBody` 没有 `email`/`extra_role_ids`:邀请表单里的「按邮箱邀请」「附带角色」被静默丢弃。
764. `list_pins` 的 `utoipa` 响应类型写成 `Page<MessageDto>`,实际是 `Vec<MessageDto>`;下次重生成客户端前应修正注释。
775. `UpdateBranchPolicyCmd` 的 `protected_file_patterns` 等是单层 `Option`,与 README 宣称的三态 PATCH 不符。
78
79**前端待决策**(改动涉及 UI 取舍,本轮未动):
80
81- 邀请弹窗的 `email` / 角色选择(#3 的用户可见部分);
82- `protection.ts` 的 `update_policy` 仍把未改动的 pattern 补成 `null`(等价于「不变」,属误导性写法);
83- `chat.update_room_setting` / `update_thread` 的 `null` 分支(当前无调用方触发);
84- `listRoomMembers` 本地空实现(后端确实没有该端点,不是契约错配)。
85
86## 复现审计
87
88```bash
89# 机械扫描(Q/B/P/R 四类,秒级)
90python3 scripts/gen-api-ops.py # 确认 OPS 表与生成客户端一致
91# 语义复核(逐 handler 结构体比对)按 README 分域进行
92bash scripts/contract-check.sh --client-only
93```