721371d806
根因:付费连接凭证硬编码 24h TTL(paidCredentialTTL),而客户端只在 _connect() (用户/看门狗前台重连)才重签,服务端从不因流量续期。macOS sysext(root)隧道独立于 GUI app 常驻,用户关窗后 Dart 看门狗根本不运行 → 凭证 24h 到期、下次 agent 重注册 用「未过期」快照整表覆盖并重渲染 sing-box → REALITY 会话被剔除、永久黑洞。该逻辑 四端共用同一份 Dart,故为全端共性(macOS 最易现形)。 修法(方案 A,服务端、与客户端生命周期无关,一改修四端): - ReportUsage 收到某 dp_uuid 有流量,若属付费套餐(!AdGate)即把其凭证 expires_at 顶到 now+PaidCredentialTTL。活跃会话永不过期;免费凭证 TTL 编码日额度、绝不续期 (否则击穿日限)。每报按 user 缓存一次 entitlement 查询。 - 新增 NodeStore.RenewCredential(纯 UPDATE,WHERE expires_at>now 不复活已过期会话)。 - 24h 提为 nodes.PaidCredentialTTL 单一真相源,httpapi 引用它消除漂移。 - 纯 DB 续期,无需再 push agent(现有 REALITY 用户仍在,只要 DB 行不过期,下次 重注册快照仍含它)。可移植 SQL(? 占位 + Go 端算时间,无 MySQL 专属构造)。 测试:handler 层付费续期/免费不续期(mock);store 层真 SQLite 续期/不复活已过期。 go test ./... 全绿、go vet 干净。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEVUXAbFT6bF1Qw27RHWoD
620 lines
25 KiB
Go
620 lines
25 KiB
Go
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")
|
|
|
|
// PaidCredentialTTL is the付费连接凭证有效期(单一真相源)。httpapi 签发凭证与
|
|
// ReportUsage 的「活跃即续期」都引用它,避免两处 24h 漂移。到期后 CredentialsForNode
|
|
// 的 WHERE expires_at > now 会把它挡在 agent 快照外 → 下次 agent 重注册重渲染即踢下线。
|
|
// 续期机制(见 RenewCredential)让活跃付费会话永不撞这道墙;免费凭证 TTL 编码日额度、
|
|
// 不走此常量、也不续期。
|
|
const PaidCredentialTTL = 24 * time.Hour
|
|
|
|
// 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
|
|
|
|
// RenewCredential 把某 dp_uuid 仍活跃(未过期)凭证的 expires_at 顶到 newExpiresAt
|
|
// ——「活跃即续期」用:付费会话持续上报用量时把有效期不断往后推,让常驻隧道(尤其
|
|
// macOS sysext,GUI app 关闭后客户端看门狗不运行、不会重连重签)不再在 24h 撞过期墙。
|
|
// 只更新 expires_at > now 的行:绝不复活已过期凭证(那属于已断开会话,须重新连接)。
|
|
// 调用方须只对付费凭证调用;免费凭证 TTL 编码日额度,续期会击穿日限。
|
|
RenewCredential(ctx context.Context, dpUUID string, newExpiresAt 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,
|
|
)
|
|
switch {
|
|
case 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
|
|
case err != nil:
|
|
return nil, fmt.Errorf("nodes.SQLNodeStore.EntitlementForUser: plan: %w", err)
|
|
}
|
|
|
|
// Per-user device-cap override (migration 000025, users.max_devices_override):
|
|
// applies uniformly to BOTH branches above (subscription hit + free
|
|
// fallback) — mirrors internal/devices/service.go's ResolvePlan funnel, so
|
|
// the per-connection backstop (internal/httpapi/nodes.go's
|
|
// DEVICE_LIMIT_EXCEEDED check, which reads Entitlement.MaxDevices) agrees
|
|
// with the login gate / /v1/me / devices.CheckDeviceLimit. NULL or <=0
|
|
// means "no override" (same semantics as devices/store.go's
|
|
// GetMaxDevicesOverride); a positive value always wins, even if smaller
|
|
// than the plan's own cap.
|
|
var override sql.NullInt64
|
|
if err := s.db.QueryRowContext(ctx,
|
|
`SELECT max_devices_override FROM users WHERE id = ?`, userID,
|
|
).Scan(&override); err != nil && err != sql.ErrNoRows {
|
|
return nil, fmt.Errorf("nodes.SQLNodeStore.EntitlementForUser: override: %w", err)
|
|
}
|
|
if override.Valid && override.Int64 > 0 {
|
|
e.MaxDevices = int(override.Int64)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// RenewCredential extends the expiry of dpUUID's still-active credential(s) to
|
|
// newExpiresAt. The `expires_at > ?` guard means an already-expired row is left
|
|
// untouched (never resurrect a disconnected session). Portable SQL: bind
|
|
// newExpiresAt then now, both UTC.
|
|
func (s *SQLNodeStore) RenewCredential(ctx context.Context, dpUUID string, newExpiresAt time.Time) error {
|
|
const q = `UPDATE connect_credentials SET expires_at = ? WHERE dp_uuid = ? AND expires_at > ?`
|
|
if _, err := s.db.ExecContext(ctx, q, newExpiresAt.UTC(), dpUUID, time.Now().UTC()); err != nil {
|
|
return fmt.Errorf("nodes.SQLNodeStore.RenewCredential: %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
|
|
}
|