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); } } }