WorkCamera/client/WorkCameraExport/Services/ResumableDownloader.cs
2026-01-05 23:58:56 +08:00

404 lines
14 KiB
C#
Raw 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.

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;
}
}
}