Files
iqudo-top1/internal/run/public.go
Eric 1549169735@qq.com da3f16e6db update
2026-09-13 20:08:13 +08:00

535 lines
21 KiB
Go

package run
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"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"
"git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit"
"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
}
}
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)
externalRef := body.ExternalRef
if externalRef == "" {
externalRef = "run-" + uuid.NewString()
}
scriptStateRaw, _ := json.Marshal(scriptkit.ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}})
run := model.SOPRun{TenantID: scenario.TenantID, SOPID: sop.ID, OperatorID: scenario.CreatedBy, ExternalRef: externalRef, CurrentNodeKey: sop.StartNodeKey, Status: "running", Answers: datatypes.JSON(raw), Input: datatypes.JSON(raw), Derived: datatypes.JSON(derivedRaw), Outputs: datatypes.JSON([]byte(`[]`)), ScriptState: datatypes.JSON(scriptStateRaw), 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 pkg, loadErr := loadRunPackage(tx, run); loadErr == nil {
if persistErr := persistRunWeights(tx, run, pkg, runWeights(run, pkg)); persistErr != nil {
return persistErr
}
}
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})
if err := tx.Create(&model.SOPRunEvent{TenantID: scenario.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "start", Payload: datatypes.JSON(payload)}).Error; err != nil {
return err
}
return audit.RecordTx(tx, auth.Principal{TenantID: scenario.TenantID, UserID: scenario.CreatedBy}, "start", "sop_run", run.ID, gin.H{"source": "public_sdk"})
})
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_id = ? AND node_key = ? AND tenant_id = ?", run.SOPID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
return err
}
if node.Type == "stage" {
if len(body.Answers) > 0 {
if err := applyStageAnswer(tx, &run, node, stageAnswerInput{NodeKey: body.NodeKey, Answers: body.Answers}); err != nil {
return err
}
}
return advanceFromStage(tx, &run, node)
}
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)
if err := tx.Model(&run).Update("answers", run.Answers).Error; err != nil {
return err
}
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
}
if err := advanceRunNode(tx, &run); err != nil {
return err
}
if run.Status == "completed" {
return audit.RecordTx(tx, auth.Principal{TenantID: run.TenantID, UserID: run.OperatorID}, "finish", "sop_run", run.ID, gin.H{"source": "public_sdk", "result": run.Result})
}
return nil
})
if err != nil {
h.respondPublicMutationError(c, err, "提交节点失败")
return
}
h.respondPublicRun(c, run, "")
}
// PublicAnswer records one stage script/dimension answer without advancing.
func (h *Handler) PublicAnswer(c *gin.Context) {
session, _, ok := h.publicSession(c)
if !ok {
return
}
var body stageAnswerInput
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_id = ? AND node_key = ? AND tenant_id = ?", run.SOPID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
return err
}
if node.Type != "stage" {
return errors.New("当前节点不是话术阶段")
}
return applyStageAnswer(tx, &run, node, body)
})
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_id = ? AND node_key = ? AND tenant_id = ?", run.SOPID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
return err
}
if node.Type == "stage" {
if err := advanceFromStage(tx, &run, node); err != nil {
return err
}
} else if node.Type == "question" || node.Type == "choice" || node.Type == "form" {
return errPublicInputNeeded
} else if err := advanceRunNode(tx, &run); err != nil {
return err
}
return tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "next", Payload: datatypes.JSON([]byte(`{}`))}).Error
})
if err != nil {
h.respondPublicMutationError(c, err, "推进节点失败")
return
}
h.respondPublicRun(c, run, "")
}
// PublicBack moves the run to the previous node.
func (h *Handler) PublicBack(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
}
if err := backRunNode(tx, &run); err != nil {
return err
}
return tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "back", Payload: datatypes.JSON([]byte(`{}`))}).Error
})
if err != nil {
h.respondPublicMutationError(c, err, "返回上一步失败")
return
}
h.respondPublicRun(c, run, "")
}
// PublicFeedback records like/report/unreasonable feedback on a script.
func (h *Handler) PublicFeedback(c *gin.Context) {
session, run, ok := h.publicSession(c)
if !ok {
return
}
var body struct {
NodeKey string `json:"node_key"`
ScriptKey string `json:"script_key" binding:"required"`
FeedbackType string `json:"feedback_type" binding:"required"`
Note string `json:"note"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "话术反馈格式不正确")
return
}
if body.FeedbackType != "like" && body.FeedbackType != "report" && body.FeedbackType != "unreasonable" {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "话术反馈类型不正确")
return
}
pkg, err := loadRunPackage(h.db, run)
if err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景没有配置话术包")
return
}
script, found := pkg.ScriptByKey(body.ScriptKey)
if !found {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "话术不存在")
return
}
item := model.ScriptFeedback{TenantID: run.TenantID, RunID: run.ID, StageID: script.StageID, ScriptID: script.ID, FeedbackType: body.FeedbackType, OperatorID: run.OperatorID, Note: body.Note}
if err := h.db.Create(&item).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存话术反馈失败")
return
}
payload, _ := json.Marshal(body)
if err := h.db.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: body.NodeKey, Action: "script_feedback", Payload: datatypes.JSON(payload)}).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存话术反馈失败")
return
}
_ = session
response.OK(c, gin.H{"recorded": true})
}
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 audit.RecordTx(tx, auth.Principal{TenantID: run.TenantID, UserID: run.OperatorID}, "finish", "sop_run", run.ID, gin.H{"source": "public_sdk", "result": body.Result})
})
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 sop model.SOP
if err := tx.Where("id = ? AND tenant_id = ?", run.SOPID, run.TenantID).First(&sop).Error; err != nil {
return err
}
run.CurrentNodeKey, run.Status, run.Result, run.FinalResult, run.CompletedAt, run.Answers = sop.StartNodeKey, "running", "", nil, nil, run.Input
run.ScriptState = datatypes.JSON([]byte(`{"script_answers":{},"dimension_selects":{}}`))
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(`[]`)), "script_state": run.ScriptState}).Error; err != nil {
return err
}
if pkg, loadErr := loadRunPackage(tx, run); loadErr == nil {
if persistErr := persistRunWeights(tx, run, pkg, runWeights(run, pkg)); persistErr != nil {
return persistErr
}
}
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 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_id = ? AND node_key = ? AND tenant_id = ?", run.SOPID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
return
}
view, err := nodeView(h.db, run, node)
if err != nil {
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成节点输出失败")
return
}
view.Config = nil
outputs, err := buildScenarioOutputs(h.db, run)
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[:])
}