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

这一篇文章我们将一起完成预算报表的开发工作,目标是实现两个核心功能:预算使用进度和预算消耗趋势。预算使用进度用于展示各项预算在当前周期内的使用比例与剩余额度,帮助用户快速判断是否需要调整支出。预算消耗趋势通过时间序列图表呈现预算消耗的历史变化,便于发现消费高峰、周期性波动以及潜在的异常支出。为此我们会从后端入手设计聚合与查询逻辑,确定数据模型与索引策略以保证统计性能,再在服务层提供简单清晰的接口供前端调用。

一、需求分析

1.1 预算使用进度

预算使用进度我们要求实现如下两个功能点:

综合预算进度:综合预算进度用于展示用户正在使用的所有预算的整体使用情况,计算公式为已使用的总金额除以总预算金额,结果以百分比形式呈现。

各类别预算进度:各类别预算进度用于展示用户在不同类别下的预算使用情况,计算公式为已使用金额除以该类别的总预算金额,结果以百分比形式呈现。

1.2 预算消耗趋势

预算消耗趋势我们要求实现如下两个功能点:

总体趋势图:总体趋势图用于展示用户所有预算的消耗变化情况,横轴为时间(按天、周或月粒度),纵轴为消耗金额。

各类别趋势图:各类别趋势图用于展示用户在不同类别下的预算消耗变化情况,横轴为时间(按天、周或月粒度),纵轴为该类别的消耗金额。

二、功能设计

我们已经分析完需求了,接下来我们开始设计具体的功能实现方案。从这篇文章开始 Controller 层我们就不再展示了,除非有特殊处理逻辑需要说明。我们主要展示 Service 层的实现细节。

2.1 预算使用进度实现

我们首先需要获取当前用户正在使用的预算列表,这个预算列表需要从SP.FinanceService服务的budgets控制器中获取。这个属于跨服务调用,因此我们需要在SP.ReportService服务中定义 Refit 客户端接口,代码如下:

csharp 复制代码
using Refit;
using SP.ReportService.Models.Response;

namespace SP.ReportService.RefitClient;

public interface IBudgetServiceApi
{
    /// <summary>
    /// 获取当前用户正在使用的预算列表
    /// </summary>
    /// <returns>正在使用的预算列表</returns>
    [Get("/api/budgets/current-budgets")]
    Task<ApiResponse<List<BudgetResponse>>> GetCurrentBudgetsAsync();
}

这个 Refit 客户端接口定义了一个方法GetCurrentBudgetsAsync,用于调用SP.FinanceService服务的/api/budgets/current-budgets Web Api 接口获取当前用户正在使用的预算列表。这个接口我们已经在SP.FinanceService服务的budgets控制器中实现好了。对应 Service 层的实现代码如下:

csharp 复制代码
///================BudgetServerImpl.cs================///
/// <summary>
/// 获取当前用户正在使用的预算列表
/// </summary>
/// <returns>正在使用的预算列表</returns>
public List<BudgetResponse> QueryActiveBudgets()
{ 
    var userId = _contextSession.UserId;
    var budgets = _dbContext.Budgets
        .Where(b => !b.IsDeleted
                    && b.CreateUserId == userId
                    && b.StartTime <= DateTime.Now
                    && b.EndTime >= DateTime.Now)
        .ToList();
    var budgetResponses = _auMapper.Map<List<BudgetResponse>>(budgets);
    return budgetResponses;
}

由于在同一个时间段内,一个用户在同一时间内只能有同一个类别的一个预算在使用中,因此我们可以根据日期范围和用户 ID 来过滤预算数据。

接下来,我们就要在BudgetReportServerImpl中调用这个 Refit 客户端接口获取当前用户正在使用的预算列表,并实现预算使用进度的计算逻辑,代码如下:

csharp 复制代码
///================BudgetReportServerImpl.cs================///

