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 的线上结构。 // // Status 是**扫码登录流程**的状态机(idle/waiting/confirmed/failed/need_verifycode); // AuthState/AuthDetail 是**会话凭据**的三态与说明("active" / "rejected" / "unknown"), // 两者是两件事:一次 confirmed 的扫码只说明「拿到了凭据」,凭据是否被 iLink 接受要等 // 一次成功的调用(那时 AuthState 才从 unknown 变成 active)。 // // 🔴 errcode=-14 是「被拒」不是「过期」:AuthState=rejected 的说明只说被拒与需要重新 // 扫码,绝不写「过期」;尚未确认(unknown)同样绝不被写成「过期」,也不写成「未连接」。 // 适配器没暴露该能力(gateway 的 sessionStatusAware)时这两个键**缺席** —— 缺席是 // 「该账号没有这条状态面」,与 AuthState=unknown(有状态面但尚未确认)不是同一件事。 type weixinLoginStatusResponse struct { Status string `json:"status"` BotID string `json:"botId,omitempty"` BotToken string `json:"botToken,omitempty"` Err string `json:"err,omitempty"` AuthState string `json:"authState,omitempty"` AuthDetail string `json:"authDetail,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} // 会话凭据三态(有效 / 被拒 / 未知)与**扫码登录流程**的状态各自独立:一次 // 扫码确认只说明拿到了凭据,凭据是否被 iLink 接受由适配器侧的证据(一次成功的 // 调用)决定。适配器没有该能力时这两个键保持缺席。 if ss, ok := s.weixinSessionStatus(accountID); ok { resp.AuthState, resp.AuthDetail = ss.SessionStatus() } 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 列表 —— 含账号顶层的 directMsgPerMin, // 主人配的「关闭流控(-1)」因而不会被一次扫码保存重置),再热重载该平台, // 使 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, DirectMsgPerMin: base.DirectMsgPerMin, } 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 } // weixinRawValueFor 返回为该平台/账号注册、且**满足 usable** 的已注册值本身(未拆包), // 于是各条可选能力(扫码登录面、会话凭据三态……)可以各自对它做类型断言,而账号匹配 // 规则只写一遍。weixin 适配器可能以裸 "weixin-ilink" 名注册(单账号),而非 // "weixin-ilink:",因此先尝试复合匹配,当调用方指定了具体账号、但没有复合名 // 承载它时,回退到裸 weixin 适配器(面板传的就是配置里的账号 id)。 // // usable 先把「不暴露该能力的注册值」滤掉再去匹配账号:这样每条能力的匹配口径与 // 重构前各自独立查找时**逐字一致**(否则一个混装了多种适配器的注册表里,首个匹配到 // 却缺该能力的值会把后面真正可用的那个挡掉)。 func weixinRawValueFor(caps CapabilityStore, platform, accountID string, usable func(any) bool) (any, bool) { if caps == nil { return nil, false } var bare any 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 } if !usable(cap.Value) { continue } if accountID == "" || acct == accountID { return cap.Value, true } if acct == "" { bare, bareFound = cap.Value, true } } if bareFound { return bare, true } return nil, false } // weixinLoginUsable 报告该注册值是否暴露扫码登录面。 func weixinLoginUsable(v any) bool { _, ok := asWeixinLoginAdmin(v) return ok } // weixinAccountFor 解析为某平台/账号注册的 *weixin_ilink.Adapter。 // 对未知账号,或未暴露登录表面的包装适配器, // 它返回 nil。 func weixinAccountFor(caps CapabilityStore, platform, accountID string) (weixinLoginAdmin, bool) { v, ok := weixinRawValueFor(caps, platform, accountID, weixinLoginUsable) if !ok { return nil, false } return asWeixinLoginAdmin(v) } // weixinSessionUsable 报告该注册值是否暴露会话凭据三态。 func weixinSessionUsable(v any) bool { _, ok := asSessionStatusAware(v) return ok } // weixinSessionStatus 读取该账号适配器的**会话凭据三态**能力。适配器没暴露该能力 // (或账号未注册)时 found=false,调用方随后让两个键**缺席** —— 缺席 = 该账号没有 // 这条状态面,与 AuthState="unknown"(有状态面但尚未确认)是两件事。 func (s *Server) weixinSessionStatus(accountID string) (sessionStatusAware, bool) { v, ok := weixinRawValueFor(s.caps, "weixin-ilink", accountID, weixinSessionUsable) if !ok { return nil, false } return asSessionStatusAware(v) } // 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 }