1
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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._();
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
// ---------- 互动 ----------
|
||||
|
||||
@@ -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 重新播放(如从登录页返回),解除挂起态
|
||||
|
||||
Reference in New Issue
Block a user