package com.yunhai.svchall.service.impl; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.model.BoaRoleInfo; import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.svchall.entity.FcAnswerInbox; import com.yunhai.svchall.entity.FcAnswerProjection; import com.yunhai.svchall.entity.FcApplyIntent; import com.yunhai.svchall.entity.FlowApplyCommand; import com.yunhai.svchall.entity.ServiceApply; import com.yunhai.svchall.entity.ServiceItem; import com.yunhai.svchall.enums.ServiceItemStatusEnum; import com.yunhai.svchall.model.FcApplyModels; import com.yunhai.svchall.model.FcApplyModels.ContextIssueRequest; import com.yunhai.svchall.model.FcApplyModels.ContextIssueView; import com.yunhai.svchall.model.FcApplyModels.ContextReceiptView; import com.yunhai.svchall.model.FcApplyModels.GrantSnapshotView; import com.yunhai.svchall.model.FcApplyModels.IntentConfirmView; import com.yunhai.svchall.model.FcApplyModels.IntentCreateRequest; import com.yunhai.svchall.model.FcApplyModels.IntentView; import com.yunhai.svchall.model.FcApplyModels.Snapshot; import com.yunhai.svchall.model.FcApplyModels.SnapshotEnvelope; import com.yunhai.svchall.repository.FcAnswerInboxRepository; import com.yunhai.svchall.repository.FcAnswerProjectionRepository; import com.yunhai.svchall.repository.FcApplyIntentRepository; import com.yunhai.svchall.repository.FlowApplyCommandRepository; import com.yunhai.svchall.repository.ServiceApplyRepository; import com.yunhai.svchall.repository.ServiceItemRepository; import com.yunhai.svchall.service.FlowClient; 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 org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.support.TransactionCallback; import org.springframework.transaction.support.TransactionTemplate; import java.lang.reflect.Field; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.TreeSet; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** * 可信建件链、快照投影消费与办件恢复的单测(TASK_2 A/B/C 段,SH 侧)。 * *

取向同 {@code ShFormBindingTest}:协作者喂成桩,规则一改立刻红,不打真库不起上下文。 * 三组最要紧的取证:

*
    *
  1. 顺序无关的收敛(A.5):事件先到 / 确认先到 / 重复投递三种次序都只形成一个办件—— * 夹具在内存里模拟唯一键与"已建件就回原 id"的语义(真库由 * {@code uk_intent_answer}/{@code uk_intent_apply}/{@code uk_projection_answer} 保证);
  2. *
  3. 结果未知不是失败(C.2):apply 响应丢失后先 GET,查到 30 只回填镜像、绝不重发; * 查无才带同一 {@code requestToken} 补发;
  4. *
  5. 跨端夹具逐字一致(B.1):{@link #canonicalVectorMatchesFormCenter()} 的字面量与 * form-center {@code FcAnswerDeliveryTest.CANONICAL_VECTOR} 必须完全相同, * 任一侧改规范化都会红在两个仓。
  6. *
* *

manual ack 与死信路由是 broker 运行期行为:本类只能证"投影异常会抛到监听器", * 真队列行为记在交付说明的待补项(本机不启 rabbit)。

*/ @DisplayName("可信建件与快照投影 - SH 侧规则单测(TASK_2)") class ShAnswerFlowTest { private static final String APP_KEY = "100018"; private static final long SCH = 5L; private static final String FORM_UUID = "f0e1d2c3b4a5968778695a4b3c2d1e0f"; private static final String ANSWER_UUID = "a1b2c3d4e5f60718293a4b5c6d7e8f90"; private static final String FC_INTENT_ID = "svchall-0123456789abcdef0123456789abcdef"; /** 与 form-center {@code FcAnswerDeliveryTest.CANONICAL_VECTOR} 逐字相同的跨端夹具(十个成员闭集) */ private static final String CANONICAL_VECTOR = "{\"answerNo\":null,\"appCode\":\"demo\",\"code\":\"honor_apply\"," + "\"createTime\":\"2026-10-21T10:00:00\",\"schId\":5,\"status\":\"processed\"," + "\"subjectId\":7,\"submitterId\":1001,\"updateTime\":null," + "\"values\":{\"a\":{\"y\":null,\"z\":1},\"b\":[2,1],\"e\":true,\"n\":100}}"; private final ObjectMapper mapper = new ObjectMapper(); // ==================== 跨端夹具(B.1) ==================== @Test @DisplayName("fc.canon.v1 跨端夹具:SH 独立实现产出与 form-center 同一规范串") void canonicalVectorMatchesFormCenter() { Snapshot snapshot = new Snapshot(); snapshot.setSchId(5L); snapshot.setAppCode("demo"); snapshot.setCode("honor_apply"); snapshot.setSubmitterId(1001); snapshot.setSubjectId(7); snapshot.setStatus("processed"); snapshot.setAnswerNo(null); snapshot.setCreateTime("2026-10-21T10:00:00"); snapshot.setUpdateTime(null); ObjectNode values = mapper.createObjectNode(); ObjectNode nested = mapper.createObjectNode(); nested.put("z", 1); nested.putNull("y"); values.set("a", nested); values.putArray("b").add(2).add(1); values.put("n", new BigDecimal("100.0")); values.put("e", true); snapshot.setValues(values); assertThat(AnswerProjectionStore.canonicalSnapshot(snapshot)).isEqualTo(CANONICAL_VECTOR); // intent 关联块不进哈希:同 revision 只能有一份 hash snapshot.setIntent(mapper.createObjectNode().put("intentId", FC_INTENT_ID)); assertThat(AnswerProjectionStore.canonicalSnapshot(snapshot)).isEqualTo(CANONICAL_VECTOR); } // ==================== 投影规则(B.5) ==================== @Test @DisplayName("合法信封:投影落库 + 台账 APPLIED,值文档全文取自消息不再回查") void validEnvelopePersistsProjection() { Harness h = harness(); SnapshotEnvelope envelope = envelope(1L, 7, snapshot("{\"k\":\"v\"}")); Disposition result = h.store.applySnapshot(envelope); assertThat(result.getOutcome()).isEqualTo(Outcome.APPLIED); assertThat(result.getState()).isEqualTo(FcAnswerInbox.STATE_APPLIED); assertThat(h.projectionsSaved).hasSize(1); FcAnswerProjection saved = h.projectionsSaved.get(0); assertThat(saved.getRevision()).isEqualTo(1L); assertThat(saved.getValuesJson()).isEqualTo("{\"k\":\"v\"}"); assertThat(saved.getLinkState()).isEqualTo(FcAnswerProjection.LINK_UNLINKED); assertThat(saved.getSchId()).isEqualTo(SCH); assertThat(saved.getContentHash()).isEqualTo(envelope.getContentHash()); } @Test @DisplayName("低 revision 忽略;同 revision 同 hash 幂等;同 revision 异 hash 记冲突且不覆盖") void revisionRulesIgnoreOutOfOrderAndFlagSameVersionDifferentHash() { Harness h = harness(); FcAnswerProjection stored = projection(5L, hash(snapshot("{\"k\":\"old\"}"))); h.currentProjection = stored; h.store.applySnapshot(envelope(4L, 7, snapshot("{\"k\":\"older\"}"))); assertThat(stored.getRevision()).isEqualTo(5L); assertThat(stored.getValuesJson()).isEqualTo("{\"k\":\"old\"}"); h.store.applySnapshot(envelope(5L, 7, snapshot("{\"k\":\"old\"}"))); assertThat(stored.getConflictFlag()).isZero(); Disposition conflict = h.store.applySnapshot(envelope(5L, 7, snapshot("{\"k\":\"other\"}"))); assertThat(stored.getConflictFlag()).isEqualTo(1); assertThat(stored.getValuesJson()).isEqualTo("{\"k\":\"old\"}"); assertThat(stored.getConflictDetail()).contains("同 revision 异 hash"); // 台账记 CONFLICT:投影不覆盖,冲突留给人工/对账核验(B.5) assertThat(conflict.getState()).isEqualTo(FcAnswerInbox.STATE_CONFLICT); } @Test @DisplayName("墓碑只保留 revision:旧 created 复活不了它,更高 revision 的墓碑照常应用") void tombstoneSurvivesStaleCreated() { Harness h = harness(); FcAnswerProjection stored = projection(6L, hash(snapshot("{\"k\":\"v\"}"))); stored.setIsDeleted(1); h.currentProjection = stored; SnapshotEnvelope staleCreated = envelope(5L, 7, snapshot("{\"k\":\"v\"}")); staleCreated.setIsDeleted(false); h.store.applySnapshot(staleCreated); assertThat(stored.getIsDeleted()).isEqualTo(1); assertThat(stored.getRevision()).isEqualTo(6L); SnapshotEnvelope newerTomb = envelope(7L, 7, snapshot("{\"k\":\"v2\"}")); newerTomb.setIsDeleted(true); h.store.applySnapshot(newerTomb); assertThat(stored.getRevision()).isEqualTo(7L); assertThat(stored.getIsDeleted()).isEqualTo(1); assertThat(stored.getValuesJson()).isEqualTo("{\"k\":\"v2\"}"); } @Test @DisplayName("授权已撤销 / 核验不可达:DEFERRED 暂缓,不写投影(v2 §5 竞态口径)") void revokedOrUnverifiableGrantDefersWrite() { Harness h = harness(); h.grantAllowed = false; Disposition revoked = h.store.applySnapshot(envelope(1L, 7, snapshot("{}"))); assertThat(revoked.getOutcome()).isEqualTo(Outcome.DEFERRED); assertThat(revoked.getState()).isEqualTo(FcAnswerInbox.STATE_DEFERRED); assertThat(h.projectionsSaved).isEmpty(); Harness h2 = harness(); h2.grantVerifyThrows = true; assertThat(h2.store.applySnapshot(envelope(1L, 7, snapshot("{}"))).getOutcome()) .isEqualTo(Outcome.DEFERRED); } @Test @DisplayName("信封闭集:协议号/目标应用/纪元/hash 形态/修订号任一不符即 REJECTED 且不落投影") void invalidEnvelopesAreRejected() { Harness h = harness(); SnapshotEnvelope wrongProtocol = envelope(1L, 7, snapshot("{}")); wrongProtocol.setProtocolVersion("fc.answer.snapshot.v1"); assertThat(h.store.applySnapshot(wrongProtocol).getOutcome()).isEqualTo(Outcome.REJECTED); SnapshotEnvelope foreignApp = envelope(1L, 7, snapshot("{}")); foreignApp.setConsumerAppKey("100099"); assertThat(h.store.applySnapshot(foreignApp).getOutcome()).isEqualTo(Outcome.REJECTED); SnapshotEnvelope badHash = envelope(1L, 7, snapshot("{}")); badHash.setContentHash("not-a-64-hex"); assertThat(h.store.applySnapshot(badHash).getOutcome()).isEqualTo(Outcome.REJECTED); SnapshotEnvelope zeroRevision = envelope(0L, 7, snapshot("{}")); assertThat(h.store.applySnapshot(zeroRevision).getOutcome()).isEqualTo(Outcome.REJECTED); SnapshotEnvelope noGrant = envelope(1L, null, snapshot("{}")); assertThat(h.store.applySnapshot(noGrant).getOutcome()).isEqualTo(Outcome.REJECTED); assertThat(h.projectionsSaved).isEmpty(); } @Test @DisplayName("本地复算 hash 与信封不符:按毒载荷拒绝,不沿用中心给的结论") void recomputedHashMismatchRejects() { Harness h = harness(); SnapshotEnvelope envelope = envelope(1L, 7, snapshot("{}")); envelope.setContentHash(chars('f')); Disposition result = h.store.applySnapshot(envelope); assertThat(result.getOutcome()).isEqualTo(Outcome.REJECTED); assertThat(h.inboxRows.get(0).getDetail()).contains("复算不符"); assertThat(h.projectionsSaved).isEmpty(); } @Test @DisplayName("事件先到且意图未验证:只落未关联投影,绝不建件(A.5)") void eventFirstDoesNotCreateApplyWithoutVerifiedIntent() { Harness h = harness(); SnapshotEnvelope envelope = envelope(1L, 7, snapshot("{}")); envelope.getSnapshot().setIntent(mapper.createObjectNode().put("intentId", "unknown-intent")); Disposition result = h.store.applySnapshot(envelope); assertThat(result.getOutcome()).isEqualTo(Outcome.APPLIED); assertThat(result.getApplyId()).isNull(); verify(h.applyService, never()).createFromVerifiedIntent(any(), any()); assertThat(h.projectionsSaved.get(0).getLinkState()).isEqualTo(FcAnswerProjection.LINK_UNLINKED); } @Test @DisplayName("事件带可信关联 → 建件恰好一次;同 eventId 重投回到同一办件") void eventWithVerifiedIntentFormsExactlyOneApply() { Harness h = harness(); FcApplyIntent intent = intent(11, null); intent.setFcIntentId(FC_INTENT_ID); h.intentRows.add(intent); SnapshotEnvelope envelope = envelope(1L, 7, snapshot("{}")); envelope.getSnapshot().setIntent(mapper.createObjectNode().put("intentId", FC_INTENT_ID)); Disposition first = h.store.applySnapshot(envelope); assertThat(first.getApplyId()).isEqualTo(9001); assertThat(h.applyCounter.get()).isEqualTo(1); assertThat(intent.getAnswerUuid()).isEqualTo(ANSWER_UUID); assertThat(intent.getState()).isEqualTo(FcApplyIntent.STATE_ANSWERED); // 建件成功即推进到 APPLIED(LINKED 是"已关联但还没建件"的中间态) assertThat(h.projectionsSaved.get(0).getLinkState()).isEqualTo(FcAnswerProjection.LINK_APPLIED); SnapshotEnvelope repeat = envelope(1L, 7, snapshot("{}")); repeat.setEventId(envelope.getEventId()); repeat.getSnapshot().setIntent(mapper.createObjectNode().put("intentId", FC_INTENT_ID)); Disposition second = h.store.applySnapshot(repeat); assertThat(second.getApplyId()).isEqualTo(9001); assertThat(h.applyCounter.get()).isEqualTo(1); } @Test @DisplayName("确认先到并已建件,事件后到只合并关联,不产生第二个办件") void confirmFirstThenEventMerges() { Harness h = harness(); FcApplyIntent intent = intent(12, 9001); intent.setState(FcApplyIntent.STATE_APPLIED); intent.setFcIntentId(FC_INTENT_ID); intent.setAnswerUuid(ANSWER_UUID); h.intentRows.add(intent); SnapshotEnvelope envelope = envelope(2L, 7, snapshot("{\"k\":\"v\"}")); envelope.getSnapshot().setIntent(mapper.createObjectNode().put("intentId", FC_INTENT_ID)); Disposition result = h.store.applySnapshot(envelope); assertThat(result.getApplyId()).isEqualTo(9001); assertThat(h.applyCounter.get()).isZero(); assertThat(h.projectionsSaved.get(0).getLinkState()).isEqualTo(FcAnswerProjection.LINK_APPLIED); } // ==================== 意图入口与确认(A.1/A.2/A.4) ==================== @Test @DisplayName("入口判定逐条拒:未发布/外链/非在线/无成对绑定/无流程线都不调中心也不建件") void entryChecksRejectBeforeAnyRemoteCall() { IntentHarness ih = intentHarness(); ih.item.setStatus(ServiceItemStatusEnum.DRAFT.getCode()); assertThatThrownBy(() -> ih.service.createIntent(user(1001), request())) .isInstanceOf(BizException.class).hasMessageContaining("未发布"); ih.item.setStatus(ServiceItemStatusEnum.PUBLISHED.getCode()); ih.item.setApplyUrl("https://other.example.com/form"); assertThatThrownBy(() -> ih.service.createIntent(user(1001), request())) .isInstanceOf(BizException.class).hasMessageContaining("手填外链"); ih.item.setApplyUrl(null); ih.item.setOnlineFlag(0); assertThatThrownBy(() -> ih.service.createIntent(user(1001), request())) .isInstanceOf(BizException.class).hasMessageContaining("未开通在线办理"); ih.item.setOnlineFlag(1); ih.item.setFcFormVersion(null); assertThatThrownBy(() -> ih.service.createIntent(user(1001), request())) .isInstanceOf(BizException.class).hasMessageContaining("确切表单版本"); ih.item.setFcFormVersion(4); ih.item.setFlowGuid(null); assertThatThrownBy(() -> ih.service.createIntent(user(1001), request())) .isInstanceOf(BizException.class).hasMessageContaining("未绑定办理流程"); verify(ih.submission, never()).issueContext(any()); verify(ih.intents, never()).saveAndFlush(any()); } @Test @DisplayName("浏览器可提交的字段闭集:只有事项坐标 + 幂等键,没有身份位") void intentRequestCarriesNoIdentityFields() { TreeSet names = new TreeSet<>(); for (Field field : IntentCreateRequest.class.getDeclaredFields()) { if (!field.isSynthetic()) { names.add(field.getName()); } } assertThat(names).containsExactly("clientKey", "itemCode", "siteCode"); } @Test @DisplayName("意图行的提交者/学校/流程线只来自会话与事项行;被嵌页地址由服务端拼") void identityComesFromSessionNotBody() { IntentHarness ih = intentHarness(); IntentView view = ih.service.createIntent(user(1001), request()); FcApplyIntent created = ih.saved(); assertThat(created.getSubmitterId()).isEqualTo(1001); assertThat(created.getSchId()).isEqualTo(SCH); assertThat(created.getFlowGuid()).isEqualTo("fg-1"); assertThat(created.getFcFormUuid()).isEqualTo(FORM_UUID); assertThat(created.getFcFormVersion()).isEqualTo(4); assertThat(created.getConsumerAppKey()).isEqualTo(APP_KEY); assertThat(view.getFillPageUrl()) .startsWith("http://localhost:8089/f/honor_apply?mode=embed") .contains("ctx=token-1"); assertThat(view.getReused()).isFalse(); assertThat(ih.issuedRequests.get(0).getSubmitterId()).isEqualTo(1001); } @Test @DisplayName("同 clientKey 重复 POST:回到同一意图并复用已签发的令牌,不再调中心") void repeatPostReusesSameIntent() { IntentHarness ih = intentHarness(); ih.service.createIntent(user(1001), request()); FcApplyIntent created = ih.saved(); created.setId(77); created.setContextToken("token-1"); created.setFillPageUrl("http://localhost:8089/f/honor_apply?mode=embed&ctx=token-1"); ih.existing = created; IntentView reused = ih.service.createIntent(user(1001), request()); assertThat(reused.getReused()).isTrue(); assertThat(reused.getContextToken()).isEqualTo("token-1"); verify(ih.intents, times(1)).saveAndFlush(any()); verify(ih.submission, times(1)).issueContext(any()); } @Test @DisplayName("中心意图键由自然键哈希得来:并发双 POST 只会得到同一个键,换幂等键才是新键") void fcIntentIdIsDeterministic() { IntentHarness a = intentHarness(); IntentHarness b = intentHarness(); a.service.createIntent(user(1001), request()); b.service.createIntent(user(1001), request()); assertThat(a.saved().getFcIntentId()).isEqualTo(b.saved().getFcIntentId()); assertThat(a.saved().getFcIntentId()).startsWith("svchall-"); assertThat(a.saved().getFcIntentId().length()).isLessThanOrEqualTo(64); IntentHarness c = intentHarness(); IntentCreateRequest otherKey = request(); otherKey.setClientKey("client-key-2"); c.service.createIntent(user(1001), otherKey); assertThat(c.saved().getFcIntentId()).isNotEqualTo(a.saved().getFcIntentId()); } @Test @DisplayName("被嵌页基址未配置即 fail-closed:拼不出地址就不给入口") void missingFillPageBaseFailsClosed() { IntentHarness ih = intentHarness(); ReflectionTestUtils.setField(ih.service, "fcFillPageBaseUrl", ""); assertThatThrownBy(() -> ih.service.createIntent(user(1001), request())) .isInstanceOf(BizException.class).hasMessageContaining("被嵌填报页基址未配置"); } @Test @DisplayName("确认只读服务端收据:未提交就不建件,已提交才建件一次并回带镜像状态") void confirmReadsServerReceiptNotBrowserPayload() { IntentHarness ih = intentHarness(); FcApplyIntent stored = intent(77, null); stored.setFcIntentId(FC_INTENT_ID); ih.existingIntent = stored; ContextReceiptView pending = new ContextReceiptView(); pending.setStatus("PENDING"); ih.receipt = pending; IntentConfirmView notYet = ih.service.confirm(user(1001), 77); assertThat(notYet.getApplyId()).isNull(); assertThat(notYet.getMessage()).contains("尚未收到答卷"); verify(ih.applyService, never()).createFromVerifiedIntent(any(), any()); ContextReceiptView submitted = new ContextReceiptView(); submitted.setStatus("SUBMITTED"); submitted.setAnswerUuid(ANSWER_UUID); submitted.setAnswerRevision(1L); submitted.setContentHash(hash(snapshot("{}"))); submitted.setConsumerAppKey(APP_KEY); submitted.setConsumerSchoolId(SCH); submitted.setSubmitterId(1001); submitted.setFormUuid(FORM_UUID); submitted.setFormVersion(4); submitted.setSnapshot(snapshot("{}")); ih.receipt = submitted; IntentConfirmView done = ih.service.confirm(user(1001), 77); assertThat(done.getApplyId()).isEqualTo(9001); assertThat(done.getAnswerUuid()).isEqualTo(ANSWER_UUID); assertThat(stored.getState()).isEqualTo(FcApplyIntent.STATE_APPLIED); assertThat(ih.storedProjection.getLinkState()).isEqualTo(FcAnswerProjection.LINK_APPLIED); } @Test @DisplayName("收据提交者/坐标与意图不符即拒:别人的答卷与别校的行集都进不了本地办件") void confirmRejectsForeignReceipt() { IntentHarness ih = intentHarness(); FcApplyIntent stored = intent(78, null); stored.setFcIntentId(FC_INTENT_ID); ih.existingIntent = stored; ContextReceiptView otherPerson = new ContextReceiptView(); otherPerson.setStatus("SUBMITTED"); otherPerson.setAnswerUuid(ANSWER_UUID); otherPerson.setAnswerRevision(1L); otherPerson.setSubmitterId(2002); otherPerson.setSnapshot(snapshot("{}")); ih.receipt = otherPerson; assertThatThrownBy(() -> ih.service.confirm(user(1001), 78)) .isInstanceOf(BizException.class).hasMessageContaining("提交者"); ContextReceiptView otherForm = new ContextReceiptView(); otherForm.setStatus("SUBMITTED"); otherForm.setAnswerUuid(ANSWER_UUID); otherForm.setAnswerRevision(1L); otherForm.setSubmitterId(1001); otherForm.setFormUuid("ffffffffffffffffffffffffffffffff"); otherForm.setSnapshot(snapshot("{}")); ih.receipt = otherForm; assertThatThrownBy(() -> ih.service.confirm(user(1001), 78)) .isInstanceOf(BizException.class).hasMessageContaining("坐标"); verify(ih.applyService, never()).createFromVerifiedIntent(any(), any()); } @Test @DisplayName("只能本人确认:拿别人的意图 id 也是 403") void confirmIsOwnerOnly() { IntentHarness ih = intentHarness(); ih.existingIntent = intent(79, null); assertThatThrownBy(() -> ih.service.confirm(user(2002), 79)) .isInstanceOf(BizException.class).hasMessageContaining("本人"); } @Test @DisplayName("已建件的意图再次确认:幂等回原办件,不再调中心也不再建件") void confirmIsIdempotentAfterApply() { IntentHarness ih = intentHarness(); FcApplyIntent applied = intent(80, 9001); applied.setState(FcApplyIntent.STATE_APPLIED); applied.setAnswerUuid(ANSWER_UUID); ih.existingIntent = applied; IntentConfirmView view = ih.service.confirm(user(1001), 80); assertThat(view.getApplyId()).isEqualTo(9001); verify(ih.submission, never()).fetchReceipt(anyString()); verify(ih.applyService, never()).createFromVerifiedIntent(any(), any()); } // ==================== 出站命令与恢复(C.1/C.2) ==================== @Test @DisplayName("响应丢失即 UNKNOWN:先按业务键查,查到 30 只回填镜像、绝不重发 apply") void lostResponseProbesAndBackfillsWithoutReapply() { RecoveryHarness rh = recoveryHarness(); FlowApplyCommand command = rh.pending(); when(rh.flowClient.applyWithIdentity(anyLong(), any())) .thenThrow(new IllegalStateException("read timed out")); Map remote = new HashMap<>(); remote.put("instanceId", 4321L); remote.put("status", 30); remote.put("version", 2); when(rh.flowClient.getInstanceByBiz(eq(SCH), anyString(), anyString())).thenReturn(remote); rh.service.runCommand(command.getId()); assertThat(command.getState()).isEqualTo(FlowApplyCommand.STATE_UNKNOWN); rh.service.runCommand(command.getId()); assertThat(command.getState()).isEqualTo(FlowApplyCommand.STATE_DONE); assertThat(command.getRemoteStatus()).isEqualTo(30); verify(rh.applies).mirrorRemoteState(eq(42), eq(4321L), eq(30), eq(2), any(LocalDateTime.class)); verify(rh.flowClient, times(1)).applyWithIdentity(anyLong(), any()); verify(rh.applies, never()).markApproving(anyInt(), anyLong(), any(LocalDateTime.class)); } @Test @DisplayName("查无才补发,且两次都带同一 requestToken(同一次命令的重投)") void resendOnlyWhenAbsentAndKeepsSameToken() { RecoveryHarness rh = recoveryHarness(); FlowApplyCommand command = rh.pending(); when(rh.flowClient.applyWithIdentity(anyLong(), any())) .thenThrow(new IllegalStateException("connection reset")); when(rh.flowClient.getInstanceByBiz(anyLong(), anyString(), anyString())).thenReturn(null); rh.service.runCommand(command.getId()); assertThat(command.getState()).isEqualTo(FlowApplyCommand.STATE_UNKNOWN); // 再桩必须走 doReturn:when(mock.apply(...)) 会真调用一次并把上一个 thenThrow 抛出来 org.mockito.Mockito.doReturn(new FlowClient.ApplyResult(555L, false)) .when(rh.flowClient).applyWithIdentity(anyLong(), any()); rh.service.runCommand(command.getId()); assertThat(command.getState()).isEqualTo(FlowApplyCommand.STATE_DONE); ArgumentCaptor sent = ArgumentCaptor.forClass(FcApplyModels.FlowOutbound.class); verify(rh.flowClient, times(2)).applyWithIdentity(eq(SCH), sent.capture()); assertThat(sent.getAllValues()).allSatisfy(outbound -> assertThat(outbound.getRequestToken()).isEqualTo(command.getRequestToken())); verify(rh.applies).markApproving(eq(42), eq(555L), any(LocalDateTime.class)); } @Test @DisplayName("轮次只由用户授权的重提推进:attempt+1 且新 token;恢复补发沿用同一行") void attemptOnlyIncrementsOnAuthorizedResubmit() { RecoveryHarness rh = recoveryHarness(); ServiceApply apply = applyRow(); FlowApplyCommand first = rh.service.createInTransaction(apply, null, rh.payload()); assertThat(first.getAttempt()).isEqualTo(1); FlowApplyCommand second = rh.service.createInTransaction(apply, null, rh.payload()); assertThat(second.getAttempt()).isEqualTo(2); assertThat(second.getRequestToken()).isNotEqualTo(first.getRequestToken()); FlowApplyCommand third = rh.service.createInTransaction(apply, null, rh.payload()); assertThat(third.getAttempt()).isEqualTo(3); } @Test @DisplayName("未接入流程中心不算失败:命令退避保留,办件不写成失败态") void disabledFlowKeepsCommandForRecovery() { RecoveryHarness rh = recoveryHarness(); FlowApplyCommand command = rh.pending(); when(rh.flowClient.enabled()).thenReturn(false); rh.service.runCommand(command.getId()); assertThat(command.getState()).isEqualTo(FlowApplyCommand.STATE_FAILED); assertThat(command.getRetryCount()).isEqualTo(1); assertThat(command.getNextRetryAt()).isAfter(LocalDateTime.now().minusSeconds(5)); verify(rh.flowClient, never()).applyWithIdentity(anyLong(), any()); } @Test @DisplayName("有界重试耗尽转 DEAD:留可查记录,不无限重投热循环") void boundedRetriesEndInDead() { RecoveryHarness rh = recoveryHarness(); FlowApplyCommand command = rh.pending(); command.setRetryCount(5); when(rh.flowClient.enabled()).thenReturn(false); rh.service.runCommand(command.getId()); assertThat(command.getState()).isEqualTo(FlowApplyCommand.STATE_DEAD); assertThat(command.getNextRetryAt()).isNull(); } @Test @DisplayName("崩溃窗口恢复:到点的命令被扫描重新推一步并落 DONE") void crashRecoveryRescansDueCommands() { RecoveryHarness rh = recoveryHarness(); FlowApplyCommand command = rh.pending(); command.setNextRetryAt(LocalDateTime.now().minusMinutes(1)); when(rh.commands.findDue(any(LocalDateTime.class), any())) .thenReturn(Collections.singletonList(command)); org.mockito.Mockito.doReturn(new FlowClient.ApplyResult(777L, true)) .when(rh.flowClient).applyWithIdentity(anyLong(), any()); assertThat(rh.service.recoverDue()).isEqualTo(1); assertThat(command.getState()).isEqualTo(FlowApplyCommand.STATE_DONE); assertThat(command.getSettledAt()).isNotNull(); } // ==================== 夹具 ==================== private Snapshot snapshot(String valuesJson) { Snapshot snapshot = new Snapshot(); snapshot.setSchId(SCH); snapshot.setAppCode("demo"); snapshot.setCode("honor_apply"); snapshot.setSubmitterId(1001); snapshot.setSubjectId(1001); snapshot.setStatus("draft"); try { snapshot.setValues((ObjectNode) mapper.readTree(valuesJson)); } catch (Exception e) { throw new IllegalStateException(e); } snapshot.setCreateTime("2026-10-21T10:00:00"); snapshot.setUpdateTime("2026-10-21T10:05:00"); return snapshot; } private String hash(Snapshot snapshot) { return AnswerProjectionStore.canonicalHash(snapshot); } private SnapshotEnvelope envelope(Long revision, Integer grantVersion, Snapshot snapshot) { SnapshotEnvelope envelope = new SnapshotEnvelope(); envelope.setProtocolVersion(FcApplyModels.PROTOCOL_VERSION); envelope.setEventId("evt-" + revision + "-" + System.nanoTime()); envelope.setEventType("updated"); envelope.setSourceAppKey("100021"); envelope.setConsumerAppKey(APP_KEY); envelope.setConsumerSchoolId(SCH); envelope.setGrantVersion(grantVersion); envelope.setFormUuid(FORM_UUID); envelope.setFormVersion(4); envelope.setAnswerUuid(ANSWER_UUID); envelope.setAnswerRevision(revision); envelope.setIsDeleted(false); envelope.setContentHash(hash(snapshot)); envelope.setOccurredAt("2026-10-21T10:05:00+08:00"); envelope.setSnapshot(snapshot); return envelope; } private FcAnswerProjection projection(Long revision, String contentHash) { FcAnswerProjection projection = new FcAnswerProjection(); projection.setSchId(SCH); projection.setAnswerUuid(ANSWER_UUID); projection.setFormUuid(FORM_UUID); projection.setFormVersion(4); projection.setRevision(revision); projection.setContentHash(contentHash); projection.setIsDeleted(0); projection.setStatus("draft"); projection.setValuesJson("{\"k\":\"old\"}"); projection.setLinkState(FcAnswerProjection.LINK_UNLINKED); projection.setConflictFlag(0); return projection; } private FcApplyIntent intent(Integer id, Integer applyId) { FcApplyIntent intent = new FcApplyIntent(); intent.setId(id); intent.setSchId(SCH); intent.setSubmitterId(1001); intent.setItemId(9); intent.setItemCode("svc_apply_01"); intent.setClientKey("client-key-1"); intent.setFcFormUuid(FORM_UUID); intent.setFcFormVersion(4); intent.setFlowGuid("fg-1"); intent.setConsumerAppKey(APP_KEY); intent.setState(FcApplyIntent.STATE_ISSUED); intent.setApplyId(applyId); return intent; } private ServiceApply applyRow() { ServiceApply apply = new ServiceApply(); apply.setId(42); apply.setSchId(SCH); apply.setSiteCode("S1"); apply.setItemId(9); apply.setItemCode("svc_apply_01"); apply.setItemTitle("测试事项"); apply.setSubmitterId(1001); apply.setSubmitterName("张三"); apply.setFlowGuid("fg-1"); return apply; } private static BoaUser user(int id) { BoaUser user = new BoaUser(); user.setId(id); user.setName("张三"); BoaRoleInfo current = new BoaRoleInfo(); current.setSchId((int) SCH); user.setCurrentRole(current); return user; } private static IntentCreateRequest request() { IntentCreateRequest request = new IntentCreateRequest(); request.setSiteCode("S1"); request.setItemCode("svc_apply_01"); request.setClientKey("client-key-1"); return request; } private static String chars(char c) { StringBuilder sb = new StringBuilder(64); for (int i = 0; i < 64; i++) { sb.append(c); } return sb.toString(); } private static TransactionTemplate runningTx() { TransactionTemplate tx = mock(TransactionTemplate.class); when(tx.execute(any())).thenAnswer(call -> ((TransactionCallback) call.getArgument(0)).doInTransaction(null)); doAnswer(call -> { ((Consumer) call.getArgument(0)).accept(null); return null; }).when(tx).executeWithoutResult(any()); return tx; } /** 投影夹具:三个仓库用内存桩模拟唯一键与"已建件回原 id" */ private Harness harness() { Harness h = new Harness(); h.projections = mock(FcAnswerProjectionRepository.class); h.inboxRepository = mock(FcAnswerInboxRepository.class); h.intents = mock(FcApplyIntentRepository.class); h.submission = mock(FcSubmissionClient.class); h.applyService = mock(ServiceApplyService.class); when(h.projections.findByAnswerUuidAndSchId(ANSWER_UUID, SCH)).thenAnswer(call -> Optional.ofNullable(h.currentProjection)); when(h.projections.save(any())).thenAnswer(call -> { FcAnswerProjection projection = call.getArgument(0); if (!h.projectionsSaved.contains(projection)) { h.projectionsSaved.add(projection); } h.currentProjection = projection; return projection; }); when(h.inboxRepository.findByEventIdAndConsumerAppKey(anyString(), anyString())).thenAnswer(call -> h.inboxRows.stream().filter(row -> row.getEventId().equals(call.getArgument(0))).findFirst()); when(h.inboxRepository.save(any())).thenAnswer(call -> { FcAnswerInbox row = call.getArgument(0); if (row.getId() == null) { row.setId(h.inboxRows.size() + 1); h.inboxRows.add(row); } return row; }); when(h.intents.findByConsumerAppKeyAndFcIntentId(APP_KEY, FC_INTENT_ID)).thenAnswer(call -> h.intentRows.stream().filter(i -> FC_INTENT_ID.equals(i.getFcIntentId())).findFirst()); when(h.intents.findBySchIdAndId(eq(SCH), anyInt())).thenAnswer(call -> h.intentRows.stream().filter(i -> i.getId().equals(call.getArgument(1))).findFirst()); when(h.intents.findByAnswerUuid(anyString())).thenAnswer(call -> h.intentRows.stream().filter(i -> ANSWER_UUID.equals(i.getAnswerUuid())).findFirst()); when(h.intents.save(any())).thenAnswer(call -> call.getArgument(0)); when(h.submission.verifyGrants(anyLong(), any())).thenAnswer(call -> { if (h.grantVerifyThrows) { throw new BizException(ErrorCode.PARAM_ERROR, "表单中心授权核验不可达"); } GrantSnapshotView view = new GrantSnapshotView(); view.setFormUuid(FORM_UUID); view.setConsumerSchoolId(SCH); view.setConsumerAppKey(APP_KEY); view.setSubscribeAllowed(h.grantAllowed); view.setGrantVersion(h.grantAllowed ? 7 : 0); return Collections.singletonList(view); }); when(h.applyService.createFromVerifiedIntent(any(), any())).thenAnswer(call -> { FcApplyIntent intent = call.getArgument(0); if (intent.getApplyId() != null) { return intent.getApplyId(); } intent.setApplyId(9001); h.applyCounter.incrementAndGet(); return 9001; }); h.store = new AnswerProjectionStore(h.projections, h.inboxRepository, h.intents, h.submission, h.applyService, mapper); ReflectionTestUtils.setField(h.store, "consumerAppKey", APP_KEY); ReflectionTestUtils.setField(h.store, "grantCacheTtlSeconds", 0); return h; } private IntentHarness intentHarness() { IntentHarness ih = new IntentHarness(); ih.intents = mock(FcApplyIntentRepository.class); ih.projections = mock(FcAnswerProjectionRepository.class); ih.items = mock(ServiceItemRepository.class); ih.siteDirectory = mock(SiteDirectory.class); ih.siteGuard = mock(SiteGuard.class); ih.submission = mock(FcSubmissionClient.class); ih.applyService = mock(ServiceApplyService.class); when(ih.siteDirectory.requireSite("S1")).thenReturn(new SiteDirectory.SiteInfo("S1", "站点一", SCH)); ih.item = new ServiceItem(); ih.item.setId(9); ih.item.setSchId(SCH); ih.item.setSiteCode("S1"); ih.item.setItemCode("svc_apply_01"); ih.item.setTitle("测试事项"); ih.item.setStatus(ServiceItemStatusEnum.PUBLISHED.getCode()); ih.item.setOnlineFlag(1); ih.item.setFcFormUuid(FORM_UUID); ih.item.setFcFormVersion(4); ih.item.setFlowGuid("fg-1"); when(ih.items.findBySchIdAndSiteCodeAndItemCode(SCH, "S1", "svc_apply_01")) .thenReturn(Optional.of(ih.item)); when(ih.intents.findBySchIdAndSubmitterIdAndItemIdAndClientKey(SCH, 1001, 9, "client-key-1")) .thenAnswer(call -> Optional.ofNullable(ih.existing)); when(ih.intents.findBySchIdAndId(eq(SCH), anyInt())).thenAnswer(call -> ih.existingIntent != null && ih.existingIntent.getId().equals(call.getArgument(1)) ? Optional.of(ih.existingIntent) : Optional.empty()); when(ih.intents.saveAndFlush(any())).thenAnswer(call -> { FcApplyIntent intent = call.getArgument(0); intent.setId(77); ih.savedIntents.add(intent); return intent; }); when(ih.intents.save(any())).thenAnswer(call -> call.getArgument(0)); ContextIssueView issued = new ContextIssueView(); issued.setIntentId(FC_INTENT_ID); issued.setContextToken("token-1"); issued.setEmbedOrigin("http://localhost:8087"); issued.setCallbackUrl("http://localhost:8086/api/apply/intents"); issued.setAppCode("demo"); issued.setCode("honor_apply"); issued.setFormUuid(FORM_UUID); issued.setFormVersion(4); issued.setExpiresAt("2026-10-21T10:30:00"); when(ih.submission.issueContext(any(ContextIssueRequest.class))).thenAnswer(call -> { ih.issuedRequests.add(call.getArgument(0)); return issued; }); when(ih.submission.fetchReceipt(anyString())).thenAnswer(call -> ih.receipt); when(ih.projections.findByAnswerUuidAndSchId(anyString(), anyLong())).thenAnswer(call -> Optional.ofNullable(ih.storedProjection)); when(ih.projections.save(any())).thenAnswer(call -> { ih.storedProjection = call.getArgument(0); return ih.storedProjection; }); when(ih.applyService.createFromVerifiedIntent(any(), any())).thenAnswer(call -> { FcApplyIntent intent = call.getArgument(0); if (intent.getApplyId() != null) { return intent.getApplyId(); } intent.setApplyId(9001); ih.applyCounter.incrementAndGet(); return 9001; }); ih.service = new FcApplyIntentService(ih.intents, ih.projections, ih.items, ih.siteDirectory, ih.siteGuard, ih.submission, ih.applyService, runningTx()); ReflectionTestUtils.setField(ih.service, "consumerAppKey", APP_KEY); ReflectionTestUtils.setField(ih.service, "fcFillPageBaseUrl", "http://localhost:8089"); return ih; } private RecoveryHarness recoveryHarness() { RecoveryHarness rh = new RecoveryHarness(); rh.commands = mock(FlowApplyCommandRepository.class); rh.applies = mock(ServiceApplyRepository.class); rh.flowClient = mock(FlowClient.class); when(rh.flowClient.enabled()).thenReturn(true); when(rh.commands.saveAndFlush(any())).thenAnswer(call -> { FlowApplyCommand command = call.getArgument(0); if (command.getId() == null) { command.setId(rh.created.size() + 1); rh.created.add(command); } rh.history.add(command); return command; }); when(rh.commands.findByApplyIdOrderByIdDesc(anyInt())).thenAnswer(call -> rh.history); when(rh.commands.findByIdForUpdate(anyInt())).thenAnswer(call -> rh.created.stream() .filter(c -> c.getId().equals(call.getArgument(0))).findFirst()); when(rh.commands.findById(anyInt())).thenAnswer(call -> rh.created.stream() .filter(c -> c.getId().equals(call.getArgument(0))).findFirst()); when(rh.commands.findDue(any(LocalDateTime.class), any())).thenReturn(Collections.emptyList()); rh.service = new FlowApplyRecoveryService(rh.commands, rh.applies, rh.flowClient, mapper, runningTx()); ReflectionTestUtils.setField(rh.service, "maxRetries", 6); ReflectionTestUtils.setField(rh.service, "backoffBaseSeconds", 20L); ReflectionTestUtils.setField(rh.service, "backoffCapSeconds", 1800L); ReflectionTestUtils.setField(rh.service, "batchSize", 50); return rh; } private static final class Harness { FcAnswerProjectionRepository projections; FcAnswerInboxRepository inboxRepository; FcApplyIntentRepository intents; FcSubmissionClient submission; ServiceApplyService applyService; AnswerProjectionStore store; final List inboxRows = new ArrayList<>(); final List projectionsSaved = new ArrayList<>(); final List intentRows = new ArrayList<>(); final AtomicInteger applyCounter = new AtomicInteger(); FcAnswerProjection currentProjection; boolean grantAllowed = true; boolean grantVerifyThrows; } private static final class IntentHarness { FcApplyIntentRepository intents; FcAnswerProjectionRepository projections; ServiceItemRepository items; SiteDirectory siteDirectory; SiteGuard siteGuard; FcSubmissionClient submission; ServiceApplyService applyService; FcApplyIntentService service; ServiceItem item; FcApplyIntent existing; FcApplyIntent existingIntent; FcAnswerProjection storedProjection; ContextReceiptView receipt; final List savedIntents = new ArrayList<>(); final List issuedRequests = new ArrayList<>(); final AtomicInteger applyCounter = new AtomicInteger(); FcApplyIntent saved() { return savedIntents.get(savedIntents.size() - 1); } } private final class RecoveryHarness { FlowApplyCommandRepository commands; ServiceApplyRepository applies; FlowClient flowClient; FlowApplyRecoveryService service; final List created = new ArrayList<>(); final List history = new ArrayList<>(); FlowApplyCommand pending() { return service.createInTransaction(applyRow(), null, payload()); } String payload() { FcApplyModels.FlowOutbound outbound = new FcApplyModels.FlowOutbound(); outbound.setFlowGuid("fg-1"); outbound.setBizType("svc-hall"); outbound.setBizId("42"); outbound.setBizTitle("张三的测试事项"); outbound.setBizUrl("/apply-detail?id=42"); outbound.setSubmitterId(1001); outbound.setSubmitterName("张三"); outbound.setFormDataJson("{}"); try { return mapper.writeValueAsString(outbound); } catch (Exception e) { throw new IllegalStateException(e); } } } }