在前后端开发联调前 的提交20260223

This commit is contained in:
Neo
2026-02-23 23:02:20 +08:00
parent 254ccb1e77
commit fafc95e64c
1142 changed files with 10366960 additions and 36957 deletions

View File

@@ -0,0 +1,372 @@
"""
运维控制面板 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)}