20260915170028

This commit is contained in:
oneao committed 2026-09-15 17:00:29 +08:00
1 parent d26afb5fec
commit 1f56cc3efb
89 files changed
+1135 -397

No files matched your search

+60 -4
View File
@@ -12,6 +12,7 @@
| `RunSqlFile.java` | 通用 | 在 FMS 库执行 `sql/` 下的建表 / 视图 / 索引脚本(按 GO 分批,单事务) |
| `OtherDataProbe.java` | 迁移辅助(只读) | `list` 列出旧库 `b_other_bmfl` 的 65 个字典模块(启用/旧库行数/新库已建),输出 `otherdata-modules.txt`;`tables <表名...>` 导出新旧结构 + 旧库数据样例 |
| `OtherDataMigrate.java` | **其他数据迁移** | 导入 `base_otherdata` 下 64 个字典模块的数据(雪花主键)、重建 `b_from`/`b_grade`、转换 `b_othercompany` 引用值,输出映射 `otherdata-id-map.tsv`;支持 `--dry-run` |
| `TreePathRepair.java` | 核对 / 修复 | 按 `b_parent_id` 重建树表的 `b_depth` / `b_path`,默认只读报告,`--apply` 才写库 |
---
@@ -162,10 +163,10 @@ del FieldNameSync*.class
javac -encoding UTF-8 -cp "mssql-jdbc-13.4.0.jre11.jar" SchemaDumpMarkdown.java
# 导出新库 FMS 到指定文件
java -Dstdout.encoding=UTF-8 -cp ".;mssql-jdbc-13.4.0.jre11.jar" SchemaDumpMarkdown "D:\path\FMS表结构.md" FMS
java -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -cp ".;mssql-jdbc-13.4.0.jre11.jar" SchemaDumpMarkdown "D:\path\FMS表结构.md" FMS
# 只导出表,不含视图
java -Dstdout.encoding=UTF-8 -cp ".;mssql-jdbc-13.4.0.jre11.jar" SchemaDumpMarkdown "D:\path\FMS表结构.md" FMS --tables-only
java -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -cp ".;mssql-jdbc-13.4.0.jre11.jar" SchemaDumpMarkdown "D:\path\FMS表结构.md" FMS --tables-only
```
参数:`<输出文件绝对路径>` `[数据库名,默认 G3HY2025]` `[--tables-only]`
@@ -184,11 +185,11 @@ catalog 必须是 `FMS`;按 `GO` 把脚本拆成批次,在单个事务中依
cd fms-api
# 先 dry-run 看批次划分
java -Dstdout.encoding=UTF-8 -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" \
java -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" \
tools/migration/RunSqlFile.java ../sql/fms_business_contact.sql --dry-run
# 确认后执行
java -Dstdout.encoding=UTF-8 -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" \
java -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" \
tools/migration/RunSqlFile.java ../sql/fms_business_contact.sql
```
@@ -201,6 +202,61 @@ javac -encoding UTF-8 -cp "mssql-jdbc-13.4.0.jre11.jar" RunSqlFile.java
> 含「重建」段落(`drop ... if exists`)的脚本会删除同名表及其数据,执行前确认表中数据已不需要。
---
## TreePathRepair — 树表 b_depth / b_path 核对与修复
`b_parent_id` 是树结构的权威关系,`b_depth` / `b_path` 是派生字段(见《FMS新系统核心表结构设计》1.7)。
本工具按 `b_parent_id` 重算这两列,用于核对和修复派生字段与父子关系不一致的数据。
历史背景:模块管理 / 菜单管理的拖拽排序曾有一处缺陷,会把**根节点** `b_path` 的前导 `/` 抹掉
(`/base/` 被写成 `base/`)并整树落库。前端已修复,本工具用于核对历史数据、必要时重算。
`b_path` 的约定格式形如 `/base/base_otherdata/`(节点编码前后均带 `/`)。
### 行为
- **默认只读报告,不写库**;确认无误后加 `--apply` 才在单个事务内更新 `b_depth` / `b_path`,失败整体回滚。
- 只更新这两列,`b_parent_id` 与其余字段一律不动;父级不存在的节点按根处理(与前端 `buildTree` 一致)。
- **成环的节点无法派生路径,只报告不修**——父子关系成环是数据问题,需人工先修 `b_parent_id`。
- 报告会区分「只缺前导 `/`」这一缺陷典型症状,并给出变更样例。
### 用法
```bash
cd fms-api
# 只读报告(默认):核对 s_module / s_menu
java -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" \
tools/migration/TreePathRepair.java
# 核对全部四张树表
java -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" \
tools/migration/TreePathRepair.java --tables s_module,s_menu,b_dept,b_othercompany_category
# 确认后执行修复
java -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" \
tools/migration/TreePathRepair.java --apply
# 多机构:指向目标机构的连接配置(默认 config/dbconfigs/G3HD.properties)
java -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -cp "tools/migration/mssql-jdbc-13.4.0.jre11.jar" \
tools/migration/TreePathRepair.java --config config/dbconfigs/OTHER.properties
```
参数:
| 参数 | 说明 |
| --- | --- |
| (无) | 只读报告,不写库 |
| `--dry-run` | 同「无参数」,显式表达只读 |
| `--apply` | 执行修复(单事务) |
| `--tables a,b` | 待核对表,默认 `s_module,s_menu`;可选 `s_module` / `s_menu` / `b_dept` / `b_othercompany_category` |
| `--config <path>` | 连接配置,默认 `config/dbconfigs/G3HD.properties` |
> 修复只重算派生字段,全部内容都能由 `b_parent_id` 重新推出,因此可安全重复执行;
> 执行后重跑一次只读报告应得到「待更新 0 行」。
---
## 已有同步记录
| 模块 / 视图 | 同步日期 | 字段数 | 备注 |
@@ -0,0 +1,372 @@
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) {
}
}