66.【.NET8 实战--孢子记账--从单体到微服务--转向微服务】--新增功能--自动记账

在日常生活中,有部分消费是固定金额、周期性的,比如每月的手机月租、水电费、房租等。为了更好地管理这些固定支出,那么在这篇文章中,我们就来实现这个自动记账的功能。

一、需求分析

用户在使用记账应用时,可以选择将某些支出设置为"自动记账",并指定记账的周期(如每日、每周、每月等)、记账分类、金额等。系统会根据用户设置的周期,自动生成相应的记账记录,帮助用户更好地管理和跟踪这些固定支出。

我们先来画一下流程图:
用户设置自动记账
选择周期
每日
每周
每月
系统生成每日记账记录
系统生成每周记账记录
系统生成每月记账记录
保存记录到数据库

这个流程图比较简单,但是也基本上涵盖了自动记账的核心逻辑。接下来,我们再细化一下每个步骤的具体实现细节。

1.1 用户设置

首先,用户在自动记账模块中,添加自动记账规则,填写相关信息,包括:

  • 规则名称
  • 计入的账本
  • 记账分类
  • 金额
  • 记录的币种
  • 周期(每日、每周、每月等)
  • 开始日期
  • 结束日期

以上信息都确认后,用户点击"保存"按钮,系统将这些信息存储到数据库中,形成一条自动记账规则记录,这条记录将作为后续自动生成记账记录的依据。

1.2 系统定时执行

系统需要有一个定时任务调度器,定期检查所有的自动记账规则。对于每一条规则,系统会根据当前日期和规则的执行周期,判断是否需要生成新的记账记录。如果需要生成,系统会根据规则中的信息,创建一条新的记账记录,并将其保存到数据库中。

首先,我们需要设计一个定时任务,定时任务会每天运行一次,检查所有的自动记账规则。如果当前日期符合某条规则的执行条件(例如,今天是每月的1号,而规则设置为每月记账),那么系统就会生成一条新的记账记录,并将执行记录保存到数据库中。

我们的执行规则如下:

  • 如果是每天记录,并且上次执行时间小于今天,则执行记账
  • 如果是周记录,并且上次执行时间小于本周一,则执行记账
  • 如果是月记录,并且上次执行时间小于本月一号,则执行记账
  • 如果是季度记录,并且上次执行时间小于本季度第一天,则执行记账
  • 如果是年记录,并且上次执行时间小于今年一号,则执行记账

二、功能设计

具体功能需求已经分析完毕,接下来我们来设计具体的功能模块。

2.1 表结构设计

我们需要设计两张表:开销规则表和定期支出规则执行记录表。以下是这两张表的实体设计:

csharp 复制代码
using SP.Common.Model;
using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations;
namespace SP.FinanceService.Models.Entity;

/// <summary>
/// 定期支出规则执行记录
/// </summary>
[Table(name: "RecurringExpenseRuleExecutionRecord")]
public class RecurringExpenseRuleExecutionRecord : BaseModel
{
    /// <summary>
    /// 定期支出规则id
    /// </summary>
    [Required]
    [Column(TypeName = "bigint")]
    public long RecurringExpenseRuleExecutioId { get; set; }

    /// <summary>
    /// 是否成功
    /// </summary>
    [Required]
    [Column(TypeName = "tinyint")]
    public bool IsOK { get; set; } = true;
}

using SP.Common.Model;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace SP.FinanceService.Models.Entity;

