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

+246
View File
@@ -0,0 +1,246 @@
package paneldecl
import (
"fmt"
"sort"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件是「清单目录」(§5.2.1 B「插件管理器 = 唯一中枢」里的 catalog 一项):
// 收集各插件**显式声明**的 adminPanels,编译期内置插件与热装条目进同一张目录 ——
// 没声明就是没页面,不做任何启发式猜测(§5.2.1 红线③)。
//
// 目录**不进** capability registry:那里是「可调用能力」,这里是「UI 声明」,混在一起会让
// 两侧的语义互相污染(§5.7 B 的落点说明)。
// Entry 是目录里的一条面板:声明本身 + 它的属主插件。
//
// 刻意不带 JSON tag:下发给面板的线形状属于 §5.2.1 的「唯一契约面」
// (GET /api/v1/admin/panels + panels_changed),那份契约由接线切片按「契约先行」登记进
// Docs/api-client.md —— 本库不预占字段名,免得线上形状被一个内部类型钉死。
type Entry struct {
// PluginID 是声明该面板的插件 id。
PluginID string
// Panel 是该面板声明(目录内部状态的**拷贝**)。
Panel sdk.Panel
}
// Group 是侧边栏折叠分组及其条目(§5.2.1 的 nav.group / nav.item 两级)。
//
// 分组的**标题**不在声明里:§5.2 的 `group` 只是分组 id,而 §5.2.1 的 nav.group 一行又写
// 「消费 group / title / order」—— 那句里的 title 只能是宿主给的本地化分组名(面板自己的
// title 是页面标题)。这个歧义点记在 PLG1-report.md,本库按「分组标题由宿主提供」实现。
type Group struct {
// ID 是分组 id(本期闭集只有 sdk.GroupPlugins)。
ID sdk.GroupID
// Panels 是该分组下的条目,已按 order 稳定排序。
Panels []Entry
}
// storedPanel 是目录内部的一条记录:声明 + 属主 + 登记序号。
//
// 序号是**稳定序**的最后一根稻草:同 group、同 order 的条目按登记顺序排,从而保证
// 多插件并存时导航不会因为 map 迭代顺序而抖动(前端导航抖动是真实体验问题)。
type storedPanel struct {
panel sdk.Panel
pluginID string
seq uint64
}
// Catalog 是并发安全的面板声明目录。零值可用(空目录),但要用 when 求值必须经
// NewCatalog 注入 Resolver。
type Catalog struct {
resolve Resolver
mu sync.RWMutex
seq uint64
panels map[string][]storedPanel // pluginID → 该插件当前的面板集合
}
// NewCatalog 构造目录。resolve 必须非 nil(返回 ErrNilResolver)——
//
// 为什么不给 nil 一个「全都显示」的默认值:那会让「忘了接线启用状态」这种集成错误表现为
// **看起来正常**(所有面板都在),而不是一个立刻能被发现的返回错误;而给「全都隐藏」的
// 默认值又会变成静默丢页面。宁可让调用方在这里就把事实来源接上。
func NewCatalog(resolve Resolver) (*Catalog, error) {
if resolve == nil {
return nil, ErrNilResolver
}
return &Catalog{resolve: resolve, panels: make(map[string][]storedPanel)}, nil
}
// Register 用 panels **整体替换** pluginID 已有的声明集,并返回被接受的面板 id。
//
// 整体替换对应 §5.2.1 的生命周期口径:install / enable / disable / uninstall(含 MCP 的
// tools/list_changed)**都重算面板集合** —— 调用方永远给「这个插件现在的完整声明」,
// 不需要 diff。
//
// 拒绝口径(对齐 §5.2.1 B「非法声明 ⇒ **该面板不注册** + 显式报错」):
//
// - 插件 id 非法 ⇒ **整插件拒绝**:一个都不进目录。目录是按插件 id 分命名空间的
// (路由 /admin/plugin/<pluginId>/<panelId>、端点 /api/v1/admin/plugins/{id}/actions/{action}),
// 没有合法命名空间就没有可挂的东西;
// - 某个面板声明非法 ⇒ **只拒该面板**,同插件其余合法面板照常注册,返回的 err 里是
// 逐条可分类的违规(errors.Is 可判类),accepted 列出真正生效的面板 id。
//
// 为什么不做「一个面板坏了就整个插件都拒」:§5.2.1 明写的是「该面板不注册」,且整插件拒绝会
// 让用户端从「少一个页面」变成「这个插件的页面全没了」,更难排查。代价是同一次安装会得到
// 「部分生效 + 一条错误」这种双重结果,接入方必须**同时**读 err 与 accepted(见报告)。
//
// 存进来的声明是**规范化并深拷**过的(空 group 回落唯一分组、调用方的后续改动不影响目录)。
func (c *Catalog) Register(pluginID string, panels []sdk.Panel) (accepted []string, err error) {
if c == nil {
return nil, ErrNilCatalog
}
if vs := checkID(pluginID, "pluginID", "插件 id", ErrInvalidPluginID); len(vs) > 0 {
return nil, violationsToError(vs)
}
c.mu.Lock()
defer c.mu.Unlock()
if c.panels == nil {
c.panels = make(map[string][]storedPanel)
}
var vs []Violation
seen := make(map[string]bool, len(panels))
accepted = make([]string, 0, len(panels))
stored := make([]storedPanel, 0, len(panels))
for i, p := range panels {
path := panelPath(i)
pvs := validatePanelAt(p, path)
if p.ID != "" && seen[p.ID] {
pvs = append(pvs, dupViolation(path+".id", "本插件", "面板 id", p.ID))
}
if len(pvs) > 0 {
vs = append(vs, pvs...)
continue
}
seen[p.ID] = true
normalized := normalizePanel(p)
c.seq++
stored = append(stored, storedPanel{
panel: clonePanel(normalized),
pluginID: pluginID,
seq: c.seq,
})
accepted = append(accepted, normalized.ID)
}
c.panels[pluginID] = stored
return accepted, violationsToError(vs)
}
// Remove 移除一个插件的全部面板声明。未知插件是幂等空操作(重复卸载不报错)。
func (c *Catalog) Remove(pluginID string) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
delete(c.panels, pluginID)
}
// List 返回按角色过滤后的面板快照,顺序为 (分组, order, 登记序号) 的**稳定序**。
//
// 两件过滤都在这里发生:`when` 不满足的入口整个不出现(§5.2.1 nav.item),角色看不到的区块
// 被裁掉(§5.4.1)。返回值是深拷,调用方随便改(改不动目录内部状态)。
//
// 角色不在 owner/admin/viewer 之内 ⇒ ErrUnknownRole(**fail-closed**:宁可报错,也不猜一个
// 默认角色去放行)。
func (c *Catalog) List(role auth.Role) ([]Entry, error) {
if !role.Valid() {
return nil, fmt.Errorf("%w: %q", ErrUnknownRole, role)
}
c.mu.RLock()
defer c.mu.RUnlock()
stored := c.sortedLocked()
out := make([]Entry, 0, len(stored))
for _, sp := range stored {
if !Eval(sp.panel.When, c.contextFor(sp.pluginID)) {
continue
}
filtered, visible := filterPanel(role, sp.panel)
if !visible {
continue
}
out = append(out, Entry{PluginID: sp.pluginID, Panel: clonePanel(filtered)})
}
return out, nil
}
// Groups 返回按分组组织的同一份快照(§5.2.1 的 nav.group 一级)。
//
// 分组之间按组内**最小的 order** 排(§5.2.1 说 nav.group 消费 order,那只能来自组内条目),
// 同值时按 List 的稳定序,因此结果是确定的。本期只有 plugins 一个分组,这一段是给后续
// 分组接入留的正确形状,不额外加接口。
func (c *Catalog) Groups(role auth.Role) ([]Group, error) {
entries, err := c.List(role)
if err != nil {
return nil, err
}
// List 的排序键第一位就是分组 ⇒ 同组条目在结果里一定连续,顺序扫描即可。
var groups []Group
for _, e := range entries {
if n := len(groups); n > 0 && groups[n-1].ID == e.Panel.Group {
groups[n-1].Panels = append(groups[n-1].Panels, e)
continue
}
groups = append(groups, Group{ID: e.Panel.Group, Panels: []Entry{e}})
}
sort.SliceStable(groups, func(i, j int) bool {
return groups[i].Panels[0].Panel.Order < groups[j].Panels[0].Panel.Order
})
return groups, nil
}
// PluginIDs 返回当前已登记**声明**的插件 id(已排序)。注意它不等于「已安装的插件」——
// 目录只认识声明过面板的插件。
//
// 它的用途是 §5.2.1 红线③:一个「已装、未声明任何页面」的插件要在「插件与能力」页**如实
// 显示「未声明管理页面」**,而不是被藏掉。插件列表页需要能拿这份 id 与已装列表对照。
func (c *Catalog) PluginIDs() []string {
c.mu.RLock()
defer c.mu.RUnlock()
out := make([]string, 0, len(c.panels))
for id := range c.panels {
out = append(out, id)
}
sort.Strings(out)
return out
}
// sortedLocked 返回 (分组, order, 登记序号) 稳定序的全部记录。调用方必须持有读锁。
func (c *Catalog) sortedLocked() []storedPanel {
out := make([]storedPanel, 0, len(c.panels))
for _, list := range c.panels {
out = append(out, list...)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].panel.Group != out[j].panel.Group {
return out[i].panel.Group < out[j].panel.Group
}
if out[i].panel.Order != out[j].panel.Order {
return out[i].panel.Order < out[j].panel.Order
}
return out[i].seq < out[j].seq
})
return out
}
// contextFor 取某插件的求值上下文。
//
// resolve 为 nil 只可能出现在零值 Catalog 上(NewCatalog 已拒 nil)或调用方显式构造 ——
// 一律按「未启用」判,fail-closed:宁可不显示入口,也不给一个可能已停用的插件开入口。
func (c *Catalog) contextFor(pluginID string) Context {
if c.resolve == nil {
return Context{}
}
return c.resolve(pluginID)
}
// normalizePanel 把面板规范化成存进目录的形态:目前只有「空分组回落到唯一分组」。
func normalizePanel(p sdk.Panel) sdk.Panel {
p.Group = normalizeGroup(p.Group)
return p
}
@@ -0,0 +1,371 @@
package paneldecl
import (
"errors"
"fmt"
"reflect"
"slices"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件锁定目录的四条不变量:生命周期重算、稳定序、按面板拒绝、返回值是拷贝。
// 并发那条交给 `go test -race` 去证。
// newTestCatalog 造一个不报错的目录(夹具里构造失败说明夹具坏了,直接 Fatal)。
func newTestCatalog(t *testing.T, resolve Resolver) *Catalog {
t.Helper()
cat, err := NewCatalog(resolve)
if err != nil {
t.Fatalf("NewCatalog: %v", err)
}
return cat
}
// mustRegister 注册并断言全部面板都被接受。
func mustRegister(t *testing.T, cat *Catalog, pluginID string, panels ...sdk.Panel) {
t.Helper()
accepted, err := cat.Register(pluginID, panels)
if err != nil {
t.Fatalf("Register(%s): %v", pluginID, err)
}
if len(accepted) != len(panels) {
t.Fatalf("Register(%s) 接受 %d 个面板,期望 %d", pluginID, len(accepted), len(panels))
}
}
// TestNewCatalog_RejectsNilResolver 锁定「事实来源必须显式接入」:给 nil resolver 一个
// 「全都显示」的默认值会让漏接线表现为看起来正常,所以构造函数直接拒。
func TestNewCatalog_RejectsNilResolver(t *testing.T) {
t.Parallel()
if _, err := NewCatalog(nil); !errors.Is(err, ErrNilResolver) {
t.Fatalf("NewCatalog(nil) err = %v,期望 ErrNilResolver", err)
}
}
// TestCatalog_WhenGatesEntrance 锁定 §5.2.1 nav.item 与 §5.6.1:when 不满足 ⇒ 入口不出现。
func TestCatalog_WhenGatesEntrance(t *testing.T) {
t.Parallel()
enabled := map[string]bool{"web-search": true}
cat := newTestCatalog(t, func(id string) Context { return Context{Enabled: enabled[id]} })
always := samplePanel()
always.ID = "always"
always.Order = 1
always.When = nil
onWhenEnabled := samplePanel()
onWhenEnabled.ID = "on"
onWhenEnabled.Order = 2
onWhenEnabled.When = &sdk.When{Enabled: boolPtr(true)}
whenDisabled := samplePanel()
whenDisabled.ID = "off"
whenDisabled.Order = 3
whenDisabled.When = &sdk.When{Enabled: boolPtr(false)}
mustRegister(t, cat, "web-search", always, onWhenEnabled, whenDisabled)
entries, err := cat.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
if got := mustPanelIDs(t, entries); !slices.Equal(got, []string{"always", "on"}) {
t.Fatalf("插件启用时的入口 = %v,期望 [always on]", got)
}
enabled["web-search"] = false
entries, err = cat.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
if got := mustPanelIDs(t, entries); !slices.Equal(got, []string{"always", "off"}) {
t.Fatalf("插件停用时的入口 = %v,期望 [always off]", got)
}
}
// TestCatalog_StableOrder 锁定稳定序:乱序注册也得到固定输出,且反复查询逐字相同。
//
// 「逐字相同」不是洁癖:List 内部要用 map 收集各插件的面板,如果排序键不含登记序号,
// 同 group 同 order 的条目顺序就会随 map 迭代抖动 —— 前端导航跟着抖。
func TestCatalog_StableOrder(t *testing.T) {
t.Parallel()
cat := newTestCatalog(t, alwaysEnabled)
late := samplePanel()
late.ID = "z-late"
late.Order = 30
first := samplePanel()
first.ID = "z-first"
first.Order = 5
// 同一个插件内**先声明 order 大的**,锁定「同 order 时按声明顺序」与「order 优先」两件事。
mustRegister(t, cat, "zz", late, first)
same := samplePanel()
same.ID = "a-same-order"
same.Order = 5
mustRegister(t, cat, "aa", same)
want := []string{"z-first", "a-same-order", "z-late"}
for i := 0; i < 100; i++ {
entries, err := cat.List(auth.RoleOwner)
if err != nil {
t.Fatalf("第 %d 次 List: %v", i, err)
}
if got := mustPanelIDs(t, entries); !slices.Equal(got, want) {
t.Fatalf("第 %d 次 List = %v,期望 %v(排序必须稳定)", i, got, want)
}
}
}
// TestCatalog_RegisterReplacesAndRemoveRecalculates 锁定 §5.2.1 的生命周期口径:
// 重装是**整体替换**,卸载后集合立即重算。
func TestCatalog_RegisterReplacesAndRemoveRecalculates(t *testing.T) {
t.Parallel()
cat := newTestCatalog(t, alwaysEnabled)
mustRegister(t, cat, "web-search", samplePanel())
// 重装只带一个新的面板 ⇒ 旧面板必须消失(不是叠加)。
replacement := statusPanel()
replacement.ID = "chain"
mustRegister(t, cat, "web-search", replacement)
entries, err := cat.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
if got := mustPanelIDs(t, entries); !slices.Equal(got, []string{"chain"}) {
t.Fatalf("重装后的入口 = %v,期望 [chain]", got)
}
cat.Remove("web-search")
entries, err = cat.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(entries) != 0 {
t.Fatalf("卸载后仍有 %d 条入口", len(entries))
}
// 重复卸载是幂等空操作。
cat.Remove("web-search")
if ids := cat.PluginIDs(); len(ids) != 0 {
t.Fatalf("卸载后仍登记着 %v", ids)
}
}
// TestCatalog_RegisterRejectsPanelOnly 锁定拒绝粒度(§5.2.1「该面板不注册 + 显式报错」):
// 坏面板被拒、坏面板所在插件的好面板照常注册,且错误可分类。
func TestCatalog_RegisterRejectsPanelOnly(t *testing.T) {
t.Parallel()
cat := newTestCatalog(t, alwaysEnabled)
good := samplePanel()
bad := statusPanel()
bad.ID = "broken"
// 形状错误:只读事实面不该有 source。
bad.Sections[0].Source = &sdk.Source{Action: "plugin.chain"}
accepted, err := cat.Register("web-search", []sdk.Panel{good, bad})
if !errors.Is(err, ErrSectionShape) {
t.Fatalf("err = %v,期望命中 ErrSectionShape", err)
}
if !slices.Equal(accepted, []string{"providers"}) {
t.Fatalf("accepted = %v,期望 [providers](坏面板不注册)", accepted)
}
entries, lerr := cat.List(auth.RoleOwner)
if lerr != nil {
t.Fatalf("List: %v", lerr)
}
if got := mustPanelIDs(t, entries); !slices.Equal(got, []string{"providers"}) {
t.Fatalf("目录内容 = %v,期望 [providers]", got)
}
// 整份声明都坏 ⇒ 该插件被登记成「零面板」(重算语义),而不是保留旧集合。
accepted, err = cat.Register("web-search", []sdk.Panel{bad})
if !errors.Is(err, ErrSectionShape) {
t.Fatalf("err = %v,期望命中 ErrSectionShape", err)
}
if len(accepted) != 0 {
t.Fatalf("accepted = %v,期望空", accepted)
}
entries, lerr = cat.List(auth.RoleOwner)
if lerr != nil {
t.Fatalf("List: %v", lerr)
}
if len(entries) != 0 {
t.Fatalf("全坏声明之后仍有 %d 条入口", len(entries))
}
// 「已装、未声明任何页面」仍要能被插件列表页查到(§5.2.1 红线③)。
if ids := cat.PluginIDs(); !slices.Equal(ids, []string{"web-search"}) {
t.Fatalf("PluginIDs = %v,期望 [web-search]", ids)
}
}
// TestCatalog_RegisterRejectsBadPluginID 锁定另一种拒绝口径:插件 id 非法 ⇒ 整插件拒绝
// (目录按插件 id 分命名空间,没有合法命名空间就无从挂载)。
func TestCatalog_RegisterRejectsBadPluginID(t *testing.T) {
t.Parallel()
cat := newTestCatalog(t, alwaysEnabled)
accepted, err := cat.Register("Web Search", []sdk.Panel{samplePanel()})
if !errors.Is(err, ErrInvalidPluginID) {
t.Fatalf("err = %v,期望命中 ErrInvalidPluginID", err)
}
if accepted != nil {
t.Fatalf("accepted = %v,期望 nil", accepted)
}
entries, lerr := cat.List(auth.RoleOwner)
if lerr != nil {
t.Fatalf("List: %v", lerr)
}
if len(entries) != 0 || len(cat.PluginIDs()) != 0 {
t.Fatalf("非法插件 id 不该留下任何痕迹:entries=%v ids=%v", entries, cat.PluginIDs())
}
}
// TestCatalog_ListReturnsCopy 锁定「返回值必须是拷贝」的两个方向:调用方改结果改不动目录,
// 注册之后改自己那份声明也改不动目录(后者同样重要 —— 插件管理器常常复用一份声明)。
func TestCatalog_ListReturnsCopy(t *testing.T) {
t.Parallel()
cat := newTestCatalog(t, alwaysEnabled)
mine := samplePanel()
mustRegister(t, cat, "web-search", mine)
entries, err := cat.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
if !reflect.DeepEqual(entries[0].Panel, samplePanel()) {
t.Fatalf("目录里的面板与声明不一致:%+v", entries[0].Panel)
}
// 方向一:改 List 的返回值。
entries[0].Panel.Title = "被我改了"
entries[0].Panel.Sections[0].Title = "被我改了"
entries[0].Panel.Sections[0].Fields[0].Key = "hacked"
entries[0].Panel.Sections[0].Fields[0].Min = floatPtr(1)
entries[0].Panel.Sections[1].Columns[0].Label = "被我改了"
entries[0].Panel.Sections[1].RowActions[0].Label = "被我改了"
entries[0].PluginID = "hacked"
*entries[0].Panel.When.Enabled = false
entries[0].Panel.Sections = append(entries[0].Panel.Sections, sdk.Section{ID: "extra", Type: sdk.SectionStatus})
// 方向二:改注册时用的那份声明。
mine.Title = "也改了"
mine.Sections[1].Columns[1].Label = "也改了"
again, err := cat.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
if got := mustPanelIDs(t, again); !slices.Equal(got, []string{"providers"}) {
t.Fatalf("入口 id 被外部改动影响:%v", got)
}
if again[0].PluginID != "web-search" {
t.Fatalf("PluginID 被改坏:%q", again[0].PluginID)
}
if !reflect.DeepEqual(again[0].Panel, samplePanel()) {
t.Fatalf("目录内部状态被调用方改坏了:%+v", again[0].Panel)
}
}
// TestCatalog_ListRejectsUnknownRole 锁定角色门是 fail-closed:认不出的角色不给任何面板,
// 也不猜一个默认角色放行。
func TestCatalog_ListRejectsUnknownRole(t *testing.T) {
t.Parallel()
cat := newTestCatalog(t, alwaysEnabled)
mustRegister(t, cat, "web-search", samplePanel())
if _, err := cat.List(auth.Role("superuser")); !errors.Is(err, ErrUnknownRole) {
t.Fatalf("err = %v,期望 ErrUnknownRole", err)
}
if _, err := cat.Groups(auth.Role("")); !errors.Is(err, ErrUnknownRole) {
t.Fatalf("Groups err = %v,期望 ErrUnknownRole", err)
}
}
// TestCatalog_Groups 锁定分组视图与 List 的一致性(§5.2.1 nav.group 一级)。
//
// 本期分组是闭集(只有 plugins),所以这里能验的只有「同一分组内条目连续且顺序与 List 一致」;
// 跨分组排序按最小 order 的规则要等分组闭集扩容后才能端到端测(见 PLG1-report.md)。
func TestCatalog_Groups(t *testing.T) {
t.Parallel()
cat := newTestCatalog(t, alwaysEnabled)
later := samplePanel()
later.ID = "later"
later.Order = 30
earlier := statusPanel()
earlier.ID = "earlier"
earlier.Order = 10
mustRegister(t, cat, "web-search", later, earlier)
groups, err := cat.Groups(auth.RoleOwner)
if err != nil {
t.Fatalf("Groups: %v", err)
}
if len(groups) != 1 {
t.Fatalf("分组数 = %d,期望 1(本期闭集只有 plugins)", len(groups))
}
if groups[0].ID != sdk.GroupPlugins {
t.Fatalf("分组 id = %q,期望 %q", groups[0].ID, sdk.GroupPlugins)
}
if got := mustPanelIDs(t, groups[0].Panels); !slices.Equal(got, []string{"earlier", "later"}) {
t.Fatalf("分组内顺序 = %v,期望 [earlier later]", got)
}
}
// TestCatalog_ConcurrentRegisterList 配合 `go test -race` 证明目录在并发
// 注册 / 查询 / 卸载 下无数据竞争、无 panic。
//
// 负载刻意**限核限时**(8 个 goroutine × 40 次,毫秒级):按仓库压测纪律,验证竞态不需要
// 堆进程风暴,注入受控并发足够。
func TestCatalog_ConcurrentRegisterList(t *testing.T) {
t.Parallel()
cat := newTestCatalog(t, alwaysEnabled)
const workers = 8
const rounds = 40
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
pluginID := fmt.Sprintf("plugin-%d", w)
for r := 0; r < rounds; r++ {
p := samplePanel()
p.ID = fmt.Sprintf("panel-%d-%d", w, r%3)
p.Order = r % 5
if _, err := cat.Register(pluginID, []sdk.Panel{p}); err != nil {
t.Errorf("Register(%s): %v", pluginID, err)
return
}
entries, err := cat.List(auth.RoleViewer)
if err != nil {
t.Errorf("List: %v", err)
return
}
for _, e := range entries {
if e.PluginID == "" || e.Panel.ID == "" {
t.Errorf("快照里出现空标识:%+v", e)
return
}
}
if _, err := cat.Groups(auth.RoleAdmin); err != nil {
t.Errorf("Groups: %v", err)
return
}
_ = cat.PluginIDs()
if r%11 == 0 {
cat.Remove(pluginID)
}
}
cat.Remove(pluginID)
}(w)
}
wg.Wait()
entries, err := cat.List(auth.RoleOwner)
if err != nil {
t.Fatalf("收尾 List: %v", err)
}
if len(entries) != 0 {
t.Fatalf("全部卸载后仍有 %d 条入口", len(entries))
}
}
+112
View File
@@ -0,0 +1,112 @@
package paneldecl
import "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
// 本文件是目录的**拷贝语义**:存进来时拷一次、发出去时再拷一次。
//
// 为什么要两次:§5.2.1 的不变量是「`List` 返回的必须是拷贝」——调用方(gateway / 插件管理器)
// 拿着结果做过滤或补字段时,绝不能改到目录内部状态;反向同理,插件管理器注册后继续改自己
// 那份声明,也不能影响目录里的快照。声明是嵌套结构(sections → fields → 指针字段),
// 浅拷贝会把内部切片暴露出去,所以这里逐层深拷。
// clonePanel 深拷一份面板声明。
func clonePanel(p sdk.Panel) sdk.Panel {
out := p
out.When = cloneWhen(p.When)
if p.Sections != nil {
out.Sections = make([]sdk.Section, len(p.Sections))
for i, s := range p.Sections {
out.Sections[i] = cloneSection(s)
}
}
return out
}
// cloneSection 深拷一个区块(切片与指针字段都要换新)。
func cloneSection(s sdk.Section) sdk.Section {
out := s
if s.Fields != nil {
out.Fields = make([]sdk.Field, len(s.Fields))
for i, f := range s.Fields {
out.Fields[i] = cloneField(f)
}
}
if s.Columns != nil {
out.Columns = make([]sdk.Column, len(s.Columns))
copy(out.Columns, s.Columns)
}
if s.RowActions != nil {
out.RowActions = make([]sdk.RowAction, len(s.RowActions))
copy(out.RowActions, s.RowActions)
}
if s.Submit != nil {
submit := *s.Submit
out.Submit = &submit
}
if s.Source != nil {
source := *s.Source
out.Source = &source
}
return out
}
// cloneField 深拷一个字段(Default 是 any,可能装着 map/slice)。
func cloneField(f sdk.Field) sdk.Field {
out := f
out.Default = cloneValue(f.Default)
if f.Min != nil {
min := *f.Min
out.Min = &min
}
if f.Max != nil {
max := *f.Max
out.Max = &max
}
return out
}
// cloneWhen 深拷 when(Enabled 是指针,共享指针等于共享可变状态)。
func cloneWhen(w *sdk.When) *sdk.When {
if w == nil {
return nil
}
out := &sdk.When{}
if w.Enabled != nil {
enabled := *w.Enabled
out.Enabled = &enabled
}
return out
}
// cloneValue 递归拷贝默认值里的容器。标量(字符串/数字/布尔)本身不可变,直接返回。
//
// 未覆盖的类型(声明由 Go 代码直接构造时可能塞进任意类型)按原值返回并在文档里写明:
// 本包只承诺「结构声明不可被调用方改坏」,不承诺替调用方深拷它自己塞进来的自定义类型。
func cloneValue(v any) any {
switch val := v.(type) {
case map[string]any:
out := make(map[string]any, len(val))
for k, item := range val {
out[k] = cloneValue(item)
}
return out
case map[string]string:
out := make(map[string]string, len(val))
for k, item := range val {
out[k] = item
}
return out
case []any:
out := make([]any, len(val))
for i, item := range val {
out[i] = cloneValue(item)
}
return out
case []string:
out := make([]string, len(val))
copy(out, val)
return out
default:
return v
}
}
+19
View File
@@ -0,0 +1,19 @@
// Package paneldecl 是 design/09-插件生态 §5 的「插件管理面板声明」在宿主侧的库:
// 校验(validate)、清单目录(catalog)、when 求值、按角色过滤。
//
// 职责边界(§5.2.1 B「插件管理器 = 唯一中枢」里的前三项):
//
// - 声明校验:闭集 + 形状 + 命名,逐条给出**可分类**的错误;非法声明绝不静默丢弃
// (口径见 Catalog.Register 与 PLG1-report.md)。
// - 清单目录:收集各插件显式声明的 adminPanels,编译期内置插件与热装条目进**同一张**
// 目录;生命周期(install/enable/disable/uninstall)重算面板集合。
// - 按角色过滤 + when 求值:owner/admin/viewer 与「是否启用」决定下发给面板的快照。
//
// 刻意**不做**的事(这一片是纯库,接线留给后续切片):
//
// - 不注册任何 HTTP 端点、不认识 gateway、不认识前端(§5.2.1 的「唯一契约面」
// GET /api/v1/admin/panels 由后续切片按契约先行登记进 Docs/api-client.md);
// - 不读环境变量、不读文件、不读 config:when 的求值上下文由调用方(插件管理器)
// 注入(见 Resolver)—— 单一事实来源,也让本包可以在没有宿主的情况下单测;
// - 不执行动作、不落审计:本包只声明「动作名」,分发与审计在 §5.2.1 的后半段。
package paneldecl
+138
View File
@@ -0,0 +1,138 @@
package paneldecl
import (
"errors"
"fmt"
"strings"
)
// 本文件是校验错误的**可分类**表示。
//
// 为什么不用一句 `invalid`:§5.2.1 要求「非法声明 ⇒ 该面板不注册 + 显式报错」,而错误
// 要同时喂给三种消费者 —— 插件管理器(决定拒哪个面板)、启动期日志(排障)、以及未来
// 的安装响应(把错误码与文案分离,照 Home Assistant 的「错误码与文案分离」,前端可本地化)。
// 所以错误必须是结构化的:哨兵做分类,Path 做定位,Detail 做人读说明。
// 校验失败分类哨兵。调用方用 errors.Is 判类,不靠字符串匹配。
var (
// ErrInvalidPluginID 报告插件 id 不合法(无法作为目录命名空间 / 路由段)。
ErrInvalidPluginID = errors.New("paneldecl: invalid plugin id")
// ErrInvalidPanelID 报告面板 id 不合法。
ErrInvalidPanelID = errors.New("paneldecl: invalid panel id")
// ErrInvalidSectionID 报告区块 id 不合法。
ErrInvalidSectionID = errors.New("paneldecl: invalid section id")
// ErrInvalidRowActionID 报告行内动作 id 不合法。
ErrInvalidRowActionID = errors.New("paneldecl: invalid row action id")
// ErrInvalidFieldKey 报告字段 key 不合法(字段 key 是配置键,字符集比 id 宽,见 shape.go)。
ErrInvalidFieldKey = errors.New("paneldecl: invalid field key")
// ErrMissingField 报告必填项缺失(id / title / section 的 type / field 的 key、type 等)。
ErrMissingField = errors.New("paneldecl: required field missing")
// ErrInvalidText 报告文案非法:空白、含控制字符、或超出长度上限。
ErrInvalidText = errors.New("paneldecl: invalid text")
// ErrUnknownGroup 报告分组不在闭集内(本期只有 plugins)。
ErrUnknownGroup = errors.New("paneldecl: unknown panel group")
// ErrUnknownSectionType 报告 section 类型不在闭集内。
ErrUnknownSectionType = errors.New("paneldecl: unknown section type")
// ErrUnknownFieldType 报告字段类型不在闭集内。
ErrUnknownFieldType = errors.New("paneldecl: unknown field type")
// ErrSectionShape 报告区块形状错误:该类型不该出现的键出现了(如 table 上写 fields)。
ErrSectionShape = errors.New("paneldecl: invalid section shape")
// ErrReadOnlySection 报告只读区块上出现了写面(status/log 上的 submit / rowActions)。
ErrReadOnlySection = errors.New("paneldecl: read-only section declares a write surface")
// ErrInvalidOptionsFrom 报告 optionsFrom 非法:只允许 `plugin:*`,且只允许出现在下拉字段上。
ErrInvalidOptionsFrom = errors.New("paneldecl: invalid optionsFrom")
// ErrInvalidAction 报告动作名非法(允许点分名,禁止 / : 等 URL 成分)。
ErrInvalidAction = errors.New("paneldecl: invalid action name")
// ErrInvalidOrder 报告 order 为负。
ErrInvalidOrder = errors.New("paneldecl: invalid order")
// ErrInvalidBounds 报告 min/max 非法(配在非 number 字段上、NaN、或 min > max)。
ErrInvalidBounds = errors.New("paneldecl: invalid numeric bounds")
// ErrInvalidDefault 报告默认值与字段类型不匹配。
ErrInvalidDefault = errors.New("paneldecl: default value does not match the field type")
// ErrSecretDefault 报告 secret 字段声明了默认值(§5.4.2 读侧无明文)。
ErrSecretDefault = errors.New("paneldecl: secret field must not declare a default")
// ErrInvalidLimit 报告 log 区块的 limit 越界。
ErrInvalidLimit = errors.New("paneldecl: invalid log limit")
// ErrInvalidIcon 报告图标名非法。
ErrInvalidIcon = errors.New("paneldecl: invalid icon name")
// ErrDuplicateID 报告同一作用域内 id / key 重复。
ErrDuplicateID = errors.New("paneldecl: duplicate id")
// ErrUnknownRole 报告角色不在 owner/admin/viewer 之内(查询目录时使用)。
ErrUnknownRole = errors.New("paneldecl: unknown role")
// ErrNilResolver 报告构造目录时没有注入 when 求值上下文来源。
ErrNilResolver = errors.New("paneldecl: nil panel context resolver")
// ErrNilCatalog 报告在 nil *Catalog 上调用了 Register。
ErrNilCatalog = errors.New("paneldecl: nil catalog")
)
// maxReportedViolations 限制聚合错误在 Error() 里铺开的条数:一个坏清单可能有几百条
// 违规,全铺进一行日志会把有用的信息挤掉。结构化的完整列表仍在 Violations 里,不丢。
const maxReportedViolations = 5
// Violation 是一条**可分类**的校验失败。
type Violation struct {
// Kind 是分类哨兵(本文件的 Err*),errors.Is 沿着它判定。
Kind error
// Path 是出问题的位置,索引式 JSON 路径,如
// `adminPanels[0].sections[1].fields[2].type` —— 索引而不是 id,因为出错的可能正是 id。
Path string
// Detail 是人读说明,只讲这一条问题本身,不含他人信息。
Detail string
}
// Error 实现 error。格式固定为 `<分类>: <路径>: <说明>`,便于日志切片与测试断言。
func (v Violation) Error() string {
return fmt.Sprintf("%s: %s: %s", v.Kind, v.Path, v.Detail)
}
// Unwrap 让 errors.Is(err, ErrXxx) 能穿过这条违规。
func (v Violation) Unwrap() error { return v.Kind }
// Errors 是逐条违规的聚合。它的存在是为了「一次把所有问题讲清楚」—— 逐个报错会让插件
// 作者改一轮、跑一轮。
type Errors struct {
// Violations 按发现顺序排列(声明顺序,因此可复现)。
Violations []Violation
}
// Error 实现 error,最多铺开 maxReportedViolations 条,其余只报条数。
func (e *Errors) Error() string {
if e == nil || len(e.Violations) == 0 {
return "paneldecl: no violations"
}
parts := make([]string, 0, maxReportedViolations)
for i, v := range e.Violations {
if i == maxReportedViolations {
parts = append(parts, fmt.Sprintf("...(+%d more)", len(e.Violations)-maxReportedViolations))
break
}
parts = append(parts, v.Error())
}
return fmt.Sprintf("paneldecl: %d violation(s): %s", len(e.Violations), strings.Join(parts, "; "))
}
// Unwrap 返回每一条违规,让 errors.Is / errors.As 能穿透聚合看分类。
func (e *Errors) Unwrap() []error {
if e == nil {
return nil
}
out := make([]error, 0, len(e.Violations))
for _, v := range e.Violations {
out = append(out, v)
}
return out
}
// violation 构造一条违规。
func violation(kind error, path, detail string) Violation {
return Violation{Kind: kind, Path: path, Detail: detail}
}
// violationsToError 把逐条违规收成一个 error:空切片返回 nil(Go 的 nil-error 约定,
// 免得调用方写 `if len(vs) > 0 { return &Errors{vs} }`)。
func violationsToError(vs []Violation) error {
if len(vs) == 0 {
return nil
}
return &Errors{Violations: vs}
}
+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)
}
+57
View File
@@ -0,0 +1,57 @@
package paneldecl
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件是「按角色过滤面板」的口径落点。依据 design/09 §5.4.1 与 §5.2.1 B:
//
// - 面板可见性与**数据面**都按 owner / admin / viewer 分级;
// - 只读事实面对 viewer 可见;写动作一律 owner/admin(沿用 design/11 的 writeAuth);
// - 逐 section 判 owner / admin / viewer(§5.2.1 B 的「鉴权与敏感策略」一行)。
//
// 设计没写清的处置**一律只收紧不放宽**(每一条都在下面注明),取舍记在 PLG1-report.md。
// filterPanel 按角色裁剪一个面板。第二个返回值为 false 表示该面板对该角色整体不可见。
//
// owner/admin 原样下发(写面可见);viewer 只留下只读区块,并把表格的行内动作摘掉。
//
// 收紧点(设计未写、按「只收紧不放宽」处理):
// - 行内动作(rowActions)对 viewer 一律摘除 —— §5.4.1 说「写动作一律 owner/admin」,
// 而行内动作就是写动作(示例:测试连通 / 重配凭据),声明里没有「只读动作」这种标记,
// 所以一律按写处理;
// - 一个区块都不剩时**整条入口不出现** —— 让 viewer 点进一个空页面等于把「没有权限」
// 表达成「页面坏了」;隐藏入口不泄露任何信息(数据面另有鉴权)。
func filterPanel(role auth.Role, panel sdk.Panel) (sdk.Panel, bool) {
if auth.HasRole(role, auth.RoleAdmin) {
return panel, true
}
out := panel
out.Sections = make([]sdk.Section, 0, len(panel.Sections))
for _, s := range panel.Sections {
fs, ok := filterSectionForViewer(s)
if ok {
out.Sections = append(out.Sections, fs)
}
}
if len(out.Sections) == 0 {
return sdk.Panel{}, false
}
return out, true
}
// filterSectionForViewer 裁剪一个区块给 viewer 看。
//
// 保留:status / log / table(§5.4.1「只读事实面对 viewer 可见」)。
// 丢弃:form —— 它是**唯一自带写面**的类型(submit.action),且在 §5.2.1 里对应
// `section.form` 的「写」侧,对 viewer 没有只读形态可言(本模型没有「只读表单」这种声明)。
func filterSectionForViewer(s sdk.Section) (sdk.Section, bool) {
if !s.Type.ReadOnly() {
return sdk.Section{}, false
}
out := s
// 摘掉行内动作而不是丢掉整张表:列(事实)对 viewer 有用,按钮(写)不该出现。
out.RowActions = nil
return out, true
}
@@ -0,0 +1,169 @@
package paneldecl
import (
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件锁定角色过滤(§5.4.1 角色门 + §5.2.1「逐 section 判 owner/admin/viewer」):
// owner/admin 全量、viewer 只见只读事实面且不见任何写面。
// mixedPanel 是一份同时含写面与只读面的面板:form(写)+ table(行内动作)+ status + log。
// 它是对「viewer 到底还能看见什么」最严格的一份夹具。
func mixedPanel() sdk.Panel {
p := samplePanel()
p.Sections = append(p.Sections, statusPanel().Sections[0], logPanel().Sections[0])
return p
}
// sectionIDs 抽取区块 id 序列(顺序敏感)。
func sectionIDs(p sdk.Panel) []string {
ids := make([]string, 0, len(p.Sections))
for _, s := range p.Sections {
ids = append(ids, s.ID)
}
return ids
}
// anySecretField 报告面板里是否还残留 secret 字段声明。
func anySecretField(p sdk.Panel) bool {
for _, s := range p.Sections {
for _, f := range s.Fields {
if f.Type.Sensitive() {
return true
}
}
}
return false
}
// panelByID 在快照里找某个面板。
func panelByID(t *testing.T, entries []Entry, id string) sdk.Panel {
t.Helper()
for _, e := range entries {
if e.Panel.ID == id {
return e.Panel
}
}
t.Fatalf("快照里没有面板 %q(实际 %v)", id, mustPanelIDs(t, entries))
return sdk.Panel{}
}
// TestList_OwnerAndAdminSeeEverything 锁定上半段:owner/admin 拿到完整声明(写面、secret
// 字段、行内动作一个不少)—— 过滤只对 viewer 收紧,不能顺手把手握写权的人也削了。
func TestList_OwnerAndAdminSeeEverything(t *testing.T) {
t.Parallel()
cat := newTestCatalog(t, alwaysEnabled)
mustRegister(t, cat, "web-search", mixedPanel())
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin} {
role := role
t.Run(string(role), func(t *testing.T) {
t.Parallel()
entries, err := cat.List(role)
if err != nil {
t.Fatalf("List(%s): %v", role, err)
}
got := panelByID(t, entries, "providers")
if len(got.Sections) != 4 {
t.Fatalf("%s 看到 %d 个区块,期望 4", role, len(got.Sections))
}
if !anySecretField(got) {
t.Fatalf("%s 应当看得到 secret 字段声明(读侧掩码是数据面的事,不是声明面)", role)
}
if got.Sections[0].Submit == nil {
t.Fatalf("%s 应当看得到 form 的 submit", role)
}
if len(got.Sections[1].RowActions) != 2 {
t.Fatalf("%s 应当看得到 2 个行内动作,实际 %d", role, len(got.Sections[1].RowActions))
}
})
}
}
// TestList_ViewerLosesWriteSurfaces 锁定下半段:viewer 只见只读区块,form 整体消失
// (连带 secret 写面),表格保留但行内动作被摘掉。
func TestList_ViewerLosesWriteSurfaces(t *testing.T) {
t.Parallel()
cat := newTestCatalog(t, alwaysEnabled)
mustRegister(t, cat, "web-search", mixedPanel())
entries, err := cat.List(auth.RoleViewer)
if err != nil {
t.Fatalf("List(viewer): %v", err)
}
got := panelByID(t, entries, "providers")
if ids := sectionIDs(got); len(ids) != 3 || ids[0] != "list" {
t.Fatalf("viewer 的区块 = %v,期望 [list chain recent](form 整体消失)", ids)
}
for _, s := range got.Sections {
if s.Type == sdk.SectionForm {
t.Fatal("viewer 不该看到 form(它是唯一自带写面的区块类型)")
}
if s.Submit != nil || len(s.RowActions) != 0 {
t.Fatalf("viewer 不该看到写面:submit=%v rowActions=%v", s.Submit, s.RowActions)
}
}
if anySecretField(got) {
t.Fatal("viewer 不该看到 secret 字段(它是写面字段)")
}
// 事实本身要留着:列还在(否则「只读事实面对 viewer 可见」就成了空话)。
table := got.Sections[0]
if len(table.Columns) != 2 || table.Source == nil {
t.Fatalf("viewer 应当看得到表格的列与只读数据源:%+v", table)
}
}
// TestList_ViewerHidesEmptyEntrances 锁定收紧点:一个只读区块都不剩时,入口整个不出现 ——
// 让 viewer 点进空页面等于把「没有权限」表达成「页面坏了」。
func TestList_ViewerHidesEmptyEntrances(t *testing.T) {
t.Parallel()
cat := newTestCatalog(t, alwaysEnabled)
writeOnly := samplePanel()
// 只留 form 一段 ⇒ 对 viewer 一个只读区块都不剩。
writeOnly.Sections = writeOnly.Sections[:1]
mustRegister(t, cat, "web-search", writeOnly)
entries, err := cat.List(auth.RoleViewer)
if err != nil {
t.Fatalf("List(viewer): %v", err)
}
if len(entries) != 0 {
t.Fatalf("纯写面板对 viewer 应当整条不出现,实际 %v", mustPanelIDs(t, entries))
}
// 直接对 filterPanel 再锁一次返回值语义(避免只靠 List 的一层包装)。
if _, visible := filterPanel(auth.RoleViewer, writeOnly); visible {
t.Fatal("纯写面板对 viewer 应当不可见")
}
if got, visible := filterPanel(auth.RoleAdmin, writeOnly); !visible || len(got.Sections) != 1 {
t.Fatalf("admin 应当可见且区块不丢:visible=%v sections=%d", visible, len(got.Sections))
}
}
// TestList_ViewerKeepsReadOnlyTables 锁定「表格保留、动作摘除」这一半:不能因为表里有写动作
// 就把整张表藏掉(那样 viewer 连事实都看不到)。
func TestList_ViewerKeepsReadOnlyTables(t *testing.T) {
t.Parallel()
cat := newTestCatalog(t, alwaysEnabled)
table := samplePanel()
table.Sections = table.Sections[1:]
mustRegister(t, cat, "web-search", table)
entries, err := cat.List(auth.RoleViewer)
if err != nil {
t.Fatalf("List(viewer): %v", err)
}
if len(entries) != 1 {
t.Fatalf("带行内动作的表格面板对 viewer 仍应可见,实际 %v", mustPanelIDs(t, entries))
}
got := entries[0].Panel
if len(got.Sections) != 1 || len(got.Sections[0].Columns) != 2 {
t.Fatalf("viewer 应当看到表格与列:%+v", got.Sections)
}
if got.Sections[0].RowActions != nil {
t.Fatalf("viewer 的行内动作应当被摘成 nil,实际 %+v", got.Sections[0].RowActions)
}
}
@@ -0,0 +1,108 @@
package paneldecl
import (
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件是测试夹具:一份**逐字对齐 design/09 §5.2 示例**的合法声明。
//
// 为什么所有非法用例都从它派生:只有这样才能保证「被拒的是那一处改动,而不是碰巧别处也不
// 合法」—— 否则一个用例变红,无法区分是目标规则生效还是夹具本身有毛病。
// boolPtr 是 &true / &false 的简写(sdk.When.Enabled 是三态指针)。
func boolPtr(v bool) *bool { return &v }
// floatPtr 是 &x 的简写(sdk.Field.Min / Max)。
func floatPtr(v float64) *float64 { return &v }
// samplePanel 返回 §5.2 示例里的面板(web-search 的 providers 页):form + table 两段,
// 覆盖 secret / select(optsFrom) / text 三种字段与两个行内动作。
func samplePanel() sdk.Panel {
return sdk.Panel{
ID: "providers",
Title: "搜索提供商",
Group: sdk.GroupPlugins,
Order: 20,
Icon: "search",
When: &sdk.When{Enabled: boolPtr(true)},
Sections: []sdk.Section{
{
Type: sdk.SectionForm,
ID: "add",
Title: "添加提供商",
Fields: []sdk.Field{
{Key: "provider", Label: "提供商", Type: sdk.FieldSelect,
OptionsFrom: "plugin:providers", Required: true},
{Key: "apiKey", Label: "API Key", Type: sdk.FieldSecret, Required: true},
{Key: "endpoint", Label: "接口地址", Type: sdk.FieldText, Placeholder: "https://..."},
},
Submit: &sdk.Submit{Action: "plugin.saveProvider"},
},
{
Type: sdk.SectionTable,
ID: "list",
Title: "已配置的提供商",
Source: &sdk.Source{Action: "plugin.listProviders"},
Columns: []sdk.Column{
{Key: "provider", Label: "提供商"},
{Key: "state", Label: "状态"},
},
RowActions: []sdk.RowAction{
{ID: "test", Label: "测试连通", Action: "plugin.testProvider"},
{ID: "reauth", Label: "重配凭据", Action: "plugin.reauthProvider"},
},
},
},
}
}
// statusPanel 是只读事实面夹具(§5.2.1 section.status)。
func statusPanel() sdk.Panel {
return sdk.Panel{
ID: "chain",
Title: "降级链",
Group: sdk.GroupPlugins,
Order: 10,
Sections: []sdk.Section{{
Type: sdk.SectionStatus,
ID: "chain",
Title: "当前生效的降级链",
Fields: []sdk.Field{
{Key: "active", Label: "实际应答者", Type: sdk.FieldText},
},
}},
}
}
// logPanel 是只读日志卡夹具(§5.2.1 section.log)。
func logPanel() sdk.Panel {
return sdk.Panel{
ID: "trace",
Title: "最近调用",
Group: sdk.GroupPlugins,
Order: 30,
Sections: []sdk.Section{{
Type: sdk.SectionLog,
ID: "recent",
Title: "最近 N 条",
Source: &sdk.Source{Action: "plugin.recentCalls"},
Limit: 50,
}},
}
}
// mustPanelIDs 抽取目录快照里的面板 id 序列(顺序敏感,用于稳定序断言)。
func mustPanelIDs(t *testing.T, entries []Entry) []string {
t.Helper()
ids := make([]string, 0, len(entries))
for _, e := range entries {
ids = append(ids, e.Panel.ID)
}
return ids
}
// alwaysEnabled / neverEnabled 是两种最简 Resolver 桩。
func alwaysEnabled(string) Context { return Context{Enabled: true} }
func neverEnabled(string) Context { return Context{} }
+182
View File
@@ -0,0 +1,182 @@
package paneldecl
import (
"fmt"
"regexp"
"strings"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件是「形状」层:字符集、长度上限、以及 §5.2.1 接入点表的键约束。
//
// 为什么要限定字符集而不是照单全收:面板 id 与插件 id 要拼成路由
// /admin/plugin/<pluginId>/<panelId>(§5.7 C)与端点 /api/v1/admin/plugins/{id}/actions/{action}
// (§5.2.1),「能直接当路径段用」是硬要求;图标名与动作名则要防「声明里塞 URL/SVG」
// (§5.4.4 不给插件开 SSRF 面)。
// 长度上限。都是「撑坏 Miuix 布局」与「URL/日志行不可读」两类实际后果倒推出来的,不是随手取的数。
const (
// idMaxLen 是 id 长度上限。既有插件 id 是 32 位十六进制(runtimeconfig.NewID),
// 面板/区块 id 是短名字,64 给前者留了一倍余量。
idMaxLen = 64
// titleMaxRunes 是标题/标签文案上限。侧边栏与页面壳的排版宽度决定它,超出即截断,
// 200 个字符足够任何语言的正常标题。
titleMaxRunes = 200
// hintMaxRunes 是 placeholder / description 这类辅助文案上限,比标题宽一档
// (接口地址、说明段落会更长)。
hintMaxRunes = 500
// iconMaxLen 是图标名上限。宿主内置图标名的量级是十几个字符,64 已极宽。
iconMaxLen = 64
// actionMaxLen 是动作名上限。示例 plugin.reauthProvider 是 21 字符,64 足够。
actionMaxLen = 64
// maxLogLimit 是 log 区块 `limit` 的上限。log 是「只读最近 N 条」的排障卡,
// 一次拉上万条会把页面卡死;1000 与既有日志尾读的量级一致。
maxLogLimit = 1000
)
// 编译期正则。包级 var 存的是**不可变**的编译产物(与 internal/runtime/memory、
// internal/runtime/tool 等既有文件同一手法),不是可变全局状态。
var (
// idRe 是 id(插件 / 面板 / 区块 / 行内动作)的形状:小写字母数字,可含中划线分段。
// 依据 §5.2 示例 web-search / providers / add / list / test / reauth,并禁止大小写混用
// (URL 路径段大小写敏感,混用会引入难查的 404)。
idRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
// fieldKeyRe 是字段 key 的形状:§5.2 示例是 provider / apiKey / endpoint —— 小驼峰,
// 因为它同时是**配置键**(写进插件配置段),比 id 宽一档:允许大写与下划线。
fieldKeyRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`)
// optionsFromRe 是下拉选项来源:只允许 `plugin:` 前缀的名字(§5.2 示例 plugin:providers)。
// 禁止 `/`、第二个 `:` 与空白 ⇒ "http://…" 这类 URL 声明在校验期就被拒。
optionsFromRe = regexp.MustCompile(`^plugin:[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$`)
// actionRe 是动作名:§5.2 示例 plugin.saveProvider / plugin.listProviders / plugin.testProvider。
// 允许点分名,**禁止** `/` 与 `:` —— 动作只是名字,不是 URL(§5.4.4)。
actionRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*$`)
// iconRe 是图标名:宿主内置图标表的名字,禁止任何标点(尤其 `<` / `{` / `;`),
// 顺带堵死「把 SVG/CSS 片段塞进 icon」这条路。
iconRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)
)
// requirement 是某个键在某种 section 类型上的必要性。
type requirement uint8
const (
// notAllowed:该键在这类区块上没有意义,出现即形状错误。
notAllowed requirement = iota
// optional:可以出现,可以不出现。
optional
// required:必须出现(缺 ⇒ 必填缺失)。
required
// writeForbidden:这是**写面**键,出现在只读区块上即越界(§5.4.1 写动作一律 owner/admin)。
writeForbidden
)
// sectionShape 是 §5.2.1 接入点表的可执行形式:每种区块消费哪些键、哪些是写面。
//
// 为什么要把这张表写成硬校验:宿主是「按声明泛化渲染」的,一个 form 没有 submit、
// 一个 table 没有 columns,到前端就是**半渲染**(区块在、功能不在)—— 正是 §5.2.1
// 点名要防的。所以形状错误在声明期整体拒掉该面板,并显式报错。
type sectionShape struct {
fields requirement
submit requirement
source requirement
columns requirement
rowActions requirement
limit requirement
}
// shapeOf 返回某类区块的键约束(§5.2.1 的 section.form / section.table / section.status /
// section.log 四行)。写成函数而不是包级 map:本仓禁包级可变全局,且 switch 让「新增
// section 类型忘了补形状」在 review 时一眼可见。
func shapeOf(t sdk.SectionType) sectionShape {
switch t {
case sdk.SectionForm:
return sectionShape{fields: required, submit: required}
case sdk.SectionTable:
return sectionShape{
columns: required, source: required,
rowActions: optional, submit: writeForbidden,
}
case sdk.SectionStatus:
return sectionShape{
fields: required, submit: writeForbidden, rowActions: writeForbidden,
}
case sdk.SectionLog:
return sectionShape{
source: required, limit: optional, submit: writeForbidden, rowActions: writeForbidden,
}
default:
return sectionShape{}
}
}
// normalizeGroup 把空分组按 §5.2「本期固定 plugins」回落到唯一分组。
//
// 为什么把空值当「用默认」而不是「必填缺失」:分组本期只有闭集里的一个成员,缺省不存在
// 歧义;而四处要求插件作者重复写同一个常量只会制造漏写。非空但不是闭集成员的仍然拒绝。
func normalizeGroup(g sdk.GroupID) sdk.GroupID {
if strings.TrimSpace(string(g)) == "" {
return sdk.GroupPlugins
}
return g
}
// validID 报告 id 是否满足路径段形状。
func validID(id string) bool {
return len(id) > 0 && len(id) <= idMaxLen && idRe.MatchString(id)
}
// validFieldKey 报告字段 key 是否满足「配置键」形状。
func validFieldKey(key string) bool { return key != "" && fieldKeyRe.MatchString(key) }
// validAction 报告动作名是否合法。
func validAction(action string) bool {
return len(action) > 0 && len(action) <= actionMaxLen && actionRe.MatchString(action)
}
// validIcon 报告图标名是否合法。
func validIcon(icon string) bool {
return len(icon) > 0 && len(icon) <= iconMaxLen && iconRe.MatchString(icon)
}
// checkOptionalText 校验一段可选文案:允许缺省,但不允许「只有空白」、控制字符、
// 或超过 maxRunes(长度超限会把侧边栏/页面壳排版撑坏,属实际后果而非洁癖)。
func checkOptionalText(value, path string, maxRunes int) []Violation {
if value == "" {
return nil
}
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return []Violation{violation(ErrInvalidText, path, "文案不能只有空白字符")}
}
if n := utf8.RuneCountInString(trimmed); n > maxRunes {
return []Violation{violation(ErrInvalidText, path,
fmt.Sprintf("文案长度 %d 超过上限 %d", n, maxRunes))}
}
if r := firstControlRune(trimmed); r >= 0 {
return []Violation{violation(ErrInvalidText, path,
fmt.Sprintf("文案含控制字符 %q(前端渲染与日志都会被污染)", r))}
}
return nil
}
// checkRequiredText 校验一段必填文案:缺省即必填缺失,其余同 checkOptionalText。
func checkRequiredText(value, path string, maxRunes int) []Violation {
if strings.TrimSpace(value) == "" {
return []Violation{violation(ErrMissingField, path, "必填文案缺失或全为空白")}
}
return checkOptionalText(value, path, maxRunes)
}
// firstControlRune 返回第一个控制字符,没有则 -1。
func firstControlRune(s string) rune {
for _, r := range s {
if r < 0x20 || r == 0x7f {
return r
}
}
return -1
}
// panelPath 是面板在声明里的索引式路径前缀(§5.2 的 adminPanels 数组)。
func panelPath(i int) string { return fmt.Sprintf("adminPanels[%d]", i) }
+252
View File
@@ -0,0 +1,252 @@
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))
}
@@ -0,0 +1,186 @@
package paneldecl
import (
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件是字段层与表格元素层(列 / 行内动作)的拒绝用例。
// rejectCasesFieldCore 是字段的必填、形状与选项来源。
func rejectCasesFieldCore() []rejectCase {
return []rejectCase{
{
name: "字段 key 缺失",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Fields[0].Key = "" })
},
want: ErrMissingField, wantPath: "adminPanels[0].sections[0].fields[0].key",
},
{
name: "字段 key 形状非法(中划线)",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Fields[0].Key = "api-key" })
},
want: ErrInvalidFieldKey, wantPath: "adminPanels[0].sections[0].fields[0].key",
},
{
name: "字段类型不在闭集",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Fields[0].Type = "chart" })
},
want: ErrUnknownFieldType, wantPath: "adminPanels[0].sections[0].fields[0].type",
},
{
name: "字段 key 在区块内重复",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Fields[1].Key = "provider" })
},
want: ErrDuplicateID, wantPath: "adminPanels[0].sections[0].fields[1].key",
},
{
name: "optionsFrom 塞了任意 URL",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) {
p.Sections[0].Fields[0].OptionsFrom = "https://api.example.com/list"
})
},
want: ErrInvalidOptionsFrom, wantPath: "adminPanels[0].sections[0].fields[0].optionsFrom",
},
{
name: "optionsFrom 配在 text 字段上",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) {
p.Sections[0].Fields[2].OptionsFrom = "plugin:providers"
})
},
want: ErrInvalidOptionsFrom, wantPath: "adminPanels[0].sections[0].fields[2].optionsFrom",
},
{
name: "字段文案含控制字符",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) {
p.Sections[0].Fields[2].Label = "接口\n地址"
})
},
want: ErrInvalidText, wantPath: "adminPanels[0].sections[0].fields[2].label",
},
}
}
// rejectCasesFieldBoundsAndDefaults 是 secret 默认值、数值边界与默认值类型。
func rejectCasesFieldBoundsAndDefaults() []rejectCase {
return []rejectCase{
{
name: "secret 字段声明默认值",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) {
p.Sections[0].Fields[1].Default = "sk-leaked"
})
},
want: ErrSecretDefault, wantPath: "adminPanels[0].sections[0].fields[1].default",
},
{
name: "min/max 配在 text 字段上",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) {
p.Sections[0].Fields[2].Min = floatPtr(1)
})
},
want: ErrInvalidBounds, wantPath: "adminPanels[0].sections[0].fields[2].min",
},
{
name: "min 大于 max",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) {
f := &p.Sections[0].Fields[2]
f.Type = sdk.FieldNumber
f.Min = floatPtr(10)
f.Max = floatPtr(1)
})
},
want: ErrInvalidBounds, wantPath: "adminPanels[0].sections[0].fields[2].min",
},
{
name: "默认值与字段类型不匹配(下拉字段给了数字)",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) {
p.Sections[0].Fields[0].Default = 42
})
},
want: ErrInvalidDefault, wantPath: "adminPanels[0].sections[0].fields[0].default",
},
}
}
// rejectCasesTableItems 是列与行内动作的拒绝用例。
func rejectCasesTableItems() []rejectCase {
return []rejectCase{
{
name: "列 key 缺失",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].Columns[0].Key = "" })
},
want: ErrMissingField, wantPath: "adminPanels[0].sections[1].columns[0].key",
},
{
name: "列 key 重复",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].Columns[1].Key = "provider" })
},
want: ErrDuplicateID, wantPath: "adminPanels[0].sections[1].columns[1].key",
},
{
name: "列 label 含控制字符",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].Columns[0].Label = "状\t态" })
},
want: ErrInvalidText, wantPath: "adminPanels[0].sections[1].columns[0].label",
},
{
name: "行内动作 id 含大写",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].RowActions[0].ID = "Test" })
},
want: ErrInvalidRowActionID, wantPath: "adminPanels[0].sections[1].rowActions[0].id",
},
{
name: "行内动作缺 button 文案",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].RowActions[0].Label = "" })
},
want: ErrMissingField, wantPath: "adminPanels[0].sections[1].rowActions[0].label",
},
{
name: "行内动作塞了任意 URL",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) {
p.Sections[1].RowActions[0].Action = "https://evil.example.com/x"
})
},
want: ErrInvalidAction, wantPath: "adminPanels[0].sections[1].rowActions[0].action",
},
{
name: "行内动作 id 重复",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].RowActions[1].ID = "test" })
},
want: ErrDuplicateID, wantPath: "adminPanels[0].sections[1].rowActions[1].id",
},
{
name: "submit 有动作键但没有动作名",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Submit = &sdk.Submit{} })
},
want: ErrMissingField, wantPath: "adminPanels[0].sections[0].submit.action",
},
{
name: "source 动作名含冒号(URL 形态)",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) {
p.Sections[1].Source = &sdk.Source{Action: "http://api.example.com/list"}
})
},
want: ErrInvalidAction, wantPath: "adminPanels[0].sections[1].source.action",
},
}
}
@@ -0,0 +1,174 @@
package paneldecl
import (
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件是区块层的拒绝用例:类型闭集、id/标题必填、以及 §5.2.1 接入点表的形状矩阵
// (哪些键必须出现、哪些不许出现、哪些是只读区块上的写面)。
// rejectCasesSectionCommon 是区块的通用必填与唯一性。
func rejectCasesSectionCommon() []rejectCase {
return []rejectCase{
{
name: "区块类型不在闭集",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Type = "chart" })
},
want: ErrUnknownSectionType, wantPath: "adminPanels[0].sections[0].type",
},
{
name: "区块 id 缺失",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].ID = "" })
},
want: ErrMissingField, wantPath: "adminPanels[0].sections[0].id",
},
{
name: "区块标题缺失",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Title = "" })
},
want: ErrMissingField, wantPath: "adminPanels[0].sections[0].title",
},
{
name: "区块 id 在面板内重复",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].ID = "add" })
},
want: ErrDuplicateID, wantPath: "adminPanels[0].sections[1].id",
},
}
}
// rejectCasesFormShape 是 form 区块的形状(§5.2.1 section.form 只消费 fields[] + submit.action)。
func rejectCasesFormShape() []rejectCase {
return []rejectCase{
{
name: "form 缺 submit",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Submit = nil })
},
want: ErrMissingField, wantPath: "adminPanels[0].sections[0].submit",
},
{
name: "form 缺 fields",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Fields = nil })
},
want: ErrMissingField, wantPath: "adminPanels[0].sections[0].fields",
},
{
name: "form 声明 source(形状错误)",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) {
p.Sections[0].Source = &sdk.Source{Action: "plugin.listProviders"}
})
},
want: ErrSectionShape, wantPath: "adminPanels[0].sections[0].source",
},
{
name: "form 声明 columns(形状错误)",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) {
p.Sections[0].Columns = []sdk.Column{{Key: "k"}}
})
},
want: ErrSectionShape, wantPath: "adminPanels[0].sections[0].columns",
},
}
}
// rejectCasesTableShape 是 table 区块的形状(§5.2.1 section.table 消费 columns[]/rowActions[]/source)。
func rejectCasesTableShape() []rejectCase {
return []rejectCase{
{
name: "table 缺 columns",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].Columns = nil })
},
want: ErrMissingField, wantPath: "adminPanels[0].sections[1].columns",
},
{
name: "table 缺 source",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].Source = nil })
},
want: ErrMissingField, wantPath: "adminPanels[0].sections[1].source",
},
{
name: "table 上出现写面 submit",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) {
p.Sections[1].Submit = &sdk.Submit{Action: "plugin.saveProvider"}
})
},
want: ErrReadOnlySection, wantPath: "adminPanels[0].sections[1].submit",
},
{
name: "table 声明字段(形状错误)",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) {
p.Sections[1].Fields = []sdk.Field{{Key: "state", Type: sdk.FieldText}}
})
},
want: ErrSectionShape, wantPath: "adminPanels[0].sections[1].fields",
},
}
}
// rejectCasesReadOnlySections 是只读区块(status / log)的写面与形状。
func rejectCasesReadOnlySections() []rejectCase {
return []rejectCase{
{
name: "status 上出现写面 submit",
panels: func() []sdk.Panel {
return panelsOf(statusPanel(), func(p *sdk.Panel) {
p.Sections[0].Submit = &sdk.Submit{Action: "plugin.saveProvider"}
})
},
want: ErrReadOnlySection, wantPath: "adminPanels[0].sections[0].submit",
},
{
name: "status 上出现行内动作",
panels: func() []sdk.Panel {
return panelsOf(statusPanel(), func(p *sdk.Panel) {
p.Sections[0].RowActions = []sdk.RowAction{{ID: "test", Label: "测试", Action: "plugin.t"}}
})
},
want: ErrReadOnlySection, wantPath: "adminPanels[0].sections[0].rowActions",
},
{
name: "status 声明 source(形状错误)",
panels: func() []sdk.Panel {
return panelsOf(statusPanel(), func(p *sdk.Panel) {
p.Sections[0].Source = &sdk.Source{Action: "plugin.chain"}
})
},
want: ErrSectionShape, wantPath: "adminPanels[0].sections[0].source",
},
{
name: "log 缺 source",
panels: func() []sdk.Panel {
return panelsOf(logPanel(), func(p *sdk.Panel) { p.Sections[0].Source = nil })
},
want: ErrMissingField, wantPath: "adminPanels[0].sections[0].source",
},
{
name: "log 的 limit 越界",
panels: func() []sdk.Panel {
return panelsOf(logPanel(), func(p *sdk.Panel) { p.Sections[0].Limit = 5000 })
},
want: ErrInvalidLimit, wantPath: "adminPanels[0].sections[0].limit",
},
{
name: "log 声明 fields(形状错误)",
panels: func() []sdk.Panel {
return panelsOf(logPanel(), func(p *sdk.Panel) {
p.Sections[0].Fields = []sdk.Field{{Key: "line", Type: sdk.FieldText}}
})
},
want: ErrSectionShape, wantPath: "adminPanels[0].sections[0].fields",
},
}
}
@@ -0,0 +1,249 @@
package paneldecl
import (
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件锁定「非法声明逐条可分类」这条验收线:每一类非法声明一条用例,断言的是
// **分类哨兵 + 出错路径**,不是错误字符串(字符串匹配会在改文案时假绿)。
//
// 用例表按层次分在三个文件里:本文件(插件/面板层与聚合错误)、validate_section_test.go
// (区块层)、validate_field_test.go(字段与表格元素层)。
// rejectCase 是一条拒绝用例。
type rejectCase struct {
name string
// pluginID 为空表示用默认的 "web-search";blankPluginID 用于「插件 id 缺失」这条。
pluginID string
blankPluginID bool
panels func() []sdk.Panel
want error
wantPath string
}
// panelsOf 用 base 造一份面板并施加 edit(edit 为 nil 表示原样)。
func panelsOf(base sdk.Panel, edit func(*sdk.Panel)) []sdk.Panel {
p := base
if edit != nil {
edit(&p)
}
return []sdk.Panel{p}
}
// requireViolation 断言 err 中存在一条 Kind==want 且 Path==wantPath 的违规。
//
// 两条断言缺一不可:errors.Is 证明**可分类**,Path 证明指的是**那一处**(而不是碰巧
// 别的字段也不合法)。
func requireViolation(t *testing.T, err error, want error, wantPath string) {
t.Helper()
if err == nil {
t.Fatalf("期望校验失败(%v @ %s),实际通过", want, wantPath)
}
if !errors.Is(err, want) {
t.Fatalf("errors.Is(err, %v) 为假;err = %v", want, err)
}
var agg *Errors
if !errors.As(err, &agg) {
t.Fatalf("err 不是 *Errors 聚合,实际 %T:%v", err, err)
}
for _, v := range agg.Violations {
if errors.Is(v.Kind, want) && v.Path == wantPath {
return
}
}
t.Fatalf("聚合错误里没有 %v @ %s 这一条;err = %v", want, wantPath, err)
}
// allRejectCases 汇总三个层次的拒绝用例。
func allRejectCases() []rejectCase {
cases := rejectCasesPanelLevel()
cases = append(cases, rejectCasesSectionCommon()...)
cases = append(cases, rejectCasesFormShape()...)
cases = append(cases, rejectCasesTableShape()...)
cases = append(cases, rejectCasesReadOnlySections()...)
cases = append(cases, rejectCasesFieldCore()...)
cases = append(cases, rejectCasesFieldBoundsAndDefaults()...)
cases = append(cases, rejectCasesTableItems()...)
return cases
}
func TestValidate_Rejects(t *testing.T) {
t.Parallel()
for _, tc := range allRejectCases() {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
pluginID := tc.pluginID
if pluginID == "" && !tc.blankPluginID {
pluginID = "web-search"
}
requireViolation(t, Validate(pluginID, tc.panels()), tc.want, tc.wantPath)
})
}
}
// rejectCasesPanelLevel 是插件 id 与面板层的拒绝用例。
func rejectCasesPanelLevel() []rejectCase {
return []rejectCase{
{
name: "插件 id 含空格与大写(不能当路由段)", pluginID: "Web Search",
panels: func() []sdk.Panel { return panelsOf(samplePanel(), nil) },
want: ErrInvalidPluginID, wantPath: "pluginID",
},
{
name: "插件 id 缺失", blankPluginID: true,
panels: func() []sdk.Panel { return panelsOf(samplePanel(), nil) },
want: ErrMissingField, wantPath: "pluginID",
},
{
name: "面板 id 缺失",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.ID = "" })
},
want: ErrMissingField, wantPath: "adminPanels[0].id",
},
{
name: "面板 id 含大写与斜杠",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.ID = "Providers/List" })
},
want: ErrInvalidPanelID, wantPath: "adminPanels[0].id",
},
{
name: "面板标题缺失",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Title = " " })
},
want: ErrMissingField, wantPath: "adminPanels[0].title",
},
{
name: "分组不在闭集(本期只有 plugins)",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Group = "search" })
},
want: ErrUnknownGroup, wantPath: "adminPanels[0].group",
},
{
name: "order 为负",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Order = -1 })
},
want: ErrInvalidOrder, wantPath: "adminPanels[0].order",
},
{
name: "图标名塞了 SVG 片段",
panels: func() []sdk.Panel {
return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Icon = "<svg onload=x>" })
},
want: ErrInvalidIcon, wantPath: "adminPanels[0].icon",
},
{
name: "面板 id 在本插件内重复",
panels: func() []sdk.Panel {
second := statusPanel()
second.ID = "providers"
return []sdk.Panel{samplePanel(), second}
},
want: ErrDuplicateID, wantPath: "adminPanels[1].id",
},
}
}
// TestValidate_AcceptsSampleDeclaration 锁定「合法声明照单通过」:夹具本身就是 §5.2 的示例,
// 它必须过 —— 否则上面的拒绝用例可能只是因为夹具本来就不合法(假绿)。
func TestValidate_AcceptsSampleDeclaration(t *testing.T) {
t.Parallel()
cases := map[string]sdk.Panel{
"§5.2 示例面板": samplePanel(),
"只读事实面": statusPanel(),
"只读日志卡": logPanel(),
}
for name, panel := range cases {
panel := panel
t.Run(name, func(t *testing.T) {
t.Parallel()
if err := Validate("web-search", []sdk.Panel{panel}); err != nil {
t.Fatalf("合法声明被拒:%v", err)
}
})
}
}
// TestValidate_AllowsDocumentedLeniency 锁定几处**刻意放宽**的口径,防止后续把「收紧」
// 当成默认动作无限加码(设计没禁止的就不该拒)。见 PLG1-report.md。
func TestValidate_AllowsDocumentedLeniency(t *testing.T) {
t.Parallel()
// 空分组 ⇒ 回落到唯一分组(§5.2「本期固定 plugins」),不算必填缺失。
noGroup := samplePanel()
noGroup.Group = ""
// 零区块面板 ⇒ §5.1「一个插件可声明零到多个管理页面」,面板本身可以是纯壳。
shell := samplePanel()
shell.Sections = nil
// when 缺省 ⇒ 无条件显示(条件显示是可选能力)。
noWhen := samplePanel()
noWhen.When = nil
// 列/字段的 label 缺省 ⇒ 宿主回落到 key。
noLabel := samplePanel()
noLabel.Sections[0].Fields[0].Label = ""
noLabel.Sections[1].Columns[0].Label = ""
for name, panel := range map[string]sdk.Panel{
"空分组回落": noGroup, "零区块面板": shell, "when 缺省": noWhen, "label 缺省": noLabel,
} {
panel := panel
t.Run(name, func(t *testing.T) {
t.Parallel()
if err := Validate("web-search", []sdk.Panel{panel}); err != nil {
t.Fatalf("刻意放宽的口径被拒:%v", err)
}
})
}
}
// TestValidatePanel_PathUsesIndex 锁定单面板校验的路径锚点与拒绝粒度(§5.2.1「该面板不注册」)。
func TestValidatePanel_PathUsesIndex(t *testing.T) {
t.Parallel()
if err := ValidatePanel(samplePanel(), 0); err != nil {
t.Fatalf("合法面板被拒:%v", err)
}
broken := statusPanel()
broken.Sections[0].Source = &sdk.Source{Action: "plugin.chain"}
requireViolation(t, ValidatePanel(broken, 7), ErrSectionShape, "adminPanels[7].sections[0].source")
}
// TestErrors_IsBoundedAndClassifiable 锁定聚合错误的两件事:Error() 不无界铺开(一条坏清单
// 可能有几百条违规),但结构化的完整列表一条不少。
func TestErrors_IsBoundedAndClassifiable(t *testing.T) {
t.Parallel()
var vs []Violation
for i := 0; i < maxReportedViolations+3; i++ {
vs = append(vs, violation(ErrInvalidPanelID, panelPath(i)+".id", "坏 id"))
}
err := violationsToError(vs)
if !errors.Is(err, ErrInvalidPanelID) {
t.Fatalf("errors.Is 未穿透聚合:%v", err)
}
var agg *Errors
if !errors.As(err, &agg) {
t.Fatalf("errors.As 未取到 *Errors:%T", err)
}
if len(agg.Violations) != maxReportedViolations+3 {
t.Fatalf("结构化列表条数 = %d,期望 %d", len(agg.Violations), maxReportedViolations+3)
}
if n := strings.Count(err.Error(), "坏 id"); n != maxReportedViolations {
t.Fatalf("Error() 铺设了 %d 条违规,期望最多 %d 条", n, maxReportedViolations)
}
if !strings.Contains(err.Error(), "more") {
t.Fatalf("Error() 应提示还有更多条违规:%s", err.Error())
}
if errors.Is(err, ErrMissingField) {
t.Fatal("无关哨兵不该被判为命中(分类必须精确)")
}
if got := violationsToError(nil); got != nil {
t.Fatalf("空违规应返回 nil,得到 %v", got)
}
}
+40
View File
@@ -0,0 +1,40 @@
package paneldecl
import "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
// 本文件是 `when` 条件显示的求值(§5.2.1 nav.item:「`when` 不满足 ⇒ 条目不出现」)。
// Context 是 when 求值的上下文:**调用方注入的最小事实**。
//
// 为什么本包不自己去问「这个插件启用了吗」:AGENTS §9 要求单一事实来源,启用状态的真源在
// 插件管理器(§5.2.1 B 的生命周期一栏),本包去读 config / env / 文件只会造出第二份可能
// 不一致的状态,而且让单测依赖外部环境。所以这里只声明「求值需要什么」,由调用方填。
type Context struct {
// Enabled 是「该插件当前已启用」这一事实。install / enable / disable / uninstall
// 之后由管理器给出最新值,对应 §5.2.1「都重算面板集合」。
Enabled bool
}
// Resolver 按插件 id 给出求值上下文。用函数类型而不是接口:调用方通常只是读一张 map 或
// 问一次管理器,闭包足够,不值得为它定义一个类型。
//
// 约束(写在这里因为编译器管不了):Resolver 不得回调 Catalog 的 Register/List/Groups ——
// 那些方法持有目录锁,回调会自锁死。
type Resolver func(pluginID string) Context
// Eval 求值一份 when 声明,报告该面板的入口是否应当出现。
//
// 语义(本期闭集只有 enabled 一个条件键):
// - when 为 nil、或对象里没写 enabled ⇒ **无条件** ⇒ 显示(条件显示是可选能力,
// 不写就等于不设条件);
// - when.enabled = true ⇒ 仅当 ctx.Enabled 为真时显示(§5.6.1「未启用的插件 ⇒ 入口不出现」);
// - when.enabled = false ⇒ 仅当 ctx.Enabled 为假时显示 —— 条件是「要求值相等」,
// 写 false 不等于没写(把 false 当「无条件」会让声明表达力凭空消失一半)。
//
// 「不满足」的效果是**入口整个不出现**,不是灰掉(§5.2.1 nav.item 明写)。
func Eval(w *sdk.When, ctx Context) bool {
if w == nil || w.Enabled == nil {
return true
}
return ctx.Enabled == *w.Enabled
}
+153
View File
@@ -0,0 +1,153 @@
package sdk
// 本文件是 design/09-插件生态 §5.2「声明形状(唯一权威形态)」在 Go 侧的唯一定义:
// 插件作者写下的就是这份形状(plugins/<pluginId>/manifest.json 的 adminPanels 段)。
//
// 为什么声明放在 SDK 而不是后端内部包:§5.2.1 的红线是「声明而非编码」—— 插件不注入
// 任何 JS/CSS/HTML,只说「我要什么字段、怎么校验、失败怎么说」,渲染与流程由宿主负责
// (面板可接入点 + 插件管理器)。声明既然是插件作者可见的对外契约,它就属于 SDK;
// JSON tag 与 §5.2 的示例逐字同形,改 tag 等于改对外契约。
//
// 本文件只放**数据形状 + 闭集判定**:校验、目录(catalog)、when 求值、角色过滤都在
// internal/capability/paneldecl —— 数据与策略分开,插件包不依赖任何内部包。
// GroupID 是侧边栏折叠分组 id(§5.2 的 `group`)。
type GroupID string
const (
// GroupPlugins 是本期的唯一分组。依据 §5.2「group: 侧边栏折叠分组 id(本期固定
// plugins)」—— 闭集只有一个成员,因此空值可以安全地回落到它(回落动作在
// paneldecl.Register/Validate 里做,SDK 这里只给判定)。
GroupPlugins GroupID = "plugins"
)
// Valid 报告 g 是否属于本期已知的分组闭集。
func (g GroupID) Valid() bool { return g == GroupPlugins }
// SectionType 是 section 的渲染形状(§5.2 的 section 类型闭集)。
//
// 闭集是刻意的:它让「插件声明 UI」在没有任意代码注入的前提下仍然够用,并且每个形状
// 都能复用既有 Miuix 组件(§5.2 末尾的口径)。
type SectionType string
const (
// SectionForm 是表单区块:消费 `fields[]` + `submit.action`(§5.2.1 section.form),
// 由既有字段渲染器泛化渲染。它是四种区块里**唯一含写面**的一种。
SectionForm SectionType = "form"
// SectionTable 是表格区块:消费 `columns[]` / `rowActions[]` / `source`(§5.2.1
// section.table),由既有虚拟滚动表格渲染。
SectionTable SectionType = "table"
// SectionStatus 是只读事实面(如「当前生效的降级链」):消费只读 `fields[]`
// (§5.2.1 section.status)。
SectionStatus SectionType = "status"
// SectionLog 是只读日志卡:消费 `source` / `limit`,最新在上(§5.2.1 section.log,
// 与 L1 口径一致)。
SectionLog SectionType = "log"
)
// Valid 报告 t 是否属于 section 类型闭集。
func (t SectionType) Valid() bool {
switch t {
case SectionForm, SectionTable, SectionStatus, SectionLog:
return true
default:
return false
}
}
// ReadOnly 报告该 section 类型本身是否只读。角色过滤的第一道判据
// (design/09 §5.4.1「只读事实面对 viewer 可见,写动作一律 owner/admin」):
// 只有 form 自带写面(submit),table 的行内动作需要额外摘除(见 paneldecl.filterPanel)。
func (t SectionType) ReadOnly() bool { return t != SectionForm }
// Panel 是一个插件声明的管理页面(§5.2 的 adminPanels 元素)。
type Panel struct {
// ID 是面板 id。它与插件 id 组合成前端路由 /admin/plugin/<pluginId>/<panelId>
// (§5.7 C),所以必须能直接当 URL 路径段与端点段用 —— 字符集由 paneldecl 校验,
// 不在这里放宽。
ID string `json:"id"`
// Title 是页面标题,同时是侧边栏条目文案(§5.2.1 nav.item 消费 id/title/icon/order/when)。
Title string `json:"title"`
// Description 是页面描述。§5.2 的示例里没有这一行,但 §5.2.1 的 `page.root` 一行
// 明确写「消费 title / description」—— 取并集,缺省为空(不一致处记在 PLG1-report.md)。
Description string `json:"description,omitempty"`
// Group 是侧边栏折叠分组 id。空值按 §5.2「本期固定 plugins」回落到 GroupPlugins;
// 非空但不是闭集成员 ⇒ 校验拒绝。
Group GroupID `json:"group"`
// Order 是组内排序,小在前;同 order 时按声明顺序(稳定序,前端导航不许因 map 迭代
// 抖动)。
Order int `json:"order"`
// Icon 是宿主图标名(§5.2 的 `icon`,示例为 "search")。宿主按名字取内置图标,
// 因此这里只接受图标名,不接受 URL / SVG 片段 —— 它是「无任意代码注入」的一部分。
Icon string `json:"icon,omitempty"`
// When 是条件显示声明:不满足 ⇒ 入口**不出现**(不是灰掉,§5.2.1 nav.item)。
// 缺省(nil)表示无条件显示。求值在 paneldecl.Eval,上下文由调用方注入。
When *When `json:"when,omitempty"`
// Sections 是页面内的区块列表。允许为空(§5.1「一个插件可声明**零到多个**管理页面」,
// 面板本身也可以是纯壳),不做「空面板即非法」的收紧。
Sections []Section `json:"sections"`
}
// Section 是页面内的一个区块(§5.2.1 section.form / section.table / section.status /
// section.log 四个接入点之一)。
//
// 每种类型**消费哪些键**由 §5.2.1 的接入点表定死,paneldecl 据此做形状校验:形状错了
// 到前端就是「区块在、功能不在」的半渲染(§5.2.1 点名要防的),所以按面板整体拒掉。
type Section struct {
// Type 决定这个区块怎么渲染,必须落在闭集内(form|table|status|log)。
Type SectionType `json:"type"`
// ID 是区块 id,在面板内唯一:它既是前端渲染 key,也是「哪一段坏了」的定位锚点。
ID string `json:"id"`
// Title 是区块标题。block 无标题会让用户看到一段无来由的表单/表格,因此必填。
Title string `json:"title"`
// Fields 是字段声明:form(可写)与 status(只读)消费。
Fields []Field `json:"fields,omitempty"`
// Submit 是表单提交动作:只有 form 消费(写面的唯一入口)。
Submit *Submit `json:"submit,omitempty"`
// Source 是数据来源:table 与 log 消费。它只给**动作名**,不给 URL(§5.4.4)。
Source *Source `json:"source,omitempty"`
// Columns 是表格列声明:只有 table 消费。
Columns []Column `json:"columns,omitempty"`
// RowActions 是行内动作:只有 table 消费(如「测试连通」「重配凭据」)。
RowActions []RowAction `json:"rowActions,omitempty"`
// Limit 是 log 区块只读的最近条数(§5.2.1 section.log 消费 source/limit)。
// §5.2 的示例里没有这个键 —— 不一致处记在 PLG1-report.md。
Limit int `json:"limit,omitempty"`
}
// Source 是 table / log 的数据来源。
//
// 为什么只有一个 action 字段:§5.4.4 的硬约束是「插件声明的只是**动作名**,不是任意
// URL ⇒ 不给插件开 SSRF 面」。所以这里既没有 url 也没有 path —— 想塞 URL 的插件会
// 在校验期被拒(paneldecl)。
type Source struct {
// Action 是插件声明的动作名(示例 plugin.listProviders),由宿主端点
// POST /api/v1/admin/plugins/{id}/actions/{action} 分发。
Action string `json:"action"`
}
// Submit 是 form 区块的提交动作(§5.2 的 `submit.action`)。
type Submit struct {
// Action 的语义与 Source.Action 相同:只有动作名,没有 URL。
Action string `json:"action"`
}
// Column 是 table 的一列(§5.2 的 `columns` 元素)。
type Column struct {
// Key 是行数据里的字段名,也是列的身份:列内唯一,用于渲染与排序。
Key string `json:"key"`
// Label 是表头文案。缺省时宿主回落到 Key(Key 是机器可读标识且列内唯一,
// 有可靠回落 ⇒ 不作为必填)。
Label string `json:"label,omitempty"`
}
// RowAction 是 table 的行内动作(§5.2 的 `rowActions` 元素,示例「测试连通」/「重配凭据」)。
type RowAction struct {
// ID 是动作在行内的身份,必须唯一 —— 同一行里两个同名动作会让用户点错。
ID string `json:"id"`
// Label 是按钮文案。**必填且没有可回落的东西**(不像 Column.Label 能回落到 Key):
// 一个没有文字的按钮就是半渲染。
Label string `json:"label"`
// Action 是插件声明的动作名(同样只有名字、没有 URL)。
Action string `json:"action"`
}
+229
View File
@@ -0,0 +1,229 @@
package sdk
import (
"encoding/json"
"errors"
"reflect"
"testing"
)
// 本文件锁定两件事:
// 1. 声明的 JSON tag 与 design/09 §5.2 的示例**逐字同形**(示例 JSON 直接解码进来);
// 2. 闭集(分组 / section 类型 / 字段类型)与 when 的严格解码确实按闭集工作。
// designExample 是 design/09-插件生态 §5.2 示例里 adminPanels 段的**逐字拷贝**
// (只去掉 JSONC 注释)。它在这里是权威输入:tag 一旦改坏,本用例必红。
const designExample = `[
{
"id": "providers",
"title": "搜索提供商",
"group": "plugins",
"order": 20,
"icon": "search",
"when": { "enabled": true },
"sections": [
{ "type": "form", "id": "add", "title": "添加提供商",
"fields": [
{ "key": "provider", "label": "提供商", "type": "select", "optionsFrom": "plugin:providers", "required": true },
{ "key": "apiKey", "label": "API Key", "type": "secret", "required": true },
{ "key": "endpoint", "label": "接口地址", "type": "text", "placeholder": "https://..." }
],
"submit": { "action": "plugin.saveProvider" } },
{ "type": "table", "id": "list", "title": "已配置的提供商",
"source": { "action": "plugin.listProviders" },
"columns": [
{ "key": "provider", "label": "提供商" },
{ "key": "state", "label": "状态" }
],
"rowActions": [
{ "id": "test", "label": "测试连通", "action": "plugin.testProvider" },
{ "id": "reauth","label": "重配凭据", "action": "plugin.reauthProvider" }
] }
]
}]`
// TestPanel_DecodesDesignExample 证明 §5.2 的示例能原样解码进我们的类型。
func TestPanel_DecodesDesignExample(t *testing.T) {
t.Parallel()
var panels []Panel
if err := json.Unmarshal([]byte(designExample), &panels); err != nil {
t.Fatalf("§5.2 示例解码失败(说明 JSON tag 与文档不同形):%v", err)
}
if len(panels) != 1 {
t.Fatalf("面板数 = %d,期望 1", len(panels))
}
p := panels[0]
if p.ID != "providers" || p.Title != "搜索提供商" || p.Group != GroupPlugins {
t.Fatalf("面板头字段解码错误:%+v", p)
}
if p.Order != 20 || p.Icon != "search" {
t.Fatalf("order/icon 解码错误:%+v", p)
}
if p.When == nil || p.When.Enabled == nil || !*p.When.Enabled {
t.Fatalf("when 解码错误:%+v", p.When)
}
if len(p.Sections) != 2 {
t.Fatalf("区块数 = %d,期望 2", len(p.Sections))
}
form := p.Sections[0]
if form.Type != SectionForm || form.ID != "add" || form.Submit == nil ||
form.Submit.Action != "plugin.saveProvider" {
t.Fatalf("form 区块解码错误:%+v", form)
}
if len(form.Fields) != 3 || form.Fields[0].OptionsFrom != "plugin:providers" ||
!form.Fields[0].Required || form.Fields[1].Type != FieldSecret ||
form.Fields[2].Placeholder != "https://..." {
t.Fatalf("字段解码错误:%+v", form.Fields)
}
table := p.Sections[1]
if table.Type != SectionTable || table.Source == nil ||
table.Source.Action != "plugin.listProviders" {
t.Fatalf("table 区块解码错误:%+v", table)
}
if len(table.Columns) != 2 || table.Columns[1].Key != "state" {
t.Fatalf("列解码错误:%+v", table.Columns)
}
if len(table.RowActions) != 2 || table.RowActions[1].ID != "reauth" ||
table.RowActions[1].Action != "plugin.reauthProvider" {
t.Fatalf("行内动作解码错误:%+v", table.RowActions)
}
}
// TestPanel_RoundTrips 锁反向:编码再解码必须一模一样(tag 只对了一半就会在这里露出来)。
func TestPanel_RoundTrips(t *testing.T) {
t.Parallel()
var first []Panel
if err := json.Unmarshal([]byte(designExample), &first); err != nil {
t.Fatalf("解码: %v", err)
}
raw, err := json.Marshal(first)
if err != nil {
t.Fatalf("编码: %v", err)
}
var second []Panel
if err := json.Unmarshal(raw, &second); err != nil {
t.Fatalf("再解码: %v (%s)", err, raw)
}
if !reflect.DeepEqual(first, second) {
t.Fatalf("编码往返不一致:\n%+v\n%+v", first, second)
}
}
// TestWhen_StrictDecode 锁定 when 是**闭集**:未知条件键必须显式报错,不能被静默丢掉。
func TestWhen_StrictDecode(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
want error
}{
{"只有 enabled", `{"enabled":true}`, nil},
{"空对象(无条件)", `{}`, nil},
{"未知条件键", `{"configured":true}`, ErrUnknownWhenKey},
{"未知条件键(多个,报错必须确定)", `{"zed":1,"alpha":2}`, ErrUnknownWhenKey},
{"enabled 不是布尔", `{"enabled":"yes"}`, ErrInvalidWhen},
{"不是对象", `true`, ErrInvalidWhen},
{"是 null", `null`, ErrInvalidWhen},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var w When
err := json.Unmarshal([]byte(tc.raw), &w)
if tc.want == nil {
if err != nil {
t.Fatalf("解码 %s 失败: %v", tc.raw, err)
}
return
}
if !errors.Is(err, tc.want) {
t.Fatalf("解码 %s err = %v,期望 %v", tc.raw, err, tc.want)
}
})
}
}
// TestWhen_UnknownKeyIsDeterministic 锁定多未知键时报的是同一个键(错误信息不能随 map 迭代变)。
func TestWhen_UnknownKeyIsDeterministic(t *testing.T) {
t.Parallel()
var first error
for i := 0; i < 20; i++ {
var w When
err := json.Unmarshal([]byte(`{"zeta":1,"alpha":2,"mu":3}`), &w)
if first == nil {
first = err
continue
}
if err == nil || err.Error() != first.Error() {
t.Fatalf("第 %d 次报错 %v,与首次 %v 不一致", i, err, first)
}
}
}
// TestWhen_PropagatesThroughPanel 锁定严格性会穿透面板解码 —— 插件管理器拿到的就是这一层。
func TestWhen_PropagatesThroughPanel(t *testing.T) {
t.Parallel()
raw := `[{"id":"p","title":"t","group":"plugins","order":1,` +
`"when":{"enabled":true,"configured":true},"sections":[]}]`
var panels []Panel
err := json.Unmarshal([]byte(raw), &panels)
if !errors.Is(err, ErrUnknownWhenKey) {
t.Fatalf("err = %v,期望 ErrUnknownWhenKey 穿透到面板解码", err)
}
}
// TestClosedSets 锁定三个闭集与它们的判定方法。新增成员必须同时改这里 —— 这正是闭集的意义。
func TestClosedSets(t *testing.T) {
t.Parallel()
for _, tt := range []FieldType{FieldText, FieldSecret, FieldNumber, FieldSwitch,
FieldSelect, FieldMultiSelect, FieldDate, FieldKV} {
if !tt.Valid() {
t.Errorf("字段类型 %q 应当合法", tt)
}
}
if FieldType("chart").Valid() {
t.Error("字段类型 chart 不该合法(闭集外)")
}
if FieldType("").Valid() {
t.Error("空字段类型不该合法")
}
for _, st := range []SectionType{SectionForm, SectionTable, SectionStatus, SectionLog} {
if !st.Valid() {
t.Errorf("区块类型 %q 应当合法", st)
}
}
if SectionType("chart").Valid() {
t.Error("区块类型 chart 不该合法(闭集外)")
}
if !GroupPlugins.Valid() || GroupID("search").Valid() {
t.Error("分组闭集判定错误(本期只有 plugins)")
}
}
// TestTypeHelpers 锁定三个语义判定:敏感、数值、要拉选项。
func TestTypeHelpers(t *testing.T) {
t.Parallel()
if !FieldSecret.Sensitive() || FieldText.Sensitive() {
t.Error("Sensitive 判定错误")
}
if !FieldNumber.Numeric() || FieldDate.Numeric() {
t.Error("Numeric 判定错误(只有 number 接受 min/max)")
}
if !FieldSelect.PullsOptions() || !FieldMultiSelect.PullsOptions() || FieldText.PullsOptions() {
t.Error("PullsOptions 判定错误")
}
}
// TestSectionType_ReadOnly 锁定角色过滤的第一道判据:只有 form 自带写面。
func TestSectionType_ReadOnly(t *testing.T) {
t.Parallel()
if SectionForm.ReadOnly() {
t.Error("form 不是只读区块(它有 submit)")
}
for _, st := range []SectionType{SectionTable, SectionStatus, SectionLog} {
if !st.ReadOnly() {
t.Errorf("%q 应当是只读区块", st)
}
}
}
+80
View File
@@ -0,0 +1,80 @@
package sdk
// 本文件是 design/09-插件生态 §5.2「字段类型集合(闭集)」在 Go 侧的唯一定义,
// 以及字段声明本身(form / status 区块的 `fields[]` 元素)。
// FieldType 是字段控件类型(闭集,§5.2:不许插件自定义渲染器)。
//
// 闭集的意义:每个形状都能复用既有 Miuix 组件(渠道配置那套字段渲染 + 虚拟滚动表格),
// 因此「插件声明 UI」在无任意代码注入的前提下依然够用 —— 配置类插件 90% 的需求就是
// 这几个形状。
type FieldType string
const (
// FieldText 是单行文本(示例 endpoint:接口地址)。
FieldText FieldType = "text"
// FieldSecret 是凭据输入(示例 apiKey)。读侧永不回显明文(§5.4.2),因此
// paneldecl 拒绝它声明默认值。
FieldSecret FieldType = "secret"
// FieldNumber 是数值输入(唯一接受 min/max 边界的类型)。
FieldNumber FieldType = "number"
// FieldSwitch 是布尔开关。
FieldSwitch FieldType = "switch"
// FieldSelect 是单选下拉(选项来自 optionsFrom)。
FieldSelect FieldType = "select"
// FieldMultiSelect 是多选下拉(选项来自 optionsFrom)。
FieldMultiSelect FieldType = "multiSelect"
// FieldDate 是日期。
FieldDate FieldType = "date"
// FieldKV 是键值对(§5.2 的例子:自定义 header)。
FieldKV FieldType = "kv"
)
// Valid 报告 t 是否属于字段类型闭集。
func (t FieldType) Valid() bool {
switch t {
case FieldText, FieldSecret, FieldNumber, FieldSwitch,
FieldSelect, FieldMultiSelect, FieldDate, FieldKV:
return true
default:
return false
}
}
// Sensitive 报告该类型是否承载凭据。§5.4.2 要求读侧永不回显明文(只回「是否已配置」+
// 尾 4 位或哈希前缀),所以敏感字段不得声明任何默认值 —— 校验在 paneldecl。
func (t FieldType) Sensitive() bool { return t == FieldSecret }
// Numeric 报告该类型是否接受 min/max 数值边界。依据:闭集里只有 number 是数值控件,
// date 在声明里是日期字符串,用数值边界表达日期范围只会产生歧义。
func (t FieldType) Numeric() bool { return t == FieldNumber }
// PullsOptions 报告该类型是否需要宿主去拉下拉选项。依据:只有 select / multiSelect 会
// 渲染选项列表,其它类型上的 optionsFrom 是无效声明(会被 paneldecl 拒绝)。
func (t FieldType) PullsOptions() bool { return t == FieldSelect || t == FieldMultiSelect }
// Field 是一个字段声明(§5.2 的 fields 元素,form 与 status 共用)。
type Field struct {
// Key 是配置键 / 行字段名,形如 §5.2 示例的 provider / apiKey / endpoint。
// 它同时是表单的提交键与 status 行的数据键,因此在区块内必须唯一。
Key string `json:"key"`
// Label 是字段标签。缺省时宿主回落到 Key(与 Column.Label 同一取舍)。
Label string `json:"label,omitempty"`
// Type 决定控件,必须落在闭集内。
Type FieldType `json:"type"`
// Required 是**配置值**的必填标记(§5.2 示例的 `"required": true`),不是声明层的
// 必填 —— 声明层的必填只认 Key 与 Type。
Required bool `json:"required,omitempty"`
// Placeholder 是输入提示(示例 `https://...`)。
Placeholder string `json:"placeholder,omitempty"`
// OptionsFrom 是下拉选项来源。§5.2 只给了插件内的一种来源(示例 plugin:providers);
// **只允许 `plugin:` 前缀**,其它一律拒 —— 防「声明里塞任意 URL」。
OptionsFrom string `json:"optionsFrom,omitempty"`
// Default 是字段初始值。§5.2 的示例里没有这个键;它按任务书「若你定义了这些可选键」
// 的口径加入,且对 secret 类型被 paneldecl 硬拒(§5.4.2 读侧无明文)。
Default any `json:"default,omitempty"`
// Min 是数值下界,仅 number 类型可声明(§5.2 示例里没有这个键,同上)。
Min *float64 `json:"min,omitempty"`
// Max 是数值上界,仅 number 类型可声明,且必须 >= Min。
Max *float64 `json:"max,omitempty"`
}
+78
View File
@@ -0,0 +1,78 @@
package sdk
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"sort"
)
// 本文件定义面板入口的**条件显示**声明 `when`(design/09 §5.2.1 nav.item:
// 「`when` 不满足 ⇒ 条目不出现」,不是灰掉)。
const (
// whenKeyEnabled 是本期唯一支持的条件键:§5.2 的 `"when": { "enabled": true }`
// 是设计里出现过的唯一条件形态,§5.6 的验收判据也只有「未启用的插件 ⇒ 入口不出现」。
// 刻意不留第二个键 —— 闭集外的条件属于「先改设计再加」的范畴。
whenKeyEnabled = "enabled"
)
var (
// ErrInvalidWhen 报告 when 不是合法的条件对象(非对象、null、或 enabled 不是布尔)。
ErrInvalidWhen = errors.New("sdk: invalid when clause")
// ErrUnknownWhenKey 报告 when 里出现了闭集外的条件键。
//
// 刻意**报错而不是忽略**:静默忽略会让插件作者以为条件生效了,而入口照常出现 ——
// §5.2.1 的口径是「绝不静默丢弃」。这条只能在解码期拦住(结构体解码本身会丢弃未知
// 键),所以这里自己实现 UnmarshalJSON。
ErrUnknownWhenKey = errors.New("sdk: unknown when key")
)
// When 是面板入口的条件显示声明。零值语义是「没有条件」,见 paneldecl.Eval。
type When struct {
// Enabled 有三个状态,用指针而不是 bool 表达:
// - nil:没声明这个条件(含整份 when 缺省的 nil 指针)⇒ 不参与判定;
// - &true:要求插件当前已启用(§5.6.1「未启用 ⇒ 入口不出现」);
// - &false:要求插件当前**未**启用 —— 条件是「要求值相等」,写 false 不等于没写。
Enabled *bool `json:"enabled,omitempty"`
}
// UnmarshalJSON 严格解码 when:只接受对象,只接受闭集内的键,enabled 必须是布尔。
//
// 为什么不用默认的 struct 解码:默认解码会**静默丢弃**未知键(`{"enabled":true,"x":1}`
// 会解码成一个看似正常的 When),插件作者会以为自己写了条件却没有任何效果。这里把
// 未知键变成显式拒绝,由插件管理器按 §5.2.1「该面板不注册 + 显式报错」处理。
func (w *When) UnmarshalJSON(raw []byte) error {
trimmed := bytes.TrimSpace(raw)
if bytes.Equal(trimmed, []byte("null")) {
return fmt.Errorf("%w: null is not a condition set", ErrInvalidWhen)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(trimmed, &fields); err != nil {
return fmt.Errorf("%w: %w", ErrInvalidWhen, err)
}
// 按键名排序后再判定:多个未知键时错误信息必须确定(否则同一个坏声明在不同进程里
// 报不同的键,排障与测试都会被搞乱)。
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if k != whenKeyEnabled {
return fmt.Errorf("%w: %q", ErrUnknownWhenKey, k)
}
}
rawEnabled, ok := fields[whenKeyEnabled]
if !ok {
w.Enabled = nil
return nil
}
var enabled bool
if err := json.Unmarshal(rawEnabled, &enabled); err != nil {
return fmt.Errorf("%w: enabled must be a boolean: %w", ErrInvalidWhen, err)
}
w.Enabled = &enabled
return nil
}