feat: simplify SOP to immediate-effect configuration

This commit is contained in:
Eric 1549169735@qq.com
2026-08-18 16:27:02 +08:00
parent c5ab886b70
commit 8de48fb05e
150 changed files with 6764 additions and 1626 deletions

View File

@@ -5,7 +5,6 @@ import (
"errors"
"net/http"
"strconv"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
@@ -15,7 +14,6 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Handler struct {
@@ -54,6 +52,10 @@ type edgeInput struct {
Priority int `json:"priority"`
}
type editState struct {
StartNodeKey string `json:"start_node_key"`
}
func (h *Handler) List(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
items := make([]model.SOP, 0)
@@ -96,11 +98,11 @@ func (h *Handler) Create(c *gin.Context) {
var item model.SOP
var version model.SOPVersion
err := h.db.Transaction(func(tx *gorm.DB) error {
item = model.SOP{TenantID: p.TenantID, ScenarioID: scenarioID, Name: input.Name, Description: input.Description, Status: "draft", CreatedBy: p.UserID}
item = model.SOP{TenantID: p.TenantID, ScenarioID: scenarioID, Name: input.Name, Description: input.Description, Status: "published", CreatedBy: p.UserID}
if err := tx.Create(&item).Error; err != nil {
return err
}
version = model.SOPVersion{TenantID: p.TenantID, SOPID: item.ID, Version: 1, Status: "draft", StartNodeKey: "start", CreatedBy: p.UserID}
version = model.SOPVersion{TenantID: p.TenantID, SOPID: item.ID, Version: 1, Status: "published", StartNodeKey: "start", CreatedBy: p.UserID}
if err := tx.Create(&version).Error; err != nil {
return err
}
@@ -116,14 +118,17 @@ func (h *Handler) Create(c *gin.Context) {
{TenantID: p.TenantID, SOPVersionID: version.ID, SourceNodeKey: "start", TargetNodeKey: "opening", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
{TenantID: p.TenantID, SOPVersionID: version.ID, SourceNodeKey: "opening", TargetNodeKey: "finish", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
}
return tx.Create(&edges).Error
if err := tx.Create(&edges).Error; err != nil {
return err
}
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", scenarioID, p.TenantID).Update("status", "active").Error
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建 SOP 失败")
return
}
_ = audit.Record(h.db, p, "create", "sop", item.ID, input)
response.Created(c, gin.H{"sop": item, "version": version})
response.Created(c, gin.H{"sop": item, "edit": editState{StartNodeKey: version.StartNodeKey}})
}
func (h *Handler) Get(c *gin.Context) {
@@ -140,17 +145,7 @@ func (h *Handler) Get(c *gin.Context) {
var version model.SOPVersion
var nodes []model.SOPNode
var edges []model.SOPEdge
var err error
if requested := c.Query("version"); requested != "" {
versionNumber, parseErr := strconv.Atoi(requested)
if parseErr != nil || versionNumber < 1 {
response.Error(c, http.StatusBadRequest, "INVALID_VERSION", "SOP 版本不正确")
return
}
item, version, nodes, edges, err = h.loadVersion(id, p.TenantID, versionNumber)
} else {
item, version, nodes, edges, err = h.loadLatest(id, p.TenantID)
}
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
@@ -159,36 +154,7 @@ func (h *Handler) Get(c *gin.Context) {
}
return
}
response.OK(c, gin.H{"sop": item, "version": version, "nodes": nodes, "edges": edges})
}
type versionItem struct {
model.SOPVersion
CreatorName string `json:"creator_name"`
ReviewerName string `json:"reviewer_name"`
}
func (h *Handler) ListVersions(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
if !ok {
return
}
if !access.CanViewSOP(h.db, p, id) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
items := make([]versionItem, 0)
err := h.db.Table("sop_versions sv").Select("sv.*, creator.display_name AS creator_name, COALESCE(reviewer.display_name, '') AS reviewer_name").
Joins("JOIN users creator ON creator.id = sv.created_by").
Joins("LEFT JOIN users reviewer ON reviewer.id = sv.reviewed_by").
Where("sv.sop_id = ? AND sv.tenant_id = ?", id, p.TenantID).
Order("sv.version DESC").Scan(&items).Error
if err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询版本历史失败")
return
}
response.OK(c, gin.H{"items": items, "total": len(items)})
response.OK(c, gin.H{"sop": item, "edit": editState{StartNodeKey: version.StartNodeKey}, "nodes": nodes, "edges": edges})
}
func (h *Handler) SaveGraph(c *gin.Context) {
@@ -206,18 +172,22 @@ func (h *Handler) SaveGraph(c *gin.Context) {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "流程配置不完整")
return
}
var version model.SOPVersion
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "draft").Order("version DESC").First(&version).Error; err != nil {
response.Error(c, http.StatusConflict, "NO_DRAFT_VERSION", "没有可编辑的草稿版本")
item, version, _, _, err := h.loadLatest(id, p.TenantID)
if err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
nodes, edges := toModels(p.TenantID, version.ID, input)
problems := ValidateGraph(input.StartNodeKey, nodes, edges)
problems, validationErr := h.validateForPublish(item, input.StartNodeKey, nodes, edges, p.TenantID)
if validationErr != nil {
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验流程失败")
return
}
if len(problems) > 0 {
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
return
}
err := h.db.Transaction(func(tx *gorm.DB) error {
err = h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPEdge{}).Error; err != nil {
return err
}
@@ -232,13 +202,22 @@ func (h *Handler) SaveGraph(c *gin.Context) {
return err
}
}
return tx.Model(&version).Update("start_node_key", input.StartNodeKey).Error
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND id <> ?", id, p.TenantID, version.ID).Update("status", "superseded").Error; err != nil {
return err
}
if err := tx.Model(&version).Updates(map[string]interface{}{"start_node_key": input.StartNodeKey, "status": "published"}).Error; err != nil {
return err
}
if err := tx.Model(&item).Update("status", "published").Error; err != nil {
return err
}
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", item.ScenarioID, p.TenantID).Update("status", "active").Error
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存流程失败")
return
}
_ = audit.Record(h.db, p, "save_graph", "sop", id, gin.H{"version_id": version.ID})
_ = audit.Record(h.db, p, "save_graph", "sop", id, nil)
h.Get(c)
}
@@ -256,17 +235,7 @@ func (h *Handler) Validate(c *gin.Context) {
var version model.SOPVersion
var nodes []model.SOPNode
var edges []model.SOPEdge
var err error
if requested := c.Query("version"); requested != "" {
versionNumber, parseErr := strconv.Atoi(requested)
if parseErr != nil || versionNumber < 1 {
response.Error(c, http.StatusBadRequest, "INVALID_VERSION", "SOP 版本不正确")
return
}
item, version, nodes, edges, err = h.loadVersion(id, p.TenantID, versionNumber)
} else {
item, version, nodes, edges, err = h.loadLatest(id, p.TenantID)
}
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
if err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
@@ -279,273 +248,6 @@ func (h *Handler) Validate(c *gin.Context) {
response.OK(c, gin.H{"valid": len(problems) == 0, "problems": problems})
}
func (h *Handler) SubmitReview(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
if !ok {
return
}
if !access.CanEditSOP(h.db, p, id) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在或不可提交")
return
}
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
if err != nil || version.Status != "draft" {
response.Error(c, http.StatusConflict, "NO_DRAFT_VERSION", "没有可提交审核的草稿版本")
return
}
problems, validationErr := h.validateForPublish(item, version.StartNodeKey, nodes, edges, p.TenantID)
if validationErr != nil {
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验流程失败")
return
}
if len(problems) > 0 {
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
return
}
err = h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&version).Update("status", "reviewing").Error; err != nil {
return err
}
var published int64
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Count(&published).Error; err != nil {
return err
}
if published == 0 {
return tx.Model(&item).Update("status", "reviewing").Error
}
return nil
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "SUBMIT_REVIEW_FAILED", "提交审核失败")
return
}
_ = audit.Record(h.db, p, "submit_review", "sop", id, gin.H{"version": version.Version})
response.OK(c, gin.H{"id": id, "version": version.Version, "status": "reviewing"})
}
func (h *Handler) Publish(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
if !ok {
return
}
if !access.CanViewSOP(h.db, p, id) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
canDirectPublish := auth.HasPermission(p, "*")
if err != nil || (version.Status != "reviewing" && !(version.Status == "draft" && canDirectPublish)) {
response.Error(c, http.StatusConflict, "NO_REVIEW_VERSION", "没有可发布的审核版本")
return
}
if !canDirectPublish && version.CreatedBy == p.UserID {
response.Error(c, http.StatusForbidden, "SELF_REVIEW_FORBIDDEN", "不能审核并发布自己创建的 SOP")
return
}
problems, validationErr := h.validateForPublish(item, version.StartNodeKey, nodes, edges, p.TenantID)
if validationErr != nil {
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验流程失败")
return
}
if len(problems) > 0 {
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
return
}
now := time.Now()
err = h.db.Transaction(func(tx *gorm.DB) error {
if err := BindKnowledgeVersions(tx, p.TenantID, version.ID); err != nil {
return err
}
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Update("status", "superseded").Error; err != nil {
return err
}
if err := tx.Model(&version).Updates(map[string]interface{}{"status": "published", "published_at": &now, "reviewed_by": p.UserID}).Error; err != nil {
return err
}
if err := tx.Model(&item).Update("status", "published").Error; err != nil {
return err
}
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", item.ScenarioID, p.TenantID).Update("status", "active").Error
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "PUBLISH_FAILED", "发布 SOP 失败")
return
}
_ = audit.Record(h.db, p, "publish", "sop", id, gin.H{"version": version.Version})
response.OK(c, gin.H{"id": id, "version": version.Version, "published_at": now})
}
func (h *Handler) Offline(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
if !ok {
return
}
if !access.CanViewSOP(h.db, p, id) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "已发布 SOP 不存在")
return
}
var item model.SOP
if err := h.db.Where("id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").First(&item).Error; err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "已发布 SOP 不存在")
return
}
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&item).Update("status", "offline").Error; err != nil {
return err
}
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Update("status", "offline").Error; err != nil {
return err
}
var published int64
if err := tx.Model(&model.SOP{}).Where("scenario_id = ? AND tenant_id = ? AND status = ?", item.ScenarioID, p.TenantID, "published").Count(&published).Error; err != nil {
return err
}
if published == 0 {
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", item.ScenarioID, p.TenantID).Update("status", "draft").Error
}
return nil
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "OFFLINE_FAILED", "下线 SOP 失败")
return
}
_ = audit.Record(h.db, p, "offline", "sop", id, nil)
response.OK(c, gin.H{"id": id, "status": "offline"})
}
func (h *Handler) CreateVersion(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
if !ok {
return
}
if !access.CanEditSOP(h.db, p, id) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在或不可编辑")
return
}
var existing int64
h.db.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status IN ?", id, p.TenantID, []string{"draft", "reviewing"}).Count(&existing)
if existing > 0 {
response.Error(c, http.StatusConflict, "DRAFT_EXISTS", "已经存在草稿或审核中的版本")
return
}
_, source, nodes, edges, err := h.loadLatest(id, p.TenantID)
if err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
var version model.SOPVersion
err = h.db.Transaction(func(tx *gorm.DB) error {
version = model.SOPVersion{TenantID: p.TenantID, SOPID: id, Version: source.Version + 1, Status: "draft", StartNodeKey: source.StartNodeKey, CreatedBy: p.UserID}
if err := tx.Create(&version).Error; err != nil {
return err
}
for i := range nodes {
nodes[i].Base = model.Base{}
nodes[i].SOPVersionID = version.ID
}
for i := range edges {
edges[i].Base = model.Base{}
edges[i].SOPVersionID = version.ID
}
if err := tx.Create(&nodes).Error; err != nil {
return err
}
if len(edges) > 0 {
return tx.Create(&edges).Error
}
return nil
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "CREATE_VERSION_FAILED", "创建草稿版本失败")
return
}
_ = audit.Record(h.db, p, "create_version", "sop", id, gin.H{"version": version.Version})
response.Created(c, version)
}
func (h *Handler) Rollback(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
if !ok {
return
}
if !access.CanViewSOP(h.db, p, id) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
var input struct {
Version int `json:"version" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择要回滚的历史版本")
return
}
item, source, nodes, edges, err := h.loadVersion(id, p.TenantID, input.Version)
if err != nil || (source.Status != "superseded" && source.Status != "offline") {
response.Error(c, http.StatusConflict, "INVALID_ROLLBACK_VERSION", "只能回滚到已替换或已下线的历史版本")
return
}
problems, validationErr := h.validateForPublish(item, source.StartNodeKey, nodes, edges, p.TenantID)
if validationErr != nil {
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验历史版本失败")
return
}
if len(problems) > 0 {
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
return
}
now := time.Now()
var restored model.SOPVersion
pendingVersionErr := errors.New("存在草稿或审核中的版本,请先处理后再回滚")
err = h.db.Transaction(func(tx *gorm.DB) error {
var locked model.SOP
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&locked).Error; err != nil {
return err
}
var pending int64
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status IN ?", id, p.TenantID, []string{"draft", "reviewing"}).Count(&pending).Error; err != nil {
return err
}
if pending > 0 {
return pendingVersionErr
}
var maxVersion int
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ?", id, p.TenantID).Select("COALESCE(MAX(version), 0)").Scan(&maxVersion).Error; err != nil {
return err
}
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Update("status", "superseded").Error; err != nil {
return err
}
reviewerID := p.UserID
restored = model.SOPVersion{TenantID: p.TenantID, SOPID: id, Version: maxVersion + 1, Status: "published", StartNodeKey: source.StartNodeKey, PublishedAt: &now, CreatedBy: p.UserID, ReviewedBy: &reviewerID}
if err := tx.Create(&restored).Error; err != nil {
return err
}
if err := cloneGraph(tx, nodes, edges, restored.ID); err != nil {
return err
}
if err := tx.Model(&locked).Update("status", "published").Error; err != nil {
return err
}
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", locked.ScenarioID, p.TenantID).Update("status", "active").Error
})
if err != nil {
if errors.Is(err, pendingVersionErr) {
response.Error(c, http.StatusConflict, "PENDING_VERSION_EXISTS", err.Error())
return
}
response.Error(c, http.StatusInternalServerError, "ROLLBACK_FAILED", "回滚 SOP 失败")
return
}
_ = audit.Record(h.db, p, "rollback", "sop", id, gin.H{"source_version": source.Version, "new_version": restored.Version})
response.Created(c, gin.H{"id": id, "source_version": source.Version, "version": restored.Version, "published_at": now})
}
func (h *Handler) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersion, []model.SOPNode, []model.SOPEdge, error) {
var item model.SOP
if err := h.db.Where("id = ? AND tenant_id = ?", sopID, tenantID).First(&item).Error; err != nil {
@@ -566,60 +268,20 @@ func (h *Handler) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersio
return item, version, nodes, edges, nil
}
func (h *Handler) loadVersion(sopID, tenantID uint64, versionNumber int) (model.SOP, model.SOPVersion, []model.SOPNode, []model.SOPEdge, error) {
var item model.SOP
if err := h.db.Where("id = ? AND tenant_id = ?", sopID, tenantID).First(&item).Error; err != nil {
return item, model.SOPVersion{}, nil, nil, err
}
var version model.SOPVersion
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND version = ?", sopID, tenantID, versionNumber).First(&version).Error; err != nil {
return item, version, nil, nil, err
}
nodes := make([]model.SOPNode, 0)
edges := make([]model.SOPEdge, 0)
if err := h.db.Where("sop_version_id = ? AND tenant_id = ?", version.ID, tenantID).Order("position_y, id").Find(&nodes).Error; err != nil {
return item, version, nil, nil, err
}
if err := h.db.Where("sop_version_id = ? AND tenant_id = ?", version.ID, tenantID).Order("priority, id").Find(&edges).Error; err != nil {
return item, version, nil, nil, err
}
return item, version, nodes, edges, nil
}
func cloneGraph(tx *gorm.DB, nodes []model.SOPNode, edges []model.SOPEdge, versionID uint64) error {
for i := range nodes {
nodes[i].Base = model.Base{}
nodes[i].SOPVersionID = versionID
}
for i := range edges {
edges[i].Base = model.Base{}
edges[i].SOPVersionID = versionID
}
if len(nodes) > 0 {
if err := tx.Create(&nodes).Error; err != nil {
return err
}
}
if len(edges) > 0 {
return tx.Create(&edges).Error
}
return nil
}
func (h *Handler) validateForPublish(item model.SOP, startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge, tenantID uint64) ([]string, error) {
fields := make([]model.ScenarioField, 0)
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&fields).Error; err != nil {
return nil, err
}
var cards []model.KnowledgeCard
if err := h.db.Where("scenario_id = ? AND tenant_id = ? AND status = ?", item.ScenarioID, tenantID, "published").Find(&cards).Error; err != nil {
var knowledgeItems []model.KnowledgeItem
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&knowledgeItems).Error; err != nil {
return nil, err
}
cardIDs := make(map[uint64]bool, len(cards))
for _, card := range cards {
cardIDs[card.ID] = true
var knowledgeRelations []model.KnowledgeRelation
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&knowledgeRelations).Error; err != nil {
return nil, err
}
return ValidateForPublish(startNodeKey, nodes, edges, ValidationContext{Fields: fields, PublishedKnowledgeCardIDs: cardIDs}), nil
return ValidateForPublish(startNodeKey, nodes, edges, ValidationContext{Fields: fields, KnowledgeItems: knowledgeItems, KnowledgeRelations: knowledgeRelations}), nil
}
func toModels(tenantID, versionID uint64, input graphInput) ([]model.SOPNode, []model.SOPEdge) {