Files
Cyrene_v2/internal/services/gateway/admin_channels_weixin.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

335 lines
11 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
// 本文件新增 weixin-iLink 扫码登录管理端点(design/08 §4),
// 仅 owner 且带审计。面板先触发二维码绑定(login-qr),再轮询
// 绑定状态(login-status);确认后 handler 把新获得的
// bot_token / bot_id 持久化进 channels.json 并热重载该平台,
// 使 bridge 连接到这个刚配置好的适配器 —— 无需重启。
//
// token 归适配器所有:绝不记录日志,且返回给面板时
// 只做脱敏(**** + 末 4 位)。身份始终落在适配器的 actor 元数据上,
// 绝不是提示词。
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"reflect"
"strings"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"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"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
)
// weixinLoginAdmin 是网关面板端点所调用的 weixin-ilink 适配器扫码
// 登录表面的子集。它由 *weixin_ilink.Adapter 实现,
// 通过拆开注册包装器抵达(见 asWeixinLoginAdmin)。
type weixinLoginAdmin interface {
StartLogin(ctx context.Context) (weixin_ilink.LoginQR, error)
LoginStatus(ctx context.Context) (status, detail string)
Config() map[string]any
}
// weixinLoginQRResponse 是 GET /admin/channels/{platform}/login-qr 的线上
// 结构。qrUrl 是微信二维码图片 URL(qrcode_img_content),qrCode 是原始扫码
// 标识(qrcode);前端从 qrUrl 渲染出可扫的二维码,因为
// liteapp URL 是一个 HTML 页面,无法当作图片拉取。
type weixinLoginQRResponse struct {
QrUrl string `json:"qrUrl,omitempty"`
QrCode string `json:"qrCode,omitempty"`
Status string `json:"status"`
}
// weixinLoginStatusResponse 是 GET /admin/channels/{platform}/login-status 的线上结构。
type weixinLoginStatusResponse struct {
Status string `json:"status"`
BotID string `json:"botId,omitempty"`
BotToken string `json:"botToken,omitempty"`
Err string `json:"err,omitempty"`
}
// adminWeixinLoginQR 为 weixin-ilink 账号触发(或返回进行中的)二维码。
// 它是面板的「扫码登录」入口;绝不会阻塞于用户的
// 扫码 —— LoginStatus 由另行轮询获得。
func (s *Server) adminWeixinLoginQR(w http.ResponseWriter, r *http.Request) {
platform := r.PathValue("platform")
accountID := r.URL.Query().Get("accountId")
wx, found := s.weixinTarget(r, w, "channel.weixin.login.qr", platform, accountID)
if !found {
return
}
qr, err := wx.StartLogin(r.Context())
if err != nil {
if errors.Is(err, weixin_ilink.ErrAlreadyLoggedIn) {
s.auditClaims(r, "channel.weixin.login.qr", "ok")
writeJSON(w, http.StatusOK, weixinLoginQRResponse{Status: weixin_ilink.LoginStatusConfirmed})
return
}
s.auditClaims(r, "channel.weixin.login.qr", "error")
s.log.Error("weixin login qr", "platform", platform, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "weixin_login_failed", "获取登录二维码失败:%s", err.Error()))
return
}
status, _ := wx.LoginStatus(r.Context())
s.auditClaims(r, "channel.weixin.login.qr", "ok")
writeJSON(w, http.StatusOK, weixinLoginQRResponse{
QrUrl: qr.Image,
QrCode: qr.Qrcode,
Status: status,
})
}
// adminWeixinLoginStatus 报告扫码登录状态。确认时它还会
// 把新获得的 token 持久化进 channels.json 并热重载该平台,
// 使 bridge 连接到这个刚配置好的适配器。
func (s *Server) adminWeixinLoginStatus(w http.ResponseWriter, r *http.Request) {
platform := r.PathValue("platform")
accountID := r.URL.Query().Get("accountId")
wx, found := s.weixinTarget(r, w, "channel.weixin.login.status", platform, accountID)
if !found {
return
}
status, detail := wx.LoginStatus(r.Context())
resp := weixinLoginStatusResponse{Status: status}
if status == weixin_ilink.LoginStatusConfirmed {
cfg := wx.Config()
token, _ := cfg["bot_token"].(string)
if token != "" {
botID, _ := cfg["bot_id"].(string)
ownerID, _ := cfg["owner_id"].(string)
resp.BotID = botID
resp.BotToken = maskToken(token)
if err := s.persistWeixinConfirmed(r.Context(), platform, accountID, token, botID, ownerID); err != nil {
s.log.Error("weixin login persist", "platform", platform, "err", err)
} else {
s.auditClaims(r, "channel.weixin.login.persist", "ok")
}
}
} else if status == weixin_ilink.LoginStatusFailed {
resp.Err = detail
}
s.auditClaims(r, "channel.weixin.login.status", "ok")
writeJSON(w, http.StatusOK, resp)
}
// persistWeixinConfirmed 把登录获得的凭据写入该账号的
// channels.json 配置(合并到用户精心维护的 base_url/cdn 字段之上,
// 并保留其 allow/deny/limit 列表),再热重载该平台,
// 使 bridge 连接到这个刚配置好的适配器。ownerID 非空时它还会
// 把扫码者绑定的 ilink_user_id 登记进主人白名单
// (owner.yaml),使 bridge 无需手工编辑就认出它是主人。
// 白名单登记是尽力而为:失败会记日志,但不会让
// 已持久化的 channels.json 失败,因为身份红线绝不
// 阻塞一次成功的绑定。
func (s *Server) persistWeixinConfirmed(ctx context.Context, platform, accountID, token, botID, ownerID string) error {
if s.channels == nil {
return fmt.Errorf("weixin login: channel config store not injected")
}
accounts := s.channels.Load()
var base config.ChannelAccount
for _, acct := range accounts[platform] {
if accountID == "" || acct.ID == accountID {
base = acct
break
}
}
if base.ID == "" && accountID != "" {
base.ID = accountID
}
cfg := make(map[string]any, len(base.Config)+3)
for k, v := range base.Config {
cfg[k] = v
}
cfg["bot_token"] = token
if botID != "" {
cfg["bot_id"] = botID
}
if ownerID != "" {
cfg["owner_id"] = ownerID
}
// 确保绑定用户(扫码者)落入该渠道的允许列表,
// 使 bridge 认出它是主人并回复,而不是把它当作
// 陌生人(actor_role=unknown / env=stranger)静默掉。
allowUsers := append([]string(nil), base.AllowUsers...)
if ownerID != "" {
found := false
for _, u := range allowUsers {
if u == ownerID {
found = true
break
}
}
if !found {
allowUsers = append(allowUsers, ownerID)
}
}
acct := config.ChannelAccount{
ID: base.ID,
Config: cfg,
AllowGroups: base.AllowGroups,
DenyGroups: base.DenyGroups,
AllowUsers: allowUsers,
DenyUsers: base.DenyUsers,
GroupMsgPerMin: base.GroupMsgPerMin,
MsgPerMin: base.MsgPerMin,
MaxMessages: base.MaxMessages,
}
if acct.ID == "" {
acct.ID = runtimeconfig.NewID()
}
updated := runtimeconfig.UpsertChannel(accounts, platform, acct)
if err := runtimeconfig.SaveChannels(s.channels.Path(), updated); err != nil {
return err
}
if err := s.channels.Reload(platform); err != nil {
return err
}
// 把扫码者(被绑定的主人)盖进主人白名单,使 bridge
// 从结构上认出它。尽力而为:绝不因为白名单写入打嗝
// 就让已经成功的绑定失败 —— 改为用一行日志
// 提醒运维。
if ownerID != "" && s.ownerRegistrar != nil {
if err := s.ownerRegistrar.RegisterChannelOwner(ctx, platform, ownerID); err != nil {
s.log.Error("weixin login owner register", "platform", platform, "err", err)
}
}
return nil
}
// weixinAccountFor 解析为某平台/账号注册的 *weixin_ilink.Adapter。
// 对未知账号,或未暴露登录表面的包装适配器,
// 它返回 nil。
func weixinAccountFor(caps CapabilityStore, platform, accountID string) (weixinLoginAdmin, bool) {
if caps == nil {
return nil, false
}
// weixin 适配器可能以裸 "weixin-ilink" 名注册(单账号),
// 而非 "weixin-ilink:<acct>",因此先尝试复合匹配,
// 当调用方指定了具体账号、但没有复合名承载它时,回退到
// 裸 weixin 适配器(面板传的就是配置里的账号 id)。
var bare weixinLoginAdmin
var bareFound bool
for _, cap := range caps.List(capreg.KindChannel) {
ch, ok := cap.Value.(sdk.Channel)
if !ok {
continue
}
plat, acct := splitAdapterName(ch.Name())
if plat != platform {
continue
}
w, ok := asWeixinLoginAdmin(cap.Value)
if !ok {
continue
}
if accountID == "" || acct == accountID {
return w, true
}
if acct == "" {
bare, bareFound = w, true
}
}
if bareFound {
return bare, true
}
return nil, false
}
// asWeixinLoginAdmin 把已注册的渠道值拆到其内层 weixin-ilink
// 适配器,仿照 asKookAdmin:注册时会把适配器包进 cmd/cyrene 的
// namedAdapter(一个内嵌 adapter.PlatformAdapter 的未导出结构体);
// 反射读取被内嵌字段并类型断言到登录表面。直接的
// *weixin_ilink.Adapter 值(测试)也被接受。
func asWeixinLoginAdmin(v any) (weixinLoginAdmin, bool) {
if w, ok := v.(weixinLoginAdmin); ok {
return w, true
}
rv := reflect.ValueOf(v)
if !rv.IsValid() {
return nil, false
}
if rv.Kind() == reflect.Ptr {
rv = rv.Elem()
}
if rv.Kind() != reflect.Struct {
return nil, false
}
f := rv.FieldByName("PlatformAdapter")
if !f.IsValid() || !f.CanInterface() {
return nil, false
}
if w, ok := f.Interface().(weixinLoginAdmin); ok {
return w, true
}
return nil, false
}
// weixinTarget 解析某平台/账号的 weixin-ilink 适配器;无法解析时
// 写出诚实的错误并报告 found=false。
func (s *Server) weixinTarget(r *http.Request, w http.ResponseWriter, what, platform, accountID string) (weixinLoginAdmin, bool) {
platform = strings.TrimSpace(platform)
if channelBasePlatform(platform) != "weixin-ilink" {
s.auditClaims(r, what, "error")
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "仅微信 iLink 平台提供扫码登录端点", HTTPCode: http.StatusNotImplemented})
return nil, false
}
wx, found := weixinAccountFor(s.caps, "weixin-ilink", accountID)
if !found {
s.auditClaims(r, what, "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found",
"weixin-ilink 账号 %q 未找到(请先在「渠道配置」保存一个 weixin-ilink 账号)", accountID))
return nil, false
}
return wx, true
}
// maskToken 把秘密脱敏成 "****" + 末 4 个字符,与项目的
// maskToken 一致,使 token 绝不原样落在线上响应里。
func maskToken(s string) string {
if len(s) <= 4 {
return "****"
}
return "****" + s[len(s)-4:]
}
// qrImageDataURL 把二维码图片的原始内容转成可内嵌的 data URL。
// 内容是 http(s) URL 时,它拉取并 base64 编码(尽力而为);
// 任何失败都返回 "",使调用方保留 qrUrl。已带
// data: 前缀的内容原样透传;其他一律当作原始 base64。
func qrImageDataURL(ctx context.Context, raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if strings.HasPrefix(raw, "data:") {
return raw
}
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
return ""
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return ""
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil || len(body) == 0 {
return ""
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(body)
}
return "data:image/png;base64," + raw
}