package run import ( "encoding/json" "errors" "net/http" "sort" "strconv" "time" "git.iwork-ai.com/xdc/iqudo-top1/internal/access" "git.iwork-ai.com/xdc/iqudo-top1/internal/audit" "git.iwork-ai.com/xdc/iqudo-top1/internal/auth" "git.iwork-ai.com/xdc/iqudo-top1/internal/model" "git.iwork-ai.com/xdc/iqudo-top1/internal/response" "git.iwork-ai.com/xdc/iqudo-top1/internal/resultcontract" "github.com/gin-gonic/gin" "github.com/google/uuid" "gorm.io/datatypes" "gorm.io/gorm" "gorm.io/gorm/clause" ) type Handler struct { db *gorm.DB } type listItem struct { model.SOPRun SOPName string `json:"sop_name"` ScenarioName string `json:"scenario_name"` 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} } func (h *Handler) AvailableSOPs(c *gin.Context) { p, _ := auth.PrincipalFromContext(c) type item struct { ID uint64 `json:"id"` Name string `json:"name"` Description string `json:"description"` ScenarioID uint64 `json:"scenario_id"` ScenarioName string `json:"scenario_name"` } items := make([]item, 0) query := h.db.Table("sops s").Select("s.id, s.name, s.description, s.scenario_id, sc.name AS scenario_name").Joins("JOIN scenarios sc ON sc.id = s.scenario_id") query = access.ScopeScenarios(query, p, "sc") err := query.Where("s.tenant_id = ? AND s.status = ?", p.TenantID, "published").Order("s.updated_at DESC").Scan(&items).Error if err != nil { response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询可用 SOP 失败") return } response.OK(c, gin.H{"items": items, "total": len(items)}) } func (h *Handler) Start(c *gin.Context) { p, _ := auth.PrincipalFromContext(c) var input struct { SOPID uint64 `json:"sop_id" binding:"required"` Input map[string]interface{} `json:"input"` ExternalRef string `json:"external_ref"` InitialValues map[string]interface{} `json:"initial_values"` InitialAnswers map[string]interface{} `json:"initial_answers"` } if err := c.ShouldBindJSON(&input); err != nil { response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择要执行的 SOP") return } if !access.CanViewSOP(h.db, p, input.SOPID) { response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的 SOP") return } if err := validateExternalRef(input.ExternalRef); err != nil { response.Error(c, http.StatusBadRequest, "INVALID_EXTERNAL_REF", err.Error()) return } if input.ExternalRef != "" { var existing model.SOPRun if err := h.db.Where("tenant_id = ? AND sop_id = ? AND external_ref = ?", p.TenantID, input.SOPID, input.ExternalRef).First(&existing).Error; err == nil { h.respondRun(c, existing) return } } var sop model.SOP if err := h.db.Where("id = ? AND tenant_id = ? AND status = ?", input.SOPID, p.TenantID, "published").First(&sop).Error; err != nil { response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的 SOP") return } fields := make([]model.ScenarioField, 0) 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 = ?", input.SOPID, p.TenantID).Order("sf.sort_order, sf.id").Find(&fields).Error; err != nil { response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景字段失败") return } var sopItem = sop if input.Input == nil { input.Input = map[string]interface{}{} } if input.InitialValues == nil { input.InitialValues = input.InitialAnswers } if input.InitialValues == nil { input.InitialValues = map[string]interface{}{} } normalizedInput := mergeValues(mapScenarioInput(fields, input.Input), input.InitialValues) if err := validateInitialAnswers(fields, normalizedInput); err != nil { response.Error(c, http.StatusUnprocessableEntity, "INVALID_INITIAL_ANSWERS", err.Error()) return } initialAnswers, marshalErr := json.Marshal(normalizedInput) if marshalErr != nil { response.Error(c, http.StatusBadRequest, "INVALID_INITIAL_ANSWERS", "传入字段格式不正确") return } derived, matchedRules, err := deriveForScenario(h.db, p.TenantID, sopItem.ScenarioID, normalizedInput) if err != nil { response.Error(c, http.StatusUnprocessableEntity, "RULE_EVALUATION_FAILED", err.Error()) return } derivedRaw, _ := json.Marshal(derived) knowledgeRaw, err := snapshotKnowledge(h.db, p.TenantID, sopItem.ScenarioID) if err != nil { response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_SNAPSHOT_FAILED", "生成知识快照失败") return } externalRef := input.ExternalRef if externalRef == "" { externalRef = "run-" + uuid.NewString() } run := model.SOPRun{TenantID: p.TenantID, SOPID: input.SOPID, OperatorID: p.UserID, ExternalRef: externalRef, CurrentNodeKey: sop.StartNodeKey, Status: "running", Answers: datatypes.JSON(initialAnswers), Input: datatypes.JSON(initialAnswers), Derived: datatypes.JSON(derivedRaw), Outputs: datatypes.JSON([]byte(`[]`)), KnowledgeSnapshot: datatypes.JSON(knowledgeRaw), Result: "", StartedAt: time.Now()} err = h.db.Transaction(func(tx *gorm.DB) error { if err := tx.Create(&run).Error; err != nil { return err } payload, err := json.Marshal(gin.H{"source": "scenario_input", "mapped_field_keys": sortedKeys(normalizedInput), "matched_rule_keys": matchedRules}) if err != nil { return err } return tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "start", Payload: datatypes.JSON(payload)}).Error }) if err != nil { response.Error(c, http.StatusInternalServerError, "START_FAILED", "启动 SOP 失败") return } _ = audit.Record(h.db, p, "start", "sop_run", run.ID, gin.H{"sop_id": input.SOPID}) h.respondRun(c, run) } func (h *Handler) Get(c *gin.Context) { p, _ := auth.PrincipalFromContext(c) id, ok := runID(c) if !ok { return } var item model.SOPRun if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&item).Error; err != nil { response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在") return } if !canViewRun(p, item) { response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在") return } h.respondRun(c, item) } func (h *Handler) List(c *gin.Context) { p, _ := auth.PrincipalFromContext(c) page, pageSize := pagination(c) 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 users u ON u.id = r.operator_id"), p, "r") if status := c.Query("status"); status != "" { query = query.Where("r.status = ?", status) } if result := c.Query("result"); result != "" { query = query.Where("r.result = ?", result) } 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) } if startedTo := c.Query("started_to"); startedTo != "" { query = query.Where("r.started_at <= ?", startedTo) } var total int64 if err := query.Count(&total).Error; err != nil { response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行记录失败") return } items := make([]listItem, 0) // The run payload contains several large JSON snapshots. The history list only // needs scalar metadata; excluding blobs keeps MySQL's sort buffer bounded. listColumns := "r.id, r.created_at, r.updated_at, r.tenant_id, r.sop_id, r.operator_id, r.external_ref, r.current_node_key, r.status, r.result, r.started_at, r.completed_at, s.name AS sop_name, sc.name AS scenario_name, u.display_name AS operator_name" if err := query.Select(listColumns).Order("r.created_at DESC, r.id 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) if !ok { return } var input struct { NodeKey string `json:"node_key"` Answers map[string]interface{} `json:"answers"` } if err := c.ShouldBindJSON(&input); err != nil { response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "回答格式不正确") return } var updated model.SOPRun 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(&updated).Error; err != nil { return err } if updated.Status != "running" { return errors.New("run is not active") } if !canOperateRun(p, updated) { return gorm.ErrRecordNotFound } if input.NodeKey != "" && input.NodeKey != updated.CurrentNodeKey { return errors.New("当前步骤已经变化,请刷新后重试") } var currentNode model.SOPNode if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", updated.SOPID, updated.CurrentNodeKey, p.TenantID).First(¤tNode).Error; err != nil { return err } fields := make([]model.ScenarioField, 0) if err := tx.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", updated.SOPID, p.TenantID).Order("sf.sort_order, sf.id").Find(&fields).Error; err != nil { return err } if err := validateNodeAnswers(currentNode, fields, input.Answers); err != nil { return err } answers := map[string]interface{}{} if len(updated.Answers) > 0 { _ = json.Unmarshal(updated.Answers, &answers) } for key, value := range input.Answers { answers[key] = value } if err := validateKnowledgeSelections(currentNode, updated, answers); err != nil { return err } var edges []model.SOPEdge if err := tx.Where("sop_id = ? AND source_node_key = ? AND tenant_id = ?", updated.SOPID, updated.CurrentNodeKey, p.TenantID).Order("priority, id").Find(&edges).Error; err != nil { return err } sortEdges(edges) nextKey := "" for _, edge := range edges { matched, matchErr := matchCondition(json.RawMessage(edge.Condition), answers) if matchErr != nil { return matchErr } if matched { nextKey = edge.TargetNodeKey break } } if nextKey == "" { return errors.New("没有满足条件的下一节点") } answerBytes, _ := json.Marshal(answers) payload, _ := json.Marshal(input) if err := tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: updated.ID, NodeKey: updated.CurrentNodeKey, Action: "answer", Payload: datatypes.JSON(payload)}).Error; err != nil { return err } var next model.SOPNode if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", updated.SOPID, nextKey, p.TenantID).First(&next).Error; err != nil { return err } updates := map[string]interface{}{"current_node_key": nextKey, "answers": datatypes.JSON(answerBytes)} if next.Type == "finish" || next.Type == "escalate" { now := time.Now() updates["status"] = "completed" updates["completed_at"] = &now updates["result"] = next.Type updated.Status = "completed" updated.CompletedAt = &now updated.Result = next.Type } if err := tx.Model(&updated).Updates(updates).Error; err != nil { return err } updated.CurrentNodeKey = nextKey updated.Answers = datatypes.JSON(answerBytes) return tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: updated.ID, NodeKey: nextKey, Action: "enter", Payload: datatypes.JSON([]byte(`{}`))}).Error }) if err != nil { response.Error(c, http.StatusUnprocessableEntity, "ADVANCE_FAILED", err.Error()) return } 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 sortedKeys(values map[string]interface{}) []string { keys := make([]string, 0, len(values)) for key := range values { keys = append(keys, key) } sort.Strings(keys) return keys } 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) if !ok { return } var input struct { Result string `json:"result"` FinalResult json.RawMessage `json:"final_result"` } if err := c.ShouldBindJSON(&input); err != nil { response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "执行结果格式不正确") return } var run model.SOPRun 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" && run.Status != "completed" { return runCompletedErr } finalResult, err := parseFinalResult(input.FinalResult) if err != nil { return err } var scenario model.Scenario if err := tx.Table("scenarios sc").Select("sc.*").Joins("JOIN sops s ON s.scenario_id = sc.id").Where("s.id = ? AND sc.tenant_id = ?", run.SOPID, run.TenantID).First(&scenario).Error; err != nil { return err } schema, err := resultcontract.ParseAndValidate(scenario.ResultSchema) if err != nil { return err } if err := resultcontract.ValidateResult(schema, finalResult); err != nil { return err } finalRaw, _ := json.Marshal(finalResult) if input.Result == "" { input.Result = run.Result if input.Result == "" { input.Result = "manual" } } now := time.Now() completedAt := run.CompletedAt if completedAt == nil { completedAt = &now } payload, _ := json.Marshal(input) action := "finish" if run.Status == "completed" { action = "final_result" } if err := tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: id, NodeKey: run.CurrentNodeKey, Action: action, Payload: datatypes.JSON(payload)}).Error; err != nil { return err } if err := tx.Model(&run).Updates(map[string]interface{}{"status": "completed", "result": input.Result, "final_result": datatypes.JSON(finalRaw), "completed_at": completedAt}).Error; err != nil { return err } run.Status = "completed" run.Result = input.Result run.FinalResult = datatypes.JSON(finalRaw) run.CompletedAt = completedAt return nil }) if errors.Is(err, gorm.ErrRecordNotFound) { response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在") return } if errors.Is(err, runCompletedErr) { response.Error(c, http.StatusConflict, "RUN_COMPLETED", err.Error()) return } if err != nil { response.Error(c, http.StatusUnprocessableEntity, "FINISH_FAILED", err.Error()) return } _ = audit.Record(h.db, p, "finish", "sop_run", id, input) h.respondRun(c, run) } func (h *Handler) Feedback(c *gin.Context) { p, _ := auth.PrincipalFromContext(c) id, ok := runID(c) if !ok { return } var input struct { Score int `json:"score" binding:"required,min=1,max=5"` Comment string `json:"comment" binding:"max=2000"` } 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 || !canViewRun(p, run) { response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在") return } if run.Status != "completed" { response.Error(c, http.StatusConflict, "RUN_NOT_COMPLETED", "执行完成后才能提交反馈") return } item := model.SOPFeedback{TenantID: p.TenantID, RunID: id, UserID: p.UserID, Score: input.Score, Comment: input.Comment} if err := h.db.Create(&item).Error; err != nil { response.Error(c, http.StatusConflict, "FEEDBACK_EXISTS", "该执行记录已经提交反馈") return } _ = audit.Record(h.db, p, "feedback", "sop_run", id, gin.H{"score": input.Score}) response.Created(c, item) } func (h *Handler) respondRun(c *gin.Context, item model.SOPRun) { var node model.SOPNode if err := h.db.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", item.SOPID, item.CurrentNodeKey, item.TenantID).First(&node).Error; err != nil { response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在") return } answers := map[string]interface{}{} _ = json.Unmarshal(item.Answers, &answers) derived := map[string]interface{}{} _ = json.Unmarshal(item.Derived, &derived) input := map[string]interface{}{} _ = json.Unmarshal(item.Input, &input) context := runtimeContext(input, derived, answers) context["__knowledge_snapshot"] = json.RawMessage(item.KnowledgeSnapshot) nodeView, err := h.nodeView(h.db, node, item.TenantID, context) if err != nil { response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "当前节点关联的历史知识内容不存在") return } outputs, err := buildScenarioOutputs(h.db, item, nodeView.Outputs) if err != nil { response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成场景输出失败") return } if raw, marshalErr := json.Marshal(outputs); marshalErr == nil { item.Outputs = datatypes.JSON(raw) _ = h.db.Model(&model.SOPRun{}).Where("id = ?", item.ID).Update("outputs", item.Outputs).Error } fields := make([]model.ScenarioField, 0) 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 } var scenario model.Scenario if err := h.db.Table("scenarios sc").Select("sc.*").Joins("JOIN sops s ON s.scenario_id = sc.id").Where("s.id = ? AND sc.tenant_id = ?", item.SOPID, item.TenantID).First(&scenario).Error; err != nil { response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景结果格式失败") return } response.OK(c, gin.H{"run": item, "node": nodeView, "fields": fields, "outputs": outputs, "result_schema": scenario.ResultSchema}) } func runID(c *gin.Context) (uint64, bool) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil || id == 0 { response.Error(c, http.StatusBadRequest, "INVALID_ID", "执行记录 ID 不正确") return 0, false } return id, true } func scopeRuns(query *gorm.DB, principal auth.Principal, alias string) *gorm.DB { query = query.Where(alias+".tenant_id = ?", principal.TenantID) if auth.HasPermission(principal, "runs.view_all") || auth.HasPermission(principal, "*") { return query } return query.Where(alias+".operator_id = ?", principal.UserID) } func canViewRun(principal auth.Principal, run model.SOPRun) bool { if run.TenantID != principal.TenantID { return false } return run.OperatorID == principal.UserID || auth.HasPermission(principal, "runs.view_all") || auth.HasPermission(principal, "*") } func canOperateRun(principal auth.Principal, run model.SOPRun) bool { return run.TenantID == principal.TenantID && (run.OperatorID == principal.UserID || auth.HasPermission(principal, "*")) } func pagination(c *gin.Context) (int, int) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) if page < 1 { page = 1 } if pageSize < 1 { pageSize = 20 } if pageSize > 100 { pageSize = 100 } return page, pageSize }