需求听起来特别简单:每天早上八点,把用户昨天走了多少步,推到他微信上。
我第一反应是,这不就是个定时任务?@Scheduled(cron = "0 0 8 * * ?"),扫一张表,调个接口,收工。真动手做,才发现 cron 是这活里最不值钱的一部分。
这篇把全过程拆开讲。技术栈是 Spring Boot + XXL-Job + Redis + 小程序。
需要的可以收藏+点赞!
一、第一道坎:没有"查步数"的接口
很多人默认,既然微信有步数,那应该有个接口,传个 openid 就能查到。没有。
微信运动的数据,只有一个官方出口:小程序里的 wx.getWeRunData()。它有三个硬限制:
-
必须用户授权 ------ 要
scope.werun权限,用户不点同意,什么都拿不到。 -
必须在小程序里调用 ------ 公众号不行,H5 不行,服务端更不行。App 想拿步数只能走系统健康数据(HealthKit、Health Connect、各家运动健康开放平台),那是另一套账,跟微信步数对不上。
-
只在用户主动进入小程序时更新 ------ 这是官方文档的原话。翻译一下:用户三天没打开你的小程序,你手里那三天的数据就是三天前的。
也就是说,服务端不存在"定时拉取某个人的步数"这种操作。定时任务能决定的只是"什么时候发",发的内容永远是用户上次进来时缓存下来的。
二、第二道坎:AES 解密和会过期的 session_key
wx.getWeRunData 返回的不是明文,是 encryptedData 加 iv,两个 base64 字符串。解它需要 session_key,而 session_key 是 wx.login 换来的,跟登录态绑在一起。
完整链路是这样:
-
小程序调
wx.login()拿code -
服务端拿
code调code2Session,换回openid和session_key -
小程序调
wx.getWeRunData(),拿到encryptedData和iv -
两个值传到服务端,服务端用
session_key做 AES-128-CBC 解密
Java 里解密长这样:
bash
public static String decrypt(String encryptedData, String iv, String sessionKey) {
try {
byte[] key = Base64.getDecoder().decode(sessionKey);
byte[] ivBytes = Base64.getDecoder().decode(iv);
byte[] data = Base64.getDecoder().decode(encryptedData);
// JCE 里 AES 用 PKCS5Padding,效果等同于微信要求的 PKCS#7
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE,
new SecretKeySpec(key, "AES"),
new IvParameterSpec(ivBytes));
returnnew String(cipher.doFinal(data), StandardCharsets.UTF_8);
} catch (GeneralSecurityException e) {
// 绝大多数情况是 session_key 过期,让前端重新 login 再来一次
thrownew IllegalStateException("微信运动数据解密失败,session_key 可能已失效", e);
}
}
四个容易翻车的地方:
-
session_key 会被换掉 ------ 用户重新登录、长时间不活跃、换设备,都会让旧的失效。必须按 openid 存最新的一份,别当永久密钥。我会放 Redis,过期时间跟登录态对齐,解密失败就让前端重新
wx.login。 -
encryptedData不能缓存 ------ 它跟当次会话绑定,隔一会儿再解可能就废了。拿到就传,传完就解。 -
step为 0 是合法值 ------ 那天用户真没带手机,就是 0 步。别写if (step == 0) continue,会把真实数据吞掉。 -
timestamp是秒 ------ 乘 1000 再转时间,忘了乘就是 1970 年。
解密出来长这样:
bash
{
"stepInfoList": [
{ "timestamp": 1761321600, "step": 8421 },
{ "timestamp": 1761408000, "step": 12033 }
],
"watermark": { "appid": "wxxxxxxxx", "timestamp": 1761408000 }
}
顺便提一句,这个接口一次返回 31 天。用户第一次授权就能把过去一个月的账补齐,不用一天天攒。做历史趋势图的时候很省事。
watermark.appid 一定要校验,跟你自己的 AppID 对不上就丢掉。不然别人拿别的小程序的密文来打你的接口,你可能真给解了。
三、第三道坎:订阅消息只给一次机会
数据拿到了,怎么"推"到微信上?只有订阅消息这一条路。而这里有个规则,直接把"每天推"这个需求判了死刑。
微信的订阅消息分两种:
-
一次性订阅 ------ 用户授权一次,你只能发一条。发完额度清零,想再发得让用户再点一次。
-
长期订阅 ------ 用户授权一次,可以长期发。但只对政务民生、医疗、交通、金融、教育这几类二级类目开放,还要模板库里正好有对应模板。
所以"每天早八点推送"这件事,在平台规则层面就是不成立的。用户不可能每天主动来找你授权。
能做的只有三条:
-
订阅续杯 ------ 用户每次进小程序,在"看步数报告"这个动作上顺手引导一次订阅。说白了,他来看一次,你就多攒一次推送额度。
-
把承诺改掉 ------ 别在文案里写"每天提醒",改成"还可提醒 3 次"。这是唯一诚实的说法,也是唯一不会招投诉的说法。
-
换通道 ------ 服务号模板消息(前提是用户关注了公众号,且类目对得上),或者干脆做 App 走厂商推送。出了微信,规则就宽松了。
发送接口是 subscribeMessage.send,几个错误码必须单独处理:
-
43101 ------ 用户拒收或没订阅。别重试,重试多少次都是这个结果,直接把这条任务标成无效。
-
47003 ------ 模板参数不匹配。这是自己代码写错了,重试没用,得改模板。
-
40001 / 42001 ------ access_token 失效,刷新后重试一次就够。
-
45009 ------ 触发频控,退避几分钟再来。
四、第四道坎:百万用户的定时任务
假设有 100 万用户,每人每天一条,就是 100 万次 HTTPS 请求。而且用户的推送时间集中在早八点前后,峰值 QPS 轻松上 500。
单机 @Scheduled 在这个量级下有四个问题:多实例重复推、重启丢任务、扩容要改代码、失败了没人知道。上 XXL-Job 或者 PowerJob,用分片广播。
核心思路是按时间分桶扫,别全表扫:
bash
@XxlJob("stepPushJob")
public void stepPush() {
int shardIndex = XxlJobHelper.getShardIndex();
int shardTotal = XxlJobHelper.getShardTotal();
ZoneId zone = ZoneId.of("Asia/Shanghai");
LocalTime end = LocalTime.now(zone).withSecond(0).withNano(0);
LocalTime start = end.minusMinutes(5);
// 只查这 5 分钟该发的,按 bucket 走索引
List<PushTask> tasks = taskMapper.selectDue(start, end, shardIndex, shardTotal);
for (PushTask task : tasks) {
// 唯一索引 (openid, push_date, template_id),插进去才算抢到
if (dedupMapper.insertIgnore(task.getOpenid(), task.getPushDate(), TEMPLATE_ID) == 0) {
continue;
}
try {
subscribeMsgSender.send(task);
} catch (Exception e) {
retryQueue.push(task, e); // 进本地消息表,退避重试
}
}
}
对应 SQL 大概是这样:
bash
select id, openid, push_date, step, push_time
from step_push_task
where status = 0
and push_bucket between #{startBucket} and #{endBucket}
and mod(user_id, #{shardTotal}) = #{shardIndex}
limit 1000;
几个细节值得抠:
-
别在 SQL 里对 user_id 取模 ------
mod(user_id, ?)用不上索引,百万行会扫到你怀疑人生。建表时加一列push_bucket(比如每 5 分钟一个桶,一天 288 个),建索引扫桶。 -
幂等靠数据库,不靠 Redis ------ Redis 的 setnx 会过期、会丢,重复推一条用户就会来问"怎么发两遍"。唯一索引最笨也最可靠。
-
失败重试要有上限 ------ 1 分钟、5 分钟、30 分钟三次,还不行进死信表,人工看。别无限重试,微信那边频控会教做人。
-
批量拉数据,别在循环里查库 ------ 这个老毛病我写过一篇专门讲,100 万行逐条查,数据库连接池先崩。
还有个架构上的取舍:100 万条同时打微信,光靠线程池是扛不住的。我的做法是先落库排队,再用固定速率消费,比如每秒 300 条,把八点的尖峰削成一条平缓的线。XXL-Job 只负责把到点的任务塞进队列,真正发消息的是限速的消费者。慢一点没关系,八点半发完用户也感知不到。
五、第五道坎:时区和"昨天"
步数是按自然日累计的。服务端如果跑在 UTC 的容器里,LocalDate.now() 在北京时间早上八点,拿到的是前一天。这个 bug 特别隐蔽,测试环境还测不出来。
我的处理:
-
全链路只用
Asia/Shanghai,LocalDate.now(ZoneId.of("Asia/Shanghai")),一个地方都别省。 -
数据库存
DATE类型,不存DATETIME。步数天然是"某一天的总数",存到秒没有意义,还容易因为时区转换错位。 -
微信返回的
timestamp是当天零点,转日期时要显式指定时区,别用默认时区。
还有个更现实的问题:数据延迟。
前面说过,步数只在用户主动进小程序时更新。用户晚上八点之后没打开过,你第二天早上八点推的"昨日总步数",其实是他昨晚八点时的数字。用户一看,我昨天明明走了一万二,你怎么写九千八?
所以文案别写"昨日总步数",写"截至昨日 20:00,9,842 步"。加一个时间限定,投诉能少一大半。
还有一种断档是数据本身没有的:用户换了手机、重装微信、关掉微信运动再打开,那几天的记录就是空白。这不是你的 bug,是微信那边就没有。前端画图的时候留个空档,别硬把两天的线连起来,看着像他一天走了三万步。
六、第六道坎:步数是敏感个人信息
这一条最容易被技术同学忽略,但它是审核能不能过的关键。
步数属于健康运动数据,在《个人信息保护法》里落在敏感个人信息那一档。要求是单独同意、最小必要、可撤回。落到开发上就四件事:
-
小程序后台的《用户隐私保护指引》里必须明确写"收集微信运动步数",没写审核大概率打回。
-
授权弹窗要在用户主动点击"查看步数"之后再弹,别一进页面就糊他脸上,拒绝率会高得离谱。
-
只存日步数和日期,别把
encryptedData原始密文长期留库。留着没用,出了事都是责任。 -
给一个能关掉的地方。用户在小程序里关了,定时任务就别再给他发。
✨✨✨
-
关注【斯维赤】 ------ 每天持续分享,关注不迷路。
-
点个「赞」+「推荐」 ------ 你的鼓励是我持续更新的动力。