Pārlūkot izejas kodu

feat(cockpit): add equipment archive and latest image (#22)

Kazerin 17 stundas atpakaļ
vecāks
revīzija
d6fd256090

+ 137 - 0
docs/cockpit/equipment-archive-demo.html

@@ -0,0 +1,137 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>Cockpit Equipment Archive Demo</title>
+  <style>
+    body { margin:0; min-height:100vh; background:#08131f; color:#e8f1f8; font:15px/1.5 "Microsoft YaHei",sans-serif; }
+    main { width:min(1180px,calc(100vw - 32px)); margin:28px auto; display:grid; gap:16px; }
+    header, section { background:#0e1e2d; border:1px solid #21465f; padding:20px; }
+    h1,h2 { margin:0 0 10px; } h1 { font-size:22px; } h2 { font-size:17px; }
+    .endpoint { color:#78a2b8; font-family:Consolas,monospace; margin:0; }
+    .toolbar { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:14px; }
+    button { border:1px solid #2f7098; background:#123247; color:#dff2ff; padding:8px 14px; cursor:pointer; }
+    button.active { outline:2px solid #61c2ff; }
+    .layout { display:grid; grid-template-columns:minmax(0,1fr) 340px; gap:18px; align-items:start; }
+    table { width:100%; border-collapse:collapse; }
+    th,td { border:1px solid #275b74; padding:8px; text-align:left; vertical-align:top; }
+    th { background:#123247; width:180px; }
+    td.mono { font-family:Consolas,monospace; }
+    .image-panel, .missing { background:#0b1922; border:1px solid #275b74; padding:12px; }
+    img { display:block; width:100%; height:180px; object-fit:cover; border:1px solid #2f7098; background:#071019; }
+    .image-meta { margin:10px 0 0; color:#8fb3c7; font-size:13px; overflow-wrap:anywhere; }
+    .missing { display:grid; place-items:center; min-height:180px; color:#a9b7c0; text-align:center; }
+    .note { color:#8fb3c7; font-size:13px; }
+    pre { max-height:300px; overflow:auto; background:#071019; border:1px solid #275b74; padding:12px; }
+  </style>
+</head>
+<body>
+<main>
+  <header>
+    <h1>驾驶舱设备基础档案演示</h1>
+    <p class="endpoint">GET /api/cockpit/v1/equipments/{deviceCode}/archive</p>
+  </header>
+  <section>
+    <div class="toolbar">
+      <button type="button" data-mode="full" class="active">完整档案</button>
+      <button type="button" data-mode="partial">部分档案</button>
+      <button type="button" data-mode="noImage">无图片</button>
+      <button type="button" data-mode="loadFailure">加载失败</button>
+    </div>
+    <div id="detail">
+      <div class="layout">
+        <div><h2>基础档案</h2><table><tbody id="archive"></tbody></table></div>
+        <div><h2>最新图片</h2><div id="image"></div></div>
+      </div>
+    </div>
+    <div id="error" hidden>
+      <h2 id="error-title"></h2>
+      <pre id="error-payload"></pre>
+    </div>
+  </section>
+  <section>
+    <h2>响应契约</h2>
+    <p class="note">坐标字段以 WGS84 发布;缺失档案字段显示“未提供”,无图片返回 latestImage 空值,加载失败使用 typed error。</p>
+    <pre id="payload"></pre>
+  </section>
+</main>
+<script>
+  var fields = [
+    ['deviceCode','设备编码'],['deviceName','设备名称'],['deviceModel','设备型号'],['deviceSpec','设备规格参数'],
+    ['manufacturer','设备制造商'],['productionDate','设备生产日期'],['deviceType','设备类别'],['deviceTypeName','类别名称'],
+    ['assetValue','设备资产价值(元)'],['expectedServiceYears','预计使用年限(年)'],
+    ['longitude','经度'],['latitude','纬度'],['coordinateSystem','坐标系'],
+    ['equipmentLocation','设备位置描述'],['installTime','安装时间'],
+    ['maintainer','设备维护人员'],['maintainerPhone','联系方式'],
+    ['ownershipUnit','权属单位'],['remark','备注信息']
+  ];
+  var image = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180"><rect width="100%" height="100%" fill="#123247"/><text x="50%" y="50%" fill="#dff2ff" text-anchor="middle" font-family="sans-serif">设备现场图</text></svg>';
+  var responses = {
+    full: response('TC-PS-YS-202609020012', {
+      deviceName:'污水管网环境监测设备', deviceModel:'WS-PRO-2000',
+      deviceSpec:'悬浮物/COD/氨氮/电导率/PH', manufacturer:'沅陵专用仪表厂',
+      productionDate:'2025-12-01', deviceType:'ws_sewage_environment', deviceTypeName:'污水环境监测设备',
+      assetValue:128000, expectedServiceYears:10, longitude:110.141, latitude:28.452, coordinateSystem:'WGS84',
+      equipmentLocation:'沅陵县太常片区污水干管检查井', installTime:'2026-01-10 09:30:00',
+      maintainer:'张维护', maintainerPhone:'13800000000', ownershipUnit:'沅陵县排水管理所',
+      remark:'监测点首选查看设备',
+      latestImage:{ url:image, capturedAt:'2026-08-30T10:20:00+08:00' }
+    }),
+    partial: response('TC-GS-202609020015', {
+      deviceName:'燃气压力监测设备', deviceModel:null, deviceSpec:'压力',
+      manufacturer:'沅陵专用仪表厂', productionDate:'2025-11-10', deviceType:'gs_pressure', deviceTypeName:'燃气压力设备',
+      assetValue:null, expectedServiceYears:8, longitude:110.151, latitude:28.462, coordinateSystem:'WGS84',
+      equipmentLocation:'沅陵县太常片区燃气中压管线', installTime:'2026-02-01 14:00:00',
+      maintainer:null, maintainerPhone:null, ownershipUnit:'沅陵县燃气公司', remark:null,
+      latestImage:{ url:image, capturedAt:'2026-08-29T16:40:00+08:00' }
+    }),
+    noImage: response('TC-PS-WS-202609020016', {
+      deviceName:'泵站液位监测设备', deviceModel:'LEVEL-300', deviceSpec:'液位/温度',
+      manufacturer:'沅陵专用仪表厂', productionDate:'2025-10-01', deviceType:'ps_level', deviceTypeName:'泵站液位设备',
+      assetValue:66000, expectedServiceYears:10, longitude:110.161, latitude:28.472, coordinateSystem:'WGS84',
+      equipmentLocation:'沅陵县太常片区泵站', installTime:'2026-03-01 08:00:00',
+      maintainer:'李巡检', maintainerPhone:'13900000000', ownershipUnit:'沅陵县排水管理所', remark:'演示无图片状态',
+      latestImage:null
+    }),
+    loadFailure: {
+      code:500, msg:'驾驶舱设备档案数据暂不可用',
+      data:{ category:'SERVER_ERROR', message:'驾驶舱设备档案数据暂不可用', requestId:'equipment-archive-demo-500', serverTime:'2026-09-02T10:00:00+08:00' }
+    }
+  };
+  function response(code, archive) {
+    return { code:200, msg:'操作成功', data:Object.assign({
+      requestId:'equipment-archive-demo-' + code, apiVersion:'v1', readOnly:true
+    }, archive) };
+  }
+  function render(mode) {
+    document.querySelectorAll('button[data-mode]').forEach(function(button) {
+      button.classList.toggle('active', button.dataset.mode === mode);
+    });
+    var payload = responses[mode];
+    var success = payload.code === 200;
+    document.getElementById('detail').hidden = !success;
+    document.getElementById('error').hidden = success;
+    if (success) {
+      var data = payload.data;
+      document.getElementById('archive').innerHTML = fields.map(function(field) {
+        var value = data[field[0]];
+        return '<tr><th>' + field[1] + '</th><td class="mono">' + (value === null || value === undefined ? '<span class="note">未提供</span>' : value) + '</td></tr>';
+      }).join('');
+      var latest = data.latestImage;
+      document.getElementById('image').innerHTML = latest
+        ? '<div class="image-panel"><img alt="设备最新图片" src="' + latest.url + '" /><p class="image-meta">' + latest.capturedAt + '<br />' + latest.url + '</p></div>'
+        : '<div class="missing">无设备图片</div>';
+    } else {
+      document.getElementById('error-title').textContent = 'HTTP ' + payload.code + ':' + payload.msg;
+      document.getElementById('error-payload').textContent = JSON.stringify(payload, null, 2);
+    }
+    document.getElementById('payload').textContent = JSON.stringify(payload, null, 2);
+  }
+  document.querySelectorAll('button[data-mode]').forEach(function(button) {
+    button.addEventListener('click', function() { render(button.dataset.mode); });
+  });
+  render('full');
+</script>
+</body>
+</html>

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

@@ -0,0 +1,87 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.equipment.CockpitEquipmentArchiveResponse;
+import com.zksy.cockpit.equipment.CockpitEquipmentArchiveService;
+import com.zksy.cockpit.foundation.CockpitDeviceNotFoundException;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidCodeException;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.utils.StringUtils;
+import com.zksy.common.utils.uuid.IdUtils;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.util.UriUtils;
+
+import java.nio.charset.StandardCharsets;
+
+import javax.servlet.http.HttpServletRequest;
+
+@RestController
+public class CockpitEquipmentArchiveController {
+    private static final String REQUEST_ID_HEADER = "X-Request-Id";
+
+    private final CockpitEquipmentArchiveService archiveService;
+
+    public CockpitEquipmentArchiveController(CockpitEquipmentArchiveService archiveService) {
+        this.archiveService = archiveService;
+    }
+
+    @GetMapping("/api/cockpit/v1/equipments/{deviceCode}/archive")
+    @PreAuthorize("isAuthenticated()")
+    public ResponseEntity<AjaxResult> equipmentArchive(
+            @PathVariable("deviceCode") String deviceCode,
+            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
+        CockpitEquipmentArchiveResponse response = archiveService.load(
+                resolveRequestId(requestId), decodePathSegment(deviceCode));
+        return ResponseEntity.ok(AjaxResult.success(response));
+    }
+
+    @ExceptionHandler(CockpitInvalidRequestException.class)
+    public ResponseEntity<AjaxResult> handleInvalidRequest(HttpServletRequest request) {
+        return error(HttpStatus.BAD_REQUEST, archiveService.invalidRequest(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+    }
+
+    @ExceptionHandler(CockpitInvalidCodeException.class)
+    public ResponseEntity<AjaxResult> handleInvalidCode(HttpServletRequest request) {
+        return error(HttpStatus.BAD_REQUEST, archiveService.invalidCode(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+    }
+
+    @ExceptionHandler(CockpitDeviceNotFoundException.class)
+    public ResponseEntity<AjaxResult> handleNotFound(HttpServletRequest request) {
+        return error(HttpStatus.NOT_FOUND, archiveService.notFound(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+    }
+
+    @ExceptionHandler(CockpitReadException.class)
+    public ResponseEntity<AjaxResult> handleReadFailure(HttpServletRequest request) {
+        return error(HttpStatus.INTERNAL_SERVER_ERROR, archiveService.serverError(
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
+    }
+
+    private ResponseEntity<AjaxResult> error(HttpStatus status, CockpitErrorPayload payload) {
+        return ResponseEntity.status(status)
+                .body(new AjaxResult(status.value(), payload.getMessage(), payload));
+    }
+
+    private String resolveRequestId(String requestId) {
+        return StringUtils.isNotEmpty(requestId) ? requestId : IdUtils.fastSimpleUUID();
+    }
+
+    private String decodePathSegment(String value) {
+        try {
+            return UriUtils.decode(value, StandardCharsets.UTF_8);
+        } catch (IllegalArgumentException exception) {
+            throw new CockpitInvalidCodeException("invalid device code encoding");
+        }
+    }
+}

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

@@ -0,0 +1,223 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.equipment.CockpitEquipmentArchiveSnapshot;
+import com.zksy.cockpit.equipment.CockpitEquipmentArchiveAdapter;
+import com.zksy.cockpit.equipment.CockpitEquipmentImage;
+import com.zksy.cockpit.equipment.CockpitEquipmentArchiveService;
+import com.zksy.cockpit.foundation.CockpitReadException;
+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.lang.reflect.Method;
+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.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+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;
+
+class CockpitEquipmentArchiveControllerTest {
+    private CockpitEquipmentArchiveAdapter adapter;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        adapter = mock(CockpitEquipmentArchiveAdapter.class);
+        CockpitEquipmentArchiveService service = new CockpitEquipmentArchiveService(
+                adapter,
+                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+        mockMvc = MockMvcBuilders
+                .standaloneSetup(new CockpitEquipmentArchiveController(service))
+                .build();
+    }
+
+    @Test
+    void archiveReturnsCompleteReadOnlyContractWithLatestImage() throws Exception {
+        when(adapter.load("TC-PS-YS-202609020012")).thenReturn(archive(
+                "TC-PS-YS-202609020012",
+                Collections.singletonList(new CockpitEquipmentImage(
+                        "https://cockpit.example/equipment-latest.jpg",
+                        "2026-08-30T10:20:00+08:00"))));
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/TC-PS-YS-202609020012/archive")
+                        .header("X-Request-Id", "equipment-archive-request-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.msg").value("操作成功"))
+                .andExpect(jsonPath("$.data.requestId").value("equipment-archive-request-001"))
+                .andExpect(jsonPath("$.data.apiVersion").value("v1"))
+                .andExpect(jsonPath("$.data.readOnly").value(true))
+                .andExpect(jsonPath("$.data.deviceCode").value("TC-PS-YS-202609020012"))
+                .andExpect(jsonPath("$.data.deviceName").value("污水管网环境监测设备"))
+                .andExpect(jsonPath("$.data.deviceModel").value("WS-PRO-2000"))
+                .andExpect(jsonPath("$.data.deviceSpec").value("悬浮物/COD/氨氮/电导率/PH"))
+                .andExpect(jsonPath("$.data.manufacturer").value("沅陵专用仪表厂"))
+                .andExpect(jsonPath("$.data.productionDate").value("2025-12-01"))
+                .andExpect(jsonPath("$.data.deviceType").value("ws_sewage_environment"))
+                .andExpect(jsonPath("$.data.deviceTypeName").value("污水环境监测设备"))
+                .andExpect(jsonPath("$.data.assetValue").value(128000.0))
+                .andExpect(jsonPath("$.data.expectedServiceYears").value(10))
+                .andExpect(jsonPath("$.data.longitude").value(110.141))
+                .andExpect(jsonPath("$.data.latitude").value(28.452))
+                .andExpect(jsonPath("$.data.coordinateSystem").value("WGS84"))
+                .andExpect(jsonPath("$.data.equipmentLocation").value("沅陵县太常片区污水干管检查井"))
+                .andExpect(jsonPath("$.data.installTime").value("2026-01-10 09:30:00"))
+                .andExpect(jsonPath("$.data.maintainer").value("张维护"))
+                .andExpect(jsonPath("$.data.maintainerPhone").value("13800000000"))
+                .andExpect(jsonPath("$.data.ownershipUnit").value("沅陵县排水管理所"))
+                .andExpect(jsonPath("$.data.remark").value("监测点首选查看设备"))
+                .andExpect(jsonPath("$.data.latestImage.url").value("https://cockpit.example/equipment-latest.jpg"))
+                .andExpect(jsonPath("$.data.latestImage.capturedAt").value("2026-08-30T10:20:00+08:00"));
+    }
+
+    @Test
+    void archiveSucceedsWithoutImageWhenArchiveExists() throws Exception {
+        when(adapter.load("TC-PS-YS-202609020013")).thenReturn(archive(
+                "TC-PS-YS-202609020013", Collections.emptyList()));
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/TC-PS-YS-202609020013/archive"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.deviceCode").value("TC-PS-YS-202609020013"))
+                .andExpect(jsonPath("$.data.latestImage").doesNotExist());
+    }
+
+    @Test
+    void archiveSelectsOnlyTheLatestImageByCapturedAt() throws Exception {
+        when(adapter.load("TC-PS-YS-202609020014")).thenReturn(archive(
+                "TC-PS-YS-202609020014",
+                Arrays.asList(
+                        new CockpitEquipmentImage(
+                                "https://cockpit.example/older.jpg",
+                                "2026-08-29T10:20:00+08:00"),
+                        new CockpitEquipmentImage(
+                                "https://cockpit.example/latest.jpg",
+                                "2026-08-30T10:20:00+08:00"))));
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/TC-PS-YS-202609020014/archive"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.latestImage.url").value("https://cockpit.example/latest.jpg"))
+                .andExpect(jsonPath("$.data.latestImage.capturedAt").value("2026-08-30T10:20:00+08:00"));
+    }
+
+    @Test
+    void missingDeviceReturnsTypedNotFound() throws Exception {
+        when(adapter.load("TC-PS-YS-404")).thenReturn(null);
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/TC-PS-YS-404/archive")
+                        .header("X-Request-Id", "equipment-archive-request-404"))
+                .andExpect(status().isNotFound())
+                .andExpect(jsonPath("$.code").value(404))
+                .andExpect(jsonPath("$.data.category").value("NOT_FOUND"))
+                .andExpect(jsonPath("$.data.message").value("设备不存在"))
+                .andExpect(jsonPath("$.data.requestId").value("equipment-archive-request-404"));
+    }
+
+    @Test
+    void blankCodeReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(get("/api/cockpit/v1/equipments/%20/archive"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
+                .andExpect(jsonPath("$.data.message").value("设备编码不能为空"));
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void malformedPathEncodingReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(get("/api/cockpit/v1/equipments/%ZZ/archive"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
+                .andExpect(jsonPath("$.data.message").value("设备编码不合法"));
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void serverFailureReturnsTypedServerError() throws Exception {
+        when(adapter.load("TC-PS-YS-500")).thenThrow(new CockpitReadException("upstream unavailable"));
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/TC-PS-YS-500/archive"))
+                .andExpect(status().isInternalServerError())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"))
+                .andExpect(jsonPath("$.data.message").value("驾驶舱设备档案数据暂不可用"));
+    }
+
+    @Test
+    void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(post("/api/cockpit/v1/equipments/TC-PS-YS-202609020012/archive"))
+                .andExpect(status().isMethodNotAllowed());
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void controllerOnlyExposesAuthenticatedReadOnlyOperations() {
+        assertTrue(java.util.Arrays.stream(CockpitEquipmentArchiveController.class.getDeclaredMethods())
+                .filter(this::hasRequestMappingAnnotation)
+                .allMatch(CockpitEquipmentArchiveControllerTest::isReadOnlyMappedMethod));
+
+        assertTrue(java.util.Arrays.stream(CockpitEquipmentArchiveController.class.getDeclaredMethods())
+                .anyMatch(method -> method.isAnnotationPresent(
+                        org.springframework.security.access.prepost.PreAuthorize.class)
+                        && method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class)
+                        .value()
+                        .equals("isAuthenticated()")));
+    }
+
+    private boolean hasRequestMappingAnnotation(Method method) {
+        return method.isAnnotationPresent(org.springframework.web.bind.annotation.RequestMapping.class)
+                || method.isAnnotationPresent(org.springframework.web.bind.annotation.GetMapping.class)
+                || method.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)
+                || method.isAnnotationPresent(org.springframework.web.bind.annotation.PutMapping.class)
+                || method.isAnnotationPresent(org.springframework.web.bind.annotation.PatchMapping.class)
+                || method.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class);
+    }
+
+    private static boolean isReadOnlyMappedMethod(Method method) {
+        return method.isAnnotationPresent(org.springframework.web.bind.annotation.GetMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PutMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PatchMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class);
+    }
+
+    private static CockpitEquipmentArchiveSnapshot archive(
+            String deviceCode,
+            java.util.List<CockpitEquipmentImage> images) {
+        return new CockpitEquipmentArchiveSnapshot(
+                deviceCode,
+                "污水管网环境监测设备",
+                "WS-PRO-2000",
+                "悬浮物/COD/氨氮/电导率/PH",
+                "沅陵专用仪表厂",
+                "2025-12-01",
+                "ws_sewage_environment",
+                "污水环境监测设备",
+                new BigDecimal("128000"),
+                10,
+                new BigDecimal("110.141"),
+                new BigDecimal("28.452"),
+                "沅陵县太常片区污水干管检查井",
+                "2026-01-10 09:30:00",
+                "张维护",
+                "13800000000",
+                "沅陵县排水管理所",
+                "监测点首选查看设备",
+                images);
+    }
+}

+ 36 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/equipment/CockpitEquipmentArchive.java

@@ -0,0 +1,36 @@
+package com.zksy.cockpit.equipment;
+
+public class CockpitEquipmentArchive {
+    private final CockpitEquipmentArchiveSnapshot archive;
+    private final String coordinateSystem;
+    private final CockpitEquipmentImage latestImage;
+
+    public CockpitEquipmentArchive(
+            CockpitEquipmentArchiveSnapshot archive, String coordinateSystem, CockpitEquipmentImage latestImage) {
+        this.archive = archive;
+        this.coordinateSystem = coordinateSystem;
+        this.latestImage = latestImage;
+    }
+
+    public String getDeviceCode() { return archive.getDeviceCode(); }
+    public String getDeviceName() { return archive.getDeviceName(); }
+    public String getDeviceModel() { return archive.getDeviceModel(); }
+    public String getDeviceSpec() { return archive.getDeviceSpec(); }
+    public String getManufacturer() { return archive.getManufacturer(); }
+    public String getCoordinateSystem() { return coordinateSystem; }
+    public String getProductionDate() { return archive.getProductionDate(); }
+    public String getDeviceType() { return archive.getDeviceType(); }
+    public String getDeviceTypeName() { return archive.getDeviceTypeName(); }
+    public java.math.BigDecimal getAssetValue() { return archive.getAssetValue(); }
+    public Integer getExpectedServiceYears() { return archive.getExpectedServiceYears(); }
+    public java.math.BigDecimal getLongitude() { return archive.getLongitude(); }
+    public java.math.BigDecimal getLatitude() { return archive.getLatitude(); }
+    public String getEquipmentLocation() { return archive.getEquipmentLocation(); }
+    public String getInstallTime() { return archive.getInstallTime(); }
+    public String getMaintainer() { return archive.getMaintainer(); }
+    public String getMaintainerPhone() { return archive.getMaintainerPhone(); }
+    public String getOwnershipUnit() { return archive.getOwnershipUnit(); }
+    public String getRemark() { return archive.getRemark(); }
+    public CockpitEquipmentImage getLatestImage() { return latestImage; }
+
+}

+ 5 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/equipment/CockpitEquipmentArchiveAdapter.java

@@ -0,0 +1,5 @@
+package com.zksy.cockpit.equipment;
+
+public interface CockpitEquipmentArchiveAdapter {
+    CockpitEquipmentArchiveSnapshot load(String deviceCode);
+}

+ 40 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/equipment/CockpitEquipmentArchiveResponse.java

@@ -0,0 +1,40 @@
+package com.zksy.cockpit.equipment;
+
+public class CockpitEquipmentArchiveResponse {
+    private final String requestId;
+    private final String apiVersion;
+    private final boolean readOnly;
+    private final CockpitEquipmentArchive archive;
+
+    public CockpitEquipmentArchiveResponse(
+            String requestId, String apiVersion, boolean readOnly, CockpitEquipmentArchive archive) {
+        this.requestId = requestId;
+        this.apiVersion = apiVersion;
+        this.readOnly = readOnly;
+        this.archive = archive;
+    }
+
+    public String getRequestId() { return requestId; }
+    public String getApiVersion() { return apiVersion; }
+    public boolean isReadOnly() { return readOnly; }
+    public String getDeviceCode() { return archive.getDeviceCode(); }
+    public String getDeviceName() { return archive.getDeviceName(); }
+    public String getDeviceModel() { return archive.getDeviceModel(); }
+    public String getDeviceSpec() { return archive.getDeviceSpec(); }
+    public String getManufacturer() { return archive.getManufacturer(); }
+    public String getCoordinateSystem() { return archive.getCoordinateSystem(); }
+    public String getProductionDate() { return archive.getProductionDate(); }
+    public String getDeviceType() { return archive.getDeviceType(); }
+    public String getDeviceTypeName() { return archive.getDeviceTypeName(); }
+    public java.math.BigDecimal getAssetValue() { return archive.getAssetValue(); }
+    public Integer getExpectedServiceYears() { return archive.getExpectedServiceYears(); }
+    public java.math.BigDecimal getLongitude() { return archive.getLongitude(); }
+    public java.math.BigDecimal getLatitude() { return archive.getLatitude(); }
+    public String getEquipmentLocation() { return archive.getEquipmentLocation(); }
+    public String getInstallTime() { return archive.getInstallTime(); }
+    public String getMaintainer() { return archive.getMaintainer(); }
+    public String getMaintainerPhone() { return archive.getMaintainerPhone(); }
+    public String getOwnershipUnit() { return archive.getOwnershipUnit(); }
+    public String getRemark() { return archive.getRemark(); }
+    public CockpitEquipmentImage getLatestImage() { return archive.getLatestImage(); }
+}

+ 89 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/equipment/CockpitEquipmentArchiveService.java

@@ -0,0 +1,89 @@
+package com.zksy.cockpit.equipment;
+
+import com.zksy.cockpit.foundation.CockpitDeviceNotFoundException;
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import org.springframework.stereotype.Service;
+
+import java.time.Clock;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+
+@Service
+public class CockpitEquipmentArchiveService {
+    private final CockpitEquipmentArchiveAdapter adapter;
+    private final Clock clock;
+
+    public CockpitEquipmentArchiveService(CockpitEquipmentArchiveAdapter adapter, Clock clock) {
+        this.adapter = adapter;
+        this.clock = clock;
+    }
+
+    public CockpitEquipmentArchiveResponse load(String requestId, String deviceCode) {
+        if (isBlank(deviceCode)) {
+            throw new CockpitInvalidRequestException("device code is required");
+        }
+
+        CockpitEquipmentArchiveSnapshot snapshot = adapter.load(deviceCode.trim());
+        if (snapshot == null) {
+            throw new CockpitDeviceNotFoundException("device not found");
+        }
+
+        return new CockpitEquipmentArchiveResponse(
+                requestId,
+                "v1",
+                true,
+                new CockpitEquipmentArchive(snapshot, "WGS84", latestImage(snapshot.getImages())));
+    }
+
+    public CockpitErrorPayload invalidRequest(String requestId) {
+        return error(requestId, CockpitErrorCategory.INVALID_REQUEST, "设备编码不能为空");
+    }
+
+    public CockpitErrorPayload invalidCode(String requestId) {
+        return error(requestId, CockpitErrorCategory.INVALID_REQUEST, "设备编码不合法");
+    }
+
+    public CockpitErrorPayload notFound(String requestId) {
+        return error(requestId, CockpitErrorCategory.NOT_FOUND, "设备不存在");
+    }
+
+    public CockpitErrorPayload serverError(String requestId) {
+        return error(requestId, CockpitErrorCategory.SERVER_ERROR, "驾驶舱设备档案数据暂不可用");
+    }
+
+    private CockpitEquipmentImage latestImage(List<CockpitEquipmentImage> images) {
+        if (images == null || images.isEmpty()) {
+            return null;
+        }
+
+        try {
+            Optional<CockpitEquipmentImage> latest = images.stream()
+                    .filter(image -> image != null && image.getCapturedAt() != null)
+                    .max(Comparator.comparing(image -> OffsetDateTime.parse(image.getCapturedAt())));
+            return latest.orElse(null);
+        } catch (DateTimeParseException exception) {
+            throw new CockpitReadException("invalid equipment image timestamp: " + exception.getMessage());
+        }
+    }
+
+    private CockpitErrorPayload error(String requestId, CockpitErrorCategory category, String message) {
+        return new CockpitErrorPayload(
+                category,
+                message,
+                requestId,
+                DateTimeFormatter.ISO_OFFSET_DATE_TIME
+                        .format(clock.instant().atZone(ZoneId.of("Asia/Shanghai"))));
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+}

+ 73 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/equipment/CockpitEquipmentArchiveSnapshot.java

@@ -0,0 +1,73 @@
+package com.zksy.cockpit.equipment;
+
+import java.util.List;
+
+public class CockpitEquipmentArchiveSnapshot {
+    private final String deviceCode;
+    private final String deviceName;
+    private final String deviceModel;
+    private final String deviceSpec;
+    private final String manufacturer;
+    private final String productionDate;
+    private final String deviceType;
+    private final String deviceTypeName;
+    private final java.math.BigDecimal assetValue;
+    private final Integer expectedServiceYears;
+    private final java.math.BigDecimal longitude;
+    private final java.math.BigDecimal latitude;
+    private final String equipmentLocation;
+    private final String installTime;
+    private final String maintainer;
+    private final String maintainerPhone;
+    private final String ownershipUnit;
+    private final String remark;
+    private final List<CockpitEquipmentImage> images;
+
+    public CockpitEquipmentArchiveSnapshot(
+            String deviceCode, String deviceName, String deviceModel, String deviceSpec,
+            String manufacturer, String productionDate, String deviceType, String deviceTypeName,
+            java.math.BigDecimal assetValue, Integer expectedServiceYears,
+            java.math.BigDecimal longitude, java.math.BigDecimal latitude,
+            String equipmentLocation, String installTime, String maintainer, String maintainerPhone,
+            String ownershipUnit, String remark, List<CockpitEquipmentImage> images) {
+        this.deviceCode = deviceCode;
+        this.deviceName = deviceName;
+        this.deviceModel = deviceModel;
+        this.deviceSpec = deviceSpec;
+        this.manufacturer = manufacturer;
+        this.productionDate = productionDate;
+        this.deviceType = deviceType;
+        this.deviceTypeName = deviceTypeName;
+        this.assetValue = assetValue;
+        this.expectedServiceYears = expectedServiceYears;
+        this.longitude = longitude;
+        this.latitude = latitude;
+        this.equipmentLocation = equipmentLocation;
+        this.installTime = installTime;
+        this.maintainer = maintainer;
+        this.maintainerPhone = maintainerPhone;
+        this.ownershipUnit = ownershipUnit;
+        this.remark = remark;
+        this.images = images;
+    }
+
+    public String getDeviceCode() { return deviceCode; }
+    public String getDeviceName() { return deviceName; }
+    public String getDeviceModel() { return deviceModel; }
+    public String getDeviceSpec() { return deviceSpec; }
+    public String getManufacturer() { return manufacturer; }
+    public String getProductionDate() { return productionDate; }
+    public String getDeviceType() { return deviceType; }
+    public String getDeviceTypeName() { return deviceTypeName; }
+    public java.math.BigDecimal getAssetValue() { return assetValue; }
+    public Integer getExpectedServiceYears() { return expectedServiceYears; }
+    public java.math.BigDecimal getLongitude() { return longitude; }
+    public java.math.BigDecimal getLatitude() { return latitude; }
+    public String getEquipmentLocation() { return equipmentLocation; }
+    public String getInstallTime() { return installTime; }
+    public String getMaintainer() { return maintainer; }
+    public String getMaintainerPhone() { return maintainerPhone; }
+    public String getOwnershipUnit() { return ownershipUnit; }
+    public String getRemark() { return remark; }
+    public List<CockpitEquipmentImage> getImages() { return images; }
+}

+ 19 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/equipment/CockpitEquipmentImage.java

@@ -0,0 +1,19 @@
+package com.zksy.cockpit.equipment;
+
+public class CockpitEquipmentImage {
+    private final String url;
+    private final String capturedAt;
+
+    public CockpitEquipmentImage(String url, String capturedAt) {
+        this.url = url;
+        this.capturedAt = capturedAt;
+    }
+
+    public String getUrl() {
+        return url;
+    }
+
+    public String getCapturedAt() {
+        return capturedAt;
+    }
+}

+ 11 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/equipment/EmptyCockpitEquipmentArchiveAdapter.java

@@ -0,0 +1,11 @@
+package com.zksy.cockpit.equipment;
+
+import org.springframework.stereotype.Service;
+
+@Service
+public class EmptyCockpitEquipmentArchiveAdapter implements CockpitEquipmentArchiveAdapter {
+    @Override
+    public CockpitEquipmentArchiveSnapshot load(String deviceCode) {
+        return null;
+    }
+}

+ 7 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitDeviceNotFoundException.java

@@ -0,0 +1,7 @@
+package com.zksy.cockpit.foundation;
+
+public class CockpitDeviceNotFoundException extends RuntimeException {
+    public CockpitDeviceNotFoundException(String message) {
+        super(message);
+    }
+}