/// <summary>
/// 预算进度
/// </summary>
/// <returns>
/// 预算报表列表包括:
/// 1. 综合预算进度
/// 2. 各类别预算进度
/// </returns>
public async Task<List<BudgetProgressReportResponse>> GetBudgetProgress()
{
    var response = await _budgetServiceApi.GetCurrentBudgetsAsync();
    if (response.IsSuccessStatusCode && response.Content != null)
    {
        var budgets = response.Content;
        var budgetProgressReports = new List<BudgetProgressReportResponse>();

        foreach (var budget in budgets)
        {
            var report = new BudgetProgressReportResponse
            {
                Period = budget.Period,
                IsComprehensive = false,
                CategoryName = budget.TransactionCategoryName,
                TotalAmount = budget.Amount,
                UsedAmount = budget.Amount - budget.Remaining,
                Remaining = budget.Remaining
            };
            budgetProgressReports.Add(report);
        }

        // 添加综合预算进度
        var totalAmount = budgets.Sum(b => b.Amount);
        var totalRemaining = budgets.Sum(b => b.Remaining);
        var comprehensiveReport = new BudgetProgressReportResponse
        {
            IsComprehensive = true,
            TotalAmount = totalAmount,
            UsedAmount = totalAmount - totalRemaining,
            Remaining = totalRemaining
        };
        budgetProgressReports.Insert(0, comprehensiveReport);
        return budgetProgressReports;
    }

    return new List<BudgetProgressReportResponse>();
}

这段方法从远端预算服务拉取当前预算列表,然后把每个类别的预算进度封装成 BudgetProgressReportResponse,并在最前面插入一条"综合"汇总记录后返回。具体流程是,调用 _budgetServiceApi.GetCurrentBudgetsAsync(),判断响应成功且 Content 不为 null,把 response.Content 作为 budgets 处理。对每个 budget 构造一个类别级别的报告。完成遍历后再计算所有类别的总额 totalAmount 和总剩余 totalRemaining,构造一条 IsComprehensivetrue 的综合报告,并插入到集合开头,最后返回列表。若接口调用失败则返回空列表。

2.2 预算消耗趋势实现

预算消耗趋势的实现相对复杂一些,我们需要根据用户的预算使用记录来计算各时间段的消耗金额。同样我们需要定义一个 Refit 客户端接口,从SP.FinanceService服务的budget-records控制器中获取预算使用记录,代码如下:

csharp 复制代码
using Refit;
using SP.ReportService.Models.Response;

namespace SP.ReportService.RefitClient;

/// <summary>
/// 预算记录服务接口
/// </summary>
public interface IBudgetRecordServiceApi
{
    /// <summary>
    /// 获取当前用户正在使用的预算列表
    /// </summary>
    /// <returns>正在使用的预算列表</returns>
    [Get("/api/budget-records/by-budget-ids")]
    Task<ApiResponse<Dictionary<long, List<BudgetRecordResponse>>>> GetBudgetRecordsByBudgetIdsAsync();
}

这个 Refit 客户端接口定义了一个方法GetBudgetRecordsByBudgetIdsAsync,用于调用SP.FinanceService服务的/api/budget-records/by-budget-ids Web Api 接口获取当前用户正在使用的预算记录列表。这个接口我们已经在SP.FinanceService服务的budget-records控制器中实现好了。对应 Service 层的实现代码如下:

csharp 复制代码
///================BudgetRecordServerImpl.cs================///

/// <summary>
/// 根据预算Id集合获取预算记录
/// </summary>
/// <returns>预算记录集合</returns>
public Dictionary<long, List<BudgetRecordResponse>> GetBudgetRecordsByBudgetIds()
{
    // 获取在用的预算Id集合
    var budgets = _budgetServer.QueryActiveBudgets();
    var budgetIds = budgets.Select(b => b.Id).ToList();
    // 查询预算记录
    var budgetRecords = _dbContext.BudgetRecords
        .Where(br => budgetIds.Contains(br.BudgetId))
        .ToList();
    // 将实体映射到响应模型
    var budgetRecordResponses = _automapper.Map<List<BudgetRecordResponse>>(budgetRecords);
    // 设置预算周期和预算类型
    for (int i = 0; i < budgetRecordResponses.Count; i++)
    {
        var budget = budgets.FirstOrDefault(b => b.Id == budgetRecordResponses[i].BudgetId);
        if (budget != null)
        {
            budgetRecordResponses[i].Period = budget.Period;
            budgetRecordResponses[i].TransactionCategoryId = budget.TransactionCategoryId;
        }
    }

    // 按预算Id分组
    var groupedRecords = budgetRecordResponses
        .GroupBy(br => br.BudgetId)
        .ToDictionary(g => g.Key, g => g.ToList());
    return groupedRecords;
}

这段方法首先获取当前用户正在使用的预算列表,然后根据这些预算的 ID 查询对应的预算使用记录,最后按预算 ID 分组返回。具体流程是,调用 _budgetServer.QueryActiveBudgets() 获取当前预算列表 budgets,提取出预算 ID 列表 budgetIds。接着查询数据库中 BudgetRecords 表,过滤出 BudgetIdbudgetIds 列表中的记录,并映射成 BudgetRecordResponse 列表 budgetRecordResponses。然后遍历这些记录,为每条记录设置对应的预算周期和类别信息。最后使用 LINQ 的 GroupBy 方法按 BudgetId 分组,并转换成字典返回。

