Files
app/lib/services/avatar_cache_service.dart
2026-05-15 09:03:09 +08:00

301 lines
9.8 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:cloudreve4_flutter/core/utils/app_logger.dart';
import 'package:cloudreve4_flutter/core/utils/avatar_utils.dart';
/// 头像缓存服务
/// 通过 Dio 下载头像,保存为本地文件 {userId}_avatar.webp
/// 服务器头像 200 优先,否则使用 Gravatar (identicon 兜底)
class AvatarCacheService extends ChangeNotifier {
static AvatarCacheService? _instance;
static AvatarCacheService get instance {
_instance ??= AvatarCacheService._();
return _instance!;
}
AvatarCacheService._();
Directory? _cacheDir;
final Map<String, String> _pathCache = {};
final Map<String, int> _sizeCache = {};
final Set<String> _loadingSet = {};
/// 初始化缓存目录
Future<void> init() async {
final appDir = await getApplicationSupportDirectory();
_cacheDir = Directory('${appDir.path}/avatar_cache');
if (!_cacheDir!.existsSync()) {
_cacheDir!.createSync(recursive: true);
}
_scanExistingFiles();
}
void _scanExistingFiles() {
if (_cacheDir == null) return;
for (final entity in _cacheDir!.listSync()) {
if (entity is File && entity.path.endsWith('_avatar.webp')) {
final name = entity.uri.pathSegments.last;
final userId = name.replaceAll('_avatar.webp', '');
_pathCache[userId] = entity.path;
try {
_sizeCache[userId] = entity.lengthSync();
} catch (_) {}
}
}
}
String _filePath(String userId) => '${_cacheDir!.path}/${userId}_avatar.webp';
/// 头像缓存文件是否存在(同步)
bool avatarIsExist(String userId) {
if (userId.isEmpty) return false;
final cached = _pathCache[userId];
if (cached != null && File(cached).existsSync()) return true;
return File(_filePath(userId)).existsSync();
}
/// 检查服务器头像是否有更新(HEAD 请求比对 Content-Length
/// 返回 true 表示已更新并重新缓存,false 表示无需更新或检查失败
Future<bool> avatarIsUpdated(String userId, String baseUrl, String token) async {
if (userId.isEmpty) return false;
if (!avatarIsExist(userId)) {
// 本地无缓存,直接走 getAvatar
return await getAvatar(userId, baseUrl: baseUrl, token: token);
}
try {
final url = AvatarUtils.getServerAvatarUrl(baseUrl, userId);
// 使用独立 Dio 实例,避免 ApiService 拦截器干扰
final dio = Dio();
final response = await dio.head<void>(
url,
options: Options(
headers: {'Authorization': 'Bearer $token'},
validateStatus: (s) => s != null && s < 500,
followRedirects: false,
),
);
// 非 200(包括 302 指向 Gravatar)视为无自定义头像,无需比对更新
if (response.statusCode != 200) return false;
final remoteSize = response.headers.value('content-length');
if (remoteSize == null) return false;
final remoteBytes = int.tryParse(remoteSize) ?? -1;
final localBytes = _sizeCache[userId] ??
(File(_filePath(userId)).existsSync()
? File(_filePath(userId)).lengthSync()
: -1);
AppLogger.d('头像更新检查 ($userId): 本地=$localBytes, 远程=$remoteBytes');
if (remoteBytes != localBytes) {
// 大小不一致,重新获取
return await getAvatar(userId, baseUrl: baseUrl, token: token);
}
return false;
} catch (e) {
AppLogger.d('头像更新检查失败 ($userId): $e');
return false;
}
}
/// 获取头像:并发请求服务器头像 + Gravatar,服务器 200 优先,否则用 Gravatar
/// 返回 true 表示成功获取并缓存,false 表示全部失败
Future<bool> getAvatar(String userId,
{String? baseUrl, String? token, String? email}) async {
if (userId.isEmpty || _cacheDir == null) return false;
// 防止同一 userId 并发
if (_loadingSet.contains(userId)) return false;
_loadingSet.add(userId);
try {
final futures = <Future<_AvatarResult>>[];
// 1. 服务器头像
if (baseUrl != null && baseUrl.isNotEmpty && token != null && token.isNotEmpty) {
futures.add(_fetchServerAvatar(userId, baseUrl, token));
}
// 2. Gravatar (identicon 兜底)
if (email != null && email.isNotEmpty) {
futures.add(_fetchGravatar(userId, email));
}
if (futures.isEmpty) return false;
// 并发请求
final results = await Future.wait(futures);
final serverResult = results.whereType<_ServerAvatarResult>().firstOrNull;
final gravatarResult = results.whereType<_GravatarResult>().firstOrNull;
// 服务器头像 200 优先
if (serverResult != null && serverResult.statusCode == 200 && serverResult.bytes != null) {
_saveFile(userId, serverResult.bytes!);
AppLogger.d('头像获取成功 (服务器, $userId), 大小=${serverResult.bytes!.length}');
notifyListeners();
return true;
}
// 服务器 404/302 或失败,用 Gravatar
if (gravatarResult != null && gravatarResult.bytes != null) {
_saveFile(userId, gravatarResult.bytes!);
AppLogger.d('头像获取成功 (Gravatar, $userId), 大小=${gravatarResult.bytes!.length}');
notifyListeners();
return true;
}
AppLogger.d('头像获取失败 ($userId): 服务器状态=${serverResult?.statusCode}, Gravatar=${gravatarResult != null ? '失败' : '未请求'}');
return false;
} finally {
_loadingSet.remove(userId);
}
}
Future<_AvatarResult> _fetchServerAvatar(String userId, String baseUrl, String token) async {
try {
final url = AvatarUtils.getServerAvatarUrl(baseUrl, userId);
// 使用独立 Dio 实例,避免 ApiService 拦截器干扰
final dio = Dio();
final response = await dio.get<List<int>>(
url,
options: Options(
responseType: ResponseType.bytes,
headers: {'Authorization': 'Bearer $token'},
validateStatus: (s) => s != null && s < 500,
// 不跟随重定向:302 指向官方 Gravatar,跟随会绕过镜像加速
followRedirects: false,
),
);
return _ServerAvatarResult(response.statusCode ?? 0, response.data);
} catch (e) {
AppLogger.d('请求服务器头像异常 ($userId): $e');
return _ServerAvatarResult(0, null);
}
}
Future<_AvatarResult> _fetchGravatar(String userId, String email) async {
try {
final url = await AvatarUtils.getGravatarUrl(email, size: 400);
final dio = Dio();
final response = await dio.get<List<int>>(
url,
options: Options(
responseType: ResponseType.bytes,
validateStatus: (s) => s != null && s < 500,
),
);
if (response.statusCode == 200 && response.data != null && response.data!.isNotEmpty) {
return _GravatarResult(response.data!);
}
return _GravatarResult(null);
} catch (e) {
AppLogger.d('请求Gravatar头像异常 ($userId): $e');
return _GravatarResult(null);
}
}
void _saveFile(String userId, List<int> bytes) {
if (_cacheDir == null) return;
final path = _filePath(userId);
final file = File(path);
file.writeAsBytesSync(bytes);
_pathCache[userId] = path;
_sizeCache[userId] = bytes.length;
}
/// 获取已缓存的头像文件(同步,快速)
File? getCachedFile(String userId) {
if (_cacheDir == null || userId.isEmpty) return null;
final cached = _pathCache[userId];
if (cached != null && File(cached).existsSync()) {
return File(cached);
}
final path = _filePath(userId);
if (File(path).existsSync()) {
_pathCache[userId] = path;
return File(path);
}
_pathCache.remove(userId);
_sizeCache.remove(userId);
return null;
}
/// 批量检查头像是否需要更新(限制并发,避免密集 IO/网络阻塞主线程)
Future<void> batchCheckUpdates(
List<String> userIds, {
required String baseUrl,
required String token,
int concurrency = 3,
}) async {
var index = 0;
final completer = Completer<void>();
var activeCount = 0;
var completedCount = 0;
final total = userIds.length;
void scheduleNext() {
while (activeCount < concurrency && index < total) {
final userId = userIds[index++];
activeCount++;
avatarIsUpdated(userId, baseUrl, token).whenComplete(() {
activeCount--;
completedCount++;
if (completedCount >= total && !completer.isCompleted) {
completer.complete();
} else {
scheduleNext();
}
});
}
if (completedCount >= total && !completer.isCompleted) {
completer.complete();
}
}
scheduleNext();
await completer.future;
}
/// 清除指定用户的头像缓存(头像上传/切换后调用)
Future<void> evictCache(String userId) async {
_pathCache.remove(userId);
_sizeCache.remove(userId);
final path = _filePath(userId);
final file = File(path);
if (file.existsSync()) {
await file.delete();
}
notifyListeners();
}
/// 清除所有头像缓存(登出时调用)
Future<void> clearAllCache() async {
_pathCache.clear();
_sizeCache.clear();
if (_cacheDir != null && _cacheDir!.existsSync()) {
await _cacheDir!.delete(recursive: true);
_cacheDir!.createSync(recursive: true);
}
notifyListeners();
}
}
/// 请求结果基类
abstract class _AvatarResult {}
class _ServerAvatarResult extends _AvatarResult {
final int statusCode;
final List<int>? bytes;
_ServerAvatarResult(this.statusCode, this.bytes);
}
class _GravatarResult extends _AvatarResult {
final List<int>? bytes;
_GravatarResult(this.bytes);
}