This commit is contained in:
lz_db
2026-10-25 11:46:44 +08:00
parent 893dfed0ed
commit 494f667719
63 changed files with 5213 additions and 239 deletions
+134
View File
@@ -0,0 +1,134 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'package:package_info_plus/package_info_plus.dart';
import '../network/api_client.dart';
/// 用户操作日志上报器(仅白名单邮箱用户启用)
///
/// 用法:
/// ```dart
/// ActionLogger.I.bindUser(userEmail);
/// ActionLogger.I.log('route_push', page: '/search/feed', detail: {...});
/// ```
///
/// 设计:
/// - 白名单:只有 email == 256kb@gmail.com 时启用,其他账号调用为 no-op
/// - 缓冲:本地 Queue 缓冲,达到 BATCH_SIZE 或超过 FLUSH_INTERVAL 自动批量上传
/// - 静默失败:网络错误/服务端 403 不重试不上抛,避免影响业务
class ActionLogger {
ActionLogger._();
static final ActionLogger I = ActionLogger._();
/// 白名单邮箱(与后端 ALLOWED_EMAILS 保持一致)
static const String _allowedEmail = '256kb@gmail.com';
static const int _batchSize = 30;
static const Duration _flushInterval = Duration(seconds: 10);
static const int _maxQueueLen = 500; // 防止离线积压撑爆内存
bool _enabled = false;
String _userEmail = '';
String _deviceInfo = '';
String _appVersion = '';
final Queue<Map<String, dynamic>> _queue = Queue();
Timer? _flushTimer;
bool _flushing = false;
bool get enabled => _enabled;
/// 登录成功后调用,开启上报;非白名单邮箱则关闭
void bindUser(String? email) {
_userEmail = email ?? '';
_enabled = _userEmail.toLowerCase() == _allowedEmail;
if (_enabled) {
_initMeta();
_startFlushTimer();
log('log_session_start', page: 'app', detail: {'email': _userEmail});
} else {
_stopFlushTimer();
_queue.clear();
}
}
/// 登出/切换账号时调用
void unbind() {
if (_enabled) {
log('log_session_end', page: 'app', detail: {'email': _userEmail});
// 立即上传残余
unawaited(flush());
}
_enabled = false;
_userEmail = '';
_stopFlushTimer();
// 不清 queue:flush 已在飞,让它带完最后这批
}
Future<void> _initMeta() async {
try {
final info = await PackageInfo.fromPlatform();
_appVersion = '${info.version}+${info.buildNumber}';
} catch (_) {
_appVersion = 'unknown';
}
_deviceInfo = '${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
}
void _startFlushTimer() {
_flushTimer?.cancel();
_flushTimer = Timer.periodic(_flushInterval, (_) => unawaited(flush()));
}
void _stopFlushTimer() {
_flushTimer?.cancel();
_flushTimer = null;
}
/// 记一条日志。enabled=false 时直接 no-op
void log(
String action, {
String page = '',
Map<String, dynamic>? detail,
String? detailRaw,
}) {
if (!_enabled) return;
if (_queue.length >= _maxQueueLen) {
_queue.removeFirst(); // 丢最老的,保留最新
}
_queue.add({
'action': action,
'page': page,
'detail': detailRaw ?? (detail != null ? jsonEncode(detail) : ''),
'device_info': _deviceInfo,
'app_version': _appVersion,
'client_ts': DateTime.now().millisecondsSinceEpoch,
});
if (_queue.length >= _batchSize) {
unawaited(flush());
}
}
/// 立即上传队列里的所有日志
Future<void> flush() async {
if (!_enabled || _flushing || _queue.isEmpty) return;
_flushing = true;
try {
final batch = <Map<String, dynamic>>[];
while (_queue.isNotEmpty && batch.length < 100) {
batch.add(_queue.removeFirst());
}
await ApiClient.I.post<Map<String, dynamic>>(
'/logs/action',
data: {'items': batch},
);
} catch (_) {
// 静默失败:上报通道绝不能影响业务
} finally {
_flushing = false;
}
}
}
+18
View File
@@ -0,0 +1,18 @@
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
/// 视频封面图的全局缓存管理器:
/// - stalePeriod=30 天:30 天内重复访问直接命中缓存,不发请求
/// - maxNrOfCacheObjects=100000:取消原默认 200 张的容量限制,足够覆盖整个浏览历史
/// 缓存目录由系统管理,卸载/清理 App 数据时一并清除
class CoverCacheManager extends CacheManager with ImageCacheManager {
CoverCacheManager._()
: super(
Config(
'coverCache',
stalePeriod: const Duration(days: 30),
maxNrOfCacheObjects: 100000,
),
);
static final CoverCacheManager I = CoverCacheManager._();
}
+2
View File
@@ -27,6 +27,7 @@ class Endpoints {
// ---------- 视频 ----------
static const feed = '/videos/feed';
static const categories = '/videos/categories';
static const sources = '/videos/sources';
static const uploadToken = '/videos/upload/token';
static const uploadFile = '/videos/upload/file';
static const publish = '/videos';
@@ -42,6 +43,7 @@ class Endpoints {
static String videoComments(int videoId) => '/videos/$videoId/comments';
static String videoEdit(int videoId) => '/videos/$videoId';
static String videoDelete(int videoId) => '/videos/$videoId';
static String videoOffline(int videoId) => '/videos/$videoId/offline';
static String tagVideos(String tagName) => '/tags/$tagName/videos';
// ---------- 互动 ----------
+15
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import '../log/action_logger.dart';
import '../media/local_media_server.dart';
/// 一个播放槽位:Player + 视图控制器 + 当前绑定的视频
@@ -192,6 +193,10 @@ class FeedPlayerPool {
/// 只有 stop 能可靠断开下载;位置存到 _resumePosition,恢复时 rebind
/// 重新 open + seek 续播(分片走本地代理磁盘缓存,零流量)
void suspend() {
ActionLogger.I.log('player_suspend', page: 'feed_pool', detail: {
'generation': _generation,
'currentIndex': _currentIndex,
});
_suspended = true;
// 使在途 bindWindow 过期:其 open 完成后不得再 play
_generation++;
@@ -213,6 +218,11 @@ class FeedPlayerPool {
/// 挂起并 play。_suspended 必须在 await 之后才解除——否则等待期间 completed
/// listener 的 auto-loop 会因为看到 _suspended=false 而提前 play
Future<void> resume() async {
ActionLogger.I.log('player_resume', page: 'feed_pool', detail: {
'generation': _generation,
'currentIndex': _currentIndex,
'suspended': _suspended,
});
final pending = _pendingBind;
if (pending != null) {
try {
@@ -274,6 +284,11 @@ class FeedPlayerPool {
/// 与 pause 不同,stop 后缓存下载断开,且槽内无媒体不会幽灵出声。
/// 返回 Feed 后需重新 bindWindow 恢复当前页。
void stopAll() {
ActionLogger.I.log('player_stopall', page: 'feed_pool', detail: {
'generation': _generation,
'currentIndex': _currentIndex,
'slots': [for (final s in _slots) s.videoId],
});
// 使所有进行中的 bindWindow 过期(其 await 恢复后不会再 play)
_generation++;
// stopAll 后通常跟着 rebind 重新播放(如从登录页返回),解除挂起态