516 lines
21 KiB
C#
516 lines
21 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|