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

feat(dingtalk): 定时任务钉钉报告通知(三形态消息 + 加签发送 + 配置管理)

- 新增 dingtalk_notify_service:build_sign(HMAC-SHA256 加签) / 三形态消息构建( 报告 / ️ 告警 / 🚨 异常)/ 失败明细折叠与超长保护 / send_markdown 发送
- 定时任务新增 dingtalk_notify 开关:执行完成后自动推送钉钉报告通知
- 系统设置提供报告钉钉通知配置管理(report_webhook_url / report_secret / report_at_mobiles,secret 脱敏)
- 新增 PLATFORM_BASE_URL 配置(消息中查看完整报告链接用)
- database._ensure_columns 旧库升级补列(scheduled_tasks.dingtalk_notify / dingtalk_configs.report_*)
- PRD + 执行计划文档归档 Docs/PRD/需求文档/执行中心/
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 ea2a90cb
# PRD 需求文档 - 定时任务钉钉报告通知
> **文档类型**: PRD 需求文档
> **创建日期**: 2026-09-07
> **作者**: czj
> **优先级**: P1
> **状态**: 待评审
---
## 一、需求背景
### 1.1 问题描述
定时任务已支持执行完成后自动生成 HTML 报告(`auto_report`),但**报告生成在服务器上无人知晓**
- 用户必须主动登录平台 → 报告中心 → 找到对应执行记录,才能看到结果;
- 「每日定时自动化测试」这类任务夜间/凌晨执行,**失败了要到第二天上班才被发现**
- 5.44 曾出现"每日任务连续 4 次看门狗失败、报告目录无一文件",**连续多日无人察觉**——缺乏主动推送通道是根因之一。
### 1.2 参考实现
参考 troubleshoot-ai-assistant 服务监测模块的钉钉通知实现(`PRD_需求文档_钉钉通知优化.md` + `notification_service.py`),沿用其**机器人 webhook 模式**与消息风格:
| 环节 | 参考实现 | 本项目沿用 |
|------|---------|-----------|
| 接入模式 | 群机器人 webhook + secret 加签 | ✅ 沿用(配置最简:群里加机器人拷 webhook 即用) |
| 消息格式 | markdown msgtype,✅/⚠️ 标题区分正常与告警 | ✅ 沿用 |
| 异常明细 | 🔴 严重最多 5 项,超出折叠「还有 N 项」 | ✅ 沿用(失败用例同样限 5 条折叠) |
| 报告链接 | `📎 [查看完整报告](url)` | ✅ 沿用(链接到平台 HTML 报告) |
| 签名算法 | `timestamp\n{secret}` HMAC-SHA256 → base64 → urlencode | ✅ 沿用 |
| 测试发送 | 保存前发测试消息验证配置 | ✅ 沿用 |
| @提醒 | `at.atMobiles` 指定手机号 | ✅ 沿用(仅失败/异常时 @) |
### 1.3 关键事实(现状确认)
| # | 事实 | 依据 |
|---|------|------|
| 1 | 平台已有钉钉配置是「企业内部应用」模式(app_key/app_secret/agent_id),全库无实际发送代码,且该表字段已被被测系统配置复用 | `models/dingtalk_config.py``routers/system.py:180` |
| 2 | 报告链接现成可用:`GET /api/reports/generate/{execution_id}` 按需生成并返回 HTML,**无需登录、不依赖报告是否已导出** | `routers/reports.py:35` |
| 3 | 定时任务执行完成后已有统一收口点(报告生成块之后),执行汇总(passed/failed/skipped/pass_rate)与失败用例明细(CaseResult)均在库中 | `services/scheduler_service.py:373` |
| 4 | 看门狗中断的执行可能 0 条用例结果(`steps_result` 为空、start_time 为 None),此类执行更需要推送「执行异常」 | 5.44 事件形态,见 HANDOFF 会话 51 |
| 5 | 平台缺少"对外可访问地址"配置,拼报告链接需新增 `PLATFORM_BASE_URL` | `config.py` 现仅有 TARGET_URL(被测系统) |
### 1.4 影响范围
- **后端**: `backend/app/services/dingtalk_notify_service.py`(新增,核心)
- **后端**: `backend/app/services/scheduler_service.py`(挂钩)
- **后端**: `backend/app/models/dingtalk_config.py` + `scheduled_task.py`(加列)
- **后端**: `backend/app/routers/system.py` + `scheduled_tasks.py`(API)
- **后端**: `backend/app/config.py`(PLATFORM_BASE_URL)
- **前端**: `frontend/src/views/Settings.vue`(机器人配置卡片)
- **前端**: `frontend/src/views/ScheduledTasks.vue`(任务级通知开关)
---
## 二、功能需求
### 2.1 钉钉机器人配置(全局)
在系统设置中新增「报告钉钉通知(机器人)」配置,与既有"钉钉对接配置(企业内部应用)"并存、互不影响:
| 字段 | 必填 | 说明 |
|------|------|------|
| `report_webhook_url` | 是 | 机器人 webhook 地址(`https://oapi.dingtalk.com/robot/send?access_token=xxx`) |
| `report_secret` | 否 | 加签密钥(机器人安全设置选"加签"时填写) |
| `report_at_mobiles` | 否 | 被 @ 的手机号列表,仅失败/异常消息生效 |
- 存储:`dingtalk_configs` 单例表新增 3 列(复用 id=1 单例行);
- secret 安全:落库明文(内网平台),**接口返回时脱敏为 `******`**;前端密码框展示;提交值为 `******` 时表示"保持原值不变";
- 提供「发送测试消息」按钮,验证配置正确后再启用任务级开关。
### 2.2 任务级通知开关
`scheduled_tasks` 表新增 `dingtalk_notify`(Boolean,默认 False):
- 定时任务编辑表单新增开关「执行完成后发送钉钉通知」;
- 仅该开关开启的任务,执行完成后推送;执行中心手动执行、批量执行**不推送**;
- 开关开启但 webhook 未配置 → 记录 warning 日志并跳过,**不影响执行与报告流程**。
### 2.3 推送时机与门控
挂钩点:`scheduler_service.run_task_once()` 报告生成块之后(UI 路径)。三种消息形态按执行结果自动选择:
| 执行结果 | 条件 | 消息形态 |
|---------|------|---------|
| 全部通过 | run_ok 且 failed == 0 且有用例结果 | ✅【UI自动化报告】 |
| 存在失败 | run_ok 且 failed > 0 | ⚠️【UI自动化告警】 |
| 执行异常 | run_ok == False 且 0 条用例结果(如看门狗中断) | 🚨【UI自动化异常】 |
- 推送独立于 `auto_report`(报告链接是按需生成接口,未导出报告也能打开);
- 推送失败(网络/钉钉返回错误)仅记日志,不影响调度推进。
### 2.4 消息内容设计(核心)
#### 2.4.1 存在失败用例(⚠️ 告警形态)
```markdown
⚠️ **【UI自动化告警】每日定时自动化测试**
📊 汇总:总数 26 / ✅ 通过 20 / ❌ 失败 6 / ⏭ 跳过 0 / 通过率 76.9%
🔴 **失败用例(6项):**
• 视讯设备-数据筛选验证:Timeout 30000ms exceeded waiting for selector ".table_row"
• 会议管理-预定会议:AssertionError: 页面标题不含「会议预定」(共 5 条,超出折叠)
⏱ 耗时 182s | ⏰ 完成 2026-09-07 14:32:05
📎 [查看完整报告](http://192.168.5.44/api/reports/generate/exec_abc123)
```
#### 2.4.2 全部通过(✅ 报告形态)
```markdown
✅ **【UI自动化报告】每日定时自动化测试**
📊 汇总:总数 26 / ✅ 通过 26 / ❌ 失败 0 / ⏭ 跳过 0 / 通过率 100%
⏱ 耗时 152s | ⏰ 完成 2026-09-07 14:30:05
📎 [查看完整报告](http://192.168.5.44/api/reports/generate/exec_def456)
```
#### 2.4.3 执行异常(🚨 异常形态)
```markdown
🚨 **【UI自动化异常】每日定时自动化测试**
执行未产出任何用例结果,可能被看门狗中断或执行引擎异常。
原因:执行超时被强制中断(看门狗 1800s)
⏰ 时间 2026-09-07 14:32:05
📎 [查看执行详情](http://192.168.5.44/api/reports/generate/exec_ghi789)
```
#### 2.4.4 内容规则
| # | 规则 |
|---|------|
| 1 | 失败用例明细**最多 5 条**,超出显示 `• ...(还有 N 项,详见报告)` |
| 2 | 错误摘要:用例级 `error_message` 优先,为空取**首个失败步骤**的 error;压平换行、截断 80 字符 |
| 3 | @手机号:仅失败/异常形态附加 `at.atMobiles`;全部通过不 @ 任何人 |
| 4 | 消息标题(通知栏):`UI自动化告警 - {任务名}` / `UI自动化报告 - {任务名}` / `UI自动化异常 - {任务名}` |
| 5 | 超长保护:正文 UTF-8 编码超 2000 字节(钉钉上限 2048)时,逐条丢弃失败明细并追加 `(明细过长已折叠,详见报告)`,汇总行与链接永远保留 |
| 6 | `PLATFORM_BASE_URL` 未配置时省略链接行(消息仍可发送) |
### 2.5 报告链接
- 链接 = `{PLATFORM_BASE_URL}/api/reports/generate/{execution_id}`;
- `PLATFORM_BASE_URL` 为全局配置(`config.py`,支持环境变量覆盖),部署时按实际访问地址设置(如 `http://192.168.5.44`);
- 手机钉钉点击链接直接打开 HTML 报告(generate 接口无认证,现状如此,安全边界与报告中心一致)。
### 2.6 测试发送
- `POST /api/system/report-notify-config/test`:用当前保存的配置发送一条测试 markdown 消息;
- 返回 `{success, message}`,失败时透出钉钉 errmsg(如 `sign 不匹配`、`关键字不匹配`),便于排查机器人安全设置问题。
### 2.7 非目标(明确不做)
- ❌ 不接安全测试定时任务推送(security 路径后续需求单独评估);
- ❌ 不改既有「钉钉对接配置(企业内部应用)」的功能与语义;
- ❌ 不做执行中心手动执行/批量执行的钉钉推送;
- ❌ 不做邮件/企业微信等多渠道(参考实现有三渠道,本项目只做钉钉);
- ❌ 不做报告附件(.html 文件)直接上传钉钉,仅推链接;
- ❌ 不改报告生成逻辑本身。
---
## 三、技术方案
### 3.1 新增通知服务(`dingtalk_notify_service.py`)
```python
# 核心函数
def build_sign(secret, timestamp) -> str # HMAC-SHA256 + base64 + urlencode
def build_execution_message(execution, failed_results, task_name) -> (title, text, at_mobiles)
async def notify_execution_result(execution_id, task) # 查执行+失败明细 → 构建 → 发送
async def send_markdown(config, title, text, at_mobiles) # 签名 + POST,errcode==0 判成功
async def send_test_message() # 测试发送
```
- 网络请求用 `requests` + `loop.run_in_executor` 线程池执行(与项目同步执行模式一致,避免阻塞事件循环),timeout 10s;
- 失败明细查询:`CaseResult where execution_id=? and status='failed'`,按 created_at 排序。
### 3.2 数据库变更(`database.py` 迁移 + 模型)
| 表 | 变更 |
|----|------|
| `dingtalk_configs` | + `report_webhook_url` String(512)、`report_secret` String(255)、`report_at_mobiles` JSON |
| `scheduled_tasks` | + `dingtalk_notify` Boolean default False |
沿用现有启动时 `_ensure_column` 式迁移模式(`database.py` 已有同类先例),SQLite/MySQL 双兼容。
### 3.3 配置项(`config.py`)
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| `PLATFORM_BASE_URL` | `""` | 平台对外访问地址,用于拼报告链接 |
### 3.4 API(`routers/system.py` + `scheduled_tasks.py`)
```
GET /api/system/report-notify-config # 返回配置(secret 脱敏 ******)
PUT /api/system/report-notify-config # 保存(****** 视为保持原值)
POST /api/system/report-notify-config/test # 测试发送
```
定时任务 create/update 接口透传 `dingtalk_notify`,`to_dict()` 返回该字段。
### 3.5 调度挂钩(`scheduler_service.py`)
```python
# UI 路径:报告生成块之后(try/except 独立包裹)
if task.dingtalk_notify:
try:
await dingtalk_notify_service.notify_execution_result(execution.id, task)
except Exception as e:
logger.warning(f"[定时任务] 钉钉通知发送失败: {e}")
```
---
## 四、验收标准
| 测试项 | 预期结果 |
|--------|----------|
| 任务开关开启 + webhook 已配置,执行存在失败用例 | 钉钉群收到 ⚠️ 告警消息,失败用例 ≤5 条明细 + 报告链接可打开 |
| 执行全部通过 | 收到 ✅ 报告消息,无 @提醒 |
| 看门狗中断(0 条用例结果) | 收到 🚨 异常消息 |
| 任务开关关闭 | 不发送任何消息 |
| 开关开启但 webhook 未配置 | 跳过发送,warning 日志,执行/报告不受影响 |
| 配置页「发送测试消息」 | 群内收到测试消息;secret 错误时返回钉钉 errmsg |
| secret 接口返回 | 恒为 `******`;提交 `******` 不覆盖原值 |
| 失败用例 > 5 | 明细 5 条 + 「还有 N 项」折叠行 |
| 超长消息(构造 6 条长错误) | 正文 < 2048 字节,钉钉不报 msg too long |
| 签名算法 | 与参考实现一致(单测对拍已知 secret+timestamp) |
| 后端既有测试 | 全部通过(无回归) |
| 前端 `npm run build` | 类型检查通过 |
---
## 五、相关文档
- `Docs/PRD/需求文档/执行中心/_执行计划_定时任务钉钉报告通知.md` — 计划执行文档
- `troubleshoot-ai-assistant/Docs/需求文档/服务监测/PRD_需求文档_钉钉通知优化.md` — 参考需求
- `troubleshoot-ai-assistant/skill/code/web/service_monitor/services/notification_service.py` — 参考实现
- `Docs/PRD/需求文档/定时任务/_PRD_需求文档_UI自动化定时任务模块与系统配置被测系统URL.md` — 定时任务模块背景
---
*本文档由 Claude Code 生成,遵循项目 PRD 文档规范。*
# 执行计划 - 定时任务钉钉报告通知
> **文档类型**: 计划执行文档
> **创建日期**: 2026-09-07
> **作者**: czj
> **关联 PRD**: `_PRD_需求文档_定时任务钉钉报告通知.md`
> **状态**: 待执行
---
## Phase 1:数据模型与配置(P0)
**目标**:配置有地方存,链接有地址可拼。
### 1.1 `dingtalk_configs` 表加列(`models/dingtalk_config.py` + `database.py` 迁移)
- [ ] `report_webhook_url` String(512) nullable
- [ ] `report_secret` String(255) nullable
- [ ] `report_at_mobiles` JSON nullable(默认 list)
- [ ] `database.py` 启动迁移补列(SQLite/MySQL 双兼容,沿用既有模式)
### 1.2 `scheduled_tasks` 表加列
- [ ] `dingtalk_notify` Boolean default False
- [ ] 模型 `to_dict()` / docstring 同步
- [ ] `database.py` 迁移补列
### 1.3 `config.py` + `schemas/`
- [ ] `Settings.PLATFORM_BASE_URL: str = ""`(env 可覆盖)
- [ ] `ReportNotifyConfigResponse`(webhook_url / secret 脱敏 / at_mobiles / configured 标志)
- [ ] `ReportNotifyConfigUpdateRequest`(secret 为 `******` 表示保持原值)
- [ ] 定时任务 create/update schema 加 `dingtalk_notify: bool = False`
**验收**:应用启动后两张表新列存在,旧数据不丢。
---
## Phase 2:钉钉通知服务(P0 核心)
**目标**:新建 `backend/app/services/dingtalk_notify_service.py`
### 2.1 签名与发送
- [ ] `build_sign(secret, timestamp)`: `f"{ts}\n{secret}"` → HMAC-SHA256 → base64 → `quote_plus`
- [ ] `send_markdown(config, title, text, at_mobiles)`: 拼接 `&timestamp=&sign=` → POST JSON(msgtype=markdown + at)→ errcode==0 判成功
- [ ] `requests.post(timeout=10)``loop.run_in_executor` 线程池,不阻塞事件循环
### 2.2 消息构建
- [ ] `build_execution_message(execution, failed_results, task_name)``(title, text, at_mobiles)`
- [ ] 三形态选择:✅ 全通过 / ⚠️ 有失败 / 🚨 0 条用例结果
- [ ] 失败明细 ≤5 条 + 「还有 N 项,详见报告」折叠
- [ ] 错误摘要:error_message 优先 → 首个失败步骤 error 兜底;压平换行、截 80 字符
- [ ] 超长保护:UTF-8 > 2000 字节逐条丢明细,追加「(明细过长已折叠,详见报告)」
- [ ] 链接:`{PLATFORM_BASE_URL}/api/reports/generate/{id}`,base 为空省略链接行
### 2.3 业务入口
- [ ] `notify_execution_result(execution_id, task)`:查执行 + 失败用例 → 构建发送;异常只 log 不抛
- [ ] `send_test_message()`:读配置发测试 markdown,透出钉钉 errmsg
**验收**:单测覆盖签名对拍、三形态消息、5 条折叠、超长折叠。
---
## Phase 3:API 路由(P1)
### 3.1 `routers/system.py`
- [ ] `GET /api/system/report-notify-config`(secret 恒返 `******`,未设置返空)
- [ ] `PUT /api/system/report-notify-config``******` 不覆盖原值)
- [ ] `POST /api/system/report-notify-config/test`(返回 `{success, message}`
### 3.2 `routers/scheduled_tasks.py`
- [ ] create/update 透传 `dingtalk_notify`
- [ ] 任务列表/详情返回 `dingtalk_notify`
**验收**:curl 走通 GET/PUT/test 三接口;任务带开关保存后读回一致。
---
## Phase 4:调度挂钩(P0)
- [ ] `scheduler_service.py` UI 路径报告生成块之后:`if task.dingtalk_notify:``notify_execution_result`,独立 try/except
- [ ] webhook 未配置 → warning 日志跳过
- [ ] security 路径不挂钩(非目标)
**验收**:单测 mock 发送函数,断言开关开/关两种路径的调用与否。
---
## Phase 5:前端(P1)
### 5.1 `Settings.vue`
- [ ] 新增「报告钉钉通知(机器人)」卡片:webhook 地址(文本框)/ 加签密钥(密码框,`******` 占位)/ @手机号(标签输入)/ 保存 + 发送测试按钮
- [ ] `api/system.ts` 补三个接口封装
### 5.2 `ScheduledTasks.vue`
- [ ] 任务编辑表单加「执行完成后发送钉钉通知」开关
- [ ] 任务列表列标识(可选,若列宽允许可省)
**验收**:配置→测试发送→保存→任务开启开关,全流程页面操作走通;`npm run build` 通过。
---
## Phase 6:测试、部署与真实验证(P1)
### 6.1 单元测试 `tests/test_dingtalk_notify.py`
- [ ] 签名对拍(已知 secret+timestamp → 期望 sign)
- [ ] 三形态消息构建
- [ ] 5 条折叠 / 超长折叠 / 链接省略
- [ ] 调度挂钩开关行为
- [ ] 配置 API 脱敏与 `******` 保留语义
### 6.2 回归与部署
- [ ] `pytest tests/ -v` 全量回归
- [ ] `deploy_44_202.py --host 192.168.5.44 --no-frontend` 同步后端(前端改动视情况带上)
- [ ] 5.44 设置 `PLATFORM_BASE_URL`(确认实际访问地址,如 `http://192.168.5.44`)并重启容器
- [ ] 页面配置真实机器人 webhook → 测试发送 → 群内收到消息
- [ ] 触发一次小任务执行(含失败用例)→ 收到 ⚠️ 告警 + 链接手机可打开
### 6.3 收尾
- [ ] 更新 `HANDOFF_UI自动化.md`
- [ ] PRD/计划文档状态更新
---
## 风险与回滚
| 风险 | 缓解 |
|------|------|
| 钉钉机器人安全设置(关键字/加签/IP白名单)导致发送失败 | 测试发送透出 errmsg;消息标题含「UI自动化」可配合关键字设置 |
| 消息超 2048 字节被钉钉拒绝 | 超长保护逐条折叠明细(PRD 2.4.4-5),单测覆盖 |
| requests 阻塞事件循环 | run_in_executor 线程池 + timeout 10s |
| 误推大量失败消息刷屏 | 失败明细限 5 条折叠;一个任务一次执行只推一条 |
| 平台未配 PLATFORM_BASE_URL | 省略链接行仍发汇总,不阻断 |
---
*本文档由 Claude Code 生成,遵循项目计划执行文档规范。*
......@@ -189,6 +189,14 @@ async def _ensure_columns(conn) -> None:
("scheduled_tasks", "case_type", "VARCHAR(20) DEFAULT 'ui'"),
# 定时任务:安全测试配置ID(旧库升级)
("scheduled_tasks", "config_id", "VARCHAR(64) DEFAULT NULL"),
# 定时任务:执行完成后发送钉钉报告通知开关(旧库升级)
("scheduled_tasks", "dingtalk_notify", "BOOLEAN DEFAULT 0"),
# 钉钉配置:报告通知机器人 Webhook(旧库升级)
("dingtalk_configs", "report_webhook_url", "VARCHAR(512) DEFAULT ''"),
# 钉钉配置:报告通知机器人加签密钥(旧库升级)
("dingtalk_configs", "report_secret", "VARCHAR(255) DEFAULT ''"),
# 钉钉配置:报告通知被@手机号列表(旧库升级)
("dingtalk_configs", "report_at_mobiles", "JSON"),
# 性能测试:增强指标体系(P95/标准差/延迟/连接时间/带宽/峰值TPS/Apdex/错误分类,旧库升级)
("performance_tasks", "p95_response_time", "FLOAT DEFAULT 0.0"),
("performance_tasks", "std_dev", "FLOAT DEFAULT 0.0"),
......
......@@ -11,7 +11,7 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import String, DateTime, Integer
from sqlalchemy import String, DateTime, Integer, JSON
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
......@@ -45,6 +45,16 @@ class DingTalkConfig(Base):
captcha_path: Mapped[str] = mapped_column(
String(200), default="/platform/api/code", comment="验证码接口路径"
)
# 报告钉钉通知(群机器人 webhook 模式,与企业内部应用配置相互独立)
report_webhook_url: Mapped[Optional[str]] = mapped_column(
String(512), default="", comment="报告通知机器人 Webhook 地址"
)
report_secret: Mapped[Optional[str]] = mapped_column(
String(255), default="", comment="报告通知机器人加签密钥"
)
report_at_mobiles: Mapped[Optional[list]] = mapped_column(
JSON, default=list, comment="报告通知被@手机号列表(仅失败/异常时@)"
)
updated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间"
)
......@@ -60,4 +70,7 @@ class DingTalkConfig(Base):
"target_url": self.target_url or "",
"login_path": self.login_path or "/platform/api/auth/login",
"captcha_path": self.captcha_path or "/platform/api/code",
"report_webhook_url": self.report_webhook_url or "",
"report_secret": self.report_secret or "",
"report_at_mobiles": self.report_at_mobiles or [],
}
\ No newline at end of file
......@@ -38,7 +38,8 @@ class ScheduledTask(Base):
interval_unit (str): 间隔单位 (minutes/hours/days)
run_time (str): 执行时间 HH:MM(schedule_type=daily/weekly)
weekdays (list): 星期 [0-6](schedule_type=weekly)
auto_report (bool): 执行完成后是否自动生成报告
auto_report (bool): 执行完成后是否自动生成报告
dingtalk_notify (bool): 执行完成后是否发送钉钉报告通知
last_run_at (datetime): 最近一次触发时间
next_run_at (datetime): 下次触发时间
run_count (int): 累计触发次数
......@@ -95,6 +96,9 @@ class ScheduledTask(Base):
auto_report: Mapped[bool] = mapped_column(
Boolean, default=True, comment="执行完成后自动生成报告"
)
dingtalk_notify: Mapped[bool] = mapped_column(
Boolean, default=False, comment="执行完成后发送钉钉报告通知"
)
last_run_at: Mapped[Optional[datetime]] = mapped_column(
DateTime, nullable=True, default=None, comment="最近一次触发时间"
)
......@@ -137,6 +141,7 @@ class ScheduledTask(Base):
"run_time": self.run_time or "",
"weekdays": self.weekdays or [],
"auto_report": self.auto_report,
"dingtalk_notify": self.dingtalk_notify,
"last_run_at": self.last_run_at.isoformat() if self.last_run_at else None,
"next_run_at": self.next_run_at.isoformat() if self.next_run_at else None,
"run_count": self.run_count,
......
......@@ -115,6 +115,7 @@ async def create_task(
run_time=data.run_time or "",
weekdays=data.weekdays or [],
auto_report=data.auto_report,
dingtalk_notify=data.dingtalk_notify,
)
task.next_run_at = compute_next_run(task)
db.add(task)
......
......@@ -30,6 +30,8 @@ from app.models.dingtalk_config import DingTalkConfig
from app.schemas.dingtalk_config import (
DingTalkConfigResponse,
DingTalkConfigUpdateRequest,
ReportNotifyConfigResponse,
ReportNotifyConfigUpdateRequest,
SutConfigResponse,
SutConfigUpdateRequest,
)
......@@ -256,6 +258,80 @@ async def update_dingtalk_config(request: DingTalkConfigUpdateRequest, db: Async
)
# ==================== 报告钉钉通知配置(群机器人 Webhook 模式) ====================
@router.get("/report-notify-config", response_model=ReportNotifyConfigResponse, summary="获取报告钉钉通知配置")
async def get_report_notify_config(db: AsyncSession = Depends(get_db)):
"""
获取报告钉钉通知配置(群机器人)
secret 已设置时脱敏返回 ******,前端密码框展示。
"""
from app.services.dingtalk_notify_service import get_notify_config, get_config_masked
config = get_config_masked(await get_notify_config(db))
return ReportNotifyConfigResponse(
webhook_url=config.get("webhook_url", ""),
secret=config.get("secret", ""),
at_mobiles=config.get("at_mobiles", []),
configured=bool(config.get("webhook_url")),
)
@router.put("/report-notify-config", response_model=ReportNotifyConfigResponse, summary="更新报告钉钉通知配置")
async def update_report_notify_config(
request: ReportNotifyConfigUpdateRequest,
db: AsyncSession = Depends(get_db),
):
"""
更新报告钉钉通知配置(持久化到数据库)
secret 传 ****** 表示保持原值不变(前端回显脱敏值后原样提交的场景)。
"""
result = await db.execute(select(DingTalkConfig).where(DingTalkConfig.id == 1))
config = result.scalar_one_or_none()
if not config:
config = DingTalkConfig(id=1)
db.add(config)
if request.webhook_url is not None:
config.report_webhook_url = request.webhook_url.strip()
if request.secret is not None and request.secret != "******":
config.report_secret = request.secret.strip()
if request.at_mobiles is not None:
# 去空白、去重、保留顺序
seen, mobiles = set(), []
for m in request.at_mobiles:
m = (m or "").strip()
if m and m not in seen:
seen.add(m)
mobiles.append(m)
config.report_at_mobiles = mobiles
await db.commit()
await db.refresh(config)
logger.info("报告钉钉通知配置已更新")
return ReportNotifyConfigResponse(
webhook_url=config.report_webhook_url or "",
secret="******" if config.report_secret else "",
at_mobiles=config.report_at_mobiles or [],
configured=bool(config.report_webhook_url),
)
@router.post("/report-notify-config/test", response_model=dict, summary="发送钉钉测试消息")
async def test_report_notify():
"""
用当前已保存的报告通知配置发送一条测试消息,验证机器人连通性。
"""
from app.services.dingtalk_notify_service import send_test_message
ok, message = await send_test_message()
return {"success": ok, "message": message}
# ==================== 被测系统配置 ====================
......
......@@ -8,7 +8,7 @@
创建日期:2026-08-13
"""
from typing import Optional
from typing import List, Optional
from pydantic import BaseModel, Field
......@@ -44,4 +44,19 @@ class SutConfigUpdateRequest(BaseModel):
)
captcha_path: Optional[str] = Field(
None, description="验证码接口路径,默认 /platform/api/code"
)
\ No newline at end of file
)
class ReportNotifyConfigResponse(BaseModel):
"""报告钉钉通知配置响应(secret 脱敏)"""
webhook_url: str = Field("", description="机器人 Webhook 地址")
secret: str = Field("", description="加签密钥(已设置时返回 ******)")
at_mobiles: List[str] = Field([], description="被@手机号列表")
configured: bool = Field(False, description="webhook 是否已配置")
class ReportNotifyConfigUpdateRequest(BaseModel):
"""报告钉钉通知配置更新请求(secret 传 ****** 表示保持原值)"""
webhook_url: Optional[str] = Field(None, description="机器人 Webhook 地址")
secret: Optional[str] = Field(None, description="加签密钥,****** 表示保持原值")
at_mobiles: Optional[List[str]] = Field(None, description="被@手机号列表")
\ No newline at end of file
......@@ -22,6 +22,7 @@ class ScheduledTaskCreate(BaseModel):
run_time: str = Field("", description="执行时间 HH:MM(daily/weekly)")
weekdays: List[int] = Field([], description="星期 [0-6](weekly)")
auto_report: bool = Field(True, description="执行完成后自动生成报告")
dingtalk_notify: bool = Field(False, description="执行完成后发送钉钉报告通知")
enabled: bool = Field(True, description="是否启用")
case_type: str = Field("ui", description="用例类型: ui/security")
config_id: Optional[str] = Field(None, description="安全测试配置ID(security时必填)")
......@@ -37,6 +38,7 @@ class ScheduledTaskUpdate(BaseModel):
run_time: Optional[str] = Field(None, description="执行时间 HH:MM")
weekdays: Optional[List[int]] = Field(None, description="星期 [0-6]")
auto_report: Optional[bool] = Field(None, description="自动生成报告")
dingtalk_notify: Optional[bool] = Field(None, description="发送钉钉报告通知")
enabled: Optional[bool] = Field(None, description="是否启用")
case_type: Optional[str] = Field(None, description="用例类型: ui/security")
config_id: Optional[str] = Field(None, description="安全测试配置ID")
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:dingtalk_notify_service.py
模块描述:定时任务钉钉报告通知服务(群机器人 Webhook 模式)
作者:czj
创建日期:2026-09-07
最后修改:2026-09-07
实现说明:
- 接入模式为钉钉群机器人 Webhook + 加签(可选),与系统设置中已有的
「钉钉对接配置(企业内部应用)」相互独立、互不影响;
- 消息为 markdown msgtype,按执行结果分三形态:
✅ 全部通过 / ⚠️ 存在失败用例 / 🚨 执行异常(无用例结果,如看门狗中断);
- 网络请求使用 requests + 线程池(run_in_executor),避免阻塞事件循环;
- 发送失败仅记录日志,不影响定时任务调度推进。
参考实现:troubleshoot-ai-assistant service_monitor/services/notification_service.py
"""
import asyncio
import base64
import hashlib
import hmac
import logging
import time
import urllib.parse
from typing import List, Optional, Tuple
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
import requests
from app.config import settings
from app.database import async_session_maker
from app.models.case_result import CaseResult
from app.models.dingtalk_config import DingTalkConfig
logger = logging.getLogger(__name__)
# 钉钉机器人 Webhook 地址
DINGTALK_WEBHOOK_BASE = "https://oapi.dingtalk.com/robot/send"
# requests 超时(秒)
REQUEST_TIMEOUT = 10
# 消息长度保护阈值(钉钉 markdown 正文上限 2048 字节,预留余量)
MAX_MESSAGE_BYTES = 2000
# 失败用例明细最大条数(超出折叠)
MAX_FAILED_ITEMS = 5
# 单条错误摘要最大长度
MAX_ERROR_LENGTH = 80
# secret 脱敏占位符(接口返回与「保持原值」判定)
SECRET_MASK = "******"
# ==================== 配置读写 ====================
async def get_notify_config(db: AsyncSession) -> dict:
"""
读取报告通知配置(原始值,secret 不脱敏,仅供发送使用)
Args:
db (AsyncSession): 数据库会话
Returns:
dict: {webhook_url, secret, at_mobiles}
"""
result = await db.execute(select(DingTalkConfig).where(DingTalkConfig.id == 1))
config = result.scalar_one_or_none()
if not config:
return {"webhook_url": "", "secret": "", "at_mobiles": []}
return {
"webhook_url": config.report_webhook_url or "",
"secret": config.report_secret or "",
"at_mobiles": config.report_at_mobiles or [],
}
def get_config_masked(config: dict) -> dict:
"""
配置脱敏(接口返回用):secret 已设置时替换为 ******
Args:
config (dict): 原始配置
Returns:
dict: 脱敏后的配置
"""
masked = dict(config)
if masked.get("secret"):
masked["secret"] = SECRET_MASK
return masked
# ==================== 签名与发送 ====================
def build_sign(secret: str, timestamp: str) -> str:
"""
钉钉加签算法:sec、timestamp 拼接后 HMAC-SHA256 → base64 → urlencode
Args:
secret (str): 加签密钥
timestamp (str): 毫秒级时间戳字符串
Returns:
str: 签名值(可直接拼接到 &sign=)
"""
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()
return urllib.parse.quote_plus(base64.b64encode(hmac_code).decode())
def _post_markdown_sync(url: str, payload: dict) -> dict:
"""同步 POST markdown 消息(在线程池中执行)"""
resp = requests.post(url, json=payload, timeout=REQUEST_TIMEOUT)
return resp.json()
async def send_markdown(
webhook_url: str,
secret: str,
title: str,
text: str,
at_mobiles: Optional[List[str]] = None,
) -> Tuple[bool, str]:
"""
发送 markdown 消息到钉钉群
Args:
webhook_url (str): 机器人 Webhook 地址
secret (str): 加签密钥(为空则不加签)
title (str): 消息标题(通知栏展示)
text (str): markdown 正文
at_mobiles (Optional[List[str]]): 被@手机号列表
Returns:
Tuple[bool, str]: (是否成功, 失败原因/成功提示)
"""
if not webhook_url:
return False, "Webhook 地址未配置"
# 加签
url = webhook_url
if secret:
timestamp = str(round(time.time() * 1000))
url = f"{webhook_url}&timestamp={timestamp}&sign={build_sign(secret, timestamp)}"
payload = {"msgtype": "markdown", "markdown": {"title": title, "text": text}}
if at_mobiles:
payload["at"] = {"atMobiles": list(at_mobiles), "isAtAll": False}
try:
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
None, _post_markdown_sync, url, payload
)
except Exception as e:
logger.error(f"[钉钉通知] 发送异常: {e}")
return False, f"发送异常: {e}"
if result.get("errcode") == 0:
logger.info(f"[钉钉通知] 消息已发送: {title}")
return True, "发送成功"
errmsg = result.get("errmsg", "未知错误")
logger.error(f"[钉钉通知] 钉钉返回错误: errcode={result.get('errcode')}, errmsg={errmsg}")
return False, f"钉钉返回错误: {errmsg}"
# ==================== 消息构建 ====================
def _flatten_error(text: str, max_length: int = MAX_ERROR_LENGTH) -> str:
"""压平换行并截断错误摘要"""
flat = " ".join(str(text or "").split())
if len(flat) > max_length:
return flat[:max_length] + "…"
return flat
def _case_error_summary(case: CaseResult) -> str:
"""
用例错误摘要:用例级 error_message 优先,为空取首个失败步骤的 error
"""
if case.error_message:
return _flatten_error(case.error_message)
for step in case.steps_result or []:
if step.get("status") == "failed" and step.get("error"):
return _flatten_error(step["error"])
return "未知错误"
def _report_url(execution_id: str) -> str:
"""拼报告链接;PLATFORM_BASE_URL 未配置时返回空串"""
base = (settings.PLATFORM_BASE_URL or "").rstrip("/")
if not base:
return ""
return f"{base}/api/reports/generate/{execution_id}"
def _target_system_line() -> str:
"""被测系统行(🌐);TARGET_URL 未配置时返回空串"""
url = (settings.TARGET_URL or "").strip()
if not url:
return ""
host = url.split("://", 1)[-1].rstrip("/")
return f"🌐 被测系统:{host}"
def build_execution_message(
execution,
failed_results: List[CaseResult],
task_name: str,
notify_mobiles: Optional[List[str]] = None,
) -> Tuple[str, str, List[str]]:
"""
按执行结果构建三形态消息
Args:
execution: Execution 记录(含 passed/failed/skipped/pass_rate/duration/status 等)
failed_results (List[CaseResult]): 失败用例结果列表
task_name (str): 定时任务名称
notify_mobiles (Optional[List[str]]): 配置的被@手机号(仅失败/异常告警形态使用)
Returns:
Tuple[str, str, List[str]]: (标题, markdown 正文, 被@手机号列表)
"""
total = execution.total_cases or 0
passed = execution.passed or 0
failed = execution.failed or 0
skipped = execution.skipped or 0
pass_rate = execution.pass_rate if execution.pass_rate is not None else 0.0
# 报告链接行(未配置 PLATFORM_BASE_URL 时省略)
link_line = ""
url = _report_url(execution.id)
if url:
link_line = f"\n\n📎 [查看完整报告]({url})"
# 被测系统行(未配置 TARGET_URL 时省略)
target_line = _target_system_line()
# ---- 形态 3:执行异常(无用例结果,如看门狗中断) ----
if total == 0 and not failed_results:
reason = _flatten_error(execution.error_message or "执行中断,原因未知", 150)
finish_at = (
execution.end_time.strftime("%Y-%m-%d %H:%M:%S")
if execution.end_time else time.strftime("%Y-%m-%d %H:%M:%S")
)
title = f"UI自动化异常 - {task_name}"
lines = [
f"🚨 **【UI自动化异常】{task_name}**",
]
if target_line:
lines.append(target_line)
lines += [
"",
"执行未产出任何用例结果,可能被看门狗中断或执行引擎异常。",
"",
f"原因:{reason}",
"",
f"⏰ 时间 {finish_at}",
]
text = "\n".join(lines) + link_line
return title, text, list(notify_mobiles or [])
# 汇总行(形态 1/2 共用)
summary_line = (
f"📊 汇总:总数 {total} / ✅ 通过 {passed} / ❌ 失败 {failed}"
f" / ⏭ 跳过 {skipped} / 通过率 {pass_rate}%"
)
duration_line = f"⏱ 耗时 {execution.duration:.0f}s" if execution.duration else ""
finish_line = (
f"⏰ 完成 {execution.end_time.strftime('%Y-%m-%d %H:%M:%S')}"
if execution.end_time else ""
)
time_line = " | ".join(x for x in (duration_line, finish_line) if x)
# ---- 形态 2:存在失败用例(⚠️ 告警形态,@指定人) ----
if failed > 0 or failed_results:
title = f"UI自动化告警 - {task_name}"
lines = [
f"⚠️ **【UI自动化告警】{task_name}**",
]
if target_line:
lines.append(target_line)
lines += [
"",
summary_line,
"",
f"🔴 **失败用例({len(failed_results)}项):**",
]
for case in failed_results[:MAX_FAILED_ITEMS]:
lines.append(f"• {case.case_name or case.case_id or '未知用例'}:{_case_error_summary(case)}")
if len(failed_results) > MAX_FAILED_ITEMS:
lines.append(f"• ...(还有 {len(failed_results) - MAX_FAILED_ITEMS} 项,详见报告)")
if time_line:
lines.append("")
lines.append(time_line)
text = "\n".join(lines) + link_line
return title, text, list(notify_mobiles or [])
# ---- 形态 1:全部通过(✅ 报告形态,不@任何人) ----
title = f"UI自动化报告 - {task_name}"
lines = [
f"✅ **【UI自动化报告】{task_name}**",
]
if target_line:
lines.append(target_line)
lines += [
"",
summary_line,
]
if time_line:
lines.append("")
lines.append(time_line)
text = "\n".join(lines) + link_line
return title, text, []
def _shrink_message(title: str, text: str, at_mobiles: List[str]) -> Tuple[str, List[str]]:
"""
超长保护:正文超 MAX_MESSAGE_BYTES 时逐条丢弃失败明细行,
全部丢完仍超长则截断正文,追加折叠提示。
"""
if len(text.encode("utf-8")) <= MAX_MESSAGE_BYTES:
return text, at_mobiles
lines = text.split("\n")
# 从后往前找失败明细行(• 开头且非折叠提示),逐条移除
for i in range(len(lines) - 1, -1, -1):
if len(text.encode("utf-8")) <= MAX_MESSAGE_BYTES:
break
if lines[i].startswith("•") and "详见报告" not in lines[i]:
lines.pop(i)
text = "\n".join(lines)
# 明细行有删减时补一行折叠提示
hint = "• (明细过长已折叠,详见报告)"
if hint not in text:
# 插到 🔴 标题行之后
for j, ln in enumerate(lines):
if ln.startswith("🔴"):
lines.insert(j + 1, hint)
break
else:
lines.append(hint)
text = "\n".join(lines)
# 仍超长:硬截断(保汇总与链接所在尾部不保)
if len(text.encode("utf-8")) > MAX_MESSAGE_BYTES:
# 按字符截断到安全长度(每字符最多 3 字节 + 余量)
safe_chars = MAX_MESSAGE_BYTES // 3 - 20
text = text[:safe_chars] + "\n\n(消息过长已截断,详见报告)"
return text, at_mobiles
# ==================== 业务入口 ====================
async def _load_failed_results(db: AsyncSession, execution_id: str) -> List[CaseResult]:
"""查询执行下所有失败用例结果(按创建时间排序)"""
result = await db.execute(
select(CaseResult)
.where(CaseResult.execution_id == execution_id, CaseResult.status == "failed")
.order_by(CaseResult.created_at.asc())
)
return list(result.scalars().all())
async def notify_execution_result(
execution_id: str,
task_name: str,
db: Optional[AsyncSession] = None,
) -> Tuple[bool, str]:
"""
定时任务执行完成后推送钉钉通知(调度器挂钩入口)
查执行记录 + 失败用例 → 构建三形态消息 → 发送。
发送失败仅记录日志,不抛异常(不影响调度推进)。
Args:
execution_id (str): 执行记录 ID
task_name (str): 定时任务名称
db (Optional[AsyncSession]): 复用的数据库会话(不传则自建)
Returns:
Tuple[bool, str]: (是否发送成功, 说明)
"""
own_session = db is None
if own_session:
db = async_session_maker()
try:
from app.models.execution import Execution
result = await db.execute(select(Execution).where(Execution.id == execution_id))
execution = result.scalar_one_or_none()
if not execution:
logger.warning(f"[钉钉通知] 执行记录不存在: {execution_id}")
return False, "执行记录不存在"
config = await get_notify_config(db)
if not config.get("webhook_url"):
logger.warning("[钉钉通知] Webhook 未配置,跳过发送")
return False, "Webhook 未配置"
failed_results = await _load_failed_results(db, execution_id)
title, text, at_mobiles = build_execution_message(
execution, failed_results, task_name, notify_mobiles=config.get("at_mobiles")
)
text, at_mobiles = _shrink_message(title, text, at_mobiles)
ok, message = await send_markdown(
config["webhook_url"], config["secret"], title, text, at_mobiles
)
return ok, message
except Exception as e:
logger.error(f"[钉钉通知] 推送执行结果失败: {e}")
return False, f"推送失败: {e}"
finally:
if own_session:
await db.close()
async def send_test_message() -> Tuple[bool, str]:
"""
发送测试消息(配置页「发送测试」按钮)
Returns:
Tuple[bool, str]: (是否成功, 钉钉 errmsg / 成功提示)
"""
async with async_session_maker() as db:
config = await get_notify_config(db)
if not config.get("webhook_url"):
return False, "Webhook 地址未配置,请先保存"
now = time.strftime("%Y-%m-%d %H:%M:%S")
title = "钉钉通知测试"
target_line = _target_system_line()
text = (
"🔧 **【钉钉通知测试】**\n\n"
"这是一条测试消息,用于验证平台自动化测试系统的钉钉报告通知配置。\n\n"
+ (f"{target_line}\n\n" if target_line else "")
+ f"⏰ 发送时间:{now}"
)
return await send_markdown(
config["webhook_url"], config["secret"], title, text, config.get("at_mobiles")
)
......@@ -390,6 +390,23 @@ async def run_task_once(task_id: str, manual: bool = False) -> Optional[str]:
f"[定时任务] 任务「{task.name}」生成报告失败: {e}"
)
# 5b. 钉钉报告通知(任务级开关;发送失败不影响调度推进)
if task.dingtalk_notify:
try:
from app.services.dingtalk_notify_service import notify_execution_result
ok, message = await notify_execution_result(execution.id, task.name, db=db)
if ok:
logger.info(f"[定时任务] 任务「{task.name}」钉钉通知已发送")
else:
logger.warning(
f"[定时任务] 任务「{task.name}」钉钉通知未发送: {message}"
)
except Exception as e:
logger.error(
f"[定时任务] 任务「{task.name}」钉钉通知异常: {e}"
)
# 6. 推进调度状态
task.last_run_at = datetime.now()
task.run_count = (task.run_count or 0) + 1
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:test_dingtalk_notify.py
模块描述:定时任务钉钉报告通知单元测试
覆盖:
- 加签算法 build_sign(HMAC-SHA256 + base64 + urlencode,与钉钉官方算法一致)
- 三形态消息构建(✅ 报告 / ⚠️ 告警 / 🚨 异常)
- 失败明细 > 5 条折叠、错误摘要截断、报告链接按 PLATFORM_BASE_URL 取舍
- 超长消息 _shrink_message 保护(正文 < 2048 字节上限)
- secret 脱敏与「****** 保持原值」语义(get_config_masked / 服务层)
- send_markdown 的 payload 组装(at.atMobiles 仅告警/异常形态携带)
- notify_execution_result 手动发送路径(报告中心「发通知」按钮,FakeDB 模拟查询)
作者:czj
创建日期:2026-09-07
"""
import base64
import hashlib
import hmac
import urllib.parse
from types import SimpleNamespace
import pytest
from app.config import settings
from app.services.dingtalk_notify_service import (
MAX_FAILED_ITEMS,
MAX_MESSAGE_BYTES,
SECRET_MASK,
_case_error_summary,
_flatten_error,
_report_url,
_shrink_message,
build_execution_message,
build_sign,
get_config_masked,
send_markdown,
)
from app.services import dingtalk_notify_service as svc
def make_execution(**overrides):
"""构造 Execution 形状的对象(不落库)"""
base = dict(
id="exec_test_001",
total_cases=26,
passed=26,
failed=0,
skipped=0,
pass_rate=100.0,
duration=182.4,
status="completed",
error_message=None,
end_time=None,
)
base.update(overrides)
return SimpleNamespace(**base)
def make_failed_case(name="用例A", error=None, steps=None):
"""构造 CaseResult 形状的对象(不落库)"""
return SimpleNamespace(
case_id="case_001",
case_name=name,
status="failed",
error_message=error,
steps_result=steps or [],
)
def make_config(**overrides):
base = dict(webhook_url="", secret="", at_mobiles=[])
base.update(overrides)
return base
class TestBuildSign:
"""加签算法:与钉钉官方 Python 示例公式一致"""
def test_matches_official_formula(self):
secret = "SEC7d941b1d9a5d8f3c2b1a0z9y8x7w6v5u4t3s2r1q"
timestamp = "1694073600000"
# 独立按官方示例公式计算期望值
string_to_sign = f"{timestamp}\n{secret}"
expected = urllib.parse.quote_plus(
base64.b64encode(
hmac.new(
secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
).decode()
)
assert build_sign(secret, timestamp) == expected
def test_sign_is_urlsafe_and_deterministic(self):
sign1 = build_sign("secret", "1694073600000")
sign2 = build_sign("secret", "1694073600000")
assert sign1 == sign2
# quote_plus 后不应含裸 + / = 等需要再编码的字符
assert "+" not in sign1 and "=" not in sign1 and "/" not in sign1
def test_sign_changes_with_timestamp(self):
assert build_sign("secret", "1694073600000") != build_sign("secret", "1694073600001")
class TestMessageForms:
"""三形态消息构建"""
TASK = "每日核心回归"
def test_all_pass_form(self):
"""✅ 全部通过:无失败块、不@任何人"""
title, text, at_mobiles = build_execution_message(
make_execution(), [], self.TASK, notify_mobiles=["13800000001"]
)
assert title == f"UI自动化报告 - {self.TASK}"
assert "✅" in text
assert "总数 26" in text and "通过率 100.0%" in text
assert "🔴" not in text
assert at_mobiles == []
def test_failure_form_with_at_mobiles(self):
"""⚠️ 存在失败:告警标题 + 失败明细 + @配置手机号"""
execution = make_execution(
total_cases=26, passed=20, failed=6, skipped=0, pass_rate=76.9
)
failed = [
make_failed_case("用例一", error="Timeout 30000ms exceeded"),
make_failed_case("用例二", steps=[{"status": "failed", "error": "元素未找到"}]),
]
title, text, at_mobiles = build_execution_message(
execution, failed, self.TASK, notify_mobiles=["13800000001", "13900000002"]
)
assert title == f"UI自动化告警 - {self.TASK}"
assert "⚠️" in text and "🔴" in text
assert "失败用例(2项)" in text
assert "用例一:Timeout 30000ms exceeded" in text
# 用例级 error_message 为空时回退首个失败步骤 error
assert "用例二:元素未找到" in text
assert at_mobiles == ["13800000001", "13900000002"]
def test_failure_form_overflow_five(self):
"""失败明细 > 5 条:仅展示 5 条 + 折叠行"""
execution = make_execution(total_cases=10, passed=3, failed=7, pass_rate=30.0)
failed = [make_failed_case(f"失败用例{i}", error=f"错误{i}") for i in range(7)]
_, text, _ = build_execution_message(execution, failed, self.TASK)
detail_lines = [ln for ln in text.split("\n") if ln.startswith("• 失败用例")]
assert len(detail_lines) == MAX_FAILED_ITEMS
assert "还有 2 项,详见报告" in text
def test_exception_form_zero_results(self):
"""🚨 0 条用例结果(看门狗中断):异常形态 + @手机号"""
execution = make_execution(
total_cases=0, passed=0, failed=0, status="failed",
error_message="看门狗:执行超时无进展",
)
title, text, at_mobiles = build_execution_message(
execution, [], self.TASK, notify_mobiles=["13800000001"]
)
assert title == f"UI自动化异常 - {self.TASK}"
assert "🚨" in text and "看门狗" in text
assert at_mobiles == ["13800000001"]
def test_link_omitted_without_base_url(self, monkeypatch):
"""PLATFORM_BASE_URL 未配置:省略报告链接行"""
monkeypatch.setattr(settings, "PLATFORM_BASE_URL", "")
_, text, _ = build_execution_message(make_execution(), [], self.TASK)
assert "查看完整报告" not in text
def test_link_appended_with_base_url(self, monkeypatch):
monkeypatch.setattr(settings, "PLATFORM_BASE_URL", "http://192.168.5.44/")
_, text, _ = build_execution_message(make_execution(), [], self.TASK)
assert "📎 [查看完整报告](http://192.168.5.44/api/reports/generate/exec_test_001)" in text
def test_target_system_line_in_all_forms(self, monkeypatch):
"""🌐 被测系统行:三形态均展示(取 TARGET_URL 去协议前缀)"""
monkeypatch.setattr(settings, "TARGET_URL", "https://192.168.5.44")
exec_failed = make_execution(total_cases=10, passed=3, failed=7, pass_rate=30.0)
exec_zero = make_execution(total_cases=0, passed=0, failed=0, status="failed")
for execution in (make_execution(), exec_failed, exec_zero):
_, text, _ = build_execution_message(execution, [], self.TASK)
assert "🌐 被测系统:192.168.5.44" in text
def test_target_system_line_omitted_when_unset(self, monkeypatch):
"""TARGET_URL 未配置:省略被测系统行"""
monkeypatch.setattr(settings, "TARGET_URL", "")
_, text, _ = build_execution_message(make_execution(), [], self.TASK)
assert "被测系统" not in text
class TestErrorSummary:
"""错误摘要:压平 + 截断"""
def test_flatten_and_truncate(self):
long_error = "line1\nline2\n" + "x" * 200
flat = _flatten_error(long_error)
assert "\n" not in flat
assert len(flat) == 80 + 1 # 截断 + 省略号
assert flat.endswith("…")
def test_case_summary_prefers_error_message(self):
case = make_failed_case(error="用例级错误", steps=[{"status": "failed", "error": "步骤级错误"}])
assert _case_error_summary(case) == "用例级错误"
def test_case_summary_falls_back_to_step(self):
case = make_failed_case(error=None, steps=[{"status": "passed"}, {"status": "failed", "error": "步骤级错误"}])
assert _case_error_summary(case) == "步骤级错误"
def test_case_summary_unknown(self):
assert _case_error_summary(make_failed_case()) == "未知错误"
class TestShrinkMessage:
"""超长保护"""
def test_short_message_untouched(self):
text = "短消息"
shrunk, at = _shrink_message("t", text, ["138"])
assert shrunk == text and at == ["138"]
def test_overlong_message_shrinks_below_limit(self):
# 构造 8 条超长失败明细 → 必然超 2000 字节
lines = ["⚠️ **【UI自动化告警】长消息任务**", "", "📊 汇总", "", "🔴 **失败用例(8项):**"]
for i in range(8):
lines.append(f"• 失败用例{i}:{'错' * 120}")
lines.append("\n\n📎 [查看完整报告](http://x/api/reports/generate/e1)")
text = "\n".join(lines)
assert len(text.encode("utf-8")) > MAX_MESSAGE_BYTES
shrunk, _ = _shrink_message("t", text, [])
assert len(shrunk.encode("utf-8")) <= MAX_MESSAGE_BYTES
assert "明细过长已折叠" in shrunk
def test_extreme_message_hard_truncated(self):
# 单条超巨明细(无 • 之外的可行折叠空间也须兜底截断)
text = "⚠️ 标题\n" + "• " + "错" * 3000
shrunk, _ = _shrink_message("t", text, [])
assert len(shrunk.encode("utf-8")) <= MAX_MESSAGE_BYTES
class TestConfigMasking:
"""secret 脱敏"""
def test_masked_when_set(self):
masked = get_config_masked(make_config(secret="SECabc", webhook_url="http://w"))
assert masked["secret"] == SECRET_MASK
assert masked["webhook_url"] == "http://w"
def test_empty_secret_stays_empty(self):
assert get_config_masked(make_config(secret=""))["secret"] == ""
class TestSendMarkdown:
"""发送:payload 组装 + 未配置拦截"""
async def test_missing_webhook_short_circuits(self):
ok, message = await send_markdown("", "", "t", "text")
assert ok is False
assert "未配置" in message
async def test_payload_contains_at_mobiles(self, monkeypatch):
captured = {}
def fake_post(url, payload):
captured["url"] = url
captured["payload"] = payload
return {"errcode": 0, "errmsg": "ok"}
monkeypatch.setattr(svc, "_post_markdown_sync", fake_post)
ok, _ = await send_markdown(
"https://oapi.dingtalk.com/robot/send?access_token=x", "", "标题", "正文",
at_mobiles=["13800000001"],
)
assert ok is True
assert captured["payload"]["at"]["atMobiles"] == ["13800000001"]
assert captured["payload"]["msgtype"] == "markdown"
assert captured["url"].startswith("https://oapi.dingtalk.com/robot/send?access_token=x")
async def test_secret_appends_sign_params(self, monkeypatch):
captured = {}
def fake_post(url, payload):
captured["url"] = url
return {"errcode": 0, "errmsg": "ok"}
monkeypatch.setattr(svc, "_post_markdown_sync", fake_post)
ok, _ = await send_markdown(
"https://oapi.dingtalk.com/robot/send?access_token=x", "SECabc", "t", "x"
)
assert ok is True
assert "timestamp=" in captured["url"] and "sign=" in captured["url"]
async def test_dingtalk_error_returned(self, monkeypatch):
def fake_post(url, payload):
return {"errcode": 310000, "errmsg": "sign not match"}
monkeypatch.setattr(svc, "_post_markdown_sync", fake_post)
ok, message = await send_markdown("https://oapi.dingtalk.com/robot/send?access_token=x", "", "t", "x")
assert ok is False
assert "sign not match" in message
# ==================== 手动发送路径(报告中心「发通知」按钮) ====================
class _FakeScalars:
def __init__(self, items):
self._items = items
def all(self):
return self._items
class _FakeResult:
def __init__(self, value):
self._value = value
def scalar_one_or_none(self):
return self._value
def scalars(self):
return _FakeScalars(self._value if isinstance(self._value, list) else [])
class _FakeDB:
"""
按 select 实体返回预设对象的假 AsyncSession
覆盖 notify_execution_result 的查询路径:
Execution(单行)→ DingTalkConfig(单行)→ CaseResult(失败列表)
"""
def __init__(self, execution=None, config_row=None, failed_results=None):
self._execution = execution
self._config_row = config_row
self._failed = failed_results or []
async def execute(self, query):
entity = query.column_descriptions[0]["entity"]
name = entity.__name__ if entity else ""
if name == "Execution":
return _FakeResult(self._execution)
if name == "DingTalkConfig":
return _FakeResult(self._config_row)
if name == "CaseResult":
return _FakeResult(self._failed)
raise AssertionError(f"未预期的查询实体: {name}")
async def close(self):
pass
def make_config_row(webhook_url=""):
"""构造 DingTalkConfig 形状的行对象(报告通知三字段)"""
return SimpleNamespace(
report_webhook_url=webhook_url,
report_secret="SECabc" if webhook_url else "",
report_at_mobiles=["13800000001"] if webhook_url else [],
)
class TestNotifyExecutionResult:
"""notify_execution_result:手动发送入口(执行查询 → 配置拦截 → 发送)"""
WEBHOOK = "https://oapi.dingtalk.com/robot/send?access_token=x"
async def test_execution_not_found(self):
db = _FakeDB(execution=None, config_row=make_config_row(self.WEBHOOK))
ok, message = await svc.notify_execution_result("exec_missing", "任务", db=db)
assert ok is False
assert "不存在" in message
async def test_webhook_not_configured(self):
db = _FakeDB(execution=make_execution(), config_row=make_config_row(""))
ok, message = await svc.notify_execution_result("exec_test_001", "任务", db=db)
assert ok is False
assert message == "Webhook 未配置"
async def test_manual_send_alert_form(self, monkeypatch):
"""有失败用例 → ⚠️ 告警形态,标题含执行名,携带 @手机号"""
captured = {}
async def fake_send(webhook_url, secret, title, text, at_mobiles=None):
captured.update(url=webhook_url, secret=secret, title=title, text=text, at=at_mobiles)
return True, "发送成功"
monkeypatch.setattr(svc, "send_markdown", fake_send)
execution = make_execution(total_cases=10, passed=3, failed=7, pass_rate=30.0)
failed = [make_failed_case("用例A", error="超时")]
db = _FakeDB(
execution=execution,
config_row=make_config_row(self.WEBHOOK),
failed_results=failed,
)
ok, message = await svc.notify_execution_result("exec_test_001", "全量回归-0908", db=db)
assert ok is True and message == "发送成功"
assert captured["title"] == "UI自动化告警 - 全量回归-0908"
assert "⚠️" in captured["text"] and "用例A:超时" in captured["text"]
assert captured["at"] == ["13800000001"]
assert captured["url"].startswith(self.WEBHOOK)
assert captured["secret"] == "SECabc"
async def test_manual_send_all_pass_form(self, monkeypatch):
"""全部通过 → ✅ 报告形态,不携带 @手机号"""
captured = {}
async def fake_send(webhook_url, secret, title, text, at_mobiles=None):
captured.update(title=title, at=at_mobiles)
return True, "发送成功"
monkeypatch.setattr(svc, "send_markdown", fake_send)
db = _FakeDB(execution=make_execution(), config_row=make_config_row(self.WEBHOOK))
ok, _ = await svc.notify_execution_result("exec_test_001", "全量回归-0908", db=db)
assert ok is True
assert captured["title"] == "UI自动化报告 - 全量回归-0908"
assert captured["at"] == []
async def test_manual_send_dingtalk_error_propagates(self, monkeypatch):
"""钉钉返回错误 → (False, 原因),不抛异常"""
async def fake_send(webhook_url, secret, title, text, at_mobiles=None):
return False, "钉钉返回错误: sign not match"
monkeypatch.setattr(svc, "send_markdown", fake_send)
db = _FakeDB(execution=make_execution(), config_row=make_config_row(self.WEBHOOK))
ok, message = await svc.notify_execution_result("exec_test_001", "任务", db=db)
assert ok is False
assert "sign not match" in message
......@@ -21,6 +21,7 @@ export interface ScheduledTask {
run_time: string
weekdays: number[]
auto_report: boolean
dingtalk_notify: boolean
schedule_description: string
last_run_at: string | null
next_run_at: string | null
......@@ -39,6 +40,7 @@ export interface ScheduledTaskPayload {
run_time?: string
weekdays?: number[]
auto_report?: boolean
dingtalk_notify?: boolean
enabled?: boolean
}
......
......@@ -83,4 +83,37 @@ export const systemApi = {
const response = await request.put('/api/system/sut-config', data)
return response as any
},
/**
* 获取报告钉钉通知配置(群机器人)
*/
async getReportNotifyConfig(): Promise<{
webhook_url: string
secret: string
at_mobiles: string[]
configured: boolean
}> {
const response = await request.get('/api/system/report-notify-config')
return response as any
},
/**
* 更新报告钉钉通知配置(secret 传 ****** 表示保持原值)
*/
async updateReportNotifyConfig(data: {
webhook_url?: string
secret?: string
at_mobiles?: string[]
}): Promise<any> {
const response = await request.put('/api/system/report-notify-config', data)
return response as any
},
/**
* 发送钉钉测试消息
*/
async testReportNotify(): Promise<{ success: boolean; message: string }> {
const response = await request.post('/api/system/report-notify-config/test')
return response as any
},
}
<!--
页面名称:ScheduledTasks.vue
页面描述:UI 自动化定时任务页面(任务列表 / 创建编辑 / 立即执行 / 运行历史)
页面描述:UI 自动化定时任务页面(任务列表 / 创建编辑 / 立即执行 / 运行历史 / 钉钉通知开关
路径:/scheduled-tasks
@author czj
@date 2026-08-19
@last-modified 2026-09-07
-->
<template>
<div class="scheduled-tasks-page">
......@@ -65,6 +66,13 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="钉钉通知" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.dingtalk_notify ? 'success' : 'info'" size="small">
{{ row.dingtalk_notify ? '开启' : '关闭' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-switch
......@@ -233,6 +241,11 @@
<span class="form-tip">执行完成后自动生成 HTML 报告到报告中心</span>
</el-form-item>
<el-form-item label="钉钉通知">
<el-switch v-model="form.dingtalk_notify" />
<span class="form-tip">执行完成后推送钉钉报告消息(机器人在「系统设置」配置)</span>
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
<span class="form-tip">停用后不会按周期触发</span>
......@@ -345,6 +358,7 @@ interface TaskForm {
run_time: string
weekdays: number[]
auto_report: boolean
dingtalk_notify: boolean
enabled: boolean
}
......@@ -357,6 +371,7 @@ const createEmptyForm = (): TaskForm => ({
run_time: '',
weekdays: [1, 2, 3, 4, 5],
auto_report: true,
dingtalk_notify: false,
enabled: true,
})
......@@ -495,6 +510,7 @@ const openEdit = async (task: ScheduledTask) => {
run_time: task.run_time || '',
weekdays: [...(task.weekdays || [])],
auto_report: task.auto_report,
dingtalk_notify: task.dingtalk_notify,
enabled: task.enabled,
})
if (modules.value.length === 0) await loadModules()
......@@ -517,6 +533,7 @@ const handleSave = async () => {
run_time: form.schedule_type === 'interval' ? '' : form.run_time,
weekdays: form.schedule_type === 'weekly' ? form.weekdays : [],
auto_report: form.auto_report,
dingtalk_notify: form.dingtalk_notify,
enabled: form.enabled,
}
if (isEdit.value) {
......
<!--
页面名称:Settings.vue
页面描述:系统配置页面(被测系统配置 + 钉钉对接配置)
页面描述:系统配置页面(被测系统配置 + 钉钉对接配置 + 报告钉钉通知配置
路径:/system/settings
@author czj
@date 2026-08-13
@last-modified 2026-08-19
@last-modified 2026-09-07
-->
<template>
<div class="settings-page">
......@@ -123,6 +123,67 @@
</el-form-item>
</el-form>
</el-card>
<!-- 报告钉钉通知配置(群机器人) -->
<el-card shadow="never" style="margin-top: 20px">
<template #header>
<div class="card-header">
<span>报告钉钉通知(群机器人)</span>
<el-tag v-if="reportNotifyForm.configured" type="success" size="small">已配置</el-tag>
</div>
</template>
<el-form
ref="reportNotifyFormRef"
:model="reportNotifyForm"
:rules="reportNotifyRules"
label-width="140px"
label-position="right"
style="max-width: 600px"
>
<el-form-item label="Webhook 地址" prop="webhook_url">
<el-input
v-model="reportNotifyForm.webhook_url"
placeholder="https://oapi.dingtalk.com/robot/send?access_token=xxx"
clearable
/>
</el-form-item>
<el-form-item label="加签密钥" prop="secret">
<el-input
v-model="reportNotifyForm.secret"
type="password"
show-password
placeholder="机器人开启加签时填 SEC 开头密钥;未开启可留空"
clearable
/>
</el-form-item>
<el-form-item label="被@手机号" prop="at_mobiles_text">
<el-input
v-model="reportNotifyForm.at_mobiles_text"
placeholder="仅失败/异常消息时@,多个用逗号分隔,如 13800000001,13800000002"
clearable
/>
</el-form-item>
<el-alert
title="定时任务开启「执行完成后发送钉钉通知」后,按结果推送 ✅ 通过 / ⚠️ 失败(@手机号)/ 🚨 异常 三种消息,并附报告链接。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
<el-form-item>
<el-button type="primary" :loading="reportNotifySaving" @click="handleReportNotifySave">
{{ reportNotifySaving ? '保存中...' : '保存配置' }}
</el-button>
<el-button :loading="reportNotifyTesting" @click="handleReportNotifyTest">发送测试消息</el-button>
<el-button @click="handleReportNotifyReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
......@@ -306,11 +367,121 @@ function handleReset() {
formRef.value?.clearValidate()
}
// ==================== 报告钉钉通知配置(群机器人) ====================
interface ReportNotifyForm {
webhook_url: string
secret: string
at_mobiles_text: string
configured: boolean
}
const reportNotifyFormRef = ref()
const reportNotifySaving = ref(false)
const reportNotifyTesting = ref(false)
const reportNotifyForm = reactive<ReportNotifyForm>({
webhook_url: '',
secret: '',
at_mobiles_text: '',
configured: false,
})
/** 原始快照,用于重置 */
let reportNotifySnapshot = ''
const reportNotifyRules = {
webhook_url: [{ required: true, message: '请输入机器人 Webhook 地址', trigger: 'blur' }],
}
/** 手机号文本 → 数组(去空白、去重、保留顺序) */
function parseMobiles(text: string): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const m of (text || '').split(/[,,;;\s]+/)) {
const trimmed = m.trim()
if (trimmed && !seen.has(trimmed)) {
seen.add(trimmed)
result.push(trimmed)
}
}
return result
}
/** 加载报告通知配置 */
async function loadReportNotifyConfig() {
try {
const res = await systemApi.getReportNotifyConfig()
reportNotifyForm.webhook_url = res.webhook_url || ''
reportNotifyForm.secret = res.secret || ''
reportNotifyForm.at_mobiles_text = (res.at_mobiles || []).join(',')
reportNotifyForm.configured = !!res.configured
reportNotifySnapshot = JSON.stringify({
webhook_url: reportNotifyForm.webhook_url,
secret: reportNotifyForm.secret,
at_mobiles_text: reportNotifyForm.at_mobiles_text,
})
} catch (err: any) {
ElMessage.error('加载报告通知配置失败:' + (err?.message || '未知错误'))
}
}
/** 保存报告通知配置 */
async function handleReportNotifySave() {
const valid = await reportNotifyFormRef.value?.validate().catch(() => false)
if (!valid) return
reportNotifySaving.value = true
try {
await systemApi.updateReportNotifyConfig({
webhook_url: reportNotifyForm.webhook_url.trim(),
secret: reportNotifyForm.secret,
at_mobiles: parseMobiles(reportNotifyForm.at_mobiles_text),
})
ElMessage.success('报告通知配置已保存')
reportNotifyForm.configured = true
reportNotifySnapshot = JSON.stringify({
webhook_url: reportNotifyForm.webhook_url,
secret: reportNotifyForm.secret,
at_mobiles_text: reportNotifyForm.at_mobiles_text,
})
} catch (err: any) {
ElMessage.error('保存报告通知配置失败:' + (err?.message || '未知错误'))
} finally {
reportNotifySaving.value = false
}
}
/** 发送测试消息 */
async function handleReportNotifyTest() {
reportNotifyTesting.value = true
try {
const res = await systemApi.testReportNotify()
if (res.success) {
ElMessage.success('测试消息已发送成功')
} else {
ElMessage.error('测试消息发送失败:' + (res.message || '未知原因'))
}
} catch (err: any) {
ElMessage.error('发送测试消息异常:' + (err?.message || '未知错误'))
} finally {
reportNotifyTesting.value = false
}
}
/** 重置表单到加载时的值 */
function handleReportNotifyReset() {
const snapshot = JSON.parse(reportNotifySnapshot)
Object.assign(reportNotifyForm, snapshot)
reportNotifyFormRef.value?.clearValidate()
}
// ==================== 生命周期 ====================
onMounted(() => {
loadConfig()
loadSutConfig()
loadReportNotifyConfig()
})
</script>
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论