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 直取) |
| 取消操作 | BatchCancelPlanCheckRuns | CancelPlanCheckRun(:cancel自定义方法) |
| 权限 | bb.planCheckRuns.list/bb.planCheckRuns.run | bb.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.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 解析」——这与仓库中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 中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})落库更新状态为 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-check8. 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 | 描述 | 关键文件 |
|---|---|---|
| 1 | Proto 契约:Get/Cancel 单例 RPC | proto/v1/v1/plan_service.proto |
| 2 | 权限:list→get重命名 | 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/Format | backend/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 可复用的改造范式
回顾整个计划,这套「集合转单例」改造提炼出四条值得固化的经验:
- 契约先行、逐层推进:先改 proto 并重新生成代码,再改权限与数据迁移,最后动后端 service 与前端消费方,每层都有独立的格式化/Lint/类型检查验证点(
buf lint、golangci-lint、biome:check、type-check),任何一层编译不过都能立刻定位。 - 存量数据与代码同步迁移:权限字符串的改名必须配套 SQL 迁移脚本(
jsonb_set+jsonb_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.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),仅供参考