Files
gaokao-volunteer-system/data/channel_sync/monitor.py
Hermes Agent 3f46811e49
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
release: cut v2.1
2026-06-13 14:49:58 +08:00

369 lines
12 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.
"""渠道兜底巡检 (T8.4).
目标:给 ops 一个可定时执行的健康检查入口,基于本地 SQLite 中已经存在的
webhook_audit / poller_state / poller_run 事实数据,判断渠道同步链路是否进入
降级态,并给出人工兜底建议。
不做:
- 不直接调用外部渠道 API避免引入额外依赖 / 凭证)
- 不自动创建订单(人工兜底仍由 order-manager CLI 明确执行)
"""
from __future__ import annotations
import argparse
import json
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
DEFAULT_DB_PATH = Path("data/orders.db")
_STATUS_EXIT_CODE = {"ok": 0, "warn": 1, "critical": 2}
def _utcnow() -> datetime:
return datetime.now(timezone.utc).replace(microsecond=0)
def _parse_iso(value: str | None) -> datetime | None:
if not value:
return None
text = str(value).strip()
if not text:
return None
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
dt = datetime.fromisoformat(text)
except ValueError:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def _minutes_ago(now: datetime, ts: str | None) -> float | None:
dt = _parse_iso(ts)
if dt is None:
return None
return round((now - dt).total_seconds() / 60.0, 2)
def _open_db(db_path: str) -> sqlite3.Connection:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
return conn
def _table_exists(conn: sqlite3.Connection, name: str) -> bool:
row = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", (name,)
).fetchone()
return row is not None
def _fetch_one(
conn: sqlite3.Connection, sql: str, params: tuple[Any, ...]
) -> dict[str, Any] | None:
row = conn.execute(sql, params).fetchone()
return dict(row) if row is not None else None
def _manual_actions(db_path: str, source: str) -> list[str]:
return [
(
"先执行巡检命令确认状态:"
f"python3 scripts/gaokao-channel-fallback --db {db_path} check --source {source} --human"
),
(
"若确认 webhook/poller 不可用,使用人工补录 CLI 新建订单:"
f"python3 scripts/gaokao-order-manager --db {db_path} create --source {source} "
"--service-version basic --amount-cents 0 --customer-name <姓名> --customer-phone <手机号>"
),
(
"人工补录后按业务推进状态:"
f"python3 scripts/gaokao-order-manager --db {db_path} pay <ORDER_ID>"
" / deliver <ORDER_ID>"
),
]
def summarize_channel_health(
db_path: str,
*,
source: str = "xianyu",
now: datetime | None = None,
poller_stale_minutes: int = 15,
webhook_stale_minutes: int = 30,
recent_window_minutes: int = 60,
reject_warn_threshold: int = 5,
poller_error_warn_threshold: int = 3,
) -> dict[str, Any]:
now = now or _utcnow()
conn = _open_db(db_path)
try:
has_audit = _table_exists(conn, "webhook_audit")
has_state = _table_exists(conn, "poller_state")
has_run = _table_exists(conn, "poller_run")
latest_accepted = None
latest_recent = None
recent_counts = {
"accepted": 0,
"duplicate": 0,
"parse_error": 0,
"rejected": 0,
}
if has_audit:
latest_accepted = _fetch_one(
conn,
"""
SELECT received_at, decision, event_id, reject_reason
FROM webhook_audit
WHERE channel=? AND decision='accepted'
ORDER BY id DESC LIMIT 1
""",
(source,),
)
latest_recent = _fetch_one(
conn,
"""
SELECT received_at, decision, event_id, reject_reason
FROM webhook_audit
WHERE channel=?
ORDER BY id DESC LIMIT 1
""",
(source,),
)
since_iso = (now - timedelta(minutes=recent_window_minutes)).isoformat()
for row in conn.execute(
"""
SELECT decision, COUNT(*) AS n
FROM webhook_audit
WHERE channel=? AND received_at>=?
GROUP BY decision
""",
(source, since_iso),
).fetchall():
decision = str(row["decision"])
if decision in recent_counts:
recent_counts[decision] = int(row["n"] or 0)
state = None
if has_state:
state = _fetch_one(
conn,
"""
SELECT source, last_cursor, last_run_at, last_error, run_count, error_count
FROM poller_state WHERE source=?
""",
(source,),
)
last_run = None
if has_run:
last_run = _fetch_one(
conn,
"""
SELECT started_at, finished_at, fetched, inserted, updated, unchanged,
rejected, error_message
FROM poller_run
WHERE source=?
ORDER BY id DESC LIMIT 1
""",
(source,),
)
findings: list[str] = []
actions: list[str] = []
status = "ok"
def raise_status(next_status: str) -> None:
nonlocal status
order = {"ok": 0, "warn": 1, "critical": 2}
if order[next_status] > order[status]:
status = next_status
if not has_audit:
raise_status("warn")
findings.append("缺少 webhook_audit 表,无法判断 webhook 接收健康度")
if not has_state:
raise_status("warn")
findings.append("缺少 poller_state 表,无法判断兜底轮询状态")
if not has_run:
raise_status("warn")
findings.append("缺少 poller_run 表,无法回看最近轮询结果")
accepted_age = _minutes_ago(
now, latest_accepted["received_at"] if latest_accepted else None
)
recent_age = _minutes_ago(
now, latest_recent["received_at"] if latest_recent else None
)
poller_age = _minutes_ago(now, state["last_run_at"] if state else None)
if state and state.get("last_error"):
error_count = int(state.get("error_count") or 0)
if error_count >= poller_error_warn_threshold:
raise_status("critical")
findings.append(
f"poller 连续错误计数={error_count}last_error={state['last_error']}"
)
else:
raise_status("warn")
findings.append(f"poller 最近一次报错:{state['last_error']}")
if last_run and last_run.get("error_message"):
raise_status("critical")
findings.append(f"最近一次 poller_run 失败:{last_run['error_message']}")
rejected_total = recent_counts["rejected"] + recent_counts["parse_error"]
if rejected_total >= reject_warn_threshold:
raise_status("warn")
findings.append(
f"最近 {recent_window_minutes} 分钟 webhook 拒绝/解析失败 {rejected_total}"
)
if poller_age is not None and poller_age > poller_stale_minutes:
raise_status("warn")
findings.append(
f"poller 最近运行距今 {poller_age} 分钟,超过阈值 {poller_stale_minutes} 分钟"
)
if (
accepted_age is not None
and accepted_age > webhook_stale_minutes
and recent_counts["rejected"] + recent_counts["parse_error"] > 0
):
raise_status("warn")
findings.append(
f"最近成功 webhook 距今 {accepted_age} 分钟,且窗口内存在失败事件"
)
if latest_accepted is None and state is None and last_run is None:
raise_status("warn")
findings.append(
"尚无 webhook accepted / poller_state / poller_run 证据,链路可能未启动"
)
if status != "ok":
actions = _manual_actions(db_path, source)
elif not findings:
findings.append("最近 webhook/poller 未发现明显异常")
payload = {
"status": status,
"checked_at": now.isoformat(),
"source": source,
"db_path": db_path,
"tables": {
"webhook_audit": has_audit,
"poller_state": has_state,
"poller_run": has_run,
},
"webhook": {
"latest_accepted": latest_accepted,
"latest_event": latest_recent,
"latest_accepted_age_minutes": accepted_age,
"latest_event_age_minutes": recent_age,
"recent_counts": recent_counts,
},
"poller": {
"state": state,
"last_run": last_run,
"last_run_age_minutes": poller_age,
},
"findings": findings,
"recommended_actions": actions,
}
return payload
finally:
conn.close()
def _emit(payload: dict[str, Any], *, human: bool) -> None:
if not human:
print(json.dumps(payload, ensure_ascii=False, indent=2))
return
print(f"status: {payload['status']}")
print(f"source: {payload['source']}")
print(f"checked_at: {payload['checked_at']}")
print(f"db_path: {payload['db_path']}")
print("findings:")
for item in payload.get("findings", []):
print(f"- {item}")
actions = payload.get("recommended_actions", [])
if actions:
print("recommended_actions:")
for item in actions:
print(f"- {item}")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="gaokao-channel-fallback",
description="渠道兜底巡检 CLIT8.4",
)
parser.add_argument(
"--db",
default=str(DEFAULT_DB_PATH),
help="订单 SQLite 路径(默认 data/orders.db",
)
parser.add_argument(
"--human",
action="store_true",
help="终端友好输出(默认 JSON",
)
subparsers = parser.add_subparsers(dest="command", required=True)
check = subparsers.add_parser("check", help="检查 webhook/poller 健康度")
check.add_argument("--source", default="xianyu", help="渠道 source默认 xianyu")
check.add_argument("--human", action="store_true", help=argparse.SUPPRESS)
check.add_argument("--poller-stale-minutes", type=int, default=15)
check.add_argument("--webhook-stale-minutes", type=int, default=30)
check.add_argument("--recent-window-minutes", type=int, default=60)
check.add_argument("--reject-warn-threshold", type=int, default=5)
check.add_argument("--poller-error-warn-threshold", type=int, default=3)
manual = subparsers.add_parser("manual-template", help="输出人工兜底建议命令")
manual.add_argument("--source", default="xianyu", help="渠道 source默认 xianyu")
manual.add_argument("--human", action="store_true", help=argparse.SUPPRESS)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
human = bool(getattr(args, "human", False))
if args.command == "manual-template":
payload = {
"status": "warn",
"checked_at": _utcnow().isoformat(),
"source": args.source,
"db_path": args.db,
"findings": ["人工兜底模板"],
"recommended_actions": _manual_actions(args.db, args.source),
}
_emit(payload, human=human)
return 0
payload = summarize_channel_health(
args.db,
source=args.source,
poller_stale_minutes=args.poller_stale_minutes,
webhook_stale_minutes=args.webhook_stale_minutes,
recent_window_minutes=args.recent_window_minutes,
reject_warn_threshold=args.reject_warn_threshold,
poller_error_warn_threshold=args.poller_error_warn_threshold,
)
_emit(payload, human=human)
return _STATUS_EXIT_CODE[payload["status"]]
if __name__ == "__main__":
raise SystemExit(main())