소스 검색

feat(cockpit): add read-only facility geometry model

Kazerin 1 주 전
부모
커밋
33ca0b67b1

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

@@ -0,0 +1,49 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.geometry.CockpitFacilityGeometryService;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.utils.uuid.IdUtils;
+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;
+
+import javax.servlet.http.HttpServletRequest;
+import java.time.Clock;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+
+@RestController
+public class CockpitFacilityGeometryController {
+    private final CockpitFacilityGeometryService service;
+    private final Clock clock;
+
+    public CockpitFacilityGeometryController(CockpitFacilityGeometryService service, Clock clock) {
+        this.service = service;
+        this.clock = clock;
+    }
+
+    @GetMapping("/api/cockpit/v1/infrastructure/geometry")
+    public ResponseEntity<AjaxResult> geometry() {
+        return ResponseEntity.ok(AjaxResult.success(service.load()));
+    }
+
+    @ExceptionHandler(RuntimeException.class)
+    public ResponseEntity<AjaxResult> handleReadFailure(HttpServletRequest request, RuntimeException exception) {
+        // Includes transaction begin/commit failures thrown outside service.load()'s body.
+        org.slf4j.LoggerFactory.getLogger(CockpitFacilityGeometryController.class)
+                .error("Facility geometry read failed", exception);
+        String requestId = request.getHeader("X-Request-Id");
+        if (requestId == null || requestId.trim().isEmpty()) {
+            requestId = IdUtils.fastSimpleUUID();
+        }
+        CockpitErrorPayload payload = new CockpitErrorPayload(CockpitErrorCategory.SERVER_ERROR,
+                "facility geometry source unavailable", requestId,
+                DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(clock.instant().atZone(ZoneId.of("Asia/Shanghai"))));
+        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+                .body(new AjaxResult(500, payload.getMessage(), payload));
+    }
+}

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

