package nodes import ( "context" "database/sql" "errors" "fmt" "time" dbx "github.com/wangjia/pangolin/server/internal/db" "github.com/wangjia/pangolin/server/internal/idgen" agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1" ) // ErrDeviceNotFound: connect 时 device_id 不是该用户的已注册设备(未注册或已被移除)。 // connect 据此拒连并提示重新登录(重新注册设备),不再回退账户级 dp_uuid。 var ErrDeviceNotFound = errors.New("device not registered") // NodeRow holds a node's essential fields from the nodes table. type NodeRow struct { ID int64 UUID string Region string NameZH string NameEN string Tier string Status string RealityPBK string // REALITY x25519 PUBLIC key (for client connect config) RealityPRK string // REALITY x25519 PRIVATE key (for agent inbound TLS) RealitySNI string RealityShortID string Endpoint string Hy2Port sql.NullInt32 } // Entitlement summarises a user's active plan for the connect gate. type Entitlement struct { DpUUID string PlanCode string AdGate bool // true = free plan, require ad unlock + minute quota DailyMinutes sql.NullInt64 DailyMB sql.NullInt64 // 每日综合流量配额(MB, 按账户综合卡); NULL = 不限 MaxDevices int // 套餐设备上限(free 1 / pro 3 / team 10);0 = 不限 ExpiresAt sql.NullTime // latest subscription expiry (nil = trial/active) } // NodeStore is the persistence interface used by the nodes domain handlers. // All methods are context-aware and safe for concurrent use. type NodeStore interface { // NodeByUUID looks up a node record by its UUID. // Returns (nil, nil) when no matching row exists. NodeByUUID(ctx context.Context, uuid string) (*NodeRow, error) // ListUp returns all nodes with status='up', ordered by weight DESC. ListUp(ctx context.Context) ([]*NodeRow, error) // EntitlementForUser returns the user's dp_uuid and active plan entitlement. // Returns (nil, nil) when the user has no active subscription (treats as free). EntitlementForUser(ctx context.Context, userID int64) (*Entitlement, error) // ConfigVersion returns the current global directory version. // This is the version from the directory_version singleton table. ConfigVersion(ctx context.Context) (int64, error) // ActiveNodeUUIDs returns the UUIDs of all nodes with status 'up' or 'draining'. // Used by Broadcast to enumerate delivery targets. ActiveNodeUUIDs(ctx context.Context) ([]string, error) // CredentialsForNode returns the active data-plane credentials for nodeUUID. CredentialsForNode(ctx context.Context, nodeUUID string) ([]*agentv1.Credential, error) // PersistCredential upserts a credential row in connect_credentials. PersistCredential(ctx context.Context, nodeID int64, cred *agentv1.Credential, expiresAt time.Time) error // DeleteCredential removes the credential for (nodeID, dpUUID). DeleteCredential(ctx context.Context, nodeID int64, dpUUID string) error // NodesHoldingCredential returns the nodes (id+uuid) that currently hold a // connect_credentials row for dpUUID — the targets of a per-device revoke. NodesHoldingCredential(ctx context.Context, dpUUID string) ([]CredentialLocation, error) // UserIDByDpUUID maps a data-plane UUID to the owning user's internal ID. // Returns (0, false, nil) if the dp_uuid is unknown or the user is inactive. UserIDByDpUUID(ctx context.Context, dpUUID string) (int64, bool, error) // EnsureDeviceDpUUID returns the per-device data-plane UUID for (userID, // deviceUUID), minting + persisting one (devices.dp_uuid) if the device has // none yet. Also returns the device's internal id. Each device gets its own // dp_uuid so the node reports per-device traffic counters (todo #5 Phase 2). EnsureDeviceDpUUID(ctx context.Context, userID int64, deviceUUID string) (dpUUID string, deviceID int64, err error) // UserDeviceByDpUUID resolves a data-plane UUID to (userID, deviceID). It // prefers the per-device credential (devices.dp_uuid); failing that it falls // back to the account-level users.dp_uuid (deviceID=0) for legacy compat. // Returns ok=false when unknown or the user is inactive. UserDeviceByDpUUID(ctx context.Context, dpUUID string) (userID, deviceID int64, ok bool, err error) // AccumulateUsage adds bytes and minutes to usage_daily for userID on date. // Uses INSERT … ON DUPLICATE KEY UPDATE (idempotent within a day). AccumulateUsage(ctx context.Context, userID int64, date time.Time, bytesUp, bytesDown int64, minutes int64) error // AccumulateHourly adds bytes/minutes to usage_hourly keyed by the UTC epoch // hour of `hour` (for timezone-aware local-day bucketing at query time). AccumulateHourly(ctx context.Context, userID int64, hour time.Time, bytesUp, bytesDown int64, minutes int64) error // AccountDayBytes returns the account's total bytes (up+down) for userID on // date — the basis for the GB 综合配额 connect gate. 0 when no usage yet. AccountDayBytes(ctx context.Context, userID int64, date time.Time) (int64, error) // AccountDayMinutes returns the account's minutes_used and ad_bonus_minutes // for userID on date — the basis for the免费版分钟配额 connect gate. Both 0 // when no usage row exists yet. Quota is account-wide (shared across devices). AccountDayMinutes(ctx context.Context, userID int64, date time.Time) (used, bonus int, err error) // CountActiveDevices counts the user's devices seen since cutoff (active). Used // by the connect device-limit backstop; stale rows are excluded. CountActiveDevices(ctx context.Context, userID int64, cutoff time.Time) (int, error) // AccumulateDeviceUsage adds bytes/minutes to usage_device_daily for // (deviceID, date); user_id is carried for per-account rollups/queries. AccumulateDeviceUsage(ctx context.Context, userID, deviceID int64, date time.Time, bytesUp, bytesDown int64, minutes int64) error // AccumulateDeviceHourly adds bytes/minutes to usage_device_hourly keyed by // (deviceID, UTC epoch hour) — the per-device counterpart of AccumulateHourly, // for timezone-aware per-device day bucketing at query time. AccumulateDeviceHourly(ctx context.Context, userID, deviceID int64, hour time.Time, bytesUp, bytesDown int64, minutes int64) error // TouchDeviceLastSeen bumps devices.last_seen to now for an active device, // driving the "online" status (connect + periodic usage reports refresh it). TouchDeviceLastSeen(ctx context.Context, deviceID int64) error } // SQLNodeStore implements NodeStore against a SQL database (MySQL or SQLite). type SQLNodeStore struct { db *sql.DB dialect dbx.Dialect } // NewSQLNodeStore creates a SQLNodeStore backed by db. func NewSQLNodeStore(db *sql.DB) *SQLNodeStore { return &SQLNodeStore{db: db, dialect: dbx.DialectForDB(db)} } // NodeByUUID looks up a node by UUID. Returns (nil, nil) when not found. func (s *SQLNodeStore) NodeByUUID(ctx context.Context, uuid string) (*NodeRow, error) { const q = ` SELECT id, uuid, region, name_zh, name_en, tier, status, reality_pbk, reality_prk, reality_sni, reality_short_id, endpoint, hy2_port FROM nodes WHERE uuid = ? ` var n NodeRow err := s.db.QueryRowContext(ctx, q, uuid).Scan( &n.ID, &n.UUID, &n.Region, &n.NameZH, &n.NameEN, &n.Tier, &n.Status, &n.RealityPBK, &n.RealityPRK, &n.RealitySNI, &n.RealityShortID, &n.Endpoint, &n.Hy2Port, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("nodes.SQLNodeStore.NodeByUUID: %w", err) } return &n, nil } // ListUp returns nodes with status='up', ordered by weight DESC. func (s *SQLNodeStore) ListUp(ctx context.Context) ([]*NodeRow, error) { const q = ` SELECT id, uuid, region, name_zh, name_en, tier, status, reality_pbk, reality_prk, reality_sni, reality_short_id, endpoint, hy2_port FROM nodes WHERE status = 'up' ORDER BY weight DESC ` rows, err := s.db.QueryContext(ctx, q) if err != nil { return nil, fmt.Errorf("nodes.SQLNodeStore.ListUp: %w", err) } defer rows.Close() var out []*NodeRow for rows.Next() { var n NodeRow if err := rows.Scan( &n.ID, &n.UUID, &n.Region, &n.NameZH, &n.NameEN, &n.Tier, &n.Status, &n.RealityPBK, &n.RealityPRK, &n.RealitySNI, &n.RealityShortID, &n.Endpoint, &n.Hy2Port, ); err != nil { return nil, fmt.Errorf("nodes.SQLNodeStore.ListUp scan: %w", err) } out = append(out, &n) } return out, rows.Err() } // EntitlementForUser returns the user's dp_uuid and best active plan entitlement. // Picks the subscription with the latest expires_at; falls back to the 'free' plan // if the user has no active subscription. func (s *SQLNodeStore) EntitlementForUser(ctx context.Context, userID int64) (*Entitlement, error) { // First get dp_uuid from the users table. var dpUUID string if err := s.db.QueryRowContext(ctx, `SELECT dp_uuid FROM users WHERE id = ? AND status = 'active'`, userID, ).Scan(&dpUUID); err == sql.ErrNoRows { return nil, nil } else if err != nil { return nil, fmt.Errorf("nodes.SQLNodeStore.EntitlementForUser: dp_uuid: %w", err) } // Look up the best active subscription. const q = ` SELECT p.code, p.ad_gate, p.daily_minutes, p.daily_mb, p.max_devices, s.expires_at FROM subscriptions s JOIN plans p ON p.id = s.plan_id WHERE s.user_id = ? AND s.expires_at > ? ORDER BY s.expires_at DESC LIMIT 1 ` e := &Entitlement{DpUUID: dpUUID} err := s.db.QueryRowContext(ctx, q, userID, time.Now().UTC()).Scan( &e.PlanCode, &e.AdGate, &e.DailyMinutes, &e.DailyMB, &e.MaxDevices, &e.ExpiresAt, ) if err == sql.ErrNoRows { // No active subscription → free plan defaults (mirrors the free plan seed). e.PlanCode = "free" e.AdGate = true e.DailyMinutes = sql.NullInt64{Valid: true, Int64: 10} e.DailyMB = sql.NullInt64{Valid: true, Int64: 500} e.MaxDevices = 1 return e, nil } if err != nil { return nil, fmt.Errorf("nodes.SQLNodeStore.EntitlementForUser: plan: %w", err) } return e, nil } // ConfigVersion returns the current global directory version. // Returns 0 with nil error if the directory_version row does not yet exist. func (s *SQLNodeStore) ConfigVersion(ctx context.Context) (int64, error) { var v int64 err := s.db.QueryRowContext(ctx, `SELECT version FROM directory_version WHERE id = 1`).Scan(&v) if err == sql.ErrNoRows { return 0, nil } if err != nil { return 0, fmt.Errorf("nodes.SQLNodeStore.ConfigVersion: %w", err) } return v, nil } // ActiveNodeUUIDs returns UUIDs of all nodes with status 'up' or 'draining'. func (s *SQLNodeStore) ActiveNodeUUIDs(ctx context.Context) ([]string, error) { rows, err := s.db.QueryContext(ctx, `SELECT uuid FROM nodes WHERE status IN ('up', 'draining')`) if err != nil { return nil, fmt.Errorf("nodes.SQLNodeStore.ActiveNodeUUIDs: %w", err) } defer rows.Close() var uuids []string for rows.Next() { var u string if err := rows.Scan(&u); err != nil { return nil, fmt.Errorf("nodes.SQLNodeStore.ActiveNodeUUIDs scan: %w", err) } uuids = append(uuids, u) } return uuids, rows.Err() } // CredentialsForNode returns active (non-expired) credentials for nodeUUID. func (s *SQLNodeStore) CredentialsForNode(ctx context.Context, nodeUUID string) ([]*agentv1.Credential, error) { const q = ` SELECT cc.dp_uuid, cc.protocol, cc.flow, cc.expires_at FROM connect_credentials cc JOIN nodes n ON n.id = cc.node_id WHERE n.uuid = ? AND cc.expires_at > ? ` rows, err := s.db.QueryContext(ctx, q, nodeUUID, time.Now().UTC()) if err != nil { return nil, fmt.Errorf("nodes.SQLNodeStore.CredentialsForNode: %w", err) } defer rows.Close() var out []*agentv1.Credential for rows.Next() { var c agentv1.Credential var expiresAt time.Time if err := rows.Scan(&c.DpUUID, &c.Protocol, &c.Flow, &expiresAt); err != nil { return nil, fmt.Errorf("nodes.SQLNodeStore.CredentialsForNode scan: %w", err) } c.ExpiresAtUnix = expiresAt.Unix() out = append(out, &c) } return out, rows.Err() } // PersistCredential upserts the credential into connect_credentials. func (s *SQLNodeStore) PersistCredential(ctx context.Context, nodeID int64, cred *agentv1.Credential, expiresAt time.Time) error { q := ` INSERT INTO connect_credentials (node_id, dp_uuid, protocol, flow, expires_at) VALUES (?, ?, ?, ?, ?) ` + s.dialect.Upsert([]string{"node_id", "dp_uuid"}, "protocol = EXCLUDED.protocol", "flow = EXCLUDED.flow", "expires_at = EXCLUDED.expires_at") if _, err := s.db.ExecContext(ctx, q, nodeID, cred.DpUUID, int32(cred.Protocol), cred.Flow, expiresAt.UTC(), ); err != nil { return fmt.Errorf("nodes.SQLNodeStore.PersistCredential: %w", err) } return nil } // CredentialLocation identifies a node holding a given dp_uuid credential. type CredentialLocation struct { NodeID int64 NodeUUID string } // NodesHoldingCredential lists the nodes that currently hold dpUUID. func (s *SQLNodeStore) NodesHoldingCredential(ctx context.Context, dpUUID string) ([]CredentialLocation, error) { rows, err := s.db.QueryContext(ctx, `SELECT cc.node_id, n.uuid FROM connect_credentials cc JOIN nodes n ON n.id = cc.node_id WHERE cc.dp_uuid = ?`, dpUUID) if err != nil { return nil, fmt.Errorf("nodes.SQLNodeStore.NodesHoldingCredential: %w", err) } defer rows.Close() var out []CredentialLocation for rows.Next() { var loc CredentialLocation if err := rows.Scan(&loc.NodeID, &loc.NodeUUID); err != nil { return nil, fmt.Errorf("nodes.SQLNodeStore.NodesHoldingCredential scan: %w", err) } out = append(out, loc) } return out, rows.Err() } // DeleteCredential removes the credential for (nodeID, dpUUID). func (s *SQLNodeStore) DeleteCredential(ctx context.Context, nodeID int64, dpUUID string) error { if _, err := s.db.ExecContext(ctx, `DELETE FROM connect_credentials WHERE node_id = ? AND dp_uuid = ?`, nodeID, dpUUID, ); err != nil { return fmt.Errorf("nodes.SQLNodeStore.DeleteCredential: %w", err) } return nil } // UserIDByDpUUID resolves a data-plane UUID to an active user's internal ID. func (s *SQLNodeStore) UserIDByDpUUID(ctx context.Context, dpUUID string) (int64, bool, error) { var userID int64 err := s.db.QueryRowContext(ctx, `SELECT id FROM users WHERE dp_uuid = ? AND status = 'active' LIMIT 1`, dpUUID, ).Scan(&userID) if err == sql.ErrNoRows { return 0, false, nil } if err != nil { return 0, false, fmt.Errorf("nodes.SQLNodeStore.UserIDByDpUUID: %w", err) } return userID, true, nil } // EnsureDeviceDpUUID returns (and lazily mints) the per-device dp_uuid for the // (userID, deviceUUID) pair. Minting uses a guarded UPDATE so concurrent connects // for the same device converge on a single dp_uuid (the loser's UPDATE is a no-op // and the re-read returns the winner's value). func (s *SQLNodeStore) EnsureDeviceDpUUID(ctx context.Context, userID int64, deviceUUID string) (string, int64, error) { var deviceID int64 var dpUUID sql.NullString err := s.db.QueryRowContext(ctx, `SELECT id, dp_uuid FROM devices WHERE user_id = ? AND uuid = ? LIMIT 1`, userID, deviceUUID, ).Scan(&deviceID, &dpUUID) if err == sql.ErrNoRows { return "", 0, fmt.Errorf("nodes.SQLNodeStore.EnsureDeviceDpUUID: device %q for user %d: %w", deviceUUID, userID, ErrDeviceNotFound) } if err != nil { return "", 0, fmt.Errorf("nodes.SQLNodeStore.EnsureDeviceDpUUID: %w", err) } if dpUUID.Valid && dpUUID.String != "" { return dpUUID.String, deviceID, nil } minted := idgen.NewString() if _, err := s.db.ExecContext(ctx, `UPDATE devices SET dp_uuid = ? WHERE id = ? AND (dp_uuid IS NULL OR dp_uuid = '')`, minted, deviceID, ); err != nil { return "", 0, fmt.Errorf("nodes.SQLNodeStore.EnsureDeviceDpUUID: mint: %w", err) } // Re-read to resolve any concurrent mint race deterministically. if err := s.db.QueryRowContext(ctx, `SELECT dp_uuid FROM devices WHERE id = ?`, deviceID, ).Scan(&dpUUID); err != nil { return "", 0, fmt.Errorf("nodes.SQLNodeStore.EnsureDeviceDpUUID: reread: %w", err) } return dpUUID.String, deviceID, nil } // UserDeviceByDpUUID resolves a dp_uuid to (userID, deviceID), preferring the // per-device credential and falling back to the legacy account-level users.dp_uuid. func (s *SQLNodeStore) UserDeviceByDpUUID(ctx context.Context, dpUUID string) (int64, int64, bool, error) { var userID, deviceID int64 err := s.db.QueryRowContext(ctx, `SELECT d.user_id, d.id FROM devices d JOIN users u ON u.id = d.user_id WHERE d.dp_uuid = ? AND u.status = 'active' LIMIT 1`, dpUUID, ).Scan(&userID, &deviceID) if err == nil { return userID, deviceID, true, nil } if err != sql.ErrNoRows { return 0, 0, false, fmt.Errorf("nodes.SQLNodeStore.UserDeviceByDpUUID: device: %w", err) } // Legacy fallback: account-level credential, no device attribution. err = s.db.QueryRowContext(ctx, `SELECT id FROM users WHERE dp_uuid = ? AND status = 'active' LIMIT 1`, dpUUID, ).Scan(&userID) if err == sql.ErrNoRows { return 0, 0, false, nil } if err != nil { return 0, 0, false, fmt.Errorf("nodes.SQLNodeStore.UserDeviceByDpUUID: user: %w", err) } return userID, 0, true, nil } // AccumulateDeviceUsage adds bytes/minutes to usage_device_daily for (deviceID, date). func (s *SQLNodeStore) AccumulateDeviceUsage( ctx context.Context, userID, deviceID int64, date time.Time, bytesUp, bytesDown int64, minutes int64, ) error { q := ` INSERT INTO usage_device_daily (user_id, device_id, date, bytes_up, bytes_down, minutes_used) VALUES (?, ?, ?, ?, ?, ?) ` + s.dialect.Upsert([]string{"device_id", "date"}, "bytes_up = bytes_up + EXCLUDED.bytes_up", "bytes_down = bytes_down + EXCLUDED.bytes_down", "minutes_used = minutes_used + EXCLUDED.minutes_used") if _, err := s.db.ExecContext(ctx, q, userID, deviceID, date.Format("2006-01-02"), bytesUp, bytesDown, minutes, ); err != nil { return fmt.Errorf("nodes.SQLNodeStore.AccumulateDeviceUsage: %w", err) } return nil } // TouchDeviceLastSeen bumps devices.last_seen to now (online-status heartbeat). func (s *SQLNodeStore) TouchDeviceLastSeen(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("nodes.SQLNodeStore.TouchDeviceLastSeen: %w", err) } return nil } // AccountDayBytes returns the account's total bytes (up+down) for the day. func (s *SQLNodeStore) AccountDayBytes(ctx context.Context, userID int64, date time.Time) (int64, error) { var total sql.NullInt64 err := s.db.QueryRowContext(ctx, `SELECT bytes_up + bytes_down FROM usage_daily WHERE user_id = ? AND date = ?`, userID, date.Format("2006-01-02"), ).Scan(&total) if err == sql.ErrNoRows { return 0, nil } if err != nil { return 0, fmt.Errorf("nodes.SQLNodeStore.AccountDayBytes: %w", err) } return total.Int64, nil } // AccountDayMinutes returns the account's minutes_used and ad_bonus_minutes for // the day (0,0 when no usage row yet). func (s *SQLNodeStore) AccountDayMinutes(ctx context.Context, userID int64, date time.Time) (used, bonus int, err error) { err = s.db.QueryRowContext(ctx, `SELECT minutes_used, ad_bonus_minutes FROM usage_daily WHERE user_id = ? AND date = ?`, userID, date.Format("2006-01-02"), ).Scan(&used, &bonus) if err == sql.ErrNoRows { return 0, 0, nil } if err != nil { return 0, 0, fmt.Errorf("nodes.SQLNodeStore.AccountDayMinutes: %w", err) } return used, bonus, nil } // CountActiveDevices counts the user's devices seen within the active window // (last_seen > cutoff). Mirrors devices.Store.CountActiveDevices for the connect // backstop; stale/never-seen rows are excluded. func (s *SQLNodeStore) 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("nodes.SQLNodeStore.CountActiveDevices: %w", err) } return n, nil } // AccumulateUsage adds bytes/minutes to usage_daily for the given user and date. func (s *SQLNodeStore) AccumulateUsage( ctx context.Context, userID int64, date time.Time, bytesUp, bytesDown int64, minutes int64, ) error { q := ` INSERT INTO usage_daily (user_id, date, bytes_up, bytes_down, minutes_used) VALUES (?, ?, ?, ?, ?) ` + s.dialect.Upsert([]string{"user_id", "date"}, "bytes_up = bytes_up + EXCLUDED.bytes_up", "bytes_down = bytes_down + EXCLUDED.bytes_down", "minutes_used = minutes_used + EXCLUDED.minutes_used") if _, err := s.db.ExecContext(ctx, q, userID, date.Format("2006-01-02"), bytesUp, bytesDown, minutes, ); err != nil { return fmt.Errorf("nodes.SQLNodeStore.AccumulateUsage: %w", err) } return nil } // AccumulateHourly adds bytes/minutes to usage_hourly for (userID, UTC epoch // hour). Epoch hour = floor(unix_seconds / 3600); the read side re-buckets these // into the client's local days. Idempotent within an hour. func (s *SQLNodeStore) AccumulateHourly( ctx context.Context, userID int64, hour time.Time, bytesUp, bytesDown int64, minutes int64, ) error { h := hour.UTC().Unix() / 3600 q := ` INSERT INTO usage_hourly (user_id, hour, bytes_up, bytes_down, minutes_used) VALUES (?, ?, ?, ?, ?) ` + s.dialect.Upsert([]string{"user_id", "hour"}, "bytes_up = bytes_up + EXCLUDED.bytes_up", "bytes_down = bytes_down + EXCLUDED.bytes_down", "minutes_used = minutes_used + EXCLUDED.minutes_used") if _, err := s.db.ExecContext(ctx, q, userID, h, bytesUp, bytesDown, minutes); err != nil { return fmt.Errorf("nodes.SQLNodeStore.AccumulateHourly: %w", err) } return nil } // AccumulateDeviceHourly adds bytes/minutes to usage_device_hourly for // (deviceID, UTC epoch hour). Per-device counterpart of AccumulateHourly. func (s *SQLNodeStore) AccumulateDeviceHourly( ctx context.Context, userID, deviceID int64, hour time.Time, bytesUp, bytesDown int64, minutes int64, ) error { h := hour.UTC().Unix() / 3600 q := ` INSERT INTO usage_device_hourly (user_id, device_id, hour, bytes_up, bytes_down, minutes_used) VALUES (?, ?, ?, ?, ?, ?) ` + s.dialect.Upsert([]string{"device_id", "hour"}, "bytes_up = bytes_up + EXCLUDED.bytes_up", "bytes_down = bytes_down + EXCLUDED.bytes_down", "minutes_used = minutes_used + EXCLUDED.minutes_used") if _, err := s.db.ExecContext(ctx, q, userID, deviceID, h, bytesUp, bytesDown, minutes); err != nil { return fmt.Errorf("nodes.SQLNodeStore.AccumulateDeviceHourly: %w", err) } return nil }