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:` → `plugin.` 调宿主动作端点 // 拉候选,宿主若不认这个名字,控件就只能降级成文本框。索引由声明推出(不是另一份 // 白名单),因此「声明里写了却没人实现」仍由登记表如实表现为 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 }