Files
sundynix-agentix/sundynix-gateway/internal/store/invite.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

173 lines
6.7 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 store
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// 租户成员「二维码邀请」(可复用团队码):owner/admin 生成一张微信带参二维码,
// 发给团队,成员扫码关注/识别即自动入组。与登录二维码同一微信机制,区别只在
// scene 指向邀请令牌、扫码后干的是「入组」而非「授权 PC 登录」。
//
// 三道安全闸:有效期(ExpiresAt) + 可撤销(Status) + 最大人数(MaxUses)。
// 令牌 Token 随机不可枚举——它就是二维码里的 scene,泄露即等于把加入权发出去。
//
// 不标 isTenantScoped:与 TenantMember 一样是基础设施表,租户过滤在查询里显式做;
// 且扫码入组发生在微信回调(无请求 ctx 租户),本就不能依赖插件自动注入。
// TenantInvite 一张可复用的租户邀请码。
type TenantInvite struct {
BaseModel
TenantID string `gorm:"size:64;index" json:"tenant_id"`
Token string `gorm:"size:64;uniqueIndex" json:"-"` // = 二维码 scene;已在图里,不必回前端
Role string `gorm:"size:16" json:"role"` // 入组角色(member/viewer/admin;禁 owner
InviterID string `gorm:"size:64" json:"-"` // 建码人(审计)
QRImage string `gorm:"size:255" json:"qr_image"` // 微信二维码图 URLshowqrcode
ExpiresAt time.Time `gorm:"index" json:"expires_at"` // 过期时间(对齐微信临时二维码,≤30 天)
MaxUses int `gorm:"default:0" json:"max_uses"` // 0 = 不限人数
UsedCount int `gorm:"default:0" json:"used_count"` // 已成功加入的人数(同一人重复扫不重复计)
Status string `gorm:"size:16;default:active" json:"status"`
}
func (TenantInvite) TableName() string { return "sundynix_tenant_invite" }
const (
InviteActive = "active"
InviteRevoked = "revoked"
)
// newInviteToken 生成不可枚举的邀请令牌(16 字节 → 32 hex)。
func newInviteToken() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// CreateInvite 建一张邀请码(QRImage 由 handler 拿到微信二维码后回填 SetInviteQR)。
func (p *Postgres) CreateInvite(ctx context.Context, tenantID, inviterID, role string, expiresAt time.Time, maxUses int) (*TenantInvite, error) {
if p.db == nil {
return nil, errStoreDisabled
}
if role == "" {
role = RoleMember
}
if !ValidRole(role) || role == RoleOwner {
return nil, errors.New("非法角色(二维码不能邀请为 owner)")
}
if maxUses < 0 {
maxUses = 0
}
inv := &TenantInvite{
TenantID: tenantID, Token: newInviteToken(), Role: role, InviterID: inviterID,
ExpiresAt: expiresAt, MaxUses: maxUses, Status: InviteActive,
}
if err := p.db.WithContext(ctx).Create(inv).Error; err != nil {
return nil, err
}
return inv, nil
}
// SetInviteQR 回填二维码图 URL(建码后拿到微信二维码再写)。
func (p *Postgres) SetInviteQR(ctx context.Context, id, qrImage string) error {
if p.db == nil {
return errStoreDisabled
}
return p.db.WithContext(ctx).Model(&TenantInvite{}).Where("id = ?", id).Update("qr_image", qrImage).Error
}
// GetInviteByToken 按令牌取邀请码(含已撤销/过期,校验交给调用方)。
func (p *Postgres) GetInviteByToken(ctx context.Context, token string) *TenantInvite {
if p.db == nil || token == "" {
return nil
}
var inv TenantInvite
if err := p.db.WithContext(WithoutTenant(ctx)).Where("token = ?", token).First(&inv).Error; err != nil {
return nil
}
return &inv
}
// ListInvites 列出某租户的邀请码(新在前)。onlyActive 时只列未撤销的。
func (p *Postgres) ListInvites(ctx context.Context, tenantID string, onlyActive bool) []TenantInvite {
if p.db == nil {
return nil
}
q := p.db.WithContext(ctx).Where("tenant_id = ?", tenantID)
if onlyActive {
q = q.Where("status = ?", InviteActive)
}
var out []TenantInvite
q.Order("created_at desc").Limit(100).Find(&out)
return out
}
// RevokeInvite 撤销一张邀请码(限本租户,防越权撤别家的)。
func (p *Postgres) RevokeInvite(ctx context.Context, tenantID, id string) error {
if p.db == nil {
return errStoreDisabled
}
res := p.db.WithContext(ctx).Model(&TenantInvite{}).
Where("id = ? AND tenant_id = ?", id, tenantID).Update("status", InviteRevoked)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errors.New("邀请码不存在或不属于本租户")
}
return nil
}
// RedeemInvite 扫码入组:校验令牌 → 把 userID 加入其租户(幂等,复活已移除者)。
// 返回加入的租户名(供回执文案)与是否成功。令牌无效/过期/超次/撤销 → ok=false(回调静默)。
// UsedCount 只对「首次加入」计数:同一人重复扫(subscribe→之后 SCAN)不重复消耗名额。
func (p *Postgres) RedeemInvite(ctx context.Context, token, userID string) (tenantName string, ok bool) {
if p.db == nil || userID == "" {
return "", false
}
inv := p.GetInviteByToken(ctx, token)
if inv == nil || inv.Status != InviteActive {
return "", false
}
if !inv.ExpiresAt.IsZero() && time.Now().After(inv.ExpiresAt) {
return "", false
}
sctx := WithoutTenant(ctx) // 入组的目标租户与请求 ctx 无关,一律显式、旁路插件
var name string
p.db.WithContext(sctx).Model(&Tenant{}).Where("id = ?", inv.TenantID).Select("name").Scan(&name)
// 已是活跃成员 → 幂等成功,不再计数、不改角色(避免重复扫把人降/升级)。
var existing TenantMember
err := p.db.WithContext(sctx).Where("tenant_id = ? AND user_id = ?", inv.TenantID, userID).First(&existing).Error
if err == nil && existing.Status == "active" {
return name, true
}
// 首次加入前再查一次名额(软闸:极端并发下可能轻微超一两个,对邀请链接可接受)。
if inv.MaxUses > 0 && inv.UsedCount >= inv.MaxUses {
return "", false
}
// 幂等入组(复活已移除者):命中唯一约束则置 active + 本码角色。
if err := p.db.WithContext(sctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "tenant_id"}, {Name: "user_id"}},
DoUpdates: clause.Assignments(map[string]any{"role": inv.Role, "status": "active", "updated_at": time.Now()}),
}).Create(&TenantMember{TenantID: inv.TenantID, UserID: userID, Role: inv.Role, Status: "active"}).Error; err != nil {
return "", false
}
// 名额 +1(原子自增,避免读改写丢更新)。
p.db.WithContext(sctx).Model(&TenantInvite{}).Where("id = ?", inv.ID).
UpdateColumn("used_count", gorm.Expr("used_count + 1"))
// 若目标租户已启用全员空间,新成员自动纳入(与 AddMemberByEmail 一致)。
p.autoJoinTenantSpace(sctx, inv.TenantID, userID, inv.Role)
return name, true
}