From 9719c6043a86f93618ca727dcb76f0a29814cc83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E8=AF=9A?= Date: Mon, 29 Dec 2025 15:20:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=AE=9A=E6=97=B6=E8=B4=A6?= =?UTF-8?q?=E5=8D=95=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Directory.Packages.props | 4 + Entity/TransactionPeriodic.cs | 87 ++ Repository/TransactionPeriodicRepository.cs | 97 ++ Service/Jobs/EmailSyncJob.cs | 185 ++++ Service/Jobs/PeriodicBillJob.cs | 34 + Service/PeriodicBillBackgroundService.cs | 61 ++ Service/Service.csproj | 3 + Service/TransactionPeriodicService.cs | 312 +++++++ Web/src/api/transactionPeriodic.js | 100 +++ Web/src/components/ClassifyPicker.vue | 169 ++++ Web/src/router/index.js | 6 + Web/src/views/CalendarView.vue | 22 +- Web/src/views/PeriodicRecord.vue | 848 ++++++++++++++++++ Web/src/views/SettingView.vue | 11 +- WebApi/Controllers/JobController.cs | 175 ++++ .../TransactionPeriodicController.cs | 249 +++++ WebApi/Expand.cs | 40 + WebApi/Program.cs | 28 +- WebApi/appsettings.json | 5 + 19 files changed, 2409 insertions(+), 27 deletions(-) create mode 100644 Entity/TransactionPeriodic.cs create mode 100644 Repository/TransactionPeriodicRepository.cs create mode 100644 Service/Jobs/EmailSyncJob.cs create mode 100644 Service/Jobs/PeriodicBillJob.cs create mode 100644 Service/PeriodicBillBackgroundService.cs create mode 100644 Service/TransactionPeriodicService.cs create mode 100644 Web/src/api/transactionPeriodic.js create mode 100644 Web/src/components/ClassifyPicker.vue create mode 100644 Web/src/views/PeriodicRecord.vue create mode 100644 WebApi/Controllers/JobController.cs create mode 100644 WebApi/Controllers/TransactionPeriodicController.cs create mode 100644 WebApi/Expand.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 8b147de..899235d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,7 @@ + @@ -25,5 +26,8 @@ + + + \ No newline at end of file diff --git a/Entity/TransactionPeriodic.cs b/Entity/TransactionPeriodic.cs new file mode 100644 index 0000000..cb45c26 --- /dev/null +++ b/Entity/TransactionPeriodic.cs @@ -0,0 +1,87 @@ +namespace Entity; + +/// +/// 周期性账单 +/// +public class TransactionPeriodic : BaseEntity +{ + /// + /// 周期类型:0-每天、1-每周、2-每月、3-每季度、4-每年 + /// + public PeriodicType PeriodicType { get; set; } + + /// + /// 周期配置(JSON格式存储不同周期类型的配置) + /// 每周:存储星期几,如 "1,3,5" 表示周一、三、五 + /// 每月:存储具体日期,如 "1,15" 表示每月1号和15号 + /// 每季度:存储季度开始后第几天,如 "15" 表示每季度第15天 + /// 每年:存储年开始后第几天,如 "100" 表示每年第100天 + /// + public string PeriodicConfig { get; set; } = string.Empty; + + /// + /// 交易金额 + /// + public decimal Amount { get; set; } + + /// + /// 交易类型 + /// + public TransactionType Type { get; set; } + + /// + /// 交易分类 + /// + public string Classify { get; set; } = string.Empty; + + /// + /// 交易摘要/备注 + /// + public string Reason { get; set; } = string.Empty; + + /// + /// 是否启用 + /// + public bool IsEnabled { get; set; } = true; + + /// + /// 下次执行时间 + /// + public DateTime? NextExecuteTime { get; set; } + + /// + /// 最后执行时间 + /// + public DateTime? LastExecuteTime { get; set; } +} + +/// +/// 周期类型枚举 +/// +public enum PeriodicType +{ + /// + /// 每天 + /// + Daily = 0, + + /// + /// 每周 + /// + Weekly = 1, + + /// + /// 每月 + /// + Monthly = 2, + + /// + /// 每季度 + /// + Quarterly = 3, + + /// + /// 每年 + /// + Yearly = 4 +} diff --git a/Repository/TransactionPeriodicRepository.cs b/Repository/TransactionPeriodicRepository.cs new file mode 100644 index 0000000..c0fde4c --- /dev/null +++ b/Repository/TransactionPeriodicRepository.cs @@ -0,0 +1,97 @@ +namespace Repository; + +/// +/// 周期性账单仓储接口 +/// +public interface ITransactionPeriodicRepository : IBaseRepository +{ + /// + /// 获取分页列表 + /// + Task> GetPagedListAsync(int pageIndex, int pageSize, string? searchKeyword = null); + + /// + /// 获取总数 + /// + Task GetTotalCountAsync(string? searchKeyword = null); + + /// + /// 获取需要执行的周期性账单(包含今天应该执行的) + /// + Task> GetPendingPeriodicBillsAsync(); + + /// + /// 更新执行时间 + /// + Task UpdateExecuteTimeAsync(long id, DateTime lastExecuteTime, DateTime? nextExecuteTime); +} + +/// +/// 周期性账单仓储实现 +/// +public class TransactionPeriodicRepository(IFreeSql freeSql) + : BaseRepository(freeSql), ITransactionPeriodicRepository +{ + public async Task> GetPagedListAsync( + int pageIndex, + int pageSize, + string? searchKeyword = null) + { + var query = FreeSql.Select(); + + // 搜索关键词 + if (!string.IsNullOrWhiteSpace(searchKeyword)) + { + query = query.Where(x => + x.Reason.Contains(searchKeyword) || + x.Classify.Contains(searchKeyword)); + } + + return await query + .OrderByDescending(x => x.CreateTime) + .Skip((pageIndex - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + } + + public async Task GetTotalCountAsync(string? searchKeyword = null) + { + var query = FreeSql.Select(); + + if (!string.IsNullOrWhiteSpace(searchKeyword)) + { + query = query.Where(x => + x.Reason.Contains(searchKeyword) || + x.Classify.Contains(searchKeyword)); + } + + return await query.CountAsync(); + } + + public async Task> GetPendingPeriodicBillsAsync() + { + var now = DateTime.Now; + return await FreeSql.Select() + .Where(x => x.IsEnabled) + .Where(x => x.NextExecuteTime == null || x.NextExecuteTime <= now) + .ToListAsync(); + } + + public async Task UpdateExecuteTimeAsync(long id, DateTime lastExecuteTime, DateTime? nextExecuteTime) + { + try + { + var result = await FreeSql.Update() + .Set(x => x.LastExecuteTime, lastExecuteTime) + .Set(x => x.NextExecuteTime, nextExecuteTime) + .Set(x => x.UpdateTime, DateTime.Now) + .Where(x => x.Id == id) + .ExecuteAffrowsAsync(); + return result == 1; + } + catch + { + return false; + } + } +} diff --git a/Service/Jobs/EmailSyncJob.cs b/Service/Jobs/EmailSyncJob.cs new file mode 100644 index 0000000..a99b655 --- /dev/null +++ b/Service/Jobs/EmailSyncJob.cs @@ -0,0 +1,185 @@ +using MimeKit; +using Quartz; + +namespace Service.Jobs; + +/// +/// 邮件同步定时任务 +/// +public class EmailSyncJob( + IOptions emailSettings, + IServiceProvider serviceProvider, + IEmailHandleService emailHandleService, + ILogger logger) : IJob +{ + private readonly Dictionary _emailFetchServices = new(); + private bool _isInitialized; + + public async Task Execute(IJobExecutionContext context) + { + try + { + logger.LogInformation("开始执行邮件同步任务"); + + // 如果未初始化,先初始化连接 + if (!_isInitialized) + { + await InitializeConnectionsAsync(); + } + + // 执行邮件同步 + await FetchAndPostCmbTransactionsAsync(); + + logger.LogInformation("邮件同步任务执行完成"); + } + catch (Exception ex) + { + logger.LogError(ex, "邮件同步任务执行出错"); + throw; // 让 Quartz 知道任务失败 + } + } + + /// + /// 初始化所有邮箱连接 + /// + private async Task InitializeConnectionsAsync() + { + if (_isInitialized) + { + logger.LogWarning("连接已初始化,跳过重复初始化"); + return; + } + + try + { + if (emailSettings.Value.SmtpList.Length == 0) + { + logger.LogWarning("未配置邮箱账户,无法初始化连接"); + return; + } + + logger.LogInformation("开始初始化 {EmailCount} 个邮箱连接...", emailSettings.Value.SmtpList.Length); + + // 并行初始化所有邮箱连接 + var tasks = emailSettings.Value.SmtpList.Select(async emailConfig => + { + try + { + var emailFetchService = ActivatorUtilities.CreateInstance(serviceProvider); + var success = await emailFetchService.ConnectAsync( + emailConfig.ImapHost, + emailConfig.ImapPort, + emailConfig.UseSsl, + emailConfig.Email, + emailConfig.Password); + + if (success) + { + _emailFetchServices[emailConfig.Email] = emailFetchService; + logger.LogInformation("邮箱 {Email} 连接建立成功", emailConfig.Email); + } + else + { + logger.LogError("邮箱 {Email} 连接建立失败", emailConfig.Email); + } + } + catch (Exception ex) + { + logger.LogError(ex, "初始化邮箱 {Email} 连接时出错", emailConfig.Email); + } + }); + + await Task.WhenAll(tasks); + _isInitialized = true; + logger.LogInformation("所有邮箱连接初始化完成,成功连接 {Count} 个邮箱", _emailFetchServices.Count); + } + catch (Exception ex) + { + logger.LogError(ex, "初始化邮箱连接失败"); + } + } + + /// + /// 抓取并处理招商银行邮件交易 + /// + private async Task FetchAndPostCmbTransactionsAsync() + { + try + { + if (_emailFetchServices.Count == 0) + { + logger.LogWarning("没有可用的邮箱连接,跳过抓取"); + return; + } + + logger.LogInformation("开始抓取 {EmailCount} 个邮箱的邮件", _emailFetchServices.Count); + + // 并行处理多个邮箱 + var tasks = _emailFetchServices.Select(async kvp => + { + var email = kvp.Key; + var emailFetchService = kvp.Value; + + try + { + // 获取未读邮件 + var unreadMessages = await emailFetchService.FetchUnreadMessagesAsync(); + logger.LogInformation("邮箱 {Email} 获取到 {MessageCount} 封未读邮件", email, unreadMessages.Count); + + foreach (var (message, uid) in unreadMessages) + { + try + { + logger.LogDebug("邮件信息 - 发送者: {From}, 主题: {Subject}, 接收时间: {Date}", + message.From, message.Subject, message.Date); + logger.LogDebug("邮件内容预览: {Preview}", GetEmailBodyPreview(message)); + + if (await emailHandleService.HandleEmailAsync( + email, + message.From.ToString(), + message.Subject, + message.Date.DateTime, + message.TextBody ?? message.HtmlBody ?? string.Empty + ) || (DateTime.Now - message.Date.DateTime > TimeSpan.FromDays(3))) + { + #if DEBUG + logger.LogDebug("DEBUG 模式下,跳过标记已读步骤"); + #else + // 标记邮件为已读 + await emailFetchService.MarkAsReadAsync(uid); + #endif + } + } + catch (Exception ex) + { + logger.LogError(ex, "处理邮件时出错"); + } + } + + logger.LogInformation("邮箱 {Email} 邮件抓取完成", email); + } + catch (Exception ex) + { + logger.LogError(ex, "邮箱 {Email} 邮件抓取失败", email); + } + }); + + await Task.WhenAll(tasks); + logger.LogInformation("所有邮箱邮件抓取完成"); + } + catch (Exception ex) + { + logger.LogError(ex, "抓取邮件异常"); + } + } + + /// + /// 获取邮件内容预览 + /// + private static string GetEmailBodyPreview(MimeMessage message) + { + var body = message.HtmlBody ?? message.TextBody ?? string.Empty; + var preview = body.Length > 100 ? body.Substring(0, 100) + "..." : body; + return preview.Replace("\n", " ").Replace("\r", ""); + } +} diff --git a/Service/Jobs/PeriodicBillJob.cs b/Service/Jobs/PeriodicBillJob.cs new file mode 100644 index 0000000..f42d15f --- /dev/null +++ b/Service/Jobs/PeriodicBillJob.cs @@ -0,0 +1,34 @@ +using Quartz; + +namespace Service.Jobs; + +/// +/// 周期性账单定时任务 +/// +[DisallowConcurrentExecution] // 防止并发执行 +public class PeriodicBillJob( + IServiceProvider serviceProvider, + ILogger logger) : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + try + { + logger.LogInformation("开始执行周期性账单检查任务"); + + // 执行周期性账单检查 + using (var scope = serviceProvider.CreateScope()) + { + var periodicService = scope.ServiceProvider.GetRequiredService(); + await periodicService.ExecutePeriodicBillsAsync(); + } + + logger.LogInformation("周期性账单检查任务执行完成"); + } + catch (Exception ex) + { + logger.LogError(ex, "周期性账单检查任务执行出错"); + throw; // 让 Quartz 知道任务失败 + } + } +} diff --git a/Service/PeriodicBillBackgroundService.cs b/Service/PeriodicBillBackgroundService.cs new file mode 100644 index 0000000..45299e1 --- /dev/null +++ b/Service/PeriodicBillBackgroundService.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.Hosting; + +namespace Service; + +/// +/// 周期性账单后台服务 +/// +public class PeriodicBillBackgroundService( + IServiceProvider serviceProvider, + ILogger logger +) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("周期性账单后台服务已启动"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var now = DateTime.Now; + + // 计算下次执行时间(每天早上6点) + var nextRun = now.Date.AddHours(6); + if (now >= nextRun) + { + nextRun = nextRun.AddDays(1); + } + + var delay = nextRun - now; + logger.LogInformation("下次执行周期性账单检查时间: {NextRun}, 延迟: {Delay}", + nextRun.ToString("yyyy-MM-dd HH:mm:ss"), delay); + + await Task.Delay(delay, stoppingToken); + + if (stoppingToken.IsCancellationRequested) + break; + + // 执行周期性账单检查 + using (var scope = serviceProvider.CreateScope()) + { + var periodicService = scope.ServiceProvider.GetRequiredService(); + await periodicService.ExecutePeriodicBillsAsync(); + } + } + catch (OperationCanceledException) + { + logger.LogInformation("周期性账单后台服务已取消"); + break; + } + catch (Exception ex) + { + logger.LogError(ex, "周期性账单后台服务执行出错"); + // 出错后等待1小时再重试 + await Task.Delay(TimeSpan.FromHours(1), stoppingToken); + } + } + + logger.LogInformation("周期性账单后台服务已停止"); + } +} diff --git a/Service/Service.csproj b/Service/Service.csproj index b3531a7..bbfa04e 100644 --- a/Service/Service.csproj +++ b/Service/Service.csproj @@ -10,12 +10,15 @@ + + + diff --git a/Service/TransactionPeriodicService.cs b/Service/TransactionPeriodicService.cs new file mode 100644 index 0000000..de5b73e --- /dev/null +++ b/Service/TransactionPeriodicService.cs @@ -0,0 +1,312 @@ +namespace Service; + +/// +/// 周期性账单服务接口 +/// +public interface ITransactionPeriodicService +{ + /// + /// 执行周期性账单检查和生成 + /// + Task ExecutePeriodicBillsAsync(); + + /// + /// 计算下次执行时间 + /// + DateTime? CalculateNextExecuteTime(TransactionPeriodic periodic, DateTime baseTime); +} + +/// +/// 周期性账单服务实现 +/// +public class TransactionPeriodicService( + ITransactionPeriodicRepository periodicRepository, + ITransactionRecordRepository transactionRepository, + IMessageRecordRepository messageRepository, + ILogger logger +) : ITransactionPeriodicService +{ + public async Task ExecutePeriodicBillsAsync() + { + try + { + logger.LogInformation("开始执行周期性账单检查..."); + + var pendingBills = await periodicRepository.GetPendingPeriodicBillsAsync(); + var billsList = pendingBills.ToList(); + + logger.LogInformation("找到 {Count} 条需要执行的周期性账单", billsList.Count); + + foreach (var bill in billsList) + { + try + { + // 检查是否满足执行条件 + if (!ShouldExecuteToday(bill)) + { + logger.LogInformation("周期性账单 {Id} 今天不需要执行", bill.Id); + continue; + } + + // 创建交易记录 + var transaction = new TransactionRecord + { + Amount = bill.Amount, + Type = bill.Type, + Classify = bill.Classify, + Reason = bill.Reason, + OccurredAt = DateTime.Now, + Card = "周期性账单", + ImportFrom = "周期性账单自动生成" + }; + + var success = await transactionRepository.AddAsync(transaction); + + if (success) + { + logger.LogInformation("成功创建周期性账单交易记录: {Reason}, 金额: {Amount}", + bill.Reason, bill.Amount); + + // 创建未读消息 + var message = new MessageRecord + { + Title = "周期性账单提醒", + Content = $"已自动生成{(bill.Type == TransactionType.Expense ? "支出" : "收入")}账单:{bill.Reason},金额:{bill.Amount:F2}元", + IsRead = false + }; + await messageRepository.AddAsync(message); + + // 更新执行时间 + var now = DateTime.Now; + var nextTime = CalculateNextExecuteTime(bill, now); + await periodicRepository.UpdateExecuteTimeAsync(bill.Id, now, nextTime); + + logger.LogInformation("周期性账单 {Id} 下次执行时间: {NextTime}", + bill.Id, nextTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "无"); + } + else + { + logger.LogWarning("创建周期性账单交易记录失败: {BillId}", bill.Id); + } + } + catch (Exception ex) + { + logger.LogError(ex, "处理周期性账单 {BillId} 时出错", bill.Id); + } + } + + logger.LogInformation("周期性账单检查执行完成"); + } + catch (Exception ex) + { + logger.LogError(ex, "执行周期性账单检查时发生错误"); + } + } + + /// + /// 判断今天是否需要执行 + /// + private bool ShouldExecuteToday(TransactionPeriodic bill) + { + var today = DateTime.Today; + + // 如果从未执行过,需要执行 + if (bill.LastExecuteTime == null) + { + return true; + } + + // 如果今天已经执行过,不需要再执行 + if (bill.LastExecuteTime.Value.Date == today) + { + return false; + } + + return bill.PeriodicType switch + { + PeriodicType.Daily => true, // 每天都执行 + PeriodicType.Weekly => ShouldExecuteWeekly(bill.PeriodicConfig, today), + PeriodicType.Monthly => ShouldExecuteMonthly(bill.PeriodicConfig, today), + PeriodicType.Quarterly => ShouldExecuteQuarterly(bill.PeriodicConfig, today), + PeriodicType.Yearly => ShouldExecuteYearly(bill.PeriodicConfig, today), + _ => false + }; + } + + /// + /// 判断是否需要在本周执行 + /// + private bool ShouldExecuteWeekly(string config, DateTime today) + { + if (string.IsNullOrWhiteSpace(config)) + return false; + + var dayOfWeek = (int)today.DayOfWeek; // 0=Sunday, 1=Monday, ..., 6=Saturday + var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(d => int.TryParse(d.Trim(), out var day) ? day : -1) + .Where(d => d >= 0 && d <= 6) + .ToList(); + + return executeDays.Contains(dayOfWeek); + } + + /// + /// 判断是否需要在本月执行 + /// + private bool ShouldExecuteMonthly(string config, DateTime today) + { + if (string.IsNullOrWhiteSpace(config)) + return false; + + var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(d => int.TryParse(d.Trim(), out var day) ? day : -1) + .Where(d => d >= 1 && d <= 31) + .ToList(); + + return executeDays.Contains(today.Day); + } + + /// + /// 判断是否需要在本季度执行 + /// + private bool ShouldExecuteQuarterly(string config, DateTime today) + { + if (string.IsNullOrWhiteSpace(config) || !int.TryParse(config, out var dayOfQuarter)) + return false; + + // 计算当前是本季度的第几天 + var quarterStartMonth = ((today.Month - 1) / 3) * 3 + 1; + var quarterStart = new DateTime(today.Year, quarterStartMonth, 1); + var daysSinceQuarterStart = (today - quarterStart).Days + 1; + + return daysSinceQuarterStart == dayOfQuarter; + } + + /// + /// 判断是否需要在本年执行 + /// + private bool ShouldExecuteYearly(string config, DateTime today) + { + if (string.IsNullOrWhiteSpace(config) || !int.TryParse(config, out var dayOfYear)) + return false; + + return today.DayOfYear == dayOfYear; + } + + /// + /// 计算下次执行时间 + /// + public DateTime? CalculateNextExecuteTime(TransactionPeriodic periodic, DateTime baseTime) + { + return periodic.PeriodicType switch + { + PeriodicType.Daily => baseTime.Date.AddDays(1), + PeriodicType.Weekly => CalculateNextWeekly(periodic.PeriodicConfig, baseTime), + PeriodicType.Monthly => CalculateNextMonthly(periodic.PeriodicConfig, baseTime), + PeriodicType.Quarterly => CalculateNextQuarterly(periodic.PeriodicConfig, baseTime), + PeriodicType.Yearly => CalculateNextYearly(periodic.PeriodicConfig, baseTime), + _ => null + }; + } + + private DateTime? CalculateNextWeekly(string config, DateTime baseTime) + { + if (string.IsNullOrWhiteSpace(config)) + return null; + + var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(d => int.TryParse(d.Trim(), out var day) ? day : -1) + .Where(d => d >= 0 && d <= 6) + .OrderBy(d => d) + .ToList(); + + if (!executeDays.Any()) + return null; + + var currentDayOfWeek = (int)baseTime.DayOfWeek; + + // 找下一个执行日 + var nextDay = executeDays.FirstOrDefault(d => d > currentDayOfWeek); + if (nextDay > 0) + { + var daysToAdd = nextDay - currentDayOfWeek; + return baseTime.Date.AddDays(daysToAdd); + } + + // 下周的第一个执行日 + var firstDay = executeDays.First(); + var daysUntilNextWeek = 7 - currentDayOfWeek + firstDay; + return baseTime.Date.AddDays(daysUntilNextWeek); + } + + private DateTime? CalculateNextMonthly(string config, DateTime baseTime) + { + if (string.IsNullOrWhiteSpace(config)) + return null; + + var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(d => int.TryParse(d.Trim(), out var day) ? day : -1) + .Where(d => d >= 1 && d <= 31) + .OrderBy(d => d) + .ToList(); + + if (!executeDays.Any()) + return null; + + // 找本月的下一个执行日 + var nextDay = executeDays.FirstOrDefault(d => d > baseTime.Day); + if (nextDay > 0) + { + var daysInMonth = DateTime.DaysInMonth(baseTime.Year, baseTime.Month); + if (nextDay <= daysInMonth) + { + return new DateTime(baseTime.Year, baseTime.Month, nextDay); + } + } + + // 下个月的第一个执行日 + var nextMonth = baseTime.AddMonths(1); + var firstDay = executeDays.First(); + var daysInNextMonth = DateTime.DaysInMonth(nextMonth.Year, nextMonth.Month); + var actualDay = Math.Min(firstDay, daysInNextMonth); + return new DateTime(nextMonth.Year, nextMonth.Month, actualDay); + } + + private DateTime? CalculateNextQuarterly(string config, DateTime baseTime) + { + if (string.IsNullOrWhiteSpace(config) || !int.TryParse(config, out var dayOfQuarter)) + return null; + + // 计算下一个季度的开始时间 + var currentQuarterStartMonth = ((baseTime.Month - 1) / 3) * 3 + 1; + var nextQuarterStartMonth = currentQuarterStartMonth + 3; + var nextQuarterYear = baseTime.Year; + + if (nextQuarterStartMonth > 12) + { + nextQuarterStartMonth = 1; + nextQuarterYear++; + } + + var nextQuarterStart = new DateTime(nextQuarterYear, nextQuarterStartMonth, 1); + return nextQuarterStart.AddDays(dayOfQuarter - 1); + } + + private DateTime? CalculateNextYearly(string config, DateTime baseTime) + { + if (string.IsNullOrWhiteSpace(config) || !int.TryParse(config, out var dayOfYear)) + return null; + + var nextYear = baseTime.Year; + if (baseTime.DayOfYear >= dayOfYear) + { + nextYear++; + } + + // 处理闰年情况 + var daysInYear = DateTime.IsLeapYear(nextYear) ? 366 : 365; + var actualDay = Math.Min(dayOfYear, daysInYear); + + return new DateTime(nextYear, 1, 1).AddDays(actualDay - 1); + } +} diff --git a/Web/src/api/transactionPeriodic.js b/Web/src/api/transactionPeriodic.js new file mode 100644 index 0000000..1f0c6a7 --- /dev/null +++ b/Web/src/api/transactionPeriodic.js @@ -0,0 +1,100 @@ +import request from './request' + +/** + * 周期性账单相关 API + */ + +/** + * 获取周期性账单列表(分页) + * @param {Object} params - 查询参数 + * @param {number} [params.pageIndex] - 页码 + * @param {number} [params.pageSize] - 每页数量 + * @param {string} [params.searchKeyword] - 搜索关键词 + * @returns {Promise<{success: boolean, data: Array, total: number}>} + */ +export const getPeriodicList = (params = {}) => { + return request({ + url: '/TransactionPeriodic/GetList', + method: 'get', + params + }) +} + +/** + * 根据ID获取周期性账单详情 + * @param {number} id - 周期性账单ID + * @returns {Promise<{success: boolean, data: Object}>} + */ +export const getPeriodicDetail = (id) => { + return request({ + url: `/TransactionPeriodic/GetById/${id}`, + method: 'get' + }) +} + +/** + * 创建周期性账单 + * @param {Object} data - 周期性账单数据 + * @param {number} data.periodicType - 周期类型 (0:每天, 1:每周, 2:每月, 3:每季度, 4:每年) + * @param {string} data.periodicConfig - 周期配置 + * @param {number} data.amount - 交易金额 + * @param {number} data.type - 交易类型 (0:支出, 1:收入, 2:不计入收支) + * @param {string} data.classify - 交易分类 + * @param {string} data.reason - 交易摘要/备注 + * @returns {Promise<{success: boolean, data: Object}>} + */ +export const createPeriodic = (data) => { + return request({ + url: '/TransactionPeriodic/Create', + method: 'post', + data + }) +} + +/** + * 更新周期性账单 + * @param {Object} data - 周期性账单数据 + * @param {number} data.id - 周期性账单ID + * @param {number} data.periodicType - 周期类型 + * @param {string} data.periodicConfig - 周期配置 + * @param {number} data.amount - 交易金额 + * @param {number} data.type - 交易类型 + * @param {string} data.classify - 交易分类 + * @param {string} data.reason - 交易摘要/备注 + * @param {boolean} data.isEnabled - 是否启用 + * @returns {Promise<{success: boolean}>} + */ +export const updatePeriodic = (data) => { + return request({ + url: '/TransactionPeriodic/Update', + method: 'post', + data + }) +} + +/** + * 删除周期性账单 + * @param {number} id - 周期性账单ID + * @returns {Promise<{success: boolean}>} + */ +export const deletePeriodic = (id) => { + return request({ + url: `/TransactionPeriodic/DeleteById`, + method: 'post', + params: { id } + }) +} + +/** + * 启用/禁用周期性账单 + * @param {number} id - 周期性账单ID + * @param {boolean} enabled - 是否启用 + * @returns {Promise<{success: boolean}>} + */ +export const togglePeriodicEnabled = (id, enabled) => { + return request({ + url: '/TransactionPeriodic/ToggleEnabled', + method: 'post', + params: { id, enabled } + }) +} diff --git a/Web/src/components/ClassifyPicker.vue b/Web/src/components/ClassifyPicker.vue new file mode 100644 index 0000000..799d6a7 --- /dev/null +++ b/Web/src/components/ClassifyPicker.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/Web/src/router/index.js b/Web/src/router/index.js index c2ff511..d023fe2 100644 --- a/Web/src/router/index.js +++ b/Web/src/router/index.js @@ -75,6 +75,12 @@ const router = createRouter({ name: 'message', component: () => import('../views/MessageView.vue'), meta: { requiresAuth: true }, + }, + { + path: '/periodic-record', + name: 'periodic-record', + component: () => import('../views/PeriodicRecord.vue'), + meta: { requiresAuth: true }, } ], }) diff --git a/Web/src/views/CalendarView.vue b/Web/src/views/CalendarView.vue index dabb8c1..3986020 100644 --- a/Web/src/views/CalendarView.vue +++ b/Web/src/views/CalendarView.vue @@ -22,7 +22,10 @@
@@ -132,6 +135,23 @@ const fetchDateTransactions = async (date) => { } }; +const getBalance = (transactions) => { + let balance = 0; + transactions.forEach(tx => { + if(tx.type === 1) { + balance += tx.amount; + } else if(tx.type === 0) { + balance -= tx.amount; + } + }); + + if(balance >= 0) { + return `结余收入 ${balance.toFixed(1)} 元`; + } else { + return `结余支出 ${(-balance).toFixed(1)} 元`; + } +}; + // 当月份显示时触发 const onMonthShow = ({ date }) => { const year = date.getFullYear(); diff --git a/Web/src/views/PeriodicRecord.vue b/Web/src/views/PeriodicRecord.vue new file mode 100644 index 0000000..f2ee956 --- /dev/null +++ b/Web/src/views/PeriodicRecord.vue @@ -0,0 +1,848 @@ + + + + + diff --git a/Web/src/views/SettingView.vue b/Web/src/views/SettingView.vue index 9e2aacf..7a148b4 100644 --- a/Web/src/views/SettingView.vue +++ b/Web/src/views/SettingView.vue @@ -3,18 +3,19 @@
-

