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()