Files
sundynix-agentix/sundynix-gateway/internal/handler/wechat_login.go
T
Blizzard bd829cfecb feat(invite): 租户成员二维码邀请(可复用团队码,扫码关注即入组)
owner/admin 在桌面端生成一张微信带参二维码发给团队,成员用微信扫码关注
即自动加入租户,并收到「 已加入团队【X】」被动回复。与登录二维码同一微信机制,
scene 加 inv_ 前缀分流;扫码入组走被动回复,不需要 access_token、不碰 IP 白名单。

- store: TenantInvite(可复用码=有效期+人数上限+可撤销三道闸);RedeemInvite 幂等入组、
  同一人重复扫不重复消耗名额、复活已移除者;带单测钉死过期/撤销/满员/幂等。
- handler: 建码/列表/撤销端点(RequireTenantRole admin);WxMPEvent inv_ 分支。
- 顺手修潜在生产 bug:微信用户此前都建成空邮箱,User.Email 整列唯一索引下第二个微信
  用户就撞唯一约束建号失败(登录"全通"只因当前仅一个微信用户)。改为按 openid 合成占位
  邮箱 wx-<openid>@wx.local,绕开冲突且不动索引。邀请功能会批量建微信用户,非修不可。
- desktop: 顶栏「邀请成员」入口(租户 admin/owner 可见) + 二维码弹窗(生成/大图展示/撤销)。

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

330 lines
11 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"
"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=<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)})
}