You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
569 lines
22 KiB
569 lines
22 KiB
using System.Threading.Tasks;
|
|
using System.Threading;
|
|
using AIK.Common;
|
|
using AIK.Service.IService;
|
|
using Microsoft.Extensions.Logging;
|
|
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Threading.Tasks;
|
|
using System.Windows;
|
|
using System.Windows.Media.Imaging;
|
|
|
|
namespace AIK.Service.Service
|
|
{
|
|
public class RemoteImageCacheService : IImageCacheService
|
|
{
|
|
private readonly ConcurrentDictionary<string, BitmapImage> _memoryCache;
|
|
private readonly ConcurrentDictionary<string, string> _memoryPathCache;
|
|
// 缓存正在下载或已完成的任务(URL/Key -> Task<string>)
|
|
private readonly ConcurrentDictionary<string, Task<string>> _pendingDownloads = new();
|
|
private readonly string _cacheBaseDirectory;
|
|
private readonly TimeSpan _cacheDuration = TimeSpan.FromDays(7); // 缓存时效7天
|
|
private readonly IHttpClientService _httpClient;
|
|
private readonly IConfigurationService _configurationService;
|
|
private readonly ILogger<RemoteImageCacheService> _logger;
|
|
private string GetCacheFilePath(string cacheKey) => Path.Combine(_cacheBaseDirectory, $"{cacheKey}.jpg");
|
|
public RemoteImageCacheService(IConfigurationService configurationService, IHttpClientService httpClient, ILogger<RemoteImageCacheService> logger)
|
|
{
|
|
_configurationService = configurationService;
|
|
_memoryCache = new ConcurrentDictionary<string, BitmapImage>();
|
|
_memoryPathCache = new ConcurrentDictionary<string, string>();
|
|
_httpClient = httpClient;
|
|
_logger = logger;
|
|
// 设置缓存目录,例如在用户本地应用数据文件夹下
|
|
_cacheBaseDirectory = CommonString.ImageCacheDirectory;
|
|
Directory.CreateDirectory(_cacheBaseDirectory); // 确保目录存在
|
|
_cacheDuration = _configurationService.CacheSettings.ImageCacheExpiry;
|
|
}
|
|
|
|
#region 基础方法
|
|
public async Task<BitmapImage> GetImageAsync(string imageUrl, string cacheKey = null, CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(imageUrl))
|
|
return GetPlaceholderImage();
|
|
|
|
// 生成缓存键,如果未提供则使用URL的MD5值
|
|
var key = cacheKey ?? GenerateCacheKey(imageUrl);
|
|
|
|
// 1. 检查内存缓存
|
|
if (_memoryCache.TryGetValue(key, out var cachedImage))
|
|
{
|
|
//_logger.LogDebug("内存缓存命中: {Key}", key);
|
|
return cachedImage;
|
|
}
|
|
|
|
// 2. 检查磁盘缓存
|
|
string filePath = GetCacheFilePath(key);
|
|
if (File.Exists(filePath) && !IsCacheExpired(filePath))
|
|
{
|
|
//_logger.LogDebug("磁盘缓存命中: {FilePath}", filePath);
|
|
// 从磁盘加载并放入内存缓存
|
|
var bitmapImage = await LoadImageFromDiskAsync(filePath);
|
|
if (bitmapImage != null)
|
|
{
|
|
_memoryCache[key] = bitmapImage;
|
|
}
|
|
return bitmapImage;
|
|
}
|
|
|
|
// 3. 从网络下载
|
|
try
|
|
{
|
|
var fullImgUrl = _configurationService.ApiSettings.FileUrl + imageUrl;
|
|
_logger.LogDebug("开始下载图片: {Url}", fullImgUrl);
|
|
|
|
// Flurl.Http 字节下载(连接级显式 TLS 1.2;非 2xx 会抛异常,由外层 catch 统一处理)
|
|
byte[] imageBytes = await _httpClient.GetByteArrayWithTimeoutAsync(fullImgUrl);
|
|
|
|
if (imageBytes == null || imageBytes.Length == 0)
|
|
{
|
|
_logger.LogWarning("图片数据为空: {Url}", fullImgUrl);
|
|
return GetPlaceholderImage();
|
|
}
|
|
|
|
// 验证图片数据有效性
|
|
if (!IsValidImageData(imageBytes))
|
|
{
|
|
_logger.LogWarning("图片数据无效: {Url}, 大小: {Size}字节", fullImgUrl, imageBytes.Length);
|
|
return GetPlaceholderImage();
|
|
}
|
|
|
|
// 保存到磁盘缓存
|
|
await SaveImageToDiskAsync(imageBytes, filePath);
|
|
|
|
// 从字节数组直接创建BitmapImage
|
|
var newImage = await CreateBitmapImageFromBytesAsync(imageBytes);
|
|
if (newImage != null)
|
|
{
|
|
_memoryCache[key] = newImage;
|
|
_logger.LogDebug("图片下载缓存成功: {Url}, 大小: {Size}字节", fullImgUrl, imageBytes.Length);
|
|
return newImage;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "下载图片失败: {ImageUrl}", imageUrl);
|
|
}
|
|
|
|
return GetPlaceholderImage();
|
|
}
|
|
/// <summary>
|
|
/// 若本地图片存在,直接加载图片(去掉读取文件流操作)优化速率大概是280ms->10ms
|
|
/// </summary>
|
|
/// <param name="imageUrl"></param>
|
|
/// <param name="cacheKey"></param>
|
|
/// <param name="cancellationToken"></param>
|
|
/// <returns></returns>
|
|
public async Task<BitmapImage> GetImageByLocalPathAsync(string imageUrl, string cacheKey = null, CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(imageUrl))
|
|
return GetPlaceholderImage();
|
|
|
|
// 生成缓存键,如果未提供则使用URL的MD5值
|
|
var key = cacheKey ?? GenerateCacheKey(imageUrl);
|
|
|
|
// 1. 检查内存缓存
|
|
if (_memoryCache.TryGetValue(key, out var cachedImage))
|
|
{
|
|
//_logger.LogDebug("内存缓存命中: {Key}", key);
|
|
return cachedImage;
|
|
}
|
|
// 2. 检查磁盘缓存
|
|
string filePath = GetCacheFilePath(key);
|
|
if (File.Exists(filePath) && !IsCacheExpired(filePath))
|
|
{
|
|
var bitmapImage = new BitmapImage();
|
|
bitmapImage.BeginInit();
|
|
bitmapImage.UriSource = new Uri(filePath);
|
|
bitmapImage.CacheOption = BitmapCacheOption.OnLoad;
|
|
bitmapImage.EndInit();
|
|
bitmapImage.Freeze();
|
|
if (bitmapImage != null)
|
|
{
|
|
_memoryCache[key] = bitmapImage;
|
|
}
|
|
return bitmapImage;
|
|
}
|
|
// 3. 从网络下载
|
|
try
|
|
{
|
|
var fullImgUrl = _configurationService.ApiSettings.FileUrl + imageUrl;
|
|
_logger.LogDebug("开始下载图片: {Url}", fullImgUrl);
|
|
|
|
// Flurl.Http 字节下载(连接级显式 TLS 1.2;非 2xx 会抛异常,由外层 catch 统一处理)
|
|
byte[] imageBytes = await _httpClient.GetByteArrayWithTimeoutAsync(fullImgUrl);
|
|
|
|
if (imageBytes == null || imageBytes.Length == 0)
|
|
{
|
|
_logger.LogWarning("图片数据为空: {Url}", fullImgUrl);
|
|
return GetPlaceholderImage();
|
|
}
|
|
|
|
// 验证图片数据有效性
|
|
if (!IsValidImageData(imageBytes))
|
|
{
|
|
_logger.LogWarning("图片数据无效: {Url}, 大小: {Size}字节", fullImgUrl, imageBytes.Length);
|
|
return GetPlaceholderImage();
|
|
}
|
|
|
|
// 保存到磁盘缓存
|
|
await SaveImageToDiskAsync(imageBytes, filePath);
|
|
|
|
// 从字节数组直接创建BitmapImage
|
|
var newImage = await CreateBitmapImageFromBytesAsync(imageBytes);
|
|
if (newImage != null)
|
|
{
|
|
_memoryCache[key] = newImage;
|
|
_logger.LogDebug("图片下载缓存成功: {Url}, 大小: {Size}字节", fullImgUrl, imageBytes.Length);
|
|
return newImage;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "下载图片失败: {ImageUrl}", imageUrl);
|
|
}
|
|
|
|
return GetPlaceholderImage();
|
|
|
|
}
|
|
public async Task<string> GetImagePathAsync(string imageUrl, string cacheKey = null, CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(imageUrl))
|
|
return string.Empty;
|
|
|
|
// 生成缓存键,如果未提供则使用URL的MD5值
|
|
var key = cacheKey ?? GenerateCacheKey(imageUrl);
|
|
|
|
// 1. 检查内存缓存
|
|
if (_memoryPathCache.TryGetValue(key, out var cachedImage))
|
|
{
|
|
//_logger.LogDebug("内存缓存命中: {Key}", key);
|
|
return cachedImage;
|
|
}
|
|
|
|
// 2. 检查磁盘缓存
|
|
string filePath = GetCacheFilePath(key);
|
|
if (File.Exists(filePath) && !IsCacheExpired(filePath))
|
|
{
|
|
//_logger.LogDebug("磁盘缓存命中: {FilePath}", filePath);
|
|
// 从磁盘加载并放入内存缓存
|
|
|
|
_memoryPathCache[key] = filePath;
|
|
return filePath;
|
|
}
|
|
var downloadTask = _pendingDownloads.GetOrAdd(key, async k =>
|
|
{
|
|
// 3. 从网络下载
|
|
try
|
|
{
|
|
var fullImgUrl = _configurationService.ApiSettings.FileUrl + imageUrl;
|
|
_logger.LogDebug("开始下载图片: {Url}", fullImgUrl);
|
|
|
|
// Flurl.Http 字节下载(连接级显式 TLS 1.2;非 2xx 会抛异常,由外层 catch 统一处理)
|
|
byte[] imageBytes = await _httpClient.GetByteArrayWithTimeoutAsync(fullImgUrl, null, cancellationToken);
|
|
|
|
if (imageBytes == null || imageBytes.Length == 0)
|
|
{
|
|
_logger.LogWarning("图片数据为空: {Url}", fullImgUrl);
|
|
return string.Empty;
|
|
}
|
|
|
|
// 验证图片数据有效性
|
|
if (!IsValidImageData(imageBytes))
|
|
{
|
|
_logger.LogWarning("图片数据无效: {Url}, 大小: {Size}字节", fullImgUrl, imageBytes.Length);
|
|
return string.Empty;
|
|
}
|
|
// 保存到磁盘缓存
|
|
await SaveImageToDiskAsyncV2(imageBytes, filePath, fullImgUrl,cancellationToken);
|
|
_memoryPathCache[key] = filePath;
|
|
return filePath;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "下载图片失败: {ImageUrl}", imageUrl);
|
|
return string.Empty;
|
|
}
|
|
finally
|
|
{
|
|
// 清理已完成的任务(可选,防止长期驻留)
|
|
_pendingDownloads.TryRemove(k, out _);
|
|
}
|
|
});
|
|
return await downloadTask;
|
|
}
|
|
#region 辅助方法
|
|
|
|
/// <summary>
|
|
/// 从字节数组创建BitmapImage
|
|
/// </summary>
|
|
private async Task<BitmapImage> CreateBitmapImageFromBytesAsync(byte[] imageBytes)
|
|
{
|
|
return await Task.Run(() =>
|
|
{
|
|
try
|
|
{
|
|
|
|
var bitmapImage = new BitmapImage();
|
|
var memoryStream = new MemoryStream(imageBytes);
|
|
bitmapImage.BeginInit();
|
|
bitmapImage.CacheOption = BitmapCacheOption.OnLoad;
|
|
//bitmapImage.CreateOptions = BitmapCreateOptions.IgnoreImageCache;
|
|
bitmapImage.DecodePixelWidth = 1000;
|
|
bitmapImage.StreamSource = memoryStream;
|
|
bitmapImage.EndInit();
|
|
memoryStream.Close(); // 或 Dispose()
|
|
// 必须冻结以实现线程安全
|
|
if (bitmapImage.CanFreeze)
|
|
{
|
|
bitmapImage.Freeze();
|
|
}
|
|
return bitmapImage;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError("执行方法:{Method}加载图片失败:{Message}", nameof(LoadImageViaMemoryStreamAsync), ex.Message);
|
|
return GetPlaceholderImage();
|
|
//throw new InvalidOperationException($"通过内存流加载图片失败: {filePath}", ex);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// 保存字节数组到磁盘
|
|
/// </summary>
|
|
private async Task SaveImageToDiskAsync(byte[] imageBytes, string filePath)
|
|
{
|
|
try
|
|
{
|
|
// 使用临时文件避免写入过程中出错
|
|
var tempFilePath = filePath + ".tmp";
|
|
|
|
File.WriteAllBytes(tempFilePath, imageBytes);
|
|
|
|
// 原子性操作:重命名临时文件
|
|
if (File.Exists(filePath))
|
|
{
|
|
File.Delete(filePath);
|
|
}
|
|
File.Move(tempFilePath, filePath);
|
|
|
|
_logger.LogDebug("图片保存成功: {FilePath}, 大小: {Size}字节", filePath, imageBytes.Length);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "保存图片到磁盘失败: {FilePath}", filePath);
|
|
|
|
// 清理临时文件
|
|
var tempFilePath = filePath + ".tmp";
|
|
if (File.Exists(tempFilePath))
|
|
{
|
|
File.Delete(tempFilePath);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task SaveImageToDiskAsyncV2(byte[] imageBytes, string targetPath, string fullpath, CancellationToken ct=default)
|
|
{
|
|
// 确保目录存在
|
|
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
|
|
|
|
// 使用临时文件 + 原子移动,避免读写冲突
|
|
string tempFile = Path.Combine(Path.GetDirectoryName(targetPath)!, Path.GetRandomFileName() + ".tmp");
|
|
|
|
try
|
|
{
|
|
File.WriteAllBytes(tempFile, imageBytes);
|
|
// 先删除目标文件(如果存在),再移动(实现覆盖效果)
|
|
if (File.Exists(targetPath))
|
|
{
|
|
File.Delete(targetPath);
|
|
}
|
|
File.Move(tempFile, targetPath); // Windows 上同目录 Move 是原子的
|
|
_logger.LogDebug("图片保存成功: {Url} -> {Path}", fullpath, targetPath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "保存图片到磁盘失败: {Path}=>{Url}", targetPath, fullpath);
|
|
throw; // 让上层捕获
|
|
}
|
|
finally
|
|
{
|
|
// 清理残留临时文件
|
|
if (File.Exists(tempFile))
|
|
File.Delete(tempFile);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 验证图片数据有效性
|
|
/// </summary>
|
|
private bool IsValidImageData(byte[] imageData)
|
|
{
|
|
if (imageData == null || imageData.Length < 8)
|
|
return false;
|
|
|
|
// 检查常见的图片文件头
|
|
// JPEG: FF D8 FF
|
|
if (imageData[0] == 0xFF && imageData[1] == 0xD8 && imageData[2] == 0xFF)
|
|
return true;
|
|
|
|
// PNG: 89 50 4E 47
|
|
if (imageData[0] == 0x89 && imageData[1] == 0x50 &&
|
|
imageData[2] == 0x4E && imageData[3] == 0x47)
|
|
return true;
|
|
|
|
// GIF: 47 49 46
|
|
if (imageData[0] == 0x47 && imageData[1] == 0x49 && imageData[2] == 0x46)
|
|
return true;
|
|
|
|
// BMP: 42 4D
|
|
if (imageData[0] == 0x42 && imageData[1] == 0x4D)
|
|
return true;
|
|
|
|
// WebP: RIFF
|
|
if (imageData[0] == 0x52 && imageData[1] == 0x49 &&
|
|
imageData[2] == 0x46 && imageData[3] == 0x46)
|
|
return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 优化的磁盘加载方法
|
|
/// </summary>
|
|
private async Task<BitmapImage> LoadImageFromDiskAsync(string filePath)
|
|
{
|
|
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
|
|
{
|
|
return GetPlaceholderImage();
|
|
}
|
|
|
|
try
|
|
{
|
|
// 直接读取文件字节数组
|
|
byte[] imageBytes = File.ReadAllBytes(filePath);
|
|
|
|
if (imageBytes == null || imageBytes.Length == 0)
|
|
{
|
|
_logger.LogWarning("磁盘缓存文件为空: {FilePath}", filePath);
|
|
// 删除损坏的文件
|
|
File.Delete(filePath);
|
|
return GetPlaceholderImage();
|
|
}
|
|
|
|
// 验证数据有效性
|
|
if (!IsValidImageData(imageBytes))
|
|
{
|
|
_logger.LogWarning("磁盘缓存文件数据无效: {FilePath}", filePath);
|
|
File.Delete(filePath);
|
|
return GetPlaceholderImage();
|
|
}
|
|
|
|
return await CreateBitmapImageFromBytesAsync(imageBytes);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "从磁盘加载图片失败: {FilePath}", filePath);
|
|
return GetPlaceholderImage();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 优化的内存流加载方法
|
|
/// </summary>
|
|
private async Task<BitmapImage> LoadImageViaMemoryStreamAsync(string filePath)
|
|
{
|
|
return await Task.Run(() =>
|
|
{
|
|
try
|
|
{
|
|
// 直接读取文件字节数组
|
|
byte[] imageData = File.ReadAllBytes(filePath);
|
|
|
|
if (imageData == null || imageData.Length == 0)
|
|
{
|
|
_logger.LogWarning("文件为空: {FilePath}", filePath);
|
|
return GetPlaceholderImage();
|
|
}
|
|
|
|
using var memoryStream = new MemoryStream(imageData);
|
|
|
|
var bitmapImage = new BitmapImage();
|
|
bitmapImage.BeginInit();
|
|
bitmapImage.CacheOption = BitmapCacheOption.OnLoad;
|
|
bitmapImage.CreateOptions = BitmapCreateOptions.IgnoreImageCache;
|
|
bitmapImage.StreamSource = memoryStream;
|
|
bitmapImage.EndInit();
|
|
|
|
if (bitmapImage.Width > 0 && bitmapImage.Height > 0)
|
|
{
|
|
bitmapImage.Freeze();
|
|
return bitmapImage;
|
|
}
|
|
|
|
return GetPlaceholderImage();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "通过内存流加载图片失败: {FilePath}", filePath);
|
|
return GetPlaceholderImage();
|
|
}
|
|
});
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region 清理和工具方法
|
|
|
|
public async Task ClearExpiredCacheAsync()
|
|
{
|
|
await Task.Run(() =>
|
|
{
|
|
try
|
|
{
|
|
var directory = new DirectoryInfo(_cacheBaseDirectory);
|
|
if (!directory.Exists) return;
|
|
|
|
int deletedCount = 0;
|
|
foreach (var file in directory.GetFiles("*.jpg"))
|
|
{
|
|
if (IsCacheExpired(file.FullName))
|
|
{
|
|
try
|
|
{
|
|
file.Delete();
|
|
deletedCount++;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "删除缓存文件失败: {FilePath}", file.FullName);
|
|
}
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation("清理过期缓存完成,删除文件数: {DeletedCount}", deletedCount);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "清理过期缓存失败");
|
|
}
|
|
});
|
|
}
|
|
|
|
public async Task ClearAllCacheAsync()
|
|
{
|
|
await Task.Run(() =>
|
|
{
|
|
try
|
|
{
|
|
// 清空内存缓存
|
|
_memoryCache.Clear();
|
|
_memoryPathCache.Clear();
|
|
|
|
var directory = new DirectoryInfo(_cacheBaseDirectory);
|
|
if (directory.Exists)
|
|
{
|
|
directory.Delete(true);
|
|
Directory.CreateDirectory(_cacheBaseDirectory);
|
|
_logger.LogInformation("清空所有缓存完成");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "清空所有缓存失败");
|
|
}
|
|
});
|
|
}
|
|
|
|
private bool IsCacheExpired(string filePath)
|
|
{
|
|
var fileInfo = new FileInfo(filePath);
|
|
return !fileInfo.Exists || (DateTime.Now - fileInfo.LastWriteTime) > _cacheDuration;
|
|
}
|
|
private string GenerateCacheKey(string url)
|
|
{
|
|
using (var md5 = System.Security.Cryptography.MD5.Create())
|
|
{
|
|
byte[] inputBytes = System.Text.Encoding.UTF8.GetBytes(url);
|
|
byte[] hashBytes = md5.ComputeHash(inputBytes);
|
|
return Convert.ToBase64String(hashBytes).Replace("/", "_").Replace("+", "-").Substring(0, 10);
|
|
}
|
|
}
|
|
#endregion
|
|
#endregion
|
|
private BitmapImage GetPlaceholderImage()
|
|
{
|
|
// 返回一个内置的占位图,或者根据需求返回null
|
|
return new BitmapImage();
|
|
}
|
|
|
|
|
|
}
|
|
}
|
|
|