代码提交

This commit is contained in:
张家训
2026-06-16 09:14:24 +08:00
parent 89ff93f78e
commit 089558ddcd
7 changed files with 390 additions and 65 deletions
@@ -20,14 +20,10 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Scanner;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.beans.factory.InitializingBean;
@@ -558,72 +554,87 @@ public class GitSyncPlugin implements Plugin, InitializingBean {
// ==================== Git 命令执行 ====================
/**
* 执行一条 Git 命令,总超时 CMD_TIMEOUT_SECONDS + 10 秒。
* <p>
* 关键:进程输出在独立线程中读取,主线程通过 process.waitFor(timeout) 等待。
* 超时后强制杀进程 + 中断读线程,杜绝僵尸进程 / 永久阻塞。
*/
private CmdResult runGit(String workingDir, String command) {
// 用 Future 包一层,防止 readLine() 因进程卡死而永久阻塞
ExecutorService singleExecutor = Executors.newSingleThreadExecutor();
Future<CmdResult> future = singleExecutor.submit(() -> {
CmdResult result = new CmdResult();
try {
ProcessBuilder pb = isWindows()
? new ProcessBuilder("cmd.exe", "/c", command)
: new ProcessBuilder("bash", "-c", command);
CmdResult result = new CmdResult();
Process process = null;
Thread readerThread = null;
StringBuilder output = new StringBuilder();
if (workingDir != null) {
pb.directory(new File(workingDir));
}
pb.redirectErrorStream(true);
try {
ProcessBuilder pb = isWindows()
? new ProcessBuilder("cmd.exe", "/c", command)
: new ProcessBuilder("bash", "-c", command);
Process process = pb.start();
StringBuilder output = new StringBuilder();
if (workingDir != null) {
pb.directory(new File(workingDir));
}
pb.redirectErrorStream(true);
process = pb.start();
final Process proc = process; // effectively final 副本供 lambda 引用
// 独立线程读取进程输出,避免 readLine() 阻塞主逻辑
readerThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), java.nio.charset.StandardCharsets.UTF_8))) {
new InputStreamReader(proc.getInputStream(), java.nio.charset.StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append("\n");
}
} catch (IOException ignored) {
// 进程被杀时流关闭会抛异常,正常情况
}
}, "git-reader");
readerThread.setDaemon(true);
readerThread.start();
boolean finished = process.waitFor(CMD_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
result.ok = false;
result.error = "命令超时(" + CMD_TIMEOUT_SECONDS + "s)";
return result;
}
int exitCode = process.exitValue();
result.ok = (exitCode == 0);
result.output = output.toString().trim();
if (!result.ok) {
String detail = output.toString().trim();
result.error = detail.isEmpty()
? "exit code " + exitCode
: "exit code " + exitCode + ": " + detail;
}
} catch (Exception e) {
// 等待进程结束(带超时)
boolean finished = process.waitFor(CMD_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!finished) {
log.warn("Git 命令超时({}s): {} -> {}", CMD_TIMEOUT_SECONDS, workingDir, command);
result.ok = false;
result.error = e.getMessage();
result.error = "命令超时(" + CMD_TIMEOUT_SECONDS + "s)";
return result;
}
return result;
});
try {
// 整体超时比内部 waitFor 多给 10 秒缓冲
return future.get(CMD_TIMEOUT_SECONDS + 10, TimeUnit.SECONDS);
} catch (TimeoutException e) {
future.cancel(true);
CmdResult result = new CmdResult();
// 进程已结束,等待读线程收尾(最多10秒)
readerThread.join(10_000);
int exitCode = process.exitValue();
result.ok = (exitCode == 0);
result.output = output.toString().trim();
if (!result.ok) {
String detail = output.toString().trim();
result.error = detail.isEmpty()
? "exit code " + exitCode
: "exit code " + exitCode + ": " + detail;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
result.ok = false;
result.error = "命令超时(" + (CMD_TIMEOUT_SECONDS + 10) + "s,进程被强制终止)";
return result;
} catch (InterruptedException | ExecutionException | CancellationException e) {
CmdResult result = new CmdResult();
result.error = "命令被中断";
} catch (Exception e) {
result.ok = false;
result.error = "执行异常: " + e.getMessage();
return result;
} finally {
singleExecutor.shutdownNow();
// 确保进程和读线程都被清理
if (process != null) {
if (process.isAlive()) {
process.destroyForcibly();
try { process.waitFor(5, TimeUnit.SECONDS); } catch (InterruptedException ignored) { }
}
}
if (readerThread != null && readerThread.isAlive()) {
readerThread.interrupt();
}
}
return result;
}
private String getRemoteUrl(String repoPath, Map<String, String> baseUrlMap) {
@@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.annotation.PreDestroy;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -15,6 +16,8 @@ import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -22,6 +25,9 @@ import java.util.regex.Pattern;
@Slf4j
public class ScannerService {
/** 单个项目扫描超时时间(分钟) */
private static final long SCAN_TIMEOUT_MINUTES = 5;
private final ExecutorService executorService = Executors.newFixedThreadPool(10);
@Autowired
private FileOperationHelper fileOperationHelper;
@@ -30,11 +36,25 @@ public class ScannerService {
@Autowired
private WorkUtilsProperties properties;
@PreDestroy
public void shutdown() {
log.info("正在关闭扫描线程池...");
executorService.shutdown();
try {
if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) {
executorService.shutdownNow();
}
} catch (InterruptedException e) {
executorService.shutdownNow();
Thread.currentThread().interrupt();
}
log.info("扫描线程池已关闭");
}
public ScanResult scanAllProjects(ScanType scanType, String pattern) throws Exception {
List<String> projectPaths = fileOperationHelper.getProjectPaths();
if (projectPaths.isEmpty()) {
log.warn("No project paths found");
return new ScanResult();
throw new IllegalStateException("未配置项目路径,请先在「系统设置」中添加要扫描的项目路径");
}
ScanResult result = new ScanResult();
@@ -53,9 +73,25 @@ public class ScannerService {
futures.add(future);
}
int total = futures.size();
int idx = 0;
for (CompletableFuture<ProjectScanResult> future : futures) {
ProjectScanResult projectResult = future.get();
result.addProjectResult(projectResult);
idx++;
String pathHint = (idx <= projectPaths.size())
? " (" + fileOperationHelper.extractProjectName(projectPaths.get(idx - 1)) + ")"
: "";
try {
ProjectScanResult projectResult = future.get(SCAN_TIMEOUT_MINUTES, TimeUnit.MINUTES);
result.addProjectResult(projectResult);
} catch (TimeoutException e) {
log.error("项目扫描超时({}分钟)第{}/{}个项目{},已跳过",
SCAN_TIMEOUT_MINUTES, idx, total, pathHint);
// 超时后仍尝试取消该任务
future.cancel(true);
} catch (Exception e) {
log.error("获取扫描结果异常 第{}/{}个项目{}: {}",
idx, total, pathHint, e.getMessage());
}
}
return result;
@@ -25,8 +25,10 @@ public class ConfigInitializer implements ApplicationListener<ApplicationReadyEv
log.info("发现外部配置文件: {}", configManager.getConfigFile().toAbsolutePath());
properties.mergeExternal(configManager.load());
} else {
log.info("未找到外部配置文件 workutils.config,使用 application.yml 默认值");
log.info("可通过「系统设置(S)」创建并编辑 workutils.config");
log.info("未找到外部配置文件 workutils.config,正在使用 application.yml 默认值生成...");
configManager.saveSectioned(configManager.generateDefaults(properties));
properties.mergeExternal(configManager.load());
log.info("已生成默认外部配置文件: {}", configManager.getConfigFile().toAbsolutePath());
}
}
}
@@ -176,6 +176,14 @@ public class ExternalConfigManager {
for (String k : mybatisKeys) {
props.setProperty(k, "");
}
// 字体大小默认值
props.setProperty(KEY_FONT_SECTION_TITLE, String.valueOf(appProps.getFontSectionTitle()));
props.setProperty(KEY_FONT_FIELD_LABEL, String.valueOf(appProps.getFontFieldLabel()));
props.setProperty(KEY_FONT_BUTTON, String.valueOf(appProps.getFontButton()));
props.setProperty(KEY_FONT_BODY, String.valueOf(appProps.getFontBody()));
props.setProperty(KEY_FONT_MONO, String.valueOf(appProps.getFontMono()));
return props;
}
@@ -57,6 +57,9 @@ public class SettingsWindow {
private TextArea taDamagedCache;
private Label lblProjectPathsFile, lblDamagedCacheFile;
// --- 项目路径替换 ---
private TextField tfReplaceParentDir, tfReplaceAccount;
// --- 字体大小 ---
private int fontSectionTitle;
private int fontFieldLabel;
@@ -270,7 +273,7 @@ public class SettingsWindow {
btnOpenPP.setOnAction(e -> openInNotepad(properties.getProjectPathsFilePath()));
HBox ppBtns = new HBox(10, btnLoadPP, btnSavePP, btnOpenPP);
ppBox.getChildren().addAll(lblProjectPathsFile, taProjectPaths, ppBtns);
ppBox.getChildren().addAll(lblProjectPathsFile, taProjectPaths, ppBtns, createReplaceTool());
Tab ppTab = new Tab("项目路径");
ppTab.setContent(ppBox);
@@ -724,6 +727,237 @@ public class SettingsWindow {
}
}
// ==================== 项目路径替换工具 ====================
private VBox createReplaceTool() {
VBox box = new VBox(8);
box.setPadding(new Insets(8, 0, 0, 0));
box.setStyle("-fx-border-color: #e0e0e0; -fx-border-width: 1 0 0 0; -fx-padding: 10 0 0 0;");
Label title = new Label("批量字符替换");
title.setStyle("-fx-font-weight: bold; -fx-font-size: " + fontSectionTitle + "px;");
titleLabels.add(title);
box.getChildren().add(title);
// 父目录替换行
HBox parentRow = new HBox(8);
parentRow.setAlignment(Pos.CENTER_LEFT);
Label lblParent = new Label("父目录: ");
lblParent.setFont(font(fontFieldLabel));
fieldLabels.add(lblParent);
tfReplaceParentDir = new TextField();
tfReplaceParentDir.setPromptText("自动检测当前父目录,在此输入新父目录");
tfReplaceParentDir.setPrefWidth(260);
textInputs.add(tfReplaceParentDir);
Button btnBrowseParent = addSmallButton("浏览...");
btnBrowseParent.setOnAction(e -> browseForParentDir());
Button btnDetectParent = addSmallButton("检测");
btnDetectParent.setOnAction(e -> detectParentDir());
Button btnApplyParent = addSmallButton("替换");
btnApplyParent.setOnAction(e -> applyReplaceParentDir());
parentRow.getChildren().addAll(lblParent, tfReplaceParentDir, btnBrowseParent, btnDetectParent, btnApplyParent);
// 账号替换行
HBox accountRow = new HBox(8);
accountRow.setAlignment(Pos.CENTER_LEFT);
Label lblAccount = new Label("账 号: ");
lblAccount.setFont(font(fontFieldLabel));
fieldLabels.add(lblAccount);
tfReplaceAccount = new TextField();
tfReplaceAccount.setPromptText("自动检测当前账号,在此输入新账号");
tfReplaceAccount.setPrefWidth(300);
textInputs.add(tfReplaceAccount);
Button btnDetectAccount = addSmallButton("检测");
btnDetectAccount.setOnAction(e -> detectAccount());
Button btnApplyAccount = addSmallButton("替换");
btnApplyAccount.setOnAction(e -> applyReplaceAccount());
accountRow.getChildren().addAll(lblAccount, tfReplaceAccount, btnDetectAccount, btnApplyAccount);
box.getChildren().addAll(parentRow, accountRow);
return box;
}
/** 从第一条有效项目路径中检测父目录 */
private void detectParentDir() {
String firstPath = findFirstProjectPath();
if (firstPath == null) {
showAlert(AlertType.WARNING, "项目路径为空,无法检测父目录。");
return;
}
// 取 / 分割后的第一个非空部分作为父目录,如 D:/bestpay-srdcloud/ft-xxx 中的 D:/bestpay-srdcloud
int slashIdx = firstPath.indexOf('/');
if (slashIdx < 0) {
tfReplaceParentDir.setText(firstPath);
return;
}
// 找到第一个 / 后,继续找 /ft-xxx 的 /,即项目目录前一个 /
int nextSlash = firstPath.indexOf('/', slashIdx + 1);
if (nextSlash > 0) {
tfReplaceParentDir.setText(firstPath.substring(0, nextSlash));
} else {
tfReplaceParentDir.setText(firstPath);
}
}
/** 浏览选择父目录 */
private void browseForParentDir() {
DirectoryChooser chooser = new DirectoryChooser();
chooser.setTitle("选择父目录");
String current = tfReplaceParentDir.getText().trim();
if (!current.isEmpty()) {
File f = new File(current);
if (f.exists()) {
chooser.setInitialDirectory(f);
} else {
// 尝试取已存在的上级目录
File parent = f.getParentFile();
while (parent != null && !parent.exists()) {
parent = parent.getParentFile();
}
if (parent != null) {
chooser.setInitialDirectory(parent);
}
}
}
File selected = chooser.showDialog(stage);
if (selected != null) {
tfReplaceParentDir.setText(selected.getAbsolutePath());
}
}
/** 从第一条带 URL 的项目路径中检测账号 */
private void detectAccount() {
String firstLine = findFirstLineWithUrl();
if (firstLine == null) {
showAlert(AlertType.WARNING, "未找到带远程 URL 的项目路径,请确保路径格式为 path=URL。");
return;
}
// 格式: path=https://account@code.srdcloud.cn/...
int eqIdx = firstLine.indexOf('=');
if (eqIdx < 0) return;
String url = firstLine.substring(eqIdx + 1).trim();
// 提取 @ 前面的账号:https://xxx → 取 // 和 @ 之间的部分
int atIdx = url.indexOf('@');
int protoIdx = url.indexOf("://");
if (atIdx > 0 && protoIdx > 0) {
String account = url.substring(protoIdx + 3, atIdx);
tfReplaceAccount.setText(account);
}
}
/** 批量替换父目录 */
private void applyReplaceParentDir() {
String newParent = tfReplaceParentDir.getText().trim();
if (newParent.isEmpty()) {
showAlert(AlertType.WARNING, "请输入新的父目录。");
return;
}
// 检测当前父目录
String firstPath = findFirstProjectPath();
if (firstPath == null) {
showAlert(AlertType.WARNING, "项目路径为空。");
return;
}
int slashIdx = firstPath.indexOf('/');
if (slashIdx < 0) {
showAlert(AlertType.WARNING, "无法解析父目录。");
return;
}
int nextSlash = firstPath.indexOf('/', slashIdx + 1);
String oldParent = (nextSlash > 0) ? firstPath.substring(0, nextSlash) : firstPath;
// 规范化:去掉末尾 /
if (newParent.endsWith("/") || newParent.endsWith("\\")) {
newParent = newParent.substring(0, newParent.length() - 1);
}
if (oldParent.endsWith("/") || oldParent.endsWith("\\")) {
oldParent = oldParent.substring(0, oldParent.length() - 1);
}
int count = replaceInTextArea(oldParent, newParent);
showAlert(AlertType.INFORMATION,
"已将 " + count + " 条路径中的父目录从\n" + oldParent + "\n替换为\n" + newParent);
}
/** 批量替换账号 */
private void applyReplaceAccount() {
String newAccount = tfReplaceAccount.getText().trim();
if (newAccount.isEmpty()) {
showAlert(AlertType.WARNING, "请输入新的账号。");
return;
}
// 检测当前账号
String firstLine = findFirstLineWithUrl();
if (firstLine == null) {
showAlert(AlertType.WARNING, "未找到带远程 URL 的项目路径。");
return;
}
int eqIdx = firstLine.indexOf('=');
if (eqIdx < 0) return;
String url = firstLine.substring(eqIdx + 1).trim();
int atIdx = url.indexOf('@');
int protoIdx = url.indexOf("://");
if (atIdx <= 0 || protoIdx <= 0) {
showAlert(AlertType.WARNING, "无法解析当前账号。");
return;
}
String oldAccount = url.substring(protoIdx + 3, atIdx);
int count = replaceInTextArea(oldAccount, newAccount);
showAlert(AlertType.INFORMATION,
"已将 " + count + " 条URL中的账号从\n" + oldAccount + "\n替换为\n" + newAccount);
}
/** 在 TextArea 中替换文本,返回替换行数 */
private int replaceInTextArea(String oldStr, String newStr) {
String text = taProjectPaths.getText();
int count = 0;
String[] lines = text.split("\n");
StringBuilder sb = new StringBuilder();
for (String line : lines) {
if (!line.trim().isEmpty() && !line.trim().startsWith("#") && line.contains(oldStr)) {
sb.append(line.replace(oldStr, newStr)).append("\n");
count++;
} else {
sb.append(line).append("\n");
}
}
taProjectPaths.setText(sb.toString());
return count;
}
/** 找到第一条有效项目路径(只取路径部分,不含 URL) */
private String findFirstProjectPath() {
String text = taProjectPaths.getText();
if (text == null || text.trim().isEmpty()) return null;
for (String line : text.split("\n")) {
line = line.trim();
if (line.isEmpty() || line.startsWith("#")) continue;
int eqIdx = line.indexOf('=');
if (eqIdx >= 0) {
return line.substring(0, eqIdx).trim();
}
return line;
}
return null;
}
/** 找到第一条同时包含路径和 URL 的行 */
private String findFirstLineWithUrl() {
String text = taProjectPaths.getText();
if (text == null || text.trim().isEmpty()) return null;
for (String line : text.split("\n")) {
line = line.trim();
if (line.isEmpty() || line.startsWith("#")) continue;
int eqIdx = line.indexOf('=');
if (eqIdx >= 0) {
String url = line.substring(eqIdx + 1).trim();
if (!url.isEmpty()) return line;
}
}
return null;
}
private void openInNotepad(Path file) {
try {
if (!Files.exists(file)) {
@@ -7,6 +7,7 @@ import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -69,6 +70,24 @@ public class WorkUtilsProperties {
@PostConstruct
void snapshotDefaults() {
// 基础目录:未配置则默认取运行目录(user.dir)
if (baseDirectory == null || baseDirectory.trim().isEmpty()) {
baseDirectory = System.getProperty("user.dir");
log.info("基础目录未配置,默认取运行目录: {}", baseDirectory);
}
// 导出目录:未配置则默认 {user.dir}/export
if (exportDirectory == null || exportDirectory.trim().isEmpty()) {
exportDirectory = System.getProperty("user.dir") + "/export";
log.info("导出目录未配置,默认: {}", exportDirectory);
ensureExportDirExists();
} else if (!Paths.get(exportDirectory).isAbsolute()) {
// 相对路径 → 解析到 user.dir 下
exportDirectory = Paths.get(System.getProperty("user.dir"), exportDirectory).toString();
ensureExportDirExists();
} else {
ensureExportDirExists();
}
defaults.put(ExternalConfigManager.KEY_BASE_DIR, baseDirectory);
defaults.put(ExternalConfigManager.KEY_EXPORT_DIR, exportDirectory);
defaults.put(ExternalConfigManager.KEY_PROJECT_PATHS, projectPathsFile);
@@ -77,6 +96,19 @@ public class WorkUtilsProperties {
ensureDataFilesExist();
}
/** 确保导出目录存在 */
private void ensureExportDirExists() {
try {
Path dir = Paths.get(exportDirectory);
if (!Files.exists(dir)) {
Files.createDirectories(dir);
log.info("已创建导出目录: {}", dir.toAbsolutePath());
}
} catch (IOException e) {
log.warn("创建导出目录失败: {} ({})", exportDirectory, e.getMessage());
}
}
/**
* 合并外部配置 —— 外部值覆盖 application.yml 值
*/
@@ -155,7 +187,7 @@ public class WorkUtilsProperties {
* 文件不存在时自动创建空文件。
*/
public Path getProjectPathsFilePath() {
Path p = Paths.get(projectPathsFile != null ? projectPathsFile : "代码仓库项目路径.txt");
Path p = Paths.get(projectPathsFile != null ? projectPathsFile : "project_paths.txt");
if (p.isAbsolute()) {
return p;
}
@@ -183,7 +215,7 @@ public class WorkUtilsProperties {
* 无法读取模板时则创建空文件。
*/
private void ensureDataFilesExist() {
ensureDataFile(getProjectPathsFilePath(), "config/代码仓库项目路径.txt", "项目路径文件");
ensureDataFile(getProjectPathsFilePath(), "config/project_paths.txt", "项目路径文件");
ensureDataFile(getDamagedReposFilePath(), "config/damaged_repos.cache", "损坏缓存文件");
}
+6 -4
View File
@@ -3,10 +3,12 @@ spring:
name: workutils
workutils:
base-directory: "D:/workspace/workutils"
export-directory: "D:/workspace/workutils/export"
# 数据文件路径(相对路径 → user.dir 同级目录,绝对路径 → 直接使用)
project-paths-file: "代码仓库项目路径.txt"
# base dir, empty = defaults to user.dir
base-directory: ""
# export dir, empty = defaults to {user.dir}/export (auto-created on startup)
export-directory: ""
# data file paths (relative -> under user.dir, absolute -> as-is)
project-paths-file: "project_paths.txt"
damaged-repos-file: "damaged_repos.cache"
logging: