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= 用户扫码后微信打开这个,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= —— 用户微信扫码后打开,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, `

✅ 登录成功

请回到电脑继续

`) } // WxMPPoll: GET /api/v1/wx/mp/poll?t= —— 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()}) }