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
@@ -63,9 +63,9 @@ func (h *Handler) AdminStatus(c *gin.Context) {
pyUp bool // mcp-py 在线
pyTools []toolInfo // mcp-py 注册工具
pyLatency int // mcp-py 探针耗时
dispUp bool // dispatcher 在线
dispDetail string // dispatcher 详情(模型/运行时长)
dispLatency int // dispatcher 探针耗时
dispUp bool // dispatcher 在线
dispDetail string // dispatcher 详情(模型/运行时长)
dispLatency int // dispatcher 探针耗时
pgUp, redisUp, minioUp bool // 基建活性探针(实时 ping,非仅启动标志)
)
@@ -0,0 +1,242 @@
package handler
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-gateway/internal/wechat"
)
// 微信公众号(服务号)扫码登录。设计见 internal/wechat/mp.go。
//
// 端点:
// POST /api/v1/wx/mp/ticket 建 ticket,返回二维码里要放的 URL
// GET /wx/mp?t=<ticket> 用户扫码后微信打开这个,302 到微信授权页
// GET /api/v1/wx/mp/callback 微信授权后回调,换 openid、找/建用户、置 ticket 已授权
// GET /api/v1/wx/mp/poll?t= PC 端轮询,已授权则签发 JWT
const (
SettingWechatMP = "wechat_mp" // 公众号登录配置(setting 表)
wxTicketTTL = 5 * time.Minute // 二维码/ticket 有效期
)
// wxTicketState 是 ticket 在 Redis 里的值。
type wxTicketState struct {
Status string `json:"status"` // pending / authorized / consumed
UserID string `json:"user_id"`
}
func (h *Handler) loadWechatMP(ctx context.Context) wechat.Config {
raw := h.db.GetSetting(ctx, SettingWechatMP)
if raw == "" {
return wechat.Config{}
}
var c wechat.Config
if json.Unmarshal([]byte(raw), &c) != nil {
return wechat.Config{}
}
return c.DecryptFromStore()
}
func newTicket() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// WxMPTicket: POST /api/v1/wx/mp/ticket —— 建一个待授权 ticket,返回二维码内容 URL。
func (h *Handler) WxMPTicket(c *gin.Context) {
cfg := h.loadWechatMP(c.Request.Context())
if !cfg.Enabled() {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "微信登录未配置"})
return
}
ticket := newTicket()
st, _ := json.Marshal(wxTicketState{Status: "pending"})
if err := h.cache.WxTicketSet(c.Request.Context(), ticket, string(st), wxTicketTTL); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "创建登录票据失败"})
return
}
// 二维码里放的是本服务的中转地址,不是微信授权地址本身 —— 授权地址带 secret 相关参数,
// 且要在用户扫码那一刻才拼(redirect_uri 要精确匹配),所以扫码后再由 /wx/mp 现拼现跳。
base := strings.TrimRight(cfg.BaseURL, "/")
if base == "" {
base = "https://" + c.Request.Host
}
c.JSON(http.StatusOK, gin.H{
"ticket": ticket,
"qr_url": base + "/wx/mp?t=" + ticket,
"expires_in": int(wxTicketTTL.Seconds()),
})
}
// WxMPEntry: GET /wx/mp?t=<ticket> —— 用户微信扫码后打开,302 到微信 OAuth 授权页。
// 这是公开路由(微信内置浏览器访问,无鉴权头)。
func (h *Handler) WxMPEntry(c *gin.Context) {
ticket := c.Query("t")
if ticket == "" {
c.String(http.StatusBadRequest, "缺少登录票据")
return
}
cfg := h.loadWechatMP(c.Request.Context())
if !cfg.Enabled() {
c.String(http.StatusServiceUnavailable, "微信登录未配置")
return
}
// ticket 必须存在且仍 pending,否则可能是过期或伪造
if h.cache.WxTicketGet(c.Request.Context(), ticket) == "" {
c.String(http.StatusBadRequest, "登录二维码已过期,请刷新重试")
return
}
base := strings.TrimRight(cfg.BaseURL, "/")
if base == "" {
base = "https://" + c.Request.Host
}
redirectURI := base + "/api/v1/wx/mp/callback"
c.Redirect(http.StatusFound, cfg.AuthorizeURL(redirectURI, ticket))
}
// WxMPCallback: GET /api/v1/wx/mp/callback?code&state —— 微信授权后回调(公开路由)。
// 用 code 换 openid → 找/建用户 → ticket 置 authorized。
func (h *Handler) WxMPCallback(c *gin.Context) {
code, ticket := c.Query("code"), c.Query("state")
if code == "" || ticket == "" {
c.String(http.StatusBadRequest, "授权参数缺失")
return
}
ctx := c.Request.Context()
// ticket 必须仍有效(防止拿别人的回调套自己的 ticket)
if h.cache.WxTicketGet(ctx, ticket) == "" {
c.String(http.StatusBadRequest, "登录已过期,请在电脑上重新扫码")
return
}
cfg := h.loadWechatMP(ctx)
if !cfg.Enabled() {
c.String(http.StatusServiceUnavailable, "微信登录未配置")
return
}
info, err := cfg.ExchangeCode(ctx, code)
if err != nil {
log.Printf("[wxlogin] 换 openid 失败 ticket=%s: %v", ticket, err)
c.String(http.StatusBadGateway, "微信授权失败,请重试")
return
}
// 找用户;没有则建号 + 默认租户(复用邮箱注册那套)
u, err := h.db.GetUserByWechatOpenID(ctx, info.OpenID)
if err != nil {
c.String(http.StatusBadGateway, "登录失败,请重试")
return
}
if u == nil {
u, err = h.db.CreateWechatUser(ctx, info.OpenID, "微信用户")
if err != nil {
log.Printf("[wxlogin] 建微信用户失败 openid=%s: %v", info.OpenID, err)
c.String(http.StatusBadGateway, "登录失败,请重试")
return
}
if _, e := h.db.EnsureDefaultTenant(ctx, u.ID, "我的空间"); e != nil {
log.Printf("[wxlogin] 建默认租户失败 uid=%s: %v", u.ID, e)
}
}
st, _ := json.Marshal(wxTicketState{Status: "authorized", UserID: u.ID})
if err := h.cache.WxTicketSet(ctx, ticket, string(st), wxTicketTTL); err != nil {
c.String(http.StatusBadGateway, "登录失败,请重试")
return
}
// 微信内置浏览器里显示一句提示即可,登录动作在 PC 端完成
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, `<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><body style="font-family:sans-serif;text-align:center;padding-top:30vh;color:#333"><h2>✅ 登录成功</h2><p style="color:#888">请回到电脑继续</p></body>`)
}
// WxMPPoll: GET /api/v1/wx/mp/poll?t=<ticket> —— PC 端轮询登录状态(公开路由)。
// authorized 时签发 JWT 并把 ticket 置 consumed(一次性,防重放)。
func (h *Handler) WxMPPoll(c *gin.Context) {
ticket := c.Query("t")
if ticket == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少票据"})
return
}
ctx := c.Request.Context()
raw := h.cache.WxTicketGet(ctx, ticket)
if raw == "" {
c.JSON(http.StatusOK, gin.H{"status": "expired"})
return
}
var st wxTicketState
if json.Unmarshal([]byte(raw), &st) != nil {
c.JSON(http.StatusOK, gin.H{"status": "expired"})
return
}
if st.Status != "authorized" {
c.JSON(http.StatusOK, gin.H{"status": st.Status}) // pending
return
}
// 一次性消费:先置 consumed 再签发,避免同一 ticket 被轮询两次拿到两个令牌
consumed, _ := json.Marshal(wxTicketState{Status: "consumed", UserID: st.UserID})
_ = h.cache.WxTicketSet(ctx, ticket, string(consumed), time.Minute)
u, err := h.db.GetUserByID(ctx, st.UserID)
if err != nil || u == nil {
c.JSON(http.StatusOK, gin.H{"status": "expired"})
return
}
issueToken(c, u)
}
// ---- 管理端配置 ----
// AdminGetWechatMP: GET /api/v1/admin/wechat-mp —— 当前配置(secret 不回显,只报有无)。
func (h *Handler) AdminGetWechatMP(c *gin.Context) {
cfg := h.loadWechatMP(c.Request.Context())
c.JSON(http.StatusOK, gin.H{
"appid": cfg.AppID,
"base_url": cfg.BaseURL,
"has_app_secret": cfg.AppSecret != "",
"enabled": cfg.Enabled(),
})
}
// AdminSaveWechatMP: PUT /api/v1/admin/wechat-mp —— 保存配置。
// app_secret 留空 = 沿用已存(只写不回显,同微信支付 APIv3 密钥)。
func (h *Handler) AdminSaveWechatMP(c *gin.Context) {
var b struct {
AppID string `json:"appid"`
AppSecret string `json:"app_secret"`
BaseURL string `json:"base_url"`
}
if err := c.ShouldBindJSON(&b); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
ctx := c.Request.Context()
secret := strings.TrimSpace(b.AppSecret)
if secret == "" {
secret = h.loadWechatMP(ctx).AppSecret // 留空沿用旧密钥(明文,下面统一加密)
}
cfg := wechat.Config{
AppID: strings.TrimSpace(b.AppID),
AppSecret: secret,
BaseURL: strings.TrimRight(strings.TrimSpace(b.BaseURL), "/"),
}
stored, err := cfg.EncryptedForStore()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密钥加密失败: " + err.Error()})
return
}
raw, _ := json.Marshal(stored)
if err := h.db.SetSetting(ctx, SettingWechatMP, string(raw)); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok", "enabled": cfg.Enabled()})
}