纯库、零接线:不碰 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」打不红,正是因为全序与算法无关;这不是测试虚护。)
247 lines
9.7 KiB
Go
247 lines
9.7 KiB
Go
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
|
||
}
|