Files
iot-device-management-service/app/routers/wechat_callback.py
16337 c36488c6e6 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>
2026-03-11 14:36:58 +08:00

373 lines
13 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.

"""
企微回调路由
处理安保人员在企微卡片上的操作(前往处理/已处理/误报忽略)。
提供告警详情接口供 H5 页面使用。
接收企微用户消息并路由到交互Agent。
"""
import asyncio
from datetime import datetime
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel
from typing import Optional
from app.yudao_compat import YudaoResponse
from app.models import get_session, AlarmEvent, AlarmLlmAnalysis
from app.services.alarm_event_service import get_alarm_event_service, AlarmEventService
from app.utils.logger import logger
router = APIRouter(prefix="/api/wechat", tags=["企微回调"])
class AlarmActionRequest(BaseModel):
"""企微卡片操作请求"""
alarm_id: str
action: str # confirm / complete / ignore
operator_uid: str # 企微 userid
remark: Optional[str] = None
@router.get("/alarm_detail")
async def get_alarm_detail(alarm_id: str = Query(..., description="告警ID")):
"""
告警详情接口(供 H5 页面使用,无认证)
返回告警基本信息 + VLM 分析描述
"""
db = get_session()
try:
alarm = db.query(AlarmEvent).filter(AlarmEvent.alarm_id == alarm_id).first()
if not alarm:
return YudaoResponse.error(404, "告警不存在")
# 查 VLM 分析结果
vlm_desc = ""
analysis = db.query(AlarmLlmAnalysis).filter(
AlarmLlmAnalysis.alarm_id == alarm_id
).order_by(AlarmLlmAnalysis.id.desc()).first()
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,
"device_id": alarm.device_id,
"scene_id": alarm.scene_id,
"event_time": alarm.event_time.strftime('%Y-%m-%d %H:%M:%S') if alarm.event_time else "",
"alarm_level": alarm.alarm_level,
"alarm_status": alarm.alarm_status,
"handle_status": alarm.handle_status,
"snapshot_url": snapshot_url,
"handler": alarm.handler or "",
"handle_remark": alarm.handle_remark or "",
"vlm_description": vlm_desc,
})
finally:
db.close()
@router.post("/callback/alarm_action")
async def alarm_action_callback(
req: AlarmActionRequest,
service: AlarmEventService = Depends(get_alarm_event_service),
):
"""
企微告警操作回调(无认证,由 H5 页面调用)
action:
- confirm: 前往处理 → handle_status=HANDLING
- complete: 已处理完成 → handle_status=DONE, alarm_status=CLOSED
- ignore: 误报忽略 → alarm_status=FALSE, handle_status=DONE
"""
action_map = {
"confirm": {
"alarm_status": "CONFIRMED",
"handle_status": "HANDLING",
"remark": "前往处理",
},
"complete": {
"alarm_status": "CLOSED",
"handle_status": "DONE",
"remark": "手动结单",
},
"ignore": {
"alarm_status": "FALSE",
"handle_status": "IGNORED",
"remark": "标记误报",
},
}
action_cfg = action_map.get(req.action)
if not action_cfg:
return YudaoResponse.error(400, f"无效操作: {req.action}")
result = service.handle_alarm(
alarm_id=req.alarm_id,
alarm_status=action_cfg["alarm_status"],
handle_status=action_cfg["handle_status"],
handler=req.operator_uid,
remark=req.remark or action_cfg["remark"],
)
if not result:
return YudaoResponse.error(404, "告警不存在")
logger.info(
f"企微操作: alarm={req.alarm_id}, action={req.action}, "
f"operator={req.operator_uid}"
)
return YudaoResponse.success(True)
# ==================== 交互Agent消息回调 ====================
@router.get("/agent/callback")
async def wechat_agent_verify(
msg_signature: str = Query(...),
timestamp: str = Query(...),
nonce: str = Query(...),
echostr: str = Query(...),
):
"""企微回调URL验证首次配置时调用"""
from app.services.wechat_crypto import WeChatCrypto
try:
crypto = WeChatCrypto()
echo = crypto.verify_url(msg_signature, timestamp, nonce, echostr)
return PlainTextResponse(content=echo)
except Exception as e:
logger.error(f"企微URL验证失败: {e}")
return PlainTextResponse(content="", status_code=403)
@router.post("/agent/callback")
async def wechat_agent_message(
request: Request,
msg_signature: str = Query(...),
timestamp: str = Query(...),
nonce: str = Query(...),
):
"""接收企微用户消息路由到交互Agent"""
body = await request.body()
from app.services.wechat_crypto import WeChatCrypto
try:
crypto = WeChatCrypto()
msg = crypto.decrypt_message(body, msg_signature, timestamp, nonce)
except Exception as e:
logger.error(f"企微消息解密失败: {e}")
return PlainTextResponse(content="success")
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")
# ---- 文本消息 → 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")
return PlainTextResponse(content="success")
async def _process_agent_message(user_id: str, content: str):
"""异步处理消息并主动回复"""
try:
from app.services.agent_dispatcher import get_agent_dispatcher
from app.services.wechat_service import get_wechat_service
dispatcher = get_agent_dispatcher()
reply = await dispatcher.handle_message(user_id, content)
wechat = get_wechat_service()
await wechat.send_text_message(user_id, reply)
except Exception as e:
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")
async def test_agent_message(
user_id: str = Query(default="test_user"),
content: str = Query(..., description="测试消息内容"),
):
"""测试Agent对话开发用无加密直接返回回复"""
from app.services.agent_dispatcher import get_agent_dispatcher
dispatcher = get_agent_dispatcher()
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()