简介:这是一套面向高校计算机相关专业学生的查寝打卡微信小程序毕业设计源码,采用Java+SSM框架开发服务端,小程序端基于uniapp或原生小程序实现,数据库使用MySQL 5.7,适合作为毕业设计、课程设计或小程序入门练手项目。功能覆盖学生注册、通知公告、课程表查看、寝室签到(获取位置并上传自拍)、事假病假申请与考勤查询,教师端则提供管理员、课程表、学生信息、通知公告、签到审核、请假与考勤统计等管理模块,前后端逻辑完整。压缩包共342个文件,约34.77MB,包含33个java源文件、37个class、59个jar依赖,以及wxml、wxss、js、json等小程序页面文件,另有sql脚本、说明文档与LW论文,结构清晰便于二次开发。目前已有71人学习下载,可帮助读者快速理解签到定位、图片上传与人工审核等业务实现,并直接用于答辩演示或功能扩展。
1. 查寝打卡小程序到底在解决什么:从辅导员半夜敲门说起
高校查寝这件事,做过的人都知道痛点在哪。辅导员晚上十点半挨个宿舍敲门点名,学生干部拿着纸质表格打勾,第二天再手动录入 Excel,月底统计迟归、未归、请假名单时对着一堆手写记录抓瞎。更麻烦的是,数据没法追溯,谁几点几分在不在,全靠一张嘴。查寝打卡微信小程序源码这类项目,本质就是把这条链路从「人肉跑腿 + 纸质记录」搬到「学生手机端打卡 + 后台自动汇总」,让辅导员在管理端一眼看到今晚谁没打卡、谁请假了、哪个宿舍整体缺勤。
这套「完整前后端 + MySQL + 说明文档 + LW」的组合,面向的主要是两类人:一类是正在做毕业设计的学生,需要一套能跑起来、能讲清楚架构、能写进论文的系统;另一类是想快速搭一个校园考勤类工具的开发者,拿现成结构改改就能用。它解决的核心问题不复杂——定位打卡、请假审批、缺勤统计、消息通知,但要把这几件事串成一个闭环,前后端的字段设计、定位精度处理、时间窗口控制,每一步都有讲究。接下来我按实际落地的顺序,把选型、建表、接口、排错一条条拆开讲。
2. 技术选型与项目结构:为什么是微信小程序 + Node 后端 + MySQL
2.1 前端为什么选微信小程序而不是 H5 或 App
查寝打卡这个场景有个硬约束:学生必须用手机,而且最好能拿到微信的登录态和定位能力。选微信小程序而不是 H5,核心原因是三点。第一,wx.login能直接换到 openid,不用自己搭一套账号密码体系,学生打开就能用,辅导员也不用管密码找回。第二,wx.getLocation拿到的经纬度精度在校园场景够用,配合wx.authorize的权限弹窗,用户拒绝授权时还能引导去设置页重新开。第三,小程序天然在微信里,通知可以走订阅消息,比短信便宜、比 App 推送省事。
H5 的问题在于定位权限在部分安卓浏览器里被降级,拿到的可能是基站粗定位,误差几百米,打卡判定直接失效。App 的问题则是开发和分发成本高,一个毕业设计项目没必要上原生。所以小程序是这个场景下的合理选择,不是跟风。
2.2 后端为什么用 Node.js + Express 而不是 Java 或 PHP
后端选型上,常见做法是 Node.js + Express 或者 Koa。原因很实际:小程序的请求量不大,一个学院几百到几千人,Node 的单线程异步模型完全扛得住,而且前后端都是 JavaScript,字段命名、时间格式处理能保持一致,少一层心智负担。Java 的 Spring Boot 当然更「正规」,但对毕业设计来说,配置量大、启动慢,改一个接口要重启半天,不划算。PHP 部署简单,但生态里 ORM 和参数校验的写法比较杂,新手容易写出 SQL 拼接的坑。
我一般会这样搭目录:
server/ app.js // 入口,挂载路由和中间件 routes/ checkin.js // 打卡相关接口 leave.js // 请假相关接口 admin.js // 管理端统计接口 models/ db.js // MySQL 连接池 schema.sql // 建表语句 utils/ geo.js // 经纬度距离计算 time.js // 打卡时间窗口判断这个结构的好处是路由、模型、工具分离,答辩的时候能清楚说出每一层干什么。数据库连接用mysql2的连接池,不要每次请求都新建连接,否则并发一上来就报Too many connections。
2.3 MySQL 表结构怎么设计才够用又不冗余
表设计是这套系统的骨架。最少需要四张表:学生表、宿舍表、打卡记录表、请假表。下面是我实际用过的建表语句,字段做了精简但保留了关键约束。
-- 学生表:openid 是微信登录的唯一标识 CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, openid VARCHAR(64) NOT NULL UNIQUE, name VARCHAR(32) NOT NULL, sno VARCHAR(20) NOT NULL UNIQUE, -- 学号 dorm_id INT NOT NULL, -- 所属宿舍 role TINYINT DEFAULT 0, -- 0学生 1辅导员 created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 宿舍表:存楼栋、房间号和基准坐标 CREATE TABLE dorm ( id INT PRIMARY KEY AUTO_INCREMENT, building VARCHAR(16) NOT NULL, room VARCHAR(16) NOT NULL, latitude DECIMAL(10,7) NOT NULL, -- 宿舍基准纬度 longitude DECIMAL(10,7) NOT NULL, -- 宿舍基准经度 UNIQUE KEY uk_room (building, room) ); -- 打卡记录表:一天一条,用唯一索引防重复 CREATE TABLE checkin ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, check_date DATE NOT NULL, check_time DATETIME NOT NULL, latitude DECIMAL(10,7), longitude DECIMAL(10,7), distance INT, -- 距宿舍基准点米数 status TINYINT DEFAULT 1, -- 1正常 2迟到 3补卡 UNIQUE KEY uk_stu_date (student_id, check_date) ); -- 请假表:审批状态流转 CREATE TABLE leave_record ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, reason VARCHAR(255), status TINYINT DEFAULT 0, -- 0待审 1通过 2驳回 created_at DATETIME DEFAULT CURRENT_TIMESTAMP );这里有几个设计点值得说。checkin表用(student_id, check_date)做唯一索引,从数据库层面杜绝同一天重复打卡,比在代码里查一次再插入可靠得多。distance字段把计算好的距离存下来,统计时不用每次重算,也方便排查「为什么判定超距」。dorm表存基准坐标,是因为一个宿舍楼的不同房间坐标差异不大,用房间级基准点比用学校中心点判定准得多。
注意:经纬度用
DECIMAL(10,7)而不是FLOAT,浮点数在比较和存储时会有精度漂移,打卡判定这种要算距离的场景,精度丢了就是玄学 bug。
3. 打卡核心逻辑:定位判定、时间窗口与防重复提交
3.1 经纬度距离计算:为什么不能用直线距离糊弄
打卡判定的核心是「学生当前位置离宿舍基准点有多远」。常见做法有两种:一种是直接算经纬度的欧氏距离,另一种是用 Haversine 公式算球面距离。前者在跨纬度时误差会放大,后者才是正确做法。下面是我用的计算函数。
// utils/geo.js // Haversine 公式计算两点球面距离,返回米 function getDistance(lat1, lng1, lat2, lng2) { const R = 6371000; // 地球半径,单位米 const toRad = (d) => (d * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLng = toRad(lng2 - lng1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return Math.round(R * c); // 四舍五入到米 } module.exports = { getDistance };逻辑说明:R取 6371000 米是地球平均半径,够用。toRad把角度转弧度,因为三角函数吃弧度。a是 Haversine 的中间量,c是两点对应的圆心角,最后乘半径得到弧长即距离。参数上,lat1/lng1是学生上报坐标,lat2/lng2是宿舍基准坐标,顺序别搞反,反了结果一样但可读性差。
判定阈值我一般设 200 米。为什么不是 50 米?因为wx.getLocation在室内、楼栋密集区会有几十米漂移,设太严学生站在宿舍里都打不上卡,投诉能把你淹没。200 米能覆盖宿舍楼范围,又不会把隔壁楼算进来。这个值要写进配置,别硬编码。
3.2 打卡时间窗口:迟到和未归怎么区分
时间窗口决定打卡状态。常见规则是:晚上 21:00 到 22:30 是正常打卡,22:30 到 23:00 算迟到,23:00 之后没打卡就是未归。这套规则要落在后端,不能信前端传的时间,否则改个手机时间就能骗过系统。
// utils/time.js // 根据打卡时间返回状态:1正常 2迟到 function getCheckStatus(checkTime) { const d = new Date(checkTime); const h = d.getHours(); const m = d.getMinutes(); const minutes = h * 60 + m; const NORMAL_START = 21 * 60; // 21:00 const NORMAL_END = 22 * 60 + 30; // 22:30 if (minutes >= NORMAL_START && minutes <= NORMAL_END) return 1; return 2; // 超出正常窗口一律记迟到,未归由定时任务补 } module.exports = { getCheckStatus };逻辑说明:把时间转成「当天第几分钟」再比较,比直接比Date对象清爽。NORMAL_START和NORMAL_END是常量,改规则只动这里。未归状态不在这里判,因为「没打卡」这件事没有记录可判,要靠一个定时任务在 23:00 扫描当天没有打卡记录的学生,批量插入状态为未归的记录,或者直接在统计接口里用左连接算出来。我倾向后者,少写数据,统计时LEFT JOIN一下就行。
3.3 打卡接口的完整实现与防重复提交
把定位、时间、防重三件事串起来,就是打卡接口。下面这段是核心逻辑。
// routes/checkin.js const express = require('express'); const router = express.Router(); const pool = require('../models/db'); const { getDistance } = require('../utils/geo'); const { getCheckStatus } = require('../utils/time'); router.post('/do', async (req, res) => { const { openid, latitude, longitude } = req.body; if (!openid || !latitude || !longitude) { return res.json({ code: 400, msg: '参数缺失' }); } // 1. 查学生和宿舍基准点 const [rows] = await pool.query( `SELECT s.id, s.dorm_id, d.latitude AS dlat, d.longitude AS dlng FROM student s JOIN dorm d ON s.dorm_id = d.id WHERE s.openid = ?`, [openid] ); if (!rows.length) return res.json({ code: 401, msg: '用户不存在' }); const stu = rows[0]; // 2. 算距离 const distance = getDistance(latitude, longitude, stu.dlat, stu.dlng); if (distance > 200) { return res.json({ code: 403, msg: `超出打卡范围,当前距离${distance}米` }); } // 3. 判状态 const now = new Date(); const status = getCheckStatus(now); const dateStr = now.toISOString().slice(0, 10); // 4. 插入,唯一索引兜底防重 try { await pool.query( `INSERT INTO checkin (student_id, check_date, check_time, latitude, longitude, distance, status) VALUES (?, ?, ?, ?, ?, ?, ?)`, [stu.id, dateStr, now, latitude, longitude, distance, status] ); res.json({ code: 0, msg: '打卡成功', data: { distance, status } }); } catch (e) { if (e.code === 'ER_DUP_ENTRY') { return res.json({ code: 409, msg: '今天已经打过卡了' }); } res.json({ code: 500, msg: '服务异常' }); } }); module.exports = router;逻辑说明:先校验参数,再查学生和宿舍坐标,算距离,超 200 米直接拒。状态用getCheckStatus判。插入时靠唯一索引uk_stu_date兜底,捕获ER_DUP_ENTRY返回友好提示,而不是先查再插——先查再插在并发下有两个请求同时查到「没打过」然后都插入的窗口,唯一索引才是后悔药。
参数上,openid从wx.login换,latitude/longitude从wx.getLocation拿。前端拿到定位后直接 POST,不要在前端算距离,前端算的都能被改。
4. 请假审批与统计报表:把数据变成辅导员看得懂的东西
4.1 请假流程的状态机与接口设计
请假不是简单存一条记录,它有状态流转:学生提交是待审,辅导员通过或驳回,通过之后这段时间的打卡应该被豁免。状态机用status字段表示:0 待审、1 通过、2 驳回。接口分三个:学生提交、辅导员审批、学生查自己的请假记录。
// routes/leave.js 提交请假 router.post('/apply', async (req, res) => { const { openid, startDate, endDate, reason } = req.body; if (!startDate || !endDate || new Date(startDate) > new Date(endDate)) { return res.json({ code: 400, msg: '日期不合法' }); } const [[stu]] = await pool.query('SELECT id FROM student WHERE openid = ?', [openid]); if (!stu) return res.json({ code: 401, msg: '用户不存在' }); await pool.query( `INSERT INTO leave_record (student_id, start_date, end_date, reason) VALUES (?, ?, ?, ?)`, [stu.id, startDate, endDate, reason] ); res.json({ code: 0, msg: '提交成功' }); }); // 辅导员审批 router.post('/audit', async (req, res) => { const { id, status } = req.body; // status: 1通过 2驳回 if (![1, 2].includes(status)) return res.json({ code: 400, msg: '状态非法' }); await pool.query('UPDATE leave_record SET status = ? WHERE id = ?', [status, id]); res.json({ code: 0, msg: '审批完成' }); });逻辑说明:提交时校验日期区间,防止结束早于开始这种脏数据。审批接口只允许 1 或 2,避免前端传个 0 把已审的又打回待审。审批权限要在中间件里校验role = 1,这里省略了但实际必须加,否则任何学生都能调审批接口。
请假和打卡的联动在统计时做:算某天未归名单时,先排除掉当天在请假区间内且状态为通过的学生。这个条件写在 SQL 的WHERE NOT EXISTS子查询里,比在代码里过滤高效。
4.2 未归统计 SQL:一条查询搞定当天缺勤名单
辅导员最关心的就是「今晚谁没打卡」。这条 SQL 用左连接加请假排除,一次查出结果。
-- 查指定日期未打卡且未请假的学生 SELECT s.sno, s.name, d.building, d.room FROM student s JOIN dorm d ON s.dorm_id = d.id LEFT JOIN checkin c ON c.student_id = s.id AND c.check_date = ? WHERE s.role = 0 AND c.id IS NULL AND NOT EXISTS ( SELECT 1 FROM leave_record l WHERE l.student_id = s.id AND l.status = 1 AND ? BETWEEN l.start_date AND l.end_date ) ORDER BY d.building, d.room;逻辑说明:LEFT JOIN checkin后c.id IS NULL就是没打卡的。NOT EXISTS子查询排除掉当天在已通过请假区间内的学生。两个?都传同一个日期。ORDER BY按楼栋房间排,辅导员拿着名单能按楼走,不用来回翻。
参数上,日期格式用YYYY-MM-DD,和check_date的DATE类型对齐。如果数据量大,给checkin(student_id, check_date)和leave_record(student_id, status)建索引,查询能从秒级降到毫秒级。
4.3 管理端统计接口与分页
统计接口要支持按日期查、按楼栋筛、分页返回。分页别用LIMIT offset, size在大数据量下会越翻越慢,但校园场景几千条数据无所谓,用着简单就行。
// routes/admin.js 未归统计 router.get('/absent', async (req, res) => { const { date, building, page = 1, size = 20 } = req.query; const offset = (page - 1) * size; let sql = `SELECT s.sno, s.name, d.building, d.room FROM student s JOIN dorm d ON s.dorm_id = d.id LEFT JOIN checkin c ON c.student_id = s.id AND c.check_date = ? WHERE s.role = 0 AND c.id IS NULL AND NOT EXISTS (SELECT 1 FROM leave_record l WHERE l.student_id = s.id AND l.status = 1 AND ? BETWEEN l.start_date AND l.end_date)`; const params = [date, date]; if (building) { sql += ' AND d.building = ?'; params.push(building); } sql += ' ORDER BY d.building, d.room LIMIT ? OFFSET ?'; params.push(Number(size), Number(offset)); const [list] = await pool.query(sql, params); res.json({ code: 0, data: { list, page: Number(page) } }); });逻辑说明:building是可选筛选,有就拼进WHERE。LIMIT和OFFSET的参数要转成数字,mysql2对字符串参数在LIMIT位置会报语法错,这是血泪经验。返回结构里带上page,前端好做翻页。
5. 避坑与排查:那些让打卡系统翻车的细节
5.1 定位漂移导致学生打不上卡
现象:学生明明在宿舍,接口返回「超出打卡范围,当前距离 350 米」。原因:wx.getLocation默认拿的是wgs84坐标,而有些场景下室内定位会漂到几百米外,或者学生手机开了省电模式,定位被降级成基站定位。解决:前端调wx.getLocation时指定type: 'gcj02',并在拿到坐标后判断accuracy字段,如果精度大于 100 米就提示用户「定位信号弱,请到窗边重试」,而不是直接提交。后端阈值也别卡太死,200 米是经验值。
5.2 时间窗口用服务器时间但服务器时区不对
现象:晚上 21:30 打卡,状态却是迟到。原因:服务器时区是 UTC,new Date().getHours()拿到的是 UTC 小时,比北京时间少 8 小时。解决:在app.js启动时设process.env.TZ = 'Asia/Shanghai',或者用dayjs显式指定时区。数据库连接也要设时区,mysql2建连接池时加timezone: '+08:00',否则CURRENT_TIMESTAMP存进去的也是 UTC。
5.3 唯一索引冲突没捕获导致接口 500
现象:学生手快点了两次打卡,第二次返回 500 而不是「已打卡」。原因:插入触发唯一索引冲突抛异常,没捕获就冒泡成 500。解决:try/catch里判e.code === 'ER_DUP_ENTRY'返回 409 和友好提示。这个坑在并发测试时必现,答辩前一定要测。
5.4 请假区间跨天统计时漏排
现象:学生请了 3 天假,中间那天还是出现在未归名单里。原因:请假区间判断用了start_date = ? OR end_date = ?,只匹配了首尾两天。解决:用? BETWEEN l.start_date AND l.end_date,一个条件覆盖区间内所有天。这个错误很隐蔽,因为首尾两天是对的,只有中间漏。
5.5 连接池耗尽导致高峰期接口超时
现象:晚上 21:00 打卡高峰,接口大面积超时,日志报Too many connections。原因:每次请求mysql.createConnection新建连接,没释放。解决:用mysql.createPool建连接池,connectionLimit设 10 到 20,查询用pool.query自动归还连接。校园场景 20 个连接足够撑住几百人同时打卡。
6. 从能跑到好用:补卡、订阅消息与数据导出
系统跑通之后,真正让它「好用」的是几个进阶点。补卡功能是刚需——学生手机没电、定位抽风,第二天得能补。补卡不能随便补,我一般做成「学生申请补卡 + 辅导员审批」,复用请假那套状态机,补卡记录status标 3,统计时和正常打卡一样算已打卡,但管理端能看到补卡标记,防止滥用。
订阅消息用来提醒未打卡学生。小程序订阅消息是一次性授权,用户点一次只能发一条,所以要在打卡页放一个「开启提醒」按钮,用户点了才拿到templateId对应的授权。晚上 22:00 定时任务扫描未打卡学生,逐个发提醒。注意订阅消息有频率限制,别做成骚扰。
数据导出是辅导员月底要的。用exceljs在后端生成 xlsx,接口返回文件流,前端用wx.downloadFile下载再wx.openDocument打开。导出字段包括学号、姓名、楼栋、房间、打卡时间、状态、距离,按日期范围筛。生成大文件时注意内存,几千行没问题,上万行要分 sheet。
// 导出示例:用 exceljs 生成打卡明细 const ExcelJS = require('exceljs'); async function exportCheckin(res, startDate, endDate) { const [rows] = await pool.query( `SELECT s.sno, s.name, d.building, d.room, c.check_time, c.status, c.distance FROM checkin c JOIN student s ON c.student_id = s.id JOIN dorm d ON s.dorm_id = d.id WHERE c.check_date BETWEEN ? AND ? ORDER BY c.check_date, d.building`, [startDate, endDate] ); const wb = new ExcelJS.Workbook(); const ws = wb.addWorksheet('打卡明细'); ws.columns = [ { header: '学号', key: 'sno', width: 16 }, { header: '姓名', key: 'name', width: 12 }, { header: '楼栋', key: 'building', width: 10 }, { header: '房间', key: 'room', width: 10 }, { header: '打卡时间', key: 'check_time', width: 22 }, { header: '状态', key: 'status', width: 8 }, { header: '距离(米)', key: 'distance', width: 12 }, ]; rows.forEach((r) => ws.addRow(r)); res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); await wb.xlsx.write(res); res.end(); }逻辑说明:ws.columns定义表头和列宽,addRow逐行写。write(res)直接写响应流,不用先存文件。状态字段导出时最好映射成中文,1正常 2迟到 3补卡,辅导员看着直观。
最后说个我自己的习惯:这套系统上线前,我一定会用脚本模拟 200 个学生同时打卡,看连接池、唯一索引、时间窗口在压力下有没有异常。查寝打卡这种系统,平时没人用,一到晚上十点集中爆发,压测不做,翻车是迟早的。把定位阈值、时间窗口、连接池大小这三个参数做成配置项,不同学校规则不一样,改配置比重编译强。希望帮到你。
本文还有配套的精品资源,点击获取