feat: simplify SOP to immediate-effect configuration
This commit is contained in:
196
internal/resultcontract/schema.go
Normal file
196
internal/resultcontract/schema.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package resultcontract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var keyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`)
|
||||
|
||||
type Schema struct {
|
||||
Fields []Field `json:"fields"`
|
||||
}
|
||||
|
||||
type Field struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Required bool `json:"required"`
|
||||
Options []any `json:"options,omitempty"`
|
||||
Fields []Field `json:"fields,omitempty"`
|
||||
Items *Field `json:"items,omitempty"`
|
||||
Min *float64 `json:"min,omitempty"`
|
||||
Max *float64 `json:"max,omitempty"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
func ParseAndValidate(raw []byte) (Schema, error) {
|
||||
var schema Schema
|
||||
if len(raw) == 0 {
|
||||
return schema, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
return schema, fmt.Errorf("result_schema 格式不正确")
|
||||
}
|
||||
if err := validateFields(schema.Fields, 0); err != nil {
|
||||
return schema, err
|
||||
}
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
func ValidateSchema(value map[string]interface{}) error {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("result_schema 格式不正确")
|
||||
}
|
||||
_, err = ParseAndValidate(raw)
|
||||
return err
|
||||
}
|
||||
|
||||
// ValidateResult permits nil and an empty object so a run can finish without a business outcome.
|
||||
func ValidateResult(schema Schema, value map[string]interface{}) error {
|
||||
if len(value) == 0 {
|
||||
return nil
|
||||
}
|
||||
return validateObject(schema.Fields, value, "final_result")
|
||||
}
|
||||
|
||||
func validateFields(fields []Field, depth int) error {
|
||||
if depth > 8 {
|
||||
return fmt.Errorf("result_schema 嵌套层级不能超过 8 层")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, field := range fields {
|
||||
if !keyPattern.MatchString(field.Key) || seen[field.Key] {
|
||||
return fmt.Errorf("结果字段标识必须唯一且格式正确")
|
||||
}
|
||||
seen[field.Key] = true
|
||||
if field.Name == "" {
|
||||
return fmt.Errorf("结果字段 %s 缺少名称", field.Key)
|
||||
}
|
||||
if !supportedType(field.Type) {
|
||||
return fmt.Errorf("结果字段 %s 使用了不支持的类型 %s", field.Key, field.Type)
|
||||
}
|
||||
if field.Type == "object" {
|
||||
if len(field.Fields) == 0 {
|
||||
return fmt.Errorf("对象字段 %s 必须定义 fields", field.Key)
|
||||
}
|
||||
if err := validateFields(field.Fields, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if field.Type == "array" && field.Items != nil {
|
||||
item := *field.Items
|
||||
if item.Type == "" {
|
||||
return fmt.Errorf("数组字段 %s 的 items 必须定义类型", field.Key)
|
||||
}
|
||||
if item.Type == "object" {
|
||||
if len(item.Fields) == 0 {
|
||||
return fmt.Errorf("数组字段 %s 的对象项必须定义 fields", field.Key)
|
||||
}
|
||||
if err := validateFields(item.Fields, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !supportedType(item.Type) || item.Type == "array" {
|
||||
return fmt.Errorf("数组字段 %s 的 items 类型不正确", field.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func supportedType(value string) bool {
|
||||
switch value {
|
||||
case "string", "text", "textarea", "number", "integer", "boolean", "select", "multiselect", "date", "array", "object", "any":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateObject(fields []Field, value map[string]interface{}, path string) error {
|
||||
definitions := make(map[string]Field, len(fields))
|
||||
for _, field := range fields {
|
||||
definitions[field.Key] = field
|
||||
if field.Required {
|
||||
if item, ok := value[field.Key]; !ok || item == nil || item == "" {
|
||||
return fmt.Errorf("%s.%s 为必填字段", path, field.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
for key, item := range value {
|
||||
field, ok := definitions[key]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s.%s 未在结果格式中定义", path, key)
|
||||
}
|
||||
if err := validateValue(field, item, path+"."+key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateValue(field Field, value interface{}, path string) error {
|
||||
if value == nil {
|
||||
if field.Required {
|
||||
return fmt.Errorf("%s 为必填字段", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch field.Type {
|
||||
case "string", "text", "textarea", "select", "date":
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s 必须是字符串", path)
|
||||
}
|
||||
if field.Type == "select" && len(field.Options) > 0 && !contains(field.Options, text) {
|
||||
return fmt.Errorf("%s 不在允许选项中", path)
|
||||
}
|
||||
case "number", "integer":
|
||||
number, ok := value.(float64)
|
||||
if !ok || (field.Type == "integer" && number != float64(int64(number))) {
|
||||
return fmt.Errorf("%s 必须是%s", path, map[bool]string{true: "整数", false: "数字"}[field.Type == "integer"])
|
||||
}
|
||||
if field.Min != nil && number < *field.Min || field.Max != nil && number > *field.Max {
|
||||
return fmt.Errorf("%s 超出允许范围", path)
|
||||
}
|
||||
case "boolean":
|
||||
if _, ok := value.(bool); !ok {
|
||||
return fmt.Errorf("%s 必须是布尔值", path)
|
||||
}
|
||||
case "object":
|
||||
object, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("%s 必须是对象", path)
|
||||
}
|
||||
return validateObject(field.Fields, object, path)
|
||||
case "array", "multiselect":
|
||||
items, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("%s 必须是数组", path)
|
||||
}
|
||||
for index, item := range items {
|
||||
if field.Type == "multiselect" && len(field.Options) > 0 && !contains(field.Options, item) {
|
||||
return fmt.Errorf("%s[%d] 不在允许选项中", path, index)
|
||||
}
|
||||
if field.Items != nil {
|
||||
if err := validateValue(*field.Items, item, fmt.Sprintf("%s[%d]", path, index)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func contains(options []any, value interface{}) bool {
|
||||
want := fmt.Sprint(value)
|
||||
for _, option := range options {
|
||||
if strings.EqualFold(fmt.Sprint(option), want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
21
internal/resultcontract/schema_test.go
Normal file
21
internal/resultcontract/schema_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package resultcontract
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateResult(t *testing.T) {
|
||||
schema, err := ParseAndValidate([]byte(`{"fields":[{"key":"recommended_products","name":"成功推荐商品","type":"array","items":{"type":"object","fields":[{"key":"product_id","name":"商品 ID","type":"string","required":true},{"key":"quantity","name":"数量","type":"integer"}]}},{"key":"note","name":"备注","type":"text"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateResult(schema, nil); err != nil {
|
||||
t.Fatalf("empty result should be allowed: %v", err)
|
||||
}
|
||||
valid := map[string]interface{}{"recommended_products": []interface{}{map[string]interface{}{"product_id": "A", "quantity": float64(1)}}}
|
||||
if err := ValidateResult(schema, valid); err != nil {
|
||||
t.Fatalf("valid result rejected: %v", err)
|
||||
}
|
||||
invalid := map[string]interface{}{"recommended_products": []interface{}{map[string]interface{}{"quantity": float64(1)}}}
|
||||
if err := ValidateResult(schema, invalid); err == nil {
|
||||
t.Fatal("missing nested required field should be rejected")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user