mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-08-18 15:20:47 +08:00
511 lines
19 KiB
C#
511 lines
19 KiB
C#
|
|
using System.IO.Compression;
|
|||
|
|
using System.Net;
|
|||
|
|
using System.Net.Http.Headers;
|
|||
|
|
using System.Security.Cryptography;
|
|||
|
|
using LanDesktopPLONDS.Installer.Services;
|
|||
|
|
using LanMountainDesktop.Shared.Contracts.Deployment;
|
|||
|
|
using Xunit;
|
|||
|
|
|
|||
|
|
namespace LanMountainDesktop.Tests;
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 弹性下载器与诊断聚合测试:覆盖 semver 排序、md5 拒绝、sha256 接受、
|
|||
|
|
/// 重试成功、Range 续传、诊断聚合消息等六个维度。
|
|||
|
|
/// </summary>
|
|||
|
|
public sealed class InstallerDownloadResilienceTests : IDisposable
|
|||
|
|
{
|
|||
|
|
private readonly string _tempRoot = Path.Combine(
|
|||
|
|
AppContext.BaseDirectory,
|
|||
|
|
"TestArtifacts",
|
|||
|
|
"LanMountainDesktop.Tests",
|
|||
|
|
nameof(InstallerDownloadResilienceTests),
|
|||
|
|
Guid.NewGuid().ToString("N"));
|
|||
|
|
|
|||
|
|
public void Dispose()
|
|||
|
|
{
|
|||
|
|
if (Directory.Exists(_tempRoot))
|
|||
|
|
{
|
|||
|
|
Directory.Delete(_tempRoot, recursive: true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#region 1. SemanticVersion 排序(含预发布版本)
|
|||
|
|
|
|||
|
|
[Theory]
|
|||
|
|
[InlineData("0.8.5-beta.1", "0.8.5", true)] // 预发布 < 正式版
|
|||
|
|
[InlineData("1.0.0-alpha", "1.0.0-beta", true)] // alpha < beta
|
|||
|
|
[InlineData("1.0.0-beta", "1.0.0-rc.1", true)] // beta < rc
|
|||
|
|
[InlineData("1.0.0-rc.1", "1.0.0", true)] // rc < 正式版
|
|||
|
|
[InlineData("1.0.0-alpha.1", "1.0.0-alpha.2", true)] // 数字排序
|
|||
|
|
[InlineData("1.0.0", "2.0.0", true)] // 主版本排序
|
|||
|
|
[InlineData("1.2.3", "1.2.3", false)] // 相等 (not less)
|
|||
|
|
[InlineData("1.0.0-beta.11", "1.0.0-beta.2", false)] // 11 > 2 (not less)
|
|||
|
|
public void SemanticVersion_Ordering_Correct(string left, string right, bool leftLessThanRight)
|
|||
|
|
{
|
|||
|
|
var v1 = SemanticVersion.Parse(left);
|
|||
|
|
var v2 = SemanticVersion.Parse(right);
|
|||
|
|
if (leftLessThanRight)
|
|||
|
|
{
|
|||
|
|
Assert.True(v1 < v2, $"Expected {left} < {right}, got CompareTo={v1.CompareTo(v2)}");
|
|||
|
|
}
|
|||
|
|
else
|
|||
|
|
{
|
|||
|
|
Assert.True(v1 >= v2, $"Expected {left} >= {right}, got CompareTo={v1.CompareTo(v2)}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void SemanticVersion_StableVersionAlwaysBeatsPrerelease()
|
|||
|
|
{
|
|||
|
|
var stable = SemanticVersion.Parse("0.8.5");
|
|||
|
|
var prerelease = SemanticVersion.Parse("0.8.5-beta.1");
|
|||
|
|
|
|||
|
|
Assert.True(stable > prerelease);
|
|||
|
|
Assert.True(prerelease < stable);
|
|||
|
|
Assert.Equal(1, stable.CompareTo(prerelease));
|
|||
|
|
Assert.Equal(-1, prerelease.CompareTo(stable));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void SemanticVersion_PrereleaseAccepted()
|
|||
|
|
{
|
|||
|
|
Assert.True(SemanticVersion.TryParse("0.8.5-beta.1", out var parsed));
|
|||
|
|
Assert.NotNull(parsed);
|
|||
|
|
Assert.Equal(0, parsed!.Major);
|
|||
|
|
Assert.Equal(8, parsed.Minor);
|
|||
|
|
Assert.Equal(5, parsed.Patch);
|
|||
|
|
Assert.Equal("beta.1", parsed.Prerelease);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void SemanticVersion_FourPartAccepted()
|
|||
|
|
{
|
|||
|
|
Assert.True(SemanticVersion.TryParse("1.2.3.4", out var parsed));
|
|||
|
|
Assert.NotNull(parsed);
|
|||
|
|
Assert.Equal(4, parsed!.Revision);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Theory]
|
|||
|
|
[InlineData(null)]
|
|||
|
|
[InlineData("")]
|
|||
|
|
[InlineData("abc")]
|
|||
|
|
[InlineData("1")]
|
|||
|
|
[InlineData("1.2.3.4.5")]
|
|||
|
|
public void SemanticVersion_InvalidFormat_Rejected(string? value)
|
|||
|
|
{
|
|||
|
|
Assert.False(SemanticVersion.TryParse(value, out _));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void SemanticVersion_OrderingInFindLatest_SortsCandidatesCorrectly()
|
|||
|
|
{
|
|||
|
|
// 模拟候选列表,验证排序逻辑与 FindLatestAsync 一致
|
|||
|
|
var versions = new[] { "0.8.5-beta.1", "0.8.5-alpha.1", "0.8.5", "0.8.4" };
|
|||
|
|
var parsed = versions
|
|||
|
|
.Select(v => (Original: v, Semver: SemanticVersion.Parse(v)))
|
|||
|
|
.OrderByDescending(x => x.Semver)
|
|||
|
|
.Select(x => x.Original)
|
|||
|
|
.ToArray();
|
|||
|
|
|
|||
|
|
Assert.Equal("0.8.5", parsed[0]);
|
|||
|
|
Assert.Equal("0.8.5-beta.1", parsed[1]);
|
|||
|
|
Assert.Equal("0.8.5-alpha.1", parsed[2]);
|
|||
|
|
Assert.Equal("0.8.4", parsed[3]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#endregion
|
|||
|
|
|
|||
|
|
#region 2. MD5 拒绝
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void VerifyPackage_Md5Checksum_RejectsWithChineseError()
|
|||
|
|
{
|
|||
|
|
var zipPath = CreateTestZip("test-content");
|
|||
|
|
|
|||
|
|
// ParseChecksum 应该拒绝 MD5 — 反射调用抛 TargetInvocationException 包装
|
|||
|
|
var ex = Assert.ThrowsAny<Exception>(() => InvokeParseChecksum("md5:" + ComputeMd5(zipPath)));
|
|||
|
|
var inner = ex is System.Reflection.TargetInvocationException tie ? tie.InnerException! : ex;
|
|||
|
|
Assert.IsType<InvalidDataException>(inner);
|
|||
|
|
Assert.Contains("MD5", inner.Message, StringComparison.OrdinalIgnoreCase);
|
|||
|
|
Assert.Contains("不被支持", inner.Message, StringComparison.OrdinalIgnoreCase);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void VerifyPackage_Md5BareHex_RejectsWithChineseError()
|
|||
|
|
{
|
|||
|
|
// 32 位十六进制 = MD5,应该被拒绝
|
|||
|
|
var md5Hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4";
|
|||
|
|
var ex = Assert.ThrowsAny<Exception>(() => InvokeParseChecksum(md5Hash));
|
|||
|
|
var inner = ex is System.Reflection.TargetInvocationException tie ? tie.InnerException! : ex;
|
|||
|
|
Assert.IsType<InvalidDataException>(inner);
|
|||
|
|
Assert.Contains("MD5", inner.Message, StringComparison.OrdinalIgnoreCase);
|
|||
|
|
Assert.Contains("32 位十六进制", inner.Message, StringComparison.OrdinalIgnoreCase);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#endregion
|
|||
|
|
|
|||
|
|
#region 3. SHA-256 接受
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public async Task VerifyPackage_Sha256Checksum_Accepts()
|
|||
|
|
{
|
|||
|
|
var zipPath = CreateTestZip("test-content-for-sha256");
|
|||
|
|
var sha256Hash = ComputeSha256(zipPath);
|
|||
|
|
var manifest = CreateManifestWithChecksum("sha256:" + sha256Hash);
|
|||
|
|
|
|||
|
|
// ParseChecksum 应该接受 SHA-256
|
|||
|
|
var (algorithm, hash) = InvokeParseChecksum("sha256:" + sha256Hash);
|
|||
|
|
Assert.Equal("sha256", algorithm);
|
|||
|
|
Assert.Equal(sha256Hash, hash);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public void VerifyPackage_Sha256BareHex_Accepts()
|
|||
|
|
{
|
|||
|
|
// 64 位十六进制 = SHA-256,应该被接受
|
|||
|
|
var sha256Hash = new string('a', 64);
|
|||
|
|
var (algorithm, hash) = InvokeParseChecksum(sha256Hash);
|
|||
|
|
Assert.Equal("sha256", algorithm);
|
|||
|
|
Assert.Equal(sha256Hash, hash);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#endregion
|
|||
|
|
|
|||
|
|
#region 4. 重试成功(2 次失败后成功)
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public async Task DownloadAndPrepare_RetrySucceedsAfterTwoFailures()
|
|||
|
|
{
|
|||
|
|
var zipPath = CreateTestZip("retry-test-content");
|
|||
|
|
var sha256Hash = ComputeSha256(zipPath);
|
|||
|
|
|
|||
|
|
// 前 2 次返回 500,第 3 次返回内容
|
|||
|
|
var handler = new RetryThenSuccessHandler(zipPath, failCount: 2);
|
|||
|
|
var client = new InstallerPlondsClient(
|
|||
|
|
new HttpClient(handler),
|
|||
|
|
Path.Combine(_tempRoot, "staging"),
|
|||
|
|
_ => TimeSpan.Zero); // 测试中跳过实际等待
|
|||
|
|
var candidate = CreateCandidate(
|
|||
|
|
sha256Hash: sha256Hash,
|
|||
|
|
filesZipUrl: "https://test.example.com/Files.zip");
|
|||
|
|
|
|||
|
|
var package = await client.DownloadAndPrepareFullPackageAsync(candidate, null, CancellationToken.None);
|
|||
|
|
|
|||
|
|
Assert.True(File.Exists(package.ZipPath));
|
|||
|
|
Assert.Equal(4, handler.RequestCount); // 2次失败(HEAD+GET) + 2次成功(HEAD+GET)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public async Task DownloadAndPrepare_AllRetriesFail_ThrowsAfterMaxAttempts()
|
|||
|
|
{
|
|||
|
|
var handler = new AlwaysFailHandler();
|
|||
|
|
var client = new InstallerPlondsClient(
|
|||
|
|
new HttpClient(handler),
|
|||
|
|
Path.Combine(_tempRoot, "staging"),
|
|||
|
|
_ => TimeSpan.Zero);
|
|||
|
|
var candidate = CreateCandidate(
|
|||
|
|
sha256Hash: "0000000000000000000000000000000000000000000000000000000000000000",
|
|||
|
|
filesZipUrl: "https://test.example.com/Files.zip");
|
|||
|
|
|
|||
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|||
|
|
() => client.DownloadAndPrepareFullPackageAsync(candidate, null, CancellationToken.None));
|
|||
|
|
|
|||
|
|
Assert.NotNull(ex.InnerException);
|
|||
|
|
// 1个唯一URL × 3次重试 × 2个请求(HEAD+GET) = 6
|
|||
|
|
Assert.Equal(6, handler.RequestCount);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#endregion
|
|||
|
|
|
|||
|
|
#region 5. Range 续传(.partial 文件存在时发送 Range 头)
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public async Task DownloadAndPrepare_ResumeSendsRangeHeader()
|
|||
|
|
{
|
|||
|
|
var zipPath = CreateTestZip("resume-test-content");
|
|||
|
|
var stagingDir = Path.Combine(_tempRoot, "staging-resume");
|
|||
|
|
var packageDir = Path.Combine(stagingDir, "1.0.0", "s3", "full");
|
|||
|
|
var partialPath = Path.Combine(packageDir, "Files.zip.partial");
|
|||
|
|
|
|||
|
|
// 创建部分下载的 .partial 文件(5 字节)
|
|||
|
|
Directory.CreateDirectory(packageDir);
|
|||
|
|
var partialContent = new byte[5];
|
|||
|
|
RandomNumberGenerator.Fill(partialContent);
|
|||
|
|
await File.WriteAllBytesAsync(partialPath, partialContent);
|
|||
|
|
|
|||
|
|
var fullContent = await File.ReadAllBytesAsync(zipPath);
|
|||
|
|
var handler = new RangeResumeHandler(fullContent, partialContent.Length);
|
|||
|
|
var client = new InstallerPlondsClient(
|
|||
|
|
new HttpClient(handler),
|
|||
|
|
stagingDir,
|
|||
|
|
_ => TimeSpan.Zero);
|
|||
|
|
|
|||
|
|
var sha256Hash = ComputeSha256(zipPath);
|
|||
|
|
var candidate = CreateCandidate(
|
|||
|
|
sha256Hash: sha256Hash,
|
|||
|
|
filesZipUrl: "https://test.example.com/Files.zip");
|
|||
|
|
|
|||
|
|
var package = await client.DownloadAndPrepareFullPackageAsync(candidate, null, CancellationToken.None);
|
|||
|
|
|
|||
|
|
Assert.True(handler.RangeHeaderSent);
|
|||
|
|
Assert.Equal(partialContent.Length, handler.RangeStart);
|
|||
|
|
Assert.True(File.Exists(package.ZipPath));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#endregion
|
|||
|
|
|
|||
|
|
#region 6. 诊断聚合消息包含所有失败源 ID
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public async Task FindLatest_AllSourcesFailed_MessageContainsAllIds()
|
|||
|
|
{
|
|||
|
|
// 使用总是返回 500 的 handler
|
|||
|
|
var handler = new AlwaysFailHandler();
|
|||
|
|
var client = new InstallerPlondsClient(
|
|||
|
|
new HttpClient(handler),
|
|||
|
|
Path.Combine(_tempRoot, "staging-no-sources"));
|
|||
|
|
|
|||
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|||
|
|
() => client.FindLatestAsync(CancellationToken.None));
|
|||
|
|
|
|||
|
|
// 消息应包含中文 "所有下载源均不可用"
|
|||
|
|
Assert.Contains("所有下载源均不可用", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|||
|
|
// 消息应包含至少一个源 ID
|
|||
|
|
Assert.Contains("-", ex.Message, StringComparison.Ordinal);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public async Task FindLatest_SingleSourceFails_MessageContainsSourceId()
|
|||
|
|
{
|
|||
|
|
var handler = new AlwaysFailHandler();
|
|||
|
|
var client = new InstallerPlondsClient(
|
|||
|
|
new HttpClient(handler),
|
|||
|
|
Path.Combine(_tempRoot, "staging-single"));
|
|||
|
|
|
|||
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|||
|
|
() => client.FindLatestAsync(CancellationToken.None));
|
|||
|
|
|
|||
|
|
// 内置源 "s3" 和 "github" 应该出现在错误消息中
|
|||
|
|
Assert.Contains("s3", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|||
|
|
Assert.Contains("github", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
[Fact]
|
|||
|
|
public async Task FindLatest_DiagnosticReport_AggregatesMultipleFailures()
|
|||
|
|
{
|
|||
|
|
// 使用返回 404 的 handler 模拟所有源失败
|
|||
|
|
var handler = new StatusCodeHandler(HttpStatusCode.NotFound);
|
|||
|
|
var client = new InstallerPlondsClient(
|
|||
|
|
new HttpClient(handler),
|
|||
|
|
Path.Combine(_tempRoot, "staging-404"));
|
|||
|
|
|
|||
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|||
|
|
() => client.FindLatestAsync(CancellationToken.None));
|
|||
|
|
|
|||
|
|
// 每行应包含一个源 ID
|
|||
|
|
var lines = ex.Message.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
|||
|
|
Assert.True(lines.Length >= 2, $"期望至少2行错误信息,实际 {lines.Length} 行");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#endregion
|
|||
|
|
|
|||
|
|
#region 辅助方法
|
|||
|
|
|
|||
|
|
private static InstallerPlondsManifest CreateManifestWithChecksum(string checksum)
|
|||
|
|
{
|
|||
|
|
return new InstallerPlondsManifest(
|
|||
|
|
"1",
|
|||
|
|
"1.0.0",
|
|||
|
|
"0.9.0",
|
|||
|
|
true,
|
|||
|
|
false,
|
|||
|
|
"stable",
|
|||
|
|
"windows-x64",
|
|||
|
|
DateTimeOffset.UtcNow,
|
|||
|
|
new Dictionary<string, InstallerPlondsFileEntry>(),
|
|||
|
|
new Dictionary<string, InstallerPlondsChangedFileEntry>(),
|
|||
|
|
new Dictionary<string, string> { ["Files.zip"] = checksum },
|
|||
|
|
null,
|
|||
|
|
null);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static InstallerPlondsCandidate CreateCandidate(
|
|||
|
|
string sha256Hash,
|
|||
|
|
string filesZipUrl,
|
|||
|
|
string version = "1.0.0")
|
|||
|
|
{
|
|||
|
|
var manifest = new InstallerPlondsManifest(
|
|||
|
|
"1",
|
|||
|
|
version,
|
|||
|
|
"0.9.0",
|
|||
|
|
true,
|
|||
|
|
false,
|
|||
|
|
"stable",
|
|||
|
|
"windows-x64",
|
|||
|
|
DateTimeOffset.UtcNow,
|
|||
|
|
new Dictionary<string, InstallerPlondsFileEntry>(),
|
|||
|
|
new Dictionary<string, InstallerPlondsChangedFileEntry>(),
|
|||
|
|
new Dictionary<string, string> { ["Files.zip"] = "sha256:" + sha256Hash },
|
|||
|
|
new InstallerPlondsDownloads(
|
|||
|
|
new InstallerPlondsGitHubDownloads(null, null, null, filesZipUrl),
|
|||
|
|
null),
|
|||
|
|
null);
|
|||
|
|
|
|||
|
|
return new InstallerPlondsCandidate(
|
|||
|
|
new InstallerPlondsSource("s3", "s3", "https://test.example.com/PLONDS.json", 100),
|
|||
|
|
manifest,
|
|||
|
|
new Uri(filesZipUrl));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private string CreateTestZip(string content)
|
|||
|
|
{
|
|||
|
|
var dir = Path.Combine(_tempRoot, "zips");
|
|||
|
|
Directory.CreateDirectory(dir);
|
|||
|
|
var zipPath = Path.Combine(dir, $"test-{Guid.NewGuid():N}.zip");
|
|||
|
|
using (var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create))
|
|||
|
|
{
|
|||
|
|
var entry = archive.CreateEntry("test.txt");
|
|||
|
|
using var writer = new StreamWriter(entry.Open());
|
|||
|
|
writer.Write(content);
|
|||
|
|
}
|
|||
|
|
return zipPath;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static string ComputeSha256(string filePath)
|
|||
|
|
{
|
|||
|
|
using var sha = SHA256.Create();
|
|||
|
|
using var stream = File.OpenRead(filePath);
|
|||
|
|
return Convert.ToHexString(sha.ComputeHash(stream)).ToLowerInvariant();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static string ComputeMd5(string filePath)
|
|||
|
|
{
|
|||
|
|
using var md5 = MD5.Create();
|
|||
|
|
using var stream = File.OpenRead(filePath);
|
|||
|
|
return Convert.ToHexString(md5.ComputeHash(stream)).ToLowerInvariant();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 反射调用 InstallerPlondsClient 的私有 ParseChecksum 方法进行测试。
|
|||
|
|
/// </summary>
|
|||
|
|
private static (string Algorithm, string Hash) InvokeParseChecksum(string checksum)
|
|||
|
|
{
|
|||
|
|
var method = typeof(InstallerPlondsClient).GetMethod(
|
|||
|
|
"ParseChecksum",
|
|||
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|||
|
|
if (method is null)
|
|||
|
|
{
|
|||
|
|
throw new InvalidOperationException("ParseChecksum 方法未找到,请检查访问修饰符。");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return ((string, string))method.Invoke(null, [checksum])!;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#endregion
|
|||
|
|
|
|||
|
|
#region 测试用 HttpMessageHandler
|
|||
|
|
|
|||
|
|
/// <summary>总是返回 500 的 Handler。</summary>
|
|||
|
|
private sealed class AlwaysFailHandler : HttpMessageHandler
|
|||
|
|
{
|
|||
|
|
private int _requestCount;
|
|||
|
|
public int RequestCount => Volatile.Read(ref _requestCount);
|
|||
|
|
|
|||
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|||
|
|
{
|
|||
|
|
Interlocked.Increment(ref _requestCount);
|
|||
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>前 N 次返回 500,之后返回文件内容的 Handler。</summary>
|
|||
|
|
private sealed class RetryThenSuccessHandler(string zipPath, int failCount) : HttpMessageHandler
|
|||
|
|
{
|
|||
|
|
private int _requestCount;
|
|||
|
|
public int RequestCount => Volatile.Read(ref _requestCount);
|
|||
|
|
|
|||
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|||
|
|
{
|
|||
|
|
var count = Interlocked.Increment(ref _requestCount);
|
|||
|
|
if (count <= failCount)
|
|||
|
|
{
|
|||
|
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var content = File.ReadAllBytes(zipPath);
|
|||
|
|
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
|||
|
|
{
|
|||
|
|
Content = new ByteArrayContent(content)
|
|||
|
|
};
|
|||
|
|
response.Content.Headers.ContentLength = content.Length;
|
|||
|
|
return Task.FromResult(response);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>返回指定状态码的 Handler。</summary>
|
|||
|
|
private sealed class StatusCodeHandler(HttpStatusCode statusCode) : HttpMessageHandler
|
|||
|
|
{
|
|||
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|||
|
|
{
|
|||
|
|
return Task.FromResult(new HttpResponseMessage(statusCode));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 支持 Range 续传的 Handler:检查 Range 头并返回对应数据片段。
|
|||
|
|
/// </summary>
|
|||
|
|
private sealed class RangeResumeHandler(byte[] fullContent, int _expectedRangeStart) : HttpMessageHandler
|
|||
|
|
{
|
|||
|
|
public bool RangeHeaderSent { get; private set; }
|
|||
|
|
public long RangeStart { get; private set; }
|
|||
|
|
|
|||
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|||
|
|
{
|
|||
|
|
// HEAD 请求:返回支持 Accept-Ranges
|
|||
|
|
if (request.Method == HttpMethod.Head)
|
|||
|
|
{
|
|||
|
|
var headResponse = new HttpResponseMessage(HttpStatusCode.OK)
|
|||
|
|
{
|
|||
|
|
Content = new ByteArrayContent(Array.Empty<byte>())
|
|||
|
|
};
|
|||
|
|
headResponse.Content.Headers.ContentLength = fullContent.Length;
|
|||
|
|
headResponse.Headers.AcceptRanges.Add("bytes");
|
|||
|
|
return Task.FromResult(headResponse);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查 Range 头
|
|||
|
|
if (request.Headers.Range is { } rangeHeader && rangeHeader.Ranges is ICollection<RangeItemHeaderValue> rangeCollection && rangeCollection.Count == 1)
|
|||
|
|
{
|
|||
|
|
var range = rangeCollection.First();
|
|||
|
|
if (range.From.HasValue)
|
|||
|
|
{
|
|||
|
|
RangeHeaderSent = true;
|
|||
|
|
RangeStart = range.From.Value;
|
|||
|
|
|
|||
|
|
var remaining = new byte[fullContent.Length - (int)range.From.Value];
|
|||
|
|
Array.Copy(fullContent, (int)range.From.Value, remaining, 0, remaining.Length);
|
|||
|
|
|
|||
|
|
var response = new HttpResponseMessage(HttpStatusCode.PartialContent)
|
|||
|
|
{
|
|||
|
|
Content = new ByteArrayContent(remaining)
|
|||
|
|
};
|
|||
|
|
response.Content.Headers.ContentLength = remaining.Length;
|
|||
|
|
response.Content.Headers.ContentRange = new System.Net.Http.Headers.ContentRangeHeaderValue(
|
|||
|
|
range.From.Value, fullContent.Length - 1, fullContent.Length);
|
|||
|
|
return Task.FromResult(response);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 无 Range 头:返回完整内容
|
|||
|
|
var fullResponse = new HttpResponseMessage(HttpStatusCode.OK)
|
|||
|
|
{
|
|||
|
|
Content = new ByteArrayContent(fullContent)
|
|||
|
|
};
|
|||
|
|
fullResponse.Content.Headers.ContentLength = fullContent.Length;
|
|||
|
|
return Task.FromResult(fullResponse);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#endregion
|
|||
|
|
}
|