fix(backend): 登录设备配额三修复——过期会话不占坑/同设备重登不叠加/配额跟套餐档位

S003 线上登录锁死根因修复:
- 活跃会话统计排除 refresh 已过期的行(此前过期未清理的会话把配额占满,
  正常用户被锁在门外,需等 cleanup retention 天后才释放)
- 同 shop+user+device_id 重登先吊销旧会话(reason=relogin),重装/重复登录
  不再叠占配额
- effectiveTotalQuota 接线套餐档位:session_policy 运维覆盖 > 有效授权
  max_devices(标准 2/高级 5,floor 2 兜底 trial=1)> 全局默认 5(无有效授权)

test(backend): session_quota_test.go 五用例覆盖三分支 + policy 优先级 + trial 兜底

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-04 13:46:59 +08:00
parent 5b9262a166
commit c9e3664d68
3 changed files with 204 additions and 10 deletions
+43 -9
View File
@@ -201,12 +201,27 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo)
jti := uuid.New().String() // 初始 refresh token jti,后续每次续期轮换
now := time.Now()
if err := s.db.Transaction(func(tx *gorm.DB) error {
// 统计该 user 全部平台的活跃会话(不分平台类);已达上限则拒绝
// 同设备重登:先吊销该设备既有活跃会话——重装 / 重复登录不叠占配额
// (历史 bug:同一台手机每次登录都新建会话,很快吃满总配额)。
if dev.DeviceID != "" {
if err := tx.Model(&model.UserSession{}).
Where("shop_id = ? AND user_id = ? AND device_id = ? AND revoked_at IS NULL",
shop.ID, user.ID, dev.DeviceID).
Updates(map[string]any{
"revoked_at": now,
"revoked_reason": "relogin",
}).Error; err != nil {
return err
}
}
// 统计该 user 全部平台的活跃会话(不分平台类);已达上限则拒绝。
// 仅计未过期会话:refresh 已过期的行等待 cleanup 物理删除,不再占配额
// (历史 bug:过期未清理的会话把坑占满,正常用户被锁在门外)。
var activeCount int64
if err := tx.Model(&model.UserSession{}).
Set("gorm:query_option", "FOR UPDATE").
Where("shop_id = ? AND user_id = ? AND revoked_at IS NULL",
shop.ID, user.ID).
Where("shop_id = ? AND user_id = ? AND revoked_at IS NULL AND refresh_exp_at > ?",
shop.ID, user.ID, now).
Count(&activeCount).Error; err != nil {
return err
}
@@ -267,27 +282,46 @@ func (s *AuthService) recordLoginAttempt(shopCode, username string, dev DeviceIn
}
}
// effectiveTotalQuota 返回某店「单用户跨全部平台」的有效并发总配额优先每店
// session_policy["total"] 覆盖,否则全局默认 config.C.Session.LimitTotal。至少为 1
// 避免误配 0 时把所有会话踢光。
// effectiveTotalQuota 返回某店「单用户跨全部平台」的有效并发总配额优先级:
// 1. 每店 session_policy["total"](运维手动覆盖,最高优先);
// 2. 有效授权(is_active 且未到期)的 max_devices —— 配额跟套餐档位走
// (标准版 2 / 高级版 5,见 pay 侧 payPlans);<2 时按 2 兜底,避免
// 试用授权(max_devices=1)把用户锁死在单设备;
// 3. 无有效授权(到期降级 / 老数据)→ 全局默认 config.C.Session.LimitTotal。
//
// 至少为 1,避免误配 0 时把所有会话踢光。
func (s *AuthService) effectiveTotalQuota(shop *model.Shop) int {
q := config.C.Session.LimitTotal
// 1) 每店运维覆盖
if shop.CustomFields != nil {
if raw, ok := shop.CustomFields["session_policy"]; ok {
if policy, ok := raw.(map[string]interface{}); ok {
switch n := policy["total"].(type) {
case float64:
if int(n) > 0 {
q = int(n)
return int(n)
}
case int:
if n > 0 {
q = n
return n
}
}
}
}
}
// 2) 有效授权 → 按套餐设备数(floor 2)
var lic model.License
if err := s.db.
Where("shop_id = ? AND is_active = 1 AND (expires_at IS NULL OR expires_at > ?)",
shop.ID, time.Now()).
Order("expires_at DESC").First(&lic).Error; err == nil {
q := lic.MaxDevices
if q < 2 {
q = 2
}
return q
}
// 3) 全局默认
q := config.C.Session.LimitTotal
if q < 1 {
q = 1
}