功能:日报支持群机器人Webhook模板卡片推送

通过群机器人 Webhook 发送 text_notice 模板卡片,视觉效果
远超纯 markdown:大号数字突出核心指标,键值对整齐排列。

新增:
- WECHAT_GROUP_ROBOT_KEY 配置(群机器人 Webhook key)
- send_webhook_template_card 方法
- _build_template_card 构建 text_notice 卡片
  - emphasis_content: 昨日新增大号数字
  - horizontal_content_list: 安保/保洁、已完成、待处理、
    首响/完结、告警热点、高发设备(最多6条)
  - sub_title_text: 需关注项或「运营良好」
  - card_action: 点击跳转详情页

发送策略:优先 Webhook 模板卡片 → 降级 appchat markdown
This commit is contained in:
2026-04-03 16:16:51 +08:00
parent 8ff396641e
commit ecc5065c71
3 changed files with 106 additions and 17 deletions

View File

@@ -63,6 +63,7 @@ class WeChatConfig:
test_uids: str = "" # 演示模式逗号分隔的企微userid如 "zhangsan,lisi"
service_base_url: str = "" # 公网地址,如 https://vsp.viewshanghai.com
group_chat_id: str = "" # 告警群聊ID通过企微API创建或手动指定
group_robot_key: str = "" # 群机器人 Webhook key用于日报等模板卡片推送
@dataclass
@@ -184,6 +185,7 @@ def load_settings() -> Settings:
test_uids=os.getenv("WECHAT_TEST_UIDS", ""),
service_base_url=os.getenv("SERVICE_BASE_URL", ""),
group_chat_id=os.getenv("WECHAT_GROUP_CHAT_ID", ""),
group_robot_key=os.getenv("WECHAT_GROUP_ROBOT_KEY", ""),
),
agent=AgentConfig(
vlm_api_key=os.getenv("DASHSCOPE_API_KEY", ""),

View File

@@ -299,8 +299,64 @@ async def _build_daily_report_data() -> Optional[Dict]:
return report
def _build_template_card(report: Dict) -> Dict:
"""构建 text_notice 模板卡片(群机器人 Webhook 专用)"""
s = report["summary"]
tops = report["tops"]
click_url = settings.wechat.service_base_url or "https://work.weixin.qq.com"
# 大号数字:昨日新增
emphasis_desc = f"昨日新增({report['change_str']}"
# 键值对列表(最多 6 条,挑最重要的)
kv_list = [
{"keyname": "安保 / 保洁", "value": f"{s['security_count']} / {s['clean_count']}"},
{"keyname": "已完成", "value": f"{s['completed_count']}"},
{"keyname": "待处理", "value": f"{s['backlog_count']}(遗留 {s['carry_over_count']}" if s['backlog_count'] > 0 else "0 全部清零"},
{"keyname": "首响 / 完结", "value": f"{s['avg_resp']} / {s['avg_close']}"},
]
# 第5条最高发告警类型
if tops["alarm_types"] != "暂无数据":
kv_list.append({"keyname": "告警热点", "value": tops["alarm_types"]})
# 第6条高发设备
if tops["cameras"] != "暂无数据":
kv_list.append({"keyname": "高发设备", "value": tops["cameras"]})
# 副标题
sub_title = ""
if report["top_overdue"]:
sub_title = f"需关注:{report['top_overdue'][0]}"
elif s["backlog_count"] == 0:
sub_title = "昨日工单全部处理完毕,运营良好"
card = {
"card_type": "text_notice",
"source": {
"desc": "VSP物业平台",
"desc_color": 0,
},
"main_title": {
"title": report["title"],
},
"emphasis_content": {
"title": str(s["yesterday_total"]),
"desc": emphasis_desc,
},
"sub_title_text": sub_title,
"horizontal_content_list": kv_list,
"card_action": {
"type": 1,
"url": click_url,
},
}
return card
def _build_markdown(report: Dict) -> str:
"""构建单条企微 markdown 日报(领导视角,简洁直观"""
"""构建单条企微 markdown 日报(降级方案"""
if report.get("empty"):
return (
f"**{report['title']}**\n\n"
@@ -314,13 +370,11 @@ def _build_markdown(report: Dict) -> str:
lines = [
f"**{report['title']}**",
"",
# ── 总览 ──
f">昨日新增 <font color=\"warning\">{s['yesterday_total']}</font> 条({report['change_str']}",
f">安保 {s['security_count']}|保洁 {s['clean_count']}"
f"已完成 {s['completed_count']}|误报 {s['false_alarm_rate']}",
]
# 待处理0 用绿色,>0 用橙色警示
if backlog == 0:
lines.append(f">待处理 <font color=\"info\">0</font> 条,全部清零")
else:
@@ -329,14 +383,12 @@ def _build_markdown(report: Dict) -> str:
f"(其中遗留 {s['carry_over_count']}"
)
# ── 效率 ──
lines.append("")
lines.append(
f">响应效率:首响 <font color=\"info\">{s['avg_resp']}</font>"
f"|完结 <font color=\"info\">{s['avg_close']}</font>"
)
# ── 风险分布(仅有数据时展示)──
tops = report["tops"]
risk_items = []
if tops["alarm_types"] != "暂无数据":
@@ -353,7 +405,6 @@ def _build_markdown(report: Dict) -> str:
lines.append("**热点分布**")
lines.extend(risk_items)
# ── 超时跟进(仅有遗留时展示)──
if report["top_overdue"]:
lines.append("")
lines.append(f"**需关注({s['carry_over_count']}条超时)**")
@@ -364,7 +415,7 @@ def _build_markdown(report: Dict) -> str:
async def generate_daily_report() -> Optional[str]:
"""生成日报 markdown 内容(供预览和发送)"""
"""生成日报 markdown 内容(供预览和降级发送)"""
report = await _build_daily_report_data()
if not report:
return None
@@ -372,26 +423,39 @@ async def generate_daily_report() -> Optional[str]:
async def _send_daily_report():
"""生成并发送单条 markdown 日报到企微群聊"""
"""发送日报:优先用群机器人 Webhook 模板卡片,降级为 markdown"""
from app.services.wechat_service import get_wechat_service
chat_id = settings.wechat.group_chat_id
if not chat_id:
logger.warning("日报发送跳过:未配置 group_chat_id")
robot_key = settings.wechat.group_robot_key
if not chat_id and not robot_key:
logger.warning("日报发送跳过:未配置 group_chat_id 或 group_robot_key")
return
try:
content = await generate_daily_report()
if not content:
report = await _build_daily_report_data()
if not report:
logger.info("日报生成内容为空,跳过发送")
return
wechat_svc = get_wechat_service()
ok = await wechat_svc.send_group_markdown(chat_id, content)
if ok:
logger.info("日报已发送到企微群聊")
else:
logger.error("日报发送失败")
# 优先:群机器人 Webhook 发送 text_notice 模板卡片
if robot_key:
card = _build_template_card(report)
ok = await wechat_svc.send_webhook_template_card(robot_key, card)
if ok:
logger.info("日报模板卡片已通过 Webhook 发送")
return
# 降级:应用群聊发送 markdown
if chat_id:
content = _build_markdown(report)
ok = await wechat_svc.send_group_markdown(chat_id, content)
if ok:
logger.info("日报已通过 markdown 发送到群聊")
else:
logger.error("日报发送失败")
except Exception:
logger.exception("日报生成或发送异常")

View File

@@ -617,6 +617,29 @@ class WeChatService:
logger.error(f"发送群聊文本卡片异常: {e}")
return False
async def send_webhook_template_card(self, webhook_key: str, card: Dict) -> bool:
"""通过群机器人 Webhook 发送 template_cardtext_notice / news_notice"""
if not webhook_key:
logger.warning("Webhook key 为空,跳过发送")
return False
url = f"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key={webhook_key}"
payload = {
"msgtype": "template_card",
"template_card": card,
}
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(url, json=payload)
data = resp.json()
if data.get("errcode") != 0:
logger.error(f"Webhook template_card 发送失败: {data}")
return False
logger.info("Webhook template_card 已发送")
return True
except Exception as e:
logger.error(f"Webhook template_card 异常: {e}")
return False
async def send_group_image(self, chat_id: str, media_id: str) -> bool:
"""发送图片消息到群聊"""
if not self._enabled: