简介:这是一份面向英语学习激励场景的微信小程序前端与Java后端完整项目源码,适合正在准备毕业设计、课程设计或期末大作业的高校学生,特别是计算机、电子信息等需要项目实战练习的开发者。项目代码均经过严格调试,可直接运行,有助于理解从需求分析、数据库设计到前后端交互与激励功能实现的完整流程。压缩包共含1343个文件,大小约14.92MB,目录结构清晰,涵盖Java后端逻辑、Vue管理端页面、小程序wxml/wxss界面以及png设计素材等;同时包含json/sql等配置与数据文件,便于快速还原开发环境并掌握数据存储和激励规则设计。资源内还提供了项目文档及运行脚本,可辅助完成部署与二次开发验证。目前已有183人学习下载,适合作为信息类专业的毕设参考或项目练手素材。
1. 微信小程序的英语学习激励系统:把"激励闭环"做透才是高分点
毕设题目叫"微信小程序的英语学习激励系统",很多人第一反应是做一个背单词工具:单词列表、点开看释义、收藏生词。真按这个思路交上去,答辩老师大概率只追问一句"激励在哪里体现",然后就冷场了。这个题的核心得分点不在页面数量,而在激励逻辑是否成体系——签到奖励、连续学习天数、积分流水、排行榜、成就徽章,五个模块互相咬合,让用户每天愿意回来。这篇笔记按做毕设的实际路线讲:先定激励规则,再设计库表,然后是前端激励展示和后端接口,最后是五个必踩的坑。适合选了小程序方向、不想把毕设做成"套模板堆页面"的同学参考。
2. 先写激励规则再写代码:积分、签到与连续天数的数据设计
写代码之前,第一件事是把激励规则用文字列清楚:什么行为给多少分、什么条件解锁徽章、连续打卡怎么算。规则不定清楚,后面接口和页面都得返工。
2.1 激励闭环的四个模块与数据流
常见做法是把激励系统拆成四段:行为记录、积分结算、激励展示、召回提醒。
- 行为记录:用户在小程序里背单词、学每日一句,产生学习行为。
- 积分结算:后端根据行为计算积分,写入积分流水表。
- 激励展示:首页看板展示连续天数与今日进度,激励中心展示徽章与排行榜。
- 召回提醒:通过微信订阅消息,在用户当天未学习时提醒他回来。
数据流是单向闭环:前端提交学习行为,后端写入学习记录并计算积分,前端拉取积分与徽章状态展示,最后订阅消息把用户拉回。四个环节对应的就是后面要实现的接口:登录、打卡、排行榜、订阅消息发送。答辩时把这个闭环画成一张图,比贴十张页面截图都有说服力,因为老师能看到你是在设计系统,不是在堆页面。
2.2 六张表把激励闭环撑起来
库表是整个系统里最不该返工的部分。选 MySQL 而不是云开发文档数据库,是因为答辩老师大概率会问数据库设计,关系型表结构更容易讲清楚主键、唯一索引、外键和事务,这些都是明确的计分点。我按最常见的 MySQL + InnoDB 设计,字符集用 utf8mb4,因为微信昵称里会出现 emoji,用 utf8 会存不进去。
-- 用户表 CREATE TABLE `user` ( `id` INT AUTO_INCREMENT PRIMARY KEY, `openid` VARCHAR(64) NOT NULL UNIQUE COMMENT '微信用户唯一标识', `nickname` VARCHAR(64) DEFAULT '', `avatar` VARCHAR(255) DEFAULT '', `consecutive_days` INT DEFAULT 0 COMMENT '连续打卡天数', `total_points` INT DEFAULT 0 COMMENT '累计积分', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 学习记录表:记录每天学了哪些单词 CREATE TABLE `learn_record` ( `id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `word_id` INT NOT NULL, `learn_date` DATE NOT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_user_word_date` (`user_id`,`word_id`,`learn_date`), KEY `idx_user_date` (`user_id`,`learn_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 打卡表:一天一条 CREATE TABLE `check_in` ( `id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `checkin_date` DATE NOT NULL, `points` INT NOT NULL COMMENT '本次打卡获得积分', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_user_date` (`user_id`,`checkin_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 积分流水表:所有积分变动都留痕 CREATE TABLE `points_log` ( `id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `change` INT NOT NULL COMMENT '正数为加,负数为扣', `reason` VARCHAR(32) NOT NULL COMMENT 'checkin/learn/badge', `ref_id` INT DEFAULT 0 COMMENT '关联业务记录ID', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, KEY `idx_user_time` (`user_id`,`created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 成就表:用户解锁徽章 CREATE TABLE `achievement` ( `id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `badge` VARCHAR(32) NOT NULL COMMENT '徽章编码', `unlocked_at` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_user_badge` (`user_id`,`badge`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 单词表:示例结构,可按难度分表 CREATE TABLE `word` ( `id` INT AUTO_INCREMENT PRIMARY KEY, `word` VARCHAR(64) NOT NULL, `meaning` VARCHAR(255) NOT NULL, `phonetic` VARCHAR(64) DEFAULT '', `example_sentence` TEXT ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几处关键设计说明。learn_record的联合唯一索引uk_user_word_date用来防止同一个单词在同一天被反复提交刷积分;check_in的uk_user_date保证一个用户一天只有一条打卡记录。积分不直接只累加在 user 表里,而是每一笔变动都写进points_log,这样答辩时能展示"每一分从哪里来"的对账能力。learn_date和checkin_date用 DATE 而不是 DATETIME,因为业务上只关心是哪一天,时间精度反而会带来跨天边界问题。
2.3 积分规则:做成配置而不是写死在 if 里
积分规则如果散落在接口代码里,后期改一个分值要动三处。常见做法是单独抽一个规则配置文件,接口里只引用规则对象。
// utils/reward-rule.js module.exports = { checkinBase: 5, // 完成打卡的基础积分 streakStep: 2, // 连续天数每增加1天,额外加2分 learnPerWord: 1, // 每学一个单词1分 learnDailyLimit: 20, // 每日学习积分上限,防止刷分 badgeRules: [ { badge: 'first_checkin', name: '初次见面', condition: { type: 'checkin_count', min: 1 } }, { badge: 'streak_7', name: '一周坚持', condition: { type: 'streak_days', min: 7 } }, { badge: 'words_100', name: '百词记录', condition: { type: 'learn_count', min: 100 } } ] };这里把徽章条件设计成{ type, min }对象而不是一长串 if/else,是为了让新增徽章变成纯配置操作。判定函数里用一个 switch 把 type 映射到用户统计数据字段,比如streak_days读user.consecutive_days。答辩时当场演示"把 learnDailyLimit 从 20 改成 10,系统自动限流",比现场翻代码改逻辑更有设计感。
3. 前端把激励感做出来:进度环、打卡页与徽章墙
后端把数据和规则准备好,前端只负责一件事:让用户直观看到"我今天学到了什么程度、还差多少、能拿什么奖励"。三个页面是核心:首页看板、学习打卡、激励中心。如果以后想跨 Android/iOS,视图层可以换 uni-app 重写,但库表和后端接口不用动,这也是把数据设计和页面设计分开的好处。
3.1 首页学习看板:进度环与导航栏适配
首页要展示今日学习进度和连续打卡天数。进度环不用 canvas,用 CSS 的 conic-gradient 就能画,代码量小,低端安卓机上也不卡。
<view style="background: conic-gradient(#07c160 {{progress}}%, #f2f2f2 {{progress}}%)" class="ring"> <view class="ring-inner"> <text class="percent">{{progress}}%</text> <text class="sub">今日已学 {{learnedCount}}/{{dailyGoal}} 词</text> </view> </view>.ring { width: 320rpx; height: 320rpx; border-radius: 50%; display: flex; align-items: center; justify-content: center; } .ring-inner { width: 280rpx; height: 280rpx; background: #fff; border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; }进度值在页面 onShow 里计算:progress = Math.round(learnedCount / dailyGoal * 100)。用户从打卡页返回首页时数据必须是最新的,onLoad 只在首次进入执行一次,这个细节很影响演示效果。
另一个首页必踩的点是自定义导航栏。如果你想把连续天数做成导航栏下一块醒目的卡片,就需要自定义导航栏,此时必须动态计算状态栏高度和胶囊按钮位置,这就是微信小程序顶部导航栏高度的经典适配问题。
// utils/navbar.js function getNavBarInfo() { const win = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync(); const menu = wx.getMenuButtonBoundingClientRect(); const navBarHeight = (menu.top - win.statusBarHeight) * 2 + menu.height; return { statusBarHeight: win.statusBarHeight, navBarHeight, menuRight: win.screenWidth - menu.right }; }wx.getMenuButtonBoundingClientRect()拿到胶囊按钮的位置,navBarHeight按胶囊上下间距对称推导。不同手机上这个值不一样,绝对不能写死;写死的话,不同机型首页头部会一高一矮,演示时很尴尬。
3.2 学习打卡页:单词卡片与打卡状态缓存
打卡页的交互用点击翻卡片就够:正面是单词和音标,点击翻转显示释义,点击"认识"把单词 id 记入learnedIds数组。关键在打卡提交的时机与防重复。
// pages/learn/learn.js 片段 const STORAGE_KEY = 'learn_daily_status_v1'; submitCheckin() { const today = this.formatDate(new Date()); const cached = wx.getStorageSync(STORAGE_KEY); // 本地缓存命中且是今天,直接置为已打卡,不再发请求 if (cached && cached.date === today && cached.done) { this.setData({ checkedIn: true }); return; } wx.request({ url: `${getApp().globalData.baseUrl}/api/learn/checkin`, method: 'POST', data: { wordIds: this.data.learnedIds }, header: { Authorization: 'Bearer ' + getApp().globalData.token }, success: (res) => { if (res.data.code === 0) { // 写本地缓存并带上当天日期,第二天自动失效 wx.setStorageSync(STORAGE_KEY, { date: today, done: true }); this.setData({ checkedIn: true, earnedPoints: res.data.earnedPoints }); } } }); }缓存 key 带了v1后缀,以后改数据结构或接口路径时,改个版本号就能让旧缓存自动失效。date字段用于区分是不是同一天,第二天再进来时缓存命中条件失败,会重新请求接口。这其实就是微信小程序设置缓存时间的常见做法:不依赖 wx.setStorage 的物理过期,而是把业务时间戳写进 value 自己判断。项目里 wx.request 建议统一封装到 utils/request.js,自动带上 baseUrl 和 token,统一处理code !== 0的错误,避免每个页面重复写 header。
3.3 激励中心:徽章墙与排行榜
激励中心用两个 Tab:徽章墙和排行榜。徽章数据来自后端 achievement 列表,页面只做展示和锁定态区分。
<view class="badge-grid"> <view class="badge-item {{item.unlocked ? '' : 'locked'}}" wx:for="{{badges}}" wx:key="badge"> <image src="{{item.icon}}"></image> <text>{{item.name}}</text> <text class="cond">{{item.conditionText}}</text> </view> </view>解锁态用查询接口返回的unlocked布尔值,配合 WXSS 给.locked加灰色滤镜。排行榜在 onShow 里拉取,切换日榜、周榜、总榜时传period参数给后端接口。我一般会在榜单顶部高亮当前用户那一行,哪怕没进前三,用户也能找到自己,这对"激励感"的提升非常明显,代码只是wx:if判断item.userId === currentUserId。排行榜不分页,后端直接返回前 50 名,激励类榜单只有展示头部才有冲击力,超过一屏用 scroll-view 滚动即可。
4. 后端接口与微信登录:从 code2session 到积分事务
前端能不能拿到真实数据,取决于三个接口:登录、打卡、排行榜。登录解决"你是谁",打卡解决"行为怎么记账",排行榜解决"激励往哪展示"。
4.1 登录接口:wx.login 换 openid 与 token
小程序端不能直接持有appid/secret,必须用wx.login拿到的临时 code,到后端换取 openid。code 有效期只有五分钟且只能用一次。后端用 Node.js 写只是演示方便,用 Java/Spring Boot 实现时流程完全不变,把 axios 换成 RestTemplate 或 OkHttp,接口路径和参数保持一致,前端不用动。
// routes/auth.js const axios = require('axios'); const jwt = require('jsonwebtoken'); router.post('/api/login', async (req, res) => { const { code } = req.body; const { WX_APPID, WX_SECRET } = process.env; // secret 只存在服务端 const url = 'https://api.weixin.qq.com/sns/jscode2session'; const qs = { appid: WX_APPID, secret: WX_SECRET, js_code: code, grant_type: 'authorization_code' }; const { data } = await axios.get(url, { params: qs }); if (!data.openid) return res.status(401).json({ error: 'jscode2session failed' }); let user = await findUserByOpenid(data.openid); if (!user) user = await createUser({ openid: data.openid }); const token = jwt.sign( { userId: user.id, openid: data.openid }, process.env.JWT_SECRET, { expiresIn: '7d' } ); res.json({ token, userId: user.id }); });登录成功后返回 JWT,小程序端存到 Storage,后续请求都带Authorization头。JWT 的好处是后端不需要维护 session 表,七天过期时间在毕设里够用。findUserByOpenid和createUser是数据访问层封装,实际项目放到 dao 目录单独维护。WX_SECRET必须放环境变量,绝不能写进小程序代码或前端仓库。
4.2 打卡接口:唯一索引加事务,把防重复做在后端
打卡接口是整个系统最核心的接口,要同时完成三件事:写打卡记录、计算积分、写积分流水。这三件事必须在一个事务里,任何一步失败都要整体回滚。
// routes/learn.js router.post('/api/learn/checkin', requireAuth, async (req, res) => { const { userId } = req.user; const { wordIds } = req.body || []; const today = getServerDate(); // 用服务器日期,不信任前端传的日期 const conn = await mysql.createConnection(dbConfig); await conn.beginTransaction(); try { // 行锁 + 唯一索引兜底,防止并发重复打卡 const existing = await conn.query( 'SELECT id FROM check_in WHERE user_id = ? AND checkin_date = ? FOR UPDATE', [userId, today] ); if (existing.length > 0) { await conn.rollback(); return res.json({ code: 1, msg: '今日已打卡' }); } // 本日有效学习数量:按单词去重,防止同一单词反复提交 const learned = await conn.query( 'SELECT COUNT(DISTINCT word_id) AS cnt FROM learn_record WHERE user_id = ? AND learn_date = ?', [userId, today] ); const earn = Math.min(learned[0].cnt * rewardRule.learnPerWord, rewardRule.learnDailyLimit); // 打卡记录 + 积分流水 + 用户积分累加,同一事务 const insertResult = await conn.query( 'INSERT INTO check_in (user_id, checkin_date, points) VALUES (?, ?, ?)', [userId, today, earn] ); await conn.query( 'INSERT INTO points_log (user_id, `change`, reason, ref_id) VALUES (?, ?, ?, ?)', [userId, earn, 'checkin', insertResult.insertId] ); await conn.query( 'UPDATE user SET total_points = total_points + ? WHERE id = ?', [earn, userId] ); await conn.commit(); res.json({ code: 0, earnedPoints: earn }); } catch (e) { await conn.rollback(); res.status(500).json({ error: e.message }); } });FOR UPDATE是行锁,两个请求同时进来时,后一个会等前一个提交后再查,此时前一个已插入记录,后一个查出来就返回"今日已打卡"。COUNT(DISTINCT word_id)是防刷的第二道闸,同一单词提交十次只算一次。积分上限learnDailyLimit来自规则配置,接口里不写死。
连续天数的计算没有放进上面代码,因为主流程已经够长。实际可以抽成一个updateStreak(userId, today)函数,在事务提交后调用:查询用户最近一条打卡日期,是昨天则连续天数加一,是今天说明是重复请求不处理,否则重置为一。
提示:打卡接口的重复提交防御,不要只依赖前端按钮置灰。按"唯一索引 + 行锁 + 服务端积分上限"三层设计,才能扛住并发请求和手动刷接口。
4.3 排行榜接口:先别急着上 Redis,把 SQL 写对
排行榜有三种区间:日榜、周榜、总榜。毕设数据量在几千条时,直接查points_log聚合完全够用,Redis 是加分项但不必要。
-- 日榜:当日积分流水聚合 SELECT p.user_id, u.nickname, u.avatar, SUM(p.change) AS point FROM points_log p JOIN user u ON u.id = p.user_id WHERE p.created_at >= CURDATE() GROUP BY p.user_id, u.nickname, u.avatar ORDER BY point DESC LIMIT 50;周榜把WHERE换成p.created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY),总榜直接对user.total_points排序。这里有个易错点:日榜周榜必须从points_log聚合,不能查user.total_points,因为后者是历史累计,体现不了"今天谁学得最多"。当数据量到几万条而且接口被频繁查时,再把聚合结果缓存 60 秒,失效时间设在每天 0 点过后 30 秒,避免当日榜单被整天缓存,或者换 Redis ZSET 维护排名。答辩时主动说出"什么时候该换 ZSET"的判断依据,比闷头写接口得分高。
5. 毕设避坑实录:微信登录、订阅消息与刷分的 5 个常见问题
做这个题目最容易翻车的五个位置,每一条都是"现象、原因、解决"的完整记录。前四条是微信小程序生态特有的平台约束,平时写普通网页根本遇不到;第五条是数据库层面的经典问题。真机调试时全程开着开发者工具自带的 Network 面板看请求状态,很多问题一眼就能定位。
5.1 真机预览登录失败:appid 与 request 合法域名
现象:开发者工具里一切正常,点真机预览后请求全部失败,Network 面板显示url not in domain list,登录按钮转圈后没有反应。
原因:开发工具调试模式下"不校验合法域名"选项只对当前工具会话生效,手机上是真机环境,所有 wx.request 的域名必须在小程序管理后台的"服务器域名"里配置过,而且必须是 HTTPS。另一个常见原因是用了测试号 appid,测试号的登录接口和正式 appid 行为不完全一致。
解决:注册小程序账号后,在 mp.weixin.qq.com 后台把后端接口域名加入 request 合法域名。域名需要是已备案的 HTTPS 域名,这一步要提前一周做。本地开发可以勾选"不校验合法域名",但提交体验版之前必须关掉再全量测一遍。预览和演示请用真机扫码,模拟器不校验的部分平台行为,真机会暴露。
5.2 订阅消息收不到:一次性订阅的限制
现象:用户点了"允许订阅",第二天服务端推送消息,用户手机上什么都没收到。
原因:微信订阅消息是一次性的,用户在 button 上点一次授权,后端最多只能发一条模板消息。如果授权流程里只让用户点了一次,而后端打算每天发提醒,第二天就必然失败。模板关键词还要先在后台申请并通过审核,模板 ID 对不上也会静默失败。
解决:在打卡成功、解锁徽章这类"用户刚得到正反馈"的时机,引导用户再次点击订阅按钮,每次授权对应一次发送配额。服务端把配额存进一张subscribe_quota表,发送一条就消费一条。演示时不要承诺"每天提醒",改成"完成七天打卡后收到学习报告"这种单次触达,既能展示订阅消息能力,又避开频率限制。
5.3 打卡积分被刷:前端校验不可信
现象:测试人员连续点击打卡按钮,或者拿到一个用户 token 后直接拼接口,积分短时间内暴涨,排行榜被刷穿。
原因:页面上的"已打卡"判断只存在于小程序端,token 是明文存在 Storage 里的,复制出来就能直接调接口。后端如果没做幂等,同样请求发一百次就会加一百分。
解决:三层防御。第一层是check_in表唯一索引加FOR UPDATE行锁,数据库层面保证一天一条;第二层是learn_record的联合唯一索引,同一单词同一天只能计入一次;第三层是服务端积分上限learnDailyLimit,即使前两层被绕过,单日积分也有天花板。排查看积分流水时,用GROUP BY reason看每个积分来源的分布,能快速定位异常行为。
5.4 连续天数计算错乱:本地时间与跨天边界
现象:用户晚上 23:59 打卡,第二天 00:01 再打卡,连续天数要么断了要么没增加,和页面显示的日期对不上。
原因:前端new Date()取的是手机本地时间,用户改系统时间或时区异常都可能导致日期错误。另一个问题是后端计算连续天数时用了数据库的now(),而没有用业务上的checkin_date做判断,跨天瞬间的边界没处理。
解决:所有日期以服务器为准,打卡接口里的getServerDate()返回后端当天日期,前端只拿这个值做展示。连续天数判断统一写成:最近一条打卡日期等于昨天则加一,等于今天说明重复打卡,否则重置为一。写完用一组跨天用例验证:昨天没打卡、昨天已打卡、今天已打卡、连续七天打卡,四种情况各跑一遍。
5.5 排行榜接口越查越慢:缺索引与聚合扫描
现象:用户量到几百、积分流水几千条后,排行榜接口耗时从几十毫秒涨到一秒多,首页打开明显卡顿。
原因:points_log表在user_id和created_at上没建索引,日榜聚合WHERE created_at >= CURDATE()会全表扫描。更隐蔽的问题是查询里写了DATE(created_at) = CURDATE(),函数包裹字段会让索引失效。
解决:建表时保留KEY idx_user_time (user_id, created_at),查询范围直接写created_at >= CURDATE()这种能用索引的写法。日榜周榜加上 LIMIT 50 截断,总榜直接读user.total_points。做到这一步,几千条数据下毫秒级返回。如果用的云开发数据库,也要在控制台给集合字段建索引,否则免费配额下很容易查询超时。
6. 答辩前把激励系统做成亮点:验证数据闭环的三种做法
毕设答辩最怕被问"你怎么证明这个系统是有效的"。我习惯提前做三件事,让数据替系统说话。
第一件事是跑对账脚本。用一条 SQL 验证每个用户的total_points与points_log累计值一致,不一致就说明有积分凭空产生或被吞掉,这是激励系统的黑匣子检查:
SELECT u.id, u.total_points, IFNULL(SUM(p.change), 0) AS log_sum FROM user u LEFT JOIN points_log p ON p.user_id = u.id GROUP BY u.id HAVING u.total_points != log_sum;第二件事是准备一条演示主线:新用户扫码进入、微信登录、学十个单词、打卡拿积分、徽章解锁、排行榜上榜。演示前在库里预置三五个账号和几百条学习记录,让排行榜看起来真实,同时明确告诉老师哪些是构造数据、哪些是现场操作,这是测试数据的标准做法,不算造假。演示时每点一步,切到数据库或服务端日志里指出对应记录的变化,比反复点页面更有说服力。
第三件事是拉一周的学习趋势:SELECT checkin_date, COUNT(*) FROM check_in GROUP BY checkin_date。如果连续打卡人数随积分奖励上升,说明激励规则起作用了;如果曲线是平的甚至下降,就解释为规则参数还需要调优,这正好证明"可配置规则"这个设计的意义。
我答辩前的习惯是导出一份points_log流水,逐条确认每笔积分都有来源、能对到业务记录。激励系统最怕数据闭环对不上,能经得起这笔对账,就已经赢过大多数只堆页面的同类毕设。希望这些拆解对你做这个题目有帮助,拿到高分。
本文还有配套的精品资源,点击获取