From 959a19f1e850eb187a0929021477d2b6fcdb1bbc Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Tue, 19 May 2026 18:11:12 +0800 Subject: [PATCH 01/11] feat: add release plan collaboration and versioned diffs Signed-off-by: huanghongbo-hhb --- ...e-plan-collaboration-and-versioned-diff.md | 411 ++++++++++++ .../common/repository/models/release_plan.go | 45 +- .../common/repository/mongodb/release_plan.go | 33 + .../repository/mongodb/release_plan_log.go | 53 +- .../mongodb/release_plan_version.go | 89 +++ .../core/release_plan/handler/release_plan.go | 81 +++ .../aslan/core/release_plan/handler/router.go | 4 + .../release_plan/service/collaboration.go | 517 +++++++++++++++ .../aslan/core/release_plan/service/diff.go | 616 ++++++++++++++++++ .../core/release_plan/service/masking.go | 199 ++++++ .../core/release_plan/service/openapi.go | 58 +- .../core/release_plan/service/release_plan.go | 261 ++++++-- .../release_plan/service/section_snapshot.go | 303 +++++++++ .../aslan/core/release_plan/service/update.go | 24 +- .../core/release_plan/service/version.go | 141 ++++ .../core/release_plan/service/watcher.go | 2 +- 16 files changed, 2751 insertions(+), 86 deletions(-) create mode 100644 community/rfc/2026-05-14-release-plan-collaboration-and-versioned-diff.md create mode 100644 pkg/microservice/aslan/core/common/repository/mongodb/release_plan_version.go create mode 100644 pkg/microservice/aslan/core/release_plan/service/collaboration.go create mode 100644 pkg/microservice/aslan/core/release_plan/service/diff.go create mode 100644 pkg/microservice/aslan/core/release_plan/service/masking.go create mode 100644 pkg/microservice/aslan/core/release_plan/service/section_snapshot.go create mode 100644 pkg/microservice/aslan/core/release_plan/service/version.go diff --git a/community/rfc/2026-05-14-release-plan-collaboration-and-versioned-diff.md b/community/rfc/2026-05-14-release-plan-collaboration-and-versioned-diff.md new file mode 100644 index 0000000000..98b4dba4a2 --- /dev/null +++ b/community/rfc/2026-05-14-release-plan-collaboration-and-versioned-diff.md @@ -0,0 +1,411 @@ +# 发布计划多人协作与版本化变更展示方案 + +- 作者:KodeRover +- 关联 Issue:TBD +- 日期:2026-05-14 +- 评审人:TBD +- 评审状态:pending + +## 目标 + +这次方案同时解决发布计划的两个需求: + +- 多人协作编辑:用户编辑发布计划时,可以看到有哪些人正在编辑哪些内容。 +- 操作记录细化:用户查看发布计划操作记录时,可以看到这次保存具体改了哪些工作流任务参数。 +- 版本记录:每次配置保存后记录一个版本,版本里只保存这次编辑区块的输入参数快照,后续查看“这次改了什么”时,用这次编辑的前后快照做对比。 + +## 一句话方案 + +不做强锁,也不阻止多人同时编辑。用户编辑时,前端实时展示“谁正在编辑什么”;用户保存后,后端记录一个“编辑区块输入参数版本”;用户点开操作记录详情时,后端比较这次编辑区块的前后快照,把变化整理成按发布内容和工作流任务分组的可读详情。 + +## 用户能看到什么 + +### 正在编辑提示 + +用户进入发布计划详情页后,不会默认显示“正在编辑”。只有当某个用户真正点击某块内容的编辑入口后,其他用户才会看到提示。 + +第一版建议展示这些编辑区块: + +- 基础信息:名称、负责人、发布窗口、定时执行、需求关联。 +- 审批配置。 +- 某一个发布内容。 + +示例: + +```text +huanghongbo 正在编辑基础信息 +patrick 正在编辑发布内容 log-test +2 人正在编辑审批配置 +``` + +### 操作记录详情 + +操作记录列表仍然先展示一句摘要: + +```text +huanghongbo 更新发布内容 log-test +``` + +用户点开详情后,再展示这次保存具体改了什么: + +```text +发布内容:log-test + +构建任务:build +- 代码分支:main -> release/202605 +- 镜像标签:v1.2.3 -> v1.2.4 + +Apollo 任务:update-config +- 命名空间:application -> application-prod +- DB_HOST:10.0.0.1 -> 10.0.0.2 + +DMS 任务:data-change +- SQL 内容:已变更 +``` + +大文本内容,比如脚本、SQL、大段 YAML 或 JSON,第一版默认只展示“已变更”,不在普通操作记录里展开全文。 + +敏感字段沿用工作流本身已有的敏感变量配置,例如 keyvault 的 `is_sensitive` 和工作流变量里的 `is_credential`,只展示“已变更”,不返回原始值。 + +## 前端需要支持什么 + +### 进入编辑态 + +- 用户打开发布计划详情页时,只建立 WebSocket 连接,不立即进入编辑态。 +- 用户点击某个编辑入口后,前端再告诉后端“我正在编辑这一块”。 +- 编辑区块建议和后端保持一致:`metadata`、`approval`、`job:`。 +- 页面上哪些状态可以编辑、哪些入口显示,由前端根据发布计划状态和权限判断;后端收到请求时会再做一次校验。 + +### 维护编辑会话 + +前端需要在一次区块编辑期间维护同一个 `session_id`: + +- 用户进入某个编辑区块时生成或获取一个 `session_id`。 +- 该编辑区块里的所有保存请求都带同一个 `session_id`。 +- 每 10 到 15 秒发送一次心跳,告诉后端“我还在编辑”。 +- 用户取消编辑、关闭弹窗、保存完成或切换编辑区块时,通知后端离开当前编辑态。 + +### 保存配置 + +现有 `verb + spec` 保存方式继续保留。前端在调用保存接口时,需要额外带上 `session_id`: + +```json +{ + "verb": "update_release_job", + "spec": {}, + "session_id": "uuid" +} +``` + +这样后端可以知道这几次 `verb` 保存属于同一次区块编辑。 + +### 提交版本 + +如果采用“按编辑区块合并版本”的方式,前端需要在一次区块编辑完成时调用版本提交接口: + +```json +{ + "session_id": "uuid", + "section_key": "job:job-id" +} +``` + +建议触发时机: + +- 用户点击编辑弹窗里的“保存”或“确定”。 +- 用户关闭编辑弹窗时,如果已经有变更,也需要触发提交。 +- 用户切换到另一个编辑区块前,如果当前区块已有变更,也需要先提交当前区块。 + +这样可以避免一个区块里多次 `verb` 保存生成多个版本。 + +### 展示版本差异 + +操作记录列表仍然先展示摘要。用户点开某条操作记录详情时: + +- 如果该记录包含 `from_version` 和 `to_version`,前端调用版本差异接口。 +- 前端按返回的 `groups` 渲染变更详情。 +- 大文本字段如果标记为 `large_text`,默认展示“已变更”。 +- 敏感字段如果在原始配置里标记为敏感变量,只展示“已变更”,不展示原始值。 +- 如果历史记录没有版本信息,前端继续按旧展示方式处理。 + +## 后端需要支持什么 + +### 实时协作编辑态 + +后端为每个浏览器编辑会话维护一条编辑记录。建议第一版的编辑粒度按内容块划分: + +- `metadata`:基础信息,比如名称、负责人、发布窗口、定时执行、需求关联。 +- `approval`:审批配置。 +- `job:`:某一个发布内容。 + +编辑记录建议包含: + +```go +type ReleasePlanEditingSession struct { + PlanID string `json:"plan_id"` + SessionID string `json:"session_id"` + UserID string `json:"user_id"` + UserName string `json:"user_name"` + Account string `json:"account"` + IdentityType string `json:"identity_type,omitempty"` + Avatar string `json:"avatar,omitempty"` + SectionKey string `json:"section_key"` + SectionType string `json:"section_type"` + SectionName string `json:"section_name"` + BaseVersion int64 `json:"base_version"` + EditingStartedAt int64 `json:"editing_started_at"` + LastHeartbeatAt int64 `json:"last_heartbeat_at"` +} +``` + +说明: + +- `BaseVersion` 表示用户开始编辑时看到的发布计划版本。第一版只用于前端提示,不用于阻断保存。 +- `SectionType` 表示正在编辑哪类内容,前端可以直接判断这是基础信息、审批还是发布内容。 +- `IdentityType` 和 `Avatar` 是可选增强字段,便于前端直接展示“谁正在编辑”。 +- `EditingStartedAt` 表示真正进入编辑态的时间,不等同于页面建立连接的时间。 + +编辑态使用 Redis 保存,并设置自动过期时间。这样即使浏览器异常关闭、断网、服务端连接断开,编辑态也会自动消失,不会一直显示“某人在编辑”。 + +如果 Aslan 是多副本部署,需要用 Redis 做一次“跨 Pod 通知”。某个 Pod 收到用户编辑态变化后,先写 Redis,再发一条 Redis 消息;其他 Pod 收到这条消息后,再推送给自己本地持有的 WebSocket 连接。这样连接在不同 Pod 上的用户也能互相看到编辑状态。 + +`hook` 外部系统配置本身不纳入第一版多人协作提示范围。外部 `hook` 触发后,发布计划可能进入“外部检测”阶段,这个阶段是否还能编辑,由前端决定是否展示编辑入口;后端只负责兜底校验。 + +### 保存与版本化 + +当前后端已经有统一的配置更新接口: + +- `PUT /api/release_plan/v1/:id` +- 请求体使用 `verb + spec` 表示“这次改的是哪一块内容” + +这一套机制建议继续保留,不需要为了版本化把它推翻重做。原因有三个: + +- 现有权限判断就是按 `verb` 分开的。 +- 现有操作记录也是按 `verb` 生成摘要。 +- 第一版版本化只需要挂在“配置保存成功”这个时机上,不要求接口形态改变。 + +保存规则保持“最后保存生效”: + +- 不加硬锁。 +- 不因为其他人正在编辑就拒绝保存。 +- 多人保存同一块内容时,以最后一次成功保存的结果为准。 + +### 版本和保存次数 + +这里需要和前端约定清楚: + +- 版本是按“成功保存一次配置”生成的,不是按“产生一条操作记录”生成的。 +- 配置类保存包括:名称、负责人、时间窗口、审批、发布内容增删改排等。 +- 流程类动作不生成配置版本,比如:状态流转、审批通过/拒绝、执行、重试、跳过、外部检测回调。 + +如果当前前端交互是“用户改一块、点一次保存、发一个 `verb` 请求”,那就自然是一条配置保存对应一个版本。 + +如果采用“按编辑区块合并版本”,则同一个 `session_id` 下的多次 `verb` 保存,最终合并成一个版本。 + +如果后面前端希望改成“整页统一保存”: + +- 可以把多个改动合并后一次提交。 +- 后端一次性应用这些改动。 +- 最终只生成一个版本。 + +这属于前端交互方式变化,不影响版本模型本身。第一版可以先兼容现有单 `verb` 保存模式,后续如果真的需要整页保存,再单独补一个批量保存接口。 + +### 版本生成时机 + +- 用户点击保存配置时,生成新的配置版本。 +- 审批通过、审批拒绝、进入执行、执行完成、外部检测等自动流转,默认只记录状态事件,不生成新的配置版本。 +- 如果未来某个系统流程会直接改动发布计划配置本身,再单独评估是否补充“系统生成版本”。 + +### 发布计划版本模型 + +新增发布计划版本集合。这里不保存完整发布计划,而是只保存“这次编辑区块的输入参数快照”。建议模型: + +```go +type ReleasePlanVersion struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + PlanID string `bson:"plan_id" json:"plan_id"` + BaseVersion int64 `bson:"base_version,omitempty" json:"base_version,omitempty"` + Version int64 `bson:"version" json:"version"` + Operator string `bson:"operator" json:"operator"` + Account string `bson:"account" json:"account"` + SectionKey string `bson:"section_key" json:"section_key"` + SectionName string `bson:"section_name" json:"section_name"` + Verb string `bson:"verb" json:"verb"` + BaseSnapshot interface{} `bson:"base_snapshot,omitempty" json:"base_snapshot,omitempty"` + Snapshot interface{} `bson:"snapshot" json:"snapshot"` + CreatedAt int64 `bson:"created_at" json:"created_at"` +} +``` + +说明: + +- `SectionKey` 表示这个版本对应哪个编辑区块,例如 `metadata`、`approval`、`job:`。 +- `BaseVersion` 表示这次编辑会话开始时看到的版本号。 +- `BaseSnapshot` 表示该区块开始编辑时的输入参数快照。 +- `Snapshot` 表示该区块保存完成后的输入参数快照。 + +快照里只保留输入参数,不保留运行态和执行态字段。例如: + +- 基础信息版本只保留名称、负责人、时间窗口、定时执行、需求关联、Jira 关联等输入项。 +- 审批版本只保留审批配置输入,不保留审批实例运行状态。 +- 发布内容版本只保留该发布内容的输入参数,不保留 `status`、`task_id`、`executed_by`、`executed_time` 这类运行字段。 + +这样可以避免因为单条发布计划过大导致版本体积和 diff 计算成本失控。 + +发布计划主表中也建议增加当前版本号: + +```go +Version int64 `bson:"version" json:"version"` +``` + +历史发布计划默认版本可以是 `0`。升级后第一次保存生成 `version = 1`。 + +### 操作日志关联版本 + +现有发布计划操作日志需要关联版本。建议给 `ReleasePlanLog` 增加: + +```go +FromVersion int64 `bson:"from_version,omitempty" json:"from_version,omitempty"` +ToVersion int64 `bson:"to_version,omitempty" json:"to_version,omitempty"` +``` + +操作记录列表仍然保持简洁,例如: + +```text +2026-05-14 10:00 huanghongbo 更新发布内容 log-test v12 -> v13 +``` + +用户点击详情时,前端使用 `plan_id`、`from_version`、`to_version` 请求版本差异。 + +这里的 `from_version` 不一定总是“上一个版本”。它表示这次编辑会话开始时看到的版本。 + +例如: + +- A 基于 `v1` 开始编辑某个发布内容。 +- B 先保存,生成 `v2`。 +- A 继续编辑后再保存,生成 `v3`。 + +那 A 这条操作记录应该关联: + +- `from_version = 1` +- `to_version = 3` + +这样点开详情时,看到的是这次编辑会话对应的区块变更区间,而不是简单的 `v2 -> v3`。 + +这里的版本和状态事件职责分开: + +- 版本主要回答“这次保存后的配置是什么”。 +- 操作日志和状态日志继续回答“发布计划后来经历了什么流转”。 +- 即使自动流转不生成版本,用户仍然可以从最近一次配置版本里查看当时保存下来的区块输入参数。 + +## 变更计算 + +变更计算负责比较一次编辑区块版本的前后快照。设计上先保证所有输入参数变化都能被找出来,再把结果整理成用户能看懂的字段名和分组。 + +第一版建议在用户查看详情时再计算变更内容,而不是在保存时就提前算好: + +- 保存成功时,只负责保存新版本、写操作日志、广播版本变更。 +- 用户点击某条操作记录详情时,后端再根据 `from_version` 和 `to_version` 读取该次编辑区块的前后快照并计算变更内容。 +- 第一版先不做变更结果缓存;如果后续确认详情打开比较慢,再单独评估缓存或后台提前计算。 + +处理流程: + +1. 读取 `to_version` 对应版本里的 `BaseSnapshot` 和 `Snapshot`。 +2. 从外到内逐层比较字段。 +3. 数组优先按“能代表这个元素身份的字段”匹配。 +4. 生成基础差异项。 +5. 对差异项做脱敏、分组和字段翻译。 + +### 对比前的数据整理 + +由于版本里保存的是区块输入参数快照,真正对比之前只需要再过滤少量不适合展示的字段,例如敏感信息和大文本字段,不需要再从整份运行对象里剔除执行状态。 + +### 数组匹配 + +数组不能总是按下标比较。能识别元素身份的数组,应该按身份字段对齐: + +```text +发布内容:id +工作流阶段:name +工作流任务:name + type +工作流参数:name +代码仓库:source + repo_namespace + repo_name + remote_name +构建服务:service_name + service_module +部署服务:service_name + service_module +key/value 数组:key +``` + +如果某类数组没有明确的身份字段,再退回按下标比较。 + +### 字段规则管理 + +字段中文名、忽略哪些字段、哪些字段需要脱敏,这些规则可以放在一张统一的规则表里管理。实现上可以用普通的路径映射表;如果规则特别多,再考虑用 `trie` 这种“按字段路径快速查规则”的结构。 + +这里的 `trie` 只适合做“某个字段路径该怎么处理”的查找,不适合拿来做两个版本内容是否有差异的核心计算。真正找出变化的步骤,还是靠前面的逐层比较。 + +## API 变更 + +### 协作态 + +```http +GET /api/aslan/release_plan/v1/:id/collaboration/ws +GET /api/aslan/release_plan/v1/:id/collaboration/editors +``` + +`editors` 用于页面初始化和 WebSocket 断线重连后的状态恢复。 + +### 版本 + +```http +GET /api/aslan/release_plan/v1/:id/versions +GET /api/aslan/release_plan/v1/:id/versions/:version +GET /api/aslan/release_plan/v1/:id/versions/:from/diff?to=:to +POST /api/aslan/release_plan/v1/:id/versions/commit +``` + +`commit` 接口用于告诉后端“这次区块编辑结束了,可以把这组变更合成一个版本”。 + +### 操作日志 + +现有日志接口保持不变,但返回值增加版本信息: + +```http +GET /api/aslan/release_plan/v1/:id/logs +``` + +每条日志可以包含 `from_version`、`to_version`。 + +## 向后兼容 + +- 现有发布计划 API 保存语义不变。 +- 历史发布计划默认版本号为 `0`。 +- 没有版本信息的历史操作日志仍按旧格式展示。 +- 升级后第一次保存生成第一个版本。 +- 版本差异展示是新增能力,不影响现有保存流程。 + +## 性能考虑 + +- 版本只保存编辑区块的输入参数快照,不保存整份发布计划运行对象。 +- 数组先按身份字段对齐后再比较,避免两两查找导致耗时变长。 +- 大文本字段在普通响应里只标记“已变更”,不做全文对比。 +- 如果一个内容块本身完全没变,就直接跳过,不继续往下比。 +- 用户点开操作详情时再计算变更内容,避免把比较耗时放到保存流程里。 +- 后续如果遇到大对象性能问题,可以先比较每个工作流任务输入参数是否变化;没变化的任务就不继续往下比。 +- 第一版先不做结果缓存,等确认真的有明显性能压力,再考虑补。 + +## 安全与隐私 + +- 敏感字段在返回前必须脱敏,脱敏依据沿用工作流自身已经配置好的敏感变量标记,不额外定义审批人、手机号之类的特殊字段规则。 +- 脱敏规则在生成基础差异项后、返回给前端前执行。 +- 不通过版本差异接口暴露敏感变量原始值。 +- WebSocket 接口需要复用发布计划查看/编辑权限校验。 + +## 实施拆分 + +虽然产品目标是一口气交付完整体验,工程实现仍建议按模块拆: + +1. 增加版本模型,并在保存成功后记录编辑区块的输入参数快照。 +2. 增加 WebSocket 协作态,使用 Redis 自动过期和跨 Pod 通知。 +3. 增加变更计算能力,支持数组按身份字段匹配和敏感字段脱敏。 +4. 增加翻译后的变更详情返回结构,并和操作日志关联。 +5. 首版尽量一次性覆盖已知字段中文标签,未知字段用处理后的字段路径兜底展示,但不改变核心返回格式。 diff --git a/pkg/microservice/aslan/core/common/repository/models/release_plan.go b/pkg/microservice/aslan/core/common/repository/models/release_plan.go index 7cdc25ed7f..f873e385ad 100644 --- a/pkg/microservice/aslan/core/common/repository/models/release_plan.go +++ b/pkg/microservice/aslan/core/common/repository/models/release_plan.go @@ -25,6 +25,7 @@ import ( type ReleasePlan struct { ID primitive.ObjectID `bson:"_id,omitempty" yaml:"-" json:"id"` Index int64 `bson:"index" yaml:"index" json:"index"` + Version int64 `bson:"version" yaml:"version" json:"version"` Name string `bson:"name" yaml:"name" json:"name"` Manager string `bson:"manager" yaml:"manager" json:"manager"` // ManagerID is the user id of the manager @@ -120,19 +121,41 @@ type WorkflowReleaseJobSpec struct { } type ReleasePlanLog struct { - ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` - PlanID string `bson:"plan_id" json:"plan_id"` - Username string `bson:"username" json:"username"` - Account string `bson:"account" json:"account"` - Verb string `bson:"verb" json:"verb"` - TargetName string `bson:"target_name" json:"target_name"` - TargetType string `bson:"target_type" json:"target_type"` - Before interface{} `bson:"before" json:"before"` - After interface{} `bson:"after" json:"after"` - Detail string `bson:"detail" json:"detail"` - CreatedAt int64 `bson:"created_at" json:"created_at"` + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + PlanID string `bson:"plan_id" json:"plan_id"` + SessionID string `bson:"session_id,omitempty" json:"session_id,omitempty"` + Username string `bson:"username" json:"username"` + Account string `bson:"account" json:"account"` + Verb string `bson:"verb" json:"verb"` + TargetName string `bson:"target_name" json:"target_name"` + TargetType string `bson:"target_type" json:"target_type"` + Before interface{} `bson:"before" json:"before"` + After interface{} `bson:"after" json:"after"` + Detail string `bson:"detail" json:"detail"` + FromVersion int64 `bson:"from_version,omitempty" json:"from_version,omitempty"` + ToVersion int64 `bson:"to_version,omitempty" json:"to_version,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` } func (ReleasePlanLog) TableName() string { return "release_plan_log" } + +type ReleasePlanVersion struct { + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + PlanID string `bson:"plan_id" json:"plan_id"` + BaseVersion int64 `bson:"base_version,omitempty" json:"base_version,omitempty"` + Version int64 `bson:"version" json:"version"` + Operator string `bson:"operator" json:"operator"` + Account string `bson:"account" json:"account"` + SectionKey string `bson:"section_key,omitempty" json:"section_key,omitempty"` + SectionName string `bson:"section_name,omitempty" json:"section_name,omitempty"` + Verb string `bson:"verb,omitempty" json:"verb,omitempty"` + BaseSnapshot interface{} `bson:"base_snapshot,omitempty" json:"base_snapshot,omitempty"` + Snapshot interface{} `bson:"snapshot" json:"snapshot"` + CreatedAt int64 `bson:"created_at" json:"created_at"` +} + +func (ReleasePlanVersion) TableName() string { + return "release_plan_version" +} diff --git a/pkg/microservice/aslan/core/common/repository/mongodb/release_plan.go b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan.go index 6c4bb0d328..cf9c37cc7f 100644 --- a/pkg/microservice/aslan/core/common/repository/mongodb/release_plan.go +++ b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan.go @@ -79,6 +79,10 @@ func (c *ReleasePlanColl) EnsureIndex(ctx context.Context) error { Keys: bson.M{"update_time": 1}, Options: options.Index().SetUnique(false), }, + { + Keys: bson.M{"version": 1}, + Options: options.Index().SetUnique(false), + }, } _, err := c.Indexes().CreateMany(ctx, mod, mongotool.CreateIndexOptions(ctx)) @@ -121,6 +125,35 @@ func (c *ReleasePlanColl) UpdateByID(ctx context.Context, idString string, args return err } +func (c *ReleasePlanColl) UpdateVersionByID(ctx context.Context, idString string, version int64) error { + id, err := primitive.ObjectIDFromHex(idString) + if err != nil { + return fmt.Errorf("invalid id") + } + + query := bson.M{"_id": id} + change := bson.M{"$set": bson.M{"version": version}} + _, err = c.UpdateOne(ctx, query, change) + return err +} + +func (c *ReleasePlanColl) IncrementVersionByID(ctx context.Context, idString string) (int64, error) { + id, err := primitive.ObjectIDFromHex(idString) + if err != nil { + return 0, fmt.Errorf("invalid id") + } + + query := bson.M{"_id": id} + change := bson.M{"$inc": bson.M{"version": 1}} + opts := options.FindOneAndUpdate().SetReturnDocument(options.After) + + result := new(models.ReleasePlan) + if err := c.FindOneAndUpdate(ctx, query, change, opts).Decode(result); err != nil { + return 0, err + } + return result.Version, nil +} + func (c *ReleasePlanColl) DeleteByID(ctx context.Context, idString string) error { id, err := primitive.ObjectIDFromHex(idString) if err != nil { diff --git a/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_log.go b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_log.go index c0e9f8d7e9..760c9c3739 100644 --- a/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_log.go +++ b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_log.go @@ -48,7 +48,17 @@ func (c *ReleasePlanLogColl) GetCollectionName() string { } func (c *ReleasePlanLogColl) EnsureIndex(ctx context.Context) error { - return nil + mod := []mongo.IndexModel{ + { + Keys: bson.D{{Key: "plan_id", Value: 1}, {Key: "created_at", Value: -1}}, + }, + { + Keys: bson.D{{Key: "session_id", Value: 1}}, + }, + } + + _, err := c.Indexes().CreateMany(ctx, mod, mongotool.CreateIndexOptions(ctx)) + return err } func (c *ReleasePlanLogColl) Create(args *models.ReleasePlanLog) error { @@ -76,7 +86,7 @@ func (c *ReleasePlanLogColl) ListByOptions(opt *ListReleasePlanLogOption) ([]*mo ctx := context.Background() opts := options.Find() if opt.IsSort { - opts.SetSort(bson.D{{"create_time", -1}}) + opts.SetSort(bson.D{{"created_at", -1}}) } if opt.PlanID != "" { query["plan_id"] = opt.PlanID @@ -94,3 +104,42 @@ func (c *ReleasePlanLogColl) ListByOptions(opt *ListReleasePlanLogOption) ([]*mo return resp, nil } + +func (c *ReleasePlanLogColl) FillVersionsBySessionID(planID, sessionID string, fromVersion, toVersion int64) error { + if sessionID == "" { + return errors.New("empty session id") + } + + query := bson.M{ + "plan_id": planID, + "session_id": sessionID, + "$or": []bson.M{ + {"to_version": bson.M{"$exists": false}}, + {"to_version": 0}, + }, + } + change := bson.M{"$set": bson.M{ + "from_version": fromVersion, + "to_version": toVersion, + }} + + _, err := c.UpdateMany(context.Background(), query, change) + return err +} + +func (c *ReleasePlanLogColl) CountPendingBySessionID(planID, sessionID string) (int64, error) { + if sessionID == "" { + return 0, errors.New("empty session id") + } + + query := bson.M{ + "plan_id": planID, + "session_id": sessionID, + "$or": []bson.M{ + {"to_version": bson.M{"$exists": false}}, + {"to_version": 0}, + }, + } + + return c.CountDocuments(context.Background(), query) +} diff --git a/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_version.go b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_version.go new file mode 100644 index 0000000000..5d90bf4e20 --- /dev/null +++ b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_version.go @@ -0,0 +1,89 @@ +/* + * Copyright 2026 The KodeRover Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mongodb + +import ( + "context" + + "github.com/pkg/errors" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + + "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" + mongotool "github.com/koderover/zadig/v2/pkg/tool/mongo" +) + +type ReleasePlanVersionColl struct { + *mongo.Collection + + coll string +} + +func NewReleasePlanVersionColl() *ReleasePlanVersionColl { + name := models.ReleasePlanVersion{}.TableName() + return &ReleasePlanVersionColl{ + Collection: mongotool.Database(config.MongoDatabase()).Collection(name), + coll: name, + } +} + +func (c *ReleasePlanVersionColl) GetCollectionName() string { + return c.coll +} + +func (c *ReleasePlanVersionColl) EnsureIndex(ctx context.Context) error { + mod := []mongo.IndexModel{ + { + Keys: bson.D{{Key: "plan_id", Value: 1}, {Key: "version", Value: 1}}, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{{Key: "plan_id", Value: 1}, {Key: "created_at", Value: -1}}, + }, + } + + _, err := c.Indexes().CreateMany(ctx, mod, mongotool.CreateIndexOptions(ctx)) + return err +} + +func (c *ReleasePlanVersionColl) Create(args *models.ReleasePlanVersion) error { + if args == nil { + return errors.New("nil ReleasePlanVersion") + } + + _, err := c.InsertOne(context.Background(), args) + return err +} + +func (c *ReleasePlanVersionColl) Get(planID string, version int64) (*models.ReleasePlanVersion, error) { + resp := new(models.ReleasePlanVersion) + err := c.FindOne(context.Background(), bson.M{ + "plan_id": planID, + "version": version, + }).Decode(resp) + return resp, err +} + +func (c *ReleasePlanVersionColl) GetLatest(planID string) (*models.ReleasePlanVersion, error) { + resp := new(models.ReleasePlanVersion) + err := c.FindOne(context.Background(), bson.M{ + "plan_id": planID, + }, options.FindOne().SetSort(bson.D{{Key: "version", Value: -1}})).Decode(resp) + return resp, err +} diff --git a/pkg/microservice/aslan/core/release_plan/handler/release_plan.go b/pkg/microservice/aslan/core/release_plan/handler/release_plan.go index e8ea19963d..5471eea658 100644 --- a/pkg/microservice/aslan/core/release_plan/handler/release_plan.go +++ b/pkg/microservice/aslan/core/release_plan/handler/release_plan.go @@ -19,6 +19,7 @@ package handler import ( "fmt" "strings" + "strconv" "github.com/gin-gonic/gin" @@ -78,6 +79,56 @@ func GetReleasePlanLogs(c *gin.Context) { ctx.Resp, ctx.RespErr = service.GetReleasePlanLogs(c.Param("id")) } +func GetReleasePlanCollaborationEditors(c *gin.Context) { + ctx, err := internalhandler.NewContextWithAuthorization(c) + defer func() { internalhandler.JSONResponse(c, ctx) }() + + if err != nil { + ctx.Logger.Errorf("failed to generate authorization info for user: %s, error: %s", ctx.UserID, err) + ctx.RespErr = fmt.Errorf("authorization Info Generation failed: err %s", err) + ctx.UnAuthorized = true + return + } + + if !ctx.Resources.IsSystemAdmin && !ctx.Resources.SystemActions.ReleasePlan.View { + ctx.UnAuthorized = true + return + } + + err = commonutil.CheckZadigEnterpriseLicense() + if err != nil { + ctx.RespErr = err + return + } + + ctx.Resp, ctx.RespErr = service.GetReleasePlanCollaborationEditors(c.Param("id")) +} + +func ReleasePlanCollaborationWS(c *gin.Context) { + ctx, err := internalhandler.NewContextWithAuthorization(c) + defer func() { internalhandler.JSONResponse(c, ctx) }() + + if err != nil { + ctx.Logger.Errorf("failed to generate authorization info for user: %s, error: %s", ctx.UserID, err) + ctx.RespErr = fmt.Errorf("authorization Info Generation failed: err %s", err) + ctx.UnAuthorized = true + return + } + + if !ctx.Resources.IsSystemAdmin && !ctx.Resources.SystemActions.ReleasePlan.View { + ctx.UnAuthorized = true + return + } + + err = commonutil.CheckZadigEnterpriseLicense() + if err != nil { + ctx.RespErr = err + return + } + + ctx.RespErr = service.OpenReleasePlanCollaborationWS(c, ctx, c.Param("id")) +} + func CreateReleasePlan(c *gin.Context) { ctx, err := internalhandler.NewContextWithAuthorization(c) defer func() { internalhandler.JSONResponse(c, ctx) }() @@ -189,6 +240,36 @@ func UpdateReleasePlan(c *gin.Context) { ctx.RespErr = service.UpdateReleasePlan(ctx, c.Param("id"), req) } +func GetReleasePlanVersionDiff(c *gin.Context) { + ctx, err := internalhandler.NewContextWithAuthorization(c) + defer func() { internalhandler.JSONResponse(c, ctx) }() + + if err != nil { + ctx.RespErr = fmt.Errorf("authorization Info Generation failed: err %s", err) + ctx.UnAuthorized = true + return + } + + if !ctx.Resources.IsSystemAdmin && !ctx.Resources.SystemActions.ReleasePlan.View { + ctx.UnAuthorized = true + return + } + + err = commonutil.CheckZadigEnterpriseLicense() + if err != nil { + ctx.RespErr = err + return + } + + version, err := strconv.ParseInt(c.Param("version"), 10, 64) + if err != nil { + ctx.RespErr = e.ErrInvalidParam.AddDesc(err.Error()) + return + } + + ctx.Resp, ctx.RespErr = service.GetReleasePlanVersionDiff(c.Param("id"), version) +} + func GetReleasePlanJobDetail(c *gin.Context) { ctx, err := internalhandler.NewContextWithAuthorization(c) defer func() { internalhandler.JSONResponse(c, ctx) }() diff --git a/pkg/microservice/aslan/core/release_plan/handler/router.go b/pkg/microservice/aslan/core/release_plan/handler/router.go index f75f4aefc4..e06142d253 100644 --- a/pkg/microservice/aslan/core/release_plan/handler/router.go +++ b/pkg/microservice/aslan/core/release_plan/handler/router.go @@ -28,7 +28,11 @@ func (*Router) Inject(router *gin.RouterGroup) { v1.POST("/:id/copy", CopyReleasePlan) v1.GET("/:id", GetReleasePlan) v1.GET("/:id/logs", GetReleasePlanLogs) + v1.GET("/:id/collaboration/editors", GetReleasePlanCollaborationEditors) + v1.GET("/:id/collaboration/ws", ReleasePlanCollaborationWS) v1.PUT("/:id", UpdateReleasePlan) + v1.POST("/:id/versions/commit", CommitReleasePlanVersion) + v1.GET("/:id/versions/:fromVersion/:toVersion/diff", GetReleasePlanVersionDiff) v1.GET("/:id/job/:jobID", GetReleasePlanJobDetail) v1.DELETE("/:id", DeleteReleasePlan) diff --git a/pkg/microservice/aslan/core/release_plan/service/collaboration.go b/pkg/microservice/aslan/core/release_plan/service/collaboration.go new file mode 100644 index 0000000000..2f6948e604 --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/collaboration.go @@ -0,0 +1,517 @@ +/* + * Copyright 2026 The KodeRover Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package service + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sort" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/pkg/errors" + + configbase "github.com/koderover/zadig/v2/pkg/config" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" + "github.com/koderover/zadig/v2/pkg/shared/handler" + "github.com/koderover/zadig/v2/pkg/tool/cache" + e "github.com/koderover/zadig/v2/pkg/tool/errors" + "github.com/koderover/zadig/v2/pkg/tool/log" + "github.com/koderover/zadig/v2/pkg/util" +) + +const ( + releasePlanCollabSessionKeyPrefix = "release-plan:collab:session:" + releasePlanCollabPlanSetPrefix = "release-plan:collab:plan:" + releasePlanCollabBroadcastChannel = "release-plan-collaboration" + releasePlanCollabSessionTTL = 45 * time.Second + releasePlanCollabBroadcastTTL = 5 * time.Minute +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +type ReleasePlanEditingSession struct { + PlanID string `json:"plan_id"` + SessionID string `json:"session_id"` + UserID string `json:"user_id"` + UserName string `json:"user_name"` + Account string `json:"account"` + IdentityType string `json:"identity_type,omitempty"` + Avatar string `json:"avatar,omitempty"` + SectionKey string `json:"section_key"` + SectionType string `json:"section_type"` + SectionName string `json:"section_name"` + BaseVersion int64 `json:"base_version"` + BaseSnapshot string `json:"base_snapshot,omitempty"` + EditingStartedAt int64 `json:"editing_started_at"` + LastHeartbeatAt int64 `json:"last_heartbeat_at"` +} + +type ReleasePlanCollaborationGroup struct { + SectionKey string `json:"section_key"` + SectionType string `json:"section_type"` + SectionName string `json:"section_name"` + Editors []*ReleasePlanEditingSession `json:"editors"` +} + +type ReleasePlanCollaborationSnapshot struct { + PlanID string `json:"plan_id"` + PlanVersion int64 `json:"plan_version"` + Groups []*ReleasePlanCollaborationGroup `json:"groups"` +} + +type releasePlanCollabWSMessage struct { + Type string `json:"type"` + SessionID string `json:"session_id,omitempty"` + SectionKey string `json:"section_key,omitempty"` + SectionType string `json:"section_type,omitempty"` + SectionName string `json:"section_name,omitempty"` + BaseVersion int64 `json:"base_version,omitempty"` +} + +type releasePlanCollabWSOutbound struct { + Type string `json:"type"` + Snapshot *ReleasePlanCollaborationSnapshot `json:"snapshot,omitempty"` + Error string `json:"error,omitempty"` +} + +type collaborationClient struct { + planID string + conn *websocket.Conn + send chan []byte +} + +var collaborationHub = struct { + sync.RWMutex + clients map[string]map[*collaborationClient]struct{} +}{ + clients: map[string]map[*collaborationClient]struct{}{}, +} + +var collaborationLoopOnce sync.Once + +func ensureReleasePlanCollaborationLoop() { + collaborationLoopOnce.Do(func() { + util.Go(func() { + ch, closeFn := cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()).Subscribe(releasePlanCollabBroadcastChannel) + defer closeFn() + + for msg := range ch { + planID := strings.TrimSpace(msg.Payload) + if planID == "" { + continue + } + broadcastReleasePlanCollaborationSnapshot(planID) + } + }) + }) +} + +func releasePlanCollabSessionKey(sessionID string) string { + return releasePlanCollabSessionKeyPrefix + sessionID +} + +func releasePlanCollabPlanSetKey(planID string) string { + return fmt.Sprintf("%s%s:sessions", releasePlanCollabPlanSetPrefix, planID) +} + +func broadcastReleasePlanCollaboration(planID string) { + if planID == "" { + return + } + _ = cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()).Publish(releasePlanCollabBroadcastChannel, planID) +} + +func registerCollaborationClient(planID string, client *collaborationClient) { + collaborationHub.Lock() + defer collaborationHub.Unlock() + + if _, exists := collaborationHub.clients[planID]; !exists { + collaborationHub.clients[planID] = make(map[*collaborationClient]struct{}) + } + collaborationHub.clients[planID][client] = struct{}{} +} + +func unregisterCollaborationClient(planID string, client *collaborationClient) { + collaborationHub.Lock() + defer collaborationHub.Unlock() + + if _, exists := collaborationHub.clients[planID]; !exists { + return + } + delete(collaborationHub.clients[planID], client) + if len(collaborationHub.clients[planID]) == 0 { + delete(collaborationHub.clients, planID) + } +} + +func sendSnapshotToLocalClients(planID string, snapshot *ReleasePlanCollaborationSnapshot) { + if snapshot == nil { + return + } + payload, err := json.Marshal(&releasePlanCollabWSOutbound{ + Type: "snapshot", + Snapshot: snapshot, + }) + if err != nil { + return + } + + collaborationHub.RLock() + clients := make([]*collaborationClient, 0, len(collaborationHub.clients[planID])) + for client := range collaborationHub.clients[planID] { + clients = append(clients, client) + } + collaborationHub.RUnlock() + + for _, client := range clients { + select { + case client.send <- payload: + default: + _ = client.conn.Close() + } + } +} + +func queueCollaborationClientMessage(client *collaborationClient, outbound *releasePlanCollabWSOutbound) { + if client == nil || outbound == nil { + return + } + payload, err := json.Marshal(outbound) + if err != nil { + return + } + select { + case client.send <- payload: + default: + } +} + +func broadcastReleasePlanCollaborationSnapshot(planID string) { + snapshot, err := GetReleasePlanCollaborationSnapshot(planID) + if err != nil { + log.Errorf("get release plan collaboration snapshot error: %v", err) + return + } + sendSnapshotToLocalClients(planID, snapshot) +} + +func GetReleasePlanCollaborationEditors(planID string) (*ReleasePlanCollaborationSnapshot, error) { + return GetReleasePlanCollaborationSnapshot(planID) +} + +func GetReleasePlanCollaborationSnapshot(planID string) (*ReleasePlanCollaborationSnapshot, error) { + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + plan, err := mongodb.NewReleasePlanColl().GetByID(ctx, planID) + if err != nil { + return nil, errors.Wrap(err, "get plan") + } + + editors, err := listActiveReleasePlanEditingSessions(planID) + if err != nil { + return nil, err + } + + groupMap := map[string]*ReleasePlanCollaborationGroup{} + groupOrder := make([]string, 0) + for _, session := range editors { + key := session.SectionKey + group, exists := groupMap[key] + if !exists { + group = &ReleasePlanCollaborationGroup{ + SectionKey: session.SectionKey, + SectionType: session.SectionType, + SectionName: session.SectionName, + Editors: make([]*ReleasePlanEditingSession, 0), + } + groupMap[key] = group + groupOrder = append(groupOrder, key) + } + group.Editors = append(group.Editors, session) + } + + sort.Strings(groupOrder) + resp := make([]*ReleasePlanCollaborationGroup, 0, len(groupOrder)) + for _, key := range groupOrder { + resp = append(resp, groupMap[key]) + } + + return &ReleasePlanCollaborationSnapshot{ + PlanID: planID, + PlanVersion: plan.Version, + Groups: resp, + }, nil +} + +func listActiveReleasePlanEditingSessions(planID string) ([]*ReleasePlanEditingSession, error) { + redisCache := cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()) + sessionIDs, err := redisCache.ListSetMembers(releasePlanCollabPlanSetKey(planID)) + if err != nil { + return nil, err + } + + resp := make([]*ReleasePlanEditingSession, 0, len(sessionIDs)) + for _, sessionID := range sessionIDs { + value, err := redisCache.GetString(releasePlanCollabSessionKey(sessionID)) + if err != nil { + continue + } + session := new(ReleasePlanEditingSession) + if err := json.Unmarshal([]byte(value), session); err != nil { + continue + } + if session.PlanID != planID { + continue + } + session.BaseSnapshot = "" + resp = append(resp, session) + } + + sort.Slice(resp, func(i, j int) bool { + if resp[i].SectionKey == resp[j].SectionKey { + return resp[i].EditingStartedAt < resp[j].EditingStartedAt + } + return resp[i].SectionKey < resp[j].SectionKey + }) + + return resp, nil +} + +func persistReleasePlanEditingSession(session *ReleasePlanEditingSession) error { + if session == nil { + return errors.New("nil editing session") + } + if session.PlanID == "" || session.SessionID == "" { + return errors.New("missing session id or plan id") + } + if session.EditingStartedAt == 0 { + session.EditingStartedAt = time.Now().Unix() + } + session.LastHeartbeatAt = time.Now().Unix() + + payload, err := json.Marshal(session) + if err != nil { + return err + } + + redisCache := cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()) + if err := redisCache.Write(releasePlanCollabSessionKey(session.SessionID), string(payload), releasePlanCollabSessionTTL); err != nil { + return err + } + if err := redisCache.AddElementsToSet(releasePlanCollabPlanSetKey(session.PlanID), []string{session.SessionID}, releasePlanCollabBroadcastTTL); err != nil { + return err + } + broadcastReleasePlanCollaboration(session.PlanID) + return nil +} + +func removeReleasePlanEditingSession(planID, sessionID string) error { + redisCache := cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()) + if err := redisCache.Delete(releasePlanCollabSessionKey(sessionID)); err != nil { + return err + } + if err := redisCache.RemoveElementsFromSet(releasePlanCollabPlanSetKey(planID), []string{sessionID}); err != nil { + return err + } + broadcastReleasePlanCollaboration(planID) + return nil +} + +func authorizeReleasePlanEditing(ctx *handler.Context, sectionType string) bool { + if ctx.Resources.IsSystemAdmin { + return true + } + switch sectionType { + case "metadata": + return ctx.Resources.SystemActions.ReleasePlan.EditMetadata + case "approval": + return ctx.Resources.SystemActions.ReleasePlan.EditApproval + case "job": + return ctx.Resources.SystemActions.ReleasePlan.EditSubtasks + default: + return false + } +} + +func validateReleasePlanEditingPlan(plan *models.ReleasePlan) error { + if plan == nil { + return errors.New("nil plan") + } + if plan.Status != config.ReleasePlanStatusPlanning { + return errors.Errorf("plan status is %s, can not edit", plan.Status) + } + return nil +} + +func getReleasePlanEditingSession(planID, sessionID string) (*ReleasePlanEditingSession, error) { + if sessionID == "" { + return nil, errors.New("empty session id") + } + value, err := cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()).GetString(releasePlanCollabSessionKey(sessionID)) + if err != nil { + return nil, err + } + session := new(ReleasePlanEditingSession) + if err := json.Unmarshal([]byte(value), session); err != nil { + return nil, err + } + if session.PlanID != planID { + return nil, errors.New("session does not belong to current plan") + } + return session, nil +} + +func OpenReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, planID string) error { + return openReleasePlanCollaborationWS(gCtx, ctx, planID) +} + +func releasePlanSnapshotString(plan *models.ReleasePlan, sectionKey string) string { + if plan == nil { + return "" + } + sectionSnapshot, err := buildReleasePlanVersionSnapshot(plan, sectionKey) + if err != nil { + return "" + } + return encodeReleasePlanVersionSnapshot(sectionSnapshot) +} + +func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, planID string) error { + ws, err := upgrader.Upgrade(gCtx.Writer, gCtx.Request, nil) + if err != nil { + return e.ErrInvalidParam.AddErr(err) + } + defer ws.Close() + + ensureReleasePlanCollaborationLoop() + + client := &collaborationClient{ + planID: planID, + conn: ws, + send: make(chan []byte, 16), + } + registerCollaborationClient(planID, client) + defer unregisterCollaborationClient(planID, client) + + done := make(chan struct{}) + util.Go(func() { + defer close(done) + for { + _, payload, err := ws.ReadMessage() + if err != nil { + return + } + + msg := new(releasePlanCollabWSMessage) + if err := json.Unmarshal(payload, msg); err != nil { + continue + } + + switch msg.Type { + case "join", "focus_section", "heartbeat": + if !authorizeReleasePlanEditing(ctx, msg.SectionType) { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: "permission denied"}) + continue + } + plan, err := mongodb.NewReleasePlanColl().GetByID(context.Background(), planID) + if err != nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) + continue + } + if err := validateReleasePlanEditingPlan(plan); err != nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) + continue + } + existingSession, _ := getReleasePlanEditingSession(planID, msg.SessionID) + session := &ReleasePlanEditingSession{ + PlanID: planID, + SessionID: msg.SessionID, + UserID: ctx.UserID, + UserName: ctx.UserName, + Account: ctx.Account, + IdentityType: ctx.IdentityType, + SectionKey: msg.SectionKey, + SectionType: msg.SectionType, + SectionName: msg.SectionName, + BaseVersion: msg.BaseVersion, + BaseSnapshot: releasePlanSnapshotString(plan, msg.SectionKey), + EditingStartedAt: time.Now().Unix(), + } + if existingSession != nil { + session.EditingStartedAt = existingSession.EditingStartedAt + if existingSession.BaseSnapshot != "" { + session.BaseSnapshot = existingSession.BaseSnapshot + } + if session.BaseVersion == 0 { + session.BaseVersion = existingSession.BaseVersion + } + if existingSession.SectionKey != "" && existingSession.SectionKey != msg.SectionKey { + session.EditingStartedAt = time.Now().Unix() + session.BaseSnapshot = releasePlanSnapshotString(plan, msg.SectionKey) + } + } + if err := persistReleasePlanEditingSession(session); err != nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) + continue + } + snapshot, err := GetReleasePlanCollaborationSnapshot(planID) + if err == nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "snapshot", Snapshot: snapshot}) + } + case "leave": + if err := removeReleasePlanEditingSession(planID, msg.SessionID); err != nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) + } + } + } + }) + + util.Go(func() { + for { + select { + case payload := <-client.send: + if err := ws.WriteMessage(websocket.TextMessage, payload); err != nil { + return + } + case <-done: + return + } + } + }) + + snapshot, err := GetReleasePlanCollaborationSnapshot(planID) + if err == nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "snapshot", Snapshot: snapshot}) + } + + <-done + return nil +} diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go new file mode 100644 index 0000000000..d083e525e6 --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -0,0 +1,616 @@ +/* + * Copyright 2026 The KodeRover Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package service + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "reflect" + "sort" + "strings" + + "github.com/pkg/errors" + + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" +) + +const ( + releasePlanHashPruneMinMapKeys = 4 + releasePlanHashPruneMinArrayItems = 4 +) + +type ReleasePlanVersionDiffResponse struct { + PlanID string `json:"plan_id"` + FromVersion int64 `json:"from_version"` + ToVersion int64 `json:"to_version"` + Groups []*ReleasePlanVersionDiffGroup `json:"groups"` +} + +type ReleasePlanVersionDiffGroup struct { + GroupKey string `json:"group_key"` + GroupName string `json:"group_name"` + GroupType string `json:"group_type"` + Changes []*ReleasePlanVersionDiffChange `json:"changes"` +} + +type ReleasePlanVersionDiffChange struct { + TaskName string `json:"task_name,omitempty"` + TaskType string `json:"task_type,omitempty"` + Path string `json:"path"` + Label string `json:"label"` + Before interface{} `json:"before,omitempty"` + After interface{} `json:"after,omitempty"` + LargeText bool `json:"large_text,omitempty"` + Masked bool `json:"masked,omitempty"` +} + +type releasePlanRawDiffEntry struct { + Path string + Before interface{} + After interface{} +} + +var releasePlanFieldLabels = map[string]string{ + "name": "名称", + "manager": "负责人", + "manager_id": "负责人 ID", + "start_time": "开始时间", + "end_time": "结束时间", + "schedule_execute_time": "定时执行时间", + "description": "需求关联", + "approval": "审批配置", + "type": "类型", + "enabled": "是否启用", + "content": "内容", + "remark": "备注", + "branch": "代码分支", + "tag": "Tag", + "pr": "PR", + "repo_name": "仓库名称", + "repo_namespace": "仓库命名空间", + "remote_name": "远端名称", + "job_name": "任务名称", + "build_name": "构建名称", + "service_name": "服务名称", + "service_module": "服务组件", + "image": "镜像", + "image_name": "镜像名称", + "namespace": "命名空间", + "env": "环境", + "cluster_id": "集群", + "cluster_source": "集群来源", + "target": "目标", + "targets": "目标列表", + "key_vals": "变量", + "key": "变量名", + "value": "变量值", + "params": "参数", + "stages": "阶段", + "jobs": "任务", + "script": "脚本内容", + "sql": "SQL 内容", + "manual_exec_users": "人工执行用户", + "approve_users": "审批人", + "approval_nodes": "审批节点", + "services": "服务", + "service_and_builds": "构建对象", + "default_service_and_builds": "默认构建对象", + "repos": "代码仓库", + "workflow": "工作流", + "native_approval": "原生审批", + "lark_approval": "飞书审批", + "dingtalk_approval": "钉钉审批", + "workwx_approval": "企业微信审批", +} + +func GetReleasePlanVersionDiff(planID string, fromVersion, toVersion int64) (*ReleasePlanVersionDiffResponse, error) { + to, err := mongodb.NewReleasePlanVersionColl().Get(planID, toVersion) + if err != nil { + return nil, errors.Wrap(err, "get to version") + } + + var fromData map[string]interface{} + var toData map[string]interface{} + groupKey, groupName, groupType := releasePlanVersionDiffGroup(to.SectionKey, to.SectionName) + + if to.BaseVersion == fromVersion { + fromData, err = toGenericMap(to.BaseSnapshot) + if err != nil { + return nil, errors.Wrap(err, "convert base snapshot") + } + toData, err = toGenericMap(to.Snapshot) + if err != nil { + return nil, errors.Wrap(err, "convert current snapshot") + } + } else { + if fromVersion == 0 { + return nil, errors.Errorf("release plan baseline diff v0 -> v%d is not available after subsequent version commits", toVersion) + } + from, err := mongodb.NewReleasePlanVersionColl().Get(planID, fromVersion) + if err != nil { + return nil, errors.Wrap(err, "get from version") + } + fromData, err = toGenericMap(from.Snapshot) + if err != nil { + return nil, errors.Wrap(err, "convert from snapshot") + } + toData, err = toGenericMap(to.Snapshot) + if err != nil { + return nil, errors.Wrap(err, "convert to snapshot") + } + } + + rawEntries := make([]*releasePlanRawDiffEntry, 0) + diffReleasePlanValues("", fromData, toData, &rawEntries) + + groupMap := map[string]*ReleasePlanVersionDiffGroup{} + groupOrder := make([]string, 0) + for _, entry := range rawEntries { + if shouldIgnoreReleasePlanDiffPath(entry.Path) { + continue + } + taskName, taskType := classifyReleasePlanDiffTask(entry.Path) + group, exists := groupMap[groupKey] + if !exists { + group = &ReleasePlanVersionDiffGroup{ + GroupKey: groupKey, + GroupName: groupName, + GroupType: groupType, + Changes: make([]*ReleasePlanVersionDiffChange, 0), + } + groupMap[groupKey] = group + groupOrder = append(groupOrder, groupKey) + } + + change := &ReleasePlanVersionDiffChange{ + TaskName: taskName, + TaskType: taskType, + Path: entry.Path, + Label: buildReleasePlanDiffLabel(entry.Path), + } + if isMaskedReleasePlanDiffValue(entry.Before) || isMaskedReleasePlanDiffValue(entry.After) { + change.Masked = true + } else if isLargeTextReleasePlanDiffPath(entry.Path, entry.Before, entry.After) { + change.LargeText = true + } else { + change.Before = normalizeReleasePlanDiffValue(entry.Before) + change.After = normalizeReleasePlanDiffValue(entry.After) + } + group.Changes = append(group.Changes, change) + } + + sort.Strings(groupOrder) + groups := make([]*ReleasePlanVersionDiffGroup, 0, len(groupOrder)) + for _, key := range groupOrder { + group := groupMap[key] + sort.Slice(group.Changes, func(i, j int) bool { + return group.Changes[i].Path < group.Changes[j].Path + }) + groups = append(groups, group) + } + + return &ReleasePlanVersionDiffResponse{ + PlanID: planID, + FromVersion: fromVersion, + ToVersion: toVersion, + Groups: groups, + }, nil +} + +func toGenericMap(value interface{}) (map[string]interface{}, error) { + if value == nil { + return map[string]interface{}{}, nil + } + payload, err := json.Marshal(value) + if err != nil { + return nil, err + } + resp := map[string]interface{}{} + if err := json.Unmarshal(payload, &resp); err != nil { + return nil, err + } + return resp, nil +} + +func diffReleasePlanValues(path string, left, right interface{}, entries *[]*releasePlanRawDiffEntry) { + if shouldIgnoreReleasePlanDiffPath(path) { + return + } + + if equal, hashed := equalReleasePlanSubtreeByHash(left, right); hashed { + if equal { + return + } + } else if reflect.DeepEqual(left, right) { + return + } + + leftMap, leftIsMap := left.(map[string]interface{}) + rightMap, rightIsMap := right.(map[string]interface{}) + if leftIsMap || rightIsMap { + keys := make([]string, 0) + keySet := map[string]struct{}{} + for key := range leftMap { + keySet[key] = struct{}{} + } + for key := range rightMap { + keySet[key] = struct{}{} + } + for key := range keySet { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + nextPath := joinReleasePlanDiffPath(path, key) + diffReleasePlanValues(nextPath, leftMap[key], rightMap[key], entries) + } + return + } + + leftList, leftIsList := left.([]interface{}) + rightList, rightIsList := right.([]interface{}) + if leftIsList || rightIsList { + diffReleasePlanArray(path, leftList, rightList, entries) + return + } + + *entries = append(*entries, &releasePlanRawDiffEntry{ + Path: path, + Before: left, + After: right, + }) +} + +func equalReleasePlanSubtreeByHash(left, right interface{}) (equal bool, hashed bool) { + if !shouldUseReleasePlanSubtreeHash(left, right) { + return false, false + } + + leftHash, err := hashReleasePlanSubtree(left) + if err != nil { + return false, false + } + rightHash, err := hashReleasePlanSubtree(right) + if err != nil { + return false, false + } + return leftHash == rightHash, true +} + +func shouldUseReleasePlanSubtreeHash(left, right interface{}) bool { + switch leftValue := left.(type) { + case map[string]interface{}: + rightValue, ok := right.(map[string]interface{}) + if !ok { + return false + } + return len(leftValue) >= releasePlanHashPruneMinMapKeys || len(rightValue) >= releasePlanHashPruneMinMapKeys + case []interface{}: + rightValue, ok := right.([]interface{}) + if !ok { + return false + } + return len(leftValue) >= releasePlanHashPruneMinArrayItems || len(rightValue) >= releasePlanHashPruneMinArrayItems + default: + return false + } +} + +func hashReleasePlanSubtree(value interface{}) (string, error) { + payload, err := json.Marshal(value) + if err != nil { + return "", err + } + sum := sha256.Sum256(payload) + return hex.EncodeToString(sum[:]), nil +} + +func diffReleasePlanArray(path string, left, right []interface{}, entries *[]*releasePlanRawDiffEntry) { + leftMap, leftOrdered, leftMapped := buildReleasePlanArrayMap(left) + rightMap, rightOrdered, rightMapped := buildReleasePlanArrayMap(right) + if leftMapped && rightMapped { + keySet := map[string]struct{}{} + keys := make([]string, 0) + for _, key := range leftOrdered { + if _, exists := keySet[key]; !exists { + keySet[key] = struct{}{} + keys = append(keys, key) + } + } + for _, key := range rightOrdered { + if _, exists := keySet[key]; !exists { + keySet[key] = struct{}{} + keys = append(keys, key) + } + } + for _, key := range keys { + nextPath := fmt.Sprintf("%s[%s]", path, key) + diffReleasePlanValues(nextPath, leftMap[key], rightMap[key], entries) + } + return + } + + maxLen := len(left) + if len(right) > maxLen { + maxLen = len(right) + } + for i := 0; i < maxLen; i++ { + nextPath := fmt.Sprintf("%s[%d]", path, i) + var leftVal, rightVal interface{} + if i < len(left) { + leftVal = left[i] + } + if i < len(right) { + rightVal = right[i] + } + diffReleasePlanValues(nextPath, leftVal, rightVal, entries) + } +} + +func buildReleasePlanArrayMap(values []interface{}) (map[string]interface{}, []string, bool) { + result := make(map[string]interface{}, len(values)) + orderedKeys := make([]string, 0, len(values)) + for idx, item := range values { + key, ok := getReleasePlanArrayItemKey(item) + if !ok { + return nil, nil, false + } + if _, exists := result[key]; exists { + key = fmt.Sprintf("%s#%d", key, idx) + } + result[key] = item + orderedKeys = append(orderedKeys, key) + } + return result, orderedKeys, true +} + +func getReleasePlanArrayItemKey(item interface{}) (string, bool) { + switch value := item.(type) { + case map[string]interface{}: + if name, ok := getStringField(value, "name"); ok { + if jobType, ok := getStringField(value, "type"); ok { + if id, ok := getStringField(value, "id"); ok { + return fmt.Sprintf("%s|%s|%s", name, jobType, id), true + } + return fmt.Sprintf("%s|%s", name, jobType), true + } + return name, true + } + if key, ok := getStringField(value, "key"); ok { + return key, true + } + if service, ok := getStringField(value, "service_name"); ok { + if module, ok := getStringField(value, "service_module"); ok { + return fmt.Sprintf("%s/%s", service, module), true + } + } + if repo, ok := getStringField(value, "repo_name"); ok { + namespace, _ := getStringField(value, "repo_namespace") + remote, _ := getStringField(value, "remote_name") + return fmt.Sprintf("%s/%s/%s", namespace, repo, remote), true + } + if target, ok := getStringField(value, "target"); ok { + return target, true + } + if userID, ok := getStringField(value, "user_id"); ok { + return userID, true + } + if id, ok := getStringField(value, "id"); ok { + return id, true + } + return "", false + default: + return "", false + } +} + +func getStringField(input map[string]interface{}, key string) (string, bool) { + value, exists := input[key] + if !exists { + return "", false + } + str, ok := value.(string) + return str, ok && str != "" +} + +func joinReleasePlanDiffPath(path, key string) string { + if path == "" { + return key + } + return path + "." + key +} + +func shouldIgnoreReleasePlanDiffPath(path string) bool { + if path == "" { + return false + } + prefixes := []string{ + "id", + "index", + "version", + "created_by", + "create_time", + "updated_by", + "update_time", + "status", + "planning_time", + "finish_planning_time", + "approval_time", + "executing_time", + "success_time", + "instance_code", + "hook_settings", + "wait_for_finish_planning_external_check_time", + "wait_for_approve_external_check_time", + "wait_for_execute_external_check_time", + "wait_for_all_done_external_check_time", + "external_check_failed_reason", + "callback_description", + } + for _, prefix := range prefixes { + if path == prefix || strings.HasPrefix(path, prefix+".") { + return true + } + } + + suffixes := []string{ + ".status", + ".last_status", + ".updated", + ".executed_by", + ".executed_time", + ".task_id", + ".hook_payload", + ".hash", + ".notification_id", + ".operation_time", + ".reject_or_approve", + ".approval_instance", + ".manual_exector_id", + ".manual_exector_name", + ".notification_sent", + } + for _, suffix := range suffixes { + if strings.HasSuffix(path, suffix) { + return true + } + } + return false +} + +func classifyReleasePlanDiffTask(path string) (taskName, taskType string) { + jobSegments := releasePlanBracketSegments(path, "jobs") + if len(jobSegments) >= 2 { + taskName, taskType = splitReleasePlanBracketKey(jobSegments[len(jobSegments)-1]) + } + return +} + +func firstReleasePlanBracketSegment(path, prefix string) string { + for _, segment := range strings.Split(path, ".") { + if strings.HasPrefix(segment, prefix+"[") { + return segment + } + } + return prefix +} + +func releasePlanBracketSegments(path, prefix string) []string { + resp := make([]string, 0) + for _, segment := range strings.Split(path, ".") { + if strings.HasPrefix(segment, prefix+"[") { + resp = append(resp, segment) + } + } + return resp +} + +func splitReleasePlanBracketKey(segment string) (string, string) { + primary := bracketPrimaryName(segment) + parts := strings.Split(primary, "|") + if len(parts) == 1 { + return primary, "" + } + return parts[0], strings.Join(parts[1:], "|") +} + +func bracketPrimaryName(segment string) string { + start := strings.Index(segment, "[") + end := strings.LastIndex(segment, "]") + if start == -1 || end == -1 || end <= start+1 { + return segment + } + return segment[start+1 : end] +} + +func buildReleasePlanDiffLabel(path string) string { + segments := strings.Split(path, ".") + labels := make([]string, 0, len(segments)) + for _, segment := range segments { + if segment == "spec" || segment == "workflow" { + continue + } + label := segment + switch { + case strings.HasPrefix(segment, "jobs["): + name, _ := splitReleasePlanBracketKey(segment) + label = fmt.Sprintf("任务 %s", name) + case strings.HasPrefix(segment, "stages["): + label = fmt.Sprintf("阶段 %s", bracketPrimaryName(segment)) + case strings.HasPrefix(segment, "params["): + label = fmt.Sprintf("参数 %s", bracketPrimaryName(segment)) + case strings.HasPrefix(segment, "key_vals["): + label = fmt.Sprintf("变量 %s", bracketPrimaryName(segment)) + case strings.HasPrefix(segment, "services["): + label = fmt.Sprintf("服务 %s", bracketPrimaryName(segment)) + case strings.Contains(segment, "["): + fieldName := segment[:strings.Index(segment, "[")] + label = fmt.Sprintf("%s %s", translateReleasePlanFieldLabel(fieldName), bracketPrimaryName(segment)) + default: + label = translateReleasePlanFieldLabel(segment) + } + labels = append(labels, label) + } + if len(labels) == 0 { + return path + } + return strings.Join(labels, " / ") +} + +func translateReleasePlanFieldLabel(name string) string { + if label, exists := releasePlanFieldLabels[name]; exists { + return label + } + return strings.ReplaceAll(name, "_", " ") +} + +func isMaskedReleasePlanDiffValue(value interface{}) bool { + return isReleasePlanMaskedStorageValue(value) +} + +func isLargeTextReleasePlanDiffPath(path string, before, after interface{}) bool { + lowerPath := strings.ToLower(path) + keywords := []string{"script", "sql", "content", "yaml", "json"} + for _, keyword := range keywords { + if strings.Contains(lowerPath, keyword) { + return true + } + } + + if value, ok := before.(string); ok && len(value) > 256 { + return true + } + if value, ok := after.(string); ok && len(value) > 256 { + return true + } + return false +} + +func normalizeReleasePlanDiffValue(value interface{}) interface{} { + switch value.(type) { + case nil, string, bool, float64: + return value + default: + payload, err := json.Marshal(value) + if err != nil { + return fmt.Sprintf("%v", value) + } + return string(payload) + } +} diff --git a/pkg/microservice/aslan/core/release_plan/service/masking.go b/pkg/microservice/aslan/core/release_plan/service/masking.go new file mode 100644 index 0000000000..b6bcc4ffe0 --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/masking.go @@ -0,0 +1,199 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/pkg/errors" + + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" +) + +const ( + releasePlanMaskedValueDisplay = "已脱敏" + releasePlanMaskedValuePrefix = "__masked__:" +) + +func createReleasePlanLog(logItem *models.ReleasePlanLog) error { + if logItem == nil { + return errors.New("nil release plan log") + } + + cloned := *logItem + cloned.Before = sanitizeReleasePlanValue(logItem.Before) + cloned.After = sanitizeReleasePlanValue(logItem.After) + return mongodb.NewReleasePlanLogColl().Create(&cloned) +} + +func sanitizeReleasePlanValue(value interface{}) interface{} { + if value == nil { + return nil + } + + genericValue, err := toReleasePlanGenericValue(value) + if err != nil { + return value + } + + return sanitizeReleasePlanGenericValue("", genericValue) +} + +func sanitizeReleasePlanValueForDisplay(value interface{}) interface{} { + if value == nil { + return nil + } + + genericValue, err := toReleasePlanGenericValue(value) + if err != nil { + if isReleasePlanMaskedStorageValue(value) { + return releasePlanMaskedValueDisplay + } + return value + } + + if hasReleasePlanRawSensitiveValue(genericValue) { + genericValue = sanitizeReleasePlanGenericValue("", genericValue) + } + return sanitizeReleasePlanDisplayGenericValue(genericValue) +} + +func sanitizeReleasePlanGenericValue(path string, value interface{}) interface{} { + switch typedValue := value.(type) { + case map[string]interface{}: + resp := make(map[string]interface{}, len(typedValue)) + for key, item := range typedValue { + resp[key] = sanitizeReleasePlanGenericValue(joinReleasePlanMaskPath(path, key), item) + } + if isReleasePlanSensitiveValueNode(resp) { + maskReleasePlanSensitiveValueNode(resp) + } + return resp + case []interface{}: + resp := make([]interface{}, 0, len(typedValue)) + for idx, item := range typedValue { + resp = append(resp, sanitizeReleasePlanGenericValue(fmt.Sprintf("%s[%d]", path, idx), item)) + } + return resp + default: + return value + } +} + +func sanitizeReleasePlanDisplayGenericValue(value interface{}) interface{} { + switch typedValue := value.(type) { + case map[string]interface{}: + resp := make(map[string]interface{}, len(typedValue)) + for key, item := range typedValue { + resp[key] = sanitizeReleasePlanDisplayGenericValue(item) + } + return resp + case []interface{}: + resp := make([]interface{}, 0, len(typedValue)) + for _, item := range typedValue { + resp = append(resp, sanitizeReleasePlanDisplayGenericValue(item)) + } + return resp + case string: + if isReleasePlanMaskedStorageValue(typedValue) { + return releasePlanMaskedValueDisplay + } + return typedValue + default: + return value + } +} + +func toReleasePlanGenericValue(value interface{}) (interface{}, error) { + payload, err := json.Marshal(value) + if err != nil { + return nil, err + } + var resp interface{} + if err := json.Unmarshal(payload, &resp); err != nil { + return nil, err + } + return resp, nil +} + +func maskReleasePlanValue(value interface{}) string { + if isReleasePlanMaskedStorageValue(value) { + if str, ok := value.(string); ok { + return str + } + } + + payload, err := json.Marshal(value) + if err != nil { + payload = []byte(fmt.Sprintf("%v", value)) + } + hash := sha256.Sum256(payload) + return releasePlanMaskedValuePrefix + hex.EncodeToString(hash[:8]) +} + +func isReleasePlanMaskedStorageValue(value interface{}) bool { + str, ok := value.(string) + return ok && strings.HasPrefix(str, releasePlanMaskedValuePrefix) +} + +func isReleasePlanSensitiveValueNode(value map[string]interface{}) bool { + if value == nil { + return false + } + return isReleasePlanSensitiveFlagTrue(value, "is_credential") || isReleasePlanSensitiveFlagTrue(value, "is_sensitive") +} + +func hasReleasePlanRawSensitiveValue(value interface{}) bool { + switch typedValue := value.(type) { + case map[string]interface{}: + if isReleasePlanSensitiveValueNode(typedValue) { + for _, key := range []string{"value", "choice_value"} { + if item, exists := typedValue[key]; exists && !isReleasePlanMaskedStorageValue(item) { + return true + } + } + } + for _, item := range typedValue { + if hasReleasePlanRawSensitiveValue(item) { + return true + } + } + case []interface{}: + for _, item := range typedValue { + if hasReleasePlanRawSensitiveValue(item) { + return true + } + } + } + return false +} + +func isReleasePlanSensitiveFlagTrue(input map[string]interface{}, key string) bool { + value, exists := input[key] + if !exists { + return false + } + flag, ok := value.(bool) + return ok && flag +} + +func maskReleasePlanSensitiveValueNode(value map[string]interface{}) { + if value == nil { + return + } + for _, key := range []string{"value", "choice_value"} { + if item, exists := value[key]; exists { + value[key] = maskReleasePlanValue(item) + } + } +} + +func joinReleasePlanMaskPath(path, key string) string { + if path == "" { + return key + } + return path + "." + key +} diff --git a/pkg/microservice/aslan/core/release_plan/service/openapi.go b/pkg/microservice/aslan/core/release_plan/service/openapi.go index 2c6739d293..1e87f01c9f 100644 --- a/pkg/microservice/aslan/core/release_plan/service/openapi.go +++ b/pkg/microservice/aslan/core/release_plan/service/openapi.go @@ -158,6 +158,7 @@ func OpenAPICreateReleasePlan(c *handler.Context, rawArgs *OpenAPICreateReleaseP args.UpdatedBy = c.UserName args.CreateTime = time.Now().Unix() args.UpdateTime = time.Now().Unix() + args.Version = 1 args.Status = config.ReleasePlanStatusPlanning planID, err := mongodb.NewReleasePlanColl().Create(args) @@ -166,13 +167,21 @@ func OpenAPICreateReleasePlan(c *handler.Context, rawArgs *OpenAPICreateReleaseP } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ + sectionSnapshot, err := buildReleasePlanInputSnapshot(args) + if err == nil { + err = createReleasePlanVersion(planID, 0, 1, nil, sectionSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), VerbCreate) + } + if err != nil { + log.Errorf("create release plan version error: %v", err) + } + if err := createReleasePlanLog(&models.ReleasePlanLog{ PlanID: planID, Username: c.UserName, Account: c.Account, Verb: VerbCreate, TargetName: args.Name, TargetType: TargetTypeReleasePlan, + ToVersion: 1, CreatedAt: time.Now().Unix(), }); err != nil { log.Errorf("create release plan log error: %v", err) @@ -220,6 +229,10 @@ func OpenAPICreateReleasePlanWithJobs(c *handler.Context, id string, rawArgs *Op if err != nil { return errors.Wrap(err, "get release plan error") } + originalPlan, err := cloneReleasePlan(plan) + if err != nil { + return errors.Wrap(err, "clone release plan") + } if rawArgs.Name == "" || rawArgs.Manager == "" { return errors.New("Required parameters are missing") @@ -361,25 +374,42 @@ func OpenAPICreateReleasePlanWithJobs(c *handler.Context, id string, rawArgs *Op } plan.Jobs = newJobs + fromVersion, err := ensureReleasePlanBaselineVersion(c, id, plan) + if err != nil { + return errors.Wrap(err, "ensure release plan baseline version") + } + plan.Version = fromVersion + 1 err = mongodb.NewReleasePlanColl().UpdateByID(c, id, plan) if err != nil { return errors.Wrap(err, "update release plan error") } - go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ - PlanID: plan.ID.Hex(), - Username: c.UserName, - Account: c.Account, - Verb: VerbUpdate, - TargetName: plan.Name, - TargetType: TargetTypeReleasePlan, - CreatedAt: time.Now().Unix(), - }); err != nil { - log.Errorf("create release plan log error: %v", err) - } - }() + baseSnapshot, err := buildReleasePlanInputSnapshot(originalPlan) + if err != nil { + return errors.Wrap(err, "build release plan base snapshot") + } + currentSnapshot, err := buildReleasePlanInputSnapshot(plan) + if err != nil { + return errors.Wrap(err, "build release plan current snapshot") + } + if err := createReleasePlanVersion(plan.ID.Hex(), fromVersion, plan.Version, baseSnapshot, currentSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, plan.Name), VerbUpdate); err != nil { + log.Errorf("create release plan version error: %v", err) + } + if err := createReleasePlanLog(&models.ReleasePlanLog{ + PlanID: plan.ID.Hex(), + Username: c.UserName, + Account: c.Account, + Verb: VerbUpdate, + TargetName: plan.Name, + TargetType: TargetTypeReleasePlan, + FromVersion: fromVersion, + ToVersion: plan.Version, + CreatedAt: time.Now().Unix(), + }); err != nil { + log.Errorf("create release plan log error: %v", err) + } + broadcastReleasePlanCollaboration(plan.ID.Hex()) return nil } diff --git a/pkg/microservice/aslan/core/release_plan/service/release_plan.go b/pkg/microservice/aslan/core/release_plan/service/release_plan.go index e1f519b2fe..db48ab2c1f 100644 --- a/pkg/microservice/aslan/core/release_plan/service/release_plan.go +++ b/pkg/microservice/aslan/core/release_plan/service/release_plan.go @@ -110,6 +110,7 @@ func CreateReleasePlan(c *handler.Context, args *models.ReleasePlan) error { args.UpdatedBy = c.UserName args.CreateTime = time.Now().Unix() args.UpdateTime = time.Now().Unix() + args.Version = 1 args.Status = config.ReleasePlanStatusPlanning args.InstanceCode, err = generateInstanceCode(args) @@ -131,13 +132,21 @@ func CreateReleasePlan(c *handler.Context, args *models.ReleasePlan) error { } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ + sectionSnapshot, err := buildReleasePlanInputSnapshot(args) + if err == nil { + err = createReleasePlanVersion(planID, 0, 1, nil, sectionSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), VerbCreate) + } + if err != nil { + log.Errorf("create release plan version error: %v", err) + } + if err := createReleasePlanLog(&models.ReleasePlanLog{ PlanID: planID, Username: c.UserName, Account: c.Account, Verb: VerbCreate, TargetName: args.Name, TargetType: TargetTypeReleasePlan, + ToVersion: 1, CreatedAt: time.Now().Unix(), }); err != nil { log.Errorf("create release plan log error: %v", err) @@ -331,8 +340,19 @@ func GetReleasePlanLogs(id string) (*GetReleasePlanLogsResponse, error) { return nil, errors.Wrap(err, "get release plan logs") } + sanitizedLogs := make([]*models.ReleasePlanLog, 0, len(logs)) + for _, item := range logs { + if item == nil { + continue + } + cloned := *item + cloned.Before = sanitizeReleasePlanValueForDisplay(item.Before) + cloned.After = sanitizeReleasePlanValueForDisplay(item.After) + sanitizedLogs = append(sanitizedLogs, &cloned) + } + return &GetReleasePlanLogsResponse{ - List: logs, + List: sanitizedLogs, I18N: &ReleasePlanLogI18N{ VerbI18Map: VerbI18nMap, TargetTypeI18Map: TargetTypeI18nMap, @@ -386,8 +406,9 @@ const ( ) type UpdateReleasePlanArgs struct { - Verb UpdateReleasePlanVerb `json:"verb"` - Spec interface{} `json:"spec"` + Verb UpdateReleasePlanVerb `json:"verb"` + Spec interface{} `json:"spec"` + SessionID string `json:"session_id,omitempty"` } func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePlanArgs) error { @@ -401,6 +422,10 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla if err != nil { return errors.Wrap(err, "get plan") } + originalPlan, err := cloneReleasePlan(plan) + if err != nil { + return errors.Wrap(err, "clone plan") + } if plan.Status != config.ReleasePlanStatusPlanning { return errors.Errorf("plan status is %s, can not update", plan.Status) @@ -419,6 +444,28 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla return errors.Wrap(err, "update") } + sectionKey, sectionName, err := releasePlanVersionSectionKeyByVerb(originalPlan, plan, args) + if err != nil { + return errors.Wrap(err, "resolve release plan section") + } + baseSnapshot, err := buildReleasePlanVersionSnapshot(originalPlan, sectionKey) + if err != nil { + return errors.Wrap(err, "build release plan base snapshot") + } + currentSnapshot, err := buildReleasePlanVersionSnapshot(plan, sectionKey) + if err != nil { + return errors.Wrap(err, "build release plan current snapshot") + } + + var fromVersion int64 + if args.SessionID == "" { + fromVersion, err = ensureReleasePlanBaselineVersion(ctx, planID, plan) + if err != nil { + return errors.Wrap(err, "ensure release plan baseline version") + } + plan.Version = fromVersion + 1 + } + plan.UpdatedBy = c.UserName plan.UpdateTime = time.Now().Unix() @@ -442,21 +489,29 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla return errors.Wrap(err, "update plan") } - go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ - PlanID: planID, - Username: c.UserName, - Account: c.Account, - Verb: updater.Verb(), - Before: before, - After: after, - TargetName: updater.TargetName(), - TargetType: updater.TargetType(), - CreatedAt: time.Now().Unix(), - }); err != nil { - log.Errorf("create release plan log error: %v", err) + logItem := &models.ReleasePlanLog{ + PlanID: planID, + SessionID: args.SessionID, + Username: c.UserName, + Account: c.Account, + Verb: updater.Verb(), + Before: before, + After: after, + TargetName: updater.TargetName(), + TargetType: updater.TargetType(), + CreatedAt: time.Now().Unix(), + } + if args.SessionID == "" { + logItem.FromVersion = fromVersion + logItem.ToVersion = plan.Version + if err := createReleasePlanVersion(planID, fromVersion, plan.Version, baseSnapshot, currentSnapshot, c.UserName, c.Account, sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), string(args.Verb)); err != nil { + log.Errorf("create release plan version error: %v", err) } - }() + } + if err := createReleasePlanLog(logItem); err != nil { + log.Errorf("create release plan log error: %v", err) + } + broadcastReleasePlanCollaboration(planID) return nil } @@ -495,6 +550,47 @@ func GetReleasePlanJobDetail(planID, jobID string) (*commonmodels.ReleaseJob, er return nil, fmt.Errorf("failed to find release plan job with id: %s. Job does not exist", jobID) } +func findReleasePlanJob(plan *models.ReleasePlan, jobID string) (*models.ReleaseJob, error) { + if plan == nil { + return nil, errors.New("nil release plan") + } + for _, job := range plan.Jobs { + if job.ID == jobID { + return job, nil + } + } + return nil, fmt.Errorf("failed to find release plan job with id: %s. Job does not exist", jobID) +} + +func buildReleasePlanJobLogSnapshot(job *models.ReleaseJob) map[string]interface{} { + if job == nil { + return nil + } + + snapshot := map[string]interface{}{ + "type": job.Type, + "status": job.Status, + "executed_by": job.ExecutedBy, + "executed_time": job.ExecutedTime, + } + + switch job.Type { + case config.JobText: + spec := new(models.TextReleaseJobSpec) + if err := models.IToi(job.Spec, spec); err == nil { + snapshot["remark"] = spec.Remark + } + case config.JobWorkflow: + spec := new(models.WorkflowReleaseJobSpec) + if err := models.IToi(job.Spec, spec); err == nil { + snapshot["workflow_status"] = spec.Status + snapshot["task_id"] = spec.TaskID + } + } + + return snapshot +} + type ExecuteReleaseJobArgs struct { ID string `json:"id"` Name string `json:"name"` @@ -531,6 +627,12 @@ func ExecuteReleaseJob(c *handler.Context, planID string, args *ExecuteReleaseJo } } + jobBefore, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job before execute") + } + beforeSnapshot := buildReleasePlanJobLogSnapshot(jobBefore) + executor, err := NewReleaseJobExecutor(&ExecuteReleaseJobContext{ AuthResources: c.Resources, UserID: c.UserID, @@ -543,6 +645,11 @@ func ExecuteReleaseJob(c *handler.Context, planID string, args *ExecuteReleaseJo if err = executor.Execute(plan); err != nil { return errors.Wrap(err, "execute") } + jobAfter, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job after execute") + } + afterSnapshot := buildReleasePlanJobLogSnapshot(jobAfter) plan.UpdatedBy = c.UserName plan.UpdateTime = time.Now().Unix() @@ -578,11 +685,13 @@ func ExecuteReleaseJob(c *handler.Context, planID string, args *ExecuteReleaseJo } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ + if err := createReleasePlanLog(&models.ReleasePlanLog{ PlanID: planID, Username: c.UserName, Account: c.Account, Verb: VerbExecute, + Before: beforeSnapshot, + After: afterSnapshot, TargetName: args.Name, TargetType: TargetTypeReleaseJob, CreatedAt: time.Now().Unix(), @@ -630,6 +739,12 @@ func RetryReleaseJob(c *handler.Context, planID string, args *RetryReleaseJobArg } } + jobBefore, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job before retry") + } + beforeSnapshot := buildReleasePlanJobLogSnapshot(jobBefore) + retryer, err := NewReleaseJobRetryer(&RetryReleaseJobContext{ AuthResources: c.Resources, UserID: c.UserID, @@ -637,11 +752,16 @@ func RetryReleaseJob(c *handler.Context, planID string, args *RetryReleaseJobArg UserName: c.UserName, }, args) if err != nil { - return errors.Wrap(err, "new release job executor") + return errors.Wrap(err, "new release job retryer") } if err = retryer.Retry(plan); err != nil { - return errors.Wrap(err, "execute") + return errors.Wrap(err, "retry") } + jobAfter, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job after retry") + } + afterSnapshot := buildReleasePlanJobLogSnapshot(jobAfter) plan.UpdatedBy = c.UserName plan.UpdateTime = time.Now().Unix() @@ -679,11 +799,13 @@ func RetryReleaseJob(c *handler.Context, planID string, args *RetryReleaseJobArg } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ + if err := createReleasePlanLog(&models.ReleasePlanLog{ PlanID: planID, Username: c.UserName, Account: c.Account, Verb: VerbRetry, + Before: beforeSnapshot, + After: afterSnapshot, TargetName: args.Name, TargetType: TargetTypeReleaseJob, CreatedAt: time.Now().Unix(), @@ -776,19 +898,11 @@ func ScheduleExecuteReleasePlan(c *handler.Context, planID, jobID string) error Type: string(job.Type), } - go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ - PlanID: planID, - Username: UserNameSystem, - Account: "", - Verb: VerbExecute, - TargetName: args.Name, - TargetType: TargetTypeReleaseJob, - CreatedAt: time.Now().Unix(), - }); err != nil { - log.Errorf("create release plan log error: %v", err) - } - }() + jobBefore, err := findReleasePlanJob(plan, job.ID) + if err != nil { + return err + } + beforeSnapshot := buildReleasePlanJobLogSnapshot(jobBefore) executor, err := NewReleaseJobExecutor(&ExecuteReleaseJobContext{ AuthResources: c.Resources, @@ -807,6 +921,12 @@ func ScheduleExecuteReleasePlan(c *handler.Context, planID, jobID string) error return err } + jobAfter, err := findReleasePlanJob(plan, job.ID) + if err != nil { + return err + } + afterSnapshot := buildReleasePlanJobLogSnapshot(jobAfter) + plan.UpdatedBy = UserNameSystem plan.UpdateTime = time.Now().Unix() @@ -831,6 +951,22 @@ func ScheduleExecuteReleasePlan(c *handler.Context, planID, jobID string) error log.Error(err) return err } + + go func(jobName string, before, after map[string]interface{}) { + if err := createReleasePlanLog(&models.ReleasePlanLog{ + PlanID: planID, + Username: UserNameSystem, + Account: "", + Verb: VerbExecute, + Before: before, + After: after, + TargetName: jobName, + TargetType: TargetTypeReleaseJob, + CreatedAt: time.Now().Unix(), + }); err != nil { + log.Errorf("create release plan log error: %v", err) + } + }(job.Name, beforeSnapshot, afterSnapshot) } } @@ -873,6 +1009,12 @@ func SkipReleaseJob(c *handler.Context, planID string, args *SkipReleaseJobArgs, } } + jobBefore, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job before skip") + } + beforeSnapshot := buildReleasePlanJobLogSnapshot(jobBefore) + skipper, err := NewReleaseJobSkipper(&SkipReleaseJobContext{ AuthResources: c.Resources, UserID: c.UserID, @@ -885,6 +1027,11 @@ func SkipReleaseJob(c *handler.Context, planID string, args *SkipReleaseJobArgs, if err = skipper.Skip(plan); err != nil { return errors.Wrap(err, "skip") } + jobAfter, err := findReleasePlanJob(plan, args.ID) + if err != nil { + return errors.Wrap(err, "find release job after skip") + } + afterSnapshot := buildReleasePlanJobLogSnapshot(jobAfter) plan.UpdatedBy = c.UserName plan.UpdateTime = time.Now().Unix() @@ -918,11 +1065,13 @@ func SkipReleaseJob(c *handler.Context, planID string, args *SkipReleaseJobArgs, } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ + if err := createReleasePlanLog(&models.ReleasePlanLog{ PlanID: planID, Username: c.UserName, Account: c.Account, Verb: VerbSkip, + Before: beforeSnapshot, + After: afterSnapshot, TargetName: args.Name, TargetType: TargetTypeReleaseJob, CreatedAt: time.Now().Unix(), @@ -954,7 +1103,9 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is return errors.Errorf("only manager can update plan status") } - if !lo.Contains(config.ReleasePlanStatusMap[plan.Status], config.ReleasePlanStatus(targetStatus)) { + newStatus := config.ReleasePlanStatus(targetStatus) + oldStatus := plan.Status + if !lo.Contains(config.ReleasePlanStatusMap[plan.Status], newStatus) { return errors.Errorf("can't convert plan status %s to %s", plan.Status, targetStatus) } @@ -963,8 +1114,6 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is return errors.Wrap(err, "get user") } - detail := "" - sendWebhook := false hookSetting, err := mongodb.NewSystemSettingColl().GetReleasePlanHookSetting() if err != nil { @@ -989,14 +1138,14 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is config.ReleasePlanStatusWaitForExecuteExternalCheckFailed, config.ReleasePlanStatusWaitForAllDoneExternalCheck, config.ReleasePlanStatusWaitForAllDoneExternalCheckFailed: - if config.ReleasePlanStatus(targetStatus) != config.ReleasePlanStatusPlanning && config.ReleasePlanStatus(targetStatus) != config.ReleasePlanStatusCancel { + if newStatus != config.ReleasePlanStatusPlanning && newStatus != config.ReleasePlanStatusCancel { return fmt.Errorf("can't update status, current status: %s", plan.Status) } } - plan.Status = config.ReleasePlanStatus(targetStatus) + plan.Status = newStatus // target status check and update - switch config.ReleasePlanStatus(targetStatus) { + switch newStatus { case config.ReleasePlanStatusPlanning: for _, job := range plan.Jobs { job.LastStatus = job.Status @@ -1116,6 +1265,8 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is if err := upsertReleasePlanCron(plan.ID.Hex(), plan.Name, plan.Index, plan.Status, plan.ScheduleExecuteTime); err != nil { return errors.Wrap(err, "upsert release plan cron") } + updatedStatus := plan.Status + detail := fmt.Sprintf("状态从 %s 变更为 %s", oldStatus, updatedStatus) if sendWebhook { if err := sendReleasePlanHook(plan, hookSetting); err != nil { @@ -1124,7 +1275,7 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is } go func() { - if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{ + if err := createReleasePlanLog(&models.ReleasePlanLog{ PlanID: planID, Username: c.UserName, Account: c.Account, @@ -1132,8 +1283,8 @@ func UpdateReleasePlanStatus(c *handler.Context, planID, targetStatus string, is TargetName: TargetTypeReleasePlanStatus, TargetType: TargetTypeReleasePlanStatus, Detail: detail, - Before: plan.Status, - After: targetStatus, + Before: oldStatus, + After: updatedStatus, CreatedAt: time.Now().Unix(), }); err != nil { log.Errorf("create release plan log error: %v", err) @@ -1204,16 +1355,18 @@ func ApproveReleasePlan(c *handler.Context, planID string, req *ApproveRequest) plan.Approval.Status = config.StatusPassed } var planLog *models.ReleasePlanLog + beforeStatus := config.ReleasePlanStatusWaitForApprove switch plan.Approval.Status { case config.StatusPassed: planLog = &models.ReleasePlanLog{ PlanID: planID, Username: UserNameSystem, + Account: "", Verb: VerbUpdate, TargetName: TargetTypeReleasePlanStatus, TargetType: TargetTypeReleasePlanStatus, Detail: DetailApprovalPass, - After: config.ReleasePlanStatusExecuting, + Before: beforeStatus, CreatedAt: time.Now().Unix(), } plan.Status = config.ReleasePlanStatusExecuting @@ -1235,11 +1388,19 @@ func ApproveReleasePlan(c *handler.Context, planID string, req *ApproveRequest) sendWebhook = true setReleaseJobsForExecuting(plan) + planLog.After = plan.Status case config.StatusReject: planLog = &models.ReleasePlanLog{ - PlanID: planID, - Detail: DetailApprovalReject, - CreatedAt: time.Now().Unix(), + PlanID: planID, + Username: UserNameSystem, + Account: "", + Verb: VerbUpdate, + TargetName: TargetTypeReleasePlanStatus, + TargetType: TargetTypeReleasePlanStatus, + Detail: DetailApprovalReject, + Before: beforeStatus, + After: config.ReleasePlanStatusApprovalDenied, + CreatedAt: time.Now().Unix(), } plan.Status = config.ReleasePlanStatusApprovalDenied @@ -1261,7 +1422,7 @@ func ApproveReleasePlan(c *handler.Context, planID string, req *ApproveRequest) return } - if err := mongodb.NewReleasePlanLogColl().Create(planLog); err != nil { + if err := createReleasePlanLog(planLog); err != nil { log.Errorf("create release plan log error: %v", err) } }() diff --git a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go new file mode 100644 index 0000000000..e8ff0fc07a --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go @@ -0,0 +1,303 @@ +package service + +import ( + "context" + "encoding/json" + "strings" + + "github.com/pkg/errors" + + "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" +) + +const ( + releasePlanVersionSectionPlan = "plan" + releasePlanVersionSectionMetadata = "metadata" + releasePlanVersionSectionApproval = "approval" + releasePlanVersionSectionJobsOrder = "jobs_order" + releasePlanVersionSectionJobPrefix = "job:" +) + +func releasePlanVersionSectionName(sectionKey, fallbackName string) string { + switch { + case sectionKey == releasePlanVersionSectionPlan: + return "发布计划" + case sectionKey == releasePlanVersionSectionMetadata: + return "基础信息" + case sectionKey == releasePlanVersionSectionApproval: + return "审批配置" + case sectionKey == releasePlanVersionSectionJobsOrder: + return "发布内容顺序" + case strings.HasPrefix(sectionKey, releasePlanVersionSectionJobPrefix): + if fallbackName != "" { + return fallbackName + } + return "发布内容" + default: + return fallbackName + } +} + +func releasePlanVersionSectionGroupType(sectionKey string) string { + switch { + case sectionKey == releasePlanVersionSectionMetadata: + return "metadata" + case sectionKey == releasePlanVersionSectionApproval: + return "approval" + case sectionKey == releasePlanVersionSectionJobsOrder: + return "jobs_order" + case strings.HasPrefix(sectionKey, releasePlanVersionSectionJobPrefix): + return "job" + default: + return "plan" + } +} + +func cloneReleasePlan(plan *models.ReleasePlan) (*models.ReleasePlan, error) { + if plan == nil { + return nil, errors.New("nil release plan") + } + + payload, err := json.Marshal(plan) + if err != nil { + return nil, err + } + + resp := new(models.ReleasePlan) + if err := json.Unmarshal(payload, resp); err != nil { + return nil, err + } + return resp, nil +} + +func releasePlanVersionSectionKeyByVerb(planBefore, planAfter *models.ReleasePlan, args *UpdateReleasePlanArgs) (string, string, error) { + if args == nil { + return releasePlanVersionSectionPlan, "发布计划", nil + } + + switch args.Verb { + case VerbUpdateName, VerbUpdateDesc, VerbUpdateTimeRange, VerbUpdateScheduleExecuteTime, VerbUpdateManager, VerbUpdateJiraSprint: + return releasePlanVersionSectionMetadata, "基础信息", nil + case VerbUpdateApproval, VerbDeleteApproval: + return releasePlanVersionSectionApproval, "审批配置", nil + case VerbReorderReleaseJob: + return releasePlanVersionSectionJobsOrder, "发布内容顺序", nil + case VerbUpdateReleaseJob, VerbDeleteReleaseJob: + jobID, _ := extractReleasePlanJobID(args.Spec) + if jobID == "" { + return "", "", errors.New("missing release job id") + } + jobName := releasePlanVersionSectionJobName(planAfter, jobID) + if jobName == "" { + jobName = releasePlanVersionSectionJobName(planBefore, jobID) + } + return releasePlanVersionSectionJobPrefix + jobID, jobName, nil + case VerbCreateReleaseJob: + createdJob := findCreatedReleasePlanJob(planBefore, planAfter) + if createdJob == nil { + return "", "", errors.New("failed to locate created release job") + } + return releasePlanVersionSectionJobPrefix + createdJob.ID, createdJob.Name, nil + default: + return releasePlanVersionSectionPlan, "发布计划", nil + } +} + +func extractReleasePlanJobID(spec interface{}) (string, error) { + if spec == nil { + return "", nil + } + payload, err := json.Marshal(spec) + if err != nil { + return "", err + } + resp := struct { + ID string `json:"id"` + }{} + if err := json.Unmarshal(payload, &resp); err != nil { + return "", err + } + return resp.ID, nil +} + +func releasePlanVersionSectionJobName(plan *models.ReleasePlan, jobID string) string { + if plan == nil { + return "" + } + for _, job := range plan.Jobs { + if job.ID == jobID { + return job.Name + } + } + return "" +} + +func findCreatedReleasePlanJob(planBefore, planAfter *models.ReleasePlan) *models.ReleaseJob { + if planAfter == nil { + return nil + } + beforeJobIDs := make(map[string]struct{}, len(planBefore.Jobs)) + if planBefore != nil { + for _, job := range planBefore.Jobs { + beforeJobIDs[job.ID] = struct{}{} + } + } + for _, job := range planAfter.Jobs { + if _, exists := beforeJobIDs[job.ID]; !exists { + return job + } + } + return nil +} + +func buildReleasePlanVersionSnapshot(plan *models.ReleasePlan, sectionKey string) (interface{}, error) { + if plan == nil { + return nil, nil + } + + switch { + case sectionKey == releasePlanVersionSectionPlan: + return buildReleasePlanInputSnapshot(plan) + case sectionKey == releasePlanVersionSectionMetadata: + return buildReleasePlanMetadataSnapshot(plan), nil + case sectionKey == releasePlanVersionSectionApproval: + return sanitizeReleasePlanValue(plan.Approval), nil + case sectionKey == releasePlanVersionSectionJobsOrder: + return buildReleasePlanJobsOrderSnapshot(plan), nil + case strings.HasPrefix(sectionKey, releasePlanVersionSectionJobPrefix): + jobID := strings.TrimPrefix(sectionKey, releasePlanVersionSectionJobPrefix) + job, err := findReleasePlanJob(plan, jobID) + if err != nil { + return nil, nil + } + return buildReleasePlanJobInputSnapshot(job) + default: + return nil, errors.Errorf("unsupported release plan version section key: %s", sectionKey) + } +} + +func buildReleasePlanInputSnapshot(plan *models.ReleasePlan) (interface{}, error) { + resp := map[string]interface{}{ + "metadata": buildReleasePlanMetadataSnapshot(plan), + "approval": sanitizeReleasePlanValue(plan.Approval), + "jobs": make([]interface{}, 0, len(plan.Jobs)), + } + for _, job := range plan.Jobs { + snapshot, err := buildReleasePlanJobInputSnapshot(job) + if err != nil { + return nil, err + } + resp["jobs"] = append(resp["jobs"].([]interface{}), snapshot) + } + return resp, nil +} + +func buildReleasePlanMetadataSnapshot(plan *models.ReleasePlan) map[string]interface{} { + if plan == nil { + return nil + } + return map[string]interface{}{ + "name": plan.Name, + "manager": plan.Manager, + "manager_id": plan.ManagerID, + "start_time": plan.StartTime, + "end_time": plan.EndTime, + "schedule_execute_time": plan.ScheduleExecuteTime, + "description": plan.Description, + "jira_sprint_association": sanitizeReleasePlanValue(plan.JiraSprintAssociation), + } +} + +func buildReleasePlanJobsOrderSnapshot(plan *models.ReleasePlan) []interface{} { + resp := make([]interface{}, 0) + if plan == nil { + return resp + } + for _, job := range plan.Jobs { + resp = append(resp, map[string]interface{}{ + "id": job.ID, + "name": job.Name, + }) + } + return resp +} + +func buildReleasePlanJobInputSnapshot(job *models.ReleaseJob) (interface{}, error) { + if job == nil { + return nil, nil + } + + spec, err := buildReleasePlanJobInputSpec(job.Type, job.Spec) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "id": job.ID, + "name": job.Name, + "manager": job.Manager, + "manager_id": job.ManagerID, + "type": job.Type, + "spec": spec, + }, nil +} + +func buildReleasePlanJobInputSpec(jobType config.ReleasePlanJobType, spec interface{}) (interface{}, error) { + switch jobType { + case config.JobText: + inputSpec := new(models.TextReleaseJobSpec) + if err := models.IToi(spec, inputSpec); err != nil { + return nil, err + } + return sanitizeReleasePlanValue(inputSpec), nil + case config.JobWorkflow: + inputSpec := new(models.WorkflowReleaseJobSpec) + if err := models.IToi(spec, inputSpec); err != nil { + return nil, err + } + return sanitizeReleasePlanValue(map[string]interface{}{ + "workflow": inputSpec.Workflow, + }), nil + default: + return sanitizeReleasePlanValue(spec), nil + } +} + +func encodeReleasePlanVersionSnapshot(snapshot interface{}) string { + if snapshot == nil { + return "" + } + payload, err := json.Marshal(snapshot) + if err != nil { + return "" + } + return string(payload) +} + +func decodeReleasePlanVersionSnapshot(snapshot string) (interface{}, error) { + if snapshot == "" { + return nil, nil + } + var resp interface{} + if err := json.Unmarshal([]byte(snapshot), &resp); err != nil { + return nil, err + } + return resp, nil +} + +func releasePlanVersionDiffGroup(sectionKey, sectionName string) (string, string, string) { + return sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), releasePlanVersionSectionGroupType(sectionKey) +} + +func ensureReleasePlanBaselineVersion(ctx context.Context, planID string, currentPlan *models.ReleasePlan) (int64, error) { + if currentPlan.Version != 0 { + return currentPlan.Version, nil + } + currentPlan.Version = 0 + if err := mongodb.NewReleasePlanColl().UpdateVersionByID(ctx, planID, 0); err != nil { + return 0, errors.Wrap(err, "initialize release plan baseline version") + } + return 0, nil +} diff --git a/pkg/microservice/aslan/core/release_plan/service/update.go b/pkg/microservice/aslan/core/release_plan/service/update.go index 8939e7355d..1fa16fe161 100644 --- a/pkg/microservice/aslan/core/release_plan/service/update.go +++ b/pkg/microservice/aslan/core/release_plan/service/update.go @@ -83,6 +83,7 @@ var VerbI18nMap = map[string]string{ VerbUpdate: "Update", VerbDelete: "Delete", VerbExecute: "Execute", + VerbRetry: "Retry", VerbSkip: "Skip", } @@ -221,12 +222,18 @@ func NewTimeRangeUpdater(args *UpdateReleasePlanArgs) (*TimeRangeUpdater, error) return &updater, nil } +func formatReleasePlanDateTime(timestamp int64) string { + if timestamp == 0 { + return "未设置" + } + return time.Unix(timestamp, 0).Format("2006-01-02 15:04:05") +} + func (u *TimeRangeUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) { - format := "2006-01-02 15:04:05" - before = fmt.Sprintf("%s-%s", time.Unix(plan.StartTime, 0).Format(format), - time.Unix(plan.EndTime, 0).Format(format)) - after = fmt.Sprintf("%s-%s", time.Unix(u.StartTime, 0).Format(format), - time.Unix(u.EndTime, 0).Format(format)) + before = fmt.Sprintf("%s-%s", formatReleasePlanDateTime(plan.StartTime), + formatReleasePlanDateTime(plan.EndTime)) + after = fmt.Sprintf("%s-%s", formatReleasePlanDateTime(u.StartTime), + formatReleasePlanDateTime(u.EndTime)) plan.StartTime = u.StartTime plan.EndTime = u.EndTime return @@ -420,6 +427,8 @@ func (u *DeleteReleaseJobUpdater) Update(plan *models.ReleasePlan) (before inter for i, job := range plan.Jobs { if job.ID == u.ID { u.name = job.Name + before = job + after = nil plan.Jobs = append(plan.Jobs[:i], plan.Jobs[i+1:]...) return } @@ -655,9 +664,8 @@ func NewScheduleExecuteTimeUpdater(args *UpdateReleasePlanArgs) (*ScheduleExecut } func (u *ScheduleExecuteTimeUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) { - format := "2006-01-02 15:04:05" - before = time.Unix(plan.ScheduleExecuteTime, 0).Format(format) - after = time.Unix(u.ScheduleExecuteTime, 0).Format(format) + before = formatReleasePlanDateTime(plan.ScheduleExecuteTime) + after = formatReleasePlanDateTime(u.ScheduleExecuteTime) plan.ScheduleExecuteTime = u.ScheduleExecuteTime return } diff --git a/pkg/microservice/aslan/core/release_plan/service/version.go b/pkg/microservice/aslan/core/release_plan/service/version.go new file mode 100644 index 0000000000..ec26b39064 --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/version.go @@ -0,0 +1,141 @@ +/* + * Copyright 2026 The KodeRover Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package service + +import ( + "context" + "time" + + "github.com/pkg/errors" + + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" +) + +type CommitReleasePlanVersionArgs struct { + SessionID string `json:"session_id"` + SectionKey string `json:"section_key"` +} + +func createReleasePlanVersion(planID string, baseVersion, version int64, baseSnapshot, snapshot interface{}, operator, account, sectionKey, sectionName, verb string) error { + return mongodb.NewReleasePlanVersionColl().Create(&models.ReleasePlanVersion{ + PlanID: planID, + BaseVersion: baseVersion, + Version: version, + Operator: operator, + Account: account, + SectionKey: sectionKey, + SectionName: sectionName, + Verb: verb, + BaseSnapshot: sanitizeReleasePlanValue(baseSnapshot), + Snapshot: sanitizeReleasePlanValue(snapshot), + CreatedAt: time.Now().Unix(), + }) +} + +func CommitReleasePlanVersion(ctx context.Context, planID string, args *CommitReleasePlanVersionArgs, userID, operator, account string) (*models.ReleasePlanVersion, error) { + if args == nil { + return nil, errors.New("nil commit args") + } + if args.SessionID == "" { + return nil, errors.New("empty session id") + } + approveLock := getLock(planID) + approveLock.Lock() + defer approveLock.Unlock() + + plan, err := mongodb.NewReleasePlanColl().GetByID(ctx, planID) + if err != nil { + return nil, errors.Wrap(err, "get plan") + } + if err := validateReleasePlanEditingPlan(plan); err != nil { + return nil, err + } + + session, err := getReleasePlanEditingSession(planID, args.SessionID) + if err != nil { + return nil, errors.Wrap(err, "get editing session") + } + if session.UserID != "" && userID != "" && session.UserID != userID { + return nil, errors.New("editing session does not belong to current user") + } + + pending, err := mongodb.NewReleasePlanLogColl().CountPendingBySessionID(planID, args.SessionID) + if err != nil { + return nil, errors.Wrap(err, "count pending session logs") + } + if pending == 0 { + return &models.ReleasePlanVersion{ + PlanID: planID, + Version: plan.Version, + Operator: operator, + Account: account, + SectionKey: args.SectionKey, + CreatedAt: time.Now().Unix(), + }, nil + } + + baseSnapshot, err := decodeReleasePlanVersionSnapshot(session.BaseSnapshot) + if err != nil { + return nil, errors.Wrap(err, "decode session snapshot") + } + + fromVersion := session.BaseVersion + if plan.Version == 0 { + fromVersion, err = ensureReleasePlanBaselineVersion(ctx, planID, plan) + if err != nil { + return nil, errors.Wrap(err, "ensure baseline version") + } + } + if fromVersion == 0 { + fromVersion = plan.Version + } + + currentVersion, err := mongodb.NewReleasePlanColl().IncrementVersionByID(ctx, planID) + if err != nil { + return nil, errors.Wrap(err, "increment plan version") + } + plan.Version = currentVersion + + currentSnapshot, err := buildReleasePlanVersionSnapshot(plan, args.SectionKey) + if err != nil { + return nil, errors.Wrap(err, "build release plan section snapshot") + } + + if err := createReleasePlanVersion(planID, fromVersion, currentVersion, baseSnapshot, currentSnapshot, operator, account, args.SectionKey, releasePlanVersionSectionName(args.SectionKey, session.SectionName), "commit"); err != nil { + return nil, errors.Wrap(err, "create committed version") + } + if err := mongodb.NewReleasePlanLogColl().FillVersionsBySessionID(planID, args.SessionID, fromVersion, currentVersion); err != nil { + return nil, errors.Wrap(err, "fill log versions") + } + + session.BaseVersion = currentVersion + session.BaseSnapshot = encodeReleasePlanVersionSnapshot(currentSnapshot) + if err := persistReleasePlanEditingSession(session); err != nil { + return nil, errors.Wrap(err, "refresh editing session") + } + + broadcastReleasePlanCollaboration(planID) + return &models.ReleasePlanVersion{ + PlanID: planID, + Version: currentVersion, + Operator: operator, + Account: account, + SectionKey: args.SectionKey, + CreatedAt: time.Now().Unix(), + }, nil +} diff --git a/pkg/microservice/aslan/core/release_plan/service/watcher.go b/pkg/microservice/aslan/core/release_plan/service/watcher.go index d18c6f1087..f532369431 100644 --- a/pkg/microservice/aslan/core/release_plan/service/watcher.go +++ b/pkg/microservice/aslan/core/release_plan/service/watcher.go @@ -285,7 +285,7 @@ func updatePlanApproval(plan *models.ReleasePlan) error { return } - if err := mongodb.NewReleasePlanLogColl().Create(planLog); err != nil { + if err := createReleasePlanLog(planLog); err != nil { log.Errorf("create release plan log error: %v", err) } }() From 0a4301bc399cccaecafa0c1d0294a6740e0f7c3b Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Tue, 19 May 2026 18:24:42 +0800 Subject: [PATCH 02/11] chore: remove release plan RFC from PR Signed-off-by: huanghongbo-hhb --- ...e-plan-collaboration-and-versioned-diff.md | 411 ------------------ 1 file changed, 411 deletions(-) delete mode 100644 community/rfc/2026-05-14-release-plan-collaboration-and-versioned-diff.md diff --git a/community/rfc/2026-05-14-release-plan-collaboration-and-versioned-diff.md b/community/rfc/2026-05-14-release-plan-collaboration-and-versioned-diff.md deleted file mode 100644 index 98b4dba4a2..0000000000 --- a/community/rfc/2026-05-14-release-plan-collaboration-and-versioned-diff.md +++ /dev/null @@ -1,411 +0,0 @@ -# 发布计划多人协作与版本化变更展示方案 - -- 作者:KodeRover -- 关联 Issue:TBD -- 日期:2026-05-14 -- 评审人:TBD -- 评审状态:pending - -## 目标 - -这次方案同时解决发布计划的两个需求: - -- 多人协作编辑:用户编辑发布计划时,可以看到有哪些人正在编辑哪些内容。 -- 操作记录细化:用户查看发布计划操作记录时,可以看到这次保存具体改了哪些工作流任务参数。 -- 版本记录:每次配置保存后记录一个版本,版本里只保存这次编辑区块的输入参数快照,后续查看“这次改了什么”时,用这次编辑的前后快照做对比。 - -## 一句话方案 - -不做强锁,也不阻止多人同时编辑。用户编辑时,前端实时展示“谁正在编辑什么”;用户保存后,后端记录一个“编辑区块输入参数版本”;用户点开操作记录详情时,后端比较这次编辑区块的前后快照,把变化整理成按发布内容和工作流任务分组的可读详情。 - -## 用户能看到什么 - -### 正在编辑提示 - -用户进入发布计划详情页后,不会默认显示“正在编辑”。只有当某个用户真正点击某块内容的编辑入口后,其他用户才会看到提示。 - -第一版建议展示这些编辑区块: - -- 基础信息:名称、负责人、发布窗口、定时执行、需求关联。 -- 审批配置。 -- 某一个发布内容。 - -示例: - -```text -huanghongbo 正在编辑基础信息 -patrick 正在编辑发布内容 log-test -2 人正在编辑审批配置 -``` - -### 操作记录详情 - -操作记录列表仍然先展示一句摘要: - -```text -huanghongbo 更新发布内容 log-test -``` - -用户点开详情后,再展示这次保存具体改了什么: - -```text -发布内容:log-test - -构建任务:build -- 代码分支:main -> release/202605 -- 镜像标签:v1.2.3 -> v1.2.4 - -Apollo 任务:update-config -- 命名空间:application -> application-prod -- DB_HOST:10.0.0.1 -> 10.0.0.2 - -DMS 任务:data-change -- SQL 内容:已变更 -``` - -大文本内容,比如脚本、SQL、大段 YAML 或 JSON,第一版默认只展示“已变更”,不在普通操作记录里展开全文。 - -敏感字段沿用工作流本身已有的敏感变量配置,例如 keyvault 的 `is_sensitive` 和工作流变量里的 `is_credential`,只展示“已变更”,不返回原始值。 - -## 前端需要支持什么 - -### 进入编辑态 - -- 用户打开发布计划详情页时,只建立 WebSocket 连接,不立即进入编辑态。 -- 用户点击某个编辑入口后,前端再告诉后端“我正在编辑这一块”。 -- 编辑区块建议和后端保持一致:`metadata`、`approval`、`job:`。 -- 页面上哪些状态可以编辑、哪些入口显示,由前端根据发布计划状态和权限判断;后端收到请求时会再做一次校验。 - -### 维护编辑会话 - -前端需要在一次区块编辑期间维护同一个 `session_id`: - -- 用户进入某个编辑区块时生成或获取一个 `session_id`。 -- 该编辑区块里的所有保存请求都带同一个 `session_id`。 -- 每 10 到 15 秒发送一次心跳,告诉后端“我还在编辑”。 -- 用户取消编辑、关闭弹窗、保存完成或切换编辑区块时,通知后端离开当前编辑态。 - -### 保存配置 - -现有 `verb + spec` 保存方式继续保留。前端在调用保存接口时,需要额外带上 `session_id`: - -```json -{ - "verb": "update_release_job", - "spec": {}, - "session_id": "uuid" -} -``` - -这样后端可以知道这几次 `verb` 保存属于同一次区块编辑。 - -### 提交版本 - -如果采用“按编辑区块合并版本”的方式,前端需要在一次区块编辑完成时调用版本提交接口: - -```json -{ - "session_id": "uuid", - "section_key": "job:job-id" -} -``` - -建议触发时机: - -- 用户点击编辑弹窗里的“保存”或“确定”。 -- 用户关闭编辑弹窗时,如果已经有变更,也需要触发提交。 -- 用户切换到另一个编辑区块前,如果当前区块已有变更,也需要先提交当前区块。 - -这样可以避免一个区块里多次 `verb` 保存生成多个版本。 - -### 展示版本差异 - -操作记录列表仍然先展示摘要。用户点开某条操作记录详情时: - -- 如果该记录包含 `from_version` 和 `to_version`,前端调用版本差异接口。 -- 前端按返回的 `groups` 渲染变更详情。 -- 大文本字段如果标记为 `large_text`,默认展示“已变更”。 -- 敏感字段如果在原始配置里标记为敏感变量,只展示“已变更”,不展示原始值。 -- 如果历史记录没有版本信息,前端继续按旧展示方式处理。 - -## 后端需要支持什么 - -### 实时协作编辑态 - -后端为每个浏览器编辑会话维护一条编辑记录。建议第一版的编辑粒度按内容块划分: - -- `metadata`:基础信息,比如名称、负责人、发布窗口、定时执行、需求关联。 -- `approval`:审批配置。 -- `job:`:某一个发布内容。 - -编辑记录建议包含: - -```go -type ReleasePlanEditingSession struct { - PlanID string `json:"plan_id"` - SessionID string `json:"session_id"` - UserID string `json:"user_id"` - UserName string `json:"user_name"` - Account string `json:"account"` - IdentityType string `json:"identity_type,omitempty"` - Avatar string `json:"avatar,omitempty"` - SectionKey string `json:"section_key"` - SectionType string `json:"section_type"` - SectionName string `json:"section_name"` - BaseVersion int64 `json:"base_version"` - EditingStartedAt int64 `json:"editing_started_at"` - LastHeartbeatAt int64 `json:"last_heartbeat_at"` -} -``` - -说明: - -- `BaseVersion` 表示用户开始编辑时看到的发布计划版本。第一版只用于前端提示,不用于阻断保存。 -- `SectionType` 表示正在编辑哪类内容,前端可以直接判断这是基础信息、审批还是发布内容。 -- `IdentityType` 和 `Avatar` 是可选增强字段,便于前端直接展示“谁正在编辑”。 -- `EditingStartedAt` 表示真正进入编辑态的时间,不等同于页面建立连接的时间。 - -编辑态使用 Redis 保存,并设置自动过期时间。这样即使浏览器异常关闭、断网、服务端连接断开,编辑态也会自动消失,不会一直显示“某人在编辑”。 - -如果 Aslan 是多副本部署,需要用 Redis 做一次“跨 Pod 通知”。某个 Pod 收到用户编辑态变化后,先写 Redis,再发一条 Redis 消息;其他 Pod 收到这条消息后,再推送给自己本地持有的 WebSocket 连接。这样连接在不同 Pod 上的用户也能互相看到编辑状态。 - -`hook` 外部系统配置本身不纳入第一版多人协作提示范围。外部 `hook` 触发后,发布计划可能进入“外部检测”阶段,这个阶段是否还能编辑,由前端决定是否展示编辑入口;后端只负责兜底校验。 - -### 保存与版本化 - -当前后端已经有统一的配置更新接口: - -- `PUT /api/release_plan/v1/:id` -- 请求体使用 `verb + spec` 表示“这次改的是哪一块内容” - -这一套机制建议继续保留,不需要为了版本化把它推翻重做。原因有三个: - -- 现有权限判断就是按 `verb` 分开的。 -- 现有操作记录也是按 `verb` 生成摘要。 -- 第一版版本化只需要挂在“配置保存成功”这个时机上,不要求接口形态改变。 - -保存规则保持“最后保存生效”: - -- 不加硬锁。 -- 不因为其他人正在编辑就拒绝保存。 -- 多人保存同一块内容时,以最后一次成功保存的结果为准。 - -### 版本和保存次数 - -这里需要和前端约定清楚: - -- 版本是按“成功保存一次配置”生成的,不是按“产生一条操作记录”生成的。 -- 配置类保存包括:名称、负责人、时间窗口、审批、发布内容增删改排等。 -- 流程类动作不生成配置版本,比如:状态流转、审批通过/拒绝、执行、重试、跳过、外部检测回调。 - -如果当前前端交互是“用户改一块、点一次保存、发一个 `verb` 请求”,那就自然是一条配置保存对应一个版本。 - -如果采用“按编辑区块合并版本”,则同一个 `session_id` 下的多次 `verb` 保存,最终合并成一个版本。 - -如果后面前端希望改成“整页统一保存”: - -- 可以把多个改动合并后一次提交。 -- 后端一次性应用这些改动。 -- 最终只生成一个版本。 - -这属于前端交互方式变化,不影响版本模型本身。第一版可以先兼容现有单 `verb` 保存模式,后续如果真的需要整页保存,再单独补一个批量保存接口。 - -### 版本生成时机 - -- 用户点击保存配置时,生成新的配置版本。 -- 审批通过、审批拒绝、进入执行、执行完成、外部检测等自动流转,默认只记录状态事件,不生成新的配置版本。 -- 如果未来某个系统流程会直接改动发布计划配置本身,再单独评估是否补充“系统生成版本”。 - -### 发布计划版本模型 - -新增发布计划版本集合。这里不保存完整发布计划,而是只保存“这次编辑区块的输入参数快照”。建议模型: - -```go -type ReleasePlanVersion struct { - ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` - PlanID string `bson:"plan_id" json:"plan_id"` - BaseVersion int64 `bson:"base_version,omitempty" json:"base_version,omitempty"` - Version int64 `bson:"version" json:"version"` - Operator string `bson:"operator" json:"operator"` - Account string `bson:"account" json:"account"` - SectionKey string `bson:"section_key" json:"section_key"` - SectionName string `bson:"section_name" json:"section_name"` - Verb string `bson:"verb" json:"verb"` - BaseSnapshot interface{} `bson:"base_snapshot,omitempty" json:"base_snapshot,omitempty"` - Snapshot interface{} `bson:"snapshot" json:"snapshot"` - CreatedAt int64 `bson:"created_at" json:"created_at"` -} -``` - -说明: - -- `SectionKey` 表示这个版本对应哪个编辑区块,例如 `metadata`、`approval`、`job:`。 -- `BaseVersion` 表示这次编辑会话开始时看到的版本号。 -- `BaseSnapshot` 表示该区块开始编辑时的输入参数快照。 -- `Snapshot` 表示该区块保存完成后的输入参数快照。 - -快照里只保留输入参数,不保留运行态和执行态字段。例如: - -- 基础信息版本只保留名称、负责人、时间窗口、定时执行、需求关联、Jira 关联等输入项。 -- 审批版本只保留审批配置输入,不保留审批实例运行状态。 -- 发布内容版本只保留该发布内容的输入参数,不保留 `status`、`task_id`、`executed_by`、`executed_time` 这类运行字段。 - -这样可以避免因为单条发布计划过大导致版本体积和 diff 计算成本失控。 - -发布计划主表中也建议增加当前版本号: - -```go -Version int64 `bson:"version" json:"version"` -``` - -历史发布计划默认版本可以是 `0`。升级后第一次保存生成 `version = 1`。 - -### 操作日志关联版本 - -现有发布计划操作日志需要关联版本。建议给 `ReleasePlanLog` 增加: - -```go -FromVersion int64 `bson:"from_version,omitempty" json:"from_version,omitempty"` -ToVersion int64 `bson:"to_version,omitempty" json:"to_version,omitempty"` -``` - -操作记录列表仍然保持简洁,例如: - -```text -2026-05-14 10:00 huanghongbo 更新发布内容 log-test v12 -> v13 -``` - -用户点击详情时,前端使用 `plan_id`、`from_version`、`to_version` 请求版本差异。 - -这里的 `from_version` 不一定总是“上一个版本”。它表示这次编辑会话开始时看到的版本。 - -例如: - -- A 基于 `v1` 开始编辑某个发布内容。 -- B 先保存,生成 `v2`。 -- A 继续编辑后再保存,生成 `v3`。 - -那 A 这条操作记录应该关联: - -- `from_version = 1` -- `to_version = 3` - -这样点开详情时,看到的是这次编辑会话对应的区块变更区间,而不是简单的 `v2 -> v3`。 - -这里的版本和状态事件职责分开: - -- 版本主要回答“这次保存后的配置是什么”。 -- 操作日志和状态日志继续回答“发布计划后来经历了什么流转”。 -- 即使自动流转不生成版本,用户仍然可以从最近一次配置版本里查看当时保存下来的区块输入参数。 - -## 变更计算 - -变更计算负责比较一次编辑区块版本的前后快照。设计上先保证所有输入参数变化都能被找出来,再把结果整理成用户能看懂的字段名和分组。 - -第一版建议在用户查看详情时再计算变更内容,而不是在保存时就提前算好: - -- 保存成功时,只负责保存新版本、写操作日志、广播版本变更。 -- 用户点击某条操作记录详情时,后端再根据 `from_version` 和 `to_version` 读取该次编辑区块的前后快照并计算变更内容。 -- 第一版先不做变更结果缓存;如果后续确认详情打开比较慢,再单独评估缓存或后台提前计算。 - -处理流程: - -1. 读取 `to_version` 对应版本里的 `BaseSnapshot` 和 `Snapshot`。 -2. 从外到内逐层比较字段。 -3. 数组优先按“能代表这个元素身份的字段”匹配。 -4. 生成基础差异项。 -5. 对差异项做脱敏、分组和字段翻译。 - -### 对比前的数据整理 - -由于版本里保存的是区块输入参数快照,真正对比之前只需要再过滤少量不适合展示的字段,例如敏感信息和大文本字段,不需要再从整份运行对象里剔除执行状态。 - -### 数组匹配 - -数组不能总是按下标比较。能识别元素身份的数组,应该按身份字段对齐: - -```text -发布内容:id -工作流阶段:name -工作流任务:name + type -工作流参数:name -代码仓库:source + repo_namespace + repo_name + remote_name -构建服务:service_name + service_module -部署服务:service_name + service_module -key/value 数组:key -``` - -如果某类数组没有明确的身份字段,再退回按下标比较。 - -### 字段规则管理 - -字段中文名、忽略哪些字段、哪些字段需要脱敏,这些规则可以放在一张统一的规则表里管理。实现上可以用普通的路径映射表;如果规则特别多,再考虑用 `trie` 这种“按字段路径快速查规则”的结构。 - -这里的 `trie` 只适合做“某个字段路径该怎么处理”的查找,不适合拿来做两个版本内容是否有差异的核心计算。真正找出变化的步骤,还是靠前面的逐层比较。 - -## API 变更 - -### 协作态 - -```http -GET /api/aslan/release_plan/v1/:id/collaboration/ws -GET /api/aslan/release_plan/v1/:id/collaboration/editors -``` - -`editors` 用于页面初始化和 WebSocket 断线重连后的状态恢复。 - -### 版本 - -```http -GET /api/aslan/release_plan/v1/:id/versions -GET /api/aslan/release_plan/v1/:id/versions/:version -GET /api/aslan/release_plan/v1/:id/versions/:from/diff?to=:to -POST /api/aslan/release_plan/v1/:id/versions/commit -``` - -`commit` 接口用于告诉后端“这次区块编辑结束了,可以把这组变更合成一个版本”。 - -### 操作日志 - -现有日志接口保持不变,但返回值增加版本信息: - -```http -GET /api/aslan/release_plan/v1/:id/logs -``` - -每条日志可以包含 `from_version`、`to_version`。 - -## 向后兼容 - -- 现有发布计划 API 保存语义不变。 -- 历史发布计划默认版本号为 `0`。 -- 没有版本信息的历史操作日志仍按旧格式展示。 -- 升级后第一次保存生成第一个版本。 -- 版本差异展示是新增能力,不影响现有保存流程。 - -## 性能考虑 - -- 版本只保存编辑区块的输入参数快照,不保存整份发布计划运行对象。 -- 数组先按身份字段对齐后再比较,避免两两查找导致耗时变长。 -- 大文本字段在普通响应里只标记“已变更”,不做全文对比。 -- 如果一个内容块本身完全没变,就直接跳过,不继续往下比。 -- 用户点开操作详情时再计算变更内容,避免把比较耗时放到保存流程里。 -- 后续如果遇到大对象性能问题,可以先比较每个工作流任务输入参数是否变化;没变化的任务就不继续往下比。 -- 第一版先不做结果缓存,等确认真的有明显性能压力,再考虑补。 - -## 安全与隐私 - -- 敏感字段在返回前必须脱敏,脱敏依据沿用工作流自身已经配置好的敏感变量标记,不额外定义审批人、手机号之类的特殊字段规则。 -- 脱敏规则在生成基础差异项后、返回给前端前执行。 -- 不通过版本差异接口暴露敏感变量原始值。 -- WebSocket 接口需要复用发布计划查看/编辑权限校验。 - -## 实施拆分 - -虽然产品目标是一口气交付完整体验,工程实现仍建议按模块拆: - -1. 增加版本模型,并在保存成功后记录编辑区块的输入参数快照。 -2. 增加 WebSocket 协作态,使用 Redis 自动过期和跨 Pod 通知。 -3. 增加变更计算能力,支持数组按身份字段匹配和敏感字段脱敏。 -4. 增加翻译后的变更详情返回结构,并和操作日志关联。 -5. 首版尽量一次性覆盖已知字段中文标签,未知字段用处理后的字段路径兜底展示,但不改变核心返回格式。 From c209b83447e4cf1a0180159b31a0c2d385974941 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Wed, 20 May 2026 13:16:10 +0800 Subject: [PATCH 03/11] refactor: simplify release plan version semantics Signed-off-by: huanghongbo-hhb --- .../common/repository/models/release_plan.go | 27 ++--- .../repository/mongodb/release_plan_log.go | 42 ------- .../aslan/core/release_plan/handler/router.go | 3 +- .../release_plan/service/collaboration.go | 22 +--- .../aslan/core/release_plan/service/diff.go | 76 +++++-------- .../core/release_plan/service/openapi.go | 33 +++--- .../core/release_plan/service/release_plan.go | 28 ++--- .../release_plan/service/section_snapshot.go | 35 ------ .../core/release_plan/service/version.go | 104 +----------------- 9 files changed, 69 insertions(+), 301 deletions(-) diff --git a/pkg/microservice/aslan/core/common/repository/models/release_plan.go b/pkg/microservice/aslan/core/common/repository/models/release_plan.go index f873e385ad..9e8003856c 100644 --- a/pkg/microservice/aslan/core/common/repository/models/release_plan.go +++ b/pkg/microservice/aslan/core/common/repository/models/release_plan.go @@ -121,20 +121,18 @@ type WorkflowReleaseJobSpec struct { } type ReleasePlanLog struct { - ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` - PlanID string `bson:"plan_id" json:"plan_id"` - SessionID string `bson:"session_id,omitempty" json:"session_id,omitempty"` - Username string `bson:"username" json:"username"` - Account string `bson:"account" json:"account"` - Verb string `bson:"verb" json:"verb"` - TargetName string `bson:"target_name" json:"target_name"` - TargetType string `bson:"target_type" json:"target_type"` - Before interface{} `bson:"before" json:"before"` - After interface{} `bson:"after" json:"after"` - Detail string `bson:"detail" json:"detail"` - FromVersion int64 `bson:"from_version,omitempty" json:"from_version,omitempty"` - ToVersion int64 `bson:"to_version,omitempty" json:"to_version,omitempty"` - CreatedAt int64 `bson:"created_at" json:"created_at"` + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + PlanID string `bson:"plan_id" json:"plan_id"` + Username string `bson:"username" json:"username"` + Account string `bson:"account" json:"account"` + Verb string `bson:"verb" json:"verb"` + TargetName string `bson:"target_name" json:"target_name"` + TargetType string `bson:"target_type" json:"target_type"` + Before interface{} `bson:"before" json:"before"` + After interface{} `bson:"after" json:"after"` + Detail string `bson:"detail" json:"detail"` + Version int64 `bson:"version,omitempty" json:"version,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` } func (ReleasePlanLog) TableName() string { @@ -144,7 +142,6 @@ func (ReleasePlanLog) TableName() string { type ReleasePlanVersion struct { ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` PlanID string `bson:"plan_id" json:"plan_id"` - BaseVersion int64 `bson:"base_version,omitempty" json:"base_version,omitempty"` Version int64 `bson:"version" json:"version"` Operator string `bson:"operator" json:"operator"` Account string `bson:"account" json:"account"` diff --git a/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_log.go b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_log.go index 760c9c3739..0686fa2bed 100644 --- a/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_log.go +++ b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_log.go @@ -52,9 +52,6 @@ func (c *ReleasePlanLogColl) EnsureIndex(ctx context.Context) error { { Keys: bson.D{{Key: "plan_id", Value: 1}, {Key: "created_at", Value: -1}}, }, - { - Keys: bson.D{{Key: "session_id", Value: 1}}, - }, } _, err := c.Indexes().CreateMany(ctx, mod, mongotool.CreateIndexOptions(ctx)) @@ -104,42 +101,3 @@ func (c *ReleasePlanLogColl) ListByOptions(opt *ListReleasePlanLogOption) ([]*mo return resp, nil } - -func (c *ReleasePlanLogColl) FillVersionsBySessionID(planID, sessionID string, fromVersion, toVersion int64) error { - if sessionID == "" { - return errors.New("empty session id") - } - - query := bson.M{ - "plan_id": planID, - "session_id": sessionID, - "$or": []bson.M{ - {"to_version": bson.M{"$exists": false}}, - {"to_version": 0}, - }, - } - change := bson.M{"$set": bson.M{ - "from_version": fromVersion, - "to_version": toVersion, - }} - - _, err := c.UpdateMany(context.Background(), query, change) - return err -} - -func (c *ReleasePlanLogColl) CountPendingBySessionID(planID, sessionID string) (int64, error) { - if sessionID == "" { - return 0, errors.New("empty session id") - } - - query := bson.M{ - "plan_id": planID, - "session_id": sessionID, - "$or": []bson.M{ - {"to_version": bson.M{"$exists": false}}, - {"to_version": 0}, - }, - } - - return c.CountDocuments(context.Background(), query) -} diff --git a/pkg/microservice/aslan/core/release_plan/handler/router.go b/pkg/microservice/aslan/core/release_plan/handler/router.go index e06142d253..10f8edd91c 100644 --- a/pkg/microservice/aslan/core/release_plan/handler/router.go +++ b/pkg/microservice/aslan/core/release_plan/handler/router.go @@ -31,8 +31,7 @@ func (*Router) Inject(router *gin.RouterGroup) { v1.GET("/:id/collaboration/editors", GetReleasePlanCollaborationEditors) v1.GET("/:id/collaboration/ws", ReleasePlanCollaborationWS) v1.PUT("/:id", UpdateReleasePlan) - v1.POST("/:id/versions/commit", CommitReleasePlanVersion) - v1.GET("/:id/versions/:fromVersion/:toVersion/diff", GetReleasePlanVersionDiff) + v1.GET("/:id/versions/:version/diff", GetReleasePlanVersionDiff) v1.GET("/:id/job/:jobID", GetReleasePlanJobDetail) v1.DELETE("/:id", DeleteReleasePlan) diff --git a/pkg/microservice/aslan/core/release_plan/service/collaboration.go b/pkg/microservice/aslan/core/release_plan/service/collaboration.go index 2f6948e604..acea0abd73 100644 --- a/pkg/microservice/aslan/core/release_plan/service/collaboration.go +++ b/pkg/microservice/aslan/core/release_plan/service/collaboration.go @@ -69,7 +69,6 @@ type ReleasePlanEditingSession struct { SectionType string `json:"section_type"` SectionName string `json:"section_name"` BaseVersion int64 `json:"base_version"` - BaseSnapshot string `json:"base_snapshot,omitempty"` EditingStartedAt int64 `json:"editing_started_at"` LastHeartbeatAt int64 `json:"last_heartbeat_at"` } @@ -291,7 +290,6 @@ func listActiveReleasePlanEditingSessions(planID string) ([]*ReleasePlanEditingS if session.PlanID != planID { continue } - session.BaseSnapshot = "" resp = append(resp, session) } @@ -393,17 +391,6 @@ func OpenReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, pla return openReleasePlanCollaborationWS(gCtx, ctx, planID) } -func releasePlanSnapshotString(plan *models.ReleasePlan, sectionKey string) string { - if plan == nil { - return "" - } - sectionSnapshot, err := buildReleasePlanVersionSnapshot(plan, sectionKey) - if err != nil { - return "" - } - return encodeReleasePlanVersionSnapshot(sectionSnapshot) -} - func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, planID string) error { ws, err := upgrader.Upgrade(gCtx.Writer, gCtx.Request, nil) if err != nil { @@ -462,22 +449,21 @@ func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, pla SectionType: msg.SectionType, SectionName: msg.SectionName, BaseVersion: msg.BaseVersion, - BaseSnapshot: releasePlanSnapshotString(plan, msg.SectionKey), EditingStartedAt: time.Now().Unix(), } if existingSession != nil { session.EditingStartedAt = existingSession.EditingStartedAt - if existingSession.BaseSnapshot != "" { - session.BaseSnapshot = existingSession.BaseSnapshot - } if session.BaseVersion == 0 { session.BaseVersion = existingSession.BaseVersion } if existingSession.SectionKey != "" && existingSession.SectionKey != msg.SectionKey { session.EditingStartedAt = time.Now().Unix() - session.BaseSnapshot = releasePlanSnapshotString(plan, msg.SectionKey) + session.BaseVersion = 0 } } + if session.BaseVersion == 0 { + session.BaseVersion = plan.Version + } if err := persistReleasePlanEditingSession(session); err != nil { queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) continue diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go index d083e525e6..16752c3c55 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -36,10 +36,10 @@ const ( ) type ReleasePlanVersionDiffResponse struct { - PlanID string `json:"plan_id"` - FromVersion int64 `json:"from_version"` - ToVersion int64 `json:"to_version"` - Groups []*ReleasePlanVersionDiffGroup `json:"groups"` + PlanID string `json:"plan_id"` + Version int64 `json:"version"` + PreviousVersion int64 `json:"previous_version"` + Groups []*ReleasePlanVersionDiffGroup `json:"groups"` } type ReleasePlanVersionDiffGroup struct { @@ -119,42 +119,22 @@ var releasePlanFieldLabels = map[string]string{ "workwx_approval": "企业微信审批", } -func GetReleasePlanVersionDiff(planID string, fromVersion, toVersion int64) (*ReleasePlanVersionDiffResponse, error) { - to, err := mongodb.NewReleasePlanVersionColl().Get(planID, toVersion) +func GetReleasePlanVersionDiff(planID string, version int64) (*ReleasePlanVersionDiffResponse, error) { + current, err := mongodb.NewReleasePlanVersionColl().Get(planID, version) if err != nil { - return nil, errors.Wrap(err, "get to version") + return nil, errors.Wrap(err, "get version") } - var fromData map[string]interface{} - var toData map[string]interface{} - groupKey, groupName, groupType := releasePlanVersionDiffGroup(to.SectionKey, to.SectionName) - - if to.BaseVersion == fromVersion { - fromData, err = toGenericMap(to.BaseSnapshot) - if err != nil { - return nil, errors.Wrap(err, "convert base snapshot") - } - toData, err = toGenericMap(to.Snapshot) - if err != nil { - return nil, errors.Wrap(err, "convert current snapshot") - } - } else { - if fromVersion == 0 { - return nil, errors.Errorf("release plan baseline diff v0 -> v%d is not available after subsequent version commits", toVersion) - } - from, err := mongodb.NewReleasePlanVersionColl().Get(planID, fromVersion) - if err != nil { - return nil, errors.Wrap(err, "get from version") - } - fromData, err = toGenericMap(from.Snapshot) - if err != nil { - return nil, errors.Wrap(err, "convert from snapshot") - } - toData, err = toGenericMap(to.Snapshot) - if err != nil { - return nil, errors.Wrap(err, "convert to snapshot") - } + fromData, err := toGenericMap(current.BaseSnapshot) + if err != nil { + return nil, errors.Wrap(err, "convert base snapshot") } + toData, err := toGenericMap(current.Snapshot) + if err != nil { + return nil, errors.Wrap(err, "convert current snapshot") + } + + groupKey, groupName, groupType := releasePlanVersionDiffGroup(current.SectionKey, current.SectionName) rawEntries := make([]*releasePlanRawDiffEntry, 0) diffReleasePlanValues("", fromData, toData, &rawEntries) @@ -206,13 +186,20 @@ func GetReleasePlanVersionDiff(planID string, fromVersion, toVersion int64) (*Re } return &ReleasePlanVersionDiffResponse{ - PlanID: planID, - FromVersion: fromVersion, - ToVersion: toVersion, - Groups: groups, + PlanID: planID, + Version: version, + PreviousVersion: previousReleasePlanVersion(version), + Groups: groups, }, nil } +func previousReleasePlanVersion(version int64) int64 { + if version <= 1 { + return 0 + } + return version - 1 +} + func toGenericMap(value interface{}) (map[string]interface{}, error) { if value == nil { return map[string]interface{}{}, nil @@ -502,15 +489,6 @@ func classifyReleasePlanDiffTask(path string) (taskName, taskType string) { return } -func firstReleasePlanBracketSegment(path, prefix string) string { - for _, segment := range strings.Split(path, ".") { - if strings.HasPrefix(segment, prefix+"[") { - return segment - } - } - return prefix -} - func releasePlanBracketSegments(path, prefix string) []string { resp := make([]string, 0) for _, segment := range strings.Split(path, ".") { diff --git a/pkg/microservice/aslan/core/release_plan/service/openapi.go b/pkg/microservice/aslan/core/release_plan/service/openapi.go index 1e87f01c9f..73e90ef046 100644 --- a/pkg/microservice/aslan/core/release_plan/service/openapi.go +++ b/pkg/microservice/aslan/core/release_plan/service/openapi.go @@ -169,7 +169,7 @@ func OpenAPICreateReleasePlan(c *handler.Context, rawArgs *OpenAPICreateReleaseP go func() { sectionSnapshot, err := buildReleasePlanInputSnapshot(args) if err == nil { - err = createReleasePlanVersion(planID, 0, 1, nil, sectionSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), VerbCreate) + err = createReleasePlanVersion(planID, 1, nil, sectionSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), VerbCreate) } if err != nil { log.Errorf("create release plan version error: %v", err) @@ -181,7 +181,7 @@ func OpenAPICreateReleasePlan(c *handler.Context, rawArgs *OpenAPICreateReleaseP Verb: VerbCreate, TargetName: args.Name, TargetType: TargetTypeReleasePlan, - ToVersion: 1, + Version: 1, CreatedAt: time.Now().Unix(), }); err != nil { log.Errorf("create release plan log error: %v", err) @@ -225,6 +225,10 @@ type OpenAPIWorkflowReleaseJobSpec struct { } func OpenAPICreateReleasePlanWithJobs(c *handler.Context, id string, rawArgs *OpenAPIUpdateReleasePlanWithJobsArgs) error { + approveLock := getLock(id) + approveLock.Lock() + defer approveLock.Unlock() + plan, err := mongodb.NewReleasePlanColl().GetByID(context.Background(), id) if err != nil { return errors.Wrap(err, "get release plan error") @@ -374,11 +378,7 @@ func OpenAPICreateReleasePlanWithJobs(c *handler.Context, id string, rawArgs *Op } plan.Jobs = newJobs - fromVersion, err := ensureReleasePlanBaselineVersion(c, id, plan) - if err != nil { - return errors.Wrap(err, "ensure release plan baseline version") - } - plan.Version = fromVersion + 1 + plan.Version = originalPlan.Version + 1 err = mongodb.NewReleasePlanColl().UpdateByID(c, id, plan) if err != nil { @@ -393,19 +393,18 @@ func OpenAPICreateReleasePlanWithJobs(c *handler.Context, id string, rawArgs *Op if err != nil { return errors.Wrap(err, "build release plan current snapshot") } - if err := createReleasePlanVersion(plan.ID.Hex(), fromVersion, plan.Version, baseSnapshot, currentSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, plan.Name), VerbUpdate); err != nil { + if err := createReleasePlanVersion(plan.ID.Hex(), plan.Version, baseSnapshot, currentSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, plan.Name), VerbUpdate); err != nil { log.Errorf("create release plan version error: %v", err) } if err := createReleasePlanLog(&models.ReleasePlanLog{ - PlanID: plan.ID.Hex(), - Username: c.UserName, - Account: c.Account, - Verb: VerbUpdate, - TargetName: plan.Name, - TargetType: TargetTypeReleasePlan, - FromVersion: fromVersion, - ToVersion: plan.Version, - CreatedAt: time.Now().Unix(), + PlanID: plan.ID.Hex(), + Username: c.UserName, + Account: c.Account, + Verb: VerbUpdate, + TargetName: plan.Name, + TargetType: TargetTypeReleasePlan, + Version: plan.Version, + CreatedAt: time.Now().Unix(), }); err != nil { log.Errorf("create release plan log error: %v", err) } diff --git a/pkg/microservice/aslan/core/release_plan/service/release_plan.go b/pkg/microservice/aslan/core/release_plan/service/release_plan.go index db48ab2c1f..55cc009acd 100644 --- a/pkg/microservice/aslan/core/release_plan/service/release_plan.go +++ b/pkg/microservice/aslan/core/release_plan/service/release_plan.go @@ -134,7 +134,7 @@ func CreateReleasePlan(c *handler.Context, args *models.ReleasePlan) error { go func() { sectionSnapshot, err := buildReleasePlanInputSnapshot(args) if err == nil { - err = createReleasePlanVersion(planID, 0, 1, nil, sectionSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), VerbCreate) + err = createReleasePlanVersion(planID, 1, nil, sectionSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name), VerbCreate) } if err != nil { log.Errorf("create release plan version error: %v", err) @@ -146,7 +146,7 @@ func CreateReleasePlan(c *handler.Context, args *models.ReleasePlan) error { Verb: VerbCreate, TargetName: args.Name, TargetType: TargetTypeReleasePlan, - ToVersion: 1, + Version: 1, CreatedAt: time.Now().Unix(), }); err != nil { log.Errorf("create release plan log error: %v", err) @@ -406,9 +406,8 @@ const ( ) type UpdateReleasePlanArgs struct { - Verb UpdateReleasePlanVerb `json:"verb"` - Spec interface{} `json:"spec"` - SessionID string `json:"session_id,omitempty"` + Verb UpdateReleasePlanVerb `json:"verb"` + Spec interface{} `json:"spec"` } func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePlanArgs) error { @@ -457,14 +456,7 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla return errors.Wrap(err, "build release plan current snapshot") } - var fromVersion int64 - if args.SessionID == "" { - fromVersion, err = ensureReleasePlanBaselineVersion(ctx, planID, plan) - if err != nil { - return errors.Wrap(err, "ensure release plan baseline version") - } - plan.Version = fromVersion + 1 - } + plan.Version = originalPlan.Version + 1 plan.UpdatedBy = c.UserName plan.UpdateTime = time.Now().Unix() @@ -491,7 +483,6 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla logItem := &models.ReleasePlanLog{ PlanID: planID, - SessionID: args.SessionID, Username: c.UserName, Account: c.Account, Verb: updater.Verb(), @@ -499,14 +490,11 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla After: after, TargetName: updater.TargetName(), TargetType: updater.TargetType(), + Version: plan.Version, CreatedAt: time.Now().Unix(), } - if args.SessionID == "" { - logItem.FromVersion = fromVersion - logItem.ToVersion = plan.Version - if err := createReleasePlanVersion(planID, fromVersion, plan.Version, baseSnapshot, currentSnapshot, c.UserName, c.Account, sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), string(args.Verb)); err != nil { - log.Errorf("create release plan version error: %v", err) - } + if err := createReleasePlanVersion(planID, plan.Version, baseSnapshot, currentSnapshot, c.UserName, c.Account, sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), string(args.Verb)); err != nil { + log.Errorf("create release plan version error: %v", err) } if err := createReleasePlanLog(logItem); err != nil { log.Errorf("create release plan log error: %v", err) diff --git a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go index e8ff0fc07a..fa5581097f 100644 --- a/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go +++ b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go @@ -1,7 +1,6 @@ package service import ( - "context" "encoding/json" "strings" @@ -9,7 +8,6 @@ import ( "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" - "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" ) const ( @@ -265,39 +263,6 @@ func buildReleasePlanJobInputSpec(jobType config.ReleasePlanJobType, spec interf } } -func encodeReleasePlanVersionSnapshot(snapshot interface{}) string { - if snapshot == nil { - return "" - } - payload, err := json.Marshal(snapshot) - if err != nil { - return "" - } - return string(payload) -} - -func decodeReleasePlanVersionSnapshot(snapshot string) (interface{}, error) { - if snapshot == "" { - return nil, nil - } - var resp interface{} - if err := json.Unmarshal([]byte(snapshot), &resp); err != nil { - return nil, err - } - return resp, nil -} - func releasePlanVersionDiffGroup(sectionKey, sectionName string) (string, string, string) { return sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), releasePlanVersionSectionGroupType(sectionKey) } - -func ensureReleasePlanBaselineVersion(ctx context.Context, planID string, currentPlan *models.ReleasePlan) (int64, error) { - if currentPlan.Version != 0 { - return currentPlan.Version, nil - } - currentPlan.Version = 0 - if err := mongodb.NewReleasePlanColl().UpdateVersionByID(ctx, planID, 0); err != nil { - return 0, errors.Wrap(err, "initialize release plan baseline version") - } - return 0, nil -} diff --git a/pkg/microservice/aslan/core/release_plan/service/version.go b/pkg/microservice/aslan/core/release_plan/service/version.go index ec26b39064..0373dde909 100644 --- a/pkg/microservice/aslan/core/release_plan/service/version.go +++ b/pkg/microservice/aslan/core/release_plan/service/version.go @@ -17,24 +17,15 @@ package service import ( - "context" "time" - "github.com/pkg/errors" - "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models" "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" ) -type CommitReleasePlanVersionArgs struct { - SessionID string `json:"session_id"` - SectionKey string `json:"section_key"` -} - -func createReleasePlanVersion(planID string, baseVersion, version int64, baseSnapshot, snapshot interface{}, operator, account, sectionKey, sectionName, verb string) error { +func createReleasePlanVersion(planID string, version int64, baseSnapshot, snapshot interface{}, operator, account, sectionKey, sectionName, verb string) error { return mongodb.NewReleasePlanVersionColl().Create(&models.ReleasePlanVersion{ PlanID: planID, - BaseVersion: baseVersion, Version: version, Operator: operator, Account: account, @@ -46,96 +37,3 @@ func createReleasePlanVersion(planID string, baseVersion, version int64, baseSna CreatedAt: time.Now().Unix(), }) } - -func CommitReleasePlanVersion(ctx context.Context, planID string, args *CommitReleasePlanVersionArgs, userID, operator, account string) (*models.ReleasePlanVersion, error) { - if args == nil { - return nil, errors.New("nil commit args") - } - if args.SessionID == "" { - return nil, errors.New("empty session id") - } - approveLock := getLock(planID) - approveLock.Lock() - defer approveLock.Unlock() - - plan, err := mongodb.NewReleasePlanColl().GetByID(ctx, planID) - if err != nil { - return nil, errors.Wrap(err, "get plan") - } - if err := validateReleasePlanEditingPlan(plan); err != nil { - return nil, err - } - - session, err := getReleasePlanEditingSession(planID, args.SessionID) - if err != nil { - return nil, errors.Wrap(err, "get editing session") - } - if session.UserID != "" && userID != "" && session.UserID != userID { - return nil, errors.New("editing session does not belong to current user") - } - - pending, err := mongodb.NewReleasePlanLogColl().CountPendingBySessionID(planID, args.SessionID) - if err != nil { - return nil, errors.Wrap(err, "count pending session logs") - } - if pending == 0 { - return &models.ReleasePlanVersion{ - PlanID: planID, - Version: plan.Version, - Operator: operator, - Account: account, - SectionKey: args.SectionKey, - CreatedAt: time.Now().Unix(), - }, nil - } - - baseSnapshot, err := decodeReleasePlanVersionSnapshot(session.BaseSnapshot) - if err != nil { - return nil, errors.Wrap(err, "decode session snapshot") - } - - fromVersion := session.BaseVersion - if plan.Version == 0 { - fromVersion, err = ensureReleasePlanBaselineVersion(ctx, planID, plan) - if err != nil { - return nil, errors.Wrap(err, "ensure baseline version") - } - } - if fromVersion == 0 { - fromVersion = plan.Version - } - - currentVersion, err := mongodb.NewReleasePlanColl().IncrementVersionByID(ctx, planID) - if err != nil { - return nil, errors.Wrap(err, "increment plan version") - } - plan.Version = currentVersion - - currentSnapshot, err := buildReleasePlanVersionSnapshot(plan, args.SectionKey) - if err != nil { - return nil, errors.Wrap(err, "build release plan section snapshot") - } - - if err := createReleasePlanVersion(planID, fromVersion, currentVersion, baseSnapshot, currentSnapshot, operator, account, args.SectionKey, releasePlanVersionSectionName(args.SectionKey, session.SectionName), "commit"); err != nil { - return nil, errors.Wrap(err, "create committed version") - } - if err := mongodb.NewReleasePlanLogColl().FillVersionsBySessionID(planID, args.SessionID, fromVersion, currentVersion); err != nil { - return nil, errors.Wrap(err, "fill log versions") - } - - session.BaseVersion = currentVersion - session.BaseSnapshot = encodeReleasePlanVersionSnapshot(currentSnapshot) - if err := persistReleasePlanEditingSession(session); err != nil { - return nil, errors.Wrap(err, "refresh editing session") - } - - broadcastReleasePlanCollaboration(planID) - return &models.ReleasePlanVersion{ - PlanID: planID, - Version: currentVersion, - Operator: operator, - Account: account, - SectionKey: args.SectionKey, - CreatedAt: time.Now().Unix(), - }, nil -} From e4e2ec7afa8a443f12e4750a18ffc9449f183a0f Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Wed, 20 May 2026 18:18:32 +0800 Subject: [PATCH 04/11] chore: extend release plan collaboration ttl Signed-off-by: huanghongbo-hhb --- .../aslan/core/release_plan/service/collaboration.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/collaboration.go b/pkg/microservice/aslan/core/release_plan/service/collaboration.go index acea0abd73..77f801e66b 100644 --- a/pkg/microservice/aslan/core/release_plan/service/collaboration.go +++ b/pkg/microservice/aslan/core/release_plan/service/collaboration.go @@ -45,7 +45,7 @@ const ( releasePlanCollabSessionKeyPrefix = "release-plan:collab:session:" releasePlanCollabPlanSetPrefix = "release-plan:collab:plan:" releasePlanCollabBroadcastChannel = "release-plan-collaboration" - releasePlanCollabSessionTTL = 45 * time.Second + releasePlanCollabSessionTTL = 90 * time.Second releasePlanCollabBroadcastTTL = 5 * time.Minute ) From 60bdadd41a3c747f6acd306145910a4fd583b42b Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Thu, 21 May 2026 10:01:31 +0800 Subject: [PATCH 05/11] fix: align release plan approval logs in watcher Signed-off-by: huanghongbo-hhb --- .../aslan/core/release_plan/service/watcher.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/watcher.go b/pkg/microservice/aslan/core/release_plan/service/watcher.go index f532369431..8eb733fabf 100644 --- a/pkg/microservice/aslan/core/release_plan/service/watcher.go +++ b/pkg/microservice/aslan/core/release_plan/service/watcher.go @@ -229,16 +229,18 @@ func updatePlanApproval(plan *models.ReleasePlan) error { return errors.Errorf("update plan %s approval error: %v", plan.Name, err) } var planLog *models.ReleasePlanLog + beforeStatus := config.ReleasePlanStatusWaitForApprove switch plan.Approval.Status { case config.StatusPassed: planLog = &models.ReleasePlanLog{ PlanID: plan.ID.Hex(), Username: UserNameSystem, + Account: "", Verb: VerbUpdate, TargetName: TargetTypeReleasePlanStatus, TargetType: TargetTypeReleasePlanStatus, Detail: DetailApprovalPass, - After: config.ReleasePlanStatusExecuting, + Before: beforeStatus, CreatedAt: time.Now().Unix(), } @@ -260,11 +262,19 @@ func updatePlanApproval(plan *models.ReleasePlan) error { sendWebhook = true setReleaseJobsForExecuting(plan) + planLog.After = plan.Status case config.StatusReject: planLog = &models.ReleasePlanLog{ - PlanID: plan.ID.Hex(), - Detail: DetailApprovalReject, - CreatedAt: time.Now().Unix(), + PlanID: plan.ID.Hex(), + Username: UserNameSystem, + Account: "", + Verb: VerbUpdate, + TargetName: TargetTypeReleasePlanStatus, + TargetType: TargetTypeReleasePlanStatus, + Detail: DetailApprovalReject, + Before: beforeStatus, + After: config.ReleasePlanStatusApprovalDenied, + CreatedAt: time.Now().Unix(), } plan.Status = config.ReleasePlanStatusApprovalDenied plan.ApprovalTime = time.Now().Unix() From 8271d8503814d8f4c91e3d4c43c6deda7631a517 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Thu, 21 May 2026 11:21:56 +0800 Subject: [PATCH 06/11] fix: harden release plan collaboration flows --- pkg/cli/initconfig/cmd/init.go | 1 + .../release_plan/service/collaboration.go | 91 ++++++++++++- .../service/collaboration_test.go | 80 +++++++++++ .../aslan/core/release_plan/service/diff.go | 10 +- .../core/release_plan/service/diff_test.go | 127 ++++++++++++++++++ .../core/release_plan/service/masking_test.go | 49 +++++++ .../core/release_plan/service/release_plan.go | 2 + .../aslan/core/release_plan/service/update.go | 6 +- .../core/release_plan/service/watcher.go | 1 + 9 files changed, 358 insertions(+), 9 deletions(-) create mode 100644 pkg/microservice/aslan/core/release_plan/service/collaboration_test.go create mode 100644 pkg/microservice/aslan/core/release_plan/service/diff_test.go create mode 100644 pkg/microservice/aslan/core/release_plan/service/masking_test.go diff --git a/pkg/cli/initconfig/cmd/init.go b/pkg/cli/initconfig/cmd/init.go index 8ab70ad404..ad8296e7ca 100644 --- a/pkg/cli/initconfig/cmd/init.go +++ b/pkg/cli/initconfig/cmd/init.go @@ -187,6 +187,7 @@ func createOrUpdateMongodbIndex(ctx context.Context) { commonrepo.NewLLMIntegrationColl(), commonrepo.NewReleasePlanColl(), commonrepo.NewReleasePlanLogColl(), + commonrepo.NewReleasePlanVersionColl(), commonrepo.NewEnvServiceVersionColl(), commonrepo.NewLabelColl(), commonrepo.NewSprintTemplateColl(), diff --git a/pkg/microservice/aslan/core/release_plan/service/collaboration.go b/pkg/microservice/aslan/core/release_plan/service/collaboration.go index 77f801e66b..2eb84b0ef2 100644 --- a/pkg/microservice/aslan/core/release_plan/service/collaboration.go +++ b/pkg/microservice/aslan/core/release_plan/service/collaboration.go @@ -20,7 +20,9 @@ import ( "context" "encoding/json" "fmt" + "net" "net/http" + "net/url" "sort" "strings" "sync" @@ -52,9 +54,7 @@ const ( var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, - CheckOrigin: func(r *http.Request) bool { - return true - }, + CheckOrigin: checkReleasePlanCollaborationOrigin, } type ReleasePlanEditingSession struct { @@ -141,6 +141,68 @@ func releasePlanCollabPlanSetKey(planID string) string { return fmt.Sprintf("%s%s:sessions", releasePlanCollabPlanSetPrefix, planID) } +func checkReleasePlanCollaborationOrigin(r *http.Request) bool { + if r == nil { + return false + } + + origin := strings.TrimSpace(r.Header.Get("Origin")) + if origin == "" { + return true + } + + originURL, err := url.Parse(origin) + if err != nil { + return false + } + + expectedHost := releasePlanRequestHost(r) + if expectedHost == "" { + return false + } + + originHost, originPort := splitReleasePlanHostPort(originURL.Host) + requestHost, requestPort := splitReleasePlanHostPort(expectedHost) + if originHost == "" || requestHost == "" { + return false + } + if !strings.EqualFold(originHost, requestHost) { + return false + } + if originPort != "" && requestPort != "" && originPort != requestPort { + return false + } + + return true +} + +func releasePlanRequestHost(r *http.Request) string { + if r == nil { + return "" + } + if forwardedHost := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); forwardedHost != "" { + if idx := strings.Index(forwardedHost, ","); idx >= 0 { + forwardedHost = forwardedHost[:idx] + } + return strings.TrimSpace(forwardedHost) + } + return strings.TrimSpace(r.Host) +} + +func splitReleasePlanHostPort(rawHost string) (string, string) { + rawHost = strings.TrimSpace(rawHost) + if rawHost == "" { + return "", "" + } + + if host, port, err := net.SplitHostPort(rawHost); err == nil { + return strings.ToLower(host), port + } + + parsed := &url.URL{Host: rawHost} + return strings.ToLower(parsed.Hostname()), parsed.Port() +} + func broadcastReleasePlanCollaboration(planID string) { if planID == "" { return @@ -387,6 +449,16 @@ func getReleasePlanEditingSession(planID, sessionID string) (*ReleasePlanEditing return session, nil } +func canManageReleasePlanEditingSession(session *ReleasePlanEditingSession, userID string, isSystemAdmin bool) bool { + if isSystemAdmin { + return true + } + if session == nil || userID == "" { + return false + } + return session.UserID == userID +} + func OpenReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, planID string) error { return openReleasePlanCollaborationWS(gCtx, ctx, planID) } @@ -438,6 +510,10 @@ func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, pla continue } existingSession, _ := getReleasePlanEditingSession(planID, msg.SessionID) + if existingSession != nil && !canManageReleasePlanEditingSession(existingSession, ctx.UserID, ctx.Resources != nil && ctx.Resources.IsSystemAdmin) { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: "permission denied"}) + continue + } session := &ReleasePlanEditingSession{ PlanID: planID, SessionID: msg.SessionID, @@ -473,6 +549,15 @@ func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, pla queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "snapshot", Snapshot: snapshot}) } case "leave": + session, err := getReleasePlanEditingSession(planID, msg.SessionID) + if err != nil { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) + continue + } + if !canManageReleasePlanEditingSession(session, ctx.UserID, ctx.Resources != nil && ctx.Resources.IsSystemAdmin) { + queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: "permission denied"}) + continue + } if err := removeReleasePlanEditingSession(planID, msg.SessionID); err != nil { queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) } diff --git a/pkg/microservice/aslan/core/release_plan/service/collaboration_test.go b/pkg/microservice/aslan/core/release_plan/service/collaboration_test.go new file mode 100644 index 0000000000..b6628c6efc --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/collaboration_test.go @@ -0,0 +1,80 @@ +package service + +import ( + "net/http" + "testing" +) + +func TestCanManageReleasePlanEditingSession(t *testing.T) { + session := &ReleasePlanEditingSession{ + SessionID: "session-1", + UserID: "owner", + } + + if !canManageReleasePlanEditingSession(session, "owner", false) { + t.Fatalf("expected session owner to manage editing session") + } + if canManageReleasePlanEditingSession(session, "viewer", false) { + t.Fatalf("expected non-owner to be denied") + } + if !canManageReleasePlanEditingSession(session, "viewer", true) { + t.Fatalf("expected system admin to manage editing session") + } + if canManageReleasePlanEditingSession(nil, "owner", false) { + t.Fatalf("expected nil session to be denied") + } +} + +func TestCheckReleasePlanCollaborationOrigin(t *testing.T) { + t.Run("allow empty origin", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "http://zadig.example.com", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req.Host = "zadig.example.com" + + if !checkReleasePlanCollaborationOrigin(req) { + t.Fatalf("expected empty origin to be allowed") + } + }) + + t.Run("allow same host", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "http://internal", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req.Host = "zadig.example.com" + req.Header.Set("Origin", "https://zadig.example.com") + + if !checkReleasePlanCollaborationOrigin(req) { + t.Fatalf("expected same origin host to be allowed") + } + }) + + t.Run("allow forwarded host", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "http://internal", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req.Host = "aslan:25000" + req.Header.Set("X-Forwarded-Host", "zadig.example.com") + req.Header.Set("Origin", "https://zadig.example.com") + + if !checkReleasePlanCollaborationOrigin(req) { + t.Fatalf("expected forwarded host to be honored") + } + }) + + t.Run("reject cross origin host", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "http://zadig.example.com", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + req.Host = "zadig.example.com" + req.Header.Set("Origin", "https://evil.example.com") + + if checkReleasePlanCollaborationOrigin(req) { + t.Fatalf("expected cross origin host to be rejected") + } + }) +} diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go index 16752c3c55..a2ed26c49c 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -125,11 +125,11 @@ func GetReleasePlanVersionDiff(planID string, version int64) (*ReleasePlanVersio return nil, errors.Wrap(err, "get version") } - fromData, err := toGenericMap(current.BaseSnapshot) + fromData, err := toGenericValue(current.BaseSnapshot) if err != nil { return nil, errors.Wrap(err, "convert base snapshot") } - toData, err := toGenericMap(current.Snapshot) + toData, err := toGenericValue(current.Snapshot) if err != nil { return nil, errors.Wrap(err, "convert current snapshot") } @@ -200,15 +200,15 @@ func previousReleasePlanVersion(version int64) int64 { return version - 1 } -func toGenericMap(value interface{}) (map[string]interface{}, error) { +func toGenericValue(value interface{}) (interface{}, error) { if value == nil { - return map[string]interface{}{}, nil + return nil, nil } payload, err := json.Marshal(value) if err != nil { return nil, err } - resp := map[string]interface{}{} + var resp interface{} if err := json.Unmarshal(payload, &resp); err != nil { return nil, err } diff --git a/pkg/microservice/aslan/core/release_plan/service/diff_test.go b/pkg/microservice/aslan/core/release_plan/service/diff_test.go new file mode 100644 index 0000000000..15053c8041 --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/diff_test.go @@ -0,0 +1,127 @@ +package service + +import "testing" + +func TestGetReleasePlanArrayItemKey(t *testing.T) { + t.Run("job key", func(t *testing.T) { + key, ok := getReleasePlanArrayItemKey(map[string]interface{}{ + "name": "build", + "type": "zadig-build", + "id": "job-id", + }) + if !ok { + t.Fatalf("expected key") + } + if key != "build|zadig-build|job-id" { + t.Fatalf("unexpected key: %s", key) + } + }) + + t.Run("service key", func(t *testing.T) { + key, ok := getReleasePlanArrayItemKey(map[string]interface{}{ + "service_name": "gateway", + "service_module": "gateway", + }) + if !ok { + t.Fatalf("expected key") + } + if key != "gateway/gateway" { + t.Fatalf("unexpected key: %s", key) + } + }) +} + +func TestBuildReleasePlanDiffLabel(t *testing.T) { + label := buildReleasePlanDiffLabel("jobs[release-job|workflow|job-id].spec.workflow.stages[build].jobs[deploy|zadig-deploy].spec.namespace") + expected := "任务 release-job / 阶段 build / 任务 deploy / 命名空间" + if label != expected { + t.Fatalf("unexpected label: %s", label) + } +} + +func TestReleasePlanDiffPathRules(t *testing.T) { + if !shouldIgnoreReleasePlanDiffPath("update_time") { + t.Fatalf("expected update_time to be ignored") + } + if !isLargeTextReleasePlanDiffPath("jobs[deploy].spec.script", "echo 1", "echo 2") { + t.Fatalf("expected script to be marked as large text") + } +} + +func TestReleasePlanSubtreeHashPrune(t *testing.T) { + left := map[string]interface{}{ + "a": 1.0, + "b": "x", + "c": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}}, + "d": map[string]interface{}{"name": "demo"}, + } + right := map[string]interface{}{ + "a": 1.0, + "b": "x", + "c": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}}, + "d": map[string]interface{}{"name": "demo"}, + } + + equal, hashed := equalReleasePlanSubtreeByHash(left, right) + if !hashed { + t.Fatalf("expected hash pruning to be enabled for large maps") + } + if !equal { + t.Fatalf("expected identical subtrees to be equal") + } +} + +func TestReleasePlanSubtreeHashPruneSkipSmallNodes(t *testing.T) { + left := map[string]interface{}{"a": 1.0, "b": 2.0} + right := map[string]interface{}{"a": 1.0, "b": 2.0} + + equal, hashed := equalReleasePlanSubtreeByHash(left, right) + if hashed { + t.Fatalf("expected hash pruning to skip small maps") + } + if equal { + t.Fatalf("hash shortcut should not report equality for skipped small maps") + } +} + +func TestToGenericValueSupportsRootArrays(t *testing.T) { + value := []map[string]interface{}{ + {"id": "job-1", "name": "job-a"}, + {"id": "job-2", "name": "job-b"}, + } + + generic, err := toGenericValue(value) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + items, ok := generic.([]interface{}) + if !ok { + t.Fatalf("expected array root, got %T", generic) + } + if len(items) != 2 { + t.Fatalf("unexpected item count: %d", len(items)) + } +} + +func TestSanitizeReleasePlanValueForDisplay(t *testing.T) { + value := map[string]interface{}{ + "vars": []interface{}{ + map[string]interface{}{ + "key": "DB_PASSWORD", + "value": "secret-token", + "is_credential": true, + }, + }, + } + + sanitized := sanitizeReleasePlanValueForDisplay(value).(map[string]interface{}) + vars := sanitized["vars"].([]interface{}) + item := vars[0].(map[string]interface{}) + if item["value"] != releasePlanMaskedValueDisplay { + t.Fatalf("expected credential value to be hidden") + } + if item["key"] != "DB_PASSWORD" { + t.Fatalf("expected non-sensitive fields to stay visible") + } +} diff --git a/pkg/microservice/aslan/core/release_plan/service/masking_test.go b/pkg/microservice/aslan/core/release_plan/service/masking_test.go new file mode 100644 index 0000000000..260bd6199b --- /dev/null +++ b/pkg/microservice/aslan/core/release_plan/service/masking_test.go @@ -0,0 +1,49 @@ +package service + +import "testing" + +func TestSanitizeReleasePlanValueForDisplayMasksRawSensitiveFields(t *testing.T) { + value := map[string]interface{}{ + "key_vals": []interface{}{ + map[string]interface{}{ + "key": "DB_PASSWORD", + "value": "secret-token", + "is_credential": true, + }, + }, + } + + sanitized := sanitizeReleasePlanValueForDisplay(value).(map[string]interface{}) + keyVals := sanitized["key_vals"].([]interface{}) + item := keyVals[0].(map[string]interface{}) + if item["value"] != releasePlanMaskedValueDisplay { + t.Fatalf("expected credential value to be hidden") + } +} + +func TestIsReleasePlanSensitiveValueNode(t *testing.T) { + if isReleasePlanSensitiveValueNode(map[string]interface{}{"user_id": "alice"}) { + t.Fatalf("plain user field should not be treated as sensitive") + } + if !isReleasePlanSensitiveValueNode(map[string]interface{}{"is_credential": true, "value": "secret"}) { + t.Fatalf("credential flag should be treated as sensitive") + } + if !isReleasePlanSensitiveValueNode(map[string]interface{}{"is_sensitive": true, "value": "secret"}) { + t.Fatalf("keyvault sensitive flag should be treated as sensitive") + } +} + +func TestHasReleasePlanRawSensitiveValue(t *testing.T) { + if !hasReleasePlanRawSensitiveValue(map[string]interface{}{ + "is_credential": true, + "value": "secret", + }) { + t.Fatalf("expected raw credential value to require sanitize") + } + if hasReleasePlanRawSensitiveValue(map[string]interface{}{ + "is_credential": true, + "value": maskReleasePlanValue("secret"), + }) { + t.Fatalf("expected masked credential value to skip re-sanitize") + } +} diff --git a/pkg/microservice/aslan/core/release_plan/service/release_plan.go b/pkg/microservice/aslan/core/release_plan/service/release_plan.go index 55cc009acd..cb006e7e15 100644 --- a/pkg/microservice/aslan/core/release_plan/service/release_plan.go +++ b/pkg/microservice/aslan/core/release_plan/service/release_plan.go @@ -1040,6 +1040,8 @@ func SkipReleaseJob(c *handler.Context, planID string, args *SkipReleaseJobArgs, } else { plan.SuccessTime = time.Now().Unix() } + + sendWebhook = true } if err = mongodb.NewReleasePlanColl().UpdateByID(ctx, planID, plan); err != nil { diff --git a/pkg/microservice/aslan/core/release_plan/service/update.go b/pkg/microservice/aslan/core/release_plan/service/update.go index 1fa16fe161..fbb9776673 100644 --- a/pkg/microservice/aslan/core/release_plan/service/update.go +++ b/pkg/microservice/aslan/core/release_plan/service/update.go @@ -375,7 +375,11 @@ func (u *UpdateReleaseJobUpdater) Update(plan *models.ReleasePlan) (before inter if job.Type != u.Type { return nil, nil, fmt.Errorf("job type cannot be changed") } - before, after = job, u + beforeJob := new(models.ReleaseJob) + if err := models.IToi(job, beforeJob); err != nil { + return nil, nil, errors.Wrap(err, "clone release job before update") + } + before, after = beforeJob, u job.Name = u.Name job.Manager = u.Manager job.ManagerID = u.ManagerID diff --git a/pkg/microservice/aslan/core/release_plan/service/watcher.go b/pkg/microservice/aslan/core/release_plan/service/watcher.go index 8eb733fabf..9e297a3bec 100644 --- a/pkg/microservice/aslan/core/release_plan/service/watcher.go +++ b/pkg/microservice/aslan/core/release_plan/service/watcher.go @@ -165,6 +165,7 @@ func WatchApproval() { }) if err != nil { log.Errorf("list approval workflow error: %v", err) + releasePlanApprovalLock.Unlock() continue } for _, plan := range list { From d196d78c4c751c67f332e74553f929e15ea1b49f Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Thu, 21 May 2026 16:12:35 +0800 Subject: [PATCH 07/11] feat: support ordered release plan diffs --- .../aslan/core/release_plan/service/diff.go | 205 +++++++++++++++--- .../core/release_plan/service/diff_test.go | 97 +++++++++ 2 files changed, 277 insertions(+), 25 deletions(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go index a2ed26c49c..8a8478055a 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -33,6 +33,7 @@ import ( const ( releasePlanHashPruneMinMapKeys = 4 releasePlanHashPruneMinArrayItems = 4 + releasePlanDiffChangeTypeOrder = "order_changed" ) type ReleasePlanVersionDiffResponse struct { @@ -49,23 +50,47 @@ type ReleasePlanVersionDiffGroup struct { Changes []*ReleasePlanVersionDiffChange `json:"changes"` } +type ReleasePlanVersionDiffOrderItem struct { + Key string `json:"key,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` +} + type ReleasePlanVersionDiffChange struct { - TaskName string `json:"task_name,omitempty"` - TaskType string `json:"task_type,omitempty"` - Path string `json:"path"` - Label string `json:"label"` - Before interface{} `json:"before,omitempty"` - After interface{} `json:"after,omitempty"` - LargeText bool `json:"large_text,omitempty"` - Masked bool `json:"masked,omitempty"` + TaskName string `json:"task_name,omitempty"` + TaskType string `json:"task_type,omitempty"` + ChangeType string `json:"change_type,omitempty"` + Path string `json:"path"` + Label string `json:"label"` + Before interface{} `json:"before,omitempty"` + After interface{} `json:"after,omitempty"` + BeforeOrder []*ReleasePlanVersionDiffOrderItem `json:"before_order,omitempty"` + AfterOrder []*ReleasePlanVersionDiffOrderItem `json:"after_order,omitempty"` + LargeText bool `json:"large_text,omitempty"` + Masked bool `json:"masked,omitempty"` } type releasePlanRawDiffEntry struct { - Path string - Before interface{} - After interface{} + Path string + ChangeType string + Before interface{} + After interface{} + BeforeOrder []*ReleasePlanVersionDiffOrderItem + AfterOrder []*ReleasePlanVersionDiffOrderItem +} + +type releasePlanDiffContext struct { + GroupType string } +type releasePlanArrayDiffStrategy int + +const ( + releasePlanArrayDiffStrategyIndex releasePlanArrayDiffStrategy = iota + releasePlanArrayDiffStrategyKeyedUnordered + releasePlanArrayDiffStrategyKeyedOrdered +) + var releasePlanFieldLabels = map[string]string{ "name": "名称", "manager": "负责人", @@ -100,6 +125,7 @@ var releasePlanFieldLabels = map[string]string{ "key_vals": "变量", "key": "变量名", "value": "变量值", + "order": "顺序", "params": "参数", "stages": "阶段", "jobs": "任务", @@ -137,7 +163,7 @@ func GetReleasePlanVersionDiff(planID string, version int64) (*ReleasePlanVersio groupKey, groupName, groupType := releasePlanVersionDiffGroup(current.SectionKey, current.SectionName) rawEntries := make([]*releasePlanRawDiffEntry, 0) - diffReleasePlanValues("", fromData, toData, &rawEntries) + diffReleasePlanValues(releasePlanDiffContext{GroupType: groupType}, "", fromData, toData, &rawEntries) groupMap := map[string]*ReleasePlanVersionDiffGroup{} groupOrder := make([]string, 0) @@ -159,12 +185,16 @@ func GetReleasePlanVersionDiff(planID string, version int64) (*ReleasePlanVersio } change := &ReleasePlanVersionDiffChange{ - TaskName: taskName, - TaskType: taskType, - Path: entry.Path, - Label: buildReleasePlanDiffLabel(entry.Path), - } - if isMaskedReleasePlanDiffValue(entry.Before) || isMaskedReleasePlanDiffValue(entry.After) { + TaskName: taskName, + TaskType: taskType, + ChangeType: entry.ChangeType, + Path: entry.Path, + Label: buildReleasePlanDiffLabel(entry.Path), + } + if entry.ChangeType == releasePlanDiffChangeTypeOrder { + change.BeforeOrder = entry.BeforeOrder + change.AfterOrder = entry.AfterOrder + } else if isMaskedReleasePlanDiffValue(entry.Before) || isMaskedReleasePlanDiffValue(entry.After) { change.Masked = true } else if isLargeTextReleasePlanDiffPath(entry.Path, entry.Before, entry.After) { change.LargeText = true @@ -215,7 +245,7 @@ func toGenericValue(value interface{}) (interface{}, error) { return resp, nil } -func diffReleasePlanValues(path string, left, right interface{}, entries *[]*releasePlanRawDiffEntry) { +func diffReleasePlanValues(ctx releasePlanDiffContext, path string, left, right interface{}, entries *[]*releasePlanRawDiffEntry) { if shouldIgnoreReleasePlanDiffPath(path) { return } @@ -245,7 +275,7 @@ func diffReleasePlanValues(path string, left, right interface{}, entries *[]*rel sort.Strings(keys) for _, key := range keys { nextPath := joinReleasePlanDiffPath(path, key) - diffReleasePlanValues(nextPath, leftMap[key], rightMap[key], entries) + diffReleasePlanValues(ctx, nextPath, leftMap[key], rightMap[key], entries) } return } @@ -253,7 +283,7 @@ func diffReleasePlanValues(path string, left, right interface{}, entries *[]*rel leftList, leftIsList := left.([]interface{}) rightList, rightIsList := right.([]interface{}) if leftIsList || rightIsList { - diffReleasePlanArray(path, leftList, rightList, entries) + diffReleasePlanArray(ctx, path, leftList, rightList, entries) return } @@ -308,10 +338,16 @@ func hashReleasePlanSubtree(value interface{}) (string, error) { return hex.EncodeToString(sum[:]), nil } -func diffReleasePlanArray(path string, left, right []interface{}, entries *[]*releasePlanRawDiffEntry) { +func diffReleasePlanArray(ctx releasePlanDiffContext, path string, left, right []interface{}, entries *[]*releasePlanRawDiffEntry) { leftMap, leftOrdered, leftMapped := buildReleasePlanArrayMap(left) rightMap, rightOrdered, rightMapped := buildReleasePlanArrayMap(right) - if leftMapped && rightMapped { + strategy := resolveReleasePlanArrayDiffStrategy(ctx, path, leftMapped, rightMapped) + if strategy == releasePlanArrayDiffStrategyKeyedOrdered { + if entry := buildReleasePlanArrayOrderChange(path, left, right, leftMap, leftOrdered, rightMap, rightOrdered); entry != nil { + *entries = append(*entries, entry) + } + } + if strategy == releasePlanArrayDiffStrategyKeyedOrdered || strategy == releasePlanArrayDiffStrategyKeyedUnordered { keySet := map[string]struct{}{} keys := make([]string, 0) for _, key := range leftOrdered { @@ -328,7 +364,7 @@ func diffReleasePlanArray(path string, left, right []interface{}, entries *[]*re } for _, key := range keys { nextPath := fmt.Sprintf("%s[%s]", path, key) - diffReleasePlanValues(nextPath, leftMap[key], rightMap[key], entries) + diffReleasePlanValues(ctx, nextPath, leftMap[key], rightMap[key], entries) } return } @@ -346,8 +382,124 @@ func diffReleasePlanArray(path string, left, right []interface{}, entries *[]*re if i < len(right) { rightVal = right[i] } - diffReleasePlanValues(nextPath, leftVal, rightVal, entries) + diffReleasePlanValues(ctx, nextPath, leftVal, rightVal, entries) + } +} + +func resolveReleasePlanArrayDiffStrategy(ctx releasePlanDiffContext, path string, leftMapped, rightMapped bool) releasePlanArrayDiffStrategy { + if !leftMapped || !rightMapped { + return releasePlanArrayDiffStrategyIndex + } + if shouldTrackReleasePlanArrayOrder(ctx, path) { + return releasePlanArrayDiffStrategyKeyedOrdered + } + return releasePlanArrayDiffStrategyKeyedUnordered +} + +func shouldTrackReleasePlanArrayOrder(ctx releasePlanDiffContext, path string) bool { + return ctx.GroupType == releasePlanVersionSectionJobsOrder && path == "" +} + +func buildReleasePlanArrayOrderChange( + path string, + left, right []interface{}, + leftMap map[string]interface{}, + leftOrdered []string, + rightMap map[string]interface{}, + rightOrdered []string, +) *releasePlanRawDiffEntry { + if !hasReleasePlanArrayRelativeOrderChange(leftMap, leftOrdered, rightMap, rightOrdered) { + return nil + } + + return &releasePlanRawDiffEntry{ + Path: joinReleasePlanDiffPath(path, "order"), + ChangeType: releasePlanDiffChangeTypeOrder, + BeforeOrder: buildReleasePlanArrayOrderItems(left, leftOrdered), + AfterOrder: buildReleasePlanArrayOrderItems(right, rightOrdered), + } +} + +func hasReleasePlanArrayRelativeOrderChange( + leftMap map[string]interface{}, + leftOrdered []string, + rightMap map[string]interface{}, + rightOrdered []string, +) bool { + leftShared := filterReleasePlanArrayOrderedKeys(leftOrdered, rightMap) + rightShared := filterReleasePlanArrayOrderedKeys(rightOrdered, leftMap) + return !reflect.DeepEqual(leftShared, rightShared) +} + +func filterReleasePlanArrayOrderedKeys(orderedKeys []string, otherMap map[string]interface{}) []string { + resp := make([]string, 0, len(orderedKeys)) + for _, key := range orderedKeys { + if _, exists := otherMap[key]; exists { + resp = append(resp, key) + } } + return resp +} + +func buildReleasePlanArrayOrderItems(values []interface{}, orderedKeys []string) []*ReleasePlanVersionDiffOrderItem { + resp := make([]*ReleasePlanVersionDiffOrderItem, 0, len(values)) + for idx, item := range values { + key := "" + if idx < len(orderedKeys) { + key = orderedKeys[idx] + } + resp = append(resp, buildReleasePlanArrayOrderItem(item, key)) + } + return resp +} + +func buildReleasePlanArrayOrderItem(item interface{}, key string) *ReleasePlanVersionDiffOrderItem { + resp := &ReleasePlanVersionDiffOrderItem{Key: key} + + switch value := item.(type) { + case map[string]interface{}: + if id, ok := getStringField(value, "id"); ok { + resp.ID = id + } + if name, ok := getStringField(value, "name"); ok { + resp.Name = name + return resp + } + if itemKey, ok := getStringField(value, "key"); ok { + resp.Name = itemKey + return resp + } + if service, ok := getStringField(value, "service_name"); ok { + if module, ok := getStringField(value, "service_module"); ok { + resp.Name = fmt.Sprintf("%s/%s", service, module) + } else { + resp.Name = service + } + return resp + } + if repo, ok := getStringField(value, "repo_name"); ok { + namespace, _ := getStringField(value, "repo_namespace") + remote, _ := getStringField(value, "remote_name") + resp.Name = strings.Trim(strings.Trim(fmt.Sprintf("%s/%s/%s", namespace, repo, remote), "/"), "/") + return resp + } + if target, ok := getStringField(value, "target"); ok { + resp.Name = target + return resp + } + if userID, ok := getStringField(value, "user_id"); ok { + resp.Name = userID + return resp + } + } + + if resp.Name == "" && key != "" { + resp.Name = key + } + if resp.Name == "" { + resp.Name = fmt.Sprintf("%v", item) + } + return resp } func buildReleasePlanArrayMap(values []interface{}) (map[string]interface{}, []string, bool) { @@ -377,6 +529,9 @@ func getReleasePlanArrayItemKey(item interface{}) (string, bool) { } return fmt.Sprintf("%s|%s", name, jobType), true } + if id, ok := getStringField(value, "id"); ok { + return fmt.Sprintf("%s|%s", name, id), true + } return name, true } if key, ok := getStringField(value, "key"); ok { diff --git a/pkg/microservice/aslan/core/release_plan/service/diff_test.go b/pkg/microservice/aslan/core/release_plan/service/diff_test.go index 15053c8041..21fc05a522 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff_test.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff_test.go @@ -29,6 +29,19 @@ func TestGetReleasePlanArrayItemKey(t *testing.T) { t.Fatalf("unexpected key: %s", key) } }) + + t.Run("name and id key", func(t *testing.T) { + key, ok := getReleasePlanArrayItemKey(map[string]interface{}{ + "id": "job-id", + "name": "build", + }) + if !ok { + t.Fatalf("expected key") + } + if key != "build|job-id" { + t.Fatalf("unexpected key: %s", key) + } + }) } func TestBuildReleasePlanDiffLabel(t *testing.T) { @@ -39,6 +52,13 @@ func TestBuildReleasePlanDiffLabel(t *testing.T) { } } +func TestBuildReleasePlanDiffLabelForOrderChange(t *testing.T) { + label := buildReleasePlanDiffLabel("order") + if label != "顺序" { + t.Fatalf("unexpected order label: %s", label) + } +} + func TestReleasePlanDiffPathRules(t *testing.T) { if !shouldIgnoreReleasePlanDiffPath("update_time") { t.Fatalf("expected update_time to be ignored") @@ -125,3 +145,80 @@ func TestSanitizeReleasePlanValueForDisplay(t *testing.T) { t.Fatalf("expected non-sensitive fields to stay visible") } } + +func TestDiffReleasePlanValuesDetectsOrderedArrayChanges(t *testing.T) { + left := []interface{}{ + map[string]interface{}{"id": "job-1", "name": "build"}, + map[string]interface{}{"id": "job-2", "name": "deploy"}, + } + right := []interface{}{ + map[string]interface{}{"id": "job-2", "name": "deploy"}, + map[string]interface{}{"id": "job-1", "name": "build"}, + } + + entries := make([]*releasePlanRawDiffEntry, 0) + diffReleasePlanValues(releasePlanDiffContext{GroupType: releasePlanVersionSectionJobsOrder}, "", left, right, &entries) + + if len(entries) != 1 { + t.Fatalf("expected exactly one order change, got %d", len(entries)) + } + entry := entries[0] + if entry.ChangeType != releasePlanDiffChangeTypeOrder { + t.Fatalf("unexpected change type: %s", entry.ChangeType) + } + if entry.Path != "order" { + t.Fatalf("unexpected path: %s", entry.Path) + } + if len(entry.BeforeOrder) != 2 || len(entry.AfterOrder) != 2 { + t.Fatalf("unexpected order item counts: before=%d after=%d", len(entry.BeforeOrder), len(entry.AfterOrder)) + } + if entry.BeforeOrder[0].ID != "job-1" || entry.BeforeOrder[0].Name != "build" { + t.Fatalf("unexpected first before order item: %#v", entry.BeforeOrder[0]) + } + if entry.AfterOrder[0].ID != "job-2" || entry.AfterOrder[0].Name != "deploy" { + t.Fatalf("unexpected first after order item: %#v", entry.AfterOrder[0]) + } +} + +func TestDiffReleasePlanValuesDetectsOrderedArrayChangesWithDuplicateNames(t *testing.T) { + left := []interface{}{ + map[string]interface{}{"id": "job-1", "name": "build"}, + map[string]interface{}{"id": "job-2", "name": "build"}, + } + right := []interface{}{ + map[string]interface{}{"id": "job-2", "name": "build"}, + map[string]interface{}{"id": "job-1", "name": "build"}, + } + + entries := make([]*releasePlanRawDiffEntry, 0) + diffReleasePlanValues(releasePlanDiffContext{GroupType: releasePlanVersionSectionJobsOrder}, "", left, right, &entries) + + if len(entries) != 1 { + t.Fatalf("expected exactly one order change, got %d", len(entries)) + } + entry := entries[0] + if entry.ChangeType != releasePlanDiffChangeTypeOrder { + t.Fatalf("unexpected change type: %s", entry.ChangeType) + } + if entry.BeforeOrder[0].ID != "job-1" || entry.AfterOrder[0].ID != "job-2" { + t.Fatalf("unexpected duplicate-name order diff: before=%#v after=%#v", entry.BeforeOrder[0], entry.AfterOrder[0]) + } +} + +func TestDiffReleasePlanValuesKeepsDefaultKeyedArrayBehavior(t *testing.T) { + left := []interface{}{ + map[string]interface{}{"id": "stage-1", "name": "build"}, + map[string]interface{}{"id": "stage-2", "name": "deploy"}, + } + right := []interface{}{ + map[string]interface{}{"id": "stage-2", "name": "deploy"}, + map[string]interface{}{"id": "stage-1", "name": "build"}, + } + + entries := make([]*releasePlanRawDiffEntry, 0) + diffReleasePlanValues(releasePlanDiffContext{GroupType: "job"}, "spec.workflow.stages", left, right, &entries) + + if len(entries) != 0 { + t.Fatalf("expected keyed unordered arrays to ignore pure reorder by default, got %d entries", len(entries)) + } +} From 9468f00823113518161cf9dfc0cf07618a2f7f0c Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Thu, 21 May 2026 16:15:20 +0800 Subject: [PATCH 08/11] chore: remove diff tests from pr --- .../core/release_plan/service/diff_test.go | 97 ------------------- 1 file changed, 97 deletions(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/diff_test.go b/pkg/microservice/aslan/core/release_plan/service/diff_test.go index 21fc05a522..15053c8041 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff_test.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff_test.go @@ -29,19 +29,6 @@ func TestGetReleasePlanArrayItemKey(t *testing.T) { t.Fatalf("unexpected key: %s", key) } }) - - t.Run("name and id key", func(t *testing.T) { - key, ok := getReleasePlanArrayItemKey(map[string]interface{}{ - "id": "job-id", - "name": "build", - }) - if !ok { - t.Fatalf("expected key") - } - if key != "build|job-id" { - t.Fatalf("unexpected key: %s", key) - } - }) } func TestBuildReleasePlanDiffLabel(t *testing.T) { @@ -52,13 +39,6 @@ func TestBuildReleasePlanDiffLabel(t *testing.T) { } } -func TestBuildReleasePlanDiffLabelForOrderChange(t *testing.T) { - label := buildReleasePlanDiffLabel("order") - if label != "顺序" { - t.Fatalf("unexpected order label: %s", label) - } -} - func TestReleasePlanDiffPathRules(t *testing.T) { if !shouldIgnoreReleasePlanDiffPath("update_time") { t.Fatalf("expected update_time to be ignored") @@ -145,80 +125,3 @@ func TestSanitizeReleasePlanValueForDisplay(t *testing.T) { t.Fatalf("expected non-sensitive fields to stay visible") } } - -func TestDiffReleasePlanValuesDetectsOrderedArrayChanges(t *testing.T) { - left := []interface{}{ - map[string]interface{}{"id": "job-1", "name": "build"}, - map[string]interface{}{"id": "job-2", "name": "deploy"}, - } - right := []interface{}{ - map[string]interface{}{"id": "job-2", "name": "deploy"}, - map[string]interface{}{"id": "job-1", "name": "build"}, - } - - entries := make([]*releasePlanRawDiffEntry, 0) - diffReleasePlanValues(releasePlanDiffContext{GroupType: releasePlanVersionSectionJobsOrder}, "", left, right, &entries) - - if len(entries) != 1 { - t.Fatalf("expected exactly one order change, got %d", len(entries)) - } - entry := entries[0] - if entry.ChangeType != releasePlanDiffChangeTypeOrder { - t.Fatalf("unexpected change type: %s", entry.ChangeType) - } - if entry.Path != "order" { - t.Fatalf("unexpected path: %s", entry.Path) - } - if len(entry.BeforeOrder) != 2 || len(entry.AfterOrder) != 2 { - t.Fatalf("unexpected order item counts: before=%d after=%d", len(entry.BeforeOrder), len(entry.AfterOrder)) - } - if entry.BeforeOrder[0].ID != "job-1" || entry.BeforeOrder[0].Name != "build" { - t.Fatalf("unexpected first before order item: %#v", entry.BeforeOrder[0]) - } - if entry.AfterOrder[0].ID != "job-2" || entry.AfterOrder[0].Name != "deploy" { - t.Fatalf("unexpected first after order item: %#v", entry.AfterOrder[0]) - } -} - -func TestDiffReleasePlanValuesDetectsOrderedArrayChangesWithDuplicateNames(t *testing.T) { - left := []interface{}{ - map[string]interface{}{"id": "job-1", "name": "build"}, - map[string]interface{}{"id": "job-2", "name": "build"}, - } - right := []interface{}{ - map[string]interface{}{"id": "job-2", "name": "build"}, - map[string]interface{}{"id": "job-1", "name": "build"}, - } - - entries := make([]*releasePlanRawDiffEntry, 0) - diffReleasePlanValues(releasePlanDiffContext{GroupType: releasePlanVersionSectionJobsOrder}, "", left, right, &entries) - - if len(entries) != 1 { - t.Fatalf("expected exactly one order change, got %d", len(entries)) - } - entry := entries[0] - if entry.ChangeType != releasePlanDiffChangeTypeOrder { - t.Fatalf("unexpected change type: %s", entry.ChangeType) - } - if entry.BeforeOrder[0].ID != "job-1" || entry.AfterOrder[0].ID != "job-2" { - t.Fatalf("unexpected duplicate-name order diff: before=%#v after=%#v", entry.BeforeOrder[0], entry.AfterOrder[0]) - } -} - -func TestDiffReleasePlanValuesKeepsDefaultKeyedArrayBehavior(t *testing.T) { - left := []interface{}{ - map[string]interface{}{"id": "stage-1", "name": "build"}, - map[string]interface{}{"id": "stage-2", "name": "deploy"}, - } - right := []interface{}{ - map[string]interface{}{"id": "stage-2", "name": "deploy"}, - map[string]interface{}{"id": "stage-1", "name": "build"}, - } - - entries := make([]*releasePlanRawDiffEntry, 0) - diffReleasePlanValues(releasePlanDiffContext{GroupType: "job"}, "spec.workflow.stages", left, right, &entries) - - if len(entries) != 0 { - t.Fatalf("expected keyed unordered arrays to ignore pure reorder by default, got %d entries", len(entries)) - } -} From 21b8f89c3a0e0f35c4eba47b8e78884f54ec6ab8 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Fri, 22 May 2026 11:16:51 +0800 Subject: [PATCH 09/11] refactor: simplify release plan diff labels --- pkg/microservice/aslan/core/release_plan/service/diff.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go index 8a8478055a..441ac03fef 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -480,7 +480,7 @@ func buildReleasePlanArrayOrderItem(item interface{}, key string) *ReleasePlanVe if repo, ok := getStringField(value, "repo_name"); ok { namespace, _ := getStringField(value, "repo_namespace") remote, _ := getStringField(value, "remote_name") - resp.Name = strings.Trim(strings.Trim(fmt.Sprintf("%s/%s/%s", namespace, repo, remote), "/"), "/") + resp.Name = strings.Trim(fmt.Sprintf("%s/%s/%s", namespace, repo, remote), "/") return resp } if target, ok := getStringField(value, "target"); ok { From 40fd37718256030391f6aad53bdee0cd1344dceb Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Fri, 22 May 2026 14:19:32 +0800 Subject: [PATCH 10/11] fix release plan collaboration cleanup and diff order Signed-off-by: huanghongbo-hhb --- .../release_plan/service/collaboration.go | 92 ++- .../aslan/core/release_plan/service/diff.go | 768 ++++++++++++++++-- 2 files changed, 805 insertions(+), 55 deletions(-) diff --git a/pkg/microservice/aslan/core/release_plan/service/collaboration.go b/pkg/microservice/aslan/core/release_plan/service/collaboration.go index 2eb84b0ef2..d87914e25d 100644 --- a/pkg/microservice/aslan/core/release_plan/service/collaboration.go +++ b/pkg/microservice/aslan/core/release_plan/service/collaboration.go @@ -29,6 +29,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/gorilla/websocket" "github.com/pkg/errors" @@ -60,6 +61,7 @@ var upgrader = websocket.Upgrader{ type ReleasePlanEditingSession struct { PlanID string `json:"plan_id"` SessionID string `json:"session_id"` + ConnectionID string `json:"connection_id,omitempty"` UserID string `json:"user_id"` UserName string `json:"user_name"` Account string `json:"account"` @@ -103,8 +105,12 @@ type releasePlanCollabWSOutbound struct { type collaborationClient struct { planID string + id string conn *websocket.Conn send chan []byte + + sessionMu sync.Mutex + sessionIDs map[string]struct{} } var collaborationHub = struct { @@ -233,6 +239,75 @@ func unregisterCollaborationClient(planID string, client *collaborationClient) { } } +func rememberCollaborationClientSession(client *collaborationClient, sessionID string) { + if client == nil || sessionID == "" { + return + } + + client.sessionMu.Lock() + defer client.sessionMu.Unlock() + + if client.sessionIDs == nil { + client.sessionIDs = make(map[string]struct{}) + } + client.sessionIDs[sessionID] = struct{}{} +} + +func forgetCollaborationClientSession(client *collaborationClient, sessionID string) { + if client == nil || sessionID == "" { + return + } + + client.sessionMu.Lock() + defer client.sessionMu.Unlock() + + delete(client.sessionIDs, sessionID) +} + +func listCollaborationClientSessionIDs(client *collaborationClient) []string { + if client == nil { + return nil + } + + client.sessionMu.Lock() + defer client.sessionMu.Unlock() + + resp := make([]string, 0, len(client.sessionIDs)) + for sessionID := range client.sessionIDs { + resp = append(resp, sessionID) + } + sort.Strings(resp) + return resp +} + +func shouldCleanupReleasePlanEditingSession(session *ReleasePlanEditingSession, connectionID string) bool { + if session == nil || connectionID == "" { + return false + } + return session.ConnectionID == connectionID +} + +func cleanupReleasePlanEditingSessionsForClient(client *collaborationClient) { + if client == nil || client.planID == "" { + return + } + + for _, sessionID := range listCollaborationClientSessionIDs(client) { + session, err := getReleasePlanEditingSession(client.planID, sessionID) + if err != nil { + continue + } + if !shouldCleanupReleasePlanEditingSession(session, client.id) { + continue + } + if err := removeReleasePlanEditingSession(client.planID, sessionID); err != nil { + log.Errorf("remove release plan editing session on disconnect error: %v", err) + continue + } + forgetCollaborationClientSession(client, sessionID) + } +} + func sendSnapshotToLocalClients(planID string, snapshot *ReleasePlanCollaborationSnapshot) { if snapshot == nil { return @@ -316,7 +391,9 @@ func GetReleasePlanCollaborationSnapshot(planID string) (*ReleasePlanCollaborati groupMap[key] = group groupOrder = append(groupOrder, key) } - group.Editors = append(group.Editors, session) + displaySession := *session + displaySession.ConnectionID = "" + group.Editors = append(group.Editors, &displaySession) } sort.Strings(groupOrder) @@ -473,11 +550,14 @@ func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, pla ensureReleasePlanCollaborationLoop() client := &collaborationClient{ - planID: planID, - conn: ws, - send: make(chan []byte, 16), + planID: planID, + id: uuid.NewString(), + conn: ws, + send: make(chan []byte, 16), + sessionIDs: map[string]struct{}{}, } registerCollaborationClient(planID, client) + defer cleanupReleasePlanEditingSessionsForClient(client) defer unregisterCollaborationClient(planID, client) done := make(chan struct{}) @@ -517,6 +597,7 @@ func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, pla session := &ReleasePlanEditingSession{ PlanID: planID, SessionID: msg.SessionID, + ConnectionID: client.id, UserID: ctx.UserID, UserName: ctx.UserName, Account: ctx.Account, @@ -544,6 +625,7 @@ func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, pla queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) continue } + rememberCollaborationClientSession(client, msg.SessionID) snapshot, err := GetReleasePlanCollaborationSnapshot(planID) if err == nil { queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "snapshot", Snapshot: snapshot}) @@ -560,7 +642,9 @@ func openReleasePlanCollaborationWS(gCtx *gin.Context, ctx *handler.Context, pla } if err := removeReleasePlanEditingSession(planID, msg.SessionID); err != nil { queueCollaborationClientMessage(client, &releasePlanCollabWSOutbound{Type: "error", Error: err.Error()}) + continue } + forgetCollaborationClientSession(client, msg.SessionID) } } }) diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go index 441ac03fef..d0e8229a18 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -23,10 +23,12 @@ import ( "fmt" "reflect" "sort" + "strconv" "strings" "github.com/pkg/errors" + "github.com/koderover/zadig/v2/pkg/microservice/aslan/config" "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb" ) @@ -91,6 +93,148 @@ const ( releasePlanArrayDiffStrategyKeyedOrdered ) +type releasePlanArrayDiffRuleMatchType int + +const ( + releasePlanArrayDiffRuleMatchTypeExact releasePlanArrayDiffRuleMatchType = iota + releasePlanArrayDiffRuleMatchTypeSafeSuffix +) + +type releasePlanArrayKeyBuilder func(item interface{}) (string, bool) + +type releasePlanArrayDiffRule struct { + GroupType string + Path string + ParentJobTypes map[string]struct{} + MatchType releasePlanArrayDiffRuleMatchType + Strategy releasePlanArrayDiffStrategy + BuildKey releasePlanArrayKeyBuilder +} + +func newReleasePlanExactArrayRule(groupType, path string, strategy releasePlanArrayDiffStrategy, buildKey releasePlanArrayKeyBuilder) releasePlanArrayDiffRule { + return releasePlanArrayDiffRule{ + GroupType: groupType, + Path: path, + MatchType: releasePlanArrayDiffRuleMatchTypeExact, + Strategy: strategy, + BuildKey: buildKey, + } +} + +func newReleasePlanTypedExactArrayRule(groupType, path string, parentJobTypes []config.JobType, strategy releasePlanArrayDiffStrategy, buildKey releasePlanArrayKeyBuilder) releasePlanArrayDiffRule { + rule := newReleasePlanExactArrayRule(groupType, path, strategy, buildKey) + rule.ParentJobTypes = make(map[string]struct{}, len(parentJobTypes)) + for _, jobType := range parentJobTypes { + rule.ParentJobTypes[string(jobType)] = struct{}{} + } + return rule +} + +func newReleasePlanSafeSuffixArrayRule(groupType, path string, strategy releasePlanArrayDiffStrategy, buildKey releasePlanArrayKeyBuilder) releasePlanArrayDiffRule { + return releasePlanArrayDiffRule{ + GroupType: groupType, + Path: path, + MatchType: releasePlanArrayDiffRuleMatchTypeSafeSuffix, + Strategy: strategy, + BuildKey: buildKey, + } +} + +var releasePlanArrayExactRules = []releasePlanArrayDiffRule{ + newReleasePlanExactArrayRule("plan", "jobs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameTypeID), + newReleasePlanExactArrayRule(releasePlanVersionSectionJobsOrder, "", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByNameID), + newReleasePlanExactArrayRule("job", "spec.workflow.params", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameType), + newReleasePlanExactArrayRule("job", "spec.workflow.stages", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameType), + newReleasePlanExactArrayRule("job", "spec.workflow.share_storages", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.default_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_config.default_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_builds", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.default_service_and_builds", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_builds_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_vm_deploys", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.default_service_and_vm_deploys", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_vm_deploys_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_tests", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_test_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_scannings", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_scanning_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_and_image", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.gray_services", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.source_service", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_trigger_workflow", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByWorkflowTrigger), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.fixed_workflow_list", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByFixedWorkflowTrigger), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_trigger_workflow.params", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByNameType), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.fixed_workflow_list.params", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByNameType), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.test_modules", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.test_module_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.scannings", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.scanning_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.services", []config.JobType{config.JobZadigDeploy, config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.services", []config.JobType{config.JobFreestyle}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_options", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_config.services", []config.JobType{config.JobSAEDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.services.modules", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_variable_config", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_variable_config.modules", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_variable_config.variable_configs", []config.JobType{config.JobZadigDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByVariableConfig), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.services.service_and_image", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.service_options.service_and_image", []config.JobType{config.JobK8sBlueGreenDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.gray_services.service_and_image", []config.JobType{config.JobMseGrayRelease}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModuleNameOnly), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobCustomDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobCustomDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobZadigDistributeImage}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobZadigDistributeImage}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByServiceModule), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobK8sBlueGreenDeploy, config.JobK8sCanaryDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByK8sTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobK8sCanaryDeploy}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByK8sTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobK8sGrayRelease}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayReleaseTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobK8sGrayRelease}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayReleaseTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobK8sGrayRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayRollbackTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobK8sGrayRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByGrayRollbackTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.targets", []config.JobType{config.JobIstioRelease, config.JobIstioRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByIstioTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.target_options", []config.JobType{config.JobIstioRelease, config.JobIstioRollback}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByIstioTarget), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.jobs", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByJobName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.job_options", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByJobName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.jobs.parameters", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByName), + newReleasePlanTypedExactArrayRule("job", "spec.workflow.stages.jobs.spec.job_options.parameters", []config.JobType{config.JobJenkins}, releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByName), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.alerts", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.alert_options", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.monitors", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.mail_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.mail_notification_config.target_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_group_notification_config.at_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_person_notification_config.target_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.native_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.dingtalk_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.cc_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "native_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByUserID), + newReleasePlanExactArrayRule("approval", "dingtalk_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "lark_approval.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.approve_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.cc_users", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByThirdPartyUserID), + newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.approve_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), + newReleasePlanExactArrayRule("approval", "lark_approval.approval_nodes.cc_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.approve_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), + newReleasePlanExactArrayRule("job", "spec.workflow.stages.jobs.spec.lark_approval.approval_nodes.cc_groups", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByApprovalGroup), + newReleasePlanExactArrayRule("metadata", "jira_sprint_association.sprints", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByJiraSprint), +} + +var releasePlanArraySafeSuffixRules = []releasePlanArrayDiffRule{ + newReleasePlanSafeSuffixArrayRule("job", "repos", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByRepo), + newReleasePlanSafeSuffixArrayRule("job", "code_info", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByRepo), + newReleasePlanSafeSuffixArrayRule("job", "key_vals", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), + newReleasePlanSafeSuffixArrayRule("job", "envs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), + newReleasePlanSafeSuffixArrayRule("job", "custom_envs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), + newReleasePlanSafeSuffixArrayRule("job", "custom_annotations", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), + newReleasePlanSafeSuffixArrayRule("job", "custom_labels", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), + newReleasePlanSafeSuffixArrayRule("job", "variable_kvs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), + newReleasePlanSafeSuffixArrayRule("job", "kv", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), + newReleasePlanSafeSuffixArrayRule("job", "original_config", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByKey), +} + var releasePlanFieldLabels = map[string]string{ "name": "名称", "manager": "负责人", @@ -339,9 +483,20 @@ func hashReleasePlanSubtree(value interface{}) (string, error) { } func diffReleasePlanArray(ctx releasePlanDiffContext, path string, left, right []interface{}, entries *[]*releasePlanRawDiffEntry) { - leftMap, leftOrdered, leftMapped := buildReleasePlanArrayMap(left) - rightMap, rightOrdered, rightMapped := buildReleasePlanArrayMap(right) - strategy := resolveReleasePlanArrayDiffStrategy(ctx, path, leftMapped, rightMapped) + rule := matchReleasePlanArrayDiffRule(ctx, path) + if rule == nil || rule.Strategy == releasePlanArrayDiffStrategyIndex { + diffReleasePlanArrayByIndex(ctx, path, left, right, entries) + return + } + + leftMap, leftOrdered, leftMapped := buildReleasePlanArrayMap(left, rule.BuildKey) + rightMap, rightOrdered, rightMapped := buildReleasePlanArrayMap(right, rule.BuildKey) + if !leftMapped || !rightMapped { + diffReleasePlanArrayByIndex(ctx, path, left, right, entries) + return + } + + strategy := rule.Strategy if strategy == releasePlanArrayDiffStrategyKeyedOrdered { if entry := buildReleasePlanArrayOrderChange(path, left, right, leftMap, leftOrdered, rightMap, rightOrdered); entry != nil { *entries = append(*entries, entry) @@ -369,6 +524,10 @@ func diffReleasePlanArray(ctx releasePlanDiffContext, path string, left, right [ return } + diffReleasePlanArrayByIndex(ctx, path, left, right, entries) +} + +func diffReleasePlanArrayByIndex(ctx releasePlanDiffContext, path string, left, right []interface{}, entries *[]*releasePlanRawDiffEntry) { maxLen := len(left) if len(right) > maxLen { maxLen = len(right) @@ -386,18 +545,151 @@ func diffReleasePlanArray(ctx releasePlanDiffContext, path string, left, right [ } } -func resolveReleasePlanArrayDiffStrategy(ctx releasePlanDiffContext, path string, leftMapped, rightMapped bool) releasePlanArrayDiffStrategy { - if !leftMapped || !rightMapped { - return releasePlanArrayDiffStrategyIndex +type releasePlanArrayRuleLookupContext struct { + GroupType string + Path string + ParentJobType string +} + +func matchReleasePlanArrayDiffRule(ctx releasePlanDiffContext, path string) *releasePlanArrayDiffRule { + lookupContexts := buildReleasePlanArrayRuleLookupContexts(ctx, path) + for _, lookup := range lookupContexts { + for idx := range releasePlanArrayExactRules { + rule := &releasePlanArrayExactRules[idx] + if rule.GroupType != lookup.GroupType { + continue + } + if !matchesReleasePlanParentJobType(rule, lookup.ParentJobType) { + continue + } + if rule.Path == lookup.Path { + return rule + } + } } - if shouldTrackReleasePlanArrayOrder(ctx, path) { - return releasePlanArrayDiffStrategyKeyedOrdered + for _, lookup := range lookupContexts { + for idx := range releasePlanArraySafeSuffixRules { + rule := &releasePlanArraySafeSuffixRules[idx] + if rule.GroupType != lookup.GroupType { + continue + } + if lookup.Path == rule.Path || strings.HasSuffix(lookup.Path, "."+rule.Path) { + return rule + } + } } - return releasePlanArrayDiffStrategyKeyedUnordered + return nil } -func shouldTrackReleasePlanArrayOrder(ctx releasePlanDiffContext, path string) bool { - return ctx.GroupType == releasePlanVersionSectionJobsOrder && path == "" +func matchesReleasePlanParentJobType(rule *releasePlanArrayDiffRule, parentJobType string) bool { + if len(rule.ParentJobTypes) == 0 { + return true + } + _, ok := rule.ParentJobTypes[parentJobType] + return ok +} + +func buildReleasePlanArrayRuleLookupContexts(ctx releasePlanDiffContext, path string) []releasePlanArrayRuleLookupContext { + normalizedPath := normalizeReleasePlanDiffPath(path) + parentJobType := extractReleasePlanParentJobType(path) + resp := []releasePlanArrayRuleLookupContext{{ + GroupType: ctx.GroupType, + Path: normalizedPath, + ParentJobType: parentJobType, + }} + + if ctx.GroupType != "plan" { + return resp + } + + // Nested arrays under the plan snapshot still belong to job/approval/metadata structures. + if strings.HasPrefix(normalizedPath, "jobs.") { + resp = append(resp, releasePlanArrayRuleLookupContext{ + GroupType: "job", + Path: strings.TrimPrefix(normalizedPath, "jobs."), + ParentJobType: parentJobType, + }) + } + if strings.HasPrefix(normalizedPath, "approval.") { + resp = append(resp, releasePlanArrayRuleLookupContext{ + GroupType: "approval", + Path: strings.TrimPrefix(normalizedPath, "approval."), + ParentJobType: parentJobType, + }) + } + if strings.HasPrefix(normalizedPath, "metadata.") { + resp = append(resp, releasePlanArrayRuleLookupContext{ + GroupType: "metadata", + Path: strings.TrimPrefix(normalizedPath, "metadata."), + ParentJobType: parentJobType, + }) + } + return resp +} + +func extractReleasePlanParentJobType(path string) string { + parentJobType := "" + searchPath := path + for { + idx := strings.Index(searchPath, "jobs[") + if idx < 0 { + return parentJobType + } + searchPath = searchPath[idx+len("jobs["):] + endIdx := strings.IndexByte(searchPath, ']') + if endIdx < 0 { + return parentJobType + } + key := searchPath[:endIdx] + if jobType, ok := extractReleasePlanJobTypeFromArrayKey(key); ok { + parentJobType = jobType + } + searchPath = searchPath[endIdx+1:] + } +} + +func extractReleasePlanJobTypeFromArrayKey(key string) (string, bool) { + parts := strings.Split(key, "|") + if len(parts) < 2 { + return "", false + } + return trimReleasePlanArrayDuplicateSuffix(parts[1]), true +} + +func trimReleasePlanArrayDuplicateSuffix(value string) string { + idx := strings.LastIndex(value, "#") + if idx < 0 || idx == len(value)-1 { + return value + } + for _, ch := range value[idx+1:] { + if ch < '0' || ch > '9' { + return value + } + } + return value[:idx] +} + +func normalizeReleasePlanDiffPath(path string) string { + if path == "" { + return "" + } + + builder := strings.Builder{} + builder.Grow(len(path)) + inBracket := false + for _, ch := range path { + switch ch { + case '[': + inBracket = true + case ']': + inBracket = false + default: + if !inBracket { + builder.WriteRune(ch) + } + } + } + return builder.String() } func buildReleasePlanArrayOrderChange( @@ -469,6 +761,26 @@ func buildReleasePlanArrayOrderItem(item interface{}, key string) *ReleasePlanVe resp.Name = itemKey return resp } + if workflowName, ok := getStringField(value, "workflow_name"); ok { + projectName, _ := getStringField(value, "project_name") + serviceName, _ := getStringField(value, "service_name") + serviceModule, _ := getStringField(value, "service_module") + parts := make([]string, 0, 4) + if projectName != "" { + parts = append(parts, projectName) + } + if workflowName != "" { + parts = append(parts, workflowName) + } + if serviceName != "" { + parts = append(parts, serviceName) + } + if serviceModule != "" { + parts = append(parts, serviceModule) + } + resp.Name = strings.Join(parts, "/") + return resp + } if service, ok := getStringField(value, "service_name"); ok { if module, ok := getStringField(value, "service_module"); ok { resp.Name = fmt.Sprintf("%s/%s", service, module) @@ -477,6 +789,10 @@ func buildReleasePlanArrayOrderItem(item interface{}, key string) *ReleasePlanVe } return resp } + if module, ok := getStringField(value, "service_module"); ok { + resp.Name = module + return resp + } if repo, ok := getStringField(value, "repo_name"); ok { namespace, _ := getStringField(value, "repo_namespace") remote, _ := getStringField(value, "remote_name") @@ -491,6 +807,23 @@ func buildReleasePlanArrayOrderItem(item interface{}, key string) *ReleasePlanVe resp.Name = userID return resp } + if groupName, ok := getStringField(value, "group_name"); ok { + resp.Name = groupName + return resp + } + if sprintName, ok := getStringField(value, "sprint_name"); ok { + projectKey, _ := getStringField(value, "project_key") + if projectKey != "" { + resp.Name = fmt.Sprintf("%s/%s", projectKey, sprintName) + } else { + resp.Name = sprintName + } + return resp + } + if variableKey, ok := getStringField(value, "variable_key"); ok { + resp.Name = variableKey + return resp + } } if resp.Name == "" && key != "" { @@ -502,11 +835,15 @@ func buildReleasePlanArrayOrderItem(item interface{}, key string) *ReleasePlanVe return resp } -func buildReleasePlanArrayMap(values []interface{}) (map[string]interface{}, []string, bool) { +func buildReleasePlanArrayMap(values []interface{}, buildKey releasePlanArrayKeyBuilder) (map[string]interface{}, []string, bool) { + if buildKey == nil { + return nil, nil, false + } + result := make(map[string]interface{}, len(values)) orderedKeys := make([]string, 0, len(values)) for idx, item := range values { - key, ok := getReleasePlanArrayItemKey(item) + key, ok := buildKey(item) if !ok { return nil, nil, false } @@ -519,47 +856,326 @@ func buildReleasePlanArrayMap(values []interface{}) (map[string]interface{}, []s return result, orderedKeys, true } -func getReleasePlanArrayItemKey(item interface{}) (string, bool) { - switch value := item.(type) { - case map[string]interface{}: - if name, ok := getStringField(value, "name"); ok { - if jobType, ok := getStringField(value, "type"); ok { - if id, ok := getStringField(value, "id"); ok { - return fmt.Sprintf("%s|%s|%s", name, jobType, id), true - } - return fmt.Sprintf("%s|%s", name, jobType), true - } - if id, ok := getStringField(value, "id"); ok { - return fmt.Sprintf("%s|%s", name, id), true - } - return name, true - } - if key, ok := getStringField(value, "key"); ok { - return key, true - } - if service, ok := getStringField(value, "service_name"); ok { - if module, ok := getStringField(value, "service_module"); ok { - return fmt.Sprintf("%s/%s", service, module), true - } - } - if repo, ok := getStringField(value, "repo_name"); ok { - namespace, _ := getStringField(value, "repo_namespace") - remote, _ := getStringField(value, "remote_name") - return fmt.Sprintf("%s/%s/%s", namespace, repo, remote), true - } - if target, ok := getStringField(value, "target"); ok { - return target, true - } - if userID, ok := getStringField(value, "user_id"); ok { - return userID, true - } - if id, ok := getStringField(value, "id"); ok { - return id, true - } +func buildReleasePlanArrayKeyByNameTypeID(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { return "", false - default: + } + name, ok := getStringField(value, "name") + if !ok { return "", false } + jobType, ok := getStringField(value, "type") + if !ok { + return "", false + } + id, ok := getStringField(value, "id") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s|%s", name, jobType, id), true +} + +func buildReleasePlanArrayKeyByNameType(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + name, ok := getStringField(value, "name") + if !ok { + return "", false + } + itemType, ok := getStringField(value, "type") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s", name, itemType), true +} + +func buildReleasePlanArrayKeyByNameID(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + name, ok := getStringField(value, "name") + if !ok { + return "", false + } + id, ok := getStringField(value, "id") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s", name, id), true +} + +func buildReleasePlanArrayKeyByName(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + return getStringField(value, "name") +} + +func buildReleasePlanArrayKeyByKey(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + return getStringField(value, "key") +} + +func buildReleasePlanArrayKeyByTarget(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + return getStringField(value, "target") +} + +func buildReleasePlanArrayKeyByServiceModule(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + service, ok := getStringField(value, "service_name") + if !ok { + return "", false + } + module, ok := getStringField(value, "service_module") + if !ok { + return "", false + } + return fmt.Sprintf("%s/%s", service, module), true +} + +func buildReleasePlanArrayKeyByServiceModuleNameOnly(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + if module, ok := getStringField(value, "service_module"); ok { + return module, true + } + return getStringField(value, "name") +} + +func buildReleasePlanArrayKeyByServiceName(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + return getStringField(value, "service_name") +} + +func buildReleasePlanArrayKeyByVariableConfig(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + variableKey, ok := getStringField(value, "variable_key") + if !ok { + return "", false + } + source, _ := getStringField(value, "source") + return fmt.Sprintf("%s|%s", variableKey, source), true +} + +func buildReleasePlanArrayKeyByWorkflowTrigger(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + serviceName, ok := getStringField(value, "service_name") + if !ok { + return "", false + } + workflowName, ok := getStringField(value, "workflow_name") + if !ok { + return "", false + } + projectName, ok := getStringField(value, "project_name") + if !ok { + return "", false + } + serviceModule, _ := getStringField(value, "service_module") + return fmt.Sprintf("%s|%s|%s|%s", serviceName, serviceModule, workflowName, projectName), true +} + +func buildReleasePlanArrayKeyByFixedWorkflowTrigger(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + workflowName, ok := getStringField(value, "workflow_name") + if !ok { + return "", false + } + projectName, ok := getStringField(value, "project_name") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s", workflowName, projectName), true +} + +func buildReleasePlanArrayKeyByJobName(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + return getStringField(value, "job_name") +} + +func buildReleasePlanArrayKeyByRepo(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + repo, ok := getStringField(value, "repo_name") + if !ok { + return "", false + } + namespace, _ := getStringField(value, "repo_namespace") + remote, _ := getStringField(value, "remote_name") + return fmt.Sprintf("%s/%s/%s", namespace, repo, remote), true +} + +func buildReleasePlanArrayKeyByUserID(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + return getStringField(value, "user_id") +} + +func buildReleasePlanArrayKeyByThirdPartyUserID(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + if id, ok := getStringField(value, "id"); ok { + return id, true + } + name, hasName := getStringField(value, "name") + userID, hasUserID := getStringField(value, "user_id") + if hasName && hasUserID { + return fmt.Sprintf("%s|%s", name, userID), true + } + return "", false +} + +func buildReleasePlanArrayKeyByApprovalGroup(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + if groupID, ok := getStringField(value, "group_id"); ok { + return groupID, true + } + return getStringField(value, "group_name") +} + +func buildReleasePlanArrayKeyByJiraSprint(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + projectKey, _ := getStringField(value, "project_key") + if projectKey == "" { + projectKey, _ = getStringField(value, "project_name") + } + if projectKey == "" { + return "", false + } + boardID, ok := getNumberFieldString(value, "board_id") + if !ok { + return "", false + } + sprintID, ok := getNumberFieldString(value, "sprint_id") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s|%s", projectKey, boardID, sprintID), true +} + +func buildReleasePlanArrayKeyByK8sTarget(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + serviceName, ok := getStringField(value, "k8s_service_name") + if !ok { + return "", false + } + workloadName, ok := getStringField(value, "workload_name") + if !ok { + return "", false + } + containerName, ok := getStringField(value, "container_name") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s|%s", serviceName, workloadName, containerName), true +} + +func buildReleasePlanArrayKeyByGrayReleaseTarget(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + workloadType, ok := getStringField(value, "workload_type") + if !ok { + return "", false + } + workloadName, ok := getStringField(value, "workload_name") + if !ok { + return "", false + } + containerName, ok := getStringField(value, "container_name") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s|%s", workloadType, workloadName, containerName), true +} + +func buildReleasePlanArrayKeyByGrayRollbackTarget(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + workloadType, ok := getStringField(value, "workload_type") + if !ok { + return "", false + } + workloadName, ok := getStringField(value, "workload_name") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s", workloadType, workloadName), true +} + +func buildReleasePlanArrayKeyByIstioTarget(item interface{}) (string, bool) { + value, ok := getMapField(item) + if !ok { + return "", false + } + virtualServiceName, ok := getStringField(value, "virtual_service_name") + if !ok { + return "", false + } + workloadName, ok := getStringField(value, "workload_name") + if !ok { + return "", false + } + containerName, ok := getStringField(value, "container_name") + if !ok { + return "", false + } + return fmt.Sprintf("%s|%s|%s", virtualServiceName, workloadName, containerName), true +} + +func getMapField(item interface{}) (map[string]interface{}, bool) { + value, ok := item.(map[string]interface{}) + return value, ok } func getStringField(input map[string]interface{}, key string) (string, bool) { @@ -571,6 +1187,56 @@ func getStringField(input map[string]interface{}, key string) (string, bool) { return str, ok && str != "" } +func getNumberFieldString(input map[string]interface{}, key string) (string, bool) { + value, exists := input[key] + if !exists { + return "", false + } + switch typed := value.(type) { + case string: + return typed, typed != "" + case float64: + intValue := int64(typed) + if float64(intValue) != typed { + return "", false + } + return strconv.FormatInt(intValue, 10), true + case float32: + intValue := int64(typed) + if float32(intValue) != typed { + return "", false + } + return strconv.FormatInt(intValue, 10), true + case int: + return strconv.Itoa(typed), true + case int8: + return strconv.FormatInt(int64(typed), 10), true + case int16: + return strconv.FormatInt(int64(typed), 10), true + case int32: + return strconv.FormatInt(int64(typed), 10), true + case int64: + return strconv.FormatInt(typed, 10), true + case uint: + return strconv.FormatUint(uint64(typed), 10), true + case uint8: + return strconv.FormatUint(uint64(typed), 10), true + case uint16: + return strconv.FormatUint(uint64(typed), 10), true + case uint32: + return strconv.FormatUint(uint64(typed), 10), true + case uint64: + return strconv.FormatUint(typed, 10), true + case json.Number: + if intValue, err := typed.Int64(); err == nil { + return strconv.FormatInt(intValue, 10), true + } + return "", false + default: + return "", false + } +} + func joinReleasePlanDiffPath(path, key string) string { if path == "" { return key From d52d858c890784fdc15fed955a85e3f8effcc285 Mon Sep 17 00:00:00 2001 From: huanghongbo-hhb Date: Fri, 22 May 2026 15:03:40 +0800 Subject: [PATCH 11/11] fix: improve release plan target order labels Signed-off-by: huanghongbo-hhb --- .../aslan/core/release_plan/service/diff.go | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/pkg/microservice/aslan/core/release_plan/service/diff.go b/pkg/microservice/aslan/core/release_plan/service/diff.go index d0e8229a18..b000bc3a04 100644 --- a/pkg/microservice/aslan/core/release_plan/service/diff.go +++ b/pkg/microservice/aslan/core/release_plan/service/diff.go @@ -803,6 +803,10 @@ func buildReleasePlanArrayOrderItem(item interface{}, key string) *ReleasePlanVe resp.Name = target return resp } + if targetName := buildReleasePlanTargetOrderName(value); targetName != "" { + resp.Name = targetName + return resp + } if userID, ok := getStringField(value, "user_id"); ok { resp.Name = userID return resp @@ -835,6 +839,35 @@ func buildReleasePlanArrayOrderItem(item interface{}, key string) *ReleasePlanVe return resp } +func buildReleasePlanTargetOrderName(value map[string]interface{}) string { + if serviceName, ok := getStringField(value, "k8s_service_name"); ok { + workloadName, _ := getStringField(value, "workload_name") + containerName, _ := getStringField(value, "container_name") + return joinReleasePlanOrderNameParts(serviceName, workloadName, containerName) + } + if virtualServiceName, ok := getStringField(value, "virtual_service_name"); ok { + workloadName, _ := getStringField(value, "workload_name") + containerName, _ := getStringField(value, "container_name") + return joinReleasePlanOrderNameParts(virtualServiceName, workloadName, containerName) + } + if workloadType, ok := getStringField(value, "workload_type"); ok { + workloadName, _ := getStringField(value, "workload_name") + containerName, _ := getStringField(value, "container_name") + return joinReleasePlanOrderNameParts(workloadType, workloadName, containerName) + } + return "" +} + +func joinReleasePlanOrderNameParts(parts ...string) string { + filtered := make([]string, 0, len(parts)) + for _, part := range parts { + if part != "" { + filtered = append(filtered, part) + } + } + return strings.Join(filtered, " / ") +} + func buildReleasePlanArrayMap(values []interface{}, buildKey releasePlanArrayKeyBuilder) (map[string]interface{}, []string, bool) { if buildKey == nil { return nil, nil, false