简介:一套基于若依(Ruoyi)框架与uniapp技术的前后端分离学生宿舍考勤管理系统源码包,面向需要毕业设计参考、课程实训或校园信息化开发学习的前后端技术人员。系统兼顾多种签到场景:口头点名、普通签到、自定义范围位置签到、二维码签到、人脸识别签到、九宫格手势签到和签到码签到,并接入阿里云人脸识别与OSS存储,同时支持微信小程序密码登录与微信授权登录,配套可视化数据看板,可直观查看出勤统计与异常记录。压缩包共包含82个文件,以Java与class源码、XML/yml配置、PNG/JPG界面截图和说明文档为主,整体大小约1.77MB,目录结构便于按模块查阅。目前已有176人学习下载。通过阅读源码、配置及截图,可以掌握Ruoyi权限管理、uniapp跨端开发、位置范围校验、阿里云人脸识别对接、OSS文件存储以及数据图表展示等关键思路,适合直接导入开发工具梳理工程脉络,也能作为宿舍考勤类项目二次开发的参考基础。
1. 宿舍考勤不是打卡:三种签到方式背后是三种管理粒度
做过宿舍考勤的人都知道,最难的不是写代码,而是回答宿管老师一句话:“我要的点名,和你做的签到,不是一回事。”学生宿舍考勤和公司打卡最大的区别在于:打卡只需要证明“人来了”,宿舍考勤要证明“人确实在宿舍”,而且不同场景下“在宿舍”的证据强度完全不一样。基于 Ruoyi 和 uniapp 做前后端分离的学生宿舍考勤管理系统,核心就是把口头点名签到、普通签到、位置签到这三种方式揉进同一套业务模型里,让宿管老师按场景去选,而不是替系统做决定。本文写给准备动手做这套系统的开发者,从表设计、后端校验到 uniapp 端的定位签到,把完整的落地路径和踩过的坑一次说清。
2. Ruoyi 后端与 uniapp 前端的分离架构:表设计和接口边界
2.1 前后端分离的职责划分
用 Ruoyi 做后端、uniapp 做学生端,这是目前比较顺手的组合。Ruoyi 的前后端分离版自带 Spring Boot + MyBatis 的后端服务、Vue 的管理后台、Shiro 或 Sa-Token 的权限体系、代码生成器和一套现成的用户/角色/菜单模型。你要做的不是从零搭框架,而是在这套体系里长出“宿舍考勤”这个业务模块。uniapp 负责的是学生手机端:签到页面、记录查询、消息提醒,以及宿管老师手持端的口头点名操作界面。
职责边界我一般这样切:Ruoyi 后端管所有业务规则——时间窗口、位置半径、重复签到校验、统计报表、导出;uniapp 只做两件事——展示规则和采集证据。采集证据包括用户点了哪个按钮、当前定位坐标、口头确认的学生编号。为什么强调这个边界?因为签到系统的信任模型天然要求“规则在服务端、证据在客户端”。如果你把时间判断、防重复逻辑写在前端,学生改个手机时间、清个缓存、抓个包就能绕过,后面统计和导出全乱。接口层面全部走 JSON,统一返回{ code, msg, data },uniapp 端封装一个request.js统一处理 token 和错误码,这套在 Ruoyi 里已经有现成约定,直接沿用即可。
2.2 考勤表设计:把三种签到落进同一套表结构
表设计是这套系统最值得花时间的部分。很多初学者会为三种签到方式各建一张表,这是典型的给自己挖坑——后面统计出勤率要 UNION 三张表,口径稍微不对就对不上。正确的做法是一张attendance_record表,用sign_type字段区分方式,用状态字段记录结果。先看核心建表 SQL:
-- 宿舍楼 CREATE TABLE dorm_building ( building_id BIGINT PRIMARY KEY AUTO_INCREMENT, building_name VARCHAR(64) NOT NULL COMMENT '楼栋名称', latitude DECIMAL(10, 6) NULL COMMENT '楼栋中心纬度', longitude DECIMAL(10, 6) NULL COMMENT '楼栋中心经度', radius INT NULL COMMENT '默认签到半径(米)', manager_id BIGINT NULL COMMENT '宿管老师用户ID', status CHAR(1) DEFAULT '0' COMMENT '0正常 1停用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT '宿舍楼栋表'; -- 宿舍房间 CREATE TABLE dorm_room ( room_id BIGINT PRIMARY KEY AUTO_INCREMENT, building_id BIGINT NOT NULL COMMENT '所属楼栋', room_no VARCHAR(16) NOT NULL COMMENT '房间号', floor INT NULL COMMENT '楼层', capacity INT DEFAULT 4 COMMENT '可住人数', UNIQUE KEY uk_building_room (building_id, room_no) ) COMMENT '宿舍房间表'; -- 住宿学生 CREATE TABLE dorm_student ( student_id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT '关联系统用户表', student_no VARCHAR(32) NOT NULL COMMENT '学号', room_id BIGINT NOT NULL COMMENT '宿舍房间', real_name VARCHAR(32) NOT NULL COMMENT '姓名', phone VARCHAR(16) NULL COMMENT '手机号', status CHAR(1) DEFAULT '0' COMMENT '0在住 1退宿', UNIQUE KEY uk_user (user_id) ) COMMENT '住宿学生表'; -- 考勤规则 CREATE TABLE attendance_rule ( rule_id BIGINT PRIMARY KEY AUTO_INCREMENT, rule_name VARCHAR(64) NOT NULL COMMENT '规则名称', building_id BIGINT NULL COMMENT '楼栋ID,空表示全校通用', room_id BIGINT NULL COMMENT '房间ID,空表示楼栋级', sign_type TINYINT NOT NULL COMMENT '1口头点名 2普通签到 3位置签到', start_time TIME NOT NULL COMMENT '签到开始时间', end_time TIME NOT NULL COMMENT '签到结束时间', latitude DECIMAL(10, 6) NULL COMMENT '有效位置纬度(位置签到时用)', longitude DECIMAL(10, 6) NULL COMMENT '有效位置经度', radius INT DEFAULT 100 COMMENT '有效半径米', status CHAR(1) DEFAULT '0' COMMENT '0启用 1停用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT '考勤规则表'; -- 考勤记录 CREATE TABLE attendance_record ( record_id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL COMMENT '学生ID', room_id BIGINT NOT NULL COMMENT '宿舍房间', rule_id BIGINT NULL COMMENT '使用的规则ID', sign_type TINYINT NOT NULL COMMENT '1口头 2普通 3位置', sign_date DATE NOT NULL COMMENT '签到日期', sign_time DATETIME NOT NULL COMMENT '签到时间', latitude DECIMAL(10, 6) NULL COMMENT '上报纬度', longitude DECIMAL(10, 6) NULL COMMENT '上报经度', address VARCHAR(255) NULL COMMENT '解析出的地址文本', source CHAR(1) DEFAULT '0' COMMENT '0学生端 1宿管端代录', remark VARCHAR(255) NULL COMMENT '备注(口头点名填老师备注)', create_by VARCHAR(64) NULL COMMENT '登记人', UNIQUE KEY uk_student_date (student_id, sign_date) ) COMMENT '考勤签到记录表';表设计有三个关键决策。第一,attendance_record用student_id + sign_date做唯一索引,这是防重复签到的最后一道物理屏障,后面接口层再防一遍,双保险。第二,attendance_rule里sign_type是单值字段,说明一条规则只对应一种签到方式,不要试图做“同时支持普通和位置”的混合规则,统计口径会被你搞死。第三,经纬度字段用DECIMAL(10, 6),精度约 0.1 米,足够宿舍楼场景使用,别用FLOAT,也别用VARCHAR存坐标字符串,后面做距离计算要转来转去。请假和外出登记可以单独建attendance_leave表,和考勤记录通过student_id + sign_date关联,统计时先排除请假学生,这块后面再细说。
2.3 给 uniapp 端暴露的 6 个核心接口
前后端分离的项目,接口契约要先定清楚,不然前端等到后端全部写完再联调,周期会拖很长。我一般先列接口清单,定义好入参出参,两边并行开发。这套系统最少需要 6 个接口:
| 接口 | 方法 | 入参 | 出参 | 说明 |
|---|---|---|---|---|
/attendance/rule/current | GET | roomId | 当前生效的规则及签到状态 | 学生进页面先拉规则 |
/attendance/record/submit | POST | studentId、roomId、signType、lat、lng、address | 签到结果 | 三种签到方式统一入口 |
/attendance/record/myToday | GET | studentId | 今日签到记录、迟到/未归状态 | 学生查看今日状态 |
/attendance/record/month | GET | studentId、month | 月度签到日历数据 | 按月展示 |
/attendance/record/voiceConfirm | POST | studentIds、roomId、remark | 批量确认结果 | 宿管口头点名代录 |
/attendance/statistics/building | GET | buildingId、date | 楼栋出勤、迟到、未归统计 | 宿管看板 |
需要特别注意的是第三个和第六个。/attendance/record/submit是唯一的写入口,无论是学生普通签到还是宿管口头代录,都走这一个接口,用参数区分。/attendance/record/voiceConfirm看名字是单独接口,实际内部也是循环调用submit,只是把source标记为宿管端、sign_type标记为口头。这样设计的好处是统计层只需要查attendance_record一张表,过滤条件也只有sign_date和sign_type,不会有口径分叉。token 校验走 Ruoyi 的拦截器,uniapp 端登录后把Authorization头带上即可。
3. 用 Ruoyi 代码生成器把考勤接口落地:建表到签到校验
3.1 代码生成器的使用路径与配置要点
Ruoyi 的代码生成器能省掉大量重复的 CRUD 代码,但很多人用不好,原因是对它的预期不对。它不是业务引擎,它只负责生成“表对应的基础增删改查 + 前端页面”,复杂的业务校验必须自己改写。我通常的做法是:先把 2.2 节里的表全部建好,然后登录 Ruoyi 管理后台,进入“系统工具 → 代码生成”,点击“导入”把表导入进来,逐表编辑配置。
配置阶段有四个地方必须改。一是“生成模板”选单表,别选树表,考勤表没有树形结构。二是“字段信息”里把create_by、create_time这类字段的插入和编辑勾选去掉,交给 MyBatis 自动填充。三是latitude、longitude的表单类型改成数字框,默认是文本框,校验规则也要去掉必填,因为口头签到和普通签到不上传坐标。四是每张表的“业务名称”填成中文,比如attendance_record的业务名称填“考勤记录”,生成的前端菜单和注释会舒服很多。配置完成后点击“生成代码”,会下载一个 zip,里面是 controller、service、mapper、xml 和 Vue 页面文件,按 Ruoyi 文档的路径覆盖到工程里即可。
这里我要强调一个血泪经验:生成的代码先别急着跑,要把生成的文件覆盖到工程后,重启服务,然后逐个功能在管理后台点一遍。很多“翻车”发生在 mapper XML 里,因为表名和字段名是驼峰还是下划线由mybatis配置决定,Ruoyi 默认开了驼峰映射,但你如果手工微调过表结构,生成的文件不会跟着自动变。跑通基础 CRUD 之后,再开始改业务逻辑。
3.2 改写签到接口:时间窗、防重复与位置半径校验
代码生成器给的是通用接口,签到系统真正的核心在校验逻辑。我一般把签到入口收敛成一个 service 方法,三段校验:时间窗校验、防重复校验、位置校验。看核心代码:
@Override public AjaxResult submitSign(SignRequest req) { // 1. 查出当前对该学生生效的规则 AttendanceRule rule = attendanceRuleMapper.selectActiveRule( req.getRoomId(), req.getSignType(), LocalDate.now()); if (rule == null) { return AjaxResult.error("当前没有生效的签到规则"); } // 2. 时间窗校验:一律以服务器时间为准 LocalTime now = LocalTime.now(); if (now.isBefore(rule.getStartTime()) || now.isAfter(rule.getEndTime())) { return AjaxResult.error("不在签到时间范围内"); } // 3. 防重复校验:数据库唯一索引是最后屏障 AttendanceRecord existing = attendanceRecordMapper .selectByStudentAndDate(req.getStudentId(), LocalDate.now()); if (existing != null) { return AjaxResult.error("今日已签到,请勿重复提交"); } // 4. 位置校验:只有 sign_type = 3 才做 if (rule.getSignType() == 3) { if (req.getLatitude() == null || req.getLongitude() == null) { return AjaxResult.error("未获取到定位信息"); } double distance = GeoUtil.distance( req.getLatitude(), req.getLongitude(), rule.getLatitude(), rule.getLongitude()); if (distance > rule.getRadius()) { return AjaxResult.error("当前位置超出签到范围 " + String.format("%.0f", distance) + " 米"); } // 距离在半径 80% 以内正常通过,80%-100% 之间记录告警 req.setRemark(distance > rule.getRadius() * 0.8 ? "边缘签到" : req.getRemark()); } // 5. 组装记录落库 AttendanceRecord record = new AttendanceRecord(); record.setStudentId(req.getStudentId()); record.setRoomId(req.getRoomId()); record.setRuleId(rule.getRuleId()); record.setSignType(rule.getSignType()); record.setSignDate(LocalDate.now()); record.setSignTime(LocalDateTime.now()); record.setLatitude(req.getLatitude()); record.setLongitude(req.getLongitude()); record.setAddress(req.getAddress()); record.setSource(req.getSource() == null ? "0" : req.getSource()); record.setRemark(req.getRemark()); attendanceRecordMapper.insert(record); return AjaxResult.success("签到成功"); }逻辑说明:第一步根据房间和签到方式查出当前生效规则,selectActiveRule的排序规则要考虑优先级——先精确匹配房间级规则,没有再匹配楼栋级,最后匹配全校通用规则,SQL 里用ORDER BY加限定条件实现。第二步时间窗,注意LocalTime.now()取的是服务器时间,不是客户端传上来的时间。第四步的位置校验是整个接口的灵魂,坐标为空直接拒绝,距离超过半径直接拒绝,边缘签到不拒绝但打上标记,这个标记在月底统计“疑似代签”时非常有用。
参数说明:SignRequest是前端传参的对象,包含studentId、roomId、signType、latitude、longitude、address、source、remark八个字段。GeoUtil.distance是工具类,内部实现用 Haversine 公式计算两个经纬度点之间的球面距离,精度在几十米范围内足够。radius 的默认值我建议设 100 米,宿舍楼场景下 50 米太紧,GPS 漂移会让你天天处理投诉,后面章节专门讲这个坑。
3.3 三种签到方式的适用场景与选择判断
这一节不讲代码,讲业务判断。口头点名签到适合低年级本科生,宿管老师晚上查寝时挨个宿舍走一圈,口头确认学生在不在,然后在手持端勾选名单批量提交。普通签到适合研究生、走读生这类不需要核实物理位置的人群,规则定一个时间窗(比如 22:00 到 23:00),学生打开应用点一下即可,老师看统计结果。位置签到适合需要确认“人在楼内”的场景,借助宿舍楼中心的经纬度和半径圈定范围,学生到楼内才能签上。
三种方式的选择原则是“证据强度和管理成本成正比”。口头点名证据最强但宿管成本最高,位置签到证据中等但成本低,普通签到证据弱但最省事。实际部署时同一个楼栋可以早晚用不同规则:晚查寝用口头点名,白天签到用位置签到。attendance_rule表里start_time和end_time字段决定了规则的时间生效范围,同一天可以配多条记录,查询时用“当前时间落在区间内”作为过滤条件。这个设计在 Ruoyi 的管理后台配置时要注意:规则的生效和停用不要只用status字段控制,要结合时间区间去判断,不然到点忘了切状态,考勤就废了。
4. uniapp 端三套签到流程的实现与定位参数设置
4.1 页面结构与签到入口设计
uniapp 这边的页面结构我按角色拆成两个入口:学生端和宿管端,同一套代码用角色字段区分。学生端的核心页面是pages/sign/index.vue—— 进入后先请求/attendance/rule/current,拿到今天适用的规则类型,然后根据类型动态渲染签到区域:口头点名时显示“等待宿管确认”状态;普通签到显示一个大的“签到”按钮;位置签到显示当前定位距离和签到按钮。宿管端的核心页面是pages/manager/voice.vue—— 按楼栋拉取学生列表,逐个口头确认后批量提交。
页面的状态设计有一个容易忽略的点:签到页要处理“规则未配置”“不在时间窗”“已签到”三种前置状态。很多人只做了“已签到”判断,结果规则没配的时候学生点签到按钮,后端返回“当前没有生效的规则”,体验很差。正确做法是进入页面先拉规则和今日签到状态,用一个pageStatus字段区分四种状态:no_rule、out_of_time、already_signed、ready,前三种直接渲染对应文案和图标,只有ready才展示签到按钮。这样后端接口的报错压力也小很多。
4.2 普通签到和口头点名的前端实现
普通签到的前端逻辑很直白:页面加载时请求今日状态,如果状态是ready,渲染一个带倒计时的按钮,用户点击后调用/attendance/record/submit。这里的关键是倒计时结束后要主动重新拉取状态,而不是只把按钮置灰——因为时间窗过了以后,后端会拒绝请求,前端要提前响应这个变化。看一个简化的提交逻辑:
// pages/sign/index.vue import request from '@/utils/request' export default { data() { return { rule: null, pageStatus: 'loading', // no_rule / out_of_time / already_signed / ready submitting: false, location: null } }, onLoad() { this.loadPageData() }, async loadPageData() { // 并行拉规则和今日状态,减少一次请求往返 const [ruleRes, statusRes] = await Promise.all([ request.get('/attendance/rule/current', { roomId: this.roomId }), request.get('/attendance/record/myToday', { studentId: this.studentId }) ]) this.rule = ruleRes.data if (!this.rule || this.rule.status !== '0') { this.pageStatus = 'no_rule' } else if (statusRes.data.signed) { this.pageStatus = 'already_signed' } else { this.pageStatus = 'ready' } }, async submitSign() { if (this.submitting) return this.submitting = true try { const payload = { studentId: this.studentId, roomId: this.roomId, signType: this.rule.signType, latitude: null, longitude: null, address: '' } // 位置签到类型才需要获取定位 if (this.rule.signType === 3) { const loc = await this.getLocation() payload.latitude = loc.latitude payload.longitude = loc.longitude payload.address = loc.address || '' } const res = await request.post('/attendance/record/submit', payload) if (res.code === 200) { uni.showToast({ title: '签到成功', icon: 'success' }) this.pageStatus = 'already_signed' } else { uni.showToast({ title: res.msg, icon: 'none' }) } } finally { this.submitting = false } } }逻辑说明:submitSign里先判断规则类型,只有signType === 3才调用getLocation(),普通签到不请求定位权限——这点很重要,因为 iOS 的定位权限弹窗一次误点“拒绝”后,要到设置里手动开启,很多学生不会操作,直接导致签到失败。submitting标志位防止重复点击,同时按钮上绑定了:disabled="submitting"属性,前后端双层防重复。
口头点名的前端实现不一样,不是学生主动点,而是宿管老师操作。页面从楼栋列表进入房间列表,再到学生列表,每个学生后面两个按钮“在”“不在”,点击后把状态暂存在本地数组里,最后统一提交到/attendance/record/voiceConfirm。这里有个体验细节:如果某个学生已经签过到再点“在”,后端会返回“已签到”,这时候前端要从暂存列表里移除该学生,不然批量提交时报错会中断整个流程。口头点名适合大屏平板操作,uniapp 编译到 iPad 或 Android 平板上效果会比手机好很多。
4.3 位置签到:gcj02 定位、半径参数与权限配置
位置签到是三种方式里技术坑最多的,主要集中在坐标系和定位精度两个地方。uniapp 的uni.getLocation在 App 端默认返回 gcj02 坐标(国测局坐标),在小程序端也是返回 gcj02,但安卓 WebView 里偶尔会返回 wgs84。后端如果拿高德地图 API 解析地址或者做距离计算,必须统一用 gcj02,否则会有 200 到 500 米的偏差,直接导致“人在宿舍却提示超出范围”。看定位代码:
// utils/location.js export function getLocation() { return new Promise((resolve, reject) => { uni.getLocation({ type: 'gcj02', // 强制使用国测局坐标系 isHighAccuracy: true, highAccuracyExpireTime: 3000, success: (res) => { resolve({ latitude: res.latitude, longitude: res.longitude, accuracy: res.accuracy || 0 }) }, fail: (err) => { // 常见错误码:1 权限拒绝,2 定位失败,3 定位超时 reject(err) } }) }) }参数说明:type: 'gcj02'必须写死,这是和后端约定的坐标系,谁都不能改。isHighAccuracy: true在 Android 上会调起高精度模式,耗电增加但定位快很多,宿舍签到场景可以接受。accuracy字段返回精度范围,单位米,如果精度大于 100 米说明定位质量很差,前端应该提示用户移动到窗边再试,而不是盲目提交。
权限配置是另一个大头。小程序端要在manifest.json里声明requiredPrivateInfos: ["getLocation"],以及permission字段里的scope.userLocation说明文案;App 端要在App.vue的onLaunch里申请定位权限,或者调用uni.authorize主动引导。审核规范里要求定位用途说明必须写清楚,不能只写“签到”,要写“用于宿舍考勤签到确认学生位置”,否则小程序提审会被驳回。
还有一个经常翻车的地方:签到成功之后要把定位信息缓存下来,而不是签到失败后再重新定位。因为宿舍楼内 GPS 信号弱,学生第一次定位可能花了 10 秒,如果因为网络超时提交失败,重新走一遍定位流程会让学生烦躁。常见做法是把最近一次成功的定位坐标存在uni.setStorageSync('last_location'),提交失败重试时优先使用缓存坐标,然后后台静默刷新定位,等下次进入页面时再更新。
5. 签到开发避坑:定位漂移、重复提交与时间窗口
5.1 定位漂移:学生在宿舍里却提示“超出范围”
现象:学生反馈“我明明坐在床上,签到却提示超出范围 120 米”,位置签到成功率不足 70%。
原因:GPS 在室内基本失效,手机走的是基站和 Wi-Fi 定位,误差在 50 到 200 米之间波动;再加上宿舍楼本身有建筑偏移,如果当初采集楼栋中心坐标时用的是地图 App 随手点的位置,误差会进一步放大。
解决:分两步走。第一步,把attendance_rule里的radius从默认 50 米放宽到 100 米,这是最低成本止血方案,但治标不治本;第二步,找一个晴好天气,在楼栋门口、宿舍窗户边分别采集 5 个坐标点,取平均值作为楼栋中心坐标,用测试账号遍历楼层签到验证,确保楼上楼下都能通过。后端把边缘签到的距离告警日志打开,观察一周,如果大量记录落在 80 到 100 米区间,说明楼栋中心坐标偏了,需要重新校准,这就不是学生的手机问题了。
5.2 弱网重试导致重复签到
现象:学生在楼道里点签到,网络超时,前端自动重试了两次,后台出现两条签到记录,虽然前端展示还是“已签到”,但统计接口的数据翻倍了。
原因:前端请求超时后自动重试,而后端接口没有幂等控制,两次请求都通过了校验——因为第一次请求虽然写了库,但响应还没回到前端,第二次校验“今日是否已签到”时数据库里已经有了,却被并发绕过。
解决:双保险。后端先加唯一索引兜底,也就是 2.2 节里的UNIQUE KEY uk_student_date (student_id, sign_date),这样即使校验漏了,数据库也会抛异常拒绝第二条;同时前端把签到按钮改成submitting状态,点击后立即禁用,不依赖接口响应,只有接口明确返回失败才恢复按钮。注意唯一索引的异常要捕获并转成友好提示“今日已签到”,不能直接抛 500。
5.3 手机时间被篡改,绕过时间窗
现象:有学生把手机时间改到晚上 10 点整,在下午 3 点就完成了晚签到,后台记录的时间是下午 3 点,和规则完全不符。
原因:前端把“当前时间”作为签到依据显示倒计时,后端校验用的却是客户端传上来的时间字段。一些开发者在做接口时图省事,让前端把signTime一起传上来,这就留了个大后门。
解决:后端一律不信任客户端时间。签到接口的signTime字段在后端用LocalDateTime.now()生成,前端传的任何时间字段都忽略。倒计时只用于 UI 展示,不参与业务判断。另外在管理后台加一个“签到时间异常”的筛选条件,查询sign_time和sign_date发生在不同自然日的记录,定期导出检查,防止有人利用跨时区漏洞。
5.4 统计口径不一致:迟到、未归、请假边界模糊
现象:宿管老师看到楼栋出勤率是 92%,但辅导员导出名单后说“怎么少了几个人”,两边数据对不上。
原因:出勤率的分子分母定义不统一。有人把“请假”算作出勤,有人不算;有人把“迟到”算作未签到,有人算签到;还有人把退宿学生算进分母,导致统计值看起来很离谱。
解决:统计口径必须在 service 层定义一次,所有接口调用同一个统计方法。我这里的口径是:分母 = 在住状态且未请假的学生数;分子 = 分母中当天有签到记录的人数;迟到 = 签到时间晚于规则end_time但仍在当天 23:59 之前;未归 = 当天无签到记录且无请假记录。这三个规则写进一个枚举类或者配置类里,前端展示、Excel 导出、定时报表全走这个口径,不许各写各的。开发时建议先在测试环境用模拟数据对一遍,把“迟到算不算出勤”这类问题在开发阶段就吵完,不要上线后让宿管老师背锅。
6. 把考勤数据用起来:导出、统计与异常预警的进阶做法
签到做通以后,这套系统真正产生价值的是数据层。我建议优先做三件事:Excel 导出、楼栋出勤看板、未归预警。Excel 导出直接复用 Ruoyi 的ExcelUtil,导出前把统计口径统一成上一章定义的规则,避免二次加工:
@PostMapping("/export") public void export(HttpServletResponse response, @RequestBody ExportReq req) { List<AttendanceRecordVO> list = attendanceService.getExportList(req); ExcelUtil<AttendanceRecordVO> util = new ExcelUtil<>(AttendanceRecordVO.class); util.exportExcel(response, list, "宿舍考勤记录"); }楼栋出勤看板用一条 SQL 就能出核心数据,按楼栋分组统计应到、实到、迟到、未归:
SELECT r.building_id, COUNT(DISTINCT s.student_id) AS total_students, COUNT(DISTINCT CASE WHEN rec.id IS NOT NULL THEN s.student_id END) AS signed_count, COUNT(DISTINCT CASE WHEN rec.sign_time > r2.end_time THEN s.student_id END) AS late_count FROM dorm_student s LEFT JOIN dorm_room r ON r.room_id = s.room_id LEFT JOIN attendance_record rec ON rec.student_id = s.student_id AND rec.sign_date = #{date} LEFT JOIN attendance_rule r2 ON r2.building_id = r.building_id AND #{time} BETWEEN r2.start_time AND r2.end_time WHERE s.status = '0' GROUP BY r.building_id;未归预警用 Ruoyi 的定时任务功能,每天晚上查寝时间过后扫一遍当天无签到记录且无请假的学生,触发微信订阅消息或短信通知。定时任务里要加一个保护开关:如果系统刚部署、宿舍楼还没配规则,不要推送,避免第一天晚上就轰炸宿管老师。这一点是我做模拟项目 X 时最深刻的教训——上线当晚宿管老师收到 80 条未归通知,结果是规则表里status字段忘记置为启用,全员未签到。后来我在预警任务里加了一个前置判断:attendance_rule表中生效规则数为 0 时直接跳过推送,并把告警日志打出来。签到系统这类业务,最怕的不是功能做不出来,而是数据错了没人发现,等月底对账才炸出来。希望这篇笔记能帮你把该踩的坑提前避开,做出一套宿管老师愿意天天用的考勤系统。
本文还有配套的精品资源,点击获取