From e333f59cb34371f1b651dacd13d0ff79601c8c1a Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 29 Jun 2026 08:04:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(stats):=20#10=20=E7=AC=AC=E2=91=A1?= =?UTF-8?q?=E5=B1=82=20=E2=80=94=E2=80=94=20=E7=BB=9F=E8=AE=A1=E9=A1=B5?= =?UTF-8?q?=E6=8C=89=E8=AE=BE=E5=A4=87=E8=BF=87=E6=BB=A4(per-device=20?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E6=97=A5=E6=9B=B2=E7=BA=BF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 镜像账户级时区曲线到「按设备」: - migration 000018 `usage_device_hourly`(每设备 UTC 小时桶,稀疏)。 - ReportUsage deviceID>0 时加 AccumulateDeviceHourly;NodeStore 接口+impl+mock。 - usage.Store.DeviceHourlyRange(JOIN devices 校验归属+解析 uuid→id);UsageCurve 增 deviceUUID 形参:空=账户级,非空=该设备本地日曲线(分桶逻辑复用)。 - /v1/usage?device=;客户端 account_api.usage(device)、usageProvider key 改 记录 (days,device)、stats_page 接 statsDeviceProvider → 选设备即重取该设备曲线。 测试:per-device 曲线隔离+归属校验(A的设备不进B)、UsageCurve(deviceUUID)、 migration v18、客户端 widget(选设备→/v1/usage 带 device=)。全量 go/flutter 绿。 Co-Authored-By: Claude Opus 4.8 --- client/lib/screens/stats_page.dart | 7 +- client/lib/services/account_api.dart | 5 +- client/lib/state/account_providers.dart | 7 +- .../test/golden/tablet_pages_golden_test.dart | 2 +- .../test/widget/stats_device_filter_test.dart | 87 ++++++++++++ server/internal/nodes/grpc_test.go | 6 + server/internal/nodes/handler_grpc.go | 7 + server/internal/nodes/store.go | 26 ++++ server/internal/store/sqlite_migrate_test.go | 6 +- .../internal/store/usage_device_curve_test.go | 56 ++++++++ server/internal/store/usage_tz_test.go | 2 +- server/internal/usage/handler.go | 5 +- server/internal/usage/service.go | 11 +- server/internal/usage/store.go | 27 ++++ .../internal/usage/usage_integration_test.go | 12 +- .../mysql/000018_usage_device_hourly.down.sql | 1 + .../mysql/000018_usage_device_hourly.up.sql | 11 ++ .../000018_usage_device_hourly.down.sql | 2 + .../sqlite/000018_usage_device_hourly.up.sql | 13 ++ todo/todo.html | 126 +++++++++--------- todo/todo.json | 6 +- 21 files changed, 343 insertions(+), 82 deletions(-) create mode 100644 client/test/widget/stats_device_filter_test.dart create mode 100644 server/internal/store/usage_device_curve_test.go create mode 100644 server/migrations/mysql/000018_usage_device_hourly.down.sql create mode 100644 server/migrations/mysql/000018_usage_device_hourly.up.sql create mode 100644 server/migrations/sqlite/000018_usage_device_hourly.down.sql create mode 100644 server/migrations/sqlite/000018_usage_device_hourly.up.sql diff --git a/client/lib/screens/stats_page.dart b/client/lib/screens/stats_page.dart index b9b319b..04d3ccc 100644 --- a/client/lib/screens/stats_page.dart +++ b/client/lib/screens/stats_page.dart @@ -23,7 +23,10 @@ class StatsPage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final c = context.pangolin; final t = ref.watch(appTextProvider); - final usageAsync = ref.watch(usageProvider(30)); + // 选中设备(#10②):null=全部=账户级;否则只算该设备的本地日曲线。 + final selDevice = ref.watch(statsDeviceProvider); + final usageKey = (days: 30, device: selDevice); + final usageAsync = ref.watch(usageProvider(usageKey)); final usage = usageAsync.valueOrNull ?? const []; // 先拉数据再渲染(不闪默认 0):auth 还在读 token,或已登录但 usage 还在拉 → 整页转圈。 // (auth 加载期 isLoggedIn=false → usage 会同步返回空,故必须并上 auth.isLoading。) @@ -62,7 +65,7 @@ class StatsPage extends ConsumerWidget { color: c.accent, backgroundColor: c.surface, onRefresh: () async { - ref.invalidate(usageProvider(30)); + ref.invalidate(usageProvider(usageKey)); ref.invalidate(deviceUsageProvider(30)); await ref.read(meProvider.notifier).refresh(); }, diff --git a/client/lib/services/account_api.dart b/client/lib/services/account_api.dart index ccbfbf0..416f9ed 100644 --- a/client/lib/services/account_api.dart +++ b/client/lib/services/account_api.dart @@ -67,9 +67,10 @@ class AccountApi { /// GET /v1/usage?days=N&tz_offset=M — 最近 N 天用量(默认 7,后端范围 [1,90])。 /// 带本机时区偏移(分钟,如 UTC+8=480),服务端按「本地日」聚合 → 「今天」与本地日历一致 /// (修「29 号却显示 28 号数据」的时区 bug)。 - Future> usage({int days = 7}) async { + Future> usage({int days = 7, String? device}) async { final tz = DateTime.now().timeZoneOffset.inMinutes; - final body = await _c.getJson('/v1/usage?days=$days&tz_offset=$tz'); + final dev = (device != null && device.isNotEmpty) ? '&device=$device' : ''; + final body = await _c.getJson('/v1/usage?days=$days&tz_offset=$tz$dev'); final raw = (body['points'] as List?) ?? const []; return raw.map((e) => UsagePoint.fromJson(e as Map)).toList(); } diff --git a/client/lib/state/account_providers.dart b/client/lib/state/account_providers.dart index c88229b..224e919 100644 --- a/client/lib/state/account_providers.dart +++ b/client/lib/state/account_providers.dart @@ -58,9 +58,12 @@ final plansProvider = FutureProvider>((ref) async { /// 最近 N 天用量(GET /v1/usage?days=N)。未登录返回空。 /// autoDispose:离开统计页即销毁,重进自动重取(配合下拉刷新/连接态变化 invalidate), /// 根治「本月流量取一次就冻住」。后端每 60s 才更新,无需更勤刷新。 -final usageProvider = FutureProvider.autoDispose.family, int>((ref, days) async { +/// 用量曲线 key:days + 可选设备 uuid(null = 全部/账户级)。 +typedef UsageKey = ({int days, String? device}); + +final usageProvider = FutureProvider.autoDispose.family, UsageKey>((ref, key) async { if (!ref.watch(authProvider).isLoggedIn) return const []; - return ref.read(accountApiProvider).usage(days: days); + return ref.read(accountApiProvider).usage(days: key.days, device: key.device); }); /// 最近 N 天按设备用量(GET /v1/usage/devices?days=N)。统计页设备下拉/明细用。 diff --git a/client/test/golden/tablet_pages_golden_test.dart b/client/test/golden/tablet_pages_golden_test.dart index b41e3d4..1856836 100644 --- a/client/test/golden/tablet_pages_golden_test.dart +++ b/client/test/golden/tablet_pages_golden_test.dart @@ -115,7 +115,7 @@ Future _shoot( localeProvider.overrideWith((ref) => lang), nodesProvider.overrideWith(_FakeNodesNotifier.new), meProvider.overrideWith(_FakeMeNotifier.new), - usageProvider(30).overrideWith((ref) async => _demoUsage), + usageProvider((days: 30, device: null)).overrideWith((ref) async => _demoUsage), deviceUsageProvider(30).overrideWith((ref) async => _demoDeviceUsage), ], child: MaterialApp( diff --git a/client/test/widget/stats_device_filter_test.dart b/client/test/widget/stats_device_filter_test.dart new file mode 100644 index 0000000..1536bfd --- /dev/null +++ b/client/test/widget/stats_device_filter_test.dart @@ -0,0 +1,87 @@ +// stats_device_filter_test.dart — #10②:统计页选设备 → /v1/usage 带 device 参数。 +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pangolin_vpn/l10n/strings_zh.dart'; +import 'package:pangolin_vpn/pangolin_theme.dart'; +import 'package:pangolin_vpn/screens/stats_page.dart'; +import 'package:pangolin_vpn/services/api_client.dart'; +import 'package:pangolin_vpn/services/token_store.dart'; +import 'package:pangolin_vpn/state/account_providers.dart'; +import 'package:pangolin_vpn/state/auth_provider.dart'; + +import '../helpers/harness.dart'; + +class _LoggedIn implements TokenStore { + const _LoggedIn(); + @override + Future loadAccessToken() async => 'tok'; + @override + Future loadRefreshToken() async => null; + @override + Future saveTokens({required String access, required String refresh}) async {} + @override + Future clear() async {} + @override + Future markOnboarded() async {} + @override + Future isOnboarded() async => true; + @override + Future saveLastEmail(String email) async {} + @override + Future loadLastEmail() async => null; +} + +void main() { + setUpAll(disableGoogleFontsFetching); + const t = StringsZh(); + + testWidgets('选设备 → /v1/usage 带 device=', (tester) async { + await tester.binding.setSurfaceSize(const Size(900, 1200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final usageDeviceParams = []; + final mock = MockClient((req) async { + final p = req.url.path; + if (p == '/v1/me') { + return http.Response(jsonEncode({'email': 'u@e', 'plan': 'free', 'devices_used': 1, 'devices_max': 5}), 200); + } + if (p == '/v1/usage') { + usageDeviceParams.add(req.url.queryParameters['device']); + return http.Response(jsonEncode({'points': []}), 200); + } + if (p == '/v1/usage/devices') { + return http.Response(jsonEncode({'devices': [ + {'uuid': 'dev-1', 'name': 'iPhone', 'platform': 'ios', 'bytes_up': 10, 'bytes_down': 20, 'minutes_used': 3}, + ]}), 200); + } + return http.Response('{}', 200); + }); + + await tester.pumpWidget(ProviderScope( + overrides: [ + tokenStoreProvider.overrideWithValue(const _LoggedIn()), + apiClientProvider.overrideWithValue(ApiClient( + baseUrl: 'http://test.local', getToken: () => 'tok', refresh: () async => false, client: mock, + )), + ], + child: MaterialApp(theme: PangolinTheme.light, home: const Scaffold(body: StatsPage(isWide: true))), + )); + await tester.pumpAndSettle(); + + // 初次:账户级,不带 device。 + expect(usageDeviceParams.contains(null), isTrue, reason: '首拉应不带 device: $usageDeviceParams'); + + // 打开下拉,选 iPhone。 + await tester.tap(find.byType(PopupMenuButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text('iPhone').last); + await tester.pumpAndSettle(); + + expect(usageDeviceParams.contains('dev-1'), isTrue, reason: '选设备后应带 device=dev-1: $usageDeviceParams'); + }); +} diff --git a/server/internal/nodes/grpc_test.go b/server/internal/nodes/grpc_test.go index 6317b91..04f3589 100644 --- a/server/internal/nodes/grpc_test.go +++ b/server/internal/nodes/grpc_test.go @@ -132,6 +132,12 @@ func (m *mockNodeStore) AccumulateHourly(_ context.Context, userID int64, hour t return nil } +func (m *mockNodeStore) AccumulateDeviceHourly(_ context.Context, _, _ int64, _ time.Time, + _, _, _ int64, +) error { + 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 a02253c..c2e3414 100644 --- a/server/internal/nodes/handler_grpc.go +++ b/server/internal/nodes/handler_grpc.go @@ -325,6 +325,13 @@ func (h *Handler) ReportUsage(ctx context.Context, req *agentv1.UsageReport) (*a slog.Warn("nodes.Handler.ReportUsage: device accumulate failed", "user_id", userID, "device_id", deviceID, "err", err) } + // Per-device hourly (tz-aware per-device display curve, #10②). + if err := h.store.AccumulateDeviceHourly(ctx, userID, deviceID, windowEnd, + entry.BytesUp, entry.BytesDown, entry.SessionMinutes, + ); err != nil { + slog.Warn("nodes.Handler.ReportUsage: device hourly accumulate failed", + "user_id", userID, "device_id", deviceID, "err", err) + } // Heartbeat: keep the device's "online" status fresh while it reports. if err := h.store.TouchDeviceLastSeen(ctx, deviceID); err != nil { slog.Warn("nodes.Handler.ReportUsage: touch last_seen failed", diff --git a/server/internal/nodes/store.go b/server/internal/nodes/store.go index 2653518..43ddd6d 100644 --- a/server/internal/nodes/store.go +++ b/server/internal/nodes/store.go @@ -108,6 +108,12 @@ type NodeStore interface { AccumulateDeviceUsage(ctx context.Context, userID, deviceID int64, date time.Time, bytesUp, bytesDown int64, minutes int64) error + // AccumulateDeviceHourly adds bytes/minutes to usage_device_hourly keyed by + // (deviceID, UTC epoch hour) — the per-device counterpart of AccumulateHourly, + // for timezone-aware per-device day bucketing at query time. + AccumulateDeviceHourly(ctx context.Context, userID, deviceID int64, hour time.Time, + bytesUp, bytesDown int64, minutes int64) error + // TouchDeviceLastSeen bumps devices.last_seen to now for an active device, // driving the "online" status (connect + periodic usage reports refresh it). TouchDeviceLastSeen(ctx context.Context, deviceID int64) error @@ -501,3 +507,23 @@ func (s *SQLNodeStore) AccumulateHourly( } return nil } + +// AccumulateDeviceHourly adds bytes/minutes to usage_device_hourly for +// (deviceID, UTC epoch hour). Per-device counterpart of AccumulateHourly. +func (s *SQLNodeStore) AccumulateDeviceHourly( + ctx context.Context, userID, deviceID int64, hour time.Time, + bytesUp, bytesDown int64, minutes int64, +) error { + h := hour.UTC().Unix() / 3600 + q := ` + INSERT INTO usage_device_hourly (user_id, device_id, hour, bytes_up, bytes_down, minutes_used) + VALUES (?, ?, ?, ?, ?, ?) + ` + s.dialect.Upsert([]string{"device_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, deviceID, h, bytesUp, bytesDown, minutes); err != nil { + return fmt.Errorf("nodes.SQLNodeStore.AccumulateDeviceHourly: %w", err) + } + return nil +} diff --git a/server/internal/store/sqlite_migrate_test.go b/server/internal/store/sqlite_migrate_test.go index df13dec..249b307 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 != 17 { - t.Errorf("version = %d, want 17", v) + if v != 18 { + t.Errorf("version = %d, want 18", 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", "usage_hourly", + "connect_credentials", "usage_device_daily", "sessions", "usage_hourly", "usage_device_hourly", } { var name string err := db.QueryRow( diff --git a/server/internal/store/usage_device_curve_test.go b/server/internal/store/usage_device_curve_test.go new file mode 100644 index 0000000..f062bd1 --- /dev/null +++ b/server/internal/store/usage_device_curve_test.go @@ -0,0 +1,56 @@ +package store_test + +import ( + "context" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/usage" +) + +// Per-device curve (#10②): UsageCurve(deviceUUID) must return only that device's +// data, account curve (deviceUUID="") the total, and a user must never read +// another user's device (JOIN enforces ownership). +func TestSQLite_UsageCurve_PerDevice(t *testing.T) { + ctx := context.Background() + db := openSQLite(t) + mustExec := func(q string, args ...any) { + if _, err := db.Exec(q, args...); err != nil { + t.Fatalf("seed %q: %v", q, err) + } + } + mustExec(`INSERT INTO users (id, uuid, email, pw_hash, dp_uuid, status) VALUES (1,'u1','u1@e','h','dp1','active'),(2,'u2','u2@e','h','dp2','active')`) + mustExec(`INSERT INTO devices (id, uuid, user_id, name, platform) VALUES (10,'devA',1,'A','macos'),(20,'devB',1,'B','ios'),(30,'devC',2,'C','windows')`) + + h := time.Now().UTC().Unix()/3600 - 2 // 2h ago, within window + mustExec(`INSERT INTO usage_device_hourly (user_id, device_id, hour, bytes_up, bytes_down, minutes_used) VALUES (1,10,?,1000,2000,5),(1,20,?,300,400,2),(2,30,?,999,0,9)`, h, h, h) + mustExec(`INSERT INTO usage_hourly (user_id, hour, bytes_up, bytes_down, minutes_used) VALUES (1,?,1300,2400,7)`, h) + + svc := usage.NewService(usage.NewStore(db), nil, nil, time.Hour) + sum := func(uid int64, dev string) (up, down uint64, mins int) { + pts, e := svc.UsageCurve(ctx, uid, 3, 0, dev) + if e != nil { + t.Fatalf("UsageCurve(dev=%q): %v", dev, e) + } + for _, p := range pts { + up += p.BytesUp + down += p.BytesDown + mins += p.MinutesUsed + } + return + } + + if up, down, m := sum(1, "devA"); up != 1000 || down != 2000 || m != 5 { + t.Errorf("devA curve = %d/%d/%d, want 1000/2000/5", up, down, m) + } + if up, _, _ := sum(1, "devB"); up != 300 { + t.Errorf("devB curve up = %d, want 300", up) + } + if up, _, _ := sum(1, ""); up != 1300 { + t.Errorf("account curve up = %d, want 1300", up) + } + // user 1 querying user 2's device → empty (ownership via JOIN). + if up, _, _ := sum(1, "devC"); up != 0 { + t.Errorf("cross-user leak: devC visible to user1, up=%d", up) + } +} diff --git a/server/internal/store/usage_tz_test.go b/server/internal/store/usage_tz_test.go index 1b76af3..df3a5f6 100644 --- a/server/internal/store/usage_tz_test.go +++ b/server/internal/store/usage_tz_test.go @@ -37,7 +37,7 @@ func TestSQLite_UsageCurve_LocalDayBucketing(t *testing.T) { 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) + pts, e := svc.UsageCurve(ctx, 1, days, offset, "") if e != nil { t.Fatalf("UsageCurve(off=%d): %v", offset, e) } diff --git a/server/internal/usage/handler.go b/server/internal/usage/handler.go index ac01921..fb0fb2e 100644 --- a/server/internal/usage/handler.go +++ b/server/internal/usage/handler.go @@ -67,7 +67,10 @@ func (h *UsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } - points, apiErr := h.svc.UsageCurve(r.Context(), userID, days, offsetMin) + // device (UUID, optional): when set, return that device's curve only (#10②). + deviceUUID := r.URL.Query().Get("device") + + points, apiErr := h.svc.UsageCurve(r.Context(), userID, days, offsetMin, deviceUUID) 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 6fe99a0..cfa289e 100644 --- a/server/internal/usage/service.go +++ b/server/internal/usage/service.go @@ -53,7 +53,8 @@ type UsagePoint struct { // 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) { +// deviceUUID == "" → account-level curve; non-empty → that device's curve only. +func (svc *Service) UsageCurve(ctx context.Context, userID int64, days, offsetMin int, deviceUUID string) ([]UsagePoint, *apierr.Error) { if days < 1 { days = 7 } @@ -73,7 +74,13 @@ func (svc *Service) UsageCurve(ctx context.Context, userID int64, days, offsetMi fromHour := fromLocal.UTC().Unix() / 3600 toHour := now.Unix() / 3600 - rows, err := svc.store.HourlyRange(ctx, userID, fromHour, toHour) + var rows []HourBucket + var err error + if deviceUUID == "" { + rows, err = svc.store.HourlyRange(ctx, userID, fromHour, toHour) + } else { + rows, err = svc.store.DeviceHourlyRange(ctx, userID, deviceUUID, fromHour, toHour) + } if err != nil { return nil, apierr.ErrInternal } diff --git a/server/internal/usage/store.go b/server/internal/usage/store.go index 517c80d..8523008 100644 --- a/server/internal/usage/store.go +++ b/server/internal/usage/store.go @@ -139,6 +139,33 @@ func (s *Store) HourlyRange(ctx context.Context, userID, fromHour, toHour int64) return out, rows.Err() } +// DeviceHourlyRange returns one device's UTC hourly buckets (usage_device_hourly) +// for hour in [fromHour, toHour], oldest first. The JOIN both resolves the +// client-facing device UUID to its internal id and enforces ownership (user_id), +// so a user can never read another user's device curve. +func (s *Store) DeviceHourlyRange(ctx context.Context, userID int64, deviceUUID string, fromHour, toHour int64) ([]HourBucket, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT uh.hour, uh.bytes_up, uh.bytes_down, uh.minutes_used + FROM usage_device_hourly uh + JOIN devices d ON d.id = uh.device_id + WHERE d.user_id = ? AND d.uuid = ? AND uh.hour BETWEEN ? AND ? + ORDER BY uh.hour ASC`, + userID, deviceUUID, fromHour, toHour) + if err != nil { + return nil, fmt.Errorf("store.DeviceHourlyRange: %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.DeviceHourlyRange 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 11a0681..3becc6b 100644 --- a/server/internal/usage/usage_integration_test.go +++ b/server/internal/usage/usage_integration_test.go @@ -110,6 +110,14 @@ func applySchema(db *sql.DB) error { ad_unlocked_at DATETIME(6) NULL, PRIMARY KEY (user_id, date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + `CREATE TABLE IF NOT EXISTS 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`, `INSERT IGNORE INTO plans (code, max_devices, daily_minutes, ad_gate) VALUES ('free',1,10,TRUE), ('pro',5,NULL,FALSE), ('team',10,NULL,FALSE)`, } @@ -445,7 +453,7 @@ func TestIntUsageCurveZeroFill(t *testing.T) { 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, 0) + points, apiErr := svc.UsageCurve(context.Background(), uid, 7, 0, "") if apiErr != nil { t.Fatalf("curve: %v", apiErr) } @@ -486,7 +494,7 @@ func TestIntUsageCurveOnlyCurrentUser(t *testing.T) { 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, 0) + 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/000018_usage_device_hourly.down.sql b/server/migrations/mysql/000018_usage_device_hourly.down.sql new file mode 100644 index 0000000..e6a21ca --- /dev/null +++ b/server/migrations/mysql/000018_usage_device_hourly.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS usage_device_hourly; diff --git a/server/migrations/mysql/000018_usage_device_hourly.up.sql b/server/migrations/mysql/000018_usage_device_hourly.up.sql new file mode 100644 index 0000000..8a0dc49 --- /dev/null +++ b/server/migrations/mysql/000018_usage_device_hourly.up.sql @@ -0,0 +1,11 @@ +-- 见 sqlite/000018 注释。 +CREATE TABLE usage_device_hourly ( + user_id BIGINT UNSIGNED NOT NULL, + device_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 (device_id, hour), + INDEX idx_usage_device_hourly_user (user_id, hour) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/migrations/sqlite/000018_usage_device_hourly.down.sql b/server/migrations/sqlite/000018_usage_device_hourly.down.sql new file mode 100644 index 0000000..6b710cf --- /dev/null +++ b/server/migrations/sqlite/000018_usage_device_hourly.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_usage_device_hourly_user; +DROP TABLE IF EXISTS usage_device_hourly; diff --git a/server/migrations/sqlite/000018_usage_device_hourly.up.sql b/server/migrations/sqlite/000018_usage_device_hourly.up.sql new file mode 100644 index 0000000..708a235 --- /dev/null +++ b/server/migrations/sqlite/000018_usage_device_hourly.up.sql @@ -0,0 +1,13 @@ +-- 每设备 UTC 小时桶,供统计页「按设备」本地日曲线(#10 第②层)。 +-- 镜像 usage_hourly + device_id(如同 usage_device_daily 之于 usage_daily)。 +-- hour = unix 纪元小时(UTC);稀疏(空小时不落行)。 +CREATE TABLE usage_device_hourly ( + user_id INTEGER NOT NULL, + device_id INTEGER NOT NULL, + hour INTEGER NOT NULL, + bytes_up INTEGER NOT NULL DEFAULT 0, + bytes_down INTEGER NOT NULL DEFAULT 0, + minutes_used INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (device_id, hour) +); +CREATE INDEX idx_usage_device_hourly_user ON usage_device_hourly (user_id, hour); diff --git a/todo/todo.html b/todo/todo.html index 87ce5ae..1e620e6 100644 --- a/todo/todo.html +++ b/todo/todo.html @@ -252,12 +252,12 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }

