Spring Boot 3.4 发布之后,我第一次升级手头项目就卡在了 Swagger 上:旧的 springfox 依赖直接起不来,Mybatis-plus 的 starter 也反复报版本冲突。折腾了两天,最后把整套整合方案从依赖到配置重新理了一遍,才稳定落地。这篇文章就按我实际操作的顺序,把 Spring Boot 3.4 下整合 Swagger(实际是 springdoc-openapi)和 Mybatis-plus 的完整过程讲清楚,包括版本选型、分页插件配置、通用 CRUD 封装、批量写入优化,以及几个我踩过的“必炸坑”。如果你是 Java 后端,准备升级老项目,或者新项目刚开始搭骨架,这份配置可以直接抄作业,中间的坑也基本能帮你避开。
1. 版本选型:先把兼容性问题解决掉
1.1 为什么 Spring Boot 3.x 必须放弃 Springfox
先说结论:Spring Boot 3.4 就是不能用 Springfox,不是配置问题,是底层包名全换了。Spring Boot 3 开始,官方把 Jakarta EE 的命名空间从javax.*换成了jakarta.*,Springfox 最后维护版本还停留在 Servlet API 3.1 时代,里面的类直接引用旧的javax.servlet包,在 Spring Boot 3.4 环境里一启动就报NoClassDefFoundError或者Failed to start bean 'documentationPluginsBootstrapper'。
替代方案很明确:springdoc-openapi。它不是 Swagger 2,而是直接实现了 OpenAPI 3 规范,内置 Swagger UI,只是底层引擎换了一批类。从代码层面看,注解从@Api、@ApiModelProperty变成@Operation、@Schema,刚开始写起来不习惯,但用顺了发现信息量更足,对泛型、文件流、安全配置的支持也比 springfox 好。
我把两个方案的对比整理成了一张表,升级或者新建项目可以直接按这个判断:
| 对比项 | springfox-swagger2 | springdoc-openapi |
|---|---|---|
| Spring Boot 3.x 支持 | 不支持 | 支持 |
| 底层规范 | Swagger 2.0 | OpenAPI 3.0 |
| 常用注解 | @Api、@ApiModelProperty | @Operation、@Parameter、@Schema |
| UI 入口 | /swagger-ui.html | /swagger-ui/index.html |
| 接口分组 | 配置 Docket | GroupedOpenApi Bean |
| 官方维护状态 | 停滞 | 持续更新 |
1.2 版本对照表与工程基础配置
Spring Boot 3.4 对 Java 版本有硬性要求,最低 JDK 17。我本机用的是 JDK 17 + Maven 3.9,生产环境准备用 JDK 21,编译都没问题。如果你还在 JDK 8 或者 11,那得先升级 Java 环境,否则后面所有依赖都拉不动。
当前这套组合我在项目里实测稳定的版本号如下:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| Spring Boot | 3.4.x | 当前最新稳定线 |
| JDK | 17 或 21 | 低于 17 无法运行 |
| springdoc-openapi | 2.7.0 | 官方明确适配 Spring Boot 3.4 |
| Mybatis-plus | 3.5.9 及以上 | 必须用 spring-boot3-starter |
| mysql-connector-j | 由 Spring Boot 管理 | 无需写版本号 |
pom.xml 里最核心的依赖就是下面这几段,注意 Mybatis-plus 的 artifactId 一定要带spring-boot3,老项目里常见的mybatis-plus-boot-starter是给 Spring Boot 2 用的,搬到 3.4 上会直接冲突。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.7.0</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.9</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>另外提醒一句:不要在这个基础上再引入mybatis-spring-boot-starter,那是 Mybatis 官方的 starter,和 Mybatis-plus 功能重叠,两个一起上会出现 SqlSessionFactory 冲突,启动时偶尔能过,运行期分页和批量操作都可能出诡异问题。我见过同事不仔细看依赖树踩了这个,排查了整整半天。
2. Swagger 整合:从依赖到完整可访问的配置
2.1 最小化配置三步走
依赖已经放进去了,接下来要让 Swagger UI 能访问,其实只需要三处设置。
第一步,写一个 OpenAPI 配置类,把文档标题、版本号、全局鉴权信息配好。这里我用的是OpenAPI这个官方对象,替代 Springfox 里的Docket。
package com.example.demo.config; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Contact; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class OpenApiConfig { @Bean public OpenAPI customOpenAPI() { return new OpenAPI() .info(new Info() .title("示例项目 API 文档") .description("Spring Boot 3.4 + Swagger + Mybatis-plus 整合示例") .version("v1.0.0") .contact(new Contact().name("开发团队").email("dev@example.com"))) .addSecurityItem(new SecurityRequirement().addList("BearerAuth")) .components(new Components() .addSecuritySchemes("BearerAuth", new SecurityScheme() .name("Authorization") .type(SecurityScheme.Type.HTTP) .scheme("bearer") .bearerFormat("JWT"))); } }第二步,在 application.yml 里指定 Swagger UI 的访问路径和分组信息。
springdoc: swagger-ui: path: /swagger-ui.html operations-sorter: method tags-sorter: alpha api-docs: path: /v3/api-docs第三步,启动项目,浏览器访问http://localhost:8080/swagger-ui.html,能看到 Swagger UI 页面就说明配置成功了。注意 3.x 的 springdoc 默认真实路径是/swagger-ui/index.html,但我把springdoc.swagger-ui.path设成了/swagger-ui.html,这其实是便于前端和网关记住一个固定的入口,重定向到实际页面。
2.2 接口分组:把管理端和客户端文档拆开
项目一大了,所有接口挤在一个文档里很难看,而且测试同学找接口也费劲。springdoc 的GroupedOpenApi可以把 Controller 按包路径拆成多份独立文档,这样 Swagger UI 右上角会多一个下拉框,可以切换“管理端”和“客户端”。
package com.example.demo.config; import org.springdoc.core.models.GroupedOpenApi; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class GroupedOpenApiConfig { @Bean public GroupedOpenApi adminApi() { return GroupedOpenApi.builder() .group("admin") .pathsToMatch("/admin/**") .packagesToScan("com.example.demo.controller.admin") .build(); } @Bean public GroupedOpenApi clientApi() { return GroupedOpenApi.builder() .group("client") .pathsToMatch("/client/**") .packagesToScan("com.example.demo.controller.client") .build(); } }分组之后,每个组的文档地址变成/v3/api-docs/admin、/v3/api-docs/client,Swagger UI 会自动加载这些分组。这个机制本身不复杂,但对前后端协作帮助很大,后端只要把不同分组的文档链接发给对应前端,就不会出现“接口太多找不到”的情况。
2.3 有 Spring Security 时必须做的放行配置
如果你的项目引入了 Spring Security,Swagger 的静态资源和 api-docs 路径默认都会被拦截。3.x 的 SecurityFilterChain 配置方式和 2.x 差别很大,直接看代码:
package com.example.demo.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; @Configuration public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers( "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", "/webjars/**", "/favicon.ico" ).permitAll() .anyRequest().authenticated() ); return http.build(); } }这里最关键的几个路径是/v3/api-docs/**和/swagger-ui/**,漏掉任何一个 Swagger UI 页面都打不开。我见过一种情况是放行了/swagger-ui/index.html,但 CSS、JS 静态资源被拦截,页面打开后只剩一个白屏,检查浏览器控制台全是 403。/webjars/**也别漏,Swagger UI 的资源文件放在这里。
3. Mybatis-plus 整合:插件、分页与通用 CRUD
3.1 starter 依赖与数据源配置
Mybatis-plus 的依赖在上面的 pom 里已经放好了,数据源配置在 yml 里设置。我建议在 JDBC URL 里直接加上rewriteBatchedStatements=true,这个参数对后面讲批量操作优化非常关键,提前写进去能省掉后面改配置的事。
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true username: root password: 123456 mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: banner: false db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0logic-delete-field这段是逻辑删除配置。字段名默认叫deleted,如果你的表里叫is_deleted,就把logic-delete-field改成对应的属性名。用了逻辑删除后,Mybatis-plus 的deleteById会自动转成UPDATE ... SET deleted = 1 WHERE id = ?,而不是真正的 DELETE 语句,这个防误删的效果很好,建议每个表都加这个字段。
3.2 MybatisPlusInterceptor 与分页插件注意事项
很多新手直接把PaginationInnerInterceptor注册成一个 Bean,然后发现分页不生效,其实是注册方式不对。Mybatis-plus 的分页、乐观锁、防全表更新都是通过MybatisPlusInterceptor这个总拦截器串起来的,必须把它注册成 Bean,再把具体的内置拦截器通过addInnerInterceptor加进去。
package com.example.demo.config; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); pagination.setOverflow(false); interceptor.addInnerInterceptor(pagination); return interceptor; } }setMaxLimit(500L)的意义是防止有人传一个pageSize=99999直接把数据库打趴。setOverflow(false)表示超出最大页数时返回空,而不是回到第一页,这个看业务场景,我习惯保持 false,让前端更早发现分页参数传错了。
分页插件的执行逻辑是:当你调用Page作为第一个参数的 selectPage 时,它会自动生成一条SELECT COUNT(*)统计总数,再生成真正的分页 SQL。注意不要对Page对象手动 set 的searchCount乱赋值,默认 true 就是最优的,只有在大数据量且明确不需要 total 的场景才考虑关掉 count 查询。
3.3 通用 CRUD 服务:无状态增删改查的实践
Mybatis-plus 自带的IService<T>和ServiceImpl<M, T>已经很强大,但实际项目中我们通常还要统一返回结构、统一异常处理。我这里做了一层非常薄的封装,把增删改查全做成泛型模板,新模块只需要继承基类,几乎不用写 CRUD 代码,这就对应了热搜里说的“基于 mybatis-plus 实现无状态增删改查”。
先看返回结构和分页结构,这个后面在 Swagger 文档中也会暴露给前端。
package com.example.demo.common; import lombok.Data; @Data public class Result<T> { private int code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }再写一个通用 Controller 基类,每张表对应的 Controller 继承它之后,自动具备 save、delete、update、getById 四个基础能力。
package com.example.demo.common; import com.baomidou.mybatisplus.extension.service.IService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; public abstract class BaseController<S extends IService<T>, T> { @Autowired protected S service; @PostMapping public Result<Boolean> save(@RequestBody T entity) { return Result.ok(service.save(entity)); } @DeleteMapping("/{id}") public Result<Boolean> delete(@PathVariable Long id) { return Result.ok(service.removeById(id)); } @PutMapping public Result<Boolean> update(@RequestBody T entity) { return Result.ok(service.updateById(entity)); } @GetMapping("/{id}") public Result<T> get(@PathVariable Long id) { return Result.ok(service.getById(id)); } }这里的“无状态”体现在哪?就是整个增删改查过程不依赖任何 Session、ThreadLocal 或上下文对象,请求带着参数进来,方法内部根据参数执行完就返回,不残留任何中间状态。状态全部由数据库事务控制,天然适合水平扩展。实际业务接口继承这个基类后,再补充查询方法即可,像这样:
@RestController @RequestMapping("/admin/user") public class UserController extends BaseController<UserService, User> { @GetMapping("/page") public Result<PageResult<User>> page( @RequestParam(defaultValue = "1") long pageNum, @RequestParam(defaultValue = "10") long pageSize, @RequestParam(required = false) String status) { Page<User> page = new Page<>(pageNum, pageSize); service.page(page, new LambdaQueryWrapper<User>() .eq(StringUtils.hasText(status), User::getStatus, status) .orderByDesc(User::getCreateTime)); return Result.ok(PageResult.of(page)); } }4. 接口文档、分页返回与批量写入的协同设计
4.1 文档字段别直接暴露数据库实体
把 Controller 里返回类型写成数据库实体类,确实省事,但 Swagger 文档里会把实体所有字段都展示给前端,包括deleted、createTime、updateTime这类不应该让前端看的字段,甚至某些表里还有内部备注字段,直接暴露出去有风险。
我的建议是:对内管理后台可以直接用实体,但对外 API 一定要做 DTO / VO 转换。DTO 上配合@Schema注解,把字段含义、示例值都写清楚。这不仅是规范问题,更是安全边界。
package com.example.demo.dto; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @Data @Schema(description = "用户信息返回对象") public class UserVO { @Schema(description = "用户ID", example = "1") private Long id; @Schema(description = "用户名", example = "zhangsan") private String username; @Schema(description = "昵称", example = "张三") private String nickname; @Schema(description = "状态,1启用 0禁用", example = "1") private Integer status; }这里有个细节:实体里如果用了 Mybatis-plus 的@TableField(exist = false)标注非表字段,在 Swagger 文档中也会正常显示,因为 Annontation 解析是从对象属性上拿的。你要是觉得看不到字段不放心,可以先去/v3/api-docs看看生成的原生 JSON,字段缺失或过多都能直观发现。
4.2 统一分页返回结构,文档展示更友好
Mybatis-plus 的IPage接口直接返回给前端,字段名是records、total、size、current、pages,结构其实还算清晰。但很多老项目前端已经习惯了自定义格式,比如把列表叫list而不是records。为了统一,我写了PageResult<T>进行二次封装。
package com.example.demo.common; import com.baomidou.mybatisplus.core.metadata.IPage; import lombok.Data; import java.util.List; @Data public class PageResult<T> { private List<T> records; private long total; private long current; private long size; private long pages; public static <T> PageResult<T> of(IPage<T> page) { PageResult<T> result = new PageResult<>(); result.setRecords(page.getRecords()); result.setTotal(page.getTotal()); result.setCurrent(page.getCurrent()); result.setSize(page.getSize()); result.setPages(page.getPages()); return result; } }封装之后,Swagger 文档里展示的分页结构就是固定的,前端拿到文档后可以一次性把类型定义写死。重点提示:如果你在分页查询时传的是orderBy字符串,一定要做白名单校验,否则用户传入orderBy=id;delete from user这类值,拼接进 SQL 会有注入风险。Mybatis-plus 的LambdaQueryWrapper都是参数化拼接,天然安全,所以能用 lambda 写法就不要手动拼 SQL。
4.3 Mybatis-plus 批量操作与真实性能
热搜词里提到了“mybatis-plus 批量”这个话题,实际项目里批量插入、批量更新确实比单条循环快很多,但它的快是有前提条件的。Mybatis-plus 的saveBatch默认按 1000 条一批执行,底层是调用 JDBC 的addBatch和executeBatch。但注意,MySQL 驱动默认情况下并不一定会把这批 SQL 真正的合并执行,要拿到真实批量性能,必须在 JDBC URL 上带上rewriteBatchedStatements=true。
我在 3.1 节的连接串里已经加上了这个参数,这里专门解释原因:没有这个参数时,MySQL 驱动会把每个批次的 SQL 当成单条语句逐条发送,性能提升微乎其微;加上之后,驱动会把多条 INSERT 重写成一条INSERT INTO ... VALUES (...), (...), (...),网络往返次数大幅下降,插入几万条数据从几十秒降到几秒,这是我自己压测过的。
public boolean batchInsertUser(List<User> userList) { return userService.saveBatch(userList, 2000); }第二个参数可以手动指定分批大小,我一般设为 1000 或 2000。分得太小批量优势不明显,分得太大单条 SQL 过长,MySQL 的max_allowed_packet可能报错。项目里如果是几万条以上的导入,还会配合ExecutorType.BATCH使用,那个复杂度高一些,这里先不展开。
批量更新同样可以用updateBatchById,但它内部也是逐条生成 UPDATE 语句,如果更新大量数据且逻辑相同,更推荐先查出主键列表,手写一条UPDATE ... WHERE id IN (...)。工具类适合通用场景,性能临界点需要自己写 SQL。
5. 常见问题与排查实录
5.1 启动报错 documentationPluginsBootstrapper 或 jakarta 冲突
这是 Spring Boot 3.x 升级时最经典的报错:
Failed to start bean 'documentationPluginsBootstrapper'看到这个基本可以确定项目里还挂着 springfox。排查思路是先查依赖树,看看是显式依赖还是被别的包传递引入的:
mvn dependency:tree -Dincludes=io.springfox:springfox-swagger2找到之后全部排除掉,换成 springdoc-openapi。还有一种情况是项目里同时存在javax.servlet-api和jakarta.servlet-api,启动时类加载冲突,日志表现为各种 servlet 相关 NoClassDefFoundError。解决办法是检查所有第三方依赖,把强制指定 javax.servlet 的包排除或升级版本。
5.2 Mapper 报 Invalid bound statement 或找不到 Mapper
Mybatis-plus 项目最常见的运行期错误就是:
Invalid bound statement (not found): com.example.demo.mapper.UserMapper.selectList这个报错有 80% 是mapper-locations配错了。检查一下你的 XML 文件路径和 yml 里的classpath*:/mapper/**/*.xml是否匹配。另一个常见情况是启动类或配置类上没有加@MapperScan("com.example.demo.mapper"),或者扫描路径写错了包名。如果用了@Mapper注解一个个标,也能生效,但项目大了容易漏。
还有一个容易被忽略的点:XML 文件如果是用 Windows 记事本编辑后保存为 UTF-8 with BOM,Mybatis 解析 XML 时第一个字符就是 BOM 头,会报Content is not allowed in prolog,把文件改成 UTF-8 无 BOM 编码即可。
5.3 Swagger 导出 Excel 文件损坏的常见原因
热搜里有个“swagger 导出 excel 损坏”,这个问题我确实在多个项目里遇到过。现象是接口本身能跑通,在浏览器或 Postman 里下载 Excel 完全正常,但从 Swagger UI 的“Try it out”下载下来后文件打不开,或者下载下来的是一段 JSON 字符串。
原因基本就三种:接口返回类型写错了、produces没有声明、以及 Swagger UI 对二进制响应的处理机制。
第一种错误写法是把 Excel 写进byte[]后直接作为String返回,这样 Spring MVC 会按 JSON 序列化,客户端拿到的是数组文本,完全不是文件字节流。
第二种是没有显式声明produces = MediaType.APPLICATION_OCTET_STREAM_VALUE,Spring MVC 有可能内容协商后返回 JSON。
第三种是 Swagger UI 下载二进制时的渲染机制。正确做法是用ResponseEntity<byte[]>,并在响应头里设置Content-Disposition。我验证过一份可以直接用的模板:
@GetMapping(value = "/export/user", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) public ResponseEntity<byte[]> exportUserExcel() throws IOException { byte[] data = buildUserExcelBytes(); // 业务方法,返回 Excel 字节数组 String fileName = URLEncoder.encode("用户列表.xlsx", StandardCharsets.UTF_8) .replace("+", "%20"); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + fileName) .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(data); }如果项目里有的老接口是用void+HttpServletResponse直接往输出流里写文件,Swagger UI 里会显示“Response Body: no content”,这是正常的,但前端同学看到容易以为接口坏了。新代码一律用ResponseEntity<byte[]>,Swagger UI 上会直接出现 DownLoad 按钮,下载行为最稳定。
5.4 部署到服务器后访问不到 swagger-ui 的排查
开发环境 Swagger UI 正常,部署到服务器访问 404,这是另一类高频问题。先确认能不能访问/v3/api-docs这个 JSON 接口,如果能返回一大段 JSON,说明接口层正常,问题在 Swagger UI 静态资源或路由。
常见原因有几个:Nginx 配置没有把/swagger-ui/**转发到后端;后端设置了server.servlet.context-path但没有在 yml 里同步调整 springdoc 的路径;或者部署在网关后面,路由规则把/swagger-ui.html拦截了。
我的排查顺序是:先 curl 后端服务器的/v3/api-docs,通了再看/swagger-ui/index.html。如果是网关转发,注意去掉前缀之后再转发给后端,比如网关把/demo/swagger-ui/**转发到后端/swagger-ui/**,别把/demo也带过去。
5.5 分页查询的边界问题
分页的坑比较隐蔽,我列两个最常见的。第一个是前端页码从 0 开始,而后端Page的 current 从 1 开始,导致第一页数据永远查不到。统一约定后,在后端或前端拦截器里做一次 +1 转换,并把约定写进接口文档。第二个是total查询在 left join 大表场景下特别慢,Mybatis-plus 默认会执行SELECT COUNT(*),如果有多表关联,count 语句也会 join 全部表,这时候可以手动把 count 优化为只查主表,或者单独写一条优化过的 count 语句。
第三个容易被忽视的是page.setSearchCount(false),关闭后 total 会变成 0,前端分页组件可能不展示总页数。这个要按场景决定,不是全局开关。
6. 最后把整个方案串起来的一些经验
项目升级到 Spring Boot 3.4 之后,这套组合我已经在一个内部管理系统和一个小型电商后端上跑过一个多月,结论是稳定。真正让我觉得值回票价的不是 Swagger 页面漂不漂亮,而是 springdoc 对 OpenAPI 3 的原生支持,让接口文档可以直接导入 Apifox、Postman,前后端联调用一份文档就够了。
我个人的习惯是每加一个 Controller,先看一眼/v3/api-docs生成的 JSON,确认字段和示例值都没问题再提交代码。另外 Mybatis-plus 的代码生成器建议也配上,从数据库表直接生成 entity、mapper、service、controller,再继承我上面写的 BaseController,新模块的开发工作量能压缩到很小。这套方案后续要继续扩展的话,最值得做的方向是引入 Mybatis-plus 的乐观锁插件和字段自动填充,这样更新数据和记录 createTime、updateTime 完全自动化,Swagger 文档里也不会再出现这些冗余字段。