diff --git a/src/c#/GeneralUpdate.ClientCore/GeneralUpdate.ClientCore.csproj b/src/c#/GeneralUpdate.ClientCore/GeneralUpdate.ClientCore.csproj
index 0242d33d..5ac09967 100644
--- a/src/c#/GeneralUpdate.ClientCore/GeneralUpdate.ClientCore.csproj
+++ b/src/c#/GeneralUpdate.ClientCore/GeneralUpdate.ClientCore.csproj
@@ -76,6 +76,8 @@
+
+
diff --git a/src/c#/GeneralUpdate.Common/Shared/Security/IHttpAuthProvider.cs b/src/c#/GeneralUpdate.Common/Shared/Security/IHttpAuthProvider.cs
new file mode 100644
index 00000000..a2d9a63f
--- /dev/null
+++ b/src/c#/GeneralUpdate.Common/Shared/Security/IHttpAuthProvider.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace GeneralUpdate.Common.Shared.Security;
+
+public interface IHttpAuthProvider
+{
+ Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default);
+}
+
+public sealed class NoOpAuthProvider : IHttpAuthProvider
+{
+ public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default)
+ => Task.CompletedTask;
+}
+
+public sealed class BearerTokenAuthProvider : IHttpAuthProvider
+{
+ private readonly string _token;
+ public BearerTokenAuthProvider(string token)
+ {
+ _token = token ?? throw new ArgumentNullException(nameof(token));
+ }
+ public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default)
+ {
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
+ return Task.CompletedTask;
+ }
+}
+
+public sealed class ApiKeyAuthProvider : IHttpAuthProvider
+{
+ private readonly string _headerName;
+ private readonly string _apiKey;
+ public ApiKeyAuthProvider(string apiKey, string headerName = "X-Api-Key")
+ {
+ _apiKey = apiKey ?? throw new ArgumentNullException(nameof(apiKey));
+ _headerName = headerName ?? throw new ArgumentNullException(nameof(headerName));
+ }
+ public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default)
+ {
+ request.Headers.Add(_headerName, _apiKey);
+ return Task.CompletedTask;
+ }
+}
+
+public sealed class HmacAuthProvider : IHttpAuthProvider
+{
+ private readonly string _secretKey;
+ public HmacAuthProvider(string secretKey)
+ {
+ _secretKey = secretKey ?? throw new ArgumentNullException(nameof(secretKey));
+ }
+ public async Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default)
+ {
+ var body = request.Content != null
+ ? await request.Content.ReadAsStringAsync().ConfigureAwait(false)
+ : string.Empty;
+ var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
+ var sig = ComputeHmacSha256($"{body}|{ts}", _secretKey);
+ request.Headers.Add("X-Update-Timestamp", ts);
+ request.Headers.Add("X-Update-Signature", sig);
+ }
+ private static string ComputeHmacSha256(string data, string key)
+ {
+ var hash = new HMACSHA256(Encoding.UTF8.GetBytes(key))
+ .ComputeHash(Encoding.UTF8.GetBytes(data));
+ return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
+ }
+}
+
+public static class HttpAuthProviderFactory
+{
+ public static IHttpAuthProvider Create(string? scheme, string? token, string? appSecretKey)
+ {
+ if (!string.IsNullOrEmpty(appSecretKey))
+ return new HmacAuthProvider(appSecretKey);
+ if (!string.IsNullOrEmpty(token))
+ {
+ return (scheme ?? "").ToLowerInvariant() switch
+ {
+ "apikey" => new ApiKeyAuthProvider(token),
+ _ => new BearerTokenAuthProvider(token)
+ };
+ }
+ return new NoOpAuthProvider();
+ }
+}
diff --git a/src/c#/GeneralUpdate.Common/Shared/Security/ISslValidationPolicy.cs b/src/c#/GeneralUpdate.Common/Shared/Security/ISslValidationPolicy.cs
new file mode 100644
index 00000000..8a54f4dc
--- /dev/null
+++ b/src/c#/GeneralUpdate.Common/Shared/Security/ISslValidationPolicy.cs
@@ -0,0 +1,21 @@
+using System.Net.Security;
+using System.Security.Cryptography.X509Certificates;
+
+namespace GeneralUpdate.Common.Shared.Security;
+
+public interface ISslValidationPolicy
+{
+ bool ValidateCertificate(
+ X509Certificate2? certificate,
+ X509Chain? chain,
+ SslPolicyErrors sslPolicyErrors);
+}
+
+public sealed class StrictSslValidationPolicy : ISslValidationPolicy
+{
+ public bool ValidateCertificate(
+ X509Certificate2? certificate,
+ X509Chain? chain,
+ SslPolicyErrors sslPolicyErrors)
+ => sslPolicyErrors == SslPolicyErrors.None;
+}
diff --git a/src/c#/GeneralUpdate.Common/Shared/Service/VersionService.cs b/src/c#/GeneralUpdate.Common/Shared/Service/VersionService.cs
index 3bcfd10b..0ba5c832 100644
--- a/src/c#/GeneralUpdate.Common/Shared/Service/VersionService.cs
+++ b/src/c#/GeneralUpdate.Common/Shared/Service/VersionService.cs
@@ -1,116 +1,169 @@
-using System;
+using System;
using System.Collections.Generic;
-using System.Diagnostics;
using System.Net.Http;
using System.Security.Cryptography.X509Certificates;
using System.Net.Security;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
+using System.Threading;
using System.Threading.Tasks;
using GeneralUpdate.Common.Internal;
using GeneralUpdate.Common.Internal.JsonContext;
using GeneralUpdate.Common.Shared.Object;
+using GeneralUpdate.Common.Shared.Security;
namespace GeneralUpdate.Common.Shared.Service
{
public class VersionService
{
+ private static readonly HttpClient _sharedClient;
+ private static ISslValidationPolicy _globalSslPolicy = new StrictSslValidationPolicy();
+
+ private readonly IHttpAuthProvider _authProvider;
+ private readonly TimeSpan _timeout;
+ private readonly int _maxRetries;
+
+ static VersionService()
+ {
+ var handler = new HttpClientHandler();
+ handler.ServerCertificateCustomValidationCallback = SharedCertValidation;
+ _sharedClient = new HttpClient(handler, disposeHandler: false);
+ }
+
+ public static void SetSslValidationPolicy(ISslValidationPolicy policy)
+ {
+ _globalSslPolicy = policy ?? throw new ArgumentNullException(nameof(policy));
+ }
+
+ private static bool SharedCertValidation(
+ HttpRequestMessage message,
+ X509Certificate2? certificate,
+ X509Chain? chain,
+ SslPolicyErrors sslPolicyErrors)
+ {
+ return _globalSslPolicy.ValidateCertificate(certificate, chain, sslPolicyErrors);
+ }
+
+ public VersionService(
+ IHttpAuthProvider? authProvider = null,
+ TimeSpan? timeout = null,
+ int maxRetries = 3)
+ {
+ _authProvider = authProvider ?? new NoOpAuthProvider();
+ _timeout = timeout ?? TimeSpan.FromSeconds(30);
+ _maxRetries = maxRetries;
+ }
+
private VersionService() { }
-
- ///
- /// Report the result of this update: whether it was successful.
- ///
- ///
- ///
- ///
- ///
- ///
- public static async Task Report(string httpUrl
- , int recordId
- , int status
- , int? type
- , string scheme = null
- , string token = null)
+
+ // ═══════════ Static API (backward-compatible) ═══════════
+
+ public static Task Report(string httpUrl, int recordId, int status, int? type,
+ string scheme = null, string token = null)
{
- var parameters = new Dictionary
- {
- { "RecordId", recordId },
- { "Status", status },
- { "Type", type }
+ var auth = HttpAuthProviderFactory.Create(scheme, token, null);
+ var svc = new VersionService(auth);
+ return svc.ReportAsync(httpUrl, recordId, status, type);
+ }
+
+ public static Task Validate(string httpUrl, string version,
+ int appType, string appKey, int platform, string productId,
+ string scheme = null, string token = null)
+ {
+ var auth = HttpAuthProviderFactory.Create(scheme, token, appKey);
+ var svc = new VersionService(auth);
+ return svc.ValidateAsync(httpUrl, version, appType, platform, productId);
+ }
+
+ // ═══════════ Instance methods ═══════════
+
+ private async Task ReportAsync(string httpUrl, int recordId, int status,
+ int? type, CancellationToken token = default)
+ {
+ var p = new Dictionary {
+ { "RecordId", recordId }, { "Status", status }, { "Type", type }
};
- await PostTaskAsync>(httpUrl, parameters, ReportRespJsonContext.Default.BaseResponseDTOBoolean, scheme, token);
+ await PostAsync>(
+ httpUrl, p, ReportRespJsonContext.Default.BaseResponseDTOBoolean, token)
+ .ConfigureAwait(false);
}
- ///
- /// Verify whether the current version needs an update.
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- ///
- public static async Task Validate(string httpUrl
- , string version
- , int appType
- , string appKey
- , int platform
- , string productId
- , string scheme = null
- , string token = null)
+ private async Task ValidateAsync(string httpUrl, string version,
+ int appType, int platform, string productId, CancellationToken token = default)
{
- var parameters = new Dictionary
- {
- { "Version", version },
- { "AppType", appType },
- { "AppKey", appKey },
- { "Platform", platform },
- { "ProductId", productId }
+ var p = new Dictionary {
+ { "Version", version }, { "AppType", appType },
+ { "Platform", platform }, { "ProductId", productId }
};
- return await PostTaskAsync(httpUrl, parameters, VersionRespJsonContext.Default.VersionRespDTO, scheme, token);
+ return await PostAsync(
+ httpUrl, p, VersionRespJsonContext.Default.VersionRespDTO, token)
+ .ConfigureAwait(false);
}
- private static async Task PostTaskAsync(string httpUrl, Dictionary parameters, JsonTypeInfo? typeInfo = null, string scheme = null, string token = null)
+ private async Task PostAsync(string httpUrl, Dictionary parameters,
+ JsonTypeInfo? typeInfo, CancellationToken token, int maxRetriesOverride = 0)
{
- try
+ int max = maxRetriesOverride > 0 ? maxRetriesOverride : _maxRetries;
+ for (int attempt = 0; ; attempt++)
{
- var uri = new Uri(httpUrl);
- using var httpClient = new HttpClient(new HttpClientHandler
+ try
{
- ServerCertificateCustomValidationCallback = CheckValidationResult
- });
- httpClient.Timeout = TimeSpan.FromSeconds(15);
- httpClient.DefaultRequestHeaders.Accept.ParseAdd("text/html, application/xhtml+xml, */*");
-
- if (!string.IsNullOrEmpty(scheme) && !string.IsNullOrEmpty(token))
+ return await SendAsync(httpUrl, parameters, typeInfo, token)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex) when (attempt < max - 1 && IsRetryable(ex))
{
- httpClient.DefaultRequestHeaders.Authorization =
- new System.Net.Http.Headers.AuthenticationHeaderValue(scheme, token);
+ GeneralTracer.Warn(
+ $"HTTP attempt {attempt + 1}/{max} failed, retrying. {ex.Message}");
+ await Task.Delay(TimeSpan.FromMilliseconds(Math.Pow(2, attempt) * 1000), token)
+ .ConfigureAwait(false);
}
-
- var parametersJson =
- JsonSerializer.Serialize(parameters, HttpParameterJsonContext.Default.DictionaryStringObject);
- var stringContent = new StringContent(parametersJson, Encoding.UTF8, "application/json");
- var result = await httpClient.PostAsync(uri, stringContent);
- var reseponseJson = await result.Content.ReadAsStringAsync();
- return typeInfo == null
- ? JsonSerializer.Deserialize(reseponseJson)
- : JsonSerializer.Deserialize(reseponseJson, typeInfo);
}
- catch (Exception e)
+ }
+
+ private async Task SendAsync(string httpUrl, Dictionary parameters,
+ JsonTypeInfo? typeInfo, CancellationToken token)
+ {
+ var uri = new Uri(httpUrl);
+ using var request = new HttpRequestMessage(HttpMethod.Post, uri);
+ request.Headers.Accept.ParseAdd("application/json");
+
+ var json = JsonSerializer.Serialize(
+ parameters, HttpParameterJsonContext.Default.DictionaryStringObject);
+ request.Content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ await _authProvider.ApplyAuthAsync(request, token).ConfigureAwait(false);
+
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(token);
+ timeoutCts.CancelAfter(_timeout);
+
+ var response = await _sharedClient.SendAsync(request, timeoutCts.Token)
+ .ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+
+ var responseJson = await response.Content.ReadAsStringAsync()
+ .ConfigureAwait(false);
+ return typeInfo == null
+ ? JsonSerializer.Deserialize(responseJson)
+ : JsonSerializer.Deserialize(responseJson, typeInfo);
+ }
+
+ private static bool IsRetryable(Exception ex)
+ {
+ if (ex is OperationCanceledException) return false;
+ if (ex is TaskCanceledException) return true;
+ if (ex is TimeoutException) return true;
+ if (ex is System.IO.IOException) return true;
+ if (ex is HttpRequestException hre)
{
- GeneralTracer.Error("The PostTaskAsync method in the VersionService class throws an exception.", e);
- throw e;
+ var msg = hre.Message ?? "";
+ return msg.Contains("timeout", StringComparison.OrdinalIgnoreCase)
+ || msg.Contains("timed out", StringComparison.OrdinalIgnoreCase)
+ || msg.Contains("500") || msg.Contains("502")
+ || msg.Contains("503") || msg.Contains("504");
}
+ return false;
}
-
- private static bool CheckValidationResult(
- HttpRequestMessage message,
- X509Certificate2 certificate,
- X509Chain chain,
- SslPolicyErrors sslPolicyErrors
- ) => true;
}
-}
\ No newline at end of file
+}
diff --git a/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj b/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj
index 4df180fb..bbed19c1 100644
--- a/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj
+++ b/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj
@@ -84,6 +84,8 @@
+
+