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
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:
@@ -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;
|
||||
}
|
||||
38
WebApi/Middleware/RequestIdMiddleware.cs
Normal file
38
WebApi/Middleware/RequestIdMiddleware.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using Scalar.AspNetCore;
|
||||
using Serilog;
|
||||
using Service.AppSettingModel;
|
||||
using WebApi;
|
||||
using WebApi.Middleware;
|
||||
using Yitter.IdGenerator;
|
||||
|
||||
// 初始化雪花算法ID生成器
|
||||
@@ -145,6 +146,9 @@ app.UseStaticFiles();
|
||||
// 启用 CORS
|
||||
app.UseCors();
|
||||
|
||||
// 启用请求ID跟踪
|
||||
app.UseRequestId();
|
||||
|
||||
// 启用认证和授权
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
@@ -26,13 +26,13 @@
|
||||
{
|
||||
"Name": "Console"
|
||||
},
|
||||
{
|
||||
{
|
||||
"Name": "File",
|
||||
"Args": {
|
||||
"path": "logs/log-.txt",
|
||||
"rollingInterval": "Day",
|
||||
"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}"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user