feat(javaTest): expose local dispatch visualizer API

This commit is contained in:
zgw
2026-08-05 15:16:52 +08:00
parent a0453d8b22
commit f80f92dca3
5 changed files with 488 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
package com.ft546.wcs.shadow.history.visualizer;
import com.ft546.wcs.shadow.history.HistoricalQueueDataset;
import com.ft546.wcs.shadow.history.HistoricalQueueLoader;
import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInput;
import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInputFactory;
import java.nio.file.Paths;
import java.util.concurrent.CountDownLatch;
/** 启动只监听本机的调度过程可视化审计台。 */
public final class HistoricalDispatchVisualizerApplication {
private HistoricalDispatchVisualizerApplication() { }
public static void main(String[] args) throws Exception {
if (args.length > 1) {
throw new IllegalArgumentException(
"usage: HistoricalDispatchVisualizerApplication [port]");
}
int port = args.length == 0 ? 18084 : Integer.parseInt(args[0]);
ClassLoader classLoader = HistoricalDispatchVisualizerApplication.class.getClassLoader();
HistoricalQueueLoader loader = new HistoricalQueueLoader();
HistoricalQueueDataset dataset = classLoader.getResource("shadow/history/manifest.json") != null
? loader.load(classLoader, "shadow/history")
: loader.load(Paths.get("src/test/resources/shadow/history"));
HistoricalSimulationInput input = new HistoricalSimulationInputFactory().create(dataset);
HistoricalDispatchVisualizerServer server =
new HistoricalDispatchVisualizerServer(port, dataset, input);
CountDownLatch shutdown = new CountDownLatch(1);
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
server.stop();
shutdown.countDown();
}, "javaTest-visualizer-shutdown"));
server.start();
System.out.println("DISPATCH-VISUALIZER http://127.0.0.1:" + server.getPort());
shutdown.await();
}
}

View File

