# 定时任务管理 Spec ## Why 系统已存在多处需要周期性执行的后台作业(月度物化视图刷新、日志清理,后续还有 ICC 数据拉取、电工资质到期扫描),但当前调度能力分散在数据库 `pg_cron` 与应用内 `@midwayjs/cron` 注解中:运维无法在管理端查看任务、启停、调整执行频次,也无法追溯「什么时候执行过、成功还是失败」。需要在管理端提供统一的定时任务管理能力。 ## What Changes - 新增 2 张表:`sys_job`(任务定义与调度配置)、`sys_job_log`(执行日志) - 后端新增「内置任务注册表」+ 自研轻量调度器:任务清单由代码注册表定义,cron / 参数 / 启停 / 重试配置持久化在 DB,页面修改后**即时生效**(无需重启) - 新增 2 个内置任务:`demo_heartbeat`(示例心跳,用于验证链路)、`log_clean`(清理操作日志 + 任务日志,默认保留 90 天) - 新增接口 `/api/jobs` 系列:列表、更新配置、启停、立即执行一次、执行日志分页、手动清理日志 - 系统管理下新增「定时任务」菜单(`sys_menu.id = 47`)+ 3 个按钮权限(48-50) - 前端新增页面 `client/src/views/system/job/index.vue`:任务列表 + 编辑弹窗 + 执行日志抽屉,并在路由注册 - 不修改现有表结构与现有接口(`/api/logs/clear` 手工清空日志能力保留) ## Impact - Affected specs:系统管理(RBAC 菜单/权限)、操作日志(新增自动清理来源) - Affected code: - 后端:`server/src/entity/`、`server/src/service/`、`server/src/controller/`、`server/src/dto/`、`server/src/task/`、`server/src/configuration.ts`、`server/src/middleware/rbac.middleware.ts`、`server/package.json` - 前端:`client/src/api/`、`client/src/views/system/job/`、`client/src/router/index.ts` - 数据库:`database/migrations/20261002_job_management.sql`、`database/db_seed.sql` ### 已确认决策(来自需求澄清) 1. 形态:**内置任务管理**——任务清单由代码定义,页面只做启停 / 改 cron / 改参数 / 立即执行 / 看日志;不做「页面任意填写调用目标」的通用调度中心 2. 调度引擎:**自研轻量调度器**(配置持久化在 DB,进程内调度),不使用 `@midwayjs/cron` 注解做静态任务 3. 首批任务:框架 + 示例任务 + 清理类任务;**ICC 用电数据采集本期不纳入** 4. 执行日志:独立日志表 + 页面分页查询 + 手动清理 5. 参数:**有限参数可编辑**(按任务注册表声明的字段白名单渲染表单,不开放任意 JSON) 6. 失败处理:支持重试,**重试次数与重试间隔均可按任务配置** 7. 日志保留期:操作日志 + 任务日志均默认 90 天,保留天数作为任务参数可在页面调整 ### 假设(如与预期不符请在开工前指出) 1. **单实例部署**:并发控制使用进程内重入锁,不实现分布式锁(多实例部署时同一任务会在每个实例各触发一次)。这与初始选项描述中的「Redis 锁」有偏差:当前代码库 `server/src` 内没有任何 Redis/ioredis 使用点(仅 `config.default.ts` 声明了 redis 配置),引入会使任务运行强依赖 Redis 可用性。 2. **不做执行超时强制中断**(Node 侧无法安全中断同步逻辑),仅在日志中记录执行耗时。 3. **立即执行接口同步返回执行结果**(请求内等待任务跑完),适配本期两个秒级内置任务;后续接入长耗时任务时再改异步触发。 4. **权限粒度**:仅新增菜单级权限 `system:job`(后端 RBAC 真实生效);3 个按钮权限码只用于前端按钮显隐,后端不做按钮级校验。 5. **不提供任务新增/删除**:任务清单由代码注册表决定,页面无删除入口。 6. **与并行规格共存**:`add-om-pricing-and-worker-registry` 规格正在并行实现,其中用 `@midwayjs/cron` 注解实现了电工资质到期扫描(`server/src/task/cert_expire.task.ts`)。本期**不改造**它,两套机制并存;后续可把该任务迁入本任务注册表统一管理(本期非目标)。 7. **无独立 UI 设计稿**:页面沿用现有约定(搜索栏 + 表格 + 新增/编辑弹窗),与「操作日志」「数据字典」页一致。 ### 非目标(本期不做) - 通用调度中心(页面填写 Bean/方法名调用目标、动态执行任意代码) - ICC 用电数据采集任务(接口未定,后续单独开规格) - 任务编排/依赖、任务分组、任务执行结果通知推送 - 多实例分布式互斥、任务超时强制中断、任务优先级与队列 - 把 `pg_cron` 中的分区维护/物化视图刷新任务迁移到应用侧 - 改造 `@midwayjs/cron` 注解任务(`cert_expire.task.ts`) --- ## 技术约定(本期实现必须遵守) - 技术栈不变:Midway.js 3.x (Koa) + Sequelize-typescript + PostgreSQL;Vue3 + Ant Design Vue 4 + Vite - `sequelize.sync = false`(见 [config.default.ts](file:///d:/MyProject/智能电网/smart_grid_iot/server/src/config/config.default.ts#L26-L52)),建表必须通过 `database/migrations/*.sql` 完成,并同步 `database/db_seed.sql` - cron 解析复用项目已安装的 `cron` 包(`node_modules/cron@3.5.0`,为 `@midwayjs/cron` 的传递依赖,已在 `server/yarn.lock` 中):在 `server/package.json` 中**显式声明** `"cron": "^3.5.0"` 后使用,不引入其它新依赖 - 所有 SQL 走 Sequelize 参数化 API,禁止字符串拼接 - 所有列表接口使用 `limit + offset` 分页;控制器入参使用 DTO + `class-validator` - 统一返回结构 `{ code, message, data }`,与现有 service 一致 - 前端不使用 `v-html` --- ## ADDED Requirements ### Requirement: 任务定义与调度配置持久化(`sys_job`) 系统 SHALL 提供 `sys_job` 表持久化内置任务的调度配置。 字段: | 列 | 类型 | 说明 | | --- | --- | --- | | id | BIGSERIAL PK | 自增主键 | | job_key | VARCHAR(64) NOT NULL UNIQUE | 任务键,与代码注册表一一对应(如 `log_clean`) | | job_name | VARCHAR(64) NOT NULL | 任务名称 | | cron_expression | VARCHAR(64) NOT NULL | cron 表达式(分 时 日 月 周,支持 6 段带秒) | | params | JSONB NOT NULL DEFAULT '{}' | 任务参数(受白名单字段约束) | | retry_times | SMALLINT NOT NULL DEFAULT 0 | 失败后最大重试次数(0-5) | | retry_interval | INT NOT NULL DEFAULT 60 | 失败后重试间隔(秒,10-3600) | | status | SMALLINT NOT NULL DEFAULT 1 | 1 启用 / 0 停用 | | remark | VARCHAR(255) | 备注 | | last_run_at | TIMESTAMPTZ | 上次执行时间 | | last_run_status | SMALLINT | 1 成功 / 2 失败 / 3 跳过 | | last_run_cost_ms | INT | 上次执行耗时(毫秒) | | last_error | TEXT | 上次失败原因(成功时清空) | | created_at / updated_at | TIMESTAMPTZ | 时间戳 | 索引:`sys_job(job_key)` 唯一索引、`sys_job(status)` 普通索引。 #### Scenario: 新库初始化与存量库迁移后结构一致 - **WHEN** 在空库执行 `db_init.sql` + `db_seed.sql`,或在存量库执行 `database/migrations/20261002_job_management.sql` - **THEN** `sys_job` 表存在且含上述全部列与索引,且迁移脚本可重复执行无报错 #### Scenario: 内置任务种子数据 - **WHEN** 种子数据/迁移执行完成 - **THEN** `sys_job` 中存在 `demo_heartbeat` 与 `log_clean` 两条启用状态记录,重复执行不产生重复行(按 `job_key` 幂等) --- ### Requirement: 内置任务注册表 系统 SHALL 通过代码注册表声明内置任务,注册表条目包含:`jobKey`、`jobName`、`description`、`defaultCron`、`defaultParams`、`paramFields`(可编辑参数白名单:key / label / 类型 / 最小值 / 最大值 / 默认值)、`handler(params, context)`。 #### Scenario: 新增内置任务 - **WHEN** 开发者在注册表中新增一个任务条目并在迁移/种子中插入对应 `sys_job` 行 - **THEN** 页面列表自动出现该任务,无需改动前端代码(参数表单由 `paramFields` 驱动渲染) #### Scenario: job_key 未注册 - **WHEN** 列表中的任务 `job_key` 在注册表中不存在 - **THEN** 列表仍展示该行但标记为「未注册」,编辑/启停/立即执行均返回 400 并提示「任务实现不存在」 --- ### Requirement: 自研轻量调度器(动态生效) 系统 SHALL 在应用启动时加载全部 `status = 1` 的 `sys_job` 并注册调度;页面修改 cron / 启停后无需重启即生效。 - 调度器 SHALL 使用 `cron` 包的 `CronJob` 承载定时触发,调度时区固定 `Asia/Shanghai` - 调度器 SHALL 在接收到配置变更(更新/启停)后,对单个任务执行 `stop → 重新注册 → start`(`reload(jobKey)`) - 调度器启动 SHALL 自行捕获异常并重试(最多 3 次、间隔 5 秒),不得因调度初始化失败阻塞应用启动 - 应用停止(`onStop`)SHALL 停止全部定时器 - 列表接口 SHALL 返回由 cron 表达式即时计算的 `nextRunTime`(停用或表达式非法时为 `null`) - 修改 cron 表达式时 SHALL 先校验表达式合法性,非法则返回 400「cron 表达式不合法」 #### Scenario: 启动加载 - **WHEN** 应用启动完成且 `sys_job` 中存在启用任务 - **THEN** 启用任务按各自 cron 被调度执行;停用任务不被调度 #### Scenario: 页面改 cron 即时生效 - **WHEN** 某启用任务的 cron 由 `0 3 * * *` 改为 `*/1 * * * *` 并保存 - **THEN** 无需重启,约 1 分钟内按新表达式触发执行,列表「下次执行时间」同步更新 #### Scenario: 停用任务 - **WHEN** 任务被停用 - **THEN** 定时触发停止,列表「下次执行时间」为 `-`;仍可手动「立即执行」 #### Scenario: 保存非法 cron - **WHEN** 提交 `0 3 * *`(字段不足)或非法字符 - **THEN** 接口返回 400 且 message 明确提示表达式不合法,配置未被写入 --- ### Requirement: 任务执行、并发控制与可配置重试 系统 SHALL 按「一次执行一条日志」的粒度记录任务执行,并对同一任务做重入保护。 - 同一 `job_key` 正在执行时再次被触发:本次**跳过**,写一条 `status = 3`(跳过)日志,`message` 为「上一次执行尚未结束,本次跳过」,不执行 handler - 每次执行 SHALL 记录:任务、触发方式(1 定时自动 / 2 手动触发 / 3 失败重试)、尝试次数 `attempt`、开始/结束时间、耗时、状态、结果摘要、异常堆栈(截断存储) - 执行失败时:若 `attempt <= retry_times`,则在 `retry_interval` 秒后以「失败重试」触发方式再次执行(`attempt + 1`),重试期间该任务仍视为执行中;超出重试次数则本次执行以失败收尾 - 执行完成后 SHALL 回写任务的 `last_run_at` / `last_run_status` / `last_run_cost_ms` / `last_error` #### Scenario: 执行成功 - **WHEN** 任务执行完成且无异常 - **THEN** 生成一条 `status = 1` 日志,`cost_ms` 有值,`sys_job.last_run_status = 1` 且 `last_error` 被清空 #### Scenario: 失败按配置重试 - **WHEN** 任务 `retry_times = 2`、`retry_interval = 30`,首次执行抛异常 - **THEN** 30 秒后自动重试(日志 `trigger_type = 3`、`attempt = 2`),最多重试到 `attempt = 3`;全部失败后 `sys_job.last_run_status = 2` 并记录最后一次错误 #### Scenario: 并发跳过 - **WHEN** 上一次执行未结束时该任务被再次触发 - **THEN** 不并发执行,新增一条 `status = 3` 的跳过日志 --- ### Requirement: 立即执行一次 系统 SHALL 支持在页面手动触发任务。 - `POST /api/jobs/:id/run` SHALL 同步执行任务并返回本次执行结果(状态、耗时、摘要/错误) - 手动执行不受任务启停状态限制(停用任务也可手动执行) #### Scenario: 手动执行成功 - **WHEN** 对停用任务点击「立即执行」 - **THEN** 接口返回 200 与执行结果,新增一条 `trigger_type = 2` 的日志,列表「上次执行」信息刷新 #### Scenario: 手动执行失败 - **WHEN** 手动执行时任务抛异常且已配置重试 - **THEN** 与定时触发一致地按配置重试,接口最终返回失败结果与错误信息 --- ### Requirement: 执行日志持久化与查询(`sys_job_log`) 系统 SHALL 提供 `sys_job_log` 表记录执行日志,并提供分页查询与手动清理。 字段:`id`、`job_id`、`job_key`、`job_name`、`trigger_type`、`status`、`attempt`、`started_at`、`finished_at`、`cost_ms`、`message`(TEXT)、`error_stack`(TEXT,截断至 4000 字符)、`created_at`;索引:`sys_job_log(job_id, created_at DESC)`、`sys_job_log(created_at)`。 - `GET /api/jobs/:id/logs`:分页,支持 `page`、`pageSize`、`status`、`triggerType`、`startDate`、`endDate` 筛选 - `DELETE /api/jobs/logs/clear?days=90`:清理早于 `days` 天的任务日志,返回删除条数(与现有 `/api/logs/clear` 风格一致) #### Scenario: 日志分页查询 - **WHEN** 在页面日志抽屉中按状态「失败」筛选 - **THEN** 仅返回该任务 `status = 2` 的日志,按 `created_at` 倒序分页 #### Scenario: 手动清理日志 - **WHEN** 调用清理接口且 `days = 90` - **THEN** 早于 90 天的任务日志被删除,返回删除条数,当前日志不受影响 --- ### Requirement: 内置任务「示例心跳」(`demo_heartbeat`) 系统 SHALL 内置一个用于验证调度链路的示例任务:默认 cron 为 `*/5 * * * *`(每 5 分钟),默认参数 `{ "message": "heartbeat" }`,handler 写入一条 `info` 级日志并以摘要形式返回本次心跳时间。该任务默认启用。 #### Scenario: 心跳按周期执行 - **WHEN** 应用启动且该任务处于启用状态 - **THEN** 每 5 分钟产生一条成功执行日志,可在页面日志抽屉中查到 --- ### Requirement: 内置任务「日志清理」(`log_clean`) 系统 SHALL 内置日志清理任务:默认 cron 为 `0 3 * * *`(每日 03:00),默认参数 `{ "keepDays": 90 }`,保留天数可在页面调整(白名单参数,范围 1-3650)。 handler SHALL 同时清理: 1. `sys_operation_log` 中早于 `keepDays` 的记录(复用 `LogService.cleanExpiredLogs`) 2. `sys_job_log` 中早于 `keepDays` 的记录 执行结果摘要 SHALL 形如「清理操作日志 12 条、任务日志 3 条」。 #### Scenario: 按保留天数清理 - **WHEN** 任务以参数 `keepDays = 90` 执行 - **THEN** 两张日志表中 90 天前的记录被删除,摘要中分别给出删除条数 #### Scenario: 参数越界校验 - **WHEN** 页面把 `keepDays` 改为 `0` 或 `5000` - **THEN** 接口返回 400,提示取值范围(1-3650),配置未被写入 --- ### Requirement: 菜单与权限 系统 SHALL 在「系统管理」下新增定时任务菜单与按钮权限: | id | parent_id | 名称 | 路径 | 组件 | 权限码 | 类型 | | --- | --- | --- | --- | --- | --- | --- | | 47 | 6 | 定时任务 | `/system/job` | `system/job/index` | `system:job` | 2 菜单 | | 48 | 47 | 编辑定时任务 | NULL | NULL | `system:job:update` | 3 按钮 | | 49 | 47 | 立即执行 | NULL | NULL | `system:job:run` | 3 按钮 | | 50 | 47 | 清理日志 | NULL | NULL | `system:job:log:clear` | 3 按钮 | - 菜单 id 从 47 起,避开 `add-om-pricing-and-worker-registry` 已占用的 34-46 - ADMIN 角色(`sys_role.id = 1`)SHALL 绑定上述 4 个菜单;`sys_menu_id_seq` SHALL 重设为 50 - `PERMISSION_MAP` SHALL 新增 `'/api/jobs': 'system:job'` #### Scenario: 菜单可见性 - **WHEN** 以 `admin` 登录 - **THEN** 侧边栏「系统管理」下可见「定时任务」,点击进入 `/system/job` #### Scenario: 权限拦截 - **WHEN** 非 ADMIN 且未被授予 `system:job` 的用户访问 `/api/jobs` - **THEN** 返回 403 --- ### Requirement: 前端「定时任务」页面 系统 SHALL 提供 `client/src/views/system/job/index.vue`,路由 `/system/job`(`name: SystemJob`,`meta.permission = 'system:job'`),交互包含: 1. **筛选区**:任务名称(模糊)、状态(启用/停用) 2. **任务表格**:任务名称、任务键、cron 表达式、参数摘要、状态开关、上次执行时间/状态/耗时、下次执行时间、重试配置(次数 × 间隔)、操作列 3. **编辑弹窗**:cron 表达式、按 `paramFields` 渲染的参数表单项、重试次数、重试间隔、备注;保存前做前端必填与范围校验 4. **立即执行**:二次确认后调用执行接口,成功/失败以 `message` 提示,随后刷新列表与日志 5. **执行日志抽屉**:分页表格(触发方式、状态、尝试次数、耗时、开始时间、摘要/错误),支持按状态筛选与「清理日志」按钮(需二次确认,默认清理 90 天前) #### Scenario: 编辑并保存 cron - **WHEN** 在编辑弹窗修改 cron 与参数并保存 - **THEN** 弹窗关闭、列表刷新显示新值,后端返回错误(如非法 cron / 参数越界)时弹窗保留并提示 message #### Scenario: 查看执行日志 - **WHEN** 点击某任务的「执行日志」 - **THEN** 抽屉打开并加载该任务日志首页,可翻页与筛选 #### Scenario: 前端未使用 v-html - **WHEN** 检查页面实现 - **THEN** 无 `v-html`,cron 与参数输入均有长度/范围校验 --- ## MODIFIED Requirements ### Requirement: RBAC 路径权限映射 [`server/src/middleware/rbac.middleware.ts`](file:///d:/MyProject/智能电网/smart_grid_iot/server/src/middleware/rbac.middleware.ts#L10-L29) 的 `PERMISSION_MAP` 在保持既有映射不变的前提下,新增: ``` '/api/jobs': 'system:job', ``` 字典 key 顺序需保证更具体的前缀优先匹配(`/api/jobs` 与其子路径共用同一权限码,无冲突)。 ### Requirement: 系统管理菜单 「系统管理」(`sys_menu.id = 6`)子菜单由 5 个扩展为 6 个,新增 id = 47 的「定时任务」;新增菜单不得改变既有 id 7-11 的名称、路径、排序(1-5),新菜单 `sort_order = 6`。 --- ## REMOVED Requirements 无。 --- ## 关键文件清单(供实现参考) | 层 | 文件 | | --- | --- | | 迁移 | `database/migrations/20261002_job_management.sql` | | 种子 | `database/db_seed.sql`(新增菜单 47-50、ADMIN 绑定、2 条 `sys_job` 种子、`sys_menu_id_seq` → 50) | | 实体 | `server/src/entity/sys_job.entity.ts`、`server/src/entity/sys_job_log.entity.ts` | | 注册表 | `server/src/task/job_registry.ts`(内置任务声明)、`server/src/task/demo_heartbeat.task.ts`、`server/src/task/log_clean.task.ts` | | 调度器 | `server/src/service/job_scheduler.service.ts` | | 业务 | `server/src/service/job.service.ts` | | 接口 | `server/src/controller/job.controller.ts`、`server/src/dto/job.dto.ts` | | 装配 | `server/src/configuration.ts`(onReady 启动调度、onStop 停止调度)、`server/src/middleware/rbac.middleware.ts`、`server/package.json`(显式声明 `cron`) | | 前端 | `client/src/api/job.ts`、`client/src/views/system/job/index.vue`、`client/src/router/index.ts` |