using System.Text.Json; using MiGu.Server.Infra; namespace MiGu.Server.Configs; /// /// 运维操作审计存储:内存队列 + data/ops-audit.json 原子持久化。 /// /// 取代 OpsController 旧的「纯静态 ConcurrentQueue」——那种实现进程一重启审计全丢, /// 且无法满足「运维动作可追溯」的合规诉求。这里启动时回载历史,写入走原子落盘, /// 仅保留最近 条避免无限增长。 /// public sealed class OpsAuditStore { public sealed record AuditEntry( string Id, DateTimeOffset Ts, string User, string Scope, string OpCode, string Target, string Result, string? Message, string? IdempotencyKey = null); private const int MaxEntries = 500; private readonly object _gate = new(); private readonly string _file; private readonly ILogger _logger; private readonly List _entries = new(); private long _seq; private readonly JsonSerializerOptions _json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }; public OpsAuditStore(IWebHostEnvironment env, ILogger logger) { _logger = logger; var dir = Path.Combine(env.ContentRootPath, "data"); Directory.CreateDirectory(dir); _file = Path.Combine(dir, "ops-audit.json"); Load(); } private void Load() { try { if (!File.Exists(_file)) return; var list = JsonSerializer.Deserialize>(File.ReadAllText(_file), _json); if (list is { Count: > 0 }) { _entries.AddRange(list.Count > MaxEntries ? list.GetRange(list.Count - MaxEntries, MaxEntries) : list); // 续上序号,避免重启后 id 从 A000001 重新开始造成重复。 _seq = _entries.Select(e => ParseSeq(e.Id)).DefaultIfEmpty(0).Max(); } } catch (Exception ex) { _logger.LogWarning(ex, "ops 审计文件 {File} 加载失败,将以空记录开始并备份损坏文件", _file); AtomicFile.BackupCorrupt(_file); } } private static long ParseSeq(string id) => long.TryParse(id.TrimStart('A'), out var n) ? n : 0; /// 追加一条审计并原子落盘。返回生成的条目(含 Id)。 public AuditEntry Append(string user, string scope, string opCode, string target, string result, string? message, string? idempotencyKey = null) { lock (_gate) { var entry = new AuditEntry( $"A{++_seq:D6}", DateTimeOffset.UtcNow, user, scope, opCode, target, result, message, idempotencyKey); _entries.Add(entry); if (_entries.Count > MaxEntries) _entries.RemoveRange(0, _entries.Count - MaxEntries); Persist(); return entry; } } /// /// 查找指定幂等键最近一条「成功(ok)」审计,用于对重复下发去重。无则返回 null。 /// 仅匹配成功记录:上次失败的请求允许重试重新下发。 /// public AuditEntry? FindSuccessByIdempotencyKey(string? key) { if (string.IsNullOrWhiteSpace(key)) return null; lock (_gate) { for (var i = _entries.Count - 1; i >= 0; i--) { var e = _entries[i]; if (e.Result == "ok" && string.Equals(e.IdempotencyKey, key, StringComparison.Ordinal)) return e; } return null; } } /// 最近的审计(倒序,最新在前)。传入 user 时只返回该操作人(忽略大小写)。 public IReadOnlyList Recent(string? user = null) { lock (_gate) { IEnumerable src = _entries; if (!string.IsNullOrWhiteSpace(user)) src = _entries.Where(e => string.Equals(e.User, user, StringComparison.OrdinalIgnoreCase)); var copy = src.ToList(); copy.Reverse(); return copy; } } private void Persist() { try { AtomicFile.WriteAllText(_file, JsonSerializer.Serialize(_entries, _json)); } catch (Exception ex) { _logger.LogWarning(ex, "ops 审计持久化到 {File} 失败", _file); } } }