package scenario import ( "encoding/json" "errors" "net/http" "regexp" "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" "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 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"` } 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"` Required bool `json:"required"` Options json.RawMessage `json:"options"` Validation json.RawMessage `json:"validation"` SortOrder int `json:"sort_order"` } var fieldKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`) func (h *Handler) List(c *gin.Context) { p, _ := auth.PrincipalFromContext(c) items := make([]model.Scenario, 0) query := access.ScopeScenarios(h.db.Model(&model.Scenario{}), p, "scenarios").Where("scenarios.status <> ?", "archived") if keyword := c.Query("keyword"); keyword != "" { query = query.Where("scenarios.name LIKE ? OR scenarios.industry LIKE ?", "%"+keyword+"%", "%"+keyword+"%") } if status := c.Query("status"); status != "" { query = query.Where("status = ?", status) } var total int64 if err := query.Count(&total).Error; err != nil { response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景失败") return } if err := query.Order("scenarios.updated_at DESC").Find(&items).Error; err != nil { response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景失败") return } response.OK(c, gin.H{"items": items, "total": total}) } func (h *Handler) Create(c *gin.Context) { p, _ := auth.PrincipalFromContext(c) var input scenarioInput if err := c.ShouldBindJSON(&input); err != nil { response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "场景信息不完整") return } visibility := input.Visibility 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} if err := h.db.Create(&item).Error; err != nil { response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建场景失败") return } _ = audit.Record(h.db, p, "create", "scenario", item.ID, input) response.Created(c, item) } func (h *Handler) Get(c *gin.Context) { p, _ := auth.PrincipalFromContext(c) id, ok := idParam(c, "id") if !ok { return } if !access.CanViewScenario(h.db, p, id) { response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在") return } var item model.Scenario if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&item).Error; err != nil { notFound(c, err, "场景不存在") return } 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) if auth.HasPermission(p, "sop.view") { h.db.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Order("updated_at DESC").Find(&sops) } response.OK(c, gin.H{"scenario": item, "fields": fields, "sops": sops}) } func (h *Handler) Update(c *gin.Context) { p, _ := auth.PrincipalFromContext(c) id, ok := idParam(c, "id") if !ok { return } if !access.CanEditScenario(h.db, p, id) { response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可编辑") return } var input scenarioInput if err := c.ShouldBindJSON(&input); err != nil { response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "场景信息不完整") return } visibility := input.Visibility if visibility == "" { 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} 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", "场景不存在") return } _ = audit.Record(h.db, p, "update", "scenario", id, input) h.Get(c) } func (h *Handler) Archive(c *gin.Context) { p, _ := auth.PrincipalFromContext(c) id, ok := idParam(c, "id") if !ok { return } if !access.CanEditScenario(h.db, p, id) { 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 } return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Update("status", "archived").Error }) if err != nil { response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在") return } _ = audit.Record(h.db, p, "archive", "scenario", id, nil) response.OK(c, gin.H{"id": id}) } func (h *Handler) CreateField(c *gin.Context) { p, _ := auth.PrincipalFromContext(c) scenarioID, ok := idParam(c, "id") if !ok || !access.CanEditScenario(h.db, p, scenarioID) { if ok { response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在") } return } var input fieldInput if err := c.ShouldBindJSON(&input); err != nil { response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "字段配置不正确") return } if err := validateFieldInput(input); err != nil { 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} if err := h.db.Create(&item).Error; err != nil { response.Error(c, http.StatusConflict, "CREATE_FAILED", "字段标识已存在或配置不正确") return } _ = audit.Record(h.db, p, "create", "scenario_field", item.ID, input) response.Created(c, item) } func (h *Handler) UpdateField(c *gin.Context) { p, _ := auth.PrincipalFromContext(c) id, ok := idParam(c, "fieldId") if !ok { return } var existing model.ScenarioField if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&existing).Error; err != nil || !access.CanEditScenario(h.db, p, existing.ScenarioID) { response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在或不可编辑") return } if h.fieldReferencedByReleasedSOP(existing.ScenarioID, existing.FieldKey, p.TenantID) { response.Error(c, http.StatusConflict, "FIELD_IN_USE", "字段已被发布版本引用,不能修改;请新增字段并创建 SOP 新版本") return } var input fieldInput if err := c.ShouldBindJSON(&input); err != nil { response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "字段配置不正确") return } if err := validateFieldInput(input); err != nil { 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} 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 } _ = 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) response.OK(c, item) } func (h *Handler) DeleteField(c *gin.Context) { p, _ := auth.PrincipalFromContext(c) id, ok := idParam(c, "fieldId") if !ok { return } var existing model.ScenarioField if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&existing).Error; err != nil || !access.CanEditScenario(h.db, p, existing.ScenarioID) { response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在或不可删除") return } if h.fieldReferencedByReleasedSOP(existing.ScenarioID, existing.FieldKey, p.TenantID) { response.Error(c, http.StatusConflict, "FIELD_IN_USE", "字段已被发布版本引用,不能删除") return } result := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).Delete(&model.ScenarioField{}) if result.Error != nil || result.RowsAffected == 0 { response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在") return } _ = audit.Record(h.db, p, "delete", "scenario_field", id, nil) response.OK(c, gin.H{"id": id}) } func normalizedJSON(value json.RawMessage, fallback string) datatypes.JSON { if len(value) == 0 || !json.Valid(value) { return datatypes.JSON([]byte(fallback)) } return datatypes.JSON(value) } func validateFieldInput(input fieldInput) error { if !fieldKeyPattern.MatchString(input.FieldKey) { return errors.New("字段标识必须以字母开头,且只能包含字母、数字和下划线") } if len(input.Options) > 0 { var options []string if err := json.Unmarshal(input.Options, &options); err != nil || options == nil { return errors.New("字段选项必须是文本数组") } seen := map[string]bool{} for _, option := range options { if strings.TrimSpace(option) == "" { return errors.New("字段选项不能为空") } normalized := strings.TrimSpace(option) if seen[normalized] { return errors.New("字段选项不能重复") } seen[normalized] = true } if (input.FieldType == "select" || input.FieldType == "multiselect") && len(options) == 0 { return errors.New("单选或多选字段至少需要一个选项") } } else if input.FieldType == "select" || input.FieldType == "multiselect" { return errors.New("单选或多选字段至少需要一个选项") } if len(input.Validation) > 0 { var validation map[string]json.RawMessage if err := json.Unmarshal(input.Validation, &validation); err != nil || validation == nil { return errors.New("字段校验规则必须是 JSON 对象") } if err := validateFieldRules(input.FieldType, validation); err != nil { return err } } return nil } func validateFieldRules(fieldType string, validation map[string]json.RawMessage) error { allowed := map[string]bool{} switch fieldType { case "number": allowed["min"], allowed["max"] = true, true case "text", "textarea": allowed["min_length"], allowed["max_length"] = true, true } for key := range validation { if !allowed[key] { return errors.New("当前字段类型不支持校验规则 " + key) } } if fieldType == "number" { min, hasMin, err := jsonNumber(validation["min"]) if err != nil { return errors.New("最小值必须是数字") } max, hasMax, err := jsonNumber(validation["max"]) if err != nil { return errors.New("最大值必须是数字") } if hasMin && hasMax && min > max { return errors.New("最小值不能大于最大值") } } if fieldType == "text" || fieldType == "textarea" { min, hasMin, err := jsonInteger(validation["min_length"]) if err != nil || (hasMin && min < 0) { return errors.New("最少字符必须是非负整数") } max, hasMax, err := jsonInteger(validation["max_length"]) if err != nil || (hasMax && max < 1) { return errors.New("最多字符必须是正整数") } if hasMin && hasMax && min > max { return errors.New("最少字符不能大于最多字符") } } return nil } func jsonNumber(raw json.RawMessage) (float64, bool, error) { if len(raw) == 0 { return 0, false, nil } var value float64 err := json.Unmarshal(raw, &value) return value, true, err } func jsonInteger(raw json.RawMessage) (int, bool, error) { if len(raw) == 0 { return 0, false, nil } var value int err := json.Unmarshal(raw, &value) return value, true, err } func idParam(c *gin.Context, key string) (uint64, bool) { id, err := strconv.ParseUint(c.Param(key), 10, 64) if err != nil || id == 0 { response.Error(c, http.StatusBadRequest, "INVALID_ID", "资源 ID 不正确") return 0, false } return id, true } func notFound(c *gin.Context, err error, message string) { if errors.Is(err, gorm.ErrRecordNotFound) { response.Error(c, http.StatusNotFound, "NOT_FOUND", message) return } response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询失败") }