Files
Cyrene_v2/internal/services/gateway/admin_write_channel_config.go
T
Aska abb137cfe2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(weixin): 面板空token保存微信iLink放行——为扫码登录先建账号
adminSaveChannelConfig 的 len(cfg)==0 校验要求至少 token/baseUrl/address, 但微信扫码登录
需先建空token账号(再扫码回填 token/bot_id/owner_id)。对 weixin-ilink 豁免该校验,允许空配置
账号保存; 其余平台保持必填。PUT 空token 保存已验证 201。build/test 全绿, 已部署.
2026-09-07 20:58:48 +08:00

283 lines
11 KiB
Go

package gateway
// Admin channel-configuration writes (owner-only, audited). The panel saves a
// platform's account config (token/address/rate limit/black-or-white lists) to
// channels.json and hot-reloads that platform's adapters — no restart required
// (design/04 §3.1). The GET /admin/channels read path masks every token so a
// secret is never returned to the client; this write path persists the real
// token to the gitignored file and triggers the bridge reload.
//
// The request carries a generic config map (platform-specific keys such as
// bot_token / base_url / remote_url / endpointPath) plus convenience top-level
// fields for the common token/address fields, so the panel can send either the
// config object or the shortcut fields.
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 is the wire shape of one configured channel account in the
// admin list. Config values are masked (no raw token) so nothing sensitive is
// returned to the client.
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"`
// BotID/BotUsername/BotNickname are the connected bot's resolved identity
// (kook fetches it from /api/v3/user/me on connect). They come from the live
// adapter, not the persisted channels.json config, and are non-sensitive (the
// bot's public handle/display name), so the panel may show them.
BotID string `json:"botId,omitempty"`
BotUsername string `json:"botUsername,omitempty"`
BotNickname string `json:"botNickname,omitempty"`
// Capabilities lists the account-level admin operations the platform's
// adapter exposes (friends/channels/user-chats/message-test) so the panel
// renders the right per-account cards. It is omitted for platforms without a
// specialized adapter surface.
Capabilities []string `json:"capabilities,omitempty"`
}
// channelConfigRequest is the PUT /api/v1/admin/channels/{platform}/config body.
// Config holds platform-specific keys (bot_token, base_url, remote_url,
// endpointPath, ...); the top-level convenience fields are merged into it for the
// common token/address fields. AllowGroups/DenyGroups/AllowUsers/DenyUsers are the
// black-or-white lists (design/16 §4.6 §6); the *_PerMin/MaxMessages fields are
// the per-account flow limits (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"`
}
// 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).
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
}
cfg := channelConfigFromRequest(req)
// weixin-ilink may be saved with an empty config: the panel creates the account
// first, then the user scans the QR (get_bot_qrcode) which fills bot_token/bot_id/
// owner_id via login-status (design/08 §4). Requiring a token here would block that
// flow, so exempt it from the "at least one field" gate.
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,
})
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 removes one account (by the id query param) from a
// platform in channels.json and hot-reloads that platform so the adapter stops.
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 merges the request's convenience fields into the
// platform-specific config map, preferring keys the panel already set explicitly.
// It never injects a key the panel left out (an empty token is left absent so the
// adapter's zero value, not an empty-string token, is used).
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
}
// 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).
func savedAccountID(list []config.ChannelAccount, supplied string) string {
if supplied != "" {
return supplied
}
if len(list) == 0 {
return ""
}
return list[len(list)-1].ID
}
// adminChannelAccountOf maps a (masked) account to its admin wire shape.
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,
}
}
// channelBasePlatform returns the bare platform of a (possibly multi-account)
// adapter name like "telegram:conf0" → "telegram", so config lookup groups every
// account of a platform together.
func channelBasePlatform(name string) string {
if base, _, ok := strings.Cut(name, ":"); ok {
return base
}
return name
}
// configuredAccount reports whether an account carries a meaningful config (a
// panel-created id or a real token/address), so the admin list skips the blank
// placeholder accounts the env fallback synthesizes for unconfigured platforms.
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
}