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