fix(mq): B-04 显式 mq-enabled 默认关+最终凭据链+注册应用核验+动态 binding——平台 host 注入不再自动启用 MQ 消费

This commit is contained in:
chenxing
2026-10-23 16:37:10 +08:00
parent a5af47abfa
commit 40dd4a1185
7 changed files with 296 additions and 11 deletions
@@ -0,0 +1,94 @@
package com.yunhai.svchall.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.rabbitmq.client.Channel;
import com.yunhai.svchall.entity.FcAnswerInbox;
import com.yunhai.svchall.model.FcApplyModels.SnapshotEnvelope;
import com.yunhai.svchall.repository.FcAnswerInboxRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.MessageProperties;
import org.springframework.test.util.ReflectionTestUtils;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** Local JSON handoff and manual ACK/NACK ordering; not real broker or transaction commit evidence. */
class FcAnswerConsumerMqTest {
private final ObjectMapper mapper = new ObjectMapper();
private AnswerProjectionStore store;
private FcSubmissionClient client;
private FcAnswerInboxRepository rows;
private FcAnswerConsumer consumer;
private Channel channel;
private final String json = "{\"protocolVersion\":\"fc.answer.snapshot.v2\",\"eventId\":\"evt\","
+ "\"eventType\":\"updated\",\"sourceAppKey\":\"100025\",\"consumerAppKey\":\"100018\","
+ "\"consumerSchoolId\":27,\"grantVersion\":4,\"formUuid\":\"form\",\"formVersion\":1,"
+ "\"answerUuid\":\"answer\",\"answerRevision\":8,\"isDeleted\":false,\"contentHash\":\"hash\","
+ "\"occurredAt\":\"2026-10-23T10:00:00+08:00\",\"snapshot\":{\"schId\":27,\"appCode\":\"owner\","
+ "\"code\":\"leave\",\"submitterId\":41,\"subjectId\":42,\"status\":\"processed\","
+ "\"values\":{\"reason\":\"leave\"},\"answerNo\":\"LEAVE-8\",\"createTime\":\"2026-10-23T10:00:00\","
+ "\"updateTime\":\"2026-10-23T10:01:00\",\"intent\":{\"intentId\":\"intent\"}}}";
@BeforeEach void setUp() {
store = mock(AnswerProjectionStore.class); client = mock(FcSubmissionClient.class);
rows = mock(FcAnswerInboxRepository.class); channel = mock(Channel.class);
consumer = new FcAnswerConsumer(store, client, rows, mapper);
ReflectionTestUtils.setField(consumer, "consumerAppKey", "100018");
}
@Test void validJsonPreservesEveryAnswerCoordinateBeforeAck() throws Exception {
when(store.applySnapshot(any())).thenReturn(new AnswerProjectionStore.Disposition(
AnswerProjectionStore.Outcome.REJECTED, "evt", FcAnswerInbox.STATE_REJECTED, 8L, "hash", null, null));
consumer.onMessage(message(json), channel, 5L);
ArgumentCaptor<SnapshotEnvelope> envelope = ArgumentCaptor.forClass(SnapshotEnvelope.class);
InOrder order = inOrder(store, channel); order.verify(store).applySnapshot(envelope.capture());
order.verify(channel).basicAck(5L, false);
// Compare the complete JSON wire representation; DTO LongNode vs parsed IntNode is not data loss.
com.fasterxml.jackson.databind.JsonNode decoded = mapper.readTree(mapper.writeValueAsBytes(envelope.getValue()));
assertThat(decoded).isEqualTo(mapper.readTree(json));
verifyNoInteractions(client);
}
@Test void invalidJsonIsAckedAsPoisonWithoutPretendingItWasApplied() throws Exception {
consumer.onMessage(message("{broken"), channel, 6L);
verify(channel).basicAck(6L, false); verify(channel, never()).basicNack(anyLong(), anyBoolean(), anyBoolean());
verifyNoInteractions(store, client);
}
@Test void failedProjectionIsNackedWithoutAckOrFalseAppliedReport() throws Exception {
when(store.applySnapshot(any())).thenThrow(new IllegalStateException("database unavailable"));
consumer.onMessage(message(json), channel, 7L);
verify(channel).basicNack(7L, false, false); verify(channel, never()).basicAck(anyLong(), anyBoolean());
verifyNoInteractions(client);
}
@Test void reportFailureKeepsTheCommittedOutcomeAndSchedulesOnlyReportRetry() throws Exception {
when(store.applySnapshot(any())).thenReturn(new AnswerProjectionStore.Disposition(
AnswerProjectionStore.Outcome.APPLIED, "evt", FcAnswerInbox.STATE_APPLIED, 8L, "hash", 42, null));
FcAnswerInbox row = new FcAnswerInbox(); row.setState(FcAnswerInbox.STATE_APPLIED);
when(rows.findByEventIdAndConsumerAppKey("evt", "100018")).thenReturn(Optional.of(row));
consumer.onMessage(message(json), channel, 8L);
InOrder order = inOrder(store, channel, client);
order.verify(store).applySnapshot(any()); order.verify(channel).basicAck(8L, false);
order.verify(client).reportApplied(any());
assertThat(row.getState()).isEqualTo(FcAnswerInbox.STATE_APPLIED);
assertThat(row.getReportState()).isEqualTo(FcAnswerInbox.REPORT_FAILED);
assertThat(row.getReportAttempts()).isEqualTo(1);
assertThat(row.getNextReportAt()).isAfter(LocalDateTime.now());
verify(channel, never()).basicNack(anyLong(), anyBoolean(), anyBoolean());
}
private Message message(String body) {
MessageProperties props = new MessageProperties(); props.setContentType(MessageProperties.CONTENT_TYPE_JSON);
return new Message(body.getBytes(StandardCharsets.UTF_8), props);
}
}
@@ -0,0 +1,59 @@
package com.yunhai.svchall.service.impl;
import com.yunhai.svchall.config.FcAnswerMqConfiguration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.mock.env.MockEnvironment;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThat;
/** B-04 默认关闭的 Spring 条件装配回归,不启动真实 broker。 */
class ShMqExplicitEnableTest {
private final ApplicationContextRunner context = new ApplicationContextRunner()
.withUserConfiguration(FcAnswerMqConfiguration.class, FcAnswerConsumer.class);
@Test
void platformHostInjectionAloneDoesNotCreateTopologyOrConsumer() {
context.withPropertyValues("spring.rabbitmq.host=platform-broker").run(ctx -> {
assertThat(ctx).hasNotFailed();
assertThat(ctx).doesNotHaveBean(FcAnswerConsumer.class);
assertThat(ctx).doesNotHaveBean(FcAnswerMqConfiguration.class);
assertThat(ctx).doesNotHaveBean("fcAnswerContainerFactory");
});
}
@Test
void explicitFalseStillWinsWithPlatformHost() {
context.withPropertyValues("spring.rabbitmq.host=platform-broker", "svchall.fc.mq-enabled=false")
.run(ctx -> assertThat(ctx).hasNotFailed().doesNotHaveBean(FcAnswerConsumer.class)
.doesNotHaveBean(FcAnswerMqConfiguration.class));
}
@Test
void enablingWithoutBrokerDoesNotCreateConsumerOrFactory() {
context.withPropertyValues("svchall.fc.mq-enabled=true", "spring.rabbitmq.host=")
.run(ctx -> assertThat(ctx).hasNotFailed().doesNotHaveBean(FcAnswerConsumer.class)
.doesNotHaveBean("fcAnswerContainerFactory"));
}
@Test
void bothProductionConditionsRequireTheSameExplicitTrueAndHost() {
String topology = FcAnswerMqConfiguration.class.getAnnotation(ConditionalOnExpression.class).value();
String consumer = FcAnswerConsumer.class.getAnnotation(ConditionalOnExpression.class).value();
assertThat(consumer).isEqualTo(topology).contains("svchall.fc.mq-enabled:false");
MockEnvironment env = new MockEnvironment().withProperty("svchall.fc.mq-enabled", "true")
.withProperty("spring.rabbitmq.host", "broker");
assertThat(new SpelExpressionParser().parseExpression(env.resolvePlaceholders(topology))
.getValue(Boolean.class)).isTrue();
}
@Test
void bindingRemainsSnapshotPrefixForOwnConsumerIdentity() {
FcAnswerMqConfiguration config = new FcAnswerMqConfiguration();
ReflectionTestUtils.setField(config, "consumerAppKey", "100018");
assertThat(config.fcSnapshotBinding().getRoutingKey()).isEqualTo("fc.snapshot.v2.100018.*");
}
}
@@ -0,0 +1,91 @@
package com.yunhai.svchall.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.yunhai.platform.openapi.base.OpenApiAppClient;
import com.yunhai.svchall.config.FcAnswerMqConfiguration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.io.ClassPathResource;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Collections;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** Binding object and registry/config contract checks; actual broker routing is NOT RUN. */
class ShMqRoutingIdentityTest {
private final ObjectMapper mapper = new ObjectMapper();
@Test void yamlUsesTheFinalSigningPropertyInsteadOfTheStaleEnvironmentFallback() throws Exception {
StandardEnvironment env = new StandardEnvironment();
new YamlPropertySourceLoader().load("application", new ClassPathResource("application.yml"))
.forEach(env.getPropertySources()::addLast);
env.getPropertySources().addFirst(new MapPropertySource("final-platform-key",
Collections.singletonMap("yh.app-auth.app-key", "100008")));
assertThat(env.getProperty("svchall.fc.consumer-app-key")).isEqualTo("100008");
assertThat(env.getProperty("svchall.fc.mq-enabled")).isEqualTo("false");
}
@Test void matchingCredentialBuildsTheCanonical100018BindingWithoutUsingThePrimaryId() {
FcAnswerMqConfiguration config = config("100018", "100018", registered(700018, 100018, "svc-hall"));
assertThatCode(config::validateIdentity).doesNotThrowAnyException();
assertThat(config.fcSnapshotBinding().getRoutingKey()).isEqualTo("fc.snapshot.v2.100018.*");
assertThat(config.fcSnapshotBinding().getExchange()).isEqualTo("fc.answer.delivery.v2");
}
@Test void stale100018CannotSurviveA100008FinalSigningConfiguration() {
FcAnswerMqConfiguration config = config("100018", "100008", registered(700008, 100008, "svc-hall"));
assertThatThrownBy(config::validateIdentity).hasMessageContaining("CONSUMER_CREDENTIAL_MISMATCH");
}
@Test void configuredCredentialDeterminesBindingWithoutHardcoding100008() {
FcAnswerMqConfiguration config = config("100008", "100008", registered(700008, 100008, "svc-hall"));
config.validateIdentity();
assertThat(config.fcSnapshotBinding().getRoutingKey()).isEqualTo("fc.snapshot.v2.100008.*")
.isNotEqualTo("fc.snapshot.v2.100018.*");
}
@Test void aCredentialRegisteredToAnotherBusinessApplicationIsRejected() {
assertThatThrownBy(config("100018", "100018", registered(700025, 100025, "form-center"))::validateIdentity)
.hasMessageContaining("CONSUMER_APPLICATION_MISMATCH");
}
@Test void businessPrimaryIdIsNeverComparedEqualToOrSubstitutedForTheCredential() {
assertThatThrownBy(config("700018", "700018", registered(700018, 100018, "svc-hall"))::validateIdentity)
.hasMessageContaining("CONSUMER_APPLICATION_MISMATCH");
}
@Test void emptyRegistryOrMissingPrimaryIdFailsClosedOnlyWhenMqIsExplicitlyEnabled() {
assertThatThrownBy(config("100018", "100018", null)::validateIdentity)
.hasMessageContaining("CONSUMER_APPLICATION_MISMATCH");
ObjectNode incomplete = registered(700018, 100018, "svc-hall"); incomplete.remove("id");
assertThatThrownBy(config("100018", "100018", incomplete)::validateIdentity)
.hasMessageContaining("CONSUMER_APPLICATION_MISMATCH");
}
@Test void routeWildcardsOrExtraWordsCannotExpandTheConsumersBinding() {
for (String key : new String[]{"", "100018.*", "100018.#", "100018.27"}) {
FcAnswerMqConfiguration config = config(key, key, null);
assertThatThrownBy(config::fcSnapshotBinding).hasMessageContaining("INVALID_CONSUMER_ROUTE_KEY");
}
}
private ObjectNode registered(int id, int credential, String code) {
return mapper.createObjectNode().put("id", id).put("appKey", credential).put("appCode", code);
}
private FcAnswerMqConfiguration config(String consumer, String signing, ObjectNode application) {
FcAnswerMqConfiguration config = new FcAnswerMqConfiguration();
ReflectionTestUtils.setField(config, "consumerAppKey", consumer);
ReflectionTestUtils.setField(config, "signingAppKey", signing);
ReflectionTestUtils.setField(config, "applicationCode", "svc-hall");
OpenApiAppClient registry = mock(OpenApiAppClient.class);
when(registry.getAppByCode("svc-hall")).thenReturn(application);
ReflectionTestUtils.setField(config, "appRegistryClient", registry);
return config;
}
}