340 lines
14 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|