Files
lserp_cs_6.0/插件库/Lskj.CommandKernel/DynamicModuleLookupResolution.cs
2026-08-14 14:28:28 +08:00

659 lines
26 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using Newtonsoft.Json.Linq;
namespace Lskj.CommandKernel
{
public sealed class DynamicModuleLookupCandidate
{
public string Value { get; set; }
public string Display { get; set; }
}
public sealed class DynamicModuleLookupContextValue
{
public string ParameterId { get; set; }
public string Scope { get; set; }
public int? RowNumber { get; set; }
public string Value { get; set; }
}
/// <summary>
/// 传给客户只读 Lookup 适配器的固定请求。只包含不透明参数 ID、配置
/// 指纹、当前行上下文和用户原文,不包含表、列、SQL 或过程名。
/// </summary>
public sealed class DynamicModuleLookupRequest
{
public DynamicModuleLookupRequest()
{
ContextValues = new List<DynamicModuleLookupContextValue>();
}
public string ModuleCode { get; set; }
public string ModuleKind { get; set; }
public string ContractFingerprint { get; set; }
public string ConfigurationFingerprint { get; set; }
public string FieldConfigurationFingerprint { get; set; }
public string ParameterId { get; set; }
public string Scope { get; set; }
public int? RowNumber { get; set; }
public string Reference { get; set; }
public IList<DynamicModuleLookupContextValue> ContextValues
{ get; private set; }
}
/// <summary>
/// 客户实现必须走固定、参数化、只读且经过验收的查询入口;禁止执行
/// FieldSpec 中的原始 Lookup SQL,也禁止让模型提供表、列或过程名。
/// </summary>
public interface IDynamicModuleLookupResolver
{
IList<DynamicModuleLookupCandidate> Resolve(
DynamicModuleLookupRequest request,
CommandExecutionContext context);
}
public interface IDynamicModuleLookupProofService
{
string Issue(
JObject resolvedInput,
ModuleInspection inspection,
CommandExecutionContext context,
TimeSpan lifetime);
bool Validate(
string proof,
JObject resolvedInput,
ModuleInspection inspection,
CommandExecutionContext context);
}
/// <summary>
/// 把服务端唯一解析后的完整输入绑定到当前用户、账套、子系统、模块和
/// 私有低代码配置。凭证不授予写权限,也不能替代原生确认或幂等键。
/// </summary>
public sealed class HmacDynamicModuleLookupProofService :
IDynamicModuleLookupProofService
{
private const string Version = "mlp1";
private readonly byte[] _secret;
private readonly ISystemClock _clock;
public HmacDynamicModuleLookupProofService(
byte[] secret,
ISystemClock clock)
{
if (secret == null || secret.Length < 32)
throw new ArgumentException(
"Lookup 解析凭证密钥至少需要 32 字节。",
"secret");
if (clock == null) throw new ArgumentNullException("clock");
_secret = (byte[])secret.Clone();
_clock = clock;
}
public static HmacDynamicModuleLookupProofService Create(
ISystemClock clock)
{
byte[] secret = new byte[32];
using (RandomNumberGenerator random = RandomNumberGenerator.Create())
random.GetBytes(secret);
return new HmacDynamicModuleLookupProofService(secret, clock);
}
public string Issue(
JObject resolvedInput,
ModuleInspection inspection,
CommandExecutionContext context,
TimeSpan lifetime)
{
ValidateArguments(resolvedInput, inspection, context);
if (lifetime <= TimeSpan.Zero || lifetime > TimeSpan.FromMinutes(10))
throw new ArgumentOutOfRangeException(
"lifetime",
"Lookup 解析凭证有效期必须在 10 分钟以内。");
long ticks = _clock.UtcNow.Add(lifetime).ToUniversalTime().Ticks;
byte[] nonceBytes = new byte[16];
using (RandomNumberGenerator random = RandomNumberGenerator.Create())
random.GetBytes(nonceBytes);
string nonce = Hex(nonceBytes);
string fingerprint = Fingerprint(
resolvedInput,
inspection,
context);
string payload = Payload(ticks, nonce, fingerprint);
return Version + "."
+ ticks.ToString(CultureInfo.InvariantCulture) + "."
+ nonce + "." + fingerprint + "." + Sign(payload);
}
public bool Validate(
string proof,
JObject resolvedInput,
ModuleInspection inspection,
CommandExecutionContext context)
{
try
{
ValidateArguments(resolvedInput, inspection, context);
string[] parts = (proof ?? string.Empty).Split('.');
long ticks;
if (parts.Length != 5 || parts[0] != Version
|| !long.TryParse(
parts[1],
NumberStyles.None,
CultureInfo.InvariantCulture,
out ticks)
|| parts[2].Length != 32
|| !parts[2].All(IsLowerHex)
|| !CommandInputFingerprint.IsValid(parts[3])
|| parts[4].Length < 40 || parts[4].Length > 64)
return false;
DateTime expiresAt = new DateTime(ticks, DateTimeKind.Utc);
DateTime now = _clock.UtcNow.ToUniversalTime();
if (expiresAt <= now || expiresAt > now.AddMinutes(10))
return false;
string fingerprint = Fingerprint(
resolvedInput,
inspection,
context);
if (!FixedEquals(parts[3], fingerprint)) return false;
return FixedEquals(
parts[4],
Sign(Payload(ticks, parts[2], fingerprint)));
}
catch
{
return false;
}
}
private static string Fingerprint(
JObject resolvedInput,
ModuleInspection inspection,
CommandExecutionContext context)
{
JObject exactInput = (JObject)resolvedInput.DeepClone();
exactInput.Remove("lookupResolutionProof");
return CommandInputFingerprint.Create(
"module.lookup-resolution-proof",
new Dictionary<string, object>
{
{ "moduleCode", inspection.ModuleCode },
{ "configurationFingerprint",
ModuleInspector.PrivateConfigurationFingerprint(
inspection) },
{ "accountBook", context.AccountBook.Trim() },
{ "subSystemId", context.SubSystemId.Trim() },
{ "userId", context.UserId.Trim() },
{ "userName", context.UserName.Trim() },
{ "databaseScopeFingerprint",
context.DatabaseScopeFingerprint.Trim() },
{ "resolvedInput", exactInput }
});
}
private static void ValidateArguments(
JObject resolvedInput,
ModuleInspection inspection,
CommandExecutionContext context)
{
if (resolvedInput == null)
throw new ArgumentNullException("resolvedInput");
if (inspection == null)
throw new ArgumentNullException("inspection");
if (context == null
|| string.IsNullOrWhiteSpace(context.AccountBook)
|| string.IsNullOrWhiteSpace(context.SubSystemId)
|| string.IsNullOrWhiteSpace(context.UserId)
|| string.IsNullOrWhiteSpace(context.UserName)
|| !CommandInputFingerprint.IsValid(
context.DatabaseScopeFingerprint))
throw new ArgumentException(
"Lookup 解析凭证需要完整 ERP 作用域。",
"context");
}
private string Sign(string payload)
{
using (HMACSHA256 hmac = new HMACSHA256(_secret))
{
string value = Convert.ToBase64String(
hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)));
return value.TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
}
private static string Payload(
long ticks,
string nonce,
string fingerprint)
{
return Version + "\u001f"
+ ticks.ToString(CultureInfo.InvariantCulture) + "\u001f"
+ nonce + "\u001f" + fingerprint;
}
private static string Hex(byte[] value)
{
StringBuilder result = new StringBuilder(value.Length * 2);
foreach (byte item in value)
result.Append(item.ToString("x2", CultureInfo.InvariantCulture));
return result.ToString();
}
private static bool IsLowerHex(char value)
{
return (value >= '0' && value <= '9')
|| (value >= 'a' && value <= 'f');
}
private static bool FixedEquals(string left, string right)
{
byte[] first = Encoding.ASCII.GetBytes(left ?? string.Empty);
byte[] second = Encoding.ASCII.GetBytes(right ?? string.Empty);
int difference = first.Length ^ second.Length;
int count = Math.Max(first.Length, second.Length);
for (int index = 0; index < count; index++)
{
byte a = index < first.Length ? first[index] : (byte)0;
byte b = index < second.Length ? second[index] : (byte)0;
difference |= a ^ b;
}
return difference == 0;
}
}
public static class DynamicModuleLookupResolution
{
private const int MaximumCandidates = 20;
private const int MaximumCandidateValueCharacters = 2048;
private const int MaximumCandidateDisplayCharacters = 512;
public static CommandPlan ResolveCreate(
ModuleInspection inspection,
string menuName,
CommandExecutionContext context,
IDictionary<string, object> input,
IDynamicModuleLookupResolver resolver,
IDynamicModuleLookupProofService proofs,
string resolvedCommand = "module.record.prepare-create")
{
if (inspection == null) throw new ArgumentNullException("inspection");
if (context == null) throw new ArgumentNullException("context");
if (resolver == null) throw new ArgumentNullException("resolver");
if (proofs == null) throw new ArgumentNullException("proofs");
if (resolvedCommand != "module.record.prepare-create"
&& resolvedCommand != "module.record.create")
throw new ArgumentException(
"动态 Lookup 后续命令必须是固定的预演或写计划命令。",
"resolvedCommand");
JObject source;
try
{
source = JObject.FromObject(
input ?? new Dictionary<string, object>());
}
catch
{
throw Error(
"module_parameter_input_invalid",
"模块 Lookup 解析输入不是有效 JSON 对象。",
2);
}
if (source.Property(
"lookupResolutionProof",
StringComparison.Ordinal) != null)
{
throw Error(
"module_lookup_resolution_proof_unexpected",
"Lookup 解析命令不能接受调用方提供的解析凭证。",
2);
}
CommandPlan initial = DynamicModuleOperationPlanner.PrepareCreate(
inspection,
menuName,
context,
input);
IList<object> initialIssues = (IList<object>)initial.Data["issues"];
if (initialIssues.Any(item => !string.Equals(
IssueCode(item),
"module_parameter_lookup_resolution_required",
StringComparison.Ordinal)))
{
initial.Data["title"] = "低代码模块 Lookup 解析前校验";
initial.Data["outcomeCode"] =
"module_lookup_resolution_input_invalid";
initial.Data["requiresFollowupPlan"] = false;
return initial;
}
IDictionary<string, object> contract =
inspection.ToParameterContract(menuName, context);
string contractFingerprint = Convert.ToString(
contract["contractFingerprint"]);
string configurationFingerprint = Convert.ToString(
contract["configurationFingerprint"]);
Dictionary<string, FieldSpec> master = LookupFields(
inspection.MasterFields,
"master");
Dictionary<string, FieldSpec> detail = LookupFields(
inspection.DetailFields,
"detail");
List<object> resolutionIssues = new List<object>();
List<object> resolvedLookups = new List<object>();
ResolveEntries(
source,
(JArray)source["masterValues"],
master,
"master",
null,
inspection,
context,
contractFingerprint,
configurationFingerprint,
resolver,
resolutionIssues,
resolvedLookups);
JArray detailRows = (JArray)source["detailRows"];
for (int index = 0; index < detailRows.Count; index++)
{
JObject row = (JObject)detailRows[index];
ResolveEntries(
source,
(JArray)row["values"],
detail,
"detail",
index + 1,
inspection,
context,
contractFingerprint,
configurationFingerprint,
resolver,
resolutionIssues,
resolvedLookups);
}
if (resolvedLookups.Count == 0 && resolutionIssues.Count == 0)
{
initial.Valid = false;
initial.Data["outcomeCode"] =
"module_lookup_resolution_not_required";
initial.Data["issues"] = new List<object>
{
new Dictionary<string, object>
{
{ "code", "module_lookup_resolution_not_required" }
}
};
initial.Data["requiresFollowupPlan"] = false;
return initial;
}
if (resolutionIssues.Count != 0)
{
initial.Valid = false;
initial.Data["outcomeCode"] =
"module_lookup_resolution_incomplete";
initial.Data["issues"] = resolutionIssues;
initial.Data["resolvedLookups"] = resolvedLookups;
initial.Data["requiresFollowupPlan"] = false;
return initial;
}
source["lookupResolutionProof"] = proofs.Issue(
source,
inspection,
context,
TimeSpan.FromMinutes(5));
IDictionary<string, object> resolvedInput = source.ToObject<
Dictionary<string, object>>();
CommandPlan resolved = DynamicModuleOperationPlanner.PrepareCreate(
inspection,
menuName,
context,
resolvedInput,
proofs);
if (!resolved.Valid)
{
throw Error(
"module_lookup_resolver_contract_invalid",
"Lookup 解析结果不符合当前模块参数合同。",
6);
}
resolved.Data["title"] = "低代码模块 Lookup 唯一解析结果";
resolved.Data["outcomeCode"] = "module_lookup_resolution_ready";
resolved.Data["resolvedLookups"] = resolvedLookups;
resolved.Data["requiresFollowupPlan"] = true;
resolved.Data["resolvedCommand"] =
resolvedCommand;
resolved.Data["fallbackResolvedCommand"] =
"module.record.prepare-create";
resolved.Data["resolvedInput"] = resolvedInput;
return resolved;
}
private static void ResolveEntries(
JObject fullInput,
JArray entries,
IDictionary<string, FieldSpec> lookupFields,
string scope,
int? rowNumber,
ModuleInspection inspection,
CommandExecutionContext context,
string contractFingerprint,
string configurationFingerprint,
IDynamicModuleLookupResolver resolver,
IList<object> issues,
IList<object> resolved)
{
foreach (JObject entry in entries.Cast<JObject>())
{
string parameterId = entry.Value<string>("parameterId");
string reference = entry.Value<string>("value") ?? string.Empty;
FieldSpec field;
if (reference.Length == 0
|| !lookupFields.TryGetValue(parameterId, out field))
continue;
if (!CommandInputFingerprint.IsValid(
field.PrivateConfigurationFingerprint))
{
throw Error(
"module_lookup_configuration_unbound",
"Lookup 字段缺少当前数据库配置指纹,不能进入只读解析适配器。",
6);
}
DynamicModuleLookupRequest request =
new DynamicModuleLookupRequest
{
ModuleCode = inspection.ModuleCode,
ModuleKind = inspection.Kind,
ContractFingerprint = contractFingerprint,
ConfigurationFingerprint = configurationFingerprint,
FieldConfigurationFingerprint =
field.PrivateConfigurationFingerprint,
ParameterId = parameterId,
Scope = scope,
RowNumber = rowNumber,
Reference = reference
};
foreach (DynamicModuleLookupContextValue value in
ContextValues(fullInput, rowNumber))
request.ContextValues.Add(value);
IList<DynamicModuleLookupCandidate> candidates;
try
{
candidates = ValidateCandidates(
resolver.Resolve(request, context));
}
catch (CommandKernelException) { throw; }
catch
{
throw Error(
"module_lookup_resolver_failed",
"客户 Lookup 只读适配器执行失败。",
6);
}
if (candidates.Count != 1)
{
issues.Add(new Dictionary<string, object>
{
{ "code", candidates.Count == 0
? "module_parameter_lookup_not_resolved"
: "module_parameter_lookup_ambiguous" },
{ "parameterId", parameterId },
{ "scope", scope },
{ "rowNumber", rowNumber },
{ "candidates", candidates.Select(
(item, index) => (object)new Dictionary<string, object>
{
{ "choiceNumber", index + 1 },
{ "display", item.Display }
}).ToList() }
});
continue;
}
DynamicModuleLookupCandidate candidate = candidates[0];
entry["value"] = candidate.Value;
resolved.Add(new Dictionary<string, object>
{
{ "parameterId", parameterId },
{ "scope", scope },
{ "rowNumber", rowNumber },
{ "display", candidate.Display }
});
}
}
private static IList<DynamicModuleLookupContextValue> ContextValues(
JObject source,
int? rowNumber)
{
List<DynamicModuleLookupContextValue> result =
new List<DynamicModuleLookupContextValue>();
AddContextValues(
result,
(JArray)source["masterValues"],
"master",
null);
if (rowNumber.HasValue)
{
JArray rows = (JArray)source["detailRows"];
JObject row = (JObject)rows[rowNumber.Value - 1];
AddContextValues(
result,
(JArray)row["values"],
"detail",
rowNumber);
}
return result;
}
private static void AddContextValues(
IList<DynamicModuleLookupContextValue> target,
JArray entries,
string scope,
int? rowNumber)
{
foreach (JObject entry in entries.Cast<JObject>())
{
target.Add(new DynamicModuleLookupContextValue
{
ParameterId = entry.Value<string>("parameterId"),
Scope = scope,
RowNumber = rowNumber,
Value = entry.Value<string>("value") ?? string.Empty
});
}
}
private static Dictionary<string, FieldSpec> LookupFields(
IEnumerable<FieldSpec> fields,
string scope)
{
Dictionary<string, FieldSpec> result =
new Dictionary<string, FieldSpec>(StringComparer.Ordinal);
foreach (FieldSpec field in fields ?? Enumerable.Empty<FieldSpec>())
{
if (field == null || !field.Exposed
|| !ModuleInspection.FieldRequiresLookup(field))
continue;
string parameterId = ModuleInspection.ParameterId(
scope,
field.Name);
if (result.ContainsKey(parameterId))
throw Error(
"module_parameter_contract_invalid",
"模块 Lookup 参数合同存在冲突。",
6);
result.Add(parameterId, field);
}
return result;
}
private static IList<DynamicModuleLookupCandidate> ValidateCandidates(
IList<DynamicModuleLookupCandidate> candidates)
{
if (candidates == null || candidates.Count > MaximumCandidates)
throw ResolverProtocol();
List<DynamicModuleLookupCandidate> result =
new List<DynamicModuleLookupCandidate>();
HashSet<string> values = new HashSet<string>(StringComparer.Ordinal);
foreach (DynamicModuleLookupCandidate candidate in candidates)
{
if (candidate == null
|| !Clean(candidate.Value, MaximumCandidateValueCharacters)
|| !Clean(candidate.Display, MaximumCandidateDisplayCharacters)
|| !values.Add(candidate.Value))
throw ResolverProtocol();
result.Add(new DynamicModuleLookupCandidate
{
Value = candidate.Value,
Display = candidate.Display
});
}
return result;
}
private static bool Clean(string value, int maximum)
{
return !string.IsNullOrWhiteSpace(value)
&& value.Length <= maximum
&& !value.Any(char.IsControl)
&& string.Equals(value, value.Trim(), StringComparison.Ordinal);
}
private static string IssueCode(object issue)
{
IDictionary<string, object> value =
issue as IDictionary<string, object>;
object code;
return value != null && value.TryGetValue("code", out code)
? Convert.ToString(code)
: string.Empty;
}
private static CommandKernelException ResolverProtocol()
{
return Error(
"module_lookup_resolver_contract_invalid",
"客户 Lookup 适配器返回了重复、越界或不完整的候选。",
6);
}
private static CommandKernelException Error(
string code,
string message,
int exitCode)
{
return new CommandKernelException(code, message, exitCode);
}
}
}