
简介基于 Java 语言的勤务保障系统设计源码面向后勤保障领域的开发者和项目管理者用于解决资源调度、用户管理、事件处理等日常运作与应急场景中的核心问题。整套源码共 439 个文件压缩包约 10.22MB其中 406 个 Java 源文件承载业务模块12 个 FTL 模板文件负责前端动态页面渲染7 个 XML、3 个 SQL、3 个 YAML 与 2 个 properties 文件分别承担配置管理、数据库脚本与环境参数设置另有 Markdown、LICENSE 等辅助文档覆盖了从代码到部署说明的完整环节。工程按 eladmin-logging、eladmin-common、sql、eladmin-generator、eladmin-system 等模块组织从日志、公共工具、代码生成器到系统主体分层清晰适合作为企业级 Spring Boot 项目的学习范本或二次开发基础。已有 235 人学习下载借助其中的代码生成器模板和分层架构可掌握模块化拆分、模板引擎应用、配置分离与数据库脚本管理方式为勤务保障类系统开发提供直接参考。1. 勤务保障系统为什么值得用 Java 重新做一套源码勤务保障系统在医院后勤、消防大队、政务值班中心这类场景里看起来只是“排个班、派个单、记个物资”但真正落地时你会发现排班规则各科室各不一样派单要管优先级和超时物资申领要连着审批流。一套代码如果没有把状态机、权限边界和并发冲突提前设计好上线三个月后就会被改成一团乱麻。Java 在这个领域里仍然是中后台最稳的选择——Spring Boot 的生态成熟MyBatis 对复杂查询友好Spring Security 能直接支撑多角色细粒度权限。这篇内容就围绕“设计源码”这个视角从数据库结构、核心业务实现、权限模型到本地验证给出你完全可以照着敲的一套方案。适合正在做毕业设计、或者接手类似管理系统的后端工程师不管你是新人还是干了五六年里面关于状态字段、事务失效和索引的坑都值得再看一眼。2. 从需求到表结构勤务保障系统的领域模型与数据库设计2.1 勤务保障系统的核心业务对象人员、任务、物资、车辆勤务保障系统这个词在不同行业里管的东西不太一样但往回抽一层核心对象就四样人、任务、物资、车辆。人是要排班和被考核的任务是临时派下去的指令比如“今晚三号门岗值班”“明天上午送检修工具”物资是消耗品或装备从入库到出库都要留痕车辆是后勤里最容易忽略的一环车辆状态不透明派车单和实际路线对不上是常见事故。先把这四类对象画出来数据库表就好设计了。我在动手建表前一般先列一张领域关系清单用业务语言描述比如业务关系说明人员 ↔ 勤务计划一个人员可以参与多个计划一个计划包含多个人员但同一天同一时段不能重复任务订单 ↔ 人员一个任务有一个指派人跟踪处理人即执行者人员 ↔ 物资申领一个人员可以发起多次申领每次申领对应一种物资任务 ↔ 车辆外勤任务可关联车辆车辆状态从空闲变为使用中这张清单决定了一对多还是多对多。项目里我一般把“勤务计划”和“任务订单”拆成两张表因为计划是周期性的、提前排的任务订单是实时的、按需发的。如果混在一起后面统计“本周计划完成率”和“临时任务响应时长”会非常难受。2.2 用 MySQL 设计五张核心表勤务计划表、任务派发表、物资台账表数据库选 MySQL 8.x字符集 utf8mb4引擎 InnoDB。下面是五张核心表的建表 SQL这套结构能覆盖 80% 的勤务保障场景。先看人员表和勤务计划表CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, user_no VARCHAR(32) NOT NULL UNIQUE COMMENT 工号, username VARCHAR(64) NOT NULL COMMENT 姓名, password_hash VARCHAR(128) NOT NULL COMMENT 密码哈希, dept_id BIGINT NOT NULL COMMENT 所属部门, phone VARCHAR(20) DEFAULT NULL COMMENT 手机号, status TINYINT NOT NULL DEFAULT 1 COMMENT 1在职 0离职, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_dept (dept_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT人员表; CREATE TABLE duty_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_date DATE NOT NULL COMMENT 班次日期, shift_type TINYINT NOT NULL COMMENT 1白班 2夜班 3备勤, user_id BIGINT NOT NULL COMMENT 值班人员ID, task_type TINYINT NOT NULL DEFAULT 1 COMMENT 任务类型1门岗 2巡逻 3值班室, remark VARCHAR(255) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_date_shift (schedule_date, user_id, shift_type), KEY idx_date (schedule_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT勤务计划表;uk_user_date_shift这个唯一索引是排班不冲突的第一道防线数据库层面保证了同一个人同一天同一种班次只能插入一次。代码里就算有并发请求也会在数据库层被拦下来。任务派发表和物资相关表CREATE TABLE task_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(40) NOT NULL UNIQUE COMMENT 业务单号幂等键, title VARCHAR(128) NOT NULL COMMENT 任务标题, content TEXT COMMENT 任务内容, priority TINYINT NOT NULL DEFAULT 2 COMMENT 1紧急 2普通 3低, assignee_id BIGINT NOT NULL COMMENT 指派人ID, creator_id BIGINT NOT NULL COMMENT 创建人ID, vehicle_id BIGINT DEFAULT NULL COMMENT 关联车辆ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待接单 1执行中 2已完成 3已取消, start_time DATETIME DEFAULT NULL, end_time DATETIME DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_assignee (assignee_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT任务派发表; CREATE TABLE material ( id BIGINT PRIMARY KEY AUTO_INCREMENT, material_no VARCHAR(40) NOT NULL UNIQUE COMMENT 物资编码, name VARCHAR(128) NOT NULL COMMENT 物资名称, spec VARCHAR(64) DEFAULT NULL COMMENT 规格型号, stock INT NOT NULL DEFAULT 0 COMMENT 当前库存, unit VARCHAR(16) NOT NULL DEFAULT 件 COMMENT 单位, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT物资台账表; CREATE TABLE material_apply ( id BIGINT PRIMARY KEY AUTO_INCREMENT, apply_no VARCHAR(40) NOT NULL UNIQUE, user_id BIGINT NOT NULL COMMENT 申领人, material_id BIGINT NOT NULL COMMENT 物资ID, apply_count INT NOT NULL COMMENT 申领数量, reason VARCHAR(255) DEFAULT NULL COMMENT 申领原因, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待审批 1通过 2驳回, approve_user_id BIGINT DEFAULT NULL COMMENT 审批人, approve_time DATETIME DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_material (material_id), KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT物资申领表;这些表都做了两件事一是每个表都有业务单号字段并加唯一索引方便做幂等二是状态字段用TINYINT加注释而不是写字符串。用整数存状态的好处是省空间、查询快配合 Java 里的枚举类可读性一样好。坏处是如果团队不看注释时间久了会忘了 2 代表什么所以实体类里一定要写枚举映射。2.3 状态机字段与时间精度为什么用 tinyint 和 datetime勤务保障系统里最容易被新手弄错的就是状态字段和时间字段。任务状态不是简单地来一个status就完事它是有流转路径的待接单 → 执行中 → 已完成或者待接单 → 已取消。这种字段应该设计成显式的状态机在 Java 层用一个枚举类管理而不是散落着魔法数字。我一般用的是tinyint 枚举类映射public enum TaskStatusEnum { PENDING(0, 待接单), RUNNING(1, 执行中), FINISHED(2, 已完成), CANCELED(3, 已取消); private final int code; private final String desc; // 构造函数和 getter 省略 }数据库层面是整数业务逻辑层用枚举前端传回字符串三层之间转换统一放在 DTO 里。时间字段优先用DATETIME如果涉及分布式部署、多个时区直接上TIMESTAMP配合time_zone配置。我的习惯是业务时间用DATETIME创建和更新时间用DEFAULT CURRENT_TIMESTAMP自动维护Java 实体类里统一用LocalDateTime不要再用java.util.Date否则 MyBatis 的类型处理器很容易踩时区偏移的坑。另一个细节是status字段的默认值和展示值。很多系统为了省事逻辑删除和状态混在一起比如用status -1表示已删除这会导致所有查询都要写status -1索引也容易失效。建议逻辑删除单独用deleted字段状态字段只管业务状态。2.4 演示建表 SQL 与索引注意事项上面几段 SQL 示例里索引选择的原则是等值查询多的字段建普通索引联合唯一约束建唯一索引。比如duty_schedule表查询经常是“某天排了哪些人”和“某个人在某段时间内排了多少班”所以索引应该是(schedule_date)和(user_id, schedule_date)而不是单独给schedule_date和user_id各建一个索引。联合索引尽量把区分度高的字段放前面。对于task_order表状态字段区分度低不建议单独建索引应该建(status, create_time)联合索引这样统计“今日待办任务”时可以直接走索引下推。这些细节在数据量小时感觉不出来等表数据到百万级SQL 慢查就都冒出来了。后面的源码里Mapper 的查询条件也要按索引顺序写否则即使有索引因为隐式类型转换或函数包裹也会失效。3. Spring Boot 分层实现Service 层处理勤务逻辑的关键代码3.1 项目包结构划分controller / service / mapper / modelJava 后端项目无论你是用 Maven 还是 Gradle分包方式直接影响后续维护成本。勤务保障系统我建议按模块分包而不是按技术层分包。也就是controller下面继续分schedule、task、material、vehicle而不是一个平铺的 20 个 Controller 文件堆在同一个包里。这样在 IDE 里查找、在 Spring 组件扫描时都更清晰。一个标准的包结构示例com.example.duty ├── controller │ ├── schedule │ ├── task │ ├── material │ └── auth ├── service │ ├── ScheduleService.java │ ├── TaskOrderService.java │ └── MaterialApplyService.java ├── mapper │ ├── ScheduleMapper.java │ ├── TaskOrderMapper.java │ └── MaterialApplyMapper.java ├── model │ ├── entity │ ├── dto │ └── enums ├── config │ ├── SecurityConfig.java │ └── MybatisPlusConfig.java └── common ├── exception └── result代码写到这里entity类用TableName注解映射表名字段用TableId和TableField做映射。这里有个小原则实体类字段命名统一用驼峰数据库字段统一用下划线然后通过mybatis-plus.configuration.map-underscore-to-camel-casetrue自动转换。不要手动给每个字段写TableField映射除非有特殊字符。3.2 生成勤务排班表的算法轮转 冲突检测勤务计划模块最核心的功能是自动排班。排班算法不需要上什么复杂的人工智能最常见的做法是“轮转 冲突检测”。所谓轮转就是基于人员列表按顺序循环分配保证每个人在一个周期内班次数尽量平均。冲突检测则要检查三个方面同一天不能同时有白班和夜班、连续值班不能超过 N 天、节假日权重可以调整。下面这段代码是排班生成的最小实现public ListDutySchedule generateSchedule(LocalDate startDate, LocalDate endDate, ListLong userIds, int shiftCountPerUser) { // 在指定日期范围内轮转分配班次 ListDutySchedule result new ArrayList(); int userIndex 0; for (LocalDate date startDate; !date.isAfter(endDate); date date.plusDays(1)) { for (int shiftType 1; shiftType 2; shiftType) { // 1白班 2夜班 Long userId userIds.get(userIndex % userIds.size()); // 冲突检测同一个人同一天不能已有相同班次 if (hasConflict(date, shiftType, userId)) { continue; } DutySchedule schedule new DutySchedule(); schedule.setScheduleDate(date); schedule.setShiftType(shiftType); schedule.setUserId(userId); result.add(schedule); userIndex; } } return result; } private boolean hasConflict(LocalDate date, int shiftType, Long userId) { LambdaQueryWrapperDutySchedule wrapper Wrappers.lambdaQuery(); wrapper.eq(DutySchedule::getScheduleDate, date) .eq(DutySchedule::getUserId, userId) .eq(DutySchedule::getShiftType, shiftType); return scheduleMapper.selectCount(wrapper) 0; }逻辑说明外层按日期循环每一天的白班和夜班分别从userIds里按顺序取一个人userIndex持续累加这样下一天会接着前一天的位置继续轮转人员列表自然交替。hasConflict在插入前检查唯一索引对应的组合是否已存在解决重复调用接口导致的数据重复。实际生产里不能只靠代码查库必须和数据库唯一索引配合。参数说明shiftCountPerUser可以用来控制周期内每个人的最大班次数当某个人达到上限时跳过这个人这就需要统计每个用户的排班数量并按剩余可排人员继续轮转。这里的顺序要注意先轮转再检测而不是先检测碰运气。如果想调整权重可以在userIds里按权重重复放入同一个人但注意这样会破坏“连续值班不超过 N 天”的约束因此冲突检测里还需要增加// 检查周日到周一连续值班天数假设值班结束后需要休息 8 小时3.3 派单接口的幂等性与并发控制任务派发经常被客户端重复提交尤其是网络超时后用户猛点提交按钮同一个任务就会插两条。解决方案是前端按钮置灰后端做幂等。后端最常见的设计是业务表带唯一单号order_no保存前先查一次插入时捕获唯一键冲突异常。Transactional(rollbackFor Exception.class) public TaskOrder createTaskOrder(TaskOrderCreateDTO dto) { String orderNo TK System.currentTimeMillis() RandomUtil.randomNumbers(4); TaskOrder order new TaskOrder(); order.setOrderNo(orderNo); order.setTitle(dto.getTitle()); order.setContent(dto.getContent()); order.setPriority(dto.getPriority()); order.setAssigneeId(dto.getAssigneeId()); order.setStatus(TaskStatusEnum.PENDING.getCode()); try { taskOrderMapper.insert(order); } catch (DuplicateKeyException e) { throw new BusinessException(单号重复请重试); } return order; }这里的Transactional必须加在 public 方法上而且不能同类内部调用否则事务注解会失效——这是 Spring 的 AOP 代理特性决定的写代码时要记住这一点很多线上问题都出在“自调用导致事务失效”。业务场景如果要求高并发下同一个 assignee 同时只能有一个待接单任务可以在任务表上再加一个(assignee_id, status)的部分唯一索引。但注意MySQL 不允许对带默认值的字段直接建部分索引这里需要用生成列或者应用层加分布式锁后者实现见 5.2 节。3.4 MyBatis 动态 SQL 处理多条件查询任务列表页面通常有多个筛选项时间范围、状态、指派人、任务类型。这种场景用 MyBatis 的动态 SQL 最合适可以利用where标签自动去掉多余的AND也避免用字符串拼接 SQL 造成注入漏洞。select idselectTaskList resultTypecom.example.duty.model.entity.TaskOrder SELECT * FROM task_order where if teststatus ! null AND status #{status} /if if testassigneeId ! null AND assignee_id #{assigneeId} /if if teststartTime ! null AND create_time gt; #{startTime} /if if testendTime ! null AND create_time lt; #{endTime} /if /where ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} /select参数说明status和assigneeId是精确匹配直接用等值判断时间范围条件里的和在 XML 里要转义成gt;和lt;否则 XML 解析报错。分页建议使用 PageHelper 或者 MyBatis Plus 的分页插件不要自己拼LIMIT因为分页插件能自动拦截 SQL 并生成 COUNT 语句而且对多表联查的处理更安全。如果统计字段很多比如要统计每个人上周出勤几次可以直接在 Mapper 里用GROUP BY返回一个 DTO 而不是实体。MyBatis 的动态 SQL 还支持foreach处理IN查询但要注意IN列表过长超过 1000 个 MySQL 会报错这时可以拆成多个OR或者分段查询。4. 权限与审批流基于 Spring Security 自定义注解的细粒度控制4.1 角色权限模型RBAC vs ABAC勤务保障系统里权限边界很现实排班管理员能看见所有人的排班表但普通人员只能看自己的物资审批人和任务指派人的权限等级也不同。主流的两种模型RBAC 是基于角色的访问控制ABAC 是基于属性的访问控制。RBAC 在中小系统里够用先建角色表sys_role再建用户-角色关联表sys_user_role再加上角色-权限关联表sys_role_permission。这套模型的优点是容易上手后面接 Spring Security 的GrantedAuthority也很方便。ABAC 会把用户属性、资源属性、环境属性一起算比如“管理员只能修改本单位人员的排班”这种规则在 RBAC 里很难直接表达需要配合数据权限过滤。我的建议是基础权限用 RBAC数据权限用DataScope注解 AOP 在 SQL 层追加部门过滤条件。这样既不在每个 Service 里复制粘贴dept_id判断也不会让权限配置复杂到没人敢动。4.2 用 Spring Security 配置 JWT 登录先说登录流程用户提交账号密码后端验证成功后签发一个 JWT前端每次请求在Authorization头带上Bearer token后端的过滤器解析 token 并设置SecurityContext。Spring Security 里配置的重点是过滤链的顺序和异常处理。Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeHttpRequests() .antMatchers(/api/auth/login).permitAll() .antMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated() .and() .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) .exceptionHandling().authenticationEntryPoint(restAuthenticationEntryPoint()); return http.build(); } }这段配置的关键点csrf().disable()在前后端分离且使用 JWT 时是合理的但如果系统还有页面端直接提交表单的登录入口不要禁 CSRF。SessionCreationPolicy.STATELESS告诉 Spring 不再创建 HttpSession这样每个请求都靠 token 认证。addFilterBefore把自定义的 JWT 过滤器放在用户名密码过滤器之前保证每个请求先被解析。JWT 里不要放敏感信息因为 token 是明文 base64 编码的加签只是防篡改不防读取。我一般只放userId和roles过期时间根据业务定普通后台系统 2 小时勤务系统如果有人要盯任务大屏可以放宽到 12 小时但过期后需要重新登录。4.3 自定义 RequirePermission 注解实现按钮级控制Spring Security 的注解PreAuthorize(hasRole(ADMIN))能解决角色级控制但要实现“只允许物资模块的审批人通过”这种细粒度控制自定义注解更直观。做法是定义一个注解结合 Spring AOP 拦截标注了该注解的方法判断当前用户是否持有指定权限码。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequirePermission { String value(); // 例如 material:approve }切面类Aspect Component public class PermissionAspect { Before(annotation(requirePermission)) public void checkPermission(JoinPoint joinPoint, RequirePermission requirePermission) { Long currentUserId SecurityUtil.getCurrentUserId(); SetString permissions userPermissionService.listByUserId(currentUserId); if (!permissions.contains(requirePermission.value())) { throw new ForbiddenException(无此操作权限); } } }切面只在方法入口拦截所以权限码千万不要拼在 SQL 字符串里否则既不好维护又容易因为拼接错误造成越权。权限码建议集中定义在常量类里例如PermConstants.MATERIAL_APPROVE material:approve。用 AOP 做权限的缺点是在同类自调用时切面不会生效比如一个类里的私有方法调用带注解的公有方法权限检查会跳过去解决办法是把需要校验的权限放到独立的 Service 类中或者让切面支持基于类级别的默认权限配置。4.4 审批流状态变更的常见坑回退与撤回物资申领的审批流虽然是轻量的三个状态待审批、通过、驳回但在状态变更时有两个容易出错的点一是并发审批两个人同时打开同一个申领单都点了通过第二次更新会覆盖第一次的结果二是驳回和撤回的语义没分开撤回是发起人撤销驳回是审批人拒绝。处理并发审批的标准做法是使用乐观锁。在material_apply表里加version字段每次更新时带上版本号UPDATE material_apply SET status 1, approve_user_id #{approveUserId}, version version 1 WHERE id #{id} AND version #{oldVersion}如果更新影响行数为 0说明别人已经改过状态这时抛异常或提示“该申请已被处理”。代码里在 Service 层捕获这个情况配合事务回滚就能避免状态被二次覆盖。审批流里状态回退也要注意如果申领物资已经出库就不能再允许驳回需要在代码里判断出库单是否存在且已执行否则会产生负库存。5. 源码组织与验证从本地启动到接口自测的完整清单5.1 源码目录结构一览设计源码和普通代码的区别在于你要让接手的人能快速找到“排班规则在哪里改”“状态枚举在哪里加”。推荐直接按模块分目录但每个模块内保持固定的三层结构。完整的源码目录可以参考下面的形式duty-system ├── pom.xml ├── sql │ └── init.sql └── src/main/java/com/example/duty ├── DutyApplication.java ├── config ├── controller │ ├── ScheduleController.java │ ├── TaskOrderController.java │ └── MaterialApplyController.java ├── service │ └── impl ├── mapper ├── model │ ├── entity │ ├── dto │ └── vo └── common如果你拿到的是别人的源码第一步不要急着跑起来先看sql/init.sql里有没有测试数据和默认账号再看application.yml里的数据库连接和 Redis 配置。很多源码启动报错不是代码的问题是环境变量缺失。5.2 用 Postman/Apifox 验证勤务保障核心链路本地启动项目后用 Apifox 建一个“勤务保障”集合按照真实业务链路依次调用登录获取 token → 生成排班 → 创建任务 → 申领物资 → 审批通过。每一步的断言要检查 HTTP 状态码和响应体里的code字段。下面是一条创建任务的 curl 示例curl -X POST http://localhost:8080/api/task/create \ -H Content-Type: application/json \ -H Authorization: Bearer eyJhbGciOiJIUzI1NiJ9... \ -d { title: 东门岗亭夜间巡检, assigneeId: 12, priority: 1, content: 检查照明设备和门禁 }如果返回error: 500优先看控制台的异常堆栈如果是403先检查 token 是否过期再看用户角色是否被正确加载。我一般会在application.yml里开启logging.level.com.example.duty.mapperdebug让 MyBatis 打印 SQL排查参数传递和数据条数不对的问题。并发测试可以直接用 Apifox 的“批量执行”功能设置 50 次重复请求观察task_order表里是否出现了重复的order_no。如果重复了说明幂等键没有生效去数据库检查唯一索引是否创建成功以及 Java 代码里是否捕获了DuplicateKeyException后继续执行了后续逻辑。另一个需要验证的是并发审批把两个浏览器标签页同时打开同一个待审批物资单分别在两个页面点击通过看第二次提交是否被乐观锁拦截。提示测试时把数据库事务隔离级别保持默认的REPEATABLE_READ即可不要为了“性能”随便改成READ_UNCOMMITTED勤务数据一旦脏读会直接导致物资台账对不上。5.3 三个容易出错的地方时区、事务失效、懒加载第一个坑是时区。Java 8 的LocalDateTime本身不带时区但 JDBC 连接 MySQL 时serverTimezone参数必须和 MySQL 服务器一致。如果你在jdbcUrl里写serverTimezoneAsia/Shanghai而服务器实际是 UTC所有日期时间都会偏 8 小时。保险做法是 JVM 启动参数加-Duser.timezoneAsia/Shanghai数据库连接串里也要显式写上时区。第二个坑是事务失效。很多人习惯在 Service 内部调用自己的另一个方法比如generateSchedule里调用checkConflict如果后者是 private 的还没问题但如果是 public 且被Transactional标注Spring 的代理不会拦截 self-invocation注解直接失效。所以我把所有事务边界都放在 impl 类的 public 方法上且不要把事务类的方法拆到另一个被同一类调用的方法上。第三个坑是懒加载。实体类里配置了ManyToOne或者 MyBatis 的association懒加载在 controller 层直接序列化成 JSON 时会报LazyInitializationException。最简单的方法是关闭懒加载直接使用left join查询出 DTO如果一定要懒加载就在事务方法里提前访问关联对象让数据加载完再返回。勤务保障系统里这种关联通常发生在“任务订单查询显示指派人姓名”的场景建议直接写一条连表 SQL 返回 VO不要返回实体再逐条翻译。本文还有配套的精品资源点击获取