news 2026/9/24 18:13:58

Java毕设考勤系统全流程实战:Spring Boot+小程序从表设计到部署避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java毕设考勤系统全流程实战:Spring Boot+小程序从表设计到部署避坑

简介:这是一套面向本科毕业设计的小程序上课考勤系统完整源代码,基于Spring Boot与微信小程序开发,适合Java学习者、毕设学生作为项目参考。系统实现了后台管理、小程序端GPS签到、定位打卡、迟到统计等核心考勤功能,设计获得优秀毕业设计,兼具实用性与教学价值。压缩包共包含488个文件,约4.3MB,涵盖Java后端代码、小程序前端js/wxml/wxss页面、SQL初始化脚本、Dockerfile、批处理启动脚本及docx操作文档,文件分类清晰,便于按模块阅读和部署。系统文档详细说明运行环境配置与数据库导入方式,可帮助读者快速跑通项目并理解考勤业务逻辑。目前已有944人学习下载,适合需要完成类似课设或快速上手Spring Boot与小程序的开发者学习借鉴。

1. 一个 Java 毕设考勤系统,真正难的不是写代码

每年毕业季都会看到一批「学生上课考勤系统」的毕设选题,Java 后端、微信小程序前端、考勤打卡三个词凑在一起,看起来是标准的课程设计模板。但真正动手跑过一遍的人都知道,这个题目最坑的地方不在 CRUD,而在「考勤」这两个字本身:时间怎么对齐、位置怎么判定、重复打卡怎么防、老师那边怎么看到可信的统计结果。我见过太多人把系统写完了,演示的时候学生改个手机时间就签到了,或者人没到教室却定位成功,答辩现场直接翻车。这篇笔记我会按自己带项目的习惯,把从表设计到接口实现、小程序端联调、再到打包交付的完整路径写清楚,照着做能少踩一半坑,新手跟得动,熟手也能看到参数和边界。

2. 先把架子搭对:Spring Boot + 小程序 + MySQL 的四层结构

2.1 为什么选 Spring Boot + 原生小程序,而不是 uni-app

常见做法是后端用 Spring Boot 2.x + MyBatis-Plus + MySQL,前端用微信原生小程序,不引入 uni-app。原因很实际:毕设答辩时老师问的最多的就是「每个文件在干什么」,原生小程序的pages/index/index.jsindex.wxmlindex.wxss一一对应,讲起来不绕。uni-app 虽然能一套代码跑多端,但启动阶段多一层vue编译链路,小程序基础库版本稍有差异就出幺蛾子,不值得为了省那点事增加调试成本。

前后端分离的结构也方便单独演示:后端接口用 Postman 就能测,小程序端独立开发。整条链路是「小程序 -> 后端 Controller -> Service -> Mapper -> MySQL」,四层结构清晰,答辩时画一张架构图就能讲五分钟。

2.2 数据库四张核心表的设计

考勤系统的核心不在用户表,而在「考勤任务」和「考勤记录」两张表的关联关系。我一般会设计四张表:用户表、课程表、考勤任务表、考勤记录表。课程表和用户表之间通过teacher_id关联,学生选课关系用选课表单独维护,避免在用户表里堆字段。

先看建表脚本,这是整个项目的地基:

-- 用户表:学生和教师共用,用 role 区分 CREATE TABLE `t_user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '学号/工号', `password` varchar(255) DEFAULT NULL COMMENT '密码,MD5 存储', `name` varchar(50) NOT NULL COMMENT '姓名', `openid` varchar(64) DEFAULT NULL COMMENT '微信 openid,小程序登录用', `role` tinyint NOT NULL DEFAULT '2' COMMENT '1教师 2学生', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`), KEY `idx_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -- 课程表 CREATE TABLE `t_course` ( `id` bigint NOT NULL AUTO_INCREMENT, `course_name` varchar(100) NOT NULL, `teacher_id` bigint NOT NULL COMMENT '教师用户 id', `classroom` varchar(100) DEFAULT NULL COMMENT '上课地点,用于定位辅助', `latitude` decimal(10,6) DEFAULT NULL COMMENT '教室纬度', `longitude` decimal(10,6) DEFAULT NULL COMMENT '教室经度', `start_time` time DEFAULT NULL COMMENT '上课时间', `end_time` time DEFAULT NULL COMMENT '下课时间', PRIMARY KEY (`id`), KEY `idx_teacher_id` (`teacher_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程表';

