尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Spring Boot 3整合MyBatis-Plus:CRUD、分页与逻辑删除
简介这是一份面向 Java Web 开发者的 Spring Boot 3 与 MyBatis-Plus 整合示例项目适合正在学习框架整合、需要快速搭建数据访问层的初中级开发者参考。工程围绕依赖引入、数据源配置、实体注解、Mapper 接口、Service 与 Controller 分层展开重点展示如何借助 BaseMapper、条件构造器、自动填充和分页思路减少重复 CRUD 代码并保留清晰的项目结构便于对照修改。读者可据此理解 Spring Boot 3 自动装配与 MyBatis-Plus 通用 CRUD 的协作关系并掌握字段映射、条件查询和基础测试的常见写法。压缩包共 24 个文件包含 7 个 Java 源码、7 个编译后的 class、2 个 YAML 配置及 pom.xml、properties、jar、说明文档等整体约 85KB标准 Maven 目录便于导入 IDE 后直接查看配置关系和运行效果。已有 1435 人学习下载可作为整合流程、注解用法与基础增删改查验证的参考样例帮助读者理解 Spring 容器自动装配、MyBatis-Plus 无缝接入以及分层开发的实践方式。1. 项目定位Spring Boot 3 与 MyBatis-Plus 的整合能省掉多少手写 DAO接手一个中后台项目的二期需求时真正拖时间的往往不是业务判断而是那几十张表来回写的 select、insert、update 语句。Spring Boot 3 配上 MyBatis-Plus 的整合方案核心价值就是用一套BaseMapper加条件构造器把单表 CRUD 的模板代码压到接近零同时保留手写 XML 应付联表统计的出口不像全 ORM 那样把 SQL 藏成黑匣子。这套组合适合正在做管理系统、内部工具、数据运营平台的团队也适合刚从 JDK 8 迁到 JDK 17、想找一个比 JPA 更贴近原生 SQL 的持久层方案的开发者。下面按工程骨架、核心链路、增强能力、踩坑排查、二次开发的顺序把一份能直接跑起来的整合工程拆开讲。2. 工程骨架搭建JDK 17 下的依赖坐标与 yml 配置一次配齐动手之前先把版本关系理顺这一步省下来的时间远大于敲代码的时间。Spring Boot 3 把基线抬到了 JDK 17javax.*全部换成jakarta.*MyBatis-Plus 也因此拆出了专门的 Boot3 Starter。很多人第一次整合翻车不是代码写错而是把 Boot2 时代的mybatis-plus-boot-starter直接粘过来启动时抛一堆类找不到的异常然后开始怀疑人生。2.1 版本三角JDK 17、Spring Boot 3 与 MyBatis-Plus 的对应关系这三者的关系是硬约束不是建议。Spring Boot 3.x 编译产物是 class 文件版本 61JDK 8 和 11 直接编译不过MyBatis-Plus 从 3.5.3 之后才开始提供 Boot3 专用的 starter包名是mybatis-plus-spring-boot3-starter和原来的mybatis-plus-boot-starter是两个不同的 artifact。Spring Boot 3.2 之后还引入了对虚拟线程的支持如果你打算开spring.threads.virtual.enabledtrue那配套的连接池和 MyBatis 的SqlSession使用方式也要一并检查别只改一个开关就上线。组件选型要求不满足时的典型现象JDK17 及以上编译报「无效的目标发行版」或类文件版本不匹配Spring Boot3.0 及以上引入 Boot3 starter 后 Bean 装配失败MyBatis-Plus使用 Boot3 专用 starter启动抛NoClassDefFoundError或缺FactoryBean类型驱动mysql-connector-j新坐标旧坐标mysql:mysql-connector-java已停止维护JSqlParser分页插件依赖3.5.9 起需单独引入分页不生效、count 语句生成异常我一般的做法是在父 pom 里用properties把这三个版本号集中管理子模块只引 artifact 不写版本。这样后面升级的时候改一处就行不会出现某个子模块偷偷用了老版本的情况。2.2 pom.xmlstarter 该引哪一个、哪些可以省依赖清单里真正必须的只有四五个剩下的都是锦上添花。Web 场景引spring-boot-starter-web持久层引 Boot3 版 MyBatis-Plus Starter数据库驱动用新坐标参数校验用spring-boot-starter-validationLombok 看团队习惯——如果团队里有人用 IDEA 的编译插件配置不统一Lombok 反而是个麻烦源我倾向在实体类里手写 getter/setter 或者用 record 做 VO。properties java.version17/java.version !-- Spring Boot 3.x 的父版本按你本地仓库实际可用的稳定版填写 -- spring-boot.version3.2.5/spring-boot.version !-- MyBatis-Plus 需使用 3.5.3 以上的 Boot3 适配版本 -- mybatis-plus.version3.5.7/mybatis-plus.version /properties dependencies !-- Web 能力Controller 层与 JSON 序列化都靠它 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 关键Boot3 专用 starter不能用 mybatis-plus-boot-starter -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version${mybatis-plus.version}/version /dependency !-- MySQL 驱动新坐标runtime 作用域即可代码里不该出现驱动类名 -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency !-- 参数校验NotNull / Size 之类 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency /dependencies这段配置里有两个参数需要单独说。第一个是${mybatis-plus.version}写在 properties 里是为了让所有子模块共用同一个版本避免 Maven 依赖调解时把 MyBatis 核心包降级。第二个是mysql-connector-j的runtime作用域它意味着编译期你拿不到驱动类也就不可能在业务代码里new Driver()这个约束本身就是一种防呆设计。如果你的 MyBatis-Plus 版本在 3.5.9 以上还要再补一个 JSqlParser 模块否则分页插件的PaginationInnerInterceptor会因为找不到解析器而在运行期出问题!-- 3.5.9 起 JSqlParser 被拆成独立模块分页、多租户等插件依赖它 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-jsqlparser/artifactId version${mybatis-plus.version}/version /dependency2.3 application.yml数据源、Mapper 扫描与全局策略配置文件是整合工程里最容易写错、也最难在报错信息里定位的部分。数据源部分注意时区和编码serverTimezone不写的话MySQL 8 在某些容器镜像下会把LocalDateTime偏移 8 小时allowPublicKeyRetrievaltrue是新版驱动在非 SSL 连接下做密码校验的必要开关少了它会报「Public Key Retrieval is not allowed」。server: port: 8081 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/demo_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 123456 hikari: # 连接池上限按数据库 max_connections 反推别随手写 50 maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 3000 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: # XML 位置多个模块时用 classpath* 前缀避免只扫到第一个 jar mapper-locations: classpath*:/mapper/**/*.xml # 实体包别名XML 里写 resultType 可以省略全限定名 type-aliases-package: com.example.demo.module.*.entity configuration: # 数据库下划线转 Java 驼峰关掉它字段映射会大面积对不上 map-underscore-to-camel-case: true # 开发期打开生产务必注释掉否则 SQL 日志会把敏感参数全打出来 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: banner: false db-config: # 主键策略ASSIGN_ID 走雪花算法适合分布式自增表改成 AUTO id-type: assign_id # 逻辑删除三件套配好之后 BaseMapper 会自动拼条件 logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0mapper-locations里的classpath*:前缀容易被忽略。单模块项目写classpath:没问题一旦拆成多模块、XML 分散在不同 jar 里就会变成「Mapper 找到了但方法找不到」的玄学问题报错是Invalid bound statement (not found)。这个坑我踩过不止一次后来一律用classpath*:。log-impl选StdOutImpl只是图省事正规点的做法是交给日志框架把com.example.demo.module这个包的日志级别调到 debugSQL 就会进你的日志文件方便和业务日志对照时间线。2.4 启动类与最小可运行验证骨架搭完先别急着写业务跑一个最小闭环确认链路是通的。启动类上加MapperScan扫描路径要覆盖所有模块下的 mapper 包如果每个 Mapper 接口都加Mapper也行但多模块项目里容易漏集中在启动类上写一次更稳。SpringBootApplication // 多模块时用通配避免新增模块忘记加扫描路径 MapperScan(com.example.demo.module.**.mapper) public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }验证顺序建议按「数据源 → 表映射 → 单表查询」三步走。先写一个只有一个selectById的接口用手工插入的一行数据打通确认返回的对象字段都正确再往上叠条件构造和分页。反过来先写复杂联表查询出问题时你要同时排查数据源、映射、SQL、分页四处效率反而低。提示第一次跑通之后立刻把log-impl打开看一眼真实执行的 SQL确认逻辑删除条件和分页 limit 都拼上去了比读十遍文档管用。3. 核心链路拆解一次分页查询从 Controller 走到 SQL 的完整路径把一条请求拆开看MyBatis-Plus 做的事其实很朴素Service 层把条件对象翻译成 SQL 片段Mapper 层执行插件层在 SQL 出去之前做改写。搞清楚这个顺序后面无论是加拦截器还是排查「条件没生效」都有明确的落点。3.1 实体与表结构的映射TableName、TableId 与驼峰规则实体类是映射的起点。类名和表名不一致时用TableName显式声明主键用TableId指定类型普通字段靠全局的驼峰转换自动对齐。有几个注解值得单独记TableField(exist false)用来标记「这个字段数据库里没有」比如聚合出来的计数TableField(fill ...)配合自动填充器使用TableLogic可以覆盖全局的逻辑删除配置适用于个别表字段名不同的情况。Data TableName(t_order) public class Order { // 雪花 IDASSIGN_ID 策略下由 MyBatis-Plus 生成不依赖数据库自增 TableId(type IdType.ASSIGN_ID) private Long id; private String orderNo; private Long userId; private BigDecimal amount; // 只在插入时填充 TableField(fill FieldFill.INSERT) private LocalDateTime createTime; // 插入和更新都填充 TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; // 逻辑删除标记查询时自动追加 deleted 0 TableLogic private Integer deleted; // 乐观锁版本号配合 OptimisticLockerInnerInterceptor Version private Integer version; // 数据库无此列仅用于承接联表查询的附加信息 TableField(exist false) private String userName; }参数说明上IdType.ASSIGN_ID生成的是Long型雪花 ID前端接收时要注意 JS 的Number精度只有 53 位直接返回会丢精度常见做法是给Long字段加JsonSerialize(using ToStringSerializer.class)转成字符串输出。IdType.AUTO则依赖数据库自增分库分表场景下不能用。Version字段在数据库里要有默认值 0否则第一次更新时null 1会出问题。3.2 Mapper 与 Service 两层BaseMapper 和 IService 该怎么选MyBatis-Plus 给了两套 API。BaseMapperT是最薄的一层方法名直白selectById、selectList、insert、updateById返回实体或集合IServiceT在其上再封一层加入了批量操作、saveOrUpdate、链式调用lambdaQuery()。我的选择标准是Controller 直接调 ServiceService 内部对单表操作优先用lambdaQuery()链式写法可读性好需要联表或复杂统计时在 Mapper 里加自定义方法配 XMLService 只做编排。public interface OrderMapper extends BaseMapperOrder { /** * 自定义分页查询联表取用户名条件由 XML 动态拼 * 注意分页场景下 IPage 必须是第一个参数否则插件不生效 */ IPageOrderVO selectOrderPage(IPage? page, Param(query) OrderQuery query); } Service RequiredArgsConstructor public class OrderServiceImpl extends ServiceImplOrderMapper, Order implements OrderService { // 构造器注入 MapperServiceImpl 里已经持有 baseMapper此处仅作演示 private final OrderMapper orderMapper; Override public IPageOrderVO pageWithUser(OrderQuery query) { // 页码从 0 开始计这点和很多前端组件的 1 起始不一致需要在 Controller 层减一 PageOrderVO page new Page(query.getPageNum(), query.getPageSize()); return orderMapper.selectOrderPage(page, query); } }这里有个IPage位置的细节值得强调自定义分页方法里IPage参数必须放在参数列表第一个插件才会去解析它。放在第二个位置的现象是查询能跑通但返回结果里total是 0、records是全量数据很容易被误判成「分页插件没注册」。ServiceImpl提供的baseMapper字段已经能覆盖大部分场景额外注入同类型 Mapper 只是个人习惯按团队规范来。3.3 分页与联表Page 对象 自定义 XML 的混合写法分页要生效必须注册拦截器。这段配置是整合工程的核心插件顺序错了会出现「乐观锁不生效」或「count 语句被改坏」的问题。官方建议的顺序是多租户、动态表名在前分页和乐观锁居中防全表更新放最后。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 分页插件显式指定方言避免多数据源时猜错 PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); // 单页上限防止前端传 pageSize100000 把库打挂 pagination.setMaxLimit(500L); // 超过最大页时返回空列表而不是回到首页 pagination.setOverflow(false); interceptor.addInnerInterceptor(pagination); // 乐观锁只对带 Version 的实体生效 interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); // 防全表更新删除拦下没有 where 条件的 update/delete interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); return interceptor; } }setMaxLimit(500L)这个上限值得当作默认配置。后台管理页面里常见「导出全部」按钮如果分页参数被前端随手写成pageSize999999一次查询就能把连接池占满。BlockAttackInnerInterceptor则是给手滑上的保险update(new Order())这种写法会直接被拦下来抛异常比等着 DBA 找上门强。联表查询的 XML 写法上MyBatis-Plus 并不会替你拼逻辑删除条件这条得自己加select idselectOrderPage resultTypecom.example.demo.module.order.vo.OrderVO select o.id, o.order_no, o.amount, o.create_time, u.nickname as userName from t_order o left join t_user u on u.id o.user_id !-- 逻辑删除条件在手写 SQL 里不会自动追加必须显式写 -- where o.deleted 0 if testquery.userId ! null and o.user_id #{query.userId} /if if testquery.keyword ! null and query.keyword ! and o.order_no like concat(%, #{query.keyword}, %) /if order by o.create_time desc /selectresultType指向 VO 时字段映射依赖驼峰转换所以 SQL 里的userName别名要和 VO 的字段名对上或者干脆用as user_name让它自动转换。concat(%, #{keyword}, %)这种写法比${}安全后者有注入风险除非是排序字段这种必须动态拼接的场景——那种情况一定要在代码里做白名单校验。3.4 条件构造器LambdaQueryWrapper 的常用姿势与易错点条件构造器是 MyBatis-Plus 里使用频率最高的 API。LambdaQueryWrapper用方法引用取字段名避免了硬编码字符串带来的「改字段名忘记改 SQL」问题QueryWrapper写字符串字段名更灵活但重构时没有编译期保护。public ListOrder searchOrders(OrderQuery query) { LambdaQueryWrapperOrder wrapper Wrappers.lambdaQuery(Order.class) // 等值条件字段非空才拼避免 where user_id null .eq(query.getUserId() ! null, Order::getUserId, query.getUserId()) // 范围条件 .ge(query.getStartTime() ! null, Order::getCreateTime, query.getStartTime()) .le(query.getEndTime() ! null, Order::getCreateTime, query.getEndTime()) // 模糊条件likeRight 生成 xx%like 生成 %xx%前者能走索引 .likeRight(StringUtils.hasText(query.getOrderNo()), Order::getOrderNo, query.getOrderNo()) // 金额区间用 between两端都为空时不拼接 .between(query.getMinAmount() ! null query.getMaxAmount() ! null, Order::getAmount, query.getMinAmount(), query.getMaxAmount()) .orderByDesc(Order::getCreateTime); return list(wrapper); }第一个参数是布尔条件这是条件构造器最好用的设计一行代码省掉一堆if。需要注意的坑有三个一是like与likeRight的选择左模糊%xx用不上索引订单号这类前缀明确的字段一律用likeRight二是eq传null值时布尔条件为 true 但参数是 null会生成where user_id null结果永远为空集所以要么用布尔判断要么保证入参非空三是orderByDesc后面可以继续串联但一旦调用过last()或者子查询后续条件就不再保证顺序复杂查询老实写 XML。另外wrapper是无状态构建、有状态使用同一个 wrapper 传给两个查询会累积条件我在 Service 里从来不跨方法复用。4. 常用增强能力逻辑删除、字段自动填充与乐观锁的落地写法骨架和主链路通了之后接下来这三件事基本每个项目都会用到。它们的共同点是「配好了几乎无感配错了排查成本很高」所以每一项都要理解它在 SQL 层面到底改了什么。4.1 逻辑删除一条配置替代手写 deleted 0逻辑删除在全局配置里写好之后BaseMapper的所有单表方法都会自动带条件selectById变成select ... where id ? and deleted 0deleteById变成update ... set deleted 1 where id ?。业务代码里一行都不用改这是它最舒服的地方。// 全局配置了 logic-delete-field 之后实体上加注解即可 TableLogic(value 0, delval 1) private Integer deleted;参数含义value是未删除时的值delval是已删除的值如果和全局配置一致可以省略不写。数据库里这个字段必须有默认值 0 且非空否则历史数据是null的话deleted 0条件会把这些行过滤掉表现为「老数据突然查不到了」。这个坑我在一个迁移项目里遇到过加了逻辑删除之后上线前一天发现半年前的数据全部消失最后靠一条update t_order set deleted 0 where deleted is null补回来。注意逻辑删除只对单表 API 自动生效手写 XML 和Select注解里的 SQL 都要自己加deleted 0否则已删除的数据会从联表查询里漏出来。4.2 自动填充MetaObjectHandler 处理创建时间与操作人创建时间、更新时间、创建人这些字段如果每次 insert 都手写一遍setCreateTime(LocalDateTime.now())早晚有人漏。MetaObjectHandler就是给这件事准备的钩子插入和更新时各触发一次。Component public class AuditMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { LocalDateTime now LocalDateTime.now(); // strictInsertFill 只在字段为 null 时填充避免覆盖调用方显式设置的值 this.strictInsertFill(metaObject, createTime, LocalDateTime.class, now); this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, now); // 操作人从当前登录上下文取这里用占位方法示意 this.strictInsertFill(metaObject, createBy, Long.class, currentUserId()); } Override public void updateFill(MetaObject metaObject) { // 更新时间在每次 update 时强制刷新 this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } }两个方法名要记住strictInsertFill和strictUpdateFill带 strict 的版本只在该字段值为 null 时填充不带 strict 的版本会无条件覆盖。一般插入用 strict更新时如果业务允许覆盖就用非 strict 的setFieldValByName。还有一点updateFill只在调用updateById(entity)或者update(entity, wrapper)时触发用LambdaUpdateWrapper.set(...)手动指定字段的更新不会走填充器得自己set(Order::getUpdateTime, LocalDateTime.now())。4.3 乐观锁Version 与 updateById 的配合并发修改同一条记录时乐观锁比悲观锁轻量得多。用法是实体上Version标注版本字段拦截器在 update 时自动追加and version ?并把version 1写回。// 业务层先查再改返回值为 0 表示版本冲突 public boolean updateAmount(Long orderId, BigDecimal amount) { Order order getById(orderId); if (order null) { throw new IllegalArgumentException(订单不存在); } order.setAmount(amount); // updateById 会带上 version 条件被别的线程改过则影响行数为 0 return updateById(order); }调用方拿到false时说明数据已经被别人改过可以重试一次重新查一遍再改或者直接返回提示。这里有个常见的误解updateById更新时会把实体里所有非 null 字段都写进去如果你的order对象是前端传上来的残缺对象很可能把库里的其他字段覆盖成 null进而触发FieldStrategy相关的配置。生产代码里我更倾向于用LambdaUpdateWrapper精确指定要改的字段只在需要版本控制的场景配合实体更新。4.4 多数据源dynamic-datasource 的接法与边界单库玩明白之后读写分离或者分业务库的需求就来了。社区里用得最多的是dynamic-datasource-spring-boot3-starter通过DS注解在方法或类上指定数据源本质是AbstractRoutingDataSource的封装。Service DS(slave) // 整个类走从库适合只读的报表服务 public class ReportServiceImpl extends ServiceImplOrderMapper, Order implements ReportService { DS(master) // 方法级注解优先级更高需要强一致读时切回主库 public BigDecimal sumAmount(Long userId) { return baseMapper.sumAmount(userId); } }配置文件里按约定的 key 声明多个数据源主库标记primary: true。需要留意的边界有三条一是DS只对 Spring 代理的方法生效同类内部方法互相调用不走代理注解会失效二是事务内跨数据源切换不生效因为事务绑定的是开启时的连接三是分页插件在多数据源下要显式指定DbType否则方言猜错limit语法可能在非 MySQL 库上直接报错。5. 踩坑排查整合期最常见的五类报错与定位手法整合类的问题有个特点报错信息指向的位置往往不是真正的病灶。下面这几条是我和同事在真实项目里反复遇到的按出现阶段归了三类每条都按现象、原因、解决的顺序写方便对着排查。5.1 启动期类找不到与 Bean 装配失败现象一启动直接抛NoClassDefFoundError: org/mybatis/logging/Logger或者报Invalid value type for attribute factoryBeanObjectType。原因引了 Boot2 时代的mybatis-plus-boot-starter或者项目里同时存在新旧两个 starterMaven 调解后留下的是老版本。解决mvn dependency:tree -Dincludescom.baomidou看一眼实际生效的坐标把mybatis-plus-boot-starter全部替换成mybatis-plus-spring-boot3-starter并在父 pom 里锁定版本。现象二启动报Invalid bound statement (not found)但 Mapper 接口明明存在。原因八成是 XML 没被扫到。mapper-locations写成classpath:在单模块下能用打包成多模块 jar 后就只扫第一个包另一种可能是有 XML 放在src/main/java目录下Maven 默认不打包非资源目录。解决改成classpath*:/mapper/**/*.xml并在 pom 的resources里把**/*.xml加进去。5.2 运行期分页不生效与自动填充失效现象三分页查询返回了全量数据total一直是 0。原因三种可能——拦截器没注册成Bean、自定义方法的IPage参数没放第一个、或者 MyBatis-Plus 版本 3.5.9 以上没引mybatis-plus-jsqlparser。解决先看日志里有没有生成select count(*)没有就是插件没生效有 count 但没 limit就检查参数位置和 JSqlParser 依赖。现象四createTime在插入后一直是 null。原因MetaObjectHandler实现类没加Component或者实体字段没标TableField(fill ...)两者缺一不可还有一种情况是用了saveBatch某些版本下批量插入不走填充器。解决确认实现类在启动类的扫描路径内字段注解补全批量场景在业务层手动设置时间。5.3 数据层时间偏移、字段映射与逻辑删除的连带问题现象五数据库里存的是2024-05-01 10:00:00接口返回变成02:00:00或者反过来。原因JDBC URL 的serverTimezone、Jackson 的time-zone、MySQL 服务的时区三者不一致另一个隐形来源是实体用了java.util.Date而参数用了LocalDateTime转换过程中丢时区。解决三处时区统一成Asia/Shanghai实体统一用LocalDateTimeURL 里显式写serverTimezone。除了上面五条还有两个高频小问题TableField(exist false)忘加导致查询报Unknown column xxx in field listmap-underscore-to-camel-case被误关导致大面积字段为 null。后者的排查有个快捷方式——看返回对象的字段是否全部为 null如果是先怀疑映射全局配置而不是逐个字段检查。6. 二次开发把通用 CRUD 抽成基类并留好改造验证的回头路业务表一多每个 Controller 里那五个方法就成了复制粘贴的重灾区。真正值得抽的其实只有「查详情、分页、新增、改、删」这五件事因为它们足够稳定带业务语义的接口比如「订单发货」一旦塞进基类后面就会变成参数越来越多、谁都不敢改的怪物。我一般抽一个抽象的BaseController用泛型承接 Service 类型具体子类只声明自己的实体和 VO。public abstract class BaseControllerS extends IServiceT, T { // 由子类提供具体 Service 实例避免泛型注入时的歧义 protected abstract S getService(); GetMapping(/{id}) public RT detail(PathVariable Serializable id) { return R.ok(getService().getById(id)); } PostMapping public RBoolean create(RequestBody Validated T entity) { // 单表插入主键由 MyBatis-Plus 按全局策略生成 return R.ok(getService().save(entity)); } PutMapping public RBoolean modify(RequestBody Validated T entity) { // 走 updateById若实体带 Version 会自动做乐观锁校验 return R.ok(getService().updateById(entity)); } DeleteMapping(/{id}) public RBoolean remove(PathVariable Serializable id) { // 配置了逻辑删除时这里是 update 而非 delete return R.ok(getService().removeById(id)); } }子类只要三行继承基类、实现getService()、声明RequestMapping路径。分页这个方法我故意没放进基类因为列表查询的过滤条件几乎每个模块都不一样硬抽出来反而要写一堆泛型参数。真正落地时还会遇到两个需要提前想清楚的点一是基类里的R返回结构要统一错误码和异常处理交给全局RestControllerAdvice别让每个 Controller 各写一套二是参数校验分组新增和修改对id的要求不同用Validated(Update.class)这类分组接口区分比在基类里写if (id null)干净得多。改造的验证方法建议按这个顺序走先保留旧接口用基类新开一组/v2路径用同一批参数分别打新旧接口比对响应 JSON 的字段和顺序再跑一遍写操作重点看逻辑删除后的deleted值、updateTime是否被填充、version有没有自增最后压一轮并发更新同一条记录确认冲突时返回的是业务提示而不是 500。这套流程跑完心里才有底把旧接口下掉。改造点推荐做法验证信号通用 CRUD抽BaseController 泛型 Service新旧接口返回结构一致权限与审计拦截器统一注入操作人createBy、updateTime有值逻辑删除全局配置 XML 显式补条件已删数据在联表查询里消失分页上限setMaxLimit兜底传入超大 pageSize 被截断异常处理全局RestControllerAdvice冲突场景返回业务码而非 500这套东西我从一个只有七张表的小系统一路用到三十多张表的中台中间最大的教训是基类只装「所有表都一样」的逻辑哪怕只有一张表有特殊需求也宁可让那个模块自己写一遍别在基类里加if (type xxx)。基类一旦开始长条件分支就说明抽象错了位置越早拆越好。后来每次动基类之前我都会先把新旧接口的响应抓下来 diff 一遍确认字段一个不差再提交——这个习惯救过我好几次希望你也能少走点弯路希望帮到你。本文还有配套的精品资源点击获取
RELATED

