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

feat(security): 新增安全测试定时任务模块

新增安全测试定时任务 API、调度分流和前端管理页面。\n复用 scheduled_tasks 表并支持 interval/daily/weekly 周期、测试配置关联和自动报告。\n补充需求文档、计划执行文档及部署验证记录。\n\nCo-Authored-By: Claude <noreply@anthropic.com>
上级 1a3d52ad
# 执行计划 — 安全测试定时任务模块
> **文档类型**:计划执行文档
> **创建日期**:2026-08-19
> **作者**:czj
> **状态**:待实现
> **关联文档**:`_PRD_安全测试定时任务模块_需求文档.md`
---
## 一、执行概述
在安全测试模块中新增**定时任务**功能,复用 UI 定时任务的 `scheduled_tasks` 表 + 后台 asyncio 调度循环,通过 `case_type` 字段区分 UI/安全测试,实现安全测试定时执行。
**设计原则**
- 复用 `ScheduledTask` 模型(新增 `case_type` / `config_id` 字段),不新增表
- 复用 `scheduler_service.py` 调度引擎(扩展 `run_task_once``case_type` 分流)
- 复用 `SecurityService` 创建执行 + 运行 + `SecurityReportService` 生成报告
- 复用前端 `ScheduledTasks.vue` 的 UI 模式,独立安全测试页面
- 菜单放在安全测试父菜单下,不干扰 UI 定时任务
---
## 二、任务分解与实施步骤
### Step 1:ScheduledTask 模型新增字段
**文件**`backend/app/models/scheduled_task.py`
```python
# 新增字段
case_type: str = String(20) default "ui" # ui / security
config_id: str = String(64) nullable=True # 安全测试配置ID(security 时必填)
```
- `to_dict()` 方法对应增加这两个字段
- `schedule_description()` 无需改动
**文件**`backend/app/database.py`
- `_ensure_columns``columns_to_add` 列表新增:
- `("scheduled_tasks", "case_type", "VARCHAR(20) DEFAULT 'ui'")`
- `("scheduled_tasks", "config_id", "VARCHAR(64) DEFAULT NULL")`
### Step 2:Schema 扩字段
**文件**`backend/app/schemas/scheduled_task.py`
- `ScheduledTaskCreate` 新增:`case_type: str = "ui"``config_id: Optional[str] = None`
- `ScheduledTaskUpdate` 新增:`case_type: Optional[str] = None``config_id: Optional[str] = None`
### Step 3:调度引擎扩展安全测试执行路径
**文件**`backend/app/services/scheduler_service.py`
修改 `collect_module_cases`
```python
async def collect_module_cases(db, module_ids: List[str], case_type: str = "ui") -> List[TestCase]:
"""收集选中模块下所有启用的用例(按 case_type 筛选)"""
if not module_ids:
return []
query = (
select(TestCase)
.where(
TestCase.module_id.in_(module_ids),
TestCase.status == "active",
TestCase.case_type == case_type,
)
.order_by(TestCase.module_id, TestCase.order, TestCase.created_at)
)
result = await db.execute(query)
return list(result.scalars().all())
```
修改 `run_task_once` 主流程,按 `case_type` 分流:
```python
if task.case_type == "security":
return await _run_security_task_once(task, db)
else:
# 原有 UI 逻辑
...
```
新增 `_run_security_task_once` 异步函数:
1. 收集模块安全用例(`collect_module_cases(db, task.module_ids, "security")`
2. 用例数为 0 → 跳过,返回 None
3. 延迟导入 `SecurityService``SecurityReportService`
4. `security_service = SecurityService(db)`
5. `execution = await security_service.create_execution(config_id=task.config_id, module_ids=task.module_ids, name=f"{task.name}-{ts}")`
6. `await security_service.run_execution(execution.id)`
7.`auto_report``SecurityReportService().generate_report(execution_id, db)`
8. 更新 `last_run_at` / `run_count` / `next_run_at`
### Step 4:安全测试定时任务管理 API
**文件**`backend/app/routers/security_scheduled_tasks.py`(新建)
参考 `scheduled_tasks.py` 实现,关键差异:
| 接口 | 差异 |
|------|------|
| GET `""` | 筛选 `case_type="security"`,join 安全测试配置表展示 `config_name` |
| POST `""` | 自动设 `case_type="security"`,校验 `config_id` 必填 |
| PUT `"/{id}"` | 不允许改 `case_type` |
| DELETE `"/{id}"` | 同 UI 定时任务 |
| POST `"/{id}/run"` | 调用 `run_task_once`(内部已按 case_type 分流) |
| POST `"/{id}/toggle"` | 同 UI 定时任务 |
| GET `"/{id}/runs"` | 查 executions where trigger_type='scheduled' AND trigger_by=任务名 |
**响应增强**:新增 `config_name` 字段(从安全测试配置表查询)
**文件**`backend/app/main.py`
- 注册 `security_scheduled_tasks.router``prefix="/api/security/scheduled-tasks"`
### Step 5:前端 API 封装
**文件**`frontend/src/api/securityScheduledTasks.ts`(新建)
```typescript
export interface SecurityScheduledTask {
id: string
name: string
enabled: boolean
module_ids: string[]
module_names: string[]
config_id: string
config_name: string
schedule_type: string
interval_value: number
interval_unit: string
run_time: string
weekdays: number[]
auto_report: boolean
schedule_description: string
last_run_at: string | null
next_run_at: string | null
run_count: number
created_at: string | null
updated_at: string | null
}
export interface SecurityScheduledTaskPayload {
name: string
module_ids: string[]
config_id: string
schedule_type: 'interval' | 'daily' | 'weekly'
interval_value?: number
interval_unit?: 'minutes' | 'hours' | 'days'
run_time?: string
weekdays?: number[]
auto_report?: boolean
enabled?: boolean
}
export const securityScheduledTaskApi = {
async list(): Promise<{ total: number; items: SecurityScheduledTask[] }> { ... },
async create(data: SecurityScheduledTaskPayload): Promise<SecurityScheduledTask> { ... },
async update(id: string, data: Partial<SecurityScheduledTaskPayload>): Promise<SecurityScheduledTask> { ... },
async remove(id: string): Promise<void> { ... },
async run(id: string): Promise<{ message: string; task_id: string }> { ... },
async toggle(id: string, enabled: boolean): Promise<SecurityScheduledTask> { ... },
async getRuns(id: string, params?: { skip?: number; limit?: number }): Promise<{ total: number; items: any[] }> { ... },
}
```
### Step 6:安全测试定时任务前端页面
**文件**`frontend/src/views/security/ScheduledTasks.vue`(新建)
参考 `ScheduledTasks.vue` 实现,关键差异:
| 功能 | UI 定时任务 | 安全测试定时任务 |
|------|------------|----------------|
| 模块列表 | `/api/modules?case_type=ui` | `/api/modules?case_type=security` |
| 配置选择 | 无 | 新增「测试配置」下拉选择框(`/api/security/config`) |
| 创建请求 | 不传 `case_type` / `config_id` | 传 `case_type="security"` + `config_id` |
| 表格列 | 不含「测试配置」列 | 新增「测试配置」列 |
| 运行记录跳转 | 跳转到 `/reports/ui` | 跳转到 `/reports/security` |
| 提示信息 | UI 执行相关内容 | 安全测试执行相关内容 |
### Step 7:路由 / 菜单注册
**文件**`frontend/src/router/index.ts`
- 新增路由:
```typescript
{
path: '/security/scheduled-tasks',
name: 'SecurityScheduledTasks',
component: () => import('@/views/security/ScheduledTasks.vue'),
meta: { title: '定时任务' }
}
```
**文件**`frontend/src/App.vue`
- 安全测试父菜单新增子菜单「定时任务」:
```
<el-menu-item index="/security/scheduled-tasks">定时任务</el-menu-item>
```
- `SECURITY_SUB_LABELS` 映射新增 `'scheduled-tasks': '定时任务'`
- `pageTitle` 计算属性中 `/security` 路由支持 `scheduled-tasks` 类型
### Step 8:验证
1. 后端:`cd backend && python -m pytest tests/ -x -q`(确认无回归)
2. 手动功能验证(安全测试定时任务):
- 建一条「每 1 分钟」任务选 1-2 个安全模块 + 选默认配置 → 等待调度触发 → 安全测试执行记录产生 → 报告出现在安全测试报告中心
- 立即执行按钮 → 立即产生安全执行 + 报告
- 运行历史展示正确
- 停用 → 不再触发;重启服务 → 任务自动恢复调度
3. UI 定时任务回归验证:
- 确认 UI 定时任务列表页不受影响(只显示 `case_type="ui"` 的任务)
- 确认 UI 定时任务调度正常
4. 前端:`cd frontend && npm run build` 通过
---
## 三、涉及文件清单
### 新建
| 文件 | 说明 |
|------|------|
| `backend/app/routers/security_scheduled_tasks.py` | 安全测试定时任务 API 路由 |
| `frontend/src/api/securityScheduledTasks.ts` | 前端 API 封装 |
| `frontend/src/views/security/ScheduledTasks.vue` | 安全测试定时任务页面 |
### 修改
| 文件 | 改动 |
|------|------|
| `backend/app/models/scheduled_task.py` | 新增 `case_type` / `config_id` 字段+ to_dict 扩展 |
| `backend/app/schemas/scheduled_task.py` | Create/Update 新增对应字段 |
| `backend/app/services/scheduler_service.py` | `collect_module_cases``case_type` 参数 + 新增 `_run_security_task_once` 分流 |
| `backend/app/database.py` | `_ensure_columns` 补齐新字段 |
| `backend/app/main.py` | 注册 security_scheduled_tasks 路由 |
| `frontend/src/router/index.ts` | 新增 `/security/scheduled-tasks` 路由 |
| `frontend/src/App.vue` | 安全测试父菜单新增「定时任务」子菜单 + 标签映射 |
---
## 四、验收标准(对照 PRD 第七节)
| 验收项 | 预期 | 检查方式 |
|--------|------|---------|
| 安全测试定时任务自动触发 | 到点自动产生 scheduled 执行记录 | 观察日志 + 执行列表 |
| 报告自动生成 | 安全测试报告中心出现新报告 | 查看报告 |
| 立即执行 | 手动触发产生执行 + 报告 | 页面操作 |
| 执行冲突 | 到点时有执行在跑 → 跳过,无失败记录 | 日志 |
| 启停/删除 | 行为符合预期 | 页面操作 |
| UI 定时任务不受影响 | UI 定时任务列表不显示安全任务、调度正常 | 页面操作 + 日志 |
| 前端构建 | `npm run build` 通过 | 构建命令 |
---
## 五、关键代码结构参考
### 后端路由(参考 `scheduled_tasks.py`)
```
security_scheduled_tasks.py
├── list_tasks() → GET "" 筛选 case_type="security"
├── create_task() → POST "" 自动设 case_type="security"
├── update_task() → PUT "/{id}"
├── delete_task() → DELETE "/{id}"
├── toggle_task() → POST "/{id}/toggle"
├── run_task_now() → POST "/{id}/run"
└── list_task_runs() → GET "/{id}/runs"
```
### 调度引擎扩展(参考 `scheduler_service.py` 现有结构)
```
scheduler_service.py
├── compute_next_run() ← 无需改动
├── collect_module_cases() ← 加 case_type 参数 (默认 "ui")
├── run_task_once() ← 按 task.case_type 分流
│ ├── case_type="ui" ← 现有逻辑
│ └── case_type="security" ← 新增 _run_security_task_once()
├── _run_security_task_once() ← 新增
├── check_and_trigger() ← 无需改动
├── scheduler_loop() ← 无需改动
└── start_scheduler() ← 无需改动
```
### 安全测试执行流程(`_run_security_task_once`)
```python
async def _run_security_task_once(task_id: str) -> Optional[str]:
"""安全测试定时任务触发流程"""
from app.services.security_service import SecurityService
from app.services.security_report_service import SecurityReportService
if task_id in _running_tasks:
return None
_running_tasks.add(task_id)
try:
async with async_session_maker() as db:
task = await db.get(ScheduledTask, task_id)
if not task:
return None
# 1. 收集安全测试用例
cases = await collect_module_cases(db, task.module_ids, "security")
if not cases:
task.next_run_at = compute_next_run(task)
await db.flush()
return None
# 2. 创建执行记录(通过 SecurityService)
security_service = SecurityService(db)
case_ids = [c.id for c in cases]
run_name = f"{task.name}-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
execution = await security_service.create_execution(
config_id=task.config_id,
module_ids=task.module_ids,
case_ids=case_ids,
name=run_name,
)
# 修改 trigger_type / trigger_by
execution.trigger_type = "scheduled"
execution.trigger_by = task.name
await db.commit()
# 3. 执行安全测试
await security_service.run_execution(execution.id)
# 4. 自动生成报告
if task.auto_report:
report_service = SecurityReportService()
await report_service.generate_report(execution.id, db)
# 5. 更新调度状态
task.last_run_at = datetime.now()
task.run_count = (task.run_count or 0) + 1
task.next_run_at = compute_next_run(task)
await db.commit()
return execution.id
finally:
_running_tasks.discard(task_id)
```
---
## 六、风险与依赖
| 风险 | 缓解 |
|------|------|
| `scheduled_tasks` 表已有数据(UI 定时任务),新增字段需兼容旧数据 | `case_type` 默认 `"ui"`,旧数据自动兼容;`config_id` 默认 `NULL` |
| 调度引擎分流逻辑修改影响现有 UI 定时任务 | `run_task_once` 分流前检查 `task.case_type`,UI 任务走原逻辑不变 |
| `SecurityService.create_execution` 内部逻辑与定时任务不完全匹配 | 创建后手动修改 `trigger_type`/`trigger_by` 字段 |
| 前后端多窗口并行开发 | 本功能涉及多处文件,避免与他窗口同时改 `main.py` / `App.vue` / `router/index.ts` |
---
*本文档由 Claude Code 生成,遵循项目计划执行文档规范。*
\ No newline at end of file
# PRD:安全测试定时任务模块
> **版本**:v1.0
> **创建日期**:2026-08-19
> **状态**:初稿
> **维护者**:czj · **所属模块**:安全测试
> **关联文档**:`_PRD_安全测试定时任务模块_计划执行.md`
---
## 一、需求背景与目标
### 1.1 背景
当前安全测试模块支持以下流程:
- ✅ 安全测试执行(OWASP API Security Top 10)
- ✅ 安全测试报告生成(Markdown 7 章节)
- ✅ 报告上传 ERP(DOCX → HTML → 测试单/项目资料/协作文档)
- ✅ ERP 任务创建(指派跟踪 → 修复 → 回归验证闭环)
但安全测试只能**手动**发起执行(执行中心选择模块 → 选择配置 → 执行)。当需要周期性安全扫描(如每周对被测系统做一次全量安全测试、每天跑一次高危漏洞回归验证)时,必须人工到点操作,无法自动化、无人值守。
UI 自动化测试模块已实现定时任务功能(`scheduled_tasks` 表 + 后台 asyncio 调度循环 + 前端列表页),安全测试需参考该模式补充定时任务模块。
### 1.2 目标
在安全测试模块中新增**定时任务**功能,提供以下能力:
1. **设置定时任务**:选择安全测试模块、选择安全测试配置、设置执行周期(按间隔/每天定时/每周定时)
2. **自动触发执行**:后台调度引擎到期自动创建安全测试执行并运行
3. **自动生成报告**:执行完成后自动生成 Markdown 安全测试报告
4. **定时任务管理**:任务列表、创建/编辑、启停、立即执行、运行历史、删除
### 1.3 范围
| 范围 | 包含 | 不包含 |
|------|------|--------|
| 定时任务管理 | 创建/编辑/删除/启停 | 继承/复制定时任务 |
| 周期设置 | 按间隔/每天/每周 | cron 表达式高级周期 |
| 自动触发 | 到期自动执行安全测试 | 任务失败重试/告警通知 |
| 执行冲突 | 到点时有执行在跑 → 跳过本次 | 任务排队/顺延执行 |
| 报告生成 | 执行完成后自动生成报告 | 报告自动上传 ERP/创建 ERP 任务 |
| 运行历史 | 查看该任务触发的执行记录 | 运行历史导出 |
---
## 二、现状分析与复用基础
| 现状 | 结论 |
|------|------|
| UI 定时任务已有 `ScheduledTask` 模型 + `scheduled_tasks` 表 | 复用模型,新增 `case_type``config_id` 字段区分 UI/安全 |
| 调调度引擎 `scheduler_service.py` 已实现轮询 + 触发 | 扩展 `run_task_once``case_type` 分流 |
| `SecurityService.create_execution()` 已支持 `config_id` + `module_ids`/`case_ids` | 安全定时任务复用现成接口创建执行记录 |
| 安全测试执行 `SecurityService.run_execution()` 已实现 | 定时任务直接复用执行引擎 |
| 安全测试报告 `SecurityReportService` 已实现 Markdown 生成 | 定时任务完成后自动调用生成报告 |
| 前端 `ScheduledTasks.vue` 已实现 UI 定时任务列表页 | 可参考该页面实现安全版定时任务页 |
---
## 三、需求描述
### 3.1 菜单入口
安全测试父菜单下新增子菜单**「定时任务」**
| 菜单项 | 路由 |
|--------|------|
| 定时任务 | `/security/scheduled-tasks` |
### 3.2 定时任务列表页
展示所有**安全测试**定时任务,表格列:
| 列 | 说明 |
|----|------|
| 任务名称 | 用户自定义 |
| 执行模块 | 该任务选择的模块名称列表(标签组展示) |
| 测试配置 | 使用的安全测试配置名称 |
| 周期 | 人类可读周期描述,如「每 30 分钟」「每天 22:00」「每周一 22:00」 |
| 状态 | 启用/停用 开关 |
| 上次执行 | 最近一次运行开始时间 |
| 下次执行 | 计算出的下次触发时间 |
| 执行次数 | 累计执行次数 |
| 操作 | 立即执行 / 编辑 / 删除 |
### 3.3 创建/编辑定时任务(对话框)
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 任务名称 | 文本 | 是 | 唯一标识,执行记录的 `trigger_by` 取该值 |
| 执行模块 | 多选下拉 | 是 | 数据源 `/api/modules?case_type=security`(安全测试模块) |
| 测试配置 | 下拉选择 | 是 | 数据源 `/api/security/config` 安全测试配置列表 |
| 周期模式 | 单选 | 是 | `interval`(按间隔)/ `daily`(每天定时)/ `weekly`(每周定时) |
| 间隔值 | 数字 | 模式=interval | 如 30 |
| 间隔单位 | 单选 | 模式=interval | `minutes` / `hours` / `days` |
| 执行时间 | 时间选择器 HH:MM | 模式=daily | 每天该时刻执行 |
| 星期 + 执行时间 | 多选星期 + 时间 | 模式=weekly | 每周选中日的该时刻执行 |
| 自动生成报告 | 开关 | 否 | 默认开启;执行完成后自动生成 Markdown 报告 |
### 3.4 立即执行
点击「立即执行」→ 立即收集该任务选中模块的安全测试用例 + 使用任务的测试配置 → 创建安全测试执行记录并运行 → 完成后生成报告。与手动执行中心效果一致,执行记录标记 `trigger_type=scheduled``trigger_by=任务名`
### 3.5 后台调度引擎
**复用现有** `scheduler_service.py` 的调度机制,在此基础上扩展:
| 原有行为(UI 定时任务) | 扩展行为(安全测试定时任务) |
|------------------------|---------------------------|
| `collect_module_cases` 筛选 `case_type="ui"` 的用例 | 新增 `case_type="security"` 的分支 |
| 通过 `ExecutionService.create_execution()` 创建执行记录 | 通过 `SecurityService.create_execution()` 创建执行记录,传入 `config_id` |
| `run_execution()` 走 UI 执行器(Playwright) | 走 `SecurityService.run_execution()` 执行 |
| 生成 HTML 报告 | 生成 Markdown 报告 |
| 使用 UI 全局执行锁 | 不共享 UI 执行锁(安全测试无浏览器冲突),但需防同一任务重复触发 |
### 3.6 触发流程
```
到期任务 → 收集模块用例(case_type="security")
→ 查询 TestCase where module_id ∈ 任务.module_ids AND status='active' AND case_type='security'
→ 若用例数为 0 → 记日志,重算下次执行,跳过本次
→ 通过 SecurityService.create_execution(config_id, module_ids/收集到的case_ids) 创建执行记录
→ 通过 SecurityService.run_execution(exec_id) 执行
→ 执行完成后:若 auto_report=True → SecurityReportService 生成 Markdown 报告
→ 更新任务 last_run_at / run_count / next_run_at
```
**执行冲突策略**:安全测试执行无浏览器冲突(不共享 UI 执行锁),但为防止同一任务短时间内重复触发,仍使用 `_running_tasks` 集合防重。同时,若已有安全测试执行在运行,到点时**跳过本次**(避免过度并发,沿用 UI 定时任务的保守策略)。
### 3.7 运行历史
每个定时任务运行都会产生一条执行记录(`trigger_type=scheduled``trigger_by=任务名`,可在安全测试报告中心查看)。定时任务页提供「运行记录」查看入口(抽屉),列出该任务的历史执行:执行时间、状态、通过/失败数、耗时。
### 3.8 删除任务
删除定时任务仅删除任务定义,**不影响**已产生的执行记录与报告。
---
## 四、数据模型设计
### 4.1 复用 `scheduled_tasks` 表,新增字段
在现有 `ScheduledTask` 模型上新增两个字段:
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `case_type` | String(20) | `"ui"` | 用例类型:`ui` / `security` |
| `config_id` | String(64) | nullable | 安全测试配置 ID(`case_type="security"` 时必填) |
**现有字段**(复用,无需改动):
| 字段 | 说明 |
|------|------|
| `module_ids` | 选中模块 ID 列表(安全测试也复用此字段) |
| `schedule_type` / `interval_value` / `interval_unit` / `run_time` / `weekdays` | 周期设置 |
| `auto_report` / `enabled` / `last_run_at` / `next_run_at` / `run_count` | 运行状态 |
| `name` | 任务名称 |
### 4.2 不新增表
不新增独立数据表,所有定时任务统一存储在 `scheduled_tasks` 表中,通过 `case_type` 字段区分 UI 自动化与安全测试。
---
## 五、接口设计
### 5.1 安全测试定时任务管理 API(新增)
**路由文件**`backend/app/routers/security_scheduled_tasks.py`
**前缀**`/api/security/scheduled-tasks`
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/security/scheduled-tasks` | 获取安全测试定时任务列表(筛选 `case_type=security`) |
| POST | `/api/security/scheduled-tasks` | 创建安全测试定时任务(自动设 `case_type=security`) |
| PUT | `/api/security/scheduled-tasks/{id}` | 更新安全测试定时任务 |
| DELETE | `/api/security/scheduled-tasks/{id}` | 删除安全测试定时任务 |
| POST | `/api/security/scheduled-tasks/{id}/run` | 立即执行安全测试定时任务 |
| POST | `/api/security/scheduled-tasks/{id}/toggle` | 启用/停用 |
| GET | `/api/security/scheduled-tasks/{id}/runs` | 运行历史 |
### 5.2 调度引擎扩展
**文件**`backend/app/services/scheduler_service.py`
- `run_task_once()`:按 `case_type` 分流
- `case_type="ui"`:现有逻辑(不变)
- `case_type="security"`:走安全测试执行路径,使用 `SecurityService` + `SecurityReportService`
- `collect_module_cases()`:筛选条件改为 `case_type` 参数
### 5.3 后端注册
`backend/app/main.py`:注册 `security_scheduled_tasks` 路由,前缀 `/api/security/scheduled-tasks`
---
## 六、变更范围
| 层面 | 变更内容 | 涉及文件 |
|------|---------|---------|
| **后端模型** | ScheduledTask 新增 `case_type` / `config_id` 字段 | `backend/app/models/scheduled_task.py`(修改) |
| **后端 Schema** | 新增 `case_type` / `config_id` 字段 | `backend/app/schemas/scheduled_task.py`(修改) |
| **后端路由** | 安全测试定时任务 CRUD + 立即执行 + 运行历史 | `backend/app/routers/security_scheduled_tasks.py`(新建) |
| **后端路由** | 注册新路由 | `backend/app/main.py`(修改) |
| **后端服务** | 调度引擎扩展安全测试执行路径 | `backend/app/services/scheduler_service.py`(修改) |
| **后端数据库** | `_ensure_columns` 补齐 `case_type` / `config_id` 字段 | `backend/app/database.py`(修改) |
| **前端 API** | 安全测试定时任务 API 封装 | `frontend/src/api/securityScheduledTasks.ts`(新建) |
| **前端页面** | 安全测试定时任务列表页 | `frontend/src/views/security/ScheduledTasks.vue`(新建) |
| **前端路由** | 新增 `/security/scheduled-tasks` 路由 | `frontend/src/router/index.ts`(修改) |
| **前端菜单** | 安全测试父菜单新增「定时任务」子菜单 | `frontend/src/App.vue`(修改) |
---
## 七、验收标准
1. ✅ 侧边栏「安全测试」下显示「定时任务」子菜单,可进入列表页
2. ✅ 可创建安全测试定时任务:选择多个安全测试模块、选择测试配置、设置周期
3. ✅ 列表正确显示任务名、执行模块、测试配置、周期、状态、上次/下次执行时间
4. ✅ 启用任务后到点自动触发安全测试执行,执行记录 `trigger_type=scheduled``trigger_by=任务名`
5. ✅ 执行完成后自动生成 Markdown 报告,出现在安全测试报告中心
6. ✅ 立即执行按钮可手动触发一次,产生执行记录 + 报告
7. ✅ 运行历史列出该任务的历史执行(时间/状态/通过/失败/耗时)
8. ✅ 停用任务后不再触发;删除任务不影响已产生的执行记录与报告
9. ✅ 服务重启后,启用的定时任务自动恢复调度(读取 DB 中的 `next_run_at`
10. ✅ UI 自动化定时任务不受影响(`case_type` 隔离,各自独立管理)
---
## 八、风险与注意事项
| 风险 | 说明 | 缓解 |
|------|------|------|
| `scheduled_tasks` 表新增字段在 MySQL 生产库自动建列 | `init_db``_ensure_columns` 会补齐字段 | 确保 `database.py``columns_to_add` 列表包含新增字段 |
| 安全测试执行耗时较长(全量 67 用例约 3-5s) | 异步执行,调度循环不阻塞 | 触发用 `asyncio.create_task` 异步,不阻塞 30s 轮询 |
| 安全测试与 UI 测试同时执行 | 两者执行器独立,不共享执行锁 | 无冲突风险,但需注意服务器资源消耗 |
| 同一任务短周期内重复触发 | 调度 30s 轮询粒度 + `_running_tasks` 防重集合 | 双保险,避免重复执行 |
| 无用户权限系统 | 定时任务创建/启停无鉴权 | 与现有平台一致,仅内网使用 |
---
## 九、后续规划(本次不实现)
- 定时任务失败通知(钉钉/邮件告警)
- cron 表达式高级周期
- 任务级超时控制
- 多次连续失败自动停用
---
*本文档由 Claude Code 生成,遵循项目 PRD 文档规范。*
\ No newline at end of file
# HANDOFF — 安全测试模块会话交接文档
> **生成时间**: 2026-07-22
> **最后更新**: 2026-08-17
> **最后更新**: 2026-08-19
> **当前分支**: `platform-auto-test`
> **开发窗口**: 安全测试模块
> **最近提交**: `5b5b35de` docs(security): 同步安全测试 HANDOFF 提交状态
> **状态**: ✅ 安全测试 P1 全部完成 + P2 执行验证完成 + 菜单升级 + ERP 配置子菜单 + ERP 上传流程对接完成 + ERP 任务创建对接完成,全部**已提交并部署 5.60**
> **最近提交**: `1a3d52ad` fix(executor): 修复执行中无法取消(MySQL 1205 行锁)并加固 SUT URL 校验
> **状态**: ✅ 安全测试 P1 全部完成 + P2 执行验证完成 + 菜单升级 + ERP 配置子菜单 + ERP 上传流程对接完成 + ERP 任务创建对接完成 + **安全测试定时任务模块完成并部署 5.60**,全部已实现
---
......@@ -26,6 +26,40 @@
## 一、本次任务记录
### 本次任务(2026-08-19):安全测试新增定时任务模块 + 部署 5.60 验证通过
**背景**:安全测试模块补充定时任务能力(设置执行周期 interval/daily/weekly + 执行时间),参考 UI 自动化定时任务模块实现,复用同一张 `scheduled_tasks` 表和调度引擎,按 `case_type` 分流调度。
**流程**:需求文档 → 计划执行文档 → 后端 → 前端 → 构建验证 → 部署 5.60 服务器 + 验证 全链路完成。
**核心成果**
1. ✅ 按规范输出两份文档(`Docs/PRD/功能测试/需求文档/_PRD_功能测试模块读取系统配置被测试系统URL.md` 同目录规范):
- `Docs/PRD/需求文档/安全测试/_PRD_安全测试定时任务_需求文档.md`
- `Docs/PRD/需求文档/安全测试/_PRD_安全测试定时任务_计划执行.md`
2.**后端实现**
- `backend/app/models/scheduled_task.py``scheduled_tasks` 表新增 `case_type VARCHAR(20) DEFAULT 'ui'` + `config_id VARCHAR(64) DEFAULT NULL` 字段(`_ensure_columns()` 迁移,重启自动加列)
- `backend/app/services/scheduler_service.py``run_task_once()``task.case_type` 分流,新增 `_run_security_task_once()`:收集 `collect_module_cases(db, module_ids, "security")``SecurityService.create_execution(config_id, module_ids, case_ids)` → 覆盖 `trigger_type="scheduled"` / `trigger_by=task.name``run_execution()` → 可选 `SecurityReportService.generate_report()` → 更新 last_run_at/run_count/next_run_at
- `backend/app/routers/security_scheduled_tasks.py`(新增)— 7 个端点:GET 列表(仅 `case_type=="security"`,含 config_name 映射)/ POST 创建(config_id 必填 422 / 配置不存在 404)/ PUT 更新(拒绝 case_type 变更 422)/ DELETE / POST toggle / POST run(asyncio.create_task)/ GET runs(按 trigger_type+trigger_by 过滤)
- `backend/app/main.py` — 注册 `security_scheduled_tasks.router`,prefix `/api/security/scheduled-tasks`
3.**前端实现**
- `frontend/src/api/securityScheduledTasks.ts`(新增)— 完整 API 封装,baseURL 空字符串 + 完整 `/api/security/scheduled-tasks` 路径 + `as any` 返回类型模式
- `frontend/src/views/security/ScheduledTasks.vue`(新增)— 任务表格(名称/模块/测试配置 config_name 标签/周期/自动报告/状态开关/上次/下次执行/运行次数/操作)+ 创建/编辑对话框(测试配置下拉 + 周期模式单选 interval/daily/weekly + 自动报告提示)+ 运行记录 drawer 跳转报告中心
- `frontend/src/router/index.ts` — 新增 `/security/scheduled-tasks` 独立路由
- `frontend/src/App.vue` — 安全测试子菜单新增「定时任务」+ `SECURITY_SUB_LABELS` 增加 `scheduled-tasks: '定时任务'`
4.**构建验证**`npm run build` 通过(修复了 `getSecurityConfigs` 带类型返回值导致的 TS2339:改用 `(res as any).data || res` 解包模式)
5.**部署 5.60 服务器 + 全部验证通过**
- SFTP 上传 6 个后端文件(router/service/model/schema/database/main.py)+ 85/86 个前端 dist 文件,`docker restart plat-auto-test-app` 容器恢复 healthy
- `GET /api/security/scheduled-tasks``{"total":0,"items":[]}`(路由加载正常)
- DB 列验证:`scheduled_tasks.case_type`(varchar(20) 默认 ui)+ `config_id`(varchar(64) 默认 NULL)均已迁移成功
- 前端 chunk `ScheduledTasks-*.js/.css` 已部署
- **端到端冒烟测试**:POST 创建安全定时任务(1 分钟间隔、禁用、关联 `sec_cfg_default`)→ 返回完整对象含 next_run_at 计算 → 列表可见 → DELETE 清理成功
**注意事项**
- 安全测试无父路由结构,定时任务复用 UI 定时任务的独立路由风格,采用 `/security/scheduled-tasks` 独立路由
- `scheduler_service.py` 已按 case_type 分流:`ui` 走原逻辑,`security` 走新 `_run_security_task_once`,互不影响
- 安全定时任务执行前检查 `_has_running_security_execution()`,有 pending/running 的安全执行时跳过本轮,避免 MySQL 行锁/并发冲突(与 UI 定时任务行为一致)
- 因对话在部署验证中途中断,本次已在后续会话补齐全部部署验证(创建/删除冒烟测试通过后方记录)
### 本次任务(2026-08-13):安全测试新增 ERP 配置子菜单
**核心成果**
......@@ -321,6 +355,7 @@ GET /api/security/executions/{id}/report/download → 下载 .md 文件
| **P2.5** | ✅ ~~前端报告类型标签修复~~ | 报告类型显示"UI自动化"应为"安全测试" |
| **P3** | ✅ ~~安全报告上传 ERP 流程对接~~ | ERP 配置入口已完成,ERP 上传流程已完成(2026-08-17) |
| **P3.5** | ✅ ~~安全测试 ERP 任务创建对接~~ | 任务创建预览 + 创建接口 + 前端对话框已完成(2026-08-17);**已部署 5.60 并实测 task-preview 通过**(真实执行 ID 返回正确的任务名/紧急程度/ERP 基础数据),create-task 待用户在界面实测 |
| **P4** | ✅ ~~安全测试定时任务模块~~ | 周期调度(interval/daily/weekly)+ 自动报告可选 + 独立页面已完成(2026-08-19);**已部署 5.60 并通过创建/删除冒烟测试**,定时调度触发待真实执行验证 |
---
......@@ -342,6 +377,9 @@ GET /api/security/executions/{id}/report/download → 下载 .md 文件
| S12 | `TS2339: Property 'content' does not exist on type 'AxiosResponse<...>'` | `request.get<T>()` 泛型与实际运行时类型(解包后)不匹配 | API 函数统一用 `as any` 返回类型,绕过类型推断(已修复) |
| S13 | `create_security_cases.py` 用同步引擎连 MySQL 报 aiomysql 兼容错误 | 脚本复制了异步引擎 URL,同步连接用 `mysql+aiomysql://` 前缀冲突 | URL 自动转换:`mysql+aiomysql://``mysql+pymysql://`(已修复) |
| S14 | 端口 8001 旧 uvicorn 进程无法 taskkill 杀掉 | uvicorn `--reload` 残留父进程,MSYS bash 的 taskkill 权限/编码问题 | 用 `powershell Stop-Process -Id <pid> -Force`,或换端口 8002 起新实例验证 |
| S15 | 前端构建时 `getSecurityConfigs``items` 类型错误 | Axios 类型声明是 `AxiosResponse`,运行时拦截器已解包为 body | 调用处使用 `const data = (res as any).data || res`,再访问 `data.items` |
| S16 | 服务器远程命令通过 paramiko 读取超时 | 长时间 `curl`/MySQL 命令在 SSH channel 上未及时结束,PipeTimeout | 使用短命令 + `head -c`;健康检查优先使用容器内 `wget -qO- --timeout=10 http://127.0.0.1/health`;必要时对读取异常做容错 |
| S17 | 安全定时任务接口未返回/表字段缺失 | 新字段未迁移或容器未加载新代码 | 上传绑定挂载目录后执行 `docker restart plat-auto-test-app`;启动时 `init_db()` 自动运行 `_ensure_columns()`,并验证 `case_type/config_id` |
---
......@@ -406,6 +444,20 @@ PYTHONIOENCODING=utf-8 python scripts/create_security_cases.py
- 文档:`Docs/PRD/需求文档/安全测试/` 下两份 ERP 任务创建文档(新增)
- **已提交并推送****已部署至 5.60**,task-preview 接口实测通过(真实执行 ID 返回正确数据),create-task 待用户在界面端到端实测
**2026-08-19 安全测试定时任务模块(已实现,已部署至 5.60 已验证)**
- `backend/app/routers/security_scheduled_tasks.py`(新增,7 个 API 端点,prefix `/api/security/scheduled-tasks`
- `backend/app/services/scheduler_service.py`(修改,`run_task_once` 按 case_type 分流 + `_run_security_task_once` 新函数)
- `backend/app/models/scheduled_task.py`(修改,`_ensure_columns` 新增 `case_type` / `config_id` 字段)
- `backend/app/schemas/scheduled_task.py`(修改,Pydantic 模型对齐新字段)
- `backend/app/database.py`(修改,`_ensure_columns` 支持安全定时任务表的列迁移)
- `backend/app/main.py`(修改,注册 `security_scheduled_tasks` 路由)
- `frontend/src/api/securityScheduledTasks.ts`(新增,API 封装)
- `frontend/src/views/security/ScheduledTasks.vue`(新增,完整页面:表格 + 创建/编辑对话框 + 周期配置 + 运行记录 drawer)
- `frontend/src/router/index.ts`(修改,新增 `/security/scheduled-tasks` 独立路由)
- `frontend/src/App.vue`(修改,安全测试子菜单新增「定时任务」+ 页面标题映射)
- 文档:`Docs/PRD/需求文档/安全测试/` 下两份安全定时任务文档(新增)
- **已部署至 5.60**,通过创建/删除冒烟测试验证,定时调度触发待真实执行验证
---
*本文档由安全测试开发窗口生成,供下一次会话快速恢复上下文。会议管理窗口的交接见 `HANDOFF.md`。*
......@@ -145,6 +145,18 @@ async def _ensure_columns(conn) -> None:
("report_templates", "description", "VARCHAR(200) DEFAULT ''"),
# 钉钉配置:被测系统URL(旧库升级)
("dingtalk_configs", "target_url", "VARCHAR(500) DEFAULT ''"),
# 钉钉配置:登录接口路径(旧库升级)
("dingtalk_configs", "login_path", "VARCHAR(200) DEFAULT '/platform/api/auth/login'"),
# 钉钉配置:验证码接口路径(旧库升级)
("dingtalk_configs", "captcha_path", "VARCHAR(200) DEFAULT '/platform/api/code'"),
# 性能测试:自定义登录用户名(旧库升级)
("performance_tasks", "login_username", "VARCHAR(200) DEFAULT NULL"),
# 性能测试:自定义登录密码(旧库升级)
("performance_tasks", "login_password", "VARCHAR(200) DEFAULT NULL"),
# 定时任务:用例类型(旧库升级,区分 UI/安全测试)
("scheduled_tasks", "case_type", "VARCHAR(20) DEFAULT 'ui'"),
# 定时任务:安全测试配置ID(旧库升级)
("scheduled_tasks", "config_id", "VARCHAR(64) DEFAULT NULL"),
]
def _do_ensure(sync_conn) -> None:
......
......@@ -27,7 +27,7 @@ from fastapi.responses import FileResponse
from app.config import settings
from app.database import init_db
from app.routers import modules, cases, executions, recorder, stats, reports, cleanup, batch, dependencies, security, security_erp, security_erp_upload, security_erp_task, ai_locator, device_sim, system, element_locator, login_template, smart_locate, performance, performance_output, api_preset, deploy, functional_report, report_template, api_test, debug, scheduled_tasks
from app.routers import modules, cases, executions, recorder, stats, reports, cleanup, batch, dependencies, security, security_erp, security_erp_upload, security_erp_task, ai_locator, device_sim, system, element_locator, login_template, smart_locate, performance, performance_output, api_preset, deploy, functional_report, report_template, api_test, debug, scheduled_tasks, security_scheduled_tasks
# 配置日志
logging.basicConfig(
......@@ -70,7 +70,7 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.warning(f"默认登录模板初始化失败(非致命): {e}")
# 加载被测系统 URL(从数据库持久化值,修复服务重启后回退到环境变量默认值的问题)
# 加载被测系统 URL 与认证路径(从数据库持久化值,修复服务重启后回退到环境变量默认值的问题)
try:
from app.models.dingtalk_config import DingTalkConfig
from sqlalchemy import select
......@@ -82,6 +82,14 @@ async def lifespan(app: FastAPI):
if cfg and cfg.target_url:
settings.TARGET_URL = cfg.target_url
logger.info(f"被测系统 URL 已从数据库加载: {settings.TARGET_URL}")
if cfg and cfg.login_path:
settings.AUTH_LOGIN_PATH = cfg.login_path
if cfg and cfg.captcha_path:
settings.AUTH_CAPTCHA_PATH = cfg.captcha_path
if cfg and (cfg.login_path or cfg.captcha_path):
logger.info(
f"认证接口路径已从数据库加载: login={settings.AUTH_LOGIN_PATH}, captcha={settings.AUTH_CAPTCHA_PATH}"
)
except Exception as e:
logger.warning(f"加载被测系统 URL 失败(使用环境变量默认值): {e}")
......@@ -324,6 +332,13 @@ app.include_router(
tags=["定时任务"]
)
# 安全测试定时任务管理
app.include_router(
security_scheduled_tasks.router,
prefix="/api/security/scheduled-tasks",
tags=["安全测试-定时任务"]
)
# ==================== 根路径 ====================
......
......@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*-
"""
模块名称:scheduled_task.py
模块描述:UI 自动化定时任务数据库模型定义
模块描述:定时任务数据库模型定义(UI 自动化 + 安全测试共用)
作者:czj
创建日期:2026-08-19
......@@ -19,15 +19,18 @@ from app.database import Base
class ScheduledTask(Base):
"""
UI 自动化定时任务数据库模型
定时任务数据库模型
存储定时任务的定义:选择哪些模块执行、周期设置、启用状态等。
每次到期触发会创建一条 Execution 记录(trigger_type=scheduled,
trigger_by=任务名),执行完成后自动生成报告到报告中心。
通过 case_type 字段区分 UI 自动化定时任务与安全测试定时任务。
Attributes:
id (str): 任务唯一标识
name (str): 任务名称(作为执行记录的 trigger_by)
case_type (str): 用例类型 (ui/security)
config_id (str): 安全测试配置ID(case_type=security 时使用)
enabled (bool): 是否启用
module_ids (list): 选中的模块 ID 列表
schedule_type (str): 周期模式 (interval/daily/weekly)
......@@ -45,8 +48,10 @@ class ScheduledTask(Base):
Example:
>>> task = ScheduledTask(
... id="scheduled_abc123",
... name="每日回归",
... module_ids=["module_001", "module_002"],
... name="每日安全扫描",
... case_type="security",
... config_id="sec_cfg_default",
... module_ids=["sec_api01"],
... schedule_type="daily",
... run_time="22:00",
... )
......@@ -56,6 +61,12 @@ class ScheduledTask(Base):
id: Mapped[str] = mapped_column(String(64), primary_key=True, comment="任务ID")
name: Mapped[str] = mapped_column(String(100), nullable=False, comment="任务名称")
case_type: Mapped[str] = mapped_column(
String(20), default="ui", comment="用例类型: ui/security"
)
config_id: Mapped[Optional[str]] = mapped_column(
String(64), nullable=True, default=None, comment="安全测试配置ID(security时使用)"
)
enabled: Mapped[bool] = mapped_column(
Boolean, default=True, comment="是否启用"
)
......@@ -116,6 +127,8 @@ class ScheduledTask(Base):
return {
"id": self.id,
"name": self.name,
"case_type": self.case_type,
"config_id": self.config_id,
"enabled": self.enabled,
"module_ids": self.module_ids or [],
"schedule_type": self.schedule_type,
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:security_scheduled_tasks.py
模块描述:安全测试定时任务管理 API 路由
作者:czj
创建日期:2026-08-19
"""
import asyncio
import logging
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.scheduled_task import ScheduledTask
from app.models.security_config import SecurityConfig
from app.models.module import Module
from app.models.execution import Execution
from app.schemas.scheduled_task import (
ScheduledTaskCreate,
ScheduledTaskUpdate,
ToggleRequest,
)
from app.services.scheduler_service import compute_next_run, run_task_once
from app.utils.id_generator import generate_id
logger = logging.getLogger(__name__)
router = APIRouter()
async def _module_name_map(db: AsyncSession, module_ids: List[str]) -> dict:
"""查询模块 ID → 名称映射"""
if not module_ids:
return {}
result = await db.execute(select(Module).where(Module.id.in_(module_ids)))
return {m.id: m.name for m in result.scalars().all()}
async def _config_name_map(db: AsyncSession) -> dict:
"""查询安全测试配置 ID → 名称映射"""
result = await db.execute(select(SecurityConfig))
return {c.id: c.name for c in result.scalars().all()}
async def _task_to_dict(db: AsyncSession, task: ScheduledTask) -> dict:
"""任务字典 + 模块名称列表 + 配置名称 + 周期描述"""
data = task.to_dict()
name_map = await _module_name_map(db, task.module_ids or [])
data["module_names"] = [
name_map.get(mid, mid) for mid in (task.module_ids or [])
]
if task.config_id:
cfg_map = await _config_name_map(db)
data["config_name"] = cfg_map.get(task.config_id, task.config_id)
else:
data["config_name"] = None
data["schedule_description"] = task.schedule_description()
return data
@router.get("", response_model=dict)
async def list_tasks(
enabled: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
):
"""
获取安全测试定时任务列表
筛选 case_type="security" 的安全测试定时任务。
"""
try:
query = (
select(ScheduledTask)
.where(ScheduledTask.case_type == "security")
.order_by(desc(ScheduledTask.enabled), ScheduledTask.created_at.desc())
)
if enabled is not None:
query = query.where(ScheduledTask.enabled == enabled)
result = await db.execute(query)
tasks = list(result.scalars().all())
items = []
for task in tasks:
items.append(await _task_to_dict(db, task))
return {"total": len(items), "items": items}
except Exception as e:
logger.error(f"获取安全测试定时任务列表失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"服务器内部错误: {str(e)}")
@router.post("", response_model=dict, status_code=201)
async def create_task(
data: ScheduledTaskCreate,
db: AsyncSession = Depends(get_db),
):
"""
创建安全测试定时任务
自动设 case_type="security",校验 config_id 必填。
"""
try:
# 校验 config_id 必填
if not data.config_id:
raise HTTPException(
status_code=422, detail="安全测试定时任务必须指定测试配置 (config_id)"
)
# 校验 config_id 是否存在
cfg_result = await db.execute(
select(SecurityConfig).where(SecurityConfig.id == data.config_id)
)
if not cfg_result.scalar_one_or_none():
raise HTTPException(
status_code=404,
detail=f"安全测试配置不存在: {data.config_id}",
)
task = ScheduledTask(
id=generate_id("scheduled"),
name=data.name.strip(),
case_type="security",
config_id=data.config_id,
enabled=data.enabled,
module_ids=data.module_ids or [],
schedule_type=data.schedule_type,
interval_value=data.interval_value,
interval_unit=data.interval_unit,
run_time=data.run_time or "",
weekdays=data.weekdays or [],
auto_report=data.auto_report,
)
task.next_run_at = compute_next_run(task)
db.add(task)
await db.flush()
await db.refresh(task)
return await _task_to_dict(db, task)
except HTTPException:
raise
except Exception as e:
logger.error(f"创建安全测试定时任务失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"服务器内部错误: {str(e)}")
@router.put("/{task_id}", response_model=dict)
async def update_task(
task_id: str,
data: ScheduledTaskUpdate,
db: AsyncSession = Depends(get_db),
):
"""
更新安全测试定时任务
不允许修改 case_type。
"""
try:
result = await db.execute(
select(ScheduledTask).where(ScheduledTask.id == task_id)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail=f"定时任务不存在: {task_id}")
# 不允许修改 case_type
if data.case_type is not None and data.case_type != task.case_type:
raise HTTPException(
status_code=422, detail="不允许修改定时任务类型 (case_type)"
)
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(task, field, value)
task.name = (task.name or "").strip()
task.next_run_at = compute_next_run(task)
await db.flush()
await db.refresh(task)
return await _task_to_dict(db, task)
except HTTPException:
raise
except Exception as e:
logger.error(f"更新安全测试定时任务失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"服务器内部错误: {str(e)}")
@router.delete("/{task_id}", status_code=204)
async def delete_task(
task_id: str,
db: AsyncSession = Depends(get_db),
):
"""
删除安全测试定时任务(不影响已产生的执行记录与报告)
"""
try:
result = await db.execute(
select(ScheduledTask).where(ScheduledTask.id == task_id)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail=f"定时任务不存在: {task_id}")
await db.delete(task)
await db.flush()
logger.info(f"安全测试定时任务已删除: {task_id}")
except HTTPException:
raise
except Exception as e:
logger.error(f"删除安全测试定时任务失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"服务器内部错误: {str(e)}")
@router.post("/{task_id}/toggle", response_model=dict)
async def toggle_task(
task_id: str,
data: ToggleRequest,
db: AsyncSession = Depends(get_db),
):
"""
启用 / 停用安全测试定时任务
"""
try:
result = await db.execute(
select(ScheduledTask).where(ScheduledTask.id == task_id)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail=f"定时任务不存在: {task_id}")
task.enabled = data.enabled
if data.enabled:
task.next_run_at = compute_next_run(task)
await db.flush()
await db.refresh(task)
return await _task_to_dict(db, task)
except HTTPException:
raise
except Exception as e:
logger.error(f"切换安全测试定时任务状态失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"服务器内部错误: {str(e)}")
@router.post("/{task_id}/run", response_model=dict)
async def run_task_now(
task_id: str,
db: AsyncSession = Depends(get_db),
):
"""
立即执行安全测试定时任务(手动触发一次)
后台异步创建执行记录并运行,执行完成后自动生成报告。
"""
try:
result = await db.execute(
select(ScheduledTask).where(ScheduledTask.id == task_id)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail=f"定时任务不存在: {task_id}")
# 后台异步执行(不阻塞请求)
asyncio.create_task(run_task_once(task_id))
return {
"message": f"安全测试任务「{task.name}」已触发执行",
"task_id": task_id,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"立即执行安全测试定时任务失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"服务器内部错误: {str(e)}")
@router.get("/{task_id}/runs", response_model=dict)
async def list_task_runs(
task_id: str,
skip: int = 0,
limit: int = 20,
db: AsyncSession = Depends(get_db),
):
"""
获取安全测试定时任务运行历史
查询该任务触发的安全测试执行记录(trigger_type=scheduled 且 trigger_by=任务名)。
"""
try:
result = await db.execute(
select(ScheduledTask).where(ScheduledTask.id == task_id)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail=f"定时任务不存在: {task_id}")
query = (
select(Execution)
.where(
Execution.trigger_type == "scheduled",
Execution.trigger_by == task.name,
Execution.case_type == "security",
)
.order_by(desc(Execution.created_at))
.offset(skip)
.limit(limit)
)
result = await db.execute(query)
executions = list(result.scalars().all())
return {
"total": len(executions),
"items": [e.to_dict() for e in executions],
}
except HTTPException:
raise
except Exception as e:
logger.error(f"获取安全测试定时任务运行历史失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"服务器内部错误: {str(e)}")
\ No newline at end of file
......@@ -23,6 +23,8 @@ class ScheduledTaskCreate(BaseModel):
weekdays: List[int] = Field([], description="星期 [0-6](weekly)")
auto_report: bool = Field(True, description="执行完成后自动生成报告")
enabled: bool = Field(True, description="是否启用")
case_type: str = Field("ui", description="用例类型: ui/security")
config_id: Optional[str] = Field(None, description="安全测试配置ID(security时必填)")
class ScheduledTaskUpdate(BaseModel):
......@@ -36,6 +38,8 @@ class ScheduledTaskUpdate(BaseModel):
weekdays: Optional[List[int]] = Field(None, description="星期 [0-6]")
auto_report: 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")
class ToggleRequest(BaseModel):
......
......@@ -28,6 +28,7 @@ from sqlalchemy import select
from app.database import async_session_maker
from app.models.scheduled_task import ScheduledTask
from app.models.test_case import TestCase
from app.models.execution import Execution
from app.services.execution_service import ExecutionService, is_execution_running
from app.services.report_service import ReportService
from app.utils.id_generator import generate_id
......@@ -103,13 +104,18 @@ def compute_next_run(
# ==================== 触发流程 ====================
async def collect_module_cases(db, module_ids: List[str]) -> List[TestCase]:
async def collect_module_cases(
db,
module_ids: List[str],
case_type: str = "ui",
) -> List[TestCase]:
"""
收集选中模块下所有启用的 UI 测试用例(按模块分组 + order 排序)
收集选中模块下所有启用的测试用例(按模块分组 + order 排序)
Args:
db: 数据库会话
db: 异步数据库会话
module_ids (List[str]): 模块 ID 列表
case_type (str): 用例类型(ui/security)
Returns:
List[TestCase]: 用例列表
......@@ -121,7 +127,7 @@ async def collect_module_cases(db, module_ids: List[str]) -> List[TestCase]:
.where(
TestCase.module_id.in_(module_ids),
TestCase.status == "active",
TestCase.case_type == "ui",
TestCase.case_type == case_type,
)
.order_by(TestCase.module_id, TestCase.order, TestCase.created_at)
)
......@@ -129,11 +135,112 @@ async def collect_module_cases(db, module_ids: List[str]) -> List[TestCase]:
return list(result.scalars().all())
async def _has_running_security_execution(db) -> bool:
"""检查是否已有安全测试执行在运行,避免定时任务并发压垮目标系统。"""
result = await db.execute(
select(Execution.id).where(
Execution.case_type == "security",
Execution.status.in_(["pending", "running"]),
).limit(1)
)
return result.scalar_one_or_none() is not None
async def _run_security_task_once(
task: ScheduledTask,
db,
) -> Optional[str]:
"""
安全测试定时任务触发流程
收集安全测试用例 → 创建执行记录(SecurityService)→ 运行 → 自动生成报告 → 推进调度
Args:
task (ScheduledTask): 定时任务对象(已从数据库加载)
db: 异步数据库会话
Returns:
Optional[str]: 本次创建的执行记录 ID;跳过/失败时返回 None
"""
from app.services.security_service import SecurityService
from app.services.security_report_service import SecurityReportService
# 1. 收集安全测试用例
cases = await collect_module_cases(db, task.module_ids or [], "security")
if not cases:
logger.warning(
f"[定时任务] 安全任务「{task.name}」选中模块下无启用安全用例,跳过本次"
)
task.next_run_at = compute_next_run(task)
await db.flush()
return None
# 2. 执行锁预检:已有安全测试执行在运行 → 跳过
if await _has_running_security_execution(db):
logger.info(
f"[定时任务] 安全任务「{task.name}」到点但已有安全执行在运行,"
f"跳过本次,下次周期再触发"
)
task.next_run_at = compute_next_run(task)
await db.flush()
return None
# 3. 创建执行记录(通过 SecurityService)
case_ids = [c.id for c in cases]
security_service = SecurityService(db)
run_name = f"{task.name}-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
execution = await security_service.create_execution(
config_id=task.config_id,
module_ids=task.module_ids,
case_ids=case_ids,
name=run_name,
)
# 覆盖 trigger_type / trigger_by(SecurityService 默认 manual)
execution.trigger_type = "scheduled"
execution.trigger_by = task.name
await db.commit()
logger.info(
f"[定时任务] 安全任务「{task.name}」触发执行: {execution.id}, "
f"用例 {len(case_ids)} 个"
)
# 4. 运行执行(阻塞等待完成)
run_ok = True
try:
await security_service.run_execution(execution.id)
except Exception as e:
run_ok = False
logger.error(f"[定时任务] 安全任务「{task.name}」执行异常: {e}")
# 5. 自动生成报告(执行成功后才生成)
if task.auto_report and run_ok:
try:
report_service = SecurityReportService()
await report_service.generate_report(execution.id, db)
logger.info(
f"[定时任务] 安全任务「{task.name}」报告已生成: {execution.id}"
)
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
task.next_run_at = compute_next_run(task)
await db.flush()
await db.commit()
return execution.id
async def run_task_once(task_id: str) -> Optional[str]:
"""
执行一次定时任务(创建执行记录 → 运行 → 自动生成报告 → 推进下次调度)
供后台调度循环与「立即执行」接口共用。
按任务 case_type 分流:ui 走原 Playwright 执行路径,security 走安全测试执行器路径。
Args:
task_id (str): 定时任务 ID
......@@ -156,7 +263,11 @@ async def run_task_once(task_id: str) -> Optional[str]:
logger.warning(f"[定时任务] 任务不存在: {task_id}")
return None
# 1. 收集模块用例
# 按 case_type 分流
if task.case_type == "security":
return await _run_security_task_once(task, db)
# ===== 以下为 UI 自动化定时任务原有逻辑 =====
cases = await collect_module_cases(db, task.module_ids or [])
if not cases:
logger.warning(
......
......@@ -75,6 +75,7 @@
<el-menu-item index="/execution/security">执行中心</el-menu-item>
<el-menu-item index="/reports/security">报告中心</el-menu-item>
<el-menu-item index="/security/erp-config">ERP 配置</el-menu-item>
<el-menu-item index="/security/scheduled-tasks">定时任务</el-menu-item>
</el-sub-menu>
<!-- 性能测试(父菜单,含三个子页面) -->
......@@ -301,6 +302,7 @@ const DOCUMENT_LABELS: Record<string, string> = {
/** 安全测试子页面标签映射(ERP配置等独立子路由) */
const SECURITY_SUB_LABELS: Record<string, string> = {
'erp-config': 'ERP 配置',
'scheduled-tasks': '定时任务',
}
/** 当前激活菜单项(无 type 时回退到默认子项以保持高亮) */
......
/**
* 安全测试定时任务管理 API 接口
*
* @module api/securityScheduledTasks
* @author czj
* @date 2026-08-19
*/
import request from '@/utils/request'
/** 安全测试定时任务数据结构 */
export interface SecurityScheduledTask {
id: string
name: string
enabled: boolean
module_ids: string[]
module_names: string[]
config_id: string
config_name: string
schedule_type: string
interval_value: number
interval_unit: string
run_time: string
weekdays: number[]
auto_report: boolean
schedule_description: string
last_run_at: string | null
next_run_at: string | null
run_count: number
created_at: string | null
updated_at: string | null
}
/** 创建/编辑安全测试定时任务请求体 */
export interface SecurityScheduledTaskPayload {
name: string
module_ids: string[]
config_id: string
schedule_type: 'interval' | 'daily' | 'weekly'
interval_value?: number
interval_unit?: 'minutes' | 'hours' | 'days'
run_time?: string
weekdays?: number[]
auto_report?: boolean
enabled?: boolean
}
/**
* 安全测试定时任务管理 API 服务
*/
export const securityScheduledTaskApi = {
/** 获取安全测试定时任务列表 */
async list(): Promise<{ total: number; items: SecurityScheduledTask[] }> {
const response = await request.get('/api/security/scheduled-tasks')
return response as any
},
/** 创建安全测试定时任务 */
async create(data: SecurityScheduledTaskPayload): Promise<SecurityScheduledTask> {
const response = await request.post('/api/security/scheduled-tasks', data)
return response as any
},
/** 更新安全测试定时任务 */
async update(id: string, data: Partial<SecurityScheduledTaskPayload>): Promise<SecurityScheduledTask> {
const response = await request.put(`/api/security/scheduled-tasks/${id}`, data)
return response as any
},
/** 删除安全测试定时任务(不影响已产生的执行记录与报告) */
async remove(id: string): Promise<void> {
await request.delete(`/api/security/scheduled-tasks/${id}`)
},
/** 立即执行 */
async run(id: string): Promise<{ message: string; task_id: string }> {
const response = await request.post(`/api/security/scheduled-tasks/${id}/run`)
return response as any
},
/** 启用 / 停用 */
async toggle(id: string, enabled: boolean): Promise<SecurityScheduledTask> {
const response = await request.post(`/api/security/scheduled-tasks/${id}/toggle`, { enabled })
return response as any
},
/** 运行历史(该任务触发的执行记录) */
async getRuns(id: string, params?: { skip?: number; limit?: number }): Promise<{ total: number; items: any[] }> {
const response = await request.get(`/api/security/scheduled-tasks/${id}/runs`, { params })
return response as any
},
}
\ No newline at end of file
......@@ -237,6 +237,13 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/security/ErpConfig.vue'),
meta: { title: 'ERP配置' }
},
// 安全测试定时任务(复用 scheduled_tasks 表,case_type=security 分流调度)
{
path: '/security/scheduled-tasks',
name: 'SecurityScheduledTasks',
component: () => import('@/views/security/ScheduledTasks.vue'),
meta: { title: '定时任务' }
},
// 系统管理(父菜单)
{
path: '/system',
......@@ -266,27 +273,28 @@ const routes: RouteRecordRaw[] = [
]
},
// 文档管理(父菜单,含文档校正优化和文档翻译两个子页面)
{
path: '/document',
name: 'Document',
component: () => import('@/views/document/index.vue'),
redirect: '/document/optimize',
meta: { title: '文档管理' },
children: [
{
path: 'optimize',
name: 'DocumentOptimize',
component: () => import('@/views/document/DocumentOptimize.vue'),
meta: { title: '文档校正优化' }
},
{
path: 'translate',
name: 'DocumentTranslate',
component: () => import('@/views/document/DocumentTranslate.vue'),
meta: { title: '文档翻译' }
}
]
},
// TODO: 文档管理模块正在开发中,解锁后取消注释
// {
// path: '/document',
// name: 'Document',
// component: () => import('@/views/document/index.vue'),
// redirect: '/document/optimize',
// meta: { title: '文档管理' },
// children: [
// {
// path: 'optimize',
// name: 'DocumentOptimize',
// component: () => import('@/views/document/DocumentOptimize.vue'),
// meta: { title: '文档校正优化' }
// },
// {
// path: 'translate',
// name: 'DocumentTranslate',
// component: () => import('@/views/document/DocumentTranslate.vue'),
// meta: { title: '文档翻译' }
// }
// ]
// },
// 旧 /settings 路由 → 重定向到 /system/settings
{
path: '/settings',
......
<!--
页面名称:ScheduledTasks.vue
页面描述:安全测试定时任务页面(任务列表 / 创建编辑 / 立即执行 / 运行历史)
路径:/security/scheduled-tasks
@author czj
@date 2026-08-19
-->
<template>
<div class="security-scheduled-tasks-page">
<!-- 页面头部 -->
<div class="page-header">
<h2>定时任务</h2>
<div class="header-actions">
<el-button @click="loadTasks">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
<el-button type="primary" @click="openCreate">
<el-icon><Plus /></el-icon>
新建任务
</el-button>
</div>
</div>
<el-alert
title="安全测试定时任务将按周期自动执行所选模块的安全用例,执行完成后自动生成报告到安全测试报告中心;若到点时有执行正在进行将自动跳过本次。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
/>
<!-- 任务列表 -->
<el-card shadow="never">
<el-table
:data="tasks"
stripe
style="width: 100%"
v-loading="loading"
empty-text="暂无安全测试定时任务"
>
<el-table-column prop="name" label="任务名称" min-width="160" />
<el-table-column label="执行模块" min-width="200">
<template #default="{ row }">
<template v-if="row.module_names?.length">
<el-tag
v-for="name in row.module_names"
:key="name"
size="small"
type="info"
style="margin-right: 4px"
>
{{ name }}
</el-tag>
</template>
<span v-else style="color: #909399">未选择</span>
</template>
</el-table-column>
<el-table-column prop="config_name" label="测试配置" min-width="140">
<template #default="{ row }">
<el-tag size="small" type="warning">{{ row.config_name || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="schedule_description" label="周期" min-width="140" />
<el-table-column label="自动报告" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.auto_report ? 'success' : 'info'" size="small">
{{ row.auto_report ? '开启' : '关闭' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.enabled"
:disabled="togglingId === row.id"
@change="(val: boolean) => handleToggle(row, val)"
/>
</template>
</el-table-column>
<el-table-column label="上次执行" width="170">
<template #default="{ row }">
{{ formatTime(row.last_run_at) }}
</template>
</el-table-column>
<el-table-column label="下次执行" width="170">
<template #default="{ row }">
<span :style="{ color: row.enabled ? '#409eff' : '#909399' }">
{{ formatTime(row.next_run_at) }}
</span>
</template>
</el-table-column>
<el-table-column prop="run_count" label="执行次数" width="90" align="center" />
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-button
size="small"
type="primary"
link
:loading="runningId === row.id"
@click="handleRun(row)"
>
<el-icon><VideoPlay /></el-icon>
立即执行
</el-button>
<el-button size="small" type="primary" link @click="openRuns(row)">
<el-icon><List /></el-icon>
运行记录
</el-button>
<el-button size="small" type="primary" link @click="openEdit(row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button size="small" type="danger" link @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 创建 / 编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑安全测试定时任务' : '新建安全测试定时任务'"
width="620px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form
ref="formRef"
:model="form"
:rules="formRules"
label-width="100px"
label-position="right"
>
<el-form-item label="任务名称" prop="name">
<el-input
v-model="form.name"
placeholder="如:每日安全扫描"
maxlength="100"
clearable
/>
</el-form-item>
<el-form-item label="执行模块" prop="module_ids">
<el-select
v-model="form.module_ids"
multiple
filterable
collapse-tags
collapse-tags-tooltip
style="width: 100%"
placeholder="选择要执行的安全测试模块"
:loading="modulesLoading"
>
<el-option
v-for="m in modules"
:key="m.id"
:label="`${m.name}(${m.caseCount ?? 0} 用例)`"
:value="m.id"
/>
</el-select>
</el-form-item>
<el-form-item label="测试配置" prop="config_id">
<el-select
v-model="form.config_id"
style="width: 100%"
placeholder="选择安全测试配置"
:loading="configsLoading"
>
<el-option
v-for="c in configs"
:key="c.id"
:label="c.name"
:value="c.id"
/>
</el-select>
</el-form-item>
<el-form-item label="周期模式" prop="schedule_type">
<el-radio-group v-model="form.schedule_type">
<el-radio-button value="interval">按间隔</el-radio-button>
<el-radio-button value="daily">每天定时</el-radio-button>
<el-radio-button value="weekly">每周定时</el-radio-button>
</el-radio-group>
</el-form-item>
<!-- 按间隔 -->
<el-form-item v-if="form.schedule_type === 'interval'" label="间隔" prop="interval_value">
<div style="display: flex; gap: 8px; width: 100%">
<el-input-number
v-model="form.interval_value"
:min="1"
:max="99999"
style="width: 160px"
/>
<el-select v-model="form.interval_unit" style="width: 140px">
<el-option label="分钟" value="minutes" />
<el-option label="小时" value="hours" />
<el-option label="天" value="days" />
</el-select>
</div>
</el-form-item>
<!-- 每天定时 -->
<el-form-item v-if="form.schedule_type === 'daily'" label="执行时间" prop="run_time">
<el-time-select
v-model="form.run_time"
start="00:00"
step="00:30"
end="23:30"
placeholder="选择执行时间"
style="width: 160px"
/>
</el-form-item>
<!-- 每周定时 -->
<el-form-item
v-if="form.schedule_type === 'weekly'"
label="执行时间"
prop="run_time"
>
<el-time-select
v-model="form.run_time"
start="00:00"
step="00:30"
end="23:30"
placeholder="选择执行时间"
style="width: 160px"
/>
</el-form-item>
<el-form-item
v-if="form.schedule_type === 'weekly'"
label="星期"
prop="weekdays"
>
<el-checkbox-group v-model="form.weekdays">
<el-checkbox-button
v-for="(label, idx) in WEEKDAY_LABELS"
:key="idx"
:value="idx"
>
{{ label }}
</el-checkbox-button>
</el-checkbox-group>
</el-form-item>
<el-form-item label="自动报告">
<el-switch v-model="form.auto_report" />
<span class="form-tip">执行完成后自动生成 Markdown 报告到安全报告中心</span>
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
<span class="form-tip">停用后不会按周期触发</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">
{{ saving ? '保存中...' : '保存' }}
</el-button>
</template>
</el-dialog>
<!-- 运行历史抽屉 -->
<el-drawer
v-model="runsDrawerVisible"
:title="`运行记录 - ${currentTaskName}`"
size="60%"
destroy-on-close
>
<el-table :data="runs" v-loading="runsLoading" empty-text="暂无运行记录" stripe>
<el-table-column prop="name" label="执行名称" min-width="180" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)" size="small">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="漏洞数" width="100">
<template #default="{ row }">{{ row.vulnerability_count ?? '-' }}</template>
</el-table-column>
<el-table-column label="高危/中危/低危" width="180">
<template #default="{ row }">
{{ row.high_count ?? 0 }}/{{ row.medium_count ?? 0 }}/{{ row.low_count ?? 0 }}
</template>
</el-table-column>
<el-table-column label="耗时" width="90">
<template #default="{ row }">{{ row.duration?.toFixed(1) || 0 }}s</template>
</el-table-column>
<el-table-column label="执行时间" width="170">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="110">
<template #default>
<el-button size="small" type="primary" link @click="goReports">
查看报告
</el-button>
</template>
</el-table-column>
</el-table>
</el-drawer>
</div>
</template>
<script setup lang="ts">
/**
* 安全测试定时任务页面
*
* 功能:
* 1. 安全测试定时任务列表(状态/周期/配置/上下次执行时间)
* 2. 创建/编辑任务(选择安全模块 + 选择测试配置 + 周期设置 + 自动报告)
* 3. 立即执行 / 启停 / 删除
* 4. 运行历史查看(跳转安全测试报告中心查看报告)
*/
import { reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Plus, VideoPlay, List, Edit, Delete } from '@element-plus/icons-vue'
import { securityScheduledTaskApi, type SecurityScheduledTask } from '@/api/securityScheduledTasks'
import { moduleApi } from '@/api/modules'
import { getSecurityConfigs } from '@/api/security'
import type { SecurityConfig } from '@/types/security'
const router = useRouter()
const WEEKDAY_LABELS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
// ==================== 响应式数据 ====================
const tasks = ref<SecurityScheduledTask[]>([])
const loading = ref(false)
const dialogVisible = ref(false)
const isEdit = ref(false)
const saving = ref(false)
const formRef = ref()
const editId = ref('')
const modules = ref<any[]>([])
const modulesLoading = ref(false)
const configs = ref<SecurityConfig[]>([])
const configsLoading = ref(false)
const togglingId = ref('')
const runningId = ref('')
const runsDrawerVisible = ref(false)
const runsLoading = ref(false)
const runs = ref<any[]>([])
const currentTaskName = ref('')
// ==================== 表单 ====================
interface TaskForm {
name: string
module_ids: string[]
config_id: string
schedule_type: 'interval' | 'daily' | 'weekly'
interval_value: number
interval_unit: 'minutes' | 'hours' | 'days'
run_time: string
weekdays: number[]
auto_report: boolean
enabled: boolean
}
const createEmptyForm = (): TaskForm => ({
name: '',
module_ids: [],
config_id: '',
schedule_type: 'interval',
interval_value: 30,
interval_unit: 'minutes',
run_time: '',
weekdays: [1, 2, 3, 4, 5],
auto_report: true,
enabled: true,
})
const form = reactive<TaskForm>(createEmptyForm())
const formRules = {
name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
module_ids: [
{
validator: (_rule: any, value: string[], callback: (e?: Error) => void) => {
if (!value || value.length === 0) {
callback(new Error('请至少选择一个执行模块'))
} else {
callback()
}
},
trigger: 'change',
},
],
config_id: [{ required: true, message: '请选择安全测试配置', trigger: 'change' }],
schedule_type: [{ required: true, message: '请选择周期模式', trigger: 'change' }],
interval_value: [
{
validator: (_rule: any, value: number, callback: (e?: Error) => void) => {
if (form.schedule_type === 'interval' && (!value || value <= 0)) {
callback(new Error('请输入有效的间隔值'))
} else {
callback()
}
},
trigger: 'blur',
},
],
run_time: [
{
validator: (_rule: any, value: string, callback: (e?: Error) => void) => {
if (form.schedule_type !== 'interval' && !value) {
callback(new Error('请选择执行时间'))
} else {
callback()
}
},
trigger: 'change',
},
],
weekdays: [
{
validator: (_rule: any, value: number[], callback: (e?: Error) => void) => {
if (form.schedule_type === 'weekly' && (!value || value.length === 0)) {
callback(new Error('请至少选择一个星期'))
} else {
callback()
}
},
trigger: 'change',
},
],
}
// ==================== 方法 ====================
const formatTime = (time: string | null) => {
if (!time) return '-'
return new Date(time).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
const statusLabel = (status: string) => {
const map: Record<string, string> = {
pending: '待执行',
running: '执行中',
completed: '已完成',
cancelled: '已取消',
failed: '失败',
}
return map[status] || status
}
const statusTag = (status: string) => {
const map: Record<string, string> = {
pending: 'info',
running: 'warning',
completed: 'success',
cancelled: 'info',
failed: 'danger',
}
return map[status] || 'info'
}
const loadTasks = async () => {
loading.value = true
try {
const res = await securityScheduledTaskApi.list()
tasks.value = res.items
} catch (err: any) {
ElMessage.error('加载安全测试定时任务失败:' + (err?.message || '未知错误'))
} finally {
loading.value = false
}
}
const loadModules = async () => {
modulesLoading.value = true
try {
const res = await moduleApi.list(0, 200, undefined, 'security')
modules.value = res.items
} catch (err: any) {
ElMessage.error('加载安全测试模块列表失败:' + (err?.message || '未知错误'))
} finally {
modulesLoading.value = false
}
}
const loadConfigs = async () => {
configsLoading.value = true
try {
const res = await getSecurityConfigs(0, 100)
const data = (res as any).data || res
configs.value = data.items || []
} catch (err: any) {
ElMessage.error('加载安全测试配置失败:' + (err?.message || '未知错误'))
} finally {
configsLoading.value = false
}
}
const openCreate = async () => {
isEdit.value = false
editId.value = ''
Object.assign(form, createEmptyForm())
if (modules.value.length === 0) await loadModules()
if (configs.value.length === 0) await loadConfigs()
dialogVisible.value = true
formRef.value?.clearValidate()
}
const openEdit = async (task: SecurityScheduledTask) => {
isEdit.value = true
editId.value = task.id
Object.assign(form, {
name: task.name,
module_ids: [...(task.module_ids || [])],
config_id: task.config_id || '',
schedule_type: task.schedule_type,
interval_value: task.interval_value,
interval_unit: task.interval_unit,
run_time: task.run_time || '',
weekdays: [...(task.weekdays || [])],
auto_report: task.auto_report,
enabled: task.enabled,
})
if (modules.value.length === 0) await loadModules()
if (configs.value.length === 0) await loadConfigs()
dialogVisible.value = true
formRef.value?.clearValidate()
}
const handleSave = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
const payload = {
name: form.name.trim(),
module_ids: form.module_ids,
config_id: form.config_id,
schedule_type: form.schedule_type,
interval_value: form.interval_value,
interval_unit: form.interval_unit,
run_time: form.schedule_type === 'interval' ? '' : form.run_time,
weekdays: form.schedule_type === 'weekly' ? form.weekdays : [],
auto_report: form.auto_report,
enabled: form.enabled,
}
if (isEdit.value) {
await securityScheduledTaskApi.update(editId.value, payload)
ElMessage.success('安全测试定时任务已更新')
} else {
await securityScheduledTaskApi.create(payload)
ElMessage.success('安全测试定时任务已创建')
}
dialogVisible.value = false
await loadTasks()
} catch (err: any) {
ElMessage.error('保存安全测试定时任务失败:' + (err?.message || '未知错误'))
} finally {
saving.value = false
}
}
const handleToggle = async (task: SecurityScheduledTask, enabled: boolean) => {
togglingId.value = task.id
try {
await securityScheduledTaskApi.toggle(task.id, enabled)
ElMessage.success(enabled ? '任务已启用' : '任务已停用')
await loadTasks()
} catch (err: any) {
ElMessage.error('切换任务状态失败:' + (err?.message || '未知错误'))
} finally {
togglingId.value = ''
}
}
const handleRun = async (task: SecurityScheduledTask) => {
try {
await ElMessageBox.confirm(
`确定立即执行安全测试任务「${task.name}」吗?将执行其选中的所有模块安全用例。`,
'立即执行',
{ type: 'warning', confirmButtonText: '执行', cancelButtonText: '取消' }
)
} catch {
return
}
runningId.value = task.id
try {
const res = await securityScheduledTaskApi.run(task.id)
ElMessage.success(res.message || '任务已触发执行')
// 延迟刷新,等待执行状态写入
setTimeout(loadTasks, 3000)
} catch (err: any) {
ElMessage.error('触发执行失败:' + (err?.message || '未知错误'))
} finally {
runningId.value = ''
}
}
const handleDelete = async (task: SecurityScheduledTask) => {
try {
await ElMessageBox.confirm(
`确定删除安全测试定时任务「${task.name}」吗?已产生的执行记录与报告不受影响。`,
'删除定时任务',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
)
} catch {
return
}
try {
await securityScheduledTaskApi.remove(task.id)
ElMessage.success('安全测试定时任务已删除')
await loadTasks()
} catch (err: any) {
ElMessage.error('删除安全测试定时任务失败:' + (err?.message || '未知错误'))
}
}
const openRuns = async (task: SecurityScheduledTask) => {
currentTaskName.value = task.name
runs.value = []
runsDrawerVisible.value = true
runsLoading.value = true
try {
const res = await securityScheduledTaskApi.getRuns(task.id, { skip: 0, limit: 50 })
runs.value = res.items
} catch (err: any) {
ElMessage.error('加载运行记录失败:' + (err?.message || '未知错误'))
} finally {
runsLoading.value = false
}
}
const goReports = () => {
runsDrawerVisible.value = false
router.push('/reports/security')
}
// ==================== 生命周期 ====================
onMounted(() => {
loadTasks()
})
</script>
<style lang="scss" scoped>
.security-scheduled-tasks-page {
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
}
.header-actions {
display: flex;
gap: 8px;
}
}
.form-tip {
margin-left: 8px;
font-size: 12px;
color: #909399;
}
}
</style>
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论