跳到主要内容

schedule - 定时任务

基于请求的定时任务,如同定时请求固定接口,可以使用中间件和请求上下文。

依赖模块

  • @zenweb/inject
  • @zenweb/router

快速使用

npm install @zenweb/schedule
src/index.ts
import modSchedule from '@zenweb/schedule';
// ...
.setup(modSchedule())
// ...
src/schedule/echo.ts
import { Schedule } from '@zenweb/schedule';

export class EchoScheduler {
@Schedule('*/1 * * * * *')
echo() {
console.log('task echo');
return 'ok';
}
}

配置项

配置项类型默认值功能
pathsstring[]['./app/schedule']定时任务控制器加载目录
patternsstring**/*.{ts,js}定时任务控制器文件匹配规则
disabledbooleanfalse是否禁用定时器,可以通过环境变量 ZENWEB_SCHEDULE_DISABLED=1 控制
prefixstring/__schedule/内部路由路径前缀

Core 挂载项

挂载项类型功能
scheduleRegisterScheduleRegisterScheduleRegister 实例

Cron 语法参考

定时任务使用标准的 5 位或 6 位 Cron 表达式定义执行规则。

格式说明

┌──────── 秒 (0-59,可选)
│ ┌────── 分钟 (0-59)
│ │ ┌──── 小时 (0-23)
│ │ │ ┌── 日 (1-31)
│ │ │ │ ┌─ 月 (1-12)
│ │ │ │ │ ┌ 星期 (0-7,0 和 7 均为周日)
│ │ │ │ │ │
* * * * * *

常用表达式

表达式说明
* * * * *每分钟执行
*/5 * * * *每 5 分钟执行
0 * * * *每小时整点执行
0 */2 * * *每 2 小时整点执行
0 0 * * *每天零点执行
0 8 * * *每天早上 8 点执行
0 8,18 * * *每天早上 8 点和下午 6 点执行
0 0 * * 1每周一零点执行
0 0 1 * *每月 1 日零点执行
0 0 1 1 *每年 1 月 1 日零点执行
*/10 * * * * *每 10 秒执行(6 位,含秒)
0 30 9 * * 1-5周一至周五早上 9:30 执行

特殊字符

字符说明示例
*任意值* * * * * 每分钟
,列举多个值0 8,12,18 * * * 每天 8、12、18 点
-范围0 9-17 * * * 9 点到 17 点的每个整点
/步进*/5 * * * * 每 5 分钟

内部路由机制

定时任务并非直接执行函数,而是注册为内部 POST 路由,由 node-schedule 在指定时间通过模拟 HTTP 请求触发。

工作流程

  1. 注册阶段:扫描 paths 目录下的所有文件,将 @Schedule 装饰的方法注册为内部 POST 路由
  2. 路由路径:路径格式为 {prefix}{文件名}/{方法名},例如 /__schedule/cleanup/dailyClean
  3. 定时触发:到达 Cron 表达式指定的时间时,构造一个模拟的 HTTP POST 请求并传入 Koa 中间件链
  4. 完整中间件:请求经过完整的中间件栈(包括 inject、metric、日志等),可以使用依赖注入、数据库等所有功能

手动触发

由于定时任务被注册为内部路由,你也可以通过 HTTP 请求手动触发:

# 手动触发 dailyClean 任务
curl -X POST http://localhost:7001/__schedule/cleanup/dailyClean

这在调试和测试定时任务时非常有用。

路由去重

如果多个文件中的方法名冲突,系统会自动在路径后追加数字序号确保唯一性:

/__schedule/report/generate     # 第一个
/__schedule/report/generate1 # 重名自动编号
/__schedule/report/generate2 # 继续编号

ZENWEB_SCHEDULE_DISABLED 环境变量

设置 ZENWEB_SCHEDULE_DISABLED=1 可以禁用定时器自动执行,但路由仍然会注册。这在以下场景中很有用:

  • 多实例部署:只让其中一个实例执行定时任务,其他实例禁用
  • 开发调试:本地开发时禁用定时任务,避免干扰
  • 手动控制:通过 HTTP 接口手动触发任务
# 禁用定时任务
ZENWEB_SCHEDULE_DISABLED=1 node dist/index.js

也可以在代码中配置:

app.setup(modSchedule({
disabled: process.env.NODE_ENV === 'development',
}));

依赖注入

定时任务方法在执行时会通过 inject 容器创建实例,因此可以使用完整的依赖注入功能。

注入服务

import { Schedule } from '@zenweb/schedule';
import { inject } from '@zenweb/inject';
import { ReportService } from '../service/ReportService';

export class ReportScheduler {
@inject
reportService!: ReportService;

@Schedule('0 8 * * *')
async dailyReport() {
const report = await this.reportService.generateDailyReport();
console.log('日报生成完成:', report.id);
}
}

使用 $mysql

import { Schedule } from '@zenweb/schedule';
import { $mysql } from '@zenweb/mysql';

