feat: complete pet SOP governance workflow

This commit is contained in:
Eric 1549169735@qq.com
2026-08-08 22:58:35 +08:00
parent 97a76250f7
commit 4b240499c2
85 changed files with 1316 additions and 269 deletions

66
internal/run/detail.go Normal file
View File

@@ -0,0 +1,66 @@
package run
import (
"net/http"
"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"
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
)
type DetailHeader struct {
model.SOPRun
SOPName string `json:"sop_name"`
ScenarioName string `json:"scenario_name"`
Version int `json:"version"`
OperatorName string `json:"operator_name"`
}
type DetailEvent struct {
model.SOPRunEvent
NodeTitle string `json:"node_title"`
NodeType string `json:"node_type"`
NodeContent string `json:"node_content"`
}
type DetailFeedback struct {
model.SOPFeedback
UserName string `json:"user_name"`
}
func (h *Handler) Detail(c *gin.Context) {
principal, _ := auth.PrincipalFromContext(c)
id, ok := runID(c)
if !ok {
return
}
var header DetailHeader
err := h.db.Table("sop_runs r").Select(
"r.*, s.name AS sop_name, sc.name AS scenario_name, sv.version, u.display_name AS operator_name",
).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").Where("r.id = ? AND r.tenant_id = ?", id, principal.TenantID).Scan(&header).Error
if err != nil || header.ID == 0 || !canViewRun(principal, header.SOPRun) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return
}
events := make([]DetailEvent, 0)
if err := h.db.Table("sop_run_events e").Select("e.*, n.title AS node_title, n.type AS node_type, n.content AS node_content").Joins("LEFT JOIN sop_nodes n ON n.sop_version_id = ? AND n.node_key = e.node_key", header.SOPVersionID).Where("e.run_id = ? AND e.tenant_id = ?", id, principal.TenantID).Order("e.created_at, e.id").Scan(&events).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行事件失败")
return
}
feedback := make([]DetailFeedback, 0)
if err := h.db.Table("sop_feedback f").Select("f.*, u.display_name AS user_name").Joins("JOIN users u ON u.id = f.user_id").Where("f.run_id = ? AND f.tenant_id = ?", id, principal.TenantID).Order("f.created_at").Scan(&feedback).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行反馈失败")
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 = ?", header.SOPID, principal.TenantID).Order("sf.sort_order, sf.id").Find(&fields).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景字段失败")
return
}
if header.Answers == nil {
header.Answers = datatypes.JSON([]byte(`{}`))
}
response.OK(c, gin.H{"run": header, "events": events, "feedback": feedback, "fields": fields})
}

View File

@@ -8,6 +8,7 @@ import (
"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"
@@ -36,8 +37,10 @@ func (h *Handler) PublishedSOPs(c *gin.Context) {
ScenarioName string `json:"scenario_name"`
Version int `json:"version"`
}
var items []item
err := 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").Where("s.tenant_id = ? AND s.status = ?", p.TenantID, "published").Order("s.updated_at DESC").Scan(&items).Error
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 = 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
@@ -54,6 +57,10 @@ func (h *Handler) Start(c *gin.Context) {
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
}
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", "没有可执行的已发布版本")
@@ -85,6 +92,10 @@ func (h *Handler) Get(c *gin.Context) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return
}
if !canViewRun(p, item) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return
}
h.respondRun(c, item)
}
@@ -94,12 +105,34 @@ func (h *Handler) List(c *gin.Context) {
model.SOPRun
SOPName string `json:"sop_name"`
}
var items []row
if err := h.db.Table("sop_runs r").Select("r.*, s.name AS sop_name").Joins("JOIN sops s ON s.id = r.sop_id").Where("r.tenant_id = ?", p.TenantID).Order("r.created_at DESC").Limit(100).Scan(&items).Error; err != nil {
page, pageSize := pagination(c)
query := scopeRuns(h.db.Table("sop_runs r").Joins("JOIN sops s ON s.id = r.sop_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 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
}
response.OK(c, gin.H{"items": items, "total": len(items)})
items := make([]row, 0)
if err := query.Select("r.*, s.name AS sop_name").Order("r.created_at 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) Answer(c *gin.Context) {
@@ -124,6 +157,9 @@ func (h *Handler) Answer(c *gin.Context) {
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("当前步骤已经变化,请刷新后重试")
}
@@ -131,7 +167,7 @@ func (h *Handler) Answer(c *gin.Context) {
if err := tx.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", updated.SOPVersionID, updated.CurrentNodeKey, p.TenantID).First(&currentNode).Error; err != nil {
return err
}
var fields []model.ScenarioField
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
}
@@ -210,8 +246,17 @@ func (h *Handler) Finish(c *gin.Context) {
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 || !canOperateRun(p, run) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return
}
if run.Status != "running" {
response.Error(c, http.StatusConflict, "RUN_COMPLETED", "执行记录已经结束")
return
}
now := time.Now()
result := h.db.Model(&model.SOPRun{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Updates(map[string]interface{}{"status": "completed", "result": input.Result, "completed_at": &now})
result := h.db.Model(&model.SOPRun{}).Where("id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "running").Updates(map[string]interface{}{"status": "completed", "result": input.Result, "completed_at": &now})
if result.Error != nil || result.RowsAffected == 0 {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return
@@ -234,11 +279,21 @@ func (h *Handler) Feedback(c *gin.Context) {
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)
}
@@ -248,7 +303,7 @@ func (h *Handler) respondRun(c *gin.Context, item model.SOPRun) {
response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
return
}
var fields []model.ScenarioField
fields := make([]model.ScenarioField, 0)
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)
response.OK(c, gin.H{"run": item, "node": node, "fields": fields})
}
@@ -261,3 +316,37 @@ func runID(c *gin.Context) (uint64, bool) {
}
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
}