feat: harden pet consultation SOP execution
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
@@ -108,6 +109,7 @@ func (h *Handler) Answer(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
NodeKey string `json:"node_key"`
|
||||
Answers map[string]interface{} `json:"answers"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
@@ -116,12 +118,26 @@ func (h *Handler) Answer(c *gin.Context) {
|
||||
}
|
||||
var updated model.SOPRun
|
||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses().Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&updated).Error; err != nil {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&updated).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if updated.Status != "running" {
|
||||
return errors.New("run is not active")
|
||||
}
|
||||
if input.NodeKey != "" && input.NodeKey != updated.CurrentNodeKey {
|
||||
return errors.New("当前步骤已经变化,请刷新后重试")
|
||||
}
|
||||
var currentNode model.SOPNode
|
||||
if err := tx.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", updated.SOPVersionID, updated.CurrentNodeKey, p.TenantID).First(¤tNode).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var fields []model.ScenarioField
|
||||
if err := tx.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", updated.SOPID, p.TenantID).Order("sf.sort_order, sf.id").Find(&fields).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateNodeAnswers(currentNode, fields, input.Answers); err != nil {
|
||||
return err
|
||||
}
|
||||
answers := map[string]interface{}{}
|
||||
if len(updated.Answers) > 0 {
|
||||
_ = json.Unmarshal(updated.Answers, &answers)
|
||||
|
||||
174
internal/run/validation.go
Normal file
174
internal/run/validation.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
)
|
||||
|
||||
type answerNodeConfig struct {
|
||||
FieldKey string `json:"field_key"`
|
||||
FieldKeys []string `json:"field_keys"`
|
||||
Required bool `json:"required"`
|
||||
}
|
||||
|
||||
func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answers map[string]interface{}) error {
|
||||
var config answerNodeConfig
|
||||
if len(node.Config) > 0 {
|
||||
if err := json.Unmarshal(node.Config, &config); err != nil {
|
||||
return fmt.Errorf("当前节点配置不正确")
|
||||
}
|
||||
}
|
||||
fieldMap := make(map[string]model.ScenarioField, len(fields))
|
||||
for _, field := range fields {
|
||||
fieldMap[field.FieldKey] = field
|
||||
}
|
||||
expected := map[string]bool{}
|
||||
switch node.Type {
|
||||
case "question", "choice":
|
||||
if config.FieldKey == "" {
|
||||
return fmt.Errorf("当前节点没有配置采集字段")
|
||||
}
|
||||
expected[config.FieldKey] = config.Required
|
||||
case "form":
|
||||
if len(config.FieldKeys) == 0 {
|
||||
return fmt.Errorf("当前表单没有配置采集字段")
|
||||
}
|
||||
for _, key := range config.FieldKeys {
|
||||
expected[key] = false
|
||||
}
|
||||
default:
|
||||
if len(answers) > 0 {
|
||||
return fmt.Errorf("当前节点不接受字段回答")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for key := range answers {
|
||||
if _, ok := expected[key]; !ok {
|
||||
return fmt.Errorf("字段 %s 不属于当前节点", key)
|
||||
}
|
||||
}
|
||||
for key, nodeRequired := range expected {
|
||||
field, exists := fieldMap[key]
|
||||
if !exists {
|
||||
return fmt.Errorf("字段 %s 不存在", key)
|
||||
}
|
||||
value, provided := answers[key]
|
||||
required := field.Required || nodeRequired
|
||||
if !provided || isEmptyValue(value) {
|
||||
if required {
|
||||
return fmt.Errorf("请填写%s", field.FieldName)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := validateFieldValue(field, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFieldValue(field model.ScenarioField, value interface{}) error {
|
||||
switch field.FieldType {
|
||||
case "text", "textarea":
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s必须是文本", field.FieldName)
|
||||
}
|
||||
var rules struct {
|
||||
MinLength int `json:"min_length"`
|
||||
MaxLength int `json:"max_length"`
|
||||
}
|
||||
_ = json.Unmarshal(field.Validation, &rules)
|
||||
length := len([]rune(text))
|
||||
if rules.MinLength > 0 && length < rules.MinLength {
|
||||
return fmt.Errorf("%s不能少于%d个字符", field.FieldName, rules.MinLength)
|
||||
}
|
||||
if rules.MaxLength > 0 && length > rules.MaxLength {
|
||||
return fmt.Errorf("%s不能超过%d个字符", field.FieldName, rules.MaxLength)
|
||||
}
|
||||
case "number":
|
||||
number, ok := toFloat(value)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s必须是数字", field.FieldName)
|
||||
}
|
||||
var rules struct {
|
||||
Min *float64 `json:"min"`
|
||||
Max *float64 `json:"max"`
|
||||
}
|
||||
_ = json.Unmarshal(field.Validation, &rules)
|
||||
if rules.Min != nil && number < *rules.Min {
|
||||
return fmt.Errorf("%s不能小于%v", field.FieldName, *rules.Min)
|
||||
}
|
||||
if rules.Max != nil && number > *rules.Max {
|
||||
return fmt.Errorf("%s不能大于%v", field.FieldName, *rules.Max)
|
||||
}
|
||||
case "boolean":
|
||||
if _, ok := value.(bool); !ok {
|
||||
return fmt.Errorf("%s必须选择是或否", field.FieldName)
|
||||
}
|
||||
case "select":
|
||||
selected, ok := value.(string)
|
||||
if !ok || !optionAllowed(field.Options, selected) {
|
||||
return fmt.Errorf("%s的选项不正确", field.FieldName)
|
||||
}
|
||||
case "multiselect":
|
||||
values, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("%s必须是多选值", field.FieldName)
|
||||
}
|
||||
for _, item := range values {
|
||||
selected, ok := item.(string)
|
||||
if !ok || !optionAllowed(field.Options, selected) {
|
||||
return fmt.Errorf("%s包含不正确的选项", field.FieldName)
|
||||
}
|
||||
}
|
||||
case "date":
|
||||
text, ok := value.(string)
|
||||
if !ok || !validDate(text) {
|
||||
return fmt.Errorf("%s的日期格式不正确", field.FieldName)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%s的字段类型不支持", field.FieldName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func optionAllowed(raw []byte, selected string) bool {
|
||||
var options []string
|
||||
if err := json.Unmarshal(raw, &options); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, option := range options {
|
||||
if option == selected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validDate(value string) bool {
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02"} {
|
||||
if _, err := time.Parse(layout, value); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isEmptyValue(value interface{}) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
}
|
||||
if text, ok := value.(string); ok {
|
||||
return strings.TrimSpace(text) == ""
|
||||
}
|
||||
if values, ok := value.([]interface{}); ok {
|
||||
return len(values) == 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
48
internal/run/validation_test.go
Normal file
48
internal/run/validation_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
func TestValidateNodeAnswers(t *testing.T) {
|
||||
fields := []model.ScenarioField{
|
||||
{FieldKey: "pet_name", FieldName: "宠物名称", FieldType: "text", Required: true, Validation: datatypes.JSON([]byte(`{"max_length":20}`))},
|
||||
{FieldKey: "pet_weight", FieldName: "体重", FieldType: "number", Validation: datatypes.JSON([]byte(`{"min":0.01,"max":200}`))},
|
||||
{FieldKey: "pet_type", FieldName: "宠物种类", FieldType: "select", Options: datatypes.JSON([]byte(`["犬","猫"]`))},
|
||||
}
|
||||
node := model.SOPNode{Type: "form", Config: datatypes.JSON([]byte(`{"field_keys":["pet_name","pet_weight","pet_type"]}`))}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
answers map[string]interface{}
|
||||
want string
|
||||
}{
|
||||
{name: "valid", answers: map[string]interface{}{"pet_name": "豆包", "pet_weight": 5.2, "pet_type": "犬"}},
|
||||
{name: "missing required", answers: map[string]interface{}{"pet_weight": 5.2}, want: "请填写宠物名称"},
|
||||
{name: "unknown field", answers: map[string]interface{}{"pet_name": "豆包", "owner_phone": "123"}, want: "不属于当前节点"},
|
||||
{name: "invalid number", answers: map[string]interface{}{"pet_name": "豆包", "pet_weight": 0.0}, want: "不能小于"},
|
||||
{name: "invalid option", answers: map[string]interface{}{"pet_name": "豆包", "pet_type": "兔"}, want: "选项不正确"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := validateNodeAnswers(node, fields, test.answers)
|
||||
if test.want == "" && err != nil {
|
||||
t.Fatalf("validateNodeAnswers() error = %v", err)
|
||||
}
|
||||
if test.want != "" && (err == nil || !strings.Contains(err.Error(), test.want)) {
|
||||
t.Fatalf("validateNodeAnswers() error = %v, want containing %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNodeAnswersRejectsAnswersForMessage(t *testing.T) {
|
||||
err := validateNodeAnswers(model.SOPNode{Type: "message", Config: datatypes.JSON([]byte(`{}`))}, nil, map[string]interface{}{"pet_name": "豆包"})
|
||||
if err == nil || !strings.Contains(err.Error(), "不接受字段回答") {
|
||||
t.Fatalf("validateNodeAnswers() error = %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user