
简介这是一套基于 Ruoyi 与 uniapp 构建的前后端分离学生宿舍考勤管理系统面向高校学生管理场景适合需要快速落地考勤功能或学习企业级前后端分离项目开发的读者。系统覆盖口头点名、普通签到、自定义范围位置签到、二维码签到、人脸识别签到、九宫格手势签到及签到码签到等多种方式并集成阿里云人脸识别与 OSS 存储配合微信小程序密码/授权登录能够灵活应对室内外不同考勤需求。压缩包共 82 个文件大小 1.77MB以 Java 源文件、编译后的 class 文件及 YML/PROPERTIES 配置为主另含 PNG/JPG 截图和 XML 资源可供二次开发时直接参考。目前已有 172 人学习下载资源内包含前后端工程目录、核心业务代码与界面截图便于对照理解考勤流程、可视化数据展示及第三方服务接入思路。 先说明一下这不是什么炫技项目。学生宿舍考勤在很多高校都是宿管拿着一张纸质表挨个勾名字要么就是学生在群里接龙既难统计又难追溯。我这次做的就是一个前后端分离的宿舍考勤管理系统后端用 Ruoyi 框架若依快速搭权限和持久层前端用 uniapp 一套代码覆盖学生端的微信小程序和 App核心解决三种场景的考勤口头点名签到、普通签到、位置签到。这个项目做完以后我最大的感受是Ruoyi 和 uniapp 这对组合确实适合“管理后台移动端”这种典型业务形态但真正磨人的不是框架本身而是三种签到方式背后的业务差异和边界情况。下面我把整个设计思路、核心实现、踩坑过程都整理出来项目结构已经脱敏大家可以在此基础上直接改需求。1. 项目全貌与核心需求拆解1.1 三种签到模式的业务差异很多刚接触这个需求的人会认为“签到嘛不就是点一下按钮”但实际上“口头点名、普通签到、位置签到”是三种完全不同的交互模型不能粗暴地做成同一个接口加个类型字段就完事。先说口头点名签到。这个场景下的操作者其实是宿管或辅导员而不是学生。实际流程是宿管在查寝时挨个确认学生在不在宿舍然后由宿管在手机上操作把在宿舍的人标记为“已到”。这意味着系统需要有一个“代他人签到”的操作入口并且要有操作者的身份记录方便追溯责任人。第二种普通签到就是最常见的学生自助签到在考勤时间内打开小程序或 App点击签到按钮即可完成。它要解决的是考勤时间窗口的校验以及防止一个学生重复签到。第三种位置签到则是在普通签到的基础上增加了地理围栏校验学生只有在宿舍楼周边一定范围内才能签到成功这需要前端获取经纬度后端计算距离并判断是否在允许的范围内。这三种模式在数据层面有交集但在接口设计上需要分别对待。我是这么设计的考勤任务表统一记录“考勤类型”签到记录表里增加“签到方式”和“操作人ID”两个字段口头点名由宿管代操作普通签到和位置签到用同一个签到接口但类型不同触发不同的校验逻辑。1.2 为什么是 Ruoyi uniapp 这套组合先说 Ruoyi 的选型理由。宿舍考勤管理系统的后台需要用户管理、角色权限、菜单管理、宿舍楼管理、学生信息导入这些如果从零开始写光权限部分至少一两周。Ruoyi若依这个框架基于 Spring Boot MyBatis自带 RBAC 权限体系、代码生成器、操作日志、定时任务我用它的代码生成功能直接从数据库表生成了宿舍管理、学生管理、考勤任务维护这些模块的 CRUD 代码省掉了大量重复工作。前端选 uniapp 的理由更直接现在学生使用的终端碎片化非常严重有 iPhone 也有各种安卓机还有大量学生习惯用微信小程序而拒绝装 App。如果每个端都单独开发工作量直接翻三倍。uniapp 基于 Vue 语法一套代码可以编译到微信小程序、支付宝小程序、H5 和原生 App而且 HBuilderX 打包 App 也足够方便。管理后台用 Ruoyi 自带的 Vue 前端移动端用 uniapp两边通过 HTTP 接口通信这就是标准的前后端分离开发模式。1.3 整体模块规划与数据流设计系统的整体模块划分大概是这样的基础管理模块宿舍楼管理、宿舍房间管理、学生信息管理、宿管分配考勤管理模块考勤任务创建设置考勤类型、时间窗口、有效范围、关联宿舍楼、考勤记录查询、考勤统计报表移动端模块学生登录复用 Ruoyi 的登录鉴权、签到首页、考勤结果查询、个人信息通知模块签到结果通知、考勤异常提醒数据流向我在这里梳理一下宿管在 Ruoyi 管理后台创建考勤任务学生通过 uniapp 客户端拉取当前有效的考勤任务然后根据考勤类型执行对应签到流程签到请求提交到后端后后端校验时间、距离、重复性写入考勤记录表前端展示签到结果。如果需要导出统计直接从管理后台导出 Excel 即可。整个流程设计成单向闭合链路调试时不容易乱。2. 后端基于 Ruoyi 的考勤核心实现2.1 考勤任务与签到记录的表结构设计表结构设计是整个项目的地基我前后改了三版才稳定下来。第一版只设计了一张考勤表和一张记录表后来发现位置签到需要记录签到时的坐标口头点名需要记录操作人加上去之后字段越来越多索性在一开始就把两张表的核心字段定完整。考勤任务表简称 attendance_task的核心字段我列一下字段名类型说明idbigint主键task_namevarchar考勤名称attendance_typechar1口头点名 2普通签到 3位置签到dorm_building_idbigint关联宿舍楼start_timedatetime考勤开始时间end_timedatetime考勤结束时间valid_radiusint位置签到的有效半径米仅在位置签到时生效sign_latitudedecimal(10,6)签到点纬度sign_longitudedecimal(10,6)签到点经度statuschar0未开始 1进行中 2已结束create_byvarchar创建人考勤记录表attendance_record的核心字段字段名类型说明idbigint主键task_idbigint关联考勤任务student_idbigint学生IDsign_typechar1口头点名 2普通签到 3位置签到sign_timedatetime签到时间sign_latitudedecimal(10,6)签到坐标纬度位置签到才有sign_longitudedecimal(10,6)签到坐标经度distancedecimal(10,2)与签到点距离米statuschar0缺勤 1正常operator_idbigint操作人ID口头点名时是宿管设计这套表结构时有个关键点是要不要做学生维度的去重约束。我的做法是给 task_id student_id 加联合唯一索引这样无论哪种签到方式一个学生在一个考勤任务下只能有一条记录重复签到会直接抛出异常由前端统一提示“你已完成本次签到”。2.2 三种签到方式的接口设计与差异化逻辑接口层面我做了两个入口一个是“学生自助签到”接口负责普通签到和位置签到另一个是“宿管代签”接口负责口头点名。学生自助签到的接口路径设计为POST /api/attendance/sign核心处理逻辑分三步先根据请求中的 taskId 加载考勤任务校验任务状态是否为“进行中”然后校验当前时间是否在 startTime 和 endTime 之间。接着根据考勤类型执行差异化校验普通签到直接落库位置签到则需要先校验前端提交的经纬度是否在有效范围内。最后在插入签到记录表时捕获唯一索引冲突如果冲突说明重复签到返回友好提示。位置签到的距离计算是这里的技术细节不能用简单的平面距离公式因为经纬度在不同纬度上每度对应的实际距离是不同的。我用的是 Haversine 公式计算球面距离虽然宿舍考勤的范围比较小通常就一两百米平面近似误差可以做到一米以内但为了代码更通用、维护更省心还是直接写球面距离计算。Haversine 公式的核心 Java 实现我放在这里private double calculateDistance(double lat1, double lon1, double lat2, double lon2) { double radLat1 Math.toRadians(lat1); double radLat2 Math.toRadians(lat2); double a Math.sin(radLat1) * Math.sin(radLat2) Math.cos(radLat1) * Math.cos(radLat2) * Math.cos(Math.toRadians(lon1 - lon2)); double distance 6371000 * Math.acos(Math.min(1, a)); return distance; }计算拿到距离后与任务的 validRadius 字段比对距离小于等于有效半径则签到成功否则返回“不在考勤范围内”的错误码前端定位到对应提示文案。宿管代签接口POST /api/attendance/proxySign的逻辑相对简单不需要校验位置只需校验宿管身份以及被代签学生是否在当前考勤任务的宿舍楼内然后直接将记录写入考勤记录表operatorId 记录为当前登录宿管的用户ID。2.3 考勤任务的状态流转与定时处理考勤任务不是创建了就一直在它有明确的生命周期未开始、进行中、已结束。最初我是在查询接口里动态判断当前时间是否落在起止时间内后来发现这样有隐患考勤统计报表需要“已结束”这个状态来筛选如果每次统计都动态算逻辑分散且容易漏。干脆用 Ruoyi 自带的定时任务能力写一个定时任务每分钟扫描一次任务表把到了开始时间的更新为进行中把到了结束时间的更新为已结束。这个思路很朴素但很稳定。Ruoyi 的定时任务模块在管理后台直接配置任务调度方法名填ryTask.scanAttendanceTaskStatus()Cron 表达式填0 0/1 * * * ?一分钟扫一次就够了不需要更频繁。3. 前端uniapp 多端适配的实践要点3.1 签到页面的交互设计与统一状态管理uniapp 端我用 Vue 语法 Vuex 做状态管理登录态保持是第一个要解决的问题。Ruoyi 后端登录成功后返回 token移动端拿到 token 后存入 uni.setStorageSync然后在请求拦截器里把 token 放到请求头 Authorization 字段。签到页面的结构我做的比较克制没有堆复杂的视觉组件重点是把当前考勤任务的状态和学生的签到结果展示清楚。页面加载时先请求“当前有效考勤任务”接口如果有进行中的任务就显示签到按钮如果已经签过到就显示“已签到”的状态和签到时间如果当前时间不在任何任务的时间窗口内就显示“当前没有进行中的考勤任务”。这个设计有个细节容易被忽略就是考勤任务可能同时有多个比如一个宿舍楼同时发起口头点名和普通签到。我在接口层做了约束一个学生在同一个时间点只处理优先级的任务如果有口头点名任务进行中先处理口头点名否则处理位置签到最后才是普通签到。前端拿到任务列表后也按这个优先级排序展示。3.2 定位能力的接入与经纬度获取位置签到的前置条件是获取学生的实时定位。uniapp 中获取定位使用的是uni.getLocation这个 API。这里有一个非常关键的坑就是坐标系问题。小程序的uni.getLocation返回的是 GCJ-02 坐标系国测局加密坐标而 App 端如果使用原生定位返回的可能是 WGS-84 坐标系。直接拿两种不同坐标系的经纬度去后端计算距离很可能会出现几十米甚至上百米的偏移。我的处理方式是在前端统一将定位结果转换为 GCJ-02小程序端默认就是 GCJ-02App 端做一次坐标转换然后传给后端后端的签到点坐标也按 GCJ-02 存储两边坐标系一致距离计算才准确。下面是我在 App 端做坐标转换时封装的一段工具方法function wgs84ToGcj02(latitude, longitude) { const a 6378245.0; const ee 0.006693421622965943; const dLat transformLat(longitude - 105.0, latitude - 35.0); const dLon transformLon(longitude - 105.0, latitude - 35.0); const radLat latitude / 180.0 * Math.PI; let magic Math.sin(radLat); magic 1 - ee * magic * magic; const sqrtMagic Math.sqrt(magic); const lat dLat * 180.0 / ((a * (1 - ee)) / (magic * sqrtMagic) * Math.PI); const lon dLon * 180.0 / (a / sqrtMagic * Math.cos(radLat) * Math.PI); return { latitude: latitude lat, longitude: longitude lon }; }这段代码我用在自定义 App 定位逻辑中如果直接用 uni.getLocation在 App 端的部分机型上需要留意返回坐标系的差异可以在 manifest.json 中配置定位 SDK 的坐标系参数尽量统一输出 GCJ-02。3.3 位置签到的前端判断与用户体验优化虽然位置是否有效最终由后端判定但前端不能完全等接口返回后再提示用户“不在范围内”这样体验太差了。我的做法是在前端先做一次预判获取定位后调用一个轻量级接口或直接在本地计算距离如果明显超出范围比如超过有效半径的三倍直接提示“当前位置距离签到点过远请确认已在宿舍楼附近”不再提交签到请求。这种前置过滤能有效减少无效请求也给学生即时反馈。另一个体验细节是定位的加载时间和失败兜底。定位是比较耗时的操作尤其在宿舍楼内 GPS 信号弱的时候可能要好几秒。我在页面上加了加载遮罩和“重新定位”按钮。如果定位失败会提示用户开启定位权限并提供手动选择地址的能力当然手动选地址需要慎重它绕过了位置校验只在极少数确实无法定位的场景下开放并且后台会记录标记为“手动定位”的记录方便事后核查。4. 实操过程核心代码与关键配置4.1 后端签到接口的具体实现为了让大家有直接可参考的成果我把普通签到和位置签到的核心代码贴出来Override public AjaxResult sign(SignRequest request) { AttendanceTask task attendanceTaskMapper.selectById(request.getTaskId()); if (task null || task.getStatus() ! 1) { return AjaxResult.error(当前考勤任务不存在或已结束); } Date now new Date(); if (now.before(task.getStartTime()) || now.after(task.getEndTime())) { return AjaxResult.error(不在考勤签到时间内); } boolean valid true; double distance 0; if (3.equals(task.getAttendanceType())) { distance calculateDistance( task.getSignLatitude(), task.getSignLongitude(), request.getLatitude(), request.getLongitude()); valid distance task.getValidRadius(); } if (!valid) { return AjaxResult.error(当前位置不在考勤范围内, Map.of(distance, Math.round(distance))); } try { AttendanceRecord record new AttendanceRecord(); record.setTaskId(task.getId()); record.setStudentId(request.getStudentId()); record.setSignType(task.getAttendanceType()); record.setSignTime(now); record.setSignLatitude(request.getLatitude()); record.setSignLongitude(request.getLongitude()); record.setDistance(new BigDecimal(distance)); record.setStatus(1); attendanceRecordMapper.insert(record); } catch (DuplicateKeyException e) { return AjaxResult.error(您已完成本次签到请勿重复提交); } return AjaxResult.success(签到成功); }这里有个业务层面的取舍要说明不管普通签到还是位置签到我在第一次插入时就通过了唯一索引如果学生打卡失败再重新签第二次插入会捕获到重复键异常。网上很多项目用“先查后插”的方式去重但并发情况下会出问题直接用唯一索引做幂等才是最可靠的。4.2 uniapp 前端签到核心逻辑uniapp 端的核心签到逻辑我封装在了签到页面的一个方法里。普通签到就是直接调接口位置签到需要先获取定位再调接口。下面这是位置签到的完整代码逻辑async handleLocationSign() { if (this.signing) return; this.signing true; try { const location await this.getCurrentLocation(); const res await this.$http.post(/api/attendance/sign, { taskId: this.currentTask.id, studentId: this.studentInfo.id, latitude: location.latitude, longitude: location.longitude }); if (res.code 200) { this.signed true; this.signTime this.formatTime(res.data.signTime); uni.showToast({ title: 签到成功, icon: success }); } else { uni.showToast({ title: res.msg, icon: none }); } } catch (e) { uni.showToast({ title: 定位失败或请求异常, icon: none }); } finally { this.signing false; } }, getCurrentLocation() { return new Promise((resolve, reject) { uni.getLocation({ type: gcj02, success: (res) resolve({ latitude: res.latitude, longitude: res.longitude }), fail: (err) reject(err) }); }); }有一个参数需要重点注意uni.getLocation中的type字段设为gcj02在小程序端是必要的如果不设置默认返回的是 WGS-84 坐标后端距离算出来会偏。App 端如果需要更高的定位精度建议在 manifest.json 的 App SDK 配置中集成高德或腾讯定位 SDK而不是依赖系统自带的定位因为系统定位在室内或城市峡谷环境下的精度很不稳定。4.3 项目部署与联调注意事项部署这套前后端分离项目需要准备的组件有MySQL 8.0、RedisRuoyi 的登录缓存和权限缓存依赖 Redis、Nginx部署后端接口和前端静态资源、以及一个打包后的 uniapp 前端产物。我在联调阶段遇到的最大问题是跨域和请求地址配置。uniapp 开发环境跑在 HBuilderX 内置浏览器或者微信开发者工具中请求后端接口会触发跨域。我在后端 Ruoyi 的配置里放行了特定的请求来源同时在前端统一封装了请求基地址。上线后把这些地址换成 HTTPS 域名避免明文传输登录凭证。还有一个小坑是 token 过期的问题。Ruoyi 默认 token 有效期是 30 分钟学生可能在签到页面停留较久token 过期后签到接口返回 401。我在前端的请求拦截器里增加了 401 统一处理跳转到登录页重新登录同时尽量在签到页面主动刷新 token 状态避免学生签到时突然被踢出去重登。5. 常见问题与排查技巧实录5.1 位置签到“定位不准”的排查思路位置签到上线后最容易被学生吐槽的就是“我明明在宿舍却提示我不在范围内”。遇到这种情况不要急着改代码按下面的顺序排查。先确认签到点坐标是否设置正确。有些宿管在管理后台设置签到点时是随便在地图上点了一个位置但那个点并不是宿舍楼的实际入口。这个解决方法是在设置签到点时用地图组件选择位置同时填一个合理的半径宿舍楼通常 100 到 150 米就够了太大会把隔壁楼的学生也算进来。然后检查学生手机是不是开了“省电模式”或者“位置权限未开启”。安卓机在省电模式下系统会对 GPS 定位做降频处理拿到的坐标精度可能偏差到几百米。前端要增加定位精度校验如果accuracy大于 100 米提示用户重新定位而不是拿到一个不准确的坐标就直接提交。最后是坐标系问题如果前后端坐标系不一致差个一两百米很正常。用 WGS-84 坐标算出来明明在范围内的学生在 GCJ-02 坐标系下可能就超了所以两端必须统一。5.2 同一个学生重复签到的兜底处理虽然加了唯一索引但我在实际测试中发现还是存在“表面上的重复签到”。原因是宿管口头点名和学生在手机上自助签到可能同时发生两条记录虽然都有相同 taskId 和 studentId但签到方式不同插入时会被唯一索引拦截掉一条。这个设计在数据上是正确的但业务上有个问题如果宿管已经口头点名标记学生“已到”学生又在手机端签了一次系统返回了“请勿重复提交”学生就会困惑质疑为什么自己签不了。我的处理方式是在唯一索引拦截到冲突时查询已有记录的签类型和操作人如果已有记录是宿管代为签到的就向前端返回一个特殊提示“宿管已为你完成本次考勤签到”。这样既保证了数据的唯一性又给了学生明确的引导。5.3 uniapp 打包发布的实际坑uniapp 项目开发完成后打包发布到微信小程序和安卓 App 时还有几个坑值得单独说一说。微信小程序的域名白名单是个经典问题。开发工具里不校验合法域名可以随意请求但上线后所有请求的域名必须在小程序后台配置为 HTTPS 白名单。这个流程要在项目提测前就走完不然等审核时才发现可能来不及。安卓 App 打包时如果使用了定位功能需要在 manifest.json 中声明定位权限。HBuilderX 的 App 打包向导里需要在“模块配置”里勾选定位服务否则打到真机上uni.getLocation直接 fail。另一个容易被忽略的是 Android 10 及以上版本的定位权限需要在代码中动态申请uniapp 的 API 在部分机型上不会自动弹出权限框需要在页面里先调用看一下。我用 HBuilderX 云打包时还踩过一个问题打包后的 App 定位一直不成功后来发现是打包时没有选择定位 SDK 提供商默认的定位模块在部分机型上不支持混合定位。换了高德定位 SDK 之后室内外的定位表现都明显改善。如果你们选用这个方案记得去对应厂商的控制台申请 key并在 manifest 里配置好。5.4 考勤数据统计与导出的效率问题考勤记录一多管理后台的统计报表可能会变慢。这里的优化方向和很多报表场景一致不要在业务表中直接做复杂聚合查询而是用定时任务把每天的考勤结果汇总到统计表管理后台查询时直接读统计表。我在统计表里按照“宿舍楼 日期 考勤类型”维度存了应到人数、实到人数、缺勤人数和签到率。定时任务每天凌晨执行一次扫描前一天的考勤数据做汇总这样宿管早上打开管理后台时看到的统计数据已经是更新好的。实施这个小改动后导出报表的速度从几秒降到毫秒级体验提升非常明显。提示规模较小的场景比如一个宿舍楼几十人不需要过度设计直接实时从考勤记录表 group by 也可以但如果你打算把这套系统复制到多栋宿舍楼甚至整个校区统计表的方案更值得投入。写在最后的实际操作体会这个系统从最初的原型到最终稳定运行中间迭代了很多次有三点体会想重点分享。第一做管理系统业务逻辑永远比技术重要。三种签到方式如果一开始没有梳理清楚差异后面接口设计、表结构设计都会很别扭。口头点名是“他签到”普通签到是“自签到”位置签到是“带条件的自签到”明确了这三者的差异整个系统的结构就清晰了。第二Ruoyi 这类脚手架的价值在于快速建立工程体系而不是限制你的思路。它的代码生成器帮我省了至少一周的 CRUD 时间但考勤的核心逻辑完全是自己写的包括距离计算、状态流转、并发去重。如果项目迭代周期长建议把考勤模块独立成单独的服务或包这样不会把核心业务和通用后台代码搅在一起。第三uniapp 跨端开发虽然方便但多端的一致性测试不能省。小程序和 App 在定位行为、权限弹窗、存储 API 上的表现都存在差异上线前至少要在这两个端上完整跑一遍签到流程。我每次发布前都会做一张“端到端测试清单”把登录、拉取任务、普通签到、位置签到、重复签到、查看记录这几个用例挨个过一遍这个习惯帮我发现了不少只在某一个端复现的问题。最后再分享一个小技巧位置签到上线初期最好在后台把每条签到记录的经纬度、距离、坐标精度都保留下来方便学生投诉时排查。这套系统跑了一段时间后我遇到过几次学生说“自己在宿舍却被判缺勤”的情况就是因为前端定位精度不高误判为不在范围内。后来我在这里增加了定位精度参数的保存并在地图上展示签到点与考勤点的距离把证据摊在桌面上这类纠纷就大大减少了。本文还有配套的精品资源点击获取