瀏覽代碼

feat(cockpit): add equipment detail aggregation (#26)

Kazerin 1 月之前
父節點
當前提交
2a27481f07

+ 140 - 0
docs/cockpit/equipment-detail-aggregate-demo.html

@@ -0,0 +1,140 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>Cockpit Equipment Detail Aggregate Demo</title>
+  <style>
+    body { margin:0; min-height:100vh; background:#08131f; color:#e8f1f8; font:15px/1.5 "Microsoft YaHei",sans-serif; }
+    main { width:min(1280px,calc(100vw - 32px)); margin:28px auto; display:grid; gap:16px; }
+    header, section { background:#0e1e2d; border:1px solid #21465f; padding:20px; }
+    h1,h2,h3 { margin:0 0 10px; } h1 { font-size:22px; } h2 { font-size:17px; } h3 { font-size:15px; }
+    .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; }
+    .sections { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:12px; }
+    .section { border:1px solid #275b74; background:#0b1922; padding:12px; }
+    .section.success { border-color:#68d095; } .section.empty { border-color:#a9b7c0; }
+    .section.stale { border-color:#f2c94c; } .section.failed { border-color:#ff7a7a; }
+    .badge { display:inline-block; font-size:12px; padding:3px 8px; border:1px solid; margin-bottom:8px; }
+    .success .badge { color:#68d095; border-color:#68d095; } .empty .badge { color:#a9b7c0; border-color:#a9b7c0; }
+    .stale .badge { color:#f2c94c; border-color:#f2c94c; } .failed .badge { color:#ff7a7a; border-color:#ff7a7a; }
+    table { width:100%; border-collapse:collapse; } th,td { border:1px solid #275b74; padding:6px; text-align:left; } th { background:#123247; }
+    .mono { font-family:Consolas,monospace; overflow-wrap:anywhere; } pre { max-height:340px; overflow:auto; background:#071019; border:1px solid #275b74; padding:12px; }
+    .note { color:#8fb3c7; font-size:13px; } .missing { color:#a9b7c0; font-style:italic; }
+  </style>
+</head>
+<body>
+<main>
+  <header>
+    <h1>设备详情聚合与独立分区演示</h1>
+    <p class="endpoint">GET /api/cockpit/v1/equipments/{deviceCode}/detail-aggregate</p>
+    <p class="endpoint">分区重试复用既有:/archive · /metrics/current · /events/current · /work-orders · /work-orders/{code}/timeline</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="empty">成功空数据</button>
+    </div>
+    <div id="sections" class="sections"></div>
+  </section>
+  <section>
+    <h2>响应契约</h2>
+    <p class="note">每个 sectionResult 独立返回 SUCCESS / EMPTY / STALE / FAILED;FAILED 分区保留原因与提示,不覆盖成功兄弟分区。最新图片继承档案分区,档案失败时显示来源分区失败。</p>
+    <pre id="payload" class="mono"></pre>
+  </section>
+</main>
+<script>
+  const section = (state, reason, data, message) => ({ state, reason, message, data });
+  const image = { url:'https://cockpit.example/latest.jpg', capturedAt:'2026-08-30T10:20:00+08:00' };
+  const archive = {
+    deviceCode:'TC-PS-WS-202609020012', deviceName:'污水环境监测设备', deviceModel:'WS-PRO-2000',
+    deviceSpec:'悬浮物/COD/氨氮/电导率/PH', manufacturer:'沅陵专用仪表厂', deviceTypeName:'污水环境监测设备',
+    assetValue:128000, expectedServiceYears:10, equipmentLocation:'太常片区', ownershipUnit:'沅陵县排水管理所',
+    latestImage:image
+  };
+  const metric = { metricCode:'COD', metricName:'COD', value:18.2, unit:'mg/L', status:'WARNING', sampledAt:'2026-09-02T09:59:30+08:00', freshness:'CURRENT' };
+  const warning = { warningNo:'WRN-20260902-001', warningName:'污水COD预警', level:2, levelColor:'YELLOW', status:'PROCESSING', eventTime:'2026-09-02T09:59:30+08:00' };
+  const alarm = { alarmId:'ALM-20260902-001', warningName:'污水COD报警', level:1, levelColor:'RED', status:'UNHANDLED', eventTime:'2026-09-02T10:00:00+08:00' };
+  const relation = { relationId:'REL-001', warningNo:warning.warningNo, alarmId:alarm.alarmId, relatedAt:'2026-09-02T10:00:00+08:00' };
+  const workOrder = { workOrderCode:'WO-2026090200001', orderTypeName:'故障维修', stateCode:4, stateName:'处理中', orderDesc:'处理异常' };
+  const timeline = { workOrder:{ ...workOrder }, timelineEntries:[{ logId:1, operationName:'创建工单', operationDesc:'处理异常', operatorName:'系统', operatedAt:'2026-09-02T09:00:00+08:00' }] };
+  const metrics = { deviceCode:archive.deviceCode, state:'CURRENT', stateReason:'READY', metrics:[metric] };
+
+  const responses = {
+    full: {
+      aggregateState:'READY',
+      archive:section('SUCCESS','SUCCESS',archive), metrics:section('SUCCESS','SUCCESS',metrics),
+      warnings:section('SUCCESS','SUCCESS',[warning]), alarms:section('SUCCESS','SUCCESS',[alarm]),
+      alarmWarningRelations:section('SUCCESS','SUCCESS',[relation]),
+      workOrders:section('SUCCESS','SUCCESS',[workOrder]), timelines:section('SUCCESS','SUCCESS',[timeline]),
+      latestImage:section('SUCCESS','SUCCESS',image)
+    },
+    partial: {
+      aggregateState:'PARTIAL',
+      archive:section('FAILED','READ_FAILURE',null,'设备档案数据暂不可用'),
+      metrics:section('STALE','SAMPLE_OUTDATED',{ ...metrics, state:'STALE' }),
+      warnings:section('SUCCESS','SUCCESS',[warning]), alarms:section('SUCCESS','SUCCESS',[alarm]),
+      alarmWarningRelations:section('SUCCESS','SUCCESS',[relation]),
+      workOrders:section('FAILED','READ_FAILURE',null,'工单数据暂不可用'),
+      timelines:section('FAILED','SOURCE_SECTION_FAILED',null,'工单分区失败,时间线不可用'),
+      latestImage:section('FAILED','SOURCE_SECTION_FAILED',null,'设备档案失败,图片不可用')
+    },
+    empty: {
+      aggregateState:'EMPTY',
+      archive:section('EMPTY','NO_ARCHIVE',null), metrics:section('EMPTY','NO_METRICS',null),
+      warnings:section('EMPTY','NO_EVENTS',null), alarms:section('EMPTY','NO_EVENTS',null),
+      alarmWarningRelations:section('EMPTY','NO_EVENTS',null),
+      workOrders:section('EMPTY','NO_WORK_ORDERS',null), timelines:section('EMPTY','NO_WORK_ORDERS',null),
+      latestImage:section('EMPTY','NO_IMAGE',null)
+    }
+  };
+
+  function renderSection(name, result) {
+    const rows = [];
+    const data = result.data;
+    if (name === 'archive' && data) {
+      rows.push(['设备编码', data.deviceCode], ['设备名称', data.deviceName], ['设备型号', data.deviceModel], ['权属单位', data.ownershipUnit]);
+    } else if (name === 'metrics' && data) {
+      rows.push(['状态', `${data.state} / ${data.stateReason}`], ['最新COD', `${data.metrics[0].value} ${data.metrics[0].unit}`]);
+    } else if (name === 'warnings' && data) {
+      data.forEach(item => rows.push([item.warningNo, `${item.warningName} · ${item.status}`]));
+    } else if (name === 'alarms' && data) {
+      data.forEach(item => rows.push([item.alarmId, `${item.warningName} · ${item.status}`]));
+    } else if (name === 'alarmWarningRelations' && data) {
+      data.forEach(item => rows.push([item.relationId, `${item.warningNo} ⇄ ${item.alarmId}`]));
+    } else if (name === 'workOrders' && data) {
+      data.forEach(item => rows.push([item.workOrderCode, `${item.stateCode} ${item.stateName}`]));
+    } else if (name === 'timelines' && data) {
+      data.forEach(item => rows.push([item.workOrder.workOrderCode, `${item.timelineEntries.length} 条时间线`]));
+    } else if (name === 'latestImage' && data) {
+      rows.push(['图片地址', data.url], ['采集时间', data.capturedAt]);
+    }
+    const body = result.data
+      ? '<table><tbody>' + rows.map(row => `<tr><th>${row[0]}</th><td class="mono">${row[1]}</td></tr>`).join('') + '</tbody></table>'
+      : `<p class="missing">${result.message || '暂无数据'}</p>`;
+    return `<article class="section ${result.state.toLowerCase()}">
+      <span class="badge">${result.state}</span>
+      <h3>${name}</h3>
+      ${result.reason ? `<p class="note">reason:${result.reason}</p>` : ''}
+      ${body}
+    </article>`;
+  }
+  function render(mode) {
+    document.querySelectorAll('button[data-mode]').forEach(button => button.classList.toggle('active', button.dataset.mode === mode));
+    const payload = responses[mode];
+    document.getElementById('sections').innerHTML = Object.keys(payload)
+      .filter(key => key !== 'aggregateState')
+      .map(key => renderSection(key, payload[key])).join('');
+    document.getElementById('payload').textContent = JSON.stringify({
+      code:200, msg:'操作成功', data:{ requestId:'equipment-detail-demo', apiVersion:'v1', readOnly:true,
+        deviceCode:archive.deviceCode, aggregateState:payload.aggregateState, serverTime:'2026-09-02T10:00:00+08:00', ...payload }
+    }, null, 2);
+  }
+  document.querySelectorAll('button[data-mode]').forEach(button => button.addEventListener('click', () => render(button.dataset.mode)));
+  render('full');
+</script>
+</body>
+</html>

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

@@ -0,0 +1,66 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.detail.CockpitEquipmentDetailAggregateResponse;
+import com.zksy.cockpit.detail.CockpitEquipmentDetailAggregateService;
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+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 javax.servlet.http.HttpServletRequest;
+import java.nio.charset.StandardCharsets;
+
+@RestController
+public class CockpitEquipmentDetailAggregateController {
+    private static final String REQUEST_ID_HEADER = "X-Request-Id";
+
+    private final CockpitEquipmentDetailAggregateService aggregateService;
+
+    public CockpitEquipmentDetailAggregateController(CockpitEquipmentDetailAggregateService aggregateService) {
+        this.aggregateService = aggregateService;
+    }
+
+    @GetMapping("/api/cockpit/v1/equipments/{deviceCode}/detail-aggregate")
+    @PreAuthorize("isAuthenticated()")
+    public ResponseEntity<AjaxResult> equipmentDetailAggregate(
+            @PathVariable("deviceCode") String deviceCode,
+            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
+        CockpitEquipmentDetailAggregateResponse response = aggregateService.loadAggregate(
+                resolveRequestId(requestId), decodePathSegment(deviceCode));
+        return ResponseEntity.ok(AjaxResult.success(response));
+    }
+
+    @ExceptionHandler(CockpitInvalidRequestException.class)
+    public ResponseEntity<AjaxResult> handleInvalidRequest(HttpServletRequest request) {
+        CockpitErrorPayload payload = new CockpitErrorPayload(
+                CockpitErrorCategory.INVALID_REQUEST,
+                "设备详情查询参数不合法",
+                resolveRequestId(request.getHeader(REQUEST_ID_HEADER)),
+                null);
+        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
+                .body(new AjaxResult(HttpStatus.BAD_REQUEST.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 CockpitInvalidRequestException("invalid device code encoding");
+        }
+    }
+}

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

@@ -0,0 +1,349 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.detail.CockpitEquipmentDetailAggregateService;
+import com.zksy.cockpit.equipment.CockpitEquipmentArchive;
+import com.zksy.cockpit.equipment.CockpitEquipmentArchiveResponse;
+import com.zksy.cockpit.equipment.CockpitEquipmentArchiveService;
+import com.zksy.cockpit.equipment.CockpitEquipmentArchiveSnapshot;
+import com.zksy.cockpit.equipment.CockpitEquipmentImage;
+import com.zksy.cockpit.event.CockpitAlarmWarningRelation;
+import com.zksy.cockpit.event.CockpitEventAlarm;
+import com.zksy.cockpit.event.CockpitEventReadResponse;
+import com.zksy.cockpit.event.CockpitEventReadService;
+import com.zksy.cockpit.event.CockpitEventSnapshot;
+import com.zksy.cockpit.event.CockpitEventWarning;
+import com.zksy.cockpit.foundation.CockpitDeviceNotFoundException;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.type.CockpitMetric;
+import com.zksy.cockpit.type.CockpitTypeMetricsResponse;
+import com.zksy.cockpit.type.CockpitTypeMetricsService;
+import com.zksy.cockpit.workorder.CockpitWorkOrderEventAssociation;
+import com.zksy.cockpit.workorder.CockpitWorkOrderReadResponse;
+import com.zksy.cockpit.workorder.CockpitWorkOrderReadService;
+import com.zksy.cockpit.workorder.CockpitWorkOrderStateDictionary;
+import com.zksy.cockpit.workorder.CockpitWorkOrderSummary;
+import com.zksy.cockpit.workorder.CockpitWorkOrderTimelineEntry;
+import com.zksy.cockpit.workorder.CockpitWorkOrderTimelineResponse;
+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.hamcrest.Matchers.hasSize;
+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 CockpitEquipmentDetailAggregateControllerTest {
+    private static final String DEVICE_CODE = "TC-PS-WS-202609020012";
+
+    private CockpitEquipmentArchiveService archiveService;
+    private CockpitTypeMetricsService metricsService;
+    private CockpitEventReadService eventService;
+    private CockpitWorkOrderReadService workOrderService;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        archiveService = mock(CockpitEquipmentArchiveService.class);
+        metricsService = mock(CockpitTypeMetricsService.class);
+        eventService = mock(CockpitEventReadService.class);
+        workOrderService = mock(CockpitWorkOrderReadService.class);
+        CockpitEquipmentDetailAggregateService service = new CockpitEquipmentDetailAggregateService(
+                archiveService,
+                metricsService,
+                eventService,
+                workOrderService,
+                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+        mockMvc = MockMvcBuilders
+                .standaloneSetup(new CockpitEquipmentDetailAggregateController(service))
+                .build();
+    }
+
+    @Test
+    void aggregateReturnsIndependentSuccessfulSections() throws Exception {
+        stubSuccessfulSections();
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/detail-aggregate")
+                        .header("X-Request-Id", "detail-aggregate-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.requestId").value("detail-aggregate-001"))
+                .andExpect(jsonPath("$.data.apiVersion").value("v1"))
+                .andExpect(jsonPath("$.data.readOnly").value(true))
+                .andExpect(jsonPath("$.data.deviceCode").value(DEVICE_CODE))
+                .andExpect(jsonPath("$.data.aggregateState").value("READY"))
+                .andExpect(jsonPath("$.data.archive.state").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.archive.data.deviceCode").value(DEVICE_CODE))
+                .andExpect(jsonPath("$.data.archive.data.deviceName").value("污水环境监测设备"))
+                .andExpect(jsonPath("$.data.metrics.state").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.metrics.data.deviceCode").value(DEVICE_CODE))
+                .andExpect(jsonPath("$.data.metrics.data.state").value("CURRENT"))
+                .andExpect(jsonPath("$.data.metrics.data.metrics", hasSize(1)))
+                .andExpect(jsonPath("$.data.warnings.state").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.warnings.data", hasSize(1)))
+                .andExpect(jsonPath("$.data.alarms.state").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.alarms.data", hasSize(1)))
+                .andExpect(jsonPath("$.data.alarmWarningRelations.state").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.alarmWarningRelations.data", hasSize(1)))
+                .andExpect(jsonPath("$.data.workOrders.state").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.workOrders.data", hasSize(1)))
+                .andExpect(jsonPath("$.data.timelines.state").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.timelines.data", hasSize(1)))
+                .andExpect(jsonPath("$.data.timelines.data[0].timelineEntries", hasSize(1)))
+                .andExpect(jsonPath("$.data.latestImage.state").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.latestImage.data.url").value("https://cockpit.example/latest.jpg"));
+    }
+
+    @Test
+    void successfulEmptySectionsDoNotBecomeFailures() throws Exception {
+        stubEmptySections();
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/detail-aggregate"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.aggregateState").value("EMPTY"))
+                .andExpect(jsonPath("$.data.archive.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.metrics.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.warnings.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.alarms.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.alarmWarningRelations.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.workOrders.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.timelines.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.latestImage.state").value("EMPTY"));
+    }
+
+    @Test
+    void singleArchiveFailurePreservesSuccessfulSiblings() throws Exception {
+        when(archiveService.load("detail-aggregate-001", DEVICE_CODE))
+                .thenThrow(new CockpitReadException("archive read failed"));
+        stubSuccessfulSectionsExceptArchive();
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/detail-aggregate")
+                        .header("X-Request-Id", "detail-aggregate-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.aggregateState").value("PARTIAL"))
+                .andExpect(jsonPath("$.data.archive.state").value("FAILED"))
+                .andExpect(jsonPath("$.data.archive.reason").value("READ_FAILURE"))
+                .andExpect(jsonPath("$.data.metrics.state").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.warnings.state").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.alarms.state").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.workOrders.state").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.timelines.state").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.latestImage.state").value("FAILED"));
+    }
+
+    @Test
+    void multipleSectionFailuresAreReportedIndependently() throws Exception {
+        stubArchive();
+        when(metricsService.loadCurrent("detail-aggregate-001", DEVICE_CODE))
+                .thenThrow(new CockpitReadException("metrics failed"));
+        when(eventService.loadCurrent("detail-aggregate-001", DEVICE_CODE))
+                .thenThrow(new CockpitDeviceNotFoundException("events missing"));
+        when(workOrderService.loadByDevice("detail-aggregate-001", DEVICE_CODE))
+                .thenThrow(new CockpitReadException("work orders failed"));
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/detail-aggregate")
+                        .header("X-Request-Id", "detail-aggregate-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.aggregateState").value("PARTIAL"))
+                .andExpect(jsonPath("$.data.archive.state").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.metrics.state").value("FAILED"))
+                .andExpect(jsonPath("$.data.metrics.reason").value("READ_FAILURE"))
+                .andExpect(jsonPath("$.data.warnings.state").value("FAILED"))
+                .andExpect(jsonPath("$.data.warnings.reason").value("NOT_FOUND"))
+                .andExpect(jsonPath("$.data.alarms.state").value("FAILED"))
+                .andExpect(jsonPath("$.data.alarms.reason").value("NOT_FOUND"))
+                .andExpect(jsonPath("$.data.workOrders.state").value("FAILED"))
+                .andExpect(jsonPath("$.data.workOrders.reason").value("READ_FAILURE"))
+                .andExpect(jsonPath("$.data.timelines.state").value("FAILED"))
+                .andExpect(jsonPath("$.data.timelines.reason").value("SOURCE_SECTION_FAILED"))
+                .andExpect(jsonPath("$.data.latestImage.state").value("SUCCESS"));
+    }
+
+    @Test
+    void staleMetricsRemainVisibleWithIndependentStaleState() throws Exception {
+        stubArchive();
+        stubEventSection();
+        when(metricsService.loadCurrent("detail-aggregate-001", DEVICE_CODE))
+                .thenReturn(metricsResponse("STALE", "SAMPLE_OUTDATED"));
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/detail-aggregate")
+                        .header("X-Request-Id", "detail-aggregate-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.aggregateState").value("PARTIAL"))
+                .andExpect(jsonPath("$.data.metrics.state").value("STALE"))
+                .andExpect(jsonPath("$.data.metrics.data.state").value("STALE"))
+                .andExpect(jsonPath("$.data.metrics.data.metrics", hasSize(1)));
+    }
+
+    @Test
+    void deviceCodeMismatchDoesNotLeakForeignSectionData() throws Exception {
+        stubArchive();
+        when(metricsService.loadCurrent("detail-aggregate-001", DEVICE_CODE))
+                .thenReturn(metricsResponse("CURRENT", "READY"));
+        when(eventService.loadCurrent("detail-aggregate-001", DEVICE_CODE))
+                .thenReturn(eventResponse("CN-GS-202609020099", "EMPTY", "NO_EVENTS",
+                        Collections.emptyList(), Collections.emptyList(), Collections.emptyList()));
+
+        mockMvc.perform(get("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/detail-aggregate")
+                        .header("X-Request-Id", "detail-aggregate-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.metrics.state").value("SUCCESS"))
+                .andExpect(jsonPath("$.data.warnings.state").value("FAILED"))
+                .andExpect(jsonPath("$.data.warnings.reason").value("DEVICE_CODE_MISMATCH"))
+                .andExpect(jsonPath("$.data.warnings.data").doesNotExist())
+                .andExpect(jsonPath("$.data.alarms.state").value("FAILED"))
+                .andExpect(jsonPath("$.data.alarms.reason").value("DEVICE_CODE_MISMATCH"));
+    }
+
+    @Test
+    void controllerExposesReadOnlyMappedMethodsOnly() throws Exception {
+        Method[] methods = CockpitEquipmentDetailAggregateController.class.getDeclaredMethods();
+        for (Method method : methods) {
+            if (method.isAnnotationPresent(org.springframework.web.bind.annotation.GetMapping.class)) {
+                assertTrue(!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));
+            }
+        }
+
+        stubSuccessfulSections();
+        mockMvc.perform(post("/api/cockpit/v1/equipments/" + DEVICE_CODE + "/detail-aggregate"))
+                .andExpect(status().isMethodNotAllowed());
+    }
+
+    private void assertTrue(boolean condition) {
+        org.junit.jupiter.api.Assertions.assertTrue(condition);
+    }
+
+    private void stubSuccessfulSections() {
+        stubArchive();
+        stubSuccessfulSectionsExceptArchive();
+    }
+
+    private void stubSuccessfulSectionsExceptArchive() {
+        when(metricsService.loadCurrent("detail-aggregate-001", DEVICE_CODE))
+                .thenReturn(metricsResponse("CURRENT", "READY"));
+        stubEventSection();
+        stubWorkOrderSections();
+    }
+
+    private void stubEmptySections() {
+        when(archiveService.load("detail-aggregate-001", DEVICE_CODE))
+                .thenReturn(new CockpitEquipmentArchiveResponse(
+                        "detail-aggregate-001", "v1", true, new CockpitEquipmentArchive(null, "WGS84", null)));
+        when(metricsService.loadCurrent("detail-aggregate-001", DEVICE_CODE))
+                .thenReturn(metricsResponse("EMPTY", "NO_METRICS"));
+        when(eventService.loadCurrent("detail-aggregate-001", DEVICE_CODE))
+                .thenReturn(eventResponse(DEVICE_CODE, "EMPTY", "NO_EVENTS", Collections.emptyList(), Collections.emptyList(), Collections.emptyList()));
+        when(workOrderService.loadByDevice("detail-aggregate-001", DEVICE_CODE))
+                .thenReturn(workOrderResponse(DEVICE_CODE, "EMPTY", "NO_WORK_ORDERS", Collections.emptyList()));
+    }
+
+    private void stubSuccessfulSectionsExceptWorkOrders() {
+        stubArchive();
+        when(metricsService.loadCurrent("detail-aggregate-001", DEVICE_CODE))
+                .thenReturn(metricsResponse("CURRENT", "READY"));
+        stubEventSection();
+    }
+
+    private void stubArchive() {
+        CockpitEquipmentImage image = new CockpitEquipmentImage(
+                "https://cockpit.example/latest.jpg", "2026-08-30T10:20:00+08:00");
+        CockpitEquipmentArchiveSnapshot snapshot = new CockpitEquipmentArchiveSnapshot(
+                DEVICE_CODE, "污水环境监测设备", "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", "沅陵县排水管理所", "演示设备",
+                Collections.singletonList(image));
+        when(archiveService.load("detail-aggregate-001", DEVICE_CODE)).thenReturn(new CockpitEquipmentArchiveResponse(
+                "detail-aggregate-001", "v1", true, new CockpitEquipmentArchive(snapshot, "WGS84", image)));
+    }
+
+    private void stubEventSection() {
+        when(eventService.loadCurrent("detail-aggregate-001", DEVICE_CODE)).thenReturn(eventResponse(
+                DEVICE_CODE, "READY", "READY",
+                Collections.singletonList(warning("WRN-20260902-001")),
+                Collections.singletonList(alarm("ALM-20260902-001")),
+                Collections.singletonList(new CockpitAlarmWarningRelation(
+                        "REL-001", "WRN-20260902-001", "ALM-20260902-001", "2026-09-02T10:00:00+08:00"))));
+    }
+
+    private void stubWorkOrderSections() {
+        CockpitWorkOrderSummary summary = workOrderSummary();
+        when(workOrderService.loadByDevice("detail-aggregate-001", DEVICE_CODE)).thenReturn(workOrderResponse(
+                DEVICE_CODE, "READY", "READY", Collections.singletonList(summary)));
+        when(workOrderService.loadTimeline("detail-aggregate-001", "WO-2026090200001")).thenReturn(timelineResponse(summary));
+    }
+
+    private CockpitTypeMetricsResponse metricsResponse(String state, String stateReason) {
+        CockpitMetric metric = new CockpitMetric(
+                "COD", "COD", new BigDecimal("18.2"), "mg/L", "WARNING",
+                "2026-09-02T09:59:30+08:00", "CURRENT", Collections.emptyList());
+        return new CockpitTypeMetricsResponse(
+                "detail-aggregate-001", "v1", true, DEVICE_CODE, "ws_sewage_environment", "污水环境监测设备",
+                state, stateReason, Collections.singletonList(metric), null, null, "Asia/Shanghai", 1, 0, 1);
+    }
+
+    private CockpitEventReadResponse eventResponse(
+            String deviceCode, String state, String stateReason,
+            java.util.List<CockpitEventWarning> warnings,
+            java.util.List<CockpitEventAlarm> alarms,
+            java.util.List<CockpitAlarmWarningRelation> relations) {
+        return new CockpitEventReadResponse(
+                "detail-aggregate-001", "v1", true, deviceCode, "ws_sewage_environment", "污水环境监测设备",
+                state, stateReason, warnings, alarms, relations, null, null, "Asia/Shanghai", 1, 0, 2);
+    }
+
+    private CockpitWorkOrderReadResponse workOrderResponse(
+            String deviceCode, String state, String stateReason,
+            java.util.List<CockpitWorkOrderSummary> workOrders) {
+        return new CockpitWorkOrderReadResponse(
+                "detail-aggregate-001", "v1", true, deviceCode, null, null,
+                state, stateReason, CockpitWorkOrderStateDictionary.states(), workOrders);
+    }
+
+    private CockpitWorkOrderTimelineResponse timelineResponse(CockpitWorkOrderSummary summary) {
+        return new CockpitWorkOrderTimelineResponse(
+                "detail-aggregate-001", "v1", true, summary.getWorkOrderCode(),
+                CockpitWorkOrderStateDictionary.states(), summary,
+                Collections.singletonList(new CockpitWorkOrderTimelineEntry(
+                        1L, summary.getWorkOrderCode(), 4, "处理中", "创建工单", "处理异常", "系统",
+                        "2026-09-02T09:00:00+08:00")));
+    }
+
+    private CockpitWorkOrderSummary workOrderSummary() {
+        return new CockpitWorkOrderSummary(
+                "WO-2026090200001", DEVICE_CODE, 1, "故障维修", 1, "处理异常", 4, "处理中",
+                "2026-09-02T09:00:00+08:00", "2026-09-02T10:00:00+08:00",
+                "2026-09-03T09:00:00+08:00", null,
+                new CockpitWorkOrderEventAssociation("ALARM", "ALM-20260902-001", "2026-09-02T10:00:00+08:00"));
+    }
+
+    private CockpitEventWarning warning(String warningNo) {
+        return new CockpitEventWarning(
+                warningNo, "污水COD预警", "WS_ENV", 1, "RED", "PROCESSING",
+                "2026-09-02T09:59:30+08:00", new BigDecimal("18.2"), "mg/L",
+                DEVICE_CODE, "污水环境监测设备", "太常片区");
+    }
+
+    private CockpitEventAlarm alarm(String alarmId) {
+        return new CockpitEventAlarm(
+                alarmId, "WS_ENV_HIGH", "污水COD报警", "WS_ENV", 1, "RED", "UNHANDLED",
+                "2026-09-02T10:00:00+08:00", new BigDecimal("42"), new BigDecimal("40"),
+                new BigDecimal("50"), "mg/L", DEVICE_CODE, "污水环境监测设备", "太常片区");
+    }
+}

+ 75 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/detail/CockpitEquipmentDetailAggregateResponse.java

@@ -0,0 +1,75 @@
+package com.zksy.cockpit.detail;
+
+import com.zksy.cockpit.equipment.CockpitEquipmentArchive;
+import com.zksy.cockpit.equipment.CockpitEquipmentImage;
+import com.zksy.cockpit.event.CockpitAlarmWarningRelation;
+import com.zksy.cockpit.event.CockpitEventAlarm;
+import com.zksy.cockpit.event.CockpitEventWarning;
+import com.zksy.cockpit.type.CockpitTypeMetricsResponse;
+import com.zksy.cockpit.workorder.CockpitWorkOrderSummary;
+import com.zksy.cockpit.workorder.CockpitWorkOrderTimelineResponse;
+
+import java.util.List;
+
+public class CockpitEquipmentDetailAggregateResponse {
+    private final String requestId;
+    private final String apiVersion;
+    private final boolean readOnly;
+    private final String deviceCode;
+    private final String aggregateState;
+    private final String serverTime;
+    private final CockpitSectionResult<CockpitEquipmentArchive> archive;
+    private final CockpitSectionResult<CockpitTypeMetricsResponse> metrics;
+    private final CockpitSectionResult<java.util.List<CockpitEventWarning>> warnings;
+    private final CockpitSectionResult<java.util.List<CockpitEventAlarm>> alarms;
+    private final CockpitSectionResult<java.util.List<CockpitAlarmWarningRelation>> alarmWarningRelations;
+    private final CockpitSectionResult<java.util.List<CockpitWorkOrderSummary>> workOrders;
+    private final CockpitSectionResult<java.util.List<CockpitWorkOrderTimelineResponse>> timelines;
+    private final CockpitSectionResult<CockpitEquipmentImage> latestImage;
+
+    public CockpitEquipmentDetailAggregateResponse(
+            String requestId,
+            String apiVersion,
+            boolean readOnly,
+            String deviceCode,
+            String aggregateState,
+            String serverTime,
+            CockpitSectionResult<CockpitEquipmentArchive> archive,
+            CockpitSectionResult<CockpitTypeMetricsResponse> metrics,
+            CockpitSectionResult<java.util.List<CockpitEventWarning>> warnings,
+            CockpitSectionResult<java.util.List<CockpitEventAlarm>> alarms,
+            CockpitSectionResult<java.util.List<CockpitAlarmWarningRelation>> alarmWarningRelations,
+            CockpitSectionResult<java.util.List<CockpitWorkOrderSummary>> workOrders,
+            CockpitSectionResult<java.util.List<CockpitWorkOrderTimelineResponse>> timelines,
+            CockpitSectionResult<CockpitEquipmentImage> latestImage) {
+        this.requestId = requestId;
+        this.apiVersion = apiVersion;
+        this.readOnly = readOnly;
+        this.deviceCode = deviceCode;
+        this.aggregateState = aggregateState;
+        this.serverTime = serverTime;
+        this.archive = archive;
+        this.metrics = metrics;
+        this.warnings = warnings;
+        this.alarms = alarms;
+        this.alarmWarningRelations = alarmWarningRelations;
+        this.workOrders = workOrders;
+        this.timelines = timelines;
+        this.latestImage = latestImage;
+    }
+
+    public String getRequestId() { return requestId; }
+    public String getApiVersion() { return apiVersion; }
+    public boolean isReadOnly() { return readOnly; }
+    public String getDeviceCode() { return deviceCode; }
+    public String getAggregateState() { return aggregateState; }
+    public String getServerTime() { return serverTime; }
+    public CockpitSectionResult<CockpitEquipmentArchive> getArchive() { return archive; }
+    public CockpitSectionResult<CockpitTypeMetricsResponse> getMetrics() { return metrics; }
+    public CockpitSectionResult<java.util.List<CockpitEventWarning>> getWarnings() { return warnings; }
+    public CockpitSectionResult<java.util.List<CockpitEventAlarm>> getAlarms() { return alarms; }
+    public CockpitSectionResult<java.util.List<CockpitAlarmWarningRelation>> getAlarmWarningRelations() { return alarmWarningRelations; }
+    public CockpitSectionResult<java.util.List<CockpitWorkOrderSummary>> getWorkOrders() { return workOrders; }
+    public CockpitSectionResult<java.util.List<CockpitWorkOrderTimelineResponse>> getTimelines() { return timelines; }
+    public CockpitSectionResult<CockpitEquipmentImage> getLatestImage() { return latestImage; }
+}

+ 312 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/detail/CockpitEquipmentDetailAggregateService.java

@@ -0,0 +1,312 @@
+package com.zksy.cockpit.detail;
+
+import com.zksy.cockpit.equipment.CockpitEquipmentArchive;
+import com.zksy.cockpit.equipment.CockpitEquipmentArchiveResponse;
+import com.zksy.cockpit.equipment.CockpitEquipmentArchiveService;
+import com.zksy.cockpit.equipment.CockpitEquipmentImage;
+import com.zksy.cockpit.event.CockpitAlarmWarningRelation;
+import com.zksy.cockpit.event.CockpitEventAlarm;
+import com.zksy.cockpit.event.CockpitEventReadResponse;
+import com.zksy.cockpit.event.CockpitEventReadService;
+import com.zksy.cockpit.event.CockpitEventWarning;
+import com.zksy.cockpit.foundation.CockpitDeviceNotFoundException;
+import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.cockpit.type.CockpitTypeMetricsResponse;
+import com.zksy.cockpit.type.CockpitTypeMetricsService;
+import com.zksy.cockpit.workorder.CockpitWorkOrderReadResponse;
+import com.zksy.cockpit.workorder.CockpitWorkOrderReadService;
+import com.zksy.cockpit.workorder.CockpitWorkOrderSummary;
+import com.zksy.cockpit.workorder.CockpitWorkOrderTimelineResponse;
+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.List;
+import java.util.Objects;
+
+@Service
+public class CockpitEquipmentDetailAggregateService {
+    private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+
+    private final CockpitEquipmentArchiveService archiveService;
+    private final CockpitTypeMetricsService metricsService;
+    private final CockpitEventReadService eventService;
+    private final CockpitWorkOrderReadService workOrderService;
+    private final Clock clock;
+
+    public CockpitEquipmentDetailAggregateService(
+            CockpitEquipmentArchiveService archiveService,
+            CockpitTypeMetricsService metricsService,
+            CockpitEventReadService eventService,
+            CockpitWorkOrderReadService workOrderService,
+            Clock clock) {
+        this.archiveService = archiveService;
+        this.metricsService = metricsService;
+        this.eventService = eventService;
+        this.workOrderService = workOrderService;
+        this.clock = clock;
+    }
+
+    public CockpitEquipmentDetailAggregateResponse loadAggregate(String requestId, String deviceCode) {
+        if (isBlank(deviceCode)) {
+            throw new CockpitInvalidRequestException("device code is required");
+        }
+        String normalizedDeviceCode = deviceCode.trim();
+        CockpitSectionResult<CockpitEquipmentArchive> archive = loadArchive(requestId, normalizedDeviceCode);
+        CockpitSectionResult<CockpitTypeMetricsResponse> metrics = loadMetrics(requestId, normalizedDeviceCode);
+        CockpitEventSection eventSection = loadEventSection(requestId, normalizedDeviceCode);
+        CockpitSectionResult<List<CockpitEventWarning>> warnings = warningsFrom(eventSection);
+        CockpitSectionResult<List<CockpitEventAlarm>> alarms = alarmsFrom(eventSection);
+        CockpitSectionResult<List<CockpitAlarmWarningRelation>> relations = relationsFrom(eventSection);
+        CockpitSectionResult<List<CockpitWorkOrderSummary>> workOrders =
+                loadWorkOrders(requestId, normalizedDeviceCode);
+        CockpitSectionResult<List<CockpitWorkOrderTimelineResponse>> timelines =
+                loadTimelines(requestId, workOrders);
+        CockpitSectionResult<CockpitEquipmentImage> latestImage = loadLatestImage(archive);
+
+        return new CockpitEquipmentDetailAggregateResponse(
+                requestId,
+                "v1",
+                true,
+                normalizedDeviceCode,
+                aggregateState(archive, metrics, warnings, alarms, relations, workOrders, timelines, latestImage),
+                serverTime(),
+                archive,
+                metrics,
+                warnings,
+                alarms,
+                relations,
+                workOrders,
+                timelines,
+                latestImage);
+    }
+
+    private CockpitSectionResult<CockpitEquipmentArchive> loadArchive(String requestId, String deviceCode) {
+        try {
+            CockpitEquipmentArchiveResponse response = archiveService.load(requestId, deviceCode);
+            CockpitEquipmentArchive archive = response == null ? null : response.getArchive();
+            if (isEmptyArchive(archive)) {
+                return CockpitSectionResult.empty("NO_ARCHIVE");
+            }
+            if (!Objects.equals(deviceCode, archive.getDeviceCode())) {
+                return CockpitSectionResult.failure("DEVICE_CODE_MISMATCH", "设备档案编码不匹配");
+            }
+            return CockpitSectionResult.success(archive);
+        } catch (CockpitDeviceNotFoundException exception) {
+            return CockpitSectionResult.failure("NOT_FOUND", "设备档案不存在");
+        } catch (CockpitReadException exception) {
+            return CockpitSectionResult.failure("READ_FAILURE", "设备档案数据暂不可用");
+        } catch (RuntimeException exception) {
+            return CockpitSectionResult.failure("READ_FAILURE", "设备档案数据暂不可用");
+        }
+    }
+
+    private CockpitSectionResult<CockpitTypeMetricsResponse> loadMetrics(String requestId, String deviceCode) {
+        try {
+            CockpitTypeMetricsResponse response = metricsService.loadCurrent(requestId, deviceCode);
+            if (response == null) {
+                return CockpitSectionResult.empty("NO_METRICS");
+            }
+            if (!Objects.equals(deviceCode, response.getDeviceCode())) {
+                return CockpitSectionResult.failure("DEVICE_CODE_MISMATCH", "设备指标编码不匹配");
+            }
+            if ("EMPTY".equals(response.getState())) {
+                return CockpitSectionResult.empty("NO_METRICS");
+            }
+            if ("STALE".equals(response.getState())) {
+                return CockpitSectionResult.stale(response.getStateReason(), response);
+            }
+            return CockpitSectionResult.success(response);
+        } catch (CockpitDeviceNotFoundException exception) {
+            return CockpitSectionResult.failure("NOT_FOUND", "设备指标不存在");
+        } catch (CockpitReadException exception) {
+            return CockpitSectionResult.failure("READ_FAILURE", "设备指标数据暂不可用");
+        } catch (RuntimeException exception) {
+            return CockpitSectionResult.failure("READ_FAILURE", "设备指标数据暂不可用");
+        }
+    }
+
+    private CockpitSectionResult<List<CockpitEventWarning>> warningsFrom(CockpitEventSection eventSection) {
+        if (eventSection.result.getState() == CockpitSectionState.FAILED) {
+            return CockpitSectionResult.failure(eventSection.result.getReason(), eventSection.result.getMessage());
+        }
+        if (eventSection.result.getState() == CockpitSectionState.EMPTY) {
+            return CockpitSectionResult.empty(eventSection.result.getReason());
+        }
+        return CockpitSectionResult.success(safeList(eventSection.response.getWarnings()));
+    }
+
+    private CockpitSectionResult<List<CockpitEventAlarm>> alarmsFrom(CockpitEventSection eventSection) {
+        if (eventSection.result.getState() == CockpitSectionState.FAILED) {
+            return CockpitSectionResult.failure(eventSection.result.getReason(), eventSection.result.getMessage());
+        }
+        if (eventSection.result.getState() == CockpitSectionState.EMPTY) {
+            return CockpitSectionResult.empty(eventSection.result.getReason());
+        }
+        return CockpitSectionResult.success(safeList(eventSection.response.getAlarms()));
+    }
+
+    private CockpitSectionResult<List<CockpitAlarmWarningRelation>> relationsFrom(CockpitEventSection eventSection) {
+        if (eventSection.result.getState() == CockpitSectionState.FAILED) {
+            return CockpitSectionResult.failure(eventSection.result.getReason(), eventSection.result.getMessage());
+        }
+        if (eventSection.result.getState() == CockpitSectionState.EMPTY) {
+            return CockpitSectionResult.empty(eventSection.result.getReason());
+        }
+        return CockpitSectionResult.success(safeList(eventSection.response.getAlarmWarningRelations()));
+    }
+
+    private CockpitEventSection loadEventSection(String requestId, String deviceCode) {
+        try {
+            CockpitEventReadResponse response = eventService.loadCurrent(requestId, deviceCode);
+            if (response == null) {
+                return new CockpitEventSection(CockpitSectionResult.empty("NO_EVENTS"), null);
+            }
+            if (!Objects.equals(deviceCode, response.getDeviceCode())) {
+                return new CockpitEventSection(
+                        CockpitSectionResult.failure("DEVICE_CODE_MISMATCH", "事件设备编码不匹配"), response);
+            }
+            if ("EMPTY".equals(response.getState())) {
+                return new CockpitEventSection(CockpitSectionResult.empty("NO_EVENTS"), response);
+            }
+            if ("STALE".equals(response.getState())) {
+                return new CockpitEventSection(CockpitSectionResult.stale(response.getStateReason(), response), response);
+            }
+            return new CockpitEventSection(CockpitSectionResult.success(response), response);
+        } catch (CockpitDeviceNotFoundException exception) {
+            return new CockpitEventSection(CockpitSectionResult.failure("NOT_FOUND", "事件数据不存在"), null);
+        } catch (CockpitReadException exception) {
+            return new CockpitEventSection(CockpitSectionResult.failure("READ_FAILURE", "事件数据暂不可用"), null);
+        } catch (RuntimeException exception) {
+            return new CockpitEventSection(CockpitSectionResult.failure("READ_FAILURE", "事件数据暂不可用"), null);
+        }
+    }
+
+    private CockpitSectionResult<List<CockpitWorkOrderSummary>> loadWorkOrders(String requestId, String deviceCode) {
+        try {
+            CockpitWorkOrderReadResponse response = workOrderService.loadByDevice(requestId, deviceCode);
+            if (response == null) {
+                return CockpitSectionResult.empty("NO_WORK_ORDERS");
+            }
+            if (!Objects.equals(deviceCode, response.getDeviceCode())) {
+                return CockpitSectionResult.failure("DEVICE_CODE_MISMATCH", "工单设备编码不匹配");
+            }
+            if ("EMPTY".equals(response.getState())) {
+                return CockpitSectionResult.empty("NO_WORK_ORDERS");
+            }
+            return CockpitSectionResult.success(safeList(response.getWorkOrders()));
+        } catch (CockpitDeviceNotFoundException exception) {
+            return CockpitSectionResult.failure("NOT_FOUND", "工单数据不存在");
+        } catch (CockpitReadException exception) {
+            return CockpitSectionResult.failure("READ_FAILURE", "工单数据暂不可用");
+        } catch (RuntimeException exception) {
+            return CockpitSectionResult.failure("READ_FAILURE", "工单数据暂不可用");
+        }
+    }
+
+    private CockpitSectionResult<List<CockpitWorkOrderTimelineResponse>> loadTimelines(
+            String requestId,
+            CockpitSectionResult<List<CockpitWorkOrderSummary>> workOrders) {
+        if (workOrders.getState() == CockpitSectionState.FAILED) {
+            return CockpitSectionResult.failure("SOURCE_SECTION_FAILED", "工单分区失败,时间线不可用");
+        }
+        if (workOrders.getState() == CockpitSectionState.EMPTY || workOrders.getData().isEmpty()) {
+            return CockpitSectionResult.empty("NO_WORK_ORDERS");
+        }
+
+        List<CockpitWorkOrderTimelineResponse> timelines = new ArrayList<>();
+        boolean failed = false;
+        boolean hasData = false;
+        for (CockpitWorkOrderSummary summary : workOrders.getData()) {
+            try {
+                CockpitWorkOrderTimelineResponse response =
+                        workOrderService.loadTimeline(requestId, summary.getWorkOrderCode());
+                if (response != null) {
+                    timelines.add(response);
+                    hasData = true;
+                }
+            } catch (RuntimeException exception) {
+                failed = true;
+            }
+        }
+        if (failed) {
+            return new CockpitSectionResult<>(
+                    CockpitSectionState.FAILED, "TIMELINE_FAILURE", "部分工单时间线不可用", hasData ? timelines : null);
+        }
+        if (!hasData) {
+            return CockpitSectionResult.empty("NO_TIMELINES");
+        }
+        return CockpitSectionResult.success(timelines);
+    }
+
+    private CockpitSectionResult<CockpitEquipmentImage> loadLatestImage(
+            CockpitSectionResult<CockpitEquipmentArchive> archive) {
+        if (archive.getState() == CockpitSectionState.FAILED) {
+            return CockpitSectionResult.failure("SOURCE_SECTION_FAILED", "设备档案失败,图片不可用");
+        }
+        CockpitEquipmentImage image = archive.getData() == null ? null : archive.getData().getLatestImage();
+        return image == null ? CockpitSectionResult.empty("NO_IMAGE") : CockpitSectionResult.success(image);
+    }
+
+    private String aggregateState(
+            CockpitSectionResult<?> archive,
+            CockpitSectionResult<?> metrics,
+            CockpitSectionResult<?> warnings,
+            CockpitSectionResult<?> alarms,
+            CockpitSectionResult<?> relations,
+            CockpitSectionResult<?> workOrders,
+            CockpitSectionResult<?> timelines,
+            CockpitSectionResult<?> latestImage) {
+        List<CockpitSectionResult<?>> sections = List.of(
+                archive, metrics, warnings, alarms, relations, workOrders, timelines, latestImage);
+        long failed = sections.stream().filter(section -> section.getState() == CockpitSectionState.FAILED).count();
+        boolean anySuccess = sections.stream().anyMatch(section -> section.getState() == CockpitSectionState.SUCCESS);
+        boolean anyStale = sections.stream().anyMatch(section -> section.getState() == CockpitSectionState.STALE);
+        boolean allEmpty = sections.stream().allMatch(section -> section.getState() == CockpitSectionState.EMPTY);
+        if (failed == sections.size()) {
+            return "FAILED";
+        }
+        if (allEmpty) {
+            return "EMPTY";
+        }
+        if (failed > 0 || anyStale) {
+            return "PARTIAL";
+        }
+        return anySuccess ? "READY" : "EMPTY";
+    }
+
+    private boolean isEmptyArchive(CockpitEquipmentArchive archive) {
+        return archive == null || (
+                isBlank(archive.getDeviceName())
+                        && isBlank(archive.getDeviceModel())
+                        && isBlank(archive.getManufacturer())
+                        && isBlank(archive.getDeviceTypeName()));
+    }
+
+    private <T> List<T> safeList(List<T> values) {
+        return values == null ? List.of() : values;
+    }
+
+    private String serverTime() {
+        return TIME_FORMATTER.format(clock.instant().atZone(ZoneId.of("Asia/Shanghai")));
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+
+    private static final class CockpitEventSection {
+        private final CockpitSectionResult<CockpitEventReadResponse> result;
+        private final CockpitEventReadResponse response;
+
+        private CockpitEventSection(
+                CockpitSectionResult<CockpitEventReadResponse> result,
+                CockpitEventReadResponse response) {
+            this.result = result;
+            this.response = response;
+        }
+    }
+}

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

@@ -0,0 +1,36 @@
+package com.zksy.cockpit.detail;
+
+public class CockpitSectionResult<T> {
+    private final CockpitSectionState state;
+    private final String reason;
+    private final String message;
+    private final T data;
+
+    CockpitSectionResult(CockpitSectionState state, String reason, String message, T data) {
+        this.state = state;
+        this.reason = reason;
+        this.message = message;
+        this.data = data;
+    }
+
+    public static <T> CockpitSectionResult<T> success(T data) {
+        return new CockpitSectionResult<>(CockpitSectionState.SUCCESS, "SUCCESS", null, data);
+    }
+
+    public static <T> CockpitSectionResult<T> empty(String reason) {
+        return new CockpitSectionResult<>(CockpitSectionState.EMPTY, reason, null, null);
+    }
+
+    public static <T> CockpitSectionResult<T> stale(String reason, T data) {
+        return new CockpitSectionResult<>(CockpitSectionState.STALE, reason, null, data);
+    }
+
+    public static <T> CockpitSectionResult<T> failure(String reason, String message) {
+        return new CockpitSectionResult<>(CockpitSectionState.FAILED, reason, message, null);
+    }
+
+    public CockpitSectionState getState() { return state; }
+    public String getReason() { return reason; }
+    public String getMessage() { return message; }
+    public T getData() { return data; }
+}

+ 8 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/detail/CockpitSectionState.java

@@ -0,0 +1,8 @@
+package com.zksy.cockpit.detail;
+
+public enum CockpitSectionState {
+    SUCCESS,
+    EMPTY,
+    STALE,
+    FAILED
+}

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

@@ -37,4 +37,5 @@ public class CockpitEquipmentArchiveResponse {
     public String getOwnershipUnit() { return archive.getOwnershipUnit(); }
     public String getRemark() { return archive.getRemark(); }
     public CockpitEquipmentImage getLatestImage() { return archive.getLatestImage(); }
+    public CockpitEquipmentArchive getArchive() { return archive; }
 }