fix(admin): 渠道 personaId 改为三态语义(省略=保留 / ""=解绑 / id=绑定)——修「保存其它配置即静默清空绑定」
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

跨端语义相反:后端 5969bba 实现的是「省略/\"\"=解绑」,而管理面板只在用户改动下拉时才发该键 ⇒ owner 打开已绑账号、不动下拉、保存其它配置就会静默清空渠道角色绑定。
- 请求 DTO 改 *string 区分「省略」与「显式空串」:省略 → 保留现状;显式 "" → 解绑;非空 id → 绑定(未知角色 400);沿用读-改-写 + bridge.Reload 热更新 + 审计
- GET /admin/channels 继续回显 account.personaId(已确认)
- Docs/api-client 三态语义写清(省略=保留是本次的关键修正)
- 测试:省略保留 / 显式空串解绑 / id 绑定 / 未知 id 400 + 变异验证(把省略改回解绑 → 断言 FAIL)
This commit is contained in:
AskaEth committed 2026-10-03 21:06:22 +08:00
1 parent 5969bba3bd
commit f9430dee81
3 files changed
+236 -52

No files matched your search

@@ -75,17 +75,22 @@ type channelConfigRequest struct {
GroupMsgPerMin int `json:"groupMsgPerMin"`
MsgPerMin int `json:"msgPerMin"`
MaxMessages int `json:"maxMessages"`
// PersonaID is the role this account answers as (design/22 §2 方案 b). An empty
// value leaves the account unbound (the session binding or the global default
// applies); an unknown id is refused with 400 so a save can never bind a role
// that cannot answer.
PersonaID string `json:"personaId"`
// PersonaID is the role this account answers as (design/22 §2 方案 b). It is a
// pointer because the wire contract is three-state and a plain string cannot
// tell an absent key from an explicit "": absent keeps the stored binding,
// "" clears it, a non-empty id binds it. An unknown id is refused with 400 so a
// save can never bind a role that cannot answer.
PersonaID *string `json:"personaId"`
}
// adminSaveChannelConfig persists one account's config for a platform into
// channels.json (Upsert by id) and hot-reloads that platform's adapters. The
// response carries the channels.json path and the account id so the panel can
// address the saved account (for a later edit/delete).
//
// personaId is three-state (see channelPersonaBinding): the panel only sends the
// key when the owner touched the role select, so an absent key must keep the
// stored binding rather than silently unbind the account.
func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request) {
platform := strings.TrimSpace(r.PathValue("platform"))
if platform == "" {
@@ -104,11 +109,15 @@ func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request)
return
}
// A channel-level role must exist in the persona library, or the account would
// be bound to a role that cannot answer (design/22 §2 方案 b). An omitted/empty
// personaId is always accepted: it means "no channel binding".
if !s.requireKnownPersona(w, req.PersonaID) {
s.auditClaims(r, "channel.save", "error")
return
// be bound to a role that cannot answer (design/22 §2 方案 b). Only a value the
// panel actually sent is validated; an absent key (nil) inherits the stored
// binding, which was validated when it was written. An explicit "" is always
// accepted: it means "clear the channel binding".
if req.PersonaID != nil {
if !s.requireKnownPersona(w, strings.TrimSpace(*req.PersonaID)) {
s.auditClaims(r, "channel.save", "error")
return
}
}
cfg := channelConfigFromRequest(req)
// weixin-ilink may be saved with an empty config: the panel creates the account
@@ -131,7 +140,7 @@ func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request)
GroupMsgPerMin: req.GroupMsgPerMin,
MsgPerMin: req.MsgPerMin,
MaxMessages: req.MaxMessages,
PersonaID: strings.TrimSpace(req.PersonaID),
PersonaID: channelPersonaBinding(req.PersonaID, existingChannelAccount(accounts[platform], req.ID)),
})
if err := runtimeconfig.SaveChannels(s.channels.Path(), updated); err != nil {
s.auditClaims(r, "channel.save", "error")
@@ -244,6 +253,41 @@ func channelConfigFromRequest(req channelConfigRequest) map[string]any {
return cfg
}
// channelPersonaBinding resolves the three-state personaId contract of
// PUT /admin/channels/{platform}/config:
//
// - the key is absent (nil) → keep the account's stored binding;
// - the key is an explicit "" → clear the binding (an explicit unbind);
// - the key is a non-empty id → bind that role.
//
// The panel omits the key unless the owner touched the role select, so resolving
// an absent key from the stored account is what keeps a plain config edit from
// silently dropping a binding. A save that creates a new account (no stored
// account to inherit from) resolves to "no binding".
func channelPersonaBinding(sent *string, stored config.ChannelAccount) string {
if sent == nil {
return strings.TrimSpace(stored.PersonaID)
}
return strings.TrimSpace(*sent)
}
// existingChannelAccount returns the stored account a save would replace (same
// platform, same id) so the write can inherit the fields the panel did not send.
// An empty id means the save creates a new account, so there is nothing to
// inherit.
func existingChannelAccount(list []config.ChannelAccount, id string) config.ChannelAccount {
id = strings.TrimSpace(id)
if id == "" {
return config.ChannelAccount{}
}
for _, acct := range list {
if acct.ID == id {
return acct
}
}
return config.ChannelAccount{}
}
// savedAccountID returns the account id that a save produced — the caller-supplied
// one when present, otherwise the id runtimeconfig.Upsert generated (which it
// appends last for a brand-new account).