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
@@ -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);
}