diff --git a/docs/mq-local-repair-contract.md b/docs/mq-local-repair-contract.md new file mode 100644 index 0000000..63b0f29 --- /dev/null +++ b/docs/mq-local-repair-contract.md @@ -0,0 +1,10 @@ +# SH MQ 独立配置契约(2026-10-23) + +- `svchall.fc.mq-enabled` 默认 false。配置类与消费者使用相同条件;平台注入 RabbitMQ host 不自动打开能力。 +- `svchall.fc.consumer-app-key` 默认引用最终 `yh.app-auth.app-key`,不再直接使用陈旧 env 回退 100018。 +- 只有显式启用且配置 host 才装配 MQ。启用时校验消费凭据键与签名键一致,再用已有 UserBase `getAppByCode(spring.application.name)` 核验注册行的 id/appKey/appCode 关系。 +- 业务应用主身份是 application_info.id;id 可以不等于凭据 appKey。禁止拿 id 或 appCode 替代 MQ 协议的 *AppKey 字段。 +- binding 继续动态生成 `fc.snapshot.v2.<自身消费凭据键>.*`,没有硬编码 100008。空键、通配符或额外 topic 段拒绝。 +- 仅默认链修正不会移除 broker 已有旧 binding;旧绑定、topic 权限及独立 vhost 清理必须由 zcode 在另行授权的环境验证。 +- 当前 MQ 投影入口的 DB 异常分支直接 basicNack(requeue=false),并吞掉异常;不能只凭工厂 retryTemplate 配置宣称发生了三次 listener 重试。实际死信路由和恢复能力 NOT RUN,本补丁未重构这一策略。 +- 本地 JSON 交接/ACK顺序测试不等于真实数据库提交或 broker 验证。核心 HTTP 签名收据链仍独立运行;本轮不处理 B5、不改 UserBase 模型、不启用对账。 diff --git a/src/main/java/com/yunhai/svchall/config/FcAnswerMqConfiguration.java b/src/main/java/com/yunhai/svchall/config/FcAnswerMqConfiguration.java index febf796..aa8cf5c 100644 --- a/src/main/java/com/yunhai/svchall/config/FcAnswerMqConfiguration.java +++ b/src/main/java/com/yunhai/svchall/config/FcAnswerMqConfiguration.java @@ -12,6 +12,8 @@ import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFacto import org.springframework.amqp.rabbit.connection.ConnectionFactory; import org.springframework.amqp.rabbit.core.RabbitAdmin; import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; +import javax.annotation.PostConstruct; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -21,22 +23,22 @@ import org.springframework.retry.support.RetryTemplate; /** * v2 快照投递的消费侧拓扑(answer-delivery-v2 §3.3/§7,TASK_2 B.3/B.5)。 * - *

整体条件装配:只有显式配了 {@code spring.rabbitmq.host} 才存在。未配 broker 时 + *

整体条件装配:只有显式设置 {@code svchall.fc.mq-enabled=true} 且配置 {@code spring.rabbitmq.host} 才存在。未配 broker 时 * 不建监听容器(否则后台反复重连把启动与日志拖脏),{@code FcAnswerConsumer} 上的 - * {@code @RabbitListener} 因没有处理器而不生效——可信建件仍走"签名收据确认"那条路, - * 只是延迟从秒级变成"用户点确认那一刻"。

+ * {@code @RabbitListener} 因没有处理器而不生效。平台统一注入 host 不能自动打开消费能力; + * 当前可信建件主链始终是签名收据 + HTTP,不依赖 MQ。

* *

队列隔离是这条链的硬前提:完整值文档只进按消费 appKey + 租户域绑定的私有队列。 * routing key 形如 {@code fc.snapshot.v2..},本应用只绑自己 appKey * 那一段({@code .*} 只覆盖自己的多租户域),新消费者不得能绑其他目标的 routing key * (v2 §5 撤权边界的 broker 侧一半)。部署必须落实的实值来源见交付说明"broker 隔离配置"一节: * 独立 vhost、账号仅对本 vhost 有 configure/write/read、禁止通配订阅他人前缀; - * 这三条没有落地就不能打开 {@code RABBITMQ_HOST}——宁可不投,也不在共享 vhost 上发明文。

+ * 这三条没有落地就不能设置 {@code svchall.fc.mq-enabled=true}——宁可不投,也不在共享 vhost 上发明文。

