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