package gateway // 管理渠道配置写操作(仅 owner,带审计)。面板把某平台的 // 账号配置(token/地址/速率限制/黑或白名单)保存到 // channels.json 并热重载该平台的适配器 —— 无需重启 // (design/04 §3.1)。GET /admin/channels 读路径会对每个 token // 脱敏,因此秘密绝不返回给客户端;这条写路径把真实 // token 持久化进被 gitignore 的文件并触发 bridge 重载。 // // 请求携带一个通用 config map(平台特有键,如 // 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" "net/http" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" ) // AdminChannelAccount 是管理列表中一个已配置渠道账号的线上 // 结构。Config 的值已脱敏(不含原始 token),因此没有敏感 // 内容返回给客户端。 type AdminChannelAccount struct { ID string `json:"id"` Config map[string]any `json:"config,omitempty"` AllowGroups []string `json:"allowGroups,omitempty"` DenyGroups []string `json:"denyGroups,omitempty"` AllowUsers []string `json:"allowUsers,omitempty"` DenyUsers []string `json:"denyUsers,omitempty"` GroupMsgPerMin int `json:"groupMsgPerMin,omitempty"` MsgPerMin int `json:"msgPerMin,omitempty"` MaxMessages int `json:"maxMessages,omitempty"` // DirectMsgPerMin 是**非主人私聊(陌生人)每分钟每发送者上限** // (design/12 §6 策略三;主人 2026-10-05 裁决③与裁决 13)。它是账号 // **顶层**字段(与 GroupMsgPerMin 同一条写路径,**不在 config 里**), // 语义与 `channelbridge.directLimit` 逐字同口径:0 或未设置为保护性 // 默认 6 条/分、正值固定为该值、**负值 = 关闭该限制**。面板必须能把它 // 原样回显,否则「已关闭」会被静默显示成「未设置」。 DirectMsgPerMin int `json:"directMsgPerMin,omitempty"` // PersonaID 是该账号应答所用的角色(design/22 §2 方案 b),账号 // 未绑定任何角色时省略。它是人格 id,不是秘密。 PersonaID string `json:"personaId,omitempty"` // BotID/BotUsername/BotNickname 是已连接机器人的实际解析身份 // (kook 在 connect 时从 /api/v3/user/me 获取)。它们来自活的 // 适配器,而不是持久化的 channels.json 配置,且非敏感(机器人 // 的公开 handle/显示名),因此面板可以展示它们。 BotID string `json:"botId,omitempty"` BotUsername string `json:"botUsername,omitempty"` BotNickname string `json:"botNickname,omitempty"` // Connected 是该账号**实时适配器**的连接状态(面板逐账号渲染连接/断开的依据)。 // nil(字段缺省)表示「当前没有该账号的已注册适配器」—— 状态**未知**,面板不得 // 画成「未连接」,也不得允许断开一条它根本没连上的账号。 Connected *bool `json:"connected,omitempty"` // AccountOnline 是该账号**在平台侧**是否在线(独立状态面,当前仅 qq-obv11 经 // OneBot `get_status` 探活提供),与 Connected(本项目与平台的会话是否连着) // **是两件事**:OneBot 实现的进程与正向 WS 可以一直连着,而背后的 QQ 账号已掉线 —— // 此时 Connected=true 而 AccountOnline=false,消息既收不到也发不出(2026-10-05 // 事故:面板一片绿)。nil(字段缺省)= **状态未知**(该适配器没有独立状态面, // 或尚未探到结果),面板不得画成「离线」,也不得画成「在线」。 // 见 design/24 §10.11 与 Docs/api-client.md §2.4(b)。 AccountOnline *bool `json:"accountOnline,omitempty"` // Pending/Ready/Reason 是该账号适配器自报的**连接态三态**(可选能力,见 // internal/services/gateway/admin_channel_connect_outcome.go):Connected=false 时 // 面板据此把「已在后台重试(连接中)」与「已就绪、等待对端接入」从「未连接」里 // 分出来。三者缺省 = 该适配器没有这条状态面 ⇒ 面板退回只看 Connected, // 绝不凭空编一个状态。 Pending bool `json:"pending,omitempty"` Ready bool `json:"ready,omitempty"` Reason string `json:"reason,omitempty"` // Capabilities 列出该平台适配器暴露的账号级管理操作 // (friends/channels/user-chats/message-test),使面板 // 渲染正确的每账号卡片。没有专用适配器表面的平台 // 会省略它。 Capabilities []string `json:"capabilities,omitempty"` } // channelConfigRequest 是 PUT /api/v1/admin/channels/{platform}/config 的请求体。 // Config 保存平台特有键(bot_token、base_url、remote_url、 // 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 { ID string `json:"id"` Token string `json:"token"` BaseURL string `json:"baseURL"` RemoteURL string `json:"remoteURL"` ListenAddr string `json:"listenAddr"` Mode string `json:"mode"` AccessToken string `json:"accessToken"` SelfID string `json:"selfID"` Config map[string]any `json:"config"` AllowGroups []string `json:"allowGroups"` DenyGroups []string `json:"denyGroups"` AllowUsers []string `json:"allowUsers"` DenyUsers []string `json:"denyUsers"` GroupMsgPerMin int `json:"groupMsgPerMin"` MsgPerMin int `json:"msgPerMin"` MaxMessages int `json:"maxMessages"` // DirectMsgPerMin 是账号顶层的陌生人私聊每分钟每发送者上限,与 // GroupMsgPerMin 同一口径的**值语义**(对非 config 字段是整值替换): // 缺失或 0 → 保护性默认 6 条/分,正值 → 该值,**负值 = 关闭该限制**。 // 它必须原样落到 config.ChannelAccount,绝不能被吞成 0 —— 否则主人显式 // 关掉的限制会被一次面板保存静默重置回默认 6(见 adminSaveChannelConfig)。 DirectMsgPerMin int `json:"directMsgPerMin"` // PersonaID 是该账号应答所用的角色(design/22 §2 方案 b)。它是 // 指针,因为线上契约是三态的,而普通 string 无法 // 区分键缺失与显式 "":缺失保留已存绑定, // "" 清除它,非空 id 绑定它。未知 id 以 400 拒绝,因此 // 保存绝不可能绑定一个无法应答的角色。 PersonaID *string `json:"personaId"` } // adminSaveChannelConfig 把某平台一个账号的配置持久化进 // channels.json(按 id 打补丁)并热重载该平台的适配器。 // 响应携带 channels.json 路径与账号 id,使面板能 // 定位已保存的账号(供之后编辑/删除)。 // // config 与 personaId 一样是三态的(见 runtimeconfig.PatchChannel 与 // channelPersonaBinding):面板只发送用户本次碰过的键, // 因此键缺失必须保留已存值,而不是静默丢弃已存凭据或 // 解绑该账号。 func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request) { platform := strings.TrimSpace(r.PathValue("platform")) if platform == "" { s.auditClaims(r, "channel.save", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "platform is required")) return } var req channelConfigRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) return } if s.channels == nil { s.auditClaims(r, "channel.save", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "channels_unavailable", "渠道配置服务未注入")) return } // 渠道级角色必须存在于人格库中,否则账号会被绑定到 // 一个无法应答的角色(design/22 §2 方案 b)。只校验面板确实 // 发送的值;键缺失(nil)继承已存绑定,该绑定在 // 写入时就已校验。显式的 "" 一律接受: // 它表示「清除渠道绑定」。 if req.PersonaID != nil { if !s.requireKnownPersona(w, strings.TrimSpace(*req.PersonaID)) { s.auditClaims(r, "channel.save", "error") return } } cfg := channelConfigFromRequest(req) // 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" { s.auditClaims(r, "channel.save", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "至少提供 token/baseUrl/address 等配置字段")) return } accounts := s.channels.Load() // patch 语义:未在 cfg 里出现的 config 键保留盘上已存值;cfg 里显式为 // null/"" 的键被清除。非 config 字段(黑白名单 / 限流 / personaId)仍是整值替换。 // DirectMsgPerMin 必须**逐字**落到账号上:它就是面板「关闭流控(-1)」的 // 保存路径,吞成 0 等于把主人显式关掉的限制静默重置回保护性默认 6 条/分。 updated := runtimeconfig.PatchChannel(accounts, platform, config.ChannelAccount{ ID: strings.TrimSpace(req.ID), Config: cfg, AllowGroups: req.AllowGroups, DenyGroups: req.DenyGroups, AllowUsers: req.AllowUsers, DenyUsers: req.DenyUsers, GroupMsgPerMin: req.GroupMsgPerMin, MsgPerMin: req.MsgPerMin, MaxMessages: req.MaxMessages, DirectMsgPerMin: req.DirectMsgPerMin, 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") s.log.Error("channel save channels.json", "path", s.channels.Path(), "error", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_persist_failed", "failed to save channels.json")) return } if err := s.channels.Reload(platform); err != nil { s.auditClaims(r, "channel.save", "error") s.log.Error("channel reload", "platform", platform, "error", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_reload_failed", "saved but failed to hot-reload")) return } s.auditClaims(r, "channel.save", "ok") writeJSON(w, http.StatusCreated, map[string]any{ "persisted": true, "path": s.channels.Path(), "platform": platform, "accountId": savedAccountID(updated[platform], req.ID), }) } // adminDeleteChannelAccount 从某平台移除账号并热重载该平台。两种寻址方式, // **都必须显式**给出: // // - `?id=<账号 id>` → 删除该 id 的那一条(id 非空); // - `?empty=1` → 删除该平台上**全部无 id 的历史条目**(面板根本改不动它们: // 带空 id 保存会被 runtimeconfig.UpsertChannel 当成新账号另开一条,越改越多)。 // // 绝不允许把「id 缺失/为空」默认解释成「删掉无 id 条目」:那是一次没有字面证据的 // 批量删除,误删无从追溯。两个参数同时给出也一律 400(一次请求只针对一种条目)。 func (s *Server) adminDeleteChannelAccount(w http.ResponseWriter, r *http.Request) { platform := strings.TrimSpace(r.PathValue("platform")) query := r.URL.Query() id := strings.TrimSpace(query.Get("id")) empty := queryFlag(query.Get("empty")) if platform == "" { s.auditClaims(r, "channel.delete", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "platform is required")) return } if id != "" && empty { s.auditClaims(r, "channel.delete", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id 与 empty=1 不能同时给出:一次请求只删除一种条目")) return } if id == "" && !empty { s.auditClaims(r, "channel.delete", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id is required(要删除无 id 的历史条目请显式传 ?empty=1)")) return } if s.channels == nil { s.auditClaims(r, "channel.delete", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "channels_unavailable", "渠道配置服务未注入")) return } accounts := s.channels.Load() updated := accounts removed := 1 if empty { updated, removed = deleteEmptyIDAccounts(accounts, platform) if removed == 0 { // 没有可删的空 id 条目:不写文件、不重载,明确告诉调用方「这里没有它要找的东西」, // 而不是回一个什么都没做的 200。 s.auditClaims(r, "channel.delete", "error") writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "platform %q has no id-less account to delete", platform)) return } } else { updated = runtimeconfig.DeleteChannel(accounts, platform, id) removed = 1 } if err := runtimeconfig.SaveChannels(s.channels.Path(), updated); err != nil { s.auditClaims(r, "channel.delete", "error") s.log.Error("channel delete channels.json", "path", s.channels.Path(), "error", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_persist_failed", "failed to save channels.json")) return } if err := s.channels.Reload(platform); err != nil { s.auditClaims(r, "channel.delete", "error") s.log.Error("channel reload", "platform", platform, "error", err) writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_reload_failed", "saved but failed to hot-reload")) return } s.auditClaimsAccount(r, "channel.delete", "ok", id) s.log.Info("channel account deleted", "platform", platform, "accountId", id, "empty", empty, "removed", removed) writeJSON(w, http.StatusOK, map[string]any{ "persisted": true, "path": s.channels.Path(), "platform": platform, "accountId": id, "empty": empty, "removed": removed, }) } // deleteEmptyIDAccounts 移除某平台上**全部无 id 条目**,返回剩余账号与删除条数。 // 空 id 条目之间没有任何可寻址的区别(没有 id 可指名),因此「只删某一条」在这类 // 条目上没有可实现的语义;面板把这动作标成「删除无 id 条目」并展示实际条数, // 删除范围因此对调用方是完全可见的。平台再无账号时整个平台键被移除,与 // runtimeconfig.DeleteChannel 的收尾口径一致。 func deleteEmptyIDAccounts(accounts map[string][]config.ChannelAccount, platform string) (map[string][]config.ChannelAccount, int) { if accounts == nil { return map[string][]config.ChannelAccount{}, 0 } kept := make([]config.ChannelAccount, 0, len(accounts[platform])) removed := 0 for _, cur := range accounts[platform] { if strings.TrimSpace(cur.ID) == "" { removed++ continue } kept = append(kept, cur) } if removed == 0 { return accounts, 0 } if len(kept) == 0 { delete(accounts, platform) } else { accounts[platform] = kept } return accounts, removed } // queryFlag 解析显式的布尔查询参数:只有字面表示「是」的取值才算给出 // (1/true/yes/on)。空串与其它一切取值都是「否」,因此 `?empty=` / `?empty=0` // 绝不会被读成「删掉无 id 条目」。 func queryFlag(raw string) bool { switch strings.ToLower(strings.TrimSpace(raw)) { case "1", "true", "yes", "on": return true default: return false } } // channelConfigFromRequest 把请求的便捷字段合并进平台特有 config // 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 { cfg[k] = v } if req.Token != "" { if _, ok := cfg["bot_token"]; !ok { cfg["bot_token"] = req.Token } } if req.BaseURL != "" { if _, ok := cfg["base_url"]; !ok { cfg["base_url"] = req.BaseURL } } if req.RemoteURL != "" { if _, ok := cfg["remote_url"]; !ok { cfg["remote_url"] = req.RemoteURL } } if req.ListenAddr != "" { if _, ok := cfg["listen_addr"]; !ok { cfg["listen_addr"] = req.ListenAddr } } if req.Mode != "" { if _, ok := cfg["mode"]; !ok { cfg["mode"] = req.Mode } } if req.AccessToken != "" { if _, ok := cfg["access_token"]; !ok { cfg["access_token"] = req.AccessToken } } if req.SelfID != "" { if _, ok := cfg["self_id"]; !ok { cfg["self_id"] = req.SelfID } } return cfg } // channelPersonaBinding 解析 PUT /admin/channels/{platform}/config 的 // 三态 personaId 契约: // // - 键缺失(nil) → 保留该账号已存的绑定; // - 键是显式 "" → 清除绑定(显式解绑); // - 键是非空 id → 绑定该角色。 // // 除非主人触碰过角色选择,面板会省略该键,因此从已存 // 账号解析缺失键,正是让一次普通的配置编辑不会 // 静默丢弃绑定的原因。创建新账号的保存(没有已存 // 账号可继承)解析为「无绑定」。 func channelPersonaBinding(sent *string, stored config.ChannelAccount) string { if sent == nil { return strings.TrimSpace(stored.PersonaID) } return strings.TrimSpace(*sent) } // existingChannelAccount 返回一次保存将要替换的已存账号(同 // 平台、同 id),使写操作能继承面板未发送的字段。 // 空 id 意味着该保存创建新账号,因此没有 // 可继承的内容。 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 返回一次保存产出的账号 id —— 调用方提供时 // 用它的,否则用 runtimeconfig.Upsert 生成的 id(对全新账号 // 它会追加在末尾)。 func savedAccountID(list []config.ChannelAccount, supplied string) string { if supplied != "" { return supplied } if len(list) == 0 { return "" } return list[len(list)-1].ID } // adminChannelAccountOf 把一个(已脱敏的)账号映射为其管理侧线上结构。 func adminChannelAccountOf(acct config.ChannelAccount) AdminChannelAccount { return AdminChannelAccount{ ID: acct.ID, Config: acct.Config, AllowGroups: acct.AllowGroups, DenyGroups: acct.DenyGroups, AllowUsers: acct.AllowUsers, DenyUsers: acct.DenyUsers, GroupMsgPerMin: acct.GroupMsgPerMin, MsgPerMin: acct.MsgPerMin, MaxMessages: acct.MaxMessages, DirectMsgPerMin: acct.DirectMsgPerMin, PersonaID: acct.PersonaID, } } // channelBasePlatform 返回形如 "telegram:conf0" → "telegram" 的 // (可能多账号的)适配器名的裸平台,使配置查找把某平台的 // 所有账号归到一组。 func channelBasePlatform(name string) string { if base, _, ok := strings.Cut(name, ":"); ok { return base } return name } // configuredAccount 报告某账号是否携带有效配置(面板创建的 // id 或真实 token/地址),使管理列表跳过环境变量回退 // 为未配置平台合成的空白占位账号。 func configuredAccount(a config.ChannelAccount) bool { if strings.TrimSpace(a.ID) != "" { return true } for _, v := range a.Config { if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { return true } } return false }