feat: complete SOP version management
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
88
internal/config/config_test.go
Normal file
88
internal/config/config_test.go
Normal 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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
23
internal/knowledge/handler_test.go
Normal file
23
internal/knowledge/handler_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
32
internal/middleware/http_test.go
Normal file
32
internal/middleware/http_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
30
internal/scenario/field_validation_test.go
Normal file
30
internal/scenario/field_validation_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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: "没有明确的转人工或转诊路径"},
|
||||
|
||||
Reference in New Issue
Block a user