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 _logger; private readonly RetryPolicyConfig _config; private readonly IFlurlClient _flurlClient; private readonly BouncyCastleTlsHandler _tlsHandler; public HttpClientService(IHttpClientFactory httpClientFactory, ILogger 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 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 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 GetWithTimeoutAsync(string url, TimeSpan? timeout = null, CancellationToken cancellationToken = default) { return await ExecuteAsync(url, HttpMethod.Get, null, timeout, null, cancellationToken); } public async Task PostWithTimeoutAsync(string url, HttpContent content, TimeSpan? timeout = null, CancellationToken cancellationToken = default) { return await ExecuteAsync(url, HttpMethod.Post, content, timeout, null, cancellationToken); } public async Task 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(); } /// /// 流式下载文件到指定路径。直接走 BouncyCastleTlsHandler 的流式通道(SendStreamingAsync), /// 响应体逐段读取、每段带空闲超时(60s)、无总时长限制,避免大文件因 30s 总超时而失败。 /// 支持断点续传:resumeOffset>0 时携带 Range 头,服务器返回 206 则从文件末尾追加写入; /// 服务器忽略 Range 返回 200 时则整文件覆盖重写。 /// public async Task DownloadToFileWithProgressAsync(string url, string filePath, IProgress 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 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 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 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); } } }