"""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])