2 Commits

Author SHA1 Message Date
SunCheng
9e14849014 feat: 添加预算统计服务增强和日志系统改进
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 24s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
1. 新增 BudgetStatsService:将预算统计逻辑从 BudgetService 中提取为独立服务,支持月度和年度统计,包含归档数据支持和硬性预算调整算法
2. 日志系统增强:添加请求ID追踪功能,支持通过请求ID查询关联日志,新增类名筛选功能
3. 日志解析优化:修复类名解析逻辑,正确提取 SourceContext 中的类名信息
4. 代码清理:移除不需要的方法名相关代码,简化日志筛选逻辑
2026-01-22 19:07:10 +08:00
SunCheng
e2c0ab5389 构建优化 2026-01-22 13:28:24 +08:00
12 changed files with 2069 additions and 499 deletions

View File

@@ -13,13 +13,64 @@ jobs:
name: Build Docker Image name: Build Docker Image
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# ✅ 使用 Gitea 兼容的代码检出方式 # 网络连接测试
- name: Test network connectivity
run: |
echo "Testing network connectivity to Gitea server..."
MAX_RETRIES=5
RETRY_DELAY=10
for i in $(seq 1 $MAX_RETRIES); do
echo "Network test attempt $i/$MAX_RETRIES"
if curl -s --connect-timeout 10 -f http://192.168.31.14:14200 > /dev/null; then
echo "✅ Gitea server is reachable"
exit 0
else
echo "❌ Network test failed, waiting $RETRY_DELAY seconds..."
sleep $RETRY_DELAY
fi
done
echo "❌ All network tests failed"
exit 1
- name: Checkout code - name: Checkout code
uses: https://gitea.com/actions/checkout@v3 uses: https://gitea.com/actions/checkout@v3
with: # 添加重试策略
gitea-server: http://192.168.31.14:14200 continue-on-error: true
token: ${{ secrets.GITEA_TOKEN }}
ref: ${{ gitea.ref }} # 必须传递 Gitea 的 ref 参数 # 手动重试逻辑
- name: Retry checkout if failed
if: steps.checkout.outcome == 'failure'
run: |
echo "First checkout attempt failed, retrying..."
MAX_RETRIES=3
RETRY_DELAY=15
for i in $(seq 1 $MAX_RETRIES); do
echo "Retry attempt $i/$MAX_RETRIES"
# 清理可能的部分检出
rm -rf .git || true
git clean -fdx || true
# 使用git命令直接检出
git init
git remote add origin http://192.168.31.14:14200/${{ gitea.repository }}
git config http.extraHeader "Authorization: Bearer ${{ secrets.GITEA_TOKEN }}"
if git fetch --depth=1 origin "${{ gitea.ref }}"; then
git checkout FETCH_HEAD
echo "Checkout successful on retry $i"
exit 0
fi
echo "Retry $i failed, waiting $RETRY_DELAY seconds..."
sleep $RETRY_DELAY
done
echo "All checkout attempts failed"
exit 1
- name: Cleanup old containers - name: Cleanup old containers
run: | run: |

View File

