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.
353 lines
13 KiB
353 lines
13 KiB
using AIK.Common;
|
|
using AIK.Common.WebHelper;
|
|
using AIK.Service.Extensions;
|
|
using AIK.Service.IService;
|
|
using Microsoft.Extensions.Logging;
|
|
using SharpCompress.Common;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.IO.Compression;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace AIK.Service.Service
|
|
{
|
|
public class FileCloudDataService : IFileCacheService
|
|
{
|
|
private readonly string _cacheBaseDirectory;
|
|
private readonly IHttpClientService _httpClient;
|
|
private readonly IConfigurationService _configurationService;
|
|
private readonly ILogger<FileBinService> _logger;
|
|
private string GetCacheFilePath(string cacheKey) => Path.Combine(_cacheBaseDirectory, $"{cacheKey}");
|
|
public FileCloudDataService(IConfigurationService configurationService, IHttpClientService httpClient, ILogger<FileBinService> logger)
|
|
{
|
|
_configurationService = configurationService;
|
|
_httpClient = httpClient;
|
|
_logger = logger;
|
|
// 设置缓存目录,例如在用户本地应用数据文件夹下
|
|
_cacheBaseDirectory = CommonString.CloudCacheDirectory;
|
|
Directory.CreateDirectory(_cacheBaseDirectory); // 确保目录存在
|
|
}
|
|
public async Task ClearAllCacheAsync()
|
|
{
|
|
await Task.Run(() =>
|
|
{
|
|
try
|
|
{
|
|
var directory = new DirectoryInfo(_cacheBaseDirectory);
|
|
if (directory.Exists)
|
|
{
|
|
directory.Delete(true);
|
|
Directory.CreateDirectory(_cacheBaseDirectory);
|
|
_logger.LogInformation("清空所有缓存完成");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "清空所有缓存失败");
|
|
}
|
|
});
|
|
}
|
|
|
|
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("*.bin"))
|
|
{
|
|
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<byte[]> GetFileByteAsync(string fileUrl, string cacheKey = null, CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(fileUrl))
|
|
return GetEmptyByte();
|
|
// 生成缓存键,如果未提供则使用URL的MD5值
|
|
var key = cacheKey ?? GenerateCacheKey(fileUrl);
|
|
if (fileUrl.Contains("http"))
|
|
{
|
|
var urifileName = UrlHelper.GetEncodedFileNameFromUrl(fileUrl);
|
|
var realfileName = Uri.UnescapeDataString(urifileName);
|
|
key = realfileName;
|
|
}
|
|
|
|
// 2. 检查磁盘缓存
|
|
string filePath = GetCacheFilePath(key);
|
|
if (File.Exists(filePath))
|
|
{
|
|
//_logger.LogDebug("磁盘缓存命中: {FilePath}", filePath);
|
|
// 从磁盘加载并放入内存
|
|
var filebyte = await LoadFileFromDiskAsync(filePath);
|
|
return filebyte;
|
|
}
|
|
|
|
// 3. 从网络下载
|
|
try
|
|
{
|
|
var fullImgUrl = fileUrl;
|
|
if (fileUrl.Contains("http"))
|
|
{
|
|
fullImgUrl = fileUrl;
|
|
}
|
|
_logger.LogInformation("开始下载bin文件: {Url}", fullImgUrl);
|
|
|
|
// Flurl.Http 字节下载(连接级显式 TLS 1.2;非 2xx 会抛异常,由外层 catch 统一处理)
|
|
byte[] fileBytes = await _httpClient.GetByteArrayWithTimeoutAsync(fullImgUrl);
|
|
|
|
if (fileBytes == null || fileBytes.Length == 0)
|
|
{
|
|
_logger.LogWarning("文件数据为空: {Url}", fullImgUrl);
|
|
return GetEmptyByte();
|
|
}
|
|
|
|
// 保存到磁盘缓存
|
|
await SavefileToDiskAsync(fileBytes, filePath);
|
|
return fileBytes;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "下载文件失败: {Url}", fileUrl);
|
|
}
|
|
|
|
return GetEmptyByte();
|
|
}
|
|
|
|
public async Task<(bool success, string localFilePath)> DownloadFileWithProgressAsync(string fileUrl, string cacheKey, IProgress<double> progress = null, CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(fileUrl))
|
|
return (false, string.Empty);
|
|
|
|
// 生成缓存键,如果未提供则使用URL的MD5值
|
|
var key = cacheKey ?? GenerateCacheKey(fileUrl);
|
|
if (fileUrl.Contains("http"))
|
|
{
|
|
var urifileName = UrlHelper.GetEncodedFileNameFromUrl(fileUrl);
|
|
var realfileName = Uri.UnescapeDataString(urifileName);
|
|
key = realfileName;
|
|
}
|
|
// 2. 检查磁盘缓存
|
|
string filePath = GetCacheFilePath(key);
|
|
if (File.Exists(filePath))
|
|
{
|
|
// 校验缓存 zip 有效性:历史版本截断下载可能留下损坏文件,命中坏缓存会导致解压永远失败
|
|
if (IsValidZipFile(filePath))
|
|
{
|
|
progress?.Report(100.0);
|
|
return (true, filePath);
|
|
}
|
|
|
|
_logger.LogWarning("缓存 zip 文件损坏,删除并重新下载: {FilePath}", filePath);
|
|
try
|
|
{
|
|
File.Delete(filePath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "删除损坏缓存失败: {FilePath}", filePath);
|
|
}
|
|
}
|
|
// 3. 从网络流式下载到临时文件(每段读取空闲超时、无总时长限制;支持断点续传),成功后原子替换
|
|
var fullImgUrl = fileUrl;
|
|
var tempFilePath = filePath + ".tmp";
|
|
const int maxAttempts = 5;
|
|
|
|
for (int attempt = 1; attempt <= maxAttempts; attempt++)
|
|
{
|
|
try
|
|
{
|
|
// 断点续传:检查临时文件已下载的字节数,作为 Range 偏移从断点继续
|
|
long resumeOffset = 0;
|
|
if (File.Exists(tempFilePath))
|
|
{
|
|
resumeOffset = new FileInfo(tempFilePath).Length;
|
|
}
|
|
|
|
if (resumeOffset > 0)
|
|
{
|
|
_logger.LogInformation("断点续传下载: {Url},已有 {Offset} 字节(第 {Attempt}/{Max} 次)",
|
|
fullImgUrl, resumeOffset, attempt, maxAttempts);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogInformation("开始下载bin文件: {Url}", fullImgUrl);
|
|
}
|
|
|
|
// 流式写盘:BouncyCastle TLS + 逐段读取(空闲 60s 超时)+ Range 断点续传
|
|
await _httpClient.DownloadToFileWithProgressAsync(fullImgUrl, tempFilePath, progress, cancellationToken, resumeOffset);
|
|
|
|
// 下载产物完整性校验(拦截截断/错误页),不通过则抛异常走重试
|
|
if (!IsValidZipFile(tempFilePath))
|
|
{
|
|
throw new InvalidDataException($"下载的 zip 文件无效(可能被截断): {fullImgUrl}");
|
|
}
|
|
|
|
// 原子性操作:临时文件替换正式文件
|
|
if (File.Exists(filePath))
|
|
{
|
|
File.Delete(filePath);
|
|
}
|
|
File.Move(tempFilePath, filePath);
|
|
|
|
progress?.Report(100.0);
|
|
return (true, filePath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// 保留本次已下载的临时文件(供下次断点续传),仅在最终失败时清理
|
|
if (attempt < maxAttempts && !cancellationToken.IsCancellationRequested)
|
|
{
|
|
_logger.LogWarning(ex, "下载文件失败(第 {Attempt}/{Max} 次,将断点续传): {Url}",
|
|
attempt, maxAttempts, fullImgUrl);
|
|
await Task.Delay(TimeSpan.FromSeconds(attempt), cancellationToken).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogError(ex, "下载文件失败(最终): {Url}", fullImgUrl);
|
|
try
|
|
{
|
|
if (File.Exists(tempFilePath))
|
|
{
|
|
File.Delete(tempFilePath);
|
|
}
|
|
}
|
|
catch { }
|
|
return (false, string.Empty);
|
|
}
|
|
}
|
|
}
|
|
|
|
return (false, string.Empty);
|
|
}
|
|
|
|
#region 辅助方法
|
|
private byte[] GetEmptyByte()
|
|
{
|
|
return Array.Empty<byte>();
|
|
}
|
|
/// <summary>
|
|
/// 保存字节数组到磁盘
|
|
/// </summary>
|
|
private async Task SavefileToDiskAsync(byte[] fileBytes, string filePath)
|
|
{
|
|
try
|
|
{
|
|
// 使用临时文件避免写入过程中出错
|
|
var tempFilePath = filePath + ".tmp";
|
|
|
|
File.WriteAllBytes(tempFilePath, fileBytes);
|
|
|
|
// 原子性操作:重命名临时文件
|
|
if (File.Exists(filePath))
|
|
{
|
|
File.Delete(filePath);
|
|
}
|
|
File.Move(tempFilePath, filePath);
|
|
|
|
_logger.LogDebug("文件保存成功: {FilePath}, 大小: {Size}字节", filePath, fileBytes.Length);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "保存文件到磁盘失败: {FilePath}", filePath);
|
|
|
|
// 清理临时文件
|
|
var tempFilePath = filePath + ".tmp";
|
|
if (File.Exists(tempFilePath))
|
|
{
|
|
File.Delete(tempFilePath);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// 优化的磁盘加载方法
|
|
/// </summary>
|
|
private async Task<byte[]> LoadFileFromDiskAsync(string filePath)
|
|
{
|
|
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
|
|
{
|
|
return GetEmptyByte();
|
|
}
|
|
|
|
try
|
|
{
|
|
// 直接读取文件字节数组
|
|
byte[] fileBytes = File.ReadAllBytes(filePath);
|
|
|
|
if (fileBytes == null || fileBytes.Length == 0)
|
|
{
|
|
_logger.LogWarning("磁盘缓存文件为空: {FilePath}", filePath);
|
|
// 删除损坏的文件
|
|
File.Delete(filePath);
|
|
return GetEmptyByte();
|
|
}
|
|
|
|
return fileBytes;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "从磁盘加载文件失败: {FilePath}", filePath);
|
|
return GetEmptyByte();
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 校验 zip 文件是否有效(能否打开中央目录记录)。
|
|
/// 用于拦截:历史版本截断下载留下的损坏缓存、本次下载被中途掐断或返回错误页的产物。
|
|
/// </summary>
|
|
private static bool IsValidZipFile(string filePath)
|
|
{
|
|
try
|
|
{
|
|
using (var archive = ZipFile.OpenRead(filePath))
|
|
{
|
|
return archive.Entries.Count > 0 || new FileInfo(filePath).Length > 0;
|
|
}
|
|
}
|
|
catch (Exception ex) when (ex is InvalidDataException || ex is IOException || ex is UnauthorizedAccessException)
|
|
{
|
|
return false;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
#endregion
|
|
}
|
|
}
|
|
|