import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.Statement; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Properties; import java.util.Set; import java.util.regex.Pattern; /** * 树表 b_depth / b_path 核对与修复。 * * 背景:模块管理 / 菜单管理的拖拽排序曾把根节点 b_path 的前导 / 抹掉 * (`/base/` 被写成 `base/`),并把整棵树按错误规则重新落库。 * b_parent_id 是权威关系,b_depth / b_path 是派生字段(见《FMS新系统核心表结构设计》1.7), * 因此受损数据可以按 b_parent_id 完整重建,本工具就是做这件事。 * * 行为: * - 默认只读报告(不写库);加 --apply 才在单个事务内重建,失败整体回滚。 * - 只处理 b_depth / b_path 两列,b_parent_id 与其余字段一律不动。 * - 成环的节点无法派生路径,只报告不修(父子关系是数据问题,应由人工先修)。 * * 用法(在 fms-api 目录下运行): * java -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" tools/migration/TreePathRepair.java * java -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" tools/migration/TreePathRepair.java --apply * java -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" tools/migration/TreePathRepair.java \ * --tables s_module,s_menu,b_dept,b_othercompany_category */ public final class TreePathRepair { /** 允许操作的表(树结构规范里的四张树表) */ private static final Set ALLOWED_TABLES = new LinkedHashSet<>(List.of( "s_module", "s_menu", "b_dept", "b_othercompany_category" )); private static final Pattern IDENTIFIER = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*"); private static final int PATH_LIMIT = 1000; private static final int SAMPLE_LIMIT = 30; public static void main(String[] args) throws Exception { boolean apply = false; List tables = List.of("s_module", "s_menu"); String configPath = "config/dbconfigs/G3HD.properties"; for (int i = 0; i < args.length; i++) { String arg = args[i]; if ("--apply".equalsIgnoreCase(arg)) { apply = true; } else if ("--dry-run".equalsIgnoreCase(arg)) { apply = false; } else if ("--tables".equalsIgnoreCase(arg) && i + 1 < args.length) { tables = List.of(args[++i].split(",")); } else if ("--config".equalsIgnoreCase(arg) && i + 1 < args.length) { configPath = args[++i]; } else { System.out.println("未知参数: " + arg); printUsage(); return; } } List normalizedTables = new ArrayList<>(); for (String table : tables) { String name = table.trim(); if (!normalizedTables.contains(name)) { normalizedTables.add(name); } } for (String table : normalizedTables) { if (!IDENTIFIER.matcher(table).matches() || !ALLOWED_TABLES.contains(table)) { throw new IllegalArgumentException( "不允许的表: " + table + "(可选: " + String.join(", ", ALLOWED_TABLES) + ")" ); } } Path apiRoot = Path.of("").toAbsolutePath().normalize(); Path propertiesFile = apiRoot.resolve(configPath).normalize(); Properties properties = new Properties(); try (InputStream input = Files.newInputStream(propertiesFile)) { properties.load(input); } System.out.println(apply ? "模式: 修复(会写库)" : "模式: 只读报告(不写库,加 --apply 才修复)"); System.out.println("配置文件: " + propertiesFile); try (Connection connection = DriverManager.getConnection( properties.getProperty("url"), properties.getProperty("username"), properties.getProperty("password") )) { System.out.println("目标库: " + connection.getCatalog()); System.out.println(); int totalChanged = 0; int totalBlocked = 0; for (String table : normalizedTables) { int[] result = repairTable(connection, table, apply); totalChanged += result[0]; totalBlocked += result[1]; System.out.println(); } if (apply) { System.out.println("=== 修复完成:共更新 " + totalChanged + " 行,跳过 " + totalBlocked + " 行 ==="); } else { System.out.println("=== 只读报告:待更新 " + totalChanged + " 行,跳过 " + totalBlocked + " 行 ==="); if (totalChanged > 0) { System.out.println("确认无误后加 --apply 执行修复。"); } } } } /** * @return {待更新行数, 因成环/超长而跳过的行数} */ private static int[] repairTable(Connection connection, String table, boolean apply) throws Exception { Map nodes = loadNodes(connection, table); Set onCycle = findCycleNodes(nodes); Map expected = new LinkedHashMap<>(); Set blocked = new LinkedHashSet<>(); Set orphan = new LinkedHashSet<>(); Set tooLong = new LinkedHashSet<>(); for (String id : nodes.keySet()) { resolve(id, nodes, expected, onCycle, blocked, orphan, tooLong); } List changes = new ArrayList<>(); int depthMismatch = 0; int pathMismatch = 0; int missingLeadingSlash = 0; for (Map.Entry entry : nodes.entrySet()) { Position position = expected.get(entry.getKey()); if (position == null) { continue; } Node node = entry.getValue(); boolean depthDiffers = node.depth() != position.depth(); boolean pathDiffers = !position.path().equals(node.path()); if (!depthDiffers && !pathDiffers) { continue; } if (depthDiffers) { depthMismatch++; } if (pathDiffers) { pathMismatch++; // 本工具针对的典型症状:路径只是丢了前导 / if (position.path().equals("/" + node.path())) { missingLeadingSlash++; } } changes.add(new Change(node, position)); } System.out.println("=== " + table + "(" + nodes.size() + " 行)==="); System.out.println(" 成环节点(父子关系问题,本工具不修): " + onCycle.size() + (onCycle.isEmpty() ? "" : " → " + join(limit(onCycle)))); if (!blocked.isEmpty()) { System.out.println(" 因父级成环/超长而无法派生: " + blocked.size() + " → " + join(limit(blocked))); } if (!orphan.isEmpty()) { System.out.println(" b_parent_id 指向不存在的节点(按根处理): " + orphan.size() + " → " + join(limit(orphan))); } if (!tooLong.isEmpty()) { System.out.println(" 派生路径超过 " + PATH_LIMIT + " 字符(跳过): " + tooLong.size() + " → " + join(limit(tooLong))); } System.out.println(" b_path 不一致: " + pathMismatch + " 行(其中「只缺前导 /」: " + missingLeadingSlash + " 行)"); System.out.println(" b_depth 不一致: " + depthMismatch + " 行"); System.out.println(" 待更新合计: " + changes.size() + " 行"); if (changes.isEmpty()) { System.out.println(" 无需修复。"); return new int[]{0, blocked.size()}; } System.out.println(" 变更样例:"); for (int i = 0; i < Math.min(SAMPLE_LIMIT, changes.size()); i++) { Change change = changes.get(i); System.out.printf(" %-28s depth %d→%d path '%s' → '%s'%n", change.node().id(), change.node().depth(), change.position().depth(), change.node().path(), change.position().path()); } if (changes.size() > SAMPLE_LIMIT) { System.out.println(" ... 其余 " + (changes.size() - SAMPLE_LIMIT) + " 行略"); } if (!apply) { return new int[]{changes.size(), blocked.size()}; } String updateSql = "UPDATE dbo." + table + " SET b_depth = ?, b_path = ? WHERE b_id = ?"; boolean originalAutoCommit = connection.getAutoCommit(); connection.setAutoCommit(false); try (PreparedStatement statement = connection.prepareStatement(updateSql)) { for (Change change : changes) { statement.setInt(1, change.position().depth()); statement.setString(2, change.position().path()); statement.setString(3, change.node().id()); statement.addBatch(); } int[] affected = statement.executeBatch(); for (int count : affected) { if (count != 1) { throw new IllegalStateException("更新行数异常: " + count); } } connection.commit(); System.out.println(" 已提交 " + changes.size() + " 行。"); } catch (Exception e) { connection.rollback(); throw new IllegalStateException(table + " 修复失败,已回滚: " + e.getMessage(), e); } finally { if (originalAutoCommit) { connection.setAutoCommit(true); } } return new int[]{changes.size(), blocked.size()}; } private static Map loadNodes(Connection connection, String table) throws Exception { String sql = "SELECT b_id, b_parent_id, b_depth, b_path FROM dbo." + table; Map nodes = new LinkedHashMap<>(); try (Statement statement = connection.createStatement(); ResultSet resultSet = statement.executeQuery(sql)) { while (resultSet.next()) { String id = normalize(resultSet.getString("b_id")); if (id == null) { throw new IllegalStateException(table + " 存在 b_id 为空的行"); } int depth = resultSet.getInt("b_depth"); String path = resultSet.getString("b_path"); nodes.put(id, new Node( id, normalize(resultSet.getString("b_parent_id")), depth, path == null ? "" : path )); } } return nodes; } /** 找出所有真正位于环上的节点(沿 b_parent_id 上溯,出现重复即为环) */ private static Set findCycleNodes(Map nodes) { Set onCycle = new LinkedHashSet<>(); for (String start : nodes.keySet()) { Map seen = new LinkedHashMap<>(); String current = start; while (current != null && nodes.containsKey(current)) { Integer previous = seen.get(current); if (previous != null) { List chain = new ArrayList<>(seen.keySet()); for (int i = previous; i < chain.size(); i++) { onCycle.add(chain.get(i)); } break; } seen.put(current, seen.size()); current = nodes.get(current).parentId(); } } return onCycle; } /** 自底向上派生 b_depth / b_path;父级成环或路径超长时返回 null 并记入 blocked */ private static Position resolve( String id, Map nodes, Map cache, Set onCycle, Set blocked, Set orphan, Set tooLong ) { if (id == null || !nodes.containsKey(id)) { return null; } Position cached = cache.get(id); if (cached != null) { return cached; } if (onCycle.contains(id)) { blocked.add(id); return null; } Node node = nodes.get(id); int depth; String path; if (node.parentId() == null) { depth = 0; path = "/" + id + "/"; } else { Position parent = resolve(node.parentId(), nodes, cache, onCycle, blocked, orphan, tooLong); if (parent == null) { if (nodes.containsKey(node.parentId())) { // 父级在环上 / 被阻断,本节点同样无法派生 blocked.add(id); return null; } // 父级不存在:与前端 buildTree 一致,提升为根 orphan.add(id); depth = 0; path = "/" + id + "/"; } else { depth = parent.depth() + 1; path = parent.path() + id + "/"; } } if (path.length() > PATH_LIMIT) { tooLong.add(id); blocked.add(id); return null; } Position position = new Position(depth, path); cache.put(id, position); return position; } private static String normalize(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } private static List limit(Set values) { return values.stream().limit(SAMPLE_LIMIT).toList(); } private static String join(List values) { return String.join(", ", values); } private static void printUsage() { System.out.println("用法: TreePathRepair [--dry-run | --apply] [--tables s_module,s_menu,...] [--config ]"); System.out.println(" 默认(不带参数)只读报告,不写库;--apply 才在单事务内重建 b_depth / b_path。"); System.out.println(" 可选表: " + String.join(", ", ALLOWED_TABLES)); System.out.println(" --config 默认 config/dbconfigs/G3HD.properties(多机构时指向目标机构的配置)。"); } private record Node(String id, String parentId, int depth, String path) { } private record Position(int depth, String path) { } private record Change(Node node, Position position) { } }