feat(devices): P2 sessions 表 + 在线/最后登录/客户端版本
ci-pangolin / Lint — shellcheck (push) Successful in 8s
ci-pangolin / OpenAPI Sync Check (push) Successful in 18s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 6s
ci-pangolin / Flutter — analyze + test (push) Successful in 24s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 5s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 4s
ci-pangolin / Go — build + test (push) Successful in 11s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Successful in 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m13s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 14s

migration 000016(mysql+sqlite,含 down):新增 sessions 表(绑 device+refresh JTI)
+ devices 加 client_version/totp_trusted_until。devices 唯一键改 + platform CHECK
加 linux(需 SQLite 表重建)拆出后续迁移,降风险。

后端:新 internal/sessions Store(Create/Rotate/Revoke/RevokeByDevice/
LastLoginByDevice);TokenManager 外露 refresh JTI(IssueWithJTI/RefreshWithJTI/
ParseRefreshJTI);auth.Service 注入 SessionStore——登录建会话、刷新轮换、登出吊销;
DeviceRegistrar 返回 deviceID;ReportUsage 心跳 touch devices.last_seen(在线判定);
devices.ListDevices 经 LastLoginSource 注入返回 online(last_seen<3min)/client_version/
last_login;RegisterIfAbsent 存 client_version。
客户端:Device model 加 online/clientVersion/lastLogin(fromJson 自动解析)。
测试:sessions store 3 例 + ListDevices 在线/最后登录 + device model 2 例 +
migration v16;全量 go test/flutter test 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-29 00:50:24 +08:00
parent 8370ee1eb7
commit 2f298f0a0a
23 changed files with 709 additions and 159 deletions
+8
View File
@@ -40,6 +40,7 @@ type mockNodeStore struct {
// devicesByDpUUID maps a per-device dp_uuid → {userID, deviceID}.
devicesByDpUUID map[string][2]int64
deviceUsageAccum []mockDeviceUsageEntry
lastSeenTouched []int64
}
type mockUsageEntry struct {
@@ -147,6 +148,13 @@ func (m *mockNodeStore) AccumulateDeviceUsage(_ context.Context, userID, deviceI
return nil
}
func (m *mockNodeStore) TouchDeviceLastSeen(_ context.Context, deviceID int64) error {
m.mu.Lock()
defer m.mu.Unlock()
m.lastSeenTouched = append(m.lastSeenTouched, deviceID)
return nil
}
func (m *mockNodeStore) deviceUsageLog() []mockDeviceUsageEntry {
m.mu.Lock()
defer m.mu.Unlock()
+5
View File
@@ -318,6 +318,11 @@ func (h *Handler) ReportUsage(ctx context.Context, req *agentv1.UsageReport) (*a
slog.Warn("nodes.Handler.ReportUsage: device accumulate failed",
"user_id", userID, "device_id", deviceID, "err", err)
}
// Heartbeat: keep the device's "online" status fresh while it reports.
if err := h.store.TouchDeviceLastSeen(ctx, deviceID); err != nil {
slog.Warn("nodes.Handler.ReportUsage: touch last_seen failed",
"device_id", deviceID, "err", err)
}
}
}
return &agentv1.UsageAck{}, nil
+14
View File
@@ -98,6 +98,10 @@ type NodeStore interface {
// (deviceID, date); user_id is carried for per-account rollups/queries.
AccumulateDeviceUsage(ctx context.Context, userID, deviceID int64, date time.Time,
bytesUp, bytesDown int64, minutes int64) error
// TouchDeviceLastSeen bumps devices.last_seen to now for an active device,
// driving the "online" status (connect + periodic usage reports refresh it).
TouchDeviceLastSeen(ctx context.Context, deviceID int64) error
}
// SQLNodeStore implements NodeStore against a SQL database (MySQL or SQLite).
@@ -396,6 +400,16 @@ func (s *SQLNodeStore) AccumulateDeviceUsage(
return nil
}
// TouchDeviceLastSeen bumps devices.last_seen to now (online-status heartbeat).
func (s *SQLNodeStore) TouchDeviceLastSeen(ctx context.Context, deviceID int64) error {
if _, err := s.db.ExecContext(ctx,
`UPDATE devices SET last_seen=? WHERE id=?`, time.Now().UTC(), deviceID,
); err != nil {
return fmt.Errorf("nodes.SQLNodeStore.TouchDeviceLastSeen: %w", err)
}
return nil
}
// AccountDayBytes returns the account's total bytes (up+down) for the day.
func (s *SQLNodeStore) AccountDayBytes(ctx context.Context, userID int64, date time.Time) (int64, error) {
var total sql.NullInt64