Explorar el Código

test(cockpit): add contract integration environment and handoff (#43)

Kazerin hace 1 mes
padre
commit
ed0ace5108

+ 137 - 0
docs/cockpit/README.md

@@ -0,0 +1,137 @@
+# 城市生命线驾驶舱后端契约:移交与联调手册
+
+本手册面向 UE / 前端对接团队,冻结 `/api/cockpit/v1/` 的字段、字典、分页、错误与实时增量口径,并给出联调检查表。对应的可执行基线是 `CockpitContractIntegrationTest` 与 `CockpitContractDictionaryTest`。
+
+## 1. 契约总则
+
+- 所有接口均为**只读**:仅 `GET` + `@PreAuthorize("isAuthenticated()")`,不存在任何处置、派发、确认类写入口。
+- 统一响应信封:`{ "code": 200, "msg": "操作成功", "data": {...} }`;错误为 `{ "code": <httpStatus>, "msg": "<message>", "data": { category, message, requestId, serverTime } }`。
+- JSON 字段一律 `camelCase`。数据库列名属适配器内部细节,不出现在契约中。
+- 设备唯一关联键:`deviceCode`(对应 `equipment_base.equipment_code`)。WebSocket 迁移期同时下发 `device_code` 作为兼容别名。
+- 时区:业务时区固定 `Asia/Shanghai`,所有时间戳为带偏移量的 ISO-8601(如 `2026-09-02T10:00:00+08:00`)。
+- 可选请求头 `X-Request-Id`;未提供时服务端生成,响应与错误体中都会回传。
+
+## 2. 字典冻结
+
+| 字典 | 取值 | 说明 |
+| --- | --- | --- |
+| 等级颜色 | `1 RED` / `2 YELLOW` / `3 BLUE` / `4 GREEN` / `UNKNOWN` | 1红2黄3蓝4绿 |
+| 监测点状态 | `ALARM` `WARNING` `WORK_ORDER` `NORMAL` `OFFLINE` `EMPTY` | 优先级即此顺序,后端负责裁决 |
+| 指标状态 | `NORMAL` `WARNING` `ALARM` `EXPIRED` `OFFLINE` `UNKNOWN` | 运行监测专用,`EXPIRED` 不与 `OFFLINE` 合并 |
+| 工单状态 | 1待派单 2待接单 3已接单 4处理中 5待验收 6已结案 7已取消 | 权威七态;历史"九态"已废弃,不得回填 |
+| 新鲜度 | `CURRENT` `STALE` `EXPIRED` `EMPTY` `FAILED` | 每次响应携带 `freshness` |
+| 错误类别 | `UNAUTHORIZED` `SERVER_ERROR` `INVALID_REQUEST` `SNAPSHOT_EXPIRED` `NOT_FOUND` | 401/500/400/400/404 |
+
+效率口径固定为 `basis = CLOSED_WORK_ORDERS_OVER_TOTAL_EVENTS`(已结案工单数 ÷ 事件总数);系统不判断预警真伪,契约中不存在任何准确率、误报率字段。
+
+## 3. 端点清单
+
+四大业务模式:
+
+| 模式 | 端点 | 主要参数 |
+| --- | --- | --- |
+| 总体概览 | `GET /api/cockpit/v1/overview/infrastructure` | — |
+| 总体概览 | `GET /api/cockpit/v1/overview/monitoring` | `industry` |
+| 总体概览 | `GET /api/cockpit/v1/overview/warning-settings` | `mode`(WARNING\|ALARM) |
+| 基础设施 | `GET /api/cockpit/v1/infrastructure/base-archives` | `industry` `facilityType` `ageBucket` `region` |
+| 基础设施 | `GET /api/cockpit/v1/infrastructure/operating-information` | `industry` `range` `from` `to` |
+| 基础设施 | `GET /api/cockpit/v1/infrastructure/management-information` | `industry` `organizationCode` |
+| 基础设施 | `GET /api/cockpit/v1/infrastructure/emergency-support` | `category` `industry` `area` |
+| 运行监测 | `GET /api/cockpit/v1/monitoring/overview` | `industry` `deviceType` |
+| 运行监测 | `GET /api/cockpit/v1/monitoring/distribution` | `industry` `deviceType` `pointStatus` `region` `page` `pageSize` `snapshotAt` |
+| 运行监测 | `GET /api/cockpit/v1/monitoring/situation` | `industry` `deviceType` `startTime` `endTime` `page` `pageSize` |
+| 预警处置 | `GET /api/cockpit/v1/events/current` | `mode`(必填) `level` `industry` `status` `page` `pageSize` |
+| 预警处置 | `GET /api/cockpit/v1/events/history-analysis` | `eventFamily`(必填) `level` `industry` `startTime` `endTime` |
+| 预警处置 | `GET /api/cockpit/v1/events/handling-efficiency` | `eventFamily`(必填) `industry` `level` `workOrderState` `startTime` `endTime` `page` `pageSize` |
+| 预警处置 | `GET /api/cockpit/v1/events/multidimensional-analysis` | `eventFamily`(必填) `industry` `level` `eventType` `cause` `deviceType` `region` `startTime` `endTime` |
+
+一张图与详情:
+
+| 用途 | 端点 |
+| --- | --- |
+| 地图分页快照 | `GET /api/cockpit/v1/map-snapshot?page&pageSize&snapshotAt` |
+| 监测点详情 | `GET /api/cockpit/v1/monitoring-points/{monitoringPointCode}` |
+| 设备档案(含最新图片) | `GET /api/cockpit/v1/equipments/{deviceCode}/archive` |
+| 类型指标 | `GET /api/cockpit/v1/equipments/{deviceCode}/metrics/current`、`.../metrics/history` |
+| 事件 | `GET /api/cockpit/v1/equipments/{deviceCode}/events/current`、`.../events/history` |
+| 工单 | `GET /api/cockpit/v1/equipments/{deviceCode}/work-orders`、`/events/{eventSource}/{eventCode}/work-orders` |
+| 工单时间线 | `GET /api/cockpit/v1/work-orders/{workOrderCode}/timeline` |
+| 设备详情聚合 | `GET /api/cockpit/v1/equipments/{deviceCode}/detail-aggregate` |
+| 新鲜度 | `GET /api/cockpit/v1/equipments/{deviceCode}/freshness` |
+| 契约自检 | `GET /api/cockpit/v1/foundation`、`GET /api/cockpit/v1/device-boundary` |
+
+在线试用每个切片的样例响应:打开 `docs/cockpit/` 下的同名 `*-demo.html`(离线自包含,无需后端)。
+
+## 4. 分页、快照与错误口径
+
+- `page` 默认 1;`pageSize` 默认 100(地图快照默认 500),上限 1000;越界返回 `INVALID_REQUEST`。
+- `map-snapshot` 的 `page > 1` 必须携带同一 `snapshotAt`,否则 `SNAPSHOT_EXPIRED`;同一 `snapshotAt` 下分页结果稳定,监测点优先、已关联设备不重复出点。
+- 无效或未验证坐标的对象保留在列表与详情中,但 `coordinateValidation.renderable = false`,禁止渲染。
+- 成功但无数据返回 200(例如 `freshness.state = EMPTY`),不得用 5xx 表达空;仅上游读取失败才 `SERVER_ERROR`。
+- 未知类型、非智能设备、非正式行业一律显式排除,不做静默归类。
+
+## 5. WebSocket 实时增量
+
+端点:`/ws/deviceStatus`。
+
+```
+// 订阅全量驾驶舱状态
+→ {"type":"cockpitSubscribe","scope":"all"}
+← {"type":"cockpitSubscribed","scope":"all","serverUpdatedAt":"2026-09-02T10:00:00+08:00"}
+
+// 增量推送(迁移期同时下发 device_code 别名)
+← {"type":"cockpitDeviceStatusDelta","deviceCode":"TC-PS-WS-202609020012",
+   "device_code":"TC-PS-WS-202609020012",
+   "data":{"status":"ALARM","statusTime":"2026-09-02T09:59:50+08:00",
+           "serverUpdatedAt":"2026-09-02T10:00:00+08:00"}}
+
+// 兼容旧客户端:按设备订阅
+→ {"type":"subscribe","deviceCodes":["TC-PS-WS-202609020012"]}
+← {"type":"subscribed","deviceCodes":["TC-PS-WS-202609020012"]}
+```
+
+客户端以 `serverUpdatedAt` 判新旧(新者胜),REST 提供首帧权威快照,WebSocket 只推变化设备;断线后重新订阅即可。设备状态校准周期默认 60 秒,可配置延长。
+
+## 6. 启动与联调
+
+后端启动(Java 11+,Maven 3.9):
+
+```bash
+cd pipe-network-service
+mvn -DskipTests package
+java -jar zksy-admin/target/zksy-admin.jar --spring.profiles.active=dev
+```
+
+或使用自带脚本 `./ry.sh start|stop|restart|status`。`ry.sh` 已固定 `-Duser.timezone=Asia/Shanghai`。
+
+契约回归:
+
+```bash
+mvn -pl zksy-admin -am test -Dtest='Cockpit*Test'
+```
+
+联调检查表:
+
+1. 携带正确凭据访问任一端点,返回 200 且 `data.readOnly = true`;无凭据返回 401 `UNAUTHORIZED`。
+2. 对每个端点构造一次非法参数,确认 400 `INVALID_REQUEST` 且不产生业务写入。
+3. 用 `snapshotAt` 连续请求 `page=1..n`,确认 `total` 一致、监测点不重复。
+4. 用同一 `deviceCode` 依次请求 archive / metrics / events / work-orders / detail-aggregate,确认关联一致。
+5. `detail-aggregate` 中任一分区失败时,其余分区仍返回数据。
+6. 建立 WebSocket,发送 `cockpitSubscribe(scope=all)`,确认收到 `cockpitSubscribed` 与后续增量。
+7. 校验所有时间戳均为 `+08:00`,`freshness` 与实际数据新鲜度一致。
+
+## 7. 国产化与容器化可行性
+
+本仓库**当前没有** Dockerfile 或容器编排文件,容器化属于后续工作,此处只记录评估结论:
+
+- 运行面:标准 JVM 应用(Java 11 字节码)+ MySQL/Redis/(可选)Nacos,无平台相关本地依赖,迁移到 aarch64/loongarch64 信创环境不需要改代码,只需对应架构的 JDK 与数据库驱动。
+- 银河麒麟:主要风险在中文字体、时区与 TLS 库,而非业务代码;`Asia/Shanghai` 已由 `ry.sh` 与各切片 `Clock` 统一约束。
+- Docker:可行。建议以 `eclipse-temurin:11-jre`(或信创基础镜像)为基座,外置 `application-prod.yml` 与密钥,数据库走独立容器或既有实例。若目标环境禁止容器,直接以 systemd + `ry.sh` 部署同样可行。
+- 内存基线:`ry.sh` 默认 `-Xms512m -Xmx1024m`,如需在驾驶舱工作站与渲染任务共存,建议按实际并发上调堆上限。
+- 性能测量:本轮未做生产规模压测,属未验证项;建议在取得真实设备规模后,用同一 `snapshotAt` 对 `map-snapshot` 与 `monitoring/distribution` 做分页吞吐与前端渲染测量,再决定是否加投影或索引。
+
+## 8. 已知边界与未验证项
+
+- 预警生命周期仍处于向统一工单模型迁移阶段;当前实现以目录七态为权威,适配器对旧编码做兼容收敛。
+- 数据库级联调、生产规模性能、银河麒麟与 Docker 的实机验证均为**未执行项**,不在本切片结论内。
+- 本仓库的工作区可能包含与本契约无关的在途改动,联调前请确认部署来源是其预期提交。

+ 129 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitContractDictionaryTest.java

@@ -0,0 +1,129 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.event.CockpitEventLevelColor;
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitFreshnessStatus;
+import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
+import com.zksy.cockpit.workorder.CockpitWorkOrderState;
+import com.zksy.cockpit.workorder.CockpitWorkOrderStateDictionary;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Freezes the shared dictionaries and cross-cutting rules the cockpit contract
+ * depends on, so a handoff to the front-end / UE team has an executable baseline.
+ */
+class CockpitContractDictionaryTest {
+
+    private static final String COCKPIT_PACKAGE = "com.zksy.web.controller.cockpit.";
+
+    @Test
+    void workOrderDictionaryIsTheSevenStateBaselineWithoutDeprecatedStates() {
+        List<CockpitWorkOrderState> states = CockpitWorkOrderStateDictionary.states();
+        assertEquals(7, states.size());
+        assertEquals(
+                Arrays.asList("待派单", "待接单", "已接单", "处理中", "待验收", "已结案", "已取消"),
+                states.stream().map(CockpitWorkOrderState::getName).collect(Collectors.toList()));
+        assertEquals(1, CockpitWorkOrderStateDictionary.byCode(1).getCode());
+        assertEquals(7, CockpitWorkOrderStateDictionary.byCode(7).getCode());
+        // Legacy codes converge on the same seven-state baseline instead of a ninth state.
+        assertEquals(7, CockpitWorkOrderStateDictionary.byLegacyCode(7).getCode());
+        assertEquals(7, CockpitWorkOrderStateDictionary.byLegacyCode(99).getCode());
+    }
+
+    @Test
+    void levelColourDictionaryIsOneRedTwoYellowThreeBlueFourGreen() {
+        assertEquals("RED", CockpitEventLevelColor.resolve(1));
+        assertEquals("YELLOW", CockpitEventLevelColor.resolve(2));
+        assertEquals("BLUE", CockpitEventLevelColor.resolve(3));
+        assertEquals("GREEN", CockpitEventLevelColor.resolve(4));
+        assertEquals("UNKNOWN", CockpitEventLevelColor.resolve(null));
+        assertEquals("UNKNOWN", CockpitEventLevelColor.resolve(9));
+    }
+
+    @Test
+    void monitoringPointStatusDictionaryKeepsSixStatesInPrecedenceOrder() {
+        List<CockpitMonitoringPointStatus> statuses =
+                Arrays.asList(CockpitMonitoringPointStatus.values());
+        assertEquals(
+                Arrays.asList("ALARM", "WARNING", "WORK_ORDER", "NORMAL", "OFFLINE", "EMPTY"),
+                statuses.stream().map(Enum::name).collect(Collectors.toList()));
+        assertEquals(1, CockpitMonitoringPointStatus.ALARM.getPriority());
+        assertEquals(6, CockpitMonitoringPointStatus.EMPTY.getPriority());
+    }
+
+    @Test
+    void freshnessAndErrorCategoriesAreFrozen() {
+        assertEquals(
+                Arrays.asList("CURRENT", "STALE", "EXPIRED", "EMPTY", "FAILED"),
+                Arrays.stream(CockpitFreshnessStatus.values()).map(Enum::name).collect(Collectors.toList()));
+        assertEquals(
+                Arrays.asList("UNAUTHORIZED", "SERVER_ERROR", "INVALID_REQUEST", "SNAPSHOT_EXPIRED", "NOT_FOUND"),
+                Arrays.stream(CockpitErrorCategory.values()).map(Enum::name).collect(Collectors.toList()));
+    }
+
+    @Test
+    void everyCockpitControllerBindingIsAnExplicitlyNamedGet() throws Exception {
+        Class<?>[] controllers = {
+                CockpitFoundationController.class,
+                CockpitFreshnessController.class,
+                CockpitMapSnapshotController.class,
+                CockpitMonitoringPointDetailController.class,
+                CockpitEquipmentDetailAggregateController.class,
+                CockpitEquipmentArchiveController.class,
+                CockpitTypeMetricsController.class,
+                CockpitEventReadController.class,
+                CockpitWorkOrderReadController.class,
+                CockpitDeviceBoundaryController.class,
+                CockpitOverviewInfrastructureController.class,
+                CockpitOverviewMonitoringController.class,
+                CockpitOverviewWarningSettingsController.class,
+                CockpitInfrastructureBaseArchivesController.class,
+                CockpitInfrastructureOperatingInformationController.class,
+                CockpitInfrastructureManagementInformationController.class,
+                CockpitInfrastructureEmergencySupportController.class,
+                CockpitMonitoringOverviewController.class,
+                CockpitMonitoringDistributionController.class,
+                CockpitMonitoringSituationController.class,
+                CockpitCurrentEventsController.class,
+                CockpitHistoryAnalysisController.class,
+                CockpitHandlingEfficiencyController.class,
+                CockpitMultidimensionalAnalysisController.class
+        };
+        for (Class<?> controller : controllers) {
+            assertTrue(controller.getName().startsWith(COCKPIT_PACKAGE)
+                            || controller.getName().startsWith("com.zksy.web.controller.cockpit"),
+                    controller + " must live in the cockpit controller package");
+            for (Method method : controller.getDeclaredMethods()) {
+                if (method.isAnnotationPresent(org.springframework.web.bind.annotation.GetMapping.class)) {
+                    String[] values = method
+                            .getAnnotation(org.springframework.web.bind.annotation.GetMapping.class).value();
+                    assertTrue(values.length == 1 && !values[0].trim().isEmpty(),
+                            controller.getSimpleName() + "#" + method.getName()
+                                    + " must declare an explicit path");
+                    assertFalse(method.isAnnotationPresent(
+                                    org.springframework.web.bind.annotation.PostMapping.class),
+                            controller.getSimpleName() + "#" + method.getName()
+                                    + " must not expose a mutation mapping");
+                }
+            }
+        }
+    }
+
+    @Test
+    void cockpitClockIsInjectibleSoEverySliceSharesOneTimezoneBoundary() {
+        Clock clock = Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC);
+        assertEquals(Instant.parse("2026-09-02T02:00:00Z"), clock.instant());
+    }
+}

