fix(ft546): isolate shadow from legacy and PLC threads
This commit is contained in:
@@ -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.
|
||||||
@@ -9,7 +9,11 @@ public class Ft546WcsProperties {
|
|||||||
public enum RuntimeMode {
|
public enum RuntimeMode {
|
||||||
LEGACY,
|
LEGACY,
|
||||||
SHADOW,
|
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;
|
private RuntimeMode runtimeMode = RuntimeMode.LEGACY;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ import org.springframework.stereotype.Component;
|
|||||||
import javax.annotation.PostConstruct;
|
import javax.annotation.PostConstruct;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
@@ -76,6 +77,8 @@ public class ScheduleThread {
|
|||||||
private volatile boolean isRun2 = false;
|
private volatile boolean isRun2 = false;
|
||||||
|
|
||||||
private static final long THREAD_STOP_TIMEOUT_MS = 5000L;
|
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
|
@PostConstruct
|
||||||
public void init() {
|
public void init() {
|
||||||
log.info("初始化调度服务");
|
log.info("初始化调度服务");
|
||||||
//读取信息初始化
|
RuntimeCapabilities capabilities = capabilities();
|
||||||
plcReadInit();
|
if (capabilities.isPlcFactReadersAllowed()) {
|
||||||
//调度初始化
|
plcReadInit();
|
||||||
|
}
|
||||||
|
if (capabilities.isLegacyBusinessThreadsAllowed()) {
|
||||||
scheduleInit();
|
scheduleInit();
|
||||||
|
}
|
||||||
log.info("调度服务执行成功");
|
log.info("调度服务执行成功");
|
||||||
// startAllThread();
|
// startAllThread();
|
||||||
// startAllThread2();
|
// startAllThread2();
|
||||||
@@ -97,7 +101,7 @@ public class ScheduleThread {
|
|||||||
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
public void startStandardFactReadersAfterApplicationReady() {
|
public void startStandardFactReadersAfterApplicationReady() {
|
||||||
if (isStandardMode()) {
|
if (capabilities().isAutomaticFactReaderStartAllowed()) {
|
||||||
startAllThread();
|
startAllThread();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +110,7 @@ public class ScheduleThread {
|
|||||||
* 调度初始化
|
* 调度初始化
|
||||||
*/
|
*/
|
||||||
private void scheduleInit() {
|
private void scheduleInit() {
|
||||||
if (isStandardMode()) {
|
if (!runtimeMode().isLegacy()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
MonitoringThread monitoringThread = new MonitoringThread();
|
MonitoringThread monitoringThread = new MonitoringThread();
|
||||||
@@ -190,6 +194,7 @@ public class ScheduleThread {
|
|||||||
* 开始线程
|
* 开始线程
|
||||||
*/
|
*/
|
||||||
public synchronized void startAllThread() {
|
public synchronized void startAllThread() {
|
||||||
|
requirePlcFactReadersAllowed();
|
||||||
if (isRun) {
|
if (isRun) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -211,10 +216,7 @@ public class ScheduleThread {
|
|||||||
* 开启线程2
|
* 开启线程2
|
||||||
*/
|
*/
|
||||||
public void startAllThread2() {
|
public void startAllThread2() {
|
||||||
if (isStandardMode()) {
|
requireLegacyBusinessThreadsAllowed();
|
||||||
throw new IllegalStateException(
|
|
||||||
"legacy business scheduler is disabled in STANDARD mode");
|
|
||||||
}
|
|
||||||
monitoringCustomThread.thread.start();
|
monitoringCustomThread.thread.start();
|
||||||
ssjThreadCustomThread.thread.start();
|
ssjThreadCustomThread.thread.start();
|
||||||
tsjThreadCustomThread.thread.start();
|
tsjThreadCustomThread.thread.start();
|
||||||
@@ -228,6 +230,7 @@ public class ScheduleThread {
|
|||||||
* 停止线程
|
* 停止线程
|
||||||
*/
|
*/
|
||||||
public synchronized void stopAllThread() {
|
public synchronized void stopAllThread() {
|
||||||
|
requirePlcFactReadersAllowed();
|
||||||
if (!isRun) {
|
if (!isRun) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -341,6 +344,9 @@ public class ScheduleThread {
|
|||||||
* 停止线程2
|
* 停止线程2
|
||||||
*/
|
*/
|
||||||
public void stopAllThread2() {
|
public void stopAllThread2() {
|
||||||
|
if (runtimeMode().isShadow()) {
|
||||||
|
throw new IllegalStateException(SHADOW_THREAD_REJECTION);
|
||||||
|
}
|
||||||
if (isStandardMode()) {
|
if (isStandardMode()) {
|
||||||
isRun2 = false;
|
isRun2 = false;
|
||||||
return;
|
return;
|
||||||
@@ -359,16 +365,84 @@ public class ScheduleThread {
|
|||||||
/**
|
/**
|
||||||
* 获取调度状态
|
* 获取调度状态
|
||||||
*/
|
*/
|
||||||
public HashMap<String,Boolean> getScheduleStatus() {
|
public HashMap<String, Boolean> getScheduleStatus() {
|
||||||
HashMap<String,Boolean> map = new HashMap<>();
|
HashMap<String, Boolean> map = new HashMap<String, Boolean>();
|
||||||
map.put("isRun",isRun);
|
map.put("isRun",isRun);
|
||||||
map.put("isRun2",isRun2);
|
map.put("isRun2",isRun2);
|
||||||
return map;
|
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() {
|
private boolean isStandardMode() {
|
||||||
return ft546WcsProperties != null
|
return runtimeMode().isStandard();
|
||||||
&& ft546WcsProperties.getRuntimeMode()
|
}
|
||||||
== Ft546WcsProperties.RuntimeMode.STANDARD;
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,6 @@ public class ThreadOperationController {
|
|||||||
*/
|
*/
|
||||||
@RequestMapping("/getScheduleStatus")
|
@RequestMapping("/getScheduleStatus")
|
||||||
public AjaxResult getScheduleStatus() {
|
public AjaxResult getScheduleStatus() {
|
||||||
return AjaxResult.success(scheduleThread.getScheduleStatus());
|
return AjaxResult.success(scheduleThread.getScheduleStatusDetails());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ public class BaseSRgvThread implements Runnable, SRgvThreadInterface {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void run() {
|
public void run() {
|
||||||
if (isStandardRuntime()) {
|
if (!isLegacyRuntime()) {
|
||||||
log.warn("STANDARD mode owns scheduling; legacy SRGV thread will not run");
|
log.warn("Non-LEGACY mode owns scheduling; legacy SRGV thread will not run");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,11 +141,10 @@ public class BaseSRgvThread implements Runnable, SRgvThreadInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isStandardRuntime() {
|
private boolean isLegacyRuntime() {
|
||||||
Ft546WcsProperties properties =
|
Ft546WcsProperties properties =
|
||||||
SpringUtils.getBean(Ft546WcsProperties.class);
|
SpringUtils.getBean(Ft546WcsProperties.class);
|
||||||
return properties.getRuntimeMode()
|
return properties.getRuntimeMode().isLegacy();
|
||||||
== Ft546WcsProperties.RuntimeMode.STANDARD;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
2
ruoyi-admin/src/main/resources/META-INF/spring.factories
Normal file
2
ruoyi-admin/src/main/resources/META-INF/spring.factories
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
org.springframework.boot.env.EnvironmentPostProcessor=\
|
||||||
|
com.ruoyi.project.ft546.runtime.Ft546AdminRuntimeModeEnvironmentPostProcessor
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user