diff --git a/docs/telemetry-presence-guide.md b/docs/telemetry-presence-guide.md new file mode 100644 index 00000000..47ff2b80 --- /dev/null +++ b/docs/telemetry-presence-guide.md @@ -0,0 +1,412 @@ +# Flutter + NestJS 实时在线统计 & DAU 系统移植指南 + +基于 RWADurian 项目提炼,适用于任何需要**实时在线人数**和**日活用户(DAU)**统计的 Flutter + NestJS 项目。 + +--- + +## 系统架构总览 + +``` +Flutter App + └─ TelemetryService(单例) + ├─ SessionManager → 监听前台/后台切换 + ├─ HeartbeatService → 前台时每60s发一次心跳 + └─ TelemetryUploader → 批量上传行为事件 + +NestJS presence-service + ├─ POST /presence/heartbeat → 记录在线时间戳到 Redis Sorted Set + ├─ GET /presence/online-count → 实时在线人数 + ├─ GET /presence/online-history → 历史在线人数曲线 + ├─ GET /analytics/dau → DAU 查询 + └─ POST /analytics/events → 批量行为事件上报(可选) + +Redis Sorted Set: presence:online_users + key=userId, score=最后心跳Unix时间戳 + → ZCOUNT(now-180s, +inf) = 当前在线人数 + +PostgreSQL (rwa_presence schema) + online_snapshots: 每分钟快照在线人数 + daily_active_stats: 每日DAU汇总 + event_logs: 行为事件记录(可选) +``` + +--- + +## 第一部分:后端(presence-service) + +### 哪些代码完全通用(直接复制,零修改) + +``` +backend/services/presence-service/ +├── src/ +│ ├── infrastructure/redis/ ← Redis Sorted Set 全套操作,与业务无关 +│ ├── domain/services/ ← 在线判定逻辑(窗口时间) +│ ├── application/schedulers/ ← 定时任务(快照、DAU计算、清理) +│ ├── application/queries/ ← 查询在线数、历史、DAU +│ └── application/commands/record-heartbeat/ ← 心跳处理 +``` + +### 哪些需要按项目调整 + +#### 1. 环境变量(`docker-compose.yml` 或 `.env`) + +```yaml +# 必须改的 +DATABASE_URL: postgresql://user:pass@postgres:5432/your_db_presence +JWT_SECRET: 与你的 identity-service 共用同一个密钥 # ← 关键! + +# 可选调整 +PRESENCE_WINDOW_SECONDS: 180 # 多少秒无心跳算离线,默认3分钟 +SNAPSHOT_INTERVAL_SECONDS: 60 # 快照频率,默认1分钟 +REDIS_DB: 10 # Redis DB编号,与其他服务隔离 +APP_PORT: 3011 # 服务端口 +``` + +#### 2. JWT 验证(`src/shared/guards/jwt-auth.guard.ts`) + +该文件从 JWT 解码出 userId,需确认字段名与你的 token payload 一致: + +```typescript +// 检查你的 JWT payload 里用户ID的字段名 +// RWADurian 用的是 userSerialNum (e.g. "D25121400005") +// 如果你的项目用 sub 或 userId,需修改 current-user.decorator.ts +``` + +```typescript +// src/shared/decorators/current-user.decorator.ts +// 确认这里取的字段名与你的 token payload 匹配 +export const CurrentUser = createParamDecorator( + (field: string, ctx: ExecutionContext) => { + const user = ctx.switchToHttp().getRequest().user; + return field ? user?.[field] : user; + }, +); + +// controller 里用法: +// @CurrentUser('userId') userId: string +// 改成你 token payload 里的实际字段名 +``` + +#### 3. Prisma schema 数据库名 + +```prisma +// prisma/schema.prisma +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + // DATABASE_URL 里的数据库名改成你的项目名,例如: + // postgresql://user:pass@localhost:5432/myapp_presence +} +``` + +#### 4. Kong API 网关路由(如果用 Kong) + +```yaml +# api-gateway/kong.yml 添加: +- name: presence-service + url: http://presence-service:3011 + routes: + - name: presence-api + paths: + - /api/v1/presence + - name: presence-analytics + paths: + - /api/v1/analytics +``` + +如果不用 Kong,用 Nginx 或直接暴露端口同理。 + +--- + +## 第二部分:前端(Flutter) + +### 哪些代码完全通用(直接复制整个目录) + +``` +lib/core/telemetry/ +├── telemetry_service.dart ← 主入口,单例 +├── models/ +│ ├── telemetry_event.dart ← 事件模型 +│ ├── telemetry_config.dart ← 远程配置模型 +│ └── device_context.dart ← 设备信息模型 +├── collectors/ +│ └── device_info_collector.dart ← 收集设备/系统信息 +├── storage/ +│ └── telemetry_storage.dart ← SharedPreferences 本地队列 +├── uploader/ +│ └── telemetry_uploader.dart ← 批量上传事件 +├── session/ +│ ├── session_manager.dart ← 前台/后台生命周期监听 +│ └── session_events.dart ← 事件名常量 +└── presence/ + ├── heartbeat_service.dart ← 心跳定时器 + └── presence_config.dart ← 心跳配置 +``` + +这些文件与业务零耦合,整个目录直接复制到新项目的 `lib/core/telemetry/` 即可。 + +### 必须安装的 Flutter 依赖 + +```yaml +# pubspec.yaml +dependencies: + dio: ^5.4.3 # HTTP 客户端(心跳和上传) + shared_preferences: ^2.2.3 # 本地队列存储 + uuid: ^4.3.3 # 生成 installId 和 eventId + device_info_plus: ^10.1.0 # 获取设备信息 + package_info_plus: ^8.0.0 # 获取 App 版本 +``` + +### 需要按项目修改的3个接入点 + +#### 接入点1:启动时初始化(在首屏 或 splash_page 调用) + +```dart +// 在你的 splash_page.dart 或 bootstrap.dart 里调用 +// 需要 BuildContext(用于获取屏幕尺寸等设备信息) + +await TelemetryService().initialize( + apiBaseUrl: 'https://your-api.example.com', // ← 改成你的 API 地址(不含 /api/v1) + context: context, + userId: currentUserId, // 已登录则传,未登录传 null +); +``` + +#### 接入点2:登录成功后注入 token(在你的 auth/login 处理代码里) + +```dart +// 登录成功,保存 token 之后,立即调用: +if (TelemetryService().isInitialized) { + TelemetryService().setUserId(response.userId); // ← 改成你的用户ID字段 + TelemetryService().setAccessToken(response.accessToken); // ← 改成你的 token 字段 +} +``` + +#### 接入点3:退出登录时清除 + +```dart +// 退出登录时调用: +if (TelemetryService().isInitialized) { + TelemetryService().clearUserId(); + TelemetryService().clearAccessToken(); +} +``` + +### 可选:账号切换时更新 token + +如果你的 App 支持多账号切换: + +```dart +// 账号切换完成,SecureStorage 已恢复新账号数据后: +if (TelemetryService().isInitialized) { + TelemetryService().setUserId(newUserId); + // 从 SecureStorage 读出恢复后的 token + final token = await secureStorage.read(key: 'access_token'); + TelemetryService().setAccessToken(token); +} +``` + +--- + +## 第三部分:事件上报格式(Amplitude 风格) + +### 设备字段放顶层,不放 properties + +```json +// POST /api/v1/analytics/events 的单条事件格式 +{ + "eventName": "page_view", + "userId": "D25121400005", + "installId": "uuid-v4-xxx", + "clientTs": 1709644800, + + // 设备字段:顶层独立列(可走数据库索引) + "deviceBrand": "Xiaomi", + "deviceModel": "Redmi Note 12", + "deviceOs": "13", + "appVersion": "1.2.0", + "locale": "zh_CN", + + // properties:仅保留事件专属数据 + "properties": { + "page": "trading", + "eventId": "uuid-v4-xxx", + "type": "pageView", + "sessionId": "uuid-v4-xxx" + } +} +``` + +### 为什么不放 properties? + +| | 放进 JSONB properties | 顶层独立列 | +|--|--|--| +| 按设备品牌分组 | `properties->>'deviceBrand'`,无法走索引 | `GROUP BY device_brand`,B-tree 索引直接命中 | +| 亿级数据查询 | 全表扫描(慢) | 毫秒级 | +| 适用规模 | < 百万行 | 千万/亿级 | + +### 实现原理 + +前端本地队列(Hive)仍将设备字段存在 `properties` 内,保持本地格式简单;**上传时 `toServerJson()` 自动将它们提取为顶层字段**,后端按顶层字段写入独立数据库列。 + +```dart +// telemetry_event.dart - toServerJson() 的核心逻辑 +final props = Map.from(properties ?? {}); +final deviceBrand = props.remove('device_brand'); // 从 props 里取出 +// ... +return { + 'deviceBrand': deviceBrand, // 放顶层 + 'properties': { ...props }, // 剩余事件专属数据 +}; +``` + +### 对应后端数据库列 + +```sql +-- analytics_event_log 表的设备列(均有索引) +device_brand VARCHAR(64) -- 索引:按品牌统计设备分布 +device_model VARCHAR(64) +device_os VARCHAR(32) +app_version VARCHAR(32) -- 索引:按版本统计留存/覆盖率 +locale VARCHAR(16) +``` + +--- + +## 第四部分:心跳接口规格 + +前端发送的心跳请求格式(固定,不需要修改): + +``` +POST /api/v1/presence/heartbeat +Authorization: Bearer + +{ + "installId": "uuid-v4-设备唯一标识", + "appVersion": "1.0.0", + "clientTs": 1709644800 // Unix 时间戳(秒) +} + +Response: { "ok": true, "serverTs": 1709644800 } +``` + +后端从 JWT 解码出 userId,不需要前端传。 + +--- + +## 第五部分:查询接口(给管理后台用) + +``` +# 当前实时在线人数 +GET /api/v1/presence/online-count +Authorization: Bearer +→ { "count": 128, "windowSeconds": 180, "queriedAt": "2026-03-05T15:00:00Z" } + +# 历史在线人数(时间段 + 间隔) +GET /api/v1/presence/online-history?startTime=2026-03-05T00:00:00Z&endTime=2026-03-05T23:59:59Z&interval=5m +Authorization: Bearer + +# DAU 统计 +GET /api/v1/analytics/dau?startDate=2026-03-01&endDate=2026-03-05 +Authorization: Bearer + +# 行为事件上报(无需认证,批量) +POST /api/v1/analytics/events +{ "events": [ { "eventName": "page_view", "installId": "...", "clientTs": 123, ... } ] } +``` + +--- + +## 第六部分:DAU 计算逻辑 + +DAU 不依赖心跳,而是依赖行为事件(session_start): + +``` +App 进入前台 + → SessionManager._startNewSession() + → TelemetryService.logEvent('app_session_start', type: session) + → TelemetryUploader 批量上传到 POST /analytics/events + → presence-service 记录到 event_logs 表 + +每天凌晨1点(Asia/Shanghai) + → AnalyticsScheduler.calculateYesterdayDau() + → 统计昨天有 app_session_start 事件的去重 userId/installId 数 + → 写入 daily_active_stats 表 +``` + +因此,**DAU 对未登录用户也有效**(用 installId 去重)。 + +--- + +## 第七部分:在线人数 vs DAU 的区别 + +| | 实时在线人数 | DAU | +|--|--|--| +| 数据来源 | 心跳(每60s) | 会话开始事件(app_session_start) | +| 存储 | Redis Sorted Set(内存,快) | PostgreSQL(持久化) | +| 统计周期 | 实时(180s窗口) | 按自然日 | +| 未登录用户 | 不统计(心跳需要 JWT) | 统计(用 installId 去重) | +| 精度 | ±60s | 按天 | + +--- + +## 第八部分:完整接入 Checklist + +### 后端 + +- [ ] 复制 `presence-service/` 整个目录到新项目 +- [ ] 修改 `DATABASE_URL`(数据库名改为新项目专用) +- [ ] 确认 `JWT_SECRET` 与 auth 服务共用同一个 +- [ ] 确认 `current-user.decorator.ts` 里取的 userId 字段名正确 +- [ ] 配置 API 网关路由(`/api/v1/presence` 和 `/api/v1/analytics`) +- [ ] 部署并确认容器启动、Prisma migration 自动执行 + +### 前端 + +- [ ] 复制 `lib/core/telemetry/` 整个目录到新项目 +- [ ] 安装依赖:`dio`, `shared_preferences`, `uuid`, `device_info_plus`, `package_info_plus` +- [ ] 在 splash/首屏调用 `TelemetryService().initialize(apiBaseUrl: '...')` +- [ ] 登录成功后调用 `setUserId()` + `setAccessToken()` +- [ ] 退出登录时调用 `clearUserId()` + `clearAccessToken()` + +### 验证 + +- [ ] 登录后等待60s,查看后端日志是否有心跳记录 +- [ ] 调用 `GET /api/v1/presence/online-count`,count 应该 ≥ 1 +- [ ] 次日查看 `GET /api/v1/analytics/dau`,应有昨日数据 + +--- + +## 常见问题 + +**Q: 心跳失败会影响 App 吗?** +A: 不会。心跳完全异步,失败只打 debug 日志,等下一个60s周期重试。presence-service 宕机期间 App 正常使用。 + +**Q: 为什么在线判定窗口是180s,不是60s?** +A: 心跳每60s发一次,考虑网络抖动,用3倍窗口(180s)避免频繁出入"在线"状态。如需更严格,把 `PRESENCE_WINDOW_SECONDS` 改小即可。 + +**Q: 未登录用户算在线吗?** +A: 默认不算(`PresenceConfig.requiresAuth = true`)。若需统计未登录用户,把 `requiresAuth` 改为 `false`,同时后端心跳接口需去掉 `@UseGuards(JwtAuthGuard)`。 + +**Q: Redis 断了怎么办?** +A: 在线人数数据会丢失,但已写入 PostgreSQL 的快照不受影响。Redis 恢复后重新开始积累数据。 + +**Q: DAU 和实时在线用同一个 Redis key 吗?** +A: 不是。在线人数用 Redis(`presence:online_users`),DAU 用 PostgreSQL 的 `event_logs` 表计算,两套数据互不干扰。 + +**Q: 心跳接口加了 JWT 校验,未登录用户怎么处理?** +A: `HeartbeatService` 在发心跳前会检查 `getUserId?.call() == null`,未登录直接跳过,不发请求,不报错。 + +--- + +## 源码位置(RWADurian 项目) + +| 组件 | 路径 | +|------|------| +| 后端服务 | `backend/services/presence-service/` | +| Flutter 遥测模块 | `frontend/mobile-app/lib/core/telemetry/` | +| 接入示例(初始化) | `frontend/mobile-app/lib/bootstrap.dart` 第132行 | +| 接入示例(登录) | `frontend/mobile-app/lib/core/services/account_service.dart` `_saveAccountData()` | +| 接入示例(退出) | `frontend/mobile-app/lib/core/services/multi_account_service.dart` `deleteAccount()` | +| Kong 路由配置 | `backend/api-gateway/kong.yml` | +| Grafana 看板 | `backend/api-gateway/grafana/provisioning/dashboards/presence-dashboard.json` |