Files
sundynix-agentix/sundynix-gateway/internal/handler/wechat_login.go
T
Blizzard 83269e067a feat(wechat): 关注公众号后自动回复欢迎语(被动回复,可后台配置)
用户关注服务号(扫登录码后关注 或 直接搜索关注)时,在回调 HTTP 响应里
回一条文本消息(微信「被动回复」)。被动回复不需要 access_token、不受 IP 白名单
限制,永远能发。欢迎语在管理端「登录设置」可配,留空用默认。

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

293 lines
9.7 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 handler
import (
"context"
"crypto/rand"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-gateway/internal/wechat"
)
// 微信公众号「带参数二维码 + 关注/扫码事件」登录。设计见 internal/wechat/mp.go。
//
// 端点:
// POST /api/v1/wx/mp/ticket 建 login ticket + 微信二维码,返回二维码图 URL
// GET /wx/mp/callback 服务器配置 URL 验证(echostr
// POST /wx/mp/callback 事件推送:关注/扫码 → 置 ticket 已授权
// GET /api/v1/wx/mp/poll?t= PC 轮询登录态,已授权则签发 JWT
const (
SettingWechatMP = "wechat_mp" // 公众号登录配置(setting 表)
wxTicketTTL = 5 * time.Minute // 二维码/ticket 有效期
)
// access_token 拉取的进程内串行化:单实例下杜绝并发拉取互相失效(多实例靠 Redis 缓存兜大头)。
var wxTokenMu sync.Mutex
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()
}
// accessToken 取(缓存优先)微信 access_token。
func (h *Handler) accessToken(ctx context.Context, cfg wechat.Config) (string, error) {
if t := h.cache.WxTokenGet(ctx, cfg.AppID); t != "" {
return t, nil
}
wxTokenMu.Lock()
defer wxTokenMu.Unlock()
if t := h.cache.WxTokenGet(ctx, cfg.AppID); t != "" { // 双检
return t, nil
}
// 中控模式:配了 WECHAT_TOKEN_URL 就只从中控拉(腾讯云静态 IP 换 token),
// 绝不自己调 FetchAccessToken——微信要求单点刷新,多点各自换会互相顶掉 token。
// 没配则维持直连(本地出网换 token,受动态 IP 白名单约束)。
var (
token string
ttl int
err error
)
if tokenURL := os.Getenv("WECHAT_TOKEN_URL"); tokenURL != "" {
token, ttl, err = wechat.PullToken(ctx, tokenURL, os.Getenv("WECHAT_TOKEN_SECRET"))
} else {
token, ttl, err = cfg.FetchAccessToken(ctx)
}
if err != nil {
return "", err
}
if ttl > 200 {
ttl -= 200 // 安全边界,避免临界过期
}
h.cache.WxTokenSet(ctx, cfg.AppID, token, time.Duration(ttl)*time.Second)
return token, nil
}
// wechatNickname 从 openid 派生稳定昵称:微信用户_XXXXX。
// 后缀取 openid 的 sha1 前 5 位、映射到去混淆字母表 —— openid 唯一 → 后缀实际不重复,
// 且同一 openid 每次一致(重登不会换名)。用户可事后自己改名。
func wechatNickname(openID string) string {
const alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" // 去掉 0/O、1/I/L 等易混
sum := sha1.Sum([]byte(openID))
var sb strings.Builder
sb.WriteString("微信用户_")
for i := 0; i < 5; i++ {
sb.WriteByte(alphabet[int(sum[i])%len(alphabet)])
}
return sb.String()
}
func newTicket() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// WxMPTicket: POST /api/v1/wx/mp/ticket
func (h *Handler) WxMPTicket(c *gin.Context) {
ctx := c.Request.Context()
cfg := h.loadWechatMP(ctx)
if !cfg.Enabled() {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "微信登录未配置"})
return
}
token, err := h.accessToken(ctx, cfg)
if err != nil {
log.Printf("[wxlogin] 取 access_token 失败: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": "微信登录暂不可用"})
return
}
ticket := newTicket()
qrURL, err := cfg.CreateLoginQR(ctx, token, ticket, int(wxTicketTTL.Seconds()))
if err != nil {
log.Printf("[wxlogin] 建二维码失败: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": "生成二维码失败"})
return
}
st, _ := json.Marshal(wxTicketState{Status: "pending"})
if err := h.cache.WxTicketSet(ctx, ticket, string(st), wxTicketTTL); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "创建登录票据失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ticket": ticket, "qr_image": qrURL, "expires_in": int(wxTicketTTL.Seconds())})
}
// WxMPVerify: GET /wx/mp/callback —— 服务器配置 URL 验证。
func (h *Handler) WxMPVerify(c *gin.Context) {
cfg := h.loadWechatMP(c.Request.Context())
if cfg.Token == "" || !cfg.CheckSignature(c.Query("signature"), c.Query("timestamp"), c.Query("nonce")) {
c.String(http.StatusForbidden, "signature check failed")
return
}
c.String(http.StatusOK, c.Query("echostr"))
}
// WxMPEvent: POST /wx/mp/callback —— 事件推送。
// 无论如何回 "success"(微信要求),否则会重试并给用户端弹"公众号服务故障"。
func (h *Handler) WxMPEvent(c *gin.Context) {
ctx := c.Request.Context()
cfg := h.loadWechatMP(ctx)
// 验签:拒绝伪造事件(否则任何人 POST 一个 openid 就能登录别人)
if cfg.Token == "" || !cfg.CheckSignature(c.Query("signature"), c.Query("timestamp"), c.Query("nonce")) {
c.String(http.StatusForbidden, "signature check failed")
return
}
body, _ := io.ReadAll(c.Request.Body)
ev, err := wechat.ParseEvent(body)
if err != nil {
c.String(http.StatusOK, "success")
return
}
// 登录扫码(关注扫码 / 已关注再扫)→ 授权对应 ticket,让 PC 端轮询登录。
if ev.IsLoginScan() {
h.authorizeWxLogin(ctx, ev)
}
// 新关注 → 被动回复欢迎语(覆盖「扫登录码后关注」和「搜索直接关注」两种入口)。
// 被动回复无需 access_token、不受 IP 白名单限制,回执 XML 即到达用户。
if ev.IsSubscribe() {
welcome := strings.TrimSpace(cfg.Welcome)
if welcome == "" {
welcome = wechat.DefaultWelcome
}
reply := wechat.BuildTextReply(ev.FromUserName, ev.ToUserName, welcome, time.Now().Unix())
c.Header("Content-Type", "application/xml")
c.String(http.StatusOK, reply)
return
}
c.String(http.StatusOK, "success")
}
// authorizeWxLogin 处理登录扫码事件:按 openid 找/建用户并置 ticket 已授权。
// 只做副作用(Redis/DB),不写 HTTP 响应——响应由调用方按事件类型统一决定。
func (h *Handler) authorizeWxLogin(ctx context.Context, ev *wechat.Event) {
ticket, openID := ev.Scene(), ev.FromUserName
if h.cache.WxTicketGet(ctx, ticket) == "" { // ticket 必须仍有效
return
}
u, err := h.db.GetUserByWechatOpenID(ctx, openID)
if err != nil {
return
}
if u == nil {
u, err = h.db.CreateWechatUser(ctx, openID, wechatNickname(openID))
if err != nil {
log.Printf("[wxlogin] 建微信用户失败 openid=%s: %v", openID, err)
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})
_ = h.cache.WxTicketSet(ctx, ticket, string(st), wxTicketTTL)
}
// WxMPPoll: GET /api/v1/wx/mp/poll?t=<ticket>
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})
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)
}
// ---- 管理端配置 ----
func (h *Handler) AdminGetWechatMP(c *gin.Context) {
cfg := h.loadWechatMP(c.Request.Context())
// 单管理员后台:AppSecret 明文回显(RequireAdmin 已拦,方便核对/复制)。
c.JSON(http.StatusOK, gin.H{
"appid": cfg.AppID,
"token": cfg.Token,
"app_secret": cfg.AppSecret,
"welcome": cfg.Welcome,
"enabled": cfg.Enabled(),
})
}
func (h *Handler) AdminSaveWechatMP(c *gin.Context) {
var b struct {
AppID string `json:"appid"`
AppSecret string `json:"app_secret"`
Token string `json:"token"`
Welcome string `json:"welcome"`
}
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, Token: strings.TrimSpace(b.Token), Welcome: strings.TrimSpace(b.Welcome)}
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
}
h.cache.WxTokenSet(ctx, cfg.AppID, "", time.Millisecond) // 换密钥→旧 token 作废
c.JSON(http.StatusOK, gin.H{"status": "ok", "enabled": cfg.Enabled()})
}
// AdminWechatUsers: GET /api/v1/admin/wechat-users —— 后台微信用户列表(openid/昵称/加入时间/余额)。
func (h *Handler) AdminWechatUsers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"users": h.db.ListWechatUsers(c.Request.Context(), 200)})
}