using System.Net; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Collections.Concurrent; using MiaoJiZhang.Domain.Entities; namespace MiaoJiZhang.Api.Services; public sealed record PushEnvelope( string MessageId, string Title, string Body, string Category, string Action, string? EntityId, int TtlSeconds); public sealed record PushSendResult( bool Accepted, bool Retryable, bool InvalidToken, string? ProviderMessageId = null, string? ErrorCode = null, string? ErrorMessage = null, TimeSpan? RetryAfter = null); public interface IPushProvider { string Provider { get; } bool IsEnabled(string flavor); IReadOnlyList ConfigurationErrors(string flavor); Task SendAsync( string flavor, string packageName, string token, PushEnvelope message, CancellationToken ct); } public sealed class PushProviderRegistry(IEnumerable providers) { private readonly IReadOnlyDictionary items = providers .ToDictionary(provider => provider.Provider, StringComparer.OrdinalIgnoreCase); public IReadOnlyCollection All => items.Values.ToArray(); public IPushProvider? Find(string provider) => items.GetValueOrDefault(provider); } public sealed class OfficialPushProvider( string provider, IConfiguration configuration, IHttpClientFactory httpClientFactory, ILogger logger) : IPushProvider { private readonly SemaphoreSlim tokenLock = new(1, 1); private readonly ConcurrentDictionary accessTokens = new(StringComparer.OrdinalIgnoreCase); public string Provider { get; } = provider; public bool IsEnabled(string flavor) => configuration.GetValue($"Push:Providers:{Provider}:{flavor}:Enabled"); public IReadOnlyList ConfigurationErrors(string flavor) { if (!IsEnabled(flavor)) return ["disabled"]; var required = Provider switch { PushProviders.Huawei or PushProviders.Honor => new[] { "AppId", "AppSecret" }, PushProviders.Xiaomi => new[] { "AppSecret" }, PushProviders.Oppo => new[] { "AppKey", "MasterSecret" }, PushProviders.Vivo => new[] { "AppId", "AppKey", "AppSecret" }, PushProviders.Meizu => new[] { "AppId", "AppSecret" }, _ => [], }; return required .Where(key => string.IsNullOrWhiteSpace(Value(flavor, key))) .Select(key => $"missing_{key.ToLowerInvariant()}") .ToList(); } public async Task SendAsync( string flavor, string packageName, string token, PushEnvelope message, CancellationToken ct) { var errors = ConfigurationErrors(flavor); if (errors.Count > 0) return new(false, false, false, ErrorCode: "provider_not_configured", ErrorMessage: string.Join(',', errors)); try { return Provider switch { PushProviders.Huawei => await SendHuaweiLike(flavor, packageName, token, message, false, ct), PushProviders.Honor => await SendHuaweiLike(flavor, packageName, token, message, true, ct), PushProviders.Xiaomi => await SendXiaomi(flavor, packageName, token, message, ct), PushProviders.Oppo => await SendOppo(flavor, packageName, token, message, ct), PushProviders.Vivo => await SendVivo(flavor, packageName, token, message, ct), PushProviders.Meizu => await SendMeizu(flavor, packageName, token, message, ct), _ => new(false, false, false, ErrorCode: "provider_unknown"), }; } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { return new(false, true, false, ErrorCode: "provider_timeout", ErrorMessage: "Provider request timed out"); } catch (HttpRequestException exception) { logger.LogWarning(exception, "Push provider {Provider} request failed", Provider); return new(false, true, false, ErrorCode: "provider_network_error", ErrorMessage: exception.Message); } catch (Exception exception) { logger.LogError(exception, "Push provider {Provider} failed unexpectedly", Provider); return new(false, false, false, ErrorCode: "provider_internal_error", ErrorMessage: exception.Message); } } private async Task SendHuaweiLike( string flavor, string packageName, string token, PushEnvelope message, bool honor, CancellationToken ct) { var accessToken = await GetOAuthToken(flavor, honor, ct); if (accessToken.Result is not null) return accessToken.Result; var appId = Value(flavor, "AppId")!; var defaultUrl = honor ? $"https://push-api.cloud.hihonor.com/api/v1/{appId}/sendMessage" : $"https://push-api.cloud.huawei.com/v1/{appId}/messages:send"; var url = Value(flavor, "SendUrl") ?? defaultUrl; var intent = IntentUri(packageName, message); object payload = honor ? new { message = new { notification = new { title = message.Title, body = message.Body }, android = new { ttl = $"{message.TtlSeconds}s", data = PayloadJson(message), notification = new { channel_id = Channel(flavor, message.Category), click_action = new { type = 1, intent }, }, }, token = new[] { token }, }, } : new { validate_only = false, message = new { notification = new { title = message.Title, body = message.Body }, android = new { ttl = $"{message.TtlSeconds}s", data = PayloadJson(message), notification = new { channel_id = Channel(flavor, message.Category), notify_id = StableNotificationId(message.MessageId), click_action = new { type = 1, intent }, }, }, token = new[] { token }, }, }; using var request = JsonRequest(HttpMethod.Post, url, payload); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Token); return await Send(request, ct); } private async Task<(string? Token, PushSendResult? Result)> GetOAuthToken( string flavor, bool honor, CancellationToken ct) { if (FreshAccessToken(flavor) is { } cached) return (cached, null); await tokenLock.WaitAsync(ct); try { if (FreshAccessToken(flavor) is { } lockedCached) return (lockedCached, null); var defaultUrl = honor ? "https://iam.developer.hihonor.com/auth/token" : "https://oauth-login.cloud.huawei.com/oauth2/v3/token"; var url = Value(flavor, "AuthUrl") ?? defaultUrl; using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "client_credentials", ["client_id"] = Value(flavor, "AppId")!, ["client_secret"] = Value(flavor, "AppSecret")!, }), }; using var response = await Client().SendAsync(request, ct); var body = await response.Content.ReadAsStringAsync(ct); if (!response.IsSuccessStatusCode) return (null, FromFailure(response, body)); using var json = JsonDocument.Parse(body); if (!TryString(json.RootElement, out var value, "access_token", "accessToken", "token")) return (null, new(false, false, false, ErrorCode: "provider_auth_invalid", ErrorMessage: Trim(body))); var expires = TryInt(json.RootElement, "expires_in", "expiresIn") ?? 3600; accessTokens[flavor] = new CachedAccessToken( value!, DateTime.UtcNow.AddSeconds(Math.Max(expires, 300))); return (value, null); } finally { tokenLock.Release(); } } private async Task SendXiaomi( string flavor, string packageName, string token, PushEnvelope message, CancellationToken ct) { var url = Value(flavor, "SendUrl") ?? "https://api.xmpush.xiaomi.com/v3/message/regid"; using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = new FormUrlEncodedContent(new Dictionary { ["registration_id"] = token, ["restricted_package_name"] = packageName, ["title"] = message.Title, ["description"] = message.Body, ["notify_id"] = StableNotificationId(message.MessageId).ToString(), ["time_to_live"] = (message.TtlSeconds * 1000L).ToString(), ["extra.notify_effect"] = "2", ["extra.intent_uri"] = IntentUri(packageName, message), ["extra.jz_payload"] = PayloadJson(message), ["extra.channel_id"] = Channel(flavor, message.Category), }), }; request.Headers.TryAddWithoutValidation("Authorization", $"key={Value(flavor, "AppSecret")}"); return await Send(request, ct); } private async Task SendOppo( string flavor, string packageName, string token, PushEnvelope message, CancellationToken ct) { var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(); var appKey = Value(flavor, "AppKey")!; var sign = Sha256Hex(appKey + timestamp + Value(flavor, "MasterSecret")); var authUrl = Value(flavor, "AuthUrl") ?? "https://api.push.oppomobile.com/server/v1/auth"; using var authRequest = new HttpRequestMessage(HttpMethod.Post, authUrl) { Content = new FormUrlEncodedContent(new Dictionary { ["app_key"] = appKey, ["timestamp"] = timestamp, ["sign"] = sign, }), }; using var authResponse = await Client().SendAsync(authRequest, ct); var authBody = await authResponse.Content.ReadAsStringAsync(ct); if (!authResponse.IsSuccessStatusCode) return FromFailure(authResponse, authBody); using var authJson = JsonDocument.Parse(authBody); if (!TryNestedString(authJson.RootElement, out var authToken, "data", "auth_token") && !TryString(authJson.RootElement, out authToken, "auth_token", "authToken")) return new(false, false, false, ErrorCode: "provider_auth_invalid", ErrorMessage: Trim(authBody)); var notification = JsonSerializer.Serialize(new { app_message_id = message.MessageId, title = message.Title, content = message.Body, click_action_type = 1, click_action_activity = $"{packageName}/com.nx.miaoji.MainActivity", action_parameters = PayloadJson(message), off_line = true, off_line_ttl = message.TtlSeconds, channel_id = Channel(flavor, message.Category), }); var sendUrl = Value(flavor, "SendUrl") ?? "https://api.push.oppomobile.com/server/v1/message/notification/unicast"; using var request = new HttpRequestMessage(HttpMethod.Post, sendUrl) { Content = new FormUrlEncodedContent(new Dictionary { ["auth_token"] = authToken!, ["registration_id"] = token, ["message"] = notification, }), }; return await Send(request, ct); } private async Task SendVivo( string flavor, string packageName, string token, PushEnvelope message, CancellationToken ct) { var appId = Value(flavor, "AppId")!; var appKey = Value(flavor, "AppKey")!; var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(); var sign = Md5Hex(appId + appKey + timestamp + Value(flavor, "AppSecret")); var authUrl = Value(flavor, "AuthUrl") ?? "https://api-push.vivo.com.cn/message/auth"; using var authRequest = JsonRequest(HttpMethod.Post, authUrl, new { appId = int.TryParse(appId, out var id) ? id : 0, appKey, timestamp = long.Parse(timestamp), sign, }); using var authResponse = await Client().SendAsync(authRequest, ct); var authBody = await authResponse.Content.ReadAsStringAsync(ct); if (!authResponse.IsSuccessStatusCode) return FromFailure(authResponse, authBody); using var authJson = JsonDocument.Parse(authBody); if (!TryString(authJson.RootElement, out var authToken, "authToken", "auth_token")) return new(false, false, false, ErrorCode: "provider_auth_invalid", ErrorMessage: Trim(authBody)); var sendUrl = Value(flavor, "SendUrl") ?? "https://api-push.vivo.com.cn/message/send"; using var request = JsonRequest(HttpMethod.Post, sendUrl, new { regId = token, notifyType = 4, title = message.Title, content = message.Body, timeToLive = message.TtlSeconds, skipType = 3, skipContent = IntentUri(packageName, message), requestId = message.MessageId, classification = message.Category == PushCategories.Operations ? 1 : 0, clientCustomMap = new Dictionary { ["jz_payload"] = PayloadJson(message) }, }); request.Headers.TryAddWithoutValidation("authToken", authToken); return await Send(request, ct); } private async Task SendMeizu( string flavor, string packageName, string token, PushEnvelope message, CancellationToken ct) { var appId = Value(flavor, "AppId")!; var messageJson = JsonSerializer.Serialize(new { noticeBarInfo = new { title = message.Title, content = message.Body, noticeBarType = 0, }, clickTypeInfo = new { clickType = 3, parameters = new Dictionary { ["jz_payload"] = PayloadJson(message) }, uri = IntentUri(packageName, message), }, pushTimeInfo = new { offLine = true, validTime = message.TtlSeconds / 3600 }, advanceInfo = new { notifyId = StableNotificationId(message.MessageId) }, }); var values = new SortedDictionary(StringComparer.Ordinal) { ["appId"] = appId, ["pushIds"] = JsonSerializer.Serialize(new[] { token }), ["messageJson"] = messageJson, }; var signSource = string.Concat(values.Select(item => item.Key + item.Value)) + Value(flavor, "AppSecret"); values["sign"] = Md5Hex(signSource); var url = Value(flavor, "SendUrl") ?? "https://server-api-push.meizu.com/garcia/api/server/push/varnished/pushByPushId"; using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = new FormUrlEncodedContent(values), }; return await Send(request, ct); } private async Task Send(HttpRequestMessage request, CancellationToken ct) { using var response = await Client().SendAsync(request, ct); var body = await response.Content.ReadAsStringAsync(ct); if (!response.IsSuccessStatusCode) return FromFailure(response, body); string? providerId = null; try { using var json = JsonDocument.Parse(body); var businessFailure = FromBusinessFailure(json.RootElement, body); if (businessFailure is not null) return businessFailure; TryString(json.RootElement, out providerId, "requestId", "request_id", "taskId", "msgId", "messageId", "code"); } catch (JsonException) { // Some providers return an empty or non-JSON success body. } return new(true, false, false, providerId); } private PushSendResult? FromBusinessFailure(JsonElement root, string body) { string? code = null; var failed = Provider switch { PushProviders.Huawei or PushProviders.Honor => HasUnexpectedValue(root, ["0", "200", "80000000"], out code, "code"), PushProviders.Xiaomi => HasUnexpectedValue(root, ["ok", "success"], out code, "result") || HasUnexpectedValue(root, ["0"], out code, "code"), PushProviders.Oppo => HasUnexpectedValue(root, ["0"], out code, "code"), PushProviders.Vivo => HasUnexpectedValue(root, ["0"], out code, "result", "code"), PushProviders.Meizu => HasUnexpectedValue(root, ["200"], out code, "code"), _ => false, }; if (!failed && TryString(root, out var error, "error", "error_description") && !string.IsNullOrWhiteSpace(error)) { code = error; failed = true; } if (!failed) return null; var normalized = body.ToLowerInvariant(); var retryable = normalized.Contains("rate limit") || normalized.Contains("too many") || normalized.Contains("frequency") || normalized.Contains("system busy") || normalized.Contains("try again"); return new PushSendResult( false, retryable, LooksLikeInvalidToken(normalized), ErrorCode: $"provider_{NormalizeCode(code)}", ErrorMessage: Trim(body)); } private static PushSendResult FromFailure(HttpResponseMessage response, string body) { var normalized = body.ToLowerInvariant(); var retryable = response.StatusCode == HttpStatusCode.TooManyRequests || (int)response.StatusCode >= 500; TimeSpan? retryAfter = response.Headers.RetryAfter?.Delta; return new(false, retryable, LooksLikeInvalidToken(normalized), ErrorCode: $"http_{(int)response.StatusCode}", ErrorMessage: Trim(body), RetryAfter: retryAfter); } private string? FreshAccessToken(string flavor) => accessTokens.TryGetValue(flavor, out var cached) && cached.ExpiresAt > DateTime.UtcNow.AddMinutes(2) ? cached.Token : null; private HttpClient Client() => httpClientFactory.CreateClient("PushProviders"); private string? Value(string flavor, string key) => configuration[$"Push:Providers:{Provider}:{flavor}:{key}"]; private string Channel(string flavor, string category) => Value(flavor, $"Channels:{category}") ?? $"jizhi_{category}"; private static HttpRequestMessage JsonRequest(HttpMethod method, string url, object body) => new(method, url) { Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"), }; private static string PayloadJson(PushEnvelope message) => JsonSerializer.Serialize(new { v = 1, messageId = message.MessageId, category = message.Category, action = message.Action, entityId = message.EntityId, }); private static string IntentUri(string packageName, PushEnvelope message) { var entity = message.EntityId is null ? "" : $"&entityId={Uri.EscapeDataString(message.EntityId)}"; return $"miaoji://push/open?messageId={Uri.EscapeDataString(message.MessageId)}" + $"&category={Uri.EscapeDataString(message.Category)}&action={Uri.EscapeDataString(message.Action)}{entity}"; } private static int StableNotificationId(string messageId) { var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(messageId)); return BitConverter.ToInt32(bytes, 0) & int.MaxValue; } private static string Sha256Hex(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); private static string Md5Hex(string value) => Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); private static string Trim(string value) => value.Length <= 400 ? value : value[..400]; private static bool HasUnexpectedValue( JsonElement root, string[] accepted, out string? value, params string[] names) { foreach (var name in names) { if (!TryString(root, out value, name)) continue; return !accepted.Contains(value!); } value = null; return false; } private static bool LooksLikeInvalidToken(string normalized) => normalized.Contains("invalid token") || normalized.Contains("invalid reg") || normalized.Contains("registration_id_invalid") || normalized.Contains("target invalid") || normalized.Contains("pushid") && normalized.Contains("invalid"); private static string NormalizeCode(string? value) { if (string.IsNullOrWhiteSpace(value)) return "rejected"; var normalized = new string(value.Where(character => char.IsLetterOrDigit(character) || character == '_') .Take(48).ToArray()); return string.IsNullOrEmpty(normalized) ? "rejected" : normalized.ToLowerInvariant(); } private static bool TryString(JsonElement root, out string? value, params string[] names) { foreach (var name in names) { if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty(name, out var element)) { value = element.ValueKind == JsonValueKind.String ? element.GetString() : element.ToString(); if (!string.IsNullOrWhiteSpace(value)) return true; } } value = null; return false; } private static bool TryNestedString(JsonElement root, out string? value, string parent, string child) { if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty(parent, out var nested)) return TryString(nested, out value, child); value = null; return false; } private static int? TryInt(JsonElement root, params string[] names) { foreach (var name in names) { if (!root.TryGetProperty(name, out var value)) continue; if (value.TryGetInt32(out var parsed)) return parsed; if (int.TryParse(value.ToString(), out parsed)) return parsed; } return null; } private sealed record CachedAccessToken(string Token, DateTime ExpiresAt); }