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:
wangjia
2026-07-02 09:57:19 +08:00
parent 837bf55f6a
commit daaed39bac
2 changed files with 94 additions and 13 deletions
+28 -13
View File
@@ -290,6 +290,13 @@ func (h *Handler) ReportUsage(ctx context.Context, req *agentv1.UsageReport) (*a
windowEnd := time.Unix(req.WindowEndUnix, 0).UTC()
date := windowEnd.Truncate(24 * time.Hour)
// Account-level minutes are deduped to WALL-CLOCK per user: the免费额度是「所有
// 设备共同的时间」,同一账户多台设备在同一窗口都活跃时只能算 1 分钟,否则 N 台并发 →
// minutes_used = N×墙上时钟(超计,会把免费账号错误判耗尽)。字节是可加的 → 求和;
// 分钟按窗口取 max(= 窗口墙上分钟)去重。每设备维度(usage_device_*)仍逐台累加。
type acctAgg struct{ bytesUp, bytesDown, minutes int64 }
byUser := make(map[int64]*acctAgg)
for _, entry := range req.Entries {
if entry.DpUUID == "" {
continue
@@ -303,21 +310,18 @@ func (h *Handler) ReportUsage(ctx context.Context, req *agentv1.UsageReport) (*a
if !found {
continue
}
// Account-level rollup (always): daily (quota/today) + hourly (tz-aware
// display curve, keyed by the window-end's UTC hour).
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)
// Fold into the per-user account aggregate (bytes sum, minutes max = 墙上时钟去重).
a := byUser[userID]
if a == nil {
a = &acctAgg{}
byUser[userID] = a
}
if err := h.store.AccumulateHourly(ctx, userID, windowEnd,
entry.BytesUp, entry.BytesDown, entry.SessionMinutes,
); err != nil {
slog.Warn("nodes.Handler.ReportUsage: hourly accumulate failed",
"user_id", userID, "err", err)
a.bytesUp += entry.BytesUp
a.bytesDown += entry.BytesDown
if entry.SessionMinutes > a.minutes {
a.minutes = entry.SessionMinutes
}
// Per-device attribution (only when the dp_uuid maps to a registered device;
// Per-device attribution (per entry — each device's own bytes/minutes;
// deviceID==0 means a legacy account-level credential — no device dimension).
if deviceID > 0 {
if err := h.store.AccumulateDeviceUsage(ctx, userID, deviceID, date,
@@ -340,5 +344,16 @@ func (h *Handler) ReportUsage(ctx context.Context, req *agentv1.UsageReport) (*a
}
}
}
// Flush the account-level rollup once per user (deduped wall-clock minutes):
// daily (quota/today) + hourly (tz-aware display curve, keyed by window-end's UTC hour).
for userID, a := range byUser {
if err := h.store.AccumulateUsage(ctx, userID, date, a.bytesUp, a.bytesDown, a.minutes); err != nil {
slog.Warn("nodes.Handler.ReportUsage: accumulate failed", "user_id", userID, "err", err)
}
if err := h.store.AccumulateHourly(ctx, userID, windowEnd, a.bytesUp, a.bytesDown, a.minutes); err != nil {
slog.Warn("nodes.Handler.ReportUsage: hourly accumulate failed", "user_id", userID, "err", err)
}
}
return &agentv1.UsageAck{}, nil
}