简介:werun 是一份面向微信小程序入门与进阶开发者的实战项目源码,核心围绕微信运动步数计数与好友排名展开,适合想通过完整案例掌握小程序开发流程、提升 JavaScript 应用能力的学习者。压缩包共 21 个文件,以 9 个 js 逻辑脚本、4 个 wxss 样式表、4 个 json 配置文件和 3 个 wxml 页面结构为主,另附说明文档,整体约 27KB,体量轻巧便于快速通读。项目覆盖数据获取、步数统计、排行榜排序、用户界面实时更新与授权权限管理等关键环节,读者可借此理解小程序生命周期、项目目录组织及开放接口调用方式。目前已有 1876 人学习下载,适合作为课程设计、练手项目或面试作品参考。
1. 从零做一个 werun 步数计数与排名小程序:先想清楚数据从哪来
微信运动每天产生几百万条步数记录,但真正落到自己项目里做计数和排名,第一道坎不是写页面,而是数据来源。werun 这个标题拆开看,核心是三件事:步数怎么拿到、计数怎么算、排名怎么排。很多人一上来就打开微信开发者工具建页面,结果卡在授权弹窗上——用户点了拒绝,后面全白搭。
这个方案适合两类人:一是想练手微信小程序完整链路的前端,二是需要给内部团队做步数打卡的开发者。它不依赖任何第三方硬件,纯靠微信运动接口就能跑通。但要注意,微信运动的步数数据是「延迟同步」的,不是实时刷新,这一点直接决定了你的计数逻辑和排名刷新策略。下面按我实际踩过的顺序,从授权、拉数据、算排名到避坑,一步步拆开讲。
2. 微信运动授权与步数拉取:wx.getWeRunData 的完整调用链
2.1 为什么不能直接读步数:加密数据与 session_key 的关系
微信运动接口返回的步数不是明文。wx.getWeRunData拿到的是一个加密的cloudID或encryptedData,必须传到后端用session_key解密才能看到真实的步数数组。这个设计是为了防止前端伪造步数,但也意味着你没法纯前端完成计数。
常见做法是:小程序端调用wx.login拿code,后端用code换session_key,同时生成一个自定义登录态(比如 token)返回给前端。前端再调wx.getWeRunData,把encryptedData和iv一起发给后端解密。整个链路里,session_key绝对不能下发到前端,否则等于把解密钥匙给了用户。
我一般会在后端维护一张user_session表,字段包括openid、session_key、expires_at。每次解密前先检查session_key是否过期,过期就让前端重新走wx.login。这一步不做,后面步数拉取会随机失败,而且报错信息很模糊,容易误判成接口限流。
2.2 前端调用与后端解密的最小可运行代码
先看小程序端的调用:
// pages/index/index.js Page({ data: { steps: 0, ranking: [] }, onLoad() { this.loginAndFetchSteps(); }, loginAndFetchSteps() { wx.login({ success: (res) => { if (!res.code) return; // 用 code 换后端登录态 wx.request({ url: 'https://your-domain.com/api/login', method: 'POST', data: { code: res.code }, success: (loginRes) => { const token = loginRes.data.token; wx.setStorageSync('token', token); this.getWeRunData(token); } }); } }); }, getWeRunData(token) { wx.getWeRunData({ success: (res) => { // 把加密数据发给后端解密 wx.request({ url: 'https://your-domain.com/api/werun/decrypt', method: 'POST', header: { Authorization: `Bearer ${token}` }, data: { encryptedData: res.encryptedData, iv: res.iv }, success: (decryptRes) => { // 后端返回解密后的步数数组 this.setData({ steps: decryptRes.data.todaySteps }); this.fetchRanking(token); } }); }, fail: (err) => { // 用户拒绝授权或未开启微信运动 console.error('getWeRunData fail', err); wx.showToast({ title: '请授权微信运动', icon: 'none' }); } }); } });这段代码的关键点有三个:第一,wx.login必须在wx.getWeRunData之前,因为后端需要session_key才能解密;第二,token存在storage里,后续排名接口复用;第三,fail回调里要区分「用户拒绝」和「未开启微信运动」,前者引导去设置页,后者提示去微信运动公众号开启。
后端解密用 Node.js 的crypto模块:
// server/werun.js const crypto = require('crypto'); function decryptWeRunData(sessionKey, encryptedData, iv) { // sessionKey 是 base64 编码的,先解码 const key = Buffer.from(sessionKey, 'base64'); const ivBuffer = Buffer.from(iv, 'base64'); const encryptedBuffer = Buffer.from(encryptedData, 'base64'); // 微信用的是 AES-128-CBC,PKCS7 填充 const decipher = crypto.createDecipheriv('aes-128-cbc', key, ivBuffer); decipher.setAutoPadding(true); let decoded = decipher.update(encryptedBuffer, 'binary', 'utf8'); decoded += decipher.final('utf8'); // decoded 是 JSON 字符串,包含 stepInfoList const data = JSON.parse(decoded); // stepInfoList 是最近30天的步数,每天一个对象 return data.stepInfoList; } module.exports = { decryptWeRunData };参数说明:sessionKey来自wx.login换取的登录态,长度 24 字节 base64;encryptedData是wx.getWeRunData返回的密文;iv是初始向量。解密后的stepInfoList是一个数组,每个元素形如{ timestamp: 1690000000, step: 8234 },时间戳是当天 0 点。
注意:
stepInfoList最多返回最近 30 天的数据,且当天步数在下午之前可能不完整。如果你要做「今日实时排名」,必须接受这个延迟,或者在前端做本地累加(但本地累加无法防作弊)。
2.3 授权失败与 session_key 过期的排查顺序
授权失败最常见的三种情况:用户点了「拒绝」、用户没关注微信运动公众号、session_key过期。排查顺序应该是:先看wx.getWeRunData的fail回调errMsg,如果是auth deny就引导用户去wx.openSetting重新授权;如果是getWeRunData:fail且没有明确原因,大概率是没开启微信运动,提示用户搜索「微信运动」公众号并启用。
session_key过期不会直接报错,而是解密失败,返回-41001或-41003。我的习惯是在后端解密接口里捕获异常,如果错误码是这两个,就返回一个特定状态码给前端,前端收到后重新走wx.login并重试一次。重试还失败就提示用户稍后再试,不要无限循环。
3. 步数计数与排名逻辑:从原始数据到榜单的完整实现
3.1 计数策略:当日步数、周累计与月累计怎么选
拿到stepInfoList后,计数方式取决于你的业务目标。如果只是「今日步数排名」,直接取stepInfoList最后一项的step值。但要注意,微信运动的当日步数在晚上 10 点后才会稳定,白天拉取可能只有几千步,用户会以为你算错了。
我一般会做三层计数:当日步数、本周累计、本月累计。当日步数用于实时榜单,周累计和月累计用于成就体系。周累计的计算方式是:找到本周一的 0 点时间戳,把stepInfoList里timestamp >= 本周一的step相加。月累计同理,找当月 1 号。
这里有个坑:stepInfoList的时间戳是服务器时间,不是用户本地时间。如果用户跨时区,周累计的边界会错。常见做法是统一用北京时间(UTC+8)计算,在代码里把时间戳转成北京时间再判断。
// 计算本周累计步数 function getWeekSteps(stepInfoList) { const now = new Date(); // 转成北京时间 const beijingNow = new Date(now.getTime() + 8 * 3600 * 1000); const dayOfWeek = beijingNow.getUTCDay() || 7; // 周日算第7天 const monday = new Date(beijingNow); monday.setUTCDate(beijingNow.getUTCDate() - dayOfWeek + 1); monday.setUTCHours(0, 0, 0, 0); const mondayTimestamp = Math.floor(monday.getTime() / 1000) - 8 * 3600; return stepInfoList .filter(item => item.timestamp >= mondayTimestamp) .reduce((sum, item) => sum + item.step, 0); }这段代码先把当前时间转成北京时间,再算本周一的 0 点时间戳,最后过滤求和。参数stepInfoList就是解密后的数组。注意mondayTimestamp减去了 8 小时,因为stepInfoList的时间戳是 UTC 时间。
3.2 排名接口设计:Redis 有序集合与 MySQL 的取舍
排名功能的核心是「谁步数多谁靠前」。数据量小的时候(比如几百人),直接 MySQLORDER BY steps DESC就够了。但步数排名是高频读、低频写,而且需要实时刷新,用 Redis 的ZSET更合适。
我的做法是:后端解密步数后,先更新 MySQL 的user_steps表(持久化),同时把当日步数写入 Redis 的ZSET,key 是ranking:today,member 是openid,score 是步数。前端请求排名时,直接ZREVRANGE ranking:today 0 99 WITHSCORES拿前 100 名。
// 更新 Redis 排名 const redis = require('redis'); const client = redis.createClient(); async function updateRanking(openid, todaySteps) { // 写入当日排名 await client.zAdd('ranking:today', { score: todaySteps, value: openid }); // 设置过期时间为当天结束,避免脏数据 const now = new Date(); const endOfDay = new Date(now); endOfDay.setHours(23, 59, 59, 999); const ttl = Math.floor((endOfDay - now) / 1000); await client.expire('ranking:today', ttl); } async function getTopRanking(limit = 100) { // 从高到低拿前 limit 名 const list = await client.zRangeWithScores('ranking:today', 0, limit - 1, { REV: true }); return list.map((item, index) => ({ rank: index + 1, openid: item.value, steps: item.score })); }参数说明:zAdd的score必须是数字,value是用户唯一标识。expire设置 TTL 是为了每天自动清空,不用手动删 key。zRangeWithScores的REV: true表示从高到低排序。
注意:Redis 的
ZSET在成员分数相同时,会按 member 的字典序排列。如果你希望同分用户按更新时间排序,需要把时间戳拼进 score,比如score = steps * 10000000000 + (9999999999 - timestamp)。这个技巧在步数相同的情况下能保证先更新的人排前面。
3.3 前端排名页面的渲染与下拉刷新
前端拿到排名数组后,渲染成列表。每个列表项显示排名、头像、昵称、步数。头像和昵称需要后端从user_info表里关联查询,或者在前端用openid批量请求。我一般会在排名接口里直接返回头像和昵称,减少请求次数。
下拉刷新用onPullDownRefresh,重新调getWeRunData和排名接口。注意刷新频率,微信运动步数更新有延迟,频繁刷新没意义。我的习惯是限制 30 秒内只能刷新一次,用storage记录上次刷新时间。
// pages/ranking/ranking.js Page({ data: { ranking: [], lastRefresh: 0 }, onPullDownRefresh() { const now = Date.now(); if (now - this.data.lastRefresh < 30000) { wx.showToast({ title: '刷新太频繁', icon: 'none' }); wx.stopPullDownRefresh(); return; } this.setData({ lastRefresh: now }); this.fetchRanking().then(() => { wx.stopPullDownRefresh(); }); }, fetchRanking() { return new Promise((resolve) => { wx.request({ url: 'https://your-domain.com/api/ranking/today', header: { Authorization: `Bearer ${wx.getStorageSync('token')}` }, success: (res) => { this.setData({ ranking: res.data.list }); resolve(); } }); }); } });这段代码的关键是lastRefresh的时间戳判断,避免用户疯狂下拉导致接口压力。fetchRanking返回 Promise 是为了在onPullDownRefresh里等数据回来再stopPullDownRefresh,否则下拉动画会提前结束。
4. 避坑与常见问题:步数排名小程序最容易翻车的 5 个点
4.1 用户拒绝授权后无法再次弹窗
现象:用户第一次拒绝微信运动授权后,再次调用wx.getWeRunData直接走fail,不会弹授权框。
原因:微信的授权弹窗每个 scope 只能弹一次,拒绝后必须引导用户去设置页手动开启。
解决:在fail回调里判断errMsg包含auth deny,然后调用wx.openSetting让用户手动勾选「微信运动」。注意wx.openSetting必须在用户点击事件里调用,不能自动触发。
4.2 解密报错 -41001 但 session_key 刚更新过
现象:后端解密返回-41001,但前端刚调完wx.login,session_key应该是新的。
原因:wx.login的code只能用一次,如果后端用同一个code换了两次session_key,第二次会失败并导致session_key失效。
解决:后端换session_key时加锁,确保一个code只换一次。如果并发请求,用 Redis 的SETNX做幂等。
4.3 步数列表时间戳跨天导致周累计少算一天
现象:周一早上拉取步数,周累计比实际少了几千步。
原因:stepInfoList的时间戳是 UTC 时间,周一 0 点北京时间对应 UTC 周日 16 点。如果代码里直接用timestamp >= 周一0点UTC,会把周一凌晨的步数漏掉。
解决:统一用北京时间计算边界,时间戳转换时加减 8 小时。上面getWeekSteps函数已经处理了这个问题。
4.4 Redis 排名数据与 MySQL 不一致
现象:前端看到的排名和数据库里的步数对不上。
原因:更新 MySQL 和 Redis 不是原子操作,如果 Redis 写入失败但 MySQL 成功,或者反过来,就会不一致。
解决:以 MySQL 为准,Redis 只做缓存。每次排名接口返回前,如果发现 Redis 里某个用户步数低于 MySQL,就用 MySQL 的值覆盖 Redis。或者干脆每分钟从 MySQL 全量同步一次到 Redis。
4.5 用户修改手机时间导致步数异常
现象:某个用户步数突然变成 10 万+,明显异常。
原因:微信运动的步数依赖手机传感器,用户修改系统时间可能影响stepInfoList的时间戳,导致同一天出现多条记录。
解决:后端解密后,按timestamp去重,同一天只保留step最大的那条。同时加一个阈值,单日步数超过 5 万就标记为异常,不参与排名。
5. 进阶技巧:用云开发绕过服务器,以及步数排名的防作弊思路
如果你不想维护服务器,微信云开发可以省掉后端解密这一步。云函数里直接调cloud.getWXContext()拿openid,再用cloud.getWeRunData解密。但云开发的session_key管理是黑盒,解密失败时排查困难,我一般只在原型阶段用。
防作弊方面,纯前端无法阻止用户伪造步数,但后端可以做几件事:第一,校验stepInfoList的时间戳连续性,如果同一天出现多条记录且步数差异巨大,标记异常;第二,限制单日步数上限,比如 5 万步;第三,对比用户历史步数,如果某天突然是历史均值的 10 倍,进入人工审核队列。
我自己的习惯是:排名只展示前 100 名,后面的用户只显示「第 100+ 名」,这样即使有少量作弊用户,也不会影响大多数人的体验。另外,每周清零一次排名,给新用户追赶的机会。
// 云函数解密示例 const cloud = require('wx-server-sdk'); cloud.init(); exports.main = async (event) => { const { encryptedData, iv } = event; const wxContext = cloud.getWXContext(); // 云开发自动管理 session_key,直接解密 const result = await cloud.getWeRunData({ encryptedData, iv }); return result.stepInfoList; };这段云函数代码省去了手动换session_key的步骤,但注意cloud.getWeRunData需要在云函数里调用,且用户必须已授权。云开发的免费额度对小型项目够用,但排名数据量大了之后,Redis 还是得自己搭。
希望帮到你。
本文还有配套的精品资源,点击获取