@@ -33,7 +33,8 @@ public class BudgetService(
IMessageService messageService, IMessageService messageService,
ILogger<BudgetService> logger, ILogger<BudgetService> logger,
IBudgetSavingsService budgetSavingsService, IBudgetSavingsService budgetSavingsService,
IDateTimeProvider dateTimeProvider IDateTimeProvider dateTimeProvider,
IBudgetStatsService budgetStatsService
) : IBudgetService ) : IBudgetService
{ {
public async Task<List<BudgetResult>> GetListAsync(DateTime referenceDate) public async Task<List<BudgetResult>> GetListAsync(DateTime referenceDate)
@@ -109,17 +110,7 @@ public class BudgetService(
public async Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate) public async Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate)
{ {
var budgets = await GetListAsync(referenceDate); return await budgetStatsService.GetCategoryStatsAsync(category, referenceDate);
var result = new BudgetCategoryStats();
// 获取月度统计
result.Month = await CalculateCategoryStatsAsync(budgets, category, BudgetPeriodType.Month, referenceDate);
// 获取年度统计
result.Year = await CalculateCategoryStatsAsync(budgets, category, BudgetPeriodType.Year, referenceDate);
return result;
} }
public async Task<List<UncoveredCategoryDetail>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null) public async Task<List<UncoveredCategoryDetail>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null)
@@ -163,179 +154,7 @@ public class BudgetService(
return archive?.Summary; return archive?.Summary;
} }
private async Task<BudgetStatsDto> CalculateCategoryStatsAsync(
List<BudgetResult> budgets,
BudgetCategory category,
BudgetPeriodType statType,
DateTime referenceDate)
{
var result = new BudgetStatsDto
{
PeriodType = statType,
Rate = 0,
Current = 0,
Limit = 0,
Count = 0
};
// 获取当前分类下所有预算,排除不记额预算
var relevant = budgets
.Where(b => b.Category == category && !b.NoLimit)
.ToList();
// 月度统计中,只包含月度预算;年度统计中,包含所有预算
if (statType == BudgetPeriodType.Month)
{
relevant = relevant.Where(b => b.Type == BudgetPeriodType.Month).ToList();
}
if (relevant.Count == 0)
{
return result;
}
result.Count = relevant.Count;
decimal totalCurrent = 0;
decimal totalLimit = 0;
// 是否可以使用趋势统计来计算实际发生额(避免多预算重复计入同一笔账)
var transactionType = category switch
{
BudgetCategory.Expense => TransactionType.Expense,
BudgetCategory.Income => TransactionType.Income,
_ => TransactionType.None
};
foreach (var budget in relevant)
{
// 限额折算
var itemLimit = budget.Limit;
if (statType == BudgetPeriodType.Year && budget.Type == BudgetPeriodType.Month)
{
// 年度视图下,月度预算折算为年度
itemLimit = budget.Limit * 12;
}
totalLimit += itemLimit;
// 先逐预算累加当前值(作为后备值)
var selectedCategories = string.Join(',', budget.SelectedCategories);
var currentAmount = await CalculateCurrentAmountAsync(new()
{
Name = budget.Name,
Type = budget.Type,
Limit = budget.Limit,
Category = budget.Category,
SelectedCategories = selectedCategories,
StartDate = new DateTime(referenceDate.Year, referenceDate.Month, 1),
IsMandatoryExpense = budget.IsMandatoryExpense
}, referenceDate);
if (statType == BudgetPeriodType.Month)
{
totalCurrent += currentAmount;
}
else if (statType == BudgetPeriodType.Year)
{
// 年度视图下,累加所有预算的当前值
totalCurrent += currentAmount;
}
}
result.Limit = totalLimit;
// 计算每日/每月趋势
if (transactionType != TransactionType.None)
{
var hasGlobalBudget = relevant.Any(b => b.SelectedCategories.Length == 0);
var allClassifies = hasGlobalBudget
? []
: relevant
.SelectMany(b => b.SelectedCategories)
.Distinct()
.ToList();
DateTime startDate, endDate;
bool groupByMonth;
if (statType == BudgetPeriodType.Month)
{
startDate = new DateTime(referenceDate.Year, referenceDate.Month, 1);
endDate = startDate.AddMonths(1).AddDays(-1);
groupByMonth = false;
}
else // Year
{
startDate = new DateTime(referenceDate.Year, 1, 1);
endDate = startDate.AddYears(1).AddDays(-1);
groupByMonth = true;
}
var dailyStats = await transactionRecordRepository.GetFilteredTrendStatisticsAsync(
startDate,
endDate,
transactionType,
allClassifies,
groupByMonth);
decimal accumulated = 0;
decimal lastValidAccumulated = 0;
var now = dateTimeProvider.Now;
if (statType == BudgetPeriodType.Month)
{
var daysInMonth = DateTime.DaysInMonth(startDate.Year, startDate.Month);
for (int i = 1; i <= daysInMonth; i++)
{
var currentDate = new DateTime(startDate.Year, startDate.Month, i);
if (currentDate.Date > now.Date)
{
result.Trend.Add(null);
continue;
}
if (dailyStats.TryGetValue(currentDate.Date, out var amount))
{
accumulated += amount;
lastValidAccumulated = accumulated;
}
result.Trend.Add(accumulated);
}
}
else // Year
{
for (int i = 1; i <= 12; i++)
{
var currentMonthDate = new DateTime(startDate.Year, i, 1);
if (currentMonthDate.Year > now.Year || (currentMonthDate.Year == now.Year && i > now.Month))
{
result.Trend.Add(null);
continue;
}
if (dailyStats.TryGetValue(currentMonthDate, out var amount))
{
accumulated += amount;
lastValidAccumulated = accumulated;
}
result.Trend.Add(accumulated);
}
}
// 如果有有效的趋势数据,使用去重后的实际发生额(趋势的累计值),避免同一账单被多预算重复计入
// 否则使用前面逐预算累加的值(作为后备)
if (lastValidAccumulated > 0)
{
totalCurrent = lastValidAccumulated;
}
}
result.Current = totalCurrent;
result.Rate = totalLimit > 0 ? totalCurrent / totalLimit * 100 : 0;
return result;
}
public async Task<string> ArchiveBudgetsAsync(int year, int month) public async Task<string> ArchiveBudgetsAsync(int year, int month)
{ {

View File

@@ -0,0 +1,940 @@
namespace Service.Budget;
public interface IBudgetStatsService
{
/// <summary>
/// 获取指定分类的统计信息(月度和年度)
/// </summary>
Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate);
}
[UsedImplicitly]
public class BudgetStatsService(
IBudgetRepository budgetRepository,
IBudgetArchiveRepository budgetArchiveRepository,
ITransactionRecordRepository transactionRecordRepository,
IDateTimeProvider dateTimeProvider,
ILogger<BudgetStatsService> logger
) : IBudgetStatsService
{
public async Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate)
{
logger.LogInformation("开始计算分类统计信息: Category={Category}, ReferenceDate={ReferenceDate:yyyy-MM-dd}",
category, referenceDate);
var result = new BudgetCategoryStats();
try
{
// 获取月度统计
logger.LogDebug("开始计算月度统计");
result.Month = await CalculateMonthlyCategoryStatsAsync(category, referenceDate);
logger.LogInformation("月度统计计算完成: Count={Count}, Limit={Limit}, Current={Current}, Rate={Rate:F2}%",
result.Month.Count, result.Month.Limit, result.Month.Current, result.Month.Rate);
// 获取年度统计
logger.LogDebug("开始计算年度统计");
result.Year = await CalculateYearlyCategoryStatsAsync(category, referenceDate);
logger.LogInformation("年度统计计算完成: Count={Count}, Limit={Limit}, Current={Current}, Rate={Rate:F2}%",
result.Year.Count, result.Year.Limit, result.Year.Current, result.Year.Rate);
logger.LogInformation("分类统计信息计算完成");
}
catch (Exception ex)
{
logger.LogError(ex, "计算分类统计信息时发生错误: Category={Category}, ReferenceDate={ReferenceDate:yyyy-MM-dd}",
category, referenceDate);
throw;
}
return result;
}
private async Task<BudgetStatsDto> CalculateMonthlyCategoryStatsAsync(BudgetCategory category, DateTime referenceDate)
{
logger.LogDebug("开始计算月度分类统计: Category={Category}, ReferenceDate={ReferenceDate:yyyy-MM}",
category, referenceDate);
var result = new BudgetStatsDto
{
PeriodType = BudgetPeriodType.Month,
Rate = 0,
Current = 0,
Limit = 0,
Count = 0
};
// 1. 获取所有预算(包含归档数据)
logger.LogDebug("开始获取预算数据(包含归档)");
var budgets = await GetAllBudgetsWithArchiveAsync(category, BudgetPeriodType.Month, referenceDate);
logger.LogDebug("获取到 {BudgetCount} 个预算", budgets.Count);
if (budgets.Count == 0)
{
logger.LogDebug("未找到相关预算,返回空结果");
return result;
}
result.Count = budgets.Count;
// 2. 计算限额总值(考虑不限额预算的特殊处理)
logger.LogDebug("开始计算限额总值,共 {BudgetCount} 个预算", budgets.Count);
decimal totalLimit = 0;
int budgetIndex = 0;
foreach (var budget in budgets)
{
budgetIndex++;
var itemLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Month, referenceDate);
logger.LogInformation("预算 {BudgetIndex}/{BudgetCount}: {BudgetName} (ID={BudgetId}) - 预算金额: {BudgetLimit}, 实际金额: {CurrentAmount}, 计算算法: {Algorithm}",
budgetIndex, budgets.Count, budget.Name, budget.Id, budget.Limit, budget.Current,
budget.NoLimit ? "不限额预算" : budget.IsMandatoryExpense ? "硬性预算" : "普通预算");
logger.LogTrace("预算 {BudgetName} (ID={BudgetId}) 计算后限额: {ItemLimit}",
budget.Name, budget.Id, itemLimit);
totalLimit += itemLimit;
}
result.Limit = totalLimit;
logger.LogDebug("限额总值计算完成: {TotalLimit}", totalLimit);
// 3. 计算当前实际值(避免重复计算同一笔交易)
var transactionType = category switch
{
BudgetCategory.Expense => TransactionType.Expense,
BudgetCategory.Income => TransactionType.Income,
_ => TransactionType.None
};
logger.LogDebug("交易类型: {TransactionType}", transactionType);
// 计算当前实际值,考虑硬性预算的特殊逻辑
decimal totalCurrent = 0;
var now = dateTimeProvider.Now;
var (startDate, endDate) = GetStatPeriodRange(BudgetPeriodType.Month, referenceDate);
logger.LogDebug("统计时间段: {StartDate:yyyy-MM-dd} 到 {EndDate:yyyy-MM-dd}", startDate, endDate);
if (transactionType != TransactionType.None)
{
// 获取所有相关分类
var allClassifies = budgets
.SelectMany(b => b.SelectedCategories)
.Distinct()
.ToList();
logger.LogDebug("相关分类数量: {ClassifyCount}", allClassifies.Count);
// 获取趋势统计数据(去重计算)
logger.LogDebug("开始获取交易趋势统计数据");
var dailyStats = await transactionRecordRepository.GetFilteredTrendStatisticsAsync(
startDate,
endDate,
transactionType,
allClassifies,
false);
logger.LogDebug("获取到 {DayCount} 天的交易数据", dailyStats.Count);
// 计算累计值
decimal accumulated = 0;
var daysInMonth = DateTime.DaysInMonth(startDate.Year, startDate.Month);
logger.LogDebug("本月天数: {DaysInMonth}", daysInMonth);
for (int i = 1; i <= daysInMonth; i++)
{
var currentDate = new DateTime(startDate.Year, startDate.Month, i);
if (currentDate.Date > now.Date)
{
result.Trend.Add(null);
logger.LogTrace("日期 {CurrentDate:yyyy-MM-dd} 为未来日期,趋势数据为 null", currentDate);
continue;
}
if (dailyStats.TryGetValue(currentDate.Date, out var amount))
{
accumulated += amount;
logger.LogTrace("日期 {CurrentDate:yyyy-MM-dd}: 金额={Amount}, 累计={Accumulated}",
currentDate, amount, accumulated);
}
else
{
logger.LogTrace("日期 {CurrentDate:yyyy-MM-dd}: 无交易数据,累计={Accumulated}",
currentDate, accumulated);
}
result.Trend.Add(accumulated);
}
totalCurrent = accumulated;
logger.LogDebug("交易累计值计算完成: {TotalCurrent}", totalCurrent);
}
else
{
// 对于非收入/支出分类,使用逐预算累加
logger.LogDebug("非收入/支出分类,使用逐预算累加,共 {BudgetCount} 个预算", budgets.Count);
budgetIndex = 0;
foreach (var budget in budgets)
{
budgetIndex++;
var currentAmount = await CalculateCurrentAmountAsync(budget, BudgetPeriodType.Month, referenceDate);
logger.LogInformation("预算 {BudgetIndex}/{BudgetCount}: {BudgetName} (ID={BudgetId}) - 实际金额计算: 预算金额={BudgetLimit}, 当前值={CurrentAmount}, 算法={Algorithm}",
budgetIndex, budgets.Count, budget.Name, budget.Id, budget.Limit, currentAmount,
budget.IsArchive ? "归档数据" : "实时计算");
logger.LogTrace("预算 {BudgetName} (ID={BudgetId}) 当前值: {CurrentAmount}",
budget.Name, budget.Id, currentAmount);
totalCurrent += currentAmount;
}
logger.LogDebug("预算累加完成: {TotalCurrent}", totalCurrent);
}
// 对于硬性预算如果当前月份且实际值为0需要按时间比例计算
if (transactionType == TransactionType.Expense)
{
logger.LogDebug("开始应用硬性预算调整,共 {BudgetCount} 个支出预算", budgets.Count);
var beforeAdjustment = totalCurrent;
totalCurrent = ApplyMandatoryBudgetAdjustment(budgets, totalCurrent, referenceDate, BudgetPeriodType.Month);
if (Math.Abs(beforeAdjustment - totalCurrent) > 0.01m)
{
logger.LogInformation("硬性预算调整完成: 调整前={BeforeAdjustment}, 调整后={AfterAdjustment}, 调整金额={AdjustmentAmount}",
beforeAdjustment, totalCurrent, totalCurrent - beforeAdjustment);
logger.LogDebug("硬性预算调整算法: 当前月份={ReferenceDate:yyyy-MM}, 硬性预算按天数比例累加计算", referenceDate);
}
else
{
logger.LogDebug("硬性预算调整未改变值");
}
}
result.Current = totalCurrent;
// 4. 计算使用率
result.Rate = totalLimit > 0 ? result.Current / totalLimit * 100 : 0;
logger.LogDebug("使用率计算完成: {Rate:F2}%", result.Rate);
// 5. 生成计算明细汇总日志
var limitParts = new List<string>();
var currentParts = new List<string>();
budgetIndex = 0;
foreach (var budget in budgets)
{
budgetIndex++;
var itemLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Month, referenceDate);
var limitPart = budget.IsArchive
? $"{budget.Name}({budget.ArchiveMonth}月归档){itemLimit}元"
: $"{budget.Name}{itemLimit}元";
limitParts.Add(limitPart);
var currentPart = budget.IsArchive
? $"{budget.Name}({budget.ArchiveMonth}月归档){budget.Current}元"
: $"{budget.Name}{budget.Current}元";
currentParts.Add(currentPart);
}
var limitSummary = string.Join(" + ", limitParts);
var currentSummary = string.Join(" + ", currentParts);
logger.LogInformation("月度统计计算明细: 预算={LimitSummary}={TotalLimit}元, 已支出={CurrentSummary}={TotalCurrent}元, 使用率={Rate:F2}%",
limitSummary, totalLimit, currentSummary, totalCurrent, result.Rate);
logger.LogDebug("月度分类统计计算完成");
return result;
}
private async Task<BudgetStatsDto> CalculateYearlyCategoryStatsAsync(BudgetCategory category, DateTime referenceDate)
{
logger.LogDebug("开始计算年度分类统计: Category={Category}, ReferenceDate={ReferenceDate:yyyy}",
category, referenceDate);
var result = new BudgetStatsDto
{
PeriodType = BudgetPeriodType.Year,
Rate = 0,
Current = 0,
Limit = 0,
Count = 0
};
// 1. 获取所有预算(包含归档数据)
logger.LogDebug("开始获取预算数据(包含归档)");
var budgets = await GetAllBudgetsWithArchiveAsync(category, BudgetPeriodType.Year, referenceDate);
logger.LogDebug("获取到 {BudgetCount} 个预算", budgets.Count);
if (budgets.Count == 0)
{
logger.LogDebug("未找到相关预算,返回空结果");
return result;
}
result.Count = budgets.Count;
// 2. 计算限额总值(考虑不限额预算的特殊处理)
logger.LogDebug("开始计算年度限额总值,共 {BudgetCount} 个预算", budgets.Count);
decimal totalLimit = 0;
int budgetIndex = 0;
foreach (var budget in budgets)
{
budgetIndex++;
var itemLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Year, referenceDate);
logger.LogInformation("年度预算 {BudgetIndex}/{BudgetCount}: {BudgetName} (ID={BudgetId}) - 原始预算金额: {BudgetLimit}, 当前实际金额: {CurrentAmount}, 预算类型: {BudgetType}, 算法: {Algorithm}",
budgetIndex, budgets.Count, budget.Name, budget.Id, budget.Limit, budget.Current,
budget.Type == BudgetPeriodType.Month ? "月度预算" : "年度预算",
budget.NoLimit ? "不限额预算" : budget.IsMandatoryExpense ? "硬性预算" : "普通预算");
logger.LogTrace("预算 {BudgetName} (ID={BudgetId}) 年度计算后限额: {ItemLimit}",
budget.Name, budget.Id, itemLimit);
totalLimit += itemLimit;
}
result.Limit = totalLimit;
logger.LogDebug("年度限额总值计算完成: {TotalLimit}", totalLimit);
// 3. 计算当前实际值(避免重复计算同一笔交易)
var transactionType = category switch
{
BudgetCategory.Expense => TransactionType.Expense,
BudgetCategory.Income => TransactionType.Income,
_ => TransactionType.None
};
logger.LogDebug("交易类型: {TransactionType}", transactionType);
// 计算当前实际值,考虑硬性预算的特殊逻辑
decimal totalCurrent = 0;
var now = dateTimeProvider.Now;
var (startDate, endDate) = GetStatPeriodRange(BudgetPeriodType.Year, referenceDate);
logger.LogDebug("统计时间段: {StartDate:yyyy-MM-dd} 到 {EndDate:yyyy-MM-dd}", startDate, endDate);
if (transactionType != TransactionType.None)
{
// 获取所有相关分类
var allClassifies = budgets
.SelectMany(b => b.SelectedCategories)
.Distinct()
.ToList();
logger.LogDebug("相关分类数量: {ClassifyCount}", allClassifies.Count);
// 获取趋势统计数据(去重计算)
logger.LogDebug("开始获取交易趋势统计数据");
var dailyStats = await transactionRecordRepository.GetFilteredTrendStatisticsAsync(
startDate,
endDate,
transactionType,
allClassifies,
true);
logger.LogDebug("获取到 {MonthCount} 个月的交易数据", dailyStats.Count);
// 计算累计值
decimal accumulated = 0;
for (int i = 1; i <= 12; i++)
{
var currentMonthDate = new DateTime(startDate.Year, i, 1);
if (currentMonthDate.Year > now.Year || (currentMonthDate.Year == now.Year && i > now.Month))
{
result.Trend.Add(null);
logger.LogTrace("月份 {Month:yyyy-MM} 为未来月份,趋势数据为 null", currentMonthDate);
continue;
}
if (dailyStats.TryGetValue(currentMonthDate, out var amount))
{
accumulated += amount;
logger.LogTrace("月份 {Month:yyyy-MM}: 金额={Amount}, 累计={Accumulated}",
currentMonthDate, amount, accumulated);
}
else
{
logger.LogTrace("月份 {Month:yyyy-MM}: 无交易数据,累计={Accumulated}",
currentMonthDate, accumulated);
}
result.Trend.Add(accumulated);
}
totalCurrent = accumulated;
logger.LogDebug("交易累计值计算完成: {TotalCurrent}", totalCurrent);
}
else
{
// 对于非收入/支出分类,使用逐预算累加
logger.LogDebug("非收入/支出分类,使用逐预算累加,共 {BudgetCount} 个预算", budgets.Count);
budgetIndex = 0;
foreach (var budget in budgets)
{
budgetIndex++;
var currentAmount = await CalculateCurrentAmountAsync(budget, BudgetPeriodType.Year, referenceDate);
logger.LogInformation("年度预算 {BudgetIndex}/{BudgetCount}: {BudgetName} (ID={BudgetId}) - 实际金额计算: 原始预算={BudgetLimit}, 年度实际值={CurrentAmount}, 数据来源: {DataSource}",
budgetIndex, budgets.Count, budget.Name, budget.Id, budget.Limit, currentAmount,
budget.IsArchive ? "归档数据" : "实时计算");
logger.LogTrace("预算 {BudgetName} (ID={BudgetId}) 年度当前值: {CurrentAmount}",
budget.Name, budget.Id, currentAmount);
totalCurrent += currentAmount;
}
logger.LogDebug("年度预算累加完成: {TotalCurrent}", totalCurrent);
}
// 对于硬性预算如果当前年份且实际值为0需要按时间比例计算
if (transactionType == TransactionType.Expense)
{
logger.LogDebug("开始应用年度硬性预算调整,共 {BudgetCount} 个支出预算", budgets.Count);
var beforeAdjustment = totalCurrent;
totalCurrent = ApplyMandatoryBudgetAdjustment(budgets, totalCurrent, referenceDate, BudgetPeriodType.Year);
if (Math.Abs(beforeAdjustment - totalCurrent) > 0.01m)
{
logger.LogInformation("年度硬性预算调整完成: 调整前={BeforeAdjustment}, 调整后={AfterAdjustment}, 调整金额={AdjustmentAmount}",
beforeAdjustment, totalCurrent, totalCurrent - beforeAdjustment);
logger.LogDebug("年度硬性预算调整算法: 当前年份={ReferenceDate:yyyy}, 硬性预算按天数比例累加计算", referenceDate);
}
else
{
logger.LogDebug("年度硬性预算调整未改变值");
}
}
result.Current = totalCurrent;
// 4. 计算使用率
result.Rate = totalLimit > 0 ? result.Current / totalLimit * 100 : 0;
logger.LogDebug("使用率计算完成: {Rate:F2}%", result.Rate);
// 5. 生成计算明细汇总日志
var limitParts = new List<string>();
var currentParts = new List<string>();
budgetIndex = 0;
foreach (var budget in budgets)
{
budgetIndex++;
var itemLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Year, referenceDate);
var limitPart = budget.IsArchive
? $"{budget.Name}(归档){itemLimit}元"
: budget.RemainingMonths > 0
? $"{budget.Name}(剩余{budget.RemainingMonths}月){itemLimit}元"
: $"{budget.Name}{itemLimit}元";
limitParts.Add(limitPart);
var currentPart = budget.IsArchive
? $"{budget.Name}(归档){budget.Current}元"
: budget.RemainingMonths > 0
? $"{budget.Name}(剩余{budget.RemainingMonths}月){budget.Current}元"
: $"{budget.Name}{budget.Current}元";
currentParts.Add(currentPart);
}
var limitSummary = string.Join(" + ", limitParts);
var currentSummary = string.Join(" + ", currentParts);
logger.LogInformation("年度统计计算明细: 预算={LimitSummary}={TotalLimit}元, 已支出={CurrentSummary}={TotalCurrent}元, 使用率={Rate:F2}%",
limitSummary, totalLimit, currentSummary, totalCurrent, result.Rate);
logger.LogDebug("年度分类统计计算完成");
return result;
}
private async Task<List<BudgetStatsItem>> GetAllBudgetsWithArchiveAsync(
BudgetCategory category,
BudgetPeriodType statType,
DateTime referenceDate)
{
logger.LogDebug("开始获取预算数据: Category={Category}, StatType={StatType}, ReferenceDate={ReferenceDate:yyyy-MM-dd}",
category, statType, referenceDate);
var result = new List<BudgetStatsItem>();
var year = referenceDate.Year;
var month = referenceDate.Month;
var now = dateTimeProvider.Now;
// 对于年度统计,需要获取整年的归档数据和当前预算
if (statType == BudgetPeriodType.Year)
{
logger.LogDebug("年度统计:开始获取整年的预算数据");
// 获取当前有效的预算(用于当前月及未来月)
var currentBudgets = await budgetRepository.GetAllAsync();
var currentBudgetsDict = currentBudgets
.Where(b => b.Category == category && ShouldIncludeBudget(b, statType))
.ToDictionary(b => b.Id);
logger.LogDebug("获取到 {Count} 个当前有效预算", currentBudgetsDict.Count);
// 用于跟踪已处理的预算ID避免重复
var processedBudgetIds = new HashSet<long>();
// 1. 处理历史归档月份1月到当前月-1
if (referenceDate.Year == now.Year && now.Month > 1)
{
logger.LogDebug("开始处理历史归档月份: 1月到{Month}月", now.Month - 1);
for (int m = 1; m < now.Month; m++)
{
var archive = await budgetArchiveRepository.GetArchiveAsync(year, m);
if (archive != null)
{
logger.LogDebug("找到{Month}月归档数据,包含 {ItemCount} 个项目", m, archive.Content.Count());
foreach (var item in archive.Content)
{
if (item.Category == category && ShouldIncludeBudget(item, statType))
{
// 对于月度预算,每个月都添加一个归档项
if (item.Type == BudgetPeriodType.Month)
{
result.Add(new BudgetStatsItem
{
Id = item.Id,
Name = item.Name,
Type = item.Type,
Limit = item.Limit,
Current = item.Actual,
Category = item.Category,
SelectedCategories = item.SelectedCategories,
NoLimit = item.NoLimit,
IsMandatoryExpense = item.IsMandatoryExpense,
IsArchive = true,
ArchiveMonth = m
});
logger.LogInformation("添加归档月度预算: {BudgetName} (ID={BudgetId}) - {Month}月归档, 预算金额: {Limit}, 实际金额: {Actual}",
item.Name, item.Id, m, item.Limit, item.Actual);
}
// 对于年度预算,只添加一次
else if (item.Type == BudgetPeriodType.Year && !processedBudgetIds.Contains(item.Id))
{
processedBudgetIds.Add(item.Id);
result.Add(new BudgetStatsItem
{
Id = item.Id,
Name = item.Name,
Type = item.Type,
Limit = item.Limit,
Current = item.Actual,
Category = item.Category,
SelectedCategories = item.SelectedCategories,
NoLimit = item.NoLimit,
IsMandatoryExpense = item.IsMandatoryExpense,
IsArchive = true
});
logger.LogInformation("添加归档年度预算: {BudgetName} (ID={BudgetId}) - 预算金额: {Limit}, 实际金额: {Actual}",
item.Name, item.Id, item.Limit, item.Actual);
}
}
}
}
}
}
// 2. 处理当前月及未来月(使用当前预算)
logger.LogDebug("开始处理当前及未来月份预算");
foreach (var budget in currentBudgetsDict.Values)
{
// 对于年度预算,如果还没有从归档中添加,则添加
if (budget.Type == BudgetPeriodType.Year && !processedBudgetIds.Contains(budget.Id))
{
var currentAmount = await CalculateCurrentAmountAsync(budget, statType, referenceDate);
result.Add(new BudgetStatsItem
{
Id = budget.Id,
Name = budget.Name,
Type = budget.Type,
Limit = budget.Limit,
Current = currentAmount,
Category = budget.Category,
SelectedCategories = string.IsNullOrEmpty(budget.SelectedCategories)
? []
: budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries),
NoLimit = budget.NoLimit,
IsMandatoryExpense = budget.IsMandatoryExpense,
IsArchive = false
});
logger.LogInformation("添加当前年度预算: {BudgetName} (ID={BudgetId}) - 预算金额: {Limit}, 实际金额: {Current}",
budget.Name, budget.Id, budget.Limit, currentAmount);
}
// 对于月度预算,添加当前及未来月份的预算(标记剩余月份数)
else if (budget.Type == BudgetPeriodType.Month)
{
var remainingMonths = 12 - now.Month + 1; // 包括当前月
result.Add(new BudgetStatsItem
{
Id = budget.Id,
Name = budget.Name,
Type = budget.Type,
Limit = budget.Limit,
Current = 0, // 剩余月份不计算实际值
Category = budget.Category,
SelectedCategories = string.IsNullOrEmpty(budget.SelectedCategories)
? []
: budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries),
NoLimit = budget.NoLimit,
IsMandatoryExpense = budget.IsMandatoryExpense,
IsArchive = false,
RemainingMonths = remainingMonths
});
logger.LogInformation("添加当前月度预算(剩余月份): {BudgetName} (ID={BudgetId}) - 预算金额: {Limit}, 剩余月份: {RemainingMonths}",
budget.Name, budget.Id, budget.Limit, remainingMonths);
}
}
}
else // 月度统计
{
// 检查是否为归档月份
var isArchive = year < now.Year || (year == now.Year && month < now.Month);
logger.LogDebug("月度统计 - 是否为归档月份: {IsArchive}", isArchive);
if (isArchive)
{
// 获取归档数据
logger.LogDebug("开始获取归档数据: Year={Year}, Month={Month}", year, month);
var archive = await budgetArchiveRepository.GetArchiveAsync(year, month);
if (archive != null)
{
int itemCount = archive.Content.Count();
logger.LogDebug("找到归档数据,包含 {ItemCount} 个项目", itemCount);
foreach (var item in archive.Content)
{
if (item.Category == category && ShouldIncludeBudget(item, statType))
{
result.Add(new BudgetStatsItem
{
Id = item.Id,
Name = item.Name,
Type = item.Type,
Limit = item.Limit,
Current = item.Actual,
Category = item.Category,
SelectedCategories = item.SelectedCategories,
NoLimit = item.NoLimit,
IsMandatoryExpense = item.IsMandatoryExpense,
IsArchive = true
});
logger.LogInformation("添加归档预算: {BudgetName} (ID={BudgetId}) - 归档月份: {Year}-{Month:00}, 预算金额: {BudgetLimit}, 实际金额: {ActualAmount}",
item.Name, item.Id, year, month, item.Limit, item.Actual);
}
}
}
else
{
logger.LogDebug("未找到归档数据");
}
}
else
{
// 获取当前预算数据
logger.LogDebug("开始获取当前预算数据");
var budgets = await budgetRepository.GetAllAsync();
int budgetCount = budgets.Count();
logger.LogDebug("获取到 {BudgetCount} 个预算", budgetCount);
foreach (var budget in budgets)
{
if (budget.Category == category && ShouldIncludeBudget(budget, statType))
{
var currentAmount = await CalculateCurrentAmountAsync(budget, statType, referenceDate);
result.Add(new BudgetStatsItem
{
Id = budget.Id,
Name = budget.Name,
Type = budget.Type,
Limit = budget.Limit,
Current = currentAmount,
Category = budget.Category,
SelectedCategories = string.IsNullOrEmpty(budget.SelectedCategories)
? []
: budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries),
NoLimit = budget.NoLimit,
IsMandatoryExpense = budget.IsMandatoryExpense,
IsArchive = false
});
logger.LogInformation("添加当前预算: {BudgetName} (ID={BudgetId}) - 预算金额: {BudgetLimit}, 实时计算实际金额: {CurrentAmount}, 预算类型: {BudgetType}",
budget.Name, budget.Id, budget.Limit, currentAmount,
budget.Type == BudgetPeriodType.Month ? "月度预算" : "年度预算");
}
}
}
}
logger.LogDebug("预算数据获取完成: 共找到 {ResultCount} 个符合条件的预算", result.Count);
return result;
}
private bool ShouldIncludeBudget(BudgetRecord budget, BudgetPeriodType statType)
{
// 排除不记额预算
if (budget.NoLimit)
{
return false;
}
// 月度统计只包含月度预算
if (statType == BudgetPeriodType.Month)
{
return budget.Type == BudgetPeriodType.Month;
}
// 年度统计包含所有预算
return true;
}
private bool ShouldIncludeBudget(BudgetArchiveContent budget, BudgetPeriodType statType)
{
// 排除不记额预算
if (budget.NoLimit)
{
return false;
}
// 月度统计只包含月度预算
if (statType == BudgetPeriodType.Month)
{
return budget.Type == BudgetPeriodType.Month;
}
// 年度统计包含所有预算
return true;
}
private decimal CalculateBudgetLimit(BudgetStatsItem budget, BudgetPeriodType statType, DateTime referenceDate)
{
// 不记额预算的限额为0
if (budget.NoLimit)
{
logger.LogTrace("预算 {BudgetName} (ID={BudgetId}) 为不记额预算限额返回0", budget.Name, budget.Id);
return 0;
}
var itemLimit = budget.Limit;
string algorithmDescription = $"直接使用原始预算金额: {budget.Limit}";
// 年度视图下,月度预算需要折算为年度
if (statType == BudgetPeriodType.Year && budget.Type == BudgetPeriodType.Month)
{
// 对于归档预算,直接使用归档的限额,不折算
if (budget.IsArchive)
{
itemLimit = budget.Limit;
algorithmDescription = $"归档月度预算: 直接使用归档限额 {budget.Limit}";
}
// 对于当前及未来月份的预算,使用剩余月份折算
else if (budget.RemainingMonths > 0)
{
itemLimit = budget.Limit * budget.RemainingMonths;
algorithmDescription = $"月度预算剩余月份折算: {budget.Limit} × {budget.RemainingMonths} (剩余月份) = {itemLimit}";
}
// 兼容旧逻辑如果没有设置RemainingMonths
else
{
logger.LogWarning("预算 {BudgetName} (ID={BudgetId}) 年度统计时未设置RemainingMonths使用默认折算逻辑", budget.Name, budget.Id);
if (budget.IsMandatoryExpense)
{
var now = dateTimeProvider.Now;
if (referenceDate.Year == now.Year)
{
var monthsElapsed = now.Month;
itemLimit = budget.Limit * monthsElapsed;
algorithmDescription = $"硬性预算当前年份折算: {budget.Limit} × {monthsElapsed} (已过月份) = {itemLimit}";
}
else
{
itemLimit = budget.Limit * 12;
algorithmDescription = $"硬性预算完整年度折算: {budget.Limit} × 12 = {itemLimit}";
}
}
else
{
itemLimit = budget.Limit * 12;
algorithmDescription = $"月度预算年度折算: {budget.Limit} × 12 = {itemLimit}";
}
}
}
logger.LogInformation("预算 {BudgetName} (ID={BudgetId}) 限额计算: 原始预算={OriginalLimit}, 计算后限额={CalculatedLimit}, 算法: {Algorithm}",
budget.Name, budget.Id, budget.Limit, itemLimit, algorithmDescription);
return itemLimit;
}
private async Task<decimal> CalculateCurrentAmountAsync(BudgetRecord budget, BudgetPeriodType statType, DateTime referenceDate)
{
var (startDate, endDate) = GetStatPeriodRange(statType, referenceDate);
// 获取预算的实际时间段
var (budgetStart, budgetEnd) = BudgetService.GetPeriodRange(budget.StartDate, budget.Type, referenceDate);
// 确保在统计时间段内
if (budgetEnd < startDate || budgetStart > endDate)
{
return 0;
}
var actualAmount = await budgetRepository.GetCurrentAmountAsync(budget, startDate, endDate);
// 硬性预算的特殊处理参考BudgetSavingsService第92-97行
if (actualAmount == 0 && budget.IsMandatoryExpense)
{
if (budget.Type == BudgetPeriodType.Month)
{
// 月度硬性预算按天数比例累加
var daysInMonth = DateTime.DaysInMonth(referenceDate.Year, referenceDate.Month);
var daysElapsed = dateTimeProvider.Now.Day;
actualAmount = budget.Limit * daysElapsed / daysInMonth;
}
else if (budget.Type == BudgetPeriodType.Year)
{
// 年度硬性预算按天数比例累加
var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365;
var daysElapsed = dateTimeProvider.Now.DayOfYear;
actualAmount = budget.Limit * daysElapsed / daysInYear;
}
}
return actualAmount;
}
private decimal ApplyMandatoryBudgetAdjustment(List<BudgetStatsItem> budgets, decimal currentTotal, DateTime referenceDate, BudgetPeriodType statType)
{
logger.LogDebug("开始应用硬性预算调整: 当前总计={CurrentTotal}, 统计类型={StatType}, 参考日期={ReferenceDate:yyyy-MM-dd}",
currentTotal, statType, referenceDate);
var now = dateTimeProvider.Now;
var adjustedTotal = currentTotal;
var mandatoryBudgets = budgets.Where(b => b.IsMandatoryExpense).ToList();
logger.LogDebug("找到 {MandatoryCount} 个硬性预算", mandatoryBudgets.Count);
int mandatoryIndex = 0;
foreach (var budget in mandatoryBudgets)
{
mandatoryIndex++;
// 检查是否为当前统计周期
var isCurrentPeriod = false;
if (statType == BudgetPeriodType.Month)
{
isCurrentPeriod = referenceDate.Year == now.Year && referenceDate.Month == now.Month;
logger.LogInformation("硬性预算 {MandatoryIndex}/{MandatoryCount}: {BudgetName} (ID={BudgetId}) - 检查月度周期: 参考月份={RefMonth}, 当前月份={CurrentMonth}, 是否为当前周期={IsCurrent}",
mandatoryIndex, mandatoryBudgets.Count, budget.Name, budget.Id,
referenceDate.ToString("yyyy-MM"), now.ToString("yyyy-MM"), isCurrentPeriod);
}
else // Year
{
isCurrentPeriod = referenceDate.Year == now.Year;
logger.LogInformation("硬性预算 {MandatoryIndex}/{MandatoryCount}: {BudgetName} (ID={BudgetId}) - 检查年度周期: 参考年份={RefYear}, 当前年份={CurrentYear}, 是否为当前周期={IsCurrent}",
mandatoryIndex, mandatoryBudgets.Count, budget.Name, budget.Id,
referenceDate.Year, now.Year, isCurrentPeriod);
}
if (isCurrentPeriod)
{
// 计算硬性预算的应累加值
decimal mandatoryAccumulation = 0;
string accumulationAlgorithm = "";
if (budget.Type == BudgetPeriodType.Month)
{
// 月度硬性预算按天数比例累加
var daysInMonth = DateTime.DaysInMonth(referenceDate.Year, referenceDate.Month);
var daysElapsed = now.Day;
mandatoryAccumulation = budget.Limit * daysElapsed / daysInMonth;
accumulationAlgorithm = $"月度硬性预算按天数比例: {budget.Limit} × {daysElapsed} ÷ {daysInMonth} = {mandatoryAccumulation:F2}";
logger.LogDebug("月度硬性预算 {BudgetName}: 限额={Limit}, 本月天数={DaysInMonth}, 已过天数={DaysElapsed}, 应累加值={Accumulation}",
budget.Name, budget.Limit, daysInMonth, daysElapsed, mandatoryAccumulation);
}
else if (budget.Type == BudgetPeriodType.Year)
{
// 年度硬性预算按天数比例累加
var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365;
var daysElapsed = now.DayOfYear;
mandatoryAccumulation = budget.Limit * daysElapsed / daysInYear;
accumulationAlgorithm = $"年度硬性预算按天数比例: {budget.Limit} × {daysElapsed} ÷ {daysInYear} = {mandatoryAccumulation:F2}";
logger.LogDebug("年度硬性预算 {BudgetName}: 限额={Limit}, 本年天数={DaysInYear}, 已过天数={DaysElapsed}, 应累加值={Accumulation}",
budget.Name, budget.Limit, daysInYear, daysElapsed, mandatoryAccumulation);
}
logger.LogInformation("硬性预算 {BudgetName} (ID={BudgetId}) 应累加值计算: 算法={Algorithm}",
budget.Name, budget.Id, accumulationAlgorithm);
// 如果趋势数据中的累计值小于硬性预算的应累加值,使用硬性预算的值
if (adjustedTotal < mandatoryAccumulation)
{
var adjustmentAmount = mandatoryAccumulation - adjustedTotal;
logger.LogInformation("硬性预算 {BudgetName} (ID={BudgetId}) 触发调整: 调整前总计={BeforeTotal}, 应累加值={MandatoryAccumulation}, 调整金额={AdjustmentAmount}, 调整后总计={AfterTotal}",
budget.Name, budget.Id, adjustedTotal, mandatoryAccumulation, adjustmentAmount, mandatoryAccumulation);
adjustedTotal = mandatoryAccumulation;
}
else
{
logger.LogDebug("硬性预算 {BudgetName} (ID={BudgetId}) 未触发调整: 当前总计={CurrentTotal} >= 应累加值={MandatoryAccumulation}",
budget.Name, budget.Id, adjustedTotal, mandatoryAccumulation);
}
}
else
{
logger.LogInformation("硬性预算 {BudgetName} (ID={BudgetId}) 不在当前统计周期,跳过调整", budget.Name, budget.Id);
}
}
logger.LogDebug("硬性预算调整完成: 最终总计={AdjustedTotal}", adjustedTotal);
return adjustedTotal;
}
private async Task<decimal> CalculateCurrentAmountAsync(BudgetStatsItem budget, BudgetPeriodType statType, DateTime referenceDate)
{
var (startDate, endDate) = GetStatPeriodRange(statType, referenceDate);
// 创建临时的BudgetRecord用于查询
var tempRecord = new BudgetRecord
{
Id = budget.Id,
Name = budget.Name,
Type = budget.Type,
Limit = budget.Limit,
Category = budget.Category,
SelectedCategories = string.Join(",", budget.SelectedCategories),
StartDate = referenceDate,
NoLimit = budget.NoLimit,
IsMandatoryExpense = budget.IsMandatoryExpense
};
// 获取预算的实际时间段
var (budgetStart, budgetEnd) = BudgetService.GetPeriodRange(tempRecord.StartDate, tempRecord.Type, referenceDate);
// 确保在统计时间段内
if (budgetEnd < startDate || budgetStart > endDate)
{
return 0;
}
var actualAmount = await budgetRepository.GetCurrentAmountAsync(tempRecord, startDate, endDate);
// 硬性预算的特殊处理
if (actualAmount == 0 && budget.IsMandatoryExpense)
{
if (budget.Type == BudgetPeriodType.Month)
{
var daysInMonth = DateTime.DaysInMonth(referenceDate.Year, referenceDate.Month);
var daysElapsed = dateTimeProvider.Now.Day;
actualAmount = budget.Limit * daysElapsed / daysInMonth;
}
else if (budget.Type == BudgetPeriodType.Year)
{
var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365;
var daysElapsed = dateTimeProvider.Now.DayOfYear;
actualAmount = budget.Limit * daysElapsed / daysInYear;
}
}
return actualAmount;
}
private (DateTime start, DateTime end) GetStatPeriodRange(BudgetPeriodType statType, DateTime referenceDate)
{
if (statType == BudgetPeriodType.Month)
{
var start = new DateTime(referenceDate.Year, referenceDate.Month, 1);
var end = start.AddMonths(1).AddDays(-1).AddHours(23).AddMinutes(59).AddSeconds(59);
return (start, end);
}
else // Year
{
var start = new DateTime(referenceDate.Year, 1, 1);
var end = new DateTime(referenceDate.Year, 12, 31).AddHours(23).AddMinutes(59).AddSeconds(59);
return (start, end);
}
}
private class BudgetStatsItem
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public BudgetPeriodType Type { get; set; }
public decimal Limit { get; set; }
public decimal Current { get; set; }
public BudgetCategory Category { get; set; }
public string[] SelectedCategories { get; set; } = [];
public bool NoLimit { get; set; }
public bool IsMandatoryExpense { get; set; }
public bool IsArchive { get; set; }
public int ArchiveMonth { get; set; } // 归档月份1-12用于标识归档数据来自哪个月
public int RemainingMonths { get; set; } // 剩余月份数,用于年度统计时的月度预算折算
}
}

