定时任务 QQ 机器人,是很多 Java 开发者在做提醒服务、群内日报、运营推送时首先想到的落地方式:某个时间点一到,机器人自动往指定 QQ 群发一条消息。这个需求看起来简单,真正落地时却会涉及两个完全不同的技术域,一个是定时调度,一个是 QQ 消息通道。定时调度要回答“到点之后做什么”,消息通道要回答“业务代码如何把文本变成 QQ 会话里的消息”。把这层链路拆清楚,再基于 Spring Boot 写一个最小可运行案例,就能很快上手,也能在后面加开关、加重试、加分布式调度时不至于重写。
本文面向已经掌握 Java 和 Spring Boot 基础、但还没有完整做过 QQ 机器人项目的开发者。文章会从整体架构讲起,然后给出一个可以直接运行的最小工程:使用 OneBot 协议兼容客户端处理 QQ 账号登录与消息收发,使用 Spring 的@Scheduled驱动定时任务,业务服务只负责在固定时间调用机器人 HTTP API 发送群消息。学完之后,你能独立搭建一个最小闭环,掌握 cron 表达式的常见写法,并且知道定时任务没触发、消息发送失败时应该从哪一层开始排查。
1. 先理解定时任务机器人的完整链路
1.1 消息从定时任务到 QQ 会话要经过哪些环节
把“定时任务”和“QQ 机器人”放在一起,一开始就要区分两件事:任务谁来触发,消息谁来发。
定时触发由业务应用负责。在 Java 生态里,最简单的做法是在方法上标注@Scheduled,Spring 容器启动时就会按照表达式注册一个调度器,到点后执行方法。这个方法里可以查询数据库、组装文本、调用外部接口,最后把要推送的内容交给消息发送服务。
消息发送则不能直接由业务应用连 QQ 来完成。出于协议复杂度和账号安全考虑,目前主流开源方案是使用一个独立的协议适配端,它负责登录 QQ 账号、维护会话状态,并对外提供统一的 HTTP 或 WebSocket 接口。业务应用只需要按协议约定发送请求,例如调用send_group_msg接口,适配端就会把消息投递到对应的 QQ 群。
这个拆分的价值在于业务代码无需关心 QQ 协议细节。只要适配端的接口稳定,业务侧就可以像调用普通 HTTP 服务一样完成消息推送,后续更换适配实现也不影响定时任务代码。
1.2 定时调度组件怎么选
定时任务本身是 Java 后端非常成熟的领域,选型取决于部署规模和任务复杂度。
| 方案 | 适用场景 | 优点 | 需要关注的成本 |
|---|---|---|---|
Spring@Scheduled | 单机、轻量、定时任务数量少 | 配置简单,注解即可 | 多实例会重复执行,任务无持久化 |
| Quartz | 单机或集群,需要复杂触发器、任务持久化 | 功能强,支持 misfire、持久化 | 配置较重,学习成本高 |
| XXL-Job | 分布式、多实例、需要可视化调度管理 | 中心化调度,自带管理界面和告警 | 需要部署调度中心,引入运维依赖 |
对于“简单快速”这个目标,第一版优先选择 Spring@Scheduled。它不需要额外装服务,不会增加部署成本,等业务规模到了多实例部署、需要统一管理执行记录的时候,再迁移到分布式调度框架也不迟。
1.3 最小闭环需要准备哪些元素
一个能跑起来的最小闭环至少包含四个元素:
- 一个能登录 QQ 账号的协议适配端,并启用了 HTTP API。
- 一个 Spring Boot 应用,包含定时任务代码和消息发送代码。
- 一组自定义配置,用于指定适配端地址、目标群号、cron 表达式。
- 一个验证入口,比如通过日志确认任务触发、通过 QQ 群消息确认结果送达。
把这四部分串起来后,整个执行链路就是:定时表达式到点,Spring 调度器调用任务方法,任务方法组装消息内容,调用机器人 HTTP 接口,适配端将消息发送到目标 QQ 群。
2. 环境准备与依赖配置
2.1 基础环境检查
开始写代码前,先把环境确认一遍,避免把时间浪费在版本不匹配上。
| 环境项 | 建议版本 | 说明 |
|---|---|---|
| JDK | 17 或 21 | Spring Boot 3.x 要求 JDK 17 起,若继续使用 Spring Boot 2.x 可用 JDK 8 |
| Maven | 3.8 以上 | 用于依赖管理和打包 |
| Spring Boot | 3.2.x | 本文示例以 3.x 为主,2.x 用法基本相同 |
| 协议适配端 | 见对应文档 | 需要支持 OneBot HTTP API,能登录 QQ 账号 |
| 网络 | 本机回环或内网互通 | 业务应用与适配端之间能互相访问 |
不同协议适配端的安装方式差异较大,有的提供桌面客户端,有的通过 Docker 运行,有的需要配置 QQ 账号的扫码登录。无论选哪种,先确认它能正常启动,并且能在本地访问到它暴露的 HTTP 端口。
2.2 创建 Spring Boot 项目并引入依赖
推荐从 Spring Initializr 创建项目,或者直接使用 Maven 骨架。因为消息发送要发起 HTTP 请求,这里只需要引入spring-boot-starter-web,它同时提供了 Web 容器和RestTemplate所需的转换器。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies>如果不想引入完整 Web 容器,也可以只引入spring-boot-starter并手动加上spring-web,但工程上直接使用starter-web更容易排查问题,后续需要对外提供管理接口时也不需要再改依赖。
2.3 配置 application.yml
定时机器人相关的地址、群号、cron 表达式不应该写死在代码里,而是放到配置文件中。
server: port: 8080 bot: base-url: http://127.0.0.1:5700 group-id: 123456789 cron: "0 30 9 * * ?"这里的bot.base-url是协议适配端监听 HTTP API 的地址。常见 OneBot 实现的默认端口是 5700,但不同项目可能不同,这里只是一个示例,要按你自己启动的适配端实际端口填写。bot.group-id是准备接收消息的 QQ 群号。bot.cron是典型表达式,表示每天上午 9 点 30 分 0 秒触发一次。
2.4 在启动类上开启定时任务
Spring Boot 默认不会自动开启定时任务,必须显式使用@EnableScheduling。
package com.example.qqbot; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling public class QqBotApplication { public static void main(String[] args) { SpringApplication.run(QqBotApplication.class, args); } }忘记这一步是最常见的“定时任务不执行”原因。加了@Scheduled但启动类上缺少@EnableScheduling,Spring 不会注册调度器,代码也不会报错,只会静默不执行。
3. 编写一个最小可运行的定时任务机器人
3.1 项目结构设计
最小项目不需要分层太多,先保证职责清晰。
src/main/java/com/example/qqbot/ ├── QqBotApplication.java ├── config/ │ └── ScheduleConfig.java ├── service/ │ └── BotMessageService.java └── task/ └── DailyReportTask.javaQqBotApplication:启动类和@EnableScheduling入口。ScheduleConfig:配置定时任务线程池。BotMessageService:封装调用机器人 HTTP 接口的逻辑。DailyReportTask:写定时任务,拼装消息并调用服务发送。
这个结构的好处是,任务逻辑和消息通道分离。将来要支持按月执行、按用户群分类发送,只需要替换或扩展task包里的类。
3.2 封装机器人消息发送服务
BotMessageService使用RestTemplate调用 OneBot 的send_group_msg接口,返回boolean表示是否成功。
package com.example.qqbot.service; import java.util.HashMap; import java.util.Map; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; @Service public class BotMessageService { private final RestTemplate restTemplate = new RestTemplate(); private final ObjectMapper objectMapper = new ObjectMapper(); @Value("${bot.base-url}") private String baseUrl; public boolean sendGroupMessage(Long groupId, String message) { String url = baseUrl + "/send_group_msg"; Map<String, Object> body = new HashMap<>(); body.put("group_id", groupId); body.put("message", message); body.put("auto_escape", true); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers); try { ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class); JsonNode root = objectMapper.readTree(response.getBody()); if ("ok".equals(root.path("status").asText())) { return true; } System.out.println("发送失败,响应内容: " + response.getBody()); return false; } catch (RestClientException | com.fasterxml.jackson.core.JsonProcessingException e) { System.out.println("调用机器人接口异常: " + e.getMessage()); return false; } } }这里有两个关键点。
第一,请求体要设置Content-Type: application/json,否则某些实现可能把 JSON 参数当成表单处理,导致group_id解析失败。
第二,OneBot 标准响应中status字段为ok表示成功。不同实现的响应结构可能略有差异,只要你的适配端文档声明是 OneBot 兼容接口,就可以按这个方式判断。
注意:生产环境不要用
System.out.println记录日志,后面会说明如何替换成规范日志。
3.3 配置定时任务线程池
Spring 默认的@Scheduled执行器是单线程的。如果一个任务执行时间过长,会阻塞后续其他定时任务。建议在一开始就给定时任务配置一个独立线程池。
package com.example.qqbot.config; import java.util.concurrent.Executors; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.SchedulingConfigurer; import org.springframework.scheduling.config.ScheduledTaskRegistrar; @Configuration public class ScheduleConfig implements SchedulingConfigurer { @Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { taskRegistrar.setScheduler(Executors.newScheduledThreadPool(4)); } }这里创建了一个大小为 4 的调度线程池。具体大小取决于任务数量和单次执行耗时。任务之间没有依赖关系时,可以适当调大;有资源竞争时,避免盲目调大导致数据库或下游接口被打满。
3.4 编写定时任务类
假设需求是每天上午在群里发送一条日报提醒,任务类可以这样写。
package com.example.qqbot.task; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import com.example.qqbot.service.BotMessageService; @Component public class DailyReportTask { private static final Logger log = LoggerFactory.getLogger(DailyReportTask.class); private final BotMessageService botMessageService; @Value("${bot.group-id}") private Long groupId; public DailyReportTask(BotMessageService botMessageService) { this.botMessageService = botMessageService; } @Scheduled(cron = "${bot.cron}", zone = "Asia/Shanghai") public void sendDailyReminder() { log.info("日报提醒任务开始执行,目标群={}", groupId); String message = "早上好,请记得在 10 点前提交昨日日报。"; boolean success = botMessageService.sendGroupMessage(groupId, message); if (!success) { log.error("日报提醒发送失败,group={}", groupId); return; } log.info("日报提醒发送成功,group={}", groupId); } }这里使用@Value("${bot.cron}")把 cron 表达式外置,避免修改执行时间需要重新编译代码。同时显式指定zone = "Asia/Shanghai",可以避免服务器时区不统一导致任务在错误时间触发。
sendDailyReminder方法内部先记录任务开始,再组装消息,接着调用BotMessageService,最后记录结果。日志一定要到位,因为定时任务没有主动触发入口,出现问题时日志几乎是唯一的排查依据。
3.5 运行方式与查看方式
运行QqBotApplication的main方法,或者使用 Maven 命令:
mvn spring-boot:run启动后观察控制台,如果出现类似下面的日志,说明 Spring 已启动:
Started QqBotApplication in 2.437 seconds (process running for 2.512)等到 cron 表达式中配置的时间到达后,再看任务日志。
4. 关键配置与参数说明
4.1 cron 表达式要理解六段结构
Spring 的@Scheduled支持六段 cron 表达式,和 Linux crontab 的五段表达式不同,第一段是秒。
| 字段位置 | 含义 | 取值范围 | 允许符号 |
|---|---|---|---|
| 第 1 位 | 秒 | 0-59 | *,-/ |
| 第 2 位 | 分 | 0-59 | *,-/ |
| 第 3 位 | 时 | 0-23 | *,-/ |
| 第 4 位 | 日 | 1-31 | *,-?/ |
| 第 5 位 | 月 | 1-12 或 JAN-DEC | *,-/ |
| 第 6 位 | 周 | 0-7 或 SUN-SAT | *,-?/ |
常用写法:
| 需求 | cron 表达式 |
|---|---|
| 每天 9 点 30 分触发 | 0 30 9 * * ? |
| 每个工作日 8 点触发 | 0 0 8 ? * MON-FRI |
| 每小时整点触发 | 0 0 * * * ? |
| 每周一 9 点触发 | 0 0 9 ? * MON |
| 每隔 10 分钟触发 | 0 */10 * * * ? |
这里最容易犯的错误是写成五段表达式,例如30 9 * * *。在 Spring 中会把30当作秒,9当作分,导致任务每天只有每秒第 30 毫秒级触发一次,而且触发时间完全不符合预期。
4.2 OneBot HTTP 接口参数速查
定时任务最终要通过 HTTP 接口把消息交给适配端。以 OneBot v11 的send_group_msg为例,常用参数如下。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
group_id | Number | 是 | 目标 QQ 群号 |
message | String | 是 | 要发送的消息内容 |
auto_escape | Boolean | 否 | 是否将内容作为纯文本处理,默认false |
发送私聊消息则使用send_private_msg,对应的必填参数是user_id。如果你的适配端使用 WebSocket 通信,也可以从 REST 调用改为 WS 连接,但最小项目中 REST 更直观,也更容易用 curl 验证。
用 curl 模拟协议适配端是否正常:
curl -X POST "http://127.0.0.1:5700/send_group_msg" \ -H "Content-Type: application/json" \ -d '{"group_id":123456789,"message":"测试消息","auto_escape":true}'如果返回:
{ "status": "ok", "retcode": 0, "data": null }说明协议适配端正常,问题大概率在业务应用侧;如果返回超时或错误码,问题大概率在适配端。
4.3 自定义配置项的读取方式
配置项建议全部集中在application.yml里,类中只通过@Value或@ConfigurationProperties读取。若配置项较多,推荐使用@ConfigurationProperties绑定为一个配置类。
package com.example.qqbot.config; import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "bot") public class BotProperties { private String baseUrl; private Long groupId; private String cron; // getter / setter 省略 }使用配置类的最大好处是配置项有类型提示,也方便在多个任务中复用。小项目中用@Value足够,项目变大后逐步迁移到配置类。
5. 运行验证与日志排查
5.1 启动顺序和检查点
整个系统有两个进程:协议适配端和 Spring Boot 应用。建议按以下顺序启动。
- 启动协议适配端,确认能成功登录 QQ 账号。
- 调用 curl 命令测试
send_group_msg接口,确认消息链路可用。 - 启动 Spring Boot 应用,观察日志。
- 等待 cron 时间点,确认任务日志出现。
- 在目标 QQ 群中确认收到机器人消息。
如果跳过第二步,直接把任务跑起来,一旦群内没有消息,就无法区分是适配端问题还是业务代码问题。先用 curl 验证,等于把这条链路的底层先点亮。
5.2 看日志确认执行链路
定时任务运行后,应该能在控制台看到类似下面的日志:
2025-01-06 09:30:00.001 INFO [scheduling-1] c.e.qqbot.task.DailyReportTask : 日报提醒任务开始执行,目标群=123456789 2025-01-06 09:30:00.120 INFO [scheduling-1] c.e.qqbot.task.DailyReportTask : 日报提醒发送成功,group=123456789日志中的[scheduling-1]表示任务是在调度线程池里执行的。如果看到的是[http-nio-8080-exec-x]或者其他线程名,说明调度配置可能有变化,但只要能执行,问题不大。
5.3 异常场景的预期表现
如果适配端没有启动,日志会类似:
调用机器人接口异常: Connection refused: connect 日报提醒发送失败,group=123456789如果 cron 表达式写错,启动时 Spring 可能直接抛出IllegalStateException,或者在日志中提示无法解析 cron 表达式。这时候要优先看启动日志,而不是等到点后找原因。
6. 常见问题排查
6.1 定时任务到点没执行
这是最典型的问题,排查顺序如下。
| 现象 | 原因 | 检查方式 | 解决方式 |
|---|---|---|---|
| 到点没有任何任务日志 | 缺少@EnableScheduling | 查看启动类注解 | 加上注解 |
| 到点没有任何任务日志 | cron 表达式解析失败 | 看启动日志有无异常 | 修正表达式 |
| 到点但日志很晚才输出 | 调度线程池被其他长任务占满 | 看日志线程名和任务耗时 | 配置独立线程池 |
| 执行时间与预期相差几个小时 | 服务器时区不对 | 执行date -R查看时区 | 指定zone或修改 TZ |
| 应用部署了多个实例 | 每个实例都会执行一次 | 检查实例数量 | 引入分布式锁或调度框架 |
先看有没有日志输出,再判断问题在调度层还是任务内部。任务内部异常如果没有被捕获,也可能表现为“看似没执行”,其实异常发生在消息拼装阶段。
6.2 消息发送失败
任务日志显示“发送失败”时,按链路从下往上排查。
调用机器人接口异常: Connection refused: connect大概率是协议适配端没启动、端口不对或地址不通。先执行:
curl -X POST "http://127.0.0.1:5700/send_group_msg" ...再参考适配端日志,确认 QQ 账号是否在线、是否出现登录失效。账号登录失效在协议适配端中属于常见问题,需要重新登录。
6.3 任务重复执行
单机部署时,如果任务执行时间超过触发间隔,Spring 默认不会等待上一个任务结束。例如 cron 设置为0 */5 * * * ?,任务运行了 8 分钟,下一次触发时就会出现两个任务叠加执行。
解决方式有两种:
- 在任务方法中使用状态标记,确保同一时间只有一个任务在执行。
- 使用 Quartz 或 XXL-Job 的任务调度语义,控制并发。
对于简单场景,使用线程池并配合状态标记即可。
6.4 使用 jstack 排查调度线程阻塞
如果定时任务长时间不输出日志,但进程还活着,可以导出线程栈。
jstack <pid>搜索scheduling-开头的线程,查看当前是否卡在某个调用上。常见原因是消息服务访问外部地址时没有设置超时时间,导致RestTemplate一直等待。生产环境一定要给 HTTP 客户端设置连接超时和读取超时。
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(3000); factory.setReadTimeout(5000); RestTemplate restTemplate = new RestTemplate(factory);6.5 三处常见坑汇总
| 坑 | 错误写法 | 正确做法 |
|---|---|---|
| cron 少写秒字段 | 30 9 * * * | 0 30 9 * * ? |
| RestTemplate 不设超时 | new RestTemplate() | 配置连接和读取超时 |
启动类缺@EnableScheduling | 只加@Scheduled | 启动类显式开启 |
7. 从学习到生产环境的最佳实践
7.1 配置外置,cron 不要在代码里写死
定时任务最常见的变更需求就是改执行时间。如果 cron 写在注解里,每次修改都要重新编译发布。建议全部放到配置中心或环境变量中,让任务时间可以独立调整。
7.2 建立任务执行记录表
生产环境里,定时任务失败往往不是立即暴露的。建议为每个任务记录执行时间、执行结果、失败原因和消息内容摘要。
| 字段名 | 类型 | 说明 |
|---|---|---|
| task_name | varchar | 任务标识 |
| executed_at | datetime | 计划执行时间 |
| finished_at | datetime | 结束时间 |
| result | varchar | 成功或失败 |
| error_msg | text | 异常信息 |
| target_group | varchar | 目标群号 |
有了这张表,即使日志被清理,也能追溯某条消息是否发送成功。
7.3 失败重试和告警
消息发送失败后,最简单的策略是重试两次。重试时要设置间隔,避免短时间连续调用导致适配端压力过大。重试仍然失败后,应该通过其他渠道告警,比如发送到运维群,或者接入统一告警平台。
重试逻辑不要直接写循环,建议使用 Spring Retry 或封装一个带延迟的重试方法。
7.4 多实例部署时必须解决重复执行问题
在 Spring Cloud 架构中,服务通常会部署多个实例。每个实例都会加载同一个@Scheduled任务,到点时都会执行一次,导致重复消息。
如果实例数量不多,可以引入 ShedLock,使用数据库锁保证只有一个实例执行。如果已经上了分布式调度中心,推荐直接迁移到 XXL-Job 这类方案,把执行权交给调度中心统一分配。迁移时只需要去掉@Scheduled,改为在调度中心配置执行器和方法,业务逻辑本身可以保持不变。
7.5 消息内容要有节制
定时机器人最忌讳高频骚扰。不要在没有明确场景的情况下每分钟推送一次。考虑用户可接受的消息频率,增加开关配置,在不需要推送的时段自动跳过。
7.6 发布前检查清单
| 检查项 | 确认内容 |
|---|---|
| cron 表达式 | 是否是六段格式,时间是否与预期一致 |
| 时区 | 是否指定zone,服务器时区是否正确 |
| 适配端 | 是否已登录,HTTP 接口是否可访问 |
| 目标群号 | 是否配置正确,机器人是否在群内 |
| 日志 | 是否有开始和结束日志 |
| 超时 | HTTP 客户端是否设置了超时时间 |
| 多实例 | 是否引入分布式锁或调度框架 |
8. 扩展方向
8.1 让用户通过群聊设置定时任务
当前示例是任务完全由配置文件决定。更常见的使用方式是用户发一条“每天 10 点提醒我喝水”,机器人解析后保存到数据库,再动态注册定时任务。这时需要监听群消息,接收到命令后回传结果。建议引入 WebSocket 事件监听,在机器人收到消息时回调业务服务。
8.2 定时任务持久化与动态变更
使用@Scheduled的任务在运行期很难动态修改。如果需要频繁变更任务,可以研究 Spring 的ScheduledTaskRegistrar动态注册机制,或者直接引入 Quartz,让任务存储在数据库中,通过管理页面修改触发时间。
8.3 定时任务与外部数据源结合
很多场景下,机器人推送的内容不是固定文本,而是来自天气接口、股票行情、数据库报表等外部数据。可以在任务方法中先查询数据,再拼装消息。注意控制外部接口的调用频率,避免因为数据源超时导致任务整体异常。
定时任务机器人的核心价值不在机器人本身,而在任务链路是否稳定可维护。先用最小方案跑通闭环,再逐步补充执行记录、失败重试和分布式调度,比一开始就套用重型框架更务实。希望这次梳理能帮助你把“定时任务”和“QQ 消息发送”这两部分真正连起来,在遇到问题时知道该在哪一层找答案。