diff --git a/server/internal/devices/service.go b/server/internal/devices/service.go index 035feca..b188ed7 100644 --- a/server/internal/devices/service.go +++ b/server/internal/devices/service.go @@ -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 } diff --git a/server/internal/sessions/store.go b/server/internal/sessions/store.go index ec47b99..8f76c17 100644 --- a/server/internal/sessions/store.go +++ b/server/internal/sessions/store.go @@ -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() diff --git a/server/internal/store/sqlite_stores_test.go b/server/internal/store/sqlite_stores_test.go index ca1e105..9e6ce80 100644 --- a/server/internal/store/sqlite_stores_test.go +++ b/server/internal/store/sqlite_stores_test.go @@ -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) {