namespace Supercell.Laser.Server { using System.Globalization; using Spectre.Console; /// /// Структурированный аудит-лог для отслеживания подозрительных транзакций, /// входов, выходов, создания комнат и прочих важных событий. /// Пишет в logs/audit_YYYY-MM-DD.log и в консоль. /// public static class AuditLogger { private const int SuspiciousGemSpendThreshold = 500; private const int SuspiciousGemGainThreshold = 1000; private static readonly object SyncRoot = new object(); private static readonly string LogDir = Path.Combine(AppContext.BaseDirectory, "logs"); private static readonly Color PositiveColor = Color.Chartreuse2; private static readonly Color SpendColor = Color.Gold1; private static readonly Color BattleColor = Color.MediumPurple1; private static StreamWriter AuditWriter; private static StreamWriter SuspiciousWriter; private static DateOnly ActiveUtcDate; private static bool IsInitialized; static AuditLogger() { AppDomain.CurrentDomain.ProcessExit += (_, _) => DisposeWriters(); AppDomain.CurrentDomain.UnhandledException += (_, _) => DisposeWriters(); } public static void Login(long accountId, string name, string ip, string device) => Write("LOGIN", accountId, name, $"ip={NormalizeValue(ip)} device={NormalizeValue(device)}", Color.Cyan1); public static void Logout(long accountId, string name, string ip) => Write("LOGOUT", accountId, name, $"ip={NormalizeValue(ip)}", Color.Grey); public static void LoginFailed(string reason, string ip, string device) => Write("LOGIN_FAIL", 0, "-", $"reason={NormalizeValue(reason)} ip={NormalizeValue(ip)} device={NormalizeValue(device)}", Color.Yellow1); public static void BanApplied(long accountId, string name, string reason, string byAdmin) => Write("BAN", accountId, name, $"reason={NormalizeValue(reason)} by={NormalizeValue(byAdmin)}", Color.Red1); public static void BanRemoved(long accountId, string name, string byAdmin) => Write("UNBAN", accountId, name, $"by={NormalizeValue(byAdmin)}", PositiveColor); public static void BattleCreated(long battleId, int locationId, int playerCount, string mode) => Write("BATTLE_CREATE", battleId, "-", $"location={locationId} players={playerCount} mode={NormalizeValue(mode)}", BattleColor); public static void BattleEnded(long battleId, int winnerTeam, int ticks) => Write("BATTLE_END", battleId, "-", $"winner_team={winnerTeam} ticks={ticks}", BattleColor); public static void GemSpend(long accountId, string name, int amount, string reason, int balanceBefore, int balanceAfter) { string details = FormatBalanceDetails(amount, reason, balanceBefore, balanceAfter); Write("GEM_SPEND", accountId, name, details, SpendColor); if (amount > SuspiciousGemSpendThreshold) Suspicious("GEM_SPEND_LARGE", accountId, name, details); } public static void GemGain(long accountId, string name, int amount, string reason, int balanceBefore, int balanceAfter) { string details = FormatBalanceDetails(amount, reason, balanceBefore, balanceAfter); Write("GEM_GAIN", accountId, name, details, PositiveColor); if (amount > SuspiciousGemGainThreshold) Suspicious("GEM_GAIN_LARGE", accountId, name, details); } public static void GoldSpend(long accountId, string name, int amount, string reason, int balanceBefore, int balanceAfter) => Write("GOLD_SPEND", accountId, name, FormatBalanceDetails(amount, reason, balanceBefore, balanceAfter), SpendColor); public static void GoldGain(long accountId, string name, int amount, string reason, int balanceBefore, int balanceAfter) => Write("GOLD_GAIN", accountId, name, FormatBalanceDetails(amount, reason, balanceBefore, balanceAfter), PositiveColor); public static void StarPointsSpend(long accountId, string name, int amount, string reason) => Write("SP_SPEND", accountId, name, $"amount={amount} reason={NormalizeValue(reason)}", SpendColor); public static void StarPointsGain(long accountId, string name, int amount, string reason) => Write("SP_GAIN", accountId, name, $"amount={amount} reason={NormalizeValue(reason)}", PositiveColor); public static void BoxOpened(long accountId, string name, int boxType, int cost, string currency) => Write("BOX_OPEN", accountId, name, $"box={boxType} cost={cost} currency={NormalizeValue(currency)}", Color.Turquoise2); public static void HeroUpgraded(long accountId, string name, int heroId, int newLevel, int goldCost) => Write("HERO_UPGRADE", accountId, name, $"hero={heroId} level={newLevel} gold={goldCost}", Color.Turquoise2); public static void RewardClaimed(long accountId, string name, int milestoneId, int rewardType, int rewardCount) => Write("REWARD_CLAIM", accountId, name, $"milestone={milestoneId} type={rewardType} count={rewardCount}", PositiveColor); public static void NameChanged(long accountId, string oldName, string newName) => Write("NAME_CHANGE", accountId, oldName, $"new_name={NormalizeValue(newName)}", Color.Cyan1); public static void NameBlocked(long accountId, string name, string attempted) => Write("NAME_BLOCKED", accountId, name, $"attempted={NormalizeValue(attempted)}", Color.Yellow1); public static void Suspicious(string eventType, long accountId, string name, string details) => Write($"⚠ SUSPICIOUS[{NormalizeValue(eventType)}]", accountId, name, details, Color.Red1, isSuspicious: true); private static void Write(string category, long id, string name, string details, Color color, bool isSuspicious = false) { string safeName = NormalizeValue(name); string safeDetails = NormalizeValue(details); DateTime nowUtc = DateTime.UtcNow; string line = FormatLine(nowUtc, category, id, safeName, safeDetails); lock (SyncRoot) { try { EnsureWriters(nowUtc); AuditWriter.WriteLine(line); if (isSuspicious) SuspiciousWriter.WriteLine(line); } catch { ResetWriters(); } WriteToConsole(line, category, id, safeName, safeDetails, color, isSuspicious); } } private static string FormatLine(DateTime nowUtc, string category, long id, string name, string details) => string.Create( CultureInfo.InvariantCulture, $"{nowUtc:yyyy-MM-dd HH:mm:ss} | {category,-18} | #{id,-14} | {name,-24} | {details}"); private static string FormatBalanceDetails(int amount, string reason, int balanceBefore, int balanceAfter) => string.Create( CultureInfo.InvariantCulture, $"amount={amount} reason={NormalizeValue(reason)} balance={balanceBefore}->{balanceAfter}"); private static string NormalizeValue(string value) { if (string.IsNullOrWhiteSpace(value)) return "?"; if (value.IndexOfAny(['\r', '\n']) < 0) return value; return value.ReplaceLineEndings(" "); } private static void EnsureWriters(DateTime nowUtc) { DateOnly currentDate = DateOnly.FromDateTime(nowUtc); if (IsInitialized && currentDate == ActiveUtcDate) return; Directory.CreateDirectory(LogDir); if (IsInitialized) DisposeWritersUnsafe(); string datePart = nowUtc.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); AuditWriter = CreateWriter($"audit_{datePart}.log"); SuspiciousWriter = CreateWriter($"suspicious_{datePart}.log"); ActiveUtcDate = currentDate; IsInitialized = true; } private static StreamWriter CreateWriter(string fileName) { var stream = new FileStream( Path.Combine(LogDir, fileName), FileMode.Append, FileAccess.Write, FileShare.ReadWrite, bufferSize: 16 * 1024, FileOptions.SequentialScan); return new StreamWriter(stream) { AutoFlush = true }; } private static void WriteToConsole(string line, string category, long id, string name, string details, Color color, bool isSuspicious) { if (isSuspicious) { AnsiConsole.Write(new Text($"{line}\n", new Style(Color.Red1, decoration: Decoration.Bold))); return; } AnsiConsole.Write(new Text(" [AUDIT] ", new Style(Color.Grey))); AnsiConsole.Write(new Text($"{category,-16}", new Style(color, decoration: Decoration.Bold))); AnsiConsole.Write(new Text($" #{id,-12} {name,-20} {details}\n", new Style(Color.White))); } private static void ResetWriters() { DisposeWritersUnsafe(); IsInitialized = false; ActiveUtcDate = default; } private static void DisposeWriters() { lock (SyncRoot) { DisposeWritersUnsafe(); IsInitialized = false; ActiveUtcDate = default; } } private static void DisposeWritersUnsafe() { AuditWriter?.Dispose(); SuspiciousWriter?.Dispose(); AuditWriter = null; SuspiciousWriter = null; } } }