generated from dellevin/template
加入用户统计信息
This commit is contained in:
@@ -8,6 +8,7 @@ import 'utils/theme/app_theme.dart';
|
|||||||
import 'utils/app_router.dart';
|
import 'utils/app_router.dart';
|
||||||
import 'utils/user_prefs.dart';
|
import 'utils/user_prefs.dart';
|
||||||
import 'utils/sync/auto_backup_service.dart';
|
import 'utils/sync/auto_backup_service.dart';
|
||||||
|
import 'utils/usage_stats_service.dart';
|
||||||
import 'providers/app_provider.dart';
|
import 'providers/app_provider.dart';
|
||||||
import 'package:flutter/widget_previews.dart';
|
import 'package:flutter/widget_previews.dart';
|
||||||
|
|
||||||
@@ -30,6 +31,8 @@ void main() async {
|
|||||||
await appProvider.initDatabase();
|
await appProvider.initDatabase();
|
||||||
// 检查并恢复本地自动备份
|
// 检查并恢复本地自动备份
|
||||||
await _initAutoBackup();
|
await _initAutoBackup();
|
||||||
|
// 启动匿名用户统计(需配置服务器地址后生效)
|
||||||
|
await _initUsageStats();
|
||||||
runApp(MyApp(appProvider: appProvider));
|
runApp(MyApp(appProvider: appProvider));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +49,15 @@ Future<void> _initAutoBackup() async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化匿名用户统计
|
||||||
|
Future<void> _initUsageStats() async {
|
||||||
|
try {
|
||||||
|
await UsageStatsService.instance.start();
|
||||||
|
} catch (e) {
|
||||||
|
print('初始化用户统计失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class MyApp extends StatelessWidget {
|
class MyApp extends StatelessWidget {
|
||||||
final AppProvider appProvider;
|
final AppProvider appProvider;
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class _ProfilePageState extends State<ProfilePage> {
|
|||||||
_version = packageInfo.version;
|
_version = packageInfo.version;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 加载用户数据
|
/// 加载用户数据
|
||||||
Future<void> _loadUserData() async {
|
Future<void> _loadUserData() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
@@ -103,9 +103,9 @@ class _ProfilePageState extends State<ProfilePage> {
|
|||||||
|
|
||||||
// 数据统计
|
// 数据统计
|
||||||
_buildStatsSection(),
|
_buildStatsSection(),
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// 功能菜单
|
// 功能菜单
|
||||||
_buildMenuSection(),
|
_buildMenuSection(),
|
||||||
|
|
||||||
@@ -692,8 +692,6 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(height: 0.5, indent: 24, endIndent: 24),
|
|
||||||
|
|
||||||
// 使用说明
|
// 使用说明
|
||||||
_buildSectionHeader('帮助'),
|
_buildSectionHeader('帮助'),
|
||||||
_buildLinkItem(
|
_buildLinkItem(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class TagManagementPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _TagManagementPageState extends State<TagManagementPage> {
|
class _TagManagementPageState extends State<TagManagementPage> {
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
|
bool _isSyncing = false;
|
||||||
|
|
||||||
static const _tabTypes = ['movie_genre', 'book_genre', 'note_tag'];
|
static const _tabTypes = ['movie_genre', 'book_genre', 'note_tag'];
|
||||||
static const _typeLabels = ['影视类型', '书籍类型', '笔记标签'];
|
static const _typeLabels = ['影视类型', '书籍类型', '笔记标签'];
|
||||||
@@ -32,6 +33,20 @@ class _TagManagementPageState extends State<TagManagementPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _syncTags() async {
|
||||||
|
setState(() => _isSyncing = true);
|
||||||
|
try {
|
||||||
|
final provider = context.read<AppProvider>();
|
||||||
|
final count = await provider.syncTagsFromData();
|
||||||
|
if (mounted) {
|
||||||
|
ToastUtil.show(context, count > 0 ? '已同步 $count 个新标签' : '标签已是最新');
|
||||||
|
await _loadTags(_currentType);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isSyncing = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String get _currentType => _tabTypes[_currentIndex];
|
String get _currentType => _tabTypes[_currentIndex];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -40,6 +55,25 @@ class _TagManagementPageState extends State<TagManagementPage> {
|
|||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('标签管理'),
|
title: const Text('标签管理'),
|
||||||
|
actions: [
|
||||||
|
_isSyncing
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Color(0xFF1A1A1A),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.sync, size: 20),
|
||||||
|
tooltip: '从数据中同步标签',
|
||||||
|
onPressed: _syncTags,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -477,6 +511,15 @@ class _TagManagementPageState extends State<TagManagementPage> {
|
|||||||
groupValue: selectedAction,
|
groupValue: selectedAction,
|
||||||
onChanged: (v) => setDialogState(() => selectedAction = v),
|
onChanged: (v) => setDialogState(() => selectedAction = v),
|
||||||
title: '从所有条目中移除该标签',
|
title: '从所有条目中移除该标签',
|
||||||
|
subtitle: '标签将从影视/书籍/笔记中清除',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_buildDeleteOption(
|
||||||
|
value: 'deleteOnly',
|
||||||
|
groupValue: selectedAction,
|
||||||
|
onChanged: (v) => setDialogState(() => selectedAction = v),
|
||||||
|
title: '仅删除标签',
|
||||||
|
subtitle: '保留已有条目上的标签名',
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
_buildDeleteOption(
|
_buildDeleteOption(
|
||||||
@@ -573,6 +616,7 @@ class _TagManagementPageState extends State<TagManagementPage> {
|
|||||||
if (replacement == null) return;
|
if (replacement == null) return;
|
||||||
}
|
}
|
||||||
Navigator.pop(ctx, {
|
Navigator.pop(ctx, {
|
||||||
|
'action': selectedAction,
|
||||||
'replacement': replacement,
|
'replacement': replacement,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -595,11 +639,18 @@ class _TagManagementPageState extends State<TagManagementPage> {
|
|||||||
),
|
),
|
||||||
).then((result) async {
|
).then((result) async {
|
||||||
if (result == null) return;
|
if (result == null) return;
|
||||||
|
final action = result['action'] as String;
|
||||||
final replacement = result['replacement'] as String?;
|
final replacement = result['replacement'] as String?;
|
||||||
|
|
||||||
await context
|
if (action == 'deleteOnly') {
|
||||||
.read<AppProvider>()
|
await context
|
||||||
.deleteTag(tagId, type, replacementName: replacement);
|
.read<AppProvider>()
|
||||||
|
.deleteTagOnly(tagId, type);
|
||||||
|
} else {
|
||||||
|
await context
|
||||||
|
.read<AppProvider>()
|
||||||
|
.deleteTag(tagId, type, replacementName: replacement);
|
||||||
|
}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ToastUtil.show(context, '删除成功');
|
ToastUtil.show(context, '删除成功');
|
||||||
}
|
}
|
||||||
@@ -612,6 +663,7 @@ class _TagManagementPageState extends State<TagManagementPage> {
|
|||||||
required String? groupValue,
|
required String? groupValue,
|
||||||
required ValueChanged<String?> onChanged,
|
required ValueChanged<String?> onChanged,
|
||||||
required String title,
|
required String title,
|
||||||
|
String? subtitle,
|
||||||
}) {
|
}) {
|
||||||
final selected = value == groupValue;
|
final selected = value == groupValue;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
@@ -642,13 +694,32 @@ class _TagManagementPageState extends State<TagManagementPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Expanded(
|
||||||
title,
|
child: Column(
|
||||||
style: TextStyle(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
fontSize: 14,
|
mainAxisSize: MainAxisSize.min,
|
||||||
fontWeight: selected ? FontWeight.w500 : FontWeight.normal,
|
children: [
|
||||||
color:
|
Text(
|
||||||
selected ? const Color(0xFF1A1A1A) : const Color(0xFF666666),
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: selected ? FontWeight.w500 : FontWeight.normal,
|
||||||
|
color:
|
||||||
|
selected ? const Color(0xFF1A1A1A) : const Color(0xFF666666),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (subtitle != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Text(
|
||||||
|
subtitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Color(0xFFAAAAAA),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../utils/movie/movie_poster_dao.dart';
|
|||||||
import '../utils/book/book_review_dao.dart';
|
import '../utils/book/book_review_dao.dart';
|
||||||
import '../utils/book/book_excerpt_dao.dart';
|
import '../utils/book/book_excerpt_dao.dart';
|
||||||
import '../utils/tag/tag_dao.dart';
|
import '../utils/tag/tag_dao.dart';
|
||||||
|
import '../utils/database_helper.dart';
|
||||||
import '../utils/image_path_helper.dart';
|
import '../utils/image_path_helper.dart';
|
||||||
|
|
||||||
/// 应用全局状态管理
|
/// 应用全局状态管理
|
||||||
@@ -409,6 +410,64 @@ class AppProvider extends ChangeNotifier {
|
|||||||
await _reloadByTagType(type);
|
await _reloadByTagType(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 仅删除标签本身,不级联影响已有条目
|
||||||
|
Future<void> deleteTagOnly(String tagId, String type) async {
|
||||||
|
await _tagDao.deleteTagOnly(tagId);
|
||||||
|
await _reloadByTagType(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从影视/书籍/笔记数据中解析标签,同步到 tags 表
|
||||||
|
Future<int> syncTagsFromData() async {
|
||||||
|
final db = await DatabaseHelper.instance.database;
|
||||||
|
final now = DateTime.now().toIso8601String();
|
||||||
|
int counter = 0;
|
||||||
|
int added = 0;
|
||||||
|
|
||||||
|
Future<void> insertTag(String name, String type) async {
|
||||||
|
try {
|
||||||
|
await db.insert('tags', {
|
||||||
|
'id': 'tag_${DateTime.now().millisecondsSinceEpoch}_${counter++}',
|
||||||
|
'name': name,
|
||||||
|
'type': type,
|
||||||
|
'created_at': now,
|
||||||
|
});
|
||||||
|
added++;
|
||||||
|
} catch (_) {
|
||||||
|
// 忽略 UNIQUE 约束冲突(标签已存在)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 影视类型
|
||||||
|
final movies = await db.query('movies',
|
||||||
|
where: 'genres IS NOT NULL AND genres != ?', whereArgs: ['[]']);
|
||||||
|
for (final row in movies) {
|
||||||
|
for (final genre in Movie.parseStringList(row['genres'])) {
|
||||||
|
await insertTag(genre, 'movie_genre');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 书籍类型
|
||||||
|
final books = await db.query('books',
|
||||||
|
where: 'genres IS NOT NULL AND genres != ?', whereArgs: ['[]']);
|
||||||
|
for (final row in books) {
|
||||||
|
for (final genre in Movie.parseStringList(row['genres'])) {
|
||||||
|
await insertTag(genre, 'book_genre');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 笔记标签
|
||||||
|
final notes = await db.query('notes',
|
||||||
|
where: 'tags IS NOT NULL AND tags != ? AND tags != ?',
|
||||||
|
whereArgs: ['[]', '']);
|
||||||
|
for (final row in notes) {
|
||||||
|
for (final tag in Movie.parseStringList(row['tags'])) {
|
||||||
|
await insertTag(tag, 'note_tag');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _reloadByTagType(String type) async {
|
Future<void> _reloadByTagType(String type) async {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'movie_genre':
|
case 'movie_genre':
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class BackupService {
|
|||||||
final notes = await db.query('notes');
|
final notes = await db.query('notes');
|
||||||
final movieReviews = await db.query('movie_reviews');
|
final movieReviews = await db.query('movie_reviews');
|
||||||
final moviePosters = await db.query('movie_posters');
|
final moviePosters = await db.query('movie_posters');
|
||||||
|
final tags = await db.query('tags');
|
||||||
|
|
||||||
// 收集所有图片路径
|
// 收集所有图片路径
|
||||||
final imagePaths = <String>{};
|
final imagePaths = <String>{};
|
||||||
@@ -99,6 +100,7 @@ class BackupService {
|
|||||||
'notes': notes,
|
'notes': notes,
|
||||||
'movie_reviews': movieReviews,
|
'movie_reviews': movieReviews,
|
||||||
'movie_posters': moviePosters,
|
'movie_posters': moviePosters,
|
||||||
|
'tags': tags,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -209,6 +211,7 @@ class BackupService {
|
|||||||
final notes = await db.query('notes');
|
final notes = await db.query('notes');
|
||||||
final movieReviews = await db.query('movie_reviews');
|
final movieReviews = await db.query('movie_reviews');
|
||||||
final moviePosters = await db.query('movie_posters');
|
final moviePosters = await db.query('movie_posters');
|
||||||
|
final tags = await db.query('tags');
|
||||||
|
|
||||||
// 收集所有图片路径
|
// 收集所有图片路径
|
||||||
final imagePaths = <String>{};
|
final imagePaths = <String>{};
|
||||||
@@ -281,6 +284,7 @@ class BackupService {
|
|||||||
'notes': notes,
|
'notes': notes,
|
||||||
'movie_reviews': movieReviews,
|
'movie_reviews': movieReviews,
|
||||||
'movie_posters': moviePosters,
|
'movie_posters': moviePosters,
|
||||||
|
'tags': tags,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -424,6 +428,7 @@ class BackupService {
|
|||||||
await txn.delete('movies');
|
await txn.delete('movies');
|
||||||
await txn.delete('books');
|
await txn.delete('books');
|
||||||
await txn.delete('notes');
|
await txn.delete('notes');
|
||||||
|
await txn.delete('tags');
|
||||||
|
|
||||||
// 导入影视数据(更新图片路径)
|
// 导入影视数据(更新图片路径)
|
||||||
if (data.containsKey('movies')) {
|
if (data.containsKey('movies')) {
|
||||||
@@ -472,6 +477,14 @@ class BackupService {
|
|||||||
await txn.insert('movie_posters', updatedMap);
|
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')) {
|
if (data.containsKey('movie_posters')) {
|
||||||
stats['海报'] = (data['movie_posters'] as List).length;
|
stats['海报'] = (data['movie_posters'] as List).length;
|
||||||
}
|
}
|
||||||
|
if (data.containsKey('tags')) {
|
||||||
|
stats['标签'] = (data['tags'] as List).length;
|
||||||
|
}
|
||||||
if (imageCount > 0) {
|
if (imageCount > 0) {
|
||||||
stats['图片'] = imageCount;
|
stats['图片'] = imageCount;
|
||||||
}
|
}
|
||||||
@@ -623,6 +639,14 @@ class BackupService {
|
|||||||
await txn.insert('movie_posters', updatedMap);
|
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]);
|
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 {
|
Future<void> _ensureTagExists(String name, String type) async {
|
||||||
final db = await _dbHelper.database;
|
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/ 下的文件名,不含扩展名)
|
/// 当前选中的应用图标名称(对应 assets/icon/ 下的文件名,不含扩展名)
|
||||||
String get appIconName => prefs.getString('appIconName') ?? 'app_icon';
|
String get appIconName => prefs.getString('appIconName') ?? 'app_icon';
|
||||||
Future<bool> setAppIconName(String value) => prefs.setString('appIconName', value);
|
Future<bool> setAppIconName(String value) => prefs.setString('appIconName', value);
|
||||||
|
|
||||||
|
// ========== 用户统计设置 ==========
|
||||||
|
|
||||||
|
/// 匿名设备标识(首次启动自动生成)
|
||||||
|
String get deviceId => prefs.getString('deviceId') ?? '';
|
||||||
|
Future<bool> setDeviceId(String value) => prefs.setString('deviceId', value);
|
||||||
}
|
}
|
||||||
|
|||||||
65
server/README.md
Normal file
65
server/README.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# MookNote 用户统计服务
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
默认监听 `0.0.0.0:5000`,可通过环境变量 `PORT` 修改端口。
|
||||||
|
|
||||||
|
### 3. 生产环境部署
|
||||||
|
|
||||||
|
推荐使用 gunicorn:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install gunicorn
|
||||||
|
gunicorn -w 2 -b 0.0.0.0:5000 app:app
|
||||||
|
```
|
||||||
|
|
||||||
|
或使用 systemd 设为开机自启。
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### POST /api/heartbeat
|
||||||
|
心跳上报,App 启动时和每 5 分钟调用一次。
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
```json
|
||||||
|
{ "device_hash": "设备匿名标识" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/stats
|
||||||
|
获取统计数据。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{ "total_users": 10, "online_users": 3 }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `total_users`: 历史总设备数
|
||||||
|
- `online_users`: 最近 5 分钟内有心跳的设备数
|
||||||
|
|
||||||
|
## 数据存储
|
||||||
|
|
||||||
|
SQLite 数据库 `stats.db`,结构:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE devices (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
device_hash TEXT UNIQUE NOT NULL, -- SHA256 哈希后的设备标识
|
||||||
|
first_seen TEXT NOT NULL, -- 首次出现时间
|
||||||
|
last_seen TEXT NOT NULL -- 最后心跳时间
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
所有数据均为匿名,不包含任何设备原始信息。
|
||||||
104
server/app.py
Normal file
104
server/app.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"""
|
||||||
|
MookNote 用户统计服务
|
||||||
|
Flask + SQLite,匿名统计设备数和在线数
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
from flask import Flask, request, jsonify
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stats.db")
|
||||||
|
ONLINE_THRESHOLD_MINUTES = 5 # 超过此时间未心跳视为离线
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
device_hash TEXT UNIQUE NOT NULL,
|
||||||
|
first_seen TEXT NOT NULL,
|
||||||
|
last_seen TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_last_seen ON devices(last_seen)"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── API ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/heartbeat", methods=["POST"])
|
||||||
|
def heartbeat():
|
||||||
|
"""接收匿名心跳"""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
device_hash = data.get("device_hash", "").strip()
|
||||||
|
if not device_hash:
|
||||||
|
return jsonify({"error": "device_hash is required"}), 400
|
||||||
|
|
||||||
|
# 只存哈希,不存原始设备信息
|
||||||
|
h = hashlib.sha256(device_hash.encode()).hexdigest()
|
||||||
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM devices WHERE device_hash = ?", (h,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE devices SET last_seen = ? WHERE device_hash = ?",
|
||||||
|
(now_iso, h),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO devices (device_hash, first_seen, last_seen) VALUES (?, ?, ?)",
|
||||||
|
(h, now_iso, now_iso),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/stats", methods=["GET"])
|
||||||
|
def stats():
|
||||||
|
"""获取统计:总用户数和当前在线数"""
|
||||||
|
threshold = (
|
||||||
|
datetime.now(timezone.utc) - timedelta(minutes=ONLINE_THRESHOLD_MINUTES)
|
||||||
|
).isoformat()
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
total = conn.execute("SELECT COUNT(*) FROM devices").fetchone()[0]
|
||||||
|
online = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM devices WHERE last_seen >= ?", (threshold,)
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
return jsonify({"total_users": total, "online_users": online})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/", methods=["GET"])
|
||||||
|
def index():
|
||||||
|
return "MookNote Stats Server is running."
|
||||||
|
|
||||||
|
|
||||||
|
# ─── MAIN ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init_db()
|
||||||
|
port = int(os.environ.get("PORT", 5000))
|
||||||
|
app.run(host="0.0.0.0", port=port, debug=False)
|
||||||
1
server/requirements.txt
Normal file
1
server/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
flask==3.1.0
|
||||||
BIN
server/stats.db
Normal file
BIN
server/stats.db
Normal file
Binary file not shown.
Reference in New Issue
Block a user