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

698 lines
26 KiB
Go

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"
"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"
)
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)
externalRef := input.ExternalRef
if externalRef == "" {
externalRef = "run-" + uuid.NewString()
}
scriptStateRaw, _ := json.Marshal(scriptkit.ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}})
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(`[]`)), ScriptState: datatypes.JSON(scriptStateRaw), Result: "", StartedAt: time.Now()}
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
}
}
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})
}
// answerInput accepts both legacy node answers and stage script answers.
type answerInput struct {
NodeKey string `json:"node_key"`
Answers map[string]interface{} `json:"answers"`
// stage-specific fields
ScriptKey string `json:"script_key"`
OptionKeys []string `json:"option_keys"`
Value string `json:"value"`
ScriptAnswers []stageScriptAnswerInput `json:"script_answers"`
DimensionSelects map[string]bool `json:"dimension_selects"`
DimensionValueKey string `json:"dimension_value_key"`
Selected *bool `json:"selected"`
}
func (h *Handler) Answer(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := runID(c)
if !ok {
return
}
var input answerInput
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(&currentNode).Error; err != nil {
return err
}
if currentNode.Type == "stage" {
return applyStageAnswer(tx, &updated, currentNode, stageAnswerInput{NodeKey: input.NodeKey, ScriptKey: input.ScriptKey, OptionKeys: input.OptionKeys, Value: input.Value, ScriptAnswers: input.ScriptAnswers, DimensionSelects: input.DimensionSelects, Answers: input.Answers, DimensionValueKey: input.DimensionValueKey, Selected: input.Selected})
}
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
}
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
}
updated.Answers = datatypes.JSON(answerBytes)
if err := tx.Model(&updated).Update("answers", updated.Answers).Error; err != nil {
return err
}
return advanceRunNode(tx, &updated)
})
if err != nil {
response.Error(c, http.StatusUnprocessableEntity, "ADVANCE_FAILED", err.Error())
return
}
h.respondRun(c, updated)
}
// Next advances a stage run to the next node after checking stage completion.
func (h *Handler) Next(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := runID(c)
if !ok {
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
}
var currentNode model.SOPNode
if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", updated.SOPID, updated.CurrentNodeKey, p.TenantID).First(&currentNode).Error; err != nil {
return err
}
if currentNode.Type == "stage" {
if err := advanceFromStage(tx, &updated, currentNode); err != nil {
return err
}
} else if currentNode.Type == "question" || currentNode.Type == "choice" || currentNode.Type == "form" {
return errors.New("当前节点需要先提交表单")
} else {
if err := advanceRunNode(tx, &updated); err != nil {
return err
}
}
return tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: updated.ID, NodeKey: updated.CurrentNodeKey, Action: "next", Payload: datatypes.JSON([]byte(`{}`))}).Error
})
if err != nil {
response.Error(c, http.StatusUnprocessableEntity, "ADVANCE_FAILED", err.Error())
return
}
h.respondRun(c, updated)
}
// Back moves the run to the previous node.
func (h *Handler) Back(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := runID(c)
if !ok {
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 err := backRunNode(tx, &updated); err != nil {
return err
}
return tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: updated.ID, NodeKey: updated.CurrentNodeKey, Action: "back", Payload: datatypes.JSON([]byte(`{}`))}).Error
})
if err != nil {
response.Error(c, http.StatusUnprocessableEntity, "ADVANCE_FAILED", err.Error())
return
}
h.respondRun(c, updated)
}
// ScriptFeedback records like/report/unreasonable feedback on a script.
func (h *Handler) ScriptFeedback(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := runID(c)
if !ok {
return
}
var input struct {
NodeKey string `json:"node_key"`
ScriptKey string `json:"script_key" binding:"required"`
FeedbackType string `json:"feedback_type" binding:"required"`
Note string `json:"note" binding:"max=2000"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "话术反馈格式不正确")
return
}
if input.FeedbackType != "like" && input.FeedbackType != "report" && input.FeedbackType != "unreasonable" {
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
}
pkg, err := loadRunPackage(h.db, run)
if err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景没有配置话术包")
return
}
script, ok := pkg.ScriptByKey(input.ScriptKey)
if !ok {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "话术不存在")
return
}
item := model.ScriptFeedback{TenantID: p.TenantID, RunID: run.ID, StageID: script.StageID, ScriptID: script.ID, FeedbackType: input.FeedbackType, OperatorID: p.UserID, Note: input.Note}
if err := h.db.Create(&item).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存话术反馈失败")
return
}
payload, _ := json.Marshal(input)
if err := h.db.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: run.ID, NodeKey: input.NodeKey, Action: "script_feedback", Payload: datatypes.JSON(payload)}).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存话术反馈失败")
return
}
response.OK(c, gin.H{"recorded": true})
}
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) RecordScriptUsage(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := runID(c)
if !ok {
return
}
var input struct {
NodeKey string `json:"node_key"`
ScriptKey string `json:"script_key"`
ScriptTitle string `json:"script_title"`
Template string `json:"template"`
Scene string `json:"scene"`
}
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 {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return
}
if !canOperateRun(p, run) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return
}
payload, _ := json.Marshal(input)
if err := h.db.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: run.ID, NodeKey: input.NodeKey, Action: "script_used", Payload: datatypes.JSON(payload)}).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存话术使用记录失败")
return
}
response.OK(c, gin.H{"recorded": true})
}
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
}
nodeView, err := nodeView(h.db, item, node)
if err != nil {
response.Error(c, http.StatusInternalServerError, "STAGE_FAILED", "生成当前阶段内容失败")
return
}
outputs, err := buildScenarioOutputs(h.db, item)
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
}