generated from dellevin/template
加入用户统计信息
This commit is contained in:
@@ -27,6 +27,7 @@ class BackupService {
|
||||
final notes = await db.query('notes');
|
||||
final movieReviews = await db.query('movie_reviews');
|
||||
final moviePosters = await db.query('movie_posters');
|
||||
final tags = await db.query('tags');
|
||||
|
||||
// 收集所有图片路径
|
||||
final imagePaths = <String>{};
|
||||
@@ -99,6 +100,7 @@ class BackupService {
|
||||
'notes': notes,
|
||||
'movie_reviews': movieReviews,
|
||||
'movie_posters': moviePosters,
|
||||
'tags': tags,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -209,6 +211,7 @@ class BackupService {
|
||||
final notes = await db.query('notes');
|
||||
final movieReviews = await db.query('movie_reviews');
|
||||
final moviePosters = await db.query('movie_posters');
|
||||
final tags = await db.query('tags');
|
||||
|
||||
// 收集所有图片路径
|
||||
final imagePaths = <String>{};
|
||||
@@ -281,6 +284,7 @@ class BackupService {
|
||||
'notes': notes,
|
||||
'movie_reviews': movieReviews,
|
||||
'movie_posters': moviePosters,
|
||||
'tags': tags,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -424,6 +428,7 @@ class BackupService {
|
||||
await txn.delete('movies');
|
||||
await txn.delete('books');
|
||||
await txn.delete('notes');
|
||||
await txn.delete('tags');
|
||||
|
||||
// 导入影视数据(更新图片路径)
|
||||
if (data.containsKey('movies')) {
|
||||
@@ -472,6 +477,14 @@ class BackupService {
|
||||
await txn.insert('movie_posters', updatedMap);
|
||||
}
|
||||
}
|
||||
|
||||
// 导入标签数据
|
||||
if (data.containsKey('tags')) {
|
||||
final tags = data['tags'] as List<dynamic>;
|
||||
for (final tag in tags) {
|
||||
await txn.insert('tags', _convertToDbMap(tag));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 恢复用户个人信息
|
||||
@@ -514,6 +527,9 @@ class BackupService {
|
||||
if (data.containsKey('movie_posters')) {
|
||||
stats['海报'] = (data['movie_posters'] as List).length;
|
||||
}
|
||||
if (data.containsKey('tags')) {
|
||||
stats['标签'] = (data['tags'] as List).length;
|
||||
}
|
||||
if (imageCount > 0) {
|
||||
stats['图片'] = imageCount;
|
||||
}
|
||||
@@ -623,6 +639,14 @@ class BackupService {
|
||||
await txn.insert('movie_posters', updatedMap);
|
||||
}
|
||||
}
|
||||
|
||||
// 导入标签数据
|
||||
if (data.containsKey('tags')) {
|
||||
final tags = data['tags'] as List<dynamic>;
|
||||
for (final tag in tags) {
|
||||
await txn.insert('tags', _convertToDbMap(tag));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 恢复用户个人信息
|
||||
|
||||
@@ -102,6 +102,12 @@ class TagDao {
|
||||
await db.delete('tags', where: 'id = ?', whereArgs: [tagId]);
|
||||
}
|
||||
|
||||
/// 仅删除标签本身,不级联影响已有条目(标签名保留在条目上)
|
||||
Future<void> deleteTagOnly(String tagId) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.delete('tags', where: 'id = ?', whereArgs: [tagId]);
|
||||
}
|
||||
|
||||
/// 确保标签存在(用于替换操作)
|
||||
Future<void> _ensureTagExists(String name, String type) async {
|
||||
final db = await _dbHelper.database;
|
||||
|
||||
136
lib/utils/usage_stats_service.dart
Normal file
136
lib/utils/usage_stats_service.dart
Normal file
@@ -0,0 +1,136 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'user_prefs.dart';
|
||||
|
||||
/// 匿名用户统计服务(静默运行,对用户不可见)
|
||||
///
|
||||
/// App 启动后每 5 分钟向统计服务器发送匿名心跳。
|
||||
/// 统计数据在服务端管理后台查看,App 内无入口。
|
||||
class UsageStatsService with WidgetsBindingObserver {
|
||||
static final UsageStatsService instance = UsageStatsService._();
|
||||
UsageStatsService._();
|
||||
|
||||
final UserPrefs _prefs = UserPrefs();
|
||||
|
||||
/// 统计服务器地址,发布前替换为实际地址,置空则禁用
|
||||
static String serverUrl = 'http://192.168.31.48:5000';
|
||||
|
||||
Timer? _heartbeatTimer;
|
||||
bool _started = false;
|
||||
|
||||
static const _heartbeatInterval = Duration(minutes: 5);
|
||||
|
||||
/// 启动统计服务(App 启动时调用一次)
|
||||
Future<void> start() async {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
|
||||
// 未配置服务器地址则直接跳过
|
||||
if (serverUrl.isEmpty) return;
|
||||
|
||||
// 首次启动生成匿名设备ID
|
||||
await _ensureDeviceId();
|
||||
|
||||
// 注册生命周期监听
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
// 立即发送一次心跳
|
||||
await _sendHeartbeat();
|
||||
|
||||
// 启动定时心跳
|
||||
_startTimer();
|
||||
}
|
||||
|
||||
/// 停止统计服务
|
||||
Future<void> stop() async {
|
||||
if (!_started) return;
|
||||
_started = false;
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
}
|
||||
|
||||
/// 获取统计信息(总用户数 / 在线数)
|
||||
static Future<Map<String, dynamic>?> fetchStats() async {
|
||||
if (serverUrl.isEmpty) return null;
|
||||
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse('$serverUrl/api/stats'))
|
||||
.timeout(const Duration(seconds: 5));
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body) as Map<String, dynamic>;
|
||||
}
|
||||
} catch (_) {
|
||||
// 静默失败
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── 内部方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// 确保设备有匿名ID
|
||||
Future<void> _ensureDeviceId() async {
|
||||
if (_prefs.deviceId.isEmpty) {
|
||||
final id = _generateDeviceId();
|
||||
await _prefs.setDeviceId(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成匿名设备标识
|
||||
String _generateDeviceId() {
|
||||
final random = Random.secure();
|
||||
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
|
||||
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
return '${hex.substring(0, 8)}-'
|
||||
'${hex.substring(8, 12)}-'
|
||||
'${hex.substring(12, 16)}-'
|
||||
'${hex.substring(16, 20)}-'
|
||||
'${hex.substring(20, 32)}';
|
||||
}
|
||||
|
||||
/// 发送心跳
|
||||
Future<void> _sendHeartbeat() async {
|
||||
if (serverUrl.isEmpty) return;
|
||||
final deviceId = _prefs.deviceId;
|
||||
if (deviceId.isEmpty) return;
|
||||
|
||||
try {
|
||||
await http
|
||||
.post(
|
||||
Uri.parse('$serverUrl/api/heartbeat'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'device_hash': deviceId}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
} catch (_) {
|
||||
// 静默失败,不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动定时心跳
|
||||
void _startTimer() {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = Timer.periodic(_heartbeatInterval, (_) {
|
||||
_sendHeartbeat();
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 生命周期 ──────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
// 回到前台:立即发送心跳,恢复定时器
|
||||
_sendHeartbeat();
|
||||
_startTimer();
|
||||
} else if (state == AppLifecycleState.paused) {
|
||||
// 进入后台:停止定时器
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,4 +83,10 @@ class UserPrefs {
|
||||
/// 当前选中的应用图标名称(对应 assets/icon/ 下的文件名,不含扩展名)
|
||||
String get appIconName => prefs.getString('appIconName') ?? 'app_icon';
|
||||
Future<bool> setAppIconName(String value) => prefs.setString('appIconName', value);
|
||||
|
||||
// ========== 用户统计设置 ==========
|
||||
|
||||
/// 匿名设备标识(首次启动自动生成)
|
||||
String get deviceId => prefs.getString('deviceId') ?? '';
|
||||
Future<bool> setDeviceId(String value) => prefs.setString('deviceId', value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user