2026-03-06 13:35:40 +08:00
|
|
|
|
"""
|
|
|
|
|
|
企微通知服务
|
|
|
|
|
|
|
|
|
|
|
|
封装企业微信 API,发送告警文本卡片。
|
|
|
|
|
|
V1 使用应用消息 + 文本卡片,后期扩展为模板卡片。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import time
|
|
|
|
|
|
from typing import Optional, List
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class WeChatService:
|
|
|
|
|
|
"""企微通知服务(单例)"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
|
self._enabled = False
|
|
|
|
|
|
self._corp_id = ""
|
|
|
|
|
|
self._agent_id = ""
|
|
|
|
|
|
self._secret = ""
|
|
|
|
|
|
self._token = ""
|
|
|
|
|
|
self._encoding_aes_key = ""
|
|
|
|
|
|
self._access_token = ""
|
|
|
|
|
|
self._token_expire_at = 0
|
|
|
|
|
|
|
|
|
|
|
|
def init(self, config):
|
|
|
|
|
|
"""初始化企微配置"""
|
|
|
|
|
|
self._enabled = config.enabled and bool(config.corp_id) and bool(config.secret)
|
|
|
|
|
|
self._corp_id = config.corp_id
|
|
|
|
|
|
self._agent_id = config.agent_id
|
|
|
|
|
|
self._secret = config.secret
|
|
|
|
|
|
self._token = config.token
|
|
|
|
|
|
self._encoding_aes_key = config.encoding_aes_key
|
|
|
|
|
|
|
|
|
|
|
|
if self._enabled:
|
|
|
|
|
|
logger.info(f"企微通知服务已启用: corp_id={self._corp_id}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.info("企微通知服务未启用")
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def enabled(self) -> bool:
|
|
|
|
|
|
return self._enabled
|
|
|
|
|
|
|
|
|
|
|
|
async def _get_access_token(self) -> str:
|
|
|
|
|
|
"""获取企微 access_token(带缓存)"""
|
|
|
|
|
|
if self._access_token and time.time() < self._token_expire_at - 60:
|
|
|
|
|
|
return self._access_token
|
|
|
|
|
|
|
|
|
|
|
|
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
|
|
|
|
|
|
params = {"corpid": self._corp_id, "corpsecret": self._secret}
|
|
|
|
|
|
|
|
|
|
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
|
|
|
|
resp = await client.get(url, params=params)
|
|
|
|
|
|
data = resp.json()
|
|
|
|
|
|
|
|
|
|
|
|
if data.get("errcode") != 0:
|
|
|
|
|
|
raise Exception(f"获取 access_token 失败: {data}")
|
|
|
|
|
|
|
|
|
|
|
|
self._access_token = data["access_token"]
|
|
|
|
|
|
self._token_expire_at = time.time() + data.get("expires_in", 7200)
|
|
|
|
|
|
logger.info("企微 access_token 已更新")
|
|
|
|
|
|
return self._access_token
|
|
|
|
|
|
|
|
|
|
|
|
async def send_alarm_card(
|
|
|
|
|
|
self,
|
|
|
|
|
|
user_ids: List[str],
|
|
|
|
|
|
alarm_id: str,
|
|
|
|
|
|
alarm_type: str,
|
|
|
|
|
|
area_name: str,
|
|
|
|
|
|
camera_name: str,
|
|
|
|
|
|
description: str,
|
|
|
|
|
|
snapshot_url: str,
|
|
|
|
|
|
event_time: str,
|
|
|
|
|
|
alarm_level: int = 2,
|
2026-03-06 13:43:55 +08:00
|
|
|
|
service_base_url: str = "",
|
2026-03-06 13:35:40 +08:00
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""
|
|
|
|
|
|
发送告警文本卡片
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
user_ids: 企微 userid 列表
|
|
|
|
|
|
alarm_id: 告警ID
|
|
|
|
|
|
alarm_type: 告警类型
|
|
|
|
|
|
area_name: 区域名称
|
|
|
|
|
|
camera_name: 摄像头名称
|
|
|
|
|
|
description: VLM 生成的场景描述
|
|
|
|
|
|
snapshot_url: 截图 URL
|
|
|
|
|
|
event_time: 告警时间
|
|
|
|
|
|
alarm_level: 告警级别
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
是否发送成功
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not self._enabled:
|
|
|
|
|
|
logger.debug("企微未启用,跳过发送")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
access_token = await self._get_access_token()
|
|
|
|
|
|
|
|
|
|
|
|
# 告警类型中文映射
|
|
|
|
|
|
type_names = {
|
|
|
|
|
|
"leave_post": "人员离岗",
|
|
|
|
|
|
"intrusion": "周界入侵",
|
|
|
|
|
|
}
|
|
|
|
|
|
type_name = type_names.get(alarm_type, alarm_type)
|
|
|
|
|
|
|
|
|
|
|
|
# 告警级别映射
|
|
|
|
|
|
level_names = {1: "提醒", 2: "一般", 3: "严重", 4: "紧急"}
|
|
|
|
|
|
level_name = level_names.get(alarm_level, "一般")
|
|
|
|
|
|
|
|
|
|
|
|
# 构造文本卡片消息
|
|
|
|
|
|
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>"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-06 13:43:55 +08:00
|
|
|
|
# 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"
|
|
|
|
|
|
|
2026-03-06 13:35:40 +08:00
|
|
|
|
msg = {
|
|
|
|
|
|
"touser": "|".join(user_ids),
|
|
|
|
|
|
"msgtype": "textcard",
|
|
|
|
|
|
"agentid": int(self._agent_id) if self._agent_id else 0,
|
|
|
|
|
|
"textcard": {
|
|
|
|
|
|
"title": f"【{level_name}】{type_name}告警",
|
|
|
|
|
|
"description": content,
|
2026-03-06 13:43:55 +08:00
|
|
|
|
"url": detail_url,
|
2026-03-06 13:35:40 +08:00
|
|
|
|
"btntxt": "查看详情",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/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"企微通知已发送: alarm={alarm_id}, users={user_ids}")
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"企微发送异常: {e}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
2026-03-09 10:42:32 +08:00
|
|
|
|
async def send_text_message(self, user_id: str, content: str) -> bool:
|
|
|
|
|
|
"""发送文本消息给指定用户"""
|
|
|
|
|
|
if not self._enabled:
|
|
|
|
|
|
return False
|
|
|
|
|
|
try:
|
|
|
|
|
|
access_token = await self._get_access_token()
|
|
|
|
|
|
msg = {
|
|
|
|
|
|
"touser": user_id,
|
|
|
|
|
|
"msgtype": "text",
|
|
|
|
|
|
"agentid": int(self._agent_id) if self._agent_id else 0,
|
|
|
|
|
|
"text": {"content": content},
|
|
|
|
|
|
}
|
|
|
|
|
|
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/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"企微文本消息已发送: user={user_id}")
|
|
|
|
|
|
return True
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"发送文本消息异常: {e}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
2026-03-06 13:35:40 +08:00
|
|
|
|
|
|
|
|
|
|
# 全局单例
|
|
|
|
|
|
_wechat_service: Optional[WeChatService] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_wechat_service() -> WeChatService:
|
|
|
|
|
|
global _wechat_service
|
|
|
|
|
|
if _wechat_service is None:
|
|
|
|
|
|
_wechat_service = WeChatService()
|
|
|
|
|
|
return _wechat_service
|