简介基于小程序的互动打卡项目是一套面向高校毕业设计、课程设计及小程序入门学习者的完整前后端源码。后端采用 Java SSM 框架与 MySQL 5.7 数据库前端基于 uniapp/原生小程序实现支持 HBuilder X 与微信开发者工具运行功能覆盖定时/定位打卡、打卡记录、排行榜、积分与好友互动等可适用于企业考勤、学校签到、活动打卡等场景。资源共1248个文件含 67 个 Java 类、198 个 JavaScript 脚本、122 个 CSS 样式、30 个 WXML 页面及 82 张 PNG 图片等另附 SQL 脚本与部署文档整体压缩包约 8.77 MB。已有 152 人学习。学习者可直接导入 Eclipse/IDEA 与微信开发者工具参照部署说明完成环境搭建适合快速掌握 SSM 后端开发与小程序前端联调流程亦可作为毕业设计答辩或课程实践的项目底稿。1. 互动打卡小程序源码包完整前后端MySQL 到底怎么落地我见过太多人拿到一个“基于小程序的互动打卡小程序源代码完整前后端mysql.zip”解压后卡在第一步小程序端能打开后端跑不起来数据库表建得乱七八糟最后只能放弃。这类项目的技术栈通常并不复杂一个小程序前端、一个提供接口的后端服务、一个 MySQL 数据库核心业务就是用户每天打卡、攒积分、看排行。但恰恰是“完整前后端 MySQL”这几个字把没有完整部署经验的人拦在门外。本文按我实际搭建同类项目的顺序把表结构、接口、小程序请求层、排行榜和补卡规则拆开讲结尾再列五个上线前后必踩的坑。适合准备做社群打卡、团队学习打卡、校园活动签到的开发者也适合想把源码改造后给自己业务用的运营同学。2. 互动打卡的业务闭环与 MySQL 选型先想清楚再动手互动打卡这个标题看起来只有一个动作真正做起来却是四个环节。用户在小程序里点打卡只是入口后面跟着校验、记账、算连续天数、加积分、更新排行。任何一个环节想不清楚源码包拿到手也只能看个热闹。把业务闭环拆明白再去碰代码返工率会低很多。2.1 互动打卡在数据上到底要存什么任务、记录、积分、排行先把数据模型拆开。任务表定义用户可以对什么打卡比如早起打卡、每日阅读、背单词每项配置单次积分和时间范围。用户表保存 openid、当前连续天数、累计天数和总积分注意它是可变数据不是流水。打卡记录表是核心流水一天同一任务只能有一条记录靠唯一索引约束。积分流水表记录每一次积分变动后台对账和用户明细都查它。排行榜不需要单独建表直接基于用户表的积分和连续天数排序用户量大时再做快照。数据实体主要字段承担职责任务表title、points、start_date、end_date定义可打卡的活动和单次积分用户表openid、continuous_days、points保存用户当前状态和总积分打卡记录表user_id、task_id、checkin_date记录每次打卡唯一索引防重复积分流水表user_id、change_value、reason记录积分变动支持对账和申诉这四类数据缺一不可。只有打卡记录你算连续天数就得一次扫几千行只有用户表丢了积分变动历史用户申诉时你说不清来源没有任务表后面想同时跑多个打卡活动就无从下手。所以源码包里的表结构如果少于这四类我一般会先补表再改后端。2.2 为什么持久层选 MySQL和 Redis 的取舍很多人一看到排行和积分就想到 Redis但打卡数据本质上是账本。用户今天是否已打卡、补卡记录了没有、积分流水是否完整这些都需要长期保存、随时回溯。Redis 的持久化能力更适合做缓存和计数器不适合作为唯一的记账源。小项目直接用 MySQL 一个库就足够等排行榜负载上来再在 MySQL 前面加 Redis 做热点缓存而不是反过来。选 MySQL 还有一个实际好处SQL 排查方便。用户说积分不对你一条 SELECT 就能定位到流水和打卡记录不需要翻编程语言的客户端 API。如果你拿到的源码包用的是 SQLite 或 MongoDB迁移思路也一致把连接配置和日期函数改一改表和业务逻辑照搬。我通常会把建表脚本单独抽出来因为后端代码可以改表结构错了连带的坑最多。2.3 前后端边界连续天数计算必须放在后端前后端职责不清是这类项目翻车的重灾区。小程序端可以做按钮状态、动画、请求 loading但连续天数、积分、今日是否已打卡不要在小程序里算。手机本地时间能改服务器和用户也可能跨时区前端算出来的连续天数天然不可信。我见过的做法是后端返回一个统一状态对象小程序只管渲染。{ code: 0, data: { checkedInToday: true, continuousDays: 7, totalDays: 30, points: 220, rank: 8 } }小程序端拿到这个 JSON 后直接显示不做任何日期运算。这样当后端调整补卡规则、跨时区策略时小程序不用发版。记住这条边界后面遇到的坑会少一半。3. 把完整前后端跑起来建库、后端接口、小程序请求三步走跑通一个完整前后端 MySQL 的互动打卡项目核心就三步先建库建表再写后端打卡接口最后接小程序请求层。很多人习惯先跑小程序端看到页面能打开就以为成功其实后端接口不通点打卡按钮只会一直报错。3.1 建库建表五张表的 SQL 与字段参数建库是第一步我通常按五张表起步。下面的 SQL 可以直接执行覆盖用户、任务、打卡记录、积分流水和补卡记录前四张是核心补卡表到第 4 章才会用到这里先建好。CREATE DATABASE IF NOT EXISTS checkin_app DEFAULT CHARACTER SET utf8mb4; USE checkin_app; CREATE TABLE user ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, openid VARCHAR(64) NOT NULL COMMENT 小程序用户标识, nickname VARCHAR(50) DEFAULT , continuous_days INT NOT NULL DEFAULT 0 COMMENT 当前连续打卡天数, total_days INT NOT NULL DEFAULT 0 COMMENT 累计打卡天数, points INT NOT NULL DEFAULT 0 COMMENT 总积分, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_openid (openid) ) ENGINEInnoDB; CREATE TABLE task ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, title VARCHAR(100) NOT NULL, points INT NOT NULL DEFAULT 10, start_date DATE NOT NULL, end_date DATE DEFAULT NULL, is_active TINYINT NOT NULL DEFAULT 1, PRIMARY KEY (id) ) ENGINEInnoDB; CREATE TABLE checkin_record ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, user_id INT UNSIGNED NOT NULL, task_id INT UNSIGNED NOT NULL, checkin_date DATE NOT NULL COMMENT 打卡日期只存当天, points_earned INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_task_date (user_id, task_id, checkin_date), KEY idx_checkin_date (checkin_date) ) ENGINEInnoDB; CREATE TABLE points_log ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, user_id INT UNSIGNED NOT NULL, change_value INT NOT NULL COMMENT 正数增加负数扣减, reason VARCHAR(50) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user_id (user_id) ) ENGINEInnoDB;注意几个参数字符集必须用 utf8mb4因为小程序用户昵称可能带 Emoji用 utf8 会在写入时直接报错日期字段用 DATE 不用 DATETIME打卡是按天为单位的openid 做唯一键避免同一用户重复注册打卡记录的唯一键要带 user_id、task_id、checkin_date 三列这是防重复打卡的最后一道防线。生产环境不要用 root 直连我会新建一个业务账号只授权 checkin_app 库。3.2 后端接口打卡提交与连续天数判定逻辑后端接口是核心我以 Node.js Express mysql2 为例写打卡提交接口。Java 或 PHP 版逻辑等价替换连接池和 SQL 语法即可。这一节代码可以直接抄到路由文件里。const express require(express); const mysql require(mysql2/promise); const app express(); app.use(express.json()); const pool mysql.createPool({ host: 127.0.0.1, user: checkin_user, password: your_password, database: checkin_app, waitForConnections: true, connectionLimit: 10, }); app.post(/api/checkin, async (req, res) { const { openid, taskId } req.body; const today new Date(); const todayStr formatDate(today); const yesterdayStr formatDate(new Date(today.getTime() - 86400000)); const conn await pool.getConnection(); try { await conn.beginTransaction(); const [users] await conn.query(SELECT * FROM user WHERE openid ? FOR UPDATE, [openid]); let userId; let continuous 1; if (users.length 0) { const [r] await conn.query(INSERT INTO user (openid) VALUES (?), [openid]); userId r.insertId; } else { userId users[0].id; continuous users[0].continuous_days 1; } const [exists] await conn.query( SELECT id FROM checkin_record WHERE user_id ? AND task_id ? AND checkin_date ?, [userId, taskId, todayStr] ); if (exists.length 0) { await conn.rollback(); return res.json({ code: 1, msg: 今天已打卡 }); } const [yesterday] await conn.query( SELECT id FROM checkin_record WHERE user_id ? AND task_id ? AND checkin_date ?, [userId, taskId, yesterdayStr] ); if (yesterday.length 0) continuous 1; await conn.query( INSERT INTO checkin_record (user_id, task_id, checkin_date, points_earned) VALUES (?, ?, ?, ?), [userId, taskId, todayStr, 10] ); await conn.query( UPDATE user SET continuous_days ?, total_days total_days 1, points points 10 WHERE id ?, [continuous, userId] ); await conn.query( INSERT INTO points_log (user_id, change_value, reason) VALUES (?, 10, ?), [userId, daily_checkin] ); await conn.commit(); res.json({ code: 0, data: { userId, continuous, points: users[0].points 10 } }); } catch (e) { await conn.rollback(); res.status(500).json({ code: 500, msg: e.message }); } finally { conn.release(); } }); function formatDate(d) { return ${d.getFullYear()}-${String(d.getMonth() 1).padStart(2, 0)}-${String(d.getDate()).padStart(2, 0)}; }这段代码做了三件事事务保底、先查后插、唯一索引兜底。FOR UPDATE 锁住用户行防止两个并发请求同时读到同一份连续天数查昨天记录是为了判断连续是否中断插入记录前再查一次今天是否已打是为了给前端返回友好提示。参数说明openid 是前端登录后换到的用户标识taskId 对应任务表主键积分先用固定 10后续再按连续天数规则调整。事务里不要执行耗时的网络请求否则会长时间占用数据库连接。3.3 小程序端 request 封装与按钮防重小程序端我习惯封装一个 request 方法所有接口统一走它方便以后切换正式域名和统一错误提示。下面代码演示打卡按钮的完整交互。// utils/request.js function request(path, data) { return new Promise((resolve, reject) { wx.request({ url: http://127.0.0.1:3000 path, method: POST, data, header: { content-type: application/json }, success: (res) resolve(res.data), fail: reject, }); }); } // pages/index/index.js Page({ data: { checkinText: 打卡, loading: false }, async onCheckin() { if (this.data.loading) return; this.setData({ loading: true }); try { const openid wx.getStorageSync(openid); const res await request(/api/checkin, { openid, taskId: 1 }); if (res.code 0) { this.setData({ checkinText: 已打卡 }); wx.showToast({ title: 打卡成功 }); } else { wx.showToast({ title: res.msg, icon: none }); } } finally { this.setData({ loading: false }); } }, });代码里的 loading 是前端防重但真正的防重靠后端唯一索引。openid 从 storage 拿只是演示真实项目要先在小程序 login 里拿 code由后端向平台接口换取 openid 再存起来。请求地址写 127.0.0.1 只能在开发者工具里用真机预览必须换成电脑的局域网 IP这个坑在第 5 章详细展开。4. 互动玩法落地连续打卡奖励、补卡规则与排行榜调参跑通基础打卡后互动属性才是留住用户的关键。连续打卡奖励、补卡规则、排行榜这三点决定了用户第三天、第七天、第三十天还会不会打开小程序。下面按“表结构 参数调法”的方式讲清楚。4.1 补卡规则消耗积分购买连续天数补卡是互动打卡最容易做崩的功能。常见规则是每月补卡次数有限每次消耗积分。为了避免用户把补卡当免死金牌补卡记录必须有状态审核不能直接写入打卡记录表。CREATE TABLE makeup_record ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, user_id INT UNSIGNED NOT NULL, target_date DATE NOT NULL COMMENT 补到哪一天, cost_points INT NOT NULL DEFAULT 50, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待审核 1生效 2驳回, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_target (user_id, target_date) ) ENGINEInnoDB;target_date 是要补的日期status 先置 0管理端审核通过后再往打卡记录表插一条记录并重算连续天数。注意唯一索引防止对同一天反复补卡。审核状态很重要否则用户自己绕过小程序直接调接口就能把连续天数刷满。我会把补卡积分设置成单次打卡积分的 5 倍比如打卡得 10 分补卡花 50 分这样用户会更珍惜连续记录。4.2 积分奖励梯度怎样设置才不枯燥积分奖励梯度决定用户能坚持多久。直接每天固定分用户第三天就没感觉我一般会在连续第 7、30、100 天给额外奖励中间每天保持恒定。奖励阈值可以先按 7 的倍数来因为一周是用户习惯形成的最小周期。连续天数额外奖励设计意图当天打卡10分基础奖励保持每日反馈连续7天30分让用户跨过第一周放弃点连续30天100分形成月度习惯防止月底流失连续100天300分长期荣誉奖励制造社交话题实现逻辑在后端打卡接口里判断连续天数是 7 的倍数时额外加一笔积分同时写积分流水。if (continuous % 7 0) { const bonus continuous 30 ? 100 : 30; await conn.query(UPDATE user SET points points ? WHERE id ?, [bonus, userId]); await conn.query( INSERT INTO points_log (user_id, change_value, reason) VALUES (?, ?, ?), [userId, bonus, continuous_bonus] ); }参数按业务改如果产品面向学生可以把连续奖励调高如果面向职场补卡次数更重要。积分流水里 reason 记 continuous_bonus方便以后做用户分析。4.3 排行榜缓存与快照MySQL 单表查询的边界排行榜最直接的做法是SELECT * FROM user ORDER BY points DESC, continuous_days DESC LIMIT 100。用户量到几千后每次打开榜单都扫全表MySQL CPU 会持续偏高。我的做法是生成每日排行榜快照表小程序端只查快照个人页实时积分查用户表。CREATE TABLE rank_snapshot ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, snapshot_date DATE NOT NULL, user_id INT UNSIGNED NOT NULL, points INT NOT NULL, continuous_days INT NOT NULL, rank_no INT NOT NULL, PRIMARY KEY (id), KEY idx_date_rank (snapshot_date, rank_no) ) ENGINEInnoDB;定时任务每天凌晨重建快照代码如下。注意 MySQL 8.0 以上可以直接用窗口函数5.7 没有 ROW_NUMBER需要改用 rank 变量。const cron require(node-cron); cron.schedule(0 1 * * *, async () { const conn await pool.getConnection(); await conn.query(DELETE FROM rank_snapshot WHERE snapshot_date ?, [yesterdayStr]); await conn.query( INSERT INTO rank_snapshot (snapshot_date, user_id, points, continuous_days, rank_no) SELECT ?, id, points, continuous_days, ROW_NUMBER() OVER (ORDER BY points DESC, continuous_days DESC, id ASC) as rn FROM user ORDER BY points DESC, continuous_days DESC, id ASC LIMIT 100 , [todayStr]); conn.release(); });快照表按天重建排行榜一天一变对打卡产品来说完全够用。如果产品要求实时榜单再在 Redis 里用有序集合存 TOP100MySQL 仍然保留全量数据做对账。不要在一开始就把实时排行做成核心成本和收益不成正比。5. 避坑互动打卡小程序从部署到上线最常见的五个问题下面是这类项目里最常见的五个坑按出现频率从高到低排。每一条都按现象、原因、解决三步说清楚直接照着排查。5.1 真机预览时小程序请求不到后端现象开发者工具里点击打卡正常手机扫码真机预览后一直转圈请求超时后端日志里没有任何请求进来。原因真机上 127.0.0.1 指向手机自身不是电脑小程序正式环境还要求请求域名已备案且支持 HTTPS真机开发版默认不校验合法域名但需要后端通过局域网 IP 才能访问到。解决把接口地址改成电脑的局域网 IP。小程序开发者工具里勾选“不校验合法域名”请求 url 换成http://192.168.1.100:3000手机和电脑连同一个 WiFi。上线后必须把接口地址换成正式域名并配 HTTPS。5.2 同一天重复打卡写了两条记录现象慢网络下快速点击打卡按钮后端日志出现两条 insert积分加了两次用户赚了漏洞。原因前端按钮 loading 挡不住并发点击后端先查后插在并发下不是原子操作。两个请求同时查到“今天未打卡”就同时插入。解决靠uk_user_task_date唯一索引兜底第二个插入会报 Duplicate Entry。在后端捕获该错误返回“今天已打卡”。只靠事务加 SELECT FOR UPDATE 也能挡但唯一索引更简单还能防止历史脏数据。5.3 时区问题导致连续打卡误判断签现象用户凌晨打卡系统却判断他昨天没打连续天数从中间断开用户截图投诉。原因后端new Date()取的是服务器本地时间。如果服务器时区是 UTC业务时区是东八区凌晨 0 点到 8 点会被归到前一天。解决所有日期判断统一挂业务时区。Node.js 启动时设TZAsia/ShanghaiMySQL 连接参数加timezone: 08:00。打卡日期只存YYYY-MM-DD不存时分秒。这样“今天”以业务时区为准不受服务器环境干扰。5.4 数据库连接被安全组拦掉导致后端启动后接口 500现象后端日志报ECONNREFUSED 127.0.0.1:3306本机用数据库客户端却连得上云服务器上的服务连不上。原因MySQL 的bind-address只监听了 127.0.0.1或云服务器入方向安全组没有放行 3306 端口。解决本地开发用 127.0.0.1部署到云服务器时确认安全组放行 3306MySQL 配置改为bind-address 0.0.0.0。不要用 root 直连业务新建一个专用账号并限制可访问库。生产环境更推荐后端通过内网 IP 连数据库不把 3306 暴露到公网。5.5 积分流水表越来越大个人页查询开始卡顿现象上线一个多月积分流水几万条用户点开积分明细要等两秒后端查询日志显示全表扫描。原因积分流水表只建了user_id索引没有考虑时间维度代码里一次把全部流水查出来再分页数据量大后必然慢。解决给流水表加联合索引(user_id, created_at)并定期把半年前的流水归档到历史表。查询只取最近 50 条不要全量加载。索引语句直接执行ALTER TABLE points_log ADD INDEX idx_user_time (user_id, created_at);。6. 让互动打卡项目更像产品的三个进阶技巧基础功能跑通后还需要三个技巧让项目脱离半成品状态。6.1 用订阅消息做次日提醒打卡产品最怕用户忘记。在小程序里申请订阅消息模板打卡成功后弹窗请求授权授权过的用户由后端在每晚固定时间发次日提醒。订阅消息是一次性的用户每次打卡都可以重新授权正好形成每天一次的触达闭环。wx.requestSubscribeMessage({ tmplIds: [模板ID], success(res) { if (res.errMsg requestSubscribeMessage:ok) { wx.setStorageSync(subscribed, true); } }, });注意订阅消息的发送接口需要用户 openid 和模板 ID后端要维护一张订阅关系表避免重复发送。6.2 每日任务刷新用定时器而不是用户触发很多新人把“新的一天任务”写在小程序端的 onShow 里用户不打开就不更新。应该在服务端用定时任务把当天任务置为可打卡状态小程序端只拉取状态。和排行榜快照的定时器放一起用 node-cron 维护。cron.schedule(0 0 * * *, () { // 按日期刷新 task 表的 is_active });定时器执行时注意时区cron 表达式里的 0 点要对应业务时区否则又会踩第 5.3 节的坑。6.3 埋点看留存而不是只看打卡人数互动打卡的核心是习惯养成上线后多关注次日留存和 7 日连续率。在打卡成功处上报checkin_success在用户进入页面时上报page_view用后端日志简单统计。7 日连续率低于 20% 时先把连续第 7 天奖励直接翻倍观察一周留存率变化再决定要不要继续加如果补卡次数很快被用完说明断签压力太大规则需要放宽。我现在上任何打卡项目都先把数据库时区、唯一索引和榜单边界确认好再调玩法不然返工成本都在后半程。希望帮到你。本文还有配套的精品资源点击获取