接下来,我们就要在BudgetReportServerImpl中调用这个 Refit 客户端接口获取预算使用记录,并实现预算消耗趋势的计算逻辑,代码如下:

csharp 复制代码
///================BudgetReportServerImpl.cs================///

/// <summary>
/// 预算消耗趋势报表
/// </summary>
/// <returns>
/// 预算消耗趋势报表列表包括:
/// 1. 综合预算消耗趋势
/// 2. 各类别预算消耗趋势
/// 年预算消耗趋势报表按月展示,月预算消耗趋势报表按日展示,季度预算消耗趋势报表按周展示
/// </returns>
public async Task<List<BudgetProgressReportResponse>> GetBudgetConsumptionTrend()
{
    // 1. 获取预算记录
    var result = await _budgetRecordServiceApi.GetBudgetRecordsByBudgetIdsAsync();
    if (result.IsSuccessStatusCode && result.Content != null)
    {
        var budgetRecords = result.Content;
        List<BudgetProgressReportResponse> trendReports = new List<BudgetProgressReportResponse>();

        // 2. 获取当前预算信息
        var budgetResponse = await _budgetServiceApi.GetCurrentBudgetsAsync();
        if (budgetResponse.IsSuccessStatusCode && budgetResponse.Content != null)
        {
            var budgets = budgetResponse.Content;

            // 3. 综合预算消耗趋势(年预算消耗趋势报表按月展示,月预算消耗趋势报表按日展示,季度预算消耗趋势报表按周展示)
            var comprehensiveTrend = CalculateComprehensiveTrend(budgetRecords, budgets);
            trendReports.AddRange(comprehensiveTrend);

            // 4. 各类别预算消耗趋势(年预算消耗趋势报表按月展示,月预算消耗趋势报表按日展示,季度预算消耗趋势报表按周展示)
            var categoryTrends = CalculateCategoryTrends(budgetRecords, budgets);
            trendReports.AddRange(categoryTrends);
        }

        return trendReports;
    }

    return new List<BudgetProgressReportResponse>();
}

/// <summary>
/// 计算综合预算消耗趋势
/// </summary>
/// <param name="budgetRecords">预算记录</param>
/// <param name="budgets">预算信息</param>
/// <returns>综合预算消耗趋势报表</returns>
private List<BudgetProgressReportResponse> CalculateComprehensiveTrend(
    Dictionary<long, List<BudgetRecordResponse>> budgetRecords,
    List<BudgetResponse> budgets)
{
    var trendReports = new List<BudgetProgressReportResponse>();

    // 按预算周期分组
    var budgetsByPeriod = budgets.GroupBy(b => b.Period);

    foreach (var periodGroup in budgetsByPeriod)
    {
        var period = periodGroup.Key;
        var periodBudgets = periodGroup.ToList();

        // 获取该周期所有预算的ID
        var budgetIds = periodBudgets.Select(b => b.Id).ToList();

        // 获取该周期所有预算记录
        var allRecords = budgetIds
            .Where(id => budgetRecords.ContainsKey(id))
            .SelectMany(id => budgetRecords[id])
            .ToList();

        if (!allRecords.Any()) continue;

        // 根据周期类型进行时间分组
        var groupedRecords = GroupRecordsByPeriod(allRecords, period);

        foreach (var group in groupedRecords)
        {
            var totalUsedAmount = group.Sum(r => r.UsedAmount);
            var totalBudgetAmount = periodBudgets.Sum(b => b.Amount);
            var totalRemaining = periodBudgets.Sum(b => b.Remaining);

            trendReports.Add(new BudgetProgressReportResponse
            {
                Period = period,
                IsComprehensive = true,
                CategoryName = "综合预算",
                TotalAmount = totalBudgetAmount,
                UsedAmount = totalUsedAmount,
                Remaining = totalRemaining,
                ReportDate = group.Key
            });
        }
    }

    return trendReports;
}

