build / build-push (push) Successful in 1m21s
含10-22精修: 对账有限重试(retry_count/指数退避/work_version防旧覆盖)+接缝身份(source-app-key空拒收)+应急对账闸让位FlowApplyRecoveryService+同attempt指令不重开终态。SQL未执行。
1009 lines
46 KiB
Java
1009 lines
46 KiB
Java
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);
|
||
}
|
||
}
|
||
}
|
||
}
|