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
@@ -0,0 +1,160 @@
package service
// 会话配额三项修复的回归测试(2026-07-04 S003 登录锁死事故):
// ① 过期未清理的会话不再占配额;② 同设备重登吊销旧会话、不叠占;
// ③ 配额跟套餐档位走(有效授权 max_devicesfloor 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)
// 首登自动发 trialmax_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)
}