feat: 企微卡片升级为原生按钮交互型模板卡片

1. wechat_service.py:
   - send_alarm_card 从 textcard 升级为 button_interaction 模板卡片
   - 卡片直接在对话框展示告警信息 + 操作按钮(前往处理/误报忽略)
   - 新增 update_alarm_card 方法:点击按钮后更新卡片状态(按钮变灰)
   - 保留群聊消息能力(create_group_chat/send_group_text)

2. wechat_callback.py:
   - 回调支持 template_card_event 按钮点击事件
   - 按钮点击自动更新告警状态(handle→HANDLING, ignore→IGNORED)
   - 通过 response_code 更新卡片按钮为已处理状态
   - H5 详情页 snapshot_url 增加 COS key→预签名URL 转换
   - 新增群聊创建和群聊发卡片的测试接口

3. vlm_service.py:
   - VLM 降级策略统一放行(所有类型 confirmed=True)
   - 避免 VLM 不可用时离岗告警无法推送

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 14:36:58 +08:00
parent 78e0076f4a
commit c36488c6e6
3 changed files with 379 additions and 46 deletions

View File

@@ -50,6 +50,15 @@ async def get_alarm_detail(alarm_id: str = Query(..., description="告警ID")):
if analysis:
vlm_desc = analysis.summary or ""
# snapshot_url 可能是 COS key需转为可访问的预签名 URL
snapshot_url = alarm.snapshot_url or ""
if snapshot_url and not snapshot_url.startswith("http"):
try:
from app.services.oss_storage import get_oss_storage
snapshot_url = get_oss_storage().get_presigned_url(snapshot_url)
except Exception:
pass
return YudaoResponse.success({
"alarm_id": alarm.alarm_id,
"alarm_type": alarm.alarm_type,
@@ -59,7 +68,7 @@ async def get_alarm_detail(alarm_id: str = Query(..., description="告警ID")):
"alarm_level": alarm.alarm_level,
"alarm_status": alarm.alarm_status,
"handle_status": alarm.handle_status,
"snapshot_url": alarm.snapshot_url or "",
"snapshot_url": snapshot_url,
"handler": alarm.handler or "",
"handle_remark": alarm.handle_remark or "",
"vlm_description": vlm_desc,
@@ -160,17 +169,22 @@ async def wechat_agent_message(
logger.error(f"企微消息解密失败: {e}")
return PlainTextResponse(content="success")
# 只处理文本消息
if msg.get("MsgType") != "text":
msg_type = msg.get("MsgType", "")
event = msg.get("Event", "")
# ---- 模板卡片按钮点击事件 ----
if msg_type == "event" and event == "template_card_event":
asyncio.create_task(_process_card_button_click(msg))
return PlainTextResponse(content="success")
user_id = msg.get("FromUserName", "")
content = msg.get("Content", "")
# ---- 文本消息 → Agent 对话 ----
if msg_type == "text":
user_id = msg.get("FromUserName", "")
content = msg.get("Content", "")
logger.info(f"收到企微消息: user={user_id}, content={content[:50]}")
asyncio.create_task(_process_agent_message(user_id, content))
return PlainTextResponse(content="success")
logger.info(f"收到企微消息: user={user_id}, content={content[:50]}")
# 异步处理企微要求5秒内响应通过主动消息回复
asyncio.create_task(_process_agent_message(user_id, content))
return PlainTextResponse(content="success")
@@ -189,6 +203,84 @@ async def _process_agent_message(user_id: str, content: str):
logger.error(f"Agent消息处理失败: user={user_id}, error={e}", exc_info=True)
async def _process_card_button_click(msg: dict):
"""
处理模板卡片按钮点击事件
企微回调 XML 解密后包含:
- FromUserName: 点击者 userid
- EventKey: 按钮 key (handle_{alarm_id} / ignore_{alarm_id})
- TaskId: 卡片的 task_id (alarm_id)
- ResponseCode: 用于更新卡片状态(一次性)
"""
try:
from app.services.wechat_service import get_wechat_service
user_id = msg.get("FromUserName", "")
event_key = msg.get("EventKey", "")
task_id = msg.get("TaskId", "")
response_code = msg.get("ResponseCode", "")
logger.info(
f"卡片按钮点击: user={user_id}, key={event_key}, "
f"task={task_id}"
)
# 解析 action 和 alarm_id
if event_key.startswith("handle_"):
action = "handle"
alarm_id = event_key[len("handle_"):]
elif event_key.startswith("ignore_"):
action = "ignore"
alarm_id = event_key[len("ignore_"):]
elif event_key.startswith("done_"):
# 已处理状态的按钮,忽略
return
else:
logger.warning(f"未知的按钮 key: {event_key}")
return
# 更新告警状态
action_map = {
"handle": {
"alarm_status": "CONFIRMED",
"handle_status": "HANDLING",
"remark": "企微卡片-前往处理",
},
"ignore": {
"alarm_status": "FALSE",
"handle_status": "IGNORED",
"remark": "企微卡片-标记误报",
},
}
action_cfg = action_map.get(action)
if action_cfg:
service = get_alarm_event_service()
service.handle_alarm(
alarm_id=alarm_id,
alarm_status=action_cfg["alarm_status"],
handle_status=action_cfg["handle_status"],
handler=user_id,
remark=action_cfg["remark"],
)
logger.info(f"告警状态已更新: alarm={alarm_id}, action={action}, by={user_id}")
# 更新卡片按钮状态(变灰 + 显示处理结果)
if response_code:
wechat = get_wechat_service()
await wechat.update_alarm_card(
response_code=response_code,
user_ids=[user_id],
alarm_id=alarm_id,
action=action,
operator_name=user_id,
)
except Exception as e:
logger.error(f"处理卡片按钮点击失败: {e}", exc_info=True)
# ==================== Agent测试接口开发用 ====================
@router.post("/agent/test")
@@ -202,3 +294,79 @@ async def test_agent_message(
reply = await dispatcher.handle_message(user_id, content)
return YudaoResponse.success({"user_id": user_id, "content": content, "reply": reply})
# ==================== 群聊管理接口 ====================
class CreateGroupRequest(BaseModel):
"""创建群聊请求"""
name: str # 群聊名称
owner: str # 群主 userid
user_list: list # 成员 userid 列表至少2人
chat_id: Optional[str] = "" # 自定义群聊ID
class SendGroupCardRequest(BaseModel):
"""发送群聊卡片请求"""
chat_id: str # 群聊ID
alarm_id: str # 告警ID
mention_user_ids: Optional[list] = [] # 要 @的人员
@router.post("/group/create")
async def create_group_chat(req: CreateGroupRequest):
"""创建企微群聊"""
from app.services.wechat_service import get_wechat_service
wechat = get_wechat_service()
chatid = await wechat.create_group_chat(
name=req.name,
owner=req.owner,
user_list=req.user_list,
chat_id=req.chat_id,
)
if chatid:
return YudaoResponse.success({"chatid": chatid})
return YudaoResponse.error(500, "创建群聊失败")
@router.post("/group/send_alarm")
async def send_group_alarm(req: SendGroupCardRequest):
"""发送告警卡片到群聊(测试用,根据 alarm_id 查库)"""
from app.services.wechat_service import get_wechat_service
from app.config import settings
db = get_session()
try:
alarm = db.query(AlarmEvent).filter(AlarmEvent.alarm_id == req.alarm_id).first()
if not alarm:
return YudaoResponse.error(404, "告警不存在")
# 查 VLM 描述
vlm_desc = ""
analysis = db.query(AlarmLlmAnalysis).filter(
AlarmLlmAnalysis.alarm_id == req.alarm_id
).order_by(AlarmLlmAnalysis.id.desc()).first()
if analysis:
vlm_desc = analysis.summary or ""
wechat = get_wechat_service()
service_base_url = settings.wechat.service_base_url or ""
event_time_str = alarm.event_time.strftime('%Y-%m-%d %H:%M:%S') if alarm.event_time else ""
sent = await wechat.send_group_card(
chat_id=req.chat_id,
alarm_id=alarm.alarm_id,
alarm_type=alarm.alarm_type,
area_name=alarm.scene_id or "未知区域",
camera_name=alarm.device_id or "未知摄像头",
description=vlm_desc or f"{alarm.alarm_type} 告警",
event_time=event_time_str,
alarm_level=alarm.alarm_level or 2,
service_base_url=service_base_url,
mention_user_ids=req.mention_user_ids,
)
if sent:
return YudaoResponse.success(True)
return YudaoResponse.error(500, "发送失败")
finally:
db.close()

View File

@@ -81,10 +81,9 @@ class VLMService:
@staticmethod
def _fallback_result(alarm_type: str, camera_name: str, reason: str) -> Dict:
"""降级结果:入侵默认放行宁可多报离岗默认拦截避免VLM不可用时误推"""
confirmed = alarm_type != "leave_post"
"""降级结果:VLM 不可用时统一放行推送(宁可多报,不可漏报"""
return {
"confirmed": confirmed,
"confirmed": True,
"description": f"{camera_name or '未知位置'} 触发 {alarm_type} 告警({reason}",
"skipped": True,
}

View File

@@ -1,13 +1,13 @@
"""
企微通知服务
封装企业微信 API发送告警文本卡片
V1 使用应用消息 + 文本卡片,后期扩展为模板卡片
封装企业微信 API发送告警模板卡片(按钮交互型)
用户直接在对话框中点击按钮处理告警,无需跳转 H5 页面
"""
import httpx
import time
from typing import Optional, List
from typing import Optional, List, Dict
from app.utils.logger import logger
@@ -24,6 +24,9 @@ class WeChatService:
self._encoding_aes_key = ""
self._access_token = ""
self._token_expire_at = 0
# 缓存 response_code用于更新卡片状态
# key: task_id (alarm_id), value: response_code
self._response_codes: Dict[str, str] = {}
def init(self, config):
"""初始化企微配置"""
@@ -63,6 +66,14 @@ class WeChatService:
logger.info("企微 access_token 已更新")
return self._access_token
def save_response_code(self, task_id: str, response_code: str):
"""保存卡片的 response_code用于后续更新卡片状态"""
self._response_codes[task_id] = response_code
def get_response_code(self, task_id: str) -> Optional[str]:
"""获取并消耗 response_code只能用一次"""
return self._response_codes.pop(task_id, None)
async def send_alarm_card(
self,
user_ids: List[str],
@@ -77,21 +88,10 @@ class WeChatService:
service_base_url: str = "",
) -> bool:
"""
发送告警文本卡片
发送按钮交互型模板卡片
Args:
user_ids: 企微 userid 列表
alarm_id: 告警ID
alarm_type: 告警类型
area_name: 区域名称
camera_name: 摄像头名称
description: VLM 生成的场景描述
snapshot_url: 截图 URL
event_time: 告警时间
alarm_level: 告警级别
Returns:
是否发送成功
卡片直接在对话框中展示告警信息 + 操作按钮,
用户点击按钮后企微回调服务器,无需跳转 H5 页面。
"""
if not self._enabled:
logger.debug("企微未启用,跳过发送")
@@ -110,28 +110,62 @@ class WeChatService:
# 告警级别映射
level_names = {1: "提醒", 2: "一般", 3: "严重", 4: "紧急"}
level_name = level_names.get(alarm_level, "一般")
level_colors = {1: "blue", 2: "yellow", 3: "red", 4: "red"}
# 构造文本卡片消息
content = (
f"<div class=\"highlight\">{level_name} | {type_name}</div>\n"
f"<div class=\"gray\">区域:{area_name}</div>\n"
f"<div class=\"gray\">摄像头:{camera_name}</div>\n"
f"<div class=\"gray\">时间:{event_time}</div>\n"
f"<div class=\"normal\">{description}</div>"
# H5 详情页 URL点击卡片标题跳转可查看截图
detail_url = (
f"{service_base_url}/static/alarm_detail.html?alarm_id={alarm_id}"
if service_base_url
else "https://work.weixin.qq.com"
)
# H5 详情页 URL企微卡片"查看详情"跳转目标)
detail_url = f"{service_base_url}/static/alarm_detail.html?alarm_id={alarm_id}" if service_base_url else snapshot_url or "https://work.weixin.qq.com"
# 构造按钮交互型模板卡片
msg = {
"touser": "|".join(user_ids),
"msgtype": "textcard",
"msgtype": "template_card",
"agentid": int(self._agent_id) if self._agent_id else 0,
"textcard": {
"title": f"{level_name}{type_name}告警",
"description": content,
"url": detail_url,
"btntxt": "查看详情",
"template_card": {
"card_type": "button_interaction",
"task_id": alarm_id,
"main_title": {
"title": f"{level_name}{type_name}告警",
"desc": description or f"{area_name} 检测到{type_name}",
},
"sub_title_text": f"请相关人员及时处理",
"horizontal_content_list": [
{
"keyname": "告警区域",
"value": area_name or "未知区域",
},
{
"keyname": "摄像头",
"value": camera_name or "未知",
},
{
"keyname": "告警时间",
"value": event_time,
},
{
"keyname": "告警级别",
"value": level_name,
},
],
"card_action": {
"type": 1,
"url": detail_url,
},
"button_list": [
{
"text": "前往处理",
"style": 1,
"key": f"handle_{alarm_id}",
},
{
"text": "误报忽略",
"style": 2,
"key": f"ignore_{alarm_id}",
},
],
},
}
@@ -144,13 +178,82 @@ class WeChatService:
logger.error(f"企微发送失败: {data}")
return False
logger.info(f"企微通知已发送: alarm={alarm_id}, users={user_ids}")
# 保存 response_code 用于后续更新卡片
response_code = data.get("response_code", "")
if response_code:
self.save_response_code(alarm_id, response_code)
logger.info(f"企微模板卡片已发送: alarm={alarm_id}, users={user_ids}")
return True
except Exception as e:
logger.error(f"企微发送异常: {e}")
return False
async def update_alarm_card(
self,
response_code: str,
user_ids: List[str],
alarm_id: str,
action: str,
operator_name: str = "",
) -> bool:
"""
更新模板卡片状态(按钮变灰 + 显示处理结果)
Args:
response_code: 企微回调提供的 response_code
user_ids: 目标用户列表
alarm_id: 告警ID
action: 操作类型 (handle/ignore/complete)
operator_name: 操作人名称
"""
if not self._enabled:
return False
try:
access_token = await self._get_access_token()
action_text = {
"handle": f"处理中 - {operator_name}" if operator_name else "处理中",
"ignore": f"已忽略 - {operator_name}" if operator_name else "已忽略",
"complete": f"已处理 - {operator_name}" if operator_name else "已处理",
}
replace_text = action_text.get(action, "已处理")
body = {
"userids": user_ids,
"agentid": int(self._agent_id) if self._agent_id else 0,
"response_code": response_code,
"template_card": {
"card_type": "button_interaction",
"task_id": alarm_id,
"button_list": [
{
"text": replace_text,
"style": 3,
"key": f"done_{alarm_id}",
},
],
},
}
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/update_template_card?access_token={access_token}"
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(url, json=body)
data = resp.json()
if data.get("errcode") != 0:
logger.error(f"更新卡片失败: {data}")
return False
logger.info(f"卡片已更新: alarm={alarm_id}, action={action}")
return True
except Exception as e:
logger.error(f"更新卡片异常: {e}")
return False
async def send_text_message(self, user_id: str, content: str) -> bool:
"""发送文本消息给指定用户"""
if not self._enabled:
@@ -176,6 +279,69 @@ class WeChatService:
logger.error(f"发送文本消息异常: {e}")
return False
# ==================== 群聊消息 ====================
async def create_group_chat(
self,
name: str,
owner: str,
user_list: List[str],
chat_id: str = "",
) -> Optional[str]:
"""创建企微群聊"""
if not self._enabled:
return None
try:
access_token = await self._get_access_token()
body = {
"name": name,
"owner": owner,
"userlist": user_list,
}
if chat_id:
body["chatid"] = chat_id
url = f"https://qyapi.weixin.qq.com/cgi-bin/appchat/create?access_token={access_token}"
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(url, json=body)
data = resp.json()
if data.get("errcode") != 0:
logger.error(f"创建群聊失败: {data}")
return None
chatid = data.get("chatid", "")
logger.info(f"群聊已创建: name={name}, chatid={chatid}")
return chatid
except Exception as e:
logger.error(f"创建群聊异常: {e}")
return None
async def send_group_text(self, chat_id: str, content: str) -> bool:
"""发送文本消息到群聊(支持 <@userid> 语法 @人员)"""
if not self._enabled:
return False
try:
access_token = await self._get_access_token()
msg = {
"chatid": chat_id,
"msgtype": "text",
"text": {"content": content},
}
url = f"https://qyapi.weixin.qq.com/cgi-bin/appchat/send?access_token={access_token}"
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(url, json=msg)
data = resp.json()
if data.get("errcode") != 0:
logger.error(f"群聊消息发送失败: {data}")
return False
logger.info(f"群聊消息已发送: chatid={chat_id}")
return True
except Exception as e:
logger.error(f"发送群聊消息异常: {e}")
return False
# 全局单例
_wechat_service: Optional[WeChatService] = None