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);
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
ServiceApply apply = new ServiceApply();
|
||||
ReflectionTestUtils.setField(apply, "id", 42);
|
||||
@@ -54,7 +64,7 @@ class FlowReconcileJobTest {
|
||||
|
||||
@Test
|
||||
void disabledByDefaultJobDoesNothing() {
|
||||
FlowReconcileJob job = new FlowReconcileJob(applyRepository, flowClient, false);
|
||||
FlowReconcileJob job = job(false);
|
||||
job.reconcile();
|
||||
verify(flowClient, never()).enabled();
|
||||
verify(applyRepository, never()).findByFlowStatus(anyInt(), any(Pageable.class));
|
||||
@@ -71,7 +81,7 @@ class FlowReconcileJobTest {
|
||||
remote.put("version", 4);
|
||||
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(Integer.valueOf(4), apply.getLastInstanceVersion());
|
||||
@@ -90,7 +100,7 @@ class FlowReconcileJobTest {
|
||||
remote.put("version", 2);
|
||||
when(flowClient.getInstanceByBiz(anyLong(), anyString(), anyString())).thenReturn(remote);
|
||||
|
||||
new FlowReconcileJob(applyRepository, flowClient, true).reconcile();
|
||||
job(true).reconcile();
|
||||
|
||||
verify(applyRepository, never()).save(any());
|
||||
}
|
||||
@@ -98,7 +108,7 @@ class FlowReconcileJobTest {
|
||||
@Test
|
||||
void enabledJobSkipsWhenFlowCenterDisabled() {
|
||||
when(flowClient.enabled()).thenReturn(false);
|
||||
new FlowReconcileJob(applyRepository, flowClient, true).reconcile();
|
||||
job(true).reconcile();
|
||||
verify(applyRepository, never()).findByFlowStatus(anyInt(), any(Pageable.class));
|
||||
}
|
||||
|
||||
@@ -111,7 +121,7 @@ class FlowReconcileJobTest {
|
||||
when(flowClient.getInstanceByBiz(anyLong(), anyString(), anyString()))
|
||||
.thenThrow(new RuntimeException("boom"));
|
||||
|
||||
new FlowReconcileJob(applyRepository, flowClient, true).reconcile();
|
||||
job(true).reconcile();
|
||||
|
||||
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.impl.AnswerProjectionStore.Disposition;
|
||||
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.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
@@ -320,6 +321,70 @@ class ShAnswerFlowTest {
|
||||
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) ====================
|
||||
|
||||
@Test
|
||||
@@ -526,6 +591,136 @@ class ShAnswerFlowTest {
|
||||
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) ====================
|
||||
|
||||
@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 {
|
||||
|
||||
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 FORM_UUID = "f0e1d2c3b4a5968778695a4b3c2d1e0f";
|
||||
private static final String ANSWER_A = "a0000000000000000000000000000001";
|
||||
@@ -103,6 +105,7 @@ class ShReconcileTaskTest {
|
||||
worker = new AnswerReconcileWorker(workRepository, projectionStore, projectionRepository,
|
||||
formSyncClient, runningTransactionTemplate(), mapper, SELF);
|
||||
ReflectionTestUtils.setField(worker, "enabled", true);
|
||||
ReflectionTestUtils.setField(worker, "sourceAppKey", FC);
|
||||
ReflectionTestUtils.setField(worker, "leaseSeconds", 900);
|
||||
ReflectionTestUtils.setField(worker, "maxRepairRetries", 2);
|
||||
ReflectionTestUtils.setField(worker, "maxErrorItems", 5);
|
||||
@@ -122,7 +125,7 @@ class ShReconcileTaskTest {
|
||||
void acceptedInstructionIsPersistedAsQueuedOnly() {
|
||||
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.getTaskUuid()).isEqualTo(TASK_ID);
|
||||
@@ -142,14 +145,14 @@ class ShReconcileTaskTest {
|
||||
@DisplayName("身份不符一律拒收:验签身份不是本应用、目标应用不是本应用、协议号不符")
|
||||
void forgedOrMismatchedInstructionIsRejected() {
|
||||
assertThatThrownBy(() -> worker.accept(instruction(1), "999999"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("消费身份不符");
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("签名方");
|
||||
ReconcileInstruction foreignTarget = instruction(1);
|
||||
foreignTarget.setConsumerAppKey("100020");
|
||||
assertThatThrownBy(() -> worker.accept(foreignTarget, SELF))
|
||||
assertThatThrownBy(() -> worker.accept(foreignTarget, FC))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不是本应用");
|
||||
ReconcileInstruction badProtocol = instruction(1);
|
||||
badProtocol.setProtocol("fc.form-sync.v9");
|
||||
assertThatThrownBy(() -> worker.accept(badProtocol, SELF))
|
||||
assertThatThrownBy(() -> worker.accept(badProtocol, FC))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("协议号不符");
|
||||
verify(workRepository, never()).save(any(AnswerReconcileWork.class));
|
||||
}
|
||||
@@ -159,27 +162,27 @@ class ShReconcileTaskTest {
|
||||
void instructionCoordinatesAreValidated() {
|
||||
ReconcileInstruction foreignSchool = instruction(1);
|
||||
foreignSchool.setConsumerSchoolId(0L);
|
||||
assertThatThrownBy(() -> worker.accept(foreignSchool, SELF))
|
||||
assertThatThrownBy(() -> worker.accept(foreignSchool, FC))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("真实学校");
|
||||
|
||||
ReconcileInstruction longId = instruction(1);
|
||||
longId.setTaskId("012345678901234567890123456789012345678901234567890123456789");
|
||||
assertThatThrownBy(() -> worker.accept(longId, SELF))
|
||||
assertThatThrownBy(() -> worker.accept(longId, FC))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("36 字符");
|
||||
|
||||
ReconcileInstruction badWindow = instruction(1);
|
||||
badWindow.setWindowStart("2026-10-21 02:00");
|
||||
assertThatThrownBy(() -> worker.accept(badWindow, SELF))
|
||||
assertThatThrownBy(() -> worker.accept(badWindow, FC))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("任务起点");
|
||||
|
||||
ReconcileInstruction negativeAttempt = instruction(1);
|
||||
negativeAttempt.setAttempt(-1);
|
||||
assertThatThrownBy(() -> worker.accept(negativeAttempt, SELF))
|
||||
assertThatThrownBy(() -> worker.accept(negativeAttempt, FC))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("attempt");
|
||||
|
||||
ReconcileInstruction halfCoordinate = instruction(1);
|
||||
halfCoordinate.setFormVersion(null);
|
||||
assertThatThrownBy(() -> worker.accept(halfCoordinate, SELF))
|
||||
assertThatThrownBy(() -> worker.accept(halfCoordinate, FC))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("版本确切");
|
||||
}
|
||||
|
||||
@@ -191,14 +194,14 @@ class ShReconcileTaskTest {
|
||||
stored.setScannedRows(7);
|
||||
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.getWatermark()).isEqualTo(ANSWER_A);
|
||||
assertThat(older.getScannedRows()).isEqualTo(7);
|
||||
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.getAttempt()).isEqualTo(4);
|
||||
assertThat(redispatched.getWatermark()).isEqualTo(ANSWER_A);
|
||||
@@ -215,7 +218,7 @@ class ShReconcileTaskTest {
|
||||
running.setScannedRows(4);
|
||||
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.getAttempt()).isEqualTo(2);
|
||||
@@ -890,10 +893,14 @@ class ShReconcileTaskTest {
|
||||
private static TaskAnswerReply present(String answerUuid, String hash, Long revision, boolean deleted) {
|
||||
TaskAnswerReply reply = new TaskAnswerReply();
|
||||
reply.setTaskId(TASK_ID);
|
||||
reply.setAnswerUuid(answerUuid);
|
||||
// trustedReplyMatches 加严后回执必须自带消费方坐标与纪元(缺任一即拒修)
|
||||
reply.setConsumerAppKey(SELF);
|
||||
reply.setConsumerSchoolId(SCHOOL);
|
||||
reply.setFormUuid(FORM_UUID);
|
||||
reply.setFormVersion(7);
|
||||
reply.setAnswerUuid(answerUuid);
|
||||
reply.setPresence(FormSyncModels.PRESENCE_PRESENT);
|
||||
reply.setGrantVersion(1);
|
||||
SnapshotEnvelope envelope = new SnapshotEnvelope();
|
||||
envelope.setProtocolVersion("fc.answer.snapshot.v2");
|
||||
envelope.setEventId(TASK_ID);
|
||||
|
||||
Reference in New Issue
Block a user