Files
shaguabijia-app-server/app/services/compare_alert_format.py
T

244 lines
9.3 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.
"""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=redtitle 含 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])