Files
andyesfly f4454ad014 feat: 新增电工APP端与定时任务管理功能
- 新增电工APP独立鉴权中间件与登录注册接口
- 新增定时任务管理模块,支持任务配置、执行日志与手动触发
- 新增运维费定价与电工派单成本价目管理
- 新增电工资质证照管理与到期提醒定时任务
- 完善数据库实体与DTO校验,优化权限控制
2026-10-02 16:08:00 +08:00

323 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 定时任务管理 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` |