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>
373 lines
13 KiB
Python
373 lines
13 KiB
Python
"""
|
||
企微回调路由
|
||
|
||
处理安保人员在企微卡片上的操作(前往处理/已处理/误报忽略)。
|
||
提供告警详情接口供 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()
|
||
|