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

77 lines
11 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.
# 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 可在接口契约冻结后并行