Linus 修复: PUT 部分更新用 map[string]json.RawMessage 区分 absent/present

原 PUT /admin/clusters/:id 用 ShouldBindJSON + struct, 缺字段自动
填 Go zero value, 无法区分 "未传" vs "传零值":
- true bool → 缺省 = false → 被误翻成 false
- 1 int → 缺省 = 0 → 丢失
- "" string → 缺省 = "" → 清空

第二次 (用 if-zero-value-keep-old 逻辑) 实际更糟: bool "缺省 = false"
跟 old "true" 不等 → 反而把 true 翻成 false。

正确做法: 用 map[string]json.RawMessage 解析 body, 用
_, ok := rawBody["field"] 判断 absent, 缺字段 = 保留 old。

- internal/admin/router.go: 改 updateCluster 解析方式,
  加 decodeString/Int/Bool/StringSlice helpers
  + validateClusterUpdate (轻量校验, 不要求所有必填字段)
- internal/admin/router_test.go: +2 测试
  - TestUpdateCluster_PartialUpdatePreservesOtherFields
    (单字段 PUT 保留其他全部)
  - TestUpdateCluster_SliceReplacement
    (slice 显式覆盖, nil = 保留, [] = 替换)

端到端实测:
- PUT rate_limit=99 保留 is_active=true (修复前会被翻 false)
- PUT is_active=false 保留 rate_limit=99
- PUT auth_type=basic 保留其他全部
- PUT url_allowlist=[only] 替换 slice
- audit 5 条 (1 create + 4 update) DESC 正确

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-10 18:23:44 +08:00
co-authored by Claude
parent 9a48f9e68e
commit 756a88a800
2 changed files with 191 additions and 9 deletions
+80
View File
@@ -13,6 +13,7 @@ import (
"github.com/gin-gonic/gin"
"spark-mcp-go/internal/audit"
"spark-mcp-go/internal/cluster"
"spark-mcp-go/internal/storage"
)
@@ -335,6 +336,85 @@ func TestUpdateCluster_PreservesEmptyPassword(t *testing.T) {
}
}
// TestUpdateCluster_PartialUpdatePreservesOtherFields verifies that PUT only
// touches fields present in the request body. Sends a full cluster via POST,
// then PUTs a body with a single field ("is_active": false) and checks every
// other field still matches the original.
func TestUpdateCluster_PartialUpdatePreservesOtherFields(t *testing.T) {
r, repo, _ := newTestAdmin(t)
original := fullClusterMap("c1")
original["is_active"] = true
original["rate_limit_per_min"] = 25
original["url_allowlist"] = []string{"rm", "shs"}
original["default_submit_args"] = []string{"--conf", "spark.foo=bar"}
if w := doReq(t, r, "POST", "/admin/clusters", "good-token", original); w.Code != http.StatusCreated {
t.Fatalf("seed POST: %d %s", w.Code, w.Body.String())
}
patch := map[string]any{"is_active": false}
w := doReq(t, r, "PUT", "/admin/clusters/c1", "good-token", patch)
if w.Code != http.StatusOK {
t.Fatalf("PUT: %d %s", w.Code, w.Body.String())
}
got, err := repo.Get(context.Background(), "c1")
if err != nil {
t.Fatalf("get after PUT: %v", err)
}
if got.IsActive != false {
t.Errorf("is_active: got %v, want false", got.IsActive)
}
// Every other field should equal the original.
if got.Name != original["name"] {
t.Errorf("name: got %q, want %q", got.Name, original["name"])
}
if got.RMURL != original["rm_url"] {
t.Errorf("rm_url: got %q, want %q", got.RMURL, original["rm_url"])
}
if got.SHSURL != original["shs_url"] {
t.Errorf("shs_url: got %q, want %q", got.SHSURL, original["shs_url"])
}
if got.SparkSubmitExecuteBin != original["spark_submit_execute_bin"] {
t.Errorf("spark_submit_execute_bin: got %q, want %q", got.SparkSubmitExecuteBin, original["spark_submit_execute_bin"])
}
if got.AuthType != cluster.AuthBasic {
t.Errorf("auth_type: got %q, want basic (preserved from seed)", got.AuthType)
}
if got.RateLimitPerMin != 25 {
t.Errorf("rate_limit_per_min: got %d, want 25 (preserved from seed)", got.RateLimitPerMin)
}
if len(got.URLAllowlist) != 2 || got.URLAllowlist[0] != "rm" || got.URLAllowlist[1] != "shs" {
t.Errorf("url_allowlist: got %v, want [rm shs] (preserved)", got.URLAllowlist)
}
if len(got.DefaultSubmitArgs) != 2 || got.DefaultSubmitArgs[0] != "--conf" {
t.Errorf("default_submit_args: got %v, want [--conf ...] (preserved)", got.DefaultSubmitArgs)
}
}
// TestUpdateCluster_SliceReplacement checks that an explicit non-nil slice
// in the body *replaces* the old slice (rather than appending or being
// ignored as a zero value).
func TestUpdateCluster_SliceReplacement(t *testing.T) {
r, repo, _ := newTestAdmin(t)
if w := doReq(t, r, "POST", "/admin/clusters", "good-token", fullClusterMap("c1")); w.Code != http.StatusCreated {
t.Fatalf("seed POST: %d %s", w.Code, w.Body.String())
}
seed, _ := repo.Get(context.Background(), "c1")
if len(seed.URLAllowlist) == 0 {
t.Fatal("seed should have url_allowlist from fullClusterMap")
}
// Replace with a new single-entry list.
patch := map[string]any{"url_allowlist": []string{"only-this"}}
if w := doReq(t, r, "PUT", "/admin/clusters/c1", "good-token", patch); w.Code != http.StatusOK {
t.Fatalf("PUT: %d %s", w.Code, w.Body.String())
}
got, _ := repo.Get(context.Background(), "c1")
if len(got.URLAllowlist) != 1 || got.URLAllowlist[0] != "only-this" {
t.Errorf("url_allowlist: got %v, want [only-this]", got.URLAllowlist)
}
}
func TestDeleteCluster(t *testing.T) {
r, _, auditRepo := newTestAdmin(t)
doReq(t, r, "POST", "/admin/clusters", "good-token", fullClusterMap("c1"))