feat: complete SOP execution workflow

This commit is contained in:
Eric 1549169735@qq.com
2026-08-09 00:29:26 +08:00
parent e32025d2ca
commit c5ab886b70
63 changed files with 1229 additions and 215 deletions

View File

@@ -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", "查询执行反馈失败")

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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
View 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
}

View File

@@ -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":

View File

@@ -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)
}
}