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
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:
@@ -187,7 +187,7 @@ func TestFullChain(t *testing.T) {
|
||||
in := devices.RegisterInput{UserID: userID, DeviceUUID: devUUID, Name: "iPhone 15 Pro", Platform: "ios", MaxDevices: plan.MaxDevices}
|
||||
|
||||
// First sight → insert.
|
||||
d1, apiErr := svc.RegisterIfAbsent(ctx, in)
|
||||
_, d1, apiErr := svc.RegisterIfAbsent(ctx, in)
|
||||
if apiErr != nil {
|
||||
t.Fatalf("RegisterIfAbsent: %v", apiErr)
|
||||
}
|
||||
@@ -196,7 +196,7 @@ func TestFullChain(t *testing.T) {
|
||||
}
|
||||
|
||||
// Second sight → idempotent (no new row), last_seen refreshed.
|
||||
if _, apiErr := svc.RegisterIfAbsent(ctx, in); apiErr != nil {
|
||||
if _, _, apiErr := svc.RegisterIfAbsent(ctx, in); apiErr != nil {
|
||||
t.Fatalf("re-register: %v", apiErr)
|
||||
}
|
||||
list, apiErr := svc.ListDevices(ctx, userID)
|
||||
@@ -245,12 +245,12 @@ func TestDeviceLimitEnforced(t *testing.T) {
|
||||
}
|
||||
|
||||
first := devices.RegisterInput{UserID: userID, DeviceUUID: newUUID(t, db), Name: "Pixel", Platform: "android", MaxDevices: 1}
|
||||
if _, apiErr := svc.RegisterIfAbsent(ctx, first); apiErr != nil {
|
||||
if _, _, apiErr := svc.RegisterIfAbsent(ctx, first); apiErr != nil {
|
||||
t.Fatalf("first register: %v", apiErr)
|
||||
}
|
||||
|
||||
second := devices.RegisterInput{UserID: userID, DeviceUUID: newUUID(t, db), Name: "iPad", Platform: "ios", MaxDevices: 1}
|
||||
_, apiErr := svc.RegisterIfAbsent(ctx, second)
|
||||
_, _, apiErr := svc.RegisterIfAbsent(ctx, second)
|
||||
if apiErr == nil {
|
||||
t.Fatal("expected second register to be rejected")
|
||||
}
|
||||
@@ -269,7 +269,7 @@ func TestDeleteOthersDevice(t *testing.T) {
|
||||
other := createUser(t, db, "other@example.com", "active")
|
||||
|
||||
devUUID := newUUID(t, db)
|
||||
if _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{
|
||||
if _, _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{
|
||||
UserID: owner, DeviceUUID: devUUID, Name: "Mac", Platform: "macos", MaxDevices: 5,
|
||||
}); apiErr != nil {
|
||||
t.Fatalf("register: %v", apiErr)
|
||||
@@ -308,7 +308,7 @@ func TestHTTPHandlers(t *testing.T) {
|
||||
giveSubscription(t, db, userID, "pro", "code", time.Now().UTC().Add(30*24*time.Hour))
|
||||
|
||||
devUUID := newUUID(t, db)
|
||||
if _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{
|
||||
if _, _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{
|
||||
UserID: userID, DeviceUUID: devUUID, Name: "Win", Platform: "windows", MaxDevices: 5,
|
||||
}); apiErr != nil {
|
||||
t.Fatalf("register: %v", apiErr)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user