在日常生活中,我们经常会遇到预算超支的情况。为了帮助用户更好地管理个人财务,我们可以在孢子记账中引入预算告警功能。该功能的主要目标是实时监控用户的支出情况,并在即将超过预算、预算用尽、预算超出时及时发出报警信息,提醒用户注意财务状况。
一、需求分析
这一小节,我们将详细分析预算告警功能的需求,明确其核心目标和用户需求。首先,我们需要定义预算告警的触发条件,包括但不限于以下几种情况:
- 预算即将超出:当用户的支出达到预算的80%或用户自定义的阈值时,系统应发出预警。
- 预算耗尽:当用户的支出达到预算的100%时,系统应立即发出告警。
- 预算超额:当用户的支出超过预算时,系统应持续发出告警。
上面这些触发条件将帮助用户及时了解自己的财务状况,避免不必要的超支。但是这里有一个问题,用户几乎不可能在记账时预算正好用尽,所以我们需要设计一个合理的告警机制,确保用户可以在预算用尽的时候收到告警。
接下来,根据前面的问题,我们要定义出什么是 预算耗尽 。在我们大部分人的概念里,预算耗尽意味着用户的支出已经达到了他们设定的预算上限(100%)。但是在实际应用中,用户很少会在记账时正好达到预算的100%。因此,我们可以将 预算耗尽 定义为用户的支出超出预算的10%,以确保用户在预算耗尽时能够及时收到告警。
最后,我们需要考虑告警的频率和方式。为了避免频繁打扰用户,我们可以设置告警的最小间隔时间,例如每24小时内只发送一次告警。告警可以通过应用内通知、电子邮件或短信等多种方式发送,用户可以根据自己的偏好选择告警方式。
到目前,我们已经对预算告警功能的需求进行了详细分析,明确了触发条件、预算耗尽的定义以及告警的频率和方式。下面的表格是对预算告警需求的总结:
触发条件 | 描述 |
---|---|
预算即将超出 | 当用户的支出达到预算的80%或用户自定义的阈值时,系统应发出预警。 |
预算耗尽 | 当用户的支出达到预算的100%但未超过110%时,系统应发出告警。 |
预算超额 | 当用户的支出超过预算的110%时,系统应持续发出告警。 |
二、功能设计
在明确了预算告警的需求后,接下来我们将设计预算告警功能的具体实现方案。该功能主要包括预算监控与告警触发和告警发送三个核心模块。
2.1 预算监控与告警触发模块
我们将预算监控与告警触发模块设计为一个定时任务,定期检查用户的预算使用情况,并根据预设的触发条件决定是否发送告警。下面是该模块的核心代码实现的补充说明与设计要点。
-
涉及思路
任务采用 Quartz 每天定时执行一次以保证告警频率可控;在任务内部使用流式查询(AsAsyncEnumerable/分页)遍历当前生效的预算,避免一次性加载导致内存峰值。对每条预算计算已用金额与使用百分比,并读取用户个性化阈值以判断告警等级(预警/耗尽/超额);告警以异步消息的形式发送到消息队列,由独立的通知服务负责具体推送(站内信、邮件、短信等),同时在 Redis 按日记录已发送类型以实现去重与幂等。任务应处理外部依赖异常并降级到默认配置,记录关键指标以便监控与运维。
-
处理流程
启动任务时获取当前时间,用于构造当天的去重 Key,然后以流式方式
AsAsyncEnumerable
读取所有当前生效的预算,避免一次性加载导致内存峰值。对每一条预算,先计算已用金额(usedAmount)与使用百分比(usagePercent),并调用配置服务获取用户的预警阈值与消息偏好,若配置异常或缺失则使用默认值。根据使用率与剩余金额判断告警类型,当
usagePercent >= warningThreshold
且remaining > 0
时视为预警,当remaining <= 0
且usedAmount <= amount * 1.1
时视为耗尽,当usedAmount > amount * 1.1
时视为超额。针对每种类型,先在按天的 Redis Hash 中检查是否已发送相同类型的告警,若未发送则构建并发送 MQ 消息(异步入队由通知服务负责具体推送),并在 Redis 中标记为已发送以实现去重与幂等。循环处理完成后,为当天的 Redis Hash 设置过期时间实现自动清理。 -
幂等与去重
使用按日期区分的 Redis Hash 记录已通知的预算 ID 与时间戳,以保证在任务重试或并发执行时不会重复发送相同类型的通知。Hash 的粒度可以为 budgetId+告警类型,也可以为不同告警类型使用不同的 Hash,并为这些 Key 设置过期时间以实现按日去重。对于重要通知,可在写入时使用 Redis 的 SETNX 或带版本号的乐观并发控制来增强幂等性,必要时结合重试与补偿机制确保可靠投递。
这些设计保证该定时任务在准确判断预算状态,避免重复打扰用户、并且便于后续功能扩展(如基于历史消费智能调整阈值)。以下代码是该模块的核心代码讲解。
csharp
/// <summary>
/// 预算监控预警
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public async System.Threading.Tasks.Task Execute(IJobExecutionContext context)
{
var today = DateTime.Now.ToString("yyyyMMdd");
var now = DateTime.Now;
// Redis Hash Key,用于存储今日已通知的预算
// 预警通知
string depletionHashKey = string.Format(SPRedisKey.DepletionHashKey, today);
// 耗尽通知
string exhaustedHashKey = string.Format(SPRedisKey.ExhaustedHashKey, today);
// 超额通知
string overrunHashKey = string.Format(SPRedisKey.OverrunHashKey, today);
var budgets = _budgetServer.QueryBudgetsByDate(now);
// 流式查询所有当前生效的预算,避免一次性加载到内存
await foreach (var budget in budgets.AsAsyncEnumerable())
{
try
{
// 计算预算使用情况
decimal usedAmount = budget.Amount - budget.Remaining;
decimal usagePercent = budget.Amount > 0
? (usedAmount / budget.Amount) * 100
: 0;
// 获取用户配置的预警阈值(默认80%)
decimal warningThreshold = await GetUserWarningThreshold(budget.CreateUserId);
// 获取用户配置的通知发送方式,默认站内信
int messagePreference = await GetUserMessagePreference(budget.CreateUserId);
// 1. 预算预警通知(达到阈值但未耗尽)
if (usagePercent >= warningThreshold && budget.Remaining > 0)
{
string? notifiedValue = await _redisService.HashGetAsync(
depletionHashKey,
budget.Id.ToString());
if (string.IsNullOrEmpty(notifiedValue))
{
// 发送预警通知
await SendDepletionWarning(budget.CreateUserId, budget.Id, usagePercent, messagePreference);
// 标记已通知
await _redisService.HashSetAsync(
depletionHashKey,
budget.Id.ToString(),
DateTime.Now.ToString("O"));
}
}
// 2. 预算耗尽通知(刚好用完或略超)
else if (budget.Remaining <= 0 && usedAmount <= budget.Amount * 1.1m)
{
string? notifiedValue = await _redisService.HashGetAsync(
exhaustedHashKey,
budget.Id.ToString());
if (string.IsNullOrEmpty(notifiedValue))
{
// 发送耗尽通知
await SendExhaustedNotification(budget.CreateUserId, budget.Id, messagePreference);
// 标记已通知
await _redisService.HashSetAsync(
exhaustedHashKey,
budget.Id.ToString(),
DateTime.Now.ToString("O"));
}
}
// 3. 预算超额通知(超过预算10%以上)
else if (usedAmount > budget.Amount * 1.1m)
{
string? notifiedValue = await _redisService.HashGetAsync(
overrunHashKey,
budget.Id.ToString());
if (string.IsNullOrEmpty(notifiedValue))
{
// 发送超额通知
decimal overrunPercent = ((usedAmount - budget.Amount) / budget.Amount) * 100;
await SendOverrunNotification(budget.CreateUserId, budget.Id, overrunPercent,
messagePreference);
// 标记已通知
await _redisService.HashSetAsync(
overrunHashKey,
budget.Id.ToString(),
DateTime.Now.ToString("O"));
}
}
}
catch (Exception ex)
{
// 记录错误但继续处理其他预算
_logger.LogError($"处理预算 {budget.Id} 时出错: {ex.Message}");
}
}
// 设置Hash的过期时间(24小时后自动清理)
await _redisService.SetExpiryAsync(depletionHashKey, 86400);
await _redisService.SetExpiryAsync(exhaustedHashKey, 86400);
await _redisService.SetExpiryAsync(overrunHashKey, 86400);
}
在上面的代码中,我们实现了预算监控与告警触发模块的核心逻辑。模块定时扫描并发现需要告警的预算,按天流式遍历当前生效预算以避免 OOM。对每条预算计算已用金额与使用率,并结合剩余金额与用户自定义阈值判断告警等级(预警 / 耗尽 / 超额)。在发送前通过按日 Redis Key 做去重与幂等保护并标记已通知项。将告警统一构建为 MQ 消息异步入队,由独立通知服务负责不同渠道的最终推送与重试。同时捕获外部依赖异常并降级到默认配置,记录失败与关键指标以便监控与运维。
2.2 告警发送模块
告警发送模块负责将预算告警消息通过用户指定的渠道发送给用户。该模块从消息队列中接收预算告警消息,并根据用户的偏好选择合适的发送方式(站内信、电子邮件、短信等)。下面是该模块的核心代码实现的补充说明与设计要点。
-
设计思路
告警发送模块作为独立的后台服务,订阅预算告警消息队列,异步处理接收到的告警消息。根据消息内容和用户偏好,选择合适的发送渠道(站内信、邮件、短信等)进行通知。
-
处理流程
启动服务时,创建消息队列订阅者,监听预算告警相关的消息。接收到消息后,首先验证消息格式和内容的有效性,确保是预算告警类型的消息。然后反序列化消息体,获取预算告警的详细信息。
接下来,根据消息类型(预警、耗尽、超额)构建相应的通知内容,包括主题和正文。根据用户的通知偏好,选择合适的发送方式,并调用对应的发送方法(如发送电子邮件、短信或站内信)。在发送过程中,捕获可能出现的异常,并记录日志以便后续排查。
-
可扩展性
告警发送模块设计为独立的服务,便于后续功能扩展。例如,可以添加更多的通知渠道(如推送通知、社交媒体等),或者引入更智能的告警策略(如基于用户行为调整告警频率)。此外,可以集成第三方通知服务,以提升发送效率和可靠性。
上面的设计保证告警发送模块能够高效、可靠地将预算告警消息传达给用户,提升用户的财务管理体验。以下代码是该模块的核心代码:
csharp
/// <summary>
/// 执行异步任务
/// </summary>
/// <param name="stoppingToken">取消令牌</param>
protected override async System.Threading.Tasks.Task ExecuteAsync(CancellationToken stoppingToken)
{
MqSubscriber subscriber = new MqSubscriber(
MqExchange.BudgetExchange,
MqRoutingKey.BudgetRoutingKey,
MqQueue.BudgetQueue);
await _rabbitMqMessage.ReceiveAsync(subscriber, async message =>
{
// 验证消息
if (message is not MqMessage mqMessage)
{
_logger.LogError("消息转换失败");
return;
}
// 只处理预算通知相关的消息
if (mqMessage.Type != MessageType.BudgetWarning &&
mqMessage.Type != MessageType.BudgetExhausted &&
mqMessage.Type != MessageType.BudgetOverrun)
{
// 不是通知类型的消息,直接返回(可能是其他预算相关消息)
return;
}
if (string.IsNullOrEmpty(mqMessage.Body))
{
_logger.LogError("消息体为空,无法处理预算通知");
return;
}
// 反序列化消息
BudgetNotificationMQ? notification = JsonSerializer.Deserialize<BudgetNotificationMQ>(mqMessage.Body);
if (notification == null)
{
_logger.LogError("消息体反序列化失败,无法处理预算通知");
return;
}
try
{
// 获取预算信息
var budget = _budgetServer.QueryById(notification.BudgetId);
if (budget == null)
{
_logger.LogWarning($"未找到预算信息,预算ID: {notification.BudgetId}");
return;
}
// 根据消息类型和用户偏好发送通知
await ProcessNotification(mqMessage.Type, notification, budget);
}
catch (Exception ex)
{
_logger.LogError(ex, $"处理预算通知消息失败: {mqMessage.Type}, 用户ID: {notification.UserId}, 预算ID: {notification.BudgetId}");
}
});
}
/// <summary>
/// 处理通知
/// </summary>
/// <param name="messageType">消息类型</param>
/// <param name="notification">通知消息</param>
/// <param name="budget">预算信息</param>
private async System.Threading.Tasks.Task ProcessNotification(string messageType, BudgetNotificationMQ notification, BudgetResponse budget)
{
// 构建通知内容
string subject = GetNotificationSubject(messageType);
string content = GetNotificationContent(messageType, notification, budget);
// 根据用户偏好发送通知
NotificationEnum preference = (NotificationEnum)notification.MessagePreference;
switch (preference)
{
case NotificationEnum.Email:
await SendEmailNotification(notification.UserId, subject, content);
break;
case NotificationEnum.SmS:
await SendSmsNotification(notification.UserId, content);
break;
case NotificationEnum.InApp:
await SendInAppNotification(notification.UserId, subject, content);
break;
default:
_logger.LogWarning($"未知的通知偏好: {notification.MessagePreference}");
break;
}
}
上面这段代码用于监听预算系统相关的通知消息,并根据不同类型的消息触发相应的用户通知方式(邮件、短信或应用内通知)。在 ExecuteAsync
方法中,首先创建了一个 MqSubscriber
对象,用于订阅特定的消息交换机(BudgetExchange
)、路由键(BudgetRoutingKey
)以及队列(BudgetQueue
)。这表示该任务会持续监听预算相关的队列消息。一旦消息到达,代码通过 _rabbitMqMessage.ReceiveAsync
异步接收,并传入一个处理消息的回调函数。
在回调中,代码首先对收到的消息进行验证:如果消息无法转换为预期的 MqMessage
类型,就会记录错误并跳过。接着会判断消息的类型,只处理三类与预算通知相关的消息:预算预警(BudgetWarning
)、预算耗尽(BudgetExhausted
)和预算超支(BudgetOverrun
)。如果消息体为空或格式不正确(反序列化失败),同样会记录日志并返回。这样一来,系统能够过滤掉与预算通知无关或不合规范的消息,保证消息处理的可靠性和健壮性。
接下来的逻辑是核心处理部分。通过消息体中的预算 ID,调用 _budgetServer.QueryById
从预算服务中获取预算详细信息。如果查不到相应预算数据,就会打印警告日志,说明该消息可能是无效的或过期的。若预算存在,则进入 ProcessNotification
方法,根据消息类型和用户的通知偏好执行实际的通知推送。整个过程被放在 try-catch
中,以防止由于单条消息异常导致整个订阅服务崩溃。
ProcessNotification
方法用于真正执行通知发送。首先根据消息类型构建通知主题(subject
)和内容(content
),这里通常会调用辅助函数 GetNotificationSubject
和 GetNotificationContent
,根据预算超支、耗尽或预警的不同情境生成合适的提示语。随后读取用户的消息偏好(MessagePreference
),该字段被枚举为 NotificationEnum
类型,用来区分用户希望接收通知的渠道。
根据不同的偏好,代码通过 switch
语句调用不同的发送函数:
- 如果用户偏好为 Email ,则调用
SendEmailNotification
; - 如果为 SmS ,则调用
SendSmsNotification
; - 如果为 InApp ,则调用
SendInAppNotification
,即应用内消息推送; - 如果类型未知,则输出警告日志提醒开发者注意数据异常。
这种结构让系统具备很好的可扩展性和容错性,可以方便地新增新的消息类型或通知方式(比如微信、钉钉等),而不会影响主逻辑。同时,异步执行保证了在高并发场景下,后台任务不会阻塞主线程,提高系统整体性能。
Tip:目前暂时没有站内信的实现代码,因此这里调用的
SendInAppNotification
方法是一个占位符,实际项目中需要根据具体的站内信实现进行补充。
三、总结
通过引入预算告警功能,孢子记账将帮助用户更好地管理个人财务,避免不必要的超支。该功能通过实时监控用户的支出情况,并在预算即将超出、预算耗尽和预算超额时及时发出告警信息,提醒用户注意财务状况。未来,我们还可以进一步优化告警机制,例如引入更智能的告警策略,根据用户的消费习惯和历史数据进行个性化告警设置,以提升用户体验。