ce7cca657e
P0 暂停租户是空开关:admin 能设 suspended,但 preflight 只查预算+余额、不看 租户 status → 暂停后照样能提交烧积分。加 TenantSuspended 校验(活跃租户 + 分叉时 的计费租户都拦),403 拒绝。 P1 金额不符只刷日志:回调/查单判了不符却没落审计、订单永远卡 pending 被补偿定时器 每轮重扫刷屏。加 disputed 终态 + MarkOrderDisputed(CAS 只挂一次) + 审计(首次写一次); disputed 不在 pending 扫描内,停止无限重扫。admin /orders?status=disputed 可查。 P1 邀请码列表混入失效码:ListInvites 只按 status=active 过滤,过期/满员的码仍显示为 有效、误导邀请人。有效列表加 expires_at>now 且 used<max 过滤(RedeemInvite 本就会拒, 这里修的是展示一致性)。 三处均带 store 单测(TenantSuspended/MarkOrderDisputed CAS/ListInvites 过滤)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
176 lines
7.0 KiB
Go
176 lines
7.0 KiB
Go
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 {
|
||
// 「有效」= 未撤销 + 未过期 + 未满员。只看 status 会把过期/满员的码当有效展示、误导邀请人。
|
||
q = q.Where("status = ?", InviteActive).
|
||
Where("expires_at > ?", time.Now()).
|
||
Where("max_uses = 0 OR used_count < max_uses")
|
||
}
|
||
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
|
||
}
|