Files
kefu/wechat_rpa/run_backend.py
T
2026-08-27 14:04:28 +08:00

199 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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()