feat(plugins): 面板动作执行器接线 —— 动作真可用,不再 501 兜底(L1)

- 插件管理器加生产动作处理器登记面(RegisterActions);未登记处理器/未声明动作仍 404
- web-search 四个动作真读写真源 + 真发 HTTP 探针:plugin.listProviders/saveProvider/
  testProvider/reauthProvider(配置落 $DATA_DIR/config/plugins/web-search.json,0600)
- 失败回结构化错误码(不含上游原文)+ 审计带 code 字段;501 文案改为「未登记处理器」
- 拆出 panelactions.go(动作索引推导/OptionsActionName/三闸门分发),panels.go 519→347 行

缺口(如实):面板保存后运行中的工具**不热重载**(下一片做);声明面缺 primary/fallback
(按 DECISIONS-2026-10-06 #1/#2 补)。
This commit is contained in:
AskaEth committed 2026-10-06 14:12:23 +08:00
1 parent a150211468
commit 2e1c3ed3fd
13 files changed
+2043 -204

No files matched your search

+12 -152
View File
@@ -1,8 +1,8 @@
package plugin
// 本文件把 design/09-插件生态 §5.2.1「插件管理器 = 唯一中枢」接到 PLG-1 的
// paneldecl 库上:声明登记 → 目录(catalog)→ 生命周期重算 → 变更通知,
// 外加「已声明动作」的读写面索引,供网关的动作分发端点使用。
// paneldecl 库上:声明登记 → 目录(catalog)→ 生命周期重算 → 变更通知。
// 「已声明动作」的读写面索引与分发在 panelactions.go,执行面登记在 actions.go。
//
// 为什么中枢放在插件管理器而不是网关:§5.2.1 明写「插件既不直接操作前端注册表,
// 也不直接读写自己的配置文件,两条路都必须经过插件管理器」。安装/卸载/启停的真源
@@ -16,11 +16,9 @@ package plugin
// 进程内 bus 的 `ws.panels_changed` 上)。
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"sync"
@@ -57,34 +55,6 @@ var (
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` 即可,避免
// 在帧里再实现一次角色过滤。
@@ -96,7 +66,7 @@ type PanelChange struct {
PluginID string
}
// Panels 是并发安全的面板声明中枢:目录 + 启用状态 + 动作索引 + 变更发布。
// Panels 是并发安全的面板声明中枢:目录 + 启用状态 + 动作索引 + 处理器表 + 变更发布。
//
// 它必须经 NewPanels 构造(目录需要非 nil 的 when 求值上下文来源)。
type Panels struct {
@@ -104,9 +74,11 @@ type Panels struct {
cat *paneldecl.Catalog
decls map[string][]sdk.Panel
enabled map[string]bool
exec ActionExecutor
publish func(PanelChange)
log *slog.Logger
// handlers 是「插件 → 动作名 → 处理器」的登记表(见 RegisterActions)。
// 它与 decls(声明)是两个不同的事实:声明决定**可不可以调**,登记决定**谁来执行**。
handlers map[string]map[string]ActionHandler
publish func(PanelChange)
log *slog.Logger
}
// NewPanels 构造面板中枢。log 为 nil 时使用丢弃型 logger。
@@ -115,9 +87,10 @@ func NewPanels(log *slog.Logger) (*Panels, error) {
log = slog.New(slog.DiscardHandler)
}
p := &Panels{
decls: make(map[string][]sdk.Panel),
enabled: make(map[string]bool),
log: log,
decls: make(map[string][]sdk.Panel),
enabled: make(map[string]bool),
handlers: make(map[string]map[string]ActionHandler),
log: log,
}
cat, err := paneldecl.NewCatalog(p.resolve)
if err != nil {
@@ -138,17 +111,6 @@ func (p *Panels) SetPublisher(pub func(PanelChange)) {
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)。
@@ -242,58 +204,6 @@ func (p *Panels) PluginIDs() []string {
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) {
@@ -346,56 +256,6 @@ func (p *Panels) enabledLocked(pluginID string) bool {
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 的唯一标签、跨重启稳定、也是用户/面板