feat: complete SOP version management

This commit is contained in:
Eric 1549169735@qq.com
2026-08-08 23:29:56 +08:00
parent 4b240499c2
commit e32025d2ca
40 changed files with 966 additions and 159 deletions

View File

@@ -98,6 +98,14 @@ func Load(options LoadOptions) (Config, error) {
if environment != "" {
cfg.App.Env = environment
}
if cfg.App.Env == "prod" {
if strings.TrimSpace(os.Getenv("APP_DATABASE_PASSWORD")) == "" {
return Config{}, errors.New("APP_DATABASE_PASSWORD must be set in production")
}
if strings.TrimSpace(os.Getenv("APP_AUTH_JWT_SECRET")) == "" {
return Config{}, errors.New("APP_AUTH_JWT_SECRET must be set in production")
}
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}

View File

@@ -0,0 +1,88 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
const baseConfig = `
app:
name: test-app
env: development
server:
host: 127.0.0.1
port: 8080
database:
host: 127.0.0.1
port: 3306
name: base_db
user: base_user
password: base_password
auth:
jwt_secret: base-development-secret
seed:
admin_username: admin
admin_password: admin123
admin_display_name: Admin
`
func TestLoadProfileThenEnvironmentOverride(t *testing.T) {
dir := writeConfigs(t, `
app:
env: test
server:
port: 8081
database:
name: profile_db
auth:
jwt_secret: test-profile-secret
`)
t.Setenv("APP_DATABASE_NAME", "environment_db")
t.Setenv("APP_SERVER_PORT", "9090")
cfg, err := Load(LoadOptions{Environment: "test", ConfigDir: dir})
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.App.Env != "test" || cfg.Database.Name != "environment_db" || cfg.Database.User != "base_user" || cfg.Server.Port != 9090 {
t.Fatalf("unexpected merged config: %+v", cfg)
}
}
func TestLoadProductionRequiresEnvironmentSecrets(t *testing.T) {
dir := writeConfigs(t, "app:\n env: production\n")
t.Setenv("APP_DATABASE_PASSWORD", "")
t.Setenv("APP_AUTH_JWT_SECRET", "")
_, err := Load(LoadOptions{Environment: "prod", ConfigDir: dir})
if err == nil || !strings.Contains(err.Error(), "APP_DATABASE_PASSWORD") {
t.Fatalf("Load() error = %v, want production password requirement", err)
}
t.Setenv("APP_DATABASE_PASSWORD", "production-password")
t.Setenv("APP_AUTH_JWT_SECRET", "production-jwt-secret")
cfg, err := Load(LoadOptions{Environment: "prod", ConfigDir: dir})
if err != nil {
t.Fatalf("Load() with environment secrets error = %v", err)
}
if cfg.Database.Password != "production-password" || cfg.Auth.JWTSecret != "production-jwt-secret" {
t.Fatalf("environment secrets were not applied")
}
}
func writeConfigs(t *testing.T, profile string) string {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "config.yml"), []byte(baseConfig), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "config.test.yml"), []byte(profile), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "config.prod.yml"), []byte(profile), 0o600); err != nil {
t.Fatal(err)
}
return dir
}

View File

