fix(server): 用量分钟数按墙上时钟去重 —— 多设备并发不再 N× 超计(bug)
agent 按每 dp_uuid(每设备)每窗口计 SessionMinutes=1,控制面 ReportUsage 逐条累加进 usage_daily.minutes_used → N 台并发 = N×墙上时钟(线上 chenxin 2903 分钟/天,不可能)。 免费额度是「所有设备共同的时间」,超计会把免费账号错误判「时长耗尽」而断连。 改 ReportUsage:账户维度按 user 分组去重 —— 字节仍求和(可加),分钟按窗口取 max (=窗口墙上分钟,恒为 1)去重,循环后每 user 各 flush 一次 AccumulateUsage/Hourly。 每设备维度(usage_device_*)保持逐台累加不变。usage_daily.minutes_used ≤ Σ 每设备分钟, /me 的 quota_today_min 与 ConnectNode 免费门随之正确。历史数据次日归零、不回填。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -942,6 +942,72 @@ func TestReportUsage_Accumulates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestReportUsage_AccountMinutesDedupPerUser verifies the account-level minutes are
|
||||
// deduped to WALL-CLOCK per user within a window: two devices of the same user active
|
||||
// in one window count as 1 minute (not 2), while bytes still sum and each device keeps
|
||||
// its own minute. Guards the多设备并发超计 fix.
|
||||
func TestReportUsage_AccountMinutesDedupPerUser(t *testing.T) {
|
||||
const nodeUUID = "test-node-usage-dedup"
|
||||
b := newTestServer(t, 1, nodeUUID)
|
||||
ctx := context.Background()
|
||||
// Two devices of user 101 (dev 55 & 66) + one device of user 202 (dev 77).
|
||||
b.store.devicesByDpUUID = map[string][2]int64{
|
||||
"dp-u1-d1": {101, 55},
|
||||
"dp-u1-d2": {101, 66},
|
||||
"dp-u2-d1": {202, 77},
|
||||
}
|
||||
|
||||
_, _, 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-u1-d1", BytesUp: 100, BytesDown: 200, SessionMinutes: 1},
|
||||
{DpUUID: "dp-u1-d2", BytesUp: 10, BytesDown: 20, SessionMinutes: 1},
|
||||
{DpUUID: "dp-u2-d1", BytesUp: 5, BytesDown: 7, SessionMinutes: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("ReportUsage: %v", err)
|
||||
}
|
||||
|
||||
// Account rollup: one entry per user; user 101 minutes deduped to 1 (not 2),
|
||||
// bytes summed across its two devices; user 202 = 1 minute.
|
||||
byUser := map[int64]mockUsageEntry{}
|
||||
for _, e := range b.store.usageLog() {
|
||||
byUser[e.UserID] = e
|
||||
}
|
||||
if len(byUser) != 2 {
|
||||
t.Fatalf("account rollup should have 2 users, got %d", len(byUser))
|
||||
}
|
||||
if u := byUser[101]; u.Minutes != 1 || u.BytesUp != 110 || u.BytesDown != 220 {
|
||||
t.Errorf("user101 account: minutes=%d bytesUp=%d bytesDown=%d, want 1/110/220 (墙上时钟去重)",
|
||||
u.Minutes, u.BytesUp, u.BytesDown)
|
||||
}
|
||||
if u := byUser[202]; u.Minutes != 1 || u.BytesUp != 5 {
|
||||
t.Errorf("user202 account: minutes=%d bytesUp=%d, want 1/5", u.Minutes, u.BytesUp)
|
||||
}
|
||||
|
||||
// Per-device: each of user101's two devices keeps its own minute (2 rows, 2 min total).
|
||||
var u1Rows int
|
||||
var u1Min int64
|
||||
for _, d := range b.store.deviceUsageLog() {
|
||||
if d.UserID == 101 {
|
||||
u1Rows++
|
||||
u1Min += d.Minutes
|
||||
}
|
||||
}
|
||||
if u1Rows != 2 || u1Min != 2 {
|
||||
t.Errorf("user101 per-device: rows=%d totalMinutes=%d, want 2/2 (每设备各计 1)", u1Rows, u1Min)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
Reference in New Issue
Block a user