feat: implement scenario-driven sales SOP platform

This commit is contained in:
Eric 1549169735@qq.com
2026-08-06 21:37:29 +08:00
parent 254469ab89
commit de5345607a
84 changed files with 7132 additions and 2 deletions

View File

@@ -0,0 +1,398 @@
package sop
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"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"
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type Handler struct {
db *gorm.DB
}
func NewHandler(db *gorm.DB) *Handler {
return &Handler{db: db}
}
type createInput struct {
Name string `json:"name" binding:"required,max=128"`
Description string `json:"description" binding:"max=2000"`
}
type graphInput struct {
StartNodeKey string `json:"start_node_key" binding:"required,max=64"`
Nodes []nodeInput `json:"nodes" binding:"required,min=1"`
Edges []edgeInput `json:"edges"`
}
type nodeInput struct {
NodeKey string `json:"node_key" binding:"required,max=64"`
Type string `json:"type" binding:"required,max=32"`
Title string `json:"title" binding:"required,max=128"`
Content string `json:"content" binding:"max=5000"`
Config json.RawMessage `json:"config"`
PositionX int `json:"position_x"`
PositionY int `json:"position_y"`
}
type edgeInput struct {
SourceNodeKey string `json:"source_node_key" binding:"required,max=64"`
TargetNodeKey string `json:"target_node_key" binding:"required,max=64"`
Condition json.RawMessage `json:"condition"`
Priority int `json:"priority"`
}
func (h *Handler) List(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
var items []model.SOP
query := h.db.Where("tenant_id = ? AND status <> ?", p.TenantID, "archived")
scenarioID := c.Query("scenario_id")
if scenarioID == "" {
scenarioID = c.Param("id")
}
if scenarioID != "" {
query = query.Where("scenario_id = ?", scenarioID)
}
if err := query.Order("updated_at DESC").Find(&items).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询 SOP 失败")
return
}
response.OK(c, gin.H{"items": items, "total": len(items)})
}
func (h *Handler) Create(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
scenarioID, ok := parseID(c, "id")
if !ok {
return
}
var scenario model.Scenario
if err := h.db.Where("id = ? AND tenant_id = ?", scenarioID, p.TenantID).First(&scenario).Error; err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
return
}
var input createInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "SOP 信息不完整")
return
}
var item model.SOP
var version model.SOPVersion
err := h.db.Transaction(func(tx *gorm.DB) error {
item = model.SOP{TenantID: p.TenantID, ScenarioID: scenarioID, Name: input.Name, Description: input.Description, Status: "draft", CreatedBy: p.UserID}
if err := tx.Create(&item).Error; err != nil {
return err
}
version = model.SOPVersion{TenantID: p.TenantID, SOPID: item.ID, Version: 1, Status: "draft", StartNodeKey: "start", CreatedBy: p.UserID}
if err := tx.Create(&version).Error; err != nil {
return err
}
nodes := []model.SOPNode{
{TenantID: p.TenantID, SOPVersionID: version.ID, NodeKey: "start", Type: "start", Title: "开始", Content: "", Config: datatypes.JSON([]byte(`{}`)), PositionX: 0, PositionY: 0},
{TenantID: p.TenantID, SOPVersionID: version.ID, NodeKey: "opening", Type: "message", Title: "开场", Content: "您好,我先了解一下具体情况。", Config: datatypes.JSON([]byte(`{}`)), PositionX: 0, PositionY: 120},
{TenantID: p.TenantID, SOPVersionID: version.ID, NodeKey: "finish", Type: "finish", Title: "结束", Content: "本次沟通已完成。", Config: datatypes.JSON([]byte(`{}`)), PositionX: 0, PositionY: 240},
}
if err := tx.Create(&nodes).Error; err != nil {
return err
}
edges := []model.SOPEdge{
{TenantID: p.TenantID, SOPVersionID: version.ID, SourceNodeKey: "start", TargetNodeKey: "opening", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
{TenantID: p.TenantID, SOPVersionID: version.ID, SourceNodeKey: "opening", TargetNodeKey: "finish", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
}
return tx.Create(&edges).Error
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建 SOP 失败")
return
}
_ = audit.Record(h.db, p, "create", "sop", item.ID, input)
response.Created(c, gin.H{"sop": item, "version": version})
}
func (h *Handler) Get(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
if !ok {
return
}
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
} else {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询 SOP 失败")
}
return
}
response.OK(c, gin.H{"sop": item, "version": version, "nodes": nodes, "edges": edges})
}
func (h *Handler) SaveGraph(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
if !ok {
return
}
var input graphInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "流程配置不完整")
return
}
var version model.SOPVersion
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "draft").Order("version DESC").First(&version).Error; err != nil {
response.Error(c, http.StatusConflict, "NO_DRAFT_VERSION", "没有可编辑的草稿版本")
return
}
nodes, edges := toModels(p.TenantID, version.ID, input)
problems := ValidateGraph(input.StartNodeKey, nodes, edges)
if len(problems) > 0 {
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
return
}
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPEdge{}).Error; err != nil {
return err
}
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPNode{}).Error; err != nil {
return err
}
if err := tx.Create(&nodes).Error; err != nil {
return err
}
if len(edges) > 0 {
if err := tx.Create(&edges).Error; err != nil {
return err
}
}
return tx.Model(&version).Update("start_node_key", input.StartNodeKey).Error
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存流程失败")
return
}
_ = audit.Record(h.db, p, "save_graph", "sop", id, gin.H{"version_id": version.ID})
h.Get(c)
}
func (h *Handler) Validate(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
if !ok {
return
}
_, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
if err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
problems := ValidateGraph(version.StartNodeKey, nodes, edges)
response.OK(c, gin.H{"valid": len(problems) == 0, "problems": problems})
}
func (h *Handler) SubmitReview(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
if !ok {
return
}
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
if err != nil || version.Status != "draft" {
response.Error(c, http.StatusConflict, "NO_DRAFT_VERSION", "没有可提交审核的草稿版本")
return
}
problems := ValidateGraph(version.StartNodeKey, nodes, edges)
if len(problems) > 0 {
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
return
}
err = h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&version).Update("status", "reviewing").Error; err != nil {
return err
}
return tx.Model(&item).Update("status", "reviewing").Error
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "SUBMIT_REVIEW_FAILED", "提交审核失败")
return
}
_ = audit.Record(h.db, p, "submit_review", "sop", id, gin.H{"version": version.Version})
response.OK(c, gin.H{"id": id, "version": version.Version, "status": "reviewing"})
}
func (h *Handler) Publish(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
if !ok {
return
}
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
if err != nil || (version.Status != "draft" && version.Status != "reviewing") {
response.Error(c, http.StatusConflict, "NO_REVIEW_VERSION", "没有可发布的审核版本")
return
}
problems := ValidateGraph(version.StartNodeKey, nodes, edges)
if len(problems) > 0 {
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
return
}
now := time.Now()
err = h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Update("status", "superseded").Error; err != nil {
return err
}
if err := tx.Model(&version).Updates(map[string]interface{}{"status": "published", "published_at": &now, "reviewed_by": p.UserID}).Error; err != nil {
return err
}
if err := tx.Model(&item).Update("status", "published").Error; err != nil {
return err
}
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", item.ScenarioID, p.TenantID).Update("status", "active").Error
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "PUBLISH_FAILED", "发布 SOP 失败")
return
}
_ = audit.Record(h.db, p, "publish", "sop", id, gin.H{"version": version.Version})
response.OK(c, gin.H{"id": id, "version": version.Version, "published_at": now})
}
func (h *Handler) Offline(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
if !ok {
return
}
var item model.SOP
if err := h.db.Where("id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").First(&item).Error; err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "已发布 SOP 不存在")
return
}
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&item).Update("status", "offline").Error; err != nil {
return err
}
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Update("status", "offline").Error; err != nil {
return err
}
var published int64
if err := tx.Model(&model.SOP{}).Where("scenario_id = ? AND tenant_id = ? AND status = ?", item.ScenarioID, p.TenantID, "published").Count(&published).Error; err != nil {
return err
}
if published == 0 {
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", item.ScenarioID, p.TenantID).Update("status", "draft").Error
}
return nil
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "OFFLINE_FAILED", "下线 SOP 失败")
return
}
_ = audit.Record(h.db, p, "offline", "sop", id, nil)
response.OK(c, gin.H{"id": id, "status": "offline"})
}
func (h *Handler) CreateVersion(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
if !ok {
return
}
var existing int64
h.db.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "draft").Count(&existing)
if existing > 0 {
response.Error(c, http.StatusConflict, "DRAFT_EXISTS", "已经存在草稿版本")
return
}
_, source, nodes, edges, err := h.loadLatest(id, p.TenantID)
if err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
var version model.SOPVersion
err = h.db.Transaction(func(tx *gorm.DB) error {
version = model.SOPVersion{TenantID: p.TenantID, SOPID: id, Version: source.Version + 1, Status: "draft", StartNodeKey: source.StartNodeKey, CreatedBy: p.UserID}
if err := tx.Create(&version).Error; err != nil {
return err
}
for i := range nodes {
nodes[i].Base = model.Base{}
nodes[i].SOPVersionID = version.ID
}
for i := range edges {
edges[i].Base = model.Base{}
edges[i].SOPVersionID = version.ID
}
if err := tx.Create(&nodes).Error; err != nil {
return err
}
if len(edges) > 0 {
return tx.Create(&edges).Error
}
return nil
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "CREATE_VERSION_FAILED", "创建草稿版本失败")
return
}
_ = audit.Record(h.db, p, "create_version", "sop", id, gin.H{"version": version.Version})
response.Created(c, version)
}
func (h *Handler) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersion, []model.SOPNode, []model.SOPEdge, error) {
var item model.SOP
if err := h.db.Where("id = ? AND tenant_id = ?", sopID, tenantID).First(&item).Error; err != nil {
return item, model.SOPVersion{}, nil, nil, err
}
var version model.SOPVersion
if err := h.db.Where("sop_id = ? AND tenant_id = ?", sopID, tenantID).Order("version DESC").First(&version).Error; err != nil {
return item, version, nil, nil, err
}
var nodes []model.SOPNode
var edges []model.SOPEdge
if err := h.db.Where("sop_version_id = ? AND tenant_id = ?", version.ID, tenantID).Order("position_y, id").Find(&nodes).Error; err != nil {
return item, version, nil, nil, err
}
if err := h.db.Where("sop_version_id = ? AND tenant_id = ?", version.ID, tenantID).Order("priority, id").Find(&edges).Error; err != nil {
return item, version, nil, nil, err
}
return item, version, nodes, edges, nil
}
func toModels(tenantID, versionID uint64, input graphInput) ([]model.SOPNode, []model.SOPEdge) {
nodes := make([]model.SOPNode, 0, len(input.Nodes))
for _, item := range input.Nodes {
config := item.Config
if len(config) == 0 || !json.Valid(config) {
config = json.RawMessage(`{}`)
}
nodes = append(nodes, model.SOPNode{TenantID: tenantID, SOPVersionID: versionID, NodeKey: item.NodeKey, Type: item.Type, Title: item.Title, Content: item.Content, Config: datatypes.JSON(config), PositionX: item.PositionX, PositionY: item.PositionY})
}
edges := make([]model.SOPEdge, 0, len(input.Edges))
for _, item := range input.Edges {
condition := item.Condition
if len(condition) == 0 || !json.Valid(condition) {
condition = json.RawMessage(`{}`)
}
edges = append(edges, model.SOPEdge{TenantID: tenantID, SOPVersionID: versionID, SourceNodeKey: item.SourceNodeKey, TargetNodeKey: item.TargetNodeKey, Condition: datatypes.JSON(condition), Priority: item.Priority})
}
return nodes, edges
}
func parseID(c *gin.Context, name string) (uint64, bool) {
id, err := strconv.ParseUint(c.Param(name), 10, 64)
if err != nil || id == 0 {
response.Error(c, http.StatusBadRequest, "INVALID_ID", "资源 ID 不正确")
return 0, false
}
return id, true
}

