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/common/repository/models/release_plan.go b/pkg/microservice/aslan/core/common/repository/models/release_plan.go
index 7cdc25ed7f..e3e37e4b58 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,43 @@ 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"`
+ 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,omitempty" json:"before,omitempty"`
+ After interface{} `bson:"after,omitempty" json:"after,omitempty"`
+ Detail string `bson:"detail" json:"detail"`
+ Version int64 `bson:"version,omitempty" json:"version,omitempty"`
+ SectionKey string `bson:"section_key,omitempty" json:"section_key,omitempty"`
+ SectionName string `bson:"section_name,omitempty" json:"section_name,omitempty"`
+ SectionType string `bson:"section_type,omitempty" json:"section_type,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"`
+ Version int64 `bson:"version" json:"version"`
+ PreviousVersion int64 `bson:"previous_version,omitempty" json:"previous_version,omitempty"`
+ 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"`
+ SectionType string `bson:"section_type,omitempty" json:"section_type,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..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
@@ -48,7 +48,14 @@ 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}},
+ },
+ }
+
+ _, err := c.Indexes().CreateMany(ctx, mod, mongotool.CreateIndexOptions(ctx))
+ return err
}
func (c *ReleasePlanLogColl) Create(args *models.ReleasePlanLog) error {
@@ -76,7 +83,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
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..861d7ffb5d
--- /dev/null
+++ b/pkg/microservice/aslan/core/common/repository/mongodb/release_plan_version.go
@@ -0,0 +1,101 @@
+/*
+ * 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
+}
+
+func (c *ReleasePlanVersionColl) GetLatestBySectionsBefore(planID string, sectionKeys []string, beforeVersion int64) (*models.ReleasePlanVersion, error) {
+ resp := new(models.ReleasePlanVersion)
+ err := c.FindOne(context.Background(), bson.M{
+ "plan_id": planID,
+ "version": bson.M{"$lt": beforeVersion},
+ "section_key": bson.M{
+ "$in": sectionKeys,
+ },
+ }, options.FindOne().SetSort(bson.D{{Key: "version", Value: -1}})).Decode(resp)
+ return resp, err
+}
diff --git a/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job_restart.go b/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job_restart.go
index 4c48c850a2..ac70b38173 100644
--- a/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job_restart.go
+++ b/pkg/microservice/aslan/core/common/service/workflowcontroller/jobcontroller/job_restart.go
@@ -242,7 +242,7 @@ func (c *RestartJobCtl) restartHelmService(ctx context.Context, env *commonmodel
}
func restartWorkloadResources(ctx context.Context, clusterID string, resources []*kube.WorkloadResource, env *commonmodels.Product, kubeClient crClient.Client, clientSet *kubernetes.Clientset) (replaceResources []commonmodels.Resource, relatedPodLabels []map[string]string, err error) {
- deployments, statefulSets, _, _, _, err := kube.FetchSelectedWorkloads(env.Namespace, resources, kubeClient, clientSet)
+ deployments, _, statefulSets, _, _, _, err := kube.FetchSelectedWorkloads(env.Namespace, resources, kubeClient, clientSet)
if err != nil {
return nil, nil, 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..10f8edd91c 100644
--- a/pkg/microservice/aslan/core/release_plan/handler/router.go
+++ b/pkg/microservice/aslan/core/release_plan/handler/router.go
@@ -28,7 +28,10 @@ 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.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
new file mode 100644
index 0000000000..aa78c2ec50
--- /dev/null
+++ b/pkg/microservice/aslan/core/release_plan/service/collaboration.go
@@ -0,0 +1,814 @@
+/*
+ * 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"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+ "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 = 90 * time.Second
+ releasePlanCollabWSWriteWait = 10 * time.Second
+ releasePlanCollabWSPongWait = 60 * time.Second
+ releasePlanCollabWSPingPeriod = releasePlanCollabWSPongWait * 9 / 10
+ releasePlanCollabWSReadLimit = 16 * 1024
+ releasePlanCollabRedisRetryWait = 3 * time.Second
+)
+
+const (
+ releasePlanCollabSectionMetadata = "metadata"
+ releasePlanCollabSectionMetadataName = "metadata:name"
+ releasePlanCollabSectionMetadataManager = "metadata:manager"
+ releasePlanCollabSectionMetadataTimeRange = "metadata:time_range"
+ releasePlanCollabSectionMetadataScheduleExecute = "metadata:schedule_execute_time"
+ releasePlanCollabSectionMetadataDescription = "metadata:description"
+ releasePlanCollabSectionMetadataJiraSprint = "metadata:jira_sprint_association"
+ releasePlanCollabSectionApproval = "approval"
+)
+
+var releasePlanCollabMetadataSectionNames = map[string]string{
+ releasePlanCollabSectionMetadata: "基础信息",
+ releasePlanCollabSectionMetadataName: "名称",
+ releasePlanCollabSectionMetadataManager: "发布负责人",
+ releasePlanCollabSectionMetadataTimeRange: "发布窗口日期",
+ releasePlanCollabSectionMetadataScheduleExecute: "定时执行",
+ releasePlanCollabSectionMetadataDescription: "需求关联",
+ releasePlanCollabSectionMetadataJiraSprint: "关联冲刺",
+}
+
+var upgrader = websocket.Upgrader{
+ ReadBufferSize: 1024,
+ WriteBufferSize: 1024,
+ CheckOrigin: checkReleasePlanCollaborationOrigin,
+}
+
+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"`
+ 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"`
+}
+
+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
+ id string
+ conn *websocket.Conn
+ send chan []byte
+
+ sessionMu sync.Mutex
+ sessionIDs map[string]struct{}
+}
+
+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(watchReleasePlanCollaborationBroadcasts)
+ })
+}
+
+func watchReleasePlanCollaborationBroadcasts() {
+ for {
+ ch, closeFn := cache.NewRedisCache(configbase.RedisCommonCacheTokenDB()).Subscribe(releasePlanCollabBroadcastChannel)
+ for msg := range ch {
+ if msg == nil {
+ continue
+ }
+ planID := strings.TrimSpace(msg.Payload)
+ if planID == "" {
+ continue
+ }
+ broadcastReleasePlanCollaborationSnapshot(planID)
+ }
+ if err := closeFn(); err != nil {
+ log.Warnf("close release plan collaboration redis subscription error: %v", err)
+ }
+ log.Warnf("release plan collaboration redis subscription closed, retrying in %s", releasePlanCollabRedisRetryWait)
+ time.Sleep(releasePlanCollabRedisRetryWait)
+ }
+}
+
+func releasePlanCollabSessionKey(sessionID string) string {
+ return releasePlanCollabSessionKeyPrefix + sessionID
+}
+
+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 normalizeReleasePlanCollaborationSection(sectionKey, sectionType, sectionName string) (string, string, string) {
+ sectionKey = strings.TrimSpace(sectionKey)
+ sectionType = strings.TrimSpace(sectionType)
+ sectionName = strings.TrimSpace(sectionName)
+
+ switch {
+ case sectionType == "metadata" || sectionKey == releasePlanCollabSectionMetadata || strings.HasPrefix(sectionKey, releasePlanCollabSectionMetadata+":"):
+ normalizedKey, normalizedName := normalizeReleasePlanMetadataCollaborationSection(sectionKey, sectionName)
+ return normalizedKey, "metadata", normalizedName
+ case sectionType == "approval" || sectionKey == releasePlanCollabSectionApproval:
+ if sectionKey == "" {
+ sectionKey = releasePlanCollabSectionApproval
+ }
+ if sectionName == "" {
+ sectionName = "审批配置"
+ }
+ return sectionKey, "approval", sectionName
+ case sectionType == "job":
+ if sectionName == "" {
+ sectionName = "发布内容"
+ }
+ return sectionKey, "job", sectionName
+ default:
+ return sectionKey, sectionType, sectionName
+ }
+}
+
+func normalizeReleasePlanMetadataCollaborationSection(sectionKey, sectionName string) (string, string) {
+ if sectionKey != releasePlanCollabSectionMetadata {
+ if normalizedName, exists := releasePlanCollabMetadataSectionNames[sectionKey]; exists {
+ return sectionKey, normalizedName
+ }
+ }
+
+ switch strings.TrimSpace(sectionName) {
+ case "", "基础信息":
+ return releasePlanCollabSectionMetadata, releasePlanCollabMetadataSectionNames[releasePlanCollabSectionMetadata]
+ case "名称", "发布计划名称":
+ return releasePlanCollabSectionMetadataName, releasePlanCollabMetadataSectionNames[releasePlanCollabSectionMetadataName]
+ case "负责人", "发布负责人":
+ return releasePlanCollabSectionMetadataManager, releasePlanCollabMetadataSectionNames[releasePlanCollabSectionMetadataManager]
+ case "发布窗口日期", "发布窗口", "发布时间窗口":
+ return releasePlanCollabSectionMetadataTimeRange, releasePlanCollabMetadataSectionNames[releasePlanCollabSectionMetadataTimeRange]
+ case "定时执行":
+ return releasePlanCollabSectionMetadataScheduleExecute, releasePlanCollabMetadataSectionNames[releasePlanCollabSectionMetadataScheduleExecute]
+ case "需求关联":
+ return releasePlanCollabSectionMetadataDescription, releasePlanCollabMetadataSectionNames[releasePlanCollabSectionMetadataDescription]
+ case "关联冲刺", "Jira Sprint", "Jira Sprint 关联":
+ return releasePlanCollabSectionMetadataJiraSprint, releasePlanCollabMetadataSectionNames[releasePlanCollabSectionMetadataJiraSprint]
+ }
+
+ if normalizedName, exists := releasePlanCollabMetadataSectionNames[sectionKey]; exists {
+ return sectionKey, normalizedName
+ }
+
+ if sectionKey == "" {
+ sectionKey = releasePlanCollabSectionMetadata
+ }
+ return sectionKey, sectionName
+}
+
+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) error {
+ if planID == "" {
+ return nil
+ }
+ 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 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
+ }
+ 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 setupReleasePlanCollaborationWSDeadline(ws *websocket.Conn) {
+ ws.SetReadLimit(releasePlanCollabWSReadLimit)
+ _ = ws.SetReadDeadline(time.Now().Add(releasePlanCollabWSPongWait))
+ ws.SetPongHandler(func(string) error {
+ return ws.SetReadDeadline(time.Now().Add(releasePlanCollabWSPongWait))
+ })
+}
+
+func writeReleasePlanCollaborationWSMessage(ws *websocket.Conn, messageType int, payload []byte) error {
+ if err := ws.SetWriteDeadline(time.Now().Add(releasePlanCollabWSWriteWait)); err != nil {
+ return err
+ }
+ return ws.WriteMessage(messageType, payload)
+}
+
+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)
+ }
+ displaySession := *session
+ displaySession.ConnectionID = ""
+ group.Editors = append(group.Editors, &displaySession)
+ }
+
+ 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
+ }
+ if len(sessionIDs) == 0 {
+ return []*ReleasePlanEditingSession{}, nil
+ }
+
+ keys := make([]string, 0, len(sessionIDs))
+ for _, sessionID := range sessionIDs {
+ keys = append(keys, releasePlanCollabSessionKey(sessionID))
+ }
+
+ values, err := redisCache.MGet(keys)
+ if err != nil {
+ return nil, err
+ }
+
+ resp := decodeReleasePlanEditingSessions(planID, values)
+
+ 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 decodeReleasePlanEditingSessions(planID string, values []interface{}) []*ReleasePlanEditingSession {
+ resp := make([]*ReleasePlanEditingSession, 0, len(values))
+ for _, value := range values {
+ raw, ok := value.(string)
+ if !ok || raw == "" {
+ continue
+ }
+ session := new(ReleasePlanEditingSession)
+ if err := json.Unmarshal([]byte(raw), session); err != nil {
+ continue
+ }
+ if session.PlanID != planID {
+ continue
+ }
+ session.SectionKey, session.SectionType, session.SectionName = normalizeReleasePlanCollaborationSection(session.SectionKey, session.SectionType, session.SectionName)
+ resp = append(resp, session)
+ }
+ return resp
+}
+
+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}, releasePlanCollabSessionTTL); err != nil {
+ return err
+ }
+ return broadcastReleasePlanCollaboration(session.PlanID)
+}
+
+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
+ }
+ return broadcastReleasePlanCollaboration(planID)
+}
+
+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")
+ }
+ session.SectionKey, session.SectionType, session.SectionName = normalizeReleasePlanCollaborationSection(session.SectionKey, session.SectionType, session.SectionName)
+ 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)
+}
+
+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)
+ }
+ var closeWSOnce sync.Once
+ closeWS := func() {
+ _ = ws.Close()
+ }
+ defer closeWSOnce.Do(closeWS)
+ setupReleasePlanCollaborationWSDeadline(ws)
+
+ ensureReleasePlanCollaborationLoop()
+
+ client := &collaborationClient{
+ 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{})
+ 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":
+ sectionKey, sectionType, sectionName := normalizeReleasePlanCollaborationSection(msg.SectionKey, msg.SectionType, msg.SectionName)
+ if !authorizeReleasePlanEditing(ctx, 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)
+ 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,
+ ConnectionID: client.id,
+ UserID: ctx.UserID,
+ UserName: ctx.UserName,
+ Account: ctx.Account,
+ IdentityType: ctx.IdentityType,
+ SectionKey: sectionKey,
+ SectionType: sectionType,
+ SectionName: sectionName,
+ BaseVersion: msg.BaseVersion,
+ EditingStartedAt: time.Now().Unix(),
+ }
+ if existingSession != nil {
+ session.EditingStartedAt = existingSession.EditingStartedAt
+ if session.BaseVersion == 0 {
+ session.BaseVersion = existingSession.BaseVersion
+ }
+ if existingSession.SectionKey != "" && existingSession.SectionKey != sectionKey {
+ session.EditingStartedAt = time.Now().Unix()
+ 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
+ }
+ rememberCollaborationClientSession(client, msg.SessionID)
+ snapshot, err := GetReleasePlanCollaborationSnapshot(planID)
+ if err == nil {
+ 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()})
+ continue
+ }
+ forgetCollaborationClientSession(client, msg.SessionID)
+ }
+ }
+ })
+
+ util.Go(func() {
+ ticker := time.NewTicker(releasePlanCollabWSPingPeriod)
+ defer ticker.Stop()
+ defer closeWSOnce.Do(closeWS)
+ for {
+ select {
+ case payload := <-client.send:
+ if err := writeReleasePlanCollaborationWSMessage(ws, websocket.TextMessage, payload); err != nil {
+ return
+ }
+ case <-ticker.C:
+ if err := writeReleasePlanCollaborationWSMessage(ws, websocket.PingMessage, nil); 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..2ce3357a18
--- /dev/null
+++ b/pkg/microservice/aslan/core/release_plan/service/diff.go
@@ -0,0 +1,1407 @@
+/*
+ * 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"
+ "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/models"
+ "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb"
+)
+
+const (
+ releasePlanHashPruneMinMapKeys = 4
+ releasePlanHashPruneMinArrayItems = 4
+ releasePlanDiffMaxDepth = 50
+ releasePlanDiffChangeTypeOrder = "order_changed"
+ releasePlanDiffDisplayApprovalSpec = "approval_spec"
+ releasePlanDiffDisplayWorkflowSpec = "workflow_spec"
+ releasePlanDiffDisplayMetadataSpec = "metadata_spec"
+)
+
+type ReleasePlanVersionDiffResponse struct {
+ PlanID string `json:"plan_id"`
+ Version int64 `json:"version"`
+ PreviousVersion int64 `json:"previous_version"`
+ Groups []*ReleasePlanVersionDiffGroup `json:"groups"`
+}
+
+type ReleasePlanVersionDiffGroup struct {
+ GroupKey string `json:"group_key"`
+ GroupName string `json:"group_name"`
+ GroupType string `json:"group_type"`
+ DisplayMode string `json:"display_mode,omitempty"`
+ BeforeSpec interface{} `json:"before_spec,omitempty"`
+ AfterSpec interface{} `json:"after_spec,omitempty"`
+ 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 {
+ ChangeType string `json:"change_type,omitempty"`
+ Path string `json:"path,omitempty"`
+ 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 ReleasePlanVersionMetadataDiffItem struct {
+ Key string `json:"key"`
+ Label string `json:"label"`
+ Value interface{} `json:"value"`
+ ValueType string `json:"value_type"`
+}
+
+type releasePlanRawDiffEntry struct {
+ Path string
+ ChangeType string
+ Before interface{}
+ After interface{}
+ BeforeOrder []*ReleasePlanVersionDiffOrderItem
+ AfterOrder []*ReleasePlanVersionDiffOrderItem
+}
+
+type releasePlanDiffContext struct {
+ GroupType string
+}
+
+type releasePlanMetadataDiffField struct {
+ Key string
+ Label string
+ ValueType string
+}
+
+type releasePlanArrayDiffStrategy int
+
+const (
+ releasePlanArrayDiffStrategyIndex releasePlanArrayDiffStrategy = iota
+ releasePlanArrayDiffStrategyKeyedUnordered
+ releasePlanArrayDiffStrategyKeyedOrdered
+)
+
+type releasePlanArrayKeyBuilder func(item interface{}) (string, bool)
+
+type releasePlanArrayDiffRule struct {
+ GroupType string
+ Path string
+ Strategy releasePlanArrayDiffStrategy
+ BuildKey releasePlanArrayKeyBuilder
+}
+
+func newReleasePlanExactArrayRule(groupType, path string, strategy releasePlanArrayDiffStrategy, buildKey releasePlanArrayKeyBuilder) releasePlanArrayDiffRule {
+ return releasePlanArrayDiffRule{
+ GroupType: groupType,
+ Path: path,
+ Strategy: strategy,
+ BuildKey: buildKey,
+ }
+}
+
+var releasePlanArrayExactRules = []releasePlanArrayDiffRule{
+ newReleasePlanExactArrayRule("plan", "jobs", releasePlanArrayDiffStrategyKeyedUnordered, buildReleasePlanArrayKeyByNameTypeID),
+ newReleasePlanExactArrayRule(releasePlanVersionSectionJobsOrder, "", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByNameID),
+ 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("metadata", "jira_sprint_association.sprints", releasePlanArrayDiffStrategyKeyedOrdered, buildReleasePlanArrayKeyByJiraSprint),
+}
+
+// Keep only labels that are still used by path-based diff rendering.
+// Workflow release jobs are now rendered from before_spec/after_spec directly.
+var releasePlanFieldLabels = map[string]string{
+ "name": "名称",
+ "manager": "负责人",
+ "manager_id": "负责人 ID",
+ "start_time": "开始时间",
+ "end_time": "结束时间",
+ "schedule_execute_time": "定时执行时间",
+ "description": "需求关联",
+ "approval": "审批配置",
+ "type": "类型",
+ "enabled": "是否启用",
+ "content": "内容",
+ "remark": "备注",
+ "order": "顺序",
+ "approve_users": "审批人",
+ "approval_nodes": "审批节点",
+ "native_approval": "原生审批",
+ "lark_approval": "飞书审批",
+ "dingtalk_approval": "钉钉审批",
+ "workwx_approval": "企业微信审批",
+}
+
+var releasePlanMetadataDiffFields = []releasePlanMetadataDiffField{
+ {Key: "name", Label: "名称", ValueType: "text"},
+ {Key: "manager", Label: "负责人", ValueType: "text"},
+ {Key: "start_time", Label: "开始时间", ValueType: "time"},
+ {Key: "end_time", Label: "结束时间", ValueType: "time"},
+ {Key: "schedule_execute_time", Label: "定时执行时间", ValueType: "time"},
+ {Key: "description", Label: "需求关联", ValueType: "rich_text"},
+ {Key: "jira_sprint_association", Label: "关联冲刺", ValueType: "jira_sprint_association"},
+}
+
+func GetReleasePlanVersionDiff(planID string, version int64) (*ReleasePlanVersionDiffResponse, error) {
+ current, err := mongodb.NewReleasePlanVersionColl().Get(planID, version)
+ if err != nil {
+ return nil, errors.Wrap(err, "get version")
+ }
+
+ fromData, hasBaseSnapshot, err := releasePlanVersionBaseSnapshotAsGenericValue(current)
+ if err != nil {
+ return nil, errors.Wrap(err, "convert base snapshot")
+ }
+
+ var previous *models.ReleasePlanVersion
+ if !hasBaseSnapshot && current.PreviousVersion > 0 {
+ previous, err = mongodb.NewReleasePlanVersionColl().Get(planID, current.PreviousVersion)
+ if err != nil {
+ return nil, errors.Wrap(err, "get previous version")
+ }
+ fromData = comparableReleasePlanVersionSnapshot(previous, current.SectionKey)
+ }
+
+ toData, err := toReleasePlanGenericValue(current.Snapshot)
+ if err != nil {
+ return nil, errors.Wrap(err, "convert current snapshot")
+ }
+
+ groupKey, groupName, groupType := releasePlanVersionDiffGroup(current.SectionKey, current.SectionName)
+ displayMode, beforeSpec, afterSpec := releasePlanVersionDiffDisplaySpec(current.SectionKey, groupType, current.Verb, fromData, toData)
+
+ rawEntries := make([]*releasePlanRawDiffEntry, 0)
+ if shouldBuildReleasePlanPathDiff(displayMode) {
+ // Workflow release jobs are rendered from full preset specs on the frontend.
+ // Keep path-level diff for simple sections only.
+ diffReleasePlanValues(releasePlanDiffContext{GroupType: groupType}, "", fromData, toData, &rawEntries)
+ }
+
+ groupMap := map[string]*ReleasePlanVersionDiffGroup{}
+ groupOrder := make([]string, 0)
+ if shouldAddReleasePlanVersionDiffDisplaySpec(displayMode, beforeSpec, afterSpec) {
+ group := ensureReleasePlanVersionDiffGroup(groupMap, &groupOrder, groupKey, groupName, groupType)
+ group.DisplayMode = displayMode
+ group.BeforeSpec = sanitizeReleasePlanValueForDisplay(beforeSpec)
+ group.AfterSpec = sanitizeReleasePlanValueForDisplay(afterSpec)
+ }
+ for _, entry := range rawEntries {
+ if shouldIgnoreReleasePlanDiffPath(entry.Path) {
+ continue
+ }
+ group := ensureReleasePlanVersionDiffGroup(groupMap, &groupOrder, groupKey, groupName, groupType)
+
+ change := &ReleasePlanVersionDiffChange{
+ 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
+ } 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,
+ Version: version,
+ PreviousVersion: current.PreviousVersion,
+ Groups: groups,
+ }, nil
+}
+
+func ensureReleasePlanVersionDiffGroup(groupMap map[string]*ReleasePlanVersionDiffGroup, groupOrder *[]string, groupKey, groupName, groupType string) *ReleasePlanVersionDiffGroup {
+ if group, exists := groupMap[groupKey]; exists {
+ return group
+ }
+
+ group := &ReleasePlanVersionDiffGroup{
+ GroupKey: groupKey,
+ GroupName: groupName,
+ GroupType: groupType,
+ Changes: make([]*ReleasePlanVersionDiffChange, 0),
+ }
+ groupMap[groupKey] = group
+ *groupOrder = append(*groupOrder, groupKey)
+ return group
+}
+
+func shouldAddReleasePlanVersionDiffDisplaySpec(displayMode string, beforeSpec, afterSpec interface{}) bool {
+ if displayMode == "" {
+ return false
+ }
+ if displayMode == releasePlanDiffDisplayMetadataSpec {
+ beforeItems, _ := beforeSpec.([]*ReleasePlanVersionMetadataDiffItem)
+ afterItems, _ := afterSpec.([]*ReleasePlanVersionMetadataDiffItem)
+ return len(beforeItems) > 0 || len(afterItems) > 0
+ }
+ return !reflect.DeepEqual(beforeSpec, afterSpec)
+}
+
+func releasePlanVersionDiffDisplaySpec(sectionKey, groupType, verb string, fromData, toData interface{}) (string, interface{}, interface{}) {
+ switch groupType {
+ case "approval":
+ if fromData == nil && toData == nil {
+ return "", nil, nil
+ }
+ return releasePlanDiffDisplayApprovalSpec, fromData, toData
+ case "metadata":
+ beforeSpec, afterSpec := releasePlanVersionDiffMetadataSpec(fromData, toData)
+ return releasePlanDiffDisplayMetadataSpec, beforeSpec, afterSpec
+ case "job":
+ if !isReleasePlanWorkflowJobSnapshot(fromData) && !isReleasePlanWorkflowJobSnapshot(toData) {
+ return "", nil, nil
+ }
+ return releasePlanDiffDisplayWorkflowSpec, releasePlanVersionDiffWorkflowSpec(fromData), releasePlanVersionDiffWorkflowSpec(toData)
+ default:
+ if sectionKey == releasePlanVersionSectionPlan && verb == VerbCreate {
+ beforeSpec, afterSpec := releasePlanVersionDiffMetadataSpec(fromData, toData)
+ return releasePlanDiffDisplayMetadataSpec, beforeSpec, afterSpec
+ }
+ return "", nil, nil
+ }
+}
+
+func shouldBuildReleasePlanPathDiff(displayMode string) bool {
+ return displayMode == "" || displayMode == releasePlanDiffDisplayApprovalSpec
+}
+
+func isReleasePlanWorkflowJobSnapshot(value interface{}) bool {
+ job, ok := getMapField(value)
+ if !ok {
+ return false
+ }
+ if jobType, ok := getStringField(job, "type"); ok && jobType == string(config.JobWorkflow) {
+ return true
+ }
+ spec, ok := getMapField(job["spec"])
+ if !ok {
+ return false
+ }
+ _, exists := spec["workflow"]
+ return exists
+}
+
+func releasePlanVersionDiffJobSpec(value interface{}) interface{} {
+ job, ok := getMapField(value)
+ if !ok {
+ return nil
+ }
+ return job["spec"]
+}
+
+func releasePlanVersionDiffWorkflowSpec(value interface{}) interface{} {
+ job, ok := getMapField(value)
+ if !ok {
+ return nil
+ }
+
+ resp := make(map[string]interface{}, 3)
+ for _, key := range []string{"name", "manager"} {
+ if item, exists := job[key]; exists {
+ resp[key] = item
+ }
+ }
+ if spec := releasePlanVersionDiffJobSpec(value); spec != nil {
+ if workflowSpec, ok := getMapField(spec); ok {
+ for key, item := range workflowSpec {
+ resp[key] = item
+ }
+ }
+ }
+ if len(resp) == 0 {
+ return nil
+ }
+ return resp
+}
+
+func releasePlanVersionDiffMetadataSpec(fromData, toData interface{}) ([]*ReleasePlanVersionMetadataDiffItem, []*ReleasePlanVersionMetadataDiffItem) {
+ fromMetadata := releasePlanVersionDiffMetadataSnapshot(fromData)
+ toMetadata := releasePlanVersionDiffMetadataSnapshot(toData)
+
+ beforeSpec := make([]*ReleasePlanVersionMetadataDiffItem, 0, len(releasePlanMetadataDiffFields))
+ afterSpec := make([]*ReleasePlanVersionMetadataDiffItem, 0, len(releasePlanMetadataDiffFields))
+ for _, field := range releasePlanMetadataDiffFields {
+ beforeValue := normalizeReleasePlanMetadataDiffValue(field.Key, fromMetadata[field.Key])
+ afterValue := normalizeReleasePlanMetadataDiffValue(field.Key, toMetadata[field.Key])
+ if reflect.DeepEqual(beforeValue, afterValue) {
+ continue
+ }
+ beforeSpec = append(beforeSpec, newReleasePlanVersionMetadataDiffItem(field, beforeValue))
+ afterSpec = append(afterSpec, newReleasePlanVersionMetadataDiffItem(field, afterValue))
+ }
+ return beforeSpec, afterSpec
+}
+
+func releasePlanVersionDiffMetadataSnapshot(value interface{}) map[string]interface{} {
+ snapshot, ok := getMapField(value)
+ if !ok {
+ return map[string]interface{}{}
+ }
+ if metadata, ok := getMapField(snapshot["metadata"]); ok {
+ return metadata
+ }
+ return snapshot
+}
+
+func newReleasePlanVersionMetadataDiffItem(field releasePlanMetadataDiffField, value interface{}) *ReleasePlanVersionMetadataDiffItem {
+ return &ReleasePlanVersionMetadataDiffItem{
+ Key: field.Key,
+ Label: field.Label,
+ Value: value,
+ ValueType: field.ValueType,
+ }
+}
+
+func normalizeReleasePlanMetadataDiffValue(key string, value interface{}) interface{} {
+ if value == nil {
+ return nil
+ }
+
+ switch key {
+ case "description":
+ return normalizeReleasePlanMetadataRichTextValue(value)
+ case "start_time", "end_time", "schedule_execute_time":
+ return normalizeReleasePlanMetadataTimeValue(value)
+ case "jira_sprint_association":
+ return normalizeReleasePlanMetadataJiraSprintAssociationValue(value)
+ }
+
+ if str, ok := value.(string); ok && strings.TrimSpace(str) == "" {
+ return nil
+ }
+ return value
+}
+
+func normalizeReleasePlanMetadataRichTextValue(value interface{}) interface{} {
+ str, ok := value.(string)
+ if !ok {
+ return value
+ }
+ if isEmptyReleasePlanRichText(str) {
+ return nil
+ }
+ return str
+}
+
+func isEmptyReleasePlanRichText(value string) bool {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ return true
+ }
+
+ // Compact is only used for empty-rich-text detection; returned content stays unchanged.
+ compact := strings.ToLower(strings.Join(strings.Fields(trimmed), ""))
+ compact = strings.ReplaceAll(compact, " ", "")
+ compact = strings.ReplaceAll(compact, "\u00a0", "")
+ switch compact {
+ case "", "
", "
", "
", "
", "
":
+ return true
+ default:
+ return false
+ }
+}
+
+func normalizeReleasePlanMetadataJiraSprintAssociationValue(value interface{}) interface{} {
+ association, ok := getMapField(value)
+ if !ok {
+ return value
+ }
+ if isEmptyReleasePlanJiraSprintAssociation(association) {
+ return nil
+ }
+ return value
+}
+
+func isEmptyReleasePlanJiraSprintAssociation(value map[string]interface{}) bool {
+ if value == nil {
+ return true
+ }
+ if jiraID, ok := value["jira_id"].(string); ok && strings.TrimSpace(jiraID) != "" {
+ return false
+ }
+ if sprints, ok := value["sprints"].([]interface{}); ok && len(sprints) > 0 {
+ return false
+ }
+ return true
+}
+
+func normalizeReleasePlanMetadataTimeValue(value interface{}) interface{} {
+ switch typed := value.(type) {
+ case float64:
+ if typed == 0 {
+ return nil
+ }
+ intValue := int64(typed)
+ if float64(intValue) == typed {
+ return intValue
+ }
+ return typed
+ case int:
+ if typed == 0 {
+ return nil
+ }
+ return int64(typed)
+ case int64:
+ if typed == 0 {
+ return nil
+ }
+ return typed
+ case json.Number:
+ intValue, err := typed.Int64()
+ if err == nil {
+ if intValue == 0 {
+ return nil
+ }
+ return intValue
+ }
+ return value
+ default:
+ return value
+ }
+}
+
+func releasePlanVersionBaseSnapshotAsGenericValue(version *models.ReleasePlanVersion) (interface{}, bool, error) {
+ if version == nil || version.BaseSnapshot == nil {
+ return nil, false, nil
+ }
+
+ value, err := toReleasePlanGenericValue(version.BaseSnapshot)
+ if err != nil {
+ return nil, true, err
+ }
+ return value, true, nil
+}
+
+func comparableReleasePlanVersionSnapshot(version *models.ReleasePlanVersion, sectionKey string) interface{} {
+ if version == nil {
+ return nil
+ }
+
+ switch {
+ case version.SectionKey == sectionKey, sectionKey == releasePlanVersionSectionPlan:
+ value, err := toReleasePlanGenericValue(version.Snapshot)
+ if err != nil {
+ return version.Snapshot
+ }
+ return value
+ case version.SectionKey != releasePlanVersionSectionPlan:
+ return nil
+ default:
+ return extractReleasePlanSectionSnapshot(version.Snapshot, sectionKey)
+ }
+}
+
+func extractReleasePlanSectionSnapshot(snapshot interface{}, sectionKey string) interface{} {
+ genericValue, err := toReleasePlanGenericValue(snapshot)
+ if err != nil {
+ return nil
+ }
+
+ planSnapshot, ok := genericValue.(map[string]interface{})
+ if !ok {
+ return nil
+ }
+
+ switch {
+ case isReleasePlanVersionMetadataSection(sectionKey):
+ metadata, ok := planSnapshot["metadata"].(map[string]interface{})
+ if !ok {
+ return nil
+ }
+ switch sectionKey {
+ case releasePlanVersionSectionMetadata:
+ return metadata
+ case releasePlanCollabSectionMetadataName:
+ return map[string]interface{}{
+ "name": metadata["name"],
+ }
+ case releasePlanCollabSectionMetadataManager:
+ return map[string]interface{}{
+ "manager": metadata["manager"],
+ "manager_id": metadata["manager_id"],
+ }
+ case releasePlanCollabSectionMetadataTimeRange:
+ return map[string]interface{}{
+ "start_time": metadata["start_time"],
+ "end_time": metadata["end_time"],
+ }
+ case releasePlanCollabSectionMetadataScheduleExecute:
+ return map[string]interface{}{
+ "schedule_execute_time": metadata["schedule_execute_time"],
+ }
+ case releasePlanCollabSectionMetadataDescription:
+ return map[string]interface{}{
+ "description": metadata["description"],
+ }
+ case releasePlanCollabSectionMetadataJiraSprint:
+ return map[string]interface{}{
+ "jira_sprint_association": metadata["jira_sprint_association"],
+ }
+ default:
+ return metadata
+ }
+ case sectionKey == releasePlanVersionSectionApproval:
+ return planSnapshot["approval"]
+ case sectionKey == releasePlanVersionSectionJobsOrder:
+ return planSnapshot["jobs_order"]
+ case strings.HasPrefix(sectionKey, releasePlanVersionSectionJobPrefix):
+ jobID := strings.TrimPrefix(sectionKey, releasePlanVersionSectionJobPrefix)
+ jobs, ok := planSnapshot["jobs"].([]interface{})
+ if !ok {
+ return nil
+ }
+ for _, item := range jobs {
+ job, ok := item.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ if id, _ := job["id"].(string); id == jobID {
+ return job
+ }
+ }
+ }
+ return nil
+}
+
+func diffReleasePlanValues(ctx releasePlanDiffContext, path string, left, right interface{}, entries *[]*releasePlanRawDiffEntry) {
+ diffReleasePlanValuesWithDepth(ctx, path, 0, left, right, entries)
+}
+
+func diffReleasePlanValuesWithDepth(ctx releasePlanDiffContext, path string, depth int, 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
+ }
+
+ if depth >= releasePlanDiffMaxDepth {
+ *entries = append(*entries, &releasePlanRawDiffEntry{
+ Path: path,
+ Before: left,
+ After: 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)
+ diffReleasePlanValuesWithDepth(ctx, nextPath, depth+1, leftMap[key], rightMap[key], entries)
+ }
+ return
+ }
+
+ leftList, leftIsList := left.([]interface{})
+ rightList, rightIsList := right.([]interface{})
+ if leftIsList || rightIsList {
+ diffReleasePlanArray(ctx, path, depth, 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(ctx releasePlanDiffContext, path string, depth int, left, right []interface{}, entries *[]*releasePlanRawDiffEntry) {
+ rule := matchReleasePlanArrayDiffRule(ctx, path)
+ if rule == nil || rule.Strategy == releasePlanArrayDiffStrategyIndex {
+ diffReleasePlanArrayByIndex(ctx, path, depth, left, right, entries)
+ return
+ }
+
+ leftMap, leftOrdered, leftMapped := buildReleasePlanArrayMap(left, rule.BuildKey)
+ rightMap, rightOrdered, rightMapped := buildReleasePlanArrayMap(right, rule.BuildKey)
+ if !leftMapped || !rightMapped {
+ diffReleasePlanArrayByIndex(ctx, path, depth, 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)
+ }
+ }
+ if strategy == releasePlanArrayDiffStrategyKeyedOrdered || strategy == releasePlanArrayDiffStrategyKeyedUnordered {
+ 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 {
+ if shouldSkipReleasePlanWorkflowTaskPresenceChange(path, leftMap[key], rightMap[key]) {
+ continue
+ }
+ nextPath := fmt.Sprintf("%s[%s]", path, key)
+ diffReleasePlanValuesWithDepth(ctx, nextPath, depth+1, leftMap[key], rightMap[key], entries)
+ }
+ return
+ }
+
+ diffReleasePlanArrayByIndex(ctx, path, depth, left, right, entries)
+}
+
+func shouldSkipReleasePlanWorkflowTaskPresenceChange(path string, left, right interface{}) bool {
+ if left != nil && right != nil {
+ return false
+ }
+ normalizedPath := normalizeReleasePlanDiffPath(path)
+ return normalizedPath == "spec.workflow.jobs" || strings.HasSuffix(normalizedPath, ".spec.workflow.jobs")
+}
+
+func diffReleasePlanArrayByIndex(ctx releasePlanDiffContext, path string, depth int, left, right []interface{}, entries *[]*releasePlanRawDiffEntry) {
+ 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]
+ }
+ diffReleasePlanValuesWithDepth(ctx, nextPath, depth+1, leftVal, rightVal, entries)
+ }
+}
+
+type releasePlanArrayRuleLookupContext struct {
+ GroupType string
+ Path 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 rule.Path == lookup.Path {
+ return rule
+ }
+ }
+ }
+ return nil
+}
+
+func buildReleasePlanArrayRuleLookupContexts(ctx releasePlanDiffContext, path string) []releasePlanArrayRuleLookupContext {
+ normalizedPath := normalizeReleasePlanDiffPath(path)
+ resp := []releasePlanArrayRuleLookupContext{{
+ GroupType: ctx.GroupType,
+ Path: normalizedPath,
+ }}
+
+ if ctx.GroupType != "plan" {
+ return resp
+ }
+
+ // Nested arrays under the plan snapshot still belong to approval/metadata structures.
+ if strings.HasPrefix(normalizedPath, "approval.") {
+ resp = append(resp, releasePlanArrayRuleLookupContext{
+ GroupType: "approval",
+ Path: strings.TrimPrefix(normalizedPath, "approval."),
+ })
+ }
+ if strings.HasPrefix(normalizedPath, "metadata.") {
+ resp = append(resp, releasePlanArrayRuleLookupContext{
+ GroupType: "metadata",
+ Path: strings.TrimPrefix(normalizedPath, "metadata."),
+ })
+ }
+ return resp
+}
+
+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(
+ 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 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)
+ } else {
+ resp.Name = service
+ }
+ 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")
+ resp.Name = 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 targetName := buildReleasePlanTargetOrderName(value); targetName != "" {
+ resp.Name = targetName
+ return resp
+ }
+ if userID, ok := getStringField(value, "user_id"); ok {
+ 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 != "" {
+ resp.Name = key
+ }
+ if resp.Name == "" {
+ resp.Name = fmt.Sprintf("%v", item)
+ }
+ 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
+ }
+
+ result := make(map[string]interface{}, len(values))
+ orderedKeys := make([]string, 0, len(values))
+ for idx, item := range values {
+ key, ok := buildKey(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 buildReleasePlanArrayKeyByNameTypeID(item interface{}) (string, bool) {
+ value, ok := getMapField(item)
+ if !ok {
+ return "", false
+ }
+ 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 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 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 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) {
+ value, exists := input[key]
+ if !exists {
+ return "", false
+ }
+ str, ok := value.(string)
+ 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
+ }
+ return path + "." + key
+}
+
+func shouldIgnoreReleasePlanDiffPath(path string) bool {
+ if path == "" {
+ return false
+ }
+ if isReleasePlanWorkflowJobStructureDiffPath(path) {
+ return true
+ }
+
+ 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 isReleasePlanWorkflowJobStructureDiffPath(path string) bool {
+ if !strings.HasPrefix(path, "spec.workflow.jobs[") {
+ return false
+ }
+ if strings.Contains(path, "].spec.") {
+ return false
+ }
+ return true
+}
+
+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 == "metadata" || segment == "spec" || segment == "workflow" {
+ continue
+ }
+ label := segment
+ switch {
+ case strings.HasPrefix(segment, "jobs["):
+ name, _ := splitReleasePlanBracketKey(segment)
+ label = fmt.Sprintf("任务 %s", name)
+ 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 {
+ if shouldPreserveFullReleasePlanDiffValue(path) {
+ return false
+ }
+
+ lowerPath := strings.ToLower(path)
+ keywords := []string{"script", "sql", "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 shouldPreserveFullReleasePlanDiffValue(path string) bool {
+ lowerPath := strings.ToLower(path)
+ switch {
+ case lowerPath == "description":
+ return true
+ case strings.HasSuffix(lowerPath, ".description"):
+ return true
+ case lowerPath == "spec.content":
+ return true
+ default:
+ 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/execute.go b/pkg/microservice/aslan/core/release_plan/service/execute.go
index b0ffe843bc..5be2b987b4 100644
--- a/pkg/microservice/aslan/core/release_plan/service/execute.go
+++ b/pkg/microservice/aslan/core/release_plan/service/execute.go
@@ -135,9 +135,13 @@ func (e *WorkflowReleaseJobExecutor) Execute(plan *models.ReleasePlan) error {
if spec.Workflow == nil {
return errors.Errorf("workflow is nil")
}
-
- err := jobManagerAuth(plan.Name, plan.ManagerID, job, e.Ctx.UserName, e.Ctx.UserID, e.Ctx.AuthResources)
+ normalizedWorkflow, err := normalizeReleasePlanWorkflowForController(spec.Workflow)
if err != nil {
+ return errors.Wrap(err, "normalize workflow")
+ }
+ spec.Workflow = normalizedWorkflow
+
+ if err := jobManagerAuth(plan.Name, plan.ManagerID, job, e.Ctx.UserName, e.Ctx.UserID, e.Ctx.AuthResources); err != nil {
return err
}
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..b373f4ccea 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,14 +167,25 @@ func OpenAPICreateReleasePlan(c *handler.Context, rawArgs *OpenAPICreateReleaseP
}
go func() {
- if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{
- PlanID: planID,
- Username: c.UserName,
- Account: c.Account,
- Verb: VerbCreate,
- TargetName: args.Name,
- TargetType: TargetTypeReleasePlan,
- CreatedAt: time.Now().Unix(),
+ sectionSnapshot, err := buildReleasePlanInputSnapshot(args)
+ if err == nil {
+ err = createReleasePlanVersion(planID, 1, 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,
+ Version: 1,
+ SectionKey: releasePlanVersionSectionPlan,
+ SectionName: releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name),
+ SectionType: releasePlanVersionSectionGroupType(releasePlanVersionSectionPlan),
+ CreatedAt: time.Now().Unix(),
}); err != nil {
log.Errorf("create release plan log error: %v", err)
}
@@ -216,10 +228,18 @@ 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")
}
+ 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 +381,56 @@ func OpenAPICreateReleasePlanWithJobs(c *handler.Context, id string, rawArgs *Op
}
plan.Jobs = newJobs
+ plan.Version = originalPlan.Version + 1
+
+ currentSnapshot, err := buildReleasePlanInputSnapshot(plan)
+ if err != nil {
+ return errors.Wrap(err, "build release plan current snapshot")
+ }
+ var baseSnapshot interface{}
+ needBaseSnapshot, previousVersion, err := shouldBuildReleasePlanVersionBaseSnapshot(plan.ID.Hex(), releasePlanVersionSectionPlan, plan.Version, VerbUpdate)
+ if err != nil {
+ return errors.Wrap(err, "check release plan base snapshot")
+ }
+ if needBaseSnapshot {
+ baseSnapshot, err = buildReleasePlanInputSnapshot(originalPlan)
+ if err != nil {
+ return errors.Wrap(err, "build release plan base snapshot")
+ }
+ }
+ logBaseSnapshot, err := resolveReleasePlanLogBaseSnapshot(baseSnapshot, originalPlan, releasePlanVersionSectionPlan)
+ if err != nil {
+ return errors.Wrap(err, "build release plan log base snapshot")
+ }
+ shouldCreateLog := hasReleasePlanSnapshotChanges(logBaseSnapshot, currentSnapshot)
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(),
+ if err := createReleasePlanVersionWithBaseSnapshot(plan.ID.Hex(), plan.Version, previousVersion, baseSnapshot, currentSnapshot, c.UserName, c.Account, releasePlanVersionSectionPlan, releasePlanVersionSectionName(releasePlanVersionSectionPlan, plan.Name), VerbUpdate); err != nil {
+ log.Errorf("create release plan version error: %v", err)
+ } else if shouldCreateLog {
+ if err := createReleasePlanLog(&models.ReleasePlanLog{
+ PlanID: plan.ID.Hex(),
+ Username: c.UserName,
+ Account: c.Account,
+ Verb: VerbUpdate,
+ TargetName: plan.Name,
+ TargetType: TargetTypeReleasePlan,
+ Version: plan.Version,
+ SectionKey: releasePlanVersionSectionPlan,
+ SectionName: releasePlanVersionSectionName(releasePlanVersionSectionPlan, plan.Name),
+ SectionType: releasePlanVersionSectionGroupType(releasePlanVersionSectionPlan),
+ CreatedAt: time.Now().Unix(),
}); err != nil {
log.Errorf("create release plan log error: %v", err)
}
- }()
+ }
+ if err := broadcastReleasePlanCollaboration(plan.ID.Hex()); err != nil {
+ log.Errorf("broadcast release plan collaboration error: %v", err)
+ }
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 c8659b82b1..d3cea7bfa0 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,14 +132,25 @@ func CreateReleasePlan(c *handler.Context, args *models.ReleasePlan) error {
}
go func() {
- if err := mongodb.NewReleasePlanLogColl().Create(&models.ReleasePlanLog{
- PlanID: planID,
- Username: c.UserName,
- Account: c.Account,
- Verb: VerbCreate,
- TargetName: args.Name,
- TargetType: TargetTypeReleasePlan,
- CreatedAt: time.Now().Unix(),
+ sectionSnapshot, err := buildReleasePlanInputSnapshot(args)
+ if err == nil {
+ err = createReleasePlanVersion(planID, 1, 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,
+ Version: 1,
+ SectionKey: releasePlanVersionSectionPlan,
+ SectionName: releasePlanVersionSectionName(releasePlanVersionSectionPlan, args.Name),
+ SectionType: releasePlanVersionSectionGroupType(releasePlanVersionSectionPlan),
+ CreatedAt: time.Now().Unix(),
}); err != nil {
log.Errorf("create release plan log error: %v", err)
}
@@ -331,8 +343,20 @@ 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.TargetType = normalizeReleasePlanTargetType(item.TargetType)
+ 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,
@@ -342,6 +366,180 @@ func GetReleasePlanLogs(id string) (*GetReleasePlanLogsResponse, error) {
}, nil
}
+func resolveReleasePlanLogBaseSnapshot(baseSnapshot interface{}, originalPlan *models.ReleasePlan, sectionKey string) (interface{}, error) {
+ if baseSnapshot != nil {
+ return baseSnapshot, nil
+ }
+ return buildReleasePlanVersionSnapshot(originalPlan, sectionKey)
+}
+
+func hasReleasePlanSnapshotChanges(beforeSnapshot, afterSnapshot interface{}) bool {
+ return !releasePlanSnapshotValuesEqual(beforeSnapshot, afterSnapshot)
+}
+
+func releasePlanSnapshotValuesEqual(left, right interface{}) bool {
+ switch leftValue := left.(type) {
+ case map[string]interface{}:
+ rightValue, ok := right.(map[string]interface{})
+ if !ok {
+ return isEmptyReleasePlanSnapshotValue(left) && isEmptyReleasePlanSnapshotValue(right)
+ }
+ return releasePlanSnapshotMapsEqual(leftValue, rightValue)
+ case []interface{}:
+ rightValue, ok := right.([]interface{})
+ if !ok {
+ return isEmptyReleasePlanSnapshotValue(left) && isEmptyReleasePlanSnapshotValue(right)
+ }
+ return releasePlanSnapshotListsEqual(leftValue, rightValue)
+ default:
+ switch right.(type) {
+ case map[string]interface{}, []interface{}:
+ return isEmptyReleasePlanSnapshotValue(left) && isEmptyReleasePlanSnapshotValue(right)
+ }
+ }
+
+ if isEmptyReleasePlanSnapshotScalarValue(left) && isEmptyReleasePlanSnapshotScalarValue(right) {
+ return true
+ }
+ leftNumber, leftIsNumber := releasePlanSnapshotNumber(left)
+ rightNumber, rightIsNumber := releasePlanSnapshotNumber(right)
+ if leftIsNumber || rightIsNumber {
+ return leftIsNumber && rightIsNumber && leftNumber == rightNumber
+ }
+
+ return left == right
+}
+
+func releasePlanSnapshotMapsEqual(left, right map[string]interface{}) bool {
+ for key, leftValue := range left {
+ rightValue, exists := right[key]
+ if !exists {
+ if !isEmptyReleasePlanSnapshotValue(leftValue) {
+ return false
+ }
+ continue
+ }
+ if !releasePlanSnapshotValuesEqual(leftValue, rightValue) {
+ return false
+ }
+ }
+
+ for key, rightValue := range right {
+ if _, exists := left[key]; exists {
+ continue
+ }
+ if !isEmptyReleasePlanSnapshotValue(rightValue) {
+ return false
+ }
+ }
+
+ return true
+}
+
+func releasePlanSnapshotListsEqual(left, right []interface{}) bool {
+ leftIdx, rightIdx := 0, 0
+ for {
+ for leftIdx < len(left) && isEmptyReleasePlanSnapshotValue(left[leftIdx]) {
+ leftIdx++
+ }
+ for rightIdx < len(right) && isEmptyReleasePlanSnapshotValue(right[rightIdx]) {
+ rightIdx++
+ }
+ if leftIdx == len(left) || rightIdx == len(right) {
+ break
+ }
+ if !releasePlanSnapshotValuesEqual(left[leftIdx], right[rightIdx]) {
+ return false
+ }
+ leftIdx++
+ rightIdx++
+ }
+
+ for leftIdx < len(left) {
+ if !isEmptyReleasePlanSnapshotValue(left[leftIdx]) {
+ return false
+ }
+ leftIdx++
+ }
+ for rightIdx < len(right) {
+ if !isEmptyReleasePlanSnapshotValue(right[rightIdx]) {
+ return false
+ }
+ rightIdx++
+ }
+
+ return true
+}
+
+func isEmptyReleasePlanSnapshotValue(value interface{}) bool {
+ switch typedValue := value.(type) {
+ case map[string]interface{}:
+ for _, item := range typedValue {
+ if !isEmptyReleasePlanSnapshotValue(item) {
+ return false
+ }
+ }
+ return true
+ case []interface{}:
+ for _, item := range typedValue {
+ if !isEmptyReleasePlanSnapshotValue(item) {
+ return false
+ }
+ }
+ return true
+ default:
+ return isEmptyReleasePlanSnapshotScalarValue(value)
+ }
+}
+
+func isEmptyReleasePlanSnapshotScalarValue(value interface{}) bool {
+ switch typedValue := value.(type) {
+ case nil:
+ return true
+ case string:
+ return typedValue == ""
+ case bool:
+ return !typedValue
+ default:
+ number, ok := releasePlanSnapshotNumber(value)
+ return ok && number == 0
+ }
+}
+
+func releasePlanSnapshotNumber(value interface{}) (float64, bool) {
+ switch typedValue := value.(type) {
+ case int:
+ return float64(typedValue), true
+ case int8:
+ return float64(typedValue), true
+ case int16:
+ return float64(typedValue), true
+ case int32:
+ return float64(typedValue), true
+ case int64:
+ return float64(typedValue), true
+ case uint:
+ return float64(typedValue), true
+ case uint8:
+ return float64(typedValue), true
+ case uint16:
+ return float64(typedValue), true
+ case uint32:
+ return float64(typedValue), true
+ case uint64:
+ return float64(typedValue), true
+ case float32:
+ return float64(typedValue), true
+ case float64:
+ return typedValue, true
+ case json.Number:
+ number, err := typedValue.Float64()
+ return number, err == nil
+ default:
+ return 0, false
+ }
+}
+
func DeleteReleasePlan(c *gin.Context, username, id string) error {
info, err := mongodb.NewReleasePlanColl().GetByID(context.Background(), id)
if err != nil {
@@ -401,6 +599,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)
@@ -414,11 +616,44 @@ func UpdateReleasePlan(c *handler.Context, planID string, args *UpdateReleasePla
if err = updater.Lint(); err != nil {
return errors.Wrap(err, "lint")
}
- before, after, err := updater.Update(plan)
- if err != nil {
+ if err = updater.Update(plan); err != nil {
return errors.Wrap(err, "update")
}
+ sectionKey, sectionName, err := releasePlanVersionSectionKeyByVerb(originalPlan, plan, args)
+ if err != nil {
+ return errors.Wrap(err, "resolve release plan section")
+ }
+ currentSnapshot, err := buildReleasePlanVersionSnapshot(plan, sectionKey)
+ if err != nil {
+ return errors.Wrap(err, "build release plan current snapshot")
+ }
+ var baseSnapshot interface{}
+ nextVersion := originalPlan.Version + 1
+ needBaseSnapshot, previousVersion, err := shouldBuildReleasePlanVersionBaseSnapshot(planID, sectionKey, nextVersion, args.Verb)
+ if err != nil {
+ return errors.Wrap(err, "check release plan base snapshot")
+ }
+ if !needBaseSnapshot {
+ needBaseSnapshot, err = shouldBuildReleasePlanWorkflowDisplayBaseSnapshot(planID, sectionKey, previousVersion, currentSnapshot)
+ if err != nil {
+ return errors.Wrap(err, "check release plan workflow base snapshot")
+ }
+ }
+ if needBaseSnapshot {
+ baseSnapshot, err = buildReleasePlanVersionSnapshot(originalPlan, sectionKey)
+ if err != nil {
+ return errors.Wrap(err, "build release plan base snapshot")
+ }
+ }
+ logBaseSnapshot, err := resolveReleasePlanLogBaseSnapshot(baseSnapshot, originalPlan, sectionKey)
+ if err != nil {
+ return errors.Wrap(err, "build release plan log base snapshot")
+ }
+ shouldCreateLog := hasReleasePlanSnapshotChanges(logBaseSnapshot, currentSnapshot)
+
+ plan.Version = nextVersion
+
plan.UpdatedBy = c.UserName
plan.UpdateTime = time.Now().Unix()
@@ -442,21 +677,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 {
+ logItem := &models.ReleasePlanLog{
+ PlanID: planID,
+ Username: c.UserName,
+ Account: c.Account,
+ Verb: updater.Verb(),
+ TargetName: updater.TargetName(),
+ TargetType: updater.TargetType(),
+ Version: plan.Version,
+ SectionKey: sectionKey,
+ SectionName: releasePlanVersionSectionName(sectionKey, sectionName),
+ SectionType: releasePlanVersionSectionGroupType(sectionKey),
+ CreatedAt: time.Now().Unix(),
+ }
+ if err := createReleasePlanVersionWithBaseSnapshot(planID, plan.Version, previousVersion, baseSnapshot, currentSnapshot, c.UserName, c.Account, sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), string(args.Verb)); err != nil {
+ log.Errorf("create release plan version error: %v", err)
+ } else if shouldCreateLog {
+ if err := createReleasePlanLog(logItem); err != nil {
log.Errorf("create release plan log error: %v", err)
}
- }()
+ }
+ if err := broadcastReleasePlanCollaboration(planID); err != nil {
+ log.Errorf("broadcast release plan collaboration error: %v", err)
+ }
return nil
}
@@ -477,6 +720,10 @@ func GetReleasePlanJobDetail(planID, jobID string) (*commonmodels.ReleaseJob, er
if spec.Workflow == nil {
return nil, fmt.Errorf("workflow is nil")
}
+ spec.Workflow, err = normalizeReleasePlanWorkflowForController(spec.Workflow)
+ if err != nil {
+ return nil, fmt.Errorf("invalid workflow for job: %s. normalize error: %s", releasePlanJob.Name, err)
+ }
workflowController := controller.CreateWorkflowController(spec.Workflow)
if err := workflowController.UpdateWithLatestWorkflow(nil); err != nil {
@@ -495,6 +742,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 +819,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 +837,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 +877,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 +931,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 +944,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 +991,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 +1090,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 +1113,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 +1143,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 +1201,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 +1219,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()
@@ -905,6 +1244,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 {
@@ -918,11 +1259,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 +1297,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 +1308,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 +1332,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 +1459,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,16 +1469,16 @@ 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,
Verb: VerbUpdate,
- TargetName: TargetTypeReleasePlanStatus,
+ TargetName: releasePlanTargetTypeDisplayName(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 +1549,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,
+ TargetName: releasePlanTargetTypeDisplayName(TargetTypeReleasePlanStatus),
TargetType: TargetTypeReleasePlanStatus,
Detail: DetailApprovalPass,
- After: config.ReleasePlanStatusExecuting,
+ Before: beforeStatus,
CreatedAt: time.Now().Unix(),
}
plan.Status = config.ReleasePlanStatusExecuting
@@ -1235,11 +1582,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: releasePlanTargetTypeDisplayName(TargetTypeReleasePlanStatus),
+ TargetType: TargetTypeReleasePlanStatus,
+ Detail: DetailApprovalReject,
+ Before: beforeStatus,
+ After: config.ReleasePlanStatusApprovalDenied,
+ CreatedAt: time.Now().Unix(),
}
plan.Status = config.ReleasePlanStatusApprovalDenied
@@ -1261,7 +1616,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..a81738dfa8
--- /dev/null
+++ b/pkg/microservice/aslan/core/release_plan/service/section_snapshot.go
@@ -0,0 +1,859 @@
+package service
+
+import (
+ "encoding/json"
+ "reflect"
+ "sort"
+ "strings"
+
+ "github.com/pkg/errors"
+ "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/bson/bsoncodec"
+ "go.mongodb.org/mongo-driver/bson/bsonoptions"
+ "go.mongodb.org/mongo-driver/bson/bsontype"
+
+ "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/workflow/service/workflow/controller"
+ "github.com/koderover/zadig/v2/pkg/tool/log"
+)
+
+const (
+ releasePlanVersionSectionPlan = "plan"
+ releasePlanVersionSectionMetadata = "metadata"
+ releasePlanVersionSectionApproval = "approval"
+ releasePlanVersionSectionJobsOrder = "jobs_order"
+ releasePlanVersionSectionJobPrefix = "job:"
+)
+
+var releasePlanWorkflowControllerBSONRegistry = func() *bsoncodec.Registry {
+ nilSliceCodec := bsoncodec.NewSliceCodec(bsonoptions.SliceCodec().SetEncodeNilAsEmpty(true))
+ tM := reflect.TypeOf(bson.M{})
+ return bson.NewRegistryBuilder().RegisterTypeMapEntry(bsontype.EmbeddedDocument, tM).RegisterDefaultEncoder(reflect.Slice, nilSliceCodec).Build()
+}()
+
+func isReleasePlanVersionMetadataSection(sectionKey string) bool {
+ return sectionKey == releasePlanVersionSectionMetadata || strings.HasPrefix(sectionKey, releasePlanVersionSectionMetadata+":")
+}
+
+func releasePlanVersionSectionName(sectionKey, fallbackName string) string {
+ switch {
+ case sectionKey == releasePlanVersionSectionPlan:
+ return "发布计划"
+ case isReleasePlanVersionMetadataSection(sectionKey):
+ if name, exists := releasePlanCollabMetadataSectionNames[sectionKey]; exists {
+ return name
+ }
+ 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 isReleasePlanVersionMetadataSection(sectionKey):
+ 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:
+ return releasePlanCollabSectionMetadataName, releasePlanVersionSectionName(releasePlanCollabSectionMetadataName, ""), nil
+ case VerbUpdateDesc:
+ return releasePlanCollabSectionMetadataDescription, releasePlanVersionSectionName(releasePlanCollabSectionMetadataDescription, ""), nil
+ case VerbUpdateTimeRange:
+ return releasePlanCollabSectionMetadataTimeRange, releasePlanVersionSectionName(releasePlanCollabSectionMetadataTimeRange, ""), nil
+ case VerbUpdateScheduleExecuteTime:
+ return releasePlanCollabSectionMetadataScheduleExecute, releasePlanVersionSectionName(releasePlanCollabSectionMetadataScheduleExecute, ""), nil
+ case VerbUpdateManager:
+ return releasePlanCollabSectionMetadataManager, releasePlanVersionSectionName(releasePlanCollabSectionMetadataManager, ""), nil
+ case VerbUpdateJiraSprint:
+ return releasePlanCollabSectionMetadataJiraSprint, releasePlanVersionSectionName(releasePlanCollabSectionMetadataJiraSprint, ""), 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 isReleasePlanVersionMetadataSection(sectionKey):
+ return buildReleasePlanMetadataSectionSnapshot(plan, sectionKey), nil
+ case sectionKey == releasePlanVersionSectionApproval:
+ return buildReleasePlanApprovalSnapshot(plan.Approval)
+ 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) {
+ approvalSnapshot, err := buildReleasePlanApprovalSnapshot(plan.Approval)
+ if err != nil {
+ return nil, err
+ }
+
+ resp := map[string]interface{}{
+ "metadata": buildReleasePlanMetadataSnapshot(plan),
+ "approval": approvalSnapshot,
+ "jobs": make([]interface{}, 0, len(plan.Jobs)),
+ "jobs_order": buildReleasePlanJobsOrderSnapshot(plan),
+ }
+ 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 buildReleasePlanMetadataSectionSnapshot(plan *models.ReleasePlan, sectionKey string) map[string]interface{} {
+ metadata := buildReleasePlanMetadataSnapshot(plan)
+ if metadata == nil {
+ return nil
+ }
+
+ switch sectionKey {
+ case releasePlanVersionSectionMetadata:
+ return metadata
+ case releasePlanCollabSectionMetadataName:
+ return map[string]interface{}{
+ "name": metadata["name"],
+ }
+ case releasePlanCollabSectionMetadataManager:
+ return map[string]interface{}{
+ "manager": metadata["manager"],
+ "manager_id": metadata["manager_id"],
+ }
+ case releasePlanCollabSectionMetadataTimeRange:
+ return map[string]interface{}{
+ "start_time": metadata["start_time"],
+ "end_time": metadata["end_time"],
+ }
+ case releasePlanCollabSectionMetadataScheduleExecute:
+ return map[string]interface{}{
+ "schedule_execute_time": metadata["schedule_execute_time"],
+ }
+ case releasePlanCollabSectionMetadataDescription:
+ return map[string]interface{}{
+ "description": metadata["description"],
+ }
+ case releasePlanCollabSectionMetadataJiraSprint:
+ return map[string]interface{}{
+ "jira_sprint_association": metadata["jira_sprint_association"],
+ }
+ default:
+ return metadata
+ }
+}
+
+func buildReleasePlanApprovalSnapshot(approval *models.Approval) (interface{}, error) {
+ if approval == nil {
+ return nil, nil
+ }
+
+ genericValue, err := toReleasePlanGenericValue(approval)
+ if err != nil {
+ return nil, err
+ }
+ return sanitizeReleasePlanValue(filterReleasePlanApprovalInputValue(genericValue)), nil
+}
+
+func filterReleasePlanApprovalInputValue(value interface{}) interface{} {
+ switch typedValue := value.(type) {
+ case map[string]interface{}:
+ resp := make(map[string]interface{}, len(typedValue))
+ for key, item := range typedValue {
+ if shouldDropReleasePlanApprovalInputField(key) {
+ continue
+ }
+ resp[key] = filterReleasePlanApprovalInputValue(item)
+ }
+ return resp
+ case []interface{}:
+ resp := make([]interface{}, 0, len(typedValue))
+ for _, item := range typedValue {
+ resp = append(resp, filterReleasePlanApprovalInputValue(item))
+ }
+ return resp
+ default:
+ return value
+ }
+}
+
+func shouldDropReleasePlanApprovalInputField(key string) bool {
+ dropKeys := map[string]struct{}{
+ "status": {},
+ "instance_code": {},
+ "instance_id": {},
+ "approval_instance": {},
+ "task_list": {},
+ "timeline": {},
+ "reject_or_approve": {},
+ "operation_time": {},
+ "comment": {},
+ "approval_node_details": {},
+ "flat_approve_users": {},
+ }
+ _, exists := dropKeys[key]
+ return exists
+}
+
+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:
+ genericValue, err := toReleasePlanGenericValue(spec)
+ if err != nil {
+ return nil, err
+ }
+ specMap, ok := getMapField(genericValue)
+ if !ok {
+ return nil, nil
+ }
+ workflowSnapshot, err := buildReleasePlanWorkflowVersionSnapshot(spec, specMap["workflow"])
+ if err != nil {
+ return nil, err
+ }
+ return map[string]interface{}{
+ "workflow": workflowSnapshot,
+ }, nil
+ default:
+ return sanitizeReleasePlanValue(spec), nil
+ }
+}
+
+func buildReleasePlanWorkflowVersionSnapshot(spec, rawWorkflow interface{}) (interface{}, error) {
+ if workflow, ok := enrichReleasePlanWorkflowWithLatest(spec); ok {
+ return buildReleasePlanWorkflowInputSnapshot(workflow)
+ }
+
+ return buildReleasePlanWorkflowInputSnapshot(rawWorkflow)
+}
+
+func enrichReleasePlanWorkflowWithLatest(spec interface{}) (_ interface{}, ok bool) {
+ defer func() {
+ if r := recover(); r != nil {
+ warnReleasePlanWorkflowRecover(r)
+ ok = false
+ }
+ }()
+
+ workflowSpec := new(models.WorkflowReleaseJobSpec)
+ if err := models.IToi(spec, workflowSpec); err != nil {
+ return nil, false
+ }
+ applyReleasePlanWorkflowLatestLookupCompat(spec, workflowSpec)
+ if workflowSpec.Workflow == nil || workflowSpec.Workflow.Name == "" {
+ return nil, false
+ }
+ normalizedWorkflow, err := normalizeReleasePlanWorkflowForController(workflowSpec.Workflow)
+ if err != nil {
+ return nil, false
+ }
+ workflowSpec.Workflow = normalizedWorkflow
+
+ workflowController := controller.CreateWorkflowController(workflowSpec.Workflow)
+ if err := workflowController.UpdateWithLatestWorkflow(nil); err != nil || workflowController.WorkflowV4 == nil {
+ return nil, false
+ }
+
+ return workflowController.WorkflowV4, true
+}
+
+func applyReleasePlanWorkflowLatestLookupCompat(spec interface{}, workflowSpec *models.WorkflowReleaseJobSpec) {
+ if workflowSpec == nil {
+ return
+ }
+
+ specMap, ok := getMapField(spec)
+ if !ok {
+ return
+ }
+
+ workflowName := firstReleasePlanWorkflowLookupString(specMap, "workflowName", "workflow_name")
+ projectName := firstReleasePlanWorkflowLookupString(specMap, "projectName", "project_name")
+ if workflowSpec.Workflow == nil {
+ if workflowName == "" && projectName == "" {
+ return
+ }
+ workflowSpec.Workflow = &models.WorkflowV4{}
+ }
+ if workflowSpec.Workflow.Name == "" {
+ workflowSpec.Workflow.Name = workflowName
+ }
+ if workflowSpec.Workflow.Project == "" {
+ workflowSpec.Workflow.Project = projectName
+ }
+}
+
+func firstReleasePlanWorkflowLookupString(input map[string]interface{}, keys ...string) string {
+ for _, key := range keys {
+ if value, ok := getStringField(input, key); ok {
+ return value
+ }
+ }
+ return ""
+}
+
+func normalizeReleasePlanWorkflowForController(workflow *models.WorkflowV4) (*models.WorkflowV4, error) {
+ if workflow == nil {
+ return nil, nil
+ }
+
+ raw, err := bson.Marshal(workflow)
+ if err != nil {
+ return nil, err
+ }
+
+ generic := bson.M{}
+ if err := bson.UnmarshalWithRegistry(releasePlanWorkflowControllerBSONRegistry, raw, &generic); err != nil {
+ return nil, err
+ }
+
+ resp := new(models.WorkflowV4)
+ if err := models.IToi(generic, resp); err != nil {
+ return nil, err
+ }
+ return resp, nil
+}
+
+func warnReleasePlanWorkflowRecover(recovered interface{}) {
+ defer func() {
+ _ = recover()
+ }()
+ log.Warnf("enrich release plan workflow panic: %v", recovered)
+}
+
+func buildReleasePlanWorkflowInputSnapshot(workflow interface{}) (interface{}, error) {
+ if workflow == nil {
+ return nil, nil
+ }
+
+ genericValue, err := toReleasePlanGenericValue(workflow)
+ if err != nil {
+ return nil, err
+ }
+ workflowMap, ok := getMapField(genericValue)
+ if !ok {
+ return nil, nil
+ }
+
+ resp := make(map[string]interface{})
+ for _, key := range []string{
+ "id",
+ "name",
+ "display_name",
+ "disabled",
+ "category",
+ "project",
+ "remark",
+ "remark_required",
+ "ignore_cache",
+ "share_storages",
+ "concurrency_limit",
+ } {
+ if value, exists := workflowMap[key]; exists {
+ resp[key] = value
+ }
+ }
+ if params, exists := workflowMap["params"]; exists {
+ resp["params"] = filterReleasePlanWorkflowInputValueAtPath("params", params)
+ }
+ if customField, exists := workflowMap["custom_field"]; exists {
+ if filtered := filterReleasePlanWorkflowInputValueAtPath("custom_field", customField); filtered != nil {
+ resp["custom_field"] = filtered
+ }
+ }
+ if stages, exists := workflowMap["stages"]; exists {
+ resp["stages"] = buildReleasePlanWorkflowStagesInputSnapshot("stages", stages)
+ }
+ if jobs, exists := workflowMap["jobs"]; exists {
+ resp["jobs"] = buildReleasePlanWorkflowJobsInputSnapshot("jobs", jobs)
+ }
+ return sanitizeReleasePlanValue(resp), nil
+}
+
+func buildReleasePlanWorkflowStagesInputSnapshot(path string, value interface{}) interface{} {
+ stages, ok := value.([]interface{})
+ if !ok {
+ return nil
+ }
+
+ resp := make([]interface{}, 0, len(stages))
+ for _, stage := range stages {
+ stageMap, ok := getMapField(stage)
+ if !ok {
+ continue
+ }
+ stageResp := make(map[string]interface{})
+ for _, key := range []string{"name", "parallel", "approval", "manual_exec"} {
+ if value, exists := stageMap[key]; exists {
+ stageResp[key] = filterReleasePlanWorkflowInputValueAtPath(joinReleasePlanWorkflowInputPath(path, key), value)
+ }
+ }
+ if jobs, exists := stageMap["jobs"]; exists {
+ stageResp["jobs"] = buildReleasePlanWorkflowJobsInputSnapshot(joinReleasePlanWorkflowInputPath(path, "jobs"), jobs)
+ }
+ if len(stageResp) > 0 {
+ resp = append(resp, stageResp)
+ }
+ }
+ return resp
+}
+
+func buildReleasePlanWorkflowJobsInputSnapshot(path string, value interface{}) interface{} {
+ jobs, ok := value.([]interface{})
+ if !ok {
+ return nil
+ }
+
+ resp := make([]interface{}, 0, len(jobs))
+ for _, job := range jobs {
+ jobMap, ok := getMapField(job)
+ if !ok {
+ continue
+ }
+ jobResp := make(map[string]interface{})
+ for _, key := range []string{"name", "type", "skipped", "run_policy", "error_policy", "execute_policy"} {
+ if item, exists := jobMap[key]; exists {
+ jobResp[key] = filterReleasePlanWorkflowInputValueAtPath(joinReleasePlanWorkflowInputPath(path, key), item)
+ }
+ }
+ if serviceModules, exists := jobMap["service_modules"]; exists {
+ jobResp["service_modules"] = filterReleasePlanWorkflowInputValueAtPath(joinReleasePlanWorkflowInputPath(path, "service_modules"), serviceModules)
+ }
+ if spec, exists := jobMap["spec"]; exists {
+ jobResp["spec"] = filterReleasePlanWorkflowInputValueAtPath(joinReleasePlanWorkflowInputPath(path, "spec"), spec)
+ }
+ if len(jobResp) > 0 {
+ resp = append(resp, jobResp)
+ }
+ }
+ return resp
+}
+
+func filterReleasePlanWorkflowInputValue(value interface{}) interface{} {
+ return filterReleasePlanWorkflowInputValueAtPath("", value)
+}
+
+func filterReleasePlanWorkflowInputValueAtPath(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 {
+ if key == "plugin" {
+ filteredPlugin := filterReleasePlanPluginTemplateInputValueAtPath(joinReleasePlanWorkflowInputPath(path, key), item)
+ if filteredPlugin != nil {
+ resp[key] = filteredPlugin
+ }
+ continue
+ }
+ if shouldDropReleasePlanWorkflowInputField(key) {
+ continue
+ }
+ if key == "variable_yaml" && hasReleasePlanWorkflowStructuredVariables(typedValue) {
+ continue
+ }
+ resp[key] = filterReleasePlanWorkflowInputValueAtPath(joinReleasePlanWorkflowInputPath(path, key), item)
+ }
+ return resp
+ case []interface{}:
+ resp := make([]interface{}, 0, len(typedValue))
+ for _, item := range typedValue {
+ resp = append(resp, filterReleasePlanWorkflowInputValueAtPath(path, item))
+ }
+ stabilizeReleasePlanWorkflowInputArray(path, resp)
+ return resp
+ default:
+ return value
+ }
+}
+
+func filterReleasePlanPluginTemplateInputValue(value interface{}) interface{} {
+ return filterReleasePlanPluginTemplateInputValueAtPath("plugin", value)
+}
+
+func filterReleasePlanPluginTemplateInputValueAtPath(path string, value interface{}) interface{} {
+ plugin, ok := value.(map[string]interface{})
+ if !ok {
+ return nil
+ }
+
+ inputs, exists := plugin["inputs"]
+ if !exists {
+ return nil
+ }
+
+ return map[string]interface{}{
+ "inputs": filterReleasePlanWorkflowInputValueAtPath(joinReleasePlanWorkflowInputPath(path, "inputs"), inputs),
+ }
+}
+
+func hasReleasePlanWorkflowStructuredVariables(value map[string]interface{}) bool {
+ if value == nil {
+ return false
+ }
+ variableKVs, ok := value["variable_kvs"].([]interface{})
+ return ok && len(variableKVs) > 0
+}
+
+func stabilizeReleasePlanWorkflowInputArray(path string, items []interface{}) {
+ if len(items) < 2 {
+ return
+ }
+
+ // Only normalize collection-like arrays here. Execution-order arrays such as
+ // workflow stages/jobs are intentionally left untouched for display fidelity.
+ switch {
+ case path == "env_options" || strings.HasSuffix(path, ".env_options"):
+ sortReleasePlanWorkflowInputArray(items, releasePlanWorkflowInputArrayKeyByEnv)
+ case path == "services" || strings.HasSuffix(path, ".services"):
+ sortReleasePlanWorkflowInputArray(items, releasePlanWorkflowInputArrayKeyByService)
+ case path == "service_modules" || strings.HasSuffix(path, ".service_modules"):
+ sortReleasePlanWorkflowInputArray(items, releasePlanWorkflowInputArrayKeyByServiceModule)
+ case path == "modules" || strings.HasSuffix(path, ".modules"):
+ sortReleasePlanWorkflowInputArray(items, releasePlanWorkflowInputArrayKeyByModule)
+ case path == "variable_kvs" || strings.HasSuffix(path, ".variable_kvs"):
+ sortReleasePlanWorkflowInputArray(items, releasePlanWorkflowInputArrayKeyByVariable)
+ case path == "target_services" || strings.HasSuffix(path, ".target_services"):
+ sortReleasePlanWorkflowInputStringArray(items)
+ case path == "service_and_builds" || strings.HasSuffix(path, ".service_and_builds"),
+ path == "default_service_and_builds" || strings.HasSuffix(path, ".default_service_and_builds"),
+ path == "service_and_builds_options" || strings.HasSuffix(path, ".service_and_builds_options"),
+ path == "service_and_images" || strings.HasSuffix(path, ".service_and_images"):
+ sortReleasePlanWorkflowInputArray(items, releasePlanWorkflowInputArrayKeyByServiceBuild)
+ case path == "service_and_scannings" || strings.HasSuffix(path, ".service_and_scannings"),
+ path == "service_scanning_options" || strings.HasSuffix(path, ".service_scanning_options"),
+ path == "scannings" || strings.HasSuffix(path, ".scannings"),
+ path == "scanning_options" || strings.HasSuffix(path, ".scanning_options"):
+ sortReleasePlanWorkflowInputArray(items, releasePlanWorkflowInputArrayKeyByScanning)
+ case path == "nacos_filtered_data" || strings.HasSuffix(path, ".nacos_filtered_data"):
+ sortReleasePlanWorkflowInputArray(items, releasePlanWorkflowInputArrayKeyByNacosData)
+ }
+}
+
+func sortReleasePlanWorkflowInputArray(items []interface{}, buildKey func(interface{}) (string, bool)) {
+ type sortableItem struct {
+ item interface{}
+ key string
+ }
+
+ sortableItems := make([]sortableItem, 0, len(items))
+ for _, item := range items {
+ primaryKey, ok := buildKey(item)
+ if !ok {
+ return
+ }
+ sortKey := primaryKey
+ if hash, err := hashReleasePlanSubtree(item); err == nil {
+ sortKey = primaryKey + "|" + hash
+ }
+ sortableItems = append(sortableItems, sortableItem{item: item, key: sortKey})
+ }
+
+ sort.SliceStable(sortableItems, func(i, j int) bool {
+ return sortableItems[i].key < sortableItems[j].key
+ })
+ for i := range sortableItems {
+ items[i] = sortableItems[i].item
+ }
+}
+
+func sortReleasePlanWorkflowInputStringArray(items []interface{}) {
+ for _, item := range items {
+ if _, ok := item.(string); !ok {
+ return
+ }
+ }
+ sort.SliceStable(items, func(i, j int) bool {
+ return items[i].(string) < items[j].(string)
+ })
+}
+
+func releasePlanWorkflowInputArrayKeyByEnv(item interface{}) (string, bool) {
+ return releasePlanWorkflowInputArrayKeyByFields(item, "env", "env_name", "env_alias")
+}
+
+func releasePlanWorkflowInputArrayKeyByService(item interface{}) (string, bool) {
+ return releasePlanWorkflowInputArrayKeyByFields(item, "service_name", "service_module", "image_name")
+}
+
+func releasePlanWorkflowInputArrayKeyByServiceModule(item interface{}) (string, bool) {
+ return releasePlanWorkflowInputArrayKeyByFields(item, "service_name", "service_module")
+}
+
+func releasePlanWorkflowInputArrayKeyByModule(item interface{}) (string, bool) {
+ return releasePlanWorkflowInputArrayKeyByFields(item, "service_module", "image_name", "image")
+}
+
+func releasePlanWorkflowInputArrayKeyByVariable(item interface{}) (string, bool) {
+ return releasePlanWorkflowInputArrayKeyByFields(item, "key")
+}
+
+func releasePlanWorkflowInputArrayKeyByServiceBuild(item interface{}) (string, bool) {
+ return releasePlanWorkflowInputArrayKeyByFields(item, "service_name", "service_module", "image_name", "build_name", "name")
+}
+
+func releasePlanWorkflowInputArrayKeyByScanning(item interface{}) (string, bool) {
+ return releasePlanWorkflowInputArrayKeyByFields(item, "service_name", "service_module", "name", "project_name")
+}
+
+func releasePlanWorkflowInputArrayKeyByNacosData(item interface{}) (string, bool) {
+ return releasePlanWorkflowInputArrayKeyByFields(item, "namespace_id", "group", "data_id")
+}
+
+func releasePlanWorkflowInputArrayKeyByFields(item interface{}, keys ...string) (string, bool) {
+ value, ok := getMapField(item)
+ if !ok {
+ return "", false
+ }
+
+ parts := make([]string, 0, len(keys))
+ for _, key := range keys {
+ part, exists := getStringField(value, key)
+ if exists {
+ parts = append(parts, part)
+ continue
+ }
+ if number, exists := getNumberFieldString(value, key); exists {
+ parts = append(parts, number)
+ continue
+ }
+ parts = append(parts, "")
+ }
+
+ // Keep empty placeholders so keys from heterogeneous-but-compatible items
+ // still compare in a consistent field order.
+ if strings.TrimSpace(strings.Join(parts, "")) == "" {
+ return "", false
+ }
+ return strings.Join(parts, "|"), true
+}
+
+func joinReleasePlanWorkflowInputPath(base, key string) string {
+ if key == "" {
+ return base
+ }
+ if base == "" {
+ return key
+ }
+ return base + "." + key
+}
+
+func shouldDropReleasePlanWorkflowInputField(key string) bool {
+ if key == "" {
+ return false
+ }
+
+ dropKeys := map[string]struct{}{
+ "last_status": {},
+ "updated": {},
+ "executed_by": {},
+ "executed_time": {},
+ "hook_payload": {},
+ "hash": {},
+ "notification_id": {},
+ "created_by": {},
+ "create_time": {},
+ "updated_by": {},
+ "update_time": {},
+ "approval_instance": {},
+ "operation_time": {},
+ "reject_or_approve": {},
+ "manual_exector_id": {},
+ "manual_exector_name": {},
+ "notification_sent": {},
+ "advanced_setting": {},
+ "runtime": {},
+ "steps": {},
+ "properties": {},
+ "outputs": {},
+ }
+ if _, exists := dropKeys[key]; exists {
+ return true
+ }
+
+ return false
+}
+
+func releasePlanVersionDiffGroup(sectionKey, sectionName string) (string, string, string) {
+ return sectionKey, releasePlanVersionSectionName(sectionKey, sectionName), releasePlanVersionSectionGroupType(sectionKey)
+}
diff --git a/pkg/microservice/aslan/core/release_plan/service/update.go b/pkg/microservice/aslan/core/release_plan/service/update.go
index 8939e7355d..b699733cd5 100644
--- a/pkg/microservice/aslan/core/release_plan/service/update.go
+++ b/pkg/microservice/aslan/core/release_plan/service/update.go
@@ -19,7 +19,6 @@ package service
import (
"context"
"fmt"
- "time"
"github.com/google/uuid"
"github.com/pkg/errors"
@@ -49,12 +48,12 @@ const (
VerbUpdateApproval = "update_approval"
VerbDeleteApproval = "delete_approval"
- TargetTypeReleasePlan = "发布计划"
- TargetTypeReleasePlanStatus = "发布计划状态"
- TargetTypeMetadata = "元数据"
- TargetTypeReleaseJob = "发布内容"
- TargetTypeApproval = "审批"
- TargetTypeDescription = "需求关联"
+ TargetTypeReleasePlan = "release_plan"
+ TargetTypeReleasePlanStatus = "release_plan_status"
+ TargetTypeMetadata = "metadata"
+ TargetTypeReleaseJob = "release_job"
+ TargetTypeApproval = "approval"
+ TargetTypeDescription = "description"
VerbCreate = "新建"
VerbUpdate = "更新"
@@ -70,12 +69,36 @@ const (
)
var TargetTypeI18nMap = map[string]string{
- TargetTypeReleasePlan: "Release Plan",
- TargetTypeReleasePlanStatus: "Release Plan Status",
- TargetTypeMetadata: "Metadata",
- TargetTypeReleaseJob: "Release Job",
- TargetTypeApproval: "Approval",
- TargetTypeDescription: "Description",
+ TargetTypeReleasePlan: "发布计划",
+ TargetTypeReleasePlanStatus: "发布计划状态",
+ TargetTypeMetadata: "元数据",
+ TargetTypeReleaseJob: "发布内容",
+ TargetTypeApproval: "审批",
+ TargetTypeDescription: "需求关联",
+}
+
+var legacyReleasePlanTargetTypeMap = map[string]string{
+ "发布计划": TargetTypeReleasePlan,
+ "发布计划状态": TargetTypeReleasePlanStatus,
+ "元数据": TargetTypeMetadata,
+ "发布内容": TargetTypeReleaseJob,
+ "审批": TargetTypeApproval,
+ "需求关联": TargetTypeDescription,
+}
+
+func normalizeReleasePlanTargetType(targetType string) string {
+ if normalized, ok := legacyReleasePlanTargetTypeMap[targetType]; ok {
+ return normalized
+ }
+ return targetType
+}
+
+func releasePlanTargetTypeDisplayName(targetType string) string {
+ targetType = normalizeReleasePlanTargetType(targetType)
+ if displayName, ok := TargetTypeI18nMap[targetType]; ok {
+ return displayName
+ }
+ return targetType
}
var VerbI18nMap = map[string]string{
@@ -83,6 +106,7 @@ var VerbI18nMap = map[string]string{
VerbUpdate: "Update",
VerbDelete: "Delete",
VerbExecute: "Execute",
+ VerbRetry: "Retry",
VerbSkip: "Skip",
}
@@ -96,8 +120,7 @@ var UserNameI18nMap = map[string]string{
}
type PlanUpdater interface {
- // Update returns the old data and the updated data
- Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error)
+ Update(plan *models.ReleasePlan) error
Verb() string
TargetName() string
TargetType() string
@@ -149,10 +172,9 @@ func NewNameUpdater(args *UpdateReleasePlanArgs) (*NameUpdater, error) {
return &updater, nil
}
-func (u *NameUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) {
- before, after = plan.Name, u.Name
+func (u *NameUpdater) Update(plan *models.ReleasePlan) error {
plan.Name = u.Name
- return
+ return nil
}
func (u *NameUpdater) Lint() error {
@@ -186,10 +208,9 @@ func NewDescUpdater(args *UpdateReleasePlanArgs) (*DescUpdater, error) {
return &updater, nil
}
-func (u *DescUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) {
- before, after = plan.Description, u.Description
+func (u *DescUpdater) Update(plan *models.ReleasePlan) error {
plan.Description = u.Description
- return
+ return nil
}
func (u *DescUpdater) Lint() error {
@@ -221,15 +242,10 @@ func NewTimeRangeUpdater(args *UpdateReleasePlanArgs) (*TimeRangeUpdater, error)
return &updater, nil
}
-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))
+func (u *TimeRangeUpdater) Update(plan *models.ReleasePlan) error {
plan.StartTime = u.StartTime
plan.EndTime = u.EndTime
- return
+ return nil
}
func (u *TimeRangeUpdater) Lint() error {
@@ -261,11 +277,10 @@ func NewManagerUpdater(args *UpdateReleasePlanArgs) (*ManagerUpdater, error) {
return &updater, nil
}
-func (u *ManagerUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) {
- before, after = plan.Manager, u.Manager
+func (u *ManagerUpdater) Update(plan *models.ReleasePlan) error {
plan.ManagerID = u.ManagerID
plan.Manager = u.Manager
- return
+ return nil
}
func (u *ManagerUpdater) Lint() error {
@@ -310,8 +325,7 @@ func NewCreateReleaseJobUpdater(args *UpdateReleasePlanArgs) (*CreateReleaseJobU
return &updater, nil
}
-func (u *CreateReleaseJobUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) {
- before, after = nil, u
+func (u *CreateReleaseJobUpdater) Update(plan *models.ReleasePlan) error {
job := &models.ReleaseJob{
ID: uuid.New().String(),
Name: u.Name,
@@ -321,7 +335,7 @@ func (u *CreateReleaseJobUpdater) Update(plan *models.ReleasePlan) (before inter
Spec: u.Spec,
}
plan.Jobs = append(plan.Jobs, job)
- return
+ return nil
}
func (u *CreateReleaseJobUpdater) Lint() error {
@@ -362,22 +376,21 @@ func NewUpdateReleaseJobUpdater(args *UpdateReleasePlanArgs) (*UpdateReleaseJobU
return &updater, nil
}
-func (u *UpdateReleaseJobUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) {
+func (u *UpdateReleaseJobUpdater) Update(plan *models.ReleasePlan) error {
for _, job := range plan.Jobs {
if job.ID == u.ID {
if job.Type != u.Type {
- return nil, nil, fmt.Errorf("job type cannot be changed")
+ return fmt.Errorf("job type cannot be changed")
}
- before, after = job, u
job.Name = u.Name
job.Manager = u.Manager
job.ManagerID = u.ManagerID
job.Spec = u.Spec
job.Updated = true
- return
+ return nil
}
}
- return nil, nil, fmt.Errorf("job %s-%s not found", u.Name, u.ID)
+ return fmt.Errorf("job %s-%s not found", u.Name, u.ID)
}
// note that the real linting process is when we finish planning, not saving the draft.
@@ -416,15 +429,15 @@ func NewDeleteReleaseJobUpdater(args *UpdateReleasePlanArgs) (*DeleteReleaseJobU
return &updater, nil
}
-func (u *DeleteReleaseJobUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) {
+func (u *DeleteReleaseJobUpdater) Update(plan *models.ReleasePlan) error {
for i, job := range plan.Jobs {
if job.ID == u.ID {
u.name = job.Name
plan.Jobs = append(plan.Jobs[:i], plan.Jobs[i+1:]...)
- return
+ return nil
}
}
- return nil, nil, fmt.Errorf("job %s not found", u.ID)
+ return fmt.Errorf("job %s not found", u.ID)
}
func (u *DeleteReleaseJobUpdater) Lint() error {
@@ -458,13 +471,7 @@ func NewReorderReleaseJobUpdater(args *UpdateReleasePlanArgs) (*ReorderReleaseJo
return &updater, nil
}
-func (u *ReorderReleaseJobUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) {
- beforeIDs := make([]string, 0, len(plan.Jobs))
- for _, job := range plan.Jobs {
- beforeIDs = append(beforeIDs, job.ID)
- }
- before = beforeIDs
-
+func (u *ReorderReleaseJobUpdater) Update(plan *models.ReleasePlan) error {
jobMap := make(map[string]*models.ReleaseJob, len(plan.Jobs))
for _, job := range plan.Jobs {
jobMap[job.ID] = job
@@ -474,13 +481,12 @@ func (u *ReorderReleaseJobUpdater) Update(plan *models.ReleasePlan) (before inte
for _, id := range u.JobIDs {
job, ok := jobMap[id]
if !ok {
- return nil, nil, fmt.Errorf("job %s not found", id)
+ return fmt.Errorf("job %s not found", id)
}
newJobs = append(newJobs, job)
}
plan.Jobs = newJobs
- after = u.JobIDs
- return
+ return nil
}
func (u *ReorderReleaseJobUpdater) Lint() error {
@@ -514,13 +520,12 @@ func NewUpdateApprovalUpdater(args *UpdateReleasePlanArgs) (*UpdateApprovalUpdat
return &updater, nil
}
-func (u *UpdateApprovalUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) {
+func (u *UpdateApprovalUpdater) Update(plan *models.ReleasePlan) error {
if err := clearApprovalData(u.Approval); err != nil {
- return nil, nil, errors.Wrap(err, "clear approval data")
+ return errors.Wrap(err, "clear approval data")
}
- before, after = plan.Approval, u.Approval
plan.Approval = u.Approval
- return
+ return nil
}
func (u *UpdateApprovalUpdater) Lint() error {
@@ -557,10 +562,9 @@ func NewDeleteApprovalUpdater(args *UpdateReleasePlanArgs) (*DeleteApprovalUpdat
return &DeleteApprovalUpdater{}, nil
}
-func (u *DeleteApprovalUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) {
- before, after = plan.Approval, nil
+func (u *DeleteApprovalUpdater) Update(plan *models.ReleasePlan) error {
plan.Approval = nil
- return
+ return nil
}
func (u *DeleteApprovalUpdater) Lint() error {
@@ -654,12 +658,9 @@ func NewScheduleExecuteTimeUpdater(args *UpdateReleasePlanArgs) (*ScheduleExecut
return &updater, nil
}
-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)
+func (u *ScheduleExecuteTimeUpdater) Update(plan *models.ReleasePlan) error {
plan.ScheduleExecuteTime = u.ScheduleExecuteTime
- return
+ return nil
}
func (u *ScheduleExecuteTimeUpdater) Lint() error {
@@ -690,10 +691,9 @@ func NewJiraSprintUpdater(args *UpdateReleasePlanArgs) (*JiraSprintUpdater, erro
return &updater, nil
}
-func (u *JiraSprintUpdater) Update(plan *models.ReleasePlan) (before interface{}, after interface{}, err error) {
- before, after = plan.JiraSprintAssociation, u.JiraSprintAssociation
+func (u *JiraSprintUpdater) Update(plan *models.ReleasePlan) error {
plan.JiraSprintAssociation = u.JiraSprintAssociation
- return
+ return nil
}
func (u *JiraSprintUpdater) Lint() error {
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..232c647ba8
--- /dev/null
+++ b/pkg/microservice/aslan/core/release_plan/service/version.go
@@ -0,0 +1,139 @@
+/*
+ * 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 (
+ "time"
+
+ "go.mongodb.org/mongo-driver/mongo"
+
+ "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/models"
+ "github.com/koderover/zadig/v2/pkg/microservice/aslan/core/common/repository/mongodb"
+)
+
+func createReleasePlanVersion(planID string, version int64, snapshot interface{}, operator, account, sectionKey, sectionName, verb string) error {
+ return createReleasePlanVersionWithBaseSnapshot(planID, version, 0, nil, snapshot, operator, account, sectionKey, sectionName, verb)
+}
+
+func createReleasePlanVersionWithBaseSnapshot(planID string, version, previousVersion int64, baseSnapshot, snapshot interface{}, operator, account, sectionKey, sectionName, verb string) error {
+ return mongodb.NewReleasePlanVersionColl().Create(&models.ReleasePlanVersion{
+ PlanID: planID,
+ Version: version,
+ PreviousVersion: previousVersion,
+ Operator: operator,
+ Account: account,
+ SectionKey: sectionKey,
+ SectionName: sectionName,
+ SectionType: releasePlanVersionSectionGroupType(sectionKey),
+ Verb: verb,
+ BaseSnapshot: sanitizeReleasePlanValue(baseSnapshot),
+ Snapshot: sanitizeReleasePlanValue(snapshot),
+ CreatedAt: time.Now().Unix(),
+ })
+}
+
+func shouldBuildReleasePlanVersionBaseSnapshot(planID, sectionKey string, version int64, verb UpdateReleasePlanVerb) (bool, int64, error) {
+ switch verb {
+ case VerbDeleteReleaseJob, VerbDeleteApproval, VerbReorderReleaseJob:
+ return true, version - 1, nil
+ default:
+ previousVersion, err := previousComparableReleasePlanVersion(planID, sectionKey, version)
+ if err != nil {
+ return false, 0, err
+ }
+ if previousVersion == 0 {
+ return true, version - 1, nil
+ }
+ return false, previousVersion, nil
+ }
+}
+
+func previousComparableReleasePlanVersion(planID, sectionKey string, beforeVersion int64) (int64, error) {
+ sectionKeys := []string{sectionKey}
+ if sectionKey != releasePlanVersionSectionPlan {
+ sectionKeys = append(sectionKeys, releasePlanVersionSectionPlan)
+ }
+
+ previous, err := mongodb.NewReleasePlanVersionColl().GetLatestBySectionsBefore(planID, sectionKeys, beforeVersion)
+ if err != nil {
+ if err == mongo.ErrNoDocuments {
+ return 0, nil
+ }
+ return 0, err
+ }
+ return previous.Version, nil
+}
+
+func shouldBuildReleasePlanWorkflowDisplayBaseSnapshot(planID, sectionKey string, previousVersion int64, currentSnapshot interface{}) (bool, error) {
+ if previousVersion == 0 || releasePlanVersionSectionGroupType(sectionKey) != "job" || !isReleasePlanWorkflowJobSnapshot(currentSnapshot) {
+ return false, nil
+ }
+
+ previous, err := mongodb.NewReleasePlanVersionColl().Get(planID, previousVersion)
+ if err != nil {
+ return false, err
+ }
+ previousSnapshot := comparableReleasePlanVersionSnapshot(previous, sectionKey)
+ return isIncompleteReleasePlanWorkflowDisplaySnapshot(previousSnapshot, currentSnapshot), nil
+}
+
+func isIncompleteReleasePlanWorkflowDisplaySnapshot(previousSnapshot, currentSnapshot interface{}) bool {
+ previousSpec, ok := getMapField(releasePlanVersionDiffJobSpec(previousSnapshot))
+ if !ok {
+ return false
+ }
+ currentSpec, ok := getMapField(releasePlanVersionDiffJobSpec(currentSnapshot))
+ if !ok {
+ return false
+ }
+
+ return hasMissingReleasePlanWorkflowDisplayFields(currentSpec, previousSpec)
+}
+
+func hasMissingReleasePlanWorkflowDisplayFields(reference, candidate interface{}) bool {
+ switch typedReference := reference.(type) {
+ case map[string]interface{}:
+ typedCandidate, ok := candidate.(map[string]interface{})
+ if !ok {
+ return true
+ }
+ for key, referenceValue := range typedReference {
+ candidateValue, exists := typedCandidate[key]
+ if !exists {
+ return true
+ }
+ if hasMissingReleasePlanWorkflowDisplayFields(referenceValue, candidateValue) {
+ return true
+ }
+ }
+ case []interface{}:
+ typedCandidate, ok := candidate.([]interface{})
+ if !ok {
+ return true
+ }
+ limit := len(typedReference)
+ if len(typedCandidate) < limit {
+ limit = len(typedCandidate)
+ }
+ for idx := 0; idx < limit; idx++ {
+ if hasMissingReleasePlanWorkflowDisplayFields(typedReference[idx], typedCandidate[idx]) {
+ return true
+ }
+ }
+ }
+ return false
+}
diff --git a/pkg/microservice/aslan/core/release_plan/service/watcher.go b/pkg/microservice/aslan/core/release_plan/service/watcher.go
index d18c6f1087..c076f26a18 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 {
@@ -229,16 +230,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,
+ TargetName: releasePlanTargetTypeDisplayName(TargetTypeReleasePlanStatus),
TargetType: TargetTypeReleasePlanStatus,
Detail: DetailApprovalPass,
- After: config.ReleasePlanStatusExecuting,
+ Before: beforeStatus,
CreatedAt: time.Now().Unix(),
}
@@ -260,11 +263,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: releasePlanTargetTypeDisplayName(TargetTypeReleasePlanStatus),
+ TargetType: TargetTypeReleasePlanStatus,
+ Detail: DetailApprovalReject,
+ Before: beforeStatus,
+ After: config.ReleasePlanStatusApprovalDenied,
+ CreatedAt: time.Now().Unix(),
}
plan.Status = config.ReleasePlanStatusApprovalDenied
plan.ApprovalTime = time.Now().Unix()
@@ -285,7 +296,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)
}
}()
diff --git a/pkg/tool/cache/redis_cache.go b/pkg/tool/cache/redis_cache.go
index f37dd5488b..7b3621bfa3 100644
--- a/pkg/tool/cache/redis_cache.go
+++ b/pkg/tool/cache/redis_cache.go
@@ -91,6 +91,13 @@ func (c *RedisCache) GetString(key string) (string, error) {
return c.redisClient.Get(context.TODO(), key).Result()
}
+func (c *RedisCache) MGet(keys []string) ([]interface{}, error) {
+ if len(keys) == 0 {
+ return []interface{}{}, nil
+ }
+ return c.redisClient.MGet(context.TODO(), keys...).Result()
+}
+
func (c *RedisCache) HGetString(key, field string) (string, error) {
return c.redisClient.HGet(context.TODO(), key, field).Result()
}