Junjie
2026-04-12 6e4ea35877963ef0787830ff61d6351d0baa1359
#日志采集优化
1个文件已添加
15个文件已修改
997 ■■■■ 已修改文件
src/main/java/com/zy/asrs/controller/DeviceLogController.java 360 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/entity/DeviceDataLog.java 47 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/common/utils/RedisUtil.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/task/DeviceAsyncLogPublisher.java 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/task/DeviceLogScheduler.java 86 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/thread/impl/ZyRgvThread.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/thread/impl/ZySiemensCrnThread.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/thread/impl/ZySiemensCrnV2Thread.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/thread/impl/ZySiemensDualCrnThread.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/thread/impl/ZyStationThread.java 45 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/thread/impl/ZyStationV3Thread.java 43 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/thread/impl/ZyStationV4Thread.java 43 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/thread/impl/v5/StationV5StatusReader.java 44 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/core/utils/DeviceLogRedisKeyBuilder.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/static/js/deviceLogs/deviceLogs.js 162 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/deviceLogs/deviceLogs.html 18 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/asrs/controller/DeviceLogController.java
@@ -4,10 +4,14 @@
import com.core.annotations.ManagerAuth;
import com.core.common.Cools;
import com.core.common.R;
import com.zy.asrs.entity.BasDevp;
import com.zy.asrs.entity.DeviceDataLog;
import com.zy.asrs.service.BasDevpService;
import com.zy.common.web.BaseController;
import com.zy.core.enums.SlaveType;
import com.zy.core.model.StationObjModel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -24,6 +28,8 @@
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@Slf4j
@RestController
@@ -39,6 +45,9 @@
        DEVICE_TYPE_LABELS.put("Devp", "输送设备");
    }
    @Autowired
    private BasDevpService basDevpService;
    @Value("${deviceLogStorage.loggingPath}")
    private String loggingPath;
@@ -53,6 +62,7 @@
    private static class FileNameInfo {
        String type;
        String deviceNo;
        String stationId;
        String day;
        int index;
    }
@@ -66,6 +76,8 @@
        String type;
        String typeLabel;
        String deviceNo;
        String stationId;
        List<String> stationIds;
        int fileCount;
        Long firstTime;
        Long lastTime;
