2026-03-23 11:51:16 +08:00
|
|
|
|
"""
|
|
|
|
|
|
企微通知 API — 供 IoT 平台调用
|
|
|
|
|
|
|
|
|
|
|
|
IoT 平台通过这两个接口驱动企微消息:
|
|
|
|
|
|
- /send-card: 工单派单后发送企微卡片
|
|
|
|
|
|
- /sync-status: 工单状态变更后同步告警状态+更新卡片
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-03-25 17:04:30 +08:00
|
|
|
|
import json
|
2026-03-23 11:51:16 +08:00
|
|
|
|
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: str
|
|
|
|
|
|
orderId: str
|
|
|
|
|
|
userIds: List[str]
|
|
|
|
|
|
title: str
|
2026-03-25 10:56:12 +08:00
|
|
|
|
areaName: Optional[str] = ""
|
|
|
|
|
|
cameraName: Optional[str] = ""
|
|
|
|
|
|
eventTime: Optional[str] = ""
|
|
|
|
|
|
level: Optional[int] = 2
|
|
|
|
|
|
snapshotUrl: Optional[str] = ""
|
2026-03-23 11:51:16 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SyncStatusRequest(BaseModel):
|
|
|
|
|
|
"""IoT 工单状态变更后同步"""
|
|
|
|
|
|
alarmId: str
|
|
|
|
|
|
orderId: str
|
2026-03-25 17:04:30 +08:00
|
|
|
|
status: str # confirmed / completed / false_alarm / auto_resolved / dispatched
|
2026-03-25 10:56:12 +08:00
|
|
|
|
operator: Optional[str] = ""
|
|
|
|
|
|
remark: Optional[str] = ""
|
2026-03-23 11:51:16 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-25 17:04:30 +08:00
|
|
|
|
async def _parse_body(request: Request) -> dict:
|
|
|
|
|
|
"""兼容解析请求体:JSON / form / query params"""
|
2026-03-25 17:11:25 +08:00
|
|
|
|
content_type = request.headers.get("content-type", "")
|
|
|
|
|
|
logger.info(f"IoT请求调试: method={request.method}, content-type={content_type}, url={request.url}")
|
2026-03-25 17:04:30 +08:00
|
|
|
|
|
2026-03-25 17:11:25 +08:00
|
|
|
|
# 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 {}
|
2026-03-25 17:04:30 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-23 11:51:16 +08:00
|
|
|
|
@router.post("/send-card")
|
2026-03-25 17:04:30 +08:00
|
|
|
|
async def send_card(request: Request):
|
2026-03-23 11:51:16 +08:00
|
|
|
|
"""IoT 派单后调用,发送企微工单卡片给指定保安"""
|
|
|
|
|
|
try:
|
2026-03-25 17:04:30 +08:00
|
|
|
|
data = await _parse_body(request)
|
|
|
|
|
|
logger.info(f"IoT send-card 收到数据: {data}")
|
|
|
|
|
|
|
|
|
|
|
|
req = SendCardRequest(**data)
|
|
|
|
|
|
|
2026-03-23 11:51:16 +08:00
|
|
|
|
from app.services.wechat_service import get_wechat_service, ALARM_LEVEL_NAMES
|
|
|
|
|
|
|
|
|
|
|
|
wechat = get_wechat_service()
|
|
|
|
|
|
if not wechat.enabled:
|
|
|
|
|
|
return {"code": -1, "msg": "企微未启用"}
|
|
|
|
|
|
|
|
|
|
|
|
level_name = ALARM_LEVEL_NAMES.get(req.level, "普通")
|
|
|
|
|
|
|
|
|
|
|
|
sent = await wechat.send_alarm_card(
|
|
|
|
|
|
user_ids=req.userIds,
|
|
|
|
|
|
alarm_id=req.alarmId,
|
|
|
|
|
|
alarm_type=req.title,
|
2026-03-25 10:56:12 +08:00
|
|
|
|
area_name=req.areaName or "",
|
|
|
|
|
|
camera_name=req.cameraName or "",
|
2026-03-23 11:51:16 +08:00
|
|
|
|
description=f"工单编号:{req.orderId}",
|
2026-03-25 10:56:12 +08:00
|
|
|
|
event_time=req.eventTime or "",
|
|
|
|
|
|
alarm_level=req.level or 2,
|
2026-03-23 11:51:16 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if sent:
|
|
|
|
|
|
logger.info(f"IoT回调发卡片成功: alarm={req.alarmId}, order={req.orderId}, 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")
|
2026-03-25 17:04:30 +08:00
|
|
|
|
async def sync_status(request: Request):
|
2026-03-23 11:51:16 +08:00
|
|
|
|
"""IoT 工单状态变更后调用,同步更新告警状态+企微卡片"""
|
|
|
|
|
|
try:
|
2026-03-25 17:04:30 +08:00
|
|
|
|
data = await _parse_body(request)
|
|
|
|
|
|
logger.info(f"IoT sync-status 收到数据: {data}")
|
|
|
|
|
|
|
|
|
|
|
|
req = SyncStatusRequest(**data)
|
|
|
|
|
|
|
2026-03-23 11:51:16 +08:00
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
|
# 状态映射
|
|
|
|
|
|
status_map = {
|
2026-03-25 17:04:30 +08:00
|
|
|
|
"dispatched": {"alarm_status": "CONFIRMED", "handle_status": "HANDLING", "card_action": None},
|
2026-03-25 15:38:52 +08:00
|
|
|
|
"confirmed": {"alarm_status": "CONFIRMED", "handle_status": "HANDLING", "card_action": "confirm"},
|
2026-03-23 11:51:16 +08:00
|
|
|
|
"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 = status_map.get(req.status)
|
|
|
|
|
|
if not mapping:
|
|
|
|
|
|
return {"code": 400, "msg": f"未知状态: {req.status}"}
|
|
|
|
|
|
|
|
|
|
|
|
# 1. 更新告警状态
|
|
|
|
|
|
service.handle_alarm(
|
|
|
|
|
|
alarm_id=req.alarmId,
|
|
|
|
|
|
alarm_status=mapping["alarm_status"],
|
|
|
|
|
|
handle_status=mapping["handle_status"],
|
2026-03-25 10:56:12 +08:00
|
|
|
|
handler=req.operator or "",
|
2026-03-23 11:51:16 +08:00
|
|
|
|
remark=req.remark or f"IoT工单同步: {req.status}",
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info(f"告警状态已同步: alarm={req.alarmId}, status={req.status}")
|
|
|
|
|
|
|
2026-03-25 15:38:52 +08:00
|
|
|
|
# 2. 更新企微卡片(区分 confirm→step2 和其他→terminal)
|
2026-03-23 11:51:16 +08:00
|
|
|
|
if mapping["card_action"] and wechat.enabled:
|
|
|
|
|
|
response_code = wechat.get_response_code(req.alarmId)
|
|
|
|
|
|
if response_code:
|
2026-03-25 15:38:52 +08:00
|
|
|
|
if mapping["card_action"] == "confirm":
|
2026-03-23 11:51:16 +08:00
|
|
|
|
# 确认接单:重绘卡片提示去H5处理
|
|
|
|
|
|
await wechat.update_alarm_card_step2(
|
|
|
|
|
|
response_code=response_code,
|
|
|
|
|
|
user_ids=[req.operator] if req.operator else [],
|
|
|
|
|
|
alarm_id=req.alarmId,
|
|
|
|
|
|
operator_name=req.operator,
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
# 终态:按钮变灰
|
|
|
|
|
|
await wechat.update_alarm_card_terminal(
|
|
|
|
|
|
response_code=response_code,
|
|
|
|
|
|
user_ids=[req.operator] if req.operator else [],
|
|
|
|
|
|
alarm_id=req.alarmId,
|
|
|
|
|
|
action=mapping["card_action"],
|
|
|
|
|
|
operator_name=req.operator,
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info(f"卡片已更新: alarm={req.alarmId}, action={mapping['card_action']}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.warning(f"未找到 response_code,跳过卡片更新: alarm={req.alarmId}")
|
|
|
|
|
|
|
|
|
|
|
|
return {"code": 0, "msg": "success"}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"IoT回调同步状态异常: {e}", exc_info=True)
|
|
|
|
|
|
return {"code": -1, "msg": str(e)}
|
2026-03-25 09:21:36 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)}
|