63.【.NET8 实战--孢子记账--从单体到微服务--转向微服务】--新增功能--预算告警

在日常生活中,我们经常会遇到预算超支的情况。为了帮助用户更好地管理个人财务,我们可以在孢子记账中引入预算告警功能。该功能的主要目标是实时监控用户的支出情况,并在即将超过预算、预算用尽、预算超出时及时发出报警信息,提醒用户注意财务状况。

一、需求分析

这一小节,我们将详细分析预算告警功能的需求,明确其核心目标和用户需求。首先,我们需要定义预算告警的触发条件,包括但不限于以下几种情况:

  1. 预算即将超出:当用户的支出达到预算的80%或用户自定义的阈值时,系统应发出预警。
  2. 预算耗尽:当用户的支出达到预算的100%时,系统应立即发出告警。
  3. 预算超额:当用户的支出超过预算时,系统应持续发出告警。

上面这些触发条件将帮助用户及时了解自己的财务状况,避免不必要的超支。但是这里有一个问题,用户几乎不可能在记账时预算正好用尽,所以我们需要设计一个合理的告警机制,确保用户可以在预算用尽的时候收到告警。

接下来,根据前面的问题,我们要定义出什么是 预算耗尽 。在我们大部分人的概念里,预算耗尽意味着用户的支出已经达到了他们设定的预算上限(100%)。但是在实际应用中,用户很少会在记账时正好达到预算的100%。因此,我们可以将 预算耗尽 定义为用户的支出超出预算的10%,以确保用户在预算耗尽时能够及时收到告警。

最后,我们需要考虑告警的频率和方式。为了避免频繁打扰用户,我们可以设置告警的最小间隔时间,例如每24小时内只发送一次告警。告警可以通过应用内通知、电子邮件或短信等多种方式发送,用户可以根据自己的偏好选择告警方式。

到目前,我们已经对预算告警功能的需求进行了详细分析,明确了触发条件、预算耗尽的定义以及告警的频率和方式。下面的表格是对预算告警需求的总结:

触发条件 描述
预算即将超出 当用户的支出达到预算的80%或用户自定义的阈值时,系统应发出预警。
预算耗尽 当用户的支出达到预算的100%但未超过110%时,系统应发出告警。
预算超额 当用户的支出超过预算的110%时,系统应持续发出告警。

二、功能设计

在明确了预算告警的需求后,接下来我们将设计预算告警功能的具体实现方案。该功能主要包括预算监控与告警触发和告警发送三个核心模块。

2.1 预算监控与告警触发模块

我们将预算监控与告警触发模块设计为一个定时任务,定期检查用户的预算使用情况,并根据预设的触发条件决定是否发送告警。下面是该模块的核心代码实现的补充说明与设计要点。

  1. 涉及思路

    任务采用 Quartz 每天定时执行一次以保证告警频率可控;在任务内部使用流式查询(AsAsyncEnumerable/分页)遍历当前生效的预算,避免一次性加载导致内存峰值。对每条预算计算已用金额与使用百分比,并读取用户个性化阈值以判断告警等级(预警/耗尽/超额);告警以异步消息的形式发送到消息队列,由独立的通知服务负责具体推送(站内信、邮件、短信等),同时在 Redis 按日记录已发送类型以实现去重与幂等。任务应处理外部依赖异常并降级到默认配置,记录关键指标以便监控与运维。

  2. 处理流程

    启动任务时获取当前时间,用于构造当天的去重 Key,然后以流式方式AsAsyncEnumerable读取所有当前生效的预算,避免一次性加载导致内存峰值。对每一条预算,先计算已用金额(usedAmount)与使用百分比(usagePercent),并调用配置服务获取用户的预警阈值与消息偏好,若配置异常或缺失则使用默认值。

    根据使用率与剩余金额判断告警类型,当 usagePercent >= warningThresholdremaining > 0 时视为预警,当 remaining <= 0usedAmount <= amount * 1.1 时视为耗尽,当 usedAmount > amount * 1.1 时视为超额。针对每种类型,先在按天的 Redis Hash 中检查是否已发送相同类型的告警,若未发送则构建并发送 MQ 消息(异步入队由通知服务负责具体推送),并在 Redis 中标记为已发送以实现去重与幂等。循环处理完成后,为当天的 Redis Hash 设置过期时间实现自动清理。

  3. 幂等与去重

    使用按日期区分的 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 告警发送模块