@@ -0,0 +1,157 @@
+package com.zksy.web.controller.cockpit;
+
+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.geometry.CockpitFacilityGeometryResponse;
+import com.zksy.cockpit.geometry.CockpitFacilityGeometryResponse.Feature;
+import com.zksy.cockpit.geometry.CockpitFacilityGeometryResponse.Properties;
+import com.zksy.cockpit.geometry.CockpitFacilityGeometryService;
+import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
+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.math.BigDecimal;
+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.hasSize;
+import static org.hamcrest.Matchers.nullValue;
+import static org.mockito.Mockito.*;
+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.*;
+
+/** Standalone MVC tests lock JSON/routing, not the deployed authentication chain. */
+class CockpitFacilityGeometryControllerTest {
+    private static final String URL = "/api/cockpit/v1/infrastructure/geometry";
+    private CockpitFacilityGeometryService service;
+    private MockMvc mvc;
+
+    @BeforeEach
+    void setUp() {
+        service = mock(CockpitFacilityGeometryService.class);
+        mvc = MockMvcBuilders.standaloneSetup(new CockpitFacilityGeometryController(service,
+                Clock.fixed(Instant.parse("2026-10-12T02:00:00Z"), ZoneOffset.UTC))).build();
+    }
+
+    @Test
+    void returnsGeoJsonInsideTheExistingAjaxEnvelopeWithoutRequiringAnAuthorizationHeader() throws Exception {
+        PipeNetworkBaseService networks = mock(PipeNetworkBaseService.class);
+        GasPipePointService points = mock(GasPipePointService.class);
+        ManholeBaseService manholes = mock(ManholeBaseService.class);
+        when(networks.list()).thenReturn(Collections.emptyList());
+        when(points.list()).thenReturn(Collections.emptyList());
+        when(manholes.list()).thenReturn(Collections.emptyList());
+        CockpitFacilityGeometryResponse response = new CockpitFacilityGeometryService(networks, points, manholes,
+                Clock.fixed(Instant.parse("2026-10-12T02:00:00Z"), ZoneOffset.UTC)).load();
+        when(service.load()).thenReturn(response);
+        mvc.perform(get(URL))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.type").value("FeatureCollection"))
+                .andExpect(jsonPath("$.data.features", hasSize(0)))
+                .andExpect(jsonPath("$.data.coverage", hasSize(9)))
+                .andExpect(jsonPath("$.data.coverage[4].state").value("UNAVAILABLE"));
+    }
+
+    @Test
+    void invalidGeometryIsExplicitJsonNullWithRawCoordinatesAndSameValidationShape() throws Exception {
+        Properties properties = new Properties("gas_pipe_point", "1", "G1", "阀门", "GAS", "GAS_PIPE_POINT",
+                null, "阀门", null, Collections.singletonList(Arrays.asList(null, new BigDecimal("28.4"))),
+                CockpitCoordinateValidator.validate(null, new BigDecimal("28.4"), true),
+                Collections.singletonList(CockpitCoordinateValidator.validate(null, new BigDecimal("28.4"), true)));
+        when(service.load()).thenReturn(new CockpitFacilityGeometryResponse("2026-10-12T10:00:00+08:00",
+                Collections.singletonList(new Feature("gas_pipe_point:1", null, properties)), Collections.emptyList()));
+        mvc.perform(get(URL))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.features[0].type").value("Feature"))
+                .andExpect(jsonPath("$.data.features[0].geometry").value(nullValue()))
+                .andExpect(jsonPath("$.data.features[0].properties.networkId").value(nullValue()))
+                .andExpect(jsonPath("$.data.features[0].properties.pointType").value("阀门"))
+                .andExpect(jsonPath("$.data.features[0].properties.sourceCoordinates[0][0]").value(nullValue()))
+                .andExpect(jsonPath("$.data.features[0].properties.coordinateValidation.state").value("INVALID"))
+                .andExpect(jsonPath("$.data.features[0].properties.coordinateValidation.reason").value("MISSING_LONGITUDE"))
+                .andExpect(jsonPath("$.data.features[0].properties.coordinateValidation.renderable").value(false))
+                .andExpect(jsonPath("$.data.features[0].properties.coordinateValidation.crs").value("WGS84"));
+    }
+
+    @Test
+    void sourceFailureReturns500AndStableCategoryRatherThanSuccess() throws Exception {
+        when(service.load()).thenThrow(new CockpitReadException("private database details"));
+        mvc.perform(get(URL).header("X-Request-Id", "geometry-failure"))
+                .andExpect(status().isInternalServerError())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"))
+                .andExpect(jsonPath("$.data.message").value("facility geometry source unavailable"))
+                .andExpect(jsonPath("$.data.requestId").value("geometry-failure"));
+    }
+
+    @Test
+    void serializesActualServicePointAndLineCoordinatesInLongitudeLatitudeOrder() throws Exception {
+        PipeNetworkBaseService networks = mock(PipeNetworkBaseService.class);
+        GasPipePointService points = mock(GasPipePointService.class);
+        ManholeBaseService manholes = mock(ManholeBaseService.class);
+        com.zksy.base.domain.PipeNetworkBase line = new com.zksy.base.domain.PipeNetworkBase();
+        line.setNetworkId("gas-line");
+        line.setNetworkType("0");
+        line.setStartLongitude(new BigDecimal("110.4"));
+        line.setStartLatitude(new BigDecimal("28.4"));
+        line.setEndLongitude(new BigDecimal("110.6"));
+        line.setEndLatitude(new BigDecimal("28.6"));
+        com.zksy.base.domain.GasPipePoint point = new com.zksy.base.domain.GasPipePoint();
+        point.setPointId(1L);
+        point.setPointType("三通");
+        point.setNetworkId("gas-line");
+        point.setLongitude(new BigDecimal("110.5"));
+        point.setLatitude(new BigDecimal("28.5"));
+        when(networks.list()).thenReturn(Collections.singletonList(line));
+        when(points.list()).thenReturn(Collections.singletonList(point));
+        when(manholes.list()).thenReturn(Collections.emptyList());
+        CockpitFacilityGeometryService actual = new CockpitFacilityGeometryService(networks, points, manholes,
+                Clock.fixed(Instant.parse("2026-10-12T02:00:00Z"), ZoneOffset.UTC));
+        mvc = MockMvcBuilders.standaloneSetup(new CockpitFacilityGeometryController(actual,
+                Clock.fixed(Instant.parse("2026-10-12T02:00:00Z"), ZoneOffset.UTC))).build();
+        mvc.perform(get(URL))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.features", hasSize(2)))
+                .andExpect(jsonPath("$.data.features[0].geometry.type").value("LineString"))
+                .andExpect(jsonPath("$.data.features[0].geometry.coordinates", hasSize(2)))
+                .andExpect(jsonPath("$.data.features[0].geometry.coordinates[0][0]").value(110.4))
+                .andExpect(jsonPath("$.data.features[0].geometry.coordinates[0][1]").value(28.4))
+                .andExpect(jsonPath("$.data.features[0].geometry.coordinates[1][0]").value(110.6))
+                .andExpect(jsonPath("$.data.features[0].geometry.coordinates[1][1]").value(28.6))
+                .andExpect(jsonPath("$.data.features[1].geometry.type").value("Point"))
+                .andExpect(jsonPath("$.data.features[1].geometry.coordinates", hasSize(2)))
+                .andExpect(jsonPath("$.data.features[1].geometry.coordinates[0]").value(110.5))
+                .andExpect(jsonPath("$.data.features[1].geometry.coordinates[1]").value(28.5))
+                .andExpect(jsonPath("$.data.features[1].properties.pointType").value("三通"))
+                .andExpect(jsonPath("$.data.features[1].properties.networkId").value("gas-line"))
+                .andExpect(jsonPath("$.data.features[1].properties.coordinateValidation.state").value("UNVERIFIED"))
+                .andExpect(jsonPath("$.data.features[1].properties.coordinateValidation.renderable").value(false));
+    }
+
+    @Test
+    void transactionProxyFailureIsAlsoMappedToSanitized500() throws Exception {
+        when(service.load()).thenThrow(new org.springframework.transaction.CannotCreateTransactionException(
+                "private connection detail"));
+        mvc.perform(get(URL).header("X-Request-Id", "transaction-failure"))
+                .andExpect(status().isInternalServerError())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"))
+                .andExpect(jsonPath("$.data.message").value("facility geometry source unavailable"))
+                .andExpect(jsonPath("$.data.requestId").value("transaction-failure"));
+    }
+
+    @Test
+    void postIsNotAWriteEntryPoint() throws Exception {
+        mvc.perform(post(URL)).andExpect(status().isMethodNotAllowed());
+        verifyNoInteractions(service);
+    }
+}

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

