1079 lines
46 KiB
C#
1079 lines
46 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Data;
|
|
using System.Data.Common;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using Lskj.CommandKernel;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Linq;
|
|
|
|
namespace Lskj.AgentBridge
|
|
{
|
|
public sealed class RuntimeCustomerProfileMetadataSnapshot
|
|
{
|
|
public string DatabaseName { get; set; }
|
|
public int SqlServerMajorVersion { get; set; }
|
|
public int CompatibilityLevel { get; set; }
|
|
public long UserTableCount { get; set; }
|
|
public long UserViewCount { get; set; }
|
|
public long UserProcedureCount { get; set; }
|
|
public long UserTriggerCount { get; set; }
|
|
public bool AgentWorkflowObjectsPresent { get; set; }
|
|
public ISet<string> CatalogEntries { get; set; }
|
|
}
|
|
|
|
public interface IRuntimeCustomerProfileMetadataProbe
|
|
{
|
|
RuntimeCustomerProfileMetadataSnapshot Capture();
|
|
}
|
|
|
|
public interface IRuntimeCustomerProfileVerifier
|
|
{
|
|
void Verify(
|
|
string expectedProfileSha256,
|
|
string workflow,
|
|
string moduleCode,
|
|
string fieldMappingSha256,
|
|
string readContractEvidenceSha256,
|
|
string writeIntegrationEvidenceSha256);
|
|
}
|
|
|
|
public sealed class RuntimeCustomerProfileWorkflowStatus
|
|
{
|
|
public RuntimeCustomerProfileWorkflowStatus()
|
|
{
|
|
OpenBlockerCodes = new List<string>();
|
|
}
|
|
|
|
public bool Approved { get; set; }
|
|
public IList<string> OpenBlockerCodes { get; private set; }
|
|
}
|
|
|
|
public sealed class RuntimeCustomerProfileActivationStatus
|
|
{
|
|
public RuntimeCustomerProfileWorkflowStatus Purchase { get; set; }
|
|
public RuntimeCustomerProfileWorkflowStatus Leave { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 使用 ERP 当前已打开的数据库连接,只读取 SQL Server 系统目录。查询文本固定,
|
|
/// 不读取业务行、不执行客户存储过程,也不把对象名投影给桥接调用方。
|
|
/// </summary>
|
|
public sealed class SqlRuntimeCustomerProfileMetadataProbe :
|
|
IRuntimeCustomerProfileMetadataProbe
|
|
{
|
|
private const int MaximumCatalogEntries = 100000;
|
|
private readonly Func<DbConnection> _connectionProvider;
|
|
private readonly int _commandTimeoutSeconds;
|
|
|
|
public const string MetadataQuery = @"
|
|
SELECT
|
|
CONVERT(nvarchar(128), DB_NAME()) AS database_name,
|
|
CONVERT(int, SERVERPROPERTY('ProductMajorVersion')) AS sql_server_major_version,
|
|
CONVERT(int, current_database.compatibility_level) AS compatibility_level,
|
|
CONVERT(bigint, (SELECT COUNT_BIG(1) FROM sys.tables WHERE is_ms_shipped = 0)) AS user_table_count,
|
|
CONVERT(bigint, (SELECT COUNT_BIG(1) FROM sys.views WHERE is_ms_shipped = 0)) AS user_view_count,
|
|
CONVERT(bigint, (SELECT COUNT_BIG(1) FROM sys.procedures WHERE is_ms_shipped = 0)) AS user_procedure_count,
|
|
CONVERT(bigint, (SELECT COUNT_BIG(1) FROM sys.triggers WHERE is_ms_shipped = 0)) AS user_trigger_count,
|
|
CONVERT(bit, CASE WHEN
|
|
(
|
|
SELECT COUNT_BIG(1)
|
|
FROM sys.objects
|
|
WHERE schema_id = SCHEMA_ID(N'dbo')
|
|
AND
|
|
(
|
|
(type = 'U' AND name IN
|
|
(
|
|
N'p_agent_business_audit',
|
|
N'p_agent_business_source_document',
|
|
N'p_agent_command_idempotency',
|
|
N'p_agent_integration_outbox',
|
|
N'p_agent_purchase_currency_crosswalk',
|
|
N'p_agent_purchase_row_scope',
|
|
N'p_agent_workflow_adapter_evidence',
|
|
N'p_agent_workflow_adapter_evidence_v2'
|
|
))
|
|
OR
|
|
(type = 'P' AND name IN
|
|
(
|
|
N'p_lserp_agent_workflow_read',
|
|
N'p_lserp_agent_workflow_read_compat100',
|
|
N'p_lserp_agent_workflow_readiness',
|
|
N'p_lserp_agent_workflow_readiness_v2',
|
|
N'p_lserp_agent_workflow_readiness_v3',
|
|
N'p_lserp_agent_workflow_write',
|
|
N'p_lserp_agent_workflow_write_leave_compat100',
|
|
N'p_lserp_agent_workflow_write_purchase_compat100'
|
|
))
|
|
)
|
|
) = 16 THEN 1 ELSE 0 END) AS agent_workflow_objects_present
|
|
FROM sys.databases AS current_database
|
|
WHERE current_database.database_id = DB_ID();
|
|
|
|
SELECT TOP (100001)
|
|
catalog_entry.entry_kind,
|
|
catalog_entry.schema_name,
|
|
catalog_entry.object_name,
|
|
catalog_entry.object_kind,
|
|
catalog_entry.member_name
|
|
FROM
|
|
(
|
|
SELECT
|
|
CONVERT(varchar(16), 'object') AS entry_kind,
|
|
CONVERT(nvarchar(128), SCHEMA_NAME(catalog_object.schema_id)) AS schema_name,
|
|
CONVERT(nvarchar(128), catalog_object.name) AS object_name,
|
|
CONVERT(varchar(16), CASE catalog_object.type
|
|
WHEN 'U' THEN 'table'
|
|
WHEN 'V' THEN 'view'
|
|
WHEN 'P' THEN 'procedure'
|
|
ELSE 'invalid' END) AS object_kind,
|
|
CONVERT(nvarchar(128), N'') AS member_name
|
|
FROM sys.objects AS catalog_object
|
|
WHERE catalog_object.is_ms_shipped = 0
|
|
AND catalog_object.type IN ('U', 'V', 'P')
|
|
|
|
UNION ALL
|
|
|
|
SELECT
|
|
CONVERT(varchar(16), 'column'),
|
|
CONVERT(nvarchar(128), SCHEMA_NAME(catalog_object.schema_id)),
|
|
CONVERT(nvarchar(128), catalog_object.name),
|
|
CONVERT(varchar(16), CASE catalog_object.type
|
|
WHEN 'U' THEN 'table'
|
|
WHEN 'V' THEN 'view'
|
|
ELSE 'invalid' END),
|
|
CONVERT(nvarchar(128), catalog_column.name)
|
|
FROM sys.objects AS catalog_object
|
|
INNER JOIN sys.columns AS catalog_column
|
|
ON catalog_column.object_id = catalog_object.object_id
|
|
WHERE catalog_object.is_ms_shipped = 0
|
|
AND catalog_object.type IN ('U', 'V')
|
|
|
|
UNION ALL
|
|
|
|
SELECT
|
|
CONVERT(varchar(16), 'parameter'),
|
|
CONVERT(nvarchar(128), SCHEMA_NAME(catalog_procedure.schema_id)),
|
|
CONVERT(nvarchar(128), catalog_procedure.name),
|
|
CONVERT(varchar(16), 'procedure'),
|
|
CONVERT(nvarchar(128), catalog_parameter.name)
|
|
FROM sys.procedures AS catalog_procedure
|
|
INNER JOIN sys.parameters AS catalog_parameter
|
|
ON catalog_parameter.object_id = catalog_procedure.object_id
|
|
AND catalog_parameter.parameter_id > 0
|
|
WHERE catalog_procedure.is_ms_shipped = 0
|
|
) AS catalog_entry
|
|
ORDER BY
|
|
catalog_entry.object_kind,
|
|
catalog_entry.schema_name,
|
|
catalog_entry.object_name,
|
|
catalog_entry.entry_kind,
|
|
catalog_entry.member_name;";
|
|
|
|
public SqlRuntimeCustomerProfileMetadataProbe(
|
|
Func<DbConnection> connectionProvider,
|
|
int commandTimeoutSeconds)
|
|
{
|
|
if (connectionProvider == null)
|
|
throw new ArgumentNullException("connectionProvider");
|
|
_connectionProvider = connectionProvider;
|
|
_commandTimeoutSeconds = Math.Max(
|
|
5,
|
|
Math.Min(commandTimeoutSeconds <= 0 ? 30 : commandTimeoutSeconds, 60));
|
|
}
|
|
|
|
public RuntimeCustomerProfileMetadataSnapshot Capture()
|
|
{
|
|
DbConnection connection;
|
|
try { connection = _connectionProvider(); }
|
|
catch { throw Unavailable(); }
|
|
if (connection == null || connection.State != ConnectionState.Open)
|
|
throw Unavailable();
|
|
try
|
|
{
|
|
using (DbCommand command = connection.CreateCommand())
|
|
{
|
|
command.CommandType = CommandType.Text;
|
|
command.CommandText = MetadataQuery;
|
|
command.CommandTimeout = _commandTimeoutSeconds;
|
|
using (DbDataReader reader = command.ExecuteReader(
|
|
CommandBehavior.SequentialAccess))
|
|
{
|
|
return ReadSnapshot(reader);
|
|
}
|
|
}
|
|
}
|
|
catch (CommandKernelException) { throw; }
|
|
catch { throw Unavailable(); }
|
|
}
|
|
|
|
internal static RuntimeCustomerProfileMetadataSnapshot ReadSnapshot(
|
|
DbDataReader reader)
|
|
{
|
|
try
|
|
{
|
|
if (reader == null || !reader.Read()) throw Contract();
|
|
RuntimeCustomerProfileMetadataSnapshot snapshot =
|
|
new RuntimeCustomerProfileMetadataSnapshot
|
|
{
|
|
DatabaseName = RequiredString(reader, "database_name", 128),
|
|
SqlServerMajorVersion = RequiredInt(
|
|
reader, "sql_server_major_version", 9, 99),
|
|
CompatibilityLevel = RequiredInt(
|
|
reader, "compatibility_level", 80, 200),
|
|
UserTableCount = RequiredLong(
|
|
reader, "user_table_count", 0, 10000000),
|
|
UserViewCount = RequiredLong(
|
|
reader, "user_view_count", 0, 10000000),
|
|
UserProcedureCount = RequiredLong(
|
|
reader, "user_procedure_count", 0, 10000000),
|
|
UserTriggerCount = RequiredLong(
|
|
reader, "user_trigger_count", 0, 10000000),
|
|
AgentWorkflowObjectsPresent = RequiredBoolean(
|
|
reader, "agent_workflow_objects_present"),
|
|
CatalogEntries = new HashSet<string>(
|
|
StringComparer.OrdinalIgnoreCase)
|
|
};
|
|
if (reader.Read() || !reader.NextResult()) throw Contract();
|
|
int count = 0;
|
|
while (reader.Read())
|
|
{
|
|
count += 1;
|
|
if (count > MaximumCatalogEntries) throw Contract();
|
|
string entryKind = RequiredChoice(
|
|
reader, "entry_kind", "object", "column", "parameter");
|
|
string objectKind = RequiredChoice(
|
|
reader, "object_kind", "table", "view", "procedure");
|
|
string schemaName = RequiredString(reader, "schema_name", 128);
|
|
string objectName = RequiredString(reader, "object_name", 128);
|
|
string memberName = OptionalString(
|
|
reader, "member_name", entryKind == "object" ? 0 : 128);
|
|
if ((entryKind == "object" && memberName.Length != 0)
|
|
|| (entryKind == "column" && objectKind == "procedure")
|
|
|| (entryKind == "parameter" && objectKind != "procedure"))
|
|
throw Contract();
|
|
if (!snapshot.CatalogEntries.Add(CatalogEntryKey(
|
|
entryKind, schemaName, objectName, objectKind, memberName)))
|
|
throw Contract();
|
|
}
|
|
if (reader.NextResult()) throw Contract();
|
|
return snapshot;
|
|
}
|
|
catch (CommandKernelException) { throw; }
|
|
catch { throw Contract(); }
|
|
}
|
|
|
|
internal static string CatalogEntryKey(
|
|
string entryKind,
|
|
string schemaName,
|
|
string objectName,
|
|
string objectKind,
|
|
string memberName)
|
|
{
|
|
return (entryKind ?? string.Empty) + "\u001f"
|
|
+ (schemaName ?? string.Empty) + "\u001f"
|
|
+ (objectName ?? string.Empty) + "\u001f"
|
|
+ (objectKind ?? string.Empty) + "\u001f"
|
|
+ (memberName ?? string.Empty);
|
|
}
|
|
|
|
private static string RequiredChoice(
|
|
DbDataReader reader,
|
|
string name,
|
|
params string[] allowed)
|
|
{
|
|
string value = RequiredString(reader, name, 16);
|
|
if (!allowed.Contains(value, StringComparer.Ordinal)) throw Contract();
|
|
return value;
|
|
}
|
|
|
|
private static string RequiredString(
|
|
DbDataReader reader,
|
|
string name,
|
|
int maximumLength)
|
|
{
|
|
object raw = reader[name];
|
|
string value = raw == null || raw == DBNull.Value
|
|
? string.Empty : Convert.ToString(raw).Trim();
|
|
if (value.Length == 0 || value.Length > maximumLength
|
|
|| value.Any(char.IsControl)) throw Contract();
|
|
return value;
|
|
}
|
|
|
|
private static string OptionalString(
|
|
DbDataReader reader,
|
|
string name,
|
|
int maximumLength)
|
|
{
|
|
object raw = reader[name];
|
|
string value = raw == null || raw == DBNull.Value
|
|
? string.Empty : Convert.ToString(raw);
|
|
if (value.Length > maximumLength || value.Any(char.IsControl))
|
|
throw Contract();
|
|
return value;
|
|
}
|
|
|
|
private static int RequiredInt(
|
|
DbDataReader reader,
|
|
string name,
|
|
int minimum,
|
|
int maximum)
|
|
{
|
|
return checked((int)RequiredLong(reader, name, minimum, maximum));
|
|
}
|
|
|
|
private static long RequiredLong(
|
|
DbDataReader reader,
|
|
string name,
|
|
long minimum,
|
|
long maximum)
|
|
{
|
|
long value;
|
|
try { value = Convert.ToInt64(reader[name]); }
|
|
catch { throw Contract(); }
|
|
if (value < minimum || value > maximum) throw Contract();
|
|
return value;
|
|
}
|
|
|
|
private static bool RequiredBoolean(DbDataReader reader, string name)
|
|
{
|
|
object raw = reader[name];
|
|
if (raw is bool) return (bool)raw;
|
|
if (raw is byte && ((byte)raw == 0 || (byte)raw == 1))
|
|
return (byte)raw == 1;
|
|
if (raw is int && ((int)raw == 0 || (int)raw == 1))
|
|
return (int)raw == 1;
|
|
throw Contract();
|
|
}
|
|
|
|
private static CommandKernelException Contract()
|
|
{
|
|
return Error(
|
|
"profile_runtime_metadata_contract_invalid",
|
|
"客户画像系统目录结果不符合固定协议,写命令保持禁用。");
|
|
}
|
|
|
|
private static CommandKernelException Unavailable()
|
|
{
|
|
return Error(
|
|
"profile_runtime_metadata_unavailable",
|
|
"无法用 ERP 当前连接完成只读系统目录复核,写命令保持禁用。");
|
|
}
|
|
|
|
private static CommandKernelException Error(string code, string message)
|
|
{
|
|
return new CommandKernelException(code, message, 6);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 将签名验收清单绑定的客户画像与当前 SQL Server 系统目录做实时比较。
|
|
/// 成功不代表可单独激活;它只是在现有 V2 就绪行和签名门禁之外再增加一层。
|
|
/// </summary>
|
|
public sealed class FileRuntimeCustomerProfileVerifier :
|
|
IRuntimeCustomerProfileVerifier
|
|
{
|
|
private const int MaximumProfileBytes = 1024 * 1024;
|
|
private static readonly Regex Sha256Pattern = new Regex(
|
|
"^[a-f0-9]{64}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeDatabaseName = new Regex(
|
|
"^[A-Za-z0-9_.-]{1,128}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeModuleCode = new Regex(
|
|
"^[A-Za-z0-9_.:-]{1,64}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeCatalogName = new Regex(
|
|
"^[A-Za-z_][A-Za-z0-9_]{0,127}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeParameterName = new Regex(
|
|
"^@[A-Za-z_][A-Za-z0-9_]{0,127}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeBlockerCode = new Regex(
|
|
"^[a-z0-9_.:-]{3,128}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly IDictionary<string, string>
|
|
PurchaseBlockerEvidence = new Dictionary<string, string>(
|
|
StringComparer.Ordinal)
|
|
{
|
|
{ "purchase_currency_field_not_configured", "field_mapping" },
|
|
{ "purchase_currency_crosswalk_not_approved", "write_integration" },
|
|
{ "purchase_row_scope_not_approved", "write_integration" },
|
|
{ "purchase_compat100_write_contract_not_approved", "write_integration" },
|
|
{ "purchase_windows_integration_not_verified", "write_integration" }
|
|
};
|
|
private static readonly IDictionary<string, string>
|
|
LeaveBlockerEvidence = new Dictionary<string, string>(
|
|
StringComparer.Ordinal)
|
|
{
|
|
{ "leave_flow_type_rules_stale", "write_integration" },
|
|
{ "leave_agent_schema_not_deployed", "write_integration" },
|
|
{ "leave_compat100_write_contract_not_approved", "write_integration" },
|
|
{ "leave_windows_integration_not_verified", "write_integration" }
|
|
};
|
|
|
|
private readonly string _path;
|
|
private readonly IRuntimeCustomerProfileMetadataProbe _probe;
|
|
|
|
public FileRuntimeCustomerProfileVerifier(
|
|
string path,
|
|
IRuntimeCustomerProfileMetadataProbe probe)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
throw new ArgumentException("客户画像路径不能为空。", "path");
|
|
if (probe == null) throw new ArgumentNullException("probe");
|
|
_path = Path.GetFullPath(path);
|
|
_probe = probe;
|
|
}
|
|
|
|
public static RuntimeCustomerProfileActivationStatus InspectActivation(
|
|
string path)
|
|
{
|
|
ProfileBaseline baseline = ParseBaseline(Load(path).Profile);
|
|
return new RuntimeCustomerProfileActivationStatus
|
|
{
|
|
Purchase = WorkflowStatus(
|
|
baseline.PurchaseActivationAllowed
|
|
&& baseline.PurchaseOpenBlockerCount == 0,
|
|
baseline.PurchaseOpenBlockerCodes),
|
|
Leave = WorkflowStatus(
|
|
baseline.LeaveOpenBlockerCount == 0,
|
|
baseline.LeaveOpenBlockerCodes)
|
|
};
|
|
}
|
|
|
|
public void Verify(
|
|
string expectedProfileSha256,
|
|
string workflow,
|
|
string moduleCode,
|
|
string fieldMappingSha256,
|
|
string readContractEvidenceSha256,
|
|
string writeIntegrationEvidenceSha256)
|
|
{
|
|
if (!Sha256Pattern.IsMatch(expectedProfileSha256 ?? string.Empty))
|
|
throw Error(
|
|
"profile_runtime_hash_mismatch",
|
|
"签名清单没有绑定有效的客户画像哈希,写命令保持禁用。");
|
|
if (workflow != "purchase" && workflow != "leave")
|
|
throw Contract();
|
|
if (!SafeModuleCode.IsMatch(moduleCode ?? string.Empty))
|
|
throw Error(
|
|
"profile_workflow_module_mismatch",
|
|
"签名清单没有绑定有效的工作流模块,写命令保持禁用。");
|
|
if (!Sha256Pattern.IsMatch(fieldMappingSha256 ?? string.Empty)
|
|
|| !Sha256Pattern.IsMatch(readContractEvidenceSha256 ?? string.Empty)
|
|
|| !Sha256Pattern.IsMatch(writeIntegrationEvidenceSha256 ?? string.Empty))
|
|
throw Error(
|
|
"profile_workflow_resolution_mismatch",
|
|
"签名清单没有提供有效的逐项阻断解决证据哈希,写命令保持禁用。");
|
|
ProfileDocument document = Load(_path);
|
|
if (!string.Equals(
|
|
document.Sha256,
|
|
expectedProfileSha256,
|
|
StringComparison.Ordinal))
|
|
throw Error(
|
|
"profile_runtime_hash_mismatch",
|
|
"当前客户画像与签名验收清单不一致,写命令保持禁用。");
|
|
ProfileBaseline baseline = ParseBaseline(document.Profile);
|
|
if ((workflow == "purchase"
|
|
&& (!baseline.PurchaseActivationAllowed
|
|
|| baseline.PurchaseOpenBlockerCount != 0))
|
|
|| (workflow == "leave"
|
|
&& baseline.LeaveOpenBlockerCount != 0))
|
|
throw Error(
|
|
"profile_workflow_activation_blocked",
|
|
"客户画像尚未批准当前工作流激活,写命令保持禁用。");
|
|
string expectedProfileModule = workflow == "purchase"
|
|
? baseline.PurchaseModuleCode
|
|
: baseline.LeaveModuleCode;
|
|
if (!string.Equals(
|
|
expectedProfileModule,
|
|
moduleCode,
|
|
StringComparison.Ordinal))
|
|
throw Error(
|
|
"profile_workflow_module_mismatch",
|
|
"客户画像选定模块与签名验收清单不一致,写命令保持禁用。");
|
|
VerifyResolutionBindings(
|
|
workflow == "purchase"
|
|
? baseline.PurchaseResolutions
|
|
: baseline.LeaveResolutions,
|
|
fieldMappingSha256,
|
|
readContractEvidenceSha256,
|
|
writeIntegrationEvidenceSha256);
|
|
RuntimeCustomerProfileMetadataSnapshot snapshot = _probe.Capture();
|
|
if (!CriticalCatalogMatches(
|
|
baseline.CriticalCatalogRequirements,
|
|
snapshot == null ? null : snapshot.CatalogEntries))
|
|
throw Error(
|
|
"profile_critical_catalog_contract_changed",
|
|
"当前数据库关键字段或过程参数已漂移,写命令保持禁用。");
|
|
if (snapshot == null
|
|
|| !string.Equals(
|
|
baseline.DatabaseName,
|
|
snapshot.DatabaseName,
|
|
StringComparison.OrdinalIgnoreCase)
|
|
|| baseline.SqlServerMajorVersion != snapshot.SqlServerMajorVersion
|
|
|| baseline.CompatibilityLevel != snapshot.CompatibilityLevel)
|
|
throw Error(
|
|
"profile_runtime_metadata_changed",
|
|
"当前数据库身份或兼容级别与签名客户画像不一致,写命令保持禁用。");
|
|
}
|
|
|
|
private static ProfileDocument Load(string path)
|
|
{
|
|
try
|
|
{
|
|
FileInfo file = new FileInfo(path);
|
|
if (!file.Exists || file.Length <= 0
|
|
|| file.Length > MaximumProfileBytes
|
|
|| (file.Attributes & FileAttributes.ReparsePoint) != 0)
|
|
throw Contract();
|
|
byte[] bytes;
|
|
using (FileStream stream = new FileStream(
|
|
file.FullName,
|
|
FileMode.Open,
|
|
FileAccess.Read,
|
|
FileShare.Read))
|
|
{
|
|
if (stream.Length <= 0 || stream.Length > MaximumProfileBytes)
|
|
throw Contract();
|
|
bytes = new byte[checked((int)stream.Length)];
|
|
int offset = 0;
|
|
while (offset < bytes.Length)
|
|
{
|
|
int read = stream.Read(bytes, offset, bytes.Length - offset);
|
|
if (read <= 0) throw Contract();
|
|
offset += read;
|
|
}
|
|
if (stream.ReadByte() != -1) throw Contract();
|
|
}
|
|
string json = new UTF8Encoding(false, true).GetString(bytes);
|
|
JObject profile;
|
|
using (StringReader text = new StringReader(json))
|
|
using (RejectCommentsJsonReader reader =
|
|
new RejectCommentsJsonReader(text))
|
|
{
|
|
reader.DateParseHandling = DateParseHandling.None;
|
|
reader.FloatParseHandling = FloatParseHandling.Decimal;
|
|
reader.MaxDepth = 64;
|
|
reader.SupportMultipleContent = false;
|
|
profile = JObject.Load(reader, new JsonLoadSettings
|
|
{
|
|
DuplicatePropertyNameHandling =
|
|
DuplicatePropertyNameHandling.Error,
|
|
CommentHandling = CommentHandling.Ignore,
|
|
LineInfoHandling = LineInfoHandling.Ignore
|
|
});
|
|
if (reader.Read()) throw Contract();
|
|
}
|
|
return new ProfileDocument
|
|
{
|
|
Profile = profile,
|
|
Sha256 = Sha256(bytes)
|
|
};
|
|
}
|
|
catch (CommandKernelException) { throw; }
|
|
catch { throw Contract(); }
|
|
}
|
|
|
|
private static ProfileBaseline ParseBaseline(JObject profile)
|
|
{
|
|
EnsureExact(profile,
|
|
"schemaVersion", "profileType", "database", "safety",
|
|
"purchaseTargetSelection", "purchaseActivationBlockers",
|
|
"leaveActivationBlockers", "menus", "modules");
|
|
if (RequiredString(profile, "schemaVersion", 16) != "1.2"
|
|
|| RequiredString(profile, "profileType", 64)
|
|
!= "readonly_low_code_metadata_review") throw Contract();
|
|
|
|
JObject safety = RequiredObject(profile, "safety");
|
|
EnsureExact(safety,
|
|
"source", "businessRowsRead", "storedProceduresExecuted",
|
|
"writesPerformed", "runtimeEnabled", "requiresCustomerReview");
|
|
if (RequiredString(safety, "source", 128)
|
|
!= "system_catalog_and_low_code_configuration_only"
|
|
|| RequiredBoolean(safety, "businessRowsRead")
|
|
|| RequiredBoolean(safety, "storedProceduresExecuted")
|
|
|| RequiredBoolean(safety, "writesPerformed")
|
|
|| RequiredBoolean(safety, "runtimeEnabled")
|
|
|| !RequiredBoolean(safety, "requiresCustomerReview"))
|
|
throw Contract();
|
|
|
|
JObject selection = RequiredObject(
|
|
profile, "purchaseTargetSelection");
|
|
EnsureExact(selection,
|
|
"selectedModuleCode", "selectedRole", "selectionState",
|
|
"activationAllowed", "onlineRevalidationRequiredBeforeActivation",
|
|
"candidatesEvaluated");
|
|
bool purchaseActivationAllowed = RequiredBoolean(
|
|
selection, "activationAllowed");
|
|
string purchaseModuleCode = RequiredString(
|
|
selection, "selectedModuleCode", 64);
|
|
if (!SafeModuleCode.IsMatch(purchaseModuleCode)
|
|
|| RequiredString(selection, "selectedRole", 128)
|
|
!= "purchase_invoice_draft_write_candidate")
|
|
throw Contract();
|
|
string selectionState = RequiredString(
|
|
selection, "selectionState", 128);
|
|
if (!RequiredBoolean(
|
|
selection, "onlineRevalidationRequiredBeforeActivation")
|
|
|| RequiredArray(selection, "candidatesEvaluated", 1, 32) == null)
|
|
throw Contract();
|
|
BlockerSet purchaseBlockers = ParseActivationBlockers(
|
|
RequiredArray(profile, "purchaseActivationBlockers", 1, 64),
|
|
PurchaseBlockerEvidence);
|
|
BlockerSet leaveBlockers = ParseActivationBlockers(
|
|
RequiredArray(profile, "leaveActivationBlockers", 1, 64),
|
|
LeaveBlockerEvidence);
|
|
int purchaseOpenBlockerCount = purchaseBlockers.OpenCount;
|
|
int leaveOpenBlockerCount = leaveBlockers.OpenCount;
|
|
bool purchaseShouldBeAllowed = purchaseOpenBlockerCount == 0;
|
|
if (purchaseActivationAllowed != purchaseShouldBeAllowed
|
|
|| selectionState != (purchaseShouldBeAllowed
|
|
? "selected_and_activation_approved"
|
|
: "selected_but_activation_blocked"))
|
|
throw Contract();
|
|
RequiredArray(profile, "menus", 1, 64);
|
|
JObject modules = RequiredObject(profile, "modules");
|
|
if (modules.Properties().Count() == 0
|
|
|| modules.Properties().Count() > 64) throw Contract();
|
|
JObject leaveModule = modules["leave"] as JObject;
|
|
string leaveModuleCode = leaveModule == null
|
|
? string.Empty
|
|
: RequiredString(leaveModule, "moduleCode", 64);
|
|
if (!SafeModuleCode.IsMatch(leaveModuleCode)) throw Contract();
|
|
|
|
JObject database = RequiredObject(profile, "database");
|
|
EnsureExact(database,
|
|
"name", "sqlServerMajorVersion", "compatibilityLevel",
|
|
"compatibilityContract", "userTableCount", "userViewCount",
|
|
"userProcedureCount", "userTriggerCount",
|
|
"agentWorkflowObjectsPresent", "criticalCatalogContract");
|
|
string databaseName = RequiredString(database, "name", 128);
|
|
if (!SafeDatabaseName.IsMatch(databaseName)
|
|
|| RequiredString(database, "compatibilityContract", 128)
|
|
!= "fixed_scalar_and_schema_validated_xml_rowsets_in_trusted_erp_process")
|
|
throw Contract();
|
|
return new ProfileBaseline
|
|
{
|
|
DatabaseName = databaseName,
|
|
SqlServerMajorVersion = RequiredInt(
|
|
database, "sqlServerMajorVersion", 9, 99),
|
|
CompatibilityLevel = RequiredInt(
|
|
database, "compatibilityLevel", 80, 200),
|
|
UserTableCount = RequiredLong(
|
|
database, "userTableCount", 0, 10000000),
|
|
UserViewCount = RequiredLong(
|
|
database, "userViewCount", 0, 10000000),
|
|
UserProcedureCount = RequiredLong(
|
|
database, "userProcedureCount", 0, 10000000),
|
|
UserTriggerCount = RequiredLong(
|
|
database, "userTriggerCount", 0, 10000000),
|
|
AgentWorkflowObjectsPresent = RequiredBoolean(
|
|
database, "agentWorkflowObjectsPresent"),
|
|
PurchaseActivationAllowed = purchaseActivationAllowed,
|
|
PurchaseModuleCode = purchaseModuleCode,
|
|
LeaveModuleCode = leaveModuleCode,
|
|
PurchaseOpenBlockerCount = purchaseOpenBlockerCount,
|
|
LeaveOpenBlockerCount = leaveOpenBlockerCount,
|
|
PurchaseOpenBlockerCodes = purchaseBlockers.OpenCodes,
|
|
LeaveOpenBlockerCodes = leaveBlockers.OpenCodes,
|
|
PurchaseResolutions = purchaseBlockers.Resolutions,
|
|
LeaveResolutions = leaveBlockers.Resolutions,
|
|
CriticalCatalogRequirements = ParseCatalogContract(
|
|
RequiredObject(database, "criticalCatalogContract"))
|
|
};
|
|
}
|
|
|
|
private static BlockerSet ParseActivationBlockers(
|
|
JArray blockers,
|
|
IDictionary<string, string> expectedEvidence)
|
|
{
|
|
HashSet<string> codes = new HashSet<string>(StringComparer.Ordinal);
|
|
BlockerSet result = new BlockerSet
|
|
{
|
|
OpenCodes = new List<string>(),
|
|
Resolutions = new List<BlockerResolution>()
|
|
};
|
|
foreach (JToken token in blockers)
|
|
{
|
|
JObject blocker = token as JObject;
|
|
if (blocker == null) throw Contract();
|
|
EnsureExact(blocker, "code", "status", "resolution", "evidence");
|
|
string code = RequiredString(blocker, "code", 128);
|
|
string status = RequiredString(blocker, "status", 16);
|
|
RequiredString(blocker, "evidence", 2000);
|
|
if (!SafeBlockerCode.IsMatch(code)
|
|
|| !codes.Add(code)
|
|
|| !expectedEvidence.ContainsKey(code)
|
|
|| (status != "open" && status != "resolved"))
|
|
throw Contract();
|
|
JToken resolutionToken = blocker["resolution"];
|
|
if (status == "open")
|
|
{
|
|
if (resolutionToken == null
|
|
|| resolutionToken.Type != JTokenType.Null)
|
|
throw Contract();
|
|
result.OpenCount += 1;
|
|
result.OpenCodes.Add(code);
|
|
}
|
|
else
|
|
{
|
|
JObject resolution = resolutionToken as JObject;
|
|
if (resolution == null) throw Contract();
|
|
EnsureExact(
|
|
resolution,
|
|
"evidenceArtifact",
|
|
"evidenceSha256",
|
|
"approvedBy",
|
|
"approvedAtUtc");
|
|
string artifact = RequiredString(
|
|
resolution, "evidenceArtifact", 32);
|
|
string hash = RequiredString(
|
|
resolution, "evidenceSha256", 64);
|
|
RequiredString(resolution, "approvedBy", 128);
|
|
string approvedAt = RequiredString(
|
|
resolution, "approvedAtUtc", 64);
|
|
DateTime parsed;
|
|
if (artifact != expectedEvidence[code]
|
|
|| !Sha256Pattern.IsMatch(hash)
|
|
|| !DateTime.TryParseExact(
|
|
approvedAt,
|
|
"o",
|
|
System.Globalization.CultureInfo.InvariantCulture,
|
|
System.Globalization.DateTimeStyles.RoundtripKind,
|
|
out parsed)
|
|
|| parsed.Kind != DateTimeKind.Utc)
|
|
throw Contract();
|
|
result.Resolutions.Add(new BlockerResolution
|
|
{
|
|
Code = code,
|
|
EvidenceArtifact = artifact,
|
|
EvidenceSha256 = hash
|
|
});
|
|
}
|
|
}
|
|
if (codes.Count != blockers.Count
|
|
|| codes.Count != expectedEvidence.Count
|
|
|| expectedEvidence.Keys.Any(code => !codes.Contains(code)))
|
|
throw Contract();
|
|
return result;
|
|
}
|
|
|
|
private static RuntimeCustomerProfileWorkflowStatus WorkflowStatus(
|
|
bool approved,
|
|
IEnumerable<string> openCodes)
|
|
{
|
|
RuntimeCustomerProfileWorkflowStatus result =
|
|
new RuntimeCustomerProfileWorkflowStatus
|
|
{
|
|
Approved = approved
|
|
};
|
|
foreach (string code in openCodes ?? new string[0])
|
|
result.OpenBlockerCodes.Add(code);
|
|
return result;
|
|
}
|
|
|
|
private static void VerifyResolutionBindings(
|
|
IList<BlockerResolution> resolutions,
|
|
string fieldMappingSha256,
|
|
string readContractEvidenceSha256,
|
|
string writeIntegrationEvidenceSha256)
|
|
{
|
|
if (resolutions == null || resolutions.Count == 0)
|
|
throw Error(
|
|
"profile_workflow_resolution_mismatch",
|
|
"客户画像没有提供当前工作流的逐项阻断解决证据,写命令保持禁用。");
|
|
foreach (BlockerResolution resolution in resolutions)
|
|
{
|
|
string expected = resolution.EvidenceArtifact == "field_mapping"
|
|
? fieldMappingSha256
|
|
: resolution.EvidenceArtifact == "read_contract"
|
|
? readContractEvidenceSha256
|
|
: resolution.EvidenceArtifact == "write_integration"
|
|
? writeIntegrationEvidenceSha256
|
|
: null;
|
|
if (expected == null
|
|
|| !string.Equals(
|
|
resolution.EvidenceSha256,
|
|
expected,
|
|
StringComparison.Ordinal))
|
|
throw Error(
|
|
"profile_workflow_resolution_mismatch",
|
|
"客户画像阻断解决证据与签名验收制品不一致,写命令保持禁用。");
|
|
}
|
|
}
|
|
|
|
private static IList<CatalogRequirement> ParseCatalogContract(
|
|
JObject contract)
|
|
{
|
|
EnsureExact(contract, "contractVersion", "requirements");
|
|
if (RequiredString(contract, "contractVersion", 16) != "1.0")
|
|
throw Contract();
|
|
JArray requirements = RequiredArray(contract, "requirements", 1, 128);
|
|
HashSet<string> identities = new HashSet<string>(
|
|
StringComparer.OrdinalIgnoreCase);
|
|
List<CatalogRequirement> result = new List<CatalogRequirement>();
|
|
int members = 0;
|
|
foreach (JToken token in requirements)
|
|
{
|
|
JObject item = token as JObject;
|
|
if (item == null) throw Contract();
|
|
EnsureExact(item,
|
|
"schemaName", "objectName", "objectKind",
|
|
"requiredColumns", "requiredParameters");
|
|
string schemaName = RequiredString(item, "schemaName", 128);
|
|
string objectName = RequiredString(item, "objectName", 128);
|
|
string objectKind = RequiredString(item, "objectKind", 16);
|
|
if (!SafeCatalogName.IsMatch(schemaName)
|
|
|| !SafeCatalogName.IsMatch(objectName)
|
|
|| (objectKind != "table" && objectKind != "view"
|
|
&& objectKind != "procedure")
|
|
|| !identities.Add(
|
|
schemaName + "\u001f" + objectName + "\u001f" + objectKind))
|
|
throw Contract();
|
|
CatalogRequirement requirement = new CatalogRequirement
|
|
{
|
|
SchemaName = schemaName,
|
|
ObjectName = objectName,
|
|
ObjectKind = objectKind
|
|
};
|
|
AddMembers(
|
|
requirement.RequiredColumns,
|
|
RequiredArray(item, "requiredColumns", 0, 128),
|
|
SafeCatalogName);
|
|
AddMembers(
|
|
requirement.RequiredParameters,
|
|
RequiredArray(item, "requiredParameters", 0, 128),
|
|
SafeParameterName);
|
|
if ((objectKind == "procedure"
|
|
&& requirement.RequiredColumns.Count != 0)
|
|
|| (objectKind != "procedure"
|
|
&& requirement.RequiredParameters.Count != 0))
|
|
throw Contract();
|
|
members += requirement.RequiredColumns.Count
|
|
+ requirement.RequiredParameters.Count;
|
|
if (members > 2048) throw Contract();
|
|
result.Add(requirement);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private static void AddMembers(
|
|
ISet<string> destination,
|
|
JArray source,
|
|
Regex format)
|
|
{
|
|
foreach (JToken token in source)
|
|
{
|
|
string value = token != null && token.Type == JTokenType.String
|
|
? Convert.ToString(token).Trim() : string.Empty;
|
|
if (!format.IsMatch(value) || !destination.Add(value))
|
|
throw Contract();
|
|
}
|
|
}
|
|
|
|
private static bool CriticalCatalogMatches(
|
|
IEnumerable<CatalogRequirement> requirements,
|
|
ISet<string> actual)
|
|
{
|
|
if (requirements == null || actual == null) return false;
|
|
foreach (CatalogRequirement requirement in requirements)
|
|
{
|
|
if (!actual.Contains(SqlRuntimeCustomerProfileMetadataProbe.CatalogEntryKey(
|
|
"object",
|
|
requirement.SchemaName,
|
|
requirement.ObjectName,
|
|
requirement.ObjectKind,
|
|
string.Empty))) return false;
|
|
foreach (string column in requirement.RequiredColumns)
|
|
if (!actual.Contains(
|
|
SqlRuntimeCustomerProfileMetadataProbe.CatalogEntryKey(
|
|
"column",
|
|
requirement.SchemaName,
|
|
requirement.ObjectName,
|
|
requirement.ObjectKind,
|
|
column))) return false;
|
|
foreach (string parameter in requirement.RequiredParameters)
|
|
if (!actual.Contains(
|
|
SqlRuntimeCustomerProfileMetadataProbe.CatalogEntryKey(
|
|
"parameter",
|
|
requirement.SchemaName,
|
|
requirement.ObjectName,
|
|
requirement.ObjectKind,
|
|
parameter))) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private static void EnsureExact(JObject source, params string[] names)
|
|
{
|
|
if (source == null) throw Contract();
|
|
HashSet<string> expected = new HashSet<string>(
|
|
names,
|
|
StringComparer.Ordinal);
|
|
if (source.Properties().Count() != expected.Count
|
|
|| source.Properties().Any(item => !expected.Contains(item.Name)))
|
|
throw Contract();
|
|
}
|
|
|
|
private static JObject RequiredObject(JObject source, string name)
|
|
{
|
|
JObject value = source[name] as JObject;
|
|
if (value == null) throw Contract();
|
|
return value;
|
|
}
|
|
|
|
private static JArray RequiredArray(
|
|
JObject source,
|
|
string name,
|
|
int minimum,
|
|
int maximum)
|
|
{
|
|
JArray value = source[name] as JArray;
|
|
if (value == null || value.Count < minimum || value.Count > maximum)
|
|
throw Contract();
|
|
return value;
|
|
}
|
|
|
|
private static string RequiredString(
|
|
JObject source,
|
|
string name,
|
|
int maximumLength)
|
|
{
|
|
JToken token = source[name];
|
|
string value = token != null && token.Type == JTokenType.String
|
|
? Convert.ToString(token).Trim() : string.Empty;
|
|
if (value.Length == 0 || value.Length > maximumLength
|
|
|| value.Any(char.IsControl)) throw Contract();
|
|
return value;
|
|
}
|
|
|
|
private static bool RequiredBoolean(JObject source, string name)
|
|
{
|
|
JToken token = source[name];
|
|
if (token == null || token.Type != JTokenType.Boolean) throw Contract();
|
|
return token.Value<bool>();
|
|
}
|
|
|
|
private static int RequiredInt(
|
|
JObject source,
|
|
string name,
|
|
int minimum,
|
|
int maximum)
|
|
{
|
|
return checked((int)RequiredLong(source, name, minimum, maximum));
|
|
}
|
|
|
|
private static long RequiredLong(
|
|
JObject source,
|
|
string name,
|
|
long minimum,
|
|
long maximum)
|
|
{
|
|
JToken token = source[name];
|
|
if (token == null || token.Type != JTokenType.Integer) throw Contract();
|
|
long value = token.Value<long>();
|
|
if (value < minimum || value > maximum) throw Contract();
|
|
return value;
|
|
}
|
|
|
|
private static string Sha256(byte[] value)
|
|
{
|
|
using (SHA256 sha = SHA256.Create())
|
|
{
|
|
return string.Concat(
|
|
sha.ComputeHash(value).Select(item => item.ToString("x2")));
|
|
}
|
|
}
|
|
|
|
private static CommandKernelException Contract()
|
|
{
|
|
return Error(
|
|
"profile_runtime_contract_invalid",
|
|
"客户画像文件不符合固定只读契约,写命令保持禁用。");
|
|
}
|
|
|
|
private static CommandKernelException Error(string code, string message)
|
|
{
|
|
return new CommandKernelException(code, message, 6);
|
|
}
|
|
|
|
private sealed class RejectCommentsJsonReader : JsonTextReader
|
|
{
|
|
public RejectCommentsJsonReader(TextReader reader) : base(reader) { }
|
|
|
|
public override bool Read()
|
|
{
|
|
bool available = base.Read();
|
|
if (available && TokenType == JsonToken.Comment)
|
|
throw new JsonReaderException("JSON comments are not allowed.");
|
|
return available;
|
|
}
|
|
}
|
|
|
|
private sealed class ProfileDocument
|
|
{
|
|
public JObject Profile { get; set; }
|
|
public string Sha256 { get; set; }
|
|
}
|
|
|
|
private sealed class ProfileBaseline
|
|
{
|
|
public string DatabaseName { get; set; }
|
|
public int SqlServerMajorVersion { get; set; }
|
|
public int CompatibilityLevel { get; set; }
|
|
public long UserTableCount { get; set; }
|
|
public long UserViewCount { get; set; }
|
|
public long UserProcedureCount { get; set; }
|
|
public long UserTriggerCount { get; set; }
|
|
public bool AgentWorkflowObjectsPresent { get; set; }
|
|
public bool PurchaseActivationAllowed { get; set; }
|
|
public string PurchaseModuleCode { get; set; }
|
|
public string LeaveModuleCode { get; set; }
|
|
public int PurchaseOpenBlockerCount { get; set; }
|
|
public int LeaveOpenBlockerCount { get; set; }
|
|
public IList<string> PurchaseOpenBlockerCodes { get; set; }
|
|
public IList<string> LeaveOpenBlockerCodes { get; set; }
|
|
public IList<BlockerResolution> PurchaseResolutions { get; set; }
|
|
public IList<BlockerResolution> LeaveResolutions { get; set; }
|
|
public IList<CatalogRequirement> CriticalCatalogRequirements { get; set; }
|
|
}
|
|
|
|
private sealed class BlockerSet
|
|
{
|
|
public int OpenCount { get; set; }
|
|
public IList<string> OpenCodes { get; set; }
|
|
public IList<BlockerResolution> Resolutions { get; set; }
|
|
}
|
|
|
|
private sealed class BlockerResolution
|
|
{
|
|
public string Code { get; set; }
|
|
public string EvidenceArtifact { get; set; }
|
|
public string EvidenceSha256 { get; set; }
|
|
}
|
|
|
|
private sealed class CatalogRequirement
|
|
{
|
|
public CatalogRequirement()
|
|
{
|
|
RequiredColumns = new HashSet<string>(
|
|
StringComparer.OrdinalIgnoreCase);
|
|
RequiredParameters = new HashSet<string>(
|
|
StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
|
|
public string SchemaName { get; set; }
|
|
public string ObjectName { get; set; }
|
|
public string ObjectKind { get; set; }
|
|
public ISet<string> RequiredColumns { get; private set; }
|
|
public ISet<string> RequiredParameters { get; private set; }
|
|
}
|
|
}
|
|
}
|