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>
This commit is contained in:
Blizzard
2026-07-21 11:47:21 +08:00
parent 68608c1592
commit bd829cfecb
14 changed files with 625 additions and 12 deletions
@@ -0,0 +1,92 @@
package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-gateway/internal/store"
)
// 租户成员「二维码邀请」(可复用团队码)。owner/admin 生成一张微信带参二维码发给团队,
// 成员扫码关注/识别即自动入组(见 wechat_login.go 的 WxMPEvent inv_ 分支)。
//
// 挂在 tenant_self.go 旁,作用于当前活跃租户;建/撤销须 ≥admin(路由 RequireTenantRole 把守)。
const (
inviteScenePrefix = "inv_" // 二维码 scene 前缀,区分邀请扫码 vs 登录扫码
inviteMaxDays = 30 // 微信临时二维码有效期上限
inviteDefaultDays = 7
)
// CreateTenantInvite: POST /api/v1/tenants/current/invites {role?, expires_days?, max_uses?}
// 建邀请码并调微信出二维码,返回含二维码图 URL 的邀请记录。
func (h *Handler) CreateTenantInvite(c *gin.Context) {
var b struct {
Role string `json:"role"`
ExpiresDays int `json:"expires_days"`
MaxUses int `json:"max_uses"`
}
_ = c.ShouldBindJSON(&b)
role := b.Role
if role == "" {
role = store.RoleMember
}
if role == store.RoleOwner || !store.ValidRole(role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "非法角色(二维码不能邀请为 owner)"})
return
}
days := b.ExpiresDays
if days <= 0 {
days = inviteDefaultDays
}
if days > inviteMaxDays {
days = inviteMaxDays
}
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 {
c.JSON(http.StatusBadGateway, gin.H{"error": "微信暂不可用"})
return
}
inv, err := h.db.CreateInvite(ctx, tenantID(c), userID(c), role, time.Now().AddDate(0, 0, days), b.MaxUses)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
qr, err := cfg.CreateSceneQR(ctx, token, inviteScenePrefix+inv.Token, days*86400)
if err != nil {
// 二维码没建成 → 撤销这条,别在列表里留一条无图的死码。
_ = h.db.RevokeInvite(ctx, tenantID(c), inv.ID)
c.JSON(http.StatusBadGateway, gin.H{"error": "生成二维码失败:" + err.Error()})
return
}
if err := h.db.SetInviteQR(ctx, inv.ID, qr); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
inv.QRImage = qr
c.JSON(http.StatusOK, gin.H{"invite": inv})
}
// ListTenantInvites: GET /api/v1/tenants/current/invites —— 本租户的有效邀请码(含已用/上限/有效期)。
func (h *Handler) ListTenantInvites(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"invites": h.db.ListInvites(c.Request.Context(), tenantID(c), true)})
}
// RevokeTenantInvite: DELETE /api/v1/tenants/current/invites/:id —— 撤销一张邀请码。
func (h *Handler) RevokeTenantInvite(c *gin.Context) {
if err := h.db.RevokeInvite(c.Request.Context(), tenantID(c), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
@@ -6,6 +6,7 @@ import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
@@ -161,6 +162,14 @@ func (h *Handler) WxMPEvent(c *gin.Context) {
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)
@@ -182,6 +191,34 @@ func (h *Handler) WxMPEvent(c *gin.Context) {
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) {