919 lines
36 KiB
C#
919 lines
36 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Text.RegularExpressions;
|
|
using Lskj.CommandKernel;
|
|
using Newtonsoft.Json.Linq;
|
|
|
|
namespace Lskj.AgentBridge
|
|
{
|
|
public sealed class AgentBridgeRuntime : IAgentBridgeRuntime
|
|
{
|
|
public const string ProtocolVersion = "1.0";
|
|
private static readonly Regex SafeEnvelopeIdentifier = new Regex(
|
|
"^[A-Za-z0-9_.:-]{8,128}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeMethod = new Regex(
|
|
"^[A-Za-z0-9_.:-]{1,128}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeCommandName = new Regex(
|
|
"^[A-Za-z0-9_.:-]{1,128}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafePlanId = new Regex(
|
|
"^[A-Fa-f0-9]{32}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeIdempotencyKey = new Regex(
|
|
"^[A-Za-z0-9_.:-]{8,128}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeOutcomeCode = new Regex(
|
|
"^[a-z0-9_.-]{1,128}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
|
|
private readonly CommandRegistry _registry;
|
|
private readonly CommandDispatcher _dispatcher;
|
|
private readonly IServerPlanStore _plans;
|
|
private readonly IBridgeExecutionContextFactory _contexts;
|
|
private readonly IBridgeContextProvider _contextProvider;
|
|
private readonly ICommandConfirmationPrompt _confirmationPrompt;
|
|
private readonly IConfirmationTokenIssuer _confirmationIssuer;
|
|
private readonly ISystemClock _clock;
|
|
private readonly BridgeOperationalPolicy _operations;
|
|
private readonly IBridgeOperationalAuditSink _operationalAudit;
|
|
private readonly CommandRolloutPolicy _rolloutPolicy;
|
|
private readonly WorkflowUatAuthorizationPolicy _uatPolicy;
|
|
private readonly object _executionSyncRoot = new object();
|
|
|
|
public AgentBridgeRuntime(
|
|
CommandRegistry registry,
|
|
CommandDispatcher dispatcher,
|
|
IServerPlanStore plans,
|
|
IBridgeExecutionContextFactory contexts,
|
|
IBridgeContextProvider contextProvider,
|
|
ICommandConfirmationPrompt confirmationPrompt,
|
|
IConfirmationTokenIssuer confirmationIssuer,
|
|
ISystemClock clock)
|
|
: this(
|
|
registry,
|
|
dispatcher,
|
|
plans,
|
|
contexts,
|
|
contextProvider,
|
|
confirmationPrompt,
|
|
confirmationIssuer,
|
|
clock,
|
|
BridgeOperationalPolicy.CreateDefault(clock),
|
|
new NullBridgeOperationalAuditSink(),
|
|
CommandRolloutPolicy.AllowAll(),
|
|
WorkflowUatAuthorizationPolicy.Disabled(clock))
|
|
{
|
|
}
|
|
|
|
public AgentBridgeRuntime(
|
|
CommandRegistry registry,
|
|
CommandDispatcher dispatcher,
|
|
IServerPlanStore plans,
|
|
IBridgeExecutionContextFactory contexts,
|
|
IBridgeContextProvider contextProvider,
|
|
ICommandConfirmationPrompt confirmationPrompt,
|
|
IConfirmationTokenIssuer confirmationIssuer,
|
|
ISystemClock clock,
|
|
BridgeOperationalPolicy operations)
|
|
: this(
|
|
registry,
|
|
dispatcher,
|
|
plans,
|
|
contexts,
|
|
contextProvider,
|
|
confirmationPrompt,
|
|
confirmationIssuer,
|
|
clock,
|
|
operations,
|
|
new NullBridgeOperationalAuditSink(),
|
|
CommandRolloutPolicy.AllowAll(),
|
|
WorkflowUatAuthorizationPolicy.Disabled(clock))
|
|
{
|
|
}
|
|
|
|
public AgentBridgeRuntime(
|
|
CommandRegistry registry,
|
|
CommandDispatcher dispatcher,
|
|
IServerPlanStore plans,
|
|
IBridgeExecutionContextFactory contexts,
|
|
IBridgeContextProvider contextProvider,
|
|
ICommandConfirmationPrompt confirmationPrompt,
|
|
IConfirmationTokenIssuer confirmationIssuer,
|
|
ISystemClock clock,
|
|
BridgeOperationalPolicy operations,
|
|
IBridgeOperationalAuditSink operationalAudit)
|
|
: this(
|
|
registry,
|
|
dispatcher,
|
|
plans,
|
|
contexts,
|
|
contextProvider,
|
|
confirmationPrompt,
|
|
confirmationIssuer,
|
|
clock,
|
|
operations,
|
|
operationalAudit,
|
|
CommandRolloutPolicy.AllowAll(),
|
|
WorkflowUatAuthorizationPolicy.Disabled(clock))
|
|
{
|
|
}
|
|
|
|
public AgentBridgeRuntime(
|
|
CommandRegistry registry,
|
|
CommandDispatcher dispatcher,
|
|
IServerPlanStore plans,
|
|
IBridgeExecutionContextFactory contexts,
|
|
IBridgeContextProvider contextProvider,
|
|
ICommandConfirmationPrompt confirmationPrompt,
|
|
IConfirmationTokenIssuer confirmationIssuer,
|
|
ISystemClock clock,
|
|
BridgeOperationalPolicy operations,
|
|
IBridgeOperationalAuditSink operationalAudit,
|
|
CommandRolloutPolicy rolloutPolicy)
|
|
: this(
|
|
registry,
|
|
dispatcher,
|
|
plans,
|
|
contexts,
|
|
contextProvider,
|
|
confirmationPrompt,
|
|
confirmationIssuer,
|
|
clock,
|
|
operations,
|
|
operationalAudit,
|
|
rolloutPolicy,
|
|
WorkflowUatAuthorizationPolicy.Disabled(clock))
|
|
{
|
|
}
|
|
|
|
public AgentBridgeRuntime(
|
|
CommandRegistry registry,
|
|
CommandDispatcher dispatcher,
|
|
IServerPlanStore plans,
|
|
IBridgeExecutionContextFactory contexts,
|
|
IBridgeContextProvider contextProvider,
|
|
ICommandConfirmationPrompt confirmationPrompt,
|
|
IConfirmationTokenIssuer confirmationIssuer,
|
|
ISystemClock clock,
|
|
BridgeOperationalPolicy operations,
|
|
IBridgeOperationalAuditSink operationalAudit,
|
|
CommandRolloutPolicy rolloutPolicy,
|
|
WorkflowUatAuthorizationPolicy uatPolicy)
|
|
{
|
|
if (registry == null) throw new ArgumentNullException("registry");
|
|
if (dispatcher == null) throw new ArgumentNullException("dispatcher");
|
|
if (plans == null) throw new ArgumentNullException("plans");
|
|
if (contexts == null) throw new ArgumentNullException("contexts");
|
|
if (contextProvider == null) throw new ArgumentNullException("contextProvider");
|
|
if (confirmationPrompt == null) throw new ArgumentNullException("confirmationPrompt");
|
|
if (confirmationIssuer == null) throw new ArgumentNullException("confirmationIssuer");
|
|
if (clock == null) throw new ArgumentNullException("clock");
|
|
if (operations == null) throw new ArgumentNullException("operations");
|
|
if (operationalAudit == null)
|
|
throw new ArgumentNullException("operationalAudit");
|
|
if (rolloutPolicy == null)
|
|
throw new ArgumentNullException("rolloutPolicy");
|
|
if (uatPolicy == null)
|
|
throw new ArgumentNullException("uatPolicy");
|
|
|
|
_registry = registry;
|
|
_dispatcher = dispatcher;
|
|
_plans = plans;
|
|
_contexts = contexts;
|
|
_contextProvider = contextProvider;
|
|
_confirmationPrompt = confirmationPrompt;
|
|
_confirmationIssuer = confirmationIssuer;
|
|
_clock = clock;
|
|
_operations = operations;
|
|
_operationalAudit = operationalAudit;
|
|
_rolloutPolicy = rolloutPolicy;
|
|
_uatPolicy = uatPolicy;
|
|
}
|
|
|
|
public BridgeResponse Handle(BridgeRequest request)
|
|
{
|
|
if (request == null) return BridgeResponse.Error(null, "invalid_request", "请求不能为空。");
|
|
if (!string.Equals(request.ProtocolVersion, ProtocolVersion, StringComparison.Ordinal))
|
|
return BridgeResponse.Error(request, "protocol_version_unsupported", "不支持的桥协议版本。");
|
|
if (!SafeEnvelopeIdentifier.IsMatch(request.RequestId ?? string.Empty)
|
|
|| !SafeEnvelopeIdentifier.IsMatch(request.CorrelationId ?? string.Empty)
|
|
|| !SafeEnvelopeIdentifier.IsMatch(request.ClientSessionId ?? string.Empty)
|
|
|| !SafeMethod.IsMatch(request.Method ?? string.Empty))
|
|
{
|
|
return BridgeResponse.Error(
|
|
request,
|
|
"invalid_request",
|
|
"requestId、correlationId、clientSessionId 或 method 格式无效。");
|
|
}
|
|
if (request.Payload == null) request.Payload = new JObject();
|
|
if (request.UatGrant != null
|
|
&& request.Method != "command.plan"
|
|
&& request.Method != "command.execute")
|
|
return BridgeResponse.Error(
|
|
request,
|
|
"workflow_uat_grant_not_applicable",
|
|
"UAT 用例令牌只能用于受控计划或执行请求。");
|
|
|
|
BridgeResponse sessionScopeFailure = ValidateSessionScope(request);
|
|
if (sessionScopeFailure != null) return sessionScopeFailure;
|
|
|
|
BridgePolicyDecision admission = _operations.Admit(
|
|
request.ClientSessionId);
|
|
if (!admission.Allowed)
|
|
{
|
|
SafeOperationalAudit(
|
|
request,
|
|
"operational_rejected",
|
|
null,
|
|
"request",
|
|
admission.Code,
|
|
admission.AuditRecommended);
|
|
return BridgeResponse.Error(
|
|
request,
|
|
admission.Code,
|
|
admission.Message);
|
|
}
|
|
|
|
try
|
|
{
|
|
_plans.PurgeExpired(_clock.UtcNow);
|
|
switch (request.Method.Trim().ToLowerInvariant())
|
|
{
|
|
case "health":
|
|
return Health(request);
|
|
case "capabilities.list":
|
|
return Capabilities(request);
|
|
case "context.get":
|
|
return Context(request);
|
|
case "command.plan":
|
|
return Plan(request);
|
|
case "command.execute":
|
|
// ERP UI 本身是单线程;串行化还能确保同一 plan 的并发重试
|
|
// 不会重复弹出确认窗口。第一个请求完成后计划即被移除。
|
|
lock (_executionSyncRoot)
|
|
return Execute(request);
|
|
default:
|
|
return BridgeResponse.Error(request, "method_not_found", "未注册桥方法:" + request.Method);
|
|
}
|
|
}
|
|
catch (CommandKernelException ex)
|
|
{
|
|
return BridgeResponse.Error(request, ex.Code, ex.Message);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
return BridgeResponse.Error(request, "internal_error", "ERP 命令桥发生内部错误,请使用关联 ID 查询审计日志。");
|
|
}
|
|
}
|
|
|
|
private BridgeResponse ValidateSessionScope(BridgeRequest request)
|
|
{
|
|
string method = (request.Method ?? string.Empty).Trim()
|
|
.ToLowerInvariant();
|
|
bool bootstrapAllowed = method == "health"
|
|
|| method == "context.get";
|
|
if (string.IsNullOrWhiteSpace(request.SessionScopeToken))
|
|
{
|
|
return bootstrapAllowed
|
|
? null
|
|
: BridgeResponse.Error(
|
|
request,
|
|
"bridge_session_scope_token_required",
|
|
"能力、计划和执行请求必须绑定当前 ERP 会话作用域。");
|
|
}
|
|
if (!BridgeSessionScopeToken.IsValid(request.SessionScopeToken))
|
|
{
|
|
return BridgeResponse.Error(
|
|
request,
|
|
"bridge_session_scope_token_invalid",
|
|
"ERP 会话作用域令牌格式无效。");
|
|
}
|
|
try
|
|
{
|
|
CommandExecutionContext context = _contexts.Create(request);
|
|
if (!BridgeSessionScopeToken.Matches(
|
|
request.SessionScopeToken,
|
|
context))
|
|
{
|
|
return BridgeResponse.Error(
|
|
request,
|
|
"erp_session_scope_mismatch",
|
|
"请求绑定的数据库、用户、账套、子系统或权限范围与当前 ERP 会话不一致。");
|
|
}
|
|
return null;
|
|
}
|
|
catch (CommandKernelException error)
|
|
{
|
|
return BridgeResponse.Error(request, error.Code, error.Message);
|
|
}
|
|
catch
|
|
{
|
|
return BridgeResponse.Error(
|
|
request,
|
|
"erp_session_scope_unavailable",
|
|
"当前 ERP 会话作用域无法安全确认。");
|
|
}
|
|
}
|
|
|
|
private BridgeResponse Health(BridgeRequest request)
|
|
{
|
|
EnsurePayloadShape(request.Payload);
|
|
BridgeOperationalSnapshot policy = _operations.Snapshot();
|
|
int enabledCommands = _registry.List().Count(item =>
|
|
_operations.IsCommandVisible(
|
|
item.Name,
|
|
IsExecutableRisk(item.Risk))
|
|
&& _uatPolicy.IsVisibleInGeneralCapabilities(item.Name));
|
|
return BridgeResponse.Ok(request, new
|
|
{
|
|
status = "ready",
|
|
protocolVersion = ProtocolVersion,
|
|
serverTimeUtc = _clock.UtcNow,
|
|
commandCount = _registry.List().Count,
|
|
enabledCommandCount = enabledCommands,
|
|
operationalPolicy = new
|
|
{
|
|
requestsPerMinute = policy.RequestsPerMinute,
|
|
maximumTrackedSessions = policy.MaximumTrackedSessions,
|
|
trackedSessionCount = policy.TrackedSessionCount,
|
|
disabledCommandCount = policy.DisabledCommandCount,
|
|
openCircuitCount = policy.OpenCircuitCount,
|
|
circuitFailureThreshold = policy.CircuitFailureThreshold,
|
|
circuitOpenSeconds = policy.CircuitOpenSeconds
|
|
},
|
|
rolloutPolicy = new
|
|
{
|
|
configured = _rolloutPolicy.Configured,
|
|
failClosed = _rolloutPolicy.Configured
|
|
&& string.Equals(
|
|
_rolloutPolicy.DefaultAction,
|
|
"deny",
|
|
StringComparison.Ordinal),
|
|
customerId = _rolloutPolicy.CustomerId,
|
|
databaseScopeFingerprint =
|
|
_rolloutPolicy.DatabaseScopeFingerprint,
|
|
sourceSha256 = _rolloutPolicy.SourceSha256,
|
|
defaultAction = _rolloutPolicy.DefaultAction,
|
|
ruleCount = _rolloutPolicy.RuleCount
|
|
},
|
|
workflowUat = _uatPolicy.SafeSnapshot()
|
|
});
|
|
}
|
|
|
|
private BridgeResponse Capabilities(BridgeRequest request)
|
|
{
|
|
EnsurePayloadShape(request.Payload);
|
|
CommandExecutionContext context = _contexts.Create(request);
|
|
IList<CommandDescriptor> commands = _registry.List()
|
|
.Where(item => _dispatcher.CheckAuthorization(item, context, false).Allowed)
|
|
.Where(item => _operations.IsCommandVisible(
|
|
item.Name,
|
|
IsExecutableRisk(item.Risk)))
|
|
.Where(item => _uatPolicy.IsVisibleInGeneralCapabilities(
|
|
item.Name))
|
|
.ToList();
|
|
return BridgeResponse.Ok(request, new
|
|
{
|
|
commands = commands.Select(item => new
|
|
{
|
|
name = item.Name,
|
|
version = item.Version,
|
|
description = item.Description,
|
|
schemaVersion = item.SchemaVersion,
|
|
inputSchema = item.InputSchema,
|
|
risk = item.Risk.ToString().ToLowerInvariant(),
|
|
requiresConfirmation = item.RequiresConfirmation,
|
|
requiresIdempotencyKey = item.RequiresIdempotencyKey
|
|
}).ToList()
|
|
});
|
|
}
|
|
|
|
private BridgeResponse Context(BridgeRequest request)
|
|
{
|
|
EnsurePayloadShape(request.Payload);
|
|
CommandExecutionContext context = _contexts.Create(request);
|
|
return BridgeResponse.Ok(request, _contextProvider.Snapshot(context));
|
|
}
|
|
|
|
private BridgeResponse Plan(BridgeRequest request)
|
|
{
|
|
EnsurePayloadShape(request.Payload, "command", "input");
|
|
string commandName = RequiredString(
|
|
request.Payload,
|
|
"command",
|
|
128,
|
|
SafeCommandName);
|
|
JToken inputToken = request.Payload["input"];
|
|
if (inputToken != null && inputToken.Type != JTokenType.Object)
|
|
throw new CommandKernelException(
|
|
"invalid_request",
|
|
"command.plan 的 input 必须是 JSON 对象。",
|
|
2);
|
|
JObject inputObject = inputToken as JObject ?? new JObject();
|
|
IDictionary<string, object> input = inputObject.ToObject<Dictionary<string, object>>();
|
|
EnsurePlanOperationalCommand(commandName, request);
|
|
try
|
|
{
|
|
CommandExecutionContext context = _contexts.Create(request);
|
|
WorkflowUatPlanBinding uatBinding = _uatPolicy.AuthorizePlan(
|
|
request,
|
|
context,
|
|
commandName);
|
|
CommandPlan plan = _dispatcher.Plan(commandName, input, context);
|
|
_uatPolicy.BindPlan(plan, uatBinding);
|
|
if (IsExecutionAllowed(plan)) _plans.Save(plan);
|
|
RecordCommandSuccess(
|
|
request,
|
|
commandName,
|
|
BridgeCommandStage.Plan);
|
|
return BridgeResponse.Ok(request, new { plan = ProjectPlan(plan) });
|
|
}
|
|
catch (CommandKernelException error)
|
|
{
|
|
RecordCommandFailure(
|
|
request,
|
|
commandName,
|
|
BridgeCommandStage.Plan,
|
|
error.Code);
|
|
throw;
|
|
}
|
|
catch
|
|
{
|
|
RecordCommandFailure(
|
|
request,
|
|
commandName,
|
|
BridgeCommandStage.Plan,
|
|
"internal_error");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private BridgeResponse Execute(BridgeRequest request)
|
|
{
|
|
EnsurePayloadShape(request.Payload, "planId", "idempotencyKey");
|
|
string planId = RequiredString(
|
|
request.Payload,
|
|
"planId",
|
|
32,
|
|
SafePlanId);
|
|
string idempotencyKey = RequiredString(
|
|
request.Payload,
|
|
"idempotencyKey",
|
|
128,
|
|
SafeIdempotencyKey);
|
|
CommandPlan plan;
|
|
if (!_plans.TryGet(planId, out plan))
|
|
return BridgeResponse.Error(request, "plan_not_found", "计划不存在或已过期,请重新生成预览。");
|
|
if (!IsExecutionAllowed(plan))
|
|
{
|
|
_plans.Remove(plan.PlanId);
|
|
return BridgeResponse.Error(request, "plan_not_executable", "该计划是解析或只读预览,不能执行。");
|
|
}
|
|
|
|
ICommandHandler handler = _registry.Resolve(plan.CommandName);
|
|
if (handler == null)
|
|
{
|
|
_plans.Remove(plan.PlanId);
|
|
return BridgeResponse.Error(request, "command_not_found", "计划对应的命令已不可用。");
|
|
}
|
|
BridgePolicyDecision availability = _operations.TryEnterCommand(
|
|
plan.CommandName,
|
|
BridgeCommandStage.Execute);
|
|
if (!availability.Allowed)
|
|
{
|
|
if (BridgeErrorRecoveryContract.InvalidatesPlan(
|
|
availability.Code))
|
|
_plans.Remove(plan.PlanId);
|
|
AuditPolicyRejection(
|
|
request,
|
|
plan.CommandName,
|
|
BridgeCommandStage.Execute,
|
|
availability);
|
|
return BridgeResponse.Error(
|
|
request,
|
|
availability.Code,
|
|
availability.Message);
|
|
}
|
|
|
|
try
|
|
{
|
|
CommandExecutionContext context = _contexts.Create(request);
|
|
_uatPolicy.AuthorizeExecute(request, context, plan);
|
|
context.IdempotencyKey = idempotencyKey;
|
|
_dispatcher.ValidateExecutionPreconditions(plan, context);
|
|
if (handler.Descriptor.RequiresConfirmation)
|
|
{
|
|
bool confirmed = _confirmationPrompt.Confirm(
|
|
handler.Descriptor,
|
|
plan,
|
|
context);
|
|
if (!confirmed)
|
|
{
|
|
RecordCommandFailure(
|
|
request,
|
|
plan.CommandName,
|
|
BridgeCommandStage.Execute,
|
|
"user_cancelled");
|
|
return BridgeResponse.Error(
|
|
request,
|
|
"user_cancelled",
|
|
"用户已取消操作。");
|
|
}
|
|
context.ConfirmationToken = _confirmationIssuer.Issue(
|
|
plan,
|
|
context,
|
|
TimeSpan.FromMinutes(2));
|
|
}
|
|
|
|
CommandResult result = _dispatcher.Execute(plan, context);
|
|
if (!result.Success)
|
|
throw new CommandKernelException(
|
|
"command_result_invalid",
|
|
"ERP 命令处理器没有返回可确认的成功结果,请使用关联 ID 查询审计。",
|
|
6);
|
|
if (result.Success) _plans.Remove(plan.PlanId);
|
|
RecordCommandSuccess(
|
|
request,
|
|
plan.CommandName,
|
|
BridgeCommandStage.Execute);
|
|
string followupCode;
|
|
CommandPlan followup = TryPlanFollowup(
|
|
handler,
|
|
plan,
|
|
result,
|
|
request,
|
|
out followupCode);
|
|
return BridgeResponse.Ok(request, new
|
|
{
|
|
result = ProjectResult(result),
|
|
followupPlan = followup == null ? null : ProjectPlan(followup),
|
|
followupCode = followupCode
|
|
});
|
|
}
|
|
catch (CommandKernelException error)
|
|
{
|
|
if (BridgeErrorRecoveryContract.InvalidatesPlan(error.Code))
|
|
_plans.Remove(plan.PlanId);
|
|
RecordCommandFailure(
|
|
request,
|
|
plan.CommandName,
|
|
BridgeCommandStage.Execute,
|
|
error.Code);
|
|
throw;
|
|
}
|
|
catch
|
|
{
|
|
_plans.Remove(plan.PlanId);
|
|
RecordCommandFailure(
|
|
request,
|
|
plan.CommandName,
|
|
BridgeCommandStage.Execute,
|
|
"internal_error");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private CommandPlan TryPlanFollowup(
|
|
ICommandHandler completedHandler,
|
|
CommandPlan completedPlan,
|
|
CommandResult completedResult,
|
|
BridgeRequest request,
|
|
out string failureCode)
|
|
{
|
|
failureCode = null;
|
|
if (completedResult == null || !completedResult.Success) return null;
|
|
ICommandExecutionFollowupProvider provider =
|
|
completedHandler as ICommandExecutionFollowupProvider;
|
|
if (provider == null) return null;
|
|
string followupCommandName = null;
|
|
try
|
|
{
|
|
CommandExecutionContext followupContext = _contexts.Create(request);
|
|
CommandFollowupRequest followup;
|
|
if (!provider.TryCreateFollowup(
|
|
completedPlan,
|
|
completedResult,
|
|
followupContext,
|
|
out followup)
|
|
|| followup == null
|
|
|| string.IsNullOrWhiteSpace(followup.CommandName))
|
|
return null;
|
|
followupCommandName = followup.CommandName;
|
|
EnsurePlanOperationalCommand(followupCommandName, request);
|
|
WorkflowUatPlanBinding uatBinding = _uatPolicy.AuthorizePlan(
|
|
request,
|
|
followupContext,
|
|
followupCommandName);
|
|
CommandPlan plan = _dispatcher.Plan(
|
|
followupCommandName,
|
|
followup.Input ?? new Dictionary<string, object>(),
|
|
followupContext);
|
|
_uatPolicy.BindPlan(plan, uatBinding);
|
|
if (IsExecutionAllowed(plan)) _plans.Save(plan);
|
|
RecordCommandSuccess(
|
|
request,
|
|
followupCommandName,
|
|
BridgeCommandStage.Plan);
|
|
return plan;
|
|
}
|
|
catch (CommandKernelException error)
|
|
{
|
|
// 主写入已成功,后续计划失败不能把整个请求伪装成写入失败。
|
|
if (!string.IsNullOrWhiteSpace(followupCommandName))
|
|
RecordCommandFailure(
|
|
request,
|
|
followupCommandName,
|
|
BridgeCommandStage.Plan,
|
|
error.Code);
|
|
failureCode = error.Code;
|
|
return null;
|
|
}
|
|
catch
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(followupCommandName))
|
|
RecordCommandFailure(
|
|
request,
|
|
followupCommandName,
|
|
BridgeCommandStage.Plan,
|
|
"internal_error");
|
|
failureCode = "followup_plan_failed";
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static object ProjectPlan(CommandPlan plan)
|
|
{
|
|
object title;
|
|
object preview;
|
|
plan.Data.TryGetValue("title", out title);
|
|
plan.Data.TryGetValue("preview", out preview);
|
|
return new
|
|
{
|
|
planId = plan.PlanId,
|
|
commandName = plan.CommandName,
|
|
commandVersion = plan.CommandVersion,
|
|
moduleCode = plan.ModuleCode,
|
|
risk = plan.Risk.ToString().ToLowerInvariant(),
|
|
createdAtUtc = plan.CreatedAtUtc,
|
|
expiresAtUtc = plan.ExpiresAtUtc,
|
|
valid = plan.Valid,
|
|
executionAllowed = IsExecutionAllowed(plan),
|
|
inputFingerprint = plan.InputFingerprint,
|
|
outcomeCode = ProjectOutcomeCode(plan),
|
|
title = title,
|
|
preview = preview,
|
|
data = plan.Data,
|
|
warnings = plan.Warnings
|
|
};
|
|
}
|
|
|
|
private static string ProjectOutcomeCode(CommandPlan plan)
|
|
{
|
|
object raw;
|
|
string value = plan != null
|
|
&& plan.Data.TryGetValue("outcomeCode", out raw)
|
|
? raw as string
|
|
: null;
|
|
if (!string.IsNullOrWhiteSpace(value)
|
|
&& SafeOutcomeCode.IsMatch(value))
|
|
return value;
|
|
return plan != null && plan.Valid ? "plan_ready" : "plan_invalid";
|
|
}
|
|
|
|
private static bool IsExecutionAllowed(CommandPlan plan)
|
|
{
|
|
if (plan == null || !plan.Valid) return false;
|
|
return plan.Risk == CommandRisk.Navigate
|
|
|| plan.Risk == CommandRisk.Write
|
|
|| plan.Risk == CommandRisk.Critical;
|
|
}
|
|
|
|
private static bool IsExecutableRisk(CommandRisk risk)
|
|
{
|
|
return risk == CommandRisk.Navigate
|
|
|| risk == CommandRisk.Write
|
|
|| risk == CommandRisk.Critical;
|
|
}
|
|
|
|
private void EnsureOperationalCommand(
|
|
string commandName,
|
|
BridgeCommandStage stage,
|
|
BridgeRequest request)
|
|
{
|
|
BridgePolicyDecision decision = _operations.TryEnterCommand(
|
|
commandName,
|
|
stage);
|
|
if (decision.Allowed) return;
|
|
AuditPolicyRejection(request, commandName, stage, decision);
|
|
throw new CommandKernelException(
|
|
decision.Code,
|
|
decision.Message,
|
|
6);
|
|
}
|
|
|
|
private void EnsurePlanOperationalCommand(
|
|
string commandName,
|
|
BridgeRequest request)
|
|
{
|
|
BridgePolicyDecision planning =
|
|
_operations.CheckCommandAvailability(
|
|
commandName,
|
|
BridgeCommandStage.Plan);
|
|
if (!planning.Allowed)
|
|
{
|
|
AuditPolicyRejection(
|
|
request,
|
|
commandName,
|
|
BridgeCommandStage.Plan,
|
|
planning);
|
|
throw new CommandKernelException(
|
|
planning.Code,
|
|
planning.Message,
|
|
6);
|
|
}
|
|
ICommandHandler handler = _registry.Resolve(commandName);
|
|
if (handler != null
|
|
&& handler.Descriptor != null
|
|
&& IsExecutableRisk(handler.Descriptor.Risk))
|
|
{
|
|
BridgePolicyDecision execution =
|
|
_operations.CheckCommandAvailability(
|
|
commandName,
|
|
BridgeCommandStage.Execute);
|
|
if (!execution.Allowed)
|
|
{
|
|
AuditPolicyRejection(
|
|
request,
|
|
commandName,
|
|
BridgeCommandStage.Execute,
|
|
execution);
|
|
throw new CommandKernelException(
|
|
execution.Code,
|
|
execution.Message,
|
|
6);
|
|
}
|
|
}
|
|
EnsureOperationalCommand(
|
|
commandName,
|
|
BridgeCommandStage.Plan,
|
|
request);
|
|
}
|
|
|
|
private void AuditPolicyRejection(
|
|
BridgeRequest request,
|
|
string commandName,
|
|
BridgeCommandStage stage,
|
|
BridgePolicyDecision decision)
|
|
{
|
|
if (decision == null || decision.Allowed) return;
|
|
bool recommended = _operations.ShouldAuditCommandRejection(
|
|
commandName,
|
|
stage,
|
|
decision.Code);
|
|
SafeOperationalAudit(
|
|
request,
|
|
"operational_rejected",
|
|
commandName,
|
|
StageName(stage),
|
|
decision.Code,
|
|
recommended);
|
|
}
|
|
|
|
private void RecordCommandSuccess(
|
|
BridgeRequest request,
|
|
string commandName,
|
|
BridgeCommandStage stage)
|
|
{
|
|
if (!_operations.RecordCommandSuccess(commandName, stage)) return;
|
|
SafeOperationalAudit(
|
|
request,
|
|
"command_circuit_closed",
|
|
commandName,
|
|
StageName(stage),
|
|
"command_circuit_closed",
|
|
true);
|
|
}
|
|
|
|
private void RecordCommandFailure(
|
|
BridgeRequest request,
|
|
string commandName,
|
|
BridgeCommandStage stage,
|
|
string errorCode)
|
|
{
|
|
if (!_operations.RecordCommandFailure(
|
|
commandName,
|
|
stage,
|
|
errorCode)) return;
|
|
SafeOperationalAudit(
|
|
request,
|
|
"command_circuit_opened",
|
|
commandName,
|
|
StageName(stage),
|
|
"command_circuit_open",
|
|
true);
|
|
}
|
|
|
|
private void SafeOperationalAudit(
|
|
BridgeRequest request,
|
|
string eventName,
|
|
string commandName,
|
|
string stage,
|
|
string outcomeCode,
|
|
bool recommended)
|
|
{
|
|
if (!recommended || request == null) return;
|
|
CommandExecutionContext context = null;
|
|
try { context = _contexts.Create(request); }
|
|
catch { }
|
|
if (context == null)
|
|
{
|
|
context = new CommandExecutionContext
|
|
{
|
|
CorrelationId = request.CorrelationId,
|
|
ClientSessionId = request.ClientSessionId
|
|
};
|
|
}
|
|
try
|
|
{
|
|
_operationalAudit.RecordOperationalEvent(
|
|
eventName,
|
|
request.Method,
|
|
commandName,
|
|
stage,
|
|
outcomeCode,
|
|
context);
|
|
}
|
|
catch
|
|
{
|
|
// A rejection must remain fail-closed even if its best-effort
|
|
// operational audit cannot be appended. Duplicate rejection
|
|
// suppression prevents the rejection path becoming a log DoS.
|
|
}
|
|
}
|
|
|
|
private static string StageName(BridgeCommandStage stage)
|
|
{
|
|
return stage == BridgeCommandStage.Execute ? "execute" : "plan";
|
|
}
|
|
|
|
private static object ProjectResult(CommandResult result)
|
|
{
|
|
return new
|
|
{
|
|
success = result.Success,
|
|
code = result.Code,
|
|
message = result.Message,
|
|
recordId = result.RecordId,
|
|
replayed = result.Replayed,
|
|
transactionEvidenceId = result.TransactionEvidenceId,
|
|
businessAuditId = result.BusinessAuditId,
|
|
data = result.Data
|
|
};
|
|
}
|
|
|
|
private static void EnsurePayloadShape(JObject payload, params string[] allowedNames)
|
|
{
|
|
if (payload == null)
|
|
throw new CommandKernelException(
|
|
"invalid_request",
|
|
"桥请求 payload 必须是 JSON 对象。",
|
|
2);
|
|
ISet<string> allowed = new HashSet<string>(
|
|
allowedNames ?? new string[0],
|
|
StringComparer.Ordinal);
|
|
foreach (JProperty property in payload.Properties())
|
|
{
|
|
if (!allowed.Contains(property.Name))
|
|
throw new CommandKernelException(
|
|
"invalid_request",
|
|
"桥请求 payload 包含未知字段:" + property.Name,
|
|
2);
|
|
}
|
|
}
|
|
|
|
private static string RequiredString(
|
|
JObject source,
|
|
string name,
|
|
int maximumLength,
|
|
Regex pattern)
|
|
{
|
|
JToken token = source == null ? null : source[name];
|
|
if (token == null || token.Type != JTokenType.String)
|
|
throw new CommandKernelException("invalid_request", "缺少字段:" + name, 2);
|
|
string value = token.Value<string>();
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
throw new CommandKernelException("invalid_request", "缺少字段:" + name, 2);
|
|
value = value.Trim();
|
|
if (value.Length > maximumLength || pattern == null || !pattern.IsMatch(value))
|
|
throw new CommandKernelException(
|
|
"invalid_request",
|
|
"桥请求字段格式无效:" + name,
|
|
2);
|
|
return value;
|
|
}
|
|
}
|
|
}
|