Files
lserp_cs_6.0/插件库/Lskj.AgentBridge/JsonLineCommandAuditSink.cs
2026-08-14 14:28:28 +08:00

340 lines
14 KiB
C#

using System;
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using Lskj.CommandKernel;
using Newtonsoft.Json;
namespace Lskj.AgentBridge
{
public sealed class JsonLineCommandAuditSink :
ICommandAuditSink,
IBridgeOperationalAuditSink,
IDisposable
{
public const long DefaultMaximumFileBytes = 64L * 1024L * 1024L;
public const long DefaultMaximumDirectoryBytes = 512L * 1024L * 1024L;
public const int DefaultMaximumInstanceFiles = 256;
public const int MaximumRecordBytes = 64 * 1024;
private static readonly Regex SafeEventName = new Regex(
"^[a-z][a-z0-9_]{0,63}$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex SafeProtocolName = new Regex(
"^[A-Za-z0-9_.:-]{1,128}$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex SafeOutcomeCode = new Regex(
"^[a-z0-9_.-]{1,128}$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly object _syncRoot = new object();
private readonly string _path;
private readonly long _maximumFileBytes;
private FileStream _stream;
public JsonLineCommandAuditSink(string path)
: this(path, DefaultMaximumFileBytes, false)
{
}
public JsonLineCommandAuditSink(string path, long maximumFileBytes)
: this(path, maximumFileBytes, false)
{
}
private JsonLineCommandAuditSink(
string path,
long maximumFileBytes,
bool createNew)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("审计日志路径不能为空。", "path");
if (maximumFileBytes < 4096L || maximumFileBytes > 1024L * 1024L * 1024L)
throw new ArgumentOutOfRangeException(
"maximumFileBytes",
"单个审计日志容量必须在 4 KB 到 1 GB 之间。");
_path = Path.GetFullPath(path);
_maximumFileBytes = maximumFileBytes;
string directory = Path.GetDirectoryName(_path);
if (!string.IsNullOrWhiteSpace(directory)) Directory.CreateDirectory(directory);
try
{
_stream = Open(createNew ? FileMode.CreateNew : FileMode.OpenOrCreate);
EnsureWritableState(_stream);
_stream.Flush();
}
catch (IOException)
{
if (_stream != null) _stream.Dispose();
_stream = null;
if (createNew && File.Exists(_path))
throw Error(
"audit_log_instance_exists",
"当前 ERP 启动实例的审计文件已经存在,禁止复用或覆盖。");
throw;
}
catch
{
if (_stream != null) _stream.Dispose();
_stream = null;
throw;
}
}
public static JsonLineCommandAuditSink CreateProcessBound(
string directory,
int processId,
DateTime processStartedAtUtc)
{
return CreateProcessBound(
directory,
processId,
processStartedAtUtc,
DefaultMaximumFileBytes,
DefaultMaximumDirectoryBytes,
DefaultMaximumInstanceFiles);
}
public static JsonLineCommandAuditSink CreateProcessBound(
string directory,
int processId,
DateTime processStartedAtUtc,
long maximumFileBytes,
long maximumDirectoryBytes,
int maximumInstanceFiles)
{
if (maximumDirectoryBytes < maximumFileBytes
|| maximumDirectoryBytes > 8L * 1024L * 1024L * 1024L)
throw new ArgumentOutOfRangeException(
"maximumDirectoryBytes",
"审计目录容量必须覆盖一个日志文件且不能超过 8 GB。");
if (maximumInstanceFiles < 1 || maximumInstanceFiles > 4096)
throw new ArgumentOutOfRangeException(
"maximumInstanceFiles",
"审计实例文件数量上限必须在 1-4096 之间。");
string path = BuildProcessBoundPath(directory, processId, processStartedAtUtc);
string fullDirectory = Path.GetDirectoryName(path);
Directory.CreateDirectory(fullDirectory);
string[] existing = Directory.GetFiles(
fullDirectory,
"audit-p*-s*.jsonl",
SearchOption.TopDirectoryOnly);
long existingBytes = 0L;
foreach (string item in existing)
{
long length = new FileInfo(item).Length;
if (length > maximumDirectoryBytes - existingBytes)
throw Error(
"audit_archive_required",
"AgentBridge 审计目录已达到留存上限,请先按客户策略归档后再启用桥。");
existingBytes += length;
}
if (existing.Length >= maximumInstanceFiles
|| existingBytes > maximumDirectoryBytes - maximumFileBytes)
{
throw Error(
"audit_archive_required",
"AgentBridge 审计目录已达到留存上限,请先按客户策略归档后再启用桥。");
}
return new JsonLineCommandAuditSink(path, maximumFileBytes, true);
}
public static string BuildProcessBoundPath(
string directory,
int processId,
DateTime processStartedAtUtc)
{
if (string.IsNullOrWhiteSpace(directory))
throw new ArgumentException("审计日志目录不能为空。", "directory");
if (processId < 1)
throw new ArgumentOutOfRangeException("processId", "ERP 进程 ID 必须为正数。");
DateTime started = processStartedAtUtc.Kind == DateTimeKind.Utc
? processStartedAtUtc
: processStartedAtUtc.ToUniversalTime();
if (started == DateTime.MinValue || started == DateTime.MaxValue)
throw new ArgumentOutOfRangeException(
"processStartedAtUtc",
"ERP 进程启动时间无效。");
string fileName = string.Format(
CultureInfo.InvariantCulture,
"audit-p{0}-s{1}.jsonl",
processId,
started.Ticks);
return Path.Combine(Path.GetFullPath(directory), fileName);
}
public void Planned(CommandDescriptor descriptor, CommandPlan plan, CommandExecutionContext context)
{
Write("planned", descriptor, plan, context, null, null);
}
public void Completed(
CommandDescriptor descriptor,
CommandPlan plan,
CommandResult result,
CommandExecutionContext context)
{
Write("completed", descriptor, plan, context, result, null);
}
public void Failed(
CommandDescriptor descriptor,
CommandPlan plan,
Exception exception,
CommandExecutionContext context)
{
Write("failed", descriptor, plan, context, null, exception);
}
public void RecordOperationalEvent(
string eventName,
string method,
string commandName,
string stage,
string outcomeCode,
CommandExecutionContext context)
{
if (!SafeEventName.IsMatch(eventName ?? string.Empty)
|| !SafeProtocolName.IsMatch(method ?? string.Empty)
|| (!string.IsNullOrWhiteSpace(commandName)
&& !SafeProtocolName.IsMatch(commandName))
|| (stage != "request"
&& stage != "plan"
&& stage != "execute")
|| !SafeOutcomeCode.IsMatch(outcomeCode ?? string.Empty))
{
throw Error(
"audit_operational_event_invalid",
"AgentBridge 运维审计事件格式无效。");
}
Append(new
{
occurredAtUtc = DateTime.UtcNow,
eventName = eventName,
correlationId = context == null ? null : context.CorrelationId,
clientSessionId = context == null ? null : context.ClientSessionId,
userId = context == null ? null : context.UserId,
accountBook = context == null ? null : context.AccountBook,
subSystemId = context == null ? null : context.SubSystemId,
uatAuthorizationIdSha256 = context == null
? null : context.UatAuthorizationIdSha256,
uatCaseCode = context == null ? null : context.UatCaseCode,
method = method,
command = string.IsNullOrWhiteSpace(commandName)
? null : commandName,
stage = stage,
outcomeCode = outcomeCode
});
}
private void Write(
string eventName,
CommandDescriptor descriptor,
CommandPlan plan,
CommandExecutionContext context,
CommandResult result,
Exception exception)
{
CommandKernelException known = exception as CommandKernelException;
object record = new
{
occurredAtUtc = DateTime.UtcNow,
eventName = eventName,
correlationId = context == null ? null : context.CorrelationId,
clientSessionId = context == null ? null : context.ClientSessionId,
userId = context == null ? null : context.UserId,
accountBook = context == null ? null : context.AccountBook,
subSystemId = context == null ? null : context.SubSystemId,
uatAuthorizationIdSha256 = context == null
? null : context.UatAuthorizationIdSha256,
uatCaseCode = context == null ? null : context.UatCaseCode,
command = descriptor == null ? null : descriptor.Name,
commandVersion = descriptor == null ? null : descriptor.Version,
risk = descriptor == null ? null : descriptor.Risk.ToString().ToLowerInvariant(),
planId = plan == null ? null : plan.PlanId,
inputFingerprint = plan == null ? null : plan.InputFingerprint,
moduleCode = plan == null ? null : plan.ModuleCode,
success = result == null ? (bool?)null : result.Success,
resultCode = result == null ? null : result.Code,
recordId = result == null ? null : result.RecordId,
replayed = result == null ? (bool?)null : result.Replayed,
transactionEvidenceId = result == null ? null : result.TransactionEvidenceId,
businessAuditId = result == null ? null : result.BusinessAuditId,
errorCode = known == null ? null : known.Code,
exceptionType = exception == null ? null : exception.GetType().FullName
};
Append(record);
}
private void Append(object record)
{
string directory = Path.GetDirectoryName(_path);
if (!string.IsNullOrWhiteSpace(directory)) Directory.CreateDirectory(directory);
string line = JsonConvert.SerializeObject(record, Formatting.None) + Environment.NewLine;
byte[] bytes = new UTF8Encoding(false, true).GetBytes(line);
if (bytes.Length > MaximumRecordBytes)
throw Error(
"audit_record_too_large",
"AgentBridge 审计记录超过安全上限,已拒绝写入。");
lock (_syncRoot)
{
if (_stream == null)
throw Error("audit_log_unavailable", "AgentBridge 审计日志已经关闭。");
EnsureWritableState(_stream);
if (bytes.LongLength > _maximumFileBytes - _stream.Length)
throw Error(
"audit_log_capacity_exceeded",
"当前 ERP 启动实例的审计日志已满,请归档后重启受控桥会话。");
_stream.Position = _stream.Length;
_stream.Write(bytes, 0, bytes.Length);
_stream.Flush();
}
}
public void Dispose()
{
lock (_syncRoot)
{
if (_stream == null) return;
try { _stream.Flush(); }
finally
{
_stream.Dispose();
_stream = null;
}
}
}
private FileStream Open(FileMode mode)
{
return new FileStream(
_path,
mode,
FileAccess.ReadWrite,
FileShare.Read,
4096,
FileOptions.WriteThrough);
}
private void EnsureWritableState(FileStream stream)
{
if (stream == null) throw new ArgumentNullException("stream");
if (stream.Length > _maximumFileBytes)
throw Error(
"audit_log_capacity_exceeded",
"当前 ERP 启动实例的审计日志已超过安全上限。");
if (stream.Length == 0) return;
stream.Position = stream.Length - 1L;
if (stream.ReadByte() != (byte)'\n')
throw Error(
"audit_log_corrupt",
"AgentBridge 审计日志尾部不完整,禁止继续追加。");
}
private static CommandKernelException Error(string code, string message)
{
return new CommandKernelException(code, message, 6);
}
}
}