账单导入

+

账单

+
-

分类处理

+

分类

@@ -22,6 +23,7 @@ +

账户

@@ -53,6 +55,10 @@ const handleImportClick = (type) => { fileInputRef.value?.click() } +const handlePeriodicRecord = () => { + router.push({ name: 'periodic-record' }) +} + /** * 处理文件选择 */ @@ -168,6 +174,7 @@ const handleLogout = async () => { .detail-header { padding: 16px 16px 5px 16px; + margin-bottom: 5px; } .detail-header p { diff --git a/WebApi/Controllers/JobController.cs b/WebApi/Controllers/JobController.cs new file mode 100644 index 0000000..82fb95d --- /dev/null +++ b/WebApi/Controllers/JobController.cs @@ -0,0 +1,175 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Quartz; + +namespace WebApi.Controllers; + +/// +/// 定时任务管理控制器 +/// +[ApiController] +[Route("api/[controller]")] +public class JobController( + ISchedulerFactory schedulerFactory, + ILogger logger) : ControllerBase +{ + /// + /// 手动触发邮件同步任务 + /// + [HttpPost("sync-email")] + [Authorize] + public async Task TriggerEmailSync() + { + try + { + var scheduler = await schedulerFactory.GetScheduler(); + var jobKey = new JobKey("EmailSyncJob"); + + // 立即触发任务 + await scheduler.TriggerJob(jobKey); + + logger.LogInformation("手动触发邮件同步任务成功"); + return Ok(new { message = "邮件同步任务已触发" }); + } + catch (Exception ex) + { + logger.LogError(ex, "触发邮件同步任务失败"); + return StatusCode(500, new { message = "触发任务失败", error = ex.Message }); + } + } + + /// + /// 手动触发周期性账单任务 + /// + [HttpPost("periodic-bill")] + [Authorize] + public async Task TriggerPeriodicBill() + { + try + { + var scheduler = await schedulerFactory.GetScheduler(); + var jobKey = new JobKey("PeriodicBillJob"); + + // 立即触发任务 + await scheduler.TriggerJob(jobKey); + + logger.LogInformation("手动触发周期性账单任务成功"); + return Ok(new { message = "周期性账单任务已触发" }); + } + catch (Exception ex) + { + logger.LogError(ex, "触发周期性账单任务失败"); + return StatusCode(500, new { message = "触发任务失败", error = ex.Message }); + } + } + + /// + /// 获取所有任务的状态 + /// + [HttpGet("status")] + [Authorize] + public async Task GetJobStatus() + { + try + { + var scheduler = await schedulerFactory.GetScheduler(); + var jobGroups = await scheduler.GetJobGroupNames(); + var jobStatuses = new List(); + + foreach (var group in jobGroups) + { + var jobKeys = await scheduler.GetJobKeys(Quartz.Impl.Matchers.GroupMatcher.GroupEquals(group)); + + foreach (var jobKey in jobKeys) + { + var triggers = await scheduler.GetTriggersOfJob(jobKey); + var jobDetail = await scheduler.GetJobDetail(jobKey); + + foreach (var trigger in triggers) + { + var triggerState = await scheduler.GetTriggerState(trigger.Key); + var nextFireTime = trigger.GetNextFireTimeUtc(); + var previousFireTime = trigger.GetPreviousFireTimeUtc(); + + jobStatuses.Add(new + { + jobName = jobKey.Name, + jobGroup = jobKey.Group, + triggerName = trigger.Key.Name, + triggerState = triggerState.ToString(), + nextFireTime = nextFireTime?.LocalDateTime, + previousFireTime = previousFireTime?.LocalDateTime, + description = trigger.Description, + jobType = jobDetail?.JobType.Name + }); + } + } + } + + return Ok(jobStatuses); + } + catch (Exception ex) + { + logger.LogError(ex, "获取任务状态失败"); + return StatusCode(500, new { message = "获取任务状态失败", error = ex.Message }); + } + } + + /// + /// 暂停指定任务 + /// + [HttpPost("pause/{jobName}")] + [Authorize] + public async Task PauseJob(string jobName) + { + try + { + var scheduler = await schedulerFactory.GetScheduler(); + var jobKey = new JobKey(jobName); + + if (!await scheduler.CheckExists(jobKey)) + { + return NotFound(new { message = $"任务 {jobName} 不存在" }); + } + + await scheduler.PauseJob(jobKey); + logger.LogInformation("任务 {JobName} 已暂停", jobName); + + return Ok(new { message = $"任务 {jobName} 已暂停" }); + } + catch (Exception ex) + { + logger.LogError(ex, "暂停任务 {JobName} 失败", jobName); + return StatusCode(500, new { message = "暂停任务失败", error = ex.Message }); + } + } + + /// + /// 恢复指定任务 + /// + [HttpPost("resume/{jobName}")] + [Authorize] + public async Task ResumeJob(string jobName) + { + try + { + var scheduler = await schedulerFactory.GetScheduler(); + var jobKey = new JobKey(jobName); + + if (!await scheduler.CheckExists(jobKey)) + { + return NotFound(new { message = $"任务 {jobName} 不存在" }); + } + + await scheduler.ResumeJob(jobKey); + logger.LogInformation("任务 {JobName} 已恢复", jobName); + + return Ok(new { message = $"任务 {jobName} 已恢复" }); + } + catch (Exception ex) + { + logger.LogError(ex, "恢复任务 {JobName} 失败", jobName); + return StatusCode(500, new { message = "恢复任务失败", error = ex.Message }); + } + } +} diff --git a/WebApi/Controllers/TransactionPeriodicController.cs b/WebApi/Controllers/TransactionPeriodicController.cs new file mode 100644 index 0000000..c7a135a --- /dev/null +++ b/WebApi/Controllers/TransactionPeriodicController.cs @@ -0,0 +1,249 @@ +namespace WebApi.Controllers; + +using Repository; + +/// +/// 周期性账单控制器 +/// +[ApiController] +[Route("api/[controller]/[action]")] +public class TransactionPeriodicController( + ITransactionPeriodicRepository periodicRepository, + ITransactionPeriodicService periodicService, + ILogger logger +) : ControllerBase +{ + /// + /// 获取周期性账单列表(分页) + /// + [HttpGet] + public async Task> GetListAsync( + [FromQuery] int pageIndex = 1, + [FromQuery] int pageSize = 20, + [FromQuery] string? searchKeyword = null + ) + { + try + { + var list = await periodicRepository.GetPagedListAsync(pageIndex, pageSize, searchKeyword); + var total = await periodicRepository.GetTotalCountAsync(searchKeyword); + + return new PagedResponse + { + Success = true, + Data = list.ToArray(), + Total = (int)total + }; + } + catch (Exception ex) + { + logger.LogError(ex, "获取周期性账单列表失败"); + return PagedResponse.Fail($"获取列表失败: {ex.Message}"); + } + } + + /// + /// 根据ID获取周期性账单详情 + /// + [HttpGet("{id}")] + public async Task> GetByIdAsync(long id) + { + try + { + var periodic = await periodicRepository.GetByIdAsync(id); + if (periodic == null) + { + return BaseResponse.Fail("周期性账单不存在"); + } + + return new BaseResponse + { + Success = true, + Data = periodic + }; + } + catch (Exception ex) + { + logger.LogError(ex, "获取周期性账单详情失败,ID: {Id}", id); + return BaseResponse.Fail($"获取详情失败: {ex.Message}"); + } + } + + /// + /// 创建周期性账单 + /// + [HttpPost] + public async Task> CreateAsync([FromBody] CreatePeriodicRequest request) + { + try + { + var periodic = new TransactionPeriodic + { + PeriodicType = request.PeriodicType, + PeriodicConfig = request.PeriodicConfig ?? string.Empty, + Amount = request.Amount, + Type = request.Type, + Classify = request.Classify ?? string.Empty, + Reason = request.Reason ?? string.Empty, + IsEnabled = true + }; + + // 计算下次执行时间 + periodic.NextExecuteTime = periodicService.CalculateNextExecuteTime(periodic, DateTime.Now); + + var success = await periodicRepository.AddAsync(periodic); + if (!success) + { + return BaseResponse.Fail("创建周期性账单失败"); + } + + return new BaseResponse + { + Success = true, + Data = periodic, + Message = "创建成功" + }; + } + catch (Exception ex) + { + logger.LogError(ex, "创建周期性账单失败"); + return BaseResponse.Fail($"创建失败: {ex.Message}"); + } + } + + /// + /// 更新周期性账单 + /// + [HttpPost] + public async Task> UpdateAsync([FromBody] UpdatePeriodicRequest request) + { + try + { + var periodic = await periodicRepository.GetByIdAsync(request.Id); + if (periodic == null) + { + return BaseResponse.Fail("周期性账单不存在"); + } + + periodic.PeriodicType = request.PeriodicType; + periodic.PeriodicConfig = request.PeriodicConfig ?? string.Empty; + periodic.Amount = request.Amount; + periodic.Type = request.Type; + periodic.Classify = request.Classify ?? string.Empty; + periodic.Reason = request.Reason ?? string.Empty; + periodic.IsEnabled = request.IsEnabled; + periodic.UpdateTime = DateTime.Now; + + // 重新计算下次执行时间 + periodic.NextExecuteTime = periodicService.CalculateNextExecuteTime(periodic, DateTime.Now); + + var success = await periodicRepository.UpdateAsync(periodic); + if (!success) + { + return BaseResponse.Fail("更新周期性账单失败"); + } + + return new BaseResponse + { + Success = true, + Message = "更新成功" + }; + } + catch (Exception ex) + { + logger.LogError(ex, "更新周期性账单失败,ID: {Id}", request.Id); + return BaseResponse.Fail($"更新失败: {ex.Message}"); + } + } + + /// + /// 删除周期性账单 + /// + [HttpPost] + public async Task> DeleteByIdAsync([FromQuery] long id) + { + try + { + var success = await periodicRepository.DeleteAsync(id); + if (!success) + { + return BaseResponse.Fail("删除周期性账单失败"); + } + + return new BaseResponse + { + Success = true, + Message = "删除成功" + }; + } + catch (Exception ex) + { + logger.LogError(ex, "删除周期性账单失败,ID: {Id}", id); + return BaseResponse.Fail($"删除失败: {ex.Message}"); + } + } + + /// + /// 启用/禁用周期性账单 + /// + [HttpPost] + public async Task> ToggleEnabledAsync([FromQuery] long id, [FromQuery] bool enabled) + { + try + { + var periodic = await periodicRepository.GetByIdAsync(id); + if (periodic == null) + { + return BaseResponse.Fail("周期性账单不存在"); + } + + periodic.IsEnabled = enabled; + periodic.UpdateTime = DateTime.Now; + + var success = await periodicRepository.UpdateAsync(periodic); + if (!success) + { + return BaseResponse.Fail("操作失败"); + } + + return new BaseResponse + { + Success = true, + Message = enabled ? "已启用" : "已禁用" + }; + } + catch (Exception ex) + { + logger.LogError(ex, "启用/禁用周期性账单失败,ID: {Id}", id); + return BaseResponse.Fail($"操作失败: {ex.Message}"); + } + } +} + +/// +/// 创建周期性账单请求 +/// +public class CreatePeriodicRequest +{ + public PeriodicType PeriodicType { get; set; } + public string? PeriodicConfig { get; set; } + public decimal Amount { get; set; } + public TransactionType Type { get; set; } + public string? Classify { get; set; } + public string? Reason { get; set; } +} + +/// +/// 更新周期性账单请求 +/// +public class UpdatePeriodicRequest +{ + public long Id { get; set; } + public PeriodicType PeriodicType { get; set; } + public string? PeriodicConfig { get; set; } + public decimal Amount { get; set; } + public TransactionType Type { get; set; } + public string? Classify { get; set; } + public string? Reason { get; set; } + public bool IsEnabled { get; set; } +} diff --git a/WebApi/Expand.cs b/WebApi/Expand.cs new file mode 100644 index 0000000..4d85232 --- /dev/null +++ b/WebApi/Expand.cs @@ -0,0 +1,40 @@ +using Quartz; + +namespace WebApi; + +public static class Expand +{ + public static void AddScheduler(this WebApplicationBuilder builder) + { + builder.Services.AddQuartz(q => + { + // 配置调度器 + q.SchedulerId = "EmailBillScheduler"; + + // 配置邮件同步任务 - 每10分钟执行一次 + var emailJobKey = new JobKey("EmailSyncJob"); + q.AddJob(opts => opts.WithIdentity(emailJobKey)); + q.AddTrigger(opts => opts + .ForJob(emailJobKey) + .WithIdentity("EmailSyncTrigger") + .WithCronSchedule("0 0/20 * * * ?") // 每20分钟执行 + .WithDescription("每20分钟同步一次邮件")); + + // 配置周期性账单任务 - 每天早上6点执行 + var periodicBillJobKey = new JobKey("PeriodicBillJob"); + q.AddJob(opts => opts.WithIdentity(periodicBillJobKey)); + q.AddTrigger(opts => opts + .ForJob(periodicBillJobKey) + .WithIdentity("PeriodicBillTrigger") + .WithCronSchedule("0 0 6 * * ?") // 每天早上6点执行 + .WithDescription("每天早上6点执行周期性账单检查")); + }); + + // 添加 Quartz Hosted Service + builder.Services.AddQuartzHostedService(options => + { + // 等待任务完成后再关闭 + options.WaitForJobsToComplete = true; + }); + } +} \ No newline at end of file diff --git a/WebApi/Program.cs b/WebApi/Program.cs index c8d9256..6649257 100644 --- a/WebApi/Program.cs +++ b/WebApi/Program.cs @@ -1,10 +1,10 @@ -using System.Text; using FreeSql; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using Scalar.AspNetCore; using Serilog; using Service.AppSettingModel; +using WebApi; using Yitter.IdGenerator; // 初始化雪花算法ID生成器 @@ -97,6 +97,9 @@ builder.Services.AddSingleton(fsql); // 自动扫描注册服务和仓储 builder.Services.AddServices(); +// 配置 Quartz.NET 定时任务 +builder.AddScheduler(); + var app = builder.Build(); // Configure the HTTP request pipeline. @@ -122,27 +125,4 @@ app.MapControllers(); // 添加 SPA 回退路由(用于前端路由) app.MapFallbackToFile("index.html"); -// 启动后台邮件抓取服务(必须只注册一次) -app.Lifetime.ApplicationStarted.Register(() => -{ - try - { - if (app.Services.GetRequiredService() is not EmailBackgroundService emailService) - { - return; - } - - // 检查是否已在运行,避免重复启动 - if (!emailService.IsBusy) - { - emailService.RunWorkerAsync(); - } - } - catch (Exception ex) - { - var logger = app.Services.GetRequiredService>(); - logger.LogError(ex, "启动后台服务失败"); - } -}); - app.Run(); diff --git a/WebApi/appsettings.json b/WebApi/appsettings.json index 3459c84..cbc1fe5 100644 --- a/WebApi/appsettings.json +++ b/WebApi/appsettings.json @@ -66,6 +66,11 @@ "AuthSettings": { "Password": "SCsunch940622" }, + "Quartz": { + "quartz.scheduler.instanceName": "EmailBillScheduler", + "quartz.jobStore.type": "Quartz.Simpl.RAMJobStore, Quartz", + "quartz.threadPool.threadCount": 10 + }, "OpenAI": { "Endpoint": "https://api.deepseek.com/v1", "Key": "sk-2240d91e2ab1475881147e3810b343d3",