fix(ft546): isolate shadow from legacy and PLC threads

This commit is contained in:
zgw
2026-08-12 12:36:40 +08:00
parent 48b0b154b6
commit d30fa0bd1c
8 changed files with 1355 additions and 24 deletions

View File

@@ -9,7 +9,11 @@ public class Ft546WcsProperties {
public enum RuntimeMode {
LEGACY,
SHADOW,
STANDARD
STANDARD;
public boolean isLegacy() { return this == LEGACY; }
public boolean isShadow() { return this == SHADOW; }
public boolean isStandard() { return this == STANDARD; }
}
private RuntimeMode runtimeMode = RuntimeMode.LEGACY;

View File

@@ -0,0 +1,36 @@
package com.ruoyi.project.ft546.runtime;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.context.config.ConfigDataEnvironmentPostProcessor;
import org.springframework.boot.env.EnvironmentPostProcessor;
import org.springframework.core.env.ConfigurableEnvironment;
import java.util.Locale;
public final class Ft546AdminRuntimeModeEnvironmentPostProcessor
implements EnvironmentPostProcessor, org.springframework.core.Ordered {
private static final String RUNTIME_MODE_PROPERTY =
"ft546.wcs.runtime-mode";
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment,
SpringApplication application) {
String configured = environment.getProperty(RUNTIME_MODE_PROPERTY);
String mode = configured == null
? "LEGACY"
: configured.trim().toUpperCase(Locale.ROOT);
if ("SHADOW".equals(mode)) {
throw new IllegalStateException(
"ADMIN_SHADOW_MODE_FORBIDDEN_USE_RUOYI_WCS_SHADOW");
}
if (!"LEGACY".equals(mode) && !"STANDARD".equals(mode)) {
throw new IllegalStateException(
"UNSUPPORTED_FT546_WCS_RUNTIME_MODE:" + mode);
}
}
@Override
public int getOrder() {
return ConfigDataEnvironmentPostProcessor.ORDER + 1;
}
}

View File

@@ -35,6 +35,7 @@ import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -76,6 +77,8 @@ public class ScheduleThread {
private volatile boolean isRun2 = false;
private static final long THREAD_STOP_TIMEOUT_MS = 5000L;
private static final String SHADOW_THREAD_REJECTION =
"SHADOW mode forbids legacy and PLC thread operations";
/**
* 项目初始化启动
@@ -83,12 +86,13 @@ public class ScheduleThread {
@PostConstruct
public void init() {
log.info("初始化调度服务");
//读取信息初始化
plcReadInit();
//调度初始化
scheduleInit();
RuntimeCapabilities capabilities = capabilities();
if (capabilities.isPlcFactReadersAllowed()) {
plcReadInit();
}
if (capabilities.isLegacyBusinessThreadsAllowed()) {
scheduleInit();
}
log.info("调度服务执行成功");
// startAllThread();
// startAllThread2();
@@ -97,7 +101,7 @@ public class ScheduleThread {
@EventListener(ApplicationReadyEvent.class)
public void startStandardFactReadersAfterApplicationReady() {
if (isStandardMode()) {
if (capabilities().isAutomaticFactReaderStartAllowed()) {
startAllThread();
}
}
@@ -106,7 +110,7 @@ public class ScheduleThread {
* 调度初始化
*/
private void scheduleInit() {
if (isStandardMode()) {
if (!runtimeMode().isLegacy()) {
return;
}
MonitoringThread monitoringThread = new MonitoringThread();
@@ -190,6 +194,7 @@ public class ScheduleThread {
* 开始线程
*/
public synchronized void startAllThread() {
requirePlcFactReadersAllowed();
if (isRun) {
return;
}
@@ -211,10 +216,7 @@ public class ScheduleThread {
* 开启线程2
*/
public void startAllThread2() {
if (isStandardMode()) {
throw new IllegalStateException(
"legacy business scheduler is disabled in STANDARD mode");
}
requireLegacyBusinessThreadsAllowed();
monitoringCustomThread.thread.start();
ssjThreadCustomThread.thread.start();
tsjThreadCustomThread.thread.start();
@@ -228,6 +230,7 @@ public class ScheduleThread {
* 停止线程
*/
public synchronized void stopAllThread() {
requirePlcFactReadersAllowed();
if (!isRun) {
return;
}
@@ -341,6 +344,9 @@ public class ScheduleThread {
* 停止线程2
*/
public void stopAllThread2() {
if (runtimeMode().isShadow()) {
throw new IllegalStateException(SHADOW_THREAD_REJECTION);
}
if (isStandardMode()) {
isRun2 = false;
return;
@@ -359,16 +365,84 @@ public class ScheduleThread {
/**
* 获取调度状态
*/
public HashMap<String,Boolean> getScheduleStatus() {
HashMap<String,Boolean> map = new HashMap<>();
public HashMap<String, Boolean> getScheduleStatus() {
HashMap<String, Boolean> map = new HashMap<String, Boolean>();
map.put("isRun",isRun);
map.put("isRun2",isRun2);
return map;
}
public HashMap<String, Object> getScheduleStatusDetails() {
RuntimeCapabilities capabilities = capabilities();
HashMap<String, Object> map = new LinkedHashMap<String, Object>();
map.putAll(getScheduleStatus());
map.put("runtimeMode", runtimeMode().name());
map.put("plcFactReadersAllowed",
capabilities.isPlcFactReadersAllowed());
map.put("legacyBusinessThreadsAllowed",
capabilities.isLegacyBusinessThreadsAllowed());
map.put("automaticFactReaderStartAllowed",
capabilities.isAutomaticFactReaderStartAllowed());
return map;
}
private boolean isStandardMode() {
return ft546WcsProperties != null
&& ft546WcsProperties.getRuntimeMode()
== Ft546WcsProperties.RuntimeMode.STANDARD;
return runtimeMode().isStandard();
}
private Ft546WcsProperties.RuntimeMode runtimeMode() {
return ft546WcsProperties == null
? Ft546WcsProperties.RuntimeMode.LEGACY
: ft546WcsProperties.getRuntimeMode();
}
private RuntimeCapabilities capabilities() {
Ft546WcsProperties.RuntimeMode mode = runtimeMode();
return new RuntimeCapabilities(!mode.isShadow(), mode.isLegacy(),
mode.isStandard());
}
private void requirePlcFactReadersAllowed() {
if (!capabilities().isPlcFactReadersAllowed()) {
throw new IllegalStateException(SHADOW_THREAD_REJECTION);
}
}
private void requireLegacyBusinessThreadsAllowed() {
Ft546WcsProperties.RuntimeMode mode = runtimeMode();
if (mode.isShadow()) {
throw new IllegalStateException(SHADOW_THREAD_REJECTION);
}
if (!mode.isLegacy()) {
throw new IllegalStateException(
"legacy business scheduler is disabled in STANDARD mode");
}
}
public static final class RuntimeCapabilities {
private final boolean plcFactReadersAllowed;
private final boolean legacyBusinessThreadsAllowed;
private final boolean automaticFactReaderStartAllowed;
RuntimeCapabilities(boolean plcFactReadersAllowed,
boolean legacyBusinessThreadsAllowed,
boolean automaticFactReaderStartAllowed) {
this.plcFactReadersAllowed = plcFactReadersAllowed;
this.legacyBusinessThreadsAllowed = legacyBusinessThreadsAllowed;
this.automaticFactReaderStartAllowed =
automaticFactReaderStartAllowed;
}
public boolean isPlcFactReadersAllowed() {
return plcFactReadersAllowed;
}
public boolean isLegacyBusinessThreadsAllowed() {
return legacyBusinessThreadsAllowed;
}
public boolean isAutomaticFactReaderStartAllowed() {
return automaticFactReaderStartAllowed;
}
}
}

View File

@@ -67,6 +67,6 @@ public class ThreadOperationController {
*/
@RequestMapping("/getScheduleStatus")
public AjaxResult getScheduleStatus() {
return AjaxResult.success(scheduleThread.getScheduleStatus());
return AjaxResult.success(scheduleThread.getScheduleStatusDetails());
}
}

View File

@@ -95,8 +95,8 @@ public class BaseSRgvThread implements Runnable, SRgvThreadInterface {
@Override
public void run() {
if (isStandardRuntime()) {
log.warn("STANDARD mode owns scheduling; legacy SRGV thread will not run");
if (!isLegacyRuntime()) {
log.warn("Non-LEGACY mode owns scheduling; legacy SRGV thread will not run");
return;
}
@@ -141,11 +141,10 @@ public class BaseSRgvThread implements Runnable, SRgvThreadInterface {
}
}
private boolean isStandardRuntime() {
private boolean isLegacyRuntime() {
Ft546WcsProperties properties =
SpringUtils.getBean(Ft546WcsProperties.class);
return properties.getRuntimeMode()
== Ft546WcsProperties.RuntimeMode.STANDARD;
return properties.getRuntimeMode().isLegacy();
}
/**

View File

@@ -0,0 +1,2 @@
org.springframework.boot.env.EnvironmentPostProcessor=\
com.ruoyi.project.ft546.runtime.Ft546AdminRuntimeModeEnvironmentPostProcessor

View File

@@ -0,0 +1,279 @@
package com.ruoyi.project.ft546.runtime;
import com.ruoyi.framework.web.exception.GlobalExceptionHandler;
import com.ruoyi.project.ft546.config.Ft546WcsProperties;
import com.ruoyi.schedule.ScheduleThread;
import com.ruoyi.schedule.ThreadOperationController;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class Ft546ShadowIsolationTest {
private static final String SHADOW_REJECTION =
"SHADOW mode forbids legacy and PLC thread operations";
private static final AtomicBoolean PROBE_CONSTRUCTED =
new AtomicBoolean();
@Test
void shadowInitializationDoesNotConstructPlcOrLegacyThreads() {
ScheduleThread runtime = runtime(Ft546WcsProperties.RuntimeMode.SHADOW);
runtime.init();
assertTrue(runtime.plcSrgvReadCustomThreads.isEmpty());
assertNull(runtime.plcSsjReadCustomThread);
assertNull(runtime.plcTsjReadCustomThread);
assertNull(runtime.plcCdzReadCustomThread);
assertNull(runtime.monitoringCustomThread);
assertNull(runtime.ssjThreadCustomThread);
assertNull(runtime.sRgvThreadCustomThread);
assertNull(runtime.tsjThreadCustomThread);
assertNull(runtime.cdzThreadCustomThread);
}
@Test
void everyManualThreadOperationFailsClosedInShadow() {
ScheduleThread runtime = runtime(Ft546WcsProperties.RuntimeMode.SHADOW);
assertRejected(runtime::startAllThread);
assertRejected(runtime::startAllThread2);
assertRejected(runtime::stopAllThread);
assertRejected(runtime::stopAllThread2);
}
@Test
void shadowStatusExplainsThatNoThreadCapabilityIsAllowed() {
ScheduleThread runtime = runtime(Ft546WcsProperties.RuntimeMode.SHADOW);
Map<?, ?> status = runtime.getScheduleStatusDetails();
assertEquals("SHADOW", status.get("runtimeMode"));
assertEquals(Boolean.FALSE, status.get("plcFactReadersAllowed"));
assertEquals(Boolean.FALSE, status.get("legacyBusinessThreadsAllowed"));
assertEquals(Boolean.FALSE, status.get("automaticFactReaderStartAllowed"));
assertEquals(Boolean.FALSE, status.get("isRun"));
assertEquals(Boolean.FALSE, status.get("isRun2"));
}
@Test
void runtimeModePredicatesAreExclusive() {
assertTrue(Ft546WcsProperties.RuntimeMode.LEGACY.isLegacy());
assertFalse(Ft546WcsProperties.RuntimeMode.LEGACY.isShadow());
assertFalse(Ft546WcsProperties.RuntimeMode.LEGACY.isStandard());
assertTrue(Ft546WcsProperties.RuntimeMode.SHADOW.isShadow());
assertTrue(Ft546WcsProperties.RuntimeMode.STANDARD.isStandard());
}
@Test
void adminEnvironmentProcessorRejectsShadowBeforeContextCreation() {
StandardEnvironment environment = environment("SHADOW");
Ft546AdminRuntimeModeEnvironmentPostProcessor processor =
new Ft546AdminRuntimeModeEnvironmentPostProcessor();
IllegalStateException failure = assertThrows(IllegalStateException.class,
() -> processor.postProcessEnvironment(environment,
new SpringApplication(Object.class)));
assertEquals(
"ADMIN_SHADOW_MODE_FORBIDDEN_USE_RUOYI_WCS_SHADOW",
failure.getMessage());
}
@Test
void adminEnvironmentProcessorAcceptsMissingLegacyAndStandardModes() {
Ft546AdminRuntimeModeEnvironmentPostProcessor processor =
new Ft546AdminRuntimeModeEnvironmentPostProcessor();
processor.postProcessEnvironment(new StandardEnvironment(),
new SpringApplication(Object.class));
processor.postProcessEnvironment(environment("LEGACY"),
new SpringApplication(Object.class));
processor.postProcessEnvironment(environment("STANDARD"),
new SpringApplication(Object.class));
}
@Test
void adminEnvironmentProcessorRejectsUnknownModes() {
Ft546AdminRuntimeModeEnvironmentPostProcessor processor =
new Ft546AdminRuntimeModeEnvironmentPostProcessor();
IllegalStateException failure = assertThrows(
IllegalStateException.class,
() -> processor.postProcessEnvironment(environment("unsafe"),
new SpringApplication(Object.class)));
assertEquals("UNSUPPORTED_FT546_WCS_RUNTIME_MODE:UNSAFE",
failure.getMessage());
}
@Test
void registeredProcessorRejectsShadowBeforeAnyBeanConstruction() {
PROBE_CONSTRUCTED.set(false);
SpringApplication application = new SpringApplication(
ProbeConfiguration.class);
application.setWebApplicationType(WebApplicationType.NONE);
IllegalStateException failure = assertThrows(IllegalStateException.class,
() -> application.run(
"--ft546.wcs.runtime-mode=SHADOW",
"--spring.main.banner-mode=off",
"--logging.level.root=OFF"));
assertEquals(
"ADMIN_SHADOW_MODE_FORBIDDEN_USE_RUOYI_WCS_SHADOW",
rootCause(failure).getMessage());
assertFalse(PROBE_CONSTRUCTED.get());
}
@Test
void registeredProcessorReadsConfigDataBeforeBeanConstruction(
@TempDir Path tempDir) throws Exception {
Path config = tempDir.resolve("application.properties");
Files.write(config, Collections.singletonList(
"ft546.wcs.runtime-mode=SHADOW"),
StandardCharsets.UTF_8);
PROBE_CONSTRUCTED.set(false);
SpringApplication application = new SpringApplication(
ProbeConfiguration.class);
application.setWebApplicationType(WebApplicationType.NONE);
IllegalStateException failure = assertThrows(
IllegalStateException.class,
() -> application.run(
"--spring.config.location="
+ config.toUri().toString(),
"--spring.main.banner-mode=off",
"--logging.level.root=OFF"));
assertEquals(
"ADMIN_SHADOW_MODE_FORBIDDEN_USE_RUOYI_WCS_SHADOW",
rootCause(failure).getMessage());
assertFalse(PROBE_CONSTRUCTED.get());
}
@Test
void threadMutationEndpointsKeepTheStableAuthorizationSurface()
throws Exception {
assertNoNewPermission("startAllThread");
assertNoNewPermission("startAllThread2");
assertNoNewPermission("stopAllThread");
assertNoNewPermission("stopAllThread2");
}
@Test
void scheduleStatusKeepsItsStableBinaryReturnType() throws Exception {
Method method = ScheduleThread.class.getMethod(
"getScheduleStatus");
assertEquals(HashMap.class, method.getReturnType());
assertEquals("java.util.HashMap<java.lang.String, java.lang.Boolean>",
method.getGenericReturnType().getTypeName());
Map<?, ?> stableStatus = runtime(
Ft546WcsProperties.RuntimeMode.SHADOW).getScheduleStatus();
assertEquals(2, stableStatus.size());
assertEquals(Boolean.FALSE, stableStatus.get("isRun"));
assertEquals(Boolean.FALSE, stableStatus.get("isRun2"));
}
@Test
void everyWebThreadMutationReturnsAnErrorInShadow() throws Exception {
ThreadOperationController controller =
new ThreadOperationController();
ReflectionTestUtils.setField(controller, "scheduleThread",
runtime(Ft546WcsProperties.RuntimeMode.SHADOW));
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new GlobalExceptionHandler())
.build();
assertWebRejected(mockMvc, "startAllThread");
assertWebRejected(mockMvc, "startAllThread2");
assertWebRejected(mockMvc, "stopAllThread");
assertWebRejected(mockMvc, "stopAllThread2");
}
private static void assertRejected(Runnable action) {
IllegalStateException failure = assertThrows(
IllegalStateException.class, action::run);
assertEquals(SHADOW_REJECTION, failure.getMessage());
}
private static ScheduleThread runtime(Ft546WcsProperties.RuntimeMode mode) {
Ft546WcsProperties properties = new Ft546WcsProperties();
properties.setRuntimeMode(mode);
ScheduleThread runtime = new ScheduleThread();
ReflectionTestUtils.setField(runtime, "ft546WcsProperties", properties);
return runtime;
}
private static StandardEnvironment environment(String runtimeMode) {
StandardEnvironment environment = new StandardEnvironment();
environment.getPropertySources().addFirst(new MapPropertySource(
"test", Collections.<String, Object>singletonMap(
"ft546.wcs.runtime-mode", runtimeMode)));
return environment;
}
private static void assertNoNewPermission(String methodName)
throws Exception {
Method method = ThreadOperationController.class.getMethod(methodName);
PreAuthorize annotation = AnnotationUtils.findAnnotation(
method, PreAuthorize.class);
assertNull(annotation, methodName
+ " must keep the stable authorization surface");
}
private static void assertWebRejected(MockMvc mockMvc, String operation)
throws Exception {
mockMvc.perform(post("/device/operation/" + operation))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(500))
.andExpect(jsonPath("$.msg").value(SHADOW_REJECTION));
}
private static Throwable rootCause(Throwable failure) {
Throwable current = failure;
while (current.getCause() != null && current.getCause() != current) {
current = current.getCause();
}
return current;
}
@Configuration(proxyBeanMethods = false)
static class ProbeConfiguration {
@Bean
Object constructionProbe() {
PROBE_CONSTRUCTED.set(true);
return new Object();
}
}
}