diff --git a/pom.xml b/pom.xml index a892d68..168c3f2 100644 --- a/pom.xml +++ b/pom.xml @@ -265,6 +265,7 @@ ruoyi-admin ruoyi-ft546-protocol ruoyi-wcs + ruoyi-wcs-shadow ruoyi-framework ruoyi-system ruoyi-quartz diff --git a/ruoyi-wcs-shadow/pom.xml b/ruoyi-wcs-shadow/pom.xml new file mode 100644 index 0000000..1f8c096 --- /dev/null +++ b/ruoyi-wcs-shadow/pom.xml @@ -0,0 +1,68 @@ + + + 4.0.0 + + + com.ruoyi + ruoyi + 3.8.9 + + + ruoyi-wcs-shadow + jar + Independent read-only FT546 shadow process + + + + com.ruoyi + ruoyi-wcs + + + com.ruoyi + ruoyi-common + + + + + com.ruoyi + ruoyi-ft546-protocol + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-test + test + + + com.tngtech.archunit + archunit-junit5 + 1.2.1 + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + com.ruoyi.shadow.Ft546ShadowApplication + + + + + repackage + + + + + + + diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/Ft546ShadowApplication.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/Ft546ShadowApplication.java new file mode 100644 index 0000000..b9f77bc --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/Ft546ShadowApplication.java @@ -0,0 +1,26 @@ +package com.ruoyi.shadow; + +import com.ruoyi.shadow.config.ShadowKernelConfiguration; +import com.ruoyi.shadow.config.ShadowRuntimeConfiguration; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; +import org.springframework.context.annotation.Import; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootConfiguration +@EnableAutoConfiguration(exclude = { + DataSourceAutoConfiguration.class, + SecurityAutoConfiguration.class, + UserDetailsServiceAutoConfiguration.class +}) +@Import({ShadowRuntimeConfiguration.class, ShadowKernelConfiguration.class}) +@EnableScheduling +public class Ft546ShadowApplication { + public static void main(String[] args) { + SpringApplication.run(Ft546ShadowApplication.class, args); + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowKernelConfiguration.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowKernelConfiguration.java new file mode 100644 index 0000000..af8b290 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowKernelConfiguration.java @@ -0,0 +1,60 @@ +package com.ruoyi.shadow.config; + +import com.ruoyi.wcs.application.dispatch.DispatchCoordinator; +import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy; +import com.ruoyi.wcs.domain.dispatch.VehicleAssignmentPolicy; +import com.ruoyi.wcs.domain.shared.IdGenerator; +import com.ruoyi.wcs.domain.traffic.AStarRoutePlanner; +import com.ruoyi.wcs.domain.traffic.DeadlockGuard; +import com.ruoyi.wcs.domain.traffic.RoutePlanner; +import com.ruoyi.wcs.domain.traffic.WaitForGraph; +import com.ruoyi.wcs.port.out.DispatchPolicy; +import com.ruoyi.wcs.port.out.TrafficStateRepository; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.atomic.AtomicLong; + +@Configuration(proxyBeanMethods = false) +public class ShadowKernelConfiguration { + @Bean + public RoutePlanner shadowRoutePlanner() { + return new AStarRoutePlanner(); + } + + @Bean + public VehicleAssignmentPolicy shadowVehicleAssignmentPolicy( + RoutePlanner routePlanner) { + return new DefaultVehicleAssignmentPolicy(routePlanner, + DispatchPolicy.NONE); + } + + @Bean + public DeadlockGuard shadowDeadlockGuard(RoutePlanner routePlanner) { + return new DeadlockGuard(new WaitForGraph(), routePlanner); + } + + @Bean + public IdGenerator shadowIdGenerator() { + AtomicLong sequence = new AtomicLong(); + return () -> "SHADOW-" + sequence.incrementAndGet(); + } + + @Bean + public TrafficStateRepository disabledShadowTrafficStateRepository() { + return commit -> { + throw new IllegalStateException( + "SHADOW_TRAFFIC_STATE_REPOSITORY_NOT_CONFIGURED"); + }; + } + + @Bean + public DispatchCoordinator shadowDispatchCoordinator( + VehicleAssignmentPolicy assignmentPolicy, + DeadlockGuard deadlockGuard, + TrafficStateRepository trafficStateRepository, + IdGenerator idGenerator) { + return new DispatchCoordinator(assignmentPolicy, deadlockGuard, + trafficStateRepository, idGenerator); + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowProperties.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowProperties.java new file mode 100644 index 0000000..822c236 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowProperties.java @@ -0,0 +1,22 @@ +package com.ruoyi.shadow.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "shadow") +public class ShadowProperties { + private boolean enabled; + private final JdbcEndpoint source = new JdbcEndpoint(); + private final JdbcEndpoint evidence = new JdbcEndpoint(); + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + public JdbcEndpoint getSource() { return source; } + public JdbcEndpoint getEvidence() { return evidence; } + + public static final class JdbcEndpoint { + private String jdbcUrl; + + public String getJdbcUrl() { return jdbcUrl; } + public void setJdbcUrl(String jdbcUrl) { this.jdbcUrl = jdbcUrl; } + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowRuntimeConfiguration.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowRuntimeConfiguration.java new file mode 100644 index 0000000..9d2fc55 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowRuntimeConfiguration.java @@ -0,0 +1,32 @@ +package com.ruoyi.shadow.config; + +import com.ruoyi.shadow.startup.ShadowStartupGate; +import com.ruoyi.shadow.startup.ShadowStartupReport; +import com.ruoyi.shadow.web.ShadowStatusController; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(ShadowProperties.class) +public class ShadowRuntimeConfiguration { + @Bean + public ShadowStartupGate shadowStartupGate() { + return new ShadowStartupGate(); + } + + @Bean + public ShadowStartupReport shadowStartupReport( + ShadowStartupGate gate, ApplicationContext context, + Environment environment, ShadowProperties properties) { + return gate.verify(context, environment, properties); + } + + @Bean + public ShadowStatusController shadowStatusController( + ShadowStartupReport report) { + return new ShadowStatusController(report); + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupGate.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupGate.java new file mode 100644 index 0000000..026fa87 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupGate.java @@ -0,0 +1,104 @@ +package com.ruoyi.shadow.startup; + +import com.ruoyi.shadow.config.ShadowProperties; +import com.ruoyi.wcs.port.out.DeviceCommandGateway; +import com.ruoyi.wcs.port.out.WmsNotificationGateway; +import org.springframework.beans.factory.BeanIsNotAFactoryException; +import org.springframework.context.ApplicationContext; +import org.springframework.core.env.Environment; + +import java.time.Instant; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class ShadowStartupGate { + public ShadowStartupReport verify(ApplicationContext context, + Environment environment, + ShadowProperties properties) { + if (!properties.isEnabled()) { + throw failure("SHADOW_MUST_BE_EXPLICITLY_ENABLED"); + } + if (!Arrays.asList(environment.getActiveProfiles()) + .contains("shadow")) { + throw failure("SHADOW_PROFILE_MUST_BE_ACTIVE"); + } + requireText(properties.getSource().getJdbcUrl(), + "SHADOW_SOURCE_JDBC_URL_REQUIRED"); + requireText(properties.getEvidence().getJdbcUrl(), + "SHADOW_EVIDENCE_JDBC_URL_REQUIRED"); + requireLoopback(environment.getProperty("server.address", + "127.0.0.1")); + requireNoBeans(context, DeviceCommandGateway.class, + "DEVICE_COMMAND_GATEWAY_FORBIDDEN_IN_SHADOW"); + requireNoBeans(context, WmsNotificationGateway.class, + "WMS_NOTIFICATION_GATEWAY_FORBIDDEN_IN_SHADOW"); + requireNoForbiddenBeanPackage(context); + Map checks = new LinkedHashMap(); + checks.put("SHADOW_ENABLED", "PASS"); + checks.put("SHADOW_PROFILE_ACTIVE", "PASS"); + checks.put("SOURCE_JDBC_CONFIGURED", "PASS"); + checks.put("EVIDENCE_JDBC_CONFIGURED", "PASS"); + checks.put("STATUS_ADDRESS_LOOPBACK", "PASS"); + checks.put("SIDE_EFFECT_BEANS_ABSENT", "PASS"); + checks.put("FORBIDDEN_BEAN_PACKAGES_ABSENT", "PASS"); + checks.put("SOURCE_PERMISSIONS_VERIFIED", "PENDING"); + checks.put("EVIDENCE_DATABASE_VERIFIED", "PENDING"); + return ShadowStartupReport.starting(Instant.now(), checks); + } + + private static void requireLoopback(String address) { + String normalized = address == null ? "" : address.trim(); + if (!"127.0.0.1".equals(normalized) + && !"localhost".equalsIgnoreCase(normalized) + && !"::1".equals(normalized)) { + throw failure("SHADOW_STATUS_ADDRESS_MUST_BE_LOOPBACK"); + } + } + + private static void requireNoBeans(ApplicationContext context, + Class type, + String reasonCode) { + if (!context.getBeansOfType(type).isEmpty()) { + throw failure(reasonCode); + } + } + + private static void requireNoForbiddenBeanPackage( + ApplicationContext context) { + for (String beanName : context.getBeanDefinitionNames()) { + requireAllowedBeanType(context.getType(beanName, false)); + requireAllowedBeanType(factoryBeanType(context, beanName)); + } + } + + private static Class factoryBeanType(ApplicationContext context, + String beanName) { + try { + return context.getType("&" + beanName, false); + } catch (BeanIsNotAFactoryException ignored) { + return null; + } + } + + private static void requireAllowedBeanType(Class beanType) { + String typeName = beanType == null ? null : beanType.getName(); + if (typeName != null + && (typeName.startsWith("com.ruoyi.schedule.") + || typeName.startsWith("com.ruoyi.project.") + || typeName.startsWith("com.ruoyi.api."))) { + throw failure("FORBIDDEN_PRODUCTION_BEAN_IN_SHADOW:" + + typeName); + } + } + + private static void requireText(String value, String reasonCode) { + if (value == null || value.trim().isEmpty()) { + throw failure(reasonCode); + } + } + + private static IllegalStateException failure(String reasonCode) { + return new IllegalStateException(reasonCode); + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupReport.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupReport.java new file mode 100644 index 0000000..f39ce44 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupReport.java @@ -0,0 +1,43 @@ +package com.ruoyi.shadow.startup; + +import java.time.Instant; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class ShadowStartupReport { + public enum Status { STARTING, READY, DEGRADED, STOPPED } + + private final Status status; + private final Instant checkedAt; + private final Instant lastCompletedCycle; + private final Long sourceLagMillis; + private final long evidenceBacklog; + private final Map startupChecks; + + private ShadowStartupReport(Status status, Instant checkedAt, + Instant lastCompletedCycle, + Long sourceLagMillis, long evidenceBacklog, + Map startupChecks) { + this.status = status; + this.checkedAt = checkedAt; + this.lastCompletedCycle = lastCompletedCycle; + this.sourceLagMillis = sourceLagMillis; + this.evidenceBacklog = evidenceBacklog; + this.startupChecks = Collections.unmodifiableMap( + new LinkedHashMap(startupChecks)); + } + + public static ShadowStartupReport starting(Instant checkedAt, + Map checks) { + return new ShadowStartupReport(Status.STARTING, checkedAt, null, null, + 0L, checks); + } + + public Status getStatus() { return status; } + public Instant getCheckedAt() { return checkedAt; } + public Instant getLastCompletedCycle() { return lastCompletedCycle; } + public Long getSourceLagMillis() { return sourceLagMillis; } + public long getEvidenceBacklog() { return evidenceBacklog; } + public Map getStartupChecks() { return startupChecks; } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/web/ShadowStatusController.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/web/ShadowStatusController.java new file mode 100644 index 0000000..43d92d6 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/web/ShadowStatusController.java @@ -0,0 +1,21 @@ +package com.ruoyi.shadow.web; + +import com.ruoyi.shadow.startup.ShadowStartupReport; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/shadow") +public final class ShadowStatusController { + private final ShadowStartupReport report; + + public ShadowStatusController(ShadowStartupReport report) { + this.report = report; + } + + @GetMapping("/status") + public ShadowStartupReport status() { + return report; + } +} diff --git a/ruoyi-wcs-shadow/src/main/resources/application-shadow.yml b/ruoyi-wcs-shadow/src/main/resources/application-shadow.yml new file mode 100644 index 0000000..4d2f772 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/resources/application-shadow.yml @@ -0,0 +1,12 @@ +server: + address: 127.0.0.1 + port: 18091 +spring: + main: + banner-mode: "off" +shadow: + enabled: false + source: + jdbc-url: + evidence: + jdbc-url: diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/project/shadowtest/ForbiddenFactoryBean.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/project/shadowtest/ForbiddenFactoryBean.java new file mode 100644 index 0000000..51a62f9 --- /dev/null +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/project/shadowtest/ForbiddenFactoryBean.java @@ -0,0 +1,20 @@ +package com.ruoyi.project.shadowtest; + +import org.springframework.beans.factory.FactoryBean; + +public final class ForbiddenFactoryBean implements FactoryBean { + @Override + public Object getObject() { + throw new AssertionError("forbidden factory must never create a bean"); + } + + @Override + public Class getObjectType() { + return null; + } + + @Override + public boolean isSingleton() { + return true; + } +} diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java new file mode 100644 index 0000000..0385a5b --- /dev/null +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java @@ -0,0 +1,200 @@ +package com.ruoyi.shadow; + +import com.ruoyi.project.shadowtest.ForbiddenFactoryBean; +import com.ruoyi.shadow.startup.ShadowStartupReport; +import com.ruoyi.shadow.web.ShadowStatusController; +import com.ruoyi.wcs.application.dispatch.DispatchCoordinator; +import com.ruoyi.wcs.port.out.DeviceCommandGateway; +import com.ruoyi.wcs.port.out.WmsNotificationGateway; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class Ft546ShadowApplicationTest { + private final WebApplicationContextRunner contextRunner = + new WebApplicationContextRunner() + .withInitializer( + new ConfigDataApplicationContextInitializer()) + .withUserConfiguration(Ft546ShadowApplication.class); + + @Test + void explicitReadOnlyShadowConfigurationStartsTheNarrowContext() { + configured(contextRunner).run(context -> { + assertFalse(context.getStartupFailure() != null); + assertEquals("127.0.0.1", + context.getEnvironment().getProperty("server.address")); + assertEquals("18091", + context.getEnvironment().getProperty("server.port")); + assertEquals(ShadowStartupReport.Status.STARTING, + context.getBean(ShadowStartupReport.class).getStatus()); + assertNotNull(context.getBean(ShadowStartupReport.class) + .getCheckedAt()); + assertNull(context.getBean(ShadowStartupReport.class) + .getLastCompletedCycle()); + assertNull(context.getBean(ShadowStartupReport.class) + .getSourceLagMillis()); + assertEquals(0L, context.getBean(ShadowStartupReport.class) + .getEvidenceBacklog()); + assertEquals("PASS", context.getBean(ShadowStartupReport.class) + .getStartupChecks().get("SHADOW_ENABLED")); + assertEquals("PASS", context.getBean(ShadowStartupReport.class) + .getStartupChecks().get("SIDE_EFFECT_BEANS_ABSENT")); + assertEquals("PENDING", context.getBean(ShadowStartupReport.class) + .getStartupChecks().get("SOURCE_PERMISSIONS_VERIFIED")); + assertEquals("PENDING", context.getBean(ShadowStartupReport.class) + .getStartupChecks().get("EVIDENCE_DATABASE_VERIFIED")); + assertNotNull(context.getBean(DispatchCoordinator.class)); + assertNotNull(context.getBean(ShadowStatusController.class)); + assertTrue(context.getBeansOfType( + DeviceCommandGateway.class).isEmpty()); + assertTrue(context.getBeansOfType( + WmsNotificationGateway.class).isEmpty()); + }); + } + + @Test + void statusEndpointReturnsTheReadOnlyStartupState() { + configured(contextRunner).run(context -> { + MockMvc mvc = MockMvcBuilders.webAppContextSetup(context).build(); + + mvc.perform(get("/shadow/status")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("STARTING")) + .andExpect(jsonPath("$.checkedAt").exists()) + .andExpect(jsonPath("$.evidenceBacklog").value(0)) + .andExpect(jsonPath("$.startupChecks.SHADOW_ENABLED") + .value("PASS")) + .andExpect(jsonPath( + "$.startupChecks.SIDE_EFFECT_BEANS_ABSENT") + .value("PASS")); + }); + } + + @Test + void startupFailsClosedUnlessEnabledProfileAndBothJdbcUrlsAreExplicit() { + assertStartupFailure(contextRunner.withPropertyValues( + "spring.profiles.active=shadow", + "shadow.source.jdbc-url=jdbc:h2:mem:source", + "shadow.evidence.jdbc-url=jdbc:h2:mem:evidence"), + "SHADOW_MUST_BE_EXPLICITLY_ENABLED"); + assertStartupFailure(contextRunner.withPropertyValues( + "shadow.enabled=true", + "shadow.source.jdbc-url=jdbc:h2:mem:source", + "shadow.evidence.jdbc-url=jdbc:h2:mem:evidence"), + "SHADOW_PROFILE_MUST_BE_ACTIVE"); + assertStartupFailure(contextRunner.withPropertyValues( + "spring.profiles.active=shadow", + "shadow.enabled=true", + "shadow.evidence.jdbc-url=jdbc:h2:mem:evidence"), + "SHADOW_SOURCE_JDBC_URL_REQUIRED"); + assertStartupFailure(contextRunner.withPropertyValues( + "spring.profiles.active=shadow", + "shadow.enabled=true", + "shadow.source.jdbc-url=jdbc:h2:mem:source"), + "SHADOW_EVIDENCE_JDBC_URL_REQUIRED"); + assertStartupFailure(contextRunner.withPropertyValues( + "spring.profiles.active=shadow", + "shadow.enabled=true", + "shadow.source.jdbc-url=jdbc:h2:mem:source", + "shadow.evidence.jdbc-url=jdbc:h2:mem:evidence", + "server.address=0.0.0.0"), + "SHADOW_STATUS_ADDRESS_MUST_BE_LOOPBACK"); + } + + @Test + void startupRejectsAnyRealCommandGatewayBean() { + assertStartupFailure(configured(contextRunner) + .withUserConfiguration( + ForbiddenGatewayConfiguration.class), + "DEVICE_COMMAND_GATEWAY_FORBIDDEN_IN_SHADOW"); + } + + @Test + void startupRejectsAnyRealWmsGatewayBean() { + assertStartupFailure(configured(contextRunner) + .withUserConfiguration( + ForbiddenWmsGatewayConfiguration.class), + "WMS_NOTIFICATION_GATEWAY_FORBIDDEN_IN_SHADOW"); + } + + @Test + void startupRejectsAForbiddenFactoryBeanWithUnknownProductType() { + assertStartupFailure(configured(contextRunner) + .withUserConfiguration( + ForbiddenFactoryBeanConfiguration.class), + "FORBIDDEN_PRODUCTION_BEAN_IN_SHADOW:" + + ForbiddenFactoryBean.class.getName()); + } + + private static WebApplicationContextRunner configured( + WebApplicationContextRunner runner) { + return runner.withPropertyValues( + "spring.profiles.active=shadow", + "shadow.enabled=true", + "shadow.source.jdbc-url=jdbc:h2:mem:source", + "shadow.evidence.jdbc-url=jdbc:h2:mem:evidence"); + } + + private static void assertStartupFailure( + WebApplicationContextRunner runner, String reasonCode) { + runner.run(context -> { + Throwable failure = context.getStartupFailure(); + assertNotNull(failure); + assertTrue(messages(failure).contains(reasonCode), + messages(failure)); + }); + } + + private static String messages(Throwable failure) { + StringBuilder messages = new StringBuilder(); + Throwable current = failure; + while (current != null) { + if (current.getMessage() != null) { + messages.append(current.getMessage()).append('\n'); + } + current = current.getCause(); + } + return messages.toString(); + } + + @Configuration(proxyBeanMethods = false) + static class ForbiddenGatewayConfiguration { + @Bean + DeviceCommandGateway forbiddenDeviceCommandGateway() { + return envelope -> { + throw new AssertionError("must never send in shadow test"); + }; + } + } + + @Configuration(proxyBeanMethods = false) + static class ForbiddenWmsGatewayConfiguration { + @Bean + WmsNotificationGateway forbiddenWmsNotificationGateway() { + return notification -> { + throw new AssertionError("must never notify in shadow test"); + }; + } + } + + @Configuration(proxyBeanMethods = false) + static class ForbiddenFactoryBeanConfiguration { + @Bean + ForbiddenFactoryBean forbiddenFactoryBean() { + return new ForbiddenFactoryBean(); + } + } +} diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/ShadowDependencyBoundaryTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/ShadowDependencyBoundaryTest.java new file mode 100644 index 0000000..a64b3b5 --- /dev/null +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/ShadowDependencyBoundaryTest.java @@ -0,0 +1,50 @@ +package com.ruoyi.shadow; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.Test; +import org.springframework.util.ClassUtils; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class ShadowDependencyBoundaryTest { + @Test + void shadowCodeCannotDependOnAdminPlcOrLegacySchedulerPackages() { + JavaClasses shadowClasses = new ClassFileImporter() + .importUrl(Ft546ShadowApplication.class.getProtectionDomain() + .getCodeSource().getLocation()); + ArchRule rule = noClasses() + .that().resideInAPackage("com.ruoyi.shadow..") + .should().dependOnClassesThat().resideInAnyPackage( + "com.ruoyi.project..", + "com.ruoyi.schedule..", + "com.ruoyi.api..", + "HslCommunication..", + "org.apache.plc4x.."); + + rule.check(shadowClasses); + } + + @Test + void productionSideEffectLibrariesAreAbsentFromTheShadowClasspath() { + ClassLoader loader = getClass().getClassLoader(); + + assertFalse(ClassUtils.isPresent( + "com.ruoyi.schedule.ScheduleThread", loader)); + assertFalse(ClassUtils.isPresent( + "HslCommunication.Profinet.Siemens.SiemensS7Net", loader)); + assertFalse(ClassUtils.isPresent( + "org.apache.plc4x.java.api.PlcDriverManager", loader)); + assertFalse(ClassUtils.isPresent( + "org.springframework.data.redis.connection.RedisConnectionFactory", + loader)); + assertFalse(ClassUtils.isPresent( + "org.springframework.security.web.SecurityFilterChain", loader)); + assertFalse(ClassUtils.isPresent( + "org.mybatis.spring.SqlSessionFactoryBean", loader)); + assertFalse(ClassUtils.isPresent( + "cn.hutool.extra.spring.SpringUtil", loader)); + } +} diff --git a/ruoyi-wcs-shadow/src/test/resources/application-shadow-test.yml b/ruoyi-wcs-shadow/src/test/resources/application-shadow-test.yml new file mode 100644 index 0000000..00bc1d0 --- /dev/null +++ b/ruoyi-wcs-shadow/src/test/resources/application-shadow-test.yml @@ -0,0 +1,9 @@ +spring: + profiles: + active: shadow +shadow: + enabled: true + source: + jdbc-url: jdbc:h2:mem:source + evidence: + jdbc-url: jdbc:h2:mem:evidence