Files
sundynix-agentix/sundynix-gateway/internal/wechat/mp.go
T
Blizzard 07955ddf07 feat(auth): 微信扫码登录后端 —— 网页授权 + ticket 轮询
服务号「植趣 ZeeQ」已认证,走网页授权(snsapi_base,只拿 openid、用户无感),
不接管消息推送,副作用最小。

流程:PC 建 ticket → 二维码指向 /wx/mp?t= → 用户微信扫码 → 302 到微信授权页 →
回调 /api/v1/wx/mp/callback 用 code 换 openid → 找/建用户 → ticket 置 authorized →
PC 轮询 /wx/mp/poll 拿到 authorized → 签发 JWT。ticket 一次性消费防重放。

- 配置(appid/secret/base_url)后台可改,secret AES 加密入库,与微信支付同一套 secrets;
- ticket 存 Redis(短 TTL),无 Redis 时回退进程内内存(本地单实例可用,生产必须有 Redis);
- User 加 wechat_openid。**部分唯一索引**(WHERE openid <> '')而非普通唯一:
  存量邮箱用户该列是空串,普通唯一索引会让多个空串互撞、AutoMigrate 直接失败
  —— 与之前 NULL 余额同类的坑,这次提前避开。

单测覆盖:授权 URL 拼接(含 #wechat_redirect 锚点必须在末尾)、secret 加密往返、
建号/查号、空 openid 不误命中存量用户。微信 API 调用依赖公网回调,本地测不了,
留待部署后真机扫码。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:44:00 +08:00

119 lines
4.5 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 wechat 实现微信公众号(服务号)网页授权登录。
//
// 为什么是网页授权而不是「带参数二维码 + 消息推送」:后者要在公众平台配「服务器配置」,
// 会接管该号的所有消息(自动回复失效),且需要额外接口权限。网页授权只需在公众平台配
// 「网页授权域名」,已认证服务号默认具备,副作用最小。
//
// 登录流程(PC 端):
// 1. 前端请求建 ticket → 后端返回二维码,内容是本服务的 /wx/mp?t=<ticket>
// 2. 用户微信扫码 → 微信内置浏览器打开该 URL → 后端 302 到微信 OAuth 授权页(state=ticket
// 3. 用户「允许」→ 微信回调 /api/v1/wx/mp/callback?code&state → 用 code 换 openid
// 4. openid 找/建用户 → ticket 置 authorized(userID)
// 5. PC 端轮询 ticket → authorized → 签发 JWT
//
// 配置(appid/secret)后台可改、密钥加密入库,与微信支付同一套 secrets。
package wechat
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/sundynix/sundynix-shared/secrets"
)
// Config 是公众号网页授权所需配置。AppSecret 加密入库,只写不回显(同微信支付 APIv3 密钥)。
type Config struct {
AppID string `json:"appid"`
AppSecret string `json:"app_secret"`
// 授权回调基地址,如 https://agent.sundynix.cn。留空则用请求 Host 推断。
// 显式配置更稳:微信要求回调域名与「网页授权域名」完全一致,靠 Host 推断在反代下易错。
BaseURL string `json:"base_url"`
}
// Enabled 报告配置是否完整到可用。
func (c Config) Enabled() bool { return c.AppID != "" && c.AppSecret != "" }
// EncryptedForStore 返回一份 AppSecret 已加密的副本,用于落库。
func (c Config) EncryptedForStore() (Config, error) {
if c.AppSecret == "" {
return c, nil
}
enc, err := secrets.Encrypt(c.AppSecret)
if err != nil {
return c, err
}
c.AppSecret = enc
return c, nil
}
// DecryptFromStore 把库内密文 AppSecret 还原为明文(无前缀的历史明文透传)。
func (c Config) DecryptFromStore() Config {
if c.AppSecret != "" {
if plain, err := secrets.Decrypt(c.AppSecret); err == nil {
c.AppSecret = plain
}
}
return c
}
// AuthorizeURL 构造微信 OAuth 授权跳转地址。
// scope=snsapi_base:只拿 openid,不弹授权页、用户无感(登录只需要唯一标识,够用了)。
// state 回传我们的 ticket,用于把回调关联回发起登录的那个 PC 会话。
func (c Config) AuthorizeURL(redirectURI, state string) string {
q := url.Values{}
q.Set("appid", c.AppID)
q.Set("redirect_uri", redirectURI)
q.Set("response_type", "code")
q.Set("scope", "snsapi_base")
q.Set("state", state)
// #wechat_redirect 是微信要求的锚点,缺了不跳转
return "https://open.weixin.qq.com/connect/oauth2/authorize?" + q.Encode() + "#wechat_redirect"
}
// UserInfo 是 code 换取的结果(snsapi_base 下只有 openid)。
type UserInfo struct {
OpenID string
}
// ExchangeCode 用授权 code 换 openid(网页授权专用接口,不占 access_token 的 IP 白名单?——
// 实际上仍走 api.weixin.qq.com,出网 IP 必须在白名单里,否则报 40164)。
func (c Config) ExchangeCode(ctx context.Context, code string) (*UserInfo, error) {
q := url.Values{}
q.Set("appid", c.AppID)
q.Set("secret", c.AppSecret)
q.Set("code", code)
q.Set("grant_type", "authorization_code")
endpoint := "https://api.weixin.qq.com/sns/oauth2/access_token?" + q.Encode()
reqCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(reqCtx, http.MethodGet, endpoint, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求微信换 openid 失败: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
// 微信无论成败都返回 200 + JSON;errcode 非 0 才是失败。
var r struct {
OpenID string `json:"openid"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &r); err != nil {
return nil, fmt.Errorf("解析微信响应失败: %s", strings.TrimSpace(string(body)))
}
if r.ErrCode != 0 || r.OpenID == "" {
// 40163=code 已使用,40029=code 无效,40164=IP 不在白名单——原样带出便于排查
return nil, fmt.Errorf("微信换 openid 失败: errcode=%d errmsg=%s", r.ErrCode, r.ErrMsg)
}
return &UserInfo{OpenID: r.OpenID}, nil
}