Bytebase Plan Check Run 单例资源 API 改造实战:从 List/Batch 到 Get/Singleton 的完整迁移指南
Bytebase Plan Check Run 单例资源 API 改造实战从 List/Batch 到 Get/Singleton 的完整迁移指南【免费下载链接】bytebaseDatabase governance built for humans and agents — controlling changes and access across every major database.项目地址: https://gitcode.com/GitHub_Trending/by/bytebase本文基于 Bytebase 仓库中的docs/plans/2025-12-24-plan-check-run-singleton-api.md实现计划展开结合当前仓库已落地的源码proto、Go 后端、TypeScript 前端、SQL 迁移脚本逐层还原这一 API 形态改造的完整路径供开发者在自有产品中复现同类「集合资源转单例资源」的演进。导读Bytebase 将 Plan Check Run计划检查运行从「集合资源」planCheckRuns/{id}支持 List / BatchCancel重构为「单例资源」planCheckRun仅支持 Get / Cancel根源在于每个 plan 在合并模型consolidated model下恰好只有一条plan check run 记录。本文以该实现计划为骨架从 proto 契约、权限系统、数据迁移、后端 service、前端 store 五个层面完整还原这套改造的每一个改动点与验证步骤读完你不仅能理解 Bytebase 这一特定 API 的设计取舍还能直接复用其资源命名、迁移脚本与测试命令来指导自己的 API 演进。1. 改造背景为什么从集合资源收敛为单例资源在旧的 API 设计中一个 deployment plan 可以关联多条 plan check run 记录因此 API 形态是ListPlanCheckRuns按过滤条件分页列出某 plan 下的 check runBatchCancelPlanCheckRuns批量取消多条 check run。而当前仓库已经将 plan check run 的记录模型合并为每 plan 一条consolidated model。当集合的基数恒定为 1 时集合资源 API 的 List / Batch 语义便失去了存在价值反而带来不必要的过滤语法、分页逻辑与批量操作复杂度。因此改造的核心目标非常明确将 plan check run 从 collection resource 转换为 plan 下的 singleton resourceAPI 从 List/Batch 操作收敛为 Get/单条操作。资源路径模式随之从planCheckRuns/{id}变为planCheckRun无 ID 段。这是一次典型的「由数据模型驱动 API 形态收敛」的重构涉及 Protocol Buffers、Go、TypeScript、Vue 3 四层技术栈的联动修改。2. Task 1Proto 契约层改造PlanService2.1 用 GetPlanCheckRun 替换 ListPlanCheckRuns在 proto/v1/v1/plan_service.proto 中将原先的 List RPC 替换为 Get RPC。当前仓库已落地为第 74-82 行// Gets the plan check run for a deployment plan. // Permissions required: bb.planCheckRuns.get rpc GetPlanCheckRun(GetPlanCheckRunRequest) returns (PlanCheckRun) { option (google.api.http) {get: /v1/{nameprojects/*/plans/*/planCheckRun}}; option (google.api.method_signature) name; option (bytebase.v1.permission) bb.planCheckRuns.get; option (bytebase.v1.auth_method) IAM; option (bytebase.v1.mcp_method_class) READ; }注意与计划文档相比仓库实现还额外增加了option (bytebase.v1.mcp_method_class) READ;这一注解用于标记该方法在 MCP 场景下属于只读类别——这正是 Bytebase 当前为 Agent/LLM 提供 MCP 服务时做方法分级的一个细节可作为参考。2.2 用 CancelPlanCheckRun 替换 BatchCancelPlanCheckRuns取消操作从批量语义改为单条语义HTTP 映射使用标准的自定义方法custom method后缀:cancel第 98-110 行// Cancels the plan check run for a deployment plan. // Permissions required: bb.planCheckRuns.run rpc CancelPlanCheckRun(CancelPlanCheckRunRequest) returns (CancelPlanCheckRunResponse) { option (google.api.http) { post: /v1/{nameprojects/*/plans/*/planCheckRun}:cancel body: * }; option (google.api.method_signature) name; option (bytebase.v1.permission) bb.planCheckRuns.run; option (bytebase.v1.auth_method) IAM; option (bytebase.v1.audit) true; option (bytebase.v1.mcp_method_class) WRITE; }2.3 消息定义同步收敛请求/响应消息也全部改为单例形态第 330-373 行message GetPlanCheckRunRequest { // The name of the plan check run to retrieve. // Format: projects/{project}/plans/{plan}/planCheckRun string name 1 [ (google.api.field_behavior) REQUIRED ]; } message CancelPlanCheckRunRequest { // The name of the plan check run to cancel. // Format: projects/{project}/plans/{plan}/planCheckRun string name 1 [ (google.api.field_behavior) REQUIRED ]; } message CancelPlanCheckRunResponse {} message PlanCheckRun { // Format: projects/{project}/plans/{plan}/planCheckRun string name 1; ... }关键变化归纳维度改造前集合资源改造后单例资源资源路径projects/{project}/plans/{plan}/planCheckRuns/{id}projects/{project}/plans/{plan}/planCheckRun查询操作ListPlanCheckRuns带 CEL 过滤、分页GetPlanCheckRun按 name 直取取消操作BatchCancelPlanCheckRunsCancelPlanCheckRun:cancel自定义方法权限bb.planCheckRuns.list/bb.planCheckRuns.runbb.planCheckRuns.get/bb.planCheckRuns.run消息List 请求/响应 Batch 请求/响应Get 请求 Cancel 请求/空响应2.4 格式化、Lint 与生成proto 修改完成后依次执行验证与生成# Step 6: 格式化并 lint buf format -w proto buf lint proto # Step 7: 生成代码 cd proto buf generate预期结果buf lint无报错buf generate更新生成的 Go/TypeScript 代码。当前仓库中backend/generated-go/v1/与frontend/src/types/proto-es/v1/下的生成产物即为本次改造的输出。3. Task 2权限系统同步重命名权限是 API 改造中极易遗漏的一环。单例化之后bb.planCheckRuns.list权限不再有对应 RPC需整体重命名为bb.planCheckRuns.getStep 1修改 backend/component/iam/permission.yaml将bb.planCheckRuns.list替换为- bb.planCheckRuns.getStep 2重新生成权限常量go generate ./backend/component/iam/...预期permission.go中生成新的权限常量。Step 3同步更新 backend/component/iam/acl.yaml访问控制列表中的所有bb.planCheckRuns.list出现处grep -n planCheckRuns.list backend/component/iam/acl.yamlStep 4更新前端权限类型联合 frontend/src/types/iam/permission.ts| bb.planCheckRuns.get这一步骤的意义在于Bytebase 的权限声明permission.yaml、ACL 映射acl.yaml、Go 常量permission.go与前端类型permission.ts四者必须保持一致任何一处漏改都会导致权限校验或前端断言失效。4. Task 2.5存量自定义角色的数据迁移权限字符串重命名后数据库中**已存在的自定义角色custom role**仍可能保存着旧权限bb.planCheckRuns.list必须通过 SQL 迁移脚本原地改写。仓库中已存在该迁移文件backend/migrator/migration/3.14/0008##rename_plan_check_runs_permission.sql核心 SQL 如下利用 PostgreSQL 的jsonb_set与jsonb_array_elements_text对权限数组做逐元素 CASE 改写-- Rename bb.planCheckRuns.list to bb.planCheckRuns.get in custom roles UPDATE role SET permissions jsonb_set( permissions, {permissions}, ( SELECT jsonb_agg( CASE WHEN elem bb.planCheckRuns.list THEN bb.planCheckRuns.get ELSE elem END ) FROM jsonb_array_elements_text(permissions-permissions) AS elem ) ) WHERE permissions-permissions bb.planCheckRuns.list;这段脚本的要点WHERE ... bb.planCheckRuns.list保证只命中确实包含旧权限的角色行避免全表空转jsonb_array_elements_text展开数组、jsonb_agg重新聚合配合CASE完成元素级重命名其余权限原样保留迁移文件按 Bytebase 的 migrator 约定以版本号/序号##描述.sql命名存放于 backend/migrator/migration/ 目录由 migrator 按序执行。这是「代码改名 数据搬家」双轨改造的典型示范仅改代码会遗留存量脏数据仅改数据则新代码无法识别二者必须同批提交。5. Task 3后端资源名解析助手改造所有 RPC 的第一步都是解析资源名。改造涉及 backend/common/resource_name.go 中的两个助手函数。5.1 新增 GetProjectIDPlanIDFromPlanCheckRun在旧的GetProjectIDPlanIDPlanCheckRunID第 339-354 行仍保留用于解析带 ID 的历史资源名之后新增单例版本解析函数第 356-369 行// GetProjectIDPlanIDFromPlanCheckRun returns the project ID and plan ID from a plan check run singleton resource name. // Format: projects/{project}/plans/{plan}/planCheckRun func GetProjectIDPlanIDFromPlanCheckRun(name string) (string, int64, error) { // Remove the trailing /planCheckRun suffix if !strings.HasSuffix(name, /planCheckRun) { return , 0, errors.Errorf(invalid plan check run name %q, expected suffix /planCheckRun, name) } planName : strings.TrimSuffix(name, /planCheckRun) projectID, planID, err : GetProjectIDPlanID(planName) if err ! nil { return , 0, err } return projectID, planID, nil }实现思路很清晰先校验name必须以/planCheckRun结尾不合法直接返回错误去掉该后缀后复用已有的GetProjectIDPlanID解析projects/{project}/plans/{plan}从而把「单例解析」降维为「plan 解析」——这与仓库中GetProjectIDPlanIDFromRolloutNamebackend/common/resource_name.go 第 372-378 行处理/rollout后缀的手法完全一致属于 Bytebase 统一的资源名解析范式。5.2 FormatPlanCheckRun 改为单例格式格式化函数同步收敛第 798-802 行// FormatPlanCheckRun formats a plan check run singleton resource name. // Format: projects/{project}/plans/{plan}/planCheckRun func FormatPlanCheckRun(projectID string, planUID int64) string { return fmt.Sprintf(%s/planCheckRun, FormatPlan(projectID, planUID)) }对比旧版返回projects/{project}/plans/{plan}/planCheckRuns/{id}新函数不再需要planCheckRunID参数直接拼接固定后缀planCheckRun。这类 Format/Parse 成对函数是 Bytebase 资源命名规范的核心backend/common/resource_name.go 中FormatPlan、FormatSpec等均遵循同一约定。6. Task 4后端 Service 层实现后端核心改动集中在 backend/api/v1/plan_service.go。6.1 GetPlanCheckRun当前仓库实现位于第 425-443 行与计划文档一致// GetPlanCheckRun gets the plan check run for the plan. func (s *PlanService) GetPlanCheckRun(ctx context.Context, request *connect.Request[v1pb.GetPlanCheckRunRequest]) (*connect.Response[v1pb.PlanCheckRun], error) { req : request.Msg projectID, planUID, err : common.GetProjectIDPlanIDFromPlanCheckRun(req.Name) if err ! nil { return nil, connect.NewError(connect.CodeInvalidArgument, err) } planCheckRun, err : s.store.GetPlanCheckRun(ctx, projectID, planUID) if err ! nil { return nil, connect.NewError(connect.CodeInternal, errors.Wrapf(err, failed to get plan check run)) } if planCheckRun nil { return nil, connect.NewError(connect.CodeNotFound, errors.Errorf(plan check run not found for plan %d, planUID)) } converted : convertToPlanCheckRun(projectID, planUID, planCheckRun) return connect.NewResponse(converted), nil }错误码语义分层值得借鉴资源名解析失败 →InvalidArgument客户端传参问题store 查询出错 →Internal查询结果为空 →NotFound。对应 store 层查询为 backend/store/plan_check_run.go 中的GetPlanCheckRun第 225 行签名(ctx, projectID, planUID)这也印证了「单例」语义——按 plan 维度的唯一性直接定位记录不再需要 filter 与分页。6.2 CancelPlanCheckRun取消逻辑位于第 521 行起相比计划文档当前实现的状态检查更完整不仅允许取消Running状态也允许取消Available已就绪但尚未发布状态的 check run第 548 行if planCheckRun.Status ! store.PlanCheckRunStatusRunning planCheckRun.Status ! store.PlanCheckRunStatusAvailable { return nil, connect.NewError(connect.CodeInvalidArgument, errors.Errorf(plan check run is not running or available)) }取消的完整流程为解析name得到projectID与planUID校验 project 存在FindProjectMessage{ResourceID: projectID}否则NotFound获取 plan check run为空则NotFound校验状态为 Running 或 Available从s.stateCfg.RunningPlanCheckRunsCancelFunc中取出对应的context.CancelFunc并调用中断正在执行的检查任务调用s.store.BatchCancelPlanCheckRuns(ctx, []int{planCheckRun.UID})落库更新状态为 canceledstore 方法保留批量签名但实际只传入单个 UID。6.3 删除 parsePlanCheckRunFilter由于不再需要 List 语义原用于解析 CEL 过滤条件的parsePlanCheckRunFilter方法整体删除同时清理其专用的 CEL 依赖 importgithub.com/google/cel-go/celcelast github.com/google/cel-go/common/astceloperators github.com/google/cel-go/common/operators这是本次改造在「减法」层面的收益删掉一整条 CEL 过滤解析链路显著降低该模块的维护面。6.4 convertToPlanCheckRun 与 convertToPlan转换函数从复数convertToPlanCheckRuns收敛为单数且不再依赖 check run IDfunc convertToPlanCheckRun(projectID string, planUID int64, run *store.PlanCheckRunMessage) *v1pb.PlanCheckRun { return v1pb.PlanCheckRun{ Name: common.FormatPlanCheckRun(projectID, planUID), Status: convertToPlanCheckRunStatus(run.Status), Results: convertToPlanCheckRunResults(run.Result.GetResults()), Error: run.Result.Error, CreateTime: timestamppb.New(run.CreatedAt), } }convertToPlan中原本基于「多条 check run」的状态计数逻辑也简化为单条planCheckRun, err : s.GetPlanCheckRun(ctx, plan.UID) if err ! nil { return nil, errors.Wrapf(err, failed to get plan check run for plan uid %d, plan.UID) } if planCheckRun ! nil { p.PlanCheckRunStatusCount[string(planCheckRun.Status)] for _, result : range planCheckRun.Result.Results { p.PlanCheckRunStatusCount[storepb.Advice_Status_name[int32(result.Status)]] } }即一个 plan 只累计一次 check run 自身状态再对每条 advice 结果累计一次状态计数。7. Task 5前端 Store 与组件联动改造前端是 API 形态变化的直接消费方改动分布在 store 与组件两个层面。7.1 实验性 Issue storefrontend/src/store/modules/v1/experimental-issue.ts 中获取 check run 的逻辑从「带权限判断的 List 请求」改为「Get 单例请求」if (hasProjectPermissionV2(projectEntity, bb.planCheckRuns.get)) { const request create(GetPlanCheckRunRequestSchema, { name: ${issue.plan}/planCheckRun, }); try { const response await planServiceClientConnect.getPlanCheckRun(request); issue.planCheckRunList [response]; } catch { // Plan check run might not exist yet issue.planCheckRunList []; } }两个值得注意的兼容性设计权限前置先通过hasProjectPermissionV2(projectEntity, bb.planCheckRuns.get)判断无权限直接跳过避免无谓请求容错降级getPlanCheckRun抛错时捕获并置空数组——因为 check run 是随 plan 创建/运行才存在的plan 尚未运行过检查时 Get 必然返回 NotFound这是业务上的正常状态而非错误。请求构造时name 直接取plan.name拼接/planCheckRun后缀${issue.plan}/planCheckRun与后端资源命名规则一一对应。7.2 资源名解析助手common.tsfrontend/src/store/modules/v1/common.ts 新增前端侧的单例解析函数放在旧getProjectNamePlanIdPlanCheckRunId之后export const getProjectNamePlanIdFromPlanCheckRun (name: string): [string, string] { // Format: projects/{project}/plans/{plan}/planCheckRun if (!name.endsWith(/planCheckRun)) { throw new Error(Invalid plan check run name: ${name}); } const planName name.replace(/\/planCheckRun$/, ); const tokens getNameParentTokens(planName, [ projectNamePrefix, planNamePrefix, ]); return [tokens[0], tokens[1]]; };与 Go 侧的GetProjectIDPlanIDFromPlanCheckRun形成前后端镜像同样先校验/planCheckRun后缀再剥离后缀、按前缀 token 解析出 project 与 plan 标识。7.3 轮询刷新逻辑poller/utils.tsfrontend/src/components/Plan/logic/poller/utils.ts 的refreshPlanCheckRuns同样改为 Get 单例并回填为单元素数组export const refreshPlanCheckRuns async ( plan: Plan, project: Project, planCheckRuns: RefPlanCheckRun[] ): Promisevoid { if (!hasProjectPermissionV2(project, bb.planCheckRuns.get)) { return; } const request create(GetPlanCheckRunRequestSchema, { name: ${plan.name}/planCheckRun, }); try { const response await planServiceClientConnect.getPlanCheckRun(request); planCheckRuns.value [response]; } catch { // Plan check run might not exist yet planCheckRuns.value []; } };前端组件层保留planCheckRuns数组状态而非强行改为单值通过「Get 一次、装进单元素数组」的方式最小化对既有渲染层的影响这是控制重构爆炸半径的实用技巧。7.4 详情组件PlanCheckRunDetail.vuefrontend/src/components/PlanCheckRun/PlanCheckRunDetail.vue 的 import 与取消操作同步更新import { getProjectNamePlanIdFromPlanCheckRun, planNamePrefix, projectNamePrefix, } from /store/modules/v1/common; import { CancelPlanCheckRunRequestSchema, PlanCheckRun_ResultSchema, PlanCheckRun_Status, } from /types/proto-es/v1/plan_service_pb;取消动作直接基于单例 nameconst cancelPlanCheckRun async () { const request create(CancelPlanCheckRunRequestSchema, { name: props.planCheckRun.name, }); await planServiceClientConnect.cancelPlanCheckRun(request); if (usePlanCheckRunContext()) { usePlanCheckRunContext().events.emit(status-changed); } };取消成功后通过 context 事件总线广播status-changed驱动轮询/渲染层刷新状态组件间通信保持解耦。前端改动完成后执行两轮静态检查pnpm --dir frontend biome:check pnpm --dir frontend type-check8. Task 6构建与测试闭环全部代码落地后按以下顺序验证整个链路# 1. 后端构建 go build -ldflags -w -s -p16 -o ./bytebase-build/bytebase ./backend/bin/server/main.go # 2. 后端 Lint golangci-lint run --allow-parallel-runners # 3. 相关单元测试 go test -v -count1 github.com/bytebase/bytebase/backend/store -run PlanCheck go test -v -count1 github.com/bytebase/bytebase/backend/api/v1 -run PlanCheck测试命令的-run PlanCheck精确匹配 store 层与 api/v1 层与 PlanCheck 相关的测试用例可快速验证数据层查询与 API handler 在新单例语义下均工作正常。仓库中 backend/store/plan_check_run.go 及其测试、backend/api/v1/plan_service.go 相关测试均可作为回归基线。9. 改动全景与经验小结9.1 改动清单Task描述关键文件1Proto 契约Get/Cancel 单例 RPCproto/v1/v1/plan_service.proto2权限list→get重命名backend/component/iam/permission.yaml、frontend/src/types/iam/permission.ts2.5存量自定义角色数据迁移backend/migrator/migration/3.14/0008##rename_plan_check_runs_permission.sql3资源名解析助手单例 Parse/Formatbackend/common/resource_name.go4后端 APIGet/Cancel/转换函数backend/api/v1/plan_service.go5前端store 与组件消费侧更新frontend/src/store/modules/v1/、frontend/src/components/PlanCheckRun/PlanCheckRunDetail.vue6构建、Lint 与测试闭环全部9.2 可复用的改造范式回顾整个计划这套「集合转单例」改造提炼出四条值得固化的经验契约先行、逐层推进先改 proto 并重新生成代码再改权限与数据迁移最后动后端 service 与前端消费方每层都有独立的格式化/Lint/类型检查验证点buf lint、golangci-lint、biome:check、type-check任何一层编译不过都能立刻定位。存量数据与代码同步迁移权限字符串的改名必须配套 SQL 迁移脚本jsonb_setjsonb_agg改写否则历史自定义角色会携带失效权限迁移文件命名遵循 migrator 目录的版本化约定。删减与收敛同样重要单例化后CEL 过滤解析链路parsePlanCheckRunFilter及其依赖可以整体移除这是 API 精简带来的长期维护收益同时保留旧的 ID 形态解析函数GetProjectIDPlanIDPlanCheckRunID以兼容历史资源名。前端以最小侵入适配新 API组件层仍保留数组状态通过「Get 单例 → 包装为单元素数组」平滑过渡配合权限前置判断与「check run 尚不存在」的容错降级避免了大范围 UI 重构。对于任何面临「资源从一对多收敛为一对一」场景的产品如把多版本检查记录合并为单一最新记录、把多 runner 心跳合并为单实例状态本文的六步改造法Proto → 权限 → 迁移 → 助手 → Service → 前端都可以直接作为操作清单复用。附进一步阅读设计上下文合并模型与单例化的动机可参考 docs/plans/2025-12-23-consolidated-plan-check-runs-design.md 与 docs/plans/2025-12-23-consolidated-plan-check-runs-impl.mdv1 API 层面的一致性设计docs/plans/2025-12-24-v1-api-consolidated-plan-check-runs.mdstore 层单例查询实现backend/store/plan_check_run.go资源命名规范汇总backend/common/resource_name.go【免费下载链接】bytebaseDatabase governance built for humans and agents — controlling changes and access across every major database.项目地址: https://gitcode.com/GitHub_Trending/by/bytebase创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考