feat: 优化服务管理脚本 - 修复硬编码路径、中文乱码和依赖等待逻辑

- 修复 Windows 控制台中文编码乱码
- 新增 ExecutableFinder 动态查找 Node/Maven/npm 可执行文件路径
- 重构 ProcessManager.start 使用动态路径替代硬编码
- 修复 _start_all 依赖等待逻辑,避免误判未就绪服务
- restart 命令支持 all 参数(默认重启所有服务)
- clean 命令增强:Node 清理 node_modules/dist/.vite,Java 清理 target
- PID 严格验证:检查进程 cwd 是否匹配服务目录
- 进度条显示稳定:百分比变化 >= 10% 才刷新
- mini-program 补充 log_file 配置

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 12:52:58 +08:00
parent 646ab3d300
commit 86af064ca3
4 changed files with 1121 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
# 情绪博物馆 - 服务管理配置文件
# Emotion Museum - Service Management Configuration
services:
backend:
name: "后端服务"
dir: "backend-single"
type: "java"
port: 19089
health_check_path: "/actuator/health"
start_cmd: "mvn spring-boot:run"
build_cmd: "mvn clean package -DskipTests"
build_check: "target/emotion-single-1.0.0.jar"
lock_file: "pom.xml"
pid_file: ".pid"
log_file: "logs/emotion-single-local.log"
depends_on: []
env:
SPRING_PROFILES_ACTIVE: "local"
web:
name: "用户前端"
dir: "web"
type: "node"
port: 5173
health_check_path: "/"
start_cmd: "npm run dev"
install_cmd: "npm install"
install_check: "node_modules"
lock_file: "package-lock.json"
pid_file: ".pid"
depends_on: ["backend"]
web-admin:
name: "管理后台"
dir: "web-admin"
type: "node"
port: 5174
health_check_path: "/"
start_cmd: "npm run dev"
install_cmd: "npm install"
install_check: "node_modules"
lock_file: "package-lock.json"
pid_file: ".pid"
depends_on: ["backend"]
mini-program:
name: "小程序 H5"
dir: "mini-program"
type: "node"
port: 5175
health_check_path: "/"
start_cmd: "npm run dev:h5"
install_cmd: "npm install"
install_check: "node_modules"
lock_file: "package-lock.json"
pid_file: ".pid"
log_file: "logs/dev-h5.log"
depends_on: ["backend"]
+121
View File
@@ -0,0 +1,121 @@
"""
情绪博物馆 - 服务管理器 CLI 入口
Emotion Museum - Service Manager CLI Entry Point
用法:
python manage.py start [service|all] # 启动服务
python manage.py stop [service|all] # 停止服务
python manage.py restart service # 重启服务
python manage.py status # 查看状态
python manage.py logs service [--follow] [--lines N] # 查看日志
python manage.py info # 显示访问地址
python manage.py clean service # 清理构建产物
python manage.py setup service # 安装依赖
"""
import sys
import io
import argparse
from pathlib import Path
# Fix Windows console Chinese character encoding
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
from tools.service_manager import ServiceManager, PROJECT_ROOT
def main():
parser = argparse.ArgumentParser(
description="情绪博物馆 - 服务管理器",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python manage.py start 启动所有服务
python manage.py start backend 启动后端服务
python manage.py stop 停止所有服务
python manage.py stop web 停止用户前端
python manage.py restart web 重启用户前端
python manage.py status 查看所有服务状态
python manage.py logs backend --follow 实时查看后端日志
python manage.py logs web --lines 100 查看前端最近 100 行日志
python manage.py info 显示所有服务访问地址
python manage.py clean web 清理前端 node_modules
python manage.py setup web 安装前端依赖
""",
)
subparsers = parser.add_subparsers(dest="command", help="可用命令")
# start 命令
start_parser = subparsers.add_parser("start", help="启动服务")
start_parser.add_argument(
"service", nargs="?", default="all", help="服务名称 (默认: all)"
)
start_parser.add_argument(
"--skip-deps", action="store_true", help="跳过依赖检查和安装"
)
# stop 命令
stop_parser = subparsers.add_parser("stop", help="停止服务")
stop_parser.add_argument(
"service", nargs="?", default="all", help="服务名称 (默认: all)"
)
# restart 命令
restart_parser = subparsers.add_parser("restart", help="重启服务")
restart_parser.add_argument("service", nargs="?", default=None, help="服务名称 (不填则重启所有服务)")
# status 命令
subparsers.add_parser("status", help="查看所有服务状态")
# logs 命令
logs_parser = subparsers.add_parser("logs", help="查看服务日志")
logs_parser.add_argument("service", help="服务名称 (必填)")
logs_parser.add_argument("--follow", "-f", action="store_true", help="实时跟踪日志")
logs_parser.add_argument(
"--lines", "-n", type=int, default=50, help="显示行数 (默认: 50)"
)
# info 命令
subparsers.add_parser("info", help="显示服务访问地址")
# clean 命令
clean_parser = subparsers.add_parser("clean", help="清理构建产物")
clean_parser.add_argument("service", help="服务名称 (必填)")
# setup 命令
setup_parser = subparsers.add_parser("setup", help="安装依赖")
setup_parser.add_argument("service", help="服务名称 (必填)")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(0)
# 加载配置
config_path = PROJECT_ROOT / "manage.conf.yaml"
manager = ServiceManager(config_path)
# 执行命令
if args.command == "start":
manager.start(args.service, skip_deps=getattr(args, "skip_deps", False))
elif args.command == "stop":
manager.stop(args.service)
elif args.command == "restart":
manager.restart(args.service or "all")
elif args.command == "status":
manager.status()
elif args.command == "logs":
manager.logs(args.service, lines=args.lines, follow=args.follow)
elif args.command == "info":
manager.info()
elif args.command == "clean":
manager.clean(args.service)
elif args.command == "setup":
manager.setup(args.service)
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
psutil>=5.9.0
pyyaml>=6.0
requests>=2.28.0
colorama>=0.4.6
+937
View File
@@ -0,0 +1,937 @@
"""
情绪博物馆 - 服务管理器核心模块
Emotion Museum - Service Manager Core Module
提供跨平台服务管理功能:启动、停止、重启、状态检查、日志查看、健康检查
"""
import os
import sys
import time
import signal
import socket
import shutil
import subprocess
import platform
from pathlib import Path
from typing import Dict, List, Optional, Any
import yaml
import psutil
import requests
from colorama import init, Fore, Style
# 初始化 colorama(跨平台彩色输出)
init(autoreset=True)
class Colors:
"""终端颜色常量"""
INFO = f"{Fore.GREEN}[INFO]{Style.RESET_ALL}"
WARN = f"{Fore.YELLOW}[WARN]{Style.RESET_ALL}"
ERROR = f"{Fore.RED}[ERROR]{Style.RESET_ALL}"
SECTION = f"{Fore.BLUE}[SECTION]{Style.RESET_ALL}"
DEBUG = f"{Fore.CYAN}[DEBUG]{Style.RESET_ALL}"
class ServiceConfig:
"""单个服务的配置信息"""
def __init__(self, name: str, config: Dict[str, Any]):
self.key = name
self.name = config.get("name", name)
self.dir = config["dir"]
self.type = config["type"]
self.port = config["port"]
self.health_check_path = config.get("health_check_path", "/")
self.start_cmd = config["start_cmd"]
self.build_cmd = config.get("build_cmd")
self.build_check = config.get("build_check")
self.install_cmd = config.get("install_cmd")
self.install_check = config.get("install_check")
self.lock_file = config.get("lock_file")
self.pid_file = config.get("pid_file", ".pid")
self.log_file = config.get("log_file")
self.depends_on = config.get("depends_on", [])
self.env = config.get("env", {})
@property
def pid_path(self) -> Path:
"""PID 文件路径(在服务目录内)"""
return PROJECT_ROOT / self.dir / self.pid_file
@property
def work_dir(self) -> Path:
"""服务工作目录"""
return PROJECT_ROOT / self.dir
@property
def manage_log_path(self) -> Path:
"""管理服务日志路径"""
log_dir = self.work_dir / "logs"
return log_dir / f"manage-{self.key}.log"
class ConfigLoader:
"""配置文件加载与校验"""
REQUIRED_FIELDS = ["name", "dir", "type", "port", "start_cmd"]
VALID_TYPES = ["java", "node"]
@classmethod
def load(cls, config_path: Path) -> Dict[str, ServiceConfig]:
"""加载并校验配置文件"""
if not config_path.exists():
print(f"{Colors.ERROR} 配置文件不存在: {config_path}")
sys.exit(1)
with open(config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if not data or "services" not in data:
print(f"{Colors.ERROR} 配置文件格式错误: 缺少 'services' 字段")
sys.exit(1)
services = {}
for key, config in data["services"].items():
cls._validate(key, config)
services[key] = ServiceConfig(key, config)
return services
@classmethod
def _validate(cls, key: str, config: Dict[str, Any]):
"""校验单个服务配置"""
# 检查必填字段
for field in cls.REQUIRED_FIELDS:
if field not in config:
print(f"{Colors.ERROR} 配置校验失败: '{key}' 缺少必填字段 '{field}'")
sys.exit(1)
# 校验 type 字段
if config["type"] not in cls.VALID_TYPES:
print(
f"{Colors.ERROR} 配置校验失败: '{key}' 的 type 必须是 {cls.VALID_TYPES} 之一"
)
sys.exit(1)
# 校验 port 字段
if not isinstance(config["port"], int) or not (1 <= config["port"] <= 65535):
print(f"{Colors.ERROR} 配置校验失败: '{key}' 的 port 必须是 1-65535 的整数")
sys.exit(1)
class HealthChecker:
"""服务健康检查"""
@staticmethod
def check_port(port: int, timeout: float = 1.0) -> bool:
"""检查端口是否被监听(支持 IPv4 和 IPv6)"""
# 先尝试 IPv4
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(timeout)
result = s.connect_ex(("127.0.0.1", port))
if result == 0:
return True
except Exception:
pass
# 再尝试 IPv6
try:
with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as s:
s.settimeout(timeout)
result = s.connect_ex(("::1", port))
if result == 0:
return True
except Exception:
pass
return False
@staticmethod
def check_http(
url: str, service_config: ServiceConfig, timeout: float = 2.0
) -> bool:
"""HTTP 健康检查"""
try:
response = requests.get(url, timeout=timeout, allow_redirects=True)
if service_config.type == "java":
# Java 服务检查 /actuator/health 返回 status: UP
data = response.json()
return data.get("status") == "UP"
else:
# Node 前端服务检查 HTTP 状态码是否为 2xx
return 200 <= response.status_code < 300
except Exception:
return False
@classmethod
def wait_for_ready(
cls, config: ServiceConfig, timeout: int = 60, interval: int = 2
) -> bool:
"""等待服务就绪,返回是否成功"""
print(f"{Colors.INFO} 等待服务就绪...", end="", flush=True)
start_time = time.time()
last_displayed = -1 # Track last displayed percentage to reduce flicker
while time.time() - start_time < timeout:
# 先检查端口
if cls.check_port(config.port):
# 端口通了,再进行 HTTP 检查
url = f"http://127.0.0.1:{config.port}{config.health_check_path}"
if cls.check_http(url, config):
print(f"\r{Colors.INFO} 等待服务就绪... [OK]")
return True
# 显示进度(仅在百分比变化 >= 10% 时刷新)
elapsed = time.time() - start_time
progress = min(int((elapsed / timeout) * 100), 99)
if progress - last_displayed >= 10:
last_displayed = progress
bar_length = 10
filled = int(bar_length * progress / 100)
bar = "=" * filled + " " * (bar_length - filled)
print(
f"\r{Colors.INFO} 等待服务就绪... [{bar}] {progress}%",
end="",
flush=True,
)
time.sleep(interval)
print(f"\r{Colors.ERROR} 等待服务就绪... [TIMEOUT] 超时 ({timeout}秒)")
return False
class ExecutableFinder:
"""动态查找可执行文件路径"""
# Windows 常见安装路径(按优先级)
NODE_PATHS = [
r"C:\Program Files\nodejs",
r"C:\Program Files (x86)\nodejs",
os.path.expanduser(r"~\AppData\Roaming\npm"),
]
MAVEN_PATHS = [
r"C:\Program Files\apache-maven\bin",
r"C:\Program Files (x86)\apache-maven\bin",
]
@classmethod
def find_node(cls) -> Optional[str]:
"""查找 node 可执行文件路径"""
path = cls._find("node.exe", cls.NODE_PATHS)
if path:
return path
# Linux/macOS fallback
return cls._find("node", [])
@classmethod
def find_npm(cls) -> Optional[str]:
"""查找 npm 可执行文件路径"""
if platform.system() == "Windows":
for name in ["npm.cmd", "npm.bat", "npm.exe"]:
path = cls._find(name, cls.NODE_PATHS)
if path:
return path
return cls._find("npm", cls.NODE_PATHS)
@classmethod
def find_maven(cls) -> Optional[str]:
"""查找 maven 可执行文件路径"""
if platform.system() == "Windows":
for name in ["mvn.cmd", "mvn.bat", "mvn.exe"]:
path = cls._find(name, cls.MAVEN_PATHS)
if path:
return path
return cls._find("mvn", cls.MAVEN_PATHS)
@staticmethod
def _find(name: str, search_paths: List[str]) -> Optional[str]:
"""按顺序查找可执行文件"""
# 1. 尝试系统 PATH
path = shutil.which(name)
if path:
return path
# 2. 尝试常见安装路径
for base in search_paths:
candidate = os.path.join(base, name)
if os.path.isfile(candidate):
return candidate
return None
class DependencyManager:
"""依赖检测与安装"""
@staticmethod
def needs_update(config: ServiceConfig) -> bool:
"""检查是否需要更新依赖"""
if not config.lock_file:
return False
lock_path = config.work_dir / config.lock_file
if config.type == "java":
# Java: 比较 pom.xml 和构建产物
if not config.build_check:
return False
build_path = config.work_dir / config.build_check
if not build_path.exists():
return True
return lock_path.stat().st_mtime > build_path.stat().st_mtime
elif config.type == "node":
# Node: 比较 package-lock.json 和 node_modules
install_path = config.work_dir / (config.install_check or "node_modules")
if not install_path.exists():
return True
return lock_path.stat().st_mtime > install_path.stat().st_mtime
return False
@classmethod
def ensure_ready(cls, config: ServiceConfig) -> bool:
"""确保依赖已安装/构建"""
# 检查是否需要安装/构建
needs_install = False
needs_build = False
if config.type == "node":
install_path = config.work_dir / (config.install_check or "node_modules")
if not install_path.exists():
needs_install = True
elif cls.needs_update(config):
needs_install = True
elif config.type == "java":
if config.build_check:
build_path = config.work_dir / config.build_check
if not build_path.exists():
needs_build = True
elif cls.needs_update(config):
needs_build = True
# 执行安装
if needs_install and config.install_cmd:
print(f"{Colors.INFO} 安装依赖: {config.install_cmd}")
if not cls._run_command(config.install_cmd, config.work_dir):
print(f"{Colors.ERROR} 依赖安装失败")
return False
print(f"{Colors.INFO} 依赖安装完成")
# 执行构建
if needs_build and config.build_cmd:
print(f"{Colors.INFO} 构建项目: {config.build_cmd}")
if not cls._run_command(config.build_cmd, config.work_dir):
print(f"{Colors.ERROR} 项目构建失败")
return False
print(f"{Colors.INFO} 项目构建完成")
return True
@staticmethod
def _run_command(cmd: str, work_dir: Path) -> bool:
"""执行命令并返回是否成功"""
try:
result = subprocess.run(
cmd, shell=True, cwd=work_dir, capture_output=True, text=True
)
return result.returncode == 0
except Exception as e:
print(f"{Colors.ERROR} 命令执行失败: {e}")
return False
class ProcessManager:
"""跨平台进程管理"""
@staticmethod
def is_running(config: ServiceConfig) -> bool:
"""检查服务是否正在运行"""
pid = ProcessManager._read_pid(config)
if pid is None:
return False
if not psutil.pid_exists(pid):
return False
return ProcessManager._is_service_process(pid, config)
@staticmethod
def get_pid(config: ServiceConfig) -> Optional[int]:
"""获取服务 PID"""
pid = ProcessManager._read_pid(config)
if pid and ProcessManager._is_service_process(pid, config):
return pid
return None
@classmethod
def start(cls, config: ServiceConfig) -> Optional[int]:
"""启动服务(后台运行)"""
work_dir = config.work_dir
log_path = config.manage_log_path
# 确保日志目录存在
log_path.parent.mkdir(parents=True, exist_ok=True)
# 准备环境变量
env = os.environ.copy()
if config.env:
env.update(config.env)
# 动态查找可执行文件路径(Windows 兼容)
if platform.system() == "Windows":
node_exe = ExecutableFinder.find_node()
npm_cmd = ExecutableFinder.find_npm()
mvn_cmd = ExecutableFinder.find_maven()
# 将找到的路径添加到 PATH
extra_paths = []
if node_exe:
extra_paths.append(os.path.dirname(node_exe))
if npm_cmd:
npm_dir = os.path.dirname(npm_cmd)
if npm_dir not in extra_paths:
extra_paths.append(npm_dir)
if mvn_cmd:
mvn_dir = os.path.dirname(mvn_cmd)
if mvn_dir not in extra_paths:
extra_paths.append(mvn_dir)
current_path = env.get("PATH", "")
for p in extra_paths:
if p not in current_path:
current_path = p + os.pathsep + current_path
env["PATH"] = current_path
if npm_cmd:
env["npm_execpath"] = npm_cmd
# 构建启动命令
cmd = config.start_cmd
if config.type == "node" and node_exe:
vite_js = work_dir / "node_modules" / "vite" / "bin" / "vite.js"
if vite_js.exists():
# 替换 npm run dev 为直接调用 node vite
if cmd in ("npm run dev", "npm run dev:h5"):
cmd = f'"{node_exe}" "{vite_js}"'
elif config.type == "java" and mvn_cmd:
if cmd.startswith("mvn "):
cmd = cmd.replace("mvn ", f'"{mvn_cmd}" ', 1)
else:
cmd = config.start_cmd
# 打开日志文件
log_file = open(log_path, "a", encoding="utf-8")
# 根据平台创建进程组
if platform.system() == "Windows":
process = subprocess.Popen(
cmd,
shell=True,
cwd=work_dir,
env=env,
stdout=log_file,
stderr=subprocess.STDOUT,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
)
else:
process = subprocess.Popen(
cmd,
shell=True,
cwd=work_dir,
env=env,
stdout=log_file,
stderr=subprocess.STDOUT,
start_new_session=True,
)
# 保存 PID
cls._write_pid(config, process.pid)
log_file.close()
return process.pid
@classmethod
def stop(cls, config: ServiceConfig, timeout: int = 10) -> bool:
"""优雅停止服务"""
pid = cls._read_pid(config)
if pid is None:
print(f"{Colors.WARN} 未找到 {config.name} 的 PID 文件")
return cls._force_cleanup(config)
if not psutil.pid_exists(pid):
print(f"{Colors.WARN} {config.name} 进程不存在 (PID: {pid})")
cls._write_pid(config, None)
return True
print(f"{Colors.INFO} 停止 {config.name} (PID: {pid})...")
try:
process = psutil.Process(pid)
# 发送终止信号
if platform.system() == "Windows":
subprocess.run(["taskkill", "/PID", str(pid)], capture_output=True)
else:
process.terminate()
# 等待进程退出
for _ in range(timeout * 2):
if not psutil.pid_exists(pid):
print(f"{Colors.INFO} {config.name} 已停止")
cls._write_pid(config, None)
return True
time.sleep(0.5)
# 超时,强制杀死
print(f"{Colors.WARN} {config.name} 未响应,强制终止...")
if platform.system() == "Windows":
subprocess.run(
["taskkill", "/F", "/PID", str(pid)], capture_output=True
)
else:
# Windows 上 signal 没有 SIGKILL,用 SIGTERM 替代
process.kill()
time.sleep(1)
cls._write_pid(config, None)
return True
except psutil.NoSuchProcess:
cls._write_pid(config, None)
return True
except Exception as e:
print(f"{Colors.ERROR} 停止服务失败: {e}")
return False
@staticmethod
def _is_service_process(pid: int, config: ServiceConfig) -> bool:
"""验证 PID 是否属于该服务进程(检查 cwd 匹配)"""
try:
proc = psutil.Process(pid)
return str(config.work_dir).lower() in (proc.cwd() or "").lower()
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
return False
@staticmethod
def _read_pid(config: ServiceConfig) -> Optional[int]:
"""读取 PID 文件"""
pid_path = config.pid_path
if not pid_path.exists():
return None
try:
return int(pid_path.read_text().strip())
except (ValueError, OSError):
return None
@staticmethod
def _write_pid(config: ServiceConfig, pid: Optional[int]):
"""写入 PID 文件"""
pid_path = config.pid_path
if pid is None:
if pid_path.exists():
pid_path.unlink()
else:
pid_path.write_text(str(pid))
@staticmethod
def _force_cleanup(config: ServiceConfig) -> bool:
"""强制清理(通过端口查找进程)"""
try:
for conn in psutil.net_connections(kind="inet"):
laddr = conn.laddr
if isinstance(laddr, tuple) and len(laddr) >= 2:
port = laddr[1] # type: ignore
elif hasattr(laddr, "port"):
port = laddr.port # type: ignore
else:
continue
if port == config.port and conn.status == "LISTEN":
pid = conn.pid
if pid:
print(
f"{Colors.WARN} 发现占用端口 {config.port} 的进程 (PID: {pid}),强制终止..."
)
if platform.system() == "Windows":
subprocess.run(
["taskkill", "/F", "/PID", str(pid)],
capture_output=True,
)
else:
os.kill(pid, 9) # SIGKILL = 9
return True
except (psutil.AccessDenied, Exception):
pass
return True
class DependencyGraph:
"""服务依赖图(拓扑排序)"""
def __init__(self, services: Dict[str, ServiceConfig]):
self.services = services
self.graph = self._build_graph()
def _build_graph(self) -> Dict[str, List[str]]:
"""构建依赖图"""
graph = {}
for key, config in self.services.items():
graph[key] = config.depends_on.copy()
return graph
def get_start_order(self) -> List[str]:
"""获取启动顺序(拓扑排序)"""
return self._topological_sort()
def get_stop_order(self) -> List[str]:
"""获取停止顺序(反向拓扑排序)"""
return list(reversed(self.get_start_order()))
def _topological_sort(self) -> List[str]:
"""拓扑排序(Kahn 算法)
依赖关系: web -> backend 表示 web 依赖于 backend
启动顺序: backend 先启动,web 后启动
"""
# 计算每个节点的入度(有多少服务依赖它)
in_degree = {key: 0 for key in self.graph}
# 邻接表:key -> 依赖 key 的服务列表
dependents = {key: [] for key in self.graph}
for key in self.graph:
for dep in self.graph[key]:
if dep in in_degree:
# key 依赖 dep,所以 key 的入度 +1
in_degree[key] += 1
# dep 被 key 依赖
dependents[dep].append(key)
# 初始入度为 0 的节点(不依赖任何服务)
queue = [k for k, v in in_degree.items() if v == 0]
result = []
while queue:
node = queue.pop(0)
result.append(node)
# 处理所有依赖 node 的服务
for dependent in dependents[node]:
in_degree[dependent] -= 1
if in_degree[dependent] == 0:
queue.append(dependent)
if len(result) != len(self.graph):
print(f"{Colors.ERROR} 依赖图中存在循环依赖")
sys.exit(1)
return result
class LogManager:
"""日志管理"""
@staticmethod
def show_logs(config: ServiceConfig, lines: int = 50, follow: bool = False):
"""查看服务日志"""
log_paths = []
# 优先显示服务自己的日志
if config.log_file:
log_path = config.work_dir / config.log_file
if log_path.exists():
log_paths.append(log_path)
# 同时显示管理服务日志
manage_log = config.manage_log_path
if manage_log.exists():
log_paths.append(manage_log)
if not log_paths:
print(f"{Colors.WARN} 未找到 {config.name} 的日志文件")
return
for log_path in log_paths:
print(f"\n{Colors.SECTION} 日志文件: {log_path}")
if follow:
LogManager._follow_log(log_path)
else:
LogManager._tail_log(log_path, lines)
@staticmethod
def _tail_log(log_path: Path, lines: int):
"""显示最后 N 行日志"""
try:
with open(log_path, "r", encoding="utf-8", errors="ignore") as f:
all_lines = f.readlines()
for line in all_lines[-lines:]:
print(line, end="")
except Exception as e:
print(f"{Colors.ERROR} 读取日志失败: {e}")
@staticmethod
def _follow_log(log_path: Path):
"""实时跟踪日志(跨平台实现)"""
print(f"{Colors.INFO} 实时跟踪日志 (Ctrl+C 退出)...")
try:
with open(log_path, "r", encoding="utf-8", errors="ignore") as f:
# 移动到文件末尾
f.seek(0, 2)
while True:
line = f.readline()
if line:
print(line, end="", flush=True)
else:
time.sleep(0.5)
except KeyboardInterrupt:
print(f"\n{Colors.INFO} 退出日志跟踪")
except Exception as e:
print(f"{Colors.ERROR} 日志跟踪失败: {e}")
class ServiceManager:
"""服务管理器(门面类)"""
def __init__(self, config_path: Path):
self.config_path = config_path
self.services = ConfigLoader.load(config_path)
self.dep_graph = DependencyGraph(self.services)
def start(self, service_key: Optional[str] = None, skip_deps: bool = False):
"""启动服务"""
if service_key and service_key != "all":
self._start_single(service_key, skip_deps=skip_deps)
else:
self._start_all(skip_deps=skip_deps)
def stop(self, service_key: Optional[str] = None):
"""停止服务"""
if service_key and service_key != "all":
self._stop_single(service_key)
else:
self._stop_all()
def restart(self, service_key: Optional[str] = None):
"""重启服务"""
if service_key and service_key != "all":
self.stop(service_key)
time.sleep(2)
self.start(service_key)
else:
self._stop_all()
time.sleep(2)
self._start_all()
def status(self):
"""显示所有服务状态"""
print(f"\n{Fore.BLUE}{'=' * 60}")
print(f" 情绪博物馆 - 服务状态")
print(f"{'=' * 60}{Style.RESET_ALL}\n")
for key in self.dep_graph.get_start_order():
config = self.services[key]
pid = ProcessManager.get_pid(config)
is_running = pid is not None
status_icon = (
f"{Fore.GREEN}● 运行中{Style.RESET_ALL}"
if is_running
else f"{Fore.RED}○ 已停止{Style.RESET_ALL}"
)
pid_info = f"PID: {pid}" if pid else "PID: -"
port_info = f"端口: {config.port}"
print(f" {status_icon} {config.name} ({key})")
print(f" {pid_info} | {port_info}")
print()
def info(self):
"""显示服务访问地址"""
print(f"\n{Fore.BLUE}{'=' * 60}")
print(f" 情绪博物馆 - 服务地址")
print(f"{'=' * 60}{Style.RESET_ALL}\n")
for key, config in self.services.items():
pid = ProcessManager.get_pid(config)
is_running = pid is not None
status = (
f"{Fore.GREEN}运行中{Style.RESET_ALL}"
if is_running
else f"{Fore.RED}已停止{Style.RESET_ALL}"
)
print(f" {config.name} ({key}) - {status}")
print(f" 本地地址: http://localhost:{config.port}")
# 显示额外地址
if key == "backend":
print(f" API 地址: http://localhost:{config.port}/api")
print(f" WebSocket: ws://localhost:{config.port}/ws")
elif key == "web-admin":
print(f" 管理后台: http://localhost:{config.port}/")
print()
def logs(self, service_key: str, lines: int = 50, follow: bool = False):
"""查看日志"""
if service_key not in self.services:
print(f"{Colors.ERROR} 未知服务: {service_key}")
return
config = self.services[service_key]
LogManager.show_logs(config, lines=lines, follow=follow)
def clean(self, service_key: str):
"""清理构建产物"""
if service_key not in self.services:
print(f"{Colors.ERROR} 未知服务: {service_key}")
return
config = self.services[service_key]
print(f"{Colors.INFO} 清理 {config.name}...")
if config.type == "java":
# 优先使用 mvn clean,如果找不到 mvn 则直接删除 target
mvn_cmd = ExecutableFinder.find_maven()
if mvn_cmd:
self._run_in_workdir(f'"{mvn_cmd}" clean', config.work_dir)
else:
target_dir = config.work_dir / "target"
if target_dir.exists():
import shutil
shutil.rmtree(target_dir)
print(f"{Colors.INFO} 已删除 target")
elif config.type == "node":
import shutil
for dir_name in ["node_modules", "dist", ".vite"]:
target = config.work_dir / dir_name
if target.exists():
shutil.rmtree(target)
print(f"{Colors.INFO} 已删除 {dir_name}")
def setup(self, service_key: str):
"""安装依赖"""
if service_key not in self.services:
print(f"{Colors.ERROR} 未知服务: {service_key}")
return
config = self.services[service_key]
DependencyManager.ensure_ready(config)
def _start_single(self, key: str, check_deps: bool = True, skip_deps: bool = False):
"""启动单个服务"""
if key not in self.services:
print(f"{Colors.ERROR} 未知服务: {key}")
return
config = self.services[key]
# 检查是否已运行
if ProcessManager.is_running(config):
pid = ProcessManager.get_pid(config)
print(f"{Colors.WARN} {config.name} 已在运行 (PID: {pid})")
return
print(f"\n{Colors.INFO} 启动服务: {config.name} ({key})")
# 检查依赖
if not skip_deps and check_deps:
print(f"{Colors.INFO} 检查依赖...")
if not DependencyManager.ensure_ready(config):
print(f"{Colors.ERROR} 依赖检查失败,无法启动 {config.name}")
return
print(f"{Colors.INFO} 依赖检查通过")
# 检查端口占用
if HealthChecker.check_port(config.port):
print(f"{Colors.WARN} 端口 {config.port} 已被占用")
return
# 启动进程
print(f"{Colors.INFO} 启动进程: {config.start_cmd}")
pid = ProcessManager.start(config)
if pid is None:
print(f"{Colors.ERROR} 启动失败")
return
# 等待服务就绪
if not HealthChecker.wait_for_ready(config):
print(
f"{Colors.WARN} 服务可能启动失败,请查看日志: {config.manage_log_path}"
)
return
print(
f"{Colors.INFO} [OK] {config.name} 已启动 (PID: {pid}, 端口: {config.port})"
)
def _start_all(self, skip_deps: bool = False):
"""启动所有服务(按依赖顺序)"""
print(f"\n{Fore.BLUE}{'' * 58}")
print(f"║ 情绪博物馆 - 启动所有服务")
print(f"{'' * 58}{Style.RESET_ALL}\n")
order = self.dep_graph.get_start_order()
for key in order:
config = self.services[key]
# 启动前先等待依赖服务就绪
for dep_key in config.depends_on:
dep_config = self.services[dep_key]
if not ProcessManager.is_running(dep_config):
print(f"{Colors.INFO} 等待依赖服务 {dep_config.name}...")
if not HealthChecker.wait_for_ready(dep_config, timeout=30):
print(f"{Colors.ERROR} 依赖服务 {dep_config.name} 未就绪,终止启动")
return
self._start_single(key, check_deps=True, skip_deps=skip_deps)
# 服务之间等待 1 秒
if key != order[-1]:
time.sleep(1)
# 显示访问地址
self.info()
def _stop_single(self, key: str):
"""停止单个服务"""
if key not in self.services:
print(f"{Colors.ERROR} 未知服务: {key}")
return
config = self.services[key]
ProcessManager.stop(config)
def _stop_all(self):
"""停止所有服务(逆依赖顺序)"""
print(f"\n{Fore.BLUE}{'' * 58}")
print(f"║ 情绪博物馆 - 停止所有服务")
print(f"{'' * 58}{Style.RESET_ALL}\n")
order = self.dep_graph.get_stop_order()
for key in order:
config = self.services[key]
ProcessManager.stop(config)
time.sleep(1) # 等待端口释放
print(f"\n{Colors.INFO} 所有服务已停止")
@staticmethod
def _run_in_workdir(cmd: str, work_dir: Path):
"""在工作目录执行命令"""
subprocess.run(cmd, shell=True, cwd=work_dir)
# 项目根目录(manage.py 所在目录)
PROJECT_ROOT = Path(__file__).parent.parent