From 5fccc31afca2cf75bfde33da98b1d4a7a3dcc002 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 27 Aug 2026 23:29:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(plugin):=20DisablePlugin=20=E7=A6=81?= =?UTF-8?q?=E6=AD=A2=E7=A6=81=E7=94=A8=E6=9C=AA=E5=AE=89=E8=A3=85=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=EF=BC=8C=E9=98=B2=E6=AD=A2=E8=84=8F=E5=86=99=20disabl?= =?UTF-8?q?ed=5Fplugins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:POST /api/v1/plugins//disable 对不存在的插件也会把它写进 disabled_plugins 表(registry.go DisablePlugin 无条件 AddDisabledPlugin), 产生脏数据堆积,且同名插件日后真实安装会被误判为已禁用。 修复: - registry.go 新增 pluginInstalled(name):已加载 / 已注册工厂(内置) / 插件目录存在, 任一命中视为已安装 - DisablePlugin 开头校验:未安装返回 'plugin X not installed',不写 disabled_plugins - webui handler:未安装→404,已禁用→409(原都返回500);enable 失败含'failed'→404 - 新增 registry_disable_test.go:覆盖未安装拒绝/内置判定/目录存在判定/普通文件不算 本机端到端验证:disable 不存在插件返回404且表无脏数据;真实插件 disable/enable 正常 --- internal/plugin/registry.go | 33 ++++++++++- internal/plugin/registry_disable_test.go | 74 ++++++++++++++++++++++++ internal/plugins/webui/handler.go | 18 +++++- 3 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 internal/plugin/registry_disable_test.go diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index 2903270..75a387d 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -672,6 +672,33 @@ func (r *Registry) IsBuiltinPlugin(name string) bool { return ok } +// pluginInstalled 判断插件是否已安装(可用于禁用/启用等操作前的存在性校验)。 +// 命中任一即视为已安装: +// 1. 已加载(plugins map 中) +// 2. 已注册工厂(内置插件,通过 init() 自注册,无需物理目录) +// 3. 插件目录 plgDir/ 存在(外部插件的安装目录) +func (r *Registry) pluginInstalled(name string) bool { + if r == nil || name == "" { + return false + } + r.mu.RLock() + _, loaded := r.plugins[name] + _, isFactory := r.factories[name] + r.mu.RUnlock() + if loaded || isFactory { + return true + } + if _, ok := globalFactories.Load(name); ok { + return true + } + if r.plgDir != "" { + if fi, err := os.Stat(filepath.Join(r.plgDir, name)); err == nil && fi.IsDir() { + return true + } + } + return false +} + func (r *Registry) ListLoadedPlugins() []string { return r.List() } func (r *Registry) ListDisabledPlugins() []sdk.DisabledPluginInfo { @@ -692,6 +719,11 @@ func (r *Registry) ListDisabledPlugins() []sdk.DisabledPluginInfo { func (r *Registry) IsPluginDisabled(name string) bool { return r.isDisabled(name) } func (r *Registry) DisablePlugin(name, by string) error { + // 插件不存在(未安装):拒绝并返回错误,避免把不存在的插件写进 disabled_plugins。 + // 判断标准:已加载 / 已注册工厂(内置)/ 插件目录存在,任一命中视为已安装。 + if !r.pluginInstalled(name) { + return fmt.Errorf("plugin %s not installed", name) + } // Check not disabling self if running if r.cfgReg != nil { // If already disabled, no-op @@ -731,7 +763,6 @@ func (r *Registry) DisablePlugin(name, by string) error { } func (r *Registry) EnablePlugin(name string) error { return r.Enable(name) } - // StopAndUnload 停止并从注册表移除插件,但保留其配置表(config_)。 // 供插件更新/升级流程使用:换 so/文件不动配置,重装后配置原样生效。 // 不执行 onRemove 回调(那是删除专用语义)。目录由调用方管理。 diff --git a/internal/plugin/registry_disable_test.go b/internal/plugin/registry_disable_test.go new file mode 100644 index 0000000..8664eff --- /dev/null +++ b/internal/plugin/registry_disable_test.go @@ -0,0 +1,74 @@ +package plugin + +import ( + "os" + "path/filepath" + "testing" + + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// TestDisablePluginNotInstalled 验证 DisablePlugin 对未安装插件的拒绝: +// 修复前会把任意名字写进 disabled_plugins 表(脏数据),修复后应直接报错。 +func TestDisablePluginNotInstalled(t *testing.T) { + r := &Registry{ + plugins: make(map[string]sdk.Plugin), + factories: make(map[string]NativeFactory), + knownDisabled: make(map[string]bool), + plgDir: t.TempDir(), + } + err := r.DisablePlugin("nonexistent_test_xyz", "test") + if err == nil { + t.Fatal("DisablePlugin should reject a plugin that is not installed") + } + if err.Error() != "plugin nonexistent_test_xyz not installed" { + t.Fatalf("unexpected error: %v", err) + } + if r.knownDisabled["nonexistent_test_xyz"] { + t.Fatal("knownDisabled must not contain a non-installed plugin") + } +} + +// TestPluginInstalledFactory 内置插件(工厂注册,无物理目录)应视为已安装。 +func TestPluginInstalledFactory(t *testing.T) { + r := &Registry{ + plugins: make(map[string]sdk.Plugin), + factories: map[string]NativeFactory{"builtin_demo": nil}, + knownDisabled: make(map[string]bool), + plgDir: t.TempDir(), + } + if !r.pluginInstalled("builtin_demo") { + t.Fatal("factory-registered builtin plugin should be considered installed") + } + if r.pluginInstalled("no_such_plugin") { + t.Fatal("unknown plugin should not be considered installed") + } +} + +// TestPluginInstalledDir 外部插件按 plgDir/ 目录存在判定已安装。 +func TestPluginInstalledDir(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "external_demo"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + r := &Registry{ + plugins: make(map[string]sdk.Plugin), + factories: make(map[string]NativeFactory), + knownDisabled: make(map[string]bool), + plgDir: dir, + } + if !r.pluginInstalled("external_demo") { + t.Fatal("plugin with an existing directory should be considered installed") + } + // 同名普通文件(非目录)不算已安装 + if err := os.WriteFile(filepath.Join(dir, "just_a_file"), []byte("x"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + if r.pluginInstalled("just_a_file") { + t.Fatal("a regular file must not count as an installed plugin") + } + // 空名兜底 + if r.pluginInstalled("") { + t.Fatal("empty plugin name must not be considered installed") + } +} diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index 0719aeb..95ecde6 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -2600,7 +2600,16 @@ func (h *Handler) handlePluginByID(w http.ResponseWriter, r *http.Request) { return } if err := h.pluginMgr.DisablePlugin(name, "webui"); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + // 未安装 → 404;已禁用 → 409;其余为真实内部错误 + status := http.StatusInternalServerError + msg := err.Error() + switch { + case strings.HasSuffix(msg, "not installed"): + status = http.StatusNotFound + case strings.HasSuffix(msg, "already disabled"): + status = http.StatusConflict + } + writeJSON(w, status, map[string]string{"error": msg}) return } writeJSON(w, http.StatusOK, map[string]string{"status": "disabled"}) @@ -2616,7 +2625,12 @@ func (h *Handler) handlePluginByID(w http.ResponseWriter, r *http.Request) { return } if err := h.pluginMgr.EnablePlugin(name); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + // 启用失败多数是“插件不存在/加载失败” → 404 更贴切 + status := http.StatusInternalServerError + if strings.Contains(err.Error(), "failed") { + status = http.StatusNotFound + } + writeJSON(w, status, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]string{"status": "enabled"})