Skip to content
Clone
b868469
57 lines 18.6 KB Markdown

issues / analysis / repos / branches / tags / releases

结论

  • 检查调用点 65 个 / 操作 63 个(issues 33、repos 12、branches 5、tags 4、releases 5、analysis 4)
  • 确认问题 5 条(Q 1 / B 2 / P 2 / R 0 / G 0),可疑 3 条,未实证 2 条
  • 基线说明:工作区存在他人未提交修改(git status 显示 branches-tab.tsx、tags-section.tsx 的 PAGE_LIMIT 已由 200 改为 100), 确认问题 #5 以 HEAD 提交版本判定;该条在工作区当前状态已被改掉,报告按 HEAD 记录以便回归

实证环境:http://localhost:7051(/healthz 200)。demo/demorepos 可匿名读,branches / tags / releases / repos / analysis 全部实测; issues 全域与 repos 写操作需登录或 owner,运行时未实证,仅源码取证(即确认问题 #3、#4)。

确认问题

#类位置操作现象后端依据建议修法
1Pview/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
2Pview/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
3Bview/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
4Bview/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:29repos.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 对齐;前端保持不变
5Qview/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_tagsquery 值越界:两个分页端点服务端上限是 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

可疑(需人工确认)

#位置操作疑点已排除的可能
1view/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 转换层,不在路由或契约字段
2view/app/src/domains/repo/model/releases.ts:17-29releases.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 字段确实存在,解析没错;问题只在错误通道
3lib/service/git/src/handler/branches/get.rs:39 vs branches/list.rsbranches.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 的短名

已核对无问题

操作依据
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
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
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)
列表 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>
列表 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
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>>
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>,无副作用
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
issues.create_issueapi.ts:112-128 发的 10 个键对齐 CreateIssueCmd(issues.rs:78-100),含 attachment_ids,null 折成 Option::None 合法
issues.bulk_issuesBulkIssuesCmd { action, issue_refs }(issues.rs:1169-1173,action 为 close/reopen)+ 1..=100 校验(:1189);前端无任何调用点(ops.ts:265 注册了,65 处调用里 0 处使用),批量改状态是逐条 close_issue,故无契约风险
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
附件三段式(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
issues 标签/里程碑 CRUDcreate_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)
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 同拼写
repos.list_reposquery 只用 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`"}`
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
branches.list_branches / tags.list_tags 响应实测 Page 形状:{"items":[{…}],"total":1,"limit":100,"offset":0};前端 {items,total} 取值正确,total 缺失时兜底 items.length 不会误判
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)
tags.list_tags 的 tag_type 判定前端 tags-section.tsx:98 比较 'Annotated',后端 TagType(rpcgit/tags/types.rs:18-22)没有 rename_all,序列化即 "Lightweight" / "Annotated",首字母大写,比较成立(不是 snake_case 陷阱)
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 误用
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`
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)
repos topicsadd/remove 的 topic_name 为单段路径,路由 /{space_name}/{repo_name}/topics/{topic_name}(routes.rs:287),topic 名不合法含 /,无编码风险;list_repo_topics 后端返回 Vec,前端 unwrapApi<TopicRow[]> 正确
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 类幽灵端点