From c9e3664d68fa4f8247ca0d7f5f2c26a57239dc27 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 4 Jul 2026 13:46:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=E7=99=BB=E5=BD=95=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E9=85=8D=E9=A2=9D=E4=B8=89=E4=BF=AE=E5=A4=8D=E2=80=94?= =?UTF-8?q?=E2=80=94=E8=BF=87=E6=9C=9F=E4=BC=9A=E8=AF=9D=E4=B8=8D=E5=8D=A0?= =?UTF-8?q?=E5=9D=91/=E5=90=8C=E8=AE=BE=E5=A4=87=E9=87=8D=E7=99=BB?= =?UTF-8?q?=E4=B8=8D=E5=8F=A0=E5=8A=A0/=E9=85=8D=E9=A2=9D=E8=B7=9F?= =?UTF-8?q?=E5=A5=97=E9=A4=90=E6=A1=A3=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/internal/model/user_session.go | 2 +- backend/internal/service/auth.go | 52 +++++- .../internal/service/session_quota_test.go | 160 ++++++++++++++++++ 3 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 backend/internal/service/session_quota_test.go diff --git a/backend/internal/model/user_session.go b/backend/internal/model/user_session.go index 138f69a..f267ade 100644 --- a/backend/internal/model/user_session.go +++ b/backend/internal/model/user_session.go @@ -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"` } diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index a1d6bc6..fe374d7 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -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 } diff --git a/backend/internal/service/session_quota_test.go b/backend/internal/service/session_quota_test.go new file mode 100644 index 0000000..39d6ce4 --- /dev/null +++ b/backend/internal/service/session_quota_test.go @@ -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) +}