package com.dms.service; import com.dms.common.DmsException; import com.dms.common.Errors; import com.dms.common.IpAddresses; import com.dms.common.Page; import com.dms.common.Payloads; import com.dms.mapper.IpEntryMapper; import com.dms.mapper.OrganizationMapper; import com.dms.model.IpEntryEntity; import com.dms.model.OrgIpAssignmentEntity; import com.dms.model.OrganizationEntity; import com.dms.security.AuthContext; import jakarta.servlet.http.HttpServletRequest; import java.net.InetAddress; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeSet; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; /** * 部门IP登录限制:IP条目池维护与部门分配, * 对齐 backend/dms/services/ip_entry_service.py。 */ @Service public class IpEntryService { private static final Set IP_ENTRY_CREATE_FIELDS = Set.of("ipValue", "remark"); private static final Set IP_ENTRY_UPDATE_FIELDS = Set.of("ipValue", "remark", "rowVersion"); private static final Set ORG_ASSIGNMENT_FIELDS = Set.of("ipEntryIds", "rowVersion"); private static final int IP_VALUE_MAX_LENGTH = 64; private static final int REMARK_MAX_LENGTH = 128; private final IpEntryMapper ipEntryMapper; private final OrganizationMapper organizationMapper; private final AuditService auditService; public IpEntryService(IpEntryMapper ipEntryMapper, OrganizationMapper organizationMapper, AuditService auditService) { this.ipEntryMapper = ipEntryMapper; this.organizationMapper = organizationMapper; this.auditService = auditService; } // ------------------------------------------------------------ 条目池查询 @Transactional(readOnly = true) public Map listIpEntries(String keyword, Page.Params pageRequest) { String keywordValue = keyword != null && !keyword.strip().isEmpty() ? keyword.strip() : null; long total = ipEntryMapper.countByFilters(keywordValue); List entries = ipEntryMapper.pageByFilters( keywordValue, pageRequest.pageSize(), pageRequest.offset()); List> items = new ArrayList<>(entries.size()); for (IpEntryEntity entry : entries) { items.add(entrySummary(entry)); } return Page.result(items, pageRequest, total); } // ------------------------------------------------------------ 条目池 CRUD /** 对齐 create_ip_entry。 */ @Transactional public Map create(Object payload, HttpServletRequest request) { Map body = Payloads.exactPayload(payload, IP_ENTRY_CREATE_FIELDS); String ipValue = normalizeIpValue(body.get("ipValue")); String remark = remark(body.get("remark")); Long actorId = AuthContext.get().userId(); try { if (ipEntryMapper.findIdByIpValueExclude(ipValue, 0L) != null) { throw conflict("该IP已存在", null); } IpEntryEntity entry = new IpEntryEntity(); entry.setIpValue(ipValue); entry.setRemark(remark); entry.setCreatedBy(actorId); entry.setUpdatedBy(actorId); entry.setRowVersion(0); ipEntryMapper.insert(entry); Map detail = Payloads.orderedMap(); detail.put("ipValue", entry.getIpValue()); detail.put("remark", entry.getRemark()); audit(request, AuditService.Action.CREATE_IP_ENTRY, entry.getId(), entry.getIpValue(), detail); return entrySummary(entry); } catch (DuplicateKeyException ex) { throw conflict("该IP已存在", null); } } /** 对齐 update_ip_entry。 */ @Transactional public Map update(long entryId, Object payload, HttpServletRequest request) { Map body = Payloads.exactPayload(payload, IP_ENTRY_UPDATE_FIELDS); String ipValue = normalizeIpValue(body.get("ipValue")); String remark = remark(body.get("remark")); int expectedVersion = Payloads.rowVersion(body.get("rowVersion")); Long actorId = AuthContext.get().userId(); LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); IpEntryEntity entry = ipEntryMapper.findActiveByIdForUpdate(entryId); if (entry == null) { throw notFound("IP条目不存在"); } checkVersion(entry, expectedVersion); if (ipEntryMapper.findIdByIpValueExclude(ipValue, entryId) != null) { throw conflict("该IP已存在", null); } Map before = Payloads.orderedMap(); before.put("ipValue", entry.getIpValue()); before.put("remark", entry.getRemark()); entry.setIpValue(ipValue); entry.setRemark(remark); entry.setUpdatedBy(actorId); entry.setUpdatedAt(now); entry.setRowVersion(entry.getRowVersion() + 1); ipEntryMapper.update(entry); Map after = Payloads.orderedMap(); after.put("ipValue", entry.getIpValue()); after.put("remark", entry.getRemark()); Map detail = Payloads.orderedMap(); detail.put("before", before); detail.put("after", after); audit(request, AuditService.Action.EDIT_IP_ENTRY, entry.getId(), entry.getIpValue(), detail); return entrySummary(entry); } /** 对齐 delete_ip_entry:被部门引用时拒绝删除。 */ @Transactional public Map delete(long entryId, int rowVersion, HttpServletRequest request) { Long actorId = AuthContext.get().userId(); LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); IpEntryEntity entry = ipEntryMapper.findActiveByIdForUpdate(entryId); if (entry == null) { throw notFound("IP条目不存在"); } checkVersion(entry, rowVersion); if (ipEntryMapper.countActiveAssignmentsByEntry(entryId) > 0) { throw conflict("该IP已分配给部门,请先取消分配", null); } String targetName = entry.getIpValue(); ipEntryMapper.softDelete(entryId, now, actorId); entry.setRowVersion(entry.getRowVersion() + 1); Map detail = Payloads.orderedMap(); detail.put("ipValue", targetName); detail.put("rowVersion", entry.getRowVersion()); audit(request, AuditService.Action.DELETE_IP_ENTRY, entryId, targetName, detail); Map result = Payloads.orderedMap(); result.put("id", serializeId(entryId)); result.put("deleted", true); return result; } // ------------------------------------------------------------ 部门分配 @Transactional(readOnly = true) public Map listOrgIpAssignments(long organizationId) { if (organizationMapper.findByIdNotDeleted(organizationId) == null) { throw notFound("机构不存在"); } Map result = Payloads.orderedMap(); result.put("ipEntryIds", ipEntryMapper.listAssignedEntryIdsByOrganization(organizationId) .stream().map(String::valueOf).toList()); return result; } /** 对齐 update_org_ip_assignments:整体替换 + 组织 row_version 联动。 */ @Transactional public Map updateOrgIpAssignments(long organizationId, Object payload, HttpServletRequest request) { Map body = Payloads.exactPayload(payload, ORG_ASSIGNMENT_FIELDS); List entryIds = Payloads.idList(body.get("ipEntryIds"), "ipEntryIds"); int expectedVersion = Payloads.rowVersion(body.get("rowVersion")); Long actorId = AuthContext.get().userId(); LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); OrganizationEntity organization = organizationMapper.findByIdNotDeletedForUpdate(organizationId); if (organization == null) { throw notFound("机构不存在"); } checkOrgVersion(organization, expectedVersion); Set targetIds = new HashSet<>(entryIds); List rows = List.of(); if (!targetIds.isEmpty()) { rows = ipEntryMapper.listActiveByIds(new TreeSet<>(targetIds)); Set validIds = new HashSet<>(); for (IpEntryEntity row : rows) { validIds.add(row.getId()); } Set invalid = new HashSet<>(targetIds); invalid.removeAll(validIds); if (!invalid.isEmpty()) { throw Errors.invalidArgument("ipEntryIds包含无效IP条目"); } } List existingRows = ipEntryMapper.listActiveAssignmentsByOrganization(organizationId); Map existingMap = new HashMap<>(); for (OrgIpAssignmentEntity row : existingRows) { existingMap.put(row.getIpEntryId(), row); } for (Map.Entry entry : existingMap.entrySet()) { if (!targetIds.contains(entry.getKey())) { ipEntryMapper.softDeleteAssignment(entry.getValue().getId(), now, actorId); } } for (Long ipEntryId : new TreeSet<>(targetIds)) { if (!existingMap.containsKey(ipEntryId)) { OrgIpAssignmentEntity row = new OrgIpAssignmentEntity(); row.setOrganizationId(organizationId); row.setIpEntryId(ipEntryId); row.setCreatedBy(actorId); row.setUpdatedBy(actorId); ipEntryMapper.insertAssignment(row); } } organization.setUpdatedBy(actorId); organization.setUpdatedAt(now); organizationMapper.update(organization); organization.setRowVersion(organization.getRowVersion() + 1); List> ipEntries = new ArrayList<>(); for (IpEntryEntity row : rows) { Map item = Payloads.orderedMap(); item.put("id", serializeId(row.getId())); item.put("ipValue", row.getIpValue()); ipEntries.add(item); } Map detail = Payloads.orderedMap(); detail.put("ipEntries", ipEntries); detail.put("rowVersion", organization.getRowVersion()); audit(request, AuditService.Action.ASSIGN_ORG_IP, organizationId, organization.getOrgName(), detail); Map result = Payloads.orderedMap(); result.put("id", serializeId(organizationId)); result.put("ipEntryIds", new TreeSet<>(targetIds).stream().map(String::valueOf).toList()); result.put("rowVersion", organization.getRowVersion()); return result; } // ------------------------------------------------------------ IP 规范化 /** * 对齐 normalize_ip_value:精确 IP、CIDR(主机位必须为 0)或「起IP-止IP」范围 * (两端同版本且起≤止)。 * *

IPv4 手动解析以完全对齐 Python ipaddress(拒绝前导零且不做 DNS 查询); * IPv6 走 InetAddress 字面量解析(Java 输出全展开形式,与 Python 压缩形式存在 * 已知展示差异,仅影响 IPv6 场景的存储文本)。

*/ public static String normalizeIpValue(Object value) { if (!(value instanceof String text)) { throw Errors.invalidArgument("ipValue必须是字符串"); } String stripped = text.strip(); if (stripped.isEmpty()) { throw Errors.invalidArgument("ipValue不能为空"); } if (stripped.length() > IP_VALUE_MAX_LENGTH) { throw Errors.invalidArgument("ipValue长度不能超过" + IP_VALUE_MAX_LENGTH); } int slash = stripped.indexOf('/'); String normalized = slash >= 0 ? normalizeCidr(stripped, slash) : stripped.indexOf('-') >= 0 ? normalizeRange(stripped, stripped.indexOf('-')) : normalizeAddress(stripped); // IPv6 等形式的规范化展开可能长于输入文本,统一复查落库长度 if (normalized.length() > IP_VALUE_MAX_LENGTH) { throw Errors.invalidArgument("ipValue长度不能超过" + IP_VALUE_MAX_LENGTH); } return normalized; } private static String normalizeCidr(String text, int slashIndex) { String addressPart = text.substring(0, slashIndex); String prefixPart = text.substring(slashIndex + 1); if (addressPart.indexOf(':') >= 0) { byte[] bytes = parseIpv6(addressPart, text); int prefix = parsePrefix(prefixPart, 128, text); if (!hostBitsZero(bytes, prefix)) { throw hostBitsError(text); } return inetText(bytes) + "/" + prefix; } int[] octets = parseIpv4(addressPart, text); int prefix = parsePrefix(prefixPart, 32, text); int address = (octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]; int hostBits = 32 - prefix; if (hostBits < 32 && (address & ((1 << hostBits) - 1)) != 0) { throw hostBitsError(text); } return octets[0] + "." + octets[1] + "." + octets[2] + "." + octets[3] + "/" + prefix; } /** 对齐范围分支:「起IP-止IP」两端同版本且起≤止,规范化为 起文本-止文本。 */ private static String normalizeRange(String text, int dashIndex) { String startPart = text.substring(0, dashIndex); String endPart = text.substring(dashIndex + 1); if (startPart.isEmpty() || endPart.isEmpty() || endPart.indexOf('-') >= 0) { throw invalidIp(text); } boolean startV6 = startPart.indexOf(':') >= 0; if (startV6 != (endPart.indexOf(':') >= 0)) { throw rangeVersionError(text); } byte[] start = startV6 ? parseIpv6(startPart, text) : ipv4ToBytes(parseIpv4(startPart, text)); byte[] end = startV6 ? parseIpv6(endPart, text) : ipv4ToBytes(parseIpv4(endPart, text)); if (IpAddresses.unsignedCompare(start, end) > 0) { throw rangeOrderError(text); } return inetText(start) + "-" + inetText(end); } private static byte[] ipv4ToBytes(int[] octets) { return new byte[]{ (byte) octets[0], (byte) octets[1], (byte) octets[2], (byte) octets[3] }; } private static String normalizeAddress(String text) { if (text.indexOf(':') >= 0) { return inetText(parseIpv6(text, text)); } int[] octets = parseIpv4(text, text); return octets[0] + "." + octets[1] + "." + octets[2] + "." + octets[3]; } /** 手动解析 IPv4:4 段、1~3 位、无前导零、0~255。 */ private static int[] parseIpv4(String text, String original) { String[] parts = text.split("\\.", -1); if (parts.length != 4) { throw invalidIp(original); } int[] octets = new int[4]; for (int i = 0; i < 4; i++) { String part = parts[i]; if (part.isEmpty() || part.length() > 3 || !isDigits(part)) { throw invalidIp(original); } if (part.length() > 1 && part.charAt(0) == '0') { throw invalidIp(original); } int value = Integer.parseInt(part); if (value > 255) { throw invalidIp(original); } octets[i] = value; } return octets; } /** IPv6 字面量解析(含 ':' 一律按 IPv6 处理,不触发 DNS 查询)。 */ private static byte[] parseIpv6(String text, String original) { try { return InetAddress.getByName(text).getAddress(); } catch (Exception ex) { throw invalidIp(original); } } private static int parsePrefix(String prefix, int maxValue, String original) { if (prefix.isEmpty() || !isDigits(prefix)) { throw invalidIp(original); } int value; try { value = Integer.parseInt(prefix); } catch (NumberFormatException ex) { throw invalidIp(original); } if (value > maxValue) { throw invalidIp(original); } return value; } /** 校验 IPv6 字节序列低 (128-prefix) 位全为 0。 */ private static boolean hostBitsZero(byte[] bytes, int prefix) { for (int bit = prefix; bit < 128; bit++) { int byteIndex = bit / 8; int bitInByte = 7 - (bit % 8); if (((bytes[byteIndex] >> bitInByte) & 1) != 0) { return false; } } return true; } /** 字节序列渲染为地址文本(4字节为IPv4点分、16字节为IPv6全展开形式)。 */ private static String inetText(byte[] bytes) { try { return InetAddress.getByAddress(bytes).getHostAddress(); } catch (Exception ex) { throw new IllegalStateException("IP 地址渲染失败", ex); } } private static DmsException invalidIp(String text) { return Errors.invalidArgument("IP地址、网段或IP范围格式非法:" + text); } private static DmsException hostBitsError(String text) { return Errors.invalidArgument("网段要求主机位为0,请输入如192.168.1.0/24的形式:" + text); } private static DmsException rangeVersionError(String text) { return Errors.invalidArgument("IP范围两端必须同为IPv4或IPv6:" + text); } private static DmsException rangeOrderError(String text) { return Errors.invalidArgument("IP范围起始不能大于结束:" + text); } private static boolean isDigits(String value) { for (int i = 0; i < value.length(); i++) { char c = value.charAt(i); if (c < '0' || c > '9') { return false; } } return true; } // ------------------------------------------------------------ 内部工具 /** 对齐 _remark:null → "",strip,长度上限 128。 */ private static String remark(Object value) { if (value == null) { return ""; } if (!(value instanceof String text)) { throw Errors.invalidArgument("remark必须是字符串"); } String normalized = text.strip(); if (normalized.length() > REMARK_MAX_LENGTH) { throw Errors.invalidArgument("remark长度不能超过" + REMARK_MAX_LENGTH); } return normalized; } private static Map entrySummary(IpEntryEntity entry) { Map result = Payloads.orderedMap(); result.put("id", serializeId(entry.getId())); result.put("ipValue", entry.getIpValue()); result.put("remark", entry.getRemark()); result.put("rowVersion", entry.getRowVersion()); return result; } private static void checkVersion(IpEntryEntity entry, int expectedVersion) { if (entry.getRowVersion() == null || entry.getRowVersion() != expectedVersion) { throw conflict("数据已被其他用户修改,请刷新后重试", Map.of("currentRowVersion", entry.getRowVersion() == null ? 0 : entry.getRowVersion())); } } private static void checkOrgVersion(OrganizationEntity entity, int expectedVersion) { if (entity.getRowVersion() == null || entity.getRowVersion() != expectedVersion) { throw conflict("数据已被其他用户修改,请刷新后重试", Map.of("currentRowVersion", entity.getRowVersion() == null ? 0 : entity.getRowVersion())); } } private void audit(HttpServletRequest request, String action, long targetId, String targetName, Map detail) { auditService.insert(auditService.businessAudit( request, action, "IP_ENTRY", targetId, targetName, auditService.toJson(detail))); } private static String serializeId(Long id) { return id == null ? null : String.valueOf(id); } private static DmsException conflict(String message, Object details) { return new DmsException("DATA_VERSION_CONFLICT", 409, message, details); } private static DmsException notFound(String message) { return new DmsException("RESOURCE_NOT_FOUND", 404, message); } }