Preserve reviewed existing gate prerequisites
Local review candidate only. Explicitly selected inherited prerequisites and prior repair patches; unrelated WIP and independent MQ fixes are excluded. No push or deployment authorized.
This commit is contained in:
@@ -42,6 +42,16 @@ class FlowReconcileJobTest {
|
|||||||
flowClient = mock(FlowClient.class);
|
flowClient = mock(FlowClient.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** C.1 起 Job 依赖出站命令仓判"新命令链办件归恢复服务管";测试注入空历史桩,否则 NPE */
|
||||||
|
private FlowReconcileJob job(boolean enabled) {
|
||||||
|
FlowReconcileJob job = new FlowReconcileJob(applyRepository, flowClient, enabled);
|
||||||
|
com.yunhai.svchall.repository.FlowApplyCommandRepository commands =
|
||||||
|
mock(com.yunhai.svchall.repository.FlowApplyCommandRepository.class);
|
||||||
|
when(commands.findByApplyIdOrderByIdDesc(anyInt())).thenReturn(Collections.emptyList());
|
||||||
|
ReflectionTestUtils.setField(job, "commandRepository", commands);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
private ServiceApply apply(int status, Integer lastVersion) {
|
private ServiceApply apply(int status, Integer lastVersion) {
|
||||||
ServiceApply apply = new ServiceApply();
|
ServiceApply apply = new ServiceApply();
|
||||||
ReflectionTestUtils.setField(apply, "id", 42);
|
ReflectionTestUtils.setField(apply, "id", 42);
|
||||||
@@ -54,7 +64,7 @@ class FlowReconcileJobTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void disabledByDefaultJobDoesNothing() {
|
void disabledByDefaultJobDoesNothing() {
|
||||||
FlowReconcileJob job = new FlowReconcileJob(applyRepository, flowClient, false);
|
FlowReconcileJob job = job(false);
|
||||||
job.reconcile();
|
job.reconcile();
|
||||||
verify(flowClient, never()).enabled();
|
verify(flowClient, never()).enabled();
|
||||||
verify(applyRepository, never()).findByFlowStatus(anyInt(), any(Pageable.class));
|
verify(applyRepository, never()).findByFlowStatus(anyInt(), any(Pageable.class));
|
||||||
@@ -71,7 +81,7 @@ class FlowReconcileJobTest {
|
|||||||
remote.put("version", 4);
|
remote.put("version", 4);
|
||||||
when(flowClient.getInstanceByBiz(1L, "svc_item", "42")).thenReturn(remote);
|
when(flowClient.getInstanceByBiz(1L, "svc_item", "42")).thenReturn(remote);
|
||||||
|
|
||||||
new FlowReconcileJob(applyRepository, flowClient, true).reconcile();
|
job(true).reconcile();
|
||||||
|
|
||||||
assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus());
|
assertEquals(ApplyFlowStatusEnum.COMPLETED.getCode(), apply.getFlowStatus());
|
||||||
assertEquals(Integer.valueOf(4), apply.getLastInstanceVersion());
|
assertEquals(Integer.valueOf(4), apply.getLastInstanceVersion());
|
||||||
@@ -90,7 +100,7 @@ class FlowReconcileJobTest {
|
|||||||
remote.put("version", 2);
|
remote.put("version", 2);
|
||||||
when(flowClient.getInstanceByBiz(anyLong(), anyString(), anyString())).thenReturn(remote);
|
when(flowClient.getInstanceByBiz(anyLong(), anyString(), anyString())).thenReturn(remote);
|
||||||
|
|
||||||
new FlowReconcileJob(applyRepository, flowClient, true).reconcile();
|
job(true).reconcile();
|
||||||
|
|
||||||
verify(applyRepository, never()).save(any());
|
verify(applyRepository, never()).save(any());
|
||||||
}
|
}
|
||||||
@@ -98,7 +108,7 @@ class FlowReconcileJobTest {
|
|||||||
@Test
|
@Test
|
||||||
void enabledJobSkipsWhenFlowCenterDisabled() {
|
void enabledJobSkipsWhenFlowCenterDisabled() {
|
||||||
when(flowClient.enabled()).thenReturn(false);
|
when(flowClient.enabled()).thenReturn(false);
|
||||||
new FlowReconcileJob(applyRepository, flowClient, true).reconcile();
|
job(true).reconcile();
|
||||||
verify(applyRepository, never()).findByFlowStatus(anyInt(), any(Pageable.class));
|
verify(applyRepository, never()).findByFlowStatus(anyInt(), any(Pageable.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +121,7 @@ class FlowReconcileJobTest {
|
|||||||
when(flowClient.getInstanceByBiz(anyLong(), anyString(), anyString()))
|
when(flowClient.getInstanceByBiz(anyLong(), anyString(), anyString()))
|
||||||
.thenThrow(new RuntimeException("boom"));
|
.thenThrow(new RuntimeException("boom"));
|
||||||
|
|
||||||
new FlowReconcileJob(applyRepository, flowClient, true).reconcile();
|
job(true).reconcile();
|
||||||
|
|
||||||
verify(applyRepository, never()).save(any());
|
verify(applyRepository, never()).save(any());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import com.yunhai.svchall.service.SiteDirectory;
|
|||||||
import com.yunhai.svchall.service.SiteGuard;
|
import com.yunhai.svchall.service.SiteGuard;
|
||||||
import com.yunhai.svchall.service.impl.AnswerProjectionStore.Disposition;
|
import com.yunhai.svchall.service.impl.AnswerProjectionStore.Disposition;
|
||||||
import com.yunhai.svchall.service.impl.AnswerProjectionStore.Outcome;
|
import com.yunhai.svchall.service.impl.AnswerProjectionStore.Outcome;
|
||||||
|
import com.yunhai.svchall.service.impl.AnswerProjectionStore.RepairResult;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
@@ -320,6 +321,70 @@ class ShAnswerFlowTest {
|
|||||||
assertThat(h.projectionsSaved.get(0).getLinkState()).isEqualTo(FcAnswerProjection.LINK_APPLIED);
|
assertThat(h.projectionsSaved.get(0).getLinkState()).isEqualTo(FcAnswerProjection.LINK_APPLIED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("已受理后答卷再演进:普通投影跟随最新版,意图受理坐标与办件关联保持固化(决策③)")
|
||||||
|
void appliedEvidenceStaysFrozenWhenNewerRevisionArrives() {
|
||||||
|
Harness h = harness();
|
||||||
|
FcApplyIntent intent = intent(12, 9001);
|
||||||
|
intent.setState(FcApplyIntent.STATE_APPLIED);
|
||||||
|
intent.setFcIntentId(FC_INTENT_ID);
|
||||||
|
intent.setAnswerUuid(ANSWER_UUID);
|
||||||
|
intent.setAnswerRevision(1L);
|
||||||
|
String acceptedHash = hash(snapshot("{\"k\":\"v1\"}"));
|
||||||
|
intent.setContentHash(acceptedHash);
|
||||||
|
h.intentRows.add(intent);
|
||||||
|
FcAnswerProjection applied = projection(1L, acceptedHash);
|
||||||
|
applied.setValuesJson("{\"k\":\"v1\"}");
|
||||||
|
applied.setIntentId(12);
|
||||||
|
applied.setApplyId(9001);
|
||||||
|
applied.setLinkState(FcAnswerProjection.LINK_APPLIED);
|
||||||
|
h.currentProjection = applied;
|
||||||
|
|
||||||
|
SnapshotEnvelope newer = envelope(2L, 7, snapshot("{\"k\":\"v2\"}"));
|
||||||
|
newer.getSnapshot().setIntent(mapper.createObjectNode().put("intentId", FC_INTENT_ID));
|
||||||
|
Disposition result = h.store.applySnapshot(newer);
|
||||||
|
|
||||||
|
assertThat(result.getApplyId()).isEqualTo(9001);
|
||||||
|
assertThat(h.applyCounter.get()).isZero();
|
||||||
|
// 普通投影(数据面镜像)跟随最新答卷
|
||||||
|
assertThat(applied.getRevision()).isEqualTo(2L);
|
||||||
|
assertThat(applied.getValuesJson()).isEqualTo("{\"k\":\"v2\"}");
|
||||||
|
// 审批证据锚点:意图的受理坐标只记受理那一刻,不被后来事件改写
|
||||||
|
assertThat(intent.getAnswerRevision()).isEqualTo(1L);
|
||||||
|
assertThat(intent.getContentHash()).isEqualTo(acceptedHash);
|
||||||
|
assertThat(applied.getLinkState()).isEqualTo(FcAnswerProjection.LINK_APPLIED);
|
||||||
|
assertThat(applied.getIntentId()).isEqualTo(12);
|
||||||
|
assertThat(applied.getApplyId()).isEqualTo(9001);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("权威修复打在已建件行上:只刷快照镜像,关联列不在条件更新里,也不走建件(决策③)")
|
||||||
|
void authoritativeRepairOfAppliedRowMirrorsOnly() {
|
||||||
|
Harness h = harness();
|
||||||
|
FcAnswerProjection applied = projection(1L, hash(snapshot("{\"k\":\"v1\"}")));
|
||||||
|
applied.setValuesJson("{\"k\":\"v1\"}");
|
||||||
|
applied.setIntentId(12);
|
||||||
|
applied.setApplyId(9001);
|
||||||
|
applied.setLinkState(FcAnswerProjection.LINK_APPLIED);
|
||||||
|
h.currentProjection = applied;
|
||||||
|
when(h.projections.repairIfUnchanged(any(), any(), any(), any())).thenReturn(1);
|
||||||
|
|
||||||
|
RepairResult result = h.store.repairAuthoritative(envelope(5L, 7, snapshot("{\"k\":\"v5\"}")),
|
||||||
|
1L, hash(snapshot("{\"k\":\"v1\"}")), 0, "proof-1");
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(RepairResult.APPLIED);
|
||||||
|
ArgumentCaptor<FcAnswerProjection> replacement = ArgumentCaptor.forClass(FcAnswerProjection.class);
|
||||||
|
verify(h.projections).repairIfUnchanged(replacement.capture(), eq(1L),
|
||||||
|
eq(hash(snapshot("{\"k\":\"v1\"}"))), eq(0));
|
||||||
|
// 关联列不在 UPDATE SET 里(数据库语义由 ShProjectionRepairSqlGuardTest 钉住),replacement 里根本没有它们
|
||||||
|
assertThat(replacement.getValue().getIntentId()).isNull();
|
||||||
|
assertThat(replacement.getValue().getApplyId()).isNull();
|
||||||
|
assertThat(replacement.getValue().getValuesJson()).isEqualTo("{\"k\":\"v5\"}");
|
||||||
|
// 修复不产生任何业务副作用:不建件、不回写办件证据
|
||||||
|
verify(h.projections, never()).save(any());
|
||||||
|
verify(h.applyService, never()).createFromVerifiedIntent(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 意图入口与确认(A.1/A.2/A.4) ====================
|
// ==================== 意图入口与确认(A.1/A.2/A.4) ====================
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -526,6 +591,136 @@ class ShAnswerFlowTest {
|
|||||||
verify(ih.applyService, never()).createFromVerifiedIntent(any(), any());
|
verify(ih.applyService, never()).createFromVerifiedIntent(any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("无 MQ 环境(决策①/④):签名收据是唯一事实源,十个快照成员完整落 fc_answer_projection")
|
||||||
|
void receiptWithoutMqLandsCompleteProjection() {
|
||||||
|
IntentHarness ih = intentHarness();
|
||||||
|
FcApplyIntent stored = intent(77, null);
|
||||||
|
stored.setFcIntentId(FC_INTENT_ID);
|
||||||
|
ih.existingIntent = stored;
|
||||||
|
|
||||||
|
Snapshot full = snapshot("{\"name\":\"张三\",\"event\":\"校运动会\"}");
|
||||||
|
full.setAnswerNo("AN-2026-001");
|
||||||
|
String receiptHash = AnswerProjectionStore.canonicalHash(full);
|
||||||
|
ContextReceiptView receipt = new ContextReceiptView();
|
||||||
|
receipt.setStatus("SUBMITTED");
|
||||||
|
receipt.setAnswerUuid(ANSWER_UUID);
|
||||||
|
receipt.setAnswerRevision(1L);
|
||||||
|
receipt.setContentHash(receiptHash);
|
||||||
|
receipt.setConsumerAppKey(APP_KEY);
|
||||||
|
receipt.setConsumerSchoolId(SCH);
|
||||||
|
receipt.setSubmitterId(1001);
|
||||||
|
receipt.setFormUuid(FORM_UUID);
|
||||||
|
receipt.setFormVersion(4);
|
||||||
|
receipt.setSnapshot(full);
|
||||||
|
ih.receipt = receipt;
|
||||||
|
|
||||||
|
IntentConfirmView done = ih.service.confirm(user(1001), 77);
|
||||||
|
|
||||||
|
assertThat(done.getApplyId()).isEqualTo(9001);
|
||||||
|
FcAnswerProjection p = ih.storedProjection;
|
||||||
|
assertThat(p.getSchId()).isEqualTo(SCH);
|
||||||
|
assertThat(p.getAnswerUuid()).isEqualTo(ANSWER_UUID);
|
||||||
|
assertThat(p.getFormUuid()).isEqualTo(FORM_UUID);
|
||||||
|
assertThat(p.getFormVersion()).isEqualTo(4);
|
||||||
|
assertThat(p.getRevision()).isEqualTo(1L);
|
||||||
|
assertThat(p.getContentHash()).isEqualTo(receiptHash);
|
||||||
|
assertThat(p.getIsDeleted()).isZero();
|
||||||
|
// 十个快照成员逐项落库(数据面镜像全文,不加工不丢弃)
|
||||||
|
assertThat(p.getAppCode()).isEqualTo("demo");
|
||||||
|
assertThat(p.getCode()).isEqualTo("honor_apply");
|
||||||
|
assertThat(p.getSubmitterId()).isEqualTo(1001);
|
||||||
|
assertThat(p.getSubjectId()).isEqualTo(1001);
|
||||||
|
assertThat(p.getStatus()).isEqualTo("draft");
|
||||||
|
assertThat(p.getValuesJson()).isEqualTo("{\"name\":\"张三\",\"event\":\"校运动会\"}");
|
||||||
|
assertThat(p.getAnswerNo()).isEqualTo("AN-2026-001");
|
||||||
|
assertThat(p.getAnswerCreateTime()).isEqualTo("2026-10-21T10:00:00");
|
||||||
|
assertThat(p.getAnswerUpdateTime()).isEqualTo("2026-10-21T10:05:00");
|
||||||
|
// 关联与受理
|
||||||
|
assertThat(p.getIntentId()).isEqualTo(77);
|
||||||
|
assertThat(p.getApplyId()).isEqualTo(9001);
|
||||||
|
assertThat(p.getLinkState()).isEqualTo(FcAnswerProjection.LINK_APPLIED);
|
||||||
|
assertThat(stored.getAnswerUuid()).isEqualTo(ANSWER_UUID);
|
||||||
|
assertThat(stored.getAnswerRevision()).isEqualTo(1L);
|
||||||
|
assertThat(stored.getContentHash()).isEqualTo(receiptHash);
|
||||||
|
assertThat(stored.getState()).isEqualTo(FcApplyIntent.STATE_APPLIED);
|
||||||
|
assertThat(stored.getConfirmedAt()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("无 MQ 建件(决策①):办件固化收据快照,出站命令载荷自持同一份冻结数据,详情读固化副本")
|
||||||
|
void verifiedIntentCreatesApplyWithFrozenSnapshotAndOutboundCommand() {
|
||||||
|
FcAnswerProjection projection = projection(1L, null);
|
||||||
|
projection.setContentHash(hash(snapshot("{}")));
|
||||||
|
projection.setValuesJson("{\"name\":\"张三\",\"event\":\"校运动会\"}");
|
||||||
|
FcApplyIntent intent = intent(11, null);
|
||||||
|
|
||||||
|
ServiceApplyRepository applyRepository = mock(ServiceApplyRepository.class);
|
||||||
|
final ServiceApply[] savedApply = new ServiceApply[1];
|
||||||
|
when(applyRepository.saveAndFlush(any())).thenAnswer(call -> {
|
||||||
|
ServiceApply a = call.getArgument(0);
|
||||||
|
ReflectionTestUtils.setField(a, "id", 42);
|
||||||
|
savedApply[0] = a;
|
||||||
|
return a;
|
||||||
|
});
|
||||||
|
when(applyRepository.findById(42)).thenAnswer(call -> Optional.ofNullable(savedApply[0]));
|
||||||
|
ServiceItemRepository items = mock(ServiceItemRepository.class);
|
||||||
|
ServiceItem item = new ServiceItem();
|
||||||
|
item.setId(9);
|
||||||
|
item.setItemCode("svc_apply_01");
|
||||||
|
item.setTitle("测试事项");
|
||||||
|
item.setStatus(ServiceItemStatusEnum.PUBLISHED.getCode());
|
||||||
|
when(items.findById(9)).thenReturn(Optional.of(item));
|
||||||
|
FlowClient flowClient = mock(FlowClient.class);
|
||||||
|
when(flowClient.enabled()).thenReturn(true);
|
||||||
|
com.yunhai.svchall.repository.FcApplyIntentRepository intents =
|
||||||
|
mock(com.yunhai.svchall.repository.FcApplyIntentRepository.class);
|
||||||
|
when(intents.saveAndFlush(any())).thenAnswer(call -> call.getArgument(0));
|
||||||
|
// 出站命令用真实例(spy):payloadJson/createInTransaction 的载荷自持冻结数据必须是真话
|
||||||
|
FlowApplyCommandRepository commandRepository = mock(FlowApplyCommandRepository.class);
|
||||||
|
when(commandRepository.findByApplyIdOrderByIdDesc(anyInt()))
|
||||||
|
.thenReturn(Collections.emptyList());
|
||||||
|
when(commandRepository.saveAndFlush(any())).thenAnswer(call -> {
|
||||||
|
FlowApplyCommand c = call.getArgument(0);
|
||||||
|
ReflectionTestUtils.setField(c, "id", 7);
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
FlowApplyRecoveryService flowCommands = org.mockito.Mockito.spy(new FlowApplyRecoveryService(
|
||||||
|
commandRepository, mock(ServiceApplyRepository.class), flowClient, mapper, runningTx()));
|
||||||
|
|
||||||
|
ServiceApplyService applyService = new ServiceApplyService(applyRepository, items,
|
||||||
|
mock(ServiceFormService.class), mock(AttachmentService.class), flowClient,
|
||||||
|
mock(SiteGuard.class), mock(SiteDirectory.class),
|
||||||
|
new com.yunhai.svchall.service.ApplyViewGuard(flowClient), mapper, runningTx());
|
||||||
|
applyService.setFlowCommandsForTest(flowCommands);
|
||||||
|
applyService.setFcIntentsForTest(intents);
|
||||||
|
|
||||||
|
Integer applyId = applyService.createFromVerifiedIntent(intent, projection);
|
||||||
|
|
||||||
|
assertThat(applyId).isEqualTo(42);
|
||||||
|
assertThat(savedApply[0].getSchId()).isEqualTo(SCH);
|
||||||
|
assertThat(savedApply[0].getItemCode()).isEqualTo("svc_apply_01");
|
||||||
|
assertThat(savedApply[0].getFormVersion()).isEqualTo(4);
|
||||||
|
assertThat(savedApply[0].getFlowGuid()).isEqualTo("fg-1");
|
||||||
|
// 提交时固化:办件表单数据抄自投影快照一次,此后投影更新不再回写
|
||||||
|
assertThat(savedApply[0].getFormDataJson()).isEqualTo("{\"name\":\"张三\",\"event\":\"校运动会\"}");
|
||||||
|
assertThat(savedApply[0].getItemSnapshotJson()).contains("测试事项");
|
||||||
|
assertThat(savedApply[0].getFcIntentId()).isEqualTo(11);
|
||||||
|
assertThat(savedApply[0].getFcAnswerUuid()).isEqualTo(ANSWER_UUID);
|
||||||
|
assertThat(intent.getState()).isEqualTo(FcApplyIntent.STATE_APPLIED);
|
||||||
|
assertThat(intent.getApplyId()).isEqualTo(42);
|
||||||
|
// 出站命令与办件同一短事务落库,载荷自持冻结数据(重放不回读办件行)
|
||||||
|
ArgumentCaptor<String> payload = ArgumentCaptor.forClass(String.class);
|
||||||
|
verify(flowCommands).createInTransaction(any(), eq(11), payload.capture());
|
||||||
|
assertThat(payload.getValue()).contains("校运动会").contains("测试事项");
|
||||||
|
verify(flowCommands).dispatchAfterCommit(7);
|
||||||
|
// 展示(决策①"详情可看"):详情 VO 读的是办件行里的固化副本
|
||||||
|
com.yunhai.svchall.model.svc.SvcModels.ApplyVo vo = applyService.detailOfApplyId(42);
|
||||||
|
assertThat(vo).isNotNull();
|
||||||
|
assertThat(vo.getFormDataJson()).isEqualTo("{\"name\":\"张三\",\"event\":\"校运动会\"}");
|
||||||
|
assertThat(vo.getFormVersion()).isEqualTo(4);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 出站命令与恢复(C.1/C.2) ====================
|
// ==================== 出站命令与恢复(C.1/C.2) ====================
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.yunhai.svchall.service.impl;
|
||||||
|
|
||||||
|
import com.yunhai.svchall.config.FcAnswerMqConfiguration;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Scanner;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SH 的 MQ 消费入口默认关闭(2026-10-22 决策④:保留实现,不为技术统一而部署)。
|
||||||
|
*
|
||||||
|
* <p>关闭必须落在<b>启动期</b>而不是运行期判断:{@code @RabbitListener} 注解处理器在容器工厂缺失时
|
||||||
|
* 直接抛异常打断启动,所以消费侧两个类都要挂 {@code @ConditionalOnExpression},且 yml 默认值
|
||||||
|
* 必须是空(部署不显式注入 {@code RABBITMQ_HOST} 就没有消费入口)。这三件事缺一,"MQ 可选"
|
||||||
|
* 就退化成"MQ 必部署"。以后真要接入:给环境注入 RABBITMQ_HOST 即可,实现原样保留。</p>
|
||||||
|
*/
|
||||||
|
@DisplayName("MQ 消费入口默认关 - 条件装配与空默认(决策④)")
|
||||||
|
class ShMqEntryDefaultOffTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("消费者与拓扑配置都挂 spring.rabbitmq.host 条件:未配 broker 时 bean 不存在")
|
||||||
|
void consumerEntryIsConditionallyAssembled() {
|
||||||
|
ConditionalOnExpression consumer =
|
||||||
|
FcAnswerConsumer.class.getAnnotation(ConditionalOnExpression.class);
|
||||||
|
assertThat(consumer).as("FcAnswerConsumer 必须条件装配").isNotNull();
|
||||||
|
assertThat(consumer.value()).contains("spring.rabbitmq.host");
|
||||||
|
|
||||||
|
ConditionalOnExpression configuration =
|
||||||
|
FcAnswerMqConfiguration.class.getAnnotation(ConditionalOnExpression.class);
|
||||||
|
assertThat(configuration).as("FcAnswerMqConfiguration 必须条件装配").isNotNull();
|
||||||
|
assertThat(configuration.value()).contains("spring.rabbitmq.host");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("application.yml 默认 RABBITMQ_HOST 为空:不注入就没有消费入口(决策④默认关)")
|
||||||
|
void ymlKeepsRabbitHostEmptyByDefault() {
|
||||||
|
InputStream in = getClass().getClassLoader().getResourceAsStream("application.yml");
|
||||||
|
assertThat(in).as("classpath 上应有 application.yml").isNotNull();
|
||||||
|
String yml = new Scanner(in, StandardCharsets.UTF_8.name()).useDelimiter("\\A").next();
|
||||||
|
assertThat(yml).contains("host: ${RABBITMQ_HOST:}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.yunhai.svchall.service.impl;
|
||||||
|
|
||||||
|
import com.yunhai.svchall.entity.FcAnswerProjection;
|
||||||
|
import com.yunhai.svchall.repository.FcAnswerProjectionRepository;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权威修复的 SQL 护栏(2026-10-22 决策③"提交时固化"的数据库侧一半)。
|
||||||
|
*
|
||||||
|
* <p>{@code repairIfUnchanged} 是对账唯一能改已落库投影的原语。办件受理后的审批证据固化在
|
||||||
|
* {@code service_apply.form_data_json}(建件时抄一次),投影行只是<b>普通投影(数据面镜像)</b>;
|
||||||
|
* 但镜像行上的关联列({@code intent_id}/{@code apply_id}/{@code link_state})与业务扩展列
|
||||||
|
* ({@code biz_ext_json})一旦被修复顺手改写,"这份答卷是哪个办件的受理依据"就断了。
|
||||||
|
* 这条 JPQL 的 SET 子句必须永远只含快照列——用注解字面量钉住,谁往里加关联列就红。</p>
|
||||||
|
*/
|
||||||
|
@DisplayName("权威修复 SQL 护栏 - repairIfUnchanged 只准刷快照列(决策③)")
|
||||||
|
class ShProjectionRepairSqlGuardTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("SET 子句只含快照列+版本自增;WHERE 带期望三元组;关联列/业务扩展列绝不出现")
|
||||||
|
void repairIfUnchangedUpdatesSnapshotColumnsOnly() throws NoSuchMethodException {
|
||||||
|
Query query = FcAnswerProjectionRepository.class
|
||||||
|
.getMethod("repairIfUnchanged", FcAnswerProjection.class, Long.class, String.class,
|
||||||
|
Integer.class)
|
||||||
|
.getAnnotation(Query.class);
|
||||||
|
assertThat(query).as("repairIfUnchanged 必须是 @Query 条件更新").isNotNull();
|
||||||
|
String jpql = query.value();
|
||||||
|
|
||||||
|
// 快照成员 + 本地写代数自增,全部在 SET 里
|
||||||
|
assertThat(jpql)
|
||||||
|
.contains("p.revision =")
|
||||||
|
.contains("p.contentHash =")
|
||||||
|
.contains("p.formVersion =")
|
||||||
|
.contains("p.isDeleted =")
|
||||||
|
.contains("p.valuesJson =")
|
||||||
|
.contains("p.projectionVersion = p.projectionVersion + 1");
|
||||||
|
// 条件更新裁决:期望三元组必须逐字出现在 WHERE
|
||||||
|
assertThat(jpql)
|
||||||
|
.contains("p.revision = :revision")
|
||||||
|
.contains("p.contentHash = :hash")
|
||||||
|
.contains("p.isDeleted = :deleted");
|
||||||
|
// 关联列与业务扩展列不在 UPDATE 里(关联列保护 uk_projection_intent 的语义,扩展列是业务侧自留地)
|
||||||
|
assertThat(jpql)
|
||||||
|
.doesNotContain("intentId")
|
||||||
|
.doesNotContain("applyId")
|
||||||
|
.doesNotContain("linkState")
|
||||||
|
.doesNotContain("bizExtJson");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,8 @@ import static org.mockito.Mockito.when;
|
|||||||
class ShReconcileTaskTest {
|
class ShReconcileTaskTest {
|
||||||
|
|
||||||
private static final String SELF = "100018";
|
private static final String SELF = "100018";
|
||||||
|
/** 指令签发方=表单中心(svchall.answer-reconcile.source-app-key 的测试值) */
|
||||||
|
private static final String FC = "100021";
|
||||||
private static final String TASK_ID = "0f1e2d3c4b5a69788796a5b4c3d2e1f0";
|
private static final String TASK_ID = "0f1e2d3c4b5a69788796a5b4c3d2e1f0";
|
||||||
private static final String FORM_UUID = "f0e1d2c3b4a5968778695a4b3c2d1e0f";
|
private static final String FORM_UUID = "f0e1d2c3b4a5968778695a4b3c2d1e0f";
|
||||||
private static final String ANSWER_A = "a0000000000000000000000000000001";
|
private static final String ANSWER_A = "a0000000000000000000000000000001";
|
||||||
@@ -103,6 +105,7 @@ class ShReconcileTaskTest {
|
|||||||
worker = new AnswerReconcileWorker(workRepository, projectionStore, projectionRepository,
|
worker = new AnswerReconcileWorker(workRepository, projectionStore, projectionRepository,
|
||||||
formSyncClient, runningTransactionTemplate(), mapper, SELF);
|
formSyncClient, runningTransactionTemplate(), mapper, SELF);
|
||||||
ReflectionTestUtils.setField(worker, "enabled", true);
|
ReflectionTestUtils.setField(worker, "enabled", true);
|
||||||
|
ReflectionTestUtils.setField(worker, "sourceAppKey", FC);
|
||||||
ReflectionTestUtils.setField(worker, "leaseSeconds", 900);
|
ReflectionTestUtils.setField(worker, "leaseSeconds", 900);
|
||||||
ReflectionTestUtils.setField(worker, "maxRepairRetries", 2);
|
ReflectionTestUtils.setField(worker, "maxRepairRetries", 2);
|
||||||
ReflectionTestUtils.setField(worker, "maxErrorItems", 5);
|
ReflectionTestUtils.setField(worker, "maxErrorItems", 5);
|
||||||
@@ -122,7 +125,7 @@ class ShReconcileTaskTest {
|
|||||||
void acceptedInstructionIsPersistedAsQueuedOnly() {
|
void acceptedInstructionIsPersistedAsQueuedOnly() {
|
||||||
when(workRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.empty());
|
when(workRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
AnswerReconcileWork work = worker.accept(instruction(1), SELF);
|
AnswerReconcileWork work = worker.accept(instruction(1), FC);
|
||||||
|
|
||||||
assertThat(work.getState()).isEqualTo(AnswerReconcileWork.STATE_QUEUED);
|
assertThat(work.getState()).isEqualTo(AnswerReconcileWork.STATE_QUEUED);
|
||||||
assertThat(work.getTaskUuid()).isEqualTo(TASK_ID);
|
assertThat(work.getTaskUuid()).isEqualTo(TASK_ID);
|
||||||
@@ -142,14 +145,14 @@ class ShReconcileTaskTest {
|
|||||||
@DisplayName("身份不符一律拒收:验签身份不是本应用、目标应用不是本应用、协议号不符")
|
@DisplayName("身份不符一律拒收:验签身份不是本应用、目标应用不是本应用、协议号不符")
|
||||||
void forgedOrMismatchedInstructionIsRejected() {
|
void forgedOrMismatchedInstructionIsRejected() {
|
||||||
assertThatThrownBy(() -> worker.accept(instruction(1), "999999"))
|
assertThatThrownBy(() -> worker.accept(instruction(1), "999999"))
|
||||||
.isInstanceOf(BizException.class).hasMessageContaining("消费身份不符");
|
.isInstanceOf(BizException.class).hasMessageContaining("签名方");
|
||||||
ReconcileInstruction foreignTarget = instruction(1);
|
ReconcileInstruction foreignTarget = instruction(1);
|
||||||
foreignTarget.setConsumerAppKey("100020");
|
foreignTarget.setConsumerAppKey("100020");
|
||||||
assertThatThrownBy(() -> worker.accept(foreignTarget, SELF))
|
assertThatThrownBy(() -> worker.accept(foreignTarget, FC))
|
||||||
.isInstanceOf(BizException.class).hasMessageContaining("不是本应用");
|
.isInstanceOf(BizException.class).hasMessageContaining("不是本应用");
|
||||||
ReconcileInstruction badProtocol = instruction(1);
|
ReconcileInstruction badProtocol = instruction(1);
|
||||||
badProtocol.setProtocol("fc.form-sync.v9");
|
badProtocol.setProtocol("fc.form-sync.v9");
|
||||||
assertThatThrownBy(() -> worker.accept(badProtocol, SELF))
|
assertThatThrownBy(() -> worker.accept(badProtocol, FC))
|
||||||
.isInstanceOf(BizException.class).hasMessageContaining("协议号不符");
|
.isInstanceOf(BizException.class).hasMessageContaining("协议号不符");
|
||||||
verify(workRepository, never()).save(any(AnswerReconcileWork.class));
|
verify(workRepository, never()).save(any(AnswerReconcileWork.class));
|
||||||
}
|
}
|
||||||
@@ -159,27 +162,27 @@ class ShReconcileTaskTest {
|
|||||||
void instructionCoordinatesAreValidated() {
|
void instructionCoordinatesAreValidated() {
|
||||||
ReconcileInstruction foreignSchool = instruction(1);
|
ReconcileInstruction foreignSchool = instruction(1);
|
||||||
foreignSchool.setConsumerSchoolId(0L);
|
foreignSchool.setConsumerSchoolId(0L);
|
||||||
assertThatThrownBy(() -> worker.accept(foreignSchool, SELF))
|
assertThatThrownBy(() -> worker.accept(foreignSchool, FC))
|
||||||
.isInstanceOf(BizException.class).hasMessageContaining("真实学校");
|
.isInstanceOf(BizException.class).hasMessageContaining("真实学校");
|
||||||
|
|
||||||
ReconcileInstruction longId = instruction(1);
|
ReconcileInstruction longId = instruction(1);
|
||||||
longId.setTaskId("012345678901234567890123456789012345678901234567890123456789");
|
longId.setTaskId("012345678901234567890123456789012345678901234567890123456789");
|
||||||
assertThatThrownBy(() -> worker.accept(longId, SELF))
|
assertThatThrownBy(() -> worker.accept(longId, FC))
|
||||||
.isInstanceOf(BizException.class).hasMessageContaining("36 字符");
|
.isInstanceOf(BizException.class).hasMessageContaining("36 字符");
|
||||||
|
|
||||||
ReconcileInstruction badWindow = instruction(1);
|
ReconcileInstruction badWindow = instruction(1);
|
||||||
badWindow.setWindowStart("2026-10-21 02:00");
|
badWindow.setWindowStart("2026-10-21 02:00");
|
||||||
assertThatThrownBy(() -> worker.accept(badWindow, SELF))
|
assertThatThrownBy(() -> worker.accept(badWindow, FC))
|
||||||
.isInstanceOf(BizException.class).hasMessageContaining("任务起点");
|
.isInstanceOf(BizException.class).hasMessageContaining("任务起点");
|
||||||
|
|
||||||
ReconcileInstruction negativeAttempt = instruction(1);
|
ReconcileInstruction negativeAttempt = instruction(1);
|
||||||
negativeAttempt.setAttempt(-1);
|
negativeAttempt.setAttempt(-1);
|
||||||
assertThatThrownBy(() -> worker.accept(negativeAttempt, SELF))
|
assertThatThrownBy(() -> worker.accept(negativeAttempt, FC))
|
||||||
.isInstanceOf(BizException.class).hasMessageContaining("attempt");
|
.isInstanceOf(BizException.class).hasMessageContaining("attempt");
|
||||||
|
|
||||||
ReconcileInstruction halfCoordinate = instruction(1);
|
ReconcileInstruction halfCoordinate = instruction(1);
|
||||||
halfCoordinate.setFormVersion(null);
|
halfCoordinate.setFormVersion(null);
|
||||||
assertThatThrownBy(() -> worker.accept(halfCoordinate, SELF))
|
assertThatThrownBy(() -> worker.accept(halfCoordinate, FC))
|
||||||
.isInstanceOf(BizException.class).hasMessageContaining("版本确切");
|
.isInstanceOf(BizException.class).hasMessageContaining("版本确切");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,14 +194,14 @@ class ShReconcileTaskTest {
|
|||||||
stored.setScannedRows(7);
|
stored.setScannedRows(7);
|
||||||
when(workRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(stored));
|
when(workRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(stored));
|
||||||
|
|
||||||
AnswerReconcileWork older = worker.accept(instruction(1), SELF);
|
AnswerReconcileWork older = worker.accept(instruction(1), FC);
|
||||||
|
|
||||||
assertThat(older.getAttempt()).isEqualTo(3);
|
assertThat(older.getAttempt()).isEqualTo(3);
|
||||||
assertThat(older.getWatermark()).isEqualTo(ANSWER_A);
|
assertThat(older.getWatermark()).isEqualTo(ANSWER_A);
|
||||||
assertThat(older.getScannedRows()).isEqualTo(7);
|
assertThat(older.getScannedRows()).isEqualTo(7);
|
||||||
verify(workRepository, never()).save(any(AnswerReconcileWork.class));
|
verify(workRepository, never()).save(any(AnswerReconcileWork.class));
|
||||||
|
|
||||||
AnswerReconcileWork redispatched = worker.accept(instruction(4), SELF);
|
AnswerReconcileWork redispatched = worker.accept(instruction(4), FC);
|
||||||
assertThat(redispatched.getTaskUuid()).isEqualTo(TASK_ID);
|
assertThat(redispatched.getTaskUuid()).isEqualTo(TASK_ID);
|
||||||
assertThat(redispatched.getAttempt()).isEqualTo(4);
|
assertThat(redispatched.getAttempt()).isEqualTo(4);
|
||||||
assertThat(redispatched.getWatermark()).isEqualTo(ANSWER_A);
|
assertThat(redispatched.getWatermark()).isEqualTo(ANSWER_A);
|
||||||
@@ -215,7 +218,7 @@ class ShReconcileTaskTest {
|
|||||||
running.setScannedRows(4);
|
running.setScannedRows(4);
|
||||||
when(workRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(running));
|
when(workRepository.findByTaskUuid(TASK_ID)).thenReturn(Optional.of(running));
|
||||||
|
|
||||||
AnswerReconcileWork accepted = worker.accept(instruction(2), SELF);
|
AnswerReconcileWork accepted = worker.accept(instruction(2), FC);
|
||||||
|
|
||||||
assertThat(accepted.getState()).isEqualTo(AnswerReconcileWork.STATE_RUNNING);
|
assertThat(accepted.getState()).isEqualTo(AnswerReconcileWork.STATE_RUNNING);
|
||||||
assertThat(accepted.getAttempt()).isEqualTo(2);
|
assertThat(accepted.getAttempt()).isEqualTo(2);
|
||||||
@@ -890,10 +893,14 @@ class ShReconcileTaskTest {
|
|||||||
private static TaskAnswerReply present(String answerUuid, String hash, Long revision, boolean deleted) {
|
private static TaskAnswerReply present(String answerUuid, String hash, Long revision, boolean deleted) {
|
||||||
TaskAnswerReply reply = new TaskAnswerReply();
|
TaskAnswerReply reply = new TaskAnswerReply();
|
||||||
reply.setTaskId(TASK_ID);
|
reply.setTaskId(TASK_ID);
|
||||||
reply.setAnswerUuid(answerUuid);
|
// trustedReplyMatches 加严后回执必须自带消费方坐标与纪元(缺任一即拒修)
|
||||||
|
reply.setConsumerAppKey(SELF);
|
||||||
|
reply.setConsumerSchoolId(SCHOOL);
|
||||||
reply.setFormUuid(FORM_UUID);
|
reply.setFormUuid(FORM_UUID);
|
||||||
reply.setFormVersion(7);
|
reply.setFormVersion(7);
|
||||||
|
reply.setAnswerUuid(answerUuid);
|
||||||
reply.setPresence(FormSyncModels.PRESENCE_PRESENT);
|
reply.setPresence(FormSyncModels.PRESENCE_PRESENT);
|
||||||
|
reply.setGrantVersion(1);
|
||||||
SnapshotEnvelope envelope = new SnapshotEnvelope();
|
SnapshotEnvelope envelope = new SnapshotEnvelope();
|
||||||
envelope.setProtocolVersion("fc.answer.snapshot.v2");
|
envelope.setProtocolVersion("fc.answer.snapshot.v2");
|
||||||
envelope.setEventId(TASK_ID);
|
envelope.setEventId(TASK_ID);
|
||||||
|
|||||||
Reference in New Issue
Block a user