493 lines
19 KiB
C#
493 lines
19 KiB
C#
using System.Diagnostics;
|
|
using System.IO.Pipes;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using Lskj.AgentPet.Host.Core.Configuration;
|
|
using Microsoft.Win32.SafeHandles;
|
|
|
|
namespace Lskj.AgentPet.Host.Core.ErpBridge;
|
|
|
|
public interface IErpBridgeClient
|
|
{
|
|
Task<JsonDocument> SendAsync(JsonElement request, CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
public sealed class ErpBridgeClient : IErpBridgeClient
|
|
{
|
|
internal static readonly TimeSpan MaximumProcessStartDrift = TimeSpan.FromSeconds(1);
|
|
private static readonly Regex SafePipe = new(
|
|
"^lserp\\.agent\\.(?<pid>[0-9]{1,10})"
|
|
+ "\\.(?<instance>[a-f0-9]{32})$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeCode = new(
|
|
"^[A-Za-z0-9_.:-]{1,128}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeBridgeInstanceId = new(
|
|
"^[a-f0-9]{32}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex TimeZoneSuffix = new(
|
|
"(?:Z|[+-][0-9]{2}:[0-9]{2})$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly ISet<string> AllowedResponseProperties = new HashSet<string>(
|
|
new[]
|
|
{
|
|
"protocolVersion",
|
|
"requestId",
|
|
"correlationId",
|
|
"success",
|
|
"code",
|
|
"message",
|
|
"data"
|
|
},
|
|
StringComparer.Ordinal);
|
|
private static readonly ISet<string> AllowedErrorDataProperties = new HashSet<string>(
|
|
new[] { "recovery" },
|
|
StringComparer.Ordinal);
|
|
private static readonly ISet<string> AllowedRecoveryProperties = new HashSet<string>(
|
|
new[] { "action", "retryable", "planInvalidated", "message" },
|
|
StringComparer.Ordinal);
|
|
private static readonly ISet<string> AllowedRecoveryActions = new HashSet<string>(
|
|
new[]
|
|
{
|
|
"review_and_retry",
|
|
"reconcile_execution",
|
|
"restart_erp_pet",
|
|
"inspect_existing_record",
|
|
"replan",
|
|
"correct_request",
|
|
"contact_administrator",
|
|
"wait_and_retry",
|
|
"contact_support"
|
|
},
|
|
StringComparer.Ordinal);
|
|
private static readonly ISet<string> AllowedDiscoveryProperties = new HashSet<string>(
|
|
new[]
|
|
{
|
|
"protocolVersion",
|
|
"pipeName",
|
|
"processId",
|
|
"startedAtUtc",
|
|
"bridgeInstanceId"
|
|
},
|
|
StringComparer.Ordinal);
|
|
private readonly HostConfiguration _configuration;
|
|
private readonly object _bridgeInstanceSync = new();
|
|
private string? _boundBridgeInstanceId;
|
|
|
|
public ErpBridgeClient(HostConfiguration configuration)
|
|
{
|
|
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
|
}
|
|
|
|
public async Task<JsonDocument> SendAsync(
|
|
JsonElement request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!OperatingSystem.IsWindows())
|
|
throw new HostError("windows_required", "ERP 命名管道只支持 Windows。");
|
|
if (request.ValueKind != JsonValueKind.Object)
|
|
throw new HostError("bridge_request_invalid", "ERP 桥请求必须是 JSON 对象。");
|
|
string requestId = RequiredString(request, "requestId", 128);
|
|
string correlationId = RequiredString(request, "correlationId", 128);
|
|
byte[] body = JsonSerializer.SerializeToUtf8Bytes(request);
|
|
if (body.Length > BridgeFrameCodec.MaxMessageBytes)
|
|
throw new HostError("bridge_protocol_error", "ERP 桥请求超过 1 MB。");
|
|
|
|
ErpBridgeDiscovery discovery = FindPipe();
|
|
lock (_bridgeInstanceSync)
|
|
{
|
|
_boundBridgeInstanceId = RequireStableBridgeInstance(
|
|
_boundBridgeInstanceId,
|
|
discovery);
|
|
}
|
|
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
timeout.CancelAfter(_configuration.BridgeTimeout);
|
|
await using NamedPipeClientStream pipe = new(
|
|
".",
|
|
discovery.PipeName,
|
|
PipeDirection.InOut,
|
|
PipeOptions.Asynchronous);
|
|
try
|
|
{
|
|
await pipe.ConnectAsync(timeout.Token).ConfigureAwait(false);
|
|
VerifyConnectedServer(pipe, discovery);
|
|
await BridgeFrameCodec.WriteAsync(pipe, body, timeout.Token).ConfigureAwait(false);
|
|
byte[] responseBytes = await BridgeFrameCodec.ReadAsync(pipe, timeout.Token).ConfigureAwait(false);
|
|
JsonDocument response = JsonDocument.Parse(responseBytes, new JsonDocumentOptions
|
|
{
|
|
AllowTrailingCommas = false,
|
|
CommentHandling = JsonCommentHandling.Disallow,
|
|
MaxDepth = 64
|
|
});
|
|
try
|
|
{
|
|
ValidateResponse(response.RootElement, requestId, correlationId);
|
|
}
|
|
catch
|
|
{
|
|
response.Dispose();
|
|
throw;
|
|
}
|
|
return response;
|
|
}
|
|
catch (OperationCanceledException error) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
throw new HostError("bridge_timeout", "连接或调用 ERP 命令桥超时。", error);
|
|
}
|
|
catch (JsonException error)
|
|
{
|
|
throw new HostError("bridge_protocol_error", "ERP 桥返回无效 JSON。", error);
|
|
}
|
|
catch (IOException error)
|
|
{
|
|
throw new HostError("bridge_unavailable", "ERP 命令桥连接失败。", error);
|
|
}
|
|
}
|
|
|
|
private ErpBridgeDiscovery FindPipe()
|
|
{
|
|
DirectoryInfo directory = new(_configuration.BridgeDiscoveryDirectory);
|
|
if (!directory.Exists)
|
|
throw new HostError("erp_bridge_not_running", "未发现已登录 ERP 的命令桥。");
|
|
List<ErpBridgeDiscovery> live = new();
|
|
foreach (FileInfo file in directory.EnumerateFiles("agentbridge-*.json")
|
|
.Where(item => (item.Attributes & FileAttributes.ReparsePoint) == 0)
|
|
.OrderByDescending(item => item.LastWriteTimeUtc))
|
|
{
|
|
try
|
|
{
|
|
if (file.Length is <= 0 or > 64 * 1024) continue;
|
|
using FileStream stream = new(
|
|
file.FullName,
|
|
FileMode.Open,
|
|
FileAccess.Read,
|
|
FileShare.ReadWrite | FileShare.Delete,
|
|
4096,
|
|
FileOptions.SequentialScan);
|
|
using JsonDocument document = JsonDocument.Parse(stream, new JsonDocumentOptions
|
|
{
|
|
AllowTrailingCommas = false,
|
|
CommentHandling = JsonCommentHandling.Disallow,
|
|
MaxDepth = 16
|
|
});
|
|
JsonElement root = document.RootElement;
|
|
ErpBridgeDiscovery discovery = ValidateDiscovery(root);
|
|
if (!string.Equals(
|
|
file.Name,
|
|
"agentbridge-" + discovery.ProcessId + ".json",
|
|
StringComparison.OrdinalIgnoreCase)
|
|
|| !ProcessMatches(discovery)) continue;
|
|
live.Add(discovery);
|
|
}
|
|
catch (Exception error) when (error is IOException
|
|
or UnauthorizedAccessException
|
|
or JsonException
|
|
or InvalidOperationException
|
|
or KeyNotFoundException
|
|
or System.ComponentModel.Win32Exception
|
|
or ArgumentException)
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
return SelectDiscovery(
|
|
live,
|
|
_configuration.BridgeProcessId,
|
|
_configuration.BridgeProcessStartedAtUnixSeconds);
|
|
}
|
|
|
|
private static void VerifyConnectedServer(
|
|
NamedPipeClientStream pipe,
|
|
ErpBridgeDiscovery discovery)
|
|
{
|
|
uint serverProcessId = 0;
|
|
bool querySucceeded;
|
|
try
|
|
{
|
|
querySucceeded = GetNamedPipeServerProcessId(
|
|
pipe.SafePipeHandle,
|
|
out serverProcessId);
|
|
}
|
|
catch (Exception error) when (error is DllNotFoundException
|
|
or EntryPointNotFoundException
|
|
or ObjectDisposedException
|
|
or InvalidOperationException)
|
|
{
|
|
querySucceeded = false;
|
|
}
|
|
|
|
bool processStillMatches;
|
|
try
|
|
{
|
|
processStillMatches = ProcessMatches(discovery);
|
|
}
|
|
catch (Exception error) when (error is ArgumentException
|
|
or InvalidOperationException
|
|
or System.ComponentModel.Win32Exception)
|
|
{
|
|
processStillMatches = false;
|
|
}
|
|
ValidateConnectedServerIdentity(
|
|
discovery,
|
|
querySucceeded,
|
|
serverProcessId,
|
|
processStillMatches);
|
|
}
|
|
|
|
internal static void ValidateConnectedServerIdentity(
|
|
ErpBridgeDiscovery discovery,
|
|
bool querySucceeded,
|
|
uint serverProcessId,
|
|
bool processStillMatches)
|
|
{
|
|
if (discovery is null
|
|
|| discovery.ProcessId <= 0
|
|
|| !querySucceeded
|
|
|| serverProcessId != (uint)discovery.ProcessId
|
|
|| !processStillMatches)
|
|
{
|
|
throw new HostError(
|
|
"bridge_server_identity_mismatch",
|
|
"ERP 命令桥服务进程身份不匹配,连接已关闭。");
|
|
}
|
|
}
|
|
|
|
internal static string RequireStableBridgeInstance(
|
|
string? expectedBridgeInstanceId,
|
|
ErpBridgeDiscovery discovery)
|
|
{
|
|
if (discovery is null
|
|
|| !SafeBridgeInstanceId.IsMatch(discovery.BridgeInstanceId))
|
|
throw ProtocolError();
|
|
if (string.IsNullOrEmpty(expectedBridgeInstanceId))
|
|
return discovery.BridgeInstanceId;
|
|
if (!string.Equals(
|
|
expectedBridgeInstanceId,
|
|
discovery.BridgeInstanceId,
|
|
StringComparison.Ordinal))
|
|
{
|
|
throw new HostError(
|
|
"erp_bridge_instance_changed",
|
|
"ERP 已重新登录或命令桥已经重建,请重新启动桌宠以建立新会话。");
|
|
}
|
|
return expectedBridgeInstanceId;
|
|
}
|
|
|
|
internal static ErpBridgeDiscovery SelectDiscovery(
|
|
IReadOnlyList<ErpBridgeDiscovery> live,
|
|
int? targetProcessId,
|
|
long? targetStartedAtUnixSeconds)
|
|
{
|
|
if (targetStartedAtUnixSeconds.HasValue && !targetProcessId.HasValue)
|
|
throw new HostError("bridge_process_binding_invalid", "ERP 进程启动时间缺少对应进程 ID。");
|
|
if (targetProcessId.HasValue)
|
|
{
|
|
List<ErpBridgeDiscovery> selected = live.Where(
|
|
item => item.ProcessId == targetProcessId.Value
|
|
&& (!targetStartedAtUnixSeconds.HasValue
|
|
|| item.StartedAtUtc.ToUnixTimeSeconds()
|
|
== targetStartedAtUnixSeconds.Value)).ToList();
|
|
if (selected.Count != 1)
|
|
{
|
|
throw new HostError(
|
|
"erp_bridge_target_not_running",
|
|
"指定的 ERP 进程未运行命令桥,请重新从目标 ERP 启动桌宠。");
|
|
}
|
|
return selected[0];
|
|
}
|
|
if (live.Count == 0)
|
|
throw new HostError("erp_bridge_not_running", "ERP 命令桥发现文件均已失效。");
|
|
if (live.Count > 1)
|
|
{
|
|
throw new HostError(
|
|
"erp_bridge_ambiguous",
|
|
"检测到多个已登录 ERP,必须从目标 ERP 启动桌宠或显式指定进程。");
|
|
}
|
|
return live[0];
|
|
}
|
|
|
|
private static bool ProcessMatches(ErpBridgeDiscovery discovery)
|
|
{
|
|
using Process process = Process.GetProcessById(discovery.ProcessId);
|
|
if (process.HasExited) return false;
|
|
DateTimeOffset actualStart = process.StartTime.ToUniversalTime();
|
|
return ProcessStartMatches(discovery, actualStart);
|
|
}
|
|
|
|
internal static bool ProcessStartMatches(
|
|
ErpBridgeDiscovery discovery,
|
|
DateTimeOffset actualStart)
|
|
{
|
|
return discovery is not null
|
|
&& Math.Abs((actualStart - discovery.StartedAtUtc).TotalSeconds)
|
|
<= MaximumProcessStartDrift.TotalSeconds;
|
|
}
|
|
|
|
internal static void ValidateResponse(
|
|
JsonElement response,
|
|
string expectedRequestId,
|
|
string expectedCorrelationId)
|
|
{
|
|
ValidateProperties(response, AllowedResponseProperties, "ERP 桥响应");
|
|
string protocol = RequiredString(response, "protocolVersion", 16);
|
|
string requestId = RequiredString(response, "requestId", 128);
|
|
string correlationId = RequiredString(response, "correlationId", 128);
|
|
string code = RequiredString(response, "code", 128);
|
|
if (!string.Equals(protocol, "1.0", StringComparison.Ordinal)
|
|
|| !string.Equals(requestId, expectedRequestId, StringComparison.Ordinal)
|
|
|| !string.Equals(correlationId, expectedCorrelationId, StringComparison.Ordinal)
|
|
|| !SafeCode.IsMatch(code)
|
|
|| !response.TryGetProperty("success", out JsonElement success)
|
|
|| (success.ValueKind != JsonValueKind.True
|
|
&& success.ValueKind != JsonValueKind.False)
|
|
|| !response.TryGetProperty("data", out JsonElement data)
|
|
|| data.ValueKind != JsonValueKind.Object)
|
|
throw ProtocolError();
|
|
if (response.TryGetProperty("message", out JsonElement message)
|
|
&& message.ValueKind != JsonValueKind.Null)
|
|
{
|
|
if (message.ValueKind != JsonValueKind.String
|
|
|| (message.GetString()?.Length ?? 0) > 2000)
|
|
throw ProtocolError();
|
|
}
|
|
if (success.ValueKind == JsonValueKind.False)
|
|
ValidateErrorRecovery(data);
|
|
}
|
|
|
|
private static void ValidateErrorRecovery(JsonElement data)
|
|
{
|
|
ValidateProperties(data, AllowedErrorDataProperties, "ERP 桥错误 data");
|
|
if (!data.TryGetProperty("recovery", out JsonElement recovery)
|
|
|| recovery.ValueKind != JsonValueKind.Object)
|
|
throw ProtocolError();
|
|
ValidateProperties(recovery, AllowedRecoveryProperties, "ERP 桥错误 recovery");
|
|
string action = RequiredString(recovery, "action", 64);
|
|
string message = RequiredString(recovery, "message", 300);
|
|
if (!AllowedRecoveryActions.Contains(action)
|
|
|| message.Any(char.IsControl)
|
|
|| !recovery.TryGetProperty("retryable", out JsonElement retryable)
|
|
|| (retryable.ValueKind != JsonValueKind.True
|
|
&& retryable.ValueKind != JsonValueKind.False)
|
|
|| !recovery.TryGetProperty("planInvalidated", out JsonElement invalidated)
|
|
|| (invalidated.ValueKind != JsonValueKind.True
|
|
&& invalidated.ValueKind != JsonValueKind.False))
|
|
throw ProtocolError();
|
|
}
|
|
|
|
internal static ErpBridgeDiscovery ValidateDiscovery(JsonElement discovery)
|
|
{
|
|
ValidateProperties(discovery, AllowedDiscoveryProperties, "ERP 桥发现文件");
|
|
string protocol = RequiredString(discovery, "protocolVersion", 16);
|
|
string pipeName = RequiredString(discovery, "pipeName", 64);
|
|
string bridgeInstanceId = RequiredString(
|
|
discovery,
|
|
"bridgeInstanceId",
|
|
32);
|
|
if (!string.Equals(protocol, "1.0", StringComparison.Ordinal)
|
|
|| !discovery.TryGetProperty("processId", out JsonElement process)
|
|
|| process.ValueKind != JsonValueKind.Number
|
|
|| !process.TryGetInt32(out int processId)
|
|
|| processId <= 0
|
|
|| !discovery.TryGetProperty("startedAtUtc", out JsonElement started)
|
|
|| started.ValueKind != JsonValueKind.String
|
|
|| !TimeZoneSuffix.IsMatch(started.GetString() ?? string.Empty)
|
|
|| !started.TryGetDateTimeOffset(out DateTimeOffset startedAt))
|
|
throw ProtocolError();
|
|
if (!SafeBridgeInstanceId.IsMatch(bridgeInstanceId))
|
|
throw ProtocolError();
|
|
Match match = SafePipe.Match(pipeName);
|
|
if (!match.Success
|
|
|| !int.TryParse(match.Groups["pid"].Value, out int pipeProcessId)
|
|
|| pipeProcessId != processId
|
|
|| !string.Equals(
|
|
match.Groups["instance"].Value,
|
|
bridgeInstanceId,
|
|
StringComparison.Ordinal))
|
|
throw ProtocolError();
|
|
return new ErpBridgeDiscovery(
|
|
pipeName,
|
|
processId,
|
|
startedAt.ToUniversalTime(),
|
|
bridgeInstanceId);
|
|
}
|
|
|
|
private static void ValidateProperties(
|
|
JsonElement source,
|
|
ISet<string> allowed,
|
|
string label)
|
|
{
|
|
if (source.ValueKind != JsonValueKind.Object) throw ProtocolError();
|
|
ValidateUniqueProperties(source, label);
|
|
foreach (JsonProperty property in source.EnumerateObject())
|
|
{
|
|
if (!allowed.Contains(property.Name))
|
|
throw new HostError(
|
|
"bridge_protocol_error",
|
|
label + "包含未知字段。");
|
|
}
|
|
}
|
|
|
|
private static void ValidateUniqueProperties(JsonElement source, string label)
|
|
{
|
|
if (source.ValueKind == JsonValueKind.Object)
|
|
{
|
|
HashSet<string> seen = new(StringComparer.Ordinal);
|
|
foreach (JsonProperty property in source.EnumerateObject())
|
|
{
|
|
if (!seen.Add(property.Name))
|
|
throw new HostError(
|
|
"bridge_protocol_error",
|
|
label + "包含重复 JSON 字段。");
|
|
ValidateUniqueProperties(property.Value, label);
|
|
}
|
|
}
|
|
else if (source.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (JsonElement item in source.EnumerateArray())
|
|
ValidateUniqueProperties(item, label);
|
|
}
|
|
}
|
|
|
|
private static string RequiredString(JsonElement source, string name, int maximumLength)
|
|
{
|
|
if (!source.TryGetProperty(name, out JsonElement value)
|
|
|| value.ValueKind != JsonValueKind.String)
|
|
throw new HostError("bridge_protocol_error", "ERP 桥消息缺少字段:" + name);
|
|
string result = value.GetString() ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(result) || result.Length > maximumLength)
|
|
throw new HostError("bridge_protocol_error", "ERP 桥消息字段格式无效:" + name);
|
|
return result;
|
|
}
|
|
|
|
private static HostError ProtocolError() => new(
|
|
"bridge_protocol_error",
|
|
"ERP 桥返回了无效或不匹配的协议消息。");
|
|
|
|
[DllImport("kernel32.dll", SetLastError = true)]
|
|
[return: MarshalAs(UnmanagedType.Bool)]
|
|
private static extern bool GetNamedPipeServerProcessId(
|
|
SafePipeHandle pipe,
|
|
out uint serverProcessId);
|
|
}
|
|
|
|
internal sealed class ErpBridgeDiscovery
|
|
{
|
|
public ErpBridgeDiscovery(
|
|
string pipeName,
|
|
int processId,
|
|
DateTimeOffset startedAtUtc,
|
|
string bridgeInstanceId)
|
|
{
|
|
PipeName = pipeName;
|
|
ProcessId = processId;
|
|
StartedAtUtc = startedAtUtc;
|
|
BridgeInstanceId = bridgeInstanceId;
|
|
}
|
|
|
|
public string PipeName { get; }
|
|
public int ProcessId { get; }
|
|
public DateTimeOffset StartedAtUtc { get; }
|
|
public string BridgeInstanceId { get; }
|
|
}
|