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:
@@ -24,7 +24,7 @@ type UserSession struct {
|
||||
// 否则 revoke/cleanup 等任意 Updates 都会把已撤销会话误刷成「刚活跃」。
|
||||
LastSeenAt time.Time `gorm:"autoCreateTime" json:"last_seen_at"`
|
||||
RevokedAt *time.Time `gorm:"index" json:"revoked_at,omitempty"`
|
||||
RevokedReason string `gorm:"size:30" json:"revoked_reason,omitempty"` // kicked|logout|admin|disabled|reuse|pwd_reset
|
||||
RevokedReason string `gorm:"size:30" json:"revoked_reason,omitempty"` // kicked|logout|admin|disabled|reuse|pwd_reset|relogin
|
||||
RevokedBy *uint64 `gorm:"column:revoked_by" json:"revoked_by,omitempty"` // 吊销操作人 user_id;系统/自助吊销为 NULL
|
||||
RefreshExpAt time.Time `json:"refresh_exp_at"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package service
|
||||
|
||||
// 会话配额三项修复的回归测试(2026-07-04 S003 登录锁死事故):
|
||||
// ① 过期未清理的会话不再占配额;② 同设备重登吊销旧会话、不叠占;
|
||||
// ③ 配额跟套餐档位走(有效授权 max_devices,floor 2;运维 session_policy 最优先)。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/testutil"
|
||||
)
|
||||
|
||||
// 过期会话不占坑:配额被「refresh 已过期但未撤销」的行占满时,登录应放行。
|
||||
func TestLogin_ExpiredSessionNotCounted(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
old := config.C.Session.LimitTotal
|
||||
config.C.Session.LimitTotal = 2
|
||||
defer func() { config.C.Session.LimitTotal = old }()
|
||||
|
||||
shop := testutil.CreateTestShop(db, "QUOTA1")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
svc := NewAuthService(db)
|
||||
|
||||
// 直接写入 2 条已过期(refresh_exp_at 在过去)但 revoked_at 为空的会话
|
||||
past := time.Now().Add(-time.Hour)
|
||||
for _, sid := range []string{"stale-1", "stale-2"} {
|
||||
require.NoError(t, db.Create(&model.UserSession{
|
||||
ShopID: shop.ID, UserID: user.ID, SID: sid,
|
||||
DeviceID: sid, Platform: "macos", PlatformClass: "desktop",
|
||||
RefreshExpAt: past,
|
||||
}).Error)
|
||||
}
|
||||
|
||||
_, _, err := svc.Login("QUOTA1", "admin", "password123",
|
||||
DeviceInfo{DeviceID: "fresh-dev", Platform: "ios"})
|
||||
assert.NoError(t, err, "过期会话不应计入配额")
|
||||
}
|
||||
|
||||
// 同设备重登:旧会话被吊销(reason=relogin),配额只占一个坑。
|
||||
func TestLogin_SameDeviceReloginReusesSlot(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
old := config.C.Session.LimitTotal
|
||||
config.C.Session.LimitTotal = 2
|
||||
defer func() { config.C.Session.LimitTotal = old }()
|
||||
|
||||
shop := testutil.CreateTestShop(db, "QUOTA2")
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
svc := NewAuthService(db)
|
||||
|
||||
dev := DeviceInfo{DeviceID: "iphone-1", Platform: "ios"}
|
||||
_, _, err := svc.Login("QUOTA2", "admin", "password123", dev)
|
||||
require.NoError(t, err)
|
||||
// 同一设备再登两次都应成功(每次吊销上一条,不叠加)
|
||||
_, _, err = svc.Login("QUOTA2", "admin", "password123", dev)
|
||||
require.NoError(t, err)
|
||||
_, _, err = svc.Login("QUOTA2", "admin", "password123", dev)
|
||||
require.NoError(t, err)
|
||||
|
||||
var active int64
|
||||
db.Model(&model.UserSession{}).
|
||||
Where("shop_id = ? AND device_id = ? AND revoked_at IS NULL", shop.ID, "iphone-1").
|
||||
Count(&active)
|
||||
assert.Equal(t, int64(1), active, "同设备只保留一条活跃会话")
|
||||
|
||||
var relogin int64
|
||||
db.Model(&model.UserSession{}).
|
||||
Where("shop_id = ? AND revoked_reason = ?", shop.ID, "relogin").
|
||||
Count(&relogin)
|
||||
assert.Equal(t, int64(2), relogin)
|
||||
|
||||
// 另一台设备占第 2 坑成功,第 3 台被拒
|
||||
_, _, err = svc.Login("QUOTA2", "admin", "password123",
|
||||
DeviceInfo{DeviceID: "mac-1", Platform: "macos"})
|
||||
require.NoError(t, err)
|
||||
_, _, err = svc.Login("QUOTA2", "admin", "password123",
|
||||
DeviceInfo{DeviceID: "web-1", Platform: "web"})
|
||||
assert.ErrorIs(t, err, ErrDeviceLimitReached)
|
||||
}
|
||||
|
||||
// 配额跟套餐走:有效授权 max_devices=2(标准版)时,即使全局默认 5,第 3 台设备被拒。
|
||||
func TestLogin_QuotaFollowsLicenseMaxDevices(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
old := config.C.Session.LimitTotal
|
||||
config.C.Session.LimitTotal = 5
|
||||
defer func() { config.C.Session.LimitTotal = old }()
|
||||
|
||||
shop := testutil.CreateTestShop(db, "QUOTA3")
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
// 预置有效标准版授权(max_devices=2),登录时不再触发 auto-trial
|
||||
future := time.Now().Add(30 * 24 * time.Hour)
|
||||
require.NoError(t, db.Create(&model.License{
|
||||
ShopID: shop.ID, LicenseKey: "STD-QUOTA3", Type: "annual", Tier: "standard",
|
||||
ExpiresAt: &future, IsActive: true, MaxDevices: 2,
|
||||
}).Error)
|
||||
svc := NewAuthService(db)
|
||||
|
||||
_, _, err := svc.Login("QUOTA3", "admin", "password123",
|
||||
DeviceInfo{DeviceID: "d1", Platform: "macos"})
|
||||
require.NoError(t, err)
|
||||
_, _, err = svc.Login("QUOTA3", "admin", "password123",
|
||||
DeviceInfo{DeviceID: "d2", Platform: "ios"})
|
||||
require.NoError(t, err)
|
||||
_, _, err = svc.Login("QUOTA3", "admin", "password123",
|
||||
DeviceInfo{DeviceID: "d3", Platform: "web"})
|
||||
assert.ErrorIs(t, err, ErrDeviceLimitReached)
|
||||
}
|
||||
|
||||
// 试用授权 max_devices=1 → floor 2 兜底:第二台设备仍可登录,第三台被拒。
|
||||
func TestLogin_TrialQuotaFloorTwo(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
old := config.C.Session.LimitTotal
|
||||
config.C.Session.LimitTotal = 5
|
||||
defer func() { config.C.Session.LimitTotal = old }()
|
||||
|
||||
shop := testutil.CreateTestShop(db, "QUOTA4")
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
svc := NewAuthService(db)
|
||||
|
||||
// 首登自动发 trial(max_devices=1)→ 配额兜底 2
|
||||
_, _, err := svc.Login("QUOTA4", "admin", "password123",
|
||||
DeviceInfo{DeviceID: "d1", Platform: "macos"})
|
||||
require.NoError(t, err)
|
||||
_, _, err = svc.Login("QUOTA4", "admin", "password123",
|
||||
DeviceInfo{DeviceID: "d2", Platform: "ios"})
|
||||
require.NoError(t, err, "trial 兜底 2 台")
|
||||
_, _, err = svc.Login("QUOTA4", "admin", "password123",
|
||||
DeviceInfo{DeviceID: "d3", Platform: "web"})
|
||||
assert.ErrorIs(t, err, ErrDeviceLimitReached)
|
||||
}
|
||||
|
||||
// 运维 session_policy.total 覆盖最优先:高于授权档位也生效。
|
||||
func TestLogin_PolicyOverridesLicenseQuota(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "QUOTA5")
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
future := time.Now().Add(30 * 24 * time.Hour)
|
||||
require.NoError(t, db.Create(&model.License{
|
||||
ShopID: shop.ID, LicenseKey: "STD-QUOTA5", Type: "annual", Tier: "standard",
|
||||
ExpiresAt: &future, IsActive: true, MaxDevices: 2,
|
||||
}).Error)
|
||||
// 运维放宽到 3
|
||||
require.NoError(t, db.Model(&model.Shop{}).Where("id = ?", shop.ID).
|
||||
Update("custom_fields", model.JSON{"session_policy": map[string]interface{}{"total": 3}}).Error)
|
||||
svc := NewAuthService(db)
|
||||
|
||||
for i, d := range []string{"d1", "d2", "d3"} {
|
||||
_, _, err := svc.Login("QUOTA5", "admin", "password123",
|
||||
DeviceInfo{DeviceID: d, Platform: "macos"})
|
||||
require.NoError(t, err, "第 %d 台应放行(policy total=3)", i+1)
|
||||
}
|
||||
_, _, err := svc.Login("QUOTA5", "admin", "password123",
|
||||
DeviceInfo{DeviceID: "d4", Platform: "ios"})
|
||||
assert.ErrorIs(t, err, ErrDeviceLimitReached)
|
||||
}
|
||||
Reference in New Issue
Block a user