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:
@@ -69,6 +69,56 @@ func (h *UsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(usageResponse{Points: points})
|
||||
}
|
||||
|
||||
// DeviceUsageHandler serves GET /v1/usage/devices?days=N — per-device usage
|
||||
// totals for the authenticated account ("下分设备" breakdown).
|
||||
type DeviceUsageHandler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
// NewDeviceUsageHandler creates a DeviceUsageHandler.
|
||||
func NewDeviceUsageHandler(svc *Service) *DeviceUsageHandler { return &DeviceUsageHandler{svc: svc} }
|
||||
|
||||
type deviceUsageResponse struct {
|
||||
Devices []DeviceUsagePoint `json:"devices"`
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (h *DeviceUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
userID := userIDFromContext(r)
|
||||
if userID == 0 {
|
||||
apierr.WriteJSON(w, http.StatusUnauthorized, &apierr.Error{
|
||||
Code: "UNAUTHORIZED",
|
||||
MessageZH: "请先登录",
|
||||
MessageEn: "Authentication required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
days := 7
|
||||
if v := r.URL.Query().Get("days"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 1 || n > 90 {
|
||||
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
days = n
|
||||
}
|
||||
|
||||
devices, apiErr := h.svc.DeviceUsage(r.Context(), userID, days)
|
||||
if apiErr != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apiErr)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(deviceUsageResponse{Devices: devices})
|
||||
}
|
||||
|
||||
// AdsUnlockHandler serves POST /v1/ads/unlock.
|
||||
type AdsUnlockHandler struct {
|
||||
svc *Service
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package usage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/codes"
|
||||
)
|
||||
|
||||
// authedRequest builds a request whose context carries an authenticated user id,
|
||||
// mirroring what auth.RequireAuth installs upstream.
|
||||
func authedRequest(method, target string, userID int64) *http.Request {
|
||||
r := httptest.NewRequest(method, target, nil)
|
||||
if userID != 0 {
|
||||
r = r.WithContext(context.WithValue(r.Context(), codes.CtxKeyUserID, userID))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// The DeviceUsageHandler gates on auth, method and the days query param before
|
||||
// ever touching the service/DB — these branches are verified DB-free.
|
||||
func TestDeviceUsageHandler_Guards(t *testing.T) {
|
||||
h := NewDeviceUsageHandler(NewService(nil, nil, nil, 0))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
req *http.Request
|
||||
status int
|
||||
}{
|
||||
{"no auth → 401", authedRequest(http.MethodGet, "/v1/usage/devices", 0), http.StatusUnauthorized},
|
||||
{"wrong method → 405", authedRequest(http.MethodPost, "/v1/usage/devices", 7), http.StatusMethodNotAllowed},
|
||||
{"days out of range → 400", authedRequest(http.MethodGet, "/v1/usage/devices?days=999", 7), http.StatusBadRequest},
|
||||
{"days non-numeric → 400", authedRequest(http.MethodGet, "/v1/usage/devices?days=abc", 7), http.StatusBadRequest},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, c.req)
|
||||
if w.Code != c.status {
|
||||
t.Errorf("status=%d, want %d (body=%s)", w.Code, c.status, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user