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:
wangjia
2026-06-28 18:12:00 +08:00
parent 4f9d2d2cf3
commit 636a3bbf2f
14 changed files with 778 additions and 8 deletions
+42
View File
@@ -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