@@ -0,0 +1,235 @@
package com.ft546.wcs.shadow.history.visualizer;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ft546.wcs.shadow.history.HistoricalQueueDataset;
import com.ft546.wcs.shadow.history.simulation.HistoricalDispatchSimulationResult;
import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInput;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 为离线调度审计页面提供同源静态资源和 JSON API。
*
* <p>服务只绑定到本机回环地址,不暴露生产系统接口,也不接受文件路径或命令输入。
*/
public final class HistoricalDispatchVisualizerServer {
private static final int MAX_REQUEST_BYTES = 64 * 1024;
private final HttpServer httpServer;
private final ExecutorService executor;
private final ObjectMapper objectMapper;
private final HistoricalSimulationInput input;
private final PointToPointSimulationService pointService;
private final HistoricalWorkloadSimulationService historicalService;
private final SimulationVisualizationMapper mapper;
private final AtomicBoolean simulationBusy = new AtomicBoolean(false);
private final AtomicBoolean started = new AtomicBoolean(false);
public HistoricalDispatchVisualizerServer(
int port, HistoricalQueueDataset dataset, HistoricalSimulationInput input)
throws IOException {
if (port < 0 || port > 65_535) {
throw new IllegalArgumentException("port must be between 0 and 65535");
}
if (dataset == null || input == null) {
throw new IllegalArgumentException("dataset and input are required");
}
this.input = input;
this.pointService = new PointToPointSimulationService(input);
this.historicalService = new HistoricalWorkloadSimulationService(input);
this.mapper = new SimulationVisualizationMapper(dataset);
this.objectMapper = new ObjectMapper();
this.httpServer = HttpServer.create(new InetSocketAddress(
InetAddress.getByName("127.0.0.1"), port), 0);
this.executor = Executors.newFixedThreadPool(4, new VisualizerThreadFactory());
this.httpServer.setExecutor(executor);
this.httpServer.createContext("/", new DispatchHandler());
}
/** 启动本地服务。重复启动属于调用错误。 */
public void start() {
if (!started.compareAndSet(false, true)) {
throw new IllegalStateException("visualizer server is already started");
}
httpServer.start();
}
/** 停止服务并释放测试或命令行进程使用的线程。 */
public void stop() {
if (started.compareAndSet(true, false)) httpServer.stop(0);
executor.shutdownNow();
}
public int getPort() {
return httpServer.getAddress().getPort();
}
private final class DispatchHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
try {
route(exchange);
} catch (VisualizerHttpException exception) {
writeError(exchange, exception.getStatus(), exception.getCode(),
exception.getMessage());
} catch (JsonProcessingException exception) {
writeError(exchange, 400, "BAD_REQUEST", "请求 JSON 无法解析");
} catch (IllegalArgumentException exception) {
writeError(exchange, 400, businessCode(exception.getMessage()),
exception.getMessage());
} catch (IllegalStateException exception) {
String code = exception.getMessage() != null
&& exception.getMessage().contains("tick limit")
? "TICK_LIMIT_EXCEEDED" : "WATCHDOG_DEADLOCK";
writeError(exchange, 422, code, exception.getMessage());
} catch (Exception exception) {
writeError(exchange, 500, "INTERNAL_ERROR", "调度审计服务处理失败");
} finally {
exchange.close();
}
}
}
private void route(HttpExchange exchange) throws IOException {
String path = exchange.getRequestURI().getPath();
String method = exchange.getRequestMethod();
if ("/api/health".equals(path)) {
requireMethod(method, "GET");
Map<String, Object> health = new LinkedHashMap<String, Object>();
health.put("status", "UP");
health.put("mode", "OFFLINE_IN_MEMORY_SIMULATION");
health.put("mapLoaded", !input.getGridMap().getSites().isEmpty());
health.put("simulationBusy", simulationBusy.get());
writeJson(exchange, 200, health);
return;
}
if ("/api/bootstrap".equals(path)) {
requireMethod(method, "GET");
writeJson(exchange, 200, mapper.bootstrap(input));
return;
}
if ("/api/simulations/point-to-point".equals(path)) {
requireMethod(method, "POST");
byte[] body = readBody(exchange);
PointToPointSimulationRequest request = objectMapper.readValue(
body, PointToPointSimulationRequest.class);
writeJson(exchange, 200, runSimulation(() ->
mapper.result(input, pointService.run(request))));
return;
}
if ("/api/simulations/historical".equals(path)) {
requireMethod(method, "POST");
readBody(exchange);
writeJson(exchange, 200, runSimulation(() -> {
HistoricalDispatchSimulationResult result = historicalService.run();
return mapper.result(input, result);
}));
return;
}
throw new VisualizerHttpException(404, "NOT_FOUND", "请求的审计资源不存在");
}
private Map<String, Object> runSimulation(SimulationCall call) {
if (!simulationBusy.compareAndSet(false, true)) {
throw new VisualizerHttpException(409, "SIMULATION_BUSY", "已有仿真正在运行");
}
try {
return call.run();
} finally {
simulationBusy.set(false);
}
}
private byte[] readBody(HttpExchange exchange) throws IOException {
String contentLength = exchange.getRequestHeaders().getFirst("Content-Length");
if (contentLength != null) {
try {
if (Long.parseLong(contentLength) > MAX_REQUEST_BYTES) {
throw new VisualizerHttpException(413, "PAYLOAD_TOO_LARGE", "请求体超过 64 KiB");
}
} catch (NumberFormatException exception) {
throw new VisualizerHttpException(400, "BAD_REQUEST", "Content-Length 无效");
}
}
try (InputStream inputStream = exchange.getRequestBody();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
byte[] buffer = new byte[4096];
int read;
while ((read = inputStream.read(buffer)) >= 0) {
if (output.size() + read > MAX_REQUEST_BYTES) {
throw new VisualizerHttpException(413, "PAYLOAD_TOO_LARGE", "请求体超过 64 KiB");
}
output.write(buffer, 0, read);
}
return output.toByteArray();
}
}
private void requireMethod(String actual, String expected) {
if (!expected.equals(actual)) {
throw new VisualizerHttpException(405, "METHOD_NOT_ALLOWED",
"该接口仅允许 " + expected);
}
}
private void writeError(HttpExchange exchange, int status, String code, String message)
throws IOException {
Map<String, Object> value = new LinkedHashMap<String, Object>();
value.put("code", code);
value.put("message", message == null ? "请求处理失败" : message);
value.put("details", new LinkedHashMap<String, Object>());
writeJson(exchange, status, value);
}
private void writeJson(HttpExchange exchange, int status, Object value) throws IOException {
byte[] body = objectMapper.writeValueAsBytes(value);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.getResponseHeaders().set("Cache-Control", "no-store");
exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff");
exchange.sendResponseHeaders(status, body.length);
try (OutputStream output = exchange.getResponseBody()) {
output.write(body);
}
}
private String businessCode(String message) {
if (message == null) return "BAD_REQUEST";
int separator = message.indexOf(':');
if (separator <= 0) return "BAD_REQUEST";
String candidate = message.substring(0, separator);
return candidate.matches("[A-Z_]+") ? candidate : "BAD_REQUEST";
}
private interface SimulationCall {
Map<String, Object> run();
}
private static final class VisualizerThreadFactory implements ThreadFactory {
private final AtomicInteger sequence = new AtomicInteger();
@Override
public Thread newThread(Runnable runnable) {
Thread thread = new Thread(runnable,
"javaTest-visualizer-http-" + sequence.incrementAndGet());
thread.setDaemon(true);
return thread;
}
}
}

