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
|
- REDIS_PORT=6379
|
||||||
- KAFKA_BROKERS=kafka:9092
|
- KAFKA_BROKERS=kafka:9092
|
||||||
- JWT_ACCESS_SECRET=dev-access-secret-change-in-production
|
- 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:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
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
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Build shared @genex/common package
|
COPY package*.json ./
|
||||||
COPY packages/common/package*.json ./packages/common/
|
COPY tsconfig.json ./
|
||||||
RUN cd packages/common && npm install
|
COPY nest-cli.json ./
|
||||||
COPY packages/common/ ./packages/common/
|
|
||||||
RUN cd packages/common && npm run build
|
|
||||||
|
|
||||||
# Install service dependencies
|
RUN npm ci
|
||||||
COPY services/telemetry-service/package*.json ./services/telemetry-service/
|
|
||||||
WORKDIR /app/services/telemetry-service
|
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
# Copy common package into node_modules for runtime resolution
|
COPY src ./src
|
||||||
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 service source and build
|
|
||||||
COPY services/telemetry-service/ ./
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# ─── Production stage ────────────────────────────────────────────────────────
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apk add --no-cache dumb-init
|
|
||||||
COPY --from=builder /app/services/telemetry-service/dist ./dist
|
RUN apk add --no-cache dumb-init curl
|
||||||
COPY --from=builder /app/services/telemetry-service/node_modules ./node_modules
|
|
||||||
COPY --from=builder /app/services/telemetry-service/package.json ./
|
COPY --from=builder /app/package*.json ./
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
EXPOSE 3011
|
EXPOSE 3011
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
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"]
|
CMD ["dumb-init", "node", "dist/main"]
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@ export class TelemetryService {
|
||||||
installId: string;
|
installId: string;
|
||||||
eventName: string;
|
eventName: string;
|
||||||
clientTs: number;
|
clientTs: number;
|
||||||
|
deviceBrand?: string;
|
||||||
|
deviceModel?: string;
|
||||||
|
deviceOs?: string;
|
||||||
|
appVersion?: string;
|
||||||
|
locale?: string;
|
||||||
properties?: Record<string, any>;
|
properties?: Record<string, any>;
|
||||||
}>): Promise<{ recorded: number }> {
|
}>): Promise<{ recorded: number }> {
|
||||||
const timer = this.metrics.startBatchTimer();
|
const timer = this.metrics.startBatchTimer();
|
||||||
|
|
@ -36,6 +41,11 @@ export class TelemetryService {
|
||||||
event.installId = e.installId;
|
event.installId = e.installId;
|
||||||
event.eventName = e.eventName;
|
event.eventName = e.eventName;
|
||||||
event.eventTime = new Date(e.clientTs * 1000);
|
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 || {};
|
event.properties = e.properties || {};
|
||||||
return event;
|
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' })
|
@Column({ name: 'event_time', type: 'timestamptz' })
|
||||||
eventTime: Date;
|
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: '{}' })
|
@Column({ type: 'jsonb', default: '{}' })
|
||||||
properties: Record<string, any>;
|
properties: Record<string, any>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,26 @@
|
||||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
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 { TelemetryService } from '../../../application/services/telemetry.service';
|
||||||
import { QueryDauDto, QueryEventsDto } from '../dto/query-dau.dto';
|
import { QueryDauDto, QueryEventsDto } from '../dto/query-dau.dto';
|
||||||
|
|
||||||
@ApiTags('admin-telemetry')
|
@ApiTags('admin-telemetry')
|
||||||
@Controller('admin/telemetry')
|
@Controller('admin/telemetry')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||||
@Roles(UserRole.ADMIN)
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class AdminTelemetryController {
|
export class AdminTelemetryController {
|
||||||
constructor(
|
constructor(private readonly telemetryService: TelemetryService) {}
|
||||||
private readonly telemetryService: TelemetryService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get('dau')
|
@Get('dau')
|
||||||
@ApiOperation({ summary: 'Query DAU statistics' })
|
@ApiOperation({ summary: '查询 DAU 统计' })
|
||||||
async getDauStats(@Query() query: QueryDauDto) {
|
async getDauStats(@Query() query: QueryDauDto) {
|
||||||
const result = await this.telemetryService.getDauStats(query.startDate, query.endDate);
|
const result = await this.telemetryService.getDauStats(query.startDate, query.endDate);
|
||||||
return { code: 0, data: result };
|
return { code: 0, data: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('events')
|
@Get('events')
|
||||||
@ApiOperation({ summary: 'Query telemetry events' })
|
@ApiOperation({ summary: '查询遥测事件列表' })
|
||||||
async listEvents(@Query() query: QueryEventsDto) {
|
async listEvents(@Query() query: QueryEventsDto) {
|
||||||
const page = query.page || 1;
|
const page = query.page || 1;
|
||||||
const limit = query.limit || 20;
|
const limit = query.limit || 20;
|
||||||
|
|
@ -36,7 +34,7 @@ export class AdminTelemetryController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('realtime')
|
@Get('realtime')
|
||||||
@ApiOperation({ summary: 'Get realtime analytics dashboard data' })
|
@ApiOperation({ summary: '获取实时数据看板' })
|
||||||
async getRealtimeData() {
|
async getRealtimeData() {
|
||||||
const data = await this.telemetryService.getRealtimeData();
|
const data = await this.telemetryService.getRealtimeData();
|
||||||
return { code: 0, data };
|
return { code: 0, data };
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Controller, Post, Get, Body, Query, UseGuards, Req } from '@nestjs/common';
|
import { Controller, Post, Get, Body, Query, UseGuards, Req } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
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 { TelemetryService } from '../../../application/services/telemetry.service';
|
||||||
import { BatchEventsDto } from '../dto/batch-events.dto';
|
import { BatchEventsDto } from '../dto/batch-events.dto';
|
||||||
import { HeartbeatDto } from '../dto/heartbeat.dto';
|
import { HeartbeatDto } from '../dto/heartbeat.dto';
|
||||||
|
|
@ -10,8 +10,64 @@ import { HeartbeatDto } from '../dto/heartbeat.dto';
|
||||||
export class TelemetryController {
|
export class TelemetryController {
|
||||||
constructor(private readonly telemetryService: TelemetryService) {}
|
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')
|
@Post('events')
|
||||||
@ApiOperation({ summary: 'Batch report telemetry events (no auth required)' })
|
@ApiOperation({ summary: '批量上报遥测事件(无需认证)' })
|
||||||
async batchEvents(@Body() body: BatchEventsDto) {
|
async batchEvents(@Body() body: BatchEventsDto) {
|
||||||
const result = await this.telemetryService.recordEvents(body.events);
|
const result = await this.telemetryService.recordEvents(body.events);
|
||||||
return { code: 0, data: result };
|
return { code: 0, data: result };
|
||||||
|
|
@ -20,16 +76,17 @@ export class TelemetryController {
|
||||||
@Post('heartbeat')
|
@Post('heartbeat')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Report heartbeat for online detection' })
|
@ApiOperation({ summary: '上报心跳(维持在线状态)' })
|
||||||
async heartbeat(@Req() req: any, @Body() body: HeartbeatDto) {
|
async heartbeat(@Req() req: any, @Body() body: HeartbeatDto) {
|
||||||
await this.telemetryService.recordHeartbeat(req.user.sub, body.installId, body.appVersion);
|
// JwtStrategy.validate() 返回 { id, role, kycLevel },故取 req.user.id
|
||||||
return { code: 0, data: { success: true } };
|
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')
|
@Get('online-count')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Get current online user count' })
|
@ApiOperation({ summary: '获取当前在线人数' })
|
||||||
async getOnlineCount() {
|
async getOnlineCount() {
|
||||||
const result = await this.telemetryService.getOnlineCount();
|
const result = await this.telemetryService.getOnlineCount();
|
||||||
return { code: 0, data: result };
|
return { code: 0, data: result };
|
||||||
|
|
@ -38,7 +95,7 @@ export class TelemetryController {
|
||||||
@Get('online-history')
|
@Get('online-history')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Get online user history trend' })
|
@ApiOperation({ summary: '获取在线人数历史趋势' })
|
||||||
async getOnlineHistory(
|
async getOnlineHistory(
|
||||||
@Query('startTime') startTime: string,
|
@Query('startTime') startTime: string,
|
||||||
@Query('endTime') endTime: string,
|
@Query('endTime') endTime: string,
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,47 @@ import { Type } from 'class-transformer';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class TelemetryEventItem {
|
export class TelemetryEventItem {
|
||||||
@ApiProperty({ example: 'page_view', maxLength: 64 }) @IsString() @MaxLength(64) eventName: string;
|
@ApiProperty({ example: 'page_view', maxLength: 64 })
|
||||||
@ApiProperty({ example: 'inst_abc123', maxLength: 128 }) @IsString() @MaxLength(128) installId: string;
|
@IsString() @MaxLength(64)
|
||||||
@ApiPropertyOptional() @IsOptional() @IsUUID() userId?: string;
|
eventName: string;
|
||||||
@ApiProperty({ example: 1700000000000 }) @IsNumber() clientTs: number;
|
|
||||||
@ApiPropertyOptional({ type: 'object' }) @IsOptional() @IsObject() properties?: Record<string, any>;
|
@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 {
|
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 { TelemetryService } from './application/services/telemetry.service';
|
||||||
import { TelemetrySchedulerService } from './application/services/telemetry-scheduler.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
|
// Interface - Controllers
|
||||||
import { TelemetryController } from './interface/http/controllers/telemetry.controller';
|
import { TelemetryController } from './interface/http/controllers/telemetry.controller';
|
||||||
import { AdminTelemetryController } from './interface/http/controllers/admin-telemetry.controller';
|
import { AdminTelemetryController } from './interface/http/controllers/admin-telemetry.controller';
|
||||||
|
|
@ -74,6 +78,8 @@ import { HealthController } from './interface/http/controllers/health.controller
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
|
JwtAuthGuard,
|
||||||
|
AdminGuard,
|
||||||
{ provide: TELEMETRY_EVENT_REPOSITORY, useClass: TelemetryEventRepository },
|
{ provide: TELEMETRY_EVENT_REPOSITORY, useClass: TelemetryEventRepository },
|
||||||
{ provide: ONLINE_SNAPSHOT_REPOSITORY, useClass: OnlineSnapshotRepository },
|
{ provide: ONLINE_SNAPSHOT_REPOSITORY, useClass: OnlineSnapshotRepository },
|
||||||
{ provide: DAILY_ACTIVE_STATS_REPOSITORY, useClass: DailyActiveStatsRepository },
|
{ 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 '../../../../app/i18n/app_localizations.dart';
|
||||||
import '../../../../core/services/auth_service.dart';
|
import '../../../../core/services/auth_service.dart';
|
||||||
import '../../../../core/network/api_client.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();
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_phoneController.dispose();
|
_phoneController.dispose();
|
||||||
|
|
@ -83,6 +98,11 @@ class _IssuerLoginPageState extends State<IssuerLoginPage> {
|
||||||
try {
|
try {
|
||||||
final result = await _authService.loginByPhone(phone, code);
|
final result = await _authService.loginByPhone(phone, code);
|
||||||
ApiClient.instance.setToken(result.accessToken);
|
ApiClient.instance.setToken(result.accessToken);
|
||||||
|
// 遥测:登录成功后注入 userId 和 token
|
||||||
|
if (TelemetryService().isInitialized) {
|
||||||
|
TelemetryService().setUserId(result.user?['id'] as String?);
|
||||||
|
TelemetryService().setAccessToken(result.accessToken);
|
||||||
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.pushReplacementNamed(context, AppRouter.main);
|
Navigator.pushReplacementNamed(context, AppRouter.main);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import '../../../../core/updater/update_service.dart';
|
||||||
import '../../../../core/services/issuer_service.dart';
|
import '../../../../core/services/issuer_service.dart';
|
||||||
import '../../../../core/services/auth_service.dart';
|
import '../../../../core/services/auth_service.dart';
|
||||||
import '../../../../core/network/api_client.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');
|
debugPrint('[SettingsPage] logout error: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 遥测:退出登录时清除 userId 和 token
|
||||||
|
if (TelemetryService().isInitialized) {
|
||||||
|
TelemetryService().clearUserId();
|
||||||
|
TelemetryService().clearAccessToken();
|
||||||
|
}
|
||||||
ApiClient.instance.setToken(null);
|
ApiClient.instance.setToken(null);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ dependencies:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
dio: ^5.4.3+1
|
dio: ^5.4.3+1
|
||||||
package_info_plus: ^8.0.0
|
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
|
path_provider: ^2.1.0
|
||||||
crypto: ^3.0.3
|
crypto: ^3.0.3
|
||||||
permission_handler: ^11.3.1
|
permission_handler: ^11.3.1
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import '../network/api_client.dart';
|
import '../network/api_client.dart';
|
||||||
import '../storage/session_storage.dart';
|
import '../storage/session_storage.dart';
|
||||||
|
import '../telemetry/telemetry_service.dart';
|
||||||
|
|
||||||
/// SMS 验证码类型
|
/// SMS 验证码类型
|
||||||
enum SmsCodeType {
|
enum SmsCodeType {
|
||||||
|
|
@ -512,12 +513,16 @@ class AuthService {
|
||||||
Future<void> _setAuth(AuthResult result) async {
|
Future<void> _setAuth(AuthResult result) async {
|
||||||
authState.value = result;
|
authState.value = result;
|
||||||
_api.setToken(result.accessToken);
|
_api.setToken(result.accessToken);
|
||||||
// 持久化 Token(非阻塞,但 await 保证写入完成再返回,防止进程被杀时丢失)
|
|
||||||
await SessionStorage.instance.save(
|
await SessionStorage.instance.save(
|
||||||
accessToken: result.accessToken,
|
accessToken: result.accessToken,
|
||||||
refreshToken: result.refreshToken,
|
refreshToken: result.refreshToken,
|
||||||
user: result.user,
|
user: result.user,
|
||||||
);
|
);
|
||||||
|
// 遥测:登录成功后注入 userId 和 token
|
||||||
|
if (TelemetryService().isInitialized) {
|
||||||
|
TelemetryService().setUserId(result.user['id'] as String?);
|
||||||
|
TelemetryService().setAccessToken(result.accessToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 登出/Token 过期时统一调用
|
/// 登出/Token 过期时统一调用
|
||||||
|
|
@ -529,5 +534,10 @@ class AuthService {
|
||||||
authState.value = null;
|
authState.value = null;
|
||||||
_api.setToken(null);
|
_api.setToken(null);
|
||||||
await SessionStorage.instance.clear();
|
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 'app/i18n/locale_manager.dart';
|
||||||
import 'core/services/auth_service.dart';
|
import 'core/services/auth_service.dart';
|
||||||
import 'core/updater/update_service.dart';
|
import 'core/updater/update_service.dart';
|
||||||
|
import 'core/telemetry/telemetry_service.dart';
|
||||||
import 'core/updater/models/update_config.dart';
|
import 'core/updater/models/update_config.dart';
|
||||||
import 'core/push/push_service.dart';
|
import 'core/push/push_service.dart';
|
||||||
import 'core/providers/notification_badge_manager.dart';
|
import 'core/providers/notification_badge_manager.dart';
|
||||||
|
|
@ -108,6 +109,19 @@ class _GenexConsumerAppState extends ConsumerState<GenexConsumerApp> {
|
||||||
ref.read(authProvider.notifier).restoreSession();
|
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
|
@override
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ dependencies:
|
||||||
intl: any
|
intl: any
|
||||||
dio: ^5.4.3+1
|
dio: ^5.4.3+1
|
||||||
package_info_plus: ^8.0.0
|
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
|
path_provider: ^2.1.0
|
||||||
crypto: ^3.0.3
|
crypto: ^3.0.3
|
||||||
permission_handler: ^11.3.1
|
permission_handler: ^11.3.1
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue