1547 lines
69 KiB
C#
1547 lines
69 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
|
|
{
|
|
/// <summary>
|
|
/// 把本地保存的 CLI 计划/执行响应投影成不含业务原值的单用例验收记录。
|
|
/// 原始响应仅作为输入读取;输出只保留稳定码、布尔/计数和不可逆 SHA-256。
|
|
/// </summary>
|
|
public static class WorkflowWriteCaseObservationProjector
|
|
{
|
|
private const int MaximumInputBytes = 4 * 1024 * 1024;
|
|
private const int MaximumOutputBytes = 128 * 1024;
|
|
private static readonly Regex SafeCaseCode = new Regex(
|
|
"^[a-z0-9_.-]{1,128}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeCommandName = new Regex(
|
|
"^[A-Za-z0-9_.:-]{1,128}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeCommandVersion = new Regex(
|
|
"^[0-9]{1,4}\\.[0-9]{1,4}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeRuntimeCliVersion = 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 Regex SafeModuleCode = new Regex(
|
|
"^[A-Za-z0-9_.:-]{1,64}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeCorrelationId = new Regex(
|
|
"^[A-Za-z0-9_.:-]{8,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 SafePlanId = new Regex(
|
|
"^[A-Fa-f0-9]{32}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeIdempotencyKey = new Regex(
|
|
"^[A-Za-z0-9_.:-]{8,128}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex Sha256Pattern = new Regex(
|
|
"^[a-f0-9]{64}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex SafeJsonFileName = new Regex(
|
|
"^[A-Za-z0-9][A-Za-z0-9_.-]{0,175}\\.json$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex MultiDayLeaveExpression = new Regex(
|
|
@"^(?:从)?(?<start>\d{4}-\d{1,2}-\d{1,2})(?:上午|下午|全天|全日|一天)(?:到|至)(?<end>\d{4}-\d{1,2}-\d{1,2})(?:上午|下午|全天|全日|一天)$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex LeaveResolutionProof = new Regex(
|
|
@"^lrp1\.[0-9]{1,19}\.[a-f0-9]{32}\.[a-f0-9]{64}\.[A-Za-z0-9_-]{40,64}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private static readonly Regex PurchaseResolutionProof = new Regex(
|
|
@"^rp1\.[0-9]{1,19}\.[a-f0-9]{32}\.[a-f0-9]{64}\.[A-Za-z0-9_-]{40,64}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
|
|
public static JObject ProjectFile(string inputPath, DateTime nowUtc)
|
|
{
|
|
return Project(LoadStrict(inputPath), nowUtc);
|
|
}
|
|
|
|
public static JObject VerifyProjectedCaseFile(
|
|
string inputPath,
|
|
DateTime nowUtc)
|
|
{
|
|
JObject projected = LoadStrict(inputPath);
|
|
WorkflowWriteIntegrationEvidenceVerifier.ValidateProjectedCase(
|
|
projected,
|
|
nowUtc);
|
|
return projected;
|
|
}
|
|
|
|
public static JObject ProjectReferencedFiles(
|
|
string indexPath,
|
|
DateTime nowUtc)
|
|
{
|
|
JObject index = LoadStrict(indexPath);
|
|
EnsureExact(
|
|
index,
|
|
"schemaVersion", "caseCode", "commandName", "commandInputFile",
|
|
"uatAuthorizationSourceSha256", "uatAuthorizationContentSha256",
|
|
"uatAuthorizationIdSha256", "uatAuthorizationIssuedAtUtc",
|
|
"uatAuthorizationExpiresAtUtc", "uatExecutionCaseCode",
|
|
"uatTokenSha256", "runtimeCliVersion", "runtimeCliSha256",
|
|
"runtimeCliSignerThumbprint",
|
|
"contextCliResponseFile", "planCliResponseFile",
|
|
"executeCliResponseFile", "idempotencyKey",
|
|
"businessMutationCount", "nativeConfirmationObserved",
|
|
"auditEventCount", "sourceDocumentWritePayloadBound",
|
|
"sourceDocumentAuditCount", "observedAtUtc");
|
|
if (RequiredString(index, "schemaVersion", 1, 16) != "1.3")
|
|
throw Error(
|
|
"write_case_file_index_schema_invalid",
|
|
"写用例文件索引版本无效。");
|
|
string fullIndex = Path.GetFullPath(indexPath);
|
|
string directory = Path.GetDirectoryName(fullIndex);
|
|
string commandInputFile = RequiredSiblingJsonFileName(
|
|
index,
|
|
"commandInputFile");
|
|
string contextFile = RequiredSiblingJsonFileName(
|
|
index,
|
|
"contextCliResponseFile");
|
|
string planFile = NullableSiblingJsonFileName(
|
|
index,
|
|
"planCliResponseFile");
|
|
string executeFile = NullableSiblingJsonFileName(
|
|
index,
|
|
"executeCliResponseFile");
|
|
if (planFile == null && executeFile == null)
|
|
throw Error(
|
|
"write_case_observation_response_required",
|
|
"写用例文件索引至少需要一份 CLI 响应。");
|
|
HashSet<string> references = new HashSet<string>(
|
|
StringComparer.OrdinalIgnoreCase);
|
|
foreach (string fileName in new[]
|
|
{
|
|
commandInputFile,
|
|
contextFile,
|
|
planFile,
|
|
executeFile
|
|
})
|
|
{
|
|
if (fileName != null && !references.Add(fileName))
|
|
throw Error(
|
|
"write_case_file_index_path_invalid",
|
|
"写用例文件索引不能重复引用同一文件。");
|
|
}
|
|
JObject source = new JObject
|
|
{
|
|
{ "schemaVersion", "1.3" },
|
|
{ "caseCode", index["caseCode"].DeepClone() },
|
|
{ "uatAuthorizationSourceSha256", index[
|
|
"uatAuthorizationSourceSha256"].DeepClone() },
|
|
{ "uatAuthorizationContentSha256", index[
|
|
"uatAuthorizationContentSha256"].DeepClone() },
|
|
{ "uatAuthorizationIdSha256", index[
|
|
"uatAuthorizationIdSha256"].DeepClone() },
|
|
{ "uatAuthorizationIssuedAtUtc", index[
|
|
"uatAuthorizationIssuedAtUtc"].DeepClone() },
|
|
{ "uatAuthorizationExpiresAtUtc", index[
|
|
"uatAuthorizationExpiresAtUtc"].DeepClone() },
|
|
{ "uatExecutionCaseCode", index[
|
|
"uatExecutionCaseCode"].DeepClone() },
|
|
{ "uatTokenSha256", index["uatTokenSha256"].DeepClone() },
|
|
{ "runtimeCliVersion", index[
|
|
"runtimeCliVersion"].DeepClone() },
|
|
{ "runtimeCliSha256", index[
|
|
"runtimeCliSha256"].DeepClone() },
|
|
{ "runtimeCliSignerThumbprint", index[
|
|
"runtimeCliSignerThumbprint"].DeepClone() },
|
|
{ "commandName", index["commandName"].DeepClone() },
|
|
{ "commandInput", LoadStrict(Path.Combine(
|
|
directory,
|
|
commandInputFile)) },
|
|
{ "contextCliResponse", LoadStrict(Path.Combine(
|
|
directory,
|
|
contextFile)) },
|
|
{ "planCliResponse", planFile == null
|
|
? JValue.CreateNull()
|
|
: (JToken)LoadStrict(Path.Combine(directory, planFile)) },
|
|
{ "executeCliResponse", executeFile == null
|
|
? JValue.CreateNull()
|
|
: (JToken)LoadStrict(Path.Combine(directory, executeFile)) },
|
|
{ "idempotencyKey", index["idempotencyKey"].DeepClone() },
|
|
{ "businessMutationCount", index["businessMutationCount"].DeepClone() },
|
|
{ "nativeConfirmationObserved", index["nativeConfirmationObserved"].DeepClone() },
|
|
{ "auditEventCount", index["auditEventCount"].DeepClone() },
|
|
{ "sourceDocumentWritePayloadBound", index[
|
|
"sourceDocumentWritePayloadBound"].DeepClone() },
|
|
{ "sourceDocumentAuditCount", index[
|
|
"sourceDocumentAuditCount"].DeepClone() },
|
|
{ "observedAtUtc", index["observedAtUtc"].DeepClone() }
|
|
};
|
|
return Project(source, nowUtc);
|
|
}
|
|
|
|
public static JArray AssembleCaseSetFile(
|
|
string indexPath,
|
|
DateTime nowUtc,
|
|
out string workflow)
|
|
{
|
|
JObject index = LoadStrict(indexPath);
|
|
EnsureExact(index, "schemaVersion", "workflow", "caseFiles");
|
|
if (RequiredString(index, "schemaVersion", 1, 16) != "1.0")
|
|
throw Error("write_case_index_schema_invalid", "写用例索引版本无效。");
|
|
workflow = RequiredString(index, "workflow", 1, 32);
|
|
string[] required =
|
|
WorkflowWriteIntegrationEvidenceVerifier
|
|
.RequiredCaseCodesForWorkflow(workflow);
|
|
JArray files = index["caseFiles"] as JArray;
|
|
if (files == null || files.Count != required.Length)
|
|
throw Error("write_case_index_coverage_incomplete", "写用例索引没有覆盖全部必测用例。");
|
|
string indexDirectory = Path.GetDirectoryName(
|
|
Path.GetFullPath(indexPath));
|
|
Dictionary<string, JObject> observed =
|
|
new Dictionary<string, JObject>(StringComparer.Ordinal);
|
|
HashSet<string> fileNames = new HashSet<string>(
|
|
StringComparer.OrdinalIgnoreCase);
|
|
foreach (JToken token in files)
|
|
{
|
|
if (token == null || token.Type != JTokenType.String)
|
|
throw Error("write_case_index_schema_invalid", "写用例索引文件名必须是字符串。");
|
|
string fileName = ((string)token).Trim();
|
|
if (!SafeJsonFileName.IsMatch(fileName)
|
|
|| !fileNames.Add(fileName)
|
|
|| !string.Equals(
|
|
Path.GetFileName(fileName),
|
|
fileName,
|
|
StringComparison.Ordinal))
|
|
throw Error("write_case_index_path_invalid", "写用例索引只能引用同目录下的不重复 JSON 文件。");
|
|
JObject item = LoadStrict(Path.Combine(indexDirectory, fileName));
|
|
WorkflowWriteIntegrationEvidenceVerifier.ValidateProjectedCase(
|
|
item,
|
|
nowUtc);
|
|
string caseCode = RequiredString(item, "caseCode", 1, 128);
|
|
if (!required.Contains(caseCode, StringComparer.Ordinal)
|
|
|| observed.ContainsKey(caseCode))
|
|
throw Error("write_case_index_coverage_incomplete", "写用例索引包含重复或错误工作流用例。");
|
|
observed.Add(caseCode, item);
|
|
}
|
|
if (observed.Count != required.Length)
|
|
throw Error("write_case_index_coverage_incomplete", "写用例索引没有覆盖全部必测用例。");
|
|
JArray ordered = new JArray();
|
|
foreach (string caseCode in required)
|
|
ordered.Add(observed[caseCode].DeepClone());
|
|
WorkflowWriteIntegrationEvidenceVerifier.ValidateProjectedCaseSet(
|
|
workflow,
|
|
ordered,
|
|
nowUtc);
|
|
return ordered;
|
|
}
|
|
|
|
internal static JObject Project(JObject source, DateTime nowUtc)
|
|
{
|
|
EnsureExact(
|
|
source,
|
|
"schemaVersion", "caseCode", "commandName", "commandInput",
|
|
"uatAuthorizationSourceSha256", "uatAuthorizationContentSha256",
|
|
"uatAuthorizationIdSha256", "uatAuthorizationIssuedAtUtc",
|
|
"uatAuthorizationExpiresAtUtc", "uatExecutionCaseCode",
|
|
"uatTokenSha256", "runtimeCliVersion", "runtimeCliSha256",
|
|
"runtimeCliSignerThumbprint",
|
|
"contextCliResponse", "planCliResponse", "executeCliResponse",
|
|
"idempotencyKey",
|
|
"businessMutationCount", "nativeConfirmationObserved",
|
|
"auditEventCount", "sourceDocumentWritePayloadBound",
|
|
"sourceDocumentAuditCount", "observedAtUtc");
|
|
if (RequiredString(source, "schemaVersion", 1, 16) != "1.3")
|
|
throw Error("write_case_observation_schema_invalid", "写用例观察清单版本无效。");
|
|
string caseCode = RequiredString(source, "caseCode", 1, 128);
|
|
string commandName = RequiredString(source, "commandName", 1, 128);
|
|
if (!SafeCaseCode.IsMatch(caseCode)
|
|
|| WorkflowWriteIntegrationEvidenceVerifier.ExpectedResultCodeForCase(
|
|
caseCode) == null)
|
|
throw Error("write_case_observation_case_invalid", "写用例观察清单包含未知用例。");
|
|
if (!SafeCommandName.IsMatch(commandName))
|
|
throw Error("write_case_observation_command_invalid", "写用例观察清单命令名无效。");
|
|
string uatSourceHash = RequiredHash(
|
|
source,
|
|
"uatAuthorizationSourceSha256");
|
|
string uatContentHash = RequiredHash(
|
|
source,
|
|
"uatAuthorizationContentSha256");
|
|
string uatAuthorizationIdHash = RequiredHash(
|
|
source,
|
|
"uatAuthorizationIdSha256");
|
|
string uatTokenHash = RequiredHash(source, "uatTokenSha256");
|
|
string runtimeCliVersion = RequiredString(
|
|
source,
|
|
"runtimeCliVersion",
|
|
5,
|
|
14);
|
|
string runtimeCliHash = RequiredHash(source, "runtimeCliSha256");
|
|
string runtimeCliSigner = RequiredString(
|
|
source,
|
|
"runtimeCliSignerThumbprint",
|
|
40,
|
|
40);
|
|
if (!SafeRuntimeCliVersion.IsMatch(runtimeCliVersion)
|
|
|| !SignerThumbprintPattern.IsMatch(runtimeCliSigner))
|
|
throw Error(
|
|
"write_case_observation_runtime_cli_invalid",
|
|
"写用例没有绑定有效的运行 CLI 版本、哈希和签名。" );
|
|
string uatExecutionCaseCode = RequiredString(
|
|
source,
|
|
"uatExecutionCaseCode",
|
|
1,
|
|
128);
|
|
DateTime uatIssuedAt = RequiredUtc(
|
|
source,
|
|
"uatAuthorizationIssuedAtUtc");
|
|
DateTime uatExpiresAt = RequiredUtc(
|
|
source,
|
|
"uatAuthorizationExpiresAtUtc");
|
|
DateTime observedAt = RequiredUtc(source, "observedAtUtc");
|
|
if (!UatExecutionCaseMatches(caseCode, uatExecutionCaseCode)
|
|
|| uatExpiresAt <= uatIssuedAt
|
|
|| uatExpiresAt - uatIssuedAt
|
|
> WorkflowUatAuthorizationVerifier.MaximumLifetime
|
|
|| observedAt > uatExpiresAt.AddMinutes(5)
|
|
|| observedAt < uatIssuedAt.AddMinutes(-5))
|
|
throw Error(
|
|
"write_case_observation_uat_binding_invalid",
|
|
"写用例没有绑定同一短时 UAT 授权、令牌与有效期。");
|
|
JObject commandInput = RequiredObject(source, "commandInput");
|
|
ValidateCaseCommandInput(caseCode, commandName, commandInput);
|
|
string inputFingerprint = CommandInputFingerprint.Create(
|
|
commandName,
|
|
commandInput.ToObject<Dictionary<string, object>>());
|
|
|
|
ContextProjection context = ParseContext(
|
|
source["contextCliResponse"]);
|
|
|
|
CliObservation planResponse = ParseNullableCliResponse(
|
|
source["planCliResponse"],
|
|
"plan");
|
|
CliObservation executeResponse = ParseNullableCliResponse(
|
|
source["executeCliResponse"],
|
|
"execute");
|
|
if (planResponse == null && executeResponse == null)
|
|
throw Error("write_case_observation_response_required", "写用例观察清单至少需要一份 CLI 响应。");
|
|
string operationCorrelation = executeResponse != null
|
|
? executeResponse.CorrelationId
|
|
: planResponse.CorrelationId;
|
|
if (!string.Equals(
|
|
context.CorrelationId,
|
|
operationCorrelation,
|
|
StringComparison.Ordinal))
|
|
throw Error(
|
|
"write_case_observation_context_mismatch",
|
|
"ERP 上下文、计划和执行响应必须使用同一个关联 ID。");
|
|
if (executeResponse != null
|
|
&& (planResponse == null || !planResponse.Ok))
|
|
throw Error("write_case_observation_response_invalid", "执行观察必须绑定成功返回的计划响应。");
|
|
if (executeResponse != null
|
|
&& !string.Equals(
|
|
planResponse.CorrelationId,
|
|
executeResponse.CorrelationId,
|
|
StringComparison.Ordinal))
|
|
throw Error(
|
|
"write_case_observation_response_invalid",
|
|
"计划与执行 CLI 响应必须使用同一个关联 ID。");
|
|
|
|
PlanProjection plan = null;
|
|
if (planResponse != null && planResponse.Ok)
|
|
{
|
|
plan = ParsePlan(
|
|
planResponse.Data,
|
|
commandName,
|
|
inputFingerprint,
|
|
observedAt);
|
|
ValidateCasePlan(caseCode, plan, commandInput);
|
|
}
|
|
if (executeResponse != null
|
|
&& (plan == null || !plan.Valid || !plan.ExecutionAllowed))
|
|
throw Error(
|
|
"write_case_observation_response_invalid",
|
|
"执行观察必须绑定有效且允许执行的写计划。");
|
|
if (executeResponse == null && plan != null)
|
|
{
|
|
bool successfulResolution =
|
|
caseCode == "leave_natural_language_resolution"
|
|
|| caseCode == "leave_multi_day_calendar_resolution";
|
|
if ((successfulResolution
|
|
&& (!plan.Valid || plan.ExecutionAllowed))
|
|
|| (!successfulResolution
|
|
&& (plan.Valid || plan.ExecutionAllowed)))
|
|
throw Error(
|
|
"write_case_observation_plan_invalid",
|
|
"计划有效性与固定验收场景不一致。");
|
|
}
|
|
ResultProjection execution = null;
|
|
if (executeResponse != null && executeResponse.Ok)
|
|
execution = ParseExecution(executeResponse.Data);
|
|
if (execution != null
|
|
&& IsPurchaseSourceProofCase(caseCode)
|
|
&& !execution.Success)
|
|
throw Error(
|
|
"write_case_observation_execution_invalid",
|
|
"采购成功验收用例的业务执行结果必须明确成功。");
|
|
|
|
string resultCode = executeResponse != null
|
|
? executeResponse.Ok ? execution.Code : executeResponse.Code
|
|
: planResponse.Ok ? plan.OutcomeCode : planResponse.Code;
|
|
string expectedResult =
|
|
WorkflowWriteIntegrationEvidenceVerifier.ExpectedResultCodeForCase(
|
|
caseCode);
|
|
if (!string.Equals(resultCode, expectedResult, StringComparison.Ordinal))
|
|
throw Error(
|
|
"write_case_observation_result_mismatch",
|
|
"CLI 观察结果与该固定验收用例的稳定码不一致。");
|
|
|
|
string expectedIssue =
|
|
WorkflowWriteIntegrationEvidenceVerifier.ExpectedIssueCodeForCase(
|
|
caseCode);
|
|
string issueCode = ExtractIssueCode(plan, expectedIssue);
|
|
string idempotencyKey = NullableString(
|
|
source,
|
|
"idempotencyKey",
|
|
8,
|
|
128);
|
|
if (idempotencyKey != null
|
|
&& !SafeIdempotencyKey.IsMatch(idempotencyKey))
|
|
throw Error("write_case_observation_idempotency_invalid", "写用例观察清单幂等键格式无效。");
|
|
|
|
bool sourceProofRequired = IsPurchaseSourceProofCase(caseCode);
|
|
SourceDocumentEvidence sourceEvidence =
|
|
ReadSourceDocumentEvidence(commandInput);
|
|
IList<string> sourceHashes = sourceEvidence.Hashes;
|
|
string sourceSet = null;
|
|
bool sourceInputBound = false;
|
|
if (sourceProofRequired)
|
|
{
|
|
if (sourceHashes.Count == 0
|
|
|| plan == null
|
|
|| !sourceEvidence.PreprocessContracts.Contains(
|
|
PurchaseSourceDocumentContract.PdfPreprocessContract,
|
|
StringComparer.Ordinal))
|
|
throw Error(
|
|
"write_case_observation_source_invalid",
|
|
"采购来源证明用例缺少受信任电子 PDF、处理契约或计划输入绑定。");
|
|
sourceSet = Sha256(string.Join("\n", sourceHashes.ToArray()));
|
|
sourceInputBound = true;
|
|
}
|
|
|
|
JObject projected = new JObject
|
|
{
|
|
{ "caseCode", caseCode },
|
|
{ "uatAuthorizationSourceSha256", uatSourceHash },
|
|
{ "uatAuthorizationContentSha256", uatContentHash },
|
|
{ "uatAuthorizationIdSha256", uatAuthorizationIdHash },
|
|
{ "uatTokenSha256", uatTokenHash },
|
|
{ "runtimeCliVersion", runtimeCliVersion },
|
|
{ "runtimeCliSha256", runtimeCliHash },
|
|
{ "runtimeCliSignerThumbprint", runtimeCliSigner },
|
|
{ "passed", true },
|
|
{ "correlationId", operationCorrelation },
|
|
{ "contextCorrelationBound", true },
|
|
{ "commandName", commandName },
|
|
{ "planCommandVersion", plan == null
|
|
? JValue.CreateNull()
|
|
: new JValue(plan.CommandVersion) },
|
|
{ "planModuleCode", plan == null
|
|
? JValue.CreateNull()
|
|
: new JValue(plan.ModuleCode) },
|
|
{ "planRisk", plan == null
|
|
? JValue.CreateNull()
|
|
: new JValue(plan.Risk) },
|
|
{ "accountBookSha256", Sha256(context.AccountBook) },
|
|
{ "subSystemIdSha256", Sha256(context.SubSystemId) },
|
|
{ "userIdSha256", Sha256(context.UserId) },
|
|
{ "userNameSha256", Sha256(context.UserName) },
|
|
{ "databaseScopeFingerprint",
|
|
context.DatabaseScopeFingerprint },
|
|
{ "isAdministrator", context.IsAdministrator },
|
|
{ "inputFingerprintSha256", inputFingerprint },
|
|
{ "planFingerprintSha256", plan == null
|
|
? JValue.CreateNull()
|
|
: new JValue(Sha256(plan.PlanId)) },
|
|
{ "resultCode", resultCode },
|
|
{ "issueCode", issueCode == null
|
|
? JValue.CreateNull()
|
|
: new JValue(issueCode) },
|
|
{ "recordIdSha256", HashNullable(execution == null
|
|
? null : execution.RecordId) },
|
|
{ "transactionEvidenceIdSha256", HashNullable(execution == null
|
|
? null : execution.TransactionEvidenceId) },
|
|
{ "businessAuditIdSha256", HashNullable(execution == null
|
|
? null : execution.BusinessAuditId) },
|
|
{ "idempotencyKeySha256", HashNullable(idempotencyKey) },
|
|
{ "businessMutationCount", RequiredInteger(
|
|
source, "businessMutationCount", 0, 1000) },
|
|
{ "replayed", execution != null && execution.Replayed },
|
|
{ "nativeConfirmationObserved", RequiredBoolean(
|
|
source, "nativeConfirmationObserved") },
|
|
{ "auditEventCount", RequiredInteger(
|
|
source, "auditEventCount", 1, 1000) },
|
|
{ "sourceDocumentSetSha256", sourceSet == null
|
|
? JValue.CreateNull()
|
|
: new JValue(sourceSet) },
|
|
{ "sourceDocumentPreprocessContracts", sourceProofRequired
|
|
? new JArray(sourceEvidence.PreprocessContracts)
|
|
: new JArray() },
|
|
{ "sourceDocumentInputFingerprintBound", sourceInputBound },
|
|
{ "sourceDocumentWritePayloadBound", RequiredBoolean(
|
|
source, "sourceDocumentWritePayloadBound") },
|
|
{ "sourceDocumentAuditCount", RequiredInteger(
|
|
source, "sourceDocumentAuditCount", 0, 1000) },
|
|
{ "observedAtUtc", observedAt.ToUniversalTime().ToString(
|
|
"yyyy-MM-dd'T'HH:mm:ss.fff'Z'",
|
|
CultureInfo.InvariantCulture) }
|
|
};
|
|
WorkflowWriteIntegrationEvidenceVerifier.ValidateProjectedCase(
|
|
projected,
|
|
nowUtc);
|
|
return projected;
|
|
}
|
|
|
|
private static bool UatExecutionCaseMatches(
|
|
string evidenceCaseCode,
|
|
string executionCaseCode)
|
|
{
|
|
if (string.Equals(
|
|
evidenceCaseCode,
|
|
executionCaseCode,
|
|
StringComparison.Ordinal))
|
|
return true;
|
|
return (evidenceCaseCode == "purchase_audit_correlated"
|
|
&& executionCaseCode == "purchase_unique_match_commit")
|
|
|| (evidenceCaseCode == "leave_audit_correlated"
|
|
&& executionCaseCode == "leave_create_draft_commit");
|
|
}
|
|
|
|
private static void ValidateCaseCommandInput(
|
|
string caseCode,
|
|
string commandName,
|
|
JObject commandInput)
|
|
{
|
|
string expectedCommand =
|
|
WorkflowWriteIntegrationEvidenceVerifier
|
|
.ExpectedCommandNameForCase(caseCode);
|
|
if (!string.Equals(
|
|
commandName,
|
|
expectedCommand,
|
|
StringComparison.Ordinal))
|
|
throw Error(
|
|
"write_case_observation_case_invalid",
|
|
"写验收用例调用的命令与固定场景不一致。");
|
|
if (IsPurchaseSourceProofCase(caseCode))
|
|
{
|
|
SourceDocumentEvidence sourceEvidence =
|
|
ReadSourceDocumentEvidence(commandInput);
|
|
if (sourceEvidence.Hashes.Count == 0
|
|
|| !sourceEvidence.PreprocessContracts.Contains(
|
|
PurchaseSourceDocumentContract.PdfPreprocessContract,
|
|
StringComparer.Ordinal))
|
|
throw Error(
|
|
"write_case_observation_source_invalid",
|
|
"采购成功验收用例必须绑定至少一份受信任电子 PDF。");
|
|
try
|
|
{
|
|
CommandInputSchemaValidator.Validate(
|
|
CommandInputSchemas.PurchaseInvoiceCreate(),
|
|
commandInput.ToObject<Dictionary<string, object>>());
|
|
}
|
|
catch (CommandKernelException)
|
|
{
|
|
throw Error(
|
|
"write_case_observation_case_invalid",
|
|
"采购成功验收输入不符合创建命令 Schema。");
|
|
}
|
|
string proof = commandInput.Value<string>("resolutionProof");
|
|
if (!PurchaseResolutionProof.IsMatch(proof ?? string.Empty))
|
|
throw Error(
|
|
"write_case_observation_case_invalid",
|
|
"采购成功验收输入缺少服务器短期解析凭证。");
|
|
return;
|
|
}
|
|
|
|
bool successfulResolution =
|
|
caseCode == "leave_natural_language_resolution"
|
|
|| caseCode == "leave_multi_day_calendar_resolution";
|
|
if (!successfulResolution) return;
|
|
try
|
|
{
|
|
CommandInputSchemaValidator.Validate(
|
|
CommandInputSchemas.LeaveResolve(),
|
|
commandInput.ToObject<Dictionary<string, object>>());
|
|
}
|
|
catch (CommandKernelException)
|
|
{
|
|
throw Error(
|
|
"write_case_observation_case_invalid",
|
|
"自然语言请假验收输入不符合命令 Schema。");
|
|
}
|
|
if (caseCode != "leave_multi_day_calendar_resolution") return;
|
|
DateTime startDate;
|
|
DateTime endDate;
|
|
if (!TryReadMultiDayDates(
|
|
commandInput.Value<string>("dateExpression"),
|
|
out startDate,
|
|
out endDate)
|
|
|| commandInput.Value<decimal?>("requestedHours") <= 0m)
|
|
{
|
|
throw Error(
|
|
"write_case_observation_case_invalid",
|
|
"多日请假验收必须使用两个绝对日期、两端明确时段和已知的日历总工时断言。");
|
|
}
|
|
}
|
|
|
|
private static void ValidateCasePlan(
|
|
string caseCode,
|
|
PlanProjection plan,
|
|
JObject commandInput)
|
|
{
|
|
if (IsPurchaseSourceProofCase(caseCode))
|
|
{
|
|
IList<string> sourceHashes = ReadSourceDocumentEvidence(
|
|
commandInput).Hashes;
|
|
JToken countToken = plan == null
|
|
? null
|
|
: plan.Data["sourceDocumentCount"];
|
|
int sourceCount;
|
|
string sourceSet = plan == null
|
|
? null
|
|
: RawString(plan.Data, "sourceDocumentSetSha256");
|
|
string expectedSet = Sha256(string.Join(
|
|
"\n",
|
|
sourceHashes.ToArray()));
|
|
if (plan == null
|
|
|| !plan.Valid
|
|
|| !plan.ExecutionAllowed
|
|
|| plan.OutcomeCode != "purchase_create_ready"
|
|
|| countToken == null
|
|
|| countToken.Type != JTokenType.Integer
|
|
|| !int.TryParse(
|
|
countToken.ToString(Formatting.None),
|
|
NumberStyles.None,
|
|
CultureInfo.InvariantCulture,
|
|
out sourceCount)
|
|
|| sourceCount != sourceHashes.Count
|
|
|| sourceCount <= 0
|
|
|| !string.Equals(
|
|
sourceSet,
|
|
expectedSet,
|
|
StringComparison.Ordinal))
|
|
{
|
|
throw Error(
|
|
"write_case_observation_plan_invalid",
|
|
"采购成功验收计划没有绑定完整来源附件集合。");
|
|
}
|
|
return;
|
|
}
|
|
|
|
bool multiDay = caseCode == "leave_multi_day_calendar_resolution";
|
|
if (!multiDay && caseCode != "leave_natural_language_resolution")
|
|
return;
|
|
JObject resolved = plan == null
|
|
? null
|
|
: plan.Data["resolvedInput"] as JObject;
|
|
DateTime startLocal;
|
|
DateTime endLocal;
|
|
DateTime expectedStartDate;
|
|
DateTime expectedEndDate;
|
|
decimal? expectedHours = commandInput == null
|
|
? null
|
|
: commandInput.Value<decimal?>("requestedHours");
|
|
if (plan == null
|
|
|| !plan.Valid
|
|
|| plan.ExecutionAllowed
|
|
|| plan.OutcomeCode != "leave_intent_resolved"
|
|
|| plan.Data.Value<string>("resolvedCommand") != "hr.leave.create"
|
|
|| plan.Data.Value<bool?>("requiresFollowupPlan") != true
|
|
|| resolved == null)
|
|
{
|
|
throw Error(
|
|
"write_case_observation_plan_invalid",
|
|
"自然语言请假验收没有返回唯一的创建续接输入。");
|
|
}
|
|
try
|
|
{
|
|
CommandInputSchemaValidator.Validate(
|
|
CommandInputSchemas.LeaveCreate(),
|
|
resolved.ToObject<Dictionary<string, object>>());
|
|
}
|
|
catch (CommandKernelException)
|
|
{
|
|
throw Error(
|
|
"write_case_observation_plan_invalid",
|
|
"自然语言请假续接输入不符合创建命令 Schema。");
|
|
}
|
|
string proof = resolved.Value<string>("resolutionProof");
|
|
if (!LeaveResolutionProof.IsMatch(proof ?? string.Empty))
|
|
{
|
|
throw Error(
|
|
"write_case_observation_plan_invalid",
|
|
"自然语言请假续接结果缺少服务器短期解析凭证。");
|
|
}
|
|
if (!multiDay)
|
|
{
|
|
if (!TryReadLocalDateTime(
|
|
resolved.Value<string>("startLocal"),
|
|
out startLocal)
|
|
|| !TryReadLocalDateTime(
|
|
resolved.Value<string>("endLocal"),
|
|
out endLocal)
|
|
|| startLocal.Date != endLocal.Date
|
|
|| endLocal <= startLocal
|
|
|| resolved.Value<decimal?>("requestedHours") <= 0m)
|
|
{
|
|
throw Error(
|
|
"write_case_observation_plan_invalid",
|
|
"单日自然语言请假续接结果不是有效的员工日历范围。");
|
|
}
|
|
return;
|
|
}
|
|
if (!TryReadLocalDateTime(
|
|
resolved.Value<string>("startLocal"),
|
|
out startLocal)
|
|
|| !TryReadLocalDateTime(
|
|
resolved.Value<string>("endLocal"),
|
|
out endLocal)
|
|
|| !TryReadMultiDayDates(
|
|
commandInput.Value<string>("dateExpression"),
|
|
out expectedStartDate,
|
|
out expectedEndDate)
|
|
|| startLocal.Date != expectedStartDate.Date
|
|
|| endLocal.Date != expectedEndDate.Date
|
|
|| endLocal.Date <= startLocal.Date
|
|
|| expectedHours <= 0m
|
|
|| resolved.Value<decimal?>("requestedHours") != expectedHours)
|
|
{
|
|
throw Error(
|
|
"write_case_observation_plan_invalid",
|
|
"多日请假续接结果不是与测试断言一致的跨日日历范围。");
|
|
}
|
|
}
|
|
|
|
private static bool TryReadMultiDayDates(
|
|
string expression,
|
|
out DateTime startDate,
|
|
out DateTime endDate)
|
|
{
|
|
startDate = DateTime.MinValue;
|
|
endDate = DateTime.MinValue;
|
|
string normalized = Regex.Replace(
|
|
expression ?? string.Empty,
|
|
@"[\s的]+",
|
|
string.Empty);
|
|
Match match = MultiDayLeaveExpression.Match(normalized);
|
|
return match.Success
|
|
&& DateTime.TryParseExact(
|
|
match.Groups["start"].Value,
|
|
new[] { "yyyy-M-d", "yyyy-MM-dd" },
|
|
CultureInfo.InvariantCulture,
|
|
DateTimeStyles.None,
|
|
out startDate)
|
|
&& DateTime.TryParseExact(
|
|
match.Groups["end"].Value,
|
|
new[] { "yyyy-M-d", "yyyy-MM-dd" },
|
|
CultureInfo.InvariantCulture,
|
|
DateTimeStyles.None,
|
|
out endDate)
|
|
&& endDate.Date > startDate.Date;
|
|
}
|
|
|
|
private static bool IsPurchaseSourceProofCase(string caseCode)
|
|
{
|
|
return caseCode == "purchase_unique_match_commit"
|
|
|| caseCode == "purchase_idempotency_replay"
|
|
|| caseCode == "purchase_audit_correlated";
|
|
}
|
|
|
|
private static bool TryReadLocalDateTime(
|
|
string value,
|
|
out DateTime result)
|
|
{
|
|
return DateTime.TryParseExact(
|
|
value,
|
|
"yyyy-MM-dd'T'HH:mm:ss",
|
|
CultureInfo.InvariantCulture,
|
|
DateTimeStyles.None,
|
|
out result)
|
|
&& result.Kind == DateTimeKind.Unspecified;
|
|
}
|
|
|
|
public static string WriteNewFile(JObject projected, string outputPath)
|
|
{
|
|
if (projected == null)
|
|
throw new ArgumentNullException("projected");
|
|
return WriteNewToken(projected, outputPath);
|
|
}
|
|
|
|
public static string WriteNewCaseSetFile(
|
|
JArray cases,
|
|
string outputPath)
|
|
{
|
|
if (cases == null) throw new ArgumentNullException("cases");
|
|
return WriteNewToken(cases, outputPath);
|
|
}
|
|
|
|
private static string WriteNewToken(JToken value, string outputPath)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(outputPath))
|
|
throw Error("write_case_observation_output_required", "请提供新的写用例证据输出文件。");
|
|
try
|
|
{
|
|
string full = Path.GetFullPath(outputPath);
|
|
string directory = Path.GetDirectoryName(full);
|
|
if (string.IsNullOrWhiteSpace(directory)
|
|
|| !Directory.Exists(directory))
|
|
throw Error("write_case_observation_output_invalid", "写用例证据输出目录不存在。");
|
|
byte[] body = new UTF8Encoding(false, true).GetBytes(
|
|
value.ToString(Formatting.Indented));
|
|
if (body.Length <= 0 || body.Length > MaximumOutputBytes)
|
|
throw Error("write_case_observation_output_invalid", "写用例证据输出大小无效。");
|
|
using (FileStream stream = new FileStream(
|
|
full,
|
|
FileMode.CreateNew,
|
|
FileAccess.Write,
|
|
FileShare.None))
|
|
{
|
|
stream.Write(body, 0, body.Length);
|
|
stream.Flush();
|
|
}
|
|
return full;
|
|
}
|
|
catch (CommandKernelException) { throw; }
|
|
catch (IOException)
|
|
{
|
|
throw Error(
|
|
"write_case_observation_output_exists",
|
|
"无法新建写用例证据;命令不会覆盖已有文件。");
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
throw Error("write_case_observation_output_denied", "当前用户无权创建写用例证据。");
|
|
}
|
|
catch
|
|
{
|
|
throw Error("write_case_observation_output_invalid", "写用例证据输出路径无效。");
|
|
}
|
|
}
|
|
|
|
private static ContextProjection ParseContext(JToken token)
|
|
{
|
|
CliObservation response = ParseNullableCliResponse(
|
|
token,
|
|
"context");
|
|
if (response == null || !response.Ok)
|
|
throw Error(
|
|
"write_case_observation_context_invalid",
|
|
"写用例必须绑定成功返回的 ERP 上下文响应。");
|
|
JObject data = response.Data;
|
|
EnsureExact(
|
|
data,
|
|
"userId", "userName", "accountBook", "subSystemId",
|
|
"databaseScopeFingerprint", "subSystemName",
|
|
"isAdministrator", "activeModule",
|
|
"openModuleCount", "openModulesTruncated", "openModules");
|
|
string userId = RequiredContextIdentifier(data, "userId");
|
|
string userName = RequiredContextText(data, "userName", 500);
|
|
string accountBook = RequiredContextText(
|
|
data,
|
|
"accountBook",
|
|
500);
|
|
string subSystemId = RequiredContextIdentifier(
|
|
data,
|
|
"subSystemId");
|
|
string databaseScopeFingerprint = RequiredContextText(
|
|
data,
|
|
"databaseScopeFingerprint",
|
|
64);
|
|
if (!CommandInputFingerprint.IsValid(
|
|
databaseScopeFingerprint))
|
|
throw Error(
|
|
"write_case_observation_context_invalid",
|
|
"ERP 数据库作用域指纹格式无效。");
|
|
RequiredContextText(data, "subSystemName", 500);
|
|
bool isAdministrator = RequiredBoolean(data, "isAdministrator");
|
|
int openCount = RequiredInteger(
|
|
data,
|
|
"openModuleCount",
|
|
0,
|
|
100000);
|
|
bool truncated = RequiredBoolean(data, "openModulesTruncated");
|
|
JArray openModules = data["openModules"] as JArray;
|
|
if (openModules == null
|
|
|| openModules.Count > 50
|
|
|| openCount < openModules.Count
|
|
|| truncated != (openCount > openModules.Count))
|
|
throw Error(
|
|
"write_case_observation_context_invalid",
|
|
"ERP 上下文打开模块统计不一致。");
|
|
foreach (JToken item in openModules)
|
|
ValidateContextModule(item, "openModules");
|
|
JToken active = data["activeModule"];
|
|
if (active == null)
|
|
throw Error(
|
|
"write_case_observation_context_invalid",
|
|
"ERP 上下文缺少活动模块字段。");
|
|
if (active.Type != JTokenType.Null)
|
|
{
|
|
ValidateContextModule(active, "activeModule");
|
|
if (openCount == 0
|
|
|| (!truncated && !openModules.Any(item =>
|
|
JToken.DeepEquals(item, active))))
|
|
throw Error(
|
|
"write_case_observation_context_invalid",
|
|
"ERP 活动模块与已打开模块不一致。");
|
|
}
|
|
return new ContextProjection
|
|
{
|
|
CorrelationId = response.CorrelationId,
|
|
UserId = userId,
|
|
UserName = userName,
|
|
AccountBook = accountBook,
|
|
SubSystemId = subSystemId,
|
|
DatabaseScopeFingerprint = databaseScopeFingerprint,
|
|
IsAdministrator = isAdministrator
|
|
};
|
|
}
|
|
|
|
private static void ValidateContextModule(JToken token, string name)
|
|
{
|
|
JObject module = token as JObject;
|
|
if (module == null)
|
|
throw Error(
|
|
"write_case_observation_context_invalid",
|
|
"ERP 上下文模块结构无效:" + name + "。");
|
|
EnsureExact(
|
|
module,
|
|
"moduleCode", "navigationCode", "moduleName");
|
|
RequiredContextIdentifier(module, "moduleCode");
|
|
RequiredContextIdentifier(module, "navigationCode");
|
|
RequiredContextText(module, "moduleName", 500);
|
|
}
|
|
|
|
private static string RequiredContextIdentifier(
|
|
JObject source,
|
|
string name)
|
|
{
|
|
string value = RequiredContextText(source, name, 128);
|
|
if (!SafeCommandName.IsMatch(value))
|
|
throw Error(
|
|
"write_case_observation_context_invalid",
|
|
"ERP 上下文标识格式无效。");
|
|
return value;
|
|
}
|
|
|
|
private static string RequiredContextText(
|
|
JObject source,
|
|
string name,
|
|
int maximum)
|
|
{
|
|
JToken token = source == null ? null : source[name];
|
|
if (token == null || token.Type != JTokenType.String)
|
|
throw Error(
|
|
"write_case_observation_context_invalid",
|
|
"ERP 上下文文本字段无效。");
|
|
string value = (string)token;
|
|
if (string.IsNullOrWhiteSpace(value)
|
|
|| value.Length > maximum
|
|
|| !string.Equals(value, value.Trim(), StringComparison.Ordinal)
|
|
|| value.Any(char.IsControl))
|
|
throw Error(
|
|
"write_case_observation_context_invalid",
|
|
"ERP 上下文文本字段无效。");
|
|
return value;
|
|
}
|
|
|
|
private static PlanProjection ParsePlan(
|
|
JObject data,
|
|
string expectedCommand,
|
|
string expectedInputFingerprint,
|
|
DateTime observedAtUtc)
|
|
{
|
|
EnsureExact(data, "plan");
|
|
JObject plan = RequiredObject(data, "plan");
|
|
EnsureExact(
|
|
plan,
|
|
"planId", "commandName", "commandVersion", "moduleCode", "risk",
|
|
"createdAtUtc", "expiresAtUtc", "valid", "executionAllowed",
|
|
"inputFingerprint", "outcomeCode", "title", "preview", "data",
|
|
"warnings");
|
|
string planId = RequiredString(plan, "planId", 32, 32);
|
|
string commandName = RequiredString(plan, "commandName", 1, 128);
|
|
string commandVersion = RequiredString(
|
|
plan,
|
|
"commandVersion",
|
|
3,
|
|
16);
|
|
string moduleCode = RequiredString(plan, "moduleCode", 1, 64);
|
|
string risk = RequiredString(plan, "risk", 1, 16);
|
|
DateTime createdAtUtc = RequiredUtc(plan, "createdAtUtc");
|
|
DateTime expiresAtUtc = RequiredUtc(plan, "expiresAtUtc");
|
|
string fingerprint = RequiredString(plan, "inputFingerprint", 64, 64)
|
|
.ToLowerInvariant();
|
|
string outcomeCode = RequiredString(plan, "outcomeCode", 1, 128);
|
|
string expectedVersion;
|
|
string expectedRisk;
|
|
ExpectedPlanContract(
|
|
expectedCommand,
|
|
out expectedVersion,
|
|
out expectedRisk);
|
|
if (!SafePlanId.IsMatch(planId)
|
|
|| !SafeCommandName.IsMatch(commandName)
|
|
|| !string.Equals(commandName, expectedCommand, StringComparison.Ordinal)
|
|
|| !SafeCommandVersion.IsMatch(commandVersion)
|
|
|| !string.Equals(
|
|
commandVersion,
|
|
expectedVersion,
|
|
StringComparison.Ordinal)
|
|
|| !SafeModuleCode.IsMatch(moduleCode)
|
|
|| !string.Equals(risk, expectedRisk, StringComparison.Ordinal)
|
|
|| expiresAtUtc <= createdAtUtc
|
|
|| expiresAtUtc != createdAtUtc.AddMinutes(10)
|
|
|| createdAtUtc > observedAtUtc.AddMinutes(5)
|
|
|| !Sha256Pattern.IsMatch(fingerprint)
|
|
|| !string.Equals(
|
|
fingerprint,
|
|
expectedInputFingerprint,
|
|
StringComparison.Ordinal)
|
|
|| !SafeCode.IsMatch(outcomeCode)
|
|
|| !(plan["data"] is JObject)
|
|
|| !(plan["warnings"] is JArray)
|
|
|| plan["valid"] == null
|
|
|| plan["valid"].Type != JTokenType.Boolean
|
|
|| plan["executionAllowed"] == null
|
|
|| plan["executionAllowed"].Type != JTokenType.Boolean)
|
|
throw Error("write_case_observation_plan_invalid", "CLI 计划响应与观察输入不一致。");
|
|
JObject planData = (JObject)plan["data"];
|
|
JToken dataOutcome = planData["outcomeCode"];
|
|
if (dataOutcome == null
|
|
|| dataOutcome.Type != JTokenType.String
|
|
|| !string.Equals(
|
|
(string)dataOutcome,
|
|
outcomeCode,
|
|
StringComparison.Ordinal))
|
|
throw Error("write_case_observation_plan_invalid", "CLI 计划稳定码投影不一致。");
|
|
return new PlanProjection
|
|
{
|
|
PlanId = planId.ToLowerInvariant(),
|
|
CommandVersion = commandVersion,
|
|
ModuleCode = moduleCode,
|
|
Risk = risk,
|
|
OutcomeCode = outcomeCode,
|
|
Valid = (bool)plan["valid"],
|
|
ExecutionAllowed = (bool)plan["executionAllowed"],
|
|
Data = planData
|
|
};
|
|
}
|
|
|
|
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_case_observation_command_invalid",
|
|
"写验收用例命令不属于固定工作流合同。");
|
|
}
|
|
}
|
|
|
|
private static ResultProjection ParseExecution(JObject data)
|
|
{
|
|
EnsureExact(data, "result", "followupPlan", "followupCode");
|
|
JObject result = RequiredObject(data, "result");
|
|
EnsureExact(
|
|
result,
|
|
"success", "code", "message", "recordId", "replayed",
|
|
"transactionEvidenceId", "businessAuditId", "data");
|
|
string code = RequiredString(result, "code", 1, 128);
|
|
if (!SafeCode.IsMatch(code)
|
|
|| result["success"] == null
|
|
|| result["success"].Type != JTokenType.Boolean
|
|
|| result["replayed"] == null
|
|
|| result["replayed"].Type != JTokenType.Boolean
|
|
|| !(result["data"] is JObject)
|
|
|| (data["followupPlan"].Type != JTokenType.Null
|
|
&& !(data["followupPlan"] is JObject))
|
|
|| (data["followupCode"].Type != JTokenType.Null
|
|
&& data["followupCode"].Type != JTokenType.String))
|
|
throw Error("write_case_observation_execution_invalid", "CLI 执行响应结构无效。");
|
|
return new ResultProjection
|
|
{
|
|
Success = (bool)result["success"],
|
|
Code = code,
|
|
Replayed = (bool)result["replayed"],
|
|
RecordId = NullableBoundedRaw(result, "recordId"),
|
|
TransactionEvidenceId = NullableBoundedRaw(
|
|
result,
|
|
"transactionEvidenceId"),
|
|
BusinessAuditId = NullableBoundedRaw(
|
|
result,
|
|
"businessAuditId")
|
|
};
|
|
}
|
|
|
|
private static string ExtractIssueCode(
|
|
PlanProjection plan,
|
|
string expectedIssue)
|
|
{
|
|
if (expectedIssue == null) return null;
|
|
if (plan == null)
|
|
throw Error("write_case_observation_issue_missing", "解析阻断用例缺少 CLI 计划问题码。");
|
|
JArray issues = plan.Data["issues"] as JArray;
|
|
if (issues == null)
|
|
throw Error("write_case_observation_issue_missing", "解析阻断用例缺少 CLI 计划问题码。");
|
|
int matches = 0;
|
|
foreach (JObject issue in issues.OfType<JObject>())
|
|
{
|
|
JToken code = issue["code"];
|
|
if (code != null && code.Type == JTokenType.String
|
|
&& string.Equals(
|
|
(string)code,
|
|
expectedIssue,
|
|
StringComparison.Ordinal))
|
|
matches += 1;
|
|
}
|
|
if (matches != 1)
|
|
throw Error("write_case_observation_issue_missing", "CLI 计划没有唯一记录该用例的精确问题码。");
|
|
return expectedIssue;
|
|
}
|
|
|
|
private static SourceDocumentEvidence ReadSourceDocumentEvidence(
|
|
JObject commandInput)
|
|
{
|
|
JToken token = commandInput["sourceDocuments"];
|
|
if (token == null)
|
|
return new SourceDocumentEvidence();
|
|
JArray documents = token as JArray;
|
|
if (documents == null
|
|
|| documents.Count > PurchaseSourceDocumentContract.MaximumCount)
|
|
throw Error("write_case_observation_source_invalid", "来源附件观察结构无效。");
|
|
HashSet<string> hashes = new HashSet<string>(StringComparer.Ordinal);
|
|
HashSet<string> preprocessContracts = new HashSet<string>(
|
|
StringComparer.Ordinal);
|
|
foreach (JToken item in documents)
|
|
{
|
|
JObject document = item as JObject;
|
|
EnsureExact(
|
|
document,
|
|
"kind", "filename", "sha256", "sizeBytes",
|
|
"extractionSha256", "preprocessContract");
|
|
string kind = RawString(document, "kind");
|
|
string filename = RawString(document, "filename");
|
|
string sourceSha256 = RawString(document, "sha256");
|
|
string extractionSha256 = RawString(
|
|
document,
|
|
"extractionSha256");
|
|
string preprocessContract = RawString(
|
|
document,
|
|
"preprocessContract");
|
|
JToken sizeToken = document["sizeBytes"];
|
|
long sizeBytes;
|
|
if ((kind != "image" && kind != "file")
|
|
|| string.IsNullOrWhiteSpace(filename)
|
|
|| filename.Length > 128
|
|
|| !string.Equals(
|
|
filename,
|
|
filename.Trim(),
|
|
StringComparison.Ordinal)
|
|
|| filename.Any(char.IsControl)
|
|
|| filename == "." || filename == ".."
|
|
|| filename.IndexOf('/') >= 0
|
|
|| filename.IndexOf('\\') >= 0
|
|
|| !Sha256Pattern.IsMatch(sourceSha256 ?? string.Empty)
|
|
|| !Sha256Pattern.IsMatch(
|
|
extractionSha256 ?? string.Empty)
|
|
|| sizeToken == null
|
|
|| sizeToken.Type != JTokenType.Integer
|
|
|| !long.TryParse(
|
|
sizeToken.ToString(Formatting.None),
|
|
NumberStyles.None,
|
|
CultureInfo.InvariantCulture,
|
|
out sizeBytes)
|
|
|| sizeBytes <= 0
|
|
|| sizeBytes
|
|
> PurchaseSourceDocumentContract.MaximumSizeBytes
|
|
|| !PurchaseSourceDocumentContract.Matches(
|
|
kind,
|
|
filename,
|
|
preprocessContract)
|
|
|| !hashes.Add(sourceSha256))
|
|
throw Error(
|
|
"write_case_observation_source_invalid",
|
|
"来源附件双摘要、预处理契约或元数据无效。");
|
|
preprocessContracts.Add(preprocessContract);
|
|
}
|
|
SourceDocumentEvidence result = new SourceDocumentEvidence
|
|
{
|
|
Hashes = hashes.OrderBy(
|
|
item => item,
|
|
StringComparer.Ordinal).ToList(),
|
|
PreprocessContracts = preprocessContracts.OrderBy(
|
|
item => item,
|
|
StringComparer.Ordinal).ToList()
|
|
};
|
|
return result;
|
|
}
|
|
|
|
private static string RawString(JObject source, string name)
|
|
{
|
|
JToken token = source == null ? null : source[name];
|
|
return token != null && token.Type == JTokenType.String
|
|
? (string)token
|
|
: null;
|
|
}
|
|
|
|
private static CliObservation ParseNullableCliResponse(
|
|
JToken token,
|
|
string kind)
|
|
{
|
|
if (token == null || token.Type == JTokenType.Null) return null;
|
|
JObject response = token as JObject;
|
|
if (response == null)
|
|
throw Error("write_case_observation_response_invalid", "CLI 响应必须是对象或 null。");
|
|
JToken okToken = response["ok"];
|
|
if (okToken == null || okToken.Type != JTokenType.Boolean)
|
|
throw Error("write_case_observation_response_invalid", "CLI 响应缺少 ok 布尔值。");
|
|
bool ok = (bool)okToken;
|
|
if (ok)
|
|
EnsureExact(response, "ok", "correlationId", "data");
|
|
else
|
|
EnsureExact(response, "ok", "correlationId", "error");
|
|
string correlation = RequiredString(
|
|
response,
|
|
"correlationId",
|
|
8,
|
|
128);
|
|
if (!SafeCorrelationId.IsMatch(correlation))
|
|
throw Error("write_case_observation_response_invalid", "CLI 响应关联 ID 无效。");
|
|
if (ok)
|
|
{
|
|
JObject data = RequiredObject(response, "data");
|
|
return new CliObservation
|
|
{
|
|
Ok = true,
|
|
CorrelationId = correlation,
|
|
Data = data,
|
|
Kind = kind
|
|
};
|
|
}
|
|
JObject error = RequiredObject(response, "error");
|
|
EnsureExact(error, "code", "message", "exitCode");
|
|
string code = RequiredString(error, "code", 1, 128);
|
|
string message = RequiredString(error, "message", 1, 2000);
|
|
if (!SafeCode.IsMatch(code)
|
|
|| message.Any(char.IsControl)
|
|
|| RequiredInteger(error, "exitCode", 1, 255) < 1)
|
|
throw Error("write_case_observation_response_invalid", "CLI 错误响应结构无效。");
|
|
return new CliObservation
|
|
{
|
|
Ok = false,
|
|
CorrelationId = correlation,
|
|
Code = code,
|
|
Kind = kind
|
|
};
|
|
}
|
|
|
|
private static JObject LoadStrict(string path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
throw Error("write_case_observation_input_required", "请提供原始写用例观察清单。");
|
|
string full;
|
|
try { full = Path.GetFullPath(path); }
|
|
catch { throw Error("write_case_observation_input_invalid", "写用例观察清单路径无效。"); }
|
|
FileInfo file = new FileInfo(full);
|
|
if (!file.Exists)
|
|
throw Error("write_case_observation_not_found", "写用例观察清单不存在。");
|
|
if (file.Length <= 0 || file.Length > MaximumInputBytes
|
|
|| (file.Attributes & FileAttributes.ReparsePoint) != 0)
|
|
throw Error("write_case_observation_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_case_observation_json_invalid", "写用例观察清单包含多个 JSON 根值。");
|
|
return value;
|
|
}
|
|
}
|
|
catch (CommandKernelException) { throw; }
|
|
catch
|
|
{
|
|
throw Error("write_case_observation_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_case_observation_schema_invalid", "写用例观察清单包含缺失或未知字段。");
|
|
}
|
|
|
|
private static JObject RequiredObject(JObject source, string name)
|
|
{
|
|
JObject value = source == null ? null : source[name] as JObject;
|
|
if (value == null)
|
|
throw Error("write_case_observation_schema_invalid", "写用例观察清单对象字段无效。");
|
|
return value;
|
|
}
|
|
|
|
private static string RequiredString(
|
|
JObject source,
|
|
string name,
|
|
int minimum,
|
|
int maximum)
|
|
{
|
|
JToken token = source == null ? null : source[name];
|
|
if (token == null || token.Type != JTokenType.String)
|
|
throw Error("write_case_observation_schema_invalid", "写用例观察清单文本字段无效。");
|
|
string value = ((string)token).Trim();
|
|
if (value.Length < minimum || value.Length > maximum)
|
|
throw Error("write_case_observation_schema_invalid", "写用例观察清单文本长度无效。");
|
|
return value;
|
|
}
|
|
|
|
private static string NullableString(
|
|
JObject source,
|
|
string name,
|
|
int minimum,
|
|
int maximum)
|
|
{
|
|
JToken token = source == null ? null : source[name];
|
|
if (token == null || token.Type == JTokenType.Null) return null;
|
|
return RequiredString(source, name, minimum, maximum);
|
|
}
|
|
|
|
private static string RequiredSiblingJsonFileName(
|
|
JObject source,
|
|
string name)
|
|
{
|
|
string value = RequiredString(source, name, 6, 181);
|
|
if (!SafeJsonFileName.IsMatch(value)
|
|
|| !string.Equals(
|
|
Path.GetFileName(value),
|
|
value,
|
|
StringComparison.Ordinal))
|
|
throw Error(
|
|
"write_case_file_index_path_invalid",
|
|
"写用例文件索引只能引用同目录下的安全 JSON 文件名。");
|
|
return value;
|
|
}
|
|
|
|
private static string NullableSiblingJsonFileName(
|
|
JObject source,
|
|
string name)
|
|
{
|
|
JToken token = source[name];
|
|
if (token == null || token.Type == JTokenType.Null) return null;
|
|
return RequiredSiblingJsonFileName(source, name);
|
|
}
|
|
|
|
private static string NullableBoundedRaw(JObject source, string name)
|
|
{
|
|
JToken token = source[name];
|
|
if (token == null || token.Type == JTokenType.Null) return null;
|
|
if (token.Type != JTokenType.String)
|
|
throw Error("write_case_observation_execution_invalid", "CLI 执行标识字段类型无效。");
|
|
string value = (string)token;
|
|
if (string.IsNullOrWhiteSpace(value)
|
|
|| value.Length > 512
|
|
|| !string.Equals(value, value.Trim(), StringComparison.Ordinal)
|
|
|| value.Any(char.IsControl))
|
|
throw Error("write_case_observation_execution_invalid", "CLI 执行标识字段格式无效。");
|
|
return value;
|
|
}
|
|
|
|
private static bool RequiredBoolean(JObject source, string name)
|
|
{
|
|
JToken token = source == null ? null : source[name];
|
|
if (token == null || token.Type != JTokenType.Boolean)
|
|
throw Error("write_case_observation_schema_invalid", "写用例观察清单布尔字段无效。");
|
|
return (bool)token;
|
|
}
|
|
|
|
private static int RequiredInteger(
|
|
JObject source,
|
|
string name,
|
|
int minimum,
|
|
int maximum)
|
|
{
|
|
JToken token = source == null ? null : 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_case_observation_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_case_observation_schema_invalid", "写用例观察清单 UTC 时间无效。");
|
|
return value;
|
|
}
|
|
|
|
private static string RequiredHash(JObject source, string name)
|
|
{
|
|
string value = RequiredString(source, name, 64, 64);
|
|
if (!Sha256Pattern.IsMatch(value))
|
|
throw Error(
|
|
"write_case_observation_schema_invalid",
|
|
"写用例观察清单 SHA-256 字段无效。");
|
|
return value;
|
|
}
|
|
|
|
private static JToken HashNullable(string value)
|
|
{
|
|
return value == null
|
|
? (JToken)JValue.CreateNull()
|
|
: new JValue(Sha256(value));
|
|
}
|
|
|
|
private static string Sha256(string value)
|
|
{
|
|
using (SHA256 sha = SHA256.Create())
|
|
{
|
|
byte[] hash = sha.ComputeHash(
|
|
new UTF8Encoding(false).GetBytes(value ?? string.Empty));
|
|
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 CliObservation
|
|
{
|
|
public bool Ok { get; set; }
|
|
public string CorrelationId { get; set; }
|
|
public string Code { get; set; }
|
|
public string Kind { get; set; }
|
|
public JObject Data { get; set; }
|
|
}
|
|
|
|
private sealed class ContextProjection
|
|
{
|
|
public string CorrelationId { get; set; }
|
|
public string UserId { get; set; }
|
|
public string UserName { get; set; }
|
|
public string AccountBook { get; set; }
|
|
public string SubSystemId { get; set; }
|
|
public string DatabaseScopeFingerprint { get; set; }
|
|
public bool IsAdministrator { get; set; }
|
|
}
|
|
|
|
private sealed class PlanProjection
|
|
{
|
|
public string PlanId { get; set; }
|
|
public string CommandVersion { get; set; }
|
|
public string ModuleCode { get; set; }
|
|
public string Risk { get; set; }
|
|
public string OutcomeCode { get; set; }
|
|
public bool Valid { get; set; }
|
|
public bool ExecutionAllowed { get; set; }
|
|
public JObject Data { get; set; }
|
|
}
|
|
|
|
private sealed class ResultProjection
|
|
{
|
|
public bool Success { get; set; }
|
|
public string Code { get; set; }
|
|
public bool Replayed { get; set; }
|
|
public string RecordId { get; set; }
|
|
public string TransactionEvidenceId { get; set; }
|
|
public string BusinessAuditId { get; set; }
|
|
}
|
|
|
|
private sealed class SourceDocumentEvidence
|
|
{
|
|
public SourceDocumentEvidence()
|
|
{
|
|
Hashes = new List<string>();
|
|
PreprocessContracts = new List<string>();
|
|
}
|
|
|
|
public IList<string> Hashes { get; set; }
|
|
public IList<string> PreprocessContracts { get; set; }
|
|
}
|
|
|
|
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_case_observation_json_invalid", "写用例观察清单不允许 JSON 注释。");
|
|
return result;
|
|
}
|
|
}
|
|
}
|
|
}
|