/// <summary>
/// 规定开销规则
/// </summary>
public class RecurringExpenseRule: BaseModel
{
    /// <summary>
    /// 账本id
    /// </summary>
    [Required]
    [Column(TypeName = "bigint")]
    public long AccountBookId { get; set; }
    /// <summary>
    /// 标题
    /// </summary>
    [Required]
    [Column(TypeName = "nvarchar(20)")]
    public string Title { get; set; }
    /// <summary>
    /// 金额
    /// </summary>
    [Required]
    [Column(TypeName = "decimal(18,2)")]
    public decimal Amount { get; set; }
    /// <summary>
    /// 分类id
    /// </summary>
    [Required]
    [Column(TypeName = "bigint")]
    public long CategoryId { get; set; }
    /// <summary>
    /// 开始日期
    /// </summary>
    [Required]
    [Column(TypeName = "datetime")]
    public DateTime StartDate { get; set; }
    /// <summary>
    /// 结束日期
    /// </summary>
    [Required]
    [Column(TypeName = "datetime")]
    public DateTime EndDate { get; set; }
    /// <summary>
    /// 频率(单位:天)
    /// </summary>
    [Required]
    [Column(TypeName = "int")]
    public int FrequencyInDays { get; set; }
    /// <summary>
    /// 币种id
    /// </summary>
    [Required]
    [Column(TypeName = "bigint")]
    public long CurrencyId { get; set; }
}

前面的代码展示了我们如何设计这两张表的实体类,具体就不过多讲解了。接下来,我们需要实现自动记账的核心逻辑。

2.2 接口设计

这里涉及到的接口不是很多,一共五个接口:新增开销规则编辑开销规则删除开销规则获取规定开销规则分页查询。以下是这些接口的定义:

csharp 复制代码
using Microsoft.AspNetCore.Mvc;
using SP.Common.Model;
using SP.FinanceService.Models.Request;
using SP.FinanceService.Models.Response;
using SP.FinanceService.Service;

namespace SP.FinanceService.Controllers;

/// <summary>
/// 规定开销规则接口
/// </summary>
[Route("api/recurring-expense-rule")]
[ApiController]
public class RecurringExpenseRuleController : ControllerBase
{

    /// <summary>
    /// 规定开销规则服务
    /// </summary>
    private readonly IRecurringExpenseRuleServer _recurringExpenseRuleServer;

    /// <summary>
    /// 构造函数
    /// </summary>
    /// <param name="recurringExpenseRuleServer"></param>
    public RecurringExpenseRuleController(IRecurringExpenseRuleServer recurringExpenseRuleServer)
    {
        _recurringExpenseRuleServer = recurringExpenseRuleServer;
    }

    /// <summary>
    /// 新增开销规则
    /// </summary>
    /// <param name="recurringExpenseRuleAdd">新增请求</param>
    /// <returns>是否成功</returns>
    [HttpPost]
    public  async Task<ActionResult<long>> AddRecurringExpenseRule([FromBody] RecurringExpenseRuleAddRequest recurringExpenseRuleAdd)
    {
        long id = await _recurringExpenseRuleServer.AddRecurringExpenseRule(recurringExpenseRuleAdd);
        return Ok(id);
    }

    /// <summary>
    /// 编辑开销规则
    /// </summary>
    /// <param name="recurringExpenseRuleEdit">修改请求</param>
    /// <returns></returns>
    [HttpPut]
    public async  Task<ActionResult<long>> EditRecurringExpenseRule([FromBody] RecurringExpenseRuleEditRequest recurringExpenseRuleEdit)
    {
        long id = await _recurringExpenseRuleServer.EditRecurringExpenseRule(recurringExpenseRuleEdit);
        return Ok(id);
    }

    /// <summary>
    /// 删除
    /// </summary>
    /// <param name="ids">删除的数据id</param>
    [HttpPost]
    public async Task<ActionResult<bool>> DeleteRecurringExpenseRule([FromBody] List<long> ids)
    {
        await _recurringExpenseRuleServer.DeleteRecurringExpenseRule(ids);
        return Ok(true);
    }

    /// <summary>
    /// 获取规定开销规则
    /// </summary>
    /// <param name="id">规定开销规则id</param>
    /// <returns>规定开销规则</returns>
    [HttpGet]
    public ActionResult<RecurringExpenseRuleResponse> GetRecurringExpenseRule([FromRoute] long id)
    {
        RecurringExpenseRuleResponse response = _recurringExpenseRuleServer.GetRecurringExpenseRule(id);
        return Ok(response);
    }

