简介:基于微信小程序与SSM框架的学生签到系统完整项目源码,面向计算机专业毕业设计学生和Java/小程序开发者。系统覆盖用户管理、签到任务创建、一键及二维码签到、签到记录查询、教师管理、消息提醒、权限控制与数据统计分析等功能模块,可满足学校日常考勤管理需求。压缩包共1087个文件,约16MB,包含Java后端代码、Vue管理端、微信小程序前端(wxml/wxss/js)、SQL初始化脚本及运行批处理脚本,文件类型涵盖java、vue、js、png、xml等,目录结构清晰,便于部署与二次开发。目前已有132人学习下载。通过该项目可掌握微信小程序与SSM框架的整合方法、前后端数据交互流程及权限设计思路,适合作为毕业设计选题或项目实战练手的参考资料。
1. 基于微信小程序的学生签到系统:Java 源码与 SSM 框架的落地形态
做学生签到系统,最容易翻车的不是功能写不出来,而是微信小程序和 SSM 框架两条线对不上。这套基于微信小程序的学生签到系统,后端是 SSM(Spring + SpringMVC + MyBatis),前端是原生微信小程序,管理端是 Vue 页面,是 Java 项目源码里很典型的毕业设计组合。它解决的核心问题是:把教师点名、纸质签到表这种低效流程,换成学生打开微信扫码或一键签到,签到记录自动落库,教师端按课程、按时间统计出勤率。适合正在挑毕业设计方向的人,也适合想快速搭一套考勤 Demo 的从业者。资源里包含了后端、小程序端、Vue 管理端和三个一键脚本,下面按我实际拆这套资源的顺序,把结构、链路、部署和坑一次讲透。
2. 拆开这套签到系统:SSM 服务端、小程序端和三个启动脚本
2.1 一个资源包两份代码:这堆 .bak 文件是退路不是垃圾
刚解压会看到一堆.bak后缀的文件,main.css.bak、update-password.vue.bak、IndexMain.vue.bak、BreadCrumbs.vue.bak、IndexHeader.vue.bak,别急着删。.bak是原开发者在改版时留下的备份,比如IndexMain.vue.bak是管理端首页的旧版本,update-password.vue.bak是修改密码页面的旧版本。这种命名习惯说明一点:这套系统在迭代时保留了可回退的副本,你改崩了还能翻回去。
真正的运行代码分三块:
| 模块 | 载体 | 作用 |
|---|---|---|
| 后端服务 | SSM 多模块 Maven 工程 | 提供登录、签到、任务、统计等接口 |
| 小程序端 | 微信开发者工具工程 | 学生登录、一键签到、扫码签到、查记录 |
| 管理端 | Vue 单页应用 | 教师创建任务、查看记录、导出数据 |
文件列表里的1-install.bat、2-run.bat、3-build.bat是部署顺序的提示:先装依赖,再启动后端,最后构建管理端前端。.vscode那几个文件不用管,那是编辑器配置。
2.2 SSM 服务端三层:签到任务和记录怎么组织
SSM 的核心是 Spring 管对象、SpringMVC 管接口路由、MyBatis 管 SQL 映射,三层职责非常清楚:Controller层只收参数和返回结果,Service层写业务逻辑和事务,Mapper层写 SQL。签到系统里最核心的两张业务表是sign_task(签到任务)和sign_record(签到记录),加上student、teacher就够撑起主流程。
sign_task表结构大概是这样的:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| teacher_id | int | 创建任务的教师 ID |
| course_name | varchar | 课程名称 |
| start_time | datetime | 签到开始时间 |
| end_time | datetime | 签到截止时间 |
| type | tinyint | 1 一键签到,2 二维码签到 |
sign_record表记录每次签到行为,核心字段是task_id、student_id、sign_time、status。status用 0 和 1 区分失败与成功,迟到与否不直接存,而是拿sign_time和start_time做差算出来,这样统计口径可以随时调。
Mapper 层用注解或 XML 都可以,这个项目里两种写法混着用。XML 方式更利于复杂统计:
<select id="selectByTaskId" resultType="SignRecord"> SELECT id, task_id, student_id, sign_time, status FROM sign_record WHERE task_id = #{taskId} </select>逻辑上,#{taskId}是预编译参数,MyBatis 会把它转成?占位符,能防 SQL 注入。如果你想按班级过滤,加一行AND student_id IN (SELECT id FROM student WHERE class_name = #{className})就行,这就是整套统计功能的地基。
2.3 小程序端与 Vue 管理端:页面职责先分清
小程序端典型的页面结构是:pages/index/index做登录和首页,pages/sign/sign做签到操作,pages/record/record查历史记录。管理端的IndexMain.vue是主框架,BreadCrumbs.vue是面包屑导航,IndexHeader.vue是顶部栏,IndexAsideStatic.vue是侧边菜单,update-password.vue是改密页。这种组件拆分方式意味着管理端可以单独打包部署,后端只需要提供 JSON 接口。
小程序端调接口的公共请求层一般长这样:
// utils/request.js const BASE_URL = 'http://localhost:8080/api' function request(path, method, data) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + path, method: method, data: data, header: { 'Content-Type': 'application/json' }, success(res) { if (res.data.code === 200) { resolve(res.data.data) } else { reject(res.data.msg) } }, fail: reject }) }) }BASE_URL这里写的是本地开发地址,上线后要换成 HTTPS 域名。注意微信小程序生产环境要求接口域名必须备案并且配好 SSL 证书,本地联调阶段可以勾选“不校验合法域名”跳过这层限制。Promise 包裹是为了让页面里用async/await写异步逻辑,避免回调地狱。
3. 微信登录与签到链路:从 code 到 openid 再到签到记录
3.1 微信登录不是“手机号+密码”:code 换 openid
学生端登录不输账号密码,而是调wx.login()拿一个临时code,这个 code 只能用一次,五分钟内有效。后端拿到 code 之后,拿它去微信服务端换openid和session_key。openid是用户在这个小程序下的唯一 ID,session_key是会话密钥,解密手机号等敏感信息时会用到。
后端典型实现:
@PostMapping("/wx-login") public Result wxLogin(@RequestBody LoginRequest req) { String url = "https://api.weixin.qq.com/sns/jscode2session" + "?appid=" + appId + "&secret=" + appSecret + "&js_code=" + req.getCode() + "&grant_type=authorization_code"; String resp = restTemplate.getForObject(url, String.class); JSONObject json = JSON.parseObject(resp); String openid = json.getString("openid"); Student student = studentMapper.findByOpenid(openid); if (student == null) { student = new Student(); student.setOpenid(openid); student.setName("微信用户" + openid.substring(openid.length() - 4)); studentMapper.insert(student); } String token = UUID.randomUUID().toString().replace("-", ""); redis.set("token:" + token, student.getId().toString(), 7 * 24 * 3600); return Result.success(token); }这段做了三件事:换 openid、查库、没有则自动注册,然后发一个自定义 token 给小程序。appId和appSecret必须放后端,放前端等于把钥匙挂在门口。restTemplate调用微信接口时,返回里如果带errcode就不是成功响应,必须判空处理。
3.2 一键签到与二维码签到:接口设计与时间窗限制
一键签到的流程是:教师端创建任务后,小程序首页显示“当前进行中的签到任务”,学生点一下按钮就调签到接口。二维码签到则是教师端生成一个包含taskId的二维码,学生扫码自动跳转到签到页。
签到接口核心逻辑:
@PostMapping("/sign/doSign") public Result doSign(@RequestBody SignRequest req) { SignTask task = signTaskMapper.selectById(req.getTaskId()); if (task == null) { return Result.error("签到任务不存在"); } Date now = new Date(); if (now.before(task.getStartTime()) || now.after(task.getEndTime())) { return Result.error("不在签到时间范围内"); } SignRecord record = new SignRecord(); record.setTaskId(task.getId()); record.setStudentId(req.getStudentId()); record.setSignTime(now); record.setStatus(1); signRecordMapper.insert(record); return Result.success("签到成功"); }时间窗判断是防早签和补签的关键,now.before(startTime)和now.after(endTime)两个条件缺一不可。如果你不想让学生反复签到,sign_record表加一个task_id + student_id的唯一索引,数据库层面挡住重复提交。我就是在这个地方吃过亏,刚开始只做了应用层判断,学生连点两下就有两条记录。
3.3 权限控制:拦截器里做角色校验
学生只能签到和查自己的记录,教师能建任务、看统计,这个用拦截器在 Web 层统一卡。SSM 项目的拦截器配置在 SpringMVC 的 XML 里,或者用注解@Interceptor注册。拦截器里校验逻辑:
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization"); if (token == null || !redis.hasKey("token:" + token)) { response.setStatus(401); return false; } String role = redis.get("role:" + token); if ("/api/teacher/".startsWith(request.getRequestURI()) && !"teacher".equals(role)) { response.setStatus(403); return false; } return true; }放行规则要分清:登录接口、签到接口放行,管理端写操作接口必须验证 teacher 角色。request.getRequestURI()取到的是当前路径,按前缀匹配角色,比在每个 Controller 里写 if 判断要干净得多。
4. 本地跑通部署:三个 bat 脚本、IDEA 导入和开发者工具配置
4.1 先把启动脚本的顺序搞对:install 再 run,最后 build
资源里三个.bat文件已经给了顺序,但它们不是双击就完事,得先明白每个脚本做了什么。1-install.bat是 Maven 依赖安装,2-run.bat是启动后端服务,3-build.bat是构建 Vue 管理端。
1-install.bat典型内容:
@echo off cd /d %~dp0 call mvn clean install -DskipTests pause这里最值钱的是cd /d %~dp0。如果不加这行,在资源管理器里双击 bat,工作目录很可能落在C:\Windows\System32,Maven 找不到项目的pom.xml,直接报错退出。pause让窗口在命令结束后停住,方便看日志。
2-run.bat本质是启动内嵌 Tomcat:
@echo off cd /d %~dp0 call mvn spring-boot:run如果项目不是 Spring Boot 而是传统 SSM 的 war 包,那这行应该替换成 Tomcat 的startup.bat。拆的时候看一下pom.xml里有没有spring-boot-maven-plugin,有就是 Boot 工程,没有就得打 war 丢外置容器。
3-build.bat构建管理端:
@echo off cd /d %~dp0\admin call npm install call npm run build pausenpm install装的是 Vue 项目的依赖,npm run build会执行vue-cli-service build,产物输出到dist目录,再用 Nginx 指过去就行。
4.2 数据库与 SSM 配置改造:MySQL 版本、时区、账号
数据库先建好,再改后端连接配置。SSM 的数据库配置一般在application.properties或jdbc.properties里:
spring.datasource.url=jdbc:mysql://localhost:3306/sign_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai spring.datasource.username=root spring.datasource.password=123456serverTimezone=Asia/Shanghai是血泪教训。MySQL 8.x 默认时区和中国本地时间差八小时,不加这个参数,签到时间全部少八小时,统计迟到率全乱。characterEncoding=utf8解决中文乱码,useUnicode=true是配套参数,MySQL 5.7 和 8.0 都要写。
IDEA 导入时注意三点:选择Import Project然后选 Maven,等待依赖下载完;确认 JDK 版本和pom.xml里配置一致,不一致会报invalid source release;Maven 仓库路径尽可能用默认的.m2,换路径后第一次构建会重新下载全网依赖,非常折磨。
数据库导入用 Navicat 或命令行都行:
mysql -u root -p < sign_system.sql导入后重点检查三张表的数据量:student里有没有测试账号,sign_task里有没有残留任务。我做毕业设计时就是忘了清测试数据,一跑起来全是别人班里的签到记录。
4.3 小程序开发者工具联调配置
小程序端导入项目时,目录要选到包含app.json的那一层。AppID 可以先选测试号,等真正上线再换成自己的。本地联调最关键的是把request.js里的BASE_URL改成自己电脑的局域网 IP:
const BASE_URL = 'http://192.168.31.24:8080/api'用局域网 IP 而不是localhost,是因为手机预览时,手机访问的是你电脑的 IP,不是手机自己的 localhost。开发者工具右上角“详情”里勾选“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”,本地 HTTP 接口才能调通。这步不做,接口请求会直接报fail url not in domain list。
后端启动时会占 8080 端口,如果本机已经被其他程序占了,改application.properties里的server.port=8081,小程序那边同步改。我曾经因为端口冲突排查了半天,最后发现是杀毒软件占着端口,换个端口世界清净。
5. 避坑指南:code2Session 到 Excel 导出的五个翻车点
5.1 登录报 40029:code 是一次性的烟火
现象:小程序登录经常失败,后端日志出现errcode: 40029, errmsg: invalid code,有时候白屏,有时候弹“登录已过期”。
原因:wx.login()生成的 code 只能用一次,且有效期只有五分钟。如果你在前端把 code 存在全局变量里,多个请求复用同一个 code,第二次调用必然失效。还有一种情况是页面onShow每次都调wx.login(),上一次的 code 还没用完就被覆盖了,导致并发请求拿到新的、旧的全部失效。
解决:前端每次需要登录态时重新调wx.login(),不缓存 code;后端拿到 code 后立即消费,不做任何重试。如果后端调用微信接口超时要重试,必须让前端重新生成 code,不能拿旧 code 反复打。
// 正确姿势:每次登录重新拿 code wx.login({ success(res) { if (res.code) { request('/wx-login', 'POST', { code: res.code }) } } })5.2 签到成功但列表查不到:Service 层没提交事务
现象:接口返回“签到成功”,但sign_record表里没有数据,刷新列表也看不到。
原因:Controller 里直接调 Mapper 插入数据,没有走@Transactional,或者 Service 方法内部抛了异常但事务没回滚。最常见的是 Service 里先insert再更新任务计数,第二步抛了空指针,事务默认不会自动回滚,但数据也没落到库里,表现就是“接口成功、库里空空”。
解决:Service 方法加@Transactional(rollbackFor = Exception.class),让所有运行时异常都触发回滚。插入和更新放一个事务里,要么都成功要么都失败:
@Transactional(rollbackFor = Exception.class) public void doSign(SignRequest req) { signRecordMapper.insert(record); signTaskMapper.increaseSignCount(req.getTaskId()); }从那以后我接手这类 SSM 项目,先翻 Service 层有没有事务注解,再谈功能。
5.3 扫码参数乱码:中文和拼接符的编码十六戒
现象:学生扫二维码跳转到签到页,taskId变成一团乱码,后端接口报参数格式错误。
原因:二维码内容拼接了 URL,比如https://xxx/?taskId=1001&name=张三,生成二维码时没做 URL 编码,中文和&符号在扫码解码后被截断或转成了非法字符。
解决:生成二维码时对整个链接做encodeURIComponent,扫码后先decodeURIComponent再拆参数。如果二维码里只需要一个taskId,就只放纯数字,不要放大段字符串,省去编码折腾:
// 解码取参数,必须放在跳转前 const taskId = decodeURIComponent(options.scene)scene参数是微信扫普通链接二维码跳小程序时自动带过来的,习惯上把参数塞scene而不是 query,因为scene本身做了编码转换,能少踩一半坑。
5.4 导出文件名乱码:ResponseHeader 的 UTF-8 陷阱
现象:教师端导出 Excel,下载下来文件名是_、%、Ã的混合体,浏览器里完全没法认。
原因:后端设置Content-Disposition时直接写了中文文件名,没有做 RFC 5987 编码。浏览器默认按 ASCII 解析响应头,中文全乱。
解决:文件名参数用URLEncoder.encode编码,再把+替换成%20:
String fileName = URLEncoder.encode("签到记录.xlsx", "UTF-8").replace("+", "%20"); response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + fileName);注意是filename*=带星号的写法,这是 RFC 5987 的规范格式,老写法filename=只支持 ASCII。Excel 文件本身内容乱码是另一个问题,那个要去检查 POI 写入时代码有没有指定UTF-8,和响应头不是一回事。
5.5 Web 管理端接口 401 连环炸:OPTIONS 预检和 token 过期
现象:管理端页面能打开,但所有请求都返回 401,F12 里看到一堆红色的OPTIONS请求。
原因:Vue 管理端和后端接口跨域了,浏览器会先发一个OPTIONS预检请求检查服务器允不允许跨域。你的拦截器把OPTIONS请求也拦了,返回 401,预检失败导致真实请求根本没发出去。token 过期也会 401,但和这个现象混在一起很容易误判。
解决:拦截器放行OPTIONS请求,跨域配置里允许来源、方法和头:
public boolean preHandle(...) { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } // 原有 token 校验 }判断方法是先看是不是 OPTIONS,是就直接放行。另一步是给后端加跨域过滤器,Access-Control-Allow-Origin别用*,*带不了Authorization头,要写具体域名或者用allowedOriginPatterns("*")。
6. 从能签到到好用:签到率统计、导出与动态二维码改造
6.1 用 SQL 把签到记录变成教学报表
签到系统跑通之后,真正被高频使用的是统计功能。教师想看的不是几十条流水,而是一张表:谁没来、谁迟到、哪门课签到率最低。MyBatis 的 XML 里写统计 SQL,注意sign_record表只存成功记录,所以算“应到人数”不能直接COUNT(*),得从任务关联的学生表去算。
SELECT t.course_name, COUNT(DISTINCT r.student_id) AS 实际签到, sc.total AS 应到人数, ROUND(COUNT(DISTINCT r.student_id) / sc.total * 100, 2) AS 签到率 FROM sign_task t LEFT JOIN sign_record r ON r.task_id = t.id AND r.status = 1 LEFT JOIN ( SELECT task_id, COUNT(*) AS total FROM sign_student_map GROUP BY task_id ) sc ON sc.task_id = t.id WHERE t.teacher_id = #{teacherId} GROUP BY t.id, t.course_nameROUND(..., 2)保留两位小数,GROUP BY t.id, t.course_name因为 task 主键唯一,再加 course_name 不影响分组粒度。算迟到次数则比较签到时间和任务开始时间:
SELECT student_id, COUNT(*) AS late_count FROM sign_record r JOIN sign_task t ON r.task_id = t.id WHERE r.sign_time > DATE_ADD(t.start_time, INTERVAL 5 MINUTE) AND r.status = 1 GROUP BY student_idDATE_ADD的 5 分钟是“宽限期”,想严格点改成INTERVAL 0 MINUTE,想宽松点改成 10。用参数传进去,别写死。
6.2 导出前把数据查出来再填 POI
导出功能不需要在 Controller 里写大段业务逻辑,更推荐把查询和导出拆开。Service 层返回List<SignRecord>,Controller 里只做响应头设置和 POI 填充:
List<SignRecord> records = signService.exportByTaskId(taskId); XSSFWorkbook workbook = new XSSFWorkbook(); Sheet sheet = workbook.createSheet("签到记录"); Row header = sheet.createRow(0); header.createCell(0).setCellValue("学号"); header.createCell(1).setCellValue("姓名"); header.createCell(2).setCellValue("签到时间"); for (int i = 0; i < records.size(); i++) { Row row = sheet.createRow(i + 1); SignRecord r = records.get(i); row.createCell(0).setCellValue(r.getStudentNo()); row.createCell(1).setCellValue(r.getStudentName()); row.createCell(2).setCellValue( new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(r.getSignTime())); }时间格式化放在 Java 层而不是 SQL 层,数据库返回的Timestamp交给前端或 POI 时格式会走默认规则,提前格式化成字符串最稳。大数据量导出用SXSSFWorkbook替代XSSFWorkbook,可以避免几十万行数据时内存溢出。
6.3 动态二维码防代签:把固定内容改成有时效的令牌
静态二维码的致命弱点是截图分享。学生把二维码截图发群里,没到场的人也能扫码签到。一个低成本改造方案:二维码内容不直接放taskId,而是放一个短时有效的令牌。
后端生成令牌:
String token = UUID.randomUUID().toString().replace("-", ""); redis.set("qr:" + token, taskId, 60); String qrContent = "https://yourdomain/sign?token=" + token;二维码链接存活只有 60 秒,过期后旧码作废。教师端每隔一分钟自动刷新二维码,学生必须当场扫码才能签上。这个方案不需要改小程序端签到逻辑,只需要在小程序里把参数从taskId换成token,然后请求后端换取真实的taskId:
const token = decodeURIComponent(options.token) request('/sign/resolveToken', 'POST', { token }).then(res => { const taskId = res.taskId // 继续走原签到流程 })resolveToken接口在 Redis 里查qr:前缀的 key,查到就返回taskId并删掉 key,保证一个码只能用一次。代码量不到二十行,但代签这个最常见的漏洞就堵上了。
这套资源把后端、小程序端、Vue 管理端和部署脚本都收在一个包里,按第四步的操作顺序,新手两个小时能跑通,熟手可以接着改动态二维码和统计报表。从那以后,我每次接手这类 SSM 毕业设计项目,都会先盘一遍事务注解、时区参数和响应头编码这三件事,再谈功能改造。这个习惯帮我省掉了很多“明明没问题”的深夜排查,希望也能帮到你。
本文还有配套的精品资源,点击获取