feat: simplify SOP to immediate-effect configuration
This commit is contained in:
280
internal/scenario/contract.go
Normal file
280
internal/scenario/contract.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package scenario
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type contractInput struct {
|
||||
OutputSchema map[string]interface{} `json:"output_schema"`
|
||||
ResultSchema map[string]interface{} `json:"result_schema"`
|
||||
Rules []ruleInput `json:"rules"`
|
||||
AllowedOrigins []string `json:"allowed_origins"`
|
||||
}
|
||||
|
||||
type ruleInput struct {
|
||||
RuleKey string `json:"rule_key"`
|
||||
Name string `json:"name"`
|
||||
Condition map[string]interface{} `json:"condition"`
|
||||
Actions []map[string]interface{} `json:"actions"`
|
||||
Priority int `json:"priority"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func (h *Handler) GetContract(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
id, ok := contractScenarioID(c)
|
||||
if !ok || !access.CanViewScenario(h.db, p, id) {
|
||||
if ok {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
|
||||
}
|
||||
return
|
||||
}
|
||||
var scenario model.Scenario
|
||||
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&scenario).Error; err != nil {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
|
||||
return
|
||||
}
|
||||
rules := make([]model.ScenarioRule, 0)
|
||||
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Order("priority,id").Find(&rules).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景规则失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"input_schema": scenario.InputSchema, "output_schema": scenario.OutputSchema, "result_schema": scenario.ResultSchema, "scenario_key": scenario.ScenarioKey, "public_key": scenario.PublicKey, "allowed_origins": scenario.AllowedOrigins, "rules": rules})
|
||||
}
|
||||
|
||||
func (h *Handler) ReplaceContract(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
id, ok := contractScenarioID(c)
|
||||
if !ok || !access.CanEditScenario(h.db, p, id) {
|
||||
if ok {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可编辑")
|
||||
}
|
||||
return
|
||||
}
|
||||
var input contractInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "契约 JSON 格式不正确")
|
||||
return
|
||||
}
|
||||
if input.OutputSchema == nil {
|
||||
input.OutputSchema = map[string]interface{}{"fields": []interface{}{}}
|
||||
}
|
||||
if input.ResultSchema == nil {
|
||||
input.ResultSchema = map[string]interface{}{"fields": []interface{}{}}
|
||||
}
|
||||
if err := validateContractInput(input); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
|
||||
return
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, rule := range input.Rules {
|
||||
if !fieldKeyPattern.MatchString(rule.RuleKey) || rule.Name == "" || seen[rule.RuleKey] {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "规则标识必须唯一且格式正确")
|
||||
return
|
||||
}
|
||||
seen[rule.RuleKey] = true
|
||||
if rule.Status != "" && rule.Status != "active" && rule.Status != "disabled" {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "规则状态不正确")
|
||||
return
|
||||
}
|
||||
}
|
||||
outputRaw, _ := json.Marshal(input.OutputSchema)
|
||||
resultRaw, _ := json.Marshal(input.ResultSchema)
|
||||
originsRaw, _ := json.Marshal(input.AllowedOrigins)
|
||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
var oldRules []model.ScenarioRule
|
||||
if err := tx.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Find(&oldRules).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Updates(map[string]interface{}{"output_schema": datatypes.JSON(outputRaw), "result_schema": datatypes.JSON(resultRaw), "allowed_origins": datatypes.JSON(originsRaw)}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Delete(&model.ScenarioRule{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rule := range input.Rules {
|
||||
condition, _ := json.Marshal(rule.Condition)
|
||||
actions, _ := json.Marshal(rule.Actions)
|
||||
status := rule.Status
|
||||
if status == "" {
|
||||
status = "active"
|
||||
}
|
||||
row := model.ScenarioRule{TenantID: p.TenantID, ScenarioID: id, RuleKey: rule.RuleKey, Name: rule.Name, Condition: datatypes.JSON(condition), Actions: datatypes.JSON(actions), Priority: rule.Priority, Status: status}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := audit.RecordTx(tx, p, "create", "scenario_rule", row.ID, gin.H{"scenario_id": id, "rule_key": row.RuleKey}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, rule := range oldRules {
|
||||
if err := audit.RecordTx(tx, p, "archive", "scenario_rule", rule.ID, gin.H{"scenario_id": id, "rule_key": rule.RuleKey, "name": rule.Name, "priority": rule.Priority, "status": rule.Status}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return audit.RecordTx(tx, p, "update", "scenario", id, gin.H{"contract": true})
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存场景契约失败")
|
||||
return
|
||||
}
|
||||
h.GetContract(c)
|
||||
}
|
||||
|
||||
func validateContractInput(input contractInput) error {
|
||||
fields, ok := input.OutputSchema["fields"].([]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("output_schema.fields 必须是数组")
|
||||
}
|
||||
outputKeys := map[string]bool{}
|
||||
allowedSources := map[string]bool{"input": true, "derived": true, "knowledge": true, "form": true, "system": true}
|
||||
for index, raw := range fields {
|
||||
field, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("第 %d 个输出字段必须是对象", index+1)
|
||||
}
|
||||
key, _ := field["key"].(string)
|
||||
if !fieldKeyPattern.MatchString(key) || outputKeys[key] {
|
||||
return fmt.Errorf("输出字段标识必须唯一且格式正确")
|
||||
}
|
||||
outputKeys[key] = true
|
||||
source, _ := field["source"].(string)
|
||||
if source == "" {
|
||||
source = "input"
|
||||
}
|
||||
if !allowedSources[source] {
|
||||
return fmt.Errorf("输出字段 %s 使用了不支持的来源 %s", key, source)
|
||||
}
|
||||
if sourceField, exists := field["source_field"]; exists {
|
||||
value, ok := sourceField.(string)
|
||||
if !ok || !fieldKeyPattern.MatchString(value) {
|
||||
return fmt.Errorf("输出字段 %s 的 source_field 格式不正确", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := resultcontract.ValidateSchema(input.ResultSchema); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, origin := range input.AllowedOrigins {
|
||||
if origin == "" || (origin != "*" && !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://")) {
|
||||
return fmt.Errorf("允许域名必须是完整的 http/https Origin")
|
||||
}
|
||||
}
|
||||
for _, rule := range input.Rules {
|
||||
if len(rule.Condition) == 0 {
|
||||
return fmt.Errorf("规则 %s 的条件不能为空", rule.RuleKey)
|
||||
}
|
||||
if err := validateContractCondition(rule.Condition, 0); err != nil {
|
||||
return fmt.Errorf("规则 %s 条件不正确: %w", rule.RuleKey, err)
|
||||
}
|
||||
if len(rule.Actions) == 0 {
|
||||
return fmt.Errorf("规则 %s 至少需要一个动作", rule.RuleKey)
|
||||
}
|
||||
for _, action := range rule.Actions {
|
||||
operation, _ := action["operation"].(string)
|
||||
field, _ := action["field"].(string)
|
||||
if operation != "set" && operation != "append" {
|
||||
return fmt.Errorf("规则 %s 使用了不支持的动作 %s", rule.RuleKey, operation)
|
||||
}
|
||||
if !fieldKeyPattern.MatchString(field) {
|
||||
return fmt.Errorf("规则 %s 的动作目标字段格式不正确", rule.RuleKey)
|
||||
}
|
||||
valuePresent := false
|
||||
if _, exists := action["value"]; exists {
|
||||
valuePresent = true
|
||||
}
|
||||
if source, exists := action["value_from"]; exists {
|
||||
value, ok := source.(string)
|
||||
if !ok || !contextFieldPattern(value) {
|
||||
return fmt.Errorf("规则 %s 的 value_from 格式不正确", rule.RuleKey)
|
||||
}
|
||||
valuePresent = true
|
||||
}
|
||||
if !valuePresent {
|
||||
return fmt.Errorf("规则 %s 的动作缺少 value", rule.RuleKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateContractCondition(value interface{}, depth int) error {
|
||||
if depth > 12 {
|
||||
return fmt.Errorf("条件嵌套层级过深")
|
||||
}
|
||||
rule, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("条件必须是对象")
|
||||
}
|
||||
groups := 0
|
||||
for _, key := range []string{"all", "any"} {
|
||||
if raw, exists := rule[key]; exists {
|
||||
groups++
|
||||
items, ok := raw.([]interface{})
|
||||
if !ok || len(items) == 0 || len(items) > 100 {
|
||||
return fmt.Errorf("%s 条件必须是非空数组且最多包含 100 项", key)
|
||||
}
|
||||
for _, item := range items {
|
||||
if err := validateContractCondition(item, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if groups > 1 {
|
||||
return fmt.Errorf("条件不能同时包含 all 和 any")
|
||||
}
|
||||
if groups == 1 {
|
||||
return nil
|
||||
}
|
||||
field, _ := rule["field"].(string)
|
||||
operator, _ := rule["operator"].(string)
|
||||
allowedOperators := map[string]bool{"equals": true, "not_equals": true, "contains": true, "greater_than": true, "less_than": true, "exists": true, "not_exists": true, "in": true}
|
||||
if !contextFieldPattern(field) {
|
||||
return fmt.Errorf("条件字段格式不正确")
|
||||
}
|
||||
if !allowedOperators[operator] {
|
||||
return fmt.Errorf("不支持的条件运算符 %s", operator)
|
||||
}
|
||||
if operator != "exists" && operator != "not_exists" {
|
||||
if _, exists := rule["value"]; !exists {
|
||||
return fmt.Errorf("条件缺少 value")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func contextFieldPattern(value string) bool {
|
||||
if fieldKeyPattern.MatchString(value) {
|
||||
return true
|
||||
}
|
||||
for _, prefix := range []string{"input.", "derived.", "form."} {
|
||||
if strings.HasPrefix(value, prefix) && fieldKeyPattern.MatchString(strings.TrimPrefix(value, prefix)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func contractScenarioID(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
|
||||
}
|
||||
71
internal/scenario/contract_test.go
Normal file
71
internal/scenario/contract_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package scenario
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateContractInput(t *testing.T) {
|
||||
valid := contractInput{
|
||||
OutputSchema: map[string]interface{}{"fields": []interface{}{
|
||||
map[string]interface{}{"key": "matched_topics", "source": "derived", "source_field": "topics"},
|
||||
}},
|
||||
ResultSchema: map[string]interface{}{"fields": []interface{}{
|
||||
map[string]interface{}{"key": "selected_products", "name": "成交商品", "type": "array", "items": map[string]interface{}{"type": "string"}},
|
||||
}},
|
||||
Rules: []ruleInput{{
|
||||
RuleKey: "match_topic", Name: "匹配主题",
|
||||
Condition: map[string]interface{}{"field": "product_tags", "operator": "contains", "value": "hot"},
|
||||
Actions: []map[string]interface{}{{"operation": "append", "field": "topics", "value": "topic_hot"}},
|
||||
}},
|
||||
AllowedOrigins: []string{"https://crm.example.com"},
|
||||
}
|
||||
if err := validateContractInput(valid); err != nil {
|
||||
t.Fatalf("valid contract rejected: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
edit func(*contractInput)
|
||||
want string
|
||||
}{
|
||||
{name: "duplicate output", edit: func(input *contractInput) {
|
||||
input.OutputSchema["fields"] = append(input.OutputSchema["fields"].([]interface{}), map[string]interface{}{"key": "matched_topics"})
|
||||
}, want: "输出字段标识"},
|
||||
{name: "invalid source", edit: func(input *contractInput) {
|
||||
input.OutputSchema["fields"].([]interface{})[0].(map[string]interface{})["source"] = "script"
|
||||
}, want: "不支持的来源"},
|
||||
{name: "invalid condition", edit: func(input *contractInput) { input.Rules[0].Condition = map[string]interface{}{"all": []interface{}{}} }, want: "非空数组"},
|
||||
{name: "invalid action", edit: func(input *contractInput) { input.Rules[0].Actions[0]["operation"] = "execute" }, want: "不支持的动作"},
|
||||
{name: "invalid origin", edit: func(input *contractInput) { input.AllowedOrigins = []string{"crm.example.com"} }, want: "完整的 http/https Origin"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
input := cloneContractInput(valid)
|
||||
test.edit(&input)
|
||||
if err := validateContractInput(input); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("error = %v, want containing %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func cloneContractInput(input contractInput) contractInput {
|
||||
field := input.OutputSchema["fields"].([]interface{})[0].(map[string]interface{})
|
||||
fieldCopy := map[string]interface{}{}
|
||||
for key, value := range field {
|
||||
fieldCopy[key] = value
|
||||
}
|
||||
rule := input.Rules[0]
|
||||
condition := map[string]interface{}{}
|
||||
for key, value := range rule.Condition {
|
||||
condition[key] = value
|
||||
}
|
||||
action := map[string]interface{}{}
|
||||
for key, value := range rule.Actions[0] {
|
||||
action[key] = value
|
||||
}
|
||||
rule.Condition = condition
|
||||
rule.Actions = []map[string]interface{}{action}
|
||||
return contractInput{OutputSchema: map[string]interface{}{"fields": []interface{}{fieldCopy}}, ResultSchema: input.ResultSchema, Rules: []ruleInput{rule}, AllowedOrigins: append([]string{}, input.AllowedOrigins...)}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user