package paneldecl import ( "fmt" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // 本文件是声明校验的入口与面板/区块层(§5.2.1 B「声明校验」)。字段层在 fieldcheck.go。 // Validate 校验一个插件的完整声明:插件 id 形状 + 逐面板校验 + 插件内 id 唯一性。 // // 它**只读**:不修补、不改写传入的声明(要存进目录的值由 Register 负责 normalize)。 // 返回 nil 或 *Errors(聚合了**逐条**可分类的违规)。插件 id 非法时仍继续逐面板校验, // 让调用方一次拿到全部问题 —— 逐轮报错会让插件作者改一轮跑一轮。 func Validate(pluginID string, panels []sdk.Panel) error { var vs []Violation vs = append(vs, checkID(pluginID, "pluginID", "插件 id", ErrInvalidPluginID)...) seen := make(map[string]bool, len(panels)) for i, p := range panels { path := panelPath(i) pvs := validatePanelAt(p, path) if p.ID != "" { if seen[p.ID] { pvs = append(pvs, dupViolation(path+".id", "本插件", "面板 id", p.ID)) } seen[p.ID] = true } vs = append(vs, pvs...) } return violationsToError(vs) } // ValidatePanel 校验单个面板,index 是它在插件声明数组里的下标(只用于错误路径)。 // // 它存在的理由是 §5.2.1 的拒绝粒度:非法声明「该面板不注册」,所以插件管理器需要**按面板** // 判定,而不是只能整插件一起拒。 func ValidatePanel(panel sdk.Panel, index int) error { return violationsToError(validatePanelAt(panel, panelPath(index))) } // validatePanelAt 校验一个面板及其全部区块。path 是索引式前缀。 func validatePanelAt(p sdk.Panel, path string) []Violation { var vs []Violation vs = append(vs, checkID(p.ID, path+".id", "面板 id", ErrInvalidPanelID)...) vs = append(vs, checkRequiredText(p.Title, path+".title", titleMaxRunes)...) vs = append(vs, checkOptionalText(p.Description, path+".description", hintMaxRunes)...) if g := normalizeGroup(p.Group); !g.Valid() { vs = append(vs, violation(ErrUnknownGroup, path+".group", fmt.Sprintf("未知分组 %q(本期分组闭集只有 %q,见 SDK 的 GroupID)", p.Group, sdk.GroupPlugins))) } if p.Order < 0 { vs = append(vs, violation(ErrInvalidOrder, path+".order", fmt.Sprintf("order 必须 >= 0(小在前),得到 %d", p.Order))) } if p.Icon != "" && !validIcon(p.Icon) { vs = append(vs, violation(ErrInvalidIcon, path+".icon", fmt.Sprintf("图标名 %q 非法:只允许宿主内置图标名(字母数字与中划线/下划线),不接受 URL 或 SVG 片段", p.Icon))) } vs = append(vs, validateSections(p.Sections, path)...) return vs } // checkID 校验一个 id 类取值:空 ⇒ 必填缺失;形状不符 ⇒ 该 id 种类的分类错误。 func checkID(id, path, label string, kind error) []Violation { if id == "" { return []Violation{violation(ErrMissingField, path, label+" 必填(空值无法构成路由段/端点段)")} } if !validID(id) { return []Violation{violation(kind, path, fmt.Sprintf( "%s %q 非法:只允许小写字母数字与中划线分段(如 providers / web-search),长度 ≤ %d", label, id, idMaxLen))} } return nil } // validateSections 校验面板的区块列表,并保证区块 id 在面板内唯一。 func validateSections(sections []sdk.Section, panelPrefix string) []Violation { var vs []Violation seen := make(map[string]bool, len(sections)) for i, s := range sections { path := fmt.Sprintf("%s.sections[%d]", panelPrefix, i) vs = append(vs, validateSectionAt(s, path)...) if s.ID == "" { continue } if seen[s.ID] { vs = append(vs, dupViolation(path+".id", "本面板", "区块 id", s.ID)) continue } seen[s.ID] = true } return vs } // validateSectionAt 校验一个区块:类型闭集 → id/标题 → 形状矩阵 → 载荷(字段/列/动作)。 func validateSectionAt(s sdk.Section, path string) []Violation { if !s.Type.Valid() { // 类型不在闭集 ⇒ 形状矩阵无从谈起。只报这一条:继续按未知类型推导出的 // 「缺 submit / 缺 columns」全是噪音,会把真正的病因埋掉。 return []Violation{violation(ErrUnknownSectionType, path+".type", fmt.Sprintf("未知 section 类型 %q(闭集:form|table|status|log)", s.Type))} } var vs []Violation vs = append(vs, checkID(s.ID, path+".id", "区块 id", ErrInvalidSectionID)...) vs = append(vs, checkRequiredText(s.Title, path+".title", titleMaxRunes)...) vs = append(vs, checkSectionShape(s, path)...) vs = append(vs, validateSectionPayload(s, path)...) return vs } // checkSectionShape 按 §5.2.1 的接入点表检查「哪些键必须出现 / 不许出现」。 func checkSectionShape(s sdk.Section, path string) []Violation { sh := shapeOf(s.Type) // 六个键 × 四种必要性。写成表是为了让每个键的判定逐行可读、加键时只改一处。 keys := []struct { key string req requirement has bool }{ {"fields", sh.fields, len(s.Fields) > 0}, {"submit", sh.submit, s.Submit != nil}, {"source", sh.source, s.Source != nil}, {"columns", sh.columns, len(s.Columns) > 0}, {"rowActions", sh.rowActions, len(s.RowActions) > 0}, {"limit", sh.limit, s.Limit != 0}, } var vs []Violation for _, k := range keys { kp := path + "." + k.key switch k.req { case required: if !k.has { vs = append(vs, violation(ErrMissingField, kp, fmt.Sprintf("%s 区块必须声明 %s", s.Type, k.key))) } case notAllowed: if k.has { vs = append(vs, violation(ErrSectionShape, kp, fmt.Sprintf("%s 区块不消费 %s(形状见 §5.2.1 的接入点表)", s.Type, k.key))) } case writeForbidden: if k.has { vs = append(vs, violation(ErrReadOnlySection, kp, fmt.Sprintf("%s 是只读区块,不得声明写面 %s(§5.4.1:写动作一律 owner/admin)", s.Type, k.key))) } case optional: } } return vs } // validateSectionPayload 校验区块内各元素的载荷(动作名、字段、列、行内动作、limit)。 func validateSectionPayload(s sdk.Section, path string) []Violation { var vs []Violation switch s.Type { case sdk.SectionForm, sdk.SectionStatus: vs = append(vs, validateFields(s.Fields, path)...) } if s.Submit != nil { vs = append(vs, checkAction(s.Submit.Action, path+".submit.action")...) } if s.Source != nil { vs = append(vs, checkAction(s.Source.Action, path+".source.action")...) } if s.Type == sdk.SectionTable { vs = append(vs, validateColumns(s.Columns, path)...) vs = append(vs, validateRowActions(s.RowActions, path)...) } if s.Type == sdk.SectionLog { vs = append(vs, checkLimit(s.Limit, path+".limit")...) } return vs } // checkAction 校验动作名。依据 §5.4.4:插件只给**动作名**、不给 URL,所以这里连 // `:` 与 `/` 都不接受 —— 想塞请求地址的声明在此被拒。空动作名是必填缺失(有 submit / // source 却没动作,前端点下去什么都不会发生,即半渲染)。 func checkAction(action, path string) []Violation { if action == "" { return []Violation{violation(ErrMissingField, path, "动作名必填(插件只给动作名,不给 URL)")} } if !validAction(action) { return []Violation{violation(ErrInvalidAction, path, fmt.Sprintf( "动作名 %q 非法:只允许点分标识(如 plugin.saveProvider),不得含 `/` 或 `:` 等 URL 成分", action))} } return nil } // checkLimit 校验 log 区块的 limit。0 表示未声明(由宿主取默认),非 0 必须在 (0, maxLogLimit]。 func checkLimit(limit int, path string) []Violation { if limit == 0 { return nil } if limit < 0 || limit > maxLogLimit { return []Violation{violation(ErrInvalidLimit, path, fmt.Sprintf("limit 必须在 (0, %d] 内(log 是排障用的只读尾读,超大值会把页面卡死),得到 %d", maxLogLimit, limit))} } return nil } // validateColumns 校验表格列:key 形状与唯一性、label 文案。 func validateColumns(cols []sdk.Column, prefix string) []Violation { var vs []Violation seen := make(map[string]bool, len(cols)) for i, c := range cols { path := fmt.Sprintf("%s.columns[%d]", prefix, i) if c.Key == "" { vs = append(vs, violation(ErrMissingField, path+".key", "列 key 必填(它是行数据字段名与列的身份)")) } else if !validFieldKey(c.Key) { vs = append(vs, violation(ErrInvalidFieldKey, path+".key", fieldKeyDetail(c.Key))) } else if seen[c.Key] { vs = append(vs, dupViolation(path+".key", "本表格", "列 key", c.Key)) } else { seen[c.Key] = true } vs = append(vs, checkOptionalText(c.Label, path+".label", titleMaxRunes)...) } return vs } // validateRowActions 校验行内动作:id 形状与唯一性、label 必填、动作名合法。 func validateRowActions(actions []sdk.RowAction, prefix string) []Violation { var vs []Violation seen := make(map[string]bool, len(actions)) for i, a := range actions { path := fmt.Sprintf("%s.rowActions[%d]", prefix, i) vs = append(vs, checkID(a.ID, path+".id", "行内动作 id", ErrInvalidRowActionID)...) // label 必填且**没有可回落的东西**:不像列 label 能回落到 key,没有文字的行内按钮 // 就是半渲染。 vs = append(vs, checkRequiredText(a.Label, path+".label", titleMaxRunes)...) vs = append(vs, checkAction(a.Action, path+".action")...) if a.ID == "" { continue } if seen[a.ID] { vs = append(vs, dupViolation(path+".id", "本表格", "行内动作 id", a.ID)) continue } seen[a.ID] = true } return vs } // dupViolation 生成「同作用域内重复」的违规。三处调用共用同一措辞,避免同一类问题在 // 不同位置报出不同说法。 func dupViolation(path, scope, kind, value string) Violation { return violation(ErrDuplicateID, path, fmt.Sprintf("%s %q 在%s内重复(重复会让入口/锚点/提交键无法区分)", kind, value, scope)) }