431 lines
16 KiB
Go
431 lines
16 KiB
Go
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"
|
|
"github.com/google/uuid"
|
|
"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"`
|
|
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"`
|
|
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"`
|
|
SortOrder int `json:"sort_order"`
|
|
}
|
|
|
|
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)
|
|
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, 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
|
|
}
|
|
_ = 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)
|
|
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)
|
|
}
|
|
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}
|
|
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", "场景不存在")
|
|
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
|
|
}
|
|
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, 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)
|
|
}
|
|
|
|
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 引用,不能修改;请先调整 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, "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)
|
|
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", "字段已被当前 SOP 引用,不能删除")
|
|
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
|
|
}
|
|
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})
|
|
}
|
|
|
|
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 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 {
|
|
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", "查询失败")
|
|
}
|