尧图精选

Bytebase Plan 变更审计快照机制:用 before/after 快照替代类型化事件的设计与实现

🕒 发布时间:2026/9/15 15:14:21 📁 来源:尧图网络
Bytebase Plan 变更审计快照机制用 before/after 快照替代类型化事件的设计与实现【免费下载链接】bytebaseDatabase governance built for humans and agents — controlling changes and access across every major database.项目地址: https://gitcode.com/GitHub_Trending/by/bytebase本指南围绕 Bytebase 中PlanService.UpdatePlan的规格spec变更审计展开深入讲解快照式审计snapshot-based audit这一架构设计以一条PlanUpdate事件承载plan.config.specs的变更前后快照取代原先按属性拆分的PlanSpecAdd/PlanSpecRemove/PlanSpecUpdate类型化事件。读完本文你将掌握该机制在 proto 定义、Go 后端发射、TypeScript 前端渲染三个层面的完整实现路径以及配套的单元测试、集成测试与手动 UI 验证方法。背景审批流中的可见性缺口在 Bytebase 的数据库变更工作流中Plan计划由编辑器editor维护而审批者approver需要在审批流程中对计划进行审查。设计文档见 2026-05-11-plan-mutation-audit-snapshot-design.md指出了一个由BYT-9175追踪的可见性缺口审批者无法看到编辑器在审批流程进行期间对计划改了什么。早期设计2026-05-09 的类型化事件方案试图通过PlanSpecUpdate携带 sheet、targets、prior_backup 的from_*/to_*属性对以及PlanSpecAdd/PlanSpecRemove三个消息来解决。但该方案在 PR #20276 评审中遭到了反对核心论据是每增加一个可审计属性都需要联动修改 proto Go helper converter 前端 renderer i18n 五处重排reorder、CreatePlan、DeletePlan这些场景因不符合类型化形状而被显式推迟。评审者提出的替代方案是直接对repeated PlanConfig.Spec做快照。这一提议最终被采纳形成了本文将要展开的快照式审计设计。架构决策快照 vs 类型化事件设计文档用一张对比表清晰展示了两个方向的取舍维度类型化事件已废弃快照本设计新增可审计属性的成本proto helper converter renderer i18n仅 renderer重排reorder覆盖非目标天然支持按修饰性过滤CreatePlan/DeletePlan覆盖非目标天然扩展暂缓每行存储小型类型化 payload全量 Spec 快照 ×2sheet 为 sha256约 1KB/个Diff 逻辑所在Go后端TypeScript前端API 消费方成本事件已类型化可直接消费快照需自行 diff 才能获得按属性信息是否可按字段选择审计是否审计Spec上的一切最终选择快照侧的原因在于向前兼容性是持久的收益——未来给PlanConfig.Spec新增字段时后端零改动即可自动纳入审计只有 renderer 需要决定是特殊展示还是落入 JSON-diff 兜底。代价是把 diff 逻辑从 Go 移到了 TypeScript但该 helper 是纯函数测试面非常干净。设计目标与非目标目标审批者能看到被审计划改了什么——sheet、targets、prior-backup 开关、spec 新增与删除针对关联了 issue 的计划Schema 向前兼容PlanConfig.Spec新增字段自动被审计后端零改动单一统一事件形状不再有按属性拆分的 proto 消息无 schema 迁移、无 DDL。非目标明确暂缓未关联 issue 的计划需未来的plan_audit表自审批资格 / 编辑器追踪BYT-9175的控制缺口纯重排审计——顺序被视为修饰性变更不产生审计行CreatePlan/DeletePlan的审计发射路径非 spec 属性标题、描述、状态的审计计划变更的实时通知 / webhook 投递。Proto 层三种事件收敛为一种快照存储层 proto在 proto/store/store/issue_comment.proto 中IssueCommentPayload的oneof event收敛为包含PlanUpdate的形状oneof event { Approval approval 2; IssueUpdate issue_update 3; PlanUpdate plan_update 7; ReviewSubmission review_submission 8; } // PlanUpdate carries before/after snapshots of plan.config.specs, // emitted once per PlanService.UpdatePlan call whose specs branch // produces a non-cosmetic diff. The renderer computes per-spec // add/remove/update from the snapshot pair. message PlanUpdate { repeated PlanConfig.Spec from_specs 1; repeated PlanConfig.Spec to_specs 2; }一个关键细节存储层字段号7恰好是此前PlanSpecUpdate用过的号。由于PlanSpecUpdate及其同伴从未合入mainPR #20032 与 PR #20276 均未合并不存在线上兼容约束因此无需声明reserved。当前仓库中该文件顶部也确实只保留了reserved 4 to 6见 issue_comment.proto与设计一致。v1 公共 API 层在 proto/v1/v1/issue_service.proto 中镜像同一形状oneof event { Approval approval 7; IssueUpdate issue_update 8; PlanUpdate plan_update 12; } // Plan update event information (snapshot of plan.config.specs before // and after a PlanService.UpdatePlan call that mutated specs). message PlanUpdate { repeated Plan.Spec from_specs 1; repeated Plan.Spec to_specs 2; }v1 的Plan.Spec与存储层PlanConfig.Spec形状相同但 sheet 字段是资源名如projects/p/sheets/s1而非原始 sha256——转换由后端的 converter 完成。格式化、lint 与重新生成改动 proto 后的标准流程buf format -w proto buf lint proto cd proto buf generate cd ..buf generate会把产物重新生成到backend/generated-go/、frontend/src/types/proto-es/以及proto/gen/grpc-doc/。此阶段构建会刻意处于半破坏状态——旧调用方仍引用已删除的IssueCommentPayload_PlanSpec*类型由后续任务逐一修复。后端实现12 行集合相等守卫 直接快照拷贝删除旧 helper原类型化设计引入了 4 个 Go helper本次重构全部删除getPlanSpecSheetSha256getPlanSpecTargetsgetPlanSpecEnablePriorBackupbuildPlanSpecAuditIssueComments同时删除plan_service_test.go中对应的 4 个单元测试与cdcSpechelper设计文档称旧TestBuildPlanSpecAuditIssueComments有 9 个用例随 helper 一并删除。新增planSpecsEqualSet在 backend/api/v1/plan_service.go 中新增一个 12 行的纯函数用于判断两次快照是否为同一集合——忽略顺序// planSpecsEqualSet reports whether two spec slices have the same set of // specs keyed by id, with each pair byte-equal under proto.Equal. Order // is ignored — reorder-only diffs are not audited. func planSpecsEqualSet(a, b []*storepb.PlanConfig_Spec) bool { if len(a) ! len(b) { return false } byID : make(map[string]*storepb.PlanConfig_Spec, len(a)) for _, s : range a { byID[s.GetId()] s } for _, s : range b { other, ok : byID[s.GetId()] if !ok || !proto.Equal(s, other) { return false } } return true }它的语义要点以id为键比较集合长度不等直接短路长度相等时用proto.Equal做逐对字节级比较。纯重排reorder-only因集合相等而返回 true从而不产生审计行——这是重排视为修饰性变更这一设计决策在后端的落点。更新UpdatePlan调用点在UpdatePlan的case specs:分支下原先是拼接buildPlanSpecAuditIssueComments(...)的结果现在替换为一条带守卫的快照写入if issue ! nil { if !planSpecsEqualSet(oldPlan.Config.GetSpecs(), allSpecs) { issueCommentCreates append(issueCommentCreates, store.IssueCommentMessage{ ProjectID: issue.ProjectID, IssueUID: issue.UID, Payload: storepb.IssueCommentPayload{ Event: storepb.IssueCommentPayload_PlanUpdate_{ PlanUpdate: storepb.IssueCommentPayload_PlanUpdate{ FromSpecs: oldPlan.Config.GetSpecs(), ToSpecs: allSpecs, }, }, }, }) } // ... 原有 approval finding 重置逻辑保持不变 ... }注意审计发射仍然被if issue ! nil门控G3 暂缓且每次UpdatePlan调用最多产生一条审计行被planSpecsEqualSet过滤后。自审批重置与审批模板重跑逻辑保持不变。当前仓库中的实际落点值得说明的是当前仓库中 plan_service.go 的实现已演进出另一种等价形式review.Workflow.UpdatePlan返回updateResult.Events其中review.PlanUpdatedEvent携带FromSpecs/ToSpecs再由UpdatePlan调用方遍历事件、在updateResult.Issue ! nil时写入IssueCommentPayload_PlanUpdatefor _, event : range updateResult.Events { planUpdated, ok : event.(review.PlanUpdatedEvent) if !ok || updateResult.Issue nil { continue } if _, err : s.store.CreateIssueComments(ctx, user.Email, store.IssueCommentMessage{ ProjectID: updateResult.Issue.ProjectID, IssueUID: updateResult.Issue.UID, Payload: storepb.IssueCommentPayload{ Event: storepb.IssueCommentPayload_PlanUpdate_{ PlanUpdate: storepb.IssueCommentPayload_PlanUpdate{ FromSpecs: planUpdated.FromSpecs, ToSpecs: planUpdated.ToSpecs, }, }, }, }); err ! nil { slog.Warn(failed to create plan spec audit issue comments, log.BBError(err)) } }这印证了快照机制的核心不变式无论发射逻辑如何组织变更前后两帧快照 非修饰性 diff 才落行始终成立。转换器复用convertToPlanSpecs在 backend/api/v1/issue_service_converter.go 中convertToIssueComment的 dispatch switch 从三个分支收敛为一个switch e : ic.Payload.Event.(type) { case *storepb.IssueCommentPayload_Approval_: r.Event convertToIssueCommentEventApproval(e) case *storepb.IssueCommentPayload_IssueUpdate_: r.Event convertToIssueCommentEventIssueUpdate(e) case *storepb.IssueCommentPayload_PlanUpdate_: projectID, _, _ : common.GetProjectIDIssueUID(issueName) r.Event convertToIssueCommentEventPlanUpdate(projectID, e) default: }并删除convertToIssueCommentEventPlanSpecUpdate/...PlanSpecAdd/...PlanSpecRemove三个函数替换为单个convertToIssueCommentEventPlanUpdate当前仓库 issue_service_converter.go 已有此函数func convertToIssueCommentEventPlanUpdate(projectID string, u *storepb.IssueCommentPayload_PlanUpdate_) *v1pb.IssueComment_PlanUpdate_ { return v1pb.IssueComment_PlanUpdate_{ PlanUpdate: v1pb.IssueComment_PlanUpdate{ FromSpecs: convertToPlanSpecs(projectID, u.PlanUpdate.GetFromSpecs()), ToSpecs: convertToPlanSpecs(projectID, u.PlanUpdate.GetToSpecs()), }, } }convertToPlanSpecs是既有计划详情页一直在用的存储层 → v1 转换器负责把 sha256 解析为 sheet 资源名此处直接复用不做重复实现。前端实现纯 diff helper 单一渲染分支IssueCommentType分类器收敛在 frontend/src/store/modules/v1/issueComment.ts 中枚举从 6 个成员收敛为 4 个export enum IssueCommentType { USER_COMMENT USER_COMMENT, APPROVAL APPROVAL, ISSUE_UPDATE ISSUE_UPDATE, PLAN_UPDATE PLAN_UPDATE, }getIssueCommentType的分类逻辑同步收敛只保留event?.case planUpdate一个分支export const getIssueCommentType ( issueComment: IssueComment ): IssueCommentType { if (issueComment.event?.case approval) { return IssueCommentType.APPROVAL; } else if (issueComment.event?.case issueUpdate) { return IssueCommentType.ISSUE_UPDATE; } else if (issueComment.event?.case planUpdate) { return IssueCommentType.PLAN_UPDATE; } return IssueCommentType.USER_COMMENT; };纯函数diffPlanSpecsdiff 逻辑从后端搬到前端落在 renderer 旁的纯 helper 中frontend/src/react/pages/project/issue-detail/utils/diffPlanSpecs.ts。其核心类型与函数签名export type SpecDiffEntry | { kind: added; spec: Plan_Spec } | { kind: removed; spec: Plan_Spec } | { kind: updated; specId: string; from: Plan_Spec; to: Plan_Spec; sheetChanged: boolean; targetsChanged: boolean; priorBackupChanged: boolean; otherChanged: boolean; }; export function diffPlanSpecs(from: Plan_Spec[], to: Plan_Spec[]): SpecDiffEntry[] { // 1. to 中有而 from 中没有的 id - added // 2. from 中有而 to 中没有的 id - removed // 3. 两侧都有的 id逐一比较 sheet / targets / priorBackup / other }实现细节值得展开specSheet/specTargets同时处理changeDatabaseConfig与exportDataConfig两种 config casearrayEqual做 targets 的逐位比较顺序敏感targets 是数组otherFieldsDiffer用于未知属性变更探测把 renderer 已特殊处理的字段清零后对 JSON 序列化结果做字符串比较任何残余差异即为未知属性变更输出顺序约定added→removed→updated且diffEntryKey生成稳定的 React keyadd:id/rm:id/up:id。otherChanged标志是向前兼容的兜底通道未来Spec新增了 renderer 尚未认识的字段时diff 依然能探测到变化UI 会走通用兜底行而不是静默丢弃审计信息。SpecDiffRow渲染组件IssueDetailCommentList.tsx中原来三个顺序if分支PLAN_SPEC_UPDATE/PLAN_SPEC_ADD/PLAN_SPEC_REMOVE被一个PLAN_UPDATE分支取代if ( commentType IssueCommentType.PLAN_UPDATE issueComment.event.case planUpdate ) { const { fromSpecs, toSpecs } issueComment.event.value; const entries diffPlanSpecs(fromSpecs, toSpecs); if (entries.length 0) return null; return ( div classNameflex w-full flex-col gap-1 {entries.map((entry) ( SpecDiffRow key{diffEntryKey(entry)} entry{entry} / ))} /div ); }新增的SpecDiffRow组件按entry.kind分派added复用SpecChangeRow渲染t(activity.sentence.added-spec)chip 可点击并联动?specid深链removed渲染t(activity.sentence.removed-spec)chip 不可点击spec 已从在线计划中消失updated按sheetChanged/targetsChanged/priorBackupChanged三个标志组装片段用joinFragments(fragments, t(common.and))连接。sheet 变化时复用IssueDetailStatementUpdateButton含单侧 sheet 兜底提供[View Details]diff 按钮targets 变化时展示db_added −db_removed的差集标注prior-backup 翻转时展示enabled/disabled prior backup onotherChanged兜底当fragments.length 0 entry.otherChanged时渲染通用 updated 行内置一个details折叠区展示from/to的原始 JSON diff。CommentActionIcon中三个图标分支也收敛为一个PLAN_UPDATE分支Pencil图标 CommentIconBadge未使用的Plus/Minus图标导入需要清理。复用的既有构建块来自 PR #20276快照渲染大量复用了该分支上已有的组件与设施无需重新实现SpecChangeRowchip trailing 槽位joinFragmentscommon.and连接的属性片段IssueDetailStatementUpdateButton含单侧 sheet 兜底?specid深链ProjectIssueDetailPage中 chip 点击内联选中 spec6 个 i18n keyadded、removed、modified-sql-of、changed-targets-of、enabled-prior-backup-on、disabled-prior-backup-onVue 与 React 两套 locale 树均已就绪Spec UUID 前缀 chip取前 8 字符跨增删变动保持稳定diff 查看器视口高度修复Monaco 铺满视口UpdateIssueno-op 守卫后端continue 前端saveTitle短路。关于specResourceName审计 payload 中的Plan_Spec.id是裸 UUID而SpecChangeRow期望完整资源名用于解析 chip。由于审计行所在 issue 页面已持有page.plangetSpecDisplayInfo按 spec id 与page.plan.specs匹配即可因此specResourceName可先以specs/${spec.id}形式传递并建议在落地前核对getSpecDisplayInfo的实现是否真的不依赖资源名前缀。测试策略从单元到集成到碰撞后端单元测试TestPlanSpecsEqualSet在 backend/api/v1/plan_service_test.go 中以表驱动方式覆盖 8 个子用例用例名场景期望both nil两侧均为 niltrueidentical single spec完全相同truesame set reordered同一集合重排trueadded spec新增一个 specfalseremoved spec删除一个 specfalsesame id sheet differs同 id、sheet 不同falsesame id targets differ同 id、targets 不同falsesame id prior_backup differs同 id、prior_backup 不同false辅助构造函数cdcSpec(id, sheet, targets, priorBackup)构建ChangeDatabaseConfig类型的 spec。运行方式go test -v -count1 github.com/bytebase/bytebase/backend/api/v1 -run ^TestPlanSpecsEqualSet$后端集成测试backend/tests/plan_update_test.go原 5 个场景测试全部保留断言重定向到新形状listPlanSpecAuditEvents重命名为listPlanUpdateEvents返回[]*v1pb.IssueComment_PlanUpdate。五个场景对应的断言变化Sheet 变更FromSpecs/ToSpecs各 1 个 specfrom.Sheet f.sheet1.Name、to.Sheet f.sheet2.Name新增 specFromSpecs1 个、ToSpecs2 个ToSpecs同时包含新旧 id删除 spec两次UpdatePlanseed add remove产生两条PlanUpdate行第二条的FromSpecs2 个、ToSpecs1 个targets 变更FromSpecs[0].Targets [db1]、ToSpecs[0].Targets [db1, db2]sheet 不变prior_backup 翻转from.enablePriorBackup false、to.enablePriorBackup true。TestPlanSpecAudit_NoIssue_NoEmission保持不变无 issue 时不发射审计a.Nil(f.issue, ...)断言与审计形状无关。新增两个测试TestPlanUpdate_ReorderOnly_NoEmission先 seed 两个 spec再仅交换顺序调用UpdatePlanUpdateMask.Paths: [specs]断言listPlanUpdateEvents仍只有 1 条seed 那条重排不产生审计行TestPlanUpdate_MultiSpec_OneRow一次UpdatePlan同时改 spec 1 的 sheet 并翻转 spec 2 的 prior_backup断言恰好 2 条PlanUpdate行seed 本次第二条的fromByID/toByID按 id 精确比对两个 spec 的各自变化。集成测试用go vet ./backend/tests/验证编译真实运行需要 Docker由 CI 执行。复合主键碰撞测试backend/tests/plan_audit_collision_test.go中的TestCollision_PlanSpecAuditEmission重定向project A 的 issue 应收到PlanUpdate审计行c.GetPlanUpdate() ! nilproject B 的快照不受影响。snapshotProject/assertProjectUnchanged对issue_comment行的扩展PR #20276 引入原样保留用于验证并发场景下复合主键写入不会互相污染。前端单元测试diffPlanSpecs.test.ts新建的 vitest 表驱动测试覆盖约 10 个用例empty/empty → []、单 added、单 removed、sheet 变更 →updated且sheetChanged: true、targets 变更、prior_backup 翻转、三者同时变更合并为一条updated、重排 →[]、混合 add remove update 输出[added, removed, updated]顺序、内容未变 →[]。运行方式pnpm --dir frontend test diffPlanSpecs 21 | tail -10手动 UI 验证清单按 CLAUDE.md 的 UI 规则需人工走查 8 个场景新增 spec→ 一行 Adela added Change id8chip 可点击并更新?specid、内联选中删除 spec→ 一行 Adela removed Change id8chip 不可点击仅改 SQLsheet→ 一行 Adela modified SQL of Change id8 [View Details]diff 按钮在 chip 后对话框铺满视口仅改 targets→ 一行 Adela changed targets of Change id8 db_added −db_removed翻转enable_prior_backup→ 一行 Adela enabled/disabled prior backup on Change id8一次保存改多个 spec→ 一条评论行内多视觉条目堆叠仅重排不改变其他→ 不出现新评论行标题原地自改autoblur 无编辑→ 不出现新评论行依赖UpdateIssueno-op 守卫。启动方式后端PG_URLpostgresql://bbdevlocalhost/bbdev go run ./backend/bin/server/main.go --port 8080 --data . --debug前端pnpm --dir frontend dev。存储成本内容寻址让快照保持廉价快照方案最常被质疑的是存储膨胀。设计文档给出的关键论据是sheet 通过sheet_sha256内容寻址快照不携带 SQL 字节。典型ChangeDatabaseConfigspec 序列化后不足 1KBprotojson一个 20 spec 的计划被编辑 100 次issue 时间线上累计约 4MBissue_commentpayloadJSONB 可轻松容纳。最坏情况单计划数百 spec 大量编辑会更大但仍在合理范围设计文档注明如有客户暴露问题再重新评估。迁移与落地force-push 重写 PR由于 PR #20276 的旧类型化实现尚未合入main本次快照重写直接在原分支feat/plan-spec-mutation-audit-a2上进行最终以--force-with-lease强推该 flag 在 origin 分支被他人意外移动时中止推送git push --force-with-lease origin feat/plan-spec-mutation-audit-a2字段号7存储层与12v1直接复用——线上无旧形状数据无需 reserved 声明。原有评审线程仍附着在 diff 上评审者需在查看重写后重新批准。边界、权衡与未来工作已确认的边界审计发射仍以issue ! nil门控——未关联 issue 的计划无审计行G3 暂缓未来由plan_audit表解决快照形状下 A2→A3 迁移近乎 1:1 的行拷贝spec id 是唯一身份标识——若用户在一次UpdatePlan中新增又立即删除同 UUID 的 spec时间线会显示两行渲染端可正确区分otherChanged兜底行的视觉表现可在后续打磨设计仅承诺渲染可辨识的内容而非静默丢弃。未来工作CreatePlan/DeletePlan发射——同一PlanUpdate形状天然支持空from_specs/to_specs重排审计——diff helper 已能探测重排只是当前选择过滤自审批资格 / 编辑器追踪——独立工作流解决BYT-9175的控制缺口。这一设计把可审计性从后端硬编码的按属性事件转变为后端无脑快照 前端灵活 diff的职责分离模型。对 Bytebase 而言它让未来的每次PlanConfig.Spec演进都自动获得审计覆盖对阅读本文的开发者而言这是一份可复用的事件形状设计决策模板当按属性拆分的审计事件越来越难以维护时before/after 快照 渲染端 diff 往往是以小博大、向前兼容的更优解。【免费下载链接】bytebaseDatabase governance built for humans and agents — controlling changes and access across every major database.项目地址: https://gitcode.com/GitHub_Trending/by/bytebase创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
上一篇/下一篇内容由系统自动关联 返回资讯列表 →