重构账单查询sql
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 26s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 26s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
This commit is contained in:
55
Service/AGENTS.md
Normal file
55
Service/AGENTS.md
Normal file
@@ -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
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -29,6 +29,7 @@ public class BudgetService(
|
||||
IBudgetRepository budgetRepository,
|
||||
IBudgetArchiveRepository budgetArchiveRepository,
|
||||
ITransactionRecordRepository transactionRecordRepository,
|
||||
ITransactionStatisticsService transactionStatisticsService,
|
||||
IOpenAiService openAiService,
|
||||
IMessageService messageService,
|
||||
ILogger<BudgetService> 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
|
||||
|
||||
@@ -12,7 +12,7 @@ public interface IBudgetStatsService
|
||||
public class BudgetStatsService(
|
||||
IBudgetRepository budgetRepository,
|
||||
IBudgetArchiveRepository budgetArchiveRepository,
|
||||
ITransactionRecordRepository transactionRecordRepository,
|
||||
ITransactionStatisticsService transactionStatisticsService,
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
ILogger<BudgetStatsService> 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,
|
||||
|
||||
@@ -11,6 +11,7 @@ public interface ISmartHandleService
|
||||
|
||||
public class SmartHandleService(
|
||||
ITransactionRecordRepository transactionRepository,
|
||||
ITransactionStatisticsService transactionStatisticsService,
|
||||
ITextSegmentService textSegmentService,
|
||||
ILogger<SmartHandleService> 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)
|
||||
{
|
||||
|
||||
336
Service/TransactionStatisticsService.cs
Normal file
336
Service/TransactionStatisticsService.cs
Normal file
@@ -0,0 +1,336 @@
|
||||
namespace Service;
|
||||
|
||||
public interface ITransactionStatisticsService
|
||||
{
|
||||
Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsAsync(int year, int month, string? savingClassify = null);
|
||||
|
||||
Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null);
|
||||
|
||||
Task<MonthlyStatistics> GetMonthlyStatisticsAsync(int year, int month);
|
||||
|
||||
Task<List<CategoryStatistics>> GetCategoryStatisticsAsync(int year, int month, TransactionType type);
|
||||
|
||||
Task<List<TrendStatistics>> GetTrendStatisticsAsync(int startYear, int startMonth, int monthCount);
|
||||
|
||||
Task<(List<ReasonGroupDto> list, int total)> GetReasonGroupsAsync(int pageIndex = 1, int pageSize = 20);
|
||||
|
||||
Task<List<(TransactionRecord record, double relevanceScore)>> GetClassifiedByKeywordsWithScoreAsync(List<string> keywords, double minMatchRate = 0.3, int limit = 10);
|
||||
|
||||
Task<Dictionary<DateTime, decimal>> GetFilteredTrendStatisticsAsync(
|
||||
DateTime startDate,
|
||||
DateTime endDate,
|
||||
TransactionType type,
|
||||
IEnumerable<string> classifies,
|
||||
bool groupByMonth = false);
|
||||
|
||||
Task<Dictionary<(string, TransactionType), decimal>> GetAmountGroupByClassifyAsync(DateTime startTime, DateTime endTime);
|
||||
}
|
||||
|
||||
public class TransactionStatisticsService(
|
||||
ITransactionRecordRepository transactionRepository
|
||||
) : ITransactionStatisticsService
|
||||
{
|
||||
public async Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> 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<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> 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<MonthlyStatistics> 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<List<CategoryStatistics>> 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<List<TrendStatistics>> GetTrendStatisticsAsync(int startYear, int startMonth, int monthCount)
|
||||
{
|
||||
var trends = new List<TrendStatistics>();
|
||||
|
||||
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<ReasonGroupDto> 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<List<(TransactionRecord record, double relevanceScore)>> GetClassifiedByKeywordsWithScoreAsync(List<string> 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<Dictionary<DateTime, decimal>> GetFilteredTrendStatisticsAsync(
|
||||
DateTime startDate,
|
||||
DateTime endDate,
|
||||
TransactionType type,
|
||||
IEnumerable<string> 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<Dictionary<(string, TransactionType), decimal>> 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<long> 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; }
|
||||
}
|
||||
Reference in New Issue
Block a user