package com.battery.business.service.impl; import java.net.Inet6Address; import java.net.InetAddress; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.time.Instant; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.ruoyi.common.constant.UserConstants; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.ip.IpUtils; import com.battery.business.domain.TDetectionCabinet; import com.battery.business.domain.TDetectionChannel; import com.battery.business.domain.vo.DiscoveredCabinetVo; import com.battery.business.mapper.TDetectionCabinetMapper; import com.battery.business.service.ITDetectionCabinetService; import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore; import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore.CabinetCandidate; import com.battery.device.api.model.DeviceWorkStatus; /** * 检测柜 Service 业务层处理 * * @author ruoyi */ @Service public class TDetectionCabinetServiceImpl implements ITDetectionCabinetService { private static final String NORMAL = "0"; private static final String DISABLED = "1"; private static final String TESTING = "1"; private static final String ONLINE = "0"; private static final String OFFLINE = "1"; private static final Set ACTIVE_WORK_STATUSES = Set.of(DeviceWorkStatus.WORKING, DeviceWorkStatus.PAUSED, DeviceWorkStatus.PROTECTED, DeviceWorkStatus.SYNCHRONIZING, DeviceWorkStatus.LIT, DeviceWorkStatus.WAITING_TIMEOUT, DeviceWorkStatus.WAITING_START, DeviceWorkStatus.LOCKED); private final TDetectionCabinetMapper detectionCabinetMapper; private final DeviceDiscoveryCandidateStore candidateStore; /** * 构造检测柜业务服务。 * * @param detectionCabinetMapper 检测柜数据访问对象 */ public TDetectionCabinetServiceImpl(TDetectionCabinetMapper detectionCabinetMapper, DeviceDiscoveryCandidateStore candidateStore) { this.detectionCabinetMapper = detectionCabinetMapper; this.candidateStore = candidateStore; } /** * 查询指定检测柜,并在记录不存在或已逻辑删除时统一抛出业务异常。 * * @param cabinetId 检测柜主键 * @return 检测柜信息 */ @Override public TDetectionCabinet selectDetectionCabinetById(Long cabinetId) { TDetectionCabinet detectionCabinet = detectionCabinetMapper.selectDetectionCabinetById(cabinetId); if (StringUtils.isNull(detectionCabinet)) { throw new ServiceException("检测柜不存在或已删除"); } return applyRealtimeState(detectionCabinet, candidatesByCode()); } /** * 查询检测柜列表;查询 MAC 地址时先转换为统一格式,避免分隔符和大小写导致漏查。 * * @param detectionCabinet 查询条件 * @return 检测柜列表 */ @Override public List selectDetectionCabinetList(TDetectionCabinet detectionCabinet) { String requestedOnlineStatus = detectionCabinet.getOnlineStatus(); String requestedTestStatus = detectionCabinet.getTestStatus(); if (StringUtils.isNotBlank(detectionCabinet.getMacAddress())) { detectionCabinet.setMacAddress(normalizeMacAddress(detectionCabinet.getMacAddress())); } detectionCabinet.setOnlineStatus(null); detectionCabinet.setTestStatus(null); Map candidates = candidatesByCode(); return detectionCabinetMapper.selectDetectionCabinetList(detectionCabinet).stream() .map(item -> applyRealtimeState(item, candidates)) .filter(item -> StringUtils.isBlank(requestedOnlineStatus) || requestedOnlineStatus.equals(item.getOnlineStatus())) .filter(item -> StringUtils.isBlank(requestedTestStatus) || requestedTestStatus.equals(item.getTestStatus())) .toList(); } /** * 读取未过期且未登记的候选,离线设备仍保留身份并可选择。 * 测试占用状态由实时通道工作状态聚合;测试归属留待后续绑定闭环确认。 * * @return 可供选择的检测柜设备摘要 */ @Override public List selectDiscoveredCabinetList() { Set registeredCodes = Set.copyOf(detectionCabinetMapper.selectRegisteredCabinetCodes()); return candidateStore.snapshot(Instant.now()).cabinets().stream() .filter(candidate -> !registeredCodes.contains(candidate.deviceCode())) .map(candidate -> { boolean active = candidate.online() && isActive(candidate); return new DiscoveredCabinetVo( candidate.deviceCode(), candidate.ip(), candidate.mac(), candidate.name(), candidate.manufacturer(), candidate.model(), candidate.channels().size(), candidate.online() ? "在线" : "离线", active ? "测试中" : "空闲", active ? "待确认" : "-"); }) .toList(); } /** * 将操作员选择的发现候选设备登记为检测柜,并保留其适配器和通道身份。 * * @param detectionCabinet 检测柜信息 * @return 影响行数 */ @Override @Transactional(rollbackFor = Exception.class) public int insertDetectionCabinet(TDetectionCabinet detectionCabinet) { normalizeBasicFields(detectionCabinet); CabinetCandidate candidate = candidateStore.findCabinet(detectionCabinet.getCabinetCode(), Instant.now()) .orElseThrow(() -> new ServiceException("请先选择有效的发现候选设备")); detectionCabinet.setAdapterId(candidate.adapterId()); detectionCabinet.setAdapterCode(candidate.adapterCode()); detectionCabinet.setVendorDeviceType(candidate.devtype()); detectionCabinet.setVendorDeviceId(candidate.devid()); detectionCabinet.setIpAddress(candidate.ip()); detectionCabinet.setMacAddress(candidate.mac() == null ? detectionCabinet.getMacAddress() : candidate.mac()); detectionCabinet.setChannelCount(candidate.channels().size()); validateIpAddress(detectionCabinet.getIpAddress()); validateUniqueFields(detectionCabinet); detectionCabinet.setOnlineStatus("OFFLINE"); detectionCabinet.setLastSeenAtUtc(null); detectionCabinet.setStatus(NORMAL); try { int rows = detectionCabinetMapper.insertDetectionCabinet(detectionCabinet); for (var channel : candidate.channels()) { TDetectionChannel value = new TDetectionChannel(); value.setAdapterId(candidate.adapterId()); value.setAdapterCode(candidate.adapterCode()); value.setCabinetId(detectionCabinet.getCabinetId()); value.setDevtype(channel.identity().devtype()); value.setDevid(channel.identity().devid()); value.setSubdevid(channel.identity().subdevid()); value.setChlid(channel.identity().chlid()); value.setDisplayNo(channel.identity().subdevid() + "-" + channel.identity().chlid()); value.setOnlineStatus(channel.online() ? "ONLINE" : "OFFLINE"); value.setWorkStatus(persistedWorkStatus(channel.workStatus(), channel.online())); value.setLastSeenAtUtc(null); detectionCabinetMapper.insertDetectionChannel(value); } // 登记只确认来源身份,在线与离线事实都按候选原样同步。 detectionCabinetMapper.updateRegisteredCabinetObservation(candidate.adapterCode(), candidate.devtype(), candidate.devid(), candidate.online() ? "ONLINE" : "OFFLINE", candidate.observedAt(), candidate.online()); candidate.channels().forEach(channel -> detectionCabinetMapper.updateRegisteredChannelObservation( candidate.adapterCode(), channel.identity().devtype(), channel.identity().devid(), channel.identity().subdevid(), channel.identity().chlid(), channel.online() ? "ONLINE" : "OFFLINE", channel.online() ? persistedWorkStatus(channel.workStatus(), true) : "UNOBSERVABLE", channel.observedAt(), channel.online())); return rows; } catch (DuplicateKeyException exception) { throw buildDuplicateException(detectionCabinet, exception); } } /** * 修改检测柜基础信息;先确认目标记录存在,再执行字段规范化和唯一性校验。 * * @param detectionCabinet 检测柜信息 * @return 影响行数 */ @Override public int updateDetectionCabinet(TDetectionCabinet detectionCabinet) { requireCabinetId(detectionCabinet.getCabinetId()); selectDetectionCabinetById(detectionCabinet.getCabinetId()); normalizeBasicFields(detectionCabinet); validateIpAddress(detectionCabinet.getIpAddress()); validateUniqueFields(detectionCabinet); try { return detectionCabinetMapper.updateDetectionCabinet(detectionCabinet); } catch (DuplicateKeyException exception) { throw buildDuplicateException(detectionCabinet, exception); } } /** * 修改检测柜启禁用状态;测试中的检测柜禁止被禁用,重复状态更新直接返回成功。 * * @param detectionCabinet 检测柜主键、目标状态及更新人 * @return 影响行数 */ @Override public int updateDetectionCabinetStatus(TDetectionCabinet detectionCabinet) { requireCabinetId(detectionCabinet.getCabinetId()); if (!NORMAL.equals(detectionCabinet.getStatus()) && !DISABLED.equals(detectionCabinet.getStatus())) { throw new ServiceException("检测柜状态值不正确"); } TDetectionCabinet current = selectDetectionCabinetById(detectionCabinet.getCabinetId()); if (DISABLED.equals(detectionCabinet.getStatus())) { validateCabinetNotTesting(current, "禁用"); } if (detectionCabinet.getStatus().equals(current.getStatus())) { return 1; } return detectionCabinetMapper.updateDetectionCabinetStatus(detectionCabinet); } /** * 批量取消检测柜登记;先完成全部目标记录校验,再执行操作,避免部分成功。 * * @param cabinetIds 检测柜主键数组 * @param updateBy 更新人 * @return 影响行数 */ @Override @Transactional(rollbackFor = Exception.class) public int deleteDetectionCabinetByIds(Long[] cabinetIds, String updateBy) { if (StringUtils.isEmpty(cabinetIds)) { throw new ServiceException("请选择要删除的检测柜"); } for (Long cabinetId : cabinetIds) { TDetectionCabinet detectionCabinet = selectDetectionCabinetById(cabinetId); validateCabinetNotTesting(detectionCabinet, "删除"); } int rows = 0; for (Long cabinetId : cabinetIds) { detectionCabinetMapper.deleteDetectionChannelsByCabinetId(cabinetId); rows += detectionCabinetMapper.deleteDetectionCabinetById(cabinetId, updateBy); } return rows; } /** * 校验设备编码唯一性,编辑当前记录时允许命中自身。 * * @param detectionCabinet 待校验检测柜 * @return true 表示唯一 */ @Override public boolean checkCabinetCodeUnique(TDetectionCabinet detectionCabinet) { Long cabinetId = StringUtils.isNull(detectionCabinet.getCabinetId()) ? -1L : detectionCabinet.getCabinetId(); TDetectionCabinet info = detectionCabinetMapper.selectByCabinetCode(detectionCabinet.getCabinetCode()); if (StringUtils.isNotNull(info) && info.getCabinetId().longValue() != cabinetId.longValue()) { return UserConstants.NOT_UNIQUE; } return UserConstants.UNIQUE; } /** * 校验 MAC 地址唯一性,调用方应传入规范化后的地址。 * * @param detectionCabinet 待校验检测柜 * @return true 表示唯一 */ @Override public boolean checkMacAddressUnique(TDetectionCabinet detectionCabinet) { if (StringUtils.isBlank(detectionCabinet.getMacAddress())) { return UserConstants.UNIQUE; } Long cabinetId = StringUtils.isNull(detectionCabinet.getCabinetId()) ? -1L : detectionCabinet.getCabinetId(); TDetectionCabinet info = detectionCabinetMapper.selectByMacAddress(detectionCabinet.getMacAddress()); if (StringUtils.isNotNull(info) && info.getCabinetId().longValue() != cabinetId.longValue()) { return UserConstants.NOT_UNIQUE; } return UserConstants.UNIQUE; } private void validateUniqueFields(TDetectionCabinet detectionCabinet) { if (!checkCabinetCodeUnique(detectionCabinet)) { throw new ServiceException("设备编码已存在"); } if (!checkMacAddressUnique(detectionCabinet)) { throw new ServiceException("MAC地址已存在"); } } private void normalizeBasicFields(TDetectionCabinet detectionCabinet) { detectionCabinet.setCabinetCode(StringUtils.trim(detectionCabinet.getCabinetCode())); detectionCabinet.setCabinetName(StringUtils.trim(detectionCabinet.getCabinetName())); detectionCabinet.setIpAddress(StringUtils.trim(detectionCabinet.getIpAddress())); detectionCabinet.setMacAddress(normalizeMacAddress(detectionCabinet.getMacAddress())); detectionCabinet.setManufacturer(StringUtils.trim(detectionCabinet.getManufacturer())); detectionCabinet.setModel(StringUtils.trim(detectionCabinet.getModel())); detectionCabinet.setRemark(StringUtils.trim(detectionCabinet.getRemark())); } private String normalizeMacAddress(String macAddress) { String normalized = StringUtils.trim(macAddress); return StringUtils.isBlank(normalized) ? null : normalized.replace('-', ':').toUpperCase(Locale.ROOT); } private void validateIpAddress(String ipAddress) { if (IpUtils.isIP(ipAddress)) { return; } try { InetAddress address = InetAddress.getByName(ipAddress); if (ipAddress.contains(":") && address instanceof Inet6Address) { return; } } catch (Exception ignored) { } throw new ServiceException("IP地址格式不正确"); } /** * 校验检测柜是否处于可操作状态。 * * @param detectionCabinet 检测柜当前数据 * @param operation 业务操作名称 */ private void validateCabinetNotTesting(TDetectionCabinet detectionCabinet, String operation) { if (TESTING.equals(detectionCabinet.getTestStatus())) { throw new ServiceException("检测柜正在测试,不能" + operation); } } private void requireCabinetId(Long cabinetId) { if (StringUtils.isNull(cabinetId)) { throw new ServiceException("检测柜ID不能为空"); } } private Map candidatesByCode() { return candidateStore.snapshot(Instant.now()).cabinets().stream().collect(java.util.stream.Collectors.toMap( item -> stableCandidateKey(item.adapterCode(), item.devtype(), item.devid()), item -> item, (left, right) -> right)); } private TDetectionCabinet applyRealtimeState(TDetectionCabinet cabinet, Map candidates) { CabinetCandidate candidate = candidates.get(stableCandidateKey(cabinet.getAdapterCode(), cabinet.getVendorDeviceType(), cabinet.getVendorDeviceId())); if (candidate == null) candidate = candidateStore.findCabinet(cabinet.getCabinetCode(), Instant.now()).orElse(null); cabinet.setOnlineStatus(candidate != null && candidate.online() ? ONLINE : OFFLINE); return cabinet; } private String stableCandidateKey(String adapterCode, Integer devtype, Integer devid) { return adapterCode + "-" + devtype + "-" + devid; } private boolean isActive(CabinetCandidate candidate) { return candidate.channels().stream().anyMatch(channel -> ACTIVE_WORK_STATUSES.contains(channel.workStatus())); } /** 将设备观测状态转换为最终数据库允许的通道事实状态。 */ private String persistedWorkStatus(DeviceWorkStatus status, boolean online) { if (!online || status == null || status == DeviceWorkStatus.UNKNOWN || status == DeviceWorkStatus.UNOBSERVABLE) return "UNOBSERVABLE"; return switch (status) { case PAUSED -> "PAUSED"; case PROTECTED -> "ABNORMAL"; case FINISHED, STOPPED -> "ENDED"; default -> "RUNNING"; }; } private ServiceException buildDuplicateException(TDetectionCabinet detectionCabinet, DuplicateKeyException cause) { String message = checkCabinetCodeUnique(detectionCabinet) ? "MAC地址已存在" : "设备编码已存在"; return new ServiceException(message).setDetailMessage(cause.getMessage()); } }