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
+38 -19
View File
@@ -11,13 +11,14 @@ import (
// DeviceRow mirrors a `devices` table row.
type DeviceRow struct {
ID int64
UUID string
UserID int64
Name string
Platform string
LastSeen sql.NullTime
CreatedAt time.Time
ID int64
UUID string
UserID int64
Name string
Platform string
LastSeen sql.NullTime
CreatedAt time.Time
ClientVersion sql.NullString // 000016: latest reported app version
}
// effSub is an active-or-expired subscription joined with its plan, used by the
@@ -56,7 +57,7 @@ func (s *Store) BeginTx(ctx context.Context) (*sql.Tx, error) {
// ListByUser returns all devices for userID ordered by creation time.
func (s *Store) ListByUser(ctx context.Context, userID int64) ([]DeviceRow, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, uuid, user_id, name, platform, last_seen, created_at
`SELECT id, uuid, user_id, name, platform, last_seen, created_at, client_version
FROM devices WHERE user_id=? ORDER BY created_at ASC`,
userID)
if err != nil {
@@ -67,7 +68,7 @@ func (s *Store) ListByUser(ctx context.Context, userID int64) ([]DeviceRow, erro
var out []DeviceRow
for rows.Next() {
var d DeviceRow
if err := rows.Scan(&d.ID, &d.UUID, &d.UserID, &d.Name, &d.Platform, &d.LastSeen, &d.CreatedAt); err != nil {
if err := rows.Scan(&d.ID, &d.UUID, &d.UserID, &d.Name, &d.Platform, &d.LastSeen, &d.CreatedAt, &d.ClientVersion); err != nil {
return nil, fmt.Errorf("store.ListByUser scan: %w", err)
}
out = append(out, d)
@@ -112,12 +113,13 @@ func (s *Store) countDevicesTx(ctx context.Context, tx *sql.Tx, userID int64) (i
}
// insertDeviceTx inserts a new device row inside tx and returns it.
func (s *Store) insertDeviceTx(ctx context.Context, tx *sql.Tx, uuid string, userID int64, name, platform string) (*DeviceRow, error) {
func (s *Store) insertDeviceTx(ctx context.Context, tx *sql.Tx, uuid string, userID int64, name, platform, clientVersion string) (*DeviceRow, error) {
now := time.Now().UTC()
cv := nullStr(clientVersion)
res, err := tx.ExecContext(ctx,
`INSERT INTO devices (uuid, user_id, name, platform, last_seen, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
uuid, userID, name, platform, now, now)
`INSERT INTO devices (uuid, user_id, name, platform, last_seen, created_at, client_version)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
uuid, userID, name, platform, now, now, cv)
if err != nil {
return nil, fmt.Errorf("store.insertDeviceTx: %w", err)
}
@@ -126,21 +128,38 @@ func (s *Store) insertDeviceTx(ctx context.Context, tx *sql.Tx, uuid string, use
// 设备 API 响应 last_seen=null 与库不一致(集成测试 TestFullChain 据此把关)。
return &DeviceRow{
ID: id, UUID: uuid, UserID: userID, Name: name, Platform: platform,
LastSeen: sql.NullTime{Time: now, Valid: true},
CreatedAt: now,
LastSeen: sql.NullTime{Time: now, Valid: true},
CreatedAt: now,
ClientVersion: sql.NullString{String: clientVersion, Valid: clientVersion != ""},
}, nil
}
// touchLastSeenTx updates a device's last_seen to now inside tx.
func (s *Store) touchLastSeenTx(ctx context.Context, tx *sql.Tx, deviceID int64) error {
_, err := tx.ExecContext(ctx,
`UPDATE devices SET last_seen=? WHERE id=?`, time.Now().UTC(), deviceID)
// touchLastSeenTx updates a device's last_seen to now inside tx, and refreshes
// client_version when a non-empty one is supplied (latest reported wins).
func (s *Store) touchLastSeenTx(ctx context.Context, tx *sql.Tx, deviceID int64, clientVersion string) error {
now := time.Now().UTC()
var err error
if clientVersion != "" {
_, err = tx.ExecContext(ctx,
`UPDATE devices SET last_seen=?, client_version=? WHERE id=?`, now, clientVersion, deviceID)
} else {
_, err = tx.ExecContext(ctx,
`UPDATE devices SET last_seen=? WHERE id=?`, now, deviceID)
}
if err != nil {
return fmt.Errorf("store.touchLastSeenTx: %w", err)
}
return nil
}
// nullStr maps "" to SQL NULL.
func nullStr(s string) any {
if s == "" {
return nil
}
return s
}
// deleteDeviceTx hard-deletes a device row inside tx.
func (s *Store) deleteDeviceTx(ctx context.Context, tx *sql.Tx, deviceID int64) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM devices WHERE id=?`, deviceID); err != nil {