diff --git a/client/lib/services/account_api.dart b/client/lib/services/account_api.dart index b821a69..ccbfbf0 100644 --- a/client/lib/services/account_api.dart +++ b/client/lib/services/account_api.dart @@ -64,9 +64,12 @@ class AccountApi { await _c.postJson('/v1/me/devices/$uuid/logout'); } - /// GET /v1/usage?days=N — 最近 N 天用量(默认 7,后端范围 [1,90])。 + /// GET /v1/usage?days=N&tz_offset=M — 最近 N 天用量(默认 7,后端范围 [1,90])。 + /// 带本机时区偏移(分钟,如 UTC+8=480),服务端按「本地日」聚合 → 「今天」与本地日历一致 + /// (修「29 号却显示 28 号数据」的时区 bug)。 Future> usage({int days = 7}) async { - final body = await _c.getJson('/v1/usage?days=$days'); + final tz = DateTime.now().timeZoneOffset.inMinutes; + final body = await _c.getJson('/v1/usage?days=$days&tz_offset=$tz'); final raw = (body['points'] as List?) ?? const []; return raw.map((e) => UsagePoint.fromJson(e as Map)).toList(); } diff --git a/server/internal/nodes/grpc_test.go b/server/internal/nodes/grpc_test.go index 6b6cd8a..6317b91 100644 --- a/server/internal/nodes/grpc_test.go +++ b/server/internal/nodes/grpc_test.go @@ -36,6 +36,7 @@ type mockNodeStore struct { nodeUUID string configVer int64 usageAccum []mockUsageEntry + hourlyAccum []mockUsageEntry usersByDpUUID map[string]int64 // devicesByDpUUID maps a per-device dp_uuid → {userID, deviceID}. devicesByDpUUID map[string][2]int64 @@ -122,6 +123,15 @@ func (m *mockNodeStore) AccumulateUsage(_ context.Context, userID int64, date ti return nil } +func (m *mockNodeStore) AccumulateHourly(_ context.Context, userID int64, hour time.Time, + bytesUp, bytesDown, minutes int64, +) error { + m.mu.Lock() + defer m.mu.Unlock() + m.hourlyAccum = append(m.hourlyAccum, mockUsageEntry{userID, hour, bytesUp, bytesDown, minutes}) + return nil +} + func (m *mockNodeStore) EnsureDeviceDpUUID(_ context.Context, _ int64, _ string) (string, int64, error) { return "", 0, nil } diff --git a/server/internal/nodes/handler_grpc.go b/server/internal/nodes/handler_grpc.go index 194776b..a02253c 100644 --- a/server/internal/nodes/handler_grpc.go +++ b/server/internal/nodes/handler_grpc.go @@ -302,13 +302,20 @@ func (h *Handler) ReportUsage(ctx context.Context, req *agentv1.UsageReport) (*a if !found { continue } - // Account-level rollup (always). + // 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) } + 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) + } // Per-device attribution (only when the dp_uuid maps to a registered device; // deviceID==0 means a legacy account-level credential — no device dimension). if deviceID > 0 { diff --git a/server/internal/nodes/store.go b/server/internal/nodes/store.go index 1213c77..2653518 100644 --- a/server/internal/nodes/store.go +++ b/server/internal/nodes/store.go @@ -94,6 +94,11 @@ type NodeStore interface { AccumulateUsage(ctx context.Context, userID int64, date time.Time, bytesUp, bytesDown int64, minutes int64) error + // AccumulateHourly adds bytes/minutes to usage_hourly keyed by the UTC epoch + // hour of `hour` (for timezone-aware local-day bucketing at query time). + AccumulateHourly(ctx context.Context, userID int64, hour time.Time, + bytesUp, bytesDown int64, minutes int64) error + // AccountDayBytes returns the account's total bytes (up+down) for userID on // date — the basis for the GB 综合配额 connect gate. 0 when no usage yet. AccountDayBytes(ctx context.Context, userID int64, date time.Time) (int64, error) @@ -475,3 +480,24 @@ func (s *SQLNodeStore) AccumulateUsage( } return nil } + +// AccumulateHourly adds bytes/minutes to usage_hourly for (userID, UTC epoch +// hour). Epoch hour = floor(unix_seconds / 3600); the read side re-buckets these +// into the client's local days. Idempotent within an hour. +func (s *SQLNodeStore) AccumulateHourly( + ctx context.Context, userID int64, hour time.Time, + bytesUp, bytesDown int64, minutes int64, +) error { + h := hour.UTC().Unix() / 3600 + q := ` + INSERT INTO usage_hourly (user_id, hour, bytes_up, bytes_down, minutes_used) + VALUES (?, ?, ?, ?, ?) + ` + s.dialect.Upsert([]string{"user_id", "hour"}, + "bytes_up = bytes_up + EXCLUDED.bytes_up", + "bytes_down = bytes_down + EXCLUDED.bytes_down", + "minutes_used = minutes_used + EXCLUDED.minutes_used") + if _, err := s.db.ExecContext(ctx, q, userID, h, bytesUp, bytesDown, minutes); err != nil { + return fmt.Errorf("nodes.SQLNodeStore.AccumulateHourly: %w", err) + } + return nil +} diff --git a/server/internal/store/sqlite_migrate_test.go b/server/internal/store/sqlite_migrate_test.go index b8b0a15..df13dec 100644 --- a/server/internal/store/sqlite_migrate_test.go +++ b/server/internal/store/sqlite_migrate_test.go @@ -29,8 +29,8 @@ func TestSQLiteMigrateUpDown(t *testing.T) { if dirty { t.Fatalf("schema dirty after MigrateUp") } - if v != 16 { - t.Errorf("version = %d, want 16", v) + if v != 17 { + t.Errorf("version = %d, want 17", v) } // 2. Core tables exist. @@ -38,7 +38,7 @@ func TestSQLiteMigrateUpDown(t *testing.T) { "users", "devices", "plans", "subscriptions", "code_batches", "codes", "usage_daily", "audit_log", "providers", "nodes", "node_events", "directory_version", "provision_idempotency", "replacements", "admins", - "connect_credentials", "usage_device_daily", "sessions", + "connect_credentials", "usage_device_daily", "sessions", "usage_hourly", } { var name string err := db.QueryRow( diff --git a/server/internal/store/usage_tz_test.go b/server/internal/store/usage_tz_test.go new file mode 100644 index 0000000..1b76af3 --- /dev/null +++ b/server/internal/store/usage_tz_test.go @@ -0,0 +1,64 @@ +package store_test + +import ( + "context" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/usage" +) + +// The timezone-fix proof: usage stored at UTC 18:00 yesterday must land on a +// DIFFERENT local calendar day under UTC (offset 0) vs UTC+8 (offset 480) — +// exactly the "今天却显示昨天数据" bug. UsageCurve re-buckets UTC hourly rows by +// the client's local day so "today" matches the user's calendar. +func TestSQLite_UsageCurve_LocalDayBucketing(t *testing.T) { + ctx := context.Background() + db := openSQLite(t) + + // Yesterday 18:00 UTC = today 02:00 in UTC+8 → straddles the day boundary. + now := time.Now().UTC() + y := now.AddDate(0, 0, -1) + utc18 := time.Date(y.Year(), y.Month(), y.Day(), 18, 0, 0, 0, time.UTC) + hourEpoch := utc18.Unix() / 3600 + if _, err := db.Exec( + `INSERT INTO usage_hourly (user_id, hour, bytes_up, bytes_down, minutes_used) VALUES (1,?,?,?,?)`, + hourEpoch, 1000, 2000, 5); err != nil { + t.Fatalf("seed: %v", err) + } + + cn := time.FixedZone("UTC+8", 8*3600) + utcDate := utc18.Format("2006-01-02") // yesterday (UTC) + cnDate := utc18.In(cn).Format("2006-01-02") // today (UTC+8) + if utcDate == cnDate { + t.Fatalf("test setup expected different dates, got %s == %s", utcDate, cnDate) + } + + svc := usage.NewService(usage.NewStore(db), nil, nil, time.Hour) + + find := func(days, offset int, date string) (usage.UsagePoint, bool) { + pts, e := svc.UsageCurve(ctx, 1, days, offset) + if e != nil { + t.Fatalf("UsageCurve(off=%d): %v", offset, e) + } + for _, p := range pts { + if p.Date == date { + return p, true + } + } + return usage.UsagePoint{}, false + } + + // Offset 0 (UTC): the data is on yesterday's UTC date. + if p, ok := find(3, 0, utcDate); !ok || p.BytesDown != 2000 || p.BytesUp != 1000 || p.MinutesUsed != 5 { + t.Fatalf("UTC bucket: want data on %s, got ok=%v %+v", utcDate, ok, p) + } + // Offset +480 (UTC+8): the SAME data is on today's local date instead. + if p, ok := find(3, 480, cnDate); !ok || p.BytesDown != 2000 { + t.Fatalf("UTC+8 bucket: want data on %s, got ok=%v %+v", cnDate, ok, p) + } + // And under +480 it must NOT appear on the UTC date. + if p, ok := find(3, 480, utcDate); ok && p.BytesDown != 0 { + t.Fatalf("UTC+8: data leaked onto UTC date %s: %+v", utcDate, p) + } +} diff --git a/server/internal/usage/handler.go b/server/internal/usage/handler.go index 85e42da..ac01921 100644 --- a/server/internal/usage/handler.go +++ b/server/internal/usage/handler.go @@ -58,7 +58,16 @@ func (h *UsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { days = n } - points, apiErr := h.svc.UsageCurve(r.Context(), userID, days) + // tz_offset: client UTC offset in minutes (e.g. 480 for UTC+8), so the curve + // is bucketed by the user's LOCAL calendar day. Absent/invalid → 0 (UTC). + offsetMin := 0 + if v := r.URL.Query().Get("tz_offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + offsetMin = n + } + } + + points, apiErr := h.svc.UsageCurve(r.Context(), userID, days, offsetMin) if apiErr != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apiErr) return diff --git a/server/internal/usage/service.go b/server/internal/usage/service.go index a2eb4e3..6fe99a0 100644 --- a/server/internal/usage/service.go +++ b/server/internal/usage/service.go @@ -46,38 +46,59 @@ type UsagePoint struct { MinutesUsed int `json:"minutes_used"` } -// UsageCurve returns the last `days` daily points for userID (UTC), with -// missing days zero-filled, oldest first. days is clamped to [1, 90]. -func (svc *Service) UsageCurve(ctx context.Context, userID int64, days int) ([]UsagePoint, *apierr.Error) { +// UsageCurve returns the last `days` usage points for userID bucketed in the +// CLIENT's timezone (offsetMin = client UTC offset in minutes, e.g. 480 for +// UTC+8), missing days zero-filled, oldest first. days clamped to [1,90]. +// +// It reads UTC hourly buckets (usage_hourly) and re-aggregates by local calendar +// day, so "today" matches the user's local date regardless of server timezone +// (fixes "29号却显示28号数据"). offsetMin out of ±14h falls back to 0 (UTC). +func (svc *Service) UsageCurve(ctx context.Context, userID int64, days, offsetMin int) ([]UsagePoint, *apierr.Error) { if days < 1 { days = 7 } if days > 90 { days = 90 } + if offsetMin < -14*60 || offsetMin > 14*60 { + offsetMin = 0 + } + loc := time.FixedZone("client", offsetMin*60) - today := utcToday() - from := today.AddDate(0, 0, -(days - 1)) + now := nowFunc().UTC() + ln := now.In(loc) + localToday := time.Date(ln.Year(), ln.Month(), ln.Day(), 0, 0, 0, 0, loc) + fromLocal := localToday.AddDate(0, 0, -(days - 1)) - rows, err := svc.store.GetUsageRange(ctx, userID, from, today) + fromHour := fromLocal.UTC().Unix() / 3600 + toHour := now.Unix() / 3600 + + rows, err := svc.store.HourlyRange(ctx, userID, fromHour, toHour) if err != nil { return nil, apierr.ErrInternal } - byDate := make(map[string]DailyUsage, len(rows)) - for _, r := range rows { - byDate[r.Date.UTC().Format(dateLayout)] = r + type agg struct { + up, down uint64 + min int + } + byDay := make(map[string]*agg) + for _, b := range rows { + key := time.Unix(b.Hour*3600, 0).In(loc).Format(dateLayout) + a := byDay[key] + if a == nil { + a = &agg{} + byDay[key] = a + } + a.up += b.BytesUp + a.down += b.BytesDown + a.min += b.MinutesUsed } points := make([]UsagePoint, 0, days) for i := 0; i < days; i++ { - d := from.AddDate(0, 0, i).Format(dateLayout) - if u, ok := byDate[d]; ok { - points = append(points, UsagePoint{ - Date: d, - BytesUp: u.BytesUp, - BytesDown: u.BytesDown, - MinutesUsed: u.MinutesUsed, - }) + d := fromLocal.AddDate(0, 0, i).Format(dateLayout) + if a, ok := byDay[d]; ok { + points = append(points, UsagePoint{Date: d, BytesUp: a.up, BytesDown: a.down, MinutesUsed: a.min}) } else { points = append(points, UsagePoint{Date: d}) } diff --git a/server/internal/usage/store.go b/server/internal/usage/store.go index 9464f02..517c80d 100644 --- a/server/internal/usage/store.go +++ b/server/internal/usage/store.go @@ -107,6 +107,38 @@ func (s *Store) GetUsageRange(ctx context.Context, userID int64, from, to time.T return out, rows.Err() } +// HourBucket is one UTC epoch-hour's account usage (read from usage_hourly). +type HourBucket struct { + Hour int64 // unix epoch hour (UTC) = floor(unix_seconds / 3600) + BytesUp uint64 + BytesDown uint64 + MinutesUsed int +} + +// HourlyRange returns usage_hourly rows for userID with hour in [fromHour, toHour] +// (inclusive, epoch hours), oldest first. Empty hours are simply absent. +func (s *Store) HourlyRange(ctx context.Context, userID, fromHour, toHour int64) ([]HourBucket, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT hour, bytes_up, bytes_down, minutes_used + FROM usage_hourly + WHERE user_id = ? AND hour BETWEEN ? AND ? + ORDER BY hour ASC`, + userID, fromHour, toHour) + if err != nil { + return nil, fmt.Errorf("store.HourlyRange: %w", err) + } + defer rows.Close() + var out []HourBucket + for rows.Next() { + var b HourBucket + if err := rows.Scan(&b.Hour, &b.BytesUp, &b.BytesDown, &b.MinutesUsed); err != nil { + return nil, fmt.Errorf("store.HourlyRange scan: %w", err) + } + out = append(out, b) + } + 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. diff --git a/server/internal/usage/usage_integration_test.go b/server/internal/usage/usage_integration_test.go index e283704..11a0681 100644 --- a/server/internal/usage/usage_integration_test.go +++ b/server/internal/usage/usage_integration_test.go @@ -438,11 +438,14 @@ func TestIntUsageCurveZeroFill(t *testing.T) { uid := createUser(t, db, "dp-curve") today := time.Now().UTC() - // Only two of the last 7 days have data. - store.AggregateUsage(context.Background(), uid, today, 1000, 2000, 5) - store.AggregateUsage(context.Background(), uid, today.AddDate(0, 0, -3), 50, 60, 2) + epochHour := func(tm time.Time) int64 { return tm.UTC().Unix() / 3600 } + // Only two of the last 7 days have data (curve reads hourly buckets now). + db.Exec(`INSERT INTO usage_hourly (user_id, hour, bytes_up, bytes_down, minutes_used) VALUES (?,?,?,?,?)`, + uid, epochHour(today), 1000, 2000, 5) + db.Exec(`INSERT INTO usage_hourly (user_id, hour, bytes_up, bytes_down, minutes_used) VALUES (?,?,?,?,?)`, + uid, epochHour(today.AddDate(0, 0, -3)), 50, 60, 2) - points, apiErr := svc.UsageCurve(context.Background(), uid, 7) + points, apiErr := svc.UsageCurve(context.Background(), uid, 7, 0) if apiErr != nil { t.Fatalf("curve: %v", apiErr) } @@ -479,10 +482,11 @@ func TestIntUsageCurveOnlyCurrentUser(t *testing.T) { uA := createUser(t, db, "dp-A") uB := createUser(t, db, "dp-B") - store.AggregateUsage(context.Background(), uA, time.Now().UTC(), 111, 0, 1) - store.AggregateUsage(context.Background(), uB, time.Now().UTC(), 999, 0, 9) + h := time.Now().UTC().Unix() / 3600 + db.Exec(`INSERT INTO usage_hourly (user_id, hour, bytes_up, bytes_down, minutes_used) VALUES (?,?,?,?,?)`, uA, h, 111, 0, 1) + db.Exec(`INSERT INTO usage_hourly (user_id, hour, bytes_up, bytes_down, minutes_used) VALUES (?,?,?,?,?)`, uB, h, 999, 0, 9) - points, _ := svc.UsageCurve(context.Background(), uA, 1) + points, _ := svc.UsageCurve(context.Background(), uA, 1, 0) if len(points) != 1 || points[0].BytesUp != 111 { t.Errorf("user A curve leaked other user data: %+v", points) } diff --git a/server/migrations/mysql/000017_usage_hourly.down.sql b/server/migrations/mysql/000017_usage_hourly.down.sql new file mode 100644 index 0000000..3e2e707 --- /dev/null +++ b/server/migrations/mysql/000017_usage_hourly.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS usage_hourly; diff --git a/server/migrations/mysql/000017_usage_hourly.up.sql b/server/migrations/mysql/000017_usage_hourly.up.sql new file mode 100644 index 0000000..8ee9819 --- /dev/null +++ b/server/migrations/mysql/000017_usage_hourly.up.sql @@ -0,0 +1,9 @@ +-- 见 sqlite/000017 注释。hour = unix 纪元小时(UTC)。 +CREATE TABLE usage_hourly ( + user_id BIGINT UNSIGNED NOT NULL, + hour BIGINT NOT NULL, + bytes_up BIGINT UNSIGNED NOT NULL DEFAULT 0, + bytes_down BIGINT UNSIGNED NOT NULL DEFAULT 0, + minutes_used INT NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, hour) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/migrations/sqlite/000017_usage_hourly.down.sql b/server/migrations/sqlite/000017_usage_hourly.down.sql new file mode 100644 index 0000000..3e2e707 --- /dev/null +++ b/server/migrations/sqlite/000017_usage_hourly.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS usage_hourly; diff --git a/server/migrations/sqlite/000017_usage_hourly.up.sql b/server/migrations/sqlite/000017_usage_hourly.up.sql new file mode 100644 index 0000000..a59c3bb --- /dev/null +++ b/server/migrations/sqlite/000017_usage_hourly.up.sql @@ -0,0 +1,13 @@ +-- 按 UTC「小时桶」记录账户用量,供 /v1/usage 按客户端时区聚合成「本地日」曲线 +-- (修「今天显示昨天数据」的时区 bug:不再服务端按 UTC 预切天,而是存小时、查询时带 +-- 客户端时区偏移按本地日聚合)。usage_daily 仍保留作配额/今日/解锁。 +-- hour = unix 纪元小时数(floor(unix_seconds/3600),UTC),整数无时区歧义、聚合好算。 +-- 稀疏:无用量的小时不落行(用户非 24h 在线,实际远少于 24 行/天)。 +CREATE TABLE usage_hourly ( + user_id INTEGER NOT NULL, + hour INTEGER NOT NULL, -- unix 纪元小时(UTC) + bytes_up INTEGER NOT NULL DEFAULT 0, + bytes_down INTEGER NOT NULL DEFAULT 0, + minutes_used INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, hour) +);