AlarmLogWatcher.cs 8.48 KB
using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Npgsql;

namespace Rcs.Api.Diagnostics;

internal static class AlarmLogWatcher
{
    private static readonly object SyncRoot = new();
    private static readonly ConcurrentDictionary<string, long> FileOffsets = new(StringComparer.OrdinalIgnoreCase);
    private static readonly HashSet<string> AlarmPatterns = new(StringComparer.Ordinal)
    {
        "VDA5050 - 补充虚拟路径失败",
        "VDA5050 - 补充终点虚拟路径失败",
        "VDA5050 - 补充终点前置虚拟路径失败",
        "VDA5050 - 补充终点前置虚拟路径后,无法定位真实终点段",
        "VDA5050 - 补充终点前置虚拟路径后校验失败",
        "VDA5050 - 申请事件挂载失败",
        "VDA5050 - 完成事件挂载失败",
        "[路径CAS] 指标告警"
    };

    private static Timer? _timer;
    private static string? _logsPath;
    private static string? _connectionString;
    private static int _isRunning;
    private static bool _started;

    public static void Start()
    {
        lock (SyncRoot)
        {
            if (_started)
            {
                return;
            }

            _started = true;
            _logsPath = Path.Combine(Directory.GetCurrentDirectory(), "logs");
            _connectionString = ResolveConnectionString();

            if (string.IsNullOrWhiteSpace(_connectionString))
            {
                return;
            }

            _timer = new Timer(async _ => await PollAsync(), null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(5));
        }
    }

    private static async Task PollAsync()
    {
        if (Interlocked.Exchange(ref _isRunning, 1) == 1)
        {
            return;
        }

        try
        {
            if (string.IsNullOrWhiteSpace(_logsPath) || !Directory.Exists(_logsPath))
            {
                return;
            }

            var logFiles = Directory.EnumerateFiles(_logsPath, "log-*.log", SearchOption.TopDirectoryOnly)
                .OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
                .ToList();

            foreach (var filePath in logFiles)
            {
                await ProcessFileAsync(filePath);
            }
        }
        catch
        {
            // Swallow errors to avoid impacting the host startup/runtime path.
        }
        finally
        {
            Interlocked.Exchange(ref _isRunning, 0);
        }
    }

    private static async Task ProcessFileAsync(string filePath)
    {
        var fileInfo = new FileInfo(filePath);
        var previousOffset = FileOffsets.GetOrAdd(filePath, _ => Math.Max(0, fileInfo.Length - 32 * 1024));
        var safeOffset = Math.Min(previousOffset, fileInfo.Length);

        using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
        stream.Seek(safeOffset, SeekOrigin.Begin);
        using var reader = new StreamReader(stream, new UTF8Encoding(false, false));

        var pendingLines = new List<string>();
        while (!reader.EndOfStream)
        {
            var line = await reader.ReadLineAsync();
            if (string.IsNullOrWhiteSpace(line))
            {
                continue;
            }

            pendingLines.Add(line);
        }

        FileOffsets[filePath] = stream.Position;

        foreach (var line in pendingLines)
        {
            var matchedPattern = AlarmPatterns.FirstOrDefault(pattern => line.Contains(pattern, StringComparison.Ordinal));
            if (matchedPattern == null)
            {
                continue;
            }

            await TryInsertAlarmAsync(filePath, matchedPattern, line);
        }
    }

    private static async Task TryInsertAlarmAsync(string filePath, string matchedPattern, string line)
    {
        if (string.IsNullOrWhiteSpace(_connectionString))
        {
            return;
        }

        var occurredAt = TryParseTimestamp(line) ?? DateTime.Now;
        var alarmCode = ResolveAlarmCode(matchedPattern);
        var title = matchedPattern.Length > 200 ? matchedPattern[..200] : matchedPattern;
        var sourceCode = ResolveSourceCode(matchedPattern);
        var extraData = JsonSerializer.Serialize(new
        {
            filePath,
            matchedPattern,
            line
        });

        await using var connection = new NpgsqlConnection(_connectionString);
        await connection.OpenAsync();

        const string sql = """
            INSERT INTO alarm_logs (
                alarm_log_id,
                alarm_code,
                alarm_type,
                level,
                source_type,
                source_code,
                source_name,
                title,
                message,
                details,
                extra_data,
                is_acknowledged,
                occurred_at,
                created_at
            )
            SELECT
                @alarm_log_id,
                @alarm_code,
                @alarm_type,
                @level,
                @source_type,
                @source_code,
                @source_name,
                @title,
                @message,
                @details,
                @extra_data,
                FALSE,
                @occurred_at,
                @created_at
            WHERE NOT EXISTS (
                SELECT 1
                FROM alarm_logs
                WHERE alarm_code = @alarm_code
                  AND message = @message
                  AND occurred_at = @occurred_at
            );
            """;

        await using var command = new NpgsqlCommand(sql, connection);
        command.Parameters.AddWithValue("alarm_log_id", Guid.NewGuid());
        command.Parameters.AddWithValue("alarm_code", alarmCode);
        command.Parameters.AddWithValue("alarm_type", "system");
        command.Parameters.AddWithValue("level", "warning");
        command.Parameters.AddWithValue("source_type", "protocol");
        command.Parameters.AddWithValue("source_code", sourceCode);
        command.Parameters.AddWithValue("source_name", "AlarmLogWatcher");
        command.Parameters.AddWithValue("title", title);
        command.Parameters.AddWithValue("message", line);
        command.Parameters.AddWithValue("details", matchedPattern);
        command.Parameters.AddWithValue("extra_data", extraData);
        command.Parameters.AddWithValue("occurred_at", occurredAt);
        command.Parameters.AddWithValue("created_at", DateTime.Now);
        await command.ExecuteNonQueryAsync();
    }

    private static string? ResolveConnectionString()
    {
        try
        {
            var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production";
            var configuration = new ConfigurationBuilder()
                .SetBasePath(Directory.GetCurrentDirectory())
                .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
                .AddJsonFile($"appsettings.{environment}.json", optional: true, reloadOnChange: false)
                .AddEnvironmentVariables()
                .Build();

            return configuration["AppSettings:ConnSql:ConnectionString"]
                   ?? configuration.GetConnectionString("DefaultConnection");
        }
        catch
        {
            return null;
        }
    }

    private static DateTime? TryParseTimestamp(string line)
    {
        var bracketIndex = line.IndexOf(" [", StringComparison.Ordinal);
        if (bracketIndex <= 0)
        {
            return null;
        }

        var timestampText = line[..bracketIndex].Trim();
        return DateTime.TryParse(timestampText, out var parsed)
            ? parsed
            : null;
    }

    private static string ResolveAlarmCode(string matchedPattern)
    {
        return matchedPattern switch
        {
            "[路径CAS] 指标告警" => "VDA_PATH_CAS_ALERT",
            _ when matchedPattern.Contains("补充虚拟路径失败", StringComparison.Ordinal) => "VDA_VIRTUAL_SEGMENT_FAILED",
            _ when matchedPattern.Contains("申请事件挂载失败", StringComparison.Ordinal) => "VDA_APPLY_ACTION_ATTACH_FAILED",
            _ when matchedPattern.Contains("完成事件挂载失败", StringComparison.Ordinal) => "VDA_COMPLETE_ACTION_ATTACH_FAILED",
            _ => "SYSTEM_WARNING"
        };
    }

    private static string ResolveSourceCode(string matchedPattern)
    {
        return matchedPattern.StartsWith("[路径CAS]", StringComparison.Ordinal)
            ? "VDA5050_PATH_CAS"
            : "VDA5050_PROTOCOL";
    }
}