feat: 接入企微智能机器人——异常报警/数据核验日报/设备健康日报

- flows/shared/wecom.py: WSClient 按需连接(连接→认证→发送→断开),全容错,超长分段
- flows/monitor/alert_failure.py: Prefect 事件触发(Failed/Crashed→run deployment),发群报警
- flows/monitor/daily_report.py: 每日 18:30 近7天 RIIN/SDS 核验(复用 verify)+商品完整性+OCR+失败回顾
- flows/monitor/device_check.py: 每日 09:00 内存/磁盘/GPU(80%阈值)+Docker 容器异常+服务探活
- db.py: 新增 get_goods_integrity/ping; compose worker 挂载 docker.sock+/:/host:ro+GPU
- scripts/wecom_capture_chatid.py: 一次性捕获群 chat_id 工具
This commit is contained in:
yeuimu
2026-08-27 09:40:53 +00:00
parent b9e0bc42be
commit 1d0da2a00b
12 changed files with 836 additions and 0 deletions
+2
View File
@@ -17,6 +17,7 @@ from flows.shared.clients import (
)
from flows.shared.db import SyncDB
from flows.shared.mappers import GoodsUpsertInput, OrderUpsertInput
from flows.shared.wecom import send_markdown, send_text
__all__ = [
"clients", "db", "mappers", "SyncDB",
@@ -26,4 +27,5 @@ __all__ = [
"get_channel_type_map", "refresh_channel_type_map", "resolve_courier_source",
"format_date", "start_of_day", "floor_to_hour",
"build_stats_rows", "emit_sync_summary", "emit_verify_report",
"send_markdown", "send_text",
]
+45
View File
@@ -86,6 +86,51 @@ class SyncDB:
row = cur.fetchone()
return int(row[0]) if row else 0
def get_goods_integrity(self, system_name: str, start: datetime, end: datetime) -> dict[str, int]:
"""商品数据完整性(与核验同一时间窗,按 orderedAt):
- orders: 窗口内订单数
- goods: 窗口内商品行数
- zeroGoodsOrders: 窗口内没有任何商品行的订单数(可疑缺拉)
- goodsNoName: 窗口内商品 itemName 为空的行数
- orphanGoods: 全库没有对应订单的商品行数(脏数据)
"""
with self._conn() as conn:
with conn.cursor() as cur:
cur.execute(
'SELECT COUNT(*), '
'COALESCE(SUM(CASE WHEN NOT EXISTS ('
' SELECT 1 FROM "Goods" g WHERE g."orderNo"=o."orderNo" AND g."createdAt"=o."createdAt"'
') THEN 1 ELSE 0 END), 0) '
'FROM "Orders" o WHERE o."systemName"=%s AND o."orderedAt">=%s AND o."orderedAt"<%s',
(system_name, start, end),
)
orders, zero_goods = cur.fetchone()
cur.execute(
'SELECT COUNT(*), COALESCE(SUM(CASE WHEN "itemName" IS NULL OR "itemName"=\'\' '
'THEN 1 ELSE 0 END), 0) FROM "Goods" g JOIN "Orders" o '
'ON g."orderNo"=o."orderNo" AND g."createdAt"=o."createdAt" '
'WHERE o."systemName"=%s AND o."orderedAt">=%s AND o."orderedAt"<%s',
(system_name, start, end),
)
goods, no_name = cur.fetchone()
cur.execute(
'SELECT COUNT(*) FROM "Goods" g WHERE NOT EXISTS '
'(SELECT 1 FROM "Orders" o WHERE o."orderNo"=g."orderNo")',
)
orphan = cur.fetchone()[0]
return {
"orders": int(orders), "goods": int(goods),
"zeroGoodsOrders": int(zero_goods), "goodsNoName": int(no_name), "orphanGoods": int(orphan),
}
def ping(self) -> str:
"""连通性检查,返回 PG 版本号(供监控探活)。"""
with self._conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT version()")
row = cur.fetchone()
return (row[0] or "").split(",")[0] if row else ""
def get_orders_with_100_prefix(self, system_name: str, statuses: list[str]) -> list[tuple[str, datetime]]:
with self._conn() as conn:
with conn.cursor() as cur:
+87
View File
@@ -0,0 +1,87 @@
"""企业微信智能机器人通知层(wecom-aibot-sdk,WebSocket 按需连接)。
连接模式:每次发消息 建立→等认证→发送→断开,无状态,契合 flow run 级部署模型。
凭据存 Prefect Secret Block `wecom-aibot-credentials`,JSON:
{"bot_id": "...", "secret": "...", "chat_id": "..."}
chat_id 为目标群会话 ID(用 scripts/wecom_capture_chatid.py 一次性捕获)。
所有函数容错——发送失败只 print 警告并返回 False,绝不中断 flow(对齐 artifacts.py 风格)。
"""
from __future__ import annotations
import asyncio
import json
from prefect.blocks.system import Secret
SECRET_BLOCK = "wecom-aibot-credentials"
MAX_CONTENT_BYTES = 4000 # WeCom markdown 单条约 4096 字节上限,留余量
def load_credentials() -> dict[str, str]:
value = Secret.load(SECRET_BLOCK).get()
if isinstance(value, str):
value = json.loads(value)
missing = [k for k in ("bot_id", "secret", "chat_id") if not value.get(k)]
if missing:
raise ValueError(f"Secret Block {SECRET_BLOCK} 缺少字段: {missing}")
return value
async def _send_bodies(creds: dict[str, str], bodies: list[dict]) -> None:
from wecom_aibot_sdk import WSClient
ws = WSClient(bot_id=creds["bot_id"], secret=creds["secret"])
auth_ok = asyncio.Event()
ws.on("authenticated", lambda: auth_ok.set())
await asyncio.wait_for(ws.connect(), timeout=15)
try:
# connect() 只等到认证帧发出;认证结果由 authenticated 事件确认
await asyncio.wait_for(auth_ok.wait(), timeout=10)
for body in bodies:
await ws.send_message(creds["chat_id"], body)
finally:
await ws.disconnect()
def _split_markdown(content: str) -> list[str]:
"""按字节预算切段,优先在换行处切,避免拆开表格行。"""
if len(content.encode("utf-8")) <= MAX_CONTENT_BYTES:
return [content]
parts: list[str] = []
cur = ""
for line in content.splitlines(keepends=True):
if cur and len((cur + line).encode("utf-8")) > MAX_CONTENT_BYTES:
parts.append(cur)
cur = line
else:
cur += line
if cur:
parts.append(cur)
return parts
def send_markdown(content: str) -> bool:
"""发送 markdown 消息到目标群(超长自动分段)。失败返回 False,不抛异常。"""
try:
creds = load_credentials()
parts = _split_markdown(content)
asyncio.run(_send_bodies(creds, [{"msgtype": "markdown", "markdown": {"content": p}} for p in parts]))
if len(parts) > 1:
print(f"[wecom] markdown 已发送 ({len(parts)} 段)")
return True
except Exception as err: # noqa: BLE001
print(f"[wecom] markdown 发送失败: {err}")
return False
def send_text(content: str) -> bool:
"""发送纯文本消息到目标群。失败返回 False,不抛异常。"""
try:
creds = load_credentials()
asyncio.run(_send_bodies(creds, [{"msgtype": "text", "text": {"content": content}}]))
return True
except Exception as err: # noqa: BLE001
print(f"[wecom] text 发送失败: {err}")
return False