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:
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user