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. 代码清理:移除不需要的方法名相关代码,简化日志筛选逻辑
This commit is contained in:
SunCheng
2026-01-22 19:06:58 +08:00
parent e2c0ab5389
commit 9e14849014
11 changed files with 2013 additions and 494 deletions

View File

@@ -1,4 +1,4 @@
using System.Text.RegularExpressions;
using System.Text.RegularExpressions;
namespace WebApi.Controllers;
@@ -9,14 +9,15 @@ public class LogController(ILogger<LogController> logger) : ControllerBase
/// <summary>
/// 获取日志列表(分页)
/// </summary>
[HttpGet]
public async Task<PagedResponse<LogEntry>> GetListAsync(
[FromQuery] int pageIndex = 1,
[FromQuery] int pageSize = 50,
[FromQuery] string? searchKeyword = null,
[FromQuery] string? logLevel = null,
[FromQuery] string? date = null
)
[HttpGet]
public async Task<PagedResponse<LogEntry>> GetListAsync(
[FromQuery] int pageIndex = 1,
[FromQuery] int pageSize = 50,
[FromQuery] string? searchKeyword = null,
[FromQuery] string? logLevel = null,
[FromQuery] string? date = null,
[FromQuery] string? className = null
)
{
try
{
@@ -52,7 +53,8 @@ public class LogController(ILogger<LogController> logger) : ControllerBase
pageIndex,
pageSize,
searchKeyword,
logLevel);
logLevel,
className);
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>
@@ -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>
@@ -150,22 +278,71 @@ public class LogController(ILogger<LogController> logger) : ControllerBase
{
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 消息可以被完整捕获。
var match = 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]*)$",
@"^\[(\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 (match.Success)
{
var sourceContext = match.Groups[4].Value;
var (className, methodName) = ParseSourceContext(sourceContext);
return new LogEntry
{
Timestamp = match.Groups[1].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>
private async Task<(List<LogEntry> entries, int total)> ReadLogsAsync(
string path,
int pageIndex,
int pageSize,
string? searchKeyword,
string? logLevel)
private async Task<(List<LogEntry> entries, int total)> ReadLogsAsync(
string path,
int pageIndex,
int pageSize,
string? searchKeyword,
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 merged = MergeMultiLineLog(allLines);
var parsed = new List<LogEntry>();
foreach (var line in merged)
var entry = ParseLogLine(line);
if (entry != null && PassFilter(entry, searchKeyword, logLevel, className))
{
var entry = ParseLogLine(line);
if (entry != null && PassFilter(entry, searchKeyword, logLevel))
{
parsed.Add(entry);
}
parsed.Add(entry);
}
}
parsed.Reverse();
@@ -219,23 +419,29 @@ public class LogController(ILogger<LogController> logger) : ControllerBase
/// <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) &&
!logEntry.Message.Contains(searchKeyword, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.IsNullOrEmpty(logLevel) &&
!logEntry.Level.Equals(logLevel, StringComparison.OrdinalIgnoreCase))
{
return false;
}
return true;
return false;
}
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>
@@ -280,4 +486,19 @@ public class LogEntry
/// 日志消息
/// </summary>
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;
}