
这套基于微信小程序的学生签到系统我实际做下来最有感触的一点是签到这个动作看起来简单真正设计起来却同时牵扯到身份、时间、位置和状态流转四套逻辑。学校大课堂上老师拿着纸质名单点名六十多个人点完基本就过去一刻钟代签现象还很难查。我当时用SSM框架做后端管理端微信小程序做学生签到端从数据库建模到接口实现再到小程序联调把整套流程完整走了一遍。这篇文章不写项目介绍册上那种套话只讲真实的设计思路、实现代码和运行阶段踩过的坑正在做同类毕业设计或者课程设计的同学可以直接当成一份实践参考。1. 项目定位与技术选型为什么是微信小程序SSM1.1 先想清楚签到系统到底要解决谁的什么问题很多同学拿到这类题目第一反应是做一个页面学生点一下签到按钮老师能看到列表实际上这是把需求理解窄了。站在教师的角度要解决的痛点是点名占用课堂时间和课后统计出勤率麻烦站在学生的角度要解决的问题是别因为排队等签到耽误上课站在管理者的角度要解决的是如何确认签到数据真实有效。所以我给这个系统的功能定位是三条线教师后台创建课程、发布签到任务、规定签到时间窗、查看签到明细和出勤统计。学生小程序端查看当前待签到的课程、一键签到、查看历史签到记录和个人出勤情况。数据侧每条签到记录必须包含签到时间、签到位置、设备标识作为后续防作弊和统计的原始依据。这个定位决定了系统的核心不是签到按钮而是签到任务的生命周期管理。一个签到任务从创建、开始、结束到归档中间涉及时间判断、位置校验、状态更新后端大部分工作量都在这里。1.2 前端为什么用微信小程序而不是H5或者原生App学生签到场景有非常强的移动端属性但又不适合强制安装App。微信小程序的几个特点几乎是为这个场景量身定做的免安装、入口浅学生用微信扫一扫或者从对话卡片点进去就能用不需要应用商店分发。登录体系现成通过wx.login拿到临时code后端再调用微信接口换取openid天然就是学生的身份标识省掉手机号注册的流程。定位能力成熟小程序提供wx.getLocation接口配合WGS84坐标系可以直接实现基于地理位置的签到。消息触达方便订阅消息可以提醒学生老师发布了签到任务这个功能在后续迭代里也补上了。对比之下H5页面在iOS系统的定位权限上经常出问题用户拒绝授权后没有统一处理入口原生App开发和上架成本对学校项目来说又太重。小程序在这两者之间是最务实的选择。1.3 后端选SSM核心原因是课程要求技术栈熟悉度SSMSpring SpringMVC MyBatis确实是老技术栈了但这类项目选它有几个非常实际的理由学校课程体系中SSM覆盖最全从Spring IoC/AOP到SpringMVC请求映射再到MyBatis持久层做完一个完整项目能把课程里的知识全部串起来。分层结构清晰Controller负责参数接收和返回Service负责业务规则Mapper负责SQL排查问题的时候能顺着调用链一路找下去。对毕业设计来说可控性高相比Spring Boot的自动配置SSM要求你手动处理配置文件反而更容易向评委解释这里为什么这样配。我选的组合是Spring 5 SpringMVC MyBatis MySQL 8 Maven前端小程序原生开发不引入uni-app或者Taro原因很简单——原生方式定位和授权相关API出问题时排查链路最短。提示如果只是做Demo后端可以换成Spring Boot省去大量XML配置但SSM版本在解释原理这个维度上更占优势。两种路线都能走通关键是答辩时能讲清楚自己每层在干什么。2. 数据库设计与签到流程建模2.1 核心数据表设计与字段含义数据库是整个系统最值得花时间的部分因为签到业务的状态流转比较复杂。我把表拆成五个核心表下面是每张表的用途和关键字段。student 学生表字段类型说明idbigint主键openidvarchar(64)微信openid唯一student_novarchar(32)学号唯一namevarchar(32)姓名class_namevarchar(64)班级avatarvarchar(255)头像URL学生第一次打开小程序时需要输入学号和姓名完成绑定绑定的核心动作就是把 openid 写入这条记录。openid 和 student_no 都建唯一索引这是身份识别的基础。teacher 教师表字段比较简单id、openid、name、title。教师账号可以通过后台预置也可以做成管理员审核制实际项目中我直接由管理员在后台添加。course 课程表字段类型说明idbigint主键namevarchar(64)课程名teacher_idbigint授课教师week_daytinyint星期几1-7start_timetime上课时间end_timetime下课时间locationvarchar(128)上课地点课程表是教师端创建签到任务的数据来源一个课程对应多个签到任务。sign_task 签到任务表字段类型说明idbigint主键course_idbigint课程IDteacher_idbigint教师IDsign_typetinyint签到方式1普通2基于位置location_namevarchar(128)位置名称latitudedecimal(10,7)纬度longitudedecimal(10,7)经度radiusint允许签到半径单位米start_timedatetime签到开始时间end_timedatetime签到截止时间statustinyint状态0未开始1进行中2已结束3已取消签到任务是一个时间段概念。教师创建任务时可以指定上课前10分钟到上课后20分钟内允许签到后端通过任务状态控制学生能否签到。radius这个字段我在设计时踩过坑后面单独说。sign_record 签到记录表字段类型说明idbigint主键task_idbigint签到任务IDstudent_idbigint学生IDsign_timedatetime签到时间latitudedecimal(10,7)签到时纬度longitudedecimal(10,7)签到时经度distancedouble与目标位置的距离单位米device_infovarchar(255)设备信息statustinyint0缺勤1正常2迟到3请假distance字段非常有价值它可以帮教师判断学生的签到位置。比如某次签到任务要求定位在教室但有个学生提交位置的经纬度对应到1公里外的宿舍楼教师后台可以直接看到distance异常。2.2 签到状态的流转逻辑我设计了一个状态机来管理签到任务所有时间判断都围绕它展开创建任务status 0此时学生端能看见任务但不能签到。到达 start_timestatus 自动变为 1进行中学生可签到。到达 end_timestatus 自动变为 2已结束系统把该任务下所有无记录的学生批量标记为缺勤。教师取消任务status 3学生端对应任务消失。批量标记缺勤这个动作可以用定时任务实现也可以用懒触发——即教师后台打开统计页面时检查所有进行中但已超时的任务并做状态归档。我实际用后者因为学校项目里没人会盯着服务器日志懒触发省心且数据不会错。记录表的状态字段则记录学生单次签到的结果正常、迟到、缺勤、请假。迟到判断很简单——如果任务规定end_time是上课后20分钟那么超过上课时间点签到的就是迟到。有人会问任务还没结束如何判断迟到我是把上课时间作为参数传给后端Service层判断signTime course.start_time就标记迟到。2.3 索引和唯一约束防止重复签到的最底层防线签到记录表一定要建立联合唯一索引这是我在并发测试后加上的ALTER TABLE sign_record ADD UNIQUE KEY uk_task_student(task_id, student_id);这个唯一索引是整个防重复体系中兜底的一环因为单纯靠代码里先查询再插入在并发场景下一定会漏。两个请求同时查到没有记录然后同时插入就会产生两条签到数据。有了唯一索引第二个插入直接抛DuplicateKeyException再在Service层捕获这个异常返回请勿重复签到即可。其他必要的索引还有sign_task(course_id)、sign_task(status)、sign_record(sign_time)。这几个索引覆盖了99%的查询场景后续联调时响应速度都在毫秒级。3. 后端SSM签到接口的实现细节3.1 小程序登录与身份绑定的完整链路小程序端和后端的身份对接是整个系统的第一道关。流程分成两步第一步小程序调用wx.login()获取临时code传到后端接口/wx/login。后端拿着这个code调用微信服务器的jscode2session接口用appid secret code换取 openid。实际代码核心就是这一段String url https://api.weixin.qq.com/sns/jscode2session?appid appId secret appSecret js_code code grant_typeauthorization_code; String result HttpClientUtils.doGet(url); JSONObject json JSONObject.parseObject(result); String openid json.getString(openid);拿到openid之后后端去student表查这条openid是否存在。存在就直接返回业务Token不存在就返回需要绑定学号的标记小程序跳到绑定页面。第二步是绑定。学生输入学号和姓名后后端根据 student_no 更新该记录的 openid。要注意的是一个openid只能绑定一个学号一个学号也只能绑定一个openid所以两边的唯一索引都不能少。注意不要把openid明文直接作为身份凭证传给前端实际操作中我生成了一个UUID Token存到内存缓存并设置7天过期小程序后续请求在请求头里带token后端通过Token解析出openid。这样即使接口被抓包也不会直接泄露用户标识。3.2 创建签到任务接口与时间窗设计教师端创建签到任务的接口参数如下{ courseId: 1, signType: 1, locationName: 第三教学楼101, latitude: 39.1234567, longitude: 117.1234567, radius: 100, startTime: 2025-03-10 08:50:00, endTime: 2025-03-10 09:20:00 }Service层主要做三件事归属校验确认当前教师确实负责这门课程否则不能创建任务。时间校验endTime必须晚于startTime且startTime不能早于当前时间太多防止教师误填日期。字段补全写入status0并设置一个简单的定时调度到时间后自动把状态改成1。这里有一个课本上不会讲的细节时间窗的粒度。如果签到时间窗口设得太短学生点进小程序还没反应过来就结束了设得太长代签风险明显增加。我按实际情况把默认值定成上课前10分钟 到 上课后20分钟既给了学生充足的操作时间也让迟到的定义清晰——上课时间点之后签到的统一记为迟到。3.3 签到接口的四层校验与位置计算学生端调用/sign/doSign接口时的核心逻辑是四层校验任何一层不过就直接返回对应错误码第一层身份校验解析Token确认openid对应的学生存在且状态正常。第二层任务校验查询签到任务确认status1进行中并且当前时间落在开始和截止时间之间。第三层时间校验具体判断时间是否在窗口内如果当前时间早于startTime返回签到未开始晚于endTime返回签到已结束。第四层位置校验如果sign_type1计算学生提交的经纬度与任务设定的目标位置距离要求距离小于radius。位置距离计算用的是球面距离公式HaversineJava实现如下public static double distance(double lat1, double lng1, double lat2, double lng2) { double radLat1 Math.toRadians(lat1); double radLat2 Math.toRadians(lat2); double a radLat1 - radLat2; double b Math.toRadians(lng1) - Math.toRadians(lng2); double s 2 * Math.asin(Math.sqrt( Math.pow(Math.sin(a / 2), 2) Math.cos(radLat1) * Math.cos(radLat2) * Math.pow(Math.sin(b / 2), 2))); return s * 6371000; }这里的6371000是地球半径单位米。用这个函数算出距离后和radius比对即可。实际运行中我建议适当放宽半径因为校园里GPS信号波动很常见10米的误差都可能让本来在教室门口的学生被判定为不在范围。我在真实测试时把100米半径放宽到150米漏判率才降到可接受范围。签到成功后记录写入sign_record表并把学生信息和任务信息组装成返回结果小程序端直接展示签到成功的卡片。3.4 统计、迟到判定与Excel导出教师查看某个任务的签到详情时后端返回两个维度的数据汇总数据应到人数、实到人数、迟到人数、缺勤人数、出勤率。明细数据每个学生的签到状态、签到时间、签到位置距离、设备信息。出勤率我建议用实到人数 / (应到人数 - 请假人数)否则请假多的班级出勤率会虚低。实际业务里这个口径需要和教务老师确认不同学校算法不一样。Excel导出我用的Apache POI导出逻辑里有个效率坑不要循环查库拼数据一次SQL查出明细再在Java内存中完成分组统计。数据量到上千条时循环查库会让接口延迟从100毫秒涨到几秒。4. 微信小程序端的功能实现与交互细节4.1 页面规划与角色边界小程序端我只做了学生端页面教师管理端放在Web后台。这个选择在答辩时被问过我的解释是教师经常需要批量操作和查看统计表格小程序的屏幕和交互并不适合学生端的使用频率高但操作简单恰恰适合小程序。学生端页面规划首页展示当前登录学生的今日课程卡片每张卡片上显示课程名、当前状态待签到/已签到/已结束以及签到按钮。签到页展示课程详细信息、签到时间窗、位置要求底部一个大的签到按钮。历史页按日期倒序展示签到记录每条记录带状态标记。我的页个人姓名、学号、班级以及退出登录入口。4.2 签到页面的核心流程代码签到核心逻辑集中在sign.js里submitSign: function(courseId, taskId) { wx.getLocation({ type: gcj02, success: (res) { wx.request({ url: app.globalData.baseUrl /sign/doSign, method: POST, data: { taskId: taskId, latitude: res.latitude, longitude: res.longitude }, header: { Authorization: app.globalData.token }, success: (resp) { if (resp.data.code 0) { this.showSignSuccess(resp.data.data); } else if (resp.data.code 10002) { this.showSignTimeError(); // 时间窗口错误 } } }); }, fail: () { this.showWxToast(定位失败请检查是否开启手机定位); } }); }这里有个使用wx.getLocation的重点必须先给用户一个明确的入口提示再调用定位接口。如果页面打开后立刻弹定位授权框很多用户会直接拒绝后面就陷入再请求授权也不能弹的僵局。实际操作是在签到按钮的点击事件里才调用wx.getLocation用户能明白这次授权的用途授权通过率高得多。另一个实现细节是定位坐标系。小程序端wx.getLocation的type参数传gcj02返回的是国测局坐标后端存到库里的纬度和教师创建任务时填的纬度也要用同一套坐标系否则两个坐标换算不一致距离计算会偏差几百米。我因为这个问题调了一个下午后来统一约定所有经纬度全部用gcj02数据库里也存gcj02。4.3 弱网、缓存与重复提交的处理签到这个动作很特殊用户只有一次机会如果因为网络超时失败了心理上的挫败感很强而且可能会反复点按钮。我做了三个保护按钮防抖签到接口一旦发出按钮立刻进入 loading 和 disabled 状态防止用户连点造成并发请求。请求超时设置wx.request的timeout设为10秒超时后弹网络有点慢请检查网络后重试不自动重发。本地状态缓存签到成功后把 taskId 和签到状态写入wx.setStorageSync下次再进入该页面按钮直接置灰显示已签到首页卡片状态也优先读本地缓存。但本地缓存只能作为展示层的优化真正的数据一致性靠服务端唯一索引保证。我的原则是服务端返回成功才算签到成功本地缓存只是减少不必要的重复请求。4.4 订阅消息提醒的设置签到类项目的完整体验离不开提醒。微信小程序的订阅消息需要用户主动授权且一次授权只能发一次通知所以实际策略是学生在首页点击接收上课提醒时引导他授权订阅消息教师创建签到任务后通过接口向所有选课学生推送一条订阅消息。消息模板放在小程序后台申请类型选上课提醒结束时间、地点都可以作为动态字段。这里要提醒一句订阅消息的模板审核周期大约1到3个工作日项目排期要把这个时间算进去别到开发完才申请。5. 联调部署与真实运行中的踩坑记录5.1 小程序合法域名与HTTPS的坑本地联调阶段最常见的问题是开发者工具里关了校验合法域名一切正常一上真机预览所有请求全部失败。原因很简单——小程序真机环境强制要求wx.request的URL必须是HTTPS且域名已经在小程序后台配置到白名单里。本地http://localhost:8080在真机上永远不通。我的处理办法分两步开发阶段用内网穿透工具把本地Tomcat的8080端口暴露成公网临时域名把临时域名加到小程序后台的 request 合法域名。这样手机上的小程序可以直接访问我电脑上跑的服务改代码后不用重新部署。生产阶段把Tomcat部署到云服务器用Nginx做HTTPS反向代理SSL证书在云服务商控制台申请免费版整个过程大约一小时。Nginx里有一个关键配置必须把/api/前缀的请求代理到后端的 Tomcatlocation /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }如果漏掉proxy_set_header Host后端SpringMVC在处理跳转和Session解析时会出现奇怪的问题排查起来很费劲。5.2 定位授权与隐私合规的处理链路小程序从2022年下半年开始对隐私接口管控很严wx.getLocation必须在 app.json 里声明requiredPrivateInfos同时在小程序管理后台配置位置信息接口说明否则定位直接调用失败。实际操作中我完善了三处app.json 中声明requiredPrivateInfos: [getLocation]。代码中调用wx.getPrivacySetting检查用户是否已同意隐私协议不同意时弹出隐私弹窗用户同意后再请求定位。位置信息用途说明里写清楚仅用于签到时的位置核验。用户拒绝定位授权时的兜底方案也很重要。我加了定位失败时可手动补充位置备注的入口学生签到成功后后台看到distance为空但备注内容教师可以人工复核。这在答辩时是一个加分点说明你处理了真实场景的边界情况。5.3 并发签到下的一致性问题排查第一次压测模拟60人同时签到MySQL日志里出现了一条Duplicate entry异常这个异常导致了两个学生一个签到成功一个失败。原因是并发请求同时通过了Service层的是否已签到检查然后同时走插入逻辑唯一索引枪毙了其中一条。解决办法不是去掉唯一索引而是在Service层捕获异常并返回友好提示try { signRecordMapper.insert(record); } catch (DuplicateKeyException e) { throw new BizException(您已签到过了请勿重复操作); }同时把Druid连接池的maxActive从默认的20调到了50毕竟课堂签到场景的峰值就是几十个并发50个连接足够调太高反而浪费内存。另一个一致性坑在缺勤归档。最初我用定时任务每分钟扫一次超时任务后来发现数据库连接经常被无关查询占满干脆改成懒触发教师端打开统计页时先检查该课程是否存在超时但仍处于进行中的任务有则先批量归档再展示数据。这样重复计算的概率几乎没有性能压力也最小。6. 这套系统还能怎么扩展项目做完之后我总结出几个值得继续扩展的方向供同样在完善项目的同学参考引入Redis缓存把token、签到任务状态、高频统计结果放进Redis减少查库次数。注意SSM项目引入Redis后要处理好缓存和数据库的一致性建议只在读多写少的地方用。随机签到码教师创建任务时生成一个6位随机码学生必须输入正确验证码才能签到这样即使学生不在教室也无法仅靠远程定位绕过。请假审批流在系统中增加请假申请和教师审批功能审批通过后自动把该学生的记录置为请假出勤率统计时就自动排除。人脸核身微信端可通过摄像头采集人脸照片调用云端人脸比对服务确认是本人操作。这个方案适合考试入场签到的高严度场景。数据大屏基于签到记录做课程出勤率趋势图、迟到时段分布教师后台可以用ECharts直接渲染视觉效果好答辩时也容易出彩。最后一个建议纯属个人实操体会这类项目最容易被忽略的部分不是代码而是数据口径。出勤率怎么算、迟到多少分钟算迟到、请假是否计入分母这些问题在开发前就找到真实的教务规则并固化到代码里比项目上线后反复改逻辑要省事得多。我在这个项目上改得最多次的代码不是定位接口恰恰是统计口径那块。