兼容win7版应用
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.

169 lines
8.5 KiB

using AIK.Models.HttpPolicy;
using AIK.Service.IService;
using Flurl;
using Flurl.Http;
using Microsoft.Extensions.Logging;
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Authentication;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace AIK.Service.Service
{
public class HttpClientService : IHttpClientService
{
private readonly ILogger<HttpClientService> _logger;
private readonly RetryPolicyConfig _config;
private readonly IFlurlClient _flurlClient;
private readonly BouncyCastleTlsHandler _tlsHandler;
public HttpClientService(IHttpClientFactory httpClientFactory, ILogger<HttpClientService> logger, RetryPolicyConfig config = null)
{
_logger = logger;
_config = config ?? new RetryPolicyConfig();
// Win7 系统 SChannel 未启用 TLS 1.2 时,.NET Framework 的 HttpClient/HttpWebRequest
// (底层全部走 SChannel)无法协商 TLS 1.2。改用 BouncyCastle 纯托管 TLS 实现
// (BouncyCastleTlsHandler),完全绕过 SChannel,在 Win7 上可直接与只接受 TLS 1.2+
// 的服务器(key.aikkey.cn)握手。Handler 内部已处理 SNI、证书链校验与 gzip/deflate 解压。
_tlsHandler = new BouncyCastleTlsHandler();
_flurlClient = new FlurlClient(new HttpClient(_tlsHandler));
}
// GET 方法
public async Task<string> GetStringWithTimeoutAsync(string url, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
{
var response = await ExecuteAsync(url, HttpMethod.Get, null, timeout, null, cancellationToken);
ThrowIfNotSuccess(response);
return await response.Content.ReadAsStringAsync();
}
// POST JSON 方法
public async Task<string> PostJsonWithTimeoutAsync(string url, string jsonData, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
{
var content = new StringContent(jsonData, Encoding.UTF8, "application/json");
var response = await ExecuteAsync(url, HttpMethod.Post, content, timeout, null, cancellationToken);
ThrowIfNotSuccess(response);
return await response.Content.ReadAsStringAsync();
}
public async Task<HttpResponseMessage> GetWithTimeoutAsync(string url, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
{
return await ExecuteAsync(url, HttpMethod.Get, null, timeout, null, cancellationToken);
}
public async Task<HttpResponseMessage> PostWithTimeoutAsync(string url, HttpContent content, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
{
return await ExecuteAsync(url, HttpMethod.Post, content, timeout, null, cancellationToken);
}
public async Task<byte[]> GetByteArrayWithTimeoutAsync(string url, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
{
var response = await ExecuteAsync(url, HttpMethod.Get, null, timeout, null, cancellationToken);
ThrowIfNotSuccess(response);
return await response.Content.ReadAsByteArrayAsync();
}
/// <summary>
/// 流式下载文件到指定路径。直接走 BouncyCastleTlsHandler 的流式通道(SendStreamingAsync),
/// 响应体逐段读取、每段带空闲超时(60s)、无总时长限制,避免大文件因 30s 总超时而失败。
/// 支持断点续传:resumeOffset&gt;0 时携带 Range 头,服务器返回 206 则从文件末尾追加写入;
/// 服务器忽略 Range 返回 200 时则整文件覆盖重写。
/// </summary>
public async Task DownloadToFileWithProgressAsync(string url, string filePath, IProgress<double> progress = null, CancellationToken cancellationToken = default, long resumeOffset = 0)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
if (resumeOffset > 0)
{
request.Headers.Range = new RangeHeaderValue(resumeOffset, null);
}
using var response = await _tlsHandler.SendStreamingAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"下载失败: HTTP {(int)response.StatusCode} ({response.StatusCode}) - {url}");
}
// 判断服务器是否接受 Range(206 = 部分内容,Content-Range 提供完整文件总大小)
var contentRange = response.Content.Headers.ContentRange;
bool resumed = resumeOffset > 0 && contentRange != null && contentRange.HasRange && contentRange.Length.HasValue;
long baseOffset = resumed ? resumeOffset : 0;
FileMode mode = resumed ? FileMode.Append : FileMode.Create;
long totalBytes = resumed
? contentRange.Length.Value
: (response.Content.Headers.ContentLength ?? -1) + baseOffset;
long receivedBytes = baseOffset;
using (var source = await response.Content.ReadAsStreamAsync().ConfigureAwait(false))
using (var target = new FileStream(filePath, mode, FileAccess.Write, FileShare.None, 81920, useAsync: true))
{
var buffer = new byte[81920];
int read;
while ((read = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) > 0)
{
await target.WriteAsync(buffer, 0, read, cancellationToken).ConfigureAwait(false);
receivedBytes += read;
if (totalBytes > 0 && progress != null)
{
progress.Report(Math.Min(100.0, receivedBytes * 100.0 / totalBytes));
}
}
await target.FlushAsync(cancellationToken).ConfigureAwait(false);
}
// 完整性校验:连接被中途掐断时 EOF 提前结束,实际字节数 < 总大小,必须视为失败(由调用方重试续传)
if (totalBytes > 0 && receivedBytes != totalBytes)
{
throw new HttpRequestException($"下载不完整: 预期 {totalBytes} 字节,实际 {receivedBytes} 字节 - {url}");
}
progress?.Report(100.0);
}
#region 附带Token权限的方法
public async Task<string> GetStringWithTimeoutAndTokenAsync(string url, string token, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
{
var response = await ExecuteAsync(url, HttpMethod.Get, null, timeout, token, cancellationToken);
ThrowIfNotSuccess(response);
return await response.Content.ReadAsStringAsync();
}
public async Task<string> PostJsonWithTimeoutAndTokenAsync(string url, string jsonData, string token, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
{
var content = new StringContent(jsonData, Encoding.UTF8, "application/json");
var response = await ExecuteAsync(url, HttpMethod.Post, content, timeout, token, cancellationToken);
ThrowIfNotSuccess(response);
return await response.Content.ReadAsStringAsync();
}
#endregion
// 每个请求独立设置超时,避免并发请求互相覆盖;Flurl 的 SendAsync 不自动抛非 2xx,由 ThrowIfNotSuccess 统一处理
private async Task<HttpResponseMessage> ExecuteAsync(string url, HttpMethod method, HttpContent content, TimeSpan? timeout, string token, CancellationToken cancellationToken)
{
var request = _flurlClient.Request(url)
.WithTimeout(timeout ?? _config.DefaultTimeout);
if (!string.IsNullOrEmpty(token))
{
request = request.WithHeader("token", token);
}
return (await request.SendAsync(method, content, HttpCompletionOption.ResponseContentRead, cancellationToken)).ResponseMessage;
}
private static void ThrowIfNotSuccess(HttpResponseMessage response)
{
if (response.IsSuccessStatusCode)
{
return;
}
var message = $"Response status code does not indicate success: {(int)response.StatusCode} ({response.StatusCode}).";
throw new HttpRequestException(message);
}
}
}