feat: 近实时远程下线 — 客户端轮询会话有效性(~15s),服务端 GET /v1/me/session

控制面无推送通道,access token 是无状态 JWT(15min),强制退出后被踢设备要等 token 过期
(≤15min)才登出。改成客户端每 15s 轮询会话是否仍有效,被强制退出即登出 → 延迟压到 ~15s。
- 服务端:sessions.HasActiveSession(user,device) + devices.SessionActive(按 UUID,fail-open)
  + GET /v1/me/session?device_id= 返回 {active}(恒 200,判据在 body)。无新迁移。
- 客户端:account_api.sessionActive + main.dart _RootFlowState 15s 轮询,active=false 即 logout
  (网络/鉴权异常不据此登出,fail-safe)。
- 测试:TestSQLite_SessionHasActiveSession(建会话=活跃→RevokeByDevice→非活跃)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-30 07:25:16 +08:00
parent 7f070a0693
commit 0b33f12400
6 changed files with 143 additions and 1 deletions
@@ -12,6 +12,7 @@ import (
"github.com/wangjia/pangolin/server/internal/nodes"
agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1"
"github.com/wangjia/pangolin/server/internal/provision"
"github.com/wangjia/pangolin/server/internal/sessions"
"github.com/wangjia/pangolin/server/internal/store"
"github.com/wangjia/pangolin/server/internal/usage"
)
@@ -57,6 +58,43 @@ func TestSQLite_UsageAccumulate(t *testing.T) {
}
}
// HasActiveSession 是「近实时远程下线」的服务端判据:有非吊销会话=在线,
// 强制退出(RevokeByDevice)后=离线 → 客户端轮询到即登出。
func TestSQLite_SessionHasActiveSession(t *testing.T) {
ctx := context.Background()
db := openSQLite(t)
// FK 前置:user + device。
res, err := db.ExecContext(ctx,
`INSERT INTO users (uuid, email, pw_hash, dp_uuid) VALUES ('u-1','a@b.c','h','dp-1')`)
if err != nil {
t.Fatalf("user: %v", err)
}
uid, _ := res.LastInsertId()
res, err = db.ExecContext(ctx,
`INSERT INTO devices (uuid, user_id, name, platform) VALUES ('d-1', ?, 'Mac', 'macos')`, uid)
if err != nil {
t.Fatalf("device: %v", err)
}
did, _ := res.LastInsertId()
ss := sessions.NewStore(db)
if ok, err := ss.HasActiveSession(ctx, uid, did); err != nil || ok {
t.Fatalf("无会话应为非活跃: ok=%v err=%v", ok, err)
}
if err := ss.Create(ctx, uid, did, "jti-1", "1.2.3.4", "v1.0"); err != nil {
t.Fatalf("create: %v", err)
}
if ok, err := ss.HasActiveSession(ctx, uid, did); err != nil || !ok {
t.Fatalf("建会话后应活跃: ok=%v err=%v", ok, 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)
}
}
func TestSQLite_NodeAccumulateUsage(t *testing.T) {
ctx := context.Background()
db := openSQLite(t)