这里有两个细节值得说。第一,openid字段必须加索引,小程序每次登录都靠它反查用户,没有索引数据量大了以后查询会明显变慢。第二,课程表里直接存教室的经纬度,这是后面做定位考勤的判断基准,不要在打卡接口里写死坐标,不然换个教室你的判断逻辑就废了。

考勤任务表和记录表是核心,单独拆出来写:

-- 考勤任务表:老师发起一次考勤就生成一条 CREATE TABLE `t_attendance` ( `id` bigint NOT NULL AUTO_INCREMENT, `course_id` bigint NOT NULL COMMENT '所属课程', `teacher_id` bigint NOT NULL COMMENT '发起人', `start_time` datetime NOT NULL COMMENT '考勤开始时间', `end_time` datetime NOT NULL COMMENT '考勤截止时间', `location_range` int DEFAULT '200' COMMENT '允许的定位误差范围,单位米', `status` tinyint DEFAULT '1' COMMENT '1进行中 2已结束', PRIMARY KEY (`id`), KEY `idx_course_id` (`course_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考勤任务表'; -- 考勤记录表:每个学生提交一次打卡对应一条 CREATE TABLE `t_attendance_record` ( `id` bigint NOT NULL AUTO_INCREMENT, `attendance_id` bigint NOT NULL COMMENT '考勤任务 id', `student_id` bigint NOT NULL COMMENT '学生用户 id', `checkin_time` datetime NOT NULL COMMENT '打卡时间(服务器时间)', `latitude` decimal(10,6) DEFAULT NULL COMMENT '打卡时的纬度', `longitude` decimal(10,6) DEFAULT NULL COMMENT '打卡时的经度', `distance` decimal(10,2) DEFAULT NULL COMMENT '与教室距离,单位米', `status` tinyint DEFAULT '1' COMMENT '1正常 2迟到 3缺勤', PRIMARY KEY (`id`), UNIQUE KEY `uk_attendance_student` (`attendance_id`, `student_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考勤记录表';

t_attendance_record表最关键的约束是uk_attendance_student这个联合唯一索引,它保证一个学生在同一次考勤任务里只能有一条记录,这是防重复打卡的地基。就算前端重复提交、后端并发处理,数据库这一层也能兜住。很多毕设在这个表上不建唯一索引,结果就是学生多点几次按钮,统计报表里一个人的出勤次数变成了好几条。

2.3 项目目录结构与环境变量准备

后端项目结构我习惯按 controller、service、mapper、entity、common 分包,考勤相关的接口单独放一个AttendanceController,不要和用户接口混在一起。Java 环境上直接用 JDK 1.8 配好环境变量,Spring Boot 2.7.x 足够稳定。

application.yml里有几个参数是必须提前定好的:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/attendance_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted # 自定义参数:token 过期时间,单位分钟 jwt: expire-minutes: 720

这里说一下logic-delete-field这个配置,MyBatis-Plus 的逻辑删除在毕设里很实用,删除课程、删除用户这些操作不会真的把记录从表里抹掉,而是打一个删除标记。答辩时老师问「数据删错了怎么办」,你直接说是逻辑删除,这就是一个可讲的亮点。serverTimezone=Asia/Shanghai这个参数很多人忽略,MySQL 8.x 不指定时区经常会报错或者时间差八个小时,属于事前加一行、事后省一天的配置。

3. 后端三个核心接口:登录换 token、打卡判距离、统计出报表

3.1 登录鉴权:wx.login 换 code,后端再用 code 换 openid

微信小程序登录的流程是固定的:小程序端调wx.login拿到一个临时code,传给后端;后端拿这个code去微信接口换openid,再用openid查用户表,查到就发一个 JWT token 给前端。这里不要自己去记 session,小程序端每个请求都带 token,后端用一个拦截器统一校验,Stateless 的方式在答辩时也好解释。

我一般用 hutool 的JWTUtil来生成和解析 token,省去手写 JJWT 的一大段配置。核心代码这样写:

@Service public class LoginService { @Autowired private UserMapper userMapper; private static final String SECRET = "attendance-demo-secret"; /** * 用 wx.login 的 code 换 openid,再查用户并签发 token */ public Map<String, Object> login(String code) { // 1. 调微信接口,code 换 openid String url = "https://api.weixin.qq.com/sns/jscode2session" + "?appid=YOUR_APPID" + "&secret=YOUR_SECRET" + "&js_code=" + code + "&grant_type=authorization_code"; String result = HttpUtil.get(url); JSONObject json = JSONUtil.parseObj(result); String openid = json.getStr("openid"); if (StrUtil.isBlank(openid)) { throw new RuntimeException("微信登录失败:" + result); } // 2. 查用户表,找不到就跳转绑定页 User user = userMapper.selectOne( new LambdaQueryWrapper<User>().eq(User::getOpenid, openid)); if (user == null) { throw new RuntimeException("该微信号未绑定账号,请先到 PC 端绑定"); } // 3. 生成 JWT,过期时间从配置读取 String token = JWTUtil.createToken( Map.of("userId", user.getId(), "role", user.getRole()), SECRET.getBytes()); Map<String, Object> resultMap = new HashMap<>(); resultMap.put("token", token); resultMap.put("userId", user.getId()); resultMap.put("role", user.getRole()); resultMap.put("name", user.getName()); return resultMap; } }

这段代码里有三个值得展开的点。第一,jscode2session的返回结果里包含openidsession_keysession_key用不到就不要存库,你只要openid。第二,Map.of是 Java 9 的语法,如果你用的是 JDK 1.8,要改成HashMap手动 put,不然编译不过,这是新手最容易卡住的一分钟。第三,token 里只放userIdrole就够了,别把姓名、手机号全塞进去,token 越短,网络传输的损耗越小。后续每个接口在拦截器里解析 token,拿到userId再查数据库,这就是无状态登录的基本链路。

3.2 发布考勤与提交打卡:经纬度距离判断的完整逻辑

老师发起一次考勤,后端只做一件事:往t_attendance表插一条记录,start_time取当前时间,end_time取当前时间加 10 分钟作为考勤窗口。学生提交打卡,后端要同时做三件事:判断考勤任务是否在有效时间内、判断打卡位置和教室的距离是否在允许范围内、写入考勤记录。

距离判断用 Haversine 公式算球面距离,这是最常考的算法点,代码也不复杂:

public class DistanceUtil { private static final double EARTH_RADIUS = 6371000.0; // 地球半径,单位米 /** * 计算两个经纬度点的球面距离 */ public static double distance(double lat1, double lon1, double lat2, double lon2) { double radLat1 = Math.toRadians(lat1); double radLat2 = Math.toRadians(lat2); double deltaLat = Math.toRadians(lat2 - lat1); double deltaLon = Math.toRadians(lon2 - lon1); double a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) + Math.cos(radLat1) * Math.cos(radLat2) * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2); double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return EARTH_RADIUS * c; } }

这个公式在很多定位场景里都能用,本质是把经纬度换算成弧度,再用余弦定理求球面距离。EARTH_RADIUS取 6371000 米而不是 6371 千米,是为了让返回结果直接是米。误差在几十米级别,对教室考勤来说完全够用。如果你想偷懒用Math.hypot按平面直角坐标算,在学校这种小范围场景下误差不大,但答辩时被问「你这个距离准不准」就不好解释了。

打卡接口的 Controller 层这样写:

@PostMapping("/student/checkin") public Result checkin(@RequestBody CheckinRequest req) { // 1. 找考勤任务,判断是否在有效窗口内 Attendance attendance = attendanceService.getById(req.getAttendanceId()); Date now = new Date(); if (now.before(attendance.getStartTime()) || now.after(attendance.getEndTime())) { return Result.fail("不在考勤时间段内"); } if (attendance.getStatus() != 1) { return Result.fail("考勤已结束"); } // 2. 算距离,超过范围直接拒绝 Course course = courseService.getById(attendance.getCourseId()); double distance = DistanceUtil.distance( req.getLatitude(), req.getLongitude(), course.getLatitude().doubleValue(), course.getLongitude().doubleValue()); if (distance > attendance.getLocationRange()) { return Result.fail("打卡位置距离教室 " + (int) distance + " 米,超出允许范围"); } // 3. 插入记录,数据库唯一索引兜底 AttendanceRecord record = new AttendanceRecord(); record.setAttendanceId(req.getAttendanceId()); record.setStudentId(req.getStudentId()); record.setCheckinTime(now); record.setLatitude(req.getLatitude()); record.setLongitude(req.getLongitude()); record.setDistance(distance); record.setStatus(now.before(attendance.getStartTime()) ? 1 : 2); // 早到算正常,迟到算迟到 attendanceRecordService.save(record); return Result.ok("打卡成功"); }

这里要注意的是服务器时间判断,new Date()用的是后端服务器的时间,绝不能用小程序传上来的时间。理由很简单:手机时间用户可以随便改,但后端服务器时间改不了,这也是整个考勤系统防作弊的第一道防线。locationRange如果没传默认 200 米,这个值在校园场景需要现场调——教学楼密集的话 200 米可能覆盖隔壁栋楼,一般我建议先去教室实测一次再定。

3.3 统计报表:按课程聚合出出勤率

统计模块是老师端最看重的功能,也是答辩加分项。核心需求就一句话:某门课某次考勤,应到多少人、实到多少人、迟到多少人、缺勤多少人。实现上用一条 SQL 关联两张表就能完成:

@Mapper public interface AttendanceRecordMapper extends BaseMapper<AttendanceRecord> { /** * 统计某次考勤的汇总数据 */ @Select("SELECT " + "COUNT(*) AS total, " + "SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) AS normal, " + "SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) AS late, " + "COUNT(*) - SUM(CASE WHEN status IN (1,2) THEN 1 ELSE 0 END) AS absent " + "FROM t_attendance_record " + "WHERE attendance_id = #{attendanceId}") Map<String, Object> summaryByAttendance(@Param("attendanceId") Long attendanceId); }

COUNT(*)是应到人数,因为每次考勤所有学生都会生成一条记录,缺勤的人记录状态是 3,但记录本身存在。所以缺勤人数不是查出来的,是用总人数减去正常和迟到的人数算出来的。这个逻辑在答辩时一定要讲清楚,很多同学做统计报表的时候只统计了打卡成功的人,缺勤的人根本没进表,那报表里的应到人数就是错的。更完整一点的做法是把学生选课表也关联进来,先查出这门课所有选课学生,再 LEFT JOIN 考勤记录,这样没提交打卡的学生也会出现在结果里,这是另一种实现思路,数据上更严谨。

4. 小程序端闭环:从 wx.login 到定位打卡的完整链路

4.1 小程序端目录结构与 request 封装

小程序端我习惯用微信原生开发,目录结构这样组织:pages/login/放登录页,pages/index/放学生首页和打卡按钮,pages/teacher/放老师端考勤管理,utils/放 request 封装和工具函数。小程序每个页面是四件套(js、json、wxml、wxss),页面的生命周期写在 js 里,结构不用额外引入。

请求封装是整个小程序端的命脉,统一在这里处理 token 注入和 401 跳转:

// utils/request.js const BASE_URL = 'http://localhost:8080'; function request(path, method, data) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + path, method: method || 'GET', data: data || {}, header: { 'Content-Type': 'application/json', 'token': wx.getStorageSync('token') || '' }, success(res) { if (res.statusCode === 401) { // token 失效,清理缓存并跳登录页 wx.removeStorageSync('token'); wx.removeStorageSync('userId'); wx.navigateTo({ url: '/pages/login/login' }); reject(new Error('登录已过期')); return; } if (res.data.code !== 200) { wx.showToast({ title: res.data.msg, icon: 'none' }); reject(new Error(res.data.msg)); return; } resolve(res.data.data); }, fail(err) { wx.showToast({ title: '网络请求失败', icon: 'none' }); reject(err); } }); }); } module.exports = { request, BASE_URL };

封装的核心就是把重复的事做掉:每个请求自动带头 token,接口返回code !== 200时统一弹 toast,401时统一踢回登录页。如果不做这个封装,每个页面都要写一遍wx.request的完整回调,代码量翻倍而且容易漏掉 token 的处理。BASE_URL这里写的localhost是开发者工具里的跑法,后面真机调试要换成电脑的局域网 IP,这个坑后面避坑章节详细说。

4.2 学生打卡页:getLocation 获取位置并提交

学生端打卡页面的核心逻辑就两步:调用wx.getLocation拿经纬度,然后调后端的/student/checkin接口。这里有个容易被忽略的前提:wx.getLocation需要在小程序后台申请权限,而且在app.json里要声明permission字段,不声明的话接口直接报错。

打卡页的 js 逻辑这样写:

// pages/checkin/checkin.js const { request } = require('../../utils/request'); Page({ data: { attendanceId: null, courseName: '', latitude: null, longitude: null, loading: false }, onLoad(options) { this.setData({ attendanceId: options.attendanceId }); }, // 获取当前位置并打卡 handleCheckin() { if (this.data.loading) return; this.setData({ loading: true }); wx.getLocation({ type: 'gcj02', success: (res) => { const { latitude, longitude } = res; this.setData({ latitude, longitude }); request('/student/checkin', 'POST', { attendanceId: this.data.attendanceId, latitude: latitude, longitude: longitude }).then(() => { wx.showToast({ title: '打卡成功', icon: 'success' }); }).catch(() => { // 具体的错误信息已经在 request 里 toast 过了 }).finally(() => { this.setData({ loading: false }); }); }, fail: (err) => { this.setData({ loading: false }); wx.showToast({ title: '定位失败,请检查手机定位权限', icon: 'none' }); } }); } })

type: 'gcj02'这个参数必须写,它返回的是国测局坐标,和腾讯地图、微信生态的坐标系一致。如果你后端用的坐标是 GPS 的 WGS84 原始坐标,两者之间会有几十到几百米的偏移,考勤距离判断直接从「允许 200 米」变成「永远不在范围内」。这是一个很典型的坐标系不一致翻车现场,最简单的处理方式就是前端传gcj02,后端基准教室坐标也用gcj02,全链路统一,不要在中间做转换。

4.3 教师端发布考勤:一个按钮搞定任务创建

教师端页面相对简单,核心功能是选择课程、发起考勤、查看记录。发起考勤的按钮事件里,调用后端的/teacher/attendance/create接口,传courseId即可。一个值得做的增强功能是调用wx.scanCode扫描课程二维码,扫码后自动填充课程信息,这个功能在答辩演示时效果很好,代码量也不大:

// pages/teacher/create.js scanAndCreate() { wx.scanCode({ onlyFromCamera: true, success: (res) => { const courseId = res.result; // 二维码内容就是课程 id this.createAttendance(courseId); } }); }, createAttendance(courseId) { request('/teacher/attendance/create', 'POST', { courseId }) .then((data) => { wx.showToast({ title: '考勤已发起', icon: 'success' }); }); }

教师端发起考勤后,学生端怎么知道有新的考勤任务?两种常见方案。第一种是最简单的:学生进入首页时拉取「进行中的考勤列表」,适合毕设;第二种是接入小程序的订阅消息,老师发起考勤后给学生推送一条通知,效果更好但需要申请消息模板,还要处理用户授权,复杂度上升一个档次。我一般建议毕设里做第一种,答辩时把第二种作为「后续优化方向」一句话带过,既能体现思考深度又不增加开发量。

小程序的wx.scanCode在真机上体验很顺,但在开发者工具里经常扫码失败,属于正常现象,演示前要提前用真机测试一遍。

5. 考勤系统避坑手册:时间作弊、模拟定位与授权消失

考勤系统的坑集中在「可信度」三个字上,这里把我自己踩过的、以及学生常遇到的五类问题整理出来,每条按「现象 -> 原因 -> 解决」展开。

5.1 现象:人没到教室,考勤却通过了

这是考勤系统里最要命的问题。现象是学生人在宿舍,用开发者工具或真机模拟了教室的定位,后端没有拦下来。

原因有两层。第一层是前端定位本身就能伪造,wx.getLocation返回的数据在开发者工具里可以手动设置,真机上也可以用第三方工具模拟;第二层是后端如果不校验「坐标是否真的在教室附近」,只接受前端传上来的值,那整个定位考勤就是形同虚设。

解决思路是后端必须做距离校验,这也是前面DistanceUtil存在的意义。更进一步的加固是多点采样:让学生在前端连续获取三次定位,后端判断三次坐标是否在合理范围内且相互之间距离不过大,如果三次坐标完全相同或者跳动异常,直接判定为疑似作弊。这个方案在答辩时讲出来,老师会觉得你考虑问题很全面。代价是接口调用次数翻三倍,对毕设项目来说无压力。

5.2 现象:学生改了手机时间,签到记录全乱了

现象是学生在考勤截止时间之后提交打卡,但在前端把手机时间改成截止时间之前,后端记录的却是「正常打卡」。

原因是前端代码里如果用new Date()取的是手机本地时间,这个时间完全可控;甚至某些安卓机型在时间被修改后,网络请求里的某个环节会把本地时间带进请求体。

解决方法是后端所有时间判断统一用服务器时间,前端传上来的时间字段一律忽略,只作为展示用。具体到代码层面,就是打卡记录里的checkin_time永远在后端new Date()生成,不在前端设置。另外一个细节:数据库连接串里要配好serverTimezone=Asia/Shanghai,不然 MySQL 默认按服务器时区解析时间,可能产生八小时偏差,你以为没事,实际上记录的时间全部错位。

5.3 现象:token 过期后接口全部 401,页面白屏

现象是学生用着用着,接口突然全部返回 401,页面没有任何提示,看起来像程序卡死了。

原因是 JWT token 有过期时间,过期后后端拦截器直接拒绝请求,但前端没有处理 401 的公共逻辑,也没有让用户重新登录的机制。

解决方法是统一在utils/request.js里处理 401:清理本地缓存的 token 和用户信息,跳转登录页。我在 4.1 节封装的代码里已经处理了。这里要注意的细节是:wx.navigateTo在页面栈超过十层时会失效,登录页跳转建议用wx.reLaunch,它会清空整个页面栈,避免用户从登录页返回时又回到打卡页。token 过期时间也不要设得太长,我一般设 720 分钟,也就是 12 小时,够一天使用,又不会让 token 在长时间内一直有效。

5.4 现象:开发者工具请求正常,真机上 request 全部失败

现象是电脑上用微信开发者工具调试一切正常,数据都能加载,但是拿手机一打开,所有请求全部失败。

原因是开发者工具默认开启了「不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书」这个选项,你在开发者工具里请求http://localhost:8080没问题;真机上微信客户端强制要求域名必须是 HTTPS 且在小程序后台配置过合法域名,localhost在真机上是手机自己,不是你的电脑。

解决方法是分两步:开发阶段,让电脑和手机连同一个 WiFi,BASE_URL改成电脑的局域网 IP,比如http://192.168.1.100:8080,同时在开发者工具里勾选「不校验合法域名」;如果要真正在真机体验,只能把后端部署到有公网 IP 的服务器上并配 HTTPS。如果你没有服务器,本地电脑配合内网穿透或者把项目部署到云服务器是最常见的做法,云平台的免费额度通常够毕设演示用。这个问题是我见过最多人卡住的地方,不是代码错,是环境没对上。

5.5 现象:学生连续点击打卡按钮,生成了多条记录

现象是学生在打卡页面快速连点按钮,Network 里能看到同一个打卡接口被调了多次,数据库里出现了同一考勤任务下的多条记录。

原因是点击事件没有加防抖,loading状态在第一次请求回来之前就已经被快速点击绕过了;如果加了loading判断,但前端的setData是异步的,也存在短暂窗口期。

解决方法是三层防护。第一层是前端按钮加loading标志,点击后立即置灰;第二层是t_attendance_record表的联合唯一索引uk_attendance_student(在 2.2 节建表时已经加上了),即使并发请求到达,数据库也会拒绝第二条插入;第三层是在 Service 层用synchronized或者分布式锁(项目不大就用数据库唯一索引就够了)。后端在插入时捕获DuplicateKeyException,返回友好提示「你已经打过卡了」,不要直接抛 500 让学生看到一堆英文堆栈。这三层里,数据库唯一索引是兜底,前两层是体验优化,少了任何一个都可能在极端场景下翻车。

6. 交作业前的最后一轮:验证用例、源码打包与答辩表达

项目写完到交源码之间,我习惯先过一遍验证用例,不要直接打包。这里列一个最小验证清单,照着跑一遍基本不会在演示时出丑:

编号验证场景操作步骤预期结果
1首次登录微信开发者工具中调用wx.login,后端用 code 换 openid未绑定用户提示去绑定;已绑定用户返回有效 token
2正常打卡教师端发起考勤,学生端在教室范围内点击打卡返回「打卡成功」,考勤记录状态为正常
3超范围打卡将开发者工具中的定位改为校门口返回「超出允许范围」,并显示距离值
4重复打卡打卡成功后再次点击按钮返回「你已经打过卡」,数据库仍只有一条记录
5token 过期修改jwt.expire-minutes为 1,等两分钟后请求任意接口小程序自动跳转登录页

验证用例通过之后,再处理源码包。打包时注意三件事:第一,application.yml里的数据库密码不要用真实密码,改成123456之类并写进 README;第二,建表 SQL 脚本单独放一个db/init.sql文件,名字起清楚一点,很多同学把 SQL 丢散在代码注释里,老师找不到就印象分大减;第三,README 里写清楚「从零跑通」的完整步骤,包括环境版本(JDK 1.8、MySQL 8.0、微信开发者工具稳定版)、BASE_URL怎么改、真机调试注意事项。代码的完整性和可复现性,比代码本身的复杂度更重要,这是毕设源码评分的潜规则。

最后说答辩表达。我通常会让学生准备两个问题的答案:第一,「JWT 和传统 Session 有什么区别」——不要背八股文,用自己的话讲:Session 是服务器记一份用户数据,JWT 是服务器发一个签过名的凭证,每次请求带着,服务器验证签名就认账,不占服务器内存;第二,「定位考勤怎么防作弊」——把距离校验、服务器时间、联合唯一索引、多次采样这四点讲一遍,每个点对应一个表或一段代码,老师立刻能看出这项目不是你网上找的。如果老师追问「数据量大怎么办」,你就说考勤记录表按attendance_id建索引,单表数据超过百万级可以按月分表——这属于加分的延伸答案,答不上来也没关系,前面几点扎实就够了。

我自己带毕设的习惯是,交付前一定亲自用真机把整条链路走两遍:一遍在开发者工具里模拟,一遍用手机开热点跑真机。定位考勤这类系统,玄学问题特别多,模拟器正常、真机偏移几十米是常有的事,提前把location_range调好比你多写一个功能有用得多。希望这篇笔记能帮你少踩几个坑,把精力花在真正能加分的地方。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/24 18:13:10

递增的三元子序列

题目描述 给你一个整数数组 numsnumsnums&#xff0c;判断这个数组中是否存在长度为 3 的递增子序列。 如果存在这样的三元组下标 (i,j,k)(i, j, k)(i,j,k) 且满足 i<j<ki < j < ki<j<k &#xff0c;使得 nums[i]<nums[j]<nums[k]nums[i] < nums[…

作者头像 李华
网站建设 2026/9/24 18:11:49

基于UNet的脑肿瘤分割与生存预测:从2D到3D的完整实践指南

简介&#xff1a;面向医学影像分析与深度学习方向的高校学生、科研工作者&#xff0c;这份毕设资源系统实现了三种脑肿瘤分割算法&#xff0c;并包含生存预测模型和项目报告&#xff0c;主要解决从算法理论到代码落地、从结果评估到论文撰写的完整需求。资源共五十个文件&#…

作者头像 李华
网站建设 2026/9/24 18:11:05

Windows GDI AlphaBlend 像素级半透明绘制实战指南

简介&#xff1a;本资源是一份面向Windows桌面开发初学者与中级程序员的AlphaBlend半透明绘制实战源码包&#xff0c;聚焦图形界面中位图透明叠加这一典型视觉需求。资源完整实现基于GDI的32位带Alpha通道位图混合渲染&#xff0c;涵盖设备上下文配置、BLENDFUNCTION结构体设置…

作者头像 李华
网站建设 2026/9/24 18:11:01

Python交通流预测实战:855个传感器数据清洗与拥堵等级建模

简介&#xff1a;这份资源面向具备一定Python基础、希望入门智能交通与数据挖掘的学习者&#xff0c;围绕道路短时车流量与拥堵状态预测展开。项目基于GCM Corridor真实交通数据&#xff0c;覆盖16座城镇主干道、855个传感器每5分钟采集的拥堵记录&#xff0c;包含日期、方向、…

作者头像 李华
网站建设 2026/9/24 18:10:58

点云融合实战:从ICP配准到RGB多帧融合与避坑指南

简介&#xff1a;这份资源面向计算机视觉与三维重建方向的学习者&#xff0c;围绕RGB-D相机采集的不连续三帧图像&#xff0c;完整演示点云多帧融合流程。内容涵盖点云生成、坐标变换、点云配准与融合策略等关键环节&#xff0c;适合正在做课程作业或入门SLAM、三维重建的读者练…

作者头像 李华
网站建设 2026/9/24 18:10:58

JavaEE二手图书交易平台源码实战:分层架构与部署避坑指南

简介&#xff1a;这是一套面向高校计算机相关专业学生的JavaEE课程设计完整资源&#xff0c;以二手图书交易平台为选题&#xff0c;适合作为期末大作业、课程设计或毕业设计参考&#xff0c;新手也能快速上手。资源包共173个文件&#xff0c;约25.68MB&#xff0c;涵盖21个Java…

作者头像 李华