IpEntryService.java 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. package com.dms.service;
  2. import com.dms.common.DmsException;
  3. import com.dms.common.Errors;
  4. import com.dms.common.IpAddresses;
  5. import com.dms.common.Page;
  6. import com.dms.common.Payloads;
  7. import com.dms.mapper.IpEntryMapper;
  8. import com.dms.mapper.OrganizationMapper;
  9. import com.dms.model.IpEntryEntity;
  10. import com.dms.model.OrgIpAssignmentEntity;
  11. import com.dms.model.OrganizationEntity;
  12. import com.dms.security.AuthContext;
  13. import jakarta.servlet.http.HttpServletRequest;
  14. import java.net.InetAddress;
  15. import java.time.LocalDateTime;
  16. import java.time.ZoneOffset;
  17. import java.util.ArrayList;
  18. import java.util.HashMap;
  19. import java.util.HashSet;
  20. import java.util.List;
  21. import java.util.Map;
  22. import java.util.Set;
  23. import java.util.TreeSet;
  24. import org.springframework.dao.DuplicateKeyException;
  25. import org.springframework.stereotype.Service;
  26. import org.springframework.transaction.annotation.Transactional;
  27. /**
  28. * 部门IP登录限制:IP条目池维护与部门分配,
  29. * 对齐 backend/dms/services/ip_entry_service.py。
  30. */
  31. @Service
  32. public class IpEntryService {
  33. private static final Set<String> IP_ENTRY_CREATE_FIELDS = Set.of("ipValue", "remark");
  34. private static final Set<String> IP_ENTRY_UPDATE_FIELDS = Set.of("ipValue", "remark", "rowVersion");
  35. private static final Set<String> ORG_ASSIGNMENT_FIELDS = Set.of("ipEntryIds", "rowVersion");
  36. private static final int IP_VALUE_MAX_LENGTH = 64;
  37. private static final int REMARK_MAX_LENGTH = 128;
  38. private final IpEntryMapper ipEntryMapper;
  39. private final OrganizationMapper organizationMapper;
  40. private final AuditService auditService;
  41. public IpEntryService(IpEntryMapper ipEntryMapper, OrganizationMapper organizationMapper,
  42. AuditService auditService) {
  43. this.ipEntryMapper = ipEntryMapper;
  44. this.organizationMapper = organizationMapper;
  45. this.auditService = auditService;
  46. }
  47. // ------------------------------------------------------------ 条目池查询
  48. @Transactional(readOnly = true)
  49. public Map<String, Object> listIpEntries(String keyword, Page.Params pageRequest) {
  50. String keywordValue = keyword != null && !keyword.strip().isEmpty()
  51. ? keyword.strip() : null;
  52. long total = ipEntryMapper.countByFilters(keywordValue);
  53. List<IpEntryEntity> entries = ipEntryMapper.pageByFilters(
  54. keywordValue, pageRequest.pageSize(), pageRequest.offset());
  55. List<Map<String, Object>> items = new ArrayList<>(entries.size());
  56. for (IpEntryEntity entry : entries) {
  57. items.add(entrySummary(entry));
  58. }
  59. return Page.result(items, pageRequest, total);
  60. }
  61. // ------------------------------------------------------------ 条目池 CRUD
  62. /** 对齐 create_ip_entry。 */
  63. @Transactional
  64. public Map<String, Object> create(Object payload, HttpServletRequest request) {
  65. Map<String, Object> body = Payloads.exactPayload(payload, IP_ENTRY_CREATE_FIELDS);
  66. String ipValue = normalizeIpValue(body.get("ipValue"));
  67. String remark = remark(body.get("remark"));
  68. Long actorId = AuthContext.get().userId();
  69. try {
  70. if (ipEntryMapper.findIdByIpValueExclude(ipValue, 0L) != null) {
  71. throw conflict("该IP已存在", null);
  72. }
  73. IpEntryEntity entry = new IpEntryEntity();
  74. entry.setIpValue(ipValue);
  75. entry.setRemark(remark);
  76. entry.setCreatedBy(actorId);
  77. entry.setUpdatedBy(actorId);
  78. entry.setRowVersion(0);
  79. ipEntryMapper.insert(entry);
  80. Map<String, Object> detail = Payloads.orderedMap();
  81. detail.put("ipValue", entry.getIpValue());
  82. detail.put("remark", entry.getRemark());
  83. audit(request, AuditService.Action.CREATE_IP_ENTRY,
  84. entry.getId(), entry.getIpValue(), detail);
  85. return entrySummary(entry);
  86. } catch (DuplicateKeyException ex) {
  87. throw conflict("该IP已存在", null);
  88. }
  89. }
  90. /** 对齐 update_ip_entry。 */
  91. @Transactional
  92. public Map<String, Object> update(long entryId, Object payload, HttpServletRequest request) {
  93. Map<String, Object> body = Payloads.exactPayload(payload, IP_ENTRY_UPDATE_FIELDS);
  94. String ipValue = normalizeIpValue(body.get("ipValue"));
  95. String remark = remark(body.get("remark"));
  96. int expectedVersion = Payloads.rowVersion(body.get("rowVersion"));
  97. Long actorId = AuthContext.get().userId();
  98. LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
  99. IpEntryEntity entry = ipEntryMapper.findActiveByIdForUpdate(entryId);
  100. if (entry == null) {
  101. throw notFound("IP条目不存在");
  102. }
  103. checkVersion(entry, expectedVersion);
  104. if (ipEntryMapper.findIdByIpValueExclude(ipValue, entryId) != null) {
  105. throw conflict("该IP已存在", null);
  106. }
  107. Map<String, Object> before = Payloads.orderedMap();
  108. before.put("ipValue", entry.getIpValue());
  109. before.put("remark", entry.getRemark());
  110. entry.setIpValue(ipValue);
  111. entry.setRemark(remark);
  112. entry.setUpdatedBy(actorId);
  113. entry.setUpdatedAt(now);
  114. entry.setRowVersion(entry.getRowVersion() + 1);
  115. ipEntryMapper.update(entry);
  116. Map<String, Object> after = Payloads.orderedMap();
  117. after.put("ipValue", entry.getIpValue());
  118. after.put("remark", entry.getRemark());
  119. Map<String, Object> detail = Payloads.orderedMap();
  120. detail.put("before", before);
  121. detail.put("after", after);
  122. audit(request, AuditService.Action.EDIT_IP_ENTRY,
  123. entry.getId(), entry.getIpValue(), detail);
  124. return entrySummary(entry);
  125. }
  126. /** 对齐 delete_ip_entry:被部门引用时拒绝删除。 */
  127. @Transactional
  128. public Map<String, Object> delete(long entryId, int rowVersion, HttpServletRequest request) {
  129. Long actorId = AuthContext.get().userId();
  130. LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
  131. IpEntryEntity entry = ipEntryMapper.findActiveByIdForUpdate(entryId);
  132. if (entry == null) {
  133. throw notFound("IP条目不存在");
  134. }
  135. checkVersion(entry, rowVersion);
  136. if (ipEntryMapper.countActiveAssignmentsByEntry(entryId) > 0) {
  137. throw conflict("该IP已分配给部门,请先取消分配", null);
  138. }
  139. String targetName = entry.getIpValue();
  140. ipEntryMapper.softDelete(entryId, now, actorId);
  141. entry.setRowVersion(entry.getRowVersion() + 1);
  142. Map<String, Object> detail = Payloads.orderedMap();
  143. detail.put("ipValue", targetName);
  144. detail.put("rowVersion", entry.getRowVersion());
  145. audit(request, AuditService.Action.DELETE_IP_ENTRY, entryId, targetName, detail);
  146. Map<String, Object> result = Payloads.orderedMap();
  147. result.put("id", serializeId(entryId));
  148. result.put("deleted", true);
  149. return result;
  150. }
  151. // ------------------------------------------------------------ 部门分配
  152. @Transactional(readOnly = true)
  153. public Map<String, Object> listOrgIpAssignments(long organizationId) {
  154. if (organizationMapper.findByIdNotDeleted(organizationId) == null) {
  155. throw notFound("机构不存在");
  156. }
  157. Map<String, Object> result = Payloads.orderedMap();
  158. result.put("ipEntryIds", ipEntryMapper.listAssignedEntryIdsByOrganization(organizationId)
  159. .stream().map(String::valueOf).toList());
  160. return result;
  161. }
  162. /** 对齐 update_org_ip_assignments:整体替换 + 组织 row_version 联动。 */
  163. @Transactional
  164. public Map<String, Object> updateOrgIpAssignments(long organizationId, Object payload,
  165. HttpServletRequest request) {
  166. Map<String, Object> body = Payloads.exactPayload(payload, ORG_ASSIGNMENT_FIELDS);
  167. List<Long> entryIds = Payloads.idList(body.get("ipEntryIds"), "ipEntryIds");
  168. int expectedVersion = Payloads.rowVersion(body.get("rowVersion"));
  169. Long actorId = AuthContext.get().userId();
  170. LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
  171. OrganizationEntity organization =
  172. organizationMapper.findByIdNotDeletedForUpdate(organizationId);
  173. if (organization == null) {
  174. throw notFound("机构不存在");
  175. }
  176. checkOrgVersion(organization, expectedVersion);
  177. Set<Long> targetIds = new HashSet<>(entryIds);
  178. List<IpEntryEntity> rows = List.of();
  179. if (!targetIds.isEmpty()) {
  180. rows = ipEntryMapper.listActiveByIds(new TreeSet<>(targetIds));
  181. Set<Long> validIds = new HashSet<>();
  182. for (IpEntryEntity row : rows) {
  183. validIds.add(row.getId());
  184. }
  185. Set<Long> invalid = new HashSet<>(targetIds);
  186. invalid.removeAll(validIds);
  187. if (!invalid.isEmpty()) {
  188. throw Errors.invalidArgument("ipEntryIds包含无效IP条目");
  189. }
  190. }
  191. List<OrgIpAssignmentEntity> existingRows =
  192. ipEntryMapper.listActiveAssignmentsByOrganization(organizationId);
  193. Map<Long, OrgIpAssignmentEntity> existingMap = new HashMap<>();
  194. for (OrgIpAssignmentEntity row : existingRows) {
  195. existingMap.put(row.getIpEntryId(), row);
  196. }
  197. for (Map.Entry<Long, OrgIpAssignmentEntity> entry : existingMap.entrySet()) {
  198. if (!targetIds.contains(entry.getKey())) {
  199. ipEntryMapper.softDeleteAssignment(entry.getValue().getId(), now, actorId);
  200. }
  201. }
  202. for (Long ipEntryId : new TreeSet<>(targetIds)) {
  203. if (!existingMap.containsKey(ipEntryId)) {
  204. OrgIpAssignmentEntity row = new OrgIpAssignmentEntity();
  205. row.setOrganizationId(organizationId);
  206. row.setIpEntryId(ipEntryId);
  207. row.setCreatedBy(actorId);
  208. row.setUpdatedBy(actorId);
  209. ipEntryMapper.insertAssignment(row);
  210. }
  211. }
  212. organization.setUpdatedBy(actorId);
  213. organization.setUpdatedAt(now);
  214. organizationMapper.update(organization);
  215. organization.setRowVersion(organization.getRowVersion() + 1);
  216. List<Map<String, Object>> ipEntries = new ArrayList<>();
  217. for (IpEntryEntity row : rows) {
  218. Map<String, Object> item = Payloads.orderedMap();
  219. item.put("id", serializeId(row.getId()));
  220. item.put("ipValue", row.getIpValue());
  221. ipEntries.add(item);
  222. }
  223. Map<String, Object> detail = Payloads.orderedMap();
  224. detail.put("ipEntries", ipEntries);
  225. detail.put("rowVersion", organization.getRowVersion());
  226. audit(request, AuditService.Action.ASSIGN_ORG_IP,
  227. organizationId, organization.getOrgName(), detail);
  228. Map<String, Object> result = Payloads.orderedMap();
  229. result.put("id", serializeId(organizationId));
  230. result.put("ipEntryIds", new TreeSet<>(targetIds).stream().map(String::valueOf).toList());
  231. result.put("rowVersion", organization.getRowVersion());
  232. return result;
  233. }
  234. // ------------------------------------------------------------ IP 规范化
  235. /**
  236. * 对齐 normalize_ip_value:精确 IP、CIDR(主机位必须为 0)或「起IP-止IP」范围
  237. * (两端同版本且起≤止)。
  238. *
  239. * <p>IPv4 手动解析以完全对齐 Python ipaddress(拒绝前导零且不做 DNS 查询);
  240. * IPv6 走 InetAddress 字面量解析(Java 输出全展开形式,与 Python 压缩形式存在
  241. * 已知展示差异,仅影响 IPv6 场景的存储文本)。</p>
  242. */
  243. public static String normalizeIpValue(Object value) {
  244. if (!(value instanceof String text)) {
  245. throw Errors.invalidArgument("ipValue必须是字符串");
  246. }
  247. String stripped = text.strip();
  248. if (stripped.isEmpty()) {
  249. throw Errors.invalidArgument("ipValue不能为空");
  250. }
  251. if (stripped.length() > IP_VALUE_MAX_LENGTH) {
  252. throw Errors.invalidArgument("ipValue长度不能超过" + IP_VALUE_MAX_LENGTH);
  253. }
  254. int slash = stripped.indexOf('/');
  255. String normalized = slash >= 0
  256. ? normalizeCidr(stripped, slash)
  257. : stripped.indexOf('-') >= 0
  258. ? normalizeRange(stripped, stripped.indexOf('-'))
  259. : normalizeAddress(stripped);
  260. // IPv6 等形式的规范化展开可能长于输入文本,统一复查落库长度
  261. if (normalized.length() > IP_VALUE_MAX_LENGTH) {
  262. throw Errors.invalidArgument("ipValue长度不能超过" + IP_VALUE_MAX_LENGTH);
  263. }
  264. return normalized;
  265. }
  266. private static String normalizeCidr(String text, int slashIndex) {
  267. String addressPart = text.substring(0, slashIndex);
  268. String prefixPart = text.substring(slashIndex + 1);
  269. if (addressPart.indexOf(':') >= 0) {
  270. byte[] bytes = parseIpv6(addressPart, text);
  271. int prefix = parsePrefix(prefixPart, 128, text);
  272. if (!hostBitsZero(bytes, prefix)) {
  273. throw hostBitsError(text);
  274. }
  275. return inetText(bytes) + "/" + prefix;
  276. }
  277. int[] octets = parseIpv4(addressPart, text);
  278. int prefix = parsePrefix(prefixPart, 32, text);
  279. int address = (octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3];
  280. int hostBits = 32 - prefix;
  281. if (hostBits < 32 && (address & ((1 << hostBits) - 1)) != 0) {
  282. throw hostBitsError(text);
  283. }
  284. return octets[0] + "." + octets[1] + "." + octets[2] + "." + octets[3] + "/" + prefix;
  285. }
  286. /** 对齐范围分支:「起IP-止IP」两端同版本且起≤止,规范化为 起文本-止文本。 */
  287. private static String normalizeRange(String text, int dashIndex) {
  288. String startPart = text.substring(0, dashIndex);
  289. String endPart = text.substring(dashIndex + 1);
  290. if (startPart.isEmpty() || endPart.isEmpty() || endPart.indexOf('-') >= 0) {
  291. throw invalidIp(text);
  292. }
  293. boolean startV6 = startPart.indexOf(':') >= 0;
  294. if (startV6 != (endPart.indexOf(':') >= 0)) {
  295. throw rangeVersionError(text);
  296. }
  297. byte[] start = startV6
  298. ? parseIpv6(startPart, text)
  299. : ipv4ToBytes(parseIpv4(startPart, text));
  300. byte[] end = startV6
  301. ? parseIpv6(endPart, text)
  302. : ipv4ToBytes(parseIpv4(endPart, text));
  303. if (IpAddresses.unsignedCompare(start, end) > 0) {
  304. throw rangeOrderError(text);
  305. }
  306. return inetText(start) + "-" + inetText(end);
  307. }
  308. private static byte[] ipv4ToBytes(int[] octets) {
  309. return new byte[]{
  310. (byte) octets[0], (byte) octets[1], (byte) octets[2], (byte) octets[3]
  311. };
  312. }
  313. private static String normalizeAddress(String text) {
  314. if (text.indexOf(':') >= 0) {
  315. return inetText(parseIpv6(text, text));
  316. }
  317. int[] octets = parseIpv4(text, text);
  318. return octets[0] + "." + octets[1] + "." + octets[2] + "." + octets[3];
  319. }
  320. /** 手动解析 IPv4:4 段、1~3 位、无前导零、0~255。 */
  321. private static int[] parseIpv4(String text, String original) {
  322. String[] parts = text.split("\\.", -1);
  323. if (parts.length != 4) {
  324. throw invalidIp(original);
  325. }
  326. int[] octets = new int[4];
  327. for (int i = 0; i < 4; i++) {
  328. String part = parts[i];
  329. if (part.isEmpty() || part.length() > 3 || !isDigits(part)) {
  330. throw invalidIp(original);
  331. }
  332. if (part.length() > 1 && part.charAt(0) == '0') {
  333. throw invalidIp(original);
  334. }
  335. int value = Integer.parseInt(part);
  336. if (value > 255) {
  337. throw invalidIp(original);
  338. }
  339. octets[i] = value;
  340. }
  341. return octets;
  342. }
  343. /** IPv6 字面量解析(含 ':' 一律按 IPv6 处理,不触发 DNS 查询)。 */
  344. private static byte[] parseIpv6(String text, String original) {
  345. try {
  346. return InetAddress.getByName(text).getAddress();
  347. } catch (Exception ex) {
  348. throw invalidIp(original);
  349. }
  350. }
  351. private static int parsePrefix(String prefix, int maxValue, String original) {
  352. if (prefix.isEmpty() || !isDigits(prefix)) {
  353. throw invalidIp(original);
  354. }
  355. int value;
  356. try {
  357. value = Integer.parseInt(prefix);
  358. } catch (NumberFormatException ex) {
  359. throw invalidIp(original);
  360. }
  361. if (value > maxValue) {
  362. throw invalidIp(original);
  363. }
  364. return value;
  365. }
  366. /** 校验 IPv6 字节序列低 (128-prefix) 位全为 0。 */
  367. private static boolean hostBitsZero(byte[] bytes, int prefix) {
  368. for (int bit = prefix; bit < 128; bit++) {
  369. int byteIndex = bit / 8;
  370. int bitInByte = 7 - (bit % 8);
  371. if (((bytes[byteIndex] >> bitInByte) & 1) != 0) {
  372. return false;
  373. }
  374. }
  375. return true;
  376. }
  377. /** 字节序列渲染为地址文本(4字节为IPv4点分、16字节为IPv6全展开形式)。 */
  378. private static String inetText(byte[] bytes) {
  379. try {
  380. return InetAddress.getByAddress(bytes).getHostAddress();
  381. } catch (Exception ex) {
  382. throw new IllegalStateException("IP 地址渲染失败", ex);
  383. }
  384. }
  385. private static DmsException invalidIp(String text) {
  386. return Errors.invalidArgument("IP地址、网段或IP范围格式非法:" + text);
  387. }
  388. private static DmsException hostBitsError(String text) {
  389. return Errors.invalidArgument("网段要求主机位为0,请输入如192.168.1.0/24的形式:" + text);
  390. }
  391. private static DmsException rangeVersionError(String text) {
  392. return Errors.invalidArgument("IP范围两端必须同为IPv4或IPv6:" + text);
  393. }
  394. private static DmsException rangeOrderError(String text) {
  395. return Errors.invalidArgument("IP范围起始不能大于结束:" + text);
  396. }
  397. private static boolean isDigits(String value) {
  398. for (int i = 0; i < value.length(); i++) {
  399. char c = value.charAt(i);
  400. if (c < '0' || c > '9') {
  401. return false;
  402. }
  403. }
  404. return true;
  405. }
  406. // ------------------------------------------------------------ 内部工具
  407. /** 对齐 _remark:null → "",strip,长度上限 128。 */
  408. private static String remark(Object value) {
  409. if (value == null) {
  410. return "";
  411. }
  412. if (!(value instanceof String text)) {
  413. throw Errors.invalidArgument("remark必须是字符串");
  414. }
  415. String normalized = text.strip();
  416. if (normalized.length() > REMARK_MAX_LENGTH) {
  417. throw Errors.invalidArgument("remark长度不能超过" + REMARK_MAX_LENGTH);
  418. }
  419. return normalized;
  420. }
  421. private static Map<String, Object> entrySummary(IpEntryEntity entry) {
  422. Map<String, Object> result = Payloads.orderedMap();
  423. result.put("id", serializeId(entry.getId()));
  424. result.put("ipValue", entry.getIpValue());
  425. result.put("remark", entry.getRemark());
  426. result.put("rowVersion", entry.getRowVersion());
  427. return result;
  428. }
  429. private static void checkVersion(IpEntryEntity entry, int expectedVersion) {
  430. if (entry.getRowVersion() == null || entry.getRowVersion() != expectedVersion) {
  431. throw conflict("数据已被其他用户修改,请刷新后重试",
  432. Map.of("currentRowVersion", entry.getRowVersion() == null
  433. ? 0 : entry.getRowVersion()));
  434. }
  435. }
  436. private static void checkOrgVersion(OrganizationEntity entity, int expectedVersion) {
  437. if (entity.getRowVersion() == null || entity.getRowVersion() != expectedVersion) {
  438. throw conflict("数据已被其他用户修改,请刷新后重试",
  439. Map.of("currentRowVersion", entity.getRowVersion() == null
  440. ? 0 : entity.getRowVersion()));
  441. }
  442. }
  443. private void audit(HttpServletRequest request, String action, long targetId,
  444. String targetName, Map<String, Object> detail) {
  445. auditService.insert(auditService.businessAudit(
  446. request, action, "IP_ENTRY", targetId, targetName,
  447. auditService.toJson(detail)));
  448. }
  449. private static String serializeId(Long id) {
  450. return id == null ? null : String.valueOf(id);
  451. }
  452. private static DmsException conflict(String message, Object details) {
  453. return new DmsException("DATA_VERSION_CONFLICT", 409, message, details);
  454. }
  455. private static DmsException notFound(String message) {
  456. return new DmsException("RESOURCE_NOT_FOUND", 404, message);
  457. }
  458. }