做查勤、巡更、值班管理这类后端系统,很多同学的第一反应是先把表建出来,再往Controller里堆接口。真上手之后才发现,项目写得越快,后面改需求的时候越头疼。今天这个查勤管理系统,我从需求梳理到技术选型再到落地实现完整走了一遍,把其中真正影响开发效率和质量的关键点都拆开讲一讲,尤其适合拿Spring Boot做毕业设计或者入职后第一个独立小项目的朋友参考。
这套系统解决的是最典型的“人到岗、事落地”问题:以前单位查勤靠纸质登记、口头汇报,管理员不知道查勤的人到底去没去、查没查、结果如何。用系统之后,查勤任务自动生成,查勤人员手机端打卡、填结果、上报异常,管理员在后台看报表、处理申诉,整个流程有记录、可追溯、可统计。技术栈以Spring Boot为核心,配合Vue做管理端、MySQL存数据、Redis做缓存和token控制,完全是当前中小型管理系统的主流搭配。下面按实际开发顺序聊聊。
1. 查勤系统的核心需求与整体设计思路
1.1 搞清楚查勤到底在查什么
先说业务。查勤这个词在不同场景下有不同含义,有的指校园查寝,有的指厂区安全巡检,有的指值班岗位查岗。但抽离出来,核心就几件事:谁去查、查谁、什么时候查、在哪儿查、查到什么结果、异常怎么处理。所有功能模块都是围绕这六个问题展开的,先把这条主线理顺,后面建表写接口才不会乱。
我做需求分析的时候习惯先用角色讲故事。这套系统里我划分了四类角色:系统管理员、查勤人员、被查人员(普通成员)、部门负责人。系统管理员负责维护基础数据,包括部门、用户、查勤点、排班计划;查勤人员按任务单执行巡查,到达现场后定位打卡并记录状态;被查人员可以查看自己被查的结果,对误判发起申诉;部门负责人看本部门的统计报表,掌握到岗情况和异常分布。
权限模型上我用了RBAC(基于角色的访问控制),没有做太细的菜单权限,因为这类系统真正的硬约束是“谁能发起查勤、谁能确认异常结果”,而不是菜单显不显示。用角色关联菜单和按钮,再用AOP或拦截器在接口层做权限校验,比在页面里做各种v-if判断要靠谱得多。
1.2 功能模块划分布局
| 模块 | 核心功能 | 涉及角色 |
| 系统管理 | 部门管理、用户管理、角色分配、菜单配置 | 系统管理员 |
| 查勤点管理 | 查勤点位置信息、允许误差范围、启用状态 | 系统管理员 |
| 任务调度 | 查勤计划配置、任务自动生成、临时加派任务 | 管理员/负责人 |
| 移动查勤 | 任务接收、定位打卡、结果提交、异常上报 | 查勤人员 |
| 结果管理 | 查勤记录查询、异常审核、被查人申诉处理 | 负责人/普通用户 |
| 统计报表 | 到岗率、异常率、任务完成率、趋势图表 | 负责人/管理员 |
这里我要特别说一下任务调度模块的设计取舍。查勤任务不是用户随手点“开始查勤”就行的,它需要按计划自动生成。我设计了“查勤计划”和“查勤任务”两张表的联动:计划表示规则,比如“行政楼2楼每小时查一次”“大门岗凌晨2点到6点每半小时查一次”;任务表是实际执行实例,每天定时根据计划批量生成当天任务。这样设计的好处是灵活,计划可以随便改,当天已经生成的任务不受影响,真正到了第二天才按新计划生成。
1.3 用户痛点与设计导向
这类系统最容易被忽视的是“查勤人员”视角的使用体验。查勤的人多半在户外跑动,手机上操作,网络信号可能不稳定。如果打卡接口设计得过于繁琐,或者表单校验太严格导致提交失败,执行人员用两次就会抵触。所以我在设计时遵循了几个原则:任务列表一次拉全且支持离线缓存、打卡只传必要参数、结果提交允许草稿机制(本地暂存待网络恢复后补传)、定位异常时允许手动选择原因并留痕。
管理者视角的痛点则是“数据可信度”。查勤人员到了现场到底有没有?这就涉及定位校验的置信度问题。我用了双重校验:一是App端定位坐标,二是后台根据查勤点坐标计算距离是否在容忍范围内。如果距离超限就标记为“疑似作弊”,不让直接通过,必须走异常审核流。后面讲数据库设计和接口实现时会说具体方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与Spring Boot关键机制解读
2.1 为什么最终锁定了这套技术组合
这套系统的后端框架选择Spring Boot,几乎是必然的。现在写Java后端,如果还从Spring MVC的XML配置开始搭建环境,搭完框架基本就不想写业务了。Spring Boot用自动配置把大量繁琐的装配过程收编掉,让开发者把精力集中在业务代码上。管理端前端我选了Vue 3 + Element Plus,前后端完全分离部署;移动端查勤页面没有单独做App,直接用H5页面适配手机浏览器,这样能省掉应用商店审核上架的流程。
持久层用的MyBatis Plus。有人觉得它不够“高级”,但查勤管理这类CRUD密集的系统,用MyBatis Plus能省下大量单表操作的样板代码,分页、逻辑删除、自动填充这些都能直接复用。要注意的是复杂报表查询我仍然手写了XML里的SQL,毕竟MyBatis Plus的QueryWrapper不适合处理多表join和动态统计。
Redis在这里承担了三件事:首页看板的缓存、JWT token的在线状态管理、以及高频查询(比如当前执行人接收任务时对任务状态的频繁判断)的缓存加速。缓存这层一定要从第一天就设计好,否则后期系统并发稍微上来一点,数据库压力全压在任务表和记录表上,扛不住。
2.2 Spring Boot自动装配到底帮你做了什么
面试常问的自动装配,在项目里最直观的体现就是引入依赖和写配置后的“开箱即用”。原理层面,@SpringBootApplication是一个组合注解,核心是@EnableAutoConfiguration,它通过AutoConfigurationImportSelector读取META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件里注册的配置类,再根据你引入的类和配置属性做条件装配。
实际开发中我建议你养成看“自动配置报告”的习惯。项目启动时把debug=true打开,控制台会列出所有自动配置类的匹配/不匹配条件。排查Redis连不上、数据源没生效这类问题,第一步不是查百度,而是看报告里DataSourceAutoConfiguration有没有匹配成功,不匹配的原因是什么。我在这个项目里遇到过数据源自动配置不生效的情况,一查报告发现是classpath里没有数据库驱动,自己早就把pom依赖删了却忘了加回来。
2.3 Spring Boot版本选择的坑与经验
版本选择可能是这类型项目里最容易“送命”的环节。这个查勤系统我要求JDK环境是1.8,所以Spring Boot版本选的是2.7.18——这是2.x系列最后一个版本,也是支持JDK1.8的“封箱之作”。如果你用Spring Boot 3.x,最低要JDK17,很多公司服务器上的旧JDK根本跑不了。对于初学者或者毕设场景,千万别追求版本最新,用2.7.18搭配JDK1.8是最稳的组合。
为什么“springboot版本太高”会成为常态化的问题?因为Spring Boot 3.x之后,很多第三方组件的starter版本没跟上,容易出现兼容性报错。比如集成Springdoc或Swagger时,Springfox的旧版本在3.x下直接启动失败;很多教程里写的spring.factories自动装配方式也在新版本中被改到了AutoConfiguration.imports。所以在项目初始化前就要把版本矩阵列出来,形成自己的基线。
我这次列出的基线是:Spring Boot 2.7.18 + JDK1.8 + MyBatis Plus 3.5.3 + MySQL 5.7 + Redis 6.x + JWT 0.9.1 + Hutool 5.8.x。这套组合我测过很多次,兼容性非常稳定,pom依赖照着写不会有莫名其妙的冲突。
2.4 高频注解与项目里的实际用法
Spring Boot项目里注解的使用频率极高,但很多人只会背定义,不知道什么时候用哪个。我这里结合查勤系统的真实场景总结几个关键注解的使用心得。
@RestController和@Controller的区别不多说了,前后端分离项目基本都用前者。@RequestMapping我建议在类上定义统一前缀,比如@RequestMapping("/api/task"),这样整个控制器的路由更清晰。@RequestBody接收前端传的JSON,比逐个@RequestParam接参省事,但要注意前端必须把Content-Type设成application/json;charset=UTF-8。@Validated配合@NotBlank、@NotNull做参数校验,可以拦截掉大量无效请求。
@Transactional是事务控制最常见的注解,我习惯把它放在Service实现类的方法上而不是Controller上。在任务生成的方法里,我是先插入任务主记录,再循环插入任务明细,如果明细插入失败了,主记录必须回滚,否则会出现“有头无尾”的脏数据。这就必须加@Transactional(rollbackFor = Exception.class),注意如果不写rollbackFor,默认只有运行时异常(RuntimeException)才回滚,普通异常(如IOException)不会触发的。
@Scheduled定时任务注解在这个项目中用得很多,任务生成和过期任务自动取消都靠它。@EnableScheduling要加到启动类上。不过用的时候一定要注意,@Scheduled默认是单线程执行,多个定时任务会互相阻塞。我后面专门在定时任务那张表里加了执行状态字段,防止并发场景下任务重复执行。
2.5 为什么用JWT管控登录态而不是Session
查勤系统的登录场景是典型的“多端访问、无状态最好”:查勤人员在手机浏览器打卡,管理员在电脑上处理审核,前后端分开部署在不同服务上。用传统的Session方案,需要维护服务端会话存储,前后端分离时还要处理跨域携带Cookie的各种问题,体验很差。JWT方案把用户信息加密放在Token里,服务端不需要保存会话状态,天然适合这种场景。
我做登录设计的时候,access_token和refresh_token是分开的。access_token有效期设成2小时,用于日常访问接口;refresh_token有效期设成7天,用于access_token过期后重新换取,免得查勤人员频繁重新登录。这个参数不是拍脑袋定的,是权衡了安全性和体验后的选择:时间太短用户被频繁踢出,时间太长泄露后的风险窗口太大。如果你做的系统对安全要求更高,可以设成30分钟和24小时。
token里放了用户ID、账号、角色编码几个核心字段,没有把整个用户对象都塞进去,不然token体积会膨胀,每次请求携带的开销会增大。redis里保存了token的jti(JWT ID),logout时删掉jti实现真正的“吊销”,弥补JWT无法主动失效的短板。
3. 数据库表设计与后端核心功能实现
3.1 数据库表结构设计要避免的坑
表结构是一个业务系统的地基。查勤管理系统核心表我用这几张:用户表(sys_user)、角色表(sys_role)、用户角色关联表(sys_user_role)、查勤点表(duty_point)、查勤计划表(duty_plan)、查勤任务表(duty_task)、任务打卡记录表(duty_record)、异常申诉表(duty_appeal)。
先说用户表设计的一个常见误区:把部门名称直接存到用户表里。正确做法是存部门ID,关联department表,因为部门会改名,如果到处冗余旧名称,后续统计数据时会出现对不上的问题。我这次用了部门ID,查询时再join出部门名称,虽然每次都多一次关联查询,但数据一致性有保障。用户表别忘加status字段,0表示禁用,1表示正常,一个离岗人员你直接删掉用户数据,历史记录表会变成孤儿数据,所有记录关联不上人,所以只能禁用不能删。
查勤点表(duty_point)除了基础名称、地址,核心是经纬度longitude和latitude,还有一个精度字段radius表示允许打卡的误差范围,单位是米。精度值的默认我设为100米,GPS定位正常情况误差在10到50米之间,设得太小容易误判,设得太大打卡就失去了意义。
打卡记录表是数据量增长最快的表,每天每个任务都有多条记录。为了避免表数据无限膨胀影响查询速度,我对记录表做了按月份的分表规划,同时在业务层面支持按时间范围查询时先计算目标所在的表分区。如果你不想搞分表,至少要在联合索引上下功夫:(task_id, user_id, create_time)和(point_id, create_time)这两组索引是必须的,报表统计性能就靠它们。
3.2 JWT工具类与拦截器的完整实现
登录认证过滤器我选的是Spring MVC的HandlerInterceptor配合WebMvcConfigurer注册,而不是使用第三方Shiro或Spring Security。查勤系统权限模型不需要那么强的安全框架,Security的过滤器链复杂,出了问题排查成本高,毕业设计答辩时也不好讲清楚。Shiro虽然简单,但和Spring Boot整合的自动配置没有官方版本,还不如手写一个拦截器来得轻量可控。
下面是登录拦截器核心逻辑,包括了白名单判断、Token校验、解析、续签等环节:
java复制public class JwtAuthInterceptor implements HandlerInterceptor {
@Autowired
private StringRedisTemplate stringRedisTemplate;
// 需要放行的路径,如登录、文档等
private static final List<String> EXCLUDE_PATHS = Arrays.asList(
"/api/auth/login",
"/api/auth/logout",
"/swagger-ui/**",
"/v3/api-docs/**",
"/doc.html"
);
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 这里不能直接对 request.getRequestURI() 做 startsWith,因为项目配置了 context-path 和 servlet 路径
String uri = request.getRequestURI();
String contextPath = request.getContextPath();
String path = uri.startsWith(contextPath) ? uri.substring(contextPath.length()) : uri;
if (EXCLUDE_PATHS.stream().anyMatch(p -> path.startsWith(p.replace("/**", "")))) {
return true;
}
String token = request.getHeader("Authorization");
if (StringUtils.hasText(token) && token.startsWith("Bearer ")) {
token = token.substring(7);
}
if (!StringUtils.hasText(token)) {
return noAuth(response);
}
try {
Claims claims = JwtUtil.parseToken(token);
String redisKey = "login:token:" + claims.get("userId");
String redisToken = stringRedisTemplate.opsForValue().get(redisKey);
if (redisToken == null || !redisToken.equals(token)) {
return noAuth(response);
}
// 用户信息共享到 ThreadLocal 或 request attribute
request.setAttribute("userId", claims.get("userId"));
request.setAttribute("roleCode", claims.get("roleCode"));
// 续签逻辑:当剩余有效时间小于 30 分钟时,重新生成 token 并写入响应头
long remain = claims.getExpiration().getTime() - System.currentTimeMillis();
if (remain < 30 * 60 * 1000 && remain > 0) {
String newToken = JwtUtil.generateToken(
claims.get("userId").toString(),
claims.get("userName").toString(),
claims.get("roleCode").toString(),
2 * 60 * 60 * 1000L
);
stringRedisTemplate.opsForValue().set("login:token:" + claims.get("userId"), newToken, 2, TimeUnit.HOURS);
response.setHeader("New-Token", newToken);
}
return true;
} catch (ExpiredJwtException e) {
// Token 过期
return noAuth(response);
} catch (JwtException e) {
// 非法 Token
return noAuth(response);
}
}
private boolean noAuth(HttpServletResponse response) throws IOException {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"msg\":\"登录状态已失效,请重新登录\"}");
return false;
}
}
这里我把续签逻辑也加了进去:AccessToken快过期时自动续期,前端收到新token后就替换掉本地存储的旧token。这样用户只要7天内活跃过,就不需要重新登录,体验会好很多。
拦截器写好之后需要注册,这里要注意放行顺序。注册代码里先放Swagger相关放行、再放登录接口放行、最后加拦截器。
java复制@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private JwtAuthInterceptor jwtAuthInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtAuthInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/api/auth/login",
"/api/auth/logout",
"/doc.html",
"/webjars/**",
"/swagger-resources/**",
"/v3/api-docs/**"
);
}
@Override
public void addCorsMappings(CorsRegistry registry) {
// 生产环境不要配 allowCredentials(true) 配 *, 前后端分离跨域要按域名收敛
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
跨域配置有两个细节容易踩坑:一是如果用了allowCredentials(true),allowedOrigins就不能配置成*,必须用allowedOriginPatterns("*"),否则浏览器会拦截响应;二是浏览器跨域请求会先发OPTIONS预检请求,如果你把/api/auth/login这个路径也拦截了,且方法里没有对OPTIONS做放行处理,前端就会报跨域。
3.3 MyBatis Plus自动填充与逻辑删除配置
MyBatis Plus的自动填充功能非常适合处理create_time、update_time这些通用字段。我在项目里定义了如下MetaObjectHandler实现类:
java复制@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
LocalDateTime now = LocalDateTime.now();
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, now);
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, now);
this.strictInsertFill(metaObject, "delFlag", Integer.class, 0);
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
}
实体类上的创建人、更新人这两个字段,我通过@TableField(fill = FieldFill.INSERT)注解自动填充,值从登录拦截器写入request的userId属性里取。注意,自动填充只有在实体字段值为null时才会填充,如果你想更新某个字段时强行改值,要在赋值后调用updateById。
逻辑删除配置我统一在application.yml里设置:
yaml复制mybatis-plus:
global-config:
db-config:
logic-delete-field: delFlag
logic-delete-value: 1
logic-not-delete-value: 0
这个配置意味着你执行deleteById时MyBatis Plus会自动把它转成UPDATE ... SET del_flag = 1 WHERE id = ...。要注意的是,如果你在XML里手写了DELETE FROM duty_record WHERE id = #{id},那么逻辑删除的拦截器不会生效,因为自定义SQL绕过了MP的内置方法。这是一类隐蔽的生产事故,务必在项目中约定:业务上不允许物理删除表数据,自定义SQL也只允许select,不允许直接delete。
3.4 定时任务生成查勤单与防重逻辑
查勤任务自动生成是系统的核心逻辑之一。我用了Spring Boot内置的@Scheduled来做,实现类大概是这样的:
java复制@Service
public class DutyTaskGenerator {
@Autowired
private DutyPlanMapper dutyPlanMapper;
@Value("${task.generate.cron:0 0 1 * * ?}")
private String generateCron;
@Scheduled(cron = "0 30 0 * * ?")
public void generateDailyTask() {
// 每天凌晨0点30分执行
LocalDate today = LocalDate.now();
List<DutyPlan> plans = dutyPlanMapper.selectList(new LambdaQueryWrapper<DutyPlan>()
.eq(DutyPlan::getStatus, 1));
for (DutyPlan plan : plans) {
List<LocalTime> times = parseCronTimes(plan.getExecuteTimes());
for (LocalTime time : times) {
DutyTask task = new DutyTask();
task.setPlanId(plan.getId());
task.setTaskDate(today);
task.setStartTime(LocalDateTime.of(today, time));
task.setEndTime(task.getStartTime().plusMinutes(plan.getDurationMinutes()));
task.setExecutorId(plan.getExecutorId());
task.setPointId(plan.getPointId());
task.setStatus(0);
// 幂等控制:同一任务当天不能重复生成
try {
dutyTaskMapper.insert(task);
} catch (DuplicateKeyException e) {
log.warn("任务重复生成,planId={}, date={}, time={}", plan.getId(), today, time);
}
}
}
}
}
@Scheduled(cron = "0 30 0 * * ?")的意思是每天凌晨0点30分触发。防重机制我用了组合唯一索引,在task表建了uk_plan_date_time (plan_id, task_date, start_time),插入重复记录时数据库会抛DuplicateKeyException,捕获后直接跳过即可。这个方案的可靠性比代码里先查询再判断要高得多,因为并发场景下两个线程同时查到“不存在”,然后同时插入,查询判断拦不住,数据库唯一索引才是最后一道可靠的防线。
执行查勤任务时任务的状态流转也很关键。我定义了如下状态:0待执行、1执行中、2已完成、3已超时、4已取消。查勤人员领取任务后,状态从0变1;完成打卡后变2。系统每天凌晨3点扫描一次状态为0但已过截止时间的任务,自动变成3,并且给直属领导推送一条提醒,这个扫描逻辑也是写在一个@Scheduled方法里的。
3.5 距离计算与打卡防作弊方案,参数怎么定
打卡接口算不算核心代码?算。如果只把前端传上来的经纬度存进库,那系统随便找个模拟定位工具就能绕过。我给打卡新增了后端二次校验,用Haversine公式计算前端上报坐标与查勤点坐标之间的距离:
java复制public static double haversine(double lat1, double lng1, double lat2, double lng2) {
double radLat1 = Math.toRadians(lat1);
double radLat2 = Math.toRadians(lat2);
double diffLat = radLat2 - radLat1;
double diffLng = Math.toRadians(lng2) - Math.toRadians(lng1);
double a = Math.sin(diffLat / 2) * Math.sin(diffLat / 2)
+ Math.cos(radLat1) * Math.cos(radLat2)
* Math.sin(diffLng / 2) * Math.sin(diffLng / 2);
return 2 * 6371000 * Math.asin(Math.sqrt(a));
}
打卡的时候后端拿到Redis里预存的查勤点坐标,计算两点间的实际球面距离。这个公式比直接勾股定理算XY轴距离要准确很多,尤其在高纬度地区,如果直接用经纬度差值乘固定系数,误差可能到几十甚至上百米。
距离容忍度的参数设置需要考虑具体室外场景:我实际测试过,普通手机GPS在室外的定位误差大约是10到30米,在楼道里或窗户边可能到50米以上。所以100米是比较合理的容忍度。设成50米会有大量误杀,设成200米就完全失去意义了。如果某个查勤点正好在大型建筑内部,GPS信号弱,我是建议管理员在后台把该duty_point的误差范围调大到150米,而不是全局放宽。
打卡的时间校验同样重要。任务单上写了上午9点到9点30分,如果用户在8点59分就到了现场、打了卡,时间没问题,但如果9点35分才到,就必须判定迟到。这里需要判断“任务要求开始时间和实际打卡时间的差值”,而不是简单对比返回是否在任务时间窗口内。设计上我允许提前5分钟打卡,防止执行人提前到位却因为系统还没开始而卡住,但提前超过5分钟或晚到超过10分钟都会被打上“异常”标记,需要提交备注。
4. 前后端分离下的接口与运维细节
4.1 统一返回体与全局异常处理
我接手过的几个Spring Boot项目,最大的通病是每个Controller的方法返回类型都不一样,有返回Map的,有直接返回实体的,前端每个接口都得单独处理返回结构。新项目我强制定了一套返回体规范:凡是业务接口一律返回Result<T>,里面包含code、message、data三个字段。前端axios封装里对code==200的视为成功,否则弹出message提示,整体非常统一。
全局异常处理也是标准配置,不外乎@RestControllerAdvice加上@ExceptionHandler。我额外做了一个小点:自定义了业务异常类型BizException,带错误码和消息,所有Service层碰到业务规则校验失败直接throw new BizException(...)。这样全局异常处理器捕捉后会把错误码直接返回给前端,不用每个方法都写if判断然后set result。
常见异常处理顺序建议是:先处理参数校验异常MethodArgumentNotValidException,再处理业务异常BizException,然后处理权限异常AccessDeniedException,最后兜底Exception。不要把Exception写最前面,否则所有异常进大兜底,日志里全是无法区分原因的内容。
4.2 数据权限与MyBatis Plus分页实现的注意事项
查勤管理系统中,部门负责人只能看本部门的数据,系统管理员能看全部,这个业务需求属于数据权限,不是功能权限,不能用按钮权限那一套解决。我在部门相关的查询接口里,加了拦截器自动拼接部门过滤条件。核心思路是:在Controller层通过@DataScope(deptAlias = "d")注解声明需要数据权限过滤,AOP解析注解后向查询SQL片段中追加AND d.dept_id IN (自己的部门ID + 子部门ID集合)。如果只用传统逐方法手写,部门一多非常容易漏条件,产生越权数据。
MyBatis Plus分页使用时有两个坑必须说。第一,分页拦截器PaginationInnerInterceptor一定要设置DbType.MYSQL,不设置的话可能在某些版本下分页失效。第二,分页插件只对MP自带方法生效,如果你写自定义SQL必须确保Mapper接口方法传入IPage作为第一个参数,并且在XML中不要手写limit子句,否则又会查出全表再内存分页。下面是配置类写法:
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL);
pagination.setMaxLimit(500L);
interceptor.addInnerInterceptor(pagination);
return interceptor;
}
}
分页的pageSize我设了最大值500条,防止前端传个10000导致数据库压力过大。管理端页面的表格分页我统一设定为20条一页,列表查询默认只返回前20条;超过上限时必须翻页。这样做不是限制功能,是保护数据库,真实生产环境里一次查上万条然后前端渲染,性能会明显劣化。
4.3 大文件与资源处理不太需要,但配置得提前想好
查勤系统本身上传的文件不多,主要是异常申诉时的现场照片。我把这些图片存的是服务器本地路径,不是把图片二进制传到数据库里。上传接口用MultipartFile接收,限制文件大小spring.servlet.multipart.max-file-size=10MB。图片保存目录是约定好的/data/upload/,数据库存的是相对路径/upload/2025/04/xxx.jpg,再由一个资源映射配置把/upload/**映射到本地磁盘:
java复制@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
String uploadPath = "file:" + System.getProperty("user.dir") + "/upload/";
registry.addResourceHandler("/upload/**")
.addResourceLocations(uploadPath);
}
这里有个细节要提醒:如果你用System.getProperty("user.dir")获取的是启动目录,而你是用java -jar从别的目录启动的,路径就会和你预期不一致。生产环境一定要用绝对路径,或从配置文件里读取,我这次把上传根路径放在application-prod.yml里了,不同环境各配一份。
4.4 Docker部署Spring Boot项目的配置经验
开发时用IDEA直接run,部署环境我用了Docker。最早我图省事把MySQL、Redis都放容器里一键启动,后来发现每次重启容器数据就没了,因为没有挂载数据卷。这里分享一套可以直接抄的部署方式。
后端镜像的Dockerfile大概是:
dockerfile复制FROM openjdk:8-jre-alpine
LABEL maintainer="dev@example.com"
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
COPY target/duty-system.jar app.jar
EXPOSE 8080
ENV JAVA_OPTS="-Xms512m -Xmx1024m -Djava.security.egd=file:/dev/./urandom"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar --spring.profiles.active=${SPRING_PROFILES_ACTIVE}"]
-Djava.security.egd=file:/dev/./urandom这个启动参数容易被忽略,不加的话在Linux上会阻塞等待随机数初始化,造成启动特别慢的错觉。时区设置必须要加,否则容器里的时间默认是UTC,定时任务生成查勤任务的时间会和北京时间差8个小时,系统上线第一天就会出大问题。
docker-compose里我把MySQL和Redis通过named volume持久化,healthcheck检查依赖服务就绪后再启动Java服务。有一步非常关键但经常被忽略:需要把application-prod.yml中的数据库地址写成服务名(比如jdbc:mysql://mysql:3306/duty_db),而不是localhost,因为容器间是独立网络。很多新手把容器跑起来后发现连不上数据库,都是配置地址写错了。
5. 常见问题排查与Spring Boot避坑实录
5.1 “启动类找不到Mapper”这类问题的标准解法
使用MyBatis Plus时,“Mapper bean找不到”是最常见问题。原因无非两种:启动类上没有加@MapperScan注解,或者Mapper接口上没有加@Mapper注解。我建议统一在启动类上加@MapperScan("com.example.duty.mapper")一次性扫描所有Mapper接口,不要在Mapper接口上逐类加@Mapper,这样可以少写很多重复注解。需要注意的是,如果你的Mapper接口和多数据源配置混在一起,扫描路径一定要精确,否则会报sessionFactory还没创建就尝试获取连接等怪异错误。
还有一个不那么直观的问题:Java 8的时间字段(LocalDateTime)默认映射到数据库的datetime调用时,会报Invalid value type或无法转换。原因是MyBatis的老版本默认的TypeHandler不支持JDK8时间类。MyBatis Plus 3.5.3已经内置了支持,但如果你还在用mybatis-spring-boot-starter旧版本,就需要把参数加上jdbcType=TIMESTAMP或者在MyBatis配置里注册LocalDateTimeTypeHandler,这是我早期接手项目时踩过的坑。
5.2 循环依赖问题:Spring Boot 2.6前后的行为差异
Spring Boot 2.6版本之前,Spring对循环依赖是默认允许的。也就是说,A类注入了B,B类注入了A,它可以正常启动。但从2.6版本开始,默认禁止循环依赖,项目一启动就报错提示。如果你是从旧项目升上来的,或者网上搜到旧教程那种“两个Service互相new对方”的写法,在2.7.18下就会直接启动失败。
查勤系统中,假如我设计的DutyTaskService要调用DutyPointService里的方法获取点位信息,而DutyPointService又把DutyTaskService注入进来拿统计数量,这就形成了循环依赖。正确解法是重新梳理职责边界:把统计数量的逻辑抽到一个独立的DutyStatisticService,让DutyPointService调用它;或者使用@Lazy注解打破一环,但不推荐把它当默认做法,因为会让Bean初始化时序变难预测,增加排查问题的难度。
5.3 Swagger接口文档与JWT放行的配置细节
前后端联调离不开接口文档,我在这个项目里用的springdoc(因为Springfox在Spring Boot 2.6之后有路径匹配策略的兼容问题)。springdoc的路径默认匹配策略是PathPatternParser,而部分旧代码基于AntPathMatcher,两种策略混用会出现接口文档能访问,但点进去全部404的奇葩现象。接入了JWT后,还需要把swagger相关路径加入放行名单,这部分路径如果在拦截器里被拦,前端同事打开文档看接口时一片401。
我见过有人为了让swagger页面出来,直接把拦截器全部放行,这是拿安全性做代价换开发便利,生产环境绝不能这样干。可接受的做法是仅当spring.profiles.active=dev时注册swagger相关页面放行,生产环境完全可以关掉文档入口。
5.4 打系统镜像与无法连接MySQL的排查顺序
Docker部署常遇到“jar包在本地跑得好好的,进容器就连不上MySQL”。这里我总结一个排查顺序:先确认两个容器是否在同一网络,再看端口映射是否生效,然后看MySQL容器是否给了远程访问权限(root用户多半只允许localhost访问,要新建专用账号),最后再查防火墙和安全组。很多时候问题不在程序,而是一个skip-grant-tables权限没有配上。
给MySQL容器设置账号密码时不要直接裸写在命令行里,建议用env文件管理。开发环境无所谓,但生产库密码硬编码在docker-compose.yml里,代码仓库一旦泄露后果很严重。配置文件级别的敏感信息也用环境变量注入,Spring Boot的application.yml里用${DB_PASSWORD}占位,容器运行时由环境提供。
5.5 高频问题速查表
| 问题现象 | 可能原因 | 解决方案 |
| 启动报Failed to configure a DataSource | 缺少数据库驱动或配置名称错误 | 检查pom是否有mysql-connector-java;检查url/username/password |
| 接口返回401,但用户确实登录了 | Redis里的token比JWT先过期 | Redis过期时间应大于等于JWT过期时间 |
| LocalDateTime返回给前端变成时间戳数组 | Jackson没配JavaTimeModule | 统一配置ObjectMapper,序列化为yyyy-MM-dd HH:mm:ss |
| 前端跨域报错但后端CORS配了 | 拦截器先于CorsFilter处理了请求 | CORS配置放到FilterRegistrationBean且order设为最高优先级 |
| DELETE接口不生效但无报错 | 数据权限拦截器把逻辑删除拦截了 | 检查SQL是否符合数据权限拼接规则 |
访问Redis的时候有个细节,token过期时间我设置成2小时,但Redis缓存的有效期也开始算了两小时。如果JWT还剩5分钟过期,而Redis里已经被清理了,用户就会提前退出登录。正确的做法是让Redis的有效期比JWT的exp长一点,留出30秒到1分钟的缓冲,避免边界情况造成频繁掉线。
6. 系统上线后我观察到的几个优化方向
这套查勤管理系统上线跑了两周之后,缓存命中率、慢查询等监控数据给了我很多启发。有几个点值得后续持续投入。
第一个是任务消息触达问题。最初查勤人员如果不主动打开列表,根本不知道今天给自己派了什么任务。我后来接入了钉钉/企业微信机器人Webhook,任务生成后通过webhook推送给对应的人,触达率提升明显。如果你不想依赖外部平台,可以用邮件或短信,但成本和到达率要做权衡。
第二个是高并发打卡的写链路。某个工厂每天早晚班集中打卡的时间是几百人同时操作,直接全量写数据库会拖慢响应。我当时的处理是加了一层Redis队列,打卡记录先进Redis List或Stream,然后由消费者批量异步落库。实时性要求高的当前状态查Redis,报表统计走离线库。这套写路径调整后接口的P99耗时从800ms降到了100ms左右,效果非常明显。
第三个是任务调度性能。如果某个计划配置的是每分钟执行一次,月底回头看一个月可能生成几万条任务数据,任务表会变得很大。这时候需要引入分库分表或者至少对task表按月归档,我这里由于规模可控只做了按月归档,就是每月1号把上月的task数据迁移到历史表。如果你的查勤点特别多、任务密度大,建议用分布式任务调度框架把生成任务分片执行,避免单点压力。
技术这条路,最重要的不是背了多少框架,而是每一次掉坑后搭桥的经验。这个查勤管理系统做下来,我自己最大的收获倒不是把Spring Boot用得多熟练,而是更清楚地理解了项目里每一个“默认配置”“约定大于配置”背后都藏着可选的调整空间。以后你再遇到别人说某个框架“报错很莫名其妙”时,先想想是不是版本没对齐、路径拦截优先级没调整、依赖冲突没消掉、时间时区不一致。排查思路顺着这几条主线走,大部分问题半小时内都能定位。
最后分享一个我个人的土办法:不管项目大小,建一个RELEASE.md文档,记录每次部署的环境版本、依赖版本、特殊配置以及踩过的坑。项目维护半年后再回来看,这份文档比注释里的任何一句话都值钱。
