feat(ft546): add isolated shadow process

This commit is contained in:
zgw
2026-08-12 14:21:32 +08:00
parent 778444e5ea
commit 364668c939
14 changed files with 668 additions and 0 deletions

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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; }
}
}

View File

@@ -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);
}
}

View File

@@ -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<String, String> checks = new LinkedHashMap<String, String>();
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 <T> void requireNoBeans(ApplicationContext context,
Class<T> 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);
}
}

View File

@@ -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<String, String> startupChecks;
private ShadowStartupReport(Status status, Instant checkedAt,
Instant lastCompletedCycle,
Long sourceLagMillis, long evidenceBacklog,
Map<String, String> startupChecks) {
this.status = status;
this.checkedAt = checkedAt;
this.lastCompletedCycle = lastCompletedCycle;
this.sourceLagMillis = sourceLagMillis;
this.evidenceBacklog = evidenceBacklog;
this.startupChecks = Collections.unmodifiableMap(
new LinkedHashMap<String, String>(startupChecks));
}
public static ShadowStartupReport starting(Instant checkedAt,
Map<String, String> 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<String, String> getStartupChecks() { return startupChecks; }
}

View File

@@ -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;
}
}