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:
@@ -0,0 +1,172 @@
|
||||
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"` // 微信二维码图 URL(showqrcode)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 邀请码 = 把「加入权」发出去。这组测试钉死:正常入组、重复扫不重复消耗名额、
|
||||
// 过期/撤销/超人数一律拒绝——每一条错了都是「陌生人白嫖共享租户积分」的口子。
|
||||
|
||||
func mkUser(t *testing.T, p *Postgres, openid string) string {
|
||||
t.Helper()
|
||||
u, err := p.CreateWechatUser(context.Background(), openid, "微信用户_"+openid)
|
||||
if err != nil {
|
||||
t.Fatalf("建用户失败: %v", err)
|
||||
}
|
||||
return u.ID
|
||||
}
|
||||
|
||||
func TestRedeemInvite_HappyAndIdempotent(t *testing.T) {
|
||||
p := newTestStore(t)
|
||||
seedTenant(t, p, "t1")
|
||||
ctx := context.Background()
|
||||
inv, err := p.CreateInvite(ctx, "t1", "inviter", RoleMember, time.Now().Add(time.Hour), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("建码失败: %v", err)
|
||||
}
|
||||
uid := mkUser(t, p, "o-1")
|
||||
|
||||
name, ok := p.RedeemInvite(ctx, inv.Token, uid)
|
||||
if !ok {
|
||||
t.Fatal("正常令牌应入组成功")
|
||||
}
|
||||
if name != "T-t1" {
|
||||
t.Fatalf("应回租户名 T-t1,得 %q", name)
|
||||
}
|
||||
if r := p.MemberRole(ctx, "t1", uid); r != RoleMember {
|
||||
t.Fatalf("入组后应为 member,得 %q", r)
|
||||
}
|
||||
|
||||
// 同一人重复扫:仍成功但不重复消耗名额。
|
||||
if _, ok := p.RedeemInvite(ctx, inv.Token, uid); !ok {
|
||||
t.Fatal("重复扫应幂等成功")
|
||||
}
|
||||
got := p.GetInviteByToken(ctx, inv.Token)
|
||||
if got.UsedCount != 1 {
|
||||
t.Fatalf("同一人重复扫,UsedCount 应仍为 1,得 %d", got.UsedCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemInvite_ExpiredAndRevoked(t *testing.T) {
|
||||
p := newTestStore(t)
|
||||
seedTenant(t, p, "t1")
|
||||
ctx := context.Background()
|
||||
|
||||
expired, _ := p.CreateInvite(ctx, "t1", "inv", RoleMember, time.Now().Add(-time.Minute), 0)
|
||||
if _, ok := p.RedeemInvite(ctx, expired.Token, mkUser(t, p, "o-exp")); ok {
|
||||
t.Fatal("过期码不该能入组")
|
||||
}
|
||||
|
||||
revoked, _ := p.CreateInvite(ctx, "t1", "inv", RoleMember, time.Now().Add(time.Hour), 0)
|
||||
if err := p.RevokeInvite(ctx, "t1", revoked.ID); err != nil {
|
||||
t.Fatalf("撤销失败: %v", err)
|
||||
}
|
||||
if _, ok := p.RedeemInvite(ctx, revoked.Token, mkUser(t, p, "o-rev")); ok {
|
||||
t.Fatal("已撤销码不该能入组")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemInvite_MaxUses(t *testing.T) {
|
||||
p := newTestStore(t)
|
||||
seedTenant(t, p, "t1")
|
||||
ctx := context.Background()
|
||||
inv, _ := p.CreateInvite(ctx, "t1", "inv", RoleViewer, time.Now().Add(time.Hour), 2)
|
||||
|
||||
if _, ok := p.RedeemInvite(ctx, inv.Token, mkUser(t, p, "u1")); !ok {
|
||||
t.Fatal("第 1 人应成功")
|
||||
}
|
||||
if _, ok := p.RedeemInvite(ctx, inv.Token, mkUser(t, p, "u2")); !ok {
|
||||
t.Fatal("第 2 人应成功")
|
||||
}
|
||||
if _, ok := p.RedeemInvite(ctx, inv.Token, mkUser(t, p, "u3")); ok {
|
||||
t.Fatal("第 3 人应被名额闸拦下")
|
||||
}
|
||||
if got := p.GetInviteByToken(ctx, inv.Token); got.UsedCount != 2 {
|
||||
t.Fatalf("UsedCount 应为 2,得 %d", got.UsedCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedeemInvite_RejectsOwnerRole(t *testing.T) {
|
||||
p := newTestStore(t)
|
||||
seedTenant(t, p, "t1")
|
||||
if _, err := p.CreateInvite(context.Background(), "t1", "inv", RoleOwner, time.Now().Add(time.Hour), 0); err == nil {
|
||||
t.Fatal("二维码不该能生成 owner 邀请")
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func OpenPostgres(dsn string) *Postgres {
|
||||
migrateLegacyIntIDs(db)
|
||||
migrateDocLinkToID(db)
|
||||
|
||||
if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}, &Space{}, &SpaceMember{}, &UsageEvent{}, &CreditLedger{}, &UsageRollup{}, &Setting{}, &CreditPack{}, &PaymentOrder{}, &RedeemCode{}, &SubscriptionPlan{}, &Subscription{}); err != nil {
|
||||
if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}, &TenantInvite{}, &Space{}, &SpaceMember{}, &UsageEvent{}, &CreditLedger{}, &UsageRollup{}, &Setting{}, &CreditPack{}, &PaymentOrder{}, &RedeemCode{}, &SubscriptionPlan{}, &Subscription{}); err != nil {
|
||||
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
|
||||
return &Postgres{}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func newTestStore(t *testing.T) *Postgres {
|
||||
sqlDB.SetMaxOpenConns(1) // :memory: 每连接一个库,锁死单连接才共享同一份数据
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&User{}, &Tenant{}, &TenantMember{}, &CreditLedger{}, &PaymentOrder{},
|
||||
&User{}, &Tenant{}, &TenantMember{}, &TenantInvite{}, &CreditLedger{}, &PaymentOrder{},
|
||||
&RedeemCode{}, &CreditPack{}, &UsageEvent{}, &UsageRollup{}, &Setting{}, &Pricing{}, &LLMModel{},
|
||||
&AuditLog{}, &Task{}, &Eval{}, &SubscriptionPlan{}, &Subscription{},
|
||||
&KB{}, // 租户作用域模型,验证隔离插件
|
||||
|
||||
@@ -74,7 +74,11 @@ func (p *Postgres) GetUserByWechatOpenID(ctx context.Context, openID string) (*U
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// CreateWechatUser 为首次微信登录的用户建号(无邮箱/密码)。name 是展示名(微信昵称或默认)。
|
||||
// CreateWechatUser 为首次微信登录的用户建号(无密码)。name 是展示名(微信昵称或默认)。
|
||||
//
|
||||
// 邮箱填一个合成的、按 openid 唯一的占位值(wx-<openid>@wx.local):User.Email 上有整列
|
||||
// 唯一索引,微信用户若都留空邮箱,第二个就会撞唯一约束建号失败。合成邮箱唯一且一望即知非真实,
|
||||
// 既绕开冲突又不动索引(改成部分唯一索引需删存量索引,无法在无库可见时稳妥进行)。
|
||||
func (p *Postgres) CreateWechatUser(ctx context.Context, openID, name string) (*User, error) {
|
||||
if p.db == nil {
|
||||
return nil, errStoreDisabled
|
||||
@@ -82,7 +86,7 @@ func (p *Postgres) CreateWechatUser(ctx context.Context, openID, name string) (*
|
||||
if openID == "" {
|
||||
return nil, errors.New("openid 必填")
|
||||
}
|
||||
u := &User{WechatOpenID: openID, Name: name}
|
||||
u := &User{WechatOpenID: openID, Name: name, Email: "wx-" + openID + "@wx.local"}
|
||||
if err := p.db.WithContext(ctx).Create(u).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 微信首次登录建号:无邮箱、按 openid 可查回。
|
||||
// 微信首次登录建号:合成占位邮箱(按 openid 唯一,避免多个微信用户空邮箱撞唯一约束)、按 openid 可查回。
|
||||
func TestCreateAndGetWechatUser(t *testing.T) {
|
||||
p := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
@@ -19,8 +19,13 @@ func TestCreateAndGetWechatUser(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("建号失败: %v", err)
|
||||
}
|
||||
if u.Email != "" {
|
||||
t.Fatalf("微信用户不该有邮箱,得 %q", u.Email)
|
||||
if u.Email != "wx-openid-x@wx.local" {
|
||||
t.Fatalf("微信用户应有按 openid 合成的占位邮箱,得 %q", u.Email)
|
||||
}
|
||||
|
||||
// 第二个微信用户不能撞唯一约束(此前空邮箱会冲突——邀请功能会批量建微信用户)。
|
||||
if _, err := p.CreateWechatUser(ctx, "openid-y", "微信用户2"); err != nil {
|
||||
t.Fatalf("第二个微信用户建号不该冲突: %v", err)
|
||||
}
|
||||
|
||||
got, err := p.GetUserByWechatOpenID(ctx, "openid-x")
|
||||
|
||||
Reference in New Issue
Block a user