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