feat(plugins): 插件「管理面板声明」库——声明模型 + 闭集校验 + catalog(design/09 §5.2/§5.2.1)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

纯库、零接线:不碰 gateway/frontend/Docs/.env*(本片刻意不接线,避免与在途切片争用文件面)。

- pkg/plugins/sdk:面板声明类型(JSON tag 与 design/09 §5.2 示例逐字同形);
  字段类型与 section 类型都是**闭集**;When 三态 + **严格 UnmarshalJSON**(未知条件键报错,
  不静默丢)
- internal/capability/paneldecl:闭集校验(47 条拒绝用例,断言的是**分类哨兵 + JSON 路径**,
  不是错误字符串)· catalog(并发安全、返回拷贝、按 (group, order, 登记序号) 的**全序**稳定排序)·
  when 求值(上下文由 Resolver 注入,本包不读环境变量/文件)· 角色过滤(viewer 只留
  status/log/table 且摘掉 rowActions,纯写面板整体隐藏;未知角色 fail-closed)
- 拒绝粒度:插件 id 非法 ⇒ 整插件拒绝;单个面板非法 ⇒ 只拒该面板 + 聚合显式报错(理由见报告)

【我的验收】隔离 worktree(19 文件)build/vet/linux/darwin 全过、69 包全绿;
我亲手打红变异:把同组同序的回退比较  反成  ⇒ TestCatalog_StableOrder FAIL。
(记录一条观察:比较器已靠 seq 构成全序,故 sort.SliceStable 的稳定性是冗余保护 ——
我第一次用「SliceStable→Slice」打不红,正是因为全序与算法无关;这不是测试虚护。)
This commit is contained in:
AskaEth committed 2026-10-06 08:06:20 +08:00
1 parent 6e28b38b08
commit c9144c1096
19 files changed
+3053

No files matched your search

+210
View File
@@ -0,0 +1,210 @@
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)
}