feat: complete SOP execution workflow
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -14,5 +15,21 @@ func Record(db *gorm.DB, principal auth.Principal, action, resource string, reso
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Create(&model.AuditLog{TenantID: principal.TenantID, UserID: principal.UserID, Action: action, Resource: resource, ResourceID: resourceID, Payload: datatypes.JSON(data)}).Error
|
||||
fields := []zap.Field{
|
||||
zap.Uint64("tenant_id", principal.TenantID),
|
||||
zap.Uint64("user_id", principal.UserID),
|
||||
zap.String("action", action),
|
||||
zap.String("resource", resource),
|
||||
zap.Uint64("resource_id", resourceID),
|
||||
}
|
||||
if err := db.Create(&model.AuditLog{TenantID: principal.TenantID, UserID: principal.UserID, Action: action, Resource: resource, ResourceID: resourceID, Payload: datatypes.JSON(data)}).Error; err != nil {
|
||||
zap.L().Error("persist business event", append(fields, zap.Error(err))...)
|
||||
return err
|
||||
}
|
||||
logBusinessEvent(fields)
|
||||
return nil
|
||||
}
|
||||
|
||||
func logBusinessEvent(fields []zap.Field) {
|
||||
zap.L().Info("business event", fields...)
|
||||
}
|
||||
|
||||
34
internal/audit/audit_test.go
Normal file
34
internal/audit/audit_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
func TestLogBusinessEventIncludesRequiredFields(t *testing.T) {
|
||||
core, logs := observer.New(zapcore.InfoLevel)
|
||||
undo := zap.ReplaceGlobals(zap.New(core))
|
||||
defer undo()
|
||||
|
||||
logBusinessEvent([]zap.Field{
|
||||
zap.Uint64("tenant_id", 1),
|
||||
zap.Uint64("user_id", 2),
|
||||
zap.String("action", "publish"),
|
||||
zap.String("resource", "sop"),
|
||||
zap.Uint64("resource_id", 3),
|
||||
})
|
||||
|
||||
entries := logs.All()
|
||||
if len(entries) != 1 || entries[0].Message != "business event" {
|
||||
t.Fatalf("entries = %+v, want one business event", entries)
|
||||
}
|
||||
fields := entries[0].ContextMap()
|
||||
for _, key := range []string{"tenant_id", "user_id", "action", "resource", "resource_id"} {
|
||||
if _, ok := fields[key]; !ok {
|
||||
t.Fatalf("required field %q is missing from business event", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func (s *Service) Refresh(rawToken string) (TokenPair, error) {
|
||||
return TokenPair{}, ErrInvalidCredentials
|
||||
}
|
||||
var user model.User
|
||||
if err := s.db.First(&user, stored.UserID).Error; err != nil {
|
||||
if err := s.db.Where("id = ? AND status = ?", stored.UserID, "active").First(&user).Error; err != nil {
|
||||
return TokenPair{}, err
|
||||
}
|
||||
principal, err := s.principalForTenant(user, stored.TenantID)
|
||||
|
||||
@@ -76,6 +76,7 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger
|
||||
|
||||
protected.GET("/published-sops", middleware.RequirePermission("sop.execute"), runHandler.PublishedSOPs)
|
||||
protected.GET("/runs", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.List)
|
||||
protected.GET("/runs/options", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.Options)
|
||||
protected.POST("/runs", middleware.RequirePermission("sop.execute"), runHandler.Start)
|
||||
protected.GET("/runs/:id", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.Get)
|
||||
protected.GET("/runs/:id/detail", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.Detail)
|
||||
|
||||
@@ -113,10 +113,7 @@ func (h *Handler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&membership).Updates(map[string]interface{}{"role_id": role.ID, "status": input.Status}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.User{}).Where("id = ?", membership.UserID).Update("status", input.Status).Error
|
||||
return tx.Model(&membership).Updates(map[string]interface{}{"role_id": role.ID, "status": input.Status}).Error
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "UPDATE_FAILED", "更新成员失败")
|
||||
|
||||
@@ -20,9 +20,11 @@ type DetailHeader struct {
|
||||
|
||||
type DetailEvent struct {
|
||||
model.SOPRunEvent
|
||||
NodeTitle string `json:"node_title"`
|
||||
NodeType string `json:"node_type"`
|
||||
NodeContent string `json:"node_content"`
|
||||
NodeTitle string `json:"node_title"`
|
||||
NodeType string `json:"node_type"`
|
||||
NodeContent string `json:"node_content"`
|
||||
NodeConfig datatypes.JSON `json:"-"`
|
||||
Knowledge *KnowledgeView `json:"knowledge,omitempty" gorm:"-"`
|
||||
}
|
||||
|
||||
type DetailFeedback struct {
|
||||
@@ -45,10 +47,28 @@ func (h *Handler) Detail(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
events := make([]DetailEvent, 0)
|
||||
if err := h.db.Table("sop_run_events e").Select("e.*, n.title AS node_title, n.type AS node_type, n.content AS node_content").Joins("LEFT JOIN sop_nodes n ON n.sop_version_id = ? AND n.node_key = e.node_key", header.SOPVersionID).Where("e.run_id = ? AND e.tenant_id = ?", id, principal.TenantID).Order("e.created_at, e.id").Scan(&events).Error; err != nil {
|
||||
if err := h.db.Table("sop_run_events e").Select("e.*, n.title AS node_title, n.type AS node_type, n.content AS node_content, n.config AS node_config").Joins("LEFT JOIN sop_nodes n ON n.sop_version_id = ? AND n.node_key = e.node_key", header.SOPVersionID).Where("e.run_id = ? AND e.tenant_id = ?", id, principal.TenantID).Order("e.created_at, e.id").Scan(&events).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行事件失败")
|
||||
return
|
||||
}
|
||||
knowledgeCache := map[string]*KnowledgeView{}
|
||||
for i := range events {
|
||||
if events[i].NodeType != "knowledge" {
|
||||
continue
|
||||
}
|
||||
cacheKey := string(events[i].NodeConfig)
|
||||
if cached, ok := knowledgeCache[cacheKey]; ok {
|
||||
events[i].Knowledge = cached
|
||||
continue
|
||||
}
|
||||
knowledge, err := loadKnowledgeView(h.db, model.SOPNode{Type: events[i].NodeType, Config: events[i].NodeConfig}, principal.TenantID)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "执行记录关联的知识卡版本不存在")
|
||||
return
|
||||
}
|
||||
events[i].Knowledge = &knowledge
|
||||
knowledgeCache[cacheKey] = &knowledge
|
||||
}
|
||||
feedback := make([]DetailFeedback, 0)
|
||||
if err := h.db.Table("sop_feedback f").Select("f.*, u.display_name AS user_name").Joins("JOIN users u ON u.id = f.user_id").Where("f.run_id = ? AND f.tenant_id = ?", id, principal.TenantID).Order("f.created_at").Scan(&feedback).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行反馈失败")
|
||||
|
||||
@@ -11,30 +11,43 @@ func matchCondition(raw json.RawMessage, answers map[string]interface{}) (bool,
|
||||
if len(raw) == 0 || string(raw) == "{}" || string(raw) == "null" {
|
||||
return true, nil
|
||||
}
|
||||
var rule map[string]interface{}
|
||||
var rule interface{}
|
||||
if err := json.Unmarshal(raw, &rule); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if all, ok := rule["all"].([]interface{}); ok {
|
||||
for _, item := range all {
|
||||
object, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return false, fmt.Errorf("invalid all condition")
|
||||
}
|
||||
matched, err := matchRule(object, answers)
|
||||
return matchConditionValue(rule, answers)
|
||||
}
|
||||
|
||||
func matchConditionValue(value interface{}, answers map[string]interface{}) (bool, error) {
|
||||
rule, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return false, fmt.Errorf("invalid condition")
|
||||
}
|
||||
all, hasAll := rule["all"]
|
||||
any, hasAny := rule["any"]
|
||||
if hasAll && hasAny {
|
||||
return false, fmt.Errorf("condition cannot contain both all and any")
|
||||
}
|
||||
if hasAll {
|
||||
items, ok := all.([]interface{})
|
||||
if !ok || len(items) == 0 {
|
||||
return false, fmt.Errorf("invalid all condition")
|
||||
}
|
||||
for _, item := range items {
|
||||
matched, err := matchConditionValue(item, answers)
|
||||
if err != nil || !matched {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
if any, ok := rule["any"].([]interface{}); ok {
|
||||
for _, item := range any {
|
||||
object, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
matched, err := matchRule(object, answers)
|
||||
if hasAny {
|
||||
items, ok := any.([]interface{})
|
||||
if !ok || len(items) == 0 {
|
||||
return false, fmt.Errorf("invalid any condition")
|
||||
}
|
||||
for _, item := range items {
|
||||
matched, err := matchConditionValue(item, answers)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package run
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
func TestMatchCondition(t *testing.T) {
|
||||
@@ -17,6 +20,7 @@ func TestMatchCondition(t *testing.T) {
|
||||
{name: "greater", rule: `{"field":"weight","operator":"greater_than","value":5}`, want: true},
|
||||
{name: "contains", rule: `{"field":"symptom","operator":"contains","value":"呕吐"}`, want: true},
|
||||
{name: "all", rule: `{"all":[{"field":"urgent","operator":"equals","value":true},{"field":"weight","operator":"greater_than","value":5}]}`, want: true},
|
||||
{name: "nested groups", rule: `{"all":[{"field":"urgent","operator":"equals","value":true},{"any":[{"field":"weight","operator":"less_than","value":3},{"field":"symptom","operator":"contains","value":"呕吐"}]}]}`, want: true},
|
||||
{name: "any false", rule: `{"any":[{"field":"urgent","operator":"equals","value":false},{"field":"weight","operator":"less_than","value":3}]}`, want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
@@ -31,3 +35,22 @@ func TestMatchCondition(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchConditionRejectsAmbiguousGroup(t *testing.T) {
|
||||
_, err := matchCondition(json.RawMessage(`{"all":[{"field":"urgent","operator":"equals","value":true}],"any":[{"field":"urgent","operator":"equals","value":true}]}`), map[string]interface{}{"urgent": true})
|
||||
if err == nil {
|
||||
t.Fatal("matchCondition() should reject a condition containing both all and any")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortEdgesAlwaysPlacesDefaultLast(t *testing.T) {
|
||||
edges := []model.SOPEdge{
|
||||
{TargetNodeKey: "default", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
|
||||
{TargetNodeKey: "second", Condition: datatypes.JSON([]byte(`{"field":"urgent","operator":"equals","value":false}`)), Priority: 20},
|
||||
{TargetNodeKey: "first", Condition: datatypes.JSON([]byte(`{"field":"urgent","operator":"equals","value":true}`)), Priority: 10},
|
||||
}
|
||||
sortEdges(edges)
|
||||
if edges[0].TargetNodeKey != "first" || edges[1].TargetNodeKey != "second" || edges[2].TargetNodeKey != "default" {
|
||||
t.Fatalf("sortEdges() order = %s, %s, %s", edges[0].TargetNodeKey, edges[1].TargetNodeKey, edges[2].TargetNodeKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,19 @@ type Handler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type listItem struct {
|
||||
model.SOPRun
|
||||
SOPName string `json:"sop_name"`
|
||||
ScenarioName string `json:"scenario_name"`
|
||||
Version int `json:"version"`
|
||||
OperatorName string `json:"operator_name"`
|
||||
}
|
||||
|
||||
type filterOption struct {
|
||||
Value uint64 `json:"value"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
func NewHandler(db *gorm.DB) *Handler {
|
||||
return &Handler{db: db}
|
||||
}
|
||||
@@ -101,12 +114,12 @@ func (h *Handler) Get(c *gin.Context) {
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
type row struct {
|
||||
model.SOPRun
|
||||
SOPName string `json:"sop_name"`
|
||||
}
|
||||
page, pageSize := pagination(c)
|
||||
query := scopeRuns(h.db.Table("sop_runs r").Joins("JOIN sops s ON s.id = r.sop_id"), p, "r")
|
||||
query := scopeRuns(h.db.Table("sop_runs r").
|
||||
Joins("JOIN sops s ON s.id = r.sop_id").
|
||||
Joins("JOIN scenarios sc ON sc.id = s.scenario_id").
|
||||
Joins("JOIN sop_versions sv ON sv.id = r.sop_version_id").
|
||||
Joins("JOIN users u ON u.id = r.operator_id"), p, "r")
|
||||
if status := c.Query("status"); status != "" {
|
||||
query = query.Where("r.status = ?", status)
|
||||
}
|
||||
@@ -116,6 +129,9 @@ func (h *Handler) List(c *gin.Context) {
|
||||
if sopID := c.Query("sop_id"); sopID != "" {
|
||||
query = query.Where("r.sop_id = ?", sopID)
|
||||
}
|
||||
if operatorID := c.Query("operator_id"); operatorID != "" {
|
||||
query = query.Where("r.operator_id = ?", operatorID)
|
||||
}
|
||||
if startedFrom := c.Query("started_from"); startedFrom != "" {
|
||||
query = query.Where("r.started_at >= ?", startedFrom)
|
||||
}
|
||||
@@ -127,14 +143,37 @@ func (h *Handler) List(c *gin.Context) {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行记录失败")
|
||||
return
|
||||
}
|
||||
items := make([]row, 0)
|
||||
if err := query.Select("r.*, s.name AS sop_name").Order("r.created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&items).Error; err != nil {
|
||||
items := make([]listItem, 0)
|
||||
if err := query.Select("r.*, s.name AS sop_name, sc.name AS scenario_name, sv.version, u.display_name AS operator_name").Order("r.created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&items).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行记录失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
func (h *Handler) Options(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
sops := make([]filterOption, 0)
|
||||
sopQuery := scopeRuns(h.db.Table("sop_runs r"), p, "r").
|
||||
Select("DISTINCT s.id AS value, s.name AS label").
|
||||
Joins("JOIN sops s ON s.id = r.sop_id").
|
||||
Order("s.name, s.id")
|
||||
if err := sopQuery.Scan(&sops).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询 SOP 筛选项失败")
|
||||
return
|
||||
}
|
||||
operators := make([]filterOption, 0)
|
||||
operatorQuery := scopeRuns(h.db.Table("sop_runs r"), p, "r").
|
||||
Select("DISTINCT u.id AS value, u.display_name AS label").
|
||||
Joins("JOIN users u ON u.id = r.operator_id").
|
||||
Order("u.display_name, u.id")
|
||||
if err := operatorQuery.Scan(&operators).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行人筛选项失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"sops": sops, "operators": operators})
|
||||
}
|
||||
|
||||
func (h *Handler) Answer(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
id, ok := runID(c)
|
||||
@@ -185,7 +224,7 @@ func (h *Handler) Answer(c *gin.Context) {
|
||||
if err := tx.Where("sop_version_id = ? AND source_node_key = ?", updated.SOPVersionID, updated.CurrentNodeKey).Order("priority, id").Find(&edges).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
sort.SliceStable(edges, func(i, j int) bool { return edges[i].Priority < edges[j].Priority })
|
||||
sortEdges(edges)
|
||||
nextKey := ""
|
||||
for _, edge := range edges {
|
||||
matched, matchErr := matchCondition(json.RawMessage(edge.Condition), answers)
|
||||
@@ -233,6 +272,29 @@ func (h *Handler) Answer(c *gin.Context) {
|
||||
h.respondRun(c, updated)
|
||||
}
|
||||
|
||||
func sortEdges(edges []model.SOPEdge) {
|
||||
sort.SliceStable(edges, func(i, j int) bool {
|
||||
leftDefault := defaultCondition(edges[i].Condition)
|
||||
rightDefault := defaultCondition(edges[j].Condition)
|
||||
if leftDefault != rightDefault {
|
||||
return !leftDefault
|
||||
}
|
||||
return edges[i].Priority < edges[j].Priority
|
||||
})
|
||||
}
|
||||
|
||||
func defaultCondition(raw []byte) bool {
|
||||
if len(raw) == 0 {
|
||||
return true
|
||||
}
|
||||
var value interface{}
|
||||
if err := json.Unmarshal(raw, &value); err != nil || value == nil {
|
||||
return err == nil && value == nil
|
||||
}
|
||||
object, ok := value.(map[string]interface{})
|
||||
return ok && len(object) == 0
|
||||
}
|
||||
|
||||
func (h *Handler) Finish(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
id, ok := runID(c)
|
||||
@@ -240,29 +302,51 @@ func (h *Handler) Finish(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Result string `json:"result" binding:"required,max=64"`
|
||||
Result string `json:"result" binding:"required,oneof=manual"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择执行结果")
|
||||
return
|
||||
}
|
||||
var run model.SOPRun
|
||||
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&run).Error; err != nil || !canOperateRun(p, run) {
|
||||
runCompletedErr := errors.New("执行记录已经结束")
|
||||
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(&run).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !canOperateRun(p, run) {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
if run.Status != "running" {
|
||||
return runCompletedErr
|
||||
}
|
||||
now := time.Now()
|
||||
payload, _ := json.Marshal(input)
|
||||
if err := tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: id, NodeKey: run.CurrentNodeKey, Action: "finish", Payload: datatypes.JSON(payload)}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&run).Updates(map[string]interface{}{"status": "completed", "result": input.Result, "completed_at": &now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
run.Status = "completed"
|
||||
run.Result = input.Result
|
||||
run.CompletedAt = &now
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
|
||||
return
|
||||
}
|
||||
if run.Status != "running" {
|
||||
response.Error(c, http.StatusConflict, "RUN_COMPLETED", "执行记录已经结束")
|
||||
if errors.Is(err, runCompletedErr) {
|
||||
response.Error(c, http.StatusConflict, "RUN_COMPLETED", err.Error())
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
result := h.db.Model(&model.SOPRun{}).Where("id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "running").Updates(map[string]interface{}{"status": "completed", "result": input.Result, "completed_at": &now})
|
||||
if result.Error != nil || result.RowsAffected == 0 {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "FINISH_FAILED", "结束执行失败")
|
||||
return
|
||||
}
|
||||
_ = audit.Record(h.db, p, "finish", "sop_run", id, input)
|
||||
h.Get(c)
|
||||
h.respondRun(c, run)
|
||||
}
|
||||
|
||||
func (h *Handler) Feedback(c *gin.Context) {
|
||||
@@ -299,13 +383,21 @@ func (h *Handler) Feedback(c *gin.Context) {
|
||||
|
||||
func (h *Handler) respondRun(c *gin.Context, item model.SOPRun) {
|
||||
var node model.SOPNode
|
||||
if err := h.db.Where("sop_version_id = ? AND node_key = ?", item.SOPVersionID, item.CurrentNodeKey).First(&node).Error; err != nil {
|
||||
if err := h.db.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", item.SOPVersionID, item.CurrentNodeKey, item.TenantID).First(&node).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
|
||||
return
|
||||
}
|
||||
nodeView, err := h.nodeView(h.db, node, item.TenantID)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "当前节点关联的知识卡版本不存在")
|
||||
return
|
||||
}
|
||||
fields := make([]model.ScenarioField, 0)
|
||||
h.db.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", item.SOPID, item.TenantID).Order("sf.sort_order, sf.id").Find(&fields)
|
||||
response.OK(c, gin.H{"run": item, "node": node, "fields": fields})
|
||||
if err := h.db.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", item.SOPID, item.TenantID).Order("sf.sort_order, sf.id").Find(&fields).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景字段失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"run": item, "node": nodeView, "fields": fields})
|
||||
}
|
||||
|
||||
func runID(c *gin.Context) (uint64, bool) {
|
||||
|
||||
76
internal/run/knowledge.go
Normal file
76
internal/run/knowledge.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type KnowledgeView struct {
|
||||
CardID uint64 `json:"card_id"`
|
||||
CardVersionID uint64 `json:"card_version_id"`
|
||||
Version int `json:"version"`
|
||||
Title string `json:"title"`
|
||||
StandardCopy string `json:"standard_copy"`
|
||||
ForbiddenCopy string `json:"forbidden_copy"`
|
||||
RiskNote string `json:"risk_note"`
|
||||
}
|
||||
|
||||
type NodeView struct {
|
||||
model.SOPNode
|
||||
Knowledge *KnowledgeView `json:"knowledge,omitempty"`
|
||||
}
|
||||
|
||||
type knowledgeNodeConfig struct {
|
||||
KnowledgeCardID uint64 `json:"knowledge_card_id"`
|
||||
KnowledgeCardVersionID uint64 `json:"knowledge_card_version_id"`
|
||||
}
|
||||
|
||||
func (h *Handler) nodeView(db *gorm.DB, node model.SOPNode, tenantID uint64) (NodeView, error) {
|
||||
view := NodeView{SOPNode: node}
|
||||
if node.Type != "knowledge" {
|
||||
return view, nil
|
||||
}
|
||||
knowledge, err := loadKnowledgeView(db, node, tenantID)
|
||||
if err != nil {
|
||||
return view, err
|
||||
}
|
||||
view.Knowledge = &knowledge
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func loadKnowledgeView(db *gorm.DB, node model.SOPNode, tenantID uint64) (KnowledgeView, error) {
|
||||
var config knowledgeNodeConfig
|
||||
if err := json.Unmarshal(node.Config, &config); err != nil {
|
||||
return KnowledgeView{}, err
|
||||
}
|
||||
type row struct {
|
||||
model.KnowledgeCardVersion
|
||||
Title string `gorm:"column:title"`
|
||||
}
|
||||
var version row
|
||||
query := db.Table("knowledge_card_versions kv").Select("kv.*, kc.title").
|
||||
Joins("JOIN knowledge_cards kc ON kc.id = kv.knowledge_card_id").
|
||||
Where("kv.tenant_id = ? AND kc.tenant_id = ? AND kv.knowledge_card_id = ?", tenantID, tenantID, config.KnowledgeCardID)
|
||||
if config.KnowledgeCardVersionID != 0 {
|
||||
query = query.Where("kv.id = ?", config.KnowledgeCardVersionID)
|
||||
} else {
|
||||
query = query.Where("kv.status = ?", "published").Order("kv.version DESC")
|
||||
}
|
||||
if err := query.First(&version).Error; err != nil {
|
||||
return KnowledgeView{}, err
|
||||
}
|
||||
var content struct {
|
||||
StandardCopy string `json:"standard_copy"`
|
||||
ForbiddenCopy string `json:"forbidden_copy"`
|
||||
RiskNote string `json:"risk_note"`
|
||||
}
|
||||
if err := json.Unmarshal(version.Content, &content); err != nil {
|
||||
return KnowledgeView{}, err
|
||||
}
|
||||
return KnowledgeView{
|
||||
CardID: version.KnowledgeCardID, CardVersionID: version.ID, Version: version.Version, Title: version.Title,
|
||||
StandardCopy: content.StandardCopy, ForbiddenCopy: content.ForbiddenCopy, RiskNote: content.RiskNote,
|
||||
}, nil
|
||||
}
|
||||
@@ -13,6 +13,7 @@ type answerNodeConfig struct {
|
||||
FieldKey string `json:"field_key"`
|
||||
FieldKeys []string `json:"field_keys"`
|
||||
Required bool `json:"required"`
|
||||
Options []string `json:"options"`
|
||||
}
|
||||
|
||||
func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answers map[string]interface{}) error {
|
||||
@@ -68,10 +69,22 @@ func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answe
|
||||
if err := validateFieldValue(field, value); err != nil {
|
||||
return err
|
||||
}
|
||||
if node.Type == "choice" && !stringAllowed(config.Options, fmt.Sprint(value)) {
|
||||
return fmt.Errorf("%s的选项不正确", field.FieldName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringAllowed(options []string, selected string) bool {
|
||||
for _, option := range options {
|
||||
if option == selected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateFieldValue(field model.ScenarioField, value interface{}) error {
|
||||
switch field.FieldType {
|
||||
case "text", "textarea":
|
||||
|
||||
@@ -46,3 +46,15 @@ func TestValidateNodeAnswersRejectsAnswersForMessage(t *testing.T) {
|
||||
t.Fatalf("validateNodeAnswers() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNodeAnswersChoiceOptions(t *testing.T) {
|
||||
fields := []model.ScenarioField{{FieldKey: "intent", FieldName: "客户意向", FieldType: "text"}}
|
||||
node := model.SOPNode{Type: "choice", Config: datatypes.JSON([]byte(`{"field_key":"intent","required":true,"options":["继续了解","暂不考虑"]}`))}
|
||||
if err := validateNodeAnswers(node, fields, map[string]interface{}{"intent": "继续了解"}); err != nil {
|
||||
t.Fatalf("validateNodeAnswers() error = %v", err)
|
||||
}
|
||||
err := validateNodeAnswers(node, fields, map[string]interface{}{"intent": "绕过配置的值"})
|
||||
if err == nil || !strings.Contains(err.Error(), "选项不正确") {
|
||||
t.Fatalf("validateNodeAnswers() error = %v, want invalid choice option", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,13 @@ func TestValidateFieldInput(t *testing.T) {
|
||||
{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},
|
||||
{name: "number range", input: fieldInput{FieldKey: "pet_age", FieldType: "number", Validation: json.RawMessage(`{"min":0,"max":50}`)}},
|
||||
{name: "reversed number range", input: fieldInput{FieldKey: "pet_age", FieldType: "number", Validation: json.RawMessage(`{"min":50,"max":0}`)}, wantErr: true},
|
||||
{name: "number rule on text", input: fieldInput{FieldKey: "pet_name", FieldType: "text", Validation: json.RawMessage(`{"min":1}`)}, wantErr: true},
|
||||
{name: "text range", input: fieldInput{FieldKey: "pet_name", FieldType: "text", Validation: json.RawMessage(`{"min_length":1,"max_length":50}`)}},
|
||||
{name: "fractional text length", input: fieldInput{FieldKey: "pet_name", FieldType: "text", Validation: json.RawMessage(`{"min_length":1.5}`)}, wantErr: true},
|
||||
{name: "negative text length", input: fieldInput{FieldKey: "pet_name", FieldType: "text", Validation: json.RawMessage(`{"min_length":-1}`)}, wantErr: true},
|
||||
{name: "duplicate options", input: fieldInput{FieldKey: "pet_type", FieldType: "select", Options: json.RawMessage(`["猫","猫"]`)}, wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
|
||||
@@ -278,10 +278,16 @@ func validateFieldInput(input fieldInput) error {
|
||||
if err := json.Unmarshal(input.Options, &options); err != nil || options == nil {
|
||||
return errors.New("字段选项必须是文本数组")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, option := range options {
|
||||
if strings.TrimSpace(option) == "" {
|
||||
return errors.New("字段选项不能为空")
|
||||
}
|
||||
normalized := strings.TrimSpace(option)
|
||||
if seen[normalized] {
|
||||
return errors.New("字段选项不能重复")
|
||||
}
|
||||
seen[normalized] = true
|
||||
}
|
||||
if (input.FieldType == "select" || input.FieldType == "multiselect") && len(options) == 0 {
|
||||
return errors.New("单选或多选字段至少需要一个选项")
|
||||
@@ -290,14 +296,77 @@ func validateFieldInput(input fieldInput) error {
|
||||
return errors.New("单选或多选字段至少需要一个选项")
|
||||
}
|
||||
if len(input.Validation) > 0 {
|
||||
var validation map[string]interface{}
|
||||
var validation map[string]json.RawMessage
|
||||
if err := json.Unmarshal(input.Validation, &validation); err != nil || validation == nil {
|
||||
return errors.New("字段校验规则必须是 JSON 对象")
|
||||
}
|
||||
if err := validateFieldRules(input.FieldType, validation); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFieldRules(fieldType string, validation map[string]json.RawMessage) error {
|
||||
allowed := map[string]bool{}
|
||||
switch fieldType {
|
||||
case "number":
|
||||
allowed["min"], allowed["max"] = true, true
|
||||
case "text", "textarea":
|
||||
allowed["min_length"], allowed["max_length"] = true, true
|
||||
}
|
||||
for key := range validation {
|
||||
if !allowed[key] {
|
||||
return errors.New("当前字段类型不支持校验规则 " + key)
|
||||
}
|
||||
}
|
||||
if fieldType == "number" {
|
||||
min, hasMin, err := jsonNumber(validation["min"])
|
||||
if err != nil {
|
||||
return errors.New("最小值必须是数字")
|
||||
}
|
||||
max, hasMax, err := jsonNumber(validation["max"])
|
||||
if err != nil {
|
||||
return errors.New("最大值必须是数字")
|
||||
}
|
||||
if hasMin && hasMax && min > max {
|
||||
return errors.New("最小值不能大于最大值")
|
||||
}
|
||||
}
|
||||
if fieldType == "text" || fieldType == "textarea" {
|
||||
min, hasMin, err := jsonInteger(validation["min_length"])
|
||||
if err != nil || (hasMin && min < 0) {
|
||||
return errors.New("最少字符必须是非负整数")
|
||||
}
|
||||
max, hasMax, err := jsonInteger(validation["max_length"])
|
||||
if err != nil || (hasMax && max < 1) {
|
||||
return errors.New("最多字符必须是正整数")
|
||||
}
|
||||
if hasMin && hasMax && min > max {
|
||||
return errors.New("最少字符不能大于最多字符")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func jsonNumber(raw json.RawMessage) (float64, bool, error) {
|
||||
if len(raw) == 0 {
|
||||
return 0, false, nil
|
||||
}
|
||||
var value float64
|
||||
err := json.Unmarshal(raw, &value)
|
||||
return value, true, err
|
||||
}
|
||||
|
||||
func jsonInteger(raw json.RawMessage) (int, bool, error) {
|
||||
if len(raw) == 0 {
|
||||
return 0, false, nil
|
||||
}
|
||||
var value int
|
||||
err := json.Unmarshal(raw, &value)
|
||||
return value, true, err
|
||||
}
|
||||
|
||||
func idParam(c *gin.Context, key string) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(c.Param(key), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
|
||||
@@ -355,6 +355,9 @@ func (h *Handler) Publish(c *gin.Context) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
58
internal/sop/knowledge_snapshot.go
Normal file
58
internal/sop/knowledge_snapshot.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package sop
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type knowledgeNodeConfig struct {
|
||||
KnowledgeCardID uint64 `json:"knowledge_card_id"`
|
||||
KnowledgeCardVersionID uint64 `json:"knowledge_card_version_id"`
|
||||
}
|
||||
|
||||
// BindKnowledgeVersions freezes the current published knowledge-card version
|
||||
// into every knowledge node before the SOP version becomes immutable.
|
||||
func BindKnowledgeVersions(tx *gorm.DB, tenantID, sopVersionID uint64) error {
|
||||
nodes := make([]model.SOPNode, 0)
|
||||
if err := tx.Where("tenant_id = ? AND sop_version_id = ? AND type = ?", tenantID, sopVersionID, "knowledge").Find(&nodes).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, node := range nodes {
|
||||
var config knowledgeNodeConfig
|
||||
if err := json.Unmarshal(node.Config, &config); err != nil || config.KnowledgeCardID == 0 {
|
||||
return fmt.Errorf("knowledge node %s has invalid configuration", node.NodeKey)
|
||||
}
|
||||
var version model.KnowledgeCardVersion
|
||||
err := tx.Table("knowledge_card_versions kv").Select("kv.*").
|
||||
Joins("JOIN knowledge_cards kc ON kc.id = kv.knowledge_card_id").
|
||||
Where("kv.tenant_id = ? AND kv.knowledge_card_id = ? AND kv.status = ? AND kc.tenant_id = ? AND kc.status = ?", tenantID, config.KnowledgeCardID, "published", tenantID, "published").
|
||||
Order("kv.version DESC").First(&version).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("knowledge node %s has no published card version: %w", node.NodeKey, err)
|
||||
}
|
||||
updated, err := withKnowledgeVersion(node.Config, version.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update knowledge node %s: %w", node.NodeKey, err)
|
||||
}
|
||||
if err := tx.Model(&model.SOPNode{}).Where("id = ? AND tenant_id = ? AND sop_version_id = ?", node.ID, tenantID, sopVersionID).Update("config", updated).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func withKnowledgeVersion(config datatypes.JSON, versionID uint64) (datatypes.JSON, error) {
|
||||
value := map[string]interface{}{}
|
||||
if len(config) > 0 {
|
||||
if err := json.Unmarshal(config, &value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
value["knowledge_card_version_id"] = versionID
|
||||
encoded, err := json.Marshal(value)
|
||||
return datatypes.JSON(encoded), err
|
||||
}
|
||||
22
internal/sop/knowledge_snapshot_test.go
Normal file
22
internal/sop/knowledge_snapshot_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package sop
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
func TestWithKnowledgeVersionPreservesConfiguration(t *testing.T) {
|
||||
updated, err := withKnowledgeVersion(datatypes.JSON([]byte(`{"knowledge_card_id":12,"display":"full"}`)), 34)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var value map[string]interface{}
|
||||
if err := json.Unmarshal(updated, &value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value["knowledge_card_id"] != float64(12) || value["knowledge_card_version_id"] != float64(34) || value["display"] != "full" {
|
||||
t.Fatalf("unexpected knowledge config: %#v", value)
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,8 @@ func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOP
|
||||
}
|
||||
if _, exists := nodeMap[startNodeKey]; !exists && startNodeKey != "" {
|
||||
problems = append(problems, "开始节点不存在")
|
||||
} else if startNodeKey != "" && nodeMap[startNodeKey].Type != "start" {
|
||||
problems = append(problems, "开始节点标识必须指向 start 类型节点")
|
||||
}
|
||||
|
||||
adjacency := make(map[string][]string)
|
||||
@@ -77,6 +79,9 @@ func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOP
|
||||
if node.Type != "finish" && node.Type != "escalate" && outgoing[node.NodeKey] == 0 {
|
||||
problems = append(problems, fmt.Sprintf("节点“%s”没有下一步", node.Title))
|
||||
}
|
||||
if (node.Type == "finish" || node.Type == "escalate") && outgoing[node.NodeKey] > 0 {
|
||||
problems = append(problems, fmt.Sprintf("结束节点“%s”不能配置下一步", node.Title))
|
||||
}
|
||||
}
|
||||
|
||||
visited := map[string]bool{}
|
||||
@@ -147,6 +152,9 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode
|
||||
problems = append(problems, fmt.Sprintf("节点“%s”引用的字段 %s 不存在", node.Title, fieldKey))
|
||||
} else {
|
||||
collected[fieldKey] = true
|
||||
if node.Type == "choice" {
|
||||
validateChoiceOptions(node, config, fieldMap[fieldKey], &problems)
|
||||
}
|
||||
}
|
||||
case "form":
|
||||
keys, _ := config["field_keys"].([]interface{})
|
||||
@@ -218,12 +226,57 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode
|
||||
return problems
|
||||
}
|
||||
|
||||
func validateChoiceOptions(node model.SOPNode, config map[string]interface{}, field model.ScenarioField, problems *[]string) {
|
||||
values, ok := config["options"].([]interface{})
|
||||
if !ok || len(values) == 0 {
|
||||
*problems = append(*problems, fmt.Sprintf("选择节点“%s”没有配置选项", node.Title))
|
||||
return
|
||||
}
|
||||
if field.FieldType != "text" && field.FieldType != "select" {
|
||||
*problems = append(*problems, fmt.Sprintf("选择节点“%s”只能写入文本或单选字段", node.Title))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
option, ok := value.(string)
|
||||
if !ok || option == "" {
|
||||
*problems = append(*problems, fmt.Sprintf("选择节点“%s”包含无效选项", node.Title))
|
||||
continue
|
||||
}
|
||||
if seen[option] {
|
||||
*problems = append(*problems, fmt.Sprintf("选择节点“%s”包含重复选项", node.Title))
|
||||
}
|
||||
seen[option] = true
|
||||
if field.FieldType == "select" && !jsonStringArrayContains(field.Options, option) {
|
||||
*problems = append(*problems, fmt.Sprintf("选择节点“%s”的选项“%s”不在字段选项中", node.Title, option))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func jsonStringArrayContains(raw []byte, expected string) bool {
|
||||
var values []string
|
||||
if json.Unmarshal(raw, &values) != nil {
|
||||
return false
|
||||
}
|
||||
for _, value := range values {
|
||||
if value == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateCondition(value interface{}, fields map[string]model.ScenarioField, label string, problems *[]string) {
|
||||
rule, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
*problems = append(*problems, label+"的条件结构不正确")
|
||||
return
|
||||
}
|
||||
_, hasAll := rule["all"]
|
||||
_, hasAny := rule["any"]
|
||||
if hasAll && hasAny {
|
||||
*problems = append(*problems, label+"不能同时包含全部满足和任一满足")
|
||||
return
|
||||
}
|
||||
for _, group := range []string{"all", "any"} {
|
||||
if raw, exists := rule[group]; exists {
|
||||
items, ok := raw.([]interface{})
|
||||
|
||||
@@ -42,6 +42,12 @@ func TestValidateForPublishBusinessRules(t *testing.T) {
|
||||
{name: "high risk without escalation", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
||||
(*edges)[1].TargetNodeKey = "finish"
|
||||
}, want: "没有明确的转人工或转诊路径"},
|
||||
{name: "terminal node has outgoing edge", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
||||
*edges = append(*edges, model.SOPEdge{SourceNodeKey: "finish", TargetNodeKey: "screen", Condition: jsonData(`{}`)})
|
||||
}, want: "不能配置下一步"},
|
||||
{name: "ambiguous condition group", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
||||
(*edges)[1].Condition = jsonData(`{"all":[{"field":"emergency","operator":"equals","value":true}],"any":[{"field":"emergency","operator":"equals","value":true}]}`)
|
||||
}, want: "不能同时包含全部满足和任一满足"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -55,6 +61,29 @@ func TestValidateForPublishBusinessRules(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGraphRequiresConfiguredStartType(t *testing.T) {
|
||||
nodes, edges, _ := validPublishGraph()
|
||||
if problems := ValidateGraph("screen", nodes, edges); !containsProblem(problems, "必须指向 start 类型节点") {
|
||||
t.Fatalf("ValidateGraph() problems = %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateForPublishChoiceConfiguration(t *testing.T) {
|
||||
nodes := []model.SOPNode{
|
||||
{NodeKey: "start", Type: "start", Title: "开始", Config: jsonData(`{}`)},
|
||||
{NodeKey: "choice", Type: "choice", Title: "客户意向", Config: jsonData(`{"field_key":"intent","options":["继续","无效"]}`)},
|
||||
{NodeKey: "finish", Type: "finish", Title: "结束", Config: jsonData(`{}`)},
|
||||
}
|
||||
edges := []model.SOPEdge{
|
||||
{SourceNodeKey: "start", TargetNodeKey: "choice", Condition: jsonData(`{}`)},
|
||||
{SourceNodeKey: "choice", TargetNodeKey: "finish", Condition: jsonData(`{}`)},
|
||||
}
|
||||
context := ValidationContext{Fields: []model.ScenarioField{{FieldKey: "intent", FieldName: "客户意向", FieldType: "select", Options: jsonData(`["继续","暂不考虑"]`)}}}
|
||||
if problems := ValidateForPublish("start", nodes, edges, context); !containsProblem(problems, "不在字段选项中") {
|
||||
t.Fatalf("ValidateForPublish() problems = %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGraphRejectsNonTerminatingCycle(t *testing.T) {
|
||||
nodes := []model.SOPNode{
|
||||
{NodeKey: "start", Type: "start", Title: "开始", Config: jsonData(`{}`)},
|
||||
|
||||
Reference in New Issue
Block a user