373 lines
12 KiB
Python
373 lines
12 KiB
Python
"""
|
|
运维控制面板 API
|
|
|
|
提供服务器各环境的服务状态查看、启停控制、Git 操作和配置管理。
|
|
面向管理后台的运维面板页面。
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
import subprocess
|
|
import platform
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import psutil
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
router = APIRouter(prefix="/api/ops", tags=["运维面板"])
|
|
|
|
# ---- 环境定义 ----
|
|
# 服务器上的两套环境;开发机上回退到本机路径(方便调试)
|
|
|
|
_SERVER_BASE = Path("D:/NeoZQYY")
|
|
|
|
ENVIRONMENTS: dict[str, dict[str, Any]] = {
|
|
"test": {
|
|
"label": "测试环境",
|
|
"repo_path": str(_SERVER_BASE / "test" / "repo"),
|
|
"branch": "test",
|
|
"port": 8001,
|
|
"bat_script": str(_SERVER_BASE / "scripts" / "start-test-api.bat"),
|
|
"window_title": "NeoZQYY Test API",
|
|
},
|
|
"prod": {
|
|
"label": "正式环境",
|
|
"repo_path": str(_SERVER_BASE / "prod" / "repo"),
|
|
"branch": "master",
|
|
"port": 8000,
|
|
"bat_script": str(_SERVER_BASE / "scripts" / "start-prod-api.bat"),
|
|
"window_title": "NeoZQYY Prod API",
|
|
},
|
|
}
|
|
|
|
|
|
# ---- 数据模型 ----
|
|
|
|
class ServiceStatus(BaseModel):
|
|
env: str
|
|
label: str
|
|
running: bool
|
|
pid: int | None = None
|
|
port: int
|
|
uptime_seconds: float | None = None
|
|
memory_mb: float | None = None
|
|
cpu_percent: float | None = None
|
|
|
|
|
|
class GitInfo(BaseModel):
|
|
env: str
|
|
branch: str
|
|
last_commit_hash: str
|
|
last_commit_message: str
|
|
last_commit_time: str
|
|
has_local_changes: bool
|
|
|
|
|
|
class SystemInfo(BaseModel):
|
|
cpu_percent: float
|
|
memory_total_gb: float
|
|
memory_used_gb: float
|
|
memory_percent: float
|
|
disk_total_gb: float
|
|
disk_used_gb: float
|
|
disk_percent: float
|
|
boot_time: str
|
|
|
|
|
|
class EnvFileContent(BaseModel):
|
|
content: str
|
|
|
|
|
|
class GitPullResult(BaseModel):
|
|
env: str
|
|
success: bool
|
|
output: str
|
|
|
|
|
|
class ServiceActionResult(BaseModel):
|
|
env: str
|
|
action: str
|
|
success: bool
|
|
message: str
|
|
|
|
|
|
# ---- 辅助函数 ----
|
|
|
|
def _find_uvicorn_process(port: int) -> psutil.Process | None:
|
|
"""查找监听指定端口的 uvicorn 进程。"""
|
|
for proc in psutil.process_iter(["pid", "name", "cmdline"]):
|
|
try:
|
|
cmdline = proc.info.get("cmdline") or []
|
|
cmdline_str = " ".join(cmdline)
|
|
# 匹配 uvicorn 进程且包含对应端口
|
|
if "uvicorn" in cmdline_str and str(port) in cmdline_str:
|
|
return proc
|
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
continue
|
|
return None
|
|
|
|
|
|
def _run_cmd(cmd: str | list[str], cwd: str | None = None, timeout: int = 30) -> tuple[bool, str]:
|
|
"""执行命令并返回 (成功, 输出)。"""
|
|
try:
|
|
result = subprocess.run(
|
|
cmd,
|
|
cwd=cwd,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
shell=isinstance(cmd, str),
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
output = (result.stdout + "\n" + result.stderr).strip()
|
|
return result.returncode == 0, output
|
|
except subprocess.TimeoutExpired:
|
|
return False, "命令执行超时"
|
|
except Exception as e:
|
|
return False, str(e)
|
|
|
|
|
|
# ---- 系统信息 ----
|
|
|
|
@router.get("/system", response_model=SystemInfo)
|
|
async def get_system_info():
|
|
"""获取服务器系统资源概况。"""
|
|
mem = psutil.virtual_memory()
|
|
disk = psutil.disk_usage("D:\\") if os.path.exists("D:\\") else psutil.disk_usage("/")
|
|
boot = datetime.fromtimestamp(psutil.boot_time())
|
|
return SystemInfo(
|
|
cpu_percent=psutil.cpu_percent(interval=0.5),
|
|
memory_total_gb=round(mem.total / (1024 ** 3), 2),
|
|
memory_used_gb=round(mem.used / (1024 ** 3), 2),
|
|
memory_percent=mem.percent,
|
|
disk_total_gb=round(disk.total / (1024 ** 3), 2),
|
|
disk_used_gb=round(disk.used / (1024 ** 3), 2),
|
|
disk_percent=disk.percent,
|
|
boot_time=boot.isoformat(),
|
|
)
|
|
|
|
|
|
# ---- 服务状态 ----
|
|
|
|
@router.get("/services", response_model=list[ServiceStatus])
|
|
async def get_services_status():
|
|
"""获取所有环境的服务运行状态。"""
|
|
results = []
|
|
for env_key, env_cfg in ENVIRONMENTS.items():
|
|
proc = _find_uvicorn_process(env_cfg["port"])
|
|
if proc:
|
|
try:
|
|
mem_info = proc.memory_info()
|
|
create_time = proc.create_time()
|
|
results.append(ServiceStatus(
|
|
env=env_key,
|
|
label=env_cfg["label"],
|
|
running=True,
|
|
pid=proc.pid,
|
|
port=env_cfg["port"],
|
|
uptime_seconds=round(datetime.now().timestamp() - create_time, 1),
|
|
memory_mb=round(mem_info.rss / (1024 ** 2), 1),
|
|
cpu_percent=proc.cpu_percent(interval=0.1),
|
|
))
|
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
results.append(ServiceStatus(
|
|
env=env_key, label=env_cfg["label"],
|
|
running=False, port=env_cfg["port"],
|
|
))
|
|
else:
|
|
results.append(ServiceStatus(
|
|
env=env_key, label=env_cfg["label"],
|
|
running=False, port=env_cfg["port"],
|
|
))
|
|
return results
|
|
|
|
|
|
# ---- 服务启停 ----
|
|
|
|
@router.post("/services/{env}/start", response_model=ServiceActionResult)
|
|
async def start_service(env: str):
|
|
"""启动指定环境的后端服务。"""
|
|
if env not in ENVIRONMENTS:
|
|
raise HTTPException(404, f"未知环境: {env}")
|
|
|
|
cfg = ENVIRONMENTS[env]
|
|
proc = _find_uvicorn_process(cfg["port"])
|
|
if proc:
|
|
return ServiceActionResult(
|
|
env=env, action="start", success=True,
|
|
message=f"服务已在运行中 (PID: {proc.pid})",
|
|
)
|
|
|
|
bat_path = cfg["bat_script"]
|
|
if not os.path.exists(bat_path):
|
|
raise HTTPException(400, f"启动脚本不存在: {bat_path}")
|
|
|
|
# 通过 start 命令在新窗口中启动 bat 脚本
|
|
try:
|
|
subprocess.Popen(
|
|
f'start "{cfg["window_title"]}" cmd /c "{bat_path}"',
|
|
shell=True,
|
|
)
|
|
# 等待进程启动
|
|
await asyncio.sleep(3)
|
|
new_proc = _find_uvicorn_process(cfg["port"])
|
|
if new_proc:
|
|
return ServiceActionResult(
|
|
env=env, action="start", success=True,
|
|
message=f"服务已启动 (PID: {new_proc.pid})",
|
|
)
|
|
else:
|
|
return ServiceActionResult(
|
|
env=env, action="start", success=False,
|
|
message="启动命令已执行,但未检测到进程,请检查日志",
|
|
)
|
|
except Exception as e:
|
|
return ServiceActionResult(
|
|
env=env, action="start", success=False, message=str(e),
|
|
)
|
|
|
|
|
|
@router.post("/services/{env}/stop", response_model=ServiceActionResult)
|
|
async def stop_service(env: str):
|
|
"""停止指定环境的后端服务。"""
|
|
if env not in ENVIRONMENTS:
|
|
raise HTTPException(404, f"未知环境: {env}")
|
|
|
|
cfg = ENVIRONMENTS[env]
|
|
proc = _find_uvicorn_process(cfg["port"])
|
|
if not proc:
|
|
return ServiceActionResult(
|
|
env=env, action="stop", success=True, message="服务未在运行",
|
|
)
|
|
|
|
try:
|
|
# 终止进程树(包括子进程)
|
|
parent = psutil.Process(proc.pid)
|
|
children = parent.children(recursive=True)
|
|
for child in children:
|
|
child.terminate()
|
|
parent.terminate()
|
|
# 等待进程退出
|
|
gone, alive = psutil.wait_procs([parent] + children, timeout=5)
|
|
for p in alive:
|
|
p.kill()
|
|
return ServiceActionResult(
|
|
env=env, action="stop", success=True, message="服务已停止",
|
|
)
|
|
except Exception as e:
|
|
return ServiceActionResult(
|
|
env=env, action="stop", success=False, message=str(e),
|
|
)
|
|
|
|
|
|
@router.post("/services/{env}/restart", response_model=ServiceActionResult)
|
|
async def restart_service(env: str):
|
|
"""重启指定环境的后端服务。"""
|
|
stop_result = await stop_service(env)
|
|
if not stop_result.success and "未在运行" not in stop_result.message:
|
|
return ServiceActionResult(
|
|
env=env, action="restart", success=False,
|
|
message=f"停止失败: {stop_result.message}",
|
|
)
|
|
await asyncio.sleep(1)
|
|
start_result = await start_service(env)
|
|
return ServiceActionResult(
|
|
env=env, action="restart",
|
|
success=start_result.success,
|
|
message=start_result.message,
|
|
)
|
|
|
|
|
|
# ---- Git 操作 ----
|
|
|
|
@router.get("/git", response_model=list[GitInfo])
|
|
async def get_git_info():
|
|
"""获取所有环境的 Git 状态。"""
|
|
results = []
|
|
for env_key, env_cfg in ENVIRONMENTS.items():
|
|
repo = env_cfg["repo_path"]
|
|
if not os.path.isdir(os.path.join(repo, ".git")):
|
|
results.append(GitInfo(
|
|
env=env_key, branch="N/A",
|
|
last_commit_hash="N/A", last_commit_message="仓库不存在",
|
|
last_commit_time="", has_local_changes=False,
|
|
))
|
|
continue
|
|
|
|
_, branch = _run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=repo)
|
|
_, log_out = _run_cmd(
|
|
["git", "log", "-1", "--format=%H|%s|%ci"],
|
|
cwd=repo,
|
|
)
|
|
_, status_out = _run_cmd(["git", "status", "--porcelain"], cwd=repo)
|
|
|
|
parts = log_out.strip().split("|", 2) if log_out else ["", "", ""]
|
|
results.append(GitInfo(
|
|
env=env_key,
|
|
branch=branch.strip(),
|
|
last_commit_hash=parts[0][:8] if parts[0] else "N/A",
|
|
last_commit_message=parts[1] if len(parts) > 1 else "",
|
|
last_commit_time=parts[2] if len(parts) > 2 else "",
|
|
has_local_changes=bool(status_out.strip()),
|
|
))
|
|
return results
|
|
|
|
|
|
@router.post("/git/{env}/pull", response_model=GitPullResult)
|
|
async def git_pull(env: str):
|
|
"""对指定环境执行 git pull。"""
|
|
if env not in ENVIRONMENTS:
|
|
raise HTTPException(404, f"未知环境: {env}")
|
|
|
|
cfg = ENVIRONMENTS[env]
|
|
repo = cfg["repo_path"]
|
|
if not os.path.isdir(os.path.join(repo, ".git")):
|
|
raise HTTPException(400, f"仓库路径不存在: {repo}")
|
|
|
|
success, output = _run_cmd(["git", "pull", "--ff-only"], cwd=repo, timeout=60)
|
|
return GitPullResult(env=env, success=success, output=output)
|
|
|
|
|
|
@router.post("/git/{env}/sync-deps", response_model=ServiceActionResult)
|
|
async def sync_deps(env: str):
|
|
"""对指定环境执行 uv sync --all-packages。"""
|
|
if env not in ENVIRONMENTS:
|
|
raise HTTPException(404, f"未知环境: {env}")
|
|
|
|
cfg = ENVIRONMENTS[env]
|
|
repo = cfg["repo_path"]
|
|
success, output = _run_cmd(["uv", "sync", "--all-packages"], cwd=repo, timeout=120)
|
|
return ServiceActionResult(
|
|
env=env, action="sync-deps", success=success, message=output[:500],
|
|
)
|
|
|
|
|
|
# ---- 环境配置管理 ----
|
|
|
|
@router.get("/env-file/{env}")
|
|
async def get_env_file(env: str):
|
|
"""读取指定环境的 .env 文件(敏感值脱敏)。"""
|
|
if env not in ENVIRONMENTS:
|
|
raise HTTPException(404, f"未知环境: {env}")
|
|
|
|
env_path = Path(ENVIRONMENTS[env]["repo_path"]) / ".env"
|
|
if not env_path.exists():
|
|
raise HTTPException(404, f".env 文件不存在: {env_path}")
|
|
|
|
lines = env_path.read_text(encoding="utf-8").splitlines()
|
|
masked_lines = []
|
|
sensitive_keys = {"PASSWORD", "SECRET", "TOKEN", "DSN", "APP_SECRET"}
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped and not stripped.startswith("#") and "=" in stripped:
|
|
key = stripped.split("=", 1)[0].strip()
|
|
if any(s in key.upper() for s in sensitive_keys):
|
|
masked_lines.append(f"{key}=********")
|
|
continue
|
|
masked_lines.append(line)
|
|
return {"env": env, "content": "\n".join(masked_lines)}
|