bd829cfecb
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>
330 lines
11 KiB
Go
330 lines
11 KiB
Go
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)})
|
||
}
|