相关推荐

微信OCR工具开发:多引擎支持与性能优化实践

微信OCR工具开发:多引擎支持与性能优化实践

1. 微信OCR屏幕文字提取工具的设计理念这个工具的核心定位是解决移动端场景下的即时文字识别需求。不同于传统OCR软件需要拍照上传再处理的繁琐流程,它直接对接微信生态,实现"所见即所得"的文字抓取体验。我在实际测试中发现,其响应…

📅 2026/10/25 2:44:13
Linux定时任务crontab详解:从基础到实战应用

Linux定时任务crontab详解:从基础到实战应用

1. Linux定时任务与crontab基础认知第一次接触服务器运维时,我被凌晨三点突然响起的报警短信惊醒,手忙脚乱连上服务器才发现是日志切割脚本没有正常执行。那次事故让我深刻认识到定时任务在Linux系统中的重要性。crontab作为Unix/Linux系统最古老的定时任…

📅 2026/10/25 2:44:13
无可挑剔的前端还原:把像素级标准做成工程流程

无可挑剔的前端还原:把像素级标准做成工程流程

impeccable 这个词,直译过来就是“无可挑剔”。我把它用作一套前端还原设计稿的标准代号,听起来有点自恋,但做过的项目越多,我越觉得“接近完美”其实是技术活,不是审美活。前阵子我接手一个跨端活动页,设计…

