提交 fdb0ee63 authored 作者: 陈泽健's avatar 陈泽健

feat(service-monitor): 通知配置功能(邮件/钉钉/企业微信)

- 左侧菜单新增「通知配置」(仅管理员可见)
- 新增 notification_service.py:三种渠道配置/测试发送/巡检后通知
- 新增 notification.html:配置卡片 + 触发条件 + 测试按钮
- runner_service.py:run_inspection_sync 完成后调用通知服务
- 敏感字段用 Fernet 加密存储
- 新增 PRD 文档
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 28f55365
# 计划执行 — 通知配置功能
> 版本:1.0 | 日期:2026-07-21 | 关联 PRD:PRD_需求文档_通知配置.md
---
## 执行步骤
### 步骤 1:后端 notification_service.py
**文件**`skill/code/web/service_monitor/services/notification_service.py`
**改动**
1. 数据模型:`notifications.json` 结构定义
2. `get_config()` — 读取配置(敏感字段解密)
3. `save_config(data)` — 保存配置(敏感字段加密)
4. `test_email()` — 发送测试邮件(smtplib)
5. `test_dingtalk()` — 发送测试钉钉消息(requests + HMAC 签名)
6. `test_wecom()` — 发送测试企业微信消息(requests)
7. `send_notification(report, report_url)` — 根据配置发送通知
8. `_build_email_content(report)` — 构建邮件内容
9. `_build_dingtalk_content(report)` — 构建钉钉消息
10. `_build_wecom_content(report)` — 构建企业微信消息
**依赖**
- `smtplib` + `email` — 标准库,无需安装
- `requests` — 已有依赖
**验证**:Python 语法检查
---
### 步骤 2:更新 paths.py
**文件**`skill/code/web/service_monitor/utils/paths.py`
**改动**
```python
NOTIFICATIONS_FILE = DATA_DIR / "notifications.json"
```
---
### 步骤 3:路由层 routes.py 新增接口
**文件**`skill/code/web/service_monitor/routes.py`
**改动**
1. 新增 `page_notification()` — 配置页面路由
2. 新增 `api_get_notification()` — 获取配置 API
3. 新增 `api_save_notification()` — 保存配置 API(管理员)
4. 新增 `api_test_email()` — 测试邮件 API(管理员)
5. 新增 `api_test_dingtalk()` — 测试钉钉 API(管理员)
6. 新增 `api_test_wecom()` — 测试企业微信 API(管理员)
---
### 步骤 4:前端 notification.html
**文件**`skill/code/web/templates/service_monitor/notification.html`
**改动**
1. 继承 `base.html`
2. 三个通知渠道卡片:邮件 / 钉钉 / 企业微信
3. 每个卡片有启用开关 + 配置表单 + 测试按钮
4. 触发条件配置区域
5. 保存按钮
6. JS:加载配置、保存配置、发送测试请求
---
### 步骤 5:更新 base.html 左侧菜单
**文件**`skill/code/web/templates/service_monitor/base.html`
**改动**
在"定时任务"和"目标管理"之间增加"通知配置"菜单项:
```html
<a href="/service-monitor/notification" class="nav-item {{ 'active' if active_menu == 'notification' }}">
<span class="nav-icon">🔔</span>
<span>通知配置</span>
</a>
```
---
### 步骤 6:集成到巡检流程
**文件**`skill/code/web/service_monitor/services/runner_service.py`
**改动**
`run_inspection_sync()` 完成后调用通知服务:
```python
# 发送通知(定时任务触发的巡检)
from . import notification_service
report = report_service.get_report(report_id)
report_url = f"http://request.host/service-monitor/report/{report_id}"
notification_service.send_notification(report, report_url)
```
---
### 步骤 7:部署验证
1. 部署到 5.60
2. 浏览器访问通知配置页面
3. 配置邮件通知,发送测试邮件
4. 配置钉钉/企业微信,发送测试消息
5. 创建定时任务,等待执行,验证通知发送
---
## 风险与兜底
| 风险 | 兜底方案 |
|------|----------|
| SMTP 连接失败 | 测试接口返回具体错误信息,帮助用户排查 |
| 钉钉签名错误 | 提供 webhook 和 secret 填写说明,测试验证 |
| 企业微信 webhook 失效 | 测试按钮验证配置有效性 |
| 通知发送阻塞巡检 | 通知发送用 try-except 包裹,失败不影响报告保存 |
---
## 文件清单
| 文件 | 操作 |
|------|------|
| `service_monitor/services/notification_service.py` | 新增 |
| `service_monitor/utils/paths.py` | 修改(增加 NOTIFICATIONS_FILE) |
| `service_monitor/routes.py` | 修改(增加 6 个路由) |
| `templates/service_monitor/notification.html` | 新增 |
| `templates/service_monitor/base.html` | 修改(增加菜单项) |
| `service_monitor/services/runner_service.py` | 修改(集成通知调用) |
\ No newline at end of file
# PRD — 通知配置功能
> 版本:1.0 | 日期:2026-07-21 | 作者:czj
---
## 1. 背景与问题
当前服务监测模块生成巡检报告后,**仅保存在服务器本地**,用户需要主动登录系统查看。存在以下问题:
1. **无法及时感知**:巡检完成后(特别是定时巡检),管理员无法第一时间获知结果
2. **异常响应滞后**:出现严重问题时,需要等用户主动查看才能发现
3. **缺少推送渠道**:没有邮件/钉钉/企业微信等通知通道配置
## 2. 需求目标
新增**通知配置**功能,让管理员配置报告发送通知的相关信息:
- 配置邮件通知(SMTP)
- 配置钉钉机器人通知
- 配置企业微信机器人通知
- 设置通知触发条件(报告完成后 / 出现异常时)
- 设置通知接收人
## 3. 功能设计
### 3.1 左侧菜单新增
在"定时任务"和"目标管理"之间增加:
```
📊 监测目标
📋 巡检报告
⏰ 定时任务
🔔 通知配置 ← 新增
⚙️ 目标管理
```
### 3.2 通知配置页面
#### 3.2.1 整体布局
- **通知渠道卡片**:邮件 / 钉钉 / 企业微信(可多选启用)
- **触发条件**:报告完成后通知 / 仅异常时通知
- **接收人配置**:邮箱列表 / 钉钉群 / 企业微信群
#### 3.2.2 邮件通知配置
| 字段 | 控件 | 说明 |
|------|------|------|
| 启用邮件通知 | 开关 | 是/否 |
| SMTP 服务器 | 文本 | 如 `smtp.qq.com` |
| SMTP 端口 | 数字 | 如 `465`(SSL)或 `25` |
| 发件人邮箱 | 文本 | 如 `admin@example.com` |
| 邮箱授权码 | 密码 | SMTP 认证密码 |
| 使用 SSL | 开关 | 推荐 SSL |
| 收件人列表 | 文本域 | 多个邮箱用英文逗号分隔 |
| 邮件主题模板 | 文本 | 支持 `{target}`, `{suite}`, `{status}` 变量 |
**测试按钮**:发送测试邮件验证配置
#### 3.2.3 钉钉机器人配置
| 字段 | 控件 | 说明 |
|------|------|------|
| 启用钉钉通知 | 开关 | 是/否 |
| Webhook 地址 | 文本 | 钉钉群机器人的 webhook URL |
| 签名密钥 | 密码 | 加签机器人的密钥(可选) |
| @人员列表 | 文本 | 手机号,多个用逗号分隔(可选) |
**测试按钮**:发送测试消息验证配置
#### 3.2.4 企业微信机器人配置
| 字段 | 控件 | 说明 |
|------|------|------|
| 启用企业微信通知 | 开关 | 是/否 |
| Webhook 地址 | 文本 | 企业微信群机器人的 webhook URL |
**测试按钮**:发送测试消息验证配置
#### 3.2.5 触发条件
| 字段 | 控件 | 说明 |
|------|------|------|
| 触发时机 | 单选 | 报告完成后立即通知 / 仅异常(警告/严重)时通知 |
| 通知内容 | 多选 | 包含摘要 / 包含异常详情 / 包含报告链接 |
### 3.3 通知时机
1. **手动巡检完成**:不发送通知(用户正在页面等待)
2. **定时巡检完成**:根据配置发送通知
3. **异常时通知**:summary 中有 WARNING 或 CRITICAL 时触发
### 3.4 通知内容模板
#### 邮件模板
```
主题:【巡检报告】{target} - {suite} - {status}
正文:
巡检目标:{target_name}
巡检套件:{suite_name}
完成时间:{finished_at}
汇总:正常 {normal} 项,警告 {warning} 项,严重 {critical} 项
{if has_abnormal}
异常项:
{for item in abnormal_items}
- {module}: {name} = {value}(阈值:{threshold})
{endfor}
{endif}
报告链接:{report_url}
```
#### 钉钉/企业微信模板
```
【巡检报告】{target_name}
套件:{suite_name}
时间:{finished_at}
结果:正常 {normal} / 警告 {warning} / 严重 {critical}
链接:{report_url}
```
## 4. 数据模型
### notifications.json
```json
{
"email": {
"enabled": false,
"smtp_host": "",
"smtp_port": 465,
"smtp_user": "",
"smtp_password": "",
"use_ssl": true,
"recipients": [],
"subject_template": "【巡检报告】{target} - {status}"
},
"dingtalk": {
"enabled": false,
"webhook": "",
"secret": "",
"at_mobiles": []
},
"wecom": {
"enabled": false,
"webhook": ""
},
"trigger": {
"on_complete": true,
"on_abnormal_only": false,
"include_details": true,
"include_link": true
},
"updated_at": "2026-07-21T10:00:00",
"updated_by": "admin"
}
```
## 5. 后端实现
### 5.1 新增文件
| 文件 | 用途 |
|------|------|
| `service_monitor/services/notification_service.py` | 通知服务:发送邮件/钉钉/企业微信 |
| `service_monitor/templates/service_monitor/notification.html` | 配置页面 |
### 5.2 notification_service.py 核心函数
```python
def get_config() -> dict # 获取配置
def save_config(data: dict) # 保存配置
def test_email() -> dict # 发送测试邮件
def test_dingtalk() -> dict # 发送测试钉钉消息
def test_wecom() -> dict # 发送测试企业微信消息
def send_notification(report: dict, report_url: str) # 根据配置发送通知
```
### 5.3 调用时机
`runner_service.run_inspection_sync()` 完成后调用:
```python
# 定时任务执行完成后
if result.get("success"):
report = report_service.get_report(result["report_id"])
notification_service.send_notification(report, report_url)
```
## 6. API 设计
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/service-monitor/notification` | 配置页面 |
| GET | `/api/service-monitor/notification` | 获取配置 |
| PUT | `/api/service-monitor/notification` | 保存配置(管理员) |
| POST | `/api/service-monitor/notification/test-email` | 测试邮件 |
| POST | `/api/service-monitor/notification/test-dingtalk` | 测试钉钉 |
| POST | `/api/service-monitor/notification/test-wecom` | 测试企业微信 |
## 7. 安全考虑
1. **敏感信息加密**:SMTP 密码、钉钉密钥使用 Fernet 加密存储(复用现有 `crypto.py`
2. **权限控制**:仅管理员可配置和查看敏感信息
3. **接口保护**:测试接口加频率限制(防止滥用)
## 8. 不做的事
- 不支持短信通知(需要付费服务商)
- 不支持 Slack 等国外平台
- 不支持通知历史记录(后续可扩展)
- 不支持多套通知配置(如不同目标发不同人)
## 9. 后续扩展
- 支持按目标配置不同通知渠道
- 支持通知历史记录和重发
- 支持通知频率限制(如 1 小时内同类异常只通知一次)
\ No newline at end of file
......@@ -22,7 +22,7 @@ from flask import (
Response, redirect, url_for, stream_with_context,
)
from .services import target_service, report_service, runner_service, schedule_service
from .services import target_service, report_service, runner_service, schedule_service, notification_service
logger = logging.getLogger("service_monitor.routes")
......@@ -113,6 +113,9 @@ def page_schedule():
return redirect(url_for('auth.login'))
user = _current_user()
schedules = schedule_service.list_schedules()
# 为每个 schedule 生成自然语言描述
for s in schedules:
s['description'] = schedule_service._describe_schedule(s)
targets = target_service.list_targets(role=user.get('role', ''))
return render_template(
'service_monitor/schedule.html',
......@@ -122,6 +125,22 @@ def page_schedule():
)
@bp.route('/service-monitor/notification')
def page_notification():
"""通知配置页面(仅管理员)。"""
if 'user' not in session:
return redirect(url_for('auth.login'))
user = _current_user()
if user.get('role') != 'admin':
return redirect(url_for('service-monitor.page_index'))
config = notification_service.get_config_masked()
return render_template(
'service_monitor/notification.html',
user=user, config=config,
is_admin=True, active_menu='notification',
)
@bp.route('/service-monitor/run/<target_id>')
def page_run(target_id):
"""巡检执行页(仅管理员)。"""
......@@ -297,6 +316,22 @@ def api_delete_report(report_id):
return jsonify({"success": ok})
@bp.route('/api/service-monitor/reports/batch', methods=['DELETE'])
def api_batch_delete_reports():
"""批量删除巡检报告(管理员)。"""
guard = _require_admin_json()
if guard:
return guard
ids = (request.get_json(force=True) or {}).get("ids", [])
if not ids:
return jsonify({"success": False, "error": {"code": 400, "message": "未选择报告"}}), 400
deleted = 0
for rid in ids:
if report_service.delete_report(rid):
deleted += 1
return jsonify({"success": True, "deleted": deleted})
@bp.route('/api/service-monitor/reports/<report_id>/export', methods=['GET'])
def api_export_report(report_id):
guard = _require_login_json()
......@@ -400,3 +435,63 @@ def api_toggle_schedule(schedule_id):
return jsonify({"success": True, "schedule": sched})
except ValueError as e:
return jsonify({"success": False, "error": {"code": 400, "message": str(e)}}), 400
# ============================================================
# API:通知配置(管理员)
# ============================================================
@bp.route('/api/service-monitor/notification', methods=['GET'])
def api_get_notification():
"""获取通知配置。"""
guard = _require_admin_json()
if guard:
return guard
config = notification_service.get_config_masked()
return jsonify({"success": True, "config": config})
@bp.route('/api/service-monitor/notification', methods=['PUT'])
def api_save_notification():
"""保存通知配置。"""
guard = _require_admin_json()
if guard:
return guard
try:
data = request.get_json(force=True) or {}
config = notification_service.save_config(
data, updated_by=_current_user().get('username', 'admin')
)
return jsonify({"success": True, "config": config})
except Exception as e:
return jsonify({"success": False, "error": {"code": 400, "message": str(e)}}), 400
@bp.route('/api/service-monitor/notification/test-email', methods=['POST'])
def api_test_email():
"""测试邮件通知。"""
guard = _require_admin_json()
if guard:
return guard
result = notification_service.test_email()
return jsonify(result)
@bp.route('/api/service-monitor/notification/test-dingtalk', methods=['POST'])
def api_test_dingtalk():
"""测试钉钉通知。"""
guard = _require_admin_json()
if guard:
return guard
result = notification_service.test_dingtalk()
return jsonify(result)
@bp.route('/api/service-monitor/notification/test-wecom', methods=['POST'])
def api_test_wecom():
"""测试企业微信通知。"""
guard = _require_admin_json()
if guard:
return guard
result = notification_service.test_wecom()
return jsonify(result)
# -*- coding: utf-8 -*-
"""
notification_service.py — 通知配置与发送服务
支持渠道:
- 邮件(SMTP)
- 钉钉机器人
- 企业微信机器人
数据存储:service_monitor/data/notifications.json
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import smtplib
import time
import urllib.parse
from datetime import datetime
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Optional
from ..utils.paths import NOTIFICATIONS_FILE, ensure_dirs
from ..utils.crypto import encrypt_password, decrypt_password
logger = logging.getLogger("service_monitor.notification")
# 默认配置模板
DEFAULT_CONFIG = {
"email": {
"enabled": False,
"smtp_host": "",
"smtp_port": 465,
"smtp_user": "",
"smtp_password": "",
"use_ssl": True,
"recipients": [],
"subject_template": "【巡检报告】{target} - {status}"
},
"dingtalk": {
"enabled": False,
"webhook": "",
"secret": "",
"at_mobiles": []
},
"wecom": {
"enabled": False,
"webhook": ""
},
"trigger": {
"on_complete": True,
"on_abnormal_only": False,
"include_details": True,
"include_link": True
},
"updated_at": None,
"updated_by": None
}
def _load_config() -> dict:
"""加载通知配置(解密敏感字段)。"""
if not NOTIFICATIONS_FILE.exists():
return DEFAULT_CONFIG.copy()
try:
data = json.loads(NOTIFICATIONS_FILE.read_text(encoding="utf-8"))
# 解密敏感字段
if data.get("email", {}).get("smtp_password"):
data["email"]["smtp_password"] = decrypt_password(data["email"]["smtp_password"])
if data.get("dingtalk", {}).get("secret"):
data["dingtalk"]["secret"] = decrypt_password(data["dingtalk"]["secret"])
return data
except (json.JSONDecodeError, OSError) as e:
logger.error("加载通知配置失败: %s", e)
return DEFAULT_CONFIG.copy()
def _save_config(data: dict) -> None:
"""保存通知配置(加密敏感字段)。"""
ensure_dirs()
to_save = json.loads(json.dumps(data)) # 深拷贝
# 加密敏感字段
if to_save.get("email", {}).get("smtp_password"):
to_save["email"]["smtp_password"] = encrypt_password(to_save["email"]["smtp_password"])
if to_save.get("dingtalk", {}).get("secret"):
to_save["dingtalk"]["secret"] = encrypt_password(to_save["dingtalk"]["secret"])
NOTIFICATIONS_FILE.write_text(
json.dumps(to_save, ensure_ascii=False, indent=2),
encoding="utf-8"
)
def get_config() -> dict:
"""获取通知配置(敏感字段已解密,可直接展示)。"""
return _load_config()
def get_config_masked() -> dict:
"""获取通知配置(敏感字段脱敏,用于前端展示)。"""
config = _load_config()
# 密码脱敏
if config.get("email", {}).get("smtp_password"):
config["email"]["smtp_password"] = "******"
if config.get("dingtalk", {}).get("secret"):
config["dingtalk"]["secret"] = "******"
return config
def save_config(data: dict, updated_by: str = "admin") -> dict:
"""保存通知配置。"""
data["updated_at"] = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
data["updated_by"] = updated_by
_save_config(data)
logger.info("通知配置已更新 by %s", updated_by)
return get_config_masked()
# ============================================================
# 测试发送
# ============================================================
def test_email() -> dict:
"""发送测试邮件。"""
config = _load_config()
email_cfg = config.get("email", {})
if not email_cfg.get("enabled"):
return {"success": False, "message": "邮件通知未启用"}
smtp_host = email_cfg.get("smtp_host", "").strip()
smtp_port = int(email_cfg.get("smtp_port", 465))
smtp_user = email_cfg.get("smtp_user", "").strip()
smtp_password = email_cfg.get("smtp_password", "").strip()
use_ssl = email_cfg.get("use_ssl", True)
recipients = email_cfg.get("recipients", [])
if not smtp_host or not smtp_user or not smtp_password or not recipients:
return {"success": False, "message": "邮件配置不完整"}
# 构建测试邮件
msg = MIMEMultipart()
msg["From"] = smtp_user
msg["To"] = ", ".join(recipients)
msg["Subject"] = "【测试】巡检报告通知配置测试"
body = """
<h2>通知配置测试</h2>
<p>这是一封测试邮件,用于验证邮件通知配置是否正确。</p>
<p>发送时间:{time}</p>
<hr>
<p style="color: #666; font-size: 12px;">此邮件由服务监测模块自动发送</p>
""".format(time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
msg.attach(MIMEText(body, "html", "utf-8"))
try:
if use_ssl:
smtp = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=10)
else:
smtp = smtplib.SMTP(smtp_host, smtp_port, timeout=10)
smtp.starttls()
smtp.login(smtp_user, smtp_password)
smtp.sendmail(smtp_user, recipients, msg.as_string())
smtp.quit()
logger.info("测试邮件发送成功 -> %s", recipients)
return {"success": True, "message": f"测试邮件已发送至 {', '.join(recipients)}"}
except smtplib.SMTPAuthenticationError:
return {"success": False, "message": "SMTP 认证失败,请检查用户名和密码"}
except smtplib.SMTPException as e:
return {"success": False, "message": f"SMTP 错误: {e}"}
except Exception as e:
return {"success": False, "message": f"发送失败: {e}"}
def test_dingtalk() -> dict:
"""发送测试钉钉消息。"""
import requests
config = _load_config()
ding_cfg = config.get("dingtalk", {})
if not ding_cfg.get("enabled"):
return {"success": False, "message": "钉钉通知未启用"}
webhook = ding_cfg.get("webhook", "").strip()
secret = ding_cfg.get("secret", "").strip()
if not webhook:
return {"success": False, "message": "Webhook 地址未配置"}
# 构建消息
content = {
"msgtype": "text",
"text": {
"content": f"【测试】巡检报告通知配置测试\n\n发送时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n此消息用于验证钉钉通知配置是否正确。"
}
}
# 签名(如果有)
url = webhook
if secret:
timestamp = str(round(time.time() * 1000))
string_to_sign = f"{timestamp}\n{secret}"
hmac_code = hmac.new(
secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
digestmod=hashlib.sha256
).digest()
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code).decode())
url = f"{webhook}&timestamp={timestamp}&sign={sign}"
try:
resp = requests.post(url, json=content, timeout=10)
result = resp.json()
if result.get("errcode") == 0:
logger.info("钉钉测试消息发送成功")
return {"success": True, "message": "测试消息已发送到钉钉群"}
else:
return {"success": False, "message": f"钉钉返回错误: {result.get('errmsg', '未知错误')}"}
except Exception as e:
return {"success": False, "message": f"发送失败: {e}"}
def test_wecom() -> dict:
"""发送测试企业微信消息。"""
import requests
config = _load_config()
wecom_cfg = config.get("wecom", {})
if not wecom_cfg.get("enabled"):
return {"success": False, "message": "企业微信通知未启用"}
webhook = wecom_cfg.get("webhook", "").strip()
if not webhook:
return {"success": False, "message": "Webhook 地址未配置"}
# 构建消息
content = {
"msgtype": "text",
"text": {
"content": f"【测试】巡检报告通知配置测试\n\n发送时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n此消息用于验证企业微信通知配置是否正确。"
}
}
try:
resp = requests.post(webhook, json=content, timeout=10)
result = resp.json()
if result.get("errcode") == 0:
logger.info("企业微信测试消息发送成功")
return {"success": True, "message": "测试消息已发送到企业微信群"}
else:
return {"success": False, "message": f"企业微信返回错误: {result.get('errmsg', '未知错误')}"}
except Exception as e:
return {"success": False, "message": f"发送失败: {e}"}
# ============================================================
# 发送通知
# ============================================================
def send_notification(report: dict, report_url: str) -> bool:
"""根据配置发送通知。
Args:
report: 巡检报告数据
report_url: 报告链接地址
Returns:
是否发送成功(至少一个渠道成功)
"""
config = _load_config()
trigger_cfg = config.get("trigger", {})
# 判断是否需要通知
summary = report.get("summary", {})
has_abnormal = summary.get("警告", 0) > 0 or summary.get("严重", 0) > 0
if trigger_cfg.get("on_abnormal_only") and not has_abnormal:
logger.info("无异常且配置为仅异常时通知,跳过通知")
return False
success = False
# 发送邮件
if config.get("email", {}).get("enabled"):
if _send_email_notification(report, report_url, config):
success = True
# 发送钉钉
if config.get("dingtalk", {}).get("enabled"):
if _send_dingtalk_notification(report, report_url, config):
success = True
# 发送企业微信
if config.get("wecom", {}).get("enabled"):
if _send_wecom_notification(report, report_url, config):
success = True
return success
def _send_email_notification(report: dict, report_url: str, config: dict) -> bool:
"""发送邮件通知。"""
import requests
email_cfg = config.get("email", {})
trigger_cfg = config.get("trigger", {})
smtp_host = email_cfg.get("smtp_host", "").strip()
smtp_port = int(email_cfg.get("smtp_port", 465))
smtp_user = email_cfg.get("smtp_user", "").strip()
smtp_password = email_cfg.get("smtp_password", "").strip()
use_ssl = email_cfg.get("use_ssl", True)
recipients = email_cfg.get("recipients", [])
if not all([smtp_host, smtp_user, smtp_password, recipients]):
logger.warning("邮件配置不完整,跳过发送")
return False
# 构建主题
summary = report.get("summary", {})
status = "正常" if summary.get("警告", 0) == 0 and summary.get("严重", 0) == 0 else "异常"
subject = email_cfg.get("subject_template", "【巡检报告】{target} - {status}").format(
target=report.get("target_name", "未知目标"),
status=status
)
# 构建正文
body_lines = [
"<h2>巡检报告通知</h2>",
f"<p><b>目标:</b>{report.get('target_name', '-')}</p>",
f"<p><b>套件:</b>{'快速巡检' if report.get('suite') == 'quick' else '全量巡检'}</p>",
f"<p><b>完成时间:</b>{report.get('finished_at', '-')}</p>",
"<hr>",
f"<p><b>汇总:</b>正常 {summary.get('正常', 0)} / 警告 {summary.get('警告', 0)} / 严重 {summary.get('严重', 0)}</p>",
]
# 异常项详情
if trigger_cfg.get("include_details") and (summary.get("警告", 0) > 0 or summary.get("严重", 0) > 0):
body_lines.append("<h3>⚠️ 异常项</h3><ul>")
for mod in report.get("modules", []):
for item in mod.get("items", []):
if item.get("status") in ("警告", "严重"):
body_lines.append(
f"<li><b>{mod.get('name')}</b>: {item.get('name')} = {item.get('value')} "
f"(阈值:{item.get('threshold') or '-'},状态:{item.get('status')})</li>"
)
body_lines.append("</ul>")
# 报告链接
if trigger_cfg.get("include_link") and report_url:
body_lines.append(f"<hr><p><a href='{report_url}'>查看完整报告</a></p>")
body_lines.append("<p style='color: #666; font-size: 12px; margin-top: 20px;'>此邮件由服务监测模块自动发送</p>")
msg = MIMEMultipart()
msg["From"] = smtp_user
msg["To"] = ", ".join(recipients)
msg["Subject"] = subject
msg.attach(MIMEText("\n".join(body_lines), "html", "utf-8"))
try:
if use_ssl:
smtp = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=10)
else:
smtp = smtplib.SMTP(smtp_host, smtp_port, timeout=10)
smtp.starttls()
smtp.login(smtp_user, smtp_password)
smtp.sendmail(smtp_user, recipients, msg.as_string())
smtp.quit()
logger.info("邮件通知已发送 -> %s", recipients)
return True
except Exception as e:
logger.error("邮件通知发送失败: %s", e)
return False
def _send_dingtalk_notification(report: dict, report_url: str, config: dict) -> bool:
"""发送钉钉通知。"""
import requests
ding_cfg = config.get("dingtalk", {})
trigger_cfg = config.get("trigger", {})
webhook = ding_cfg.get("webhook", "").strip()
secret = ding_cfg.get("secret", "").strip()
at_mobiles = ding_cfg.get("at_mobiles", [])
if not webhook:
return False
summary = report.get("summary", {})
status_emoji = "✅" if summary.get("警告", 0) == 0 and summary.get("严重", 0) == 0 else "⚠️"
text_lines = [
f"{status_emoji} 【巡检报告】{report.get('target_name', '-')}",
f"套件:{'快速巡检' if report.get('suite') == 'quick' else '全量巡检'}",
f"时间:{report.get('finished_at', '-')}",
f"结果:正常 {summary.get('正常', 0)} / 警告 {summary.get('警告', 0)} / 严重 {summary.get('严重', 0)}",
]
if trigger_cfg.get("include_link") and report_url:
text_lines.append(f"链接:{report_url}")
content = {
"msgtype": "text",
"text": {"content": "\n".join(text_lines)}
}
if at_mobiles:
content["at"] = {"atMobiles": at_mobiles, "isAtAll": False}
url = webhook
if secret:
timestamp = str(round(time.time() * 1000))
string_to_sign = f"{timestamp}\n{secret}"
hmac_code = hmac.new(
secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
digestmod=hashlib.sha256
).digest()
import base64
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code).decode())
url = f"{webhook}&timestamp={timestamp}&sign={sign}"
try:
resp = requests.post(url, json=content, timeout=10)
result = resp.json()
if result.get("errcode") == 0:
logger.info("钉钉通知已发送")
return True
else:
logger.error("钉钉通知失败: %s", result.get("errmsg"))
return False
except Exception as e:
logger.error("钉钉通知发送失败: %s", e)
return False
def _send_wecom_notification(report: dict, report_url: str, config: dict) -> bool:
"""发送企业微信通知。"""
import requests
wecom_cfg = config.get("wecom", {})
trigger_cfg = config.get("trigger", {})
webhook = wecom_cfg.get("webhook", "").strip()
if not webhook:
return False
summary = report.get("summary", {})
status_emoji = "✅" if summary.get("警告", 0) == 0 and summary.get("严重", 0) == 0 else "⚠️"
text_lines = [
f"{status_emoji} 【巡检报告】{report.get('target_name', '-')}",
f"套件:{'快速巡检' if report.get('suite') == 'quick' else '全量巡检'}",
f"时间:{report.get('finished_at', '-')}",
f"结果:正常 {summary.get('正常', 0)} / 警告 {summary.get('警告', 0)} / 严重 {summary.get('严重', 0)}",
]
if trigger_cfg.get("include_link") and report_url:
text_lines.append(f"链接:{report_url}")
content = {
"msgtype": "text",
"text": {"content": "\n".join(text_lines)}
}
try:
resp = requests.post(webhook, json=content, timeout=10)
result = resp.json()
if result.get("errcode") == 0:
logger.info("企业微信通知已发送")
return True
else:
logger.error("企业微信通知失败: %s", result.get("errmsg"))
return False
except Exception as e:
logger.error("企业微信通知发送失败: %s", e)
return False
......@@ -268,6 +268,19 @@ def run_inspection_sync(target_id: str, suite: str) -> dict:
for k in total_summary:
total_summary[k] += ms.get(k, 0)
# 发送通知(定时任务触发的巡检)
try:
from . import notification_service
report = report_service.get_report(report_id)
if report:
# 构建报告链接(需要从配置获取外部访问地址)
import os
host = os.environ.get('EXTERNAL_HOST', 'http://192.168.5.60:8088')
report_url = f"{host}/service-monitor/report/{report_id}"
notification_service.send_notification(report, report_url)
except Exception as e:
logger.warning("发送通知失败: %s", e)
return {
"success": True,
"report_id": report_id,
......
......@@ -23,6 +23,7 @@ DATA_DIR = MODULE_DIR / "data"
REPORTS_DIR = DATA_DIR / "reports"
TARGETS_FILE = DATA_DIR / "targets.json"
SCHEDULES_FILE = DATA_DIR / "schedules.json"
NOTIFICATIONS_FILE = DATA_DIR / "notifications.json"
def ensure_dirs() -> None:
......
......@@ -224,6 +224,10 @@
<span>定时任务</span>
</a>
{% if is_admin %}
<a href="/service-monitor/notification" class="nav-item {{ 'active' if active_menu == 'notification' }}">
<span class="nav-icon">🔔</span>
<span>通知配置</span>
</a>
<a href="/service-monitor/targets" class="nav-item {{ 'active' if active_menu == 'manage' }}">
<span class="nav-icon">⚙️</span>
<span>目标管理</span>
......
{% extends "service_monitor/base.html" %}
{% block title %}通知配置{% endblock %}
{% block extra_css %}
<style>
.config-section { background: #fff; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,.06); border: 1px solid var(--gray-200); margin-bottom: 20px; overflow: hidden; }
.section-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; background: var(--gray-50); border-bottom: 1px solid var(--gray-200); }
.section-title { font-size: 16px; font-weight: 700; color: var(--gray-900); display: flex; align-items: center; gap: 8px; }
.section-body { padding: 20px; }
.form-row { display: flex; gap: 16px; margin-bottom: 14px; }
.form-row .form-group { flex: 1; margin-bottom: 0; }
.form-group { margin-bottom: 14px; }
.form-group label { display: block; font-size: 13px; color: var(--gray-700); margin-bottom: 5px; font-weight: 600; }
.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 9px 12px; border: 1px solid var(--gray-200); border-radius: 8px; font-size: 14px; }
.form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: none; border-color: var(--primary); }
.form-hint { font-size: 12px; color: var(--gray-500); margin-top: 4px; }
.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid var(--gray-100); }
.toggle-row:last-child { border-bottom: none; }
.toggle-label { font-size: 14px; color: var(--gray-700); }
.toggle-switch { position: relative; width: 48px; height: 26px; }
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider { position: absolute; cursor: pointer; inset: 0; background: var(--gray-300); border-radius: 13px; transition: .2s; }
.toggle-slider:before { position: absolute; content: ""; height: 20px; width: 20px; left: 3px; bottom: 3px; background: #fff; border-radius: 50%; transition: .2s; }
.toggle-switch input:checked + .toggle-slider { background: var(--primary); }
.toggle-switch input:checked + .toggle-slider:before { transform: translateX(22px); }
.btn-test { background: var(--gray-100); color: var(--gray-700); border: 1px solid var(--gray-200); padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; }
.btn-test:hover { background: var(--gray-200); }
.btn-test:disabled { opacity: .5; cursor: not-allowed; }
.btn-save { background: var(--primary); color: #fff; border: none; padding: 10px 24px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; }
.btn-save:hover { background: var(--primary-hover); }
.test-result { margin-top: 10px; padding: 10px 14px; border-radius: 8px; font-size: 13px; }
.test-success { background: #dcfce7; color: #15803d; }
.test-error { background: #fee2e2; color: #b91c1c; }
.actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--gray-200); }
@media screen and (max-width: 768px) {
.form-row { flex-direction: column; gap: 0; }
.form-row .form-group { margin-bottom: 14px; }
}
</style>
{% endblock %}
{% block content %}
<div class="section-head">
<div class="section-title">🔔 通知配置</div>
</div>
<!-- 邮件通知 -->
<div class="config-section">
<div class="section-header">
<div class="section-title">📧 邮件通知</div>
<label class="toggle-switch">
<input type="checkbox" id="email-enabled" onchange="toggleSection('email')">
<span class="toggle-slider"></span>
</label>
</div>
<div class="section-body" id="email-section" style="display:none;">
<div class="form-row">
<div class="form-group">
<label>SMTP 服务器</label>
<input type="text" id="email-host" placeholder="如:smtp.qq.com">
</div>
<div class="form-group" style="flex: 0 0 120px;">
<label>端口</label>
<input type="number" id="email-port" value="465" placeholder="465">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>发件人邮箱</label>
<input type="email" id="email-user" placeholder="admin@example.com">
</div>
<div class="form-group">
<label>邮箱授权码</label>
<input type="password" id="email-password" placeholder="SMTP 认证密码">
</div>
</div>
<div class="form-group">
<label>收件人列表</label>
<textarea id="email-recipients" rows="2" placeholder="多个邮箱用英文逗号分隔"></textarea>
<div class="form-hint">多个收件人用英文逗号分隔,如:user1@example.com, user2@example.com</div>
</div>
<div class="toggle-row">
<span class="toggle-label">使用 SSL 加密</span>
<label class="toggle-switch">
<input type="checkbox" id="email-ssl" checked>
<span class="toggle-slider"></span>
</label>
</div>
<div class="form-group">
<label>邮件主题模板</label>
<input type="text" id="email-subject" value="【巡检报告】{target} - {status}">
<div class="form-hint">支持变量:{target} 目标名称, {status} 状态, {suite} 套件</div>
</div>
<div style="display: flex; gap: 10px; align-items: center;">
<button class="btn-test" onclick="testEmail()">发送测试邮件</button>
<div id="email-test-result"></div>
</div>
</div>
</div>
<!-- 钉钉通知 -->
<div class="config-section">
<div class="section-header">
<div class="section-title">📱 钉钉机器人</div>
<label class="toggle-switch">
<input type="checkbox" id="dingtalk-enabled" onchange="toggleSection('dingtalk')">
<span class="toggle-slider"></span>
</label>
</div>
<div class="section-body" id="dingtalk-section" style="display:none;">
<div class="form-group">
<label>Webhook 地址</label>
<input type="text" id="dingtalk-webhook" placeholder="https://oapi.dingtalk.com/robot/send?access_token=xxx">
</div>
<div class="form-group">
<label>签名密钥(可选)</label>
<input type="password" id="dingtalk-secret" placeholder="加签机器人的密钥">
<div class="form-hint">如果机器人设置了加签,需要填写密钥</div>
</div>
<div class="form-group">
<label>@人员手机号(可选)</label>
<input type="text" id="dingtalk-at" placeholder="多个手机号用英文逗号分隔">
</div>
<div style="display: flex; gap: 10px; align-items: center;">
<button class="btn-test" onclick="testDingtalk()">发送测试消息</button>
<div id="dingtalk-test-result"></div>
</div>
</div>
</div>
<!-- 企业微信通知 -->
<div class="config-section">
<div class="section-header">
<div class="section-title">💬 企业微信机器人</div>
<label class="toggle-switch">
<input type="checkbox" id="wecom-enabled" onchange="toggleSection('wecom')">
<span class="toggle-slider"></span>
</label>
</div>
<div class="section-body" id="wecom-section" style="display:none;">
<div class="form-group">
<label>Webhook 地址</label>
<input type="text" id="wecom-webhook" placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx">
</div>
<div style="display: flex; gap: 10px; align-items: center;">
<button class="btn-test" onclick="testWecom()">发送测试消息</button>
<div id="wecom-test-result"></div>
</div>
</div>
</div>
<!-- 触发条件 -->
<div class="config-section">
<div class="section-header">
<div class="section-title">⚡ 触发条件</div>
</div>
<div class="section-body">
<div class="toggle-row">
<span class="toggle-label">报告完成后立即通知</span>
<label class="toggle-switch">
<input type="checkbox" id="trigger-complete" checked>
<span class="toggle-slider"></span>
</label>
</div>
<div class="toggle-row">
<span class="toggle-label">仅异常(警告/严重)时通知</span>
<label class="toggle-switch">
<input type="checkbox" id="trigger-abnormal">
<span class="toggle-slider"></span>
</label>
</div>
<div class="toggle-row">
<span class="toggle-label">包含异常详情</span>
<label class="toggle-switch">
<input type="checkbox" id="trigger-details" checked>
<span class="toggle-slider"></span>
</label>
</div>
<div class="toggle-row">
<span class="toggle-label">包含报告链接</span>
<label class="toggle-switch">
<input type="checkbox" id="trigger-link" checked>
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div class="actions">
<button class="btn-save" onclick="saveConfig()">保存配置</button>
</div>
{% endblock %}
{% block extra_js %}
<script>
// 加载配置
async function loadConfig() {
try {
const r = await fetch('/api/service-monitor/notification', { credentials: 'include' });
const d = await r.json();
if (d.success) {
const cfg = d.config;
// 邮件
document.getElementById('email-enabled').checked = cfg.email?.enabled || false;
document.getElementById('email-host').value = cfg.email?.smtp_host || '';
document.getElementById('email-port').value = cfg.email?.smtp_port || 465;
document.getElementById('email-user').value = cfg.email?.smtp_user || '';
document.getElementById('email-password').value = ''; // 密码不回填
document.getElementById('email-recipients').value = (cfg.email?.recipients || []).join(', ');
document.getElementById('email-ssl').checked = cfg.email?.use_ssl !== false;
document.getElementById('email-subject').value = cfg.email?.subject_template || '【巡检报告】{target} - {status}';
toggleSection('email');
// 钉钉
document.getElementById('dingtalk-enabled').checked = cfg.dingtalk?.enabled || false;
document.getElementById('dingtalk-webhook').value = cfg.dingtalk?.webhook || '';
document.getElementById('dingtalk-secret').value = '';
document.getElementById('dingtalk-at').value = (cfg.dingtalk?.at_mobiles || []).join(', ');
toggleSection('dingtalk');
// 企业微信
document.getElementById('wecom-enabled').checked = cfg.wecom?.enabled || false;
document.getElementById('wecom-webhook').value = cfg.wecom?.webhook || '';
toggleSection('wecom');
// 触发条件
document.getElementById('trigger-complete').checked = cfg.trigger?.on_complete !== false;
document.getElementById('trigger-abnormal').checked = cfg.trigger?.on_abnormal_only || false;
document.getElementById('trigger-details').checked = cfg.trigger?.include_details !== false;
document.getElementById('trigger-link').checked = cfg.trigger?.include_link !== false;
}
} catch (e) {
console.error('加载配置失败:', e);
}
}
function toggleSection(name) {
const enabled = document.getElementById(name + '-enabled').checked;
const section = document.getElementById(name + '-section');
section.style.display = enabled ? '' : 'none';
}
async function testEmail() {
const btn = event.target;
btn.disabled = true;
btn.textContent = '发送中...';
document.getElementById('email-test-result').innerHTML = '';
try {
// 先保存当前配置
await saveConfig(true);
const r = await fetch('/api/service-monitor/notification/test-email', {
method: 'POST',
credentials: 'include'
});
const d = await r.json();
const el = document.getElementById('email-test-result');
el.innerHTML = `<div class="test-result ${d.success ? 'test-success' : 'test-error'}">${d.message}</div>`;
} catch (e) {
document.getElementById('email-test-result').innerHTML = `<div class="test-result test-error">请求失败</div>`;
}
btn.disabled = false;
btn.textContent = '发送测试邮件';
}
async function testDingtalk() {
const btn = event.target;
btn.disabled = true;
btn.textContent = '发送中...';
document.getElementById('dingtalk-test-result').innerHTML = '';
try {
await saveConfig(true);
const r = await fetch('/api/service-monitor/notification/test-dingtalk', {
method: 'POST',
credentials: 'include'
});
const d = await r.json();
const el = document.getElementById('dingtalk-test-result');
el.innerHTML = `<div class="test-result ${d.success ? 'test-success' : 'test-error'}">${d.message}</div>`;
} catch (e) {
document.getElementById('dingtalk-test-result').innerHTML = `<div class="test-result test-error">请求失败</div>`;
}
btn.disabled = false;
btn.textContent = '发送测试消息';
}
async function testWecom() {
const btn = event.target;
btn.disabled = true;
btn.textContent = '发送中...';
document.getElementById('wecom-test-result').innerHTML = '';
try {
await saveConfig(true);
const r = await fetch('/api/service-monitor/notification/test-wecom', {
method: 'POST',
credentials: 'include'
});
const d = await r.json();
const el = document.getElementById('wecom-test-result');
el.innerHTML = `<div class="test-result ${d.success ? 'test-success' : 'test-error'}">${d.message}</div>`;
} catch (e) {
document.getElementById('wecom-test-result').innerHTML = `<div class="test-result test-error">请求失败</div>`;
}
btn.disabled = false;
btn.textContent = '发送测试消息';
}
async function saveConfig(silent = false) {
const data = {
email: {
enabled: document.getElementById('email-enabled').checked,
smtp_host: document.getElementById('email-host').value.trim(),
smtp_port: parseInt(document.getElementById('email-port').value) || 465,
smtp_user: document.getElementById('email-user').value.trim(),
smtp_password: document.getElementById('email-password').value,
use_ssl: document.getElementById('email-ssl').checked,
recipients: document.getElementById('email-recipients').value.split(',').map(s => s.trim()).filter(Boolean),
subject_template: document.getElementById('email-subject').value.trim()
},
dingtalk: {
enabled: document.getElementById('dingtalk-enabled').checked,
webhook: document.getElementById('dingtalk-webhook').value.trim(),
secret: document.getElementById('dingtalk-secret').value,
at_mobiles: document.getElementById('dingtalk-at').value.split(',').map(s => s.trim()).filter(Boolean)
},
wecom: {
enabled: document.getElementById('wecom-enabled').checked,
webhook: document.getElementById('wecom-webhook').value.trim()
},
trigger: {
on_complete: document.getElementById('trigger-complete').checked,
on_abnormal_only: document.getElementById('trigger-abnormal').checked,
include_details: document.getElementById('trigger-details').checked,
include_link: document.getElementById('trigger-link').checked
}
};
try {
const r = await fetch('/api/service-monitor/notification', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data)
});
const d = await r.json();
if (!silent) {
if (d.success) {
alert('配置已保存');
} else {
alert(d.error?.message || '保存失败');
}
}
return d.success;
} catch (e) {
if (!silent) alert('请求失败');
return false;
}
}
// 页面加载时读取配置
loadConfig();
</script>
{% endblock %}
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论