View File

@@ -0,0 +1,28 @@
package com.ft546.wcs.shadow.history.visualizer;
import com.ft546.wcs.shadow.history.simulation.DeterministicFaultPlan;
import com.ft546.wcs.shadow.history.simulation.HistoricalDispatchSimulationResult;
import com.ft546.wcs.shadow.history.simulation.HistoricalDispatchSimulator;
import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInput;
import com.ft546.wcs.shadow.history.simulation.SimulatedPlcGateway;
import com.ft546.wcs.shadow.history.simulation.SimulationConfig;
/** 重新运行固化的 240 条历史负载,并保留既定的确定性 PLC 故障注入。 */
public final class HistoricalWorkloadSimulationService {
private final HistoricalSimulationInput input;
public HistoricalWorkloadSimulationService(HistoricalSimulationInput input) {
if (input == null) throw new IllegalArgumentException("input is required");
this.input = input;
}
public HistoricalDispatchSimulationResult run() {
DeterministicFaultPlan.Builder faults = DeterministicFaultPlan.builder();
for (int i = 0; i < input.getTasks().size(); i += 37) {
faults.plcFailure(input.getTasks().get(i).getSimulationTaskId(), 1,
SimulatedPlcGateway.PlcReason.SEND_FAILURE);
}
return new HistoricalDispatchSimulator().simulate(
input, SimulationConfig.defaults(), faults.build());
}
}

View File

@@ -0,0 +1,16 @@
package com.ft546.wcs.shadow.history.visualizer;
/** 表示可稳定映射为前端业务错误码的 HTTP 请求异常。 */
final class VisualizerHttpException extends RuntimeException {
private final int status;
private final String code;
VisualizerHttpException(int status, String code, String message) {
super(message);
this.status = status;
this.code = code;
}
int getStatus() { return status; }
String getCode() { return code; }
}

View File

