feat: add ERP agent pet bridge and startup guide
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user