简介这是一套面向高校课程设计与移动开发初学者的安卓学生签到系统完整源码采用SSM后端与Android Studio前端组合开发适合需要完成课程作业、毕业设计或练手全栈项目的学生与开发者。资源包共3个文件包含2个rar压缩包与1个sql数据库脚本分别对应后端IDEA工程、Android客户端源码以及初始数据库文件整体约139MB解压后可直接导入开发工具运行调试。系统功能覆盖扫码签到、数字签到、定位签到三种模式并支持修改签到记录同时附带加课码选课功能便于还原真实课堂考勤与选课场景。目前已有837人学习下载读者可借此掌握SSM接口设计、安卓端网络请求与数据库建表思路快速搭建可运行的签到系统原型。1. 从纸质点名到扫码签到这套安卓签到系统到底能跑通什么如果你带过高校的课或者在公司里组织过培训一定经历过这样的场景上课铃响了五分钟还在拿着花名册挨个点名一节课四十五分钟点名去掉八分钟后排的学生代答“到”你根本分不清。更麻烦的是签到数据留在纸上期末想统计出勤率得对着几十张纸一张张数。这套基于安卓的学生签到系统解决的就是这个从“点名”到“数据沉淀”的完整链路。它不是一个只跑在手机上的孤立 App而是一套前后端分离的完整方案安卓端负责学生扫码或定位签到服务端负责生成签到码、记录时间戳、统计出勤管理端负责导出报表。技术栈上安卓端用 Android Studio 开发服务端基于 SSMSpring SpringMVC MyBatis框架开发工具是 IntelliJ IDEA。适合谁用正在做课程设计的学生、需要快速搭建一套签到原型的开发者以及想拿一个完整前后端项目练手的人。下面我从环境搭建开始一步步拆给你看。2. 环境搭建与项目结构把 SSM 后端和安卓端跑起来2.1 开发环境清单与版本选择这套项目涉及两个独立的开发环境服务端和安卓端。服务端用 IntelliJ IDEA Maven 构建安卓端用 Android Studio。很多人卡在第一步不是因为技术难而是版本对不上。我一般会先确认 JDK 版本SSM 项目在 JDK 8 和 JDK 11 上都能跑但如果你用的 Spring 版本较老JDK 17 可能会遇到反射相关的报错。安卓端这边Gradle 版本和 Android Studio 版本要匹配否则同步阶段就红一片。组件推荐版本说明JDK8 或 11服务端编译运行IntelliJ IDEA2021.3 及以上服务端开发Maven3.6依赖管理MySQL5.7 或 8.0数据存储Android Studio2021.1 及以上安卓端开发Gradle7.0安卓构建Tomcat8.5 或 9.0服务端部署选 MySQL 5.7 还是 8.0如果你的项目里用了GROUP BY的宽松模式5.7 默认兼容8.0 默认开启ONLY_FULL_GROUP_BY统计出勤率时可能会报错。我一般会在 8.0 的my.ini里把这个模式关掉或者改写 SQL 让字段都出现在GROUP BY里。2.2 服务端项目结构与核心配置服务端用标准的 SSM 分层结构包名按com.xxx.signin组织。核心目录结构如下signin-server/ ├── src/main/java/com/xxx/signin/ │ ├── controller/ # 接口层 │ ├── service/ # 业务逻辑 │ ├── mapper/ # MyBatis 映射接口 │ ├── entity/ # 实体类 │ └── util/ # 工具类 ├── src/main/resources/ │ ├── mapper/ # MyBatis XML │ ├── spring-dao.xml # 数据源配置 │ ├── spring-service.xml │ ├── spring-mvc.xml │ └── jdbc.properties └── pom.xmljdbc.properties里配置数据库连接注意useSSLfalse和serverTimezoneAsia/Shanghai这两个参数少了第一个会有安全警告少了第二个时间会差八小时签到时间戳直接错乱。jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/signin_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai jdbc.usernameroot jdbc.passwordyour_passwordspring-dao.xml里配置数据源和 MyBatis 的SqlSessionFactory把mapper目录下的 XML 扫描进去。这里有个容易翻车的点mapper扫描路径如果写成classpath:mapper/*.xml打包后 XML 文件可能没被复制到target目录导致BindingException。稳妥做法是在pom.xml的build里加上资源目录配置。build resources resource directorysrc/main/resources/directory includes include**/*.xml/include include**/*.properties/include /includes /resource /resources /build这段配置的作用是确保resources下的 XML 和 properties 文件在编译时被复制到target/classes否则 MyBatis 找不到映射文件启动就报错。2.3 安卓端项目结构与网络配置安卓端在 Android Studio 里新建一个 Empty Activity 项目包名和服务端可以不同但接口地址要对应。核心目录app/src/main/java/com/xxx/signinapp/ ├── activity/ # 页面 ├── adapter/ # 列表适配器 ├── model/ # 数据模型 ├── network/ # 网络请求封装 └── util/ # 工具类网络请求用 OkHttp 或 Retrofit我一般用 OkHttp 加 Gson轻量够用。注意安卓 9.0 以后默认禁止明文 HTTP 请求如果你的服务端还没上 HTTPS需要在AndroidManifest.xml的application标签里加android:usesCleartextTraffictrue否则请求直接被系统拦截日志里只看到CLEARTEXT communication not permitted新手很容易在这里卡半天。application android:usesCleartextTraffictrue android:networkSecurityConfigxml/network_security_config ... /application更规范的做法是配一个network_security_config.xml只对特定域名放行明文而不是全局打开。但课程设计阶段全局打开更省事上线前再收紧。3. 签到核心逻辑二维码生成、定位校验与防代签3.1 签到码的生成与刷新机制签到码有两种常见方案一种是固定二维码贴在讲台上学生扫码签到另一种是教师端动态生成带时间戳的二维码每隔几秒刷新一次。固定二维码的问题是学生可以把码拍下来发给没来的人代签成本极低。动态二维码解决了这个问题但实现上要多做一步。服务端生成签到码的逻辑是教师发起签到 → 服务端生成一个 UUID 作为本次签到的sessionId→ 把sessionId和过期时间存入 Redis 或数据库 → 返回给教师端一个二维码内容通常是sessionId加上签名。学生扫码后把sessionId和学号传给服务端服务端校验sessionId是否有效、是否过期、该学号是否已签到。// 教师发起签到 PostMapping(/startSignIn) public Result startSignIn(RequestParam Integer courseId) { String sessionId UUID.randomUUID().toString().replace(-, ); long expireAt System.currentTimeMillis() 5 * 60 * 1000; // 5分钟有效 SignInSession session new SignInSession(); session.setSessionId(sessionId); session.setCourseId(courseId); session.setExpireAt(expireAt); signInSessionMapper.insert(session); return Result.success(sessionId); }这段代码的关键参数是expireAt我设的是 5 分钟。太短了学生还没扫完就过期太长了代签风险又上来。实际用的时候可以根据班级规模调整大班可以设 3 分钟小班 5 分钟足够。sessionId用 UUID 去掉横线避免二维码内容里出现特殊字符导致扫码解析异常。3.2 定位校验与距离计算如果签到不只用二维码还要校验学生是否在教室附近就需要用到定位。安卓端通过LocationManager或FusedLocationProviderClient获取经纬度传给服务端服务端计算学生位置和教室坐标的距离超过阈值就拒绝签到。// 服务端计算两点距离单位米 public static double getDistance(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))); s s * 6371000; // 地球半径单位米 return Math.round(s * 10000.0) / 10000.0; }这个公式是 Haversine 公式6371000是地球平均半径。阈值我一般设 100 米因为 GPS 在室内漂移可能到 50 米以上设太小会导致明明在教室却签不上。如果教室在高层建筑里GPS 信号弱可以放宽到 200 米或者干脆只用二维码不校验定位。定位校验的另一个坑是安卓 6.0 以后需要动态申请ACCESS_FINE_LOCATION权限不申请直接调定位接口会返回空值而且不报错只是拿不到数据。3.3 防代签的几层校验防代签不能只靠一种手段我一般会叠三层第一层是动态二维码过期即失效第二层是设备绑定同一个设备不能给多个学号签到第三层是签到频率限制同一学号在短时间内不能重复签到。设备绑定的实现方式是在学生首次登录时把设备的ANDROID_ID或IMEI安卓 10 以后 IMEI 不可用改用ANDROID_ID和学号绑定签到请求里带上设备标识服务端校验该设备是否已经给其他学号签过到。ANDROID_ID在恢复出厂设置后会变但日常使用中足够稳定。// 获取设备标识 String deviceId Settings.Secure.getString(getContentResolver(), Settings.Secure.ANDROID_ID);签到频率限制用 Redis 的setnx实现key 是signin:limit:{studentId}过期时间设 60 秒。如果setnx返回 false说明 60 秒内已经签过直接拒绝。这样即使有人拿到了签到码也没法在短时间内批量代签。4. 数据统计与报表导出从签到记录到出勤率4.1 数据库表设计与索引优化签到系统的核心表有三张学生表、课程表、签到记录表。签到记录表的数据量增长最快一个班 50 人一学期 32 节课就是 1600 条记录多个班级叠加可能到几万条。所以索引必须提前建好。CREATE TABLE sign_in_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, student_id VARCHAR(20) NOT NULL, course_id INT NOT NULL, session_id VARCHAR(64) NOT NULL, sign_time DATETIME NOT NULL, status TINYINT DEFAULT 1 COMMENT 1正常 2迟到 3缺勤, device_id VARCHAR(64), INDEX idx_student_course (student_id, course_id), INDEX idx_session (session_id), INDEX idx_sign_time (sign_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;idx_student_course用于按学生和课程查询出勤记录idx_session用于按签到场次统计idx_sign_time用于按时间范围导出报表。注意status字段用TINYINT而不是VARCHAR省空间且比较快。device_id字段长度给 64因为ANDROID_ID是 64 位十六进制字符串。4.2 出勤率统计的 SQL 写法出勤率 实际签到次数 / 总签到场次。总签到场次从sign_in_session表里按课程统计实际签到次数从sign_in_record表里按学生和课程统计。一条 SQL 搞定SELECT s.student_id, s.student_name, COUNT(DISTINCT r.session_id) AS attended, (SELECT COUNT(*) FROM sign_in_session WHERE course_id #{courseId}) AS total, ROUND(COUNT(DISTINCT r.session_id) * 100.0 / (SELECT COUNT(*) FROM sign_in_session WHERE course_id #{courseId}), 2) AS rate FROM student s LEFT JOIN sign_in_record r ON s.student_id r.student_id AND r.course_id #{courseId} AND r.status 1 WHERE s.class_id #{classId} GROUP BY s.student_id, s.student_name ORDER BY rate ASC;这里用COUNT(DISTINCT r.session_id)而不是COUNT(*)是因为一个学生在一场签到里可能有多条记录比如先签失败后签成功去重后才准确。LEFT JOIN保证没签到的学生也会出现在结果里出勤率为 0。ORDER BY rate ASC让出勤率低的排在前面方便教师重点关注。4.3 报表导出与 Excel 生成导出报表用 Apache POI服务端生成 Excel 文件后返回给管理端下载。核心代码GetMapping(/export) public void export(HttpServletResponse response, RequestParam Integer courseId) throws IOException { ListAttendanceVO list signInService.getAttendanceList(courseId); XSSFWorkbook workbook new XSSFWorkbook(); XSSFSheet sheet workbook.createSheet(出勤统计); XSSFRow header sheet.createRow(0); String[] titles {学号, 姓名, 签到次数, 总场次, 出勤率}; for (int i 0; i titles.length; i) { header.createCell(i).setCellValue(titles[i]); } int rowNum 1; for (AttendanceVO vo : list) { XSSFRow row sheet.createRow(rowNum); row.createCell(0).setCellValue(vo.getStudentId()); row.createCell(1).setCellValue(vo.getStudentName()); row.createCell(2).setCellValue(vo.getAttended()); row.createCell(3).setCellValue(vo.getTotal()); row.createCell(4).setCellValue(vo.getRate() %); } response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment;filenameattendance.xlsx); workbook.write(response.getOutputStream()); workbook.close(); }XSSFWorkbook对应.xlsx格式HSSFWorkbook对应.xls。数据量大时用SXSSFWorkbook它是流式写入内存占用低。Content-Disposition里的文件名如果包含中文需要 URL 编码否则浏览器下载下来文件名是乱码。我一般写成filename*UTF-8加编码后的文件名。5. 避坑与排查那些让我熬夜的报错5.1 跨域请求被拦截现象安卓端请求服务端接口返回 403 或直接失败浏览器控制台报CORS policy。原因服务端和安卓端不在同一个域浏览器和安卓 WebView 都会触发跨域检查。解决在服务端加一个跨域过滤器或者用 Spring 的CrossOrigin注解。我一般写一个全局过滤器一劳永逸。WebFilter(/*) public class CorsFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletResponse response (HttpServletResponse) res; response.setHeader(Access-Control-Allow-Origin, *); response.setHeader(Access-Control-Allow-Methods, GET,POST,PUT,DELETE,OPTIONS); response.setHeader(Access-Control-Allow-Headers, Content-Type,Authorization); chain.doFilter(req, res); } }注意Access-Control-Allow-Origin设成*在携带 Cookie 时会失效如果项目用了 Session 保持登录态需要把*改成具体的前端域名并且加上Access-Control-Allow-Credentials: true。5.2 时间差八小时现象签到记录里的时间和实际时间差八小时。原因MySQL 的serverTimezone没配或者 JDBC URL 里没加时区参数。解决在jdbc.url里加serverTimezoneAsia/Shanghai同时检查 MySQL 服务器的time_zone变量。如果服务端和数据库不在同一台机器还要确认两台机器的系统时间是否同步。5.3 二维码扫描后无响应现象学生扫码后 App 没反应或者一直转圈。原因二维码内容里带了换行符或特殊字符安卓端解析失败或者扫码后的网络请求超时。解决生成二维码时对内容做 URL 编码扫码后先解码再解析。网络请求超时时间设 10 秒超时后给用户明确提示而不是一直转圈。5.4 定位权限被拒绝后无提示现象学生点了签到App 没反应也没报错。原因安卓 6.0 以后定位权限需要动态申请代码里没处理权限回调直接调定位接口返回空值。解决在Activity里重写onRequestPermissionsResult权限被拒绝时弹 Toast 提示用户去设置里手动开启。if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ! PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, 1); }5.5 MyBatis 映射文件找不到现象启动报BindingException: Invalid bound statement (not found)。原因mapperXML 文件没被编译到target/classes下或者namespace和接口全限定名不一致。解决检查pom.xml的资源目录配置确认 XML 文件在target/classes/mapper下存在。如果用的是 IDEA还要检查Settings → Build → Compiler里有没有排除 XML 文件。6. 进阶技巧把签到系统做成可复用的组件这套系统跑通之后我习惯把它拆成可复用的组件下次做类似项目直接拿过来改。第一个技巧是把签到逻辑抽成一个独立的SignInService不依赖具体的课程和班级只接收sessionId和studentId这样换一个业务场景比如活动签到、会议签到只需要改上层调用核心逻辑不动。第二个技巧是用 Redis 缓存签到场次信息而不是每次都查数据库。教师发起签到后把sessionId、courseId、expireAt写入 Redis设置过期时间。学生签到时先查 Redis命中就直接校验没命中再查数据库。这样在高并发场景下比如 200 人同时扫码数据库压力小很多。// Redis 缓存签到场次 redisTemplate.opsForValue().set(signin:session: sessionId, session, 5, TimeUnit.MINUTES);第三个技巧是给签到接口加限流。用 Redis 的INCR加过期时间实现简单的计数器同一个 IP 每分钟最多请求 30 次超过就返回 429。这样即使有人写脚本刷签到也能挡住大部分。最后一个技巧是关于数据归档的。签到记录表用久了会很大我一般会在每学期结束后把上学期的记录导出到历史表主表只保留当前学期的数据。归档用INSERT INTO ... SELECT加DELETE实现注意在事务里操作避免中途失败导致数据丢失。从那以后我每次做签到类项目都会先把 Redis 缓存和限流加上再去做业务逻辑。因为等到几百人同时扫码的时候再补就不是改几行代码的事了。希望帮到你。本文还有配套的精品资源点击获取