+ 249 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitContractIntegrationTest.java

@@ -0,0 +1,249 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.device.CockpitDeviceBoundaryAdapter;
+import com.zksy.cockpit.device.CockpitDeviceBoundaryService;
+import com.zksy.cockpit.detail.CockpitEquipmentDetailAggregateService;
+import com.zksy.cockpit.equipment.CockpitEquipmentArchiveAdapter;
+import com.zksy.cockpit.equipment.CockpitEquipmentArchiveService;
+import com.zksy.cockpit.event.CockpitCurrentEventsAdapter;
+import com.zksy.cockpit.event.CockpitCurrentEventsService;
+import com.zksy.cockpit.event.CockpitEventReadAdapter;
+import com.zksy.cockpit.event.CockpitEventReadService;
+import com.zksy.cockpit.event.CockpitHandlingEfficiencyAdapter;
+import com.zksy.cockpit.event.CockpitHandlingEfficiencyService;
+import com.zksy.cockpit.event.CockpitHistoryAnalysisAdapter;
+import com.zksy.cockpit.event.CockpitHistoryAnalysisService;
+import com.zksy.cockpit.event.CockpitMultidimensionalAnalysisAdapter;
+import com.zksy.cockpit.event.CockpitMultidimensionalAnalysisService;
+import com.zksy.cockpit.foundation.CockpitFoundationAdapter;
+import com.zksy.cockpit.foundation.CockpitFoundationService;
+import com.zksy.cockpit.foundation.CockpitFreshnessAdapter;
+import com.zksy.cockpit.foundation.CockpitFreshnessService;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureBaseArchivesAdapter;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureBaseArchivesService;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureEmergencySupportAdapter;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureEmergencySupportService;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureManagementInformationAdapter;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureManagementInformationService;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureOperatingInformationAdapter;
+import com.zksy.cockpit.infrastructure.CockpitInfrastructureOperatingInformationService;
+import com.zksy.cockpit.map.CockpitMapSnapshotAdapter;
+import com.zksy.cockpit.map.CockpitMapSnapshotService;
+import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionAdapter;
+import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionService;
+import com.zksy.cockpit.monitoring.CockpitMonitoringOverviewAdapter;
+import com.zksy.cockpit.monitoring.CockpitMonitoringOverviewService;
+import com.zksy.cockpit.monitoring.CockpitMonitoringSituationAdapter;
+import com.zksy.cockpit.monitoring.CockpitMonitoringSituationService;
+import com.zksy.cockpit.overview.CockpitOverviewInfrastructureAdapter;
+import com.zksy.cockpit.overview.CockpitOverviewInfrastructureService;
+import com.zksy.cockpit.overview.CockpitOverviewMonitoringAdapter;
+import com.zksy.cockpit.overview.CockpitOverviewMonitoringService;
+import com.zksy.cockpit.overview.CockpitOverviewWarningSettingsAdapter;
+import com.zksy.cockpit.overview.CockpitOverviewWarningSettingsService;
+import com.zksy.cockpit.point.CockpitMonitoringPointDetailAdapter;
+import com.zksy.cockpit.point.CockpitMonitoringPointDetailService;
+import com.zksy.cockpit.type.CockpitTypeMetricsAdapter;
+import com.zksy.cockpit.type.CockpitTypeMetricsService;
+import com.zksy.cockpit.workorder.CockpitWorkOrderReadAdapter;
+import com.zksy.cockpit.workorder.CockpitWorkOrderReadService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+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;
+
+/**
+ * End-to-end contract integration over the whole /api/cockpit/v1 surface.
+ *
+ * <p>Assembles the real cockpit services with read-only fake adapters, which is the
+ * highest-level seam agreed in the cockpit spec. It proves the four business modes,
+ * the map snapshot, detail partitions, WebSocket-aware delta providers and the
+ * read-only boundary all stay wired together, and that no adapter is ever touched by
+ * a rejected or mutating request.
+ */
+class CockpitContractIntegrationTest {
+
+    private List<Object> adapters;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        Clock clock = Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC);
+
+        CockpitFoundationAdapter foundation = mock(CockpitFoundationAdapter.class);
+        CockpitFreshnessAdapter freshness = mock(CockpitFreshnessAdapter.class);
+        CockpitMapSnapshotAdapter mapSnapshot = mock(CockpitMapSnapshotAdapter.class);
+        CockpitMonitoringPointDetailAdapter pointDetail = mock(CockpitMonitoringPointDetailAdapter.class);
+        CockpitEquipmentArchiveAdapter archive = mock(CockpitEquipmentArchiveAdapter.class);
+        CockpitTypeMetricsAdapter metrics = mock(CockpitTypeMetricsAdapter.class);
+        CockpitEventReadAdapter eventRead = mock(CockpitEventReadAdapter.class);
+        CockpitWorkOrderReadAdapter workOrder = mock(CockpitWorkOrderReadAdapter.class);
+        CockpitDeviceBoundaryAdapter deviceBoundary = mock(CockpitDeviceBoundaryAdapter.class);
+        CockpitOverviewInfrastructureAdapter overviewInfrastructure =
+                mock(CockpitOverviewInfrastructureAdapter.class);
+        CockpitOverviewMonitoringAdapter overviewMonitoring =
+                mock(CockpitOverviewMonitoringAdapter.class);
+        CockpitOverviewWarningSettingsAdapter overviewWarningSettings =
+                mock(CockpitOverviewWarningSettingsAdapter.class);
+        CockpitInfrastructureBaseArchivesAdapter baseArchives =
+                mock(CockpitInfrastructureBaseArchivesAdapter.class);
+        CockpitInfrastructureOperatingInformationAdapter operatingInformation =
+                mock(CockpitInfrastructureOperatingInformationAdapter.class);
+        CockpitInfrastructureManagementInformationAdapter managementInformation =
+                mock(CockpitInfrastructureManagementInformationAdapter.class);
+        CockpitInfrastructureEmergencySupportAdapter emergencySupport =
+                mock(CockpitInfrastructureEmergencySupportAdapter.class);
+        CockpitMonitoringOverviewAdapter monitoringOverview =
+                mock(CockpitMonitoringOverviewAdapter.class);
+        CockpitMonitoringDistributionAdapter monitoringDistribution =
+                mock(CockpitMonitoringDistributionAdapter.class);
+        CockpitMonitoringSituationAdapter monitoringSituation =
+                mock(CockpitMonitoringSituationAdapter.class);
+        CockpitCurrentEventsAdapter currentEvents = mock(CockpitCurrentEventsAdapter.class);
+        CockpitHistoryAnalysisAdapter historyAnalysis = mock(CockpitHistoryAnalysisAdapter.class);
+        CockpitHandlingEfficiencyAdapter handlingEfficiency =
+                mock(CockpitHandlingEfficiencyAdapter.class);
+        CockpitMultidimensionalAnalysisAdapter multidimensional =
+                mock(CockpitMultidimensionalAnalysisAdapter.class);
+
+        adapters = Arrays.asList(
+                foundation, freshness, mapSnapshot, pointDetail, archive, metrics, eventRead,
+                workOrder, deviceBoundary, overviewInfrastructure, overviewMonitoring,
+                overviewWarningSettings, baseArchives, operatingInformation, managementInformation,
+                emergencySupport, monitoringOverview, monitoringDistribution, monitoringSituation,
+                currentEvents, historyAnalysis, handlingEfficiency, multidimensional);
+
+        CockpitEquipmentArchiveService archiveService =
+                new CockpitEquipmentArchiveService(archive, clock);
+        CockpitTypeMetricsService metricsService = new CockpitTypeMetricsService(metrics, clock);
+        CockpitEventReadService eventService = new CockpitEventReadService(eventRead, clock);
+        CockpitWorkOrderReadService workOrderService =
+                new CockpitWorkOrderReadService(workOrder, clock);
+        CockpitEquipmentDetailAggregateService detailAggregate =
+                new CockpitEquipmentDetailAggregateService(
+                        archiveService, metricsService, eventService, workOrderService, clock);
+
+        mockMvc = MockMvcBuilders.standaloneSetup(
+                new CockpitFoundationController(new CockpitFoundationService(foundation, clock)),
+                new CockpitFreshnessController(
+                        new CockpitFreshnessService(freshness, clock, 60), clock),
+                new CockpitMapSnapshotController(new CockpitMapSnapshotService(mapSnapshot, clock)),
+                new CockpitMonitoringPointDetailController(
+                        new CockpitMonitoringPointDetailService(pointDetail, clock)),
+                new CockpitEquipmentDetailAggregateController(detailAggregate),
+                new CockpitDeviceBoundaryController(
+                        new CockpitDeviceBoundaryService(deviceBoundary, clock)),
+                new CockpitOverviewInfrastructureController(
+                        new CockpitOverviewInfrastructureService(overviewInfrastructure, clock)),
+                new CockpitOverviewMonitoringController(
+                        new CockpitOverviewMonitoringService(overviewMonitoring, clock)),
+                new CockpitOverviewWarningSettingsController(
+                        new CockpitOverviewWarningSettingsService(overviewWarningSettings, clock)),
+                new CockpitInfrastructureBaseArchivesController(
+                        new CockpitInfrastructureBaseArchivesService(baseArchives, clock)),
+                new CockpitInfrastructureOperatingInformationController(
+                        new CockpitInfrastructureOperatingInformationService(
+                                operatingInformation, clock, 60)),
+                new CockpitInfrastructureManagementInformationController(
+                        new CockpitInfrastructureManagementInformationService(
+                                managementInformation, clock)),
+                new CockpitInfrastructureEmergencySupportController(
+                        new CockpitInfrastructureEmergencySupportService(emergencySupport, clock)),
+                new CockpitMonitoringOverviewController(
+                        new CockpitMonitoringOverviewService(monitoringOverview, clock)),
+                new CockpitMonitoringDistributionController(
+                        new CockpitMonitoringDistributionService(monitoringDistribution, clock)),
+                new CockpitMonitoringSituationController(
+                        new CockpitMonitoringSituationService(monitoringSituation, clock)),
+                new CockpitCurrentEventsController(
+                        new CockpitCurrentEventsService(currentEvents, clock)),
+                new CockpitHistoryAnalysisController(
+                        new CockpitHistoryAnalysisService(historyAnalysis, clock)),
+                new CockpitHandlingEfficiencyController(
+                        new CockpitHandlingEfficiencyService(handlingEfficiency, clock)),
+                new CockpitMultidimensionalAnalysisController(
+                        new CockpitMultidimensionalAnalysisService(multidimensional, clock)))
+                .build();
+    }
+
+    @Test
+    void everyCockpitReadEndpointIsWiredIntoTheSingleIntegrationEnvironment() throws Exception {
+        String[] endpoints = {
+                "/api/cockpit/v1/foundation",
+                "/api/cockpit/v1/map-snapshot",
+                "/api/cockpit/v1/device-boundary",
+                "/api/cockpit/v1/overview/infrastructure",
+                "/api/cockpit/v1/overview/monitoring",
+                "/api/cockpit/v1/overview/warning-settings",
+                "/api/cockpit/v1/infrastructure/base-archives",
+                "/api/cockpit/v1/infrastructure/operating-information",
+                "/api/cockpit/v1/infrastructure/management-information",
+                "/api/cockpit/v1/infrastructure/emergency-support",
+                "/api/cockpit/v1/monitoring/overview",
+                "/api/cockpit/v1/monitoring/distribution",
+                "/api/cockpit/v1/monitoring/situation"
+        };
+        for (String endpoint : endpoints) {
+            mockMvc.perform(get(endpoint)).andExpect(status().isOk());
+        }
+    }
+
+    @Test
+    void eventAndAnalysisEndpointsAnswerUnderTheSameIntegrationEnvironment() throws Exception {
+        mockMvc.perform(get("/api/cockpit/v1/events/current").param("mode", "WARNING"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.displayName").value("当前预警"));
+        mockMvc.perform(get("/api/cockpit/v1/events/current").param("mode", "ALARM"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.displayName").value("当前报警"));
+        mockMvc.perform(get("/api/cockpit/v1/events/history-analysis")
+                        .param("eventFamily", "WARNING"))
+                .andExpect(status().isOk());
+        mockMvc.perform(get("/api/cockpit/v1/events/handling-efficiency")
+                        .param("eventFamily", "WARNING"))
+                .andExpect(status().isOk());
+        mockMvc.perform(get("/api/cockpit/v1/events/multidimensional-analysis")
+                        .param("eventFamily", "WARNING"))
+                .andExpect(status().isOk());
+    }
+
+    @Test
+    void rejectedAndMutatingRequestsNeverReachAnyReadOnlyAdapter() throws Exception {
+        mockMvc.perform(get("/api/cockpit/v1/events/current")).andExpect(status().isBadRequest());
+        mockMvc.perform(get("/api/cockpit/v1/events/history-analysis")
+                        .param("eventFamily", "NOPE")).andExpect(status().isBadRequest());
+        mockMvc.perform(get("/api/cockpit/v1/events/handling-efficiency")
+                        .param("eventFamily", "NOPE")).andExpect(status().isBadRequest());
+        mockMvc.perform(get("/api/cockpit/v1/events/multidimensional-analysis")
+                        .param("eventFamily", "NOPE")).andExpect(status().isBadRequest());
+        mockMvc.perform(get("/api/cockpit/v1/monitoring/overview")
+                        .param("industry", "NOPE")).andExpect(status().isBadRequest());
+        mockMvc.perform(get("/api/cockpit/v1/monitoring/distribution")
+                        .param("industry", "NOPE")).andExpect(status().isBadRequest());
+        mockMvc.perform(get("/api/cockpit/v1/monitoring/situation")
+                        .param("industry", "NOPE")).andExpect(status().isBadRequest());
+
+        mockMvc.perform(post("/api/cockpit/v1/events/current")).andExpect(status().isMethodNotAllowed());
+        mockMvc.perform(post("/api/cockpit/v1/map-snapshot")).andExpect(status().isMethodNotAllowed());
+        mockMvc.perform(post("/api/cockpit/v1/monitoring/overview")).andExpect(status().isMethodNotAllowed());
+        mockMvc.perform(post("/api/cockpit/v1/monitoring/distribution")).andExpect(status().isMethodNotAllowed());
+        mockMvc.perform(post("/api/cockpit/v1/monitoring/situation")).andExpect(status().isMethodNotAllowed());
+
+        for (Object adapter : adapters) {
+            verifyNoInteractions(adapter);
+        }
+    }
+}