Files
Cyrene_v2/internal/services/gateway/admin_write_channel_config.go
T
AskaEth 7aaf7731dc
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway): directMsgPerMin 的写入路径 5 处透传(关掉「面板保存把 -1 静默重置为 0」)
- 缺口:后端解析与面板都已就绪,但 gateway 没让该字段通过 ⇒ 面板保存一次渠道配置就把主人手配的「关闭流控(-1)」重置为 0=保护性默认 6 条/分
- 5 处:AdminChannelAccount DTO / channelConfigRequest / config.ChannelAccount 拷贝 / adminChannelAccountOf 映射 / 微信扫码重建账号
- 语义零改动(只透传);变异实测:删拷贝 ⇒ channels.json 变 0(want -1)、删微信重建拷贝 ⇒ 同红、删 GET 映射 ⇒ 回显缺失
- 顺带登记一条负载诱发 flake(capability/plugin 的 TestManagerInstall_ConnectFailureDoesNotPersist,靠「端口被拒」断言,满载时端口被抢)⇒ 非本切片引入,已进 00-索引 §四
2026-10-05 14:01:52 +08:00

470 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"`
// 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
}