Jelajahi Sumber

feat(cockpit): 历史分析补逐事件行与 page/pageSize 分页(票 61 批次三)

- /events/history-analysis 新增 events[](与 events/current 的 EventItem 同构,
  eventTime 倒序 + eventNo 升序)与 pagination;接 page/pageSize(默认 1/100、上限 1000)
- 快照源行补展示字段(状态/设备名/位置/量值/阈值),与 currentWarning/currentAlarm 同源
- 契约测试:逐事件行排序分页、族隔离、越界页、非法分页;cockpit 419/419 通过
- docs/cockpit/README.md 同步契约说明(原「不存在逐事件行」表述已更正)
Kazerin 1 hari lalu
induk
melakukan
9ac8a8ab89

+ 2 - 2
docs/cockpit/README.md

@@ -41,7 +41,7 @@
 | 运行监测 | `GET /api/cockpit/v1/monitoring/distribution` | `industry` `deviceType` `pointStatus` `district` `page` `pageSize` `snapshotAt` |
 | 运行监测 | `GET /api/cockpit/v1/monitoring/situation` | `industry` `deviceType` `startTime` `endTime` `page` `pageSize` |
 | 预警处置 | `GET /api/cockpit/v1/events/current` | `mode`(必填) `level` `industry` `status` `page` `pageSize` |
-| 预警处置 | `GET /api/cockpit/v1/events/history-analysis` | `eventFamily`(必填) `level` `industry` `startTime` `endTime` |
+| 预警处置 | `GET /api/cockpit/v1/events/history-analysis` | `eventFamily`(必填) `level` `industry` `startTime` `endTime` `page` `pageSize` |
 | 预警处置 | `GET /api/cockpit/v1/events/handling-efficiency` | `eventFamily`(必填) `industry` `level` `workOrderState` `startTime` `endTime` `page` `pageSize` |
 | 预警处置 | `GET /api/cockpit/v1/events/multidimensional-analysis` | `eventFamily`(必填) `industry` `level` `eventType` `cause` `deviceType` `district` `startTime` `endTime` |
 
@@ -96,7 +96,7 @@
 - 未知类型、非智能设备、非正式行业在**按行业/类型筛选时**显式排除,不做静默归类;`map-snapshot` 不带筛选时仍返回全部对象(`industry`/`typeName` 为 `null`),保证「全城底图」不因类型数据缺失而丢对象。
 - 事件行(`overview`/`warningResponse` 的 `events/current` 的 `EventItem`)携带 `coordinateValidation`,与地图/监测切片**同一校验实现**(`CockpitCoordinateValidator`);坐标结构非法、越界或落在主场景区外时 `renderable = false` 且给出 `reason`。
 - 事件坐标按同一自动准入判定(不再依赖 `coordinateVerified`):区内且数值合法 → `VALID` / `renderable=true`。报警族在数据可得时另带 `thresholdMinValue`/`thresholdMaxValue`(预警族为 `null`)。
-- `events/history-analysis` 的 `rows[]` 是**小时桶聚合行**,不存在逐事件行,因此不携带 `coordinateValidation` 与阈值字段(逐事件坐标只在 `events/current` 提供)。
+- `events/history-analysis` 同时返回两块内容:`rows[]` 是**小时桶聚合行**(趋势与行级处置率口径,不携带逐事件坐标/阈值);`events[]` 是**逐事件行**(票 61 新增,形状与 `events/current` 的 `EventItem` 同构),按 `eventTime` 倒序、同时间按 `eventNo` 升序,由 `page`/`pageSize`(默认 1/100,上限 1000)分页,元数据在 `pagination` 中,`pagination.total` 即窗口内事件总数。逐事件坐标仍只在 `events/current` 提供,历史逐事件行的 `coordinateValidation` 为 `null`。
 
 ### 监测点详情设备坐标
 

+ 5 - 1
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitHistoryAnalysisController.java

@@ -34,6 +34,8 @@ public class CockpitHistoryAnalysisController {
             @RequestParam(value = "industry", required = false) String industry,
             @RequestParam(value = "startTime", required = false) String startTime,
             @RequestParam(value = "endTime", required = false) String endTime,
+            @RequestParam(value = "page", defaultValue = "1") Integer page,
+            @RequestParam(value = "pageSize", defaultValue = "100") Integer pageSize,
             @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
         return ResponseEntity.ok(AjaxResult.success(
                 historyAnalysisService.load(
@@ -42,7 +44,9 @@ public class CockpitHistoryAnalysisController {
                         level,
                         industry,
                         startTime,
-                        endTime)));
+                        endTime,
+                        page,
+                        pageSize)));
     }
 
     @ExceptionHandler(CockpitInvalidRequestException.class)

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

