From 8edccd5514e5acfeb8631f2226e28ace8925a907 Mon Sep 17 00:00:00 2001 From: chenxing Date: Fri, 23 Oct 2026 10:51:32 +0800 Subject: [PATCH] Preserve reviewed existing gate prerequisites Local review candidate only. Explicitly selected inherited prerequisites and prior repair patches; unrelated WIP and independent MQ fixes are excluded. No push or deployment authorized. --- .../svchall/job/FlowReconcileJobTest.java | 20 +- .../service/impl/ShAnswerFlowTest.java | 195 ++++++++++++++++++ .../service/impl/ShMqEntryDefaultOffTest.java | 47 +++++ .../impl/ShProjectionRepairSqlGuardTest.java | 53 +++++ .../service/impl/ShReconcileTaskTest.java | 33 +-- 5 files changed, 330 insertions(+), 18 deletions(-) create mode 100644 src/test/java/com/yunhai/svchall/service/impl/ShMqEntryDefaultOffTest.java create mode 100644 src/test/java/com/yunhai/svchall/service/impl/ShProjectionRepairSqlGuardTest.java diff --git a/src/test/java/com/yunhai/svchall/job/FlowReconcileJobTest.java b/src/test/java/com/yunhai/svchall/job/FlowReconcileJobTest.java index b947a13..d18f757 100644 --- a/src/test/java/com/yunhai/svchall/job/FlowReconcileJobTest.java +++ b/src/test/java/com/yunhai/svchall/job/FlowReconcileJobTest.java @@ -42,6 +42,16 @@ class FlowReconcileJobTest { flowClient = mock(FlowClient.class); } + /** C.1 起 Job 依赖出站命令仓判"新命令链办件归恢复服务管";测试注入空历史桩,否则 NPE */ + private FlowReconcileJob job(boolean enabled) { + FlowReconcileJob job = new FlowReconcileJob(applyRepository, flowClient, enabled); + com.yunhai.svchall.repository.FlowApplyCommandRepository commands = + mock(com.yunhai.svchall.repository.FlowApplyCommandRepository.class); + when(commands.findByApplyIdOrderByIdDesc(anyInt())).thenReturn(Collections.emptyList()); + ReflectionTestUtils.setField(job, "commandRepository", commands); + return job; + } + private ServiceApply apply(int status, Integer lastVersion) { ServiceApply apply = new ServiceApply(); ReflectionTestUtils.setField(apply, "id", 42); @@ -54,7 +64,7 @@ class FlowReconcileJobTest { @Test void disabledByDefaultJobDoesNothing() { - FlowReconcileJob job = new FlowReconcileJob(applyRepository, flowClient, false); + FlowReconcileJob job = job(false); job.reconcile(); verify(flowClient, never()).enabled(); verify(applyRepository, never()).findByFlowStatus(anyInt(), any(Pageable.class)); @@ -71,7 +81,7 @@ class FlowReconcileJobTest { remote.put("version", 4); when(flowClient.getInstanceByBiz(1L, "svc_item", "42")).thenReturn(remote); - new FlowReconcileJob(applyRepository, flowClient, true).reconcile(); + job(true).reconcile(); assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus()); assertEquals(Integer.valueOf(4), apply.getLastInstanceVersion()); @@ -90,7 +100,7 @@ class FlowReconcileJobTest { remote.put("version", 2); when(flowClient.getInstanceByBiz(anyLong(), anyString(), anyString())).thenReturn(remote); - new FlowReconcileJob(applyRepository, flowClient, true).reconcile(); + job(true).reconcile(); verify(applyRepository, never()).save(any()); } @@ -98,7 +108,7 @@ class FlowReconcileJobTest { @Test void enabledJobSkipsWhenFlowCenterDisabled() { when(flowClient.enabled()).thenReturn(false); - new FlowReconcileJob(applyRepository, flowClient, true).reconcile(); + job(true).reconcile(); verify(applyRepository, never()).findByFlowStatus(anyInt(), any(Pageable.class)); } @@ -111,7 +121,7 @@ class FlowReconcileJobTest { when(flowClient.getInstanceByBiz(anyLong(), anyString(), anyString())) .thenThrow(new RuntimeException("boom")); - new FlowReconcileJob(applyRepository, flowClient, true).reconcile(); + job(true).reconcile(); verify(applyRepository, never()).save(any()); } diff --git a/src/test/java/com/yunhai/svchall/service/impl/ShAnswerFlowTest.java b/src/test/java/com/yunhai/svchall/service/impl/ShAnswerFlowTest.java index 6a445e8..bff2c53 100644 --- a/src/test/java/com/yunhai/svchall/service/impl/ShAnswerFlowTest.java +++ b/src/test/java/com/yunhai/svchall/service/impl/ShAnswerFlowTest.java @@ -34,6 +34,7 @@ import com.yunhai.svchall.service.SiteDirectory; import com.yunhai.svchall.service.SiteGuard; import com.yunhai.svchall.service.impl.AnswerProjectionStore.Disposition; import com.yunhai.svchall.service.impl.AnswerProjectionStore.Outcome; +import com.yunhai.svchall.service.impl.AnswerProjectionStore.RepairResult; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -320,6 +321,70 @@ class ShAnswerFlowTest { assertThat(h.projectionsSaved.get(0).getLinkState()).isEqualTo(FcAnswerProjection.LINK_APPLIED); } + @Test + @DisplayName("已受理后答卷再演进:普通投影跟随最新版,意图受理坐标与办件关联保持固化(决策③)") + void appliedEvidenceStaysFrozenWhenNewerRevisionArrives() { + Harness h = harness(); + FcApplyIntent intent = intent(12, 9001); + intent.setState(FcApplyIntent.STATE_APPLIED); + intent.setFcIntentId(FC_INTENT_ID); + intent.setAnswerUuid(ANSWER_UUID); + intent.setAnswerRevision(1L); + String acceptedHash = hash(snapshot("{\"k\":\"v1\"}")); + intent.setContentHash(acceptedHash); + h.intentRows.add(intent); + FcAnswerProjection applied = projection(1L, acceptedHash); + applied.setValuesJson("{\"k\":\"v1\"}"); + applied.setIntentId(12); + applied.setApplyId(9001); + applied.setLinkState(FcAnswerProjection.LINK_APPLIED); + h.currentProjection = applied; + + SnapshotEnvelope newer = envelope(2L, 7, snapshot("{\"k\":\"v2\"}")); + newer.getSnapshot().setIntent(mapper.createObjectNode().put("intentId", FC_INTENT_ID)); + Disposition result = h.store.applySnapshot(newer); + + assertThat(result.getApplyId()).isEqualTo(9001); + assertThat(h.applyCounter.get()).isZero(); + // 普通投影(数据面镜像)跟随最新答卷 + assertThat(applied.getRevision()).isEqualTo(2L); + assertThat(applied.getValuesJson()).isEqualTo("{\"k\":\"v2\"}"); + // 审批证据锚点:意图的受理坐标只记受理那一刻,不被后来事件改写 + assertThat(intent.getAnswerRevision()).isEqualTo(1L); + assertThat(intent.getContentHash()).isEqualTo(acceptedHash); + assertThat(applied.getLinkState()).isEqualTo(FcAnswerProjection.LINK_APPLIED); + assertThat(applied.getIntentId()).isEqualTo(12); + assertThat(applied.getApplyId()).isEqualTo(9001); + } + + @Test + @DisplayName("权威修复打在已建件行上:只刷快照镜像,关联列不在条件更新里,也不走建件(决策③)") + void authoritativeRepairOfAppliedRowMirrorsOnly() { + Harness h = harness(); + FcAnswerProjection applied = projection(1L, hash(snapshot("{\"k\":\"v1\"}"))); + applied.setValuesJson("{\"k\":\"v1\"}"); + applied.setIntentId(12); + applied.setApplyId(9001); + applied.setLinkState(FcAnswerProjection.LINK_APPLIED); + h.currentProjection = applied; + when(h.projections.repairIfUnchanged(any(), any(), any(), any())).thenReturn(1); + + RepairResult result = h.store.repairAuthoritative(envelope(5L, 7, snapshot("{\"k\":\"v5\"}")), + 1L, hash(snapshot("{\"k\":\"v1\"}")), 0, "proof-1"); + + assertThat(result).isEqualTo(RepairResult.APPLIED); + ArgumentCaptor replacement = ArgumentCaptor.forClass(FcAnswerProjection.class); + verify(h.projections).repairIfUnchanged(replacement.capture(), eq(1L), + eq(hash(snapshot("{\"k\":\"v1\"}"))), eq(0)); + // 关联列不在 UPDATE SET 里(数据库语义由 ShProjectionRepairSqlGuardTest 钉住),replacement 里根本没有它们 + assertThat(replacement.getValue().getIntentId()).isNull(); + assertThat(replacement.getValue().getApplyId()).isNull(); + assertThat(replacement.getValue().getValuesJson()).isEqualTo("{\"k\":\"v5\"}"); + // 修复不产生任何业务副作用:不建件、不回写办件证据 + verify(h.projections, never()).save(any()); + verify(h.applyService, never()).createFromVerifiedIntent(any(), any()); + } + // ==================== 意图入口与确认(A.1/A.2/A.4) ==================== @Test @@ -526,6 +591,136 @@ class ShAnswerFlowTest { verify(ih.applyService, never()).createFromVerifiedIntent(any(), any()); } + @Test + @DisplayName("无 MQ 环境(决策①/④):签名收据是唯一事实源,十个快照成员完整落 fc_answer_projection") + void receiptWithoutMqLandsCompleteProjection() { + IntentHarness ih = intentHarness(); + FcApplyIntent stored = intent(77, null); + stored.setFcIntentId(FC_INTENT_ID); + ih.existingIntent = stored; + + Snapshot full = snapshot("{\"name\":\"张三\",\"event\":\"校运动会\"}"); + full.setAnswerNo("AN-2026-001"); + String receiptHash = AnswerProjectionStore.canonicalHash(full); + ContextReceiptView receipt = new ContextReceiptView(); + receipt.setStatus("SUBMITTED"); + receipt.setAnswerUuid(ANSWER_UUID); + receipt.setAnswerRevision(1L); + receipt.setContentHash(receiptHash); + receipt.setConsumerAppKey(APP_KEY); + receipt.setConsumerSchoolId(SCH); + receipt.setSubmitterId(1001); + receipt.setFormUuid(FORM_UUID); + receipt.setFormVersion(4); + receipt.setSnapshot(full); + ih.receipt = receipt; + + IntentConfirmView done = ih.service.confirm(user(1001), 77); + + assertThat(done.getApplyId()).isEqualTo(9001); + FcAnswerProjection p = ih.storedProjection; + assertThat(p.getSchId()).isEqualTo(SCH); + assertThat(p.getAnswerUuid()).isEqualTo(ANSWER_UUID); + assertThat(p.getFormUuid()).isEqualTo(FORM_UUID); + assertThat(p.getFormVersion()).isEqualTo(4); + assertThat(p.getRevision()).isEqualTo(1L); + assertThat(p.getContentHash()).isEqualTo(receiptHash); + assertThat(p.getIsDeleted()).isZero(); + // 十个快照成员逐项落库(数据面镜像全文,不加工不丢弃) + assertThat(p.getAppCode()).isEqualTo("demo"); + assertThat(p.getCode()).isEqualTo("honor_apply"); + assertThat(p.getSubmitterId()).isEqualTo(1001); + assertThat(p.getSubjectId()).isEqualTo(1001); + assertThat(p.getStatus()).isEqualTo("draft"); + assertThat(p.getValuesJson()).isEqualTo("{\"name\":\"张三\",\"event\":\"校运动会\"}"); + assertThat(p.getAnswerNo()).isEqualTo("AN-2026-001"); + assertThat(p.getAnswerCreateTime()).isEqualTo("2026-10-21T10:00:00"); + assertThat(p.getAnswerUpdateTime()).isEqualTo("2026-10-21T10:05:00"); + // 关联与受理 + assertThat(p.getIntentId()).isEqualTo(77); + assertThat(p.getApplyId()).isEqualTo(9001); + assertThat(p.getLinkState()).isEqualTo(FcAnswerProjection.LINK_APPLIED); + assertThat(stored.getAnswerUuid()).isEqualTo(ANSWER_UUID); + assertThat(stored.getAnswerRevision()).isEqualTo(1L); + assertThat(stored.getContentHash()).isEqualTo(receiptHash); + assertThat(stored.getState()).isEqualTo(FcApplyIntent.STATE_APPLIED); + assertThat(stored.getConfirmedAt()).isNotNull(); + } + + @Test + @DisplayName("无 MQ 建件(决策①):办件固化收据快照,出站命令载荷自持同一份冻结数据,详情读固化副本") + void verifiedIntentCreatesApplyWithFrozenSnapshotAndOutboundCommand() { + FcAnswerProjection projection = projection(1L, null); + projection.setContentHash(hash(snapshot("{}"))); + projection.setValuesJson("{\"name\":\"张三\",\"event\":\"校运动会\"}"); + FcApplyIntent intent = intent(11, null); + + ServiceApplyRepository applyRepository = mock(ServiceApplyRepository.class); + final ServiceApply[] savedApply = new ServiceApply[1]; + when(applyRepository.saveAndFlush(any())).thenAnswer(call -> { + ServiceApply a = call.getArgument(0); + ReflectionTestUtils.setField(a, "id", 42); + savedApply[0] = a; + return a; + }); + when(applyRepository.findById(42)).thenAnswer(call -> Optional.ofNullable(savedApply[0])); + ServiceItemRepository items = mock(ServiceItemRepository.class); + ServiceItem item = new ServiceItem(); + item.setId(9); + item.setItemCode("svc_apply_01"); + item.setTitle("测试事项"); + item.setStatus(ServiceItemStatusEnum.PUBLISHED.getCode()); + when(items.findById(9)).thenReturn(Optional.of(item)); + FlowClient flowClient = mock(FlowClient.class); + when(flowClient.enabled()).thenReturn(true); + com.yunhai.svchall.repository.FcApplyIntentRepository intents = + mock(com.yunhai.svchall.repository.FcApplyIntentRepository.class); + when(intents.saveAndFlush(any())).thenAnswer(call -> call.getArgument(0)); + // 出站命令用真实例(spy):payloadJson/createInTransaction 的载荷自持冻结数据必须是真话 + FlowApplyCommandRepository commandRepository = mock(FlowApplyCommandRepository.class); + when(commandRepository.findByApplyIdOrderByIdDesc(anyInt())) + .thenReturn(Collections.emptyList()); + when(commandRepository.saveAndFlush(any())).thenAnswer(call -> { + FlowApplyCommand c = call.getArgument(0); + ReflectionTestUtils.setField(c, "id", 7); + return c; + }); + FlowApplyRecoveryService flowCommands = org.mockito.Mockito.spy(new FlowApplyRecoveryService( + commandRepository, mock(ServiceApplyRepository.class), flowClient, mapper, runningTx())); + + ServiceApplyService applyService = new ServiceApplyService(applyRepository, items, + mock(ServiceFormService.class), mock(AttachmentService.class), flowClient, + mock(SiteGuard.class), mock(SiteDirectory.class), + new com.yunhai.svchall.service.ApplyViewGuard(flowClient), mapper, runningTx()); + applyService.setFlowCommandsForTest(flowCommands); + applyService.setFcIntentsForTest(intents); + + Integer applyId = applyService.createFromVerifiedIntent(intent, projection); + + assertThat(applyId).isEqualTo(42); + assertThat(savedApply[0].getSchId()).isEqualTo(SCH); + assertThat(savedApply[0].getItemCode()).isEqualTo("svc_apply_01"); + assertThat(savedApply[0].getFormVersion()).isEqualTo(4); + assertThat(savedApply[0].getFlowGuid()).isEqualTo("fg-1"); + // 提交时固化:办件表单数据抄自投影快照一次,此后投影更新不再回写 + assertThat(savedApply[0].getFormDataJson()).isEqualTo("{\"name\":\"张三\",\"event\":\"校运动会\"}"); + assertThat(savedApply[0].getItemSnapshotJson()).contains("测试事项"); + assertThat(savedApply[0].getFcIntentId()).isEqualTo(11); + assertThat(savedApply[0].getFcAnswerUuid()).isEqualTo(ANSWER_UUID); + assertThat(intent.getState()).isEqualTo(FcApplyIntent.STATE_APPLIED); + assertThat(intent.getApplyId()).isEqualTo(42); + // 出站命令与办件同一短事务落库,载荷自持冻结数据(重放不回读办件行) + ArgumentCaptor payload = ArgumentCaptor.forClass(String.class); + verify(flowCommands).createInTransaction(any(), eq(11), payload.capture()); + assertThat(payload.getValue()).contains("校运动会").contains("测试事项"); + verify(flowCommands).dispatchAfterCommit(7); + // 展示(决策①"详情可看"):详情 VO 读的是办件行里的固化副本 + com.yunhai.svchall.model.svc.SvcModels.ApplyVo vo = applyService.detailOfApplyId(42); + assertThat(vo).isNotNull(); + assertThat(vo.getFormDataJson()).isEqualTo("{\"name\":\"张三\",\"event\":\"校运动会\"}"); + assertThat(vo.getFormVersion()).isEqualTo(4); + } + // ==================== 出站命令与恢复(C.1/C.2) ==================== @Test diff --git a/src/test/java/com/yunhai/svchall/service/impl/ShMqEntryDefaultOffTest.java b/src/test/java/com/yunhai/svchall/service/impl/ShMqEntryDefaultOffTest.java new file mode 100644 index 0000000..b9616e6 --- /dev/null +++ b/src/test/java/com/yunhai/svchall/service/impl/ShMqEntryDefaultOffTest.java @@ -0,0 +1,47 @@ +package com.yunhai.svchall.service.impl; + +import com.yunhai.svchall.config.FcAnswerMqConfiguration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Scanner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * SH 的 MQ 消费入口默认关闭(2026-10-22 决策④:保留实现,不为技术统一而部署)。 + * + *

关闭必须落在启动期而不是运行期判断:{@code @RabbitListener} 注解处理器在容器工厂缺失时 + * 直接抛异常打断启动,所以消费侧两个类都要挂 {@code @ConditionalOnExpression},且 yml 默认值 + * 必须是空(部署不显式注入 {@code RABBITMQ_HOST} 就没有消费入口)。这三件事缺一,"MQ 可选" + * 就退化成"MQ 必部署"。以后真要接入:给环境注入 RABBITMQ_HOST 即可,实现原样保留。

+ */ +@DisplayName("MQ 消费入口默认关 - 条件装配与空默认(决策④)") +class ShMqEntryDefaultOffTest { + + @Test + @DisplayName("消费者与拓扑配置都挂 spring.rabbitmq.host 条件:未配 broker 时 bean 不存在") + void consumerEntryIsConditionallyAssembled() { + ConditionalOnExpression consumer = + FcAnswerConsumer.class.getAnnotation(ConditionalOnExpression.class); + assertThat(consumer).as("FcAnswerConsumer 必须条件装配").isNotNull(); + assertThat(consumer.value()).contains("spring.rabbitmq.host"); + + ConditionalOnExpression configuration = + FcAnswerMqConfiguration.class.getAnnotation(ConditionalOnExpression.class); + assertThat(configuration).as("FcAnswerMqConfiguration 必须条件装配").isNotNull(); + assertThat(configuration.value()).contains("spring.rabbitmq.host"); + } + + @Test + @DisplayName("application.yml 默认 RABBITMQ_HOST 为空:不注入就没有消费入口(决策④默认关)") + void ymlKeepsRabbitHostEmptyByDefault() { + InputStream in = getClass().getClassLoader().getResourceAsStream("application.yml"); + assertThat(in).as("classpath 上应有 application.yml").isNotNull(); + String yml = new Scanner(in, StandardCharsets.UTF_8.name()).useDelimiter("\\A").next(); + assertThat(yml).contains("host: ${RABBITMQ_HOST:}"); + } +} diff --git a/src/test/java/com/yunhai/svchall/service/impl/ShProjectionRepairSqlGuardTest.java b/src/test/java/com/yunhai/svchall/service/impl/ShProjectionRepairSqlGuardTest.java new file mode 100644 index 0000000..385f701 --- /dev/null +++ b/src/test/java/com/yunhai/svchall/service/impl/ShProjectionRepairSqlGuardTest.java @@ -0,0 +1,53 @@ +package com.yunhai.svchall.service.impl; + +import com.yunhai.svchall.entity.FcAnswerProjection; +import com.yunhai.svchall.repository.FcAnswerProjectionRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.jpa.repository.Query; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 权威修复的 SQL 护栏(2026-10-22 决策③"提交时固化"的数据库侧一半)。 + * + *

{@code repairIfUnchanged} 是对账唯一能改已落库投影的原语。办件受理后的审批证据固化在 + * {@code service_apply.form_data_json}(建件时抄一次),投影行只是普通投影(数据面镜像); + * 但镜像行上的关联列({@code intent_id}/{@code apply_id}/{@code link_state})与业务扩展列 + * ({@code biz_ext_json})一旦被修复顺手改写,"这份答卷是哪个办件的受理依据"就断了。 + * 这条 JPQL 的 SET 子句必须永远只含快照列——用注解字面量钉住,谁往里加关联列就红。

+ */ +@DisplayName("权威修复 SQL 护栏 - repairIfUnchanged 只准刷快照列(决策③)") +class ShProjectionRepairSqlGuardTest { + + @Test + @DisplayName("SET 子句只含快照列+版本自增;WHERE 带期望三元组;关联列/业务扩展列绝不出现") + void repairIfUnchangedUpdatesSnapshotColumnsOnly() throws NoSuchMethodException { + Query query = FcAnswerProjectionRepository.class + .getMethod("repairIfUnchanged", FcAnswerProjection.class, Long.class, String.class, + Integer.class) + .getAnnotation(Query.class); + assertThat(query).as("repairIfUnchanged 必须是 @Query 条件更新").isNotNull(); + String jpql = query.value(); + + // 快照成员 + 本地写代数自增,全部在 SET 里 + assertThat(jpql) + .contains("p.revision =") + .contains("p.contentHash =") + .contains("p.formVersion =") + .contains("p.isDeleted =") + .contains("p.valuesJson =") + .contains("p.projectionVersion = p.projectionVersion + 1"); + // 条件更新裁决:期望三元组必须逐字出现在 WHERE + assertThat(jpql) + .contains("p.revision = :revision") + .contains("p.contentHash = :hash") + .contains("p.isDeleted = :deleted"); + // 关联列与业务扩展列不在 UPDATE 里(关联列保护 uk_projection_intent 的语义,扩展列是业务侧自留地) + assertThat(jpql) + .doesNotContain("intentId") + .doesNotContain("applyId") + .doesNotContain("linkState") + .doesNotContain("bizExtJson"); + } +} diff --git a/src/test/java/com/yunhai/svchall/service/impl/ShReconcileTaskTest.java b/src/test/java/com/yunhai/svchall/service/impl/ShReconcileTaskTest.java index a4fee28..c38d359 100644 --- a/src/test/java/com/yunhai/svchall/service/impl/ShReconcileTaskTest.java +++ b/src/test/java/com/yunhai/svchall/service/impl/ShReconcileTaskTest.java @@ -75,6 +75,8 @@ import static org.mockito.Mockito.when; class ShReconcileTaskTest { private static final String SELF = "100018"; + /** 指令签发方=表单中心(svchall.answer-reconcile.source-app-key 的测试值) */ + private static final String FC = "100021"; private static final String TASK_ID = "0f1e2d3c4b5a69788796a5b4c3d2e1f0"; private static final String FORM_UUID = "f0e1d2c3b4a5968778695a4b3c2d1e0f"; private static final String ANSWER_A = "a0000000000000000000000000000001"; @@ -103,6 +105,7 @@ class ShReconcileTaskTest { worker = new AnswerReconcileWorker(workRepository, projectionStore, projectionRepository, formSyncClient, runningTransactionTemplate(), mapper, SELF); ReflectionTestUtils.setField(worker, "enabled", true); + ReflectionTestUtils.setField(worker, "sourceAppKey", FC); ReflectionTestUtils.setField(worker, "leaseSeconds", 900); ReflectionTestUtils.setField(worker, "maxRepairRetries", 2); ReflectionTestUtils.setField(worker, "maxErrorItems", 5); @@ -122,7 +125,7 @@ class ShReconcileTaskTest { void acceptedInstructionIsPersistedAsQueuedOnly() { when(workRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.empty()); - AnswerReconcileWork work = worker.accept(instruction(1), SELF); + AnswerReconcileWork work = worker.accept(instruction(1), FC); assertThat(work.getState()).isEqualTo(AnswerReconcileWork.STATE_QUEUED); assertThat(work.getTaskUuid()).isEqualTo(TASK_ID); @@ -142,14 +145,14 @@ class ShReconcileTaskTest { @DisplayName("身份不符一律拒收:验签身份不是本应用、目标应用不是本应用、协议号不符") void forgedOrMismatchedInstructionIsRejected() { assertThatThrownBy(() -> worker.accept(instruction(1), "999999")) - .isInstanceOf(BizException.class).hasMessageContaining("消费身份不符"); + .isInstanceOf(BizException.class).hasMessageContaining("签名方"); ReconcileInstruction foreignTarget = instruction(1); foreignTarget.setConsumerAppKey("100020"); - assertThatThrownBy(() -> worker.accept(foreignTarget, SELF)) + assertThatThrownBy(() -> worker.accept(foreignTarget, FC)) .isInstanceOf(BizException.class).hasMessageContaining("不是本应用"); ReconcileInstruction badProtocol = instruction(1); badProtocol.setProtocol("fc.form-sync.v9"); - assertThatThrownBy(() -> worker.accept(badProtocol, SELF)) + assertThatThrownBy(() -> worker.accept(badProtocol, FC)) .isInstanceOf(BizException.class).hasMessageContaining("协议号不符"); verify(workRepository, never()).save(any(AnswerReconcileWork.class)); } @@ -159,27 +162,27 @@ class ShReconcileTaskTest { void instructionCoordinatesAreValidated() { ReconcileInstruction foreignSchool = instruction(1); foreignSchool.setConsumerSchoolId(0L); - assertThatThrownBy(() -> worker.accept(foreignSchool, SELF)) + assertThatThrownBy(() -> worker.accept(foreignSchool, FC)) .isInstanceOf(BizException.class).hasMessageContaining("真实学校"); ReconcileInstruction longId = instruction(1); longId.setTaskId("012345678901234567890123456789012345678901234567890123456789"); - assertThatThrownBy(() -> worker.accept(longId, SELF)) + assertThatThrownBy(() -> worker.accept(longId, FC)) .isInstanceOf(BizException.class).hasMessageContaining("36 字符"); ReconcileInstruction badWindow = instruction(1); badWindow.setWindowStart("2026-10-21 02:00"); - assertThatThrownBy(() -> worker.accept(badWindow, SELF)) + assertThatThrownBy(() -> worker.accept(badWindow, FC)) .isInstanceOf(BizException.class).hasMessageContaining("任务起点"); ReconcileInstruction negativeAttempt = instruction(1); negativeAttempt.setAttempt(-1); - assertThatThrownBy(() -> worker.accept(negativeAttempt, SELF)) + assertThatThrownBy(() -> worker.accept(negativeAttempt, FC)) .isInstanceOf(BizException.class).hasMessageContaining("attempt"); ReconcileInstruction halfCoordinate = instruction(1); halfCoordinate.setFormVersion(null); - assertThatThrownBy(() -> worker.accept(halfCoordinate, SELF)) + assertThatThrownBy(() -> worker.accept(halfCoordinate, FC)) .isInstanceOf(BizException.class).hasMessageContaining("版本确切"); } @@ -191,14 +194,14 @@ class ShReconcileTaskTest { stored.setScannedRows(7); when(workRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(stored)); - AnswerReconcileWork older = worker.accept(instruction(1), SELF); + AnswerReconcileWork older = worker.accept(instruction(1), FC); assertThat(older.getAttempt()).isEqualTo(3); assertThat(older.getWatermark()).isEqualTo(ANSWER_A); assertThat(older.getScannedRows()).isEqualTo(7); verify(workRepository, never()).save(any(AnswerReconcileWork.class)); - AnswerReconcileWork redispatched = worker.accept(instruction(4), SELF); + AnswerReconcileWork redispatched = worker.accept(instruction(4), FC); assertThat(redispatched.getTaskUuid()).isEqualTo(TASK_ID); assertThat(redispatched.getAttempt()).isEqualTo(4); assertThat(redispatched.getWatermark()).isEqualTo(ANSWER_A); @@ -215,7 +218,7 @@ class ShReconcileTaskTest { running.setScannedRows(4); when(workRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(running)); - AnswerReconcileWork accepted = worker.accept(instruction(2), SELF); + AnswerReconcileWork accepted = worker.accept(instruction(2), FC); assertThat(accepted.getState()).isEqualTo(AnswerReconcileWork.STATE_RUNNING); assertThat(accepted.getAttempt()).isEqualTo(2); @@ -890,10 +893,14 @@ class ShReconcileTaskTest { private static TaskAnswerReply present(String answerUuid, String hash, Long revision, boolean deleted) { TaskAnswerReply reply = new TaskAnswerReply(); reply.setTaskId(TASK_ID); - reply.setAnswerUuid(answerUuid); + // trustedReplyMatches 加严后回执必须自带消费方坐标与纪元(缺任一即拒修) + reply.setConsumerAppKey(SELF); + reply.setConsumerSchoolId(SCHOOL); reply.setFormUuid(FORM_UUID); reply.setFormVersion(7); + reply.setAnswerUuid(answerUuid); reply.setPresence(FormSyncModels.PRESENCE_PRESENT); + reply.setGrantVersion(1); SnapshotEnvelope envelope = new SnapshotEnvelope(); envelope.setProtocolVersion("fc.answer.snapshot.v2"); envelope.setEventId(TASK_ID);