using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; using Lskj.CommandKernel; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace Lskj.AgentBridge { public sealed class BusinessAdapterConfiguration { private const int MaximumBytes = 64 * 1024; public string SchemaVersion { get; set; } public string SourceSha256 { get; private set; } public string CustomerProfilePath { get; set; } public PurchaseAdapterConfiguration Purchase { get; set; } public LeaveAdapterConfiguration Leave { get; set; } public static BusinessAdapterConfiguration Load(string path) { if (string.IsNullOrWhiteSpace(path)) throw Invalid("未配置业务适配器文件路径。"); try { string fullPath; byte[] sourceBytes = ReadRegularFile(path, out fullPath); string source = new UTF8Encoding(false, true).GetString(sourceBytes); if (!StrictRuntimeJsonSyntax.IsStandard(source)) throw Invalid("业务适配器配置必须使用无 BOM、无注释、无尾逗号的标准 JSON。"); JObject root = JObject.Parse( source, new JsonLoadSettings { DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error, CommentHandling = CommentHandling.Ignore, LineInfoHandling = LineInfoHandling.Ignore }); EnsureOnly(root, "schemaVersion", "customerProfilePath", "purchase", "leave"); EnsureObject(root, "purchase", "enabled", "acceptanceEvidencePath", "fields", "matchOptions"); EnsureObject(root, "leave", "enabled", "acceptanceEvidencePath", "fields", "validationOptions"); EnsureNestedOnly(root, "purchase", "fields", "moduleCode", "supplierCode", "invoiceNumber", "invoiceDate", "currencyCode", "materialCode", "unit", "quantity", "unitPrice", "taxRate", "exchangeRate", "lineAmount", "sourceOrderId", "sourceLineId"); EnsureNestedOnly(root, "purchase", "matchOptions", "quantityTolerance", "unitPriceAbsoluteTolerance", "unitPriceRelativeTolerance", "taxRateTolerance", "lineAmountTolerance", "headerAmountTolerance", "currencyScale", "lineAmountMode"); EnsureNestedOnly(root, "leave", "fields", "moduleCode", "employeeId", "leaveTypeCode", "flowTypeCode", "startLocal", "endLocal", "requestedHours", "reason"); EnsureNestedOnly(root, "leave", "validationOptions", "allowPastStart", "maximumCalendarDays", "minimumReasonLength", "hoursTolerance"); BusinessAdapterConfiguration result = root.ToObject(); if (result == null || result.SchemaVersion != "1.1") throw Invalid("业务适配器 schemaVersion 必须为 1.1。"); if (result.Purchase == null) result.Purchase = new PurchaseAdapterConfiguration(); if (result.Leave == null) result.Leave = new LeaveAdapterConfiguration(); result.SourceSha256 = Sha256(sourceBytes); string baseDirectory = Path.GetDirectoryName(fullPath); if (result.Purchase.Enabled || result.Leave.Enabled) result.CustomerProfilePath = NormalizeProfilePath( result.CustomerProfilePath, baseDirectory); result.Purchase.Normalize(baseDirectory); result.Leave.Normalize(baseDirectory); return result; } catch (CommandKernelException) { throw; } catch { throw Invalid("业务适配器配置不是有效的严格 UTF-8 JSON。"); } } private static byte[] ReadRegularFile(string path, out string fullPath) { fullPath = Path.GetFullPath(path); FileInfo file = new FileInfo(fullPath); if (!file.Exists) throw Invalid("业务适配器配置文件不存在。"); FileAttributes attributes = file.Attributes; if ((attributes & (FileAttributes.Directory | FileAttributes.Device | FileAttributes.ReparsePoint)) != 0) { throw Invalid("业务适配器配置必须是普通文件,不能是目录、设备或链接。"); } using (FileStream stream = new FileStream( fullPath, FileMode.Open, FileAccess.Read, FileShare.Read)) { if (stream.Length <= 0 || stream.Length > MaximumBytes) throw Invalid("业务适配器配置为空或超过大小上限。"); byte[] result = new byte[(int)stream.Length]; int offset = 0; while (offset < result.Length) { int count = stream.Read(result, offset, result.Length - offset); if (count <= 0) throw Invalid("业务适配器配置读取不完整。"); offset += count; } if (stream.ReadByte() != -1) throw Invalid("业务适配器配置读取期间发生变化。"); return result; } } private static void EnsureObject(JObject root, string name, params string[] allowed) { JToken token = root[name]; if (token == null) return; JObject value = token as JObject; if (value == null) throw Invalid(name + " 必须是 JSON 对象。"); EnsureOnly(value, allowed); } private static void EnsureNestedOnly( JObject root, string parent, string child, params string[] allowed) { JObject parentObject = root[parent] as JObject; if (parentObject == null || parentObject[child] == null) return; JObject value = parentObject[child] as JObject; if (value == null) throw Invalid(parent + "." + child + " 必须是 JSON 对象。"); EnsureOnly(value, allowed); } private static void EnsureOnly(JObject value, params string[] allowed) { HashSet names = new HashSet(allowed, StringComparer.Ordinal); JProperty unknown = value.Properties().FirstOrDefault(item => !names.Contains(item.Name)); if (unknown != null) throw Invalid("业务适配器配置包含未知字段:" + unknown.Name); } private static CommandKernelException Invalid(string message) { return new CommandKernelException("adapter_config_invalid", message, 6); } private static string NormalizeProfilePath( string value, string baseDirectory) { if (string.IsNullOrWhiteSpace(value)) throw Invalid("已启用业务适配器时必须配置 customerProfilePath。"); string candidate = value.Trim(); return Path.GetFullPath(Path.IsPathRooted(candidate) ? candidate : Path.Combine(baseDirectory ?? string.Empty, candidate)); } private static string Sha256(byte[] value) { using (SHA256 sha = SHA256.Create()) { byte[] hash = sha.ComputeHash(value); StringBuilder result = new StringBuilder(hash.Length * 2); foreach (byte item in hash) result.Append(item.ToString("x2")); return result.ToString(); } } } public sealed class PurchaseAdapterConfiguration { public PurchaseAdapterConfiguration() { MatchOptions = new PurchaseInvoiceMatchOptions(); } public bool Enabled { get; set; } public string AcceptanceEvidencePath { get; set; } public PurchaseWorkflowFieldMap Fields { get; set; } public PurchaseInvoiceMatchOptions MatchOptions { get; set; } internal void Normalize(string baseDirectory) { if (!Enabled) return; if (Fields == null || string.IsNullOrWhiteSpace(Fields.ModuleCode)) throw new CommandKernelException( "adapter_config_invalid", "已启用的采购适配器缺少 fields.moduleCode。", 6); AcceptanceEvidencePath = NormalizeEvidencePath( AcceptanceEvidencePath, baseDirectory, "采购"); if (MatchOptions == null) MatchOptions = new PurchaseInvoiceMatchOptions(); PurchaseInvoiceMatcher.Match( new PurchaseInvoiceDraft(), new List(), MatchOptions); if (MatchOptions.LineAmountMode == InvoiceLineAmountMode.None) throw new CommandKernelException( "adapter_config_invalid", "采购 matchOptions 超出允许范围,且 lineAmountMode 必须明确为不含税或含税。", 6); } private static string NormalizeEvidencePath( string value, string baseDirectory, string workflowName) { if (string.IsNullOrWhiteSpace(value)) throw new CommandKernelException( "adapter_config_invalid", "已启用的" + workflowName + "适配器缺少 acceptanceEvidencePath。", 6); string candidate = value.Trim(); return Path.GetFullPath(Path.IsPathRooted(candidate) ? candidate : Path.Combine(baseDirectory ?? string.Empty, candidate)); } } public sealed class LeaveAdapterConfiguration { public LeaveAdapterConfiguration() { ValidationOptions = new LeaveValidationOptions(); } public bool Enabled { get; set; } public string AcceptanceEvidencePath { get; set; } public LeaveWorkflowFieldMap Fields { get; set; } public LeaveValidationOptions ValidationOptions { get; set; } internal void Normalize(string baseDirectory) { if (!Enabled) return; if (Fields == null || string.IsNullOrWhiteSpace(Fields.ModuleCode)) throw new CommandKernelException( "adapter_config_invalid", "已启用的请假适配器缺少 fields.moduleCode。", 6); AcceptanceEvidencePath = NormalizeEvidencePath( AcceptanceEvidencePath, baseDirectory, "请假"); if (ValidationOptions == null) ValidationOptions = new LeaveValidationOptions(); if (ValidationOptions.MaximumCalendarDays <= 0 || ValidationOptions.MaximumCalendarDays > 31 || ValidationOptions.MinimumReasonLength < 0 || ValidationOptions.MinimumReasonLength > 500 || ValidationOptions.HoursTolerance < 0 || ValidationOptions.HoursTolerance > 1m) throw new CommandKernelException( "adapter_config_invalid", "请假 validationOptions 超出允许范围。", 6); } private static string NormalizeEvidencePath( string value, string baseDirectory, string workflowName) { if (string.IsNullOrWhiteSpace(value)) throw new CommandKernelException( "adapter_config_invalid", "已启用的" + workflowName + "适配器缺少 acceptanceEvidencePath。", 6); string candidate = value.Trim(); return Path.GetFullPath(Path.IsPathRooted(candidate) ? candidate : Path.Combine(baseDirectory ?? string.Empty, candidate)); } } }