Part A: 数据层
- 新增 WechatCardState 模型(order_id ↔ alarm_id 映射 + response_code)
- 新建 models_iot.py(IoT 工单只读 ORM:ops_order + security_ext + clean_ext)
- config.py 新增 IOT_DATABASE_URL 配置
Part B: 企微解耦(alarm_id → order_id)
- wechat_service: response_code 存储迁移到 wechat_card_state,集中 helper
- 卡片发送/更新方法改用 order_id,按钮 key: confirm_{order_id}
- wechat_callback: 按钮解析改 order_id,反查 alarm_id(可空)
- wechat_notify_api: send-card/sync-status 以 orderId 为主键
- yudao_aiot_alarm: 卡片操作改用 order_id,删重复 helper
Part C: Agent 工具全面改为工单驱动
- 新建 order_query.py(查 IoT ops_order,支持安保+保洁工单)
- 新建 order_action.py(操作工单状态 + 提交处理结果)
- 更新 prompts.py 为工单助手
- 更新工具注册(__init__.py)
Part D: 日报改为工单驱动
- daily_report_service 从查 alarm_event 改为查 IoT ops_order + 扩展表
- 支持安保+保洁工单统计
285 lines
11 KiB
Python
285 lines
11 KiB
Python
"""
|
||
企微通知 API — 供 IoT 平台调用
|
||
|
||
IoT 平台通过这些接口驱动企微消息:
|
||
- /send-card: 工单派单后发送企微群聊+私发卡片
|
||
- /sync-status: 工单状态变更后同步(confirmed更新卡片、终态更新告警+卡片)
|
||
"""
|
||
|
||
import json
|
||
from fastapi import APIRouter, Request
|
||
from pydantic import BaseModel
|
||
from typing import List, Optional
|
||
|
||
from app.utils.logger import logger
|
||
|
||
router = APIRouter(prefix="/api/wechat/notify", tags=["企微通知-IoT回调"])
|
||
|
||
|
||
class SendCardRequest(BaseModel):
|
||
"""IoT 派单后发送企微卡片"""
|
||
alarmId: Optional[str] = ""
|
||
orderId: str
|
||
userIds: List[str]
|
||
title: str
|
||
areaName: Optional[str] = ""
|
||
cameraName: Optional[str] = ""
|
||
eventTime: Optional[str] = ""
|
||
level: Optional[int] = 2
|
||
snapshotUrl: Optional[str] = ""
|
||
|
||
|
||
class SyncStatusRequest(BaseModel):
|
||
"""IoT 工单状态变更后同步"""
|
||
alarmId: Optional[str] = ""
|
||
orderId: str
|
||
status: str # dispatched / confirmed / completed / false_alarm / auto_resolved
|
||
operator: Optional[str] = ""
|
||
remark: Optional[str] = ""
|
||
|
||
|
||
async def _parse_body(request: Request) -> dict:
|
||
"""兼容解析请求体:JSON / form / query params"""
|
||
content_type = request.headers.get("content-type", "")
|
||
logger.info(f"IoT请求调试: method={request.method}, content-type={content_type}, url={request.url}")
|
||
|
||
# 1. 读取原始 body
|
||
raw_body = await request.body()
|
||
logger.info(f"IoT请求调试: raw_body_len={len(raw_body)}, raw_body={raw_body[:500]}")
|
||
|
||
# 2. 尝试 JSON 解析
|
||
if raw_body:
|
||
try:
|
||
return json.loads(raw_body)
|
||
except Exception as e:
|
||
logger.warning(f"JSON解析失败: {e}")
|
||
|
||
# 3. 尝试 form 解析(需要重新构造 body,因为上面已经读过了)
|
||
if raw_body and "form" in content_type:
|
||
try:
|
||
from urllib.parse import parse_qs
|
||
params = parse_qs(raw_body.decode("utf-8"))
|
||
return {k: v[0] if len(v) == 1 else v for k, v in params.items()}
|
||
except Exception as e:
|
||
logger.warning(f"Form解析失败: {e}")
|
||
|
||
# 4. 降级到 query params
|
||
qp = dict(request.query_params)
|
||
if qp:
|
||
logger.info(f"使用query params: {qp}")
|
||
return qp
|
||
|
||
# 5. 尝试从 headers 中找数据(某些客户端可能这么做)
|
||
logger.warning(f"所有解析方式均无数据,headers={dict(request.headers)}")
|
||
return {}
|
||
|
||
|
||
@router.post("/send-card")
|
||
async def send_card(request: Request):
|
||
"""IoT 派单后调用,发送企微群聊+私发卡片"""
|
||
try:
|
||
data = await _parse_body(request)
|
||
logger.info(f"IoT send-card 收到数据: {data}")
|
||
|
||
req = SendCardRequest(**data)
|
||
|
||
from app.services.wechat_service import get_wechat_service
|
||
from app.config import settings
|
||
|
||
wechat = get_wechat_service()
|
||
if not wechat.enabled:
|
||
return {"code": -1, "msg": "企微未启用"}
|
||
|
||
# 群聊通知
|
||
group_chat_id = settings.wechat.group_chat_id
|
||
if group_chat_id:
|
||
# 截图:从告警表获取 object key 生成预签名 URL
|
||
from app.services.notify_dispatch import _get_presigned_url
|
||
snapshot_url = req.snapshotUrl or ""
|
||
# 如果是完整 COS URL,从告警表取 object key 重新生成预签名
|
||
if not snapshot_url or "403" in snapshot_url:
|
||
snapshot_url = ""
|
||
presigned_url = ""
|
||
if snapshot_url:
|
||
presigned_url = _get_presigned_url(snapshot_url)
|
||
|
||
# 从告警表补全摄像头名称和截图(IoT 可能没传或传了无效数据)
|
||
camera_name = req.cameraName or ""
|
||
alarm_type_code = ""
|
||
alarm_snapshot_key = ""
|
||
if req.alarmId:
|
||
from app.models import get_session, AlarmEvent
|
||
from app.services.camera_name_service import get_camera_name_service
|
||
db = get_session()
|
||
try:
|
||
alarm = db.query(AlarmEvent).filter(AlarmEvent.alarm_id == req.alarmId).first()
|
||
if alarm:
|
||
alarm_type_code = alarm.alarm_type or ""
|
||
alarm_snapshot_key = alarm.snapshot_url or ""
|
||
if not camera_name or camera_name == "未知":
|
||
if alarm.device_id:
|
||
camera_service = get_camera_name_service()
|
||
camera_info = await camera_service.get_camera_info(alarm.device_id)
|
||
camera_name = camera_service.format_display_name(alarm.device_id, camera_info)
|
||
finally:
|
||
db.close()
|
||
|
||
# 截图预签名:优先用告警表的 object key(能正确签名)
|
||
if not presigned_url or not snapshot_url:
|
||
if alarm_snapshot_key:
|
||
presigned_url = _get_presigned_url(alarm_snapshot_key)
|
||
|
||
logger.info(f"群聊截图诊断: order={req.orderId}, alarm={req.alarmId}, "
|
||
f"iot_snapshot={req.snapshotUrl!r}, "
|
||
f"db_snapshot={alarm_snapshot_key!r}, "
|
||
f"presigned_url={presigned_url[:80] if presigned_url else '(空)'}...")
|
||
|
||
# alarm_type: 用 alarm_type_code 避免"告警告警"
|
||
actual_alarm_type = alarm_type_code or req.title
|
||
|
||
await wechat.send_group_alarm_combo(
|
||
chat_id=group_chat_id,
|
||
alarm_id=req.alarmId or req.orderId,
|
||
alarm_type=actual_alarm_type,
|
||
area_name=req.areaName or "",
|
||
camera_name=camera_name,
|
||
description=req.title,
|
||
event_time=req.eventTime or "",
|
||
alarm_level=req.level or 2,
|
||
snapshot_url=presigned_url,
|
||
mention_user_ids=req.userIds,
|
||
)
|
||
|
||
# 私发卡片(以 order_id 为主键)
|
||
if not alarm_type_code and req.alarmId:
|
||
from app.models import get_session, AlarmEvent
|
||
db = get_session()
|
||
try:
|
||
alarm = db.query(AlarmEvent).filter(AlarmEvent.alarm_id == req.alarmId).first()
|
||
alarm_type_code = alarm.alarm_type if alarm else ""
|
||
finally:
|
||
db.close()
|
||
|
||
sent = await wechat.send_alarm_card(
|
||
user_ids=req.userIds,
|
||
order_id=req.orderId,
|
||
alarm_type=alarm_type_code or req.title,
|
||
area_name=req.areaName or "",
|
||
camera_name=camera_name if 'camera_name' in dir() else (req.cameraName or ""),
|
||
description=f"工单编号:{req.orderId}",
|
||
event_time=req.eventTime or "",
|
||
alarm_level=req.level or 2,
|
||
alarm_id=req.alarmId or "",
|
||
)
|
||
|
||
if sent:
|
||
logger.info(f"IoT回调发卡片成功: order={req.orderId}, alarm={req.alarmId}, users={req.userIds}")
|
||
return {"code": 0, "msg": "success"}
|
||
else:
|
||
return {"code": -1, "msg": "发送失败"}
|
||
|
||
except Exception as e:
|
||
logger.error(f"IoT回调发卡片异常: {e}", exc_info=True)
|
||
return {"code": -1, "msg": str(e)}
|
||
|
||
|
||
@router.post("/sync-status")
|
||
async def sync_status(request: Request):
|
||
"""IoT 工单状态变更后调用"""
|
||
try:
|
||
data = await _parse_body(request)
|
||
logger.info(f"IoT sync-status 收到数据: {data}")
|
||
|
||
req = SyncStatusRequest(**data)
|
||
|
||
from app.services.alarm_event_service import get_alarm_event_service
|
||
from app.services.wechat_service import get_wechat_service
|
||
|
||
service = get_alarm_event_service()
|
||
wechat = get_wechat_service()
|
||
|
||
# 用 order_id 查 response_code 和 alarm_id
|
||
order_id = req.orderId
|
||
alarm_id = req.alarmId or wechat.get_alarm_id_for_order(order_id)
|
||
|
||
if req.status == "dispatched":
|
||
# 派单:不更新告警,不发企微(企微由 send-card 接口发)
|
||
logger.info(f"dispatched 已记录: order={order_id}, alarm={alarm_id}")
|
||
return {"code": 0, "msg": "success"}
|
||
|
||
elif req.status == "confirmed":
|
||
# 确认接单:更新卡片到第二步(不更新告警)
|
||
if wechat.enabled:
|
||
response_code = wechat.get_response_code(order_id)
|
||
if response_code:
|
||
await wechat.update_alarm_card_step2(
|
||
response_code=response_code,
|
||
user_ids=[req.operator] if req.operator else [],
|
||
order_id=order_id,
|
||
operator_name=req.operator,
|
||
)
|
||
logger.info(f"卡片已更新到步骤2: order={order_id}")
|
||
return {"code": 0, "msg": "success"}
|
||
|
||
elif req.status in ("completed", "false_alarm", "auto_resolved"):
|
||
# 终态:更新告警状态(如果有关联告警)+ 更新卡片
|
||
terminal_map = {
|
||
"completed": {"alarm_status": "CLOSED", "handle_status": "DONE", "card_action": "complete"},
|
||
"false_alarm": {"alarm_status": "FALSE", "handle_status": "IGNORED", "card_action": "false"},
|
||
"auto_resolved": {"alarm_status": "CLOSED", "handle_status": "DONE", "card_action": "auto_resolve"},
|
||
}
|
||
mapping = terminal_map[req.status]
|
||
|
||
# 仅在有关联告警时更新告警状态
|
||
if alarm_id:
|
||
service.handle_alarm(
|
||
alarm_id=alarm_id,
|
||
alarm_status=mapping["alarm_status"],
|
||
handle_status=mapping["handle_status"],
|
||
handler=req.operator or "",
|
||
remark=req.remark or f"IoT工单: {req.status}",
|
||
)
|
||
logger.info(f"告警终态已同步: alarm={alarm_id}, status={req.status}")
|
||
|
||
if wechat.enabled:
|
||
response_code = wechat.get_response_code(order_id)
|
||
if response_code:
|
||
await wechat.update_alarm_card_terminal(
|
||
response_code=response_code,
|
||
user_ids=[req.operator] if req.operator else [],
|
||
order_id=order_id,
|
||
action=mapping["card_action"],
|
||
operator_name=req.operator,
|
||
)
|
||
return {"code": 0, "msg": "success"}
|
||
|
||
else:
|
||
logger.warning(f"未知状态,忽略: {req.status}")
|
||
return {"code": 0, "msg": "ignored"}
|
||
|
||
except Exception as e:
|
||
logger.error(f"IoT回调同步状态异常: {e}", exc_info=True)
|
||
return {"code": -1, "msg": str(e)}
|
||
|
||
|
||
@router.post("/daily-report")
|
||
async def trigger_daily_report(preview: bool = False):
|
||
"""手动触发每日告警日报
|
||
|
||
- preview=false(默认):生成并发送到企微群聊
|
||
- preview=true:仅生成内容预览,不发送
|
||
"""
|
||
try:
|
||
from app.services.daily_report_service import generate_daily_report, _send_daily_report
|
||
|
||
if preview:
|
||
content = await generate_daily_report()
|
||
return {"code": 0, "data": {"content": content}, "msg": "预览生成成功(未发送)"}
|
||
|
||
await _send_daily_report()
|
||
return {"code": 0, "msg": "日报已发送"}
|
||
|
||
except Exception as e:
|
||
logger.error(f"手动触发日报异常: {e}", exc_info=True)
|
||
return {"code": -1, "msg": str(e)}
|