Files
pangolin/server/internal/usage/store.go
T
wangjia e333f59cb3
ci-pangolin / Lint — shellcheck (push) Successful in 8s
ci-pangolin / OpenAPI Sync Check (push) Successful in 19s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 6s
ci-pangolin / Flutter — analyze + test (push) Failing after 14s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 6s
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 4m8s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 15s
feat(stats): #10 第②层 —— 统计页按设备过滤(per-device 本地日曲线)
镜像账户级时区曲线到「按设备」:
- migration 000018 `usage_device_hourly`(每设备 UTC 小时桶,稀疏)。
- ReportUsage deviceID>0 时加 AccumulateDeviceHourly;NodeStore 接口+impl+mock。
- usage.Store.DeviceHourlyRange(JOIN devices 校验归属+解析 uuid→id);UsageCurve
  增 deviceUUID 形参:空=账户级,非空=该设备本地日曲线(分桶逻辑复用)。
- /v1/usage?device=<uuid>;客户端 account_api.usage(device)、usageProvider key 改
  记录 (days,device)、stats_page 接 statsDeviceProvider → 选设备即重取该设备曲线。
测试:per-device 曲线隔离+归属校验(A的设备不进B)、UsageCurve(deviceUUID)、
migration v18、客户端 widget(选设备→/v1/usage 带 device=)。全量 go/flutter 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 08:04:33 +08:00

315 lines
11 KiB
Go

