Files
svc-hall-service/src/test/java/com/yunhai/svchall/service/impl/ShAnswerFlowTest.java
T
chenxing 391281582a
build / build-push (push) Successful in 1m21s
t1~t3+精修: 事项FC绑定/可信建件(意图+投影+恢复)/答卷对账Worker
含10-22精修: 对账有限重试(retry_count/指数退避/work_version防旧覆盖)+接缝身份(source-app-key空拒收)+应急对账闸让位FlowApplyRecoveryService+同attempt指令不重开终态。SQL未执行。
2026-10-22 10:03:07 +08:00

1009 lines
46 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 侧)。
*
* <p>取向同 {@code ShFormBindingTest}:协作者喂成桩,规则一改立刻红,不打真库不起上下文。
* 三组最要紧的取证:</p>
* <ol>
* <li><b>顺序无关的收敛</b>(A.5):事件先到 / 确认先到 / 重复投递三种次序都只形成一个办件——
* 夹具在内存里模拟唯一键与"已建件就回原 id"的语义(真库由
* {@code uk_intent_answer}/{@code uk_intent_apply}/{@code uk_projection_answer} 保证);</li>
* <li><b>结果未知不是失败</b>(C.2):apply 响应丢失后先 GET,查到 30 只回填镜像、绝不重发;
* 查无才带同一 {@code requestToken} 补发;</li>
* <li><b>跨端夹具逐字一致</b>(B.1):{@link #canonicalVectorMatchesFormCenter()} 的字面量与
* form-center {@code FcAnswerDeliveryTest.CANONICAL_VECTOR} <b>必须完全相同</b>,
* 任一侧改规范化都会红在两个仓。</li>
* </ol>
*
* <p>manual ack 与死信路由是 broker 运行期行为:本类只能证"投影异常会抛到监听器",
* 真队列行为记在交付说明的待补项(本机不启 rabbit)。</p>
*/
@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<String> 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<String, Object> 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<FcApplyModels.FlowOutbound> 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<org.springframework.transaction.TransactionStatus>) 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.<FcApplyIntent>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<FcAnswerInbox> inboxRows = new ArrayList<>();
final List<FcAnswerProjection> projectionsSaved = new ArrayList<>();
final List<FcApplyIntent> 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<FcApplyIntent> savedIntents = new ArrayList<>();
final List<ContextIssueRequest> 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<FlowApplyCommand> created = new ArrayList<>();
final List<FlowApplyCommand> 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);
}
}
}
}