62b30342ed
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
244 lines
9.3 KiB
Python
244 lines
9.3 KiB
Python
"""AlertHit[] → 飞书群机器人消息。
|
||
|
||
提供三个格式化函数:
|
||
- format_alert_message: 纯文本(保留,已有集成测试依赖)。
|
||
- format_alert_post: 富文本 post(行式明细:时间|手机|版本|原因|trace 超链接)。
|
||
- format_alert_card: schema 2.0 卡片 + table 组件(正式发送格式)。
|
||
|
||
按触发类型分组,每类给计数 + 明细(trace/版本/原因)。两级截断防报警风暴:单类型超
|
||
max_detail_per_type 只列前 N + 「另有 M 条」;本期总量超 max_total 只给各类型计数、提示去分析库查。
|
||
标题含关键词「比价失败报警」——飞书自定义机器人用关键词验证,消息必须含它,否则被拒收。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from app.services.compare_alert import ALERT_TYPE_LABELS, AlertHit
|
||
|
||
ALERT_KEYWORD = "比价失败报警"
|
||
|
||
_TYPE_ORDER = ("T1", "T6", "T2", "T5")
|
||
|
||
|
||
def _detail_line(h: AlertHit) -> str:
|
||
ver = h.app_version or "?"
|
||
return f" - trace {h.trace_id} | {ver} | {h.reason}"
|
||
|
||
|
||
def format_alert_message(
|
||
hits: list[AlertHit],
|
||
*,
|
||
window_label: str,
|
||
max_detail_per_type: int,
|
||
max_total: int,
|
||
) -> str:
|
||
total = len(hits)
|
||
header = f"🚨 {ALERT_KEYWORD} · {window_label} · 本期触发 {total} 条"
|
||
|
||
grouped: dict[str, list[AlertHit]] = {t: [] for t in _TYPE_ORDER}
|
||
for h in hits:
|
||
grouped.setdefault(h.alert_type, []).append(h)
|
||
|
||
lines = [header]
|
||
counts_only = total > max_total
|
||
for t in _TYPE_ORDER:
|
||
bucket = grouped.get(t) or []
|
||
if not bucket:
|
||
continue
|
||
lines.append(f"• {ALERT_TYPE_LABELS[t]} {len(bucket)} 条")
|
||
if counts_only:
|
||
continue
|
||
shown = bucket[:max_detail_per_type]
|
||
lines.extend(_detail_line(h) for h in shown)
|
||
if len(bucket) > max_detail_per_type:
|
||
lines.append(f" …另有 {len(bucket) - max_detail_per_type} 条")
|
||
if counts_only:
|
||
lines.append(f"(本期命中超 {max_total} 条,仅列计数,明细见分析库 comparison_record)")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def format_alert_post(
|
||
hits: list[AlertHit],
|
||
*,
|
||
window_label: str,
|
||
phone_map: dict[int, str],
|
||
max_detail_per_type: int,
|
||
max_total: int,
|
||
) -> tuple[str, list]:
|
||
"""行式富文本:返回 (title, content)。title 含 ALERT_KEYWORD(飞书关键词验证)。
|
||
|
||
content: 摘要段(各类型计数) + 表头段 + 明细行(每条「时间|手机|版本|原因|」+ trace 超链接 a 元素)。
|
||
phone_map: {user_id: phone};明细手机号取 phone_map.get(hit.user_id) or "-"。
|
||
截断规则:总命中 > max_total → 只出摘要+各类型计数(不列明细);
|
||
否则明细最多列前 max_detail_per_type 条,超出加「…另有 N 条」。
|
||
"""
|
||
total = len(hits)
|
||
title = f"🚨 {ALERT_KEYWORD} · {window_label}"
|
||
|
||
grouped: dict[str, list[AlertHit]] = {t: [] for t in _TYPE_ORDER}
|
||
for h in hits:
|
||
grouped.setdefault(h.alert_type, []).append(h)
|
||
|
||
# 摘要段:各类型计数
|
||
summary_parts = [f"{ALERT_TYPE_LABELS[t]} {len(grouped[t])}" for t in _TYPE_ORDER if grouped.get(t)]
|
||
summary_text = f"合计 {total} 条:" + " | ".join(summary_parts)
|
||
content: list[list[dict]] = [
|
||
[{"tag": "text", "text": summary_text}],
|
||
]
|
||
|
||
counts_only = total > max_total
|
||
if counts_only:
|
||
content.append([{"tag": "text", "text": f"(本期命中超 {max_total} 条,仅列计数,明细见分析库 comparison_record)"}])
|
||
return title, content
|
||
|
||
# 表头段
|
||
content.append([{"tag": "text", "text": "时间 | 手机号 | 版本 | 失败原因 | trace"}])
|
||
|
||
# 明细行(按类型顺序展开,每条一段)
|
||
shown_count = 0
|
||
for t in _TYPE_ORDER:
|
||
bucket = grouped.get(t) or []
|
||
if not bucket:
|
||
continue
|
||
for h in bucket[:max_detail_per_type]:
|
||
time_str = h.created_at.strftime("%m-%d %H:%M") if h.created_at else "-"
|
||
phone = phone_map.get(h.user_id) if h.user_id is not None else None
|
||
phone = phone or "-"
|
||
ver = h.app_version or "-"
|
||
row: list[dict] = [{"tag": "text", "text": f"{time_str} | {phone} | {ver} | {h.reason} | "}]
|
||
if h.trace_url:
|
||
row.append({"tag": "a", "text": "trace", "href": h.trace_url})
|
||
else:
|
||
row.append({"tag": "text", "text": h.trace_id[:16]})
|
||
content.append(row)
|
||
shown_count += 1
|
||
if len(bucket) > max_detail_per_type:
|
||
content.append([{"tag": "text", "text": f"…另有 {len(bucket) - max_detail_per_type} 条"}])
|
||
|
||
return title, content
|
||
|
||
|
||
# ---- format_alert_card (schema 2.0 卡片 + table 组件) ----
|
||
|
||
def _cost_cell(total_ms: int | None, step_count: int | None) -> str:
|
||
"""组合「用时」列值。有 ms → '{N}s',有 step_count → '{M}步',两者用 ' / ' 连;都无 → '-'。"""
|
||
parts = []
|
||
if total_ms is not None:
|
||
parts.append(f"{round(total_ms / 1000)}s")
|
||
if step_count is not None:
|
||
parts.append(f"{step_count}步")
|
||
return " / ".join(parts) or "-"
|
||
|
||
|
||
def _build_card(title_text: str, elements: list[dict]) -> dict:
|
||
"""组装 schema 2.0 红色 header 卡片;三条路径只需决定 elements。"""
|
||
return {
|
||
"schema": "2.0",
|
||
"header": {
|
||
"title": {"tag": "plain_text", "content": title_text},
|
||
"template": "red",
|
||
},
|
||
"body": {"elements": elements},
|
||
}
|
||
|
||
|
||
def _build_table_rows(
|
||
hits: list[AlertHit],
|
||
*,
|
||
phone_map: dict[int, str],
|
||
max_detail_per_type: int,
|
||
) -> list[dict]:
|
||
"""按 _TYPE_ORDER 顺序展开,每类型最多 max_detail_per_type 条。"""
|
||
grouped: dict[str, list[AlertHit]] = {t: [] for t in _TYPE_ORDER}
|
||
for h in hits:
|
||
if h.alert_type in grouped:
|
||
grouped[h.alert_type].append(h)
|
||
# 非 _TYPE_ORDER 类型静默跳过(与既有行为一致)
|
||
|
||
rows = []
|
||
for t in _TYPE_ORDER:
|
||
bucket = grouped.get(t) or []
|
||
for h in bucket[:max_detail_per_type]:
|
||
time_str = h.created_at.strftime("%m-%d %H:%M") if h.created_at else "-"
|
||
phone = (phone_map.get(h.user_id) if h.user_id is not None else None) or "-"
|
||
trace = (
|
||
f"[链接]({h.trace_url})" if h.trace_url
|
||
else (h.trace_id or "")[:12]
|
||
)
|
||
rows.append({
|
||
"time": time_str,
|
||
"phone": phone,
|
||
"cost": _cost_cell(h.total_ms, h.step_count),
|
||
"reason": h.reason,
|
||
"stuck": h.stuck_point or "-",
|
||
"ver": h.app_version or "-",
|
||
"trace": trace,
|
||
})
|
||
return rows
|
||
|
||
|
||
_TABLE_COLUMNS = [
|
||
{"name": "time", "display_name": "时间", "data_type": "text"},
|
||
{"name": "phone", "display_name": "手机号", "data_type": "text"},
|
||
{"name": "cost", "display_name": "用时", "data_type": "text"},
|
||
{"name": "reason", "display_name": "失败原因", "data_type": "text"},
|
||
{"name": "stuck", "display_name": "卡点", "data_type": "text"},
|
||
{"name": "ver", "display_name": "版本", "data_type": "text"},
|
||
{"name": "trace", "display_name": "trace", "data_type": "lark_md"},
|
||
]
|
||
|
||
|
||
def format_alert_card(
|
||
hits: list[AlertHit],
|
||
*,
|
||
window_label: str,
|
||
phone_map: dict[int, str],
|
||
interval_min: int,
|
||
max_detail_per_type: int,
|
||
max_total: int,
|
||
) -> dict:
|
||
"""返回飞书 schema 2.0 卡片 dict(配合 send_feishu_card 发送)。
|
||
|
||
- header: template=red,title 含 ALERT_KEYWORD(飞书关键词验证必须)。
|
||
- body 第一个元素: markdown 摘要(数据范围 + 合计 + 各类型计数)。
|
||
- 空 hits: 只有摘要「本期无异常」。
|
||
- total > max_total: 只有摘要(提示去 comparison_record 查),不加 table。
|
||
- 否则: 第二个元素为 table(列序 time/phone/cost/reason/stuck/ver/trace)。
|
||
"""
|
||
total = len(hits)
|
||
title_text = f"🚨 {ALERT_KEYWORD} · {window_label}"
|
||
|
||
# ---------- 空 hits ----------
|
||
if total == 0:
|
||
md_content = f"数据范围:近 {interval_min} 分钟\n本期无异常"
|
||
return _build_card(title_text, [{"tag": "markdown", "content": md_content}])
|
||
|
||
# ---------- 摘要 ----------
|
||
grouped_count: dict[str, int] = {}
|
||
for h in hits:
|
||
grouped_count[h.alert_type] = grouped_count.get(h.alert_type, 0) + 1
|
||
count_parts = [
|
||
f"{ALERT_TYPE_LABELS[t]} {grouped_count[t]}"
|
||
for t in _TYPE_ORDER
|
||
if grouped_count.get(t)
|
||
]
|
||
md_content = (
|
||
f"数据范围:近 {interval_min} 分钟\n"
|
||
f"**合计 {total} 条**:" + " | ".join(count_parts)
|
||
)
|
||
|
||
# ---------- 截断:超 max_total 只出摘要 ----------
|
||
if total > max_total:
|
||
md_content += f"\n超 {max_total} 条仅列计数,明细见分析库 comparison_record"
|
||
return _build_card(title_text, [{"tag": "markdown", "content": md_content}])
|
||
|
||
# ---------- 常规:摘要 + table ----------
|
||
rows = _build_table_rows(hits, phone_map=phone_map, max_detail_per_type=max_detail_per_type)
|
||
table_element = {
|
||
"tag": "table",
|
||
"page_size": 10,
|
||
"row_height": "low",
|
||
"header_style": {"background_style": "grey", "bold": True},
|
||
"columns": _TABLE_COLUMNS,
|
||
"rows": rows,
|
||
}
|
||
return _build_card(title_text, [{"tag": "markdown", "content": md_content}, table_element])
|