using Microsoft.Extensions.Logging; namespace MoShiro.Backend.Application; /// /// 审计日志里的一行:谁(,用户名或 key:令牌名)、怎么登录的()、做了什么(, /// 管理接口的 operationId,或 auth.login 这类登录事件)、对谁()、结果如何。 /// public sealed record AuditRecord(long Id, long AtMs, string? UserId, string Actor, string Method, string Action, string? Target, string? Detail, string? Remote, bool Success); public sealed record AuditQuery(long? BeforeId, int Limit, string? Actor, string? Action); /// 审计日志只追加:没有修改和单条删除,只按保留期整体清理。 public sealed partial class AuditService { /// 保留一年。审计记录的是谁动过信任相关的东西,比监测数据留得久。 public const int RetentionDays = 365; private readonly IAuditStore _store; private readonly TimeProvider _time; private readonly ILogger _logger; public AuditService(IAuditStore store, TimeProvider time, ILogger logger) { _store = store; _time = time; _logger = logger; } /// 记一条。写不进去只记日志,不能因为审计失败而让已经完成的操作报错。 public async ValueTask RecordAsync(Principal principal, string action, string? target, string? detail, string? remote, bool success, CancellationToken ct = default) => await RecordAsync(principal.UserId, principal.Label, MethodOf(principal), action, target, detail, remote, success, ct); public async ValueTask RecordAsync(string? userId, string actor, string method, string action, string? target, string? detail, string? remote, bool success, CancellationToken ct = default) { try { await _store.AddAsync(new AuditRecord(0, _time.GetUtcNow().ToUnixTimeMilliseconds(), userId, actor, method, action, Clip(target, 200), Clip(detail, 500), remote, success), ct); } catch (Exception ex) when (ex is not OperationCanceledException) { LogFailed(ex, action); } } public ValueTask> ListAsync(AuditQuery query, CancellationToken ct) => _store.ListAsync(query, ct); public ValueTask PruneAsync(long nowMs, CancellationToken ct) => _store.DeleteOlderThanAsync(nowMs - (RetentionDays * 86_400_000L), ct); /// 登录方式:密码、passkey,或 API key。 public static string MethodOf(Principal principal) => principal.Kind == PrincipalKind.Anonymous ? "anonymous" : principal.Kind == PrincipalKind.Token && principal.UserId is null ? "key" : principal.AuthMethod.Length > 0 ? principal.AuthMethod : AuthMethods.Password; private static string? Clip(string? text, int max) => text is { } t && t.Length > max ? t[..max] : text; [LoggerMessage(Level = LogLevel.Warning, Message = "could not write the audit record for {Action}")] private partial void LogFailed(Exception exception, string action); }