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

feat(monitor): 钉钉机器人关键词与负责人员配置(阶段十五)

钉钉通知增强:
- notification_service.py 新增 _build_dingtalk_markdown(关键词去重补全 + 负责人末尾追加)与 _send_dingtalk_message(统一加签+发送),三个钉钉调用点收敛复用
- DEFAULT_CONFIG.dingtalk 新增 keyword(可选,空=不处理)、responsible_person(默认"陈泽键",空=不展示)
- 前端 Notification.vue 钉钉卡片新增"自定义关键词/负责人员"表单,DingTalkConfig 类型同步,旧配置自动回填
- 新增 tests/test_notification_service.py 12 用例(全部 mock HTTP,零真实发送)

一并入库(遗留变更):
- deploy/deploy_update.py:5.60 Docker 容器热更新部署脚本(密钥免密 + tar 进容器 + 健康检查),替代已过时的 upload_to_server.py
- PRD_问题处理/计划执行_监测统计接口404 ×2:8-25 统计接口 404 修复的配套文档补交
- HANDOFF_服务监测:阶段十五进度 + 坑 25-29 + 下一步计划

验证:12 个新单测全绿、vue-tsc 类型检查通过、已部署 5.60(/api/health 正常)
Co-Authored-By: 's avatarClaude Fable 5 <noreply@anthropic.com>
上级 6eb7a784
# 计划执行:监测统计接口 404 修复
## 文档信息
- 创建时间:2026-08-25
- 关联问题文档:`Docs/需求文档/服务监测/PRD_问题处理_监测统计接口404.md`
- 预计改动文件:3 个(2 个后端 + 2 个前端 API 包装)
- 预计新增文件:0 个
- 分支:troubleshoot-ai-assistant
---
## 执行概述
修复监测统计页面两个 404 接口:
1. `GET /api/service-monitor/statistics/health-trend` —— 健康趋势聚合(按天统计 正常/警告/严重)
2. `GET /api/service-monitor/notification/check-missing-reports` —— 定时巡检缺失报告检测
根因:前端(服务器上的 dist 构建)已调用,后端路由与服务函数从未实现。属实现缺失,非路径错误。
---
## 任务分解与实施计划
### Step 1:后端 — statistics_service 新增 get_health_trend()
**文件**`skill/code/web/service_monitor/services/statistics_service.py`
**核心函数**
```python
def get_health_trend(target_id, start_date, end_date) -> dict:
"""计算每日健康趋势。
Returns:
{
"days": [
{
"date": "2026-08-01",
"total_reports": int,
"normal_count": int,
"warning_count": int,
"critical_count": int,
"abnormal_rate": float,
},
...
] # 按日期升序
}
"""
```
**实现要点**
- 复用 `_parse_date_filter()` / `_load_reports_in_range()`(默认 30 天、日期校验、报告加载逻辑一致)
-`started_at[:10]`(自然日)分组,聚合报告 `summary` 的 正常/警告/严重
- 只返回有报告的日期(与 `get_overview` 语义一致,不做日期补零)
- 返回结构 `{"days": [...]}`,供前端趋势图表填充
---
### Step 2:前端 — statistics.ts 新增 getHealthTrend 包装
**文件**`frontend/src/api/service-monitor/statistics.ts`
```typescript
/** 获取每日健康趋势 */
export async function getHealthTrend(
params?: StatsParams
): Promise<{ success: boolean; data: HealthTrendData }> {
const res = await http.get<{ success: boolean; data: HealthTrendData }>(
'/api/service-monitor/statistics/health-trend',
{ params }
)
return res.data
}
```
**类型**:在 `frontend/src/types/service-monitor` 补充 `HealthTrendData``{ days: HealthTrendDay[] }`)。
---
### Step 3:后端 — notification_service 新增 check_missing_reports()
**文件**`skill/code/web/service_monitor/services/notification_service.py`
**核心函数**
```python
def check_missing_reports(days: int = 7, target_id: Optional[str] = None) -> dict:
"""检测定时巡检是否有缺失报告。
Returns:
{
"checked": [
{
"schedule_id": str,
"name": str,
"target_id": str,
"target_name": str,
"suite": str,
"cron": str,
"enabled": bool,
"expected_dates": [str, ...],
"missing_dates": [str, ...],
"report_count": int,
"last_report_at": str | None,
"ok": bool,
},
...
],
"total_missing": int,
}
"""
```
**实现要点**
- 只检查 `enabled == True` 的定时任务,默认检查最近 7 天(含今天)
-`schedules.json``cron` 表达式解析应执行日期:用 `croniter`(仓库已有依赖)从 `start_date` 起逐一生成至今天,过滤 ≤ 今天
-`report_service.list_reports()`(不传 target 过滤会慢,先取全部再按 target_id 过滤)收集已有报告日期
- 缺失 = 应执行日期 - 已有报告日期,计算 `missing_dates`
- 不依赖定时任务状态字段(last_report_id 等),以实际报告文件为准,更可靠
- 极端情况(cron 解析失败等)跳过该任务并标记 `ok: false`,不影响整体
---
### Step 4:后端 — routes.py 新增两个端点
**文件**`skill/code/web/service_monitor/routes.py`
新增统计端点(放在 statistics 段落):
```python
@bp.route('/api/service-monitor/statistics/health-trend', methods=['GET'])
def api_statistics_health_trend():
"""每日健康趋势(登录即可查看)。"""
guard = _require_login_json()
if guard:
return guard
target_id = request.args.get('target_id') or None
start_date = request.args.get('start_date') or None
end_date = request.args.get('end_date') or None
data = statistics_service.get_health_trend(target_id, start_date, end_date)
return jsonify({"success": True, "data": data})
```
新增通知端点(放在通知配置段落之后):
```python
@bp.route('/api/service-monitor/notification/check-missing-reports', methods=['GET'])
def api_check_missing_reports():
"""检测定时巡检缺失报告(登录即可查看)。"""
guard = _require_login_json()
if guard:
return guard
days = request.args.get('days', default=7, type=int)
target_id = request.args.get('target_id') or None
data = notification_service.check_missing_reports(days=days, target_id=target_id)
return jsonify({"success": True, "data": data})
```
---
### Step 5:前端 — notification.ts 新增 checkMissingReports 包装
**文件**`frontend/src/api/service-monitor/notification.ts`
```typescript
/** 检测定时巡检缺失报告 */
export async function checkMissingReports(
params?: { days?: number; target_id?: string }
): Promise<{ success: boolean; data: MissingReportsData }> {
const res = await http.get<{ success: boolean; data: MissingReportsData }>(
'/api/service-monitor/notification/check-missing-reports',
{ params }
)
return res.data
}
```
**类型**:在 `frontend/src/types/service-monitor` 补充 `MissingReportsData`
---
### Step 6:本地测试
```bash
cd skill/code && python -m pytest -v
```
**单元测试**(service_monitor/tests/ 模块私有测试,可选新增):
- `get_health_trend`:构造 3 份跨日期的 mock 报告,断言按天分组聚合正确
- `check_missing_reports`:mock schedules(daily/周末) + 构造缺失报告,断言 missing_dates 正确
**手动冒烟**(本地起服务后):
```bash
curl "http://localhost:8088/api/service-monitor/statistics/health-trend?start_date=2026-07-26&end_date=2026-08-25"
curl "http://localhost:8088/api/service-monitor/notification/check-missing-reports?days=7"
```
未登录返回 401 即说明路由已注册(不再是 404)。
---
### Step 7:部署到 5.60
```bash
! cd deploy && SSH_PASSWORD='***' python upload_to_server.py
```
**注意**(前端 dist):
- 后端代码通过 `upload_to_server.py` 递归上传即可(service_monitor 已覆盖,自动排除 `data/`
- 服务器上 `/opt/troubleshoot/dist/` 是 volume 挂载目录,且**当前服务器 dist 与本仓库不同步**(本地无 dist 构建产物)。若需前端趋势展示落地,需在构建机 `npm run build` 后同步 dist。本期仅修复后端 404,前端调用无需改动即可恢复正常(服务器 dist 已包含这两个调用)。
**验证**
```bash
curl http://localhost:8088/api/service-monitor/statistics/health-trend # 200/401,非 404
curl http://localhost:8088/api/service-monitor/notification/check-missing-reports # 200/401,非 404
# 权威验证
cd deploy && python verify_deployment.py
```
登录后浏览器打开统计页面,控制台无 404。
---
## 验收标准
| 检查项 | 验证方法 |
|--------|---------|
| 两个接口不再 404 | curl 返回 200(登录后)或 401(未登录),不再是 404 |
| health-trend 返回结构化数据 | `data.days[]` 含 date + 正常/警告/严重 计数,按日期升序 |
| check-missing-reports 返回检测结果 | `data.checked[]` 含 expected/missing 日期,`total_missing` 计数正确 |
| 现有功能不回归 | 143 个单元测试全绿 |
| 统计页面趋势模块正常 | 浏览器控制台无 404,图表有数据 |
## 测试计划
| 层 | 方法 | 覆盖点 |
|----|------|--------|
| 单元测试 | pytest(现有 143 用例) | 不回归 |
| 服务函数 | Python REPL 直接调用 | 分组/日期/空数据边界 |
| API 冒烟 | curl(本地 + 5.60) | 路由存在、参数校验 |
| 页面验证 | 浏览器 5.60 统计页 | 无 404、趋势正常 |
## 风险评估
| 风险 | 等级 | 缓解 |
|------|------|------|
| croniter 解析复杂 cron 失败 | 低 | try/except 跳过该任务并标记,不影响整体 |
| 大量报告时 list_reports 全量加载慢 | 中 | 复用 limit=200 上限;缺失检测按目标过滤 |
| 服务器 dist 与本地源码不同步 | 低 | 本期不改前端;部署时提示用户 dist 同步策略 |
| 前后端类型不一致 | 低 | 前端包装与后端返回结构对齐,文档记录结构 |
## 实施记录
| 步骤 | 状态 | 时间 | 备注 |
|------|------|------|------|
| Step 1: get_health_trend() | 待执行 | - | statistics_service.py |
| Step 2: statistics.ts 包装 | 待执行 | - | 如本地前端需同步 |
| Step 3: check_missing_reports() | 待执行 | - | notification_service.py |
| Step 4: routes.py 两个端点 | 待执行 | - | - |
| Step 5: notification.ts 包装 | 待执行 | - | 如本地前端需同步 |
| Step 6: 本地测试 | 待执行 | - | - |
| Step 7: 部署到 5.60 | 待执行 | - | - |
## 后续工作
1. 服务器 dist 版本与本地源码对齐(前端构建产物统一提交/托管),避免再次出现"前端已调、后端没有"的错位
2. 健康趋势图表在统计页的完整展示(当前仅后端就绪,页面展示依赖 dist 同步)
3. 缺失报告检测后续可接入通知渠道(有缺失时推送告警)
\ No newline at end of file
# 计划执行:钉钉机器人关键词与负责人员配置
> 来源:PRD_需求文档_钉钉机器人关键词与负责人员配置.md
> 日期:2026-09-14
> 状态:执行中
## 1. 实现范围
### 1.1 后端通知服务
文件:`skill/code/web/service_monitor/services/notification_service.py`
-`DEFAULT_CONFIG.dingtalk` 增加 `keyword``responsible_person` 默认字段。
- 增加统一的钉钉 Markdown 文本处理逻辑:
- 关键词非空且正文不包含时,补到标题和正文。
- 负责人员非空时,追加责任人行。
- 应用于巡检完成、报告缺失告警、测试消息三类钉钉消息。
- 保持敏感字段加密逻辑不变,兼容缺少新字段的旧配置。
### 1.2 前端配置页面
文件:`frontend/src/views/service-monitor/Notification.vue`
- 扩展初始钉钉配置对象。
- 在钉钉机器人区域增加关键词输入框和负责人员输入框。
- 加载旧配置时通过默认值兼容缺失字段。
- 保存时沿用现有通知配置接口,不新增 API。
### 1.3 前端类型
文件:`frontend/src/types/service-monitor.ts`
- `DingTalkConfig` 增加 `keyword: string``responsible_person: string`
### 1.4 测试
- 增加后端纯函数/消息构造测试,验证关键词去重、责任人追加及空配置兼容。
- 执行服务监测 pytest。
- 执行前端 TypeScript 构建或项目既有检查命令。
- 不调用钉钉测试接口,不发送真实消息。
## 2. 实施步骤
1. 确认当前分支和工作区,避免覆盖已有用户文件。
2. 修改后端默认配置和三类钉钉消息构造逻辑。
3. 修改 Vue 页面及 TypeScript 类型。
4. 编写或更新自动化测试,使用 mock HTTP 请求,不访问真实 Webhook。
5. 运行后端测试和前端编译检查。
6. 检查 git diff,确认未修改邮件/企微行为、加签算法和 API 路径。
7. 汇总验证结果;真实钉钉联调由用户手工完成。
## 3. 风险与处理
| 风险 | 处理方式 |
|---|---|
| 旧 notifications.json 缺少字段 | 读取时使用 `.get()` 和默认值,页面初始化提供完整默认结构 |
| 关键词重复显示 | 仅当正文不含关键词时补充,并同步保证标题含关键词 |
| 报告缺失告警漏加配置 | 复用统一处理函数,覆盖所有钉钉 Markdown 消息 |
| 加签逻辑回归 | 不修改 URL 签名代码,仅修改消息内容 |
| 误发真实通知 | 测试只 mock HTTP 请求,不调用测试发送接口 |
## 4. 验收清单
- [ ] PRD 与本计划文档已生成。
- [ ] 关键词和负责人员可在页面配置、保存、回显。
- [ ] 三类钉钉消息按规则包含关键词和责任人。
- [ ] 空值和旧配置兼容。
- [ ] 自动化测试通过。
- [ ] 前端编译检查通过。
- [ ] 未发送真实钉钉测试消息。
# 问题处理:监测统计接口 404
## 文档信息
- 创建时间:2026-08-25
- 问题来源:用户反馈(5.60 服务界面上监测统计页面报 404)
- 关联模块:service_monitor / statistics、notification
- 排查人:czj
---
## 1. 问题描述
### 错误信息
浏览器(监测统计页面)发起以下两个接口请求,均返回 HTTP 404:
```
GET http://192.168.5.60:8088/api/service-monitor/notification/check-missing-reports
GET http://192.168.5.60:8088/api/service-monitor/statistics/health-trend?start_date=2026-07-26&end_date=2026-08-25
```
### 触发条件
打开 `http://192.168.5.60:8088/service-monitor/statistics`(监测统计)页面时触发。
---
## 2. 根因分析
### 结论:后端路由完全没有实现(实现缺失,非路径写错)
对全代码库(`skill/code/web/``frontend/src/``frontend/dist/`、模板)进行 `check-missing-reports``health-trend` 字符串检索:
| 位置 | 结果 |
|------|------|
| `service_monitor/routes.py`(677 行,API 路由全集) | ❌ 无 `health-trend` 端点;❌ 无 `check-missing-reports` 端点 |
| `services/statistics_service.py` | ❌ 无 `get_health_trend()` 函数 |
| `services/notification_service.py` | ❌ 无 `check_missing_reports()` 函数 |
| `frontend/src/api/service-monitor/statistics.ts` | ❌ 只有 5 个 API 包装,无 `health-trend` |
| `frontend/src/api/service-monitor/notification.ts` | ❌ 只有 GET/PUT/3 个测试端点,无 `check-missing-reports` |
| `frontend/src/views/service-monitor/Statistics.vue` | ❌ 未调用 health-trend |
| 模板 `templates/service_monitor/statistics.html` | ❌ 未调用 |
| `frontend/dist/`(生产构建产物) | ❌ 不存在该目录(本地未构建) |
| Git 全历史(`git log -S`) | ❌ 从未提交过这两个端点 |
### 为什么是 404 而不是 500
Flask 蓝图(Blueprint,前缀 `/api/service-monitor/*`)中未注册的路由,会被 Flask 默认的 404 处理逻辑拦截,返回 `{"success": false, ...}` / HTML 404。这与"服务端逻辑报错"(500)不同——服务端**根本不知道**这两个接口的存在。
### 为什么页面会出现两个"孤儿"请求
监测统计页面(Vue SPA,浏览器加载的 `/assets/index-*.js`)调用了这两个接口,但:
1. **`health-trend`**:这是统计模块 PRD(`PRD_需求文档_监测统计模块.md`)中规划的"查看各目标服务器的健康趋势"功能,设计留有前端调用,但后端一直未实现对应聚合函数与路由。
2. **`check-missing-reports`**:规划用于"定时巡检是否有缺失报告"的运维提示,同样只停留在前端调用层面。
前端构建版本与后端代码版本不同步(前端 SPA 已包含趋势/缺失检测逻辑,后端从未实现),导致线上 404。
---
## 3. 影响范围
| 影响 | 说明 |
|------|------|
| 监测统计页面部分模块加载失败 | 健康趋势图表、缺失报告提示拉取失败,控制台出现 404 |
| 统计数据不完整 | 页面其余统计(概览/模块/检测项趋势)正常,但趋势模块无数据 |
| 部署一致性隐患 | 前端已构建、后端未实现,运行版本与源码仓库不一致 |
---
## 4. 修复方案
### 4.1 健康趋势(statistics)
`services/statistics_service.py` 新增 `get_health_trend()`
- 复用现有 `_parse_date_filter()` / `_load_reports_in_range()`(30 天默认、日期校验、报告加载逻辑完全一致)
- 按自然日(`started_at[:10]`)分组,聚合每日的 `正常 / 警告 / 严重` 数量
- 返回按日期升序的每日时段数组
`routes.py` 新增:
```python
@bp.route('/api/service-monitor/statistics/health-trend', methods=['GET'])
def api_statistics_health_trend():
# 参数:target_id / start_date / end_date(登录即可查看)
# 返回:{"success": True, "data": {...}}
```
### 4.2 缺失报告检测(notification)
`services/notification_service.py` 新增 `check_missing_reports()`
- 读取 `schedule_service.list_schedules()``report_service.list_reports()`
- 对每个**启用中**的定时任务,按 `start_date ~ 今天` 生成应执行日期集合(daily/weekday/weekly 按 cron 语义)
- 与已生成的报告进行比对,输出缺失日期/时间段列表(含目标名、任务名、缺失日期、最近一次报告时间)
`routes.py` 新增:
```python
@bp.route('/api/service-monitor/notification/check-missing-reports', methods=['GET'])
def api_check_missing_reports():
# 参数:days(默认 7)/ target_id(可选)
# 返回:{"success": True, "missing": [...]}
```
### 4.3 前端同步
- `frontend/src/api/service-monitor/statistics.ts` 增加 `getHealthTrend()` 包装
- `frontend/src/api/service-monitor/notification.ts` 增加 `checkMissingReports()` 包装
- 业务逻辑页面(Statistics.vue / 通知页)按需挂载(本仓不含前端构建产物,由部署侧构建 dist 后同步)
### 4.4 部署
后端代码走 `deploy/upload_to_server.py``service_monitor` 目录递归上传,自动排除 `data/` 不覆盖运行时数据)。前端需在构建机重新构建 `frontend/dist/` 并同步到服务器 `/opt/troubleshoot/dist/`(避免坑 6:空 volume 覆盖)。
---
## 5. 预防措施
1. **前后端接口对齐检查**:前端新增 API 调用前,先确认后端路由存在(`grep` 路由注册);后端新增接口后,同步更新前端 API 包装。
2. **部署清单同步**:新增代码文件后,同步检查 `deploy/upload_to_server.py``FILES_TO_UPLOAD` / `DIRS_TO_UPLOAD` / `RECURSIVE_DIRS_TO_UPLOAD`(service_monitor 子包已覆盖递归上传,无需新增)。
3. **部署后接口冒烟**:部署后对新增接口执行 `curl` 冒烟(GET 返回 200 非 404 即通过)。
4. **版本一致性**:前端 dist 与后端代码必须同批次部署,避免运行版本错位。
---
## 6. 验收标准
| 标准 | 验证方法 |
|------|---------|
| 两个接口不再 404 | `curl http://192.168.5.60:8088/api/service-monitor/statistics/health-trend` 返回 200(未登录 401 也可,属于已注册路由) |
| health-trend 返回结构化数据 | 返回 `data.days[]`,含 date / summary 的 正常/警告/严重 统计 |
| check-missing-reports 返回检测结果 | 返回 `missing[]`,无缺失时为空数组 |
| 统计页面趋势模块正常 | 浏览器打开统计页面,控制台无 404,趋势图表有数据 |
| 单元测试通过 | `cd skill/code && python -m pytest -v`(现有 143 用例不回归) |
\ No newline at end of file
# PRD:钉钉机器人关键词与负责人员配置
> 创建日期:2026-09-14
> 模块:服务监测 / 通知配置
> 状态:待实现
## 1. 背景
服务监测当前支持钉钉自定义机器人通知,但通知配置页面仅支持 Webhook、加签密钥和 @人员手机号。钉钉机器人可启用“自定义关键词”安全校验,消息内容必须包含关键词,否则消息会被钉钉拦截。
同时,巡检通知需要明确展示负责人员,当前通知正文没有责任人信息,且责任人应支持按部署环境配置。
## 2. 需求目标
1. 为钉钉机器人增加可选的自定义关键词配置。
2. 配置关键词后,所有钉钉 Markdown 通知自动包含该关键词,避免因关键词校验导致发送失败。
3. 为钉钉通知增加“负责人员”配置,默认值为“陈泽键”。
4. 所有钉钉业务通知正文增加一行“负责人员:{配置值}”。
5. 兼容已有通知配置;旧配置没有新增字段时不报错。
6. 本次实现只进行代码和自动化验证,不主动发送钉钉测试消息。
## 3. 使用范围
### 3.1 配置页面
在服务监测“通知配置”的“钉钉机器人”区域增加:
- **自定义关键词(可选)**:单个文本,默认空;为空时不改变现有行为。
- **负责人员**:单个文本,默认“陈泽键”;为空时不追加责任人行。
配置随现有通知配置一起保存,并在再次打开页面时回显。
### 3.2 通知类型
以下钉钉 Markdown 消息均需要应用关键词和负责人员配置:
- 巡检完成通知。
- 报告缺失告警通知。
- 钉钉测试消息模板(保留现有测试接口,但本次不主动调用)。
## 4. 详细规则
### 4.1 关键词
- 关键词字段为可选字符串,去除首尾空白后使用。
- 未配置关键词时,消息标题和正文保持原有格式。
- 已配置关键词时,若正文未包含该关键词,则在正文开头自动增加关键词;若正文已经包含,则不得重复添加。
- 标题也应包含关键词,确保钉钉 Markdown 关键词校验在不同机器人配置下均可通过。
- 关键词不得进入加密字段处理,不需要脱敏。
### 4.2 负责人员
- 配置字段使用明确的英文键名 `responsible_person`
- 默认值为“陈泽键”。
- 配置值去除首尾空白。
- 配置非空时,在钉钉正文末尾增加独立一行:
`负责人员:陈泽键`
- 配置为空时不增加空的责任人行。
- 责任人配置不是密码,不加密、不脱敏。
### 4.3 兼容性
- 读取旧的 `notifications.json` 时,缺少 `keyword``responsible_person` 应使用默认值。
- 保存时保留现有 email、wecom、trigger、report_missing 等配置字段。
- 不改变现有接口路径、请求方法和测试接口权限。
## 5. 非功能要求
- 不发送真实钉钉测试消息作为本次开发验证步骤。
- 不在日志中输出 Webhook、加签密钥等敏感信息。
- 保持现有通知发送失败处理和返回值语义。
- 代码遵循现有 routes/services/frontend 分层结构。
## 6. 验收标准
1. 旧配置加载成功,缺少新字段时页面显示默认责任人、空关键词。
2. 页面可以编辑、保存并回显关键词和负责人员。
3. 巡检完成钉钉消息配置关键词后,标题或正文包含关键词。
4. 报告缺失钉钉告警配置关键词后,标题或正文包含关键词。
5. 巡检完成和报告缺失通知在责任人非空时均包含责任人行。
6. 关键词为空、责任人为空时行为符合规则,不产生多余空行或异常。
7. Python 单元测试、前端 TypeScript 构建/检查通过。
8. 开发过程中不调用 `test-dingtalk` 接口,不发送真实消息。
## 7. 不包含内容
- 不新增钉钉机器人创建、删除或连通性校验功能。
- 不修改企业微信和邮件的正文格式。
- 不改变钉钉加签算法和 Webhook 请求协议。
- 不自动为用户发送测试消息。
# HANDOFF_服务监测 — 服务监测模块:检测项丰富化移植 + 移动端响应式优化 + 功能增强 + SSH sudo 自动检测 + 定时任务修复 + 根因排查彻底解决 + 功能增强(报告清理/通知修复/通知内容增强/报告缺失兜底告警)
# HANDOFF_服务监测 — 服务监测模块:检测项丰富化移植 + 移动端响应式优化 + 功能增强 + SSH sudo 自动检测 + 定时任务修复 + 根因排查彻底解决 + 功能增强(报告清理/通知修复/通知内容增强/报告缺失兜底告警/钉钉关键词与负责人员
> 最后更新:2026-08-12(阶段十四:功能增强 — 报告缺失兜底告警)| 分支:troubleshoot-ai-assistant | 负责人:czj
> 最后更新:2026-09-14(阶段十五:功能增强 — 钉钉机器人关键词与负责人员配置)| 分支:troubleshoot-ai-assistant | 负责人:czj
> 专属交接文档:聚焦服务监测模块(service_monitor)的全部历史与当前状态
---
......@@ -23,10 +23,24 @@
- **阶段十一**:定时任务调度器日志修复 + 任务触发验证
- **阶段十二**:根因排查彻底解决 — 定时任务死锁修复 + 9.89 麒麟系统适配 + 加密密钥固定
- **阶段十三**:功能增强 — 报告自动清理 + 钉钉通知修复 + 通知内容增强
- **阶段十四(本次)**:功能增强 — 报告缺失兜底告警
- **阶段十四**:功能增强 — 报告缺失兜底告警
- **阶段十五(本次)**:功能增强 — 钉钉机器人关键词与负责人员配置 + 5.60 容器热更新部署
本轮会话完成了以下工作:
**阶段十五:钉钉机器人关键词与负责人员配置**
1. **需求**:钉钉机器人可开启"自定义关键词"安全校验(消息必须含关键词否则被拦截);同时要求所有钉钉通知正文末尾展示负责人员行,人员名称可配置(默认"陈泽键")
2. **PRD 文档**`Docs/需求文档/服务监测/PRD_需求文档_钉钉机器人关键词与负责人员配置.md` + 配套计划执行文档
3. **后端统一构造**`notification_service.py` 新增 `_build_dingtalk_markdown()`(关键词去重补全:正文不含才补标题前缀 + 正文开头;责任人末尾追加"负责人员:xxx")+ `_send_dingtalk_message()`(统一加签 + 发送,返回 `(ok, errmsg)`
4. **三个钉钉调用点收敛复用** — test_dingtalk / send_missing_report_notification / _send_dingtalk_notification 全部改走统一构造,签名算法与 API 路径不变
5. **前端配置页**`Notification.vue` 钉钉卡片新增"自定义关键词(可选)"+"负责人员"输入项;`DingTalkConfig` 类型同步;旧配置加载时自动回填默认值
6. **新增测试**`tests/test_notification_service.py` 12 用例(全部 mock HTTP,绝不发送真实消息)
**验证结果**
- 新增 12 个单元测试全绿 ✅(服务监测全套 106 用例中 4 个既有失败与本轮改动无关:3 × apscheduler 本地未安装、1 × 61_platform_log_check.sh 历史遗留)
- vue-tsc 前端类型检查通过 ✅
- 前端重新构建后已部署 5.60(Docker 容器热更新),/api/health 正常 ✅
**阶段十四:报告缺失兜底告警**
1. **报告缺失检测**`report_service.py` 新增 `check_missing_reports()` 函数,检查每个目标最后报告时间,超过 `missing_days` 阈值的目标标记为缺失
2. **定时触发**`schedule_service.py` 新增每天 09:30(Asia/Shanghai)定时任务,自动检测缺失报告并发送告警通知
......@@ -751,31 +765,69 @@
- vue-tsc 前端类型检查通过 ✅
- 通知配置页保存/加载正常 ✅
---
### 2.35 钉钉机器人关键词与负责人员配置 ✅
## 3. 当前卡在哪
**需求文档**`Docs/需求文档/服务监测/PRD_需求文档_钉钉机器人关键词与负责人员配置.md`
**需求背景**:钉钉自定义机器人可开启"自定义关键词"安全校验(消息必须包含关键词,否则被钉钉拦截);同时巡检通知需明确展示负责人员(默认"陈泽键",可按部署环境配置)。
**核心实现**
**1. `notification_service.py` — 统一消息构造**
- `DEFAULT_CONFIG.dingtalk` 新增 `keyword: ""`(可选关键词,空 = 不处理)、`responsible_person: "陈泽键"`(空 = 不追加责任人行)
- 新增 `_build_dingtalk_markdown(title, text, ding_cfg)`
- 关键词非空且正文不含时 → 标题加 `[关键词]` 前缀 + 正文开头补关键词;正文已含则不重复(避免标题前缀重复)
- 负责人非空时 → 正文末尾追加独立行 `负责人员:xxx`
- 新增 `_send_dingtalk_message(webhook_url, secret, title, text, at_mobiles)`:统一封装加签(HMAC-SHA256 + `timestamp\n{secret}` + base64 + `quote_plus`)与请求发送,返回 `(ok, errmsg)` 元组
- 三个钉钉调用点全部收敛复用:`test_dingtalk()`(保留 errmsg 返回给前端)、`send_missing_report_notification()` 钉钉分支(保持"仅 errcode==0 才算成功"语义)、`_send_dingtalk_notification()`
- `keyword` / `responsible_person` 为明文配置,**不加密、不脱敏**(与 secret 区分)
**2. 前端**
- `frontend/src/types/service-monitor.ts``DingTalkConfig` 新增 `keyword` / `responsible_person`
- `frontend/src/views/service-monitor/Notification.vue`:钉钉卡片新增"自定义关键词(可选)"+"负责人员(默认:陈泽键)"输入项;`loadConfig``{ ...默认值, ...(服务端配置) }` 合并,旧配置缺字段自动回填
**3. 测试**`tests/test_notification_service.py`,12 用例,全部 `@patch("requests.post")` mock,绝不发真实消息):
- 文本构造 4 例:关键词+责任人补全 / 正文已含关键词去重 / 空配置不变 / 默认责任人
- `_send_dingtalk_message` 2 例:成功负载与签名 URL 断言 / errcode!=0 返回 errmsg
- 业务链路 3 例:巡检完成 / 报告缺失 / 测试消息均应用关键词+责任人
- 配置 3 例:DEFAULT_CONFIG 字段 / keyword 与 person 不脱敏(secret 正常脱敏)
**无卡点!** 本轮会话已完成所有问题的根因排查和彻底修复 + 报告缺失兜底告警功能。
**验证结果**
- 12 个新测试全绿 ✅
- vue-tsc 类型检查通过 ✅
- git diff 审阅确认:邮件/企微行为、加签算法、API 路径均未改动 ✅
- **已部署 5.60**(Docker 容器热更新,见 2.36)✅
- 真实钉钉消息由用户手工测试(开发期间零真实发送)⏳
### 2.36 5.60 容器热更新部署(deploy_update.py)✅
**背景**:本轮前端有 UI 改动,必须重新构建 dist 并上传。5.60 当前为 Docker 容器部署(见坑 12/13/19),旧脚本 `upload_to_server.py`(systemd 模式)已不适用,正确脚本是 `deploy/deploy_update.py`(快速热更新,不重建镜像)。
**部署流程**`deploy_update.py` 自动完成):
1. SSH 密钥认证(`~/.ssh/id_ed25519`,无需密码)
2. 上传后端 `skill/code/web` → 服务器暂存目录(119 文件,排除 tests/data/__pycache__)
3. 上传前端 `frontend/dist``/opt/troubleshoot/dist`(80 文件,容器 /data/dist volume 热更新)
4. tar 管道同步后端进容器 `/app/web`(排除 `users.json``service_monitor/data` 运行时数据)
5. `docker restart troubleshoot` + 等待 10s
6. 验证 `/api/health`(ok)与统计接口(401 未登录 = 路由存在,非 404)
**验证结果**:部署成功,前端 Notification 页面已含新表单 ✅
定时任务功能已验证正常:
- 5.44 全量巡检成功(525 项检测)✅
- 调度器正常触发 ✅
- 死锁已解除(`_schedules_lock``RLock`)✅
- 加密密钥已固定(`MONITOR_ENC_KEY` 写入 `.env`)✅
**附带工作**
- 拉取远端 `6eb7a784`(5.44 代理签名对齐与降级 + 5.69 部署手册),与本地钉钉改动无冲突,fast-forward 完成
- 确认本机 SSH 密钥免密登录 5.60 已生效(`deploy_update.py` 无需 `SSH_PASSWORD`
**待处理(非阻塞)**
1. 钉钉通知需要重新配置 secret(访问通知配置页面重新填写)
2. 通知配置页面会加载失败,因为钉钉 secret 用旧密钥加密,需先在页面清除旧值并重新填写
---
## 3. 当前卡在哪
定时任务功能已验证正常:
- 5.44 全量巡检成功(525 项检测)✅
- 调度器正常触发 ✅
- 死锁已解除(`_schedules_lock``RLock`)✅
- 加密密钥已固定(`MONITOR_ENC_KEY` 写入 `.env`)✅
**代码与部署均已完成,等用户手工验证真实钉钉消息。**
**待处理(非阻塞)**
1. 钉钉通知需要重新配置 secret(访问通知配置页面重新填写)
2. 通知配置页面会加载失败,因为钉钉 secret 用旧密钥加密,需先在页面清除旧值并重新填写
- 前端(含关键词/负责人员表单)+ 后端已部署 5.60 容器,`/api/health` 正常 ✅
- 待用户访问 通知配置页 → 钉钉机器人:确认新表单出现、保存配置、点击"发送测试消息"验证钉钉群收到含关键词 + `负责人员:陈泽键` 的消息 ⏳
- 工作区有一批**未提交改动**(见第 6 节),等真实联调通过后再 git 提交推送
**本地测试环境注意(非阻塞)**:服务监测全套 106 用例中 4 个既有失败与本轮改动无关——3 个 `test_schedule_service.py` 因本地未装 `apscheduler`(requirements.txt 已声明),1 个 `test_check_modules.py``61_platform_log_check.sh` 语法检查失败为历史遗留(该文件本轮未改动)。
---
......@@ -790,7 +842,10 @@
| ~~P1~~ | ~~钉钉通知修复~~ | ✅ 已完成 — 通知配置页面恢复正常,secret 已重新加密 |
| ~~P1~~ | ~~通知内容增强~~ | ✅ 已完成 — 钉钉消息包含巡检摘要 + 异常项 + 报告链接 |
| ~~P1~~ | ~~报告缺失兜底告警~~ | ✅ 已完成 — 每天 09:30 检测缺失报告并通知 |
| ~~P1~~ | ~~钉钉关键词与负责人员配置~~ | ✅ 已完成 — 代码 + 测试 + 已部署 5.60(见 2.35/2.36) |
| **P2** | **测试 9.89 全量巡检** | 手动触发 9.89 full 套件,验证全部 42 个模块 |
| **P2** | **钉钉通知配置** | 访问 192.168.5.60:8088/service-monitor/notification 配置钉钉 secret 与关键词/负责人员 |
| **P3** | **验证新功能** | 确认钉钉测试消息含关键词 + "负责人员:陈泽键",报告缺失告警正常 |
| 持续 | 更新 HANDOFF_服务监测文档 | 每次会话结束前更新进度 |
---
......@@ -1074,12 +1129,43 @@ sudo docker compose down && sudo docker compose up -d --build
**避免方法**:关键路径的状态更新要在启动后台耗时任务**之前**执行,避免锁竞争。或者状态更新不持有锁去调用其他也可能获取锁的函数。
### 坑 25:测试数据与消息正文重叠导致关键词去重"误伤"断言
**坑**`test_send_missing_report_notification_applies_keyword_and_person` 用关键词 `"缺失告警"`,但报告缺失告警正文标题本身就含 `### ⚠️ 报告缺失告警``_build_dingtalk_markdown()` 的去重逻辑(正文已含关键词则不加前缀)正确判定"已包含",标题没有 `[缺失告警]` 前缀,断言失败。**这是测试数据选得不好,不是代码 bug。**
**避免方法**:测关键词补全逻辑时,测试关键词必须选**不可能出现在消息正文里**的词(如改用 `"运维告警"`)。先想清楚"去重"分支会被哪种数据触发,再写断言。
### 坑 26:5.60 部署脚本选错——upload_to_server.py 已过时
**坑**:5.60 现在是 Docker 容器部署(坑 12/13/19),但仓库里旧的 `deploy/upload_to_server.py`(systemd + 宿主机文件模式)还在。若用它部署:代码更新宿主机目录但容器内不变(代码是镜像 COPY 进去的),服务也不会正确重启。
**避免方法**:5.60 更新一律用 `deploy/deploy_update.py`(上传后端 → 上传前端 dist → tar 同步进容器 /app/web → docker restart → 健康检查)。它还会自动 inspect 容器挂载点找前端真实目录。
### 坑 27:前端有改动但 dist 是旧的——部署前必须 npm run build
**坑**`frontend/dist/` 是上次构建(8-25)的产物,Vue 源码改了 dist 不会自动变。直接部署会把旧页面传上去,用户在页面看不到新表单,还以为代码没生效。
**避免方法**:部署前检查 `dist/index.html` 的修改时间是否晚于本轮改动的 `.vue`/`.ts` 文件;但凡前端有改动,先 `cd frontend && npm run build` 再跑 `deploy_update.py`。部署后可 `grep "新字段名" dist/assets/Notification-*.js` 验证新代码已进包。
### 坑 28:Edit 大段替换会连带吞掉中间章节
**坑**:更新 HANDOFF 时用一个大 `old_string` 把"2.34 验证结果 + 旧第 3 节"整体替换为"2.35 + 2.36",`new_string` 里忘了带上 `## 3. 当前卡在哪` 标题,导致第 3 节被整个删掉。另外 old_string 凭记忆手打时把"钉钉"误写成"钉丁/钉叮"导致匹配失败。
**避免方法**:Edit 的 `new_string` 必须完整保留不想改动的中间内容(含章节标题);改文档结构后立即 `grep -n "^## "` 检查章节完整性。old_string 一律从 Read 输出复制,不要手打中文。
### 坑 29:本地测试环境缺依赖导致"全套件红"误判
**坑**:跑 `web/service_monitor/tests/` 全套 106 用例时 4 个失败(3 × `ModuleNotFoundError: apscheduler`,1 × bash 语法检查历史遗留),容易误判为本次改动引入回归。
**避免方法**:失败先做归因——看失败用例是否与本次改动文件有依赖关系;`apscheduler` 在 requirements.txt 声明但本机未装,属环境问题。可用 `git stash``git diff` 快速确认失败用例涉及的文件本轮是否改过。
---
## 6. Git 提交记录
| 日期 | Commit | 说明 |
|------|--------|------|
| **2026-09-14** | - | **feat(monitor): 钉钉机器人关键词与负责人员配置(阶段十五,待提交)** |
| **2026-08-12** | - | **feat(monitor): 报告缺失兜底告警(阶段十四,待提交)** |
| **2026-08-05** | - | **fix(monitor): 定时任务死锁修复 + 9.89 麒麟系统适配 + 加密密钥固定(阶段十二,待提交)** |
| **2026-08-05** | `65b25c4d` | **feat(monitor): SSH sudo 自动检测 + Docker 权限适配** |
......@@ -1283,6 +1369,19 @@ feat(service-monitor): 新增安全合规/中间件检测模块并修复状态
| `frontend/src/views/service-monitor/Notification.vue` | 新增"报告缺失告警"配置区(阈值 + 渠道选择) |
| `frontend/src/views/service-monitor/Statistics.vue` | 新增报告缺失告警红色横幅 + 缺失目标列表 |
### 本次修改文件(阶段十五:钉钉关键词与负责人员配置)
| 文件 | 改动 |
|------|------|
| `service_monitor/services/notification_service.py` | `DEFAULT_CONFIG.dingtalk` 新增 `keyword`/`responsible_person`;新增 `_build_dingtalk_markdown()`(关键词去重 + 责任人追加)与 `_send_dingtalk_message()`(统一加签+发送,返回 `(ok, errmsg)`);三个钉钉调用点收敛复用 |
| `service_monitor/tests/test_notification_service.py` | **新增** — 12 用例(文本构造 4 + 网络封装 2 + 业务链路 3 + 配置兼容 3,全部 mock HTTP) |
| `frontend/src/types/service-monitor.ts` | `DingTalkConfig` 新增 `keyword` / `responsible_person` 字段 |
| `frontend/src/views/service-monitor/Notification.vue` | 钉钉卡片新增"自定义关键词(可选)"+"负责人员"输入项;`loadConfig` 默认值合并兼容旧配置 |
| `Docs/需求文档/服务监测/PRD_需求文档_钉钉机器人关键词与负责人员配置.md` | **新增** — 需求文档(规则/兼容性/验收标准) |
| `Docs/需求文档/服务监测/PRD_计划执行_钉钉机器人关键词与负责人员配置.md` | **新增** — 计划执行文档 |
**阶段十五部署方式**`python deploy/deploy_update.py`(Docker 容器热更新:后端 tar 进容器 `/app/web` + 前端 dist 上传 `/opt/troubleshoot/dist` + `docker restart`)。部署前先 `cd frontend && npm run build` 重建 dist。
### 新增文档
| 文件 | 说明 |
......@@ -1314,8 +1413,10 @@ python skill/code/web/server.py
# 单元测试
cd skill/code && python -m pytest -v
# 部署到 5.60(用 ! 前缀执行)
! cd deploy && SSH_PASSWORD='***' python upload_to_server.py
# 部署到 5.60(Docker 容器热更新,SSH 密钥免密;前端有改动时先构建)
cd frontend && npm run build && cd ..
python deploy/deploy_update.py
# 旧脚本 upload_to_server.py(systemd 模式)已不适用于 5.60,勿用(见坑 26)
# 健康检查
curl -s http://192.168.5.60:8088/api/health
......
# -*- coding: utf-8 -*-
"""
deploy_update.py - 快速更新 5.60(不重建镜像,只传包重启容器)
步骤:
1. docker inspect 找出容器 /data/dist 实际挂载的宿主目录
2. 上传后端 skill/code/web -> 服务器暂存目录(排除 tests/data/__pycache__)
3. 上传前端 frontend/dist -> 容器实际挂载目录(volume 热更新)
4. tar 管道 docker exec 把后端代码同步进容器 /app/web(排除 service_monitor/data 运行时数据)
5. docker restart troubleshoot
6. 验证 /api/health 与两个新接口路由(非 404 即部署成功)
用法:
SSH_PASSWORD='xxx' python deploy_update.py # 密码认证
python deploy_update.py # 自动尝试 ~/.ssh/id_ed25519 密钥认证
"""
import os
import time
import paramiko
HOST = os.environ.get('SSH_HOST', '192.168.5.60')
USER = os.environ.get('SSH_USER', 'ubains')
PASSWORD = os.environ.get('SSH_PASSWORD', '')
CONTAINER = 'troubleshoot'
REMOTE_BASE = '/data/third_party/monitor-platform'
# 密钥认证候选(优先级从高到低)
_SSH_KEY_CANDIDATES = [
os.path.expanduser('~/.ssh/id_ed25519'),
os.path.expanduser('~/.ssh/id_rsa'),
]
# 本地路径
REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
LOCAL_WEB = os.path.join(REPO_ROOT, "skill", "code", "web")
LOCAL_DIST = os.path.join(REPO_ROOT, "frontend", "dist")
STAGE_WEB = f"{REMOTE_BASE}/stage_web" # 后端暂存目录(tar 源)
DIST_DIR = f"{REMOTE_BASE}/frontend/dist" # 前端目录(deploy_docker.py 约定)
# 递归上传排除
_EXCLUDE_DIRS = {'__pycache__', 'tests', 'data', '.pytest_cache', 'logs', 'cache'}
_EXCLUDE_SUFFIXES = ('.pyc', '.pyo')
def run(ssh, cmd):
stdin, stdout, stderr = ssh.exec_command(cmd)
out = stdout.read().decode()
err = stderr.read().decode()
rc = stdout.channel.recv_exit_status()
return rc, out, err
def upload_recursive(sftp, ssh, local_dir, remote_dir, remote_rel):
"""递归上传,排除 tests/data/__pycache__/pyc。"""
ssh.exec_command(f'mkdir -p "{remote_dir}"')[1].channel.recv_exit_status()
count = 0
for dirpath, dirnames, filenames in os.walk(local_dir):
dirnames[:] = [d for d in dirnames if d not in _EXCLUDE_DIRS]
rel = os.path.relpath(dirpath, local_dir)
target = remote_dir if rel == '.' else f'{remote_dir}/{rel.replace(os.sep, "/")}'
if rel != '.':
ssh.exec_command(f'mkdir -p "{target}"')[1].channel.recv_exit_status()
for fname in filenames:
if fname.endswith(_EXCLUDE_SUFFIXES):
continue
lpath = os.path.join(dirpath, fname)
rpath = f'{target}/{fname}'
try:
sftp.put(lpath, rpath)
count += 1
except Exception as e:
print(f" [FAIL] {fname}: {e}")
print(f" [OK] {remote_rel}: {count} 个文件")
def main():
print("=" * 60)
print(" Troubleshoot - Quick Update (no rebuild)")
print("=" * 60)
print(f"Server: {HOST} Container: {CONTAINER}")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
print("\n[1/6] Connecting...")
# 密码优先;未设密码则尝试密钥认证(~/.ssh/id_ed25519 / id_rsa)
connected = False
if PASSWORD:
ssh.connect(HOST, username=USER, password=PASSWORD, timeout=10)
connected = True
print("[OK] Connected via password")
else:
for keypath in _SSH_KEY_CANDIDATES:
if not os.path.isfile(keypath):
continue
try:
ssh.connect(HOST, username=USER, timeout=10, key_filename=keypath)
connected = True
print(f"[OK] Connected via key: {keypath}")
break
except Exception as e:
print(f"[INFO] Key auth failed ({keypath}): {e}")
if not connected:
print("[FAIL] 无可用认证方式:SSH_PASSWORD 未设置且密钥认证失败")
return
# 检查容器存在
rc, out, _ = run(ssh, f"sudo docker ps -a --filter name={CONTAINER} --format '{{{{.Names}}}}'")
if CONTAINER not in out:
print(f"[FAIL] 容器 {CONTAINER} 不存在,请先完成初始部署")
return
# 先 inspect 挂载点,确定前端实际目录
rc, out, _ = run(ssh,
f"sudo docker inspect {CONTAINER} --format "
"'{{range .Mounts}}{{.Destination}}|{{.Source}}{{println}}{{end}}'")
mounts = {}
for line in (out or "").splitlines():
if '|' in line:
dest, src = line.split('|', 1)
mounts[dest.strip()] = src.strip()
print("[OK] Mounts:")
for dest, src in mounts.items():
print(f" {dest} <- {src}")
# 前端目标目录:优先容器 /data/dist 实际挂载源
dist_src = mounts.get('/data/dist')
if dist_src and dist_src.startswith('/'):
frontend_target = dist_src.rstrip('/')
else:
frontend_target = DIST_DIR
print(f"[INFO] 前端将上传到: {frontend_target}")
sftp = ssh.open_sftp()
print("\n[2/6] Uploading backend (skill/code/web -> stage_web)...")
upload_recursive(sftp, ssh, LOCAL_WEB, STAGE_WEB, "stage_web")
print(f"\n[3/6] Uploading frontend (frontend/dist -> {frontend_target})...")
upload_recursive(sftp, ssh, LOCAL_DIST, frontend_target, os.path.basename(frontend_target))
print("\n[4/6] Syncing backend into container (tar via docker exec)...")
# tar 管道把暂存目录同步进容器 /app/web
# 排除挂载点(写入会穿透到宿主或直接失败):
# ./users.json — 绑定挂载的生产用户数据,绝不能覆盖
# ./service_monitor/data — monitor-data 运行时卷
tar_cmd = (
f'cd {STAGE_WEB} && '
f"tar --exclude='./users.json' --exclude='./service_monitor/data' -cf - . | "
f'sudo docker exec -i {CONTAINER} tar -xf - -C /app/web'
)
rc, out, err = run(ssh, tar_cmd)
if rc != 0:
print(f"[FAIL] 后端同步失败: {err.strip() or out.strip()}")
return
print("[OK] Backend synced into container /app/web")
print("\n[5/6] Restarting container...")
rc, out, err = run(ssh, f"sudo docker restart {CONTAINER}")
if rc != 0:
print(f"[FAIL] restart: {err.strip()}")
return
print("[OK] Container restarted")
# 等待服务加载(357 条知识库索引加载较慢)
print("[INFO] Waiting 10s for service...")
time.sleep(10)
print("\n[6/6] Verifying...")
rc, out, _ = run(ssh, "curl -s http://localhost:8088/api/health")
if out and '"status":"ok"' in out:
print("[OK] Health: " + out[:200])
else:
print(f"[FAIL] Health 未返回 ok: {out[:200]}")
rc, log, _ = run(ssh, f"sudo docker logs {CONTAINER} --tail 30")
print(log)
# 验证两个新接口(未登录返回 JSON success:false 而非 404)
for name, path in [
("health-trend",
"/api/service-monitor/statistics/health-trend?start_date=2026-07-26&end_date=2026-08-25"),
("check-missing-reports",
"/api/service-monitor/notification/check-missing-reports?days=7"),
]:
rc, code, _ = run(ssh,
f'curl -s -o /dev/null -w "%{{http_code}}" "http://localhost:8088{path}"')
rc, body, _ = run(ssh,
f'curl -s "http://localhost:8088{path}" | head -c 200')
status = "OK" if code.strip() != "404" else "FAIL"
print(f" [{status}] {name}: HTTP {code.strip()} body: {(body or '')[:120]}")
print("\n" + "=" * 60)
print(" Update Complete!")
print(f" URL: http://{HOST}:8088")
print("=" * 60)
except Exception as e:
print(f"\n[ERROR] {e}")
import traceback
traceback.print_exc()
finally:
try:
ssh.close()
except Exception:
pass
if __name__ == '__main__':
main()
\ No newline at end of file
......@@ -258,6 +258,8 @@ export interface DingTalkConfig {
webhook_url: string
secret: string
at_mobiles: string[]
keyword: string
responsible_person: string
}
export interface WeComConfig {
......
......@@ -21,6 +21,8 @@ const config = ref<NotificationConfig>({
webhook_url: '',
secret: '',
at_mobiles: [],
keyword: '',
responsible_person: '陈泽键',
},
wecom: {
enabled: false,
......@@ -69,7 +71,7 @@ async function loadConfig() {
config.value = {
...res.config,
email: res.config.email || config.value.email,
dingtalk: res.config.dingtalk || config.value.dingtalk,
dingtalk: { ...config.value.dingtalk, ...(res.config.dingtalk || {}) },
wecom: res.config.wecom || config.value.wecom,
trigger: res.config.trigger || config.value.trigger,
alert_on_consecutive: res.config.alert_on_consecutive || config.value.alert_on_consecutive,
......@@ -257,6 +259,14 @@ onMounted(loadConfig)
<el-form-item label="@人员手机号(可选)">
<el-input v-model="dingtalkAtMobiles" placeholder="多个手机号用英文逗号分隔" />
</el-form-item>
<el-form-item label="自定义关键词(可选)">
<el-input v-model="config.dingtalk.keyword" placeholder="钉钉机器人安全设置中的自定义关键词" />
<div class="form-hint">机器人开启"自定义关键词"校验时应填写,通知消息将自动包含该关键词</div>
</el-form-item>
<el-form-item label="负责人员">
<el-input v-model="config.dingtalk.responsible_person" placeholder="默认:陈泽键" />
<div class="form-hint">通知正文末尾展示一行"负责人员:xxx"</div>
</el-form-item>
<div class="test-section">
<el-button :loading="testingDingtalk" @click="handleTestDingtalk">发送测试消息</el-button>
<span v-if="dingtalkTestResult" class="test-msg" :class="dingtalkTestResult.success ? 'success' : 'error'">
......
......@@ -45,7 +45,11 @@ DEFAULT_CONFIG = {
"enabled": False,
"webhook_url": "",
"secret": "",
"at_mobiles": []
"at_mobiles": [],
# 自定义关键词(机器人安全校验用,消息自动包含;空 = 不处理)
"keyword": "",
# 负责人员(通知正文末尾展示;空 = 不展示)
"responsible_person": "陈泽键"
},
"wecom": {
"enabled": False,
......@@ -189,9 +193,6 @@ def test_email() -> dict:
def test_dingtalk() -> dict:
"""发送测试钉钉消息(Markdown 格式)。"""
import requests
import base64
config = _load_config()
ding_cfg = config.get("dingtalk", {})
......@@ -211,34 +212,13 @@ def test_dingtalk() -> dict:
f"这是一条测试消息,用于验证钉钉通知配置是否正确。\n\n"
f"⏰ 发送时间:{now_str}\n"
)
content = {
"msgtype": "markdown",
"markdown": {"title": "钉钉通知测试", "text": text}
}
# 签名(如果有)
url = webhook_url
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_url}&timestamp={timestamp}&sign={sign}"
try:
resp = requests.post(url, json=content, timeout=10)
result = resp.json()
if result.get("errcode") == 0:
logger.info("钉钉测试消息发送成功")
title = "钉钉通知测试"
md = _build_dingtalk_markdown(title, text, ding_cfg)
ok, errmsg = _send_dingtalk_message(webhook_url, secret, md["title"], md["text"])
if ok:
return {"success": True, "message": "测试消息已发送到钉钉群"}
else:
return {"success": False, "message": f"钉钉返回错误: {result.get('errmsg', '未知错误')}"}
except Exception as e:
return {"success": False, "message": f"发送失败: {e}"}
return {"success": False, "message": f"发送失败: {errmsg}"}
def test_wecom() -> dict:
......@@ -416,34 +396,17 @@ def send_missing_report_notification(
# 发送钉钉
if "dingtalk" in channels and config.get("dingtalk", {}).get("enabled"):
try:
import requests, base64
ding_cfg = config.get("dingtalk", {})
webhook_url = ding_cfg.get("webhook_url", "").strip()
secret = ding_cfg.get("secret", "").strip()
if webhook_url:
content = {
"msgtype": "markdown",
"markdown": {
"title": f"报告缺失告警 - {target_count} 个目标",
"text": md_msg,
}
}
url = webhook_url
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_url}&timestamp={timestamp}&sign={sign}"
resp = requests.post(url, json=content, timeout=10)
result = resp.json()
if result.get("errcode") == 0:
title = f"报告缺失告警 - {target_count} 个目标"
md = _build_dingtalk_markdown(title, md_msg, ding_cfg)
ok, _ = _send_dingtalk_message(
webhook_url, secret, md["title"], md["text"]
)
if ok:
success = True
except Exception as e:
logger.warning("报告缺失告警钉钉发送失败: %s", e)
......@@ -551,9 +514,6 @@ def _send_email_notification(report: dict, report_url: str, config: dict) -> boo
def _send_dingtalk_notification(report: dict, report_url: str, config: dict) -> bool:
"""发送钉钉通知(Markdown 格式,突出异常项)。"""
import requests
import base64
ding_cfg = config.get("dingtalk", {})
trigger_cfg = config.get("trigger", {})
......@@ -626,41 +586,15 @@ def _send_dingtalk_notification(report: dict, report_url: str, config: dict) ->
lines.append("")
lines.append(f"📎 [查看完整报告]({report_url})")
content = {
"msgtype": "markdown",
"markdown": {
"title": f"{title_prefix} - {report.get('target_name', '-')}",
"text": "\n".join(lines)
}
}
if at_mobiles:
content["at"] = {"atMobiles": at_mobiles, "isAtAll": False}
title = f"{title_prefix} - {report.get('target_name', '-')}"
md = _build_dingtalk_markdown(title, "\n".join(lines), ding_cfg)
# 签名
url = webhook_url
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_url}&timestamp={timestamp}&sign={sign}"
try:
resp = requests.post(url, json=content, timeout=10)
result = resp.json()
if result.get("errcode") == 0:
ok, _ = _send_dingtalk_message(
webhook_url, secret, md["title"], md["text"], at_mobiles=at_mobiles
)
if ok:
logger.info("钉钉通知已发送")
return True
else:
logger.error("钉钉通知失败: %s", result.get("errmsg"))
return False
except Exception as e:
logger.error("钉钉通知发送失败: %s", e)
return False
......@@ -706,3 +640,90 @@ def _send_wecom_notification(report: dict, report_url: str, config: dict) -> boo
except Exception as e:
logger.error("企业微信通知发送失败: %s", e)
return False
# ============================================================
# 钉钉消息统一构造(关键词 + 负责人员)
# ============================================================
def _build_dingtalk_markdown(
title: str,
text: str,
ding_cfg: dict,
) -> dict:
"""按钉钉配置构造 Markdown 消息内容。
规则:
1. keyword 非空且正文不含关键词时,标题和正文开头各补一次(标题加粗);
正文已含关键词则不重复添加。
2. responsible_person 非空时,正文末尾追加一行"负责人员:xxx"。
3. 空配置不改变原有消息格式。
Returns:
{"title": str, "text": str}
"""
keyword = (ding_cfg.get("keyword") or "").strip()
person = (ding_cfg.get("responsible_person") or "").strip()
# 关键词:正文不含才补,避免重复
if keyword and keyword not in text:
title = f"[{keyword}] {title}"
text = f"{keyword}\n\n{text}"
elif keyword:
# 正文已含关键词时,标题不再重复添加,正文保持不变
pass
# 负责人员
if person:
text = f"{text.rstrip()}\n\n负责人员:{person}\n"
return {"title": title, "text": text}
def _send_dingtalk_message(
webhook_url: str,
secret: str,
title: str,
text: str,
at_mobiles: list = None,
) -> tuple:
"""发送钉钉 Markdown 消息(含加签),统一封装请求发送。
Returns:
(是否成功, 失败原因 errmsg;成功时为空字符串)
"""
import requests
import base64
content = {
"msgtype": "markdown",
"markdown": {"title": title, "text": text}
}
if at_mobiles:
content["at"] = {"atMobiles": at_mobiles, "isAtAll": False}
# 加签
url = webhook_url
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_url}&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, ""
errmsg = result.get("errmsg", "未知错误")
logger.error("钉钉消息失败: %s", errmsg)
return False, errmsg
except Exception as e:
logger.error("钉钉消息发送失败: %s", e)
return False, str(e)
# -*- coding: utf-8 -*-
"""
test_notification_service.py — 钉钉机器人与通知配置增强测试
验证:
1. 关键词配置(去重补全、空值安全、标题同步补全)
2. 负责人员配置(追加展示、默认值、空值处理)
3. 巡检完成/缺失告警/测试消息三种消息形态构造
4. 旧配置兼容与默认值加载
5. 所有 HTTP 请求均被 mock,绝不调用真实 Webhook
"""
from unittest.mock import MagicMock, patch
import pytest
from service_monitor.services.notification_service import (
DEFAULT_CONFIG,
_build_dingtalk_markdown,
_send_dingtalk_message,
_send_dingtalk_notification,
send_missing_report_notification,
test_dingtalk,
get_config_masked,
save_config,
)
# ============================================================
# 1. 文本构造纯函数测试
# ============================================================
def test_build_dingtalk_markdown_with_keyword_and_person():
"""配置了关键词和负责人员:标题和正文补关键词,末尾追加负责人员。"""
ding_cfg = {
"keyword": "巡检告警",
"responsible_person": "陈泽键",
}
result = _build_dingtalk_markdown("服务器异常", "CPU 使用率过高:95%", ding_cfg)
assert result["title"] == "[巡检告警] 服务器异常"
assert result["text"].startswith("巡检告警\n\nCPU 使用率过高:95%")
assert "负责人员:陈泽键" in result["text"]
def test_build_dingtalk_markdown_keyword_dedup():
"""正文已包含关键词时,不重复添加关键词,标题也不添加前缀。"""
ding_cfg = {
"keyword": "巡检告警",
"responsible_person": "陈泽键",
}
raw_text = "【巡检告警】生产集群汇报\n内存正常"
result = _build_dingtalk_markdown("巡检通知", raw_text, ding_cfg)
# 标题未重复加 [巡检告警]
assert result["title"] == "巡检通知"
# 正文未重复在开头补
assert not result["text"].startswith("巡检告警\n\n【巡检告警】")
# 但责任人员依然追加
assert "负责人员:陈泽键" in result["text"]
def test_build_dingtalk_markdown_empty_config():
"""关键词和责任人为空时,保持原始文本与标题不变。"""
ding_cfg = {
"keyword": " ",
"responsible_person": "",
}
raw_title = "巡检报告 - 目标A"
raw_text = "正常 5 / 警告 0 / 严重 0"
result = _build_dingtalk_markdown(raw_title, raw_text, ding_cfg)
assert result["title"] == raw_title
assert result["text"] == raw_text
assert "负责人员" not in result["text"]
def test_build_dingtalk_markdown_default_person():
"""默认负责人员为陈泽键。"""
ding_cfg = {
"keyword": "",
"responsible_person": "陈泽键",
}
result = _build_dingtalk_markdown("测试", "正文内容", ding_cfg)
assert result["title"] == "测试"
assert "负责人员:陈泽键" in result["text"]
# ============================================================
# 2. _send_dingtalk_message 基础网络封装测试(MOCK HTTP)
# ============================================================
@patch("requests.post")
def test_send_dingtalk_message_success_and_payload(mock_post):
"""验证 requests.post 调用参数、加签算法及 errcode=0 成功分支。"""
mock_resp = MagicMock()
mock_resp.json.return_value = {"errcode": 0, "errmsg": "ok"}
mock_post.return_value = mock_resp
ok, errmsg = _send_dingtalk_message(
webhook_url="https://oapi.dingtalk.com/robot/send?access_token=mock_token",
secret="SECmocksecret123",
title="[告警] 标题",
text="告警\n\n正文\n\n负责人员:陈泽键\n",
at_mobiles=["13800000000"],
)
assert ok is True
assert errmsg == ""
assert mock_post.called
# 检查 URL 包含 timestamp 和 sign
call_args, call_kwargs = mock_post.call_args
assert "timestamp=" in call_args[0]
assert "sign=" in call_args[0]
# 检查 body 结构
payload = call_kwargs["json"]
assert payload["msgtype"] == "markdown"
assert payload["markdown"]["title"] == "[告警] 标题"
assert payload["markdown"]["text"] == "告警\n\n正文\n\n负责人员:陈泽键\n"
assert payload["at"] == {"atMobiles": ["13800000000"], "isAtAll": False}
@patch("requests.post")
def test_send_dingtalk_message_failure_return(mock_post):
"""errcode != 0 时正确返回失败与错误信息。"""
mock_resp = MagicMock()
mock_resp.json.return_value = {"errcode": 300001, "errmsg": "token not exist"}
mock_post.return_value = mock_resp
ok, errmsg = _send_dingtalk_message(
webhook_url="https://oapi.dingtalk.com/robot/send?access_token=bad",
secret="",
title="测试",
text="测试正文",
)
assert ok is False
assert "token not exist" in errmsg
# ============================================================
# 3. 完整业务链路(巡检完成、缺失告警、测试消息)
# ============================================================
@patch("requests.post")
def test_send_dingtalk_notification_applies_keyword_and_person(mock_post):
"""巡检完成通知正文末尾包含负责人员,必要时补充关键词。"""
mock_resp = MagicMock()
mock_resp.json.return_value = {"errcode": 0}
mock_post.return_value = mock_resp
sample_report = {
"target_name": "核心服务器-01",
"suite": "quick",
"finished_at": "2026-09-14T10:00:00",
"summary": {"正常": 10, "警告": 1, "严重": 0, "total": 11},
"modules": [
{
"name": "系统基础",
"items": [
{"name": "CPU", "value": "85%", "threshold": "<80%", "status": "警告"}
],
}
],
}
config = {
"trigger": {"include_link": True},
"dingtalk": {
"enabled": True,
"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=test",
"secret": "",
"at_mobiles": [],
"keyword": "服务监测",
"responsible_person": "陈泽键",
},
}
ok = _send_dingtalk_notification(sample_report, "http://localhost/report/1", config)
assert ok is True
payload = mock_post.call_args[1]["json"]
assert "[服务监测]" in payload["markdown"]["title"]
assert "负责人员:陈泽键" in payload["markdown"]["text"]
assert "服务监测\n\n" in payload["markdown"]["text"]
@patch("service_monitor.services.notification_service._load_config")
@patch("requests.post")
def test_send_missing_report_notification_applies_keyword_and_person(mock_post, mock_load):
"""报告缺失告警使用统一构造逻辑,包含关键词与负责人员。"""
mock_resp = MagicMock()
mock_resp.json.return_value = {"errcode": 0}
mock_post.return_value = mock_resp
mock_load.return_value = {
"email": {"enabled": False},
"wecom": {"enabled": False},
"dingtalk": {
"enabled": True,
"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=test",
"secret": "",
"keyword": "运维告警",
"responsible_person": "陈泽键",
},
}
missing_targets = [
{"target_name": "目标A", "last_report_at": "2026-09-10T12:00:00", "missing_days": 4}
]
report_missing_cfg = {
"enabled": True,
"channels": ["dingtalk"],
}
ok = send_missing_report_notification(missing_targets, report_missing_cfg)
assert ok is True
payload = mock_post.call_args[1]["json"]
assert "[运维告警]" in payload["markdown"]["title"]
assert "负责人员:陈泽键" in payload["markdown"]["text"]
@patch("service_monitor.services.notification_service._load_config")
@patch("requests.post")
def test_test_dingtalk_applies_keyword_and_person(mock_post, mock_load):
"""test_dingtalk 接口也使用统一构造逻辑(但开发阶段只做单测,不手工调用该接口)。"""
mock_resp = MagicMock()
mock_resp.json.return_value = {"errcode": 0}
mock_post.return_value = mock_resp
mock_load.return_value = {
"dingtalk": {
"enabled": True,
"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=test",
"secret": "",
"keyword": "联调测试",
"responsible_person": "陈泽键",
}
}
res = test_dingtalk()
assert res["success"] is True
payload = mock_post.call_args[1]["json"]
assert "[联调测试]" in payload["markdown"]["title"]
assert "负责人员:陈泽键" in payload["markdown"]["text"]
# ============================================================
# 4. 配置与兼容性测试
# ============================================================
def test_default_config_fields():
"""DEFAULT_CONFIG 应包含 keyword 为空字符串,responsible_person 为陈泽键。"""
dingtalk_def = DEFAULT_CONFIG.get("dingtalk", {})
assert "keyword" in dingtalk_def
assert dingtalk_def["keyword"] == ""
assert "responsible_person" in dingtalk_def
assert dingtalk_def["responsible_person"] == "陈泽键"
def test_keyword_and_person_not_masked(tmp_path, monkeypatch):
"""关键词与负责人员是明文配置,不应被脱敏为 ******。"""
from service_monitor.utils import paths as sm_paths
from service_monitor.services import notification_service
test_file = tmp_path / "notifications.json"
monkeypatch.setattr(sm_paths, "NOTIFICATIONS_FILE", test_file)
monkeypatch.setattr(notification_service, "NOTIFICATIONS_FILE", test_file)
cfg = {
"dingtalk": {
"enabled": True,
"webhook_url": "https://oapi.dingtalk.com/test",
"secret": "my-secret",
"at_mobiles": [],
"keyword": "运维监控",
"responsible_person": "陈泽键",
}
}
save_config(cfg, updated_by="admin")
masked = get_config_masked()
# 密钥被脱敏
assert masked["dingtalk"]["secret"] == "******"
# 关键词和责任人保持原值
assert masked["dingtalk"]["keyword"] == "运维监控"
assert masked["dingtalk"]["responsible_person"] == "陈泽键"
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论