feat: simplify SOP to immediate-effect configuration
This commit is contained in:
@@ -13,7 +13,9 @@ import (
|
||||
"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"
|
||||
@@ -27,7 +29,6 @@ type listItem struct {
|
||||
model.SOPRun
|
||||
SOPName string `json:"sop_name"`
|
||||
ScenarioName string `json:"scenario_name"`
|
||||
Version int `json:"version"`
|
||||
OperatorName string `json:"operator_name"`
|
||||
}
|
||||
|
||||
@@ -40,7 +41,7 @@ func NewHandler(db *gorm.DB) *Handler {
|
||||
return &Handler{db: db}
|
||||
}
|
||||
|
||||
func (h *Handler) PublishedSOPs(c *gin.Context) {
|
||||
func (h *Handler) AvailableSOPs(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
type item struct {
|
||||
ID uint64 `json:"id"`
|
||||
@@ -48,14 +49,13 @@ func (h *Handler) PublishedSOPs(c *gin.Context) {
|
||||
Description string `json:"description"`
|
||||
ScenarioID uint64 `json:"scenario_id"`
|
||||
ScenarioName string `json:"scenario_name"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
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, sv.version").Joins("JOIN scenarios sc ON sc.id = s.scenario_id").Joins("JOIN sop_versions sv ON sv.sop_id = s.id AND sv.status = ?", "published")
|
||||
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").Joins("JOIN sop_versions sv ON sv.sop_id = s.id AND sv.status = ?", "published")
|
||||
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 失败")
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询可用 SOP 失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items, "total": len(items)})
|
||||
@@ -64,27 +64,90 @@ func (h *Handler) PublishedSOPs(c *gin.Context) {
|
||||
func (h *Handler) Start(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
var input struct {
|
||||
SOPID uint64 `json:"sop_id" binding:"required"`
|
||||
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")
|
||||
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 version model.SOPVersion
|
||||
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", input.SOPID, p.TenantID, "published").Order("version DESC").First(&version).Error; err != nil {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的已发布版本")
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的 SOP")
|
||||
return
|
||||
}
|
||||
run := model.SOPRun{TenantID: p.TenantID, SOPID: input.SOPID, SOPVersionID: version.ID, OperatorID: p.UserID, CurrentNodeKey: version.StartNodeKey, Status: "running", Answers: datatypes.JSON([]byte(`{}`)), Result: "", StartedAt: time.Now()}
|
||||
err := h.db.Transaction(func(tx *gorm.DB) 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 = ?", 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 model.SOP
|
||||
if err := h.db.Where("id = ? AND tenant_id = ?", input.SOPID, p.TenantID).First(&sopItem).Error; err != nil {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||
return
|
||||
}
|
||||
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, SOPVersionID: version.ID, OperatorID: p.UserID, ExternalRef: externalRef, CurrentNodeKey: version.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
|
||||
}
|
||||
return tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "start", Payload: datatypes.JSON([]byte(`{}`))}).Error
|
||||
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 失败")
|
||||
@@ -118,7 +181,6 @@ func (h *Handler) List(c *gin.Context) {
|
||||
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)
|
||||
@@ -144,7 +206,10 @@ func (h *Handler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
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 {
|
||||
// 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.sop_version_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
|
||||
}
|
||||
@@ -220,6 +285,9 @@ func (h *Handler) Answer(c *gin.Context) {
|
||||
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_version_id = ? AND source_node_key = ?", updated.SOPVersionID, updated.CurrentNodeKey).Order("priority, id").Find(&edges).Error; err != nil {
|
||||
return err
|
||||
@@ -283,6 +351,15 @@ func sortEdges(edges []model.SOPEdge) {
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
@@ -302,10 +379,11 @@ func (h *Handler) Finish(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Result string `json:"result" binding:"required,oneof=manual"`
|
||||
Result string `json:"result"`
|
||||
FinalResult json.RawMessage `json:"final_result"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择执行结果")
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "执行结果格式不正确")
|
||||
return
|
||||
}
|
||||
var run model.SOPRun
|
||||
@@ -317,20 +395,51 @@ func (h *Handler) Finish(c *gin.Context) {
|
||||
if !canOperateRun(p, run) {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
if run.Status != "running" {
|
||||
if run.Status != "running" && run.Status != "completed" {
|
||||
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 {
|
||||
finalResult, err := parseFinalResult(input.FinalResult)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&run).Updates(map[string]interface{}{"status": "completed", "result": input.Result, "completed_at": &now}).Error; err != nil {
|
||||
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.CompletedAt = &now
|
||||
run.FinalResult = datatypes.JSON(finalRaw)
|
||||
run.CompletedAt = completedAt
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -342,7 +451,7 @@ func (h *Handler) Finish(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "FINISH_FAILED", "结束执行失败")
|
||||
response.Error(c, http.StatusUnprocessableEntity, "FINISH_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
_ = audit.Record(h.db, p, "finish", "sop_run", id, input)
|
||||
@@ -387,17 +496,39 @@ func (h *Handler) respondRun(c *gin.Context, item model.SOPRun) {
|
||||
response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
|
||||
return
|
||||
}
|
||||
nodeView, err := h.nodeView(h.db, node, item.TenantID)
|
||||
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", "当前节点关联的知识卡版本不存在")
|
||||
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
|
||||
}
|
||||
response.OK(c, gin.H{"run": item, "node": nodeView, "fields": fields})
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user