    /// <summary>
    /// 分页查询
    /// </summary>
    /// <param name="page">分页信息</param>
    /// <returns>规定开销规则列表</returns>
    [HttpPost("/page")]
    public ActionResult<PageResponse<RecurringExpenseRuleResponse>> GetRecurringExpenseRulePage([FromBody] RecurringExpenseRulePgRequest page)
    {
        PageResponse<RecurringExpenseRuleResponse> response = _recurringExpenseRuleServer.GetRecurringExpenseRulePage(page);
        return Ok(response);
    }
}

上述代码展示了我们如何设计这些接口,接下来我们来实现自动记账的核心逻辑,经过了这么多章节的文章,我们已经掌握了很多基础知识,因此这里的实现就不再赘述细节代码了,我们直接来看一下核心逻辑的实现思路。

Tip:Response 和 Request 相关的代码这里就不展示了,主要是封装请求和响应参数的,具体代码查看项目即可。

2.3 业务实现

业务实现主要分为两部分:规则管理执行记录。规则管理负责处理自动记账规则的增删改查操作,而执行记录则用于追踪每条规则的执行历史。这两部分的实现相对简单,主要是对数据库的基本操作。在这一小节中,我们重点关注自动记账的核心逻辑实现,也就是定时任务如何检查规则并自动生成记账记录。

我们需要实现一个定时任务,这个定时任务每天运行一次,系统会遍历所有有效的自动记账规则,根据每条规则设定的频率(每日、每周、每月、每季度、每年)以及上次执行时间,判断是否需要在当前时间点生成新的记账记录。如果判断结果为需要执行,系统就会自动创建一条新的记账记录,并将本次执行的情况保存到执行记录表中,以便下次执行时进行比对。

首先来看定时任务类的整体结构。我们创建了一个 AccountingWatcher 类,它实现了 Quartz 框架的 IJob 接口,这使得它可以被调度器定期调用执行。在构造函数中,我们注入了三个核心服务:IRecurringExpenseRuleServer 用于管理自动记账规则,IRecurringExpenseRuleRecordServer 用于管理执行记录,IAccountingServer 用于创建实际的记账记录。这种依赖注入的方式使得代码的可测试性和可维护性都得到了保障。

csharp 复制代码
public class AccountingWatcher : IJob
{
    private readonly IRecurringExpenseRuleServer _recurringExpenseRuleServer;
    private readonly IRecurringExpenseRuleRecordServer _recurringExpenseRuleRecordServer;
    private readonly IAccountingServer _accountingServer;

    public AccountingWatcher(IRecurringExpenseRuleServer recurringExpenseRuleServer,
        IRecurringExpenseRuleRecordServer recurringExpenseRuleRecordServer, 
        IAccountingServer accountingServer)
    {
        _recurringExpenseRuleServer = recurringExpenseRuleServer;
        _recurringExpenseRuleRecordServer = recurringExpenseRuleRecordServer;
        _accountingServer = accountingServer;
    }
}

接下来看 Execute 方法的实现,这是整个定时任务的入口点。方法开始时,我们通过 _recurringExpenseRuleServer.GetAllRecurringExpenseRules() 获取系统中所有的自动记账规则。这个方法会返回一个规则列表,每个规则对象包含了规则的所有配置信息,包括频率、金额、分类、开始结束日期等。

csharp 复制代码
public async System.Threading.Tasks.Task Execute(IJobExecutionContext context)
{
    List<RecurringExpenseRuleResponse> recurringExpenseRules =
        await _recurringExpenseRuleServer.GetAllRecurringExpenseRules();
    
    List<long> deleteIds = new List<long>();
    // 后续处理逻辑...
}

