Files
AskaEth 7505c46b70 feat(channel): 微信 iLink 账号状态三态(诚实口径)
state ∈ 有效 / **被拒(需重扫)** / 未知;🔴 -14 是**被拒不是过期**,且**未知原因绝不
写成「过期」**(面板文案与错误码都如实)。三态判定为纯函数 + 三态各有用例。

⚠️ 未做(如实登记): 三态(每账号逐条三态)本片未做。
2026-10-06 09:03:44 +08:00

390 lines
15 KiB
Go
Raw Permalink 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 的线上结构。
//
// 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:<acct>",因此先尝试复合匹配,当调用方指定了具体账号、但没有复合名
// 承载它时,回退到裸 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
}