@@ -0,0 +1,214 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.base.domain.GasPipePoint;
+import com.zksy.base.domain.ManholeBase;
+import com.zksy.base.domain.PipeNetworkBase;
+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.geometry.CockpitFacilityGeometryResponse;
+import com.zksy.cockpit.geometry.CockpitFacilityGeometryResponse.Coverage;
+import com.zksy.cockpit.geometry.CockpitFacilityGeometryResponse.Feature;
+import com.zksy.cockpit.geometry.CockpitFacilityGeometryService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class CockpitFacilityGeometryServiceTest {
+    private PipeNetworkBaseService networks;
+    private GasPipePointService points;
+    private ManholeBaseService manholes;
+    private CockpitFacilityGeometryService service;
+
+    @BeforeEach
+    void setUp() {
+        networks = mock(PipeNetworkBaseService.class);
+        points = mock(GasPipePointService.class);
+        manholes = mock(ManholeBaseService.class);
+        when(networks.list()).thenReturn(Collections.emptyList());
+        when(points.list()).thenReturn(Collections.emptyList());
+        when(manholes.list()).thenReturn(Collections.emptyList());
+        service = new CockpitFacilityGeometryService(networks, points, manholes,
+                Clock.fixed(Instant.parse("2026-10-12T02:00:00Z"), ZoneOffset.UTC));
+    }
+
+    @Test
+    void readsAllThreeIndustriesAsActualTwoEndpointLinesWithoutInventingTopology() {
+        when(networks.list()).thenReturn(Arrays.asList(network("same-id", "0"), network("water", "2"),
+                network("drain", "1"), network("unknown", "3")));
+        GasPipePoint point = point(1L, "三通");
+        point.setNetworkId("same-id");
+        when(points.list()).thenReturn(Collections.singletonList(point));
+        ManholeBase manhole = new ManholeBase();
+        manhole.setManholeId("same-id");
+        manhole.setLongitude(new BigDecimal("110.5"));
+        manhole.setLatitude(new BigDecimal("28.5"));
+        when(manholes.list()).thenReturn(Collections.singletonList(manhole));
+
+        CockpitFacilityGeometryResponse response = service.load();
+        assertEquals("FeatureCollection", response.getType());
+        assertEquals("2026-10-12T10:00:00+08:00", response.getGeneratedAt());
+        assertEquals(6, response.getFeatures().size());
+        assertEquals(6, response.getFeatures().stream().map(Feature::getId).distinct().count());
+        assertEquals(Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "UNKNOWN", "GAS", "MANHOLE"),
+                response.getFeatures().stream().map(item -> item.getProperties().getIndustry()).collect(Collectors.toList()));
+        Feature gas = response.getFeatures().get(0);
+        assertEquals("LineString", gas.getGeometry().getType());
+        assertEquals(Arrays.asList(Arrays.asList(new BigDecimal("110.4"), new BigDecimal("28.4")),
+                Arrays.asList(new BigDecimal("110.6"), new BigDecimal("28.6"))), gas.getGeometry().getCoordinates());
+        assertEquals("GAS_PIPE", gas.getProperties().getTypeCode());
+        assertEquals("PIPE_UNCLASSIFIED", response.getFeatures().get(3).getProperties().getTypeCode());
+        assertEquals("3", response.getFeatures().get(3).getProperties().getNetworkType());
+        Feature component = response.getFeatures().get(4);
+        assertEquals("Point", component.getGeometry().getType());
+        assertEquals("三通", component.getProperties().getPointType());
+        assertEquals(gas.getProperties().getNetworkId(), component.getProperties().getNetworkId());
+        assertEquals(1, coverage(response, "UNKNOWN", "LineString").getRecordCount());
+        verify(networks).list();
+        verify(points).list();
+        verify(manholes).list();
+        verifyNoMoreInteractions(networks, points, manholes);
+    }
+
+    @Test
+    void preservesEveryComponentTypeIncludingUnknownAndMissingTypesWithoutFabricatingNodes() {
+        List<String> types = Arrays.asList("阀门", "调压", "计量", "阴极保护", "弯头", "三通", "其它", "未知原始值", null);
+        List<GasPipePoint> records = new java.util.ArrayList<>();
+        for (int i = 0; i < types.size(); i++) {
+            records.add(point((long) i, types.get(i)));
+        }
+        when(points.list()).thenReturn(records);
+        CockpitFacilityGeometryResponse response = service.load();
+        assertEquals(types, response.getFeatures().stream()
+                .map(item -> item.getProperties().getPointType()).collect(Collectors.toList()));
+        assertEquals(types.size(), response.getFeatures().size());
+        assertTrue(response.getFeatures().stream().allMatch(item -> item.getProperties().getNetworkId() == null));
+    }
+
+    @ParameterizedTest
+    @CsvSource({",28.4,MISSING_LONGITUDE", "110.4,,MISSING_LATITUDE", "181,28.4,LONGITUDE_OUT_OF_RANGE",
+            "-181,28.4,LONGITUDE_OUT_OF_RANGE", "110.4,91,LATITUDE_OUT_OF_RANGE",
+            "110.4,-91,LATITUDE_OUT_OF_RANGE", "0,0,ZERO_COORDINATE"})
+    void invalidPointsStayInTheCollectionWithNullGeometryAndSpecificReason(String longitude, String latitude,
+            String reason) {
+        GasPipePoint point = point(1L, "阀门");
+        point.setLongitude(decimal(longitude));
+        point.setLatitude(decimal(latitude));
+        when(points.list()).thenReturn(Collections.singletonList(point));
+        Feature feature = service.load().getFeatures().get(0);
+        assertNull(feature.getGeometry());
+        assertEquals("INVALID", feature.getProperties().getCoordinateValidation().getState());
+        assertEquals(reason, feature.getProperties().getCoordinateValidation().getReason());
+        assertFalse(feature.getProperties().getCoordinateValidation().isRenderable());
+        assertEquals(Arrays.asList(decimal(longitude), decimal(latitude)),
+                feature.getProperties().getSourceCoordinates().get(0));
+    }
+
+    @Test
+    void invalidEndIsNotMaskedByAnUnverifiedStartAndEntireLineHasNullGeometry() {
+        PipeNetworkBase line = network("bad-end", "1");
+        line.setEndLatitude(null);
+        when(networks.list()).thenReturn(Collections.singletonList(line));
+        CockpitFacilityGeometryResponse response = service.load();
+        Feature feature = response.getFeatures().get(0);
+        assertNull(feature.getGeometry());
+        assertEquals("MISSING_LATITUDE", feature.getProperties().getCoordinateValidation().getReason());
+        assertEquals("UNVERIFIED", feature.getProperties().getCoordinateValidations().get(0).getState());
+        assertEquals("INVALID", feature.getProperties().getCoordinateValidations().get(1).getState());
+        assertEquals(1, coverage(response, "DRAINAGE", "LineString").getRecordCount());
+        assertEquals(0, coverage(response, "DRAINAGE", "LineString").getGeometryCount());
+    }
+
+    @Test
+    void numericBoundsDoNotGrantCoordinateVerificationForAnySource() {
+        when(networks.list()).thenReturn(Collections.singletonList(network("line", "0")));
+        when(points.list()).thenReturn(Collections.singletonList(point(1L, "阀门")));
+        ManholeBase manhole = new ManholeBase();
+        manhole.setManholeId("well");
+        manhole.setLongitude(new BigDecimal("-180"));
+        manhole.setLatitude(new BigDecimal("90"));
+        when(manholes.list()).thenReturn(Collections.singletonList(manhole));
+        CockpitFacilityGeometryResponse response = service.load();
+        for (Feature feature : response.getFeatures()) {
+            assertNotNull(feature.getGeometry());
+            assertEquals("UNVERIFIED", feature.getProperties().getCoordinateValidation().getState());
+            assertEquals("UNVERIFIED_COORDINATE", feature.getProperties().getCoordinateValidation().getReason());
+            assertEquals("WGS84", feature.getProperties().getCoordinateValidation().getCrs());
+            assertFalse(feature.getProperties().getCoordinateValidation().isRenderable());
+        }
+        assertTrue(response.getCoverage().stream().allMatch(item -> item.getRenderableCount() == 0));
+    }
+
+    @Test
+    void emptySourcesAreDistinguishedFromUnsupportedIndustryGeometry() {
+        CockpitFacilityGeometryResponse response = service.load();
+        assertTrue(response.getFeatures().isEmpty());
+        assertEquals("EMPTY", coverage(response, "GAS", "Point").getState());
+        assertEquals("EMPTY", coverage(response, "WATER_SUPPLY", "LineString").getState());
+        assertEquals("TWO_ENDPOINTS_ONLY", coverage(response, "WATER_SUPPLY", "LineString").getReason());
+        assertEquals("UNAVAILABLE", coverage(response, "WATER_SUPPLY", "Point").getState());
+        assertEquals("NO_COMPONENT_SOURCE", coverage(response, "WATER_SUPPLY", "Point").getReason());
+        assertEquals("NO_TYPED_COMPONENT_SOURCE", coverage(response, "DRAINAGE", "Point").getReason());
+        assertEquals("NO_COMPONENT_TYPE", coverage(response, "MANHOLE", "Point").getReason());
+        assertEquals("NO_PIPELINE_SOURCE", coverage(response, "MANHOLE", "LineString").getReason());
+    }
+
+    @Test
+    void aFailedSourceNeverProducesAnEmptyOrPartialSuccess() {
+        when(networks.list()).thenReturn(Collections.singletonList(network("line", "0")));
+        when(points.list()).thenThrow(new IllegalStateException("private database detail"));
+        CockpitReadException failure = assertThrows(CockpitReadException.class, service::load);
+        assertEquals("facility geometry source unavailable", failure.getMessage());
+        verifyNoInteractions(manholes);
+    }
+
+    @Test
+    void aNullSourceIsAReadFailureRatherThanASuccessfulEmptyCollection() {
+        when(manholes.list()).thenReturn(null);
+        assertThrows(CockpitReadException.class, service::load);
+    }
+
+    private static Coverage coverage(CockpitFacilityGeometryResponse response, String industry, String geometry) {
+        return response.getCoverage().stream().filter(item -> industry.equals(item.getIndustry())
+                && geometry.equals(item.getGeometryType())).findFirst().orElseThrow();
+    }
+
+    private static PipeNetworkBase network(String id, String type) {
+        PipeNetworkBase network = new PipeNetworkBase();
+        network.setNetworkId(id);
+        network.setNetworkCode("code-" + id);
+        network.setNetworkType(type);
+        network.setStartLongitude(new BigDecimal("110.4"));
+        network.setStartLatitude(new BigDecimal("28.4"));
+        network.setEndLongitude(new BigDecimal("110.6"));
+        network.setEndLatitude(new BigDecimal("28.6"));
+        return network;
+    }
+
+    private static GasPipePoint point(Long id, String type) {
+        GasPipePoint point = new GasPipePoint();
+        point.setPointId(id);
+        point.setPointType(type);
+        point.setLongitude(new BigDecimal("110.4"));
+        point.setLatitude(new BigDecimal("28.4"));
+        return point;
+    }
+
+    private static BigDecimal decimal(String value) {
+        return value == null ? null : new BigDecimal(value);
+    }
+}

