- 新增电工APP独立鉴权中间件与登录注册接口 - 新增定时任务管理模块,支持任务配置、执行日志与手动触发 - 新增运维费定价与电工派单成本价目管理 - 新增电工资质证照管理与到期提醒定时任务 - 完善数据库实体与DTO校验,优化权限控制
11 KiB
11 KiB
Tasks
规格:
.trae/specs/add-scheduled-job-management/spec.md说明:任务清单中「内置任务注册表」是本规格的核心抽象,Task 4 依赖它;Task 5-7 可并行。
-
Task 1: 数据库迁移与种子数据(全部后端任务的前置)
- 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) - SubTask 1.2: 同一迁移内写入菜单:
sys_menu新增 id 47(定时任务,parent_id=6,menu_path/system/job,componentsystem/job/index,permissionsystem: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) - 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 - SubTask 1.4: 同步修订
database/db_seed.sql:在现有菜单段落之后追加 47-50 菜单与 ADMIN 绑定、2 条sys_job种子,更新sys_menu_id_seq为 50 与文件头部注释中的菜单计数 - SubTask 1.5: 执行迁移脚本两次验证幂等(第二次无报错、无重复菜单/任务行)
- SubTask 1.1: 新建
-
Task 2: 实体层
- SubTask 2.1: 新建
server/src/entity/sys_job.entity.ts(列与 spec 中sys_job定义一致,params用DataType.JSONB,timestamps: true,字段名用field显式声明 snake_case,参考sysOperationLog.entity.ts风格) - SubTask 2.2: 新建
server/src/entity/sys_job_log.entity.ts(timestamps: true且仅created_at,与sysOperationLog一致)
- SubTask 2.1: 新建
-
Task 3: 内置任务注册表与两个内置任务(依赖 Task 2)
- SubTask 3.1:
server/package.json显式声明"cron": "^3.5.0"并执行yarn install(该版本已在node_modules与yarn.lock中存在,来自@midwayjs/cron传递依赖) - 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 - SubTask 3.3: 新建
server/src/task/demo_heartbeat.task.ts:jobKey = demo_heartbeat,默认 cron*/5 * * * *,参数message(string,可选),handler 写 info 日志并返回「心跳时间」摘要 - SubTask 3.4: 新建
server/src/task/log_clean.task.ts:jobKey = log_clean,默认 cron0 3 * * *,参数keepDays(number,1-3650,默认 90),handler 调用LogService.cleanExpiredLogs(keepDays)清理sys_operation_log,再删除sys_job_log中早于keepDays的记录,返回「清理操作日志 X 条、任务日志 Y 条」 - SubTask 3.5: 在
job_registry.ts中把两个任务注册进BUILTIN_JOBS
- SubTask 3.1:
-
Task 4: 调度器(依赖 Task 2、3)
- SubTask 4.1: 新建
server/src/service/job_scheduler.service.ts:start():加载status=1的sys_job→ 按job_key匹配注册表 → 用cron包CronJob(timezoneAsia/Shanghai)注册并start();内部 try/catch + 最多 3 次重试(间隔 5s),失败仅记录日志不阻塞启动stop():停止全部定时器(应用停止时调用)reload(jobKey):停旧 → 按最新配置重新注册(停用则不注册)runOnce(jobKey, triggerType):导出给 controller 的立即执行入口,返回执行结果nextRunTime(cronExpression):用 cron 表达式计算下次执行时间,非法或空返回nullvalidateCron(expression):校验表达式合法性
- 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跳过日志 - SubTask 4.3: 实现失败重试:
attempt <= retry_times时用setTimeout(retry_interval * 1000)以trigger_type = 3、attempt + 1再次执行,重试期间保持重入锁;重试链结束仍失败则以last_run_status = 2收尾 - SubTask 4.4: 修改
server/src/configuration.ts:onReady中获取并start()调度服务(捕获异常),onStop中stop();保持不变更其它已有逻辑
- SubTask 4.1: 新建
-
Task 5: 业务服务与接口(依赖 Task 4)
- SubTask 5.1: 新建
server/src/dto/job.dto.ts:UpdateJobDTO(cronExpression、params、retryTimes、retryInterval、remark,class-validator校验)、JobLogQueryDTO(page/pageSize/status/triggerType/startDate/endDate) - 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 DESCclearLogs(days = 90):删除早于days天的sys_job_log
- 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/runGET /api/jobs/:id/logs、DELETE /api/jobs/logs/clear- 返回统一
{ code, message, data },400/404 场景给出明确 message
- SubTask 5.4: 修改
server/src/middleware/rbac.middleware.ts:PERMISSION_MAP新增'/api/jobs': 'system:job'
- SubTask 5.1: 新建
-
Task 6: 前端 API 与页面(依赖 Task 5)
- SubTask 6.1: 新建
client/src/api/job.ts(getJobList/updateJob/changeJobStatus/runJob/getJobLogs/clearJobLogs,含 TS 类型,参考api/log.ts风格) - SubTask 6.2: 新建
client/src/views/system/job/index.vue:搜索栏(任务名称、状态)+ 任务表格(任务名/键、cron、参数摘要、状态开关、上次执行时间与状态与耗时、下次执行时间、重试配置、操作)+ 编辑弹窗(cron、按paramFields动态渲染的参数项、重试次数、重试间隔、备注)+ 立即执行二次确认 + 执行日志抽屉(分页表格、状态筛选、清理日志按钮),保持与views/log/operation/index.vue一致的页面结构 - SubTask 6.3: 修改
client/src/router/index.ts:在系统管理下新增路由system/job(name: SystemJob,meta: { requiresAuth: true, title: '定时任务', permission: 'system:job' })
- SubTask 6.1: 新建
-
Task 7: 联调与验证(依赖 Task 1-6)
- SubTask 7.1: 后端
yarn dev启动无编译错误;curl验证列表/更新/启停/立即执行/日志查询/日志清理接口 - SubTask 7.2: 将
demo_heartbeat改为*/1 * * * *验证「改 cron 即时生效」与「下次执行时间」更新;停用后验证不再触发、恢复启用后重新调度(首轮验证失败 → 见 Task 8;修复后复测:每分钟恰好触发 1 次、停用后 0 次触发、手动执行正常、恢复后nextRunTime正确) - SubTask 7.3: 验证重试:临时构造失败场景(如
log_clean传入非法参数不可行时,用retry_times/retry_interval观测日志trigger_type = 3与attempt递增) - SubTask 7.4: 前端
yarn dev启动,人工走通「编辑 → 立即执行 → 查看日志 → 清理日志」全链路,并确认侧边栏「系统管理 → 定时任务」可见(实测:侧边栏菜单可见、/system/job正常进入、两行任务渲染正确、编辑保存成功、立即执行二次确认与成功提示、日志抽屉含手动触发的记录并带分页、清理日志二次确认成功;新标签页控制台无 error) - SubTask 7.5: 确认迁移脚本与
db_seed.sql在空库/存量库两端结果一致,且sys_menu_id_seq为 50
- SubTask 7.1: 后端
-
Task 8: 修复调度器作用域缺陷(Task 7.2 首轮验证发现的真实缺陷)
- SubTask 8.1: 定位根因 ——
@Provide()在本项目 Midway 版本中默认解析为 Request 作用域(@midwayjs/core的container.bind()对 class 目标亦执行definition.scope = options?.scope || ScopeEnum.Request),导致「启动注册定时器」与「接口reload」落在不同服务实例上:reload所在实例的timers为空,既停不掉旧定时器,又新建一个定时器,每次保存/启停都泄漏一个常驻定时器(实测同一分钟内出现 2 次以上重复触发,且停用后仍持续触发) - SubTask 8.2: 为
JobSchedulerService显式声明@Scope(ScopeEnum.Singleton),使定时器注册表在启动注册与接口重载之间共享(@InjectRepository由单例数据源管理器解析,无需请求上下文,可安全用于单例) - SubTask 8.3:
LogService同为请求作用域,单例中不可直接@Inject;改为在buildRuntime()内通过this.app.createAnonymousContext().requestContext.getAsync(LogService)按需获取(对齐@midwayjs/cron官方执行任务的上下文用法),注入@App()替换原@Inject() logService - SubTask 8.4: 清理定位期间加入的临时诊断日志,
npx tsc --noEmit通过,并在隔离单实例(7009)下复测 7.2 全部通过
- SubTask 8.1: 定位根因 ——
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 可在接口契约冻结后并行