/// <summary>
/// 计算各类别预算消耗趋势
/// </summary>
/// <param name="budgetRecords">预算记录</param>
/// <param name="budgets">预算信息</param>
/// <returns>各类别预算消耗趋势报表</returns>
private List<BudgetProgressReportResponse> CalculateCategoryTrends(
    Dictionary<long, List<BudgetRecordResponse>> budgetRecords,
    List<BudgetResponse> budgets)
{
    var trendReports = new List<BudgetProgressReportResponse>();

    // 按预算周期和类别分组
    var budgetsByPeriodAndCategory =
        budgets.GroupBy(b => new { b.Period, b.TransactionCategoryId, b.TransactionCategoryName });

    foreach (var group in budgetsByPeriodAndCategory)
    {
        var period = group.Key.Period;
        var categoryId = group.Key.TransactionCategoryId;
        var categoryName = group.Key.TransactionCategoryName;
        var periodBudgets = group.ToList();

        // 获取该类别预算的ID
        var budgetIds = periodBudgets.Select(b => b.Id).ToList();

        // 获取该类别所有预算记录
        var categoryRecords = budgetIds
            .Where(id => budgetRecords.ContainsKey(id))
            .SelectMany(id => budgetRecords[id])
            .ToList();

        if (!categoryRecords.Any()) continue;

        // 根据周期类型进行时间分组
        var groupedRecords = GroupRecordsByPeriod(categoryRecords, period);

        foreach (var recordGroup in groupedRecords)
        {
            var totalUsedAmount = recordGroup.Sum(r => r.UsedAmount);
            var totalBudgetAmount = periodBudgets.Sum(b => b.Amount);
            var totalRemaining = periodBudgets.Sum(b => b.Remaining);

            trendReports.Add(new BudgetProgressReportResponse
            {
                Period = period,
                IsComprehensive = false,
                CategoryName = categoryName,
                TotalAmount = totalBudgetAmount,
                UsedAmount = totalUsedAmount,
                Remaining = totalRemaining,
                ReportDate = recordGroup.Key
            });
        }
    }

    return trendReports;
}

/// <summary>
/// 根据预算周期对记录进行时间分组
/// </summary>
/// <param name="records">预算记录</param>
/// <param name="period">预算周期</param>
/// <returns>分组后的记录</returns>
private IEnumerable<IGrouping<string, BudgetRecordResponse>> GroupRecordsByPeriod(
    List<BudgetRecordResponse> records,
    PeriodEnum period)
{
    return period switch
    {
        PeriodEnum.Year => records.GroupBy(r => r.RecordDate.ToString("yyyy-MM")), // 年预算按月展示
        PeriodEnum.Month => records.GroupBy(r => r.RecordDate.ToString("yyyy-MM-dd")), // 月预算按日展示
        PeriodEnum.Quarter => records.GroupBy(r => GetWeekOfYear(r.RecordDate)), // 季度预算按周展示
        _ => records.GroupBy(r => r.RecordDate.ToString("yyyy-MM-dd"))
    };
}

/// <summary>
/// 获取日期所在年份的周数
/// </summary>
/// <param name="date">日期</param>
/// <returns>周数标识</returns>
private string GetWeekOfYear(DateTime date)
{
    var calendar = System.Globalization.CultureInfo.CurrentCulture.Calendar;
    var weekOfYear = calendar.GetWeekOfYear(date,
        System.Globalization.CalendarWeekRule.FirstDay,
        DayOfWeek.Monday);
    return $"{date.Year}-W{weekOfYear:D2}";
}

这段代码从外部服务拉取预算记录和当前预算信息,然后基于预算周期把数据汇总成"综合"与"按类别"两类趋势报表,年按月、月按日、季度按周展示。方法首先通过 _budgetRecordServiceApi.GetBudgetRecordsByBudgetIdsAsync() 异步获取所有预算记录,随后检查返回的 IsSuccessStatusCodeContent 非空,只有在成功并有数据时才继续处理。如果失败或无数据,最终会返回空的报表列表。接着再调用 _budgetServiceApi.GetCurrentBudgetsAsync() 拉取当前预算对象列表,同样做成功与非空校验。拿到两个数据集后,把综合趋势和各类别趋势分别交给 CalculateComprehensiveTrendCalculateCategoryTrends 计算并把结果合并到 trendReports 返回。

