本次更新涵盖多个功能模块的优化与新增: 1. 新增Wi-Fi直连配对方式与协作画布模块 2. 完成设备管理重命名API与前端适配 3. 优化日签卡片空数据保护与UI细节 4. 新增剪贴板工具与每日运势会话 5. 修复应用锁恢复、语音消息录制等已知问题 6. 完善文件传输统计与配对逻辑 7. 更新安卓权限配置与iOS隐私描述 8. 新增自动化测试脚本与文档 9. 清理旧版审计报告与测试文件
481 lines
13 KiB
Dart
481 lines
13 KiB
Dart
// ============================================================
|
|
// 闲言APP — 传输统计服务
|
|
// 创建时间: 2026-05-12
|
|
// 更新时间: 2026-05-12
|
|
// 作用: 传输数据聚合查询+每日快照写入 — 日/周/月维度统计
|
|
// 上次更新: v11.4.0 初始版本
|
|
// ============================================================
|
|
|
|
import 'package:drift/drift.dart' hide Column;
|
|
import 'package:intl/intl.dart';
|
|
import 'package:xianyan/core/storage/database/app_database.dart';
|
|
import 'package:xianyan/core/utils/logger.dart';
|
|
import 'package:xianyan/features/file_transfer/database/transfer_database.dart';
|
|
|
|
class DailyStats {
|
|
const DailyStats({
|
|
required this.date,
|
|
this.sentBytes = 0,
|
|
this.receivedBytes = 0,
|
|
this.fileCount = 0,
|
|
this.avgSpeed = 0.0,
|
|
this.successCount = 0,
|
|
this.failCount = 0,
|
|
});
|
|
|
|
final String date;
|
|
final int sentBytes;
|
|
final int receivedBytes;
|
|
int get totalBytes => sentBytes + receivedBytes;
|
|
final int fileCount;
|
|
final double avgSpeed;
|
|
final int successCount;
|
|
final int failCount;
|
|
int get totalCount => successCount + failCount;
|
|
double get successRate => totalCount > 0 ? successCount / totalCount : 0.0;
|
|
}
|
|
|
|
class DeviceRanking {
|
|
const DeviceRanking({
|
|
required this.peerId,
|
|
required this.peerAlias,
|
|
this.fileCount = 0,
|
|
this.totalBytes = 0,
|
|
this.avgSpeed = 0.0,
|
|
});
|
|
|
|
final String peerId;
|
|
final String peerAlias;
|
|
final int fileCount;
|
|
final int totalBytes;
|
|
final double avgSpeed;
|
|
}
|
|
|
|
class FileTypeDistribution {
|
|
const FileTypeDistribution({
|
|
required this.category,
|
|
this.fileCount = 0,
|
|
this.totalBytes = 0,
|
|
});
|
|
|
|
final String category;
|
|
final int fileCount;
|
|
final int totalBytes;
|
|
}
|
|
|
|
class TransferQuality {
|
|
const TransferQuality({
|
|
this.totalTasks = 0,
|
|
this.successCount = 0,
|
|
this.failCount = 0,
|
|
this.avgSpeed = 0.0,
|
|
this.maxSpeed = 0.0,
|
|
this.retryCount = 0,
|
|
});
|
|
|
|
final int totalTasks;
|
|
final int successCount;
|
|
final int failCount;
|
|
final double avgSpeed;
|
|
final double maxSpeed;
|
|
final int retryCount;
|
|
double get successRate => totalTasks > 0 ? successCount / totalTasks : 0.0;
|
|
double get failRate => totalTasks > 0 ? failCount / totalTasks : 0.0;
|
|
}
|
|
|
|
class TransferStatsOverview {
|
|
const TransferStatsOverview({
|
|
this.totalSentBytes = 0,
|
|
this.totalReceivedBytes = 0,
|
|
this.totalFileCount = 0,
|
|
this.avgSpeed = 0.0,
|
|
this.topTransport = 'localsend_http',
|
|
this.totalSuccessCount = 0,
|
|
this.totalFailCount = 0,
|
|
});
|
|
|
|
final int totalSentBytes;
|
|
final int totalReceivedBytes;
|
|
final int totalFileCount;
|
|
final double avgSpeed;
|
|
final String topTransport;
|
|
final int totalSuccessCount;
|
|
final int totalFailCount;
|
|
int get totalBytes => totalSentBytes + totalReceivedBytes;
|
|
}
|
|
|
|
class TransferStatsService {
|
|
TransferStatsService._();
|
|
static final TransferStatsService _instance = TransferStatsService._();
|
|
static TransferStatsService get instance => _instance;
|
|
|
|
final _db = TransferDatabase.instance;
|
|
final _dateFormat = DateFormat('yyyy-MM-dd');
|
|
|
|
// ============================================================
|
|
// 总览统计
|
|
// ============================================================
|
|
|
|
Future<TransferStatsOverview> getOverview() async {
|
|
try {
|
|
final records = await _db.getAllRecords();
|
|
|
|
if (records.isEmpty) {
|
|
return const TransferStatsOverview();
|
|
}
|
|
|
|
int totalSent = 0;
|
|
int totalReceived = 0;
|
|
int fileCount = 0;
|
|
double totalSpeed = 0.0;
|
|
int speedCount = 0;
|
|
int successCount = 0;
|
|
int failCount = 0;
|
|
final transportCounts = <String, int>{};
|
|
|
|
for (final r in records) {
|
|
if (r.direction == 'send') {
|
|
totalSent += r.fileSize;
|
|
} else {
|
|
totalReceived += r.fileSize;
|
|
}
|
|
fileCount++;
|
|
if (r.speed > 0) {
|
|
totalSpeed += r.speed;
|
|
speedCount++;
|
|
}
|
|
if (r.status == 'completed') {
|
|
successCount++;
|
|
} else if (r.status == 'failed') {
|
|
failCount++;
|
|
}
|
|
transportCounts[r.transport] = (transportCounts[r.transport] ?? 0) + 1;
|
|
}
|
|
|
|
final topTransport = transportCounts.entries
|
|
.reduce((a, b) => a.value > b.value ? a : b)
|
|
.key;
|
|
|
|
return TransferStatsOverview(
|
|
totalSentBytes: totalSent,
|
|
totalReceivedBytes: totalReceived,
|
|
totalFileCount: fileCount,
|
|
avgSpeed: speedCount > 0 ? totalSpeed / speedCount : 0.0,
|
|
topTransport: topTransport,
|
|
totalSuccessCount: successCount,
|
|
totalFailCount: failCount,
|
|
);
|
|
} catch (e) {
|
|
Log.e('TransferStatsService: getOverview failed: $e');
|
|
return const TransferStatsOverview();
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 每日趋势
|
|
// ============================================================
|
|
|
|
Future<List<DailyStats>> getDailyTrend({int days = 7}) async {
|
|
try {
|
|
final records = await _db.getAllRecords();
|
|
final now = DateTime.now();
|
|
final dailyMap = <String, _DailyAccumulator>{};
|
|
|
|
for (int i = 0; i < days; i++) {
|
|
final date = now.subtract(Duration(days: i));
|
|
final key = _dateFormat.format(date);
|
|
dailyMap[key] = _DailyAccumulator(date: key);
|
|
}
|
|
|
|
for (final r in records) {
|
|
final key = _dateFormat.format(r.startTime);
|
|
if (!dailyMap.containsKey(key)) continue;
|
|
|
|
final acc = dailyMap[key]!;
|
|
if (r.direction == 'send') {
|
|
acc.sentBytes += r.fileSize;
|
|
} else {
|
|
acc.receivedBytes += r.fileSize;
|
|
}
|
|
acc.fileCount++;
|
|
if (r.speed > 0) {
|
|
acc.totalSpeed += r.speed;
|
|
acc.speedCount++;
|
|
}
|
|
if (r.status == 'completed') {
|
|
acc.successCount++;
|
|
} else if (r.status == 'failed') {
|
|
acc.failCount++;
|
|
}
|
|
}
|
|
|
|
final result = dailyMap.values
|
|
.map(
|
|
(acc) => DailyStats(
|
|
date: acc.date,
|
|
sentBytes: acc.sentBytes,
|
|
receivedBytes: acc.receivedBytes,
|
|
fileCount: acc.fileCount,
|
|
avgSpeed: acc.speedCount > 0
|
|
? acc.totalSpeed / acc.speedCount
|
|
: 0.0,
|
|
successCount: acc.successCount,
|
|
failCount: acc.failCount,
|
|
),
|
|
)
|
|
.toList();
|
|
|
|
result.sort((a, b) => a.date.compareTo(b.date));
|
|
return result;
|
|
} catch (e) {
|
|
Log.e('TransferStatsService: getDailyTrend failed: $e');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 设备排行
|
|
// ============================================================
|
|
|
|
Future<List<DeviceRanking>> getDeviceRanking({int limit = 10}) async {
|
|
try {
|
|
final records = await _db.getAllRecords();
|
|
final deviceMap = <String, _DeviceAccumulator>{};
|
|
|
|
for (final r in records) {
|
|
if (!deviceMap.containsKey(r.peerId)) {
|
|
deviceMap[r.peerId] = _DeviceAccumulator(
|
|
peerId: r.peerId,
|
|
peerAlias: r.peerAlias,
|
|
);
|
|
}
|
|
final acc = deviceMap[r.peerId]!;
|
|
acc.fileCount++;
|
|
acc.totalBytes += r.fileSize;
|
|
if (r.speed > 0) {
|
|
acc.totalSpeed += r.speed;
|
|
acc.speedCount++;
|
|
}
|
|
}
|
|
|
|
final result = deviceMap.values
|
|
.map(
|
|
(acc) => DeviceRanking(
|
|
peerId: acc.peerId,
|
|
peerAlias: acc.peerAlias,
|
|
fileCount: acc.fileCount,
|
|
totalBytes: acc.totalBytes,
|
|
avgSpeed: acc.speedCount > 0
|
|
? acc.totalSpeed / acc.speedCount
|
|
: 0.0,
|
|
),
|
|
)
|
|
.toList();
|
|
|
|
result.sort((a, b) => b.fileCount.compareTo(a.fileCount));
|
|
return result.take(limit).toList();
|
|
} catch (e) {
|
|
Log.e('TransferStatsService: getDeviceRanking failed: $e');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 文件类型分布
|
|
// ============================================================
|
|
|
|
Future<List<FileTypeDistribution>> getFileTypeDistribution() async {
|
|
try {
|
|
final records = await _db.getAllRecords();
|
|
final typeMap = <String, _TypeAccumulator>{};
|
|
|
|
for (final r in records) {
|
|
final category = _categorizeMimeType(r.mimeType);
|
|
if (!typeMap.containsKey(category)) {
|
|
typeMap[category] = _TypeAccumulator(category: category);
|
|
}
|
|
final acc = typeMap[category]!;
|
|
acc.fileCount++;
|
|
acc.totalBytes += r.fileSize;
|
|
}
|
|
|
|
final result = typeMap.values
|
|
.map(
|
|
(acc) => FileTypeDistribution(
|
|
category: acc.category,
|
|
fileCount: acc.fileCount,
|
|
totalBytes: acc.totalBytes,
|
|
),
|
|
)
|
|
.toList();
|
|
|
|
result.sort((a, b) => b.fileCount.compareTo(a.fileCount));
|
|
return result;
|
|
} catch (e) {
|
|
Log.e('TransferStatsService: getFileTypeDistribution failed: $e');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 传输质量
|
|
// ============================================================
|
|
|
|
Future<TransferQuality> getTransferQuality() async {
|
|
try {
|
|
final records = await _db.getAllRecords();
|
|
|
|
if (records.isEmpty) {
|
|
return const TransferQuality();
|
|
}
|
|
|
|
int successCount = 0;
|
|
int failCount = 0;
|
|
double totalSpeed = 0.0;
|
|
double maxSpeed = 0.0;
|
|
int speedCount = 0;
|
|
|
|
for (final r in records) {
|
|
if (r.status == 'completed') {
|
|
successCount++;
|
|
} else if (r.status == 'failed') {
|
|
failCount++;
|
|
}
|
|
if (r.speed > 0) {
|
|
totalSpeed += r.speed;
|
|
speedCount++;
|
|
if (r.speed > maxSpeed) maxSpeed = r.speed;
|
|
}
|
|
}
|
|
|
|
return TransferQuality(
|
|
totalTasks: records.length,
|
|
successCount: successCount,
|
|
failCount: failCount,
|
|
avgSpeed: speedCount > 0 ? totalSpeed / speedCount : 0.0,
|
|
maxSpeed: maxSpeed,
|
|
);
|
|
} catch (e) {
|
|
Log.e('TransferStatsService: getTransferQuality failed: $e');
|
|
return const TransferQuality();
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 每日快照写入
|
|
// ============================================================
|
|
|
|
Future<void> writeDailySnapshot() async {
|
|
try {
|
|
final today = _dateFormat.format(DateTime.now());
|
|
|
|
final existing = await _db.getStatsRecordByDate(today);
|
|
|
|
final records = await _db.getAllRecords();
|
|
final todayRecords = records.where(
|
|
(r) => _dateFormat.format(r.startTime) == today,
|
|
);
|
|
|
|
int sentBytes = 0;
|
|
int receivedBytes = 0;
|
|
int fileCount = 0;
|
|
double totalSpeed = 0.0;
|
|
int speedCount = 0;
|
|
|
|
for (final r in todayRecords) {
|
|
if (r.direction == 'send') {
|
|
sentBytes += r.fileSize;
|
|
} else {
|
|
receivedBytes += r.fileSize;
|
|
}
|
|
fileCount++;
|
|
if (r.speed > 0) {
|
|
totalSpeed += r.speed;
|
|
speedCount++;
|
|
}
|
|
}
|
|
|
|
final avgSpeed = speedCount > 0 ? totalSpeed / speedCount : 0.0;
|
|
|
|
if (existing != null) {
|
|
await _db.updateStatsRecord(
|
|
existing.copyWith(
|
|
totalSentBytes: sentBytes,
|
|
totalReceivedBytes: receivedBytes,
|
|
fileCount: fileCount,
|
|
avgSpeed: avgSpeed,
|
|
),
|
|
);
|
|
} else {
|
|
await _db.insertStatsRecord(
|
|
TransferStatsRecordsCompanion(
|
|
date: Value(today),
|
|
totalSentBytes: Value(sentBytes),
|
|
totalReceivedBytes: Value(receivedBytes),
|
|
fileCount: Value(fileCount),
|
|
avgSpeed: Value(avgSpeed),
|
|
transportType: const Value('all'),
|
|
createdAt: Value(DateTime.now()),
|
|
),
|
|
);
|
|
}
|
|
|
|
Log.i('TransferStatsService: Daily snapshot written for $today');
|
|
} catch (e) {
|
|
Log.e('TransferStatsService: writeDailySnapshot failed: $e');
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 工具方法
|
|
// ============================================================
|
|
|
|
String _categorizeMimeType(String? mimeType) {
|
|
if (mimeType == null) return '其他';
|
|
if (mimeType.startsWith('image/')) return '图片';
|
|
if (mimeType.startsWith('video/')) return '视频';
|
|
if (mimeType.startsWith('audio/')) return '音频';
|
|
if (mimeType.contains('pdf') ||
|
|
mimeType.contains('document') ||
|
|
mimeType.contains('spreadsheet') ||
|
|
mimeType.contains('presentation') ||
|
|
mimeType.contains('text/'))
|
|
return '文档';
|
|
if (mimeType.contains('zip') ||
|
|
mimeType.contains('rar') ||
|
|
mimeType.contains('tar') ||
|
|
mimeType.contains('7z'))
|
|
return '压缩包';
|
|
return '其他';
|
|
}
|
|
}
|
|
|
|
class _DailyAccumulator {
|
|
_DailyAccumulator({required this.date});
|
|
final String date;
|
|
int sentBytes = 0;
|
|
int receivedBytes = 0;
|
|
int fileCount = 0;
|
|
double totalSpeed = 0.0;
|
|
int speedCount = 0;
|
|
int successCount = 0;
|
|
int failCount = 0;
|
|
}
|
|
|
|
class _DeviceAccumulator {
|
|
_DeviceAccumulator({required this.peerId, required this.peerAlias});
|
|
final String peerId;
|
|
final String peerAlias;
|
|
int fileCount = 0;
|
|
int totalBytes = 0;
|
|
double totalSpeed = 0.0;
|
|
int speedCount = 0;
|
|
}
|
|
|
|
class _TypeAccumulator {
|
|
_TypeAccumulator({required this.category});
|
|
final String category;
|
|
int fileCount = 0;
|
|
int totalBytes = 0;
|
|
}
|