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 _entries = new Dictionary(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 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 expired = new List(); foreach (KeyValuePair 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 input) { if (string.IsNullOrWhiteSpace(commandName)) throw new ArgumentException("命令名称不能为空。", "commandName"); JToken source; try { source = JToken.FromObject(input ?? new Dictionary()); // 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(); } } /// /// 为实际 ERP 数据库生成不透明、无凭据的稳定作用域指纹。这里只接收 /// 受信任登录运行时读取的值,命令输入和模型均不能指定这些参数。 /// public static class ErpDatabaseScopeFingerprint { public static string Create( string provider, string server, string database) { return CommandInputFingerprint.Create( "erp.database-scope", new Dictionary { { "provider", Normalize(provider, "数据库提供者") }, { "server", Normalize(server, "数据库服务器") }, { "database", Normalize(database, "数据库名称") } }); } /// /// 同时绑定 ERP 配置端点和当前 DbConnection 实际报告的端点。同名 /// 数据库被切换到另一台服务器、连接被替换,或登录后配置被改动时, /// 指纹都会改变,旧计划、验收证据和幂等作用域不能继续复用。 /// 配置库名与实际库名必须一致;服务器允许使用别名或规范化后的不同 /// 文本,但两者都会进入私有指纹,不能由命令输入或模型指定。 /// 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 { { "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; } } }