# P16：调度任务最小运行间隔机制 — task-min-run-interval

> 优先级：P2（调度系统增强）
> 来源：Session 58_5de84e40_195620 用户需求
> 预估工作量：中
> 依赖：无新增外部依赖

---

## 背景

现有调度系统（`scheduled_tasks` 表 + `scheduler.py`）支持 5 种调度类型（once/interval/daily/weekly/cron），但缺少"最小运行间隔"维度。部分 ETL 任务（如租户配置）实际只需 10 天运行一次，员工/助教信息 1 天一次，而订单类任务无此限制。

当前问题：
- 无法限制任务的最小再次运行间隔，调度到期即执行
- 无法防止同一任务并发执行（上一次未完成就再次入队）
- 手动执行（`POST /api/schedules/{id}/run`）无法区分"尊重间隔"和"强制执行"

---

## 需求（Requirements）

### 用户故事

1. 作为管理员，我需要为每个调度任务设置最小运行间隔（如 10 天、1 天），使任务即使调度到期也不会在间隔内重复执行，避免资源浪费。
2. 作为管理员，我需要在必要时强制执行某个任务（绕过最小间隔限制），以应对紧急数据同步需求。
3. 作为管理员，我需要在调度任务列表中看到每个任务的最小间隔配置和上次成功执行时间，以便了解任务运行状态。

### 验收标准

- AC1：`scheduled_tasks` 表新增 `min_run_interval_value`（INTEGER）、`min_run_interval_unit`（VARCHAR）、`last_success_at`（TIMESTAMPTZ）3 个字段
- AC2：调度器轮询时，若 `now() - last_run_at < min_run_interval`（换算为秒），跳过本次执行并推进 `next_run_at`
- AC3：任务执行失败时，不更新 `last_success_at`，允许下次调度到期时立即重试（失败不算有效执行）
- AC4：若任务 `last_status = 'running'`（上次未完成），跳过本次入队，标记为 `skipped_concurrent`
- AC5：`POST /api/schedules/{id}/run` 新增 `force` 查询参数，`force=true` 时绕过最小间隔和并发检查
- AC6：Admin Web 创建/编辑调度任务表单新增"最小运行间隔"字段（数字输入 + 单位下拉：分钟/小时/天）
- AC7：Admin Web 任务列表新增"最小间隔"列和"上次成功"列
- AC8：Admin Web "手动执行"按钮增加"强制执行"勾选项
- AC9：现有调度任务 `min_run_interval_value` 默认 0（无限制），向后兼容
- AC10：所有变更有对应的 BD 手册更新和审计记录

---

## 设计要点

### 核心概念

- **最小运行间隔**：任务开始执行后的最小等待时间（非完成后），基于 `last_run_at` 判断
- **有效执行**：仅成功完成的执行才更新 `last_success_at`；失败不重置间隔计时器，允许立即重试
- **并发保护**：若上一次执行仍在进行中（`last_status = 'running'`），跳过本次入队
- **强制执行**：通过 API `force=true` 参数绕过最小间隔和并发检查

### DDL 变更（`scheduled_tasks` 表）

新增 3 个字段：

| 字段名 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `min_run_interval_value` | INTEGER | 0 | 最小间隔数值（0 = 无限制） |
| `min_run_interval_unit` | VARCHAR(20) | `'minutes'` | 间隔单位：`minutes` / `hours` / `days` |
| `last_success_at` | TIMESTAMPTZ | NULL | 最后一次成功执行的时间 |

> `min_run_interval_value = 0` 表示无限制，与现有行为完全一致，确保向后兼容。

### 调度器逻辑修改（`scheduler.py`）

`check_and_enqueue()` 方法的判断流程变更为：

```
对每个到期任务（enabled=true AND next_run_at <= now）：
  1. 并发检查：if last_status == 'running' → 跳过，标记 skipped_concurrent
  2. 间隔检查：if min_run_interval_value > 0：
     a. 计算 min_interval_seconds = convert(value, unit)
     b. if last_run_at IS NOT NULL AND (now - last_run_at) < min_interval_seconds：
        → 跳过，推进 next_run_at，标记 skipped_interval
  3. 正常入队执行
```

SQL 查询扩展（新增读取字段）：
```sql
SELECT id, site_id, task_config, schedule_config,
       min_run_interval_value, min_run_interval_unit,
       last_run_at, last_status
FROM scheduled_tasks
WHERE enabled = TRUE
  AND next_run_at IS NOT NULL
  AND next_run_at <= NOW()
ORDER BY next_run_at ASC
```

任务完成回调需区分成功/失败：
- 成功：`last_status = 'completed'`，同时更新 `last_success_at = NOW()`
- 失败：`last_status = 'failed'`，不更新 `last_success_at`

### API 扩展（`schedules.py`）

| 端点 | 变更 |
|---|---|
| `POST /api/schedules` | `CreateScheduleRequest` 新增 `min_run_interval_value`（int, default=0）、`min_run_interval_unit`（str, default='minutes'） |
| `PUT /api/schedules/{id}` | `UpdateScheduleRequest` 新增同上两个可选字段 |
| `GET /api/schedules` | `ScheduleResponse` 新增 `min_run_interval_value`、`min_run_interval_unit`、`last_success_at` |
| `POST /api/schedules/{id}/run` | 新增查询参数 `force: bool = False`，`force=true` 时绕过间隔和并发检查 |

### Admin Web 变更（`ScheduleTab.tsx`）

**创建/编辑表单**：
- 新增"最小运行间隔"行：`InputNumber`（数值）+ `Select`（单位：分钟/小时/天）
- 数值为 0 时显示提示"无限制"
- 位置：在调度类型配置区域下方

