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:
1 parent
a150211468
commit
2e1c3ed3fd
13 files changed
+2043
-204
No files matched your search
@@ -0,0 +1,190 @@
|
||||
package plugin
|
||||
|
||||
// 本文件是 design/09-插件生态 §5.2.1「动作分发」一栏在**插件管理器侧的落点**(切片
|
||||
// L1/PLG-2 的执行面接线):动作处理器的**注册**(谁执行哪个动作)与失败的**结构化形状**。
|
||||
//
|
||||
// 三条口径:
|
||||
//
|
||||
// 1. **注册面而不是全局执行器**(§5.2.1 B「插件管理器 = 唯一中枢」):插件在组合根把
|
||||
// 自己的动作处理器登记到插件管理器上;登记是可查的(谁登记了什么),未登记的插件
|
||||
// 互不干扰 —— 用一个全局 ActionExecutor 会让「哪个插件负责这个名字」变成不可证的约定。
|
||||
// 2. **声明仍是唯一闸门**:登记处理器**不**等于可执行。Dispatch 先查声明(`Action`),
|
||||
// 未声明一律 404 —— 处理器的存在不会让一个没声明的名字变得可调用(纵深不变)。
|
||||
// 3. **失败要能分类**:ActionError 把「错误码 / 文案 / HTTP 状态」一起交出去
|
||||
// (§5.4.6 错误码与文案分离,前端据此本地化),绝不靠解析字符串把病因猜出来。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ActionHandler 执行一个插件的**已声明动作**。
|
||||
//
|
||||
// 与调用方的分工:宿主端点(网关)负责取调用者身份、角色门与审计;登记进来的处理器
|
||||
// 只负责「这个名字要做什么」。处理器**不得**去发起插件提供的任意 URL(§5.4.4:动作面
|
||||
// 只有名字),也不得把凭据写进返回值或日志(§5.4.2)。
|
||||
type ActionHandler func(ctx context.Context, req ActionRequest) (any, error)
|
||||
|
||||
// ErrInvalidActionRegistration 报告一次动作处理器登记本身不合法(命名空间/名字/处理器)。
|
||||
// 它是**接线错误**(组合根写错了),不是运行期错误:调用方应当把它当成启动期问题处理。
|
||||
var ErrInvalidActionRegistration = errors.New("plugin: invalid action registration")
|
||||
|
||||
// ActionCodeInvalidPayload 是宿主级的通用失败码:请求体形状不对(缺字段 / 类型错)。
|
||||
// 领域自己的失败码(如搜索的 unknown_provider)由插件实现定义,不集中在这里。
|
||||
const ActionCodeInvalidPayload = "invalid_payload"
|
||||
|
||||
// ActionError 是动作处理器交出的**结构化失败**(错误码 + 文案 + 目标 HTTP 状态)。
|
||||
//
|
||||
// 为什么由插件侧给出状态码:只有实现知道这次失败是「调用方写错了请求」(400)、
|
||||
// 「上游不可用」(502)还是「宿主没接线」(501,由哨兵错误表达)。网关只做透传,
|
||||
// 不替实现猜病因(§5.4.6)。
|
||||
type ActionError struct {
|
||||
// Code 是稳定的机器可读错误码(前端按它本地化,不解析文案)。
|
||||
Code string
|
||||
// Message 是给人看的一句话(**绝不含凭据、绝不含上游原始报文**)。
|
||||
Message string
|
||||
// Status 是这次失败该用的 HTTP 状态;0 表示由网关按默认(502)处理。
|
||||
Status int
|
||||
// Err 是底层病因(可选)。它只进服务端日志,不进响应体。
|
||||
Err error
|
||||
}
|
||||
|
||||
// NewActionError 构造一个结构化动作失败。
|
||||
func NewActionError(code, message string, status int, err error) *ActionError {
|
||||
return &ActionError{Code: code, Message: message, Status: status, Err: err}
|
||||
}
|
||||
|
||||
// Error 实现 error。格式把码、文案与病因串起来,只为日志可读;
|
||||
// 客户端拿到的是 Code/Message 两个字段(不解析这句话)。
|
||||
func (e *ActionError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if e.Err != nil {
|
||||
return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.Err)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// Unwrap 让 errors.Is/As 能穿到病因(例如 runtimeconfig.ErrInvalidWebSearch)。
|
||||
func (e *ActionError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// AsActionError 取出一条错误链里的结构化动作失败(没有则 false)。
|
||||
// 网关用它把「插件侧自己说清了病因」与「未知的内部错误」分开映射。
|
||||
func AsActionError(err error) (*ActionError, bool) {
|
||||
var ae *ActionError
|
||||
if !errors.As(err, &ae) || ae == nil {
|
||||
return nil, false
|
||||
}
|
||||
return ae, true
|
||||
}
|
||||
|
||||
// RegisterActions 为一个插件登记它的动作处理器(覆盖同名)。
|
||||
//
|
||||
// pluginID 必须能当面板目录键(`PanelIDValid`):动作的命名空间就是面板目录的命名空间,
|
||||
// 一个没有合法键的插件不可能被端点寻址到,登记它就是登记一个永远调不到的名字。
|
||||
//
|
||||
// 名字为空 / 处理器为 nil 一律拒(静默忽略会让「点了没反应」重新变成一个查不出的谜)。
|
||||
// 首次调用会整体替换该插件的处理器表,之后再次调用是**前缀合并**(同名覆盖)——
|
||||
// 插件可以分两步登记自己的动作面。
|
||||
//
|
||||
// 允许登记一个**当前还没声明**的动作名:它暂时不可达(Dispatch 先查声明),
|
||||
// 但会记一条 WARN 让「登记了却永远不会触发」当场可见。声明与登记的先后顺序在
|
||||
// 组合根里不总是可控(热装插件的声明来自 manifest,可能晚于处理器注册)。
|
||||
func (p *Panels) RegisterActions(pluginID string, handlers map[string]ActionHandler) error {
|
||||
if p == nil {
|
||||
return fmt.Errorf("plugin: nil panel hub")
|
||||
}
|
||||
pluginID = strings.TrimSpace(pluginID)
|
||||
if !PanelIDValid(pluginID) {
|
||||
return fmt.Errorf("%w: %q 不能当面板目录键(动作没有所属命名空间)",
|
||||
ErrInvalidActionRegistration, pluginID)
|
||||
}
|
||||
if len(handlers) == 0 {
|
||||
return fmt.Errorf("%w: %s 没有给出任何处理器", ErrInvalidActionRegistration, pluginID)
|
||||
}
|
||||
clean := make(map[string]ActionHandler, len(handlers))
|
||||
for name, h := range handlers {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("%w: %s 里有空动作名", ErrInvalidActionRegistration, pluginID)
|
||||
}
|
||||
if h == nil {
|
||||
return fmt.Errorf("%w: %s/%s 的处理器是 nil", ErrInvalidActionRegistration, pluginID, name)
|
||||
}
|
||||
clean[name] = h
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
if p.handlers == nil {
|
||||
p.handlers = make(map[string]map[string]ActionHandler)
|
||||
}
|
||||
if p.handlers[pluginID] == nil {
|
||||
p.handlers[pluginID] = make(map[string]ActionHandler, len(clean))
|
||||
}
|
||||
declared := actionIndex(p.decls[pluginID])
|
||||
for name, h := range clean {
|
||||
p.handlers[pluginID][name] = h
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
warnUndeclaredHandlers(p.log, pluginID, clean, declared)
|
||||
return nil
|
||||
}
|
||||
|
||||
// warnUndeclaredHandlers 对「登记了但当前没有声明」的动作记一条 WARN(见 RegisterActions)。
|
||||
// 名字排序后再报:日志顺序稳定,测试与排查都不靠 map 迭代顺序。
|
||||
func warnUndeclaredHandlers(log *slog.Logger, pluginID string, handlers map[string]ActionHandler, declared map[string]ActionKind) {
|
||||
missing := make([]string, 0, len(handlers))
|
||||
for name := range handlers {
|
||||
if _, ok := declared[name]; !ok {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
for _, name := range missing {
|
||||
log.Warn("action handler has no declaration yet; it stays unreachable until declared",
|
||||
"pluginId", pluginID, "action", name)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterActions 是 *Manager 上的动作登记入口(§5.2.1「插件在组合根注册自己的动作
|
||||
// 处理器」):它把登记转交给已接线的面板中枢,中枢未接线时报错 —— 绝不静默吞掉,
|
||||
// 否则组合根会以为「动作接好了」而端点仍在 501。
|
||||
func (m *Manager) RegisterActions(pluginID string, handlers map[string]ActionHandler) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("plugin: nil manager")
|
||||
}
|
||||
hub := m.Panels()
|
||||
if hub == nil {
|
||||
return fmt.Errorf("%w: %s(面板中枢未接线,动作登记无处落)",
|
||||
ErrInvalidActionRegistration, pluginID)
|
||||
}
|
||||
return hub.RegisterActions(pluginID, handlers)
|
||||
}
|
||||
|
||||
// ActionHandlerNames 返回某插件**已登记处理器**的动作名(已排序,面向诊断与测试)。
|
||||
// 它与 DeclaredActions 是两个不同的事实:前者是「谁能执行」,后者是「声明里有什么」。
|
||||
// 两者的交集才是真正可调用的动作(Dispatch 的口径)。
|
||||
func (p *Panels) ActionHandlerNames(pluginID string) []string {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
out := make([]string, 0, len(p.handlers[pluginID]))
|
||||
for name := range p.handlers[pluginID] {
|
||||
out = append(out, name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件锁定切片 L1/PLG-2 的**执行面接线**(design/09 §5.2.1 动作分发):
|
||||
// - 登记面按 (pluginId, action) 精确路由,一个插件登记的处理器不替另一个插件作答;
|
||||
// - 登记**不**放宽声明闸门:没声明的名字即使有处理器也 404;
|
||||
// - 未登记处理器仍诚实回 ErrActionUnavailable(501),绝不假装执行成功;
|
||||
// - 登记本身的错误(空命名空间 / 空名字 / nil 处理器)当场拒,不静默半登记。
|
||||
|
||||
// registerPanel 给 id 登记一份最简声明(一个读来源动作 + 一个写动作)。
|
||||
func registerPanel(t *testing.T, hub *Panels, pluginID string, readAction, writeAction string) {
|
||||
t.Helper()
|
||||
enabled := true
|
||||
panel := sdk.Panel{
|
||||
ID: "p", Title: "P", Order: 1,
|
||||
When: &sdk.When{Enabled: &enabled},
|
||||
Sections: []sdk.Section{
|
||||
{Type: sdk.SectionForm, ID: "f", Title: "F",
|
||||
Fields: []sdk.Field{{Key: "k", Type: sdk.FieldText}},
|
||||
Submit: &sdk.Submit{Action: writeAction}},
|
||||
{Type: sdk.SectionTable, ID: "t", Title: "T",
|
||||
Source: &sdk.Source{Action: readAction},
|
||||
Columns: []sdk.Column{{Key: "k"}}},
|
||||
},
|
||||
}
|
||||
if _, err := hub.Declare(pluginID, []sdk.Panel{panel}); err != nil {
|
||||
t.Fatalf("Declare(%s): %v", pluginID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPanels_RegisterActionsRoutesByPluginAndAction 锁定登记面的精确路由:
|
||||
// 同名动作在两个插件下各走各的处理器,返回结果互不串台。
|
||||
func TestPanels_RegisterActionsRoutesByPluginAndAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
hub, _ := newTestHub(t)
|
||||
registerPanel(t, hub, "alpha", "plugin.listProviders", "plugin.saveProvider")
|
||||
registerPanel(t, hub, "beta", "plugin.listProviders", "plugin.saveProvider")
|
||||
|
||||
for _, id := range []string{"alpha", "beta"} {
|
||||
id := id
|
||||
if err := hub.RegisterActions(id, map[string]ActionHandler{
|
||||
"plugin.listProviders": func(_ context.Context, r ActionRequest) (any, error) {
|
||||
return "from-" + r.PluginID, nil
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("RegisterActions(%s): %v", id, err)
|
||||
}
|
||||
}
|
||||
for _, id := range []string{"alpha", "beta"} {
|
||||
got, err := hub.Dispatch(context.Background(), ActionRequest{PluginID: id, Action: "plugin.listProviders"})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch(%s): %v", id, err)
|
||||
}
|
||||
if got != "from-"+id {
|
||||
t.Fatalf("Dispatch(%s) = %v,期望走自己的处理器", id, got)
|
||||
}
|
||||
}
|
||||
// 只登记了读动作:同插件的写动作仍未接线(501),不因「这个插件登记过东西」而放行。
|
||||
if _, err := hub.Dispatch(context.Background(), ActionRequest{PluginID: "alpha", Action: "plugin.saveProvider"}); !errors.Is(err, ErrActionUnavailable) {
|
||||
t.Fatalf("未单独登记的动作 err = %v,期望 ErrActionUnavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPanels_RegisteredHandlerCannotBypassDeclaration 锁定登记的从属地位:
|
||||
// 处理器存在但动作**没声明** ⇒ 仍然 404(且处理器一次都不跑)。
|
||||
func TestPanels_RegisteredHandlerCannotBypassDeclaration(t *testing.T) {
|
||||
t.Parallel()
|
||||
hub, _ := newTestHub(t)
|
||||
registerPanel(t, hub, "alpha", "plugin.listProviders", "plugin.saveProvider")
|
||||
|
||||
ran := 0
|
||||
if err := hub.RegisterActions("alpha", map[string]ActionHandler{
|
||||
"plugin.nukeEverything": func(_ context.Context, _ ActionRequest) (any, error) {
|
||||
ran++
|
||||
return "should-not-run", nil
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("RegisterActions: %v", err)
|
||||
}
|
||||
if _, err := hub.Dispatch(context.Background(), ActionRequest{PluginID: "alpha", Action: "plugin.nukeEverything"}); !errors.Is(err, ErrActionNotDeclared) {
|
||||
t.Fatalf("未声明动作 err = %v,期望 ErrActionNotDeclared", err)
|
||||
}
|
||||
if ran != 0 {
|
||||
t.Fatalf("未声明动作的处理器跑了 %d 次(声明闸门被绕过)", ran)
|
||||
}
|
||||
// 停用的插件同理:声明还在,但不再分发。
|
||||
if err := hub.RegisterActions("alpha", map[string]ActionHandler{
|
||||
"plugin.listProviders": func(_ context.Context, _ ActionRequest) (any, error) { return "ok", nil },
|
||||
}); err != nil {
|
||||
t.Fatalf("RegisterActions: %v", err)
|
||||
}
|
||||
hub.SetEnabled("alpha", false)
|
||||
if _, err := hub.Dispatch(context.Background(), ActionRequest{PluginID: "alpha", Action: "plugin.listProviders"}); !errors.Is(err, ErrPluginDisabled) {
|
||||
t.Fatalf("停用后 err = %v,期望 ErrPluginDisabled", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPanels_RegisterActionsRejectsInvalidRegistration 锁定登记期的形状约束:
|
||||
// 空命名空间 / 空名字 / nil 处理器一律当场拒(静默忽略会让「点了没反应」重新变成谜)。
|
||||
func TestPanels_RegisterActionsRejectsInvalidRegistration(t *testing.T) {
|
||||
t.Parallel()
|
||||
hub, _ := newTestHub(t)
|
||||
ok := func(_ context.Context, _ ActionRequest) (any, error) { return nil, nil }
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
pluginID string
|
||||
handlers map[string]ActionHandler
|
||||
}{
|
||||
{"空命名空间", "", map[string]ActionHandler{"plugin.listProviders": ok}},
|
||||
{"非法命名空间", "not a key", map[string]ActionHandler{"plugin.listProviders": ok}},
|
||||
{"空处理器表", "alpha", map[string]ActionHandler{}},
|
||||
{"空动作名", "alpha", map[string]ActionHandler{" ": ok}},
|
||||
{"nil 处理器", "alpha", map[string]ActionHandler{"plugin.listProviders": nil}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := hub.RegisterActions(tc.pluginID, tc.handlers)
|
||||
if !errors.Is(err, ErrInvalidActionRegistration) {
|
||||
t.Fatalf("err = %v,期望 ErrInvalidActionRegistration", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
// 被拒的登记不得留下任何痕迹(半登记最坏:看起来接好了,实际没人执行)。
|
||||
if names := hub.ActionHandlerNames("alpha"); len(names) != 0 {
|
||||
t.Fatalf("被拒的登记留下了处理器: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPanels_RegisterActionsMergesAndOverwrites 锁定重复登记的语义:
|
||||
// 第二次登记是**前缀合并**,同名覆盖(插件可分批登记自己的动作面)。
|
||||
func TestPanels_RegisterActionsMergesAndOverwrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
hub, _ := newTestHub(t)
|
||||
registerPanel(t, hub, "alpha", "plugin.listProviders", "plugin.saveProvider")
|
||||
|
||||
first := func(_ context.Context, _ ActionRequest) (any, error) { return "v1", nil }
|
||||
second := func(_ context.Context, _ ActionRequest) (any, error) { return "v2", nil }
|
||||
if err := hub.RegisterActions("alpha", map[string]ActionHandler{
|
||||
"plugin.listProviders": first,
|
||||
"plugin.saveProvider": first,
|
||||
}); err != nil {
|
||||
t.Fatalf("RegisterActions: %v", err)
|
||||
}
|
||||
if err := hub.RegisterActions("alpha", map[string]ActionHandler{
|
||||
"plugin.listProviders": second,
|
||||
}); err != nil {
|
||||
t.Fatalf("RegisterActions(merge): %v", err)
|
||||
}
|
||||
if got := hub.ActionHandlerNames("alpha"); strings.Join(got, ",") != "plugin.listProviders,plugin.saveProvider" {
|
||||
t.Fatalf("合并后的处理器表 = %v", got)
|
||||
}
|
||||
out, err := hub.Dispatch(context.Background(), ActionRequest{PluginID: "alpha", Action: "plugin.listProviders"})
|
||||
if err != nil || out != "v2" {
|
||||
t.Fatalf("同名覆盖后 Dispatch = (%v,%v),期望 (v2,nil)", out, err)
|
||||
}
|
||||
out, err = hub.Dispatch(context.Background(), ActionRequest{PluginID: "alpha", Action: "plugin.saveProvider"})
|
||||
if err != nil || out != "v1" {
|
||||
t.Fatalf("未覆盖的那条 Dispatch = (%v,%v),期望 (v1,nil)", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_RegisterActionsRequiresAttachedHub 锁定管理器侧入口的降级口径:
|
||||
// 面板中枢未接线时**响亮失败**(组合根会以为动作接好了,而端点在 501)。
|
||||
func TestManager_RegisterActionsRequiresAttachedHub(t *testing.T) {
|
||||
t.Parallel()
|
||||
mgr := NewManager(nil, t.TempDir()+"/plugins.json", nil)
|
||||
err := mgr.RegisterActions("alpha", map[string]ActionHandler{
|
||||
"plugin.listProviders": func(_ context.Context, _ ActionRequest) (any, error) { return nil, nil },
|
||||
})
|
||||
if !errors.Is(err, ErrInvalidActionRegistration) {
|
||||
t.Fatalf("未接线中枢时 err = %v,期望 ErrInvalidActionRegistration", err)
|
||||
}
|
||||
hub, herr := NewPanels(nil)
|
||||
if herr != nil {
|
||||
t.Fatalf("NewPanels: %v", herr)
|
||||
}
|
||||
mgr.AttachPanels(hub)
|
||||
if err := mgr.RegisterActions("alpha", map[string]ActionHandler{
|
||||
"plugin.listProviders": func(_ context.Context, _ ActionRequest) (any, error) { return "ok", nil },
|
||||
}); err != nil {
|
||||
t.Fatalf("接线后 RegisterActions: %v", err)
|
||||
}
|
||||
if names := hub.ActionHandlerNames("alpha"); strings.Join(names, ",") != "plugin.listProviders" {
|
||||
t.Fatalf("管理器登记没有落到中枢上: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActionErrorCarriesStructuredFailure 锁定结构化失败的形状与可判类
|
||||
// (§5.4.6:错误码与文案分离;病因只进日志、不进响应)。
|
||||
func TestActionErrorCarriesStructuredFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
cause := errors.New("disk full")
|
||||
err := fmt.Errorf("save failed: %w", NewActionError("config_write_failed", "写配置失败", 500, cause))
|
||||
|
||||
ae, ok := AsActionError(err)
|
||||
if !ok {
|
||||
t.Fatalf("AsActionError 没认出结构化失败: %v", err)
|
||||
}
|
||||
if ae.Code != "config_write_failed" || ae.Message != "写配置失败" || ae.Status != 500 {
|
||||
t.Fatalf("ActionError = %+v", ae)
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("err 链必须穿到病因(errors.Is(cause) 为假): %v", err)
|
||||
}
|
||||
if _, ok := AsActionError(context.Canceled); ok {
|
||||
t.Fatalf("普通错误不得被当成结构化失败")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 的唯一标签、跨重启稳定、也是用户/面板
|
||||
|
||||
@@ -202,7 +202,8 @@ func TestPanels_InvalidPluginIDIsRejectedWholesale(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestPanels_ActionKindsFollowDeclarationSite 锁定读写面由**声明位置**决定:
|
||||
// table/log 的 source 是读来源,form 的 submit 与 table 的 rowActions 是写面。
|
||||
// table/log 的 source 与下拉的 optionsFrom 是读来源,form 的 submit 与 table 的
|
||||
// rowActions 是写面。
|
||||
func TestPanels_ActionKindsFollowDeclarationSite(t *testing.T) {
|
||||
t.Parallel()
|
||||
hub, _ := newTestHub(t)
|
||||
@@ -216,6 +217,9 @@ func TestPanels_ActionKindsFollowDeclarationSite(t *testing.T) {
|
||||
{"plugin.listProviders", ActionRead},
|
||||
{"plugin.saveProvider", ActionWrite},
|
||||
{"plugin.testProvider", ActionWrite},
|
||||
// 下拉候选来源 `plugin:providers` 也进索引(读面):前端按它拉候选,
|
||||
// 宿主不认这个名字,控件就只能降级成文本框。
|
||||
{"plugin.providers", ActionRead},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := hub.Action("web-search", c.action)
|
||||
@@ -227,11 +231,31 @@ func TestPanels_ActionKindsFollowDeclarationSite(t *testing.T) {
|
||||
t.Fatalf("未声明的动作不得被判为已声明")
|
||||
}
|
||||
if got := hub.DeclaredActions("web-search"); strings.Join(got, ",") !=
|
||||
"plugin.listProviders,plugin.saveProvider,plugin.testProvider" {
|
||||
"plugin.listProviders,plugin.providers,plugin.saveProvider,plugin.testProvider" {
|
||||
t.Fatalf("DeclaredActions = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOptionsActionNameMatchesFrontendMapping 锁定 `optionsFrom` → 动作名的映射
|
||||
// 与前端 `resolveOptionsAction` **逐字同源**(前端片 PLG-3 登记的口径):
|
||||
// 两处一旦分叉,声明的下拉就会去调一个宿主不认识的名字。
|
||||
func TestOptionsActionNameMatchesFrontendMapping(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]string{
|
||||
"plugin:providers": "plugin.providers",
|
||||
"plugin:search.regions": "plugin.search.regions",
|
||||
" plugin:providers ": "plugin.providers",
|
||||
"plugin:": "",
|
||||
"providers": "",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := OptionsActionName(in); got != want {
|
||||
t.Errorf("OptionsActionName(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPanels_DispatchThreeWayRefusal 锁定分发失败的三种病因各自可判类,
|
||||
// 且执行器只在「已声明 + 已启用 + 已接线」时才被调用。
|
||||
func TestPanels_DispatchThreeWayRefusal(t *testing.T) {
|
||||
@@ -246,23 +270,27 @@ func TestPanels_DispatchThreeWayRefusal(t *testing.T) {
|
||||
t.Fatalf("未声明动作 err = %v,期望 ErrActionNotDeclared", err)
|
||||
}
|
||||
if _, err := hub.Dispatch(context.Background(), req); !errors.Is(err, ErrActionUnavailable) {
|
||||
t.Fatalf("未接线执行器 err = %v,期望 ErrActionUnavailable", err)
|
||||
t.Fatalf("未登记处理器 err = %v,期望 ErrActionUnavailable", err)
|
||||
}
|
||||
|
||||
var seen ActionRequest
|
||||
hub.SetExecutor(func(_ context.Context, r ActionRequest) (any, error) {
|
||||
seen = r
|
||||
return map[string]any{"rows": 1}, nil
|
||||
})
|
||||
if err := hub.RegisterActions("web-search", map[string]ActionHandler{
|
||||
"plugin.listProviders": func(_ context.Context, r ActionRequest) (any, error) {
|
||||
seen = r
|
||||
return map[string]any{"rows": 1}, nil
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("RegisterActions: %v", err)
|
||||
}
|
||||
out, err := hub.Dispatch(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
if seen.PluginID != "web-search" || seen.Action != "plugin.listProviders" {
|
||||
t.Fatalf("执行器收到 %+v", seen)
|
||||
t.Fatalf("处理器收到 %+v", seen)
|
||||
}
|
||||
if m, ok := out.(map[string]any); !ok || m["rows"] != 1 {
|
||||
t.Fatalf("Dispatch 返回 %#v,期望执行器结果原样透传", out)
|
||||
t.Fatalf("Dispatch 返回 %#v,期望处理器结果原样透传", out)
|
||||
}
|
||||
|
||||
hub.SetEnabled("web-search", false)
|
||||
|
||||
@@ -147,7 +147,7 @@ func adminPanelGroupOf(g paneldecl.Group) AdminPanelGroup {
|
||||
// 1. 未声明 ⇒ 404(插件只给动作名 ⇒ 未声明的名字根本不存在,不是「没权限」);
|
||||
// 2. 写动作 ⇒ 必须 owner/admin(读来源动作对 viewer 开放,§5.4.1);
|
||||
// 3. 停止用插件 ⇒ 409;
|
||||
// 4. 未接线执行器 ⇒ 501(诚实作答,绝不假装执行成功)。
|
||||
// 4. 已声明但没登记处理器 ⇒ 501(诚实作答,绝不假装执行成功)。
|
||||
//
|
||||
// 每一步都落审计:谁在什么时候想动哪个插件的哪个动作、结果如何。
|
||||
func (s *Server) adminPluginAction(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -155,13 +155,13 @@ func (s *Server) adminPluginAction(w http.ResponseWriter, r *http.Request) {
|
||||
action := strings.TrimSpace(r.PathValue("action"))
|
||||
hub := s.panelHub()
|
||||
if hub == nil {
|
||||
s.auditPanelAction(r, pluginID, action, "unavailable")
|
||||
s.auditPanelAction(r, pluginID, action, "unavailable", "panels_unavailable")
|
||||
writeError(w, panelsUnavailable())
|
||||
return
|
||||
}
|
||||
kind, declared := hub.Action(pluginID, action)
|
||||
if !declared {
|
||||
s.auditPanelAction(r, pluginID, action, "not_declared")
|
||||
s.auditPanelAction(r, pluginID, action, "not_declared", "action_not_declared")
|
||||
writeError(w, &apperr.AppError{
|
||||
Code: "action_not_declared",
|
||||
Message: fmt.Sprintf("插件 %q 没有声明动作 %q(插件只给动作名,不给 URL)", pluginID, action),
|
||||
@@ -170,13 +170,13 @@ func (s *Server) adminPluginAction(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if kind == pluginmgr.ActionWrite && !callerHasWrite(r) {
|
||||
s.auditPanelAction(r, pluginID, action, "denied")
|
||||
s.auditPanelAction(r, pluginID, action, "denied", "forbidden")
|
||||
writeError(w, apperr.Wrap(apperr.ErrForbidden, "forbidden", "写动作需要 owner/admin(§5.4.1)"))
|
||||
return
|
||||
}
|
||||
payload, derr := decodeActionPayload(r)
|
||||
if derr != nil {
|
||||
s.auditPanelAction(r, pluginID, action, "bad_request")
|
||||
s.auditPanelAction(r, pluginID, action, "bad_request", "bad_request")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%v", derr))
|
||||
return
|
||||
}
|
||||
@@ -187,11 +187,11 @@ func (s *Server) adminPluginAction(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
if err != nil {
|
||||
status, code, msg := actionFailure(err)
|
||||
s.auditPanelAction(r, pluginID, action, auditResultOf(err))
|
||||
s.auditPanelAction(r, pluginID, action, auditResultOf(err), code)
|
||||
writeError(w, &apperr.AppError{Code: code, Message: msg, Cause: err, HTTPCode: status})
|
||||
return
|
||||
}
|
||||
s.auditPanelAction(r, pluginID, action, "ok")
|
||||
s.auditPanelAction(r, pluginID, action, "ok", "")
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"pluginId": pluginID,
|
||||
"action": action,
|
||||
@@ -202,6 +202,10 @@ func (s *Server) adminPluginAction(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// actionFailure 把分发失败映射为 HTTP 状态 + 结构化错误码 + 文案
|
||||
// (「错误码与文案分离」,§5.4.6:前端据此本地化,不靠解析字符串)。
|
||||
//
|
||||
// 顺序是先认宿主哨兵(未声明 / 停用 / 未接线),再认**插件侧自己给出的**
|
||||
// ActionError —— 插件比宿主更清楚这次失败是「请求写错了」还是「上游不可用」,
|
||||
// 宿主只做透传,不替它猜病因;两者都不是的内部错误才落 action_failed。
|
||||
func actionFailure(err error) (status int, code, message string) {
|
||||
switch {
|
||||
case errors.Is(err, pluginmgr.ErrActionNotDeclared):
|
||||
@@ -209,13 +213,43 @@ func actionFailure(err error) (status int, code, message string) {
|
||||
case errors.Is(err, pluginmgr.ErrPluginDisabled):
|
||||
return http.StatusConflict, "plugin_disabled", "插件已停用,其动作不再分发"
|
||||
case errors.Is(err, pluginmgr.ErrActionUnavailable):
|
||||
return http.StatusNotImplemented, "action_not_implemented", "动作已声明,但宿主尚未接线执行器"
|
||||
default:
|
||||
return http.StatusBadGateway, "action_failed", "插件动作执行失败,详细原因见后端日志"
|
||||
return http.StatusNotImplemented, "action_not_implemented", "动作已声明,但宿主尚未登记它的处理器"
|
||||
}
|
||||
if ae, ok := pluginmgr.AsActionError(err); ok {
|
||||
return actionErrorStatus(ae.Status), actionErrorCode(ae.Code), actionErrorText(ae.Message)
|
||||
}
|
||||
return http.StatusBadGateway, "action_failed", "插件动作执行失败,详细原因见后端日志"
|
||||
}
|
||||
|
||||
// auditResultOf 把分发失败归成审计链里的一档结果。
|
||||
// actionErrorStatus 归一结构化失败的状态码:0 或越界值一律回落到 502
|
||||
// (「上游/插件侧执行失败」是这条路径最诚实的默认档,不是 200)。
|
||||
func actionErrorStatus(status int) int {
|
||||
if status < http.StatusBadRequest || status > 599 {
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
// actionErrorCode 兜住空失败码:没有码的失败退回 action_failed,
|
||||
// 前端至少能显示一个稳定标识(空码会让提示行上少一段信息)。
|
||||
func actionErrorCode(code string) string {
|
||||
if strings.TrimSpace(code) == "" {
|
||||
return "action_failed"
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// actionErrorText 兜住空文案:实现没给出人话时,回一句明确指向服务端日志的话,
|
||||
// 绝不把空串当文案发给前端(那会渲染成一行无来由的红字)。
|
||||
func actionErrorText(message string) string {
|
||||
if strings.TrimSpace(message) == "" {
|
||||
return "插件动作执行失败,详细原因见后端日志"
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
// auditResultOf 把分发失败归成审计链里的一档结果(**分类**,不是插件域的错误码;
|
||||
// 后者作为 code 附加字段进日志,见 auditPanelAction)。
|
||||
func auditResultOf(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, pluginmgr.ErrActionNotDeclared):
|
||||
@@ -230,13 +264,19 @@ func auditResultOf(err error) string {
|
||||
}
|
||||
|
||||
// auditPanelAction 把一次插件动作的结局写进审计链(who / what / when / result)。
|
||||
// 附加字段只有插件 id 与动作名 —— 请求体可能有凭据,绝不进审计。
|
||||
func (s *Server) auditPanelAction(r *http.Request, pluginID, action, result string) {
|
||||
// 附加字段只有插件 id、动作名与结构化失败码 —— 请求体可能有凭据,绝不进审计;
|
||||
// 失败码是机器可读的稳定标识(不是请求内容),它让「审计里那次 error 到底是什么」
|
||||
// 不必翻服务端日志才能回答。
|
||||
func (s *Server) auditPanelAction(r *http.Request, pluginID, action, result, code string) {
|
||||
who := "anonymous"
|
||||
if claims, ok := auth.ClaimsFromContext(r.Context()); ok {
|
||||
who = claims.Sub + ":" + string(claims.Role)
|
||||
}
|
||||
s.audit(r.Context(), who, "plugin.action", result, "pluginId", pluginID, "action", action)
|
||||
extra := []any{"pluginId", pluginID, "action", action}
|
||||
if strings.TrimSpace(code) != "" {
|
||||
extra = append(extra, "code", code)
|
||||
}
|
||||
s.audit(r.Context(), who, "plugin.action", result, extra...)
|
||||
}
|
||||
|
||||
// decodeActionPayload 解码可选的动作请求体:空体 ⇒ nil(多数读来源动作不需要参数),
|
||||
|
||||
@@ -3,6 +3,7 @@ package gateway
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -219,11 +220,11 @@ func TestAdminPluginActionUndeclaredIs404(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestAdminPluginActionWriteRequiresOwnerOrAdmin 锁定写动作门(§5.4.1):
|
||||
// viewer 触发 form 的 submit ⇒ 403 且落审计;owner 且已接线执行器 ⇒ 200 且结果透传。
|
||||
// viewer 触发 form 的 submit ⇒ 403 且落审计;owner 且已登记处理器 ⇒ 200 且结果透传。
|
||||
func TestAdminPluginActionWriteRequiresOwnerOrAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newPanelTestEnv(t)
|
||||
env.hub.SetExecutor(func(_ context.Context, req pluginmgr.ActionRequest) (any, error) {
|
||||
registerTestActions(t, env.hub, "plugin.saveProvider", func(_ context.Context, req pluginmgr.ActionRequest) (any, error) {
|
||||
return map[string]any{"saved": req.Action, "payload": req.Payload}, nil
|
||||
})
|
||||
url := env.ts.URL + "/api/v1/admin/plugins/web-search/actions/plugin.saveProvider"
|
||||
@@ -259,7 +260,7 @@ func TestAdminPluginActionWriteRequiresOwnerOrAdmin(t *testing.T) {
|
||||
func TestAdminPluginActionReadSourceIsOpenToViewer(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newPanelTestEnv(t)
|
||||
env.hub.SetExecutor(func(_ context.Context, req pluginmgr.ActionRequest) (any, error) {
|
||||
registerTestActions(t, env.hub, "plugin.listProviders", func(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
|
||||
return []map[string]any{{"provider": "searxng"}}, nil
|
||||
})
|
||||
resp, raw := doReq(t, http.MethodPost,
|
||||
@@ -272,9 +273,10 @@ func TestAdminPluginActionReadSourceIsOpenToViewer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminPluginActionHonest501WithoutExecutor 锁定「声明了但没接线」的诚实作答:
|
||||
// 501 action_not_implemented,绝不回一份假的成功。
|
||||
func TestAdminPluginActionHonest501WithoutExecutor(t *testing.T) {
|
||||
// TestAdminPluginActionHonest501WithoutHandler 锁定「声明了但宿主没登记处理器」的诚实
|
||||
// 作答:501 action_not_implemented,绝不回一份假的成功。它**不是**兜底档 ——
|
||||
// 已登记处理器后同一条路必须真的执行(见下一个用例)。
|
||||
func TestAdminPluginActionHonest501WithoutHandler(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newPanelTestEnv(t)
|
||||
resp, raw := doReq(t, http.MethodPost,
|
||||
@@ -283,7 +285,45 @@ func TestAdminPluginActionHonest501WithoutExecutor(t *testing.T) {
|
||||
t.Fatalf("status = %d, body = %s,期望 501 action_not_implemented", resp.StatusCode, raw)
|
||||
}
|
||||
if !hasAudit(env.auditRecords(t), "plugin.action", "unimplemented") {
|
||||
t.Fatalf("未接线的动作结算必须落审计(unimplemented)")
|
||||
t.Fatalf("未登记处理器的动作结算必须落审计(unimplemented)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminPluginActionMapsStructuredFailure 锁定插件侧结构化失败的透传口径
|
||||
// (§5.4.6):错误码与文案分离、状态码由实现给出、病因只进日志;且失败也落审计。
|
||||
func TestAdminPluginActionMapsStructuredFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newPanelTestEnv(t)
|
||||
registerTestActions(t, env.hub, "plugin.testProvider", func(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
|
||||
return nil, pluginmgr.NewActionError("unauthorized", "上游拒绝了凭据(401/403):请核对 API Key 是否有效",
|
||||
http.StatusBadGateway, errors.New("upstream 401"))
|
||||
})
|
||||
resp, raw := doReq(t, http.MethodPost,
|
||||
env.ts.URL+"/api/v1/admin/plugins/web-search/actions/plugin.testProvider",
|
||||
map[string]any{"row": map[string]any{"provider": "zhipu"}}, env.token(t, auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, body = %s,期望 502(上游失败)", resp.StatusCode, raw)
|
||||
}
|
||||
var out struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, raw)
|
||||
}
|
||||
if out.Error.Code != "unauthorized" {
|
||||
t.Fatalf("错误码 = %q,期望实现给出的 unauthorized(不是笼统的 action_failed)", out.Error.Code)
|
||||
}
|
||||
if !strings.Contains(out.Error.Message, "上游拒绝了凭据") {
|
||||
t.Fatalf("文案 = %q,期望实现给出的那句话(错误码与文案分离)", out.Error.Message)
|
||||
}
|
||||
if strings.Contains(string(raw), "upstream 401") {
|
||||
t.Fatalf("病因(底层报错原文)不得进响应体:%s", raw)
|
||||
}
|
||||
if !hasAudit(env.auditRecords(t), "plugin.action", "error") {
|
||||
t.Fatalf("失败的插件动作必须落审计(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +344,7 @@ func TestAdminPluginActionDisabledIs409(t *testing.T) {
|
||||
func TestAdminPluginActionRejectsMalformedBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newPanelTestEnv(t)
|
||||
env.hub.SetExecutor(func(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
|
||||
registerTestActions(t, env.hub, "plugin.saveProvider", func(_ context.Context, _ pluginmgr.ActionRequest) (any, error) {
|
||||
return "should-not-run", nil
|
||||
})
|
||||
url := env.ts.URL + "/api/v1/admin/plugins/web-search/actions/plugin.saveProvider"
|
||||
@@ -343,6 +383,15 @@ func readBody(t *testing.T, resp *http.Response) []byte {
|
||||
return b
|
||||
}
|
||||
|
||||
// registerTestActions 给测试插件(web-search)登记一个动作处理器 —— 走的是生产同一条
|
||||
// 登记面(`Panels.RegisterActions`),不另开测试专用的后门。
|
||||
func registerTestActions(t *testing.T, hub *pluginmgr.Panels, action string, handler pluginmgr.ActionHandler) {
|
||||
t.Helper()
|
||||
if err := hub.RegisterActions("web-search", map[string]pluginmgr.ActionHandler{action: handler}); err != nil {
|
||||
t.Fatalf("RegisterActions(%s): %v", action, err)
|
||||
}
|
||||
}
|
||||
|
||||
// hasAudit 报告审计链里是否存在指定的 (what, result) 记录。
|
||||
func hasAudit(recs []AuditRecord, what, result string) bool {
|
||||
for _, r := range recs {
|
||||
|
||||
Reference in new issue
Block a user