Initial commit: add AutoDeploy project

This commit is contained in:
wangpeng
2026-07-14 16:30:36 +08:00
commit 2d7b6216ba
23 changed files with 3615 additions and 0 deletions

1
deploy_tool/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Spring Boot + Vue deployment workbench."""

626
deploy_tool/deployer.py Normal file
View File

@@ -0,0 +1,626 @@
from __future__ import annotations
import json
import os
import socket
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Callable, Iterable, Mapping
from .environment import discover_executable
from .scanner import ScanResult
@dataclass(frozen=True)
class DeploymentConfig:
mysql_user: str = "root"
mysql_password: str = ""
mysql_database: str = ""
backend_port: str = "8080"
user_frontend_port: str = "8082"
admin_frontend_port: str = "8081"
output_dir: Path | None = None
tool_paths: Mapping[str, str | Path] | None = None
tool_search_roots: Iterable[Path] | None = None
@dataclass(frozen=True)
class DeploymentStep:
label: str
command: list[str]
cwd: Path
description: str
kind: str = "command"
input_path: Path | None = None
output_path: Path | None = None
source_path: Path | None = None
background: bool = False
service_name: str | None = None
port: int | None = None
url: str | None = None
OutputHandler = Callable[[str], None]
ProcessRunner = Callable[[list[str], Path, OutputHandler, Path | None, bool], int]
def build_deployment_plan(scan: ScanResult, config: DeploymentConfig) -> list[DeploymentStep]:
steps: list[DeploymentStep] = []
output_dir = config.output_dir or scan.root / "deploy"
if scan.backend:
backend_dir = scan.backend.path
existing_jar = _find_existing_backend_jar(backend_dir)
if existing_jar:
steps.append(
DeploymentStep(
"跳过后端构建",
[],
backend_dir,
f"检测到已有后端 jar跳过构建: {existing_jar}",
kind="backend_build_skip",
)
)
else:
build_file_names = {path.name for path in backend_dir.iterdir() if path.is_file()} if backend_dir.exists() else set()
if "pom.xml" in build_file_names:
command = [_tool_command(config, "Maven", "mvn"), "clean", "package", "-DskipTests"]
elif "gradlew.bat" in build_file_names:
command = ["gradlew.bat", "bootJar"]
elif "gradlew" in build_file_names:
command = ["./gradlew", "bootJar"]
elif backend_dir.exists():
command = [_tool_command(config, "Gradle", "gradle"), "bootJar"]
else:
command = [_tool_command(config, "Maven", "mvn"), "clean", "package", "-DskipTests"]
steps.append(DeploymentStep("构建后端", command, backend_dir, "打包 Spring Boot 应用", kind="backend_build"))
frontends = scan.detected_frontends
for frontend in frontends:
display_name = frontend.metadata.get("display_name", frontend.path.name)
npm_command = _tool_command(config, "npm", _default_npm_command())
if not _frontend_dependencies_installed(frontend.path):
steps.append(
DeploymentStep(
f"安装{display_name}依赖",
[npm_command, "install"],
frontend.path,
f"安装{display_name} package.json 中的依赖",
kind="frontend_install",
)
)
if scan.sql_files and config.mysql_database:
for sql_file in scan.sql_files:
command = [_tool_command(config, "MySQL", "mysql"), "-u", config.mysql_user]
if config.mysql_password:
command.append(f"-p{config.mysql_password}")
if not _sql_file_selects_database(sql_file):
command.append(config.mysql_database)
steps.append(
DeploymentStep(
"导入 SQL",
command,
scan.root,
f"导入 {sql_file.name}",
kind="sql_import",
input_path=sql_file,
)
)
if scan.backend:
backend_context = scan.backend.metadata.get("context_path", "")
backend_url = f"http://localhost:{config.backend_port}{backend_context}"
steps.append(
DeploymentStep(
"启动后端",
[_tool_command(config, "JDK", "java"), f"-Dserver.port={config.backend_port}", "-jar", "latest-built-jar"],
output_dir,
"启动 Spring Boot 服务",
kind="backend_start",
source_path=scan.backend.path,
background=True,
service_name="后端",
port=_validate_port(config.backend_port, "后端端口"),
url=backend_url,
)
)
frontend_urls: list[tuple[str, str]] = []
allocated_ports: dict[int, str] = {}
if scan.backend:
allocated_ports[_validate_port(config.backend_port, "后端端口")] = "后端"
for index, frontend in enumerate(frontends):
display_name = frontend.metadata.get("display_name", frontend.path.name)
port = _frontend_port(frontend, config, index)
if port in allocated_ports:
raise ValueError(f"{display_name}端口 {port}{allocated_ports[port]}端口冲突")
allocated_ports[port] = display_name
start_script = frontend.metadata.get("start_script") or _frontend_start_script(frontend.path)
if not start_script:
raise ValueError(f"{display_name}缺少 serve、dev 或 start 启动脚本: {frontend.path / 'package.json'}")
url = f"http://localhost:{port}/"
role = frontend.metadata.get("role", f"frontend-{index + 1}")
steps.append(
DeploymentStep(
f"启动{display_name}",
[
_tool_command(config, "npm", _default_npm_command()),
"run",
start_script,
"--",
"--host",
"0.0.0.0",
"--port",
str(port),
],
frontend.path,
f"启动{display_name}开发服务",
kind="frontend_start",
output_path=output_dir / f"frontend-{role}.log",
background=True,
service_name=display_name,
port=port,
url=url,
)
)
frontend_urls.append((display_name, url))
addresses: list[tuple[str, str]] = []
if scan.backend:
addresses.append(("后端", backend_url))
addresses.extend(frontend_urls)
if addresses:
summary = "部署完成,访问地址:\n" + "\n".join(f" {name}: {url}" for name, url in addresses)
steps.append(DeploymentStep("访问地址", [], output_dir, summary, kind="deployment_summary"))
return steps
def _tool_command(config: DeploymentConfig, tool_name: str, fallback: str) -> str:
configured = config.tool_paths.get(tool_name) if config.tool_paths else None
if configured:
path = Path(configured)
if path.exists():
return str(path)
discovered = discover_executable(tool_name, config.tool_search_roots)
return str(discovered) if discovered else fallback
def _default_npm_command() -> str:
return "npm.cmd" if os.name == "nt" else "npm"
def _validate_port(value: str | int, label: str) -> int:
try:
port = int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{label}必须是 1 到 65535 之间的整数: {value}") from exc
if not 1 <= port <= 65535:
raise ValueError(f"{label}必须是 1 到 65535 之间的整数: {value}")
return port
def _frontend_port(frontend, config: DeploymentConfig, index: int) -> int:
role = frontend.metadata.get("role")
if role == "user":
return _validate_port(config.user_frontend_port, "用户端端口")
if role == "admin":
return _validate_port(config.admin_frontend_port, "管理端端口")
configured = frontend.metadata.get("port")
if configured:
return _validate_port(configured, f"{frontend.path.name} 端口")
return 8081 + index
def _frontend_start_script(frontend_dir: Path) -> str | None:
package_file = frontend_dir / "package.json"
try:
package = json.loads(package_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
scripts = package.get("scripts")
if not isinstance(scripts, dict):
return None
for name in ("serve", "dev", "start"):
if isinstance(scripts.get(name), str):
return name
return None
def _sql_file_selects_database(sql_file: Path) -> bool:
try:
text = sql_file.read_text(encoding="utf-8", errors="ignore")
except OSError:
return False
normalized = text.lower()
return "create database" in normalized and "use `" in normalized or "create database" in normalized and "use " in normalized
def resolve_process_command(command: list[str], path: str | None = None) -> list[str]:
if not command:
return command
executable = command[0]
if Path(executable).is_absolute() or any(separator in executable for separator in ("/", "\\")):
return command
resolved = shutil.which(executable, path=path)
if resolved is None and os.name == "nt":
for suffix in (".cmd", ".bat", ".exe"):
resolved = shutil.which(executable + suffix, path=path)
if resolved:
break
if resolved is None:
return command
return [resolved, *command[1:]]
def _extract_server_port(command: list[str]) -> int | None:
prefix = "-Dserver.port="
for part in command:
if part.startswith(prefix):
raw_port = part[len(prefix) :]
try:
port = int(raw_port)
except ValueError as exc:
raise ValueError(f"后端端口必须是 1 到 65535 之间的整数: {raw_port}") from exc
if not 1 <= port <= 65535:
raise ValueError(f"后端端口必须是 1 到 65535 之间的整数: {raw_port}")
return port
return None
def _parse_windows_listening_pids(output: str, port: int) -> set[int]:
pids: set[int] = set()
for line in output.splitlines():
parts = line.split()
if len(parts) < 5 or parts[0].upper() != "TCP" or parts[-2].upper() != "LISTENING":
continue
try:
local_port = int(parts[1].rsplit(":", 1)[1])
pid = int(parts[-1])
except (IndexError, ValueError):
continue
if local_port == port:
pids.add(pid)
return pids
def _find_listening_pids(port: int) -> set[int]:
if os.name != "nt":
raise RuntimeError("自动终止端口占用进程当前仅支持 Windows")
result = subprocess.run(
["netstat", "-ano", "-p", "tcp"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
errors="replace",
shell=False,
)
if result.returncode != 0:
message = result.stderr.strip() or f"netstat 退出码 {result.returncode}"
raise RuntimeError(f"无法检查端口占用: {message}")
return _parse_windows_listening_pids(result.stdout, port)
def _terminate_process_tree(pid: int) -> None:
result = subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
errors="replace",
shell=False,
)
if result.returncode != 0:
message = result.stdout.strip() or f"taskkill 退出码 {result.returncode}"
raise RuntimeError(f"无法终止进程 PID {pid}: {message}")
def _is_port_open(port: int) -> bool:
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
return True
except OSError:
return False
def _wait_until_port_closed(port: int, timeout: float = 10.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if not _find_listening_pids(port):
return True
time.sleep(0.1)
return not _find_listening_pids(port)
def _replace_port_listeners(
port: int,
on_output: OutputHandler,
*,
find_listeners=None,
terminate=None,
wait_until_closed=None,
) -> bool:
find_listeners = find_listeners or _find_listening_pids
terminate = terminate or _terminate_process_tree
wait_until_closed = wait_until_closed or _wait_until_port_closed
for pid in sorted(find_listeners(port)):
if pid not in find_listeners(port):
on_output(f"端口 {port} 的 PID {pid} 已退出,无需再次终止。")
continue
on_output(f"端口 {port} 已被 PID {pid} 占用,正在终止旧进程树...")
try:
terminate(pid)
except RuntimeError:
if pid in find_listeners(port):
raise
on_output(f"PID {pid} 在终止前已退出,继续检查端口 {port}")
return wait_until_closed(port)
def _restart_backend(
command: list[str],
cwd: Path,
on_output: OutputHandler,
*,
replace_listeners=None,
popen=None,
) -> int:
port = _extract_server_port(command)
if port is None:
raise ValueError("后端启动命令缺少 -Dserver.port 配置")
replace_listeners = replace_listeners or _replace_port_listeners
if not replace_listeners(port, on_output):
on_output(f"端口 {port} 未能在限定时间内释放,后端未启动。")
return 1
popen = popen or subprocess.Popen
log_path = cwd / "backend.log"
pid_path = cwd / "backend.pid"
try:
pid_path.unlink()
except FileNotFoundError:
pass
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
with log_path.open("a", encoding="utf-8") as log_file:
log_file.write(f"\n\n===== 后端部署 {started_at},端口 {port} =====\n")
log_file.flush()
if os.name == "nt":
console_runner = Path(__file__).with_name("service_console.py")
process = popen(
[
sys.executable,
str(console_runner),
"--title",
f"后端服务 - 端口 {port}",
"--log",
str(log_path),
"--pid-file",
str(pid_path),
"--",
*command,
],
cwd=cwd,
stdin=subprocess.DEVNULL,
shell=False,
creationflags=_backend_console_creation_flags(),
)
else:
log_file = log_path.open("a", encoding="utf-8")
try:
process = popen(
command,
cwd=cwd,
stdin=subprocess.DEVNULL,
stdout=log_file,
stderr=subprocess.STDOUT,
shell=False,
creationflags=0,
)
finally:
log_file.close()
deadline = time.monotonic() + 60.0
while time.monotonic() < deadline:
return_code = process.poll()
if return_code is not None:
on_output(f"后端进程 PID {process.pid} 在端口就绪前退出,退出码: {return_code},日志: {log_path}")
return return_code or 1
service_pid = _read_service_pid(pid_path) or process.pid
if service_pid in _find_listening_pids(port) and _is_port_open(port):
on_output(f"后端服务启动成功PID: {service_pid},端口: {port},日志: {log_path}")
return 0
time.sleep(0.2)
on_output(f"后端进程 PID {process.pid} 启动超时,端口 {port} 在 60 秒内未就绪,正在终止进程树。")
_terminate_process_tree(process.pid)
return 1
def _restart_frontend(
command: list[str],
cwd: Path,
on_output: OutputHandler,
*,
port: int,
service_name: str,
log_path: Path,
replace_listeners=None,
popen=None,
) -> int:
port = _validate_port(port, f"{service_name}端口")
replace_listeners = replace_listeners or _replace_port_listeners
if not replace_listeners(port, on_output):
on_output(f"端口 {port} 未能在限定时间内释放,{service_name}未启动。")
return 1
popen = popen or subprocess.Popen
log_path.parent.mkdir(parents=True, exist_ok=True)
with log_path.open("a", encoding="utf-8") as log_file:
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
log_file.write(f"\n\n===== {service_name}部署 {started_at},端口 {port} =====\n")
log_file.flush()
process = popen(
command,
cwd=cwd,
stdin=subprocess.DEVNULL,
stdout=log_file,
stderr=subprocess.STDOUT,
shell=False,
creationflags=_background_creation_flags(),
)
deadline = time.monotonic() + 120.0
while time.monotonic() < deadline:
if _is_port_open(port) and _find_listening_pids(port):
on_output(
f"{service_name}启动成功PID: {process.pid},端口: {port}"
f"访问地址: http://localhost:{port}/,日志: {log_path}"
)
return 0
return_code = process.poll()
if return_code is not None:
on_output(f"{service_name}进程 PID {process.pid} 在端口就绪前退出,退出码: {return_code},日志: {log_path}")
return return_code or 1
time.sleep(0.2)
on_output(f"{service_name}进程 PID {process.pid} 启动超时,端口 {port} 在 120 秒内未就绪,正在终止进程树。")
_terminate_process_tree(process.pid)
return 1
def _background_creation_flags() -> int:
if os.name != "nt":
return 0
return subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
def _backend_console_creation_flags() -> int:
if os.name != "nt":
return 0
return subprocess.CREATE_NEW_CONSOLE | subprocess.CREATE_NEW_PROCESS_GROUP
def _read_service_pid(pid_path: Path) -> int | None:
try:
return int(pid_path.read_text(encoding="ascii").strip())
except (OSError, ValueError):
return None
def run_steps(
steps: Iterable[DeploymentStep],
on_output: OutputHandler,
process_runner: ProcessRunner = None,
) -> int:
runner = process_runner or _run_process
for step in steps:
try:
command = _resolve_command(step)
command_text = " ".join(command)
on_output(f"\n[{step.label}]{' ' + command_text if command_text else ''}")
if step.input_path:
on_output(f"stdin: {step.input_path}")
if _is_skip_step(step):
on_output(step.description)
continue
if step.kind == "deployment_summary":
on_output(step.description)
continue
step.cwd.mkdir(parents=True, exist_ok=True)
if step.kind == "frontend_start" and process_runner is None:
if step.port is None or step.output_path is None:
raise ValueError(f"{step.label}缺少端口或日志路径配置")
return_code = _restart_frontend(
resolve_process_command(command),
step.cwd,
on_output,
port=step.port,
service_name=step.service_name or "前端",
log_path=step.output_path,
)
else:
return_code = runner(command, step.cwd, on_output, step.input_path, step.background)
except Exception as exc:
on_output(f"步骤失败: {exc}")
return 1
if return_code != 0:
on_output(f"步骤失败,退出码: {return_code}")
return return_code
return 0
def _resolve_command(step: DeploymentStep) -> list[str]:
if step.kind != "backend_start":
return step.command
backend_dir = step.source_path or step.cwd
jar_file = _find_latest_backend_jar(backend_dir)
return [part if part != "latest-built-jar" else str(jar_file) for part in step.command]
def _is_skip_step(step: DeploymentStep) -> bool:
return step.kind == "skip" or step.kind.endswith("_skip")
def _frontend_dependencies_installed(frontend_dir: Path) -> bool:
return (frontend_dir / "node_modules").is_dir()
def _find_latest_backend_jar(backend_dir: Path) -> Path:
candidates: list[Path] = []
for folder in (backend_dir / "target", backend_dir / "build" / "libs"):
if folder.is_dir():
candidates.extend(path for path in folder.glob("*.jar") if _is_runnable_jar(path))
if not candidates:
raise FileNotFoundError(f"未找到后端 jar 产物: {backend_dir / 'target'}{backend_dir / 'build' / 'libs'}")
return max(candidates, key=lambda path: (path.stat().st_mtime, path.name))
def _find_existing_backend_jar(backend_dir: Path) -> Path | None:
try:
return _find_latest_backend_jar(backend_dir)
except FileNotFoundError:
return None
def _is_runnable_jar(path: Path) -> bool:
name = path.name.lower()
excluded_suffixes = ("-sources.jar", "-javadoc.jar", "-plain.jar")
return not name.startswith("original-") and not name.endswith(excluded_suffixes)
def _run_process(
command: list[str],
cwd: Path,
on_output: OutputHandler,
input_path: Path | None = None,
background: bool = False,
) -> int:
command = resolve_process_command(command)
if background:
return _restart_backend(command, cwd, on_output)
input_file = input_path.open("r", encoding="utf-8", errors="ignore") if input_path else None
try:
process = subprocess.Popen(
command,
cwd=cwd,
stdin=input_file,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
shell=False,
)
assert process.stdout is not None
for line in process.stdout:
on_output(line.rstrip())
return process.wait()
finally:
if input_file:
input_file.close()

189
deploy_tool/environment.py Normal file
View File

@@ -0,0 +1,189 @@
from __future__ import annotations
import json
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable
CommandRunner = Callable[[list[str]], subprocess.CompletedProcess[str]]
DEFAULT_CHECKS: dict[str, list[str]] = {
"JDK": ["java", "-version"],
"Maven": ["mvn", "-version"],
"Gradle": ["gradle", "-version"],
"Node.js": ["node", "--version"],
"npm": ["npm", "--version"],
"Vue CLI": ["vue", "--version"],
"MySQL": ["mysql", "--version"],
"Git": ["git", "--version"],
}
EXECUTABLE_NAMES: dict[str, list[str]] = {
"JDK": ["java.exe", "java"],
"Maven": ["mvn.cmd", "mvn.bat", "mvn"],
"Gradle": ["gradle.bat", "gradle"],
"Node.js": ["node.exe", "node"],
"npm": ["npm.cmd", "npm"],
"Vue CLI": ["vue.cmd", "vue"],
"MySQL": ["mysql.exe", "mysql"],
"Git": ["git.exe", "git"],
}
CONFIG_DIR = Path(".deploy_tool_config")
TOOL_PATHS_FILE = CONFIG_DIR / "tool-paths.json"
@dataclass(frozen=True)
class EnvironmentCheck:
name: str
command: list[str]
installed: bool
version: str
message: str
source: str = "PATH"
def check_environment(
runner: CommandRunner | None = None,
checks: dict[str, list[str]] | None = None,
manual_paths: dict[str, str | Path] | None = None,
search_roots: Iterable[Path] | None = None,
) -> list[EnvironmentCheck]:
command_runner = runner or _run_command
selected_checks = checks or DEFAULT_CHECKS
configured_paths = {name: Path(value) for name, value in (manual_paths or {}).items() if str(value).strip()}
roots = list(search_roots) if search_roots is not None else default_search_roots()
results: list[EnvironmentCheck] = []
for name, command in selected_checks.items():
path_result = _try_command(name, command, "PATH", command_runner)
if path_result.installed:
results.append(path_result)
continue
manual_path = configured_paths.get(name)
if manual_path and manual_path.exists():
manual_command = [str(manual_path), *command[1:]]
manual_result = _try_command(name, manual_command, "手动指定", command_runner)
if manual_result.installed:
results.append(manual_result)
continue
discovered = discover_executable(name, roots)
if discovered:
discovered_command = [str(discovered), *command[1:]]
discovered_result = _try_command(name, discovered_command, "自动发现", command_runner)
if discovered_result.installed:
results.append(discovered_result)
continue
results.append(path_result)
return results
def discover_executable(name: str, search_roots: Iterable[Path] | None = None) -> Path | None:
executable_names = {item.lower() for item in EXECUTABLE_NAMES.get(name, [])}
if not executable_names:
return None
for root in search_roots or default_search_roots():
if not root.exists() or not root.is_dir():
continue
for candidate in _direct_candidates(name, root):
if candidate.exists() and candidate.name.lower() in executable_names:
return candidate
return None
def default_search_roots() -> list[Path]:
roots = [
Path(os.environ.get("ProgramFiles", r"C:\Program Files")),
Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")),
Path(os.environ.get("LOCALAPPDATA", "")),
Path(r"C:\ProgramData"),
Path(r"C:\xampp"),
Path(r"C:\phpstudy_pro"),
Path(r"D:\Program Files"),
Path(r"D:\phpstudy_pro"),
]
return [root for root in roots if str(root)]
def load_tool_paths(config_file: Path = TOOL_PATHS_FILE) -> dict[str, Path]:
if not config_file.exists():
return {}
try:
data = json.loads(config_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
if not isinstance(data, dict):
return {}
return {str(name): Path(value) for name, value in data.items() if isinstance(value, str) and value.strip()}
def save_tool_paths(paths: dict[str, str | Path], config_file: Path = TOOL_PATHS_FILE) -> None:
config_file.parent.mkdir(parents=True, exist_ok=True)
data = {name: str(path) for name, path in paths.items() if str(path).strip()}
config_file.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def _try_command(name: str, command: list[str], source: str, runner: CommandRunner) -> EnvironmentCheck:
try:
completed = runner(command)
except FileNotFoundError:
return EnvironmentCheck(name, command, False, "", f"{name} 未安装或不在 PATH 中", source)
except subprocess.TimeoutExpired:
return EnvironmentCheck(name, command, False, "", f"{name} 检测超时", source)
except OSError as exc:
return EnvironmentCheck(name, command, False, "", f"{name} 检测失败: {exc}", source)
output = _combine_output(completed)
installed = completed.returncode == 0
message = "已安装" if installed else f"命令返回错误码 {completed.returncode}"
if installed and source != "PATH":
message = f"已安装,来源: {source}"
return EnvironmentCheck(name, command, installed, output, message, source)
def _run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(command, capture_output=True, text=True, timeout=6, shell=False)
def _combine_output(completed: subprocess.CompletedProcess[str]) -> str:
output = "\n".join(part.strip() for part in (completed.stdout, completed.stderr) if part and part.strip())
return output.splitlines()[0] if output else ""
def _direct_candidates(name: str, root: Path) -> list[Path]:
if name == "MySQL":
return [
root / "MySQL" / "MySQL Server 8.4" / "bin" / "mysql.exe",
root / "MySQL" / "MySQL Server 8.0" / "bin" / "mysql.exe",
root / "mysql" / "bin" / "mysql.exe",
root / "Extensions" / "MySQL" / "bin" / "mysql.exe",
]
if name == "JDK":
return _glob(root, "Java/*/bin/java.exe") + _glob(root, "Eclipse Adoptium/*/bin/java.exe")
if name == "Node.js":
return [root / "nodejs" / "node.exe", root / "Programs" / "nodejs" / "node.exe"]
if name == "npm":
return [root / "nodejs" / "npm.cmd", root / "Programs" / "nodejs" / "npm.cmd"]
if name == "Maven":
return _glob(root, "apache-maven*/bin/mvn.cmd")
if name == "Gradle":
return _glob(root, "gradle*/bin/gradle.bat")
if name == "Git":
return [root / "Git" / "bin" / "git.exe", root / "Git" / "cmd" / "git.exe"]
return []
def _glob(root: Path, pattern: str) -> list[Path]:
try:
return list(root.glob(pattern))
except OSError:
return []

554
deploy_tool/gui.py Normal file
View File

@@ -0,0 +1,554 @@
from __future__ import annotations
import queue
import threading
import tkinter as tk
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from .deployer import DeploymentConfig, build_deployment_plan, run_steps
from .environment import EnvironmentCheck, check_environment, load_tool_paths, save_tool_paths
from .installer import build_install_command, recommend_jdk_version, run_install_command
from .path_manager import apply_user_environment, build_environment_update
from .scanner import ScanResult, scan_project
TEXT = {
"title": "\u0053\u0070\u0072\u0069\u006e\u0067\u0020\u0042\u006f\u006f\u0074\u0020\u002b\u0020\u0056\u0075\u0065\u0020\u81ea\u52a8\u5316\u90e8\u7f72\u5de5\u5177",
"workbench": "\u90e8\u7f72\u5de5\u4f5c\u53f0",
"select_dir": "\u9009\u62e9\u76ee\u5f55",
"scan": "\u626b\u63cf\u9879\u76ee",
"check_env": "\u68c0\u6d4b\u73af\u5883",
"deploy": "\u6267\u884c\u90e8\u7f72",
"results": "\u9879\u76ee\u4e0e\u73af\u5883\u7ed3\u679c",
"config": "\u90e8\u7f72\u914d\u7f6e",
"tool_paths": "\u5de5\u5177\u8def\u5f84\uff08\u53ef\u9009\uff09",
"logs": "\u8fd0\u884c\u65e5\u5fd7",
}
SIDEBAR_ITEMS = ("项目识别", "环境检测", "工具路径", "数据库", "部署任务", "运行日志")
def database_name_from_sql_files(sql_files: list[Path]) -> str:
return sql_files[0].stem if sql_files else ""
class DeployWorkbench(tk.Tk):
def __init__(self) -> None:
super().__init__()
self.title(TEXT["title"])
self.geometry("1260x820")
self.minsize(1080, 700)
self.project_dir = tk.StringVar()
self.backend_port = tk.StringVar(value="8080")
self.user_frontend_port = tk.StringVar(value="8082")
self.admin_frontend_port = tk.StringVar(value="8081")
self.mysql_user = tk.StringVar(value="root")
self.mysql_password = tk.StringVar()
self.mysql_database = tk.StringVar()
self.output_dir = tk.StringVar()
self.jdk_version = tk.StringVar(value="8")
self.jdk_reason = tk.StringVar(value="默认推荐 JDK 8扫描项目后会自动调整。")
self.tool_paths = load_tool_paths()
self.tool_path_vars: dict[str, tk.StringVar] = {
name: tk.StringVar(value=str(self.tool_paths.get(name, "")))
for name in ("MySQL", "JDK", "Maven", "Gradle", "Node.js", "npm", "Git")
}
self.scan_result: ScanResult | None = None
self.environment_results: list[EnvironmentCheck] = []
self.ui_queue: queue.Queue[tuple[str, object]] = queue.Queue()
self.sidebar_buttons: dict[str, tk.Button] = {}
self.tool_path_entries: dict[str, ttk.Entry] = {}
self.deployment_running = False
self.deploy_buttons: list[ttk.Button] = []
self._configure_style()
self._build_layout()
self.after(120, self._drain_queue)
def _configure_style(self) -> None:
self.configure(bg="#f5f7fb")
self.style = ttk.Style(self)
self.style.theme_use("clam")
self.style.configure("TFrame", background="#f5f7fb")
self.style.configure("Sidebar.TFrame", background="#172033")
self.style.configure("Panel.TFrame", background="#ffffff", relief="flat")
self.style.configure("TLabel", background="#f5f7fb", foreground="#243043", font=("Microsoft YaHei UI", 10))
self.style.configure("Sidebar.TLabel", background="#172033", foreground="#dce6f7", font=("Microsoft YaHei UI", 10))
self.style.configure("Title.TLabel", background="#f5f7fb", foreground="#172033", font=("Microsoft YaHei UI", 18, "bold"))
self.style.configure("CardTitle.TLabel", background="#ffffff", foreground="#172033", font=("Microsoft YaHei UI", 11, "bold"))
self.style.configure("CardValue.TLabel", background="#ffffff", foreground="#2563eb", font=("Microsoft YaHei UI", 12, "bold"))
self.style.configure("Muted.TLabel", background="#ffffff", foreground="#667085", font=("Microsoft YaHei UI", 9))
self.style.configure("Primary.TButton", font=("Microsoft YaHei UI", 10, "bold"), padding=(14, 8))
self.style.configure("TButton", font=("Microsoft YaHei UI", 10), padding=(10, 6))
self.style.configure("TEntry", padding=6)
def _build_layout(self) -> None:
shell = ttk.Frame(self)
shell.pack(fill=tk.BOTH, expand=True)
sidebar = ttk.Frame(shell, style="Sidebar.TFrame", width=190)
sidebar.pack(side=tk.LEFT, fill=tk.Y)
sidebar.pack_propagate(False)
ttk.Label(sidebar, text=TEXT["workbench"], style="Sidebar.TLabel", font=("Microsoft YaHei UI", 16, "bold")).pack(
anchor=tk.W, padx=22, pady=(26, 22)
)
for item in SIDEBAR_ITEMS:
button = tk.Button(
sidebar,
text=item,
command=lambda name=item: self.activate_sidebar_item(name),
anchor=tk.W,
relief=tk.FLAT,
bd=0,
cursor="hand2",
bg="#172033",
activebackground="#22314d",
fg="#dce6f7",
activeforeground="#ffffff",
font=("Microsoft YaHei UI", 10),
padx=20,
pady=8,
)
button.pack(fill=tk.X, padx=6, pady=1)
self.sidebar_buttons[item] = button
content = ttk.Frame(shell)
content.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=24, pady=22)
self._build_header(content)
self._build_dashboard(content)
self._build_middle(content)
self._build_logs(content)
def _build_header(self, parent: ttk.Frame) -> None:
ttk.Label(parent, text="Spring Boot + Vue 自动化部署", style="Title.TLabel").pack(anchor=tk.W)
action_bar = ttk.Frame(parent)
action_bar.pack(fill=tk.X, pady=(14, 16))
self.project_dir_entry = ttk.Entry(action_bar, textvariable=self.project_dir)
self.project_dir_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 10))
ttk.Button(action_bar, text=TEXT["select_dir"], command=self.choose_folder).pack(side=tk.LEFT, padx=4)
ttk.Button(action_bar, text=TEXT["scan"], style="Primary.TButton", command=self.scan_folder).pack(side=tk.LEFT, padx=4)
ttk.Button(action_bar, text=TEXT["check_env"], command=self.check_environment_async).pack(side=tk.LEFT, padx=4)
ttk.Button(action_bar, text="\u5b89\u88c5\u73af\u5883", style="Primary.TButton", command=self.show_environment_installer).pack(side=tk.LEFT, padx=4)
self.deploy_button = ttk.Button(
action_bar, text=TEXT["deploy"], style="Primary.TButton", command=self.run_deploy_plan_async
)
self.deploy_button.pack(side=tk.LEFT, padx=4)
self.deploy_buttons.append(self.deploy_button)
def _build_dashboard(self, parent: ttk.Frame) -> None:
cards = ttk.Frame(parent)
cards.pack(fill=tk.X, pady=(0, 16))
self.card_labels: dict[str, ttk.Label] = {}
for key, title in (("backend", "后端"), ("frontend", "前端"), ("sql", "SQL 文件"), ("env", "环境")):
card = ttk.Frame(cards, style="Panel.TFrame", padding=16)
card.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 12))
ttk.Label(card, text=title, style="CardTitle.TLabel").pack(anchor=tk.W)
value = ttk.Label(card, text="未扫描", style="CardValue.TLabel")
value.pack(anchor=tk.W, pady=(9, 3))
ttk.Label(card, text="等待操作", style="Muted.TLabel").pack(anchor=tk.W)
self.card_labels[key] = value
def _build_middle(self, parent: ttk.Frame) -> None:
middle = ttk.Frame(parent)
middle.pack(fill=tk.BOTH, expand=True)
left = ttk.Frame(middle, style="Panel.TFrame", padding=16)
left.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 14))
ttk.Label(left, text=TEXT["results"], style="CardTitle.TLabel").pack(anchor=tk.W)
self.result_text = tk.Text(left, height=16, wrap=tk.WORD, relief=tk.FLAT, bg="#ffffff", fg="#243043", font=("Consolas", 10))
self.result_text.pack(fill=tk.BOTH, expand=True, pady=(10, 0))
right = ttk.Frame(middle, style="Panel.TFrame", padding=16, width=420)
right.pack(side=tk.LEFT, fill=tk.Y)
right.pack_propagate(False)
ttk.Label(right, text=TEXT["config"], style="CardTitle.TLabel").pack(anchor=tk.W)
self.mysql_user_widget = self._add_field(right, "MySQL 用户", self.mysql_user)
self.mysql_password_widget = self._add_field(right, "MySQL 密码", self.mysql_password, show="*")
self.database_widget = self._add_field(right, "数据库名", self.mysql_database)
ttk.Label(
right,
text="服务端口使用默认值8080 / 8082 / 8081",
style="Muted.TLabel",
).pack(anchor=tk.W, pady=(12, 0))
def _build_jdk_install_section(self, parent: ttk.Frame) -> None:
ttk.Label(parent, text="JDK 推荐与安装", style="CardTitle.TLabel").pack(anchor=tk.W)
row = ttk.Frame(parent, style="Panel.TFrame")
row.pack(fill=tk.X, pady=(8, 4))
ttk.Label(row, text="版本", style="Muted.TLabel").pack(side=tk.LEFT, padx=(0, 8))
ttk.Combobox(row, textvariable=self.jdk_version, values=("8", "11", "17", "21"), state="readonly", width=8).pack(side=tk.LEFT)
ttk.Button(row, text="安装 JDK", command=self.install_jdk_async).pack(side=tk.LEFT, padx=(10, 0))
ttk.Button(row, text="查看命令", command=self.preview_jdk_install_command).pack(side=tk.LEFT, padx=(6, 0))
ttk.Button(row, text="加入 PATH", command=lambda: self.add_tool_to_path("JDK")).pack(side=tk.LEFT, padx=(6, 0))
ttk.Label(parent, textvariable=self.jdk_reason, style="Muted.TLabel", wraplength=360).pack(anchor=tk.W, pady=(2, 0))
def _build_logs(self, parent: ttk.Frame) -> None:
log_panel = ttk.Frame(parent, style="Panel.TFrame", padding=14)
log_panel.pack(fill=tk.BOTH, pady=(16, 0))
ttk.Label(log_panel, text=TEXT["logs"], style="CardTitle.TLabel").pack(anchor=tk.W)
self.log_text = tk.Text(log_panel, height=9, wrap=tk.WORD, relief=tk.FLAT, bg="#111827", fg="#d1fadf", insertbackground="#d1fadf", font=("Consolas", 10))
self.log_text.pack(fill=tk.BOTH, expand=True, pady=(10, 0))
def _add_field(self, parent: ttk.Frame, label: str, variable: tk.StringVar, show: str | None = None) -> ttk.Entry:
ttk.Label(parent, text=label, style="Muted.TLabel").pack(anchor=tk.W, pady=(10, 4))
entry = ttk.Entry(parent, textvariable=variable, show=show)
entry.pack(fill=tk.X)
return entry
def _add_tool_path_row(self, parent: ttk.Frame, name: str) -> None:
ttk.Label(parent, text=name, style="Muted.TLabel").pack(anchor=tk.W, pady=(8, 3))
row = ttk.Frame(parent, style="Panel.TFrame")
row.pack(fill=tk.X)
entry = ttk.Entry(row, textvariable=self.tool_path_vars[name])
entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 6))
self.tool_path_entries[name] = entry
ttk.Button(row, text="选择", command=lambda tool=name: self.choose_tool_path(tool)).pack(side=tk.LEFT)
if name not in {"JDK", "npm"}:
ttk.Button(row, text="安装", command=lambda tool=name: self.install_tool_async(tool)).pack(side=tk.LEFT, padx=(6, 0))
ttk.Button(row, text="PATH", command=lambda tool=name: self.add_tool_to_path(tool)).pack(side=tk.LEFT, padx=(6, 0))
def show_environment_installer(self) -> None:
dialog = tk.Toplevel(self)
dialog.title("\u5b89\u88c5\u73af\u5883")
dialog.geometry("680x420")
dialog.minsize(620, 360)
dialog.configure(bg="#f5f7fb")
dialog.transient(self)
panel = ttk.Frame(dialog, style="Panel.TFrame", padding=18)
panel.pack(fill=tk.BOTH, expand=True, padx=16, pady=16)
ttk.Label(panel, text="\u5b89\u88c5\u73af\u5883", style="CardTitle.TLabel").pack(anchor=tk.W)
ttk.Label(
panel,
text="\u5b89\u88c5\u524d\u4f1a\u5f39\u7a97\u786e\u8ba4 winget \u547d\u4ee4\uff0c\u5b89\u88c5\u5b8c\u6210\u540e\u4f1a\u81ea\u52a8\u91cd\u65b0\u68c0\u6d4b\u73af\u5883\u3002",
style="Muted.TLabel",
).pack(anchor=tk.W, pady=(4, 12))
jdk_row = ttk.Frame(panel, style="Panel.TFrame")
jdk_row.pack(fill=tk.X, pady=5)
ttk.Label(jdk_row, text="JDK", width=12, style="CardTitle.TLabel").pack(side=tk.LEFT)
ttk.Combobox(jdk_row, textvariable=self.jdk_version, values=("8", "11", "17", "21"), state="readonly", width=8).pack(side=tk.LEFT)
ttk.Button(jdk_row, text="\u5b89\u88c5", command=self.install_jdk_async).pack(side=tk.LEFT, padx=(8, 0))
ttk.Button(jdk_row, text="\u52a0\u5165 PATH", command=lambda: self.add_tool_to_path("JDK")).pack(side=tk.LEFT, padx=(8, 0))
ttk.Label(jdk_row, textvariable=self.jdk_reason, style="Muted.TLabel").pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(10, 0))
for name in ("Maven", "Gradle", "Node.js", "Git", "MySQL"):
row = ttk.Frame(panel, style="Panel.TFrame")
row.pack(fill=tk.X, pady=5)
ttk.Label(row, text=name, width=12, style="CardTitle.TLabel").pack(side=tk.LEFT)
ttk.Button(row, text="\u5b89\u88c5", command=lambda tool=name: self.install_tool_async(tool)).pack(side=tk.LEFT)
ttk.Button(row, text="\u9009\u62e9\u8def\u5f84", command=lambda tool=name: self.choose_tool_path(tool)).pack(side=tk.LEFT, padx=(8, 0))
ttk.Button(row, text="\u52a0\u5165 PATH", command=lambda tool=name: self.add_tool_to_path(tool)).pack(side=tk.LEFT, padx=(8, 0))
ttk.Separator(panel).pack(fill=tk.X, pady=14)
ttk.Button(panel, text="\u68c0\u6d4b\u73af\u5883", command=self.check_environment_async).pack(side=tk.LEFT)
ttk.Button(panel, text="\u5173\u95ed", command=dialog.destroy).pack(side=tk.RIGHT)
def activate_sidebar_item(self, item: str) -> None:
for name, button in self.__dict__.get("sidebar_buttons", {}).items():
button.configure(bg="#22314d" if name == item else "#172033", fg="#ffffff" if name == item else "#dce6f7")
if item == "项目识别":
self._focus_widget(self.__dict__.get("project_dir_entry"))
elif item == "环境检测":
self.check_environment_async()
elif item == "工具路径":
entries = self.__dict__.get("tool_path_entries", {})
first_entry = next(iter(entries.values()), None)
self._focus_widget(first_entry)
elif item == "数据库":
self._focus_widget(self.__dict__.get("database_widget"))
elif item == "部署任务":
self._focus_widget(self.__dict__.get("deploy_button"))
elif item == "运行日志":
log_text = self.__dict__.get("log_text")
self._focus_widget(log_text)
if log_text is not None:
log_text.see(tk.END)
def _focus_widget(self, widget: object | None) -> None:
if widget is not None:
widget.focus_set()
def choose_folder(self) -> None:
folder = filedialog.askdirectory(title="选择前后端项目根目录")
if folder:
self.project_dir.set(folder)
if not self.output_dir.get():
self.output_dir.set(str(Path(folder) / "deploy"))
def choose_output_dir(self) -> None:
folder = filedialog.askdirectory(title="选择部署输出目录")
if folder:
self.output_dir.set(folder)
def choose_tool_path(self, name: str) -> None:
file_path = filedialog.askopenfilename(title=f"选择 {name} 可执行文件", filetypes=(("可执行文件", "*.exe *.cmd *.bat"), ("所有文件", "*.*")))
if file_path:
self.tool_path_vars[name].set(file_path)
self.save_tool_path_settings(silent=True)
def save_tool_path_settings(self, silent: bool = False) -> None:
paths = {name: value.get().strip() for name, value in self.tool_path_vars.items() if value.get().strip()}
save_tool_paths(paths)
self.tool_paths = load_tool_paths()
if not silent:
self._append_log("工具路径已保存。")
def scan_folder(self) -> None:
if not self.project_dir.get().strip():
messagebox.showwarning("需要目录", "请先选择或输入项目文件夹。")
return
try:
self.scan_result = scan_project(self.project_dir.get().strip())
except Exception as exc:
messagebox.showerror("扫描失败", str(exc))
return
database_name = database_name_from_sql_files(self.scan_result.sql_files)
if database_name:
self.mysql_database.set(database_name)
self._append_log(f"已根据 SQL 文件自动填写数据库名: {database_name}")
self._apply_jdk_recommendation()
self._render_scan_result()
self._append_log("项目扫描完成。")
def _apply_jdk_recommendation(self) -> None:
recommendation = recommend_jdk_version(self.scan_result)
self.jdk_version.set(recommendation.version)
self.jdk_reason.set(recommendation.reason)
self._append_log(f"JDK 推荐: {recommendation.reason}")
def check_environment_async(self) -> None:
self.save_tool_path_settings(silent=True)
manual_paths = load_tool_paths()
self._append_log("开始检测本机环境,会同时检查 PATH、手动路径和常见安装目录...")
threading.Thread(target=lambda: self.ui_queue.put(("env", check_environment(manual_paths=manual_paths))), daemon=True).start()
def preview_jdk_install_command(self) -> None:
command = build_install_command("JDK", version=self.jdk_version.get())
self._append_log(f"JDK {self.jdk_version.get()} 安装命令: {' '.join(command)}")
def install_jdk_async(self) -> None:
command = build_install_command("JDK", version=self.jdk_version.get())
if not messagebox.askyesno(
"确认安装 JDK",
"即将通过 winget 安装 JDK。\n\n"
f"版本: JDK {self.jdk_version.get()}\n"
f"命令: {' '.join(command)}\n\n"
"安装可能需要管理员权限,并可能修改 PATH。是否继续",
):
return
self._append_log(f"开始安装 JDK {self.jdk_version.get()}...")
threading.Thread(target=lambda: self.ui_queue.put(("install_done", ("JDK", run_install_command(command, self._thread_log)))), daemon=True).start()
def install_tool_async(self, tool_name: str) -> None:
try:
command = build_install_command(tool_name)
except ValueError as exc:
messagebox.showwarning("暂不支持自动安装", str(exc))
return
if not messagebox.askyesno(
f"确认安装 {tool_name}",
f"即将通过 winget 安装 {tool_name}\n\n"
f"命令: {' '.join(command)}\n\n"
"安装可能需要管理员权限,并可能修改 PATH。是否继续",
):
return
self._append_log(f"开始安装 {tool_name}...")
threading.Thread(
target=lambda: self.ui_queue.put(("install_done", (tool_name, run_install_command(command, self._thread_log)))),
daemon=True,
).start()
def add_tool_to_path(self, tool_name: str) -> None:
result = self._find_environment_result(tool_name)
executable = Path(result.command[0]) if result and Path(result.command[0]).exists() else None
configured = self.tool_path_vars.get(tool_name)
if executable is None and configured and configured.get().strip() and Path(configured.get().strip()).exists():
executable = Path(configured.get().strip())
if executable is None:
messagebox.showwarning("未找到可执行文件", f"请先检测环境,或手动选择 {tool_name} 的可执行文件。")
return
update = build_environment_update(tool_name, executable)
env_text = "\n".join(f"{name}={value}" for name, value in update.env_vars.items()) or "无额外环境变量"
if not messagebox.askyesno(
"确认加入 PATH",
f"将写入当前用户环境变量,不修改系统级 PATH。\n\n"
f"工具: {tool_name}\n"
f"PATH 目录: {update.path_entries[0]}\n"
f"{env_text}\n\n"
"修改后新打开的终端会生效。是否继续?",
):
return
try:
apply_user_environment(update)
except Exception as exc:
messagebox.showerror("写入失败", str(exc))
return
self._append_log(f"{tool_name} 已加入当前用户 PATH。")
self.check_environment_async()
def add_all_available_tools_to_path(self) -> None:
available = [
item.name
for item in self.environment_results
if item.installed and Path(item.command[0]).exists() and item.source in {"手动指定", "自动发现"}
]
if not available:
messagebox.showinfo("没有可加入项", "请先检测环境,或手动指定工具路径。")
return
if not messagebox.askyesno("确认加入 PATH", f"将这些工具加入当前用户 PATH\n{', '.join(available)}\n\n是否继续?"):
return
for name in available:
result = self._find_environment_result(name)
if result:
apply_user_environment(build_environment_update(name, Path(result.command[0])))
self._append_log(f"{name} 已加入当前用户 PATH。")
self.check_environment_async()
def _find_environment_result(self, tool_name: str) -> EnvironmentCheck | None:
for item in self.environment_results:
if item.name == tool_name:
return item
return None
def _set_deployment_running(self, running: bool) -> None:
self.deployment_running = running
state = tk.DISABLED if running else tk.NORMAL
for button in self.deploy_buttons:
button.configure(state=state)
def _run_deployment_worker(self, plan) -> None:
exit_code = 1
try:
exit_code = run_steps(plan, self._thread_log)
except Exception as exc:
self.ui_queue.put(("log", f"部署线程异常: {exc}"))
finally:
self.ui_queue.put(("deploy_done", exit_code))
def run_deploy_plan_async(self) -> None:
if self.deployment_running:
self._append_log("部署任务正在执行,请等待当前任务结束。")
return
plan = self._current_plan()
if not plan:
return
if not messagebox.askyesno("确认执行", "将开始自动执行本地部署:构建前后端、复制前端产物、导入 SQL并启动后端、用户端和管理端服务。请确认继续。"):
return
self._set_deployment_running(True)
worker = threading.Thread(target=self._run_deployment_worker, args=(plan,), daemon=True)
try:
worker.start()
except Exception as exc:
self._set_deployment_running(False)
self._append_log(f"无法启动部署线程: {exc}")
def _current_plan(self):
if self.scan_result is None:
messagebox.showwarning("需要扫描", "请先扫描项目。")
return []
config = DeploymentConfig(
mysql_user=self.mysql_user.get().strip() or "root",
mysql_password=self.mysql_password.get(),
mysql_database=self.mysql_database.get().strip(),
backend_port="8080",
user_frontend_port="8082",
admin_frontend_port="8081",
output_dir=None,
tool_paths=load_tool_paths(),
)
return build_deployment_plan(self.scan_result, config)
def _render_scan_result(self) -> None:
assert self.scan_result is not None
scan = self.scan_result
self.card_labels["backend"].configure(text="已识别" if scan.backend else "未找到")
self.card_labels["frontend"].configure(text=f"{len(scan.detected_frontends)}" if scan.detected_frontends else "未找到")
self.card_labels["sql"].configure(text=f"{len(scan.sql_files)}")
lines = [f"根目录: {scan.root}", ""]
lines.append(f"后端: {scan.backend.path if scan.backend else '未识别'}")
if scan.backend:
lines.append(f" 依据: {scan.backend.detail}")
if scan.backend.metadata:
lines.append(" 版本线索:")
for key, value in scan.backend.metadata.items():
lines.append(f" {key}: {value}")
lines.append(f"前端: {len(scan.detected_frontends)}")
for frontend in scan.detected_frontends:
display_name = frontend.metadata.get("display_name", frontend.path.name)
port = frontend.metadata.get("port", "未配置")
lines.append(f" {display_name}: {frontend.path}")
lines.append(f" 端口: {port},依据: {frontend.detail}")
lines.append(f"JDK 推荐: JDK {self.jdk_version.get()} - {self.jdk_reason.get()}")
lines.append("SQL:")
lines.extend(f" - {path}" for path in scan.sql_files[:20])
if len(scan.sql_files) > 20:
lines.append(f" ... 还有 {len(scan.sql_files) - 20}")
self._set_result_text("\n".join(lines))
def _render_environment(self, results: list[EnvironmentCheck]) -> None:
self.environment_results = results
installed = sum(1 for item in results if item.installed)
self.card_labels["env"].configure(text=f"{installed}/{len(results)} 可用")
lines = ["环境检测结果:", ""]
for item in results:
status = "OK" if item.installed else "MISS"
lines.append(f"[{status}] {item.name} ({item.source}): {item.message}")
lines.append(f" 命令: {' '.join(item.command)}")
if item.version:
lines.append(f" 版本: {item.version}")
self._set_result_text("\n".join(lines))
self._append_log("环境检测完成。")
def _set_result_text(self, value: str) -> None:
self.result_text.configure(state=tk.NORMAL)
self.result_text.delete("1.0", tk.END)
self.result_text.insert(tk.END, value)
def _append_log(self, value: str) -> None:
self.log_text.configure(state=tk.NORMAL)
self.log_text.insert(tk.END, value + "\n")
self.log_text.see(tk.END)
def _thread_log(self, value: str) -> None:
self.ui_queue.put(("log", value))
def _drain_queue(self) -> None:
while True:
try:
event, payload = self.ui_queue.get_nowait()
except queue.Empty:
break
if event == "env":
self._render_environment(payload) # type: ignore[arg-type]
elif event == "log":
self._append_log(str(payload))
elif event == "deploy_done":
self._set_deployment_running(False)
self._append_log(f"部署任务结束,退出码: {payload}")
elif event == "install_done":
tool_name, exit_code = payload # type: ignore[misc]
self._append_log(f"{tool_name} 安装命令结束,退出码: {exit_code}")
self.check_environment_async()
self.after(1800, lambda name=tool_name: self._prompt_add_tool_after_install(name))
self.after(120, self._drain_queue)
def _prompt_add_tool_after_install(self, tool_name: str) -> None:
result = self._find_environment_result(tool_name)
if result and result.installed and Path(result.command[0]).exists():
extra = " 并设置 JAVA_HOME" if tool_name == "JDK" else ""
if messagebox.askyesno(f"{tool_name} 已安装", f"检测到 {tool_name} 可执行文件,是否加入当前用户 PATH{extra}"):
self.add_tool_to_path(tool_name)
def run_app() -> None:
app = DeployWorkbench()
app.mainloop()

177
deploy_tool/installer.py Normal file
View File

@@ -0,0 +1,177 @@
from __future__ import annotations
import re
import subprocess
from dataclasses import dataclass
from .scanner import ScanResult
JDK_PACKAGES = {
"8": "EclipseAdoptium.Temurin.8.JDK",
"11": "EclipseAdoptium.Temurin.11.JDK",
"17": "EclipseAdoptium.Temurin.17.JDK",
"21": "EclipseAdoptium.Temurin.21.JDK",
}
WINGET_PACKAGES = {
"Maven": "Apache.Maven",
"Gradle": "Gradle.Gradle",
"Node.js": "OpenJS.NodeJS.LTS",
"Git": "Git.Git",
"MySQL": "Oracle.MySQL",
}
ANSI_PATTERN = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
PERCENT_PATTERN = re.compile(r"(\d{1,3})\s*%")
PROGRESS_CHARS = "█▓▒░▏▎▍▌▋▊▉■□▪▫▬─━═|/-\\"
@dataclass(frozen=True)
class JdkRecommendation:
version: str
reason: str
def recommend_jdk_version(scan: ScanResult | None) -> JdkRecommendation:
if not scan or not scan.backend:
return JdkRecommendation("8", "\u672a\u626b\u63cf\u5230\u540e\u7aef\u7248\u672c\u4fe1\u606f\uff0c\u9ed8\u8ba4\u63a8\u8350 JDK 8\u3002")
metadata = scan.backend.metadata
java_version = metadata.get("java_version")
if java_version:
mapped = _map_java_version(java_version)
source = metadata.get("java_version_source", "\u9879\u76ee\u914d\u7f6e")
return JdkRecommendation(mapped, f"\u6839\u636e {source}={java_version} \u63a8\u8350 JDK {mapped}\u3002")
boot_version = metadata.get("spring_boot_version", "")
if boot_version.startswith("3."):
return JdkRecommendation("17", f"Spring Boot 3.x \u9700\u8981 Java 17+\uff0c\u68c0\u6d4b\u5230 Spring Boot {boot_version}\uff0c\u63a8\u8350 JDK 17\u3002")
if boot_version.startswith("2."):
return JdkRecommendation("8", f"\u68c0\u6d4b\u5230 Spring Boot {boot_version}\uff0c\u9ed8\u8ba4\u63a8\u8350\u517c\u5bb9\u6027\u66f4\u597d\u7684 JDK 8\u3002")
return JdkRecommendation("8", "\u672a\u53d1\u73b0\u660e\u786e Java \u7248\u672c\uff0c\u9ed8\u8ba4\u63a8\u8350 JDK 8\u3002")
def build_install_command(tool_name: str, version: str | None = None) -> list[str]:
if tool_name == "JDK":
package_id = JDK_PACKAGES.get(version or "8")
if not package_id:
raise ValueError(f"\u4e0d\u652f\u6301\u7684 JDK \u7248\u672c: {version}")
else:
package_id = WINGET_PACKAGES.get(tool_name)
if not package_id:
raise ValueError(f"\u4e0d\u652f\u6301\u81ea\u52a8\u5b89\u88c5: {tool_name}")
return ["winget", "install", "-e", "--id", package_id]
def run_install_command(command: list[str], on_output) -> int:
output_lines: list[str] = []
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
shell=False,
bufsize=0,
)
assert process.stdout is not None
last_line = ""
for raw_line in process.stdout:
for line in format_install_output(raw_line):
if line and line != last_line:
on_output(line)
output_lines.append(line)
last_line = line
exit_code = process.wait()
failure_hint = explain_install_failure(_tool_name_from_command(command), exit_code, output_lines)
if failure_hint:
on_output(failure_hint)
return exit_code
def explain_install_failure(tool_name: str | None, exit_code: int, output_lines: list[str]) -> str:
if exit_code == 0:
return ""
output_text = "\n".join(output_lines)
if tool_name == "Maven" and ("找不到与输入条件匹配的程序包" in output_text or "No package found" in output_text):
return (
"winget 当前源找不到 Maven 包。请手动下载 Apache Maven解压后在工具里选择 "
"apache-maven 的 bin\\mvn.cmd或把该 bin 目录加入 PATH。"
)
return ""
def format_install_output(raw: bytes) -> list[str]:
text = _decode_process_bytes(raw)
text = ANSI_PATTERN.sub("", text).replace("\b", "")
lines: list[str] = []
for part in re.split(r"[\r\n]+", text):
cleaned = _clean_install_line(part)
if cleaned:
lines.append(cleaned)
return lines
def _decode_process_bytes(raw: bytes) -> str:
for encoding in ("utf-8", "utf-16", "gb18030", "mbcs"):
try:
return raw.decode(encoding)
except (LookupError, UnicodeDecodeError):
continue
return raw.decode("utf-8", errors="replace")
def _clean_install_line(line: str) -> str:
text = line.strip()
if not text:
return ""
percent = PERCENT_PATTERN.search(text)
progress_only = _looks_like_progress_line(text)
if percent and progress_only:
value = min(int(percent.group(1)), 100)
return f"\u5b89\u88c5\u8fdb\u5ea6: {value}%"
if progress_only:
return ""
return text
def _looks_like_progress_line(text: str) -> bool:
if not text:
return False
progress_count = sum(1 for char in text if char in PROGRESS_CHARS)
percent = bool(PERCENT_PATTERN.search(text))
if percent and progress_count:
return True
visible = [char for char in text if not char.isspace()]
if not visible:
return False
return progress_count / len(visible) > 0.45
def _tool_name_from_command(command: list[str]) -> str | None:
package_ids = {package_id: name for name, package_id in WINGET_PACKAGES.items()}
package_ids.update({package_id: "JDK" for package_id in JDK_PACKAGES.values()})
for part in command:
name = package_ids.get(part)
if name:
return name
return None
def _map_java_version(version: str) -> str:
try:
major = int(version.split(".", 1)[0])
except ValueError:
return "8"
if major <= 8:
return "8"
if major <= 11:
return "11"
if major <= 17:
return "17"
return "21"

View File

@@ -0,0 +1,98 @@
from __future__ import annotations
import ctypes
import os
import sys
import winreg
from dataclasses import dataclass, field
from pathlib import Path
from typing import Protocol
@dataclass(frozen=True)
class EnvironmentUpdate:
path_entries: list[Path] = field(default_factory=list)
env_vars: dict[str, str] = field(default_factory=dict)
class RegistryLike(Protocol):
def get_value(self, name: str) -> str: ...
def set_value(self, name: str, value: str) -> None: ...
def broadcast_change(self) -> None: ...
class UserEnvironmentRegistry:
key_path = r"Environment"
def get_value(self, name: str) -> str:
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, self.key_path, 0, winreg.KEY_READ) as key:
value, _ = winreg.QueryValueEx(key, name)
return str(value)
except FileNotFoundError:
return ""
def set_value(self, name: str, value: str) -> None:
with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, self.key_path, 0, winreg.KEY_SET_VALUE) as key:
winreg.SetValueEx(key, name, 0, winreg.REG_EXPAND_SZ, value)
def broadcast_change(self) -> None:
if sys.platform != "win32":
return
hwnd_broadcast = 0xFFFF
wm_settingchange = 0x001A
smto_abortifhung = 0x0002
result = ctypes.c_ulong()
ctypes.windll.user32.SendMessageTimeoutW(
hwnd_broadcast,
wm_settingchange,
0,
"Environment",
smto_abortifhung,
5000,
ctypes.byref(result),
)
def merge_path_entries(current_path: str, entries: list[Path]) -> str:
parts = [part for part in current_path.split(os.pathsep) if part.strip()]
seen = {_normalize_path(part) for part in parts}
for entry in entries:
value = str(entry)
normalized = _normalize_path(value)
if normalized not in seen:
parts.append(value)
seen.add(normalized)
return os.pathsep.join(parts)
def build_environment_update(tool_name: str, executable: Path) -> EnvironmentUpdate:
exe = Path(executable)
bin_dir = exe.parent
env_vars: dict[str, str] = {}
if tool_name == "JDK":
env_vars["JAVA_HOME"] = str(bin_dir.parent)
elif tool_name == "Maven":
env_vars["MAVEN_HOME"] = str(bin_dir.parent)
elif tool_name == "Gradle":
env_vars["GRADLE_HOME"] = str(bin_dir.parent)
return EnvironmentUpdate(path_entries=[bin_dir], env_vars=env_vars)
def apply_user_environment(update: EnvironmentUpdate, registry: RegistryLike | None = None) -> None:
env_registry = registry or UserEnvironmentRegistry()
current_path = env_registry.get_value("Path")
env_registry.set_value("Path", merge_path_entries(current_path, update.path_entries))
for name, value in update.env_vars.items():
env_registry.set_value(name, value)
env_registry.broadcast_change()
def _normalize_path(value: str) -> str:
return os.path.normcase(os.path.normpath(os.path.expandvars(value.strip().strip('"'))))

260
deploy_tool/scanner.py Normal file
View File

@@ -0,0 +1,260 @@
from __future__ import annotations
import json
import os
import re
from collections.abc import Iterator
from dataclasses import dataclass, field
from pathlib import Path
IGNORED_DIRECTORIES = {"node_modules", "target", "build", "dist", ".git", ".idea", ".vscode"}
@dataclass(frozen=True)
class ProjectComponent:
kind: str
path: Path
detail: str
metadata: dict[str, str] = field(default_factory=dict)
@dataclass(frozen=True)
class ScanResult:
root: Path
backend: ProjectComponent | None
frontend: ProjectComponent | None
sql_files: list[Path]
frontends: list[ProjectComponent] = field(default_factory=list)
@property
def detected_frontends(self) -> list[ProjectComponent]:
"""Return every detected frontend while preserving the old single-frontend API."""
if self.frontends:
return self.frontends
return [self.frontend] if self.frontend else []
def scan_project(root: str | Path) -> ScanResult:
project_root = Path(root).resolve()
if not project_root.exists():
raise FileNotFoundError(f"目录不存在: {project_root}")
if not project_root.is_dir():
raise NotADirectoryError(f"不是文件夹: {project_root}")
backend = _find_backend(project_root)
frontends = _find_frontends(project_root)
frontend = frontends[0] if frontends else None
sql_files = list(_walk_project_files(project_root, suffix=".sql"))
return ScanResult(project_root, backend, frontend, sql_files, frontends)
def _find_backend(root: Path) -> ProjectComponent | None:
candidates: list[tuple[int, Path, str]] = []
for build_file in _walk_project_files(root, names={"pom.xml", "build.gradle", "build.gradle.kts"}):
project_dir = build_file.parent
score = 1
detail_parts = [build_file.name]
text = _read_text(build_file)
metadata = _extract_backend_metadata(text)
metadata.update(_extract_backend_runtime_metadata(project_dir))
if "spring-boot" in text or "org.springframework.boot" in text:
score += 3
detail_parts.append("Spring Boot dependency")
if java_version := metadata.get("java_version"):
detail_parts.append(f"Java {java_version}")
if boot_version := metadata.get("spring_boot_version"):
detail_parts.append(f"Spring Boot {boot_version}")
if (project_dir / "src" / "main" / "java").exists():
score += 2
detail_parts.append("src/main/java")
candidates.append((score, project_dir, ", ".join(detail_parts), metadata))
if not candidates:
return None
score, path, detail, metadata = sorted(candidates, key=lambda item: (-item[0], len(item[1].parts)))[0]
if score < 2:
return None
return ProjectComponent("Spring Boot", path, detail, metadata)
def _find_frontends(root: Path) -> list[ProjectComponent]:
candidates: list[tuple[int, Path, str, dict[str, str]]] = []
for package_file in _walk_project_files(root, names={"package.json"}):
project_dir = package_file.parent
score = 1
detail_parts = ["package.json"]
package = _load_json(package_file)
deps = {}
for key in ("dependencies", "devDependencies"):
value = package.get(key)
if isinstance(value, dict):
deps.update(value)
scripts = package.get("scripts") if isinstance(package.get("scripts"), dict) else {}
if "vue" in deps:
score += 3
detail_parts.append("Vue dependency")
if "build" in scripts:
score += 1
detail_parts.append("build script")
if any((project_dir / name).exists() for name in ("vue.config.js", "vite.config.js", "vite.config.ts")):
score += 1
detail_parts.append("Vue/Vite config")
metadata = _classify_frontend(project_dir)
for script_name in ("serve", "dev", "start"):
if isinstance(scripts.get(script_name), str):
metadata["start_script"] = script_name
break
configured_port = _extract_frontend_port(project_dir)
if configured_port:
metadata["port"] = configured_port
detail_parts.append(f"port {configured_port}")
candidates.append((score, project_dir, ", ".join(detail_parts), metadata))
if not candidates:
return []
detected = [
ProjectComponent("Vue", path, detail, metadata)
for score, path, detail, metadata in candidates
if score >= 3
]
role_order = {"user": 0, "admin": 1, "frontend": 2}
return sorted(
detected,
key=lambda item: (role_order.get(item.metadata.get("role", "frontend"), 2), len(item.path.parts), str(item.path)),
)
def _find_frontend(root: Path) -> ProjectComponent | None:
"""Compatibility helper for callers that still expect one frontend."""
frontends = _find_frontends(root)
return frontends[0] if frontends else None
def _classify_frontend(project_dir: Path) -> dict[str, str]:
name = project_dir.name.lower()
if any(token in name for token in ("manage", "manager", "admin", "backend", "backstage", "后台", "管理")):
return {"role": "admin", "display_name": "管理端"}
if any(token in name for token in ("client", "user", "portal", "front", "web", "用户", "前台")):
return {"role": "user", "display_name": "用户端"}
return {"role": "frontend", "display_name": project_dir.name}
def _extract_frontend_port(project_dir: Path) -> str | None:
for name in ("vue.config.js", "vite.config.js", "vite.config.ts"):
config_file = project_dir / name
if not config_file.is_file():
continue
match = re.search(r"\bport\s*:\s*['\"]?(\d{1,5})", _read_text(config_file), re.IGNORECASE)
if match and 1 <= int(match.group(1)) <= 65535:
return match.group(1)
return None
def _is_ignored(path: Path) -> bool:
return any(part in IGNORED_DIRECTORIES for part in path.parts)
def _walk_project_files(
root: Path,
*,
names: set[str] | None = None,
suffix: str | None = None,
) -> Iterator[Path]:
"""Yield matching files without descending into generated or dependency directories."""
for current_dir, directories, filenames in os.walk(root, topdown=True, onerror=lambda _error: None):
directories[:] = sorted(name for name in directories if name not in IGNORED_DIRECTORIES)
for filename in sorted(filenames):
if names is not None and filename not in names:
continue
if suffix is not None and not filename.lower().endswith(suffix.lower()):
continue
yield Path(current_dir) / filename
def _read_text(path: Path) -> str:
try:
return path.read_text(encoding="utf-8", errors="ignore").lower()
except OSError:
return ""
def _load_json(path: Path) -> dict:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def _extract_backend_metadata(text: str) -> dict[str, str]:
metadata: dict[str, str] = {}
java_patterns = [
r"<maven\.compiler\.source>\s*([^<\s]+)\s*</maven\.compiler\.source>",
r"<maven\.compiler\.target>\s*([^<\s]+)\s*</maven\.compiler\.target>",
r"<java\.version>\s*([^<\s]+)\s*</java\.version>",
r"sourceCompatibility\s*=\s*['\"]?([^'\"\s]+)",
r"targetCompatibility\s*=\s*['\"]?([^'\"\s]+)",
r"java\s*\{\s*sourceCompatibility\s*=\s*JavaVersion\.VERSION_([^'\"\s}]+)",
]
for pattern in java_patterns:
match = re.search(pattern, text, re.IGNORECASE | re.MULTILINE)
if match:
metadata["java_version"] = _normalize_java_version(match.group(1))
metadata["java_version_source"] = _java_source_label(pattern)
break
boot_patterns = [
r"<artifactId>\s*spring-boot-starter-parent\s*</artifactId>\s*<version>\s*([^<\s]+)\s*</version>",
r"org\.springframework\.boot['\"]?\s*:\s*spring-boot[^:'\"]*['\"]?\s*:\s*([^'\"\s]+)",
r"id\s+['\"]org\.springframework\.boot['\"]\s+version\s+['\"]([^'\"]+)['\"]",
]
for pattern in boot_patterns:
match = re.search(pattern, text, re.IGNORECASE | re.MULTILINE)
if match:
metadata["spring_boot_version"] = match.group(1)
break
return metadata
def _extract_backend_runtime_metadata(project_dir: Path) -> dict[str, str]:
resources = project_dir / "src" / "main" / "resources"
if not resources.is_dir():
return {}
for pattern in ("application*.properties", "application*.yml", "application*.yaml"):
for config_file in sorted(resources.glob(pattern)):
try:
text = config_file.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
match = re.search(
r"(?:server\.(?:servlet\.)?)?context-path\s*[:=]\s*['\"]?([^'\"\s#]+)",
text,
re.IGNORECASE,
)
if match:
context_path = match.group(1).strip()
if not context_path.startswith("/"):
context_path = "/" + context_path
return {"context_path": context_path}
return {}
def _normalize_java_version(value: str) -> str:
clean = value.strip().replace("version_", "").replace("_", ".").lower()
if clean.startswith("1."):
return clean.split(".", 1)[1]
return clean.split(".", 1)[0]
def _java_source_label(pattern: str) -> str:
if "maven\\.compiler\\.source" in pattern:
return "maven.compiler.source"
if "maven\\.compiler\\.target" in pattern:
return "maven.compiler.target"
if "java\\.version" in pattern:
return "java.version"
if "sourceCompatibility" in pattern:
return "sourceCompatibility"
if "targetCompatibility" in pattern:
return "targetCompatibility"
return "项目配置"

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
import argparse
import ctypes
import locale
import os
import subprocess
import sys
from pathlib import Path
def _set_console_title(title: str) -> None:
if os.name == "nt":
ctypes.windll.kernel32.SetConsoleTitleW(title)
def main() -> int:
parser = argparse.ArgumentParser(description="Run a service in a visible console and tee its output to a log file.")
parser.add_argument("--title", default="后台服务")
parser.add_argument("--log", required=True)
parser.add_argument("--pid-file", required=True)
parser.add_argument("command", nargs=argparse.REMAINDER)
args = parser.parse_args()
command = list(args.command)
if command and command[0] == "--":
command.pop(0)
if not command:
parser.error("missing service command after --")
_set_console_title(args.title)
log_path = Path(args.log)
pid_path = Path(args.pid_file)
log_path.parent.mkdir(parents=True, exist_ok=True)
pid_path.parent.mkdir(parents=True, exist_ok=True)
encoding = locale.getpreferredencoding(False) or "utf-8"
with log_path.open("a", encoding="utf-8") as log_file:
process = subprocess.Popen(
command,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding=encoding,
errors="replace",
bufsize=1,
shell=False,
)
pid_path.write_text(str(process.pid), encoding="ascii")
assert process.stdout is not None
try:
for line in process.stdout:
print(line, end="", flush=True)
log_file.write(line)
log_file.flush()
return process.wait()
except KeyboardInterrupt:
process.terminate()
return process.wait()
finally:
process.stdout.close()
try:
pid_path.unlink()
except FileNotFoundError:
pass
if __name__ == "__main__":
raise SystemExit(main())