366 lines
15 KiB
C#
366 lines
15 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace Lskj.CommandKernel
|
|
{
|
|
public sealed class SystemClock : ISystemClock
|
|
{
|
|
public DateTime UtcNow
|
|
{
|
|
get { return DateTime.UtcNow; }
|
|
}
|
|
}
|
|
|
|
public sealed class AllowAllCommandAuthorizer : ICommandAuthorizer
|
|
{
|
|
public CommandAuthorizationDecision Authorize(
|
|
CommandDescriptor descriptor,
|
|
CommandExecutionContext context,
|
|
bool execution)
|
|
{
|
|
return CommandAuthorizationDecision.Allow();
|
|
}
|
|
}
|
|
|
|
public sealed class NullCommandAuditSink : ICommandAuditSink
|
|
{
|
|
public void Planned(CommandDescriptor descriptor, CommandPlan plan, CommandExecutionContext context)
|
|
{
|
|
}
|
|
|
|
public void Completed(
|
|
CommandDescriptor descriptor,
|
|
CommandPlan plan,
|
|
CommandResult result,
|
|
CommandExecutionContext context)
|
|
{
|
|
}
|
|
|
|
public void Failed(
|
|
CommandDescriptor descriptor,
|
|
CommandPlan plan,
|
|
Exception exception,
|
|
CommandExecutionContext context)
|
|
{
|
|
}
|
|
}
|
|
|
|
public sealed class CommandDispatcher
|
|
{
|
|
private static readonly Regex SafeIdempotencyKey = new Regex(
|
|
"^[A-Za-z0-9_.:-]{8,128}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private readonly CommandRegistry _registry;
|
|
private readonly ICommandAuditSink _audit;
|
|
private readonly IIdempotencyStore _idempotency;
|
|
private readonly IConfirmationValidator _confirmation;
|
|
private readonly ICommandAuthorizer _authorizer;
|
|
private readonly ISystemClock _clock;
|
|
|
|
public CommandDispatcher(
|
|
CommandRegistry registry,
|
|
ICommandAuditSink audit,
|
|
IIdempotencyStore idempotency,
|
|
IConfirmationValidator confirmation,
|
|
ICommandAuthorizer authorizer,
|
|
ISystemClock clock)
|
|
{
|
|
if (registry == null) throw new ArgumentNullException("registry");
|
|
if (audit == null) throw new ArgumentNullException("audit");
|
|
if (idempotency == null) throw new ArgumentNullException("idempotency");
|
|
if (confirmation == null) throw new ArgumentNullException("confirmation");
|
|
if (authorizer == null) throw new ArgumentNullException("authorizer");
|
|
if (clock == null) throw new ArgumentNullException("clock");
|
|
|
|
_registry = registry;
|
|
_audit = audit;
|
|
_idempotency = idempotency;
|
|
_confirmation = confirmation;
|
|
_authorizer = authorizer;
|
|
_clock = clock;
|
|
}
|
|
|
|
public CommandPlan Plan(
|
|
string commandName,
|
|
IDictionary<string, object> input,
|
|
CommandExecutionContext context)
|
|
{
|
|
ValidateContext(context);
|
|
ICommandHandler handler = Resolve(commandName);
|
|
CommandPlan plan = null;
|
|
try
|
|
{
|
|
EnsureAuthorized(handler.Descriptor, context, false);
|
|
CommandInputSchemaValidator.Validate(
|
|
handler.Descriptor.InputSchema,
|
|
input ?? new Dictionary<string, object>());
|
|
string inputFingerprint = CommandInputFingerprint.Create(
|
|
handler.Descriptor.Name,
|
|
input ?? new Dictionary<string, object>());
|
|
plan = handler.Plan(input ?? new Dictionary<string, object>(), context);
|
|
if (plan == null)
|
|
throw Error("invalid_plan", "命令处理器没有返回执行计划。");
|
|
|
|
plan.CommandName = handler.Descriptor.Name;
|
|
plan.CommandVersion = handler.Descriptor.Version;
|
|
plan.Risk = handler.Descriptor.Risk;
|
|
plan.OwnerClientSessionId = context.ClientSessionId;
|
|
plan.OwnerUserId = context.UserId;
|
|
plan.OwnerUserName = context.UserName;
|
|
plan.OwnerAccountBook = context.AccountBook;
|
|
plan.OwnerSubSystemId = context.SubSystemId;
|
|
plan.OwnerDatabaseScopeFingerprint =
|
|
context.DatabaseScopeFingerprint;
|
|
plan.CorrelationId = context.CorrelationId;
|
|
plan.InputFingerprint = inputFingerprint;
|
|
// 计划有效期属于调度器安全边界,不能由处理器或本机墙钟另行决定。
|
|
// 统一使用注入时钟也让桥协议、确认令牌和过期判断共享同一时间源。
|
|
plan.CreatedAtUtc = _clock.UtcNow;
|
|
plan.ExpiresAtUtc = plan.CreatedAtUtc.AddMinutes(10);
|
|
|
|
_audit.Planned(handler.Descriptor, plan, context);
|
|
return plan;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
SafeAuditFailure(handler.Descriptor, plan, ex, context);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public CommandResult Execute(CommandPlan plan, CommandExecutionContext context)
|
|
{
|
|
ValidateExecutionPreconditions(plan, context);
|
|
ICommandHandler handler = Resolve(plan.CommandName);
|
|
CommandDescriptor descriptor = handler.Descriptor;
|
|
|
|
bool idempotencyClaimed = false;
|
|
try
|
|
{
|
|
if (descriptor.RequiresIdempotencyKey)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(context.IdempotencyKey))
|
|
throw Error("idempotency_key_required", "写操作必须提供幂等键。");
|
|
context.IdempotencyKey = context.IdempotencyKey.Trim();
|
|
if (!SafeIdempotencyKey.IsMatch(context.IdempotencyKey))
|
|
throw Error(
|
|
"idempotency_key_invalid",
|
|
"幂等键只允许 8-128 位字母、数字、点、冒号、下划线和连字符。");
|
|
IdempotencyClaim claim = _idempotency.Claim(
|
|
descriptor.Name,
|
|
context.IdempotencyKey,
|
|
context,
|
|
plan.InputFingerprint);
|
|
if (claim == null)
|
|
throw Error("idempotency_store_error", "幂等存储没有返回有效状态。");
|
|
if (claim.State == IdempotencyClaimState.Replay)
|
|
{
|
|
CommandResult replay = claim.Result;
|
|
if (replay == null)
|
|
throw Error("idempotency_store_error", "幂等存储的历史结果无效。");
|
|
replay.Replayed = true;
|
|
_audit.Completed(descriptor, plan, replay, context);
|
|
return replay;
|
|
}
|
|
if (claim.State == IdempotencyClaimState.InProgress)
|
|
throw Error("idempotency_in_progress", "相同业务请求正在执行,请稍后查询结果,禁止重复提交。");
|
|
if (claim.State == IdempotencyClaimState.Conflict)
|
|
throw Error("idempotency_key_conflict", "同一幂等键已经用于不同业务内容,请生成新的幂等键。");
|
|
idempotencyClaimed = true;
|
|
}
|
|
if (descriptor.RequiresConfirmation)
|
|
{
|
|
string failureReason;
|
|
if (!_confirmation.Validate(plan, context, out failureReason))
|
|
throw Error("confirmation_required", failureReason);
|
|
}
|
|
|
|
CommandResult result = handler.Execute(plan, context);
|
|
if (result == null)
|
|
throw Error("invalid_result", "命令处理器没有返回执行结果。");
|
|
|
|
if (idempotencyClaimed)
|
|
_idempotency.Complete(
|
|
descriptor.Name,
|
|
context.IdempotencyKey,
|
|
context,
|
|
plan.InputFingerprint,
|
|
result);
|
|
|
|
_audit.Completed(descriptor, plan, result, context);
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (idempotencyClaimed)
|
|
{
|
|
try
|
|
{
|
|
_idempotency.Abandon(
|
|
descriptor.Name,
|
|
context.IdempotencyKey,
|
|
context,
|
|
plan.InputFingerprint);
|
|
}
|
|
catch { }
|
|
}
|
|
SafeAuditFailure(descriptor, plan, ex, context);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public void ValidateExecutionPreconditions(
|
|
CommandPlan plan,
|
|
CommandExecutionContext context)
|
|
{
|
|
ValidateContext(context);
|
|
ValidatePlanOwnership(plan, context);
|
|
ICommandHandler handler = Resolve(plan.CommandName);
|
|
CommandDescriptor descriptor = handler.Descriptor;
|
|
ValidatePlanDescriptor(plan, descriptor);
|
|
EnsureAuthorized(descriptor, context, true);
|
|
if (!plan.Valid)
|
|
throw Error("plan_invalid", "执行计划校验未通过,不能执行。");
|
|
if (_clock.UtcNow > plan.ExpiresAtUtc)
|
|
throw Error("plan_expired", "执行计划已过期,请重新生成预览。");
|
|
}
|
|
|
|
public CommandAuthorizationDecision CheckAuthorization(
|
|
CommandDescriptor descriptor,
|
|
CommandExecutionContext context,
|
|
bool execution)
|
|
{
|
|
ValidateContext(context);
|
|
if (descriptor == null)
|
|
throw Error("command_descriptor_required", "命令描述不能为空。");
|
|
CommandAuthorizationDecision decision = _authorizer.Authorize(descriptor, context, execution);
|
|
if (decision != null) return decision;
|
|
return CommandAuthorizationDecision.Deny(
|
|
"command_access_denied",
|
|
"当前 ERP 用户没有执行该命令的权限。");
|
|
}
|
|
|
|
private ICommandHandler Resolve(string commandName)
|
|
{
|
|
ICommandHandler handler = _registry.Resolve(commandName);
|
|
if (handler == null)
|
|
throw Error("command_not_found", "未注册命令:" + (commandName ?? string.Empty));
|
|
return handler;
|
|
}
|
|
|
|
private static void ValidateContext(CommandExecutionContext context)
|
|
{
|
|
if (context == null) throw new ArgumentNullException("context");
|
|
if (string.IsNullOrWhiteSpace(context.CorrelationId))
|
|
throw Error("correlation_id_required", "请求必须包含关联 ID。");
|
|
if (!ValidScopeValue(context.UserId)
|
|
|| !ValidScopeValue(context.UserName)
|
|
|| !ValidScopeValue(context.AccountBook)
|
|
|| !ValidScopeValue(context.SubSystemId)
|
|
|| !CommandInputFingerprint.IsValid(
|
|
context.DatabaseScopeFingerprint))
|
|
{
|
|
throw Error(
|
|
"erp_session_required",
|
|
"未检测到完整 ERP 登录作用域(用户编号、用户名、账套、子系统和数据库)。");
|
|
}
|
|
if (string.IsNullOrWhiteSpace(context.ClientSessionId))
|
|
throw Error("client_session_required", "未检测到桌宠客户端会话。");
|
|
}
|
|
|
|
private static bool ValidScopeValue(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value)
|
|
|| value.Length > 256
|
|
|| !string.Equals(value, value.Trim(), StringComparison.Ordinal))
|
|
return false;
|
|
foreach (char item in value)
|
|
{
|
|
if (char.IsControl(item)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private static void ValidatePlanOwnership(CommandPlan plan, CommandExecutionContext context)
|
|
{
|
|
if (plan == null) throw Error("plan_required", "执行计划不能为空。");
|
|
if (!string.Equals(
|
|
plan.CorrelationId ?? string.Empty,
|
|
context.CorrelationId ?? string.Empty,
|
|
StringComparison.Ordinal))
|
|
{
|
|
throw Error(
|
|
"plan_correlation_mismatch",
|
|
"计划关联 ID 与当前执行请求不一致,请重新生成预览。");
|
|
}
|
|
if (!Same(plan.OwnerClientSessionId, context.ClientSessionId)
|
|
|| !Same(plan.OwnerUserId, context.UserId)
|
|
|| !Same(plan.OwnerUserName, context.UserName)
|
|
|| !Same(plan.OwnerAccountBook, context.AccountBook)
|
|
|| !Same(plan.OwnerSubSystemId, context.SubSystemId)
|
|
|| !Same(
|
|
plan.OwnerDatabaseScopeFingerprint,
|
|
context.DatabaseScopeFingerprint))
|
|
{
|
|
throw Error("plan_owner_mismatch", "计划所属 ERP 会话与当前登录会话不一致。");
|
|
}
|
|
}
|
|
|
|
private static void ValidatePlanDescriptor(CommandPlan plan, CommandDescriptor descriptor)
|
|
{
|
|
if (!Same(plan.CommandName, descriptor.Name)
|
|
|| !Same(plan.CommandVersion, descriptor.Version)
|
|
|| plan.Risk != descriptor.Risk
|
|
|| !CommandInputFingerprint.IsValid(plan.InputFingerprint))
|
|
{
|
|
throw Error("plan_tampered", "执行计划与已注册命令不一致。");
|
|
}
|
|
}
|
|
|
|
private void EnsureAuthorized(
|
|
CommandDescriptor descriptor,
|
|
CommandExecutionContext context,
|
|
bool execution)
|
|
{
|
|
CommandAuthorizationDecision decision = CheckAuthorization(descriptor, context, execution);
|
|
if (decision == null || !decision.Allowed)
|
|
{
|
|
string code = decision == null || string.IsNullOrWhiteSpace(decision.Code)
|
|
? "command_access_denied"
|
|
: decision.Code;
|
|
string message = decision == null || string.IsNullOrWhiteSpace(decision.Message)
|
|
? "当前 ERP 用户没有执行该命令的权限。"
|
|
: decision.Message;
|
|
throw Error(code, message);
|
|
}
|
|
}
|
|
|
|
private void SafeAuditFailure(
|
|
CommandDescriptor descriptor,
|
|
CommandPlan plan,
|
|
Exception exception,
|
|
CommandExecutionContext context)
|
|
{
|
|
try
|
|
{
|
|
_audit.Failed(descriptor, plan, exception, context);
|
|
}
|
|
catch
|
|
{
|
|
// 保留原始业务异常;审计后端自身失败应由其监控通道报警。
|
|
}
|
|
}
|
|
|
|
private static bool Same(string left, string right)
|
|
{
|
|
return string.Equals(left ?? string.Empty, right ?? string.Empty, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static CommandKernelException Error(string code, string message)
|
|
{
|
|
return new CommandKernelException(code, message, 6);
|
|
}
|
|
}
|
|
}
|