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>
This commit is contained in:
Blizzard
2026-07-21 08:44:00 +08:00
parent a8a497b4ce
commit 07955ddf07
18 changed files with 592 additions and 27 deletions
+118
View File
@@ -0,0 +1,118 @@
// 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
}
@@ -0,0 +1,53 @@
package wechat
import (
"strings"
"testing"
)
func TestConfig_Enabled(t *testing.T) {
if (Config{AppID: "x"}).Enabled() {
t.Fatal("缺 secret 不该 enabled")
}
if !(Config{AppID: "x", AppSecret: "y"}).Enabled() {
t.Fatal("齐全应 enabled")
}
}
func TestAuthorizeURL(t *testing.T) {
c := Config{AppID: "wxAPP"}
u := c.AuthorizeURL("https://a.b/cb", "tkt123")
// 必备参数与微信要求的锚点
for _, want := range []string{
"open.weixin.qq.com/connect/oauth2/authorize",
"appid=wxAPP",
"scope=snsapi_base",
"state=tkt123",
"redirect_uri=https%3A%2F%2Fa.b%2Fcb", // 必须 URL 编码
"#wechat_redirect", // 缺了微信不跳转
} {
if !strings.Contains(u, want) {
t.Fatalf("授权 URL 缺 %q%s", want, u)
}
}
// 锚点必须在最后
if !strings.HasSuffix(u, "#wechat_redirect") {
t.Fatalf("#wechat_redirect 必须在末尾:%s", u)
}
}
// secret 加密往返:落库是密文,取出还原成明文。
func TestConfig_SecretRoundTrip(t *testing.T) {
c := Config{AppID: "x", AppSecret: "the-plain-secret"}
stored, err := c.EncryptedForStore()
if err != nil {
t.Fatal(err)
}
if stored.AppSecret == "the-plain-secret" {
t.Fatal("落库的 secret 不该是明文")
}
back := stored.DecryptFromStore()
if back.AppSecret != "the-plain-secret" {
t.Fatalf("还原失败:%q", back.AppSecret)
}
}