Files
Cyrene_v2/internal/capability/paneldecl/catalog.go
T
AskaEth c9144c1096
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(plugins): 插件「管理面板声明」库——声明模型 + 闭集校验 + catalog(design/09 §5.2/§5.2.1)
纯库、零接线:不碰 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」打不红,正是因为全序与算法无关;这不是测试虚护。)
2026-10-06 08:06:20 +08:00

247 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}