Files
2026-08-11 19:41:54 +08:00

181 lines
6.8 KiB
C#
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.
using System.Net;
using System.Net.Http.Headers;
using LanDesktopPLONDS.Installer.Models;
namespace LanDesktopPLONDS.Installer.Services;
/// <summary>
/// 弹性下载器:支持 HTTP Range 续传和传输停滞检测。
/// 重试(指数退避)逻辑由调用方 InstallerPlondsClient 负责。
/// </summary>
internal static class ResilientDownloader
{
/// <summary>传输停滞超时(秒):连续无数据则中止当前尝试。</summary>
internal const int StallTimeoutSeconds = 60;
/// <summary>
/// 单次下载尝试:检查 .partial 文件实现 Range 续传,使用停滞检测 CTS 保护传输。
/// 成功时将 .partial 重命名为 destinationPath。
/// </summary>
/// <param name="httpClient">HTTP 客户端(不限全局超时)。</param>
/// <param name="url">下载 URL。</param>
/// <param name="destinationPath">最终文件路径(.partial 会被重命名到此处)。</param>
/// <param name="progress">进度回调,报告已下载字节数。</param>
/// <param name="parentCancellationToken">外部取消令牌。</param>
public static async Task DownloadSingleAttemptAsync(
HttpClient httpClient,
Uri url,
string destinationPath,
IProgress<long>? progress,
CancellationToken parentCancellationToken)
{
var partialPath = $"{destinationPath}.partial";
// 检查已有的 .partial 文件大小用于续传
long existingBytes = 0;
if (File.Exists(partialPath))
{
existingBytes = new FileInfo(partialPath).Length;
}
// 停滞检测:创建独立 CTS超时则取消当前尝试
using var stallCts = new CancellationTokenSource();
using var stallTimer = new Timer(
static state => ((CancellationTokenSource)state!).Cancel(),
stallCts,
Timeout.Infinite,
Timeout.Infinite);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
parentCancellationToken, stallCts.Token);
// 重置停滞定时器
stallTimer.Change(TimeSpan.FromSeconds(StallTimeoutSeconds), Timeout.InfiniteTimeSpan);
try
{
HttpResponseMessage response;
if (existingBytes > 0)
{
// 尝试 Range 续传
response = await SendRangeRequestAsync(httpClient, url, existingBytes, linkedCts.Token)
.ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable)
{
// 服务器不支持续传或已重置,从头下载
response.Dispose();
existingBytes = 0;
File.Delete(partialPath);
response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, linkedCts.Token)
.ConfigureAwait(false);
}
else if (response.StatusCode == HttpStatusCode.PartialContent)
{
// 服务器支持续传,追加到已有文件
}
else if (response.IsSuccessStatusCode)
{
// 服务器返回 200不支持 Range从头下载
existingBytes = 0;
File.Delete(partialPath);
}
else
{
response.EnsureSuccessStatusCode(); // 抛出异常
return; // unreachable but satisfies compiler
}
}
else
{
response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, linkedCts.Token)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
try
{
var totalBytes = response.Content.Headers.ContentLength;
await using var responseStream = await response.Content.ReadAsStreamAsync(linkedCts.Token)
.ConfigureAwait(false);
await using var fileStream = new FileStream(
partialPath,
FileMode.Append,
FileAccess.Write,
FileShare.None,
bufferSize: 128 * 1024,
useAsync: true);
var buffer = new byte[128 * 1024];
long totalDownloaded = existingBytes;
while (true)
{
var bytesRead = await responseStream.ReadAsync(buffer, linkedCts.Token).ConfigureAwait(false);
if (bytesRead == 0)
{
break;
}
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), linkedCts.Token).ConfigureAwait(false);
totalDownloaded += bytesRead;
// 重置停滞定时器
stallTimer.Change(TimeSpan.FromSeconds(StallTimeoutSeconds), Timeout.InfiniteTimeSpan);
progress?.Report(totalDownloaded);
}
await fileStream.FlushAsync(linkedCts.Token).ConfigureAwait(false);
}
finally
{
response.Dispose();
}
// 下载完成,重命名 .partial → 目标文件
File.Move(partialPath, destinationPath, overwrite: true);
}
finally
{
stallTimer.Change(Timeout.Infinite, Timeout.Infinite);
}
}
/// <summary>
/// 发送带 Range 头的 HEAD 请求检测服务器是否支持续传。
/// </summary>
public static async Task<bool> CheckRangeSupportAsync(
HttpClient httpClient,
Uri url,
long existingBytes,
CancellationToken cancellationToken)
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Head, url);
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
return response.Headers.AcceptRanges.Contains("bytes");
}
catch
{
return false;
}
}
/// <summary>
/// 发送带 Range 头的 GET 请求开始续传下载。
/// </summary>
private static async Task<HttpResponseMessage> SendRangeRequestAsync(
HttpClient httpClient,
Uri url,
long rangeStart,
CancellationToken cancellationToken)
{
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Range = new RangeHeaderValue(rangeStart, null);
return await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
}
}