477 lines
17 KiB
C#
477 lines
17 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Lskj.CommandKernel;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Linq;
|
|
|
|
namespace Lskj.AgentBridge
|
|
{
|
|
public sealed class BridgeUatGrant
|
|
{
|
|
[JsonProperty("authorizationId")]
|
|
public string AuthorizationId { get; set; }
|
|
[JsonProperty("caseCode")]
|
|
public string CaseCode { get; set; }
|
|
[JsonProperty("token")]
|
|
public string Token { get; set; }
|
|
}
|
|
|
|
public sealed class BridgeRequest
|
|
{
|
|
public BridgeRequest()
|
|
{
|
|
ProtocolVersion = "1.0";
|
|
Payload = new JObject();
|
|
}
|
|
|
|
[JsonProperty("protocolVersion")]
|
|
public string ProtocolVersion { get; set; }
|
|
[JsonProperty("requestId")]
|
|
public string RequestId { get; set; }
|
|
[JsonProperty("correlationId")]
|
|
public string CorrelationId { get; set; }
|
|
[JsonProperty("clientSessionId")]
|
|
public string ClientSessionId { get; set; }
|
|
[JsonProperty("sessionScopeToken")]
|
|
public string SessionScopeToken { get; set; }
|
|
[JsonProperty("method")]
|
|
public string Method { get; set; }
|
|
[JsonProperty("payload")]
|
|
public JObject Payload { get; set; }
|
|
[JsonProperty("uatGrant")]
|
|
public BridgeUatGrant UatGrant { get; set; }
|
|
|
|
// Set by the named-pipe transport after GetNamedPipeClientProcessId.
|
|
// It is never accepted from JSON and never echoed in a response.
|
|
[JsonIgnore]
|
|
public int TransportClientProcessId { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 由当前 ERP 权威会话重算的 v3 作用域令牌。令牌不是认证秘密;它的
|
|
/// 作用是让服务端证明调用方明确绑定了数据库、用户、账套、子系统和
|
|
/// 管理员身份,而不是只依赖客户端在调用前后的自检。
|
|
/// </summary>
|
|
public static class BridgeSessionScopeToken
|
|
{
|
|
public const string Domain = "lserp-pet-session-scope-v3\n";
|
|
|
|
public static string Compute(CommandExecutionContext context)
|
|
{
|
|
if (context == null)
|
|
throw Unavailable();
|
|
string database = NormalizeDatabase(
|
|
context.DatabaseScopeFingerprint);
|
|
string userId = ScopeText(context.UserId);
|
|
string userName = ScopeText(context.UserName);
|
|
string accountBook = ScopeText(context.AccountBook);
|
|
string subSystemId = ScopeText(context.SubSystemId);
|
|
string administrator = AdministratorIdentity.IsBuiltIn(
|
|
userId,
|
|
userName) ? "true" : "false";
|
|
|
|
StringBuilder canonical = new StringBuilder(Domain);
|
|
Append(canonical, "databaseScopeFingerprint", database);
|
|
Append(canonical, "userId", userId);
|
|
Append(canonical, "userName", userName);
|
|
Append(canonical, "accountBook", accountBook);
|
|
Append(canonical, "subSystemId", subSystemId);
|
|
Append(canonical, "isAdministrator", administrator);
|
|
using (SHA256 algorithm = SHA256.Create())
|
|
{
|
|
byte[] digest = algorithm.ComputeHash(
|
|
Encoding.UTF8.GetBytes(canonical.ToString()));
|
|
StringBuilder token = new StringBuilder(32);
|
|
for (int index = 0; index < 16; index++)
|
|
token.Append(digest[index].ToString("x2", CultureInfo.InvariantCulture));
|
|
return token.ToString();
|
|
}
|
|
}
|
|
|
|
public static bool IsValid(string value)
|
|
{
|
|
if (string.IsNullOrEmpty(value) || value.Length != 32)
|
|
return false;
|
|
for (int index = 0; index < value.Length; index++)
|
|
{
|
|
char item = value[index];
|
|
if (!((item >= '0' && item <= '9')
|
|
|| (item >= 'a' && item <= 'f')))
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
public static bool Matches(string expected, CommandExecutionContext context)
|
|
{
|
|
if (!IsValid(expected)) return false;
|
|
string actual = Compute(context);
|
|
int difference = 0;
|
|
for (int index = 0; index < actual.Length; index++)
|
|
difference |= actual[index] ^ expected[index];
|
|
return difference == 0;
|
|
}
|
|
|
|
private static void Append(
|
|
StringBuilder target,
|
|
string name,
|
|
string value)
|
|
{
|
|
target.Append(name)
|
|
.Append('=')
|
|
.Append(Encoding.UTF8.GetByteCount(value).ToString(
|
|
CultureInfo.InvariantCulture))
|
|
.Append(':')
|
|
.Append(value)
|
|
.Append('\n');
|
|
}
|
|
|
|
private static string NormalizeDatabase(string value)
|
|
{
|
|
string normalized = (value ?? string.Empty).ToLowerInvariant();
|
|
if (normalized.Length != 64 || !IsLowerHex(normalized))
|
|
throw Unavailable();
|
|
return normalized;
|
|
}
|
|
|
|
private static bool IsLowerHex(string value)
|
|
{
|
|
for (int index = 0; index < value.Length; index++)
|
|
{
|
|
char item = value[index];
|
|
if (!((item >= '0' && item <= '9')
|
|
|| (item >= 'a' && item <= 'f')))
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private static string ScopeText(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value)
|
|
|| value.Length > 256
|
|
|| !string.Equals(value, value.Trim(), StringComparison.Ordinal))
|
|
throw Unavailable();
|
|
foreach (char item in value)
|
|
if (char.IsControl(item)) throw Unavailable();
|
|
return value;
|
|
}
|
|
|
|
private static CommandKernelException Unavailable()
|
|
{
|
|
return new CommandKernelException(
|
|
"erp_session_scope_unavailable",
|
|
"当前 ERP 会话缺少数据库、用户、账套、子系统或权限范围。",
|
|
8);
|
|
}
|
|
}
|
|
|
|
public sealed class BridgeResponse
|
|
{
|
|
public BridgeResponse()
|
|
{
|
|
ProtocolVersion = "1.0";
|
|
Data = new JObject();
|
|
}
|
|
|
|
[JsonProperty("protocolVersion")]
|
|
public string ProtocolVersion { get; set; }
|
|
[JsonProperty("requestId")]
|
|
public string RequestId { get; set; }
|
|
[JsonProperty("correlationId")]
|
|
public string CorrelationId { get; set; }
|
|
[JsonProperty("success")]
|
|
public bool Success { get; set; }
|
|
[JsonProperty("code")]
|
|
public string Code { get; set; }
|
|
[JsonProperty("message")]
|
|
public string Message { get; set; }
|
|
[JsonProperty("data")]
|
|
public JObject Data { get; set; }
|
|
|
|
public static BridgeResponse Ok(BridgeRequest request, object data)
|
|
{
|
|
return new BridgeResponse
|
|
{
|
|
RequestId = request == null ? null : SafeEcho(request.RequestId),
|
|
CorrelationId = request == null ? null : SafeEcho(request.CorrelationId),
|
|
Success = true,
|
|
Code = "ok",
|
|
Data = data == null ? new JObject() : JObject.FromObject(data)
|
|
};
|
|
}
|
|
|
|
public static BridgeResponse Error(
|
|
BridgeRequest request,
|
|
string code,
|
|
string message)
|
|
{
|
|
string stableCode = string.IsNullOrWhiteSpace(code)
|
|
? "bridge_error"
|
|
: code;
|
|
return new BridgeResponse
|
|
{
|
|
RequestId = request == null ? null : SafeEcho(request.RequestId),
|
|
CorrelationId = request == null ? null : SafeEcho(request.CorrelationId),
|
|
Success = false,
|
|
Code = stableCode,
|
|
Message = message,
|
|
Data = BridgeErrorRecoveryContract.Project(stableCode)
|
|
};
|
|
}
|
|
|
|
private static string SafeEcho(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value)
|
|
|| value.Length < 8
|
|
|| value.Length > 128)
|
|
return null;
|
|
foreach (char item in value)
|
|
{
|
|
if ((item >= 'a' && item <= 'z')
|
|
|| (item >= 'A' && item <= 'Z')
|
|
|| (item >= '0' && item <= '9')
|
|
|| item == '.'
|
|
|| item == '_'
|
|
|| item == ':'
|
|
|| item == '-')
|
|
continue;
|
|
return null;
|
|
}
|
|
return value;
|
|
}
|
|
}
|
|
|
|
public static class BridgeErrorRecoveryContract
|
|
{
|
|
public static JObject Project(string code)
|
|
{
|
|
BridgeErrorRecovery recovery = Resolve(code);
|
|
return new JObject
|
|
{
|
|
["recovery"] = new JObject
|
|
{
|
|
["action"] = recovery.Action,
|
|
["retryable"] = recovery.Retryable,
|
|
["planInvalidated"] = recovery.PlanInvalidated,
|
|
["message"] = recovery.Message
|
|
}
|
|
};
|
|
}
|
|
|
|
public static bool InvalidatesPlan(string code)
|
|
{
|
|
return Resolve(code).PlanInvalidated;
|
|
}
|
|
|
|
private static BridgeErrorRecovery Resolve(string code)
|
|
{
|
|
string value = (code ?? string.Empty).Trim().ToLowerInvariant();
|
|
if (value == "user_cancelled")
|
|
{
|
|
return Recovery(
|
|
"review_and_retry",
|
|
true,
|
|
false,
|
|
"本次没有写入;核对原预览后可再次确认执行。");
|
|
}
|
|
if (value == "bridge_timeout"
|
|
|| value == "workflow_database_error")
|
|
{
|
|
return Recovery(
|
|
"reconcile_execution",
|
|
true,
|
|
false,
|
|
"先检查 ERP 确认窗口和审计记录;无法确认结果时使用原预览重试,系统会复用幂等键。");
|
|
}
|
|
if (value == "bridge_unavailable"
|
|
|| value == "erp_bridge_not_running"
|
|
|| value == "erp_bridge_ambiguous"
|
|
|| value == "erp_bridge_target_not_running"
|
|
|| value == "bridge_session_scope_token_required"
|
|
|| value == "bridge_session_scope_token_invalid"
|
|
|| value == "erp_session_scope_unavailable"
|
|
|| value == "erp_session_scope_mismatch"
|
|
|| value == "erp_session_scope_changed")
|
|
{
|
|
return Recovery(
|
|
"restart_erp_pet",
|
|
false,
|
|
true,
|
|
"确认目标 ERP 登录范围后,从该 ERP 重新启动桌宠并重新生成预览。");
|
|
}
|
|
if (value == "duplicate_invoice")
|
|
{
|
|
return Recovery(
|
|
"inspect_existing_record",
|
|
false,
|
|
true,
|
|
"先查询该供应商的现有发票;确认不是重复单据后再重新生成预览。");
|
|
}
|
|
if (IsStalePlan(value))
|
|
{
|
|
return Recovery(
|
|
"replan",
|
|
false,
|
|
true,
|
|
"当前预览依据已失效;重新读取 ERP 实时数据、生成新预览并再次核对。");
|
|
}
|
|
if (IsInvalidRequest(value))
|
|
{
|
|
return Recovery(
|
|
"correct_request",
|
|
false,
|
|
true,
|
|
"补充或修正业务输入后重新生成预览,不要直接重复执行旧计划。");
|
|
}
|
|
if (IsCapacityOrTransientPolicy(value))
|
|
{
|
|
return Recovery(
|
|
"wait_and_retry",
|
|
true,
|
|
false,
|
|
"等待当前受控任务释放后,使用原预览和同一幂等键重试。");
|
|
}
|
|
if (IsAdministratorAction(value))
|
|
{
|
|
return Recovery(
|
|
"contact_administrator",
|
|
false,
|
|
true,
|
|
"请管理员检查当前登录权限、客户适配器激活状态和验收凭证后重新生成预览。");
|
|
}
|
|
return Recovery(
|
|
"contact_support",
|
|
false,
|
|
true,
|
|
"不要重复执行旧计划;请使用关联 ID 查询审计记录后再重新发起。");
|
|
}
|
|
|
|
private static bool IsStalePlan(string code)
|
|
{
|
|
return code == "plan_not_found"
|
|
|| code == "plan_expired"
|
|
|| code == "plan_not_executable"
|
|
|| code == "command_not_found"
|
|
|| code == "plan_owner_mismatch"
|
|
|| code == "plan_correlation_mismatch"
|
|
|| code == "untrusted_plan"
|
|
|| code.EndsWith("_changed", StringComparison.Ordinal)
|
|
|| code.EndsWith("_expired", StringComparison.Ordinal)
|
|
|| code.Contains("_proof_expired")
|
|
|| code.Contains("_proof_invalid");
|
|
}
|
|
|
|
private static bool IsInvalidRequest(string code)
|
|
{
|
|
return code == "invalid_input"
|
|
|| code == "invalid_request"
|
|
|| code == "input_schema_violation"
|
|
|| code == "module_not_found"
|
|
|| code.EndsWith("_input_invalid", StringComparison.Ordinal)
|
|
|| code.StartsWith("invalid_", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static bool IsCapacityOrTransientPolicy(string code)
|
|
{
|
|
return code.Contains("rate_limit")
|
|
|| code.Contains("capacity_exceeded")
|
|
|| code.Contains("circuit_open")
|
|
|| code.Contains("temporarily_unavailable");
|
|
}
|
|
|
|
private static bool IsAdministratorAction(string code)
|
|
{
|
|
return code.Contains("permission")
|
|
|| code.Contains("authorization")
|
|
|| code.Contains("acceptance")
|
|
|| code.Contains("adapter_")
|
|
|| code.Contains("_readiness")
|
|
|| code.Contains("_not_ready")
|
|
|| code.Contains("_policy_")
|
|
|| code.StartsWith("workflow_uat_", StringComparison.Ordinal)
|
|
|| code.StartsWith("erp_database_", StringComparison.Ordinal)
|
|
|| code == "execution_context_required";
|
|
}
|
|
|
|
private static BridgeErrorRecovery Recovery(
|
|
string action,
|
|
bool retryable,
|
|
bool planInvalidated,
|
|
string message)
|
|
{
|
|
return new BridgeErrorRecovery
|
|
{
|
|
Action = action,
|
|
Retryable = retryable,
|
|
PlanInvalidated = planInvalidated,
|
|
Message = message
|
|
};
|
|
}
|
|
|
|
private sealed class BridgeErrorRecovery
|
|
{
|
|
public string Action { get; set; }
|
|
public bool Retryable { get; set; }
|
|
public bool PlanInvalidated { get; set; }
|
|
public string Message { get; set; }
|
|
}
|
|
}
|
|
|
|
public interface IAgentBridgeRuntime
|
|
{
|
|
BridgeResponse Handle(BridgeRequest request);
|
|
}
|
|
|
|
public interface IBridgeOperationalAuditSink
|
|
{
|
|
void RecordOperationalEvent(
|
|
string eventName,
|
|
string method,
|
|
string commandName,
|
|
string stage,
|
|
string outcomeCode,
|
|
CommandExecutionContext context);
|
|
}
|
|
|
|
public sealed class NullBridgeOperationalAuditSink :
|
|
IBridgeOperationalAuditSink
|
|
{
|
|
public void RecordOperationalEvent(
|
|
string eventName,
|
|
string method,
|
|
string commandName,
|
|
string stage,
|
|
string outcomeCode,
|
|
CommandExecutionContext context)
|
|
{
|
|
}
|
|
}
|
|
|
|
public interface IBridgeExecutionContextFactory
|
|
{
|
|
CommandExecutionContext Create(BridgeRequest request);
|
|
}
|
|
|
|
public interface IBridgeContextProvider
|
|
{
|
|
IDictionary<string, object> Snapshot(CommandExecutionContext context);
|
|
}
|
|
|
|
public interface ICommandConfirmationPrompt
|
|
{
|
|
bool Confirm(CommandDescriptor descriptor, CommandPlan plan, CommandExecutionContext context);
|
|
}
|
|
|
|
public interface IServerPlanStore
|
|
{
|
|
void Save(CommandPlan plan);
|
|
bool TryGet(string planId, out CommandPlan plan);
|
|
void Remove(string planId);
|
|
void PurgeExpired(DateTime utcNow);
|
|
}
|
|
}
|