feat(telemetry): 完整实现 presence-service 迁移 + Flutter 遥测接入
## 后端 (backend/services/telemetry-service) ### 修复骨架中的关键缺陷 - 创建本地 JwtAuthGuard / AdminGuard,替换不存在的 @genex/common 包 - 修复 req.user.sub → req.user.id(与 JwtStrategy.validate() 返回值对齐) - 重写 Dockerfile,移除对不存在的 packages/common 目录的引用 - 更新 telemetry.module.ts,将 JwtAuthGuard / AdminGuard 注册为 providers - admin-telemetry.controller.ts 改用本地 AdminGuard(检查 role === 'admin') ### 新增功能 - GET /api/v1/telemetry/config:公开配置下发接口 Flutter TelemetryConfig.syncFromRemote() 每小时拉取,返回全局开关、采样率、 心跳间隔等配置,所有字段支持环境变量覆盖(TELEMETRY_GLOBAL_ENABLED 等) - 设备字段(deviceBrand/deviceModel/deviceOs/appVersion/locale)提升为顶层列 BatchEventsDto / TelemetryEvent 实体 / recordEvents() 全链路补齐 原因:JSONB 内字段无法走 B-tree 索引,千万级数据分组查询需独立列 ### DB 迁移 - 050_add_device_fields_to_telemetry_events.sql:为已有 telemetry_events 表 新增 5 个设备字段列和 device_brand / app_version 索引 ### docker-compose 环境变量 - telemetry-service 新增 TELEMETRY_GLOBAL_ENABLED / SAMPLING_RATE / HEARTBEAT_INTERVAL / CONFIG_VERSION 环境变量,支持生产环境热调整 ## 前端 ### genex-mobile (Flutter 消费者端) - 复制 lib/core/telemetry/ 模块(11个文件):TelemetryService、 HeartbeatService、SessionManager、TelemetryUploader、TelemetryStorage 等 - 修正 API 路径:presence/heartbeat → telemetry/heartbeat, analytics/events → telemetry/events - pubspec.yaml 新增依赖:uuid ^4.3.3、equatable ^2.0.5、device_info_plus ^10.1.0 - main.dart:initState 首帧回调初始化 TelemetryService(需 BuildContext 采集设备信息) 已登录时自动注入 accessToken - auth_service.dart:_setAuth() 登录成功后注入 userId + accessToken; _clearAuth() 退出时清除(同时覆盖 Token 过期自动清除场景) ### admin-app (Flutter 发行方控制台) - 复制 lib/core/telemetry/ 模块(同上) - pubspec.yaml 新增依赖:uuid、equatable、device_info_plus、shared_preferences - IssuerLoginPage:initState 首帧初始化 + 登录成功后注入 userId/token 使用 api.gogenex.cn(与 UpdateService 域名一致) - settings_page.dart:退出登录时调用 clearUserId() + clearAccessToken() ## 架构说明 - 在线人数:Redis Sorted Set (genex:presence:online),心跳 60s/次,180s 窗口判定在线 - DAU:app_session_start 事件写入 telemetry_events,每天凌晨 1 点聚合到 daily_active_stats 表,同时每小时滚动更新当日 DAU - 设备字段采用 Amplitude 风格:前端本地队列存 properties 内, toServerJson() 上传时自动提升为顶层字段,后端写入独立索引列 - 心跳需要 JWT 认证(未登录用户自动跳过,不报错) - 遥测完全异步,任何失败只打 debug 日志,不影响主流程 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a6aeb8799e
commit
ce9cc5b72e
|
|
@ -360,6 +360,11 @@ services:
|
|||
- REDIS_PORT=6379
|
||||
- KAFKA_BROKERS=kafka:9092
|
||||
- JWT_ACCESS_SECRET=dev-access-secret-change-in-production
|
||||
# 遥测配置开关(生产环境可按需关闭某类事件)
|
||||
- TELEMETRY_GLOBAL_ENABLED=true
|
||||
- TELEMETRY_SAMPLING_RATE=0.1
|
||||
- TELEMETRY_HEARTBEAT_INTERVAL=60
|
||||
- TELEMETRY_CONFIG_VERSION=1.0.0
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
-- 为 telemetry_events 添加设备字段顶层列
|
||||
-- 设备字段提升为独立列(而非放进 JSONB properties)的原因:
|
||||
-- 按设备品牌/版本分组查询可以命中 B-tree 索引,千万级数据下毫秒级响应
|
||||
-- 若放进 JSONB: properties->>'device_brand' 需全表扫描
|
||||
|
||||
ALTER TABLE telemetry_events
|
||||
ADD COLUMN IF NOT EXISTS device_brand VARCHAR(64) NULL,
|
||||
ADD COLUMN IF NOT EXISTS device_model VARCHAR(64) NULL,
|
||||
ADD COLUMN IF NOT EXISTS device_os VARCHAR(32) NULL,
|
||||
ADD COLUMN IF NOT EXISTS app_version VARCHAR(32) NULL,
|
||||
ADD COLUMN IF NOT EXISTS locale VARCHAR(16) NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_telemetry_events_device_brand ON telemetry_events(device_brand);
|
||||
CREATE INDEX IF NOT EXISTS idx_telemetry_events_app_version ON telemetry_events(app_version);
|
||||
|
|
@ -1,34 +1,35 @@
|
|||
# =============================================================================
|
||||
# Telemetry Service Dockerfile
|
||||
# =============================================================================
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Build shared @genex/common package
|
||||
COPY packages/common/package*.json ./packages/common/
|
||||
RUN cd packages/common && npm install
|
||||
COPY packages/common/ ./packages/common/
|
||||
RUN cd packages/common && npm run build
|
||||
COPY package*.json ./
|
||||
COPY tsconfig.json ./
|
||||
COPY nest-cli.json ./
|
||||
|
||||
# Install service dependencies
|
||||
COPY services/telemetry-service/package*.json ./services/telemetry-service/
|
||||
WORKDIR /app/services/telemetry-service
|
||||
RUN npm install
|
||||
RUN npm ci
|
||||
|
||||
# Copy common package into node_modules for runtime resolution
|
||||
RUN mkdir -p node_modules/@genex/common && \
|
||||
cp -r /app/packages/common/dist node_modules/@genex/common/ && \
|
||||
cp /app/packages/common/package.json node_modules/@genex/common/
|
||||
COPY src ./src
|
||||
|
||||
# Copy service source and build
|
||||
COPY services/telemetry-service/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ─── Production stage ────────────────────────────────────────────────────────
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache dumb-init
|
||||
COPY --from=builder /app/services/telemetry-service/dist ./dist
|
||||
COPY --from=builder /app/services/telemetry-service/node_modules ./node_modules
|
||||
COPY --from=builder /app/services/telemetry-service/package.json ./
|
||||
|
||||
RUN apk add --no-cache dumb-init curl
|
||||
|
||||
COPY --from=builder /app/package*.json ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
USER node
|
||||
EXPOSE 3011
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3011/api/v1/health || exit 1
|
||||
CMD curl -f http://localhost:3011/api/v1/health || exit 1
|
||||
|
||||
CMD ["dumb-init", "node", "dist/main"]
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@ export class TelemetryService {
|
|||
installId: string;
|
||||
eventName: string;
|
||||
clientTs: number;
|
||||
deviceBrand?: string;
|
||||
deviceModel?: string;
|
||||
deviceOs?: string;
|
||||
appVersion?: string;
|
||||
locale?: string;
|
||||
properties?: Record<string, any>;
|
||||
}>): Promise<{ recorded: number }> {
|
||||
const timer = this.metrics.startBatchTimer();
|
||||
|
|
@ -36,6 +41,11 @@ export class TelemetryService {
|
|||
event.installId = e.installId;
|
||||
event.eventName = e.eventName;
|
||||
event.eventTime = new Date(e.clientTs * 1000);
|
||||
event.deviceBrand = e.deviceBrand || null;
|
||||
event.deviceModel = e.deviceModel || null;
|
||||
event.deviceOs = e.deviceOs || null;
|
||||
event.appVersion = e.appVersion || null;
|
||||
event.locale = e.locale || null;
|
||||
event.properties = e.properties || {};
|
||||
return event;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
-- =============================================================================
|
||||
-- Telemetry Service - Initial Schema
|
||||
-- =============================================================================
|
||||
|
||||
-- 遥测事件流水表(append-only)
|
||||
CREATE TABLE IF NOT EXISTS telemetry_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id UUID NULL,
|
||||
install_id VARCHAR(128) NOT NULL,
|
||||
event_name VARCHAR(64) NOT NULL,
|
||||
event_time TIMESTAMPTZ NOT NULL,
|
||||
properties JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_telemetry_events_name_time ON telemetry_events (event_name, event_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_telemetry_events_user_id ON telemetry_events (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_telemetry_events_time ON telemetry_events (event_time DESC);
|
||||
|
||||
-- 在线人数快照表(每分钟写入一次)
|
||||
CREATE TABLE IF NOT EXISTS online_snapshots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ts TIMESTAMPTZ NOT NULL UNIQUE,
|
||||
online_count INT NOT NULL DEFAULT 0,
|
||||
window_seconds INT NOT NULL DEFAULT 180
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_online_snapshots_ts ON online_snapshots (ts DESC);
|
||||
|
||||
-- 日活统计汇总表
|
||||
CREATE TABLE IF NOT EXISTS daily_active_stats (
|
||||
day DATE PRIMARY KEY,
|
||||
dau_count INT NOT NULL DEFAULT 0,
|
||||
dau_by_platform JSONB NOT NULL DEFAULT '{}',
|
||||
dau_by_region JSONB NOT NULL DEFAULT '{}',
|
||||
calculated_at TIMESTAMPTZ NOT NULL,
|
||||
version INT NOT NULL DEFAULT 1
|
||||
);
|
||||
|
|
@ -18,6 +18,22 @@ export class TelemetryEvent {
|
|||
@Column({ name: 'event_time', type: 'timestamptz' })
|
||||
eventTime: Date;
|
||||
|
||||
// 设备字段:顶层独立列(B-tree 索引,千万级数据下按设备/版本分组查询毫秒级)
|
||||
@Column({ name: 'device_brand', length: 64, nullable: true })
|
||||
deviceBrand: string | null;
|
||||
|
||||
@Column({ name: 'device_model', length: 64, nullable: true })
|
||||
deviceModel: string | null;
|
||||
|
||||
@Column({ name: 'device_os', length: 32, nullable: true })
|
||||
deviceOs: string | null;
|
||||
|
||||
@Column({ name: 'app_version', length: 32, nullable: true })
|
||||
appVersion: string | null;
|
||||
|
||||
@Column({ length: 16, nullable: true })
|
||||
locale: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', default: '{}' })
|
||||
properties: Record<string, any>;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,26 @@
|
|||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard, Roles, RolesGuard, UserRole } from '@genex/common';
|
||||
import { JwtAuthGuard } from '../../../shared/guards/jwt-auth.guard';
|
||||
import { AdminGuard } from '../../../shared/guards/admin.guard';
|
||||
import { TelemetryService } from '../../../application/services/telemetry.service';
|
||||
import { QueryDauDto, QueryEventsDto } from '../dto/query-dau.dto';
|
||||
|
||||
@ApiTags('admin-telemetry')
|
||||
@Controller('admin/telemetry')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
export class AdminTelemetryController {
|
||||
constructor(
|
||||
private readonly telemetryService: TelemetryService,
|
||||
) {}
|
||||
constructor(private readonly telemetryService: TelemetryService) {}
|
||||
|
||||
@Get('dau')
|
||||
@ApiOperation({ summary: 'Query DAU statistics' })
|
||||
@ApiOperation({ summary: '查询 DAU 统计' })
|
||||
async getDauStats(@Query() query: QueryDauDto) {
|
||||
const result = await this.telemetryService.getDauStats(query.startDate, query.endDate);
|
||||
return { code: 0, data: result };
|
||||
}
|
||||
|
||||
@Get('events')
|
||||
@ApiOperation({ summary: 'Query telemetry events' })
|
||||
@ApiOperation({ summary: '查询遥测事件列表' })
|
||||
async listEvents(@Query() query: QueryEventsDto) {
|
||||
const page = query.page || 1;
|
||||
const limit = query.limit || 20;
|
||||
|
|
@ -36,7 +34,7 @@ export class AdminTelemetryController {
|
|||
}
|
||||
|
||||
@Get('realtime')
|
||||
@ApiOperation({ summary: 'Get realtime analytics dashboard data' })
|
||||
@ApiOperation({ summary: '获取实时数据看板' })
|
||||
async getRealtimeData() {
|
||||
const data = await this.telemetryService.getRealtimeData();
|
||||
return { code: 0, data };
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Controller, Post, Get, Body, Query, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '@genex/common';
|
||||
import { JwtAuthGuard } from '../../../shared/guards/jwt-auth.guard';
|
||||
import { TelemetryService } from '../../../application/services/telemetry.service';
|
||||
import { BatchEventsDto } from '../dto/batch-events.dto';
|
||||
import { HeartbeatDto } from '../dto/heartbeat.dto';
|
||||
|
|
@ -10,8 +10,64 @@ import { HeartbeatDto } from '../dto/heartbeat.dto';
|
|||
export class TelemetryController {
|
||||
constructor(private readonly telemetryService: TelemetryService) {}
|
||||
|
||||
/**
|
||||
* 遥测客户端配置接口(无需认证)
|
||||
*
|
||||
* Flutter TelemetryConfig.syncFromRemote() 每小时调用一次此接口,
|
||||
* 获取服务端下发的遥测开关、采样率、心跳配置等。
|
||||
*
|
||||
* 所有字段均有环境变量覆盖,默认值为生产安全值(全量采集、10% 采样)。
|
||||
*
|
||||
* 响应字段说明:
|
||||
* global_enabled — 遥测总开关,false 时客户端停止所有上报
|
||||
* error_report_enabled — 错误/崩溃上报开关(建议始终 true)
|
||||
* performance_enabled — 性能监控开关
|
||||
* user_action_enabled — 用户行为事件开关
|
||||
* page_view_enabled — 页面访问事件开关
|
||||
* session_enabled — 会话事件开关(DAU 依赖此项)
|
||||
* sampling_rate — 采样率 0~1(仅影响 userAction/pageView/performance 类型)
|
||||
* disabled_events — 事件黑名单(精确匹配 eventName)
|
||||
* version — 配置版本,客户端用于判断是否需要刷新本地缓存
|
||||
* presence_config — 心跳子配置
|
||||
* heartbeat_interval_seconds — 心跳间隔秒数(默认 60)
|
||||
* requires_auth — true: 未登录用户不发心跳
|
||||
* presence_enabled — 心跳总开关
|
||||
*/
|
||||
@Get('config')
|
||||
@ApiOperation({
|
||||
summary: '获取遥测客户端配置(公开接口,无需认证)',
|
||||
description: 'Flutter TelemetryConfig.syncFromRemote() 调用,每小时同步一次',
|
||||
})
|
||||
getConfig() {
|
||||
return {
|
||||
code: 0,
|
||||
data: {
|
||||
global_enabled: process.env.TELEMETRY_GLOBAL_ENABLED !== 'false',
|
||||
error_report_enabled: process.env.TELEMETRY_ERROR_ENABLED !== 'false',
|
||||
performance_enabled: process.env.TELEMETRY_PERFORMANCE_ENABLED !== 'false',
|
||||
user_action_enabled: process.env.TELEMETRY_USER_ACTION_ENABLED !== 'false',
|
||||
page_view_enabled: process.env.TELEMETRY_PAGE_VIEW_ENABLED !== 'false',
|
||||
session_enabled: process.env.TELEMETRY_SESSION_ENABLED !== 'false',
|
||||
sampling_rate: parseFloat(process.env.TELEMETRY_SAMPLING_RATE || '0.1'),
|
||||
disabled_events: (process.env.TELEMETRY_DISABLED_EVENTS || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
version: process.env.TELEMETRY_CONFIG_VERSION || '1.0.0',
|
||||
presence_config: {
|
||||
heartbeat_interval_seconds: parseInt(
|
||||
process.env.TELEMETRY_HEARTBEAT_INTERVAL || '60',
|
||||
10,
|
||||
),
|
||||
requires_auth: process.env.TELEMETRY_HEARTBEAT_REQUIRES_AUTH !== 'false',
|
||||
presence_enabled: process.env.TELEMETRY_PRESENCE_ENABLED !== 'false',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post('events')
|
||||
@ApiOperation({ summary: 'Batch report telemetry events (no auth required)' })
|
||||
@ApiOperation({ summary: '批量上报遥测事件(无需认证)' })
|
||||
async batchEvents(@Body() body: BatchEventsDto) {
|
||||
const result = await this.telemetryService.recordEvents(body.events);
|
||||
return { code: 0, data: result };
|
||||
|
|
@ -20,16 +76,17 @@ export class TelemetryController {
|
|||
@Post('heartbeat')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Report heartbeat for online detection' })
|
||||
@ApiOperation({ summary: '上报心跳(维持在线状态)' })
|
||||
async heartbeat(@Req() req: any, @Body() body: HeartbeatDto) {
|
||||
await this.telemetryService.recordHeartbeat(req.user.sub, body.installId, body.appVersion);
|
||||
return { code: 0, data: { success: true } };
|
||||
// JwtStrategy.validate() 返回 { id, role, kycLevel },故取 req.user.id
|
||||
await this.telemetryService.recordHeartbeat(req.user.id, body.installId, body.appVersion);
|
||||
return { code: 0, data: { ok: true, serverTs: Math.floor(Date.now() / 1000) } };
|
||||
}
|
||||
|
||||
@Get('online-count')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get current online user count' })
|
||||
@ApiOperation({ summary: '获取当前在线人数' })
|
||||
async getOnlineCount() {
|
||||
const result = await this.telemetryService.getOnlineCount();
|
||||
return { code: 0, data: result };
|
||||
|
|
@ -38,7 +95,7 @@ export class TelemetryController {
|
|||
@Get('online-history')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get online user history trend' })
|
||||
@ApiOperation({ summary: '获取在线人数历史趋势' })
|
||||
async getOnlineHistory(
|
||||
@Query('startTime') startTime: string,
|
||||
@Query('endTime') endTime: string,
|
||||
|
|
|
|||
|
|
@ -12,11 +12,47 @@ import { Type } from 'class-transformer';
|
|||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class TelemetryEventItem {
|
||||
@ApiProperty({ example: 'page_view', maxLength: 64 }) @IsString() @MaxLength(64) eventName: string;
|
||||
@ApiProperty({ example: 'inst_abc123', maxLength: 128 }) @IsString() @MaxLength(128) installId: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsUUID() userId?: string;
|
||||
@ApiProperty({ example: 1700000000000 }) @IsNumber() clientTs: number;
|
||||
@ApiPropertyOptional({ type: 'object' }) @IsOptional() @IsObject() properties?: Record<string, any>;
|
||||
@ApiProperty({ example: 'page_view', maxLength: 64 })
|
||||
@IsString() @MaxLength(64)
|
||||
eventName: string;
|
||||
|
||||
@ApiProperty({ example: 'inst_abc123', maxLength: 128 })
|
||||
@IsString() @MaxLength(128)
|
||||
installId: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional() @IsUUID()
|
||||
userId?: string;
|
||||
|
||||
/** Unix 时间戳(秒) */
|
||||
@ApiProperty({ example: 1709644800 })
|
||||
@IsNumber()
|
||||
clientTs: number;
|
||||
|
||||
// 设备字段:顶层独立列,方便服务端按索引查询
|
||||
@ApiPropertyOptional({ example: 'Xiaomi', maxLength: 64 })
|
||||
@IsOptional() @IsString() @MaxLength(64)
|
||||
deviceBrand?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Redmi Note 12', maxLength: 64 })
|
||||
@IsOptional() @IsString() @MaxLength(64)
|
||||
deviceModel?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '13', maxLength: 32 })
|
||||
@IsOptional() @IsString() @MaxLength(32)
|
||||
deviceOs?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '1.2.0', maxLength: 32 })
|
||||
@IsOptional() @IsString() @MaxLength(32)
|
||||
appVersion?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'zh_CN', maxLength: 16 })
|
||||
@IsOptional() @IsString() @MaxLength(16)
|
||||
locale?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: 'object' })
|
||||
@IsOptional() @IsObject()
|
||||
properties?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class BatchEventsDto {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Verifies the JWT is valid (handled by JwtStrategy) and that user has admin role.
|
||||
* Use after JwtAuthGuard: @UseGuards(JwtAuthGuard, AdminGuard)
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
if (!user) throw new UnauthorizedException('未认证');
|
||||
if (user.role !== 'admin') throw new ForbiddenException('需要管理员权限');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
|
|
@ -32,6 +32,10 @@ import { TelemetryMetricsService } from './infrastructure/metrics/telemetry-metr
|
|||
import { TelemetryService } from './application/services/telemetry.service';
|
||||
import { TelemetrySchedulerService } from './application/services/telemetry-scheduler.service';
|
||||
|
||||
// Shared guards
|
||||
import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
|
||||
import { AdminGuard } from './shared/guards/admin.guard';
|
||||
|
||||
// Interface - Controllers
|
||||
import { TelemetryController } from './interface/http/controllers/telemetry.controller';
|
||||
import { AdminTelemetryController } from './interface/http/controllers/admin-telemetry.controller';
|
||||
|
|
@ -74,6 +78,8 @@ import { HealthController } from './interface/http/controllers/health.controller
|
|||
],
|
||||
providers: [
|
||||
JwtStrategy,
|
||||
JwtAuthGuard,
|
||||
AdminGuard,
|
||||
{ provide: TELEMETRY_EVENT_REPOSITORY, useClass: TelemetryEventRepository },
|
||||
{ provide: ONLINE_SNAPSHOT_REPOSITORY, useClass: OnlineSnapshotRepository },
|
||||
{ provide: DAILY_ACTIVE_STATS_REPOSITORY, useClass: DailyActiveStatsRepository },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
import 'dart:io';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/device_context.dart';
|
||||
|
||||
/// 设备信息收集器
|
||||
/// 负责收集完整的设备上下文信息
|
||||
class DeviceInfoCollector {
|
||||
static DeviceInfoCollector? _instance;
|
||||
DeviceInfoCollector._();
|
||||
|
||||
factory DeviceInfoCollector() {
|
||||
_instance ??= DeviceInfoCollector._();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
DeviceContext? _cachedContext;
|
||||
|
||||
/// 收集完整设备上下文(首次会缓存)
|
||||
Future<DeviceContext> collect(BuildContext context) async {
|
||||
if (_cachedContext != null) return _cachedContext!;
|
||||
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
DeviceContext result;
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final androidInfo = await deviceInfo.androidInfo;
|
||||
|
||||
result = DeviceContext(
|
||||
platform: 'android',
|
||||
brand: androidInfo.brand,
|
||||
model: androidInfo.model,
|
||||
manufacturer: androidInfo.manufacturer,
|
||||
isPhysicalDevice: androidInfo.isPhysicalDevice,
|
||||
osVersion: androidInfo.version.release,
|
||||
sdkInt: androidInfo.version.sdkInt,
|
||||
androidId: androidInfo.id, // 匿名ID,不是IMEI
|
||||
screen: _collectScreenInfo(mediaQuery),
|
||||
appName: packageInfo.appName,
|
||||
packageName: packageInfo.packageName,
|
||||
appVersion: packageInfo.version,
|
||||
buildNumber: packageInfo.buildNumber,
|
||||
buildMode: _getBuildMode(),
|
||||
locale: Platform.localeName,
|
||||
timezone: DateTime.now().timeZoneName,
|
||||
isDarkMode: mediaQuery.platformBrightness == Brightness.dark,
|
||||
networkType: 'unknown', // 需要额外的connectivity包
|
||||
collectedAt: DateTime.now(),
|
||||
);
|
||||
} else if (Platform.isIOS) {
|
||||
final iosInfo = await deviceInfo.iosInfo;
|
||||
|
||||
result = DeviceContext(
|
||||
platform: 'ios',
|
||||
brand: 'Apple',
|
||||
model: iosInfo.model,
|
||||
manufacturer: 'Apple',
|
||||
isPhysicalDevice: iosInfo.isPhysicalDevice,
|
||||
osVersion: iosInfo.systemVersion,
|
||||
sdkInt: 0, // iOS没有SDK版本号
|
||||
androidId: iosInfo.identifierForVendor ?? '',
|
||||
screen: _collectScreenInfo(mediaQuery),
|
||||
appName: packageInfo.appName,
|
||||
packageName: packageInfo.packageName,
|
||||
appVersion: packageInfo.version,
|
||||
buildNumber: packageInfo.buildNumber,
|
||||
buildMode: _getBuildMode(),
|
||||
locale: Platform.localeName,
|
||||
timezone: DateTime.now().timeZoneName,
|
||||
isDarkMode: mediaQuery.platformBrightness == Brightness.dark,
|
||||
networkType: 'unknown',
|
||||
collectedAt: DateTime.now(),
|
||||
);
|
||||
} else {
|
||||
throw UnsupportedError('Unsupported platform');
|
||||
}
|
||||
|
||||
_cachedContext = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 收集屏幕信息
|
||||
ScreenInfo _collectScreenInfo(MediaQueryData mediaQuery) {
|
||||
final size = mediaQuery.size;
|
||||
final density = mediaQuery.devicePixelRatio;
|
||||
|
||||
return ScreenInfo(
|
||||
widthPx: size.width * density,
|
||||
heightPx: size.height * density,
|
||||
density: density,
|
||||
widthDp: size.width,
|
||||
heightDp: size.height,
|
||||
hasNotch: mediaQuery.padding.top > 24, // 简单判断刘海屏
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取构建模式
|
||||
String _getBuildMode() {
|
||||
if (kReleaseMode) return 'release';
|
||||
if (kProfileMode) return 'profile';
|
||||
return 'debug';
|
||||
}
|
||||
|
||||
/// 清除缓存(版本更新时调用)
|
||||
void clearCache() {
|
||||
_cachedContext = null;
|
||||
}
|
||||
|
||||
/// 获取缓存的上下文
|
||||
DeviceContext? get cachedContext => _cachedContext;
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// 屏幕信息
|
||||
class ScreenInfo extends Equatable {
|
||||
final double widthPx;
|
||||
final double heightPx;
|
||||
final double density;
|
||||
final double widthDp;
|
||||
final double heightDp;
|
||||
final bool hasNotch;
|
||||
|
||||
const ScreenInfo({
|
||||
required this.widthPx,
|
||||
required this.heightPx,
|
||||
required this.density,
|
||||
required this.widthDp,
|
||||
required this.heightDp,
|
||||
required this.hasNotch,
|
||||
});
|
||||
|
||||
factory ScreenInfo.fromJson(Map<String, dynamic> json) {
|
||||
return ScreenInfo(
|
||||
widthPx: (json['widthPx'] as num).toDouble(),
|
||||
heightPx: (json['heightPx'] as num).toDouble(),
|
||||
density: (json['density'] as num).toDouble(),
|
||||
widthDp: (json['widthDp'] as num).toDouble(),
|
||||
heightDp: (json['heightDp'] as num).toDouble(),
|
||||
hasNotch: json['hasNotch'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'widthPx': widthPx,
|
||||
'heightPx': heightPx,
|
||||
'density': density,
|
||||
'widthDp': widthDp,
|
||||
'heightDp': heightDp,
|
||||
'hasNotch': hasNotch,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
[widthPx, heightPx, density, widthDp, heightDp, hasNotch];
|
||||
}
|
||||
|
||||
/// 设备上下文
|
||||
/// 包含完整的设备信息,用于兼容性分析
|
||||
class DeviceContext extends Equatable {
|
||||
// 设备信息
|
||||
final String platform; // 'android' | 'ios'
|
||||
final String brand; // 'Samsung'
|
||||
final String model; // 'SM-G9980'
|
||||
final String manufacturer; // 'samsung'
|
||||
final bool isPhysicalDevice; // true
|
||||
|
||||
// 系统信息
|
||||
final String osVersion; // '14'
|
||||
final int sdkInt; // 34
|
||||
final String androidId; // 匿名设备ID
|
||||
|
||||
// 屏幕信息
|
||||
final ScreenInfo screen;
|
||||
|
||||
// App信息
|
||||
final String appName;
|
||||
final String packageName;
|
||||
final String appVersion;
|
||||
final String buildNumber;
|
||||
final String buildMode; // 'debug' | 'profile' | 'release'
|
||||
|
||||
// 用户环境
|
||||
final String locale; // 'zh_CN'
|
||||
final String timezone; // 'Asia/Shanghai'
|
||||
final bool isDarkMode;
|
||||
final String networkType; // 'wifi' | 'cellular' | 'none'
|
||||
|
||||
// 时间戳
|
||||
final DateTime collectedAt;
|
||||
|
||||
const DeviceContext({
|
||||
required this.platform,
|
||||
required this.brand,
|
||||
required this.model,
|
||||
required this.manufacturer,
|
||||
required this.isPhysicalDevice,
|
||||
required this.osVersion,
|
||||
required this.sdkInt,
|
||||
required this.androidId,
|
||||
required this.screen,
|
||||
required this.appName,
|
||||
required this.packageName,
|
||||
required this.appVersion,
|
||||
required this.buildNumber,
|
||||
required this.buildMode,
|
||||
required this.locale,
|
||||
required this.timezone,
|
||||
required this.isDarkMode,
|
||||
required this.networkType,
|
||||
required this.collectedAt,
|
||||
});
|
||||
|
||||
factory DeviceContext.fromJson(Map<String, dynamic> json) {
|
||||
return DeviceContext(
|
||||
platform: json['platform'] as String,
|
||||
brand: json['brand'] as String,
|
||||
model: json['model'] as String,
|
||||
manufacturer: json['manufacturer'] as String,
|
||||
isPhysicalDevice: json['isPhysicalDevice'] as bool,
|
||||
osVersion: json['osVersion'] as String,
|
||||
sdkInt: json['sdkInt'] as int,
|
||||
androidId: json['androidId'] as String,
|
||||
screen: ScreenInfo.fromJson(json['screen'] as Map<String, dynamic>),
|
||||
appName: json['appName'] as String,
|
||||
packageName: json['packageName'] as String,
|
||||
appVersion: json['appVersion'] as String,
|
||||
buildNumber: json['buildNumber'] as String,
|
||||
buildMode: json['buildMode'] as String,
|
||||
locale: json['locale'] as String,
|
||||
timezone: json['timezone'] as String,
|
||||
isDarkMode: json['isDarkMode'] as bool,
|
||||
networkType: json['networkType'] as String,
|
||||
collectedAt: DateTime.parse(json['collectedAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'platform': platform,
|
||||
'brand': brand,
|
||||
'model': model,
|
||||
'manufacturer': manufacturer,
|
||||
'isPhysicalDevice': isPhysicalDevice,
|
||||
'osVersion': osVersion,
|
||||
'sdkInt': sdkInt,
|
||||
'androidId': androidId,
|
||||
'screen': screen.toJson(),
|
||||
'appName': appName,
|
||||
'packageName': packageName,
|
||||
'appVersion': appVersion,
|
||||
'buildNumber': buildNumber,
|
||||
'buildMode': buildMode,
|
||||
'locale': locale,
|
||||
'timezone': timezone,
|
||||
'isDarkMode': isDarkMode,
|
||||
'networkType': networkType,
|
||||
'collectedAt': collectedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
platform,
|
||||
brand,
|
||||
model,
|
||||
manufacturer,
|
||||
isPhysicalDevice,
|
||||
osVersion,
|
||||
sdkInt,
|
||||
androidId,
|
||||
screen,
|
||||
appName,
|
||||
packageName,
|
||||
appVersion,
|
||||
buildNumber,
|
||||
buildMode,
|
||||
locale,
|
||||
timezone,
|
||||
isDarkMode,
|
||||
networkType,
|
||||
collectedAt,
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'telemetry_event.dart';
|
||||
import '../presence/presence_config.dart';
|
||||
|
||||
/// 遥测配置
|
||||
/// 支持远程配置和本地缓存
|
||||
class TelemetryConfig {
|
||||
// 全局开关
|
||||
bool globalEnabled = true;
|
||||
|
||||
// 分类型开关
|
||||
bool errorReportEnabled = true; // 错误上报
|
||||
bool performanceEnabled = true; // 性能监控
|
||||
bool userActionEnabled = true; // 用户行为
|
||||
bool pageViewEnabled = true; // 页面访问
|
||||
bool sessionEnabled = true; // 会话事件(DAU相关)
|
||||
|
||||
// 采样配置
|
||||
double samplingRate = 0.1; // 10% 采样率
|
||||
|
||||
// 事件黑名单
|
||||
List<String> disabledEvents = [];
|
||||
|
||||
// 配置版本
|
||||
String configVersion = '1.0.0';
|
||||
|
||||
// 用户是否同意(可选,用于隐私合规)
|
||||
bool userOptIn = true;
|
||||
|
||||
// 心跳/在线状态配置
|
||||
PresenceConfig? presenceConfig;
|
||||
|
||||
static final TelemetryConfig _instance = TelemetryConfig._();
|
||||
TelemetryConfig._();
|
||||
factory TelemetryConfig() => _instance;
|
||||
|
||||
/// 从后端同步配置
|
||||
Future<void> syncFromRemote(String apiBaseUrl) async {
|
||||
try {
|
||||
final dio = Dio(BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 5),
|
||||
));
|
||||
// apiBaseUrl 已经包含 /api/v1,所以这里只需要添加相对路径
|
||||
final response = await dio.get('$apiBaseUrl/telemetry/config');
|
||||
final data = response.data;
|
||||
|
||||
globalEnabled = data['global_enabled'] ?? true;
|
||||
errorReportEnabled = data['error_report_enabled'] ?? true;
|
||||
performanceEnabled = data['performance_enabled'] ?? true;
|
||||
userActionEnabled = data['user_action_enabled'] ?? true;
|
||||
pageViewEnabled = data['page_view_enabled'] ?? true;
|
||||
sessionEnabled = data['session_enabled'] ?? true;
|
||||
samplingRate = (data['sampling_rate'] ?? 0.1).toDouble();
|
||||
disabledEvents = List<String>.from(data['disabled_events'] ?? []);
|
||||
configVersion = data['version'] ?? '1.0.0';
|
||||
|
||||
// 解析心跳配置
|
||||
if (data['presence_config'] != null) {
|
||||
presenceConfig = PresenceConfig.fromJson(data['presence_config']);
|
||||
}
|
||||
|
||||
// 缓存到本地
|
||||
await _saveToLocal();
|
||||
|
||||
debugPrint('📊 Telemetry config synced (v$configVersion)');
|
||||
debugPrint(
|
||||
' Global: $globalEnabled, Sampling: ${(samplingRate * 100).toInt()}%');
|
||||
debugPrint(' Presence: ${presenceConfig?.enabled ?? true}');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Failed to sync telemetry config: $e');
|
||||
// 失败时加载本地缓存
|
||||
await _loadFromLocal();
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存到本地
|
||||
Future<void> _saveToLocal() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('telemetry_global_enabled', globalEnabled);
|
||||
await prefs.setBool('telemetry_error_enabled', errorReportEnabled);
|
||||
await prefs.setBool('telemetry_performance_enabled', performanceEnabled);
|
||||
await prefs.setBool('telemetry_user_action_enabled', userActionEnabled);
|
||||
await prefs.setBool('telemetry_page_view_enabled', pageViewEnabled);
|
||||
await prefs.setBool('telemetry_session_enabled', sessionEnabled);
|
||||
await prefs.setDouble('telemetry_sampling_rate', samplingRate);
|
||||
await prefs.setStringList('telemetry_disabled_events', disabledEvents);
|
||||
await prefs.setString('telemetry_config_version', configVersion);
|
||||
}
|
||||
|
||||
/// 从本地加载
|
||||
Future<void> _loadFromLocal() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
globalEnabled = prefs.getBool('telemetry_global_enabled') ?? true;
|
||||
errorReportEnabled = prefs.getBool('telemetry_error_enabled') ?? true;
|
||||
performanceEnabled =
|
||||
prefs.getBool('telemetry_performance_enabled') ?? true;
|
||||
userActionEnabled = prefs.getBool('telemetry_user_action_enabled') ?? true;
|
||||
pageViewEnabled = prefs.getBool('telemetry_page_view_enabled') ?? true;
|
||||
sessionEnabled = prefs.getBool('telemetry_session_enabled') ?? true;
|
||||
samplingRate = prefs.getDouble('telemetry_sampling_rate') ?? 0.1;
|
||||
disabledEvents = prefs.getStringList('telemetry_disabled_events') ?? [];
|
||||
configVersion = prefs.getString('telemetry_config_version') ?? '1.0.0';
|
||||
}
|
||||
|
||||
/// 判断是否应该记录该事件
|
||||
bool shouldLog(EventType type, String eventName) {
|
||||
// 1. 全局开关
|
||||
if (!globalEnabled) return false;
|
||||
|
||||
// 2. 用户未同意
|
||||
if (!userOptIn) return false;
|
||||
|
||||
// 3. 事件黑名单
|
||||
if (disabledEvents.contains(eventName)) return false;
|
||||
|
||||
// 4. 分类型判断
|
||||
switch (type) {
|
||||
case EventType.error:
|
||||
case EventType.crash:
|
||||
return errorReportEnabled;
|
||||
case EventType.performance:
|
||||
return performanceEnabled;
|
||||
case EventType.userAction:
|
||||
return userActionEnabled;
|
||||
case EventType.pageView:
|
||||
return pageViewEnabled;
|
||||
case EventType.apiCall:
|
||||
return performanceEnabled; // API调用归入性能监控
|
||||
case EventType.session:
|
||||
return sessionEnabled; // 会话事件
|
||||
case EventType.presence:
|
||||
return presenceConfig?.enabled ?? true; // 在线状态
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置用户是否同意
|
||||
Future<void> setUserOptIn(bool optIn) async {
|
||||
userOptIn = optIn;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('telemetry_user_opt_in', optIn);
|
||||
debugPrint('📊 User opt-in: $optIn');
|
||||
}
|
||||
|
||||
/// 加载用户选择
|
||||
Future<void> loadUserOptIn() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
userOptIn = prefs.getBool('telemetry_user_opt_in') ?? true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// 事件级别
|
||||
enum EventLevel {
|
||||
debug,
|
||||
info,
|
||||
warning,
|
||||
error,
|
||||
fatal,
|
||||
}
|
||||
|
||||
/// 事件类型
|
||||
enum EventType {
|
||||
/// 页面访问
|
||||
pageView,
|
||||
|
||||
/// 用户行为
|
||||
userAction,
|
||||
|
||||
/// API请求
|
||||
apiCall,
|
||||
|
||||
/// 性能指标
|
||||
performance,
|
||||
|
||||
/// 错误异常
|
||||
error,
|
||||
|
||||
/// 崩溃
|
||||
crash,
|
||||
|
||||
/// 会话事件 (app_session_start, app_session_end)
|
||||
session,
|
||||
|
||||
/// 在线状态 (心跳相关)
|
||||
presence,
|
||||
}
|
||||
|
||||
/// 遥测事件模型
|
||||
class TelemetryEvent extends Equatable {
|
||||
/// 事件ID (UUID)
|
||||
final String eventId;
|
||||
|
||||
/// 事件类型
|
||||
final EventType type;
|
||||
|
||||
/// 事件级别
|
||||
final EventLevel level;
|
||||
|
||||
/// 事件名称: 'app_session_start', 'open_planting_page'
|
||||
final String name;
|
||||
|
||||
/// 事件参数
|
||||
final Map<String, dynamic>? properties;
|
||||
|
||||
/// 事件时间戳
|
||||
final DateTime timestamp;
|
||||
|
||||
/// 用户ID(登录后设置)
|
||||
final String? userId;
|
||||
|
||||
/// 会话ID
|
||||
final String? sessionId;
|
||||
|
||||
/// 安装ID(设备唯一标识)
|
||||
final String installId;
|
||||
|
||||
/// 关联设备信息ID
|
||||
final String deviceContextId;
|
||||
|
||||
const TelemetryEvent({
|
||||
required this.eventId,
|
||||
required this.type,
|
||||
required this.level,
|
||||
required this.name,
|
||||
this.properties,
|
||||
required this.timestamp,
|
||||
this.userId,
|
||||
this.sessionId,
|
||||
required this.installId,
|
||||
required this.deviceContextId,
|
||||
});
|
||||
|
||||
factory TelemetryEvent.fromJson(Map<String, dynamic> json) {
|
||||
return TelemetryEvent(
|
||||
eventId: json['eventId'] as String,
|
||||
type: EventType.values.firstWhere(
|
||||
(e) => e.name == json['type'],
|
||||
orElse: () => EventType.userAction,
|
||||
),
|
||||
level: EventLevel.values.firstWhere(
|
||||
(e) => e.name == json['level'],
|
||||
orElse: () => EventLevel.info,
|
||||
),
|
||||
name: json['name'] as String,
|
||||
properties: json['properties'] as Map<String, dynamic>?,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
userId: json['userId'] as String?,
|
||||
sessionId: json['sessionId'] as String?,
|
||||
installId: json['installId'] as String,
|
||||
deviceContextId: json['deviceContextId'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
/// 转换为本地存储 JSON 格式
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'eventId': eventId,
|
||||
'type': type.name,
|
||||
'level': level.name,
|
||||
'name': name,
|
||||
'properties': properties,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'userId': userId,
|
||||
'sessionId': sessionId,
|
||||
'installId': installId,
|
||||
'deviceContextId': deviceContextId,
|
||||
};
|
||||
}
|
||||
|
||||
/// 转换为服务端 API 格式(Amplitude 风格:设备字段为顶层独立列,方便服务端索引)
|
||||
/// 顶层字段: eventName, userId, installId, clientTs, deviceBrand, deviceModel,
|
||||
/// deviceOs, appVersion, locale
|
||||
/// properties: 仅保留事件专属数据(页面名、金额等)
|
||||
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 {
|
||||
'eventName': name,
|
||||
'userId': userId,
|
||||
'installId': installId,
|
||||
'clientTs': timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
'deviceBrand': deviceBrand,
|
||||
'deviceModel': deviceModel,
|
||||
'deviceOs': deviceOs,
|
||||
'appVersion': appVersion,
|
||||
'locale': locale,
|
||||
'properties': {
|
||||
...props,
|
||||
'eventId': eventId,
|
||||
'type': type.name,
|
||||
'level': level.name,
|
||||
'sessionId': sessionId,
|
||||
'deviceContextId': deviceContextId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
TelemetryEvent copyWith({
|
||||
String? eventId,
|
||||
EventType? type,
|
||||
EventLevel? level,
|
||||
String? name,
|
||||
Map<String, dynamic>? properties,
|
||||
DateTime? timestamp,
|
||||
String? userId,
|
||||
String? sessionId,
|
||||
String? installId,
|
||||
String? deviceContextId,
|
||||
}) {
|
||||
return TelemetryEvent(
|
||||
eventId: eventId ?? this.eventId,
|
||||
type: type ?? this.type,
|
||||
level: level ?? this.level,
|
||||
name: name ?? this.name,
|
||||
properties: properties ?? this.properties,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
userId: userId ?? this.userId,
|
||||
sessionId: sessionId ?? this.sessionId,
|
||||
installId: installId ?? this.installId,
|
||||
deviceContextId: deviceContextId ?? this.deviceContextId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
eventId,
|
||||
type,
|
||||
level,
|
||||
name,
|
||||
properties,
|
||||
timestamp,
|
||||
userId,
|
||||
sessionId,
|
||||
installId,
|
||||
deviceContextId,
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../session/session_manager.dart';
|
||||
import '../session/session_events.dart';
|
||||
import 'presence_config.dart';
|
||||
|
||||
/// 心跳服务
|
||||
///
|
||||
/// 职责:
|
||||
/// 1. 在 App 前台时定期发送心跳
|
||||
/// 2. 进入后台时停止心跳
|
||||
/// 3. 心跳失败时不立即重试,等待下一个周期
|
||||
class HeartbeatService {
|
||||
static HeartbeatService? _instance;
|
||||
|
||||
HeartbeatService._();
|
||||
|
||||
factory HeartbeatService() {
|
||||
_instance ??= HeartbeatService._();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// 配置
|
||||
PresenceConfig _config = PresenceConfig.defaultConfig;
|
||||
|
||||
/// 心跳定时器
|
||||
Timer? _heartbeatTimer;
|
||||
|
||||
/// 是否正在运行
|
||||
bool _isRunning = false;
|
||||
bool get isRunning => _isRunning;
|
||||
|
||||
/// 最后一次心跳时间
|
||||
DateTime? _lastHeartbeatAt;
|
||||
DateTime? get lastHeartbeatAt => _lastHeartbeatAt;
|
||||
|
||||
/// 心跳计数(调试用)
|
||||
int _heartbeatCount = 0;
|
||||
int get heartbeatCount => _heartbeatCount;
|
||||
|
||||
/// API 基础地址
|
||||
String? _apiBaseUrl;
|
||||
|
||||
/// 获取 installId 的回调
|
||||
String Function()? getInstallId;
|
||||
|
||||
/// 获取 userId 的回调
|
||||
String? Function()? getUserId;
|
||||
|
||||
/// 获取 appVersion 的回调
|
||||
String Function()? getAppVersion;
|
||||
|
||||
/// 获取认证头的回调
|
||||
Map<String, String> Function()? getAuthHeaders;
|
||||
|
||||
late Dio _dio;
|
||||
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// 初始化
|
||||
void initialize({
|
||||
required String apiBaseUrl,
|
||||
PresenceConfig? config,
|
||||
required String Function() getInstallId,
|
||||
required String? Function() getUserId,
|
||||
required String Function() getAppVersion,
|
||||
Map<String, String> Function()? getAuthHeaders,
|
||||
}) {
|
||||
if (_isInitialized) return;
|
||||
|
||||
_apiBaseUrl = apiBaseUrl;
|
||||
_config = config ?? PresenceConfig.defaultConfig;
|
||||
this.getInstallId = getInstallId;
|
||||
this.getUserId = getUserId;
|
||||
this.getAppVersion = getAppVersion;
|
||||
this.getAuthHeaders = getAuthHeaders;
|
||||
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 5),
|
||||
));
|
||||
|
||||
// 监听会话状态变化
|
||||
final sessionManager = SessionManager();
|
||||
sessionManager.onSessionStart = _onSessionStart;
|
||||
sessionManager.onSessionEnd = _onSessionEnd;
|
||||
|
||||
// 如果当前已经在前台,立即启动心跳
|
||||
if (sessionManager.state == SessionState.foreground) {
|
||||
_startHeartbeat();
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint(
|
||||
'💓 [Heartbeat] Initialized, interval: ${_config.heartbeatIntervalSeconds}s');
|
||||
}
|
||||
|
||||
/// 更新配置(支持远程配置热更新)
|
||||
void updateConfig(PresenceConfig config) {
|
||||
final wasRunning = _isRunning;
|
||||
|
||||
if (wasRunning) {
|
||||
_stopHeartbeat();
|
||||
}
|
||||
|
||||
_config = config;
|
||||
|
||||
if (wasRunning && _config.enabled) {
|
||||
_startHeartbeat();
|
||||
}
|
||||
|
||||
debugPrint('💓 [Heartbeat] Config updated');
|
||||
}
|
||||
|
||||
/// 销毁
|
||||
void dispose() {
|
||||
_stopHeartbeat();
|
||||
_isInitialized = false;
|
||||
_instance = null;
|
||||
debugPrint('💓 [Heartbeat] Disposed');
|
||||
}
|
||||
|
||||
/// 会话开始回调
|
||||
void _onSessionStart() {
|
||||
if (_config.enabled) {
|
||||
_startHeartbeat();
|
||||
}
|
||||
}
|
||||
|
||||
/// 会话结束回调
|
||||
void _onSessionEnd() {
|
||||
_stopHeartbeat();
|
||||
}
|
||||
|
||||
/// 启动心跳
|
||||
void _startHeartbeat() {
|
||||
if (_isRunning) return;
|
||||
if (!_config.enabled) return;
|
||||
|
||||
_isRunning = true;
|
||||
_heartbeatCount = 0;
|
||||
|
||||
// 立即发送第一次心跳
|
||||
_sendHeartbeat();
|
||||
|
||||
// 启动定时器
|
||||
_heartbeatTimer = Timer.periodic(
|
||||
Duration(seconds: _config.heartbeatIntervalSeconds),
|
||||
(_) => _sendHeartbeat(),
|
||||
);
|
||||
|
||||
debugPrint('💓 [Heartbeat] Started');
|
||||
}
|
||||
|
||||
/// 停止心跳
|
||||
void _stopHeartbeat() {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
_isRunning = false;
|
||||
|
||||
debugPrint('💓 [Heartbeat] Stopped (count: $_heartbeatCount)');
|
||||
}
|
||||
|
||||
/// 发送心跳
|
||||
Future<void> _sendHeartbeat() async {
|
||||
// 检查是否需要登录
|
||||
if (_config.requiresAuth && (getUserId?.call() == null)) {
|
||||
debugPrint('💓 [Heartbeat] Skipped: user not logged in');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'/api/v1/telemetry/heartbeat',
|
||||
data: {
|
||||
'installId': getInstallId?.call() ?? '',
|
||||
'appVersion': getAppVersion?.call() ?? '',
|
||||
'clientTs': DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
},
|
||||
options: Options(
|
||||
headers: getAuthHeaders?.call(),
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
_lastHeartbeatAt = DateTime.now();
|
||||
_heartbeatCount++;
|
||||
debugPrint('💓 [Heartbeat] Sent #$_heartbeatCount');
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
// 心跳失败不重试,等待下一个周期
|
||||
debugPrint('💓 [Heartbeat] Failed (DioException): ${e.message}');
|
||||
} catch (e) {
|
||||
// 心跳失败不重试,等待下一个周期
|
||||
debugPrint('💓 [Heartbeat] Failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 手动触发心跳(用于测试)
|
||||
@visibleForTesting
|
||||
Future<void> forceHeartbeat() async {
|
||||
await _sendHeartbeat();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/// 心跳配置
|
||||
class PresenceConfig {
|
||||
/// 心跳间隔(秒)
|
||||
/// 默认 60 秒,与后端 3 分钟窗口配合
|
||||
final int heartbeatIntervalSeconds;
|
||||
|
||||
/// 是否仅登录用户发送心跳
|
||||
/// 默认 true,未登录用户不参与在线统计
|
||||
final bool requiresAuth;
|
||||
|
||||
/// 是否启用心跳
|
||||
final bool enabled;
|
||||
|
||||
const PresenceConfig({
|
||||
this.heartbeatIntervalSeconds = 60,
|
||||
this.requiresAuth = true,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
/// 默认配置
|
||||
static const PresenceConfig defaultConfig = PresenceConfig();
|
||||
|
||||
/// 从远程配置解析
|
||||
factory PresenceConfig.fromJson(Map<String, dynamic> json) {
|
||||
return PresenceConfig(
|
||||
heartbeatIntervalSeconds: json['heartbeat_interval_seconds'] ?? 60,
|
||||
requiresAuth: json['requires_auth'] ?? true,
|
||||
enabled: json['presence_enabled'] ?? json['enabled'] ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'heartbeat_interval_seconds': heartbeatIntervalSeconds,
|
||||
'requires_auth': requiresAuth,
|
||||
'presence_enabled': enabled,
|
||||
};
|
||||
}
|
||||
|
||||
PresenceConfig copyWith({
|
||||
int? heartbeatIntervalSeconds,
|
||||
bool? requiresAuth,
|
||||
bool? enabled,
|
||||
}) {
|
||||
return PresenceConfig(
|
||||
heartbeatIntervalSeconds:
|
||||
heartbeatIntervalSeconds ?? this.heartbeatIntervalSeconds,
|
||||
requiresAuth: requiresAuth ?? this.requiresAuth,
|
||||
enabled: enabled ?? this.enabled,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
/// 会话相关的事件名常量
|
||||
class SessionEvents {
|
||||
/// App 会话开始(用于 DAU 统计)
|
||||
/// 触发时机:App 从后台切到前台,或冷启动
|
||||
static const String sessionStart = 'app_session_start';
|
||||
|
||||
/// App 会话结束
|
||||
/// 触发时机:App 进入后台
|
||||
static const String sessionEnd = 'app_session_end';
|
||||
|
||||
/// 心跳事件(用于在线统计)
|
||||
/// 触发时机:前台状态下每 60 秒
|
||||
static const String heartbeat = 'presence_heartbeat';
|
||||
|
||||
/// 私有构造函数,防止实例化
|
||||
SessionEvents._();
|
||||
}
|
||||
|
||||
/// 会话状态
|
||||
enum SessionState {
|
||||
/// 前台活跃
|
||||
foreground,
|
||||
|
||||
/// 后台
|
||||
background,
|
||||
|
||||
/// 未知(初始状态)
|
||||
unknown,
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../telemetry_service.dart';
|
||||
import '../models/telemetry_event.dart';
|
||||
import 'session_events.dart';
|
||||
|
||||
/// 会话管理器
|
||||
///
|
||||
/// 职责:
|
||||
/// 1. 监听 App 生命周期,触发 app_session_start/app_session_end 事件
|
||||
/// 2. 管理 sessionId(每次前台生成新的)
|
||||
/// 3. 与 HeartbeatService 联动(前台启动心跳,后台停止)
|
||||
class SessionManager with WidgetsBindingObserver {
|
||||
static SessionManager? _instance;
|
||||
|
||||
SessionManager._();
|
||||
|
||||
factory SessionManager() {
|
||||
_instance ??= SessionManager._();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// 当前会话 ID
|
||||
String? _currentSessionId;
|
||||
String? get currentSessionId => _currentSessionId;
|
||||
|
||||
/// 当前会话状态
|
||||
SessionState _state = SessionState.unknown;
|
||||
SessionState get state => _state;
|
||||
|
||||
/// 会话开始时间
|
||||
DateTime? _sessionStartTime;
|
||||
|
||||
/// 回调:会话开始(HeartbeatService 监听此回调)
|
||||
VoidCallback? onSessionStart;
|
||||
|
||||
/// 回调:会话结束(HeartbeatService 监听此回调)
|
||||
VoidCallback? onSessionEnd;
|
||||
|
||||
/// TelemetryService 引用
|
||||
TelemetryService? _telemetryService;
|
||||
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// 初始化
|
||||
void initialize(TelemetryService telemetryService) {
|
||||
if (_isInitialized) return;
|
||||
|
||||
_telemetryService = telemetryService;
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
// 首次启动视为进入前台
|
||||
_handleForeground();
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('📱 [Session] Manager initialized');
|
||||
}
|
||||
|
||||
/// 销毁
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_isInitialized = false;
|
||||
_instance = null;
|
||||
debugPrint('📱 [Session] Manager disposed');
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
switch (state) {
|
||||
case AppLifecycleState.resumed:
|
||||
_handleForeground();
|
||||
break;
|
||||
case AppLifecycleState.paused:
|
||||
_handleBackground();
|
||||
break;
|
||||
case AppLifecycleState.inactive:
|
||||
case AppLifecycleState.detached:
|
||||
case AppLifecycleState.hidden:
|
||||
// 不处理这些中间状态
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理进入前台
|
||||
void _handleForeground() {
|
||||
if (_state == SessionState.foreground) return;
|
||||
|
||||
_state = SessionState.foreground;
|
||||
_startNewSession();
|
||||
|
||||
// 上传上次被强杀遗留的事件 + 本次 session_start(含设备信息)
|
||||
// Amplitude/Mixpanel 标准做法:回到前台即是下一次上传机会
|
||||
_telemetryService?.flushOnBackground();
|
||||
}
|
||||
|
||||
/// 处理进入后台
|
||||
void _handleBackground() {
|
||||
if (_state == SessionState.background) return;
|
||||
|
||||
_state = SessionState.background;
|
||||
_endCurrentSession();
|
||||
|
||||
// session_end 写入队列后立即上传,不等待10条阈值
|
||||
_telemetryService?.flushOnBackground();
|
||||
}
|
||||
|
||||
/// 开始新会话
|
||||
void _startNewSession() {
|
||||
// 生成新的 sessionId
|
||||
_currentSessionId = const Uuid().v4();
|
||||
_sessionStartTime = DateTime.now();
|
||||
|
||||
// 记录 app_session_start 事件(用于 DAU)
|
||||
_telemetryService?.logEvent(
|
||||
SessionEvents.sessionStart,
|
||||
type: EventType.session,
|
||||
level: EventLevel.info,
|
||||
properties: {
|
||||
'session_id': _currentSessionId,
|
||||
},
|
||||
);
|
||||
|
||||
// 通知外部(HeartbeatService 会监听这个回调)
|
||||
onSessionStart?.call();
|
||||
|
||||
debugPrint('📱 [Session] Started: $_currentSessionId');
|
||||
}
|
||||
|
||||
/// 结束当前会话
|
||||
void _endCurrentSession() {
|
||||
if (_currentSessionId == null) return;
|
||||
|
||||
final duration = _sessionStartTime != null
|
||||
? DateTime.now().difference(_sessionStartTime!).inSeconds
|
||||
: 0;
|
||||
|
||||
// 记录 app_session_end 事件
|
||||
_telemetryService?.logEvent(
|
||||
SessionEvents.sessionEnd,
|
||||
type: EventType.session,
|
||||
level: EventLevel.info,
|
||||
properties: {
|
||||
'session_id': _currentSessionId,
|
||||
'duration_seconds': duration,
|
||||
},
|
||||
);
|
||||
|
||||
// 通知外部(HeartbeatService 会监听这个回调)
|
||||
onSessionEnd?.call();
|
||||
|
||||
debugPrint('📱 [Session] Ended: $_currentSessionId (${duration}s)');
|
||||
|
||||
_currentSessionId = null;
|
||||
_sessionStartTime = null;
|
||||
}
|
||||
|
||||
/// 获取当前会话时长(秒)
|
||||
int get sessionDurationSeconds {
|
||||
if (_sessionStartTime == null) return 0;
|
||||
return DateTime.now().difference(_sessionStartTime!).inSeconds;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/telemetry_event.dart';
|
||||
|
||||
/// 遥测本地存储
|
||||
/// 负责缓存事件队列和设备上下文
|
||||
class TelemetryStorage {
|
||||
static const String _keyEventQueue = 'telemetry_event_queue';
|
||||
static const String _keyDeviceContext = 'telemetry_device_context';
|
||||
static const String _keyInstallId = 'telemetry_install_id';
|
||||
static const int _maxQueueSize = 500; // 最多缓存500条
|
||||
|
||||
late SharedPreferences _prefs;
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// 初始化
|
||||
Future<void> init() async {
|
||||
if (_isInitialized) return;
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
/// 保存设备上下文
|
||||
Future<void> saveDeviceContext(Map<String, dynamic> context) async {
|
||||
await _prefs.setString(_keyDeviceContext, jsonEncode(context));
|
||||
}
|
||||
|
||||
/// 读取设备上下文
|
||||
Map<String, dynamic>? getDeviceContext() {
|
||||
final str = _prefs.getString(_keyDeviceContext);
|
||||
if (str == null) return null;
|
||||
return jsonDecode(str) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// 保存 InstallId
|
||||
Future<void> saveInstallId(String installId) async {
|
||||
await _prefs.setString(_keyInstallId, installId);
|
||||
}
|
||||
|
||||
/// 读取 InstallId
|
||||
String? getInstallId() {
|
||||
return _prefs.getString(_keyInstallId);
|
||||
}
|
||||
|
||||
/// 添加事件到队列
|
||||
Future<void> enqueueEvent(TelemetryEvent event) async {
|
||||
final queue = _getEventQueue();
|
||||
|
||||
// 防止队列过大
|
||||
if (queue.length >= _maxQueueSize) {
|
||||
queue.removeAt(0); // 移除最旧的
|
||||
debugPrint('⚠️ Telemetry queue full, removed oldest event');
|
||||
}
|
||||
|
||||
queue.add(event.toJson());
|
||||
await _saveEventQueue(queue);
|
||||
}
|
||||
|
||||
/// 批量添加事件
|
||||
Future<void> enqueueEvents(List<TelemetryEvent> events) async {
|
||||
final queue = _getEventQueue();
|
||||
|
||||
for (var event in events) {
|
||||
if (queue.length >= _maxQueueSize) break;
|
||||
queue.add(event.toJson());
|
||||
}
|
||||
|
||||
await _saveEventQueue(queue);
|
||||
}
|
||||
|
||||
/// 获取待上传的事件(最多N条)
|
||||
List<TelemetryEvent> dequeueEvents(int limit) {
|
||||
final queue = _getEventQueue();
|
||||
final count = queue.length > limit ? limit : queue.length;
|
||||
|
||||
final events = queue
|
||||
.take(count)
|
||||
.map((json) => TelemetryEvent.fromJson(json))
|
||||
.toList();
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/// 删除已上传的事件
|
||||
Future<void> removeEvents(int count) async {
|
||||
final queue = _getEventQueue();
|
||||
if (count >= queue.length) {
|
||||
await clearEventQueue();
|
||||
} else {
|
||||
queue.removeRange(0, count);
|
||||
await _saveEventQueue(queue);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取队列长度
|
||||
int getQueueSize() {
|
||||
return _getEventQueue().length;
|
||||
}
|
||||
|
||||
/// 清空事件队列
|
||||
Future<void> clearEventQueue() async {
|
||||
await _prefs.remove(_keyEventQueue);
|
||||
}
|
||||
|
||||
/// 清空所有遥测数据(退出登录时调用)
|
||||
/// 保留 installId 和 deviceContext(设备级别数据)
|
||||
Future<void> clearUserData() async {
|
||||
if (!_isInitialized) {
|
||||
await init();
|
||||
}
|
||||
await _prefs.remove(_keyEventQueue);
|
||||
debugPrint('📊 TelemetryStorage: 已清除用户相关遥测数据');
|
||||
}
|
||||
|
||||
// 私有方法
|
||||
List<Map<String, dynamic>> _getEventQueue() {
|
||||
final str = _prefs.getString(_keyEventQueue);
|
||||
if (str == null) return [];
|
||||
|
||||
try {
|
||||
final List<dynamic> list = jsonDecode(str);
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Failed to parse event queue: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveEventQueue(List<Map<String, dynamic>> queue) async {
|
||||
await _prefs.setString(_keyEventQueue, jsonEncode(queue));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,396 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'models/telemetry_event.dart';
|
||||
import 'models/device_context.dart';
|
||||
import 'models/telemetry_config.dart';
|
||||
import 'collectors/device_info_collector.dart';
|
||||
import 'storage/telemetry_storage.dart';
|
||||
import 'uploader/telemetry_uploader.dart';
|
||||
import 'session/session_manager.dart';
|
||||
import 'presence/heartbeat_service.dart';
|
||||
import 'presence/presence_config.dart';
|
||||
|
||||
/// 遥测服务
|
||||
/// 统一管理设备信息收集、事件记录、上传等功能
|
||||
class TelemetryService {
|
||||
static TelemetryService? _instance;
|
||||
TelemetryService._();
|
||||
|
||||
factory TelemetryService() {
|
||||
_instance ??= TelemetryService._();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
final _storage = TelemetryStorage();
|
||||
late TelemetryUploader _uploader;
|
||||
|
||||
DeviceContext? _deviceContext;
|
||||
|
||||
/// 安装ID(设备唯一标识,首次安装生成,用于未登录用户的DAU去重)
|
||||
late String _installId;
|
||||
String get installId => _installId;
|
||||
|
||||
/// 用户ID(登录后设置)
|
||||
String? _userId;
|
||||
String? get userId => _userId;
|
||||
|
||||
/// 访问令牌(登录后缓存,用于心跳/上传认证头)
|
||||
String? _accessToken;
|
||||
|
||||
/// API 基础地址
|
||||
late String _apiBaseUrl;
|
||||
|
||||
bool _isInitialized = false;
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
/// 会话管理器
|
||||
late SessionManager _sessionManager;
|
||||
|
||||
/// 心跳服务
|
||||
late HeartbeatService _heartbeatService;
|
||||
|
||||
Timer? _configSyncTimer;
|
||||
|
||||
/// 初始化(在main.dart中调用)
|
||||
Future<void> initialize({
|
||||
required String apiBaseUrl,
|
||||
required BuildContext context,
|
||||
String? userId,
|
||||
Duration configSyncInterval = const Duration(hours: 1),
|
||||
PresenceConfig? presenceConfig,
|
||||
}) async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
_apiBaseUrl = apiBaseUrl;
|
||||
|
||||
// 1. 初始化存储
|
||||
await _storage.init();
|
||||
|
||||
// 2. 初始化或获取 installId
|
||||
await _initInstallId();
|
||||
|
||||
// 3. 加载用户选择
|
||||
await TelemetryConfig().loadUserOptIn();
|
||||
|
||||
// 4. 同步远程配置(非阻塞,后台执行)
|
||||
// 不阻塞启动流程,使用本地缓存的配置先启动
|
||||
TelemetryConfig().syncFromRemote(apiBaseUrl).catchError((e) {
|
||||
debugPrint('📊 [Telemetry] Remote config sync failed (non-blocking): $e');
|
||||
});
|
||||
|
||||
// 5. 收集设备信息
|
||||
_deviceContext = await DeviceInfoCollector().collect(context);
|
||||
await _storage.saveDeviceContext(_deviceContext!.toJson());
|
||||
|
||||
// 6. 设置用户ID
|
||||
_userId = userId;
|
||||
|
||||
// 7. 初始化上传器
|
||||
_uploader = TelemetryUploader(
|
||||
apiBaseUrl: apiBaseUrl,
|
||||
storage: _storage,
|
||||
getAuthHeaders: _getAuthHeaders,
|
||||
);
|
||||
|
||||
// 8. 启动定时上传(如果启用)
|
||||
if (TelemetryConfig().globalEnabled) {
|
||||
_uploader.startPeriodicUpload();
|
||||
}
|
||||
|
||||
// 9. 定期同步配置
|
||||
_configSyncTimer = Timer.periodic(configSyncInterval, (_) async {
|
||||
await TelemetryConfig().syncFromRemote(apiBaseUrl);
|
||||
|
||||
// 根据最新配置调整上传器状态
|
||||
if (TelemetryConfig().globalEnabled) {
|
||||
_uploader.startPeriodicUpload();
|
||||
} else {
|
||||
_uploader.stopPeriodicUpload();
|
||||
}
|
||||
|
||||
// 更新心跳配置
|
||||
if (TelemetryConfig().presenceConfig != null) {
|
||||
_heartbeatService.updateConfig(TelemetryConfig().presenceConfig!);
|
||||
}
|
||||
});
|
||||
|
||||
// 10. 初始化会话管理器
|
||||
_sessionManager = SessionManager();
|
||||
_sessionManager.initialize(this);
|
||||
|
||||
// 11. 初始化心跳服务
|
||||
_heartbeatService = HeartbeatService();
|
||||
_heartbeatService.initialize(
|
||||
apiBaseUrl: apiBaseUrl,
|
||||
config: presenceConfig ?? TelemetryConfig().presenceConfig,
|
||||
getInstallId: () => _installId,
|
||||
getUserId: () => _userId,
|
||||
getAppVersion: () => _deviceContext?.appVersion ?? 'unknown',
|
||||
getAuthHeaders: _getAuthHeaders,
|
||||
);
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('📊 TelemetryService initialized');
|
||||
debugPrint(' InstallId: $_installId');
|
||||
debugPrint(' UserId: $_userId');
|
||||
}
|
||||
|
||||
/// 初始化 installId
|
||||
Future<void> _initInstallId() async {
|
||||
final storedId = _storage.getInstallId();
|
||||
|
||||
if (storedId != null) {
|
||||
_installId = storedId;
|
||||
} else {
|
||||
_installId = const Uuid().v4();
|
||||
await _storage.saveInstallId(_installId);
|
||||
}
|
||||
|
||||
debugPrint('📊 [Telemetry] Install ID: $_installId');
|
||||
}
|
||||
|
||||
/// 获取认证头(使用缓存的 access token,由 AccountService 登录时注入)
|
||||
Map<String, String> _getAuthHeaders() {
|
||||
if (_accessToken != null) {
|
||||
return {'Authorization': 'Bearer $_accessToken'};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/// 记录事件(核心方法)
|
||||
void logEvent(
|
||||
String eventName, {
|
||||
EventType type = EventType.userAction,
|
||||
EventLevel level = EventLevel.info,
|
||||
Map<String, dynamic>? properties,
|
||||
}) {
|
||||
if (!_isInitialized) {
|
||||
debugPrint('⚠️ TelemetryService not initialized, event ignored');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查配置:是否应该记录
|
||||
if (!TelemetryConfig().shouldLog(type, eventName)) {
|
||||
return; // 配置禁止记录
|
||||
}
|
||||
|
||||
// 采样判断(错误和崩溃不采样)
|
||||
if (_needsSampling(type)) {
|
||||
if (Random().nextDouble() > TelemetryConfig().samplingRate) {
|
||||
return; // 未被采样
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
eventId: const Uuid().v4(),
|
||||
type: type,
|
||||
level: level,
|
||||
name: eventName,
|
||||
properties: {...deviceProps, ...?properties},
|
||||
timestamp: DateTime.now(),
|
||||
userId: _userId,
|
||||
sessionId: _sessionManager.currentSessionId,
|
||||
installId: _installId,
|
||||
deviceContextId: _deviceContext?.androidId ?? '',
|
||||
);
|
||||
|
||||
_storage.enqueueEvent(event);
|
||||
|
||||
// 检查是否需要立即上传
|
||||
_uploader.uploadIfNeeded();
|
||||
}
|
||||
|
||||
/// 判断是否需要采样
|
||||
bool _needsSampling(EventType type) {
|
||||
// 错误、崩溃、会话事件 100% 上报,不采样
|
||||
return type != EventType.error &&
|
||||
type != EventType.crash &&
|
||||
type != EventType.session;
|
||||
}
|
||||
|
||||
/// 记录页面访问
|
||||
void logPageView(String pageName, {Map<String, dynamic>? extra}) {
|
||||
logEvent(
|
||||
'page_view',
|
||||
type: EventType.pageView,
|
||||
properties: {'page': pageName, ...?extra},
|
||||
);
|
||||
}
|
||||
|
||||
/// 记录用户行为
|
||||
void logUserAction(String action, {Map<String, dynamic>? properties}) {
|
||||
logEvent(
|
||||
action,
|
||||
type: EventType.userAction,
|
||||
properties: properties,
|
||||
);
|
||||
}
|
||||
|
||||
/// 记录错误
|
||||
void logError(
|
||||
String errorMessage, {
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
Map<String, dynamic>? extra,
|
||||
}) {
|
||||
logEvent(
|
||||
'error_occurred',
|
||||
type: EventType.error,
|
||||
level: EventLevel.error,
|
||||
properties: {
|
||||
'message': errorMessage,
|
||||
'error': error?.toString(),
|
||||
'stack_trace': stackTrace?.toString(),
|
||||
...?extra,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 记录API调用
|
||||
void logApiCall({
|
||||
required String url,
|
||||
required String method,
|
||||
required int statusCode,
|
||||
required int durationMs,
|
||||
String? error,
|
||||
}) {
|
||||
logEvent(
|
||||
'api_call',
|
||||
type: EventType.apiCall,
|
||||
level: error != null ? EventLevel.error : EventLevel.info,
|
||||
properties: {
|
||||
'url': url,
|
||||
'method': method,
|
||||
'status_code': statusCode,
|
||||
'duration_ms': durationMs,
|
||||
'error': error,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 记录性能指标
|
||||
void logPerformance(
|
||||
String metricName, {
|
||||
required int durationMs,
|
||||
Map<String, dynamic>? extra,
|
||||
}) {
|
||||
logEvent(
|
||||
metricName,
|
||||
type: EventType.performance,
|
||||
properties: {'duration_ms': durationMs, ...?extra},
|
||||
);
|
||||
}
|
||||
|
||||
/// 设置用户ID(登录后调用)
|
||||
void setUserId(String? userId) {
|
||||
_userId = userId;
|
||||
debugPrint('📊 [Telemetry] User ID set: $userId');
|
||||
}
|
||||
|
||||
/// 清除用户ID(退出登录时)
|
||||
void clearUserId() {
|
||||
_userId = null;
|
||||
debugPrint('📊 [Telemetry] User ID cleared');
|
||||
}
|
||||
|
||||
/// 设置访问令牌(登录/切换账号后调用,用于心跳认证)
|
||||
void setAccessToken(String? token) {
|
||||
_accessToken = token;
|
||||
debugPrint('📊 [Telemetry] Access token ${token != null ? 'set' : 'cleared'}');
|
||||
}
|
||||
|
||||
/// 清除访问令牌(退出登录时调用)
|
||||
void clearAccessToken() {
|
||||
_accessToken = null;
|
||||
}
|
||||
|
||||
/// 暂停遥测上传(退出登录时调用)
|
||||
/// 停止定期上传任务,清空事件队列
|
||||
Future<void> pauseForLogout() async {
|
||||
_uploader.stopPeriodicUpload();
|
||||
await _storage.clearEventQueue();
|
||||
_userId = null;
|
||||
debugPrint('📊 [Telemetry] Paused for logout');
|
||||
}
|
||||
|
||||
/// 恢复遥测上传(登录后调用)
|
||||
void resumeAfterLogin() {
|
||||
if (TelemetryConfig().globalEnabled) {
|
||||
_uploader.startPeriodicUpload();
|
||||
debugPrint('📊 [Telemetry] Resumed after login');
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置用户是否同意数据收集
|
||||
Future<void> setUserOptIn(bool optIn) async {
|
||||
await TelemetryConfig().setUserOptIn(optIn);
|
||||
|
||||
if (!optIn) {
|
||||
// 用户拒绝,停止上传并清空队列
|
||||
_uploader.stopPeriodicUpload();
|
||||
await _storage.clearEventQueue();
|
||||
debugPrint('📊 Telemetry disabled by user');
|
||||
} else {
|
||||
// 用户同意,重新启动
|
||||
if (TelemetryConfig().globalEnabled) {
|
||||
_uploader.startPeriodicUpload();
|
||||
}
|
||||
debugPrint('📊 Telemetry enabled by user');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 会话和在线状态相关方法 ==========
|
||||
|
||||
/// 获取当前会话 ID
|
||||
String? get currentSessionId => _sessionManager.currentSessionId;
|
||||
|
||||
/// 获取会话时长(秒)
|
||||
int get sessionDurationSeconds => _sessionManager.sessionDurationSeconds;
|
||||
|
||||
/// 心跳是否运行中
|
||||
bool get isHeartbeatRunning => _heartbeatService.isRunning;
|
||||
|
||||
/// 心跳计数
|
||||
int get heartbeatCount => _heartbeatService.heartbeatCount;
|
||||
|
||||
/// 更新心跳配置
|
||||
void updatePresenceConfig(PresenceConfig config) {
|
||||
_heartbeatService.updateConfig(config);
|
||||
}
|
||||
|
||||
/// 获取设备上下文
|
||||
DeviceContext? get deviceContext => _deviceContext;
|
||||
|
||||
/// App进入后台时调用:忽略10条阈值,立即上传队列中的事件
|
||||
Future<void> flushOnBackground() async {
|
||||
await _uploader.uploadBatch(batchSize: 50);
|
||||
debugPrint('📊 [Telemetry] Flushed on background');
|
||||
}
|
||||
|
||||
/// App退出前调用
|
||||
Future<void> dispose() async {
|
||||
_configSyncTimer?.cancel();
|
||||
_sessionManager.dispose();
|
||||
_heartbeatService.dispose();
|
||||
await _uploader.forceUploadAll();
|
||||
_isInitialized = false;
|
||||
debugPrint('📊 TelemetryService disposed');
|
||||
}
|
||||
|
||||
/// 重置实例
|
||||
static void reset() {
|
||||
_instance = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import 'dart:async';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../storage/telemetry_storage.dart';
|
||||
|
||||
/// 遥测上传器
|
||||
/// 负责批量上传事件到服务器
|
||||
class TelemetryUploader {
|
||||
final String apiBaseUrl;
|
||||
final TelemetryStorage storage;
|
||||
final Dio _dio;
|
||||
|
||||
Timer? _uploadTimer;
|
||||
bool _isUploading = false;
|
||||
|
||||
/// 获取认证头的回调
|
||||
Map<String, String> Function()? getAuthHeaders;
|
||||
|
||||
TelemetryUploader({
|
||||
required this.apiBaseUrl,
|
||||
required this.storage,
|
||||
this.getAuthHeaders,
|
||||
}) : _dio = Dio(BaseOptions(
|
||||
baseUrl: apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
));
|
||||
|
||||
/// 启动定时上传(每30秒或累积20条)
|
||||
void startPeriodicUpload({
|
||||
Duration interval = const Duration(seconds: 30),
|
||||
int batchSize = 20,
|
||||
}) {
|
||||
_uploadTimer?.cancel();
|
||||
_uploadTimer = Timer.periodic(interval, (_) {
|
||||
uploadIfNeeded(batchSize: batchSize);
|
||||
});
|
||||
debugPrint('📊 Telemetry uploader started (interval: ${interval.inSeconds}s)');
|
||||
}
|
||||
|
||||
/// 停止定时上传
|
||||
void stopPeriodicUpload() {
|
||||
_uploadTimer?.cancel();
|
||||
_uploadTimer = null;
|
||||
debugPrint('📊 Telemetry uploader stopped');
|
||||
}
|
||||
|
||||
/// 条件上传(队列大于阈值才上传)
|
||||
Future<void> uploadIfNeeded({int batchSize = 20}) async {
|
||||
if (_isUploading) return;
|
||||
|
||||
final queueSize = storage.getQueueSize();
|
||||
if (queueSize < 10) return; // 少于10条不上传,等待积累
|
||||
|
||||
await uploadBatch(batchSize: batchSize);
|
||||
}
|
||||
|
||||
/// 立即上传一批
|
||||
Future<bool> uploadBatch({int batchSize = 20}) async {
|
||||
if (_isUploading) return false;
|
||||
|
||||
_isUploading = true;
|
||||
try {
|
||||
final events = storage.dequeueEvents(batchSize);
|
||||
if (events.isEmpty) return true;
|
||||
|
||||
// 调用后端API (使用 toServerJson 格式化为服务端期望的格式)
|
||||
final response = await _dio.post(
|
||||
'/api/v1/telemetry/events',
|
||||
data: {
|
||||
'events': events.map((e) => e.toServerJson()).toList(),
|
||||
},
|
||||
options: Options(
|
||||
headers: getAuthHeaders?.call(),
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// 上传成功,删除本地记录
|
||||
await storage.removeEvents(events.length);
|
||||
debugPrint('✅ Uploaded ${events.length} telemetry events');
|
||||
return true;
|
||||
} else {
|
||||
debugPrint('❌ Upload failed: ${response.statusCode}');
|
||||
return false;
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
debugPrint('❌ Upload error (DioException): ${e.message}');
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Upload error: $e');
|
||||
return false;
|
||||
} finally {
|
||||
_isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 强制上传全部(app退出前调用)
|
||||
Future<void> forceUploadAll() async {
|
||||
stopPeriodicUpload();
|
||||
|
||||
int retries = 0;
|
||||
while (storage.getQueueSize() > 0 && retries < 3) {
|
||||
final success = await uploadBatch(batchSize: 50);
|
||||
if (!success) {
|
||||
retries++;
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
}
|
||||
|
||||
if (storage.getQueueSize() > 0) {
|
||||
debugPrint('⚠️ ${storage.getQueueSize()} events remaining in queue');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import '../../../../app/router.dart';
|
|||
import '../../../../app/i18n/app_localizations.dart';
|
||||
import '../../../../core/services/auth_service.dart';
|
||||
import '../../../../core/network/api_client.dart';
|
||||
import '../../../../core/telemetry/telemetry_service.dart';
|
||||
|
||||
/// 发行方登录页
|
||||
///
|
||||
|
|
@ -27,6 +28,20 @@ class _IssuerLoginPageState extends State<IssuerLoginPage> {
|
|||
|
||||
final _authService = AuthService();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 遥测服务初始化(在首帧渲染后采集设备信息)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (!TelemetryService().isInitialized) {
|
||||
await TelemetryService().initialize(
|
||||
apiBaseUrl: 'https://api.gogenex.cn',
|
||||
context: context,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
|
|
@ -83,6 +98,11 @@ class _IssuerLoginPageState extends State<IssuerLoginPage> {
|
|||
try {
|
||||
final result = await _authService.loginByPhone(phone, code);
|
||||
ApiClient.instance.setToken(result.accessToken);
|
||||
// 遥测:登录成功后注入 userId 和 token
|
||||
if (TelemetryService().isInitialized) {
|
||||
TelemetryService().setUserId(result.user?['id'] as String?);
|
||||
TelemetryService().setAccessToken(result.accessToken);
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.pushReplacementNamed(context, AppRouter.main);
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import '../../../../core/updater/update_service.dart';
|
|||
import '../../../../core/services/issuer_service.dart';
|
||||
import '../../../../core/services/auth_service.dart';
|
||||
import '../../../../core/network/api_client.dart';
|
||||
import '../../../../core/telemetry/telemetry_service.dart';
|
||||
|
||||
/// 发行方设置页面(我的)
|
||||
///
|
||||
|
|
@ -83,6 +84,11 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
debugPrint('[SettingsPage] logout error: $e');
|
||||
}
|
||||
|
||||
// 遥测:退出登录时清除 userId 和 token
|
||||
if (TelemetryService().isInitialized) {
|
||||
TelemetryService().clearUserId();
|
||||
TelemetryService().clearAccessToken();
|
||||
}
|
||||
ApiClient.instance.setToken(null);
|
||||
|
||||
if (!mounted) return;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ dependencies:
|
|||
sdk: flutter
|
||||
dio: ^5.4.3+1
|
||||
package_info_plus: ^8.0.0
|
||||
uuid: ^4.3.3
|
||||
equatable: ^2.0.5
|
||||
device_info_plus: ^10.1.0
|
||||
shared_preferences: ^2.2.3
|
||||
path_provider: ^2.1.0
|
||||
crypto: ^3.0.3
|
||||
permission_handler: ^11.3.1
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import 'package:dio/dio.dart';
|
|||
import 'package:flutter/foundation.dart';
|
||||
import '../network/api_client.dart';
|
||||
import '../storage/session_storage.dart';
|
||||
import '../telemetry/telemetry_service.dart';
|
||||
|
||||
/// SMS 验证码类型
|
||||
enum SmsCodeType {
|
||||
|
|
@ -512,12 +513,16 @@ class AuthService {
|
|||
Future<void> _setAuth(AuthResult result) async {
|
||||
authState.value = result;
|
||||
_api.setToken(result.accessToken);
|
||||
// 持久化 Token(非阻塞,但 await 保证写入完成再返回,防止进程被杀时丢失)
|
||||
await SessionStorage.instance.save(
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
user: result.user,
|
||||
);
|
||||
// 遥测:登录成功后注入 userId 和 token
|
||||
if (TelemetryService().isInitialized) {
|
||||
TelemetryService().setUserId(result.user['id'] as String?);
|
||||
TelemetryService().setAccessToken(result.accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// 登出/Token 过期时统一调用
|
||||
|
|
@ -529,5 +534,10 @@ class AuthService {
|
|||
authState.value = null;
|
||||
_api.setToken(null);
|
||||
await SessionStorage.instance.clear();
|
||||
// 遥测:退出登录时清除 userId 和 token
|
||||
if (TelemetryService().isInitialized) {
|
||||
TelemetryService().clearUserId();
|
||||
TelemetryService().clearAccessToken();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
import 'dart:io';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/device_context.dart';
|
||||
|
||||
/// 设备信息收集器
|
||||
/// 负责收集完整的设备上下文信息
|
||||
class DeviceInfoCollector {
|
||||
static DeviceInfoCollector? _instance;
|
||||
DeviceInfoCollector._();
|
||||
|
||||
factory DeviceInfoCollector() {
|
||||
_instance ??= DeviceInfoCollector._();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
DeviceContext? _cachedContext;
|
||||
|
||||
/// 收集完整设备上下文(首次会缓存)
|
||||
Future<DeviceContext> collect(BuildContext context) async {
|
||||
if (_cachedContext != null) return _cachedContext!;
|
||||
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
DeviceContext result;
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final androidInfo = await deviceInfo.androidInfo;
|
||||
|
||||
result = DeviceContext(
|
||||
platform: 'android',
|
||||
brand: androidInfo.brand,
|
||||
model: androidInfo.model,
|
||||
manufacturer: androidInfo.manufacturer,
|
||||
isPhysicalDevice: androidInfo.isPhysicalDevice,
|
||||
osVersion: androidInfo.version.release,
|
||||
sdkInt: androidInfo.version.sdkInt,
|
||||
androidId: androidInfo.id, // 匿名ID,不是IMEI
|
||||
screen: _collectScreenInfo(mediaQuery),
|
||||
appName: packageInfo.appName,
|
||||
packageName: packageInfo.packageName,
|
||||
appVersion: packageInfo.version,
|
||||
buildNumber: packageInfo.buildNumber,
|
||||
buildMode: _getBuildMode(),
|
||||
locale: Platform.localeName,
|
||||
timezone: DateTime.now().timeZoneName,
|
||||
isDarkMode: mediaQuery.platformBrightness == Brightness.dark,
|
||||
networkType: 'unknown', // 需要额外的connectivity包
|
||||
collectedAt: DateTime.now(),
|
||||
);
|
||||
} else if (Platform.isIOS) {
|
||||
final iosInfo = await deviceInfo.iosInfo;
|
||||
|
||||
result = DeviceContext(
|
||||
platform: 'ios',
|
||||
brand: 'Apple',
|
||||
model: iosInfo.model,
|
||||
manufacturer: 'Apple',
|
||||
isPhysicalDevice: iosInfo.isPhysicalDevice,
|
||||
osVersion: iosInfo.systemVersion,
|
||||
sdkInt: 0, // iOS没有SDK版本号
|
||||
androidId: iosInfo.identifierForVendor ?? '',
|
||||
screen: _collectScreenInfo(mediaQuery),
|
||||
appName: packageInfo.appName,
|
||||
packageName: packageInfo.packageName,
|
||||
appVersion: packageInfo.version,
|
||||
buildNumber: packageInfo.buildNumber,
|
||||
buildMode: _getBuildMode(),
|
||||
locale: Platform.localeName,
|
||||
timezone: DateTime.now().timeZoneName,
|
||||
isDarkMode: mediaQuery.platformBrightness == Brightness.dark,
|
||||
networkType: 'unknown',
|
||||
collectedAt: DateTime.now(),
|
||||
);
|
||||
} else {
|
||||
throw UnsupportedError('Unsupported platform');
|
||||
}
|
||||
|
||||
_cachedContext = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 收集屏幕信息
|
||||
ScreenInfo _collectScreenInfo(MediaQueryData mediaQuery) {
|
||||
final size = mediaQuery.size;
|
||||
final density = mediaQuery.devicePixelRatio;
|
||||
|
||||
return ScreenInfo(
|
||||
widthPx: size.width * density,
|
||||
heightPx: size.height * density,
|
||||
density: density,
|
||||
widthDp: size.width,
|
||||
heightDp: size.height,
|
||||
hasNotch: mediaQuery.padding.top > 24, // 简单判断刘海屏
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取构建模式
|
||||
String _getBuildMode() {
|
||||
if (kReleaseMode) return 'release';
|
||||
if (kProfileMode) return 'profile';
|
||||
return 'debug';
|
||||
}
|
||||
|
||||
/// 清除缓存(版本更新时调用)
|
||||
void clearCache() {
|
||||
_cachedContext = null;
|
||||
}
|
||||
|
||||
/// 获取缓存的上下文
|
||||
DeviceContext? get cachedContext => _cachedContext;
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// 屏幕信息
|
||||
class ScreenInfo extends Equatable {
|
||||
final double widthPx;
|
||||
final double heightPx;
|
||||
final double density;
|
||||
final double widthDp;
|
||||
final double heightDp;
|
||||
final bool hasNotch;
|
||||
|
||||
const ScreenInfo({
|
||||
required this.widthPx,
|
||||
required this.heightPx,
|
||||
required this.density,
|
||||
required this.widthDp,
|
||||
required this.heightDp,
|
||||
required this.hasNotch,
|
||||
});
|
||||
|
||||
factory ScreenInfo.fromJson(Map<String, dynamic> json) {
|
||||
return ScreenInfo(
|
||||
widthPx: (json['widthPx'] as num).toDouble(),
|
||||
heightPx: (json['heightPx'] as num).toDouble(),
|
||||
density: (json['density'] as num).toDouble(),
|
||||
widthDp: (json['widthDp'] as num).toDouble(),
|
||||
heightDp: (json['heightDp'] as num).toDouble(),
|
||||
hasNotch: json['hasNotch'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'widthPx': widthPx,
|
||||
'heightPx': heightPx,
|
||||
'density': density,
|
||||
'widthDp': widthDp,
|
||||
'heightDp': heightDp,
|
||||
'hasNotch': hasNotch,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
[widthPx, heightPx, density, widthDp, heightDp, hasNotch];
|
||||
}
|
||||
|
||||
/// 设备上下文
|
||||
/// 包含完整的设备信息,用于兼容性分析
|
||||
class DeviceContext extends Equatable {
|
||||
// 设备信息
|
||||
final String platform; // 'android' | 'ios'
|
||||
final String brand; // 'Samsung'
|
||||
final String model; // 'SM-G9980'
|
||||
final String manufacturer; // 'samsung'
|
||||
final bool isPhysicalDevice; // true
|
||||
|
||||
// 系统信息
|
||||
final String osVersion; // '14'
|
||||
final int sdkInt; // 34
|
||||
final String androidId; // 匿名设备ID
|
||||
|
||||
// 屏幕信息
|
||||
final ScreenInfo screen;
|
||||
|
||||
// App信息
|
||||
final String appName;
|
||||
final String packageName;
|
||||
final String appVersion;
|
||||
final String buildNumber;
|
||||
final String buildMode; // 'debug' | 'profile' | 'release'
|
||||
|
||||
// 用户环境
|
||||
final String locale; // 'zh_CN'
|
||||
final String timezone; // 'Asia/Shanghai'
|
||||
final bool isDarkMode;
|
||||
final String networkType; // 'wifi' | 'cellular' | 'none'
|
||||
|
||||
// 时间戳
|
||||
final DateTime collectedAt;
|
||||
|
||||
const DeviceContext({
|
||||
required this.platform,
|
||||
required this.brand,
|
||||
required this.model,
|
||||
required this.manufacturer,
|
||||
required this.isPhysicalDevice,
|
||||
required this.osVersion,
|
||||
required this.sdkInt,
|
||||
required this.androidId,
|
||||
required this.screen,
|
||||
required this.appName,
|
||||
required this.packageName,
|
||||
required this.appVersion,
|
||||
required this.buildNumber,
|
||||
required this.buildMode,
|
||||
required this.locale,
|
||||
required this.timezone,
|
||||
required this.isDarkMode,
|
||||
required this.networkType,
|
||||
required this.collectedAt,
|
||||
});
|
||||
|
||||
factory DeviceContext.fromJson(Map<String, dynamic> json) {
|
||||
return DeviceContext(
|
||||
platform: json['platform'] as String,
|
||||
brand: json['brand'] as String,
|
||||
model: json['model'] as String,
|
||||
manufacturer: json['manufacturer'] as String,
|
||||
isPhysicalDevice: json['isPhysicalDevice'] as bool,
|
||||
osVersion: json['osVersion'] as String,
|
||||
sdkInt: json['sdkInt'] as int,
|
||||
androidId: json['androidId'] as String,
|
||||
screen: ScreenInfo.fromJson(json['screen'] as Map<String, dynamic>),
|
||||
appName: json['appName'] as String,
|
||||
packageName: json['packageName'] as String,
|
||||
appVersion: json['appVersion'] as String,
|
||||
buildNumber: json['buildNumber'] as String,
|
||||
buildMode: json['buildMode'] as String,
|
||||
locale: json['locale'] as String,
|
||||
timezone: json['timezone'] as String,
|
||||
isDarkMode: json['isDarkMode'] as bool,
|
||||
networkType: json['networkType'] as String,
|
||||
collectedAt: DateTime.parse(json['collectedAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'platform': platform,
|
||||
'brand': brand,
|
||||
'model': model,
|
||||
'manufacturer': manufacturer,
|
||||
'isPhysicalDevice': isPhysicalDevice,
|
||||
'osVersion': osVersion,
|
||||
'sdkInt': sdkInt,
|
||||
'androidId': androidId,
|
||||
'screen': screen.toJson(),
|
||||
'appName': appName,
|
||||
'packageName': packageName,
|
||||
'appVersion': appVersion,
|
||||
'buildNumber': buildNumber,
|
||||
'buildMode': buildMode,
|
||||
'locale': locale,
|
||||
'timezone': timezone,
|
||||
'isDarkMode': isDarkMode,
|
||||
'networkType': networkType,
|
||||
'collectedAt': collectedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
platform,
|
||||
brand,
|
||||
model,
|
||||
manufacturer,
|
||||
isPhysicalDevice,
|
||||
osVersion,
|
||||
sdkInt,
|
||||
androidId,
|
||||
screen,
|
||||
appName,
|
||||
packageName,
|
||||
appVersion,
|
||||
buildNumber,
|
||||
buildMode,
|
||||
locale,
|
||||
timezone,
|
||||
isDarkMode,
|
||||
networkType,
|
||||
collectedAt,
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'telemetry_event.dart';
|
||||
import '../presence/presence_config.dart';
|
||||
|
||||
/// 遥测配置
|
||||
/// 支持远程配置和本地缓存
|
||||
class TelemetryConfig {
|
||||
// 全局开关
|
||||
bool globalEnabled = true;
|
||||
|
||||
// 分类型开关
|
||||
bool errorReportEnabled = true; // 错误上报
|
||||
bool performanceEnabled = true; // 性能监控
|
||||
bool userActionEnabled = true; // 用户行为
|
||||
bool pageViewEnabled = true; // 页面访问
|
||||
bool sessionEnabled = true; // 会话事件(DAU相关)
|
||||
|
||||
// 采样配置
|
||||
double samplingRate = 0.1; // 10% 采样率
|
||||
|
||||
// 事件黑名单
|
||||
List<String> disabledEvents = [];
|
||||
|
||||
// 配置版本
|
||||
String configVersion = '1.0.0';
|
||||
|
||||
// 用户是否同意(可选,用于隐私合规)
|
||||
bool userOptIn = true;
|
||||
|
||||
// 心跳/在线状态配置
|
||||
PresenceConfig? presenceConfig;
|
||||
|
||||
static final TelemetryConfig _instance = TelemetryConfig._();
|
||||
TelemetryConfig._();
|
||||
factory TelemetryConfig() => _instance;
|
||||
|
||||
/// 从后端同步配置
|
||||
Future<void> syncFromRemote(String apiBaseUrl) async {
|
||||
try {
|
||||
final dio = Dio(BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 5),
|
||||
));
|
||||
// apiBaseUrl 已经包含 /api/v1,所以这里只需要添加相对路径
|
||||
final response = await dio.get('$apiBaseUrl/telemetry/config');
|
||||
final data = response.data;
|
||||
|
||||
globalEnabled = data['global_enabled'] ?? true;
|
||||
errorReportEnabled = data['error_report_enabled'] ?? true;
|
||||
performanceEnabled = data['performance_enabled'] ?? true;
|
||||
userActionEnabled = data['user_action_enabled'] ?? true;
|
||||
pageViewEnabled = data['page_view_enabled'] ?? true;
|
||||
sessionEnabled = data['session_enabled'] ?? true;
|
||||
samplingRate = (data['sampling_rate'] ?? 0.1).toDouble();
|
||||
disabledEvents = List<String>.from(data['disabled_events'] ?? []);
|
||||
configVersion = data['version'] ?? '1.0.0';
|
||||
|
||||
// 解析心跳配置
|
||||
if (data['presence_config'] != null) {
|
||||
presenceConfig = PresenceConfig.fromJson(data['presence_config']);
|
||||
}
|
||||
|
||||
// 缓存到本地
|
||||
await _saveToLocal();
|
||||
|
||||
debugPrint('📊 Telemetry config synced (v$configVersion)');
|
||||
debugPrint(
|
||||
' Global: $globalEnabled, Sampling: ${(samplingRate * 100).toInt()}%');
|
||||
debugPrint(' Presence: ${presenceConfig?.enabled ?? true}');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Failed to sync telemetry config: $e');
|
||||
// 失败时加载本地缓存
|
||||
await _loadFromLocal();
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存到本地
|
||||
Future<void> _saveToLocal() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('telemetry_global_enabled', globalEnabled);
|
||||
await prefs.setBool('telemetry_error_enabled', errorReportEnabled);
|
||||
await prefs.setBool('telemetry_performance_enabled', performanceEnabled);
|
||||
await prefs.setBool('telemetry_user_action_enabled', userActionEnabled);
|
||||
await prefs.setBool('telemetry_page_view_enabled', pageViewEnabled);
|
||||
await prefs.setBool('telemetry_session_enabled', sessionEnabled);
|
||||
await prefs.setDouble('telemetry_sampling_rate', samplingRate);
|
||||
await prefs.setStringList('telemetry_disabled_events', disabledEvents);
|
||||
await prefs.setString('telemetry_config_version', configVersion);
|
||||
}
|
||||
|
||||
/// 从本地加载
|
||||
Future<void> _loadFromLocal() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
globalEnabled = prefs.getBool('telemetry_global_enabled') ?? true;
|
||||
errorReportEnabled = prefs.getBool('telemetry_error_enabled') ?? true;
|
||||
performanceEnabled =
|
||||
prefs.getBool('telemetry_performance_enabled') ?? true;
|
||||
userActionEnabled = prefs.getBool('telemetry_user_action_enabled') ?? true;
|
||||
pageViewEnabled = prefs.getBool('telemetry_page_view_enabled') ?? true;
|
||||
sessionEnabled = prefs.getBool('telemetry_session_enabled') ?? true;
|
||||
samplingRate = prefs.getDouble('telemetry_sampling_rate') ?? 0.1;
|
||||
disabledEvents = prefs.getStringList('telemetry_disabled_events') ?? [];
|
||||
configVersion = prefs.getString('telemetry_config_version') ?? '1.0.0';
|
||||
}
|
||||
|
||||
/// 判断是否应该记录该事件
|
||||
bool shouldLog(EventType type, String eventName) {
|
||||
// 1. 全局开关
|
||||
if (!globalEnabled) return false;
|
||||
|
||||
// 2. 用户未同意
|
||||
if (!userOptIn) return false;
|
||||
|
||||
// 3. 事件黑名单
|
||||
if (disabledEvents.contains(eventName)) return false;
|
||||
|
||||
// 4. 分类型判断
|
||||
switch (type) {
|
||||
case EventType.error:
|
||||
case EventType.crash:
|
||||
return errorReportEnabled;
|
||||
case EventType.performance:
|
||||
return performanceEnabled;
|
||||
case EventType.userAction:
|
||||
return userActionEnabled;
|
||||
case EventType.pageView:
|
||||
return pageViewEnabled;
|
||||
case EventType.apiCall:
|
||||
return performanceEnabled; // API调用归入性能监控
|
||||
case EventType.session:
|
||||
return sessionEnabled; // 会话事件
|
||||
case EventType.presence:
|
||||
return presenceConfig?.enabled ?? true; // 在线状态
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置用户是否同意
|
||||
Future<void> setUserOptIn(bool optIn) async {
|
||||
userOptIn = optIn;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('telemetry_user_opt_in', optIn);
|
||||
debugPrint('📊 User opt-in: $optIn');
|
||||
}
|
||||
|
||||
/// 加载用户选择
|
||||
Future<void> loadUserOptIn() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
userOptIn = prefs.getBool('telemetry_user_opt_in') ?? true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// 事件级别
|
||||
enum EventLevel {
|
||||
debug,
|
||||
info,
|
||||
warning,
|
||||
error,
|
||||
fatal,
|
||||
}
|
||||
|
||||
/// 事件类型
|
||||
enum EventType {
|
||||
/// 页面访问
|
||||
pageView,
|
||||
|
||||
/// 用户行为
|
||||
userAction,
|
||||
|
||||
/// API请求
|
||||
apiCall,
|
||||
|
||||
/// 性能指标
|
||||
performance,
|
||||
|
||||
/// 错误异常
|
||||
error,
|
||||
|
||||
/// 崩溃
|
||||
crash,
|
||||
|
||||
/// 会话事件 (app_session_start, app_session_end)
|
||||
session,
|
||||
|
||||
/// 在线状态 (心跳相关)
|
||||
presence,
|
||||
}
|
||||
|
||||
/// 遥测事件模型
|
||||
class TelemetryEvent extends Equatable {
|
||||
/// 事件ID (UUID)
|
||||
final String eventId;
|
||||
|
||||
/// 事件类型
|
||||
final EventType type;
|
||||
|
||||
/// 事件级别
|
||||
final EventLevel level;
|
||||
|
||||
/// 事件名称: 'app_session_start', 'open_planting_page'
|
||||
final String name;
|
||||
|
||||
/// 事件参数
|
||||
final Map<String, dynamic>? properties;
|
||||
|
||||
/// 事件时间戳
|
||||
final DateTime timestamp;
|
||||
|
||||
/// 用户ID(登录后设置)
|
||||
final String? userId;
|
||||
|
||||
/// 会话ID
|
||||
final String? sessionId;
|
||||
|
||||
/// 安装ID(设备唯一标识)
|
||||
final String installId;
|
||||
|
||||
/// 关联设备信息ID
|
||||
final String deviceContextId;
|
||||
|
||||
const TelemetryEvent({
|
||||
required this.eventId,
|
||||
required this.type,
|
||||
required this.level,
|
||||
required this.name,
|
||||
this.properties,
|
||||
required this.timestamp,
|
||||
this.userId,
|
||||
this.sessionId,
|
||||
required this.installId,
|
||||
required this.deviceContextId,
|
||||
});
|
||||
|
||||
factory TelemetryEvent.fromJson(Map<String, dynamic> json) {
|
||||
return TelemetryEvent(
|
||||
eventId: json['eventId'] as String,
|
||||
type: EventType.values.firstWhere(
|
||||
(e) => e.name == json['type'],
|
||||
orElse: () => EventType.userAction,
|
||||
),
|
||||
level: EventLevel.values.firstWhere(
|
||||
(e) => e.name == json['level'],
|
||||
orElse: () => EventLevel.info,
|
||||
),
|
||||
name: json['name'] as String,
|
||||
properties: json['properties'] as Map<String, dynamic>?,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
userId: json['userId'] as String?,
|
||||
sessionId: json['sessionId'] as String?,
|
||||
installId: json['installId'] as String,
|
||||
deviceContextId: json['deviceContextId'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
/// 转换为本地存储 JSON 格式
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'eventId': eventId,
|
||||
'type': type.name,
|
||||
'level': level.name,
|
||||
'name': name,
|
||||
'properties': properties,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'userId': userId,
|
||||
'sessionId': sessionId,
|
||||
'installId': installId,
|
||||
'deviceContextId': deviceContextId,
|
||||
};
|
||||
}
|
||||
|
||||
/// 转换为服务端 API 格式(Amplitude 风格:设备字段为顶层独立列,方便服务端索引)
|
||||
/// 顶层字段: eventName, userId, installId, clientTs, deviceBrand, deviceModel,
|
||||
/// deviceOs, appVersion, locale
|
||||
/// properties: 仅保留事件专属数据(页面名、金额等)
|
||||
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 {
|
||||
'eventName': name,
|
||||
'userId': userId,
|
||||
'installId': installId,
|
||||
'clientTs': timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
'deviceBrand': deviceBrand,
|
||||
'deviceModel': deviceModel,
|
||||
'deviceOs': deviceOs,
|
||||
'appVersion': appVersion,
|
||||
'locale': locale,
|
||||
'properties': {
|
||||
...props,
|
||||
'eventId': eventId,
|
||||
'type': type.name,
|
||||
'level': level.name,
|
||||
'sessionId': sessionId,
|
||||
'deviceContextId': deviceContextId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
TelemetryEvent copyWith({
|
||||
String? eventId,
|
||||
EventType? type,
|
||||
EventLevel? level,
|
||||
String? name,
|
||||
Map<String, dynamic>? properties,
|
||||
DateTime? timestamp,
|
||||
String? userId,
|
||||
String? sessionId,
|
||||
String? installId,
|
||||
String? deviceContextId,
|
||||
}) {
|
||||
return TelemetryEvent(
|
||||
eventId: eventId ?? this.eventId,
|
||||
type: type ?? this.type,
|
||||
level: level ?? this.level,
|
||||
name: name ?? this.name,
|
||||
properties: properties ?? this.properties,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
userId: userId ?? this.userId,
|
||||
sessionId: sessionId ?? this.sessionId,
|
||||
installId: installId ?? this.installId,
|
||||
deviceContextId: deviceContextId ?? this.deviceContextId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
eventId,
|
||||
type,
|
||||
level,
|
||||
name,
|
||||
properties,
|
||||
timestamp,
|
||||
userId,
|
||||
sessionId,
|
||||
installId,
|
||||
deviceContextId,
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../session/session_manager.dart';
|
||||
import '../session/session_events.dart';
|
||||
import 'presence_config.dart';
|
||||
|
||||
/// 心跳服务
|
||||
///
|
||||
/// 职责:
|
||||
/// 1. 在 App 前台时定期发送心跳
|
||||
/// 2. 进入后台时停止心跳
|
||||
/// 3. 心跳失败时不立即重试,等待下一个周期
|
||||
class HeartbeatService {
|
||||
static HeartbeatService? _instance;
|
||||
|
||||
HeartbeatService._();
|
||||
|
||||
factory HeartbeatService() {
|
||||
_instance ??= HeartbeatService._();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// 配置
|
||||
PresenceConfig _config = PresenceConfig.defaultConfig;
|
||||
|
||||
/// 心跳定时器
|
||||
Timer? _heartbeatTimer;
|
||||
|
||||
/// 是否正在运行
|
||||
bool _isRunning = false;
|
||||
bool get isRunning => _isRunning;
|
||||
|
||||
/// 最后一次心跳时间
|
||||
DateTime? _lastHeartbeatAt;
|
||||
DateTime? get lastHeartbeatAt => _lastHeartbeatAt;
|
||||
|
||||
/// 心跳计数(调试用)
|
||||
int _heartbeatCount = 0;
|
||||
int get heartbeatCount => _heartbeatCount;
|
||||
|
||||
/// API 基础地址
|
||||
String? _apiBaseUrl;
|
||||
|
||||
/// 获取 installId 的回调
|
||||
String Function()? getInstallId;
|
||||
|
||||
/// 获取 userId 的回调
|
||||
String? Function()? getUserId;
|
||||
|
||||
/// 获取 appVersion 的回调
|
||||
String Function()? getAppVersion;
|
||||
|
||||
/// 获取认证头的回调
|
||||
Map<String, String> Function()? getAuthHeaders;
|
||||
|
||||
late Dio _dio;
|
||||
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// 初始化
|
||||
void initialize({
|
||||
required String apiBaseUrl,
|
||||
PresenceConfig? config,
|
||||
required String Function() getInstallId,
|
||||
required String? Function() getUserId,
|
||||
required String Function() getAppVersion,
|
||||
Map<String, String> Function()? getAuthHeaders,
|
||||
}) {
|
||||
if (_isInitialized) return;
|
||||
|
||||
_apiBaseUrl = apiBaseUrl;
|
||||
_config = config ?? PresenceConfig.defaultConfig;
|
||||
this.getInstallId = getInstallId;
|
||||
this.getUserId = getUserId;
|
||||
this.getAppVersion = getAppVersion;
|
||||
this.getAuthHeaders = getAuthHeaders;
|
||||
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 5),
|
||||
));
|
||||
|
||||
// 监听会话状态变化
|
||||
final sessionManager = SessionManager();
|
||||
sessionManager.onSessionStart = _onSessionStart;
|
||||
sessionManager.onSessionEnd = _onSessionEnd;
|
||||
|
||||
// 如果当前已经在前台,立即启动心跳
|
||||
if (sessionManager.state == SessionState.foreground) {
|
||||
_startHeartbeat();
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint(
|
||||
'💓 [Heartbeat] Initialized, interval: ${_config.heartbeatIntervalSeconds}s');
|
||||
}
|
||||
|
||||
/// 更新配置(支持远程配置热更新)
|
||||
void updateConfig(PresenceConfig config) {
|
||||
final wasRunning = _isRunning;
|
||||
|
||||
if (wasRunning) {
|
||||
_stopHeartbeat();
|
||||
}
|
||||
|
||||
_config = config;
|
||||
|
||||
if (wasRunning && _config.enabled) {
|
||||
_startHeartbeat();
|
||||
}
|
||||
|
||||
debugPrint('💓 [Heartbeat] Config updated');
|
||||
}
|
||||
|
||||
/// 销毁
|
||||
void dispose() {
|
||||
_stopHeartbeat();
|
||||
_isInitialized = false;
|
||||
_instance = null;
|
||||
debugPrint('💓 [Heartbeat] Disposed');
|
||||
}
|
||||
|
||||
/// 会话开始回调
|
||||
void _onSessionStart() {
|
||||
if (_config.enabled) {
|
||||
_startHeartbeat();
|
||||
}
|
||||
}
|
||||
|
||||
/// 会话结束回调
|
||||
void _onSessionEnd() {
|
||||
_stopHeartbeat();
|
||||
}
|
||||
|
||||
/// 启动心跳
|
||||
void _startHeartbeat() {
|
||||
if (_isRunning) return;
|
||||
if (!_config.enabled) return;
|
||||
|
||||
_isRunning = true;
|
||||
_heartbeatCount = 0;
|
||||
|
||||
// 立即发送第一次心跳
|
||||
_sendHeartbeat();
|
||||
|
||||
// 启动定时器
|
||||
_heartbeatTimer = Timer.periodic(
|
||||
Duration(seconds: _config.heartbeatIntervalSeconds),
|
||||
(_) => _sendHeartbeat(),
|
||||
);
|
||||
|
||||
debugPrint('💓 [Heartbeat] Started');
|
||||
}
|
||||
|
||||
/// 停止心跳
|
||||
void _stopHeartbeat() {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
_isRunning = false;
|
||||
|
||||
debugPrint('💓 [Heartbeat] Stopped (count: $_heartbeatCount)');
|
||||
}
|
||||
|
||||
/// 发送心跳
|
||||
Future<void> _sendHeartbeat() async {
|
||||
// 检查是否需要登录
|
||||
if (_config.requiresAuth && (getUserId?.call() == null)) {
|
||||
debugPrint('💓 [Heartbeat] Skipped: user not logged in');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'/api/v1/telemetry/heartbeat',
|
||||
data: {
|
||||
'installId': getInstallId?.call() ?? '',
|
||||
'appVersion': getAppVersion?.call() ?? '',
|
||||
'clientTs': DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
},
|
||||
options: Options(
|
||||
headers: getAuthHeaders?.call(),
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
_lastHeartbeatAt = DateTime.now();
|
||||
_heartbeatCount++;
|
||||
debugPrint('💓 [Heartbeat] Sent #$_heartbeatCount');
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
// 心跳失败不重试,等待下一个周期
|
||||
debugPrint('💓 [Heartbeat] Failed (DioException): ${e.message}');
|
||||
} catch (e) {
|
||||
// 心跳失败不重试,等待下一个周期
|
||||
debugPrint('💓 [Heartbeat] Failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 手动触发心跳(用于测试)
|
||||
@visibleForTesting
|
||||
Future<void> forceHeartbeat() async {
|
||||
await _sendHeartbeat();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/// 心跳配置
|
||||
class PresenceConfig {
|
||||
/// 心跳间隔(秒)
|
||||
/// 默认 60 秒,与后端 3 分钟窗口配合
|
||||
final int heartbeatIntervalSeconds;
|
||||
|
||||
/// 是否仅登录用户发送心跳
|
||||
/// 默认 true,未登录用户不参与在线统计
|
||||
final bool requiresAuth;
|
||||
|
||||
/// 是否启用心跳
|
||||
final bool enabled;
|
||||
|
||||
const PresenceConfig({
|
||||
this.heartbeatIntervalSeconds = 60,
|
||||
this.requiresAuth = true,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
/// 默认配置
|
||||
static const PresenceConfig defaultConfig = PresenceConfig();
|
||||
|
||||
/// 从远程配置解析
|
||||
factory PresenceConfig.fromJson(Map<String, dynamic> json) {
|
||||
return PresenceConfig(
|
||||
heartbeatIntervalSeconds: json['heartbeat_interval_seconds'] ?? 60,
|
||||
requiresAuth: json['requires_auth'] ?? true,
|
||||
enabled: json['presence_enabled'] ?? json['enabled'] ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'heartbeat_interval_seconds': heartbeatIntervalSeconds,
|
||||
'requires_auth': requiresAuth,
|
||||
'presence_enabled': enabled,
|
||||
};
|
||||
}
|
||||
|
||||
PresenceConfig copyWith({
|
||||
int? heartbeatIntervalSeconds,
|
||||
bool? requiresAuth,
|
||||
bool? enabled,
|
||||
}) {
|
||||
return PresenceConfig(
|
||||
heartbeatIntervalSeconds:
|
||||
heartbeatIntervalSeconds ?? this.heartbeatIntervalSeconds,
|
||||
requiresAuth: requiresAuth ?? this.requiresAuth,
|
||||
enabled: enabled ?? this.enabled,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
/// 会话相关的事件名常量
|
||||
class SessionEvents {
|
||||
/// App 会话开始(用于 DAU 统计)
|
||||
/// 触发时机:App 从后台切到前台,或冷启动
|
||||
static const String sessionStart = 'app_session_start';
|
||||
|
||||
/// App 会话结束
|
||||
/// 触发时机:App 进入后台
|
||||
static const String sessionEnd = 'app_session_end';
|
||||
|
||||
/// 心跳事件(用于在线统计)
|
||||
/// 触发时机:前台状态下每 60 秒
|
||||
static const String heartbeat = 'presence_heartbeat';
|
||||
|
||||
/// 私有构造函数,防止实例化
|
||||
SessionEvents._();
|
||||
}
|
||||
|
||||
/// 会话状态
|
||||
enum SessionState {
|
||||
/// 前台活跃
|
||||
foreground,
|
||||
|
||||
/// 后台
|
||||
background,
|
||||
|
||||
/// 未知(初始状态)
|
||||
unknown,
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../telemetry_service.dart';
|
||||
import '../models/telemetry_event.dart';
|
||||
import 'session_events.dart';
|
||||
|
||||
/// 会话管理器
|
||||
///
|
||||
/// 职责:
|
||||
/// 1. 监听 App 生命周期,触发 app_session_start/app_session_end 事件
|
||||
/// 2. 管理 sessionId(每次前台生成新的)
|
||||
/// 3. 与 HeartbeatService 联动(前台启动心跳,后台停止)
|
||||
class SessionManager with WidgetsBindingObserver {
|
||||
static SessionManager? _instance;
|
||||
|
||||
SessionManager._();
|
||||
|
||||
factory SessionManager() {
|
||||
_instance ??= SessionManager._();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// 当前会话 ID
|
||||
String? _currentSessionId;
|
||||
String? get currentSessionId => _currentSessionId;
|
||||
|
||||
/// 当前会话状态
|
||||
SessionState _state = SessionState.unknown;
|
||||
SessionState get state => _state;
|
||||
|
||||
/// 会话开始时间
|
||||
DateTime? _sessionStartTime;
|
||||
|
||||
/// 回调:会话开始(HeartbeatService 监听此回调)
|
||||
VoidCallback? onSessionStart;
|
||||
|
||||
/// 回调:会话结束(HeartbeatService 监听此回调)
|
||||
VoidCallback? onSessionEnd;
|
||||
|
||||
/// TelemetryService 引用
|
||||
TelemetryService? _telemetryService;
|
||||
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// 初始化
|
||||
void initialize(TelemetryService telemetryService) {
|
||||
if (_isInitialized) return;
|
||||
|
||||
_telemetryService = telemetryService;
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
// 首次启动视为进入前台
|
||||
_handleForeground();
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('📱 [Session] Manager initialized');
|
||||
}
|
||||
|
||||
/// 销毁
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_isInitialized = false;
|
||||
_instance = null;
|
||||
debugPrint('📱 [Session] Manager disposed');
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
switch (state) {
|
||||
case AppLifecycleState.resumed:
|
||||
_handleForeground();
|
||||
break;
|
||||
case AppLifecycleState.paused:
|
||||
_handleBackground();
|
||||
break;
|
||||
case AppLifecycleState.inactive:
|
||||
case AppLifecycleState.detached:
|
||||
case AppLifecycleState.hidden:
|
||||
// 不处理这些中间状态
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理进入前台
|
||||
void _handleForeground() {
|
||||
if (_state == SessionState.foreground) return;
|
||||
|
||||
_state = SessionState.foreground;
|
||||
_startNewSession();
|
||||
|
||||
// 上传上次被强杀遗留的事件 + 本次 session_start(含设备信息)
|
||||
// Amplitude/Mixpanel 标准做法:回到前台即是下一次上传机会
|
||||
_telemetryService?.flushOnBackground();
|
||||
}
|
||||
|
||||
/// 处理进入后台
|
||||
void _handleBackground() {
|
||||
if (_state == SessionState.background) return;
|
||||
|
||||
_state = SessionState.background;
|
||||
_endCurrentSession();
|
||||
|
||||
// session_end 写入队列后立即上传,不等待10条阈值
|
||||
_telemetryService?.flushOnBackground();
|
||||
}
|
||||
|
||||
/// 开始新会话
|
||||
void _startNewSession() {
|
||||
// 生成新的 sessionId
|
||||
_currentSessionId = const Uuid().v4();
|
||||
_sessionStartTime = DateTime.now();
|
||||
|
||||
// 记录 app_session_start 事件(用于 DAU)
|
||||
_telemetryService?.logEvent(
|
||||
SessionEvents.sessionStart,
|
||||
type: EventType.session,
|
||||
level: EventLevel.info,
|
||||
properties: {
|
||||
'session_id': _currentSessionId,
|
||||
},
|
||||
);
|
||||
|
||||
// 通知外部(HeartbeatService 会监听这个回调)
|
||||
onSessionStart?.call();
|
||||
|
||||
debugPrint('📱 [Session] Started: $_currentSessionId');
|
||||
}
|
||||
|
||||
/// 结束当前会话
|
||||
void _endCurrentSession() {
|
||||
if (_currentSessionId == null) return;
|
||||
|
||||
final duration = _sessionStartTime != null
|
||||
? DateTime.now().difference(_sessionStartTime!).inSeconds
|
||||
: 0;
|
||||
|
||||
// 记录 app_session_end 事件
|
||||
_telemetryService?.logEvent(
|
||||
SessionEvents.sessionEnd,
|
||||
type: EventType.session,
|
||||
level: EventLevel.info,
|
||||
properties: {
|
||||
'session_id': _currentSessionId,
|
||||
'duration_seconds': duration,
|
||||
},
|
||||
);
|
||||
|
||||
// 通知外部(HeartbeatService 会监听这个回调)
|
||||
onSessionEnd?.call();
|
||||
|
||||
debugPrint('📱 [Session] Ended: $_currentSessionId (${duration}s)');
|
||||
|
||||
_currentSessionId = null;
|
||||
_sessionStartTime = null;
|
||||
}
|
||||
|
||||
/// 获取当前会话时长(秒)
|
||||
int get sessionDurationSeconds {
|
||||
if (_sessionStartTime == null) return 0;
|
||||
return DateTime.now().difference(_sessionStartTime!).inSeconds;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/telemetry_event.dart';
|
||||
|
||||
/// 遥测本地存储
|
||||
/// 负责缓存事件队列和设备上下文
|
||||
class TelemetryStorage {
|
||||
static const String _keyEventQueue = 'telemetry_event_queue';
|
||||
static const String _keyDeviceContext = 'telemetry_device_context';
|
||||
static const String _keyInstallId = 'telemetry_install_id';
|
||||
static const int _maxQueueSize = 500; // 最多缓存500条
|
||||
|
||||
late SharedPreferences _prefs;
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// 初始化
|
||||
Future<void> init() async {
|
||||
if (_isInitialized) return;
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
/// 保存设备上下文
|
||||
Future<void> saveDeviceContext(Map<String, dynamic> context) async {
|
||||
await _prefs.setString(_keyDeviceContext, jsonEncode(context));
|
||||
}
|
||||
|
||||
/// 读取设备上下文
|
||||
Map<String, dynamic>? getDeviceContext() {
|
||||
final str = _prefs.getString(_keyDeviceContext);
|
||||
if (str == null) return null;
|
||||
return jsonDecode(str) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// 保存 InstallId
|
||||
Future<void> saveInstallId(String installId) async {
|
||||
await _prefs.setString(_keyInstallId, installId);
|
||||
}
|
||||
|
||||
/// 读取 InstallId
|
||||
String? getInstallId() {
|
||||
return _prefs.getString(_keyInstallId);
|
||||
}
|
||||
|
||||
/// 添加事件到队列
|
||||
Future<void> enqueueEvent(TelemetryEvent event) async {
|
||||
final queue = _getEventQueue();
|
||||
|
||||
// 防止队列过大
|
||||
if (queue.length >= _maxQueueSize) {
|
||||
queue.removeAt(0); // 移除最旧的
|
||||
debugPrint('⚠️ Telemetry queue full, removed oldest event');
|
||||
}
|
||||
|
||||
queue.add(event.toJson());
|
||||
await _saveEventQueue(queue);
|
||||
}
|
||||
|
||||
/// 批量添加事件
|
||||
Future<void> enqueueEvents(List<TelemetryEvent> events) async {
|
||||
final queue = _getEventQueue();
|
||||
|
||||
for (var event in events) {
|
||||
if (queue.length >= _maxQueueSize) break;
|
||||
queue.add(event.toJson());
|
||||
}
|
||||
|
||||
await _saveEventQueue(queue);
|
||||
}
|
||||
|
||||
/// 获取待上传的事件(最多N条)
|
||||
List<TelemetryEvent> dequeueEvents(int limit) {
|
||||
final queue = _getEventQueue();
|
||||
final count = queue.length > limit ? limit : queue.length;
|
||||
|
||||
final events = queue
|
||||
.take(count)
|
||||
.map((json) => TelemetryEvent.fromJson(json))
|
||||
.toList();
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/// 删除已上传的事件
|
||||
Future<void> removeEvents(int count) async {
|
||||
final queue = _getEventQueue();
|
||||
if (count >= queue.length) {
|
||||
await clearEventQueue();
|
||||
} else {
|
||||
queue.removeRange(0, count);
|
||||
await _saveEventQueue(queue);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取队列长度
|
||||
int getQueueSize() {
|
||||
return _getEventQueue().length;
|
||||
}
|
||||
|
||||
/// 清空事件队列
|
||||
Future<void> clearEventQueue() async {
|
||||
await _prefs.remove(_keyEventQueue);
|
||||
}
|
||||
|
||||
/// 清空所有遥测数据(退出登录时调用)
|
||||
/// 保留 installId 和 deviceContext(设备级别数据)
|
||||
Future<void> clearUserData() async {
|
||||
if (!_isInitialized) {
|
||||
await init();
|
||||
}
|
||||
await _prefs.remove(_keyEventQueue);
|
||||
debugPrint('📊 TelemetryStorage: 已清除用户相关遥测数据');
|
||||
}
|
||||
|
||||
// 私有方法
|
||||
List<Map<String, dynamic>> _getEventQueue() {
|
||||
final str = _prefs.getString(_keyEventQueue);
|
||||
if (str == null) return [];
|
||||
|
||||
try {
|
||||
final List<dynamic> list = jsonDecode(str);
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Failed to parse event queue: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveEventQueue(List<Map<String, dynamic>> queue) async {
|
||||
await _prefs.setString(_keyEventQueue, jsonEncode(queue));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,396 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'models/telemetry_event.dart';
|
||||
import 'models/device_context.dart';
|
||||
import 'models/telemetry_config.dart';
|
||||
import 'collectors/device_info_collector.dart';
|
||||
import 'storage/telemetry_storage.dart';
|
||||
import 'uploader/telemetry_uploader.dart';
|
||||
import 'session/session_manager.dart';
|
||||
import 'presence/heartbeat_service.dart';
|
||||
import 'presence/presence_config.dart';
|
||||
|
||||
/// 遥测服务
|
||||
/// 统一管理设备信息收集、事件记录、上传等功能
|
||||
class TelemetryService {
|
||||
static TelemetryService? _instance;
|
||||
TelemetryService._();
|
||||
|
||||
factory TelemetryService() {
|
||||
_instance ??= TelemetryService._();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
final _storage = TelemetryStorage();
|
||||
late TelemetryUploader _uploader;
|
||||
|
||||
DeviceContext? _deviceContext;
|
||||
|
||||
/// 安装ID(设备唯一标识,首次安装生成,用于未登录用户的DAU去重)
|
||||
late String _installId;
|
||||
String get installId => _installId;
|
||||
|
||||
/// 用户ID(登录后设置)
|
||||
String? _userId;
|
||||
String? get userId => _userId;
|
||||
|
||||
/// 访问令牌(登录后缓存,用于心跳/上传认证头)
|
||||
String? _accessToken;
|
||||
|
||||
/// API 基础地址
|
||||
late String _apiBaseUrl;
|
||||
|
||||
bool _isInitialized = false;
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
/// 会话管理器
|
||||
late SessionManager _sessionManager;
|
||||
|
||||
/// 心跳服务
|
||||
late HeartbeatService _heartbeatService;
|
||||
|
||||
Timer? _configSyncTimer;
|
||||
|
||||
/// 初始化(在main.dart中调用)
|
||||
Future<void> initialize({
|
||||
required String apiBaseUrl,
|
||||
required BuildContext context,
|
||||
String? userId,
|
||||
Duration configSyncInterval = const Duration(hours: 1),
|
||||
PresenceConfig? presenceConfig,
|
||||
}) async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
_apiBaseUrl = apiBaseUrl;
|
||||
|
||||
// 1. 初始化存储
|
||||
await _storage.init();
|
||||
|
||||
// 2. 初始化或获取 installId
|
||||
await _initInstallId();
|
||||
|
||||
// 3. 加载用户选择
|
||||
await TelemetryConfig().loadUserOptIn();
|
||||
|
||||
// 4. 同步远程配置(非阻塞,后台执行)
|
||||
// 不阻塞启动流程,使用本地缓存的配置先启动
|
||||
TelemetryConfig().syncFromRemote(apiBaseUrl).catchError((e) {
|
||||
debugPrint('📊 [Telemetry] Remote config sync failed (non-blocking): $e');
|
||||
});
|
||||
|
||||
// 5. 收集设备信息
|
||||
_deviceContext = await DeviceInfoCollector().collect(context);
|
||||
await _storage.saveDeviceContext(_deviceContext!.toJson());
|
||||
|
||||
// 6. 设置用户ID
|
||||
_userId = userId;
|
||||
|
||||
// 7. 初始化上传器
|
||||
_uploader = TelemetryUploader(
|
||||
apiBaseUrl: apiBaseUrl,
|
||||
storage: _storage,
|
||||
getAuthHeaders: _getAuthHeaders,
|
||||
);
|
||||
|
||||
// 8. 启动定时上传(如果启用)
|
||||
if (TelemetryConfig().globalEnabled) {
|
||||
_uploader.startPeriodicUpload();
|
||||
}
|
||||
|
||||
// 9. 定期同步配置
|
||||
_configSyncTimer = Timer.periodic(configSyncInterval, (_) async {
|
||||
await TelemetryConfig().syncFromRemote(apiBaseUrl);
|
||||
|
||||
// 根据最新配置调整上传器状态
|
||||
if (TelemetryConfig().globalEnabled) {
|
||||
_uploader.startPeriodicUpload();
|
||||
} else {
|
||||
_uploader.stopPeriodicUpload();
|
||||
}
|
||||
|
||||
// 更新心跳配置
|
||||
if (TelemetryConfig().presenceConfig != null) {
|
||||
_heartbeatService.updateConfig(TelemetryConfig().presenceConfig!);
|
||||
}
|
||||
});
|
||||
|
||||
// 10. 初始化会话管理器
|
||||
_sessionManager = SessionManager();
|
||||
_sessionManager.initialize(this);
|
||||
|
||||
// 11. 初始化心跳服务
|
||||
_heartbeatService = HeartbeatService();
|
||||
_heartbeatService.initialize(
|
||||
apiBaseUrl: apiBaseUrl,
|
||||
config: presenceConfig ?? TelemetryConfig().presenceConfig,
|
||||
getInstallId: () => _installId,
|
||||
getUserId: () => _userId,
|
||||
getAppVersion: () => _deviceContext?.appVersion ?? 'unknown',
|
||||
getAuthHeaders: _getAuthHeaders,
|
||||
);
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('📊 TelemetryService initialized');
|
||||
debugPrint(' InstallId: $_installId');
|
||||
debugPrint(' UserId: $_userId');
|
||||
}
|
||||
|
||||
/// 初始化 installId
|
||||
Future<void> _initInstallId() async {
|
||||
final storedId = _storage.getInstallId();
|
||||
|
||||
if (storedId != null) {
|
||||
_installId = storedId;
|
||||
} else {
|
||||
_installId = const Uuid().v4();
|
||||
await _storage.saveInstallId(_installId);
|
||||
}
|
||||
|
||||
debugPrint('📊 [Telemetry] Install ID: $_installId');
|
||||
}
|
||||
|
||||
/// 获取认证头(使用缓存的 access token,由 AccountService 登录时注入)
|
||||
Map<String, String> _getAuthHeaders() {
|
||||
if (_accessToken != null) {
|
||||
return {'Authorization': 'Bearer $_accessToken'};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/// 记录事件(核心方法)
|
||||
void logEvent(
|
||||
String eventName, {
|
||||
EventType type = EventType.userAction,
|
||||
EventLevel level = EventLevel.info,
|
||||
Map<String, dynamic>? properties,
|
||||
}) {
|
||||
if (!_isInitialized) {
|
||||
debugPrint('⚠️ TelemetryService not initialized, event ignored');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查配置:是否应该记录
|
||||
if (!TelemetryConfig().shouldLog(type, eventName)) {
|
||||
return; // 配置禁止记录
|
||||
}
|
||||
|
||||
// 采样判断(错误和崩溃不采样)
|
||||
if (_needsSampling(type)) {
|
||||
if (Random().nextDouble() > TelemetryConfig().samplingRate) {
|
||||
return; // 未被采样
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
eventId: const Uuid().v4(),
|
||||
type: type,
|
||||
level: level,
|
||||
name: eventName,
|
||||
properties: {...deviceProps, ...?properties},
|
||||
timestamp: DateTime.now(),
|
||||
userId: _userId,
|
||||
sessionId: _sessionManager.currentSessionId,
|
||||
installId: _installId,
|
||||
deviceContextId: _deviceContext?.androidId ?? '',
|
||||
);
|
||||
|
||||
_storage.enqueueEvent(event);
|
||||
|
||||
// 检查是否需要立即上传
|
||||
_uploader.uploadIfNeeded();
|
||||
}
|
||||
|
||||
/// 判断是否需要采样
|
||||
bool _needsSampling(EventType type) {
|
||||
// 错误、崩溃、会话事件 100% 上报,不采样
|
||||
return type != EventType.error &&
|
||||
type != EventType.crash &&
|
||||
type != EventType.session;
|
||||
}
|
||||
|
||||
/// 记录页面访问
|
||||
void logPageView(String pageName, {Map<String, dynamic>? extra}) {
|
||||
logEvent(
|
||||
'page_view',
|
||||
type: EventType.pageView,
|
||||
properties: {'page': pageName, ...?extra},
|
||||
);
|
||||
}
|
||||
|
||||
/// 记录用户行为
|
||||
void logUserAction(String action, {Map<String, dynamic>? properties}) {
|
||||
logEvent(
|
||||
action,
|
||||
type: EventType.userAction,
|
||||
properties: properties,
|
||||
);
|
||||
}
|
||||
|
||||
/// 记录错误
|
||||
void logError(
|
||||
String errorMessage, {
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
Map<String, dynamic>? extra,
|
||||
}) {
|
||||
logEvent(
|
||||
'error_occurred',
|
||||
type: EventType.error,
|
||||
level: EventLevel.error,
|
||||
properties: {
|
||||
'message': errorMessage,
|
||||
'error': error?.toString(),
|
||||
'stack_trace': stackTrace?.toString(),
|
||||
...?extra,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 记录API调用
|
||||
void logApiCall({
|
||||
required String url,
|
||||
required String method,
|
||||
required int statusCode,
|
||||
required int durationMs,
|
||||
String? error,
|
||||
}) {
|
||||
logEvent(
|
||||
'api_call',
|
||||
type: EventType.apiCall,
|
||||
level: error != null ? EventLevel.error : EventLevel.info,
|
||||
properties: {
|
||||
'url': url,
|
||||
'method': method,
|
||||
'status_code': statusCode,
|
||||
'duration_ms': durationMs,
|
||||
'error': error,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 记录性能指标
|
||||
void logPerformance(
|
||||
String metricName, {
|
||||
required int durationMs,
|
||||
Map<String, dynamic>? extra,
|
||||
}) {
|
||||
logEvent(
|
||||
metricName,
|
||||
type: EventType.performance,
|
||||
properties: {'duration_ms': durationMs, ...?extra},
|
||||
);
|
||||
}
|
||||
|
||||
/// 设置用户ID(登录后调用)
|
||||
void setUserId(String? userId) {
|
||||
_userId = userId;
|
||||
debugPrint('📊 [Telemetry] User ID set: $userId');
|
||||
}
|
||||
|
||||
/// 清除用户ID(退出登录时)
|
||||
void clearUserId() {
|
||||
_userId = null;
|
||||
debugPrint('📊 [Telemetry] User ID cleared');
|
||||
}
|
||||
|
||||
/// 设置访问令牌(登录/切换账号后调用,用于心跳认证)
|
||||
void setAccessToken(String? token) {
|
||||
_accessToken = token;
|
||||
debugPrint('📊 [Telemetry] Access token ${token != null ? 'set' : 'cleared'}');
|
||||
}
|
||||
|
||||
/// 清除访问令牌(退出登录时调用)
|
||||
void clearAccessToken() {
|
||||
_accessToken = null;
|
||||
}
|
||||
|
||||
/// 暂停遥测上传(退出登录时调用)
|
||||
/// 停止定期上传任务,清空事件队列
|
||||
Future<void> pauseForLogout() async {
|
||||
_uploader.stopPeriodicUpload();
|
||||
await _storage.clearEventQueue();
|
||||
_userId = null;
|
||||
debugPrint('📊 [Telemetry] Paused for logout');
|
||||
}
|
||||
|
||||
/// 恢复遥测上传(登录后调用)
|
||||
void resumeAfterLogin() {
|
||||
if (TelemetryConfig().globalEnabled) {
|
||||
_uploader.startPeriodicUpload();
|
||||
debugPrint('📊 [Telemetry] Resumed after login');
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置用户是否同意数据收集
|
||||
Future<void> setUserOptIn(bool optIn) async {
|
||||
await TelemetryConfig().setUserOptIn(optIn);
|
||||
|
||||
if (!optIn) {
|
||||
// 用户拒绝,停止上传并清空队列
|
||||
_uploader.stopPeriodicUpload();
|
||||
await _storage.clearEventQueue();
|
||||
debugPrint('📊 Telemetry disabled by user');
|
||||
} else {
|
||||
// 用户同意,重新启动
|
||||
if (TelemetryConfig().globalEnabled) {
|
||||
_uploader.startPeriodicUpload();
|
||||
}
|
||||
debugPrint('📊 Telemetry enabled by user');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 会话和在线状态相关方法 ==========
|
||||
|
||||
/// 获取当前会话 ID
|
||||
String? get currentSessionId => _sessionManager.currentSessionId;
|
||||
|
||||
/// 获取会话时长(秒)
|
||||
int get sessionDurationSeconds => _sessionManager.sessionDurationSeconds;
|
||||
|
||||
/// 心跳是否运行中
|
||||
bool get isHeartbeatRunning => _heartbeatService.isRunning;
|
||||
|
||||
/// 心跳计数
|
||||
int get heartbeatCount => _heartbeatService.heartbeatCount;
|
||||
|
||||
/// 更新心跳配置
|
||||
void updatePresenceConfig(PresenceConfig config) {
|
||||
_heartbeatService.updateConfig(config);
|
||||
}
|
||||
|
||||
/// 获取设备上下文
|
||||
DeviceContext? get deviceContext => _deviceContext;
|
||||
|
||||
/// App进入后台时调用:忽略10条阈值,立即上传队列中的事件
|
||||
Future<void> flushOnBackground() async {
|
||||
await _uploader.uploadBatch(batchSize: 50);
|
||||
debugPrint('📊 [Telemetry] Flushed on background');
|
||||
}
|
||||
|
||||
/// App退出前调用
|
||||
Future<void> dispose() async {
|
||||
_configSyncTimer?.cancel();
|
||||
_sessionManager.dispose();
|
||||
_heartbeatService.dispose();
|
||||
await _uploader.forceUploadAll();
|
||||
_isInitialized = false;
|
||||
debugPrint('📊 TelemetryService disposed');
|
||||
}
|
||||
|
||||
/// 重置实例
|
||||
static void reset() {
|
||||
_instance = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import 'dart:async';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../storage/telemetry_storage.dart';
|
||||
|
||||
/// 遥测上传器
|
||||
/// 负责批量上传事件到服务器
|
||||
class TelemetryUploader {
|
||||
final String apiBaseUrl;
|
||||
final TelemetryStorage storage;
|
||||
final Dio _dio;
|
||||
|
||||
Timer? _uploadTimer;
|
||||
bool _isUploading = false;
|
||||
|
||||
/// 获取认证头的回调
|
||||
Map<String, String> Function()? getAuthHeaders;
|
||||
|
||||
TelemetryUploader({
|
||||
required this.apiBaseUrl,
|
||||
required this.storage,
|
||||
this.getAuthHeaders,
|
||||
}) : _dio = Dio(BaseOptions(
|
||||
baseUrl: apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
));
|
||||
|
||||
/// 启动定时上传(每30秒或累积20条)
|
||||
void startPeriodicUpload({
|
||||
Duration interval = const Duration(seconds: 30),
|
||||
int batchSize = 20,
|
||||
}) {
|
||||
_uploadTimer?.cancel();
|
||||
_uploadTimer = Timer.periodic(interval, (_) {
|
||||
uploadIfNeeded(batchSize: batchSize);
|
||||
});
|
||||
debugPrint('📊 Telemetry uploader started (interval: ${interval.inSeconds}s)');
|
||||
}
|
||||
|
||||
/// 停止定时上传
|
||||
void stopPeriodicUpload() {
|
||||
_uploadTimer?.cancel();
|
||||
_uploadTimer = null;
|
||||
debugPrint('📊 Telemetry uploader stopped');
|
||||
}
|
||||
|
||||
/// 条件上传(队列大于阈值才上传)
|
||||
Future<void> uploadIfNeeded({int batchSize = 20}) async {
|
||||
if (_isUploading) return;
|
||||
|
||||
final queueSize = storage.getQueueSize();
|
||||
if (queueSize < 10) return; // 少于10条不上传,等待积累
|
||||
|
||||
await uploadBatch(batchSize: batchSize);
|
||||
}
|
||||
|
||||
/// 立即上传一批
|
||||
Future<bool> uploadBatch({int batchSize = 20}) async {
|
||||
if (_isUploading) return false;
|
||||
|
||||
_isUploading = true;
|
||||
try {
|
||||
final events = storage.dequeueEvents(batchSize);
|
||||
if (events.isEmpty) return true;
|
||||
|
||||
// 调用后端API (使用 toServerJson 格式化为服务端期望的格式)
|
||||
final response = await _dio.post(
|
||||
'/api/v1/telemetry/events',
|
||||
data: {
|
||||
'events': events.map((e) => e.toServerJson()).toList(),
|
||||
},
|
||||
options: Options(
|
||||
headers: getAuthHeaders?.call(),
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// 上传成功,删除本地记录
|
||||
await storage.removeEvents(events.length);
|
||||
debugPrint('✅ Uploaded ${events.length} telemetry events');
|
||||
return true;
|
||||
} else {
|
||||
debugPrint('❌ Upload failed: ${response.statusCode}');
|
||||
return false;
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
debugPrint('❌ Upload error (DioException): ${e.message}');
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Upload error: $e');
|
||||
return false;
|
||||
} finally {
|
||||
_isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 强制上传全部(app退出前调用)
|
||||
Future<void> forceUploadAll() async {
|
||||
stopPeriodicUpload();
|
||||
|
||||
int retries = 0;
|
||||
while (storage.getQueueSize() > 0 && retries < 3) {
|
||||
final success = await uploadBatch(batchSize: 50);
|
||||
if (!success) {
|
||||
retries++;
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
}
|
||||
|
||||
if (storage.getQueueSize() > 0) {
|
||||
debugPrint('⚠️ ${storage.getQueueSize()} events remaining in queue');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import 'app/i18n/app_localizations.dart';
|
|||
import 'app/i18n/locale_manager.dart';
|
||||
import 'core/services/auth_service.dart';
|
||||
import 'core/updater/update_service.dart';
|
||||
import 'core/telemetry/telemetry_service.dart';
|
||||
import 'core/updater/models/update_config.dart';
|
||||
import 'core/push/push_service.dart';
|
||||
import 'core/providers/notification_badge_manager.dart';
|
||||
|
|
@ -108,6 +109,19 @@ class _GenexConsumerAppState extends ConsumerState<GenexConsumerApp> {
|
|||
ref.read(authProvider.notifier).restoreSession();
|
||||
});
|
||||
}
|
||||
|
||||
// 遥测服务初始化(需 BuildContext 采集设备信息,在首帧渲染后执行)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
final auth = AuthService.instance.authState.value;
|
||||
await TelemetryService().initialize(
|
||||
apiBaseUrl: 'https://api.gogenex.com',
|
||||
context: context,
|
||||
userId: auth?.user['id'] as String?,
|
||||
);
|
||||
if (auth != null) {
|
||||
TelemetryService().setAccessToken(auth.accessToken);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ dependencies:
|
|||
intl: any
|
||||
dio: ^5.4.3+1
|
||||
package_info_plus: ^8.0.0
|
||||
uuid: ^4.3.3
|
||||
equatable: ^2.0.5
|
||||
device_info_plus: ^10.1.0
|
||||
path_provider: ^2.1.0
|
||||
crypto: ^3.0.3
|
||||
permission_handler: ^11.3.1
|
||||
|
|
|
|||
Loading…
Reference in New Issue