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:
AskaEth committed 2026-10-06 14:12:23 +08:00
1 parent a150211468
commit 2e1c3ed3fd
13 files changed
+2043 -204

No files matched your search

+54 -14
View File
@@ -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(多数读来源动作不需要参数),
+57 -8
View File
@@ -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 {