| @ -0,0 +1,57 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |||
| xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> | |||
| <modelVersion>4.0.0</modelVersion> | |||
| <parent> | |||
| <groupId>com.inspect</groupId> | |||
| <artifactId>inspect-main</artifactId> | |||
| <version>3.4.0</version> | |||
| </parent> | |||
| <artifactId>inspect-main-agg</artifactId> | |||
| <version>3.4.0</version> | |||
| <name>inspect-main-agg</name> | |||
| <description>inspect-main-agg</description> | |||
| <properties> | |||
| <java.version>1.8</java.version> | |||
| <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> | |||
| <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> | |||
| <spring-boot.version>2.6.13</spring-boot.version> | |||
| </properties> | |||
| <dependencies> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter</artifactId> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter-test</artifactId> | |||
| <scope>test</scope> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter-web</artifactId> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>com.inspect</groupId> | |||
| <artifactId>inspect-base-core</artifactId> | |||
| </dependency> | |||
| <dependency> | |||
| <groupId>org.mybatis.spring.boot</groupId> | |||
| <artifactId>mybatis-spring-boot-starter</artifactId> | |||
| </dependency> <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-starter-data-redis</artifactId> | |||
| </dependency> | |||
| </dependencies> | |||
| <dependencyManagement> | |||
| <dependencies> | |||
| <dependency> | |||
| <groupId>org.springframework.boot</groupId> | |||
| <artifactId>spring-boot-dependencies</artifactId> | |||
| <version>2.6.6</version> | |||
| <type>pom</type> | |||
| <scope>import</scope> | |||
| </dependency> | |||
| </dependencies> | |||
| </dependencyManagement> | |||
| </project> | |||
| @ -0,0 +1,20 @@ | |||
| package com.inspect.agg; | |||
| import org.mybatis.spring.annotation.MapperScan; | |||
| import org.springframework.boot.SpringApplication; | |||
| import org.springframework.boot.autoconfigure.SpringBootApplication; | |||
| import org.springframework.scheduling.annotation.EnableScheduling; | |||
| /** | |||
| * 智能巡检数据汇聚系统 - 中心采集服务(区域试点)。 | |||
| */ | |||
| @SpringBootApplication | |||
| @EnableScheduling | |||
| @MapperScan("com.inspect.agg.mapper") | |||
| public class AggregationApplication { | |||
| public static void main(String[] args) { | |||
| SpringApplication.run(AggregationApplication.class, args); | |||
| } | |||
| } | |||
| @ -0,0 +1,28 @@ | |||
| package com.inspect.agg.calendar; | |||
| import java.io.Serializable; | |||
| import java.time.LocalDateTime; | |||
| import lombok.AllArgsConstructor; | |||
| import lombok.Builder; | |||
| import lombok.Data; | |||
| import lombok.NoArgsConstructor; | |||
| @Data | |||
| @Builder | |||
| @NoArgsConstructor | |||
| @AllArgsConstructor | |||
| public class AggCalendarEvent implements Serializable { | |||
| private static final long serialVersionUID = 1L; | |||
| private String stationCode; | |||
| /** 仅用于展示:所属站点名称,不落业务表 */ | |||
| private String stationName; | |||
| private String id; | |||
| private String name; | |||
| private String status; | |||
| private String taskCode; | |||
| private String source; | |||
| private LocalDateTime startTime; | |||
| private LocalDateTime endTime; | |||
| } | |||
| @ -0,0 +1,180 @@ | |||
| package com.inspect.agg.calendar; | |||
| import java.time.DayOfWeek; | |||
| import java.time.LocalDate; | |||
| import java.time.LocalDateTime; | |||
| import java.time.LocalTime; | |||
| import java.time.YearMonth; | |||
| import java.time.temporal.TemporalAdjusters; | |||
| import java.util.ArrayList; | |||
| import java.util.Collections; | |||
| import java.util.List; | |||
| import org.springframework.stereotype.Service; | |||
| import org.springframework.util.StringUtils; | |||
| import com.inspect.agg.entity.AggPatrolTask; | |||
| import com.inspect.agg.mapper.AggPatrolTaskMapper; | |||
| import com.inspect.base.core.enums.ExecStatus; | |||
| import com.inspect.base.core.enums.IntervalType; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * 根据汇聚方案规则生成指定月份的待执行计划。 | |||
| */ | |||
| @Slf4j | |||
| @Service | |||
| @RequiredArgsConstructor | |||
| public class AggTaskCalendarGenerator { | |||
| private static final String STATE_PENDING = "5"; | |||
| private final AggPatrolTaskMapper taskMapper; | |||
| public List<AggCalendarEvent> generateMonth(String stationCode, int year, int month) { | |||
| YearMonth yearMonth = YearMonth.of(year, month); | |||
| LocalDate firstDay = yearMonth.atDay(1); | |||
| LocalDate lastDay = yearMonth.atEndOfMonth(); | |||
| LocalDateTime monthStart = firstDay.atStartOfDay(); | |||
| LocalDateTime monthEnd = lastDay.atTime(23, 59, 59); | |||
| LocalDateTime now = LocalDateTime.now(); | |||
| if (monthEnd.isBefore(now)) { | |||
| return Collections.emptyList(); | |||
| } | |||
| List<AggCalendarEvent> events = new ArrayList<>(); | |||
| List<AggPatrolTask> tasks = taskMapper.selectEnabledTasks(stationCode); | |||
| for (AggPatrolTask task : tasks) { | |||
| if (!StringUtils.hasText(task.getDevType())) { | |||
| continue; | |||
| } | |||
| try { | |||
| if (ExecStatus.REGULAR.getCode().equals(task.getExecutionStatus())) { | |||
| generateRegular(task, monthStart, monthEnd, now, events); | |||
| } else if (ExecStatus.PERIODIC.getCode().equals(task.getExecutionStatus())) { | |||
| generatePeriodic(task, monthStart, monthEnd, now, events); | |||
| } else if (ExecStatus.INTERVAL.getCode().equals(task.getExecutionStatus())) { | |||
| generateInterval(task, monthStart, monthEnd, now, events); | |||
| } | |||
| } catch (Exception e) { | |||
| log.warn("generate calendar failed: station={}, taskId={}, error={}", | |||
| task.getStationCode(), task.getTaskId(), e.getMessage()); | |||
| } | |||
| } | |||
| events.sort((a, b) -> a.getStartTime().compareTo(b.getStartTime())); | |||
| return events; | |||
| } | |||
| private void generateRegular(AggPatrolTask task, LocalDateTime monthStart, LocalDateTime monthEnd, | |||
| LocalDateTime now, List<AggCalendarEvent> events) { | |||
| LocalDateTime startTime = task.getFixedStartTime(); | |||
| if (startTime != null && startTime.isAfter(now) | |||
| && !startTime.isBefore(monthStart) && !startTime.isAfter(monthEnd)) { | |||
| events.add(event(task, startTime, null)); | |||
| } | |||
| } | |||
| private void generatePeriodic(AggPatrolTask task, LocalDateTime monthStart, LocalDateTime monthEnd, | |||
| LocalDateTime now, List<AggCalendarEvent> events) { | |||
| if (task.getCycleStartTime() == null || task.getCycleEndTime() == null | |||
| || !StringUtils.hasText(task.getCycleExecuteTime())) { | |||
| return; | |||
| } | |||
| LocalDateTime start = max(monthStart, task.getCycleStartTime()); | |||
| LocalDateTime end = min(monthEnd, task.getCycleEndTime()); | |||
| if (end.isBefore(start)) { | |||
| return; | |||
| } | |||
| String[] executeTimes = task.getCycleExecuteTime().split(","); | |||
| if (StringUtils.hasText(task.getCycleWeek())) { | |||
| String[] weeks = task.getCycleWeek().split(","); | |||
| for (LocalDate date = start.toLocalDate(); !date.isAfter(end.toLocalDate()); date = date.plusDays(1)) { | |||
| for (int i = 0; i < weeks.length && i < executeTimes.length; i++) { | |||
| if (date.getDayOfWeek() == parseDayOfWeek(weeks[i])) { | |||
| addIfVisible(task, date, executeTimes[i], now, end, events); | |||
| } | |||
| } | |||
| } | |||
| } else if (StringUtils.hasText(task.getCycleMonth())) { | |||
| String[] monthDays = task.getCycleMonth().split(","); | |||
| for (LocalDate date = start.toLocalDate(); !date.isAfter(end.toLocalDate()); date = date.plusDays(1)) { | |||
| for (int i = 0; i < monthDays.length && i < executeTimes.length; i++) { | |||
| if (date.getDayOfMonth() == Integer.parseInt(monthDays[i].trim())) { | |||
| addIfVisible(task, date, executeTimes[i], now, end, events); | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } | |||
| private void generateInterval(AggPatrolTask task, LocalDateTime monthStart, LocalDateTime monthEnd, | |||
| LocalDateTime now, List<AggCalendarEvent> events) { | |||
| if (!IntervalType.BY_HOUR.getCode().equals(task.getIntervalType()) | |||
| || task.getIntervalNumber() == null || task.getIntervalNumber() <= 0 | |||
| || task.getIntervalStartTime() == null || task.getIntervalEndTime() == null | |||
| || !StringUtils.hasText(task.getIntervalExecuteTime())) { | |||
| return; | |||
| } | |||
| LocalDateTime start = task.getIntervalStartTime(); | |||
| LocalDateTime end = min(monthEnd, task.getIntervalEndTime()); | |||
| LocalTime executeTime = LocalTime.parse(task.getIntervalExecuteTime().trim()); | |||
| LocalDateTime cursor = LocalDateTime.of(start.toLocalDate(), executeTime); | |||
| while (cursor.isBefore(start)) { | |||
| cursor = cursor.plusHours(task.getIntervalNumber()); | |||
| } | |||
| while (!cursor.isAfter(end)) { | |||
| if (cursor.isAfter(now) && !cursor.isBefore(monthStart)) { | |||
| events.add(event(task, cursor, null)); | |||
| } | |||
| cursor = cursor.plusHours(task.getIntervalNumber()); | |||
| } | |||
| } | |||
| private void addIfVisible(AggPatrolTask task, LocalDate date, String time, | |||
| LocalDateTime now, LocalDateTime end, | |||
| List<AggCalendarEvent> events) { | |||
| if (!StringUtils.hasText(time)) { | |||
| return; | |||
| } | |||
| LocalDateTime startTime = LocalDateTime.of(date, LocalTime.parse(time.trim())); | |||
| if (startTime.isAfter(now) && !startTime.isAfter(end)) { | |||
| events.add(event(task, startTime, null)); | |||
| } | |||
| } | |||
| private AggCalendarEvent event(AggPatrolTask task, LocalDateTime startTime, LocalDateTime endTime) { | |||
| return AggCalendarEvent.builder() | |||
| .stationCode(task.getStationCode()) | |||
| .stationName(task.getStationName()) | |||
| .id(task.getTaskCode()) | |||
| .taskCode(task.getTaskCode()) | |||
| .name(task.getTaskName()) | |||
| .status("pending") | |||
| .source("plan") | |||
| .startTime(startTime) | |||
| .endTime(endTime) | |||
| .build(); | |||
| } | |||
| private static LocalDateTime max(LocalDateTime a, LocalDateTime b) { | |||
| return a.isAfter(b) ? a : b; | |||
| } | |||
| private static LocalDateTime min(LocalDateTime a, LocalDateTime b) { | |||
| return a.isBefore(b) ? a : b; | |||
| } | |||
| private static DayOfWeek parseDayOfWeek(String value) { | |||
| String week = value == null ? "" : value.trim(); | |||
| if ("星期一".equals(week) || "1".equals(week)) return DayOfWeek.MONDAY; | |||
| if ("星期二".equals(week) || "2".equals(week)) return DayOfWeek.TUESDAY; | |||
| if ("星期三".equals(week) || "3".equals(week)) return DayOfWeek.WEDNESDAY; | |||
| if ("星期四".equals(week) || "4".equals(week)) return DayOfWeek.THURSDAY; | |||
| if ("星期五".equals(week) || "5".equals(week)) return DayOfWeek.FRIDAY; | |||
| if ("星期六".equals(week) || "6".equals(week)) return DayOfWeek.SATURDAY; | |||
| if ("星期日".equals(week) || "7".equals(week)) return DayOfWeek.SUNDAY; | |||
| throw new IllegalArgumentException("不支持的星期: " + week); | |||
| } | |||
| } | |||
| @ -0,0 +1,340 @@ | |||
| package com.inspect.agg.calendar; | |||
| import java.time.Duration; | |||
| import java.time.LocalDate; | |||
| import java.time.LocalDateTime; | |||
| import java.time.YearMonth; | |||
| import java.time.format.DateTimeFormatter; | |||
| import java.util.ArrayList; | |||
| import java.util.HashMap; | |||
| import java.util.LinkedHashMap; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| import java.util.Set; | |||
| import java.util.TreeMap; | |||
| import org.springframework.data.redis.core.StringRedisTemplate; | |||
| import org.springframework.scheduling.annotation.Scheduled; | |||
| import org.springframework.stereotype.Service; | |||
| import org.springframework.util.StringUtils; | |||
| import com.fasterxml.jackson.core.type.TypeReference; | |||
| import com.fasterxml.jackson.databind.ObjectMapper; | |||
| import com.inspect.agg.entity.AggPatrolTaskStatus; | |||
| import com.inspect.agg.entity.AggStation; | |||
| import com.inspect.agg.mapper.AggPatrolTaskStatusMapper; | |||
| import com.inspect.agg.service.StationRegistry; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * 汇聚计划服务:规则生成未来计划,合并实际任务状态,并使用 Redis 缓存。 | |||
| */ | |||
| @Slf4j | |||
| @Service | |||
| @RequiredArgsConstructor | |||
| public class AggTaskCalendarService { | |||
| private static final String CACHE_PREFIX = "agg:calendar:v2:"; | |||
| private static final Duration CACHE_TTL = Duration.ofHours(12); | |||
| private static final DateTimeFormatter DATE_TIME_FORMATTER = | |||
| DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); | |||
| private static final DateTimeFormatter DATE_FORMATTER = | |||
| DateTimeFormatter.ofPattern("yyyy-MM-dd"); | |||
| private static final TypeReference<List<AggCalendarEvent>> EVENT_LIST_TYPE = | |||
| new TypeReference<List<AggCalendarEvent>>() { }; | |||
| private static final TypeReference<List<Map<String, Object>>> STATS_LIST_TYPE = | |||
| new TypeReference<List<Map<String, Object>>>() { }; | |||
| private final AggTaskCalendarGenerator calendarGenerator; | |||
| private final AggPatrolTaskStatusMapper taskStatusMapper; | |||
| private final StationRegistry stationRegistry; | |||
| private final StringRedisTemplate redisTemplate; | |||
| private final ObjectMapper objectMapper; | |||
| public List<Map<String, Object>> year(String stationCode, int year) { | |||
| String scope = scope(stationCode); | |||
| String cacheKey = CACHE_PREFIX + "summary:" + scope + ":" + year; | |||
| List<Map<String, Object>> cached = readCache(cacheKey, STATS_LIST_TYPE); | |||
| if (cached != null) { | |||
| return cached; | |||
| } | |||
| List<Map<String, Object>> result = new ArrayList<>(); | |||
| for (int month = 1; month <= 12; month++) { | |||
| Map<String, Object> item = new LinkedHashMap<>(); | |||
| item.put("month", month); | |||
| item.put("executed", 0L); | |||
| item.put("executing", 0L); | |||
| item.put("pending", 0L); | |||
| item.put("total", 0L); | |||
| result.add(item); | |||
| } | |||
| for (int month = 1; month <= 12; month++) { | |||
| List<AggCalendarEvent> events = visibleEvents(loadMonthEvents(stationCode, year, month)); | |||
| Map<String, Object> item = result.get(month - 1); | |||
| long executed = count(events, "executed"); | |||
| long executing = count(events, "executing"); | |||
| long pending = count(events, "pending"); | |||
| item.put("executed", executed); | |||
| item.put("executing", executing); | |||
| item.put("pending", pending); | |||
| item.put("total", executed + executing + pending); | |||
| } | |||
| writeCache(cacheKey, result, summaryTtl(year)); | |||
| return result; | |||
| } | |||
| public List<Map<String, Object>> month(String stationCode, int year, int month) { | |||
| List<AggCalendarEvent> events = visibleEvents(loadMonthEvents(stationCode, year, month)); | |||
| applyStationNames(events); | |||
| return toMaps(events); | |||
| } | |||
| public List<Map<String, Object>> day(String stationCode, int year, int month, int day) { | |||
| LocalDate target = LocalDate.of(year, month, day); | |||
| List<AggCalendarEvent> result = new ArrayList<>(); | |||
| for (AggCalendarEvent event : visibleEvents(loadMonthEvents(stationCode, year, month))) { | |||
| if (event.getStartTime() != null && target.equals(event.getStartTime().toLocalDate())) { | |||
| result.add(event); | |||
| } | |||
| } | |||
| applyStationNames(result); | |||
| return toMaps(result); | |||
| } | |||
| public void evictStation(String stationCode) { | |||
| if (!StringUtils.hasText(stationCode)) { | |||
| return; | |||
| } | |||
| YearMonth now = YearMonth.now(); | |||
| for (int i = -12; i <= 12; i++) { | |||
| YearMonth month = now.plusMonths(i); | |||
| deleteQuietly(monthKey(stationCode, month.getYear(), month.getMonthValue())); | |||
| } | |||
| deleteQuietly(CACHE_PREFIX + "summary:" + stationCode + ":" + now.getYear()); | |||
| deleteQuietly(CACHE_PREFIX + "summary:" + stationCode + ":" + (now.getYear() + 1)); | |||
| evictAllScopes(); | |||
| } | |||
| @Scheduled(cron = "0 50 0 * * ?") | |||
| public void refreshFutureCalendar() { | |||
| YearMonth current = YearMonth.now(); | |||
| for (AggStation station : stationRegistry.listEnabled()) { | |||
| for (int i = 0; i < 6; i++) { | |||
| YearMonth month = current.plusMonths(i); | |||
| deleteQuietly(monthKey(station.getStationCode(), month.getYear(), month.getMonthValue())); | |||
| loadStationMonthCached(station.getStationCode(), month.getYear(), month.getMonthValue()); | |||
| } | |||
| } | |||
| evictAllScopes(); | |||
| } | |||
| private List<AggCalendarEvent> loadMonthEvents(String stationCode, int year, int month) { | |||
| String scope = scope(stationCode); | |||
| String key = monthKey(stationCode, year, month); | |||
| List<AggCalendarEvent> cached = readCache(key, EVENT_LIST_TYPE); | |||
| if (cached != null) { | |||
| return cached; | |||
| } | |||
| List<AggCalendarEvent> result = new ArrayList<>(); | |||
| if (StringUtils.hasText(stationCode)) { | |||
| result.addAll(loadStationMonth(stationCode, year, month)); | |||
| } else { | |||
| for (AggStation station : stationRegistry.listEnabled()) { | |||
| result.addAll(loadStationMonthCached(station.getStationCode(), year, month)); | |||
| } | |||
| result = merge(result); | |||
| } | |||
| writeCache(key, result, monthTtl(year, month)); | |||
| return result; | |||
| } | |||
| private List<AggCalendarEvent> loadStationMonthCached(String stationCode, int year, int month) { | |||
| String key = monthKey(stationCode, year, month); | |||
| List<AggCalendarEvent> cached = readCache(key, EVENT_LIST_TYPE); | |||
| if (cached != null) { | |||
| return cached; | |||
| } | |||
| List<AggCalendarEvent> result = loadStationMonth(stationCode, year, month); | |||
| writeCache(key, result, monthTtl(year, month)); | |||
| return result; | |||
| } | |||
| private List<AggCalendarEvent> loadStationMonth(String stationCode, int year, int month) { | |||
| List<AggCalendarEvent> events = new ArrayList<>(); | |||
| events.addAll(calendarGenerator.generateMonth(stationCode, year, month)); | |||
| List<AggPatrolTaskStatus> actualTasks = | |||
| taskStatusMapper.selectDailyStatsByYearAndMonth(stationCode, year, month); | |||
| for (AggPatrolTaskStatus task : actualTasks) { | |||
| if (task.getStartTime() == null) { | |||
| continue; | |||
| } | |||
| events.add(AggCalendarEvent.builder() | |||
| .stationCode(task.getStationCode()) | |||
| .stationName(task.getStationName()) | |||
| .id(task.getTaskPatrolledId()) | |||
| .taskCode(task.getTaskCode()) | |||
| .name(task.getTaskName()) | |||
| .status(stateName(task.getTaskState())) | |||
| .source("actual") | |||
| .startTime(task.getStartTime()) | |||
| .endTime(task.getEndTime()) | |||
| .build()); | |||
| } | |||
| return merge(events); | |||
| } | |||
| private static List<AggCalendarEvent> merge(List<AggCalendarEvent> events) { | |||
| Map<String, AggCalendarEvent> unique = new HashMap<>(); | |||
| for (AggCalendarEvent event : events) { | |||
| if (event.getStartTime() == null) { | |||
| continue; | |||
| } | |||
| String key = event.getStationCode() + "|" + event.getTaskCode() + "|" + event.getStartTime(); | |||
| AggCalendarEvent existing = unique.get(key); | |||
| if (existing == null || "actual".equals(event.getSource())) { | |||
| unique.put(key, event); | |||
| } | |||
| } | |||
| List<AggCalendarEvent> result = new ArrayList<>(unique.values()); | |||
| result.sort((a, b) -> a.getStartTime().compareTo(b.getStartTime())); | |||
| return result; | |||
| } | |||
| private List<AggCalendarEvent> visibleEvents(List<AggCalendarEvent> events) { | |||
| LocalDateTime now = LocalDateTime.now(); | |||
| List<AggCalendarEvent> result = new ArrayList<>(); | |||
| for (AggCalendarEvent event : events) { | |||
| if ("plan".equals(event.getSource()) | |||
| && event.getStartTime() != null && !event.getStartTime().isAfter(now)) { | |||
| continue; | |||
| } | |||
| result.add(event); | |||
| } | |||
| return result; | |||
| } | |||
| private List<Map<String, Object>> toMaps(List<AggCalendarEvent> events) { | |||
| List<Map<String, Object>> result = new ArrayList<>(); | |||
| for (AggCalendarEvent event : events) { | |||
| Map<String, Object> item = new LinkedHashMap<>(); | |||
| item.put("id", event.getId()); | |||
| item.put("name", event.getName()); | |||
| item.put("status", event.getStatus()); | |||
| item.put("start", event.getStartTime() == null ? null : event.getStartTime().format(DATE_FORMATTER)); | |||
| item.put("end", event.getEndTime() == null ? null : event.getEndTime().format(DATE_FORMATTER)); | |||
| item.put("startTime", event.getStartTime() == null ? null : event.getStartTime().format(DATE_TIME_FORMATTER)); | |||
| item.put("endTime", event.getEndTime() == null ? null : event.getEndTime().format(DATE_TIME_FORMATTER)); | |||
| item.put("stationCode", event.getStationCode()); | |||
| item.put("stationName", event.getStationName() != null ? event.getStationName() : event.getStationCode()); | |||
| result.add(item); | |||
| } | |||
| return result; | |||
| } | |||
| private void applyStationNames(List<AggCalendarEvent> events) { | |||
| if (events == null || events.isEmpty()) { | |||
| return; | |||
| } | |||
| boolean needLookup = false; | |||
| for (AggCalendarEvent event : events) { | |||
| if (StringUtils.hasText(event.getStationCode()) && !StringUtils.hasText(event.getStationName())) { | |||
| needLookup = true; | |||
| break; | |||
| } | |||
| } | |||
| if (!needLookup) { | |||
| return; | |||
| } | |||
| Map<String, String> stationNames = new HashMap<>(); | |||
| for (AggStation station : stationRegistry.listAll()) { | |||
| stationNames.put(station.getStationCode(), station.getStationName()); | |||
| } | |||
| for (AggCalendarEvent event : events) { | |||
| if (StringUtils.hasText(event.getStationCode()) && !StringUtils.hasText(event.getStationName())) { | |||
| event.setStationName(stationNames.getOrDefault(event.getStationCode(), event.getStationCode())); | |||
| } | |||
| } | |||
| } | |||
| private static long count(List<AggCalendarEvent> events, String status) { | |||
| long count = 0; | |||
| for (AggCalendarEvent event : events) { | |||
| if (status.equals(event.getStatus())) { | |||
| count++; | |||
| } | |||
| } | |||
| return count; | |||
| } | |||
| private static String stateName(String state) { | |||
| if ("1".equals(state)) return "executed"; | |||
| if ("2".equals(state)) return "executing"; | |||
| if ("3".equals(state)) return "paused"; | |||
| if ("4".equals(state)) return "terminate"; | |||
| if ("5".equals(state)) return "pending"; | |||
| if ("6".equals(state)) return "exceeded"; | |||
| return null; | |||
| } | |||
| private String monthKey(String stationCode, int year, int month) { | |||
| return CACHE_PREFIX + "events:" + scope(stationCode) + ":" | |||
| + year + "-" + String.format("%02d", month); | |||
| } | |||
| private static String scope(String stationCode) { | |||
| return StringUtils.hasText(stationCode) ? stationCode : "ALL"; | |||
| } | |||
| private <T> T readCache(String key, TypeReference<T> type) { | |||
| try { | |||
| String value = redisTemplate.opsForValue().get(key); | |||
| return StringUtils.hasText(value) ? objectMapper.readValue(value, type) : null; | |||
| } catch (Exception e) { | |||
| log.warn("read calendar cache failed: key={}, error={}", key, e.getMessage()); | |||
| return null; | |||
| } | |||
| } | |||
| private void writeCache(String key, Object value, Duration ttl) { | |||
| try { | |||
| redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(value), ttl); | |||
| } catch (Exception e) { | |||
| log.warn("write calendar cache failed: key={}, error={}", key, e.getMessage()); | |||
| } | |||
| } | |||
| private static Duration monthTtl(int year, int month) { | |||
| YearMonth current = YearMonth.now(); | |||
| YearMonth target = YearMonth.of(year, month); | |||
| if (target.equals(current)) return Duration.ofMinutes(1); | |||
| if (target.equals(current.plusMonths(1))) return Duration.ofHours(1); | |||
| return CACHE_TTL; | |||
| } | |||
| private static Duration summaryTtl(int year) { | |||
| return year == YearMonth.now().getYear() ? Duration.ofMinutes(5) : CACHE_TTL; | |||
| } | |||
| private void deleteQuietly(String key) { | |||
| try { | |||
| redisTemplate.delete(key); | |||
| } catch (Exception e) { | |||
| log.warn("delete calendar cache failed: key={}, error={}", key, e.getMessage()); | |||
| } | |||
| } | |||
| private void evictAllScopes() { | |||
| YearMonth current = YearMonth.now(); | |||
| for (int i = 0; i < 6; i++) { | |||
| YearMonth month = current.plusMonths(i); | |||
| deleteQuietly(monthKey(null, month.getYear(), month.getMonthValue())); | |||
| } | |||
| deleteQuietly(CACHE_PREFIX + "summary:ALL:" + current.getYear()); | |||
| deleteQuietly(CACHE_PREFIX + "summary:ALL:" + (current.getYear() + 1)); | |||
| } | |||
| } | |||
| @ -0,0 +1,83 @@ | |||
| package com.inspect.agg.config; | |||
| import lombok.Data; | |||
| import org.springframework.boot.context.properties.ConfigurationProperties; | |||
| import org.springframework.stereotype.Component; | |||
| /** | |||
| * 汇聚采集服务配置(prefix = agg):默认值写在代码中;application.yml 已无 agg.* 配置项。 | |||
| * 站点配置已迁移到 DB 表 agg_station(见 StationRegistry),不再放 yaml。 | |||
| */ | |||
| @Data | |||
| @Component | |||
| @ConfigurationProperties(prefix = "agg") | |||
| public class AggProperties { | |||
| private Sync sync = new Sync(); | |||
| private Proxy proxy = new Proxy(); | |||
| private Video video = new Video(); | |||
| private Http http = new Http(); | |||
| @Data | |||
| public static class Sync { | |||
| /** 基础数据同步公共配置(轮询周期见 SyncDefaults,已写死在代码) */ | |||
| private Base base = new Base(); | |||
| private Dynamic dynamic = new Dynamic(); | |||
| private AnalysisSummary analysisSummary = new AnalysisSummary(); | |||
| @Data | |||
| public static class Base { | |||
| private int pageSize = 100; | |||
| /** 启动时先同步一次(联调用) */ | |||
| private boolean runOnStart = false; | |||
| } | |||
| @Data | |||
| public static class Dynamic { | |||
| /** 启动时先同步一次 */ | |||
| private boolean runOnStart = false; | |||
| } | |||
| @Data | |||
| public static class AnalysisSummary { | |||
| private boolean runOnStart = true; | |||
| } | |||
| } | |||
| @Data | |||
| public static class Proxy { | |||
| /** 允许代理的路径前缀(为空=不限制);建议按需收紧,如 patrol/、v1/ */ | |||
| private java.util.List<String> allowedPrefixes = new java.util.ArrayList<>(); | |||
| } | |||
| @Data | |||
| public static class Video { | |||
| /** 集控 ZLMediaKit API/播放地址(如 http://192.168.1.116:33380) */ | |||
| private String zlmBaseUrl = "http://192.168.1.116:33380"; | |||
| /** ZLM config.ini 的 api.secret */ | |||
| private String zlmSecret = "Xg@123456"; | |||
| /** 拉流目标 app */ | |||
| private String app = "live"; | |||
| /** 播放地址对办公网络暴露的地址(默认同 zlmBaseUrl) */ | |||
| private String publicBaseUrl = ""; | |||
| /** 是否启用 RTSP TCP 传输(0=TCP, 1=UDP) */ | |||
| private int rtspTransport = 0; | |||
| /** 拉流重试次数(-1=无限) */ | |||
| private int retryCount = -1; | |||
| /** ZLM hook 接收接口 token(内部鉴权) */ | |||
| private String hookToken = "inspect-agg-hook"; | |||
| /** 无 HLS 请求多少毫秒后视为空闲 */ | |||
| private long hookIdleTimeoutMs = 30_000L; | |||
| } | |||
| @Data | |||
| public static class Http { | |||
| private int connectTimeoutMs = 10_000; | |||
| private int readTimeoutMs = 30_000; | |||
| } | |||
| } | |||
| @ -0,0 +1,44 @@ | |||
| package com.inspect.agg.config; | |||
| import java.nio.charset.StandardCharsets; | |||
| import java.time.Duration; | |||
| import java.util.List; | |||
| import org.springframework.boot.web.client.RestTemplateBuilder; | |||
| import org.springframework.context.annotation.Bean; | |||
| import org.springframework.context.annotation.Configuration; | |||
| import org.springframework.http.converter.HttpMessageConverter; | |||
| import org.springframework.http.converter.StringHttpMessageConverter; | |||
| import org.springframework.web.client.RestTemplate; | |||
| /** | |||
| * HTTP 客户端配置:RestTemplate + 超时 + UTF-8(站点接口返回中文,必须 UTF-8 解码)。 | |||
| */ | |||
| @Configuration | |||
| public class RestTemplateConfig { | |||
| private final AggProperties aggProperties; | |||
| /** 显式构造器注入(不用 Lombok,避免 IDE 静态检查误报找不到 Bean) */ | |||
| public RestTemplateConfig(AggProperties aggProperties) { | |||
| this.aggProperties = aggProperties; | |||
| } | |||
| @Bean | |||
| public RestTemplate restTemplate(RestTemplateBuilder builder) { | |||
| RestTemplate restTemplate = builder | |||
| .setConnectTimeout(Duration.ofMillis(aggProperties.getHttp().getConnectTimeoutMs())) | |||
| .setReadTimeout(Duration.ofMillis(aggProperties.getHttp().getReadTimeoutMs())) | |||
| .build(); | |||
| // 默认 StringHttpMessageConverter 用 ISO-8859-1,会破坏站点返回的中文 | |||
| List<HttpMessageConverter<?>> converters = restTemplate.getMessageConverters(); | |||
| for (HttpMessageConverter<?> converter : converters) { | |||
| if (converter instanceof StringHttpMessageConverter) { | |||
| StringHttpMessageConverter stringConverter = (StringHttpMessageConverter) converter; | |||
| stringConverter.setDefaultCharset(StandardCharsets.UTF_8); | |||
| } | |||
| } | |||
| return restTemplate; | |||
| } | |||
| } | |||
| @ -0,0 +1,19 @@ | |||
| package com.inspect.agg.config; | |||
| /** | |||
| * 同步相关默认配置(写死在代码内;如需调整改这里并重新构建)。 | |||
| */ | |||
| public final class SyncDefaults { | |||
| /** 基础数据全量轮询周期(毫秒):5 分钟 */ | |||
| public static final String BASE_INTERVAL_MS = "300000"; | |||
| /** 任务执行活跃子集轮询周期(毫秒):60 秒 */ | |||
| public static final String DYNAMIC_INTERVAL_MS = "60000"; | |||
| /** 动态数据全量对账周期(毫秒):5 分钟 */ | |||
| public static final String DYNAMIC_FULL_INTERVAL_MS = "300000"; | |||
| private SyncDefaults() { | |||
| } | |||
| } | |||
| @ -0,0 +1,38 @@ | |||
| package com.inspect.agg.dto; | |||
| import java.time.LocalDateTime; | |||
| import com.fasterxml.jackson.annotation.JsonFormat; | |||
| import lombok.Data; | |||
| /** | |||
| * Response item for /agg/patrolpoint/list/monitor. | |||
| * The patrolpoint* JSON names match the station-side API contract. | |||
| */ | |||
| @Data | |||
| public class AggMonitorPatrolPointVo { | |||
| private Long id; | |||
| private String stationCode; | |||
| private String stationName; | |||
| private Long patrolpointId; | |||
| private String patrolpointCode; | |||
| private String patrolpointName; | |||
| private Long mainDeviceId; | |||
| private String deviceName; | |||
| private Long deviceId; | |||
| private Long areaId; | |||
| private String areaName; | |||
| private String patrolContent; | |||
| private String purpose; | |||
| private String patrolTypeId; | |||
| private String pointImptLevel; | |||
| private String alarmThreshold; | |||
| private String algSubtypeIds; | |||
| private String algSubtypeName; | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") | |||
| private LocalDateTime updateTime; | |||
| private LocalDateTime syncedAt; | |||
| private Integer isDeleted; | |||
| } | |||
| @ -0,0 +1,15 @@ | |||
| package com.inspect.agg.dto; | |||
| import lombok.Data; | |||
| /** | |||
| * 汇聚视频接口请求体。 | |||
| */ | |||
| @Data | |||
| public class VideoRequest { | |||
| private String cameraCode; | |||
| private Integer streamType = 2; | |||
| private Integer direct = 0; | |||
| private String rtspUrl; | |||
| } | |||
| @ -0,0 +1,13 @@ | |||
| package com.inspect.agg.entity; | |||
| import lombok.Data; | |||
| /** | |||
| * Shared station algorithm subtype dictionary (read-only). | |||
| */ | |||
| @Data | |||
| public class AggAlgSubtype { | |||
| private Long algSubtypeId; | |||
| private String algSubtypeCode; | |||
| private String algSubtypeName; | |||
| } | |||
| @ -0,0 +1,26 @@ | |||
| package com.inspect.agg.entity; | |||
| import java.time.LocalDate; | |||
| import java.time.LocalDateTime; | |||
| import lombok.Data; | |||
| /** | |||
| * 站端缺陷月度汇总表。 | |||
| */ | |||
| @Data | |||
| public class AggAnalysisDefectMonth { | |||
| private Long id; | |||
| private String stationCode; | |||
| /** 仅用于展示:所属站点名称,不落业务表 */ | |||
| private String stationName; | |||
| private LocalDate statMonth; | |||
| private String pointType; | |||
| private Integer minorCount; | |||
| private Integer generalCount; | |||
| private Integer seriousCount; | |||
| private Integer urgentCount; | |||
| private Integer normalCount; | |||
| private LocalDateTime syncedAt; | |||
| } | |||
| @ -0,0 +1,43 @@ | |||
| package com.inspect.agg.entity; | |||
| import java.time.LocalDateTime; | |||
| import com.fasterxml.jackson.annotation.JsonFormat; | |||
| import lombok.Data; | |||
| /** | |||
| * 区域镜像表 agg_basedata_area(源表:站点 basedata_area)。 | |||
| */ | |||
| @Data | |||
| public class AggBasedataArea { | |||
| private Long id; | |||
| /** 站点编码 */ | |||
| private String stationCode; | |||
| /** 仅用于展示:所属站点名称,不落业务表 */ | |||
| private String stationName; | |||
| /** 源区域 id */ | |||
| private Long areaId; | |||
| /** 父区域 id */ | |||
| private Long parentId; | |||
| /** 祖级列表,如 ,1,2002 */ | |||
| private String ancestors; | |||
| private String areaName; | |||
| private String areaCode; | |||
| private String areaAddr; | |||
| /** station / station_in / bayunit */ | |||
| private String areaType; | |||
| private String deviceTypeCode; | |||
| private Integer orderNum; | |||
| /** 0正常 1停用 */ | |||
| private String status; | |||
| private String remark; | |||
| /** 站点侧 update_time */ | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") | |||
| private LocalDateTime updateTime; | |||
| /** 中心落库时间 */ | |||
| private LocalDateTime syncedAt; | |||
| /** 0存在 1站点侧已删除 */ | |||
| private Integer isDeleted; | |||
| } | |||
| @ -0,0 +1,42 @@ | |||
| package com.inspect.agg.entity; | |||
| import java.time.LocalDateTime; | |||
| import com.fasterxml.jackson.annotation.JsonFormat; | |||
| import lombok.Data; | |||
| /** | |||
| * 设备镜像表 agg_basedata_device(源表:站点 basedata_device,接口 /basedata/prodevmnt/device/list)。 | |||
| */ | |||
| @Data | |||
| public class AggBasedataDevice { | |||
| private Long id; | |||
| private String stationCode; | |||
| /** 仅用于展示:所属站点名称,不落业务表 */ | |||
| private String stationName; | |||
| /** 源设备 id */ | |||
| private Long deviceId; | |||
| /** 父设备 id */ | |||
| private Long parentId; | |||
| private String ancestors; | |||
| private Long areaId; | |||
| private String areaName; | |||
| private String deviceCode; | |||
| private String deviceName; | |||
| private String deviceTypeName; | |||
| private String deviceModel; | |||
| private String manufacturer; | |||
| /** 电力主设备标识 Yes/No */ | |||
| private String powerMainEquFlag; | |||
| private String stationNo; | |||
| private String monitorIndexNo; | |||
| /** 0正常 1停用 */ | |||
| private String status; | |||
| private Integer orderNum; | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") | |||
| private LocalDateTime updateTime; | |||
| private LocalDateTime syncedAt; | |||
| private Integer isDeleted; | |||
| } | |||
| @ -0,0 +1,37 @@ | |||
| package com.inspect.agg.entity; | |||
| import java.time.LocalDateTime; | |||
| import com.fasterxml.jackson.annotation.JsonFormat; | |||
| import lombok.Data; | |||
| /** | |||
| * 设备类型镜像表 agg_basedata_devicetype(源表:站点 basedata_devicetype,接口 /basedata/devicetype/list)。 | |||
| */ | |||
| @Data | |||
| public class AggBasedataDevicetype { | |||
| private Long id; | |||
| private String stationCode; | |||
| /** 仅用于展示:所属站点名称,不落业务表 */ | |||
| private String stationName; | |||
| /** 源设备类型 id */ | |||
| private Long deviceTypeId; | |||
| private Long parentId; | |||
| private Integer orderNum; | |||
| private String ancestors; | |||
| private String ancestorsAddr; | |||
| private String deviceTypeName; | |||
| private String deviceTypeCode; | |||
| private String powerMainEquFlag; | |||
| private String phaseNum; | |||
| private String phase; | |||
| private String status; | |||
| private String professionType; | |||
| private Long level; | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") | |||
| private LocalDateTime updateTime; | |||
| private LocalDateTime syncedAt; | |||
| private Integer isDeleted; | |||
| } | |||
| @ -0,0 +1,40 @@ | |||
| package com.inspect.agg.entity; | |||
| import java.time.LocalDateTime; | |||
| import com.fasterxml.jackson.annotation.JsonFormat; | |||
| import lombok.Data; | |||
| /** | |||
| * 设备台账镜像表 agg_basedata_eqpbook(源表:站点 basedata_eqpbook,接口 /basedata/eqpbook/list)。 | |||
| * 注意:源响应含 user/password 等敏感字段,本表不镜像。 | |||
| */ | |||
| @Data | |||
| public class AggBasedataEqpbook { | |||
| private Long id; | |||
| private String stationCode; | |||
| /** 源台账 id */ | |||
| private Long eqpbookId; | |||
| private String patrolDeviceCode; | |||
| private String patrolDeviceName; | |||
| private String stationName; | |||
| private Long areaId; | |||
| private String areaName; | |||
| private String deviceSource; | |||
| private String deviceModel; | |||
| private String manufacturer; | |||
| /** 设备类型(type) */ | |||
| private String deviceType; | |||
| private String ipAddr; | |||
| private String port; | |||
| private String videoNvrCode; | |||
| private String mainSystemCode; | |||
| private String useMode; | |||
| private String videoMode; | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") | |||
| private LocalDateTime updateTime; | |||
| private LocalDateTime syncedAt; | |||
| private Integer isDeleted; | |||
| } | |||
| @ -0,0 +1,41 @@ | |||
| package com.inspect.agg.entity; | |||
| import java.time.LocalDateTime; | |||
| import com.fasterxml.jackson.annotation.JsonFormat; | |||
| import lombok.Data; | |||
| /** | |||
| * 设备通道镜像表 agg_basedata_eqpbook_channel(源表:站点 basedata_eqpbook_channel,接口 /basedata/eqpbook/channel/list)。 | |||
| */ | |||
| @Data | |||
| public class AggBasedataEqpbookChannel { | |||
| private Long id; | |||
| private String stationCode; | |||
| /** 仅用于展示:所属站点名称,不落业务表 */ | |||
| private String stationName; | |||
| /** 源通道 id */ | |||
| private Long channelId; | |||
| private String channelCode; | |||
| private String channelName; | |||
| /** vl/ir/ptz/ng */ | |||
| private String channelType; | |||
| /** 1在线 0离线 */ | |||
| private String onlineState; | |||
| private String channelContent; | |||
| private String host; | |||
| private String port; | |||
| private Long areaId; | |||
| private String areaName; | |||
| /** 所属台账 id */ | |||
| private Long eqpbookId; | |||
| private String patrolDeviceCode; | |||
| private String upSystemDeviceCode; | |||
| private String deviceSource; | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") | |||
| private LocalDateTime updateTime; | |||
| private LocalDateTime syncedAt; | |||
| private Integer isDeleted; | |||
| } | |||
| @ -0,0 +1,44 @@ | |||
| package com.inspect.agg.entity; | |||
| import java.time.LocalDateTime; | |||
| import com.fasterxml.jackson.annotation.JsonFormat; | |||
| import lombok.Data; | |||
| /** | |||
| * 巡检点位镜像表 agg_basedata_patrolpoint(源表:站点 basedata_patrolpoint,接口 /basedata/patrolpointmnt/patrolpoint/list)。 | |||
| */ | |||
| @Data | |||
| public class AggBasedataPatrolpoint { | |||
| private Long id; | |||
| private String stationCode; | |||
| /** 仅用于展示:所属站点名称,不落业务表 */ | |||
| private String stationName; | |||
| /** 源点位 id */ | |||
| private Long patrolPointId; | |||
| private String patrolPointCode; | |||
| private String patrolPointName; | |||
| /** 主设备 id */ | |||
| private Long mainDeviceId; | |||
| private String deviceName; | |||
| /** 设备 id(真正配置点位的设备/部件) */ | |||
| private Long deviceId; | |||
| private Long areaId; | |||
| private String areaName; | |||
| private String patrolContent; | |||
| private String purpose; | |||
| /** 巡视类型,逗号分隔 */ | |||
| private String patrolTypeId; | |||
| /** 重要等级 Ⅰ/Ⅱ/Ⅲ */ | |||
| private String pointImptLevel; | |||
| /** 告警阈值 JSON */ | |||
| private String alarmThreshold; | |||
| /** Source algorithm subtype IDs, comma-separated. */ | |||
| private String algSubtypeIds; | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") | |||
| private LocalDateTime updateTime; | |||
| private LocalDateTime syncedAt; | |||
| private Integer isDeleted; | |||
| } | |||
| @ -0,0 +1,64 @@ | |||
| package com.inspect.agg.entity; | |||
| import java.time.LocalDateTime; | |||
| import com.fasterxml.jackson.annotation.JsonFormat; | |||
| import com.fasterxml.jackson.annotation.JsonIgnore; | |||
| import lombok.Data; | |||
| /** | |||
| * Snapshot row returned by station-side /patrol/analysis/defect/list. | |||
| */ | |||
| @Data | |||
| public class AggDefectRecord { | |||
| @JsonIgnore | |||
| private Long id; | |||
| private String stationCode; | |||
| private String stationName; | |||
| private String deviceName; | |||
| private String channelName; | |||
| private String channelCode; | |||
| private String deviceSource; | |||
| private String pointName; | |||
| private String pointId; | |||
| private String warnStatus; | |||
| private String algName; | |||
| private String resValue; | |||
| private String img; | |||
| private String filterDesc; | |||
| private String brightDesc; | |||
| private String imgAnalyse; | |||
| private String brightImgAnalyse; | |||
| private String desc; | |||
| private String lineId; | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") | |||
| private LocalDateTime patrolTime; | |||
| private String pointStatus; | |||
| private String imgType; | |||
| private String dataType; | |||
| private String filter; | |||
| private String requestId; | |||
| private String resStatus; | |||
| private String brightResStatus; | |||
| private String areaName; | |||
| private String areaId; | |||
| private String algType; | |||
| private String objectId; | |||
| private String imageNormalUrlPath; | |||
| private String filePath; | |||
| private String suggestion; | |||
| private String originalValue; | |||
| private String taskPatrolId; | |||
| private String taskName; | |||
| private Integer defectLevel; | |||
| @JsonIgnore | |||
| private LocalDateTime syncedAt; | |||
| @JsonIgnore | |||
| private Integer isDeleted; | |||
| } | |||
| @ -0,0 +1,50 @@ | |||
| package com.inspect.agg.entity; | |||
| import java.time.LocalDateTime; | |||
| import com.fasterxml.jackson.annotation.JsonFormat; | |||
| import lombok.Data; | |||
| /** | |||
| * 巡视报告镜像表 agg_inspection_report。 | |||
| */ | |||
| @Data | |||
| public class AggInspectionReport { | |||
| private Long id; | |||
| private String stationCode; | |||
| private Long lineId; | |||
| private String stationName; | |||
| private String voltLevel; | |||
| private String stationType; | |||
| @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") | |||
| private LocalDateTime inspectionDate; | |||
| private String inspectionTaskName; | |||
| private String envInfo; | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") | |||
| private LocalDateTime inspectionStartTime; | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") | |||
| private LocalDateTime inspectionEndTime; | |||
| private String patrolStatistics; | |||
| private String checkPerson; | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") | |||
| private LocalDateTime checkTime; | |||
| private String description; | |||
| private String filePath; | |||
| private String taskId; | |||
| private String taskResultId; | |||
| private String taskPatrolledId; | |||
| private String location; | |||
| private String filter; | |||
| private LocalDateTime syncedAt; | |||
| private Integer isDeleted; | |||
| } | |||
| @ -0,0 +1,65 @@ | |||
| package com.inspect.agg.entity; | |||
| import java.time.LocalDateTime; | |||
| import com.fasterxml.jackson.annotation.JsonFormat; | |||
| import lombok.Data; | |||
| /** | |||
| * 任务方案镜像表 agg_patrol_task(源:patrol_task,接口 /patrol/task/list)。 | |||
| */ | |||
| @Data | |||
| public class AggPatrolTask { | |||
| private Long id; | |||
| private String stationCode; | |||
| /** 仅用于展示:所属站点名称,不落业务表 */ | |||
| private String stationName; | |||
| /** 源任务 id */ | |||
| private Long taskId; | |||
| private String taskCode; | |||
| private String taskName; | |||
| /** 巡视类型 */ | |||
| private String type; | |||
| private String priority; | |||
| private String deviceLevel; | |||
| private String deviceList; | |||
| private LocalDateTime fixedStartTime; | |||
| /** 0可用 1不可用 */ | |||
| private String isEnable; | |||
| /** 状态 */ | |||
| private String fileStatus; | |||
| /** 执行状态 */ | |||
| private String executionStatus; | |||
| private String taskCycle; | |||
| private String cycleMonth; | |||
| private String cycleWeek; | |||
| private String cycleExecuteTime; | |||
| private LocalDateTime cycleStartTime; | |||
| private LocalDateTime cycleEndTime; | |||
| private Integer intervalNumber; | |||
| private String intervalType; | |||
| private String intervalExecuteTime; | |||
| private LocalDateTime intervalStartTime; | |||
| private LocalDateTime intervalEndTime; | |||
| private LocalDateTime invalidStartTime; | |||
| private LocalDateTime invalidEndTime; | |||
| /** 1机器人 0无人机 2视频 */ | |||
| private String devType; | |||
| private String areaId; | |||
| private String areaName; | |||
| /** 字典 point_alg_category 的 dict_code,多个值逗号分隔 */ | |||
| private String pointType; | |||
| /** 方案点位总数,来源:站端 /patrol/task/list 统计返回 */ | |||
| private Integer totalPoints; | |||
| private String executionMode; | |||
| private String drafter; | |||
| private String specifiedAlg; | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") | |||
| private LocalDateTime createTime; | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") | |||
| private LocalDateTime updateTime; | |||
| private LocalDateTime syncedAt; | |||
| private Integer isDeleted; | |||
| } | |||
| @ -0,0 +1,49 @@ | |||
| package com.inspect.agg.entity; | |||
| import java.time.LocalDateTime; | |||
| import com.fasterxml.jackson.annotation.JsonFormat; | |||
| import lombok.Data; | |||
| /** | |||
| * 任务执行状态镜像表 agg_patrol_task_status(源:patrol_task_status,接口 /patrol/taskstatus/list)。 | |||
| */ | |||
| @Data | |||
| public class AggPatrolTaskStatus { | |||
| private Long id; | |||
| private String stationCode; | |||
| /** 仅用于展示:所属站点名称,不落业务表 */ | |||
| private String stationName; | |||
| private Long lineId; | |||
| private String taskPatrolledId; | |||
| private String taskName; | |||
| private String taskCode; | |||
| /** 1已执行 2执行中 3暂停 4终止 5未执行 6超期 */ | |||
| private String taskState; | |||
| private LocalDateTime planStartTime; | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") | |||
| private LocalDateTime startTime; | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") | |||
| private LocalDateTime endTime; | |||
| private String taskProgress; | |||
| private String taskEstimatedTime; | |||
| private String patrolType; | |||
| private String posType; | |||
| private String code; | |||
| private String pos; | |||
| private String execType; | |||
| private String taskType; | |||
| private String mainId; | |||
| /** ??????????? /patrol/taskstatus/list ???? */ | |||
| private Integer qxNum; | |||
| /** ?????0??? 1????????? /patrol/taskstatus/list ???? */ | |||
| private String fileStatus; | |||
| private String summary; | |||
| private String description; | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") | |||
| private LocalDateTime createTime; | |||
| private LocalDateTime syncedAt; | |||
| private Integer isDeleted; | |||
| } | |||
| @ -0,0 +1,45 @@ | |||
| package com.inspect.agg.entity; | |||
| import java.time.LocalDateTime; | |||
| import com.fasterxml.jackson.annotation.JsonFormat; | |||
| import lombok.Data; | |||
| /** | |||
| * result_analysis 汇聚镜像表。 | |||
| */ | |||
| @Data | |||
| public class AggResultAnalysis { | |||
| private Long id; | |||
| private String stationCode; | |||
| /** 仅用于展示:所属站点名称,不落业务表 */ | |||
| private String stationName; | |||
| private Long lineId; | |||
| private String resultType; | |||
| private String businessId; | |||
| private String resultContent; | |||
| private String resStatus; | |||
| private String pointStatus; | |||
| private String suggestion; | |||
| private String objectId; | |||
| private String description; | |||
| private String requestId; | |||
| private String taskPatrolId; | |||
| private String algType; | |||
| private String resValue; | |||
| private String resCode; | |||
| private String resConf; | |||
| private String resDesc; | |||
| private String result; | |||
| private String resImgUrl; | |||
| private String imageNormalUrlPath; | |||
| private String resPos; | |||
| private String filter; | |||
| private String originalValue; | |||
| private Integer defectLevel; | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") | |||
| private LocalDateTime createTime; | |||
| private LocalDateTime syncedAt; | |||
| private Integer isDeleted; | |||
| } | |||
| @ -0,0 +1,31 @@ | |||
| package com.inspect.agg.entity; | |||
| import com.fasterxml.jackson.annotation.JsonIgnore; | |||
| import lombok.Data; | |||
| /** | |||
| * 站点配置表 agg_station(DB 维护,替代 yaml 站点列表)。 | |||
| * 密码临时明文存储。 | |||
| */ | |||
| @Data | |||
| public class AggStation { | |||
| private Long id; | |||
| /** 站点编码(中心侧唯一) */ | |||
| private String stationCode; | |||
| private String stationName; | |||
| /** 站点 API 根地址,以 / 结尾 */ | |||
| @JsonIgnore | |||
| private String baseUrl; | |||
| /** 站端门户网址 */ | |||
| private String portalUrl; | |||
| @JsonIgnore | |||
| private String username; | |||
| /** 明文密码(临时;正式化前轮换并改加密/密钥) */ | |||
| @JsonIgnore | |||
| private String password; | |||
| /** 1启用 0停用 */ | |||
| private String enabled; | |||
| } | |||
| @ -0,0 +1,27 @@ | |||
| package com.inspect.agg.entity; | |||
| import java.time.LocalDateTime; | |||
| import lombok.Data; | |||
| /** | |||
| * 同步运行日志(表 agg_sync_log)。 | |||
| */ | |||
| @Data | |||
| public class AggSyncLog { | |||
| private Long id; | |||
| private String stationCode; | |||
| /** 同步实体:area/device/point/eqpbook/channel/devicetype/task/taskplan */ | |||
| private String entity; | |||
| /** FULL / ACTIVE */ | |||
| private String syncType; | |||
| /** SUCCESS / FAILED */ | |||
| private String status; | |||
| private Integer rowCount; | |||
| private Integer deletedCount; | |||
| private String message; | |||
| private LocalDateTime startedAt; | |||
| private LocalDateTime finishedAt; | |||
| } | |||
| @ -0,0 +1,37 @@ | |||
| package com.inspect.agg.enums; | |||
| import org.springframework.util.StringUtils; | |||
| public enum AlgCategory { | |||
| INFRARED("882", "红外"), | |||
| METER("881", "表计"), | |||
| APPEARANCE("880", "外观"); | |||
| private final String code; | |||
| private final String label; | |||
| AlgCategory(String code, String label) { | |||
| this.code = code; | |||
| this.label = label; | |||
| } | |||
| public String getCode() { | |||
| return code; | |||
| } | |||
| public String getLabel() { | |||
| return label; | |||
| } | |||
| public static AlgCategory fromLabel(String label) { | |||
| if (!StringUtils.hasText(label)) { | |||
| return null; | |||
| } | |||
| for (AlgCategory category : values()) { | |||
| if (category.label.equals(label.trim())) { | |||
| return category; | |||
| } | |||
| } | |||
| throw new IllegalArgumentException("不支持的算法分类: " + label); | |||
| } | |||
| } | |||
| @ -0,0 +1,25 @@ | |||
| package com.inspect.agg.mapper; | |||
| import java.time.LocalDate; | |||
| import java.util.List; | |||
| import org.apache.ibatis.annotations.Mapper; | |||
| import org.apache.ibatis.annotations.Param; | |||
| import com.inspect.agg.entity.AggAnalysisDefectMonth; | |||
| @Mapper | |||
| public interface AggAnalysisDefectMonthMapper { | |||
| int upsert(AggAnalysisDefectMonth item); | |||
| int deleteRange(@Param("stationCode") String stationCode, | |||
| @Param("beginMonth") LocalDate beginMonth, | |||
| @Param("endMonth") LocalDate endMonth); | |||
| List<AggAnalysisDefectMonth> selectRange(@Param("stationCode") String stationCode, | |||
| @Param("beginMonth") LocalDate beginMonth, | |||
| @Param("endMonth") LocalDate endMonth); | |||
| long countAll(); | |||
| } | |||
| @ -0,0 +1,30 @@ | |||
| package com.inspect.agg.mapper; | |||
| import org.apache.ibatis.annotations.Mapper; | |||
| import java.util.List; | |||
| import org.apache.ibatis.annotations.Param; | |||
| import com.inspect.agg.entity.AggBasedataArea; | |||
| /** | |||
| * AggBasedataArea 镜像 Mapper:custom SQL 全部在 XML(resources/mapper/AggBasedataAreaMapper.xml),无 QueryWrapper。 | |||
| */ | |||
| @Mapper | |||
| public interface AggBasedataAreaMapper { | |||
| /** 幂等 upsert:按 (station_code, 源主键) */ | |||
| int upsert(AggBasedataArea e); | |||
| /** 全量对账:本次未出现的行标记 is_deleted=1 */ | |||
| int markDeletedExcept(@Param("stationCode") String stationCode, @Param("ids") List<Long> ids); | |||
| /** 列表(限制条数,limit<=0 表示不限制) */ | |||
| List<AggBasedataArea> listByStation(@Param("stationCode") String stationCode, @Param("limit") int limit); | |||
| /** 整站区域(用于组树) */ | |||
| List<AggBasedataArea> listForTree(@Param("stationCode") String stationCode); | |||
| long countByStation(@Param("stationCode") String stationCode); | |||
| } | |||
| @ -0,0 +1,26 @@ | |||
| package com.inspect.agg.mapper; | |||
| import org.apache.ibatis.annotations.Mapper; | |||
| import java.util.List; | |||
| import org.apache.ibatis.annotations.Param; | |||
| import com.inspect.agg.entity.AggBasedataDevice; | |||
| /** | |||
| * AggBasedataDevice 镜像 Mapper:custom SQL 全部在 XML(resources/mapper/AggBasedataDeviceMapper.xml),无 QueryWrapper。 | |||
| */ | |||
| @Mapper | |||
| public interface AggBasedataDeviceMapper { | |||
| /** 幂等 upsert:按 (station_code, 源主键) */ | |||
| int upsert(AggBasedataDevice e); | |||
| /** 全量对账:本次未出现的行标记 is_deleted=1 */ | |||
| int markDeletedExcept(@Param("stationCode") String stationCode, @Param("ids") List<Long> ids); | |||
| List<AggBasedataDevice> list(@Param("stationCode") String stationCode, @Param("areaId") Long areaId, @Param("limit") int limit); | |||
| long count(@Param("stationCode") String stationCode, @Param("areaId") Long areaId); | |||
| } | |||
| @ -0,0 +1,26 @@ | |||
| package com.inspect.agg.mapper; | |||
| import org.apache.ibatis.annotations.Mapper; | |||
| import java.util.List; | |||
| import org.apache.ibatis.annotations.Param; | |||
| import com.inspect.agg.entity.AggBasedataDevicetype; | |||
| /** | |||
| * AggBasedataDevicetype 镜像 Mapper:custom SQL 全部在 XML(resources/mapper/AggBasedataDevicetypeMapper.xml),无 QueryWrapper。 | |||
| */ | |||
| @Mapper | |||
| public interface AggBasedataDevicetypeMapper { | |||
| /** 幂等 upsert:按 (station_code, 源主键) */ | |||
| int upsert(AggBasedataDevicetype e); | |||
| /** 全量对账:本次未出现的行标记 is_deleted=1 */ | |||
| int markDeletedExcept(@Param("stationCode") String stationCode, @Param("ids") List<Long> ids); | |||
| List<AggBasedataDevicetype> list(@Param("stationCode") String stationCode, @Param("limit") int limit); | |||
| long count(@Param("stationCode") String stationCode); | |||
| } | |||
| @ -0,0 +1,28 @@ | |||
| package com.inspect.agg.mapper; | |||
| import org.apache.ibatis.annotations.Mapper; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| import org.apache.ibatis.annotations.Param; | |||
| import com.inspect.agg.entity.AggBasedataEqpbookChannel; | |||
| /** | |||
| * AggBasedataEqpbookChannel 闀滃儚 Mapper锛歝ustom SQL 鍏ㄩ儴鍦?XML锛坮esources/mapper/AggBasedataEqpbookChannelMapper.xml锛夛紝鏃?QueryWrapper銆? */ | |||
| @Mapper | |||
| public interface AggBasedataEqpbookChannelMapper { | |||
| /** 骞傜瓑 upsert锛氭寜 (station_code, 婧愪富閿? */ | |||
| int upsert(AggBasedataEqpbookChannel e); | |||
| /** 鍏ㄩ噺瀵硅处锛氭湰娆℃湭鍑虹幇鐨勮鏍囪 is_deleted=1 */ | |||
| int markDeletedExcept(@Param("stationCode") String stationCode, @Param("ids") List<Long> ids); | |||
| List<AggBasedataEqpbookChannel> list(@Param("stationCode") String stationCode, @Param("limit") int limit); | |||
| long count(@Param("stationCode") String stationCode); | |||
| List<Map<String, Object>> countByDeviceSource(@Param("stationCode") String stationCode); | |||
| } | |||
| @ -0,0 +1,26 @@ | |||
| package com.inspect.agg.mapper; | |||
| import org.apache.ibatis.annotations.Mapper; | |||
| import java.util.List; | |||
| import org.apache.ibatis.annotations.Param; | |||
| import com.inspect.agg.entity.AggBasedataEqpbook; | |||
| /** | |||
| * AggBasedataEqpbook 镜像 Mapper:custom SQL 全部在 XML(resources/mapper/AggBasedataEqpbookMapper.xml),无 QueryWrapper。 | |||
| */ | |||
| @Mapper | |||
| public interface AggBasedataEqpbookMapper { | |||
| /** 幂等 upsert:按 (station_code, 源主键) */ | |||
| int upsert(AggBasedataEqpbook e); | |||
| /** 全量对账:本次未出现的行标记 is_deleted=1 */ | |||
| int markDeletedExcept(@Param("stationCode") String stationCode, @Param("ids") List<Long> ids); | |||
| List<AggBasedataEqpbook> list(@Param("stationCode") String stationCode, @Param("limit") int limit); | |||
| long count(@Param("stationCode") String stationCode); | |||
| } | |||
| @ -0,0 +1,31 @@ | |||
| package com.inspect.agg.mapper; | |||
| import org.apache.ibatis.annotations.Mapper; | |||
| import java.util.List; | |||
| import org.apache.ibatis.annotations.Param; | |||
| import com.inspect.agg.dto.AggMonitorPatrolPointVo; | |||
| import com.inspect.agg.entity.AggBasedataPatrolpoint; | |||
| /** | |||
| * AggBasedataPatrolpoint 镜像 Mapper:custom SQL 全部在 XML(resources/mapper/AggBasedataPatrolpointMapper.xml),无 QueryWrapper。 | |||
| */ | |||
| @Mapper | |||
| public interface AggBasedataPatrolpointMapper { | |||
| /** 幂等 upsert:按 (station_code, 源主键) */ | |||
| int upsert(AggBasedataPatrolpoint e); | |||
| /** 全量对账:本次未出现的行标记 is_deleted=1 */ | |||
| int markDeletedExcept(@Param("stationCode") String stationCode, @Param("ids") List<Long> ids); | |||
| List<AggBasedataPatrolpoint> list(@Param("stationCode") String stationCode, @Param("deviceId") Long deviceId, | |||
| @Param("areaId") Long areaId, @Param("limit") int limit); | |||
| long count(@Param("stationCode") String stationCode, @Param("deviceId") Long deviceId, @Param("areaId") Long areaId); | |||
| List<AggMonitorPatrolPointVo> selectMonitorRelationList(@Param("stationCode") String stationCode, | |||
| @Param("deviceId") Long deviceId); | |||
| } | |||
| @ -0,0 +1,25 @@ | |||
| package com.inspect.agg.mapper; | |||
| import java.util.List; | |||
| import org.apache.ibatis.annotations.Mapper; | |||
| import org.apache.ibatis.annotations.Param; | |||
| import com.inspect.agg.entity.AggDefectRecord; | |||
| @Mapper | |||
| public interface AggDefectRecordMapper { | |||
| int upsert(AggDefectRecord record); | |||
| List<AggDefectRecord> selectActiveKeys(@Param("stationCode") String stationCode); | |||
| int markDeletedByIds(@Param("stationCode") String stationCode, @Param("ids") List<Long> ids); | |||
| List<AggDefectRecord> selectList(@Param("stationCode") String stationCode, | |||
| @Param("pointName") String pointName, | |||
| @Param("patrolTimeBegin") String patrolTimeBegin, | |||
| @Param("patrolTimeEnd") String patrolTimeEnd, | |||
| @Param("algName") String algName); | |||
| List<String> selectDistinctAlgNames(@Param("stationCode") String stationCode); | |||
| } | |||
| @ -0,0 +1,26 @@ | |||
| package com.inspect.agg.mapper; | |||
| import java.util.List; | |||
| import org.apache.ibatis.annotations.Mapper; | |||
| import org.apache.ibatis.annotations.Param; | |||
| import com.inspect.agg.entity.AggInspectionReport; | |||
| @Mapper | |||
| public interface AggInspectionReportMapper { | |||
| int upsert(AggInspectionReport report); | |||
| int markDeletedExcept(@Param("stationCode") String stationCode, @Param("lineIds") List<Long> lineIds); | |||
| List<AggInspectionReport> selectList(@Param("stationCode") String stationCode, | |||
| @Param("cycleStartTime") String cycleStartTime, | |||
| @Param("cycleEndTime") String cycleEndTime, | |||
| @Param("inspectionTaskName") String inspectionTaskName); | |||
| long count(@Param("stationCode") String stationCode, | |||
| @Param("cycleStartTime") String cycleStartTime, | |||
| @Param("cycleEndTime") String cycleEndTime, | |||
| @Param("inspectionTaskName") String inspectionTaskName); | |||
| } | |||
| @ -0,0 +1,31 @@ | |||
| package com.inspect.agg.mapper; | |||
| import org.apache.ibatis.annotations.Mapper; | |||
| import java.util.List; | |||
| import org.apache.ibatis.annotations.Param; | |||
| import com.inspect.agg.entity.AggPatrolTask; | |||
| /** | |||
| * AggPatrolTask 镜像 Mapper:custom SQL 全部在 XML(resources/mapper/AggPatrolTaskMapper.xml),无 QueryWrapper。 | |||
| */ | |||
| @Mapper | |||
| public interface AggPatrolTaskMapper { | |||
| /** 幂等 upsert:按 (station_code, 源主键) */ | |||
| int upsert(AggPatrolTask e); | |||
| /** 全量对账:本次未出现的行标记 is_deleted=1 */ | |||
| int markDeletedExcept(@Param("stationCode") String stationCode, @Param("ids") List<Long> ids); | |||
| List<AggPatrolTask> list(@Param("stationCode") String stationCode, @Param("limit") int limit); | |||
| long count(@Param("stationCode") String stationCode); | |||
| List<AggPatrolTask> selectTaskList(@Param("stationCode") String stationCode, | |||
| @Param("taskName") String taskName); | |||
| List<AggPatrolTask> selectEnabledTasks(@Param("stationCode") String stationCode); | |||
| } | |||
| @ -0,0 +1,46 @@ | |||
| package com.inspect.agg.mapper; | |||
| import java.time.LocalDateTime; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| import org.apache.ibatis.annotations.Mapper; | |||
| import org.apache.ibatis.annotations.Param; | |||
| import com.inspect.agg.entity.AggPatrolTaskStatus; | |||
| @Mapper | |||
| public interface AggPatrolTaskStatusMapper { | |||
| int upsert(AggPatrolTaskStatus e); | |||
| int markDeletedExcept(@Param("stationCode") String stationCode, @Param("ids") List<Long> ids); | |||
| List<AggPatrolTaskStatus> list(@Param("stationCode") String stationCode, | |||
| @Param("taskState") String taskState, | |||
| @Param("limit") int limit); | |||
| long count(@Param("stationCode") String stationCode, @Param("taskState") String taskState); | |||
| List<AggPatrolTaskStatus> selectTaskStatusList(@Param("stationCode") String stationCode, | |||
| @Param("taskName") String taskName, | |||
| @Param("beginTime") String beginTime, | |||
| @Param("endTime") String endTime); | |||
| List<Map<String, Object>> countMonthlyStatsByYear(@Param("stationCode") String stationCode, | |||
| @Param("year") Integer year); | |||
| List<AggPatrolTaskStatus> selectDailyStatsByYearAndMonth(@Param("stationCode") String stationCode, | |||
| @Param("year") Integer year, | |||
| @Param("month") Integer month); | |||
| List<AggPatrolTaskStatus> selectDailyStatsByYearAndMonthAndDay(@Param("stationCode") String stationCode, | |||
| @Param("year") Integer year, | |||
| @Param("month") Integer month, | |||
| @Param("day") Integer day); | |||
| Map<String, Object> selectInspectionTaskStats(@Param("stationCode") String stationCode, | |||
| @Param("pointTypeCode") String pointTypeCode, | |||
| @Param("beginTime") LocalDateTime beginTime, | |||
| @Param("endTime") LocalDateTime endTime); | |||
| } | |||
| @ -0,0 +1,22 @@ | |||
| package com.inspect.agg.mapper; | |||
| import java.time.LocalDateTime; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| import org.apache.ibatis.annotations.Mapper; | |||
| import org.apache.ibatis.annotations.Param; | |||
| import com.inspect.agg.entity.AggResultAnalysis; | |||
| @Mapper | |||
| public interface AggResultAnalysisMapper { | |||
| int upsert(AggResultAnalysis e); | |||
| List<Map<String, Object>> selectDefectStats(@Param("stationCode") String stationCode, | |||
| @Param("pointTypeCode") String pointTypeCode, | |||
| @Param("dateType") String dateType, | |||
| @Param("beginTime") LocalDateTime beginTime, | |||
| @Param("endTime") LocalDateTime endTime); | |||
| } | |||
| @ -0,0 +1,24 @@ | |||
| package com.inspect.agg.mapper; | |||
| import org.apache.ibatis.annotations.Mapper; | |||
| import java.util.List; | |||
| import org.apache.ibatis.annotations.Param; | |||
| import com.inspect.agg.entity.AggStation; | |||
| /** | |||
| * 站点配置 Mapper:custom SQL 在 XML,无 QueryWrapper。 | |||
| */ | |||
| @Mapper | |||
| public interface AggStationMapper { | |||
| List<AggStation> selectEnabled(); | |||
| List<AggStation> selectAllOrdered(); | |||
| AggStation selectByCode(@Param("stationCode") String stationCode); | |||
| List<AggStation> selectByEnabled(@Param("enabled") Integer enabled); | |||
| } | |||
| @ -0,0 +1,14 @@ | |||
| package com.inspect.agg.mapper; | |||
| import org.apache.ibatis.annotations.Mapper; | |||
| import com.inspect.agg.entity.AggSyncLog; | |||
| /** | |||
| * 同步日志 Mapper:SQL 在 XML(AggSyncLogMapper.xml),纯 MyBatis。 | |||
| */ | |||
| @Mapper | |||
| public interface AggSyncLogMapper { | |||
| int insert(AggSyncLog log); | |||
| } | |||
| @ -0,0 +1,12 @@ | |||
| package com.inspect.agg.mapper; | |||
| import java.util.List; | |||
| import org.apache.ibatis.annotations.Mapper; | |||
| import com.inspect.agg.entity.AggAlgSubtype; | |||
| @Mapper | |||
| public interface BasedataAlgSubtypeMapper { | |||
| List<AggAlgSubtype> selectEnabledList(); | |||
| } | |||
| @ -0,0 +1,61 @@ | |||
| package com.inspect.agg.scheduler; | |||
| import org.springframework.boot.ApplicationArguments; | |||
| import org.springframework.boot.ApplicationRunner; | |||
| import org.springframework.scheduling.annotation.Scheduled; | |||
| import org.springframework.stereotype.Component; | |||
| import com.inspect.agg.config.AggProperties; | |||
| import com.inspect.agg.config.SyncDefaults; | |||
| import com.inspect.agg.service.AreaSyncService; | |||
| import com.inspect.agg.service.ChannelSyncService; | |||
| import com.inspect.agg.service.DevicetypeSyncService; | |||
| import com.inspect.agg.service.DeviceSyncService; | |||
| import com.inspect.agg.service.EqpbookSyncService; | |||
| import com.inspect.agg.service.InspectionReportSyncService; | |||
| import com.inspect.agg.service.PointSyncService; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * 基础数据(区域/设备/点位/台账/通道/设备类型)轮询调度:定时周期执行 + 可选启动时先同步一次。 | |||
| */ | |||
| @Slf4j | |||
| @Component | |||
| @RequiredArgsConstructor | |||
| public class BaseSyncScheduler implements ApplicationRunner { | |||
| private final AreaSyncService areaSyncService; | |||
| private final DeviceSyncService deviceSyncService; | |||
| private final PointSyncService pointSyncService; | |||
| private final EqpbookSyncService eqpbookSyncService; | |||
| private final ChannelSyncService channelSyncService; | |||
| private final DevicetypeSyncService devicetypeSyncService; | |||
| private final InspectionReportSyncService inspectionReportSyncService; | |||
| private final AggProperties props; | |||
| @Scheduled(initialDelayString = SyncDefaults.BASE_INTERVAL_MS, | |||
| fixedDelayString = SyncDefaults.BASE_INTERVAL_MS) | |||
| public void scheduledBaseSync() { | |||
| syncAll(); | |||
| } | |||
| @Override | |||
| public void run(ApplicationArguments args) { | |||
| if (props.getSync().getBase().isRunOnStart()) { | |||
| log.info("run-on-start enabled, start base-data sync once"); | |||
| syncAll(); | |||
| } | |||
| } | |||
| private void syncAll() { | |||
| areaSyncService.syncAllStations(); | |||
| deviceSyncService.syncAllStations(); | |||
| pointSyncService.syncAllStations(); | |||
| eqpbookSyncService.syncAllStations(); | |||
| channelSyncService.syncAllStations(); | |||
| devicetypeSyncService.syncAllStations(); | |||
| inspectionReportSyncService.syncAllStations(); | |||
| } | |||
| } | |||
| @ -0,0 +1,28 @@ | |||
| package com.inspect.agg.scheduler; | |||
| import org.springframework.scheduling.annotation.Scheduled; | |||
| import org.springframework.stereotype.Component; | |||
| import com.inspect.agg.config.SyncDefaults; | |||
| import com.inspect.agg.service.DefectRecordSyncService; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| @Slf4j | |||
| @Component | |||
| @RequiredArgsConstructor | |||
| public class DefectRecordSyncScheduler { | |||
| private final DefectRecordSyncService defectRecordSyncService; | |||
| @Scheduled(initialDelay = 120_000L, fixedDelayString = SyncDefaults.BASE_INTERVAL_MS) | |||
| public void syncRecent() { | |||
| defectRecordSyncService.syncRecentAllStations(); | |||
| } | |||
| @Scheduled(cron = "0 10 3 * * ?") | |||
| public void syncFull() { | |||
| defectRecordSyncService.syncFullAllStations(); | |||
| } | |||
| } | |||
| @ -0,0 +1,53 @@ | |||
| package com.inspect.agg.scheduler; | |||
| import org.springframework.boot.ApplicationArguments; | |||
| import org.springframework.boot.ApplicationRunner; | |||
| import org.springframework.scheduling.annotation.Scheduled; | |||
| import org.springframework.stereotype.Component; | |||
| import com.inspect.agg.config.AggProperties; | |||
| import com.inspect.agg.config.SyncDefaults; | |||
| import com.inspect.agg.service.PatrolTaskSyncService; | |||
| import com.inspect.agg.service.TaskExecSyncService; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * 动态数据调度: | |||
| * - 任务执行 ACTIVE:interval-ms(默认 60s)拉执行中子集 | |||
| * - FULL:full-interval-ms(默认 5min)任务执行 + 任务方案全量对账 | |||
| * 站点间并行(服务内 parallelStream)。 | |||
| */ | |||
| @Slf4j | |||
| @Component | |||
| @RequiredArgsConstructor | |||
| public class DynamicSyncScheduler implements ApplicationRunner { | |||
| private final TaskExecSyncService taskExecSyncService; | |||
| private final PatrolTaskSyncService patrolTaskSyncService; | |||
| private final AggProperties props; | |||
| @Scheduled(initialDelayString = SyncDefaults.DYNAMIC_INTERVAL_MS, | |||
| fixedDelayString = SyncDefaults.DYNAMIC_INTERVAL_MS) | |||
| public void scheduledActive() { | |||
| taskExecSyncService.syncActiveAllStations(); | |||
| } | |||
| @Scheduled(initialDelayString = SyncDefaults.DYNAMIC_FULL_INTERVAL_MS, | |||
| fixedDelayString = SyncDefaults.DYNAMIC_FULL_INTERVAL_MS) | |||
| public void scheduledFull() { | |||
| taskExecSyncService.syncFullAllStations(); | |||
| patrolTaskSyncService.syncFullAllStations(); | |||
| } | |||
| @Override | |||
| public void run(ApplicationArguments args) { | |||
| if (props.getSync().getDynamic().isRunOnStart()) { | |||
| log.info("dynamic run-on-start enabled, run ACTIVE+FULL once"); | |||
| taskExecSyncService.syncActiveAllStations(); | |||
| taskExecSyncService.syncFullAllStations(); | |||
| patrolTaskSyncService.syncFullAllStations(); | |||
| } | |||
| } | |||
| } | |||
| @ -0,0 +1,31 @@ | |||
| package com.inspect.agg.scheduler; | |||
| import org.springframework.scheduling.annotation.Scheduled; | |||
| import org.springframework.stereotype.Component; | |||
| import com.inspect.agg.video.RealtimeStreamService; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * 清理过期的实时观看票据;票据全部过期后关闭对应汇聚和站端实时流。 | |||
| */ | |||
| @Slf4j | |||
| @Component | |||
| @RequiredArgsConstructor | |||
| public class RealtimeViewerCleanupScheduler { | |||
| private static final long CLEAN_INTERVAL_MS = 30L * 60 * 1000; | |||
| private final RealtimeStreamService realtimeStreamService; | |||
| @Scheduled(fixedDelay = CLEAN_INTERVAL_MS, initialDelay = CLEAN_INTERVAL_MS) | |||
| public void cleanup() { | |||
| try { | |||
| realtimeStreamService.cleanupExpired(); | |||
| } catch (Exception e) { | |||
| log.error("cleanup expired realtime streams failed: {}", e.getMessage(), e); | |||
| } | |||
| } | |||
| } | |||
| @ -0,0 +1,33 @@ | |||
| package com.inspect.agg.scheduler; | |||
| import org.springframework.boot.ApplicationArguments; | |||
| import org.springframework.boot.ApplicationRunner; | |||
| import org.springframework.scheduling.annotation.Scheduled; | |||
| import org.springframework.stereotype.Component; | |||
| import com.inspect.agg.config.AggProperties; | |||
| import com.inspect.agg.service.AggAnalysisDefectMonthSyncService; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| @Slf4j | |||
| @Component | |||
| @RequiredArgsConstructor | |||
| public class ResultAnalysisSyncScheduler implements ApplicationRunner { | |||
| private final AggAnalysisDefectMonthSyncService syncService; | |||
| private final AggProperties props; | |||
| @Scheduled(cron = "0 30 2 * * ?") | |||
| public void syncLast12Months() { | |||
| syncService.syncLast12MonthsAllStations(); | |||
| } | |||
| @Override | |||
| public void run(ApplicationArguments args) { | |||
| if (props.getSync().getAnalysisSummary().isRunOnStart()) { | |||
| syncService.syncLast12MonthsAllStations(); | |||
| } | |||
| } | |||
| } | |||
| @ -0,0 +1,100 @@ | |||
| package com.inspect.agg.service; | |||
| import java.time.LocalDate; | |||
| import java.time.LocalDateTime; | |||
| import java.time.YearMonth; | |||
| import java.time.format.DateTimeFormatter; | |||
| import org.springframework.stereotype.Service; | |||
| import org.springframework.util.StringUtils; | |||
| import com.fasterxml.jackson.databind.JsonNode; | |||
| import com.inspect.agg.entity.AggAnalysisDefectMonth; | |||
| import com.inspect.agg.entity.AggStation; | |||
| import com.inspect.agg.mapper.AggAnalysisDefectMonthMapper; | |||
| import com.inspect.agg.station.StationApiClient; | |||
| import com.inspect.agg.support.Rows; | |||
| import com.inspect.agg.support.SyncLogWriter; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| @Slf4j | |||
| @Service | |||
| @RequiredArgsConstructor | |||
| public class AggAnalysisDefectMonthSyncService { | |||
| private static final DateTimeFormatter MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM"); | |||
| private final StationRegistry stationRegistry; | |||
| private final StationApiClient stationApiClient; | |||
| private final AggAnalysisDefectMonthMapper defectMonthMapper; | |||
| private final SyncLogWriter syncLogWriter; | |||
| public void syncLast12MonthsAllStations() { | |||
| stationRegistry.listEnabled().parallelStream().forEach(this::syncLast12Months); | |||
| } | |||
| public void syncLast12Months(AggStation station) { | |||
| LocalDateTime started = LocalDateTime.now(); | |||
| String status = "SUCCESS"; | |||
| int upserted = 0; | |||
| String message = null; | |||
| YearMonth current = YearMonth.now(); | |||
| YearMonth first = current.minusMonths(11); | |||
| LocalDate begin = first.atDay(1); | |||
| LocalDate endExclusive = current.plusMonths(1).atDay(1); | |||
| try { | |||
| JsonNode root = stationApiClient.fetchAggDefectMonth( | |||
| station, first.format(MONTH_FORMAT), current.format(MONTH_FORMAT)); | |||
| if (root.path("code").asInt() != 200) { | |||
| throw new IllegalStateException(root.path("msg").asText(root.toString())); | |||
| } | |||
| JsonNode data = root.path("data"); | |||
| if (!data.isArray()) { | |||
| throw new IllegalStateException("station response data is not array: " + root); | |||
| } | |||
| defectMonthMapper.deleteRange(station.getStationCode(), begin, endExclusive); | |||
| for (JsonNode row : data) { | |||
| AggAnalysisDefectMonth item = map(row, station); | |||
| if (item == null) { | |||
| continue; | |||
| } | |||
| defectMonthMapper.upsert(item); | |||
| upserted++; | |||
| } | |||
| message = "rows=" + data.size() + ", upserted=" + upserted; | |||
| log.info("station={} analysis month summary ok: {}", station.getStationCode(), message); | |||
| } catch (Exception e) { | |||
| status = "FAILED"; | |||
| message = Rows.abbrev(e.getMessage(), 480); | |||
| log.error("station={} analysis month summary failed: {}", station.getStationCode(), e.getMessage()); | |||
| } | |||
| syncLogWriter.write(station.getStationCode(), "analysis-month", "FULL", status, | |||
| upserted, 0, message, started, LocalDateTime.now()); | |||
| } | |||
| private AggAnalysisDefectMonth map(JsonNode row, AggStation station) { | |||
| String statMonth = Rows.text(row, "statMonth"); | |||
| if (!StringUtils.hasText(statMonth)) { | |||
| return null; | |||
| } | |||
| AggAnalysisDefectMonth item = new AggAnalysisDefectMonth(); | |||
| item.setStationCode(station.getStationCode()); | |||
| item.setStatMonth(YearMonth.parse(statMonth.trim(), MONTH_FORMAT).atDay(1)); | |||
| String pointType = Rows.text(row, "pointType"); | |||
| item.setPointType(StringUtils.hasText(pointType) ? pointType.trim() : ""); | |||
| item.setMinorCount(value(row, "minorCount")); | |||
| item.setGeneralCount(value(row, "generalCount")); | |||
| item.setSeriousCount(value(row, "seriousCount")); | |||
| item.setUrgentCount(value(row, "urgentCount")); | |||
| item.setNormalCount(value(row, "normalCount")); | |||
| item.setSyncedAt(LocalDateTime.now()); | |||
| return item; | |||
| } | |||
| private Integer value(JsonNode row, String key) { | |||
| Integer value = Rows.intVal(row, key); | |||
| return value == null ? 0 : value; | |||
| } | |||
| } | |||
| @ -0,0 +1,87 @@ | |||
| package com.inspect.agg.service; | |||
| import java.util.ArrayList; | |||
| import java.util.Collections; | |||
| import java.util.Iterator; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| import org.springframework.stereotype.Service; | |||
| import org.springframework.util.StringUtils; | |||
| import com.fasterxml.jackson.databind.JsonNode; | |||
| import com.fasterxml.jackson.databind.node.ObjectNode; | |||
| import com.inspect.agg.config.AggProperties; | |||
| import com.inspect.agg.entity.AggStation; | |||
| import com.inspect.agg.station.StationApiClient; | |||
| import lombok.RequiredArgsConstructor; | |||
| /** | |||
| * 关系树数据尚未展开为汇聚表,按站点读取关系树并统一补充 stationCode。 | |||
| */ | |||
| @Service | |||
| @RequiredArgsConstructor | |||
| public class AggDeviceTreeService { | |||
| private static final String TREE_PATH = | |||
| "basedata/prodevmnt/device/treeAreaDeviceSelectFilterByPreset/relation"; | |||
| private final StationRegistry stationRegistry; | |||
| private final StationApiClient stationApiClient; | |||
| private final AggProperties props; | |||
| public List<JsonNode> loadRelationTree(String stationCode, Map<String, String> params) { | |||
| List<AggStation> stations; | |||
| if (StringUtils.hasText(stationCode)) { | |||
| stations = Collections.singletonList(stationRegistry.requireByCode(stationCode)); | |||
| } else { | |||
| stations = stationRegistry.listEnabled(); | |||
| } | |||
| boolean aggregate = !StringUtils.hasText(stationCode); | |||
| List<JsonNode> result = new ArrayList<>(); | |||
| int pageSize = props.getSync().getBase().getPageSize(); | |||
| for (AggStation station : stations) { | |||
| List<JsonNode> roots = stationApiClient.fetchAllRows( | |||
| station, TREE_PATH, pageSize, 20, params, "dataId"); | |||
| for (JsonNode root : roots) { | |||
| ObjectNode copy = (ObjectNode) root.deepCopy(); | |||
| normalizeNode(copy, station.getStationCode(), | |||
| station.getStationName() != null ? station.getStationName() : station.getStationCode(), | |||
| aggregate); | |||
| result.add(copy); | |||
| } | |||
| } | |||
| return result; | |||
| } | |||
| private static void normalizeNode(ObjectNode node, String stationCode, String stationName, | |||
| boolean prefixIds) { | |||
| if (node == null || !node.isObject()) { | |||
| return; | |||
| } | |||
| node.put("stationCode", stationCode); | |||
| node.put("stationName", stationName != null ? stationName : stationCode); | |||
| if (prefixIds) { | |||
| String dataId = text(node, "dataId"); | |||
| if (StringUtils.hasText(dataId)) { | |||
| node.put("dataId", stationCode + ":" + dataId); | |||
| } | |||
| String parentId = text(node, "parentId"); | |||
| if (StringUtils.hasText(parentId)) { | |||
| node.put("parentId", stationCode + ":" + parentId); | |||
| } | |||
| } | |||
| JsonNode children = node.get("children"); | |||
| if (children != null && children.isArray()) { | |||
| for (JsonNode child : children) { | |||
| normalizeNode((ObjectNode) child, stationCode, stationName, prefixIds); | |||
| } | |||
| } | |||
| } | |||
| private static String text(JsonNode node, String key) { | |||
| JsonNode value = node.get(key); | |||
| return value == null || value.isNull() ? null : value.asText(); | |||
| } | |||
| } | |||
| @ -0,0 +1,236 @@ | |||
| package com.inspect.agg.service; | |||
| import java.time.LocalDate; | |||
| import java.time.YearMonth; | |||
| import java.time.format.DateTimeFormatter; | |||
| import java.util.ArrayList; | |||
| import java.util.LinkedHashMap; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| import org.springframework.stereotype.Service; | |||
| import org.springframework.util.StringUtils; | |||
| import com.inspect.agg.entity.AggAnalysisDefectMonth; | |||
| import com.inspect.agg.entity.AggStation; | |||
| import com.inspect.agg.enums.AlgCategory; | |||
| import com.inspect.agg.mapper.AggAnalysisDefectMonthMapper; | |||
| import com.inspect.agg.mapper.AggStationMapper; | |||
| import com.inspect.agg.mapper.AggPatrolTaskStatusMapper; | |||
| import lombok.RequiredArgsConstructor; | |||
| @Service | |||
| @RequiredArgsConstructor | |||
| public class AggStatisticService { | |||
| private static final DateTimeFormatter MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM"); | |||
| private static final String MONTH = "month"; | |||
| private static final String QUARTER = "quarter"; | |||
| private final AggAnalysisDefectMonthMapper defectMonthMapper; | |||
| private final AggPatrolTaskStatusMapper taskStatusMapper; | |||
| private final AggStationMapper stationMapper; | |||
| public List<Map<String, Object>> defectStatistic(String stationCode, String algCategory, | |||
| String dateType) { | |||
| AlgCategory category = AlgCategory.fromLabel(algCategory); | |||
| String normalizedType = normalizeDateType(dateType); | |||
| YearMonth current = YearMonth.now(); | |||
| LocalDate begin; | |||
| LocalDate endExclusive; | |||
| if (QUARTER.equals(normalizedType)) { | |||
| YearMonth currentQuarter = quarterStart(current); | |||
| begin = currentQuarter.minusMonths(9).atDay(1); | |||
| endExclusive = currentQuarter.plusMonths(3).atDay(1); | |||
| } else { | |||
| begin = current.minusMonths(11).atDay(1); | |||
| endExclusive = current.plusMonths(1).atDay(1); | |||
| } | |||
| List<AggAnalysisDefectMonth> rows = defectMonthMapper.selectRange(stationCode, begin, endExclusive); | |||
| Map<YearMonth, Counts> monthlyMap = toMonthlyMap(rows, category == null ? "" : category.getCode()); | |||
| String stationName = stationDisplayName(stationCode); | |||
| List<Map<String, Object>> result = new ArrayList<>(); | |||
| if (QUARTER.equals(normalizedType)) { | |||
| YearMonth firstQuarter = quarterStart(current).minusMonths(9); | |||
| for (int i = 0; i < 4; i++) { | |||
| YearMonth quarter = firstQuarter.plusMonths(i * 3L); | |||
| Counts counts = new Counts(); | |||
| for (int monthOffset = 0; monthOffset < 3; monthOffset++) { | |||
| counts.add(monthlyMap.get(quarter.plusMonths(monthOffset))); | |||
| } | |||
| result.add(toPeriodResult(counts, | |||
| quarter.getYear() + "-Q" + ((quarter.getMonthValue() - 1) / 3 + 1), | |||
| normalizedType, algCategory, stationCode, stationName)); | |||
| } | |||
| return result; | |||
| } | |||
| YearMonth first = current.minusMonths(11); | |||
| for (int i = 0; i < 12; i++) { | |||
| YearMonth month = first.plusMonths(i); | |||
| Counts counts = monthlyMap.getOrDefault(month, new Counts()); | |||
| result.add(toPeriodResult(counts, month.format(MONTH_FORMAT), normalizedType, algCategory, | |||
| stationCode, stationName)); | |||
| } | |||
| return result; | |||
| } | |||
| public List<Map<String, Object>> inspectionStatistic(String stationCode, String algCategory) { | |||
| AlgCategory category = AlgCategory.fromLabel(algCategory); | |||
| String stationName = stationDisplayName(stationCode); | |||
| if (category != null) { | |||
| return java.util.Collections.singletonList(inspectionByCategory(stationCode, stationName, category)); | |||
| } | |||
| List<Map<String, Object>> result = new ArrayList<>(); | |||
| for (AlgCategory item : AlgCategory.values()) { | |||
| result.add(inspectionByCategory(stationCode, stationName, item)); | |||
| } | |||
| return result; | |||
| } | |||
| private Map<String, Object> inspectionByCategory(String stationCode, String stationName, | |||
| AlgCategory category) { | |||
| YearMonth current = YearMonth.now(); | |||
| LocalDate begin = current.atDay(1); | |||
| LocalDate endExclusive = current.plusMonths(1).atDay(1); | |||
| Map<String, Object> taskStats = taskStatusMapper.selectInspectionTaskStats( | |||
| stationCode, category.getCode(), begin.atStartOfDay(), endExclusive.atStartOfDay()); | |||
| List<AggAnalysisDefectMonth> rows = defectMonthMapper.selectRange(stationCode, begin, endExclusive); | |||
| Counts defectStats = new Counts(); | |||
| for (AggAnalysisDefectMonth row : rows) { | |||
| if (category.getCode().equals(emptyIfNull(row.getPointType()))) { | |||
| defectStats.add(row); | |||
| } | |||
| } | |||
| return toInspectionResult(category.getLabel(), taskStats, defectStats, stationCode, stationName); | |||
| } | |||
| private Map<YearMonth, Counts> toMonthlyMap(List<AggAnalysisDefectMonth> rows, String pointType) { | |||
| Map<YearMonth, Counts> result = new LinkedHashMap<>(); | |||
| for (AggAnalysisDefectMonth row : rows) { | |||
| if (!pointType.equals(emptyIfNull(row.getPointType()))) { | |||
| continue; | |||
| } | |||
| YearMonth month = YearMonth.from(row.getStatMonth()); | |||
| result.computeIfAbsent(month, key -> new Counts()).add(row); | |||
| } | |||
| return result; | |||
| } | |||
| private Map<String, Object> toPeriodResult(Counts counts, String period, | |||
| String dateType, String algCategory, | |||
| String stationCode, String stationName) { | |||
| Map<String, Object> item = new LinkedHashMap<>(); | |||
| item.put("date", period); | |||
| item.put("dateType", dateType); | |||
| if (StringUtils.hasText(algCategory)) { | |||
| item.put("algCategory", algCategory); | |||
| } | |||
| if (StringUtils.hasText(stationCode)) { | |||
| item.put("stationCode", stationCode); | |||
| item.put("stationName", stationName != null ? stationName : stationCode); | |||
| } | |||
| item.put("minorCount", counts.minor); | |||
| item.put("generalCount", counts.general); | |||
| item.put("seriousCount", counts.serious); | |||
| item.put("urgentCount", counts.urgent); | |||
| item.put("normalCount", counts.normal); | |||
| item.put("defectCount", counts.defectCount()); | |||
| return item; | |||
| } | |||
| private Map<String, Object> toInspectionResult(String label, Map<String, Object> taskStats, | |||
| Counts defectStats, | |||
| String stationCode, String stationName) { | |||
| Map<String, Object> result = new LinkedHashMap<>(); | |||
| result.put("algCategory", label); | |||
| if (StringUtils.hasText(stationCode)) { | |||
| result.put("stationCode", stationCode); | |||
| result.put("stationName", stationName != null ? stationName : stationCode); | |||
| } | |||
| result.put("inspectedTaskCount", number(taskStats, "inspected_task_count")); | |||
| result.put("completedTaskCount", number(taskStats, "completed_task_count")); | |||
| result.put("defectCount", defectStats.defectCount()); | |||
| result.put("minorCount", defectStats.minor); | |||
| result.put("generalCount", defectStats.general); | |||
| result.put("seriousCount", defectStats.serious); | |||
| result.put("urgentCount", defectStats.urgent); | |||
| return result; | |||
| } | |||
| private String stationDisplayName(String stationCode) { | |||
| if (!StringUtils.hasText(stationCode)) { | |||
| return null; | |||
| } | |||
| AggStation station = stationMapper.selectByCode(stationCode); | |||
| if (station != null && StringUtils.hasText(station.getStationName())) { | |||
| return station.getStationName(); | |||
| } | |||
| return stationCode; | |||
| } | |||
| private static String normalizeDateType(String dateType) { | |||
| return QUARTER.equalsIgnoreCase(dateType == null ? "" : dateType.trim()) ? QUARTER : MONTH; | |||
| } | |||
| private static YearMonth quarterStart(YearMonth month) { | |||
| int quarterMonth = ((month.getMonthValue() - 1) / 3) * 3 + 1; | |||
| return YearMonth.of(month.getYear(), quarterMonth); | |||
| } | |||
| private static long number(Map<String, Object> row, String key) { | |||
| if (row == null) { | |||
| return 0L; | |||
| } | |||
| Object value = row.get(key); | |||
| if (value == null) { | |||
| value = row.get(key.toUpperCase()); | |||
| } | |||
| return value instanceof Number ? ((Number) value).longValue() : 0L; | |||
| } | |||
| private static String emptyIfNull(String value) { | |||
| return value == null ? "" : value; | |||
| } | |||
| private static class Counts { | |||
| private int minor; | |||
| private int general; | |||
| private int serious; | |||
| private int urgent; | |||
| private int normal; | |||
| private void add(AggAnalysisDefectMonth row) { | |||
| if (row == null) { | |||
| return; | |||
| } | |||
| minor += nz(row.getMinorCount()); | |||
| general += nz(row.getGeneralCount()); | |||
| serious += nz(row.getSeriousCount()); | |||
| urgent += nz(row.getUrgentCount()); | |||
| normal += nz(row.getNormalCount()); | |||
| } | |||
| private void add(Counts other) { | |||
| if (other == null) { | |||
| return; | |||
| } | |||
| minor += other.minor; | |||
| general += other.general; | |||
| serious += other.serious; | |||
| urgent += other.urgent; | |||
| normal += other.normal; | |||
| } | |||
| private long defectCount() { | |||
| return minor + general + serious + urgent; | |||
| } | |||
| private static int nz(Integer value) { | |||
| return value == null ? 0 : value; | |||
| } | |||
| } | |||
| } | |||
| @ -0,0 +1,99 @@ | |||
| package com.inspect.agg.service; | |||
| import java.time.LocalDateTime; | |||
| import java.util.ArrayList; | |||
| import java.util.List; | |||
| import org.springframework.stereotype.Service; | |||
| import com.fasterxml.jackson.databind.JsonNode; | |||
| import com.inspect.agg.config.AggProperties; | |||
| import com.inspect.agg.entity.AggBasedataArea; | |||
| import com.inspect.agg.entity.AggStation; | |||
| import com.inspect.agg.mapper.AggBasedataAreaMapper; | |||
| import com.inspect.agg.station.StationApiClient; | |||
| import com.inspect.agg.support.Rows; | |||
| import com.inspect.agg.support.SyncLogWriter; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * 区域同步服务(基础数据全量:源 basedata_area)。站点配置来自 DB(StationRegistry)。 | |||
| */ | |||
| @Slf4j | |||
| @Service | |||
| @RequiredArgsConstructor | |||
| public class AreaSyncService { | |||
| private final StationRegistry stationRegistry; | |||
| private final StationApiClient stationApiClient; | |||
| private final AggBasedataAreaMapper aggAreaMapper; | |||
| private final SyncLogWriter syncLogWriter; | |||
| private final AggProperties props; | |||
| public void syncAllStations() { | |||
| for (AggStation station : stationRegistry.listEnabled()) { | |||
| syncStation(station); | |||
| } | |||
| } | |||
| public void syncStation(AggStation station) { | |||
| LocalDateTime started = LocalDateTime.now(); | |||
| String status = "SUCCESS"; | |||
| int upserted = 0; | |||
| int deletedMarked = 0; | |||
| String message = null; | |||
| try { | |||
| List<JsonNode> rows = stationApiClient.fetchAllAreas(station, | |||
| props.getSync().getBase().getPageSize(), 500); | |||
| List<Long> ids = new ArrayList<>(); | |||
| for (JsonNode row : rows) { | |||
| AggBasedataArea area = map(row, station); | |||
| if (area == null) { | |||
| continue; | |||
| } | |||
| aggAreaMapper.upsert(area); | |||
| if (area.getAreaId() != null) { | |||
| ids.add(area.getAreaId()); | |||
| } | |||
| upserted++; | |||
| } | |||
| deletedMarked = aggAreaMapper.markDeletedExcept(station.getStationCode(), ids); | |||
| message = "rows=" + rows.size(); | |||
| log.info("station={} area sync ok: upserted={}, deletedMarked={}", station.getStationCode(), upserted, deletedMarked); | |||
| } catch (Exception e) { | |||
| status = "FAILED"; | |||
| message = Rows.abbrev(e.getMessage(), 480); | |||
| log.error("station={} area sync failed: {}", station.getStationCode(), e.getMessage()); | |||
| } | |||
| syncLogWriter.write(station.getStationCode(), "area", "FULL", status, upserted, deletedMarked, | |||
| message, started, LocalDateTime.now()); | |||
| } | |||
| private AggBasedataArea map(JsonNode r, AggStation station) { | |||
| Long areaId = Rows.longVal(r, "areaId", "area_id"); | |||
| if (areaId == null) { | |||
| return null; | |||
| } | |||
| AggBasedataArea a = new AggBasedataArea(); | |||
| a.setStationCode(station.getStationCode()); | |||
| a.setAreaId(areaId); | |||
| Long parentId = Rows.longVal(r, "parentId", "parent_id", "pid"); | |||
| a.setParentId(parentId == null ? 0L : parentId); | |||
| a.setAncestors(Rows.text(r, "ancestors")); | |||
| a.setAreaName(Rows.text(r, "areaName", "area_name")); | |||
| a.setAreaCode(Rows.text(r, "areaCode", "area_code")); | |||
| a.setAreaAddr(Rows.text(r, "areaAddr", "area_addr")); | |||
| a.setAreaType(Rows.text(r, "areaType", "area_type")); | |||
| a.setDeviceTypeCode(Rows.text(r, "deviceTypeCode", "device_type_code")); | |||
| Integer orderNum = Rows.intVal(r, "orderNum", "order_num"); | |||
| a.setOrderNum(orderNum == null ? 0 : orderNum); | |||
| a.setStatus(Rows.text(r, "status")); | |||
| a.setRemark(Rows.text(r, "remark")); | |||
| a.setUpdateTime(Rows.date(r, "updateTime", "update_time")); | |||
| a.setSyncedAt(LocalDateTime.now()); | |||
| a.setIsDeleted(0); | |||
| return a; | |||
| } | |||
| } | |||
| @ -0,0 +1,100 @@ | |||
| package com.inspect.agg.service; | |||
| import java.time.LocalDateTime; | |||
| import java.util.ArrayList; | |||
| import java.util.List; | |||
| import org.springframework.stereotype.Service; | |||
| import com.fasterxml.jackson.databind.JsonNode; | |||
| import com.inspect.agg.config.AggProperties; | |||
| import com.inspect.agg.entity.AggBasedataEqpbookChannel; | |||
| import com.inspect.agg.entity.AggStation; | |||
| import com.inspect.agg.mapper.AggBasedataEqpbookChannelMapper; | |||
| import com.inspect.agg.station.StationApiClient; | |||
| import com.inspect.agg.support.Rows; | |||
| import com.inspect.agg.support.SyncLogWriter; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * 设备通道同步服务(基础数据全量:源 basedata_eqpbook_channel)。 | |||
| */ | |||
| @Slf4j | |||
| @Service | |||
| @RequiredArgsConstructor | |||
| public class ChannelSyncService { | |||
| private final StationRegistry stationRegistry; | |||
| private final StationApiClient stationApiClient; | |||
| private final AggBasedataEqpbookChannelMapper aggChannelMapper; | |||
| private final SyncLogWriter syncLogWriter; | |||
| private final AggProperties props; | |||
| public void syncAllStations() { | |||
| for (AggStation station : stationRegistry.listEnabled()) { | |||
| syncStation(station); | |||
| } | |||
| } | |||
| public void syncStation(AggStation station) { | |||
| LocalDateTime started = LocalDateTime.now(); | |||
| String status = "SUCCESS"; | |||
| int upserted = 0; | |||
| int deletedMarked = 0; | |||
| String message = null; | |||
| try { | |||
| List<JsonNode> rows = stationApiClient.fetchAllChannels(station, | |||
| props.getSync().getBase().getPageSize(), 100); | |||
| List<Long> ids = new ArrayList<>(); | |||
| for (JsonNode row : rows) { | |||
| AggBasedataEqpbookChannel c = map(row, station); | |||
| if (c == null) { | |||
| continue; | |||
| } | |||
| aggChannelMapper.upsert(c); | |||
| if (c.getChannelId() != null) { | |||
| ids.add(c.getChannelId()); | |||
| } | |||
| upserted++; | |||
| } | |||
| deletedMarked = aggChannelMapper.markDeletedExcept(station.getStationCode(), ids); | |||
| message = "rows=" + rows.size(); | |||
| log.info("station={} channel sync ok: upserted={}, deletedMarked={}", station.getStationCode(), upserted, deletedMarked); | |||
| } catch (Exception e) { | |||
| status = "FAILED"; | |||
| message = Rows.abbrev(e.getMessage(), 480); | |||
| log.error("station={} channel sync failed: {}", station.getStationCode(), e.getMessage()); | |||
| } | |||
| syncLogWriter.write(station.getStationCode(), "channel", "FULL", status, upserted, deletedMarked, | |||
| message, started, LocalDateTime.now()); | |||
| } | |||
| private AggBasedataEqpbookChannel map(JsonNode r, AggStation station) { | |||
| Long id = Rows.longVal(r, "channelId"); | |||
| if (id == null) { | |||
| return null; | |||
| } | |||
| AggBasedataEqpbookChannel c = new AggBasedataEqpbookChannel(); | |||
| c.setStationCode(station.getStationCode()); | |||
| c.setChannelId(id); | |||
| c.setChannelCode(Rows.text(r, "channelCode")); | |||
| c.setChannelName(Rows.text(r, "channelName")); | |||
| c.setChannelType(Rows.text(r, "channelType")); | |||
| c.setOnlineState(Rows.text(r, "onlineState")); | |||
| c.setChannelContent(Rows.text(r, "channelContent")); | |||
| c.setHost(Rows.text(r, "host")); | |||
| c.setPort(Rows.text(r, "port")); | |||
| c.setAreaId(Rows.longVal(r, "areaId")); | |||
| c.setAreaName(Rows.text(r, "areaName")); | |||
| c.setEqpbookId(Rows.longVal(r, "eqpbookId")); | |||
| c.setPatrolDeviceCode(Rows.text(r, "patroldeviceCode", "patrolDeviceCode")); | |||
| c.setUpSystemDeviceCode(Rows.text(r, "upSystemDeviceCode")); | |||
| c.setDeviceSource(Rows.text(r, "deviceSource")); | |||
| c.setUpdateTime(Rows.date(r, "updateTime")); | |||
| c.setSyncedAt(LocalDateTime.now()); | |||
| c.setIsDeleted(0); | |||
| return c; | |||
| } | |||
| } | |||
| @ -0,0 +1,185 @@ | |||
| package com.inspect.agg.service; | |||
| import java.time.LocalDate; | |||
| import java.time.LocalDateTime; | |||
| import java.util.HashMap; | |||
| import java.util.HashSet; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| import java.util.Set; | |||
| import java.util.stream.Collectors; | |||
| import org.springframework.stereotype.Service; | |||
| import com.fasterxml.jackson.databind.JsonNode; | |||
| import com.inspect.agg.config.AggProperties; | |||
| import com.inspect.agg.entity.AggDefectRecord; | |||
| import com.inspect.agg.entity.AggStation; | |||
| import com.inspect.agg.mapper.AggDefectRecordMapper; | |||
| import com.inspect.agg.station.StationApiClient; | |||
| import com.inspect.agg.support.Rows; | |||
| import com.inspect.agg.support.SyncLogWriter; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * Syncs merged defect-record rows from station-side /patrol/analysis/defect/list. | |||
| */ | |||
| @Slf4j | |||
| @Service | |||
| @RequiredArgsConstructor | |||
| public class DefectRecordSyncService { | |||
| private static final String PATH = "patrol/analysis/defect/list"; | |||
| private final StationRegistry stationRegistry; | |||
| private final StationApiClient stationApiClient; | |||
| private final AggDefectRecordMapper defectRecordMapper; | |||
| private final SyncLogWriter syncLogWriter; | |||
| private final AggProperties props; | |||
| public void syncFullAllStations() { | |||
| stationRegistry.listEnabled().parallelStream().forEach(this::syncFull); | |||
| } | |||
| public void syncRecentAllStations() { | |||
| stationRegistry.listEnabled().parallelStream().forEach(this::syncRecent); | |||
| } | |||
| public void syncFull(AggStation station) { | |||
| LocalDateTime started = LocalDateTime.now(); | |||
| String status = "SUCCESS"; | |||
| int upserted = 0; | |||
| int deletedMarked = 0; | |||
| String message = null; | |||
| try { | |||
| List<AggDefectRecord> existing = defectRecordMapper.selectActiveKeys(station.getStationCode()); | |||
| Map<String, Long> existingIds = existing.stream() | |||
| .collect(Collectors.toMap(this::naturalKey, AggDefectRecord::getId, (oldValue, ignored) -> oldValue)); | |||
| List<JsonNode> rows = stationApiClient.fetchAllRows(station, PATH, | |||
| props.getSync().getBase().getPageSize(), 1000, "lineId"); | |||
| Set<String> presentKeys = new HashSet<>(); | |||
| for (JsonNode row : rows) { | |||
| AggDefectRecord record = map(row, station); | |||
| if (record == null) { | |||
| continue; | |||
| } | |||
| defectRecordMapper.upsert(record); | |||
| presentKeys.add(naturalKey(record)); | |||
| upserted++; | |||
| } | |||
| List<Long> missingIds = existingIds.entrySet().stream() | |||
| .filter(entry -> !presentKeys.contains(entry.getKey())) | |||
| .map(Map.Entry::getValue) | |||
| .collect(Collectors.toList()); | |||
| if (!missingIds.isEmpty()) { | |||
| deletedMarked = defectRecordMapper.markDeletedByIds(station.getStationCode(), missingIds); | |||
| } | |||
| message = "rows=" + rows.size() + ", deletedMarked=" + deletedMarked; | |||
| log.info("station={} defect record FULL ok: upserted={}, deletedMarked={}", | |||
| station.getStationCode(), upserted, deletedMarked); | |||
| } catch (Exception e) { | |||
| status = "FAILED"; | |||
| message = Rows.abbrev(e.getMessage(), 480); | |||
| log.error("station={} defect record FULL failed: {}", station.getStationCode(), e.getMessage()); | |||
| } | |||
| syncLogWriter.write(station.getStationCode(), "defect", "FULL", status, upserted, deletedMarked, | |||
| message, started, LocalDateTime.now()); | |||
| } | |||
| public void syncRecent(AggStation station) { | |||
| LocalDateTime started = LocalDateTime.now(); | |||
| String status = "SUCCESS"; | |||
| int upserted = 0; | |||
| String message = null; | |||
| try { | |||
| Map<String, String> query = new HashMap<>(); | |||
| query.put("patrolTimeBegin", LocalDate.now().minusDays(7).toString()); | |||
| query.put("patrolTimeEnd", LocalDate.now().toString()); | |||
| List<JsonNode> rows = stationApiClient.fetchAllRows(station, PATH, | |||
| props.getSync().getBase().getPageSize(), 200, query, "lineId"); | |||
| for (JsonNode row : rows) { | |||
| AggDefectRecord record = map(row, station); | |||
| if (record != null) { | |||
| defectRecordMapper.upsert(record); | |||
| upserted++; | |||
| } | |||
| } | |||
| message = "rows=" + rows.size(); | |||
| log.info("station={} defect record RECENT ok: upserted={}", station.getStationCode(), upserted); | |||
| } catch (Exception e) { | |||
| status = "FAILED"; | |||
| message = Rows.abbrev(e.getMessage(), 480); | |||
| log.error("station={} defect record RECENT failed: {}", station.getStationCode(), e.getMessage()); | |||
| } | |||
| syncLogWriter.write(station.getStationCode(), "defect", "RECENT", status, upserted, 0, | |||
| message, started, LocalDateTime.now()); | |||
| } | |||
| private AggDefectRecord map(JsonNode r, AggStation station) { | |||
| String taskPatrolId = text(r, "taskPatrolId"); | |||
| String objectId = text(r, "objectId"); | |||
| String algType = text(r, "algType"); | |||
| if (isBlank(taskPatrolId) || isBlank(objectId) || isBlank(algType)) { | |||
| return null; | |||
| } | |||
| AggDefectRecord e = new AggDefectRecord(); | |||
| e.setStationCode(station.getStationCode()); | |||
| e.setDeviceName(text(r, "deviceName")); | |||
| e.setChannelName(text(r, "channelName")); | |||
| e.setChannelCode(text(r, "channelCode")); | |||
| e.setDeviceSource(text(r, "deviceSource")); | |||
| e.setPointName(text(r, "pointName")); | |||
| e.setPointId(text(r, "pointId")); | |||
| e.setWarnStatus(text(r, "warnStatus")); | |||
| e.setAlgName(text(r, "algName")); | |||
| e.setResValue(text(r, "resValue")); | |||
| e.setImg(text(r, "img")); | |||
| e.setFilterDesc(text(r, "filterDesc")); | |||
| e.setBrightDesc(text(r, "brightDesc")); | |||
| e.setImgAnalyse(text(r, "imgAnalyse")); | |||
| e.setBrightImgAnalyse(text(r, "brightImgAnalyse")); | |||
| e.setDesc(text(r, "desc")); | |||
| e.setLineId(text(r, "lineId")); | |||
| e.setPatrolTime(Rows.date(r, "patrolTime")); | |||
| e.setPointStatus(text(r, "pointStatus")); | |||
| e.setImgType(text(r, "imgType")); | |||
| e.setDataType(text(r, "dataType")); | |||
| e.setFilter(text(r, "filter")); | |||
| e.setRequestId(text(r, "requestId")); | |||
| e.setResStatus(text(r, "resStatus")); | |||
| e.setBrightResStatus(text(r, "brightResStatus")); | |||
| e.setAreaName(text(r, "areaName")); | |||
| e.setAreaId(text(r, "areaId")); | |||
| e.setAlgType(algType); | |||
| e.setObjectId(objectId); | |||
| e.setImageNormalUrlPath(text(r, "imageNormalUrlPath")); | |||
| e.setFilePath(text(r, "filePath")); | |||
| e.setSuggestion(text(r, "suggestion")); | |||
| e.setOriginalValue(text(r, "originalValue")); | |||
| e.setTaskPatrolId(taskPatrolId); | |||
| e.setTaskName(text(r, "taskName")); | |||
| Integer defectLevel = Rows.intVal(r, "defectLevel"); | |||
| e.setDefectLevel(defectLevel == null ? 0 : defectLevel); | |||
| e.setSyncedAt(LocalDateTime.now()); | |||
| e.setIsDeleted(0); | |||
| return e; | |||
| } | |||
| private String naturalKey(AggDefectRecord r) { | |||
| return r.getStationCode() + "|" + r.getTaskPatrolId() + "|" + r.getObjectId() + "|" + r.getAlgType(); | |||
| } | |||
| private static String text(JsonNode node, String key) { | |||
| JsonNode value = node.get(key); | |||
| if (value == null || value.isNull()) { | |||
| return null; | |||
| } | |||
| return value.asText(); | |||
| } | |||
| private static boolean isBlank(String value) { | |||
| return value == null || value.trim().isEmpty(); | |||
| } | |||
| } | |||
| @ -0,0 +1,103 @@ | |||
| package com.inspect.agg.service; | |||
| import java.time.LocalDateTime; | |||
| import java.util.ArrayList; | |||
| import java.util.List; | |||
| import org.springframework.stereotype.Service; | |||
| import com.fasterxml.jackson.databind.JsonNode; | |||
| import com.inspect.agg.config.AggProperties; | |||
| import com.inspect.agg.entity.AggBasedataDevice; | |||
| import com.inspect.agg.entity.AggStation; | |||
| import com.inspect.agg.mapper.AggBasedataDeviceMapper; | |||
| import com.inspect.agg.station.StationApiClient; | |||
| import com.inspect.agg.support.Rows; | |||
| import com.inspect.agg.support.SyncLogWriter; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * 设备同步服务(基础数据全量:源 basedata_device)。 | |||
| */ | |||
| @Slf4j | |||
| @Service | |||
| @RequiredArgsConstructor | |||
| public class DeviceSyncService { | |||
| private final StationRegistry stationRegistry; | |||
| private final StationApiClient stationApiClient; | |||
| private final AggBasedataDeviceMapper aggDeviceMapper; | |||
| private final SyncLogWriter syncLogWriter; | |||
| private final AggProperties props; | |||
| public void syncAllStations() { | |||
| for (AggStation station : stationRegistry.listEnabled()) { | |||
| syncStation(station); | |||
| } | |||
| } | |||
| public void syncStation(AggStation station) { | |||
| LocalDateTime started = LocalDateTime.now(); | |||
| String status = "SUCCESS"; | |||
| int upserted = 0; | |||
| int deletedMarked = 0; | |||
| String message = null; | |||
| try { | |||
| List<JsonNode> rows = stationApiClient.fetchAllDevices(station, | |||
| props.getSync().getBase().getPageSize(), 500); | |||
| List<Long> ids = new ArrayList<>(); | |||
| for (JsonNode row : rows) { | |||
| AggBasedataDevice d = map(row, station); | |||
| if (d == null) { | |||
| continue; | |||
| } | |||
| aggDeviceMapper.upsert(d); | |||
| if (d.getDeviceId() != null) { | |||
| ids.add(d.getDeviceId()); | |||
| } | |||
| upserted++; | |||
| } | |||
| deletedMarked = aggDeviceMapper.markDeletedExcept(station.getStationCode(), ids); | |||
| message = "rows=" + rows.size(); | |||
| log.info("station={} device sync ok: upserted={}, deletedMarked={}", station.getStationCode(), upserted, deletedMarked); | |||
| } catch (Exception e) { | |||
| status = "FAILED"; | |||
| message = Rows.abbrev(e.getMessage(), 480); | |||
| log.error("station={} device sync failed: {}", station.getStationCode(), e.getMessage()); | |||
| } | |||
| syncLogWriter.write(station.getStationCode(), "device", "FULL", status, upserted, deletedMarked, | |||
| message, started, LocalDateTime.now()); | |||
| } | |||
| private AggBasedataDevice map(JsonNode r, AggStation station) { | |||
| Long deviceId = Rows.longVal(r, "deviceId"); | |||
| if (deviceId == null) { | |||
| return null; | |||
| } | |||
| AggBasedataDevice d = new AggBasedataDevice(); | |||
| d.setStationCode(station.getStationCode()); | |||
| d.setDeviceId(deviceId); | |||
| Long parentId = Rows.longVal(r, "parentId"); | |||
| d.setParentId(parentId == null ? 0L : parentId); | |||
| d.setAncestors(Rows.text(r, "ancestors")); | |||
| d.setAreaId(Rows.longVal(r, "areaId")); | |||
| d.setAreaName(Rows.text(r, "areaName")); | |||
| d.setDeviceCode(Rows.text(r, "deviceCode")); | |||
| d.setDeviceName(Rows.text(r, "deviceName")); | |||
| d.setDeviceTypeName(Rows.text(r, "deviceTypeName")); | |||
| d.setDeviceModel(Rows.text(r, "deviceModel")); | |||
| d.setManufacturer(Rows.text(r, "manufacturer")); | |||
| d.setPowerMainEquFlag(Rows.text(r, "powerMainEquFlag")); | |||
| d.setStationNo(Rows.text(r, "stationNo")); | |||
| d.setMonitorIndexNo(Rows.text(r, "monitorIndexNo")); | |||
| d.setStatus(Rows.text(r, "status")); | |||
| Integer orderNum = Rows.intVal(r, "orderNum"); | |||
| d.setOrderNum(orderNum == null ? 0 : orderNum); | |||
| d.setUpdateTime(Rows.date(r, "updateTime")); | |||
| d.setSyncedAt(LocalDateTime.now()); | |||
| d.setIsDeleted(0); | |||
| return d; | |||
| } | |||
| } | |||
| @ -0,0 +1,102 @@ | |||
| package com.inspect.agg.service; | |||
| import java.time.LocalDateTime; | |||
| import java.util.ArrayList; | |||
| import java.util.List; | |||
| import org.springframework.stereotype.Service; | |||
| import com.fasterxml.jackson.databind.JsonNode; | |||
| import com.inspect.agg.config.AggProperties; | |||
| import com.inspect.agg.entity.AggBasedataDevicetype; | |||
| import com.inspect.agg.entity.AggStation; | |||
| import com.inspect.agg.mapper.AggBasedataDevicetypeMapper; | |||
| import com.inspect.agg.station.StationApiClient; | |||
| import com.inspect.agg.support.Rows; | |||
| import com.inspect.agg.support.SyncLogWriter; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * 设备类型同步服务(基础数据全量:源 basedata_devicetype)。 | |||
| */ | |||
| @Slf4j | |||
| @Service | |||
| @RequiredArgsConstructor | |||
| public class DevicetypeSyncService { | |||
| private final StationRegistry stationRegistry; | |||
| private final StationApiClient stationApiClient; | |||
| private final AggBasedataDevicetypeMapper aggDevicetypeMapper; | |||
| private final SyncLogWriter syncLogWriter; | |||
| private final AggProperties props; | |||
| public void syncAllStations() { | |||
| for (AggStation station : stationRegistry.listEnabled()) { | |||
| syncStation(station); | |||
| } | |||
| } | |||
| public void syncStation(AggStation station) { | |||
| LocalDateTime started = LocalDateTime.now(); | |||
| String status = "SUCCESS"; | |||
| int upserted = 0; | |||
| int deletedMarked = 0; | |||
| String message = null; | |||
| try { | |||
| List<JsonNode> rows = stationApiClient.fetchAllDeviceTypes(station, | |||
| props.getSync().getBase().getPageSize(), 100); | |||
| List<Long> ids = new ArrayList<>(); | |||
| for (JsonNode row : rows) { | |||
| AggBasedataDevicetype d = map(row, station); | |||
| if (d == null) { | |||
| continue; | |||
| } | |||
| aggDevicetypeMapper.upsert(d); | |||
| if (d.getDeviceTypeId() != null) { | |||
| ids.add(d.getDeviceTypeId()); | |||
| } | |||
| upserted++; | |||
| } | |||
| deletedMarked = aggDevicetypeMapper.markDeletedExcept(station.getStationCode(), ids); | |||
| message = "rows=" + rows.size(); | |||
| log.info("station={} devicetype sync ok: upserted={}, deletedMarked={}", station.getStationCode(), upserted, deletedMarked); | |||
| } catch (Exception e) { | |||
| status = "FAILED"; | |||
| message = Rows.abbrev(e.getMessage(), 480); | |||
| log.error("station={} devicetype sync failed: {}", station.getStationCode(), e.getMessage()); | |||
| } | |||
| syncLogWriter.write(station.getStationCode(), "devicetype", "FULL", status, upserted, deletedMarked, | |||
| message, started, LocalDateTime.now()); | |||
| } | |||
| private AggBasedataDevicetype map(JsonNode r, AggStation station) { | |||
| Long id = Rows.longVal(r, "devicetypeId"); | |||
| if (id == null) { | |||
| return null; | |||
| } | |||
| AggBasedataDevicetype d = new AggBasedataDevicetype(); | |||
| d.setStationCode(station.getStationCode()); | |||
| d.setDeviceTypeId(id); | |||
| Long parentId = Rows.longVal(r, "parentId"); | |||
| d.setParentId(parentId == null ? 0L : parentId); | |||
| Integer orderNum = Rows.intVal(r, "orderNum"); | |||
| d.setOrderNum(orderNum == null ? 0 : orderNum); | |||
| d.setAncestors(Rows.text(r, "ancestors")); | |||
| d.setAncestorsAddr(Rows.text(r, "ancestorsAddr")); | |||
| d.setDeviceTypeName(Rows.text(r, "devicetypeName", "deviceTypeName")); | |||
| d.setDeviceTypeCode(Rows.text(r, "devicetypeCode", "deviceTypeCode")); | |||
| d.setPowerMainEquFlag(Rows.text(r, "powerMainEquFlag")); | |||
| d.setPhaseNum(Rows.text(r, "phaseNum")); | |||
| d.setPhase(Rows.text(r, "phase")); | |||
| d.setStatus(Rows.text(r, "status")); | |||
| d.setProfessionType(Rows.text(r, "professionType")); | |||
| Long level = Rows.longVal(r, "level"); | |||
| d.setLevel(level == null ? 0L : level); | |||
| d.setUpdateTime(Rows.date(r, "updateTime")); | |||
| d.setSyncedAt(LocalDateTime.now()); | |||
| d.setIsDeleted(0); | |||
| return d; | |||
| } | |||
| } | |||
| @ -0,0 +1,103 @@ | |||
| package com.inspect.agg.service; | |||
| import java.time.LocalDateTime; | |||
| import java.util.ArrayList; | |||
| import java.util.List; | |||
| import org.springframework.stereotype.Service; | |||
| import com.fasterxml.jackson.databind.JsonNode; | |||
| import com.inspect.agg.config.AggProperties; | |||
| import com.inspect.agg.entity.AggBasedataEqpbook; | |||
| import com.inspect.agg.entity.AggStation; | |||
| import com.inspect.agg.mapper.AggBasedataEqpbookMapper; | |||
| import com.inspect.agg.station.StationApiClient; | |||
| import com.inspect.agg.support.Rows; | |||
| import com.inspect.agg.support.SyncLogWriter; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * 设备台账同步服务(基础数据全量:源 basedata_eqpbook)。 | |||
| */ | |||
| @Slf4j | |||
| @Service | |||
| @RequiredArgsConstructor | |||
| public class EqpbookSyncService { | |||
| private final StationRegistry stationRegistry; | |||
| private final StationApiClient stationApiClient; | |||
| private final AggBasedataEqpbookMapper aggEqpbookMapper; | |||
| private final SyncLogWriter syncLogWriter; | |||
| private final AggProperties props; | |||
| public void syncAllStations() { | |||
| for (AggStation station : stationRegistry.listEnabled()) { | |||
| syncStation(station); | |||
| } | |||
| } | |||
| public void syncStation(AggStation station) { | |||
| LocalDateTime started = LocalDateTime.now(); | |||
| String status = "SUCCESS"; | |||
| int upserted = 0; | |||
| int deletedMarked = 0; | |||
| String message = null; | |||
| try { | |||
| List<JsonNode> rows = stationApiClient.fetchAllEqpbooks(station, | |||
| props.getSync().getBase().getPageSize(), 100); | |||
| List<Long> ids = new ArrayList<>(); | |||
| for (JsonNode row : rows) { | |||
| AggBasedataEqpbook e = map(row, station); | |||
| if (e == null) { | |||
| continue; | |||
| } | |||
| aggEqpbookMapper.upsert(e); | |||
| if (e.getEqpbookId() != null) { | |||
| ids.add(e.getEqpbookId()); | |||
| } | |||
| upserted++; | |||
| } | |||
| deletedMarked = aggEqpbookMapper.markDeletedExcept(station.getStationCode(), ids); | |||
| message = "rows=" + rows.size(); | |||
| log.info("station={} eqpbook sync ok: upserted={}, deletedMarked={}", station.getStationCode(), upserted, deletedMarked); | |||
| } catch (Exception e) { | |||
| status = "FAILED"; | |||
| message = Rows.abbrev(e.getMessage(), 480); | |||
| log.error("station={} eqpbook sync failed: {}", station.getStationCode(), e.getMessage()); | |||
| } | |||
| syncLogWriter.write(station.getStationCode(), "eqpbook", "FULL", status, upserted, deletedMarked, | |||
| message, started, LocalDateTime.now()); | |||
| } | |||
| private AggBasedataEqpbook map(JsonNode r, AggStation station) { | |||
| Long id = Rows.longVal(r, "eqpbookId"); | |||
| if (id == null) { | |||
| return null; | |||
| } | |||
| AggBasedataEqpbook e = new AggBasedataEqpbook(); | |||
| e.setStationCode(station.getStationCode()); | |||
| e.setEqpbookId(id); | |||
| e.setPatrolDeviceCode(Rows.text(r, "patroldeviceCode", "patrolDeviceCode")); | |||
| e.setPatrolDeviceName(Rows.text(r, "patroldeviceName", "patrolDeviceName")); | |||
| e.setStationName(Rows.text(r, "stationName")); | |||
| e.setAreaId(Rows.longVal(r, "areaId")); | |||
| e.setAreaName(Rows.text(r, "areaName")); | |||
| e.setDeviceSource(Rows.text(r, "deviceSource")); | |||
| e.setDeviceModel(Rows.text(r, "deviceModel")); | |||
| e.setManufacturer(Rows.text(r, "manufacturer")); | |||
| e.setDeviceType(Rows.text(r, "type")); | |||
| e.setIpAddr(Rows.text(r, "ipAddr")); | |||
| e.setPort(Rows.text(r, "port")); | |||
| e.setVideoNvrCode(Rows.text(r, "videoNvrCode")); | |||
| e.setMainSystemCode(Rows.text(r, "mainSystemCode")); | |||
| e.setUseMode(Rows.text(r, "useMode")); | |||
| e.setVideoMode(Rows.text(r, "videoMode")); | |||
| e.setUpdateTime(Rows.date(r, "updateTime")); | |||
| e.setSyncedAt(LocalDateTime.now()); | |||
| e.setIsDeleted(0); | |||
| return e; | |||
| } | |||
| } | |||
| @ -0,0 +1,217 @@ | |||
| package com.inspect.agg.service; | |||
| import java.util.ArrayList; | |||
| import java.util.HashMap; | |||
| import java.util.HashSet; | |||
| import java.util.LinkedHashMap; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| import java.util.Set; | |||
| import org.springframework.stereotype.Service; | |||
| import org.springframework.util.StringUtils; | |||
| import com.inspect.agg.entity.AggBasedataArea; | |||
| import com.inspect.agg.entity.AggBasedataEqpbook; | |||
| import com.inspect.agg.entity.AggBasedataEqpbookChannel; | |||
| import com.inspect.agg.mapper.AggBasedataAreaMapper; | |||
| import com.inspect.agg.mapper.AggBasedataEqpbookChannelMapper; | |||
| import com.inspect.agg.mapper.AggBasedataEqpbookMapper; | |||
| import lombok.RequiredArgsConstructor; | |||
| /** | |||
| * 使用汇聚区域、台账和通道数据构建通道树。 | |||
| */ | |||
| @Service | |||
| @RequiredArgsConstructor | |||
| public class EqpbookTreeService { | |||
| private final AggBasedataAreaMapper areaMapper; | |||
| private final AggBasedataEqpbookMapper eqpbookMapper; | |||
| private final AggBasedataEqpbookChannelMapper channelMapper; | |||
| public List<Map<String, Object>> buildFilterTree(String stationCode, | |||
| String patrolDeviceName, | |||
| String onlineStatus, | |||
| String patrolDeviceTypeFlag) { | |||
| List<AggBasedataArea> areas = areaMapper.listForTree(stationCode); | |||
| List<AggBasedataEqpbook> eqpbooks = eqpbookMapper.list(stationCode, 0); | |||
| List<AggBasedataEqpbookChannel> channels = channelMapper.list(stationCode, 0); | |||
| Map<String, AggBasedataArea> areaIndex = new HashMap<>(); | |||
| for (AggBasedataArea area : areas) { | |||
| areaIndex.put(areaKey(area.getStationCode(), area.getAreaId()), area); | |||
| } | |||
| Map<String, List<AggBasedataEqpbookChannel>> channelIndex = new HashMap<>(); | |||
| for (AggBasedataEqpbookChannel channel : channels) { | |||
| channelIndex.computeIfAbsent(eqpbookKey(channel.getStationCode(), channel.getEqpbookId()), | |||
| key -> new ArrayList<>()).add(channel); | |||
| } | |||
| List<AggBasedataEqpbook> selectedEqpbooks = new ArrayList<>(); | |||
| Set<String> selectedAreaKeys = new HashSet<>(); | |||
| for (AggBasedataEqpbook eqpbook : eqpbooks) { | |||
| if (!matchesName(eqpbook, patrolDeviceName) || !matchesType(eqpbook, patrolDeviceTypeFlag)) { | |||
| continue; | |||
| } | |||
| List<AggBasedataEqpbookChannel> eqpbookChannels = | |||
| channelIndex.getOrDefault(eqpbookKey(eqpbook.getStationCode(), eqpbook.getEqpbookId()), | |||
| new ArrayList<AggBasedataEqpbookChannel>()); | |||
| if (eqpbookChannels.isEmpty()) { | |||
| continue; | |||
| } | |||
| boolean online = false; | |||
| for (AggBasedataEqpbookChannel channel : eqpbookChannels) { | |||
| if ("1".equals(channel.getOnlineState())) { | |||
| online = true; | |||
| break; | |||
| } | |||
| } | |||
| if ("1".equals(onlineStatus) && !online) continue; | |||
| if ("0".equals(onlineStatus) && online) continue; | |||
| AggBasedataArea area = areaIndex.get(areaKey(eqpbook.getStationCode(), eqpbook.getAreaId())); | |||
| if (area == null) { | |||
| continue; | |||
| } | |||
| selectedEqpbooks.add(eqpbook); | |||
| collectAreaKeys(area, areaIndex, selectedAreaKeys); | |||
| } | |||
| Map<String, Map<String, Object>> areaNodes = new LinkedHashMap<>(); | |||
| for (String key : selectedAreaKeys) { | |||
| AggBasedataArea area = areaIndex.get(key); | |||
| if (area == null) continue; | |||
| Map<String, Object> node = new LinkedHashMap<>(); | |||
| node.put("id", key); | |||
| String parentKey = area.getParentId() == null ? null : areaKey(area.getStationCode(), area.getParentId()); | |||
| node.put("parentId", selectedAreaKeys.contains(parentKey) ? parentKey : null); | |||
| node.put("label", area.getAreaName()); | |||
| node.put("areaId", area.getAreaId()); | |||
| node.put("areaName", area.getAreaName()); | |||
| node.put("areaFlag", "Yes"); | |||
| node.put("areaType", area.getAreaType()); | |||
| node.put("stationCode", area.getStationCode()); | |||
| node.put("stationName", area.getStationName() != null ? area.getStationName() : area.getStationCode()); | |||
| node.put("children", new ArrayList<Map<String, Object>>()); | |||
| areaNodes.put(key, node); | |||
| } | |||
| for (AggBasedataEqpbook eqpbook : selectedEqpbooks) { | |||
| String parentKey = areaKey(eqpbook.getStationCode(), eqpbook.getAreaId()); | |||
| Map<String, Object> areaNode = areaNodes.get(parentKey); | |||
| if (areaNode == null) continue; | |||
| List<AggBasedataEqpbookChannel> eqpbookChannels = | |||
| channelIndex.get(eqpbookKey(eqpbook.getStationCode(), eqpbook.getEqpbookId())); | |||
| boolean online = false; | |||
| for (AggBasedataEqpbookChannel channel : eqpbookChannels) { | |||
| if ("1".equals(channel.getOnlineState())) { | |||
| online = true; | |||
| break; | |||
| } | |||
| } | |||
| String eqpbookNodeId = eqpbookKey(eqpbook.getStationCode(), eqpbook.getEqpbookId()); | |||
| Map<String, Object> eqpbookNode = new LinkedHashMap<>(); | |||
| eqpbookNode.put("id", eqpbookNodeId); | |||
| eqpbookNode.put("parentId", parentKey); | |||
| eqpbookNode.put("label", eqpbook.getPatrolDeviceName()); | |||
| eqpbookNode.put("areaId", eqpbook.getAreaId()); | |||
| eqpbookNode.put("areaName", eqpbook.getAreaName()); | |||
| eqpbookNode.put("eqpbookId", eqpbook.getEqpbookId()); | |||
| eqpbookNode.put("patroldeviceFlag", "Yes"); | |||
| eqpbookNode.put("patroldeviceCode", eqpbook.getPatrolDeviceCode()); | |||
| eqpbookNode.put("patroldeviceName", eqpbook.getPatrolDeviceName()); | |||
| eqpbookNode.put("patroldeviceType", eqpbook.getDeviceType()); | |||
| eqpbookNode.put("patroldeviceOnlineStatus", online ? "1" : "0"); | |||
| eqpbookNode.put("type", eqpbook.getDeviceType()); | |||
| eqpbookNode.put("stationCode", eqpbook.getStationCode()); | |||
| eqpbookNode.put("stationName", eqpbook.getStationName() != null ? eqpbook.getStationName() : eqpbook.getStationCode()); | |||
| eqpbookNode.put("children", new ArrayList<Map<String, Object>>()); | |||
| children(areaNode).add(eqpbookNode); | |||
| for (AggBasedataEqpbookChannel channel : eqpbookChannels) { | |||
| Map<String, Object> channelNode = new LinkedHashMap<>(); | |||
| channelNode.put("id", eqpbookNodeId + ":channel:" + channel.getChannelId()); | |||
| channelNode.put("parentId", eqpbookNodeId); | |||
| channelNode.put("label", channel.getChannelName()); | |||
| channelNode.put("areaId", eqpbook.getAreaId()); | |||
| channelNode.put("areaName", eqpbook.getAreaName()); | |||
| channelNode.put("patroldeviceCode", eqpbook.getPatrolDeviceCode()); | |||
| channelNode.put("patroldeviceName", eqpbook.getPatrolDeviceName()); | |||
| channelNode.put("patroldeviceType", eqpbook.getDeviceType()); | |||
| channelNode.put("patroldeviceOnlineStatus", channel.getOnlineState()); | |||
| channelNode.put("channelId", channel.getChannelId()); | |||
| channelNode.put("channelCode", channel.getChannelCode()); | |||
| channelNode.put("channelName", channel.getChannelName()); | |||
| channelNode.put("channelFlag", "Yes"); | |||
| channelNode.put("channelType", channel.getChannelType()); | |||
| channelNode.put("host", channel.getHost()); | |||
| channelNode.put("port", channel.getPort()); | |||
| channelNode.put("stationCode", channel.getStationCode()); | |||
| channelNode.put("stationName", channel.getStationName() != null ? channel.getStationName() : channel.getStationCode()); | |||
| children(eqpbookNode).add(channelNode); | |||
| } | |||
| } | |||
| List<Map<String, Object>> roots = new ArrayList<>(); | |||
| for (Map<String, Object> node : areaNodes.values()) { | |||
| if (node.get("parentId") == null) { | |||
| roots.add(node); | |||
| } else { | |||
| Map<String, Object> parent = areaNodes.get(node.get("parentId")); | |||
| if (parent != null) { | |||
| children(parent).add(node); | |||
| } else { | |||
| roots.add(node); | |||
| } | |||
| } | |||
| } | |||
| return roots; | |||
| } | |||
| @SuppressWarnings("unchecked") | |||
| private static List<Map<String, Object>> children(Map<String, Object> node) { | |||
| return (List<Map<String, Object>>) node.get("children"); | |||
| } | |||
| private static void collectAreaKeys(AggBasedataArea area, | |||
| Map<String, AggBasedataArea> areaIndex, | |||
| Set<String> keys) { | |||
| AggBasedataArea current = area; | |||
| while (current != null) { | |||
| String key = areaKey(current.getStationCode(), current.getAreaId()); | |||
| if (!keys.add(key)) break; | |||
| current = current.getParentId() == null | |||
| ? null | |||
| : areaIndex.get(areaKey(current.getStationCode(), current.getParentId())); | |||
| } | |||
| } | |||
| private static boolean matchesName(AggBasedataEqpbook eqpbook, String value) { | |||
| if (!StringUtils.hasText(value)) return true; | |||
| return value.equals(eqpbook.getPatrolDeviceCode()) | |||
| || (eqpbook.getPatrolDeviceName() != null | |||
| && eqpbook.getPatrolDeviceName().contains(value)); | |||
| } | |||
| private static boolean matchesType(AggBasedataEqpbook eqpbook, String flag) { | |||
| if (!StringUtils.hasText(flag)) return true; | |||
| String type = eqpbook.getDeviceType(); | |||
| if ("nvr".equals(flag)) return "11".equals(type); | |||
| if ("ipc".equals(flag)) return type == null || (!"11".equals(type) && !"20".equals(type)); | |||
| if ("robotAndUav".equals(flag)) return "1".equals(type) || "2".equals(type) | |||
| || "3".equals(type) || "13".equals(type); | |||
| if ("uav".equals(flag)) return "13".equals(type); | |||
| return true; | |||
| } | |||
| private static String areaKey(String stationCode, Long areaId) { | |||
| return stationCode + ":area:" + areaId; | |||
| } | |||
| private static String eqpbookKey(String stationCode, Long eqpbookId) { | |||
| return stationCode + ":eqpbook:" + eqpbookId; | |||
| } | |||
| } | |||
| @ -0,0 +1,104 @@ | |||
| package com.inspect.agg.service; | |||
| import java.time.LocalDateTime; | |||
| import java.util.ArrayList; | |||
| import java.util.List; | |||
| import org.springframework.stereotype.Service; | |||
| import com.fasterxml.jackson.databind.JsonNode; | |||
| import com.inspect.agg.config.AggProperties; | |||
| import com.inspect.agg.entity.AggInspectionReport; | |||
| import com.inspect.agg.entity.AggStation; | |||
| import com.inspect.agg.mapper.AggInspectionReportMapper; | |||
| import com.inspect.agg.station.StationApiClient; | |||
| import com.inspect.agg.support.Rows; | |||
| import com.inspect.agg.support.SyncLogWriter; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * 巡视报告镜像同步。 | |||
| */ | |||
| @Slf4j | |||
| @Service | |||
| @RequiredArgsConstructor | |||
| public class InspectionReportSyncService { | |||
| private final StationRegistry stationRegistry; | |||
| private final StationApiClient stationApiClient; | |||
| private final AggInspectionReportMapper reportMapper; | |||
| private final SyncLogWriter syncLogWriter; | |||
| private final AggProperties props; | |||
| public void syncAllStations() { | |||
| for (AggStation station : stationRegistry.listEnabled()) { | |||
| syncStation(station); | |||
| } | |||
| } | |||
| public void syncStation(AggStation station) { | |||
| LocalDateTime started = LocalDateTime.now(); | |||
| String status = "SUCCESS"; | |||
| int upserted = 0; | |||
| int deletedMarked = 0; | |||
| String message = null; | |||
| try { | |||
| List<JsonNode> rows = stationApiClient.fetchAllInspectionReports( | |||
| station, props.getSync().getBase().getPageSize(), 100); | |||
| List<Long> lineIds = new ArrayList<>(); | |||
| for (JsonNode row : rows) { | |||
| AggInspectionReport report = map(row, station); | |||
| if (report == null) { | |||
| continue; | |||
| } | |||
| reportMapper.upsert(report); | |||
| lineIds.add(report.getLineId()); | |||
| upserted++; | |||
| } | |||
| deletedMarked = reportMapper.markDeletedExcept(station.getStationCode(), lineIds); | |||
| message = "rows=" + rows.size(); | |||
| log.info("station={} inspection_report sync ok: upserted={}, deletedMarked={}", | |||
| station.getStationCode(), upserted, deletedMarked); | |||
| } catch (Exception e) { | |||
| status = "FAILED"; | |||
| message = Rows.abbrev(e.getMessage(), 480); | |||
| log.error("station={} inspection_report sync failed: {}", | |||
| station.getStationCode(), e.getMessage()); | |||
| } | |||
| syncLogWriter.write(station.getStationCode(), "report", "FULL", status, upserted, | |||
| deletedMarked, message, started, LocalDateTime.now()); | |||
| } | |||
| private AggInspectionReport map(JsonNode row, AggStation station) { | |||
| Long lineId = Rows.longVal(row, "lineId"); | |||
| if (lineId == null) { | |||
| return null; | |||
| } | |||
| AggInspectionReport report = new AggInspectionReport(); | |||
| report.setStationCode(station.getStationCode()); | |||
| report.setLineId(lineId); | |||
| report.setStationName(Rows.text(row, "stationName")); | |||
| report.setVoltLevel(Rows.text(row, "voltLevel")); | |||
| report.setStationType(Rows.text(row, "stationType")); | |||
| report.setInspectionDate(Rows.date(row, "inspectionDate")); | |||
| report.setInspectionTaskName(Rows.text(row, "inspectionTaskName")); | |||
| report.setEnvInfo(Rows.text(row, "envInfo")); | |||
| report.setInspectionStartTime(Rows.date(row, "inspectionStartTime")); | |||
| report.setInspectionEndTime(Rows.date(row, "inspectionEndTime")); | |||
| report.setPatrolStatistics(Rows.text(row, "patrolStatistics")); | |||
| report.setCheckPerson(Rows.text(row, "checkPerson")); | |||
| report.setCheckTime(Rows.date(row, "checkTime")); | |||
| report.setDescription(Rows.text(row, "description")); | |||
| report.setFilePath(Rows.text(row, "filePath")); | |||
| report.setTaskId(Rows.text(row, "taskId")); | |||
| report.setTaskResultId(Rows.text(row, "taskResultId")); | |||
| report.setTaskPatrolledId(Rows.text(row, "taskPatrolledId")); | |||
| report.setLocation(Rows.text(row, "location")); | |||
| report.setFilter(Rows.text(row, "filter")); | |||
| report.setSyncedAt(LocalDateTime.now()); | |||
| report.setIsDeleted(0); | |||
| return report; | |||
| } | |||
| } | |||
| @ -0,0 +1,120 @@ | |||
| package com.inspect.agg.service; | |||
| import java.time.LocalDateTime; | |||
| import java.util.ArrayList; | |||
| import java.util.List; | |||
| import org.springframework.stereotype.Service; | |||
| import com.fasterxml.jackson.databind.JsonNode; | |||
| import com.inspect.agg.calendar.AggTaskCalendarService; | |||
| import com.inspect.agg.config.AggProperties; | |||
| import com.inspect.agg.entity.AggPatrolTask; | |||
| import com.inspect.agg.entity.AggStation; | |||
| import com.inspect.agg.mapper.AggPatrolTaskMapper; | |||
| import com.inspect.agg.station.StationApiClient; | |||
| import com.inspect.agg.support.Rows; | |||
| import com.inspect.agg.support.SyncLogWriter; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * 任务方案同步服务(源 patrol_task,接口 /patrol/task/list,量小,周期全量对账)。 | |||
| */ | |||
| @Slf4j | |||
| @Service | |||
| @RequiredArgsConstructor | |||
| public class PatrolTaskSyncService { | |||
| private final StationRegistry stationRegistry; | |||
| private final StationApiClient stationApiClient; | |||
| private final AggPatrolTaskMapper aggPatrolTaskMapper; | |||
| private final SyncLogWriter syncLogWriter; | |||
| private final AggProperties props; | |||
| private final AggTaskCalendarService calendarService; | |||
| public void syncFullAllStations() { | |||
| stationRegistry.listEnabled().parallelStream().forEach(this::syncFull); | |||
| } | |||
| public void syncFull(AggStation station) { | |||
| LocalDateTime started = LocalDateTime.now(); | |||
| String status = "SUCCESS"; | |||
| int upserted = 0; | |||
| int deletedMarked = 0; | |||
| String message = null; | |||
| try { | |||
| List<JsonNode> rows = stationApiClient.fetchAllRows(station, "patrol/task/list", | |||
| props.getSync().getBase().getPageSize(), 100, "taskId"); | |||
| List<Long> ids = new ArrayList<>(); | |||
| for (JsonNode row : rows) { | |||
| AggPatrolTask p = map(row, station); | |||
| if (p == null) { | |||
| continue; | |||
| } | |||
| aggPatrolTaskMapper.upsert(p); | |||
| if (p.getTaskId() != null) { | |||
| ids.add(p.getTaskId()); | |||
| } | |||
| upserted++; | |||
| } | |||
| deletedMarked = aggPatrolTaskMapper.markDeletedExcept(station.getStationCode(), ids); | |||
| calendarService.evictStation(station.getStationCode()); | |||
| message = "rows=" + rows.size(); | |||
| log.info("station={} patrol_task FULL ok: upserted={}, deletedMarked={}", station.getStationCode(), upserted, deletedMarked); | |||
| } catch (Exception e) { | |||
| status = "FAILED"; | |||
| message = Rows.abbrev(e.getMessage(), 480); | |||
| log.error("station={} patrol_task FULL failed: {}", station.getStationCode(), e.getMessage()); | |||
| } | |||
| syncLogWriter.write(station.getStationCode(), "taskplan", "FULL", status, upserted, deletedMarked, | |||
| message, started, LocalDateTime.now()); | |||
| } | |||
| private AggPatrolTask map(JsonNode r, AggStation station) { | |||
| Long taskId = Rows.longVal(r, "taskId"); | |||
| if (taskId == null) { | |||
| return null; | |||
| } | |||
| AggPatrolTask p = new AggPatrolTask(); | |||
| p.setStationCode(station.getStationCode()); | |||
| p.setTaskId(taskId); | |||
| p.setTaskCode(Rows.text(r, "taskCode")); | |||
| p.setTaskName(Rows.text(r, "taskName")); | |||
| p.setType(Rows.text(r, "type")); | |||
| p.setPriority(Rows.text(r, "priority")); | |||
| p.setDeviceLevel(Rows.text(r, "deviceLevel")); | |||
| p.setDeviceList(Rows.text(r, "deviceList")); | |||
| p.setFixedStartTime(Rows.date(r, "fixedStartTime")); | |||
| p.setIsEnable(Rows.text(r, "isEnable", "isenable")); | |||
| p.setFileStatus(Rows.text(r, "fileStatus")); | |||
| p.setExecutionStatus(Rows.text(r, "executionStatus")); | |||
| p.setTaskCycle(Rows.text(r, "taskCycle")); | |||
| p.setCycleMonth(Rows.text(r, "cycleMonth")); | |||
| p.setCycleWeek(Rows.text(r, "cycleWeek")); | |||
| p.setCycleExecuteTime(Rows.text(r, "cycleExecuteTime")); | |||
| p.setCycleStartTime(Rows.date(r, "cycleStartTime")); | |||
| p.setCycleEndTime(Rows.date(r, "cycleEndTime")); | |||
| p.setIntervalNumber(Rows.intVal(r, "intervalNumber")); | |||
| p.setIntervalType(Rows.text(r, "intervalType")); | |||
| p.setIntervalExecuteTime(Rows.text(r, "intervalExecuteTime")); | |||
| p.setIntervalStartTime(Rows.date(r, "intervalStartTime")); | |||
| p.setIntervalEndTime(Rows.date(r, "intervalEndTime")); | |||
| p.setInvalidStartTime(Rows.date(r, "invalidStartTime")); | |||
| p.setInvalidEndTime(Rows.date(r, "invalidEndTime")); | |||
| p.setDevType(Rows.text(r, "devType")); | |||
| p.setAreaId(Rows.text(r, "areaId")); | |||
| p.setAreaName(Rows.text(r, "areaName")); | |||
| p.setPointType(Rows.text(r, "pointType")); | |||
| Integer totalPoints = Rows.intVal(r, "totalPoints"); | |||
| p.setTotalPoints(totalPoints == null ? 0 : totalPoints); | |||
| p.setExecutionMode(Rows.text(r, "executionMode")); | |||
| p.setDrafter(Rows.text(r, "drafter")); | |||
| p.setSpecifiedAlg(Rows.text(r, "specifiedAlg")); | |||
| p.setCreateTime(Rows.date(r, "createTime")); | |||
| p.setSyncedAt(LocalDateTime.now()); | |||
| p.setIsDeleted(0); | |||
| return p; | |||
| } | |||
| } | |||
| @ -0,0 +1,99 @@ | |||
| package com.inspect.agg.service; | |||
| import java.time.LocalDateTime; | |||
| import java.util.ArrayList; | |||
| import java.util.List; | |||
| import org.springframework.stereotype.Service; | |||
| import com.fasterxml.jackson.databind.JsonNode; | |||
| import com.inspect.agg.config.AggProperties; | |||
| import com.inspect.agg.entity.AggBasedataPatrolpoint; | |||
| import com.inspect.agg.entity.AggStation; | |||
| import com.inspect.agg.mapper.AggBasedataPatrolpointMapper; | |||
| import com.inspect.agg.station.StationApiClient; | |||
| import com.inspect.agg.support.Rows; | |||
| import com.inspect.agg.support.SyncLogWriter; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * 巡检点位同步服务(基础数据全量:源 basedata_patrolpoint)。 | |||
| */ | |||
| @Slf4j | |||
| @Service | |||
| @RequiredArgsConstructor | |||
| public class PointSyncService { | |||
| private final StationRegistry stationRegistry; | |||
| private final StationApiClient stationApiClient; | |||
| private final AggBasedataPatrolpointMapper aggPointMapper; | |||
| private final SyncLogWriter syncLogWriter; | |||
| private final AggProperties props; | |||
| public void syncAllStations() { | |||
| for (AggStation station : stationRegistry.listEnabled()) { | |||
| syncStation(station); | |||
| } | |||
| } | |||
| public void syncStation(AggStation station) { | |||
| LocalDateTime started = LocalDateTime.now(); | |||
| String status = "SUCCESS"; | |||
| int upserted = 0; | |||
| int deletedMarked = 0; | |||
| String message = null; | |||
| try { | |||
| List<JsonNode> rows = stationApiClient.fetchAllPatrolPoints(station, | |||
| props.getSync().getBase().getPageSize(), 500); | |||
| List<Long> ids = new ArrayList<>(); | |||
| for (JsonNode row : rows) { | |||
| AggBasedataPatrolpoint p = map(row, station); | |||
| if (p == null) { | |||
| continue; | |||
| } | |||
| aggPointMapper.upsert(p); | |||
| if (p.getPatrolPointId() != null) { | |||
| ids.add(p.getPatrolPointId()); | |||
| } | |||
| upserted++; | |||
| } | |||
| deletedMarked = aggPointMapper.markDeletedExcept(station.getStationCode(), ids); | |||
| message = "rows=" + rows.size(); | |||
| log.info("station={} point sync ok: upserted={}, deletedMarked={}", station.getStationCode(), upserted, deletedMarked); | |||
| } catch (Exception e) { | |||
| status = "FAILED"; | |||
| message = Rows.abbrev(e.getMessage(), 480); | |||
| log.error("station={} point sync failed: {}", station.getStationCode(), e.getMessage()); | |||
| } | |||
| syncLogWriter.write(station.getStationCode(), "point", "FULL", status, upserted, deletedMarked, | |||
| message, started, LocalDateTime.now()); | |||
| } | |||
| private AggBasedataPatrolpoint map(JsonNode r, AggStation station) { | |||
| Long pointId = Rows.longVal(r, "patrolpointId"); | |||
| if (pointId == null) { | |||
| return null; | |||
| } | |||
| AggBasedataPatrolpoint p = new AggBasedataPatrolpoint(); | |||
| p.setStationCode(station.getStationCode()); | |||
| p.setPatrolPointId(pointId); | |||
| p.setPatrolPointCode(Rows.text(r, "patrolpointCode")); | |||
| p.setPatrolPointName(Rows.text(r, "patrolpointName")); | |||
| p.setMainDeviceId(Rows.longVal(r, "mainDeviceId")); | |||
| p.setDeviceId(Rows.longVal(r, "deviceId")); | |||
| p.setAreaId(Rows.longVal(r, "areaId")); | |||
| p.setAreaName(Rows.text(r, "areaName")); | |||
| p.setPatrolContent(Rows.text(r, "patrolContent")); | |||
| p.setPurpose(Rows.text(r, "purpose")); | |||
| p.setPatrolTypeId(Rows.text(r, "patrolTypeId")); | |||
| p.setPointImptLevel(Rows.text(r, "pointImptLevel")); | |||
| p.setAlarmThreshold(Rows.text(r, "alarmThreshold")); | |||
| p.setAlgSubtypeIds(Rows.text(r, "algSubtypeIds")); | |||
| p.setUpdateTime(Rows.date(r, "updateTime")); | |||
| p.setSyncedAt(LocalDateTime.now()); | |||
| p.setIsDeleted(0); | |||
| return p; | |||
| } | |||
| } | |||
| @ -0,0 +1,130 @@ | |||
| package com.inspect.agg.service; | |||
| import java.time.LocalDateTime; | |||
| import java.time.YearMonth; | |||
| import java.time.format.DateTimeFormatter; | |||
| import java.util.HashMap; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| import org.springframework.stereotype.Service; | |||
| import com.fasterxml.jackson.databind.JsonNode; | |||
| import com.inspect.agg.config.AggProperties; | |||
| import com.inspect.agg.entity.AggResultAnalysis; | |||
| import com.inspect.agg.entity.AggStation; | |||
| import com.inspect.agg.mapper.AggResultAnalysisMapper; | |||
| import com.inspect.agg.station.StationApiClient; | |||
| import com.inspect.agg.support.Rows; | |||
| import com.inspect.agg.support.SyncLogWriter; | |||
| import com.inspect.base.core.enums.DefectLevel; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * result_analysis 汇聚同步服务。 | |||
| */ | |||
| @Slf4j | |||
| @Service | |||
| @RequiredArgsConstructor | |||
| public class ResultAnalysisSyncService { | |||
| private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); | |||
| private final StationRegistry stationRegistry; | |||
| private final StationApiClient stationApiClient; | |||
| private final AggResultAnalysisMapper resultAnalysisMapper; | |||
| private final SyncLogWriter syncLogWriter; | |||
| private final AggProperties props; | |||
| public void syncLast12MonthsAllStations() { | |||
| stationRegistry.listEnabled().forEach(this::syncLast12Months); | |||
| } | |||
| public void syncRecentAllStations() { | |||
| LocalDateTime end = LocalDateTime.now(); | |||
| LocalDateTime begin = end.minusDays(7); | |||
| stationRegistry.listEnabled().forEach(station -> syncRange(station, begin, end, "RECENT")); | |||
| } | |||
| public void syncLast12Months(AggStation station) { | |||
| LocalDateTime begin = YearMonth.now().minusMonths(11).atDay(1).atStartOfDay(); | |||
| LocalDateTime end = YearMonth.now().plusMonths(1).atDay(1).atStartOfDay(); | |||
| syncRange(station, begin, end, "FULL_12M"); | |||
| } | |||
| private void syncRange(AggStation station, LocalDateTime begin, LocalDateTime end, String syncType) { | |||
| LocalDateTime started = LocalDateTime.now(); | |||
| String status = "SUCCESS"; | |||
| int upserted = 0; | |||
| String message = null; | |||
| try { | |||
| Map<String, String> query = new HashMap<>(); | |||
| query.put("beginTime", begin.format(DATE_TIME)); | |||
| query.put("endTime", end.minusSeconds(1).format(DATE_TIME)); | |||
| List<JsonNode> rows = stationApiClient.fetchAllRows(station, "patrol/analysis/agg/list", | |||
| props.getSync().getBase().getPageSize(), 1000, query, "lineId"); | |||
| for (JsonNode row : rows) { | |||
| AggResultAnalysis item = map(row, station); | |||
| if (item == null) { | |||
| continue; | |||
| } | |||
| resultAnalysisMapper.upsert(item); | |||
| upserted++; | |||
| } | |||
| message = "rows=" + rows.size(); | |||
| log.info("station={} result_analysis {} ok: upserted={}", station.getStationCode(), syncType, upserted); | |||
| } catch (Exception e) { | |||
| status = "FAILED"; | |||
| message = Rows.abbrev(e.getMessage(), 480); | |||
| log.error("station={} result_analysis {} failed: {}", station.getStationCode(), syncType, e.getMessage()); | |||
| } | |||
| syncLogWriter.write(station.getStationCode(), "analysis", syncType, status, upserted, 0, | |||
| message, started, LocalDateTime.now()); | |||
| } | |||
| private AggResultAnalysis map(JsonNode row, AggStation station) { | |||
| Long lineId = Rows.longVal(row, "lineId"); | |||
| if (lineId == null) { | |||
| return null; | |||
| } | |||
| String resultType = Rows.text(row, "resultType"); | |||
| AggResultAnalysis item = new AggResultAnalysis(); | |||
| item.setStationCode(station.getStationCode()); | |||
| item.setLineId(lineId); | |||
| item.setResultType(resultType); | |||
| item.setBusinessId(Rows.text(row, "businessId")); | |||
| item.setResultContent(Rows.text(row, "resultContent")); | |||
| item.setResStatus(Rows.text(row, "resStatus")); | |||
| item.setPointStatus(Rows.text(row, "pointStatus")); | |||
| item.setSuggestion(Rows.text(row, "suggestion")); | |||
| item.setObjectId(Rows.text(row, "objectId")); | |||
| item.setDescription(Rows.text(row, "description")); | |||
| item.setRequestId(Rows.text(row, "requestId")); | |||
| item.setTaskPatrolId(Rows.text(row, "taskPatrolId")); | |||
| item.setAlgType(Rows.text(row, "algType")); | |||
| item.setResValue(Rows.text(row, "resValue")); | |||
| item.setResCode(Rows.text(row, "resCode")); | |||
| item.setResConf(Rows.text(row, "resConf")); | |||
| item.setResDesc(Rows.text(row, "resDesc")); | |||
| item.setResult(Rows.text(row, "result")); | |||
| item.setResImgUrl(Rows.text(row, "resImgUrl")); | |||
| item.setImageNormalUrlPath(Rows.text(row, "imageNormalUrlPath")); | |||
| item.setResPos(Rows.jsonText(row, "resPos")); | |||
| item.setFilter(Rows.text(row, "filter")); | |||
| item.setOriginalValue(Rows.text(row, "originalValue")); | |||
| Integer defectLevel = Rows.intVal(row, "defectLevel"); | |||
| item.setDefectLevel(defectLevel == null ? deriveDefectLevel(resultType) : defectLevel); | |||
| item.setCreateTime(Rows.date(row, "createTime")); | |||
| item.setSyncedAt(LocalDateTime.now()); | |||
| item.setIsDeleted(0); | |||
| return item; | |||
| } | |||
| private static int deriveDefectLevel(String resultType) { | |||
| if ("0".equals(resultType)) return DefectLevel.NORMAL.getCode(); | |||
| if ("2".equals(resultType)) return DefectLevel.MINOR.getCode(); | |||
| return DefectLevel.NONE.getCode(); | |||
| } | |||
| } | |||
| @ -0,0 +1,47 @@ | |||
| package com.inspect.agg.service; | |||
| import java.util.List; | |||
| import org.springframework.stereotype.Service; | |||
| import org.springframework.util.StringUtils; | |||
| import com.inspect.agg.entity.AggStation; | |||
| import com.inspect.agg.mapper.AggStationMapper; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * 站点注册表(DB 维护):查询走 Mapper XML | |||
| */ | |||
| @Slf4j | |||
| @Service | |||
| @RequiredArgsConstructor | |||
| public class StationRegistry { | |||
| private final AggStationMapper aggStationMapper; | |||
| /** 所有启用站点 */ | |||
| public List<AggStation> listEnabled() { | |||
| List<AggStation> list = aggStationMapper.selectEnabled(); | |||
| log.info("listEnabled: {}", list); | |||
| return list; | |||
| } | |||
| /** 全部站点(含停用) */ | |||
| public List<AggStation> listAll() { | |||
| return aggStationMapper.selectAllOrdered(); | |||
| } | |||
| /** 按编码查站点(不存在抛异常) */ | |||
| public AggStation requireByCode(String stationCode) { | |||
| if (!StringUtils.hasText(stationCode)) { | |||
| throw new IllegalArgumentException("stationCode 不能为空"); | |||
| } | |||
| AggStation station = aggStationMapper.selectByCode(stationCode); | |||
| if (station == null) { | |||
| throw new IllegalArgumentException("站点不存在: " + stationCode); | |||
| } | |||
| return station; | |||
| } | |||
| } | |||
| @ -0,0 +1,147 @@ | |||
| package com.inspect.agg.service; | |||
| import java.time.LocalDateTime; | |||
| import java.util.ArrayList; | |||
| import java.util.Collections; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| import org.springframework.stereotype.Service; | |||
| import com.fasterxml.jackson.databind.JsonNode; | |||
| import com.inspect.agg.config.AggProperties; | |||
| import com.inspect.agg.entity.AggPatrolTaskStatus; | |||
| import com.inspect.agg.entity.AggStation; | |||
| import com.inspect.agg.mapper.AggPatrolTaskStatusMapper; | |||
| import com.inspect.agg.station.StationApiClient; | |||
| import com.inspect.agg.support.Rows; | |||
| import com.inspect.agg.support.SyncLogWriter; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * 任务执行同步服务(动态数据)。 | |||
| * 站点侧 patrol_task_status 无 update_time,无法时间水位: | |||
| * - ACTIVE:高频拉 taskState=2(执行中)子集,满足 1 分钟新鲜度 | |||
| * - FULL:周期全量 + 对账删除,覆盖终态/状态变化 | |||
| */ | |||
| @Slf4j | |||
| @Service | |||
| @RequiredArgsConstructor | |||
| public class TaskExecSyncService { | |||
| private final StationRegistry stationRegistry; | |||
| private final StationApiClient stationApiClient; | |||
| private final AggPatrolTaskStatusMapper aggTaskExecMapper; | |||
| private final SyncLogWriter syncLogWriter; | |||
| private final AggProperties props; | |||
| public void syncActiveAllStations() { | |||
| stationRegistry.listEnabled().parallelStream().forEach(this::syncActive); | |||
| } | |||
| public void syncFullAllStations() { | |||
| stationRegistry.listEnabled().parallelStream().forEach(this::syncFull); | |||
| } | |||
| /** 高频:只拉执行中任务 */ | |||
| public void syncActive(AggStation station) { | |||
| LocalDateTime started = LocalDateTime.now(); | |||
| String status = "SUCCESS"; | |||
| int upserted = 0; | |||
| String message = null; | |||
| try { | |||
| List<JsonNode> rows = stationApiClient.fetchAllRows(station, "patrol/taskstatus/list", | |||
| props.getSync().getBase().getPageSize(), 50, | |||
| Collections.singletonMap("taskState", "2"), "lineId"); | |||
| for (JsonNode row : rows) { | |||
| if (upsert(row, station)) { | |||
| upserted++; | |||
| } | |||
| } | |||
| message = "rows=" + rows.size(); | |||
| log.info("station={} task ACTIVE ok: upserted={}", station.getStationCode(), upserted); | |||
| } catch (Exception e) { | |||
| status = "FAILED"; | |||
| message = Rows.abbrev(e.getMessage(), 480); | |||
| log.error("station={} task ACTIVE failed: {}", station.getStationCode(), e.getMessage()); | |||
| } | |||
| syncLogWriter.write(station.getStationCode(), "task", "ACTIVE", status, upserted, 0, | |||
| message, started, LocalDateTime.now()); | |||
| } | |||
| /** 周期:全量拉取 + 对账删除 */ | |||
| public void syncFull(AggStation station) { | |||
| LocalDateTime started = LocalDateTime.now(); | |||
| String status = "SUCCESS"; | |||
| int upserted = 0; | |||
| int deletedMarked = 0; | |||
| String message = null; | |||
| try { | |||
| List<JsonNode> rows = stationApiClient.fetchAllRows(station, "patrol/taskstatus/list", | |||
| 500, 100, "lineId"); | |||
| List<Long> ids = new ArrayList<>(); | |||
| for (JsonNode row : rows) { | |||
| if (upsert(row, station) && row.hasNonNull("lineId")) { | |||
| ids.add(row.get("lineId").asLong()); | |||
| } | |||
| } | |||
| deletedMarked = aggTaskExecMapper.markDeletedExcept(station.getStationCode(), ids); | |||
| upserted = ids.size(); | |||
| message = "rows=" + rows.size(); | |||
| log.info("station={} task FULL ok: upserted={}, deletedMarked={}", station.getStationCode(), upserted, deletedMarked); | |||
| } catch (Exception e) { | |||
| status = "FAILED"; | |||
| message = Rows.abbrev(e.getMessage(), 480); | |||
| log.error("station={} task FULL failed: {}", station.getStationCode(), e.getMessage()); | |||
| } | |||
| syncLogWriter.write(station.getStationCode(), "task", "FULL", status, upserted, deletedMarked, | |||
| message, started, LocalDateTime.now()); | |||
| } | |||
| private boolean upsert(JsonNode r, AggStation station) { | |||
| AggPatrolTaskStatus e = map(r, station); | |||
| if (e == null) { | |||
| return false; | |||
| } | |||
| aggTaskExecMapper.upsert(e); | |||
| return true; | |||
| } | |||
| private AggPatrolTaskStatus map(JsonNode r, AggStation station) { | |||
| Long lineId = Rows.longVal(r, "lineId"); | |||
| if (lineId == null) { | |||
| return null; | |||
| } | |||
| AggPatrolTaskStatus e = new AggPatrolTaskStatus(); | |||
| e.setStationCode(station.getStationCode()); | |||
| e.setLineId(lineId); | |||
| e.setTaskPatrolledId(Rows.text(r, "taskPatrolledId")); | |||
| e.setTaskName(Rows.text(r, "taskName")); | |||
| e.setTaskCode(Rows.text(r, "taskCode")); | |||
| e.setTaskState(Rows.text(r, "taskState")); | |||
| e.setPlanStartTime(Rows.date(r, "planStartTime")); | |||
| e.setStartTime(Rows.date(r, "startTime")); | |||
| e.setEndTime(Rows.date(r, "endTime")); | |||
| e.setTaskProgress(Rows.text(r, "taskProgress")); | |||
| e.setTaskEstimatedTime(Rows.text(r, "taskEstimatedTime")); | |||
| e.setPatrolType(Rows.text(r, "patrolType")); | |||
| e.setPosType(Rows.text(r, "posType")); | |||
| e.setCode(Rows.text(r, "code")); | |||
| e.setPos(Rows.text(r, "pos")); | |||
| e.setExecType(Rows.text(r, "execType")); | |||
| e.setTaskType(Rows.text(r, "taskType")); | |||
| e.setMainId(Rows.text(r, "mainId")); | |||
| Integer qxNum = Rows.intVal(r, "qxNum"); | |||
| e.setQxNum(qxNum == null ? 0 : qxNum); | |||
| String fileStatus = Rows.text(r, "fileStatus"); | |||
| e.setFileStatus(fileStatus == null ? "0" : fileStatus); | |||
| e.setSummary(Rows.jsonText(r, "summary")); | |||
| e.setDescription(Rows.text(r, "description")); | |||
| e.setCreateTime(Rows.date(r, "createTime")); | |||
| e.setSyncedAt(LocalDateTime.now()); | |||
| e.setIsDeleted(0); | |||
| return e; | |||
| } | |||
| } | |||
| @ -0,0 +1,386 @@ | |||
| package com.inspect.agg.station; | |||
| import java.net.URI; | |||
| import java.util.ArrayList; | |||
| import java.util.Collections; | |||
| import java.util.HashSet; | |||
| import java.util.LinkedHashMap; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| import java.util.Set; | |||
| import java.util.concurrent.ConcurrentHashMap; | |||
| import org.springframework.http.HttpEntity; | |||
| import org.springframework.http.HttpHeaders; | |||
| import org.springframework.http.HttpMethod; | |||
| import org.springframework.http.MediaType; | |||
| import org.springframework.http.ResponseEntity; | |||
| import org.springframework.stereotype.Component; | |||
| import org.springframework.web.client.RestTemplate; | |||
| import org.springframework.web.util.UriComponentsBuilder; | |||
| import com.fasterxml.jackson.databind.JsonNode; | |||
| import com.fasterxml.jackson.databind.ObjectMapper; | |||
| import com.inspect.agg.entity.AggStation; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * 站点 API 客户端:getToken 鉴权(Bearer)+ 基础数据分页拉取。 | |||
| * 兼容两种响应形态:{data:[...]} 与 RuoYi 分页 {total,rows}(根级或 data 内)。 | |||
| * 每个实体的行唯一键由调用方显式指定(idKeys),禁止回退到可重复字段判重。 | |||
| */ | |||
| @Slf4j | |||
| @Component | |||
| @RequiredArgsConstructor | |||
| public class StationApiClient { | |||
| private final RestTemplate restTemplate; | |||
| private final ObjectMapper objectMapper; | |||
| /** 每站点 token 缓存:value + 过期时刻(epoch ms),提前 60s 续期 */ | |||
| private final Map<String, Token> tokenCache = new ConcurrentHashMap<>(); | |||
| /** 令牌缓存项(JDK8 兼容写法,替代 record) */ | |||
| private static final class Token { | |||
| private final String value; | |||
| private final long expireAtMs; | |||
| Token(String value, long expireAtMs) { | |||
| this.value = value; | |||
| this.expireAtMs = expireAtMs; | |||
| } | |||
| String value() { | |||
| return value; | |||
| } | |||
| boolean valid() { | |||
| return value != null && System.currentTimeMillis() < expireAtMs - 60_000L; | |||
| } | |||
| } | |||
| public List<JsonNode> fetchAllAreas(AggStation station, int pageSize, int maxPages) { | |||
| return fetchAllRows(station, "basedata/area/list", pageSize, maxPages, "areaId"); | |||
| } | |||
| public List<JsonNode> fetchAllInspectionReports(AggStation station, int pageSize, int maxPages) { | |||
| return fetchAllRows(station, "patrol/insreport/list", pageSize, maxPages, "lineId"); | |||
| } | |||
| public List<JsonNode> fetchAllDevices(AggStation station, int pageSize, int maxPages) { | |||
| return fetchAllRows(station, "basedata/prodevmnt/device/list", pageSize, maxPages, "deviceId"); | |||
| } | |||
| public List<JsonNode> fetchAllPatrolPoints(AggStation station, int pageSize, int maxPages) { | |||
| return fetchAllRows(station, "basedata/patrolpointmnt/patrolpoint/list", pageSize, maxPages, "patrolpointId"); | |||
| } | |||
| public List<JsonNode> fetchAllEqpbooks(AggStation station, int pageSize, int maxPages) { | |||
| return fetchAllRows(station, "basedata/eqpbook/list", pageSize, maxPages, "eqpbookId"); | |||
| } | |||
| public List<JsonNode> fetchAllChannels(AggStation station, int pageSize, int maxPages) { | |||
| return fetchAllRows(station, "basedata/eqpbook/channel/list", pageSize, maxPages, "channelId"); | |||
| } | |||
| public List<JsonNode> fetchAllDeviceTypes(AggStation station, int pageSize, int maxPages) { | |||
| return fetchAllRows(station, "basedata/devicetype/list", pageSize, maxPages, "devicetypeId"); | |||
| } | |||
| public JsonNode fetchAggDefectMonth(AggStation station, String beginMonth, String endMonth) { | |||
| String url = UriComponentsBuilder.fromHttpUrl(stripTrailingSlash(station.getBaseUrl()) + "/patrol/analysis/agg/defect/month") | |||
| .queryParam("beginMonth", beginMonth) | |||
| .queryParam("endMonth", endMonth) | |||
| .toUriString(); | |||
| return doGetJson(url, station); | |||
| } | |||
| /** | |||
| * 取摄像机 RTSP 地址(站端 inspect-ivs 接口): | |||
| * GET {base_url}/ivs/api/v1/stream/rtspUrl?cameraCode=xxx | |||
| */ | |||
| public JsonNode fetchRtspUrl(AggStation station, String cameraCode, Integer streamType) { | |||
| String url = UriComponentsBuilder.fromHttpUrl(stripTrailingSlash(station.getBaseUrl()) + "/ivs/api/v1/stream/rtspUrl") | |||
| .queryParam("cameraCode", cameraCode) | |||
| .queryParam("streamType", streamType) | |||
| .toUriString(); | |||
| return getJson(url, ensureToken(station)); | |||
| } | |||
| /** 启动站端转流:GET {base_url}/ivs/api/v1/stream/start?cameraCode=xxx(返回 StreamID/FLV/HLS 等) */ | |||
| public JsonNode fetchStreamStart(AggStation station, String cameraCode, Integer streamType) { | |||
| String url = UriComponentsBuilder.fromHttpUrl(stripTrailingSlash(station.getBaseUrl()) + "/ivs/api/v1/stream/start") | |||
| .queryParam("cameraCode", cameraCode) | |||
| .queryParam("streamType", streamType) | |||
| .toUriString(); | |||
| return getJson(url, ensureToken(station)); | |||
| } | |||
| public JsonNode fetchRealtimeStart(AggStation station, String cameraCode, Integer streamType) { | |||
| String url = stripTrailingSlash(station.getBaseUrl()) + "/patrol/video/realtime/start"; | |||
| Map<String, Object> body = new LinkedHashMap<>(); | |||
| body.put("streamType", streamType == null ? 2 : streamType); | |||
| body.put("cameraCode", cameraCode); | |||
| return doPostJson(url, body, station); | |||
| } | |||
| /** 停止站端实时转流:POST {base_url}/patrol/video/realtime/stop */ | |||
| public JsonNode fetchRealtimeStop(AggStation station, String stationStreamId, | |||
| String cameraCode, Integer streamType) { | |||
| String url = stripTrailingSlash(station.getBaseUrl()) + "/patrol/video/realtime/stop"; | |||
| Map<String, Object> body = new LinkedHashMap<>(); | |||
| if (stationStreamId != null && !stationStreamId.trim().isEmpty()) { | |||
| body.put("streamId", stationStreamId); | |||
| } | |||
| body.put("streamType", streamType == null ? 2 : streamType); | |||
| body.put("cameraCode", cameraCode); | |||
| return doPostJson(url, body, station); | |||
| } | |||
| /** 停止站端转流:GET {base_url}/ivs/api/v1/stream/stop?streamId=xxx */ | |||
| public JsonNode fetchStreamStop(AggStation station, String streamId) { | |||
| String url = UriComponentsBuilder.fromHttpUrl(stripTrailingSlash(station.getBaseUrl()) + "/ivs/api/v1/stream/stop") | |||
| .queryParam("streamId", streamId) | |||
| .toUriString(); | |||
| return getJson(url, ensureToken(station)); | |||
| } | |||
| /** 创建站端 HLS 历史回放会话。 */ | |||
| public JsonNode createHlsPlaybackSession(AggStation station, String cameraCode, Integer streamType, | |||
| String startTime, String endTime) { | |||
| String url = stripTrailingSlash(station.getBaseUrl()) + "/patrol/videotape/hls/ui/session/create"; | |||
| Map<String, Object> body = new LinkedHashMap<>(); | |||
| body.put("streamType", streamType); | |||
| body.put("cameraCode", cameraCode); | |||
| body.put("startTime", startTime); | |||
| body.put("endTime", endTime); | |||
| return doPostJson(url, body, station); | |||
| } | |||
| /** 控制站端 HLS 历史回放会话。 */ | |||
| public JsonNode controlHlsPlaybackSession(AggStation station, String sessionId, String cameraCode, String action) { | |||
| String url = stripTrailingSlash(station.getBaseUrl()) | |||
| + "/patrol/videotape/hls/ui/session/control/" + sessionId; | |||
| Map<String, Object> body = new LinkedHashMap<>(); | |||
| body.put("cameraCode", cameraCode); | |||
| body.put("action", action); | |||
| return doPostJson(url, body, station); | |||
| } | |||
| /** 通用分页拉取:按 total 或页大小/重复检测终止 */ | |||
| public List<JsonNode> fetchAllRows(AggStation station, String path, int pageSize, int maxPages, String... idKeys) { | |||
| return fetchAllRows(station, path, pageSize, maxPages, Collections.emptyMap(), idKeys); | |||
| } | |||
| /** 带附加查询参数(如 taskState/fileStatus)的分页拉取 */ | |||
| public List<JsonNode> fetchAllRows(AggStation station, String path, int pageSize, int maxPages, | |||
| Map<String, String> extraQuery, String... idKeys) { | |||
| String token = ensureToken(station); | |||
| List<JsonNode> rows = new ArrayList<>(); | |||
| Set<String> seenKeys = new HashSet<>(); | |||
| int page = 1; | |||
| long total = -1; | |||
| boolean duplicateBreak = false; | |||
| while (page <= maxPages) { | |||
| UriComponentsBuilder ub = UriComponentsBuilder.fromHttpUrl(stripTrailingSlash(station.getBaseUrl()) + "/" + path) | |||
| .queryParam("pageNum", page) | |||
| .queryParam("pageSize", pageSize); | |||
| if (extraQuery != null) { | |||
| extraQuery.forEach(ub::queryParam); | |||
| } | |||
| String url = ub.toUriString(); | |||
| PageData pd = doGetRows(url, token, station); | |||
| int before = rows.size(); | |||
| for (JsonNode r : pd.rows()) { | |||
| String key = rowKey(r, idKeys); | |||
| if (key != null && !seenKeys.add(key)) { | |||
| duplicateBreak = true; // 接口未按分页生效,返回重复全集 | |||
| continue; | |||
| } | |||
| rows.add(r); | |||
| } | |||
| if (pd.total() >= 0) { | |||
| if (rows.size() >= pd.total()) { | |||
| break; | |||
| } | |||
| } else { | |||
| if (pd.rows().isEmpty() || (rows.size() - before) == 0) { | |||
| break; | |||
| } | |||
| } | |||
| if (duplicateBreak) { | |||
| break; | |||
| } | |||
| if (pd.total() < 0 && pd.rows().size() < pageSize) { | |||
| break; | |||
| } | |||
| page++; | |||
| } | |||
| log.info("station={} fetch {} done: rows={} (pages tried={})", station.getStationCode(), path, rows.size(), page); | |||
| return rows; | |||
| } | |||
| /** 获取有效 token(缓存 + 惰性刷新) */ | |||
| public String ensureToken(AggStation station) { | |||
| Token cached = tokenCache.get(station.getStationCode()); | |||
| if (cached != null && cached.valid()) { | |||
| return cached.value; | |||
| } | |||
| synchronized (tokenCache) { | |||
| cached = tokenCache.get(station.getStationCode()); | |||
| if (cached != null && cached.valid()) { | |||
| return cached.value; | |||
| } | |||
| String token = requestToken(station); | |||
| tokenCache.put(station.getStationCode(), new Token(token, System.currentTimeMillis() + 525_600_000L)); | |||
| return token; | |||
| } | |||
| } | |||
| private String requestToken(AggStation station) { | |||
| String url = UriComponentsBuilder.fromHttpUrl(stripTrailingSlash(station.getBaseUrl()) + "/auth/getToken") | |||
| .queryParam("username", station.getUsername()) | |||
| .queryParam("password", station.getPassword()) | |||
| .toUriString(); | |||
| JsonNode root = getJson(url, null); | |||
| if (root.path("code").asInt() != 200) { | |||
| throw new IllegalStateException("getToken 失败: " + root.path("data").asText(root.path("msg").asText())); | |||
| } | |||
| JsonNode data = root.path("data"); | |||
| String token = data.path("access_token").asText(null); | |||
| if (token == null || token.trim().isEmpty()) { | |||
| throw new IllegalStateException("getToken 响应缺少 access_token: " + root); | |||
| } | |||
| return token; | |||
| } | |||
| private PageData doGetRows(String url, String token, AggStation station) { | |||
| try { | |||
| return parseRows(getJson(url, token)); | |||
| } catch (RuntimeException e) { | |||
| log.warn("station={} first attempt failed: {}", station.getStationCode(), e.getMessage()); | |||
| String fresh = requestToken(station); | |||
| tokenCache.put(station.getStationCode(), new Token(fresh, System.currentTimeMillis() + 525_600_000L)); | |||
| return parseRows(getJson(url, fresh)); | |||
| } | |||
| } | |||
| /** 兼容多种响应形态:{data:[...]} / {data:{rows,total}} / {rows,total}(根级) */ | |||
| private PageData parseRows(JsonNode root) { | |||
| if (root.path("code").asInt() != 200) { | |||
| throw new IllegalStateException("接口返回错误 code=" + root.path("code").asText() | |||
| + " msg=" + root.path("msg").asText() + " data=" + root.path("data").asText()); | |||
| } | |||
| JsonNode node = root.has("data") ? root.get("data") : root; | |||
| long total = -1; | |||
| JsonNode arr = null; | |||
| if (node.isArray()) { | |||
| arr = node; | |||
| } else if (node != null && node.isObject()) { | |||
| if (node.has("rows")) { | |||
| arr = node.get("rows"); | |||
| total = node.path("total").asLong(-1); | |||
| } else if (node.has("row")) { | |||
| arr = node.get("row"); | |||
| total = node.path("total").asLong(-1); | |||
| } else if (node.has("list")) { | |||
| arr = node.get("list"); | |||
| total = node.path("total").asLong(-1); | |||
| } | |||
| } | |||
| if (arr == null || !arr.isArray()) { | |||
| throw new IllegalStateException("无法识别的响应结构,请人工核对接口返回: " + root); | |||
| } | |||
| List<JsonNode> rows = new ArrayList<>(); | |||
| arr.forEach(rows::add); | |||
| return new PageData(rows, total); | |||
| } | |||
| private JsonNode doGetJson(String url, AggStation station) { | |||
| try { | |||
| return getJson(url, ensureToken(station)); | |||
| } catch (RuntimeException e) { | |||
| log.warn("station={} GET first attempt failed: {}", station.getStationCode(), e.getMessage()); | |||
| String fresh = requestToken(station); | |||
| tokenCache.put(station.getStationCode(), new Token(fresh, System.currentTimeMillis() + 525_600_000L)); | |||
| return getJson(url, fresh); | |||
| } | |||
| } | |||
| private JsonNode doPostJson(String url, Object body, AggStation station) { | |||
| try { | |||
| return postJson(url, body, ensureToken(station)); | |||
| } catch (RuntimeException e) { | |||
| log.warn("station={} POST first attempt failed: {}", station.getStationCode(), e.getMessage()); | |||
| String fresh = requestToken(station); | |||
| tokenCache.put(station.getStationCode(), new Token(fresh, System.currentTimeMillis() + 525_600_000L)); | |||
| return postJson(url, body, fresh); | |||
| } | |||
| } | |||
| private JsonNode postJson(String url, Object body, String token) { | |||
| HttpHeaders headers = new HttpHeaders(); | |||
| headers.setContentType(MediaType.APPLICATION_JSON); | |||
| if (token != null) { | |||
| headers.set(HttpHeaders.AUTHORIZATION, "Bearer " + token); | |||
| } | |||
| ResponseEntity<String> resp = restTemplate.exchange(URI.create(url), HttpMethod.POST, new HttpEntity<>(body, headers), String.class); | |||
| try { | |||
| return objectMapper.readTree(resp.getBody()); | |||
| } catch (Exception e) { | |||
| throw new IllegalStateException("响应 JSON 解析失败: " + e.getMessage()); | |||
| } | |||
| } | |||
| private JsonNode getJson(String url, String token) { | |||
| HttpHeaders headers = new HttpHeaders(); | |||
| if (token != null) { | |||
| headers.set(HttpHeaders.AUTHORIZATION, "Bearer " + token); | |||
| } | |||
| ResponseEntity<String> resp = restTemplate.exchange(URI.create(url), HttpMethod.GET, new HttpEntity<>(headers), String.class); | |||
| try { | |||
| return objectMapper.readTree(resp.getBody()); | |||
| } catch (Exception e) { | |||
| throw new IllegalStateException("响应 JSON 解析失败: " + e.getMessage()); | |||
| } | |||
| } | |||
| /** 行唯一键:只取调用方指定的实体主键字段;均缺失时用 id */ | |||
| private static String rowKey(JsonNode r, String... idKeys) { | |||
| if (idKeys != null) { | |||
| for (String k : idKeys) { | |||
| if (r.has(k) && !r.get(k).isNull()) { | |||
| return r.get(k).asText(); | |||
| } | |||
| } | |||
| } | |||
| if (r.has("id") && !r.get("id").isNull()) { | |||
| return r.get("id").asText(); | |||
| } | |||
| return null; | |||
| } | |||
| private static String stripTrailingSlash(String base) { | |||
| return base == null ? "" : base.replaceAll("/+$", ""); | |||
| } | |||
| /** 分页结果(JDK8 兼容写法,替代 record) */ | |||
| private static final class PageData { | |||
| private final List<JsonNode> rows; | |||
| private final long total; | |||
| PageData(List<JsonNode> rows, long total) { | |||
| this.rows = rows; | |||
| this.total = total; | |||
| } | |||
| List<JsonNode> rows() { | |||
| return rows; | |||
| } | |||
| long total() { | |||
| return total; | |||
| } | |||
| } | |||
| } | |||
| @ -0,0 +1,23 @@ | |||
| package com.inspect.agg.station; | |||
| import com.fasterxml.jackson.annotation.JsonIgnoreProperties; | |||
| import com.fasterxml.jackson.annotation.JsonProperty; | |||
| import lombok.Data; | |||
| /** | |||
| * getToken 接口响应 data 部分。 | |||
| */ | |||
| @Data | |||
| @JsonIgnoreProperties(ignoreUnknown = true) | |||
| public class TokenData { | |||
| @JsonProperty("access_token") | |||
| private String accessToken; | |||
| @JsonProperty("token_type") | |||
| private String tokenType; | |||
| @JsonProperty("expires_in") | |||
| private Long expiresIn; | |||
| } | |||
| @ -0,0 +1,103 @@ | |||
| package com.inspect.agg.support; | |||
| import java.time.LocalDate; | |||
| import java.time.LocalDateTime; | |||
| import java.time.format.DateTimeFormatter; | |||
| import com.fasterxml.jackson.databind.JsonNode; | |||
| /** | |||
| * 站点响应行 JsonNode 字段读取工具(兼容 camelCase/snake_case、字符串数字)。 | |||
| */ | |||
| public final class Rows { | |||
| private static final DateTimeFormatter SRC_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); | |||
| private static final DateTimeFormatter SRC_DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd"); | |||
| private Rows() { | |||
| } | |||
| public static String text(JsonNode n, String... keys) { | |||
| for (String k : keys) { | |||
| JsonNode v = n.get(k); | |||
| if (v != null && !v.isNull() && v.isTextual()) { | |||
| return v.asText(); | |||
| } | |||
| } | |||
| return null; | |||
| } | |||
| public static Long longVal(JsonNode n, String... keys) { | |||
| for (String k : keys) { | |||
| JsonNode v = n.get(k); | |||
| if (v != null && !v.isNull() && v.isNumber()) { | |||
| return v.asLong(); | |||
| } | |||
| } | |||
| return null; | |||
| } | |||
| public static Integer intVal(JsonNode n, String... keys) { | |||
| for (String k : keys) { | |||
| JsonNode v = n.get(k); | |||
| if (v == null || v.isNull()) { | |||
| continue; | |||
| } | |||
| if (v.isNumber()) { | |||
| return v.asInt(); | |||
| } | |||
| if (v.isTextual()) { | |||
| try { | |||
| return Integer.parseInt(v.asText().trim()); | |||
| } catch (NumberFormatException ignore) { | |||
| // 非数字字符串按空处理 | |||
| } | |||
| } | |||
| } | |||
| return null; | |||
| } | |||
| public static LocalDateTime date(JsonNode n, String... keys) { | |||
| for (String k : keys) { | |||
| JsonNode v = n.get(k); | |||
| if (v != null && !v.isNull() && v.isTextual()) { | |||
| String s = v.asText().trim(); | |||
| if (!s.isEmpty()) { | |||
| try { | |||
| return LocalDateTime.parse(s, SRC_TIME); | |||
| } catch (Exception ignore) { | |||
| // ignore | |||
| } | |||
| try { | |||
| return LocalDate.parse(s, SRC_DATE).atStartOfDay(); | |||
| } catch (Exception ignore) { | |||
| // 非标准格式按 null 处理 | |||
| } | |||
| } | |||
| } | |||
| } | |||
| return null; | |||
| } | |||
| /** 文本优先,对象/数组转 JSON 字符串(如 summary) */ | |||
| public static String jsonText(JsonNode n, String... keys) { | |||
| for (String k : keys) { | |||
| JsonNode v = n.get(k); | |||
| if (v == null || v.isNull()) { | |||
| continue; | |||
| } | |||
| if (v.isTextual()) { | |||
| return v.asText(); | |||
| } | |||
| return v.toString(); | |||
| } | |||
| return null; | |||
| } | |||
| public static String abbrev(String s, int max) { | |||
| if (s == null) { | |||
| return null; | |||
| } | |||
| return s.length() <= max ? s : s.substring(0, max); | |||
| } | |||
| } | |||
| @ -0,0 +1,42 @@ | |||
| package com.inspect.agg.support; | |||
| import java.time.LocalDateTime; | |||
| import org.springframework.stereotype.Component; | |||
| import com.inspect.agg.entity.AggSyncLog; | |||
| import com.inspect.agg.mapper.AggSyncLogMapper; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * 同步运行日志写入(原生 MyBatis,XML 实现)。 | |||
| */ | |||
| @Slf4j | |||
| @Component | |||
| @RequiredArgsConstructor | |||
| public class SyncLogWriter { | |||
| private final AggSyncLogMapper aggSyncLogMapper; | |||
| public void write(String stationCode, String entity, String syncType, String status, | |||
| int rowCount, int deletedCount, String message, | |||
| LocalDateTime started, LocalDateTime finished) { | |||
| try { | |||
| AggSyncLog log = new AggSyncLog(); | |||
| log.setStationCode(stationCode); | |||
| log.setEntity(entity); | |||
| log.setSyncType(syncType); | |||
| log.setStatus(status); | |||
| log.setRowCount(rowCount); | |||
| log.setDeletedCount(deletedCount); | |||
| log.setMessage(message); | |||
| log.setStartedAt(started); | |||
| log.setFinishedAt(finished); | |||
| aggSyncLogMapper.insert(log); | |||
| } catch (Exception e) { | |||
| log.error("write agg_sync_log failed: {}", e.getMessage()); | |||
| } | |||
| } | |||
| } | |||
| @ -0,0 +1,16 @@ | |||
| package com.inspect.agg.video; | |||
| import lombok.Data; | |||
| /** | |||
| * 汇聚侧历史回放会话。 | |||
| */ | |||
| @Data | |||
| public class PlaybackSession { | |||
| private String stationCode; | |||
| private String cameraCode; | |||
| private String sessionId; | |||
| private String streamId; | |||
| private long createdAt = System.currentTimeMillis(); | |||
| } | |||
| @ -0,0 +1,55 @@ | |||
| package com.inspect.agg.video; | |||
| import java.util.ArrayList; | |||
| import java.util.Collections; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| import java.util.concurrent.ConcurrentHashMap; | |||
| import org.springframework.stereotype.Component; | |||
| import org.springframework.util.StringUtils; | |||
| /** | |||
| * 历史回放会话注册表。 | |||
| */ | |||
| @Component | |||
| public class PlaybackSessionRegistry { | |||
| private final Map<String, List<PlaybackSession>> sessions = new ConcurrentHashMap<>(); | |||
| public void add(String stationCode, String cameraCode, String sessionId, String streamId) { | |||
| PlaybackSession session = new PlaybackSession(); | |||
| session.setStationCode(stationCode); | |||
| session.setCameraCode(cameraCode); | |||
| session.setSessionId(sessionId); | |||
| session.setStreamId(streamId); | |||
| sessions.compute(key(stationCode, cameraCode), (ignored, current) -> { | |||
| List<PlaybackSession> list = current == null ? new ArrayList<>() : current; | |||
| list.removeIf(item -> sessionId.equals(item.getSessionId())); | |||
| list.add(0, session); | |||
| return list; | |||
| }); | |||
| } | |||
| public List<PlaybackSession> list(String stationCode, String cameraCode) { | |||
| List<PlaybackSession> current = sessions.get(key(stationCode, cameraCode)); | |||
| if (current == null || current.isEmpty()) { | |||
| return Collections.emptyList(); | |||
| } | |||
| return new ArrayList<>(current); | |||
| } | |||
| public void remove(String stationCode, String cameraCode, String sessionId) { | |||
| sessions.computeIfPresent(key(stationCode, cameraCode), (ignored, current) -> { | |||
| current.removeIf(item -> sessionId.equals(item.getSessionId())); | |||
| return current.isEmpty() ? null : current; | |||
| }); | |||
| } | |||
| private static String key(String stationCode, String cameraCode) { | |||
| String base = StringUtils.hasText(cameraCode) && cameraCode.contains("#") | |||
| ? cameraCode.substring(0, cameraCode.indexOf('#')) | |||
| : cameraCode; | |||
| return stationCode + "|" + base; | |||
| } | |||
| } | |||
| @ -0,0 +1,33 @@ | |||
| package com.inspect.agg.video; | |||
| import lombok.Data; | |||
| /** | |||
| * 汇聚实时流与站端实时流之间的绑定关系。 | |||
| */ | |||
| @Data | |||
| public class RealtimeStreamBinding { | |||
| /** 站点编码。 */ | |||
| private String stationCode; | |||
| /** 原始摄像机编码,保留 cameraCode 中 # 后的附加信息。 */ | |||
| private String cameraCode; | |||
| /** 汇聚 ZLMediaKit 中使用的确定性流 ID。 */ | |||
| private String aggStreamId; | |||
| /** 站端实时流 ID;直连 RTSP 模式为空。 */ | |||
| private String stationStreamId; | |||
| /** 汇聚 ZLMediaKit 拉取的源 RTSP 地址。 */ | |||
| private String sourceRtsp; | |||
| /** 流类型,透传给站端 stop 接口。 */ | |||
| private Integer streamType; | |||
| /** 是否由站端启动和停止;false 表示调用方直接传入 RTSP。 */ | |||
| private boolean stationManaged = true; | |||
| private long createdAt = System.currentTimeMillis(); | |||
| } | |||
| @ -0,0 +1,65 @@ | |||
| package com.inspect.agg.video; | |||
| import java.util.Map; | |||
| import java.util.concurrent.ConcurrentHashMap; | |||
| import org.springframework.stereotype.Component; | |||
| import org.springframework.util.StringUtils; | |||
| /** | |||
| * 实时流绑定注册表:按站点 + 摄像机维护汇聚流与站端流的关系。 | |||
| */ | |||
| @Component | |||
| public class RealtimeStreamRegistry { | |||
| private final Map<String, RealtimeStreamBinding> byCamera = new ConcurrentHashMap<>(); | |||
| private final Map<String, String> aggStreamIdToCameraKey = new ConcurrentHashMap<>(); | |||
| public static String cameraKey(String stationCode, String cameraCode) { | |||
| String base = cameraCode; | |||
| if (base != null && base.contains("#")) { | |||
| base = base.substring(0, base.indexOf('#')); | |||
| } | |||
| return stationCode + "|" + base; | |||
| } | |||
| public void put(RealtimeStreamBinding binding) { | |||
| if (binding == null | |||
| || !StringUtils.hasText(binding.getStationCode()) | |||
| || !StringUtils.hasText(binding.getCameraCode()) | |||
| || !StringUtils.hasText(binding.getAggStreamId())) { | |||
| throw new IllegalArgumentException("实时流绑定信息不完整"); | |||
| } | |||
| String key = cameraKey(binding.getStationCode(), binding.getCameraCode()); | |||
| byCamera.put(key, binding); | |||
| aggStreamIdToCameraKey.put(binding.getAggStreamId(), key); | |||
| } | |||
| public RealtimeStreamBinding getByCamera(String stationCode, String cameraCode) { | |||
| if (!StringUtils.hasText(stationCode) || !StringUtils.hasText(cameraCode)) { | |||
| return null; | |||
| } | |||
| return byCamera.get(cameraKey(stationCode, cameraCode)); | |||
| } | |||
| public RealtimeStreamBinding getByAggStreamId(String aggStreamId) { | |||
| String cameraKey = aggStreamIdToCameraKey.get(aggStreamId); | |||
| return cameraKey == null ? null : byCamera.get(cameraKey); | |||
| } | |||
| public RealtimeStreamBinding removeByAggStreamId(String aggStreamId) { | |||
| String cameraKey = aggStreamIdToCameraKey.remove(aggStreamId); | |||
| if (cameraKey == null) { | |||
| return null; | |||
| } | |||
| RealtimeStreamBinding binding = byCamera.remove(cameraKey); | |||
| if (binding == null) { | |||
| return null; | |||
| } | |||
| if (!aggStreamId.equals(binding.getAggStreamId())) { | |||
| // 正常不会发生;若同摄像机绑定已被替换,恢复反向索引,避免误删新绑定。 | |||
| aggStreamIdToCameraKey.put(binding.getAggStreamId(), cameraKey); | |||
| } | |||
| return binding; | |||
| } | |||
| } | |||
| @ -0,0 +1,340 @@ | |||
| package com.inspect.agg.video; | |||
| import java.net.MalformedURLException; | |||
| import java.net.URL; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| import java.util.concurrent.ConcurrentHashMap; | |||
| import org.springframework.stereotype.Component; | |||
| import org.springframework.util.StringUtils; | |||
| import com.fasterxml.jackson.databind.JsonNode; | |||
| import com.inspect.agg.dto.VideoRequest; | |||
| import com.inspect.agg.entity.AggStation; | |||
| import com.inspect.agg.service.StationRegistry; | |||
| import com.inspect.agg.station.StationApiClient; | |||
| import lombok.Data; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * 实时流生命周期服务:统一处理站端启动、汇聚代理、观看引用计数和停止清理。 | |||
| */ | |||
| @Slf4j | |||
| @Component | |||
| @RequiredArgsConstructor | |||
| public class RealtimeStreamService { | |||
| private final StationRegistry stationRegistry; | |||
| private final StationApiClient stationApiClient; | |||
| private final ZlmApiClient zlmApiClient; | |||
| private final RealtimeViewerRegistry viewerRegistry; | |||
| private final RealtimeStreamRegistry streamRegistry; | |||
| /** 单实例部署下按相机串行化 start/stop,避免并发重复创建代理。 */ | |||
| private final Map<String, Object> cameraLocks = new ConcurrentHashMap<>(); | |||
| public StartResult start(String stationCode, VideoRequest request) { | |||
| if (request == null | |||
| || !StringUtils.hasText(stationCode) | |||
| || !StringUtils.hasText(request.getCameraCode())) { | |||
| throw new IllegalArgumentException("stationCode / cameraCode 不能为空"); | |||
| } | |||
| String cameraCode = request.getCameraCode(); | |||
| Integer streamType = request.getStreamType() == null ? 2 : request.getStreamType(); | |||
| String directRtsp = request.getRtspUrl(); | |||
| AggStation station = stationRegistry.requireByCode(stationCode); | |||
| String aggStreamId = aggStreamId(stationCode, cameraCode); | |||
| synchronized (cameraLock(stationCode, cameraCode)) { | |||
| boolean mediaActive = zlmApiClient.streamExists(aggStreamId); | |||
| JsonNode existingProxy = zlmApiClient.findStreamProxy(aggStreamId); | |||
| boolean proxyActive = existingProxy != null; | |||
| RealtimeStreamBinding binding = streamRegistry.getByCamera(stationCode, cameraCode); | |||
| boolean stationStartAttempted = false; | |||
| try { | |||
| if (!mediaActive && !proxyActive) { | |||
| // 既无在线媒体也无拉流代理:调站端启动并新建汇聚代理。 | |||
| if (StringUtils.hasText(directRtsp)) { | |||
| binding = directBinding(stationCode, cameraCode, aggStreamId, directRtsp, streamType); | |||
| } else { | |||
| binding = startStationStream(station, cameraCode, aggStreamId, streamType); | |||
| stationStartAttempted = true; | |||
| } | |||
| // addStreamProxy 已对 already exists 幂等处理,兜底并发/残留同名代理。 | |||
| zlmApiClient.addStreamProxy(aggStreamId, binding.getSourceRtsp()); | |||
| } else if (binding == null) { | |||
| // ZLM 已有在线媒体或拉流代理,但本机绑定丢失(例如服务重启后)。 | |||
| // 不再重复调用站端 start(避免站端重复生成一路流),改用代理源地址补登绑定。 | |||
| if (StringUtils.hasText(directRtsp)) { | |||
| binding = directBinding(stationCode, cameraCode, aggStreamId, directRtsp, streamType); | |||
| } else { | |||
| String sourceRtsp = existingProxy == null ? null : existingProxy.path("url").asText(null); | |||
| binding = recoveredBinding(stationCode, cameraCode, aggStreamId, streamType, sourceRtsp); | |||
| } | |||
| } else { | |||
| binding.setAggStreamId(aggStreamId); | |||
| } | |||
| streamRegistry.put(binding); | |||
| viewerRegistry.add(aggStreamId); | |||
| // 媒体已注册可播放返回 PLAYING;代理已存在但尚未出流返回 STARTING。 | |||
| return new StartResult(aggStreamId, mediaActive); | |||
| } catch (RuntimeException e) { | |||
| if (stationStartAttempted) { | |||
| stopStationQuietly(station, binding, cameraCode, streamType, null); | |||
| } | |||
| throw e; | |||
| } | |||
| } | |||
| } | |||
| /** | |||
| * 停止一个观看者。 | |||
| * | |||
| * @return -1 没有观看票据;0 最后一个观看者已退出并停止流;正数表示剩余观看者数量 | |||
| */ | |||
| public int stop(String stationCode, VideoRequest request) { | |||
| if (request == null | |||
| || !StringUtils.hasText(stationCode) | |||
| || !StringUtils.hasText(request.getCameraCode())) { | |||
| throw new IllegalArgumentException("stationCode / cameraCode 不能为空"); | |||
| } | |||
| String cameraCode = request.getCameraCode(); | |||
| Integer streamType = request.getStreamType() == null ? 2 : request.getStreamType(); | |||
| AggStation station = stationRegistry.requireByCode(stationCode); | |||
| String aggStreamId = aggStreamId(stationCode, cameraCode); | |||
| synchronized (cameraLock(stationCode, cameraCode)) { | |||
| int remaining = viewerRegistry.remove(aggStreamId); | |||
| if (remaining != 0) { | |||
| return remaining; | |||
| } | |||
| boolean deleted; | |||
| try { | |||
| deleted = zlmApiClient.delStreamProxy(aggStreamId); | |||
| } catch (RuntimeException e) { | |||
| viewerRegistry.add(aggStreamId); | |||
| throw e; | |||
| } | |||
| if (!deleted) { | |||
| boolean stillExists; | |||
| try { | |||
| stillExists = zlmApiClient.streamExists(aggStreamId); | |||
| } catch (RuntimeException e) { | |||
| viewerRegistry.add(aggStreamId); | |||
| throw e; | |||
| } | |||
| if (stillExists) { | |||
| viewerRegistry.add(aggStreamId); | |||
| throw new IllegalStateException("汇聚 ZLMediaKit 流仍存在,停止失败"); | |||
| } | |||
| } | |||
| RealtimeStreamBinding binding = streamRegistry.removeByAggStreamId(aggStreamId); | |||
| viewerRegistry.clear(aggStreamId); | |||
| stopStationQuietly(station, binding, cameraCode, streamType, request.getRtspUrl()); | |||
| return 0; | |||
| } | |||
| } | |||
| /** 定时清理过期观看票据;仅清理没有活跃观看者的汇聚流,并尽力停止对应站端流。 */ | |||
| public void cleanupExpired() { | |||
| long now = System.currentTimeMillis(); | |||
| List<String> expiredStreams = viewerRegistry.expire(now); | |||
| for (String aggStreamId : expiredStreams) { | |||
| RealtimeStreamBinding existing = streamRegistry.getByAggStreamId(aggStreamId); | |||
| String lockKey = existing == null | |||
| ? "expired:" + aggStreamId | |||
| : RealtimeStreamRegistry.cameraKey(existing.getStationCode(), existing.getCameraCode()); | |||
| synchronized (cameraLocks.computeIfAbsent(lockKey, ignored -> new Object())) { | |||
| if (viewerRegistry.count(aggStreamId) > 0) { | |||
| continue; | |||
| } | |||
| boolean deleted; | |||
| try { | |||
| deleted = zlmApiClient.delStreamProxy(aggStreamId); | |||
| } catch (Exception e) { | |||
| log.warn("delete expired agg stream failed, stream={} err={}", aggStreamId, e.getMessage()); | |||
| continue; | |||
| } | |||
| if (!deleted) { | |||
| try { | |||
| if (zlmApiClient.streamExists(aggStreamId)) { | |||
| continue; | |||
| } | |||
| } catch (Exception e) { | |||
| log.warn("check expired agg stream failed, stream={} err={}", aggStreamId, e.getMessage()); | |||
| continue; | |||
| } | |||
| } | |||
| RealtimeStreamBinding binding = streamRegistry.removeByAggStreamId(aggStreamId); | |||
| viewerRegistry.clear(aggStreamId); | |||
| RealtimeStreamBinding target = binding != null ? binding : existing; | |||
| if (target == null) { | |||
| continue; | |||
| } | |||
| try { | |||
| AggStation station = stationRegistry.requireByCode(target.getStationCode()); | |||
| stopStationQuietly(station, target, target.getCameraCode(), target.getStreamType(), null); | |||
| log.info("realtime viewer tickets expired, close stream={}", aggStreamId); | |||
| } catch (Exception e) { | |||
| log.warn("stop expired station stream failed, stream={} err={}", aggStreamId, e.getMessage()); | |||
| } | |||
| } | |||
| } | |||
| } | |||
| private RealtimeStreamBinding startStationStream(AggStation station, String cameraCode, | |||
| String aggStreamId, Integer streamType) { | |||
| JsonNode root = stationApiClient.fetchRealtimeStart(station, cameraCode, streamType); | |||
| JsonNode payload = successPayload(root); | |||
| String stationStreamId = textAnyDeep(payload, "streamId", "StreamID", "streamID", "id"); | |||
| String sourceRtsp = textAnyDeep(payload, "RTSP", "rtspURL", "rtspUrl", "rtsp"); | |||
| if (!StringUtils.hasText(sourceRtsp)) { | |||
| String stationIp = extractIp(station.getBaseUrl()); | |||
| if (!StringUtils.hasText(stationStreamId) || !StringUtils.hasText(stationIp)) { | |||
| throw new IllegalStateException("站端未返回有效 streamId / RTSP 地址"); | |||
| } | |||
| sourceRtsp = String.format("rtsp://%s:8554/live/%s", stationIp, stationStreamId); | |||
| } | |||
| RealtimeStreamBinding binding = new RealtimeStreamBinding(); | |||
| binding.setStationCode(station.getStationCode()); | |||
| binding.setCameraCode(cameraCode); | |||
| binding.setAggStreamId(aggStreamId); | |||
| binding.setStationStreamId(stationStreamId); | |||
| binding.setSourceRtsp(sourceRtsp); | |||
| binding.setStreamType(streamType); | |||
| binding.setStationManaged(true); | |||
| return binding; | |||
| } | |||
| private RealtimeStreamBinding directBinding(String stationCode, String cameraCode, | |||
| String aggStreamId, String rtspUrl, Integer streamType) { | |||
| RealtimeStreamBinding binding = new RealtimeStreamBinding(); | |||
| binding.setStationCode(stationCode); | |||
| binding.setCameraCode(cameraCode); | |||
| binding.setAggStreamId(aggStreamId); | |||
| binding.setSourceRtsp(rtspUrl); | |||
| binding.setStreamType(streamType); | |||
| binding.setStationManaged(false); | |||
| return binding; | |||
| } | |||
| /** | |||
| * 服务重启后依据 ZLM 已有代理恢复内存绑定。 | |||
| * 此时不掌握站端 streamId,stop 时仅靠 cameraCode 尽力通知站端停止。 | |||
| */ | |||
| private RealtimeStreamBinding recoveredBinding(String stationCode, String cameraCode, | |||
| String aggStreamId, Integer streamType, String sourceRtsp) { | |||
| RealtimeStreamBinding binding = new RealtimeStreamBinding(); | |||
| binding.setStationCode(stationCode); | |||
| binding.setCameraCode(cameraCode); | |||
| binding.setAggStreamId(aggStreamId); | |||
| binding.setSourceRtsp(sourceRtsp); | |||
| binding.setStreamType(streamType); | |||
| binding.setStationManaged(true); | |||
| return binding; | |||
| } | |||
| private void stopStationQuietly(AggStation station, RealtimeStreamBinding binding, | |||
| String fallbackCameraCode, Integer fallbackStreamType, | |||
| String directRtsp) { | |||
| if (binding != null && !binding.isStationManaged()) { | |||
| return; | |||
| } | |||
| if (binding == null && StringUtils.hasText(directRtsp)) { | |||
| return; | |||
| } | |||
| String cameraCode = binding != null && StringUtils.hasText(binding.getCameraCode()) | |||
| ? binding.getCameraCode() : fallbackCameraCode; | |||
| String stationStreamId = binding == null ? null : binding.getStationStreamId(); | |||
| Integer streamType = binding != null && binding.getStreamType() != null | |||
| ? binding.getStreamType() : fallbackStreamType; | |||
| if (!StringUtils.hasText(cameraCode)) { | |||
| return; | |||
| } | |||
| try { | |||
| JsonNode root = stationApiClient.fetchRealtimeStop(station, stationStreamId, cameraCode, streamType); | |||
| if (root.has("code") && root.path("code").asInt(-1) != 200) { | |||
| log.warn("stop station realtime stream returned non-success, station={} camera={} resp={}", | |||
| station.getStationCode(), cameraCode, root); | |||
| } | |||
| } catch (Exception e) { | |||
| log.warn("stop station realtime stream failed, station={} camera={} stationStreamId={} err={}", | |||
| station.getStationCode(), cameraCode, stationStreamId, e.getMessage()); | |||
| } | |||
| } | |||
| private Object cameraLock(String stationCode, String cameraCode) { | |||
| String key = RealtimeStreamRegistry.cameraKey(stationCode, cameraCode); | |||
| return cameraLocks.computeIfAbsent(key, ignored -> new Object()); | |||
| } | |||
| private static JsonNode successPayload(JsonNode root) { | |||
| if (root.has("code") && root.path("code").asInt(-1) != 200) { | |||
| String message = root.path("msg").asText(root.path("message").asText("站端接口返回失败")); | |||
| throw new IllegalStateException(message); | |||
| } | |||
| JsonNode payload = root.path("data"); | |||
| return payload.isObject() ? payload : root; | |||
| } | |||
| private static String textAnyDeep(JsonNode node, String... fields) { | |||
| String value = textAny(node, fields); | |||
| if (!StringUtils.hasText(value) && node.path("data").isObject()) { | |||
| value = textAny(node.path("data"), fields); | |||
| } | |||
| return value; | |||
| } | |||
| private static String textAny(JsonNode node, String... fields) { | |||
| if (node == null || fields == null) { | |||
| return null; | |||
| } | |||
| for (String field : fields) { | |||
| String value = text(node, field); | |||
| if (StringUtils.hasText(value)) { | |||
| return value; | |||
| } | |||
| } | |||
| return null; | |||
| } | |||
| private static String text(JsonNode node, String field) { | |||
| JsonNode value = node == null ? null : node.get(field); | |||
| return value == null || value.isNull() ? null : value.asText(); | |||
| } | |||
| /** 汇聚实时流 ID:站点 + 摄像机;去除 cameraCode 中 # 后的附属信息。 */ | |||
| private static String aggStreamId(String stationCode, String cameraCode) { | |||
| String base = cameraCode.contains("#") ? cameraCode.substring(0, cameraCode.indexOf('#')) : cameraCode; | |||
| return (stationCode + "_" + base).replaceAll("[^A-Za-z0-9_]", "_"); | |||
| } | |||
| private static String extractIp(String urlString) { | |||
| try { | |||
| return new URL(urlString).getHost(); | |||
| } catch (MalformedURLException e) { | |||
| return null; | |||
| } | |||
| } | |||
| @Data | |||
| public static class StartResult { | |||
| private final String streamId; | |||
| private final boolean existing; | |||
| public StartResult(String streamId, boolean existing) { | |||
| this.streamId = streamId; | |||
| this.existing = existing; | |||
| } | |||
| } | |||
| } | |||
| @ -0,0 +1,105 @@ | |||
| package com.inspect.agg.video; | |||
| import java.util.ArrayList; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| import java.util.concurrent.ConcurrentHashMap; | |||
| import org.springframework.stereotype.Component; | |||
| /** | |||
| * 实时流观看会话注册表:一个 streamId 对应多个观看票据。 | |||
| * start 增加一个票据,stop 只减少一个票据,最后一个观看者退出时才允许删除 ZLM 流。 | |||
| */ | |||
| @Component | |||
| public class RealtimeViewerRegistry { | |||
| /** 观看票据过期时间;当前没有心跳接口,暂用 24 小时作为兜底清理。 */ | |||
| private static final long TICKET_TTL_MILLIS = 24L * 60 * 60 * 1000; | |||
| private final Map<String, List<Long>> tickets = new ConcurrentHashMap<>(); | |||
| private final Map<String, Long> lastActivity = new ConcurrentHashMap<>(); | |||
| public void add(String streamId) { | |||
| tickets.compute(streamId, (key, current) -> { | |||
| List<Long> list = current == null ? new ArrayList<>() : current; | |||
| synchronized (list) { | |||
| list.add(System.currentTimeMillis()); | |||
| } | |||
| return list; | |||
| }); | |||
| } | |||
| /** | |||
| * 移除一个观看票据。 | |||
| * | |||
| * @return -1 表示没有票据;0 表示本次移除后无人观看;大于 0 表示仍有 N 个观看者 | |||
| */ | |||
| public int remove(String streamId) { | |||
| final int[] remaining = {-2}; | |||
| tickets.compute(streamId, (key, current) -> { | |||
| if (current == null || current.isEmpty()) { | |||
| remaining[0] = -1; | |||
| return null; | |||
| } | |||
| synchronized (current) { | |||
| current.remove(current.size() - 1); | |||
| if (current.isEmpty()) { | |||
| remaining[0] = 0; | |||
| lastActivity.remove(streamId); | |||
| return null; | |||
| } | |||
| remaining[0] = current.size(); | |||
| return current; | |||
| } | |||
| }); | |||
| return remaining[0]; | |||
| } | |||
| public void touch(String streamId) { | |||
| if (streamId != null) { | |||
| lastActivity.put(streamId, System.currentTimeMillis()); | |||
| } | |||
| } | |||
| public long lastActivityAge(String streamId, long now) { | |||
| Long last = lastActivity.get(streamId); | |||
| return last == null ? Long.MAX_VALUE : now - last; | |||
| } | |||
| public void clear(String streamId) { | |||
| tickets.remove(streamId); | |||
| lastActivity.remove(streamId); | |||
| } | |||
| public int count(String streamId) { | |||
| List<Long> current = tickets.get(streamId); | |||
| if (current == null) { | |||
| return 0; | |||
| } | |||
| synchronized (current) { | |||
| return current.size(); | |||
| } | |||
| } | |||
| /** | |||
| * 清理过期票据,返回因此没有观看者的 streamId 列表。 | |||
| */ | |||
| public List<String> expire(long now) { | |||
| List<String> closed = new ArrayList<>(); | |||
| for (String streamId : new ArrayList<>(tickets.keySet())) { | |||
| tickets.computeIfPresent(streamId, (key, current) -> { | |||
| synchronized (current) { | |||
| current.removeIf(ticketTime -> now - ticketTime > TICKET_TTL_MILLIS); | |||
| if (current.isEmpty()) { | |||
| closed.add(streamId); | |||
| lastActivity.remove(streamId); | |||
| return null; | |||
| } | |||
| return current; | |||
| } | |||
| }); | |||
| } | |||
| return closed; | |||
| } | |||
| } | |||
| @ -0,0 +1,18 @@ | |||
| package com.inspect.agg.video; | |||
| import lombok.Data; | |||
| /** | |||
| * 站端转流信息(来自 /ivs/api/v1/stream/start)。 | |||
| */ | |||
| @Data | |||
| public class StationStream { | |||
| private String stationCode; | |||
| /** 站端 StreamID */ | |||
| private String stationStreamId; | |||
| /** 站端 ZLM 的 HTTP-FLV / HLS 地址 */ | |||
| private String flvUrl; | |||
| private String hlsUrl; | |||
| private long createdAt = System.currentTimeMillis(); | |||
| } | |||
| @ -0,0 +1,35 @@ | |||
| package com.inspect.agg.video; | |||
| import java.util.Map; | |||
| import java.util.concurrent.ConcurrentHashMap; | |||
| import org.springframework.stereotype.Component; | |||
| import org.springframework.util.StringUtils; | |||
| /** | |||
| * 会话内的站端流信息注册表(内存,进程重启后失效,前端重新调用 /play 即可)。 | |||
| */ | |||
| @Component | |||
| public class StationStreamRegistry { | |||
| private final Map<String, StationStream> streams = new ConcurrentHashMap<>(); | |||
| public static String key(String stationCode, String stationStreamId) { | |||
| return stationCode + "/" + stationStreamId; | |||
| } | |||
| public void put(StationStream stream) { | |||
| streams.put(key(stream.getStationCode(), stream.getStationStreamId()), stream); | |||
| } | |||
| public StationStream get(String stationCode, String streamId) { | |||
| if (!StringUtils.hasText(stationCode) || !StringUtils.hasText(streamId)) { | |||
| return null; | |||
| } | |||
| return streams.get(key(stationCode, streamId)); | |||
| } | |||
| public void remove(String stationCode, String streamId) { | |||
| streams.remove(key(stationCode, streamId)); | |||
| } | |||
| } | |||
| @ -0,0 +1,170 @@ | |||
| package com.inspect.agg.video; | |||
| import org.springframework.http.HttpEntity; | |||
| import org.springframework.http.HttpHeaders; | |||
| import org.springframework.http.HttpMethod; | |||
| import org.springframework.http.ResponseEntity; | |||
| import org.springframework.stereotype.Component; | |||
| import org.springframework.web.client.RestTemplate; | |||
| import org.springframework.web.util.UriComponentsBuilder; | |||
| import com.fasterxml.jackson.databind.JsonNode; | |||
| import com.fasterxml.jackson.databind.ObjectMapper; | |||
| import com.inspect.agg.config.AggProperties; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * ZLMediaKit API 客户端(汇聚侧)。 | |||
| * 仅依赖文档化接口 getMediaList/addStreamProxy/delStreamProxy, | |||
| * 以及新版 ZLM 提供的 listStreamProxy(旧版本不支持时自动降级)。 | |||
| */ | |||
| @Slf4j | |||
| @Component | |||
| @RequiredArgsConstructor | |||
| public class ZlmApiClient { | |||
| private static final String DEFAULT_VHOST = "__defaultVhost__"; | |||
| private final RestTemplate restTemplate; | |||
| private final ObjectMapper objectMapper; | |||
| private final AggProperties props; | |||
| /** 判断汇聚 ZLM 上在线媒体是否已存在(流已注册、可播放)。 */ | |||
| public boolean streamExists(String stream) { | |||
| AggProperties.Video v = props.getVideo(); | |||
| String url = UriComponentsBuilder.fromHttpUrl(api("/index/api/getMediaList")) | |||
| .queryParam("secret", v.getZlmSecret()) | |||
| .queryParam("vhost", DEFAULT_VHOST) | |||
| .queryParam("app", v.getApp()) | |||
| .queryParam("stream", stream) | |||
| .toUriString(); | |||
| JsonNode root = getJson(url); | |||
| for (JsonNode n : root.path("data")) { | |||
| if (stream.equals(n.path("stream").asText())) { | |||
| return true; | |||
| } | |||
| } | |||
| return false; | |||
| } | |||
| /** | |||
| * 按 key 精确查询拉流代理(listStreamProxy)。 | |||
| * 返回代理节点(含 key、src、url、status、status_str 等);不存在时返回 null。 | |||
| * 代理可能正在重试或源不可达,此时在线媒体(getMediaList)查不到,但代理对象已存在。 | |||
| * 旧版 ZLM 不支持该接口或调用异常时按“无代理”处理,交由 addStreamProxy 的幂等逻辑兜底。 | |||
| */ | |||
| public JsonNode findStreamProxy(String stream) { | |||
| AggProperties.Video v = props.getVideo(); | |||
| String key = proxyKey(stream); | |||
| String url = UriComponentsBuilder.fromHttpUrl(api("/index/api/listStreamProxy")) | |||
| .queryParam("secret", v.getZlmSecret()) | |||
| .queryParam("key", key) | |||
| .toUriString(); | |||
| try { | |||
| JsonNode root = getJson(url); | |||
| // 新版 ZLM 精确 key 未命中时返回 code=-400、key not found,按无代理处理。 | |||
| if (root.path("code").asInt(-1) != 0) { | |||
| return null; | |||
| } | |||
| for (JsonNode n : root.path("data")) { | |||
| if (stream.equals(n.path("src").path("stream").asText()) | |||
| || key.equals(n.path("key").asText())) { | |||
| return n; | |||
| } | |||
| } | |||
| return null; | |||
| } catch (Exception e) { | |||
| log.warn("ZLM listStreamProxy 查询失败,按无代理处理: stream={} err={}", stream, e.getMessage()); | |||
| return null; | |||
| } | |||
| } | |||
| /** 拉流代理配置是否已存在(不要求媒体已在线)。 */ | |||
| public boolean proxyExists(String stream) { | |||
| return findStreamProxy(stream) != null; | |||
| } | |||
| /** | |||
| * 添加拉流代理,返回 ZLM 的 key。 | |||
| * 若 ZLM 返回 “This stream already exists”(并发或残留同名代理),按幂等成功处理并返回确定性 key。 | |||
| */ | |||
| public String addStreamProxy(String stream, String sourceUrl) { | |||
| AggProperties.Video v = props.getVideo(); | |||
| String url = UriComponentsBuilder.fromHttpUrl(api("/index/api/addStreamProxy")) | |||
| .queryParam("secret", v.getZlmSecret()) | |||
| .queryParam("vhost", DEFAULT_VHOST) | |||
| .queryParam("app", v.getApp()) | |||
| .queryParam("stream", stream) | |||
| .queryParam("url", sourceUrl) | |||
| .queryParam("enable_hls", 1) | |||
| .queryParam("enable_rtsp", 1) | |||
| .queryParam("enable_rtmp", 1) | |||
| .queryParam("enable_mp4", 0) | |||
| .queryParam("rtsp_transport", v.getRtspTransport()) | |||
| .queryParam("timeout_sec", 10) | |||
| .queryParam("retry_count", v.getRetryCount()) | |||
| .toUriString(); | |||
| JsonNode root = getJson(url); | |||
| if (root.path("code").asInt(-1) != 0) { | |||
| String msg = root.path("msg").asText(); | |||
| if (msg != null && msg.toLowerCase().contains("already exists")) { | |||
| log.info("ZLM addStreamProxy 代理已存在,按复用处理: stream={} msg={}", stream, msg); | |||
| return proxyKey(stream); | |||
| } | |||
| throw new IllegalStateException("ZLM addStreamProxy 失败: " + msg); | |||
| } | |||
| return root.path("data").path("key").asText(proxyKey(stream)); | |||
| } | |||
| /** 关闭拉流代理(删除代理对象,即使媒体尚未注册成功也可删除)。 */ | |||
| public boolean delStreamProxy(String stream) { | |||
| AggProperties.Video v = props.getVideo(); | |||
| String key = proxyKey(stream); | |||
| JsonNode root = getJson(UriComponentsBuilder.fromHttpUrl(api("/index/api/delStreamProxy")) | |||
| .queryParam("secret", v.getZlmSecret()) | |||
| .queryParam("key", key) | |||
| .toUriString()); | |||
| boolean ok = root.path("code").asInt(-1) == 0 && root.path("data").path("flag").asBoolean(false); | |||
| if (!ok) { | |||
| log.warn("ZLM delStreamProxy 未成功: stream={} resp={}", stream, root); | |||
| } | |||
| return ok; | |||
| } | |||
| /** 汇聚 HLS 播放地址。 */ | |||
| public String hlsUrl(String stream) { | |||
| return publicBase() + "/" + props.getVideo().getApp() + "/" + stream + "/hls.m3u8"; | |||
| } | |||
| /** 汇聚 HTTP-FLV 播放地址。 */ | |||
| public String flvUrl(String stream) { | |||
| return publicBase() + "/" + props.getVideo().getApp() + "/" + stream + ".live.flv"; | |||
| } | |||
| private String proxyKey(String stream) { | |||
| return DEFAULT_VHOST + "/" + props.getVideo().getApp() + "/" + stream; | |||
| } | |||
| private String publicBase() { | |||
| AggProperties.Video v = props.getVideo(); | |||
| String base = v.getPublicBaseUrl() == null || v.getPublicBaseUrl().trim().isEmpty() | |||
| ? v.getZlmBaseUrl() : v.getPublicBaseUrl(); | |||
| return base.replaceAll("/+$", ""); | |||
| } | |||
| private String api(String path) { | |||
| return props.getVideo().getZlmBaseUrl().replaceAll("/+$", "") + path; | |||
| } | |||
| private JsonNode getJson(String url) { | |||
| ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.GET, | |||
| new HttpEntity<>(new HttpHeaders()), String.class); | |||
| try { | |||
| return objectMapper.readTree(resp.getBody()); | |||
| } catch (Exception e) { | |||
| throw new IllegalStateException("ZLM 响应解析失败: " + e.getMessage()); | |||
| } | |||
| } | |||
| } | |||
| @ -0,0 +1,251 @@ | |||
| package com.inspect.agg.web; | |||
| import java.util.ArrayList; | |||
| import java.util.Collections; | |||
| import java.util.HashMap; | |||
| import java.util.LinkedHashMap; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| import java.util.Set; | |||
| import java.util.stream.Collectors; | |||
| import org.springframework.util.StringUtils; | |||
| import org.springframework.web.bind.annotation.GetMapping; | |||
| import org.springframework.web.bind.annotation.PathVariable; | |||
| import org.springframework.web.bind.annotation.PostMapping; | |||
| import org.springframework.web.bind.annotation.RequestMapping; | |||
| import org.springframework.web.bind.annotation.RequestParam; | |||
| import org.springframework.web.bind.annotation.RestController; | |||
| import com.inspect.agg.entity.AggBasedataArea; | |||
| import com.inspect.agg.entity.AggBasedataDevice; | |||
| import com.inspect.agg.entity.AggBasedataDevicetype; | |||
| import com.inspect.agg.entity.AggBasedataEqpbook; | |||
| import com.inspect.agg.entity.AggBasedataEqpbookChannel; | |||
| import com.inspect.agg.entity.AggBasedataPatrolpoint; | |||
| import com.inspect.agg.entity.AggPatrolTask; | |||
| import com.inspect.agg.entity.AggPatrolTaskStatus; | |||
| import com.inspect.agg.mapper.AggBasedataAreaMapper; | |||
| import com.inspect.agg.mapper.AggBasedataDeviceMapper; | |||
| import com.inspect.agg.mapper.AggBasedataDevicetypeMapper; | |||
| import com.inspect.agg.mapper.AggBasedataEqpbookMapper; | |||
| import com.inspect.agg.mapper.AggBasedataEqpbookChannelMapper; | |||
| import com.inspect.agg.mapper.AggBasedataPatrolpointMapper; | |||
| import com.inspect.agg.mapper.AggPatrolTaskMapper; | |||
| import com.inspect.agg.mapper.AggPatrolTaskStatusMapper; | |||
| import com.inspect.agg.service.AreaSyncService; | |||
| import com.inspect.agg.service.ChannelSyncService; | |||
| import com.inspect.agg.service.DevicetypeSyncService; | |||
| import com.inspect.agg.service.DeviceSyncService; | |||
| import com.inspect.agg.service.EqpbookSyncService; | |||
| import com.inspect.agg.service.InspectionReportSyncService; | |||
| import com.inspect.agg.service.PatrolTaskSyncService; | |||
| import com.inspect.agg.service.PointSyncService; | |||
| import com.inspect.agg.service.AggAnalysisDefectMonthSyncService; | |||
| import com.inspect.agg.service.DefectRecordSyncService; | |||
| import com.inspect.agg.service.TaskExecSyncService; | |||
| import lombok.RequiredArgsConstructor; | |||
| /** | |||
| * 镜像数据查询与手动同步触发。 | |||
| * 说明:查询全部走 Mapper XML(无 QueryWrapper);大屏不直连站点,只读中心库。 | |||
| */ | |||
| @RestController | |||
| @RequestMapping("/api/agg") | |||
| @RequiredArgsConstructor | |||
| public class AggAreaController { | |||
| private final AggBasedataAreaMapper areaMapper; | |||
| private final AggBasedataDeviceMapper deviceMapper; | |||
| private final AggBasedataPatrolpointMapper pointMapper; | |||
| private final AggBasedataEqpbookMapper eqpbookMapper; | |||
| private final AggBasedataEqpbookChannelMapper channelMapper; | |||
| private final AggBasedataDevicetypeMapper devicetypeMapper; | |||
| private final AggPatrolTaskStatusMapper taskStatusMapper; | |||
| private final AggPatrolTaskMapper taskMapper; | |||
| private final AreaSyncService areaSyncService; | |||
| private final DeviceSyncService deviceSyncService; | |||
| private final PointSyncService pointSyncService; | |||
| private final EqpbookSyncService eqpbookSyncService; | |||
| private final ChannelSyncService channelSyncService; | |||
| private final DevicetypeSyncService devicetypeSyncService; | |||
| private final TaskExecSyncService taskExecSyncService; | |||
| private final PatrolTaskSyncService patrolTaskSyncService; | |||
| private final AggAnalysisDefectMonthSyncService resultAnalysisSyncService; | |||
| private final InspectionReportSyncService inspectionReportSyncService; | |||
| private final DefectRecordSyncService defectRecordSyncService; | |||
| /** 手动触发同步:/sync/{area|device|point|eqpbook|channel|devicetype|task|taskplan|analysis|report|all} */ | |||
| @PostMapping("/sync/{entity}") | |||
| public Map<String, Object> sync(@PathVariable String entity) { | |||
| switch (entity) { | |||
| case "area": | |||
| areaSyncService.syncAllStations(); | |||
| break; | |||
| case "device": | |||
| deviceSyncService.syncAllStations(); | |||
| break; | |||
| case "point": | |||
| pointSyncService.syncAllStations(); | |||
| break; | |||
| case "eqpbook": | |||
| eqpbookSyncService.syncAllStations(); | |||
| break; | |||
| case "channel": | |||
| channelSyncService.syncAllStations(); | |||
| break; | |||
| case "devicetype": | |||
| devicetypeSyncService.syncAllStations(); | |||
| break; | |||
| case "task": | |||
| taskExecSyncService.syncFullAllStations(); | |||
| break; | |||
| case "taskplan": | |||
| patrolTaskSyncService.syncFullAllStations(); | |||
| break; | |||
| case "analysis": | |||
| resultAnalysisSyncService.syncLast12MonthsAllStations(); | |||
| break; | |||
| case "report": | |||
| inspectionReportSyncService.syncAllStations(); | |||
| break; | |||
| case "defect": | |||
| defectRecordSyncService.syncFullAllStations(); | |||
| break; | |||
| default: | |||
| areaSyncService.syncAllStations(); | |||
| deviceSyncService.syncAllStations(); | |||
| pointSyncService.syncAllStations(); | |||
| eqpbookSyncService.syncAllStations(); | |||
| channelSyncService.syncAllStations(); | |||
| devicetypeSyncService.syncAllStations(); | |||
| taskExecSyncService.syncFullAllStations(); | |||
| patrolTaskSyncService.syncFullAllStations(); | |||
| resultAnalysisSyncService.syncLast12MonthsAllStations(); | |||
| inspectionReportSyncService.syncAllStations(); | |||
| defectRecordSyncService.syncFullAllStations(); | |||
| break; | |||
| } | |||
| Map<String, Object> m = new HashMap<>(); | |||
| m.put("code", 200); | |||
| m.put("msg", "sync triggered: " + entity); | |||
| return m; | |||
| } | |||
| @GetMapping("/area/list") | |||
| public Map<String, Object> areaList(@RequestParam(required = false) String stationCode, | |||
| @RequestParam(defaultValue = "500") int max) { | |||
| return ok(areaMapper.listByStation(stationCode, cap(max)), | |||
| areaMapper.countByStation(stationCode)); | |||
| } | |||
| /** 区域树(可选 stationCode 过滤站点,可选 rootId 指定根区域生成子树) */ | |||
| @GetMapping("/area/tree") | |||
| public Map<String, Object> areaTree(@RequestParam(required = false) String stationCode, | |||
| @RequestParam(required = false) Long rootId) { | |||
| List<AggBasedataArea> all = areaMapper.listForTree(stationCode); | |||
| Set<Long> ids = all.stream().map(AggBasedataArea::getAreaId).collect(Collectors.toSet()); | |||
| Map<Long, List<AggBasedataArea>> byParent = all.stream() | |||
| .collect(Collectors.groupingBy(a -> a.getParentId() == null ? 0L : a.getParentId())); | |||
| List<Map<String, Object>> roots = new ArrayList<>(); | |||
| if (rootId != null) { | |||
| for (AggBasedataArea a : all) { | |||
| if (a.getAreaId() != null && a.getAreaId().equals(rootId)) { | |||
| roots.add(toNode(a, byParent)); | |||
| break; | |||
| } | |||
| } | |||
| } else { | |||
| for (AggBasedataArea a : all) { | |||
| long pid = a.getParentId() == null ? 0L : a.getParentId(); | |||
| if (pid == 0L || !ids.contains(pid)) { | |||
| roots.add(toNode(a, byParent)); | |||
| } | |||
| } | |||
| } | |||
| Map<String, Object> m = ok(roots, all.size()); | |||
| return m; | |||
| } | |||
| @GetMapping("/device/list") | |||
| public Map<String, Object> deviceList(@RequestParam(required = false) String stationCode, | |||
| @RequestParam(required = false) Long areaId, | |||
| @RequestParam(defaultValue = "500") int max) { | |||
| return ok(deviceMapper.list(stationCode, areaId, cap(max)), | |||
| deviceMapper.count(stationCode, areaId)); | |||
| } | |||
| @GetMapping("/point/list") | |||
| public Map<String, Object> pointList(@RequestParam(required = false) String stationCode, | |||
| @RequestParam(required = false) Long deviceId, | |||
| @RequestParam(required = false) Long areaId, | |||
| @RequestParam(defaultValue = "500") int max) { | |||
| return ok(pointMapper.list(stationCode, deviceId, areaId, cap(max)), | |||
| pointMapper.count(stationCode, deviceId, areaId)); | |||
| } | |||
| @GetMapping("/eqpbook/list") | |||
| public Map<String, Object> eqpbookList(@RequestParam(required = false) String stationCode, | |||
| @RequestParam(defaultValue = "500") int max) { | |||
| return ok(eqpbookMapper.list(stationCode, cap(max)), eqpbookMapper.count(stationCode)); | |||
| } | |||
| @GetMapping("/channel/list") | |||
| public Map<String, Object> channelList(@RequestParam(required = false) String stationCode, | |||
| @RequestParam(defaultValue = "500") int max) { | |||
| return ok(channelMapper.list(stationCode, cap(max)), channelMapper.count(stationCode)); | |||
| } | |||
| @GetMapping("/devicetype/list") | |||
| public Map<String, Object> devicetypeList(@RequestParam(required = false) String stationCode, | |||
| @RequestParam(defaultValue = "500") int max) { | |||
| return ok(devicetypeMapper.list(stationCode, cap(max)), devicetypeMapper.count(stationCode)); | |||
| } | |||
| @GetMapping("/task/list") | |||
| public Map<String, Object> taskList(@RequestParam(required = false) String stationCode, | |||
| @RequestParam(required = false) String taskState, | |||
| @RequestParam(defaultValue = "500") int max) { | |||
| return ok(taskStatusMapper.list(stationCode, taskState, cap(max)), | |||
| taskStatusMapper.count(stationCode, taskState)); | |||
| } | |||
| @GetMapping("/taskplan/list") | |||
| public Map<String, Object> taskplanList(@RequestParam(required = false) String stationCode, | |||
| @RequestParam(defaultValue = "500") int max) { | |||
| return ok(taskMapper.list(stationCode, cap(max)), taskMapper.count(stationCode)); | |||
| } | |||
| private static int cap(int max) { | |||
| return Math.max(1, Math.min(max, 5000)); | |||
| } | |||
| private Map<String, Object> ok(Object data, long total) { | |||
| Map<String, Object> m = new HashMap<>(); | |||
| m.put("code", 200); | |||
| m.put("total", total); | |||
| m.put("data", data); | |||
| return m; | |||
| } | |||
| private Map<String, Object> toNode(AggBasedataArea a, Map<Long, List<AggBasedataArea>> byParent) { | |||
| Map<String, Object> node = new LinkedHashMap<>(); | |||
| node.put("areaId", a.getAreaId()); | |||
| node.put("parentId", a.getParentId()); | |||
| node.put("stationCode", a.getStationCode()); | |||
| node.put("stationName", a.getStationName() != null ? a.getStationName() : a.getStationCode()); | |||
| node.put("areaName", a.getAreaName()); | |||
| node.put("areaCode", a.getAreaCode()); | |||
| node.put("areaType", a.getAreaType()); | |||
| node.put("orderNum", a.getOrderNum()); | |||
| node.put("status", a.getStatus()); | |||
| List<Map<String, Object>> children = new ArrayList<>(); | |||
| for (AggBasedataArea c : byParent.getOrDefault(a.getAreaId(), Collections.<AggBasedataArea>emptyList())) { | |||
| children.add(toNode(c, byParent)); | |||
| } | |||
| node.put("children", children); | |||
| return node; | |||
| } | |||
| } | |||
| @ -0,0 +1,65 @@ | |||
| package com.inspect.agg.web; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| import org.springframework.web.bind.annotation.GetMapping; | |||
| import org.springframework.web.bind.annotation.RequestMapping; | |||
| import org.springframework.web.bind.annotation.RequestParam; | |||
| import org.springframework.web.bind.annotation.RestController; | |||
| import com.inspect.agg.calendar.AggTaskCalendarService; | |||
| import lombok.RequiredArgsConstructor; | |||
| /** | |||
| * 汇聚侧任务日历查询:实际状态 + 规则生成的未来计划。 | |||
| */ | |||
| @RestController | |||
| @RequestMapping("/agg/calender") | |||
| @RequiredArgsConstructor | |||
| public class AggCalendarController { | |||
| private final AggTaskCalendarService calendarService; | |||
| @GetMapping("/year") | |||
| public List<Map<String, Object>> year(@RequestParam Integer year, | |||
| @RequestParam(required = false) String stationCode) { | |||
| checkYear(year); | |||
| return calendarService.year(stationCode, year); | |||
| } | |||
| @GetMapping("/month") | |||
| public List<Map<String, Object>> month(@RequestParam Integer year, | |||
| @RequestParam Integer month, | |||
| @RequestParam(required = false) String stationCode) { | |||
| checkYear(year); | |||
| checkMonth(month); | |||
| return calendarService.month(stationCode, year, month); | |||
| } | |||
| @GetMapping("/day") | |||
| public List<Map<String, Object>> day(@RequestParam Integer year, | |||
| @RequestParam Integer month, | |||
| @RequestParam Integer day, | |||
| @RequestParam(required = false) String stationCode) { | |||
| checkYear(year); | |||
| checkMonth(month); | |||
| if (day == null || day < 1 || day > 31) { | |||
| throw new IllegalArgumentException("无效的日期"); | |||
| } | |||
| return calendarService.day(stationCode, year, month, day); | |||
| } | |||
| private static void checkYear(Integer year) { | |||
| if (year == null || year < 1900 || year > 2100) { | |||
| throw new IllegalArgumentException("无效的年份"); | |||
| } | |||
| } | |||
| private static void checkMonth(Integer month) { | |||
| if (month == null || month < 1 || month > 12) { | |||
| throw new IllegalArgumentException("无效的月份"); | |||
| } | |||
| } | |||
| } | |||
| @ -0,0 +1,55 @@ | |||
| package com.inspect.agg.web; | |||
| import java.util.List; | |||
| import org.springframework.web.bind.annotation.GetMapping; | |||
| import org.springframework.web.bind.annotation.RequestMapping; | |||
| import org.springframework.web.bind.annotation.RequestParam; | |||
| import org.springframework.web.bind.annotation.RestController; | |||
| import com.github.pagehelper.PageHelper; | |||
| import com.github.pagehelper.PageInfo; | |||
| import com.inspect.agg.entity.AggDefectRecord; | |||
| import com.inspect.agg.mapper.AggDefectRecordMapper; | |||
| import com.inspect.base.core.web.domain.AjaxResult; | |||
| import com.inspect.base.core.web.page.TableDataInfo; | |||
| import lombok.RequiredArgsConstructor; | |||
| /** | |||
| * Aggregated defect-record query, matching the station-side path and parameters. | |||
| */ | |||
| @RestController | |||
| @RequestMapping("/agg/analysis") | |||
| @RequiredArgsConstructor | |||
| public class AggDefectRecordController { | |||
| private final AggDefectRecordMapper defectRecordMapper; | |||
| @GetMapping("/defect/algNameOptions") | |||
| public AjaxResult algNameOptions(@RequestParam(required = false) String stationCode) { | |||
| return AjaxResult.success(defectRecordMapper.selectDistinctAlgNames(stationCode)); | |||
| } | |||
| @GetMapping("/defect/list") | |||
| public TableDataInfo defectList(@RequestParam(required = false) String stationCode, | |||
| @RequestParam(required = false) String pointName, | |||
| @RequestParam(required = false) String patrolTimeBegin, | |||
| @RequestParam(required = false) String patrolTimeEnd, | |||
| @RequestParam(required = false) String algName, | |||
| @RequestParam(defaultValue = "1") Integer pageNum, | |||
| @RequestParam(defaultValue = "10") Integer pageSize) { | |||
| int safePageNum = pageNum == null || pageNum < 1 ? 1 : pageNum; | |||
| int safePageSize = pageSize == null || pageSize < 1 ? 10 : pageSize; | |||
| PageHelper.startPage(safePageNum, safePageSize); | |||
| List<AggDefectRecord> list = defectRecordMapper.selectList( | |||
| stationCode, pointName, patrolTimeBegin, patrolTimeEnd, algName); | |||
| PageInfo<AggDefectRecord> pageInfo = new PageInfo<>(list); | |||
| TableDataInfo result = new TableDataInfo(); | |||
| result.setCode(200); | |||
| result.setMsg("Query Ok"); | |||
| result.setRows(list); | |||
| result.setTotal(pageInfo.getTotal()); | |||
| return result; | |||
| } | |||
| } | |||
| @ -0,0 +1,42 @@ | |||
| package com.inspect.agg.web; | |||
| import java.util.LinkedHashMap; | |||
| import java.util.Map; | |||
| import javax.servlet.http.HttpServletRequest; | |||
| import org.springframework.util.StringUtils; | |||
| import org.springframework.web.bind.annotation.GetMapping; | |||
| import org.springframework.web.bind.annotation.RequestMapping; | |||
| import org.springframework.web.bind.annotation.RequestParam; | |||
| import org.springframework.web.bind.annotation.RestController; | |||
| import com.inspect.agg.service.AggDeviceTreeService; | |||
| import com.inspect.base.core.web.domain.AjaxResult; | |||
| import lombok.RequiredArgsConstructor; | |||
| /** | |||
| * 汇聚侧区域-设备关系树,兼容站端 relation 接口。 | |||
| */ | |||
| @RestController | |||
| @RequestMapping("/basedata/prodevmnt/device") | |||
| @RequiredArgsConstructor | |||
| public class AggDeviceTreeController { | |||
| private final AggDeviceTreeService deviceTreeService; | |||
| @GetMapping("/treeAreaDeviceSelectFilterByPreset/relation") | |||
| public AjaxResult treeAreaDeviceSelectFilterByPresetRelation( | |||
| @RequestParam(required = false) String stationCode, | |||
| HttpServletRequest request) { | |||
| Map<String, String> params = new LinkedHashMap<>(); | |||
| request.getParameterMap().forEach((key, values) -> { | |||
| if (!"stationCode".equals(key) && !"pageNum".equals(key) && !"pageSize".equals(key) | |||
| && values != null && values.length > 0 && StringUtils.hasText(values[0])) { | |||
| params.put(key, values[0]); | |||
| } | |||
| }); | |||
| return AjaxResult.success(deviceTreeService.loadRelationTree(stationCode, params)); | |||
| } | |||
| } | |||
| @ -0,0 +1,53 @@ | |||
| package com.inspect.agg.web; | |||
| import java.util.List; | |||
| import org.springframework.web.bind.annotation.GetMapping; | |||
| import org.springframework.web.bind.annotation.RequestMapping; | |||
| import org.springframework.web.bind.annotation.RequestParam; | |||
| import org.springframework.web.bind.annotation.RestController; | |||
| import com.github.pagehelper.PageHelper; | |||
| import com.github.pagehelper.PageInfo; | |||
| import com.inspect.agg.entity.AggInspectionReport; | |||
| import com.inspect.agg.mapper.AggInspectionReportMapper; | |||
| import com.inspect.base.core.web.page.TableDataInfo; | |||
| import lombok.RequiredArgsConstructor; | |||
| /** | |||
| * 汇聚巡视报告查询。 | |||
| */ | |||
| @RestController | |||
| @RequestMapping({"/agg/insreport"}) | |||
| @RequiredArgsConstructor | |||
| public class AggInspectionReportController { | |||
| private final AggInspectionReportMapper reportMapper; | |||
| @GetMapping("/list") | |||
| public TableDataInfo list(@RequestParam(required = false) String stationCode, | |||
| @RequestParam(required = false) String cycleStartTime, | |||
| @RequestParam(required = false) String cycleEndTime, | |||
| @RequestParam(value = "inspection_task_name", required = false) | |||
| String inspectionTaskName, | |||
| @RequestParam(defaultValue = "1") Integer pageNum, | |||
| @RequestParam(defaultValue = "10") Integer pageSize) { | |||
| int safePageNum = pageNum == null || pageNum < 1 ? 1 : pageNum; | |||
| int safePageSize = pageSize == null || pageSize < 1 ? 10 : pageSize; | |||
| PageHelper.startPage(safePageNum, safePageSize); | |||
| List<AggInspectionReport> list = reportMapper.selectList( | |||
| stationCode, cycleStartTime, cycleEndTime, inspectionTaskName); | |||
| PageInfo<AggInspectionReport> pageInfo = new PageInfo<>(list); | |||
| return dataTable(list, pageInfo.getTotal()); | |||
| } | |||
| private static TableDataInfo dataTable(List<?> rows, long total) { | |||
| TableDataInfo result = new TableDataInfo(); | |||
| result.setCode(200); | |||
| result.setMsg("Query Ok"); | |||
| result.setRows(rows); | |||
| result.setTotal(total); | |||
| return result; | |||
| } | |||
| } | |||
| @ -0,0 +1,81 @@ | |||
| package com.inspect.agg.web; | |||
| import java.util.Collections; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| import java.util.stream.Collectors; | |||
| import org.springframework.web.bind.annotation.GetMapping; | |||
| import org.springframework.web.bind.annotation.RequestMapping; | |||
| import org.springframework.web.bind.annotation.RequestParam; | |||
| import org.springframework.web.bind.annotation.RestController; | |||
| import com.github.pagehelper.PageHelper; | |||
| import com.github.pagehelper.PageInfo; | |||
| import com.inspect.agg.dto.AggMonitorPatrolPointVo; | |||
| import com.inspect.agg.entity.AggAlgSubtype; | |||
| import com.inspect.agg.mapper.AggBasedataPatrolpointMapper; | |||
| import com.inspect.agg.mapper.BasedataAlgSubtypeMapper; | |||
| import com.inspect.base.core.utils.StringUtils; | |||
| import com.inspect.base.core.web.page.TableDataInfo; | |||
| import lombok.RequiredArgsConstructor; | |||
| /** | |||
| * Aggregated patrol point monitor relation query. | |||
| */ | |||
| @RestController | |||
| @RequestMapping("/agg/patrolpoint") | |||
| @RequiredArgsConstructor | |||
| public class AggPatrolPointQueryController { | |||
| private final AggBasedataPatrolpointMapper patrolPointMapper; | |||
| private final BasedataAlgSubtypeMapper basedataAlgSubtypeMapper; | |||
| @GetMapping("/list/monitor") | |||
| public TableDataInfo monitorRelationList(@RequestParam(required = false) String stationCode, | |||
| @RequestParam(required = false) Long deviceId, | |||
| @RequestParam(defaultValue = "1") Integer pageNum, | |||
| @RequestParam(defaultValue = "10") Integer pageSize) { | |||
| if (deviceId == null) { | |||
| return dataTable(Collections.emptyList(), 0L); | |||
| } | |||
| int safePageNum = pageNum == null || pageNum < 1 ? 1 : pageNum; | |||
| int safePageSize = pageSize == null || pageSize < 1 ? 10 : pageSize; | |||
| PageHelper.startPage(safePageNum, safePageSize); | |||
| List<AggMonitorPatrolPointVo> list = patrolPointMapper.selectMonitorRelationList(stationCode, deviceId); | |||
| fillAlgSubtypeName(list); | |||
| PageInfo<AggMonitorPatrolPointVo> pageInfo = new PageInfo<>(list); | |||
| return dataTable(list, pageInfo.getTotal()); | |||
| } | |||
| private void fillAlgSubtypeName(List<AggMonitorPatrolPointVo> list) { | |||
| if (list == null || list.isEmpty()) { | |||
| return; | |||
| } | |||
| Map<String, String> algSubtypeNameMap = basedataAlgSubtypeMapper.selectEnabledList().stream() | |||
| .collect(Collectors.toMap(item -> String.valueOf(item.getAlgSubtypeId()), | |||
| AggAlgSubtype::getAlgSubtypeName, (oldValue, newValue) -> oldValue)); | |||
| for (AggMonitorPatrolPointVo item : list) { | |||
| String algSubtypeIds = item.getAlgSubtypeIds(); | |||
| if (StringUtils.isNotEmpty(algSubtypeIds)) { | |||
| String algSubtypeName = java.util.Arrays.stream(algSubtypeIds.split(",")) | |||
| .map(String::trim) | |||
| .filter(id -> !id.isEmpty()) | |||
| .map(algSubtypeNameMap::get) | |||
| .filter(name -> name != null && !name.isEmpty()) | |||
| .collect(Collectors.joining(",")); | |||
| item.setAlgSubtypeName(algSubtypeName); | |||
| } | |||
| } | |||
| } | |||
| private static TableDataInfo dataTable(List<?> rows, long total) { | |||
| TableDataInfo result = new TableDataInfo(); | |||
| result.setCode(200); | |||
| result.setMsg("Query Ok"); | |||
| result.setRows(rows); | |||
| result.setTotal(total); | |||
| return result; | |||
| } | |||
| } | |||
| @ -0,0 +1,47 @@ | |||
| package com.inspect.agg.web; | |||
| import java.util.List; | |||
| import org.springframework.web.bind.annotation.GetMapping; | |||
| import org.springframework.web.bind.annotation.RequestMapping; | |||
| import org.springframework.web.bind.annotation.RequestParam; | |||
| import org.springframework.web.bind.annotation.RestController; | |||
| import com.github.pagehelper.PageHelper; | |||
| import com.github.pagehelper.PageInfo; | |||
| import com.inspect.agg.entity.AggPatrolTask; | |||
| import com.inspect.agg.mapper.AggPatrolTaskMapper; | |||
| import com.inspect.base.core.web.page.TableDataInfo; | |||
| import lombok.RequiredArgsConstructor; | |||
| /** | |||
| * 汇聚侧巡视方案列表,兼容站端 /patrol/task/list。 | |||
| */ | |||
| @RestController | |||
| @RequestMapping("/agg/task") | |||
| @RequiredArgsConstructor | |||
| public class AggPatrolTaskController { | |||
| private final AggPatrolTaskMapper taskMapper; | |||
| @GetMapping("/list") | |||
| public TableDataInfo list(@RequestParam(required = false) String stationCode, | |||
| @RequestParam(required = false) String taskName, | |||
| @RequestParam(defaultValue = "1") Integer pageNum, | |||
| @RequestParam(defaultValue = "10") Integer pageSize) { | |||
| int safePageNum = pageNum == null || pageNum < 1 ? 1 : pageNum; | |||
| int safePageSize = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 500); | |||
| PageHelper.startPage(safePageNum, safePageSize); | |||
| List<AggPatrolTask> list = taskMapper.selectTaskList(stationCode, taskName); | |||
| PageInfo<AggPatrolTask> pageInfo = new PageInfo<>(list); | |||
| TableDataInfo result = new TableDataInfo(); | |||
| result.setCode(200); | |||
| result.setMsg("Query Ok"); | |||
| result.setRows(list); | |||
| result.setTotal(pageInfo.getTotal()); | |||
| return result; | |||
| } | |||
| } | |||
| @ -0,0 +1,50 @@ | |||
| package com.inspect.agg.web; | |||
| import java.util.List; | |||
| import org.springframework.web.bind.annotation.GetMapping; | |||
| import org.springframework.web.bind.annotation.RequestMapping; | |||
| import org.springframework.web.bind.annotation.RequestParam; | |||
| import org.springframework.web.bind.annotation.RestController; | |||
| import com.github.pagehelper.PageHelper; | |||
| import com.github.pagehelper.PageInfo; | |||
| import com.inspect.agg.entity.AggPatrolTaskStatus; | |||
| import com.inspect.agg.mapper.AggPatrolTaskStatusMapper; | |||
| import com.inspect.base.core.web.page.TableDataInfo; | |||
| import lombok.RequiredArgsConstructor; | |||
| /** | |||
| * 汇聚侧巡视任务执行列表,兼容站端 /patrol/taskstatus/list 的分页返回协议。 | |||
| */ | |||
| @RestController | |||
| @RequestMapping("/agg/taskstatus") | |||
| @RequiredArgsConstructor | |||
| public class AggPatrolTaskStatusController { | |||
| private final AggPatrolTaskStatusMapper taskStatusMapper; | |||
| @GetMapping("/list") | |||
| public TableDataInfo list(@RequestParam(required = false) String stationCode, | |||
| @RequestParam(required = false) String taskName, | |||
| @RequestParam(required = false) String beginTime, | |||
| @RequestParam(required = false) String endTime, | |||
| @RequestParam(defaultValue = "1") Integer pageNum, | |||
| @RequestParam(defaultValue = "10") Integer pageSize) { | |||
| int safePageNum = pageNum == null || pageNum < 1 ? 1 : pageNum; | |||
| int safePageSize = pageSize == null || pageSize < 1 ? 10 : pageSize; | |||
| PageHelper.startPage(safePageNum, safePageSize); | |||
| List<AggPatrolTaskStatus> list = taskStatusMapper.selectTaskStatusList( | |||
| stationCode, taskName, beginTime, endTime); | |||
| PageInfo<AggPatrolTaskStatus> pageInfo = new PageInfo<>(list); | |||
| TableDataInfo result = new TableDataInfo(); | |||
| result.setCode(200); | |||
| result.setMsg("Query Ok"); | |||
| result.setRows(list); | |||
| result.setTotal(pageInfo.getTotal()); | |||
| return result; | |||
| } | |||
| } | |||
| @ -0,0 +1,34 @@ | |||
| package com.inspect.agg.web; | |||
| import java.util.List; | |||
| import org.springframework.web.bind.annotation.GetMapping; | |||
| import org.springframework.web.bind.annotation.RequestMapping; | |||
| import org.springframework.web.bind.annotation.RequestParam; | |||
| import org.springframework.web.bind.annotation.RestController; | |||
| import com.inspect.agg.entity.AggStation; | |||
| import com.inspect.agg.mapper.AggStationMapper; | |||
| import com.inspect.base.core.web.domain.AjaxResult; | |||
| import lombok.RequiredArgsConstructor; | |||
| /** | |||
| * 汇聚站点信息查询。 | |||
| */ | |||
| @RestController | |||
| @RequestMapping("/agg/station") | |||
| @RequiredArgsConstructor | |||
| public class AggStationController { | |||
| private final AggStationMapper stationMapper; | |||
| /** | |||
| * 查询站点信息;默认只返回启用站点。 | |||
| */ | |||
| @GetMapping("/all") | |||
| public AjaxResult all(@RequestParam(required = false) Integer enabled) { | |||
| List<AggStation> stations = stationMapper.selectByEnabled(enabled); | |||
| return AjaxResult.success(stations); | |||
| } | |||
| } | |||
| @ -0,0 +1,69 @@ | |||
| package com.inspect.agg.web; | |||
| import com.inspect.agg.mapper.AggBasedataEqpbookChannelMapper; | |||
| import com.inspect.agg.service.AggStatisticService; | |||
| import com.inspect.base.core.utils.StringUtils; | |||
| import com.inspect.base.core.web.domain.AjaxResult; | |||
| import lombok.RequiredArgsConstructor; | |||
| import org.springframework.web.bind.annotation.GetMapping; | |||
| import org.springframework.web.bind.annotation.RequestMapping; | |||
| import org.springframework.web.bind.annotation.RequestParam; | |||
| import org.springframework.web.bind.annotation.RestController; | |||
| import java.util.LinkedHashMap; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| @RestController | |||
| @RequestMapping("/agg/statistic") | |||
| @RequiredArgsConstructor | |||
| public class AggStatisticController { | |||
| private final AggStatisticService statisticService; | |||
| private final AggBasedataEqpbookChannelMapper channelMapper; | |||
| @GetMapping("/defect") | |||
| public AjaxResult defectStatistic(@RequestParam(required = false) String stationCode, | |||
| @RequestParam(required = false) String algCategory, | |||
| @RequestParam(defaultValue = "month") String dateType) { | |||
| try { | |||
| List<Map<String, Object>> data = statisticService.defectStatistic( | |||
| stationCode, algCategory, dateType); | |||
| return AjaxResult.success(data); | |||
| } catch (IllegalArgumentException e) { | |||
| return AjaxResult.error(e.getMessage()); | |||
| } | |||
| } | |||
| @GetMapping("/inspection") | |||
| public AjaxResult inspectionStatistic(@RequestParam(required = false) String stationCode, | |||
| @RequestParam(required = false) String algCategory) { | |||
| try { | |||
| return AjaxResult.success(statisticService.inspectionStatistic(stationCode, algCategory)); | |||
| } catch (IllegalArgumentException e) { | |||
| return AjaxResult.error(e.getMessage()); | |||
| } | |||
| } | |||
| /** | |||
| * 统计不同 deviceSource 的通道数量。 | |||
| * stationCode 为空时统计全部站点,传入时统计单站。 | |||
| * NULL、空字符串和纯空白统一归入“其他”。 | |||
| */ | |||
| @GetMapping("/channel") | |||
| public AjaxResult channelStatistic(@RequestParam(required = false) String stationCode) { | |||
| List<Map<String, Object>> items = channelMapper.countByDeviceSource(stationCode); | |||
| long totalCount = 0L; | |||
| for (Map<String, Object> item : items) { | |||
| Object count = item.get("count"); | |||
| if (count instanceof Number) { | |||
| totalCount += ((Number) count).longValue(); | |||
| } | |||
| } | |||
| Map<String, Object> data = new LinkedHashMap<>(); | |||
| data.put("totalCount", totalCount); | |||
| data.put("deviceSourceCounts", items); | |||
| return AjaxResult.success(data); | |||
| } | |||
| } | |||
| @ -0,0 +1,56 @@ | |||
| package com.inspect.agg.web; | |||
| import java.util.LinkedHashMap; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| import com.inspect.agg.service.AggDeviceTreeService; | |||
| import org.springframework.util.StringUtils; | |||
| import org.springframework.web.bind.annotation.GetMapping; | |||
| import org.springframework.web.bind.annotation.RequestMapping; | |||
| import org.springframework.web.bind.annotation.RequestParam; | |||
| import org.springframework.web.bind.annotation.RestController; | |||
| import com.inspect.agg.service.EqpbookTreeService; | |||
| import com.inspect.base.core.web.domain.AjaxResult; | |||
| import lombok.RequiredArgsConstructor; | |||
| import javax.servlet.http.HttpServletRequest; | |||
| /** | |||
| * 汇聚侧通道树,兼容站端 /basedata/eqpbook/getAreaEqubookTreeSelectForFilter。 | |||
| */ | |||
| @RestController | |||
| @RequestMapping("/agg/tree") | |||
| @RequiredArgsConstructor | |||
| public class AggTreeController { | |||
| private final EqpbookTreeService eqpbookTreeService; | |||
| private final AggDeviceTreeService deviceTreeService; | |||
| @GetMapping("/channel") | |||
| public AjaxResult getAreaEqubookTreeSelectForFilter( | |||
| @RequestParam(value = "patroldeviceName", required = false) String patrolDeviceName, | |||
| @RequestParam(value = "onlineStatus", required = false) String onlineStatus, | |||
| @RequestParam(value = "patroldeviceTypeFlag", required = false) String patrolDeviceTypeFlag, | |||
| @RequestParam(required = false) String stationCode) { | |||
| List<Map<String, Object>> tree = eqpbookTreeService.buildFilterTree( | |||
| stationCode, patrolDeviceName, onlineStatus, patrolDeviceTypeFlag); | |||
| return AjaxResult.success(tree); | |||
| } | |||
| @GetMapping("/device") | |||
| public AjaxResult treeAreaDeviceSelectFilterByPresetRelation( | |||
| @RequestParam(required = false) String stationCode, | |||
| HttpServletRequest request) { | |||
| Map<String, String> params = new LinkedHashMap<>(); | |||
| request.getParameterMap().forEach((key, values) -> { | |||
| if (!"stationCode".equals(key) && !"pageNum".equals(key) && !"pageSize".equals(key) | |||
| && values != null && values.length > 0 && StringUtils.hasText(values[0])) { | |||
| params.put(key, values[0]); | |||
| } | |||
| }); | |||
| return AjaxResult.success(deviceTreeService.loadRelationTree(stationCode, params)); | |||
| } | |||
| } | |||
| @ -0,0 +1,313 @@ | |||
| package com.inspect.agg.web; | |||
| import com.fasterxml.jackson.databind.JsonNode; | |||
| import com.inspect.agg.dto.VideoRequest; | |||
| import com.inspect.agg.entity.AggStation; | |||
| import com.inspect.agg.service.StationRegistry; | |||
| import com.inspect.agg.station.StationApiClient; | |||
| import com.inspect.agg.video.PlaybackSession; | |||
| import com.inspect.agg.video.PlaybackSessionRegistry; | |||
| import com.inspect.agg.video.RealtimeStreamService; | |||
| import com.inspect.agg.video.ZlmApiClient; | |||
| import com.inspect.base.core.web.domain.AjaxResult; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| import org.springframework.util.StringUtils; | |||
| import org.springframework.web.bind.annotation.*; | |||
| import java.net.URI; | |||
| import java.util.ArrayList; | |||
| import java.util.LinkedHashMap; | |||
| import java.util.List; | |||
| import java.util.Map; | |||
| /** | |||
| * 汇聚视频接口。 | |||
| */ | |||
| @Slf4j | |||
| @RestController | |||
| @RequestMapping("/agg/video") | |||
| @RequiredArgsConstructor | |||
| public class AggVideoController { | |||
| private final StationRegistry stationRegistry; | |||
| private final StationApiClient stationApiClient; | |||
| private final ZlmApiClient zlmApiClient; | |||
| private final PlaybackSessionRegistry playbackSessionRegistry; | |||
| private final RealtimeStreamService realtimeStreamService; | |||
| private static String resolvePlaybackUrl(AggStation station, String url) { | |||
| if (url.startsWith("http://") || url.startsWith("https://")) { | |||
| return url; | |||
| } | |||
| try { | |||
| String base = station.getBaseUrl().replaceAll("/+$", "") + "/"; | |||
| return URI.create(base).resolve(url).toString(); | |||
| } catch (Exception e) { | |||
| throw new IllegalArgumentException("无法解析站端 hlsUrl: " + url); | |||
| } | |||
| } | |||
| private static String playbackStreamId(String stationCode, String sessionId) { | |||
| return (stationCode + "_playback_" + sessionId).replaceAll("[^A-Za-z0-9_]", "_"); | |||
| } | |||
| private static String textAny(JsonNode node, String... fields) { | |||
| if (node == null || fields == null) { | |||
| return null; | |||
| } | |||
| for (String field : fields) { | |||
| String value = text(node, field); | |||
| if (StringUtils.hasText(value)) { | |||
| return value; | |||
| } | |||
| } | |||
| return null; | |||
| } | |||
| private static String text(JsonNode node, String field) { | |||
| JsonNode value = node == null ? null : node.get(field); | |||
| return value == null || value.isNull() ? null : value.asText(); | |||
| } | |||
| /** | |||
| * 只获取 RTSP 地址,供调试和排障使用。 | |||
| */ | |||
| @GetMapping("/rtsp") | |||
| public AjaxResult rtsp(@RequestParam String stationCode, | |||
| @RequestParam String cameraCode) { | |||
| if (!StringUtils.hasText(stationCode) || !StringUtils.hasText(cameraCode)) { | |||
| return AjaxResult.error(400, "stationCode / cameraCode 不能为空"); | |||
| } | |||
| AggStation station; | |||
| try { | |||
| station = stationRegistry.requireByCode(stationCode); | |||
| } catch (IllegalArgumentException e) { | |||
| return AjaxResult.error(404, e.getMessage()); | |||
| } | |||
| try { | |||
| JsonNode root = fetchStreamInfo(station, cameraCode, 2); | |||
| Map<String, Object> data = new LinkedHashMap<>(); | |||
| data.put("stationCode", stationCode); | |||
| data.put("cameraCode", cameraCode); | |||
| data.put("rtspURL", text(root, "rtspURL")); | |||
| data.put("flvURL", text(root, "flvURL")); | |||
| data.put("hlsURL", text(root, "hlsURL")); | |||
| return AjaxResult.success(data); | |||
| } catch (Exception e) { | |||
| log.error("fetch rtsp failed: station={} camera={} err={}", stationCode, cameraCode, e.getMessage()); | |||
| return AjaxResult.error(502, "获取 RTSP 地址失败: " + e.getMessage()); | |||
| } | |||
| } | |||
| /** | |||
| * 启动实时视频流。 | |||
| */ | |||
| @PostMapping("/realtime/start") | |||
| public AjaxResult start(@RequestParam String stationCode, | |||
| @RequestBody(required = false) VideoRequest request) { | |||
| if (request == null) { | |||
| return AjaxResult.error(400, "请求体不能为空"); | |||
| } | |||
| String cameraCode = request.getCameraCode(); | |||
| if (!StringUtils.hasText(stationCode) || !StringUtils.hasText(cameraCode)) { | |||
| return AjaxResult.error(400, "stationCode / cameraCode 不能为空"); | |||
| } | |||
| try { | |||
| RealtimeStreamService.StartResult result = realtimeStreamService.start(stationCode, request); | |||
| String streamId = result.getStreamId(); | |||
| String hlsUrl = cacheBustedHlsUrl(streamId); | |||
| String flvUrl = zlmApiClient.flvUrl(streamId); | |||
| Map<String, Object> data = new LinkedHashMap<>(); | |||
| data.put("stationCode", stationCode); | |||
| data.put("cameraCode", cameraCode); | |||
| data.put("streamId", streamId); | |||
| data.put("sourceMode", "RTSP_DIRECT"); | |||
| data.put("status", result.isExisting() ? "PLAYING" : "STARTING"); | |||
| data.put("hlsUrl", hlsUrl); | |||
| data.put("flvUrl", flvUrl); | |||
| data.put("hls", hlsUrl); | |||
| data.put("flv", flvUrl); | |||
| if (!result.isExisting()) { | |||
| return AjaxResult.success( | |||
| "已开始拉流;HLS 清单生成前请优先播放 FLV,或稍后重试 HLS", data); | |||
| } | |||
| return AjaxResult.success(data); | |||
| } catch (IllegalArgumentException e) { | |||
| return AjaxResult.error(404, e.getMessage()); | |||
| } catch (Exception e) { | |||
| log.error("video start failed: station={} camera={} err={}", stationCode, cameraCode, e.getMessage()); | |||
| return AjaxResult.error(502, "启动视频流失败: " + e.getMessage()); | |||
| } | |||
| } | |||
| /** | |||
| * 启动历史回放:站端创建 HLS 会话,汇聚 ZLM 拉取 HLS 并返回汇聚播放地址。 | |||
| */ | |||
| @GetMapping("/playback/start") | |||
| public AjaxResult playbackStart(@RequestParam String stationCode, | |||
| @RequestParam String cameraCode, | |||
| @RequestParam(required = false, defaultValue = "2") Integer streamType, | |||
| @RequestParam String startTime, | |||
| @RequestParam String endTime) { | |||
| if (!StringUtils.hasText(stationCode) || !StringUtils.hasText(cameraCode) | |||
| || !StringUtils.hasText(startTime) || !StringUtils.hasText(endTime)) { | |||
| return AjaxResult.error(400, "stationCode / cameraCode / startTime / endTime 不能为空"); | |||
| } | |||
| AggStation station; | |||
| try { | |||
| station = stationRegistry.requireByCode(stationCode); | |||
| } catch (IllegalArgumentException e) { | |||
| return AjaxResult.error(404, e.getMessage()); | |||
| } | |||
| try { | |||
| JsonNode root = stationApiClient.createHlsPlaybackSession( | |||
| station, cameraCode, streamType, startTime, endTime); | |||
| if (root.path("code").asInt(-1) != 200) { | |||
| throw new IllegalStateException("站端创建回放会话失败: " | |||
| + root.path("msg").asText(root.path("message").asText())); | |||
| } | |||
| JsonNode payload = root.path("data").isObject() ? root.get("data") : root; | |||
| String sessionId = text(payload, "sessionId"); | |||
| String hlsUrl = textAny(payload, "hlsUrl", "hlsURL", "HLS"); | |||
| if (!StringUtils.hasText(sessionId)) { | |||
| throw new IllegalStateException("站端未返回 sessionId"); | |||
| } | |||
| if (!StringUtils.hasText(hlsUrl)) { | |||
| throw new IllegalStateException("站端未返回 hlsUrl"); | |||
| } | |||
| String playbackUrl = resolvePlaybackUrl(station, hlsUrl); | |||
| String streamId = playbackStreamId(stationCode, sessionId); | |||
| boolean exists = zlmApiClient.streamExists(streamId); | |||
| if (!exists) { | |||
| zlmApiClient.addStreamProxy(streamId, playbackUrl); | |||
| } | |||
| playbackSessionRegistry.add(stationCode, cameraCode, sessionId, streamId); | |||
| Map<String, Object> data = new LinkedHashMap<>(); | |||
| data.put("stationCode", stationCode); | |||
| data.put("cameraCode", cameraCode); | |||
| data.put("sessionId", sessionId); | |||
| data.put("sessionStatus", text(payload, "status")); | |||
| data.put("streamId", streamId); | |||
| data.put("sourceMode", "HLS_PLAYBACK"); | |||
| data.put("status", exists ? "PLAYING" : "STARTING"); | |||
| data.put("generation", text(payload, "generation")); | |||
| data.put("speed", text(payload, "speed")); | |||
| data.put("actualTimeRange", payload.get("actualTimeRange")); | |||
| data.put("currentTime", text(payload, "currentTime")); | |||
| data.put("hls", cacheBustedHlsUrl(streamId)); | |||
| data.put("flv", zlmApiClient.flvUrl(streamId)); | |||
| if (!exists) { | |||
| return AjaxResult.success( | |||
| "已开始回放拉流;HLS 清单生成前请优先播放 FLV,或稍后重试 HLS", data); | |||
| } | |||
| return AjaxResult.success(data); | |||
| } catch (Exception e) { | |||
| log.error("video playback start failed: station={} camera={} err={}", | |||
| stationCode, cameraCode, e.getMessage()); | |||
| return AjaxResult.error(502, "启动历史回放失败: " + e.getMessage()); | |||
| } | |||
| } | |||
| /** | |||
| * 停止实时视频流。 | |||
| */ | |||
| @PostMapping("/realtime/stop") | |||
| public AjaxResult stop(@RequestParam String stationCode, | |||
| @RequestBody(required = false) VideoRequest request) { | |||
| if (request == null) { | |||
| return AjaxResult.error(400, "请求体不能为空"); | |||
| } | |||
| String cameraCode = request.getCameraCode(); | |||
| if (!StringUtils.hasText(stationCode) || !StringUtils.hasText(cameraCode)) { | |||
| return AjaxResult.error(400, "stationCode / cameraCode 不能为空"); | |||
| } | |||
| try { | |||
| int remaining = realtimeStreamService.stop(stationCode, request); | |||
| if (remaining == -1) { | |||
| return AjaxResult.success("未找到可停止的观看会话"); | |||
| } | |||
| if (remaining > 0) { | |||
| return AjaxResult.success("仍有 " + remaining + " 个观看会话,共享流已保留", false); | |||
| } | |||
| return AjaxResult.success(true); | |||
| } catch (IllegalArgumentException e) { | |||
| return AjaxResult.error(404, e.getMessage()); | |||
| } catch (Exception e) { | |||
| log.error("video stop failed: station={} camera={} err={}", stationCode, cameraCode, e.getMessage()); | |||
| return AjaxResult.error(502, "停止视频流失败: " + e.getMessage()); | |||
| } | |||
| } | |||
| /** | |||
| * 停止当前站点和摄像机的历史回放会话。 | |||
| */ | |||
| @GetMapping("/playback/stop") | |||
| public AjaxResult playbackStop(@RequestParam String stationCode, | |||
| @RequestParam String cameraCode) { | |||
| if (!StringUtils.hasText(stationCode) || !StringUtils.hasText(cameraCode)) { | |||
| return AjaxResult.error(400, "stationCode / cameraCode 不能为空"); | |||
| } | |||
| AggStation station; | |||
| try { | |||
| station = stationRegistry.requireByCode(stationCode); | |||
| } catch (IllegalArgumentException e) { | |||
| return AjaxResult.error(404, e.getMessage()); | |||
| } | |||
| List<PlaybackSession> sessions = playbackSessionRegistry.list(stationCode, cameraCode); | |||
| if (sessions.isEmpty()) { | |||
| return AjaxResult.error(404, "未找到可停止的历史回放会话"); | |||
| } | |||
| int stationStopped = 0; | |||
| int streamStopped = 0; | |||
| List<String> failures = new ArrayList<>(); | |||
| for (PlaybackSession session : sessions) { | |||
| try { | |||
| JsonNode root = stationApiClient.controlHlsPlaybackSession( | |||
| station, session.getSessionId(), session.getCameraCode(), "STOP"); | |||
| if (root.path("code").asInt(-1) != 200) { | |||
| throw new IllegalStateException(root.path("msg").asText(root.path("message").asText())); | |||
| } | |||
| stationStopped++; | |||
| } catch (Exception e) { | |||
| failures.add("station:" + session.getSessionId() + ":" + e.getMessage()); | |||
| } | |||
| try { | |||
| if (zlmApiClient.delStreamProxy(session.getStreamId())) { | |||
| streamStopped++; | |||
| } else { | |||
| failures.add("stream:" + session.getStreamId()); | |||
| } | |||
| } catch (Exception e) { | |||
| failures.add("stream:" + session.getStreamId() + ":" + e.getMessage()); | |||
| } | |||
| playbackSessionRegistry.remove(stationCode, cameraCode, session.getSessionId()); | |||
| } | |||
| if (!failures.isEmpty()) { | |||
| Map<String, Object> data = new LinkedHashMap<>(); | |||
| data.put("totalCount", sessions.size()); | |||
| data.put("stationStoppedCount", stationStopped); | |||
| data.put("streamStoppedCount", streamStopped); | |||
| return AjaxResult.error("部分历史回放会话停止失败: " + String.join("; ", failures), data); | |||
| } | |||
| return AjaxResult.success(true); | |||
| } | |||
| /** | |||
| * 每次启动返回不同的 m3u8 URL,避免浏览器或播放器复用已过期的切片清单。 | |||
| */ | |||
| private String cacheBustedHlsUrl(String streamId) { | |||
| String url = zlmApiClient.hlsUrl(streamId); | |||
| return url + (url.contains("?") ? "&" : "?") + "_ts=" + System.currentTimeMillis(); | |||
| } | |||
| private JsonNode fetchStreamInfo(AggStation station, String cameraCode, Integer streamType) { | |||
| JsonNode root = stationApiClient.fetchRtspUrl(station, cameraCode, streamType); | |||
| if (!StringUtils.hasText(text(root, "rtspURL")) && cameraCode.contains("#")) { | |||
| root = stationApiClient.fetchRtspUrl(station, cameraCode.substring(0, cameraCode.indexOf('#')), streamType); | |||
| } | |||
| return root; | |||
| } | |||
| } | |||
| @ -0,0 +1,146 @@ | |||
| package com.inspect.agg.web; | |||
| import java.nio.charset.StandardCharsets; | |||
| import java.util.Arrays; | |||
| import java.util.stream.Collectors; | |||
| import javax.servlet.http.HttpServletRequest; | |||
| import org.springframework.http.HttpEntity; | |||
| import org.springframework.http.HttpHeaders; | |||
| import org.springframework.http.HttpMethod; | |||
| import org.springframework.http.MediaType; | |||
| import org.springframework.http.ResponseEntity; | |||
| import org.springframework.util.StreamUtils; | |||
| import org.springframework.util.StringUtils; | |||
| import org.springframework.web.bind.annotation.RequestMapping; | |||
| import org.springframework.web.bind.annotation.RequestMethod; | |||
| import org.springframework.web.bind.annotation.RestController; | |||
| import org.springframework.web.client.HttpStatusCodeException; | |||
| import org.springframework.web.client.RestTemplate; | |||
| import com.inspect.agg.config.AggProperties; | |||
| import com.inspect.agg.entity.AggStation; | |||
| import com.inspect.agg.service.StationRegistry; | |||
| import com.inspect.agg.station.StationApiClient; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * 站点接口反向代理:调用方只传 stationCode,汇聚服务自动取站点配置 + token 转发, | |||
| * 并原样返回站点响应(含错误响应)。 | |||
| * | |||
| * 用法示例: | |||
| * GET /agg/proxy/patrol/video/realtime/start?stationCode=shaoxing&patroldeviceCode=xxx&channelCode=yyy | |||
| */ | |||
| @Slf4j | |||
| @RestController | |||
| @RequiredArgsConstructor | |||
| public class StationProxyController { | |||
| private static final String PREFIX = "/agg/proxy/"; | |||
| private final StationRegistry stationRegistry; | |||
| private final StationApiClient stationApiClient; | |||
| private final RestTemplate restTemplate; | |||
| private final AggProperties props; | |||
| @RequestMapping(value = "/agg/proxy/**", method = {RequestMethod.GET, RequestMethod.POST, | |||
| RequestMethod.PUT, RequestMethod.DELETE}) | |||
| public ResponseEntity<byte[]> proxy(HttpServletRequest request) { | |||
| String stationCode = request.getParameter("stationCode"); | |||
| if (!StringUtils.hasText(stationCode)) { | |||
| return error(400, "stationCode 不能为空"); | |||
| } | |||
| String uri = request.getRequestURI(); | |||
| String subPath = uri.startsWith(PREFIX) ? uri.substring(PREFIX.length()) : uri; | |||
| if (!StringUtils.hasText(subPath)) { | |||
| return error(400, "缺少站点侧接口路径"); | |||
| } | |||
| if (!allowed(subPath)) { | |||
| return error(403, "该路径不在代理白名单内: " + subPath); | |||
| } | |||
| AggStation station; | |||
| try { | |||
| station = stationRegistry.requireByCode(stationCode); | |||
| } catch (IllegalArgumentException e) { | |||
| return error(404, e.getMessage()); | |||
| } | |||
| String query = filterStationCode(request.getQueryString()); | |||
| String targetUrl = stripTrailingSlash(station.getBaseUrl()) + "/" + subPath | |||
| + (StringUtils.hasText(query) ? "?" + query : ""); | |||
| HttpHeaders headers = new HttpHeaders(); | |||
| String token = stationApiClient.ensureToken(station); | |||
| headers.set(HttpHeaders.AUTHORIZATION, "Bearer " + token); | |||
| String contentType = request.getContentType(); | |||
| if (StringUtils.hasText(contentType)) { | |||
| headers.set(HttpHeaders.CONTENT_TYPE, contentType); | |||
| } | |||
| String accept = request.getHeader(HttpHeaders.ACCEPT); | |||
| if (StringUtils.hasText(accept)) { | |||
| headers.set(HttpHeaders.ACCEPT, accept); | |||
| } | |||
| byte[] body = readBody(request); | |||
| HttpMethod method = HttpMethod.valueOf(request.getMethod()); | |||
| try { | |||
| ResponseEntity<byte[]> resp = restTemplate.exchange(targetUrl, method, | |||
| new HttpEntity<>(body, headers), byte[].class); | |||
| return relay(resp.getStatusCodeValue(), resp.getHeaders().getContentType(), resp.getBody()); | |||
| } catch (HttpStatusCodeException e) { | |||
| // 站点返回 4xx/5xx 也原样透传 | |||
| return relay(e.getRawStatusCode(), e.getResponseHeaders() == null ? null | |||
| : e.getResponseHeaders().getContentType(), e.getResponseBodyAsByteArray()); | |||
| } catch (Exception e) { | |||
| log.error("proxy failed: station={} path={} err={}", stationCode, subPath, e.getMessage()); | |||
| return error(502, "代理调用站点失败: " + e.getMessage()); | |||
| } | |||
| } | |||
| private boolean allowed(String subPath) { | |||
| if (props.getProxy() == null || props.getProxy().getAllowedPrefixes() == null | |||
| || props.getProxy().getAllowedPrefixes().isEmpty()) { | |||
| return true; | |||
| } | |||
| return props.getProxy().getAllowedPrefixes().stream().anyMatch(subPath::startsWith); | |||
| } | |||
| private static String filterStationCode(String rawQuery) { | |||
| if (!StringUtils.hasText(rawQuery)) { | |||
| return null; | |||
| } | |||
| return Arrays.stream(rawQuery.split("&")) | |||
| .filter(p -> !p.equals("stationCode") && !p.startsWith("stationCode=")) | |||
| .collect(Collectors.joining("&")); | |||
| } | |||
| private static byte[] readBody(HttpServletRequest request) { | |||
| try { | |||
| return StreamUtils.copyToByteArray(request.getInputStream()); | |||
| } catch (Exception e) { | |||
| return new byte[0]; | |||
| } | |||
| } | |||
| private static ResponseEntity<byte[]> relay(int status, MediaType contentType, byte[] body) { | |||
| HttpHeaders headers = new HttpHeaders(); | |||
| if (contentType != null) { | |||
| headers.setContentType(contentType); | |||
| } | |||
| return new ResponseEntity<>(body == null ? new byte[0] : body, headers, status); | |||
| } | |||
| private static ResponseEntity<byte[]> error(int status, String message) { | |||
| HttpHeaders headers = new HttpHeaders(); | |||
| headers.setContentType(MediaType.APPLICATION_JSON); | |||
| String json = "{\"code\":" + status + ",\"msg\":\"" + message.replace("\"", "'") + "\"}"; | |||
| return new ResponseEntity<>(json.getBytes(StandardCharsets.UTF_8), headers, status); | |||
| } | |||
| private static String stripTrailingSlash(String base) { | |||
| return base == null ? "" : base.replaceAll("/+$", ""); | |||
| } | |||
| } | |||
| @ -0,0 +1,358 @@ | |||
| package com.inspect.agg.web; | |||
| import java.io.InputStream; | |||
| import java.io.OutputStream; | |||
| import java.net.HttpURLConnection; | |||
| import java.net.URI; | |||
| import java.net.URL; | |||
| import java.net.URLEncoder; | |||
| import java.nio.charset.StandardCharsets; | |||
| import java.util.LinkedHashMap; | |||
| import java.util.Map; | |||
| import java.util.regex.Matcher; | |||
| import java.util.regex.Pattern; | |||
| import org.springframework.http.HttpHeaders; | |||
| import org.springframework.http.MediaType; | |||
| import org.springframework.http.ResponseEntity; | |||
| import org.springframework.util.StringUtils; | |||
| import org.springframework.web.bind.annotation.GetMapping; | |||
| import org.springframework.web.bind.annotation.RequestMapping; | |||
| import org.springframework.web.bind.annotation.RequestParam; | |||
| import org.springframework.web.bind.annotation.RestController; | |||
| import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; | |||
| import com.fasterxml.jackson.databind.JsonNode; | |||
| import com.inspect.agg.config.AggProperties; | |||
| import com.inspect.agg.entity.AggStation; | |||
| import com.inspect.agg.service.StationRegistry; | |||
| import com.inspect.agg.station.StationApiClient; | |||
| import com.inspect.agg.video.StationStream; | |||
| import com.inspect.agg.video.StationStreamRegistry; | |||
| import lombok.RequiredArgsConstructor; | |||
| import lombok.extern.slf4j.Slf4j; | |||
| /** | |||
| * 方案 B:集控纯 HTTP 反向代理(不拉流、不转码)。 | |||
| * 办公端只访问集控地址;集控把站端 HLS/FLV 流式转发,并重写 m3u8 中的分片地址。 | |||
| * | |||
| * GET /agg/video/play?stationCode=&cameraCode= 启动站端转流并返回集控播放地址 | |||
| * GET /agg/video/flv?stationCode=&streamId= HTTP-FLV 流式代理 | |||
| * GET /agg/video/hls?stationCode=&streamId= HLS 清单(重写分片地址) | |||
| * GET /agg/video/segment?stationCode=&streamId=&u= HLS 分片流式代理 | |||
| * GET /agg/video/proxy/stop?stationCode=&streamId= 停止站端转流 | |||
| */ | |||
| @Slf4j | |||
| @RestController | |||
| @RequestMapping("/agg/video/stream") | |||
| @RequiredArgsConstructor | |||
| public class VideoProxyController { | |||
| private static final Pattern TAG_URI = Pattern.compile("URI=\"([^\"]+)\""); | |||
| private final StationRegistry stationRegistry; | |||
| private final StationApiClient stationApiClient; | |||
| private final StationStreamRegistry streamRegistry; | |||
| private final AggProperties props; | |||
| /** 启动播放:调站端 /v1/stream/start,返回经集控代理的 HLS/FLV 地址 */ | |||
| @GetMapping("/play") | |||
| public Map<String, Object> play(@RequestParam String stationCode, | |||
| @RequestParam String cameraCode) { | |||
| if (!StringUtils.hasText(stationCode) || !StringUtils.hasText(cameraCode)) { | |||
| return error(400, "stationCode / cameraCode 不能为空"); | |||
| } | |||
| AggStation station; | |||
| try { | |||
| station = stationRegistry.requireByCode(stationCode); | |||
| } catch (IllegalArgumentException e) { | |||
| return error(404, e.getMessage()); | |||
| } | |||
| try { | |||
| JsonNode root = stationApiClient.fetchStreamStart(station, mainCode(cameraCode), 2); | |||
| if (!StringUtils.hasText(text(root, "rtspURL")) && !StringUtils.hasText(text(root, "FLV")) | |||
| && !StringUtils.hasText(text(root, "HLS"))) { | |||
| root = stationApiClient.fetchStreamStart(station, cameraCode, 2); | |||
| } | |||
| String stationStreamId = text(root, "StreamID"); | |||
| String flv = text(root, "FLV"); | |||
| String hls = text(root, "HLS"); | |||
| if (!StringUtils.hasText(stationStreamId)) { | |||
| return error(502, "站端未返回 StreamID: " + root); | |||
| } | |||
| StationStream stream = new StationStream(); | |||
| stream.setStationCode(stationCode); | |||
| stream.setStationStreamId(stationStreamId); | |||
| stream.setFlvUrl(flv); | |||
| stream.setHlsUrl(hls); | |||
| streamRegistry.put(stream); | |||
| Map<String, Object> data = new LinkedHashMap<>(); | |||
| data.put("stationCode", stationCode); | |||
| data.put("cameraCode", cameraCode); | |||
| data.put("streamId", stationStreamId); | |||
| data.put("mode", "HTTP_PROXY"); | |||
| if (StringUtils.hasText(hls)) { | |||
| data.put("hls", "/api/agg/video/hls?stationCode=" + enc(stationCode) + "&streamId=" + enc(stationStreamId)); | |||
| } | |||
| if (StringUtils.hasText(flv)) { | |||
| data.put("flv", "/api/agg/video/flv?stationCode=" + enc(stationCode) + "&streamId=" + enc(stationStreamId)); | |||
| } | |||
| Map<String, Object> m = new LinkedHashMap<>(); | |||
| m.put("code", 200); | |||
| m.put("data", data); | |||
| return m; | |||
| } catch (Exception e) { | |||
| log.error("video play failed: station={} camera={} err={}", stationCode, cameraCode, e.getMessage()); | |||
| return error(502, "启动视频流失败: " + e.getMessage()); | |||
| } | |||
| } | |||
| /** 停止站端转流 */ | |||
| @GetMapping("/stop") | |||
| public Map<String, Object> stop(@RequestParam String stationCode, @RequestParam String streamId) { | |||
| AggStation station; | |||
| try { | |||
| station = stationRegistry.requireByCode(stationCode); | |||
| } catch (IllegalArgumentException e) { | |||
| return error(404, e.getMessage()); | |||
| } | |||
| try { | |||
| stationApiClient.fetchStreamStop(station, streamId); | |||
| streamRegistry.remove(stationCode, streamId); | |||
| Map<String, Object> m = new LinkedHashMap<>(); | |||
| m.put("code", 200); | |||
| m.put("data", true); | |||
| return m; | |||
| } catch (Exception e) { | |||
| return error(502, "停止视频流失败: " + e.getMessage()); | |||
| } | |||
| } | |||
| /** HTTP-FLV 流式代理(不缓冲,边收边发) */ | |||
| @GetMapping("/flv") | |||
| public ResponseEntity<StreamingResponseBody> flv(@RequestParam String stationCode, | |||
| @RequestParam String streamId) { | |||
| StationStream stream = streamRegistry.get(stationCode, streamId); | |||
| if (stream == null || !StringUtils.hasText(stream.getFlvUrl())) { | |||
| return ResponseEntity.status(404).build(); | |||
| } | |||
| return streaming(stream.getFlvUrl(), "video/x-flv"); | |||
| } | |||
| /** HLS 清单:拉取站端 m3u8 并重写分片地址到集控代理 */ | |||
| @GetMapping("/hls") | |||
| public ResponseEntity<byte[]> hls(@RequestParam String stationCode, @RequestParam String streamId) { | |||
| StationStream stream = streamRegistry.get(stationCode, streamId); | |||
| if (stream == null || !StringUtils.hasText(stream.getHlsUrl())) { | |||
| return ResponseEntity.status(404).build(); | |||
| } | |||
| try { | |||
| PlaylistResult pr = readHlsWithFallback(stream.getHlsUrl()); | |||
| if (!pr.url.equals(stream.getHlsUrl())) { | |||
| // 记录实际生效的清单地址,后续分片按它的目录解析 | |||
| stream.setHlsUrl(pr.url); | |||
| } | |||
| String rewritten = rewritePlaylist(pr.content, stationCode, streamId, pr.url); | |||
| return ResponseEntity.ok() | |||
| .header(HttpHeaders.CONTENT_TYPE, "application/vnd.apple.mpegurl") | |||
| .header(HttpHeaders.CACHE_CONTROL, "no-store") | |||
| .body(rewritten.getBytes(StandardCharsets.UTF_8)); | |||
| } catch (Exception e) { | |||
| log.error("hls proxy failed: stream={} err={}", streamId, e.getMessage()); | |||
| return ResponseEntity.status(502).build(); | |||
| } | |||
| } | |||
| /** HLS 分片流式代理 */ | |||
| @GetMapping("/segment") | |||
| public ResponseEntity<StreamingResponseBody> segment(@RequestParam String stationCode, | |||
| @RequestParam String streamId, | |||
| @RequestParam("u") String uri) { | |||
| StationStream stream = streamRegistry.get(stationCode, streamId); | |||
| if (stream == null) { | |||
| return ResponseEntity.status(404).build(); | |||
| } | |||
| String target = resolve(stream.getHlsUrl(), uri); | |||
| if (target == null) { | |||
| return ResponseEntity.status(403).build(); | |||
| } | |||
| return streaming(target, null); | |||
| } | |||
| /** 把 m3u8 里所有 URI(分片/密钥/初始化段)改写成集控代理地址 */ | |||
| private String rewritePlaylist(String playlist, String stationCode, String streamId, String hlsUrl) { | |||
| StringBuilder sb = new StringBuilder(playlist.length() + 256); | |||
| for (String line : playlist.split("\n")) { | |||
| String trimmed = line.trim(); | |||
| if (trimmed.isEmpty()) { | |||
| continue; | |||
| } | |||
| if (trimmed.startsWith("#")) { | |||
| Matcher matcher = TAG_URI.matcher(trimmed); | |||
| if (matcher.find()) { | |||
| String rewritten = proxyUri(stationCode, streamId, matcher.group(1)); | |||
| sb.append(trimmed, 0, matcher.start(1)).append(rewritten).append(trimmed.substring(matcher.end(1))); | |||
| } else { | |||
| sb.append(trimmed); | |||
| } | |||
| } else { | |||
| sb.append(proxyUri(stationCode, streamId, trimmed)); | |||
| } | |||
| sb.append('\n'); | |||
| } | |||
| return sb.toString(); | |||
| } | |||
| private String proxyUri(String stationCode, String streamId, String uri) { | |||
| return "/api/agg/video/segment?stationCode=" + enc(stationCode) | |||
| + "&streamId=" + enc(streamId) + "&u=" + enc(uri); | |||
| } | |||
| /** 解析分片绝对地址(仅允许与站端 HLS 同 host:port,避免开放代理) */ | |||
| private String resolve(String hlsUrl, String uri) { | |||
| try { | |||
| URI base = URI.create(hlsUrl); | |||
| String baseDir = hlsUrl.substring(0, hlsUrl.lastIndexOf('/') + 1); | |||
| String target = uri.startsWith("http://") || uri.startsWith("https://") ? uri | |||
| : uri.startsWith("/") ? base.getScheme() + "://" + base.getAuthority() + uri | |||
| : baseDir + uri; | |||
| URI t = URI.create(target); | |||
| if (!base.getAuthority().equalsIgnoreCase(t.getAuthority())) { | |||
| log.warn("拒绝跨主机分片代理: {}", target); | |||
| return null; | |||
| } | |||
| return target; | |||
| } catch (Exception e) { | |||
| return null; | |||
| } | |||
| } | |||
| /** 流式转发:不缓冲,边读边写 */ | |||
| private ResponseEntity<StreamingResponseBody> streaming(String url, String contentType) { | |||
| StreamingResponseBody body = outputStream -> { | |||
| HttpURLConnection conn = null; | |||
| try { | |||
| conn = (HttpURLConnection) new URL(url).openConnection(); | |||
| conn.setConnectTimeout(props.getHttp().getConnectTimeoutMs()); | |||
| conn.setReadTimeout(props.getHttp().getReadTimeoutMs()); | |||
| conn.setInstanceFollowRedirects(true); | |||
| conn.setRequestProperty("User-Agent", "inspect-aggregation-proxy"); | |||
| if (StringUtils.hasText(contentType)) { | |||
| conn.setRequestProperty(HttpHeaders.ACCEPT, contentType); | |||
| } | |||
| try (InputStream in = conn.getInputStream()) { | |||
| copy(in, outputStream); | |||
| } | |||
| } catch (Exception e) { | |||
| log.warn("stream proxy interrupted: url={} err={}", url, e.getMessage()); | |||
| } finally { | |||
| if (conn != null) { | |||
| conn.disconnect(); | |||
| } | |||
| } | |||
| }; | |||
| ResponseEntity.BodyBuilder builder = ResponseEntity.ok() | |||
| .header(HttpHeaders.CACHE_CONTROL, "no-store") | |||
| .header("X-Accel-Buffering", "no"); | |||
| builder.header(HttpHeaders.CONTENT_TYPE, | |||
| StringUtils.hasText(contentType) ? contentType : MediaType.APPLICATION_OCTET_STREAM_VALUE); | |||
| return builder.body(body); | |||
| } | |||
| /** | |||
| * 兼容站端 HLS 路径风格差异:站端 start 接口可能返回 /live/{id}.m3u8(旧), | |||
| * 实际可用为 /live/{id}/hls.m3u8,反之亦然。 | |||
| */ | |||
| private PlaylistResult readHlsWithFallback(String hlsUrl) throws Exception { | |||
| try { | |||
| return new PlaylistResult(readString(hlsUrl), hlsUrl); | |||
| } catch (Exception first) { | |||
| for (String alt : hlsAlternatives(hlsUrl)) { | |||
| try { | |||
| log.info("HLS 主地址不可用,尝试备用地址: {}", alt); | |||
| return new PlaylistResult(readString(alt), alt); | |||
| } catch (Exception ignore) { | |||
| // 继续尝试 | |||
| } | |||
| } | |||
| throw first; | |||
| } | |||
| } | |||
| /** 清单内容 + 实际生效的清单地址 */ | |||
| private static final class PlaylistResult { | |||
| private final String content; | |||
| private final String url; | |||
| PlaylistResult(String content, String url) { | |||
| this.content = content; | |||
| this.url = url; | |||
| } | |||
| } | |||
| private static java.util.List<String> hlsAlternatives(String hlsUrl) { | |||
| java.util.List<String> list = new java.util.ArrayList<>(); | |||
| if (hlsUrl == null) { | |||
| return list; | |||
| } | |||
| if (hlsUrl.endsWith("/hls.m3u8")) { | |||
| // /live/{id}/hls.m3u8 -> /live/{id}.m3u8 | |||
| String base = hlsUrl.substring(0, hlsUrl.length() - "/hls.m3u8".length()); | |||
| list.add(base + ".m3u8"); | |||
| } else if (hlsUrl.endsWith(".m3u8")) { | |||
| // /live/{id}.m3u8 -> /live/{id}/hls.m3u8 | |||
| String base = hlsUrl.substring(0, hlsUrl.length() - ".m3u8".length()); | |||
| list.add(base + "/hls.m3u8"); | |||
| } | |||
| return list; | |||
| } | |||
| private String readString(String url) throws Exception { | |||
| HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); | |||
| conn.setConnectTimeout(props.getHttp().getConnectTimeoutMs()); | |||
| conn.setReadTimeout(props.getHttp().getReadTimeoutMs()); | |||
| conn.setRequestProperty("User-Agent", "inspect-aggregation-proxy"); | |||
| try (InputStream in = conn.getInputStream()) { | |||
| java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); | |||
| copy(in, bos); | |||
| return new String(bos.toByteArray(), StandardCharsets.UTF_8); | |||
| } finally { | |||
| conn.disconnect(); | |||
| } | |||
| } | |||
| private static void copy(InputStream in, OutputStream out) throws Exception { | |||
| byte[] buf = new byte[8192]; | |||
| int len; | |||
| while ((len = in.read(buf)) != -1) { | |||
| out.write(buf, 0, len); | |||
| out.flush(); | |||
| } | |||
| } | |||
| private static String mainCode(String cameraCode) { | |||
| return cameraCode.contains("#") ? cameraCode.substring(0, cameraCode.indexOf('#')) : cameraCode; | |||
| } | |||
| private static String enc(String s) { | |||
| try { | |||
| return URLEncoder.encode(s, "UTF-8"); | |||
| } catch (Exception e) { | |||
| return s; | |||
| } | |||
| } | |||
| private static String text(JsonNode node, String field) { | |||
| JsonNode v = node == null ? null : node.get(field); | |||
| return v == null || v.isNull() ? null : v.asText(); | |||
| } | |||
| private static Map<String, Object> error(int code, String msg) { | |||
| Map<String, Object> m = new LinkedHashMap<>(); | |||
| m.put("code", code); | |||
| m.put("msg", msg); | |||
| return m; | |||
| } | |||
| } | |||
| @ -0,0 +1,34 @@ | |||
| server: | |||
| port: 18088 | |||
| spring: | |||
| datasource: | |||
| driver-class-name: com.mysql.cj.jdbc.Driver | |||
| url: jdbc:mysql://127.0.0.1:3306/inspect_agg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true | |||
| username: root | |||
| password: htjc2018 | |||
| redis: | |||
| host: ${REDIS_HOST:127.0.0.1} | |||
| port: ${REDIS_PORT:6379} | |||
| database: ${REDIS_DATABASE:0} | |||
| timeout: 3000ms | |||
| mybatis: | |||
| mapper-locations: classpath*:mapper/**/*.xml | |||
| configuration: | |||
| map-underscore-to-camel-case: true | |||
| logging: | |||
| level: | |||
| com.inspect.agg: info | |||
| agg: | |||
| video: | |||
| zlm-base-url: http://192.168.1.116:33380 | |||
| zlm-secret: Xg@123456 | |||
| app: live | |||
| public-base-url: http://192.168.1.116:33380 | |||
| rtsp-transport: 0 | |||
| retry-count: -1 | |||
| @ -0,0 +1,429 @@ | |||
| -- ============================================================= | |||
| -- 中心库 inspect_agg 初始化脚本(区域试点) | |||
| -- 执行:mysql -h127.0.0.1 -uroot -p inspect_agg < schema.sql | |||
| -- 表名约定:中心镜像表 = 站点源表名 + _agg(如 agg_basedata_area);agg_station/agg_sync_log 为内部表 | |||
| -- ============================================================= | |||
| -- 区域镜像表(源表 basedata_area) | |||
| CREATE TABLE IF NOT EXISTS agg_basedata_area ( | |||
| id BIGINT NOT NULL AUTO_INCREMENT, | |||
| station_code VARCHAR(32) NOT NULL COMMENT '站点编码', | |||
| area_id BIGINT NOT NULL COMMENT '源区域 id', | |||
| parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父区域 id', | |||
| ancestors VARCHAR(255) DEFAULT '' COMMENT '祖级列表,如 ,1,2002', | |||
| area_name VARCHAR(64) DEFAULT '' COMMENT '区域名称', | |||
| area_code VARCHAR(64) DEFAULT NULL COMMENT '区域编码', | |||
| area_addr VARCHAR(255) DEFAULT NULL COMMENT '区域路径', | |||
| area_type VARCHAR(64) DEFAULT NULL COMMENT 'station/station_in/bayunit', | |||
| device_type_code VARCHAR(255) DEFAULT NULL COMMENT '生产区域编码', | |||
| order_num INT DEFAULT 0 COMMENT '显示顺序', | |||
| status CHAR(1) DEFAULT '0' COMMENT '0正常 1停用', | |||
| remark VARCHAR(255) DEFAULT NULL COMMENT '描述', | |||
| update_time DATETIME DEFAULT NULL COMMENT '站点侧 update_time', | |||
| synced_at DATETIME NOT NULL COMMENT '中心落库时间', | |||
| is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '0存在 1站点侧已删除(对账标记)', | |||
| PRIMARY KEY (id), | |||
| UNIQUE KEY uk_station_area (station_code, area_id), | |||
| KEY idx_station_parent (station_code, parent_id), | |||
| KEY idx_station_deleted (station_code, is_deleted) | |||
| ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '区域镜像表'; | |||
| -- 同步运行日志 | |||
| CREATE TABLE IF NOT EXISTS agg_sync_log ( | |||
| id BIGINT NOT NULL AUTO_INCREMENT, | |||
| station_code VARCHAR(32) NOT NULL COMMENT '站点编码', | |||
| entity VARCHAR(32) NOT NULL DEFAULT 'area' COMMENT '同步实体', | |||
| sync_type VARCHAR(16) DEFAULT 'FULL' COMMENT 'FULL/INCREMENT', | |||
| status VARCHAR(16) DEFAULT NULL COMMENT 'SUCCESS/FAILED', | |||
| row_count INT DEFAULT 0 COMMENT '本次拉取行数', | |||
| deleted_count INT DEFAULT 0 COMMENT '对账标记删除数', | |||
| message VARCHAR(255) DEFAULT NULL COMMENT '结果或错误信息', | |||
| started_at DATETIME DEFAULT NULL, | |||
| finished_at DATETIME DEFAULT NULL, | |||
| PRIMARY KEY (id), | |||
| KEY idx_station_entity (station_code, entity, started_at) | |||
| ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '同步运行日志'; | |||
| -- ============================================================= | |||
| -- 设备 / 点位 镜像表(基础数据,第二批) | |||
| -- ============================================================= | |||
| -- 设备镜像表(源表 basedata_device,接口 /basedata/prodevmnt/device/list) | |||
| CREATE TABLE IF NOT EXISTS agg_basedata_device ( | |||
| id BIGINT NOT NULL AUTO_INCREMENT, | |||
| station_code VARCHAR(32) NOT NULL COMMENT '站点编码', | |||
| device_id BIGINT NOT NULL COMMENT '源设备 id', | |||
| parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父设备 id', | |||
| ancestors VARCHAR(255) DEFAULT '' COMMENT '祖级列表', | |||
| area_id BIGINT DEFAULT NULL COMMENT '区域 id', | |||
| area_name VARCHAR(128) DEFAULT NULL COMMENT '区域名称', | |||
| device_code VARCHAR(64) DEFAULT NULL COMMENT '设备编号', | |||
| device_name VARCHAR(200) DEFAULT NULL COMMENT '设备名称', | |||
| device_type_name VARCHAR(64) DEFAULT NULL COMMENT '设备类型名称', | |||
| device_model VARCHAR(64) DEFAULT NULL COMMENT '设备型号', | |||
| manufacturer VARCHAR(64) DEFAULT NULL COMMENT '生产厂家', | |||
| power_main_equ_flag VARCHAR(5) DEFAULT NULL COMMENT '电力主设备标识 Yes/No', | |||
| station_no VARCHAR(32) DEFAULT NULL COMMENT '场站编号', | |||
| monitor_index_no VARCHAR(32) DEFAULT NULL COMMENT '监控索引号', | |||
| status CHAR(1) DEFAULT '0' COMMENT '0正常 1停用', | |||
| order_num INT DEFAULT 0 COMMENT '显示顺序', | |||
| update_time DATETIME DEFAULT NULL COMMENT '站点侧 update_time', | |||
| synced_at DATETIME NOT NULL COMMENT '中心落库时间', | |||
| is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '0存在 1站点侧已删除', | |||
| PRIMARY KEY (id), | |||
| UNIQUE KEY uk_station_device (station_code, device_id), | |||
| KEY idx_device_area (station_code, area_id), | |||
| KEY idx_device_deleted (station_code, is_deleted) | |||
| ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '设备镜像表'; | |||
| -- 巡检点位镜像表(源表 basedata_patrolpoint,接口 /basedata/patrolpointmnt/patrolpoint/list) | |||
| CREATE TABLE IF NOT EXISTS agg_basedata_patrolpoint ( | |||
| id BIGINT NOT NULL AUTO_INCREMENT, | |||
| station_code VARCHAR(32) NOT NULL COMMENT '站点编码', | |||
| patrol_point_id BIGINT NOT NULL COMMENT '源点位 id', | |||
| patrol_point_code VARCHAR(255) DEFAULT NULL COMMENT '点位编码', | |||
| patrol_point_name VARCHAR(255) DEFAULT NULL COMMENT '点位名称', | |||
| main_device_id BIGINT DEFAULT NULL COMMENT '主设备 id', | |||
| device_id BIGINT DEFAULT NULL COMMENT '设备 id(真正配置点位的设备/部件)', | |||
| area_id BIGINT DEFAULT NULL COMMENT '区域 id', | |||
| area_name VARCHAR(128) DEFAULT NULL COMMENT '区域名称', | |||
| patrol_content VARCHAR(255) DEFAULT NULL COMMENT '巡检内容', | |||
| purpose VARCHAR(255) DEFAULT NULL COMMENT '用途', | |||
| patrol_type_id VARCHAR(255) DEFAULT NULL COMMENT '巡视类型(逗号分隔)', | |||
| point_impt_level VARCHAR(10) DEFAULT NULL COMMENT '重要等级 Ⅰ/Ⅱ/Ⅲ', | |||
| alarm_threshold TEXT DEFAULT NULL COMMENT '告警阈值(JSON)', | |||
| alg_subtype_ids VARCHAR(200) DEFAULT NULL COMMENT 'Algorithm subtype IDs, comma-separated', | |||
| update_time DATETIME DEFAULT NULL COMMENT '站点侧 update_time', | |||
| synced_at DATETIME NOT NULL COMMENT '中心落库时间', | |||
| is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '0存在 1站点侧已删除', | |||
| PRIMARY KEY (id), | |||
| UNIQUE KEY uk_station_point (station_code, patrol_point_id), | |||
| KEY idx_point_device (station_code, device_id), | |||
| KEY idx_point_area (station_code, area_id), | |||
| KEY idx_point_deleted (station_code, is_deleted) | |||
| ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '巡检点位镜像表'; | |||
| -- ============================================================= | |||
| -- 台账 / 通道 / 设备类型 镜像表(基础数据,第三批) | |||
| -- ============================================================= | |||
| -- 设备台账镜像表(源表 basedata_eqpbook,接口 /basedata/eqpbook/list) | |||
| CREATE TABLE IF NOT EXISTS agg_basedata_eqpbook ( | |||
| id BIGINT NOT NULL AUTO_INCREMENT, | |||
| station_code VARCHAR(32) NOT NULL COMMENT '站点编码', | |||
| eqpbook_id BIGINT NOT NULL COMMENT '源台账 id', | |||
| patrol_device_code VARCHAR(255) DEFAULT NULL COMMENT '巡视设备编码', | |||
| patrol_device_name VARCHAR(255) DEFAULT NULL COMMENT '设备名称', | |||
| station_name VARCHAR(255) DEFAULT NULL COMMENT '变电站名称', | |||
| area_id BIGINT DEFAULT NULL COMMENT '区域 id', | |||
| area_name VARCHAR(255) DEFAULT NULL COMMENT '区域名称', | |||
| device_source VARCHAR(255) DEFAULT NULL COMMENT '设备来源', | |||
| device_model VARCHAR(255) DEFAULT NULL COMMENT '设备型号', | |||
| manufacturer VARCHAR(255) DEFAULT NULL COMMENT '生产厂家', | |||
| device_type VARCHAR(64) DEFAULT NULL COMMENT '设备类型(type)', | |||
| ip_addr VARCHAR(255) DEFAULT NULL COMMENT 'ip 地址', | |||
| port VARCHAR(64) DEFAULT NULL COMMENT '端口', | |||
| video_nvr_code VARCHAR(255) DEFAULT NULL COMMENT '所属 NVR 编码', | |||
| main_system_code VARCHAR(255) DEFAULT NULL COMMENT '主系统编号', | |||
| use_mode VARCHAR(255) DEFAULT NULL COMMENT '使用类型', | |||
| video_mode VARCHAR(255) DEFAULT NULL COMMENT '视频类型', | |||
| update_time DATETIME DEFAULT NULL COMMENT '站点侧 update_time', | |||
| synced_at DATETIME NOT NULL COMMENT '中心落库时间', | |||
| is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '0存在 1站点侧已删除', | |||
| PRIMARY KEY (id), | |||
| UNIQUE KEY uk_station_eqpbook (station_code, eqpbook_id), | |||
| KEY idx_eqpbook_area (station_code, area_id), | |||
| KEY idx_eqpbook_deleted (station_code, is_deleted) | |||
| ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '设备台账镜像表'; | |||
| -- 设备通道镜像表(源表 basedata_eqpbook_channel,接口 /basedata/eqpbook/channel/list) | |||
| CREATE TABLE IF NOT EXISTS agg_basedata_eqpbook_channel ( | |||
| id BIGINT NOT NULL AUTO_INCREMENT, | |||
| station_code VARCHAR(32) NOT NULL COMMENT '站点编码', | |||
| channel_id BIGINT NOT NULL COMMENT '源通道 id', | |||
| channel_code VARCHAR(255) DEFAULT NULL COMMENT '通道编码', | |||
| channel_name VARCHAR(255) DEFAULT NULL COMMENT '通道名称', | |||
| channel_type VARCHAR(255) DEFAULT NULL COMMENT '通道类型 vl/ir/ptz/ng', | |||
| online_state VARCHAR(4) DEFAULT NULL COMMENT '在线状态 1在线 0离线', | |||
| channel_content VARCHAR(255) DEFAULT NULL COMMENT '通道内容', | |||
| host VARCHAR(64) DEFAULT NULL COMMENT '主机 ip', | |||
| port VARCHAR(16) DEFAULT NULL COMMENT '端口', | |||
| area_id BIGINT DEFAULT NULL COMMENT '区域 id', | |||
| area_name VARCHAR(255) DEFAULT NULL COMMENT '区域名称', | |||
| eqpbook_id BIGINT DEFAULT NULL COMMENT '所属台账 id', | |||
| patrol_device_code VARCHAR(255) DEFAULT NULL COMMENT '巡视设备编码', | |||
| up_system_device_code VARCHAR(255) DEFAULT NULL COMMENT '上级系统编码', | |||
| device_source VARCHAR(255) DEFAULT NULL COMMENT '设备来源', | |||
| update_time DATETIME DEFAULT NULL COMMENT '站点侧 update_time', | |||
| synced_at DATETIME NOT NULL COMMENT '中心落库时间', | |||
| is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '0存在 1站点侧已删除', | |||
| PRIMARY KEY (id), | |||
| UNIQUE KEY uk_station_channel (station_code, channel_id), | |||
| KEY idx_channel_eqpbook (station_code, eqpbook_id), | |||
| KEY idx_channel_deleted (station_code, is_deleted) | |||
| ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '设备通道镜像表'; | |||
| -- 设备类型镜像表(源表 basedata_devicetype,接口 /basedata/devicetype/list) | |||
| CREATE TABLE IF NOT EXISTS agg_basedata_devicetype ( | |||
| id BIGINT NOT NULL AUTO_INCREMENT, | |||
| station_code VARCHAR(32) NOT NULL COMMENT '站点编码', | |||
| device_type_id BIGINT NOT NULL COMMENT '源设备类型 id', | |||
| parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父类型 id', | |||
| order_num INT DEFAULT 0 COMMENT '显示顺序', | |||
| ancestors VARCHAR(255) DEFAULT '' COMMENT '祖级列表', | |||
| ancestors_addr VARCHAR(255) DEFAULT NULL COMMENT '祖级路径', | |||
| device_type_name VARCHAR(64) DEFAULT NULL COMMENT '类型名称', | |||
| device_type_code VARCHAR(255) DEFAULT NULL COMMENT '类型编码', | |||
| power_main_equ_flag VARCHAR(5) DEFAULT NULL COMMENT '电力主设备标识 Yes/No', | |||
| phase_num VARCHAR(10) DEFAULT NULL COMMENT '相数', | |||
| phase VARCHAR(10) DEFAULT NULL COMMENT '相别', | |||
| status CHAR(1) DEFAULT '0' COMMENT '0正常 1停用', | |||
| profession_type VARCHAR(10) DEFAULT NULL COMMENT '专业类型', | |||
| level BIGINT DEFAULT NULL COMMENT '层级', | |||
| update_time DATETIME DEFAULT NULL COMMENT '站点侧 update_time', | |||
| synced_at DATETIME NOT NULL COMMENT '中心落库时间', | |||
| is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '0存在 1站点侧已删除', | |||
| PRIMARY KEY (id), | |||
| UNIQUE KEY uk_station_devicetype (station_code, device_type_id), | |||
| KEY idx_devicetype_deleted (station_code, is_deleted) | |||
| ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '设备类型镜像表'; | |||
| -- ============================================================= | |||
| -- 站点配置表 agg_station(DB 维护,替代 yaml agg.sites) | |||
| -- ============================================================= | |||
| CREATE TABLE IF NOT EXISTS agg_station ( | |||
| id BIGINT NOT NULL AUTO_INCREMENT, | |||
| station_code VARCHAR(32) NOT NULL COMMENT '站点编码(中心唯一)', | |||
| station_name VARCHAR(64) NOT NULL COMMENT '站点名称', | |||
| base_url VARCHAR(255) NOT NULL COMMENT '站点 API 根地址,以 / 结尾', | |||
| portal_url VARCHAR(255) DEFAULT NULL COMMENT '站端门户网址', | |||
| username VARCHAR(64) NOT NULL COMMENT '对接账号', | |||
| password VARCHAR(128) DEFAULT NULL COMMENT '密码(临时明文,正式化前轮换/加密)', | |||
| enabled TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', | |||
| PRIMARY KEY (id), | |||
| UNIQUE KEY uk_station_code (station_code) | |||
| ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '站点配置表'; | |||
| -- 种子:绍兴站(联调用;生产请按实际修改并轮换密码) | |||
| INSERT IGNORE INTO agg_station (station_code, station_name, base_url, username, password, enabled) | |||
| VALUES ('CZ000000004', '白杨坪风光电场', 'http://192.168.131.10:18889/api/', 'admin', 'admin@123', 1); | |||
| -- ============================================================= | |||
| -- 动态数据镜像表(任务执行 / 结果主表) | |||
| -- 站点侧无 update_time:不做时间水位;活跃子集高频拉取 + 全量慢周期对账 | |||
| -- ============================================================= | |||
| -- 任务执行状态镜像表(源表 patrol_task_status,接口 /patrol/taskstatus/list) | |||
| CREATE TABLE IF NOT EXISTS agg_patrol_task_status ( | |||
| id BIGINT NOT NULL AUTO_INCREMENT, | |||
| station_code VARCHAR(32) NOT NULL COMMENT '站点编码', | |||
| line_id BIGINT NOT NULL COMMENT '源 line_id', | |||
| task_patrolled_id VARCHAR(255) DEFAULT NULL COMMENT '巡视任务执行ID', | |||
| task_name VARCHAR(255) DEFAULT NULL COMMENT '任务名称', | |||
| task_code VARCHAR(255) DEFAULT NULL COMMENT '任务编码', | |||
| task_state VARCHAR(16) DEFAULT NULL COMMENT '1已执行 2执行中 3暂停 4终止 5未执行 6超期', | |||
| plan_start_time DATETIME DEFAULT NULL COMMENT '计划开始时间', | |||
| start_time DATETIME DEFAULT NULL COMMENT '开始时间', | |||
| end_time DATETIME DEFAULT NULL COMMENT '结束时间', | |||
| task_progress VARCHAR(64) DEFAULT NULL COMMENT '任务进度', | |||
| task_estimated_time VARCHAR(64) DEFAULT NULL COMMENT '预计剩余时间', | |||
| patrol_type VARCHAR(64) DEFAULT NULL COMMENT '巡视类型', | |||
| pos_type VARCHAR(16) DEFAULT NULL COMMENT '0视频 1机器人 2无人机', | |||
| code VARCHAR(255) DEFAULT NULL COMMENT '机器编码', | |||
| pos VARCHAR(255) DEFAULT NULL COMMENT '机器预置位号', | |||
| exec_type VARCHAR(16) DEFAULT NULL COMMENT '执行类型 1系统 0手动', | |||
| task_type VARCHAR(16) DEFAULT NULL COMMENT '0普通 1主 2子', | |||
| main_id VARCHAR(255) DEFAULT NULL COMMENT '主任务编码', | |||
| qx_num INT NOT NULL DEFAULT 0 COMMENT '缺陷点位数', | |||
| file_status VARCHAR(16) NOT NULL DEFAULT '0' COMMENT '归档状态 0未归档 1已归档', | |||
| summary VARCHAR(255) DEFAULT NULL COMMENT 'summary(JSON 摘要)', | |||
| description VARCHAR(255) DEFAULT NULL COMMENT '描述', | |||
| create_time DATETIME DEFAULT NULL COMMENT '站点侧 create_time', | |||
| synced_at DATETIME NOT NULL COMMENT '中心落库时间', | |||
| is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '0存在 1对账删除', | |||
| PRIMARY KEY (id), | |||
| UNIQUE KEY uk_station_line (station_code, line_id), | |||
| KEY idx_task_state (station_code, task_state), | |||
| KEY idx_task_deleted (station_code, is_deleted) | |||
| ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '任务执行状态镜像表'; | |||
| -- 结果主表镜像表(源表 patrol_task_result_main,接口 /patrol/resultmain/list) | |||
| -- ============================================================= | |||
| -- 任务方案镜像表(源 patrol_task,接口 /patrol/task/list) | |||
| -- ============================================================= | |||
| CREATE TABLE IF NOT EXISTS agg_patrol_task ( | |||
| id BIGINT NOT NULL AUTO_INCREMENT, | |||
| station_code VARCHAR(32) NOT NULL COMMENT '站点编码', | |||
| task_id BIGINT NOT NULL COMMENT '源任务 id', | |||
| task_code VARCHAR(200) DEFAULT NULL COMMENT '任务编码', | |||
| task_name VARCHAR(255) DEFAULT NULL COMMENT '任务名称', | |||
| type VARCHAR(255) DEFAULT NULL COMMENT '巡视类型', | |||
| priority VARCHAR(255) DEFAULT NULL COMMENT '优先级', | |||
| device_level VARCHAR(255) DEFAULT NULL COMMENT '设备层级', | |||
| device_list TEXT DEFAULT NULL COMMENT '设备列表(逗号分隔)', | |||
| fixed_start_time DATETIME DEFAULT NULL COMMENT '定期开始时间', | |||
| is_enable VARCHAR(16) DEFAULT NULL COMMENT '0可用 1不可用', | |||
| file_status VARCHAR(16) DEFAULT NULL COMMENT '状态', | |||
| execution_status VARCHAR(16) DEFAULT NULL COMMENT '执行状态', | |||
| task_cycle VARCHAR(255) DEFAULT NULL COMMENT '执行任务周期', | |||
| cycle_month VARCHAR(255) DEFAULT NULL COMMENT '周期-每月日期', | |||
| cycle_week VARCHAR(255) DEFAULT NULL COMMENT '周期-星期', | |||
| cycle_execute_time VARCHAR(255) DEFAULT NULL COMMENT '周期-执行时间', | |||
| cycle_start_time DATETIME DEFAULT NULL COMMENT '周期开始时间', | |||
| cycle_end_time DATETIME DEFAULT NULL COMMENT '周期结束时间', | |||
| interval_number INT DEFAULT NULL COMMENT '间隔数量', | |||
| interval_type VARCHAR(16) DEFAULT NULL COMMENT '间隔类型', | |||
| interval_execute_time VARCHAR(64) DEFAULT NULL COMMENT '间隔执行时间', | |||
| interval_start_time DATETIME DEFAULT NULL COMMENT '间隔开始时间', | |||
| interval_end_time DATETIME DEFAULT NULL COMMENT '间隔结束时间', | |||
| invalid_start_time DATETIME DEFAULT NULL COMMENT '不可用开始时间', | |||
| invalid_end_time DATETIME DEFAULT NULL COMMENT '不可用结束时间', | |||
| dev_type VARCHAR(255) DEFAULT NULL COMMENT '1机器人 0无人机 2视频', | |||
| area_id VARCHAR(255) DEFAULT NULL COMMENT '区域id', | |||
| area_name VARCHAR(255) DEFAULT NULL COMMENT '区域名称', | |||
| point_type VARCHAR(255) DEFAULT NULL COMMENT '算法大类 dict_code,多个值逗号分隔', | |||
| total_points INT NOT NULL DEFAULT 0 COMMENT '方案点位总数', | |||
| execution_mode VARCHAR(255) DEFAULT NULL COMMENT '执行模式', | |||
| drafter VARCHAR(255) DEFAULT NULL COMMENT '编制人', | |||
| specified_alg VARCHAR(255) DEFAULT NULL COMMENT '指定算法', | |||
| create_time DATETIME DEFAULT NULL COMMENT '站点侧 create_time', | |||
| update_time DATETIME DEFAULT NULL COMMENT '站点侧 update_time', | |||
| synced_at DATETIME NOT NULL COMMENT '中心落库时间', | |||
| is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '0存在 1对账删除', | |||
| PRIMARY KEY (id), | |||
| UNIQUE KEY uk_station_task (station_code, task_id), | |||
| KEY idx_task_deleted (station_code, is_deleted), | |||
| KEY idx_task_plan (station_code, is_enable, execution_status) | |||
| ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '任务方案镜像表'; | |||
| -- ============================================================= | |||
| -- 巡检分析结果镜像表(源表 result_analysis) | |||
| -- ============================================================= | |||
| CREATE TABLE IF NOT EXISTS agg_result_analysis ( | |||
| id BIGINT NOT NULL AUTO_INCREMENT, | |||
| station_code VARCHAR(32) NOT NULL COMMENT '站点编码', | |||
| line_id BIGINT NOT NULL COMMENT '源 line_id', | |||
| result_type VARCHAR(16) DEFAULT NULL COMMENT '0一般/严重/重大缺陷 1正常 2轻微缺陷', | |||
| business_id VARCHAR(255) DEFAULT NULL COMMENT '关联业务 ID', | |||
| result_content LONGTEXT DEFAULT NULL COMMENT '原始结果内容', | |||
| res_status VARCHAR(16) DEFAULT NULL COMMENT '结果状态', | |||
| point_status VARCHAR(16) DEFAULT NULL COMMENT '点位处理状态', | |||
| suggestion VARCHAR(255) DEFAULT NULL COMMENT '处理建议', | |||
| object_id VARCHAR(255) DEFAULT NULL COMMENT '点位/对象 ID', | |||
| description VARCHAR(255) DEFAULT NULL COMMENT '结果描述', | |||
| request_id VARCHAR(255) DEFAULT NULL COMMENT '请求 ID', | |||
| task_patrol_id VARCHAR(255) DEFAULT NULL COMMENT '巡检任务执行 ID', | |||
| alg_type VARCHAR(255) DEFAULT NULL COMMENT '算法类型', | |||
| res_value VARCHAR(255) DEFAULT NULL COMMENT '识别值', | |||
| res_code VARCHAR(64) DEFAULT NULL COMMENT '返回码', | |||
| res_conf VARCHAR(64) DEFAULT NULL COMMENT '置信度', | |||
| res_desc VARCHAR(255) DEFAULT NULL COMMENT '算法描述', | |||
| result VARCHAR(16) DEFAULT NULL COMMENT '结果标识', | |||
| res_img_url VARCHAR(255) DEFAULT NULL COMMENT '结果图片地址', | |||
| image_normal_url_path VARCHAR(255) DEFAULT NULL COMMENT '正常基准图片地址', | |||
| res_pos TEXT DEFAULT NULL COMMENT '识别位置', | |||
| filter VARCHAR(16) DEFAULT NULL COMMENT '0大模型 1初筛', | |||
| original_value LONGTEXT DEFAULT NULL COMMENT '原始值/纠偏值', | |||
| defect_level INT DEFAULT 0 COMMENT '0无 1重大 2严重 3一般 4轻微', | |||
| create_time DATETIME DEFAULT NULL COMMENT '分析结果创建时间', | |||
| synced_at DATETIME NOT NULL COMMENT '中心落库时间', | |||
| is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '0存在 1对账删除', | |||
| PRIMARY KEY (id), | |||
| UNIQUE KEY uk_station_result_line (station_code, line_id), | |||
| KEY idx_station_result_time (station_code, create_time), | |||
| KEY idx_station_result_group (station_code, task_patrol_id(191), object_id(64), alg_type(64)), | |||
| KEY idx_station_result_deleted (station_code, is_deleted) | |||
| ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '巡检分析结果镜像表'; | |||
| CREATE TABLE IF NOT EXISTS agg_inspection_report ( | |||
| id BIGINT NOT NULL AUTO_INCREMENT, | |||
| station_code VARCHAR(32) NOT NULL COMMENT '站点编码', | |||
| line_id BIGINT NOT NULL COMMENT '源 line_id', | |||
| station_name VARCHAR(255) DEFAULT NULL COMMENT '变电站名称', | |||
| volt_level VARCHAR(255) DEFAULT NULL COMMENT '电压等级', | |||
| station_type VARCHAR(255) DEFAULT NULL COMMENT '变电站类型', | |||
| inspection_date DATETIME DEFAULT NULL COMMENT '巡视计划创建日期', | |||
| inspection_task_name VARCHAR(255) DEFAULT NULL COMMENT '巡视任务名称', | |||
| env_info TEXT DEFAULT NULL COMMENT '环境信息', | |||
| inspection_start_time DATETIME DEFAULT NULL COMMENT '巡视开始时间', | |||
| inspection_end_time DATETIME DEFAULT NULL COMMENT '巡视结束时间', | |||
| patrol_statistics VARCHAR(255) DEFAULT NULL COMMENT '巡视统计', | |||
| check_person VARCHAR(255) DEFAULT NULL COMMENT '审核人', | |||
| check_time DATETIME DEFAULT NULL COMMENT '审核时间', | |||
| description TEXT DEFAULT NULL COMMENT '巡视结论', | |||
| file_path TEXT DEFAULT NULL COMMENT '报告文件路径', | |||
| task_id TEXT DEFAULT NULL COMMENT '巡视任务 ID', | |||
| task_result_id VARCHAR(255) DEFAULT NULL COMMENT '任务结果 ID', | |||
| task_patrolled_id VARCHAR(255) DEFAULT NULL COMMENT '任务执行 ID', | |||
| location VARCHAR(255) DEFAULT NULL COMMENT '位置', | |||
| filter VARCHAR(16) DEFAULT NULL COMMENT '过滤标识', | |||
| synced_at DATETIME NOT NULL COMMENT '中心落库时间', | |||
| is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '0存在 1对账删除', | |||
| PRIMARY KEY (id), | |||
| UNIQUE KEY uk_station_report_line (station_code, line_id), | |||
| KEY idx_station_report_date (station_code, inspection_date), | |||
| KEY idx_station_report_name (station_code, inspection_task_name), | |||
| KEY idx_station_report_deleted (station_code, is_deleted) | |||
| ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '巡视报告镜像表'; | |||
| CREATE TABLE IF NOT EXISTS agg_analysis_defect_month ( | |||
| id BIGINT NOT NULL AUTO_INCREMENT, | |||
| station_code VARCHAR(32) NOT NULL COMMENT '站点编码', | |||
| stat_month DATE NOT NULL COMMENT '统计月份(当月1日)', | |||
| point_type VARCHAR(255) NOT NULL DEFAULT '' COMMENT '算法大类字典编码,空字符串为总览', | |||
| minor_count INT NOT NULL DEFAULT 0 COMMENT '轻微缺陷数量', | |||
| general_count INT NOT NULL DEFAULT 0 COMMENT '一般缺陷数量', | |||
| serious_count INT NOT NULL DEFAULT 0 COMMENT '严重缺陷数量', | |||
| urgent_count INT NOT NULL DEFAULT 0 COMMENT '重大缺陷数量', | |||
| normal_count INT NOT NULL DEFAULT 0 COMMENT '正常数量', | |||
| synced_at DATETIME NOT NULL COMMENT '同步时间', | |||
| PRIMARY KEY (id), | |||
| UNIQUE KEY uk_station_month_point (station_code, stat_month, point_type), | |||
| KEY idx_station_month (station_code, stat_month) | |||
| ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '站端缺陷月度汇总表'; | |||
| CREATE TABLE IF NOT EXISTS agg_defect_record ( | |||
| id BIGINT NOT NULL AUTO_INCREMENT, | |||
| station_code VARCHAR(32) NOT NULL COMMENT 'Station code', | |||
| device_name VARCHAR(255) DEFAULT NULL COMMENT 'Device name', | |||
| channel_name VARCHAR(255) DEFAULT NULL COMMENT 'Channel name', | |||
| channel_code VARCHAR(255) DEFAULT NULL COMMENT 'Channel code', | |||
| device_source VARCHAR(255) DEFAULT NULL COMMENT 'Device source', | |||
| point_name VARCHAR(255) DEFAULT NULL COMMENT 'Point name', | |||
| point_id VARCHAR(64) DEFAULT NULL COMMENT 'Point ID', | |||
| warn_status VARCHAR(16) DEFAULT NULL COMMENT 'Warn status', | |||
| alg_name VARCHAR(255) DEFAULT NULL COMMENT 'Algorithm name', | |||
| res_value VARCHAR(255) DEFAULT NULL COMMENT 'Recognition value', | |||
| img VARCHAR(255) DEFAULT NULL COMMENT 'Display image', | |||
| filter_desc VARCHAR(255) DEFAULT NULL COMMENT 'Filter description', | |||
| bright_desc VARCHAR(255) DEFAULT NULL COMMENT 'Bright model description', | |||
| img_analyse VARCHAR(255) DEFAULT NULL COMMENT 'Analysis image', | |||
| bright_img_analyse VARCHAR(255) DEFAULT NULL COMMENT 'Bright analysis image', | |||
| defect_desc TEXT DEFAULT NULL COMMENT 'Merged defect descriptions', | |||
| line_id TEXT DEFAULT NULL COMMENT 'Source line IDs, comma-separated', | |||
| patrol_time DATETIME DEFAULT NULL COMMENT 'Patrol time', | |||
| point_status VARCHAR(16) DEFAULT NULL COMMENT 'Point status', | |||
| img_type VARCHAR(32) DEFAULT NULL COMMENT 'Image type', | |||
| data_type VARCHAR(32) DEFAULT NULL COMMENT 'Data type', | |||
| filter_value VARCHAR(16) DEFAULT NULL COMMENT 'Filter value', | |||
| request_id VARCHAR(255) DEFAULT NULL COMMENT 'Request ID', | |||
| res_status VARCHAR(16) DEFAULT NULL COMMENT 'Result status', | |||
| bright_res_status VARCHAR(32) DEFAULT NULL COMMENT 'Bright result status', | |||
| area_name VARCHAR(255) DEFAULT NULL COMMENT 'Area name', | |||
| area_id VARCHAR(64) DEFAULT NULL COMMENT 'Area ID', | |||
| alg_type VARCHAR(64) NOT NULL COMMENT 'Algorithm type', | |||
| object_id VARCHAR(64) NOT NULL COMMENT 'Object ID', | |||
| image_normal_url_path VARCHAR(255) DEFAULT NULL COMMENT 'Normal image path', | |||
| file_path VARCHAR(255) DEFAULT NULL COMMENT 'File path', | |||
| suggestion VARCHAR(255) DEFAULT NULL COMMENT 'Suggestion', | |||
| original_value LONGTEXT DEFAULT NULL COMMENT 'Original value', | |||
| task_patrol_id VARCHAR(64) NOT NULL COMMENT 'Task patrol ID', | |||
| task_name VARCHAR(255) DEFAULT NULL COMMENT 'Task name', | |||
| defect_level INT DEFAULT 0 COMMENT 'Defect level', | |||
| synced_at DATETIME NOT NULL COMMENT 'Center sync time', | |||
| is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '0 active, 1 deleted by reconciliation', | |||
| PRIMARY KEY (id), | |||
| UNIQUE KEY uk_station_defect_group (station_code, task_patrol_id, object_id, alg_type), | |||
| KEY idx_station_patrol_time (station_code, patrol_time), | |||
| KEY idx_station_point_name (station_code, point_name), | |||
| KEY idx_station_deleted (station_code, is_deleted) | |||
| ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'Defect record snapshot from station API'; | |||
| @ -0,0 +1,54 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> | |||
| <mapper namespace="com.inspect.agg.mapper.AggAnalysisDefectMonthMapper"> | |||
| <resultMap id="BaseResultMap" type="com.inspect.agg.entity.AggAnalysisDefectMonth"> | |||
| <id column="id" property="id"/> | |||
| <result column="station_code" property="stationCode"/> | |||
| <result column="agg_station_name" property="stationName"/> | |||
| <result column="stat_month" property="statMonth"/> | |||
| <result column="point_type" property="pointType"/> | |||
| <result column="minor_count" property="minorCount"/> | |||
| <result column="general_count" property="generalCount"/> | |||
| <result column="serious_count" property="seriousCount"/> | |||
| <result column="urgent_count" property="urgentCount"/> | |||
| <result column="normal_count" property="normalCount"/> | |||
| <result column="synced_at" property="syncedAt"/> | |||
| </resultMap> | |||
| <insert id="upsert" parameterType="com.inspect.agg.entity.AggAnalysisDefectMonth"> | |||
| INSERT INTO agg_analysis_defect_month | |||
| (station_code, stat_month, point_type, minor_count, general_count, | |||
| serious_count, urgent_count, normal_count, synced_at) | |||
| VALUES | |||
| (#{stationCode}, #{statMonth}, #{pointType}, #{minorCount}, #{generalCount}, | |||
| #{seriousCount}, #{urgentCount}, #{normalCount}, #{syncedAt}) | |||
| ON DUPLICATE KEY UPDATE | |||
| minor_count = VALUES(minor_count), | |||
| general_count = VALUES(general_count), | |||
| serious_count = VALUES(serious_count), | |||
| urgent_count = VALUES(urgent_count), | |||
| normal_count = VALUES(normal_count), | |||
| synced_at = VALUES(synced_at) | |||
| </insert> | |||
| <delete id="deleteRange"> | |||
| DELETE FROM agg_analysis_defect_month | |||
| WHERE station_code = #{stationCode} | |||
| AND stat_month >= #{beginMonth} | |||
| AND stat_month < #{endMonth} | |||
| </delete> | |||
| <select id="selectRange" resultMap="BaseResultMap"> | |||
| SELECT m.id, m.station_code, m.stat_month, m.point_type, m.minor_count, m.general_count, | |||
| m.serious_count, m.urgent_count, m.normal_count, m.synced_at, | |||
| COALESCE(s.station_name, m.station_code) AS agg_station_name | |||
| FROM agg_analysis_defect_month m | |||
| LEFT JOIN agg_station s ON s.station_code = m.station_code | |||
| WHERE m.stat_month >= #{beginMonth} | |||
| AND m.stat_month < #{endMonth} | |||
| <if test="stationCode != null and stationCode != ''"> | |||
| AND m.station_code = #{stationCode} | |||
| </if> | |||
| ORDER BY m.station_code, m.stat_month, m.point_type | |||
| </select> | |||
| <select id="countAll" resultType="long"> | |||
| SELECT COUNT(*) FROM agg_analysis_defect_month | |||
| </select> | |||
| </mapper> | |||
| @ -0,0 +1,81 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> | |||
| <mapper namespace="com.inspect.agg.mapper.AggBasedataAreaMapper"> | |||
| <resultMap id="BaseResultMap" type="com.inspect.agg.entity.AggBasedataArea"> | |||
| <id column="id" property="id"/> | |||
| <result column="station_code" property="stationCode"/> | |||
| <result column="agg_station_name" property="stationName"/> | |||
| <result column="area_id" property="areaId"/> | |||
| <result column="parent_id" property="parentId"/> | |||
| <result column="ancestors" property="ancestors"/> | |||
| <result column="area_name" property="areaName"/> | |||
| <result column="area_code" property="areaCode"/> | |||
| <result column="area_addr" property="areaAddr"/> | |||
| <result column="area_type" property="areaType"/> | |||
| <result column="device_type_code" property="deviceTypeCode"/> | |||
| <result column="order_num" property="orderNum"/> | |||
| <result column="status" property="status"/> | |||
| <result column="remark" property="remark"/> | |||
| <result column="update_time" property="updateTime"/> | |||
| <result column="synced_at" property="syncedAt"/> | |||
| <result column="is_deleted" property="isDeleted"/> | |||
| </resultMap> | |||
| <!-- 幂等 upsert:按 (station_code, area_id) --> | |||
| <insert id="upsert" parameterType="com.inspect.agg.entity.AggBasedataArea"> | |||
| INSERT INTO agg_basedata_area (station_code, area_id, parent_id, ancestors, area_name, area_code, area_addr, area_type, device_type_code, order_num, status, remark, update_time, synced_at, is_deleted) | |||
| VALUES (#{stationCode}, #{areaId}, #{parentId}, #{ancestors}, #{areaName}, #{areaCode}, #{areaAddr}, #{areaType}, #{deviceTypeCode}, #{orderNum}, #{status}, #{remark}, #{updateTime}, #{syncedAt}, #{isDeleted}) | |||
| ON DUPLICATE KEY UPDATE | |||
| parent_id = VALUES(parent_id), | |||
| ancestors = VALUES(ancestors), | |||
| area_name = VALUES(area_name), | |||
| area_code = VALUES(area_code), | |||
| area_addr = VALUES(area_addr), | |||
| area_type = VALUES(area_type), | |||
| device_type_code = VALUES(device_type_code), | |||
| order_num = VALUES(order_num), | |||
| status = VALUES(status), | |||
| remark = VALUES(remark), | |||
| update_time = VALUES(update_time), | |||
| synced_at = VALUES(synced_at), | |||
| is_deleted = VALUES(is_deleted), | |||
| is_deleted = 0 | |||
| </insert> | |||
| <!-- 全量对账:本次未出现的行标记删除 --> | |||
| <update id="markDeletedExcept"> | |||
| UPDATE agg_basedata_area | |||
| SET is_deleted = 1, synced_at = NOW() | |||
| WHERE station_code = #{stationCode} AND is_deleted = 0 | |||
| <if test="ids != null and ids.size() > 0"> | |||
| AND area_id NOT IN | |||
| <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach> | |||
| </if> | |||
| </update> | |||
| <select id="listByStation" resultMap="BaseResultMap"> | |||
| SELECT a.*, COALESCE(s.station_name, a.station_code) AS agg_station_name | |||
| FROM agg_basedata_area a | |||
| LEFT JOIN agg_station s ON s.station_code = a.station_code | |||
| WHERE a.is_deleted = 0 | |||
| <if test="stationCode != null and stationCode != ''">AND a.station_code = #{stationCode}</if> | |||
| ORDER BY a.order_num, a.area_id | |||
| <if test="limit > 0">LIMIT #{limit}</if> | |||
| </select> | |||
| <select id="listForTree" resultMap="BaseResultMap"> | |||
| SELECT a.*, COALESCE(s.station_name, a.station_code) AS agg_station_name | |||
| FROM agg_basedata_area a | |||
| LEFT JOIN agg_station s ON s.station_code = a.station_code | |||
| WHERE a.is_deleted = 0 | |||
| <if test="stationCode != null and stationCode != ''">AND a.station_code = #{stationCode}</if> | |||
| ORDER BY a.order_num, a.area_id | |||
| </select> | |||
| <select id="countByStation" resultType="long"> | |||
| SELECT COUNT(*) FROM agg_basedata_area | |||
| WHERE is_deleted = 0 | |||
| <if test="stationCode != null and stationCode != ''">AND station_code = #{stationCode}</if> | |||
| </select> | |||
| </mapper> | |||
| @ -0,0 +1,82 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> | |||
| <mapper namespace="com.inspect.agg.mapper.AggBasedataDeviceMapper"> | |||
| <resultMap id="BaseResultMap" type="com.inspect.agg.entity.AggBasedataDevice"> | |||
| <id column="id" property="id"/> | |||
| <result column="station_code" property="stationCode"/> | |||
| <result column="agg_station_name" property="stationName"/> | |||
| <result column="device_id" property="deviceId"/> | |||
| <result column="parent_id" property="parentId"/> | |||
| <result column="ancestors" property="ancestors"/> | |||
| <result column="area_id" property="areaId"/> | |||
| <result column="area_name" property="areaName"/> | |||
| <result column="device_code" property="deviceCode"/> | |||
| <result column="device_name" property="deviceName"/> | |||
| <result column="device_type_name" property="deviceTypeName"/> | |||
| <result column="device_model" property="deviceModel"/> | |||
| <result column="manufacturer" property="manufacturer"/> | |||
| <result column="power_main_equ_flag" property="powerMainEquFlag"/> | |||
| <result column="station_no" property="stationNo"/> | |||
| <result column="monitor_index_no" property="monitorIndexNo"/> | |||
| <result column="status" property="status"/> | |||
| <result column="order_num" property="orderNum"/> | |||
| <result column="update_time" property="updateTime"/> | |||
| <result column="synced_at" property="syncedAt"/> | |||
| <result column="is_deleted" property="isDeleted"/> | |||
| </resultMap> | |||
| <!-- 幂等 upsert:按 (station_code, device_id) --> | |||
| <insert id="upsert" parameterType="com.inspect.agg.entity.AggBasedataDevice"> | |||
| INSERT INTO agg_basedata_device (station_code, device_id, parent_id, ancestors, area_id, area_name, device_code, device_name, device_type_name, device_model, manufacturer, power_main_equ_flag, station_no, monitor_index_no, status, order_num, update_time, synced_at, is_deleted) | |||
| VALUES (#{stationCode}, #{deviceId}, #{parentId}, #{ancestors}, #{areaId}, #{areaName}, #{deviceCode}, #{deviceName}, #{deviceTypeName}, #{deviceModel}, #{manufacturer}, #{powerMainEquFlag}, #{stationNo}, #{monitorIndexNo}, #{status}, #{orderNum}, #{updateTime}, #{syncedAt}, #{isDeleted}) | |||
| ON DUPLICATE KEY UPDATE | |||
| parent_id = VALUES(parent_id), | |||
| ancestors = VALUES(ancestors), | |||
| area_id = VALUES(area_id), | |||
| area_name = VALUES(area_name), | |||
| device_code = VALUES(device_code), | |||
| device_name = VALUES(device_name), | |||
| device_type_name = VALUES(device_type_name), | |||
| device_model = VALUES(device_model), | |||
| manufacturer = VALUES(manufacturer), | |||
| power_main_equ_flag = VALUES(power_main_equ_flag), | |||
| station_no = VALUES(station_no), | |||
| monitor_index_no = VALUES(monitor_index_no), | |||
| status = VALUES(status), | |||
| order_num = VALUES(order_num), | |||
| update_time = VALUES(update_time), | |||
| synced_at = VALUES(synced_at), | |||
| is_deleted = VALUES(is_deleted), | |||
| is_deleted = 0 | |||
| </insert> | |||
| <!-- 全量对账:本次未出现的行标记删除 --> | |||
| <update id="markDeletedExcept"> | |||
| UPDATE agg_basedata_device | |||
| SET is_deleted = 1, synced_at = NOW() | |||
| WHERE station_code = #{stationCode} AND is_deleted = 0 | |||
| <if test="ids != null and ids.size() > 0"> | |||
| AND device_id NOT IN | |||
| <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach> | |||
| </if> | |||
| </update> | |||
| <select id="list" resultMap="BaseResultMap"> | |||
| SELECT d.*, COALESCE(s.station_name, d.station_code) AS agg_station_name | |||
| FROM agg_basedata_device d | |||
| LEFT JOIN agg_station s ON s.station_code = d.station_code | |||
| WHERE d.is_deleted = 0 | |||
| <if test="stationCode != null and stationCode != ''">AND d.station_code = #{stationCode}</if> | |||
| <if test="areaId != null">AND d.area_id = #{areaId}</if> | |||
| ORDER BY d.area_id, d.order_num, d.device_id | |||
| <if test="limit > 0">LIMIT #{limit}</if> | |||
| </select> | |||
| <select id="count" resultType="long"> | |||
| SELECT COUNT(*) FROM agg_basedata_device | |||
| WHERE is_deleted = 0 | |||
| <if test="stationCode != null and stationCode != ''">AND station_code = #{stationCode}</if> | |||
| <if test="areaId != null">AND area_id = #{areaId}</if> | |||
| </select> | |||
| </mapper> | |||
| @ -0,0 +1,76 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> | |||
| <mapper namespace="com.inspect.agg.mapper.AggBasedataDevicetypeMapper"> | |||
| <resultMap id="BaseResultMap" type="com.inspect.agg.entity.AggBasedataDevicetype"> | |||
| <id column="id" property="id"/> | |||
| <result column="station_code" property="stationCode"/> | |||
| <result column="agg_station_name" property="stationName"/> | |||
| <result column="device_type_id" property="deviceTypeId"/> | |||
| <result column="parent_id" property="parentId"/> | |||
| <result column="order_num" property="orderNum"/> | |||
| <result column="ancestors" property="ancestors"/> | |||
| <result column="ancestors_addr" property="ancestorsAddr"/> | |||
| <result column="device_type_name" property="deviceTypeName"/> | |||
| <result column="device_type_code" property="deviceTypeCode"/> | |||
| <result column="power_main_equ_flag" property="powerMainEquFlag"/> | |||
| <result column="phase_num" property="phaseNum"/> | |||
| <result column="phase" property="phase"/> | |||
| <result column="status" property="status"/> | |||
| <result column="profession_type" property="professionType"/> | |||
| <result column="level" property="level"/> | |||
| <result column="update_time" property="updateTime"/> | |||
| <result column="synced_at" property="syncedAt"/> | |||
| <result column="is_deleted" property="isDeleted"/> | |||
| </resultMap> | |||
| <!-- 幂等 upsert:按 (station_code, device_type_id) --> | |||
| <insert id="upsert" parameterType="com.inspect.agg.entity.AggBasedataDevicetype"> | |||
| INSERT INTO agg_basedata_devicetype (station_code, device_type_id, parent_id, order_num, ancestors, ancestors_addr, device_type_name, device_type_code, power_main_equ_flag, phase_num, phase, status, profession_type, level, update_time, synced_at, is_deleted) | |||
| VALUES (#{stationCode}, #{deviceTypeId}, #{parentId}, #{orderNum}, #{ancestors}, #{ancestorsAddr}, #{deviceTypeName}, #{deviceTypeCode}, #{powerMainEquFlag}, #{phaseNum}, #{phase}, #{status}, #{professionType}, #{level}, #{updateTime}, #{syncedAt}, #{isDeleted}) | |||
| ON DUPLICATE KEY UPDATE | |||
| parent_id = VALUES(parent_id), | |||
| order_num = VALUES(order_num), | |||
| ancestors = VALUES(ancestors), | |||
| ancestors_addr = VALUES(ancestors_addr), | |||
| device_type_name = VALUES(device_type_name), | |||
| device_type_code = VALUES(device_type_code), | |||
| power_main_equ_flag = VALUES(power_main_equ_flag), | |||
| phase_num = VALUES(phase_num), | |||
| phase = VALUES(phase), | |||
| status = VALUES(status), | |||
| profession_type = VALUES(profession_type), | |||
| level = VALUES(level), | |||
| update_time = VALUES(update_time), | |||
| synced_at = VALUES(synced_at), | |||
| is_deleted = VALUES(is_deleted), | |||
| is_deleted = 0 | |||
| </insert> | |||
| <!-- 全量对账:本次未出现的行标记删除 --> | |||
| <update id="markDeletedExcept"> | |||
| UPDATE agg_basedata_devicetype | |||
| SET is_deleted = 1, synced_at = NOW() | |||
| WHERE station_code = #{stationCode} AND is_deleted = 0 | |||
| <if test="ids != null and ids.size() > 0"> | |||
| AND device_type_id NOT IN | |||
| <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach> | |||
| </if> | |||
| </update> | |||
| <select id="list" resultMap="BaseResultMap"> | |||
| SELECT d.*, COALESCE(s.station_name, d.station_code) AS agg_station_name | |||
| FROM agg_basedata_devicetype d | |||
| LEFT JOIN agg_station s ON s.station_code = d.station_code | |||
| WHERE d.is_deleted = 0 | |||
| <if test="stationCode != null and stationCode != ''">AND d.station_code = #{stationCode}</if> | |||
| ORDER BY d.parent_id, d.order_num, d.device_type_id | |||
| <if test="limit > 0">LIMIT #{limit}</if> | |||
| </select> | |||
| <select id="count" resultType="long"> | |||
| SELECT COUNT(*) FROM agg_basedata_devicetype | |||
| WHERE is_deleted = 0 | |||
| <if test="stationCode != null and stationCode != ''">AND station_code = #{stationCode}</if> | |||
| </select> | |||
| </mapper> | |||
| @ -0,0 +1,87 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> | |||
| <mapper namespace="com.inspect.agg.mapper.AggBasedataEqpbookChannelMapper"> | |||
| <resultMap id="BaseResultMap" type="com.inspect.agg.entity.AggBasedataEqpbookChannel"> | |||
| <id column="id" property="id"/> | |||
| <result column="station_code" property="stationCode"/> | |||
| <result column="agg_station_name" property="stationName"/> | |||
| <result column="channel_id" property="channelId"/> | |||
| <result column="channel_code" property="channelCode"/> | |||
| <result column="channel_name" property="channelName"/> | |||
| <result column="channel_type" property="channelType"/> | |||
| <result column="online_state" property="onlineState"/> | |||
| <result column="channel_content" property="channelContent"/> | |||
| <result column="host" property="host"/> | |||
| <result column="port" property="port"/> | |||
| <result column="area_id" property="areaId"/> | |||
| <result column="area_name" property="areaName"/> | |||
| <result column="eqpbook_id" property="eqpbookId"/> | |||
| <result column="patrol_device_code" property="patrolDeviceCode"/> | |||
| <result column="up_system_device_code" property="upSystemDeviceCode"/> | |||
| <result column="device_source" property="deviceSource"/> | |||
| <result column="update_time" property="updateTime"/> | |||
| <result column="synced_at" property="syncedAt"/> | |||
| <result column="is_deleted" property="isDeleted"/> | |||
| </resultMap> | |||
| <!-- 骞傜瓑 upsert锛氭寜 (station_code, channel_id) --> | |||
| <insert id="upsert" parameterType="com.inspect.agg.entity.AggBasedataEqpbookChannel"> | |||
| INSERT INTO agg_basedata_eqpbook_channel (station_code, channel_id, channel_code, channel_name, channel_type, online_state, channel_content, host, port, area_id, area_name, eqpbook_id, patrol_device_code, up_system_device_code, device_source, update_time, synced_at, is_deleted) | |||
| VALUES (#{stationCode}, #{channelId}, #{channelCode}, #{channelName}, #{channelType}, #{onlineState}, #{channelContent}, #{host}, #{port}, #{areaId}, #{areaName}, #{eqpbookId}, #{patrolDeviceCode}, #{upSystemDeviceCode}, #{deviceSource}, #{updateTime}, #{syncedAt}, #{isDeleted}) | |||
| ON DUPLICATE KEY UPDATE | |||
| channel_code = VALUES(channel_code), | |||
| channel_name = VALUES(channel_name), | |||
| channel_type = VALUES(channel_type), | |||
| online_state = VALUES(online_state), | |||
| channel_content = VALUES(channel_content), | |||
| host = VALUES(host), | |||
| port = VALUES(port), | |||
| area_id = VALUES(area_id), | |||
| area_name = VALUES(area_name), | |||
| eqpbook_id = VALUES(eqpbook_id), | |||
| patrol_device_code = VALUES(patrol_device_code), | |||
| up_system_device_code = VALUES(up_system_device_code), | |||
| device_source = VALUES(device_source), | |||
| update_time = VALUES(update_time), | |||
| synced_at = VALUES(synced_at), | |||
| is_deleted = VALUES(is_deleted), | |||
| is_deleted = 0 | |||
| </insert> | |||
| <!-- 鍏ㄩ噺瀵硅处锛氭湰娆℃湭鍑虹幇鐨勮鏍囪鍒犻櫎 --> | |||
| <update id="markDeletedExcept"> | |||
| UPDATE agg_basedata_eqpbook_channel | |||
| SET is_deleted = 1, synced_at = NOW() | |||
| WHERE station_code = #{stationCode} AND is_deleted = 0 | |||
| <if test="ids != null and ids.size() > 0"> | |||
| AND channel_id NOT IN | |||
| <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach> | |||
| </if> | |||
| </update> | |||
| <select id="list" resultMap="BaseResultMap"> | |||
| SELECT c.*, COALESCE(s.station_name, c.station_code) AS agg_station_name | |||
| FROM agg_basedata_eqpbook_channel c | |||
| LEFT JOIN agg_station s ON s.station_code = c.station_code | |||
| WHERE c.is_deleted = 0 | |||
| <if test="stationCode != null and stationCode != ''">AND c.station_code = #{stationCode}</if> | |||
| ORDER BY c.eqpbook_id, c.channel_id | |||
| <if test="limit > 0">LIMIT #{limit}</if> | |||
| </select> | |||
| <select id="count" resultType="long"> | |||
| SELECT COUNT(*) FROM agg_basedata_eqpbook_channel | |||
| WHERE is_deleted = 0 | |||
| <if test="stationCode != null and stationCode != ''">AND station_code = #{stationCode}</if> | |||
| </select> | |||
| <select id="countByDeviceSource" resultType="java.util.LinkedHashMap"> | |||
| SELECT COALESCE(NULLIF(TRIM(device_source), ''), '其他') AS deviceSource, | |||
| COUNT(*) AS count | |||
| FROM agg_basedata_eqpbook_channel | |||
| WHERE is_deleted = 0 | |||
| <if test="stationCode != null and stationCode != ''">AND station_code = #{stationCode}</if> | |||
| GROUP BY COALESCE(NULLIF(TRIM(device_source), ''), '其他') | |||
| ORDER BY count DESC, deviceSource ASC | |||
| </select> | |||
| </mapper> | |||
| @ -0,0 +1,81 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> | |||
| <mapper namespace="com.inspect.agg.mapper.AggBasedataEqpbookMapper"> | |||
| <resultMap id="BaseResultMap" type="com.inspect.agg.entity.AggBasedataEqpbook"> | |||
| <id column="id" property="id"/> | |||
| <result column="station_code" property="stationCode"/> | |||
| <result column="eqpbook_id" property="eqpbookId"/> | |||
| <result column="patrol_device_code" property="patrolDeviceCode"/> | |||
| <result column="patrol_device_name" property="patrolDeviceName"/> | |||
| <result column="agg_station_name" property="stationName"/> | |||
| <result column="area_id" property="areaId"/> | |||
| <result column="area_name" property="areaName"/> | |||
| <result column="device_source" property="deviceSource"/> | |||
| <result column="device_model" property="deviceModel"/> | |||
| <result column="manufacturer" property="manufacturer"/> | |||
| <result column="device_type" property="deviceType"/> | |||
| <result column="ip_addr" property="ipAddr"/> | |||
| <result column="port" property="port"/> | |||
| <result column="video_nvr_code" property="videoNvrCode"/> | |||
| <result column="main_system_code" property="mainSystemCode"/> | |||
| <result column="use_mode" property="useMode"/> | |||
| <result column="video_mode" property="videoMode"/> | |||
| <result column="update_time" property="updateTime"/> | |||
| <result column="synced_at" property="syncedAt"/> | |||
| <result column="is_deleted" property="isDeleted"/> | |||
| </resultMap> | |||
| <!-- 幂等 upsert:按 (station_code, eqpbook_id) --> | |||
| <insert id="upsert" parameterType="com.inspect.agg.entity.AggBasedataEqpbook"> | |||
| INSERT INTO agg_basedata_eqpbook (station_code, eqpbook_id, patrol_device_code, patrol_device_name, station_name, area_id, area_name, device_source, device_model, manufacturer, device_type, ip_addr, port, video_nvr_code, main_system_code, use_mode, video_mode, update_time, synced_at, is_deleted) | |||
| VALUES (#{stationCode}, #{eqpbookId}, #{patrolDeviceCode}, #{patrolDeviceName}, #{stationName}, #{areaId}, #{areaName}, #{deviceSource}, #{deviceModel}, #{manufacturer}, #{deviceType}, #{ipAddr}, #{port}, #{videoNvrCode}, #{mainSystemCode}, #{useMode}, #{videoMode}, #{updateTime}, #{syncedAt}, #{isDeleted}) | |||
| ON DUPLICATE KEY UPDATE | |||
| patrol_device_code = VALUES(patrol_device_code), | |||
| patrol_device_name = VALUES(patrol_device_name), | |||
| station_name = VALUES(station_name), | |||
| area_id = VALUES(area_id), | |||
| area_name = VALUES(area_name), | |||
| device_source = VALUES(device_source), | |||
| device_model = VALUES(device_model), | |||
| manufacturer = VALUES(manufacturer), | |||
| device_type = VALUES(device_type), | |||
| ip_addr = VALUES(ip_addr), | |||
| port = VALUES(port), | |||
| video_nvr_code = VALUES(video_nvr_code), | |||
| main_system_code = VALUES(main_system_code), | |||
| use_mode = VALUES(use_mode), | |||
| video_mode = VALUES(video_mode), | |||
| update_time = VALUES(update_time), | |||
| synced_at = VALUES(synced_at), | |||
| is_deleted = VALUES(is_deleted), | |||
| is_deleted = 0 | |||
| </insert> | |||
| <!-- 全量对账:本次未出现的行标记删除 --> | |||
| <update id="markDeletedExcept"> | |||
| UPDATE agg_basedata_eqpbook | |||
| SET is_deleted = 1, synced_at = NOW() | |||
| WHERE station_code = #{stationCode} AND is_deleted = 0 | |||
| <if test="ids != null and ids.size() > 0"> | |||
| AND eqpbook_id NOT IN | |||
| <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach> | |||
| </if> | |||
| </update> | |||
| <select id="list" resultMap="BaseResultMap"> | |||
| SELECT e.*, COALESCE(s.station_name, NULLIF(e.station_name, ''), e.station_code) AS agg_station_name | |||
| FROM agg_basedata_eqpbook e | |||
| LEFT JOIN agg_station s ON s.station_code = e.station_code | |||
| WHERE e.is_deleted = 0 | |||
| <if test="stationCode != null and stationCode != ''">AND e.station_code = #{stationCode}</if> | |||
| ORDER BY e.area_id, e.eqpbook_id | |||
| <if test="limit > 0">LIMIT #{limit}</if> | |||
| </select> | |||
| <select id="count" resultType="long"> | |||
| SELECT COUNT(*) FROM agg_basedata_eqpbook | |||
| WHERE is_deleted = 0 | |||
| <if test="stationCode != null and stationCode != ''">AND station_code = #{stationCode}</if> | |||
| </select> | |||
| </mapper> | |||