package paneldecl import ( "fmt" "math" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // 本文件是字段层校验(§5.2 的 fields 元素):key/type 必填、optionsFrom 只允许 plugin:*、 // secret 不得有默认值、数值边界与默认值类型。 // validateFields 校验一组字段声明,并保证 key 在区块内唯一。 func validateFields(fields []sdk.Field, prefix string) []Violation { var vs []Violation seen := make(map[string]bool, len(fields)) for i, f := range fields { path := fmt.Sprintf("%s.fields[%d]", prefix, i) vs = append(vs, validateFieldAt(f, path)...) if f.Key == "" { continue } if seen[f.Key] { vs = append(vs, dupViolation(path+".key", "本区块", "字段 key", f.Key)) continue } seen[f.Key] = true } return vs } // validateFieldAt 校验单个字段。type 未知时只报类型一条并停止:其余规则都依赖类型 // (optionsFrom / min-max / 默认值类型),继续推导只会产生噪音。 func validateFieldAt(f sdk.Field, path string) []Violation { var vs []Violation if f.Key == "" { vs = append(vs, violation(ErrMissingField, path+".key", "字段 key 必填(它同时是配置键与提交键)")) } else if !validFieldKey(f.Key) { vs = append(vs, violation(ErrInvalidFieldKey, path+".key", fieldKeyDetail(f.Key))) } vs = append(vs, checkOptionalText(f.Label, path+".label", titleMaxRunes)...) vs = append(vs, checkOptionalText(f.Placeholder, path+".placeholder", hintMaxRunes)...) if !f.Type.Valid() { return append(vs, violation(ErrUnknownFieldType, path+".type", fmt.Sprintf( "未知字段类型 %q(闭集:text|secret|number|switch|select|multiSelect|date|kv)", f.Type))) } vs = append(vs, checkOptionsFrom(f, path)...) vs = append(vs, checkBounds(f, path)...) vs = append(vs, checkDefault(f, path)...) return vs } // checkOptionsFrom 校验下拉选项来源。两条规则: // 1. 只允许 `plugin:<名字>` —— §5.2 只给了插件内这一种来源,其它一律拒(防「声明里塞 // 任意 URL」:选项来源一旦可以是 URL,声明就变相获得了发请求的能力); // 2. 只允许出现在 select / multiSelect 上 —— 其它控件不渲染选项列表,声明了也不生效, // 静默忽略正是 §5.2.1 要禁的那种假成功。 func checkOptionsFrom(f sdk.Field, path string) []Violation { if f.OptionsFrom == "" { return nil } if !f.Type.PullsOptions() { return []Violation{violation(ErrInvalidOptionsFrom, path+".optionsFrom", fmt.Sprintf("%s 不渲染下拉选项,optionsFrom 只对 select / multiSelect 有效", f.Type))} } if !optionsFromRe.MatchString(f.OptionsFrom) { return []Violation{violation(ErrInvalidOptionsFrom, path+".optionsFrom", fmt.Sprintf( "optionsFrom %q 非法:只允许 `plugin:<名字>`(如 plugin:providers),不接受任意 URL", f.OptionsFrom))} } return nil } // checkBounds 校验 min/max:只有 number 可声明,取值为有限数,且 min <= max。 func checkBounds(f sdk.Field, path string) []Violation { if f.Min == nil && f.Max == nil { return nil } if !f.Type.Numeric() { return []Violation{violation(ErrInvalidBounds, path+".min", fmt.Sprintf( "%s 不支持 min/max(闭集里只有 number 是数值控件;date 用数值边界表达日期范围只会歧义)", f.Type))} } // NaN/Inf 在标准 JSON 里写不出来,但声明也可以由 Go 代码直接构造,所以照样拦。 if f.Min != nil && (math.IsNaN(*f.Min) || math.IsInf(*f.Min, 0)) { return []Violation{violation(ErrInvalidBounds, path+".min", "min 必须是有限数")} } if f.Max != nil && (math.IsNaN(*f.Max) || math.IsInf(*f.Max, 0)) { return []Violation{violation(ErrInvalidBounds, path+".max", "max 必须是有限数")} } if f.Min != nil && f.Max != nil && *f.Min > *f.Max { return []Violation{violation(ErrInvalidBounds, path+".min", fmt.Sprintf( "min (%v) 不得大于 max (%v)", *f.Min, *f.Max))} } return nil } // checkDefault 校验默认值。secret 字段**一律不许**有默认值(§5.4.2 读侧永不回显明文: // 声明里写死的凭据会被读侧原样吐回,等于绕过掩码);其余类型要求默认值的 JSON 类型与 // 控件相容,否则到前端是「控件显示不出该值」的半渲染。 func checkDefault(f sdk.Field, path string) []Violation { if f.Default == nil { return nil } if f.Type.Sensitive() { return []Violation{violation(ErrSecretDefault, path+".default", "secret 字段不得声明默认值(§5.4.2:读侧永不回显明文)")} } want := defaultShapeOf(f.Type) if !defaultMatches(f.Type, f.Default) { return []Violation{violation(ErrInvalidDefault, path+".default", fmt.Sprintf("默认值类型与 %s 不匹配(期望 %s)", f.Type, want))} } return nil } // defaultMatches 判定默认值是否与字段类型相容。不做类型转换:能被静默转换的值(如给 // number 写字符串 "20")正是「看着配好了、其实没生效」的来源。 func defaultMatches(t sdk.FieldType, v any) bool { switch t { case sdk.FieldText, sdk.FieldDate, sdk.FieldSelect: _, ok := v.(string) return ok case sdk.FieldNumber: return isNumeric(v) case sdk.FieldSwitch: _, ok := v.(bool) return ok case sdk.FieldMultiSelect: return isStringList(v) case sdk.FieldKV: return isStringMap(v) default: // secret 已在 checkDefault 里被拒,走到这里说明字段类型闭集新增了成员而 // defaultMatches 没跟上 —— 拒绝而不是放行。 return false } } // defaultShapeOf 给出期望形态的人类可读说法(错误信息里用)。 func defaultShapeOf(t sdk.FieldType) string { switch t { case sdk.FieldText, sdk.FieldDate, sdk.FieldSelect: return "字符串" case sdk.FieldNumber: return "数字" case sdk.FieldSwitch: return "布尔" case sdk.FieldMultiSelect: return "字符串数组" case sdk.FieldKV: return "键值对对象" default: return "与字段类型相容的值" } } // isNumeric 报告 v 是不是数值。覆盖 Go 侧直接构造声明时可能用的整型(JSON 解码只会给 // float64,但 SDK 是给插件作者用的 Go 包,不该逼他们写 20.0)。 func isNumeric(v any) bool { switch v.(type) { case float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: return true default: return false } } // isStringList 报告 v 是不是字符串列表(JSON → []any,Go 直构 → []string)。 func isStringList(v any) bool { switch items := v.(type) { case []string: return true case []any: for _, it := range items { if _, ok := it.(string); !ok { return false } } return true default: return false } } // isStringMap 报告 v 是不是键值对(JSON → map[string]any,Go 直构 → map[string]string)。 func isStringMap(v any) bool { switch m := v.(type) { case map[string]string: return true case map[string]any: for _, val := range m { if _, ok := val.(string); !ok { return false } } return true default: return false } } // fieldKeyDetail 是字段 key 形状的统一说明。 func fieldKeyDetail(key string) string { return fmt.Sprintf( "字段 key %q 非法:只允许字母开头、后接字母数字或下划线(如 provider / apiKey),因为它同时是配置键", key) }