using System; using System.Collections.Generic; using System.Globalization; using System.Text.RegularExpressions; using Lskj.CommandKernel; namespace Lskj.AgentBridge { public enum BridgeCommandStage { Plan, Execute } public sealed class BridgePolicyDecision { public static BridgePolicyDecision Allow() { return new BridgePolicyDecision { Allowed = true }; } public static BridgePolicyDecision Deny(string code, string message) { return Deny(code, message, false); } public static BridgePolicyDecision Deny( string code, string message, bool auditRecommended) { return new BridgePolicyDecision { Allowed = false, Code = code, Message = message, AuditRecommended = auditRecommended }; } public bool Allowed { get; set; } public string Code { get; set; } public string Message { get; set; } public bool AuditRecommended { get; set; } } public sealed class BridgeOperationalSnapshot { public int RequestsPerMinute { get; set; } public int MaximumTrackedSessions { get; set; } public int TrackedSessionCount { get; set; } public int DisabledCommandCount { get; set; } public int OpenCircuitCount { get; set; } public int CircuitFailureThreshold { get; set; } public int CircuitOpenSeconds { get; set; } } /// /// Process-local operational guard for the ERP command bridge. Database /// idempotency remains the business safety boundary; this policy protects /// the legacy UI process from request floods and repeated infrastructure /// failures, and supplies a deployment-time command kill switch. /// public sealed class BridgeOperationalPolicy { public const int DefaultRequestsPerMinute = 120; public const int DefaultMaximumTrackedSessions = 256; public const int DefaultCircuitFailureThreshold = 5; public const int DefaultCircuitFailureWindowSeconds = 60; public const int DefaultCircuitOpenSeconds = 30; private const int MaximumDisabledCommands = 64; private const int MaximumCircuitStates = 512; private const int MaximumRejectionAuditStates = 512; private static readonly Regex SafeCommandName = new Regex( "^[A-Za-z0-9_.:-]{1,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex SafeOutcomeCode = new Regex( "^[a-z0-9_.-]{1,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly ISet OperationalFailureCodes = new HashSet(new[] { "internal_error", "workflow_database_error", "audit_log_unavailable", "audit_log_capacity_exceeded", "audit_log_corrupt", "idempotency_store_error", "plan_store_capacity_exceeded", "adapter_protocol_error", "adapter_result_missing", "adapter_commit_evidence_missing", "adapter_not_ready", "adapter_readiness_failed" }, StringComparer.OrdinalIgnoreCase); private readonly object _syncRoot = new object(); private readonly ISystemClock _clock; private readonly ISet _disabledCommands; private readonly int _requestsPerMinute; private readonly int _maximumTrackedSessions; private readonly int _circuitFailureThreshold; private readonly TimeSpan _circuitFailureWindow; private readonly TimeSpan _circuitOpenDuration; private readonly TimeSpan _sessionRetention = TimeSpan.FromMinutes(5); private readonly IDictionary _rateStates = new Dictionary(StringComparer.Ordinal); private readonly IDictionary _circuitStates = new Dictionary(StringComparer.Ordinal); private readonly IDictionary _rejectionAudits = new Dictionary(StringComparer.Ordinal); private DateTime _capacityAuditWindowStartedAtUtc; public BridgeOperationalPolicy( ISystemClock clock, IEnumerable disabledCommands, int requestsPerMinute, int maximumTrackedSessions, int circuitFailureThreshold, TimeSpan circuitFailureWindow, TimeSpan circuitOpenDuration) { if (clock == null) throw new ArgumentNullException("clock"); if (requestsPerMinute < 1 || requestsPerMinute > 6000) throw Invalid("桥每分钟请求上限必须在 1-6000 之间。"); if (maximumTrackedSessions < 1 || maximumTrackedSessions > 4096) throw Invalid("桥限流会话容量必须在 1-4096 之间。"); if (circuitFailureThreshold < 2 || circuitFailureThreshold > 100) throw Invalid("桥熔断故障阈值必须在 2-100 之间。"); if (circuitFailureWindow < TimeSpan.FromSeconds(1) || circuitFailureWindow > TimeSpan.FromMinutes(30)) throw Invalid("桥熔断统计窗口必须在 1-1800 秒之间。"); if (circuitOpenDuration < TimeSpan.FromSeconds(1) || circuitOpenDuration > TimeSpan.FromMinutes(30)) throw Invalid("桥熔断时长必须在 1-1800 秒之间。"); HashSet disabled = new HashSet( StringComparer.OrdinalIgnoreCase); foreach (string item in disabledCommands ?? new string[0]) { string command = (item ?? string.Empty).Trim(); if (!SafeCommandName.IsMatch(command) || !disabled.Add(command) || disabled.Count > MaximumDisabledCommands) throw Invalid("停用命令列表包含空值、重复项、非法名称或超过 64 项。"); } _clock = clock; _disabledCommands = disabled; _requestsPerMinute = requestsPerMinute; _maximumTrackedSessions = maximumTrackedSessions; _circuitFailureThreshold = circuitFailureThreshold; _circuitFailureWindow = circuitFailureWindow; _circuitOpenDuration = circuitOpenDuration; } public static BridgeOperationalPolicy CreateDefault(ISystemClock clock) { return new BridgeOperationalPolicy( clock, new string[0], DefaultRequestsPerMinute, DefaultMaximumTrackedSessions, DefaultCircuitFailureThreshold, TimeSpan.FromSeconds(DefaultCircuitFailureWindowSeconds), TimeSpan.FromSeconds(DefaultCircuitOpenSeconds)); } public static BridgeOperationalPolicy FromEnvironment(ISystemClock clock) { return new BridgeOperationalPolicy( clock, ParseDisabledCommands( Environment.GetEnvironmentVariable( "LSERP_AGENT_DISABLED_COMMANDS")), ParseBoundedInteger( "LSERP_AGENT_RATE_LIMIT_PER_MINUTE", DefaultRequestsPerMinute, 1, 6000), ParseBoundedInteger( "LSERP_AGENT_RATE_LIMIT_SESSIONS", DefaultMaximumTrackedSessions, 1, 4096), ParseBoundedInteger( "LSERP_AGENT_CIRCUIT_FAILURE_THRESHOLD", DefaultCircuitFailureThreshold, 2, 100), TimeSpan.FromSeconds(ParseBoundedInteger( "LSERP_AGENT_CIRCUIT_WINDOW_SECONDS", DefaultCircuitFailureWindowSeconds, 1, 1800)), TimeSpan.FromSeconds(ParseBoundedInteger( "LSERP_AGENT_CIRCUIT_OPEN_SECONDS", DefaultCircuitOpenSeconds, 1, 1800))); } public void ValidateRegisteredCommands( IEnumerable descriptors) { if (descriptors == null) throw Invalid("ERP 命令注册表不能为空。"); HashSet registered = new HashSet( StringComparer.OrdinalIgnoreCase); foreach (CommandDescriptor descriptor in descriptors) { string name = descriptor == null ? string.Empty : (descriptor.Name ?? string.Empty).Trim(); if (!SafeCommandName.IsMatch(name) || !registered.Add(name)) throw Invalid("ERP 命令注册表包含空值、重复项或非法名称。"); } lock (_syncRoot) { foreach (string disabled in _disabledCommands) { if (!registered.Contains(disabled)) throw Invalid("停用列表包含当前未注册的 ERP 命令。"); } } } public BridgePolicyDecision Admit(string clientSessionId) { if (string.IsNullOrWhiteSpace(clientSessionId)) return BridgePolicyDecision.Deny( "invalid_request", "桌宠客户端会话不能为空。"); DateTime now = _clock.UtcNow; lock (_syncRoot) { PurgeRateStates(now); RateState state; if (!_rateStates.TryGetValue(clientSessionId, out state)) { if (_rateStates.Count >= _maximumTrackedSessions) { return BridgePolicyDecision.Deny( "bridge_rate_state_capacity_exceeded", "ERP 命令桥限流会话容量已满,请关闭无效桌宠会话后重试。", ShouldAuditCapacityRejection(now)); } state = new RateState { WindowStartedAtUtc = now, LastSeenAtUtc = now }; _rateStates.Add(clientSessionId, state); } if (now >= state.WindowStartedAtUtc && now - state.WindowStartedAtUtc >= TimeSpan.FromMinutes(1)) { state.WindowStartedAtUtc = now; state.RequestCount = 0; state.LimitAuditRecorded = false; } if (now > state.LastSeenAtUtc) state.LastSeenAtUtc = now; if (state.RequestCount >= _requestsPerMinute) { bool auditRecommended = !state.LimitAuditRecorded; state.LimitAuditRecorded = true; return BridgePolicyDecision.Deny( "bridge_rate_limit_exceeded", "ERP 命令桥请求过于频繁,请稍后重试。", auditRecommended); } state.RequestCount += 1; return BridgePolicyDecision.Allow(); } } public bool IsCommandVisible(string commandName, bool requiresExecution) { if (!CheckCommandAvailability( commandName, BridgeCommandStage.Plan).Allowed) return false; return !requiresExecution || CheckCommandAvailability( commandName, BridgeCommandStage.Execute).Allowed; } public BridgePolicyDecision CheckCommandAvailability( string commandName, BridgeCommandStage stage) { if (!SafeCommandName.IsMatch(commandName ?? string.Empty)) return BridgePolicyDecision.Deny( "command_policy_invalid", "ERP 命令运维策略无法识别该命令。"); DateTime now = _clock.UtcNow; lock (_syncRoot) { if (_disabledCommands.Contains(commandName)) { return BridgePolicyDecision.Deny( "command_disabled", "该 ERP 命令已被运维策略停用。"); } return IsCircuitBlocking(commandName, stage, now) ? BridgePolicyDecision.Deny( "command_circuit_open", "该 ERP 命令因连续运行故障暂时停用,请稍后重试。") : BridgePolicyDecision.Allow(); } } public BridgePolicyDecision TryEnterCommand( string commandName, BridgeCommandStage stage) { if (!SafeCommandName.IsMatch(commandName ?? string.Empty)) return BridgePolicyDecision.Deny( "command_policy_invalid", "ERP 命令运维策略无法识别该命令。"); DateTime now = _clock.UtcNow; lock (_syncRoot) { if (_disabledCommands.Contains(commandName)) { return BridgePolicyDecision.Deny( "command_disabled", "该 ERP 命令已被运维策略停用。"); } string key = CircuitKey(commandName, stage); CircuitState state; if (!_circuitStates.TryGetValue(key, out state)) return BridgePolicyDecision.Allow(); if (state.OpenUntilUtc > now || state.HalfOpenProbe) { return BridgePolicyDecision.Deny( "command_circuit_open", "该 ERP 命令因连续运行故障暂时停用,请稍后重试。"); } if (state.OpenUntilUtc != DateTime.MinValue) state.HalfOpenProbe = true; return BridgePolicyDecision.Allow(); } } public bool ShouldAuditCommandRejection( string commandName, BridgeCommandStage stage, string outcomeCode) { if (!SafeCommandName.IsMatch(commandName ?? string.Empty) || !SafeOutcomeCode.IsMatch(outcomeCode ?? string.Empty)) return false; DateTime now = _clock.UtcNow; string key = CircuitKey(commandName, stage) + "|" + outcomeCode.ToLowerInvariant(); lock (_syncRoot) { PurgeRejectionAudits(now); DateTime previous; if (_rejectionAudits.TryGetValue(key, out previous)) { if (now < previous || now - previous < TimeSpan.FromMinutes(1)) return false; _rejectionAudits[key] = now; return true; } if (_rejectionAudits.Count >= MaximumRejectionAuditStates) return false; _rejectionAudits.Add(key, now); return true; } } public bool RecordCommandSuccess( string commandName, BridgeCommandStage stage) { lock (_syncRoot) { string key = CircuitKey(commandName, stage); CircuitState state; bool recovered = _circuitStates.TryGetValue(key, out state) && (state.OpenUntilUtc != DateTime.MinValue || state.HalfOpenProbe); _circuitStates.Remove(key); return recovered; } } public bool RecordCommandFailure( string commandName, BridgeCommandStage stage, string errorCode) { string key = CircuitKey(commandName, stage); DateTime now = _clock.UtcNow; lock (_syncRoot) { CircuitState existing; if (!OperationalFailureCodes.Contains(errorCode ?? string.Empty)) { // Validation, permission and user-cancel outcomes must not // heal an infrastructure circuit. They only release a // half-open probe so a later valid request can test it. if (_circuitStates.TryGetValue(key, out existing)) existing.HalfOpenProbe = false; return false; } if (!_circuitStates.TryGetValue(key, out existing)) { PurgeCircuitStates(now); if (_circuitStates.Count >= MaximumCircuitStates) return false; existing = new CircuitState { FailureWindowStartedAtUtc = now }; _circuitStates.Add(key, existing); } if (existing.OpenUntilUtc > now && !existing.HalfOpenProbe) return false; if (existing.HalfOpenProbe || existing.OpenUntilUtc != DateTime.MinValue) { existing.FailureCount = _circuitFailureThreshold; existing.FailureWindowStartedAtUtc = now; existing.OpenUntilUtc = now.Add(_circuitOpenDuration); existing.HalfOpenProbe = false; return true; } bool sameWindow = now < existing.FailureWindowStartedAtUtc || now - existing.FailureWindowStartedAtUtc <= _circuitFailureWindow; if (!sameWindow) { existing.FailureWindowStartedAtUtc = now; existing.FailureCount = 0; } existing.FailureCount += 1; if (existing.FailureCount >= _circuitFailureThreshold) { existing.OpenUntilUtc = now.Add(_circuitOpenDuration); return true; } return false; } } public BridgeOperationalSnapshot Snapshot() { DateTime now = _clock.UtcNow; lock (_syncRoot) { PurgeRateStates(now); PurgeCircuitStates(now); int open = 0; foreach (CircuitState state in _circuitStates.Values) { if (state.OpenUntilUtc > now || state.HalfOpenProbe) open += 1; } return new BridgeOperationalSnapshot { RequestsPerMinute = _requestsPerMinute, MaximumTrackedSessions = _maximumTrackedSessions, TrackedSessionCount = _rateStates.Count, DisabledCommandCount = _disabledCommands.Count, OpenCircuitCount = open, CircuitFailureThreshold = _circuitFailureThreshold, CircuitOpenSeconds = (int)_circuitOpenDuration.TotalSeconds }; } } private bool IsCircuitBlocking( string commandName, BridgeCommandStage stage, DateTime now) { CircuitState state; return _circuitStates.TryGetValue( CircuitKey(commandName, stage), out state) && (state.OpenUntilUtc > now || state.HalfOpenProbe); } private void PurgeRateStates(DateTime now) { List expired = new List(); foreach (KeyValuePair item in _rateStates) { if (now >= item.Value.LastSeenAtUtc && now - item.Value.LastSeenAtUtc >= _sessionRetention) expired.Add(item.Key); } foreach (string key in expired) _rateStates.Remove(key); } private void PurgeCircuitStates(DateTime now) { List expired = new List(); foreach (KeyValuePair item in _circuitStates) { CircuitState state = item.Value; if (state.HalfOpenProbe) continue; if (state.OpenUntilUtc != DateTime.MinValue) { if (now >= state.OpenUntilUtc && now - state.OpenUntilUtc > _circuitFailureWindow) expired.Add(item.Key); } else if (now >= state.FailureWindowStartedAtUtc && now - state.FailureWindowStartedAtUtc > _circuitFailureWindow) { expired.Add(item.Key); } } foreach (string key in expired) _circuitStates.Remove(key); } private void PurgeRejectionAudits(DateTime now) { List expired = new List(); foreach (KeyValuePair item in _rejectionAudits) { if (now >= item.Value && now - item.Value >= TimeSpan.FromMinutes(5)) expired.Add(item.Key); } foreach (string key in expired) _rejectionAudits.Remove(key); } private bool ShouldAuditCapacityRejection(DateTime now) { if (_capacityAuditWindowStartedAtUtc == DateTime.MinValue || (now >= _capacityAuditWindowStartedAtUtc && now - _capacityAuditWindowStartedAtUtc >= TimeSpan.FromMinutes(1))) { _capacityAuditWindowStartedAtUtc = now; return true; } return false; } private static string CircuitKey( string commandName, BridgeCommandStage stage) { return (commandName ?? string.Empty).Trim().ToLowerInvariant() + "|" + (stage == BridgeCommandStage.Execute ? "execute" : "plan"); } private static IEnumerable ParseDisabledCommands(string value) { if (string.IsNullOrWhiteSpace(value)) return new string[0]; return value.Split(new[] { ',' }, StringSplitOptions.None); } private static int ParseBoundedInteger( string name, int defaultValue, int minimum, int maximum) { string raw = Environment.GetEnvironmentVariable(name); if (string.IsNullOrWhiteSpace(raw)) return defaultValue; int value; if (!int.TryParse( raw.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out value) || value < minimum || value > maximum) throw Invalid("ERP 命令桥运维参数格式或范围无效:" + name); return value; } private static CommandKernelException Invalid(string message) { return new CommandKernelException( "bridge_operational_policy_invalid", message, 6); } private sealed class RateState { public DateTime WindowStartedAtUtc { get; set; } public DateTime LastSeenAtUtc { get; set; } public int RequestCount { get; set; } public bool LimitAuditRecorded { get; set; } } private sealed class CircuitState { public DateTime FailureWindowStartedAtUtc { get; set; } public int FailureCount { get; set; } public DateTime OpenUntilUtc { get; set; } public bool HalfOpenProbe { get; set; } } } }