fix(server): 强退设备立即离线 — online 须含活跃会话 + 撤销会话不刷 last_seen

trick bug:强退后被踢设备的下一次会话轮询会先刷 last_seen 再发现 active=false,那一刷
把它顶成「在线」直到 90s 窗口过期。修:① online = last_seen 新 且 有未撤销会话
(ActiveSessionDeviceIDs);被强退设备无活跃会话 → 立即离线。② SessionActive 仅在 active
时才 TouchLastSeen,撤销会话的轮询不再刷新。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-30 08:51:10 +08:00
parent 2fbaba7608
commit f0af3bcc94
3 changed files with 43 additions and 2 deletions
+15 -2
View File
@@ -37,6 +37,7 @@ type SessionPort interface {
LastLoginByDevice(ctx context.Context, userID int64) (map[int64]time.Time, error)
RevokeByDevice(ctx context.Context, userID, deviceID int64) ([]string, error)
HasActiveSession(ctx context.Context, userID, deviceID int64) (bool, error)
ActiveSessionDeviceIDs(ctx context.Context, userID int64) (map[int64]bool, error)
}
// JTIRevoker drops a refresh JTI from the Redis whitelist. Satisfied by
@@ -110,14 +111,23 @@ func (svc *Service) ListDevices(ctx context.Context, userID int64) ([]Device, *a
return nil, apierr.ErrInternal
}
var lastLogin map[int64]time.Time
var activeSet map[int64]bool
if svc.sessions != nil {
if m, e := svc.sessions.LastLoginByDevice(ctx, userID); e == nil {
lastLogin = m
}
// 有活跃会话集:online 须同时满足「last_seen 新」+「会话未撤销」,
// 被强退的设备没有活跃会话 → 立即离线(不必等 last_seen 窗口过期)。
if m, e := svc.sessions.ActiveSessionDeviceIDs(ctx, userID); e == nil {
activeSet = m
}
}
out := make([]Device, 0, len(rows))
for _, r := range rows {
d := toAPIDevice(r)
if activeSet != nil && !activeSet[r.ID] {
d.Online = false // 无未撤销会话 → 强制离线(activeSet 为 nil 时不干预,fail-open)
}
if t, ok := lastLogin[r.ID]; ok {
s := t.UTC().Format(time.RFC3339)
d.LastLogin = &s
@@ -361,12 +371,15 @@ func (svc *Service) SessionActive(ctx context.Context, userID int64, deviceUUID
if dev == nil || dev.UserID != userID {
return true, nil // 未知 / 非本人设备:不据此登出
}
// 设备在轮询 = app 正在运行:刷 last_seen,让「在线」状态准确(不再只反映上次连接)。
_ = svc.store.TouchLastSeen(ctx, dev.ID)
active, err := svc.sessions.HasActiveSession(ctx, userID, dev.ID)
if err != nil {
return false, apierr.ErrInternal
}
// 仅活跃会话才刷 last_seen(=app 在运行且未被踢)。被强退设备的轮询不刷,
// 否则那一刷会把它顶成「在线」直到窗口过期(本问题的根因)。
if active {
_ = svc.store.TouchLastSeen(ctx, dev.ID)
}
return active, nil
}
+22
View File
@@ -61,6 +61,28 @@ func (s *Store) HasActiveSession(ctx context.Context, userID, deviceID int64) (b
return n > 0, nil
}
// ActiveSessionDeviceIDs returns the set of the user's devices that have at least
// one non-revoked session. Used to mark a device "online" only while it has a live
// session — so a force-logged-out device drops offline immediately, not only after
// its last_seen window lapses.
func (s *Store) ActiveSessionDeviceIDs(ctx context.Context, userID int64) (map[int64]bool, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT DISTINCT device_id FROM sessions WHERE user_id=? AND revoked_at IS NULL`, userID)
if err != nil {
return nil, fmt.Errorf("sessions.ActiveSessionDeviceIDs: %w", err)
}
defer rows.Close()
set := make(map[int64]bool)
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("sessions.ActiveSessionDeviceIDs scan: %w", err)
}
set[id] = true
}
return set, rows.Err()
}
// Revoke marks the session with the given JTI revoked (logout). Idempotent.
func (s *Store) Revoke(ctx context.Context, jti string) error {
now := time.Now().UTC()
@@ -87,12 +87,18 @@ func TestSQLite_SessionHasActiveSession(t *testing.T) {
if ok, err := ss.HasActiveSession(ctx, uid, did); err != nil || !ok {
t.Fatalf("建会话后应活跃: ok=%v err=%v", ok, err)
}
if set, err := ss.ActiveSessionDeviceIDs(ctx, uid); err != nil || !set[did] {
t.Fatalf("活跃设备集应含 %d: set=%v err=%v", did, set, err)
}
if _, err := ss.RevokeByDevice(ctx, uid, did); err != nil {
t.Fatalf("revoke: %v", err)
}
if ok, err := ss.HasActiveSession(ctx, uid, did); err != nil || ok {
t.Fatalf("强制退出后应非活跃: ok=%v err=%v", ok, err)
}
if set, err := ss.ActiveSessionDeviceIDs(ctx, uid); err != nil || set[did] {
t.Fatalf("强退后活跃设备集不应含 %d(→立即离线): set=%v err=%v", did, set, err)
}
}
func TestSQLite_NodeAccumulateUsage(t *testing.T) {