Add domestic vendor push infrastructure
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user