@@ -63,6 +63,7 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger
protected.GET("/scenarios/:id/sops", middleware.RequirePermission("sop.view"), sopHandler.List)
protected.POST("/scenarios/:id/sops", middleware.RequirePermission("sop.edit"), sopHandler.Create)
protected.GET("/sops/:id", middleware.RequirePermission("sop.view"), sopHandler.Get)
protected.GET("/sops/:id/versions", middleware.RequirePermission("sop.view"), sopHandler.ListVersions)
protected.PUT("/sops/:id/draft", middleware.RequirePermission("sop.edit"), sopHandler.SaveGraph)
protected.POST("/sops/:id/validate", middleware.RequireAnyPermission("sop.view", "sop.edit"), sopHandler.Validate)
protected.POST("/sops/:id/submit-review", middleware.RequirePermission("sop.submit_review"), sopHandler.SubmitReview)
@@ -70,6 +71,7 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger
protected.POST("/sops/:id/reject", middleware.RequirePermission("sop.review"), sopHandler.Reject)
protected.POST("/sops/:id/offline", middleware.RequirePermission("sop.publish"), sopHandler.Offline)
protected.POST("/sops/:id/versions", middleware.RequirePermission("sop.edit"), sopHandler.CreateVersion)
protected.POST("/sops/:id/rollback", middleware.RequirePermission("sop.publish"), sopHandler.Rollback)
protected.GET("/reviews", middleware.RequirePermission("sop.review"), sopHandler.Reviews)
protected.GET("/published-sops", middleware.RequirePermission("sop.execute"), runHandler.PublishedSOPs)
@@ -83,6 +85,8 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger
protected.GET("/knowledge-cards", middleware.RequirePermission("knowledge.view"), knowledgeHandler.List)
protected.POST("/knowledge-cards", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.Create)
protected.GET("/knowledge-cards/:id/versions", middleware.RequirePermission("knowledge.view"), knowledgeHandler.Versions)
protected.PUT("/knowledge-cards/:id", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.Update)
protected.DELETE("/knowledge-cards/:id", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.Delete)
router.NoRoute(spaHandler(frontend))

View File

@@ -2,8 +2,10 @@ package knowledge
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
@@ -13,6 +15,7 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Handler struct{ db *gorm.DB }
@@ -25,16 +28,22 @@ type input struct {
Content map[string]interface{} `json:"content" binding:"required"`
}
type updateInput struct {
Title string `json:"title" binding:"required,max=128"`
Content map[string]interface{} `json:"content" binding:"required"`
}
func (h *Handler) List(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
type row struct {
model.KnowledgeCard
Content datatypes.JSON `json:"content"`
Version int `json:"version"`
}
items := make([]row, 0)
query := h.db.Table("knowledge_cards kc").Select("kc.*, kcv.content").Joins("JOIN scenarios sc ON sc.id = kc.scenario_id").Joins("LEFT JOIN knowledge_card_versions kcv ON kcv.knowledge_card_id = kc.id AND kcv.status = ?", "published")
query := h.db.Table("knowledge_cards kc").Select("kc.*, kcv.content, kcv.version").Joins("JOIN scenarios sc ON sc.id = kc.scenario_id").Joins("LEFT JOIN knowledge_card_versions kcv ON kcv.knowledge_card_id = kc.id AND kcv.status = ?", "published")
query = access.ScopeScenarios(query, p, "sc")
err := query.Where("kc.tenant_id = ? AND kc.status <> ?", p.TenantID, "archived").Order("kc.updated_at DESC").Scan(&items).Error
err := query.Where("kc.tenant_id = ? AND kc.status <> ? AND sc.status <> ?", p.TenantID, "archived", "archived").Order("kc.updated_at DESC").Scan(&items).Error
if err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询知识卡失败")
return
@@ -42,6 +51,78 @@ func (h *Handler) List(c *gin.Context) {
response.OK(c, gin.H{"items": items, "total": len(items)})
}
func (h *Handler) Update(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.Error(c, http.StatusBadRequest, "INVALID_ID", "知识卡 ID 不正确")
return
}
var body updateInput
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识卡内容不完整")
return
}
if err := validateContent(body.Content); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
return
}
content, err := json.Marshal(body.Content)
if err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识卡内容格式不正确")
return
}
var card model.KnowledgeCard
if err := h.db.Where("id = ? AND tenant_id = ? AND status <> ?", id, p.TenantID, "archived").First(&card).Error; err != nil || !access.CanEditScenario(h.db, p, card.ScenarioID) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "知识卡不存在或不可编辑")
return
}
var version model.KnowledgeCardVersion
err = h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&card).Error; err != nil {
return err
}
var maxVersion int
if err := tx.Model(&model.KnowledgeCardVersion{}).Where("knowledge_card_id = ? AND tenant_id = ?", id, p.TenantID).Select("COALESCE(MAX(version), 0)").Scan(&maxVersion).Error; err != nil {
return err
}
if err := tx.Model(&model.KnowledgeCardVersion{}).Where("knowledge_card_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Update("status", "superseded").Error; err != nil {
return err
}
version = model.KnowledgeCardVersion{TenantID: p.TenantID, KnowledgeCardID: id, Version: maxVersion + 1, Content: datatypes.JSON(content), Status: "published"}
if err := tx.Create(&version).Error; err != nil {
return err
}
return tx.Model(&card).Updates(map[string]interface{}{"title": body.Title, "status": "published"}).Error
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "UPDATE_FAILED", "更新知识卡失败")
return
}
_ = audit.Record(h.db, p, "update", "knowledge_card", id, gin.H{"version": version.Version})
response.OK(c, gin.H{"id": id, "title": body.Title, "version": version.Version, "content": datatypes.JSON(content)})
}
func (h *Handler) Versions(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.Error(c, http.StatusBadRequest, "INVALID_ID", "知识卡 ID 不正确")
return
}
var card model.KnowledgeCard
if err := h.db.Where("id = ? AND tenant_id = ? AND status <> ?", id, p.TenantID, "archived").First(&card).Error; err != nil || !access.CanViewScenario(h.db, p, card.ScenarioID) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "知识卡不存在")
return
}
items := make([]model.KnowledgeCardVersion, 0)
if err := h.db.Where("knowledge_card_id = ? AND tenant_id = ?", id, p.TenantID).Order("version DESC").Find(&items).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询知识卡版本失败")
return
}
response.OK(c, gin.H{"items": items, "total": len(items)})
}
func (h *Handler) Create(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
var body input
@@ -49,6 +130,10 @@ func (h *Handler) Create(c *gin.Context) {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识卡内容不完整")
return
}
if err := validateContent(body.Content); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
return
}
if !access.CanEditScenario(h.db, p, body.ScenarioID) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可编辑")
return
@@ -103,3 +188,18 @@ func (h *Handler) Delete(c *gin.Context) {
_ = audit.Record(h.db, p, "archive", "knowledge_card", id, nil)
response.OK(c, gin.H{"id": id})
}
func validateContent(content map[string]interface{}) error {
standard, ok := content["standard_copy"].(string)
if !ok || strings.TrimSpace(standard) == "" {
return errors.New("请填写标准话术")
}
for _, key := range []string{"forbidden_copy", "risk_note"} {
if value, exists := content[key]; exists {
if _, ok := value.(string); !ok {
return errors.New("知识卡文本字段格式不正确")
}
}
}
return nil
}

