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

@@ -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=<target>`; two-stage one `4404 -> 4422=1 -> 4402=<taskType> -> 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<String>) -> 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<String> calls = new ArrayList<String>();
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<Ft546SrgvCommandIntent> 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<ShadowDifference> 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.

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