View File

@@ -1,4 +1,4 @@
import request from './request' import request from './request'
/** /**
* 日志相关 API * 日志相关 API
@@ -12,6 +12,7 @@
* @param {string} [params.searchKeyword] - 搜索关键词 * @param {string} [params.searchKeyword] - 搜索关键词
* @param {string} [params.logLevel] - 日志级别 * @param {string} [params.logLevel] - 日志级别
* @param {string} [params.date] - 日期 (yyyyMMdd) * @param {string} [params.date] - 日期 (yyyyMMdd)
* @param {string} [params.className] - 类名
* @returns {Promise<{success: boolean, data: Array, total: number}>} * @returns {Promise<{success: boolean, data: Array, total: number}>}
*/ */
export const getLogList = (params = {}) => { export const getLogList = (params = {}) => {
@@ -32,3 +33,34 @@ export const getAvailableDates = () => {
method: 'get' method: 'get'
}) })
} }
/**
* 获取可用的类名列表
* @param {Object} params - 查询参数
* @param {string} [params.date] - 日期 (yyyyMMdd)
* @returns {Promise<{success: boolean, data: Array}>}
*/
export const getAvailableClassNames = (params = {}) => {
return request({
url: '/Log/GetAvailableClassNames',
method: 'get',
params
})
}
/**
* 根据请求ID查询关联日志
* @param {Object} params - 查询参数
* @param {string} params.requestId - 请求ID
* @param {number} [params.pageIndex=1] - 页码
* @param {number} [params.pageSize=50] - 每页条数
* @param {string} [params.date] - 日期 (yyyyMMdd)
* @returns {Promise<{success: boolean, data: Array, total: number}>}
*/
export const getLogsByRequestId = (params = {}) => {
return request({
url: '/Log/GetLogsByRequestId',
method: 'get',
params
})
}