CalculateComprehensiveTrend 的职责是对同一周期下的所有预算做合并汇总。它先把传入的 budgetsPeriod分组,每个周期组内取出该周期下所有预算的 Id,然后从传入的 budgetRecords 字典里把这些预算 ID 对应的记录取出来并扁平化成 allRecords。若该周期没有任何记录则跳过。对有记录的情况,代码调用 GroupRecordsByPeriod 按周期类型(年/月/季度)把记录按时间段分组,组内计算该时间段的总已用金额Sum(r => r.UsedAmount),并用周期内所有预算对象的 AmountRemaining 求和得到该时间点的总预算与剩余。最后每个时间分组构造一个 BudgetProgressReportResponse 对象(IsComprehensive=trueCategoryName="综合预算"),把 Period、金额汇总与 ReportDate(这里是分组键)写入并加入返回列表。注意这里 totalBudgetAmounttotalRemaining 是把整个周期下所有预算的数值直接相加并在每个时间分组里重复使用------也就是说如果你有按月展示多个月份,totalBudgetAmount 在每个月份都会是同一个值(周期内预算总和),代码并没有按时间窗口重算预算分配到每个子时间段的"应分配额度",这是设计选择但也可能不是期望的行为。

CalculateCategoryTrends 的处理思路与综合趋势类似,但它在分组时更细:先按 PeriodTransactionCategoryIdTransactionCategoryName 三个字段把 budgets 分组,每个分组代表某个周期下的某个交易类别。对每个类别分组,取出该组全部预算的 Id,从 budgetRecords 中取出这些 Id 对应的记录并扁平化成 categoryRecords,若无记录则跳过。有记录则同样调用 GroupRecordsByPeriod 把记录按时间窗口分组,在每个时间窗口里汇总 UsedAmount 并把该类别下的 AmountRemaining 求和,构造 BudgetProgressReportResponseIsComprehensive=falseCategoryName 填类别名),写入 ReportDate 为时间分组键。这样每个类别在每个时间段都会有一条记录,体现该类别在该时间段的预算总量、已用与剩余情况。

GroupRecordsByPeriod 是把单条预算记录按 PeriodEnum 转换成分组键的地方。年周期使用 r.RecordDate.ToString("yyyy-MM") 把记录按年-月分组(年预算按月展示),月周期使用 ToString("yyyy-MM-dd") 按天分组(按日展示),季度周期则调用 GetWeekOfYear,把 RecordDate 转成类似 2025-W07 这样的"年-周"字符串以实现按周展示。默认分支返回按天分组。这里值得注意的是 RecordDate 的类型与时区、DateTime.Kind 的处理很重要:ToString 会受本地文化与时区影响,若数据跨时区或记录是 UTC,可能导致分组落在预期外的日期。同样 GetWeekOfYear 使用 CultureInfo.CurrentCulture.CalendarCalendarWeekRule.FirstDayDayOfWeek.Monday 来计算周数,当前文化与 CalendarWeekRule 的选择会影响周编号(例如 ISO 周通常使用 FirstFourDayWeek),因此若需要与外部标准(如 ISO 周)一致,应明确使用 CultureInfo.InvariantCulture 或显式指定 CalendarWeekRule

三、总结

本文实现了预算报表的两大核心功能:预算使用进度(综合与按类别)与预算消耗趋势(按年/季/月的不同粒度展示)。实现要点包括使用 Refit 调用跨服务的预算与预算记录接口、在 Report 服务的 Service 层完成数据汇总与计算、以及按周期(年按月、季按周、月按日)对记录分组生成时间序列报表。文中同时指出了若干设计与实现注意事项:综合报表中对周期内预算总额在每个子时间点重复使用(未做按子区间分配)、时间分组受时区与文化设置影响,以及周次计算规则可能与 ISO 周不同。建议后续改进包括:按子时间窗口合理拆分周期预算以反映"应分配额度"、明确使用 UTC/指定 Culture 与 ISO 周规则以保证分组一致性、以及为跨服务调用和大数据量统计增加缓存、分页与索引优化以提升鲁棒性与性能。

相关推荐
z2014z3 小时前
LitJSON 轻量级、高效易用的 .NET JSON 库 深度解析与实战指南
json·.net
杨充3 小时前
OkHttp网络框架设计
架构
喝拿铁写前端3 小时前
从面条代码到抽象能力:一个小表单场景里的前端成长四阶段
前端·设计模式·架构
MobotStone3 小时前
边际成本趋近于零:如何让AI智能体"说得清、讲得明"
人工智能·架构
summer_west_fish3 小时前
Distributed Architecture: 分布式服务架构演进
架构
q***38514 小时前
电池管理系统(BMS)架构详细解析:原理与器件选型指南
架构
生财6 小时前
.NET 10发布和它的新增功能
.net
q***56386 小时前
深入浅出 SQLSugar:快速掌握高效 .NET ORM 框架
.net