From 4ae714883d13fa734c506f56759e14f2a49136d4 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 13:42:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(gateway,runtimeconfig):=20=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E9=85=8D=E7=BD=AE=E5=86=99=E8=B7=AF=E5=BE=84=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=20patch=20=E8=AF=AD=E4=B9=89=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=87=AD=E6=8D=AE=E9=9D=99=E9=BB=98=E4=B8=A2=E5=A4=B1=EF=BC=88?= =?UTF-8?q?P0=20=E7=BA=A7=E6=95=B0=E6=8D=AE=E5=AE=89=E5=85=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新问题(本轮前端组发现):config 是整条替换,而面板只发送用户本次碰过的平台字段 ⇒ ① 保存微信(iLink)账号卡(其平台字段只有 cdn_base_url,甚至可为空)会把 bot_token/bot_id/owner_id/get_updates_buf **全部抹掉**(iLink token 是长期凭据); ② Telegram 填 webhook_url 而不重填 token 会因 cleanAccounts「token 空即丢账号」把**整个账号**删掉 - 修法:新增 runtimeconfig.PatchChannel + mergeChannelConfig + isEmptyConfigValue(三态,与 personaId 同构) · 键省略 / config 整体缺席 / null = **保留已存值**(未给出的键绝不被抹掉) · 给出非空值 = 覆盖 · 显式 null 或 "" = **清除该键**(0/false 不算空) · 写路径 UpsertChannel → PatchChannel;非 config 字段(名单/限流/personaId)仍整值替换(不变) - 回归(真断言落盘内容):微信卡片只带 cdn_base_url / 空卡片三种形态 → 四个凭据键全在; Telegram 只带 webhook_url → 账号存活且 token 不变;显式 null/"" → 键真被清除;新账号正常写入; 既有 TestAdminSaveChannelConfigPersistsAndReloads 保持绿 - 变异反证 3 条(改回 UpsertChannel → 精确复现两缺陷;去掉清除分支;"" 不再算空)均已还原 - 边界未改:token 要求型平台**显式**清 token 仍按 cleanAccounts 丢账号(主人明确清除凭据的语义); 「本次一个 config 键都没给出」在非 weixin-ilink 平台仍回 400(既有行为,不在授权内放宽) --- internal/config/runtimeconfig/channels.go | 57 ++++ .../config/runtimeconfig/channels_test.go | 95 +++++++ .../admin_channel_config_patch_test.go | 262 ++++++++++++++++++ .../gateway/admin_write_channel_config.go | 41 ++- 4 files changed, 446 insertions(+), 9 deletions(-) create mode 100644 internal/services/gateway/admin_channel_config_patch_test.go diff --git a/internal/config/runtimeconfig/channels.go b/internal/config/runtimeconfig/channels.go index a8cf9cf..b214f9d 100644 --- a/internal/config/runtimeconfig/channels.go +++ b/internal/config/runtimeconfig/channels.go @@ -138,6 +138,63 @@ func UpsertChannel(accounts map[string][]config.ChannelAccount, platform string, return accounts } +// PatchChannel 以 **patch 语义** 写入一个账号,是 UpsertChannel 的补丁版: +// acct.Config 被当作「只含本次请求显式给出的键」的补丁,而不是一整套完整配置。 +// +// - 补丁里**缺位的键** → 保留该账号已存值(未被触碰的键绝不因一次保存被抹掉); +// - 补丁里给出的非空值 → 覆盖该键; +// - 补丁里给出 nil 或空白字符串 → **删除**该键(保留「清空」能力,删掉后适配器回落到自己的零值)。 +// +// 同平台同 id 的已存条目是补丁的基线;没有基线时(新账号,或 id 为空、由 UpsertChannel +// 生成 id)只写入补丁本身。acct 的其它字段(黑白名单 / 限流 / personaId)与 UpsertChannel +// 一样是整值替换 —— 它们在线上是值语义,不是三态。返回值与入参 accounts 的关系同 UpsertChannel。 +func PatchChannel(accounts map[string][]config.ChannelAccount, platform string, acct config.ChannelAccount) map[string][]config.ChannelAccount { + if accounts == nil { + accounts = map[string][]config.ChannelAccount{} + } + var stored config.ChannelAccount + if id := strings.TrimSpace(acct.ID); id != "" { + for _, cur := range accounts[platform] { + if cur.ID == id { + stored = cur + break + } + } + } + acct.Config = mergeChannelConfig(stored.Config, acct.Config) + return UpsertChannel(accounts, platform, acct) +} + +// mergeChannelConfig 把补丁合并进已存 config,返回一个新 map(stored 与 patch 均不被改动)。 +// 缺位的键保留已存值;nil/空白的补丁值删除该键;其余覆盖。空补丁是恒等变换。 +func mergeChannelConfig(stored, patch map[string]any) map[string]any { + out := make(map[string]any, len(stored)+len(patch)) + for k, v := range stored { + out[k] = v + } + for k, v := range patch { + if isEmptyConfigValue(v) { + delete(out, k) + continue + } + out[k] = v + } + return out +} + +// isEmptyConfigValue 报告一个补丁值是否表示「清除该键」:显式 nil,或只含空白的字符串。 +// 其它类型(数字/布尔/对象/数组)永远不算空 —— 0 与 false 是真实取值,不是「未设置」。 +func isEmptyConfigValue(v any) bool { + switch t := v.(type) { + case nil: + return true + case string: + return strings.TrimSpace(t) == "" + default: + return false + } +} + // DeleteChannel returns accounts with the account of the given id removed from // its platform. When the platform has no remaining accounts the platform key is // dropped entirely, so a reload of that platform yields no adapters. diff --git a/internal/config/runtimeconfig/channels_test.go b/internal/config/runtimeconfig/channels_test.go index 2d843ec..7033066 100644 --- a/internal/config/runtimeconfig/channels_test.go +++ b/internal/config/runtimeconfig/channels_test.go @@ -211,6 +211,101 @@ func TestUpsertChannelAssignsIDAndReplaces(t *testing.T) { } } +// TestPatchChannelKeepsOmittedKeys 钉住补丁语义:补丁里缺位的键保留 +// 已存值,给出的键覆盖,显式 nil/"" 删除该键(且已存 map 不被就地改写)。 +func TestPatchChannelKeepsOmittedKeys(t *testing.T) { + t.Parallel() + stored := map[string]any{"bot_token": "old-token", "webhook_url": "https://old", "cdn_base_url": ""} + accounts := map[string][]config.ChannelAccount{ + "telegram": {{ID: "conf0", Config: stored}}, + } + out := PatchChannel(accounts, "telegram", config.ChannelAccount{ + ID: "conf0", + Config: map[string]any{"webhook_url": "https://new"}, + }) + got := out["telegram"][0].Config + if got["bot_token"] != "old-token" { + t.Fatalf("an omitted key was lost: %v", got) + } + if got["webhook_url"] != "https://new" { + t.Fatalf("a given key was not overridden: %v", got) + } + // 已存条目自身不被改动(补丁是纯函数式的合并)。 + if stored["webhook_url"] != "https://old" { + t.Fatalf("PatchChannel mutated the stored map: %v", stored) + } +} + +// TestPatchChannelClearsOnNilAndBlank 覆盖「清除」三态的一半:显式 nil 与 +// 只含空白的字符串删除该键;数字 0 / 布尔 false 是真实取值,不算空。 +func TestPatchChannelClearsOnNilAndBlank(t *testing.T) { + t.Parallel() + accounts := map[string][]config.ChannelAccount{ + "qq-obv11": {{ID: "ob1", Config: map[string]any{ + "mode": "server", "remote_url": "ws://x", "listen_addr": "127.0.0.1:1", "verbose": true, + }}}, + } + out := PatchChannel(accounts, "qq-obv11", config.ChannelAccount{ + ID: "ob1", + Config: map[string]any{ + "mode": nil, "remote_url": "", "listen_addr": " ", + "verbose": false, "retry": float64(0), + }, + }) + got := out["qq-obv11"][0].Config + for _, key := range []string{"mode", "remote_url", "listen_addr"} { + if _, present := got[key]; present { + t.Fatalf("key %q should have been cleared: %v", key, got) + } + } + if v, ok := got["verbose"]; !ok || v != false { + t.Fatalf("verbose = %#v (present=%v), want a real false kept", v, ok) + } + if v, ok := got["retry"]; !ok || v != float64(0) { + t.Fatalf("retry = %#v (present=%v), want a real 0 kept", v, ok) + } +} + +// TestPatchChannelWithoutBaseline 覆盖没有已存条目的情形:补丁即配置, +// 空 id 由 UpsertChannel 生成,且清除不会留下 null 值。 +func TestPatchChannelWithoutBaseline(t *testing.T) { + t.Parallel() + accounts := map[string][]config.ChannelAccount{} + out := PatchChannel(accounts, "weixin-ilink", config.ChannelAccount{ + Config: map[string]any{"cdn_base_url": "https://cdn", "bot_token": nil}, + }) + list := out["weixin-ilink"] + if len(list) != 1 || list[0].ID == "" { + t.Fatalf("out = %+v, want one account with a generated id", list) + } + if list[0].Config["cdn_base_url"] != "https://cdn" { + t.Fatalf("config = %v, want the patch written", list[0].Config) + } + if _, present := list[0].Config["bot_token"]; present { + t.Fatalf("a clear must not write a key: %v", list[0].Config) + } +} + +// TestPatchChannelOnlyTouchesTargetAccount 钉住「补丁只作用于目标账号」: +// 兄弟账号与其它平台的 config 完全不动。 +func TestPatchChannelOnlyTouchesTargetAccount(t *testing.T) { + t.Parallel() + accounts := map[string][]config.ChannelAccount{ + "telegram": { + {ID: "conf0", Config: map[string]any{"bot_token": "a"}}, + {ID: "conf1", Config: map[string]any{"bot_token": "b", "webhook_url": "https://keep"}}, + }, + "kook": {{ID: "conf0", Config: map[string]any{"bot_token": "k"}}}, + } + out := PatchChannel(accounts, "kook", config.ChannelAccount{ID: "conf0", Config: map[string]any{"bot_token": "k2"}}) + if len(out["telegram"]) != 2 || out["telegram"][1].Config["webhook_url"] != "https://keep" { + t.Fatalf("a sibling account was touched: %+v", out["telegram"]) + } + if out["kook"][0].Config["bot_token"] != "k2" || len(out["kook"][0].Config) != 1 { + t.Fatalf("kook account = %+v, want just the patched key", out["kook"][0].Config) + } +} + func TestDeleteChannelRemovesAccountAndDropsPlatform(t *testing.T) { t.Parallel() accounts := map[string][]config.ChannelAccount{ diff --git a/internal/services/gateway/admin_channel_config_patch_test.go b/internal/services/gateway/admin_channel_config_patch_test.go new file mode 100644 index 0000000..79689c0 --- /dev/null +++ b/internal/services/gateway/admin_channel_config_patch_test.go @@ -0,0 +1,262 @@ +package gateway + +// 本文件覆盖渠道配置写路径的 **patch 语义** —— 「凭据静默丢失」回归 +// (实现见 admin_write_channel_config.go 与 runtimeconfig.PatchChannel)。 +// +// 面板只携带用户本次碰过的平台字段,旧实现把 config 当作整条替换, +// 未被面板携带的键因此被静默抹掉:保存微信(iLink)账号卡会清掉 +// bot_token/bot_id/owner_id/get_updates_buf(iLink token 是长期凭据), +// 给 Telegram 账号填 webhook_url 而不重填 token 会把整个账号 +// 从 channels.json 里丢掉(cleanAccounts)。线上契约因此是三态: +// +// 键缺席 → 保留该账号已存值 +// 给出非空值 → 覆盖该键 +// 显式 null / "" → 清除该键 +// +// 用字面量 JSON 请求体断言线上形状(尤其是「完全没有 token 键」), +// 否则 map[string]any 会让断言从别处取值而通过。 + +import ( + "path/filepath" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" +) + +// storedChannelAccountOrNil 读取 channels.json 里某平台某 id 的账号; +// 不存在时返回 (零值, false)。 +func storedChannelAccountOrNil(t *testing.T, path, platform, accountID string) (config.ChannelAccount, bool) { + t.Helper() + accounts, warn := runtimeconfig.LoadChannels(path, nil) + if warn != nil { + t.Fatalf("load channels warn = %v", warn) + } + for _, acct := range accounts[platform] { + if acct.ID == accountID { + return acct, true + } + } + return config.ChannelAccount{}, false +} + +// TestAdminSaveChannelConfigKeepsWeixinIlinkCredentials 是主回归:微信 +// (iLink)账号卡的 PLATFORM_FIELDS 只有 cdn_base_url,保存它时请求体 +// 里一个凭据键都没有。patch 语义下 bot_token/bot_id/owner_id/ +// get_updates_buf 必须原样留在盘上 —— iLink token 是长期凭据, +// 被抹掉就等于掉线且不可恢复。 +func TestAdminSaveChannelConfigKeepsWeixinIlinkCredentials(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "channels.json") + store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ + "weixin-ilink": {{ID: "wx1", Config: map[string]any{ + "bot_token": "ilink-long-lived-token", + "bot_id": "wxid_bot", + "owner_id": "wxid_owner", + "get_updates_buf": "buf-42", + "cdn_base_url": "https://cdn.old", + }}}, + }} + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + putChannelConfigRaw(t, ts, "weixin-ilink", + `{"id":"wx1","config":{"cdn_base_url":"https://cdn.new"}}`, tok) + + acct, ok := storedChannelAccountOrNil(t, path, "weixin-ilink", "wx1") + if !ok { + t.Fatalf("weixin-ilink/wx1 vanished from channels.json after saving its card") + } + for key, want := range map[string]any{ + "bot_token": "ilink-long-lived-token", + "bot_id": "wxid_bot", + "owner_id": "wxid_owner", + "get_updates_buf": "buf-42", + "cdn_base_url": "https://cdn.new", + } { + if got := acct.Config[key]; got != want { + t.Fatalf("config[%q] = %#v, want %#v (config=%v)", key, got, want, acct.Config) + } + } +} + +// TestAdminSaveChannelConfigKeepsWeixinCredentialsOnEmptyPatch 是同一条 +// 丢失口的真实触发形态:主人打开微信账号卡、**什么都没填**就点保存 +// (面板于是既不发 config,也不发任何便捷字段)。整个请求不得 +// 改变已存 config —— 既不能抹掉凭据,也不能写入 null 垃圾。 +func TestAdminSaveChannelConfigKeepsWeixinCredentialsOnEmptyPatch(t *testing.T) { + t.Parallel() + stored := func() map[string][]config.ChannelAccount { + return map[string][]config.ChannelAccount{ + "weixin-ilink": {{ID: "wx1", Config: map[string]any{ + "bot_token": "ilink-token", "bot_id": "wxid_bot", "owner_id": "wxid_owner", + }}}, + } + } + for _, tc := range []struct{ name, body string }{ + {"没有 config 键", `{"id":"wx1"}`}, + {"config 显式 null", `{"id":"wx1","config":null}`}, + {"config 是空对象", `{"id":"wx1","config":{}}`}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + // 每个子用例独占一个路径:并行写同一个临时文件会互相打架。 + path := filepath.Join(t.TempDir(), "channels.json") + store := &stubChannelConfigStore{path: path, accounts: stored()} + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + putChannelConfigRaw(t, ts, "weixin-ilink", tc.body, tok) + + acct, ok := storedChannelAccountOrNil(t, path, "weixin-ilink", "wx1") + if !ok { + t.Fatalf("account vanished (body=%s)", tc.body) + } + for _, key := range []string{"bot_token", "bot_id", "owner_id"} { + if got := acct.Config[key]; got == nil || got == "" { + t.Fatalf("config[%q] = %#v, want the stored value kept (body=%s)", key, got, tc.body) + } + } + for key, v := range acct.Config { + if v == nil { + t.Fatalf("config[%q] was written as JSON null (body=%s)", key, tc.body) + } + } + }) + } +} + +// TestAdminSaveChannelConfigKeepsTelegramAccountWhenTokenOmitted 覆盖第二条 +// 丢失口:主人给已有的 Telegram 账号填 webhook_url 而不重填 token。 +// 旧行为是整条替换 ⇒ bot_token 变空 ⇒ cleanAccounts 把**整个账号** +// 丢掉。patch 语义下该账号必须还在,且已存 token 不变。 +func TestAdminSaveChannelConfigKeepsTelegramAccountWhenTokenOmitted(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "channels.json") + store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ + "telegram": {{ID: "conf0", Config: map[string]any{"bot_token": "tg-stored-token"}}}, + }} + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + putChannelConfigRaw(t, ts, "telegram", + `{"id":"conf0","config":{"webhook_url":"https://hook.example"}}`, tok) + + accounts, warn := runtimeconfig.LoadChannels(path, nil) + if warn != nil { + t.Fatalf("load channels warn = %v", warn) + } + list := accounts["telegram"] + if len(list) != 1 { + t.Fatalf("telegram = %+v, want the single existing account to survive", list) + } + if list[0].ID != "conf0" || list[0].Config["bot_token"] != "tg-stored-token" { + t.Fatalf("telegram account = %+v, want conf0 with its stored token kept", list[0]) + } + if list[0].Config["webhook_url"] != "https://hook.example" { + t.Fatalf("webhook_url = %#v, want the newly sent value", list[0].Config["webhook_url"]) + } +} + +// TestAdminSaveChannelConfigClearsConfigKeyExplicitly 保持「清空」能力: +// 显式 null 与显式 "" 都必须真的删掉该键(适配器随后回落到自己的 +// 零值),而未触碰的兄弟键不受影响。 +func TestAdminSaveChannelConfigClearsConfigKeyExplicitly(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "channels.json") + store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ + "telegram": {{ID: "conf0", Config: map[string]any{ + "bot_token": "tg-stored-token", "webhook_url": "https://old.hook", "base_url": "https://old.api", + }}}, + }} + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + // 显式 null → 清除。 + putChannelConfigRaw(t, ts, "telegram", `{"id":"conf0","config":{"webhook_url":null}}`, tok) + acct, ok := storedChannelAccountOrNil(t, path, "telegram", "conf0") + if !ok { + t.Fatal("telegram/conf0 vanished after an explicit null") + } + if _, present := acct.Config["webhook_url"]; present { + t.Fatalf("explicit null did not clear the key: %v", acct.Config) + } + if acct.Config["base_url"] != "https://old.api" || acct.Config["bot_token"] != "tg-stored-token" { + t.Fatalf("sibling keys were touched by a single-key clear: %v", acct.Config) + } + + // 显式 "" → 清除。 + putChannelConfigRaw(t, ts, "telegram", `{"id":"conf0","config":{"base_url":""}}`, tok) + acct, ok = storedChannelAccountOrNil(t, path, "telegram", "conf0") + if !ok { + t.Fatal("telegram/conf0 vanished after an explicit \"\"") + } + if _, present := acct.Config["base_url"]; present { + t.Fatalf("explicit \"\" did not clear the key: %v", acct.Config) + } + if acct.Config["bot_token"] != "tg-stored-token" { + t.Fatalf("the token was lost while clearing another key: %v", acct.Config) + } +} + +// TestAdminSaveChannelConfigExplicitTokenClearStillDropsAccount 钉住 patch +// 与既有凭据保护的交互:token 要求型平台(kook/telegram)上**显式** +// 清掉 bot_token 仍然按 cleanAccounts 丢弃该账号 —— 这是主人明确要求 +// 清除凭据,不是「未被触碰的键被误删」。本用例只记录现状,不改该行为。 +func TestAdminSaveChannelConfigExplicitTokenClearStillDropsAccount(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "channels.json") + store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ + "telegram": { + {ID: "conf0", Config: map[string]any{"bot_token": "tg-token"}}, + {ID: "conf1", Config: map[string]any{"bot_token": "tg-other"}}, + }, + }} + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + putChannelConfigRaw(t, ts, "telegram", `{"id":"conf0","config":{"bot_token":null}}`, tok) + + accounts, warn := runtimeconfig.LoadChannels(path, nil) + if warn != nil { + t.Fatalf("load channels warn = %v", warn) + } + if _, ok := storedChannelAccountOrNil(t, path, "telegram", "conf0"); ok { + t.Fatalf("telegram/conf0 = %+v, want it dropped after an explicit token clear", accounts["telegram"]) + } + if len(accounts["telegram"]) != 1 || accounts["telegram"][0].ID != "conf1" { + t.Fatalf("telegram = %+v, want only the untouched sibling conf1", accounts["telegram"]) + } +} + +// TestAdminSaveChannelConfigCreatesAccountWithPatchOnly 覆盖无基线的新建: +// 没有已存条目时补丁本身就是配置(非空键写入,显式清除不留 null)。 +func TestAdminSaveChannelConfigCreatesAccountWithPatchOnly(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "channels.json") + store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{}} + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + // qq-obv11 不要求 token,因此新建账号不会被 cleanAccounts 丢掉。 + putChannelConfigRaw(t, ts, "qq-obv11", + `{"id":"ob1","config":{"mode":"server","remote_url":"ws://127.0.0.1:3001"}}`, tok) + acct, ok := storedChannelAccountOrNil(t, path, "qq-obv11", "ob1") + if !ok { + t.Fatal("a brand-new account was not created") + } + if acct.Config["mode"] != "server" || acct.Config["remote_url"] != "ws://127.0.0.1:3001" { + t.Fatalf("new account config = %v, want the patch written verbatim", acct.Config) + } + + // 全新账号 + 只有显式清除 → 不写 null 垃圾,账号照样建出来。 + putChannelConfigRaw(t, ts, "qq-obv11", `{"id":"ob2","config":{"mode":null}}`, tok) + acct, ok = storedChannelAccountOrNil(t, path, "qq-obv11", "ob2") + if !ok { + t.Fatal("a new account carrying only clears was not created") + } + if _, present := acct.Config["mode"]; present { + t.Fatalf("a clear on a brand-new account must not write the key: %v", acct.Config) + } +} diff --git a/internal/services/gateway/admin_write_channel_config.go b/internal/services/gateway/admin_write_channel_config.go index cd31336..f051e14 100644 --- a/internal/services/gateway/admin_write_channel_config.go +++ b/internal/services/gateway/admin_write_channel_config.go @@ -11,6 +11,12 @@ package gateway // bot_token / base_url / remote_url / endpointPath),外加针对常见 // token/地址字段的顶层便捷字段,因此面板既可发送 // config 对象,也可发送快捷字段。 +// +// config 的线上契约是 **patch 语义**(三态,与 personaId 同构),不是整条替换: +// 面板只携带用户本次碰过的平台字段,因此未被携带的键必须保留已存值 —— +// 否则打开一张账号卡直接保存,就会把面板不渲染的凭据(weixin-ilink 的 +// bot_token/bot_id/owner_id)静默抹掉。判定见 runtimeconfig.PatchChannel: +// 键缺席 = 保留、给出非空值 = 覆盖、给出 null/"" = 清除。 import ( "encoding/json" @@ -54,8 +60,16 @@ type AdminChannelAccount struct { // channelConfigRequest 是 PUT /api/v1/admin/channels/{platform}/config 的请求体。 // Config 保存平台特有键(bot_token、base_url、remote_url、 -// endpointPath……);顶层便捷字段会为常见 token/地址字段 -// 合并进去。AllowGroups/DenyGroups/AllowUsers/DenyUsers 是 +// endpointPath……),**按 patch 语义解释**(见 PatchChannel): +// - 键缺席(本 map 里没有该键,或整个 config 缺席 / 为 null)→ 保留该账号已存值; +// - 键给出非空值 → 覆盖该键; +// - 键显式给出 null 或 "" → 清除该键。 +// +// 顶层便捷字段(token/baseURL/remoteURL/listenAddr/mode/accessToken/selfID)会为 +// 常见 token/地址字段合并进去,且只在**非空**时才合并 —— 它们是普通 string, +// 分不出「缺席」与「显式空串」,因此线上一律以「非空 = 给出」为准;要清除 +// bot_token 之类的键请走 config 里的显式 null/""。 +// AllowGroups/DenyGroups/AllowUsers/DenyUsers 是 // 黑或白名单(design/16 §4.6 §6);*_PerMin/MaxMessages 字段是 // 每账号的流量限制(design/12 §6 策略三/四)。 type channelConfigRequest struct { @@ -84,13 +98,14 @@ type channelConfigRequest struct { } // adminSaveChannelConfig 把某平台一个账号的配置持久化进 -// channels.json(按 id Upsert)并热重载该平台的适配器。 +// channels.json(按 id 打补丁)并热重载该平台的适配器。 // 响应携带 channels.json 路径与账号 id,使面板能 // 定位已保存的账号(供之后编辑/删除)。 // -// personaId 是三态的(见 channelPersonaBinding):面板仅在主人 -// 触碰过角色选择时才发送该键,因此键缺失必须保留 -// 已存绑定,而不是静默解绑该账号。 +// config 与 personaId 一样是三态的(见 runtimeconfig.PatchChannel 与 +// channelPersonaBinding):面板只发送用户本次碰过的键, +// 因此键缺失必须保留已存值,而不是静默丢弃已存凭据或 +// 解绑该账号。 func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request) { platform := strings.TrimSpace(r.PathValue("platform")) if platform == "" { @@ -120,8 +135,9 @@ func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request) } } cfg := channelConfigFromRequest(req) - // weixin-ilink 可以带空配置保存:面板先创建账号, - // 随后用户扫码(get_bot_qrcode),它经 login-status 填入 + // cfg 是**补丁**(只含本次请求显式给出的键),因此这道门禁读作 + // 「本次请求一个平台特有配置键都没给出」。weixin-ilink 可以带空配置保存: + // 面板先创建账号,随后用户扫码(get_bot_qrcode),它经 login-status 填入 // bot_token/bot_id/owner_id(design/08 §4)。此处要求 token 会阻塞该 // 流程,因此把它从「至少一个字段」的门禁中豁免。 if len(cfg) == 0 && platform != "weixin-ilink" { @@ -130,7 +146,9 @@ func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request) return } accounts := s.channels.Load() - updated := runtimeconfig.UpsertChannel(accounts, platform, config.ChannelAccount{ + // patch 语义:未在 cfg 里出现的 config 键保留盘上已存值;cfg 里显式为 + // null/"" 的键被清除。非 config 字段(黑白名单 / 限流 / personaId)仍是整值替换。 + updated := runtimeconfig.PatchChannel(accounts, platform, config.ChannelAccount{ ID: strings.TrimSpace(req.ID), Config: cfg, AllowGroups: req.AllowGroups, @@ -210,6 +228,11 @@ func (s *Server) adminDeleteChannelAccount(w http.ResponseWriter, r *http.Reques // map,优先采用面板已显式设置的键。它绝不注入面板 // 未给出的键(空 token 保持缺位,这样用的是 // 适配器的零值,而不是空字符串 token)。 +// +// 返回的是**补丁**:只含本次请求给出的键,调用方必须 +// 交给 runtimeconfig.PatchChannel,不得当作完整配置整条替换 —— +// 后者的三态(缺席 / 给出 / null 或 "")正是「未被面板携带的 +// 键不会被抹掉」所依赖的。 func channelConfigFromRequest(req channelConfigRequest) map[string]any { cfg := make(map[string]any, len(req.Config)+8) for k, v := range req.Config {