package handler import ( "context" "crypto/rand" "crypto/sha1" "encoding/hex" "encoding/json" "fmt" "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 } // 邀请扫码:scene 带 inv_ 前缀 → 找/建用户 + 入组 + 被动回复回执。独立于 PC 登录, // 且不需要 access_token(回执走被动回复)。放在最前,避免被登录/欢迎逻辑抢先。 if scene := ev.Scene(); strings.HasPrefix(scene, inviteScenePrefix) { c.Header("Content-Type", "application/xml") c.String(http.StatusOK, h.handleInviteScan(ctx, ev, strings.TrimPrefix(scene, inviteScenePrefix))) 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") } // handleInviteScan 处理邀请扫码:按 openid 找/建用户 → 兑换邀请令牌入组 → 返回被动回复 XML。 // 回执走被动回复(无需 access_token),无论成败都要回一段合法响应体给微信。 func (h *Handler) handleInviteScan(ctx context.Context, ev *wechat.Event, token string) string { openID := ev.FromUserName u, err := h.db.GetUserByWechatOpenID(ctx, openID) if err != nil { return "success" } if u == nil { // 新用户:建号 + 个人租户(与登录一致),随后再入被邀请的租户 u, err = h.db.CreateWechatUser(ctx, openID, wechatNickname(openID)) if err != nil { log.Printf("[wxinvite] 建微信用户失败 openid=%s: %v", openID, err) return "success" } if _, e := h.db.EnsureDefaultTenant(ctx, u.ID, "我的空间"); e != nil { log.Printf("[wxinvite] 建默认租户失败 uid=%s: %v", u.ID, e) } } name, ok := h.db.RedeemInvite(ctx, token, u.ID) now := time.Now().Unix() if !ok { return wechat.BuildTextReply(ev.FromUserName, ev.ToUserName, "邀请链接已失效或人数已满,请向邀请人重新获取。", now) } msg := fmt.Sprintf("✅ 已加入团队【%s】\n在桌面端用微信扫码登录即可共享团队积分。", name) return wechat.BuildTextReply(ev.FromUserName, ev.ToUserName, msg, now) } // 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= 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)}) }