*/ @Slf4j @Configuration @org.springframework.amqp.rabbit.annotation.EnableRabbit -@ConditionalOnExpression("!'${spring.rabbitmq.host:}'.trim().isEmpty()") +@ConditionalOnExpression("${svchall.fc.mq-enabled:false} && !'${spring.rabbitmq.host:}'.trim().isEmpty()") public class FcAnswerMqConfiguration { /** 中心侧 v2 exchange(与 FC {@code AnswerDeliveryModels.EXCHANGE} 同一字面量) */ @@ -49,9 +51,32 @@ public class FcAnswerMqConfiguration { public static final String DLX = "svc-hall.fc.answer.snapshot.v2.dlx"; public static final String DLQ = QUEUE + ".dlq"; - @Value("${svchall.fc.consumer-app-key:${yh.app-auth.app-key:100018}}") + @Value("${svchall.fc.consumer-app-key:${yh.app-auth.app-key:}}") private String consumerAppKey; + @Value("${yh.app-auth.app-key:}") + private String signingAppKey; + + @Value("${spring.application.name:svc-hall}") + private String applicationCode; + + @Autowired + private com.yunhai.platform.openapi.base.OpenApiAppClient appRegistryClient; + + @PostConstruct + public void validateIdentity() { + String key = trimmedAppKey(); + if (signingAppKey == null || !key.equals(signingAppKey.trim())) { + throw new IllegalStateException("CONSUMER_CREDENTIAL_MISMATCH"); + } + com.fasterxml.jackson.databind.JsonNode app = appRegistryClient.getAppByCode(applicationCode); + if (app == null || !app.path("id").isIntegralNumber() || !app.path("id").canConvertToLong() + || app.path("id").asLong() <= 0 || !key.equals(app.path("appKey").asText()) + || !applicationCode.equals(app.path("appCode").asText())) { + throw new IllegalStateException("CONSUMER_APPLICATION_MISMATCH"); + } + } + @Value("${svchall.fc.prefetch-count:10}") private int prefetchCount; @@ -123,6 +148,10 @@ public class FcAnswerMqConfiguration { } private String trimmedAppKey() { - return consumerAppKey == null ? "" : consumerAppKey.trim(); + String key = consumerAppKey == null ? "" : consumerAppKey.trim(); + if (!key.matches("[A-Za-z0-9_-]+")) { + throw new IllegalStateException("INVALID_CONSUMER_ROUTE_KEY"); + } + return key; } } diff --git a/src/main/java/com/yunhai/svchall/service/impl/FcAnswerConsumer.java b/src/main/java/com/yunhai/svchall/service/impl/FcAnswerConsumer.java index eb336b9..5947f11 100644 --- a/src/main/java/com/yunhai/svchall/service/impl/FcAnswerConsumer.java +++ b/src/main/java/com/yunhai/svchall/service/impl/FcAnswerConsumer.java @@ -26,7 +26,7 @@ import java.util.List; /** * v2 快照消费者(answer-delivery-v2 §7,TASK_2 B.5/B.6)。 * - *

本类整体条件装配:只有显式配了 {@code spring.rabbitmq.host} 才存在(与 + *

本类整体条件装配:只有显式设置 {@code svchall.fc.mq-enabled=true} 且配置 {@code spring.rabbitmq.host} 才存在(与 * {@link FcAnswerMqConfiguration} 同一条件表达式)。未接 MQ 的环境里 bean 不创建, * {@code @RabbitListener} 端点自然不被注册——"MQ 可选"必须落到启动期:注解处理器在 * 容器工厂缺失时会直接抛 {@code NoSuchBeanDefinitionException} 打断启动,不会优雅跳过。 @@ -49,7 +49,7 @@ import java.util.List; @Slf4j @Component @org.springframework.boot.autoconfigure.condition.ConditionalOnExpression( - "!'${spring.rabbitmq.host:}'.trim().isEmpty()") + "${svchall.fc.mq-enabled:false} && !'${spring.rabbitmq.host:}'.trim().isEmpty()") public class FcAnswerConsumer { private final AnswerProjectionStore projectionStore; @@ -57,7 +57,7 @@ public class FcAnswerConsumer { private final FcAnswerInboxRepository inboxRepository; private final ObjectMapper objectMapper; - @Value("${svchall.fc.consumer-app-key:${yh.app-auth.app-key:100018}}") + @Value("${svchall.fc.consumer-app-key:${yh.app-auth.app-key:}}") private String consumerAppKey; @Value("${svchall.fc.report-batch-size:20}") diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 295fabb..86a5af9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -149,8 +149,10 @@ svchall: public-base-url: http://localhost:8086/upload/ # ===== 可信建件与快照消费(answer-delivery-v2 §6~§8,TASK_2)===== fc: + # 平台注入 RabbitMQ host 不代表 SH 允许消费;HTTP 收据建件主链不依赖 MQ。 + mq-enabled: ${SVCHALL_FC_MQ_ENABLED:false} # 本应用消费身份(默认取签名身份同一把 appKey;信封目标必须等于它,否则整封拒收) - consumer-app-key: ${YH_APP_AUTH_APP_KEY:100018} + consumer-app-key: ${yh.app-auth.app-key:} # 被嵌填报页基址 = 表单中心前端地址(不是后端 API 地址):iframe 的 src 由服务端拼, # 浏览器传进来的任何路径都不采信(A.2 末句)。缺键即拒开托管填报页——宁可不给入口, # 也不给一个"拼了个大概能打开的地址"的入口。本地=FC front dev(8089),生产 env 注入。 diff --git a/src/test/java/com/yunhai/svchall/service/impl/FcAnswerConsumerMqTest.java b/src/test/java/com/yunhai/svchall/service/impl/FcAnswerConsumerMqTest.java new file mode 100644 index 0000000..170b429 --- /dev/null +++ b/src/test/java/com/yunhai/svchall/service/impl/FcAnswerConsumerMqTest.java @@ -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 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); + } +} diff --git a/src/test/java/com/yunhai/svchall/service/impl/ShMqExplicitEnableTest.java b/src/test/java/com/yunhai/svchall/service/impl/ShMqExplicitEnableTest.java new file mode 100644 index 0000000..6227a51 --- /dev/null +++ b/src/test/java/com/yunhai/svchall/service/impl/ShMqExplicitEnableTest.java @@ -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.*"); + } +} diff --git a/src/test/java/com/yunhai/svchall/service/impl/ShMqRoutingIdentityTest.java b/src/test/java/com/yunhai/svchall/service/impl/ShMqRoutingIdentityTest.java new file mode 100644 index 0000000..cac7e11 --- /dev/null +++ b/src/test/java/com/yunhai/svchall/service/impl/ShMqRoutingIdentityTest.java @@ -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; + } +}