feat: simplify SOP to immediate-effect configuration

This commit is contained in:
Eric 1549169735@qq.com
2026-08-18 16:27:02 +08:00
parent c5ab886b70
commit 8de48fb05e
150 changed files with 6764 additions and 1626 deletions

458
internal/run/public.go Normal file
View File

@@ -0,0 +1,458 @@
package run
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"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"
)
var (
errPublicRunCompleted = errors.New("执行记录已经结束")
errPublicNodeChanged = errors.New("当前步骤已经变化")
errPublicInputNeeded = errors.New("当前节点需要提交表单")
)
func (h *Handler) PublicStart(c *gin.Context) {
var body struct {
PublicKey string `json:"public_key" binding:"required"`
SOPID uint64 `json:"sop_id"`
Input map[string]interface{} `json:"input"`
InitialValues map[string]interface{} `json:"initial_values"`
ExternalRef string `json:"external_ref"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "公开场景参数不完整")
return
}
var scenario model.Scenario
if err := h.db.Where("scenario_key = ? AND public_key = ? AND status <> ?", c.Param("scenarioKey"), body.PublicKey, "archived").First(&scenario).Error; err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "公开场景不存在")
return
}
query := h.db.Where("scenario_id = ? AND tenant_id = ? AND status = ?", scenario.ID, scenario.TenantID, "published")
if body.SOPID != 0 {
query = query.Where("id = ?", body.SOPID)
}
var sop model.SOP
if err := query.Order("updated_at DESC").First(&sop).Error; err != nil {
response.Error(c, http.StatusNotFound, "SOP_NOT_FOUND", "场景没有可执行的 SOP")
return
}
if body.ExternalRef != "" {
var existing model.SOPRun
if err := h.db.Where("tenant_id = ? AND sop_id = ? AND external_ref = ?", scenario.TenantID, sop.ID, body.ExternalRef).First(&existing).Error; err == nil {
token := uuid.NewString() + uuid.NewString()
if err := h.db.Create(&model.PublicRunSession{TenantID: scenario.TenantID, RunID: existing.ID, TokenHash: publicTokenHash(token), ExpiresAt: time.Now().Add(24 * time.Hour)}).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "SESSION_FAILED", "创建 SDK 会话失败")
return
}
h.respondPublicRun(c, existing, token)
return
}
}
var version model.SOPVersion
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", sop.ID, scenario.TenantID, "published").Order("version DESC").First(&version).Error; err != nil {
response.Error(c, http.StatusNotFound, "SOP_NOT_FOUND", "场景没有可执行的 SOP")
return
}
fields := make([]model.ScenarioField, 0)
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", scenario.ID, scenario.TenantID).Order("sort_order, id").Find(&fields).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景契约失败")
return
}
normalized := mergeValues(mapScenarioInput(fields, body.Input), body.InitialValues)
if err := validateInitialAnswers(fields, normalized); err != nil {
response.Error(c, http.StatusUnprocessableEntity, "INVALID_INPUT", err.Error())
return
}
raw, _ := json.Marshal(normalized)
derived, matchedRules, err := deriveForScenario(h.db, scenario.TenantID, scenario.ID, normalized)
if err != nil {
response.Error(c, http.StatusUnprocessableEntity, "RULE_EVALUATION_FAILED", err.Error())
return
}
derivedRaw, _ := json.Marshal(derived)
knowledgeRaw, err := snapshotKnowledge(h.db, scenario.TenantID, scenario.ID)
if err != nil {
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_SNAPSHOT_FAILED", "生成知识快照失败")
return
}
externalRef := body.ExternalRef
if externalRef == "" {
externalRef = "run-" + uuid.NewString()
}
run := model.SOPRun{TenantID: scenario.TenantID, SOPID: sop.ID, SOPVersionID: version.ID, OperatorID: scenario.CreatedBy, ExternalRef: externalRef, CurrentNodeKey: version.StartNodeKey, Status: "running", Answers: datatypes.JSON(raw), Input: datatypes.JSON(raw), Derived: datatypes.JSON(derivedRaw), Outputs: datatypes.JSON([]byte(`[]`)), KnowledgeSnapshot: datatypes.JSON(knowledgeRaw), StartedAt: time.Now()}
token := uuid.NewString() + uuid.NewString()
err = h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&run).Error; err != nil {
return err
}
if err := tx.Create(&model.PublicRunSession{TenantID: scenario.TenantID, RunID: run.ID, TokenHash: publicTokenHash(token), ExpiresAt: time.Now().Add(24 * time.Hour)}).Error; err != nil {
return err
}
payload, _ := json.Marshal(gin.H{"source": "public_sdk", "mapped_field_keys": sortedKeys(normalized), "matched_rule_keys": matchedRules})
return tx.Create(&model.SOPRunEvent{TenantID: scenario.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "start", Payload: datatypes.JSON(payload)}).Error
})
if err != nil {
if body.ExternalRef != "" {
var existing model.SOPRun
if findErr := h.db.Where("tenant_id = ? AND sop_id = ? AND external_ref = ?", scenario.TenantID, sop.ID, body.ExternalRef).First(&existing).Error; findErr == nil {
token = uuid.NewString() + uuid.NewString()
if sessionErr := h.db.Create(&model.PublicRunSession{TenantID: scenario.TenantID, RunID: existing.ID, TokenHash: publicTokenHash(token), ExpiresAt: time.Now().Add(24 * time.Hour)}).Error; sessionErr == nil {
h.respondPublicRun(c, existing, token)
return
}
}
}
response.Error(c, http.StatusInternalServerError, "START_FAILED", "启动公开场景失败")
return
}
h.respondPublicRun(c, run, token)
}
func (h *Handler) PublicCurrent(c *gin.Context) {
session, run, ok := h.publicSession(c)
if !ok {
return
}
_ = session
h.respondPublicRun(c, run, "")
}
func (h *Handler) PublicSubmit(c *gin.Context) {
session, _, ok := h.publicSession(c)
if !ok {
return
}
var body struct {
NodeKey string `json:"node_key"`
Answers map[string]interface{} `json:"answers"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "提交内容格式不正确")
return
}
var run model.SOPRun
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := lockPublicRun(tx, session, &run); err != nil {
return err
}
if run.Status != "running" {
return errPublicRunCompleted
}
if body.NodeKey != "" && body.NodeKey != run.CurrentNodeKey {
return errPublicNodeChanged
}
var node model.SOPNode
if err := tx.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", run.SOPVersionID, run.CurrentNodeKey, run.TenantID).First(&node).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 = ?", run.SOPID, run.TenantID).Find(&fields).Error; err != nil {
return err
}
if err := validateNodeAnswers(node, fields, body.Answers); err != nil {
return err
}
answers := map[string]interface{}{}
_ = json.Unmarshal(run.Answers, &answers)
for key, value := range body.Answers {
answers[key] = value
}
answerRaw, _ := json.Marshal(answers)
run.Answers = datatypes.JSON(answerRaw)
payload, _ := json.Marshal(body.Answers)
if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "answer", Payload: datatypes.JSON(payload)}).Error; err != nil {
return err
}
return advancePublicRun(tx, &run)
})
if err != nil {
h.respondPublicMutationError(c, err, "提交节点失败")
return
}
h.respondPublicRun(c, run, "")
}
func (h *Handler) PublicNext(c *gin.Context) {
session, _, ok := h.publicSession(c)
if !ok {
return
}
var run model.SOPRun
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := lockPublicRun(tx, session, &run); err != nil {
return err
}
if run.Status != "running" {
return errPublicRunCompleted
}
var node model.SOPNode
if err := tx.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", run.SOPVersionID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
return err
}
if node.Type == "question" || node.Type == "choice" || node.Type == "form" {
return errPublicInputNeeded
}
if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "next", Payload: datatypes.JSON([]byte(`{}`))}).Error; err != nil {
return err
}
return advancePublicRun(tx, &run)
})
if err != nil {
h.respondPublicMutationError(c, err, "推进节点失败")
return
}
h.respondPublicRun(c, run, "")
}
func (h *Handler) PublicFinish(c *gin.Context) {
session, _, ok := h.publicSession(c)
if !ok {
return
}
var body struct {
Result string `json:"result"`
FinalResult json.RawMessage `json:"final_result"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "最终结果格式不正确")
return
}
var run model.SOPRun
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := lockPublicRun(tx, session, &run); err != nil {
return err
}
if run.Status != "running" && run.Status != "completed" {
return errPublicRunCompleted
}
if body.Result == "" {
body.Result = run.Result
if body.Result == "" {
body.Result = "completed"
}
}
finalResult, err := parseFinalResult(body.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)
now := time.Now()
completedAt := run.CompletedAt
if completedAt == nil {
completedAt = &now
}
payload, _ := json.Marshal(gin.H{"result": body.Result, "final_result": finalResult})
if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.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": body.Result, "final_result": datatypes.JSON(finalRaw), "completed_at": completedAt}).Error; err != nil {
return err
}
run.Status, run.Result, run.FinalResult, run.CompletedAt = "completed", body.Result, datatypes.JSON(finalRaw), completedAt
return nil
})
if err != nil {
h.respondPublicMutationError(c, err, "结束执行失败")
return
}
h.respondPublicRun(c, run, "")
}
func (h *Handler) PublicReset(c *gin.Context) {
session, _, ok := h.publicSession(c)
if !ok {
return
}
var run model.SOPRun
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := lockPublicRun(tx, session, &run); err != nil {
return err
}
var version model.SOPVersion
if err := tx.Where("id = ? AND tenant_id = ?", run.SOPVersionID, run.TenantID).First(&version).Error; err != nil {
return err
}
run.CurrentNodeKey, run.Status, run.Result, run.FinalResult, run.CompletedAt, run.Answers = version.StartNodeKey, "running", "", nil, nil, run.Input
if err := tx.Model(&run).Updates(map[string]interface{}{"current_node_key": run.CurrentNodeKey, "status": run.Status, "result": run.Result, "final_result": nil, "completed_at": nil, "answers": run.Input, "outputs": datatypes.JSON([]byte(`[]`))}).Error; err != nil {
return err
}
if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "reset", Payload: datatypes.JSON([]byte(`{}`))}).Error; err != nil {
return err
}
return tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "enter", Payload: datatypes.JSON([]byte(`{}`))}).Error
})
if err != nil {
h.respondPublicMutationError(c, err, "重置执行失败")
return
}
h.respondPublicRun(c, run, "")
}
func advancePublicRun(tx *gorm.DB, run *model.SOPRun) error {
var edges []model.SOPEdge
if err := tx.Where("sop_version_id = ? AND source_node_key = ? AND tenant_id = ?", run.SOPVersionID, run.CurrentNodeKey, run.TenantID).Order("priority,id").Find(&edges).Error; err != nil {
return err
}
answers := map[string]interface{}{}
_ = json.Unmarshal(run.Answers, &answers)
derived := map[string]interface{}{}
_ = json.Unmarshal(run.Derived, &derived)
input := map[string]interface{}{}
_ = json.Unmarshal(run.Input, &input)
context := runtimeContext(input, derived, answers)
sortEdges(edges)
nextKey := ""
for _, edge := range edges {
matched, err := matchCondition(json.RawMessage(edge.Condition), context)
if err != nil {
return err
}
if matched {
nextKey = edge.TargetNodeKey
break
}
}
if nextKey == "" {
return errors.New("没有满足条件的下一节点")
}
var next model.SOPNode
if err := tx.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", run.SOPVersionID, nextKey, run.TenantID).First(&next).Error; err != nil {
return err
}
updates := map[string]interface{}{"current_node_key": nextKey}
if next.Type == "finish" || next.Type == "escalate" {
now := time.Now()
updates["status"] = "completed"
updates["completed_at"] = &now
updates["result"] = next.Type
run.Status, run.CompletedAt, run.Result = "completed", &now, next.Type
}
if err := tx.Model(run).Updates(updates).Error; err != nil {
return err
}
run.CurrentNodeKey = nextKey
if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: nextKey, Action: "enter", Payload: datatypes.JSON([]byte(`{}`))}).Error; err != nil {
return err
}
if run.Status == "completed" {
return tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: nextKey, Action: "finish", Payload: datatypes.JSON([]byte(`{"source":"terminal_node"}`))}).Error
}
return nil
}
func lockPublicRun(tx *gorm.DB, session model.PublicRunSession, run *model.SOPRun) error {
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", session.RunID, session.TenantID).First(run).Error
}
func (h *Handler) respondPublicMutationError(c *gin.Context, err error, fallback string) {
switch {
case errors.Is(err, errPublicRunCompleted):
response.Error(c, http.StatusConflict, "RUN_COMPLETED", err.Error())
case errors.Is(err, errPublicNodeChanged):
response.Error(c, http.StatusConflict, "NODE_CHANGED", err.Error())
case errors.Is(err, errPublicInputNeeded):
response.Error(c, http.StatusUnprocessableEntity, "INPUT_REQUIRED", err.Error())
case errors.Is(err, gorm.ErrRecordNotFound):
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录或流程节点不存在")
default:
response.Error(c, http.StatusUnprocessableEntity, "ADVANCE_FAILED", fallback+": "+err.Error())
}
}
func (h *Handler) respondPublicRun(c *gin.Context, run model.SOPRun, token string) {
var node model.SOPNode
if err := h.db.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", run.SOPVersionID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
return
}
answers := map[string]interface{}{}
_ = json.Unmarshal(run.Answers, &answers)
derived := map[string]interface{}{}
_ = json.Unmarshal(run.Derived, &derived)
input := map[string]interface{}{}
_ = json.Unmarshal(run.Input, &input)
context := runtimeContext(input, derived, answers)
context["__knowledge_snapshot"] = json.RawMessage(run.KnowledgeSnapshot)
view, err := h.nodeView(h.db, node, run.TenantID, context)
if err != nil {
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成节点输出失败")
return
}
view.Config = nil
outputs, err := buildScenarioOutputs(h.db, run, view.Outputs)
if err != nil {
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成场景输出失败")
return
}
if raw, marshalErr := json.Marshal(outputs); marshalErr == nil {
run.Outputs = datatypes.JSON(raw)
_ = h.db.Model(&model.SOPRun{}).Where("id = ?", run.ID).Update("outputs", run.Outputs).Error
}
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 = ?", run.SOPID, run.TenantID).First(&scenario).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "读取场景结果格式失败")
return
}
data := gin.H{"run_id": run.ID, "external_ref": run.ExternalRef, "status": run.Status, "final_result": json.RawMessage(run.FinalResult), "result_schema": json.RawMessage(scenario.ResultSchema), "node": view, "outputs": outputs}
if token != "" {
data["session_token"] = token
}
response.OK(c, data)
}
func parseFinalResult(raw json.RawMessage) (map[string]interface{}, error) {
if len(raw) == 0 || string(raw) == "null" {
return nil, nil
}
var result map[string]interface{}
if err := json.Unmarshal(raw, &result); err != nil || result == nil {
return nil, errors.New("final_result 必须是对象或 null")
}
return result, nil
}
func (h *Handler) publicSession(c *gin.Context) (model.PublicRunSession, model.SOPRun, bool) {
raw := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
var session model.PublicRunSession
if raw == "" || h.db.Where("token_hash = ? AND expires_at > ?", publicTokenHash(raw), time.Now()).First(&session).Error != nil {
response.Error(c, http.StatusUnauthorized, "INVALID_SESSION", "SDK 会话无效或已过期")
return session, model.SOPRun{}, false
}
var run model.SOPRun
if err := h.db.Where("id = ? AND tenant_id = ?", c.Param("id"), session.TenantID).First(&run).Error; err != nil || run.ID != session.RunID {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return session, run, false
}
return session, run, true
}
func publicTokenHash(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}