373 lines
15 KiB
Java
373 lines
15 KiB
Java
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<String> 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<String> 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<String> 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<String, Node> nodes = loadNodes(connection, table);
|
||
Set<String> onCycle = findCycleNodes(nodes);
|
||
|
||
Map<String, Position> expected = new LinkedHashMap<>();
|
||
Set<String> blocked = new LinkedHashSet<>();
|
||
Set<String> orphan = new LinkedHashSet<>();
|
||
Set<String> tooLong = new LinkedHashSet<>();
|
||
for (String id : nodes.keySet()) {
|
||
resolve(id, nodes, expected, onCycle, blocked, orphan, tooLong);
|
||
}
|
||
|
||
List<Change> changes = new ArrayList<>();
|
||
int depthMismatch = 0;
|
||
int pathMismatch = 0;
|
||
int missingLeadingSlash = 0;
|
||
for (Map.Entry<String, Node> 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<String, Node> loadNodes(Connection connection, String table) throws Exception {
|
||
String sql = "SELECT b_id, b_parent_id, b_depth, b_path FROM dbo." + table;
|
||
Map<String, Node> 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<String> findCycleNodes(Map<String, Node> nodes) {
|
||
Set<String> onCycle = new LinkedHashSet<>();
|
||
for (String start : nodes.keySet()) {
|
||
Map<String, Integer> seen = new LinkedHashMap<>();
|
||
String current = start;
|
||
while (current != null && nodes.containsKey(current)) {
|
||
Integer previous = seen.get(current);
|
||
if (previous != null) {
|
||
List<String> 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<String, Node> nodes,
|
||
Map<String, Position> cache,
|
||
Set<String> onCycle,
|
||
Set<String> blocked,
|
||
Set<String> orphan,
|
||
Set<String> 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<String> limit(Set<String> values) {
|
||
return values.stream().limit(SAMPLE_LIMIT).toList();
|
||
}
|
||
|
||
private static String join(List<String> values) {
|
||
return String.join(", ", values);
|
||
}
|
||
|
||
private static void printUsage() {
|
||
System.out.println("用法: TreePathRepair [--dry-run | --apply] [--tables s_module,s_menu,...] [--config <properties>]");
|
||
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) {
|
||
}
|
||
}
|