- 插件管理器加生产动作处理器登记面(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 补)。
191 lines
7.3 KiB
Go
191 lines
7.3 KiB
Go
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
|
||
}
|