Files
pangolin/server/internal/devices/store.go
T
wangjia 07c339c18e
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 23s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 16s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 39s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 19s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 36s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 3s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 4s
ci-pangolin / Go — build + test (pull_request) Failing after 13s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 10s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 5m8s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 19s
ci-pangolin / Lint — shellcheck (pull_request) Failing after 13m37s
feat(server/devices): users.max_devices_override 单用户设备上限覆盖(NULL走套餐默认)+ 迁移000025
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-13 10:15:07 +08:00

340 lines
13 KiB
Go

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
}
// GetMaxDevicesOverride returns the per-user device-cap override set by
// migration 000025 (users.max_devices_override). NULL or <=0 means "no
// override" (ok=false) — the caller should fall back to the plan-derived cap.
// A positive value always wins, even if it is *smaller* than the plan's cap,
// so operators can also tighten a specific account.
func (s *Store) GetMaxDevicesOverride(ctx context.Context, userID int64) (int, bool, error) {
var override sql.NullInt64
row := s.db.QueryRowContext(ctx, `SELECT max_devices_override FROM users WHERE id=?`, userID)
if err := row.Scan(&override); err != nil {
if err == sql.ErrNoRows {
return 0, false, nil
}
return 0, false, fmt.Errorf("store.GetMaxDevicesOverride: %w", err)
}
if !override.Valid || override.Int64 <= 0 {
return 0, false, nil
}
return int(override.Int64), true, 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
}