📅 2026/10/25 2:44:13
MORE NEWS

更多资讯

📰

如何为技术博客提供有效输入以生成高质量内容

我无法根据当前输入生成符合要求的博文。原因如下:项目标题“skills”过于宽泛抽象,未指向任何具体领域、技术、场景或可操作对象(如“Python自动化技能”“UI动效设计技能”“嵌入式调试技能”等),缺乏可解构的技术锚…

📰

技能提升全攻略:从技能拆解到组合效应,打造可迁移的核心竞争力

1. 从“skills”这个词说起:为什么它突然成了硬通货“skills”这个词最近频繁出现在各种讨论里,不是没有原因的。我观察到一个很明显的趋势:过去大家聊的是“你做过什么项目”“你用过什么工具”,现在越来越多的人在问“你具备哪些…

📰

微信小程序真机调试与上线全流程实战指南

简介:本资源是一份面向初学者的微信小程序开发入门教程文档,适用于零基础前端学习者、高校计算机专业学生及希望快速掌握小程序开发的转行开发者。文档系统讲解了从环境搭建、项目创建到页面开发、数据绑定、API调用及最终发布的全流程,涵盖A…

📰

SAP寄售采购业务核心流程与数据表解析

1. SAP寄售采购业务的核心流程解析寄售采购(Consignment)是SAP系统中一种特殊的采购模式,供应商将物料存放在客户仓库,但物权仍归供应商所有。只有当客户实际消耗物料时,才产生应付账款。这种模式常见于JIT&#xff08…

📰

消防隐患AI智能识别:烟火检测与楼道堆物检测落地

小区居民楼里的消防隐患有个很别扭的特点:真出事的时候都是大事,但平时看上去又都是些不起眼的小事。楼道拐角堆两袋旧纸箱、电井门口靠一辆电动车、天井里飘一缕不知道哪来的白烟,人走过去都不会多看一眼。可一旦烧起来,楼道就是…

📰

表格里那行合计,让求和结果多了 95%

版权与内容来源声明 本文为原创整理。文中涉及官方文档、开源仓库、论文与公开报道的内容,均在附表 A 中标注来源;引用官方原文保持原样,不作改写。文中命令、版本号与界面截图以本文成文时的实测/核验结果为准,标注「待验证」的部…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