feat: add ERP agent pet bridge and startup guide
This commit is contained in:
@@ -0,0 +1,515 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Lskj.CommandKernel
|
||||
{
|
||||
public sealed class InMemoryIdempotencyStore : IIdempotencyStore
|
||||
{
|
||||
public const int DefaultMaximumEntries = 4096;
|
||||
private readonly object _syncRoot = new object();
|
||||
private readonly Dictionary<string, Entry> _entries =
|
||||
new Dictionary<string, Entry>(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ISystemClock _clock;
|
||||
private readonly TimeSpan _completedRetention;
|
||||
private readonly int _maximumEntries;
|
||||
|
||||
public InMemoryIdempotencyStore()
|
||||
: this(new SystemClock(), TimeSpan.FromHours(24), DefaultMaximumEntries)
|
||||
{
|
||||
}
|
||||
|
||||
public InMemoryIdempotencyStore(
|
||||
ISystemClock clock,
|
||||
TimeSpan completedRetention,
|
||||
int maximumEntries)
|
||||
{
|
||||
if (clock == null) throw new ArgumentNullException("clock");
|
||||
if (completedRetention <= TimeSpan.Zero
|
||||
|| completedRetention > TimeSpan.FromDays(7))
|
||||
throw new ArgumentOutOfRangeException(
|
||||
"completedRetention",
|
||||
"进程内幂等回放保留期必须在 7 天以内。");
|
||||
if (maximumEntries < 1 || maximumEntries > 100000)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
"maximumEntries",
|
||||
"进程内幂等记录容量必须在 1-100000 之间。");
|
||||
_clock = clock;
|
||||
_completedRetention = completedRetention;
|
||||
_maximumEntries = maximumEntries;
|
||||
}
|
||||
|
||||
public IdempotencyClaim Claim(
|
||||
string commandName,
|
||||
string idempotencyKey,
|
||||
CommandExecutionContext context,
|
||||
string inputFingerprint)
|
||||
{
|
||||
string key = BuildKey(commandName, idempotencyKey, context);
|
||||
EnsureFingerprint(inputFingerprint);
|
||||
lock (_syncRoot)
|
||||
{
|
||||
DateTime now = _clock.UtcNow;
|
||||
PurgeExpiredCompleted(now);
|
||||
Entry entry;
|
||||
if (!_entries.TryGetValue(key, out entry))
|
||||
{
|
||||
if (_entries.Count >= _maximumEntries)
|
||||
throw new CommandKernelException(
|
||||
"idempotency_store_capacity_exceeded",
|
||||
"进程内幂等回放记录已达到安全上限,请稍后重试或重启受控 ERP 会话。",
|
||||
6);
|
||||
_entries.Add(key, new Entry
|
||||
{
|
||||
InProgress = true,
|
||||
InputFingerprint = inputFingerprint,
|
||||
ExpiresAtUtc = now.Add(_completedRetention)
|
||||
});
|
||||
return new IdempotencyClaim { State = IdempotencyClaimState.Acquired };
|
||||
}
|
||||
if (!string.Equals(
|
||||
entry.InputFingerprint, inputFingerprint, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new IdempotencyClaim { State = IdempotencyClaimState.Conflict };
|
||||
}
|
||||
if (entry.InProgress)
|
||||
return new IdempotencyClaim { State = IdempotencyClaimState.InProgress };
|
||||
return new IdempotencyClaim
|
||||
{
|
||||
State = IdempotencyClaimState.Replay,
|
||||
Result = Clone(entry.Result)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public void Complete(
|
||||
string commandName,
|
||||
string idempotencyKey,
|
||||
CommandExecutionContext context,
|
||||
string inputFingerprint,
|
||||
CommandResult result)
|
||||
{
|
||||
if (result == null) throw new ArgumentNullException("result");
|
||||
string key = BuildKey(commandName, idempotencyKey, context);
|
||||
EnsureFingerprint(inputFingerprint);
|
||||
lock (_syncRoot)
|
||||
{
|
||||
Entry entry;
|
||||
if (!_entries.TryGetValue(key, out entry) || !entry.InProgress
|
||||
|| !string.Equals(
|
||||
entry.InputFingerprint, inputFingerprint, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("幂等键未被当前执行请求占用。");
|
||||
entry.InProgress = false;
|
||||
entry.Result = Clone(result);
|
||||
entry.ExpiresAtUtc = _clock.UtcNow.Add(_completedRetention);
|
||||
}
|
||||
}
|
||||
|
||||
public void Abandon(
|
||||
string commandName,
|
||||
string idempotencyKey,
|
||||
CommandExecutionContext context,
|
||||
string inputFingerprint)
|
||||
{
|
||||
string key = BuildKey(commandName, idempotencyKey, context);
|
||||
EnsureFingerprint(inputFingerprint);
|
||||
lock (_syncRoot)
|
||||
{
|
||||
Entry entry;
|
||||
if (_entries.TryGetValue(key, out entry) && entry.InProgress
|
||||
&& string.Equals(
|
||||
entry.InputFingerprint, inputFingerprint, StringComparison.OrdinalIgnoreCase))
|
||||
_entries.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildKey(
|
||||
string commandName,
|
||||
string idempotencyKey,
|
||||
CommandExecutionContext context)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(commandName))
|
||||
throw new ArgumentException("命令名称不能为空。", "commandName");
|
||||
if (string.IsNullOrWhiteSpace(idempotencyKey))
|
||||
throw new ArgumentException("幂等键不能为空。", "idempotencyKey");
|
||||
if (context == null) throw new ArgumentNullException("context");
|
||||
if (string.IsNullOrWhiteSpace(context.AccountBook)
|
||||
|| string.IsNullOrWhiteSpace(context.SubSystemId)
|
||||
|| string.IsNullOrWhiteSpace(context.UserId)
|
||||
|| string.IsNullOrWhiteSpace(context.UserName))
|
||||
{
|
||||
throw new ArgumentException("幂等存储需要完整的账套、子系统和用户身份作用域。", "context");
|
||||
}
|
||||
string separator = "\u001f";
|
||||
return context.AccountBook.Trim() + separator
|
||||
+ context.SubSystemId.Trim() + separator
|
||||
+ context.UserId.Trim() + separator
|
||||
+ context.UserName.Trim() + separator
|
||||
+ (context.DatabaseScopeFingerprint ?? string.Empty).Trim()
|
||||
+ separator
|
||||
+ commandName.Trim() + separator
|
||||
+ idempotencyKey.Trim();
|
||||
}
|
||||
|
||||
private static CommandResult Clone(CommandResult source)
|
||||
{
|
||||
if (source == null) return null;
|
||||
CommandResult clone = new CommandResult
|
||||
{
|
||||
Success = source.Success,
|
||||
Code = source.Code,
|
||||
Message = source.Message,
|
||||
RecordId = source.RecordId,
|
||||
Replayed = source.Replayed,
|
||||
TransactionEvidenceId = source.TransactionEvidenceId,
|
||||
BusinessAuditId = source.BusinessAuditId
|
||||
};
|
||||
foreach (KeyValuePair<string, object> item in source.Data)
|
||||
clone.Data[item.Key] = item.Value;
|
||||
return clone;
|
||||
}
|
||||
|
||||
private sealed class Entry
|
||||
{
|
||||
public bool InProgress;
|
||||
public string InputFingerprint;
|
||||
public CommandResult Result;
|
||||
public DateTime ExpiresAtUtc;
|
||||
}
|
||||
|
||||
private void PurgeExpiredCompleted(DateTime utcNow)
|
||||
{
|
||||
List<string> expired = new List<string>();
|
||||
foreach (KeyValuePair<string, Entry> item in _entries)
|
||||
{
|
||||
if (item.Value != null
|
||||
&& !item.Value.InProgress
|
||||
&& item.Value.ExpiresAtUtc <= utcNow)
|
||||
expired.Add(item.Key);
|
||||
}
|
||||
foreach (string key in expired) _entries.Remove(key);
|
||||
}
|
||||
|
||||
private static void EnsureFingerprint(string inputFingerprint)
|
||||
{
|
||||
if (!CommandInputFingerprint.IsValid(inputFingerprint))
|
||||
throw new ArgumentException("输入指纹格式无效。", "inputFingerprint");
|
||||
}
|
||||
}
|
||||
|
||||
public static class CommandInputFingerprint
|
||||
{
|
||||
public static string Create(
|
||||
string commandName,
|
||||
IDictionary<string, object> input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(commandName))
|
||||
throw new ArgumentException("命令名称不能为空。", "commandName");
|
||||
JToken source;
|
||||
try
|
||||
{
|
||||
source = JToken.FromObject(input ?? new Dictionary<string, object>());
|
||||
// resolutionProof 是服务器短期授权凭证,不是业务事实。采购/请假
|
||||
// 每次重新 resolve 都会生成新 nonce;若把它计入幂等指纹,网络
|
||||
// 丢包后以同一业务键重新走完整工作流会被误判为内容冲突。
|
||||
// 只允许两个固定 create 命令排除顶层精确字段,其他命令、大小写
|
||||
// 变体和嵌套同名字段仍参与指纹,不能扩大为通用忽略规则。
|
||||
JObject objectSource = source as JObject;
|
||||
if (objectSource != null
|
||||
&& IsResolvedCreateCommand(commandName))
|
||||
objectSource.Remove("resolutionProof");
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw new CommandKernelException(
|
||||
"invalid_input",
|
||||
"命令输入无法转换为稳定 JSON。",
|
||||
2);
|
||||
}
|
||||
string canonical = Normalize(source).ToString(Formatting.None);
|
||||
using (SHA256 sha = SHA256.Create())
|
||||
{
|
||||
byte[] bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(
|
||||
commandName.Trim().ToLowerInvariant() + "\u001f" + canonical));
|
||||
StringBuilder result = new StringBuilder(bytes.Length * 2);
|
||||
foreach (byte item in bytes) result.Append(item.ToString("x2", CultureInfo.InvariantCulture));
|
||||
return result.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsValid(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || value.Length != 64) return false;
|
||||
return value.All(item => (item >= '0' && item <= '9')
|
||||
|| (item >= 'a' && item <= 'f')
|
||||
|| (item >= 'A' && item <= 'F'));
|
||||
}
|
||||
|
||||
private static bool IsResolvedCreateCommand(string commandName)
|
||||
{
|
||||
string normalized = (commandName ?? string.Empty).Trim();
|
||||
return string.Equals(
|
||||
normalized,
|
||||
"purchase.invoice.create",
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(
|
||||
normalized,
|
||||
"hr.leave.create",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static JToken Normalize(JToken value)
|
||||
{
|
||||
JObject objectValue = value as JObject;
|
||||
if (objectValue != null)
|
||||
{
|
||||
JObject normalized = new JObject();
|
||||
foreach (JProperty property in objectValue.Properties()
|
||||
.OrderBy(item => item.Name, StringComparer.Ordinal))
|
||||
{
|
||||
normalized.Add(property.Name, Normalize(property.Value));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
JArray arrayValue = value as JArray;
|
||||
if (arrayValue != null)
|
||||
return new JArray(arrayValue.Select(Normalize));
|
||||
return value == null ? JValue.CreateNull() : value.DeepClone();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为实际 ERP 数据库生成不透明、无凭据的稳定作用域指纹。这里只接收
|
||||
/// 受信任登录运行时读取的值,命令输入和模型均不能指定这些参数。
|
||||
/// </summary>
|
||||
public static class ErpDatabaseScopeFingerprint
|
||||
{
|
||||
public static string Create(
|
||||
string provider,
|
||||
string server,
|
||||
string database)
|
||||
{
|
||||
return CommandInputFingerprint.Create(
|
||||
"erp.database-scope",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "provider", Normalize(provider, "数据库提供者") },
|
||||
{ "server", Normalize(server, "数据库服务器") },
|
||||
{ "database", Normalize(database, "数据库名称") }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 同时绑定 ERP 配置端点和当前 DbConnection 实际报告的端点。同名
|
||||
/// 数据库被切换到另一台服务器、连接被替换,或登录后配置被改动时,
|
||||
/// 指纹都会改变,旧计划、验收证据和幂等作用域不能继续复用。
|
||||
/// 配置库名与实际库名必须一致;服务器允许使用别名或规范化后的不同
|
||||
/// 文本,但两者都会进入私有指纹,不能由命令输入或模型指定。
|
||||
/// </summary>
|
||||
public static string CreateBound(
|
||||
string provider,
|
||||
string configuredServer,
|
||||
string configuredDatabase,
|
||||
string connectedServer,
|
||||
string connectedDatabase)
|
||||
{
|
||||
string normalizedConfiguredDatabase = Normalize(
|
||||
configuredDatabase,
|
||||
"配置数据库名称");
|
||||
string normalizedConnectedDatabase = Normalize(
|
||||
connectedDatabase,
|
||||
"实际数据库名称");
|
||||
if (!string.Equals(
|
||||
normalizedConfiguredDatabase,
|
||||
normalizedConnectedDatabase,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
throw new CommandKernelException(
|
||||
"erp_database_session_changed",
|
||||
"当前 ERP 配置与实际数据库连接不一致,请重新登录后再执行命令。",
|
||||
6);
|
||||
}
|
||||
|
||||
return CommandInputFingerprint.Create(
|
||||
"erp.database-scope.v2",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "provider", Normalize(provider, "数据库提供者") },
|
||||
{ "configuredServer", Normalize(
|
||||
configuredServer,
|
||||
"配置数据库服务器") },
|
||||
{ "connectedServer", Normalize(
|
||||
connectedServer,
|
||||
"实际数据库服务器") },
|
||||
{ "database", normalizedConnectedDatabase }
|
||||
});
|
||||
}
|
||||
|
||||
private static string Normalize(string value, string label)
|
||||
{
|
||||
value = (value ?? string.Empty).Trim();
|
||||
if (value.Length == 0 || value.Length > 512
|
||||
|| value.Any(char.IsControl))
|
||||
{
|
||||
throw new CommandKernelException(
|
||||
"erp_database_scope_invalid",
|
||||
label + "无效,不能建立可信 ERP 数据库作用域。",
|
||||
6);
|
||||
}
|
||||
return value.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class HmacConfirmationService : IConfirmationValidator, IConfirmationTokenIssuer
|
||||
{
|
||||
private readonly byte[] _secret;
|
||||
private readonly ISystemClock _clock;
|
||||
|
||||
public HmacConfirmationService(byte[] secret, ISystemClock clock)
|
||||
{
|
||||
if (secret == null || secret.Length < 32)
|
||||
throw new ArgumentException("确认令牌密钥至少需要 32 字节。", "secret");
|
||||
if (clock == null) throw new ArgumentNullException("clock");
|
||||
_secret = (byte[])secret.Clone();
|
||||
_clock = clock;
|
||||
}
|
||||
|
||||
public string Issue(CommandPlan plan, CommandExecutionContext context, TimeSpan lifetime)
|
||||
{
|
||||
if (plan == null) throw new ArgumentNullException("plan");
|
||||
if (context == null) throw new ArgumentNullException("context");
|
||||
if (!string.Equals(
|
||||
plan.CorrelationId ?? string.Empty,
|
||||
context.CorrelationId ?? string.Empty,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"确认令牌只能签发给计划原始关联 ID。",
|
||||
"context");
|
||||
}
|
||||
if (lifetime <= TimeSpan.Zero || lifetime > TimeSpan.FromMinutes(5))
|
||||
throw new ArgumentOutOfRangeException("lifetime", "确认令牌有效期必须在 5 分钟以内。");
|
||||
|
||||
DateTime expiresAt = _clock.UtcNow.Add(lifetime);
|
||||
if (expiresAt > plan.ExpiresAtUtc) expiresAt = plan.ExpiresAtUtc;
|
||||
long ticks = expiresAt.ToUniversalTime().Ticks;
|
||||
string payload = Canonical(plan, context, ticks);
|
||||
return ticks.ToString(CultureInfo.InvariantCulture) + "." + Sign(payload);
|
||||
}
|
||||
|
||||
public bool Validate(
|
||||
CommandPlan plan,
|
||||
CommandExecutionContext context,
|
||||
out string failureReason)
|
||||
{
|
||||
failureReason = null;
|
||||
if (plan == null || context == null || string.IsNullOrWhiteSpace(context.ConfirmationToken))
|
||||
{
|
||||
failureReason = "该操作需要在 ERP 原生确认窗口中确认。";
|
||||
return false;
|
||||
}
|
||||
if (!string.Equals(
|
||||
plan.CorrelationId ?? string.Empty,
|
||||
context.CorrelationId ?? string.Empty,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
failureReason = "确认令牌与当前关联 ID 不匹配。";
|
||||
return false;
|
||||
}
|
||||
|
||||
string[] parts = context.ConfirmationToken.Split('.');
|
||||
long ticks;
|
||||
if (parts.Length != 2
|
||||
|| !long.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out ticks))
|
||||
{
|
||||
failureReason = "确认令牌格式无效。";
|
||||
return false;
|
||||
}
|
||||
|
||||
DateTime expiresAt;
|
||||
try
|
||||
{
|
||||
expiresAt = new DateTime(ticks, DateTimeKind.Utc);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
failureReason = "确认令牌时间无效。";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_clock.UtcNow > expiresAt || expiresAt > plan.ExpiresAtUtc)
|
||||
{
|
||||
failureReason = "确认已过期,请重新查看预览并确认。";
|
||||
return false;
|
||||
}
|
||||
|
||||
string expected = Sign(Canonical(plan, context, ticks));
|
||||
if (!FixedTimeEquals(expected, parts[1]))
|
||||
{
|
||||
failureReason = "确认令牌与当前用户、账套或计划不匹配。";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private string Sign(string payload)
|
||||
{
|
||||
using (HMACSHA256 hmac = new HMACSHA256(_secret))
|
||||
{
|
||||
return Base64Url(hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)));
|
||||
}
|
||||
}
|
||||
|
||||
private static string Canonical(
|
||||
CommandPlan plan,
|
||||
CommandExecutionContext context,
|
||||
long expiresAtTicks)
|
||||
{
|
||||
string separator = "\u001f";
|
||||
return expiresAtTicks.ToString(CultureInfo.InvariantCulture) + separator
|
||||
+ Safe(plan.PlanId) + separator
|
||||
+ Safe(plan.CommandName) + separator
|
||||
+ Safe(plan.CommandVersion) + separator
|
||||
+ Safe(plan.InputFingerprint) + separator
|
||||
+ Safe(plan.CorrelationId) + separator
|
||||
+ Safe(context.CorrelationId) + separator
|
||||
+ Safe(context.UserId) + separator
|
||||
+ Safe(context.UserName) + separator
|
||||
+ Safe(context.AccountBook) + separator
|
||||
+ Safe(context.SubSystemId) + separator
|
||||
+ Safe(context.DatabaseScopeFingerprint) + separator
|
||||
+ Safe(context.IdempotencyKey) + separator
|
||||
+ Safe(context.ClientSessionId);
|
||||
}
|
||||
|
||||
private static string Safe(string value)
|
||||
{
|
||||
return value ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string Base64Url(byte[] value)
|
||||
{
|
||||
return Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
|
||||
private static bool FixedTimeEquals(string left, string right)
|
||||
{
|
||||
byte[] leftBytes = Encoding.ASCII.GetBytes(left ?? string.Empty);
|
||||
byte[] rightBytes = Encoding.ASCII.GetBytes(right ?? string.Empty);
|
||||
int difference = leftBytes.Length ^ rightBytes.Length;
|
||||
int maximum = Math.Max(leftBytes.Length, rightBytes.Length);
|
||||
for (int index = 0; index < maximum; index++)
|
||||
{
|
||||
byte leftByte = index < leftBytes.Length ? leftBytes[index] : (byte)0;
|
||||
byte rightByte = index < rightBytes.Length ? rightBytes[index] : (byte)0;
|
||||
difference |= leftByte ^ rightByte;
|
||||
}
|
||||
return difference == 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user