package plugin // 本文件把 design/09-插件生态 §5.2.1「插件管理器 = 唯一中枢」接到 PLG-1 的 // paneldecl 库上:声明登记 → 目录(catalog)→ 生命周期重算 → 变更通知, // 外加「已声明动作」的读写面索引,供网关的动作分发端点使用。 // // 为什么中枢放在插件管理器而不是网关:§5.2.1 明写「插件既不直接操作前端注册表, // 也不直接读写自己的配置文件,两条路都必须经过插件管理器」。安装/卸载/启停的真源 // 就在 *Manager,面板集合是它的函数值 —— 两处各存一份状态必然分叉。 // // 刻意不做的事: // - 不注册 HTTP 端点、不认识 gateway(契约面在 internal/services/gateway); // - 不读 manifest / 不读 config / 不读 env:声明由调用方(未来的 manifest 读取 // 切片、或宿主内置插件)交给 Declare,单一事实来源; // - 不新开端口、不自己起 goroutine:变更只经注入的发布器(组合根把它接到既有 // 进程内 bus 的 `ws.panels_changed` 上)。 import ( "context" "errors" "fmt" "log/slog" "sort" "strings" "sync" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // 面板集合变更的原因标签,也是 `panels_changed` 帧 `data.reason` 的取值集合 // (§5.2.1 生命周期一栏点名的四种变化 + 声明本身的变化)。 const ( // PanelChangeDeclared 报告某插件的面板声明被登记或替换。 PanelChangeDeclared = "declare" // PanelChangeInstalled 报告某插件安装(或启动期恢复)成功。 PanelChangeInstalled = "install" // PanelChangeUninstalled 报告某插件被卸载,其声明整条移除。 PanelChangeUninstalled = "uninstall" // PanelChangeEnabled 报告某插件被启用。 PanelChangeEnabled = "enable" // PanelChangeDisabled 报告某插件被停用(入口隐藏,声明保留)。 PanelChangeDisabled = "disable" ) // 面板中枢的哨兵错误。调用方用 errors.Is 判类,不靠字符串匹配。 var ( // ErrActionNotDeclared 报告动作名不在该插件的声明集里 ⇒ 404(§5.2.1: // 只接受该插件声明过的动作名)。 ErrActionNotDeclared = errors.New("plugin: action not declared") // ErrActionUnavailable 报告动作已声明,但宿主没有接线执行器 ⇒ 501(诚实作答, // 绝不假装执行成功)。 ErrActionUnavailable = errors.New("plugin: action executor is not wired") // ErrPluginDisabled 报告插件已被显式停用,其动作不再分发 ⇒ 409。 ErrPluginDisabled = errors.New("plugin: plugin is disabled") ) // ActionKind 分类一个已声明动作的读写面(§5.4.1:只读事实面对 viewer 可见, // 写动作一律 owner/admin)。分类由**声明位置**决定,不由名字猜: // form 的 submit 与 table 的 rowActions 是写面,table/log 的 source 是读面。 type ActionKind string const ( // ActionRead 是只读来源动作(section.table / section.log 的 source)。 ActionRead ActionKind = "read" // ActionWrite 是写动作(section.form 的 submit、section.table 的 rowActions)。 ActionWrite ActionKind = "write" ) // ActionRequest 是一次已声明动作的分发请求。 type ActionRequest struct { // PluginID 是声明该动作的插件 id(目录命名空间)。 PluginID string // Action 是插件声明的动作名(如 plugin.saveProvider)。它只是名字,不是 URL。 Action string // Payload 是请求体解码后的任意值(缺省为 nil)。宿主原样交给执行器, // 自身不解释插件的数据形状。 Payload any } // ActionExecutor 执行插件动作。宿主端点只把**已声明**的动作交给它,因此实现 // 无需再做「这个名字合法吗」的判断;但**绝不允许**由它去发起插件提供的 URL —— // §5.4.4 的动作面只有名字。 type ActionExecutor func(ctx context.Context, req ActionRequest) (any, error) // PanelChange 是一次面板集合变更。它是发布给前端的热更新信号,**不含任何面板 // 内容**:前端按自己的角色重新拉一次 `GET /api/v1/admin/panels` 即可,避免 // 在帧里再实现一次角色过滤。 type PanelChange struct { // Reason 是变更原因(本文件的 PanelChange* 常量)。 Reason string // PluginID 是发生变化的插件 id。为空表示「无法归因到单个插件」, // 调用方应把整份快照视为可能已变。 PluginID string } // Panels 是并发安全的面板声明中枢:目录 + 启用状态 + 动作索引 + 变更发布。 // // 它必须经 NewPanels 构造(目录需要非 nil 的 when 求值上下文来源)。 type Panels struct { mu sync.RWMutex cat *paneldecl.Catalog decls map[string][]sdk.Panel enabled map[string]bool exec ActionExecutor publish func(PanelChange) log *slog.Logger } // NewPanels 构造面板中枢。log 为 nil 时使用丢弃型 logger。 func NewPanels(log *slog.Logger) (*Panels, error) { if log == nil { log = slog.New(slog.DiscardHandler) } p := &Panels{ decls: make(map[string][]sdk.Panel), enabled: make(map[string]bool), log: log, } cat, err := paneldecl.NewCatalog(p.resolve) if err != nil { return nil, fmt.Errorf("plugin: panel catalog: %w", err) } p.cat = cat return p, nil } // SetPublisher 注入变更发布器(组合根把它接到既有进程内 bus 的 `ws.panels_changed`)。 // nil 是合法值 ⇒ 只重算、不通知(单测与未接线的进程用得上)。 func (p *Panels) SetPublisher(pub func(PanelChange)) { if p == nil { return } p.mu.Lock() p.publish = pub p.mu.Unlock() } // SetExecutor 注入动作执行器。未注入时已声明动作返回 ErrActionUnavailable // (501),而不是静默成功。 func (p *Panels) SetExecutor(exec ActionExecutor) { if p == nil { return } p.mu.Lock() p.exec = exec p.mu.Unlock() } // Declare 用 panels 整体替换 pluginID 的声明集(§5.2.1 的「整体替换」口径), // 返回真正生效的面板 id。非法插件 id 整插件拒绝(目录键不了它);某个面板非法 // 只拒该面板(口径与理由见 paneldecl.Catalog.Register)。 // // 被拒面板的动作**不进**动作索引:§5.2.1 的「该面板不注册」意味着它的 // submit/source/rowActions 也不存在,否则面板没渲染出来、动作却能调用。 func (p *Panels) Declare(pluginID string, panels []sdk.Panel) ([]string, error) { if p == nil { return nil, fmt.Errorf("plugin: nil panel hub") } accepted, err := p.cat.Register(pluginID, panels) if errors.Is(err, paneldecl.ErrInvalidPluginID) { return accepted, err } p.mu.Lock() p.decls[pluginID] = keepAccepted(panels, accepted) p.mu.Unlock() p.emit(PanelChange{Reason: PanelChangeDeclared, PluginID: pluginID}) return accepted, err } // MarkInstalled 报告某插件已安装(或启动期恢复成功):确保它处于启用态, // 并推一次面板变更(§5.2.1:install 也重算面板集合)。 // // pluginID 为空表示无法把该插件归因到目录键(见 PanelPluginID),此时只记日志 —— // 没有合法命名空间就没有可挂的面板,这与 paneldecl「非法插件 id 整插件拒绝」同向。 func (p *Panels) MarkInstalled(pluginID string) { p.setEnabled(pluginID, true, PanelChangeInstalled, true) } // MarkUninstalled 报告某插件已卸载:整条移除它的声明(面板与动作一起消失)。 func (p *Panels) MarkUninstalled(pluginID string) { if p == nil || pluginID == "" { return } p.cat.Remove(pluginID) p.mu.Lock() delete(p.decls, pluginID) delete(p.enabled, pluginID) p.mu.Unlock() p.emit(PanelChange{Reason: PanelChangeUninstalled, PluginID: pluginID}) } // SetEnabled 显式启用或停用一个插件:停用后 `when: {enabled:true}` 的入口不出现 // (§5.6.1),声明本身保留(重新启用即原样回来)。启用态**未变化时不发通知**, // 避免「点两次启用」推两次无意义的帧。 func (p *Panels) SetEnabled(pluginID string, on bool) { reason := PanelChangeDisabled if on { reason = PanelChangeEnabled } p.setEnabled(pluginID, on, reason, false) } // Enabled 报告插件当前是否启用。**缺省为启用**:声明只在安装期存在(卸载即整条 // 移除),所以「没有显式停用记录」等于启用;只有 SetEnabled(false) 会写下 false。 // 这样 `when:{enabled:true}` 的入口在正常安装后可见,`when:{enabled:false}` 的入口 // 要等显式停用才出现 —— 两条语义都可表达,且不需要第二份「装没装」的事实。 func (p *Panels) Enabled(pluginID string) bool { if p == nil { return false } p.mu.RLock() defer p.mu.RUnlock() return p.enabledLocked(pluginID) } // List 返回按角色过滤后的面板快照(含 when 求值),排序与 paneldecl 一致。 func (p *Panels) List(role auth.Role) ([]paneldecl.Entry, error) { if p == nil { return nil, fmt.Errorf("plugin: nil panel hub") } return p.cat.List(role) } // Groups 返回按侧边栏分组组织的同一份快照(§5.2.1 的 nav.group 一级)。 func (p *Panels) Groups(role auth.Role) ([]paneldecl.Group, error) { if p == nil { return nil, fmt.Errorf("plugin: nil panel hub") } return p.cat.Groups(role) } // PluginIDs 返回当前登记过声明的插件 id(已排序)。**不等于已安装插件**: // 它是 §5.2.1 红线③的支撑面 —— 前端可拿它与已安装列表对照,把「已装、未声明 // 任何页面」的插件如实显示成「未声明管理页面」。 func (p *Panels) PluginIDs() []string { if p == nil { return nil } return p.cat.PluginIDs() } // Action 报告 pluginID 是否声明过 action,并给出它的读写面。 func (p *Panels) Action(pluginID, action string) (ActionKind, bool) { if p == nil || action == "" { return "", false } p.mu.RLock() defer p.mu.RUnlock() kind, ok := p.actionIndexLocked(pluginID)[action] return kind, ok } // DeclaredActions 返回某插件已声明的动作名(已排序,读/写混在一起)。 // 它面向诊断与测试;鉴权判定请用 Action(读写面要分开)。 func (p *Panels) DeclaredActions(pluginID string) []string { if p == nil { return nil } p.mu.RLock() defer p.mu.RUnlock() idx := p.actionIndexLocked(pluginID) out := make([]string, 0, len(idx)) for name := range idx { out = append(out, name) } sort.Strings(out) return out } // Dispatch 把一次动作交给执行器。它按「未声明 ⇒ 未声明」「已停用 ⇒ 停用」 // 「未接线 ⇒ 未接线」三条分别报错,绝不用一个笼统失败掩盖病因。 // // 路由层已在进门前校验过动作名与角色;这里再校验一次是**纵深防御**: // Dispatch 是唯一的执行入口,不能只靠调用方自觉。 func (p *Panels) Dispatch(ctx context.Context, req ActionRequest) (any, error) { if p == nil { return nil, fmt.Errorf("plugin: nil panel hub") } if _, ok := p.Action(req.PluginID, req.Action); !ok { return nil, fmt.Errorf("%w: %s/%s", ErrActionNotDeclared, req.PluginID, req.Action) } if !p.Enabled(req.PluginID) { return nil, fmt.Errorf("%w: %s", ErrPluginDisabled, req.PluginID) } p.mu.RLock() exec := p.exec p.mu.RUnlock() if exec == nil { return nil, fmt.Errorf("%w: %s/%s", ErrActionUnavailable, req.PluginID, req.Action) } return exec(ctx, req) } // setEnabled 是启用态变更的唯一实现(force=true 表示无论是否变化都发通知, // 供 install/restore 这类「生命周期事件本身就值得一帧」的场合使用)。 func (p *Panels) setEnabled(pluginID string, on bool, reason string, force bool) { if p == nil { return } if pluginID == "" { p.log.Warn("panel change without a catalog key", "reason", reason) return } p.mu.Lock() prev := p.enabledLocked(pluginID) p.enabled[pluginID] = on p.mu.Unlock() if !force && prev == on { return } p.emit(PanelChange{Reason: reason, PluginID: pluginID}) } // emit 把一次变更交给发布器。它在状态锁**之外**调用:发布器会碰 bus(外部代码), // 持锁调用等于把中枢的锁交给调用方(本项目踩过同类自锁死)。 func (p *Panels) emit(ch PanelChange) { if p == nil { return } p.mu.RLock() pub := p.publish p.mu.RUnlock() if pub == nil { return } pub(ch) } // resolve 是目录的 when 求值上下文来源。它只读启用状态,**绝不**回调目录方法 // (paneldecl 明令:Resolver 回调 Register/List/Groups 会与目录锁自锁死)。 func (p *Panels) resolve(pluginID string) paneldecl.Context { p.mu.RLock() defer p.mu.RUnlock() return paneldecl.Context{Enabled: p.enabledLocked(pluginID)} } // enabledLocked 读取启用态(缺省启用,见 Enabled)。调用方必须持有 p.mu。 func (p *Panels) enabledLocked(pluginID string) bool { on, ok := p.enabled[pluginID] if !ok { return true } return on } // actionIndexLocked 返回某插件的「动作名 → 读写面」索引。调用方必须持有 p.mu。 func (p *Panels) actionIndexLocked(pluginID string) map[string]ActionKind { return actionIndex(p.decls[pluginID]) } // actionIndex 从一个插件的声明集里抽出动作索引。 // // 同一动作名可能同时出现在读面与写面(例如 table 的 source 与某个 form 的 submit // 同名);此时一律按**写**处理 —— 收紧方向永远是对的,放宽才需要证据(§5.4.1)。 func actionIndex(panels []sdk.Panel) map[string]ActionKind { out := make(map[string]ActionKind) for _, panel := range panels { for _, s := range panel.Sections { if s.Submit != nil { out[s.Submit.Action] = ActionWrite } for _, ra := range s.RowActions { out[ra.Action] = ActionWrite } if s.Source != nil { if _, exists := out[s.Source.Action]; !exists { out[s.Source.Action] = ActionRead } } } } return out } // keepAccepted 只留下被目录接受的声明(按 id 比对)。 // // 用途是让**动作索引**与目录一致:被拒面板的动作不得可调用。normalizePanel 只改 // Group、不改 ID,因此按 ID 过滤是与目录等价的判据。 func keepAccepted(panels []sdk.Panel, accepted []string) []sdk.Panel { if len(accepted) == 0 { return nil } ok := make(map[string]bool, len(accepted)) for _, id := range accepted { ok[id] = true } out := make([]sdk.Panel, 0, len(accepted)) for _, p := range panels { if ok[p.ID] { out = append(out, p) } } return out } // PanelPluginID 给出一个已安装插件在面板目录里的键(命名空间)。 // // 口径:**优先 Name**(它是 plugins.json 的唯一标签、跨重启稳定、也是用户/面板 // 自然使用的地址),Name 不是合法目录键时回落 ID(runtimeconfig.NewID 生成的 // 32 位十六进制,形状天然合法),两者都不合法时返回空串(该插件无法拥有面板)。 // // 为什么不优先 ID:首次安装时 install 拿到的 cfg.ID 还是空的(ID 由 UpsertPlugin // 在落盘那一刻才生成),按 ID 建键会让同一个插件在「首次安装」与「重启后恢复」两次 // 得到不同的键 —— 那正是「重启后面板消失」的事故形状。导出它是为了让未来读取 // manifest 的切片复用**同一个**函数取键,不出现第二个口径。 func PanelPluginID(cfg runtimeconfig.PluginConfig) string { if PanelIDValid(strings.TrimSpace(cfg.Name)) { return strings.TrimSpace(cfg.Name) } if id := strings.TrimSpace(cfg.ID); PanelIDValid(id) { return id } return "" } // PanelIDValid 报告 id 能否直接当面板目录键(路径段形状)。 // 判据直接问 paneldecl,不复制它的正则 —— 口径只能有一份。 func PanelIDValid(id string) bool { return id != "" && paneldecl.Validate(id, nil) == nil } // --- 管理器侧的面板衔接(*Manager 与 *Panels 的结合面) ----------------------- // markInstalled 记录安装时用过的面板目录键(memo,供卸载/停用复用),并把它报给 // 面板中枢。未接线时只记 memo —— 计时与归因不依赖中枢在不在。 func (m *Manager) markInstalled(cfg runtimeconfig.PluginConfig, changeReason string) { key := PanelPluginID(cfg) if key == "" { m.log.Warn("plugin has no panel catalog key", "name", cfg.Name) return } m.mu.Lock() m.panelKeys[cfg.Name] = key hub := m.panels m.mu.Unlock() if hub == nil { return } // 由「启用」触发的重连走 enable 语义(入口从隐藏变可见),其余(安装/启动期恢复) // 走 install 语义。 if changeReason == PanelChangeEnabled { hub.SetEnabled(key, true) return } hub.MarkInstalled(key) } // forgetPanels 在卸载后清掉该插件的面板。键优先取安装时记下的 memo;没有记录 // (例如配置文件是手工放进 plugins.json 的)时回落到「名字恰好合法就用名字」。 func (m *Manager) forgetPanels(name string) { m.mu.Lock() key := m.panelKeys[name] delete(m.panelKeys, name) hub := m.panels m.mu.Unlock() if hub == nil { return } if key == "" { key = strings.TrimSpace(name) if !PanelIDValid(key) { return } } hub.MarkUninstalled(key) } // setPanelsEnabled 把启用态变化交给面板中枢(未接线时空操作)。 func (m *Manager) setPanelsEnabled(name string, on bool) { m.mu.Lock() key := m.panelKeys[name] hub := m.panels m.mu.Unlock() if hub == nil { return } if key == "" { key = strings.TrimSpace(name) if !PanelIDValid(key) { return } } hub.SetEnabled(key, on) }