Files
AskaEth 2e1c3ed3fd 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 补)。
2026-10-06 14:12:23 +08:00

191 lines
7.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}