weixin qrcode_img_content 是微信liteapp HTML页(非图片),后端fetch转base64只得<!doctype>→前端img坏图。
- 后端 login-qr 改返回 qrUrl(qrcode_img_content URL)+qrCode(原始qrcode),去掉坏 qrImage
- 前端用 qrcode 库从 qrUrl 生成真实二维码图片(微信扫https链接即开连接页), 新增 qrcode+@types/qrcode(纯UI依赖)
- DecodeConfig: nil config map 当空map解码(不报错), 否则 pending账号(空config)构建被跳过→未注册→登录端点404
- 测试更新(DecodeConfigNil/admin QR端点断言 qrUrl+qrCode)
- 另有 weixinAccountFor 裸weixin兜底(此前提交 d3b0921) 保证 accountId 命中
- 实测: login-qr 200{waiting, qrUrl=89}, 前端能看到可扫二维码
build/vet/test 全绿, 已部署.
302 lines
10 KiB
Go
302 lines
10 KiB
Go
package gateway
|
|
|
|
// This file adds the weixin-iLink scan-login admin endpoints (design/08 §4),
|
|
// owner only and audited. The panel triggers a QR bind (login-qr), then polls the
|
|
// bind status (login-status); on confirmation the handler persists the newly
|
|
// obtained bot_token / bot_id into channels.json and hot-reloads the platform so
|
|
// the bridge connects the now-configured adapter — no restart required.
|
|
//
|
|
// The adapter owns the token: it is never logged, and it is returned to the panel
|
|
// only masked (**** + trailing 4). Identity stays on the adapter's actor metadata,
|
|
// never a prompt.
|
|
|
|
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 is the subset of the weixin-ilink adapter's scan-login surface
|
|
// the gateway panel endpoints invoke. It is implemented by *weixin_ilink.Adapter
|
|
// and reached by unwrapping the registration wrapper (see asWeixinLoginAdmin).
|
|
type weixinLoginAdmin interface {
|
|
StartLogin(ctx context.Context) (weixin_ilink.LoginQR, error)
|
|
LoginStatus(ctx context.Context) (status, detail string)
|
|
Config() map[string]any
|
|
}
|
|
|
|
// weixinLoginQRResponse is the wire shape of GET /admin/channels/{platform}/login-qr.
|
|
// qrUrl is the WeChat QR image URL (qrcode_img_content) and qrCode is the raw scan
|
|
// identifier (qrcode); the frontend renders a scannable QR from qrUrl, since the
|
|
// liteapp URL is an HTML page that cannot be fetched as an image.
|
|
type weixinLoginQRResponse struct {
|
|
QrUrl string `json:"qrUrl,omitempty"`
|
|
QrCode string `json:"qrCode,omitempty"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// weixinLoginStatusResponse is the wire shape of 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 triggers (or returns the in-progress) QR code for a
|
|
// weixin-ilink account. It is the panel's "扫码登录" entry point; it never blocks
|
|
// on the user's scan — LoginStatus is polled separately.
|
|
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 reports the scan-login state. On confirmation it also
|
|
// persists the freshly obtained token to channels.json and hot-reloads the
|
|
// platform, so the bridge connects the now-configured adapter.
|
|
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(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 writes the login-obtained credentials into the account's
|
|
// channels.json config (merging onto the user's curated base_url/cdn fields and
|
|
// preserving its allow/deny/limit lists) and hot-reloads the platform so the
|
|
// bridge connects the now-configured adapter.
|
|
func (s *Server) persistWeixinConfirmed(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
|
|
}
|
|
acct := config.ChannelAccount{
|
|
ID: base.ID,
|
|
Config: cfg,
|
|
AllowGroups: base.AllowGroups,
|
|
DenyGroups: base.DenyGroups,
|
|
AllowUsers: base.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
|
|
}
|
|
return s.channels.Reload(platform)
|
|
}
|
|
|
|
// weixinAccountFor resolves the *weixin_ilink.Adapter registered for the
|
|
// platform/account. It is nil for an unknown account or a wrapped adapter that
|
|
// does not expose the login surface.
|
|
func weixinAccountFor(caps CapabilityStore, platform, accountID string) (weixinLoginAdmin, bool) {
|
|
if caps == nil {
|
|
return nil, false
|
|
}
|
|
// A weixin adapter may be registered with the bare "weixin-ilink" name (single
|
|
// account) rather than "weixin-ilink:<acct>", so first try a composite match, and
|
|
// fall back to a bare weixin adapter when the caller names a specific account but
|
|
// no composite name carries it (the panel passes the config account 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 unwraps a registered channel value to its inner weixin-ilink
|
|
// adapter, mirroring asKookAdmin: the registration wraps the adapter in cmd/cyrene's
|
|
// namedAdapter (an unexported struct embedding adapter.PlatformAdapter); reflection
|
|
// reads the embedded field and type-asserts to the login surface. A direct
|
|
// *weixin_ilink.Adapter value (tests) is also accepted.
|
|
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 resolves the weixin-ilink adapter for a platform/account, writing
|
|
// an honest error and reporting found=false when it cannot.
|
|
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 redacts a secret to "****" + the trailing 4 characters, matching the
|
|
// project's maskToken so a token never lands verbatim in a wire response.
|
|
func maskToken(s string) string {
|
|
if len(s) <= 4 {
|
|
return "****"
|
|
}
|
|
return "****" + s[len(s)-4:]
|
|
}
|
|
|
|
// qrImageDataURL turns a QR image's raw content into an embeddable data URL. When
|
|
// the content is an http(s) URL it fetches and base64-encodes it (best effort); on
|
|
// any failure it returns "" so the caller keeps qrUrl. A content already carrying a
|
|
// data: prefix is passed through; anything else is treated as raw 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
|
|
}
|