feat: add ERP agent pet bridge and startup guide
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace Lskj.AgentPet.Host.Core.ErpBridge;
|
||||
|
||||
public static class BridgeFrameCodec
|
||||
{
|
||||
public const int MaxMessageBytes = 1024 * 1024;
|
||||
|
||||
public static async Task WriteAsync(
|
||||
Stream stream,
|
||||
ReadOnlyMemory<byte> body,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
if (body.Length is <= 0 or > MaxMessageBytes)
|
||||
throw new HostError("bridge_protocol_error", "ERP 桥请求长度无效或超过 1 MB。");
|
||||
byte[] header = new byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(header, body.Length);
|
||||
await stream.WriteAsync(header, cancellationToken).ConfigureAwait(false);
|
||||
await stream.WriteAsync(body, cancellationToken).ConfigureAwait(false);
|
||||
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task<byte[]> ReadAsync(
|
||||
Stream stream,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
byte[] header = new byte[4];
|
||||
await ReadExactlyAsync(stream, header, cancellationToken).ConfigureAwait(false);
|
||||
int length = BinaryPrimitives.ReadInt32LittleEndian(header);
|
||||
if (length is <= 0 or > MaxMessageBytes)
|
||||
throw new HostError("bridge_protocol_error", "ERP 桥响应长度无效或超过 1 MB。");
|
||||
byte[] body = new byte[length];
|
||||
await ReadExactlyAsync(stream, body, cancellationToken).ConfigureAwait(false);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static async Task ReadExactlyAsync(
|
||||
Stream stream,
|
||||
Memory<byte> target,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int offset = 0;
|
||||
while (offset < target.Length)
|
||||
{
|
||||
int count = await stream.ReadAsync(target[offset..], cancellationToken).ConfigureAwait(false);
|
||||
if (count == 0)
|
||||
throw new HostError("bridge_disconnected", "ERP 桥在完整消息到达前关闭。");
|
||||
offset += count;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Text.Json;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Lskj.AgentPet.Host.Core.Configuration;
|
||||
|
||||
namespace Lskj.AgentPet.Host.Core.ErpBridge;
|
||||
|
||||
public sealed class SessionBoundErpBridgeClient : IErpBridgeClient
|
||||
{
|
||||
private readonly IErpBridgeClient _inner;
|
||||
private readonly ErpSessionScopeBinding _scope;
|
||||
|
||||
public SessionBoundErpBridgeClient(
|
||||
IErpBridgeClient inner,
|
||||
ErpSessionScopeBinding scope)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_scope = scope ?? throw new ArgumentNullException(nameof(scope));
|
||||
}
|
||||
|
||||
public async Task<JsonDocument> SendAsync(
|
||||
JsonElement request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string method = RequiredString(request, "method", 128);
|
||||
if (string.Equals(method, "context.get", StringComparison.Ordinal))
|
||||
{
|
||||
using JsonDocument boundRequest = BindRequest(request);
|
||||
JsonDocument context = await _inner.SendAsync(
|
||||
boundRequest.RootElement,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_scope.VerifyContextResponse(context.RootElement);
|
||||
return context;
|
||||
}
|
||||
catch
|
||||
{
|
||||
context.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
string protocol = RequiredString(request, "protocolVersion", 16);
|
||||
string clientSessionId = RequiredString(request, "clientSessionId", 128);
|
||||
ErpSessionScopeSnapshot before = await ReadContextAsync(
|
||||
protocol,
|
||||
clientSessionId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
JsonDocument? result = null;
|
||||
try
|
||||
{
|
||||
using JsonDocument boundRequest = BindRequest(request);
|
||||
result = await _inner.SendAsync(
|
||||
boundRequest.RootElement,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
ErpSessionScopeSnapshot after;
|
||||
try
|
||||
{
|
||||
after = await ReadContextAsync(
|
||||
protocol,
|
||||
clientSessionId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (HostError error) when (error.Code == "erp_session_scope_mismatch")
|
||||
{
|
||||
throw new HostError(
|
||||
"erp_session_scope_changed",
|
||||
"ERP 会话在桌宠操作期间发生切换,操作结果不会交给页面继续使用。",
|
||||
error);
|
||||
}
|
||||
bool targetMayChangeUi = string.Equals(
|
||||
method,
|
||||
"command.execute",
|
||||
StringComparison.Ordinal);
|
||||
if (!before.SameSessionIdentity(after)
|
||||
|| (!targetMayChangeUi && !before.SameUiState(after)))
|
||||
{
|
||||
throw new HostError(
|
||||
"erp_session_scope_changed",
|
||||
"ERP 会话在桌宠操作期间发生切换,操作结果不会交给页面继续使用。");
|
||||
}
|
||||
JsonDocument accepted = result;
|
||||
result = null;
|
||||
return accepted;
|
||||
}
|
||||
finally
|
||||
{
|
||||
result?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ErpSessionScopeSnapshot> ReadContextAsync(
|
||||
string protocol,
|
||||
string clientSessionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string requestId = Guid.NewGuid().ToString("N");
|
||||
string correlationId = "scope-" + Guid.NewGuid().ToString("N");
|
||||
using JsonDocument request = JsonSerializer.SerializeToDocument(new
|
||||
{
|
||||
protocolVersion = protocol,
|
||||
requestId,
|
||||
correlationId,
|
||||
clientSessionId,
|
||||
sessionScopeToken = _scope.Token,
|
||||
method = "context.get",
|
||||
payload = new { }
|
||||
});
|
||||
using JsonDocument response = await _inner.SendAsync(
|
||||
request.RootElement,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return _scope.VerifyContextResponse(response.RootElement);
|
||||
}
|
||||
|
||||
private JsonDocument BindRequest(JsonElement request)
|
||||
{
|
||||
if (request.ValueKind != JsonValueKind.Object)
|
||||
throw new HostError(
|
||||
"bridge_request_invalid",
|
||||
"ERP 桥请求必须是 JSON 对象。");
|
||||
|
||||
Dictionary<string, JsonElement> fields = new(StringComparer.Ordinal);
|
||||
foreach (JsonProperty property in request.EnumerateObject())
|
||||
{
|
||||
if (!fields.TryAdd(property.Name, property.Value.Clone()))
|
||||
throw new HostError(
|
||||
"bridge_request_invalid",
|
||||
"ERP 桥请求包含重复字段。");
|
||||
}
|
||||
|
||||
// 浏览器页面不能声明或覆盖会话令牌;Host 只使用启动时绑定的
|
||||
// ERP 登录范围重新注入它,然后才把请求交给命名管道客户端。
|
||||
fields["sessionScopeToken"] = JsonSerializer.SerializeToElement(
|
||||
_scope.Token);
|
||||
return JsonSerializer.SerializeToDocument(fields);
|
||||
}
|
||||
|
||||
private static string RequiredString(JsonElement source, string name, int maximumLength)
|
||||
{
|
||||
if (source.ValueKind != JsonValueKind.Object
|
||||
|| !source.TryGetProperty(name, out JsonElement value)
|
||||
|| value.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
throw new HostError("bridge_request_invalid", "ERP 桥请求缺少会话绑定字段。");
|
||||
}
|
||||
string result = value.GetString() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(result)
|
||||
|| result.Length > maximumLength
|
||||
|| result.Any(char.IsControl))
|
||||
throw new HostError("bridge_request_invalid", "ERP 桥请求会话绑定字段无效。");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user