获取到规则列表后,我们需要遍历每一条规则进行处理。在处理每条规则之前,首先要检查规则是否已经过期。我们通过比较规则的结束日期和当前时间来判断,如果 recurringExpense.EndDate < DateTime.Now,说明这条规则已经超过了有效期,应当被清理掉。我们将这些过期规则的ID收集到 deleteIds 列表中,以便后续统一删除。对于已过期的规则,我们使用 continue 语句跳过后续的执行逻辑,直接处理下一条规则。

csharp 复制代码
foreach (var recurringExpense in recurringExpenseRules)
{
    if (recurringExpense.EndDate < DateTime.Now)
    {
        deleteIds.Add(recurringExpense.Id);
        continue;
    }
    // 后续判断和执行逻辑...
}
await _recurringExpenseRuleServer.DeleteRecurringExpenseRule(deleteIds);

对于没有过期的规则,我们需要根据规则的频率类型和上次执行时间来判断是否需要执行。为了做出这个判断,我们首先通过 _recurringExpenseRuleRecordServer.GetRecordById(recurringExpense.Id) 查询该规则上次执行的记录。如果返回的 recordnull,说明这条规则从未执行过,那么无论什么频率都应该立即执行。如果 record 不为 null,我们就需要根据不同的频率类型进行不同的时间比较。

对于每日频率的规则,判断逻辑相对简单。我们检查 record 是否为 null,或者上次执行时间的日期部分是否早于今天。这里使用了 .Date 属性来只比较日期部分,忽略时间部分。如果 record.CreateDateTime.Date < DateTime.Now.Date 为真,说明上次执行是在昨天或更早,那么今天就应该执行一次新的记账。

csharp 复制代码
RecurringExpenseRuleExecutionRecord record =
    _recurringExpenseRuleRecordServer.GetRecordById(recurringExpense.Id);

if (recurringExpense.Frequency == FrequencyEnum.Day)
{
    if (record == null || record.CreateDateTime.Date < DateTime.Now.Date)
    {
        await Account(recurringExpense);
        RecurringExpenseRuleExecutionRecord newRecord = new RecurringExpenseRuleExecutionRecord();
        newRecord.IsOK = true;
        newRecord.RecurringExpenseRuleExecutioId = recurringExpense.Id;
        newRecord.CreateDateTime = DateTime.Now;
        newRecord.CreateUserId = recurringExpense.CreateUserId;
        newRecord.Id = Snow.GetId();
        _recurringExpenseRuleRecordServer.Add(newRecord);
    }
    continue;
}

当条件满足时,我们调用 Account 方法来创建实际的记账记录。然后创建一个新的执行记录对象 RecurringExpenseRuleExecutionRecord,设置其各个属性:IsOK 标记为 true 表示执行成功,RecurringExpenseRuleExecutioId 关联到对应的规则ID,CreateDateTime 记录执行时间,CreateUserId 记录执行用户,Id 通过雪花算法生成唯一标识。最后通过 _recurringExpenseRuleRecordServer.Add(newRecord) 将执行记录保存到数据库。

对于每周频率的规则,判断逻辑稍微复杂一些。我们需要判断上次执行时间是否早于本周的周一。这里使用了一个辅助方法 GetStartOfWeek(DateTime.Now) 来计算本周周一的日期。如果 recordnull 或者 record.CreateDateTime < GetStartOfWeek(DateTime.Now),说明上次执行是在上周或更早,那么本周应该执行一次新的记账。执行和记录保存的逻辑与每日频率完全相同。

csharp 复制代码
if (recurringExpense.Frequency == FrequencyEnum.Week)
{
    if (record == null || record.CreateDateTime < GetStartOfWeek(DateTime.Now))
    {
        await Account(recurringExpense);
        RecurringExpenseRuleExecutionRecord newRecord = new RecurringExpenseRuleExecutionRecord();
        newRecord.IsOK = true;
        newRecord.RecurringExpenseRuleExecutioId = recurringExpense.Id;
        newRecord.CreateDateTime = DateTime.Now;
        newRecord.CreateUserId = recurringExpense.CreateUserId;
        newRecord.Id = Snow.GetId();
        _recurringExpenseRuleRecordServer.Add(newRecord);
    }
    continue;
}