View File

@@ -0,0 +1,23 @@
package knowledge
import "testing"
func TestValidateContent(t *testing.T) {
tests := []struct {
name string
content map[string]interface{}
wantErr bool
}{
{name: "valid", content: map[string]interface{}{"standard_copy": "标准表达", "risk_note": "风险提示"}},
{name: "missing standard copy", content: map[string]interface{}{"risk_note": "风险提示"}, wantErr: true},
{name: "blank standard copy", content: map[string]interface{}{"standard_copy": " "}, wantErr: true},
{name: "invalid risk note", content: map[string]interface{}{"standard_copy": "标准表达", "risk_note": 1}, wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if err := validateContent(test.content); (err != nil) != test.wantErr {
t.Fatalf("validateContent() error = %v, wantErr %v", err, test.wantErr)
}
})
}
}

View File

@@ -24,9 +24,10 @@ func RequestLogger(log *zap.Logger) gin.HandlerFunc {
c.Header("X-Request-ID", requestID)
c.Next()
duration := time.Since(started)
fields := []zap.Field{
zap.String("request_id", requestID), zap.String("method", c.Request.Method), zap.String("path", c.Request.URL.Path),
zap.Int("status", c.Writer.Status()), zap.Int("response_bytes", c.Writer.Size()), zap.Duration("duration", time.Since(started)), zap.String("client_ip", c.ClientIP()),
zap.Int("status", c.Writer.Status()), zap.Int("response_bytes", c.Writer.Size()), zap.Int64("duration_ms", duration.Milliseconds()), zap.String("client_ip", c.ClientIP()),
}
if principal, ok := auth.PrincipalFromContext(c); ok {
fields = append(fields, zap.Uint64("tenant_id", principal.TenantID), zap.Uint64("user_id", principal.UserID))
@@ -34,7 +35,14 @@ func RequestLogger(log *zap.Logger) gin.HandlerFunc {
if len(c.Errors) > 0 {
fields = append(fields, zap.String("errors", c.Errors.String()))
}
log.Info("http request", fields...)
switch status := c.Writer.Status(); {
case status >= http.StatusInternalServerError:
log.Error("http request", fields...)
case status >= http.StatusBadRequest:
log.Warn("http request", fields...)
default:
log.Info("http request", fields...)
}
}
}

View File

@@ -0,0 +1,32 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
)
func TestRequestLoggerUsesStatusLevelAndDurationMilliseconds(t *testing.T) {
gin.SetMode(gin.TestMode)
core, logs := observer.New(zapcore.DebugLevel)
router := gin.New()
router.Use(RequestLogger(zap.New(core)))
router.GET("/bad", func(c *gin.Context) { c.Status(http.StatusBadRequest) })
request := httptest.NewRequest(http.MethodGet, "/bad", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
entries := logs.All()
if len(entries) != 1 || entries[0].Level != zapcore.WarnLevel {
t.Fatalf("log entries = %+v, want one warning", entries)
}
if _, exists := entries[0].ContextMap()["duration_ms"]; !exists {
t.Fatalf("duration_ms is missing from request log")
}
}

View File

@@ -0,0 +1,30 @@
package scenario
import (
"encoding/json"
"testing"
)
func TestValidateFieldInput(t *testing.T) {
tests := []struct {
name string
input fieldInput
wantErr bool
}{
{name: "text without options", input: fieldInput{FieldKey: "customer_name", FieldType: "text"}},
{name: "select options", input: fieldInput{FieldKey: "pet_type", FieldType: "select", Options: json.RawMessage(`["猫","狗"]`)}},
{name: "select missing options", input: fieldInput{FieldKey: "pet_type", FieldType: "select"}, wantErr: true},
{name: "invalid key", input: fieldInput{FieldKey: "1pet", FieldType: "text"}, wantErr: true},
{name: "options object", input: fieldInput{FieldKey: "pet_type", FieldType: "select", Options: json.RawMessage(`{"猫":1}`)}, wantErr: true},
{name: "options null", input: fieldInput{FieldKey: "pet_note", FieldType: "text", Options: json.RawMessage(`null`)}, wantErr: true},
{name: "validation array", input: fieldInput{FieldKey: "pet_age", FieldType: "number", Validation: json.RawMessage(`[1]`)}, wantErr: true},
{name: "validation null", input: fieldInput{FieldKey: "pet_age", FieldType: "number", Validation: json.RawMessage(`null`)}, wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if err := validateFieldInput(test.input); (err != nil) != test.wantErr {
t.Fatalf("validateFieldInput() error = %v, wantErr %v", err, test.wantErr)
}
})
}
}

