404 lines
14 KiB
C#
404 lines
14 KiB
C#
using System.Net;
|
||
using System.Text.Json;
|
||
using WorkCameraExport.Services.Interfaces;
|
||
|
||
namespace WorkCameraExport.Services
|
||
{
|
||
/// <summary>
|
||
/// 可恢复下载器 - 支持断点续传
|
||
/// </summary>
|
||
public class ResumableDownloader : IDisposable
|
||
{
|
||
private readonly HttpClient _httpClient;
|
||
private readonly ILogService? _logService;
|
||
private readonly string _downloadStateDir;
|
||
private bool _disposed;
|
||
|
||
// 下载状态文件扩展名
|
||
private const string StateFileExtension = ".download";
|
||
// 临时文件扩展名
|
||
private const string TempFileExtension = ".tmp";
|
||
// 分块大小(1MB)
|
||
private const int ChunkSize = 1024 * 1024;
|
||
|
||
public ResumableDownloader(IConfigService configService, ILogService? logService = null)
|
||
{
|
||
_logService = logService;
|
||
_downloadStateDir = Path.Combine(configService.TempPath, "downloads");
|
||
|
||
if (!Directory.Exists(_downloadStateDir))
|
||
{
|
||
Directory.CreateDirectory(_downloadStateDir);
|
||
}
|
||
|
||
_httpClient = new HttpClient
|
||
{
|
||
Timeout = TimeSpan.FromMinutes(5)
|
||
};
|
||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("WorkCameraExport/2.0 (Windows; .NET)");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 下载文件(支持断点续传)
|
||
/// </summary>
|
||
public async Task<bool> DownloadFileAsync(
|
||
string url,
|
||
string outputPath,
|
||
IProgress<DownloadProgressInfo>? progress = null,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var downloadId = GetDownloadId(url);
|
||
var stateFilePath = Path.Combine(_downloadStateDir, $"{downloadId}{StateFileExtension}");
|
||
var tempFilePath = outputPath + TempFileExtension;
|
||
|
||
try
|
||
{
|
||
// 加载或创建下载状态
|
||
var state = LoadDownloadState(stateFilePath) ?? new DownloadState
|
||
{
|
||
Url = url,
|
||
OutputPath = outputPath,
|
||
DownloadedBytes = 0,
|
||
TotalBytes = 0,
|
||
StartTime = DateTime.Now
|
||
};
|
||
|
||
// 检查临时文件是否存在,获取已下载的字节数
|
||
long downloadedBytes = 0;
|
||
if (File.Exists(tempFilePath))
|
||
{
|
||
var fileInfo = new FileInfo(tempFilePath);
|
||
downloadedBytes = fileInfo.Length;
|
||
state.DownloadedBytes = downloadedBytes;
|
||
}
|
||
|
||
// 获取文件总大小
|
||
var totalBytes = await GetFileSizeAsync(url, cancellationToken);
|
||
if (totalBytes <= 0)
|
||
{
|
||
_logService?.Warn($"无法获取文件大小: {url}");
|
||
// 如果无法获取文件大小,尝试普通下载
|
||
return await DownloadWithoutResumeAsync(url, outputPath, progress, cancellationToken);
|
||
}
|
||
|
||
state.TotalBytes = totalBytes;
|
||
|
||
// 如果已经下载完成
|
||
if (downloadedBytes >= totalBytes)
|
||
{
|
||
if (File.Exists(tempFilePath))
|
||
{
|
||
File.Move(tempFilePath, outputPath, true);
|
||
}
|
||
CleanupDownloadState(stateFilePath);
|
||
progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, true));
|
||
return true;
|
||
}
|
||
|
||
// 创建带 Range 头的请求
|
||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||
if (downloadedBytes > 0)
|
||
{
|
||
request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(downloadedBytes, null);
|
||
_logService?.Info($"断点续传: 从 {downloadedBytes} 字节开始下载");
|
||
}
|
||
|
||
using var response = await _httpClient.SendAsync(
|
||
request,
|
||
HttpCompletionOption.ResponseHeadersRead,
|
||
cancellationToken);
|
||
|
||
// 检查服务器是否支持断点续传
|
||
if (response.StatusCode == HttpStatusCode.PartialContent ||
|
||
response.StatusCode == HttpStatusCode.OK)
|
||
{
|
||
// 如果服务器返回 200 OK(不支持 Range),需要重新下载
|
||
if (response.StatusCode == HttpStatusCode.OK && downloadedBytes > 0)
|
||
{
|
||
_logService?.Info("服务器不支持断点续传,重新下载");
|
||
downloadedBytes = 0;
|
||
if (File.Exists(tempFilePath))
|
||
{
|
||
File.Delete(tempFilePath);
|
||
}
|
||
}
|
||
|
||
// 打开文件流(追加模式)
|
||
using var fileStream = new FileStream(
|
||
tempFilePath,
|
||
downloadedBytes > 0 ? FileMode.Append : FileMode.Create,
|
||
FileAccess.Write,
|
||
FileShare.None,
|
||
bufferSize: ChunkSize,
|
||
useAsync: true);
|
||
|
||
using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||
|
||
var buffer = new byte[ChunkSize];
|
||
int bytesRead;
|
||
var lastProgressReport = DateTime.Now;
|
||
|
||
while ((bytesRead = await contentStream.ReadAsync(buffer, cancellationToken)) > 0)
|
||
{
|
||
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
|
||
downloadedBytes += bytesRead;
|
||
state.DownloadedBytes = downloadedBytes;
|
||
|
||
// 每秒报告一次进度
|
||
if ((DateTime.Now - lastProgressReport).TotalMilliseconds >= 500)
|
||
{
|
||
progress?.Report(new DownloadProgressInfo(downloadedBytes, totalBytes, false));
|
||
SaveDownloadState(stateFilePath, state);
|
||
lastProgressReport = DateTime.Now;
|
||
}
|
||
}
|
||
|
||
// 下载完成,移动临时文件到目标位置
|
||
fileStream.Close();
|
||
File.Move(tempFilePath, outputPath, true);
|
||
CleanupDownloadState(stateFilePath);
|
||
|
||
progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, true));
|
||
_logService?.Info($"下载完成: {url}");
|
||
return true;
|
||
}
|
||
else
|
||
{
|
||
_logService?.Warn($"下载失败,状态码: {response.StatusCode}");
|
||
return false;
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
_logService?.Info($"下载已取消: {url}");
|
||
return false;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logService?.Error($"下载异常: {url}", ex);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 不支持断点续传的普通下载
|
||
/// </summary>
|
||
private async Task<bool> DownloadWithoutResumeAsync(
|
||
string url,
|
||
string outputPath,
|
||
IProgress<DownloadProgressInfo>? progress,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
using var response = await _httpClient.GetAsync(url, cancellationToken);
|
||
|
||
if (response.IsSuccessStatusCode)
|
||
{
|
||
var data = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||
await File.WriteAllBytesAsync(outputPath, data, cancellationToken);
|
||
progress?.Report(new DownloadProgressInfo(data.Length, data.Length, true));
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logService?.Error($"普通下载失败: {url}", ex);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取文件大小
|
||
/// </summary>
|
||
private async Task<long> GetFileSizeAsync(string url, CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
using var request = new HttpRequestMessage(HttpMethod.Head, url);
|
||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||
|
||
if (response.IsSuccessStatusCode && response.Content.Headers.ContentLength.HasValue)
|
||
{
|
||
return response.Content.Headers.ContentLength.Value;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logService?.Warn($"获取文件大小失败: {ex.Message}");
|
||
}
|
||
|
||
return -1;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取下载 ID(基于 URL 的哈希)
|
||
/// </summary>
|
||
private static string GetDownloadId(string url)
|
||
{
|
||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||
var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(url));
|
||
return Convert.ToHexString(hashBytes)[..16];
|
||
}
|
||
|
||
/// <summary>
|
||
/// 加载下载状态
|
||
/// </summary>
|
||
private DownloadState? LoadDownloadState(string stateFilePath)
|
||
{
|
||
try
|
||
{
|
||
if (File.Exists(stateFilePath))
|
||
{
|
||
var json = File.ReadAllText(stateFilePath);
|
||
return JsonSerializer.Deserialize<DownloadState>(json);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logService?.Warn($"加载下载状态失败: {ex.Message}");
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 保存下载状态
|
||
/// </summary>
|
||
private void SaveDownloadState(string stateFilePath, DownloadState state)
|
||
{
|
||
try
|
||
{
|
||
var json = JsonSerializer.Serialize(state);
|
||
File.WriteAllText(stateFilePath, json);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logService?.Warn($"保存下载状态失败: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 清理下载状态
|
||
/// </summary>
|
||
private void CleanupDownloadState(string stateFilePath)
|
||
{
|
||
try
|
||
{
|
||
if (File.Exists(stateFilePath))
|
||
{
|
||
File.Delete(stateFilePath);
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// 忽略清理错误
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 清理所有未完成的下载
|
||
/// </summary>
|
||
public void CleanupIncompleteDownloads()
|
||
{
|
||
try
|
||
{
|
||
if (Directory.Exists(_downloadStateDir))
|
||
{
|
||
foreach (var file in Directory.GetFiles(_downloadStateDir, $"*{StateFileExtension}"))
|
||
{
|
||
try { File.Delete(file); } catch { }
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logService?.Warn($"清理未完成下载失败: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取未完成的下载列表
|
||
/// </summary>
|
||
public List<DownloadState> GetIncompleteDownloads()
|
||
{
|
||
var result = new List<DownloadState>();
|
||
|
||
try
|
||
{
|
||
if (Directory.Exists(_downloadStateDir))
|
||
{
|
||
foreach (var file in Directory.GetFiles(_downloadStateDir, $"*{StateFileExtension}"))
|
||
{
|
||
var state = LoadDownloadState(file);
|
||
if (state != null)
|
||
{
|
||
result.Add(state);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logService?.Warn($"获取未完成下载列表失败: {ex.Message}");
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
#region IDisposable
|
||
|
||
public void Dispose()
|
||
{
|
||
Dispose(true);
|
||
GC.SuppressFinalize(this);
|
||
}
|
||
|
||
protected virtual void Dispose(bool disposing)
|
||
{
|
||
if (!_disposed)
|
||
{
|
||
if (disposing)
|
||
{
|
||
_httpClient.Dispose();
|
||
}
|
||
_disposed = true;
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
}
|
||
|
||
/// <summary>
|
||
/// 下载状态
|
||
/// </summary>
|
||
public class DownloadState
|
||
{
|
||
public string Url { get; set; } = "";
|
||
public string OutputPath { get; set; } = "";
|
||
public long DownloadedBytes { get; set; }
|
||
public long TotalBytes { get; set; }
|
||
public DateTime StartTime { get; set; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// 下载进度信息
|
||
/// </summary>
|
||
public class DownloadProgressInfo
|
||
{
|
||
public long DownloadedBytes { get; }
|
||
public long TotalBytes { get; }
|
||
public bool IsCompleted { get; }
|
||
public double ProgressPercent => TotalBytes > 0 ? (double)DownloadedBytes / TotalBytes * 100 : 0;
|
||
|
||
public DownloadProgressInfo(long downloadedBytes, long totalBytes, bool isCompleted)
|
||
{
|
||
DownloadedBytes = downloadedBytes;
|
||
TotalBytes = totalBytes;
|
||
IsCompleted = isCompleted;
|
||
}
|
||
}
|
||
}
|