Files
Blizzard ce7cca657e fix(backend): 补齐三处漏洞——暂停租户拦截/金额不符落审计终态/邀请码列表滤失效
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>
2026-07-21 14:20:34 +08:00

176 lines
7.0 KiB
Go
Raw Permalink 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 {
// 「有效」= 未撤销 + 未过期 + 未满员。只看 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
}