Bytebase Plan Check Run 单例资源 API 改造实战:从 List/Batch 到 Get/Singleton 的完整迁移指南
2026/9/14 12:03:16 网站建设 项目流程

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 run;
  • BatchCancelPlanCheckRuns:批量取消多条 check run。

而当前仓库已经将 plan check run 的记录模型合并为每 plan 一条(consolidated model)。当集合的基数恒定为 1 时,集合资源 API 的 List / Batch 语义便失去了存在价值,反而带来不必要的过滤语法、分页逻辑与批量操作复杂度。因此改造的核心目标非常明确:

将 plan check run 从 collection resource 转换为 plan 下的 singleton resource,API 从 List/Batch 操作收敛为 Get/单条操作。

资源路径模式随之从planCheckRuns/{id}变为planCheckRun(无 ID 段)。这是一次典型的「由数据模型驱动 API 形态收敛」的重构,涉及 Protocol Buffers、Go、TypeScript、Vue 3 四层技术栈的联动修改。


2. Task 1:Proto 契约层改造(PlanService)

2.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/{name=projects/*/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/{name=projects/*/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.get

Step 1:修改 backend/component/iam/permission.yaml,将bb.planCheckRuns.list替换为:

- bb.planCheckRuns.get

Step 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.yaml

Step 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_setjsonb_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 解析」——这与仓库中GetProjectIDPlanIDFromRolloutName(backend/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 中FormatPlanFormatSpec等均遵循同一约定。


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")) }

取消的完整流程为:

  1. 解析name得到projectIDplanUID
  2. 校验 project 存在(FindProjectMessage{ResourceID: &projectID}),否则NotFound
  3. 获取 plan check run,为空则NotFound
  4. 校验状态为 Running 或 Available;
  5. s.stateCfg.RunningPlanCheckRunsCancelFunc中取出对应的context.CancelFunc并调用,中断正在执行的检查任务;
  6. 调用s.store.BatchCancelPlanCheckRuns(ctx, []int{planCheckRun.UID})落库更新状态为 canceled(store 方法保留批量签名,但实际只传入单个 UID)。

6.3 删除 parsePlanCheckRunFilter

由于不再需要 List 语义,原用于解析 CEL 过滤条件的parsePlanCheckRunFilter方法整体删除,同时清理其专用的 CEL 依赖 import:

  • "github.com/google/cel-go/cel"
  • celast "github.com/google/cel-go/common/ast"
  • celoperators "github.com/google/cel-go/common/operators"

这是本次改造在「减法」层面的收益:删掉一整条 CEL 过滤解析链路,显著降低该模块的维护面。

6.4 convertToPlanCheckRun 与 convertToPlan

转换函数从复数convertToPlanCheckRuns收敛为单数,且不再依赖 check run ID:

func 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 store

frontend/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.ts)

frontend/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.ts)

frontend/src/components/Plan/logic/poller/utils.ts 的refreshPlanCheckRuns同样改为 Get 单例并回填为单元素数组:

export const refreshPlanCheckRuns = async ( plan: Plan, project: Project, planCheckRuns: Ref<PlanCheckRun[]> ): Promise<void> => { 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.vue)

frontend/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";

取消动作直接基于单例 name:

const 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-check

8. Task 6:构建与测试闭环

全部代码落地后,按以下顺序验证整个链路:

# 1. 后端构建 go build -ldflags "-w -s" -p=16 -o ./bytebase-build/bytebase ./backend/bin/server/main.go # 2. 后端 Lint golangci-lint run --allow-parallel-runners # 3. 相关单元测试 go test -v -count=1 github.com/bytebase/bytebase/backend/store -run PlanCheck go test -v -count=1 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.proto
2权限:listget重命名backend/component/iam/permission.yaml、frontend/src/types/iam/permission.ts
2.5存量自定义角色数据迁移backend/migrator/migration/3.14/0008##rename_plan_check_runs_permission.sql
3资源名解析助手:单例 Parse/Formatbackend/common/resource_name.go
4后端 API:Get/Cancel/转换函数backend/api/v1/plan_service.go
5前端:store 与组件消费侧更新frontend/src/store/modules/v1/、frontend/src/components/PlanCheckRun/PlanCheckRunDetail.vue
6构建、Lint 与测试闭环全部

9.2 可复用的改造范式

回顾整个计划,这套「集合转单例」改造提炼出四条值得固化的经验:

  1. 契约先行、逐层推进:先改 proto 并重新生成代码,再改权限与数据迁移,最后动后端 service 与前端消费方,每层都有独立的格式化/Lint/类型检查验证点(buf lintgolangci-lintbiome:checktype-check),任何一层编译不过都能立刻定位。
  2. 存量数据与代码同步迁移:权限字符串的改名必须配套 SQL 迁移脚本(jsonb_set+jsonb_agg改写),否则历史自定义角色会携带失效权限;迁移文件命名遵循 migrator 目录的版本化约定。
  3. 删减与收敛同样重要:单例化后,CEL 过滤解析链路(parsePlanCheckRunFilter)及其依赖可以整体移除,这是 API 精简带来的长期维护收益;同时保留旧的 ID 形态解析函数(GetProjectIDPlanIDPlanCheckRunID)以兼容历史资源名。
  4. 前端以最小侵入适配新 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.md
  • v1 API 层面的一致性设计:docs/plans/2025-12-24-v1-api-consolidated-plan-check-runs.md
  • store 层单例查询实现: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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询