Files
ai-balance-monitor/app/monitor.py
T

192 lines
7.0 KiB
Python

"""监控调度器:线程池并发轮询所有启用账号。
调度逻辑:
- 每个账号记录 next_check_at(epoch 秒),间隔取平台覆盖值或全局默认
- 调度线程每秒扫描一次,到期的账号且无进行中任务则提交线程池
- 任务完成后把 next_check_at 更新为 now + interval,避免重复执行
"""
from __future__ import annotations
import logging
import threading
import time
from concurrent.futures import Future, ThreadPoolExecutor
from app import db
from app.alert import evaluate, notify_disabled
from app.config import Config
from app.fetcher import fetch_balance
logger = logging.getLogger("monitor.scheduler")
# JOIN 查询后拆分为账号/平台两个 dict 用到的字段
_ACCOUNT_FIELDS = [
"id", "platform_id", "name", "api_key", "threshold", "enabled",
"alert_armed", "last_balance", "last_status", "last_error",
"last_check_at", "note",
]
_PLATFORM_FIELDS = [
"platform_id", "platform_name", "currency", "icon", "method", "url",
"headers", "body", "balance_path", "interval_seconds",
"retry_count", "timeout_seconds", "platform_enabled", "note",
]
_ACCOUNT_SQL = """
SELECT a.*,
p.name AS platform_name, p.currency, p.icon, p.method, p.url,
p.headers, p.body, p.balance_path, p.interval_seconds,
p.retry_count, p.timeout_seconds, p.enabled AS platform_enabled,
p.note AS platform_note
FROM accounts a JOIN platforms p ON p.id = a.platform_id
"""
def _split(row: dict) -> tuple[dict, dict]:
"""把 JOIN 行拆成 (account, platform) 两个 dict。"""
account = {k: row[k] for k in _ACCOUNT_FIELDS if k in row}
platform = {k: row[k] for k in _PLATFORM_FIELDS if k in row}
platform["name"] = platform.pop("platform_name")
platform["id"] = platform.pop("platform_id")
platform["enabled"] = platform.pop("platform_enabled")
return account, platform
def _account_interval(platform: dict, cfg: Config) -> float:
return float(platform.get("interval_seconds") or cfg.global_interval_seconds)
class Monitor:
def __init__(self, cfg: Config) -> None:
self.cfg = cfg
self._stop = threading.Event()
self._pool: ThreadPoolExecutor | None = None
self._thread: threading.Thread | None = None
self._next_check: dict[int, float] = {}
self._inflight: set[int] = set()
self._lock = threading.Lock()
# ---------- 生命周期 ----------
def start(self) -> None:
if self._thread is not None:
return
self._pool = ThreadPoolExecutor(max_workers=self.cfg.max_workers, thread_name_prefix="check")
self._thread = threading.Thread(target=self._loop, name="monitor-loop", daemon=True)
self._thread.start()
logger.info(
"监控调度已启动,全局间隔 %ss,并发 %s",
self.cfg.global_interval_seconds,
self.cfg.max_workers,
)
def stop(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=5)
if self._pool is not None:
self._pool.shutdown(wait=False, cancel_futures=True)
logger.info("监控调度已停止")
# ---------- 调度循环 ----------
def _loop(self) -> None:
while not self._stop.is_set():
try:
self._dispatch()
except Exception:
logger.exception("调度循环异常")
self._stop.wait(1)
def _dispatch(self) -> None:
with db.get_conn() as conn:
rows = conn.execute(
_ACCOUNT_SQL + " WHERE a.enabled = 1 AND p.enabled = 1"
).fetchall()
now = time.time()
for row in rows:
data = dict(row)
aid = data["id"]
with self._lock:
if aid in self._inflight:
continue
if now < self._next_check.get(aid, 0):
continue
self._inflight.add(aid)
assert self._pool is not None
future = self._pool.submit(self._check_one, data)
future.add_done_callback(lambda f, a=aid: self._on_done(a, f))
def _on_done(self, account_id: int, future: Future) -> None:
with self._lock:
self._inflight.discard(account_id)
if future.exception():
logger.error("账号 %s 检查任务异常: %s", account_id, future.exception())
# ---------- 单次检查 ----------
def _check_one(self, row: dict) -> None:
cfg = self.cfg
account, platform = _split(row)
retry = platform.get("retry_count")
if retry is None:
retry = cfg.retry_count
timeout = platform.get("timeout_seconds")
if timeout is None:
timeout = cfg.timeout_seconds
result = fetch_balance(platform, account["api_key"], int(retry), int(timeout))
with db.get_conn() as conn:
if result.ok:
conn.execute(
"UPDATE accounts SET last_balance=?, last_status='ok', last_error='', "
"last_check_at=datetime('now','localtime') WHERE id=?",
(result.balance, account["id"]),
)
conn.execute(
"INSERT INTO balance_history (account_id, balance) VALUES (?, ?)",
(account["id"], result.balance),
)
elif result.auth_error:
conn.execute(
"UPDATE accounts SET enabled=0, last_status='disabled', last_error=?, "
"last_check_at=datetime('now','localtime') WHERE id=?",
(result.error, account["id"]),
)
else:
conn.execute(
"UPDATE accounts SET last_status='error', last_error=?, "
"last_check_at=datetime('now','localtime') WHERE id=?",
(result.error, account["id"]),
)
if result.auth_error:
notify_disabled(account, platform, cfg)
elif result.ok:
evaluate(account, platform, result.balance, cfg)
# 调度:无论成功失败,按间隔排下一次
with self._lock:
self._next_check[account["id"]] = time.time() + _account_interval(platform, cfg)
# ---------- 手动触发 ----------
def check_now(self, account_id: int) -> bool:
"""API 手动立即检查;账号/平台未启用或已在检查中则返回 False。"""
with db.get_conn() as conn:
row = conn.execute(
_ACCOUNT_SQL + " WHERE a.id = ? AND a.enabled = 1 AND p.enabled = 1",
(account_id,),
).fetchone()
if row is None:
return False
data = dict(row)
with self._lock:
if account_id in self._inflight:
return False
self._inflight.add(account_id)
assert self._pool is not None
future = self._pool.submit(self._check_one, data)
future.add_done_callback(lambda f, a=account_id: self._on_done(a, f))
return True