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

+190
View File
@@ -0,0 +1,190 @@
package plugin
// 本文件是面板声明的**动作索引与分发**(切片 L1/PLG-2 从 panels.go 拆出,为守
//「文件 ≤500 行」):
//
// - 读写面分类(`ActionKind`)与分发请求(`ActionRequest`)的形状;
// - 索引的推导(`actionIndex`:submit / rowActions / source / optionsFrom 四个来源);
// - 候选来源映射(`OptionsActionName`,与前端 `resolveOptionsAction` 同源);
// - 分发(`Dispatch`:声明 → 启用 → 处理器三条闸门)与处理器查找。
//
// 登记的入口在 actions.go;目录/生命周期/变更通知在 panels.go。
import (
"context"
"fmt"
"sort"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// ActionKind 分类一个已声明动作的读写面(§5.4.1:只读事实面对 viewer 可见,
// 写动作一律 owner/admin)。分类由**声明位置**决定,不由名字猜:
// form 的 submit 与 table 的 rowActions 是写面,table/log 的 source 是读面。
type ActionKind string
const (
// ActionRead 是只读来源动作(section.table / section.log 的 source,以及下拉的 optionsFrom)。
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 与 SetExecutor 已被 RegisterActions(见 actions.go)取代(切片 L1/PLG-2
// 的执行面接线):单一的全局执行器说不出「哪个插件负责哪个名字」,而登记表能把这件事
// 变成可查的事实。未登记处理器的已声明动作仍返回 ErrActionUnavailable(501),语义不变。
// 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 是唯一的执行入口,不能只靠调用方自觉。处理器按 (pluginId, action) 精确取,
// 一个插件登记的名字不会替另一个插件作答。
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)
}
if handler := p.actionHandler(req.PluginID, req.Action); handler != nil {
return handler(ctx, req)
}
return nil, fmt.Errorf("%w: %s/%s", ErrActionUnavailable, req.PluginID, req.Action)
}
// actionHandler 取该插件的动作处理器(没有则 nil)。锁在返回前释放:
// 处理器是外部代码,持锁调用等于把中枢的锁交给调用方(本项目踩过同类自锁死)。
func (p *Panels) actionHandler(pluginID, action string) ActionHandler {
p.mu.RLock()
defer p.mu.RUnlock()
return p.handlers[pluginID][action]
}
// 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)。
//
// 下拉字段的 `optionsFrom` 也进索引(读面):它同样是**声明里写下的动作名** ——
// 前端(`resolveOptionsAction`)按 `plugin:<name>` → `plugin.<name>` 调宿主动作端点
// 拉候选,宿主若不认这个名字,控件就只能降级成文本框。索引由声明推出(不是另一份
// 白名单),因此「声明里写了却没人实现」仍由登记表如实表现为 501。
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
}
}
for _, f := range s.Fields {
name := OptionsActionName(f.OptionsFrom)
if name == "" {
continue
}
if _, exists := out[name]; !exists {
out[name] = ActionRead
}
}
}
}
return out
}
// OptionsActionName 把字段声明的候选来源(`plugin:<名字>`)映射成宿主动作名。
//
// 口径与前端 `admin/plugin/actions.ts:resolveOptionsAction` **逐字一致**:
// 去掉 `plugin:` 前缀再逐字加前缀 ⇒ `plugin:providers` → `plugin.providers`、
// `plugin:search.regions` → `plugin.search.regions`。两处必须同源,否则声明的下拉
// 会去调一个宿主不认识的名字(表现就是「候选项加载失败,请手填」)。
//
// 不是 `plugin:` 前缀(或前缀后为空)时返回空串:`optionsFrom` 的字符集已由
// paneldecl 校验(`plugin:` + 名字),这里只做形状兜底、不重复一项策略。
func OptionsActionName(optionsFrom string) string {
raw := strings.TrimSpace(optionsFrom)
if !strings.HasPrefix(raw, "plugin:") {
return ""
}
name := strings.TrimSpace(strings.TrimPrefix(raw, "plugin:"))
if name == "" {
return ""
}
return "plugin." + name
}
// 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
}