Files
lserp_cs_6.0/插件库/Lskj.AgentBridge/BridgeOperationalPolicy.cs
2026-08-14 14:28:28 +08:00

612 lines
24 KiB
C#

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; }
}
/// <summary>
/// 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.
/// </summary>
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<string> OperationalFailureCodes =
new HashSet<string>(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<string> _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<string, RateState> _rateStates =
new Dictionary<string, RateState>(StringComparer.Ordinal);
private readonly IDictionary<string, CircuitState> _circuitStates =
new Dictionary<string, CircuitState>(StringComparer.Ordinal);
private readonly IDictionary<string, DateTime> _rejectionAudits =
new Dictionary<string, DateTime>(StringComparer.Ordinal);
private DateTime _capacityAuditWindowStartedAtUtc;
public BridgeOperationalPolicy(
ISystemClock clock,
IEnumerable<string> 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<string> disabled = new HashSet<string>(
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<CommandDescriptor> descriptors)
{
if (descriptors == null)
throw Invalid("ERP 命令注册表不能为空。");
HashSet<string> registered = new HashSet<string>(
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<string> expired = new List<string>();
foreach (KeyValuePair<string, RateState> 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<string> expired = new List<string>();
foreach (KeyValuePair<string, CircuitState> 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<string> expired = new List<string>();
foreach (KeyValuePair<string, DateTime> 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<string> 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; }
}
}
}