feat: 新增电工APP端与定时任务管理功能
- 新增电工APP独立鉴权中间件与登录注册接口 - 新增定时任务管理模块,支持任务配置、执行日志与手动触发 - 新增运维费定价与电工派单成本价目管理 - 新增电工资质证照管理与到期提醒定时任务 - 完善数据库实体与DTO校验,优化权限控制
This commit is contained in:
1 parent
1803ad679c
commit
f4454ad014
57 files changed
+9388
-200
No files matched your search
@@ -0,0 +1,64 @@
|
||||
# Checklist
|
||||
|
||||
## 数据库与种子
|
||||
|
||||
- [x] `database/migrations/20261002_job_management.sql` 已创建,建 `sys_job` 与 `sys_job_log` 两表及索引,可重复执行无报错
|
||||
- [x] `sys_job` 含 `job_key` 唯一索引与 `status` 索引;`params` 为 JSONB 且默认 `{}`
|
||||
- [x] `sys_job_log` 含 `(job_id, created_at DESC)` 与 `created_at` 索引
|
||||
- [x] 迁移写入菜单 id 47(定时任务,parent_id=6,`/system/job`,`system:job`,menu_type=2,sort_order=6)与 48/49/50 三个按钮权限(parent_id=47,menu_type=3)
|
||||
- [x] ADMIN 角色(role_id=1)已绑定菜单 47-50;`sys_menu_id_seq` 被设为 50
|
||||
- [x] 迁移写入 `demo_heartbeat`(`*/5 * * * *`)与 `log_clean`(`0 3 * * *`,retry_times=2、retry_interval=60)两条 `sys_job` 种子,重复执行不产生重复行
|
||||
- [x] `database/db_seed.sql` 已同步菜单 47-50、ADMIN 绑定、2 条 `sys_job` 种子与 `sys_menu_id_seq`=50;新库初始化与迁移后结构一致
|
||||
- [x] 既有菜单 id 7-11 的名称、路径、排序未被改动
|
||||
|
||||
## 后端
|
||||
|
||||
- [x] `server/package.json` 已显式声明 `cron` 依赖,`yarn dev` 可正常编译启动
|
||||
- [x] 内置任务注册表 `server/src/task/job_registry.ts` 提供 `jobKey/jobName/description/defaultCron/defaultParams/paramFields/handler`,页面参数表单由 `paramFields` 驱动,无需前端硬编码
|
||||
- [x] `demo_heartbeat` 与 `log_clean` 两个内置任务均已注册,`job_key` 与种子数据一致
|
||||
- [x] 调度器启动加载全部启用任务,停用任务不被调度
|
||||
- [x] 调度初始化失败时不阻塞应用启动(try/catch + 最多 3 次重试),`onStop` 停止全部定时器
|
||||
- [x] 页面修改 cron 后无需重启即按新表达式触发,列表 `nextRunTime` 同步更新
|
||||
- [x] 提交非法 cron 表达式返回 400 且提示明确,配置未落库
|
||||
- [x] 停用任务后定时触发停止,`nextRunTime` 为 `null`/`-`,但仍可手动执行
|
||||
- [x] 同一任务执行中再次触发时写 `status = 3` 跳过日志且不并发执行
|
||||
- [x] 失败且 `attempt <= retry_times` 时按 `retry_interval` 秒后以 `trigger_type = 3` 重试,`attempt` 递增
|
||||
- [x] `sys_job.last_run_at / last_run_status / last_run_cost_ms / last_error` 每次执行后正确回写(成功时清空 `last_error`)
|
||||
- [x] `log_clean` 同时清理 `sys_operation_log` 与 `sys_job_log`,摘要含两张表删除条数,默认 `keepDays = 90`
|
||||
- [x] `keepDays` 越界(如 0 或 5000)返回 400,配置未落库
|
||||
- [x] `GET /api/jobs` 支持任务名称模糊与状态筛选,返回分页结构并携带 `paramFields`、`nextRunTime`、上次执行信息
|
||||
- [x] `PUT /api/jobs/:id`、`PUT /api/jobs/:id/status`、`POST /api/jobs/:id/run`、`GET /api/jobs/:id/logs`、`DELETE /api/jobs/logs/clear` 均可用且返回统一结构
|
||||
- [x] 静态路由 `/api/jobs/logs/clear` 未被 `:id` 动态路由吞掉
|
||||
- [x] `error_stack` 落库被截断至 4000 字符,`message` 摘要可读
|
||||
- [x] 未注册的 `job_key` 在页面标记为未注册,编辑/启停/执行返回 400「任务实现不存在」
|
||||
- [x] `PERMISSION_MAP` 已新增 `'/api/jobs': 'system:job'`,非 ADMIN 无该权限时访问返回 403
|
||||
- [x] 所有查询/更新走 Sequelize 参数化 API,无字符串拼接 SQL 与模板字符串 LIKE
|
||||
- [x] 控制器入参使用 DTO + `class-validator`,未滥用 `any`(除既有约定处)
|
||||
- [x] 列表接口均使用 `limit + offset` 分页,无全表扫描式查询
|
||||
- [x] 调度器为进程内单例(`@Scope(ScopeEnum.Singleton)`),保证「启动注册」与「接口重载」操作同一个定时器注册表
|
||||
|
||||
## 前端
|
||||
|
||||
- [x] `client/src/api/job.ts` 提供 6 个接口方法与 TS 类型
|
||||
- [x] `client/src/views/system/job/index.vue` 含搜索栏 + 表格 + 编辑弹窗 + 立即执行二次确认 + 执行日志抽屉
|
||||
- [x] 表格列包含任务名/键、cron、参数摘要、状态开关、上次执行时间与状态与耗时、下次执行时间、重试配置
|
||||
- [x] 编辑弹窗中 cron、参数、重试次数、重试间隔可编辑,且保存前后端校验失败时弹窗保留并提示 message
|
||||
- [x] 执行日志抽屉支持分页、状态筛选与「清理日志」二次确认(默认清理 90 天前)
|
||||
- [x] 路由 `/system/job` 已注册,`meta.permission = 'system:job'`
|
||||
- [x] 侧边栏「系统管理」下可见「定时任务」(ADMIN),非 ADMIN 访问跳转 403
|
||||
- [x] 页面未使用 `v-html`,cron 与参数输入含长度/范围校验
|
||||
- [x] 页面结构与既有页面(操作日志 / 数据字典)保持一致,未引入多余 UI 元素
|
||||
|
||||
## 联调
|
||||
|
||||
- [x] 后端 `yarn dev` 启动无编译错误,接口逐条通过
|
||||
- [x] 前端 `yarn dev` 启动,编辑 → 立即执行 → 查看日志 → 清理日志 全链路可用
|
||||
- [x] 改 cron 即时生效与启停即时生效已验证
|
||||
- [x] 重试机制(`trigger_type = 3`、`attempt` 递增)已验证
|
||||
- [x] 迁移脚本与 `db_seed.sql` 在空库与存量库两端结果一致
|
||||
- [x] 隔离单实例复测:`*/1` 每分钟恰好触发 1 次、停用后 0 次触发、手动执行正常、恢复后 `nextRunTime` 正确(见 tasks.md Task 8)
|
||||
|
||||
## 遗留说明(非阻塞)
|
||||
|
||||
- `sys_job.job_key` 同时存在列级 `UNIQUE` 自动生成的 `sys_job_job_key_key` 与显式 `uk_sys_job_key`,两个索引功能重复(冗余但无害,不影响验收)
|
||||
- 非法入参返回的是响应体 `code=400`,HTTP 状态保持 200,与仓库既有 26 个控制器的约定一致
|
||||
@@ -0,0 +1,322 @@
|
||||
# 定时任务管理 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` |
|
||||
@@ -0,0 +1,76 @@
|
||||
# Tasks
|
||||
|
||||
> 规格:`.trae/specs/add-scheduled-job-management/spec.md`
|
||||
> 说明:任务清单中「内置任务注册表」是本规格的核心抽象,Task 4 依赖它;Task 5-7 可并行。
|
||||
|
||||
- [x] Task 1: 数据库迁移与种子数据(全部后端任务的前置)
|
||||
- [x] SubTask 1.1: 新建 `database/migrations/20261002_job_management.sql`:创建 `sys_job`(含 `job_key` 唯一索引、`status` 索引)与 `sys_job_log`(含 `(job_id, created_at DESC)`、`created_at` 索引)两张表,风格对齐现有迁移(注释、幂等 `IF NOT EXISTS`)
|
||||
- [x] SubTask 1.2: 同一迁移内写入菜单:`sys_menu` 新增 id 47(定时任务,parent_id=6,menu_path `/system/job`,component `system/job/index`,permission `system:job`,menu_type=2,sort_order=6)、48/49/50(按钮权限 `system:job:update` / `system:job:run` / `system:job:log:clear`,parent_id=47,menu_type=3),使用 `ON CONFLICT (id) DO UPDATE` 幂等;为 ADMIN 角色(role_id=1)插入 `sys_role_menu` 绑定;`setval('sys_menu_id_seq', 50, true)`
|
||||
- [x] SubTask 1.3: 同一迁移内写入 `sys_job` 种子:`demo_heartbeat`(`*/5 * * * *`,params `{"message":"heartbeat"}`,status=1)与 `log_clean`(`0 3 * * *`,params `{"keepDays":90}`,status=1,retry_times=2、retry_interval=60),使用 `ON CONFLICT (job_key) DO NOTHING`
|
||||
- [x] SubTask 1.4: 同步修订 `database/db_seed.sql`:在现有菜单段落之后追加 47-50 菜单与 ADMIN 绑定、2 条 `sys_job` 种子,更新 `sys_menu_id_seq` 为 50 与文件头部注释中的菜单计数
|
||||
- [x] SubTask 1.5: 执行迁移脚本两次验证幂等(第二次无报错、无重复菜单/任务行)
|
||||
|
||||
- [x] Task 2: 实体层
|
||||
- [x] SubTask 2.1: 新建 `server/src/entity/sys_job.entity.ts`(列与 spec 中 `sys_job` 定义一致,`params` 用 `DataType.JSONB`,`timestamps: true`,字段名用 `field` 显式声明 snake_case,参考 `sysOperationLog.entity.ts` 风格)
|
||||
- [x] SubTask 2.2: 新建 `server/src/entity/sys_job_log.entity.ts`(`timestamps: true` 且仅 `created_at`,与 `sysOperationLog` 一致)
|
||||
|
||||
- [x] Task 3: 内置任务注册表与两个内置任务(依赖 Task 2)
|
||||
- [x] SubTask 3.1: `server/package.json` 显式声明 `"cron": "^3.5.0"` 并执行 `yarn install`(该版本已在 `node_modules` 与 `yarn.lock` 中存在,来自 `@midwayjs/cron` 传递依赖)
|
||||
- [x] SubTask 3.2: 新建 `server/src/task/job_registry.ts`:定义 `BuiltinJob` 类型(jobKey / jobName / description / defaultCron / defaultParams / paramFields / handler)与 `BUILTIN_JOBS` 数组、`getBuiltinJob(jobKey)`;`paramFields` 支持 `number` 类型与 min/max
|
||||
- [x] SubTask 3.3: 新建 `server/src/task/demo_heartbeat.task.ts`:`jobKey = demo_heartbeat`,默认 cron `*/5 * * * *`,参数 `message`(string,可选),handler 写 info 日志并返回「心跳时间」摘要
|
||||
- [x] SubTask 3.4: 新建 `server/src/task/log_clean.task.ts`:`jobKey = log_clean`,默认 cron `0 3 * * *`,参数 `keepDays`(number,1-3650,默认 90),handler 调用 `LogService.cleanExpiredLogs(keepDays)` 清理 `sys_operation_log`,再删除 `sys_job_log` 中早于 `keepDays` 的记录,返回「清理操作日志 X 条、任务日志 Y 条」
|
||||
- [x] SubTask 3.5: 在 `job_registry.ts` 中把两个任务注册进 `BUILTIN_JOBS`
|
||||
|
||||
- [x] Task 4: 调度器(依赖 Task 2、3)
|
||||
- [x] SubTask 4.1: 新建 `server/src/service/job_scheduler.service.ts`:
|
||||
- `start()`:加载 `status=1` 的 `sys_job` → 按 `job_key` 匹配注册表 → 用 `cron` 包 `CronJob`(timezone `Asia/Shanghai`)注册并 `start()`;内部 try/catch + 最多 3 次重试(间隔 5s),失败仅记录日志不阻塞启动
|
||||
- `stop()`:停止全部定时器(应用停止时调用)
|
||||
- `reload(jobKey)`:停旧 → 按最新配置重新注册(停用则不注册)
|
||||
- `runOnce(jobKey, triggerType)`:导出给 controller 的立即执行入口,返回执行结果
|
||||
- `nextRunTime(cronExpression)`:用 cron 表达式计算下次执行时间,非法或空返回 `null`
|
||||
- `validateCron(expression)`:校验表达式合法性
|
||||
- [x] SubTask 4.2: 实现执行内核:进程内 `Set<string>` 重入锁 → 未注册任务返回「任务实现不存在」→ 写入 `sys_job_log`(`trigger_type`、`attempt`、`started_at`)→ 执行 handler → 回写日志 `status`/`finished_at`/`cost_ms`/`message`/`error_stack`(截断 4000 字符)→ 回写 `sys_job` 的 `last_run_at`/`last_run_status`/`last_run_cost_ms`/`last_error`;并发触发时写 `status = 3` 跳过日志
|
||||
- [x] SubTask 4.3: 实现失败重试:`attempt <= retry_times` 时用 `setTimeout(retry_interval * 1000)` 以 `trigger_type = 3`、`attempt + 1` 再次执行,重试期间保持重入锁;重试链结束仍失败则以 `last_run_status = 2` 收尾
|
||||
- [x] SubTask 4.4: 修改 `server/src/configuration.ts`:`onReady` 中获取并 `start()` 调度服务(捕获异常),`onStop` 中 `stop()`;保持不变更其它已有逻辑
|
||||
|
||||
- [x] Task 5: 业务服务与接口(依赖 Task 4)
|
||||
- [x] SubTask 5.1: 新建 `server/src/dto/job.dto.ts`:`UpdateJobDTO`(cronExpression、params、retryTimes、retryInterval、remark,`class-validator` 校验)、`JobLogQueryDTO`(page/pageSize/status/triggerType/startDate/endDate)
|
||||
- [x] SubTask 5.2: 新建 `server/src/service/job.service.ts`:
|
||||
- `list(query)`:按任务名称模糊 + 状态筛选,返回 `list/total/page/pageSize`,每行附带注册表信息(`jobName`、`description`、`paramFields`、`registered`)、`nextRunTime`(由调度器计算)、上次执行信息
|
||||
- `update(id, dto)`:校验存在性与已注册 → 校验 cron 合法性 → 按 `paramFields` 白名单与范围校验 params(越界返回 400)→ 保存 → `scheduler.reload(jobKey)`
|
||||
- `changeStatus(id, status)`:切换启停 → `scheduler.reload(jobKey)`
|
||||
- `run(id)`:调用 `scheduler.runOnce(jobKey, 2)`
|
||||
- `listLogs(id, query)`:分页 + 状态/触发方式/时间范围筛选,`created_at DESC`
|
||||
- `clearLogs(days = 90)`:删除早于 `days` 天的 `sys_job_log`
|
||||
- [x] SubTask 5.3: 新建 `server/src/controller/job.controller.ts`(`/api/jobs`,注意静态路径写在动态路径之前):
|
||||
- `GET /api/jobs`、`PUT /api/jobs/:id`、`PUT /api/jobs/:id/status`、`POST /api/jobs/:id/run`
|
||||
- `GET /api/jobs/:id/logs`、`DELETE /api/jobs/logs/clear`
|
||||
- 返回统一 `{ code, message, data }`,400/404 场景给出明确 message
|
||||
- [x] SubTask 5.4: 修改 `server/src/middleware/rbac.middleware.ts`:`PERMISSION_MAP` 新增 `'/api/jobs': 'system:job'`
|
||||
|
||||
- [x] Task 6: 前端 API 与页面(依赖 Task 5)
|
||||
- [x] SubTask 6.1: 新建 `client/src/api/job.ts`(`getJobList` / `updateJob` / `changeJobStatus` / `runJob` / `getJobLogs` / `clearJobLogs`,含 TS 类型,参考 `api/log.ts` 风格)
|
||||
- [x] SubTask 6.2: 新建 `client/src/views/system/job/index.vue`:搜索栏(任务名称、状态)+ 任务表格(任务名/键、cron、参数摘要、状态开关、上次执行时间与状态与耗时、下次执行时间、重试配置、操作)+ 编辑弹窗(cron、按 `paramFields` 动态渲染的参数项、重试次数、重试间隔、备注)+ 立即执行二次确认 + 执行日志抽屉(分页表格、状态筛选、清理日志按钮),保持与 `views/log/operation/index.vue` 一致的页面结构
|
||||
- [x] SubTask 6.3: 修改 `client/src/router/index.ts`:在系统管理下新增路由 `system/job`(`name: SystemJob`,`meta: { requiresAuth: true, title: '定时任务', permission: 'system:job' }`)
|
||||
|
||||
- [x] Task 7: 联调与验证(依赖 Task 1-6)
|
||||
- [x] SubTask 7.1: 后端 `yarn dev` 启动无编译错误;`curl` 验证列表/更新/启停/立即执行/日志查询/日志清理接口
|
||||
- [x] SubTask 7.2: 将 `demo_heartbeat` 改为 `*/1 * * * *` 验证「改 cron 即时生效」与「下次执行时间」更新;停用后验证不再触发、恢复启用后重新调度(首轮验证失败 → 见 Task 8;修复后复测:每分钟恰好触发 1 次、停用后 0 次触发、手动执行正常、恢复后 `nextRunTime` 正确)
|
||||
- [x] SubTask 7.3: 验证重试:临时构造失败场景(如 `log_clean` 传入非法参数不可行时,用 `retry_times`/`retry_interval` 观测日志 `trigger_type = 3` 与 `attempt` 递增)
|
||||
- [x] SubTask 7.4: 前端 `yarn dev` 启动,人工走通「编辑 → 立即执行 → 查看日志 → 清理日志」全链路,并确认侧边栏「系统管理 → 定时任务」可见(实测:侧边栏菜单可见、`/system/job` 正常进入、两行任务渲染正确、编辑保存成功、立即执行二次确认与成功提示、日志抽屉含手动触发的记录并带分页、清理日志二次确认成功;新标签页控制台无 error)
|
||||
- [x] SubTask 7.5: 确认迁移脚本与 `db_seed.sql` 在空库/存量库两端结果一致,且 `sys_menu_id_seq` 为 50
|
||||
|
||||
- [x] Task 8: 修复调度器作用域缺陷(Task 7.2 首轮验证发现的真实缺陷)
|
||||
- [x] SubTask 8.1: 定位根因 —— `@Provide()` 在本项目 Midway 版本中默认解析为 **Request 作用域**(`@midwayjs/core` 的 `container.bind()` 对 class 目标亦执行 `definition.scope = options?.scope || ScopeEnum.Request`),导致「启动注册定时器」与「接口 `reload`」落在不同服务实例上:`reload` 所在实例的 `timers` 为空,既停不掉旧定时器,又新建一个定时器,每次保存/启停都泄漏一个常驻定时器(实测同一分钟内出现 2 次以上重复触发,且停用后仍持续触发)
|
||||
- [x] SubTask 8.2: 为 `JobSchedulerService` 显式声明 `@Scope(ScopeEnum.Singleton)`,使定时器注册表在启动注册与接口重载之间共享(`@InjectRepository` 由单例数据源管理器解析,无需请求上下文,可安全用于单例)
|
||||
- [x] SubTask 8.3: `LogService` 同为请求作用域,单例中不可直接 `@Inject`;改为在 `buildRuntime()` 内通过 `this.app.createAnonymousContext().requestContext.getAsync(LogService)` 按需获取(对齐 `@midwayjs/cron` 官方执行任务的上下文用法),注入 `@App()` 替换原 `@Inject() logService`
|
||||
- [x] SubTask 8.4: 清理定位期间加入的临时诊断日志,`npx tsc --noEmit` 通过,并在隔离单实例(7009)下复测 7.2 全部通过
|
||||
|
||||
# Task Dependencies
|
||||
|
||||
- Task 2 依赖 Task 1(表结构存在后才能通过实体读写)
|
||||
- Task 3 依赖 Task 2;Task 4 依赖 Task 2、3
|
||||
- Task 5 依赖 Task 4;Task 6 依赖 Task 5
|
||||
- Task 7 依赖 Task 1-6
|
||||
- Task 8 依赖 Task 7.2 的首轮失败验证;Task 8 完成后需回归 Task 7.2
|
||||
- 并行建议:Task 1 完成后,Task 2/3 与前端 Task 6.1(API 层)可并行;Task 5 与 Task 6.2/6.3 可在接口契约冻结后并行
|
||||
Reference in new issue
Block a user