fe7649ed6e
- feat(deploy): 前端多阶段构建 (vite build + nginx:alpine),移除 Vite 开发模式 - feat(deploy): OpenResty HTTPS 配置 (SSL + HSTS + 安全头) - fix(ws): WebSocket 路由添加 /api 前缀,修正前后端路径不匹配 - security: SSH AutoAddPolicy → WarningPolicy - security: CORS 来源环境变量化 (CORS_ORIGINS) - security: 限流器使用 X-Forwarded-For 真实客户端 IP - perf(db): 数据库连接池配置 (pool_size=20, max_overflow=40) - refactor: 移除硬编码 URL/IP (NTP、域名、微信代理),改为环境变量 - chore: 更新 .env.example 模板,补充新增配置项 - chore: 清理 .reasonix/、scripts/、guide.md 无用文件 - docs: 更新 CLAUDE.md 至 v0.10.0,补充生产架构文档 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
242 lines
9.7 KiB
Python
242 lines
9.7 KiB
Python
"""企业微信回调 API(URL验证 + 消息接收)"""
|
||
import logging
|
||
from fastapi import APIRouter, Request, Response
|
||
from app.services.wechat_service import get_wechat_service
|
||
from app.core.config import settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter(prefix="/api/wechat", tags=["企业微信回调"])
|
||
|
||
|
||
@router.post("/menu/create")
|
||
async def create_menu():
|
||
"""创建/更新企业微信应用菜单"""
|
||
svc = get_wechat_service()
|
||
menu = {
|
||
"button": [
|
||
{
|
||
"name": "设备查询",
|
||
"sub_button": [
|
||
{"type": "click", "name": "在线统计", "key": "online"},
|
||
{"type": "click", "name": "全离线学校", "key": "offline_schools"},
|
||
{"type": "click", "name": "MAC查询", "key": "status"},
|
||
]
|
||
},
|
||
{"type": "click", "name": "帮助", "key": "help"},
|
||
]
|
||
}
|
||
ok = svc.create_menu(menu)
|
||
return {"success": ok}
|
||
|
||
|
||
@router.get("/callback")
|
||
async def wechat_callback_get(request: Request):
|
||
"""企业微信 URL 验证(GET)"""
|
||
params = request.query_params
|
||
msg_signature = params.get("msg_signature", "")
|
||
timestamp = params.get("timestamp", "")
|
||
nonce = params.get("nonce", "")
|
||
echostr = params.get("echostr", "")
|
||
|
||
svc = get_wechat_service()
|
||
result = svc.verify_url(msg_signature, timestamp, nonce, echostr)
|
||
if result:
|
||
return Response(content=result, media_type="text/plain")
|
||
return Response(content="验证失败", status_code=403)
|
||
|
||
|
||
@router.post("/callback")
|
||
async def wechat_callback_post(request: Request):
|
||
"""企业微信消息接收(POST)"""
|
||
params = request.query_params
|
||
msg_signature = params.get("msg_signature", "")
|
||
timestamp = params.get("timestamp", "")
|
||
nonce = params.get("nonce", "")
|
||
|
||
xml_data = await request.body()
|
||
if not xml_data:
|
||
return Response(content="", media_type="text/plain")
|
||
|
||
svc = get_wechat_service()
|
||
msg = svc.parse_message(xml_data)
|
||
if not msg:
|
||
return Response(content="", media_type="text/plain")
|
||
|
||
msg_type = msg.get("MsgType", "")
|
||
from_user = msg.get("FromUserName", "")
|
||
|
||
logger.info(f"收到企微消息: type={msg_type}, from={from_user}, content={msg.get('Content', '')}")
|
||
|
||
if msg_type == "text":
|
||
content = msg.get("Content", "").strip()
|
||
if content.lower() in ("online", "在线", "在线统计"):
|
||
_handle_online_cmd(svc, from_user)
|
||
elif content.lower() in ("全离线", "离线学校", "offline"):
|
||
_handle_offline_schools_cmd(svc, from_user)
|
||
elif content.startswith("#状态+") or content.startswith("#status+"):
|
||
_handle_status_cmd(svc, from_user, content)
|
||
elif content.lower() in ("help", "帮助", "#帮助", "#help"):
|
||
_handle_help_cmd(svc, from_user)
|
||
else:
|
||
# 尝试作为 MAC 后缀查询
|
||
_handle_status_cmd(svc, from_user, f"#状态+{content}")
|
||
|
||
elif msg_type == "event":
|
||
event = msg.get("Event", "")
|
||
event_key = msg.get("EventKey", "")
|
||
if event == "click":
|
||
if event_key == "online":
|
||
_handle_online_cmd(svc, from_user)
|
||
elif event_key == "offline_schools":
|
||
_handle_offline_schools_cmd(svc, from_user)
|
||
elif event_key == "help":
|
||
_handle_help_cmd(svc, from_user)
|
||
|
||
return Response(content="", media_type="text/plain")
|
||
|
||
|
||
# ── 命令处理 ────────────────────────────────────────────────────────────────
|
||
|
||
def _handle_online_cmd(svc, from_user: str):
|
||
"""处理在线统计命令"""
|
||
try:
|
||
from app.core.database import SessionLocal
|
||
from app.models.device import ONUDevice, DeviceStatusHistory
|
||
from sqlalchemy import func, case
|
||
|
||
db = SessionLocal()
|
||
try:
|
||
latest_subq = (
|
||
db.query(DeviceStatusHistory.onu_device_id,
|
||
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"))
|
||
.group_by(DeviceStatusHistory.onu_device_id).subquery()
|
||
)
|
||
latest_status_subq = (
|
||
db.query(DeviceStatusHistory.onu_device_id, DeviceStatusHistory.status)
|
||
.join(latest_subq, (DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)).subquery()
|
||
)
|
||
total = db.query(func.count(ONUDevice.id)).scalar() or 0
|
||
online = (
|
||
db.query(func.count(ONUDevice.id))
|
||
.join(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id, isouter=True)
|
||
.filter(latest_status_subq.c.status == 'online').scalar() or 0
|
||
)
|
||
rate = (online / total * 100) if total > 0 else 0
|
||
svc.send_text_message(
|
||
f"📊 设备在线统计\n总设备数: {total}\n在线: {online}\n离线: {total - online}\n在线率: {rate:.1f}%",
|
||
to_user=from_user
|
||
)
|
||
finally:
|
||
db.close()
|
||
except Exception as e:
|
||
logger.error(f"在线统计失败: {e}")
|
||
svc.send_text_message("查询失败,请稍后重试", to_user=from_user)
|
||
|
||
|
||
def _handle_status_cmd(svc, from_user: str, content: str):
|
||
"""处理设备状态查询命令"""
|
||
try:
|
||
mac_suffix = content.split('+')[1].strip().upper()
|
||
from app.core.database import SessionLocal
|
||
from app.models.device import ONUDevice, DeviceStatusHistory
|
||
from sqlalchemy import func
|
||
|
||
db = SessionLocal()
|
||
try:
|
||
devices = db.query(ONUDevice).filter(
|
||
ONUDevice.mac_address.ilike(f"%{mac_suffix}")
|
||
).limit(10).all()
|
||
|
||
if not devices:
|
||
svc.send_text_message("未找到匹配的设备", to_user=from_user)
|
||
return
|
||
|
||
lines = [f"🔍 找到 {len(devices)} 个设备(MAC 含 {mac_suffix}):", ""]
|
||
for d in devices[:8]:
|
||
# 查最新状态
|
||
latest = (
|
||
db.query(DeviceStatusHistory.status,
|
||
func.max(DeviceStatusHistory.checked_at))
|
||
.filter(DeviceStatusHistory.onu_device_id == d.id)
|
||
.group_by(DeviceStatusHistory.status)
|
||
.order_by(func.max(DeviceStatusHistory.checked_at).desc())
|
||
.first()
|
||
)
|
||
status_text = latest[0] if latest else "未知"
|
||
emoji = "🟢" if status_text == "online" else "🔴"
|
||
school = d.school_name or "未知"
|
||
lines.append(f"{emoji} {d.mac_address} | {school} | {d.region or ''}")
|
||
|
||
svc.send_text_message("\n".join(lines), to_user=from_user)
|
||
finally:
|
||
db.close()
|
||
except Exception as e:
|
||
logger.error(f"设备查询失败: {e}")
|
||
svc.send_text_message("查询失败,请稍后重试", to_user=from_user)
|
||
|
||
|
||
def _handle_offline_schools_cmd(svc, from_user: str):
|
||
"""查询全离线学校"""
|
||
try:
|
||
from app.core.database import SessionLocal
|
||
from app.models.device import ONUDevice, DeviceStatusHistory
|
||
from sqlalchemy import func, case
|
||
|
||
db = SessionLocal()
|
||
try:
|
||
latest_subq = (
|
||
db.query(DeviceStatusHistory.onu_device_id,
|
||
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"))
|
||
.group_by(DeviceStatusHistory.onu_device_id).subquery()
|
||
)
|
||
latest_status_subq = (
|
||
db.query(DeviceStatusHistory.onu_device_id, DeviceStatusHistory.status)
|
||
.join(latest_subq,
|
||
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)).subquery()
|
||
)
|
||
rows = (
|
||
db.query(
|
||
ONUDevice.school_name, ONUDevice.region,
|
||
func.count().label("total"),
|
||
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
||
)
|
||
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
|
||
.group_by(ONUDevice.school_name, ONUDevice.region)
|
||
.having(func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)) == 0)
|
||
.all()
|
||
)
|
||
|
||
if not rows:
|
||
svc.send_text_message("✅ 当前没有全离线的学校", to_user=from_user)
|
||
return
|
||
|
||
lines = [f"🔴 全离线学校 ({len(rows)} 所):", ""]
|
||
for r in rows:
|
||
school = r.school_name or "未知"
|
||
region = r.region or "未知"
|
||
total = int(r.total or 0)
|
||
if total > 0:
|
||
lines.append(f"• {school}({region}): {total}台全离线")
|
||
svc.send_text_message("\n".join(lines), to_user=from_user)
|
||
finally:
|
||
db.close()
|
||
except Exception as e:
|
||
logger.error(f"全离线查询失败: {e}")
|
||
svc.send_text_message("查询失败,请稍后重试", to_user=from_user)
|
||
|
||
|
||
def _handle_help_cmd(svc, from_user: str):
|
||
"""处理帮助命令"""
|
||
svc.send_text_message(
|
||
"📋 H3C ONU 管理助手\n\n"
|
||
"🔍 设备查询:\n"
|
||
"• 发送「在线」查看设备在线统计\n"
|
||
"• 发送「全离线」查看全离线学校\n"
|
||
"• 发送 MAC 地址后四位查询设备\n\n"
|
||
"💡 发送「帮助」显示此信息\n"
|
||
f"💻 完整功能: {settings.FRONTEND_URL}",
|
||
to_user=from_user
|
||
)
|