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
}