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