View File

@@ -1,4 +1,4 @@
import axios from 'axios' import axios from 'axios'
import { showToast } from 'vant' import { showToast } from 'vant'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import router from '@/router' import router from '@/router'
@@ -12,6 +12,15 @@ const request = axios.create({
} }
}) })
// 生成请求ID
const generateRequestId = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0
const v = c === 'x' ? r : (r & 0x3 | 0x8)
return v.toString(16)
})
}
// 请求拦截器 // 请求拦截器
request.interceptors.request.use( request.interceptors.request.use(
(config) => { (config) => {
@@ -20,6 +29,11 @@ request.interceptors.request.use(
if (authStore.token) { if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}` config.headers.Authorization = `Bearer ${authStore.token}`
} }
// 添加请求ID
const requestId = generateRequestId()
config.headers['X-Request-ID'] = requestId
return config return config
}, },
(error) => { (error) => {

View File

@@ -1,4 +1,4 @@
<template> <template>
<div class="page-container-flex log-view"> <div class="page-container-flex log-view">
<van-nav-bar <van-nav-bar
title="查看日志" title="查看日志"
@@ -28,7 +28,12 @@
<van-dropdown-item <van-dropdown-item
v-model="selectedDate" v-model="selectedDate"
:options="dateOptions" :options="dateOptions"
@change="handleSearch" @change="handleDateChange"
/>
<van-dropdown-item
v-model="selectedClassName"
:options="classNameOptions"
@change="handleClassNameChange"
/> />
</van-dropdown-menu> </van-dropdown-menu>
</div> </div>
@@ -66,6 +71,31 @@
<span class="log-level">{{ log.level }}</span> <span class="log-level">{{ log.level }}</span>
<span class="log-time">{{ formatTime(log.timestamp) }}</span> <span class="log-time">{{ formatTime(log.timestamp) }}</span>
</div> </div>
<div
v-if="log.className || log.methodName"
class="log-source"
>
<span
v-if="log.className"
class="source-class"
>{{ log.className }}</span>
<span
v-if="log.methodName"
class="source-method"
>.{{ log.methodName }}</span>
</div>
<div
v-if="log.requestId"
class="log-request-id"
>
<span class="request-id-label">请求ID:</span>
<span
class="request-id-value"
@click="handleRequestIdClick(log.requestId)"
>
{{ log.requestId }}
</span>
</div>
<div class="log-message"> <div class="log-message">
{{ log.message }} {{ log.message }}
</div> </div>
@@ -90,7 +120,7 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { showToast } from 'vant' import { showToast } from 'vant'
import { getLogList, getAvailableDates } from '@/api/log' import { getLogList, getAvailableDates, getAvailableClassNames, getLogsByRequestId } from '@/api/log'
const router = useRouter() const router = useRouter()
@@ -110,6 +140,11 @@ const total = ref(0)
const searchKeyword = ref('') const searchKeyword = ref('')
const selectedLevel = ref('') const selectedLevel = ref('')
const selectedDate = ref('') const selectedDate = ref('')
const selectedClassName = ref('')
// requestId 查询模式
const isRequestIdMode = ref(false)
const currentRequestId = ref('')
// 日志级别选项 // 日志级别选项
const levelOptions = ref([ const levelOptions = ref([
@@ -125,6 +160,9 @@ const levelOptions = ref([
// 日期选项 // 日期选项
const dateOptions = ref([{ text: '全部日期', value: '' }]) const dateOptions = ref([{ text: '全部日期', value: '' }])
// 类名选项
const classNameOptions = ref([{ text: '全部类名', value: '' }])
/** /**
* 返回上一页 * 返回上一页
*/ */
@@ -177,22 +215,37 @@ const loadLogs = async (reset = false) => {
finished.value = false finished.value = false
} }
const params = { let response
pageIndex: pageIndex.value,
pageSize: pageSize.value
}
if (searchKeyword.value) { if (isRequestIdMode.value) {
params.searchKeyword = searchKeyword.value // requestId 查询模式
} response = await getLogsByRequestId({
if (selectedLevel.value) { requestId: currentRequestId.value,
params.logLevel = selectedLevel.value pageIndex: pageIndex.value,
} pageSize: pageSize.value
if (selectedDate.value) { })
params.date = selectedDate.value } else {
} // 普通查询模式
const params = {
pageIndex: pageIndex.value,
pageSize: pageSize.value
}
const response = await getLogList(params) if (searchKeyword.value) {
params.searchKeyword = searchKeyword.value
}
if (selectedLevel.value) {
params.logLevel = selectedLevel.value
}
if (selectedDate.value) {
params.date = selectedDate.value
}
if (selectedClassName.value) {
params.className = selectedClassName.value
}
response = await getLogList(params)
}
if (response.success) { if (response.success) {
const newLogs = response.data || [] const newLogs = response.data || []
@@ -206,12 +259,9 @@ const loadLogs = async (reset = false) => {
total.value = response.total total.value = response.total
// 判断是否还有更多数据 // 判断是否还有更多数据
// total = -1 表示总数未知,此时只根据返回数据量判断
if (total.value === -1) { if (total.value === -1) {
// 如果返回的数据少于请求的数量,说明没有更多了
finished.value = newLogs.length < pageSize.value finished.value = newLogs.length < pageSize.value
} else { } else {
// 如果有明确的总数,则判断是否已加载完全部数据
if (logList.value.length >= total.value || newLogs.length < pageSize.value) { if (logList.value.length >= total.value || newLogs.length < pageSize.value) {
finished.value = true finished.value = true
} else { } else {
@@ -237,7 +287,12 @@ const loadLogs = async (reset = false) => {
* 下拉刷新 * 下拉刷新
*/ */
const onRefresh = async () => { const onRefresh = async () => {
await loadLogs(true) if (isRequestIdMode.value) {
// requestId 模式下刷新,重置为第一页
await loadLogs(true)
} else {
await loadLogs(true)
}
} }
/** /**
@@ -262,6 +317,8 @@ const onLoad = async () => {
* 搜索处理 * 搜索处理
*/ */
const handleSearch = () => { const handleSearch = () => {
isRequestIdMode.value = false
currentRequestId.value = ''
loadLogs(true) loadLogs(true)
} }
@@ -291,6 +348,44 @@ const loadAvailableDates = async () => {
} }
} }
/**
* 加载可用类名列表
*/
const loadAvailableClassNames = async () => {
try {
const params = {}
if (selectedDate.value) {
params.date = selectedDate.value
}
const response = await getAvailableClassNames(params)
if (response.success && response.data) {
const classNames = response.data.map((name) => ({
text: name,
value: name
}))
classNameOptions.value = [{ text: '全部类名', value: '' }, ...classNames]
}
} catch (error) {
console.error('加载类名列表失败:', error)
}
}
/**
* 日期改变时重新加载类名
*/
const handleDateChange = async () => {
selectedClassName.value = ''
await loadAvailableClassNames()
handleSearch()
}
/**
* 类名改变时重新搜索
*/
const handleClassNameChange = () => {
handleSearch()
}
/** /**
* 格式化日期显示 * 格式化日期显示
*/ */
@@ -302,9 +397,50 @@ const formatDate = (dateStr) => {
return dateStr return dateStr
} }
/**
* 处理请求ID点击
*/
const handleRequestIdClick = async (requestId) => {
try {
showToast('正在查询关联日志...')
isRequestIdMode.value = true
currentRequestId.value = requestId
const response = await getLogsByRequestId({
requestId,
pageIndex: 1,
pageSize: 100
})
if (response.success && response.data && response.data.length > 0) {
logList.value = response.data
total.value = response.total
pageIndex.value = 1
// 根据返回数据量判断是否还有更多
if (response.data.length < 100) {
finished.value = true
} else {
finished.value = false
}
showToast(`找到 ${response.total} 条关联日志`)
} else {
showToast('未找到关联日志')
logList.value = []
finished.value = true
}
} catch (error) {
console.error('查询关联日志失败:', error)
showToast('查询失败')
}
}
// 组件挂载时加载数据 // 组件挂载时加载数据
onMounted(() => { onMounted(() => {
loadAvailableDates() loadAvailableDates()
loadAvailableClassNames()
// 不在这里调用 loadLogs让 van-list 的 @load 事件自动触发 // 不在这里调用 loadLogs让 van-list 的 @load 事件自动触发
}) })
</script> </script>
@@ -399,6 +535,43 @@ onMounted(() => {
} }
} }
.log-request-id {
margin: 2px 0;
font-size: 10px;
color: #666;
display: flex;
align-items: center;
}
.log-source {
margin: 2px 0;
font-size: 10px;
color: #666;
display: flex;
align-items: center;
}
.source-class {
font-weight: bold;
color: var(--van-primary-color);
}
.request-id-label {
margin-right: 4px;
font-weight: bold;
}
.request-id-value {
cursor: pointer;
color: var(--van-primary-color);
text-decoration: underline;
word-break: break-all;
}
.request-id-value:hover {
opacity: 0.8;
}
.log-message { .log-message {
color: #323233; color: #323233;
line-height: 1.4; line-height: 1.4;

View File

@@ -0,0 +1,519 @@
using Microsoft.Extensions.Logging;
namespace WebApi.Test.Budget;
public class BudgetStatsTest : BaseTest
{
private readonly IBudgetRepository _budgetRepository = Substitute.For<IBudgetRepository>();
private readonly IBudgetArchiveRepository _budgetArchiveRepository = Substitute.For<IBudgetArchiveRepository>();
private readonly ITransactionRecordRepository _transactionsRepository = Substitute.For<ITransactionRecordRepository>();
private readonly IOpenAiService _openAiService = Substitute.For<IOpenAiService>();
private readonly IMessageService _messageService = Substitute.For<IMessageService>();
private readonly ILogger<BudgetService> _logger = Substitute.For<ILogger<BudgetService>>();
private readonly IBudgetSavingsService _budgetSavingsService = Substitute.For<IBudgetSavingsService>();
private readonly IDateTimeProvider _dateTimeProvider = Substitute.For<IDateTimeProvider>();
private readonly IBudgetStatsService _budgetStatsService;
private readonly BudgetService _service;
public BudgetStatsTest()
{
_dateTimeProvider.Now.Returns(new DateTime(2024, 1, 15));
_budgetStatsService = new BudgetStatsService(
_budgetRepository,
_budgetArchiveRepository,
_transactionsRepository,
_dateTimeProvider,
Substitute.For<ILogger<BudgetStatsService>>()
);
_service = new BudgetService(
_budgetRepository,
_budgetArchiveRepository,
_transactionsRepository,
_openAiService,
_messageService,
_logger,
_budgetSavingsService,
_dateTimeProvider,
_budgetStatsService
);
}
[Fact]
public async Task GetCategoryStats_月度_Test()
{
// Arrange
var referenceDate = new DateTime(2024, 1, 15);
var budgets = new List<BudgetRecord>
{
new() { Id = 1, Name = "吃喝", Limit = 2000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "餐饮,零食" },
new() { Id = 2, Name = "交通", Limit = 500, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "交通" }
};
_budgetRepository.GetAllAsync().Returns(budgets);
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.Returns(args =>
{
var b = (BudgetRecord)args[0];
return b.Name == "吃喝" ? 1200m : 300m;
});
_transactionsRepository.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
.Returns(new Dictionary<DateTime, decimal>
{
{ new DateTime(2024, 1, 15), 1500m } // 1月15日累计1500吃喝1200+交通300
});
// Act
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Assert
result.Month.Limit.Should().Be(2500); // 吃喝2000 + 交通500
result.Month.Current.Should().Be(1500); // 吃喝1200 + 交通300
}
[Fact]
public async Task GetCategoryStats_月度_硬性收支_Test()
{
// Arrange
var referenceDate = new DateTime(2024, 1, 15);
var budgets = new List<BudgetRecord>
{
new() { Id = 1, Name = "房租", Limit = 3100, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, IsMandatoryExpense = true }
};
_budgetRepository.GetAllAsync().Returns(budgets);
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.Returns(0m); // 实际支出的金额为0
_dateTimeProvider.Now.Returns(referenceDate);
_transactionsRepository.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
.Returns(new Dictionary<DateTime, decimal>());
// Act
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Assert
// 1月有31天15号经过了15天
// 3100 * 15 / 31 = 1500
result.Month.Limit.Should().Be(3100);
result.Month.Current.Should().Be(1500);
}
[Fact]
public async Task GetCategoryStats_年度_1月_Test()
{
// Arrange
var referenceDate = new DateTime(2024, 1, 15);
var budgets = new List<BudgetRecord>
{
new() { Id = 2, Name = "月度吃饭", Limit = 3000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "餐饮" },
new() { Id = 1, Name = "年度旅游", Limit = 12000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year, SelectedCategories = "旅游" }
};
_budgetRepository.GetAllAsync().Returns(budgets);
// 月度统计使用趋势统计数据(只包含月度预算的分类)
_transactionsRepository.GetFilteredTrendStatisticsAsync(
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1 && d.Day == 1),
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1 && d.Day == 31),
TransactionType.Expense,
Arg.Is<List<string>>(list => list.Count == 1 && list.Contains("餐饮")), // 只包含月度预算的分类
false)
.Returns(new Dictionary<DateTime, decimal>
{
{ new DateTime(2024, 1, 15), 800m } // 1月15日月度吃饭累计800
});
// 年度统计使用GetCurrentAmountAsync
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.Returns(args =>
{
var b = (BudgetRecord)args[0];
var startDate = (DateTime)args[1];
// 年度范围查询 - 年度旅游
if (startDate.Month == 1 && startDate.Day == 1)
{
return b.Name == "年度旅游" ? 2000m : 0m;
}
return 0m;
});
// 年度趋势统计(包含所有分类)
_transactionsRepository.GetFilteredTrendStatisticsAsync(
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1 && d.Day == 1),
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 12 && d.Day == 31),
TransactionType.Expense,
Arg.Is<List<string>>(list => list.Count == 2), // 包含所有分类:餐饮、旅游
true)
.Returns(new Dictionary<DateTime, decimal>
{
{ new DateTime(2024, 1, 1), 2800m } // 1月累计月度吃饭800 + 年度旅游2000 = 2800
});
// Act
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Assert
// 月度统计中:只包含月度预算
result.Month.Limit.Should().Be(3000); // 月度吃饭3000
result.Month.Current.Should().Be(800); // 月度吃饭已用800
result.Month.Count.Should().Be(1); // 只包含1个月度预算
// 年度统计中:包含所有预算(月度预算按剩余月份折算)
// 1月时剩余月份 = 12 - 1 + 1 = 12个月
result.Year.Limit.Should().Be(12000 + (3000 * 12)); // 年度旅游12000 + 月度吃饭折算年度(3000*12=36000) = 48000
result.Year.Current.Should().Be(2000 + 800); // 年度旅游2000 + 月度吃饭800 = 2800
result.Year.Count.Should().Be(2); // 包含2个预算1个月度+1个年度
}
[Fact]
public async Task GetCategoryStats_年度_1月_硬性收支_Test()
{
// Arrange
var referenceDate = new DateTime(2024, 1, 1); // 元旦
var budgets = new List<BudgetRecord>
{
new() { Id = 1, Name = "年度固定支出", Limit = 3660, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year, IsMandatoryExpense = true }
};
_budgetRepository.GetAllAsync().Returns(budgets);
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>()).Returns(0m);
_dateTimeProvider.Now.Returns(referenceDate);
_transactionsRepository.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
.Returns(new Dictionary<DateTime, decimal>());
// Act
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Assert
// 2024是闰年366天。1月1号是第1天。
// 3660 * 1 / 366 = 10
result.Year.Limit.Should().Be(3660);
result.Year.Current.Should().Be(10);
}
[Fact]
public async Task GetCategoryStats_年度_3月_硬性收支_Test()
{
// Arrange
var referenceDate = new DateTime(2024, 3, 31); // 3月最后一天
var budgets = new List<BudgetRecord>
{
new() { Id = 1, Name = "年度固定支出", Limit = 3660, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year, IsMandatoryExpense = true }
};
_budgetRepository.GetAllAsync().Returns(budgets);
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>()).Returns(0m);
_dateTimeProvider.Now.Returns(referenceDate);
_transactionsRepository.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
.Returns(new Dictionary<DateTime, decimal>());
// Act
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Assert
// 2024是闰年。1月(31) + 2月(29) + 3月(31) = 91天
// 3660 * 91 / 366 = 910
result.Year.Limit.Should().Be(3660);
result.Year.Current.Should().Be(910);
}
[Fact]
public async Task GetCategoryStats_月度_发生年度收支_Test()
{
// Arrange
var referenceDate = new DateTime(2024, 1, 15);
// 设置预算:包含月度预算和年度预算
var budgets = new List<BudgetRecord>
{
// 月度预算:吃喝
new() { Id = 1, Name = "吃喝", Limit = 2000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "餐饮,零食" },
// 月度预算:交通
new() { Id = 2, Name = "交通", Limit = 500, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "交通" },
// 年度预算:年度旅游(当前月度发生了相关支出)
new() { Id = 3, Name = "年度旅游", Limit = 12000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year, SelectedCategories = "旅游,度假" },
// 年度预算:年度奖金(当前月度发生了相关收入)
new() { Id = 4, Name = "年度奖金", Limit = 50000, Category = BudgetCategory.Income, Type = BudgetPeriodType.Year, SelectedCategories = "奖金,年终奖" }
};
_budgetRepository.GetAllAsync().Returns(budgets);
// 设置月度预算的当前金额
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.Returns(args =>
{
var b = (BudgetRecord)args[0];
return b.Name switch
{
"吃喝" => 1200m, // 月度预算已用1200元
"交通" => 300m, // 月度预算已用300元
"年度旅游" => 2000m, // 年度预算1月份已用2000元
"年度奖金" => 10000m, // 年度预算1月份已收10000元
_ => 0m
};
});
// 设置月度趋势统计数据:只包含月度预算相关的分类(餐饮、零食、交通)
// 注意:不应包含年度预算的分类(旅游、度假、奖金、年终奖)
_transactionsRepository.GetFilteredTrendStatisticsAsync(
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1),
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1),
TransactionType.Expense,
Arg.Is<List<string>>(list => list.Count == 3 && list.Contains("餐饮") && list.Contains("零食") && list.Contains("交通")),
false)
.Returns(new Dictionary<DateTime, decimal>
{
{ new DateTime(2024, 1, 15), 1500m } // 1月15日累计1500吃喝1200+交通300不包含年度旅游2000
});
// 设置年度趋势统计数据:包含所有预算相关的分类
_transactionsRepository.GetFilteredTrendStatisticsAsync(
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1 && d.Day == 1),
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 12 && d.Day == 31),
TransactionType.Expense,
Arg.Is<List<string>>(list => list.Count == 5), // 餐饮、零食、交通、旅游、度假
true)
.Returns(new Dictionary<DateTime, decimal>
{
{ new DateTime(2024, 1, 1), 3500m } // 1月累计3500吃喝1200+交通300+年度旅游2000
});
// 设置收入相关的趋势统计数据
_transactionsRepository.GetFilteredTrendStatisticsAsync(
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1),
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1),
TransactionType.Income,
Arg.Any<List<string>>(),
false)
.Returns(new Dictionary<DateTime, decimal>()); // 月度收入为空
_transactionsRepository.GetFilteredTrendStatisticsAsync(
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1 && d.Day == 1),
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 12 && d.Day == 31),
TransactionType.Income,
Arg.Any<List<string>>(),
true)
.Returns(new Dictionary<DateTime, decimal>
{
{ new DateTime(2024, 1, 1), 10000m } // 年度奖金10000
});
// Act - 测试支出统计
var expenseResult = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Act - 测试收入统计
var incomeResult = await _service.GetCategoryStatsAsync(BudgetCategory.Income, referenceDate);
// Assert - 支出月度统计:只包含月度预算,不包含年度预算
expenseResult.Month.Limit.Should().Be(2500); // 吃喝2000 + 交通500
expenseResult.Month.Current.Should().Be(1500); // 吃喝1200 + 交通300不包含年度旅游2000
expenseResult.Month.Count.Should().Be(2); // 只包含2个月度预算
expenseResult.Month.Rate.Should().Be(1500m / 2500m * 100); // 60%
// Assert - 支出年度统计:包含所有预算(月度+年度)
// 1月时剩余月份 = 12 - 1 + 1 = 12个月
expenseResult.Year.Limit.Should().Be(12000 + (2500 * 12)); // 年度旅游12000 + 月度预算折算为年度(2500*12)
expenseResult.Year.Current.Should().Be(3500); // 吃喝1200 + 交通300 + 年度旅游2000
expenseResult.Year.Count.Should().Be(3); // 包含3个预算2个月度+1个年度
// Assert - 收入月度统计只包含月度预算这里没有月度收入预算所以应该为0
incomeResult.Month.Limit.Should().Be(0); // 没有月度收入预算
incomeResult.Month.Current.Should().Be(0); // 没有月度收入预算不包含年度奖金10000
incomeResult.Month.Count.Should().Be(0); // 没有月度收入预算
// Assert - 收入年度统计:包含所有预算(只有年度收入预算)
incomeResult.Year.Limit.Should().Be(50000); // 年度奖金50000
incomeResult.Year.Current.Should().Be(10000); // 年度奖金已收10000
incomeResult.Year.Count.Should().Be(1); // 包含1个年度收入预算
}
[Fact]
public async Task GetCategoryStats_年度_3月_2月预算变更_Test()
{
// Arrange
// 测试场景2024年3月查看年度预算统计其中2月份发生了预算变更吃喝预算从2000增加到2500
var referenceDate = new DateTime(2024, 3, 15);
// 设置当前时间确保3月被认为是当前月份
_dateTimeProvider.Now.Returns(new DateTime(2024, 3, 15));
// 当前3月份有效的预算
var currentBudgets = new List<BudgetRecord>
{
new() { Id = 1, Name = "吃喝", Limit = 2500, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "餐饮,零食" }, // 2月预算变更后
new() { Id = 2, Name = "交通", Limit = 500, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "交通" },
new() { Id = 3, Name = "年度旅游", Limit = 12000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year, SelectedCategories = "旅游,度假" }
};
// 2月份的归档数据预算变更前
var febArchive = new BudgetArchive
{
Year = 2024,
Month = 2,
Content = new[]
{
new BudgetArchiveContent
{
Id = 1,
Name = "吃喝",
Limit = 2000, // 2月份时预算还是2000
Actual = 1800, // 2月份实际花费1800
Category = BudgetCategory.Expense,
Type = BudgetPeriodType.Month,
SelectedCategories = new[] { "餐饮", "零食" }
},
new BudgetArchiveContent
{
Id = 2,
Name = "交通",
Limit = 500,
Actual = 300,
Category = BudgetCategory.Expense,
Type = BudgetPeriodType.Month,
SelectedCategories = new[] { "交通" }
}
}
};
// 1月份的归档数据
var janArchive = new BudgetArchive
{
Year = 2024,
Month = 1,
Content = new[]
{
new BudgetArchiveContent
{
Id = 1,
Name = "吃喝",
Limit = 2000, // 1月份预算也是2000
Actual = 1500, // 1月份实际花费1500
Category = BudgetCategory.Expense,
Type = BudgetPeriodType.Month,
SelectedCategories = new[] { "餐饮", "零食" }
},
new BudgetArchiveContent
{
Id = 2,
Name = "交通",
Limit = 500,
Actual = 250,
Category = BudgetCategory.Expense,
Type = BudgetPeriodType.Month,
SelectedCategories = new[] { "交通" }
}
}
};
// 设置仓储响应
_budgetRepository.GetAllAsync().Returns(currentBudgets);
// 设置归档仓储响应
_budgetArchiveRepository.GetArchiveAsync(2024, 2).Returns(febArchive);
_budgetArchiveRepository.GetArchiveAsync(2024, 1).Returns(janArchive);
// 设置月度预算的当前金额查询仅用于3月份
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Is<DateTime>(d => d.Month == 3), Arg.Is<DateTime>(d => d.Month == 3))
.Returns(args =>
{
var b = (BudgetRecord)args[0];
return b.Name switch
{
"吃喝" => 800m, // 3月份已花费800
"交通" => 200m, // 3月份已花费200
_ => 0m
};
});
// 年度旅游的年度金额查询
_budgetRepository.GetCurrentAmountAsync(
Arg.Is<BudgetRecord>(b => b.Id == 3),
Arg.Is<DateTime>(d => d.Month == 1),
Arg.Is<DateTime>(d => d.Month == 12))
.Returns(2500m); // 年度旅游1-3月已花费2500
// 设置趋势统计数据查询(用于月度统计)
_transactionsRepository.GetFilteredTrendStatisticsAsync(
Arg.Is<DateTime>(d => d.Month == 3),
Arg.Is<DateTime>(d => d.Month == 3),
Arg.Any<TransactionType>(),
Arg.Any<List<string>>(),
Arg.Any<bool>())
.Returns(new Dictionary<DateTime, decimal>
{
{ new DateTime(2024, 3, 15), 1000m } // 3月15日累计1000吃喝800+交通200
});
// 年度趋势统计数据查询
// 注意年度统计使用GetFilteredTrendStatisticsAsync获取趋势数据
// 需要返回所有分类的累计金额包括年度旅游的2500
_transactionsRepository.GetFilteredTrendStatisticsAsync(
Arg.Is<DateTime>(d => d.Month == 1),
Arg.Is<DateTime>(d => d.Month == 12),
Arg.Any<TransactionType>(),
Arg.Any<List<string>>(),
Arg.Is<bool>(b => b == true))
.Returns(new Dictionary<DateTime, decimal>
{
// 3月累计月度预算1000 + 年度旅游2500 = 3500
{ new DateTime(2024, 3, 1), 3500m }
});
// Act
// 直接测试BudgetStatsService而不是通过BudgetService
var budgetStatsService = new BudgetStatsService(
_budgetRepository,
_budgetArchiveRepository,
_transactionsRepository,
_dateTimeProvider,
Substitute.For<ILogger<BudgetStatsService>>()
);
var result = await budgetStatsService.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Assert - 月度统计3月份
// 月度统计应该只包含月度预算,使用当前预算限额
result.Month.Limit.Should().Be(3000); // 吃喝2500 + 交通500使用变更后的预算
result.Month.Current.Should().Be(1000); // 吃喝800 + 交通200
result.Month.Count.Should().Be(2); // 包含2个月度预算
// Assert - 年度统计(需要考虑预算变更和剩余月份)
// 新逻辑:
// 1. 对于归档数据,直接使用归档的限额,不折算
// 2. 对于当前及未来月份,使用当前预算 × 剩余月份
//
// 预期年度限额计算:
// 1月归档吃喝2000 + 交通500 = 2500
// 2月归档吃喝2000 + 交通500 = 2500
// 3-12月剩余12 - 3 + 1 = 10个月吃喝2500×10 + 交通500×10 = 30000
// 年度旅游12000
// 总计2500 + 2500 + 30000 + 12000 = 47000
result.Year.Limit.Should().Be(47000);
// 预期年度实际金额:
// 根据趋势统计数据3月累计: 月度预算1000 + 年度旅游2500 = 3500
result.Year.Current.Should().Be(3500);
// 应该包含:
// - 1月归档的月度预算吃喝、1个
// - 1月归档的月度预算交通、1个
// - 2月归档的月度预算吃喝、1个
// - 2月归档的月度预算交通、1个
// - 3-12月的月度预算吃喝、1个
// - 3-12月的月度预算交通、1个
// - 年度旅游1个
// 总计7个
result.Year.Count.Should().Be(7);
// 验证使用率计算正确
result.Month.Rate.Should().BeApproximately(1000m / 3000m * 100, 0.01m);
result.Year.Rate.Should().BeApproximately(3500m / 47000m * 100, 0.01m);
}
}

View File

@@ -1,241 +0,0 @@
using Microsoft.Extensions.Logging;
using Common;
namespace WebApi.Test.Budget;
public class BudgetTest : BaseTest
{
private readonly IBudgetRepository _budgetRepository = Substitute.For<IBudgetRepository>();
private readonly IBudgetArchiveRepository _budgetArchiveRepository = Substitute.For<IBudgetArchiveRepository>();
private readonly ITransactionRecordRepository _transactionsRepository = Substitute.For<ITransactionRecordRepository>();
private readonly IOpenAiService _openAiService = Substitute.For<IOpenAiService>();
private readonly IMessageService _messageService = Substitute.For<IMessageService>();
private readonly ILogger<BudgetService> _logger = Substitute.For<ILogger<BudgetService>>();
private readonly IBudgetSavingsService _budgetSavingsService = Substitute.For<IBudgetSavingsService>();
private readonly IDateTimeProvider _dateTimeProvider = Substitute.For<IDateTimeProvider>();
private readonly BudgetService _service;
public BudgetTest()
{
_dateTimeProvider.Now.Returns(new DateTime(2024, 1, 15));
_service = new BudgetService(
_budgetRepository,
_budgetArchiveRepository,
_transactionsRepository,
_openAiService,
_messageService,
_logger,
_budgetSavingsService,
_dateTimeProvider
);
}
[Fact]
public async Task GetCategoryStats_月度_Test()
{
// Arrange
var referenceDate = new DateTime(2024, 1, 15);
var budgets = new List<BudgetRecord>
{
new() { Id = 1, Name = "吃喝", Limit = 2000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "餐饮,零食" },
new() { Id = 2, Name = "交通", Limit = 500, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "交通" }
};
_budgetRepository.GetAllAsync().Returns(budgets);
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.Returns(args =>
{
var b = (BudgetRecord)args[0];
return b.Name == "吃喝" ? 1200m : 300m;
});
_transactionsRepository.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
.Returns(new Dictionary<DateTime, decimal>());
// Act
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Assert
result.Month.Limit.Should().Be(2500); // 吃喝2000 + 交通500
result.Month.Current.Should().Be(1500); // 吃喝1200 + 交通300
}
[Fact]
public async Task GetCategoryStats_月度_硬性收支_Test()
{
// Arrange
var referenceDate = new DateTime(2024, 1, 15);
var budgets = new List<BudgetRecord>
{
new() { Id = 1, Name = "房租", Limit = 3100, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, IsMandatoryExpense = true }
};
_budgetRepository.GetAllAsync().Returns(budgets);
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.Returns(0m); // 实际支出的金额为0
_dateTimeProvider.Now.Returns(referenceDate);
_transactionsRepository.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
.Returns(new Dictionary<DateTime, decimal>());
// Act
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Assert
// 1月有31天15号经过了15天
// 3100 * 15 / 31 = 1500
result.Month.Limit.Should().Be(3100);
result.Month.Current.Should().Be(1500);
}
[Fact]
public async Task GetCategoryStats_年度_1月_Test()
{
// Arrange
var referenceDate = new DateTime(2024, 1, 15);
var budgets = new List<BudgetRecord>
{
new() { Id = 1, Name = "年度旅游", Limit = 12000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year }
};
_budgetRepository.GetAllAsync().Returns(budgets);
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Is<DateTime>(d => d.Month == 1 && d.Day == 1), Arg.Is<DateTime>(d => d.Month == 12 && d.Day == 31))
.Returns(2000m);
_transactionsRepository.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
.Returns(new Dictionary<DateTime, decimal>());
// Act
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Assert
// 月度统计中,年度预算被忽略 (Limit=0)
result.Month.Limit.Should().Be(0);
result.Month.Current.Should().Be(0);
// 年度统计中
result.Year.Limit.Should().Be(12000);
result.Year.Current.Should().Be(2000);
}
[Fact]
public async Task GetCategoryStats_年度_1月_硬性收支_Test()
{
// Arrange
var referenceDate = new DateTime(2024, 1, 1); // 元旦
var budgets = new List<BudgetRecord>
{
new() { Id = 1, Name = "年度固定支出", Limit = 3660, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year, IsMandatoryExpense = true }
};
_budgetRepository.GetAllAsync().Returns(budgets);
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>()).Returns(0m);
_dateTimeProvider.Now.Returns(referenceDate);
_transactionsRepository.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
.Returns(new Dictionary<DateTime, decimal>());
// Act
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Assert
// 2024是闰年366天。1月1号是第1天。
// 3660 * 1 / 366 = 10
result.Year.Limit.Should().Be(3660);
result.Year.Current.Should().Be(10);
}
[Fact]
public async Task GetCategoryStats_年度_3月_硬性收支_Test()
{
// Arrange
var referenceDate = new DateTime(2024, 3, 31); // 3月最后一天
var budgets = new List<BudgetRecord>
{
new() { Id = 1, Name = "年度固定支出", Limit = 3660, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year, IsMandatoryExpense = true }
};
_budgetRepository.GetAllAsync().Returns(budgets);
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>()).Returns(0m);
_dateTimeProvider.Now.Returns(referenceDate);
_transactionsRepository.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
.Returns(new Dictionary<DateTime, decimal>());
// Act
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Assert
// 2024是闰年。1月(31) + 2月(29) + 3月(31) = 91天
// 3660 * 91 / 366 = 910
result.Year.Limit.Should().Be(3660);
result.Year.Current.Should().Be(910);
}
[Fact]
public async Task GetCategoryStats_月度_发生年度收支_Test()
{
// Arrange
var referenceDate = new DateTime(2024, 1, 15);
// 设置预算:包含月度预算和年度预算
var budgets = new List<BudgetRecord>
{
// 月度预算:吃喝
new() { Id = 1, Name = "吃喝", Limit = 2000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "餐饮,零食" },
// 月度预算:交通
new() { Id = 2, Name = "交通", Limit = 500, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "交通" },
// 年度预算:年度旅游(当前月度发生了相关支出)
new() { Id = 3, Name = "年度旅游", Limit = 12000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year, SelectedCategories = "旅游,度假" },
// 年度预算:年度奖金(当前月度发生了相关收入)
new() { Id = 4, Name = "年度奖金", Limit = 50000, Category = BudgetCategory.Income, Type = BudgetPeriodType.Year, SelectedCategories = "奖金,年终奖" }
};
_budgetRepository.GetAllAsync().Returns(budgets);
// 设置月度预算的当前金额
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.Returns(args =>
{
var b = (BudgetRecord)args[0];
return b.Name switch
{
"吃喝" => 1200m, // 月度预算已用1200元
"交通" => 300m, // 月度预算已用300元
"年度旅游" => 2000m, // 年度预算1月份已用2000元
"年度奖金" => 10000m, // 年度预算1月份已收10000元
_ => 0m
};
});
// 设置趋势统计数据为空(简化测试)
_transactionsRepository.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
.Returns(new Dictionary<DateTime, decimal>());
// Act - 测试支出统计
var expenseResult = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Act - 测试收入统计
var incomeResult = await _service.GetCategoryStatsAsync(BudgetCategory.Income, referenceDate);
// Assert - 支出月度统计:只包含月度预算
expenseResult.Month.Limit.Should().Be(2500); // 吃喝2000 + 交通500
expenseResult.Month.Current.Should().Be(1500); // 吃喝1200 + 交通300
expenseResult.Month.Count.Should().Be(2); // 只包含2个月度预算
expenseResult.Month.Rate.Should().Be(1500m / 2500m * 100); // 60%
// Assert - 支出年度统计:包含所有预算(月度+年度)
expenseResult.Year.Limit.Should().Be(12000 + (2500 * 12)); // 年度旅游12000 + 月度预算折算为年度(2500*12)
expenseResult.Year.Current.Should().Be(2000 + 1500); // 年度旅游2000 + 月度预算1500
expenseResult.Year.Count.Should().Be(3); // 包含3个预算2个月度+1个年度
// Assert - 收入月度统计只包含月度预算这里没有月度收入预算所以应该为0
incomeResult.Month.Limit.Should().Be(0); // 没有月度收入预算
incomeResult.Month.Current.Should().Be(0); // 没有月度收入预算
incomeResult.Month.Count.Should().Be(0); // 没有月度收入预算
// Assert - 收入年度统计:包含所有预算(只有年度收入预算)
incomeResult.Year.Limit.Should().Be(50000); // 年度奖金50000
incomeResult.Year.Current.Should().Be(10000); // 年度奖金已收10000
incomeResult.Year.Count.Should().Be(1); // 包含1个年度收入预算
}
}

View File

@@ -1,4 +1,4 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace WebApi.Controllers; namespace WebApi.Controllers;
@@ -9,14 +9,15 @@ public class LogController(ILogger<LogController> logger) : ControllerBase
/// <summary> /// <summary>
/// 获取日志列表(分页) /// 获取日志列表(分页)
/// </summary> /// </summary>
[HttpGet] [HttpGet]
public async Task<PagedResponse<LogEntry>> GetListAsync( public async Task<PagedResponse<LogEntry>> GetListAsync(
[FromQuery] int pageIndex = 1, [FromQuery] int pageIndex = 1,
[FromQuery] int pageSize = 50, [FromQuery] int pageSize = 50,
[FromQuery] string? searchKeyword = null, [FromQuery] string? searchKeyword = null,
[FromQuery] string? logLevel = null, [FromQuery] string? logLevel = null,
[FromQuery] string? date = null [FromQuery] string? date = null,
) [FromQuery] string? className = null
)
{ {
try try
{ {
@@ -52,7 +53,8 @@ public class LogController(ILogger<LogController> logger) : ControllerBase
pageIndex, pageIndex,
pageSize, pageSize,
searchKeyword, searchKeyword,
logLevel); logLevel,
className);
var pagedData = logEntries; var pagedData = logEntries;
@@ -65,6 +67,80 @@ public class LogController(ILogger<LogController> logger) : ControllerBase
} }
} }
/// <summary>
/// 根据请求ID查询关联日志
/// </summary>
[HttpGet]
public async Task<PagedResponse<LogEntry>> GetLogsByRequestIdAsync(
[FromQuery] string requestId,
[FromQuery] int pageIndex = 1,
[FromQuery] int pageSize = 50,
[FromQuery] string? date = null
)
{
try
{
if (string.IsNullOrEmpty(requestId))
{
return PagedResponse<LogEntry>.Fail("请求ID不能为空");
}
// 获取日志目录
var logDirectory = Path.Combine(Directory.GetCurrentDirectory(), "logs");
if (!Directory.Exists(logDirectory))
{
return PagedResponse<LogEntry>.Fail("日志目录不存在");
}
// 确定要读取的日志文件
string[] logFiles;
if (!string.IsNullOrEmpty(date))
{
var logFilePath = Path.Combine(logDirectory, $"log-{date}.txt");
logFiles = System.IO.File.Exists(logFilePath) ? [logFilePath] : [];
}
else
{
// 读取最近7天的日志文件
logFiles = Directory.GetFiles(logDirectory, "log-*.txt")
.OrderByDescending(f => f)
.Take(7)
.ToArray();
}
var allLogs = new List<LogEntry>();
foreach (var logFile in logFiles)
{
var lines = await ReadAllLinesAsync(logFile);
var merged = MergeMultiLineLog(lines);
foreach (var line in merged)
{
var entry = ParseLogLine(line);
if (entry != null && entry.RequestId == requestId)
{
allLogs.Add(entry);
}
}
}
// 按时间倒序排序
allLogs = allLogs.OrderByDescending(l => l.Timestamp).ToList();
var total = allLogs.Count;
var skip = Math.Max(0, (pageIndex - 1) * pageSize);
var pagedData = allLogs.Skip(skip).Take(pageSize).ToList();
return PagedResponse<LogEntry>.Done(pagedData.ToArray(), total);
}
catch (Exception ex)
{
logger.LogError(ex, "根据请求ID查询日志失败: RequestId={RequestId}", requestId);
return PagedResponse<LogEntry>.Fail($"查询失败: {ex.Message}");
}
}
/// <summary> /// <summary>
/// 获取可用的日志日期列表 /// 获取可用的日志日期列表
/// </summary> /// </summary>
@@ -95,6 +171,58 @@ public class LogController(ILogger<LogController> logger) : ControllerBase
} }
} }
/// <summary>
/// 获取可用的类名列表
/// </summary>
[HttpGet]
public BaseResponse<string[]> GetAvailableClassNames([FromQuery] string? date = null)
{
try
{
var logDirectory = Path.Combine(Directory.GetCurrentDirectory(), "logs");
if (!Directory.Exists(logDirectory))
{
return ((string[])[]).Ok();
}
string logFilePath;
if (!string.IsNullOrEmpty(date))
{
logFilePath = Path.Combine(logDirectory, $"log-{date}.txt");
}
else
{
var today = DateTime.Now.ToString("yyyyMMdd");
logFilePath = Path.Combine(logDirectory, $"log-{today}.txt");
}
if (!System.IO.File.Exists(logFilePath))
{
return ((string[])[]).Ok();
}
var classNames = new HashSet<string>();
var lines = ReadAllLinesAsync(logFilePath).GetAwaiter().GetResult();
var merged = MergeMultiLineLog(lines);
foreach (var line in merged)
{
var entry = ParseLogLine(line);
if (entry != null && !string.IsNullOrEmpty(entry.ClassName))
{
classNames.Add(entry.ClassName);
}
}
return classNames.OrderBy(c => c).ToArray().Ok();
}
catch (Exception ex)
{
logger.LogError(ex, "获取类名列表失败");
return $"获取类名列表失败: {ex.Message}".Fail<string[]>();
}
}
/// <summary> /// <summary>
/// 合并多行日志(已废弃,现在在流式读取中处理) /// 合并多行日志(已废弃,现在在流式读取中处理)
/// </summary> /// </summary>
@@ -150,22 +278,71 @@ public class LogController(ILogger<LogController> logger) : ControllerBase
{ {
try try
{ {
// 日志格式示例: [2025-12-29 16:38:45.730 +08:00] [INF] Message here // 日志格式示例: [2025-12-29 16:38:45.730 +08:00] [INF] [request-id] [SourceContext] Message here
// 使用正则表达式解析 // 使用正则表达式解析
// 使用 Singleline 模式使 '.' 可以匹配换行,这样 multi-line 消息可以被完整捕获。 // 使用 Singleline 模式使 '.' 可以匹配换行,这样 multi-line 消息可以被完整捕获。
var match = Regex.Match( var match = Regex.Match(
line, line,
@"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} [+-]\d{2}:\d{2})\] \[([A-Z]{2,5})\] ([\s\S]*)$", @"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} [+-]\d{2}:\d{2})\] \[([A-Z]{2,5})\] \[([^]]*)\] \[([^]]*)\] ([\s\S]*)$",
RegexOptions.Singleline RegexOptions.Singleline
); );
if (match.Success) if (match.Success)
{ {
var sourceContext = match.Groups[4].Value;
var (className, methodName) = ParseSourceContext(sourceContext);
return new LogEntry return new LogEntry
{ {
Timestamp = match.Groups[1].Value, Timestamp = match.Groups[1].Value,
Level = match.Groups[2].Value, Level = match.Groups[2].Value,
Message = match.Groups[3].Value RequestId = match.Groups[3].Value,
ClassName = className,
MethodName = methodName,
Message = match.Groups[5].Value
};
}
// 尝试解析旧的日志格式没有请求ID有 SourceContext
var oldMatch = Regex.Match(
line,
@"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} [+-]\d{2}:\d{2})\] \[([A-Z]{2,5})\] \[([^]]*)\] ([\s\S]*)$",
RegexOptions.Singleline
);
if (oldMatch.Success)
{
var sourceContext = oldMatch.Groups[3].Value;
var (className, methodName) = ParseSourceContext(sourceContext);
return new LogEntry
{
Timestamp = oldMatch.Groups[1].Value,
Level = oldMatch.Groups[2].Value,
RequestId = "",
ClassName = className,
MethodName = methodName,
Message = oldMatch.Groups[4].Value
};
}
// 尝试解析更旧的日志格式没有请求ID和 SourceContext
var veryOldMatch = Regex.Match(
line,
@"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} [+-]\d{2}:\d{2})\] \[([A-Z]{2,5})\] ([\s\S]*)$",
RegexOptions.Singleline
);
if (veryOldMatch.Success)
{
return new LogEntry
{
Timestamp = veryOldMatch.Groups[1].Value,
Level = veryOldMatch.Groups[2].Value,
RequestId = "",
ClassName = "",
MethodName = "",
Message = veryOldMatch.Groups[3].Value
}; };
} }
@@ -183,29 +360,52 @@ public class LogController(ILogger<LogController> logger) : ControllerBase
} }
} }
/// <summary>
/// 解析 SourceContext提取类名
/// </summary>
private (string className, string methodName) ParseSourceContext(string sourceContext)
{
if (string.IsNullOrWhiteSpace(sourceContext))
{
return ("", "");
}
// SourceContext 格式是完整的命名空间.类名,如: Service.Budget.BudgetStatsService
// 提取最后一个部分作为类名
var parts = sourceContext.Split('.');
if (parts.Length == 0)
{
return ("", "");
}
var className = parts[^1];
return (className, "");
}
/// <summary> /// <summary>
/// 读取日志 /// 读取日志
/// </summary> /// </summary>
private async Task<(List<LogEntry> entries, int total)> ReadLogsAsync( private async Task<(List<LogEntry> entries, int total)> ReadLogsAsync(
string path, string path,
int pageIndex, int pageIndex,
int pageSize, int pageSize,
string? searchKeyword, string? searchKeyword,
string? logLevel) string? logLevel,
string? className)
{
var allLines = await ReadAllLinesAsync(path);
var merged = MergeMultiLineLog(allLines);
var parsed = new List<LogEntry>();
foreach (var line in merged)
{ {
var allLines = await ReadAllLinesAsync(path); var entry = ParseLogLine(line);
if (entry != null && PassFilter(entry, searchKeyword, logLevel, className))
var merged = MergeMultiLineLog(allLines);
var parsed = new List<LogEntry>();
foreach (var line in merged)
{ {
var entry = ParseLogLine(line); parsed.Add(entry);
if (entry != null && PassFilter(entry, searchKeyword, logLevel))
{
parsed.Add(entry);
}
} }
}
parsed.Reverse(); parsed.Reverse();
@@ -219,23 +419,29 @@ public class LogController(ILogger<LogController> logger) : ControllerBase
/// <summary> /// <summary>
/// 检查日志条目是否通过过滤条件 /// 检查日志条目是否通过过滤条件
/// </summary> /// </summary>
private bool PassFilter(LogEntry logEntry, string? searchKeyword, string? logLevel) private bool PassFilter(LogEntry logEntry, string? searchKeyword, string? logLevel, string? className)
{
if (!string.IsNullOrEmpty(searchKeyword) &&
!logEntry.Message.Contains(searchKeyword, StringComparison.OrdinalIgnoreCase))
{ {
if (!string.IsNullOrEmpty(searchKeyword) && return false;
!logEntry.Message.Contains(searchKeyword, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.IsNullOrEmpty(logLevel) &&
!logEntry.Level.Equals(logLevel, StringComparison.OrdinalIgnoreCase))
{
return false;
}
return true;
} }
if (!string.IsNullOrEmpty(logLevel) &&
!logEntry.Level.Equals(logLevel, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.IsNullOrEmpty(className) &&
!logEntry.ClassName.Equals(className, StringComparison.OrdinalIgnoreCase))
{
return false;
}
return true;
}
/// <summary> /// <summary>
/// 读取文件所有行(支持共享读取) /// 读取文件所有行(支持共享读取)
/// </summary> /// </summary>
@@ -280,4 +486,19 @@ public class LogEntry
/// 日志消息 /// 日志消息
/// </summary> /// </summary>
public string Message { get; set; } = string.Empty; public string Message { get; set; } = string.Empty;
/// <summary>
/// 请求ID
/// </summary>
public string RequestId { get; set; } = string.Empty;
/// <summary>
/// 类名
/// </summary>
public string ClassName { get; set; } = string.Empty;
/// <summary>
/// 方法名
/// </summary>
public string MethodName { get; set; } = string.Empty;
} }

View File

@@ -0,0 +1,38 @@
using Serilog.Context;
namespace WebApi.Middleware;
public class RequestIdMiddleware
{
private readonly RequestDelegate _next;
public RequestIdMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
var requestId = context.Request.Headers["X-Request-ID"].FirstOrDefault() ?? Guid.NewGuid().ToString("N");
context.Items["RequestId"] = requestId;
using (LogContext.PushProperty("RequestId", requestId))
{
await _next(context);
}
}
}
public static class RequestIdExtensions
{
public static string? GetRequestId(this HttpContext context)
{
return context.Items["RequestId"] as string;
}
public static IApplicationBuilder UseRequestId(this IApplicationBuilder builder)
{
return builder.UseMiddleware<RequestIdMiddleware>();
}
}

View File

@@ -7,6 +7,7 @@ using Scalar.AspNetCore;
using Serilog; using Serilog;
using Service.AppSettingModel; using Service.AppSettingModel;
using WebApi; using WebApi;
using WebApi.Middleware;
using Yitter.IdGenerator; using Yitter.IdGenerator;
// 初始化雪花算法ID生成器 // 初始化雪花算法ID生成器
@@ -145,6 +146,9 @@ app.UseStaticFiles();
// 启用 CORS // 启用 CORS
app.UseCors(); app.UseCors();
// 启用请求ID跟踪
app.UseRequestId();
// 启用认证和授权 // 启用认证和授权
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();

View File

@@ -26,13 +26,13 @@
{ {
"Name": "Console" "Name": "Console"
}, },
{ {
"Name": "File", "Name": "File",
"Args": { "Args": {
"path": "logs/log-.txt", "path": "logs/log-.txt",
"rollingInterval": "Day", "rollingInterval": "Day",
"retainedFileCountLimit": 30, "retainedFileCountLimit": 30,
"outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {Message:lj}{NewLine}{Exception}" "outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{RequestId}] [{SourceContext}] {Message:lj}{NewLine}{Exception}"
} }
} }
], ],