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

19 KiB
Raw Permalink Blame History

定时任务管理 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),建表必须通过 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 的 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