From be4ef7d1aaf9cf0bae8d2f0db64801c2497b712d Mon Sep 17 00:00:00 2001 From: hailin Date: Thu, 27 Nov 2025 02:44:01 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0APK=E5=9C=A8=E7=BA=BF?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E5=92=8C=E9=81=A5=E6=B5=8B=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit APK升级模块 (lib/core/updater/): - 支持自建服务器和Google Play双渠道更新 - 版本检测、APK下载、SHA-256校验、安装 - 应用市场来源检测 - 强制更新和普通更新对话框 遥测模块 (lib/core/telemetry/): - 设备信息采集 (品牌、型号、系统版本、屏幕等) - 会话管理 (DAU日活统计) - 心跳服务 (实时在线人数统计) - 事件队列和批量上传 - 远程配置热更新 Android原生配置: - MainActivity.kt Platform Channel实现 - FileProvider配置 (APK安装) - 权限配置 (INTERNET, REQUEST_INSTALL_PACKAGES) 文档: - docs/backend_api_guide.md 后端API开发指南 - docs/testing_guide.md 测试指南 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../android/app/src/main/AndroidManifest.xml | 16 + .../rwadurian/rwa_android_app/MainActivity.kt | 82 +- .../app/src/main/res/xml/file_paths.xml | 9 + frontend/mobile-app/docs/backend_api_guide.md | 725 +++++++++++ frontend/mobile-app/docs/testing_guide.md | 1096 +++++++++++++++++ frontend/mobile-app/lib/bootstrap.dart | 48 + .../collectors/device_info_collector.dart | 116 ++ .../core/telemetry/models/device_context.dart | 174 +++ .../telemetry/models/telemetry_config.dart | 151 +++ .../telemetry/models/telemetry_event.dart | 159 +++ .../telemetry/presence/heartbeat_service.dart | 206 ++++ .../telemetry/presence/presence_config.dart | 52 + .../telemetry/session/session_events.dart | 29 + .../telemetry/session/session_manager.dart | 155 +++ .../telemetry/storage/telemetry_storage.dart | 123 ++ .../lib/core/telemetry/telemetry_service.dart | 348 ++++++ .../uploader/telemetry_uploader.dart | 115 ++ .../lib/core/updater/apk_installer.dart | 64 + .../lib/core/updater/app_market_detector.dart | 141 +++ .../updater/channels/google_play_updater.dart | 85 ++ .../updater/channels/self_hosted_updater.dart | 427 +++++++ .../lib/core/updater/download_manager.dart | 152 +++ .../core/updater/models/update_config.dart | 56 + .../lib/core/updater/models/version_info.dart | 82 ++ .../lib/core/updater/update_service.dart | 219 ++++ .../lib/core/updater/version_checker.dart | 94 ++ .../auth/presentation/pages/splash_page.dart | 21 +- frontend/mobile-app/pubspec.lock | 66 +- frontend/mobile-app/pubspec.yaml | 12 +- 29 files changed, 5009 insertions(+), 14 deletions(-) create mode 100644 frontend/mobile-app/android/app/src/main/res/xml/file_paths.xml create mode 100644 frontend/mobile-app/docs/backend_api_guide.md create mode 100644 frontend/mobile-app/docs/testing_guide.md create mode 100644 frontend/mobile-app/lib/core/telemetry/collectors/device_info_collector.dart create mode 100644 frontend/mobile-app/lib/core/telemetry/models/device_context.dart create mode 100644 frontend/mobile-app/lib/core/telemetry/models/telemetry_config.dart create mode 100644 frontend/mobile-app/lib/core/telemetry/models/telemetry_event.dart create mode 100644 frontend/mobile-app/lib/core/telemetry/presence/heartbeat_service.dart create mode 100644 frontend/mobile-app/lib/core/telemetry/presence/presence_config.dart create mode 100644 frontend/mobile-app/lib/core/telemetry/session/session_events.dart create mode 100644 frontend/mobile-app/lib/core/telemetry/session/session_manager.dart create mode 100644 frontend/mobile-app/lib/core/telemetry/storage/telemetry_storage.dart create mode 100644 frontend/mobile-app/lib/core/telemetry/telemetry_service.dart create mode 100644 frontend/mobile-app/lib/core/telemetry/uploader/telemetry_uploader.dart create mode 100644 frontend/mobile-app/lib/core/updater/apk_installer.dart create mode 100644 frontend/mobile-app/lib/core/updater/app_market_detector.dart create mode 100644 frontend/mobile-app/lib/core/updater/channels/google_play_updater.dart create mode 100644 frontend/mobile-app/lib/core/updater/channels/self_hosted_updater.dart create mode 100644 frontend/mobile-app/lib/core/updater/download_manager.dart create mode 100644 frontend/mobile-app/lib/core/updater/models/update_config.dart create mode 100644 frontend/mobile-app/lib/core/updater/models/version_info.dart create mode 100644 frontend/mobile-app/lib/core/updater/update_service.dart create mode 100644 frontend/mobile-app/lib/core/updater/version_checker.dart diff --git a/frontend/mobile-app/android/app/src/main/AndroidManifest.xml b/frontend/mobile-app/android/app/src/main/AndroidManifest.xml index 655c9f8b..88b16874 100644 --- a/frontend/mobile-app/android/app/src/main/AndroidManifest.xml +++ b/frontend/mobile-app/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,9 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/mobile-app/docs/backend_api_guide.md b/frontend/mobile-app/docs/backend_api_guide.md new file mode 100644 index 00000000..44c3599e --- /dev/null +++ b/frontend/mobile-app/docs/backend_api_guide.md @@ -0,0 +1,725 @@ +# 后端 API 开发指南 + +> 本文档为 Flutter 前端已实现功能提供后端 API 接口规范,供后端开发参考。 + +--- + +## 目录 + +1. [APK 在线升级 API](#1-apk-在线升级-api) +2. [遥测系统 API](#2-遥测系统-api) +3. [数据库设计](#3-数据库设计) +4. [Redis 数据结构](#4-redis-数据结构) +5. [定时任务](#5-定时任务) + +--- + +## 1. APK 在线升级 API + +### 1.1 版本检测接口 + +**请求** + +``` +GET /api/app/version/check +``` + +**Query 参数** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| platform | string | 是 | 平台类型: `android` / `ios` | +| currentVersion | string | 是 | 当前版本号,如 `1.0.0` | +| currentVersionCode | int | 是 | 当前版本代码,如 `1` | + +**响应示例 - 有新版本** + +```json +{ + "code": 0, + "message": "success", + "data": { + "hasUpdate": true, + "version": "1.1.0", + "versionCode": 2, + "downloadUrl": "https://cdn.rwadurian.com/releases/app-v1.1.0.apk", + "fileSize": 52428800, + "fileSizeFriendly": "50.0 MB", + "sha256": "a1b2c3d4e5f6...完整64字符哈希", + "forceUpdate": false, + "updateLog": "1. 新增挖矿动画效果\n2. 修复已知BUG\n3. 性能优化", + "releaseDate": "2024-01-15T10:00:00Z" + } +} +``` + +**响应示例 - 已是最新版本** + +```json +{ + "code": 0, + "message": "success", + "data": { + "hasUpdate": false + } +} +``` + +**前端对应代码** + +```dart +// lib/core/updater/version_checker.dart +Future checkForUpdate() async { + final response = await _dio.get( + '/api/app/version/check', + queryParameters: { + 'platform': 'android', + 'currentVersion': packageInfo.version, + 'currentVersionCode': int.parse(packageInfo.buildNumber), + }, + ); + // ... +} +``` + +### 1.2 APK 下载 + +**要求** + +- 下载链接必须使用 HTTPS +- 支持断点续传 (Range 请求头) +- 提供正确的 `Content-Length` 响应头 +- 提供 SHA-256 校验值供前端验证文件完整性 + +**前端下载流程** + +``` +1. 请求版本检测接口获取下载信息 +2. 使用 Dio 下载 APK 到应用私有目录 +3. 计算下载文件 SHA-256 与服务器返回值比对 +4. 校验通过后调用系统安装器安装 +``` + +### 1.3 版本管理后台 (建议) + +建议实现后台管理界面支持: + +- 上传新版本 APK +- 自动计算文件大小和 SHA-256 +- 设置强制更新标志 +- 填写更新日志 +- 查看各版本下载统计 + +--- + +## 2. 遥测系统 API + +### 2.1 事件上报接口 + +**请求** + +``` +POST /api/v1/analytics/events +``` + +**请求头** + +``` +Content-Type: application/json +Authorization: Bearer // 可选,用户已登录时携带 +``` + +**请求体** + +```json +{ + "events": [ + { + "eventId": "550e8400-e29b-41d4-a716-446655440000", + "name": "page_view", + "type": "page_view", + "level": "info", + "installId": "device-unique-install-id", + "deviceContextId": "ctx-abc123", + "sessionId": "session-xyz789", + "userId": "user-123", + "timestamp": "2024-01-15T10:30:00.000Z", + "properties": { + "page": "/ranking", + "referrer": "/splash" + } + }, + { + "eventId": "550e8400-e29b-41d4-a716-446655440001", + "name": "button_click", + "type": "user_action", + "level": "info", + "installId": "device-unique-install-id", + "deviceContextId": "ctx-abc123", + "sessionId": "session-xyz789", + "userId": "user-123", + "timestamp": "2024-01-15T10:30:05.000Z", + "properties": { + "button": "start_mining", + "page": "/mining" + } + } + ] +} +``` + +**事件类型枚举 (EventType)** + +| 值 | 说明 | +|---|------| +| `page_view` | 页面浏览 | +| `user_action` | 用户操作 | +| `api_call` | API 调用 | +| `performance` | 性能指标 | +| `error` | 错误 | +| `crash` | 崩溃 | +| `session` | 会话事件 | +| `presence` | 在线状态 | + +**事件级别枚举 (EventLevel)** + +| 值 | 说明 | +|---|------| +| `debug` | 调试 | +| `info` | 信息 | +| `warning` | 警告 | +| `error` | 错误 | + +**响应** + +```json +{ + "code": 0, + "message": "success" +} +``` + +**前端对应代码** + +```dart +// lib/core/telemetry/uploader/telemetry_uploader.dart +Future uploadBatch({int batchSize = 20}) async { + final events = storage.dequeueEvents(batchSize); + final response = await _dio.post( + '/api/v1/analytics/events', + data: { + 'events': events.map((e) => e.toJson()).toList(), + }, + ); + // ... +} +``` + +### 2.2 心跳接口 (在线状态) + +**请求** + +``` +POST /api/v1/presence/heartbeat +``` + +**请求头** + +``` +Content-Type: application/json +Authorization: Bearer // 可选 +``` + +**请求体** + +```json +{ + "installId": "device-unique-install-id", + "sessionId": "session-xyz789", + "userId": "user-123", + "timestamp": "2024-01-15T10:30:00.000Z" +} +``` + +**响应** + +```json +{ + "code": 0, + "message": "success" +} +``` + +**前端行为** + +- 应用在前台时,每 **60 秒** 发送一次心跳 +- 应用进入后台时暂停心跳 +- 应用恢复前台时立即发送心跳并恢复定时器 + +**前端对应代码** + +```dart +// lib/core/telemetry/presence/heartbeat_service.dart +void _sendHeartbeat() { + _dio.post( + '/api/v1/presence/heartbeat', + data: { + 'installId': _installId, + 'sessionId': _sessionId, + 'userId': _userId, + 'timestamp': DateTime.now().toUtc().toIso8601String(), + }, + ); +} +``` + +### 2.3 遥测配置接口 + +**请求** + +``` +GET /api/telemetry/config +``` + +**响应** + +```json +{ + "code": 0, + "message": "success", + "data": { + "enabled": true, + "samplingRate": 1.0, + "enabledEventTypes": ["page_view", "user_action", "error", "crash", "session", "presence"], + "maxQueueSize": 1000, + "uploadBatchSize": 20, + "uploadIntervalSeconds": 30, + "heartbeatIntervalSeconds": 60, + "sessionTimeoutMinutes": 30 + } +} +``` + +**字段说明** + +| 字段 | 类型 | 说明 | +|------|------|------| +| enabled | bool | 全局开关,false 时前端停止所有遥测 | +| samplingRate | double | 采样率 0.0~1.0,用于控制上报比例 | +| enabledEventTypes | string[] | 启用的事件类型列表 | +| maxQueueSize | int | 本地队列最大容量 | +| uploadBatchSize | int | 每批上传事件数量 | +| uploadIntervalSeconds | int | 上传间隔秒数 | +| heartbeatIntervalSeconds | int | 心跳间隔秒数 | +| sessionTimeoutMinutes | int | 会话超时分钟数 | + +**前端对应代码** + +```dart +// lib/core/telemetry/models/telemetry_config.dart +class TelemetryConfig { + final bool enabled; + final double samplingRate; + final List enabledEventTypes; + final int maxQueueSize; + final int uploadBatchSize; + final int uploadIntervalSeconds; + final int heartbeatIntervalSeconds; + final int sessionTimeoutMinutes; +} +``` + +--- + +## 3. 数据库设计 + +### 3.1 应用版本表 (app_versions) + +```sql +CREATE TABLE app_versions ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + platform ENUM('android', 'ios') NOT NULL, + version VARCHAR(20) NOT NULL COMMENT '版本号 如 1.0.0', + version_code INT NOT NULL COMMENT '版本代码 如 1', + download_url VARCHAR(500) NOT NULL COMMENT 'APK/IPA 下载地址', + file_size BIGINT NOT NULL COMMENT '文件大小(字节)', + sha256 CHAR(64) NOT NULL COMMENT 'SHA-256 哈希值', + force_update TINYINT(1) DEFAULT 0 COMMENT '是否强制更新', + update_log TEXT COMMENT '更新日志', + release_date DATETIME NOT NULL COMMENT '发布时间', + is_active TINYINT(1) DEFAULT 1 COMMENT '是否激活', + download_count INT DEFAULT 0 COMMENT '下载次数', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uk_platform_version (platform, version_code), + INDEX idx_platform_active (platform, is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='应用版本管理'; +``` + +### 3.2 遥测事件表 (analytics_events) + +```sql +CREATE TABLE analytics_events ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + event_id VARCHAR(36) NOT NULL COMMENT '事件唯一ID (UUID)', + name VARCHAR(100) NOT NULL COMMENT '事件名称', + type ENUM('page_view', 'user_action', 'api_call', 'performance', 'error', 'crash', 'session', 'presence') NOT NULL, + level ENUM('debug', 'info', 'warning', 'error') DEFAULT 'info', + install_id VARCHAR(100) NOT NULL COMMENT '设备安装ID', + device_context_id VARCHAR(100) COMMENT '设备上下文ID', + session_id VARCHAR(100) COMMENT '会话ID', + user_id VARCHAR(100) COMMENT '用户ID', + properties JSON COMMENT '事件属性', + event_time DATETIME(3) NOT NULL COMMENT '事件发生时间', + received_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) COMMENT '服务器接收时间', + + UNIQUE KEY uk_event_id (event_id), + INDEX idx_install_id (install_id), + INDEX idx_user_id (user_id), + INDEX idx_session_id (session_id), + INDEX idx_type_time (type, event_time), + INDEX idx_event_time (event_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='遥测事件记录' +PARTITION BY RANGE (TO_DAYS(event_time)) ( + PARTITION p_default VALUES LESS THAN MAXVALUE +); +``` + +> 建议按天分区,便于数据清理和查询优化 + +### 3.3 设备上下文表 (device_contexts) + +```sql +CREATE TABLE device_contexts ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + context_id VARCHAR(100) NOT NULL COMMENT '上下文ID', + install_id VARCHAR(100) NOT NULL COMMENT '设备安装ID', + platform VARCHAR(20) NOT NULL COMMENT '平台 android/ios', + brand VARCHAR(50) COMMENT '品牌', + model VARCHAR(100) COMMENT '型号', + manufacturer VARCHAR(100) COMMENT '制造商', + is_physical_device TINYINT(1) COMMENT '是否真机', + os_version VARCHAR(50) COMMENT '系统版本', + sdk_int INT COMMENT 'SDK版本号', + android_id VARCHAR(100) COMMENT 'Android ID', + screen_width INT COMMENT '屏幕宽度', + screen_height INT COMMENT '屏幕高度', + screen_density DECIMAL(4,2) COMMENT '屏幕密度', + app_name VARCHAR(100) COMMENT '应用名称', + package_name VARCHAR(200) COMMENT '包名', + app_version VARCHAR(20) COMMENT '应用版本', + build_number VARCHAR(20) COMMENT '构建号', + build_mode VARCHAR(20) COMMENT '构建模式 debug/release', + locale VARCHAR(20) COMMENT '语言区域', + timezone VARCHAR(50) COMMENT '时区', + network_type VARCHAR(20) COMMENT '网络类型', + is_dark_mode TINYINT(1) COMMENT '是否深色模式', + collected_at DATETIME(3) NOT NULL COMMENT '采集时间', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uk_context_id (context_id), + INDEX idx_install_id (install_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备上下文信息'; +``` + +### 3.4 日活统计表 (analytics_daily_active) + +```sql +CREATE TABLE analytics_daily_active ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + stat_date DATE NOT NULL COMMENT '统计日期', + total_dau INT DEFAULT 0 COMMENT '总DAU', + new_users INT DEFAULT 0 COMMENT '新用户数', + returning_users INT DEFAULT 0 COMMENT '回访用户数', + total_sessions INT DEFAULT 0 COMMENT '总会话数', + avg_session_duration INT DEFAULT 0 COMMENT '平均会话时长(秒)', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uk_stat_date (stat_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='日活统计汇总'; +``` + +### 3.5 在线快照表 (analytics_online_snapshots) + +```sql +CREATE TABLE analytics_online_snapshots ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + snapshot_time DATETIME NOT NULL COMMENT '快照时间', + online_count INT NOT NULL COMMENT '在线人数', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + INDEX idx_snapshot_time (snapshot_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='在线人数快照'; +``` + +--- + +## 4. Redis 数据结构 + +### 4.1 在线用户集合 (实时在线统计) + +**Key**: `presence:online_users` +**Type**: Sorted Set (ZSET) +**Score**: 最后心跳时间戳 (Unix timestamp) +**Member**: `installId` 或 `userId` (取决于是否登录) + +```redis +# 用户发送心跳时 +ZADD presence:online_users + +# 获取在线人数 (2分钟内有心跳的用户) +ZCOUNT presence:online_users + +# 清理过期用户 (可选,定时任务执行) +ZREMRANGEBYSCORE presence:online_users -inf +``` + +**在线判定规则** + +用户最后心跳时间在 **2 分钟** 内视为在线(心跳间隔 60 秒,允许 1 次丢失) + +### 4.2 日活去重集合 + +**Key**: `analytics:dau:` (如 `analytics:dau:2024-01-15`) +**Type**: Set +**Member**: `installId` +**TTL**: 48 小时 + +```redis +# 记录日活 +SADD analytics:dau:2024-01-15 +EXPIRE analytics:dau:2024-01-15 172800 + +# 获取当日DAU +SCARD analytics:dau:2024-01-15 +``` + +### 4.3 会话缓存 + +**Key**: `session:` +**Type**: Hash +**TTL**: 30 分钟 (随活动更新) + +```redis +HSET session:xyz789 + installId "device-xxx" + userId "user-123" + startTime "2024-01-15T10:00:00Z" + lastActiveTime "2024-01-15T10:30:00Z" + +EXPIRE session:xyz789 1800 +``` + +--- + +## 5. 定时任务 + +### 5.1 DAU 统计任务 + +**执行频率**: 每天凌晨 00:05 + +```python +# 伪代码 +def calculate_daily_dau(): + yesterday = date.today() - timedelta(days=1) + date_str = yesterday.strftime('%Y-%m-%d') + + # 从 Redis 获取昨日 DAU + dau_key = f'analytics:dau:{date_str}' + total_dau = redis.scard(dau_key) + + # 或从数据库统计 (session_start 事件去重) + total_dau = db.query(""" + SELECT COUNT(DISTINCT install_id) + FROM analytics_events + WHERE type = 'session' + AND name = 'session_start' + AND DATE(event_time) = %s + """, [date_str]) + + # 统计新用户 (首次出现的 install_id) + new_users = db.query(""" + SELECT COUNT(*) FROM ( + SELECT install_id + FROM analytics_events + WHERE type = 'session' AND name = 'session_start' + GROUP BY install_id + HAVING MIN(DATE(event_time)) = %s + ) t + """, [date_str]) + + # 写入统计表 + db.upsert('analytics_daily_active', { + 'stat_date': date_str, + 'total_dau': total_dau, + 'new_users': new_users, + 'returning_users': total_dau - new_users + }) +``` + +### 5.2 在线人数快照任务 + +**执行频率**: 每 5 分钟 + +```python +# 伪代码 +def snapshot_online_count(): + now = datetime.now() + threshold = now - timedelta(minutes=2) + + # 从 Redis 获取在线人数 + online_count = redis.zcount( + 'presence:online_users', + threshold.timestamp(), + now.timestamp() + ) + + # 写入快照表 + db.insert('analytics_online_snapshots', { + 'snapshot_time': now, + 'online_count': online_count + }) +``` + +### 5.3 过期数据清理任务 + +**执行频率**: 每天凌晨 03:00 + +```python +# 伪代码 +def cleanup_expired_data(): + # 清理 30 天前的事件数据 + retention_days = 30 + cutoff_date = date.today() - timedelta(days=retention_days) + + db.execute(""" + DELETE FROM analytics_events + WHERE event_time < %s + LIMIT 100000 + """, [cutoff_date]) + + # 清理 Redis 过期在线状态 + redis.zremrangebyscore( + 'presence:online_users', + '-inf', + (datetime.now() - timedelta(minutes=5)).timestamp() + ) +``` + +--- + +## 6. 前端关键行为说明 + +### 6.1 会话管理 + +**会话开始条件**: +- 应用冷启动 +- 应用从后台恢复且距上次活动超过 30 分钟 + +**会话结束条件**: +- 应用进入后台 + +**前端发送事件**: + +```dart +// 会话开始 +TelemetryService().trackEvent( + name: SessionEvents.sessionStart, + type: EventType.session, + properties: {'trigger': 'app_launch'}, +); + +// 会话结束 +TelemetryService().trackEvent( + name: SessionEvents.sessionEnd, + type: EventType.session, + properties: { + 'duration_seconds': duration, + 'trigger': 'app_pause', + }, +); +``` + +### 6.2 事件上报策略 + +- **队列阈值触发**: 本地队列 ≥10 条时触发上传 +- **定时触发**: 每 30 秒检查一次 +- **应用退出触发**: 强制上传全部队列 +- **批量大小**: 每批最多 20 条 + +### 6.3 设备上下文 + +前端采集设备信息后生成唯一 `deviceContextId`,同一设备配置不变时复用相同 ID。设备信息变化(如系统升级)时生成新 ID。 + +--- + +## 7. API 错误码规范 + +| code | 说明 | +|------|------| +| 0 | 成功 | +| 1001 | 参数错误 | +| 1002 | 认证失败 | +| 2001 | 服务器内部错误 | +| 2002 | 数据库错误 | +| 3001 | 版本不存在 | +| 3002 | 下载链接已过期 | + +--- + +## 8. 安全建议 + +1. **APK 下载** + - 使用 HTTPS + - 提供 SHA-256 校验 + - 下载链接可设置有效期 + +2. **遥测数据** + - 不采集敏感个人信息 (如通讯录、短信) + - installId 使用 UUID 生成,不关联设备硬件标识 + - 支持用户关闭遥测 (通过远程配置) + +3. **心跳接口** + - 防刷限流 (每个 installId 每分钟最多 2 次) + - 异常流量监控 + +--- + +## 附录: 前端代码目录结构 + +``` +lib/core/ +├── updater/ # APK升级模块 +│ ├── models/ +│ │ ├── version_info.dart # 版本信息模型 +│ │ └── update_config.dart # 更新配置模型 +│ ├── channels/ +│ │ ├── google_play_updater.dart # Google Play更新器 +│ │ └── self_hosted_updater.dart # 自建服务器更新器 +│ ├── version_checker.dart # 版本检测器 +│ ├── download_manager.dart # 下载管理器 +│ ├── apk_installer.dart # APK安装器 +│ ├── app_market_detector.dart # 应用市场检测 +│ └── update_service.dart # 统一更新服务 +│ +└── telemetry/ # 遥测模块 + ├── models/ + │ ├── device_context.dart # 设备上下文模型 + │ ├── telemetry_event.dart # 遥测事件模型 + │ └── telemetry_config.dart # 遥测配置模型 + ├── collectors/ + │ └── device_info_collector.dart # 设备信息采集器 + ├── storage/ + │ └── telemetry_storage.dart # 本地事件存储 + ├── uploader/ + │ └── telemetry_uploader.dart # 事件上传器 + ├── session/ + │ ├── session_events.dart # 会话事件常量 + │ └── session_manager.dart # 会话管理器 + ├── presence/ + │ ├── presence_config.dart # 心跳配置 + │ └── heartbeat_service.dart # 心跳服务 + └── telemetry_service.dart # 遥测服务入口 +``` diff --git a/frontend/mobile-app/docs/testing_guide.md b/frontend/mobile-app/docs/testing_guide.md new file mode 100644 index 00000000..95f7faa3 --- /dev/null +++ b/frontend/mobile-app/docs/testing_guide.md @@ -0,0 +1,1096 @@ +# 测试指南 + +> 本文档提供 APK 升级模块、遥测模块的手动测试和自动化测试方法。 + +--- + +## 目录 + +1. [测试环境准备](#1-测试环境准备) +2. [APK 升级模块测试](#2-apk-升级模块测试) +3. [遥测模块测试](#3-遥测模块测试) +4. [自动化单元测试](#4-自动化单元测试) +5. [Mock 服务器搭建](#5-mock-服务器搭建) + +--- + +## 1. 测试环境准备 + +### 1.1 开发环境 + +```bash +# 确保 Flutter 环境正常 +flutter doctor + +# 获取依赖 +cd c:\Users\dong\Desktop\rwadurian\frontend\mobile-app +flutter pub get +``` + +### 1.2 测试设备 + +| 测试项 | 推荐设备 | +|-------|---------| +| APK 安装 | Android 真机 (API 24+) | +| 遥测功能 | Android 真机或模拟器 | +| 心跳服务 | Android 真机 (测试前后台切换) | + +### 1.3 构建测试 APK + +```bash +# Debug 版本 (用于开发测试) +flutter build apk --debug + +# Release 版本 (用于正式测试) +flutter build apk --release + +# 输出路径 +# build/app/outputs/flutter-apk/app-debug.apk +# build/app/outputs/flutter-apk/app-release.apk +``` + +--- + +## 2. APK 升级模块测试 + +### 2.1 手动测试清单 + +#### 测试用例 1: 版本检测 - 有新版本 + +**前置条件**: +- 当前安装版本: 1.0.0 (versionCode: 1) +- 服务器配置新版本: 1.1.0 (versionCode: 2) + +**测试步骤**: +1. 启动应用,进入主页面(龙虎榜) +2. 等待 3 秒后观察是否弹出更新对话框 + +**预期结果**: +- 弹出更新对话框 +- 显示新版本号 "1.1.0" +- 显示文件大小 +- 显示更新日志(如有) + +**验证日志**: +``` +flutter: 📊 Checking for update... +flutter: 📊 New version available: 1.1.0 +``` + +--- + +#### 测试用例 2: 版本检测 - 已是最新版本 + +**前置条件**: +- 当前安装版本与服务器最新版本一致 + +**测试步骤**: +1. 启动应用,进入主页面 +2. 等待 3 秒 + +**预期结果**: +- 不弹出更新对话框 + +**验证日志**: +``` +flutter: Already latest version +``` + +--- + +#### 测试用例 3: 强制更新 + +**前置条件**: +- 服务器配置 `forceUpdate: true` + +**测试步骤**: +1. 启动应用,触发版本检测 +2. 尝试点击对话框外部关闭 +3. 尝试按返回键关闭 + +**预期结果**: +- 对话框标题显示"发现重要更新" +- 无法通过点击外部或返回键关闭对话框 +- 没有"稍后"按钮 + +--- + +#### 测试用例 4: APK 下载与安装 + +**前置条件**: +- 服务器已配置有效的 APK 下载链接 +- APK 已正确签名 + +**测试步骤**: +1. 点击"立即更新"按钮 +2. 观察下载进度 +3. 下载完成后观察安装流程 + +**预期结果**: +- 显示下载进度对话框 +- 进度百分比实时更新 +- 下载完成后自动弹出系统安装界面 + +**验证日志**: +``` +flutter: 📥 Downloading APK from: https://... +flutter: 📥 Download progress: 50% +flutter: ✅ APK downloaded successfully +flutter: 🔐 SHA-256 verification passed +flutter: 📲 Installing APK... +``` + +--- + +#### 测试用例 5: 下载取消 + +**测试步骤**: +1. 开始下载更新 +2. 在下载过程中点击"取消"按钮 + +**预期结果**: +- 下载立即停止 +- 显示"下载已取消" +- 临时文件被清理 + +--- + +#### 测试用例 6: 下载失败重试 + +**前置条件**: +- 模拟网络不稳定或服务器错误 + +**测试步骤**: +1. 开始下载更新 +2. 断开网络或服务器返回错误 +3. 观察错误提示 +4. 点击"重试"按钮 + +**预期结果**: +- 显示"下载失败,请稍后重试" +- 出现"重试"按钮 +- 点击重试后重新开始下载 + +--- + +#### 测试用例 7: SHA-256 校验失败 + +**前置条件**: +- 服务器返回的 SHA-256 与实际文件不匹配 + +**测试步骤**: +1. 下载 APK +2. 等待校验完成 + +**预期结果**: +- 显示下载失败 +- 日志显示校验失败信息 + +**验证日志**: +``` +flutter: ❌ SHA-256 verification failed +flutter: Expected: abc123... +flutter: Actual: def456... +``` + +--- + +#### 测试用例 8: 应用市场来源检测 + +**前置条件**: +- 从 Google Play 安装的应用 + +**测试步骤**: +1. 启动应用 +2. 触发版本检测 + +**预期结果**: +- 显示"检测到您的应用来自应用市场"提示 +- 按钮变为"前往应用市场" + +--- + +### 2.2 调试技巧 + +#### 强制触发版本检测 + +在代码中临时修改版本号进行测试: + +```dart +// lib/core/updater/version_checker.dart +// 临时修改 checkForUpdate() 方法,强制返回测试数据 + +Future checkForUpdate() async { + // 测试用: 返回模拟的新版本信息 + return VersionInfo( + version: '1.1.0', + versionCode: 999, + downloadUrl: 'https://your-test-server.com/test.apk', + fileSize: 52428800, + fileSizeFriendly: '50.0 MB', + sha256: 'your-test-sha256-hash', + forceUpdate: false, + updateLog: '测试更新日志', + releaseDate: DateTime.now(), + ); +} +``` + +#### 使用 Charles/Fiddler 抓包 + +1. 配置手机代理到电脑 +2. 监控 `/api/app/version/check` 请求 +3. 可修改响应数据测试不同场景 + +--- + +## 3. 遥测模块测试 + +### 3.1 手动测试清单 + +#### 测试用例 1: 设备信息采集 + +**测试步骤**: +1. 首次启动应用 +2. 等待 Splash 页面完成 + +**预期结果**: +- 控制台输出设备信息采集日志 + +**验证日志**: +``` +flutter: 📊 Collecting device info... +flutter: 📊 Device: Samsung SM-G991B +flutter: 📊 OS: Android 13 (SDK 33) +flutter: 📊 App: 1.0.0 (1) +flutter: 📊 Screen: 1080x2400 @3.0x +flutter: 📊 Network: wifi +flutter: 📊 Locale: zh_CN +``` + +--- + +#### 测试用例 2: 会话开始事件 + +**测试步骤**: +1. 完全关闭应用 +2. 重新启动应用 + +**预期结果**: +- 生成新的 sessionId +- 触发 `session_start` 事件 + +**验证日志**: +``` +flutter: 📊 New session started: session-abc123 +flutter: 📊 Event tracked: session_start +``` + +--- + +#### 测试用例 3: 会话结束事件 + +**测试步骤**: +1. 应用在前台运行 +2. 按 Home 键将应用切到后台 + +**预期结果**: +- 触发 `session_end` 事件 +- 事件包含会话时长 + +**验证日志**: +``` +flutter: 📊 Session paused, duration: 120 seconds +flutter: 📊 Event tracked: session_end +``` + +--- + +#### 测试用例 4: 会话恢复 (30分钟内) + +**测试步骤**: +1. 将应用切到后台 +2. 5 分钟内切回前台 + +**预期结果**: +- 恢复原有 session +- 触发 `session_resume` 事件 + +**验证日志**: +``` +flutter: 📊 Session resumed: session-abc123 +flutter: 📊 Event tracked: session_resume +``` + +--- + +#### 测试用例 5: 新会话 (超过30分钟) + +**测试步骤**: +1. 将应用切到后台 +2. 等待超过 30 分钟(或修改超时配置为较短时间测试) +3. 切回前台 + +**预期结果**: +- 生成新的 sessionId +- 触发 `session_start` 事件(非 resume) + +--- + +#### 测试用例 6: 心跳发送 + +**测试步骤**: +1. 保持应用在前台 +2. 观察 60 秒内的日志 + +**预期结果**: +- 每 60 秒发送一次心跳 + +**验证日志**: +``` +flutter: 💓 Heartbeat sent +flutter: 💓 Heartbeat sent +... +``` + +--- + +#### 测试用例 7: 心跳暂停 (后台) + +**测试步骤**: +1. 应用在前台,心跳正常发送 +2. 将应用切到后台 +3. 等待 2 分钟 +4. 切回前台 + +**预期结果**: +- 后台期间不发送心跳 +- 切回前台后立即发送心跳并恢复定时器 + +**验证日志**: +``` +flutter: 💓 Heartbeat paused (app in background) +... (后台期间无心跳日志) +flutter: 💓 Heartbeat resumed (app in foreground) +flutter: 💓 Heartbeat sent +``` + +--- + +#### 测试用例 8: 事件批量上传 + +**测试步骤**: +1. 在应用中执行多个操作(页面切换、按钮点击等) +2. 等待 30 秒或事件队列达到 10 条 + +**预期结果**: +- 触发批量上传 +- 上传成功后清理本地队列 + +**验证日志**: +``` +flutter: 📊 Queue size: 10, triggering upload +flutter: 📊 Uploading 10 events... +flutter: ✅ Uploaded 10 telemetry events +``` + +--- + +#### 测试用例 9: 上传失败重试 + +**前置条件**: +- 模拟网络断开或服务器错误 + +**测试步骤**: +1. 积累一些事件 +2. 断开网络 +3. 等待上传触发 + +**预期结果**: +- 上传失败但事件保留在本地队列 +- 网络恢复后下次触发时重新上传 + +**验证日志**: +``` +flutter: ❌ Upload error (DioException): Connection refused +flutter: 📊 Events kept in queue for retry +``` + +--- + +#### 测试用例 10: 远程配置同步 + +**测试步骤**: +1. 启动应用 +2. 修改服务器配置(如 `samplingRate: 0.5`) +3. 等待配置同步周期(默认 1 小时,测试时可改短) + +**预期结果**: +- 获取并应用新配置 + +**验证日志**: +``` +flutter: 📊 Fetching telemetry config... +flutter: 📊 Config updated: samplingRate=0.5 +``` + +--- + +#### 测试用例 11: 采样率过滤 + +**前置条件**: +- 配置 `samplingRate: 0.0` + +**测试步骤**: +1. 应用新配置 +2. 尝试触发事件 + +**预期结果**: +- 所有事件被过滤,不进入队列 + +**验证日志**: +``` +flutter: 📊 Event filtered by sampling rate +``` + +--- + +#### 测试用例 12: 事件类型过滤 + +**前置条件**: +- 配置 `enabledEventTypes: ['error', 'crash']` + +**测试步骤**: +1. 触发 `page_view` 事件 +2. 触发 `error` 事件 + +**预期结果**: +- `page_view` 被过滤 +- `error` 被记录 + +--- + +### 3.2 调试技巧 + +#### 查看本地事件队列 + +```dart +// 临时添加调试代码 +final storage = TelemetryStorage(); +final events = storage.peekEvents(100); +for (var e in events) { + debugPrint('Event: ${e.name} - ${e.type} - ${e.timestamp}'); +} +``` + +#### 强制触发上传 + +```dart +// 在需要的地方调用 +await TelemetryService().forceUpload(); +``` + +#### 缩短配置同步周期 + +```dart +// lib/bootstrap.dart +await TelemetryService().initialize( + apiBaseUrl: _apiBaseUrl, + context: context, + configSyncInterval: const Duration(minutes: 1), // 测试用 +); +``` + +--- + +## 4. 自动化单元测试 + +### 4.1 测试文件结构 + +``` +test/ +├── unit/ +│ ├── core/ +│ │ ├── updater/ +│ │ │ ├── version_checker_test.dart +│ │ │ ├── download_manager_test.dart +│ │ │ └── update_service_test.dart +│ │ └── telemetry/ +│ │ ├── device_info_collector_test.dart +│ │ ├── telemetry_storage_test.dart +│ │ ├── telemetry_uploader_test.dart +│ │ ├── session_manager_test.dart +│ │ └── heartbeat_service_test.dart +│ └── mocks/ +│ ├── mock_dio.dart +│ └── mock_storage.dart +``` + +### 4.2 VersionChecker 单元测试 + +```dart +// test/unit/core/updater/version_checker_test.dart + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:dio/dio.dart'; +import 'package:rwa_android_app/core/updater/version_checker.dart'; +import 'package:rwa_android_app/core/updater/models/version_info.dart'; + +class MockDio extends Mock implements Dio {} + +void main() { + late VersionChecker versionChecker; + late MockDio mockDio; + + setUp(() { + mockDio = MockDio(); + versionChecker = VersionChecker( + apiBaseUrl: 'https://api.test.com', + dio: mockDio, // 需要修改 VersionChecker 支持注入 Dio + ); + }); + + group('VersionChecker', () { + test('should return VersionInfo when new version available', () async { + // Arrange + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer((_) async => Response( + data: { + 'code': 0, + 'data': { + 'hasUpdate': true, + 'version': '1.1.0', + 'versionCode': 2, + 'downloadUrl': 'https://cdn.test.com/app.apk', + 'fileSize': 52428800, + 'fileSizeFriendly': '50.0 MB', + 'sha256': 'abc123', + 'forceUpdate': false, + 'updateLog': 'Bug fixes', + 'releaseDate': '2024-01-15T10:00:00Z', + } + }, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + )); + + // Act + final result = await versionChecker.checkForUpdate(); + + // Assert + expect(result, isNotNull); + expect(result!.version, '1.1.0'); + expect(result.versionCode, 2); + expect(result.forceUpdate, false); + }); + + test('should return null when no update available', () async { + // Arrange + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer((_) async => Response( + data: { + 'code': 0, + 'data': {'hasUpdate': false} + }, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + )); + + // Act + final result = await versionChecker.checkForUpdate(); + + // Assert + expect(result, isNull); + }); + + test('should return null on network error', () async { + // Arrange + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenThrow(DioException( + type: DioExceptionType.connectionError, + requestOptions: RequestOptions(path: ''), + )); + + // Act + final result = await versionChecker.checkForUpdate(); + + // Assert + expect(result, isNull); + }); + }); +} +``` + +### 4.3 TelemetryStorage 单元测试 + +```dart +// test/unit/core/telemetry/telemetry_storage_test.dart + +import 'package:flutter_test/flutter_test.dart'; +import 'package:rwa_android_app/core/telemetry/storage/telemetry_storage.dart'; +import 'package:rwa_android_app/core/telemetry/models/telemetry_event.dart'; + +void main() { + late TelemetryStorage storage; + + setUp(() { + storage = TelemetryStorage(); + storage.clear(); // 清理之前的数据 + }); + + group('TelemetryStorage', () { + test('should enqueue and dequeue events', () { + // Arrange + final event = TelemetryEvent( + eventId: 'test-1', + name: 'test_event', + type: EventType.userAction, + level: EventLevel.info, + installId: 'install-1', + deviceContextId: 'ctx-1', + timestamp: DateTime.now(), + ); + + // Act + storage.enqueueEvent(event); + final events = storage.dequeueEvents(10); + + // Assert + expect(events.length, 1); + expect(events.first.eventId, 'test-1'); + }); + + test('should respect max queue size', () { + // Arrange + storage.setMaxQueueSize(5); + + // Act + for (int i = 0; i < 10; i++) { + storage.enqueueEvent(TelemetryEvent( + eventId: 'event-$i', + name: 'test_event', + type: EventType.userAction, + level: EventLevel.info, + installId: 'install-1', + deviceContextId: 'ctx-1', + timestamp: DateTime.now(), + )); + } + + // Assert + expect(storage.getQueueSize(), 5); + }); + + test('should dequeue in FIFO order', () { + // Arrange + for (int i = 0; i < 5; i++) { + storage.enqueueEvent(TelemetryEvent( + eventId: 'event-$i', + name: 'test_event', + type: EventType.userAction, + level: EventLevel.info, + installId: 'install-1', + deviceContextId: 'ctx-1', + timestamp: DateTime.now(), + )); + } + + // Act + final events = storage.dequeueEvents(3); + + // Assert + expect(events[0].eventId, 'event-0'); + expect(events[1].eventId, 'event-1'); + expect(events[2].eventId, 'event-2'); + }); + }); +} +``` + +### 4.4 SessionManager 单元测试 + +```dart +// test/unit/core/telemetry/session_manager_test.dart + +import 'package:flutter_test/flutter_test.dart'; +import 'package:rwa_android_app/core/telemetry/session/session_manager.dart'; + +void main() { + late SessionManager sessionManager; + + setUp(() { + sessionManager = SessionManager( + sessionTimeout: const Duration(minutes: 30), + ); + }); + + group('SessionManager', () { + test('should generate new session on start', () { + // Act + sessionManager.startSession(); + + // Assert + expect(sessionManager.currentSessionId, isNotNull); + expect(sessionManager.currentSessionId, isNotEmpty); + }); + + test('should keep same session on resume within timeout', () { + // Arrange + sessionManager.startSession(); + final originalSessionId = sessionManager.currentSessionId; + sessionManager.pauseSession(); + + // Act + sessionManager.resumeSession(); + + // Assert + expect(sessionManager.currentSessionId, originalSessionId); + }); + + test('should generate new session on resume after timeout', () async { + // Arrange + sessionManager = SessionManager( + sessionTimeout: const Duration(milliseconds: 100), + ); + sessionManager.startSession(); + final originalSessionId = sessionManager.currentSessionId; + sessionManager.pauseSession(); + + // Wait for timeout + await Future.delayed(const Duration(milliseconds: 150)); + + // Act + sessionManager.resumeSession(); + + // Assert + expect(sessionManager.currentSessionId, isNot(originalSessionId)); + }); + + test('should calculate session duration correctly', () async { + // Arrange + sessionManager.startSession(); + + // Wait some time + await Future.delayed(const Duration(seconds: 1)); + + // Act + final duration = sessionManager.getSessionDuration(); + + // Assert + expect(duration.inSeconds, greaterThanOrEqualTo(1)); + }); + }); +} +``` + +### 4.5 运行测试 + +```bash +# 运行所有单元测试 +flutter test + +# 运行指定测试文件 +flutter test test/unit/core/updater/version_checker_test.dart + +# 运行测试并生成覆盖率报告 +flutter test --coverage + +# 查看覆盖率报告 (需要安装 lcov) +genhtml coverage/lcov.info -o coverage/html +open coverage/html/index.html +``` + +--- + +## 5. Mock 服务器搭建 + +### 5.1 使用 JSON Server (快速搭建) + +**安装**: +```bash +npm install -g json-server +``` + +**创建 db.json**: +```json +{ + "app": { + "version": { + "check": { + "code": 0, + "data": { + "hasUpdate": true, + "version": "1.1.0", + "versionCode": 2, + "downloadUrl": "http://192.168.1.100:3000/releases/app.apk", + "fileSize": 52428800, + "fileSizeFriendly": "50.0 MB", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "forceUpdate": false, + "updateLog": "1. 新功能\\n2. Bug修复", + "releaseDate": "2024-01-15T10:00:00Z" + } + } + } + }, + "analytics": { + "events": [] + }, + "presence": { + "heartbeat": { "code": 0, "message": "success" } + }, + "telemetry": { + "config": { + "code": 0, + "data": { + "enabled": true, + "samplingRate": 1.0, + "enabledEventTypes": ["page_view", "user_action", "error", "crash", "session", "presence"], + "maxQueueSize": 1000, + "uploadBatchSize": 20, + "uploadIntervalSeconds": 30, + "heartbeatIntervalSeconds": 60, + "sessionTimeoutMinutes": 30 + } + } + } +} +``` + +**启动服务器**: +```bash +json-server --watch db.json --port 3000 --host 0.0.0.0 +``` + +**修改应用配置**: +```dart +// lib/bootstrap.dart +const String _apiBaseUrl = 'http://192.168.1.100:3000'; // 你的电脑IP +``` + +### 5.2 使用 Python Flask (更灵活) + +```python +# mock_server.py + +from flask import Flask, request, jsonify +from datetime import datetime +import hashlib + +app = Flask(__name__) + +# 存储上报的事件 +events_log = [] + +@app.route('/api/app/version/check', methods=['GET']) +def check_version(): + current_version = request.args.get('currentVersion', '1.0.0') + current_code = int(request.args.get('currentVersionCode', '1')) + + # 模拟新版本 + if current_code < 2: + return jsonify({ + 'code': 0, + 'data': { + 'hasUpdate': True, + 'version': '1.1.0', + 'versionCode': 2, + 'downloadUrl': 'http://192.168.1.100:5000/releases/app.apk', + 'fileSize': 52428800, + 'fileSizeFriendly': '50.0 MB', + 'sha256': 'your-sha256-hash', + 'forceUpdate': False, + 'updateLog': '1. 新功能\n2. Bug修复', + 'releaseDate': datetime.now().isoformat() + } + }) + else: + return jsonify({ + 'code': 0, + 'data': {'hasUpdate': False} + }) + +@app.route('/api/v1/analytics/events', methods=['POST']) +def receive_events(): + data = request.json + events = data.get('events', []) + events_log.extend(events) + print(f"Received {len(events)} events. Total: {len(events_log)}") + for e in events: + print(f" - {e['name']} ({e['type']}) at {e['timestamp']}") + return jsonify({'code': 0, 'message': 'success'}) + +@app.route('/api/v1/presence/heartbeat', methods=['POST']) +def heartbeat(): + data = request.json + print(f"Heartbeat from {data.get('installId')} at {data.get('timestamp')}") + return jsonify({'code': 0, 'message': 'success'}) + +@app.route('/api/telemetry/config', methods=['GET']) +def telemetry_config(): + return jsonify({ + 'code': 0, + 'data': { + 'enabled': True, + 'samplingRate': 1.0, + 'enabledEventTypes': ['page_view', 'user_action', 'error', 'crash', 'session', 'presence'], + 'maxQueueSize': 1000, + 'uploadBatchSize': 20, + 'uploadIntervalSeconds': 30, + 'heartbeatIntervalSeconds': 60, + 'sessionTimeoutMinutes': 30 + } + }) + +@app.route('/releases/', methods=['GET']) +def download_apk(filename): + # 返回测试APK文件 + return app.send_static_file(filename) + +@app.route('/events', methods=['GET']) +def view_events(): + """调试用: 查看所有上报的事件""" + return jsonify({ + 'total': len(events_log), + 'events': events_log[-100:] # 返回最近100条 + }) + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=True) +``` + +**启动**: +```bash +pip install flask +python mock_server.py +``` + +### 5.3 网络调试工具 + +| 工具 | 用途 | +|-----|------| +| Charles | HTTPS 抓包、修改响应 | +| Fiddler | Windows 抓包工具 | +| mitmproxy | 命令行抓包工具 | +| Wireshark | 底层网络分析 | + +**Charles 配置要点**: +1. 手机和电脑在同一局域网 +2. 手机设置代理到电脑 IP:8888 +3. 安装 Charles 根证书到手机 +4. 使用 Map Remote 或 Rewrite 修改响应 + +--- + +## 6. 常见问题排查 + +### 6.1 APK 安装失败 + +**症状**: 下载成功但安装界面不弹出 + +**排查步骤**: +1. 检查 `REQUEST_INSTALL_PACKAGES` 权限 +2. 检查 FileProvider 配置 +3. 查看 Logcat 中的安装错误 + +```bash +adb logcat | grep -i "install" +``` + +### 6.2 心跳不发送 + +**症状**: 控制台没有心跳日志 + +**排查步骤**: +1. 确认遥测服务已初始化 +2. 确认应用在前台 +3. 检查远程配置是否禁用了 presence 类型 + +### 6.3 事件不上传 + +**症状**: 事件积累但不上传 + +**排查步骤**: +1. 检查网络连接 +2. 检查队列大小是否达到阈值 (默认10) +3. 检查远程配置 `enabled` 是否为 true +4. 查看上传错误日志 + +### 6.4 设备信息采集失败 + +**症状**: DeviceContext 部分字段为空 + +**排查步骤**: +1. 确认使用真机测试(模拟器部分信息不可用) +2. 检查 device_info_plus 插件版本 +3. 查看采集错误日志 + +--- + +## 附录: 测试数据示例 + +### 版本检测响应 + +```json +{ + "code": 0, + "data": { + "hasUpdate": true, + "version": "1.1.0", + "versionCode": 2, + "downloadUrl": "https://cdn.rwadurian.com/releases/app-v1.1.0.apk", + "fileSize": 52428800, + "fileSizeFriendly": "50.0 MB", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "forceUpdate": false, + "updateLog": "1. 新增挖矿动画\n2. 优化性能\n3. 修复已知问题", + "releaseDate": "2024-01-15T10:00:00Z" + } +} +``` + +### 遥测事件示例 + +```json +{ + "events": [ + { + "eventId": "550e8400-e29b-41d4-a716-446655440000", + "name": "session_start", + "type": "session", + "level": "info", + "installId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "deviceContextId": "ctx-xyz789", + "sessionId": "sess-abc123", + "userId": null, + "timestamp": "2024-01-15T10:00:00.000Z", + "properties": { + "trigger": "app_launch", + "is_first_launch": false + } + }, + { + "eventId": "550e8400-e29b-41d4-a716-446655440001", + "name": "page_view", + "type": "page_view", + "level": "info", + "installId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "deviceContextId": "ctx-xyz789", + "sessionId": "sess-abc123", + "userId": null, + "timestamp": "2024-01-15T10:00:01.000Z", + "properties": { + "page": "/ranking", + "referrer": "/splash" + } + } + ] +} +``` diff --git a/frontend/mobile-app/lib/bootstrap.dart b/frontend/mobile-app/lib/bootstrap.dart index 30987b37..64d3a5ff 100644 --- a/frontend/mobile-app/lib/bootstrap.dart +++ b/frontend/mobile-app/lib/bootstrap.dart @@ -6,6 +6,12 @@ import 'package:hive_flutter/hive_flutter.dart'; import 'core/storage/local_storage.dart'; import 'core/di/injection_container.dart'; import 'core/utils/logger.dart'; +import 'core/updater/update_service.dart'; +import 'core/updater/models/update_config.dart'; +import 'core/telemetry/telemetry_service.dart'; + +/// API 基础地址(正式环境应该从配置读取) +const String _apiBaseUrl = 'https://api.rwadurian.com'; Future bootstrap(FutureOr Function() builder) async { // Ensure Flutter bindings are initialized @@ -33,12 +39,30 @@ Future bootstrap(FutureOr Function() builder) async { // Initialize LocalStorage final localStorage = await LocalStorage.init(); + // Initialize UpdateService (自建服务器模式) + UpdateService().initialize( + UpdateConfig.selfHosted( + apiBaseUrl: _apiBaseUrl, + enabled: true, + checkIntervalSeconds: 86400, // 24小时 + ), + ); + // Create provider container with initialized dependencies final container = createProviderContainer(localStorage); // Run app with error handling FlutterError.onError = (details) { AppLogger.e('Flutter Error', details.exception, details.stack); + // 上报错误到遥测服务 + if (TelemetryService().isInitialized) { + TelemetryService().logError( + 'Flutter error', + error: details.exception, + stackTrace: details.stack, + extra: {'context': details.context?.toString()}, + ); + } }; runApp( @@ -48,3 +72,27 @@ Future bootstrap(FutureOr Function() builder) async { ), ); } + +/// 初始化遥测服务(需要 BuildContext,在首屏加载后调用) +Future initializeTelemetry(BuildContext context, {String? userId}) async { + if (TelemetryService().isInitialized) return; + + await TelemetryService().initialize( + apiBaseUrl: _apiBaseUrl, + context: context, + userId: userId, + configSyncInterval: const Duration(hours: 1), + ); +} + +/// 检查应用更新 +Future checkForAppUpdate(BuildContext context) async { + if (!UpdateService().isInitialized) return; + + // 延迟几秒后检查更新,避免干扰用户 + await Future.delayed(const Duration(seconds: 3)); + + if (!context.mounted) return; + + await UpdateService().checkForUpdate(context); +} diff --git a/frontend/mobile-app/lib/core/telemetry/collectors/device_info_collector.dart b/frontend/mobile-app/lib/core/telemetry/collectors/device_info_collector.dart new file mode 100644 index 00000000..927cd231 --- /dev/null +++ b/frontend/mobile-app/lib/core/telemetry/collectors/device_info_collector.dart @@ -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 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; +} diff --git a/frontend/mobile-app/lib/core/telemetry/models/device_context.dart b/frontend/mobile-app/lib/core/telemetry/models/device_context.dart new file mode 100644 index 00000000..79e5099c --- /dev/null +++ b/frontend/mobile-app/lib/core/telemetry/models/device_context.dart @@ -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 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 toJson() { + return { + 'widthPx': widthPx, + 'heightPx': heightPx, + 'density': density, + 'widthDp': widthDp, + 'heightDp': heightDp, + 'hasNotch': hasNotch, + }; + } + + @override + List 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 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), + 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 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 get props => [ + platform, + brand, + model, + manufacturer, + isPhysicalDevice, + osVersion, + sdkInt, + androidId, + screen, + appName, + packageName, + appVersion, + buildNumber, + buildMode, + locale, + timezone, + isDarkMode, + networkType, + collectedAt, + ]; +} diff --git a/frontend/mobile-app/lib/core/telemetry/models/telemetry_config.dart b/frontend/mobile-app/lib/core/telemetry/models/telemetry_config.dart new file mode 100644 index 00000000..4e0af299 --- /dev/null +++ b/frontend/mobile-app/lib/core/telemetry/models/telemetry_config.dart @@ -0,0 +1,151 @@ +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 disabledEvents = []; + + // 配置版本 + String configVersion = '1.0.0'; + + // 用户是否同意(可选,用于隐私合规) + bool userOptIn = true; + + // 心跳/在线状态配置 + PresenceConfig? presenceConfig; + + static final TelemetryConfig _instance = TelemetryConfig._(); + TelemetryConfig._(); + factory TelemetryConfig() => _instance; + + /// 从后端同步配置 + Future syncFromRemote(String apiBaseUrl) async { + try { + final dio = Dio(BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + )); + final response = await dio.get('$apiBaseUrl/api/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.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 _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 _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 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 loadUserOptIn() async { + final prefs = await SharedPreferences.getInstance(); + userOptIn = prefs.getBool('telemetry_user_opt_in') ?? true; + } +} diff --git a/frontend/mobile-app/lib/core/telemetry/models/telemetry_event.dart b/frontend/mobile-app/lib/core/telemetry/models/telemetry_event.dart new file mode 100644 index 00000000..55a9cb44 --- /dev/null +++ b/frontend/mobile-app/lib/core/telemetry/models/telemetry_event.dart @@ -0,0 +1,159 @@ +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? 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 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?, + 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, + ); + } + + Map 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, + }; + } + + TelemetryEvent copyWith({ + String? eventId, + EventType? type, + EventLevel? level, + String? name, + Map? 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 get props => [ + eventId, + type, + level, + name, + properties, + timestamp, + userId, + sessionId, + installId, + deviceContextId, + ]; +} diff --git a/frontend/mobile-app/lib/core/telemetry/presence/heartbeat_service.dart b/frontend/mobile-app/lib/core/telemetry/presence/heartbeat_service.dart new file mode 100644 index 00000000..068fb1c1 --- /dev/null +++ b/frontend/mobile-app/lib/core/telemetry/presence/heartbeat_service.dart @@ -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 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 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 _sendHeartbeat() async { + // 检查是否需要登录 + if (_config.requiresAuth && (getUserId?.call() == null)) { + debugPrint('💓 [Heartbeat] Skipped: user not logged in'); + return; + } + + try { + final response = await _dio.post( + '/api/v1/presence/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 forceHeartbeat() async { + await _sendHeartbeat(); + } +} diff --git a/frontend/mobile-app/lib/core/telemetry/presence/presence_config.dart b/frontend/mobile-app/lib/core/telemetry/presence/presence_config.dart new file mode 100644 index 00000000..767d49c9 --- /dev/null +++ b/frontend/mobile-app/lib/core/telemetry/presence/presence_config.dart @@ -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 json) { + return PresenceConfig( + heartbeatIntervalSeconds: json['heartbeat_interval_seconds'] ?? 60, + requiresAuth: json['requires_auth'] ?? true, + enabled: json['presence_enabled'] ?? json['enabled'] ?? true, + ); + } + + Map 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, + ); + } +} diff --git a/frontend/mobile-app/lib/core/telemetry/session/session_events.dart b/frontend/mobile-app/lib/core/telemetry/session/session_events.dart new file mode 100644 index 00000000..6faa9191 --- /dev/null +++ b/frontend/mobile-app/lib/core/telemetry/session/session_events.dart @@ -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, +} diff --git a/frontend/mobile-app/lib/core/telemetry/session/session_manager.dart b/frontend/mobile-app/lib/core/telemetry/session/session_manager.dart new file mode 100644 index 00000000..5970b86e --- /dev/null +++ b/frontend/mobile-app/lib/core/telemetry/session/session_manager.dart @@ -0,0 +1,155 @@ +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(); + } + + /// 处理进入后台 + void _handleBackground() { + if (_state == SessionState.background) return; + + _state = SessionState.background; + _endCurrentSession(); + } + + /// 开始新会话 + 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; + } +} diff --git a/frontend/mobile-app/lib/core/telemetry/storage/telemetry_storage.dart b/frontend/mobile-app/lib/core/telemetry/storage/telemetry_storage.dart new file mode 100644 index 00000000..f69681f3 --- /dev/null +++ b/frontend/mobile-app/lib/core/telemetry/storage/telemetry_storage.dart @@ -0,0 +1,123 @@ +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 init() async { + if (_isInitialized) return; + _prefs = await SharedPreferences.getInstance(); + _isInitialized = true; + } + + /// 保存设备上下文 + Future saveDeviceContext(Map context) async { + await _prefs.setString(_keyDeviceContext, jsonEncode(context)); + } + + /// 读取设备上下文 + Map? getDeviceContext() { + final str = _prefs.getString(_keyDeviceContext); + if (str == null) return null; + return jsonDecode(str) as Map; + } + + /// 保存 InstallId + Future saveInstallId(String installId) async { + await _prefs.setString(_keyInstallId, installId); + } + + /// 读取 InstallId + String? getInstallId() { + return _prefs.getString(_keyInstallId); + } + + /// 添加事件到队列 + Future 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 enqueueEvents(List events) async { + final queue = _getEventQueue(); + + for (var event in events) { + if (queue.length >= _maxQueueSize) break; + queue.add(event.toJson()); + } + + await _saveEventQueue(queue); + } + + /// 获取待上传的事件(最多N条) + List 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 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 clearEventQueue() async { + await _prefs.remove(_keyEventQueue); + } + + // 私有方法 + List> _getEventQueue() { + final str = _prefs.getString(_keyEventQueue); + if (str == null) return []; + + try { + final List list = jsonDecode(str); + return list.cast>(); + } catch (e) { + debugPrint('⚠️ Failed to parse event queue: $e'); + return []; + } + } + + Future _saveEventQueue(List> queue) async { + await _prefs.setString(_keyEventQueue, jsonEncode(queue)); + } +} diff --git a/frontend/mobile-app/lib/core/telemetry/telemetry_service.dart b/frontend/mobile-app/lib/core/telemetry/telemetry_service.dart new file mode 100644 index 00000000..eb234c50 --- /dev/null +++ b/frontend/mobile-app/lib/core/telemetry/telemetry_service.dart @@ -0,0 +1,348 @@ +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; + + /// API 基础地址 + late String _apiBaseUrl; + + bool _isInitialized = false; + bool get isInitialized => _isInitialized; + + /// 会话管理器 + late SessionManager _sessionManager; + + /// 心跳服务 + late HeartbeatService _heartbeatService; + + Timer? _configSyncTimer; + + /// 初始化(在main.dart中调用) + Future 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. 同步远程配置(首次) + await TelemetryConfig().syncFromRemote(apiBaseUrl); + + // 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 _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'); + } + + /// 获取认证头 + Map _getAuthHeaders() { + // TODO: 从 AuthService 获取 token + // final token = AuthService.instance.accessToken; + // if (token != null) { + // return {'Authorization': 'Bearer $token'}; + // } + return {}; + } + + /// 记录事件(核心方法) + void logEvent( + String eventName, { + EventType type = EventType.userAction, + EventLevel level = EventLevel.info, + Map? 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 event = TelemetryEvent( + eventId: const Uuid().v4(), + type: type, + level: level, + name: eventName, + properties: 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? extra}) { + logEvent( + 'page_view', + type: EventType.pageView, + properties: {'page': pageName, ...?extra}, + ); + } + + /// 记录用户行为 + void logUserAction(String action, {Map? properties}) { + logEvent( + action, + type: EventType.userAction, + properties: properties, + ); + } + + /// 记录错误 + void logError( + String errorMessage, { + Object? error, + StackTrace? stackTrace, + Map? 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? 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'); + } + + /// 设置用户是否同意数据收集 + Future 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退出前调用 + Future dispose() async { + _configSyncTimer?.cancel(); + _sessionManager.dispose(); + _heartbeatService.dispose(); + await _uploader.forceUploadAll(); + _isInitialized = false; + debugPrint('📊 TelemetryService disposed'); + } + + /// 重置实例 + static void reset() { + _instance = null; + } +} diff --git a/frontend/mobile-app/lib/core/telemetry/uploader/telemetry_uploader.dart b/frontend/mobile-app/lib/core/telemetry/uploader/telemetry_uploader.dart new file mode 100644 index 00000000..cd501345 --- /dev/null +++ b/frontend/mobile-app/lib/core/telemetry/uploader/telemetry_uploader.dart @@ -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 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 uploadIfNeeded({int batchSize = 20}) async { + if (_isUploading) return; + + final queueSize = storage.getQueueSize(); + if (queueSize < 10) return; // 少于10条不上传,等待积累 + + await uploadBatch(batchSize: batchSize); + } + + /// 立即上传一批 + Future uploadBatch({int batchSize = 20}) async { + if (_isUploading) return false; + + _isUploading = true; + try { + final events = storage.dequeueEvents(batchSize); + if (events.isEmpty) return true; + + // 调用后端API + final response = await _dio.post( + '/api/v1/analytics/events', + data: { + 'events': events.map((e) => e.toJson()).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 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'); + } + } +} diff --git a/frontend/mobile-app/lib/core/updater/apk_installer.dart b/frontend/mobile-app/lib/core/updater/apk_installer.dart new file mode 100644 index 00000000..73a20e5f --- /dev/null +++ b/frontend/mobile-app/lib/core/updater/apk_installer.dart @@ -0,0 +1,64 @@ +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; +import 'package:permission_handler/permission_handler.dart'; + +/// APK 安装器 +/// 负责调用原生代码安装 APK 文件 +class ApkInstaller { + static const MethodChannel _channel = + MethodChannel('com.rwadurian.app/apk_installer'); + + /// 安装 APK + static Future installApk(File apkFile) async { + try { + if (!await apkFile.exists()) { + debugPrint('APK file not found'); + return false; + } + + // Android 8.0+ 需要请求安装权限 + if (Platform.isAndroid) { + final hasPermission = await _requestInstallPermission(); + if (!hasPermission) { + debugPrint('Install permission denied'); + return false; + } + } + + debugPrint('Installing APK: ${apkFile.path}'); + final result = await _channel.invokeMethod('installApk', { + 'apkPath': apkFile.path, + }); + + debugPrint('Installation triggered: $result'); + return result == true; + } on PlatformException catch (e) { + debugPrint('Install failed (PlatformException): ${e.message}'); + return false; + } catch (e) { + debugPrint('Install failed: $e'); + return false; + } + } + + /// 请求安装权限(Android 8.0+) + static Future _requestInstallPermission() async { + if (await Permission.requestInstallPackages.isGranted) { + return true; + } + + final status = await Permission.requestInstallPackages.request(); + return status.isGranted; + } + + /// 检查是否有安装权限 + static Future hasInstallPermission() async { + return await Permission.requestInstallPackages.isGranted; + } + + /// 打开应用设置页面(用于手动授权) + static Future openAppSettings() async { + await openAppSettings(); + } +} diff --git a/frontend/mobile-app/lib/core/updater/app_market_detector.dart b/frontend/mobile-app/lib/core/updater/app_market_detector.dart new file mode 100644 index 00000000..4e056b64 --- /dev/null +++ b/frontend/mobile-app/lib/core/updater/app_market_detector.dart @@ -0,0 +1,141 @@ +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// 应用市场检测器 +/// 检测应用安装来源,决定升级策略 +class AppMarketDetector { + static const MethodChannel _channel = + MethodChannel('com.rwadurian.app/app_market'); + + /// 常见应用市场包名 + static const List _marketPackages = [ + 'com.android.vending', // Google Play + 'com.huawei.appmarket', // 华为应用市场 + 'com.xiaomi.market', // 小米应用商店 + 'com.oppo.market', // OPPO 软件商店 + 'com.bbk.appstore', // vivo 应用商店 + 'com.tencent.android.qqdownloader', // 应用宝 + 'com.qihoo.appstore', // 360 手机助手 + 'com.baidu.appsearch', // 百度手机助手 + 'com.wandoujia.phoenix2', // 豌豆荚 + 'com.dragon.android.pandaspace', // 91 助手 + 'com.sec.android.app.samsungapps', // 三星应用商店 + ]; + + /// 获取安装来源 + static Future getInstallerPackageName() async { + if (!Platform.isAndroid) return null; + + try { + final installer = + await _channel.invokeMethod('getInstallerPackageName'); + return installer; + } on PlatformException catch (e) { + debugPrint('Get installer failed (PlatformException): ${e.message}'); + return null; + } catch (e) { + debugPrint('Get installer failed: $e'); + return null; + } + } + + /// 判断是否来自应用市场 + static Future isFromAppMarket() async { + final installer = await getInstallerPackageName(); + + if (installer == null || installer.isEmpty) { + return false; // 直接安装(如 adb install) + } + + return _marketPackages.contains(installer); + } + + /// 判断是否来自 Google Play + static Future isFromGooglePlay() async { + final installer = await getInstallerPackageName(); + return installer == 'com.android.vending'; + } + + /// 判断是否来自国内应用市场 + static Future isFromChineseMarket() async { + final installer = await getInstallerPackageName(); + + if (installer == null || installer.isEmpty) { + return false; + } + + // 排除 Google Play + if (installer == 'com.android.vending') { + return false; + } + + return _marketPackages.contains(installer); + } + + /// 获取安装来源名称(用于显示) + static Future getInstallerName() async { + final installer = await getInstallerPackageName(); + + if (installer == null || installer.isEmpty) { + return '直接安装'; + } + + switch (installer) { + case 'com.android.vending': + return 'Google Play'; + case 'com.huawei.appmarket': + return '华为应用市场'; + case 'com.xiaomi.market': + return '小米应用商店'; + case 'com.oppo.market': + return 'OPPO 软件商店'; + case 'com.bbk.appstore': + return 'vivo 应用商店'; + case 'com.tencent.android.qqdownloader': + return '应用宝'; + case 'com.qihoo.appstore': + return '360 手机助手'; + case 'com.baidu.appsearch': + return '百度手机助手'; + case 'com.wandoujia.phoenix2': + return '豌豆荚'; + case 'com.sec.android.app.samsungapps': + return '三星应用商店'; + default: + return installer; + } + } + + /// 打开应用市场详情页 + static Future openAppMarketDetail(String packageName) async { + final marketUri = Uri.parse('market://details?id=$packageName'); + + if (await canLaunchUrl(marketUri)) { + await launchUrl(marketUri, mode: LaunchMode.externalApplication); + return true; + } else { + // 回退到 Google Play 网页版 + final webUri = + Uri.parse('https://play.google.com/store/apps/details?id=$packageName'); + if (await canLaunchUrl(webUri)) { + await launchUrl(webUri, mode: LaunchMode.externalApplication); + return true; + } + return false; + } + } + + /// 打开 Google Play 详情页 + static Future openGooglePlay(String packageName) async { + final uri = Uri.parse( + 'https://play.google.com/store/apps/details?id=$packageName'); + + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + return true; + } + return false; + } +} diff --git a/frontend/mobile-app/lib/core/updater/channels/google_play_updater.dart b/frontend/mobile-app/lib/core/updater/channels/google_play_updater.dart new file mode 100644 index 00000000..63f05cac --- /dev/null +++ b/frontend/mobile-app/lib/core/updater/channels/google_play_updater.dart @@ -0,0 +1,85 @@ +import 'package:in_app_update/in_app_update.dart'; +import 'package:flutter/foundation.dart'; + +/// Google Play 应用内更新类型 +enum GooglePlayUpdateType { + /// 灵活更新:后台下载,可继续使用 + flexible, + + /// 强制更新:阻塞式,必须更新 + immediate, +} + +/// Google Play 应用内更新器 +class GooglePlayUpdater { + /// 检查是否有更新可用 + static Future checkForUpdate() async { + try { + final updateInfo = await InAppUpdate.checkForUpdate(); + return updateInfo.updateAvailability == + UpdateAvailability.updateAvailable; + } catch (e) { + debugPrint('Check update failed: $e'); + return false; + } + } + + /// 灵活更新 + /// 用户可以在后台下载,继续使用应用 + static Future performFlexibleUpdate() async { + try { + final updateInfo = await InAppUpdate.checkForUpdate(); + + if (updateInfo.updateAvailability == UpdateAvailability.updateAvailable) { + if (updateInfo.flexibleUpdateAllowed) { + await InAppUpdate.startFlexibleUpdate(); + + InAppUpdate.completeFlexibleUpdate().then((_) { + debugPrint('Update completed, app will restart'); + }).catchError((e) { + debugPrint('Update failed: $e'); + }); + } else { + debugPrint('Flexible update not allowed'); + } + } + } catch (e) { + debugPrint('Flexible update error: $e'); + } + } + + /// 强制更新 + /// 阻塞式更新,用户必须更新才能继续使用 + static Future performImmediateUpdate() async { + try { + final updateInfo = await InAppUpdate.checkForUpdate(); + + if (updateInfo.updateAvailability == UpdateAvailability.updateAvailable) { + if (updateInfo.immediateUpdateAllowed) { + await InAppUpdate.performImmediateUpdate(); + } else { + debugPrint('Immediate update not allowed'); + } + } + } catch (e) { + debugPrint('Immediate update error: $e'); + } + } + + /// 智能更新策略 + /// 根据版本差异决定更新方式 + static Future smartUpdate({ + required int currentVersion, + required int latestVersion, + }) async { + final versionDiff = latestVersion - currentVersion; + + if (versionDiff >= 10) { + // 版本差异大,强制更新 + await performImmediateUpdate(); + } else { + // 版本差异小,灵活更新 + await performFlexibleUpdate(); + } + } +} diff --git a/frontend/mobile-app/lib/core/updater/channels/self_hosted_updater.dart b/frontend/mobile-app/lib/core/updater/channels/self_hosted_updater.dart new file mode 100644 index 00000000..560bd2df --- /dev/null +++ b/frontend/mobile-app/lib/core/updater/channels/self_hosted_updater.dart @@ -0,0 +1,427 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import '../version_checker.dart'; +import '../download_manager.dart'; +import '../apk_installer.dart'; +import '../app_market_detector.dart'; +import '../models/version_info.dart'; + +/// 自建服务器更新器 +/// 负责从自建服务器下载 APK 并安装 +class SelfHostedUpdater { + final VersionChecker versionChecker; + final DownloadManager downloadManager; + + SelfHostedUpdater({ + required String apiBaseUrl, + }) : versionChecker = VersionChecker(apiBaseUrl: apiBaseUrl), + downloadManager = DownloadManager(); + + /// 检查并提示更新 + Future checkAndPromptUpdate(BuildContext context) async { + final versionInfo = await versionChecker.checkForUpdate(); + + if (versionInfo == null) { + debugPrint('Already latest version'); + return; + } + + if (!context.mounted) return; + + // 检测安装来源 + final isFromMarket = await AppMarketDetector.isFromAppMarket(); + + if (isFromMarket) { + _showMarketUpdateDialog(context, versionInfo); + } else { + _showSelfHostedUpdateDialog(context, versionInfo); + } + } + + /// 静默检查更新(不显示对话框) + Future silentCheckUpdate() async { + return await versionChecker.checkForUpdate(); + } + + /// 应用市场更新提示 + void _showMarketUpdateDialog(BuildContext context, VersionInfo versionInfo) { + showDialog( + context: context, + barrierDismissible: !versionInfo.forceUpdate, + builder: (context) => PopScope( + canPop: !versionInfo.forceUpdate, + child: AlertDialog( + title: Text( + versionInfo.forceUpdate ? '发现重要更新' : '发现新版本', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF292524), + ), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '最新版本: ${versionInfo.version}', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF57534E), + ), + ), + if (versionInfo.updateLog != null) ...[ + SizedBox(height: 16.h), + Text( + '更新内容:', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF292524), + ), + ), + SizedBox(height: 8.h), + Text( + versionInfo.updateLog!, + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF57534E), + ), + ), + ], + SizedBox(height: 16.h), + Text( + '检测到您的应用来自应用市场,建议前往应用市场更新以获得最佳体验。', + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFFA8A29E), + ), + ), + ], + ), + ), + actions: [ + if (!versionInfo.forceUpdate) + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + '稍后', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF78716C), + ), + ), + ), + ElevatedButton( + onPressed: () async { + Navigator.pop(context); + final packageInfo = await versionChecker.getCurrentVersion(); + await AppMarketDetector.openAppMarketDetail( + packageInfo.packageName); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFD4A84B), + foregroundColor: Colors.white, + ), + child: Text( + '前往应用市场', + style: TextStyle(fontSize: 14.sp), + ), + ), + ], + ), + ), + ); + } + + /// 自建更新对话框 + void _showSelfHostedUpdateDialog( + BuildContext context, VersionInfo versionInfo) { + showDialog( + context: context, + barrierDismissible: !versionInfo.forceUpdate, + builder: (context) => PopScope( + canPop: !versionInfo.forceUpdate, + child: AlertDialog( + title: Text( + versionInfo.forceUpdate ? '发现重要更新' : '发现新版本', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF292524), + ), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '最新版本: ${versionInfo.version}', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF57534E), + ), + ), + SizedBox(height: 8.h), + Text( + '文件大小: ${versionInfo.fileSizeFriendly}', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF57534E), + ), + ), + if (versionInfo.updateLog != null) ...[ + SizedBox(height: 16.h), + Text( + '更新内容:', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF292524), + ), + ), + SizedBox(height: 8.h), + Text( + versionInfo.updateLog!, + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF57534E), + ), + ), + ], + ], + ), + ), + actions: [ + if (!versionInfo.forceUpdate) + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + '稍后', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF78716C), + ), + ), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + _startUpdate(context, versionInfo); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFD4A84B), + foregroundColor: Colors.white, + ), + child: Text( + '立即更新', + style: TextStyle(fontSize: 14.sp), + ), + ), + ], + ), + ), + ); + } + + /// 开始下载并安装 + Future _startUpdate( + BuildContext context, VersionInfo versionInfo) async { + if (!context.mounted) return; + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => _DownloadProgressDialog( + versionInfo: versionInfo, + downloadManager: downloadManager, + ), + ); + } + + /// 清理下载的 APK + Future cleanup() async { + await downloadManager.cleanupDownloadedApk(); + } +} + +/// 下载进度对话框 +class _DownloadProgressDialog extends StatefulWidget { + final VersionInfo versionInfo; + final DownloadManager downloadManager; + + const _DownloadProgressDialog({ + required this.versionInfo, + required this.downloadManager, + }); + + @override + State<_DownloadProgressDialog> createState() => + _DownloadProgressDialogState(); +} + +class _DownloadProgressDialogState extends State<_DownloadProgressDialog> { + double _progress = 0.0; + String _statusText = '准备下载...'; + bool _isDownloading = true; + bool _hasError = false; + + @override + void initState() { + super.initState(); + _startDownload(); + } + + Future _startDownload() async { + setState(() { + _statusText = '正在下载...'; + _isDownloading = true; + _hasError = false; + }); + + final apkFile = await widget.downloadManager.downloadApk( + url: widget.versionInfo.downloadUrl, + sha256Expected: widget.versionInfo.sha256, + onProgress: (received, total) { + if (mounted) { + setState(() { + _progress = received / total; + final receivedMB = (received / 1024 / 1024).toStringAsFixed(1); + final totalMB = (total / 1024 / 1024).toStringAsFixed(1); + _statusText = '正在下载... $receivedMB MB / $totalMB MB'; + }); + } + }, + ); + + if (!mounted) return; + + if (apkFile == null) { + setState(() { + _statusText = widget.downloadManager.status == DownloadStatus.cancelled + ? '下载已取消' + : '下载失败,请稍后重试'; + _isDownloading = false; + _hasError = true; + }); + return; + } + + setState(() { + _statusText = '下载完成,准备安装...'; + }); + + await Future.delayed(const Duration(milliseconds: 500)); + + final installed = await ApkInstaller.installApk(apkFile); + + if (!mounted) return; + + if (installed) { + Navigator.pop(context); + } else { + setState(() { + _statusText = '安装失败,请手动安装'; + _isDownloading = false; + _hasError = true; + }); + } + } + + void _cancelDownload() { + widget.downloadManager.cancelDownload(); + } + + void _retryDownload() { + widget.downloadManager.reset(); + _startDownload(); + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: !_isDownloading, + child: AlertDialog( + title: Text( + '正在更新', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF292524), + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + LinearProgressIndicator( + value: _progress, + backgroundColor: const Color(0xFFE7E5E4), + valueColor: AlwaysStoppedAnimation( + _hasError ? const Color(0xFFEF4444) : const Color(0xFFD4A84B), + ), + ), + SizedBox(height: 16.h), + Text( + _statusText, + style: TextStyle( + fontSize: 14.sp, + color: _hasError + ? const Color(0xFFEF4444) + : const Color(0xFF57534E), + ), + ), + if (_isDownloading) ...[ + SizedBox(height: 8.h), + Text( + '${(_progress * 100).toStringAsFixed(0)}%', + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w700, + color: const Color(0xFFD4A84B), + ), + ), + ], + ], + ), + actions: [ + if (_isDownloading) + TextButton( + onPressed: _cancelDownload, + child: Text( + '取消', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF78716C), + ), + ), + ), + if (!_isDownloading && _hasError) ...[ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + '关闭', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF78716C), + ), + ), + ), + ElevatedButton( + onPressed: _retryDownload, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFD4A84B), + foregroundColor: Colors.white, + ), + child: Text( + '重试', + style: TextStyle(fontSize: 14.sp), + ), + ), + ], + ], + ), + ); + } +} diff --git a/frontend/mobile-app/lib/core/updater/download_manager.dart b/frontend/mobile-app/lib/core/updater/download_manager.dart new file mode 100644 index 00000000..18fdd5a2 --- /dev/null +++ b/frontend/mobile-app/lib/core/updater/download_manager.dart @@ -0,0 +1,152 @@ +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; + +/// 下载状态 +enum DownloadStatus { + idle, + downloading, + verifying, + completed, + failed, + cancelled, +} + +/// 下载进度回调 +typedef DownloadProgressCallback = void Function(int received, int total); + +/// 下载管理器 +/// 负责下载 APK 文件并验证完整性 +class DownloadManager { + final Dio _dio = Dio(); + CancelToken? _cancelToken; + DownloadStatus _status = DownloadStatus.idle; + + /// 当前下载状态 + DownloadStatus get status => _status; + + /// 下载 APK 文件 + /// [url] 下载地址(必须是 HTTPS) + /// [sha256Expected] SHA-256 校验值 + /// [onProgress] 下载进度回调 (已下载字节, 总字节) + Future downloadApk({ + required String url, + required String sha256Expected, + DownloadProgressCallback? onProgress, + }) async { + try { + // 强制 HTTPS + if (!url.startsWith('https://')) { + debugPrint('Download URL must use HTTPS'); + _status = DownloadStatus.failed; + return null; + } + + _status = DownloadStatus.downloading; + _cancelToken = CancelToken(); + + // 使用应用专属目录(无需额外权限) + final dir = await getApplicationDocumentsDirectory(); + final savePath = '${dir.path}/app_update.apk'; + final file = File(savePath); + + // 如果已存在则删除 + if (await file.exists()) { + await file.delete(); + } + + debugPrint('Downloading APK to: $savePath'); + + await _dio.download( + url, + savePath, + cancelToken: _cancelToken, + onReceiveProgress: (received, total) { + if (total != -1) { + final progress = (received / total * 100).toStringAsFixed(0); + debugPrint('Download progress: $progress%'); + onProgress?.call(received, total); + } + }, + options: Options( + receiveTimeout: const Duration(minutes: 10), + sendTimeout: const Duration(minutes: 10), + ), + ); + + debugPrint('Download completed'); + _status = DownloadStatus.verifying; + + // 校验 SHA-256 + final isValid = await _verifySha256(file, sha256Expected); + if (!isValid) { + debugPrint('SHA-256 verification failed'); + await file.delete(); + _status = DownloadStatus.failed; + return null; + } + + debugPrint('SHA-256 verified'); + _status = DownloadStatus.completed; + return file; + } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) { + debugPrint('Download cancelled'); + _status = DownloadStatus.cancelled; + } else { + debugPrint('Download failed: $e'); + _status = DownloadStatus.failed; + } + return null; + } catch (e) { + debugPrint('Download failed: $e'); + _status = DownloadStatus.failed; + return null; + } + } + + /// 取消下载 + void cancelDownload() { + _cancelToken?.cancel('User cancelled download'); + _status = DownloadStatus.cancelled; + } + + /// 重置状态 + void reset() { + _cancelToken = null; + _status = DownloadStatus.idle; + } + + /// 校验文件 SHA-256 + Future _verifySha256(File file, String expectedSha256) async { + try { + final bytes = await file.readAsBytes(); + final digest = sha256.convert(bytes); + final actualSha256 = digest.toString(); + + debugPrint('Expected SHA-256: $expectedSha256'); + debugPrint('Actual SHA-256: $actualSha256'); + + return actualSha256.toLowerCase() == expectedSha256.toLowerCase(); + } catch (e) { + debugPrint('SHA-256 verification error: $e'); + return false; + } + } + + /// 删除已下载的 APK 文件 + Future cleanupDownloadedApk() async { + try { + final dir = await getApplicationDocumentsDirectory(); + final file = File('${dir.path}/app_update.apk'); + if (await file.exists()) { + await file.delete(); + debugPrint('Cleaned up downloaded APK'); + } + } catch (e) { + debugPrint('Cleanup failed: $e'); + } + } +} diff --git a/frontend/mobile-app/lib/core/updater/models/update_config.dart b/frontend/mobile-app/lib/core/updater/models/update_config.dart new file mode 100644 index 00000000..6c5f39b6 --- /dev/null +++ b/frontend/mobile-app/lib/core/updater/models/update_config.dart @@ -0,0 +1,56 @@ +/// 更新渠道类型 +enum UpdateChannel { + /// Google Play 应用内更新 + googlePlay, + + /// 自建服务器 APK 升级 + selfHosted, +} + +/// 更新配置 +class UpdateConfig { + /// 更新渠道 + final UpdateChannel channel; + + /// API 基础地址 (selfHosted 模式必需) + final String? apiBaseUrl; + + /// 是否启用更新检测 + final bool enabled; + + /// 检查更新间隔(秒) + final int checkIntervalSeconds; + + const UpdateConfig({ + required this.channel, + this.apiBaseUrl, + this.enabled = true, + this.checkIntervalSeconds = 86400, // 默认24小时 + }); + + /// 默认配置 - 自建服务器模式 + static UpdateConfig selfHosted({ + required String apiBaseUrl, + bool enabled = true, + int checkIntervalSeconds = 86400, + }) { + return UpdateConfig( + channel: UpdateChannel.selfHosted, + apiBaseUrl: apiBaseUrl, + enabled: enabled, + checkIntervalSeconds: checkIntervalSeconds, + ); + } + + /// 默认配置 - Google Play 模式 + static UpdateConfig googlePlay({ + bool enabled = true, + int checkIntervalSeconds = 86400, + }) { + return UpdateConfig( + channel: UpdateChannel.googlePlay, + enabled: enabled, + checkIntervalSeconds: checkIntervalSeconds, + ); + } +} diff --git a/frontend/mobile-app/lib/core/updater/models/version_info.dart b/frontend/mobile-app/lib/core/updater/models/version_info.dart new file mode 100644 index 00000000..dcd6fe23 --- /dev/null +++ b/frontend/mobile-app/lib/core/updater/models/version_info.dart @@ -0,0 +1,82 @@ +import 'package:equatable/equatable.dart'; + +/// 版本信息模型 +class VersionInfo extends Equatable { + /// 版本号: "1.2.0" + final String version; + + /// 版本代码: 120 + final int versionCode; + + /// APK 下载地址 + final String downloadUrl; + + /// 文件大小(字节) + final int fileSize; + + /// 友好显示: "25.6 MB" + final String fileSizeFriendly; + + /// SHA-256 校验 + final String sha256; + + /// 是否强制更新 + final bool forceUpdate; + + /// 更新日志 + final String? updateLog; + + /// 发布时间 + final DateTime releaseDate; + + const VersionInfo({ + required this.version, + required this.versionCode, + required this.downloadUrl, + required this.fileSize, + required this.fileSizeFriendly, + required this.sha256, + this.forceUpdate = false, + this.updateLog, + required this.releaseDate, + }); + + factory VersionInfo.fromJson(Map json) { + return VersionInfo( + version: json['version'] as String, + versionCode: json['versionCode'] as int, + downloadUrl: json['downloadUrl'] as String, + fileSize: json['fileSize'] as int, + fileSizeFriendly: json['fileSizeFriendly'] as String, + sha256: json['sha256'] as String, + forceUpdate: json['forceUpdate'] as bool? ?? false, + updateLog: json['updateLog'] as String?, + releaseDate: DateTime.parse(json['releaseDate'] as String), + ); + } + + Map toJson() { + return { + 'version': version, + 'versionCode': versionCode, + 'downloadUrl': downloadUrl, + 'fileSize': fileSize, + 'fileSizeFriendly': fileSizeFriendly, + 'sha256': sha256, + 'forceUpdate': forceUpdate, + 'updateLog': updateLog, + 'releaseDate': releaseDate.toIso8601String(), + }; + } + + @override + List get props => [ + version, + versionCode, + downloadUrl, + fileSize, + sha256, + forceUpdate, + releaseDate, + ]; +} diff --git a/frontend/mobile-app/lib/core/updater/update_service.dart b/frontend/mobile-app/lib/core/updater/update_service.dart new file mode 100644 index 00000000..ab25ad32 --- /dev/null +++ b/frontend/mobile-app/lib/core/updater/update_service.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'channels/google_play_updater.dart'; +import 'channels/self_hosted_updater.dart'; +import 'models/update_config.dart'; +import 'models/version_info.dart'; + +/// 统一升级服务 +/// 根据配置自动选择合适的升级渠道 +class UpdateService { + static UpdateService? _instance; + UpdateService._(); + + factory UpdateService() { + _instance ??= UpdateService._(); + return _instance!; + } + + late UpdateConfig _config; + SelfHostedUpdater? _selfHostedUpdater; + bool _isInitialized = false; + + /// 是否已初始化 + bool get isInitialized => _isInitialized; + + /// 当前配置 + UpdateConfig get config => _config; + + /// 初始化 + void initialize(UpdateConfig config) { + _config = config; + + if (config.channel == UpdateChannel.selfHosted) { + if (config.apiBaseUrl == null) { + throw ArgumentError('apiBaseUrl is required for self-hosted channel'); + } + _selfHostedUpdater = SelfHostedUpdater(apiBaseUrl: config.apiBaseUrl!); + } + + _isInitialized = true; + debugPrint('UpdateService initialized with channel: ${_config.channel}'); + } + + /// 更新配置 + void updateConfig(UpdateConfig config) { + _config = config; + + if (config.channel == UpdateChannel.selfHosted && config.apiBaseUrl != null) { + _selfHostedUpdater = SelfHostedUpdater(apiBaseUrl: config.apiBaseUrl!); + } + + debugPrint('UpdateService config updated'); + } + + /// 检查更新(自动显示对话框) + Future checkForUpdate(BuildContext context) async { + if (!_isInitialized) { + debugPrint('UpdateService not initialized'); + return; + } + + if (!_config.enabled) { + debugPrint('Update check is disabled'); + return; + } + + switch (_config.channel) { + case UpdateChannel.googlePlay: + await _checkGooglePlayUpdate(context); + break; + case UpdateChannel.selfHosted: + await _selfHostedUpdater!.checkAndPromptUpdate(context); + break; + } + } + + /// 静默检查更新(不显示对话框) + Future silentCheck() async { + if (!_isInitialized || !_config.enabled) { + return null; + } + + if (_config.channel == UpdateChannel.selfHosted) { + return await _selfHostedUpdater?.silentCheckUpdate(); + } + + // Google Play 没有静默检查,返回 null + return null; + } + + /// Google Play 更新检查 + Future _checkGooglePlayUpdate(BuildContext context) async { + final hasUpdate = await GooglePlayUpdater.checkForUpdate(); + + if (!hasUpdate) { + debugPrint('No update available'); + return; + } + + if (!context.mounted) return; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + '发现新版本', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF292524), + ), + ), + content: Text( + '有新版本可用,是否立即更新?', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF57534E), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + '稍后', + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF78716C), + ), + ), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + GooglePlayUpdater.performFlexibleUpdate(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFD4A84B), + foregroundColor: Colors.white, + ), + child: Text( + '更新', + style: TextStyle(fontSize: 14.sp), + ), + ), + ], + ), + ); + } + + /// 手动检查更新(带加载提示) + Future manualCheckUpdate(BuildContext context) async { + if (!_isInitialized) { + debugPrint('UpdateService not initialized'); + return; + } + + // 显示加载中 + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const Center( + child: CircularProgressIndicator( + color: Color(0xFFD4A84B), + ), + ), + ); + + await Future.delayed(const Duration(seconds: 1)); + + if (!context.mounted) return; + Navigator.pop(context); + + // 检查是否有更新 + final versionInfo = await silentCheck(); + + if (!context.mounted) return; + + if (versionInfo == null) { + // 没有更新 + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '当前已是最新版本', + style: TextStyle(fontSize: 14.sp), + ), + backgroundColor: const Color(0xFF22C55E), + duration: const Duration(seconds: 2), + ), + ); + } else { + // 有更新,显示对话框 + await checkForUpdate(context); + } + } + + /// 执行强制更新 + Future performForceUpdate(BuildContext context) async { + if (!_isInitialized) return; + + switch (_config.channel) { + case UpdateChannel.googlePlay: + await GooglePlayUpdater.performImmediateUpdate(); + break; + case UpdateChannel.selfHosted: + await _selfHostedUpdater?.checkAndPromptUpdate(context); + break; + } + } + + /// 清理缓存的更新文件 + Future cleanup() async { + await _selfHostedUpdater?.cleanup(); + } + + /// 重置实例 + static void reset() { + _instance = null; + } +} diff --git a/frontend/mobile-app/lib/core/updater/version_checker.dart b/frontend/mobile-app/lib/core/updater/version_checker.dart new file mode 100644 index 00000000..74a4b056 --- /dev/null +++ b/frontend/mobile-app/lib/core/updater/version_checker.dart @@ -0,0 +1,94 @@ +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'models/version_info.dart'; + +/// 版本检测器 +/// 负责从服务器获取最新版本信息并与当前版本比较 +class VersionChecker { + final String apiBaseUrl; + final Dio _dio; + + VersionChecker({required this.apiBaseUrl}) + : _dio = Dio(BaseOptions( + baseUrl: apiBaseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + )); + + /// 获取当前版本信息 + Future getCurrentVersion() async { + return await PackageInfo.fromPlatform(); + } + + /// 从服务器获取最新版本信息 + Future fetchLatestVersion() async { + try { + final currentInfo = await getCurrentVersion(); + + final response = await _dio.get( + '/api/app/version/check', + queryParameters: { + 'platform': 'android', + 'current_version': currentInfo.version, + 'current_version_code': currentInfo.buildNumber, + }, + ); + + if (response.statusCode == 200 && response.data != null) { + // 检查是否需要更新 + final needUpdate = response.data['needUpdate'] as bool? ?? true; + if (!needUpdate) { + return null; + } + return VersionInfo.fromJson(response.data); + } + return null; + } catch (e) { + debugPrint('Fetch version failed: $e'); + return null; + } + } + + /// 检查是否有新版本 + Future checkForUpdate() async { + try { + final currentInfo = await getCurrentVersion(); + final latestInfo = await fetchLatestVersion(); + + if (latestInfo == null) return null; + + final currentCode = int.tryParse(currentInfo.buildNumber) ?? 0; + if (latestInfo.versionCode > currentCode) { + return latestInfo; + } + + return null; + } catch (e) { + debugPrint('Check update failed: $e'); + return null; + } + } + + /// 是否需要强制更新 + Future needForceUpdate() async { + final latestInfo = await checkForUpdate(); + return latestInfo?.forceUpdate ?? false; + } + + /// 获取版本差异 + Future getVersionDiff() async { + try { + final currentInfo = await getCurrentVersion(); + final latestInfo = await fetchLatestVersion(); + + if (latestInfo == null) return 0; + + final currentCode = int.tryParse(currentInfo.buildNumber) ?? 0; + return latestInfo.versionCode - currentCode; + } catch (e) { + debugPrint('Get version diff failed: $e'); + return 0; + } + } +} diff --git a/frontend/mobile-app/lib/features/auth/presentation/pages/splash_page.dart b/frontend/mobile-app/lib/features/auth/presentation/pages/splash_page.dart index ec74f147..ee0593aa 100644 --- a/frontend/mobile-app/lib/features/auth/presentation/pages/splash_page.dart +++ b/frontend/mobile-app/lib/features/auth/presentation/pages/splash_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/constants/app_constants.dart'; import '../../../../routes/route_paths.dart'; +import '../../../../bootstrap.dart'; import '../providers/auth_provider.dart'; /// 开屏页面 - 应用启动时显示的第一个页面 @@ -23,6 +24,9 @@ class _SplashPageState extends ConsumerState { /// 初始化应用并检查认证状态 Future _initializeApp() async { + // 初始化遥测服务(需要 BuildContext) + await initializeTelemetry(context); + // 等待开屏动画展示 await Future.delayed(AppConstants.splashDuration); @@ -33,15 +37,26 @@ class _SplashPageState extends ConsumerState { final authState = ref.read(authProvider); + // 根据认证状态决定跳转目标 + String targetRoute; if (authState.isWalletCreated) { // 已创建钱包,进入主页面(龙虎榜) - context.go(RoutePaths.ranking); + targetRoute = RoutePaths.ranking; } else if (authState.isFirstLaunch || !authState.hasSeenGuide) { // 首次打开或未看过向导,进入向导页 - context.go(RoutePaths.guide); + targetRoute = RoutePaths.guide; } else { // 已看过向导但未创建钱包,直接进入创建账户页面 - context.go(RoutePaths.onboarding); + targetRoute = RoutePaths.onboarding; + } + + // 跳转到目标页面 + context.go(targetRoute); + + // 延迟检查应用更新(跳转后执行,避免阻塞启动) + if (targetRoute == RoutePaths.ranking) { + // 只在进入主页面时检查更新 + checkForAppUpdate(context); } } diff --git a/frontend/mobile-app/pubspec.lock b/frontend/mobile-app/pubspec.lock index 61743c09..b09f2d26 100644 --- a/frontend/mobile-app/pubspec.lock +++ b/frontend/mobile-app/pubspec.lock @@ -234,7 +234,7 @@ packages: source: hosted version: "0.3.5+1" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -281,6 +281,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a" + url: "https://pub.dev" + source: hosted + version: "11.5.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" dio: dependency: "direct main" description: @@ -693,6 +709,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + in_app_update: + dependency: "direct main" + description: + name: in_app_update + sha256: "9924a3efe592e1c0ec89dda3683b3cfec3d4cd02d908e6de00c24b759038ddb1" + url: "https://pub.dev" + source: hosted + version: "4.2.5" intl: dependency: "direct main" description: @@ -901,6 +925,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -918,7 +958,7 @@ packages: source: hosted version: "1.1.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -1153,18 +1193,18 @@ packages: dependency: "direct main" description: name: share_plus - sha256: fb5319f3aab4c5dda5ebb92dca978179ba21f8c783ee4380910ef4c1c6824f51 + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da url: "https://pub.dev" source: hosted - version: "8.0.3" + version: "10.1.4" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: "251eb156a8b5fa9ce033747d73535bf53911071f8d3b6f4f0b578505ce0d4496" + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "5.0.2" shared_preferences: dependency: "direct main" description: @@ -1275,7 +1315,7 @@ packages: source: hosted version: "1.10.1" sqflite: - dependency: transitive + dependency: "direct main" description: name: sqflite sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 @@ -1526,10 +1566,10 @@ packages: dependency: transitive description: name: web - sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "1.1.1" web3dart: dependency: "direct main" description: @@ -1562,6 +1602,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" xdg_directories: dependency: transitive description: diff --git a/frontend/mobile-app/pubspec.yaml b/frontend/mobile-app/pubspec.yaml index 4b635326..244da4ef 100644 --- a/frontend/mobile-app/pubspec.yaml +++ b/frontend/mobile-app/pubspec.yaml @@ -52,11 +52,21 @@ dependencies: image_picker: ^1.0.7 permission_handler: ^11.3.1 url_launcher: ^6.2.6 - share_plus: ^8.0.3 + share_plus: ^10.0.0 # 生物识别 local_auth: ^2.2.0 + # APK升级 + package_info_plus: ^8.0.0 + in_app_update: ^4.2.2 + path_provider: ^2.1.0 + crypto: ^3.0.3 + + # 设备信息收集与遥测 + device_info_plus: ^11.0.0 + sqflite: ^2.3.0 + cupertino_icons: ^1.0.8 dev_dependencies: