1352 lines
67 KiB
C#
1352 lines
67 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
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 WorkflowWriteIntegrationEvidenceResult
|
|
{
|
|
public string ContentSha256 { get; set; }
|
|
public string Workflow { get; set; }
|
|
public string ModuleCode { get; set; }
|
|
public string AccountBook { get; set; }
|
|
public string SubSystemId { get; set; }
|
|
public string UserIdSha256 { get; set; }
|
|
public string UserNameSha256 { get; set; }
|
|
public string DatabaseScopeFingerprint { get; set; }
|
|
public bool IsAdministrator { get; set; }
|
|
public string SourceCommit { get; set; }
|
|
public string PackageSha256 { get; set; }
|
|
public string RuntimeConfigurationSha256 { get; set; }
|
|
public string RuntimeCliVersion { get; set; }
|
|
public string RuntimeCliSha256 { get; set; }
|
|
public string RuntimeCliSignerThumbprint { get; set; }
|
|
public string UatAuthorizationSourceSha256 { get; set; }
|
|
public string UatAuthorizationContentSha256 { get; set; }
|
|
public string UatAuthorizationIdSha256 { get; set; }
|
|
public string EnvironmentId { get; set; }
|
|
public DateTime TestedAtUtc { get; set; }
|
|
public string TestedBy { get; set; }
|
|
public int CaseCount { get; set; }
|
|
internal IDictionary<string, string> UatTokenSha256ByCase { get; set; }
|
|
|
|
public IDictionary<string, object> ToDictionary()
|
|
{
|
|
return new Dictionary<string, object>
|
|
{
|
|
{ "evidenceType", "workflow_write_integration" },
|
|
{ "schemaVersion", "1.6" },
|
|
{ "contentSha256", ContentSha256 },
|
|
{ "workflow", Workflow },
|
|
{ "moduleCode", ModuleCode },
|
|
{ "erpScope", new Dictionary<string, object>
|
|
{
|
|
{ "accountBook", AccountBook },
|
|
{ "subSystemId", SubSystemId },
|
|
{ "userIdSha256", UserIdSha256 },
|
|
{ "userNameSha256", UserNameSha256 },
|
|
{ "databaseScopeFingerprint",
|
|
DatabaseScopeFingerprint },
|
|
{ "isAdministrator", IsAdministrator }
|
|
}
|
|
},
|
|
{ "sourceCommit", SourceCommit },
|
|
{ "packageSha256", PackageSha256 },
|
|
{ "runtimeConfigurationSha256", RuntimeConfigurationSha256 },
|
|
{ "runtimeCli", new Dictionary<string, object>
|
|
{
|
|
{ "fileName", "lserp-agent-cli.exe" },
|
|
{ "version", RuntimeCliVersion },
|
|
{ "sha256", RuntimeCliSha256 },
|
|
{ "signerThumbprint", RuntimeCliSignerThumbprint },
|
|
{ "requiresElevation", false },
|
|
{ "bridgeOnly", true },
|
|
{ "databaseDirectAccess", false },
|
|
{ "sessionSource", "current_logged_in_erp_process" }
|
|
}
|
|
},
|
|
{ "uatAuthorizationSourceSha256", UatAuthorizationSourceSha256 },
|
|
{ "uatAuthorizationContentSha256", UatAuthorizationContentSha256 },
|
|
{ "uatAuthorizationIdSha256", UatAuthorizationIdSha256 },
|
|
{ "environmentId", EnvironmentId },
|
|
{ "testedAtUtc", TestedAtUtc },
|
|
{ "testedBy", TestedBy },
|
|
{ "caseCount", CaseCount },
|
|
{ "verified", true },
|
|
{ "registrationReady", false }
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 验证客户 Windows/SQL Server 写链路报告。报告只保存不可逆标识哈希,
|
|
/// 但必须逐项证明权限、确认、事务、回滚、幂等、运行时复核和审计行为。
|
|
/// </summary>
|
|
public static class WorkflowWriteIntegrationEvidenceVerifier
|
|
{
|
|
private const int MaximumBytes = 4 * 1024 * 1024;
|
|
private static readonly Regex SafeModule = new Regex(
|
|
@"^[A-Za-z0-9_.:\-]{1,64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeIdentifier = new Regex(
|
|
@"^[A-Za-z0-9_.:\-]{1,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeCode = new Regex(
|
|
@"^[a-z0-9_.\-]{1,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex Sha256Pattern = new Regex(
|
|
@"^[a-f0-9]{64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex CommitPattern = new Regex(
|
|
@"^[a-f0-9]{40}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex RuntimeCliVersionPattern = new Regex(
|
|
@"^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SignerThumbprintPattern = new Regex(
|
|
@"^[A-F0-9]{40}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
|
|
private static readonly string[] PurchaseCases =
|
|
{
|
|
"purchase_unique_match_commit",
|
|
"purchase_ambiguous_match_blocked",
|
|
"purchase_overallocation_blocked",
|
|
"purchase_permission_denied",
|
|
"purchase_database_permission_recheck_denied",
|
|
"purchase_currency_field_missing_blocked",
|
|
"purchase_currency_crosswalk_unapproved_blocked",
|
|
"purchase_row_scope_denied",
|
|
"purchase_runtime_recheck_blocked",
|
|
"purchase_transaction_rollback",
|
|
"purchase_idempotency_replay",
|
|
"purchase_idempotency_conflict",
|
|
"purchase_audit_correlated"
|
|
};
|
|
|
|
private static readonly string[] LeaveCases =
|
|
{
|
|
"leave_natural_language_resolution",
|
|
"leave_multi_day_calendar_resolution",
|
|
"leave_resolution_proof_bypass_blocked",
|
|
"leave_ambiguous_type_blocked",
|
|
"leave_ambiguous_flow_type_blocked",
|
|
"leave_time_segment_required_blocked",
|
|
"leave_local_time_zone_rejected",
|
|
"leave_other_employee_denied",
|
|
"leave_permission_denied",
|
|
"leave_database_permission_recheck_denied",
|
|
"leave_create_draft_commit",
|
|
"leave_submit_separate_confirmation",
|
|
"leave_overlap_blocked",
|
|
"leave_stale_flow_type_blocked",
|
|
"leave_runtime_recheck_blocked",
|
|
"leave_transaction_rollback",
|
|
"leave_idempotency_replay",
|
|
"leave_idempotency_conflict",
|
|
"leave_audit_correlated"
|
|
};
|
|
|
|
private static readonly IDictionary<string, string> ExpectedResultCodes =
|
|
new Dictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
{ "purchase_unique_match_commit", "purchase_document_created" },
|
|
{ "purchase_ambiguous_match_blocked", "purchase_match_invalid" },
|
|
{ "purchase_overallocation_blocked", "purchase_match_invalid" },
|
|
{ "purchase_permission_denied", "command_access_denied" },
|
|
{ "purchase_database_permission_recheck_denied", "purchase_write_permission_denied" },
|
|
{ "purchase_currency_field_missing_blocked", "purchase_currency_field_not_configured" },
|
|
{ "purchase_currency_crosswalk_unapproved_blocked", "purchase_currency_crosswalk_not_approved" },
|
|
{ "purchase_row_scope_denied", "purchase_row_scope_denied" },
|
|
{ "purchase_runtime_recheck_blocked", "purchase_source_changed" },
|
|
{ "purchase_transaction_rollback", "purchase_legacy_create_failed" },
|
|
{ "purchase_idempotency_replay", "purchase_document_created" },
|
|
{ "purchase_idempotency_conflict", "idempotency_key_conflict" },
|
|
{ "purchase_audit_correlated", "purchase_document_created" },
|
|
{ "leave_natural_language_resolution", "leave_intent_resolved" },
|
|
{ "leave_multi_day_calendar_resolution", "leave_intent_resolved" },
|
|
{ "leave_resolution_proof_bypass_blocked", "leave_resolution_invalid" },
|
|
{ "leave_ambiguous_type_blocked", "leave_resolution_invalid" },
|
|
{ "leave_ambiguous_flow_type_blocked", "leave_resolution_invalid" },
|
|
{ "leave_time_segment_required_blocked", "leave_resolution_invalid" },
|
|
{ "leave_local_time_zone_rejected", "input_schema_violation" },
|
|
{ "leave_other_employee_denied", "leave_resolution_invalid" },
|
|
{ "leave_permission_denied", "command_access_denied" },
|
|
{ "leave_database_permission_recheck_denied", "leave_write_permission_denied" },
|
|
{ "leave_create_draft_commit", "leave_draft_created" },
|
|
{ "leave_submit_separate_confirmation", "leave_submitted" },
|
|
{ "leave_overlap_blocked", "leave_request_invalid" },
|
|
{ "leave_stale_flow_type_blocked", "leave_request_changed" },
|
|
{ "leave_runtime_recheck_blocked", "leave_request_changed" },
|
|
{ "leave_transaction_rollback", "leave_legacy_create_failed" },
|
|
{ "leave_idempotency_replay", "leave_draft_created" },
|
|
{ "leave_idempotency_conflict", "idempotency_key_conflict" },
|
|
{ "leave_audit_correlated", "leave_draft_created" }
|
|
};
|
|
|
|
private static readonly IDictionary<string, string> ExpectedIssueCodes =
|
|
new Dictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
{ "leave_ambiguous_type_blocked", "leave_type_ambiguous" },
|
|
{ "leave_ambiguous_flow_type_blocked", "leave_flow_type_ambiguous" },
|
|
{ "leave_time_segment_required_blocked", "leave_time_segment_required" },
|
|
{ "leave_other_employee_denied", "leave_employee_reference_unsupported" }
|
|
};
|
|
|
|
internal static string ExpectedResultCodeForCase(string caseCode)
|
|
{
|
|
string value;
|
|
return !string.IsNullOrWhiteSpace(caseCode)
|
|
&& ExpectedResultCodes.TryGetValue(caseCode, out value)
|
|
? value
|
|
: null;
|
|
}
|
|
|
|
internal static string ExpectedIssueCodeForCase(string caseCode)
|
|
{
|
|
string value;
|
|
return !string.IsNullOrWhiteSpace(caseCode)
|
|
&& ExpectedIssueCodes.TryGetValue(caseCode, out value)
|
|
? value
|
|
: null;
|
|
}
|
|
|
|
internal static string ExpectedCommandNameForCase(string caseCode)
|
|
{
|
|
if (PurchaseCases.Contains(caseCode, StringComparer.Ordinal))
|
|
return "purchase.invoice.create";
|
|
switch (caseCode)
|
|
{
|
|
case "leave_natural_language_resolution":
|
|
case "leave_multi_day_calendar_resolution":
|
|
case "leave_ambiguous_type_blocked":
|
|
case "leave_ambiguous_flow_type_blocked":
|
|
case "leave_time_segment_required_blocked":
|
|
case "leave_other_employee_denied":
|
|
return "hr.leave.resolve";
|
|
case "leave_submit_separate_confirmation":
|
|
return "hr.leave.submit";
|
|
default:
|
|
return LeaveCases.Contains(caseCode, StringComparer.Ordinal)
|
|
? "hr.leave.create"
|
|
: null;
|
|
}
|
|
}
|
|
|
|
internal static void ValidateProjectedCase(JObject item, DateTime nowUtc)
|
|
{
|
|
string caseCode = item == null
|
|
? null
|
|
: Convert.ToString(item["caseCode"], CultureInfo.InvariantCulture);
|
|
string[] required = PurchaseCases.Contains(
|
|
caseCode,
|
|
StringComparer.Ordinal)
|
|
? PurchaseCases
|
|
: LeaveCases.Contains(caseCode, StringComparer.Ordinal)
|
|
? LeaveCases
|
|
: null;
|
|
if (required == null)
|
|
throw Error(
|
|
"write_integration_evidence_case_invalid",
|
|
"写集成证据包含未知用例。");
|
|
ValidateCase(
|
|
item,
|
|
new HashSet<string>(StringComparer.Ordinal),
|
|
required,
|
|
nowUtc.ToUniversalTime(),
|
|
new CaseScopeValidation(
|
|
null, null, null, null, null, null,
|
|
null, null, null));
|
|
}
|
|
|
|
internal static string[] RequiredCaseCodesForWorkflow(string workflow)
|
|
{
|
|
string[] source = workflow == "purchase"
|
|
? PurchaseCases
|
|
: workflow == "leave"
|
|
? LeaveCases
|
|
: null;
|
|
if (source == null)
|
|
throw Error(
|
|
"write_integration_evidence_schema_invalid",
|
|
"写集成证据工作流无效。");
|
|
return source.ToArray();
|
|
}
|
|
|
|
internal static void ValidateProjectedCaseSet(
|
|
string workflow,
|
|
JArray cases,
|
|
DateTime testedAtUtc)
|
|
{
|
|
ValidateProjectedCaseSet(
|
|
workflow,
|
|
cases,
|
|
testedAtUtc,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
null);
|
|
}
|
|
|
|
private static void ValidateProjectedCaseSet(
|
|
string workflow,
|
|
JArray cases,
|
|
DateTime testedAtUtc,
|
|
string expectedModuleCode,
|
|
string expectedAccountBook,
|
|
string expectedSubSystemId,
|
|
string expectedUatAuthorizationSourceSha256,
|
|
string expectedUatAuthorizationContentSha256,
|
|
string expectedUatAuthorizationIdSha256,
|
|
string expectedRuntimeCliVersion,
|
|
string expectedRuntimeCliSha256,
|
|
string expectedRuntimeCliSignerThumbprint)
|
|
{
|
|
string[] requiredCases = RequiredCaseCodesForWorkflow(workflow);
|
|
if (cases == null || cases.Count != requiredCases.Length)
|
|
throw Error(
|
|
"write_integration_evidence_coverage_incomplete",
|
|
"写集成证据没有覆盖工作流全部必测用例。");
|
|
HashSet<string> observed = new HashSet<string>(StringComparer.Ordinal);
|
|
Dictionary<string, ValidatedCaseEvidence> validatedCases =
|
|
new Dictionary<string, ValidatedCaseEvidence>(StringComparer.Ordinal);
|
|
CaseScopeValidation scope = new CaseScopeValidation(
|
|
expectedModuleCode,
|
|
expectedAccountBook == null ? null : Sha256(expectedAccountBook),
|
|
expectedSubSystemId == null ? null : Sha256(expectedSubSystemId),
|
|
expectedUatAuthorizationSourceSha256,
|
|
expectedUatAuthorizationContentSha256,
|
|
expectedUatAuthorizationIdSha256,
|
|
expectedRuntimeCliVersion,
|
|
expectedRuntimeCliSha256,
|
|
expectedRuntimeCliSignerThumbprint);
|
|
foreach (JToken token in cases)
|
|
{
|
|
JObject item = token as JObject;
|
|
if (item == null)
|
|
throw Error(
|
|
"write_integration_evidence_case_invalid",
|
|
"写集成证据用例必须是对象。");
|
|
ValidatedCaseEvidence validated = ValidateCase(
|
|
item,
|
|
observed,
|
|
requiredCases,
|
|
testedAtUtc.ToUniversalTime(),
|
|
scope);
|
|
validatedCases.Add(validated.Code, validated);
|
|
}
|
|
if (observed.Count != requiredCases.Length
|
|
|| requiredCases.Any(item => !observed.Contains(item)))
|
|
throw Error(
|
|
"write_integration_evidence_coverage_incomplete",
|
|
"写集成证据没有覆盖工作流全部必测用例。");
|
|
scope.ValidateComplete();
|
|
ValidateCaseRelationships(workflow, validatedCases);
|
|
}
|
|
|
|
public static WorkflowWriteIntegrationEvidenceResult VerifyFile(
|
|
string path,
|
|
string expectedWorkflow,
|
|
string expectedModuleCode,
|
|
string expectedAccountBook,
|
|
string expectedSubSystemId,
|
|
string expectedRuntimeConfigurationSha256,
|
|
string expectedSourceCommit,
|
|
string expectedPackageSha256,
|
|
DateTime nowUtc)
|
|
{
|
|
JObject root = LoadStrict(path);
|
|
EnsureExact(root, "schemaVersion", "contentSha256", "content");
|
|
if (RequiredString(root, "schemaVersion", 1, 16) != "1.6")
|
|
throw Error("write_integration_evidence_schema_invalid", "写集成证据版本无效。");
|
|
string contentHash = RequiredHash(root, "contentSha256");
|
|
JObject content = RequiredObject(root, "content");
|
|
EnsureExact(content,
|
|
"evidenceType", "workflow", "moduleCode", "erpScope", "sourceCommit",
|
|
"packageSha256", "runtimeConfigurationSha256", "runtimeCli",
|
|
"environmentId",
|
|
"uatAuthorizationSourceSha256",
|
|
"uatAuthorizationContentSha256",
|
|
"uatAuthorizationIdSha256",
|
|
"testedAtUtc", "testedBy", "cases");
|
|
if (RequiredString(content, "evidenceType", 1, 64) != "workflow_write_integration")
|
|
throw Error("write_integration_evidence_schema_invalid", "写集成证据类型无效。");
|
|
string canonical = content.ToString(Formatting.None);
|
|
if (!string.Equals(contentHash, Sha256(canonical), StringComparison.Ordinal))
|
|
throw Error("write_integration_evidence_hash_mismatch", "写集成证据内容哈希不一致。");
|
|
|
|
string workflow = RequiredString(content, "workflow", 1, 32);
|
|
string moduleCode = RequiredString(content, "moduleCode", 1, 64);
|
|
if ((workflow != "purchase" && workflow != "leave") || !SafeModule.IsMatch(moduleCode))
|
|
throw Error("write_integration_evidence_schema_invalid", "写集成证据工作流或模块编号无效。");
|
|
JObject scope = RequiredObject(content, "erpScope");
|
|
EnsureExact(scope, "accountBook", "subSystemId");
|
|
string accountBook = RequiredString(scope, "accountBook", 1, 128);
|
|
string subSystemId = RequiredString(scope, "subSystemId", 1, 128);
|
|
string sourceCommit = RequiredString(content, "sourceCommit", 40, 40).ToLowerInvariant();
|
|
string packageHash = RequiredHash(content, "packageSha256");
|
|
string runtimeHash = RequiredHash(content, "runtimeConfigurationSha256");
|
|
JObject runtimeCli = RequiredObject(content, "runtimeCli");
|
|
EnsureExact(
|
|
runtimeCli,
|
|
"fileName", "version", "sha256", "signerThumbprint",
|
|
"requiresElevation", "bridgeOnly", "databaseDirectAccess",
|
|
"sessionSource");
|
|
string runtimeCliVersion = RequiredString(
|
|
runtimeCli,
|
|
"version",
|
|
5,
|
|
14);
|
|
string runtimeCliSha256 = RequiredHash(runtimeCli, "sha256");
|
|
string runtimeCliSigner = RequiredString(
|
|
runtimeCli,
|
|
"signerThumbprint",
|
|
40,
|
|
40);
|
|
if (RequiredString(runtimeCli, "fileName", 19, 19)
|
|
!= "lserp-agent-cli.exe"
|
|
|| !RuntimeCliVersionPattern.IsMatch(runtimeCliVersion)
|
|
|| !SignerThumbprintPattern.IsMatch(runtimeCliSigner)
|
|
|| RequiredBoolean(runtimeCli, "requiresElevation")
|
|
|| !RequiredBoolean(runtimeCli, "bridgeOnly")
|
|
|| RequiredBoolean(runtimeCli, "databaseDirectAccess")
|
|
|| RequiredString(runtimeCli, "sessionSource", 29, 29)
|
|
!= "current_logged_in_erp_process")
|
|
throw Error(
|
|
"write_integration_evidence_runtime_cli_invalid",
|
|
"写集成证据运行 CLI 身份或本地会话边界无效。");
|
|
string uatSourceHash = RequiredHash(
|
|
content,
|
|
"uatAuthorizationSourceSha256");
|
|
string uatContentHash = RequiredHash(
|
|
content,
|
|
"uatAuthorizationContentSha256");
|
|
string uatAuthorizationIdHash = RequiredHash(
|
|
content,
|
|
"uatAuthorizationIdSha256");
|
|
if (!CommitPattern.IsMatch(sourceCommit))
|
|
throw Error("write_integration_evidence_schema_invalid", "写集成证据源码提交无效。");
|
|
string environmentId = RequiredString(content, "environmentId", 8, 128);
|
|
string testedBy = RequiredString(content, "testedBy", 1, 128);
|
|
if (!SafeIdentifier.IsMatch(environmentId) || !SafeIdentifier.IsMatch(testedBy))
|
|
throw Error("write_integration_evidence_schema_invalid", "验收环境或验证人标识无效。");
|
|
DateTime testedAt = RequiredUtc(content, "testedAtUtc");
|
|
nowUtc = nowUtc.Kind == DateTimeKind.Utc ? nowUtc : nowUtc.ToUniversalTime();
|
|
if (testedAt > nowUtc.AddMinutes(5) || testedAt < nowUtc.AddDays(-30))
|
|
throw Error("write_integration_evidence_expired", "写集成证据必须在最近 30 天内生成。");
|
|
|
|
RequireExpected(workflow, expectedWorkflow, "workflow");
|
|
RequireExpected(moduleCode, expectedModuleCode, "moduleCode");
|
|
RequireExpected(accountBook, expectedAccountBook, "accountBook");
|
|
RequireExpected(subSystemId, expectedSubSystemId, "subSystemId");
|
|
if (!string.IsNullOrWhiteSpace(expectedRuntimeConfigurationSha256)
|
|
&& !string.Equals(runtimeHash, expectedRuntimeConfigurationSha256,
|
|
StringComparison.OrdinalIgnoreCase))
|
|
throw Error("write_integration_evidence_scope_mismatch", "写集成证据与运行时配置哈希不一致。");
|
|
if (!string.IsNullOrWhiteSpace(expectedSourceCommit)
|
|
&& !string.Equals(sourceCommit, expectedSourceCommit,
|
|
StringComparison.OrdinalIgnoreCase))
|
|
throw Error("write_integration_evidence_scope_mismatch", "写集成证据与发布源码提交不一致。");
|
|
if (!string.IsNullOrWhiteSpace(expectedPackageSha256)
|
|
&& !string.Equals(packageHash, expectedPackageSha256,
|
|
StringComparison.OrdinalIgnoreCase))
|
|
throw Error("write_integration_evidence_scope_mismatch", "写集成证据与商用包哈希不一致。");
|
|
|
|
JArray cases = content["cases"] as JArray;
|
|
if (cases == null || cases.Count <= 0 || cases.Count > 32)
|
|
throw Error("write_integration_evidence_schema_invalid", "写集成证据用例集合无效。");
|
|
ValidateProjectedCaseSet(
|
|
workflow,
|
|
cases,
|
|
testedAt,
|
|
moduleCode,
|
|
accountBook,
|
|
subSystemId,
|
|
uatSourceHash,
|
|
uatContentHash,
|
|
uatAuthorizationIdHash,
|
|
runtimeCliVersion,
|
|
runtimeCliSha256,
|
|
runtimeCliSigner);
|
|
Dictionary<string, string> uatTokenSha256ByCase = cases
|
|
.OfType<JObject>()
|
|
.ToDictionary(
|
|
item => RequiredString(item, "caseCode", 1, 128),
|
|
item => RequiredHash(item, "uatTokenSha256"),
|
|
StringComparer.Ordinal);
|
|
JObject firstCase = (JObject)cases[0];
|
|
|
|
return new WorkflowWriteIntegrationEvidenceResult
|
|
{
|
|
ContentSha256 = contentHash,
|
|
Workflow = workflow,
|
|
ModuleCode = moduleCode,
|
|
AccountBook = accountBook,
|
|
SubSystemId = subSystemId,
|
|
UserIdSha256 = RequiredHash(firstCase, "userIdSha256"),
|
|
UserNameSha256 = RequiredHash(firstCase, "userNameSha256"),
|
|
DatabaseScopeFingerprint = RequiredHash(
|
|
firstCase,
|
|
"databaseScopeFingerprint"),
|
|
IsAdministrator = RequiredBoolean(
|
|
firstCase,
|
|
"isAdministrator"),
|
|
SourceCommit = sourceCommit,
|
|
PackageSha256 = packageHash,
|
|
RuntimeConfigurationSha256 = runtimeHash,
|
|
RuntimeCliVersion = runtimeCliVersion,
|
|
RuntimeCliSha256 = runtimeCliSha256,
|
|
RuntimeCliSignerThumbprint = runtimeCliSigner,
|
|
UatAuthorizationSourceSha256 = uatSourceHash,
|
|
UatAuthorizationContentSha256 = uatContentHash,
|
|
UatAuthorizationIdSha256 = uatAuthorizationIdHash,
|
|
EnvironmentId = environmentId,
|
|
TestedAtUtc = testedAt,
|
|
TestedBy = testedBy,
|
|
CaseCount = cases.Count,
|
|
UatTokenSha256ByCase = uatTokenSha256ByCase
|
|
};
|
|
}
|
|
|
|
private static ValidatedCaseEvidence ValidateCase(
|
|
JObject item,
|
|
ISet<string> observed,
|
|
IEnumerable<string> requiredCases,
|
|
DateTime testedAtUtc,
|
|
CaseScopeValidation scope)
|
|
{
|
|
EnsureExact(item,
|
|
"caseCode", "uatAuthorizationSourceSha256",
|
|
"uatAuthorizationContentSha256", "uatAuthorizationIdSha256",
|
|
"uatTokenSha256", "runtimeCliVersion", "runtimeCliSha256",
|
|
"runtimeCliSignerThumbprint", "passed", "correlationId",
|
|
"contextCorrelationBound", "commandName",
|
|
"planCommandVersion", "planModuleCode", "planRisk",
|
|
"accountBookSha256", "subSystemIdSha256", "userIdSha256",
|
|
"userNameSha256", "databaseScopeFingerprint",
|
|
"isAdministrator",
|
|
"inputFingerprintSha256",
|
|
"planFingerprintSha256", "resultCode", "issueCode", "recordIdSha256",
|
|
"transactionEvidenceIdSha256", "businessAuditIdSha256",
|
|
"idempotencyKeySha256", "businessMutationCount", "replayed",
|
|
"nativeConfirmationObserved", "auditEventCount",
|
|
"sourceDocumentSetSha256",
|
|
"sourceDocumentPreprocessContracts",
|
|
"sourceDocumentInputFingerprintBound",
|
|
"sourceDocumentWritePayloadBound", "sourceDocumentAuditCount",
|
|
"observedAtUtc");
|
|
string code = RequiredString(item, "caseCode", 1, 128);
|
|
if (!SafeCode.IsMatch(code) || !requiredCases.Contains(code) || !observed.Add(code))
|
|
throw Error("write_integration_evidence_case_invalid", "写集成证据包含未知或重复用例。");
|
|
if (!RequiredBoolean(item, "passed"))
|
|
throw Error("write_integration_evidence_case_failed", "写集成证据包含未通过用例。");
|
|
if (!RequiredBoolean(item, "contextCorrelationBound"))
|
|
throw Error(
|
|
"write_integration_evidence_scope_mismatch",
|
|
"写集成证据用例没有把 ERP 上下文绑定到计划关联 ID。");
|
|
string correlation = RequiredString(item, "correlationId", 8, 128);
|
|
string commandName = RequiredString(item, "commandName", 1, 128);
|
|
string expectedCommand = ExpectedCommandNameForCase(code);
|
|
string resultCode = RequiredString(item, "resultCode", 1, 128);
|
|
if (!SafeIdentifier.IsMatch(correlation)
|
|
|| !SafeIdentifier.IsMatch(commandName)
|
|
|| !string.Equals(
|
|
commandName,
|
|
expectedCommand,
|
|
StringComparison.Ordinal)
|
|
|| !SafeCode.IsMatch(resultCode))
|
|
throw Error("write_integration_evidence_case_invalid", "写集成证据关联 ID 或结果码无效。");
|
|
string expectedResultCode;
|
|
if (!ExpectedResultCodes.TryGetValue(code, out expectedResultCode)
|
|
|| !string.Equals(resultCode, expectedResultCode, StringComparison.Ordinal))
|
|
throw Error("write_integration_evidence_case_invalid", "写集成证据结果码与必测场景不一致。");
|
|
string issueCode = NullableCode(item, "issueCode");
|
|
string expectedIssueCode;
|
|
if (ExpectedIssueCodes.TryGetValue(code, out expectedIssueCode))
|
|
{
|
|
if (!string.Equals(issueCode, expectedIssueCode, StringComparison.Ordinal))
|
|
throw Error("write_integration_evidence_case_invalid", "写集成证据没有记录精确的解析阻断原因。");
|
|
}
|
|
else if (issueCode != null)
|
|
throw Error("write_integration_evidence_case_invalid", "写集成证据在非解析场景携带了未知问题码。");
|
|
RequiredHash(item, "inputFingerprintSha256");
|
|
string plan = NullableHash(item, "planFingerprintSha256");
|
|
string planVersion = NullableIdentifier(
|
|
item,
|
|
"planCommandVersion",
|
|
3,
|
|
16);
|
|
string planModule = NullableIdentifier(
|
|
item,
|
|
"planModuleCode",
|
|
1,
|
|
64);
|
|
string planRisk = NullableIdentifier(item, "planRisk", 1, 16);
|
|
bool hasPlanContract = planVersion != null
|
|
|| planModule != null
|
|
|| planRisk != null;
|
|
if (hasPlanContract
|
|
&& (planVersion == null || planModule == null || planRisk == null))
|
|
throw Error(
|
|
"write_integration_evidence_case_invalid",
|
|
"写集成证据计划合同字段不完整。");
|
|
if ((plan == null) != !hasPlanContract)
|
|
throw Error(
|
|
"write_integration_evidence_case_invalid",
|
|
"写集成证据计划指纹与计划合同不一致。");
|
|
string expectedVersion;
|
|
string expectedRisk;
|
|
ExpectedPlanContract(
|
|
commandName,
|
|
out expectedVersion,
|
|
out expectedRisk);
|
|
if (hasPlanContract
|
|
&& (!SafeModule.IsMatch(planModule)
|
|
|| !string.Equals(
|
|
planVersion,
|
|
expectedVersion,
|
|
StringComparison.Ordinal)
|
|
|| !string.Equals(
|
|
planRisk,
|
|
expectedRisk,
|
|
StringComparison.Ordinal)))
|
|
throw Error(
|
|
"write_integration_evidence_case_invalid",
|
|
"写集成证据计划命令版本、模块或风险合同无效。");
|
|
bool planRequired = PlanRequiredForCase(code);
|
|
if (planRequired != hasPlanContract)
|
|
throw Error(
|
|
"write_integration_evidence_case_invalid",
|
|
"写集成证据的计划阶段与固定场景不一致。");
|
|
string accountBookHash = RequiredHash(item, "accountBookSha256");
|
|
string subSystemHash = RequiredHash(item, "subSystemIdSha256");
|
|
string userHash = RequiredHash(item, "userIdSha256");
|
|
string userNameHash = RequiredHash(item, "userNameSha256");
|
|
string databaseScopeFingerprint = RequiredHash(
|
|
item,
|
|
"databaseScopeFingerprint");
|
|
bool isAdministrator = RequiredBoolean(item, "isAdministrator");
|
|
string uatSourceHash = RequiredHash(
|
|
item,
|
|
"uatAuthorizationSourceSha256");
|
|
string uatContentHash = RequiredHash(
|
|
item,
|
|
"uatAuthorizationContentSha256");
|
|
string uatAuthorizationIdHash = RequiredHash(
|
|
item,
|
|
"uatAuthorizationIdSha256");
|
|
string uatTokenHash = RequiredHash(item, "uatTokenSha256");
|
|
string runtimeCliVersion = RequiredString(
|
|
item,
|
|
"runtimeCliVersion",
|
|
5,
|
|
14);
|
|
string runtimeCliSha256 = RequiredHash(item, "runtimeCliSha256");
|
|
string runtimeCliSigner = RequiredString(
|
|
item,
|
|
"runtimeCliSignerThumbprint",
|
|
40,
|
|
40);
|
|
if (!RuntimeCliVersionPattern.IsMatch(runtimeCliVersion)
|
|
|| !SignerThumbprintPattern.IsMatch(runtimeCliSigner))
|
|
throw Error(
|
|
"write_integration_evidence_runtime_cli_invalid",
|
|
"写集成证据用例运行 CLI 身份无效。");
|
|
scope.Observe(
|
|
planModule,
|
|
accountBookHash,
|
|
subSystemHash,
|
|
userHash,
|
|
userNameHash,
|
|
databaseScopeFingerprint,
|
|
isAdministrator,
|
|
uatSourceHash,
|
|
uatContentHash,
|
|
uatAuthorizationIdHash,
|
|
uatTokenHash,
|
|
runtimeCliVersion,
|
|
runtimeCliSha256,
|
|
runtimeCliSigner);
|
|
string record = NullableHash(item, "recordIdSha256");
|
|
string transaction = NullableHash(item, "transactionEvidenceIdSha256");
|
|
string audit = NullableHash(item, "businessAuditIdSha256");
|
|
string idempotency = NullableHash(item, "idempotencyKeySha256");
|
|
int mutationCount = RequiredInteger(item, "businessMutationCount", 0, 1000);
|
|
bool replayed = RequiredBoolean(item, "replayed");
|
|
bool confirmed = RequiredBoolean(item, "nativeConfirmationObserved");
|
|
int auditEvents = RequiredInteger(item, "auditEventCount", 1, 1000);
|
|
string sourceDocumentSet = NullableHash(item, "sourceDocumentSetSha256");
|
|
string[] sourcePreprocessContracts =
|
|
RequiredSourcePreprocessContracts(
|
|
item,
|
|
"sourceDocumentPreprocessContracts");
|
|
bool sourceInputBound = RequiredBoolean(
|
|
item, "sourceDocumentInputFingerprintBound");
|
|
bool sourcePayloadBound = RequiredBoolean(
|
|
item, "sourceDocumentWritePayloadBound");
|
|
int sourceAuditCount = RequiredInteger(
|
|
item, "sourceDocumentAuditCount", 0, 1000);
|
|
DateTime observedAtUtc = RequiredUtc(item, "observedAtUtc");
|
|
if (observedAtUtc > testedAtUtc.AddMinutes(5)
|
|
|| observedAtUtc < testedAtUtc.AddDays(-30))
|
|
throw Error(
|
|
"write_integration_evidence_case_invalid",
|
|
"写集成证据用例观察时间必须位于报告生成前 30 天内。");
|
|
|
|
bool commit = code == "purchase_unique_match_commit"
|
|
|| code == "leave_create_draft_commit"
|
|
|| code == "leave_submit_separate_confirmation";
|
|
bool replay = code == "purchase_idempotency_replay"
|
|
|| code == "leave_idempotency_replay";
|
|
bool idempotencyConflict = code == "purchase_idempotency_conflict"
|
|
|| code == "leave_idempotency_conflict";
|
|
bool auditCase = code == "purchase_audit_correlated"
|
|
|| code == "leave_audit_correlated";
|
|
bool sourceProofRequired = code == "purchase_unique_match_commit"
|
|
|| code == "purchase_idempotency_replay"
|
|
|| code == "purchase_audit_correlated";
|
|
bool postConfirmationBlock = code == "purchase_database_permission_recheck_denied"
|
|
|| code == "purchase_currency_field_missing_blocked"
|
|
|| code == "purchase_currency_crosswalk_unapproved_blocked"
|
|
|| code == "purchase_row_scope_denied"
|
|
|| code == "purchase_runtime_recheck_blocked"
|
|
|| code == "purchase_transaction_rollback"
|
|
|| code == "leave_database_permission_recheck_denied"
|
|
|| code == "leave_stale_flow_type_blocked"
|
|
|| code == "leave_runtime_recheck_blocked"
|
|
|| code == "leave_transaction_rollback";
|
|
if (commit && (plan == null || record == null || transaction == null || audit == null
|
|
|| idempotency == null || mutationCount < 1 || replayed || !confirmed
|
|
|| auditEvents < 2))
|
|
throw Error("write_integration_evidence_case_invalid", "成功写用例缺少事务、幂等、确认或审计证据。");
|
|
if (replay && (plan == null || record == null || transaction == null
|
|
|| audit == null || idempotency == null || mutationCount != 0
|
|
|| !replayed || !confirmed || auditEvents < 2))
|
|
throw Error("write_integration_evidence_case_invalid", "幂等重放用例证据无效。");
|
|
if (auditCase && (plan == null || record == null || transaction == null
|
|
|| audit == null || idempotency == null || mutationCount != 0
|
|
|| replayed || !confirmed || auditEvents < 2))
|
|
throw Error("write_integration_evidence_case_invalid", "审计关联用例证据无效。");
|
|
if (!commit && !replay && !auditCase && (mutationCount != 0 || replayed))
|
|
throw Error("write_integration_evidence_case_invalid", "阻断或只读用例不得产生业务写入或重放结果。");
|
|
if (!commit && !replay && !auditCase
|
|
&& (record != null || transaction != null || audit != null))
|
|
throw Error("write_integration_evidence_case_invalid", "阻断或只读用例不得声明业务记录、事务或业务审计 ID。");
|
|
if (postConfirmationBlock
|
|
&& (plan == null || idempotency == null || !confirmed))
|
|
throw Error("write_integration_evidence_case_invalid", "运行时阻断用例缺少计划、幂等键或原生确认证据。");
|
|
if (idempotencyConflict
|
|
&& (plan == null || idempotency == null || !confirmed
|
|
|| auditEvents < 2))
|
|
throw Error("write_integration_evidence_case_invalid", "幂等冲突用例缺少计划、幂等键、原生确认或完整命令审计证据。");
|
|
if (!postConfirmationBlock && !commit && !replay
|
|
&& !idempotencyConflict && !auditCase && confirmed)
|
|
throw Error("write_integration_evidence_case_invalid", "确认前阻断场景不应声明原生确认已发生。");
|
|
if (sourceProofRequired
|
|
&& (sourceDocumentSet == null || !sourceInputBound
|
|
|| !sourcePayloadBound || sourceAuditCount < 1
|
|
|| !sourcePreprocessContracts.Contains(
|
|
PurchaseSourceDocumentContract.PdfPreprocessContract,
|
|
StringComparer.Ordinal)))
|
|
throw Error("write_integration_evidence_case_invalid", "采购来源文件没有同时绑定电子 PDF 处理契约、输入指纹、写入载荷和审计证据。");
|
|
if (!sourceProofRequired
|
|
&& (sourceDocumentSet != null || sourceInputBound
|
|
|| sourcePayloadBound || sourceAuditCount != 0
|
|
|| sourcePreprocessContracts.Length != 0))
|
|
throw Error("write_integration_evidence_case_invalid", "非来源文件证明场景携带了不一致的来源证据。");
|
|
return new ValidatedCaseEvidence
|
|
{
|
|
Code = code,
|
|
CorrelationId = correlation,
|
|
InputFingerprintSha256 = RequiredHash(item, "inputFingerprintSha256"),
|
|
PlanFingerprintSha256 = plan,
|
|
RecordIdSha256 = record,
|
|
TransactionEvidenceIdSha256 = transaction,
|
|
BusinessAuditIdSha256 = audit,
|
|
IdempotencyKeySha256 = idempotency,
|
|
SourceDocumentSetSha256 = sourceDocumentSet,
|
|
SourceDocumentPreprocessContracts =
|
|
sourcePreprocessContracts,
|
|
SourceDocumentAuditCount = sourceAuditCount
|
|
};
|
|
}
|
|
|
|
private static bool PlanRequiredForCase(string caseCode)
|
|
{
|
|
return caseCode != "purchase_permission_denied"
|
|
&& caseCode != "leave_permission_denied"
|
|
&& caseCode != "leave_local_time_zone_rejected";
|
|
}
|
|
|
|
private static void ExpectedPlanContract(
|
|
string commandName,
|
|
out string version,
|
|
out string risk)
|
|
{
|
|
switch (commandName)
|
|
{
|
|
case "purchase.invoice.create":
|
|
version = "1.4";
|
|
risk = "write";
|
|
return;
|
|
case "hr.leave.resolve":
|
|
version = "1.4";
|
|
risk = "draft";
|
|
return;
|
|
case "hr.leave.create":
|
|
version = "1.2";
|
|
risk = "write";
|
|
return;
|
|
case "hr.leave.submit":
|
|
version = "1.0";
|
|
risk = "write";
|
|
return;
|
|
default:
|
|
throw Error(
|
|
"write_integration_evidence_case_invalid",
|
|
"写集成证据命令不属于固定工作流合同。");
|
|
}
|
|
}
|
|
|
|
private static void ValidateCaseRelationships(
|
|
string workflow,
|
|
IDictionary<string, ValidatedCaseEvidence> cases)
|
|
{
|
|
if (workflow == "purchase")
|
|
{
|
|
ValidatedCaseEvidence commit = cases["purchase_unique_match_commit"];
|
|
ValidatedCaseEvidence replay = cases["purchase_idempotency_replay"];
|
|
ValidatedCaseEvidence conflict = cases["purchase_idempotency_conflict"];
|
|
ValidatedCaseEvidence audit = cases["purchase_audit_correlated"];
|
|
RequireSameBusinessResult(commit, replay, true);
|
|
RequirePresentAndDifferent(
|
|
commit.PlanFingerprintSha256,
|
|
replay.PlanFingerprintSha256);
|
|
RequireSameBusinessResult(commit, audit, true);
|
|
RequireSame(
|
|
commit.PlanFingerprintSha256,
|
|
audit.PlanFingerprintSha256);
|
|
RequireSame(commit.CorrelationId, audit.CorrelationId);
|
|
RequireDifferent(commit.CorrelationId, replay.CorrelationId);
|
|
RequireSame(commit.IdempotencyKeySha256, conflict.IdempotencyKeySha256);
|
|
RequireDifferent(commit.CorrelationId, conflict.CorrelationId);
|
|
RequireDifferent(
|
|
commit.InputFingerprintSha256,
|
|
conflict.InputFingerprintSha256);
|
|
RequirePresentAndDifferent(
|
|
commit.PlanFingerprintSha256,
|
|
conflict.PlanFingerprintSha256);
|
|
return;
|
|
}
|
|
|
|
ValidatedCaseEvidence create = cases["leave_create_draft_commit"];
|
|
ValidatedCaseEvidence replayLeave = cases["leave_idempotency_replay"];
|
|
ValidatedCaseEvidence conflictLeave = cases["leave_idempotency_conflict"];
|
|
ValidatedCaseEvidence auditLeave = cases["leave_audit_correlated"];
|
|
ValidatedCaseEvidence submit = cases["leave_submit_separate_confirmation"];
|
|
RequireSameBusinessResult(create, replayLeave, false);
|
|
RequirePresentAndDifferent(
|
|
create.PlanFingerprintSha256,
|
|
replayLeave.PlanFingerprintSha256);
|
|
RequireSameBusinessResult(create, auditLeave, false);
|
|
RequireSame(
|
|
create.PlanFingerprintSha256,
|
|
auditLeave.PlanFingerprintSha256);
|
|
RequireSame(create.CorrelationId, auditLeave.CorrelationId);
|
|
RequireDifferent(create.CorrelationId, replayLeave.CorrelationId);
|
|
RequireSame(create.IdempotencyKeySha256, conflictLeave.IdempotencyKeySha256);
|
|
RequireDifferent(create.CorrelationId, conflictLeave.CorrelationId);
|
|
RequireDifferent(
|
|
create.InputFingerprintSha256,
|
|
conflictLeave.InputFingerprintSha256);
|
|
RequirePresentAndDifferent(
|
|
create.PlanFingerprintSha256,
|
|
conflictLeave.PlanFingerprintSha256);
|
|
RequireSame(create.RecordIdSha256, submit.RecordIdSha256);
|
|
RequireDifferent(create.CorrelationId, submit.CorrelationId);
|
|
RequireDifferent(
|
|
create.InputFingerprintSha256,
|
|
submit.InputFingerprintSha256);
|
|
RequirePresentAndDifferent(
|
|
create.PlanFingerprintSha256,
|
|
submit.PlanFingerprintSha256);
|
|
RequireDifferent(
|
|
create.TransactionEvidenceIdSha256,
|
|
submit.TransactionEvidenceIdSha256);
|
|
RequireDifferent(
|
|
create.BusinessAuditIdSha256,
|
|
submit.BusinessAuditIdSha256);
|
|
RequireDifferent(
|
|
create.IdempotencyKeySha256,
|
|
submit.IdempotencyKeySha256);
|
|
}
|
|
|
|
private static void RequireSameBusinessResult(
|
|
ValidatedCaseEvidence expected,
|
|
ValidatedCaseEvidence observed,
|
|
bool requireSourceDocuments)
|
|
{
|
|
RequireSame(
|
|
expected.InputFingerprintSha256,
|
|
observed.InputFingerprintSha256);
|
|
RequireSame(expected.RecordIdSha256, observed.RecordIdSha256);
|
|
RequireSame(
|
|
expected.TransactionEvidenceIdSha256,
|
|
observed.TransactionEvidenceIdSha256);
|
|
RequireSame(
|
|
expected.BusinessAuditIdSha256,
|
|
observed.BusinessAuditIdSha256);
|
|
RequireSame(
|
|
expected.IdempotencyKeySha256,
|
|
observed.IdempotencyKeySha256);
|
|
if (!requireSourceDocuments) return;
|
|
RequireSame(
|
|
expected.SourceDocumentSetSha256,
|
|
observed.SourceDocumentSetSha256);
|
|
if (!expected.SourceDocumentPreprocessContracts.SequenceEqual(
|
|
observed.SourceDocumentPreprocessContracts,
|
|
StringComparer.Ordinal))
|
|
throw RelationshipInvalid();
|
|
if (expected.SourceDocumentAuditCount != observed.SourceDocumentAuditCount)
|
|
throw RelationshipInvalid();
|
|
}
|
|
|
|
private static void RequireSame(string left, string right)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(left)
|
|
|| !string.Equals(left, right, StringComparison.Ordinal))
|
|
throw RelationshipInvalid();
|
|
}
|
|
|
|
private static void RequireDifferent(string left, string right)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(left)
|
|
|| string.IsNullOrWhiteSpace(right)
|
|
|| string.Equals(left, right, StringComparison.Ordinal))
|
|
throw RelationshipInvalid();
|
|
}
|
|
|
|
private static void RequirePresentAndDifferent(string left, string right)
|
|
{
|
|
RequireDifferent(left, right);
|
|
}
|
|
|
|
private static CommandKernelException RelationshipInvalid()
|
|
{
|
|
return Error(
|
|
"write_integration_evidence_relationship_invalid",
|
|
"写集成证据的提交、重放、冲突、审计或独立提交记录没有关联到同一业务事实。");
|
|
}
|
|
|
|
private sealed class ValidatedCaseEvidence
|
|
{
|
|
public string Code { get; set; }
|
|
public string CorrelationId { get; set; }
|
|
public string InputFingerprintSha256 { get; set; }
|
|
public string PlanFingerprintSha256 { get; set; }
|
|
public string RecordIdSha256 { get; set; }
|
|
public string TransactionEvidenceIdSha256 { get; set; }
|
|
public string BusinessAuditIdSha256 { get; set; }
|
|
public string IdempotencyKeySha256 { get; set; }
|
|
public string SourceDocumentSetSha256 { get; set; }
|
|
public string[] SourceDocumentPreprocessContracts { get; set; }
|
|
public int SourceDocumentAuditCount { get; set; }
|
|
}
|
|
|
|
private sealed class CaseScopeValidation
|
|
{
|
|
private readonly string _expectedModuleCode;
|
|
private readonly string _expectedAccountBookSha256;
|
|
private readonly string _expectedSubSystemIdSha256;
|
|
private readonly string _expectedUatAuthorizationSourceSha256;
|
|
private readonly string _expectedUatAuthorizationContentSha256;
|
|
private readonly string _expectedUatAuthorizationIdSha256;
|
|
private readonly string _expectedRuntimeCliVersion;
|
|
private readonly string _expectedRuntimeCliSha256;
|
|
private readonly string _expectedRuntimeCliSignerThumbprint;
|
|
private readonly HashSet<string> _modules = new HashSet<string>(
|
|
StringComparer.Ordinal);
|
|
private readonly HashSet<string> _accountBooks = new HashSet<string>(
|
|
StringComparer.Ordinal);
|
|
private readonly HashSet<string> _subSystems = new HashSet<string>(
|
|
StringComparer.Ordinal);
|
|
private readonly HashSet<string> _users = new HashSet<string>(
|
|
StringComparer.Ordinal);
|
|
private readonly HashSet<string> _userNames = new HashSet<string>(
|
|
StringComparer.Ordinal);
|
|
private readonly HashSet<string> _databaseScopes = new HashSet<string>(
|
|
StringComparer.Ordinal);
|
|
private readonly HashSet<bool> _administratorStates =
|
|
new HashSet<bool>();
|
|
private readonly HashSet<string> _uatSources = new HashSet<string>(
|
|
StringComparer.Ordinal);
|
|
private readonly HashSet<string> _uatContents = new HashSet<string>(
|
|
StringComparer.Ordinal);
|
|
private readonly HashSet<string> _uatAuthorizationIds =
|
|
new HashSet<string>(StringComparer.Ordinal);
|
|
private readonly HashSet<string> _uatTokens = new HashSet<string>(
|
|
StringComparer.Ordinal);
|
|
private readonly HashSet<string> _runtimeCliVersions =
|
|
new HashSet<string>(StringComparer.Ordinal);
|
|
private readonly HashSet<string> _runtimeCliHashes =
|
|
new HashSet<string>(StringComparer.Ordinal);
|
|
private readonly HashSet<string> _runtimeCliSigners =
|
|
new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
public CaseScopeValidation(
|
|
string expectedModuleCode,
|
|
string expectedAccountBookSha256,
|
|
string expectedSubSystemIdSha256,
|
|
string expectedUatAuthorizationSourceSha256,
|
|
string expectedUatAuthorizationContentSha256,
|
|
string expectedUatAuthorizationIdSha256,
|
|
string expectedRuntimeCliVersion,
|
|
string expectedRuntimeCliSha256,
|
|
string expectedRuntimeCliSignerThumbprint)
|
|
{
|
|
_expectedModuleCode = expectedModuleCode;
|
|
_expectedAccountBookSha256 = expectedAccountBookSha256;
|
|
_expectedSubSystemIdSha256 = expectedSubSystemIdSha256;
|
|
_expectedUatAuthorizationSourceSha256 =
|
|
expectedUatAuthorizationSourceSha256;
|
|
_expectedUatAuthorizationContentSha256 =
|
|
expectedUatAuthorizationContentSha256;
|
|
_expectedUatAuthorizationIdSha256 =
|
|
expectedUatAuthorizationIdSha256;
|
|
_expectedRuntimeCliVersion = expectedRuntimeCliVersion;
|
|
_expectedRuntimeCliSha256 = expectedRuntimeCliSha256;
|
|
_expectedRuntimeCliSignerThumbprint =
|
|
expectedRuntimeCliSignerThumbprint;
|
|
}
|
|
|
|
public void Observe(
|
|
string moduleCode,
|
|
string accountBookSha256,
|
|
string subSystemIdSha256,
|
|
string userIdSha256,
|
|
string userNameSha256,
|
|
string databaseScopeFingerprint,
|
|
bool isAdministrator,
|
|
string uatAuthorizationSourceSha256,
|
|
string uatAuthorizationContentSha256,
|
|
string uatAuthorizationIdSha256,
|
|
string uatTokenSha256,
|
|
string runtimeCliVersion,
|
|
string runtimeCliSha256,
|
|
string runtimeCliSignerThumbprint)
|
|
{
|
|
if (moduleCode != null)
|
|
{
|
|
if (_expectedModuleCode != null
|
|
&& !string.Equals(
|
|
moduleCode,
|
|
_expectedModuleCode,
|
|
StringComparison.Ordinal))
|
|
throw Error(
|
|
"write_integration_evidence_scope_mismatch",
|
|
"写集成证据用例计划模块与报告模块不一致。");
|
|
_modules.Add(moduleCode);
|
|
}
|
|
if (_expectedAccountBookSha256 != null
|
|
&& !string.Equals(
|
|
accountBookSha256,
|
|
_expectedAccountBookSha256,
|
|
StringComparison.Ordinal))
|
|
throw Error(
|
|
"write_integration_evidence_scope_mismatch",
|
|
"写集成证据用例账套与报告账套不一致。");
|
|
if (_expectedSubSystemIdSha256 != null
|
|
&& !string.Equals(
|
|
subSystemIdSha256,
|
|
_expectedSubSystemIdSha256,
|
|
StringComparison.Ordinal))
|
|
throw Error(
|
|
"write_integration_evidence_scope_mismatch",
|
|
"写集成证据用例子系统与报告子系统不一致。");
|
|
if ((_expectedUatAuthorizationSourceSha256 != null
|
|
&& !string.Equals(
|
|
uatAuthorizationSourceSha256,
|
|
_expectedUatAuthorizationSourceSha256,
|
|
StringComparison.Ordinal))
|
|
|| (_expectedUatAuthorizationContentSha256 != null
|
|
&& !string.Equals(
|
|
uatAuthorizationContentSha256,
|
|
_expectedUatAuthorizationContentSha256,
|
|
StringComparison.Ordinal))
|
|
|| (_expectedUatAuthorizationIdSha256 != null
|
|
&& !string.Equals(
|
|
uatAuthorizationIdSha256,
|
|
_expectedUatAuthorizationIdSha256,
|
|
StringComparison.Ordinal)))
|
|
throw Error(
|
|
"write_integration_evidence_scope_mismatch",
|
|
"写集成证据用例与报告声明的短时 UAT 授权不一致。");
|
|
if ((_expectedRuntimeCliVersion != null
|
|
&& !string.Equals(
|
|
runtimeCliVersion,
|
|
_expectedRuntimeCliVersion,
|
|
StringComparison.Ordinal))
|
|
|| (_expectedRuntimeCliSha256 != null
|
|
&& !string.Equals(
|
|
runtimeCliSha256,
|
|
_expectedRuntimeCliSha256,
|
|
StringComparison.Ordinal))
|
|
|| (_expectedRuntimeCliSignerThumbprint != null
|
|
&& !string.Equals(
|
|
runtimeCliSignerThumbprint,
|
|
_expectedRuntimeCliSignerThumbprint,
|
|
StringComparison.Ordinal)))
|
|
throw Error(
|
|
"write_integration_evidence_runtime_cli_mismatch",
|
|
"写集成证据用例与报告声明的运行 CLI 不一致。");
|
|
_accountBooks.Add(accountBookSha256);
|
|
_subSystems.Add(subSystemIdSha256);
|
|
_users.Add(userIdSha256);
|
|
_userNames.Add(userNameSha256);
|
|
_databaseScopes.Add(databaseScopeFingerprint);
|
|
_administratorStates.Add(isAdministrator);
|
|
_uatSources.Add(uatAuthorizationSourceSha256);
|
|
_uatContents.Add(uatAuthorizationContentSha256);
|
|
_uatAuthorizationIds.Add(uatAuthorizationIdSha256);
|
|
_runtimeCliVersions.Add(runtimeCliVersion);
|
|
_runtimeCliHashes.Add(runtimeCliSha256);
|
|
_runtimeCliSigners.Add(runtimeCliSignerThumbprint);
|
|
if (!_uatTokens.Add(uatTokenSha256))
|
|
throw Error(
|
|
"write_integration_evidence_scope_mismatch",
|
|
"每个 UAT 用例必须绑定不同的短时用例令牌摘要。");
|
|
}
|
|
|
|
public void ValidateComplete()
|
|
{
|
|
if (_modules.Count != 1
|
|
|| _accountBooks.Count != 1
|
|
|| _subSystems.Count != 1
|
|
|| _users.Count != 1
|
|
|| _userNames.Count != 1
|
|
|| _databaseScopes.Count != 1
|
|
|| _administratorStates.Count != 1
|
|
|| _uatSources.Count != 1
|
|
|| _uatContents.Count != 1
|
|
|| _uatAuthorizationIds.Count != 1
|
|
|| _runtimeCliVersions.Count != 1
|
|
|| _runtimeCliHashes.Count != 1
|
|
|| _runtimeCliSigners.Count != 1)
|
|
throw Error(
|
|
"write_integration_evidence_scope_mismatch",
|
|
"写集成证据用例不是来自同一模块、账套、子系统、ERP 用户、数据库作用域、短时 UAT 授权和运行 CLI。");
|
|
}
|
|
}
|
|
|
|
private static JObject LoadStrict(string path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
throw Error("write_integration_evidence_input_required", "请提供写集成证据文件。");
|
|
string full;
|
|
try { full = Path.GetFullPath(path); }
|
|
catch { throw Error("write_integration_evidence_input_invalid", "写集成证据路径无效。"); }
|
|
FileInfo file = new FileInfo(full);
|
|
if (!file.Exists) throw Error("write_integration_evidence_not_found", "写集成证据文件不存在。");
|
|
if (file.Length <= 0 || file.Length > MaximumBytes
|
|
|| (file.Attributes & FileAttributes.ReparsePoint) != 0)
|
|
throw Error("write_integration_evidence_size_invalid", "写集成证据必须是 4 MB 内的非空普通文件。");
|
|
try
|
|
{
|
|
using (FileStream stream = new FileStream(full, FileMode.Open, FileAccess.Read, FileShare.Read))
|
|
using (StreamReader text = new StreamReader(stream, new UTF8Encoding(false, true), true, 4096))
|
|
using (RejectCommentsJsonReader json = new RejectCommentsJsonReader(text))
|
|
{
|
|
json.DateParseHandling = DateParseHandling.None;
|
|
JObject value = JObject.Load(json, new JsonLoadSettings
|
|
{
|
|
DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error,
|
|
CommentHandling = CommentHandling.Ignore,
|
|
LineInfoHandling = LineInfoHandling.Ignore
|
|
});
|
|
if (json.Read()) throw Error("write_integration_evidence_json_invalid", "写集成证据包含多个 JSON 根值。");
|
|
return value;
|
|
}
|
|
}
|
|
catch (CommandKernelException) { throw; }
|
|
catch { throw Error("write_integration_evidence_json_invalid", "写集成证据不是严格 UTF-8 JSON 对象。"); }
|
|
}
|
|
|
|
private static void EnsureExact(JObject value, params string[] names)
|
|
{
|
|
if (value == null || value.Properties().Count() != names.Length
|
|
|| names.Any(name => value.Property(name, StringComparison.Ordinal) == null))
|
|
throw Error("write_integration_evidence_schema_invalid", "写集成证据包含缺失或未知字段。");
|
|
}
|
|
|
|
private static JObject RequiredObject(JObject source, string name)
|
|
{
|
|
JObject value = source[name] as JObject;
|
|
if (value == null) throw Error("write_integration_evidence_schema_invalid", "写集成证据对象字段无效。");
|
|
return value;
|
|
}
|
|
|
|
private static string RequiredString(JObject source, string name, int minimum, int maximum)
|
|
{
|
|
JToken token = source[name];
|
|
if (token == null || token.Type != JTokenType.String)
|
|
throw Error("write_integration_evidence_schema_invalid", "写集成证据文本字段无效。");
|
|
string value = ((string)token).Trim();
|
|
if (value.Length < minimum || value.Length > maximum)
|
|
throw Error("write_integration_evidence_schema_invalid", "写集成证据文本长度无效。");
|
|
return value;
|
|
}
|
|
|
|
private static string RequiredHash(JObject source, string name)
|
|
{
|
|
string value = RequiredString(source, name, 64, 64).ToLowerInvariant();
|
|
if (!Sha256Pattern.IsMatch(value))
|
|
throw Error("write_integration_evidence_schema_invalid", "写集成证据 SHA-256 字段无效。");
|
|
return value;
|
|
}
|
|
|
|
private static string NullableHash(JObject source, string name)
|
|
{
|
|
JToken token = source[name];
|
|
if (token == null || token.Type == JTokenType.Null) return null;
|
|
return RequiredHash(source, name);
|
|
}
|
|
|
|
private static string NullableIdentifier(
|
|
JObject source,
|
|
string name,
|
|
int minimum,
|
|
int maximum)
|
|
{
|
|
JToken token = source[name];
|
|
if (token == null || token.Type == JTokenType.Null) return null;
|
|
string value = RequiredString(source, name, minimum, maximum);
|
|
if (!SafeIdentifier.IsMatch(value))
|
|
throw Error(
|
|
"write_integration_evidence_schema_invalid",
|
|
"写集成证据标识字段无效。");
|
|
return value;
|
|
}
|
|
|
|
private static string NullableCode(JObject source, string name)
|
|
{
|
|
JToken token = source[name];
|
|
if (token == null || token.Type == JTokenType.Null) return null;
|
|
string value = RequiredString(source, name, 1, 128);
|
|
if (!SafeCode.IsMatch(value))
|
|
throw Error("write_integration_evidence_schema_invalid", "写集成证据问题码无效。");
|
|
return value;
|
|
}
|
|
|
|
private static string[] RequiredSourcePreprocessContracts(
|
|
JObject source,
|
|
string name)
|
|
{
|
|
JArray values = source[name] as JArray;
|
|
if (values == null || values.Count > 4)
|
|
throw Error(
|
|
"write_integration_evidence_schema_invalid",
|
|
"写集成证据来源预处理契约集合无效。");
|
|
HashSet<string> allowed = new HashSet<string>(
|
|
new[]
|
|
{
|
|
PurchaseSourceDocumentContract.ImagePreprocessContract,
|
|
PurchaseSourceDocumentContract.PdfPreprocessContract,
|
|
PurchaseSourceDocumentContract.CsvPreprocessContract,
|
|
PurchaseSourceDocumentContract.XlsxPreprocessContract
|
|
},
|
|
StringComparer.Ordinal);
|
|
List<string> result = new List<string>();
|
|
string previous = null;
|
|
foreach (JToken token in values)
|
|
{
|
|
string value = token != null && token.Type == JTokenType.String
|
|
? token.Value<string>()
|
|
: null;
|
|
if (value == null
|
|
|| !allowed.Contains(value)
|
|
|| (previous != null
|
|
&& string.CompareOrdinal(previous, value) >= 0))
|
|
throw Error(
|
|
"write_integration_evidence_schema_invalid",
|
|
"写集成证据来源预处理契约必须是有序、不重复的固定值。");
|
|
result.Add(value);
|
|
previous = value;
|
|
}
|
|
return result.ToArray();
|
|
}
|
|
|
|
private static bool RequiredBoolean(JObject source, string name)
|
|
{
|
|
JToken token = source[name];
|
|
if (token == null || token.Type != JTokenType.Boolean)
|
|
throw Error("write_integration_evidence_schema_invalid", "写集成证据布尔字段无效。");
|
|
return (bool)token;
|
|
}
|
|
|
|
private static int RequiredInteger(JObject source, string name, int minimum, int maximum)
|
|
{
|
|
JToken token = source[name];
|
|
int value;
|
|
if (token == null || token.Type != JTokenType.Integer
|
|
|| !int.TryParse(token.ToString(Formatting.None), NumberStyles.None,
|
|
CultureInfo.InvariantCulture, out value)
|
|
|| value < minimum || value > maximum)
|
|
throw Error("write_integration_evidence_schema_invalid", "写集成证据整数字段无效。");
|
|
return value;
|
|
}
|
|
|
|
private static DateTime RequiredUtc(JObject source, string name)
|
|
{
|
|
string text = RequiredString(source, name, 20, 40);
|
|
DateTime value;
|
|
if (!DateTime.TryParse(text, CultureInfo.InvariantCulture,
|
|
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out value)
|
|
|| value.Kind != DateTimeKind.Utc || !text.EndsWith("Z", StringComparison.OrdinalIgnoreCase))
|
|
throw Error("write_integration_evidence_schema_invalid", "写集成证据 UTC 时间字段无效。");
|
|
return value;
|
|
}
|
|
|
|
private static void RequireExpected(string actual, string expected, string field)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(expected)
|
|
&& !string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase))
|
|
throw Error("write_integration_evidence_scope_mismatch", "写集成证据与预期 " + field + " 不一致。");
|
|
}
|
|
|
|
private static string Sha256(string value)
|
|
{
|
|
using (SHA256 sha = SHA256.Create())
|
|
{
|
|
byte[] hash = sha.ComputeHash(new UTF8Encoding(false).GetBytes(value));
|
|
return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant();
|
|
}
|
|
}
|
|
|
|
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 result = base.Read();
|
|
if (result && TokenType == JsonToken.Comment)
|
|
throw Error("write_integration_evidence_json_invalid", "写集成证据不允许 JSON 注释。");
|
|
return result;
|
|
}
|
|
}
|
|
}
|
|
}
|