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//、端点 /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 }