# -*- coding: utf-8 -*- """一次拉起后端的三个服务。 服务是拆开的,但开发和小规模部署时逐个开三个终端太啰嗦。这个脚本把它们放在一个 进程组里:任何一个退出就把其余的一起停掉——留着半套服务在跑,比全停更难排查。 python run_backend.py # 三个都起 python run_backend.py --only api gateway # 只起指定的 python run_backend.py --db /data/backend.db 生产环境请用 systemd / NSSM 之类的进程守护,各服务独立托管、独立重启: 一个模型上游雪崩不应该把管理后台一起带走,这正是当初把它们拆开的原因。 """ from __future__ import annotations import argparse import os import signal import socket import subprocess import sys import threading import time from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent SERVICES = { "api": { # 老的网页后台(console/8765)已整体退役:界面由 Vue 管理端承担, # 桌面端同步、调用留痕上报也都并进了这个服务。 "label": "管理端(Vue 前端 + JSON API + 桌面端同步)", "default_port": 8766, "argv": lambda db, host, port: [ sys.executable, "admin_api.py", "--db", str(db), "--host", host, "--port", str(port), ], }, "gateway": { "label": "模型网关(答题 + 裁判)", "default_port": 8770, "argv": lambda db, host, port: [ sys.executable, "model_gateway.py", "--db", str(db), "--host", host, "--port", str(port), ], }, } def _port_busy(host: str, port: int) -> bool: """这个端口是不是已经被占了。 提前查一遍,是为了给一句人话。让 uvicorn 自己撞上去的话,Windows 会把 WinError 10048 的系统文案按本地代码页塞进管道,输出一串乱码——看到的人 只知道"起不来",不知道是端口冲突,更不知道是谁占着。 """ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: probe.settimeout(0.4) try: return probe.connect_ex((host if host != "0.0.0.0" else "127.0.0.1", port)) == 0 except OSError: return False def _who_holds(port: int) -> str: """尽力查出占用端口的进程,查不到就算了——这只是帮忙,不是必需。""" try: output = subprocess.run( ["netstat", "-ano", "-p", "tcp"], capture_output=True, text=True, timeout=5, ).stdout except Exception: return "" for line in output.splitlines(): parts = line.split() if len(parts) >= 5 and parts[3] == "LISTENING" and parts[1].endswith(f":{port}"): pid = parts[4] try: name = subprocess.run( ["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"], capture_output=True, text=True, timeout=5, ).stdout.split(",")[0].strip().strip(chr(34)) except Exception: name = "" return f"{name or '未知进程'}(PID {pid})" return "" def _pump(name: str, stream) -> None: """把子进程的输出打上服务名前缀转发出来,三路日志混在一起才分得清。""" for raw in iter(stream.readline, ""): line = raw.rstrip() if line: print(f"[{name}] {line}", flush=True) def main() -> None: parser = argparse.ArgumentParser(description="拉起后端服务") parser.add_argument("--db", default=str(SCRIPT_DIR / "backend.db")) parser.add_argument("--host", default="127.0.0.1") parser.add_argument( "--only", nargs="*", choices=sorted(SERVICES), default=sorted(SERVICES) ) for name, spec in SERVICES.items(): parser.add_argument(f"--{name}-port", type=int, default=spec["default_port"]) args = parser.parse_args() db = Path(args.db).resolve() # 管理端负责建表和播种 admin,必须最先起来:网关和它读同一个库,表不在就只能报错 order = [name for name in ("api", "gateway") if name in args.only] if not order: raise SystemExit("没有选中任何服务") # 先把端口全查一遍再启动。逐个起的话,第三个服务撞上占用端口时前两个已经 # 在跑了——退出流程会把它们一起停掉,你看到的是"全部服务已停止",得往回翻 # 好几屏才找得到真正的原因。 busy = [ (name, getattr(args, f"{name}_port")) for name in order if _port_busy(args.host, getattr(args, f"{name}_port")) ] if busy: print("以下端口已被占用,服务没有启动:") print() for name, port in busy: holder = _who_holds(port) tail = f" ← 被 {holder} 占用" if holder else "" print(f" {name:8s} {args.host}:{port}{tail}") first_name, first_port = busy[0] print() print("处理办法(任选其一):") print(" 1. 关掉占用它的进程——多半是上一次没退干净的同一套服务") print( f" 2. 换端口启动:python run_backend.py " f"--{first_name}-port {first_port + 10}" ) print(" 查占用:netstat -ano | findstr :" + str(first_port)) raise SystemExit(1) procs: dict[str, subprocess.Popen] = {} print(f"数据库:{db}") for name in order: spec = SERVICES[name] port = getattr(args, f"{name}_port") proc = subprocess.Popen( spec["argv"](db, args.host, port), cwd=SCRIPT_DIR, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", bufsize=1, ) procs[name] = proc threading.Thread( target=_pump, args=(name, proc.stdout), daemon=True ).start() print(f"已启动 {name:8s} http://{args.host}:{port} {spec['label']}") if name == "api": # 让建表先跑完,网关起来时表就已经在了 time.sleep(1.5) print("\nCtrl+C 停止全部服务。\n") try: while True: for name, proc in procs.items(): code = proc.poll() if code is not None: print(f"\n[{name}] 已退出(code={code}),正在停止其余服务…") raise KeyboardInterrupt time.sleep(0.5) except KeyboardInterrupt: pass finally: for name, proc in procs.items(): if proc.poll() is None: try: if os.name == "nt": proc.terminate() else: proc.send_signal(signal.SIGINT) except OSError: pass deadline = time.time() + 8 for name, proc in procs.items(): remaining = max(0.5, deadline - time.time()) try: proc.wait(timeout=remaining) except subprocess.TimeoutExpired: print(f"[{name}] 没有及时退出,强制结束") proc.kill() print("全部服务已停止。") if __name__ == "__main__": main()