export class DataScheduler {
@Schedule('0 2 * * *')
async cleanExpiredData() {
// 清理 30 天前的过期数据
const result = await $mysql.query(
'DELETE FROM sessions WHERE expired_at < DATE_SUB(NOW(), INTERVAL 30 DAY)'
);
console.log(`已清理 ${result.affectedRows} 条过期会话`);
}
}

使用请求上下文

由于定时任务通过模拟 HTTP 请求触发,因此可以访问完整的请求上下文:

import { Schedule } from '@zenweb/schedule';
import { Context } from 'zenweb';

export class NotificationScheduler {
@Schedule('0 9 * * 1-5')
async morningNotification() {
// 可以通过参数注入获取上下文相关内容
// ctx 会在运行时注入
}
}

多文件发现模式

schedule 模块会扫描 paths 配置的所有目录,自动发现和加载定时任务文件。文件名会作为路由路径的前缀,便于按业务分类组织。

目录结构示例

src/
app/
schedule/
cleanup.ts -> 路径前缀: cleanup
report.ts -> 路径前缀: report
notification.ts -> 路径前缀: notification
sync/
orders.ts -> 路径前缀: sync/orders
products.ts -> 路径前缀: sync/products

自定义扫描目录

app.setup(modSchedule({
paths: ['./app/schedule', './app/jobs'],
patterns: '**/*.{ts,js}',
}));

完整示例:每日数据清理与报告生成

清理过期会话

src/app/schedule/cleanup.ts
import { Schedule } from '@zenweb/schedule';
import { $mysql } from '@zenweb/mysql';

export class CleanupScheduler {
/**
* 每天凌晨 2 点清理过期数据
*/
@Schedule('0 2 * * *')
async dailyClean() {
console.log('[Cleanup] 开始清理过期数据...');

// 清理过期会话
const [sessions] = await $mysql.query(
'DELETE FROM user_sessions WHERE expired_at < NOW()'
);
console.log(`[Cleanup] 已清理 ${sessions.affectedRows} 条过期会话`);

// 清理过期验证码
const [codes] = await $mysql.query(
'DELETE FROM verify_codes WHERE expired_at < NOW()'
);
console.log(`[Cleanup] 已清理 ${codes.affectedRows} 条过期验证码`);

// 清理临时文件记录
await $mysql.query(
"DELETE FROM temp_files WHERE status = 'expired' AND created_at < DATE_SUB(NOW(), INTERVAL 7 DAY)"
);

console.log('[Cleanup] 清理完成');
}

/**
* 每周日凌晨 3 点清理日志
*/
@Schedule('0 3 * * 0')
async weeklyClean() {
await $mysql.query(
'DELETE FROM access_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL 90 DAY)'
);
console.log('[Cleanup] 已清理 90 天前的访问日志');
}
}

生成业务报告

src/app/schedule/report.ts
import { Schedule } from '@zenweb/schedule';
import { inject } from '@zenweb/inject';
import { $mysql } from '@zenweb/mysql';
import { ReportService } from '../service/ReportService';

export class ReportScheduler {
@inject
reportService!: ReportService;

/**
* 每天早上 8 点生成昨日业务报告
*/
@Schedule('0 8 * * *')
async dailyReport() {
console.log('[Report] 开始生成日报...');

const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const dateStr = yesterday.toISOString().split('T')[0];

// 汇总昨日订单数据
const orderStats = await $mysql.query<{
total: number;
amount: number;
avg_amount: number;
}>(
`SELECT
COUNT(*) as total,
COALESCE(SUM(amount), 0) as amount,
COALESCE(AVG(amount), 0) as avg_amount
FROM orders
WHERE DATE(created_at) = ?`,
[dateStr]
);

// 汇总用户注册数
const userStats = await $mysql.query<{ count: number }>(
'SELECT COUNT(*) as count FROM users WHERE DATE(created_at) = ?',
[dateStr]
);

// 生成报告
await this.reportService.generateAndSave({
date: dateStr,
orders: orderStats[0],
newUsers: userStats[0].count,
});

console.log(`[Report] ${dateStr} 日报生成完成`);
}

/**
* 每月 1 日凌晨生成上月汇总
*/
@Schedule('0 2 1 * *')
async monthlyReport() {
const now = new Date();
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const monthStr = `${lastMonth.getFullYear()}-${String(lastMonth.getMonth() + 1).padStart(2, '0')}`;

console.log(`[Report] 开始生成 ${monthStr} 月报...`);
await this.reportService.generateMonthlyReport(monthStr);
console.log(`[Report] ${monthStr} 月报生成完成`);
}
}

数据同步

src/app/schedule/sync.ts
import { Schedule } from '@zenweb/schedule';
import { inject } from '@zenweb/inject';
import { SyncService } from '../service/SyncService';

export class SyncScheduler {
@inject
syncService!: SyncService;

/**
* 每 30 分钟同步一次商品库存
*/
@Schedule('*/30 * * * *')
async syncInventory() {
const result = await this.syncService.syncInventory();
console.log(`[Sync] 库存同步完成,更新 ${result.updated}`);
}
}