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
}