20260915170028
This commit is contained in:
1 parent
d26afb5fec
commit
1f56cc3efb
89 files changed
+1135
-397
No files matched your search
@@ -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) {
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user