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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user