feature+windows — 项目 TODO

-
生成于 2026-06-28 · 真相源 todo/todo.json
+
生成于 2026-06-29 · 真相源 todo/todo.json
12全部
-
9待开始
+
7待开始
0开发中
-
1待验收
+
3待验收
2已验收
@@ -301,7 +301,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
- 📋 待开始 9 + 📋 待开始 7 ▴ 收起
@@ -423,35 +423,6 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
-
  • -
    - 打通每设备归因(设备注册 + per-device dp_uuid) -
    - 待开始 - 重要 - 二级 - - -
    -
    - -
    现状:多设备共用账户级 dp_uuid,统计无法区分设备;usage_device_daily 永远空,统计页设备下拉只有「全部」。基础设施已具备(devices.dp_uuid / EnsureDeviceDpUUID / usage_device_daily / UserDeviceByDpUUID)但未激活——客户端没 POST /v1/me/devices 注册本机,连接时 EnsureDeviceDpUUID 查不到设备行→回退账户 uuid。需:① 客户端启动/登录时注册本机设备(稳定 uuid + name + platform);② 连接时带该 device_id;③ 或让 EnsureDeviceDpUUID 缺行时自动建设备行。打通后每设备统计 + 统计页设备下拉才有真数据。是统计重设计「设备下拉」的前置依赖。
    - - - -
  • -
  • -
  • -
    - 统计页设备下拉接真实设备数据(依赖#上条) -
    - 待开始 - 一般 / 优化 - 二级 - - -
    -
    - -
    统计重设计的设备下拉,先只挂「全部」上线;每设备归因打通后,把下拉填上「全部 + 近30天用过的设备名」,选设备走 /v1/usage/devices 过滤。依赖每设备归因 TODO。
    - - - -
  • -
  • - 🔍 待验收 1 + 🔍 待验收 3 ▴ 收起
    @@ -614,6 +556,64 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
    🕐 2026-06-28 +
    +
    +
  • + +
  • +
    + 打通每设备归因(设备注册 + per-device dp_uuid) +
    + 待验收 + 重要 + 二级 + + +
    +
    + +
    现状:多设备共用账户级 dp_uuid,统计无法区分设备;usage_device_daily 永远空,统计页设备下拉只有「全部」。基础设施已具备(devices.dp_uuid / EnsureDeviceDpUUID / usage_device_daily / UserDeviceByDpUUID)但未激活——客户端没 POST /v1/me/devices 注册本机,连接时 EnsureDeviceDpUUID 查不到设备行→回退账户 uuid。需:① 客户端启动/登录时注册本机设备(稳定 uuid + name + platform);② 连接时带该 device_id;③ 或让 EnsureDeviceDpUUID 缺行时自动建设备行。打通后每设备统计 + 统计页设备下拉才有真数据。是统计重设计「设备下拉」的前置依赖。
    + + + +
  • + +
  • +
    + 统计页设备下拉接真实设备数据(依赖#上条) +
    + 待验收 + 一般 / 优化 + 二级 + + +
    +
    + +
    统计重设计的设备下拉,先只挂「全部」上线;每设备归因打通后,把下拉填上「全部 + 近30天用过的设备名」,选设备走 /v1/usage/devices 过滤。依赖每设备归因 TODO。
    + + +
  • diff --git a/todo/todo.json b/todo/todo.json index 69047a1..eca0a3b 100644 --- a/todo/todo.json +++ b/todo/todo.json @@ -1,7 +1,7 @@ { "meta": { "title": "feature+windows — 项目 TODO", - "updated_at": "2026-06-28T13:19:24.300Z" + "updated_at": "2026-06-29T00:04:33.913Z" }, "seq": 12, "items": [ @@ -137,7 +137,7 @@ "前端", "后端" ], - "status": "open", + "status": "done", "created_at": "2026-06-28T09:32:26.008Z", "done": false, "completed_at": null, @@ -152,7 +152,7 @@ "tags": [ "前端" ], - "status": "open", + "status": "done", "created_at": "2026-06-28T09:32:26.080Z", "done": false, "completed_at": null,