Files
Cyrene_v2/internal/services/gateway/admin_write_channel_config.go
T
AskaEth 074ffc4ebf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
2026-10-03 22:28:25 +08:00

344 lines
13 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 对象,也可发送快捷字段。
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"`
// 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"`
// 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……);顶层便捷字段会为常见 token/地址字段
// 合并进去。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"`
// PersonaID 是该账号应答所用的角色(design/22 §2 方案 b)。它是
// 指针,因为线上契约是三态的,而普通 string 无法
// 区分键缺失与显式 "":缺失保留已存绑定,
// "" 清除它,非空 id 绑定它。未知 id 以 400 拒绝,因此
// 保存绝不可能绑定一个无法应答的角色。
PersonaID *string `json:"personaId"`
}
// adminSaveChannelConfig 把某平台一个账号的配置持久化进
// channels.json(按 id Upsert)并热重载该平台的适配器。
// 响应携带 channels.json 路径与账号 id,使面板能
// 定位已保存的账号(供之后编辑/删除)。
//
// personaId 是三态的(见 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)
// 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()
updated := runtimeconfig.UpsertChannel(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,
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 查询参数从某平台在 channels.json
// 中移除一个账号,并热重载该平台使适配器停止。
func (s *Server) adminDeleteChannelAccount(w http.ResponseWriter, r *http.Request) {
platform := strings.TrimSpace(r.PathValue("platform"))
id := strings.TrimSpace(r.URL.Query().Get("id"))
if platform == "" {
s.auditClaims(r, "channel.delete", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "platform is required"))
return
}
if id == "" {
s.auditClaims(r, "channel.delete", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id is required"))
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 := runtimeconfig.DeleteChannel(accounts, platform, id)
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.auditClaims(r, "channel.delete", "ok")
writeJSON(w, http.StatusOK, map[string]any{
"persisted": true,
"path": s.channels.Path(),
"platform": platform,
"accountId": id,
})
}
// channelConfigFromRequest 把请求的便捷字段合并进平台特有 config
// map,优先采用面板已显式设置的键。它绝不注入面板
// 未给出的键(空 token 保持缺位,这样用的是
// 适配器的零值,而不是空字符串 token)。
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,
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
}