告警发送模块负责将预算告警消息通过用户指定的渠道发送给用户。该模块从消息队列中接收预算告警消息,并根据用户的偏好选择合适的发送方式(站内信、电子邮件、短信等)。下面是该模块的核心代码实现的补充说明与设计要点。

  1. 设计思路

    告警发送模块作为独立的后台服务,订阅预算告警消息队列,异步处理接收到的告警消息。根据消息内容和用户偏好,选择合适的发送渠道(站内信、邮件、短信等)进行通知。

  2. 处理流程

    启动服务时,创建消息队列订阅者,监听预算告警相关的消息。接收到消息后,首先验证消息格式和内容的有效性,确保是预算告警类型的消息。然后反序列化消息体,获取预算告警的详细信息。

    接下来,根据消息类型(预警、耗尽、超额)构建相应的通知内容,包括主题和正文。根据用户的通知偏好,选择合适的发送方式,并调用对应的发送方法(如发送电子邮件、短信或站内信)。在发送过程中,捕获可能出现的异常,并记录日志以便后续排查。

  3. 可扩展性

    告警发送模块设计为独立的服务,便于后续功能扩展。例如,可以添加更多的通知渠道(如推送通知、社交媒体等),或者引入更智能的告警策略(如基于用户行为调整告警频率)。此外,可以集成第三方通知服务,以提升发送效率和可靠性。

上面的设计保证告警发送模块能够高效、可靠地将预算告警消息传达给用户,提升用户的财务管理体验。以下代码是该模块的核心代码:

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),这里通常会调用辅助函数 GetNotificationSubjectGetNotificationContent,根据预算超支、耗尽或预警的不同情境生成合适的提示语。随后读取用户的消息偏好(MessagePreference),该字段被枚举为 NotificationEnum 类型,用来区分用户希望接收通知的渠道。

根据不同的偏好,代码通过 switch 语句调用不同的发送函数:

  • 如果用户偏好为 Email ,则调用 SendEmailNotification
  • 如果为 SmS ,则调用 SendSmsNotification
  • 如果为 InApp ,则调用 SendInAppNotification,即应用内消息推送;
  • 如果类型未知,则输出警告日志提醒开发者注意数据异常。

这种结构让系统具备很好的可扩展性和容错性,可以方便地新增新的消息类型或通知方式(比如微信、钉钉等),而不会影响主逻辑。同时,异步执行保证了在高并发场景下,后台任务不会阻塞主线程,提高系统整体性能。

Tip:目前暂时没有站内信的实现代码,因此这里调用的 SendInAppNotification 方法是一个占位符,实际项目中需要根据具体的站内信实现进行补充。

三、总结

通过引入预算告警功能,孢子记账将帮助用户更好地管理个人财务,避免不必要的超支。该功能通过实时监控用户的支出情况,并在预算即将超出、预算耗尽和预算超额时及时发出告警信息,提醒用户注意财务状况。未来,我们还可以进一步优化告警机制,例如引入更智能的告警策略,根据用户的消费习惯和历史数据进行个性化告警设置,以提升用户体验。

相关推荐
专注VB编程开发20年4 小时前
VB.NET2003和VB2008可以导入VB6项目
.net·vb.net·vb6·vb2008
Akshsjsjenjd4 小时前
Docker资源限制详解
运维·docker·容器
yalipf4 小时前
忘记密码更改ubuntu18.08的密码--前提是要知道用户名work
linux·运维·ubuntu
xrkhy4 小时前
微服务之OpenFeign 服务调用
微服务·云原生·架构
小猪咪piggy4 小时前
【微服务】(2) 环境和工程搭建
微服务·云原生·架构
xrkhy4 小时前
微服务之配置中心Nacos
微服务·架构
xrkhy4 小时前
微服务之Gateway网关(1)
微服务·架构·gateway
喵叔哟5 小时前
62.【.NET8 实战--孢子记账--从单体到微服务--转向微服务】--新增功能--自训练ML模型
微服务·架构·.net
雲帝6 小时前
1panel docker开启swap内存
运维·docker·容器