GetStartOfWeek 方法的实现值得详细说明。这个方法接收一个日期参数,返回该日期所在周的周一日期。实现原理是先计算当前日期是周几,然后计算它与周一的差值。date.DayOfWeek 返回一个枚举值,表示星期几,其中周日是0,周一是1。表达式 (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7 计算出当前日期距离周一有多少天。之所以要加7再取模,是为了处理周日的情况,因为周日减周一会得到负数。最后通过 date.AddDays(-1 * diff).Date 将日期往前推算到周一,并使用 .Date 只保留日期部分。

csharp 复制代码
private DateTime GetStartOfWeek(DateTime date)
{
    int diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7;
    return date.AddDays(-1 * diff).Date;
}

对于每月频率的规则,我们需要判断上次执行时间是否早于本月的第一天。这里使用 new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1) 来构造本月一号的日期对象。如果 recordnull 或者 record.CreateDateTime.Date 小于本月一号,说明上次执行是在上个月或更早,那么本月应该执行一次新的记账。

csharp 复制代码
if (recurringExpense.Frequency == FrequencyEnum.Month)
{
    if (record == null ||
        record.CreateDateTime.Date < new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1))
    {
        await Account(recurringExpense);
        RecurringExpenseRuleExecutionRecord newRecord = new RecurringExpenseRuleExecutionRecord();
        newRecord.IsOK = true;
        newRecord.RecurringExpenseRuleExecutioId = recurringExpense.Id;
        newRecord.CreateDateTime = DateTime.Now;
        newRecord.CreateUserId = recurringExpense.CreateUserId;
        newRecord.Id = Snow.GetId();
        _recurringExpenseRuleRecordServer.Add(newRecord);
    }
    continue;
}

对于每季度频率的规则,判断逻辑更为复杂。我们需要计算当前日期所在季度的第一天。一年有四个季度,分别是1-3月、4-6月、7-9月、10-12月。表达式 (DateTime.Now.Month - 1) / 3 * 3 + 1 巧妙地计算出了当前季度第一个月的月份。首先 DateTime.Now.Month - 1 将月份转换为0-11的范围,然后除以3取整数部分得到季度索引(0、1、2、3),再乘以3得到该季度第一个月相对于1月的偏移量(0、3、6、9),最后加1得到实际月份(1、4、7、10)。结合当前年份,就构造出了本季度第一天的日期。

csharp 复制代码
if (recurringExpense.Frequency == FrequencyEnum.Quarter)
{
    if(record==null || record.CreateDateTime.Date < new DateTime(DateTime.Now.Year, (DateTime.Now.Month - 1) / 3 * 3 + 1, 1))
    {
        await Account(recurringExpense);
        RecurringExpenseRuleExecutionRecord newRecord = new RecurringExpenseRuleExecutionRecord();
        newRecord.IsOK = true;
        newRecord.RecurringExpenseRuleExecutioId = recurringExpense.Id;
        newRecord.CreateDateTime = DateTime.Now;
        newRecord.CreateUserId = recurringExpense.CreateUserId;
        newRecord.Id = Snow.GetId();
        _recurringExpenseRuleRecordServer.Add(newRecord);
    }
    continue;
}

对于每年频率的规则,判断最为简单直接。我们只需要判断上次执行时间是否早于今年的1月1日。使用 new DateTime(DateTime.Now.Year, 1, 1) 构造出今年元旦的日期,如果 recordnull 或者 record.CreateDateTime.Date 小于这个日期,说明上次执行是在去年或更早,那么今年应该执行一次新的记账。

