feat(server/stats): stats-overhaul Phase2 — 每设备归因 + GB 综合配额
服务端记账从「账户」细到「每设备」(每设备独立 dp_uuid),配额单位分钟→GB 按账户综合卡控。000015_per_device_usage 迁移(mysql+sqlite 双份):devices.dp_uuid + usage_device_daily 表 + plans.daily_mb。handler_grpc 按 dp_uuid 回映射 (user_id,device_id) 双写账户+每设备;usage 服务/handler 暴露 /v1/usage(/devices)。 含 sqlite_per_device / usage handler 测试。 注:此迁移 prod 已 migrate up 运行、部署二进制已内嵌;本次补提交使 git 与线上一致。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -286,6 +286,7 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
||||
usageStore := usage.NewStore(sqlDB)
|
||||
usageSvc := usage.NewService(usageStore, rdb, nil, time.Hour)
|
||||
usageHandler := usage.NewUsageHandler(usageSvc)
|
||||
deviceUsageHandler := usage.NewDeviceUsageHandler(usageSvc)
|
||||
adsHandler := usage.NewAdsUnlockHandler(usageSvc)
|
||||
|
||||
// ── Account / Plans / Notices ─────────────────────────────────────────────
|
||||
@@ -364,6 +365,7 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
||||
})
|
||||
protected.Post("/redeem", redeemHandler.ServeHTTP)
|
||||
protected.Get("/usage", usageHandler.ServeHTTP)
|
||||
protected.Get("/usage/devices", deviceUsageHandler.ServeHTTP)
|
||||
protected.Post("/ads/unlock", adsHandler.ServeHTTP)
|
||||
protected.Get("/plans", accountAPI.ListPlans)
|
||||
protected.Get("/notices", accountAPI.ListNotices)
|
||||
|
||||
@@ -37,6 +37,9 @@ type mockNodeStore struct {
|
||||
configVer int64
|
||||
usageAccum []mockUsageEntry
|
||||
usersByDpUUID map[string]int64
|
||||
// devicesByDpUUID maps a per-device dp_uuid → {userID, deviceID}.
|
||||
devicesByDpUUID map[string][2]int64
|
||||
deviceUsageAccum []mockDeviceUsageEntry
|
||||
}
|
||||
|
||||
type mockUsageEntry struct {
|
||||
@@ -47,6 +50,15 @@ type mockUsageEntry struct {
|
||||
Minutes int64
|
||||
}
|
||||
|
||||
type mockDeviceUsageEntry struct {
|
||||
UserID int64
|
||||
DeviceID int64
|
||||
Date time.Time
|
||||
BytesUp int64
|
||||
BytesDown int64
|
||||
Minutes int64
|
||||
}
|
||||
|
||||
func (m *mockNodeStore) NodeByUUID(_ context.Context, uuid string) (*nodes.NodeRow, error) {
|
||||
if uuid == m.nodeUUID {
|
||||
return &nodes.NodeRow{
|
||||
@@ -105,6 +117,44 @@ func (m *mockNodeStore) AccumulateUsage(_ context.Context, userID int64, date ti
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockNodeStore) EnsureDeviceDpUUID(_ context.Context, _ int64, _ string) (string, int64, error) {
|
||||
return "", 0, nil
|
||||
}
|
||||
|
||||
func (m *mockNodeStore) AccountDayBytes(_ context.Context, _ int64, _ time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockNodeStore) UserDeviceByDpUUID(_ context.Context, dpUUID string) (int64, int64, bool, error) {
|
||||
if m.devicesByDpUUID != nil {
|
||||
if ud, ok := m.devicesByDpUUID[dpUUID]; ok {
|
||||
return ud[0], ud[1], true, nil
|
||||
}
|
||||
}
|
||||
if uid, ok := m.usersByDpUUID[dpUUID]; ok {
|
||||
return uid, 0, true, nil // legacy account credential, no device dimension
|
||||
}
|
||||
return 0, 0, false, nil
|
||||
}
|
||||
|
||||
func (m *mockNodeStore) AccumulateDeviceUsage(_ context.Context, userID, deviceID int64, date time.Time,
|
||||
bytesUp, bytesDown, minutes int64,
|
||||
) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.deviceUsageAccum = append(m.deviceUsageAccum,
|
||||
mockDeviceUsageEntry{userID, deviceID, date, bytesUp, bytesDown, minutes})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockNodeStore) deviceUsageLog() []mockDeviceUsageEntry {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]mockDeviceUsageEntry, len(m.deviceUsageAccum))
|
||||
copy(out, m.deviceUsageAccum)
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *mockNodeStore) usageLog() []mockUsageEntry {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -850,4 +900,51 @@ func TestReportUsage_Accumulates(t *testing.T) {
|
||||
t.Errorf("bytes_up=%d bytes_down=%d, want 100/200",
|
||||
log[0].BytesUp, log[0].BytesDown)
|
||||
}
|
||||
// Account-level dp_uuid → no per-device attribution (deviceID 0).
|
||||
if dev := b.store.deviceUsageLog(); len(dev) != 0 {
|
||||
t.Errorf("device usage should be empty for account credential, got %d", len(dev))
|
||||
}
|
||||
}
|
||||
|
||||
// TestReportUsage_PerDevice verifies a per-device dp_uuid is dual-written: account
|
||||
// rollup (usage_daily) AND per-device attribution (usage_device_daily).
|
||||
func TestReportUsage_PerDevice(t *testing.T) {
|
||||
const nodeUUID = "test-node-usage-dev"
|
||||
b := newTestServer(t, 1, nodeUUID)
|
||||
ctx := context.Background()
|
||||
// dp-dev1 resolves to user 101, device 55 (per-device credential).
|
||||
b.store.devicesByDpUUID = map[string][2]int64{"dp-dev1": {101, 55}}
|
||||
|
||||
_, _, conn := enrollNode(t, b, nodeUUID)
|
||||
client := agentv1.NewAgentServiceClient(conn)
|
||||
if _, err := client.Register(ctx, &agentv1.RegisterRequest{NodeUUID: nodeUUID}); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if _, err := client.ReportUsage(ctx, &agentv1.UsageReport{
|
||||
NodeUUID: nodeUUID,
|
||||
WindowStartUnix: now.Add(-time.Minute).Unix(),
|
||||
WindowEndUnix: now.Unix(),
|
||||
Entries: []*agentv1.UsageEntry{
|
||||
{DpUUID: "dp-dev1", BytesUp: 100, BytesDown: 200, SessionMinutes: 3},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("ReportUsage: %v", err)
|
||||
}
|
||||
|
||||
// Account rollup still recorded.
|
||||
acc := b.store.usageLog()
|
||||
if len(acc) != 1 || acc[0].UserID != 101 || acc[0].BytesUp != 100 {
|
||||
t.Fatalf("account usage wrong: %+v", acc)
|
||||
}
|
||||
// Per-device usage recorded with device attribution.
|
||||
dev := b.store.deviceUsageLog()
|
||||
if len(dev) != 1 {
|
||||
t.Fatalf("device usage has %d entries, want 1", len(dev))
|
||||
}
|
||||
if dev[0].UserID != 101 || dev[0].DeviceID != 55 ||
|
||||
dev[0].BytesUp != 100 || dev[0].BytesDown != 200 || dev[0].Minutes != 3 {
|
||||
t.Errorf("device usage wrong: %+v", dev[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +293,7 @@ func (h *Handler) ReportUsage(ctx context.Context, req *agentv1.UsageReport) (*a
|
||||
if entry.DpUUID == "" {
|
||||
continue
|
||||
}
|
||||
userID, found, err := h.store.UserIDByDpUUID(ctx, entry.DpUUID)
|
||||
userID, deviceID, found, err := h.store.UserDeviceByDpUUID(ctx, entry.DpUUID)
|
||||
if err != nil {
|
||||
slog.Warn("nodes.Handler.ReportUsage: dp_uuid lookup failed",
|
||||
"dp_uuid", entry.DpUUID, "err", err)
|
||||
@@ -302,12 +302,23 @@ func (h *Handler) ReportUsage(ctx context.Context, req *agentv1.UsageReport) (*a
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
// Account-level rollup (always).
|
||||
if err := h.store.AccumulateUsage(ctx, userID, date,
|
||||
entry.BytesUp, entry.BytesDown, entry.SessionMinutes,
|
||||
); err != nil {
|
||||
slog.Warn("nodes.Handler.ReportUsage: accumulate failed",
|
||||
"user_id", userID, "err", err)
|
||||
}
|
||||
// Per-device attribution (only when the dp_uuid maps to a registered device;
|
||||
// deviceID==0 means a legacy account-level credential — no device dimension).
|
||||
if deviceID > 0 {
|
||||
if err := h.store.AccumulateDeviceUsage(ctx, userID, deviceID, date,
|
||||
entry.BytesUp, entry.BytesDown, entry.SessionMinutes,
|
||||
); err != nil {
|
||||
slog.Warn("nodes.Handler.ReportUsage: device accumulate failed",
|
||||
"user_id", userID, "device_id", deviceID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return &agentv1.UsageAck{}, nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -33,7 +34,8 @@ type Entitlement struct {
|
||||
PlanCode string
|
||||
AdGate bool // true = free plan, require ad unlock + minute quota
|
||||
DailyMinutes sql.NullInt64
|
||||
ExpiresAt sql.NullTime // latest subscription expiry (nil = trial/active)
|
||||
DailyMB sql.NullInt64 // 每日综合流量配额(MB, 按账户综合卡); NULL = 不限
|
||||
ExpiresAt sql.NullTime // latest subscription expiry (nil = trial/active)
|
||||
}
|
||||
|
||||
// NodeStore is the persistence interface used by the nodes domain handlers.
|
||||
@@ -71,10 +73,31 @@ type NodeStore interface {
|
||||
// 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
|
||||
|
||||
// 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)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// SQLNodeStore implements NodeStore against a SQL database (MySQL or SQLite).
|
||||
@@ -159,7 +182,7 @@ func (s *SQLNodeStore) EntitlementForUser(ctx context.Context, userID int64) (*E
|
||||
|
||||
// Look up the best active subscription.
|
||||
const q = `
|
||||
SELECT p.code, p.ad_gate, p.daily_minutes, s.expires_at
|
||||
SELECT p.code, p.ad_gate, p.daily_minutes, p.daily_mb, s.expires_at
|
||||
FROM subscriptions s
|
||||
JOIN plans p ON p.id = s.plan_id
|
||||
WHERE s.user_id = ? AND s.expires_at > ?
|
||||
@@ -168,13 +191,14 @@ func (s *SQLNodeStore) EntitlementForUser(ctx context.Context, userID int64) (*E
|
||||
`
|
||||
e := &Entitlement{DpUUID: dpUUID}
|
||||
err := s.db.QueryRowContext(ctx, q, userID, time.Now().UTC()).Scan(
|
||||
&e.PlanCode, &e.AdGate, &e.DailyMinutes, &e.ExpiresAt,
|
||||
&e.PlanCode, &e.AdGate, &e.DailyMinutes, &e.DailyMB, &e.ExpiresAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
// No active subscription → free plan defaults.
|
||||
// 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}
|
||||
return e, nil
|
||||
}
|
||||
if err != nil {
|
||||
@@ -288,6 +312,106 @@ func (s *SQLNodeStore) UserIDByDpUUID(ctx context.Context, dpUUID string) (int64
|
||||
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 not found for user %d", deviceUUID, userID)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -29,8 +29,8 @@ func TestSQLiteMigrateUpDown(t *testing.T) {
|
||||
if dirty {
|
||||
t.Fatalf("schema dirty after MigrateUp")
|
||||
}
|
||||
if v != 14 {
|
||||
t.Errorf("version = %d, want 14", v)
|
||||
if v != 15 {
|
||||
t.Errorf("version = %d, want 15", v)
|
||||
}
|
||||
|
||||
// 2. Core tables exist.
|
||||
@@ -38,7 +38,7 @@ func TestSQLiteMigrateUpDown(t *testing.T) {
|
||||
"users", "devices", "plans", "subscriptions", "code_batches", "codes",
|
||||
"usage_daily", "audit_log", "providers", "nodes", "node_events",
|
||||
"directory_version", "provision_idempotency", "replacements", "admins",
|
||||
"connect_credentials",
|
||||
"connect_credentials", "usage_device_daily",
|
||||
} {
|
||||
var name string
|
||||
err := db.QueryRow(
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/nodes"
|
||||
"github.com/wangjia/pangolin/server/internal/usage"
|
||||
)
|
||||
|
||||
// seedDevice inserts a device row with a NULL dp_uuid (awaiting lazy mint).
|
||||
func seedDevice(t *testing.T, db *sql.DB, id, userID int64, uuid string) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO devices (id, uuid, user_id, name, platform, created_at)
|
||||
VALUES (?, ?, ?, ?, 'ios', ?)`,
|
||||
id, uuid, userID, "dev"+uuid, time.Now().UTC()); err != nil {
|
||||
t.Fatalf("seed device %s: %v", uuid, err)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureDeviceDpUUID mints a per-device dp_uuid, is idempotent for the same
|
||||
// device, and yields distinct credentials for distinct devices.
|
||||
func TestSQLite_EnsureDeviceDpUUID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := openSQLite(t)
|
||||
ns := nodes.NewSQLNodeStore(db)
|
||||
seedUser(t, db, 1)
|
||||
seedDevice(t, db, 10, 1, "dev-a")
|
||||
seedDevice(t, db, 11, 1, "dev-b")
|
||||
|
||||
dpA, idA, err := ns.EnsureDeviceDpUUID(ctx, 1, "dev-a")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure dev-a: %v", err)
|
||||
}
|
||||
if dpA == "" || idA != 10 {
|
||||
t.Fatalf("dev-a: dp=%q id=%d, want non-empty/10", dpA, idA)
|
||||
}
|
||||
|
||||
// Idempotent: a second call returns the same dp_uuid (no re-mint).
|
||||
dpA2, idA2, err := ns.EnsureDeviceDpUUID(ctx, 1, "dev-a")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure dev-a again: %v", err)
|
||||
}
|
||||
if dpA2 != dpA || idA2 != 10 {
|
||||
t.Errorf("not idempotent: dp=%q (want %q) id=%d", dpA2, dpA, idA2)
|
||||
}
|
||||
|
||||
// Distinct device → distinct dp_uuid.
|
||||
dpB, idB, err := ns.EnsureDeviceDpUUID(ctx, 1, "dev-b")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure dev-b: %v", err)
|
||||
}
|
||||
if dpB == dpA {
|
||||
t.Errorf("dev-b dp_uuid collides with dev-a: %q", dpB)
|
||||
}
|
||||
if idB != 11 {
|
||||
t.Errorf("dev-b id=%d, want 11", idB)
|
||||
}
|
||||
|
||||
// Persisted to devices.dp_uuid.
|
||||
var stored string
|
||||
if err := db.QueryRow(`SELECT dp_uuid FROM devices WHERE id=10`).Scan(&stored); err != nil {
|
||||
t.Fatalf("read stored dp_uuid: %v", err)
|
||||
}
|
||||
if stored != dpA {
|
||||
t.Errorf("stored dp_uuid=%q, want %q", stored, dpA)
|
||||
}
|
||||
|
||||
// Unknown device → error (not silently minted against nothing).
|
||||
if _, _, err := ns.EnsureDeviceDpUUID(ctx, 1, "ghost"); err == nil {
|
||||
t.Errorf("expected error for unknown device")
|
||||
}
|
||||
}
|
||||
|
||||
// UserDeviceByDpUUID prefers the per-device credential and falls back to the
|
||||
// legacy account-level users.dp_uuid (deviceID=0).
|
||||
func TestSQLite_UserDeviceByDpUUID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := openSQLite(t)
|
||||
ns := nodes.NewSQLNodeStore(db)
|
||||
seedUser(t, db, 1) // seedUser sets users.dp_uuid='dp-u'
|
||||
seedDevice(t, db, 10, 1, "dev-a")
|
||||
|
||||
dpA, _, err := ns.EnsureDeviceDpUUID(ctx, 1, "dev-a")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure: %v", err)
|
||||
}
|
||||
|
||||
// Per-device resolution.
|
||||
uid, did, ok, err := ns.UserDeviceByDpUUID(ctx, dpA)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("resolve device dp: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if uid != 1 || did != 10 {
|
||||
t.Errorf("device dp → user=%d device=%d, want 1/10", uid, did)
|
||||
}
|
||||
|
||||
// Legacy account fallback (no device dimension).
|
||||
uid, did, ok, err = ns.UserDeviceByDpUUID(ctx, "dp-u")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("resolve account dp: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if uid != 1 || did != 0 {
|
||||
t.Errorf("account dp → user=%d device=%d, want 1/0", uid, did)
|
||||
}
|
||||
|
||||
// Unknown dp_uuid.
|
||||
if _, _, ok, _ := ns.UserDeviceByDpUUID(ctx, "nope"); ok {
|
||||
t.Errorf("unknown dp_uuid resolved unexpectedly")
|
||||
}
|
||||
}
|
||||
|
||||
// AccumulateDeviceUsage sums within (device, date) and keeps devices separate.
|
||||
func TestSQLite_AccumulateDeviceUsage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := openSQLite(t)
|
||||
ns := nodes.NewSQLNodeStore(db)
|
||||
seedUser(t, db, 1)
|
||||
seedDevice(t, db, 10, 1, "dev-a")
|
||||
seedDevice(t, db, 11, 1, "dev-b")
|
||||
day := time.Date(2026, 6, 24, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
if err := ns.AccumulateDeviceUsage(ctx, 1, 10, day, 100, 200, 1); err != nil {
|
||||
t.Fatalf("accum a1: %v", err)
|
||||
}
|
||||
if err := ns.AccumulateDeviceUsage(ctx, 1, 10, day, 50, 25, 2); err != nil {
|
||||
t.Fatalf("accum a2: %v", err)
|
||||
}
|
||||
if err := ns.AccumulateDeviceUsage(ctx, 1, 11, day, 7, 8, 9); err != nil {
|
||||
t.Fatalf("accum b1: %v", err)
|
||||
}
|
||||
|
||||
var up, down, mins int64
|
||||
if err := db.QueryRow(
|
||||
`SELECT bytes_up, bytes_down, minutes_used FROM usage_device_daily WHERE device_id=10`,
|
||||
).Scan(&up, &down, &mins); err != nil {
|
||||
t.Fatalf("read dev-a: %v", err)
|
||||
}
|
||||
if up != 150 || down != 225 || mins != 3 {
|
||||
t.Errorf("dev-a usage up=%d down=%d mins=%d, want 150/225/3", up, down, mins)
|
||||
}
|
||||
|
||||
if err := db.QueryRow(
|
||||
`SELECT bytes_up, bytes_down, minutes_used FROM usage_device_daily WHERE device_id=11`,
|
||||
).Scan(&up, &down, &mins); err != nil {
|
||||
t.Fatalf("read dev-b: %v", err)
|
||||
}
|
||||
if up != 7 || down != 8 || mins != 9 {
|
||||
t.Errorf("dev-b usage up=%d down=%d mins=%d, want 7/8/9", up, down, mins)
|
||||
}
|
||||
}
|
||||
|
||||
// AccountDayBytes returns the account's total (up+down) bytes for the day,
|
||||
// the basis for the GB 综合配额 connect gate.
|
||||
func TestSQLite_AccountDayBytes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := openSQLite(t)
|
||||
ns := nodes.NewSQLNodeStore(db)
|
||||
seedUser(t, db, 1)
|
||||
day := time.Date(2026, 6, 24, 0, 0, 0, 0, time.UTC)
|
||||
other := time.Date(2026, 6, 23, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// No usage yet → 0.
|
||||
if b, err := ns.AccountDayBytes(ctx, 1, day); err != nil || b != 0 {
|
||||
t.Fatalf("empty: bytes=%d err=%v, want 0/nil", b, err)
|
||||
}
|
||||
|
||||
if err := ns.AccumulateUsage(ctx, 1, day, 300, 700, 5); err != nil {
|
||||
t.Fatalf("accum: %v", err)
|
||||
}
|
||||
if err := ns.AccumulateUsage(ctx, 1, day, 100, 100, 1); err != nil {
|
||||
t.Fatalf("accum2: %v", err)
|
||||
}
|
||||
if b, err := ns.AccountDayBytes(ctx, 1, day); err != nil || b != 1200 {
|
||||
t.Errorf("day bytes=%d err=%v, want 1200", b, err)
|
||||
}
|
||||
// A different date is isolated.
|
||||
if b, err := ns.AccountDayBytes(ctx, 1, other); err != nil || b != 0 {
|
||||
t.Errorf("other day bytes=%d, want 0", b)
|
||||
}
|
||||
}
|
||||
|
||||
// DeviceUsageRange aggregates usage_device_daily per device over a window,
|
||||
// joins devices for display metadata, sums across days within the window,
|
||||
// excludes out-of-window dates and zero-usage devices, and orders busiest-first.
|
||||
func TestSQLite_DeviceUsageRange(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := openSQLite(t)
|
||||
ns := nodes.NewSQLNodeStore(db)
|
||||
us := usage.NewStore(db)
|
||||
seedUser(t, db, 1)
|
||||
seedDevice(t, db, 10, 1, "dev-a")
|
||||
seedDevice(t, db, 11, 1, "dev-b")
|
||||
seedDevice(t, db, 12, 1, "dev-quiet") // no usage → omitted
|
||||
|
||||
from := time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC)
|
||||
to := time.Date(2026, 6, 24, 0, 0, 0, 0, time.UTC)
|
||||
inWindow1 := time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC)
|
||||
inWindow2 := time.Date(2026, 6, 23, 0, 0, 0, 0, time.UTC)
|
||||
outWindow := time.Date(2026, 6, 19, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// dev-a: two in-window days summed + one out-of-window day excluded.
|
||||
if err := ns.AccumulateDeviceUsage(ctx, 1, 10, inWindow1, 1000, 2000, 5); err != nil {
|
||||
t.Fatalf("accum a1: %v", err)
|
||||
}
|
||||
if err := ns.AccumulateDeviceUsage(ctx, 1, 10, inWindow2, 500, 500, 3); err != nil {
|
||||
t.Fatalf("accum a2: %v", err)
|
||||
}
|
||||
if err := ns.AccumulateDeviceUsage(ctx, 1, 10, outWindow, 9999, 9999, 99); err != nil {
|
||||
t.Fatalf("accum a-out: %v", err)
|
||||
}
|
||||
// dev-b: smaller total (so it sorts after dev-a).
|
||||
if err := ns.AccumulateDeviceUsage(ctx, 1, 11, inWindow1, 100, 100, 1); err != nil {
|
||||
t.Fatalf("accum b1: %v", err)
|
||||
}
|
||||
|
||||
rows, err := us.DeviceUsageRange(ctx, 1, from, to)
|
||||
if err != nil {
|
||||
t.Fatalf("DeviceUsageRange: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("got %d device rows, want 2 (dev-quiet omitted)", len(rows))
|
||||
}
|
||||
|
||||
// Busiest first: dev-a (4000 bytes) before dev-b (200 bytes).
|
||||
if rows[0].UUID != "dev-a" {
|
||||
t.Errorf("rows[0].UUID=%q, want dev-a (busiest first)", rows[0].UUID)
|
||||
}
|
||||
a := rows[0]
|
||||
if a.BytesUp != 1500 || a.BytesDown != 2500 || a.MinutesUsed != 8 {
|
||||
t.Errorf("dev-a sums up=%d down=%d min=%d, want 1500/2500/8 (out-of-window excluded)",
|
||||
a.BytesUp, a.BytesDown, a.MinutesUsed)
|
||||
}
|
||||
if a.Name != "devdev-a" || a.Platform != "ios" {
|
||||
t.Errorf("dev-a join meta name=%q platform=%q, want devdev-a/ios", a.Name, a.Platform)
|
||||
}
|
||||
if rows[1].UUID != "dev-b" || rows[1].BytesUp != 100 || rows[1].BytesDown != 100 {
|
||||
t.Errorf("dev-b row wrong: %+v", rows[1])
|
||||
}
|
||||
|
||||
// A different user sees none of this account's devices.
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO users (id, uuid, email, pw_hash, dp_uuid, status, created_at)
|
||||
VALUES (2, 'u2-uuid', 'u2@example.com', 'x', 'dp-u2', 'active', ?)`,
|
||||
time.Now().UTC()); err != nil {
|
||||
t.Fatalf("seed user2: %v", err)
|
||||
}
|
||||
other, err := us.DeviceUsageRange(ctx, 2, from, to)
|
||||
if err != nil {
|
||||
t.Fatalf("DeviceUsageRange user2: %v", err)
|
||||
}
|
||||
if len(other) != 0 {
|
||||
t.Errorf("user2 leaked %d rows, want 0", len(other))
|
||||
}
|
||||
}
|
||||
|
||||
// EntitlementForUser loads daily_mb: free fallback = 500; an active pro
|
||||
// subscription = 102400 (from the 000015 seed).
|
||||
func TestSQLite_EntitlementForUser_DailyMB(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := openSQLite(t)
|
||||
ns := nodes.NewSQLNodeStore(db)
|
||||
seedUser(t, db, 1)
|
||||
|
||||
// No subscription → free defaults.
|
||||
ent, err := ns.EntitlementForUser(ctx, 1)
|
||||
if err != nil || ent == nil {
|
||||
t.Fatalf("entitlement: %v", err)
|
||||
}
|
||||
if !ent.AdGate || !ent.DailyMB.Valid || ent.DailyMB.Int64 != 500 {
|
||||
t.Errorf("free: adGate=%v dailyMB=%v/%d, want true/valid/500",
|
||||
ent.AdGate, ent.DailyMB.Valid, ent.DailyMB.Int64)
|
||||
}
|
||||
|
||||
// Active pro subscription (plan_id=2 from seed) → daily_mb 102400, no ad gate.
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO subscriptions (user_id, plan_id, expires_at, source, created_at)
|
||||
VALUES (1, 2, ?, 'code', ?)`,
|
||||
time.Now().UTC().Add(24*time.Hour), time.Now().UTC()); err != nil {
|
||||
t.Fatalf("seed subscription: %v", err)
|
||||
}
|
||||
ent, err = ns.EntitlementForUser(ctx, 1)
|
||||
if err != nil || ent == nil {
|
||||
t.Fatalf("entitlement pro: %v", err)
|
||||
}
|
||||
if ent.AdGate || !ent.DailyMB.Valid || ent.DailyMB.Int64 != 102400 {
|
||||
t.Errorf("pro: adGate=%v dailyMB=%v/%d, want false/valid/102400",
|
||||
ent.AdGate, ent.DailyMB.Valid, ent.DailyMB.Int64)
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,56 @@ func (h *UsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(usageResponse{Points: points})
|
||||
}
|
||||
|
||||
// DeviceUsageHandler serves GET /v1/usage/devices?days=N — per-device usage
|
||||
// totals for the authenticated account ("下分设备" breakdown).
|
||||
type DeviceUsageHandler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
// NewDeviceUsageHandler creates a DeviceUsageHandler.
|
||||
func NewDeviceUsageHandler(svc *Service) *DeviceUsageHandler { return &DeviceUsageHandler{svc: svc} }
|
||||
|
||||
type deviceUsageResponse struct {
|
||||
Devices []DeviceUsagePoint `json:"devices"`
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (h *DeviceUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
userID := userIDFromContext(r)
|
||||
if userID == 0 {
|
||||
apierr.WriteJSON(w, http.StatusUnauthorized, &apierr.Error{
|
||||
Code: "UNAUTHORIZED",
|
||||
MessageZH: "请先登录",
|
||||
MessageEn: "Authentication required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
days := 7
|
||||
if v := r.URL.Query().Get("days"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 1 || n > 90 {
|
||||
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
days = n
|
||||
}
|
||||
|
||||
devices, apiErr := h.svc.DeviceUsage(r.Context(), userID, days)
|
||||
if apiErr != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(deviceUsageResponse{Devices: devices})
|
||||
}
|
||||
|
||||
// AdsUnlockHandler serves POST /v1/ads/unlock.
|
||||
type AdsUnlockHandler struct {
|
||||
svc *Service
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package usage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/codes"
|
||||
)
|
||||
|
||||
// authedRequest builds a request whose context carries an authenticated user id,
|
||||
// mirroring what auth.RequireAuth installs upstream.
|
||||
func authedRequest(method, target string, userID int64) *http.Request {
|
||||
r := httptest.NewRequest(method, target, nil)
|
||||
if userID != 0 {
|
||||
r = r.WithContext(context.WithValue(r.Context(), codes.CtxKeyUserID, userID))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// The DeviceUsageHandler gates on auth, method and the days query param before
|
||||
// ever touching the service/DB — these branches are verified DB-free.
|
||||
func TestDeviceUsageHandler_Guards(t *testing.T) {
|
||||
h := NewDeviceUsageHandler(NewService(nil, nil, nil, 0))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
req *http.Request
|
||||
status int
|
||||
}{
|
||||
{"no auth → 401", authedRequest(http.MethodGet, "/v1/usage/devices", 0), http.StatusUnauthorized},
|
||||
{"wrong method → 405", authedRequest(http.MethodPost, "/v1/usage/devices", 7), http.StatusMethodNotAllowed},
|
||||
{"days out of range → 400", authedRequest(http.MethodGet, "/v1/usage/devices?days=999", 7), http.StatusBadRequest},
|
||||
{"days non-numeric → 400", authedRequest(http.MethodGet, "/v1/usage/devices?days=abc", 7), http.StatusBadRequest},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, c.req)
|
||||
if w.Code != c.status {
|
||||
t.Errorf("status=%d, want %d (body=%s)", w.Code, c.status, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,49 @@ func (svc *Service) UsageCurve(ctx context.Context, userID int64, days int) ([]U
|
||||
return points, nil
|
||||
}
|
||||
|
||||
// DeviceUsagePoint is one device's aggregated usage over the requested window,
|
||||
// used by the stats page's per-device ("下分设备") breakdown.
|
||||
type DeviceUsagePoint struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"` // ios | android | windows | macos
|
||||
BytesUp uint64 `json:"bytes_up"`
|
||||
BytesDown uint64 `json:"bytes_down"`
|
||||
MinutesUsed int `json:"minutes_used"`
|
||||
}
|
||||
|
||||
// DeviceUsage returns per-device usage totals for userID over the last `days`
|
||||
// (UTC), busiest device first. days is clamped to [1, 90]. Devices with no
|
||||
// usage in the window are omitted (the response is never nil — empty slice).
|
||||
func (svc *Service) DeviceUsage(ctx context.Context, userID int64, days int) ([]DeviceUsagePoint, *apierr.Error) {
|
||||
if days < 1 {
|
||||
days = 7
|
||||
}
|
||||
if days > 90 {
|
||||
days = 90
|
||||
}
|
||||
|
||||
today := utcToday()
|
||||
from := today.AddDate(0, 0, -(days - 1))
|
||||
|
||||
rows, err := svc.store.DeviceUsageRange(ctx, userID, from, today)
|
||||
if err != nil {
|
||||
return nil, apierr.ErrInternal
|
||||
}
|
||||
points := make([]DeviceUsagePoint, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
points = append(points, DeviceUsagePoint{
|
||||
UUID: r.UUID,
|
||||
Name: r.Name,
|
||||
Platform: r.Platform,
|
||||
BytesUp: r.BytesUp,
|
||||
BytesDown: r.BytesDown,
|
||||
MinutesUsed: r.MinutesUsed,
|
||||
})
|
||||
}
|
||||
return points, nil
|
||||
}
|
||||
|
||||
// TodaySummary is the /v1/me today_usage block.
|
||||
type TodaySummary struct {
|
||||
MinutesUsed int `json:"minutes_used"`
|
||||
|
||||
@@ -107,6 +107,48 @@ func (s *Store) GetUsageRange(ctx context.Context, userID int64, from, to time.T
|
||||
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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE plans DROP COLUMN daily_mb;
|
||||
DROP TABLE IF EXISTS usage_device_daily;
|
||||
DROP INDEX idx_devices_dp_uuid ON devices;
|
||||
ALTER TABLE devices DROP COLUMN dp_uuid;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 每设备归因 + GB 综合配额(todo #5 Phase 2)
|
||||
--
|
||||
-- ① devices.dp_uuid:每设备独立的数据面凭证(替代账户级 users.dp_uuid 作为出网凭证)。
|
||||
-- 唯一索引而非内联 UNIQUE,与 SQLite 迁移对齐(NULL 不冲突,存量设备待回填)。
|
||||
-- ② usage_device_daily:按设备的每日用量;usage_daily 仍作账户综合 rollup。
|
||||
-- ③ plans.daily_mb:每日综合流量配额(MB,与 daily_minutes 并存,按账户综合卡)。
|
||||
-- 用 MB 而非 GB 以支持免费版 500MB。free 500 / pro 100GB / team 200GB(可调)。
|
||||
|
||||
ALTER TABLE devices ADD COLUMN dp_uuid CHAR(36) NULL;
|
||||
CREATE UNIQUE INDEX idx_devices_dp_uuid ON devices (dp_uuid);
|
||||
|
||||
CREATE TABLE usage_device_daily (
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
device_id BIGINT UNSIGNED NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
bytes_up BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
bytes_down BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
minutes_used INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (device_id, date),
|
||||
INDEX idx_usage_device_user_date (user_id, date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
ALTER TABLE plans ADD COLUMN daily_mb INT NULL;
|
||||
UPDATE plans SET daily_mb = 500 WHERE code = 'free';
|
||||
UPDATE plans SET daily_mb = 102400 WHERE code = 'pro';
|
||||
UPDATE plans SET daily_mb = 204800 WHERE code = 'team';
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE plans DROP COLUMN daily_mb;
|
||||
DROP INDEX IF EXISTS idx_usage_device_user_date;
|
||||
DROP TABLE IF EXISTS usage_device_daily;
|
||||
DROP INDEX IF EXISTS idx_devices_dp_uuid;
|
||||
ALTER TABLE devices DROP COLUMN dp_uuid;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- 每设备归因 + GB 综合配额(todo #5 Phase 2)
|
||||
--
|
||||
-- ① devices.dp_uuid:每设备独立的数据面凭证(替代账户级 users.dp_uuid 作为出网凭证)。
|
||||
-- SQLite ADD COLUMN 不支持 UNIQUE 约束 → 列加完再建唯一索引(NULL 视为相异,存量设备
|
||||
-- NULL 不冲突,待回填)。
|
||||
-- ② usage_device_daily:按设备的每日用量;usage_daily 仍作账户综合 rollup。
|
||||
-- ③ plans.daily_mb:每日综合流量配额(MB,与 daily_minutes 并存,按账户综合卡)。
|
||||
-- 用 MB 而非 GB 以支持免费版 500MB。free 500 / pro 100GB / team 200GB(可调)。
|
||||
|
||||
ALTER TABLE devices ADD COLUMN dp_uuid TEXT NULL;
|
||||
CREATE UNIQUE INDEX idx_devices_dp_uuid ON devices (dp_uuid);
|
||||
|
||||
CREATE TABLE usage_device_daily (
|
||||
user_id INTEGER NOT NULL,
|
||||
device_id INTEGER NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
bytes_up INTEGER NOT NULL DEFAULT 0,
|
||||
bytes_down INTEGER NOT NULL DEFAULT 0,
|
||||
minutes_used INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (device_id, date)
|
||||
);
|
||||
CREATE INDEX idx_usage_device_user_date ON usage_device_daily (user_id, date);
|
||||
|
||||
ALTER TABLE plans ADD COLUMN daily_mb INTEGER NULL;
|
||||
UPDATE plans SET daily_mb = 500 WHERE code = 'free';
|
||||
UPDATE plans SET daily_mb = 102400 WHERE code = 'pro';
|
||||
UPDATE plans SET daily_mb = 204800 WHERE code = 'team';
|
||||
Reference in New Issue
Block a user