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
+67 -32
View File
@@ -43,10 +43,17 @@ func (n *NoopRevoker) RevokeForUser(_ context.Context, userID int64, reason stri
return nil
}
// LastLoginSource provides per-device last-login times (most recent session
// created_at). Satisfied by sessions.Store; injected to keep packages decoupled.
type LastLoginSource interface {
LastLoginByDevice(ctx context.Context, userID int64) (map[int64]time.Time, error)
}
// Service implements the devices business logic.
type Service struct {
store *Store
revoker CredentialRevoker
store *Store
revoker CredentialRevoker
lastLogin LastLoginSource // nil until wired (P2)
}
// NewService creates a Service. If revoker is nil a NoopRevoker is used so the
@@ -58,32 +65,58 @@ func NewService(store *Store, revoker CredentialRevoker) *Service {
return &Service{store: store, revoker: revoker}
}
// SetLastLoginSource wires the per-device last-login source (sessions.Store).
func (svc *Service) SetLastLoginSource(s LastLoginSource) { svc.lastLogin = s }
// Device is the API representation of a registered device.
type Device struct {
UUID string `json:"uuid"`
Name string `json:"name"`
Platform string `json:"platform"`
LastSeen *string `json:"last_seen"` // RFC 3339 UTC; null when never seen
UUID string `json:"uuid"`
Name string `json:"name"`
Platform string `json:"platform"`
LastSeen *string `json:"last_seen"` // RFC 3339 UTC; null when never seen
ClientVersion string `json:"client_version,omitempty"` // 000016
Online bool `json:"online"` // last_seen within onlineWindow (data-plane active)
LastLogin *string `json:"last_login"` // most recent session created_at; null when none
}
// onlineWindow: a device is "online" if its last_seen (touched by connect +
// periodic usage reports) is within this window.
const onlineWindow = 3 * time.Minute
func toAPIDevice(d DeviceRow) Device {
out := Device{UUID: d.UUID, Name: d.Name, Platform: d.Platform}
if d.LastSeen.Valid {
s := d.LastSeen.Time.UTC().Format(time.RFC3339)
out.LastSeen = &s
out.Online = time.Since(d.LastSeen.Time) < onlineWindow
}
if d.ClientVersion.Valid {
out.ClientVersion = d.ClientVersion.String
}
return out
}
// ListDevices returns the user's devices.
// ListDevices returns the user's devices with online status (from last_seen) and
// last-login time (most recent session, when a LastLoginSource is wired).
func (svc *Service) ListDevices(ctx context.Context, userID int64) ([]Device, *apierr.Error) {
rows, err := svc.store.ListByUser(ctx, userID)
if err != nil {
return nil, apierr.ErrInternal
}
var lastLogin map[int64]time.Time
if svc.lastLogin != nil {
if m, e := svc.lastLogin.LastLoginByDevice(ctx, userID); e == nil {
lastLogin = m
}
}
out := make([]Device, 0, len(rows))
for _, r := range rows {
out = append(out, toAPIDevice(r))
d := toAPIDevice(r)
if t, ok := lastLogin[r.ID]; ok {
s := t.UTC().Format(time.RFC3339)
d.LastLogin = &s
}
out = append(out, d)
}
return out, nil
}
@@ -91,31 +124,33 @@ func (svc *Service) ListDevices(ctx context.Context, userID int64) ([]Device, *a
// RegisterInput carries the inputs for implicit device registration, called by
// nodes.connect when a client presents a device_id.
type RegisterInput struct {
UserID int64
DeviceUUID string
Name string // client-reported, may come from UA; truncated to 64 runes
Platform string // ios | android | windows | macos
MaxDevices int // plan cap, from the resolved Plan (PlanFromCtx)
UserID int64
DeviceUUID string
Name string // client-reported, may come from UA; truncated to 64 runes
Platform string // ios | android | windows | macos | linux
ClientVersion string // app version, stored/refreshed on devices.client_version
MaxDevices int // plan cap; 0 = no cap
}
// RegisterIfAbsent registers a device on first sight and refreshes last_seen on
// subsequent sights. The device count is checked against MaxDevices before
// inserting a brand-new device. Per-user serialization is achieved by locking
// the users row for the duration of the transaction.
func (svc *Service) RegisterIfAbsent(ctx context.Context, in RegisterInput) (*Device, *apierr.Error) {
// Returns the internal device id (for session binding), the API device, or an error.
func (svc *Service) RegisterIfAbsent(ctx context.Context, in RegisterInput) (int64, *Device, *apierr.Error) {
uuid := strings.TrimSpace(in.DeviceUUID)
if uuid == "" {
return nil, apierr.ErrBadRequest
return 0, nil, apierr.ErrBadRequest
}
platform, ok := normalizePlatform(in.Platform)
if !ok {
return nil, apierr.ErrBadRequest
return 0, nil, apierr.ErrBadRequest
}
name := normalizeName(in.Name, platform)
tx, err := svc.store.BeginTx(ctx)
if err != nil {
return nil, apierr.ErrInternal
return 0, nil, apierr.ErrInternal
}
committed := false
defer func() {
@@ -127,48 +162,48 @@ func (svc *Service) RegisterIfAbsent(ctx context.Context, in RegisterInput) (*De
// Lock the owning user to serialize concurrent registrations.
exists, status, err := svc.store.lockUser(ctx, tx, in.UserID)
if err != nil {
return nil, apierr.ErrInternal
return 0, nil, apierr.ErrInternal
}
if !exists {
return nil, apierr.ErrUnauthorized
return 0, nil, apierr.ErrUnauthorized
}
if status == "banned" {
return nil, apierr.ErrAccountBanned
return 0, nil, apierr.ErrAccountBanned
}
existing, err := svc.store.findDeviceByUUIDTx(ctx, tx, uuid)
if err != nil {
return nil, apierr.ErrInternal
return 0, nil, apierr.ErrInternal
}
if existing != nil {
if existing.UserID != in.UserID {
// UUID is client-generated; a collision across users is treated as
// a conflict rather than silently rebinding the device.
return nil, apierr.ErrForbidden
return 0, nil, apierr.ErrForbidden
}
if err := svc.store.touchLastSeenTx(ctx, tx, existing.ID); err != nil {
return nil, apierr.ErrInternal
if err := svc.store.touchLastSeenTx(ctx, tx, existing.ID, in.ClientVersion); err != nil {
return 0, nil, apierr.ErrInternal
}
if err := tx.Commit(); err != nil {
return nil, apierr.ErrInternal
return 0, nil, apierr.ErrInternal
}
committed = true
d := toAPIDevice(*existing)
return &d, nil
return existing.ID, &d, nil
}
// New device: enforce the plan device cap.
count, err := svc.store.countDevicesTx(ctx, tx, in.UserID)
if err != nil {
return nil, apierr.ErrInternal
return 0, nil, apierr.ErrInternal
}
if in.MaxDevices > 0 && count >= in.MaxDevices {
return nil, errDeviceLimit(in.MaxDevices)
return 0, nil, errDeviceLimit(in.MaxDevices)
}
row, err := svc.store.insertDeviceTx(ctx, tx, uuid, in.UserID, name, platform)
row, err := svc.store.insertDeviceTx(ctx, tx, uuid, in.UserID, name, platform, in.ClientVersion)
if err != nil {
return nil, apierr.ErrInternal
return 0, nil, apierr.ErrInternal
}
if err := svc.store.writeAuditLogTx(ctx, tx,
fmt.Sprintf("user:%d", in.UserID), "device.register", "device:"+uuid,
@@ -176,12 +211,12 @@ func (svc *Service) RegisterIfAbsent(ctx context.Context, in RegisterInput) (*De
_ = err // audit failure must not abort the business transaction
}
if err := tx.Commit(); err != nil {
return nil, apierr.ErrInternal
return 0, nil, apierr.ErrInternal
}
committed = true
d := toAPIDevice(*row)
return &d, nil
return row.ID, &d, nil
}
// DeleteDevice hard-deletes a device (transactionally, with an audit_log entry)