+ 76 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/geometry/CockpitFacilityGeometryResponse.java

@@ -0,0 +1,76 @@
+package com.zksy.cockpit.geometry;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/** GeoJSON with explicit source coverage and coordinate eligibility as foreign members. */
+@Getter
+public class CockpitFacilityGeometryResponse {
+    private final String type = "FeatureCollection";
+    private final String generatedAt;
+    private final List<Feature> features;
+    private final List<Coverage> coverage;
+
+    public CockpitFacilityGeometryResponse(String generatedAt, List<Feature> features, List<Coverage> coverage) {
+        this.generatedAt = generatedAt;
+        this.features = features;
+        this.coverage = coverage;
+    }
+
+    @Getter
+    @AllArgsConstructor
+    public static class Feature {
+        private final String type = "Feature";
+        private final String id;
+        @JsonInclude(JsonInclude.Include.ALWAYS)
+        private final Geometry geometry;
+        private final Properties properties;
+    }
+
+    @Getter
+    @AllArgsConstructor
+    public static class Geometry {
+        private final String type;
+        // Point: [lon, lat]; LineString: [[lon, lat], [lon, lat]].
+        private final List<?> coordinates;
+    }
+
+    @Getter
+    @AllArgsConstructor
+    public static class Properties {
+        private final String source;
+        private final String sourceId;
+        private final String code;
+        private final String name;
+        private final String industry;
+        private final String typeCode;
+        private final String networkType;
+        @JsonInclude(JsonInclude.Include.ALWAYS)
+        private final String pointType;
+        @JsonInclude(JsonInclude.Include.ALWAYS)
+        private final String networkId;
+        // Raw pairs remain available for objects whose GeoJSON geometry must be null.
+        private final List<List<BigDecimal>> sourceCoordinates;
+        private final CockpitCoordinateValidation coordinateValidation;
+        // In source order; one for Point, start/end for LineString.
+        private final List<CockpitCoordinateValidation> coordinateValidations;
+    }
+
+    @Getter
+    @AllArgsConstructor
+    public static class Coverage {
+        private final String industry;
+        private final String source;
+        private final String geometryType;
+        private final String state;
+        private final String reason;
+        private final int recordCount;
+        private final int geometryCount;
+        private final int renderableCount;
+    }
+}

