package handler import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "io" "log" "net/http" "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 } 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 } 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 || !ev.IsLoginScan() { c.String(http.StatusOK, "success") // 非登录扫码事件忽略,照常回执 return } ticket, openID := ev.Scene(), ev.FromUserName if h.cache.WxTicketGet(ctx, ticket) == "" { // ticket 必须仍有效 c.String(http.StatusOK, "success") return } u, err := h.db.GetUserByWechatOpenID(ctx, openID) if err != nil { c.String(http.StatusOK, "success") return } if u == nil { u, err = h.db.CreateWechatUser(ctx, openID, "微信用户") if err != nil { log.Printf("[wxlogin] 建微信用户失败 openid=%s: %v", openID, err) c.String(http.StatusOK, "success") 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) c.String(http.StatusOK, "success") } // WxMPPoll: GET /api/v1/wx/mp/poll?t= 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()) c.JSON(http.StatusOK, gin.H{ "appid": cfg.AppID, "token": cfg.Token, "has_app_secret": cfg.AppSecret != "", "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"` } 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)} 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()}) }