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 { /// /// 把本地保存的 CLI 计划/执行响应投影成不含业务原值的单用例验收记录。 /// 原始响应仅作为输入读取;输出只保留稳定码、布尔/计数和不可逆 SHA-256。 /// 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( @"^(?:从)?(?\d{4}-\d{1,2}-\d{1,2})(?:上午|下午|全天|全日|一天)(?:到|至)(?\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 references = new HashSet( 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 observed = new Dictionary(StringComparer.Ordinal); HashSet fileNames = new HashSet( 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>()); 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 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>()); } catch (CommandKernelException) { throw Error( "write_case_observation_case_invalid", "采购成功验收输入不符合创建命令 Schema。"); } string proof = commandInput.Value("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>()); } 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("dateExpression"), out startDate, out endDate) || commandInput.Value("requestedHours") <= 0m) { throw Error( "write_case_observation_case_invalid", "多日请假验收必须使用两个绝对日期、两端明确时段和已知的日历总工时断言。"); } } private static void ValidateCasePlan( string caseCode, PlanProjection plan, JObject commandInput) { if (IsPurchaseSourceProofCase(caseCode)) { IList 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("requestedHours"); if (plan == null || !plan.Valid || plan.ExecutionAllowed || plan.OutcomeCode != "leave_intent_resolved" || plan.Data.Value("resolvedCommand") != "hr.leave.create" || plan.Data.Value("requiresFollowupPlan") != true || resolved == null) { throw Error( "write_case_observation_plan_invalid", "自然语言请假验收没有返回唯一的创建续接输入。"); } try { CommandInputSchemaValidator.Validate( CommandInputSchemas.LeaveCreate(), resolved.ToObject>()); } catch (CommandKernelException) { throw Error( "write_case_observation_plan_invalid", "自然语言请假续接输入不符合创建命令 Schema。"); } string proof = resolved.Value("resolutionProof"); if (!LeaveResolutionProof.IsMatch(proof ?? string.Empty)) { throw Error( "write_case_observation_plan_invalid", "自然语言请假续接结果缺少服务器短期解析凭证。"); } if (!multiDay) { if (!TryReadLocalDateTime( resolved.Value("startLocal"), out startLocal) || !TryReadLocalDateTime( resolved.Value("endLocal"), out endLocal) || startLocal.Date != endLocal.Date || endLocal <= startLocal || resolved.Value("requestedHours") <= 0m) { throw Error( "write_case_observation_plan_invalid", "单日自然语言请假续接结果不是有效的员工日历范围。"); } return; } if (!TryReadLocalDateTime( resolved.Value("startLocal"), out startLocal) || !TryReadLocalDateTime( resolved.Value("endLocal"), out endLocal) || !TryReadMultiDayDates( commandInput.Value("dateExpression"), out expectedStartDate, out expectedEndDate) || startLocal.Date != expectedStartDate.Date || endLocal.Date != expectedEndDate.Date || endLocal.Date <= startLocal.Date || expectedHours <= 0m || resolved.Value("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()) { 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 hashes = new HashSet(StringComparer.Ordinal); HashSet preprocessContracts = new HashSet( 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(); PreprocessContracts = new List(); } public IList Hashes { get; set; } public IList 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; } } } }