package devices import ( "context" "database/sql" "fmt" "time" dbx "github.com/wangjia/pangolin/server/internal/db" ) // 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 ClientVersion sql.NullString // 000016: latest reported app version DpUUID sql.NullString // per-device data-plane credential (000015) } // effSub is an active-or-expired subscription joined with its plan, used by the // pure plan resolver. expiry filtering is performed in Go (resolveEffectivePlan) // so the UTC boundary logic is unit-testable without a database. type effSub struct { PlanCode string MaxDevices int DailyMinutes sql.NullInt64 AdGate bool ExpiresAt time.Time Source string } // Store wraps a *sql.DB and exposes the database operations the devices module // needs. Methods that take a *sql.Tx run within that transaction. type Store struct { db *sql.DB dialect dbx.Dialect } // NewStore creates a Store backed by the given connection pool (MySQL or SQLite). func NewStore(db *sql.DB) *Store { return &Store{db: db, dialect: dbx.DialectForDB(db)} } // BeginTx starts a transaction at Read Committed isolation. Per-user // serialization for device mutations is achieved by locking the users row // (SELECT ... FOR UPDATE) inside the transaction. func (s *Store) BeginTx(ctx context.Context) (*sql.Tx, error) { return s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) } // -------------------------------------------------------------------------- // Device queries // -------------------------------------------------------------------------- // 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, client_version FROM devices WHERE user_id=? ORDER BY created_at ASC`, userID) if err != nil { return nil, fmt.Errorf("store.ListByUser: %w", err) } defer rows.Close() 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, &d.ClientVersion); err != nil { return nil, fmt.Errorf("store.ListByUser scan: %w", err) } out = append(out, d) } return out, rows.Err() } // findDeviceByUserUUIDTx looks up the user's device by UUID with FOR UPDATE // inside tx. Returns (nil, nil) when the device does not exist for this user. // 必须带 user_id:唯一键是 UNIQUE(user_id,uuid)(migration 21),同一物理设备的 // uuid 可在多个账号下各有一行,全局按 uuid 查会歧义。 func (s *Store) findDeviceByUserUUIDTx(ctx context.Context, tx *sql.Tx, userID int64, uuid string) (*DeviceRow, error) { row := tx.QueryRowContext(ctx, `SELECT id, uuid, user_id, name, platform, last_seen, created_at, client_version, dp_uuid FROM devices WHERE user_id=? AND uuid=? `+s.dialect.LockForUpdate(), userID, uuid) return scanDeviceRow(row) } // FindByUserUUID looks up the user's device by UUID (non-tx). Returns (nil, nil) // if absent for this user. Used by force-logout/delete/rename/session-poll to // resolve the device row; other users' rows with the same uuid are invisible. func (s *Store) FindByUserUUID(ctx context.Context, userID int64, uuid string) (*DeviceRow, error) { row := s.db.QueryRowContext(ctx, `SELECT id, uuid, user_id, name, platform, last_seen, created_at, client_version, dp_uuid FROM devices WHERE user_id=? AND uuid=?`, userID, uuid) return scanDeviceRow(row) } type rowScanner interface { Scan(dest ...any) error } func scanDeviceRow(row rowScanner) (*DeviceRow, error) { var d DeviceRow if err := row.Scan(&d.ID, &d.UUID, &d.UserID, &d.Name, &d.Platform, &d.LastSeen, &d.CreatedAt, &d.ClientVersion, &d.DpUUID); err == sql.ErrNoRows { return nil, nil } else if err != nil { return nil, fmt.Errorf("store.scanDeviceRow: %w", err) } return &d, nil } // lockUser locks the users row to serialize per-user device mutations and // returns the user's status. Returns (false, "", nil) when the user is absent. func (s *Store) lockUser(ctx context.Context, tx *sql.Tx, userID int64) (exists bool, status string, err error) { row := tx.QueryRowContext(ctx, `SELECT status FROM users WHERE id=? `+s.dialect.LockForUpdate(), userID) if e := row.Scan(&status); e == sql.ErrNoRows { return false, "", nil } else if e != nil { return false, "", fmt.Errorf("store.lockUser: %w", e) } return true, status, nil } // countDevicesTx counts a user's devices inside tx. func (s *Store) countDevicesTx(ctx context.Context, tx *sql.Tx, userID int64) (int, error) { var n int if err := tx.QueryRowContext(ctx, `SELECT COUNT(1) FROM devices WHERE user_id=?`, userID).Scan(&n); err != nil { return 0, fmt.Errorf("store.countDevicesTx: %w", err) } return n, nil } // CountActiveDevices counts the user's devices seen within the active window // (last_seen > cutoff). Stale rows (churned free-plan reinstalls) and never-seen // rows are excluded so they don't count against the plan cap. func (s *Store) CountActiveDevices(ctx context.Context, userID int64, cutoff time.Time) (int, error) { var n int if err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM devices WHERE user_id=? AND last_seen IS NOT NULL AND last_seen > ?`, userID, cutoff.UTC()).Scan(&n); err != nil { return 0, fmt.Errorf("store.CountActiveDevices: %w", err) } return n, nil } // PruneStaleDevices deletes the user's devices not seen since cutoff (best-effort // churn cleanup). Stale devices are offline; their sessions/credentials have long // expired and node resync reconciles any residue, so a plain row delete is safe. // Returns the number of rows removed. func (s *Store) PruneStaleDevices(ctx context.Context, userID int64, cutoff time.Time) (int64, error) { res, err := s.db.ExecContext(ctx, `DELETE FROM devices WHERE user_id=? AND last_seen IS NOT NULL AND last_seen < ?`, userID, cutoff.UTC()) if err != nil { return 0, fmt.Errorf("store.PruneStaleDevices: %w", err) } n, _ := res.RowsAffected() return n, nil } // 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, 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, client_version) VALUES (?, ?, ?, ?, ?, ?, ?)`, uuid, userID, name, platform, now, now, cv) if err != nil { return nil, fmt.Errorf("store.insertDeviceTx: %w", err) } id, _ := res.LastInsertId() // 回填 last_seen/created_at:库里已写入 now,返回值也要带上,否则刚注册的 // 设备 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, ClientVersion: sql.NullString{String: clientVersion, Valid: clientVersion != ""}, }, nil } // TouchLastSeen bumps a device's last_seen to now (non-tx, best-effort). Called by // the ~15s session poll so "online" tracks an actually-running app, not just the // last connect/usage report. func (s *Store) TouchLastSeen(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("store.TouchLastSeen: %w", err) } return nil } // 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 } // UpdateName sets a device's display name (non-tx). Used by rename. func (s *Store) UpdateName(ctx context.Context, deviceID int64, name string) error { if _, err := s.db.ExecContext(ctx, `UPDATE devices SET name=? WHERE id=?`, name, deviceID); err != nil { return fmt.Errorf("store.UpdateName: %w", err) } return nil } // 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 { return fmt.Errorf("store.deleteDeviceTx: %w", err) } return nil } // -------------------------------------------------------------------------- // Subscription / plan queries (for the subscription middleware & /me summary) // -------------------------------------------------------------------------- // GetUserStatus returns a user's account status ("active"/"banned"). // exists is false when no such user row is present. func (s *Store) GetUserStatus(ctx context.Context, userID int64) (status string, exists bool, err error) { row := s.db.QueryRowContext(ctx, `SELECT status FROM users WHERE id=?`, userID) if e := row.Scan(&status); e == sql.ErrNoRows { return "", false, nil } else if e != nil { return "", false, fmt.Errorf("store.GetUserStatus: %w", e) } return status, true, nil } // GetSubscriptions returns all subscriptions for userID joined with their plan. // Expiry filtering is intentionally left to resolveEffectivePlan. func (s *Store) GetSubscriptions(ctx context.Context, userID int64) ([]effSub, error) { rows, err := s.db.QueryContext(ctx, `SELECT p.code, p.max_devices, p.daily_minutes, p.ad_gate, s.expires_at, s.source FROM subscriptions s JOIN plans p ON p.id=s.plan_id WHERE s.user_id=?`, userID) if err != nil { return nil, fmt.Errorf("store.GetSubscriptions: %w", err) } defer rows.Close() var out []effSub for rows.Next() { var e effSub if err := rows.Scan(&e.PlanCode, &e.MaxDevices, &e.DailyMinutes, &e.AdGate, &e.ExpiresAt, &e.Source); err != nil { return nil, fmt.Errorf("store.GetSubscriptions scan: %w", err) } out = append(out, e) } return out, rows.Err() } // GetFreePlan loads the free plan row (the no-subscription fallback). func (s *Store) GetFreePlan(ctx context.Context) (Plan, error) { row := s.db.QueryRowContext(ctx, `SELECT max_devices, daily_minutes, ad_gate FROM plans WHERE code='free'`) var maxDevices int var dailyMinutes sql.NullInt64 var adGate bool if err := row.Scan(&maxDevices, &dailyMinutes, &adGate); err != nil { return Plan{}, fmt.Errorf("store.GetFreePlan: %w", err) } p := Plan{ PlanCode: "free", MaxDevices: maxDevices, AdGate: adGate, Source: "free", } if dailyMinutes.Valid { m := int(dailyMinutes.Int64) p.DailyMinutes = &m } return p, nil } // -------------------------------------------------------------------------- // Audit log // -------------------------------------------------------------------------- // writeAuditLogTx inserts an audit_log row inside tx. func (s *Store) writeAuditLogTx(ctx context.Context, tx *sql.Tx, actor, action, target, metaJSON string) error { if metaJSON == "" { metaJSON = "null" } _, err := tx.ExecContext(ctx, `INSERT INTO audit_log (actor, action, target, meta, at) VALUES (?, ?, ?, ?, ?)`, actor, action, target, metaJSON, time.Now().UTC()) if err != nil { return fmt.Errorf("store.writeAuditLogTx: %w", err) } return nil }