t1~t3+精修: 事项FC绑定/可信建件(意图+投影+恢复)/答卷对账Worker
build / build-push (push) Successful in 1m21s

含10-22精修: 对账有限重试(retry_count/指数退避/work_version防旧覆盖)+接缝身份(source-app-key空拒收)+应急对账闸让位FlowApplyRecoveryService+同attempt指令不重开终态。SQL未执行。
This commit is contained in:
chenxing
2026-10-22 10:03:07 +08:00
parent 036bdb1f7c
commit 391281582a
45 changed files with 8222 additions and 6 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,202 @@
package com.yunhai.svchall.service.impl;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.flowcallback.FlowCallbackRegistrar;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.svchall.controller.api.svc.SvcFormCatalogController;
import com.yunhai.svchall.entity.ServiceItem;
import com.yunhai.svchall.model.svc.SvcModels.SvcFormCatalogPage;
import com.yunhai.svchall.model.svc.SvcModels.SvcItemSaveRequest;
import com.yunhai.svchall.repository.ServiceFormRepository;
import com.yunhai.svchall.repository.ServiceItemRepository;
import com.yunhai.svchall.service.DeptSource;
import com.yunhai.svchall.service.FlowClient;
import com.yunhai.svchall.service.SiteDirectory;
import com.yunhai.svchall.service.SiteGuard;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.Optional;
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.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 事项的表单中心绑定单测(v3/TASK_1)。
*
* <p>任务书要的证据是「<b>事项保存远端校验失败即拒绝该保存,不写半个绑定</b>」。这里给单元级取证:
* 核验点落在 {@code adminSave} 的<em>任何写动作之前</em>({@code validateBindings}),核验一抛,
* 事项行与附件重绑都不许被碰到——事务回滚是兜底,"根本没写"才是这条链路的常态。
* 中心侧的撤权/跨校/版本行缺失三种情形在客户端都表现为同一次核验失败,本类用"客户端抛错"代表它们
* (各自的规则在 form-center 的 {@code FcDiscoveryAccessTest} 里逐条钉住)。</p>
*
* <p>另外四条:成对提交(单传一个即拒)、本地 {@code form_code} 只读不写(改值即拒、原样回传保留存量绑定,
* 不做"顺手清空历史行"这种二次伤害)、没表单的事项保留既有允许形态、目录代理的校域只能由 CAS 站点派生。</p>
*/
@DisplayName("事项绑定表单中心 - v3 保存链路单测(TASK_1)")
class ShFormBindingTest {
private static final SiteDirectory.SiteInfo SITE = new SiteDirectory.SiteInfo("S1", "站点一", 1L);
private static final String FC_UUID = "fcuuid0000000000000000000000ab";
private ServiceItemRepository itemRepository;
private ServiceFormRepository formRepository;
private AttachmentService attachmentService;
private SiteGuard siteGuard;
private FormCenterCatalogClient catalogClient;
private ServiceItemService service;
@BeforeEach
void setUp() {
itemRepository = mock(ServiceItemRepository.class);
formRepository = mock(ServiceFormRepository.class);
attachmentService = mock(AttachmentService.class);
siteGuard = mock(SiteGuard.class);
when(siteGuard.requireSiteManager(any(), any())).thenReturn(SITE);
when(itemRepository.save(any(ServiceItem.class))).thenAnswer(call -> call.getArgument(0));
catalogClient = mock(FormCenterCatalogClient.class);
service = new ServiceItemService(
itemRepository, formRepository,
mock(SiteDirectory.class), mock(ServiceDictService.class), mock(ServiceFormService.class),
attachmentService, siteGuard, mock(DeptSource.class),
mock(FlowClient.class), mock(FlowCallbackRegistrar.class), mock(TransactionTemplate.class));
// setter 注入(本类那 11 参构造被存量测试逐位钉住,见 ServiceItemService 的字段注释)
service.setFormCenterClient(catalogClient);
}
private static SvcItemSaveRequest request() {
SvcItemSaveRequest req = new SvcItemSaveRequest();
req.setSiteCode("S1");
req.setItemCode("svc_apply_01");
req.setTitle("测试事项");
// 线下形态:不牵动"可在线办理必须绑流程"那条既有规则,本类只管表单坐标
req.setOnlineFlag(0);
return req;
}
private ServiceItem savedItem() {
ArgumentCaptor<ServiceItem> saved = ArgumentCaptor.forClass(ServiceItem.class);
verify(itemRepository).save(saved.capture());
return saved.getValue();
}
@Test
@DisplayName("成对提交:只给 uuid 或只给 version 都拒,且不碰远端、不写库")
void bindingMustBePaired() {
SvcItemSaveRequest uuidOnly = request();
uuidOnly.setFcFormUuid(FC_UUID);
assertThatThrownBy(() -> service.adminSave(null, uuidOnly))
.isInstanceOf(BizException.class)
.hasMessageContaining("必须成对提交");
SvcItemSaveRequest versionOnly = request();
versionOnly.setFcFormVersion(4);
assertThatThrownBy(() -> service.adminSave(null, versionOnly))
.isInstanceOf(BizException.class)
.hasMessageContaining("必须成对提交");
verify(catalogClient, never()).requireBindable(any(), any(), any());
verify(itemRepository, never()).save(any(ServiceItem.class));
}
@Test
@DisplayName("远端核验失败=整次保存失败:事项没落库、附件没重绑(不留半个绑定)")
void remoteCheckFailureWritesNothing() {
SvcItemSaveRequest req = request();
req.setFcFormUuid(FC_UUID);
req.setFcFormVersion(4);
// 撤权 / 跨校 / 版本行缺失,在客户端都是同一次核验失败
doThrow(new BizException(ErrorCode.PARAM_ERROR, "表单中心校验未通过"))
.when(catalogClient).requireBindable(1L, FC_UUID, 4);
assertThatThrownBy(() -> service.adminSave(null, req))
.isInstanceOf(BizException.class)
.hasMessageContaining("表单中心校验未通过");
verify(itemRepository, never()).save(any(ServiceItem.class));
verify(attachmentService, never()).bindGuide(any(), any(), any());
}
@Test
@DisplayName("核验通过才落库:按 CAS 站点派生的校域核验,uuid 去空白后成对写入两列")
void verifiedPairIsStored() {
SvcItemSaveRequest req = request();
req.setFcFormUuid(" " + FC_UUID + " ");
req.setFcFormVersion(7);
service.adminSave(null, req);
verify(catalogClient).requireBindable(eq(1L), eq(FC_UUID), eq(7));
assertThat(savedItem().getFcFormUuid()).isEqualTo(FC_UUID);
assertThat(savedItem().getFcFormVersion()).isEqualTo(7);
}
@Test
@DisplayName("本地 form_code 停写:新建与改值都拒;原样回传保留存量绑定(不被清空)")
void localFormCodeIsReadOnly() {
when(formRepository.existsBySchIdAndCode(1L, "legacy_form")).thenReturn(true);
when(formRepository.existsBySchIdAndCode(1L, "another_form")).thenReturn(true);
// 新建事项就带本地 code:即便这张表在本地存在,也不允许形成新绑定
SvcItemSaveRequest create = request();
create.setFormCode("legacy_form");
assertThatThrownBy(() -> service.adminSave(null, create))
.isInstanceOf(BizException.class)
.hasMessageContaining("本地表单定义已停用");
ServiceItem stored = new ServiceItem();
stored.setFormCode("legacy_form");
when(itemRepository.findByIdAndSchIdAndSiteCode(9, 1L, "S1")).thenReturn(Optional.of(stored));
// 编辑存量事项、换成另一张本地表:同样拒
SvcItemSaveRequest switchCode = request();
switchCode.setId(9);
switchCode.setFormCode("another_form");
assertThatThrownBy(() -> service.adminSave(null, switchCode))
.isInstanceOf(BizException.class)
.hasMessageContaining("本地表单定义已停用");
// 原样回传:历史绑定保留(保存链路不清空存量行)
SvcItemSaveRequest echo = request();
echo.setId(9);
echo.setFormCode("legacy_form");
service.adminSave(null, echo);
assertThat(stored.getFormCode()).isEqualTo("legacy_form");
}
@Test
@DisplayName("没有表单的事项保留既有允许形态:两坐标都不传即正常保存")
void itemWithoutFormStillAllowed() {
service.adminSave(null, request());
assertThat(savedItem().getFcFormUuid()).isNull();
assertThat(savedItem().getFcFormVersion()).isNull();
verify(catalogClient, never()).requireBindable(any(), any(), any());
}
@Test
@DisplayName("目录代理的租户域从 CAS 站点派生:前端没有可越界的校号入口")
void catalogProxyDerivesSchoolFromCas() {
SiteDirectory.SiteInfo site2 = new SiteDirectory.SiteInfo("S2", "站点二", 999L);
when(siteGuard.requireSiteManager(any(), eq("S2"))).thenReturn(site2);
when(catalogClient.listForms(any(), any(), any(), any(), any())).thenReturn(new SvcFormCatalogPage());
SvcFormCatalogController controller = new SvcFormCatalogController(siteGuard, catalogClient);
controller.forms(null, "S1", null, "荣誉", 1, 20);
verify(catalogClient).listForms(1L, null, "荣誉", 1, 20);
controller.forms(null, "S2", null, null, null, null);
verify(catalogClient).listForms(999L, null, null, null, null);
}
}
@@ -0,0 +1,225 @@
package com.yunhai.svchall.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.svchall.entity.FcAnswerProjection;
import com.yunhai.svchall.model.FcApplyModels;
import com.yunhai.svchall.model.FcApplyModels.Snapshot;
import com.yunhai.svchall.model.FcApplyModels.SnapshotEnvelope;
import com.yunhai.svchall.model.FcApplyModels.GrantSnapshotView;
import com.yunhai.svchall.repository.FcAnswerProjectionRepository;
import com.yunhai.svchall.repository.FcAnswerInboxRepository;
import com.yunhai.svchall.repository.FcApplyIntentRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.data.domain.Pageable;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Arrays;
import java.util.Collections;
import java.util.Optional;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class ShReconcileExtensionTest {
private static final String UUID = "00000000000000000000000000000001";
private final ObjectMapper mapper = new ObjectMapper();
private FcAnswerProjectionRepository projections;
private FcAnswerInboxRepository inbox;
private FcApplyIntentRepository intents;
private ServiceApplyService applies;
private FcSubmissionClient submission;
private AnswerProjectionStore store;
private SnapshotEnvelope envelope;
private FcAnswerProjection local;
@BeforeEach
void setup() {
projections = mock(FcAnswerProjectionRepository.class);
inbox = mock(FcAnswerInboxRepository.class);
intents = mock(FcApplyIntentRepository.class);
applies = mock(ServiceApplyService.class);
submission = mock(FcSubmissionClient.class);
store = new AnswerProjectionStore(projections, inbox, intents, submission, applies, mapper);
ReflectionTestUtils.setField(store, "consumerAppKey", "100018");
GrantSnapshotView grant = new GrantSnapshotView();
grant.setFormUuid("form-1");
grant.setConsumerSchoolId(5L);
grant.setConsumerAppKey("100018");
grant.setSubscribeAllowed(true);
grant.setGrantVersion(7);
when(submission.verifyGrants(eq(5L), any())).thenReturn(Collections.singletonList(grant));
Snapshot snapshot = new Snapshot();
snapshot.setSchId(5L);
snapshot.setValues(mapper.createObjectNode().put("value", "authoritative"));
snapshot.setIntent(mapper.createObjectNode().put("intentId", "should-not-link"));
envelope = new SnapshotEnvelope();
envelope.setProtocolVersion(FcApplyModels.PROTOCOL_VERSION);
envelope.setConsumerAppKey("100018");
envelope.setConsumerSchoolId(5L);
envelope.setGrantVersion(7);
envelope.setFormUuid("form-1");
envelope.setFormVersion(4);
envelope.setAnswerUuid(UUID);
envelope.setEventId("reconcile-task-1-answer-1");
envelope.setAnswerRevision(3L);
envelope.setIsDeleted(false);
envelope.setSnapshot(snapshot);
envelope.setContentHash(AnswerProjectionStore.canonicalHash(snapshot));
local = new FcAnswerProjection();
local.setSchId(5L);
local.setAnswerUuid(UUID);
local.setFormUuid("form-1");
local.setRevision(3L);
local.setContentHash("local-corrupt-hash");
local.setIsDeleted(0);
local.setBizExtJson("business-extension");
local.setApplyId(42);
when(projections.findByAnswerUuidAndSchId(UUID, 5L)).thenAnswer(i -> Optional.ofNullable(local));
when(projections.repairIfUnchanged(any(), anyLong(), anyString(), anyInt())).thenReturn(1);
}
private AnswerProjectionStore.RepairResult repair() {
return store.repairAuthoritative(envelope, 3L, "local-corrupt-hash", 0, "task-1/proof-1");
}
@Test
void sameRevisionRepairUsesConditionalSnapshotUpdateWithoutBusinessEffects() {
assertThat(repair()).isEqualTo(AnswerProjectionStore.RepairResult.APPLIED);
ArgumentCaptor<FcAnswerProjection> replacement = ArgumentCaptor.forClass(FcAnswerProjection.class);
verify(projections).repairIfUnchanged(replacement.capture(), eq(3L), eq("local-corrupt-hash"), eq(0));
assertThat(replacement.getValue().getContentHash()).isEqualTo(envelope.getContentHash());
assertThat(replacement.getValue().getValuesJson()).contains("authoritative");
assertThat(replacement.getValue().getConflictFlag()).isZero();
assertThat(local.getBizExtJson()).isEqualTo("business-extension");
assertThat(local.getApplyId()).isEqualTo(42);
verifyNoInteractions(inbox, intents, applies);
}
@Test
void absentRepairDoesNotLinkEvenWhenSnapshotContainsIntent() {
local = null;
assertThat(store.repairAuthoritative(envelope, null, null, null, "proof-absent"))
.isEqualTo(AnswerProjectionStore.RepairResult.APPLIED);
ArgumentCaptor<FcAnswerProjection> row = ArgumentCaptor.forClass(FcAnswerProjection.class);
verify(projections).saveAndFlush(row.capture());
assertThat(row.getValue().getLinkState()).isEqualTo(FcAnswerProjection.LINK_UNLINKED);
assertThat(row.getValue().getIntentId()).isNull();
assertThat(row.getValue().getApplyId()).isNull();
verifyNoInteractions(inbox, intents, applies);
}
@Test
void localChangeBetweenReadAndUpdateIsNotOverwritten() {
when(projections.repairIfUnchanged(any(), anyLong(), anyString(), anyInt())).thenReturn(0);
assertThat(repair()).isEqualTo(AnswerProjectionStore.RepairResult.LOCAL_CHANGED);
assertThat(local.getContentHash()).isEqualTo("local-corrupt-hash");
}
@Test
void changedTupleAndHigherLocalRevisionRequireRecheck() {
local.setIsDeleted(1);
assertThat(repair()).isEqualTo(AnswerProjectionStore.RepairResult.LOCAL_CHANGED);
local.setRevision(4L);
assertThat(repair()).isEqualTo(AnswerProjectionStore.RepairResult.LOCAL_AHEAD);
verify(projections, never()).repairIfUnchanged(any(), anyLong(), anyString(), anyInt());
}
@Test
void newerTombstoneUsesSameRepairPrimitive() {
envelope.setAnswerRevision(4L);
envelope.setIsDeleted(true);
assertThat(repair()).isEqualTo(AnswerProjectionStore.RepairResult.APPLIED);
ArgumentCaptor<FcAnswerProjection> row = ArgumentCaptor.forClass(FcAnswerProjection.class);
verify(projections).repairIfUnchanged(row.capture(), eq(3L), anyString(), eq(0));
assertThat(row.getValue().getIsDeleted()).isEqualTo(1);
assertThat(row.getValue().getRevision()).isEqualTo(4L);
}
@Test
void repairRechecksGrantEvenWithWarmCacheAndRejectsUnknownEpoch() {
assertThat(repair()).isEqualTo(AnswerProjectionStore.RepairResult.APPLIED);
envelope.setGrantVersion(8);
assertThat(repair()).isEqualTo(AnswerProjectionStore.RepairResult.DEFERRED);
when(submission.verifyGrants(eq(5L), any())).thenReturn(Collections.emptyList());
assertThat(repair()).isEqualTo(AnswerProjectionStore.RepairResult.DEFERRED);
verify(submission, times(3)).verifyGrants(eq(5L), any());
}
@Test
void freshGrantMustBelongToTheSameTenantAndConsumer() {
GrantSnapshotView other = new GrantSnapshotView();
other.setFormUuid("form-1");
other.setConsumerSchoolId(6L);
other.setConsumerAppKey("100018");
other.setSubscribeAllowed(true);
other.setGrantVersion(7);
when(submission.verifyGrants(eq(5L), any())).thenReturn(Collections.singletonList(other));
assertThat(repair()).isEqualTo(AnswerProjectionStore.RepairResult.DEFERRED);
other.setConsumerSchoolId(5L);
other.setConsumerAppKey("another-app");
assertThat(repair()).isEqualTo(AnswerProjectionStore.RepairResult.DEFERRED);
verifyNoInteractions(projections);
}
@Test
void malformedProofWrongTenantAndHashAreRejectedWithoutWrites() {
assertThatThrownBy(() -> store.repairAuthoritative(envelope, 3L, null, 0, "proof"))
.isInstanceOf(BizException.class);
envelope.getSnapshot().setSchId(6L);
assertThatThrownBy(this::repair).isInstanceOf(BizException.class);
envelope.getSnapshot().setSchId(5L);
envelope.setContentHash(String.join("", Collections.nCopies(64, "a")));
assertThatThrownBy(this::repair).isInstanceOf(BizException.class);
verify(projections, never()).repairIfUnchanged(any(), anyLong(), anyString(), anyInt());
}
private FcAnswerProjectionRepository.ManifestRow row(String uuid) {
FcAnswerProjectionRepository.ManifestRow row = mock(FcAnswerProjectionRepository.ManifestRow.class);
when(row.getAnswerUuid()).thenReturn(uuid);
when(row.getCreateTime()).thenReturn(LocalDateTime.of(2026, 10, 21, 1, 0));
return row;
}
@Test
void uuidCursorTraversesSparseRowsAndReturnsExplicitEnd() {
String second = "00000000000000000000000000000050";
String third = "00000000000000000000000000000099";
FcAnswerProjectionRepository.ManifestRow firstRow = row(UUID);
FcAnswerProjectionRepository.ManifestRow secondRow = row(second);
FcAnswerProjectionRepository.ManifestRow thirdRow = row(third);
when(projections.findManifest(eq(5L), eq("form-1"), eq(""), any()))
.thenReturn(Arrays.asList(firstRow, secondRow, thirdRow));
when(projections.findManifest(eq(5L), eq("form-1"), eq(second), any()))
.thenReturn(Collections.singletonList(thirdRow));
AnswerProjectionStore.ManifestPage first = store.manifestPage(5L, "form-1", null, 2);
assertThat(first.getRows()).hasSize(2);
assertThat(first.isCompleted()).isFalse();
assertThat(first.getNextCursor()).isEqualTo(second);
assertThat(first.getRows().get(0).getCreateTime()).isNotNull();
AnswerProjectionStore.ManifestPage last = store.manifestPage(5L, "form-1", first.getNextCursor(), 2);
assertThat(last.getRows()).extracting(FcAnswerProjectionRepository.ManifestRow::getAnswerUuid)
.containsExactly(third);
assertThat(last.isCompleted()).isTrue();
assertThat(last.getNextCursor()).isNull();
ArgumentCaptor<Pageable> page = ArgumentCaptor.forClass(Pageable.class);
verify(projections).findManifest(eq(5L), eq("form-1"), eq(""), page.capture());
assertThat(page.getValue().getPageSize()).isEqualTo(3);
}
@Test
void emptyAndExactlyFullPagesAreCompletedAndInvalidCursorIsRejected() {
FcAnswerProjectionRepository.ManifestRow first = row(UUID);
FcAnswerProjectionRepository.ManifestRow second = row("00000000000000000000000000000050");
when(projections.findManifest(anyLong(), anyString(), anyString(), any()))
.thenReturn(Collections.emptyList(), Arrays.asList(first, second));
assertThat(store.manifestPage(5L, "form-1", null, 2).isCompleted()).isTrue();
assertThat(store.manifestPage(5L, "form-1", null, 2).isCompleted()).isTrue();
assertThatThrownBy(() -> store.manifestPage(5L, "form-1", "invalid", 2))
.isInstanceOf(BizException.class);
}
}
File diff suppressed because it is too large Load Diff