Files
jizhi/backend/MiaoJiZhang.Api/Services/PushProviders.cs
T

572 lines
23 KiB
C#

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<string> ConfigurationErrors(string flavor);
Task<PushSendResult> SendAsync(
string flavor,
string packageName,
string token,
PushEnvelope message,
CancellationToken ct);
}
public sealed class PushProviderRegistry(IEnumerable<IPushProvider> providers)
{
private readonly IReadOnlyDictionary<string, IPushProvider> items = providers
.ToDictionary(provider => provider.Provider, StringComparer.OrdinalIgnoreCase);
public IReadOnlyCollection<IPushProvider> All => items.Values.ToArray();
public IPushProvider? Find(string provider) => items.GetValueOrDefault(provider);
}
public sealed class OfficialPushProvider(
string provider,
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
ILogger<OfficialPushProvider> logger) : IPushProvider
{
private readonly SemaphoreSlim tokenLock = new(1, 1);
private readonly ConcurrentDictionary<string, CachedAccessToken> accessTokens =
new(StringComparer.OrdinalIgnoreCase);
public string Provider { get; } = provider;
public bool IsEnabled(string flavor) =>
configuration.GetValue<bool>($"Push:Providers:{Provider}:{flavor}:Enabled");
public IReadOnlyList<string> 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<PushSendResult> 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<PushSendResult> 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<string, string>
{
["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<PushSendResult> 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<string, string>
{
["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<PushSendResult> 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<string, string>
{
["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<string, string>
{
["auth_token"] = authToken!,
["registration_id"] = token,
["message"] = notification,
}),
};
return await Send(request, ct);
}
private async Task<PushSendResult> 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<string, string> { ["jz_payload"] = PayloadJson(message) },
});
request.Headers.TryAddWithoutValidation("authToken", authToken);
return await Send(request, ct);
}
private async Task<PushSendResult> 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<string, string> { ["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<string, string>(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<PushSendResult> 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);
}