View File

@@ -0,0 +1,90 @@
package sop
import (
"encoding/json"
"fmt"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
)
var allowedNodeTypes = map[string]bool{"start": true, "message": true, "question": true, "form": true, "choice": true, "condition": true, "knowledge": true, "escalate": true, "finish": true}
func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge) []string {
var problems []string
if startNodeKey == "" {
problems = append(problems, "未设置开始节点")
}
nodeMap := make(map[string]model.SOPNode, len(nodes))
startCount := 0
finishCount := 0
for _, node := range nodes {
if node.NodeKey == "" {
problems = append(problems, "存在没有标识的节点")
continue
}
if _, exists := nodeMap[node.NodeKey]; exists {
problems = append(problems, fmt.Sprintf("节点标识重复:%s", node.NodeKey))
}
nodeMap[node.NodeKey] = node
if !allowedNodeTypes[node.Type] {
problems = append(problems, fmt.Sprintf("节点 %s 类型不支持", node.Title))
}
if node.Type == "start" {
startCount++
}
if node.Type == "finish" || node.Type == "escalate" {
finishCount++
}
}
if startCount != 1 {
problems = append(problems, "流程必须且只能包含一个开始节点")
}
if finishCount == 0 {
problems = append(problems, "流程至少需要一个结束或转人工节点")
}
if _, exists := nodeMap[startNodeKey]; !exists && startNodeKey != "" {
problems = append(problems, "开始节点不存在")
}
adjacency := make(map[string][]string)
outgoing := make(map[string]int)
for _, edge := range edges {
if _, exists := nodeMap[edge.SourceNodeKey]; !exists {
problems = append(problems, fmt.Sprintf("连线起点不存在:%s", edge.SourceNodeKey))
}
if _, exists := nodeMap[edge.TargetNodeKey]; !exists {
problems = append(problems, fmt.Sprintf("连线终点不存在:%s", edge.TargetNodeKey))
}
if len(edge.Condition) > 0 && !json.Valid(edge.Condition) {
problems = append(problems, fmt.Sprintf("连线 %s -> %s 的条件不是有效 JSON", edge.SourceNodeKey, edge.TargetNodeKey))
}
adjacency[edge.SourceNodeKey] = append(adjacency[edge.SourceNodeKey], edge.TargetNodeKey)
outgoing[edge.SourceNodeKey]++
}
for _, node := range nodes {
if node.Type != "finish" && node.Type != "escalate" && outgoing[node.NodeKey] == 0 {
problems = append(problems, fmt.Sprintf("节点“%s”没有下一步", node.Title))
}
}
visited := map[string]bool{}
var walk func(string)
walk = func(key string) {
if visited[key] {
return
}
visited[key] = true
for _, next := range adjacency[key] {
walk(next)
}
}
if startNodeKey != "" {
walk(startNodeKey)
}
for key, node := range nodeMap {
if !visited[key] {
problems = append(problems, fmt.Sprintf("节点“%s”无法从开始节点到达", node.Title))
}
}
return problems
}