Compare commits
3 Commits
033d1cde42
...
b9cfa67835
| Author | SHA1 | Date |
|---|---|---|
|
|
b9cfa67835 | |
|
|
6bca65e434 | |
|
|
482df12f91 |
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- AlterTable: add device context columns to analytics_event_log
|
||||||
|
ALTER TABLE "analytics_event_log"
|
||||||
|
ADD COLUMN "device_brand" VARCHAR(64),
|
||||||
|
ADD COLUMN "device_model" VARCHAR(64),
|
||||||
|
ADD COLUMN "device_os" VARCHAR(32),
|
||||||
|
ADD COLUMN "app_version" VARCHAR(32),
|
||||||
|
ADD COLUMN "locale" VARCHAR(16);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "idx_event_log_device_brand" ON "analytics_event_log"("device_brand");
|
||||||
|
CREATE INDEX "idx_event_log_app_version" ON "analytics_event_log"("app_version");
|
||||||
|
|
@ -13,18 +13,25 @@ datasource db {
|
||||||
|
|
||||||
// 事件日志表 (append-only)
|
// 事件日志表 (append-only)
|
||||||
model EventLog {
|
model EventLog {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
userId String? @map("user_id") @db.VarChar(20) // userSerialNum, e.g. "D25121400005"
|
userId String? @map("user_id") @db.VarChar(20) // userSerialNum, e.g. "D25121400005"
|
||||||
installId String @map("install_id") @db.VarChar(64)
|
installId String @map("install_id") @db.VarChar(64)
|
||||||
eventName String @map("event_name") @db.VarChar(64)
|
eventName String @map("event_name") @db.VarChar(64)
|
||||||
eventTime DateTime @map("event_time") @db.Timestamptz()
|
eventTime DateTime @map("event_time") @db.Timestamptz()
|
||||||
properties Json? @db.JsonB
|
deviceBrand String? @map("device_brand") @db.VarChar(64)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
deviceModel String? @map("device_model") @db.VarChar(64)
|
||||||
|
deviceOs String? @map("device_os") @db.VarChar(32)
|
||||||
|
appVersion String? @map("app_version") @db.VarChar(32)
|
||||||
|
locale String? @map("locale") @db.VarChar(16)
|
||||||
|
properties Json? @db.JsonB
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||||
|
|
||||||
@@index([eventTime], name: "idx_event_log_event_time")
|
@@index([eventTime], name: "idx_event_log_event_time")
|
||||||
@@index([eventName], name: "idx_event_log_event_name")
|
@@index([eventName], name: "idx_event_log_event_name")
|
||||||
@@index([eventName, eventTime], name: "idx_event_log_event_name_time")
|
@@index([eventName, eventTime], name: "idx_event_log_event_name_time")
|
||||||
@@index([userId], name: "idx_event_log_user_id")
|
@@index([userId], name: "idx_event_log_user_id")
|
||||||
|
@@index([deviceBrand], name: "idx_event_log_device_brand")
|
||||||
|
@@index([appVersion], name: "idx_event_log_app_version")
|
||||||
@@map("analytics_event_log")
|
@@map("analytics_event_log")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,32 @@ export class EventItemDto {
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
clientTs: number;
|
clientTs: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '事件属性' })
|
@ApiPropertyOptional({ description: '设备品牌', example: 'Xiaomi' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
deviceBrand?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '设备型号', example: 'Redmi Note 12' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
deviceModel?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '操作系统版本', example: '13' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
deviceOs?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'App版本号', example: '1.2.0' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
appVersion?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '语言/地区', example: 'zh_CN' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
locale?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '事件专属属性' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsObject()
|
@IsObject()
|
||||||
properties?: Record<string, unknown>;
|
properties?: Record<string, unknown>;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,11 @@ export interface EventItemDto {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
installId: string;
|
installId: string;
|
||||||
clientTs: number;
|
clientTs: number;
|
||||||
|
deviceBrand?: string;
|
||||||
|
deviceModel?: string;
|
||||||
|
deviceOs?: string;
|
||||||
|
appVersion?: string;
|
||||||
|
locale?: string;
|
||||||
properties?: Record<string, unknown>;
|
properties?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,9 +72,9 @@ export class RecordEventsHandler implements ICommandHandler<RecordEventsCommand>
|
||||||
log.installId.value,
|
log.installId.value,
|
||||||
log.eventTime,
|
log.eventTime,
|
||||||
{
|
{
|
||||||
appVersion: log.properties.appVersion,
|
appVersion: log.appVersion ?? log.properties.appVersion,
|
||||||
os: log.properties.os,
|
os: log.deviceOs ?? log.properties.os,
|
||||||
osVersion: log.properties.osVersion,
|
osVersion: log.deviceOs ?? log.properties.osVersion,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -101,6 +101,11 @@ export class RecordEventsHandler implements ICommandHandler<RecordEventsCommand>
|
||||||
installId: InstallId.fromString(dto.installId),
|
installId: InstallId.fromString(dto.installId),
|
||||||
eventName: EventName.fromString(dto.eventName),
|
eventName: EventName.fromString(dto.eventName),
|
||||||
eventTime: new Date(dto.clientTs * 1000),
|
eventTime: new Date(dto.clientTs * 1000),
|
||||||
|
deviceBrand: dto.deviceBrand ?? null,
|
||||||
|
deviceModel: dto.deviceModel ?? null,
|
||||||
|
deviceOs: dto.deviceOs ?? null,
|
||||||
|
appVersion: dto.appVersion ?? null,
|
||||||
|
locale: dto.locale ?? null,
|
||||||
properties: EventProperties.fromData(dto.properties ?? {}),
|
properties: EventProperties.fromData(dto.properties ?? {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@ export class EventLog {
|
||||||
private _installId: InstallId;
|
private _installId: InstallId;
|
||||||
private _eventName: EventName;
|
private _eventName: EventName;
|
||||||
private _eventTime: Date;
|
private _eventTime: Date;
|
||||||
|
private _deviceBrand: string | null;
|
||||||
|
private _deviceModel: string | null;
|
||||||
|
private _deviceOs: string | null;
|
||||||
|
private _appVersion: string | null;
|
||||||
|
private _locale: string | null;
|
||||||
private _properties: EventProperties;
|
private _properties: EventProperties;
|
||||||
private _createdAt: Date;
|
private _createdAt: Date;
|
||||||
|
|
||||||
|
|
@ -34,6 +39,26 @@ export class EventLog {
|
||||||
return this._eventTime;
|
return this._eventTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get deviceBrand(): string | null {
|
||||||
|
return this._deviceBrand;
|
||||||
|
}
|
||||||
|
|
||||||
|
get deviceModel(): string | null {
|
||||||
|
return this._deviceModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
get deviceOs(): string | null {
|
||||||
|
return this._deviceOs;
|
||||||
|
}
|
||||||
|
|
||||||
|
get appVersion(): string | null {
|
||||||
|
return this._appVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
get locale(): string | null {
|
||||||
|
return this._locale;
|
||||||
|
}
|
||||||
|
|
||||||
get properties(): EventProperties {
|
get properties(): EventProperties {
|
||||||
return this._properties;
|
return this._properties;
|
||||||
}
|
}
|
||||||
|
|
@ -56,6 +81,11 @@ export class EventLog {
|
||||||
installId: InstallId;
|
installId: InstallId;
|
||||||
eventName: EventName;
|
eventName: EventName;
|
||||||
eventTime: Date;
|
eventTime: Date;
|
||||||
|
deviceBrand?: string | null;
|
||||||
|
deviceModel?: string | null;
|
||||||
|
deviceOs?: string | null;
|
||||||
|
appVersion?: string | null;
|
||||||
|
locale?: string | null;
|
||||||
properties?: EventProperties;
|
properties?: EventProperties;
|
||||||
}): EventLog {
|
}): EventLog {
|
||||||
const log = new EventLog();
|
const log = new EventLog();
|
||||||
|
|
@ -64,6 +94,11 @@ export class EventLog {
|
||||||
log._installId = props.installId;
|
log._installId = props.installId;
|
||||||
log._eventName = props.eventName;
|
log._eventName = props.eventName;
|
||||||
log._eventTime = props.eventTime;
|
log._eventTime = props.eventTime;
|
||||||
|
log._deviceBrand = props.deviceBrand ?? null;
|
||||||
|
log._deviceModel = props.deviceModel ?? null;
|
||||||
|
log._deviceOs = props.deviceOs ?? null;
|
||||||
|
log._appVersion = props.appVersion ?? null;
|
||||||
|
log._locale = props.locale ?? null;
|
||||||
log._properties = props.properties ?? EventProperties.empty();
|
log._properties = props.properties ?? EventProperties.empty();
|
||||||
log._createdAt = new Date();
|
log._createdAt = new Date();
|
||||||
return log;
|
return log;
|
||||||
|
|
@ -76,6 +111,11 @@ export class EventLog {
|
||||||
installId: InstallId;
|
installId: InstallId;
|
||||||
eventName: EventName;
|
eventName: EventName;
|
||||||
eventTime: Date;
|
eventTime: Date;
|
||||||
|
deviceBrand: string | null;
|
||||||
|
deviceModel: string | null;
|
||||||
|
deviceOs: string | null;
|
||||||
|
appVersion: string | null;
|
||||||
|
locale: string | null;
|
||||||
properties: EventProperties;
|
properties: EventProperties;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}): EventLog {
|
}): EventLog {
|
||||||
|
|
@ -85,6 +125,11 @@ export class EventLog {
|
||||||
log._installId = props.installId;
|
log._installId = props.installId;
|
||||||
log._eventName = props.eventName;
|
log._eventName = props.eventName;
|
||||||
log._eventTime = props.eventTime;
|
log._eventTime = props.eventTime;
|
||||||
|
log._deviceBrand = props.deviceBrand;
|
||||||
|
log._deviceModel = props.deviceModel;
|
||||||
|
log._deviceOs = props.deviceOs;
|
||||||
|
log._appVersion = props.appVersion;
|
||||||
|
log._locale = props.locale;
|
||||||
log._properties = props.properties;
|
log._properties = props.properties;
|
||||||
log._createdAt = props.createdAt;
|
log._createdAt = props.createdAt;
|
||||||
return log;
|
return log;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ export class EventLogMapper {
|
||||||
installId: InstallId.fromString(prisma.installId),
|
installId: InstallId.fromString(prisma.installId),
|
||||||
eventName: EventName.fromString(prisma.eventName),
|
eventName: EventName.fromString(prisma.eventName),
|
||||||
eventTime: prisma.eventTime,
|
eventTime: prisma.eventTime,
|
||||||
|
deviceBrand: prisma.deviceBrand,
|
||||||
|
deviceModel: prisma.deviceModel,
|
||||||
|
deviceOs: prisma.deviceOs,
|
||||||
|
appVersion: prisma.appVersion,
|
||||||
|
locale: prisma.locale,
|
||||||
properties: EventProperties.fromData((prisma.properties as EventPropertiesData) ?? {}),
|
properties: EventProperties.fromData((prisma.properties as EventPropertiesData) ?? {}),
|
||||||
createdAt: prisma.createdAt,
|
createdAt: prisma.createdAt,
|
||||||
});
|
});
|
||||||
|
|
@ -27,6 +32,11 @@ export class EventLogMapper {
|
||||||
installId: domain.installId.value,
|
installId: domain.installId.value,
|
||||||
eventName: domain.eventName.value,
|
eventName: domain.eventName.value,
|
||||||
eventTime: domain.eventTime,
|
eventTime: domain.eventTime,
|
||||||
|
deviceBrand: domain.deviceBrand,
|
||||||
|
deviceModel: domain.deviceModel,
|
||||||
|
deviceOs: domain.deviceOs,
|
||||||
|
appVersion: domain.appVersion,
|
||||||
|
locale: domain.locale,
|
||||||
properties: domain.properties.toJSON() as Prisma.InputJsonValue,
|
properties: domain.properties.toJSON() as Prisma.InputJsonValue,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<String, dynamic>.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 <JWT>
|
||||||
|
|
||||||
|
{
|
||||||
|
"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 <admin-token>
|
||||||
|
→ { "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 <admin-token>
|
||||||
|
|
||||||
|
# DAU 统计
|
||||||
|
GET /api/v1/analytics/dau?startDate=2026-03-01&endDate=2026-03-05
|
||||||
|
Authorization: Bearer <admin-token>
|
||||||
|
|
||||||
|
# 行为事件上报(无需认证,批量)
|
||||||
|
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` |
|
||||||
|
|
@ -118,15 +118,31 @@ class TelemetryEvent extends Equatable {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 转换为服务端 API 格式
|
/// 转换为服务端 API 格式(Amplitude 风格:设备字段为顶层独立列,方便服务端索引)
|
||||||
|
/// 顶层字段: eventName, userId, installId, clientTs, deviceBrand, deviceModel,
|
||||||
|
/// deviceOs, appVersion, locale
|
||||||
|
/// properties: 仅保留事件专属数据(页面名、金额等)
|
||||||
Map<String, dynamic> toServerJson() {
|
Map<String, dynamic> toServerJson() {
|
||||||
|
// 从 properties 中提取设备字段(提升为顶层),剩余为事件专属数据
|
||||||
|
final props = Map<String, dynamic>.from(properties ?? {});
|
||||||
|
final deviceBrand = props.remove('device_brand');
|
||||||
|
final deviceModel = props.remove('device_model');
|
||||||
|
final deviceOs = props.remove('device_os');
|
||||||
|
final appVersion = props.remove('app_version');
|
||||||
|
final locale = props.remove('locale');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'eventName': name,
|
'eventName': name,
|
||||||
'userId': userId,
|
'userId': userId,
|
||||||
'installId': installId,
|
'installId': installId,
|
||||||
'clientTs': timestamp.millisecondsSinceEpoch ~/ 1000,
|
'clientTs': timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||||
|
'deviceBrand': deviceBrand,
|
||||||
|
'deviceModel': deviceModel,
|
||||||
|
'deviceOs': deviceOs,
|
||||||
|
'appVersion': appVersion,
|
||||||
|
'locale': locale,
|
||||||
'properties': {
|
'properties': {
|
||||||
...?properties,
|
...props,
|
||||||
'eventId': eventId,
|
'eventId': eventId,
|
||||||
'type': type.name,
|
'type': type.name,
|
||||||
'level': level.name,
|
'level': level.name,
|
||||||
|
|
|
||||||
|
|
@ -179,12 +179,22 @@ class TelemetryService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final deviceProps = _deviceContext != null
|
||||||
|
? {
|
||||||
|
'device_brand': _deviceContext!.brand,
|
||||||
|
'device_model': _deviceContext!.model,
|
||||||
|
'device_os': _deviceContext!.osVersion,
|
||||||
|
'app_version': _deviceContext!.appVersion,
|
||||||
|
'locale': _deviceContext!.locale,
|
||||||
|
}
|
||||||
|
: <String, dynamic>{};
|
||||||
|
|
||||||
final event = TelemetryEvent(
|
final event = TelemetryEvent(
|
||||||
eventId: const Uuid().v4(),
|
eventId: const Uuid().v4(),
|
||||||
type: type,
|
type: type,
|
||||||
level: level,
|
level: level,
|
||||||
name: eventName,
|
name: eventName,
|
||||||
properties: properties,
|
properties: {...deviceProps, ...?properties},
|
||||||
timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
userId: _userId,
|
userId: _userId,
|
||||||
sessionId: _sessionManager.currentSessionId,
|
sessionId: _sessionManager.currentSessionId,
|
||||||
|
|
|
||||||
|
|
@ -118,21 +118,31 @@ class TelemetryEvent extends Equatable {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 转换为服务端 API 格式
|
/// 转换为服务端 API 格式(Amplitude 风格:设备字段为顶层独立列,方便服务端索引)
|
||||||
/// 后端 presence-service 期望的格式:
|
/// 顶层字段: eventName, userId, installId, clientTs, deviceBrand, deviceModel,
|
||||||
/// - eventName: string (required)
|
/// deviceOs, appVersion, locale
|
||||||
/// - userId: string (optional) - userSerialNum, e.g. "D25121400005"
|
/// properties: 仅保留事件专属数据(页面名、金额等)
|
||||||
/// - installId: string (required)
|
|
||||||
/// - clientTs: number (required) - Unix timestamp in seconds
|
|
||||||
/// - properties: object (optional)
|
|
||||||
Map<String, dynamic> toServerJson() {
|
Map<String, dynamic> toServerJson() {
|
||||||
|
// 从 properties 中提取设备字段(提升为顶层),剩余为事件专属数据
|
||||||
|
final props = Map<String, dynamic>.from(properties ?? {});
|
||||||
|
final deviceBrand = props.remove('device_brand');
|
||||||
|
final deviceModel = props.remove('device_model');
|
||||||
|
final deviceOs = props.remove('device_os');
|
||||||
|
final appVersion = props.remove('app_version');
|
||||||
|
final locale = props.remove('locale');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'eventName': name,
|
'eventName': name,
|
||||||
'userId': userId,
|
'userId': userId,
|
||||||
'installId': installId,
|
'installId': installId,
|
||||||
'clientTs': timestamp.millisecondsSinceEpoch ~/ 1000,
|
'clientTs': timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||||
|
'deviceBrand': deviceBrand,
|
||||||
|
'deviceModel': deviceModel,
|
||||||
|
'deviceOs': deviceOs,
|
||||||
|
'appVersion': appVersion,
|
||||||
|
'locale': locale,
|
||||||
'properties': {
|
'properties': {
|
||||||
...?properties,
|
...props,
|
||||||
'eventId': eventId,
|
'eventId': eventId,
|
||||||
'type': type.name,
|
'type': type.name,
|
||||||
'level': level.name,
|
'level': level.name,
|
||||||
|
|
|
||||||
|
|
@ -183,17 +183,27 @@ class TelemetryService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final deviceProps = _deviceContext != null
|
||||||
|
? {
|
||||||
|
'device_brand': _deviceContext!.brand,
|
||||||
|
'device_model': _deviceContext!.model,
|
||||||
|
'device_os': _deviceContext!.osVersion,
|
||||||
|
'app_version': _deviceContext!.appVersion,
|
||||||
|
'locale': _deviceContext!.locale,
|
||||||
|
}
|
||||||
|
: <String, dynamic>{};
|
||||||
|
|
||||||
final event = TelemetryEvent(
|
final event = TelemetryEvent(
|
||||||
eventId: const Uuid().v4(),
|
eventId: const Uuid().v4(),
|
||||||
type: type,
|
type: type,
|
||||||
level: level,
|
level: level,
|
||||||
name: eventName,
|
name: eventName,
|
||||||
properties: properties,
|
properties: {...deviceProps, ...?properties},
|
||||||
timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
userId: _userId,
|
userId: _userId,
|
||||||
sessionId: _sessionManager.currentSessionId,
|
sessionId: _sessionManager.currentSessionId,
|
||||||
installId: _installId,
|
installId: _installId,
|
||||||
deviceContextId: _deviceContext!.androidId,
|
deviceContextId: _deviceContext?.androidId ?? '',
|
||||||
);
|
);
|
||||||
|
|
||||||
_storage.enqueueEvent(event);
|
_storage.enqueueEvent(event);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue