From d30fa0bd1cb95f7f657531c64d2b3bc5b96b27ad Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Wed, 12 Aug 2026 12:36:40 +0800 Subject: [PATCH] fix(ft546): isolate shadow from legacy and PLC threads --- ...26-08-12-ft546-realtime-readonly-shadow.md | 937 ++++++++++++++++++ .../ft546/config/Ft546WcsProperties.java | 6 +- ...inRuntimeModeEnvironmentPostProcessor.java | 36 + .../com/ruoyi/schedule/ScheduleThread.java | 108 +- .../schedule/ThreadOperationController.java | 2 +- .../scheduleTask/srgv/BaseSRgvThread.java | 9 +- .../main/resources/META-INF/spring.factories | 2 + .../runtime/Ft546ShadowIsolationTest.java | 279 ++++++ 8 files changed, 1355 insertions(+), 24 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-12-ft546-realtime-readonly-shadow.md create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/project/ft546/runtime/Ft546AdminRuntimeModeEnvironmentPostProcessor.java create mode 100644 ruoyi-admin/src/main/resources/META-INF/spring.factories create mode 100644 ruoyi-admin/src/test/java/com/ruoyi/project/ft546/runtime/Ft546ShadowIsolationTest.java diff --git a/docs/superpowers/plans/2026-08-12-ft546-realtime-readonly-shadow.md b/docs/superpowers/plans/2026-08-12-ft546-realtime-readonly-shadow.md new file mode 100644 index 0000000..9035590 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-ft546-realtime-readonly-shadow.md @@ -0,0 +1,937 @@ +# FT546 Real-Time Read-Only Shadow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build an independently deployable FT546 real-time shadow program that reads approved production facts, runs the same dispatch and FT546 PLC-intent code intended for `STANDARD`, and persists comparison evidence without starting legacy threads, connecting to PLC/WMS, or writing any production table. + +**Architecture:** Keep the deployed stable WCS as the only production scheduler and PLC/WMS writer. Add a separate `ruoyi-wcs-shadow` Spring Boot process with two explicit data sources: a production account restricted to approved read-only views and a physically separate evidence database. Extract FT546 register encoding into a pure protocol component shared by the real adapter and shadow recorder; run the shared dispatch kernel through a shadow-specific cycle that persists virtual commands and comparison evidence instead of dispatching Outbox entries. + +**Tech Stack:** Java 8, Maven multi-module, Spring Boot 2.5.15, Spring JDBC transactions, SQL Server JDBC, H2 file database for local evidence tests, JUnit 5, ArchUnit, PowerShell deployment scripts. + +## Global Constraints + +- Work only in the `optimize/wcs` worktree; do not merge, push, or modify the deployed stable branch. +- The deployed stable artifact remains the only scheduler and only PLC/WMS writer throughout shadow collection. +- `LEGACY` remains the default `ft546.wcs.runtime-mode`; no task changes that default. +- `SHADOW` must have zero PLC connections, zero PLC writes, zero WMS callbacks, zero writes to production business/core tables, and zero legacy business or PLC-reader threads. +- The real-time shadow is a separate process, service port, log directory, deployment directory, configuration file, and preferably a separate host. +- The shadow production account may read only `shadow_ro.*` views; it must not receive `INSERT`, `UPDATE`, `DELETE`, `EXECUTE`, `ALTER`, `CONTROL`, or ownership permissions in the production database. +- Shadow evidence writes use a physically separate database or server. A different schema in the production database is insufficient for the first site run. +- Do not use `DataSourceType`, `DynamicDataSourceContextHolder`, or the shared RuoYi `SqlSessionFactory` for the two shadow data sources. Use explicit bean qualifiers and explicit transaction managers so a production connection can never be reused for evidence writes. +- Production reads use a maximum pool size of 2, zero minimum idle connections, a 2-second connect timeout, a 3-second query timeout, fixed-delay polling, exponential backoff, and a one-cycle in-flight guard. +- Missing, stale, partial, or contradictory facts produce `INSUFFICIENT_FACTS`; they do not generate a comparable virtual command. +- The FT546 protocol contract remains exactly: main `4004 -> 4000 -> 4002 -> 4021`; cross-floor adds `4020=1 -> 4022=`; two-stage one `4404 -> 4422=1 -> 4402= -> 4400`; two-stage two `4404 -> 4422=2 -> 4423=99 -> 4402=9 -> 4400`. +- A shadow protocol difference and an algorithm decision difference are separate categories. Never call a different selected vehicle/path a protocol failure when both encodings are internally correct. +- No history field (`agv_id`, `path_info`, completion status, or `finish_date`) may force the new kernel to repeat the legacy choice. +- This plan delivers the real-time read-only shadow and its deployment/rollback evidence. It does not authorize `STANDARD` PLC control or a production cutover. +- All feature changes follow red-green-refactor. Each task runs its named test while red and again while green. +- Preserve the four pre-existing untracked documentation HTML files and the generated `javaTest/outputs/wcs-history-business-shadow-20260812-f66585f/` evidence directory. + +--- + +## File Structure + +New shared protocol module: + +- `ruoyi-ft546-protocol/`: pure FT546 profile, capability, command-intent, register-write, and encoder types. It has no Spring, JDBC, PLC, RuoYi Admin, or network dependency. +- `Ft546SrgvCommandIntent`: strong input containing command identity, phase, route version, task number/type, route points, optional cross-floor target, and optional two-stage number. +- `Ft546RegisterWrite`: immutable `address/dataType/values/writeSequence/trigger` value. +- `Ft546EncodedCommand`: immutable ordered write list and command evidence. +- `Ft546SrgvCommandEncoder`: the only FT546 shuttle register encoder used by both real and shadow paths. + +New independent shadow process: + +- `ruoyi-wcs-shadow/`: Spring Boot command-line service with no dependency on `ruoyi-admin` or `ruoyi-framework`. +- `Ft546ShadowApplication`: imports only explicit shadow configuration and pure core/protocol beans; it does not component-scan `com.ruoyi.wcs` or `com.ruoyi.project`, so it cannot discover `WcsCoreConfiguration`, `ScheduleThread`, `ThreadOperationController`, PLC readers, WMS controllers, or production adapters. +- `ShadowSourceConfiguration`: explicit read-only SQL Server `DataSource`, `JdbcTemplate`, and transaction manager. +- `ShadowEvidenceConfiguration`: explicit evidence `DataSource`, `JdbcTemplate`, and transaction manager. +- `ShadowStartupGate`: fail-closed verification of process mode, JDBC users/databases, source permissions, evidence location, dependency graph, and local output writability. +- `SqlServerShadowFactReader`: bounded reads from `shadow_ro.v_*` views and assembly of one immutable `ShadowFactBatch`. +- `Ft546ShadowCycle`: source fact collection -> fact validation -> same-kernel dispatch -> virtual command intent -> protocol encoding -> comparison -> evidence commit. It never invokes `CommandOutboxDispatcher`. +- `RecordingFt546CommandGateway`: records encoded commands to the evidence repository; it has no `send` or PLC runtime reference. +- `ShadowEvidenceRepository`: stores run, input batch, legacy result, new decision, encoded writes, differences, and cycle health in the evidence database. + +Existing production files remain owners of production behavior: + +- `ScheduleThread` continues to own legacy readers/business threads in `LEGACY` and standard PLC fact readers in `STANDARD`; `SHADOW` constructs neither. +- `Ft546LegacySrgvAdapter` remains the real PLC gateway in `STANDARD`, but delegates encoding to the shared pure protocol module before iterating the encoded writes. +- `Ft546AdapterConfiguration` remains `STANDARD`-only. The shadow process does not import it. + +--- + +### Task 1: Make Runtime Modes Fail-Closed Before Any Legacy or PLC Object Exists + +**Files:** +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/schedule/ScheduleThread.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/schedule/ThreadOperationController.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/schedule/scheduleTask/srgv/BaseSRgvThread.java` +- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/runtime/Ft546AdminRuntimeModeEnvironmentPostProcessor.java` +- Modify: `ruoyi-admin/src/main/resources/META-INF/spring.factories` +- Modify: `ruoyi-admin/src/test/java/com/ruoyi/project/ft546/runtime/Ft546StandardRuntimeTest.java` +- Create: `ruoyi-admin/src/test/java/com/ruoyi/project/ft546/runtime/Ft546ShadowIsolationTest.java` + +**Interfaces:** +- Produces: `Ft546WcsProperties.RuntimeMode.isLegacy/isShadow/isStandard`. +- Produces: `ScheduleThread.RuntimeCapabilities` with `plcFactReadersAllowed`, `legacyBusinessThreadsAllowed`, and `automaticFactReaderStartAllowed`. +- Preserves: `LEGACY` initialization/start/stop behavior and `STANDARD` application-ready PLC fact-reader start. +- Enforces: every thread operation method rejects `SHADOW` before dereferencing a thread field. +- Enforces: the `ruoyi-admin` artifact refuses to start in `SHADOW` during environment preparation, before Spring constructs `ScheduleThread`; operators must use the dedicated `ruoyi-wcs-shadow` artifact. + +- [ ] **Step 1: Write failing mode-isolation tests** + +```java +@Test +void shadowPostConstructDoesNotQueryDevicesOrConstructAnyThread() { + ScheduleThread runtime = runtime(SHADOW); + ReflectionTestUtils.setField(runtime, "deviceSrgvService", + failOnAnyInvocation(IDeviceSrgvService.class)); + + runtime.init(); + + assertTrue(runtime.plcSrgvReadCustomThreads.isEmpty()); + assertNull(runtime.plcSsjReadCustomThread); + assertNull(runtime.plcTsjReadCustomThread); + assertNull(runtime.plcCdzReadCustomThread); + assertNull(runtime.sRgvThreadCustomThread); +} + +@Test +void everyManualThreadOperationFailsClosedInShadow() { + ScheduleThread runtime = runtime(SHADOW); + + assertThrows(IllegalStateException.class, runtime::startAllThread); + assertThrows(IllegalStateException.class, runtime::startAllThread2); + assertThrows(IllegalStateException.class, runtime::stopAllThread); + assertThrows(IllegalStateException.class, runtime::stopAllThread2); +} +``` + +Add a MockMvc test proving all four mutation endpoints return an error in `SHADOW`. Preserve the deployed stable authorization surface exactly: the stable controller currently leaves its `@PreAuthorize` lines commented, so this task must not enable them and risk blocking existing operator accounts. Add a `SpringApplication` test proving `Ft546AdminRuntimeModeEnvironmentPostProcessor` throws `ADMIN_SHADOW_MODE_FORBIDDEN_USE_RUOYI_WCS_SHADOW` during environment preparation, after Config Data is loaded but before any test bean's constructor or `@PostConstruct`, when the admin artifact is configured with `runtime-mode=SHADOW`. + +- [ ] **Step 2: Run the isolation test and verify current `@PostConstruct` behavior fails** + +Run: + +```bash +mvn -pl ruoyi-admin -am -Dtest=Ft546ShadowIsolationTest,Ft546StandardRuntimeTest test +``` + +Expected: `shadowPostConstructDoesNotQueryDevicesOrConstructAnyThread` fails because current `init()` unconditionally calls `plcReadInit()` and `scheduleInit()`; manual PLC-reader start also does not reject `SHADOW`. + +- [ ] **Step 3: Centralize mode capabilities and guard before construction** + +```java +public void init() { + RuntimeCapabilities capabilities = capabilities(); + if (capabilities.plcFactReadersAllowed()) { + plcReadInit(); + } + if (capabilities.legacyBusinessThreadsAllowed()) { + scheduleInit(); + } +} + +private RuntimeCapabilities capabilities() { + RuntimeMode mode = ft546WcsProperties.getRuntimeMode(); + return new RuntimeCapabilities( + mode == RuntimeMode.LEGACY || mode == RuntimeMode.STANDARD, + mode == RuntimeMode.LEGACY, + mode == RuntimeMode.STANDARD); +} +``` + +Call `requirePlcFactReadersAllowed()` at the first line of `startAllThread/stopAllThread`, and `requireLegacyBusinessThreadsAllowed()` at the first line of `startAllThread2/stopAllThread2`. Keep `BaseSRgvThread.run()` fail-closed for every non-`LEGACY` mode. Preserve the four endpoints' stable authorization behavior. Preserve the existing `ScheduleThread.getScheduleStatus()` Java signature and add a detailed status view for the controller so operators can see the current mode and allowed operations without breaking existing callers. Register `Ft546AdminRuntimeModeEnvironmentPostProcessor` in `META-INF/spring.factories`; it reads the raw environment property, accepts missing/`LEGACY`/`STANDARD`, and throws after Config Data loading but before bean creation for `SHADOW` or an unknown value. The exception names the dedicated shadow artifact and does not silently fall back to `LEGACY`. Lower-level thread guards remain as defense in depth and are unit-tested directly. + +- [ ] **Step 4: Run focused and regression tests** + +Run: + +```bash +mvn -pl ruoyi-admin -am -Dtest=Ft546ShadowIsolationTest,Ft546StandardRuntimeTest,Ft546DeviceRuntimeRegistryTest test +``` + +Expected: `SHADOW` constructs no thread/runtime object; `LEGACY` existing initialization remains intact; `STANDARD` starts readers only after `ApplicationReadyEvent`; endpoint authorization and mode rejection pass. + +- [ ] **Step 5: Commit the hard runtime boundary** + +```bash +git add ruoyi-admin/src/main/java/com/ruoyi/schedule \ + ruoyi-admin/src/main/java/com/ruoyi/project/ft546/runtime/Ft546AdminRuntimeModeEnvironmentPostProcessor.java \ + ruoyi-admin/src/main/resources/META-INF/spring.factories \ + ruoyi-admin/src/test/java/com/ruoyi/project/ft546/runtime +git commit -m "fix(ft546): isolate shadow from legacy and PLC threads" +``` + +--- + +### Task 2: Extract One Pure FT546 Protocol Encoder For Real And Shadow Paths + +**Files:** +- Create: `ruoyi-ft546-protocol/pom.xml` +- Modify: `pom.xml` +- Create: `ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546DeviceProfile.java` +- Create: `ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546SrgvCommandIntent.java` +- Create: `ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546RegisterWrite.java` +- Create: `ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546EncodedCommand.java` +- Create: `ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546SrgvCommandEncoder.java` +- Delete after import migration: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546DeviceProfile.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546ConfigurationValidator.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546CapabilityGate.java` +- Modify: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapter.java` +- Create: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546CommandIntentMapper.java` +- Delete after callers migrate: `ruoyi-admin/src/main/java/com/ruoyi/project/ft546/device/shuttle/Ft546SrgvCommandEncoder.java` +- Create: `ruoyi-ft546-protocol/src/test/java/com/ruoyi/ft546/protocol/Ft546SrgvCommandEncoderTest.java` +- Modify: `ruoyi-admin/src/test/java/com/ruoyi/project/ft546/device/shuttle/Ft546LegacySrgvAdapterTest.java` +- Move/copy fixtures: `ruoyi-ft546-protocol/src/test/resources/ft546/plc/srgv-point-to-point.json` and `srgv-two-stage-gate.json` + +**Interfaces:** +- Produces: `Ft546SrgvCommandEncoder.encode(Ft546SrgvCommandIntent, Ft546DeviceProfile, Set) -> Ft546EncodedCommand`. +- Produces: `Ft546CommandIntentMapper.from(DeviceCommandEnvelope) -> Ft546SrgvCommandIntent` in `ruoyi-admin`; the pure module never imports WCS envelope types. +- Produces: ordered `Ft546RegisterWrite` values with public getters for `address`, `dataType`, `values`, `writeSequence`, and `trigger`. +- Preserves: current `SendReceipt` and `UNKNOWN` behavior in the real adapter. + +- [ ] **Step 1: Write protocol golden-master tests independent of PLC classes** + +```java +@Test +void crossFloorIntentEncodesTheExactLegacySequence() { + Ft546EncodedCommand encoded = encoder.encode( + intent("PARENT-1", "LOADED_TO_PLACE", 30001, (short) 8, + points(70501, 70502), (short) 8, null), + fullProfile(), set("POINT_TO_POINT_COMMAND")); + + assertWrites(encoded, + write(1, "4004", INT32, 70501, 70502, 0, 0, 0, 0, 0, 0), + write(2, "4000", INT32, 30001), + write(3, "4002", INT16, 8), + trigger(4, "4021", INT16, 1), + write(5, "4020", INT16, 1), + write(6, "4022", INT16, 8)); +} +``` + +Add exact tests for ordinary main task, two-stage stage one, two-stage stage two, missing registers, route length 2/8 boundaries, route length 1/9 rejection, six-digit point overflow, capability gates, and immutable profile/write lists. + +- [ ] **Step 2: Run the new module test and verify it fails to compile** + +Run: + +```bash +mvn -pl ruoyi-ft546-protocol -am -Dtest=Ft546SrgvCommandEncoderTest test +``` + +Expected: Maven initially cannot find the module/types. + +- [ ] **Step 3: Implement the pure encoder and make the real adapter consume it** + +```java +Ft546EncodedCommand encoded = encoder.encode( + intentMapper.from(envelope), profile, + capabilities.getEnabledCapabilities()); +boolean wroteAny = false; +for (Ft546RegisterWrite write : encoded.getWrites()) { + if (!plcRegisterWriter.write(runtime, write)) { + return markUnknown(envelope.getDeviceId(), receiptId, + wroteAny + ? "PLC rejected a partial register sequence" + : "first register write result is ambiguous"); + } + wroteAny = true; +} +``` + +Keep `PlcRegisterWriter` inside `ruoyi-admin`; the pure module never imports `PlcSrgvRead`. The encoder includes evidence fields `deviceId`, `parentTaskId`, `phase`, `commandKey`, `routeVersion`, `taskNo`, `taskType`, `pathPoints`, `writeSequence`, and `triggerValue`. + +- [ ] **Step 4: Run protocol and adapter regression tests** + +Run: + +```bash +mvn -pl ruoyi-ft546-protocol,ruoyi-admin -am \ + -Dtest=Ft546SrgvCommandEncoderTest,Ft546LegacySrgvAdapterTest,Ft546StandardWcsEndToEndTest test +``` + +Expected: golden protocol fixtures and all current real-adapter behaviors pass; partial writes still become `ACCEPTED_UNKNOWN`; no address/type/value/order changes occur. + +- [ ] **Step 5: Commit the shared protocol seam** + +```bash +git add pom.xml ruoyi-ft546-protocol ruoyi-admin/pom.xml \ + ruoyi-admin/src/main/java/com/ruoyi/project/ft546 \ + ruoyi-admin/src/test/java/com/ruoyi/project/ft546 +git commit -m "refactor(ft546): share pure PLC command encoding" +``` + +--- + +### Task 3: Add An Independent Shadow Launcher With A Dependency Deny-List + +**Files:** +- Create: `ruoyi-wcs-shadow/pom.xml` +- Modify: `pom.xml` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/Ft546ShadowApplication.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowProperties.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowRuntimeConfiguration.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupGate.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupReport.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/web/ShadowStatusController.java` +- Create: `ruoyi-wcs-shadow/src/main/resources/application-shadow.yml` +- Create: `ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java` +- Create: `ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/ShadowDependencyBoundaryTest.java` +- Create: `ruoyi-wcs-shadow/src/test/resources/application-shadow-test.yml` + +**Interfaces:** +- Launch: `java -jar ruoyi-wcs-shadow.jar --spring.profiles.active=shadow --shadow.enabled=true`. +- Status: `GET /shadow/status` returns `STARTING`, `READY`, `DEGRADED`, or `STOPPED`, last completed cycle, source lag, evidence backlog, and startup checks. +- Produces: `ShadowStartupGate.verify(ApplicationContext, ShadowProperties)`. +- Enforces: `server.address=127.0.0.1` by default and `server.port=18091`; no actuator mutation endpoint. + +- [ ] **Step 1: Write application and architecture tests** + +```java +@Test +void shadowContextContainsNoProductionSideEffectType() { + contextRunner.run(context -> { + assertThat(context).doesNotHaveBean(DeviceCommandGateway.class); + assertThat(context).doesNotHaveBean(WmsNotificationGateway.class); + assertFalse(ClassUtils.isPresent( + "com.ruoyi.schedule.ScheduleThread", context.getClassLoader())); + assertThat(context.getBeanDefinitionNames()) + .noneMatch(name -> name.toLowerCase().contains("plc")); + }); +} + +@ArchTest +static final ArchRule shadowMustNotDependOnAdminOrPlc = noClasses() + .that().resideInAPackage("com.ruoyi.shadow..") + .should().dependOnClassesThat() + .resideInAnyPackage("com.ruoyi.schedule..", + "com.ruoyi.api..", "HslCommunication.."); +``` + +Also assert startup fails unless `shadow.enabled=true`, `spring.profiles.active=shadow`, and both source/evidence URLs are explicitly supplied outside tests. + +- [ ] **Step 2: Run the new module test and verify the launcher is missing** + +Run: + +```bash +mvn -pl ruoyi-wcs-shadow -am -Dtest=Ft546ShadowApplicationTest,ShadowDependencyBoundaryTest test +``` + +Expected: module/application classes are missing. + +- [ ] **Step 3: Implement a narrow component graph** + +```java +@SpringBootConfiguration +@EnableAutoConfiguration +@Import({ShadowRuntimeConfiguration.class, ShadowKernelConfiguration.class}) +@EnableScheduling +public class Ft546ShadowApplication { + public static void main(String[] args) { + SpringApplication.run(Ft546ShadowApplication.class, args); + } +} +``` + +Exclude RuoYi security, Redis, Quartz, Swagger, TCP servers, MyBatis auto-scans, `WcsCoreConfiguration`, and all `ruoyi-admin` dependencies. `ShadowKernelConfiguration` constructs `DefaultVehicleAssignmentPolicy`, `AStarRoutePlanner`, `DeadlockGuard`, and `DispatchCoordinator` explicitly. The module depends only on `ruoyi-wcs`, `ruoyi-ft546-protocol`, Spring Web/JDBC, SQL Server driver, Jackson, and tests. `ShadowStartupGate` inspects bean types and package names at runtime and stops scheduling if a forbidden side-effect type appears. + +- [ ] **Step 4: Run context and package-boundary tests** + +Run: + +```bash +mvn -pl ruoyi-wcs-shadow -am -Dtest=Ft546ShadowApplicationTest,ShadowDependencyBoundaryTest test +``` + +Expected: the context starts with in-memory test adapters only, status binds to loopback, and no forbidden type is reachable. + +- [ ] **Step 5: Commit the isolated process** + +```bash +git add pom.xml ruoyi-wcs-shadow +git commit -m "feat(ft546): add isolated shadow process" +``` + +--- + +### Task 4: Enforce Two Physical Data Sources And Read-Only Production Permissions + +**Files:** +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowSourceConfiguration.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowEvidenceConfiguration.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/SqlServerPermissionProbe.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/PermissionCheckResult.java` +- Modify: `ruoyi-wcs-shadow/src/main/resources/application-shadow.yml` +- Create: `ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/config/ShadowDataSourceIsolationTest.java` +- Create: `ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/startup/SqlServerPermissionProbeTest.java` +- Create: `sql/shadow/V001__ft546_shadow_read_views.sql` +- Create: `sql/shadow/V002__ft546_shadow_read_login.sql` +- Create: `sql/shadow/V003__ft546_shadow_evidence.sql` +- Create: `sql/shadow/verify_ft546_shadow_permissions.sql` + +**Interfaces:** +- Beans: `@Qualifier("shadowSourceDataSource")`, `shadowSourceJdbc`, `shadowSourceTx`; `@Qualifier("shadowEvidenceDataSource")`, `shadowEvidenceJdbc`, `shadowEvidenceTx`. +- Produces: `SqlServerPermissionProbe.verifySource(Connection)` and `verifyEvidence(Connection)`. +- Production views: `shadow_ro.v_wcs_srgv_queue`, `v_wcs_srgv_queue_finish`, `v_device_srgv`, `v_wcs_srgv_map`, `v_bas_cargo_space`, `v_tsj_execution`, and `v_current_database_clock`. +- Evidence tables: `shadow_run`, `shadow_cycle`, `shadow_input_batch`, `shadow_legacy_queue`, `shadow_decision`, `shadow_encoded_write`, `shadow_difference`, `shadow_traffic_state`, `shadow_hold_history`, and `shadow_startup_check`. + +- [ ] **Step 1: Write dual-source and permission-probe tests** + +```java +@Test +void sourceAndEvidenceConnectionsCannotResolveToTheSameDatabase() { + StartupFailure failure = assertThrows(StartupFailure.class, + () -> gate.verify(sourceMeta("prod-host", "FT546-wcs", "shadow_ro"), + evidenceMeta("prod-host", "FT546-wcs", "shadow_evidence"))); + assertEquals("SOURCE_AND_EVIDENCE_DATABASE_MUST_DIFFER", + failure.getReasonCode()); +} + +@Test +void sourceProbeRejectsAnyProductionWritePermission() { + PermissionCheckResult result = probe.verifySource(connectionWith( + "SELECT=1;INSERT=0;UPDATE=1;DELETE=0;CONTROL=0")); + + assertFalse(result.isAccepted()); + assertEquals("SOURCE_HAS_UPDATE_PERMISSION", result.getReasonCode()); +} +``` + +The source connection test executes `SET TRANSACTION ISOLATION LEVEL READ COMMITTED`, calls `Connection.setReadOnly(true)`, and verifies permissions only through `HAS_PERMS_BY_NAME`, `fn_my_permissions`, database role membership, and JDBC metadata. The startup probe performs no DML of any kind, including temporary or probe-table writes. + +- [ ] **Step 2: Run tests and verify current dynamic data-source path cannot satisfy them** + +Run: + +```bash +mvn -pl ruoyi-wcs-shadow -am -Dtest=ShadowDataSourceIsolationTest,SqlServerPermissionProbeTest test +``` + +Expected: missing explicit data-source beans/probe classes. + +- [ ] **Step 3: Implement explicit pools and fail-closed startup checks** + +```java +@Bean("shadowSourceDataSource") +DataSource source(ShadowProperties properties) { + HikariConfig config = base(properties.getSource()); + config.setReadOnly(true); + config.setMaximumPoolSize(2); + config.setMinimumIdle(0); + config.setConnectionTimeout(2000L); + return new HikariDataSource(config); +} + +@Bean("shadowSourceTx") +DataSourceTransactionManager sourceTx( + @Qualifier("shadowSourceDataSource") DataSource source) { + DataSourceTransactionManager manager = new DataSourceTransactionManager(source); + manager.setEnforceReadOnly(true); + return manager; +} +``` + +Do not modify `DataSourceType`, `DruidConfig`, or `DynamicDataSourceContextHolder`; the shadow process does not load them. `ShadowStartupGate` compares JDBC URL host/database and login names, persists every check to the evidence database, and keeps the scheduler disabled unless all checks pass. + +- [ ] **Step 4: Add additive SQL objects and least-privilege verification** + +`V001` creates only `shadow_ro` views selecting the exact existing columns needed from `wcs_srgv_queue`, `wcs_srgv_queue_finish`, `device_srgv`, `wcs_srgv_map`, `bas_cargo_space`, and `tsj_device_execution_queue`. `V002` creates a login/user template, denies write/control permissions, and grants `SELECT` only on `shadow_ro`; site credentials remain deployment secrets and are not committed. `V003` creates evidence tables in a separate database. The verify script returns one row per expected grant/deny and fails if the source user can write any approved base table. + +- [ ] **Step 5: Run automated tests; run SQL verification only against an approved isolated SQL Server** + +Run locally: + +```bash +mvn -pl ruoyi-wcs-shadow -am -Dtest=ShadowDataSourceIsolationTest,SqlServerPermissionProbeTest test +``` + +Run in the approved SQL Server integration environment: + +```powershell +sqlcmd -S $env:SHADOW_SQLSERVER -d FT546-wcs -E -b -i sql/shadow/verify_ft546_shadow_permissions.sql +``` + +Expected: local tests pass. SQL verification reports source `SELECT` on views, zero source business-table write permissions, and evidence write permission only in the distinct evidence database. If no isolated SQL Server is available, record this gate as `NOT_RUN`; do not claim site readiness. + +- [ ] **Step 6: Commit data isolation and additive SQL** + +```bash +git add ruoyi-wcs-shadow sql/shadow +git commit -m "feat(ft546): enforce shadow database isolation" +``` + +--- + +### Task 5: Collect One Immutable, Bounded Production Fact Batch + +**Files:** +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowFactBatch.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowQueueFact.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowVehicleFact.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowMapFact.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowLegacyQueueResult.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/LegacyQueueProtocolProjector.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowFactValidation.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/SqlServerShadowFactReader.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowFactBatchValidator.java` +- Create: `ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/facts/SqlServerShadowFactReaderTest.java` +- Create: `ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/facts/ShadowFactBatchValidatorTest.java` + +**Interfaces:** +- Produces: `ShadowFactBatch SqlServerShadowFactReader.read(ShadowReadCursor cursor, Instant deadline)`. +- Produces: `ShadowFactValidation validate(ShadowFactBatch, Instant now)`. +- Produces: `LegacyQueueProtocolProjector.project(ShadowQueueFact) -> ShadowLegacyQueueResult` using captured legacy queue fields only; it does not call the shared new encoder. +- Cursor: `(lastCreateDate, lastGuid)` with a 5-second overlap; duplicate rows are retained as source evidence but deduplicated by `(tableName, guid, contentHash)` for kernel input. +- Hard limits: 500 queue rows, 100 vehicle rows, 10,000 map rows on startup refresh, 3-second JDBC query timeout, and 4-second whole-batch deadline. + +- [ ] **Step 1: Write immutable batch, ordering, and stale-fact tests** + +```java +@Test +void readsQueueVehicleLegacyResultAndDatabaseClockInOneReadOnlyTransaction() { + ShadowFactBatch batch = reader.read(cursor, deadline); + + assertEquals(Arrays.asList("Q-1", "Q-2"), guids(batch.getQueues())); + assertEquals("070502", batch.vehicle("1").getCurrentSite()); + assertEquals("4004,4000,4002,4021", + projector.project(batch.queue("Q-1")).getWriteAddresses()); + assertTrue(batch.getDatabaseCapturedAt().isBefore(deadline)); +} + +@Test +void staleOrContradictoryFactsAreNotComparable() { + ShadowFactValidation validation = validator.validate( + batchWithVehicleAtTwoSites(), NOW); + + assertEquals(INSUFFICIENT_FACTS, validation.getStatus()); + assertEquals("CONTRADICTORY_VEHICLE_POSITION:1", + validation.getReasonCode()); +} +``` + +Add tests for query timeout, row-limit overflow, non-monotonic database clock, missing map site, missing vehicle for legacy assignment, queue mutation during the overlap window, source outage backoff, ordinary/cross-floor/two-stage legacy projection, and `LEGACY_RESULT_UNAVAILABLE` when the captured queue lacks the fields needed to reconstruct old register values. + +- [ ] **Step 2: Run fact tests and verify the reader/validator are missing** + +Run: + +```bash +mvn -pl ruoyi-wcs-shadow -am -Dtest=SqlServerShadowFactReaderTest,ShadowFactBatchValidatorTest test +``` + +Expected: missing fact types/readers. + +- [ ] **Step 3: Implement bounded read-only SQL with deterministic ordering** + +```sql +SELECT TOP (?) guid, task_no, task_type, priority, cargo_pick_site, + cargo_place_site, start_site, end_site, create_date, agv_id, + path_info, default02, default03, default04, default05, default06, + default07, default08 +FROM shadow_ro.v_wcs_srgv_queue +WHERE create_date > ? OR (create_date = ? AND guid > ?) +ORDER BY create_date, guid; +``` + +Read all component queries inside `shadowSourceTx` with `readOnly=true`. Capture the database clock before and after; accept a batch only when the span is within the configured freshness window and every required object is present. `LegacyQueueProtocolProjector` independently reconstructs the old expected queue/PLC result from captured `agv_id/path_info/task_type/default*` fields and the frozen legacy contract; it must not invoke `Ft546SrgvCommandEncoder`. Keep all legacy result fields out of new-kernel candidate selection inputs. When legacy values are not reconstructable, persist `LEGACY_RESULT_UNAVAILABLE` and skip protocol equality rather than declaring a match. + +- [ ] **Step 4: Add load-shedding and outage behavior** + +One `AtomicBoolean` prevents overlapping reads. On timeout/row overflow, persist a failed cycle, discard the partial batch, and back off `1s -> 2s -> 4s -> 8s -> 30s`. A successful complete batch resets the backoff. The reader never retries inside the same cycle. + +- [ ] **Step 5: Run focused tests** + +Run: + +```bash +mvn -pl ruoyi-wcs-shadow -am -Dtest=SqlServerShadowFactReaderTest,ShadowFactBatchValidatorTest test +``` + +Expected: deterministic ordering, immutable data, stale/contradictory rejection, query limits, and backoff pass. + +- [ ] **Step 6: Commit the fact boundary** + +```bash +git add ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts \ + ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/facts +git commit -m "feat(ft546): collect bounded read-only shadow facts" +``` + +--- + +### Task 6: Run The Shared Dispatch Kernel Without Command Outbox Or Side Effects + +**Files:** +- Create: `ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/ShadowDispatchResult.java` +- Create: `ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/ReadOnlyDispatchCycle.java` +- Create: `ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/ReadOnlyDispatchCycleTest.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowDispatchSnapshotAssembler.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCommandIntentFactory.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/RecordingFt546CommandGateway.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/JdbcShadowTrafficStateRepository.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowCycle.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCycleResult.java` +- Create: `ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/Ft546ShadowCycleTest.java` +- Modify: `ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/WcsRuntimeCoordinatorTest.java` + +**Interfaces:** +- Produces: `ReadOnlyDispatchCycle.run(DispatchSnapshot) -> ShadowDispatchResult`. +- Produces: `ShadowDispatchSnapshotAssembler.assemble(ShadowFactBatch) -> DispatchSnapshot`. +- Produces: `ShadowCommandIntentFactory.create(GrantedDispatch) -> Ft546SrgvCommandIntent`. +- Produces: `RecordingFt546CommandGateway.record(Ft546SrgvCommandIntent) -> Ft546EncodedCommand`. +- Produces: `JdbcShadowTrafficStateRepository.commit(DispatchCommit)` in the evidence database with optimistic `snapshotVersion`; reservations, wait-for edges, and HOLD history survive process restart. +- Preserves: `WcsRuntimeCoordinator.tick()` production order and Outbox behavior unchanged. + +- [ ] **Step 1: Write a same-kernel/no-Outbox test** + +```java +@Test +void readOnlyCycleUsesDispatchCoordinatorButNeverCreatesOrSendsACommand() { + List calls = new ArrayList(); + ReadOnlyDispatchCycle cycle = new ReadOnlyDispatchCycle( + snapshot -> { + calls.add("dispatch"); + return committedGrant(); + }, + grant -> calls.add("intent:" + grant.getVehicleId())); + + ShadowDispatchResult result = cycle.run(snapshot()); + + assertEquals(Arrays.asList("dispatch", "intent:1"), calls); + assertEquals(1, result.getIntents().size()); +} +``` + +The integration test injects throwing fakes for every `DeviceCommandRepository`, `CommandOutboxRepository`, `DeviceCommandGateway`, and `WmsNotificationGateway`; a successful shadow cycle proves none is called. + +- [ ] **Step 2: Run the tests and verify the read-only cycle does not exist** + +Run: + +```bash +mvn -pl ruoyi-wcs,ruoyi-wcs-shadow -am \ + -Dtest=ReadOnlyDispatchCycleTest,Ft546ShadowCycleTest,WcsRuntimeCoordinatorTest test +``` + +Expected: missing cycle/assembler/recorder types. + +- [ ] **Step 3: Extract the pure dispatch use without changing production tick** + +```java +public ShadowDispatchResult run(DispatchSnapshot snapshot) { + DispatchCycleResult decisions = dispatcher.apply(snapshot); + List intents = new ArrayList<>(); + for (GrantedDispatch grant : decisions.getGranted()) { + intents.add(intentFactory.apply(grant)); + } + return new ShadowDispatchResult(decisions, intents); +} +``` + +`Ft546ShadowCycle` validates facts first. For valid facts it assembles the same `DispatchSnapshot`/`DispatchTask`/`VehicleSnapshot`/`EffectiveTopology` values used by `DispatchCoordinator`, invokes `ReadOnlyDispatchCycle`, encodes every intent through `Ft546SrgvCommandEncoder`, and sends all evidence to one evidence-database transaction. It does not instantiate `DeviceExecutionService`, `CommandOutboxDispatcher`, `WmsNotificationOutboxDispatcher`, or a production lease/fencing writer. `JdbcShadowTrafficStateRepository` stores the resulting ledger, wait-for graph, consecutive HOLD counters, and snapshot version only in the evidence database; restart reload tests must prove avoidance/HOLD state is not reset. + +- [ ] **Step 4: Prove phase parameters include ordinary, cross-floor, and two-stage semantics** + +Add fixture cases whose `GrantedDispatch.commandParameters` include: + +```text +parentTaskId=PARENT-1 +phase=LOADED_TO_PLACE +routeVersion=3 +taskNo=30001 +taskType=1|8 +crossFloorTarget=8 (only when taskType=8) +stage=1|2 (only for the matching two-stage phase) +``` + +Assert the encoder output equals the legacy sequence fixture and the vehicle/path may differ without changing protocol classification. + +- [ ] **Step 5: Run kernel, protocol, and shadow cycle tests** + +Run: + +```bash +mvn -pl ruoyi-wcs,ruoyi-ft546-protocol,ruoyi-wcs-shadow -am \ + -Dtest=ReadOnlyDispatchCycleTest,WcsRuntimeCoordinatorTest,Ft546SrgvCommandEncoderTest,Ft546ShadowCycleTest test +``` + +Expected: shared dispatch policy and encoder run; all side-effect fakes remain uncalled; invalid facts produce no intent. + +- [ ] **Step 6: Commit the same-kernel shadow cycle** + +```bash +git add ruoyi-wcs ruoyi-wcs-shadow +git commit -m "feat(ft546): run shared dispatch kernel in shadow" +``` + +--- + +### Task 7: Persist Auditable Differences And Prove Idempotent Collection + +**Files:** +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowEvidenceRepository.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepository.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDifference.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDifferenceCategory.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowComparisonService.java` +- Create: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowEvidenceDigest.java` +- Create: `ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/ShadowComparisonServiceTest.java` +- Create: `ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepositoryTest.java` +- Modify: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowCycle.java` +- Modify: `ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/web/ShadowStatusController.java` + +**Interfaces:** +- Categories: `MATCH`, `PROTOCOL_ADDRESS`, `PROTOCOL_DATA_TYPE`, `PROTOCOL_VALUE`, `PROTOCOL_ORDER`, `QUEUE_FIELD`, `ALGORITHM_VEHICLE`, `ALGORITHM_ROUTE`, `ALGORITHM_HOLD`, `INSUFFICIENT_FACTS`, and `SOURCE_GAP`. +- Idempotency: unique `(run_id, input_batch_hash, parent_task_id, phase, route_version)`. +- Produces: `ShadowComparisonService.compare(ShadowLegacyQueueResult, ShadowDecision, Ft546EncodedCommand)`. +- Produces: daily digest counts and reproducible evidence hash. + +- [ ] **Step 1: Write classification and idempotency tests** + +```java +@Test +void differentVehicleWithMatchingWritesIsAlgorithmOnly() { + List differences = comparison.compare( + legacy("1", path(70501, 70502), mainWrites()), + decision("2", path(70501, 70502)), encoded(mainWrites())); + + assertEquals(singletonList(ALGORITHM_VEHICLE), categories(differences)); +} + +@Test +void changedWriteOrderIsProtocolOrderEvenWhenValuesMatch() { + assertEquals(PROTOCOL_ORDER, singleDifference( + legacy(mainWrites()), encoded(swappedTaskNoAndType())).getCategory()); +} + +@Test +void replayingTheSameBatchDoesNotDuplicateEvidence() { + repository.save(cycle); + repository.save(cycle); + + assertEquals(1, repository.countCycles()); + assertEquals(cycle.getEvidenceHash(), repository.findCycle( + cycle.getRunId(), cycle.getInputBatchHash(), + cycle.getParentTaskId(), cycle.getPhase(), + cycle.getRouteVersion()).getEvidenceHash()); +} +``` + +- [ ] **Step 2: Run evidence tests and verify repository/comparison types are missing** + +Run: + +```bash +mvn -pl ruoyi-wcs-shadow -am \ + -Dtest=ShadowComparisonServiceTest,JdbcShadowEvidenceRepositoryTest test +``` + +Expected: missing evidence types. + +- [ ] **Step 3: Implement one evidence transaction per complete cycle** + +Persist immutable JSON plus indexed columns. Hash canonical UTF-8 JSON for the input batch, legacy result, decision, encoded writes, and differences. If the unique key already exists, verify the stored hash matches and return the existing cycle; a hash mismatch is `EVIDENCE_KEY_COLLISION` and stops collection. Never partially commit a cycle. + +- [ ] **Step 4: Add storage-pressure fail-safe** + +Before every cycle check configured minimum free bytes and evidence database health. When the threshold is crossed, change status to `DEGRADED:EVIDENCE_STORAGE_UNAVAILABLE`, stop new reads/calculations, retain the last complete evidence, and keep the stable WCS unaffected. Do not discard differences and continue reporting healthy. + +- [ ] **Step 5: Run comparison, repository, and cycle tests** + +Run: + +```bash +mvn -pl ruoyi-wcs-shadow -am \ + -Dtest=ShadowComparisonServiceTest,JdbcShadowEvidenceRepositoryTest,Ft546ShadowCycleTest test +``` + +Expected: exact classification, idempotent replay, hash verification, atomic commit, and disk/db fail-safe pass. + +- [ ] **Step 6: Commit the evidence chain** + +```bash +git add ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence \ + ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence \ + ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel \ + ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/web +git commit -m "feat(ft546): persist shadow comparison evidence" +``` + +--- + +### Task 8: Verify End-To-End Shadow Operation And Prepare Immediate Stable Rollback + +**Files:** +- Create: `ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowEndToEndTest.java` +- Create: `ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowFaultIsolationTest.java` +- Create: `ruoyi-wcs-shadow/src/test/resources/fixtures/ordinary-cycle.json` +- Create: `ruoyi-wcs-shadow/src/test/resources/fixtures/cross-floor-cycle.json` +- Create: `ruoyi-wcs-shadow/src/test/resources/fixtures/two-stage-cycle.json` +- Create: `ruoyi-wcs-shadow/src/test/resources/fixtures/hold-avoidance-cycle.json` +- Create: `ruoyi-wcs-shadow/src/test/resources/application-shadow-local.yml` +- Create: `tools/shadow/Start-Ft546Shadow.ps1` +- Create: `tools/shadow/Stop-Ft546Shadow.ps1` +- Create: `tools/shadow/Test-Ft546ShadowPreflight.ps1` +- Create: `tools/shadow/Export-Ft546ShadowEvidence.ps1` +- Create: `tools/shadow/Capture-StableWcsManifest.ps1` +- Create: `docs/runbooks/ft546-realtime-shadow-runbook.md` +- Modify: `README.md` + +**Interfaces:** +- Preflight output: stable PID/command line/artifact SHA-256/config SHA-256/listening ports; shadow artifact/config SHA-256; source/evidence JDBC identities; permission checks; PLC/WMS network-deny checks; output free space. +- Start command refuses to run without `-ConfirmReadOnlyShadow`, a successful preflight JSON, and a distinct shadow deployment directory. +- Stop command stops only the recorded shadow PID and never finds/kills a process by broad Java name. +- Evidence export emits `manifest.json`, `manifest.sha256`, cycle CSV/JSON, protocol-difference CSV, algorithm-difference CSV, insufficient-facts CSV, and startup checks. + +- [ ] **Step 1: Write end-to-end and fault-isolation tests first** + +```java +@Test +void ordinaryCrossFloorAndTwoStageFixturesReachVirtualPlcEvidence() { + ShadowRunResult result = harness.run(fixtures()); + + assertEquals(3, result.getComparableCycles()); + assertWrites(result.forTask("ORDINARY"), "4004", "4000", + "4002", "4021"); + assertWrites(result.forTask("CROSS_FLOOR"), "4004", "4000", + "4002", "4021", "4020", "4022"); + assertWrites(result.forTask("STAGE_2"), "4404", "4422", + "4423", "4402", "4400"); + assertEquals("HOLD", result.forTask("AVOIDANCE").getDecision()); + assertTrue(result.forTask("AVOIDANCE").getEncodedWrites().isEmpty()); + assertEquals(0, result.getSideEffectCalls()); +} + +@Test +void sourceOutageEvidenceFailureAndShadowCrashNeverAffectStableProbe() { + faultHarness.inject(SOURCE_TIMEOUT, EVIDENCE_DISK_FULL, SHADOW_CRASH); + + assertTrue(stableProbe.isContinuouslyHealthy()); + assertEquals(0, sideEffectProbe.totalCalls()); +} +``` + +Add a package/socket test proving the shadow process has no configured PLC IP/port or WMS URL, a process test proving stop/restart changes only the shadow PID, and a restart test proving persisted wait-for edges/consecutive HOLD counters are reloaded so the third identical HOLD follows the configured reroute rule instead of resetting to the first HOLD. + +- [ ] **Step 2: Run end-to-end tests and verify scripts/runbook are missing** + +Run: + +```bash +mvn -pl ruoyi-ft546-protocol,ruoyi-wcs,ruoyi-wcs-shadow,ruoyi-admin -am \ + -Dtest=Ft546ShadowEndToEndTest,Ft546ShadowFaultIsolationTest,Ft546StandardRuntimeTest,Ft546LegacySrgvAdapterTest test +``` + +Expected: missing fixtures/harness or end-to-end program. + +- [ ] **Step 3: Implement safe operational scripts** + +`Capture-StableWcsManifest.ps1` records the actual deployed stable artifact path, SHA-256, command line, configuration paths/hashes, service name/PID, and listening ports; it does not assume `origin/main` is the deployed version. `Test-Ft546ShadowPreflight.ps1` verifies the shadow source login, separate evidence database, permissions, distinct PID/ports/directories, loopback status endpoint, PLC/WMS network denial, disk threshold, and stable health before start. `Start-Ft546Shadow.ps1` starts only the shadow jar with a PID file in its own directory. `Stop-Ft546Shadow.ps1` resolves and validates that exact PID and executable path before stopping it. + +- [ ] **Step 4: Write the site runbook with explicit stop conditions** + +The runbook sequence is: + +```text +capture stable manifest and verify stable health + -> create/read-only source login and separate evidence database + -> deny shadow host PLC/WMS egress at network layer + -> run preflight and archive JSON + -> start shadow only + -> verify stable PID/health/PLC writer unchanged + -> collect 15 minutes at low polling rate + -> inspect source query latency and evidence growth + -> extend collection window only when stable metrics are unchanged + -> stop shadow immediately on any stable degradation + -> export evidence and verify manifest hashes +``` + +Immediate stop conditions include production DB latency increase above the agreed threshold, permission-probe change, evidence disk threshold, source lag beyond freshness, any PLC/WMS connection attempt, any production-table write audit, shadow CPU/memory limit breach, or stable WCS health change. Stopping/removing the shadow service is the rollback for this phase; the stable WCS is never stopped or switched. + +- [ ] **Step 5: Run all automated verification from a clean build** + +Run: + +```bash +mvn clean test +``` + +Expected: all modules pass with zero failures/errors; record exact test count and duration. + +- [ ] **Step 6: Run local process smoke without production endpoints** + +Run: + +```powershell +.\tools\shadow\Test-Ft546ShadowPreflight.ps1 ` + -Config .\ruoyi-wcs-shadow\src\test\resources\application-shadow-local.yml +.\tools\shadow\Start-Ft546Shadow.ps1 ` + -ConfirmReadOnlyShadow ` + -PreflightJson .\outputs\shadow-preflight-local.json +Invoke-RestMethod http://127.0.0.1:18091/shadow/status +.\tools\shadow\Stop-Ft546Shadow.ps1 +``` + +Expected: local H2/test-view mode reaches `READY`, generates evidence, makes zero external calls, and stops only its PID. + +- [ ] **Step 7: Run SQL Server/HIL/site gates only in approved environments** + +Required evidence before site collection: + +- SQL Server integration: exact transaction/permission/query-timeout behavior passes against an isolated SQL Server. +- Network: shadow host cannot reach PLC write endpoints or production WMS callback endpoints. +- Site preflight: stable manifest captured, stable process healthy, source login read-only, evidence database distinct, and shadow directories/ports distinct. +- Site observation: stable process metrics and PLC ownership stay unchanged while shadow is started/stopped repeatedly. + +HIL remains a later gate for real PLC timing and partial-write behavior; this shadow program does not connect to HIL or production PLC. If any gate is missing, the conclusion is limited to local or SQL integration readiness. + +- [ ] **Step 8: Run repository checks and request review** + +Run: + +```bash +git diff --check +git status --short +git log --oneline -15 +``` + +Reviewers receive the confirmed design, this plan, base `48b0b154b6319de05df3b71b4457d12110bdb095`, stable tracking reference `652607ce15653f12a682248ad3f335861ebd9e3f`, final HEAD, Maven evidence, preflight output, SQL permission output, process start/stop evidence, and exported manifest. Resolve all Critical/Important findings and rerun focused plus full tests. + +- [ ] **Step 9: Commit the runbook and acceptance harness** + +```bash +git add ruoyi-wcs-shadow/src/test tools/shadow \ + docs/runbooks/ft546-realtime-shadow-runbook.md README.md +git commit -m "test(ft546): verify read-only shadow operation" +``` + +--- + +## End-To-End Acceptance Matrix + +| Layer | Required proof | Permitted conclusion | +| --- | --- | --- | +| Unit/domain | Runtime guards, fact validation, same-kernel decision, pure PLC encoding, difference classification | Local implementation behavior only | +| Spring context | No side-effect beans/types, separate data sources, fail-closed startup | Process wiring is isolated | +| Local E2E | Ordinary/cross-floor/two-stage reach virtual PLC evidence; zero side-effect calls | Local shadow flow works | +| SQL Server integration | Read-only permissions, transactions, timeouts, separate evidence writes, restart/idempotency | Database integration works | +| Site preflight | Stable artifact/PID/config captured; network deny; accounts/ports/directories distinct | Safe to start collection window | +| Live shadow | Stable health unchanged, source query load bounded, evidence continuous, zero production side effects | Shadow observations are valid | +| HIL | Real isolated PLC register sequence, disconnect, partial write, restart | Protocol adapter hardware gate only | +| Cutover drill | Single-writer lease/fencing, recovery, reconciliation, rollback | Future `STANDARD` cutover gate; outside this plan | + +## Plan Self-Review Checklist + +- Design coverage: mode isolation (Task 1), pure PLC intent (Task 2), independent launcher (Task 3), two-data-source permission gate (Task 4), read-only facts (Task 5), same-kernel cycle (Task 6), differences/evidence (Task 7), E2E/site protection (Task 8). +- Stable WCS protection is independent at code, process, database, network, operations, and test layers; one configuration mistake is not the sole barrier. +- `LEGACY` remains default and unchanged; no task authorizes production `STANDARD`. +- `SHADOW` never uses current PLC readers as its source because those readers can connect/write and mutate production device tables. +- The real adapter and shadow recorder share one pure encoder, so address/type/value/order comparisons do not rely on duplicated protocol code. +- Production source and evidence data sources are explicit and physically distinct; no ThreadLocal routing or transaction-time switching is used. +- Every partial/invalid fact path closes as `INSUFFICIENT_FACTS`; no fabricated comparison is emitted. +- Protocol and algorithm differences have distinct enums and tests. +- Ordinary, cross-floor, two-stage, HOLD/avoidance inputs, source outage, evidence failure, restart, and stable-process isolation all have named automated tests. +- SQL Server, site, network, and HIL evidence boundaries are explicit; local tests cannot be reported as production acceptance. +- Every task contains concrete files, interfaces, red/green commands, expected results, and explicit failure behavior; no deferred implementation placeholder remains. diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546WcsProperties.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546WcsProperties.java index 5ba1342..643196e 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546WcsProperties.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546WcsProperties.java @@ -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; diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/runtime/Ft546AdminRuntimeModeEnvironmentPostProcessor.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/runtime/Ft546AdminRuntimeModeEnvironmentPostProcessor.java new file mode 100644 index 0000000..55da927 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/runtime/Ft546AdminRuntimeModeEnvironmentPostProcessor.java @@ -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; + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/schedule/ScheduleThread.java b/ruoyi-admin/src/main/java/com/ruoyi/schedule/ScheduleThread.java index bd9eb50..521d7d3 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/schedule/ScheduleThread.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/schedule/ScheduleThread.java @@ -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 getScheduleStatus() { - HashMap map = new HashMap<>(); + public HashMap getScheduleStatus() { + HashMap map = new HashMap(); map.put("isRun",isRun); map.put("isRun2",isRun2); return map; } + public HashMap getScheduleStatusDetails() { + RuntimeCapabilities capabilities = capabilities(); + HashMap map = new LinkedHashMap(); + 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; + } } } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/schedule/ThreadOperationController.java b/ruoyi-admin/src/main/java/com/ruoyi/schedule/ThreadOperationController.java index dc04fb4..c175c45 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/schedule/ThreadOperationController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/schedule/ThreadOperationController.java @@ -67,6 +67,6 @@ public class ThreadOperationController { */ @RequestMapping("/getScheduleStatus") public AjaxResult getScheduleStatus() { - return AjaxResult.success(scheduleThread.getScheduleStatus()); + return AjaxResult.success(scheduleThread.getScheduleStatusDetails()); } } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/schedule/scheduleTask/srgv/BaseSRgvThread.java b/ruoyi-admin/src/main/java/com/ruoyi/schedule/scheduleTask/srgv/BaseSRgvThread.java index e815b77..2c8fe31 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/schedule/scheduleTask/srgv/BaseSRgvThread.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/schedule/scheduleTask/srgv/BaseSRgvThread.java @@ -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(); } /** diff --git a/ruoyi-admin/src/main/resources/META-INF/spring.factories b/ruoyi-admin/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..2089683 --- /dev/null +++ b/ruoyi-admin/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ +com.ruoyi.project.ft546.runtime.Ft546AdminRuntimeModeEnvironmentPostProcessor diff --git a/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/runtime/Ft546ShadowIsolationTest.java b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/runtime/Ft546ShadowIsolationTest.java new file mode 100644 index 0000000..9094c6c --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/runtime/Ft546ShadowIsolationTest.java @@ -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", + 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.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(); + } + } +}