1. 项目概述与技术选型解析
1.1 这套考勤系统到底解决什么问题
做管理系统的朋友应该深有体会,考勤这种需求每个公司都逃不掉,但市面上成熟产品要么收费不菲,要么功能冗杂、定制困难。自己动手写一套前后端分离的考勤系统,几乎是每位后端开发者职业生涯绕不开的练兵项目。
这个项目的核心目标是解决公司日常考勤管理的三件事:员工每天怎么打卡、管理员怎么审批请假、人事怎么快速统计出勤数据。围绕这三个场景,我选用SpringBoot+Vue+MyBatis+MySQL这套经典组合来实现,整套系统包含完整的源码和部署教程,适合正在学习JavaWeb开发的初中级开发者,也适合有内部系统定制需求的小微企业技术团队参考。
先说说为什么锁定这个技术栈。SpringBoot作为后端框架,内嵌Tomcat、自动配置、起步依赖这些特性让项目初始化成本极低,一个注解就能启动RESTful API服务,省去大量XML配置的繁琐。Vue在前端领域的优势不必多说,组件化开发配合Vuex状态管理,处理打卡记录、审批流程这类交互密集的页面非常顺手。MyBatis则是国内中小企业项目中的常客,SQL由开发者自己掌控,复杂联表查询写起来直观,不像JPA那样容易在性能上踩坑。MySQL作为关系型数据库,免费成熟、社区活跃,配合InnoDB引擎在并发写入和事务支持上都令人放心。
1.2 为什么坚持前后端分离而非传统模板渲染
很多初学者会问,用Thymeleaf直接在SpringBoot里渲染页面不也挺好吗?确实,传统单体架构在小型项目里开发更快,但一旦涉及多端复用、多人协作、后续维护,前后端分离的优势就显现了。
前后端分离最大的特点是职责边界清晰。后端只负责输出JSON数据,不关心页面长什么样;前端只专注于页面交互和数据展示,不用被服务端模板语法绑架。分离之后,前端可以独立部署到CDN,后端API可以横向扩容,移动端App如果要接入系统,直接调同一套接口即可。考勤系统的打卡功能天然具备多端使用的需求,如果哪天要做企业微信集成或钉钉打卡对接,前后端分离的架构会让你轻松很多。
开发节奏上也能并行推进。前端团队按照接口文档开发页面,后端团队按既定接口规范编写业务逻辑,双方只通过约定好的JSON结构对接,不会因为页面改版反复影响后端代码。我在实际开发中体会最深的是,联调阶段虽然需要两边同步,但后续迭代时只改前端不动后端、只扩接口不动页面,这种灵活性是传统模板渲染给不了的。
1.3 核心功能矩阵与角色权限划分
考勤系统大致可以划分为三个角色视图:普通员工、部门主管、系统管理员。每个角色看到的页面和可执行的操作完全不同,这背后涉及的就是权限模型设计。
员工端的功能包括上下班打卡、查看个人考勤记录、提交请假申请、查看审批状态。主管端的功能包括审批下属的请假申请、查看本部门出勤统计。管理员端的功能包括用户管理、部门管理、考勤规则配置、全局考勤数据导表、异常考勤修正。
权限控制的实现我选择了最务实的方案:登录接口在签发JWT令牌时把用户角色编码进Token里,后端通过拦截器统一校验接口权限,前端则根据角色动态渲染路由菜单。这种做法虽然不如Spring Security那么重型,但对考勤系统这种角色固定、权限层级简单的业务来说足够清晰高效。后文会给出具体的拦截器实现代码。
2. 数据库设计与核心表结构拆解
2.1 五张核心表的字段规划思路
数据库设计是整个项目的基石,字段命名和表关系如果混乱,后面写Mapper和Service时会痛苦不堪。我的做法是表名全部采用单数形式、字段统一使用snake_case风格,主键一律使用自增ID,创建时间和更新时间由代码统一维护。
员工表(employee)是系统的用户主体,包含工号、姓名、密码、手机号、邮箱、部门ID、角色ID、入职日期、在职状态。工号设置唯一索引,用于登录和考勤机同步时的身份识别。部门表(department)包含部门名称、上级部门ID、负责人ID,之所以保留负责人ID是因为审批流程需要按部门逐级上报。
考勤记录表(attendance_record)是数据量最大的一张表,字段包括员工ID、打卡日期、上班打卡时间、下班打卡时间、考勤状态、异常原因、备注。这里有个细节值得注意:日期和打卡时间分开存储,日期用DATE类型,打卡时间用DATETIME类型,这样既能按天检索记录,又能灵活计算工时。
请假申请表(leave_application)包含申请人ID、请假类型、开始时间、结束时间、请假时长、请假事由、审批人ID、审批状态、审批意见。审批状态使用整数类型存储,比字符串更高效:0待审批、1已通过、2已驳回。考勤规则表(attendance_rule)则保存公司级的上下班时间配置,包括上午上班时间、下午下班时间、迟到阈值、早退阈值、迟到的宽容分钟数。
2.2 表关系与索引设计的实操考量
表关系相对简单:员工表通过外键关联部门表,考勤记录表、请假申请表都通过员工ID关联员工表。这里我建议在物理表上不要真的建外键约束,而是在业务层通过查询维护关联关系。原因很实际:外键约束会降低并发插入性能,分布式场景下还会限制分库分表,大多数互联网公司的生产环境都选择逻辑外键而非物理外键。
索引设计上要根据查询场景来。考勤记录表的查询有两个高频维度:某员工某段时间的考勤明细、某部门某天的全员出勤情况。前者需要建立(employee_id, work_date)联合索引,后者需要建立(work_date, department_id)联合索引。请假申请表则建立(approver_id, status)联合索引,因为审批人查询待办事项是最频繁的操作。
我在初始化SQL脚本里为每张表都加了字符集和排序规则设置,utf8mb4和utf8mb4_general_ci,前端如果提交Emoji表情或特殊符号,utf8mb4才不会报错。虽然考勤系统里出现Emoji的概率不高,但员工姓名备注里万一有人输入特殊字符,就会触发臭名昭著的"Incorrect string value"错误,这个坑值得提前规避。
2.3 初始化数据的种子策略
系统首次启动时,需要预置管理员账号、默认部门、基础考勤规则。我的做法是写一个数据初始化脚本,在数据库创建完成后手工执行一次,而不是在SpringBoot启动时自动执行。
脚本内容主要包括:插入一个超级管理员账号(密码为MD5加密后的初始密码)、插入几个示例部门、插入默认考勤规则。如果你希望系统开箱即用体验更好,可以把初始化语句放在schema.sql和data.sql中,然后在application.yml里配置spring.sql.init.mode=always。但要注意生产环境不能这样搞,否则每次启动都会尝试重复执行初始化语句,导致主键冲突或数据覆盖。这也是我坚持手工执行脚本的原因——环境可控、逻辑清晰。
3. 后端核心实现与接口设计
3.1 基于JWT的登录认证流程
登录认证选择了JWT方案而非Session,根本原因是前后端分离后,后端服务可能分布在多个节点上,Session粘滞会带来扩展性问题,而JWT的无状态特性天然适配分布式环境。
用户提交工号和密码后,后端先通过MyBatis查询员工表,比对MD5加密后的密码是否一致。校验通过后使用HS256算法签发Token,Token的有效期设置为24小时,并将员工ID、工号、姓名、部门ID、角色编码写入Token的自定义claims中。后端在拦截器中解析Token,从claims中获取角色编码用于接口鉴权。
这里有一个我反复强调的细节:Token里只放非敏感信息,不要放密码和手机号。因为JWT的Payload虽然经过Base64编码,但本质上是可以被解码还原的明文,攻击者即便无法篡改Token,也能轻松读取其中的内容。密码这类敏感数据放进去等于裸奔。
// JWT工具类核心代码 public class JwtUtil { // 密钥建议使用环境变量注入,不要硬编码在代码里 private static final String SECRET_KEY = System.getenv("JWT_SECRET"); private static final long EXPIRE_TIME = 24 * 60 * 60 * 1000; public static String generateToken(Employee employee) { Date now = new Date(); Date expireDate = new Date(now.getTime() + EXPIRE_TIME); return Jwts.builder() .setSubject(employee.getId().toString()) .claim("employeeNo", employee.getEmployeeNo()) .claim("name", employee.getName()) .claim("role", employee.getRole()) .claim("departmentId", employee.getDepartmentId()) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); } }3.2 登录拦截器与权限校验的实现细节
拦截器是后端安全防护的第一道关卡。我实现了两个拦截器:一个是认证拦截器,校验所有非白名单接口的Token是否有效;另一个是权限拦截器,根据接口路径前缀校验角色权限。
// 认证拦截器核心代码 public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 放行OPTIONS预检请求,否则前端跨域调用会失败 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (StringUtils.isBlank(token) || !JwtUtil.verifyToken(token.replace("Bearer ", ""))) { // 返回401并输出JSON错误信息 response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"登录已过期,请重新登录\"}"); return false; } return true; } }需要特别提示的一点:一定要在拦截器中对OPTIONS请求放行。浏览器在发起跨域POST请求时,会先发送一个OPTIONS预检请求,如果这个请求被拦截器挡住,前端Console会一直报CORS错误,但后端日志却没有任何异常记录,排查起来极其费劲。我最初开发时就因为漏了这行判断,浪费了整整半天时间。
权限校验的思路是按接口路径前缀匹配。比如员工端的考勤操作统一放在/api/attendance/**下,管理端的管理操作统一放在/api/admin/**下。权限拦截器读取Token中的角色编码,如果是ADMIN则放行所有路径,如果是EMPLOYEE则只允许访问/api/attendance/**和/api/leave/**。Graceful即可,不必过度设计细粒度权限。
3.3 打卡接口的防重复与时间判定逻辑
打卡接口是考勤系统业务逻辑最密集的地方。员工点击打卡按钮时,后端需要完成三件事:判断是否在可打卡时间段内、判断是上班打卡还是下班打卡、防止同类型重复打卡。
实现思路是:以当天日期和员工ID作为检索条件,先查询是否存在当天的考勤记录。如果记录不存在,说明是上班打卡,插入一条新记录,上班时间设为当前时间,状态设为正常。如果记录已存在但下班时间为空,说明是下班打卡,更新下班时间为当前时间,并根据考勤规则计算当日工时状态。如果记录已存在且上下班时间都已填写,直接返回"今日已完成打卡"提示。
时间判定逻辑中最容易出错的是日期边界问题。比如员工在23:59打卡,数据库存的是23:59:59,而考勤规则又要求下班打卡必须在18:00之后,这个判断要统一使用服务器时间。服务器时区如果配置不对,就会出现打卡日期偏移一天的诡异现象。我在配置文件中显式指定了server.servlet.session.timeout和JDBC连接串的serverTimezone=Asia/Shanghai参数,从源头避免时区问题。
// 打卡接口核心逻辑 @PostMapping("/clock") public Result clockIn(@RequestAttribute("employeeId") Long employeeId) { LocalDate today = LocalDate.now(); LocalDateTime now = LocalDateTime.now(); AttendanceRecord record = attendanceMapper.selectByEmployeeAndDate(employeeId, today); if (record == null) { // 上班打卡:插入新记录 AttendanceRecord newRecord = new AttendanceRecord(); newRecord.setEmployeeId(employeeId); newRecord.setWorkDate(today); newRecord.setCheckInTime(now); newRecord.setStatus(0); // 状态默认正常 attendanceMapper.insert(newRecord); return Result.success("上班打卡成功", newRecord); } if (record.getCheckOutTime() != null) { return Result.error("今日已完成上下班打卡,无需重复操作"); } // 下班打卡:更新记录 record.setCheckOutTime(now); attendanceMapper.updateById(record); return Result.success("下班打卡成功", record); }3.4 请假审批状态机的流转设计
请假流程比较典型,我把它设计成一个简单的状态机:待审批→通过或驳回,状态变更统一通过Service层的方法完成,不允许前端直接传状态值来篡改。
提交请假申请时,系统自动根据当前登录人获取其部门负责人ID,存到审批人字段。员工可以随时撤回待审批的申请,但已通过或已驳回的申请不可撤回。主管审批时,可以选择通过或驳回,驳回必须填写审批意见,通过则可以选择是否备注。
审批操作的并发控制同样值得注意。如果员工和主管同时操作同一张请假单,有可能出现状态脏读。我采用乐观锁方案,在请假表里增加version字段,更新时带上where id = ? and version = ?条件,更新成功后version加一。如果影响行数为0,说明数据已被他人修改,返回"请刷新后重试"。这种方式比悲观锁性能更好,实现也更简单。
4. 前端页面架构与交互实现
4.1 Vue项目的目录结构与路由设计
前端项目基于Vue CLI创建,配合Vue Router实现路由管理和Vuex实现全局状态管理,UI组件库选用Element UI。
目录结构上做了一个清晰的模块划分:views目录按角色拆分子目录,admin下放用户管理、部门管理、考勤规则等页面,employee下放打卡页、我的考勤、请假申请等页面。api目录统一封装所有后端请求,utils目录存放JWT解析、日期格式化等工具函数。
路由守卫是前端权限控制的关键环节。每次路由跳转前,先在router.beforeEach里检查本地localStorage是否有Token。如果存在但用户信息为空,调用后端接口拉取当前用户信息存入Vuex。路由meta字段中定义所需角色,切换路由时判断当前用户角色与meta.roles是否有交集,没有则跳转到403页面。
4.2 Axios请求封装与Token管理
Axios请求封装是我每次项目都必须做的一步。直接在组件里写axios.get虽然简单,但遇到接口统一报错处理、Token自动附加header、登录过期统一跳转这些需求时,就会陷入大量重复代码的泥潭。
我的做法是创建一个request.js文件,在axios实例的拦截器里完成三件事:从localStorage读取Token并添加到请求头、响应成功后直接返回data部分、响应失败时统一提示错误信息。最关键的是对401状态码的全局处理:清除本地Token和用户信息,跳转登录页,同时用Element UI的Message组件弹出"登录状态已失效,请重新登录"。
实践中遇到过Token过期和接口并发请求的矛盾:如果页面同时发起三个请求,三个请求都返回401,就会触发三次跳转和三次弹窗。解决思路是引入一个isRelogin标志位,首次401时置为true并跳转,后续401直接忽略。这里不展开全局队列的中断重发方案,考勤系统这种并发场景用标志位方案已经绰绰有余。
4.3 打卡页面的实时状态展示
打卡页面是员工使用频率最高的页面,我把它设计成一个卡片式布局。卡片顶部显示当前时间和星期,中间区域根据打卡状态显示不同的按钮文案和颜色:未打卡时显示"上班打卡"主按钮,已完成上班打卡显示"下班打卡"按钮,全部完成后按钮置灰。
页面加载时会请求后端接口获取今日打卡状态,同时通过Element UI的TimePicker展示当天已记录的打卡时间。为了让页面更直观,我增加了一个最近七天的考勤记录列表,用Tag标签标识每天的状态:正常显示绿色、迟到显示橙色、缺卡显示红色。这个小功能虽然实现起来不复杂,但对员工和管理者而言能迅速了解一周的出勤概况,效果很好。
要注意一个交互细节:打卡按钮点击后应立即置为loading状态,防止用户双击导致后端重复插入记录。虽然后端已经做了防重复校验,但前端交互层的防御也是必要的。我在第一次测试时连点三次按钮,后端接口成功拦截了后两次请求并返回提示,但体验上不如前端提前禁用按钮来得干净利落。
4.4 管理端的考勤统计看板
管理端的核心页面是考勤统计看板,我使用ECharts实现图表展示。页面顶部是四个统计卡片:今日应到人数、今日实到人数、今日迟到人数、今日缺勤人数。中部是一个部门维度的柱状图,展示各部门的出勤率。底部是近30天出勤趋势折线图。
统计接口的设计要考虑查询性能。我的做法是前端传部门ID和时间范围,后端聚合SQL直接查询出结果,不在Java代码里做大量嵌套循环。例如统计今日部门出勤情况,一条SQL通过LEFT JOIN员工表和考勤记录表,结合DATEDIFF函数判断记录日期是否等于当天,配合GROUP BY部门ID和SUM(CASE)表达式,一秒钟就能返回全部数据。
管理员还可以通过日期选择器查看任意历史日期的出勤快照。这个功能涉及一个设计取舍:考勤系统的统计结果应该基于记录生成时点的数据,而非当前数据。如果管理员事后修正了某条异常记录,历史快照是否要同步变化?我选择的方案是实时统计,不做快照固化,因为异常修正的场景极少,实时统计的准确性和实现成本都更有优势。
5. 部署环境搭建与全流程部署指南
5.1 本地开发环境的版本选型
环境准备是部署的第一步,也是新人最容易栽跟头的地方。我的建议是JDK使用1.8版本,虽然JDK 17已经发布许久,但Spring Boot 2.x系列对JDK 8的支持最成熟,且大多数公司的生产服务器仍以JDK 8为主。Maven使用3.6以上版本,Node.js使用14以上版本,Vue CLI使用4.5以上版本。
MySQL选用5.7或8.0都可以,8.0的窗口函数和JSON特性更丰富,但5.7的稳定性和兼容性广泛验证过。如果你是在本地已有MySQL 8.0环境,完全可以直接使用,注意JDBC连接串需要显式添加allowPublicKeyRetrieval=true&useSSL=false参数,否则8.0连接时会因为RSA密钥交换问题报错。
数据库管理工具方面,Navicat和DataGrip我都用过,DataGrip对复杂SQL的提示更智能,Navicat的界面更接近国内用户习惯。两者选一个顺手的即可,重要的是初始化SQL脚本的编码问题:MySQL命令行导入时要用source命令或指定--default-character-set=utf8mb4,否则脚本中的中文注释和初始数据会变成乱码。
5.2 后端打包与生产环境启动
后端项目打包前,先检查application-prod.yml中的数据库连接信息是否指向生产库。然后执行Maven打包命令:
mvn clean package -Dmaven.test.skip=true打包完成后,target目录下会生成一个可执行的jar包。生产环境的启动命令我推荐用nohup进行后台运行:
nohup java -jar -Xms512m -Xmx1024m -XX:+UseG1GC attendance-system.jar --spring.profiles.active=prod > app.log 2>&1 &启动命令里的JVM参数值得展开讲讲。-Xms512m设定JVM初始堆内存,-Xmx1024m设定最大堆内存,这两个值不相等是为了避免频繁扩容和收缩带来的性能损耗。G1垃圾收集器适合多核大内存服务器,如果是2G内存的小服务器,改用默认的Parallel GC反而更稳。启动日志重定向到app.log文件,方便后续排查问题。
部署过程中我踩过一个印象深刻的坑:jar包升级时,旧的进程还占着端口,新版启动直接报端口被占用。正确操作是先通过jps -l查看Java进程PID,执行kill -9杀掉旧进程,确认端口释放后再启动新包。为了防止同事误操作,我写了一个deploy.sh脚本来完成"停止→备份→启动→检查健康"四个步骤,每次升级只需要执行一个脚本。
5.3 前端构建部署与Nginx配置
前端部署过程分两步:本地构建生成静态文件,然后上传到服务器配置Nginx。
npm install npm run build构建完成后,dist目录下的文件就是最终产物。这部分注意事项较多:第一,如果前端请求路径配置了baseURL=/api,那么Nginx需要配置反向代理将/api请求转发到后端服务;第二,dist目录内容更新时,建议直接删除旧目录再复制新文件,避免残留旧版本的JS文件被浏览器缓存后引发各种诡异问题;第三,index.html、static目录这些资源要区分cache策略,index.html设置no-cache保证每次都能拿到最新版本,JS、CSS文件设置较长缓存时间配合文件名hash实现自动更新。
一个典型的Nginx配置如下:
server { listen 80; server_name attendance.example.com; # 前端静态文件 location / { root /usr/share/nginx/html/dist; index index.html; try_files $uri $uri/ /index.html; # 解决Vue Router history模式刷新404 } # 后端API反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }Nginx配置中最重要的就是那行try_files $uri $uri/ /index.html。Vue Router开启history模式后,直接在浏览器地址栏访问/attendance这类路由路径,Nginx会按照路径去找物理文件,找不到就返回404。加上这行配置后,所有不存在的路径都会回退到index.html,由前端路由接管URL解析。如果你不想要这个配置,也可以改用hash模式(URL带#号),但视觉效果和搜索引擎友好度都会差一些。
5.4 数据库备份与日常运维
部署不是终点,日常运维中的数据库备份是很多公司容易忽视的环节。我在生产服务器上配置了一个每日凌晨的crontab定时任务,用mysqldump将数据库全量备份到指定目录,并保留最近七天的备份文件。
0 2 * * * mysqldump -uattendance -p'password' attendance_db > /backup/attendance_$(date +\%Y\%m\%d).sql 2>> /backup/backup.log && find /backup -name "*.sql" -mtime +7 -exec rm -f {} \;需要提醒的是,mysqldump导出的SQL文件可以用cron的gzip压缩后再存储,一个几十MB的数据库能压缩到几MB,极大降低磁盘占用和传输成本。同时建议把备份文件定期同步到另一台独立的存储服务器或云存储,避免服务器磁盘故障导致备份本身也一起丢失。
日常运维还需要关注磁盘空间、Java进程状态、MySQL慢查询日志。我在服务器上配置了一个简易的健康检查脚本,每五分钟检查Java进程和Nginx进程是否存在,异常时通过邮件或短信报警。考勤系统虽小,但直接关系到全公司工资核算的准确性,稳定性和数据安全性必须放在第一位。
6. 常见问题排查与避坑经验
6.1 跨域请求失败的完整排查思路
前后端分离项目最经典的报错就是CORS跨域。如果你的前端通过http://localhost:8081访问后端的http://localhost:8080接口,浏览器默认会拦截这种跨域请求。
我遇到过三种典型表现:Console报错"CORS policy: No 'Access-Control-Allow-Origin' header"、请求返回200但response为空、POST请求不报错但实际数据没写入。排查思路应该按以下顺序推进:先确认后端是否配置了CORS过滤器或@CrossOrigin注解,再确认拦截器是否放行了OPTIONS预检请求,最后检查Nginx反向代理场景下的跨域配置。
后端CORS配置推荐使用全局配置类,不用在每个Controller上加注解。使用WebMvcConfigurer的addCorsMappings方法统一设置允许的域名、方法、请求头。生产环境要把allowedOrigins指定为具体域名,不要用*通配符,否则配合Credentials时浏览器会直接拒绝。
6.2 Token过期踩坑与前端处理策略
JWT过期这个问题在测试环境基本遇不到,因为时效设置得比较长。但在生产环境中,员工早上的Token到下午就会失效,此时打卡操作全部失败。
后端返回401后,前端需要做的不仅是提示"重新登录",而是尽量无缝地让用户重新走一遍登录流程。我在实际项目中有一个改进方案:登录页保留用户工号,点击重新登录按钮时自动带入工号,用户只需要再输一次密码就能快速恢复会话。
另有一个常见需求是"记住我"功能。实现方式是在签发Token时生成两个Token:主Token有效期较短(如24小时),刷新Token有效期较长(如7天)。主Token过期时,前端用刷新Token向后端换发新Token。考勤系统这个场景我建议不要过度设计,直接让员工重新登录一次就好,因为员工一天至少登录一次,且打卡按钮旁边就有登录入口,频繁的自动续期反而增加安全风险。
6.3 打卡时间时区偏移问题的两次教训
时区问题是我在这类系统里吃过亏的重灾区。第一次遇到的现象是:员工打卡后,数据库记录的日期比实际日期晚一天。排查后发现是JDBC连接串没有配置serverTimezone参数,导致MySQL Connector/J使用服务器默认时区与Java进程时区不一致。
第二次遇到的是前端时间展示偏移8小时。前端拿到后端返回的时间字符串,直接用JavaScript的new Date()解析,浏览器按本地时区转成Date对象,再显示时又被Vue filters格式化,来回一折腾就出现在DevTools里看时间是正确的、页面上却显示少了8小时的问题。
最终的解决思路是前后端统一约定。后端所有接口返回的时间字段统一是yyyy-MM-dd HH:mm:ss格式的字符串,不携带时区信息。前端拿到后直接按字符串处理,不做任何Date对象转换,只在需要比较时间大小的时候通过moment.js或Day.js解析。这种"前后端都按服务器时间处理"的约定,虽然不够国际化,但在企业内部系统里最稳定、最不容易出错。
6.4 MyBatis动态SQL的写法与SQL注入防护
考勤系统有大量的条件查询场景:按日期范围、按部门、按姓名、按状态筛选考勤记录。这些需求的实现依赖MyBatis的动态SQL,但动态SQL如果不小心就会埋下SQL注入隐患。
<!-- 属性映射示例:避免使用SELECT * --> <resultMap id="AttendanceRecordMap" type="com.example.entity.AttendanceRecord"> <id column="id" property="id"/> <result column="employee_id" property="employeeId"/> <result column="employee_no" property="employeeNo"/> <result column="work_date" property="workDate"/> <result column="check_in_time" property="checkInTime"/> <result column="check_out_time" property="checkOutTime"/> <result column="status" property="status"/> </resultMap> <!-- 动态SQL查询示例 --> <select id="queryAttendanceRecords" resultMap="AttendanceRecordMap"> SELECT ar.*, e.employee_no, e.name FROM attendance_record ar LEFT JOIN employee e ON ar.employee_id = e.id <where> <if test="employeeId != null"> AND ar.employee_id = #{employeeId} </if> <if test="startDate != null"> AND ar.work_date >= #{startDate} </if> <if test="endDate != null"> AND ar.work_date <= #{endDate} </if> <if test="status != null"> AND ar.status = #{status} </if> </where> ORDER BY ar.work_date DESC, ar.check_in_time DESC </select>关于SQL注入的安全认知需要专门强调:MyBatis的#{}预编译占位符是对抗SQL注入的根本保障,它会把参数值作为字符串绑定到PreparedStatement中,数据库层面会做严格转义。但${}直接拼接SQL片段的行为,一旦参数来自用户输入就极度危险。我的铁律是:业务代码中禁止使用${}拼接任何来自客户端的值,动态表名、排序字段如果确实需要动态传入,必须在Java代码侧做白名单校验。
6.5 常见错误速查表
| 错误现象 | 可能原因 | 解决方案 |
|---|---|---|
| 后端启动报"Port 8080 was already in use" | 上次运行的进程未关闭 | jps -l找到PID后kill,或更换端口 |
| 前端npm install报ELIFECYCLE错误 | Node版本不兼容或网络源慢 | 升级Node版本,或切换为国内npm镜像源 |
| 数据库连接报Public Key Retrieval异常 | MySQL 8.0加密规则变化 | JDBC连接串添加allowPublicKeyRetrieval=true |
| 中文乱码 | 数据库字符集或连接字符集未配置 | 统一utf8mb4字符集,并配置JDBC字符集参数 |
| 页面404 | Vue Router history模式未配置Nginx回退 | 添加try_files配置 |
| 请求返回null数据 | 前端字段名与后端不一致 | 确认JSON字段名的驼峰与下划线映射关系 |
| 打卡提示"日期错误" | 服务器时区偏移 | 检查系统时区和JDBC serverTimezone参数 |
以上都是我在这个项目开发过程中真实踩过并解决的坑。每个问题看起来都不复杂,但如果没有系统的排查思路,调试时很容易绕远路。把这些经验整理出来,希望能帮你缩短排错时间,把精力集中在核心业务功能的打磨上。
7. 项目扩展方向与个人实操体会
考勤系统做完之后,我最大的感受是:这类管理系统的价值不在于技术有多炫酷,而在于业务流程是否顺畅、数据是否准确、操作是否顺手。技术栈选得再先进,如果打卡流程逻辑混乱、审批状态流转不清晰,用户照样不愿用。
如果后续要在这个基础上继续扩展,我建议优先考虑三个方向。第一个是导出功能增强,把考勤统计数据导出为Excel格式的月报表,直接对接财务核算流程。实现方式可以用Apache POI生成xlsx文件,导出接口加一层异步任务处理,大文件生成完成后通过下载链接通知用户。第二个是引入Redis缓存,考勤看板的统计接口通过Redis缓存热门查询结果,设定五分钟过期时间,能显著降低数据库压力。第三个是接入企业微信或钉钉的能力,提供打卡提醒和审批通知。这些平台都有现成的开放接口,和考勤场景结合非常自然。
对于正在学习这套源码的开发者,我的建议是先整体运行起来,跑通一条完整链路:注册用户、登录、打卡、提交请假、主管审批、查看统计。然后逐步改造代码:试着把固定考勤时间改成支持多班次、把单一管理员角色扩展成多级审批流。通过亲手改动一个真实的业务系统,你对SpringBoot的自动配置、MyBatis的SQL映射、Vue的组件通信这些知识点的理解,会比背一百道八股文都深刻。
最后再分享一个细节技巧:考勤系统的日期计算尽量统一用Java 8的LocalDate和LocalDateTime,不要用已经过时的Date和SimpleDateFormat。前者对日期加减、区间比较、线程安全性都有更好的支持,配合DateTimeFormatter格式化也清晰直观。现代JDK的API设计真的值得每个开发者认真学习,它会帮你规避掉大量历史遗留的时间处理问题。