- 新增电工APP独立鉴权中间件与登录注册接口 - 新增定时任务管理模块,支持任务配置、执行日志与手动触发 - 新增运维费定价与电工派单成本价目管理 - 新增电工资质证照管理与到期提醒定时任务 - 完善数据库实体与DTO校验,优化权限控制
19 KiB
定时任务管理 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
- 后端:
已确认决策(来自需求澄清)
- 形态:内置任务管理——任务清单由代码定义,页面只做启停 / 改 cron / 改参数 / 立即执行 / 看日志;不做「页面任意填写调用目标」的通用调度中心
- 调度引擎:自研轻量调度器(配置持久化在 DB,进程内调度),不使用
@midwayjs/cron注解做静态任务 - 首批任务:框架 + 示例任务 + 清理类任务;ICC 用电数据采集本期不纳入
- 执行日志:独立日志表 + 页面分页查询 + 手动清理
- 参数:有限参数可编辑(按任务注册表声明的字段白名单渲染表单,不开放任意 JSON)
- 失败处理:支持重试,重试次数与重试间隔均可按任务配置
- 日志保留期:操作日志 + 任务日志均默认 90 天,保留天数作为任务参数可在页面调整
假设(如与预期不符请在开工前指出)
- 单实例部署:并发控制使用进程内重入锁,不实现分布式锁(多实例部署时同一任务会在每个实例各触发一次)。这与初始选项描述中的「Redis 锁」有偏差:当前代码库
server/src内没有任何 Redis/ioredis 使用点(仅config.default.ts声明了 redis 配置),引入会使任务运行强依赖 Redis 可用性。 - 不做执行超时强制中断(Node 侧无法安全中断同步逻辑),仅在日志中记录执行耗时。
- 立即执行接口同步返回执行结果(请求内等待任务跑完),适配本期两个秒级内置任务;后续接入长耗时任务时再改异步触发。
- 权限粒度:仅新增菜单级权限
system:job(后端 RBAC 真实生效);3 个按钮权限码只用于前端按钮显隐,后端不做按钮级校验。 - 不提供任务新增/删除:任务清单由代码注册表决定,页面无删除入口。
- 与并行规格共存:
add-om-pricing-and-worker-registry规格正在并行实现,其中用@midwayjs/cron注解实现了电工资质到期扫描(server/src/task/cert_expire.task.ts)。本期不改造它,两套机制并存;后续可把该任务迁入本任务注册表统一管理(本期非目标)。 - 无独立 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),建表必须通过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/runSHALL 同步执行任务并返回本次执行结果(状态、耗时、摘要/错误)- 手动执行不受任务启停状态限制(停用任务也可手动执行)
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 同时清理:
sys_operation_log中早于keepDays的记录(复用LogService.cleanExpiredLogs)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_seqSHALL 重设为 50 PERMISSION_MAPSHALL 新增'/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'),交互包含:
- 筛选区:任务名称(模糊)、状态(启用/停用)
- 任务表格:任务名称、任务键、cron 表达式、参数摘要、状态开关、上次执行时间/状态/耗时、下次执行时间、重试配置(次数 × 间隔)、操作列
- 编辑弹窗:cron 表达式、按
paramFields渲染的参数表单项、重试次数、重试间隔、备注;保存前做前端必填与范围校验 - 立即执行:二次确认后调用执行接口,成功/失败以
message提示,随后刷新列表与日志 - 执行日志抽屉:分页表格(触发方式、状态、尝试次数、耗时、开始时间、摘要/错误),支持按状态筛选与「清理日志」按钮(需二次确认,默认清理 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 的 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 |