supervisor.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. """Web 面板后端:FastAPI + Bot 子进程管理 + 日志缓冲 + SSE。
  2. 被 main.py 通过 uvicorn 启动。
  3. """
  4. from __future__ import annotations
  5. import asyncio
  6. import json
  7. import logging
  8. import os
  9. import re
  10. import signal
  11. import subprocess
  12. import sys
  13. import threading
  14. import time
  15. from collections import deque
  16. from dataclasses import dataclass, field
  17. from pathlib import Path
  18. from typing import AsyncGenerator, Optional
  19. from fastapi import FastAPI, Request, Response, HTTPException, Depends
  20. from fastapi.responses import (
  21. HTMLResponse,
  22. JSONResponse,
  23. StreamingResponse,
  24. RedirectResponse,
  25. )
  26. from fastapi.staticfiles import StaticFiles
  27. from pydantic import BaseModel
  28. import yaml
  29. # 确保能 import 项目根的 config
  30. sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
  31. from config import Config, load_config # noqa: E402
  32. from web.auth import ( # noqa: E402
  33. COOKIE_NAME,
  34. COOKIE_MAX_AGE,
  35. has_password,
  36. sign_session,
  37. verify_password,
  38. verify_session,
  39. )
  40. logger = logging.getLogger("lark2lark.supervisor")
  41. PROJECT_ROOT = Path(__file__).resolve().parent.parent
  42. BOT_SCRIPT = PROJECT_ROOT / "bot.py"
  43. CONFIG_PATH = PROJECT_ROOT / "config.yaml"
  44. INDEX_HTML = Path(__file__).resolve().parent / "index.html"
  45. LOG_BUFFER_MAX = 500
  46. STATUS_PREFIX = "__STATUS__"
  47. BOT_RESTART_DELAY = 1.0 # 秒,重启间隔
  48. # ---------- 数据模型 ----------
  49. class LoginForm(BaseModel):
  50. password: str
  51. class ConfigUpdate(BaseModel):
  52. app_id: Optional[str] = None
  53. app_secret: Optional[str] = None
  54. source_chat_ids: Optional[list[str]] = None
  55. target_chat_ids: Optional[list[str]] = None
  56. dedup_cache_size: Optional[int] = None
  57. max_qps: Optional[int] = None
  58. max_retry: Optional[int] = None
  59. log_level: Optional[str] = None
  60. @dataclass
  61. class LogEntry:
  62. seq: int
  63. ts: float
  64. level: str
  65. text: str
  66. class LogBuffer:
  67. """线程安全环形缓冲 + SSE 订阅广播。"""
  68. def __init__(self, max_size: int = LOG_BUFFER_MAX) -> None:
  69. self._lock = threading.Lock()
  70. self._entries: deque[LogEntry] = deque(maxlen=max_size)
  71. self._seq = 0
  72. self._cond = threading.Condition(self._lock)
  73. def append(self, text: str, level: str = "INFO") -> None:
  74. with self._cond:
  75. self._seq += 1
  76. entry = LogEntry(seq=self._seq, ts=time.time(), level=level, text=text.rstrip("\n"))
  77. self._entries.append(entry)
  78. self._cond.notify_all()
  79. def since(self, seq: int) -> list[LogEntry]:
  80. with self._lock:
  81. return [e for e in self._entries if e.seq > seq]
  82. def all(self) -> list[LogEntry]:
  83. with self._lock:
  84. return list(self._entries)
  85. def wait_for_new(self, last_seq: int, timeout: float = 25.0) -> list[LogEntry]:
  86. """阻塞等待新日志,超时返回空列表(SSE keepalive)。"""
  87. with self._cond:
  88. if not self._cond.wait_for(
  89. lambda: any(e.seq > last_seq for e in self._entries), timeout=timeout
  90. ):
  91. return []
  92. return [e for e in self._entries if e.seq > last_seq]
  93. # ---------- Bot 子进程管理 ----------
  94. class BotManager:
  95. """管理 bot.py 子进程:启动、停止、重启、日志采集、状态采集。"""
  96. def __init__(self, log_buffer: LogBuffer) -> None:
  97. self._log_buffer = log_buffer
  98. self._proc: Optional[subprocess.Popen] = None
  99. self._lock = threading.Lock()
  100. self._reader_thread: Optional[threading.Thread] = None
  101. self._watch_thread: Optional[threading.Thread] = None
  102. self._stop_requested = False
  103. self._last_status: dict = {}
  104. self._last_status_ts: float = 0.0
  105. self._start_time: float = 0.0
  106. def start(self) -> None:
  107. with self._lock:
  108. if self._proc and self._proc.poll() is None:
  109. return
  110. self._stop_requested = False
  111. self._start_time = time.time()
  112. env = os.environ.copy()
  113. env["PYTHONUNBUFFERED"] = "1"
  114. env["PYTHONIOENCODING"] = "utf-8"
  115. try:
  116. self._proc = subprocess.Popen(
  117. [sys.executable, str(BOT_SCRIPT), "--config", str(CONFIG_PATH)],
  118. stdout=subprocess.PIPE,
  119. stderr=subprocess.STDOUT,
  120. cwd=str(PROJECT_ROOT),
  121. env=env,
  122. encoding="utf-8",
  123. errors="replace",
  124. bufsize=1, # 行缓冲
  125. )
  126. except Exception as e:
  127. self._log_buffer.append(f"启动 bot 子进程失败: {e}", "ERROR")
  128. raise
  129. self._reader_thread = threading.Thread(
  130. target=self._read_output, name="bot-reader", daemon=True
  131. )
  132. self._reader_thread.start()
  133. self._watch_thread = threading.Thread(
  134. target=self._watch, name="bot-watch", daemon=True
  135. )
  136. self._watch_thread.start()
  137. self._log_buffer.append("Bot 子进程已启动", "INFO")
  138. def stop(self) -> None:
  139. with self._lock:
  140. self._stop_requested = True
  141. proc = self._proc
  142. if proc and proc.poll() is None:
  143. self._log_buffer.append("正在停止 Bot 子进程...", "INFO")
  144. try:
  145. proc.terminate()
  146. try:
  147. proc.wait(timeout=5)
  148. except subprocess.TimeoutExpired:
  149. proc.kill()
  150. proc.wait(timeout=3)
  151. except Exception as e:
  152. self._log_buffer.append(f"停止 Bot 异常: {e}", "WARNING")
  153. def restart(self) -> None:
  154. self._log_buffer.append("重启 Bot 子进程...", "INFO")
  155. self.stop()
  156. time.sleep(BOT_RESTART_DELAY)
  157. self.start()
  158. def is_alive(self) -> bool:
  159. with self._lock:
  160. return self._proc is not None and self._proc.poll() is None
  161. def uptime(self) -> int:
  162. if not self.is_alive():
  163. return 0
  164. return int(time.time() - self._start_time)
  165. @property
  166. def last_status(self) -> dict:
  167. return dict(self._last_status) if self._last_status else {}
  168. def _read_output(self) -> None:
  169. """读取 bot stdout,识别 __STATUS__ 行,其余写入日志缓冲。"""
  170. proc = self._proc
  171. if proc is None or proc.stdout is None:
  172. return
  173. try:
  174. for line in proc.stdout:
  175. line = line.rstrip("\n")
  176. if not line:
  177. continue
  178. if line.startswith(STATUS_PREFIX):
  179. self._parse_status(line[len(STATUS_PREFIX):])
  180. continue
  181. level = self._detect_level(line)
  182. self._log_buffer.append(line, level)
  183. except Exception as e:
  184. self._log_buffer.append(f"读取 Bot 输出异常: {e}", "ERROR")
  185. @staticmethod
  186. def _detect_level(line: str) -> str:
  187. m = re.search(r"\[(DEBUG|INFO|WARNING|ERROR|CRITICAL)\]", line)
  188. return m.group(1) if m else "INFO"
  189. def _parse_status(self, json_str: str) -> None:
  190. try:
  191. data = json.loads(json_str)
  192. self._last_status = data
  193. self._last_status_ts = time.time()
  194. except json.JSONDecodeError:
  195. pass
  196. def _watch(self) -> None:
  197. """监控子进程存活,异常退出自动重启。"""
  198. proc = self._proc
  199. if proc is None:
  200. return
  201. while True:
  202. rc = proc.wait()
  203. self._log_buffer.append(f"Bot 子进程退出,返回码={rc}", "WARNING")
  204. if self._stop_requested:
  205. break
  206. self._log_buffer.append(f"{BOT_RESTART_DELAY}s 后自动重启 Bot...", "INFO")
  207. time.sleep(BOT_RESTART_DELAY)
  208. try:
  209. self.start()
  210. except Exception as e:
  211. self._log_buffer.append(f"自动重启失败: {e},10s 后再试", "ERROR")
  212. time.sleep(10)
  213. continue
  214. break # 新进程已由新 watch 线程接管,本线程退出
  215. # ---------- FastAPI 应用 ----------
  216. app = FastAPI(title="lark2lark 面板", docs_url=None, redoc_url=None)
  217. log_buffer = LogBuffer()
  218. bot_mgr = BotManager(log_buffer)
  219. def get_panel_password() -> str:
  220. """从当前配置读取面板密码。每次调用都重新加载,支持热更新。"""
  221. try:
  222. cfg = load_config()
  223. return cfg.panel_password
  224. except Exception:
  225. return ""
  226. def require_auth(request: Request) -> None:
  227. """FastAPI 依赖:校验登录 Cookie。"""
  228. token = request.cookies.get(COOKIE_NAME)
  229. if not verify_session(token or "", get_panel_password()):
  230. raise HTTPException(status_code=401, detail="未登录")
  231. @app.on_event("startup")
  232. async def _startup() -> None:
  233. logging.basicConfig(
  234. level=logging.INFO,
  235. format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
  236. datefmt="%Y-%m-%d %H:%M:%S",
  237. )
  238. logging.getLogger("lark-oapi").setLevel(logging.WARNING)
  239. logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
  240. log_buffer.append("Supervisor 启动", "INFO")
  241. try:
  242. bot_mgr.start()
  243. except Exception as e:
  244. log_buffer.append(f"启动 Bot 失败: {e}", "ERROR")
  245. @app.on_event("shutdown")
  246. async def _shutdown() -> None:
  247. log_buffer.append("Supervisor 关闭中", "INFO")
  248. bot_mgr.stop()
  249. # ---------- 路由 ----------
  250. @app.get("/", response_class=HTMLResponse)
  251. async def index() -> HTMLResponse:
  252. if not INDEX_HTML.exists():
  253. return HTMLResponse("<h1>index.html 缺失</h1>", status_code=500)
  254. return HTMLResponse(INDEX_HTML.read_text(encoding="utf-8"))
  255. @app.post("/api/login")
  256. async def login(form: LoginForm, response: Response):
  257. pwd = get_panel_password()
  258. if not has_password(pwd):
  259. raise HTTPException(status_code=503, detail="面板未设置密码(LARK_PANEL_PASSWORD 环境变量或 config.panel.password)")
  260. if not verify_password(form.password, pwd):
  261. raise HTTPException(status_code=401, detail="密码错误")
  262. token = sign_session(pwd)
  263. response.set_cookie(
  264. COOKIE_NAME, token,
  265. max_age=COOKIE_MAX_AGE,
  266. httponly=True,
  267. samesite="lax",
  268. )
  269. return {"ok": True}
  270. @app.post("/api/logout")
  271. async def logout(response: Response):
  272. response.delete_cookie(COOKIE_NAME)
  273. return {"ok": True}
  274. @app.get("/api/config")
  275. async def get_config(_: None = Depends(require_auth)):
  276. """返回当前配置,app_secret 脱敏。"""
  277. if not CONFIG_PATH.exists():
  278. raise HTTPException(status_code=404, detail="config.yaml 不存在")
  279. with CONFIG_PATH.open("r", encoding="utf-8") as f:
  280. data = yaml.safe_load(f) or {}
  281. # 脱敏
  282. if data.get("app_secret"):
  283. data["app_secret"] = "***"
  284. return data
  285. @app.put("/api/config")
  286. async def update_config(payload: ConfigUpdate, _: None = Depends(require_auth)):
  287. """更新 config.yaml 并重启 Bot。app_secret 为 '***' 时保留原值。"""
  288. if not CONFIG_PATH.exists():
  289. raise HTTPException(status_code=404, detail="config.yaml 不存在")
  290. with CONFIG_PATH.open("r", encoding="utf-8") as f:
  291. current = yaml.safe_load(f) or {}
  292. updates = payload.model_dump(exclude_none=True)
  293. for key, val in updates.items():
  294. if key == "app_secret" and val == "***":
  295. continue # 保留原值
  296. current[key] = val
  297. # 校验:写前快速检查
  298. try:
  299. _validate_partial(current)
  300. except ValueError as e:
  301. raise HTTPException(status_code=400, detail=str(e))
  302. with CONFIG_PATH.open("w", encoding="utf-8") as f:
  303. yaml.safe_dump(current, f, allow_unicode=True, sort_keys=False)
  304. log_buffer.append("配置已更新,重启 Bot 使其生效", "INFO")
  305. # 异步重启,避免阻塞 HTTP 响应
  306. threading.Thread(target=bot_mgr.restart, daemon=True).start()
  307. return {"ok": True, "note": "配置已保存,Bot 正在重启"}
  308. def _validate_partial(data: dict) -> None:
  309. """对待写入的配置做基本校验。"""
  310. if "max_qps" in data:
  311. qps = data["max_qps"]
  312. if not isinstance(qps, int) or qps <= 0 or qps > 5:
  313. raise ValueError(f"max_qps 必须在 (0, 5] 区间,当前 {qps}")
  314. if "source_chat_ids" in data:
  315. if not isinstance(data["source_chat_ids"], list) or not data["source_chat_ids"]:
  316. raise ValueError("source_chat_ids 至少 1 个")
  317. if "target_chat_ids" in data:
  318. if not isinstance(data["target_chat_ids"], list) or not data["target_chat_ids"]:
  319. raise ValueError("target_chat_ids 至少 1 个")
  320. @app.get("/api/status")
  321. async def get_status(_: None = Depends(require_auth)):
  322. """返回 Bot 运行状态。"""
  323. status = bot_mgr.last_status
  324. # 状态超时判定:超过 15s 未更新视为失联
  325. status_fresh = (time.time() - bot_mgr._last_status_ts) < 15 if bot_mgr._last_status_ts else False
  326. return {
  327. "bot_alive": bot_mgr.is_alive(),
  328. "ws_connected": status.get("ws_connected", False) and status_fresh,
  329. "uptime": status.get("uptime", 0) if status_fresh else 0,
  330. "queue_size": status.get("queue_size", 0),
  331. "dedup_size": status.get("dedup_size", 0),
  332. "dedup_max": status.get("dedup_max", 0),
  333. "status_fresh": status_fresh,
  334. "last_status_ts": bot_mgr._last_status_ts,
  335. }
  336. @app.get("/api/logs")
  337. async def get_logs(request: Request, since: int = 0, _: None = Depends(require_auth)):
  338. """SSE 流:实时推送新日志。"""
  339. async def event_stream() -> AsyncGenerator[bytes, None]:
  340. last_seq = since
  341. # 先发送历史
  342. for entry in log_buffer.since(last_seq):
  343. last_seq = entry.seq
  344. yield _format_sse(entry)
  345. # 再订阅新日志
  346. while True:
  347. if await request.is_disconnected():
  348. break
  349. new_entries = await asyncio.get_event_loop().run_in_executor(
  350. None, log_buffer.wait_for_new, last_seq, 25.0
  351. )
  352. if not new_entries:
  353. # keepalive
  354. yield b": ping\n\n"
  355. continue
  356. for entry in new_entries:
  357. last_seq = entry.seq
  358. yield _format_sse(entry)
  359. return StreamingResponse(
  360. event_stream(),
  361. media_type="text/event-stream",
  362. headers={
  363. "Cache-Control": "no-cache",
  364. "X-Accel-Buffering": "no", # nginx 不缓冲
  365. },
  366. )
  367. def _format_sse(entry: LogEntry) -> bytes:
  368. data = json.dumps({
  369. "seq": entry.seq,
  370. "ts": entry.ts,
  371. "level": entry.level,
  372. "text": entry.text,
  373. }, ensure_ascii=False)
  374. return f"data: {data}\n\n".encode("utf-8")
  375. @app.get("/api/logs/history")
  376. async def get_logs_history(_: None = Depends(require_auth)):
  377. """返回全部历史日志(一次性)。"""
  378. return [
  379. {"seq": e.seq, "ts": e.ts, "level": e.level, "text": e.text}
  380. for e in log_buffer.all()
  381. ]
  382. @app.post("/api/bot/restart")
  383. async def restart_bot(_: None = Depends(require_auth)):
  384. threading.Thread(target=bot_mgr.restart, daemon=True).start()
  385. return {"ok": True, "note": "Bot 正在重启"}