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