主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批: 已推库 16 个文件 + 本批剩余文件)。 校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0); 全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
335 lines
11 KiB
Go
335 lines
11 KiB
Go
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
|
||
}
|