news 2026/9/12 5:15:50

SpringBoot综合签到系统:从表结构到幂等控制的设计实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot综合签到系统:从表结构到幂等控制的设计实践

简介:这是一份面向计算机专业应届毕业生与课程设计学习者的 SpringBoot 综合签到打卡系统毕业设计资料,包含项目源码与配套数据库脚本,可用于毕业设计选题落地、课程作业参考或 Java Web 入门练习。项目以 Spring Boot 为核心框架,结合前端页面与数据库完成签到打卡主流程,适合作为从需求梳理到代码实现的整包参考。压缩包共 88 个文件,约 2.16MB:42 个 Java 源文件承担后端业务与接口实现,JavaScript、HTML、CSS 构成前端交互页面,yml 负责项目与数据源配置,sql 提供建表与初始化数据,整体轻量,便于本地导入运行与二次改造。目前已有 212 人学习下载。读者可获得一套可运行的签到打卡项目结构,了解控制层、服务层、实体与持久层的组织方式,以及签到记录、用户权限等模块的实现思路;数据库脚本可直接导入,便于对照表结构理解业务字段;配置文件与页面资源齐全,方便替换界面或扩展统计功能,适合作为答辩演示与功能扩展的起点。

1. 一个签到打卡系统,为什么值得认真做一遍

综合签到打卡系统这类题目,看着像课程设计,实际上把 Java 面试里最常考的几个点都串在了一起。打卡动作要处理重复提交,考勤规则要处理时间边界,统计报表需要条件聚合 SQL,再加上用户、部门这类基础数据,正好覆盖 SpringBoot 框架从配置到接口的完整链路。这套源码和数据库脚本的价值,不在于功能有多花哨,而在于它给你一个可以对照的完整工程:能看懂表结构,能跑通接口,能解释清楚为什么这样设计。适合正在准备毕业设计的学生,也适合刚学完 SpringBoot、想补一个完整项目经验的人。

2. SpringBoot 综合签到系统的模块划分与数据模型

2.1 先看库再看代码:三个核心业务域怎么切

拿到项目压缩包,不要急着用 IDEA 打开整个工程。我一般先解压,花 10 分钟看一遍 sql 目录下的数据库脚本,再决定从哪块代码切入。综合签到打卡系统从业务上可以切成三个域:签到域负责“记一笔”,考勤域负责“算一笔”,统计域负责“看结果”。切成三个域之后,Controller 只做参数接收和响应封装,Service 管业务判定,Mapper 管纯 SQL,这就是分层思想在真实项目里的落点。

切分的时候要注意一个边界:迟到、早退的判定不该放在签到接口里。签到接口只负责写入原始打卡时间,状态字段可以留空,由考勤模块后续回算。这样做的原因有两个。第一,考勤规则随时可能改,如果把判定写死在签到接口里,规则一改就要动核心链路。第二,打卡时刻判定需要同时知道上班时间和下班时间,签到时只有一边数据,判断不了早退。所以模块拆分不只是代码整洁的问题,它直接决定规则变更时需要改多少个文件。

2.2 表结构设计:唯一索引解决一天只能打一次卡

下面这份建表脚本是这类系统常见的设计,去掉了部门表、审批表这些次级模块,保证跑通核心逻辑不需要理解太多前置概念。

CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', username VARCHAR(64) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(128) NOT NULL COMMENT 'BCrypt加密后的密码', real_name VARCHAR(32) DEFAULT NULL COMMENT '姓名', dept_id BIGINT DEFAULT NULL COMMENT '部门id,预留字段', status TINYINT DEFAULT 1 COMMENT '1启用 0禁用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE attend_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT '用户id', sign_date DATE NOT NULL COMMENT '打卡日期', sign_in_time DATETIME DEFAULT NULL COMMENT '上班打卡时间', sign_out_time DATETIME DEFAULT NULL COMMENT '下班打卡时间', status TINYINT DEFAULT 0 COMMENT '0正常 1迟到 2早退 3缺卡', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_date (user_id, sign_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='打卡记录表'; CREATE TABLE attend_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, rule_name VARCHAR(64) NOT NULL COMMENT '规则名称', start_time TIME NOT NULL COMMENT '上班时间', end_time TIME NOT NULL COMMENT '下班时间', late_minutes INT DEFAULT 30 COMMENT '迟到宽容分钟数', is_active TINYINT DEFAULT 1 COMMENT '是否启用' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考勤规则表';

这里的核心设计在attend_record上的唯一索引uk_user_date。它表达了一个约束:“一个用户一天只能存在一条打卡记录”。这个约束放在数据库层,比写在 Java 代码里可靠得多。并发场景下,两条请求同时查到“今天没记录”,都往里面插入,没有唯一索引就会产生两条。有了唯一索引,第二条插入直接抛 DuplicateKeyException,再配合ON DUPLICATE KEY UPDATE就能优雅地降级成更新。

字段类型上注意两点。第一,sign_date用 DATE 而不是 VARCHAR,这样在 SQL 里做范围查询时可以直接走索引。第二,late_minutes放在规则表里而不是写死在代码里,需求方哪天说“迟到超过 30 分钟才算迟到”,改一条数据库记录就行。

2.3 数据库脚本与种子数据怎么组织

zip 里的数据库脚本常见组织方式是三个文件:schema.sql只放建表语句,data.sql放测试账号和考勤规则,init.sql用 source 命令按顺序引入。这样区分的好处是正式环境中可以分开执行。解压后先看 data.sql 里有什么,正常会有一个 admin 账号和两条测试规则。

脚本文件内容执行时机
schema.sql全部建表语句第一次创建数据库
data.sql测试用户、考勤规则初始化或重置环境
init.sql引入 schema 和 data完整初始化

注意 data.sql 里的测试密码如果是明文,登录时大概率用的是 BCrypt 加密后的密文,需要先跑一次注册接口生成密文再替换回去。如果项目里预留了注册功能,直接用注册接口创建测试账号,省去手工加密的步骤。

3. 核心打卡接口:幂等、时间判定与统计 SQL

3.1 打卡接口的幂等控制,连点两次也不能产生两条记录

用户在打卡页面等网络慢,习惯性连点两次,这是签到系统最高频的异常输入。处理这个需求,不能只依赖前端按钮加 loading,接口层必须有能力兜住。后端经验不足的人会写成“先 select 判断,再 insert”,并发量 1 的时候没问题,压测一上来就会出现同时通过判断的情况,产生两条当天记录。

把前端、后端两层防护拆开看:前端禁用按钮降低触发概率,后端用唯一索引拦截真正的并发写入。在 SpringBoot 的 Service 层,打卡方法推荐写成这样:

@Service @RequiredArgsConstructor public class AttendService { private final AttendRecordMapper attendRecordMapper; private final AttendRuleMapper attendRuleMapper; /** * 上班签到 * @param userId 当前登录用户id */ @Transactional(rollbackFor = Exception.class) public AttendRecord signIn(Long userId) { LocalDate today = LocalDate.now(); LocalDateTime now = LocalDateTime.now(); AttendRecord record = new AttendRecord(); record.setUserId(userId); record.setSignDate(today); record.setSignInTime(now); // 第一次插入,重复点击时转成对签入时间的更新 attendRecordMapper.insertWithDuplicateUpdate(record); return attendRecordMapper.selectByUserIdAndDate(userId, today); } }

对应的 Mapper 语句写在 XML 里:

<insert id="insertWithDuplicateUpdate"> INSERT INTO attend_record (user_id, sign_date, sign_in_time, status) VALUES (#{userId}, #{signDate}, #{signInTime}, 0) ON DUPLICATE KEY UPDATE sign_in_time = VALUES(sign_in_time) </insert>

人工点击速度远低于数据库写入速度,所以实际效果是:第一次点插入,第二次点触发唯一键冲突,走更新分支把 sign_in_time 覆盖成最新时间。这里有一个隐藏的坑:如果用户改完手机时间想“穿越打卡”,后端拿的是LocalDateTime.now()的服务器时间,不接收前端传的时间,这样就堵住了改本地时间的漏洞。如果部署环境不在同一时区,application.yml 里要把时区写成Asia/Shanghai,否则拿到的世界协调时间会比北京时间差八小时。

3.2 迟到早退判定不应该发生在签到时点

签到接口完成时只有 sign_in_time 一半数据,下班签退前无法判断早退,所以把迟到早退的判定放在接口里会形成逻辑死角。观察常见实现:签到接口只看 start_time 判断是否迟到,签退接口再看 end_time 判断是否早退,中间规则变化时直接改业务代码,最麻烦。

更稳的做法是考勤状态推迟到“回算”阶段。每天凌晨用定时任务扫昨天的记录,把考勤规则查出来逐一比对,再统一更新 status 字段。下面是回算迟到逻辑的一个片段:

public void recalc(LocalDate date) { List<AttendRule> rules = attendRuleMapper.findActiveRules(); for (AttendRule rule : rules) { // 找出所有应用该规则的用户的打卡记录 List<AttendRecord> records = attendRecordMapper.findBySignDateAndRule(date, rule.getId()); for (AttendRecord record : records) { LocalTime startTime = rule.getStartTime().toLocalTime(); boolean late = record.getSignInTime() != null && record.getSignInTime().toLocalTime().isAfter(startTime); if (late && record.getStatus() != 3) { record.setStatus(1); attendRecordMapper.updateStatus(record); } } } }

回算逻辑里有一个业务细节要跟需求方确认:迟到了之后,早退还算不算?不同企业的口径不一样,有的迟到一次就不算全勤,有的只记录当天异常。这套设计把“什么时候判定”和“判定口径”分开了,后面改口径不用动签到接口。

  • 判断迟到用isAfter(startTime),而不是用分钟差,避免跨天时取负数
  • status 初始值为 0,缺卡由回算任务单独把没有打卡记录的用户补成 3
  • 回算任务要加幂等保护,重复执行时不能把正常状态改成异常

3.3 统计报表的 SQL,避免在 Java 里循环查询

报表模块经常被问到的点是“出勤率怎么算”。粗浅的实现是先把用户列表查出来,再循环查每个用户的天数,N 个用户就产生 N+1 条 SQL,这就是常见面试题里说的 N+1 查询问题。避免循环查询的正确写法是先把用户一次性查出来,再用一条 SQL 按 user_id 聚合。

SELECT u.id AS user_id, u.real_name, DATE_FORMAT(a.sign_date, '%Y-%m') AS stat_month, COUNT(*) AS should_days, SUM(CASE WHEN a.status = 0 THEN 1 ELSE 0 END) AS normal_days, SUM(CASE WHEN a.status = 1 THEN 1 ELSE 0 END) AS late_times, SUM(CASE WHEN a.status = 2 THEN 1 ELSE 0 END) AS early_times FROM sys_user u LEFT JOIN attend_record a ON a.user_id = u.id AND a.sign_date BETWEEN #{startDate} AND #{endDate} WHERE u.status = 1 GROUP BY u.id, u.real_name, DATE_FORMAT(a.sign_date, '%Y-%m')

LEFT JOIN 保证了没有打卡记录的用户也会出现在结果里,should_days 为 0,方便前端展示。统计 SQL 里必须设置 startDate 和 endDate,否则全表扫描,数据量上去以后报表接口会明显变慢。顺手加一个(user_id, sign_date)的联合索引,这个查询能稳定走索引。

4. 从 zip 到能跑的数据库:导入脚本与 SpringBoot 联调

4.1 用命令行导入数据库脚本

解压之后第一步是建库。项目里的 init.sql 如果规范,会在开头写上 CREATE DATABASE 语句,但保险起见还是手动建库再引入:

mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS attend DEFAULT CHARACTER SET utf8mb4;" mysql -u root -p attend < sql/init.sql

第一条命令指定 utf8mb4 字符集,避免中文乱码;第二条命令把建表和种子数据一起引入。如果用了 Navicat,可以右键数据库选择“运行 SQL 文件”,效果一样。执行完后用SHOW TABLES验证三张核心表是否都建出来了,再执行SELECT * FROM sys_user看看种子用户有没有进去。

4.2 application.yml 三个最容易配错的地方

数据库连不上是所有 SpringBoot 项目联调时最先暴露的问题。检查配置的顺序一般是:URL 里的时区、账号密码、驱动类。下面这份配置可以直接对照修改:

spring: datasource: url: jdbc:mysql://localhost:3306/attend?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.attend.entity server: port: 8080
配置项常见错误正确的意义
serverTimezone不写或写成 UTC决定 LocalDateTime 序列化时用的时区
allowPublicKeyRetrieval缺失MySQL 8 使用 caching_sha2_password 时需要
mapper-locations写错目录必须和 resources/mapper 路径一致

4.3 日志定位与启动失败的排查

如果启动后访问接口报 500,先看控制台日志里最后一个 Caused by。最常见的是三种:表不存在、字段不存在、驱动类报 CommunicationsException。第一种是脚本没执行成功,回去补执行 sql;第二种是实体字段和数据库字段下划线映射没开,在配置里加mybatis.configuration.map-underscore-to-camel-case: true;第三种是 MySQL 8 的驱动版本和数据库版本不匹配,检查 pom 里 mysql-connector-j 的版本号。用 springboot 框架的默认版本管理时,要注意它内部锁定的驱动版本是否兼容本地数据库。

5. 让系统抗住并发:Redis 缓存与补签考勤的边界处理

5.1 用 Redis 拦截当天的重复点击

数据库唯一索引已经能保证数据正确性,但每次重复点击都会打一次 MySQL,高峰时段压力不小。常见做法是在 Service 层前面加一层 Redis,用setIfAbsent控制当天是否已经打过卡:

public AttendRecord signIn(Long userId) { String key = "attend:sign:" + userId + ":" + LocalDate.now(); Boolean first = stringRedisTemplate.opsForValue() .setIfAbsent(key, "1", 1, TimeUnit.DAYS); if (Boolean.FALSE.equals(first)) { throw new BusinessException("今日已签到,请勿重复操作"); } // 后续插入数据库 }

这个方案的优点是接口层可以直接拒绝第二次请求,数据库压力小;缺点是 Redis 和数据库之间需要处理极端情况下的数据一致性,比如 Redis 设置了但数据库插入失败,就要手动删除 key。

5.2 补签接口与审批状态

补签是这个项目里值得展开的功能点。补签接口真正难的地方在于权限:什么时候允许补签、由谁审批、审批通过后统计要不要算进去。主流方案是建一张补签记录表,包含 target_date、reason、approval_status 三个核心字段,统计模块计算考勤时排除未审批的补签记录。

CREATE TABLE attend_supplement ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT '申请人', target_date DATE NOT NULL COMMENT '补签日期', reason VARCHAR(255) NOT NULL COMMENT '补签原因', approval_status TINYINT DEFAULT 0 COMMENT '0待审批 1通过 2拒绝', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='补签记录表';

补签审批通过后,后台会往 attend_record 里补一条记录,同时打上“由补签生成”的来源标记,避免和正常打卡混在一起,这也是综合签到打卡系统区别于 demo 项目的一个细节。

5.3 写一个不依赖前端就能跑的接口测试

springboot 项目自带的测试依赖已经够用。补一个核心接口测试,验证连点两次只产生一条打卡记录:

@SpringBootTest @AutoConfigureMockMvc @Transactional class AttendControllerTest { @Autowired private MockMvc mockMvc; @Autowired private AttendRecordMapper attendRecordMapper; @Test void signInTwiceShouldReturnOnlyOneRecord() throws Exception { mockMvc.perform(post("/api/attend/signIn") .param("userId", "1")) .andExpect(status().isOk()); mockMvc.perform(post("/api/attend/signIn") .param("userId", "1")) .andExpect(status().isOk()); Long count = attendRecordMapper.countByUserIdAndDate(1L, LocalDate.now()); assertEquals(1, count); } }

这个测试用 @Transactional 保证结束后回滚,不会污染本地数据。面试时能主动说出“用什么方式防止重复打卡”“为什么唯一索引比先查再插可靠”,比背几道 springboot 面试题更能体现项目是真正做过而不是下载下来改个名字的。

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

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

Python接口自动化测试中的Token机制与实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 5:12:26

嵌入式Linux下Modbus RTU工业通信实战:从串口配置到传感器数据落地

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 5:11:32

OpenCLIP零样本分类:10分钟跑通图像识别与跨模态检索

OpenCLIP零样本分类&#xff1a;10分钟跑通图像识别与跨模态检索 【免费下载链接】open_clip An open source implementation of CLIP. 项目地址: https://gitcode.com/GitHub_Trending/op/open_clip 当你拿到一批照片&#xff0c;想知道每张图里有什么&#xff0c;却不…

作者头像 李华
网站建设 2026/9/12 5:08:31

Codex 上手指南:从安装配置到 AI 编程实战(2026 更新)

Codex 上手指南&#xff1a;从安装配置到 AI 编程实战&#xff08;2026 更新&#xff09; 更新说明&#xff1a;本文最初发表于 2025 年&#xff0c;现已于 2026 年 9 月更新安装命令、模型服务说明、MCP 与 SDK 示例&#xff0c;并替换失效的注册链接。旧版部分配置已不再适用…

作者头像 李华
网站建设 2026/9/12 5:08:26

go2rtc视频流转发教程:把RTSP监控摄像头转成WebRTC低延迟直播

go2rtc视频流转发教程&#xff1a;把RTSP监控摄像头转成WebRTC低延迟直播 【免费下载链接】go2rtc Ultimate camera streaming application 项目地址: https://gitcode.com/GitHub_Trending/go/go2rtc 家里的监控摄像头大多只支持RTSP&#xff0c;用VLC能看&#xff0c;…

作者头像 李华