Files
2026-09-15 17:00:29 +08:00

373 lines
15 KiB
Java
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) {
}
}