Эх сурвалжийг харах

feat(cockpit): 新增设备→设施真实关联只读端点(免认证)

前端「地下管线详情视图」此前拿不到设备→设施真实关系,只能按同业邻近做
「示意安装」。本次在免认证 cockpit 面补一个只读契约:

GET /api/cockpit/v1/device-facility-relations

数据来源(只做等值主键解析,不猜关联、不写库、无事务注解):
- manhole_device_rel(del_flag=0) → manhole_base.manhole_id,facilityKind=MANHOLE
- gas_pipe_point_device_rel(del_flag=0) → gas_pipe_point.network_id
  → pipe_network_base.network_id,facilityKind=PIPE(回管段主键,前端挂管线)
- deviceCode 取 equipment_base.equipment_code;查不到回 null 并只带 deviceId

降级语义:设施主键解析不到 → 跳过该条并计入 statistics(skipped*);
一条都没有 → relations 空数组(正常响应);查询失败 → 500 错误信封,
不把失败伪装成「无关联」。

安全面:沿用 SecurityConfig 既有 /api/cockpit/v1/** GET permitAll,
未改动任何白名单。

测试:CockpitDeviceFacilityRelationContractTest(3 例,主键解析/空数组/失败信封)
编译:mvn -pl pipe-network-service/zksy-admin -am -DskipTests compile 通过
Kazerin 19 цаг өмнө
parent
commit
83bd5ee387

+ 45 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitDeviceFacilityRelationController.java

@@ -0,0 +1,45 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.relation.CockpitDeviceFacilityRelationService;
+import com.zksy.common.core.domain.AjaxResult;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 设备 → 设施真实关联(免鉴权只读,`/api/cockpit/v1/**` GET 已由 SecurityConfig 放行)。
+ *
+ * <p>存在意义:前端「地下管线详情视图」此前拿不到真实设施关系,只能把设备按同业邻近摆成「示意安装」。
+ * 本端点把 {@code manhole_device_rel} / {@code gas_pipe_point_device_rel} 的真实主键关系如实吐出,
+ * 让前端按主键把设备挂到管段上;查不到就是空数组,前端继续降级,绝不在这里补关系。
+ *
+ * <p>只读:纯查询,无写库、无事务注解。
+ */
+@RestController
+public class CockpitDeviceFacilityRelationController {
+    private static final Logger LOG = LoggerFactory.getLogger(CockpitDeviceFacilityRelationController.class);
+
+    private final CockpitDeviceFacilityRelationService service;
+
+    public CockpitDeviceFacilityRelationController(CockpitDeviceFacilityRelationService service) {
+        this.service = service;
+    }
+
+    @GetMapping("/api/cockpit/v1/device-facility-relations")
+    public ResponseEntity<AjaxResult> relations() {
+        return ResponseEntity.ok(AjaxResult.success(service.load()));
+    }
+
+    @ExceptionHandler(CockpitReadException.class)
+    public ResponseEntity<AjaxResult> handleReadFailure(CockpitReadException exception) {
+        // 失败返回错误信封而不是空数组:空数组对前端意味着「确实没有真实关联」。
+        LOG.error("Device facility relation read failed", exception);
+        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+                .body(AjaxResult.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), exception.getMessage()));
+    }
+}

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

@@ -0,0 +1,178 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.GasPipePoint;
+import com.zksy.base.domain.GasPipePointDeviceRel;
+import com.zksy.base.domain.ManholeBase;
+import com.zksy.base.domain.ManholeDeviceRel;
+import com.zksy.base.domain.PipeNetworkBase;
+import com.zksy.base.mapper.GasPipePointDeviceRelMapper;
+import com.zksy.base.mapper.ManholeDeviceRelMapper;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.GasPipePointService;
+import com.zksy.base.service.ManholeBaseService;
+import com.zksy.base.service.PipeNetworkBaseService;
+import com.zksy.cockpit.relation.CockpitDeviceFacilityRelationService;
+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.Collections;
+
+import static org.hamcrest.Matchers.nullValue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 设备 → 设施关联端点(`/api/cockpit/v1/device-facility-relations`)契约测试。
+ *
+ * <p>锁四件事:
+ * <ol>
+ *   <li>只认真实主键:窨井回 manholeId、燃气管点回所属管段 networkId,解析不到就跳过并记缺;</li>
+ *   <li>设备编码从 equipment_base 精确匹配,查不到时为 null(不拿名字/编码猜);</li>
+ *   <li>真无关联时是空数组(200),查询失败时是 500 错误信封——两者不能混为一谈;</li>
+ *   <li>输出顺序稳定,前端可直接 diff。</li>
+ * </ol>
+ */
+class CockpitDeviceFacilityRelationContractTest {
+    private static final String URL = "/api/cockpit/v1/device-facility-relations";
+
+    private ManholeDeviceRelMapper manholeRelationMapper;
+    private GasPipePointDeviceRelMapper gasPipePointRelationMapper;
+    private ManholeBaseService manholes;
+    private GasPipePointService gasPipePoints;
+    private PipeNetworkBaseService pipeNetworks;
+    private EquipmentBaseService equipments;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        manholeRelationMapper = mock(ManholeDeviceRelMapper.class);
+        gasPipePointRelationMapper = mock(GasPipePointDeviceRelMapper.class);
+        manholes = mock(ManholeBaseService.class);
+        gasPipePoints = mock(GasPipePointService.class);
+        pipeNetworks = mock(PipeNetworkBaseService.class);
+        equipments = mock(EquipmentBaseService.class);
+        CockpitDeviceFacilityRelationService service = new CockpitDeviceFacilityRelationService(
+                manholeRelationMapper, gasPipePointRelationMapper, manholes, gasPipePoints, pipeNetworks, equipments,
+                // 2026-10-22T02:00:00Z == 10:00:00+08:00
+                Clock.fixed(Instant.parse("2026-10-22T02:00:00Z"), ZoneOffset.UTC));
+        mockMvc = MockMvcBuilders.standaloneSetup(new CockpitDeviceFacilityRelationController(service)).build();
+    }
+
+    @Test
+    void relationsUseFacilityPrimaryKeysAndSkipUnresolvedRows() throws Exception {
+        when(manholeRelationMapper.selectList(any())).thenReturn(Arrays.asList(
+                manholeRelation("M-1", "EQ-1"),
+                manholeRelation("M-LEGACY-DEVICE-ID", "EQ-2"),   // 不是 manhole_base 主键 → 跳过
+                manholeRelation("M-1", "EQ-UNKNOWN")));           // 设备查不到 → deviceCode=null
+        when(gasPipePointRelationMapper.selectList(any())).thenReturn(Arrays.asList(
+                gasRelation(49L, "EQ-1"),
+                gasRelation(50L, "EQ-1"),                         // 管段主键悬空 → 跳过
+                gasRelation(51L, "EQ-1")));                       // 管点不存在 → 跳过
+        when(manholes.listByIds(any())).thenReturn(Collections.singletonList(manhole("M-1")));
+        when(equipments.listByIds(any())).thenReturn(Arrays.asList(equipment("EQ-1", "DEV-1")));
+        when(gasPipePoints.listByIds(any())).thenReturn(Arrays.asList(
+                gasPoint(49L, "NET-1"), gasPoint(50L, "NET-DANGLING")));
+        when(pipeNetworks.listByIds(any())).thenReturn(Collections.singletonList(network("NET-1")));
+
+        mockMvc.perform(get(URL))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.readOnly").value(true))
+                .andExpect(jsonPath("$.data.generatedAt").value("2026-10-22T10:00:00+08:00"))
+                .andExpect(jsonPath("$.data.relations.length()").value(3))
+                // 窨井用 manholeId,燃气管点用所属管段 networkId
+                .andExpect(jsonPath("$.data.relations[0].deviceCode").value("DEV-1"))
+                .andExpect(jsonPath("$.data.relations[0].facilityCode").value("M-1"))
+                .andExpect(jsonPath("$.data.relations[0].facilityKind").value("MANHOLE"))
+                .andExpect(jsonPath("$.data.relations[0].relationType").value("MANHOLE_DEVICE"))
+                // 设备查不到时如实回 null,不编造编码
+                .andExpect(jsonPath("$.data.relations[1].deviceId").value("EQ-UNKNOWN"))
+                .andExpect(jsonPath("$.data.relations[1].deviceCode").value(nullValue()))
+                .andExpect(jsonPath("$.data.relations[2].facilityCode").value("NET-1"))
+                .andExpect(jsonPath("$.data.relations[2].facilityKind").value("PIPE"))
+                .andExpect(jsonPath("$.data.relations[2].relationType").value("GAS_PIPE_POINT_DEVICE"))
+                .andExpect(jsonPath("$.data.statistics.manholeRelationRows").value(3))
+                .andExpect(jsonPath("$.data.statistics.gasPipePointRelationRows").value(3))
+                .andExpect(jsonPath("$.data.statistics.returnedRelationCount").value(3))
+                .andExpect(jsonPath("$.data.statistics.skippedManholeFacilityMissing").value(1))
+                .andExpect(jsonPath("$.data.statistics.skippedGasPipeNetworkMissing").value(2))
+                .andExpect(jsonPath("$.data.statistics.skippedDeviceIdMissing").value(0))
+                .andExpect(jsonPath("$.data.statistics.deviceCodeUnresolved").value(1));
+    }
+
+    @Test
+    void noRelationsIsEmptyArrayNotError() throws Exception {
+        when(manholeRelationMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(gasPipePointRelationMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        mockMvc.perform(get(URL))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                // 空数组 = 确实没有真实关联;前端据此走「同业邻近示意」降级
+                .andExpect(jsonPath("$.data.relations.length()").value(0))
+                .andExpect(jsonPath("$.data.statistics.returnedRelationCount").value(0));
+    }
+
+    @Test
+    void readFailureIsNotReportedAsEmptyCollection() throws Exception {
+        when(manholeRelationMapper.selectList(any())).thenThrow(new IllegalStateException("db down"));
+
+        mockMvc.perform(get(URL))
+                .andExpect(status().isInternalServerError())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.msg").value("device facility relation source unavailable"));
+    }
+
+    private static ManholeDeviceRel manholeRelation(String manholeId, String equipmentId) {
+        ManholeDeviceRel relation = new ManholeDeviceRel();
+        relation.setManholeId(manholeId);
+        relation.setEquipmentId(equipmentId);
+        relation.setDelFlag("0");
+        return relation;
+    }
+
+    private static GasPipePointDeviceRel gasRelation(Long pointId, String equipmentId) {
+        GasPipePointDeviceRel relation = new GasPipePointDeviceRel();
+        relation.setPointId(pointId);
+        relation.setEquipmentId(equipmentId);
+        relation.setDelFlag("0");
+        return relation;
+    }
+
+    private static ManholeBase manhole(String manholeId) {
+        ManholeBase manhole = new ManholeBase();
+        manhole.setManholeId(manholeId);
+        return manhole;
+    }
+
+    private static GasPipePoint gasPoint(Long pointId, String networkId) {
+        GasPipePoint point = new GasPipePoint();
+        point.setPointId(pointId);
+        point.setNetworkId(networkId);
+        return point;
+    }
+
+    private static PipeNetworkBase network(String networkId) {
+        PipeNetworkBase network = new PipeNetworkBase();
+        network.setNetworkId(networkId);
+        return network;
+    }
+
+    private static EquipmentBase equipment(String equipmentId, String equipmentCode) {
+        EquipmentBase equipment = new EquipmentBase();
+        equipment.setEquipmentId(equipmentId);
+        equipment.setEquipmentCode(equipmentCode);
+        return equipment;
+    }
+}

+ 130 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/relation/CockpitDeviceFacilityRelationResponse.java

@@ -0,0 +1,130 @@
+package com.zksy.cockpit.relation;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+import java.util.List;
+
+/**
+ * 设备 → 设施关联只读契约(免鉴权 cockpit v1:`GET /api/cockpit/v1/device-facility-relations`)。
+ *
+ * <p>数据来源(只读,不写库、不推导、不猜关联):
+ * <ul>
+ *   <li>窨井:{@code app_user.manhole_device_rel}(del_flag=0)→ {@code app_user.manhole_base.manhole_id};</li>
+ *   <li>燃气管点:{@code app_user.gas_pipe_point_device_rel}(del_flag=0)→ {@code app_user.gas_pipe_point.network_id}
+ *       → {@code app_user.pipe_network_base.network_id}(返回**管段**主键,因为前端挂载的是管线);</li>
+ *   <li>{@code deviceCode} 取 {@code app_user.equipment_base.equipment_code};查不到时为 null,只回退到
+ *       {@code deviceId}(关联表里的 equipment_id 原值),绝不用名字或编码去猜。</li>
+ * </ul>
+ *
+ * <p>降级语义(前端据此判定「真实关联」是否成立):
+ * <ul>
+ *   <li>关联行的设施主键在设施表里不存在/已删除 → 该条不返回,计入统计 {@code skipped*};</li>
+ *   <li>一条都取不到 → {@code relations} 为空数组(不是错误),前端按「同业邻近示意」降级;</li>
+ *   <li>查询本身失败 → HTTP 500 + {@code AjaxResult} 错误信封,不返回空数组冒充「无关联」。</li>
+ * </ul>
+ */
+public class CockpitDeviceFacilityRelationResponse {
+
+    /** 生成时间(Asia/Shanghai,ISO-8601 带偏移)。 */
+    private final String generatedAt;
+    /** 本端点恒为只读。 */
+    private final boolean readOnly;
+    /** 真实关联行;无关联时为空数组。 */
+    private final List<Relation> relations;
+    /** 口径统计:返回了几条、因何跳过几条。 */
+    private final Statistics statistics;
+
+    public CockpitDeviceFacilityRelationResponse(String generatedAt, boolean readOnly, List<Relation> relations,
+            Statistics statistics) {
+        this.generatedAt = generatedAt;
+        this.readOnly = readOnly;
+        this.relations = relations;
+        this.statistics = statistics;
+    }
+
+    public String getGeneratedAt() { return generatedAt; }
+
+    public boolean isReadOnly() { return readOnly; }
+
+    public List<Relation> getRelations() { return relations; }
+
+    public Statistics getStatistics() { return statistics; }
+
+    /** 一条设备 → 设施真实关联。 */
+    public static class Relation {
+        /** 设备主键(关联表 equipment_id 原值,始终非空)。 */
+        private final String deviceId;
+        /** 设备编码(equipment_base.equipment_code);查不到时为 null,但字段始终回显(契约稳定)。 */
+        @JsonInclude(JsonInclude.Include.ALWAYS)
+        private final String deviceCode;
+        /** 设施主键:窨井为 manholeId,燃气管点为所属管段 networkId。 */
+        private final String facilityCode;
+        /** 设施种类:MANHOLE / PIPE。 */
+        private final String facilityKind;
+        /** 关联类型:MANHOLE_DEVICE / GAS_PIPE_POINT_DEVICE。 */
+        private final String relationType;
+
+        public Relation(String deviceId, String deviceCode, String facilityCode, String facilityKind,
+                String relationType) {
+            this.deviceId = deviceId;
+            this.deviceCode = deviceCode;
+            this.facilityCode = facilityCode;
+            this.facilityKind = facilityKind;
+            this.relationType = relationType;
+        }
+
+        public String getDeviceId() { return deviceId; }
+
+        public String getDeviceCode() { return deviceCode; }
+
+        public String getFacilityCode() { return facilityCode; }
+
+        public String getFacilityKind() { return facilityKind; }
+
+        public String getRelationType() { return relationType; }
+    }
+
+    /** 口径统计:让前端和排查者能区分「没有关联」和「关联丢了」。 */
+    public static class Statistics {
+        /** manhole_device_rel 中 del_flag=0 的行数。 */
+        private final int manholeRelationRows;
+        /** gas_pipe_point_device_rel 中 del_flag=0 的行数。 */
+        private final int gasPipePointRelationRows;
+        /** 实际返回的关联条数。 */
+        private final int returnedRelationCount;
+        /** 因窨井主键在 manhole_base 里查不到(含已删除)而跳过的条数。 */
+        private final int skippedManholeFacilityMissing;
+        /** 因管点所属管段 networkId 在 pipe_network_base 里查不到(含已删除)而跳过的条数。 */
+        private final int skippedGasPipeNetworkMissing;
+        /** 关联行设备主键为空、无法定位设备而跳过的条数。 */
+        private final int skippedDeviceIdMissing;
+        /** 设备主键在 equipment_base 里查不到、deviceCode 只能为 null 的条数。 */
+        private final int deviceCodeUnresolved;
+
+        public Statistics(int manholeRelationRows, int gasPipePointRelationRows, int returnedRelationCount,
+                int skippedManholeFacilityMissing, int skippedGasPipeNetworkMissing, int skippedDeviceIdMissing,
+                int deviceCodeUnresolved) {
+            this.manholeRelationRows = manholeRelationRows;
+            this.gasPipePointRelationRows = gasPipePointRelationRows;
+            this.returnedRelationCount = returnedRelationCount;
+            this.skippedManholeFacilityMissing = skippedManholeFacilityMissing;
+            this.skippedGasPipeNetworkMissing = skippedGasPipeNetworkMissing;
+            this.skippedDeviceIdMissing = skippedDeviceIdMissing;
+            this.deviceCodeUnresolved = deviceCodeUnresolved;
+        }
+
+        public int getManholeRelationRows() { return manholeRelationRows; }
+
+        public int getGasPipePointRelationRows() { return gasPipePointRelationRows; }
+
+        public int getReturnedRelationCount() { return returnedRelationCount; }
+
+        public int getSkippedManholeFacilityMissing() { return skippedManholeFacilityMissing; }
+
+        public int getSkippedGasPipeNetworkMissing() { return skippedGasPipeNetworkMissing; }
+
+        public int getSkippedDeviceIdMissing() { return skippedDeviceIdMissing; }
+
+        public int getDeviceCodeUnresolved() { return deviceCodeUnresolved; }
+    }
+}

+ 249 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/relation/CockpitDeviceFacilityRelationService.java

@@ -0,0 +1,249 @@
+package com.zksy.cockpit.relation;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.zksy.base.domain.EquipmentBase;
+import com.zksy.base.domain.GasPipePoint;
+import com.zksy.base.domain.GasPipePointDeviceRel;
+import com.zksy.base.domain.ManholeBase;
+import com.zksy.base.domain.ManholeDeviceRel;
+import com.zksy.base.domain.PipeNetworkBase;
+import com.zksy.base.mapper.GasPipePointDeviceRelMapper;
+import com.zksy.base.mapper.ManholeDeviceRelMapper;
+import com.zksy.base.service.EquipmentBaseService;
+import com.zksy.base.service.GasPipePointService;
+import com.zksy.base.service.ManholeBaseService;
+import com.zksy.base.service.PipeNetworkBaseService;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.relation.CockpitDeviceFacilityRelationResponse.Relation;
+import com.zksy.cockpit.relation.CockpitDeviceFacilityRelationResponse.Statistics;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.time.Clock;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 设备 → 设施关联只读服务。
+ *
+ * <p><b>数据来源</b>(只读查询,不写库、不推导):
+ * <ol>
+ *   <li>窨井:{@code manhole_device_rel.manhole_id} → {@code manhole_base.manhole_id},设施主键即 manholeId;</li>
+ *   <li>燃气管点:{@code gas_pipe_point_device_rel.point_id} → {@code gas_pipe_point.network_id}
+ *       → {@code pipe_network_base.network_id},返回**管段**主键(前端挂的是管线);</li>
+ *   <li>设备编码:{@code equipment_base.equipment_code}(按 rel.equipment_id 精确匹配主键)。</li>
+ * </ol>
+ *
+ * <p><b>不编造</b>:只用上表主键做等值解析;解析不到就跳过该条并在 {@link Statistics} 里记缺,
+ * 既不按名称/编码猜设施,也不把查不到设备的行填个假 deviceCode。
+ *
+ * <p><b>降级语义</b>:一条都解析不到 → {@code relations} 为空数组(正常响应);查询失败 → 抛
+ * {@link CockpitReadException},由控制器转 500,不把失败伪装成「无关联」。
+ */
+@Service
+public class CockpitDeviceFacilityRelationService {
+    private static final Logger LOG = LoggerFactory.getLogger(CockpitDeviceFacilityRelationService.class);
+
+    private static final String SCHEMA_TIME_ZONE = "Asia/Shanghai";
+    private static final String FACILITY_KIND_MANHOLE = "MANHOLE";
+    private static final String FACILITY_KIND_PIPE = "PIPE";
+    private static final String RELATION_TYPE_MANHOLE_DEVICE = "MANHOLE_DEVICE";
+    private static final String RELATION_TYPE_GAS_PIPE_POINT_DEVICE = "GAS_PIPE_POINT_DEVICE";
+
+    private final ManholeDeviceRelMapper manholeRelationMapper;
+    private final GasPipePointDeviceRelMapper gasPipePointRelationMapper;
+    private final ManholeBaseService manholes;
+    private final GasPipePointService gasPipePoints;
+    private final PipeNetworkBaseService pipeNetworks;
+    private final EquipmentBaseService equipments;
+    private final Clock clock;
+
+    public CockpitDeviceFacilityRelationService(ManholeDeviceRelMapper manholeRelationMapper,
+            GasPipePointDeviceRelMapper gasPipePointRelationMapper, ManholeBaseService manholes,
+            GasPipePointService gasPipePoints, PipeNetworkBaseService pipeNetworks, EquipmentBaseService equipments,
+            Clock clock) {
+        this.manholeRelationMapper = manholeRelationMapper;
+        this.gasPipePointRelationMapper = gasPipePointRelationMapper;
+        this.manholes = manholes;
+        this.gasPipePoints = gasPipePoints;
+        this.pipeNetworks = pipeNetworks;
+        this.equipments = equipments;
+        this.clock = clock;
+    }
+
+    public CockpitDeviceFacilityRelationResponse load() {
+        try {
+            // del_flag 显式写进条件:即使全局逻辑删除配置漂移,也只会读到未删除的关联行。
+            List<ManholeDeviceRel> manholeRelations = manholeRelationMapper.selectList(
+                    new LambdaQueryWrapper<ManholeDeviceRel>().eq(ManholeDeviceRel::getDelFlag, "0"));
+            List<GasPipePointDeviceRel> gasRelations = gasPipePointRelationMapper.selectList(
+                    new LambdaQueryWrapper<GasPipePointDeviceRel>().eq(GasPipePointDeviceRel::getDelFlag, "0"));
+
+            Map<String, String> deviceCodeById = loadDeviceCodes(manholeRelations, gasRelations);
+            Set<String> existingManholeIds = loadExistingManholeIds(manholeRelations);
+            Map<Long, String> networkIdByPointId = loadNetworkIdByPointId(gasRelations);
+            Set<String> existingNetworkIds = loadExistingNetworkIds(networkIdByPointId.values());
+
+            List<Relation> relations = new ArrayList<>();
+            int skippedManholeMissing = 0;
+            int skippedGasNetworkMissing = 0;
+            int skippedDeviceMissing = 0;
+            int deviceCodeUnresolved = 0;
+
+            for (ManholeDeviceRel relation : manholeRelations) {
+                String deviceId = trimToNull(relation.getEquipmentId());
+                String manholeId = trimToNull(relation.getManholeId());
+                if (manholeId == null || !existingManholeIds.contains(manholeId)) {
+                    skippedManholeMissing++;
+                    continue;
+                }
+                if (deviceId == null) {
+                    skippedDeviceMissing++;
+                    continue;
+                }
+                String deviceCode = trimToNull(deviceCodeById.get(deviceId));
+                if (deviceCode == null) {
+                    deviceCodeUnresolved++;
+                }
+                relations.add(new Relation(deviceId, deviceCode, manholeId, FACILITY_KIND_MANHOLE,
+                        RELATION_TYPE_MANHOLE_DEVICE));
+            }
+
+            for (GasPipePointDeviceRel relation : gasRelations) {
+                String deviceId = trimToNull(relation.getEquipmentId());
+                String networkId = trimToNull(networkIdByPointId.get(relation.getPointId()));
+                if (networkId == null || !existingNetworkIds.contains(networkId)) {
+                    skippedGasNetworkMissing++;
+                    continue;
+                }
+                if (deviceId == null) {
+                    skippedDeviceMissing++;
+                    continue;
+                }
+                String deviceCode = trimToNull(deviceCodeById.get(deviceId));
+                if (deviceCode == null) {
+                    deviceCodeUnresolved++;
+                }
+                relations.add(new Relation(deviceId, deviceCode, networkId, FACILITY_KIND_PIPE,
+                        RELATION_TYPE_GAS_PIPE_POINT_DEVICE));
+            }
+
+            // 顺序固定,便于前端 diff / 排查时不因 SQL 返回顺序抖动。
+            relations.sort(Comparator.comparing(Relation::getFacilityKind)
+                    .thenComparing(Relation::getFacilityCode)
+                    .thenComparing(Relation::getDeviceId));
+
+            Statistics statistics = new Statistics(manholeRelations.size(), gasRelations.size(), relations.size(),
+                    skippedManholeMissing, skippedGasNetworkMissing, skippedDeviceMissing, deviceCodeUnresolved);
+            String generatedAt = DateTimeFormatter.ISO_OFFSET_DATE_TIME
+                    .format(clock.instant().atZone(ZoneId.of(SCHEMA_TIME_ZONE)));
+            return new CockpitDeviceFacilityRelationResponse(generatedAt, true, relations, statistics);
+        } catch (RuntimeException exception) {
+            // 失败不能退化成空数组:空数组在前端代表「确实没有真实关联」。
+            LOG.error("Device facility relation source read failed", exception);
+            throw new CockpitReadException("device facility relation source unavailable");
+        }
+    }
+
+    /** 设备主键 → 设备编码;只做等值匹配,不按名称/编码猜。 */
+    private Map<String, String> loadDeviceCodes(List<ManholeDeviceRel> manholeRelations,
+            List<GasPipePointDeviceRel> gasRelations) {
+        Set<String> deviceIds = new LinkedHashSet<>();
+        for (ManholeDeviceRel relation : manholeRelations) {
+            addIfPresent(deviceIds, relation.getEquipmentId());
+        }
+        for (GasPipePointDeviceRel relation : gasRelations) {
+            addIfPresent(deviceIds, relation.getEquipmentId());
+        }
+        Map<String, String> deviceCodeById = new HashMap<>();
+        if (deviceIds.isEmpty()) {
+            return deviceCodeById;
+        }
+        for (EquipmentBase equipment : equipments.listByIds(deviceIds)) {
+            if (equipment != null && equipment.getEquipmentId() != null) {
+                deviceCodeById.put(equipment.getEquipmentId(), equipment.getEquipmentCode());
+            }
+        }
+        return deviceCodeById;
+    }
+
+    /** 只认 manhole_base 里真实存在(未删除)的窨井主键。 */
+    private Set<String> loadExistingManholeIds(List<ManholeDeviceRel> manholeRelations) {
+        Set<String> manholeIds = new LinkedHashSet<>();
+        for (ManholeDeviceRel relation : manholeRelations) {
+            addIfPresent(manholeIds, relation.getManholeId());
+        }
+        Set<String> existing = new HashSet<>();
+        if (manholeIds.isEmpty()) {
+            return existing;
+        }
+        for (ManholeBase manhole : manholes.listByIds(manholeIds)) {
+            if (manhole != null && manhole.getManholeId() != null) {
+                existing.add(manhole.getManholeId());
+            }
+        }
+        return existing;
+    }
+
+    /** 管点主键 → 所属管段 networkId(管点表自身字段,原样读出)。 */
+    private Map<Long, String> loadNetworkIdByPointId(List<GasPipePointDeviceRel> gasRelations) {
+        Set<Long> pointIds = new LinkedHashSet<>();
+        for (GasPipePointDeviceRel relation : gasRelations) {
+            if (relation.getPointId() != null) {
+                pointIds.add(relation.getPointId());
+            }
+        }
+        Map<Long, String> networkIdByPointId = new HashMap<>();
+        if (pointIds.isEmpty()) {
+            return networkIdByPointId;
+        }
+        for (GasPipePoint point : gasPipePoints.listByIds(pointIds)) {
+            if (point != null && point.getPointId() != null) {
+                networkIdByPointId.put(point.getPointId(), point.getNetworkId());
+            }
+        }
+        return networkIdByPointId;
+    }
+
+    /** 只认 pipe_network_base 里真实存在(未删除)的管段主键。 */
+    private Set<String> loadExistingNetworkIds(Iterable<String> networkIds) {
+        Set<String> candidates = new LinkedHashSet<>();
+        for (String networkId : networkIds) {
+            addIfPresent(candidates, networkId);
+        }
+        Set<String> existing = new HashSet<>();
+        if (candidates.isEmpty()) {
+            return existing;
+        }
+        for (PipeNetworkBase network : pipeNetworks.listByIds(candidates)) {
+            if (network != null && network.getNetworkId() != null) {
+                existing.add(network.getNetworkId());
+            }
+        }
+        return existing;
+    }
+
+    private static void addIfPresent(Set<String> target, String value) {
+        String trimmed = trimToNull(value);
+        if (trimmed != null) {
+            target.add(trimmed);
+        }
+    }
+
+    private static String trimToNull(String value) {
+        if (value == null) {
+            return null;
+        }
+        String trimmed = value.trim();
+        return trimmed.isEmpty() ? null : trimmed;
+    }
+}