**任务列表表格**：
- 新增"最小间隔"列：显示格式如"10 天"、"1 小时"、"无限制"
- 新增"上次成功"列：显示 `last_success_at` 的相对时间（如"2 小时前"）

**手动执行**：
- 现有"立即执行"按钮点击后弹出确认框
- 确认框新增"强制执行（忽略最小间隔）"勾选项，默认不勾选
- 勾选后调用 `POST /api/schedules/{id}/run?force=true`

### ETL 任务注册

ETL 侧 `TaskMeta` 和 `TaskRegistry` 不需要修改。最小运行间隔是调度层概念，在 `scheduled_tasks` 表中配置，与 ETL 任务注册解耦。

---

## 数据流向

```
Admin Web（ScheduleTab.tsx）
  ├─ 创建/编辑表单 → min_run_interval_value + min_run_interval_unit
  └─ 手动执行 → force=true/false

    ↓ API（schedules.py）

数据库：scheduled_tasks 表
  ├─ min_run_interval_value (INTEGER)
  ├─ min_run_interval_unit (VARCHAR)
  └─ last_success_at (TIMESTAMPTZ)

    ↓ 调度器轮询（scheduler.py，每 30 秒）

判断逻辑：
  1. 并发检查 → last_status == 'running' → 跳过
  2. 间隔检查 → now - last_run_at < min_interval → 跳过
  3. 正常入队 → task_queue.enqueue()

    ↓ 任务执行完成回调

更新 scheduled_tasks：
  - 成功 → last_status='completed', last_success_at=NOW()
  - 失败 → last_status='failed'（last_success_at 不变）
```

---

## 边界条件与风险

| 场景 | 处理方式 |
|---|---|
| `min_run_interval_value = 0` | 无限制，与现有行为一致 |
| `last_run_at IS NULL`（从未执行） | 跳过间隔检查，正常执行 |
| 任务执行失败后立即到期 | `last_success_at` 未更新，但间隔检查基于 `last_run_at`（开始时间），所以失败后仍需等待间隔。但因为失败不算有效执行，下次到期时 `last_run_at` 已过间隔，可正常执行 |
| 手动执行 + force=false + 间隔未到 | 返回 409 Conflict，提示"最小运行间隔未到，距下次可执行还有 X 分钟" |
| 手动执行 + force=true | 绕过所有检查，直接入队 |
| 并发：上次仍在 running | 跳过入队，`last_status` 标记为 `skipped_concurrent`（不覆盖原 running 状态，仅日志记录） |
| 调度器重启后 | `last_run_at` 和 `last_success_at` 持久化在数据库，重启无影响 |

---

## 不做什么（明确排除）

- 不修改 ETL 任务注册机制（`TaskMeta`/`TaskRegistry`）
- 不新增独立的 `task_run_policy` 表（直接在 `scheduled_tasks` 表扩展）
- 不提供批量 seed SQL 设定初始间隔值（用户逐个配置）
- 不修改 `schedule_config` JSONB 结构（新字段放在表级列）
- 不涉及 ETL 侧的执行逻辑修改
- 不涉及小程序或 MCP Server

---

## 任务清单

### 数据库层

- [x] T1：DDL 迁移 — `scheduled_tasks` 新增 3 个字段
  - 迁移脚本：`db/zqyy_app/migrations/2026-03-22__p16_min_run_interval.sql`
  - DDL 基线同步：`docs/database/ddl/zqyy_app__public.sql`

### 后端层

- [x] T2：Schema 更新 — `ScheduleConfigSchema` 不变，`CreateScheduleRequest`/`UpdateScheduleRequest`/`ScheduleResponse` 新增字段
  - 文件：`apps/backend/app/schemas/schedules.py`
- [x] T3：调度器核心逻辑 — `check_and_enqueue()` 新增并发检查 + 间隔检查
  - 文件：`apps/backend/app/services/scheduler.py`
  - 新增辅助函数 `_convert_interval_to_seconds(value, unit)`
- [x] T4：API 路由 — 创建/更新端点支持新字段，手动执行端点支持 `force` 参数
  - 文件：`apps/backend/app/routers/schedules.py`
- [x] T5：任务完成回调 — 区分成功/失败，成功时更新 `last_success_at`
  - 文件：`apps/backend/app/services/task_queue.py`

### 前端层

- [x] T6：ScheduleTab 表单 — 新增"最小运行间隔"输入（数字 + 单位下拉）
  - 文件：`apps/admin-web/src/components/ScheduleTab.tsx`
- [x] T7：ScheduleTab 列表 — 新增"最小间隔"列和"上次成功"列
  - 文件：同 T6
- [x] T8：手动执行确认框 — 新增"强制执行"勾选项
  - 文件：同 T6

### 文档与验证

- [x] T9：BD 手册更新 — `scheduled_tasks` 表新增字段说明
  - 文件：`docs/database/BD_Manual_scheduled_tasks.md`
- [x] T10：验证 — Monorepo 属性测试通过；OpenAPI 契约同步；文档同步完成

### 涉及文件汇总

| 模块 | 文件路径 | 操作 |
|---|---|---|
| DDL 迁移 | `db/zqyy_app/migrations/2026-03-22__p16_min_run_interval.sql` | 新增 |
| DDL 基线 | `docs/database/ddl/zqyy_app__public.sql` | 修改 |
| Schema | `apps/backend/app/schemas/schedules.py` | 修改 |
| 调度器 | `apps/backend/app/services/scheduler.py` | 修改 |
| API 路由 | `apps/backend/app/routers/schedules.py` | 修改 |
| 任务队列 | `apps/backend/app/services/task_queue.py` | 可能修改（回调） |
| Admin Web | `apps/admin-web/src/components/ScheduleTab.tsx` | 修改 |
| BD 手册 | `docs/database/BD_Manual_scheduled_tasks.md` | 新建/修改 |