package usage
import (
"context"
"database/sql"
"fmt"
"time"
dbx "github.com/wangjia/pangolin/server/internal/db"
)
// Unlimited is the sentinel returned by CheckFreeConnect for plans with no
// daily-minute cap (pro / team). Callers (e.g. the connect endpoint) treat it
// as "no minute limit; use the default paid credential TTL".
const Unlimited = -1
// dateLayout is the canonical UTC day format used as the usage_daily.date key
// and in the API response.
const dateLayout = "2006-01-02"
// Plan is the minimal subset of the plans row needed for quota decisions.
type Plan struct {
Code string // free | pro | team
DailyMinutes sql.NullInt64 // free=10; NULL = unlimited
AdGate bool // free=true: daily rewarded-ad unlock required
}
// DailyUsage mirrors a usage_daily row. It carries only aggregate byte and
// minute counts plus the ad-unlock timestamp — never destinations or DNS, per
// the no-log policy.
type DailyUsage struct {
Date time.Time
BytesUp uint64
BytesDown uint64
MinutesUsed int
AdUnlockedAt sql.NullTime
}
// Store wraps a *sql.DB and exposes the usage_daily / plans / users queries the
// usage package needs.
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)} }
// AggregateUsage accumulates one usage delta into usage_daily for (userID, day)
// using INSERT … ON DUPLICATE KEY UPDATE so concurrent reports from multiple
// nodes add up correctly. day is truncated to its UTC calendar date.
func (s *Store) AggregateUsage(ctx context.Context, userID int64, day time.Time, bytesUp, bytesDown uint64, minutes int) error {
d := day.UTC().Format(dateLayout)
_, err := s.db.ExecContext(ctx,
`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"),
userID, d, bytesUp, bytesDown, minutes)
if err != nil {
return fmt.Errorf("store.AggregateUsage: %w", err)
}
return nil
}
// LookupUserIDByDPUUID resolves a data-plane credential UUID to the owning
// user_id. Returns (0, nil) when no user matches (e.g. a rotated/stale dp_uuid)
// so the caller can decide to silently drop the report.
func (s *Store) LookupUserIDByDPUUID(ctx context.Context, dpUUID string) (int64, error) {
var id int64
err := s.db.QueryRowContext(ctx,
`SELECT id FROM users WHERE dp_uuid = ? LIMIT 1`, dpUUID).Scan(&id)
if err == sql.ErrNoRows {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("store.LookupUserIDByDPUUID: %w", err)
}
return id, nil
}
// GetUsageRange returns the usage_daily rows for userID with date in
// [from, to] (inclusive, UTC dates), ordered ascending. Missing days are NOT
// filled here; zero-filling is the handler's responsibility.
func (s *Store) GetUsageRange(ctx context.Context, userID int64, from, to time.Time) ([]DailyUsage, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT date, bytes_up, bytes_down, minutes_used, ad_unlocked_at
FROM usage_daily
WHERE user_id = ? AND date BETWEEN ? AND ?
ORDER BY date ASC`,
userID, from.UTC().Format(dateLayout), to.UTC().Format(dateLayout))
if err != nil {
return nil, fmt.Errorf("store.GetUsageRange: %w", err)
}
defer rows.Close()
var out []DailyUsage
for rows.Next() {
var u DailyUsage
if err := rows.Scan(&u.Date, &u.BytesUp, &u.BytesDown, &u.MinutesUsed, &u.AdUnlockedAt); err != nil {
return nil, fmt.Errorf("store.GetUsageRange scan: %w", err)
}
out = append(out, u)
}
return out, rows.Err()
}
// HourBucket is one UTC epoch-hour's account usage (read from usage_hourly).
type HourBucket struct {
Hour int64 // unix epoch hour (UTC) = floor(unix_seconds / 3600)
BytesUp uint64
BytesDown uint64
MinutesUsed int
}
// HourlyRange returns usage_hourly rows for userID with hour in [fromHour, toHour]
// (inclusive, epoch hours), oldest first. Empty hours are simply absent.
func (s *Store) HourlyRange(ctx context.Context, userID, fromHour, toHour int64) ([]HourBucket, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT hour, bytes_up, bytes_down, minutes_used
FROM usage_hourly
WHERE user_id = ? AND hour BETWEEN ? AND ?
ORDER BY hour ASC`,
userID, fromHour, toHour)
if err != nil {
return nil, fmt.Errorf("store.HourlyRange: %w", err)
}
defer rows.Close()
var out []HourBucket
for rows.Next() {
var b HourBucket
if err := rows.Scan(&b.Hour, &b.BytesUp, &b.BytesDown, &b.MinutesUsed); err != nil {
return nil, fmt.Errorf("store.HourlyRange scan: %w", err)
}
out = append(out, b)
}
return out, rows.Err()
}
// DeviceHourlyRange returns one device's UTC hourly buckets (usage_device_hourly)
// for hour in [fromHour, toHour], oldest first. The JOIN both resolves the
// client-facing device UUID to its internal id and enforces ownership (user_id),
// so a user can never read another user's device curve.
func (s *Store) DeviceHourlyRange(ctx context.Context, userID int64, deviceUUID string, fromHour, toHour int64) ([]HourBucket, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT uh.hour, uh.bytes_up, uh.bytes_down, uh.minutes_used
FROM usage_device_hourly uh
JOIN devices d ON d.id = uh.device_id
WHERE d.user_id = ? AND d.uuid = ? AND uh.hour BETWEEN ? AND ?
ORDER BY uh.hour ASC`,
userID, deviceUUID, fromHour, toHour)
if err != nil {
return nil, fmt.Errorf("store.DeviceHourlyRange: %w", err)
}
defer rows.Close()
var out []HourBucket
for rows.Next() {
var b HourBucket
if err := rows.Scan(&b.Hour, &b.BytesUp, &b.BytesDown, &b.MinutesUsed); err != nil {
return nil, fmt.Errorf("store.DeviceHourlyRange scan: %w", err)
}
out = append(out, b)
}
return out, rows.Err()
}
// DeviceUsageRow is one device's aggregated usage over a date window, joined to
// the devices table for display metadata. It is the per-device ("下分设备")
// counterpart of DailyUsage's account rollup.
type DeviceUsageRow struct {
UUID string
Name string
Platform string
BytesUp uint64
BytesDown uint64
MinutesUsed int
}
// DeviceUsageRange returns each device's summed usage over [from, to] (inclusive
// UTC dates) for userID, one row per device, ordered by total bytes descending.
// Devices with no usage in the window are omitted. All non-aggregate columns
// are in GROUP BY for MySQL ONLY_FULL_GROUP_BY portability (sqlite tolerant).
func (s *Store) DeviceUsageRange(ctx context.Context, userID int64, from, to time.Time) ([]DeviceUsageRow, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT d.uuid, d.name, d.platform,
SUM(ud.bytes_up), SUM(ud.bytes_down), SUM(ud.minutes_used)
FROM usage_device_daily ud
JOIN devices d ON d.id = ud.device_id
WHERE ud.user_id = ? AND ud.date BETWEEN ? AND ?
GROUP BY ud.device_id, d.uuid, d.name, d.platform
ORDER BY (SUM(ud.bytes_up) + SUM(ud.bytes_down)) DESC`,
userID, from.UTC().Format(dateLayout), to.UTC().Format(dateLayout))
if err != nil {
return nil, fmt.Errorf("store.DeviceUsageRange: %w", err)
}
defer rows.Close()
var out []DeviceUsageRow
for rows.Next() {
var r DeviceUsageRow
if err := rows.Scan(&r.UUID, &r.Name, &r.Platform, &r.BytesUp, &r.BytesDown, &r.MinutesUsed); err != nil {
return nil, fmt.Errorf("store.DeviceUsageRange scan: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// GetDay returns the usage_daily row for (userID, day), or nil if none exists.
func (s *Store) GetDay(ctx context.Context, userID int64, day time.Time) (*DailyUsage, error) {
var u DailyUsage
err := s.db.QueryRowContext(ctx,
`SELECT date, bytes_up, bytes_down, minutes_used, ad_unlocked_at
FROM usage_daily
WHERE user_id = ? AND date = ?`,
userID, day.UTC().Format(dateLayout)).
Scan(&u.Date, &u.BytesUp, &u.BytesDown, &u.MinutesUsed, &u.AdUnlockedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("store.GetDay: %w", err)
}
return &u, nil
}
// MarkAdUnlocked sets usage_daily.ad_unlocked_at for (userID, day) to now if it
// is not already set. It returns alreadyUnlocked=true when the day was already
// unlocked (making a second call idempotent). Runs inside a transaction with
// SELECT … FOR UPDATE to be safe under concurrent unlock attempts.
func (s *Store) MarkAdUnlocked(ctx context.Context, userID int64, day time.Time) (alreadyUnlocked bool, err error) {
d := day.UTC().Format(dateLayout)
now := time.Now().UTC()
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return false, fmt.Errorf("store.MarkAdUnlocked begin: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
var unlockedAt sql.NullTime
row := tx.QueryRowContext(ctx,
`SELECT ad_unlocked_at FROM usage_daily WHERE user_id = ? AND date = ? `+s.dialect.LockForUpdate(),
userID, d)
switch err := row.Scan(&unlockedAt); err {
case nil:
if unlockedAt.Valid {
// Already unlocked today → idempotent success.
if cErr := tx.Commit(); cErr != nil {
return false, fmt.Errorf("store.MarkAdUnlocked commit: %w", cErr)
}
committed = true
return true, nil
}
if _, uErr := tx.ExecContext(ctx,
`UPDATE usage_daily SET ad_unlocked_at = ?
WHERE user_id = ? AND date = ? AND ad_unlocked_at IS NULL`,
now, userID, d); uErr != nil {
return false, fmt.Errorf("store.MarkAdUnlocked update: %w", uErr)
}
case sql.ErrNoRows:
if _, iErr := tx.ExecContext(ctx,
`INSERT INTO usage_daily (user_id, date, ad_unlocked_at)
VALUES (?, ?, ?)`,
userID, d, now); iErr != nil {
return false, fmt.Errorf("store.MarkAdUnlocked insert: %w", iErr)
}
default:
return false, fmt.Errorf("store.MarkAdUnlocked select: %w", err)
}
if cErr := tx.Commit(); cErr != nil {
return false, fmt.Errorf("store.MarkAdUnlocked commit: %w", cErr)
}
committed = true
return false, nil
}
// EffectivePlan returns the plan that currently governs userID: the highest
// tier among the user's non-expired subscriptions, falling back to the free
// plan when the user has no active subscription (trial-expired free state).
func (s *Store) EffectivePlan(ctx context.Context, userID int64) (*Plan, error) {
var p Plan
err := s.db.QueryRowContext(ctx,
`SELECT p.code, p.daily_minutes, p.ad_gate
FROM subscriptions s
JOIN plans p ON p.id = s.plan_id
WHERE s.user_id = ? AND s.expires_at > ?
ORDER BY CASE p.code WHEN 'team' THEN 0 WHEN 'pro' THEN 1 WHEN 'free' THEN 2 ELSE 3 END,
s.expires_at DESC
LIMIT 1`,
userID, time.Now().UTC()).Scan(&p.Code, &p.DailyMinutes, &p.AdGate)
if err == nil {
return &p, nil
}
if err != sql.ErrNoRows {
return nil, fmt.Errorf("store.EffectivePlan: %w", err)
}
// No active subscription → free plan.
if err := s.db.QueryRowContext(ctx,
`SELECT code, daily_minutes, ad_gate FROM plans WHERE code = 'free'`).
Scan(&p.Code, &p.DailyMinutes, &p.AdGate); err != nil {
return nil, fmt.Errorf("store.EffectivePlan free fallback: %w", err)
}
return &p, nil
}