csharp 复制代码
if (recurringExpense.Frequency == FrequencyEnum.Year)
{
    if (record == null || record.CreateDateTime.Date < new DateTime(DateTime.Now.Year, 1, 1))
    {
        await Account(recurringExpense);
        RecurringExpenseRuleExecutionRecord newRecord = new RecurringExpenseRuleExecutionRecord();
        newRecord.IsOK = true;
        newRecord.RecurringExpenseRuleExecutioId = recurringExpense.Id;
        newRecord.CreateDateTime = DateTime.Now;
        newRecord.CreateUserId = recurringExpense.CreateUserId;
        newRecord.Id = Snow.GetId();
        _recurringExpenseRuleRecordServer.Add(newRecord);
    }
    continue;
}

最后来看 Account 方法的实现。这是实际创建记账记录的方法,它接收一个 RecurringExpenseRuleResponse 对象作为参数。方法内部首先获取规则关联的账本ID,然后创建一个 AccountingAddRequest 对象,将规则中的各项信息填充到这个请求对象中,包括账本ID、金额、币种ID、交易分类ID等。特别注意 RecordDate 被设置为 DateTime.Now,表示记账时间为当前时间。最后调用 _accountingServer.Add 方法来真正创建记账记录并保存到数据库。

csharp 复制代码
private async System.Threading.Tasks.Task Account(RecurringExpenseRuleResponse recurringExpense)
{
    long accountBookId = recurringExpense.AccountBookId;
    AccountingAddRequest accountingAdd = new AccountingAddRequest();
    accountingAdd.AccountBookId = accountBookId;
    accountingAdd.Amount = recurringExpense.Amount;
    accountingAdd.CurrencyId = recurringExpense.CurrencyId;
    accountingAdd.RecordDate = DateTime.Now;
    accountingAdd.TransactionCategoryId = recurringExpense.CategoryId;
    await _accountingServer.Add(accountBookId, accountingAdd);
}

通过这样的设计和实现,我们的自动记账系统能够准确地根据用户设定的规则,在正确的时间点自动生成记账记录。系统每天运行一次定时任务,遍历所有有效规则,根据不同的频率类型进行时间比对,确保每条规则都能按照预期的周期执行。同时,执行记录的保存机制保证了系统能够准确追踪每条规则的执行历史,避免重复执行或遗漏执行的情况发生。整个实现逻辑清晰,代码结构合理,为用户提供了可靠的自动记账功能。

三、总结

通过本文的介绍,我们实现了一个自动记账的功能,帮助用户更好地管理和跟踪他们的固定支出。我们设计了相应的数据库表结构,定义了必要的接口,并实现了核心的业务逻辑。通过定时任务的方式,系统能够定期检查自动记账规则,并根据规则生成相应的记账记录。希望这篇文章能对你有所帮助,祝你编码愉快!

相关推荐
这儿有个昵称2 小时前
Java面试场景:从音视频到微服务的技术深挖
java·spring boot·spring cloud·微服务·面试·kafka·音视频
星辰_mya2 小时前
零拷贝之浅入深出
中间件·架构
鱼跃鹰飞3 小时前
面试题:说说MMAP和DMA/SG-DMA的关系
面试·职场和发展·架构·系统架构
国科安芯3 小时前
无人驾驶物流车网关的多路CANFD冗余架构与通信可靠性分析
单片机·嵌入式硬件·性能优化·架构·自动驾驶·安全性测试
程序员侠客行3 小时前
Mybatis插件原理及分页插件
java·后端·架构·mybatis
REDcker3 小时前
C86 架构详解
数据库·微服务·架构
亲爱的非洲野猪4 小时前
Apache Cassandra完全指南:架构、原理与生产实践深度解析
架构·apache·database·cassandra
酷酷的鱼14 小时前
跨平台技术选型方案(2026年App实战版)
react native·架构·鸿蒙系统
The Open Group16 小时前
架构驱动未来:2026年数字化转型中的TOGAF®角色
架构