+ 135 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/geometry/CockpitFacilityGeometryService.java

@@ -0,0 +1,135 @@
+package com.zksy.cockpit.geometry;
+
+import com.zksy.base.domain.GasPipePoint;
+import com.zksy.base.domain.ManholeBase;
+import com.zksy.base.domain.PipeNetworkBase;
+import com.zksy.base.service.GasPipePointService;
+import com.zksy.base.service.ManholeBaseService;
+import com.zksy.base.service.PipeNetworkBaseService;
+import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
+import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
+import com.zksy.cockpit.foundation.CockpitPipeNetworkType;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.geometry.CockpitFacilityGeometryResponse.Coverage;
+import com.zksy.cockpit.geometry.CockpitFacilityGeometryResponse.Feature;
+import com.zksy.cockpit.geometry.CockpitFacilityGeometryResponse.Geometry;
+import com.zksy.cockpit.geometry.CockpitFacilityGeometryResponse.Properties;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+@Service
+public class CockpitFacilityGeometryService {
+    private final PipeNetworkBaseService networks;
+    private final GasPipePointService gasPoints;
+    private final ManholeBaseService manholes;
+    private final Clock clock;
+
+    public CockpitFacilityGeometryService(PipeNetworkBaseService networks, GasPipePointService gasPoints,
+            ManholeBaseService manholes, Clock clock) {
+        this.networks = networks;
+        this.gasPoints = gasPoints;
+        this.manholes = manholes;
+        this.clock = clock;
+    }
+
+    @Transactional(readOnly = true)
+    public CockpitFacilityGeometryResponse load() {
+        try {
+            List<Feature> features = new ArrayList<>();
+            // IService.list() honours each entity's @TableLogic (del_flag=0).
+            for (PipeNetworkBase network : Objects.requireNonNull(networks.list())) {
+                String industry = CockpitPipeNetworkType.industryOf(network.getNetworkType());
+                features.add(feature("pipe_network_base", network.getNetworkId(), network.getNetworkCode(),
+                        network.getNetworkName(), industry == null ? "UNKNOWN" : industry,
+                        CockpitPipeNetworkType.typeCodeOf(network.getNetworkType()), network.getNetworkType(),
+                        null, network.getNetworkId(), "LineString", Arrays.asList(
+                                pair(network.getStartLongitude(), network.getStartLatitude()),
+                                pair(network.getEndLongitude(), network.getEndLatitude()))));
+            }
+            for (GasPipePoint point : Objects.requireNonNull(gasPoints.list())) {
+                features.add(feature("gas_pipe_point", String.valueOf(point.getPointId()), point.getPointCode(),
+                        point.getPointName(), "GAS", "GAS_PIPE_POINT", null, point.getPointType(),
+                        point.getNetworkId(), "Point",
+                        Collections.singletonList(pair(point.getLongitude(), point.getLatitude()))));
+            }
+            for (ManholeBase manhole : Objects.requireNonNull(manholes.list())) {
+                features.add(feature("manhole_base", manhole.getManholeId(), manhole.getManholeCode(),
+                        manhole.getManholeName(), "MANHOLE", "MANHOLE", null, null,
+                        manhole.getNetworkId(), "Point",
+                        Collections.singletonList(pair(manhole.getLongitude(), manhole.getLatitude()))));
+            }
+            List<Coverage> coverage = new ArrayList<>();
+            for (String industry : Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE")) {
+                coverage.add(coverage(features, industry, "pipe_network_base", "LineString", "TWO_ENDPOINTS_ONLY"));
+            }
+            coverage.add(coverage(features, "GAS", "gas_pipe_point", "Point", null));
+            coverage.add(new Coverage("WATER_SUPPLY", null, "Point", "UNAVAILABLE",
+                    "NO_COMPONENT_SOURCE", 0, 0, 0));
+            coverage.add(new Coverage("DRAINAGE", null, "Point", "UNAVAILABLE",
+                    "NO_TYPED_COMPONENT_SOURCE", 0, 0, 0));
+            coverage.add(coverage(features, "MANHOLE", "manhole_base", "Point", "NO_COMPONENT_TYPE"));
+            coverage.add(new Coverage("MANHOLE", null, "LineString", "UNAVAILABLE",
+                    "NO_PIPELINE_SOURCE", 0, 0, 0));
+            coverage.add(coverage(features, "UNKNOWN", "pipe_network_base", "LineString", "UNKNOWN_NETWORK_TYPE"));
+            String generatedAt = DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(
+                    clock.instant().atZone(ZoneId.of("Asia/Shanghai")));
+            return new CockpitFacilityGeometryResponse(generatedAt, features, coverage);
+        } catch (RuntimeException exception) {
+            // Never turn a failed source into an empty/partial successful collection.
+            org.slf4j.LoggerFactory.getLogger(CockpitFacilityGeometryService.class)
+                    .error("Facility geometry source read failed", exception);
+            throw new CockpitReadException("facility geometry source unavailable");
+        }
+    }
+
+    private static Feature feature(String source, String sourceId, String code, String name, String industry,
+            String typeCode, String networkType, String pointType, String networkId, String geometryType,
+            List<List<BigDecimal>> coordinates) {
+        List<CockpitCoordinateValidation> validations = coordinates.stream()
+                .map(pair -> validate(pair.get(0), pair.get(1)))
+                .collect(Collectors.toList());
+        CockpitCoordinateValidation overall = validations.stream()
+                .filter(validation -> "INVALID".equals(validation.getState()))
+                .findFirst().orElse(validations.get(0));
+        // Invalid GeoJSON coordinates cannot be represented as an empty or invented geometry.
+        Geometry geometry = "INVALID".equals(overall.getState()) ? null : new Geometry(geometryType,
+                "Point".equals(geometryType) ? coordinates.get(0) : coordinates);
+        Properties properties = new Properties(source, sourceId, code, name, industry, typeCode, networkType,
+                pointType, networkId, coordinates, overall, validations);
+        return new Feature(source + ":" + sourceId, geometry, properties);
+    }
+
+    private static CockpitCoordinateValidation validate(BigDecimal longitude, BigDecimal latitude) {
+        // Structural errors must remain distinguishable even without a verification flag.
+        CockpitCoordinateValidation structural = CockpitCoordinateValidator.validate(longitude, latitude, true);
+        return structural.isRenderable()
+                ? CockpitCoordinateValidator.validate(longitude, latitude, false) : structural;
+    }
+
+    private static List<BigDecimal> pair(BigDecimal longitude, BigDecimal latitude) {
+        return Arrays.asList(longitude, latitude);
+    }
+
+    private static Coverage coverage(List<Feature> features, String industry, String source,
+            String geometryType, String reason) {
+        List<Feature> records = features.stream().filter(feature ->
+                industry.equals(feature.getProperties().getIndustry())
+                        && source.equals(feature.getProperties().getSource())).collect(Collectors.toList());
+        int geometryCount = (int) records.stream().filter(feature -> feature.getGeometry() != null).count();
+        int renderableCount = (int) records.stream()
+                .filter(feature -> feature.getProperties().getCoordinateValidation().isRenderable()).count();
+        return new Coverage(industry, source, geometryType, records.isEmpty() ? "EMPTY" : "AVAILABLE",
+                reason, records.size(), geometryCount, renderableCount);
+    }
+}