feat: 通知发送层切换到群机器人 webhook, 排版精简

- wecom.py 重写: HTTP POST webhook(替代 aibot WebSocket 按需连接), 接口 send_markdown/send_text 不变, Secret Block wecom-webhook 存 URL
- webhook markdown 支持标题/引用/font 三色, 报告改为 '## 标题 + 单行结论 + 引用详情' 紧凑排版, 结论行用 info/warning 色
- alert_failure 改标题式排版; device_check 结论用 font 色, 去掉负载行
- 移除 wecom-aibot-sdk 依赖与 wecom_capture_chatid.py(镜像下次构建生效, 无需立即重建)
This commit is contained in:
yeuimu
2026-09-07 10:09:11 +00:00
parent c545c1b87d
commit 9b130097f9
8 changed files with 283 additions and 277 deletions
+3 -6
View File
@@ -102,14 +102,11 @@ def wecom_alert_failure(event: dict | str | None = None) -> dict:
ended = detail.get("end_time") or detail.get("expected_start_time")
occurred = occurred or ended
lines = [
f"🔴 **流程失败** {flow_name or resource_name or '未知'}",
]
if run_name and run_name != flow_name:
lines.append(f"> Run:{run_name}")
lines = [f"## 🔴 流程失败:{flow_name or resource_name or '未知'}"]
if run_name:
lines.append(f"> Run「{run_name}」 · {event_name.split('.')[-1]} · {_fmt_ts(occurred)}")
if state_msg:
lines.append(f"> 错误:{state_msg[:150].replace(chr(10), ' ')}")
lines.append(f"> {event_name} · {_fmt_ts(occurred)}")
if run_id:
lines.append(f"> [查看详情]({UI_BASE}/runs/flow-run/{run_id})")
else:
+19 -31
View File
@@ -27,7 +27,7 @@ def _affected_units(diff: dict) -> int:
def _system_lines(name: str, report: dict, goods: dict, lines: list[str]) -> bool:
"""单系统的核验两段:①数量是否一致(订单+商品) ②状态不符多少单。返回是否需关注。"""
"""单系统一行结论:数量(远程 vs 本地,一致只显示一个数)+商品;状态不符进引用行。"""
vr = (report.get("verifyReport") or {}) if isinstance(report, dict) else {}
days_r = vr.get("days") or []
post = vr.get("post")
@@ -36,35 +36,27 @@ def _system_lines(name: str, report: dict, goods: dict, lines: list[str]) -> boo
else:
remote_t = sum(d.get("remote", 0) for d in days_r)
local_t = sum(d.get("local", 0) for d in days_r)
# 数量一致只看总量;状态差异属第二段,不混入数量结论
ok = remote_t == local_t
ok = remote_t == local_t # 数量一致只看总量;状态差异属第二段,不混入数量结论
if ok:
lines.append(f"**{name} 订单** 远程 {remote_t:,} · 本地 {local_t:,} ✅")
else:
lines.append(f"**{name} 订单** 远程 {remote_t:,} · 本地 {local_t:,} ⚠️ 不一致")
g = goods or {}
zero = g.get("zeroGoodsOrders", 0)
goods_txt = f"{g.get('goods', 0):,} 行" + (f"(缺 {zero:,})" if zero else "")
order_txt = f"订单 {remote_t:,} ✅" if ok else f"订单 远程 {remote_t:,} / 本地 {local_t:,} ⚠️"
lines.append(f"**{name}** {order_txt} · 商品 {goods_txt} {'✅' if not zero else '⚠️'}")
diff_days = [d for d in days_r if not d.get("ok")]
diff_units = sum(_affected_units(d.get("diff")) for d in diff_days)
if diff_units:
lines.append(f"> 核验时发现约 {diff_units:,} 单状态不符({len(diff_days)} 天),已自动刷新修复")
g = goods or {}
zero = g.get("zeroGoodsOrders", 0)
if zero:
lines.append(f"**{name} 商品** {g.get('goods', 0):,} 行 · {zero:,} 单缺商品 ⚠️")
return True
lines.append(f"**{name} 商品** {g.get('goods', 0):,} 行 · 无缺漏 ✅")
return not ok
lines.append(f"> 状态不符约 {diff_units:,} 单({len(diff_days)} 天),已自动刷新修复")
return (not ok) or bool(zero)
def build_daily_message(
days: int, window: str, riin: dict, sds: dict,
goods: dict, ocr_today: dict, ocr_pending: int, failures: list[dict],
) -> tuple[str, bool]:
"""拼装日报 markdown,返回 (消息, 是否需关注)。"""
lines = [f"📊 **数据核验日报** {datetime.now().strftime('%m-%d %H:%M')}"
f"(近 {days} 天,{window})", ""]
"""拼装日报 markdown,返回 (消息, 是否需关注)。单行结论 + 引用详情的紧凑排版。"""
lines = [f"## 📊 数据核验日报 {datetime.now():%m-%d %H:%M}(近 {days} 天 {window})"]
attention = False
for name, report in (("RIIN", riin), ("SDS", sds)):
@@ -73,30 +65,26 @@ def build_daily_message(
except Exception as err: # noqa: BLE001
lines.append(f"**{name}** 核验结果解析失败:{err}")
attention = True
lines.append("")
processed, failed = (ocr_today or {}).get("processed", 0), (ocr_today or {}).get("failed", 0)
lines.append(f"**OCR 今日** 已处理 {processed:,} 单 · 识别失败 {failed:,} 单" + (" ✅" if not failed else ""))
ocr_txt = f"**OCR 今日** 处理 {processed:,} · 识别失败 {failed:,}" + (" ✅" if not failed else " ⚠️")
if ocr_pending:
lines.append(f"> 累计待处理 {ocr_pending:,} 单")
lines.append("")
ocr_txt += f"(累计待处理 {ocr_pending:,})"
lines.append(ocr_txt)
if failures:
attention = True
lines.append(f"**失败任务(24h)** 共 {len(failures)} 次 🔴")
lines.append(f"**失败任务(24h)** {len(failures)} 次 🔴")
for f in failures[:6]:
start = (f.get("start") or "")[5:] # MM-DD HH:MM
lines.append(f"> {f.get('flow', '?')} · run「{f.get('name', '?')}」{f.get('state', '?')} · {start}")
lines.append(f"> {f.get('flow', '?')}「{f.get('name', '?')}」{f.get('state', '?')} · {start}")
if len(failures) > 6:
lines.append(f"> …等共 {len(failures)} 次")
else:
lines.append("**失败任务(24h)** 0 次 ✅")
lines.append("**失败任务(24h)** 0 ✅")
lines.append("")
if attention:
lines.append("⚠️ **存在需关注项,见上**")
else:
lines.append("✅ **数据全部一致,无异常**")
lines.append('<font color="warning">⚠️ 存在需关注项,见上</font>' if attention
else '<font color="info">✅ 数据全部一致,无异常</font>')
return "\n".join(lines), attention
+42 -51
View File
@@ -291,11 +291,11 @@ def _check_services() -> dict:
def _mark(pct: float | None, threshold: int) -> str:
"""百分比展示:超阈值加粗+🔴(WeCom aibot markdown 不支持 font 标签和表格)。"""
"""百分比展示:超阈值用 warning 色加粗+🔴。"""
if pct is None:
return "—"
if pct >= threshold:
return f"**{pct:.0f}%**🔴"
return f'<font color="warning">**{pct:.0f}%** 🔴</font>'
return f"{pct:.0f}%"
@@ -343,104 +343,94 @@ def device_check(alarm_threshold: int = 80) -> dict:
print(f"[device-check] 磁盘检查失败: {err}")
threshold = alarm_threshold
header = f"🖥 **设备日报** {datetime.now():%m-%d %H:%M}(过去 24h · 采样 {agg['count']} 次"
header = f"## 🖥 设备日报 {datetime.now():%m-%d %H:%M}(24h 采样 {agg['count']} 次"
if agg.get("firstTs"):
first = datetime.fromisoformat(agg["firstTs"])
if first > datetime.now() - timedelta(hours=23):
header += f" · 自 {first:%m-%d %H:%M} 起"
lines = [header + ")", ""]
lines = [header + ")"]
# ---- 内存 / swap ----
mem, swap = agg.get("mem"), agg.get("swap")
if mem:
lines.append(f"**内存** 平均 {mem['avg']:.0f}% · 峰值 {_mark(mem['peak'], threshold)} · 最低 {mem['min']:.0f}%"
+ (f"(共 {_gb((agg.get('memTotal') or 0) * (1 << 30))})" if agg.get("memTotal") else ""))
total_txt = f"(共 {_gb((agg.get('memTotal') or 0) * (1 << 30))})" if agg.get("memTotal") else ""
lines.append(f"**内存** 均 {mem['avg']:.0f}% · 峰 {_mark(mem['peak'], threshold)} · 低 {mem['min']:.0f}%{total_txt}")
else:
try:
now_mem = _check_memory()
lines.append(f"**内存** 当前 {_mark(now_mem['pct'], threshold)}({now_mem['used']}/{now_mem['total']},无采样数据)")
lines.append(f"**内存** 当前 {_mark(now_mem['pct'], threshold)}({now_mem['used']}/{now_mem['total']},无采样)")
mem = {"peak": now_mem["pct"]}
except Exception: # noqa: BLE001
lines.append("**内存** 检查失败")
mem = None
if swap:
lines.append(f"**交换内存** 平均 {swap['avg']:.0f}% · 峰值 {_mark(swap['peak'], threshold)} · 最低 {swap['min']:.0f}%"
+ (f"(共 {_gb((agg.get('swapTotal') or 0) * (1 << 30))})" if agg.get("swapTotal") else ""))
total_txt = f"(共 {_gb((agg.get('swapTotal') or 0) * (1 << 30))})" if agg.get("swapTotal") else ""
lines.append(f"**交换内存** 均 {swap['avg']:.0f}% · 峰 {_mark(swap['peak'], threshold)} · 低 {swap['min']:.0f}%{total_txt}")
else:
lines.append("**交换内存** 无采样数据")
swap = None
# ---- 磁盘:总体 / 根目录 / home ----
lines.append("**磁盘**")
if overall:
lines.append(f"> 总体 已用 {_gb(overall['used'])} · 剩余 {_gb(overall['free'])}({_mark(overall['pct'], threshold)})")
lines.append(f"**磁盘** 总体 已用 {_gb(overall['used'])} · 剩 {_gb(overall['free'])}"
f"({_mark(overall['pct'], threshold)})")
else:
lines.append("**磁盘** 检查不可用")
label_map = {"/": "root", "/home": "home"}
for m in mounts:
g = growth.get(label_map.get(m["label"], ""))
growth_txt = ""
if g and g.get("dir"):
growth_txt = f"|增长最快 {g['dir']} {_fmt_growth(g['growth'])}"
elif m["label"] in ("/", "/home"):
growth_txt = "|增长数据不足" if not g else ""
lines.append(f"> {m['label']} 已用 {_gb(m['used'])} · 剩余 {_gb(m['free'])}"
f"({_mark(m['pct'], threshold)}){growth_txt}")
if not mounts:
lines.append("> 磁盘检查不可用")
growth_txt = f" · 增长最快 {g['dir']} {_fmt_growth(g['growth'])}" if g and g.get("dir") else ""
lines.append(f"> {m['label']} {_mark(m['pct'], threshold)} · 已用 {_gb(m['used'])} · 剩 {_gb(m['free'])}{growth_txt}")
# ---- GPU ----
gpu_stats = agg.get("gpus") or []
if gpu_stats:
for g in gpu_stats:
name = f"{g['name']}(GPU{g['index']})" if g.get("name") else f"GPU{g['index']}"
name = g.get("name") or f"GPU{g['index']}"
util = g.get("util")
util_txt = f"利用率 平均 {util['avg']:.0f}% · 峰值 {util['peak']:.0f}% · 最低 {util['min']:.0f}%" if util else "利用率 无数据"
mem_peak = g.get("memPeakUsed")
mem_total = g.get("memTotal")
util_txt = (f"利用率 均 {util['avg']:.0f}% · 峰 {util['peak']:.0f}% · 低 {util['min']:.0f}%"
if util else "利用率 无数据")
mem_peak, mem_total = g.get("memPeakUsed"), g.get("memTotal")
if mem_peak is not None and mem_total:
mem_pct = mem_peak / mem_total * 100
mem_txt = f"显存峰值 {_gb(mem_peak * (1 << 30))}/{_gb(mem_total * (1 << 30))}({_mark(mem_pct, threshold)})"
mem_txt = (f"显存峰 {_gb(mem_peak * (1 << 30))}/{_gb(mem_total * (1 << 30))}"
f"({_mark(mem_peak / mem_total * 100, threshold)})")
else:
mem_txt = "显存 无数据"
temp_peak = g.get("tempPeak")
if temp_peak is not None:
temp_txt = f"温度峰值 {temp_peak:.0f}°C" + ("🔴 过热" if temp_peak >= GPU_TEMP_WARN else " 正常 ✅")
else:
temp_txt = "温度 无数据"
lines.append(f"**GPU** {name}|{util_txt}|{mem_txt}|{temp_txt}")
temp_txt = (f"温度峰 {temp_peak:.0f}°C" + (" 🔴过热" if temp_peak >= GPU_TEMP_WARN else " ✅")
if temp_peak is not None else "温度 无数据")
lines.append(f"**GPU** {name} {util_txt} | {mem_txt} | {temp_txt}")
else:
gpus_now = _check_gpus()
if isinstance(gpus_now, list) and gpus_now:
for g in gpus_now:
lines.append(f"**GPU** {g['name']}|利用率 {g['utilPct']:.0f}%|显存 {g['memUsed']}/{g['memTotal']}"
f"(无采样数据)")
lines.append(f"**GPU** {g['name']} 利用率 {g['utilPct']:.0f}% · 显存 {g['memUsed']}/{g['memTotal']}(无采样)")
else:
lines.append(f"**GPU** {gpus_now if isinstance(gpus_now, str) else '未检测到'}")
# ---- Docker / 失败任务 / 探活 ----
# ---- Docker / 探活 / 失败任务 ----
docker = checks.get("docker") or {}
issues = docker.get("issues") or []
svc = checks.get("services") or {}
if docker.get("running") is None:
lines.append(f"**Docker** {issues[0]['issue'] if issues else '不可用'}")
docker_txt = issues[0]["issue"] if issues else "不可用"
else:
docker_line = f"**Docker** 运行 {docker['running']}/{docker.get('total', '?')}"
if docker.get("stopped"):
docker_line += f" · 已停用 {docker['stopped']}"
docker_line += f" · 异常 **{len(issues)}**🔴" if issues else " · 异常 0"
lines.append(docker_line)
docker_txt = f"运行 {docker['running']}/{docker.get('total', '?')} · 异常 {len(issues)}" + ("🔴" if issues else "")
if svc:
docker_txt += f" | OCR {'✅' if svc.get('ocr') else '🔴'} | PG {'✅' if svc.get('pg') else '🔴'}"
lines.append(f"**Docker** {docker_txt}")
for it in issues[:8]:
lines.append(f"> 🔴 {it['name']}:{it['issue']}")
failures = checks.get("prefectFailures") or []
lines.append(f"**失败任务(24h)** {len(failures)} 次" + (" ✅" if not failures else "🔴"))
for f in failures[:5]:
lines.append(f"> 🔴 {f['flow']} ·「{f['name']}」{f['state']} · {f['start']}")
if len(failures) > 5:
lines.append(f"> …共 {len(failures)} 次")
svc = checks.get("services") or {}
if svc:
lines.append(f"**探活** OCR {'✅' if svc.get('ocr') else '🔴'}|PG {'✅' if svc.get('pg') else '🔴'}"
f"|负载 {os.getloadavg()[0]:.1f}")
if failures:
lines.append(f"**失败任务(24h)** {len(failures)} 次 🔴")
for f in failures[:5]:
lines.append(f"> {f['flow']}「{f['name']}」{f['state']} · {(f.get('start') or '')[5:]}")
if len(failures) > 5:
lines.append(f"> …共 {len(failures)} 次")
else:
lines.append("**失败任务(24h)** 0 ✅")
mem_peak_for_alarm = mem.get("peak") if mem else None
swap_peak_for_alarm = swap.get("peak") if swap else None
@@ -452,7 +442,8 @@ def device_check(alarm_threshold: int = 80) -> dict:
or any((g.get("tempPeak") or 0) >= GPU_TEMP_WARN for g in gpu_stats)
or bool(issues) or bool(failures) or not svc.get("ocr") or not svc.get("pg")
)
lines += ["", ("🔴 **有项目需要处理,见上**" if alarms else "✅ **一切正常**")]
lines.append('<font color="warning">🔴 有项目需要处理,见上</font>' if alarms
else '<font color="info">✅ 一切正常</font>')
content = "\n".join(lines)
sent = send_markdown(content)
+52 -56
View File
@@ -1,58 +1,44 @@
"""企业微信智能机器人通知层(wecom-aibot-sdk,WebSocket 按需连接)。
"""企微群机器人通知层(消息推送 webhook,HTTP POST,无状态)。
连接模式:每次发消息 建立→等认证→发送→断开,无状态,契合 flow run 级部署模型。
凭据存 Prefect Secret Block `wecom-aibot-credentials`,JSON:
{"bot_id": "...", "secret": "...", "chat_id": "..."}
chat_id 为目标群会话 ID(用 scripts/wecom_capture_chatid.py 一次性捕获)。
文档:https://developer.work.weixin.qq.com/document/path/99110
凭据存 Prefect Secret Block `wecom-webhook`(值为完整 webhook URL,含 key);
本地调试可用环境变量 WECOM_WEBHOOK_URL 覆盖。
markdown 支持:1-6 级标题/加粗/链接/引用/<font color="info|comment|warning">(不支持表格);
单条 ≤4096 字节,超长自动按行分段;频率限制 20 条/分钟。
所有函数容错——发送失败只 print 警告并返回 False,绝不中断 flow(对齐 artifacts.py 风格)。
"""
from __future__ import annotations
import asyncio
import json
import os
from prefect.blocks.system import Secret
import httpx
SECRET_BLOCK = "wecom-aibot-credentials"
MAX_CONTENT_BYTES = 4000 # WeCom markdown 单条约 4096 字节上限,留余量
WEBHOOK_BLOCK = "wecom-webhook"
MAX_MARKDOWN_BYTES = 4000 # 官方上限 4096,留余量
MAX_TEXT_BYTES = 2000 # text 类型上限 2048,留余量
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
def load_webhook_url() -> str:
url = os.environ.get("WECOM_WEBHOOK_URL", "").strip()
if not url:
from prefect.blocks.system import Secret
url = (Secret.load(WEBHOOK_BLOCK).get() or "").strip()
if not url:
raise ValueError(f"webhook 地址为空(Secret Block {WEBHOOK_BLOCK} 或 WECOM_WEBHOOK_URL)")
return url
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:
def _split_bytes(content: str, limit: int) -> list[str]:
"""按字节预算切段,优先在换行处切。"""
if len(content.encode("utf-8")) <= limit:
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:
if cur and len((cur + line).encode("utf-8")) > limit:
parts.append(cur)
cur = line
else:
@@ -62,26 +48,36 @@ def _split_markdown(content: str) -> list[str]:
return parts
def send_markdown(content: str) -> bool:
"""发送 markdown 消息到目标群(超长自动分段)。失败返回 False,不抛异常。"""
def _post(url: str, payload: dict) -> bool:
resp = httpx.post(url, json=payload, timeout=15)
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}")
data = resp.json()
except ValueError:
data = {}
if resp.status_code != 200 or data.get("errcode") != 0:
print(f"[wecom] 发送失败: HTTP {resp.status_code} {data or resp.text[:100]}")
return False
return True
def _send(content: str, msgtype: str, limit: int) -> bool:
try:
url = load_webhook_url()
parts = _split_bytes(content, limit)
ok = all(_post(url, {"msgtype": msgtype, msgtype: {"content": part}}) for part in parts)
if ok and len(parts) > 1:
print(f"[wecom] {msgtype} 已发送 ({len(parts)} 段)")
return ok
except Exception as err: # noqa: BLE001
print(f"[wecom] {msgtype} 发送失败: {err}")
return False
def send_markdown(content: str) -> bool:
"""发送 markdown 到群(超长自动分段)。失败返回 False,不抛异常。"""
return _send(content, "markdown", MAX_MARKDOWN_BYTES)
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
"""发送纯文本到群。失败返回 False,不抛异常。"""
return _send(content, "text", MAX_TEXT_BYTES)