Add domestic vendor push infrastructure

This commit is contained in:
2026-07-26 01:45:59 +08:00
parent 7cca34b331
commit 0738953e6d
77 changed files with 6470 additions and 855 deletions
@@ -9,6 +9,18 @@ public class AccountDataEraser(AppDbContext db)
{
await using var transaction =
await db.Database.BeginTransactionAsync(ct);
await db.PushMessages
.Where(message => message.TargetUserId == userId)
.ExecuteDeleteAsync(ct);
await db.PushDevices
.Where(device => device.UserId == userId)
.ExecuteDeleteAsync(ct);
await db.UserPushPreferences
.Where(preference => preference.UserId == userId)
.ExecuteDeleteAsync(ct);
await db.BudgetNotificationReceipts
.Where(receipt => receipt.UserId == userId)
.ExecuteDeleteAsync(ct);
await db.ChatMessages
.Where(message => message.UserId == userId)
.ExecuteDeleteAsync(ct);
@@ -14,6 +14,7 @@ public record AgentTurnResult(
public class AgentService(
AppDbContext db,
ILlmClient llm,
BudgetPushService budgetPush,
ILogger<AgentService> logger)
{
private static readonly JsonSerializerOptions JsonOptions =
@@ -330,12 +331,23 @@ public class AgentService(
}
db.Transactions.AddRange(pending);
await using var writeScope = await db.Database.BeginTransactionAsync(ct);
try
{
await db.SaveChangesAsync(ct);
await budgetPush.EvaluateAsync(userId, pending
.Where(transaction => transaction.Type == TransactionType.Expense)
.Select(transaction => new BudgetExpenseChange(
transaction.LedgerId,
transaction.CategoryId,
transaction.OccurredAt,
transaction.Amount)), ct);
await db.SaveChangesAsync(ct);
await writeScope.CommitAsync(ct);
}
catch
{
await writeScope.RollbackAsync(ct);
foreach (var transaction in pending)
db.Entry(transaction).State = EntityState.Detached;
throw;
@@ -0,0 +1,142 @@
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Domain.Enums;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Services;
public sealed record BudgetExpenseChange(
long LedgerId,
long CategoryId,
DateTime OccurredAt,
decimal Delta);
public sealed class BudgetPushService(AppDbContext db)
{
private static readonly int[] Thresholds = [80, 100];
public async Task EvaluateAsync(
long userId,
IEnumerable<BudgetExpenseChange> rawChanges,
CancellationToken ct = default)
{
var changes = rawChanges
.Where(change => change.Delta != 0)
.Select(change => new
{
Change = change,
Local = ChinaClock.ToLocal(change.OccurredAt),
})
.Select(item => new ChangeWithPeriod(
item.Change.LedgerId,
item.Change.CategoryId,
item.Local.Year * 100 + item.Local.Month,
item.Change.Delta))
.ToList();
if (changes.Count == 0) return;
var crossed = new List<CrossedBudget>();
foreach (var group in changes.GroupBy(change => new { change.LedgerId, change.Period }))
{
var period = group.Key.Period;
var year = period / 100;
var month = period % 100;
if (month is < 1 or > 12) continue;
var rows = await db.Budgets
.Where(budget => budget.UserId == userId && budget.LedgerId == group.Key.LedgerId &&
(budget.Period == period || budget.Period == 0) && budget.Amount > 0)
.ToListAsync(ct);
var budgets = rows.GroupBy(budget => budget.CategoryId)
.Select(items => items.FirstOrDefault(item => item.Period == period) ??
items.First(item => item.Period == 0))
.ToList();
if (budgets.Count == 0) continue;
var (start, end) = ChinaClock.MonthRangeUtc(year, month);
var spent = await db.Transactions
.Where(transaction => transaction.UserId == userId &&
transaction.LedgerId == group.Key.LedgerId &&
transaction.Type == TransactionType.Expense &&
transaction.OccurredAt >= start && transaction.OccurredAt < end)
.GroupBy(transaction => transaction.CategoryId)
.Select(items => new { CategoryId = items.Key, Amount = items.Sum(item => item.Amount) })
.ToDictionaryAsync(item => item.CategoryId, item => item.Amount, ct);
var existing = await db.BudgetNotificationReceipts
.Where(receipt => receipt.UserId == userId && receipt.Period == period &&
budgets.Select(budget => budget.Id).Contains(receipt.BudgetId))
.Select(receipt => new { receipt.BudgetId, receipt.Threshold })
.ToListAsync(ct);
var existingKeys = existing.Select(item => (item.BudgetId, item.Threshold)).ToHashSet();
foreach (var budget in budgets)
{
var currentSpent = budget.CategoryId.HasValue
? spent.GetValueOrDefault(budget.CategoryId.Value)
: spent.Values.Sum();
var delta = budget.CategoryId.HasValue
? group.Where(change => change.CategoryId == budget.CategoryId.Value).Sum(change => change.Delta)
: group.Sum(change => change.Delta);
var previousSpent = currentSpent - delta;
var highestCrossed = 0;
foreach (var threshold in Thresholds)
{
if (currentSpent * 100 < budget.Amount * threshold ||
existingKeys.Contains((budget.Id, threshold))) continue;
db.BudgetNotificationReceipts.Add(new BudgetNotificationReceipt
{
UserId = userId,
BudgetId = budget.Id,
Period = period,
Threshold = threshold,
CreatedAt = DateTime.UtcNow,
});
existingKeys.Add((budget.Id, threshold));
if (delta > 0 && previousSpent * 100 < budget.Amount * threshold)
highestCrossed = threshold;
}
if (highestCrossed > 0)
crossed.Add(new CrossedBudget(budget.CategoryId, highestCrossed));
}
}
if (crossed.Count == 0) return;
var notificationsEnabled = await db.UserPushPreferences.AnyAsync(preference =>
preference.UserId == userId && preference.Category == PushCategories.Budget &&
preference.IsEnabled, ct);
if (!notificationsEnabled) return;
var categoryIds = crossed.Where(item => item.CategoryId.HasValue)
.Select(item => item.CategoryId!.Value).Distinct().ToList();
var names = await db.Categories.Where(category => categoryIds.Contains(category.Id))
.ToDictionaryAsync(category => category.Id, category => category.Name, ct);
var details = crossed
.OrderByDescending(item => item.Threshold)
.ThenBy(item => item.CategoryId)
.Select(item =>
$"{(item.CategoryId.HasValue ? names.GetValueOrDefault(item.CategoryId.Value, "") : "")}" +
(item.Threshold >= 100 ? "已用完" : "已使用 80%"))
.Distinct()
.ToList();
var body = string.Join("", details);
if (body.Length > 240) body = body[..237] + "...";
var now = DateTime.UtcNow;
db.PushMessages.Add(new PushMessage
{
PublicId = Guid.NewGuid().ToString(),
Source = "budget",
State = PushMessageStates.Queued,
Category = PushCategories.Budget,
Title = crossed.Any(item => item.Threshold >= 100) ? "预算已达到上限" : "预算接近上限",
Body = body,
Action = PushActions.Budget,
TargetUserId = userId,
Flavor = "",
TtlSeconds = 24 * 3600,
CreatedAt = now,
UpdatedAt = now,
});
}
private sealed record ChangeWithPeriod(long LedgerId, long CategoryId, int Period, decimal Delta);
private sealed record CrossedBudget(long? CategoryId, int Threshold);
}
@@ -0,0 +1,358 @@
using MiaoJiZhang.Domain.Entities;
using MiaoJiZhang.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MiaoJiZhang.Api.Services;
public sealed class PushDispatchService(
IServiceScopeFactory scopeFactory,
IConfiguration configuration,
ILogger<PushDispatchService> logger) : BackgroundService
{
private static readonly TimeSpan[] RetrySchedule =
[
TimeSpan.FromMinutes(1),
TimeSpan.FromMinutes(5),
TimeSpan.FromMinutes(30),
TimeSpan.FromHours(2),
TimeSpan.FromHours(6),
];
private DateTime nextCleanupAt = DateTime.MinValue;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
while (!stoppingToken.IsCancellationRequested)
{
if (configuration.GetValue<bool>("Push:Enabled"))
{
try
{
await RecoverAndFinalizeMessages(stoppingToken);
await ExpandMessages(stoppingToken);
await DispatchDeliveries(stoppingToken);
if (nextCleanupAt <= DateTime.UtcNow)
{
await Cleanup(stoppingToken);
nextCleanupAt = DateTime.UtcNow.AddHours(6);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception exception)
{
logger.LogError(exception, "Push dispatch loop failed");
}
}
await timer.WaitForNextTickAsync(stoppingToken);
}
}
private async Task RecoverAndFinalizeMessages(CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var now = DateTime.UtcNow;
var staleBefore = now.AddMinutes(-3);
await db.PushMessages
.Where(message => message.State == PushMessageStates.Sending &&
message.StartedAt <= staleBefore &&
!db.PushDeliveries.Any(delivery => delivery.PushMessageId == message.Id))
.ExecuteUpdateAsync(setters => setters
.SetProperty(message => message.State, PushMessageStates.Queued)
.SetProperty(message => message.StartedAt, (DateTime?)null)
.SetProperty(message => message.UpdatedAt, now), ct);
var ready = await db.PushMessages
.Where(message => message.State == PushMessageStates.Sending &&
db.PushDeliveries.Any(delivery => delivery.PushMessageId == message.Id) &&
!db.PushDeliveries.Any(delivery => delivery.PushMessageId == message.Id &&
(delivery.State == PushDeliveryStates.Queued ||
delivery.State == PushDeliveryStates.Sending)))
.Select(message => message.Id)
.Take(100)
.ToListAsync(ct);
foreach (var messageId in ready)
await FinalizeMessage(db, messageId, ct);
}
private async Task ExpandMessages(CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var now = DateTime.UtcNow;
var candidates = await db.PushMessages
.Where(message =>
message.State == PushMessageStates.Queued ||
(message.State == PushMessageStates.Scheduled && message.ScheduledAt <= now))
.OrderBy(message => message.ScheduledAt ?? message.CreatedAt)
.Select(message => message.Id)
.Take(20)
.ToListAsync(ct);
foreach (var id in candidates)
{
var claimed = await db.PushMessages
.Where(message => message.Id == id &&
(message.State == PushMessageStates.Queued ||
(message.State == PushMessageStates.Scheduled && message.ScheduledAt <= now)))
.ExecuteUpdateAsync(setters => setters
.SetProperty(message => message.State, PushMessageStates.Sending)
.SetProperty(message => message.StartedAt, now)
.SetProperty(message => message.UpdatedAt, now), ct);
if (claimed != 1) continue;
var message = await db.PushMessages.FirstAsync(item => item.Id == id, ct);
var deviceQuery = db.PushDevices
.Where(device => device.IsActive && device.NotificationsAllowed &&
!device.User.IsBanned && device.User.AccountClosureScheduledAt == null);
if (message.IsTest)
{
deviceQuery = deviceQuery.Where(device => device.Id == message.TestDeviceId);
}
else
{
deviceQuery = deviceQuery.Where(device =>
db.UserPushPreferences.Any(preference =>
preference.UserId == device.UserId &&
preference.Category == message.Category &&
preference.IsEnabled));
if (message.TargetUserId.HasValue)
deviceQuery = deviceQuery.Where(device => device.UserId == message.TargetUserId.Value);
if (!string.IsNullOrWhiteSpace(message.Flavor))
deviceQuery = deviceQuery.Where(device => device.Flavor == message.Flavor);
if (!string.IsNullOrWhiteSpace(message.ProviderFilter))
deviceQuery = deviceQuery.Where(device => device.Provider == message.ProviderFilter);
if (message.MinVersionCode.HasValue)
deviceQuery = deviceQuery.Where(device => device.VersionCode >= message.MinVersionCode.Value);
if (message.MaxVersionCode.HasValue)
deviceQuery = deviceQuery.Where(device => device.VersionCode <= message.MaxVersionCode.Value);
}
var devices = await deviceQuery.Select(device => new
{
device.Id,
device.UserId,
device.Provider,
}).ToListAsync(ct);
var existing = await db.PushDeliveries
.Where(delivery => delivery.PushMessageId == id)
.Select(delivery => delivery.PushDeviceId)
.ToListAsync(ct);
var existingIds = existing.ToHashSet();
foreach (var device in devices.Where(device => !existingIds.Contains(device.Id)))
{
db.PushDeliveries.Add(new PushDelivery
{
PushMessageId = id,
PushDeviceId = device.Id,
UserId = device.UserId,
Provider = device.Provider,
State = PushDeliveryStates.Queued,
NextAttemptAt = now,
CreatedAt = now,
UpdatedAt = now,
});
}
if (devices.Count == 0)
{
message.State = PushMessageStates.Completed;
message.CompletedAt = now;
message.UpdatedAt = now;
}
await db.SaveChangesAsync(ct);
}
}
private async Task DispatchDeliveries(CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var registry = scope.ServiceProvider.GetRequiredService<PushProviderRegistry>();
var tokenProtector = scope.ServiceProvider.GetRequiredService<PushTokenProtector>();
var now = DateTime.UtcNow;
var candidates = await db.PushDeliveries
.Where(delivery =>
(delivery.State == PushDeliveryStates.Queued && delivery.NextAttemptAt <= now) ||
(delivery.State == PushDeliveryStates.Sending && delivery.LeaseExpiresAt <= now))
.OrderBy(delivery => delivery.NextAttemptAt)
.Select(delivery => delivery.Id)
.Take(50)
.ToListAsync(ct);
var affectedMessages = new HashSet<long>();
foreach (var id in candidates)
{
var claimNow = DateTime.UtcNow;
var leaseId = Guid.NewGuid().ToString();
var leaseUntil = claimNow.AddMinutes(2);
var claimed = await db.PushDeliveries
.Where(delivery => delivery.Id == id &&
((delivery.State == PushDeliveryStates.Queued && delivery.NextAttemptAt <= claimNow) ||
(delivery.State == PushDeliveryStates.Sending && delivery.LeaseExpiresAt <= claimNow)))
.ExecuteUpdateAsync(setters => setters
.SetProperty(delivery => delivery.State, PushDeliveryStates.Sending)
.SetProperty(delivery => delivery.LeaseId, leaseId)
.SetProperty(delivery => delivery.LeaseExpiresAt, leaseUntil)
.SetProperty(delivery => delivery.AttemptCount, delivery => delivery.AttemptCount + 1)
.SetProperty(delivery => delivery.UpdatedAt, claimNow), ct);
if (claimed != 1) continue;
var delivery = await db.PushDeliveries
.Include(item => item.PushMessage)
.Include(item => item.PushDevice).ThenInclude(device => device.User)
.FirstAsync(item => item.Id == id, ct);
affectedMessages.Add(delivery.PushMessageId);
await SendOne(db, registry, tokenProtector, delivery, ct);
}
foreach (var messageId in affectedMessages)
await FinalizeMessage(db, messageId, ct);
}
private static async Task SendOne(
AppDbContext db,
PushProviderRegistry registry,
PushTokenProtector tokenProtector,
PushDelivery delivery,
CancellationToken ct)
{
var now = DateTime.UtcNow;
var message = delivery.PushMessage;
var device = delivery.PushDevice;
var expiresAt = (message.ScheduledAt ?? message.CreatedAt).AddSeconds(message.TtlSeconds);
if (expiresAt <= now)
{
Skip(delivery, "message_expired", now);
await db.SaveChangesAsync(ct);
return;
}
if (!device.IsActive || !device.NotificationsAllowed ||
device.User.IsBanned || device.User.AccountClosureScheduledAt.HasValue)
{
Skip(delivery, "device_or_account_inactive", now);
await db.SaveChangesAsync(ct);
return;
}
if (!message.IsTest && !await db.UserPushPreferences.AnyAsync(preference =>
preference.UserId == device.UserId && preference.Category == message.Category &&
preference.IsEnabled, ct))
{
Skip(delivery, "category_disabled", now);
await db.SaveChangesAsync(ct);
return;
}
var provider = registry.Find(device.Provider);
if (provider is null)
{
Fail(delivery, "provider_unknown", "Unknown push provider", now);
await db.SaveChangesAsync(ct);
return;
}
var envelope = new PushEnvelope(
message.PublicId,
message.Title,
message.Body,
message.Category,
message.Action,
message.EntityId,
Math.Max(60, (int)(expiresAt - now).TotalSeconds));
var result = await provider.SendAsync(
device.Flavor,
device.PackageName,
tokenProtector.Unprotect(device.TokenCiphertext),
envelope,
ct);
if (result.Accepted)
{
delivery.State = PushDeliveryStates.Accepted;
delivery.AcceptedAt = now;
delivery.ProviderMessageId = result.ProviderMessageId;
delivery.ErrorCode = null;
delivery.ErrorMessage = null;
ClearLease(delivery, now);
}
else if (result.Retryable && delivery.AttemptCount <= RetrySchedule.Length && expiresAt > now)
{
var retry = result.RetryAfter ?? RetrySchedule[Math.Clamp(delivery.AttemptCount - 1, 0, RetrySchedule.Length - 1)];
delivery.State = PushDeliveryStates.Queued;
delivery.NextAttemptAt = now.Add(retry) < expiresAt ? now.Add(retry) : expiresAt;
delivery.ErrorCode = result.ErrorCode;
delivery.ErrorMessage = result.ErrorMessage;
ClearLease(delivery, now);
}
else
{
Fail(delivery, result.ErrorCode ?? "provider_rejected", result.ErrorMessage, now);
}
if (result.InvalidToken)
{
device.IsActive = false;
device.DisabledReason = "provider_invalid_token";
device.UpdatedAt = now;
}
await db.SaveChangesAsync(ct);
}
private static async Task FinalizeMessage(AppDbContext db, long messageId, CancellationToken ct)
{
var pending = await db.PushDeliveries.AnyAsync(delivery =>
delivery.PushMessageId == messageId &&
(delivery.State == PushDeliveryStates.Queued || delivery.State == PushDeliveryStates.Sending), ct);
if (pending) return;
var failed = await db.PushDeliveries.AnyAsync(delivery =>
delivery.PushMessageId == messageId && delivery.State == PushDeliveryStates.Failed, ct);
var now = DateTime.UtcNow;
await db.PushMessages.Where(message => message.Id == messageId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(message => message.State,
failed ? PushMessageStates.PartiallyFailed : PushMessageStates.Completed)
.SetProperty(message => message.CompletedAt, now)
.SetProperty(message => message.UpdatedAt, now), ct);
}
private async Task Cleanup(CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var staleBefore = DateTime.UtcNow.AddDays(-90);
await db.PushDevices
.Where(device => device.IsActive && device.LastSeenAt < staleBefore)
.ExecuteUpdateAsync(setters => setters
.SetProperty(device => device.IsActive, false)
.SetProperty(device => device.DisabledReason, "stale_device")
.SetProperty(device => device.UpdatedAt, DateTime.UtcNow), ct);
await db.PushDeliveries
.Where(delivery => delivery.UpdatedAt < staleBefore &&
delivery.State != PushDeliveryStates.Queued &&
delivery.State != PushDeliveryStates.Sending)
.ExecuteDeleteAsync(ct);
}
private static void Skip(PushDelivery delivery, string code, DateTime now)
{
delivery.State = PushDeliveryStates.Skipped;
delivery.ErrorCode = code;
delivery.ErrorMessage = null;
ClearLease(delivery, now);
}
private static void Fail(PushDelivery delivery, string code, string? message, DateTime now)
{
delivery.State = PushDeliveryStates.Failed;
delivery.ErrorCode = code;
delivery.ErrorMessage = message is { Length: > 400 } ? message[..400] : message;
ClearLease(delivery, now);
}
private static void ClearLease(PushDelivery delivery, DateTime now)
{
delivery.LeaseId = null;
delivery.LeaseExpiresAt = null;
delivery.UpdatedAt = now;
}
}
@@ -0,0 +1,571 @@
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);
}
@@ -0,0 +1,67 @@
using System.Security.Cryptography;
using System.Text;
namespace MiaoJiZhang.Api.Services;
public sealed class PushTokenProtector(IConfiguration configuration)
{
private readonly byte[]? key = ReadKey(configuration["Push:TokenEncryptionKey"]);
public bool IsConfigured => key is { Length: 32 };
public string Protect(string value)
{
if (key is null)
throw new InvalidOperationException("Push__TokenEncryptionKey must be a base64-encoded 32-byte key");
var plaintext = Encoding.UTF8.GetBytes(value);
var nonce = RandomNumberGenerator.GetBytes(12);
var tag = new byte[16];
var ciphertext = new byte[plaintext.Length];
using var aes = new AesGcm(key, tag.Length);
aes.Encrypt(nonce, plaintext, ciphertext, tag);
var envelope = new byte[nonce.Length + tag.Length + ciphertext.Length];
Buffer.BlockCopy(nonce, 0, envelope, 0, nonce.Length);
Buffer.BlockCopy(tag, 0, envelope, nonce.Length, tag.Length);
Buffer.BlockCopy(ciphertext, 0, envelope, nonce.Length + tag.Length, ciphertext.Length);
return Convert.ToBase64String(envelope);
}
public string Unprotect(string value)
{
if (key is null)
throw new InvalidOperationException("Push__TokenEncryptionKey must be a base64-encoded 32-byte key");
var envelope = Convert.FromBase64String(value);
if (envelope.Length < 29) throw new CryptographicException("Invalid push token envelope");
var nonce = envelope.AsSpan(0, 12);
var tag = envelope.AsSpan(12, 16);
var ciphertext = envelope.AsSpan(28);
var plaintext = new byte[ciphertext.Length];
using var aes = new AesGcm(key, tag.Length);
aes.Decrypt(nonce, ciphertext, tag, plaintext);
return Encoding.UTF8.GetString(plaintext);
}
public static string Hash(string value) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)));
public static string CreateUnbindToken() =>
Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static byte[]? ReadKey(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
try
{
var parsed = Convert.FromBase64String(value);
return parsed.Length == 32 ? parsed : null;
}
catch (FormatException)
{
return null;
}
}
}