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

View File

@@ -14,6 +14,7 @@ import (
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/datatypes"
"gorm.io/gorm"
)
@@ -27,18 +28,21 @@ func NewHandler(db *gorm.DB) *Handler {
}
type scenarioInput struct {
Name string `json:"name" binding:"required,max=128"`
Industry string `json:"industry" binding:"required,max=64"`
RoleName string `json:"role_name" binding:"required,max=64"`
Goal string `json:"goal" binding:"required,max=2000"`
TriggerText string `json:"trigger_text" binding:"required,max=2000"`
Visibility string `json:"visibility" binding:"omitempty,oneof=private team tenant"`
Name string `json:"name" binding:"required,max=128"`
Industry string `json:"industry" binding:"required,max=64"`
RoleName string `json:"role_name" binding:"required,max=64"`
Goal string `json:"goal" binding:"required,max=2000"`
TriggerText string `json:"trigger_text" binding:"required,max=2000"`
Visibility string `json:"visibility" binding:"omitempty,oneof=private team tenant"`
OutputSchema json.RawMessage `json:"output_schema"`
ResultSchema json.RawMessage `json:"result_schema"`
}
type fieldInput struct {
FieldKey string `json:"field_key" binding:"required,max=64"`
FieldName string `json:"field_name" binding:"required,max=128"`
FieldType string `json:"field_type" binding:"required,oneof=text textarea number boolean select multiselect date"`
SourcePath string `json:"source_path" binding:"max=255"`
FieldType string `json:"field_type" binding:"required,oneof=text textarea number boolean select multiselect array date"`
Required bool `json:"required"`
Options json.RawMessage `json:"options"`
Validation json.RawMessage `json:"validation"`
@@ -46,6 +50,24 @@ type fieldInput struct {
}
var fieldKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`)
var sourcePathPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*(?:\.(?:[A-Za-z][A-Za-z0-9_]*|\*)|\[(?:\d+|\*)\])*$`)
func buildInputSchema(fields []model.ScenarioField) datatypes.JSON {
items := make([]gin.H, 0, len(fields))
for _, field := range fields {
items = append(items, gin.H{"key": field.FieldKey, "name": field.FieldName, "type": field.FieldType, "source_path": field.SourcePath, "required": field.Required, "options": field.Options, "validation": field.Validation})
}
raw, _ := json.Marshal(gin.H{"fields": items})
return datatypes.JSON(raw)
}
func syncInputSchema(tx *gorm.DB, tenantID, scenarioID uint64) error {
fields := make([]model.ScenarioField, 0)
if err := tx.Where("tenant_id = ? AND scenario_id = ?", tenantID, scenarioID).Order("sort_order, id").Find(&fields).Error; err != nil {
return err
}
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", scenarioID, tenantID).Update("input_schema", buildInputSchema(fields)).Error
}
func (h *Handler) List(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
@@ -80,7 +102,7 @@ func (h *Handler) Create(c *gin.Context) {
if visibility == "" {
visibility = "tenant"
}
item := model.Scenario{TenantID: p.TenantID, Name: input.Name, Industry: input.Industry, RoleName: input.RoleName, Goal: input.Goal, TriggerText: input.TriggerText, Visibility: visibility, Status: "draft", CreatedBy: p.UserID}
item := model.Scenario{TenantID: p.TenantID, ScenarioKey: "scenario-" + uuid.NewString(), PublicKey: "pk_" + uuid.NewString(), AllowedOrigins: datatypes.JSON([]byte(`[]`)), Name: input.Name, Industry: input.Industry, RoleName: input.RoleName, Goal: input.Goal, TriggerText: input.TriggerText, Visibility: visibility, Status: "draft", CreatedBy: p.UserID, InputSchema: datatypes.JSON([]byte(`{"fields":[]}`)), OutputSchema: normalizedJSON(input.OutputSchema, `{"fields":[]}`), ResultSchema: normalizedJSON(input.ResultSchema, `{"fields":[]}`)}
if err := h.db.Create(&item).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建场景失败")
return
@@ -107,6 +129,7 @@ func (h *Handler) Get(c *gin.Context) {
fields := make([]model.ScenarioField, 0)
sops := make([]model.SOP, 0)
h.db.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Order("sort_order, id").Find(&fields)
item.InputSchema = buildInputSchema(fields)
if auth.HasPermission(p, "sop.view") {
h.db.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Order("updated_at DESC").Find(&sops)
}
@@ -133,6 +156,12 @@ func (h *Handler) Update(c *gin.Context) {
visibility = "tenant"
}
updates := map[string]interface{}{"name": input.Name, "industry": input.Industry, "role_name": input.RoleName, "goal": input.Goal, "trigger_text": input.TriggerText, "visibility": visibility}
if len(input.OutputSchema) > 0 {
updates["output_schema"] = normalizedJSON(input.OutputSchema, `{"fields":[]}`)
}
if len(input.ResultSchema) > 0 {
updates["result_schema"] = normalizedJSON(input.ResultSchema, `{"fields":[]}`)
}
result := h.db.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ? AND status <> ?", id, p.TenantID, "archived").Updates(updates)
if result.Error != nil || result.RowsAffected == 0 {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
@@ -152,15 +181,6 @@ func (h *Handler) Archive(c *gin.Context) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可归档")
return
}
var activeSOPs int64
if err := h.db.Model(&model.SOP{}).Where("scenario_id = ? AND tenant_id = ? AND status IN ?", id, p.TenantID, []string{"published", "reviewing"}).Count(&activeSOPs).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "检查场景状态失败")
return
}
if activeSOPs > 0 {
response.Error(c, http.StatusConflict, "SCENARIO_IN_USE", "请先下线已发布 SOP 或处理审核任务")
return
}
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.SOP{}).Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Update("status", "archived").Error; err != nil {
return err
@@ -193,11 +213,15 @@ func (h *Handler) CreateField(c *gin.Context) {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
return
}
item := model.ScenarioField{TenantID: p.TenantID, ScenarioID: scenarioID, FieldKey: input.FieldKey, FieldName: input.FieldName, FieldType: input.FieldType, Required: input.Required, Options: normalizedJSON(input.Options, `[]`), Validation: normalizedJSON(input.Validation, `{}`), SortOrder: input.SortOrder}
item := model.ScenarioField{TenantID: p.TenantID, ScenarioID: scenarioID, FieldKey: input.FieldKey, FieldName: input.FieldName, SourcePath: input.SourcePath, FieldType: input.FieldType, Required: input.Required, Options: normalizedJSON(input.Options, `[]`), Validation: normalizedJSON(input.Validation, `{}`), SortOrder: input.SortOrder}
if err := h.db.Create(&item).Error; err != nil {
response.Error(c, http.StatusConflict, "CREATE_FAILED", "字段标识已存在或配置不正确")
return
}
if err := syncInputSchema(h.db, p.TenantID, scenarioID); err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "更新输入契约失败")
return
}
_ = audit.Record(h.db, p, "create", "scenario_field", item.ID, input)
response.Created(c, item)
}
@@ -214,7 +238,7 @@ func (h *Handler) UpdateField(c *gin.Context) {
return
}
if h.fieldReferencedByReleasedSOP(existing.ScenarioID, existing.FieldKey, p.TenantID) {
response.Error(c, http.StatusConflict, "FIELD_IN_USE", "字段已被发布版本引用,不能修改;请新增字段并创建 SOP 新版本")
response.Error(c, http.StatusConflict, "FIELD_IN_USE", "字段已被当前 SOP 引用,不能修改;请先调整 SOP")
return
}
var input fieldInput
@@ -226,12 +250,16 @@ func (h *Handler) UpdateField(c *gin.Context) {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
return
}
updates := map[string]interface{}{"field_key": input.FieldKey, "field_name": input.FieldName, "field_type": input.FieldType, "required": input.Required, "options": normalizedJSON(input.Options, `[]`), "validation": normalizedJSON(input.Validation, `{}`), "sort_order": input.SortOrder}
updates := map[string]interface{}{"field_key": input.FieldKey, "field_name": input.FieldName, "source_path": input.SourcePath, "field_type": input.FieldType, "required": input.Required, "options": normalizedJSON(input.Options, `[]`), "validation": normalizedJSON(input.Validation, `{}`), "sort_order": input.SortOrder}
result := h.db.Model(&model.ScenarioField{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Updates(updates)
if result.Error != nil || result.RowsAffected == 0 {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在")
return
}
if err := syncInputSchema(h.db, p.TenantID, existing.ScenarioID); err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "更新输入契约失败")
return
}
_ = audit.Record(h.db, p, "update", "scenario_field", id, input)
var item model.ScenarioField
h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&item)
@@ -250,7 +278,7 @@ func (h *Handler) DeleteField(c *gin.Context) {
return
}
if h.fieldReferencedByReleasedSOP(existing.ScenarioID, existing.FieldKey, p.TenantID) {
response.Error(c, http.StatusConflict, "FIELD_IN_USE", "字段已被发布版本引用,不能删除")
response.Error(c, http.StatusConflict, "FIELD_IN_USE", "字段已被当前 SOP 引用,不能删除")
return
}
result := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).Delete(&model.ScenarioField{})
@@ -258,7 +286,21 @@ func (h *Handler) DeleteField(c *gin.Context) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在")
return
}
_ = audit.Record(h.db, p, "delete", "scenario_field", id, nil)
if err := syncInputSchema(h.db, p.TenantID, existing.ScenarioID); err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "更新输入契约失败")
return
}
_ = audit.Record(h.db, p, "delete", "scenario_field", id, gin.H{
"scenario_id": existing.ScenarioID,
"field_key": existing.FieldKey,
"field_name": existing.FieldName,
"source_path": existing.SourcePath,
"field_type": existing.FieldType,
"required": existing.Required,
"options": json.RawMessage(existing.Options),
"validation": json.RawMessage(existing.Validation),
"sort_order": existing.SortOrder,
})
response.OK(c, gin.H{"id": id})
}
@@ -273,6 +315,9 @@ func validateFieldInput(input fieldInput) error {
if !fieldKeyPattern.MatchString(input.FieldKey) {
return errors.New("字段标识必须以字母开头,且只能包含字母、数字和下划线")
}
if input.SourcePath != "" && !sourcePathPattern.MatchString(input.SourcePath) {
return errors.New("数据路径格式不正确,例如 customer.name 或 order.items[*].product_id")
}
if len(input.Options) > 0 {
var options []string
if err := json.Unmarshal(input.Options, &options); err != nil || options == nil {