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
+43
View File
@@ -85,6 +85,49 @@ func (svc *Service) UsageCurve(ctx context.Context, userID int64, days int) ([]U
return points, nil
}
// DeviceUsagePoint is one device's aggregated usage over the requested window,
// used by the stats page's per-device ("下分设备") breakdown.
type DeviceUsagePoint struct {
UUID string `json:"uuid"`
Name string `json:"name"`
Platform string `json:"platform"` // ios | android | windows | macos
BytesUp uint64 `json:"bytes_up"`
BytesDown uint64 `json:"bytes_down"`
MinutesUsed int `json:"minutes_used"`
}
// DeviceUsage returns per-device usage totals for userID over the last `days`
// (UTC), busiest device first. days is clamped to [1, 90]. Devices with no
// usage in the window are omitted (the response is never nil — empty slice).
func (svc *Service) DeviceUsage(ctx context.Context, userID int64, days int) ([]DeviceUsagePoint, *apierr.Error) {
if days < 1 {
days = 7
}
if days > 90 {
days = 90
}
today := utcToday()
from := today.AddDate(0, 0, -(days - 1))
rows, err := svc.store.DeviceUsageRange(ctx, userID, from, today)
if err != nil {
return nil, apierr.ErrInternal
}
points := make([]DeviceUsagePoint, 0, len(rows))
for _, r := range rows {
points = append(points, DeviceUsagePoint{
UUID: r.UUID,
Name: r.Name,
Platform: r.Platform,
BytesUp: r.BytesUp,
BytesDown: r.BytesDown,
MinutesUsed: r.MinutesUsed,
})
}
return points, nil
}
// TodaySummary is the /v1/me today_usage block.
type TodaySummary struct {
MinutesUsed int `json:"minutes_used"`