feat: add ERP agent pet bridge and startup guide
This commit is contained in:
@@ -0,0 +1,918 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user