@@ -212,6 +212,15 @@ class CockpitContractIntegrationTest {
         mockMvc.perform(get("/api/cockpit/v1/events/history-analysis")
                         .param("eventFamily", "WARNING"))
                 .andExpect(status().isOk());
+        // 票 61:历史分析除小时桶外提供逐事件行分页(与 events/current 同一套页码语义)。
+        mockMvc.perform(get("/api/cockpit/v1/events/history-analysis")
+                        .param("eventFamily", "WARNING")
+                        .param("page", "1")
+                        .param("pageSize", "10"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.pagination.page").value(1))
+                .andExpect(jsonPath("$.data.pagination.pageSize").value(10))
+                .andExpect(jsonPath("$.data.events").isArray());
         mockMvc.perform(get("/api/cockpit/v1/events/handling-efficiency")
                         .param("eventFamily", "WARNING"))
                 .andExpect(status().isOk());

+ 96 - 6
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitHistoryAnalysisControllerTest.java

@@ -88,8 +88,11 @@ class CockpitHistoryAnalysisControllerTest {
                 .andExpect(jsonPath("$.data.summary.handlingEfficiency.denominator").value(5))
                 .andExpect(jsonPath("$.data.summary.handlingEfficiency.state").value("AVAILABLE"))
                 .andExpect(jsonPath("$.data.rows", hasSize(4)))
-                .andExpect(jsonPath("$.data.rows[0].timeBucket").value(BUCKET_23))
-                .andExpect(jsonPath("$.data.rows[0].eventCount").value(1))
+                .andExpect(jsonPath("$.data.events", hasSize(5)))
+                .andExpect(jsonPath("$.data.pagination.page").value(1))
+                .andExpect(jsonPath("$.data.pagination.pageSize").value(100))
+                .andExpect(jsonPath("$.data.pagination.total").value(5))
+                .andExpect(jsonPath("$.data.rows[0].timeBucket").value(BUCKET_23))                .andExpect(jsonPath("$.data.rows[0].eventCount").value(1))
                 .andExpect(jsonPath("$.data.rows[0].handledCount").value(1))
                 .andExpect(jsonPath("$.data.rows[0].handlingEfficiency.value").value("1.0000"))
                 .andExpect(jsonPath("$.data.rows[0].mapFilter.deviceCodes[0]").value("D-GAS-1"))
@@ -165,7 +168,11 @@ class CockpitHistoryAnalysisControllerTest {
                         .value("NO_ELIGIBLE_WORK_ORDERS"))
                 .andExpect(jsonPath("$.data.rows", hasSize(3)))
                 .andExpect(jsonPath("$.data.rows[0].eventCount").value(0))
-                .andExpect(jsonPath("$.data.rows[2].handlingEfficiency.state").value("EMPTY"));
+                .andExpect(jsonPath("$.data.rows[2].handlingEfficiency.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.events", hasSize(0)))
+                .andExpect(jsonPath("$.data.pagination.total").value(0))
+                .andExpect(jsonPath("$.data.pagination.totalPages").value(0))
+                .andExpect(jsonPath("$.data.pagination.hasMore").value(false));
     }
 
     @Test
@@ -189,7 +196,9 @@ class CockpitHistoryAnalysisControllerTest {
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.filters.level").value(2))
                 .andExpect(jsonPath("$.data.summary.eventCount").value(2))
-                .andExpect(jsonPath("$.data.summary.handledCount").value(0));
+                .andExpect(jsonPath("$.data.summary.handledCount").value(0))
+                .andExpect(jsonPath("$.data.events", hasSize(2)))
+                .andExpect(jsonPath("$.data.pagination.total").value(2));
 
         mockMvc.perform(get(ENDPOINT)
                         .param("eventFamily", "WARNING")
@@ -198,7 +207,88 @@ class CockpitHistoryAnalysisControllerTest {
                         .param("endTime", END))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.data.summary.eventCount").value(2))
-                .andExpect(jsonPath("$.data.summary.handledCount").value(1));
+                .andExpect(jsonPath("$.data.summary.handledCount").value(1))
+                .andExpect(jsonPath("$.data.events", hasSize(2)));
+    }
+
+    @Test
+    void eventRowsAreOrderedAndPaginated() throws Exception {
+        // 票 61:逐事件行按 eventTime 倒序、同时间按 eventNo 升序(与 events/current 同一口径)。
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END)
+                        .param("page", "1")
+                        .param("pageSize", "2"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.pagination.page").value(1))
+                .andExpect(jsonPath("$.data.pagination.pageSize").value(2))
+                .andExpect(jsonPath("$.data.pagination.total").value(5))
+                .andExpect(jsonPath("$.data.pagination.totalPages").value(3))
+                .andExpect(jsonPath("$.data.pagination.hasMore").value(true))
+                .andExpect(jsonPath("$.data.events", hasSize(2)))
+                .andExpect(jsonPath("$.data.events[0].eventNo").value("HW-005"))
+                .andExpect(jsonPath("$.data.events[0].eventTime").value("2026-09-02T01:50:00+08:00"))
+                .andExpect(jsonPath("$.data.events[0].levelColor").value("RED"))
+                .andExpect(jsonPath("$.data.events[0].deviceCode").value("D-WS-2"))
+                .andExpect(jsonPath("$.data.events[0].industry").value("WATER_SUPPLY"))
+                .andExpect(jsonPath("$.data.events[1].eventNo").value("HW-004"));
+
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END)
+                        .param("page", "3")
+                        .param("pageSize", "2"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.events", hasSize(1)))
+                .andExpect(jsonPath("$.data.events[0].eventNo").value("HW-001"))
+                .andExpect(jsonPath("$.data.pagination.hasMore").value(false));
+
+        // 越界页:事件为空但仍返回成功信封与真实 total(不由当前页反推)。
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("startTime", START)
+                        .param("endTime", END)
+                        .param("page", "9")
+                        .param("pageSize", "2"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.events", hasSize(0)))
+                .andExpect(jsonPath("$.data.pagination.total").value(5))
+                .andExpect(jsonPath("$.data.pagination.hasMore").value(false));
+    }
+
+    @Test
+    void alarmFamilyEventRowsAreIndependentFromWarnings() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "ALARM")
+                        .param("startTime", START)
+                        .param("endTime", END)
+                        .param("pageSize", "10"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.events", hasSize(2)))
+                .andExpect(jsonPath("$.data.events[0].eventNo").value("HA-002"))
+                .andExpect(jsonPath("$.data.events[0].levelColor").value("RED"))
+                .andExpect(jsonPath("$.data.events[1].eventNo").value("HA-001"))
+                .andExpect(jsonPath("$.data.events[1].levelColor").value("BLUE"))
+                .andExpect(jsonPath("$.data.pagination.total").value(2));
+    }
+
+    @Test
+    void invalidPaginationIsRejectedBeforeAdapterIsCalled() throws Exception {
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("page", "0"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value("历史分析分页参数不合法"));
+
+        mockMvc.perform(get(ENDPOINT)
+                        .param("eventFamily", "WARNING")
+                        .param("pageSize", "1001"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.data.message").value("历史分析分页参数不合法"));
+
+        verifyNoInteractions(adapter);
     }
 
     @Test
@@ -295,7 +385,7 @@ class CockpitHistoryAnalysisControllerTest {
         return new CockpitHistoryAnalysisService(
                 adapter,
                 Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC))
-                .load("direct", family, null, null, start, end);
+                .load("direct", family, null, null, start, end, 1, 100);
     }
 
     private static CockpitHistoryAnalysisSnapshot fixture() {

+ 44 - 16
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/BusinessCockpitEventAdapter.java

@@ -405,26 +405,54 @@ public class BusinessCockpitEventAdapter {
         }
     }
 
+    /**
+     * 票 61:历史分析除小时桶聚合外还要返回**可分页的逐事件行**,所以这里把
+     * 展示字段(名称/状态/设备名/位置/量值…)一并带进快照——与
+     * `currentWarning/currentAlarm` 取同一份事实,两族事件行形状因此同构。
+     */
     private List<CockpitHistoryAnalysisSnapshot.SourceWarning> historyWarnings(Facts facts) {
-        return facts.warnings.stream()
-                .map(item -> new CockpitHistoryAnalysisSnapshot.SourceWarning(
-                        trimToNull(item.warning.getWarningNo()),
-                        level(item.warning.getWarningLevel()),
-                        offsetTime(item.warning.getPublishTime()),
-                        typeCode(item.warning.getDeviceCode(), facts),
-                        trimToNull(item.warning.getDeviceCode())))
-                .collect(Collectors.toList());
+        List<CockpitHistoryAnalysisSnapshot.SourceWarning> result = new ArrayList<>();
+        for (WarningFact item : facts.warnings) {
+            EarlyWarning warning = item.warning;
+            PointFact point = facts.pointsByDevice.get(normalize(warning.getDeviceCode()));
+            result.add(new CockpitHistoryAnalysisSnapshot.SourceWarning(
+                    trimToNull(warning.getWarningNo()),
+                    level(warning.getWarningLevel()),
+                    offsetTime(warning.getPublishTime()),
+                    typeCode(warning.getDeviceCode(), facts),
+                    trimToNull(warning.getDeviceCode()),
+                    trimToNull(warning.getStatus()),
+                    firstNonBlank(warning.getDeviceName(), deviceName(warning.getDeviceCode(), facts)),
+                    firstNonBlank(warning.getLocation(), point == null ? null : point.location),
+                    offsetTime(warning.getUpdateTime())));
+        }
+        return result;
     }
 
     private List<CockpitHistoryAnalysisSnapshot.SourceAlarm> historyAlarms(Facts facts) {
-        return facts.alarms.stream()
-                .map(item -> new CockpitHistoryAnalysisSnapshot.SourceAlarm(
-                        trimToNull(item.alarm.getId()),
-                        item.alarm.getAlarmLevel(),
-                        offsetTime(item.alarm.getCreateTime()),
-                        typeCode(item.alarm.getDeviceCode(), facts),
-                        trimToNull(item.alarm.getDeviceCode())))
-                .collect(Collectors.toList());
+        List<CockpitHistoryAnalysisSnapshot.SourceAlarm> result = new ArrayList<>();
+        for (AlarmFact item : facts.alarms) {
+            AlarmData alarm = item.alarm;
+            EquipmentWarningType metadata = warningType(alarm, facts);
+            PointFact point = facts.pointsByDevice.get(normalize(alarm.getDeviceCode()));
+            EquipmentBase device = facts.devicesByCode.get(normalize(alarm.getDeviceCode()));
+            result.add(new CockpitHistoryAnalysisSnapshot.SourceAlarm(
+                    trimToNull(alarm.getId()),
+                    alarm.getAlarmLevel(),
+                    offsetTime(alarm.getCreateTime()),
+                    typeCode(alarm.getDeviceCode(), facts),
+                    trimToNull(alarm.getDeviceCode()),
+                    firstNonBlank(warningTypeName(metadata), alarm.getWarningCode()),
+                    alarmStatus(alarm.getAlarmStatus()),
+                    firstNonBlank(alarm.getDeviceName(), device == null ? null : device.getEquipmentName()),
+                    point == null ? (device == null ? null : device.getEquipmentLocation()) : point.location,
+                    offsetTime(alarm.getUpdateTime()),
+                    alarm.getActualValue(),
+                    warningTypeUnit(metadata),
+                    alarm.getMinValue(),
+                    alarm.getMaxValue()));
+        }
+        return result;
     }
 
     private List<CockpitHistoryAnalysisSnapshot.SourceWorkOrderLink> historyWorkOrders(Facts facts) {

+ 67 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHistoryAnalysisResponse.java

@@ -1,11 +1,25 @@
 package com.zksy.cockpit.event;
 
 import com.fasterxml.jackson.annotation.JsonFormat;
+import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
 import com.zksy.cockpit.foundation.CockpitFreshnessStatus;
 
 import java.math.BigDecimal;
+import java.util.Collections;
 import java.util.List;
 
+/**
+ * 预警处置 / 历史分析响应。
+ *
+ * <p>两块内容并存:
+ * <ul>
+ *   <li>{@code rows[]}:小时桶聚合(趋势与行级处置率口径,历史既有契约,未改动);</li>
+ *   <li>{@code events[]} + {@code pagination}:**逐事件行**(票 61 新增,形状与
+ *       {@code /events/current} 的 {@code EventItem} 同构),按 eventTime 倒序、
+ *       同时间按 eventNo 升序,分页由 {@code page/pageSize} 控制。</li>
+ * </ul>
+ * 逐事件行与聚合桶取自同一份窗口内事件集合,两条口径可互相复算。
+ */
 public class CockpitHistoryAnalysisResponse {
     private final String requestId;
     private final String apiVersion;
@@ -17,6 +31,8 @@ public class CockpitHistoryAnalysisResponse {
     private final String updatedAt;
     private final Filters filters;
     private final Summary summary;
+    private final Pagination pagination;
+    private final List<EventItem> events;
     private final List<Row> rows;
 
     public CockpitHistoryAnalysisResponse(
@@ -30,6 +46,8 @@ public class CockpitHistoryAnalysisResponse {
             String updatedAt,
             Filters filters,
             Summary summary,
+            Pagination pagination,
+            List<EventItem> events,
             List<Row> rows) {
         this.requestId = requestId;
         this.apiVersion = apiVersion;
@@ -41,6 +59,8 @@ public class CockpitHistoryAnalysisResponse {
         this.updatedAt = updatedAt;
         this.filters = filters;
         this.summary = summary;
+        this.pagination = pagination;
+        this.events = events == null ? Collections.emptyList() : events;
         this.rows = rows;
     }
 
@@ -54,6 +74,8 @@ public class CockpitHistoryAnalysisResponse {
     public String getUpdatedAt() { return updatedAt; }
     public Filters getFilters() { return filters; }
     public Summary getSummary() { return summary; }
+    public Pagination getPagination() { return pagination; }
+    public List<EventItem> getEvents() { return events; }
     public List<Row> getRows() { return rows; }
     public CockpitFreshnessStatus getFreshness() {
         return rows == null || rows.isEmpty()
@@ -131,6 +153,45 @@ public class CockpitHistoryAnalysisResponse {
         public String getReason() { return reason; }
     }
 
+    /** 逐事件行:直接复用当前事件端点的 {@code EventItem} 形状(同一份大屏读模型)。 */
+    public static class EventItem extends CockpitCurrentEventsResponse.EventItem {
+        public EventItem(
+                String eventNo,
+                String eventName,
+                String eventType,
+                Integer level,
+                String levelColor,
+                String status,
+                String eventTime,
+                String updatedTime,
+                BigDecimal value,
+                String unit,
+                String deviceCode,
+                String deviceName,
+                String monitoringPointCode,
+                String monitoringPointName,
+                String location,
+                String industry,
+                BigDecimal longitude,
+                BigDecimal latitude,
+                CockpitCoordinateValidation coordinateValidation,
+                BigDecimal thresholdMinValue,
+                BigDecimal thresholdMaxValue,
+                CockpitCurrentEventsResponse.WorkOrderSummary workOrderSummary) {
+            super(eventNo, eventName, eventType, level, levelColor, status, eventTime, updatedTime,
+                    value, unit, deviceCode, deviceName, monitoringPointCode, monitoringPointName,
+                    location, industry, longitude, latitude, coordinateValidation,
+                    thresholdMinValue, thresholdMaxValue, workOrderSummary);
+        }
+    }
+
+    /** 分页元数据:与 {@code /events/current} 的 {@code Pagination} 同形状(同一套页码语义)。 */
+    public static class Pagination extends CockpitCurrentEventsResponse.Pagination {
+        public Pagination(int page, int pageSize, int total, int totalPages, boolean hasMore) {
+            super(page, pageSize, total, totalPages, hasMore);
+        }
+    }
+
     public static class Row {
         private final String timeBucket;
         private final int eventCount;
@@ -138,8 +199,12 @@ public class CockpitHistoryAnalysisResponse {
         private final HandlingEfficiency handlingEfficiency;
         private final RowMapFilter mapFilter;
 
-        public Row(String timeBucket, int eventCount, int handledCount,
-                HandlingEfficiency handlingEfficiency, RowMapFilter mapFilter) {
+        public Row(
+                String timeBucket,
+                int eventCount,
+                int handledCount,
+                HandlingEfficiency handlingEfficiency,
+                RowMapFilter mapFilter) {
             this.timeBucket = timeBucket;
             this.eventCount = eventCount;
             this.handledCount = handledCount;

+ 208 - 42
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHistoryAnalysisService.java

@@ -33,6 +33,11 @@ import java.util.Set;
  * isolated, and resolves the handled disposal scope through the authoritative
  * work-order dictionary (ADR-0010): an event counts as handled only when its
  * associated work order stateCode equals 5 (已结案).
+ *
+ * <p>票 61(前端统一分页批次三):响应同时携带**小时桶聚合**(趋势,口径不变)
+ * 与**逐事件行**({@code events[]},形状与 {@code /events/current} 的 EventItem 同构,
+ * 按 eventTime 倒序、同时间按 eventNo 升序),逐事件行由 {@code page/pageSize} 分页。
+ * 两条口径取自同一份窗口内事件集合,可互相复算。
  */
 @Service
 public class CockpitHistoryAnalysisService {
@@ -42,6 +47,8 @@ public class CockpitHistoryAnalysisService {
     private static final String WARNING = "WARNING";
     private static final String ALARM = "ALARM";
     private static final int HANDLED_STATE_CODE = CockpitWorkOrderStateDictionary.CLOSED_RATE_CODE;
+    private static final int DEFAULT_PAGE_SIZE = 100;
+    private static final int MAX_PAGE_SIZE = 1000;
     private static final long DEFAULT_WINDOW_DAYS = 7;
     private static final long MAX_WINDOW_DAYS = 31;
     private static final DateTimeFormatter TIMESTAMP =
@@ -64,18 +71,23 @@ public class CockpitHistoryAnalysisService {
             String level,
             String industry,
             String startTime,
-            String endTime) {
+            String endTime,
+            Integer page,
+            Integer pageSize) {
         String family = resolveFamily(eventFamily);
         Integer resolvedLevel = resolveLevel(level);
         String selectedIndustry = resolveIndustry(industry);
         TimeWindow window = resolveWindow(startTime, endTime);
+        int safePage = page == null ? 1 : page;
+        int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
+        validatePagination(safePage, safePageSize);
         if (state == null) {
             CockpitHistoryAnalysisSnapshot loaded = adapter.load();
             state = loaded == null
                     ? CockpitHistoryAnalysisSnapshot.empty(businessTime())
                     : loaded;
         }
-        return build(requestId, family, resolvedLevel, selectedIndustry, window);
+        return build(requestId, family, resolvedLevel, selectedIndustry, window, safePage, safePageSize);
     }
 
     public CockpitErrorPayload invalidRequest(String requestId, String message) {
@@ -99,12 +111,14 @@ public class CockpitHistoryAnalysisService {
             String family,
             Integer level,
             String industry,
-            TimeWindow window) {
+            TimeWindow window,
+            int page,
+            int pageSize) {
         Map<String, CockpitHistoryAnalysisSnapshot.SourceType> types = types(state);
         Map<String, Integer> handledLinks = handledLinks(state.getWorkOrders());
-        List<EventFact> facts = WARNING.equals(family)
-                ? warningFacts(state, types, level, industry)
-                : alarmFacts(state, types, level, industry);
+        List<EventRow> eventRows = WARNING.equals(family)
+                ? warningRows(state, types, level, industry)
+                : alarmRows(state, types, level, industry);
 
         ZonedDateTime first = window.start
                 .atZoneSameInstant(BUSINESS_ZONE)
@@ -122,11 +136,9 @@ public class CockpitHistoryAnalysisService {
 
         int totalEvents = 0;
         int totalHandled = 0;
-        for (EventFact fact : facts) {
-            if (fact.eventTime == null) {
-                continue;
-            }
-            ZonedDateTime bucket = fact.eventTime
+        List<EventRow> inWindow = new ArrayList<>();
+        for (EventRow row : eventRows) {
+            ZonedDateTime bucket = row.eventTime
                     .atZoneSameInstant(BUSINESS_ZONE)
                     .truncatedTo(ChronoUnit.HOURS);
             String key = TIMESTAMP.format(bucket);
@@ -134,15 +146,16 @@ public class CockpitHistoryAnalysisService {
             if (target == null) {
                 continue;
             }
+            inWindow.add(row);
             totalEvents++;
             target.eventCount++;
-            Integer stateCode = fact.eventNo == null ? null : handledLinks.get(fact.eventNo.trim());
+            Integer stateCode = row.eventNo == null ? null : handledLinks.get(row.eventNo.trim());
             if (HANDLED_STATE_CODE == effectiveStateCode(stateCode)) {
                 target.handledCount++;
                 totalHandled++;
             }
-            if (!isBlank(fact.deviceCode)) {
-                target.deviceCodes.add(fact.deviceCode.trim());
+            if (!isBlank(row.deviceCode)) {
+                target.deviceCodes.add(row.deviceCode.trim());
             }
         }
 
@@ -158,6 +171,17 @@ public class CockpitHistoryAnalysisService {
                             family, sorted(bucket.deviceCodes))));
         }
 
+        // 票 61:逐事件行分页(与 hours buckets 同一份窗口内事件集合)。
+        inWindow.sort(eventOrdering());
+        int total = inWindow.size();
+        int totalPages = total == 0 ? 0 : (total + pageSize - 1) / pageSize;
+        int fromIndex = Math.min((page - 1) * pageSize, total);
+        int toIndex = Math.min(fromIndex + pageSize, total);
+        List<CockpitHistoryAnalysisResponse.EventItem> events = new ArrayList<>();
+        for (EventRow row : inWindow.subList(fromIndex, toIndex)) {
+            events.add(row.toItem());
+        }
+
         String updatedAt = isBlank(state.getUpdatedAt()) ? businessTime() : state.getUpdatedAt();
         // 汇总处置率=范围内全部工单(状态 0–6)口径(决策票 06),与行级事件桶分开;
         // 行级桶仍是事件时间桶,其 efficiency 保留事件口径。
@@ -180,17 +204,21 @@ public class CockpitHistoryAnalysisService {
                         // 保持事件口径,与行级 handledCount 同维(决策 q7:只改汇总处置率)。
                         totalHandled,
                         scopeEfficiency(scopeRate.handled, scopeRate.eligible)),
+                new CockpitHistoryAnalysisResponse.Pagination(
+                        page, pageSize, total, totalPages, toIndex < total),
+                events,
                 rows);
     }
 
-    private List<EventFact> warningFacts(
+    /** 预警族逐事件行(字段取自历史快照;时间/等级/设备/位置与当前事件端点同源)。 */
+    private List<EventRow> warningRows(
             CockpitHistoryAnalysisSnapshot snapshot,
             Map<String, CockpitHistoryAnalysisSnapshot.SourceType> types,
             Integer level,
             String industry) {
-        List<EventFact> facts = new ArrayList<>();
+        List<EventRow> rows = new ArrayList<>();
         if (snapshot.getWarnings() == null) {
-            return facts;
+            return rows;
         }
         for (CockpitHistoryAnalysisSnapshot.SourceWarning warning : snapshot.getWarnings()) {
             if (warning == null
@@ -198,22 +226,38 @@ public class CockpitHistoryAnalysisService {
                     || (level != null && !level.equals(warning.getLevel()))) {
                 continue;
             }
-            facts.add(new EventFact(
+            OffsetDateTime eventTime = parseEventTime(warning.getEventTime());
+            if (eventTime == null) {
+                continue;
+            }
+            rows.add(new EventRow(
                     warning.getWarningNo(),
-                    parseEventTime(warning.getEventTime()),
-                    warning.getDeviceCode()));
-        }
-        return facts;
+                    warning.getWarningNo(),
+                    warning.getTypeCode(),
+                    warning.getLevel(),
+                    warning.getStatus(),
+                    eventTime,
+                    warning.getUpdatedTime(),
+                    null,
+                    null,
+                    warning.getDeviceCode(),
+                    warning.getDeviceName(),
+                    warning.getLocation(),
+                    industryOf(types, warning.getTypeCode()),
+                    null,
+                    null));
+        }
+        return rows;
     }
 
-    private List<EventFact> alarmFacts(
+    private List<EventRow> alarmRows(
             CockpitHistoryAnalysisSnapshot snapshot,
             Map<String, CockpitHistoryAnalysisSnapshot.SourceType> types,
             Integer level,
             String industry) {
-        List<EventFact> facts = new ArrayList<>();
+        List<EventRow> rows = new ArrayList<>();
         if (snapshot.getAlarms() == null) {
-            return facts;
+            return rows;
         }
         for (CockpitHistoryAnalysisSnapshot.SourceAlarm alarm : snapshot.getAlarms()) {
             if (alarm == null
@@ -221,12 +265,28 @@ public class CockpitHistoryAnalysisService {
                     || (level != null && !level.equals(alarm.getLevel()))) {
                 continue;
             }
-            facts.add(new EventFact(
+            OffsetDateTime eventTime = parseEventTime(alarm.getEventTime());
+            if (eventTime == null) {
+                continue;
+            }
+            rows.add(new EventRow(
                     alarm.getAlarmId(),
-                    parseEventTime(alarm.getEventTime()),
-                    alarm.getDeviceCode()));
-        }
-        return facts;
+                    isBlank(alarm.getEventName()) ? alarm.getAlarmId() : alarm.getEventName(),
+                    alarm.getTypeCode(),
+                    alarm.getLevel(),
+                    alarm.getStatus(),
+                    eventTime,
+                    alarm.getUpdatedTime(),
+                    alarm.getValue(),
+                    alarm.getUnit(),
+                    alarm.getDeviceCode(),
+                    alarm.getDeviceName(),
+                    alarm.getLocation(),
+                    industryOf(types, alarm.getTypeCode()),
+                    alarm.getMinValue(),
+                    alarm.getMaxValue()));
+        }
+        return rows;
     }
 
     private static boolean inScope(
@@ -244,6 +304,15 @@ public class CockpitHistoryAnalysisService {
         return industry == null || industry.equals(type.getIndustry());
     }
 
+    /** 事件行的行业=其类型所属行业(未登记类型回 null,不猜)。 */
+    private static String industryOf(
+            Map<String, CockpitHistoryAnalysisSnapshot.SourceType> types,
+            String typeCode) {
+        CockpitHistoryAnalysisSnapshot.SourceType type =
+                isBlank(typeCode) ? null : types.get(typeCode.trim());
+        return type == null ? null : type.getIndustry();
+    }
+
     /**
      * 范围处置率:分母=窗口内全部工单的状态 0–6 计数(状态 7 已取消排除),
      * 按 {@code create_time} 界定窗口、按行业(经类型)收敛,与事件是否回联无关。
@@ -391,6 +460,12 @@ public class CockpitHistoryAnalysisService {
         return resolved;
     }
 
+    private static void validatePagination(int page, int pageSize) {
+        if (page < 1 || pageSize < 1 || pageSize > MAX_PAGE_SIZE) {
+            throw new CockpitInvalidRequestException("历史分析分页参数不合法");
+        }
+    }
+
     private TimeWindow resolveWindow(String startTime, String endTime) {
         boolean hasStart = !isBlank(startTime);
         boolean hasEnd = !isBlank(endTime);
@@ -456,6 +531,109 @@ public class CockpitHistoryAnalysisService {
         return value == null || value.trim().isEmpty();
     }
 
+    /** 逐事件行(领域内形状):聚合与逐事件清单共用一份事实。 */
+    private static class EventRow {
+        private final String eventNo;
+        private final String eventName;
+        private final String eventType;
+        private final Integer level;
+        private final String status;
+        private final OffsetDateTime eventTime;
+        private final String updatedTime;
+        private final BigDecimal value;
+        private final String unit;
+        private final String deviceCode;
+        private final String deviceName;
+        private final String location;
+        private final String industry;
+        private final BigDecimal minValue;
+        private final BigDecimal maxValue;
+
+        private EventRow(
+                String eventNo,
+                String eventName,
+                String eventType,
+                Integer level,
+                String status,
+                OffsetDateTime eventTime,
+                String updatedTime,
+                BigDecimal value,
+                String unit,
+                String deviceCode,
+                String deviceName,
+                String location,
+                String industry,
+                BigDecimal minValue,
+                BigDecimal maxValue) {
+            this.eventNo = eventNo;
+            this.eventName = eventName;
+            this.eventType = eventType;
+            this.level = level;
+            this.status = status;
+            this.eventTime = eventTime;
+            this.updatedTime = updatedTime;
+            this.value = value;
+            this.unit = unit;
+            this.deviceCode = deviceCode;
+            this.deviceName = deviceName;
+            this.location = location;
+            this.industry = industry;
+            this.minValue = minValue;
+            this.maxValue = maxValue;
+        }
+
+        private String getEventNo() {
+            return eventNo;
+        }
+
+        /** 逐事件行 → 与 `/events/current` 同构的 EventItem(缺的字段留 null,不编造)。 */
+        private CockpitHistoryAnalysisResponse.EventItem toItem() {
+            return new CockpitHistoryAnalysisResponse.EventItem(
+                    eventNo,
+                    isBlank(eventName) ? eventNo : eventName,
+                    eventType,
+                    level,
+                    CockpitEventLevelColor.resolve(level),
+                    status,
+                    timestamp(eventTime),
+                    updatedTime,
+                    value,
+                    unit,
+                    deviceCode,
+                    deviceName,
+                    // 历史快照不携带监测点与坐标(逐事件坐标只在 events/current 提供):
+                    // 保留字段为 null,前端按既有缺值守卫处理。
+                    null,
+                    null,
+                    location,
+                    industry,
+                    null,
+                    null,
+                    null,
+                    minValue,
+                    maxValue,
+                    null);
+        }
+    }
+
+    /** 逐事件排序:eventTime 倒序(缺时间排最后),同时间按 eventNo 升序(与当前事件端点同一口径)。 */
+    private static Comparator<EventRow> eventOrdering() {
+        Comparator<EventRow> byTime = (left, right) -> {
+            if (left.eventTime == null && right.eventTime == null) {
+                return 0;
+            }
+            if (left.eventTime == null) {
+                return 1;
+            }
+            if (right.eventTime == null) {
+                return -1;
+            }
+            return right.eventTime.compareTo(left.eventTime);
+        };
+        return byTime.thenComparing(
+                EventRow::getEventNo, Comparator.nullsLast(String::compareTo));
+    }
+
     private static class TimeWindow {
         private final OffsetDateTime start;
         private final OffsetDateTime end;
@@ -466,18 +644,6 @@ public class CockpitHistoryAnalysisService {
         }
     }
 
-    private static class EventFact {
-        private final String eventNo;
-        private final OffsetDateTime eventTime;
-        private final String deviceCode;
-
-        private EventFact(String eventNo, OffsetDateTime eventTime, String deviceCode) {
-            this.eventNo = eventNo;
-            this.eventTime = eventTime;
-            this.deviceCode = deviceCode;
-        }
-    }
-
     private static class Bucket {
         private final Set<String> deviceCodes = new LinkedHashSet<>();
         private int eventCount;

+ 121 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/event/CockpitHistoryAnalysisSnapshot.java

@@ -1,5 +1,6 @@
 package com.zksy.cockpit.event;
 
+import java.math.BigDecimal;
 import java.util.Collections;
 import java.util.List;
 
@@ -9,6 +10,11 @@ import java.util.List;
  * <p>Delivers both event families separately plus optional work-order links used
  * to resolve the handled disposal scope. A null workOrders list is treated as no
  * associated work orders (handled count zero).
+ *
+ * <p>票 61(前端统一分页批次三):快照行除聚合所需的最小字段外,还携带逐事件行
+ * 展示字段(名称/状态/设备名/位置/量值…),供历史分析返回**可分页的逐事件清单**。
+ * 早期 5 参构造保留(只填聚合字段),新 9/14 参构造由业务适配器提供完整字段——
+ * 这样既有测试与空适配器无需改动。
  */
 public class CockpitHistoryAnalysisSnapshot {
     private final String updatedAt;
@@ -140,18 +146,41 @@ public class CockpitHistoryAnalysisSnapshot {
         private final String eventTime;
         private final String typeCode;
         private final String deviceCode;
+        private final String status;
+        private final String deviceName;
+        private final String location;
+        private final String updatedTime;
 
+        /** 聚合最小字段(既有测试/空适配器用)。 */
         public SourceWarning(
                 String warningNo,
                 Integer level,
                 String eventTime,
                 String typeCode,
                 String deviceCode) {
+            this(warningNo, level, eventTime, typeCode, deviceCode, null, null, null, null);
+        }
+
+        /** 票 61:逐事件行展示字段(业务适配器用)。 */
+        public SourceWarning(
+                String warningNo,
+                Integer level,
+                String eventTime,
+                String typeCode,
+                String deviceCode,
+                String status,
+                String deviceName,
+                String location,
+                String updatedTime) {
             this.warningNo = warningNo;
             this.level = level;
             this.eventTime = eventTime;
             this.typeCode = typeCode;
             this.deviceCode = deviceCode;
+            this.status = status;
+            this.deviceName = deviceName;
+            this.location = location;
+            this.updatedTime = updatedTime;
         }
 
         public String getWarningNo() {
@@ -173,6 +202,22 @@ public class CockpitHistoryAnalysisSnapshot {
         public String getDeviceCode() {
             return deviceCode;
         }
+
+        public String getStatus() {
+            return status;
+        }
+
+        public String getDeviceName() {
+            return deviceName;
+        }
+
+        public String getLocation() {
+            return location;
+        }
+
+        public String getUpdatedTime() {
+            return updatedTime;
+        }
     }
 
     public static class SourceAlarm {
@@ -181,18 +226,57 @@ public class CockpitHistoryAnalysisSnapshot {
         private final String eventTime;
         private final String typeCode;
         private final String deviceCode;
-
+        private final String eventName;
+        private final String status;
+        private final String deviceName;
+        private final String location;
+        private final String updatedTime;
+        private final BigDecimal value;
+        private final String unit;
+        private final BigDecimal minValue;
+        private final BigDecimal maxValue;
+
+        /** 聚合最小字段(既有测试/空适配器用)。 */
         public SourceAlarm(
                 String alarmId,
                 Integer level,
                 String eventTime,
                 String typeCode,
                 String deviceCode) {
+            this(alarmId, level, eventTime, typeCode, deviceCode,
+                    null, null, null, null, null, null, null, null, null);
+        }
+
+        /** 票 61:逐事件行展示字段(业务适配器用)。 */
+        public SourceAlarm(
+                String alarmId,
+                Integer level,
+                String eventTime,
+                String typeCode,
+                String deviceCode,
+                String eventName,
+                String status,
+                String deviceName,
+                String location,
+                String updatedTime,
+                BigDecimal value,
+                String unit,
+                BigDecimal minValue,
+                BigDecimal maxValue) {
             this.alarmId = alarmId;
             this.level = level;
             this.eventTime = eventTime;
             this.typeCode = typeCode;
             this.deviceCode = deviceCode;
+            this.eventName = eventName;
+            this.status = status;
+            this.deviceName = deviceName;
+            this.location = location;
+            this.updatedTime = updatedTime;
+            this.value = value;
+            this.unit = unit;
+            this.minValue = minValue;
+            this.maxValue = maxValue;
         }
 
         public String getAlarmId() {
@@ -214,6 +298,42 @@ public class CockpitHistoryAnalysisSnapshot {
         public String getDeviceCode() {
             return deviceCode;
         }
+
+        public String getEventName() {
+            return eventName;
+        }
+
+        public String getStatus() {
+            return status;
+        }
+
+        public String getDeviceName() {
+            return deviceName;
+        }
+
+        public String getLocation() {
+            return location;
+        }
+
+        public String getUpdatedTime() {
+            return updatedTime;
+        }
+
+        public BigDecimal getValue() {
+            return value;
+        }
+
+        public String getUnit() {
+            return unit;
+        }
+
+        public BigDecimal getMinValue() {
+            return minValue;
+        }
+
+        public BigDecimal getMaxValue() {
+            return maxValue;
+        }
     }
 
     public static class SourceWorkOrderLink {