@@ -140,31 +152,29 @@
            if (!Files.exists(dayDir) || !Files.isDirectory(dayDir)) {
                return R.ok(new ArrayList<>());
            }
            List<Path> files = Files.list(dayDir)
                    .filter(p -> !Files.isDirectory(p) && p.getFileName().toString().endsWith(".log"))
                    .collect(Collectors.toList());
            List<Path> files = listDayLogFiles(dayDir);
            Map<String, Map<String, Object>> deviceMap = new HashMap<>();
            for (Path p : files) {
                String name = p.getFileName().toString();
                String[] parts = name.split("_");
                if (parts.length < 4) {
                FileNameInfo info = parseFileName(p.getFileName().toString());
                if (info == null || !day.equals(info.day)) {
                    continue;
                }
                String deviceNo = parts[1];
                String type = parts[0];
                Map<String, Object> info = deviceMap.computeIfAbsent(deviceNo, k -> {
                String deviceKey = buildDeviceKey(info.type, info.deviceNo, info.stationId);
                Map<String, Object> infoMap = deviceMap.computeIfAbsent(deviceKey, k -> {
                    Map<String, Object> map = new HashMap<>();
                    map.put("deviceNo", deviceNo);
                    map.put("deviceNo", info.deviceNo);
                    map.put("stationId", info.stationId);
                    map.put("types", new HashSet<String>());
                    map.put("fileCount", 0);
                    return map;
                });
                ((Set<String>) info.get("types")).add(type);
                info.put("fileCount", ((Integer) info.get("fileCount")) + 1);
                ((Set<String>) infoMap.get("types")).add(info.type);
                infoMap.put("fileCount", ((Integer) infoMap.get("fileCount")) + 1);
            }
            List<Map<String, Object>> res = deviceMap.values().stream().map(m -> {
                Map<String, Object> x = new HashMap<>();
                x.put("deviceNo", m.get("deviceNo"));
                x.put("stationId", m.get("stationId"));
                x.put("types", ((Set<String>) m.get("types")).stream().collect(Collectors.toList()));
                x.put("fileCount", m.get("fileCount"));
                return x;
@@ -189,32 +199,29 @@
            }
            Map<String, DeviceAggregate> aggregateMap = new LinkedHashMap<>();
            try (Stream<Path> stream = Files.list(dayDir)) {
                List<Path> files = stream
                        .filter(p -> !Files.isDirectory(p) && p.getFileName().toString().endsWith(".log"))
                        .collect(Collectors.toList());
                for (Path file : files) {
                    FileNameInfo info = parseFileName(file.getFileName().toString());
                    if (info == null || !dayClean.equals(info.day) || !DEVICE_TYPE_LABELS.containsKey(info.type)) {
                        continue;
                    }
                    String key = info.type + ":" + info.deviceNo;
                    DeviceAggregate aggregate = aggregateMap.computeIfAbsent(key, k -> {
                        DeviceAggregate x = new DeviceAggregate();
                        x.type = info.type;
                        x.typeLabel = DEVICE_TYPE_LABELS.get(info.type);
                        x.deviceNo = info.deviceNo;
                        return x;
                    });
                    aggregate.fileCount += 1;
                    if (aggregate.firstIndex == null || info.index < aggregate.firstIndex) {
                        aggregate.firstIndex = info.index;
                        aggregate.firstFile = file;
                    }
                    if (aggregate.lastIndex == null || info.index > aggregate.lastIndex) {
                        aggregate.lastIndex = info.index;
                        aggregate.lastFile = file;
                    }
            List<Path> files = listDayLogFiles(dayDir);
            for (Path file : files) {
                FileNameInfo info = parseFileName(file.getFileName().toString());
                if (info == null || !dayClean.equals(info.day) || !DEVICE_TYPE_LABELS.containsKey(info.type)) {
                    continue;
                }
                String key = buildDeviceKey(info.type, info.deviceNo, info.stationId);
                DeviceAggregate aggregate = aggregateMap.computeIfAbsent(key, k -> {
                    DeviceAggregate x = new DeviceAggregate();
                    x.type = info.type;
                    x.typeLabel = DEVICE_TYPE_LABELS.get(info.type);
                    x.deviceNo = info.deviceNo;
                    x.stationId = info.stationId;
                    return x;
                });
                aggregate.fileCount += 1;
                if (aggregate.firstIndex == null || info.index < aggregate.firstIndex) {
                    aggregate.firstIndex = info.index;
                    aggregate.firstFile = file;
                }
                if (aggregate.lastIndex == null || info.index > aggregate.lastIndex) {
                    aggregate.lastIndex = info.index;
                    aggregate.lastFile = file;
                }
            }
            for (DeviceAggregate aggregate : aggregateMap.values()) {
@@ -227,6 +234,7 @@
                    aggregate.lastTime = lastRange.endTime != null ? lastRange.endTime : lastRange.startTime;
                }
            }
            enrichDevpStationIds(aggregateMap.values());
            return R.ok(buildSummaryResponse(aggregateMap.values()));
        } catch (Exception e) {
            log.error("读取设备日志摘要失败", e);
@@ -238,7 +246,8 @@
    @ManagerAuth
    public R timeline(@PathVariable("day") String day,
                      @RequestParam("type") String type,
                      @RequestParam("deviceNo") String deviceNo) {
                      @RequestParam("deviceNo") String deviceNo,
                      @RequestParam(value = "stationId", required = false) String stationId) {
        try {
            String dayClean = normalizeDay(day);
            if (dayClean == null) {
@@ -250,11 +259,14 @@
            if (deviceNo == null || !deviceNo.chars().allMatch(Character::isDigit)) {
                return R.error("设备编号错误");
            }
            if (isDevpType(type) && (stationId == null || !stationId.chars().allMatch(Character::isDigit))) {
                return R.error("站点编号错误");
            }
            Path dayDir = Paths.get(loggingPath, dayClean);
            if (!Files.exists(dayDir) || !Files.isDirectory(dayDir)) {
                return R.error("未找到日志文件");
            }
            List<Path> files = findDeviceFiles(dayDir, dayClean, type, deviceNo);
            List<Path> files = findDeviceFiles(dayDir, dayClean, type, deviceNo, stationId);
            if (files.isEmpty()) {
                return R.error("未找到日志文件");
            }
@@ -300,6 +312,7 @@
            result.put("type", type);
            result.put("typeLabel", DEVICE_TYPE_LABELS.getOrDefault(type, type));
            result.put("deviceNo", deviceNo);
            result.put("stationId", stationId);
            result.put("startTime", startTime);
            result.put("endTime", endTime);
            result.put("totalFiles", files.size());
@@ -316,6 +329,7 @@
    public R preview(@PathVariable("day") String day,
                     @RequestParam("type") String type,
                     @RequestParam("deviceNo") String deviceNo,
                     @RequestParam(value = "stationId", required = false) String stationId,
                     @RequestParam(value = "offset", required = false) Integer offset,
                     @RequestParam(value = "limit", required = false) Integer limit) {
        try {
@@ -329,39 +343,27 @@
            if (deviceNo == null || !deviceNo.chars().allMatch(Character::isDigit)) {
                return R.error("设备编号错误");
            }
            if (isDevpType(type) && (stationId == null || !stationId.chars().allMatch(Character::isDigit))) {
                return R.error("站点编号错误");
            }
            Path dayDir = Paths.get(loggingPath, dayClean);
            if (!Files.exists(dayDir) || !Files.isDirectory(dayDir)) {
                return R.ok(new ArrayList<>());
            }
            String prefix = type + "_" + deviceNo + "_" + dayClean + "_";
            List<Path> files = Files.list(dayDir)
                    .filter(p -> {
                        String name = p.getFileName().toString();
                        return name.endsWith(".log") && name.startsWith(prefix);
                    }).collect(Collectors.toList());
            files.sort(Comparator.comparingInt(p -> {
                String n = p.getFileName().toString();
                try {
                    String suf = n.substring(prefix.length(), n.length() - 4);
                    return Integer.parseInt(suf);
                } catch (Exception e) {
                    return Integer.MAX_VALUE;
                }
            }));
            List<Path> files = findDeviceFiles(dayDir, dayClean, type, deviceNo, stationId);
            int from = offset == null || offset < 0 ? 0 : offset;
            int max = limit == null || limit <= 0 ? 5 : limit; // 默认读取5个文件
            if (max > 10) max = 10; // 限制最大文件数,防止超时
            int max = limit == null || limit <= 0 ? 5 : limit;
            if (max > 10) max = 10;
            int to = Math.min(files.size(), from + max);
            if (from >= files.size()) {
                return R.ok(new ArrayList<>());
            }
            List<Path> targetFiles = files.subList(from, to);
            List<DeviceDataLog> resultLogs = new ArrayList<>();
            for (Path f : targetFiles) {
                try (Stream<String> lines = Files.lines(f, StandardCharsets.UTF_8)) {
                    lines.forEach(line -> {
@@ -370,7 +372,6 @@
                                DeviceDataLog logItem = JSON.parseObject(line, DeviceDataLog.class);
                                resultLogs.add(logItem);
                            } catch (Exception e) {
                                // 忽略解析错误
                            }
                        }
                    });
@@ -378,9 +379,8 @@
                    log.error("读取日志文件失败: " + f, e);
                }
            }
            // 按时间排序
            resultLogs.sort(Comparator.comparing(DeviceDataLog::getCreateTime, Comparator.nullsLast(Date::compareTo)));
            return R.ok(resultLogs);
        } catch (Exception e) {
            log.error("预览日志失败", e);
@@ -393,6 +393,7 @@
    public R seek(@PathVariable("day") String day,
                  @RequestParam("type") String type,
                  @RequestParam("deviceNo") String deviceNo,
                  @RequestParam(value = "stationId", required = false) String stationId,
                  @RequestParam("timestamp") Long timestamp) {
        try {
            String dayClean = day == null ? null : day.replaceAll("\\D", "");
@@ -405,72 +406,49 @@
            if (deviceNo == null || !deviceNo.chars().allMatch(Character::isDigit)) {
                return R.error("设备编号错误");
            }
            if (isDevpType(type) && (stationId == null || !stationId.chars().allMatch(Character::isDigit))) {
                return R.error("站点编号错误");
            }
            Path dayDir = Paths.get(loggingPath, dayClean);
            if (!Files.exists(dayDir) || !Files.isDirectory(dayDir)) {
                return R.error("未找到日志文件");
            }
            String prefix = type + "_" + deviceNo + "_" + dayClean + "_";
            List<Path> files = Files.list(dayDir)
                    .filter(p -> {
                        String name = p.getFileName().toString();
                        return name.endsWith(".log") && name.startsWith(prefix);
                    }).collect(Collectors.toList());
            files.sort(Comparator.comparingInt(p -> {
                String n = p.getFileName().toString();
                try {
                    String suf = n.substring(prefix.length(), n.length() - 4);
                    return Integer.parseInt(suf);
                } catch (Exception e) {
                    return Integer.MAX_VALUE;
                }
            }));
            List<Path> files = findDeviceFiles(dayDir, dayClean, type, deviceNo, stationId);
            if (files.isEmpty()) {
                return R.error("未找到日志文件");
            }
            // Binary search for the file containing the timestamp
            // We want to find the LAST file that has startTime <= targetTime.
            // Because files are sequential: [t0, t1), [t1, t2), ...
            // If we find file[i].startTime <= target < file[i+1].startTime, then target is in file[i].
            int low = 0;
            int high = files.size() - 1;
            int foundIndex = -1;
            while (low <= high) {
                int mid = (low + high) >>> 1;
                Path midFile = files.get(mid);
                // Read start time of this file
                Long midStart = getFileStartTime(midFile);
                if (midStart == null) {
                    low = mid + 1;
                    continue;
                }
                if (midStart <= timestamp) {
                    // This file starts before or at target. It COULD be the one.
                    // But maybe a later file also starts before target?
                    foundIndex = mid;
                    low = mid + 1; // Try to find a later start time
                    low = mid + 1;
                } else {
                    // This file starts AFTER target. So target must be in an earlier file.
                    high = mid - 1;
                }
            }
            if (foundIndex == -1) {
                foundIndex = 0;
            }
            // Return the file index (offset)
            Map<String, Object> result = new HashMap<>();
            result.put("offset", foundIndex);
            return R.ok(result);
        } catch (Exception e) {
            log.error("寻址失败", e);
            return R.error("寻址失败");
@@ -522,12 +500,17 @@
                response.setStatus(400);
                return;
            }
            String stationId = request.getParameter("stationId");
            if (isDevpType(type) && (stationId == null || !stationId.chars().allMatch(Character::isDigit))) {
                response.setStatus(400);
                return;
            }
            Path dayDir = Paths.get(loggingPath, dayClean);
            if (!Files.exists(dayDir) || !Files.isDirectory(dayDir)) {
                response.setStatus(404);
                return;
            }
            List<Path> files = findDeviceFiles(dayDir, dayClean, type, deviceNo);
            List<Path> files = findDeviceFiles(dayDir, dayClean, type, deviceNo, stationId);
            files = sliceDownloadFiles(files, offset, limit);
            if (files.isEmpty()) {
                response.setStatus(404);
@@ -563,9 +546,9 @@
            response.setHeader("X-Total-Size", String.valueOf(totalRawSize));
            response.setHeader("X-File-Count", String.valueOf(files.size()));
            response.setHeader("X-Progress-Id", id);
            try (java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(response.getOutputStream())) {
            try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) {
                for (Path f : files) {
                    java.util.zip.ZipEntry entry = new java.util.zip.ZipEntry(f.getFileName().toString());
                    ZipEntry entry = new ZipEntry(f.getFileName().toString());
                    zos.putNextEntry(entry);
                    Files.copy(f, zos);
                    zos.closeEntry();
@@ -601,11 +584,15 @@
            if (Cools.isEmpty(deviceNo) || !deviceNo.chars().allMatch(Character::isDigit)) {
                return R.error("设备编号错误");
            }
            String stationId = param.getString("stationId");
            if (isDevpType(type) && (Cools.isEmpty(stationId) || !stationId.chars().allMatch(Character::isDigit))) {
                return R.error("站点编号错误");
            }
            Path dayDir = Paths.get(loggingPath, dayClean);
            if (!Files.exists(dayDir) || !Files.isDirectory(dayDir)) {
                return R.error("当日目录不存在");
            }
            List<Path> files = findDeviceFiles(dayDir, dayClean, type, deviceNo);
            List<Path> files = findDeviceFiles(dayDir, dayClean, type, deviceNo, stationId);
            if ((offset != null && offset >= files.size())) {
                return R.error("起始序号超出范围");
            }
@@ -732,9 +719,11 @@
                x.put("type", item.type);
                x.put("typeLabel", item.typeLabel);
                x.put("deviceNo", item.deviceNo);
                x.put("stationId", item.stationId);
                x.put("fileCount", item.fileCount);
                x.put("firstTime", item.firstTime);
                x.put("lastTime", item.lastTime);
                x.put("stationIds", item.stationIds == null ? Collections.emptyList() : item.stationIds);
                return x;
            }).collect(Collectors.toList()));
            groups.add(group);
@@ -746,6 +735,56 @@
        return result;
    }
    private void enrichDevpStationIds(Collection<DeviceAggregate> aggregates) {
        if (aggregates == null || aggregates.isEmpty() || basDevpService == null) {
            return;
        }
        List<DeviceAggregate> devpAggregates = aggregates.stream()
                .filter(item -> item != null && isDevpType(item.type) && Cools.isEmpty(item.stationId) && !Cools.isEmpty(item.deviceNo))
                .collect(Collectors.toList());
        if (devpAggregates.isEmpty()) {
            return;
        }
        List<Integer> devpNos = devpAggregates.stream()
                .map(item -> parseInteger(item.deviceNo))
                .filter(Objects::nonNull)
                .distinct()
                .collect(Collectors.toList());
        if (devpNos.isEmpty()) {
            return;
        }
        Map<Integer, List<String>> stationIdsByDevpNo = basDevpService.listByIds(devpNos).stream()
                .filter(Objects::nonNull)
                .collect(Collectors.toMap(BasDevp::getDevpNo, this::extractStationIds, (left, right) -> left));
        for (DeviceAggregate aggregate : devpAggregates) {
            Integer devpNo = parseInteger(aggregate.deviceNo);
            if (devpNo != null) {
                aggregate.stationIds = stationIdsByDevpNo.getOrDefault(devpNo, Collections.emptyList());
            }
        }
    }
    private List<String> extractStationIds(BasDevp basDevp) {
        if (basDevp == null) {
            return Collections.emptyList();
        }
        return basDevp.getStationList$().stream()
                .map(StationObjModel::getStationId)
                .filter(Objects::nonNull)
                .map(String::valueOf)
                .distinct()
                .sorted(Comparator.comparingInt(this::parseDeviceNo))
                .collect(Collectors.toList());
    }
    private Integer parseInteger(String value) {
        try {
            return Integer.parseInt(String.valueOf(value));
        } catch (Exception e) {
            return null;
        }
    }
    private String normalizeDay(String day) {
        String dayClean = day == null ? null : day.replaceAll("\\D", "");
        if (dayClean == null || dayClean.length() != 8 || !dayClean.chars().allMatch(Character::isDigit)) {
@@ -754,27 +793,57 @@
        return dayClean;
    }
    private List<Path> findDeviceFiles(Path dayDir, String dayClean, String type, String deviceNo) throws Exception {
        String prefix = type + "_" + deviceNo + "_" + dayClean + "_";
    private List<Path> findDeviceFiles(Path dayDir, String dayClean, String type, String deviceNo, String stationId) throws Exception {
        FileNameInfo target = new FileNameInfo();
        target.type = type;
        target.deviceNo = deviceNo;
        target.stationId = stationId;
        target.day = dayClean;
        Path deviceDir = resolveDeviceDir(dayDir, type, deviceNo);
        if (deviceDir == null || !Files.exists(deviceDir) || !Files.isDirectory(deviceDir)) {
            return Collections.emptyList();
        }
        List<Path> files;
        try (Stream<Path> stream = Files.list(dayDir)) {
        try (Stream<Path> stream = Files.list(deviceDir)) {
            files = stream
                    .filter(p -> {
                        String name = p.getFileName().toString();
                        return name.endsWith(".log") && name.startsWith(prefix);
                    })
                    .filter(p -> !Files.isDirectory(p) && matchesFileInfo(parseFileName(p.getFileName().toString()), target))
                    .collect(Collectors.toList());
        }
        files.sort(Comparator.comparingInt(p -> {
            String n = p.getFileName().toString();
            try {
                String suf = n.substring(prefix.length(), n.length() - 4);
                return Integer.parseInt(suf);
            } catch (Exception e) {
                return Integer.MAX_VALUE;
            }
            FileNameInfo info = parseFileName(p.getFileName().toString());
            return info == null ? Integer.MAX_VALUE : info.index;
        }));
        return files;
    }
    private List<Path> listDayLogFiles(Path dayDir) throws Exception {
        if (dayDir == null || !Files.exists(dayDir) || !Files.isDirectory(dayDir)) {
            return Collections.emptyList();
        }
        List<Path> files = new ArrayList<>();
        try (Stream<Path> typeStream = Files.list(dayDir)) {
            List<Path> typeDirs = typeStream.filter(Files::isDirectory).collect(Collectors.toList());
            for (Path typeDir : typeDirs) {
                try (Stream<Path> deviceStream = Files.list(typeDir)) {
                    List<Path> deviceDirs = deviceStream.filter(Files::isDirectory).collect(Collectors.toList());
                    for (Path deviceDir : deviceDirs) {
                        try (Stream<Path> fileStream = Files.list(deviceDir)) {
                            fileStream
                                    .filter(p -> !Files.isDirectory(p) && p.getFileName().toString().endsWith(".log"))
                                    .forEach(files::add);
                        }
                    }
                }
            }
        }
        return files;
    }
    private Path resolveDeviceDir(Path dayDir, String type, String deviceNo) {
        if (dayDir == null || Cools.isEmpty(type) || Cools.isEmpty(deviceNo)) {
            return null;
        }
        return dayDir.resolve(type).resolve(deviceNo);
    }
    private List<Path> sliceDownloadFiles(List<Path> files, Integer offset, Integer limit) {
@@ -801,22 +870,71 @@
        if (fileName == null || !fileName.endsWith(".log")) {
            return null;
        }
        String[] parts = fileName.split("_", 4);
        String fileNameNoExt = fileName.substring(0, fileName.length() - 4);
        String[] parts = fileNameNoExt.split("_");
        if (parts.length < 4) {
            return null;
        }
        FileNameInfo info = new FileNameInfo();
        info.type = parts[0];
        info.deviceNo = parts[1];
        if (isDevpType(info.type)) {
            if (parts.length != 6 || !"station".equals(parts[2])) {
                return null;
            }
            info.stationId = parts[3];
            info.day = parts[4];
            try {
                info.index = Integer.parseInt(parts[5]);
            } catch (Exception e) {
                return null;
            }
            return info;
        }
        if (parts.length != 4) {
            return null;
        }
        info.day = parts[2];
        try {
            info.index = Integer.parseInt(parts[3].replace(".log", ""));
            info.index = Integer.parseInt(parts[3]);
        } catch (Exception e) {
            return null;
        }
        return info;
    }
    private String buildDeviceKey(String type, String deviceNo, String stationId) {
        StringBuilder builder = new StringBuilder();
        builder.append(String.valueOf(type)).append(":").append(String.valueOf(deviceNo));
        if (isDevpType(type)) {
            builder.append(":").append(String.valueOf(stationId));
        }
        return builder.toString();
    }
    private boolean matchesFileInfo(FileNameInfo actual, FileNameInfo target) {
        if (actual == null || target == null) {
            return false;
        }
        if (!Objects.equals(actual.type, target.type)) {
            return false;
        }
        if (!Objects.equals(actual.deviceNo, target.deviceNo)) {
            return false;
        }
        if (!Objects.equals(actual.day, target.day)) {
            return false;
        }
        if (isDevpType(actual.type)) {
            return Objects.equals(actual.stationId, target.stationId);
        }
        return true;
    }
    private boolean isDevpType(String type) {
        return SlaveType.Devp.name().equals(type);
    }
    private int parseDeviceNo(String deviceNo) {
        try {
            return Integer.parseInt(String.valueOf(deviceNo));
src/main/java/com/zy/asrs/entity/DeviceDataLog.java
@@ -1,16 +1,15 @@
package com.zy.asrs.entity;
import com.core.common.Cools;import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import java.text.SimpleDateFormat;
import java.util.Date;
import org.springframework.format.annotation.DateTimeFormat;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import com.baomidou.mybatisplus.annotation.TableName;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.util.Date;
@Data
@TableName("wcs_device_data_log")
@@ -18,62 +17,60 @@
    private static final long serialVersionUID = 1L;
    @ApiModelProperty(value= "")
    @ApiModelProperty(value = "")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    /**
     * 设备类型
     */
    @ApiModelProperty(value= "设备类型")
    @ApiModelProperty(value = "设备类型")
    private String type;
    /**
     * 设备号
     */
    @ApiModelProperty(value= "设备号")
    @ApiModelProperty(value = "设备号")
    @TableField("device_no")
    private Integer deviceNo;
    /**
     * 站点号
     */
    @ApiModelProperty(value = "站点号")
    @TableField("station_id")
    private Integer stationId;
    /**
     * 源数据
     */
    @ApiModelProperty(value= "源数据")
    @ApiModelProperty(value = "源数据")
    @TableField("origin_data")
    private String originData;
    /**
     * 源数据解析后得到的wcs数据
     */
    @ApiModelProperty(value= "源数据解析后得到的wcs数据")
    @ApiModelProperty(value = "源数据解析后得到的wcs数据")
    @TableField("wcs_data")
    private String wcsData;
    /**
     * 采集时间
     */
    @ApiModelProperty(value= "采集时间")
    @ApiModelProperty(value = "采集时间")
    @TableField("create_time")
    @DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date createTime;
    public DeviceDataLog() {}
    public DeviceDataLog() {
    }
    public DeviceDataLog(String type,Integer deviceNo,String originData,String wcsData,Date createTime) {
    public DeviceDataLog(String type, Integer deviceNo, String originData, String wcsData, Date createTime) {
        this.type = type;
        this.deviceNo = deviceNo;
        this.originData = originData;
        this.wcsData = wcsData;
        this.createTime = createTime;
    }
//    DeviceDataLog deviceDataLog = new DeviceDataLog(
//            null,    // 设备类型
//            null,    // 设备号
//            null,    // 源数据
//            null,    // 源数据解析后得到的wcs数据
//            null    // 采集时间
//    );
}
src/main/java/com/zy/common/utils/RedisUtil.java
@@ -215,6 +215,37 @@
        }
    }
    public boolean multiSet(Map<String, Object> values, long time) {
        if (values == null || values.isEmpty()) {
            return true;
        }
        try {
            redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
                for (Map.Entry<String, Object> entry : values.entrySet()) {
                    if (entry == null || entry.getKey() == null) {
                        continue;
                    }
                    byte[] keyBytes = redisTemplate.getStringSerializer().serialize(entry.getKey());
                    byte[] valueBytes = redisTemplate.getValueSerializer().serialize(entry.getValue());
                    if (keyBytes == null || valueBytes == null) {
                        continue;
                    }
                    if (time > 0) {
                        connection.stringCommands().setEx(keyBytes, time, valueBytes);
                    } else {
                        connection.stringCommands().set(keyBytes, valueBytes);
                    }
                }
                return null;
            });
            redisTemplate.execute((RedisCallback<Void>) connection -> null);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }
    /**
     * 递增
     *
src/main/java/com/zy/core/task/DeviceAsyncLogPublisher.java
New file
@@ -0,0 +1,82 @@
package com.zy.core.task;
import com.zy.asrs.entity.DeviceDataLog;
import com.zy.common.utils.RedisUtil;
import com.zy.core.utils.DeviceLogRedisKeyBuilder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
@Slf4j
@Component
public class DeviceAsyncLogPublisher {
    private static final String LANE_PREFIX = "device-log-publish-";
    private static final String TASK_NAME = "publish-device-log";
    private static final long LOG_TTL_SECONDS = 60L * 60L * 24L;
    private static final long MIN_INTERVAL_MS = 0L;
    private final ConcurrentHashMap<String, AtomicReference<DeviceDataLog>> pendingByLane = new ConcurrentHashMap<>();
    @Autowired
    private MainProcessTaskSubmitter mainProcessTaskSubmitter;
    @Autowired
    private RedisUtil redisUtil;
    public void publishLatest(DeviceDataLog deviceDataLog) {
        String laneKey = buildLaneKey(deviceDataLog);
        if (laneKey == null) {
            return;
        }
        pendingByLane
                .computeIfAbsent(laneKey, key -> new AtomicReference<>())
                .set(deviceDataLog);
        boolean submitted = mainProcessTaskSubmitter.submitKeyedSerialTask(
                LANE_PREFIX,
                laneKey,
                TASK_NAME,
                MIN_INTERVAL_MS,
                () -> drain(laneKey)
        );
        if (!submitted) {
            log.debug("Skip duplicate device async log publish submit, laneKey={}", laneKey);
        }
    }
    private void drain(String laneKey) {
        AtomicReference<DeviceDataLog> pending = pendingByLane.get(laneKey);
        if (pending == null) {
            return;
        }
        while (true) {
            DeviceDataLog deviceDataLog = pending.getAndSet(null);
            if (deviceDataLog == null) {
                return;
            }
            try {
                boolean success = redisUtil.set(DeviceLogRedisKeyBuilder.build(deviceDataLog), deviceDataLog, LOG_TTL_SECONDS);
                if (!success) {
                    pending.compareAndSet(null, deviceDataLog);
                    log.warn("Device async log publish failed, keep latest pending snapshot, type={}, deviceNo={}, stationId={}",
                            deviceDataLog.getType(), deviceDataLog.getDeviceNo(), deviceDataLog.getStationId());
                    return;
                }
            } catch (Exception e) {
                pending.compareAndSet(null, deviceDataLog);
                log.error("Device async log publish error, type={}, deviceNo={}, stationId={}",
                        deviceDataLog.getType(), deviceDataLog.getDeviceNo(), deviceDataLog.getStationId(), e);
                return;
            }
        }
    }
    private String buildLaneKey(DeviceDataLog deviceDataLog) {
        if (deviceDataLog == null || deviceDataLog.getType() == null || deviceDataLog.getDeviceNo() == null) {
            return null;
        }
        return deviceDataLog.getType() + ":" + deviceDataLog.getDeviceNo();
    }
}
src/main/java/com/zy/core/task/DeviceLogScheduler.java
@@ -7,6 +7,7 @@
import com.zy.asrs.service.DeviceDataLogService;
import com.zy.common.utils.RedisUtil;
import com.zy.core.enums.RedisKeyType;
import com.zy.core.enums.SlaveType;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
@@ -18,21 +19,21 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.List;
import java.util.ArrayList;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Stream;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Slf4j
@Component
@@ -59,7 +60,7 @@
    public void delDeviceLog() {
        if ("mysql".equals(storageType)) {
            deviceDataLogService.clearLog(expireDays == null ? 1 : expireDays);
        }else if ("file".equals(storageType)) {
        } else if ("file".equals(storageType)) {
            if (!FILE_OP_LOCK.tryLock()) {
                return;
            }
@@ -68,7 +69,7 @@
            } finally {
                FILE_OP_LOCK.unlock();
            }
        }else {
        } else {
            log.error("未定义的存储类型:{}", storageType);
        }
    }
@@ -90,7 +91,7 @@
        if (!list.isEmpty()) {
            if ("mysql".equals(storageType)) {
                mysqlSave(keys, list);
            }else if ("file".equals(storageType)) {
            } else if ("file".equals(storageType)) {
                if (!FILE_OP_LOCK.tryLock()) {
                    return;
                }
@@ -99,7 +100,7 @@
                } finally {
                    FILE_OP_LOCK.unlock();
                }
            }else {
            } else {
                log.error("未定义的存储类型:{}", storageType);
            }
        }
@@ -143,22 +144,40 @@
            SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd");
            Map<String, Map<String, List<DeviceDataLog>>> group = new HashMap<>();
            for (DeviceDataLog logItem : list) {
                String typeName = logItem.getType();
                String datePart = sdf.format(logItem.getCreateTime() == null ? new Date() : logItem.getCreateTime());
                String prefix = typeName + "_" + String.valueOf(logItem.getDeviceNo()) + "_" + datePart + "_";
                String prefix = buildFilePrefix(logItem, datePart);
                if (prefix == null) {
                    continue;
                }
                String deviceFolderKey = buildDeviceFolderKey(logItem);
                if (deviceFolderKey == null) {
                    continue;
                }
                group.computeIfAbsent(datePart, k -> new HashMap<>())
                        .computeIfAbsent(prefix, k -> new ArrayList<>())
                        .computeIfAbsent(deviceFolderKey, k -> new ArrayList<>())
                        .add(logItem);
            }
            for (Map.Entry<String, Map<String, List<DeviceDataLog>>> dateEntry : group.entrySet()) {
                Path dayDir = baseDir.resolve(dateEntry.getKey());
                Files.createDirectories(dayDir);
                for (Map.Entry<String, List<DeviceDataLog>> entry : dateEntry.getValue().entrySet()) {
                    String prefix = entry.getKey();
                    List<DeviceDataLog> logs = entry.getValue();
                    if (logs == null || logs.isEmpty()) {
                        continue;
                    }
                    DeviceDataLog firstLog = logs.get(0);
                    Path deviceDir = resolveDeviceDir(dayDir, firstLog);
                    if (deviceDir == null) {
                        continue;
                    }
                    Files.createDirectories(deviceDir);
                    String prefix = buildFilePrefix(firstLog, dateEntry.getKey());
                    if (prefix == null) {
                        continue;
                    }
                    logs.sort(Comparator.comparing(DeviceDataLog::getCreateTime, Comparator.nullsLast(Date::compareTo)));
                    int index = findStartIndex(dayDir, prefix);
                    Path current = dayDir.resolve(prefix + index + ".log");
                    int index = findStartIndex(deviceDir, prefix);
                    Path current = deviceDir.resolve(prefix + index + ".log");
                    if (!Files.exists(current)) {
                        Files.createFile(current);
                    }
@@ -169,7 +188,7 @@
                        byte[] line = (json + System.lineSeparator()).getBytes(StandardCharsets.UTF_8);
                        if (size + line.length > max) {
                            index++;
                            current = dayDir.resolve(prefix + index + ".log");
                            current = deviceDir.resolve(prefix + index + ".log");
                            if (!Files.exists(current)) {
                                Files.createFile(current);
                            }
@@ -184,6 +203,34 @@
        } catch (Exception e) {
            log.error("设备日志文件存储失败", e);
        }
    }
    private String buildFilePrefix(DeviceDataLog logItem, String datePart) {
        if (logItem == null || logItem.getType() == null || logItem.getDeviceNo() == null) {
            return null;
        }
        if (String.valueOf(SlaveType.Devp).equals(logItem.getType())) {
            if (logItem.getStationId() == null) {
                log.warn("跳过缺少站点号的输送设备日志, deviceNo={}, createTime={}", logItem.getDeviceNo(), logItem.getCreateTime());
                return null;
            }
            return logItem.getType() + "_" + logItem.getDeviceNo() + "_station_" + logItem.getStationId() + "_" + datePart + "_";
        }
        return logItem.getType() + "_" + logItem.getDeviceNo() + "_" + datePart + "_";
    }
    private String buildDeviceFolderKey(DeviceDataLog logItem) {
        if (logItem == null || logItem.getType() == null || logItem.getDeviceNo() == null) {
            return null;
        }
        return logItem.getType() + ":" + logItem.getDeviceNo();
    }
    private Path resolveDeviceDir(Path dayDir, DeviceDataLog logItem) {
        if (dayDir == null || logItem == null || logItem.getType() == null || logItem.getDeviceNo() == null) {
            return null;
        }
        return dayDir.resolve(logItem.getType()).resolve(String.valueOf(logItem.getDeviceNo()));
    }
    private int findStartIndex(Path baseDir, String prefix) throws Exception {
@@ -206,7 +253,8 @@
                    if (val > maxIdx) {
                        maxIdx = val;
                    }
                } catch (NumberFormatException ignored) {}
                } catch (NumberFormatException ignored) {
                }
            }
        }
        int candidate = maxIdx == 0 ? 1 : maxIdx;
@@ -242,7 +290,8 @@
                            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                                try {
                                    Files.deleteIfExists(file);
                                } catch (Exception ignored) {}
                                } catch (Exception ignored) {
                                }
                                return FileVisitResult.CONTINUE;
                            }
@@ -250,7 +299,8 @@
                            public FileVisitResult postVisitDirectory(Path dir, java.io.IOException exc) {
                                try {
                                    Files.deleteIfExists(dir);
                                } catch (Exception ignored) {}
                                } catch (Exception ignored) {
                                }
                                return FileVisitResult.CONTINUE;
                            }
                        });
src/main/java/com/zy/core/thread/impl/ZyRgvThread.java
@@ -25,7 +25,7 @@
import com.zy.core.network.ZyRgvConnectDriver;
import com.zy.core.network.entity.ZyRgvStatusEntity;
import com.zy.core.thread.RgvThread;
import com.zy.core.utils.DeviceLogRedisKeyBuilder;
import com.zy.core.task.DeviceAsyncLogPublisher;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
@@ -43,10 +43,12 @@
    private ZyRgvConnectDriver zyRgvConnectDriver;
    private RgvProtocol rgvProtocol;
    private int deviceLogCollectTime = 200;
    private final DeviceAsyncLogPublisher deviceAsyncLogPublisher;
    public ZyRgvThread(DeviceConfig deviceConfig, RedisUtil redisUtil) {
        this.deviceConfig = deviceConfig;
        this.redisUtil = redisUtil;
        this.deviceAsyncLogPublisher = SpringUtils.getBean(DeviceAsyncLogPublisher.class);
    }
    @Override
@@ -140,7 +142,7 @@
            deviceDataLog.setType(String.valueOf(SlaveType.Rgv));
            deviceDataLog.setDeviceNo(rgvProtocol.getRgvNo());
            deviceDataLog.setCreateTime(new Date());
            redisUtil.set(DeviceLogRedisKeyBuilder.build(deviceDataLog), deviceDataLog, 60 * 60 * 24);
            deviceAsyncLogPublisher.publishLatest(deviceDataLog);
            rgvProtocol.setDeviceDataLog(System.currentTimeMillis());
        }
src/main/java/com/zy/core/thread/impl/ZySiemensCrnThread.java
@@ -26,7 +26,7 @@
import com.zy.core.network.entity.ZyCrnStatusEntity;
import com.zy.core.service.WrkCommandRollbackService;
import com.zy.core.thread.CrnThread;
import com.zy.core.utils.DeviceLogRedisKeyBuilder;
import com.zy.core.task.DeviceAsyncLogPublisher;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
@@ -47,10 +47,12 @@
    private ZyCrnConnectDriver zyCrnConnectDriver;
    private CrnProtocol crnProtocol;
    private int deviceLogCollectTime = 200;
    private final DeviceAsyncLogPublisher deviceAsyncLogPublisher;
    public ZySiemensCrnThread(DeviceConfig deviceConfig, RedisUtil redisUtil) {
        this.deviceConfig = deviceConfig;
        this.redisUtil = redisUtil;
        this.deviceAsyncLogPublisher = SpringUtils.getBean(DeviceAsyncLogPublisher.class);
    }
    @Override
@@ -200,7 +202,7 @@
            deviceDataLog.setDeviceNo(crnProtocol.getCrnNo());
            deviceDataLog.setCreateTime(new Date());
            redisUtil.set(DeviceLogRedisKeyBuilder.build(deviceDataLog), deviceDataLog, 60 * 60 * 24);
            deviceAsyncLogPublisher.publishLatest(deviceDataLog);
            //更新采集时间
            crnProtocol.setDeviceDataLog(System.currentTimeMillis());
        }
src/main/java/com/zy/core/thread/impl/ZySiemensCrnV2Thread.java
@@ -25,7 +25,7 @@
import com.zy.core.network.entity.ZyCrnStatusEntity;
import com.zy.core.service.WrkCommandRollbackService;
import com.zy.core.thread.CrnThread;
import com.zy.core.utils.DeviceLogRedisKeyBuilder;
import com.zy.core.task.DeviceAsyncLogPublisher;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
@@ -44,10 +44,12 @@
    private ZyCrnV2ConnectDriver zyCrnConnectDriver;
    private CrnProtocol crnProtocol;
    private int deviceLogCollectTime = 200;
    private final DeviceAsyncLogPublisher deviceAsyncLogPublisher;
    public ZySiemensCrnV2Thread(DeviceConfig deviceConfig, RedisUtil redisUtil) {
        this.deviceConfig = deviceConfig;
        this.redisUtil = redisUtil;
        this.deviceAsyncLogPublisher = SpringUtils.getBean(DeviceAsyncLogPublisher.class);
    }
    @Override
@@ -199,7 +201,7 @@
            deviceDataLog.setDeviceNo(crnProtocol.getCrnNo());
            deviceDataLog.setCreateTime(new Date());
            redisUtil.set(DeviceLogRedisKeyBuilder.build(deviceDataLog), deviceDataLog, 60 * 60 * 24);
            deviceAsyncLogPublisher.publishLatest(deviceDataLog);
            //更新采集时间
            crnProtocol.setDeviceDataLog(System.currentTimeMillis());
        }
src/main/java/com/zy/core/thread/impl/ZySiemensDualCrnThread.java
@@ -26,7 +26,7 @@
import com.zy.core.network.entity.ZyDualCrnStatusEntity;
import com.zy.core.thread.DualCrnThread;
import com.zy.core.thread.StationThread;
import com.zy.core.utils.DeviceLogRedisKeyBuilder;
import com.zy.core.task.DeviceAsyncLogPublisher;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
@@ -48,10 +48,12 @@
    private ZyDualCrnConnectDriver zyDualCrnConnectDriver;
    private DualCrnProtocol crnProtocol;
    private int deviceLogCollectTime = 200;
    private final DeviceAsyncLogPublisher deviceAsyncLogPublisher;
    public ZySiemensDualCrnThread(DeviceConfig deviceConfig, RedisUtil redisUtil) {
        this.deviceConfig = deviceConfig;
        this.redisUtil = redisUtil;
        this.deviceAsyncLogPublisher = SpringUtils.getBean(DeviceAsyncLogPublisher.class);
    }
    @Override
@@ -372,7 +374,7 @@
            deviceDataLog.setDeviceNo(crnProtocol.getCrnNo());
            deviceDataLog.setCreateTime(new Date());
            redisUtil.set(DeviceLogRedisKeyBuilder.build(deviceDataLog), deviceDataLog, 60 * 60 * 24);
            deviceAsyncLogPublisher.publishLatest(deviceDataLog);
            //更新采集时间
            crnProtocol.setDeviceDataLog(System.currentTimeMillis());
        }
src/main/java/com/zy/core/thread/impl/ZyStationThread.java
@@ -28,11 +28,11 @@
import com.zy.core.model.command.StationCommand;
import com.zy.core.model.protocol.StationProtocol;
import com.zy.core.network.entity.ZyStationStatusEntity;
import com.zy.core.task.DeviceAsyncLogPublisher;
import java.text.MessageFormat;
import java.util.*;
import com.zy.core.utils.DeviceLogRedisKeyBuilder;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
@@ -51,11 +51,13 @@
    private int deviceLogCollectTime = 200;
    private long deviceDataLogTime = System.currentTimeMillis();
    private final RecentStationArrivalTracker recentArrivalTracker;
    private final DeviceAsyncLogPublisher devpAsyncLogPublisher;
    public ZyStationThread(DeviceConfig deviceConfig, RedisUtil redisUtil) {
        this.deviceConfig = deviceConfig;
        this.redisUtil = redisUtil;
        this.recentArrivalTracker = new RecentStationArrivalTracker(redisUtil);
        this.devpAsyncLogPublisher = SpringUtils.getBean(DeviceAsyncLogPublisher.class);
    }
    @Override
@@ -170,20 +172,41 @@
        StationErrLogSupport.sync(deviceConfig, redisUtil, statusList);
        if (System.currentTimeMillis() - deviceDataLogTime > deviceLogCollectTime) {
            //保存数据记录
            DeviceDataLog deviceDataLog = new DeviceDataLog();
            deviceDataLog.setOriginData(JSON.toJSONString(zyStationStatusEntities));
            deviceDataLog.setWcsData(JSON.toJSONString(statusList));
            deviceDataLog.setType(String.valueOf(SlaveType.Devp));
            deviceDataLog.setDeviceNo(deviceConfig.getDeviceNo());
            deviceDataLog.setCreateTime(new Date());
            redisUtil.set(DeviceLogRedisKeyBuilder.build(deviceDataLog), deviceDataLog, 60 * 60 * 24);
            //更新采集时间
            Date createTime = new Date();
            HashMap<Integer, ZyStationStatusEntity> originDataMap = buildStationStatusMap(zyStationStatusEntities);
            for (int i = 0; i < statusList.size(); i++) {
                StationProtocol stationProtocol = statusList.get(i);
                if (stationProtocol == null || stationProtocol.getStationId() == null) {
                    continue;
                }
                DeviceDataLog deviceDataLog = new DeviceDataLog();
                ZyStationStatusEntity originEntity = originDataMap.get(stationProtocol.getStationId());
                deviceDataLog.setOriginData(originEntity == null ? null : JSON.toJSONString(originEntity));
                deviceDataLog.setWcsData(JSON.toJSONString(stationProtocol));
                deviceDataLog.setType(String.valueOf(SlaveType.Devp));
                deviceDataLog.setDeviceNo(deviceConfig.getDeviceNo());
                deviceDataLog.setStationId(stationProtocol.getStationId());
                deviceDataLog.setCreateTime(createTime);
                devpAsyncLogPublisher.publishLatest(deviceDataLog);
            }
            deviceDataLogTime = System.currentTimeMillis();
        }
    }
    private HashMap<Integer, ZyStationStatusEntity> buildStationStatusMap(List<ZyStationStatusEntity> zyStationStatusEntities) {
        HashMap<Integer, ZyStationStatusEntity> map = new HashMap<>();
        if (zyStationStatusEntities == null) {
            return map;
        }
        for (ZyStationStatusEntity statusEntity : zyStationStatusEntities) {
            if (statusEntity == null || statusEntity.getStationId() == null) {
                continue;
            }
            map.put(statusEntity.getStationId(), statusEntity);
        }
        return map;
    }
    @Override
    public boolean connect() {
        zyStationConnectDriver = new ZyStationConnectDriver(deviceConfig, redisUtil);
src/main/java/com/zy/core/thread/impl/ZyStationV3Thread.java
@@ -30,9 +30,9 @@
import com.zy.core.network.DeviceConnectPool;
import com.zy.core.network.ZyStationConnectDriver;
import com.zy.core.network.entity.ZyStationStatusEntity;
import com.zy.core.task.DeviceAsyncLogPublisher;
import com.zy.core.thread.support.RecentStationArrivalTracker;
import com.zy.core.thread.support.StationErrLogSupport;
import com.zy.core.utils.DeviceLogRedisKeyBuilder;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
@@ -59,11 +59,13 @@
    private long deviceDataLogTime = System.currentTimeMillis();
    private ExecutorService executor = Executors.newFixedThreadPool(9999);
    private final RecentStationArrivalTracker recentArrivalTracker;
    private final DeviceAsyncLogPublisher devpAsyncLogPublisher;
    public ZyStationV3Thread(DeviceConfig deviceConfig, RedisUtil redisUtil) {
        this.deviceConfig = deviceConfig;
        this.redisUtil = redisUtil;
        this.recentArrivalTracker = new RecentStationArrivalTracker(redisUtil);
        this.devpAsyncLogPublisher = SpringUtils.getBean(DeviceAsyncLogPublisher.class);
    }
    @Override
@@ -176,18 +178,41 @@
        StationErrLogSupport.sync(deviceConfig, redisUtil, statusList);
        if (System.currentTimeMillis() - deviceDataLogTime > deviceLogCollectTime) {
            DeviceDataLog deviceDataLog = new DeviceDataLog();
            deviceDataLog.setOriginData(JSON.toJSONString(zyStationStatusEntities));
            deviceDataLog.setWcsData(JSON.toJSONString(statusList));
            deviceDataLog.setType(String.valueOf(SlaveType.Devp));
            deviceDataLog.setDeviceNo(deviceConfig.getDeviceNo());
            deviceDataLog.setCreateTime(new Date());
            redisUtil.set(DeviceLogRedisKeyBuilder.build(deviceDataLog), deviceDataLog, 60 * 60 * 24);
            Date createTime = new Date();
            HashMap<Integer, ZyStationStatusEntity> originDataMap = buildStationStatusMap(zyStationStatusEntities);
            for (int i = 0; i < statusList.size(); i++) {
                StationProtocol stationProtocol = statusList.get(i);
                if (stationProtocol == null || stationProtocol.getStationId() == null) {
                    continue;
                }
                DeviceDataLog deviceDataLog = new DeviceDataLog();
                ZyStationStatusEntity originEntity = originDataMap.get(stationProtocol.getStationId());
                deviceDataLog.setOriginData(originEntity == null ? null : JSON.toJSONString(originEntity));
                deviceDataLog.setWcsData(JSON.toJSONString(stationProtocol));
                deviceDataLog.setType(String.valueOf(SlaveType.Devp));
                deviceDataLog.setDeviceNo(deviceConfig.getDeviceNo());
                deviceDataLog.setStationId(stationProtocol.getStationId());
                deviceDataLog.setCreateTime(createTime);
                devpAsyncLogPublisher.publishLatest(deviceDataLog);
            }
            deviceDataLogTime = System.currentTimeMillis();
        }
    }
    private HashMap<Integer, ZyStationStatusEntity> buildStationStatusMap(List<ZyStationStatusEntity> zyStationStatusEntities) {
        HashMap<Integer, ZyStationStatusEntity> map = new HashMap<>();
        if (zyStationStatusEntities == null) {
            return map;
        }
        for (ZyStationStatusEntity statusEntity : zyStationStatusEntities) {
            if (statusEntity == null || statusEntity.getStationId() == null) {
                continue;
            }
            map.put(statusEntity.getStationId(), statusEntity);
        }
        return map;
    }
    @Override
    public boolean connect() {
        zyStationConnectDriver = new ZyStationConnectDriver(deviceConfig, redisUtil);
src/main/java/com/zy/core/thread/impl/ZyStationV4Thread.java
@@ -30,10 +30,10 @@
import com.zy.core.network.DeviceConnectPool;
import com.zy.core.network.ZyStationConnectDriver;
import com.zy.core.network.entity.ZyStationStatusEntity;
import com.zy.core.task.DeviceAsyncLogPublisher;
import com.zy.core.thread.impl.v5.StationMoveSegmentExecutor;
import com.zy.core.thread.support.RecentStationArrivalTracker;
import com.zy.core.thread.support.StationErrLogSupport;
import com.zy.core.utils.DeviceLogRedisKeyBuilder;
import com.zy.system.entity.Config;
import com.zy.system.service.ConfigService;
import lombok.Data;
@@ -61,12 +61,14 @@
    private long deviceDataLogTime = System.currentTimeMillis();
    private ExecutorService executor = Executors.newFixedThreadPool(9999);
    private final RecentStationArrivalTracker recentArrivalTracker;
    private final DeviceAsyncLogPublisher devpAsyncLogPublisher;
    public ZyStationV4Thread(DeviceConfig deviceConfig, RedisUtil redisUtil) {
        this.deviceConfig = deviceConfig;
        this.redisUtil = redisUtil;
        this.recentArrivalTracker = new RecentStationArrivalTracker(redisUtil);
        this.segmentExecutor = new StationMoveSegmentExecutor(deviceConfig, redisUtil, this::sendCommand);
        this.devpAsyncLogPublisher = SpringUtils.getBean(DeviceAsyncLogPublisher.class);
    }
    @Override
@@ -193,18 +195,41 @@
        StationErrLogSupport.sync(deviceConfig, redisUtil, statusList);
        if (System.currentTimeMillis() - deviceDataLogTime > deviceLogCollectTime) {
            DeviceDataLog deviceDataLog = new DeviceDataLog();
            deviceDataLog.setOriginData(JSON.toJSONString(zyStationStatusEntities));
            deviceDataLog.setWcsData(JSON.toJSONString(statusList));
            deviceDataLog.setType(String.valueOf(SlaveType.Devp));
            deviceDataLog.setDeviceNo(deviceConfig.getDeviceNo());
            deviceDataLog.setCreateTime(new Date());
            redisUtil.set(DeviceLogRedisKeyBuilder.build(deviceDataLog), deviceDataLog, 60 * 60 * 24);
            Date createTime = new Date();
            HashMap<Integer, ZyStationStatusEntity> originDataMap = buildStationStatusMap(zyStationStatusEntities);
            for (int i = 0; i < statusList.size(); i++) {
                StationProtocol stationProtocol = statusList.get(i);
                if (stationProtocol == null || stationProtocol.getStationId() == null) {
                    continue;
                }
                DeviceDataLog deviceDataLog = new DeviceDataLog();
                ZyStationStatusEntity originEntity = originDataMap.get(stationProtocol.getStationId());
                deviceDataLog.setOriginData(originEntity == null ? null : JSON.toJSONString(originEntity));
                deviceDataLog.setWcsData(JSON.toJSONString(stationProtocol));
                deviceDataLog.setType(String.valueOf(SlaveType.Devp));
                deviceDataLog.setDeviceNo(deviceConfig.getDeviceNo());
                deviceDataLog.setStationId(stationProtocol.getStationId());
                deviceDataLog.setCreateTime(createTime);
                devpAsyncLogPublisher.publishLatest(deviceDataLog);
            }
            deviceDataLogTime = System.currentTimeMillis();
        }
    }
    private HashMap<Integer, ZyStationStatusEntity> buildStationStatusMap(List<ZyStationStatusEntity> zyStationStatusEntities) {
        HashMap<Integer, ZyStationStatusEntity> map = new HashMap<>();
        if (zyStationStatusEntities == null) {
            return map;
        }
        for (ZyStationStatusEntity statusEntity : zyStationStatusEntities) {
            if (statusEntity == null || statusEntity.getStationId() == null) {
                continue;
            }
            map.put(statusEntity.getStationId(), statusEntity);
        }
        return map;
    }
    @Override
    public boolean connect() {
        zyStationConnectDriver = new ZyStationConnectDriver(deviceConfig, redisUtil);
src/main/java/com/zy/core/thread/impl/v5/StationV5StatusReader.java
@@ -17,13 +17,14 @@
import com.zy.core.model.protocol.StationProtocol;
import com.zy.core.network.ZyStationConnectDriver;
import com.zy.core.network.entity.ZyStationStatusEntity;
import com.zy.core.task.DeviceAsyncLogPublisher;
import com.zy.core.thread.support.RecentStationArrivalTracker;
import com.zy.core.thread.support.StationErrLogSupport;
import com.zy.core.utils.DeviceLogRedisKeyBuilder;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
@@ -32,6 +33,7 @@
    private final DeviceConfig deviceConfig;
    private final RedisUtil redisUtil;
    private final RecentStationArrivalTracker recentArrivalTracker;
    private final DeviceAsyncLogPublisher devpAsyncLogPublisher;
    private final List<StationProtocol> statusList = new ArrayList<>();
    private volatile List<Integer> taskNoList = new ArrayList<>();
    private boolean initialized = false;
@@ -43,6 +45,7 @@
        this.deviceConfig = deviceConfig;
        this.redisUtil = redisUtil;
        this.recentArrivalTracker = recentArrivalTracker;
        this.devpAsyncLogPublisher = SpringUtils.getBean(DeviceAsyncLogPublisher.class);
    }
    public void readStatus(ZyStationConnectDriver zyStationConnectDriver) {
@@ -128,18 +131,41 @@
        StationErrLogSupport.sync(deviceConfig, redisUtil, statusList);
        if (System.currentTimeMillis() - deviceDataLogTime > deviceLogCollectTime) {
            DeviceDataLog deviceDataLog = new DeviceDataLog();
            deviceDataLog.setOriginData(JSON.toJSONString(zyStationStatusEntities));
            deviceDataLog.setWcsData(JSON.toJSONString(statusList));
            deviceDataLog.setType(String.valueOf(SlaveType.Devp));
            deviceDataLog.setDeviceNo(deviceConfig.getDeviceNo());
            deviceDataLog.setCreateTime(new Date());
            redisUtil.set(DeviceLogRedisKeyBuilder.build(deviceDataLog), deviceDataLog, 60 * 60 * 24);
            Date createTime = new Date();
            HashMap<Integer, ZyStationStatusEntity> originDataMap = buildStationStatusMap(zyStationStatusEntities);
            for (int i = 0; i < statusList.size(); i++) {
                StationProtocol stationProtocol = statusList.get(i);
                if (stationProtocol == null || stationProtocol.getStationId() == null) {
                    continue;
                }
                DeviceDataLog deviceDataLog = new DeviceDataLog();
                ZyStationStatusEntity originEntity = originDataMap.get(stationProtocol.getStationId());
                deviceDataLog.setOriginData(originEntity == null ? null : JSON.toJSONString(originEntity));
                deviceDataLog.setWcsData(JSON.toJSONString(stationProtocol));
                deviceDataLog.setType(String.valueOf(SlaveType.Devp));
                deviceDataLog.setDeviceNo(deviceConfig.getDeviceNo());
                deviceDataLog.setStationId(stationProtocol.getStationId());
                deviceDataLog.setCreateTime(createTime);
                devpAsyncLogPublisher.publishLatest(deviceDataLog);
            }
            deviceDataLogTime = System.currentTimeMillis();
        }
    }
    private HashMap<Integer, ZyStationStatusEntity> buildStationStatusMap(List<ZyStationStatusEntity> zyStationStatusEntities) {
        HashMap<Integer, ZyStationStatusEntity> map = new HashMap<>();
        if (zyStationStatusEntities == null) {
            return map;
        }
        for (ZyStationStatusEntity statusEntity : zyStationStatusEntities) {
            if (statusEntity == null || statusEntity.getStationId() == null) {
                continue;
            }
            map.put(statusEntity.getStationId(), statusEntity);
        }
        return map;
    }
    public List<StationProtocol> getStatusList() {
        return statusList;
    }
src/main/java/com/zy/core/utils/DeviceLogRedisKeyBuilder.java
@@ -2,6 +2,7 @@
import com.zy.asrs.entity.DeviceDataLog;
import com.zy.core.enums.RedisKeyType;
import com.zy.core.enums.SlaveType;
import java.util.UUID;
@@ -14,10 +15,19 @@
        String deviceNo = deviceDataLog == null || deviceDataLog.getDeviceNo() == null
                ? "unknown"
                : String.valueOf(deviceDataLog.getDeviceNo());
        String stationPart = buildStationPart(deviceDataLog, type);
        long millis = deviceDataLog != null && deviceDataLog.getCreateTime() != null
                ? deviceDataLog.getCreateTime().getTime()
                : System.currentTimeMillis();
        String uuid = UUID.randomUUID().toString().replace("-", "");
        return RedisKeyType.DEVICE_LOG_KEY.key + type + ":" + deviceNo + ":" + millis + ":" + uuid;
        return RedisKeyType.DEVICE_LOG_KEY.key + type + ":" + deviceNo + stationPart + ":" + millis + ":" + uuid;
    }
    private static String buildStationPart(DeviceDataLog deviceDataLog, String type) {
        if (!String.valueOf(SlaveType.Devp).equals(type)) {
            return "";
        }
        Integer stationId = deviceDataLog == null ? null : deviceDataLog.getStationId();
        return stationId == null ? ":station:unknown" : ":station:" + stationId;
    }
}
src/main/webapp/static/js/deviceLogs/deviceLogs.js
@@ -16,6 +16,7 @@
        },
        selectedDay: '',
        searchDeviceNo: '',
        searchStationId: '',
        activeType: '',
        viewMode: 'picker',
        deviceSummary: {
@@ -31,6 +32,8 @@
        selectedType: '',
        selectedDeviceNo: '',
        selectedStationId: '',
        visualFocusStationId: '',
        activeDeviceKey: '',
        timelineMeta: {
@@ -123,12 +126,38 @@
        filteredDevices: function () {
            var group = this.activeGroup;
            var devices = group && group.devices ? group.devices.slice() : [];
            var keyword = String(this.searchDeviceNo || '').trim();
            if (!keyword) {
                return devices;
            }
            return devices.filter(function (item) {
                return String(item.deviceNo).indexOf(keyword) >= 0;
            var deviceKeyword = String(this.searchDeviceNo || '').trim();
            var stationKeyword = String(this.searchStationId || '').trim();
            return devices.map(function (item) {
                var matchedStationId = '';
                var matchesDeviceNo = !deviceKeyword || String(item.deviceNo).indexOf(deviceKeyword) >= 0;
                var matchesStation = true;
                if (stationKeyword) {
                    matchesStation = false;
                    if (item.type === 'Devp') {
                        if (item.stationId && String(item.stationId).indexOf(stationKeyword) >= 0) {
                            matchesStation = true;
                            matchedStationId = String(item.stationId);
                        } else {
                            var stationIds = Array.isArray(item.stationIds) ? item.stationIds : [];
                            for (var i = 0; i < stationIds.length; i++) {
                                if (String(stationIds[i]).indexOf(stationKeyword) >= 0) {
                                    matchesStation = true;
                                    matchedStationId = String(stationIds[i]);
                                    break;
                                }
                            }
                        }
                    }
                }
                if (!matchesDeviceNo || !matchesStation) {
                    return null;
                }
                return Object.assign({}, item, {
                    matchedStationId: matchedStationId
                });
            }).filter(function (item) {
                return !!item;
            });
        },
        selectedDeviceSummary: function () {
@@ -136,7 +165,7 @@
            var found = null;
            (this.deviceGroups || []).forEach(function (group) {
                (group.devices || []).forEach(function (device) {
                    if (this.buildDeviceKey(device.type, device.deviceNo) === key) {
                    if (this.buildDeviceKey(device.type, device.deviceNo, device.stationId) === key) {
                        found = device;
                    }
                }, this);
@@ -228,6 +257,9 @@
            return this.selectedLogRow ? this.selectedLogRow._visualItems : [];
        },
        visualParam: function () {
            if (this.selectedType === 'Devp') {
                return this.buildVisualParam(this.selectedType, this.selectedDeviceNo, this.resolveVisualStationId(this.selectedLogRow));
            }
            return this.selectedLogRow ? this.selectedLogRow._visualParam : {};
        },
        activeRawText: function () {
@@ -420,6 +452,7 @@
            }
            this.selectedDay = day;
            this.searchDeviceNo = '';
            this.searchStationId = '';
            this.pause();
            this.summaryLoading = true;
            this.resetSelectionState();
@@ -471,6 +504,9 @@
                            type: device.type,
                            typeLabel: device.typeLabel || self.typeLabels[device.type] || device.type,
                            deviceNo: String(device.deviceNo),
                            stationId: device.stationId == null ? '' : String(device.stationId),
                            stationIds: (device.stationIds || []).map(function (stationId) { return String(stationId); }),
                            matchedStationId: '',
                            fileCount: device.fileCount || 0,
                            firstTime: device.firstTime || 0,
                            lastTime: device.lastTime || 0
@@ -536,6 +572,8 @@
            this.viewMode = 'picker';
            this.selectedType = '';
            this.selectedDeviceNo = '';
            this.selectedStationId = '';
            this.visualFocusStationId = '';
            this.activeDeviceKey = '';
            this.detailTab = 'logs';
            this.rawTab = 'wcs';
@@ -553,7 +591,7 @@
            if (!device) {
                return;
            }
            var nextKey = this.buildDeviceKey(device.type, device.deviceNo);
            var nextKey = this.buildDeviceKey(device.type, device.deviceNo, device.stationId);
            if (this.activeDeviceKey === nextKey && this.logRows.length) {
                return;
            }
@@ -562,6 +600,10 @@
            this.activeType = device.type;
            this.selectedType = device.type;
            this.selectedDeviceNo = String(device.deviceNo);
            this.selectedStationId = device.stationId == null ? '' : String(device.stationId);
            this.visualFocusStationId = device.stationId == null || String(device.stationId) === ''
                ? (device.matchedStationId == null ? '' : String(device.matchedStationId))
                : String(device.stationId);
            this.activeDeviceKey = nextKey;
            this.detailTab = 'raw';
            this.rawTab = 'wcs';
@@ -572,6 +614,25 @@
            this.selectedTimestamp = 0;
            this.logLoadError = '';
            this.loadTimeline();
        },
        resolveVisualStationId: function (logItem) {
            if (this.selectedType !== 'Devp') {
                return '';
            }
            if (logItem && logItem.stationId != null && String(logItem.stationId) !== '') {
                return String(logItem.stationId);
            }
            if (this.visualFocusStationId) {
                return String(this.visualFocusStationId);
            }
            var selected = this.selectedDeviceSummary;
            if (selected && selected.stationId) {
                return String(selected.stationId);
            }
            if (selected && selected.matchedStationId) {
                return String(selected.matchedStationId);
            }
            return '';
        },
        returnToSelector: function () {
            this.pause();
@@ -590,7 +651,8 @@
                method: 'GET',
                data: {
                    type: this.selectedType,
                    deviceNo: this.selectedDeviceNo
                    deviceNo: this.selectedDeviceNo,
                    stationId: this.selectedStationId || undefined
                },
                success: function (res) {
                    self.timelineLoading = false;
@@ -627,6 +689,7 @@
            timeline.type = data.type || this.selectedType;
            timeline.typeLabel = data.typeLabel || this.typeLabels[timeline.type] || timeline.type;
            timeline.deviceNo = String(data.deviceNo || this.selectedDeviceNo || '');
            timeline.stationId = data.stationId == null ? this.selectedStationId : String(data.stationId || '');
            timeline.startTime = data.startTime || 0;
            timeline.endTime = data.endTime || 0;
            timeline.totalFiles = data.totalFiles || 0;
@@ -685,6 +748,7 @@
                data: {
                    type: this.selectedType,
                    deviceNo: this.selectedDeviceNo,
                    stationId: this.selectedStationId || undefined,
                    offset: offset,
                    limit: batchSize
                },
@@ -741,12 +805,13 @@
            var protocol = this.safeParse(logItem && logItem.wcsData);
            var visualItems = this.buildVisualItems(protocol, this.selectedType);
            return Object.assign({}, logItem, {
                stationId: logItem && logItem.stationId == null ? this.selectedStationId : logItem.stationId,
                _ts: this.parseTimestamp(logItem && logItem.createTime),
                _key: this.buildLogRowKey(logItem),
                _segmentOffset: segmentOffset,
                _protocol: protocol,
                _visualItems: visualItems,
                _visualParam: this.buildVisualParam(this.selectedType, this.selectedDeviceNo),
                _visualParam: this.buildVisualParam(this.selectedType, this.selectedDeviceNo, this.resolveVisualStationId(logItem)),
                _summary: this.buildLogSummary(this.selectedType, visualItems, protocol)
            });
        },
@@ -795,36 +860,17 @@
                };
            }
            if (type === 'Devp') {
                var stations = visualItems || [];
                var autoCount = 0;
                var taskCount = 0;
                var loadingCount = 0;
                var errorStations = [];
                var canInCount = 0;
                for (var i = 0; i < stations.length; i++) {
                    if (this.toBool(stations[i].autoing)) {
                        autoCount += 1;
                    }
                    if (stations[i].taskNo != null && stations[i].taskNo !== '' && Number(stations[i].taskNo) !== 0) {
                        taskCount += 1;
                    }
                    if (this.toBool(stations[i].loading)) {
                        loadingCount += 1;
                    }
                    if (this.toBool(stations[i].inEnable)) {
                        canInCount += 1;
                    }
                    if (stations[i].error || stations[i].errorMsg) {
                        errorStations.push(stations[i].stationId);
                    }
                }
                var statusLabel = errorStations.length ? '故障' : (autoCount === stations.length && stations.length ? '自动' : '手动');
                var station = visualItems[0] || this.transformData(protocol, type) || {};
                var hasError = !!(station.error || station.errorMsg);
                var autoing = this.toBool(station.autoing);
                var loading = this.toBool(station.loading);
                var statusLabel = hasError ? '故障' : (autoing ? '自动' : '手动');
                return {
                    statusLabel: statusLabel,
                    tone: MonitorCardKit.statusTone(statusLabel),
                    title: stations.length + ' 个站点 · 任务 ' + taskCount + ' · 有物 ' + loadingCount,
                    detail: '自动 ' + autoCount + ' / 手动 ' + Math.max(0, stations.length - autoCount) + ' / 可入 ' + canInCount,
                    hint: errorStations.length ? ('异常站点 ' + errorStations.slice(0, 6).join(', ')) : ('站点数组大小 ' + stations.length)
                    title: '站点 ' + MonitorCardKit.orDash(station.stationId) + ' · 任务 ' + MonitorCardKit.orDash(station.taskNo),
                    detail: '目标站点 ' + MonitorCardKit.orDash(station.targetStaNo) + ' / 载货 ' + (loading ? '有物' : '无物') + ' / 条码 ' + MonitorCardKit.orDash(station.barcode),
                    hint: hasError ? (MonitorCardKit.orDash(station.error) + (station.errorMsg ? (' · ' + station.errorMsg) : '')) : ('自动 ' + (autoing ? '是' : '否') + ' / 允许入库 ' + (this.toBool(station.inEnable) ? '是' : '否'))
                };
            }
            return fallback;
@@ -833,25 +879,23 @@
            if (!protocol) {
                return [];
            }
            if (type === 'Devp' && Array.isArray(protocol)) {
                var self = this;
                return protocol.map(function (item) {
                    return self.transformData(item, type);
                }).sort(function (a, b) {
                    return (a.stationId || 0) - (b.stationId || 0);
                });
            if (type === 'Devp') {
                return [this.transformData(protocol, type)];
            }
            if (type !== 'Devp') {
                return [this.transformData(protocol, type)];
            }
            return [];
        },
        buildVisualParam: function (type, deviceNo) {
        buildVisualParam: function (type, deviceNo, stationId) {
            if (type === 'Crn' || type === 'DualCrn') {
                return { crnNo: Number(deviceNo) };
            }
            if (type === 'Rgv') {
                return { rgvNo: Number(deviceNo) };
            }
            if (type === 'Devp' && stationId) {
                return { stationId: Number(stationId) };
            }
            return {};
        },
@@ -1013,6 +1057,7 @@
            return [
                this.selectedType,
                this.selectedDeviceNo,
                this.selectedStationId,
                logItem && logItem.createTime ? logItem.createTime : '',
                this.hashString(logItem && logItem.originData ? logItem.originData : ''),
                this.hashString(logItem && logItem.wcsData ? logItem.wcsData : '')
@@ -1290,6 +1335,7 @@
                data: {
                    type: this.selectedType,
                    deviceNo: this.selectedDeviceNo,
                    stationId: this.selectedStationId || undefined,
                    timestamp: timestamp
                },
                success: function (res) {
@@ -1331,9 +1377,9 @@
            return this.formatTimestamp(this.timelineMeta.startTime + value, true);
        },
        handleCurrentDeviceDownload: function () {
            this.doDownload(this.selectedDay, this.selectedType, this.selectedDeviceNo);
            this.doDownload(this.selectedDay, this.selectedType, this.selectedDeviceNo, this.selectedStationId);
        },
        doDownload: function (day, type, deviceNo) {
        doDownload: function (day, type, deviceNo, stationId) {
            if (!day || !type || !deviceNo) {
                return;
            }
@@ -1345,7 +1391,8 @@
                data: JSON.stringify({
                    day: day,
                    type: type,
                    deviceNo: deviceNo
                    deviceNo: deviceNo,
                    stationId: stationId || undefined
                }),
                dataType: 'json',
                contentType: 'application/json;charset=UTF-8',
@@ -1356,7 +1403,7 @@
                    }
                    var pid = res.data.progressId;
                    self.startDownloadProgress(pid);
                    self.performDownloadRequest(day, type, deviceNo, pid);
                    self.performDownloadRequest(day, type, deviceNo, stationId, pid);
                },
                error: function () {
                    self.$message.error('初始化失败');
@@ -1386,10 +1433,15 @@
                });
            }, 500);
        },
        performDownloadRequest: function (day, type, deviceNo, pid) {
        performDownloadRequest: function (day, type, deviceNo, stationId, pid) {
            var self = this;
            var query = '?type=' + encodeURIComponent(type) + '&deviceNo=' + encodeURIComponent(deviceNo);
            if (stationId) {
                query += '&stationId=' + encodeURIComponent(stationId);
            }
            query += '&progressId=' + encodeURIComponent(pid);
            $.ajax({
                url: baseUrl + '/deviceLog/day/' + day + '/download/auth?type=' + encodeURIComponent(type) + '&deviceNo=' + encodeURIComponent(deviceNo) + '&progressId=' + encodeURIComponent(pid),
                url: baseUrl + '/deviceLog/day/' + day + '/download/auth' + query,
                headers: { token: localStorage.getItem('token') },
                method: 'GET',
                xhrFields: { responseType: 'blob' },
@@ -1438,8 +1490,12 @@
                }
            });
        },
        buildDeviceKey: function (type, deviceNo) {
            return String(type || '') + ':' + String(deviceNo || '');
        buildDeviceKey: function (type, deviceNo, stationId) {
            var key = String(type || '') + ':' + String(deviceNo || '');
            if (String(type || '') === 'Devp') {
                key += ':' + String(stationId || '');
            }
            return key;
        },
        parseDeviceNo: function (deviceNo) {
            var n = parseInt(deviceNo, 10);
src/main/webapp/views/deviceLogs/deviceLogs.html
@@ -1140,12 +1140,19 @@
                                    当前日期
                                    <strong>{{ selectedDay ? formatDayText(selectedDay) : '未选择' }}</strong>
                                </div>
                                <div class="dl-device-search" style="margin-bottom: 0;">
                                <div class="dl-device-search" style="margin-bottom: 0; display: flex; flex-direction: column; gap: 8px;">
                                    <el-input
                                        v-model.trim="searchDeviceNo"
                                        size="small"
                                        clearable
                                        placeholder="按设备编号筛选">
                                        <i slot="prefix" class="el-input__icon el-icon-search"></i>
                                    </el-input>
                                    <el-input
                                        v-model.trim="searchStationId"
                                        size="small"
                                        clearable
                                        placeholder="按站点编号筛选">
                                        <i slot="prefix" class="el-input__icon el-icon-search"></i>
                                    </el-input>
                                </div>
@@ -1160,17 +1167,19 @@
                            <button
                                v-else
                                v-for="device in filteredDevices"
                                :key="buildDeviceKey(device.type, device.deviceNo)"
                                :key="buildDeviceKey(device.type, device.deviceNo, device.stationId)"
                                type="button"
                                class="dl-device-item"
                                @click="selectDevice(device)">
                                <div class="dl-device-name">
                                    <span>{{ device.deviceNo }} 号{{ device.typeLabel }}</span>
                                    <span>{{ device.deviceNo }} 号{{ device.typeLabel }}<template v-if="device.type === 'Devp' && device.stationId"> · 站点 {{ device.stationId }}</template></span>
                                    <span class="dl-device-badge">{{ device.fileCount }} 文件</span>
                                </div>
                                <div class="dl-device-meta">
                                    <span>首条: {{ formatTimestamp(device.firstTime, false) }}</span>
                                    <span>末条: {{ formatTimestamp(device.lastTime, false) }}</span>
                                    <span v-if="device.type === 'Devp' && device.stationIds && device.stationIds.length">站点数: {{ device.stationIds.length }}</span>
                                    <span v-if="device.type === 'Devp' && device.matchedStationId">命中站点: {{ device.matchedStationId }}</span>
                                </div>
                            </button>
                        </div>
@@ -1185,10 +1194,11 @@
                    <div class="dl-viewer-header-main">
                        <button type="button" class="dl-btn is-ghost" @click="returnToSelector">返回筛选</button>
                        <div class="dl-viewer-copy">
                            <div class="dl-panel-title">{{ selectedDeviceSummary ? (selectedDeviceSummary.typeLabel + ' ' + selectedDeviceSummary.deviceNo + '号') : '设备状态查看' }}</div>
                            <div class="dl-panel-title">{{ selectedDeviceSummary ? (selectedDeviceSummary.typeLabel + ' ' + selectedDeviceSummary.deviceNo + '号' + (selectedDeviceSummary.type === 'Devp' && selectedDeviceSummary.stationId ? (' · 站点 ' + selectedDeviceSummary.stationId) : '')) : '设备状态查看' }}</div>
                            <div class="dl-panel-desc">{{ selectedDay ? ('日志日期 ' + formatDayText(selectedDay)) : '请选择日期和设备' }}</div>
                            <div v-if="selectedDeviceSummary" class="dl-viewer-meta">
                                <span class="dl-viewer-meta-item">类型 <strong>{{ selectedDeviceSummary.typeLabel }}</strong></span>
                                <span v-if="selectedDeviceSummary.type === 'Devp' && selectedDeviceSummary.stationId" class="dl-viewer-meta-item">站点 <strong>{{ selectedDeviceSummary.stationId }}</strong></span>
                                <span class="dl-viewer-meta-item">文件 <strong>{{ selectedDeviceSummary.fileCount }}</strong></span>
                                <span class="dl-viewer-meta-item">已载 <strong>{{ loadedSegmentCount }}</strong></span>
                                <span class="dl-viewer-meta-item">范围 <strong>{{ timelineRangeText }}</strong></span>