View File

@@ -6,6 +6,7 @@ import (
"net/http"
"regexp"
"strconv"
"strings"
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
@@ -188,8 +189,8 @@ func (h *Handler) CreateField(c *gin.Context) {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "字段配置不正确")
return
}
if !fieldKeyPattern.MatchString(input.FieldKey) {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "字段标识必须以字母开头,且只能包含字母、数字和下划线")
if err := validateFieldInput(input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
return
}
item := model.ScenarioField{TenantID: p.TenantID, ScenarioID: scenarioID, FieldKey: input.FieldKey, FieldName: input.FieldName, FieldType: input.FieldType, Required: input.Required, Options: normalizedJSON(input.Options, `[]`), Validation: normalizedJSON(input.Validation, `{}`), SortOrder: input.SortOrder}
@@ -221,8 +222,8 @@ func (h *Handler) UpdateField(c *gin.Context) {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "字段配置不正确")
return
}
if !fieldKeyPattern.MatchString(input.FieldKey) {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "字段标识必须以字母开头,且只能包含字母、数字和下划线")
if err := validateFieldInput(input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
return
}
updates := map[string]interface{}{"field_key": input.FieldKey, "field_name": input.FieldName, "field_type": input.FieldType, "required": input.Required, "options": normalizedJSON(input.Options, `[]`), "validation": normalizedJSON(input.Validation, `{}`), "sort_order": input.SortOrder}
@@ -268,6 +269,35 @@ func normalizedJSON(value json.RawMessage, fallback string) datatypes.JSON {
return datatypes.JSON(value)
}
func validateFieldInput(input fieldInput) error {
if !fieldKeyPattern.MatchString(input.FieldKey) {
return errors.New("字段标识必须以字母开头,且只能包含字母、数字和下划线")
}
if len(input.Options) > 0 {
var options []string
if err := json.Unmarshal(input.Options, &options); err != nil || options == nil {
return errors.New("字段选项必须是文本数组")
}
for _, option := range options {
if strings.TrimSpace(option) == "" {
return errors.New("字段选项不能为空")
}
}
if (input.FieldType == "select" || input.FieldType == "multiselect") && len(options) == 0 {
return errors.New("单选或多选字段至少需要一个选项")
}
} else if input.FieldType == "select" || input.FieldType == "multiselect" {
return errors.New("单选或多选字段至少需要一个选项")
}
if len(input.Validation) > 0 {
var validation map[string]interface{}
if err := json.Unmarshal(input.Validation, &validation); err != nil || validation == nil {
return errors.New("字段校验规则必须是 JSON 对象")
}
}
return nil
}
func idParam(c *gin.Context, key string) (uint64, bool) {
id, err := strconv.ParseUint(c.Param(key), 10, 64)
if err != nil || id == 0 {

View File

@@ -15,6 +15,7 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Handler struct {
@@ -135,7 +136,21 @@ func (h *Handler) Get(c *gin.Context) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
var item model.SOP
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)
}
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
@@ -147,6 +162,35 @@ func (h *Handler) Get(c *gin.Context) {
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)})
}
func (h *Handler) SaveGraph(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
@@ -208,7 +252,21 @@ func (h *Handler) Validate(c *gin.Context) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
var item model.SOP
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)
}
if err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
@@ -406,6 +464,85 @@ func (h *Handler) CreateVersion(c *gin.Context) {
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 {
@@ -426,6 +563,46 @@ 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 {

View File

@@ -179,12 +179,14 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode
}
defaultPaths := map[string]int{}
conditionalPaths := map[string]int{}
for _, edge := range edges {
adjacency[edge.SourceNodeKey] = append(adjacency[edge.SourceNodeKey], edge.TargetNodeKey)
if isDefaultCondition(edge.Condition) {
defaultPaths[edge.SourceNodeKey]++
continue
}
conditionalPaths[edge.SourceNodeKey]++
var rule interface{}
if err := json.Unmarshal(edge.Condition, &rule); err != nil {
continue
@@ -196,6 +198,11 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode
problems = append(problems, fmt.Sprintf("节点 %s 配置了多条默认路径", source))
}
}
for source := range conditionalPaths {
if defaultPaths[source] == 0 {
problems = append(problems, fmt.Sprintf("节点 %s 包含条件路径但没有默认路径", source))
}
}
for _, node := range nodes {
var config map[string]interface{}

View File

@@ -36,6 +36,9 @@ func TestValidateForPublishBusinessRules(t *testing.T) {
{name: "duplicate default path", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
(*edges)[1].Condition = jsonData(`{}`)
}, want: "配置了多条默认路径"},
{name: "conditional branch without default", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
(*edges)[2].Condition = jsonData(`{"field":"emergency","operator":"not_equals","value":true}`)
}, want: "包含条件路径但没有默认路径"},
{name: "high risk without escalation", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
(*edges)[1].TargetNodeKey = "finish"
}, want: "没有明确的转人工或转诊路径"},