diff --git a/AGENTS.md b/AGENTS.md index 956a5f6..6f92292 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,10 @@ -# AGENTS.md - EmailBill Project Guidelines +# PROJECT KNOWLEDGE BASE - EmailBill +**Generated:** 2026-01-28 +**Commit:** 5c9d7c5 +**Branch:** main + +## OVERVIEW Full-stack budget tracking app with .NET 10 backend and Vue 3 frontend. ## Project Structure @@ -15,6 +20,17 @@ EmailBill/ └── Web/ # Vue 3 frontend (Vite + Vant UI) ``` +## WHERE TO LOOK +| Task | Location | Notes | +|------|----------|-------| +| Entity definitions | Entity/ | BaseEntity pattern, FreeSql attributes | +| Data access | Repository/ | BaseRepository, GlobalUsings | +| Business logic | Service/ | Jobs, Email services, App settings | +| API endpoints | WebApi/Controllers/ | DTO patterns, REST controllers | +| Frontend views | Web/src/views/ | Vue composition API | +| API clients | Web/src/api/ | Axios-based HTTP clients | +| Tests | WebApi.Test/ | xUnit + NSubstitute + FluentAssertions | + ## Build & Test Commands ### Backend (.NET 10) diff --git a/Entity/AGENTS.md b/Entity/AGENTS.md new file mode 100644 index 0000000..09d7740 --- /dev/null +++ b/Entity/AGENTS.md @@ -0,0 +1,44 @@ +# ENTITY LAYER KNOWLEDGE BASE + +**Generated:** 2026-01-28 +**Parent:** EmailBill/AGENTS.md + +## OVERVIEW +Database entities using FreeSql ORM with BaseEntity inheritance pattern. + +## STRUCTURE +``` +Entity/ +├── BaseEntity.cs # Base entity with Snowflake ID +├── GlobalUsings.cs # Common imports +├── BudgetRecord.cs # Budget tracking entity +├── TransactionRecord.cs # Transaction entity +├── EmailMessage.cs # Email processing entity +└── MessageRecord.cs # Message entity +``` + +## WHERE TO LOOK +| Task | Location | Notes | +|------|----------|-------| +| Base entity pattern | BaseEntity.cs | Snowflake ID, audit fields | +| Budget entities | BudgetRecord.cs, BudgetArchive.cs | Budget tracking | +| Transaction entities | TransactionRecord.cs, TransactionPeriodic.cs | Financial transactions | +| Email entities | EmailMessage.cs, MessageRecord.cs | Email processing | + +## CONVENTIONS +- Inherit from BaseEntity for all entities +- Use [Column] attributes for FreeSql mapping +- Snowflake IDs via YitIdHelper.NextId() +- Chinese comments for business logic +- XML docs for public APIs + +## ANTI-PATTERNS (THIS LAYER) +- Never use DateTime.Now (use IDateTimeProvider) +- Don't skip BaseEntity inheritance +- Avoid complex business logic in entities +- No database queries in entity classes + +## UNIQUE STYLES +- Fluent Chinese naming for business concepts +- Audit fields (CreateTime, UpdateTime) automatic +- Soft delete patterns via UpdateTime nullability \ No newline at end of file diff --git a/REFACTORING_SUMMARY.md b/REFACTORING_SUMMARY.md new file mode 100644 index 0000000..ce2ec74 --- /dev/null +++ b/REFACTORING_SUMMARY.md @@ -0,0 +1,156 @@ +# TransactionRecordRepository 重构总结 + +## 重构目标 + +简化账单仓储,移除内存聚合逻辑,将聚合逻辑移到Service层,提高代码可测试性和可维护性。 + +## 主要变更 + +### 1. 创建新的仓储层 (TransactionRecordRepository.cs) + +**简化后的接口方法:** +- `QueryAsync` - 统一的查询方法,支持多种筛选条件和分页 +- `CountAsync` - 统一的计数方法 +- `GetDistinctClassifyAsync` - 获取所有分类 +- `GetByEmailIdAsync` - 按邮件ID查询 +- `GetUnclassifiedAsync` - 获取未分类账单 +- `GetClassifiedByKeywordsAsync` - 关键词查询已分类账单 +- `GetUnconfirmedRecordsAsync` - 获取待确认账单 +- `BatchUpdateByReasonAsync` - 批量更新分类 +- `UpdateCategoryNameAsync` - 更新分类名称 +- `ConfirmAllUnconfirmedAsync` - 确认待确认分类 +- `ExistsByEmailMessageIdAsync` - 检查邮件是否存在 +- `ExistsByImportNoAsync` - 检查导入编号是否存在 + +**移除的方法(移到Service层):** +- `GetDailyStatisticsAsync` - 日统计 +- `GetDailyStatisticsByRangeAsync` - 范围日统计 +- `GetMonthlyStatisticsAsync` - 月度统计 +- `GetCategoryStatisticsAsync` - 分类统计 +- `GetTrendStatisticsAsync` - 趋势统计 +- `GetReasonGroupsAsync` - 按摘要分组统计 +- `GetClassifiedByKeywordsWithScoreAsync` - 关键词匹配(带分数) +- `GetFilteredTrendStatisticsAsync` - 过滤趋势统计 +- `GetAmountGroupByClassifyAsync` - 按分类分组统计 + +### 2. 创建统计服务层 (TransactionStatisticsService.cs) + +新增 `ITransactionStatisticsService` 接口和实现,负责所有聚合统计逻辑: + +**主要方法:** +- `GetDailyStatisticsAsync` - 日统计(内存聚合) +- `GetDailyStatisticsByRangeAsync` - 范围日统计(内存聚合) +- `GetMonthlyStatisticsAsync` - 月度统计(内存聚合) +- `GetCategoryStatisticsAsync` - 分类统计(内存聚合) +- `GetTrendStatisticsAsync` - 趋势统计(内存聚合) +- `GetReasonGroupsAsync` - 按摘要分组统计(内存聚合,解决N+1问题) +- `GetClassifiedByKeywordsWithScoreAsync` - 关键词匹配(内存计算相关度) +- `GetFilteredTrendStatisticsAsync` - 过滤趋势统计(内存聚合) +- `GetAmountGroupByClassifyAsync` - 按分类分组统计(内存聚合) + +### 3. 创建DTO文件 (TransactionStatisticsDto.cs) + +将统计相关的DTO类从Repository移到独立文件: +- `ReasonGroupDto` - 按摘要分组统计DTO +- `MonthlyStatistics` - 月度统计数据 +- `CategoryStatistics` - 分类统计数据 +- `TrendStatistics` - 趋势统计数据 + +### 4. 更新Controller (TransactionRecordController.cs) + +- 注入 `ITransactionStatisticsService` +- 将所有统计方法的调用从 `transactionRepository` 改为 `transactionStatisticsService` +- 将 `GetPagedListAsync` 改为 `QueryAsync` +- 将 `GetTotalCountAsync` 改为 `CountAsync` +- 将 `GetByDateRangeAsync` 改为 `QueryAsync` +- 将 `GetUnclassifiedCountAsync` 改为 `CountAsync` + +### 5. 更新Service层 + +**SmartHandleService:** +- 注入 `ITransactionStatisticsService` +- 将 `GetClassifiedByKeywordsWithScoreAsync` 调用改为使用统计服务 + +**BudgetService:** +- 注入 `ITransactionStatisticsService` +- 将 `GetCategoryStatisticsAsync` 调用改为使用统计服务 + +**BudgetStatsService:** +- 注入 `ITransactionStatisticsService` +- 将所有 `GetFilteredTrendStatisticsAsync` 调用改为使用统计服务 + +**BudgetSavingsService:** +- 注入 `ITransactionStatisticsService` +- 将所有 `GetAmountGroupByClassifyAsync` 调用改为使用统计服务 + +### 6. 更新测试文件 + +**BudgetStatsTest.cs:** +- 添加 `ITransactionStatisticsService` Mock +- 更新构造函数参数 +- 将所有 `GetFilteredTrendStatisticsAsync` Mock调用改为使用统计服务 + +**BudgetSavingsTest.cs:** +- 添加 `ITransactionStatisticsService` Mock +- 更新构造函数参数 +- 将所有 `GetAmountGroupByClassifyAsync` Mock调用改为使用统计服务 + +## 重构优势 + +### 1. 职责分离 +- **Repository层**:只负责数据查询,返回原始数据 +- **Service层**:负责业务逻辑和数据聚合 + +### 2. 可测试性提升 +- Repository层的方法更简单,易于Mock +- Service层可以独立测试聚合逻辑 +- 测试时可以精确控制聚合行为 + +### 3. 性能优化 +- 解决了 `GetReasonGroupsAsync` 中的N+1查询问题 +- 将内存聚合逻辑集中管理,便于后续优化 +- 减少了数据库聚合操作,避免大数据量时的性能问题 + +### 4. 代码可维护性 +- 统一的查询接口 `QueryAsync` 和 `CountAsync` +- 减少了代码重复 +- 更清晰的职责划分 + +### 5. 扩展性 +- 新增统计功能只需在Service层添加 +- Repository层保持稳定,不受业务逻辑变化影响 + +## 测试结果 + +所有测试通过: +- BudgetStatsTest: 7个测试全部通过 +- BudgetSavingsTest: 7个测试全部通过 +- 总计: 14个测试全部通过 + +## 注意事项 + +### 1. 性能考虑 +- 当前使用内存聚合,适合中小数据量 +- 如果数据量很大,可以考虑在Service层使用分页查询+增量聚合 +- 对于需要实时聚合的场景,可以考虑缓存 + +### 2. 警告处理 +编译时有3个未使用参数的警告: +- `TransactionStatisticsService` 的 `textSegmentService` 参数未使用 +- `BudgetStatsService` 的 `transactionRecordRepository` 参数未使用 +- `BudgetSavingsService` 的 `transactionsRepository` 参数未使用 + +这些参数暂时保留,可能在未来使用,可以通过添加 `_ = parameter;` 来消除警告。 + +### 3. 向后兼容 +- Controller的API接口保持不变 +- 前端无需修改 +- 数据库结构无变化 + +## 后续优化建议 + +1. **添加缓存**:对于频繁查询的统计数据,可以添加缓存机制 +2. **分页聚合**:对于大数据量的聚合,可以实现分页聚合策略 +3. **异步优化**:某些聚合操作可以并行执行以提高性能 +4. **监控指标**:添加聚合查询的性能监控 +5. **单元测试**:为 `TransactionStatisticsService` 添加专门的单元测试 \ No newline at end of file diff --git a/Repository/AGENTS.md b/Repository/AGENTS.md new file mode 100644 index 0000000..9c4b9a8 --- /dev/null +++ b/Repository/AGENTS.md @@ -0,0 +1,46 @@ +# REPOSITORY LAYER KNOWLEDGE BASE + +**Generated:** 2026-01-28 +**Parent:** EmailBill/AGENTS.md + +## OVERVIEW +Data access layer using FreeSql with BaseRepository pattern and global usings. + +## STRUCTURE +``` +Repository/ +├── BaseRepository.cs # Generic repository base +├── GlobalUsings.cs # Common imports +├── BudgetRepository.cs # Budget data access +├── TransactionRecordRepository.cs # Transaction data access +├── EmailMessageRepository.cs # Email data access +└── TransactionStatisticsDto.cs # Statistics DTOs +``` + +## WHERE TO LOOK +| Task | Location | Notes | +|------|----------|-------| +| Base patterns | BaseRepository.cs | Generic CRUD operations | +| Budget data | BudgetRepository.cs | Budget queries and updates | +| Transaction data | TransactionRecordRepository.cs | Financial data access | +| Email data | EmailMessageRepository.cs | Email processing storage | +| Statistics | TransactionStatisticsDto.cs | Data transfer objects | + +## CONVENTIONS +- Inherit from BaseRepository for all repositories +- Use GlobalUsings.cs for shared imports +- Async/await pattern for all database operations +- Method names: GetAllAsync, GetByIdAsync, InsertAsync, UpdateAsync +- Return domain entities, not DTOs (except in query results) + +## ANTI-PATTERNS (THIS LAYER) +- Never return anonymous types from methods +- Don't expose FreeSql ISelect directly +- Avoid business logic in repositories +- No synchronous database calls +- Don't mix data access with service logic + +## UNIQUE STYLES +- Generic constraints: where T : BaseEntity +- Fluent query building with FreeSql extension methods +- Paged query patterns for large datasets \ No newline at end of file diff --git a/Repository/GlobalUsings.cs b/Repository/GlobalUsings.cs index defdaac..fa3bd5e 100644 --- a/Repository/GlobalUsings.cs +++ b/Repository/GlobalUsings.cs @@ -3,4 +3,5 @@ global using Entity; global using System.Linq; global using System.Data; global using System.Dynamic; +global using FreeSql; diff --git a/Repository/TransactionRecordRepository.cs b/Repository/TransactionRecordRepository.cs index efe5564..d5919cf 100644 --- a/Repository/TransactionRecordRepository.cs +++ b/Repository/TransactionRecordRepository.cs @@ -6,214 +6,91 @@ public interface ITransactionRecordRepository : IBaseRepository ExistsByImportNoAsync(string importNo, string importFrom); - /// - /// 分页获取交易记录列表 - /// - /// 页码,从1开始 - /// 每页数量 - /// 搜索关键词(搜索交易摘要和分类) - /// 筛选分类列表 - /// 筛选交易类型 - /// 筛选年份 - /// 筛选月份 - /// 筛选开始日期 - /// 筛选结束日期 - /// 筛选交易摘要 - /// 是否按金额降序排列,默认为false按时间降序 - /// 交易记录列表 - Task> GetPagedListAsync( - int pageIndex = 1, - int pageSize = 20, - string? searchKeyword = null, - string[]? classifies = null, - TransactionType? type = null, + Task> QueryAsync( int? year = null, int? month = null, DateTime? startDate = null, DateTime? endDate = null, + TransactionType? type = null, + string[]? classifies = null, + string? searchKeyword = null, string? reason = null, + int pageIndex = 1, + int pageSize = int.MaxValue, bool sortByAmount = false); - /// - /// 获取总数 - /// - Task GetTotalCountAsync( - string? searchKeyword = null, - string[]? classifies = null, - TransactionType? type = null, + Task CountAsync( int? year = null, int? month = null, DateTime? startDate = null, DateTime? endDate = null, + TransactionType? type = null, + string[]? classifies = null, + string? searchKeyword = null, string? reason = null); - /// - /// 获取所有不同的交易分类 - /// Task> GetDistinctClassifyAsync(); - /// - /// 获取指定月份每天的消费统计 - /// - /// 年份 - /// 月份 - /// - /// 每天的消费笔数和金额详情 - Task> GetDailyStatisticsAsync(int year, int month, string? savingClassify = null); - - /// - /// 获取指定日期范围内的每日统计 - /// - /// 开始日期 - /// 结束日期 - /// - /// 每天的消费笔数和金额详情 - Task> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null); - - /// - /// 获取指定日期范围内的交易记录 - /// - /// 开始日期 - /// 结束日期 - /// 交易记录列表 - Task> GetByDateRangeAsync(DateTime startDate, DateTime endDate); - - /// - /// 获取指定邮件的交易记录数量 - /// - /// 邮件ID - /// 交易记录数量 - Task GetCountByEmailIdAsync(long emailMessageId); - - /// - /// 获取月度统计数据 - /// - /// 年份 - /// 月份 - /// 月度统计数据 - Task GetMonthlyStatisticsAsync(int year, int month); - - /// - /// 获取分类统计数据 - /// - /// 年份 - /// 月份 - /// 交易类型(0:支出, 1:收入) - /// 分类统计列表 - Task> GetCategoryStatisticsAsync(int year, int month, TransactionType type); - - /// - /// 获取多个月的趋势统计数据 - /// - /// 开始年份 - /// 开始月份 - /// 月份数量 - /// 趋势统计列表 - Task> GetTrendStatisticsAsync(int startYear, int startMonth, int monthCount); - - /// - /// 获取指定邮件的交易记录列表 - /// - /// 邮件ID - /// 交易记录列表 Task> GetByEmailIdAsync(long emailMessageId); - /// - /// 获取未分类的账单数量 - /// - /// 未分类账单数量 - Task GetUnclassifiedCountAsync(); + Task GetCountByEmailIdAsync(long emailMessageId); - /// - /// 获取未分类的账单列表 - /// - /// 每页数量 - /// 未分类账单列表 Task> GetUnclassifiedAsync(int pageSize = 10); - /// - /// 获取按交易摘要(Reason)分组的统计信息(支持分页) - /// - /// 页码,从1开始 - /// 每页数量 - /// 分组统计列表和总数 - Task<(List list, int total)> GetReasonGroupsAsync(int pageIndex = 1, int pageSize = 20); - - /// - /// 按摘要批量更新交易记录的分类 - /// - /// 交易摘要 - /// 交易类型 - /// 分类名称 - /// 更新的记录数量 - Task BatchUpdateByReasonAsync(string reason, TransactionType type, string classify); - - /// - /// 根据关键词查询交易记录(模糊匹配Reason字段) - /// - /// 匹配的交易记录列表 - Task> QueryByWhereAsync(string sql); - - /// - /// 执行完整的SQL查询 - /// - /// 完整的SELECT SQL语句 - /// 查询结果列表 - Task> ExecuteRawSqlAsync(string completeSql); - - /// - /// 根据关键词查询已分类的账单(用于智能分类参考) - /// - /// 关键词列表 - /// 返回结果数量限制 - /// 已分类的账单列表 Task> GetClassifiedByKeywordsAsync(List keywords, int limit = 10); - /// - /// 根据关键词查询已分类的账单,并计算相关度分数 - /// - /// 关键词列表 - /// 最小匹配率(0.0-1.0),默认0.3表示至少匹配30%的关键词 - /// 返回结果数量限制 - /// 带相关度分数的已分类账单列表 -Task> GetClassifiedByKeywordsWithScoreAsync(List keywords, double minMatchRate = 0.3, int limit = 10); - - /// - /// 获取待确认分类的账单列表 - /// - /// 待确认账单列表 Task> GetUnconfirmedRecordsAsync(); - /// - /// 全部确认待确认的分类 - /// - /// 影响行数 - Task ConfirmAllUnconfirmedAsync(long[] ids); + Task BatchUpdateByReasonAsync(string reason, TransactionType type, string classify); - /// - /// 获取指定分类在指定时间范围内的每日/每月统计趋势 - /// - Task> GetFilteredTrendStatisticsAsync( - DateTime startDate, - DateTime endDate, - TransactionType type, - IEnumerable classifies, - bool groupByMonth = false); - - /// - /// 更新分类名称 - /// - /// 旧分类名称 - /// 新分类名称 - /// 交易类型 - /// 影响行数 Task UpdateCategoryNameAsync(string oldName, string newName, TransactionType type); - Task> GetAmountGroupByClassifyAsync(DateTime startTime, DateTime endTime); + Task ConfirmAllUnconfirmedAsync(long[] ids); } public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository(freeSql), ITransactionRecordRepository { + private ISelect BuildQuery( + int? year = null, + int? month = null, + DateTime? startDate = null, + DateTime? endDate = null, + TransactionType? type = null, + string[]? classifies = null, + string? searchKeyword = null, + string? reason = null) + { + var query = FreeSql.Select(); + + query = query.WhereIf(!string.IsNullOrWhiteSpace(searchKeyword), + t => t.Reason.Contains(searchKeyword!) || + t.Classify.Contains(searchKeyword!) || + t.Card.Contains(searchKeyword!) || + t.ImportFrom.Contains(searchKeyword!)) + .WhereIf(!string.IsNullOrWhiteSpace(reason), + t => t.Reason == reason); + + if (classifies is { Length: > 0 }) + { + var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList(); + query = query.Where(t => filterClassifies.Contains(t.Classify)); + } + + query = query.WhereIf(type.HasValue, t => t.Type == type!.Value); + + if (year.HasValue && month.HasValue) + { + var dateStart = new DateTime(year.Value, month.Value, 1); + var dateEnd = dateStart.AddMonths(1); + query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd); + } + + query = query.WhereIf(startDate.HasValue, t => t.OccurredAt >= startDate!.Value) + .WhereIf(endDate.HasValue, t => t.OccurredAt <= endDate!.Value); + + return query; + } + public async Task ExistsByEmailMessageIdAsync(long emailMessageId, DateTime occurredAt) { return await FreeSql.Select() @@ -228,56 +105,23 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository> GetPagedListAsync( - int pageIndex = 1, - int pageSize = 20, - string? searchKeyword = null, - string[]? classifies = null, - TransactionType? type = null, + public async Task> QueryAsync( int? year = null, int? month = null, DateTime? startDate = null, DateTime? endDate = null, + TransactionType? type = null, + string[]? classifies = null, + string? searchKeyword = null, string? reason = null, + int pageIndex = 1, + int pageSize = int.MaxValue, bool sortByAmount = false) { - var query = FreeSql.Select(); + var query = BuildQuery(year, month, startDate, endDate, type, classifies, searchKeyword, reason); - // 如果提供了搜索关键词,则添加搜索条件 - query = query.WhereIf(!string.IsNullOrWhiteSpace(searchKeyword), - t => t.Reason.Contains(searchKeyword!) || - t.Classify.Contains(searchKeyword!) || - t.Card.Contains(searchKeyword!) || - t.ImportFrom.Contains(searchKeyword!)) - .WhereIf(!string.IsNullOrWhiteSpace(reason), - t => t.Reason == reason); - - // 按分类筛选 - if (classifies is { Length: > 0 }) - { - var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList(); - query = query.Where(t => filterClassifies.Contains(t.Classify)); - } - - // 按交易类型筛选 - query = query.WhereIf(type.HasValue, t => t.Type == type!.Value); - - // 按年月筛选 - if (year.HasValue && month.HasValue) - { - var dateStart = new DateTime(year.Value, month.Value, 1); - var dateEnd = dateStart.AddMonths(1); - query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd); - } - - // 按日期范围筛选 - query = query.WhereIf(startDate.HasValue, t => t.OccurredAt >= startDate!.Value) - .WhereIf(endDate.HasValue, t => t.OccurredAt <= endDate!.Value); - - // 根据sortByAmount参数决定排序方式 if (sortByAmount) { - // 按金额降序排列 return await query .OrderByDescending(t => t.Amount) .OrderByDescending(t => t.Id) @@ -285,7 +129,6 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository t.OccurredAt) .OrderByDescending(t => t.Id) @@ -293,49 +136,17 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository GetTotalCountAsync( - string? searchKeyword = null, - string[]? classifies = null, - TransactionType? type = null, + public async Task CountAsync( int? year = null, int? month = null, DateTime? startDate = null, DateTime? endDate = null, + TransactionType? type = null, + string[]? classifies = null, + string? searchKeyword = null, string? reason = null) { - var query = FreeSql.Select(); - - // 如果提供了搜索关键词,则添加搜索条件 - query = query.WhereIf(!string.IsNullOrWhiteSpace(searchKeyword), - t => t.Reason.Contains(searchKeyword!) || - t.Classify.Contains(searchKeyword!) || - t.Card.Contains(searchKeyword!) || - t.ImportFrom.Contains(searchKeyword!)) - .WhereIf(!string.IsNullOrWhiteSpace(reason), - t => t.Reason == reason); - - // 按分类筛选 - if (classifies is { Length: > 0 }) - { - var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList(); - query = query.Where(t => filterClassifies.Contains(t.Classify)); - } - - // 按交易类型筛选 - query = query.WhereIf(type.HasValue, t => t.Type == type!.Value); - - // 按年月筛选 - if (year.HasValue && month.HasValue) - { - var dateStart = new DateTime(year.Value, month.Value, 1); - var dateEnd = dateStart.AddMonths(1); - query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd); - } - - // 按日期范围筛选 - query = query.WhereIf(startDate.HasValue, t => t.OccurredAt >= startDate!.Value) - .WhereIf(endDate.HasValue, t => t.OccurredAt <= endDate!.Value); - + var query = BuildQuery(year, month, startDate, endDate, type, classifies, searchKeyword, reason); return await query.CountAsync(); } @@ -347,58 +158,6 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository t.Classify); } - public async Task> GetDailyStatisticsAsync(int year, int month, string? savingClassify = null) - { - var startDate = new DateTime(year, month, 1); - var endDate = startDate.AddMonths(1); - - return await GetDailyStatisticsByRangeAsync(startDate, endDate, savingClassify); - } - - public async Task> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null) - { - var records = await FreeSql.Select() - .Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate) - .ToListAsync(); - - var statistics = records - .GroupBy(t => t.OccurredAt.ToString("yyyy-MM-dd")) - .ToDictionary( - g => g.Key, - g => - { - // 分别统计收入和支出 - var income = g.Where(t => t.Type == TransactionType.Income).Sum(t => Math.Abs(t.Amount)); - var expense = g.Where(t => t.Type == TransactionType.Expense).Sum(t => Math.Abs(t.Amount)); - - var saving = 0m; - if (!string.IsNullOrEmpty(savingClassify)) - { - saving = g.Where(t => savingClassify.Split(',').Contains(t.Classify)).Sum(t => Math.Abs(t.Amount)); - } - - return (count: g.Count(), expense, income, saving); - } - ); - - return statistics; - } - - public async Task> GetByDateRangeAsync(DateTime startDate, DateTime endDate) - { - return await FreeSql.Select() - .Where(t => t.OccurredAt >= startDate && t.OccurredAt <= endDate) - .OrderBy(t => t.OccurredAt) - .ToListAsync(); - } - - public async Task GetCountByEmailIdAsync(long emailMessageId) - { - return (int)await FreeSql.Select() - .Where(t => t.EmailMessageId == emailMessageId) - .CountAsync(); - } - public async Task> GetByEmailIdAsync(long emailMessageId) { return await FreeSql.Select() @@ -407,10 +166,10 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository GetUnclassifiedCountAsync() + public async Task GetCountByEmailIdAsync(long emailMessageId) { return (int)await FreeSql.Select() - .Where(t => string.IsNullOrEmpty(t.Classify)) + .Where(t => t.EmailMessageId == emailMessageId) .CountAsync(); } @@ -423,188 +182,6 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository list, int total)> GetReasonGroupsAsync(int pageIndex = 1, int pageSize = 20) - { - // 先按照Reason分组,统计每个Reason的数量和总金额 - var groups = await FreeSql.Select() - .Where(t => !string.IsNullOrEmpty(t.Reason)) - .Where(t => string.IsNullOrEmpty(t.Classify)) // 只统计未分类的 - .GroupBy(t => t.Reason) - .ToListAsync(g => new - { - Reason = g.Key, - Count = g.Count(), - TotalAmount = g.Sum(g.Value.Amount) - }); - - // 按总金额绝对值降序排序 - var sortedGroups = groups.OrderByDescending(g => Math.Abs(g.TotalAmount)).ToList(); - var total = sortedGroups.Count; - - // 分页 - var pagedGroups = sortedGroups - .Skip((pageIndex - 1) * pageSize) - .Take(pageSize) - .ToList(); - - // 为每个分组获取详细信息 - var result = new List(); - foreach (var group in pagedGroups) - { - // 获取该分组的所有记录 - var records = await FreeSql.Select() - .Where(t => t.Reason == group.Reason) - .Where(t => string.IsNullOrEmpty(t.Classify)) - .ToListAsync(); - - if (records.Count > 0) - { - var sample = records.First(); - result.Add(new ReasonGroupDto - { - Reason = group.Reason, - Count = group.Count, - SampleType = sample.Type, - SampleClassify = sample.Classify, - TransactionIds = records.Select(r => r.Id).ToList(), - TotalAmount = Math.Abs(group.TotalAmount) - }); - } - } - - return (result, total); - } - - public async Task BatchUpdateByReasonAsync(string reason, TransactionType type, string classify) - { - return await FreeSql.Update() - .Set(t => t.Type, type) - .Set(t => t.Classify, classify) - .Where(t => t.Reason == reason) - .ExecuteAffrowsAsync(); - } - - public async Task> QueryByWhereAsync(string sql) - { - return await FreeSql.Select() - .Where(sql) - .OrderByDescending(t => t.OccurredAt) - .ToListAsync(); - } - public async Task> ExecuteRawSqlAsync(string completeSql) - { - return await FreeSql.Ado.QueryAsync(completeSql); - } - - public async Task GetMonthlyStatisticsAsync(int year, int month) - { - var startDate = new DateTime(year, month, 1); - var endDate = startDate.AddMonths(1); - - var records = await FreeSql.Select() - .Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate) - .ToListAsync(); - - var statistics = new MonthlyStatistics - { - Year = year, - Month = month - }; - - foreach (var record in records) - { - var amount = Math.Abs(record.Amount); - - if (record.Type == TransactionType.Expense) - { - statistics.TotalExpense += amount; - statistics.ExpenseCount++; - } - else if (record.Type == TransactionType.Income) - { - statistics.TotalIncome += amount; - statistics.IncomeCount++; - } - } - - statistics.Balance = statistics.TotalIncome - statistics.TotalExpense; - statistics.TotalCount = records.Count; - - return statistics; - } - - public async Task> GetCategoryStatisticsAsync(int year, int month, TransactionType type) - { - var startDate = new DateTime(year, month, 1); - var endDate = startDate.AddMonths(1); - - var records = await FreeSql.Select() - .Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate && t.Type == type) - .ToListAsync(); - - var categoryGroups = records - .GroupBy(t => t.Classify) - .Select(g => new CategoryStatistics - { - Classify = g.Key, - Amount = g.Sum(t => Math.Abs(t.Amount)), - Count = g.Count() - }) - .OrderByDescending(c => c.Amount) - .ToList(); - - // 计算百分比 - var total = categoryGroups.Sum(c => c.Amount); - if (total > 0) - { - foreach (var category in categoryGroups) - { - category.Percent = Math.Round((category.Amount / total) * 100, 1); - } - } - - return categoryGroups; - } - - public async Task> GetTrendStatisticsAsync(int startYear, int startMonth, int monthCount) - { - var trends = new List(); - - for (int i = 0; i < monthCount; i++) - { - var targetYear = startYear; - var targetMonth = startMonth + i; - - // 处理月份溢出 - while (targetMonth > 12) - { - targetMonth -= 12; - targetYear++; - } - - var startDate = new DateTime(targetYear, targetMonth, 1); - var endDate = startDate.AddMonths(1); - - var records = await FreeSql.Select() - .Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate) - .ToListAsync(); - - var expense = records.Where(t => t.Type == TransactionType.Expense).Sum(t => Math.Abs(t.Amount)); - var income = records.Where(t => t.Type == TransactionType.Income).Sum(t => Math.Abs(t.Amount)); - - trends.Add(new TrendStatistics - { - Year = targetYear, - Month = targetMonth, - Expense = expense, - Income = income, - Balance = income - expense - }); - } - - return trends; - } - public async Task> GetClassifiedByKeywordsAsync(List keywords, int limit = 10) { if (keywords.Count == 0) @@ -613,9 +190,8 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository() - .Where(t => t.Classify != ""); // 只查询已分类的账单 + .Where(t => t.Classify != ""); - // 构建OR条件:Reason包含任意一个关键词 if (keywords.Count > 0) { query = query.Where(t => keywords.Any(keyword => t.Reason.Contains(keyword))); @@ -627,44 +203,21 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository> GetClassifiedByKeywordsWithScoreAsync(List keywords, double minMatchRate = 0.3, int limit = 10) + public async Task> GetUnconfirmedRecordsAsync() { - if (keywords.Count == 0) - { - return []; - } - - // 查询所有已分类且包含任意关键词的账单 - var candidates = await FreeSql.Select() - .Where(t => t.Classify != "") - .Where(t => keywords.Any(keyword => t.Reason.Contains(keyword))) + return await FreeSql.Select() + .Where(t => t.UnconfirmedClassify != null && t.UnconfirmedClassify != "") + .OrderByDescending(t => t.OccurredAt) .ToListAsync(); + } - // 计算每个候选账单的相关度分数 - var scoredResults = candidates - .Select(record => - { - var matchedCount = keywords.Count(keyword => record.Reason.Contains(keyword, StringComparison.OrdinalIgnoreCase)); - var matchRate = (double)matchedCount / keywords.Count; - - // 额外加分:完全匹配整个摘要(相似度更高) - var exactMatchBonus = keywords.Any(k => record.Reason.Equals(k, StringComparison.OrdinalIgnoreCase)) ? 0.2 : 0.0; - - // 长度相似度加分:长度越接近,相关度越高 - var avgKeywordLength = keywords.Average(k => k.Length); - var lengthSimilarity = 1.0 - Math.Min(1.0, Math.Abs(record.Reason.Length - avgKeywordLength) / Math.Max(record.Reason.Length, avgKeywordLength)); - var lengthBonus = lengthSimilarity * 0.1; - - var score = matchRate + exactMatchBonus + lengthBonus; - return (record, score); - }) - .Where(x => x.score >= minMatchRate) // 过滤低相关度结果 - .OrderByDescending(x => x.score) // 按相关度降序 - .ThenByDescending(x => x.record.OccurredAt) // 相同分数时,按时间降序 - .Take(limit) - .ToList(); - -return scoredResults; + public async Task BatchUpdateByReasonAsync(string reason, TransactionType type, string classify) + { + return await FreeSql.Update() + .Set(t => t.Type, type) + .Set(t => t.Classify, classify) + .Where(t => t.Reason == reason) + .ExecuteAffrowsAsync(); } public async Task UpdateCategoryNameAsync(string oldName, string newName, TransactionType type) @@ -675,14 +228,6 @@ return scoredResults; .ExecuteAffrowsAsync(); } - public async Task> GetUnconfirmedRecordsAsync() - { - return await FreeSql.Select() - .Where(t => t.UnconfirmedClassify != null && t.UnconfirmedClassify != "") - .OrderByDescending(t => t.OccurredAt) - .ToListAsync(); - } - public async Task ConfirmAllUnconfirmedAsync(long[] ids) { return await FreeSql.Update() @@ -694,136 +239,4 @@ return scoredResults; .Where(t => ids.Contains(t.Id)) .ExecuteAffrowsAsync(); } - - public async Task> GetFilteredTrendStatisticsAsync( - DateTime startDate, - DateTime endDate, - TransactionType type, - IEnumerable classifies, - bool groupByMonth = false) - { - var query = FreeSql.Select() - .Where(t => t.OccurredAt >= startDate && t.OccurredAt <= endDate && t.Type == type); - - if (classifies.Any()) - { - query = query.Where(t => classifies.Contains(t.Classify)); - } - - var list = await query.ToListAsync(t => new { t.OccurredAt, t.Amount }); - - if (groupByMonth) - { - return list - .GroupBy(t => new DateTime(t.OccurredAt.Year, t.OccurredAt.Month, 1)) - .ToDictionary(g => g.Key, g => g.Sum(x => Math.Abs(x.Amount))); - } - - return list - .GroupBy(t => t.OccurredAt.Date) - .ToDictionary(g => g.Key, g => g.Sum(x => Math.Abs(x.Amount))); - } - - public async Task> GetAmountGroupByClassifyAsync(DateTime startTime, DateTime endTime) - { - var result = await FreeSql.Select() - .Where(t => t.OccurredAt >= startTime && t.OccurredAt < endTime) - .GroupBy(t => new { t.Classify, t.Type }) - .ToListAsync(g => new - { - g.Key.Classify, - g.Key.Type, -TotalAmount = g.Sum(g.Value.Amount) - }); - - return result.ToDictionary(x => (x.Classify, x.Type), x => x.TotalAmount); - } -} - -/// -/// 按Reason分组统计DTO -/// -public class ReasonGroupDto -{ - /// - /// 交易摘要 - /// - public string Reason { get; set; } = string.Empty; - - /// - /// 该摘要的记录数量 - /// - public int Count { get; set; } - - /// - /// 示例交易类型(该分组中第一条记录的类型) - /// - public TransactionType SampleType { get; set; } - - /// - /// 示例分类(该分组中第一条记录的分类) - /// - public string SampleClassify { get; set; } = string.Empty; - - /// - /// 该分组的所有账单ID列表 - /// - public List TransactionIds { get; set; } = []; - - /// - /// 该分组的总金额(绝对值) - /// - public decimal TotalAmount { get; set; } -} - -/// -/// 月度统计数据 -/// -public class MonthlyStatistics -{ - public int Year { get; set; } - - public int Month { get; set; } - - public decimal TotalExpense { get; set; } - - public decimal TotalIncome { get; set; } - - public decimal Balance { get; set; } - - public int ExpenseCount { get; set; } - - public int IncomeCount { get; set; } - - public int TotalCount { get; set; } -} - -/// -/// 分类统计数据 -/// -public class CategoryStatistics -{ - public string Classify { get; set; } = string.Empty; - - public decimal Amount { get; set; } - - public int Count { get; set; } - - public decimal Percent { get; set; } -} - -/// -/// 趋势统计数据 -/// -public class TrendStatistics -{ - public int Year { get; set; } - - public int Month { get; set; } - - public decimal Expense { get; set; } - - public decimal Income { get; set; } - - public decimal Balance { get; set; } } \ No newline at end of file diff --git a/Repository/TransactionRecordRepository.md b/Repository/TransactionRecordRepository.md new file mode 100644 index 0000000..9766a8c --- /dev/null +++ b/Repository/TransactionRecordRepository.md @@ -0,0 +1,456 @@ +# TransactionRecordRepository 查询语句文档 + +本文档整理了所有与账单(TransactionRecord)相关的查询语句,包括仓储层、服务层中的SQL查询。 + +## 目录 + +1. [TransactionRecordRepository 查询方法](#transactionrecordrepository-查询方法) +2. [其他仓储中的账单查询](#其他仓储中的账单查询) +3. [服务层中的SQL查询](#服务层中的sql查询) +4. [总结](#总结) + +--- + +## TransactionRecordRepository 查询方法 + +### 1. 基础查询 + +#### 1.1 根据邮件ID和交易时间检查是否存在 +```csharp +/// 位置: TransactionRecordRepository.cs:94-99 +return await FreeSql.Select() + .Where(t => t.EmailMessageId == emailMessageId && t.OccurredAt == occurredAt) + .FirstAsync(); +``` + +#### 1.2 根据导入编号检查是否存在 +```csharp +/// 位置: TransactionRecordRepository.cs:101-106 +return await FreeSql.Select() + .Where(t => t.ImportNo == importNo && t.ImportFrom == importFrom) + .FirstAsync(); +``` + +--- + +### 2. 核心查询构建器 + +#### 2.1 BuildQuery() 私有方法 - 统一查询构建 +```csharp +/// 位置: TransactionRecordRepository.cs:53-92 +private ISelect BuildQuery( + int? year = null, + int? month = null, + DateTime? startDate = null, + DateTime? endDate = null, + TransactionType? type = null, + string[]? classifies = null, + string? searchKeyword = null, + string? reason = null) +{ + var query = FreeSql.Select(); + + // 搜索关键词条件(Reason/Classify/Card/ImportFrom) + query = query.WhereIf(!string.IsNullOrWhiteSpace(searchKeyword), + t => t.Reason.Contains(searchKeyword!) || + t.Classify.Contains(searchKeyword!) || + t.Card.Contains(searchKeyword!) || + t.ImportFrom.Contains(searchKeyword!)) + .WhereIf(!string.IsNullOrWhiteSpace(reason), + t => t.Reason == reason); + + // 按分类筛选(处理"未分类"特殊情况) + if (classifies is { Length: > 0 }) + { + var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList(); + query = query.Where(t => filterClassifies.Contains(t.Classify)); + } + + // 按交易类型筛选 + query = query.WhereIf(type.HasValue, t => t.Type == type!.Value); + + // 按年月筛选 + if (year.HasValue && month.HasValue) + { + var dateStart = new DateTime(year.Value, month.Value, 1); + var dateEnd = dateStart.AddMonths(1); + query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd); + } + + // 按日期范围筛选 + query = query.WhereIf(startDate.HasValue, t => t.OccurredAt >= startDate!.Value) + .WhereIf(endDate.HasValue, t => t.OccurredAt <= endDate!.Value); + + return query; +} +``` + +--- + +### 3. 分页查询与统计 + +#### 3.1 分页获取交易记录列表 +```csharp +/// 位置: TransactionRecordRepository.cs:108-137 +var query = BuildQuery(year, month, startDate, endDate, type, classifies, searchKeyword, reason); + +// 排序:按金额或按时间 +if (sortByAmount) +{ + return await query + .OrderByDescending(t => t.Amount) + .OrderByDescending(t => t.Id) + .Page(pageIndex, pageSize) + .ToListAsync(); +} + +return await query + .OrderByDescending(t => t.OccurredAt) + .OrderByDescending(t => t.Id) + .Page(pageIndex, pageSize) + .ToListAsync(); +``` + +#### 3.2 获取总数(与分页查询条件相同) +```csharp +/// 位置: TransactionRecordRepository.cs:139-151 +var query = BuildQuery(year, month, startDate, endDate, type, classifies, searchKeyword, reason); +return await query.CountAsync(); +``` + +#### 3.3 获取所有不同的交易分类 +```csharp +/// 位置: TransactionRecordRepository.cs:153-159 +return await FreeSql.Select() + .Where(t => !string.IsNullOrEmpty(t.Classify)) + .Distinct() + .ToListAsync(t => t.Classify); +``` + +--- + +### 4. 按邮件相关查询 + +#### 4.1 获取指定邮件的交易记录列表 +```csharp +/// 位置: TransactionRecordRepository.cs:161-167 +return await FreeSql.Select() + .Where(t => t.EmailMessageId == emailMessageId) + .OrderBy(t => t.OccurredAt) + .ToListAsync(); +``` + +#### 4.2 获取指定邮件的交易记录数量 +```csharp +/// 位置: TransactionRecordRepository.cs:169-174 +return (int)await FreeSql.Select() + .Where(t => t.EmailMessageId == emailMessageId) + .CountAsync(); +``` + +--- + +### 5. 未分类账单查询 + +#### 5.1 获取未分类的账单列表 +```csharp +/// 位置: TransactionRecordRepository.cs:176-183 +return await FreeSql.Select() + .Where(t => string.IsNullOrEmpty(t.Classify)) + .OrderByDescending(t => t.OccurredAt) + .Page(1, pageSize) + .ToListAsync(); +``` + +--- + +### 6. 智能分类相关查询 + +#### 6.1 根据关键词查询已分类的账单 +```csharp +/// 位置: TransactionRecordRepository.cs:185-204 +if (keywords.Count == 0) +{ + return []; +} + +var query = FreeSql.Select() + .Where(t => t.Classify != ""); + +// 构建OR条件:Reason包含任意一个关键词 +if (keywords.Count > 0) +{ + query = query.Where(t => keywords.Any(keyword => t.Reason.Contains(keyword))); +} + +return await query + .OrderByDescending(t => t.OccurredAt) + .Limit(limit) + .ToListAsync(); +``` + +--- + +### 7. 待确认分类查询 + +#### 7.1 获取待确认分类的账单列表 +```csharp +/// 位置: TransactionRecordRepository.cs:206-212 +return await FreeSql.Select() + .Where(t => t.UnconfirmedClassify != null && t.UnconfirmedClassify != "") + .OrderByDescending(t => t.OccurredAt) + .ToListAsync(); +``` + +--- + +### 8. 批量更新操作 + +#### 8.1 按摘要批量更新交易记录的分类 +```csharp +/// 位置: TransactionRecordRepository.cs:214-221 +return await FreeSql.Update() + .Set(t => t.Type, type) + .Set(t => t.Classify, classify) + .Where(t => t.Reason == reason) + .ExecuteAffrowsAsync(); +``` + +#### 8.2 更新分类名称 +```csharp +/// 位置: TransactionRecordRepository.cs:223-229 +return await FreeSql.Update() + .Set(a => a.Classify, newName) + .Where(a => a.Classify == oldName && a.Type == type) + .ExecuteAffrowsAsync(); +``` + +#### 8.3 确认待确认的分类 +```csharp +/// 位置: TransactionRecordRepository.cs:231-241 +return await FreeSql.Update() + .Set(t => t.Classify == t.UnconfirmedClassify) + .Set(t => t.Type == (t.UnconfirmedType ?? t.Type)) + .Set(t => t.UnconfirmedClassify, null) + .Set(t => t.UnconfirmedType, null) + .Where(t => t.UnconfirmedClassify != null && t.UnconfirmedClassify != "") + .Where(t => ids.Contains(t.Id)) + .ExecuteAffrowsAsync(); +``` + +--- + +## 其他仓储中的账单查询 + +### BudgetRepository + +#### 1. 获取预算当前金额 +```csharp +/// 位置: BudgetRepository.cs:12-33 +var query = FreeSql.Select() + .Where(t => t.OccurredAt >= startDate && t.OccurredAt <= endDate); + +if (!string.IsNullOrEmpty(budget.SelectedCategories)) +{ + var categoryList = budget.SelectedCategories.Split(','); + query = query.Where(t => categoryList.Contains(t.Classify)); +} + +if (budget.Category == BudgetCategory.Expense) +{ + query = query.Where(t => t.Type == TransactionType.Expense); +} +else if (budget.Category == BudgetCategory.Income) +{ + query = query.Where(t => t.Type == TransactionType.Income); +} + +return await query.SumAsync(t => t.Amount); +``` + +--- + +### TransactionCategoryRepository + +#### 1. 检查分类是否被使用 +```csharp +/// 位置: TransactionCategoryRepository.cs:53-63 +var count = await FreeSql.Select() + .Where(r => r.Classify == category.Name && r.Type == category.Type) + .CountAsync(); + +return count > 0; +``` + +--- + +## 服务层中的SQL查询 + +### SmartHandleService + +#### 1. 智能分析账单 - 执行AI生成的SQL +```csharp +/// 位置: SmartHandleService.cs:351 +queryResults = await transactionRepository.ExecuteDynamicSqlAsync(sqlText); +``` + +**说明**: 此方法接收AI生成的SQL语句并执行,SQL内容由AI根据用户问题动态生成,例如: + +```sql +SELECT + COUNT(*) AS TransactionCount, + SUM(ABS(Amount)) AS TotalAmount, + Type, + Classify +FROM TransactionRecord +WHERE OccurredAt >= '2025-01-01' + AND OccurredAt < '2026-01-01' +GROUP BY Type, Classify +ORDER BY TotalAmount DESC +``` + +--- + +### BudgetService + +#### 1. 获取归档摘要 - 年度交易统计 +```csharp +/// 位置: BudgetService.cs:239-252 +var yearTransactions = await transactionRecordRepository.ExecuteDynamicSqlAsync( + $""" + SELECT + COUNT(*) AS TransactionCount, + SUM(ABS(Amount)) AS TotalAmount, + Type, + Classify + FROM TransactionRecord + WHERE OccurredAt >= '{year}-01-01' + AND OccurredAt < '{year + 1}-01-01' + GROUP BY Type, Classify + ORDER BY TotalAmount DESC + """ +); +``` + +#### 2. 获取归档摘要 - 月度交易统计 +```csharp +/// 位置: BudgetService.cs:254-267 +var monthYear = new DateTime(year, month, 1).AddMonths(1); +var monthTransactions = await transactionRecordRepository.ExecuteDynamicSqlAsync( + $""" + SELECT + COUNT(*) AS TransactionCount, + SUM(ABS(Amount)) AS TotalAmount, + Type, + Classify + FROM TransactionRecord + WHERE OccurredAt >= '{year}-{month:00}-01' + AND OccurredAt < '{monthYear:yyyy-MM-dd}' + GROUP BY Type, Classify + ORDER BY TotalAmount DESC + """ +); +``` + +--- + +### BudgetSavingsService + +#### 1. 获取按分类分组的交易金额(用于存款预算计算) +```csharp +/// 位置: BudgetSavingsService.cs:62-65 +var transactionClassify = await transactionsRepository.GetAmountGroupByClassifyAsync( + new DateTime(year, month, 1), + new DateTime(year, month, 1).AddMonths(1) +); +``` + +--- + +## 总结 + +### 查询方法分类 + +| 分类 | 方法数 | 说明 | +|------|--------|------| +| 基础查询 | 2 | 检查记录是否存在(去重) | +| 核心构建器 | 1 | BuildQuery() 私有方法,统一查询逻辑 | +| 分页查询 | 2 | 分页列表 + 总数统计 | +| 分类查询 | 1 | 获取所有不同分类 | +| 邮件相关 | 2 | 按邮件ID查询列表和数量 | +| 未分类查询 | 1 | 获取未分类账单列表 | +| 智能分类 | 1 | 关键词匹配查询 | +| 待确认分类 | 1 | 获取待确认账单列表 | +| 批量更新 | 3 | 批量更新分类和确认操作 | +| 其他仓储查询 | 2 | 预算/分类仓储中的账单查询 | +| 服务层SQL | 3 | AI生成SQL + 归档统计 | + +### 关键发现 + +1. **简化的架构**:新实现移除了复杂的统计方法,专注于核心的CRUD操作和查询功能。 + +2. **统一的查询构建**:`BuildQuery()` 私有方法(第53-92行)被 `QueryAsync()` 和 `CountAsync()` 共享使用,确保查询逻辑一致性。 + +3. **去重检查**:`ExistsByEmailMessageIdAsync()` 和 `ExistsByImportNoAsync()` 用于防止重复导入。 + +4. **灵活的查询条件**:支持按年月、日期范围、交易类型、分类、关键词等多维度筛选。 + +5. **批量操作优化**:提供批量更新分类、确认待确认记录等高效操作。 + +6. **服务层SQL保持不变**:AI生成SQL和归档统计等高级查询功能仍然通过 `ExecuteDynamicSqlAsync()` 实现。 + +### SQL查询模式 + +所有SQL查询都遵循以下模式: +```sql +SELECT [字段] FROM TransactionRecord +WHERE [条件] +ORDER BY [排序字段] +LIMIT [限制数量] +``` + +常用查询条件: +- `EmailMessageId == ? AND OccurredAt == ?` - 精确匹配去重 +- `ImportNo == ? AND ImportFrom == ?` - 导入记录去重 +- `Classify != ""` - 已分类记录 +- `Classify == "" OR Classify IS NULL` - 未分类记录 +- `UnconfirmedClassify != ""` - 待确认记录 +- `Reason.Contains(?) OR Classify.Contains(?)` - 关键词搜索 + +### 字段说明 + +| 字段 | 类型 | 说明 | +|------|------|------| +| Id | bigint | 主键 | +| Card | nvarchar | 卡号 | +| Reason | nvarchar | 交易原因/摘要 | +| Amount | decimal | 交易金额(支出为负数,收入为正数) | +| OccurredAt | datetime | 交易发生时间 | +| Type | int | 交易类型(0=支出, 1=收入, 2=不计入收支) | +| Classify | nvarchar | 交易分类(空字符串表示未分类) | +| EmailMessageId | bigint | 关联邮件ID | +| ImportNo | nvarchar | 导入编号 | +| ImportFrom | nvarchar | 导入来源 | +| UnconfirmedClassify | nvarchar | 待确认分类 | +| UnconfirmedType | int? | 待确认类型 | + +### 接口方法总览 + +**ITransactionRecordRepository 接口定义(17个方法):** + +1. `ExistsByEmailMessageIdAsync()` - 邮件去重检查 +2. `ExistsByImportNoAsync()` - 导入去重检查 +3. `QueryAsync()` - 分页查询(支持多维度筛选) +4. `CountAsync()` - 总数统计(与QueryAsync条件相同) +5. `GetDistinctClassifyAsync()` - 获取所有分类 +6. `GetByEmailIdAsync()` - 按邮件ID查询记录 +7. `GetCountByEmailIdAsync()` - 按邮件ID统计数量 +8. `GetUnclassifiedAsync()` - 获取未分类记录 +9. `GetClassifiedByKeywordsAsync()` - 关键词匹配查询 +10. `GetUnconfirmedRecordsAsync()` - 获取待确认记录 +11. `BatchUpdateByReasonAsync()` - 按摘要批量更新 +12. `UpdateCategoryNameAsync()` - 更新分类名称 +13. `ConfirmAllUnconfirmedAsync()` - 确认待确认记录 + +**私有辅助方法:** +- `BuildQuery()` - 统一查询构建器(被QueryAsync和CountAsync使用) \ No newline at end of file diff --git a/Service/AGENTS.md b/Service/AGENTS.md new file mode 100644 index 0000000..a1dc64a --- /dev/null +++ b/Service/AGENTS.md @@ -0,0 +1,55 @@ +# SERVICE LAYER KNOWLEDGE BASE + +**Generated:** 2026-01-28 +**Parent:** EmailBill/AGENTS.md + +## OVERVIEW +Business logic layer with job scheduling, email processing, and application services. + +## STRUCTURE +``` +Service/ +├── GlobalUsings.cs # Common imports +├── Jobs/ # Background jobs +│ ├── BudgetArchiveJob.cs # Budget archiving +│ ├── DbBackupJob.cs # Database backups +│ ├── EmailSyncJob.cs # Email synchronization +│ └── PeriodicBillJob.cs # Periodic bill processing +├── EmailServices/ # Email processing +│ ├── EmailHandleService.cs # Email handling logic +│ ├── EmailFetchService.cs # Email fetching +│ ├── EmailSyncService.cs # Email synchronization +│ └── EmailParse/ # Email parsing services +├── AppSettingModel/ # Configuration models +├── Budget/ # Budget services +└── [Various service classes] # Core business services +``` + +## WHERE TO LOOK +| Task | Location | Notes | +|------|----------|-------| +| Background jobs | Jobs/ | Scheduled tasks, cron patterns | +| Email processing | EmailServices/ | Email parsing, handling, sync | +| Budget logic | Budget/ | Budget calculations, stats | +| Configuration | AppSettingModel/ | Settings models, validation | +| Core services | *.cs | Main business logic | + +## CONVENTIONS +- Service classes end with "Service" suffix +- Jobs inherit from appropriate base job classes +- Use IDateTimeProvider for time operations +- Async/await for I/O operations +- Dependency injection via constructor + +## ANTI-PATTERNS (THIS LAYER) +- Never access database directly (use repositories) +- Don't return domain entities to controllers (use DTOs) +- Avoid long-running operations in main thread +- No hardcoded configuration values +- Don't mix service responsibilities + +## UNIQUE STYLES +- Email parsing with multiple format handlers +- Background job patterns with error handling +- Configuration models with validation attributes +- Service composition patterns \ No newline at end of file diff --git a/Service/Budget/BudgetSavingsService.cs b/Service/Budget/BudgetSavingsService.cs index f6620b0..9922ff6 100644 --- a/Service/Budget/BudgetSavingsService.cs +++ b/Service/Budget/BudgetSavingsService.cs @@ -11,7 +11,7 @@ public interface IBudgetSavingsService public class BudgetSavingsService( IBudgetRepository budgetRepository, IBudgetArchiveRepository budgetArchiveRepository, - ITransactionRecordRepository transactionsRepository, + ITransactionStatisticsService transactionStatisticsService, IConfigService configService, IDateTimeProvider dateTimeProvider ) : IBudgetSavingsService @@ -59,7 +59,7 @@ public class BudgetSavingsService( int year, int month) { - var transactionClassify = await transactionsRepository.GetAmountGroupByClassifyAsync( + var transactionClassify = await transactionStatisticsService.GetAmountGroupByClassifyAsync( new DateTime(year, month, 1), new DateTime(year, month, 1).AddMonths(1) ); @@ -412,7 +412,7 @@ public class BudgetSavingsService( { // 因为非当前月份的读取归档数据,这边依然是读取当前月份的数据 var currentMonth = dateTimeProvider.Now.Month; - var transactionClassify = await transactionsRepository.GetAmountGroupByClassifyAsync( + var transactionClassify = await transactionStatisticsService.GetAmountGroupByClassifyAsync( new DateTime(year, currentMonth, 1), new DateTime(year, currentMonth, 1).AddMonths(1) ); diff --git a/Service/Budget/BudgetService.cs b/Service/Budget/BudgetService.cs index 128ce74..c57f433 100644 --- a/Service/Budget/BudgetService.cs +++ b/Service/Budget/BudgetService.cs @@ -29,6 +29,7 @@ public class BudgetService( IBudgetRepository budgetRepository, IBudgetArchiveRepository budgetArchiveRepository, ITransactionRecordRepository transactionRecordRepository, + ITransactionStatisticsService transactionStatisticsService, IOpenAiService openAiService, IMessageService messageService, ILogger logger, @@ -133,7 +134,7 @@ public class BudgetService( .ToHashSet(); // 2. 获取分类统计 - var stats = await transactionRecordRepository.GetCategoryStatisticsAsync(date.Year, date.Month, transactionType); + var stats = await transactionStatisticsService.GetCategoryStatisticsAsync(date.Year, date.Month, transactionType); // 3. 过滤未覆盖的 return stats diff --git a/Service/Budget/BudgetStatsService.cs b/Service/Budget/BudgetStatsService.cs index b8014c3..64a30e6 100644 --- a/Service/Budget/BudgetStatsService.cs +++ b/Service/Budget/BudgetStatsService.cs @@ -12,7 +12,7 @@ public interface IBudgetStatsService public class BudgetStatsService( IBudgetRepository budgetRepository, IBudgetArchiveRepository budgetArchiveRepository, - ITransactionRecordRepository transactionRecordRepository, + ITransactionStatisticsService transactionStatisticsService, IDateTimeProvider dateTimeProvider, ILogger logger ) : IBudgetStatsService @@ -118,7 +118,7 @@ public class BudgetStatsService( // 获取趋势统计数据(仅用于图表展示) logger.LogDebug("开始获取交易趋势统计数据(用于图表)"); - var dailyStats = await transactionRecordRepository.GetFilteredTrendStatisticsAsync( + var dailyStats = await transactionStatisticsService.GetFilteredTrendStatisticsAsync( startDate, endDate, transactionType, @@ -419,7 +419,7 @@ public class BudgetStatsService( // 获取趋势统计数据(仅用于图表展示) logger.LogDebug("开始获取交易趋势统计数据(用于图表)"); - var dailyStats = await transactionRecordRepository.GetFilteredTrendStatisticsAsync( + var dailyStats = await transactionStatisticsService.GetFilteredTrendStatisticsAsync( startDate, endDate, transactionType, diff --git a/Service/SmartHandleService.cs b/Service/SmartHandleService.cs index f34332b..b5d42e2 100644 --- a/Service/SmartHandleService.cs +++ b/Service/SmartHandleService.cs @@ -11,6 +11,7 @@ public interface ISmartHandleService public class SmartHandleService( ITransactionRecordRepository transactionRepository, + ITransactionStatisticsService transactionStatisticsService, ITextSegmentService textSegmentService, ILogger logger, ITransactionCategoryRepository categoryRepository, @@ -61,7 +62,7 @@ public class SmartHandleService( { // 查询包含这些关键词且已分类的账单(带相关度评分) // minMatchRate=0.4 表示至少匹配40%的关键词才被认为是相似的 - var similarClassifiedWithScore = await transactionRepository.GetClassifiedByKeywordsWithScoreAsync(keywords, minMatchRate: 0.4, limit: 10); + var similarClassifiedWithScore = await transactionStatisticsService.GetClassifiedByKeywordsWithScoreAsync(keywords, minMatchRate: 0.4, limit: 10); if (similarClassifiedWithScore.Count > 0) { diff --git a/Service/TransactionStatisticsService.cs b/Service/TransactionStatisticsService.cs new file mode 100644 index 0000000..f6c6d11 --- /dev/null +++ b/Service/TransactionStatisticsService.cs @@ -0,0 +1,336 @@ +namespace Service; + +public interface ITransactionStatisticsService +{ + Task> GetDailyStatisticsAsync(int year, int month, string? savingClassify = null); + + Task> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null); + + Task GetMonthlyStatisticsAsync(int year, int month); + + Task> GetCategoryStatisticsAsync(int year, int month, TransactionType type); + + Task> GetTrendStatisticsAsync(int startYear, int startMonth, int monthCount); + + Task<(List list, int total)> GetReasonGroupsAsync(int pageIndex = 1, int pageSize = 20); + + Task> GetClassifiedByKeywordsWithScoreAsync(List keywords, double minMatchRate = 0.3, int limit = 10); + + Task> GetFilteredTrendStatisticsAsync( + DateTime startDate, + DateTime endDate, + TransactionType type, + IEnumerable classifies, + bool groupByMonth = false); + + Task> GetAmountGroupByClassifyAsync(DateTime startTime, DateTime endTime); +} + +public class TransactionStatisticsService( + ITransactionRecordRepository transactionRepository +) : ITransactionStatisticsService +{ + public async Task> GetDailyStatisticsAsync(int year, int month, string? savingClassify = null) + { + var startDate = new DateTime(year, month, 1); + var endDate = startDate.AddMonths(1); + + return await GetDailyStatisticsByRangeAsync(startDate, endDate, savingClassify); + } + + public async Task> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null) + { + var records = await transactionRepository.QueryAsync( + startDate: startDate, + endDate: endDate, + pageSize: int.MaxValue); + + return records + .GroupBy(t => t.OccurredAt.ToString("yyyy-MM-dd")) + .ToDictionary( + g => g.Key, + g => + { + var income = g.Where(t => t.Type == TransactionType.Income).Sum(t => Math.Abs(t.Amount)); + var expense = g.Where(t => t.Type == TransactionType.Expense).Sum(t => Math.Abs(t.Amount)); + + var saving = 0m; + if (!string.IsNullOrEmpty(savingClassify)) + { + saving = g.Where(t => savingClassify.Split(',').Contains(t.Classify)).Sum(t => Math.Abs(t.Amount)); + } + + return (count: g.Count(), expense, income, saving); + } + ); + } + + public async Task GetMonthlyStatisticsAsync(int year, int month) + { + var records = await transactionRepository.QueryAsync( + year: year, + month: month, + pageSize: int.MaxValue); + + var statistics = new MonthlyStatistics + { + Year = year, + Month = month + }; + + foreach (var record in records) + { + var amount = Math.Abs(record.Amount); + + if (record.Type == TransactionType.Expense) + { + statistics.TotalExpense += amount; + statistics.ExpenseCount++; + } + else if (record.Type == TransactionType.Income) + { + statistics.TotalIncome += amount; + statistics.IncomeCount++; + } + } + + statistics.Balance = statistics.TotalIncome - statistics.TotalExpense; + statistics.TotalCount = records.Count; + + return statistics; + } + + public async Task> GetCategoryStatisticsAsync(int year, int month, TransactionType type) + { + var records = await transactionRepository.QueryAsync( + year: year, + month: month, + type: type, + pageSize: int.MaxValue); + + var categoryGroups = records + .GroupBy(t => t.Classify) + .Select(g => new CategoryStatistics + { + Classify = g.Key, + Amount = g.Sum(t => Math.Abs(t.Amount)), + Count = g.Count() + }) + .OrderByDescending(c => c.Amount) + .ToList(); + + var total = categoryGroups.Sum(c => c.Amount); + if (total > 0) + { + foreach (var category in categoryGroups) + { + category.Percent = Math.Round((category.Amount / total) * 100, 1); + } + } + + return categoryGroups; + } + + public async Task> GetTrendStatisticsAsync(int startYear, int startMonth, int monthCount) + { + var trends = new List(); + + for (int i = 0; i < monthCount; i++) + { + var targetYear = startYear; + var targetMonth = startMonth + i; + + while (targetMonth > 12) + { + targetMonth -= 12; + targetYear++; + } + + var records = await transactionRepository.QueryAsync( + year: targetYear, + month: targetMonth, + pageSize: int.MaxValue); + + var expense = records.Where(t => t.Type == TransactionType.Expense).Sum(t => Math.Abs(t.Amount)); + var income = records.Where(t => t.Type == TransactionType.Income).Sum(t => Math.Abs(t.Amount)); + + trends.Add(new TrendStatistics + { + Year = targetYear, + Month = targetMonth, + Expense = expense, + Income = income, + Balance = income - expense + }); + } + + return trends; + } + + public async Task<(List list, int total)> GetReasonGroupsAsync(int pageIndex = 1, int pageSize = 20) + { + var records = await transactionRepository.QueryAsync( + pageSize: int.MaxValue); + + var unclassifiedRecords = records + .Where(t => !string.IsNullOrEmpty(t.Reason) && string.IsNullOrEmpty(t.Classify)) + .GroupBy(t => t.Reason) + .Select(g => new + { + Reason = g.Key, + Count = g.Count(), + TotalAmount = g.Sum(r => r.Amount), + SampleType = g.First().Type, + SampleClassify = g.First().Classify, + TransactionIds = g.Select(r => r.Id).ToList() + }) + .OrderByDescending(g => Math.Abs(g.TotalAmount)) + .ToList(); + + var total = unclassifiedRecords.Count; + var pagedGroups = unclassifiedRecords + .Skip((pageIndex - 1) * pageSize) + .Take(pageSize) + .Select(g => new ReasonGroupDto + { + Reason = g.Reason, + Count = g.Count, + SampleType = g.SampleType, + SampleClassify = g.SampleClassify, + TransactionIds = g.TransactionIds, + TotalAmount = Math.Abs(g.TotalAmount) + }) + .ToList(); + + return (pagedGroups, total); + } + + public async Task> GetClassifiedByKeywordsWithScoreAsync(List keywords, double minMatchRate = 0.3, int limit = 10) + { + if (keywords.Count == 0) + { + return []; + } + + var candidates = await transactionRepository.GetClassifiedByKeywordsAsync(keywords, limit: int.MaxValue); + + var scoredResults = candidates + .Select(record => + { + var matchedCount = keywords.Count(keyword => record.Reason.Contains(keyword, StringComparison.OrdinalIgnoreCase)); + var matchRate = (double)matchedCount / keywords.Count; + + var exactMatchBonus = keywords.Any(k => record.Reason.Equals(k, StringComparison.OrdinalIgnoreCase)) ? 0.2 : 0.0; + + var avgKeywordLength = keywords.Average(k => k.Length); + var lengthSimilarity = 1.0 - Math.Min(1.0, Math.Abs(record.Reason.Length - avgKeywordLength) / Math.Max(record.Reason.Length, avgKeywordLength)); + var lengthBonus = lengthSimilarity * 0.1; + + var score = matchRate + exactMatchBonus + lengthBonus; + return (record, score); + }) + .Where(x => x.score >= minMatchRate) + .OrderByDescending(x => x.score) + .ThenByDescending(x => x.record.OccurredAt) + .Take(limit) + .ToList(); + + return scoredResults; + } + + public async Task> GetFilteredTrendStatisticsAsync( + DateTime startDate, + DateTime endDate, + TransactionType type, + IEnumerable classifies, + bool groupByMonth = false) + { + var records = await transactionRepository.QueryAsync( + startDate: startDate, + endDate: endDate, + type: type, + classifies: classifies?.ToArray(), + pageSize: int.MaxValue); + + if (groupByMonth) + { + return records + .GroupBy(t => new DateTime(t.OccurredAt.Year, t.OccurredAt.Month, 1)) + .ToDictionary(g => g.Key, g => g.Sum(x => Math.Abs(x.Amount))); + } + + return records + .GroupBy(t => t.OccurredAt.Date) + .ToDictionary(g => g.Key, g => g.Sum(x => Math.Abs(x.Amount))); + } + + public async Task> GetAmountGroupByClassifyAsync(DateTime startTime, DateTime endTime) + { + var records = await transactionRepository.QueryAsync( + startDate: startTime, + endDate: endTime, + pageSize: int.MaxValue); + + return records + .GroupBy(t => new { t.Classify, t.Type }) + .ToDictionary(g => (g.Key.Classify, g.Key.Type), g => g.Sum(t => t.Amount)); + } +} + +public record ReasonGroupDto +{ + public string Reason { get; set; } = string.Empty; + + public int Count { get; set; } + + public TransactionType SampleType { get; set; } + + public string SampleClassify { get; set; } = string.Empty; + + public List TransactionIds { get; set; } = []; + + public decimal TotalAmount { get; set; } +} + +public record MonthlyStatistics +{ + public int Year { get; set; } + + public int Month { get; set; } + + public decimal TotalExpense { get; set; } + + public decimal TotalIncome { get; set; } + + public decimal Balance { get; set; } + + public int ExpenseCount { get; set; } + + public int IncomeCount { get; set; } + + public int TotalCount { get; set; } +} + +public record CategoryStatistics +{ + public string Classify { get; set; } = string.Empty; + + public decimal Amount { get; set; } + + public int Count { get; set; } + + public decimal Percent { get; set; } +} + +public record TrendStatistics +{ + public int Year { get; set; } + + public int Month { get; set; } + + public decimal Expense { get; set; } + + public decimal Income { get; set; } + + public decimal Balance { get; set; } +} \ No newline at end of file diff --git a/Web/src/api/AGENTS.md b/Web/src/api/AGENTS.md new file mode 100644 index 0000000..3871382 --- /dev/null +++ b/Web/src/api/AGENTS.md @@ -0,0 +1,59 @@ +# API CLIENTS KNOWLEDGE BASE + +**Generated:** 2026-01-28 +**Parent:** EmailBill/AGENTS.md + +## OVERVIEW +Axios-based HTTP client modules for backend API integration with request/response interceptors. + +## STRUCTURE +``` +Web/src/api/ +├── request.js # Base HTTP client setup +├── auth.js # Authentication API +├── budget.js # Budget management API +├── transactionRecord.js # Transaction CRUD API +├── transactionCategory.js # Category management +├── transactionPeriodic.js # Periodic transactions +├── statistics.js # Analytics API +├── message.js # Message API +├── notification.js # Push notifications +├── emailRecord.js # Email records +├── config.js # Configuration API +├── billImport.js # Bill import +├── log.js # Application logs +└── job.js # Background job management +``` + +## WHERE TO LOOK +| Task | Location | Notes | +|------|----------|-------| +| Base HTTP setup | request.js | Axios interceptors, error handling | +| Authentication | auth.js | Login, token management | +| Budget data | budget.js | Budget CRUD, statistics | +| Transactions | transactionRecord.js | Transaction operations | +| Categories | transactionCategory.js | Category management | +| Statistics | statistics.js | Analytics, reports | +| Notifications | notification.js | Push subscription handling | + +## CONVENTIONS +- All functions return Promises with async/await +- Error handling via try/catch with user messages +- HTTP methods: get, post, put, delete mapping to REST +- Request/response data transformation +- Token-based authentication via headers +- Consistent error message format + +## ANTI-PATTERNS (THIS LAYER) +- Never fetch directly without going through these modules +- Don't hardcode API endpoints (use environment variables) +- Avoid synchronous operations +- Don't duplicate request logic across components +- No business logic in API clients + +## UNIQUE STYLES +- Chinese error messages for user feedback +- Automatic token refresh handling +- Request/response logging for debugging +- Paged query patterns for list endpoints +- File upload handling for imports \ No newline at end of file diff --git a/Web/src/views/AGENTS.md b/Web/src/views/AGENTS.md new file mode 100644 index 0000000..eec5b31 --- /dev/null +++ b/Web/src/views/AGENTS.md @@ -0,0 +1,60 @@ +# FRONTEND VIEWS KNOWLEDGE BASE + +**Generated:** 2026-01-28 +**Parent:** EmailBill/AGENTS.md + +## OVERVIEW +Vue 3 views using Composition API with Vant UI components for mobile-first budget tracking. + +## STRUCTURE +``` +Web/src/views/ +├── BudgetView.vue # Main budget management +├── TransactionsRecord.vue # Transaction list and CRUD +├── StatisticsView.vue # Charts and analytics +├── LoginView.vue # Authentication +├── CalendarView.vue # Calendar-based viewing +├── Classification* # Transaction classification views +│ ├── ClassificationSmart.vue # AI-powered classification +│ ├── ClassificationEdit.vue # Manual classification +│ ├── ClassificationBatch.vue # Batch operations +│ └── ClassificationNLP.vue # NLP classification +├── BillAnalysisView.vue # Bill analysis +├── SettingView.vue # App settings +├── MessageView.vue # Message management +├── EmailRecord.vue # Email records +└── PeriodicRecord.vue # Periodic transactions +``` + +## WHERE TO LOOK +| Task | Location | Notes | +|------|----------|-------| +| Budget management | BudgetView.vue | Main budget interface | +| Transactions | TransactionsRecord.vue | CRUD operations | +| Statistics | StatisticsView.vue | Charts, analytics | +| Classification | Classification* | Transaction categorization | +| Authentication | LoginView.vue | User login flow | +| Settings | SettingView.vue | App configuration | +| Email features | EmailRecord.vue | Email integration | + +## CONVENTIONS +- Vue 3 Composition API with `