OpsController 由「假成功队列」改为经投影反射 API 真实下发 SimpleLite,按 appsettings 的 Ops:Dispatch 映射 opCode→kind:Method,并做白名单 + JWT ops claim 双重校验; 审计改用 OpsAuditStore 原子落盘(取代进程内队列,重启不丢),新增 IdempotencyKey 幂等去重; ExtractNumericId 取首段数字避免 AGV-12-3 误合并。appsettings 同步加 Ops:Dispatch 说明与 sl-route 鉴权策略。 Co-authored-by: Cursor <cursoragent@cursor.com>
129 lines
4.2 KiB
C#
129 lines
4.2 KiB
C#
using System.Text.Json;
|
|
using MiGu.Server.Infra;
|
|
|
|
namespace MiGu.Server.Configs;
|
|
|
|
/// <summary>
|
|
/// 运维操作审计存储:内存队列 + <c>data/ops-audit.json</c> 原子持久化。
|
|
///
|
|
/// 取代 OpsController 旧的「纯静态 ConcurrentQueue」——那种实现进程一重启审计全丢,
|
|
/// 且无法满足「运维动作可追溯」的合规诉求。这里启动时回载历史,写入走原子落盘,
|
|
/// 仅保留最近 <see cref="MaxEntries"/> 条避免无限增长。
|
|
/// </summary>
|
|
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<OpsAuditStore> _logger;
|
|
private readonly List<AuditEntry> _entries = new();
|
|
private long _seq;
|
|
|
|
private readonly JsonSerializerOptions _json = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
WriteIndented = false
|
|
};
|
|
|
|
public OpsAuditStore(IWebHostEnvironment env, ILogger<OpsAuditStore> 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<List<AuditEntry>>(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;
|
|
|
|
/// <summary>追加一条审计并原子落盘。返回生成的条目(含 Id)。</summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 查找指定幂等键最近一条「成功(ok)」审计,用于对重复下发去重。无则返回 null。
|
|
/// 仅匹配成功记录:上次失败的请求允许重试重新下发。
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>最近的审计(倒序,最新在前)。</summary>
|
|
public IReadOnlyList<AuditEntry> Recent()
|
|
{
|
|
lock (_gate)
|
|
{
|
|
var copy = new List<AuditEntry>(_entries);
|
|
copy.Reverse();
|
|
return copy;
|
|
}
|
|
}
|
|
|
|
private void Persist()
|
|
{
|
|
try
|
|
{
|
|
AtomicFile.WriteAllText(_file, JsonSerializer.Serialize(_entries, _json));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "ops 审计持久化到 {File} 失败", _file);
|
|
}
|
|
}
|
|
}
|