@@ -0,0 +1,171 @@
package com.ft546.wcs.shadow.history.visualizer;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ft546.wcs.shadow.history.HistoricalQueueDataset;
import com.ft546.wcs.shadow.history.HistoricalQueueLoader;
import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInput;
import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInputFactory;
import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationTask;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class HistoricalDispatchVisualizerServerTest {
private static final ObjectMapper JSON = new ObjectMapper();
private static HistoricalQueueDataset dataset;
private static HistoricalSimulationInput input;
private static HistoricalSimulationTask sampleTask;
private HistoricalDispatchVisualizerServer server;
@BeforeAll
static void loadFixture() {
dataset = new HistoricalQueueLoader().load(
HistoricalDispatchVisualizerServerTest.class.getClassLoader(), "shadow/history");
input = new HistoricalSimulationInputFactory().create(dataset);
sampleTask = input.getTasks().stream()
.filter(task -> !task.getPhysicalSource().equals(task.getPhysicalTarget()))
.findFirst()
.orElseThrow(() -> new AssertionError("a point-to-point sample is required"));
}
@BeforeEach
void startServer() throws IOException {
server = new HistoricalDispatchVisualizerServer(0, dataset, input);
server.start();
}
@AfterEach
void stopServer() {
if (server != null) server.stop();
}
@Test
void servesHealthBootstrapAndPointToPointSimulation() throws Exception {
HttpResult health = request("GET", "/api/health", null);
HttpResult bootstrap = request("GET", "/api/bootstrap", null);
PointToPointSimulationRequest task = PointToPointSimulationRequest.valid(
sampleTask.getPhysicalSource(), sampleTask.getPhysicalTarget(),
"INBOUND", 1, 100, null, Collections.emptyList(), "NONE");
HttpResult simulation = request("POST", "/api/simulations/point-to-point",
JSON.writeValueAsString(task));
assertEquals(200, health.status);
assertEquals(Boolean.TRUE, map(health.body).get("mapLoaded"));
assertEquals(Boolean.FALSE, map(health.body).get("simulationBusy"));
assertEquals(200, bootstrap.status);
assertEquals("OFFLINE_IN_MEMORY_SIMULATION", map(bootstrap.body).get("mode"));
assertEquals(200, simulation.status);
assertEquals(1, map(map(simulation.body).get("summary")).get("completedCount"));
assertFalse(list(map(simulation.body).get("events")).isEmpty());
assertTrue(simulation.body.contains("plannedPath"));
assertTrue(simulation.body.contains("MOVE"));
}
@Test
void historicalEndpointRunsAllCurrentTasks() throws Exception {
HttpResult response = request("POST", "/api/simulations/historical", "{}");
Map<String, Object> summary = map(map(response.body).get("summary"));
assertEquals(200, response.status);
assertEquals(240, summary.get("inputCount"));
assertEquals(240, summary.get("completedCount"));
assertEquals(0, summary.get("waitingCount"));
assertEquals(0, summary.get("failedCount"));
assertEquals(Boolean.FALSE, map(response.body).get("pathInfoUsedForPlanning"));
assertEquals(0, map(map(response.body).get("invariants")).get("totalSafetyViolations"));
}
@Test
void rejectsUnknownRoutesWrongMethodsMalformedAndOversizedBodies() throws Exception {
HttpResult unknown = request("GET", "/api/unknown", null);
HttpResult wrongMethod = request("POST", "/api/health", "{}");
HttpResult malformed = request("POST", "/api/simulations/point-to-point", "{");
StringBuilder oversized = new StringBuilder(70_000);
for (int i = 0; i < 70_000; i++) oversized.append('x');
HttpResult tooLarge = request("POST", "/api/simulations/point-to-point",
oversized.toString());
assertEquals(404, unknown.status);
assertEquals("NOT_FOUND", map(unknown.body).get("code"));
assertEquals(405, wrongMethod.status);
assertEquals("METHOD_NOT_ALLOWED", map(wrongMethod.body).get("code"));
assertEquals(400, malformed.status);
assertEquals("BAD_REQUEST", map(malformed.body).get("code"));
assertEquals(413, tooLarge.status);
assertEquals("PAYLOAD_TOO_LARGE", map(tooLarge.body).get("code"));
}
private HttpResult request(String method, String path, String body) throws IOException {
URL url = new URL("http://127.0.0.1:" + server.getPort() + path);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod(method);
connection.setConnectTimeout(5_000);
connection.setReadTimeout(60_000);
connection.setRequestProperty("Accept", "application/json");
if (body != null) {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
connection.setDoOutput(true);
connection.setRequestProperty("Content-Type", "application/json; charset=utf-8");
connection.setFixedLengthStreamingMode(bytes.length);
try (OutputStream output = connection.getOutputStream()) {
output.write(bytes);
}
}
int status = connection.getResponseCode();
InputStream stream = status >= 400
? connection.getErrorStream() : connection.getInputStream();
String response = stream == null ? "" : read(stream);
connection.disconnect();
return new HttpResult(status, response);
}
private String read(InputStream input) throws IOException {
try (InputStream source = input; ByteArrayOutputStream output = new ByteArrayOutputStream()) {
byte[] buffer = new byte[4096];
int read;
while ((read = source.read(buffer)) >= 0) output.write(buffer, 0, read);
return new String(output.toByteArray(), StandardCharsets.UTF_8);
}
}
private static Map<String, Object> map(String json) throws IOException {
return JSON.readValue(json, new TypeReference<Map<String, Object>>() { });
}
@SuppressWarnings("unchecked")
private static Map<String, Object> map(Object value) {
return (Map<String, Object>) value;
}
@SuppressWarnings("unchecked")
private static java.util.List<Object> list(Object value) {
return (java.util.List<Object>) value;
}
private static final class HttpResult {
private final int status;
private final String body;
private HttpResult(int status, String body) {
this.status = status;
this.body = body;
}
}
}